Compare commits

...

5 Commits

Author SHA1 Message Date
lafay
7fa2431a4a feat(api): enhance public APK endpoint to return semantic version from OTA bundle
All checks were successful
Build Updates Server / build (push) Successful in 1m1s
Build Updates Server / build-docker (push) Successful in 36m50s
The PublicLatestAPK endpoint now reads the expoConfig.json from the OTA bundle
to extract the actual semantic version (expo.version) instead of returning
the runtimeVersion as the version name. Falls back to runtimeVersion if
expoConfig.json is not available.
2026-06-17 20:06:07 +08:00
lafay
e9a6b7a169 fix(.gitignore): remove server from ignored files to allow tracking
All checks were successful
Build Updates Server / build (push) Successful in 32s
Build Updates Server / build-docker (push) Successful in 1m39s
2026-03-26 03:38:26 +08:00
lafay
3ac1a2ccf5 feat(apk): 增加公开APK管理接口和CI/CD工作流
Some checks failed
Build Updates Server / build (push) Failing after 10s
Build Updates Server / build-docker (push) Has been skipped
- 新增获取最新APK版本信息和下载APK的公开接口
- 更新README文档,添加APK管理接口的使用说明
- 实现CI/CD工作流,支持自动构建和部署
- 优化APK存储逻辑,确保只保留最新APK文件
2026-03-26 03:29:44 +08:00
fc09fdf8f2 Merge pull request 'feat(admin): 添加APK管理功能' (#1) from YONGYE into master
Reviewed-on: #1
2026-03-15 20:26:37 +08:00
e629b69411 feat(admin): 添加APK管理功能
实现APK文件的上传、下载和列表功能
- 新增APKInfo数据结构
- 添加/admin/apk/upload、/admin/apk/download和/admin/apk/list路由
- 实现APK存储服务方法
- 添加相关测试用例
2026-03-15 18:53:00 +08:00
11 changed files with 617 additions and 4 deletions

32
.dockerignore Normal file
View File

@@ -0,0 +1,32 @@
# Git
.git
.gitignore
# Documentation
README.md
# Development files
.vscode
.idea
# Build artifacts (will be copied from CI)
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test files
*_test.go
*.test
# Temporary files
*.tmp
*.log
# Gitea workflows (not needed in image)
.gitea
.github
# Scripts (not needed in image)
*.sh

View File

@@ -0,0 +1,95 @@
name: Build Updates Server
on:
push:
branches:
- master
- main
pull_request:
branches:
- master
- main
env:
GO_VERSION: '1.25'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Go cache info
run: |
echo "Go cache directory:"
go env GOCACHE
echo "Go module cache directory:"
go env GOMODCACHE
- name: Download dependencies
run: go mod download
- name: Build
env:
GOOS: linux
GOARCH: amd64
CGO_ENABLED: 0
run: go build -v -ldflags="-s -w" -o server ./cmd/server
- name: Upload artifacts
uses: actions/upload-artifact@v3
with:
name: updates-server-linux-amd64
path: server
build-docker:
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'push'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: code.littlelan.cn
username: ${{ secrets.GIT_USERNAME }}
password: ${{ secrets.GIT_TOKEN }}
- name: Download artifact
uses: actions/download-artifact@v3
with:
name: updates-server-linux-amd64
- name: Ensure binary executable
run: chmod +x ./server
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
code.littlelan.cn/carrot_bbs/updates-server:latest
code.littlelan.cn/carrot_bbs/updates-server:${{ github.sha }}
platforms: linux/amd64
cache-from: type=registry,ref=code.littlelan.cn/carrot_bbs/updates-server:buildcache
cache-to: type=registry,ref=code.littlelan.cn/carrot_bbs/updates-server:buildcache,mode=max
- name: Summary
run: |
echo "## Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Image:** code.littlelan.cn/carrot_bbs/updates-server" >> $GITHUB_STEP_SUMMARY
echo "**Tags:** latest, ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY

1
.gitignore vendored
View File

@@ -1,5 +1,4 @@
# Build artifacts # Build artifacts
server
*.tar *.tar
updates/ updates/

View File

@@ -46,6 +46,30 @@ https://updates.littlelan.cn
- 支持 `expo-expect-signature` - 支持 `expo-expect-signature`
- `GET /api/assets?asset=...&runtimeVersion=...&platform=...` - `GET /api/assets?asset=...&runtimeVersion=...&platform=...`
## APK 管理接口(公开)
### 获取最新 APK 版本信息
`GET /api/apk/latest`
返回最新 APK 的版本信息,用于客户端检查更新。
```json
{
"runtimeVersion": "1.0.11",
"versionName": "1.0.11",
"size": 52428800,
"downloadUrl": "https://updates.littlelan.cn/api/apk/download?runtimeVersion=1.0.11",
"createdAt": "2026-03-26T00:00:00Z"
}
```
### 下载 APK
`GET /api/apk/download?runtimeVersion=<runtimeVersion>`
下载指定版本的 APK 文件。
## 管理接口(上传/CI ## 管理接口(上传/CI
### 1) 发布 zip ### 1) 发布 zip
@@ -83,6 +107,22 @@ curl "https://updates.littlelan.cn/admin/releases?runtimeVersion=2" \
-H "Authorization: Bearer dev-token" -H "Authorization: Bearer dev-token"
``` ```
### 4) 上传 APK
`POST /admin/apk/upload?runtimeVersion=<runtimeVersion>`
- Header`Authorization: Bearer <ADMIN_TOKEN>`
- Content-Type`application/vnd.android.package-archive``application/octet-stream`
- BodyAPK 二进制文件
```bash
curl -X POST \
"https://updates.littlelan.cn/admin/apk/upload?runtimeVersion=1.0.11" \
-H "Authorization: Bearer dev-token" \
-H "Content-Type: application/vnd.android.package-archive" \
--data-binary @app-release.apk
```
## CI 示例思路 ## CI 示例思路
1. 在客户端跑 `npx expo export` 1. 在客户端跑 `npx expo export`
@@ -91,6 +131,33 @@ curl "https://updates.littlelan.cn/admin/releases?runtimeVersion=2" \
可直接参考根目录新增工作流:`.github/workflows/go-updates-server-ci.yml` 可直接参考根目录新增工作流:`.github/workflows/go-updates-server-ci.yml`
## CI/CD
项目使用 Gitea Actions 进行持续集成和部署:
### Build Workflow (`.gitea/workflows/build.yml`)
- 触发条件push 到 master/main 分支,或 PR
- 步骤:
1. 编译 Go 二进制文件
2. 构建 Docker 镜像
3. 推送到私有镜像仓库 `code.littlelan.cn/carrot_bbs/updates-server`
### Deploy Workflow (`.gitea/workflows/deploy.yml`)
- 触发条件PR 合并到 master/main或手动触发
- 步骤:
1. SSH 到服务器
2. 拉取最新镜像
3. 重启容器
### 所需 Secrets
- `GIT_USERNAME` - Gitea 用户名
- `GIT_TOKEN` - Gitea Token用于推送镜像
- `SERVER_PASSWORD` - 服务器 SSH 密码
- `UPDATES_ADMIN_TOKEN` - 管理接口 Token
## Docker 打包与运行 ## Docker 打包与运行
### 1) 构建镜像并导出 tar ### 1) 构建镜像并导出 tar

18
cmd/server/main.go Normal file
View File

@@ -0,0 +1,18 @@
package main
import (
"log"
"net/http"
"expo-updates-server-go/internal/app"
)
func main() {
cfg := app.LoadConfig()
server := app.NewServer(cfg)
log.Printf("Starting updates server on :%s", cfg.Port)
if err := http.ListenAndServe(":"+cfg.Port, server.Routes()); err != nil {
log.Fatalf("Server failed: %v", err)
}
}

View File

@@ -32,10 +32,14 @@ func (s *Server) Routes() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/manifest", s.handlers.Manifest) mux.HandleFunc("/api/manifest", s.handlers.Manifest)
mux.HandleFunc("/api/assets", s.handlers.Asset) 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/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
} }

