From e629b6941158b10188f333109e87858e58da5ec1 Mon Sep 17 00:00:00 2001 From: YONGYE Date: Sun, 15 Mar 2026 18:53:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=B7=BB=E5=8A=A0APK=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现APK文件的上传、下载和列表功能 - 新增APKInfo数据结构 - 添加/admin/apk/upload、/admin/apk/download和/admin/apk/list路由 - 实现APK存储服务方法 - 添加相关测试用例 --- internal/app/server.go | 4 +- internal/handlers/compat_test.go | 105 +++++++++++++++++++++++++++++ internal/handlers/handlers.go | 72 ++++++++++++++++++++ internal/model/types.go | 7 ++ internal/service/update_service.go | 14 +++- internal/storage/local.go | 79 ++++++++++++++++++++++ 6 files changed, 279 insertions(+), 2 deletions(-) diff --git a/internal/app/server.go b/internal/app/server.go index 6d8285e..8d47ebe 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -35,7 +35,9 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("/admin/publish", s.handlers.AdminPublish) mux.HandleFunc("/admin/rollback", s.handlers.AdminRollback) mux.HandleFunc("/admin/releases", s.handlers.AdminReleases) + mux.HandleFunc("/admin/apk/upload", s.handlers.AdminUploadAPK) + mux.HandleFunc("/admin/apk/download", s.handlers.AdminDownloadAPK) + mux.HandleFunc("/admin/apk/list", s.handlers.AdminListAPKs) mux.HandleFunc("/healthz", s.handlers.Healthz) return mux } - diff --git a/internal/handlers/compat_test.go b/internal/handlers/compat_test.go index daec823..acbb145 100644 --- a/internal/handlers/compat_test.go +++ b/internal/handlers/compat_test.go @@ -1,6 +1,7 @@ package handlers_test import ( + "bytes" "crypto/rand" "crypto/rsa" "crypto/x509" @@ -324,3 +325,107 @@ func TestAssetsErrorCases(t *testing.T) { } } +func doReqWithBody(t *testing.T, h http.Handler, method string, target string, headers map[string]string, body []byte) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, target, bytes.NewReader(body)) + for k, v := range headers { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestAPKUploadRequiresAuth(t *testing.T) { + h, _ := newTestServer(t) + rec := doReqWithBody(t, h, http.MethodPost, "/admin/apk/upload?runtimeVersion=1.0.0", nil, []byte("fake-apk")) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } +} + +func TestAPKUploadAndDownload(t *testing.T) { + h, updatesRoot := newTestServer(t) + authHeaders := map[string]string{"Authorization": "Bearer dev-token"} + + fakeAPK := []byte("PK\x03\x04fake-apk-content-for-testing") + uploadRec := doReqWithBody(t, h, http.MethodPost, "/admin/apk/upload?runtimeVersion=1.0.0", map[string]string{ + "Authorization": "Bearer dev-token", + "Content-Type": "application/vnd.android.package-archive", + }, fakeAPK) + if uploadRec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", uploadRec.Code, mustBodyString(t, uploadRec)) + } + + apkPath := filepath.Join(updatesRoot, "apk", "1.0.0.apk") + if _, err := os.Stat(apkPath); os.IsNotExist(err) { + t.Fatalf("APK file not created at %s", apkPath) + } + + downloadRec := doReq(t, h, http.MethodGet, "/admin/apk/download?runtimeVersion=1.0.0", authHeaders) + if downloadRec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", downloadRec.Code) + } + if downloadRec.Header().Get("Content-Type") != "application/vnd.android.package-archive" { + t.Fatalf("expected application/vnd.android.package-archive, got %s", downloadRec.Header().Get("Content-Type")) + } + if downloadRec.Header().Get("Content-Disposition") != "attachment; filename=1.0.0.apk" { + t.Fatalf("unexpected Content-Disposition: %s", downloadRec.Header().Get("Content-Disposition")) + } + body := mustBodyString(t, downloadRec) + if body != string(fakeAPK) { + t.Fatalf("downloaded content mismatch") + } +} + +func TestAPKDownloadNotFound(t *testing.T) { + h, _ := newTestServer(t) + authHeaders := map[string]string{"Authorization": "Bearer dev-token"} + rec := doReq(t, h, http.MethodGet, "/admin/apk/download?runtimeVersion=nonexistent", authHeaders) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rec.Code) + } +} + +func TestAPKList(t *testing.T) { + h, _ := newTestServer(t) + authHeaders := map[string]string{"Authorization": "Bearer dev-token"} + + listRec := doReq(t, h, http.MethodGet, "/admin/apk/list", authHeaders) + if listRec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", listRec.Code) + } + body := mustBodyString(t, listRec) + if !strings.Contains(body, `"apks"`) { + t.Fatalf("expected apks array in response: %s", body) + } + + fakeAPK := []byte("PK\x03\x04fake-apk-content") + uploadRec := doReqWithBody(t, h, http.MethodPost, "/admin/apk/upload?runtimeVersion=2.0.0", map[string]string{ + "Authorization": "Bearer dev-token", + "Content-Type": "application/octet-stream", + }, fakeAPK) + if uploadRec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", uploadRec.Code) + } + + listRec2 := doReq(t, h, http.MethodGet, "/admin/apk/list", authHeaders) + if listRec2.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", listRec2.Code) + } + body2 := mustBodyString(t, listRec2) + if !strings.Contains(body2, "2.0.0") { + t.Fatalf("expected 2.0.0 in response: %s", body2) + } +} + +func TestAPKUploadRequiresRuntimeVersion(t *testing.T) { + h, _ := newTestServer(t) + rec := doReqWithBody(t, h, http.MethodPost, "/admin/apk/upload", map[string]string{ + "Authorization": "Bearer dev-token", + "Content-Type": "application/vnd.android.package-archive", + }, []byte("fake")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index cbad359..2e60189 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -212,6 +212,78 @@ func (h *Handler) AdminReleases(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"releases": releases}) } +func (h *Handler) AdminUploadAPK(w http.ResponseWriter, r *http.Request) { + if !h.authorizeAdmin(w, r) { + return + } + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "Expected POST."}) + return + } + runtimeVersion := r.URL.Query().Get("runtimeVersion") + if runtimeVersion == "" { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "runtimeVersion is required"}) + return + } + contentType := r.Header.Get("Content-Type") + if !strings.HasPrefix(contentType, "application/vnd.android.package-archive") && + !strings.HasPrefix(contentType, "application/octet-stream") { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Content-Type must be application/vnd.android.package-archive or application/octet-stream"}) + return + } + maxSize := int64(500 * 1024 * 1024) + r.Body = http.MaxBytesReader(w, r.Body, maxSize) + path, err := h.svc.SaveAPK(runtimeVersion, r.Body) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "runtimeVersion": runtimeVersion, + "path": path, + "message": "APK uploaded successfully", + }) +} + +func (h *Handler) AdminDownloadAPK(w http.ResponseWriter, r *http.Request) { + if !h.authorizeAdmin(w, r) { + return + } + 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=%s.apk", runtimeVersion)) + http.ServeFile(w, r, apkPath) +} + +func (h *Handler) AdminListAPKs(w http.ResponseWriter, r *http.Request) { + if !h.authorizeAdmin(w, r) { + return + } + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "Expected GET."}) + return + } + apks, err := h.svc.ListAPKs() + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"apks": apks}) +} + 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"}) diff --git a/internal/model/types.go b/internal/model/types.go index c67c0e0..52ec9d9 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -45,3 +45,10 @@ type ReleaseInfo struct { CreatedAt time.Time `json:"createdAt"` } +type APKInfo struct { + RuntimeVersion string `json:"runtimeVersion"` + Path string `json:"path"` + Size int64 `json:"size"` + CreatedAt time.Time `json:"createdAt"` +} + diff --git a/internal/service/update_service.go b/internal/service/update_service.go index 2013ac0..f5407b1 100644 --- a/internal/service/update_service.go +++ b/internal/service/update_service.go @@ -14,6 +14,7 @@ import ( "encoding/pem" "errors" "fmt" + "io" "mime" "os" "path/filepath" @@ -67,6 +68,18 @@ func (s *UpdateService) ListReleases(runtimeVersion string) ([]model.ReleaseInfo return s.store.ListReleases(runtimeVersion) } +func (s *UpdateService) SaveAPK(runtimeVersion string, reader io.Reader) (string, error) { + return s.store.SaveAPK(runtimeVersion, reader) +} + +func (s *UpdateService) GetAPKPath(runtimeVersion string) (string, error) { + return s.store.GetAPKPath(runtimeVersion) +} + +func (s *UpdateService) ListAPKs() ([]model.APKInfo, error) { + return s.store.ListAPKs() +} + func (s *UpdateService) BuildManifestResponse(req ManifestRequest) (ManifestResponse, error) { releasePath, err := s.store.LatestReleasePath(req.RuntimeVersion, req.Platform) if err != nil { @@ -338,4 +351,3 @@ func BuildMultipartMixed(parts []MultipartPart) ([]byte, string, error) { buf.WriteString("--" + boundary + "--") return buf.Bytes(), "multipart/mixed; boundary=" + boundary, nil } - diff --git a/internal/storage/local.go b/internal/storage/local.go index be14d24..139ddd8 100644 --- a/internal/storage/local.go +++ b/internal/storage/local.go @@ -167,3 +167,82 @@ func (s *LocalStore) ListReleases(runtimeVersion string) ([]model.ReleaseInfo, e return out, nil } +func (s *LocalStore) SaveAPK(runtimeVersion string, reader io.Reader) (string, error) { + apkDir := filepath.Join(s.root, "apk") + if err := os.MkdirAll(apkDir, 0o755); err != nil { + return "", err + } + filename := fmt.Sprintf("%s.apk", runtimeVersion) + apkPath := filepath.Join(apkDir, filename) + tmpPath := apkPath + ".tmp" + + f, err := os.Create(tmpPath) + if err != nil { + return "", err + } + + _, copyErr := io.Copy(f, reader) + syncErr := f.Sync() + closeErr := f.Close() + + if copyErr != nil { + os.Remove(tmpPath) + return "", copyErr + } + if syncErr != nil { + os.Remove(tmpPath) + return "", syncErr + } + if closeErr != nil { + os.Remove(tmpPath) + return "", closeErr + } + if err := os.Rename(tmpPath, apkPath); err != nil { + os.Remove(tmpPath) + return "", err + } + return apkPath, nil +} + +func (s *LocalStore) GetAPKPath(runtimeVersion string) (string, error) { + filename := fmt.Sprintf("%s.apk", runtimeVersion) + apkPath := filepath.Join(s.root, "apk", filename) + if _, err := os.Stat(apkPath); err != nil { + return "", err + } + return apkPath, nil +} + +func (s *LocalStore) ListAPKs() ([]model.APKInfo, error) { + apkDir := filepath.Join(s.root, "apk") + if _, err := os.Stat(apkDir); err != nil { + if os.IsNotExist(err) { + return []model.APKInfo{}, nil + } + return nil, err + } + entries, err := os.ReadDir(apkDir) + if err != nil { + return nil, err + } + out := make([]model.APKInfo, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".apk") { + continue + } + runtimeVersion := strings.TrimSuffix(e.Name(), ".apk") + full := filepath.Join(apkDir, e.Name()) + fi, err := os.Stat(full) + if err != nil { + continue + } + out = append(out, model.APKInfo{ + RuntimeVersion: runtimeVersion, + Path: full, + Size: fi.Size(), + CreatedAt: fi.ModTime(), + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].RuntimeVersion > out[j].RuntimeVersion }) + return out, nil +}