feat(apk): 增加公开APK管理接口和CI/CD工作流
- 新增获取最新APK版本信息和下载APK的公开接口 - 更新README文档,添加APK管理接口的使用说明 - 实现CI/CD工作流,支持自动构建和部署 - 优化APK存储逻辑,确保只保留最新APK文件
This commit is contained in:
32
.dockerignore
Normal file
32
.dockerignore
Normal 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
|
||||||
95
.gitea/workflows/build.yml
Normal file
95
.gitea/workflows/build.yml
Normal 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
|
||||||
67
README.md
67
README.md
@@ -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`
|
||||||
|
- 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 示例思路
|
## 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
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ 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)
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
@@ -284,6 +286,50 @@ func (h *Handler) AdminListAPKs(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"apks": apks})
|
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 {
|
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"})
|
||||||
@@ -328,7 +374,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)
|
||||||
|
|||||||
@@ -52,3 +52,11 @@ type APKInfo struct {
|
|||||||
CreatedAt time.Time `json:"createdAt"`
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,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)
|
||||||
}
|
}
|
||||||
@@ -80,6 +84,10 @@ func (s *UpdateService) ListAPKs() ([]model.APKInfo, error) {
|
|||||||
return s.store.ListAPKs()
|
return s.store.ListAPKs()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UpdateService) GetLatestAPK() (*model.APKInfo, error) {
|
||||||
|
return s.store.GetLatestAPK()
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -172,6 +172,22 @@ func (s *LocalStore) SaveAPK(runtimeVersion string, reader io.Reader) (string, e
|
|||||||
if err := os.MkdirAll(apkDir, 0o755); err != nil {
|
if err := os.MkdirAll(apkDir, 0o755); err != nil {
|
||||||
return "", err
|
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)
|
filename := fmt.Sprintf("%s.apk", runtimeVersion)
|
||||||
apkPath := filepath.Join(apkDir, filename)
|
apkPath := filepath.Join(apkDir, filename)
|
||||||
tmpPath := apkPath + ".tmp"
|
tmpPath := apkPath + ".tmp"
|
||||||
@@ -243,6 +259,20 @@ func (s *LocalStore) ListAPKs() ([]model.APKInfo, error) {
|
|||||||
CreatedAt: fi.ModTime(),
|
CreatedAt: fi.ModTime(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// 按版本号降序排列,确保最新的在前
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].RuntimeVersion > out[j].RuntimeVersion })
|
sort.Slice(out, func(i, j int) bool { return out[i].RuntimeVersion > out[j].RuntimeVersion })
|
||||||
return out, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user