From 3ac1a2ccf59055fb110ce88b3130b6bab9848d09 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Thu, 26 Mar 2026 03:29:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(apk):=20=E5=A2=9E=E5=8A=A0=E5=85=AC?= =?UTF-8?q?=E5=BC=80APK=E7=AE=A1=E7=90=86=E6=8E=A5=E5=8F=A3=E5=92=8CCI/CD?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增获取最新APK版本信息和下载APK的公开接口 - 更新README文档,添加APK管理接口的使用说明 - 实现CI/CD工作流,支持自动构建和部署 - 优化APK存储逻辑,确保只保留最新APK文件 --- .dockerignore | 32 ++++++++++ .gitea/workflows/build.yml | 95 ++++++++++++++++++++++++++++++ README.md | 67 +++++++++++++++++++++ internal/app/server.go | 2 + internal/handlers/handlers.go | 48 ++++++++++++++- internal/model/types.go | 8 +++ internal/service/update_service.go | 8 +++ internal/storage/local.go | 30 ++++++++++ 8 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/build.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..92af228 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..8f0dd4e --- /dev/null +++ b/.gitea/workflows/build.yml @@ -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 diff --git a/README.md b/README.md index 25f721b..1a76aa1 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,30 @@ https://updates.littlelan.cn - 支持 `expo-expect-signature` - `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=` + +下载指定版本的 APK 文件。 + ## 管理接口(上传/CI) ### 1) 发布 zip @@ -83,6 +107,22 @@ curl "https://updates.littlelan.cn/admin/releases?runtimeVersion=2" \ -H "Authorization: Bearer dev-token" ``` +### 4) 上传 APK + +`POST /admin/apk/upload?runtimeVersion=` + +- Header:`Authorization: Bearer ` +- Content-Type:`application/vnd.android.package-archive` 或 `application/octet-stream` +- Body:APK 二进制文件 + +```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 示例思路 1. 在客户端跑 `npx expo export` @@ -91,6 +131,33 @@ curl "https://updates.littlelan.cn/admin/releases?runtimeVersion=2" \ 可直接参考根目录新增工作流:`.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 打包与运行 ### 1) 构建镜像并导出 tar diff --git a/internal/app/server.go b/internal/app/server.go index 8d47ebe..f9caf3a 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -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) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 2e60189..081424a 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -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) diff --git a/internal/model/types.go b/internal/model/types.go index 52ec9d9..2f87968 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -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"` +} + diff --git a/internal/service/update_service.go b/internal/service/update_service.go index f5407b1..5ec7d3b 100644 --- a/internal/service/update_service.go +++ b/internal/service/update_service.go @@ -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 { diff --git a/internal/storage/local.go b/internal/storage/local.go index 139ddd8..300c7bc 100644 --- a/internal/storage/local.go +++ b/internal/storage/local.go @@ -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 +}