feat(admin): 添加APK管理功能 #1

Merged
lan merged 1 commits from YONGYE into master 2026-03-15 20:26:37 +08:00
6 changed files with 279 additions and 2 deletions

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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"})

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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
}