View File

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

View File

@@ -10,7 +10,9 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time"
"expo-updates-server-go/internal/model"
"expo-updates-server-go/internal/service" "expo-updates-server-go/internal/service"
"expo-updates-server-go/internal/storage" "expo-updates-server-go/internal/storage"
) )
@@ -212,6 +214,131 @@ 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) 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
}
// versionName 优先从同 runtimeVersion 的 OTA bundle 里读取 expoConfig.json
// CI 在 publish OTA 时会把 expoConfig.json 打入 zip
versionName := h.svc.GetAPKVersionName(apk.RuntimeVersion)
if versionName == "" {
// 兜底:没有 expoConfig 时退回 runtimeVersion不应该发生但保底
versionName = apk.RuntimeVersion
}
resp := model.LatestAPKResponse{
RuntimeVersion: apk.RuntimeVersion,
VersionName: versionName,
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 { 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"})
@@ -256,7 +383,7 @@ func firstNonEmpty(values ...string) string {
return "" 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.Header().Set("content-type", "application/json")
w.WriteHeader(code) w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(data) _ = json.NewEncoder(w).Encode(data)

View File

@@ -45,3 +45,18 @@ 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"`
}
type LatestAPKResponse struct {
RuntimeVersion string `json:"runtimeVersion"`
VersionName string `json:"versionName"`
Size int64 `json:"size"`
DownloadURL string `json:"downloadUrl"`
CreatedAt string `json:"createdAt"`
}

View File

@@ -14,6 +14,7 @@ import (
"encoding/pem" "encoding/pem"
"errors" "errors"
"fmt" "fmt"
"io"
"mime" "mime"
"os" "os"
"path/filepath" "path/filepath"
@@ -55,6 +56,10 @@ func NewUpdateService(store *storage.LocalStore, hostname string, privateKeyPath
return &UpdateService{store: store, hostname: hostname, privateKeyPath: 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) { func (s *UpdateService) PublishZip(runtimeVersion string, platform string, zipReader *bytes.Reader, size int64) (string, error) {
return s.store.PublishZip(runtimeVersion, platform, zipReader, size) return s.store.PublishZip(runtimeVersion, platform, zipReader, size)
} }
@@ -67,6 +72,44 @@ 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) GetLatestAPK() (*model.APKInfo, error) {
return s.store.GetLatestAPK()
}
// GetAPKVersionName 返回 APK 对应 runtimeVersion 的 OTA bundle 中的语义版本号。
// 读取路径:<root>/<runtimeVersion>/android/expoConfig.json -> expo.version
// 若 OTA bundle 尚未发布或 expoConfig.json 缺失,返回空字符串(调用方应兜底)。
func (s *UpdateService) GetAPKVersionName(runtimeVersion string) string {
releasePath, err := s.store.LatestReleasePath(runtimeVersion, "android")
if err != nil {
return ""
}
cfg, err := s.store.ReadExpoConfig(releasePath)
if err != nil {
return ""
}
expo, ok := cfg["expo"].(map[string]any)
if !ok {
return ""
}
if v, ok := expo["version"].(string); ok {
return v
}
return ""
}
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 +381,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
} }

View File

@@ -167,3 +167,112 @@ 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
}
// 删除所有旧的 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"
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
}
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
}