feat(admin): 添加APK管理功能
实现APK文件的上传、下载和列表功能 - 新增APKInfo数据结构 - 添加/admin/apk/upload、/admin/apk/download和/admin/apk/list路由 - 实现APK存储服务方法 - 添加相关测试用例
This commit is contained in:
@@ -35,7 +35,9 @@ func (s *Server) Routes() http.Handler {
|
|||||||
mux.HandleFunc("/admin/publish", s.handlers.AdminPublish)
|
mux.HandleFunc("/admin/publish", s.handlers.AdminPublish)
|
||||||
mux.HandleFunc("/admin/rollback", s.handlers.AdminRollback)
|
mux.HandleFunc("/admin/rollback", s.handlers.AdminRollback)
|
||||||
mux.HandleFunc("/admin/releases", s.handlers.AdminReleases)
|
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)
|
mux.HandleFunc("/healthz", s.handlers.Healthz)
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers_test
|
package handlers_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"crypto/x509"
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -212,6 +212,78 @@ func (h *Handler) AdminReleases(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"releases": releases})
|
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 {
|
func (h *Handler) authorizeAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||||
if h.adminToken == "" {
|
if h.adminToken == "" {
|
||||||
writeJSON(w, http.StatusForbidden, map[string]any{"error": "ADMIN_TOKEN is not configured"})
|
writeJSON(w, http.StatusForbidden, map[string]any{"error": "ADMIN_TOKEN is not configured"})
|
||||||
|
|||||||
@@ -45,3 +45,10 @@ type ReleaseInfo struct {
|
|||||||
CreatedAt time.Time `json:"createdAt"`
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -67,6 +68,18 @@ func (s *UpdateService) ListReleases(runtimeVersion string) ([]model.ReleaseInfo
|
|||||||
return s.store.ListReleases(runtimeVersion)
|
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) {
|
func (s *UpdateService) BuildManifestResponse(req ManifestRequest) (ManifestResponse, error) {
|
||||||
releasePath, err := s.store.LatestReleasePath(req.RuntimeVersion, req.Platform)
|
releasePath, err := s.store.LatestReleasePath(req.RuntimeVersion, req.Platform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -338,4 +351,3 @@ func BuildMultipartMixed(parts []MultipartPart) ([]byte, string, error) {
|
|||||||
buf.WriteString("--" + boundary + "--")
|
buf.WriteString("--" + boundary + "--")
|
||||||
return buf.Bytes(), "multipart/mixed; boundary=" + boundary, nil
|
return buf.Bytes(), "multipart/mixed; boundary=" + boundary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -167,3 +167,82 @@ func (s *LocalStore) ListReleases(runtimeVersion string) ([]model.ReleaseInfo, e
|
|||||||
return out, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user