feat(apk): 增加公开APK管理接口和CI/CD工作流
- 新增获取最新APK版本信息和下载APK的公开接口 - 更新README文档,添加APK管理接口的使用说明 - 实现CI/CD工作流,支持自动构建和部署 - 优化APK存储逻辑,确保只保留最新APK文件
This commit is contained in:
@@ -32,6 +32,8 @@ func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/manifest", s.handlers.Manifest)
|
||||
mux.HandleFunc("/api/assets", s.handlers.Asset)
|
||||
mux.HandleFunc("/api/apk/latest", s.handlers.PublicLatestAPK)
|
||||
mux.HandleFunc("/api/apk/download", s.handlers.PublicDownloadAPK)
|
||||
mux.HandleFunc("/admin/publish", s.handlers.AdminPublish)
|
||||
mux.HandleFunc("/admin/rollback", s.handlers.AdminRollback)
|
||||
mux.HandleFunc("/admin/releases", s.handlers.AdminReleases)
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"expo-updates-server-go/internal/model"
|
||||
"expo-updates-server-go/internal/service"
|
||||
"expo-updates-server-go/internal/storage"
|
||||
)
|
||||
@@ -284,6 +286,50 @@ func (h *Handler) AdminListAPKs(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"apks": apks})
|
||||
}
|
||||
|
||||
func (h *Handler) PublicLatestAPK(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "Expected GET."})
|
||||
return
|
||||
}
|
||||
apk, err := h.svc.GetLatestAPK()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if apk == nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"error": "No APK available"})
|
||||
return
|
||||
}
|
||||
resp := model.LatestAPKResponse{
|
||||
RuntimeVersion: apk.RuntimeVersion,
|
||||
VersionName: apk.RuntimeVersion,
|
||||
Size: apk.Size,
|
||||
DownloadURL: fmt.Sprintf("%s/api/apk/download?runtimeVersion=%s", h.svc.Hostname(), apk.RuntimeVersion),
|
||||
CreatedAt: apk.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) PublicDownloadAPK(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "Expected GET."})
|
||||
return
|
||||
}
|
||||
runtimeVersion := r.URL.Query().Get("runtimeVersion")
|
||||
if runtimeVersion == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "runtimeVersion is required"})
|
||||
return
|
||||
}
|
||||
apkPath, err := h.svc.GetAPKPath(runtimeVersion)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"error": fmt.Sprintf("APK for runtimeVersion %s not found", runtimeVersion)})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=carrot-bbs-%s.apk", runtimeVersion))
|
||||
http.ServeFile(w, r, apkPath)
|
||||
}
|
||||
|
||||
func (h *Handler) authorizeAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
if h.adminToken == "" {
|
||||
writeJSON(w, http.StatusForbidden, map[string]any{"error": "ADMIN_TOKEN is not configured"})
|
||||
@@ -328,7 +374,7 @@ func firstNonEmpty(values ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, data map[string]any) {
|
||||
func writeJSON(w http.ResponseWriter, code int, data any) {
|
||||
w.Header().Set("content-type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(data)
|
||||
|
||||
@@ -52,3 +52,11 @@ type APKInfo struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type LatestAPKResponse struct {
|
||||
RuntimeVersion string `json:"runtimeVersion"`
|
||||
VersionName string `json:"versionName"`
|
||||
Size int64 `json:"size"`
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,10 @@ func NewUpdateService(store *storage.LocalStore, hostname string, privateKeyPath
|
||||
return &UpdateService{store: store, hostname: hostname, privateKeyPath: privateKeyPath}
|
||||
}
|
||||
|
||||
func (s *UpdateService) Hostname() string {
|
||||
return s.hostname
|
||||
}
|
||||
|
||||
func (s *UpdateService) PublishZip(runtimeVersion string, platform string, zipReader *bytes.Reader, size int64) (string, error) {
|
||||
return s.store.PublishZip(runtimeVersion, platform, zipReader, size)
|
||||
}
|
||||
@@ -80,6 +84,10 @@ func (s *UpdateService) ListAPKs() ([]model.APKInfo, error) {
|
||||
return s.store.ListAPKs()
|
||||
}
|
||||
|
||||
func (s *UpdateService) GetLatestAPK() (*model.APKInfo, error) {
|
||||
return s.store.GetLatestAPK()
|
||||
}
|
||||
|
||||
func (s *UpdateService) BuildManifestResponse(req ManifestRequest) (ManifestResponse, error) {
|
||||
releasePath, err := s.store.LatestReleasePath(req.RuntimeVersion, req.Platform)
|
||||
if err != nil {
|
||||
|
||||
@@ -172,6 +172,22 @@ func (s *LocalStore) SaveAPK(runtimeVersion string, reader io.Reader) (string, e
|
||||
if err := os.MkdirAll(apkDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 删除所有旧的 APK 文件,只保留最新的一个
|
||||
entries, err := os.ReadDir(apkDir)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".apk") {
|
||||
oldPath := filepath.Join(apkDir, e.Name())
|
||||
if err := os.Remove(oldPath); err != nil {
|
||||
// 记录错误但不中断,继续保存新文件
|
||||
fmt.Printf("Warning: failed to remove old APK %s: %v\n", oldPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s.apk", runtimeVersion)
|
||||
apkPath := filepath.Join(apkDir, filename)
|
||||
tmpPath := apkPath + ".tmp"
|
||||
@@ -243,6 +259,20 @@ func (s *LocalStore) ListAPKs() ([]model.APKInfo, error) {
|
||||
CreatedAt: fi.ModTime(),
|
||||
})
|
||||
}
|
||||
// 按版本号降序排列,确保最新的在前
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].RuntimeVersion > out[j].RuntimeVersion })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *LocalStore) GetLatestAPK() (*model.APKInfo, error) {
|
||||
apks, err := s.ListAPKs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(apks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// 由于 SaveAPK 会删除旧文件,理论上只会有一个 APK
|
||||
// 但为了安全起见,返回版本号最大的那个
|
||||
return &apks[0], nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user