topfans/docs/superpowers/plans/2026-07-09-app-download-page-implementation.md

1728 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# App 下载分享页 — 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 实现 App 下载分享页的完整自动同步链路uni-admin 发布版本 → uniCloud 云函数 push → Go Backend upsert → HTML 分享页读取展示
**Architecture:** Go Backend 三层架构controller → service → repositorygateway 直连 PG无 Dubbo RPCuniCloud 云函数 HTTP push 触发同步HTML 静态页 fetch API 渲染下载按钮
**Tech Stack:** Go 1.25 + Gin + GORM + PostgreSQL · uniCloud 云函数 (Node.js) · 原生 HTML/CSS/JS · uni-admin (Vue 2独立仓库)
**Spec:** `docs/specs/2026-07-08-app-download-page-design.md`
---
## 文件清单
### 新增文件(本仓库)
| # | 文件 | 职责 |
|---|------|------|
| 1 | `backend/migrations/2026_07_08_001_app_download_configs.sql` | 建表 + 序列初始化 |
| 2 | `backend/pkg/models/app_download_config.go` | GORM model + 包类型常量 |
| 3 | `backend/gateway/repository/app_download_repository.go` | 数据层FindByType + UpsertAll |
| 4 | `backend/gateway/repository/app_download_repository_test.go` | Repository 单元测试 |
| 5 | `backend/gateway/service/app_download_service.go` | 业务层GetAllNativeApp + SyncVersion含请求 DTO |
| 6 | `backend/gateway/service/app_download_service_test.go` | Service 单元测试 |
| 7 | `backend/gateway/controller/app_download_controller.go` | HandlerGetDownloadUrls + SyncVersion含 Swagger 注解) |
| 8 | `backend/gateway/controller/app_download_controller_test.go` | Controller 集成测试 |
| 9 | `frontend/static/html/download.html` | HTML 分享下载页 |
### 修改文件(本仓库)
| # | 文件 | 改动 |
|---|------|------|
| 10 | `backend/gateway/router/router.go` | 三层装配 + 注册 2 条路由 |
### 独立仓库uni-admin不在本仓库
| # | 文件 | 改动 |
|---|------|------|
| 11 | `uniCloud-alipay/cloudfunctions/sync-download-urls/index.js` | 云函数主逻辑 |
| 12 | `uniCloud-alipay/cloudfunctions/sync-download-urls/package.json` | 云函数配置 |
| 13 | `uni_modules/uni-upgrade-center/pages/version/add.vue` | submitForm 后触发同步 |
---
## 阶段 AGo Backend 基础设施Task 1-4
### Task 1: 数据库 Migration
**Files:**
- Create: `backend/migrations/2026_07_08_001_app_download_configs.sql`
- [ ] **Step 1: 编写 migration SQL**
```sql
-- App 下载页配置表
-- 存储各平台最新下载地址,由 uniCloud 云函数自动同步
-- type 区分 native_app整包安装和 wgt热更新资源包
CREATE TABLE IF NOT EXISTS public.app_download_configs (
id BIGSERIAL PRIMARY KEY,
platform VARCHAR(20) NOT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'native_app',
download_url TEXT NOT NULL,
version VARCHAR(50),
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT,
updated_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT,
CONSTRAINT uq_app_download_configs_platform_type UNIQUE (platform, type)
);
COMMENT ON TABLE public.app_download_configs IS 'App 下载页配置,存储各平台最新下载地址';
COMMENT ON COLUMN public.app_download_configs.platform IS '平台标识android / ios';
COMMENT ON COLUMN public.app_download_configs.type IS '包类型native_app整包安装/ wgt热更新资源包';
COMMENT ON COLUMN public.app_download_configs.download_url IS '下载地址或 App Store 链接';
COMMENT ON COLUMN public.app_download_configs.version IS '版本号,如 1.0.5';
-- 预留序列起始值(按项目规范)
ALTER SEQUENCE public.app_download_configs_id_seq RESTART WITH 10000;
```
- [ ] **Step 2: 执行 migration**
```bash
psql -h <host> -U <user> -d topfans -f backend/migrations/2026_07_08_001_app_download_configs.sql
```
预期输出:`CREATE TABLE` + `COMMENT` × 5 + `ALTER SEQUENCE`
- [ ] **Step 3: 验证表结构**
```sql
\d public.app_download_configs
```
预期id (BIGSERIAL), platform (VARCHAR(20)), type (VARCHAR(20)), download_url (TEXT), version (VARCHAR(50)), created_at (BIGINT), updated_at (BIGINT), 以及 UNIQUE 约束 `uq_app_download_configs_platform_type`
- [ ] **Step 4: Commit**
```bash
git add backend/migrations/2026_07_08_001_app_download_configs.sql
git commit -m "feat: add app_download_configs migration
Co-Authored-By: Claude <noreply@anthropic.com>"
```
---
### Task 2: Go Model
**Files:**
- Create: `backend/pkg/models/app_download_config.go`
- [ ] **Step 1: 编写 GORM model**
```go
package models
// AppDownloadConfig App下载页配置表模型
type AppDownloadConfig struct {
ID int64 `gorm:"primaryKey;autoIncrement;column:id" json:"-"`
Platform string `gorm:"type:varchar(20);uniqueIndex:uq_platform_type;not null;column:platform" json:"platform"`
Type string `gorm:"type:varchar(20);uniqueIndex:uq_platform_type;not null;default:native_app;column:type" json:"type"`
DownloadURL string `gorm:"type:text;not null;column:download_url" json:"download_url"`
Version string `gorm:"type:varchar(50);column:version" json:"version"`
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
}
// TableName 指定表名
func (AppDownloadConfig) TableName() string {
return "app_download_configs"
}
// 包类型常量
const (
AppPackageTypeNativeApp = "native_app" // 整包安装
AppPackageTypeWgt = "wgt" // 热更新资源包
)
```
- [ ] **Step 2: 验证 model 编译通过**
```bash
cd backend/gateway && go build ./...
```
预期编译成功model 被其他包引用,单独编译可能无输出,用 `go vet` 校验语法)
```bash
cd backend/gateway && go vet ./...
```
- [ ] **Step 3: Commit**
```bash
git add backend/pkg/models/app_download_config.go
git commit -m "feat: add AppDownloadConfig model and package type constants
Co-Authored-By: Claude <noreply@anthropic.com>"
```
---
### Task 3: Repository 层
**Files:**
- Create: `backend/gateway/repository/app_download_repository.go`
- [ ] **Step 1: 编写 Repository**
```go
package repository
import (
"context"
"gorm.io/gorm"
"github.com/topfans/backend/pkg/models"
)
// AppDownloadRepository 下载配置数据访问层
type AppDownloadRepository struct {
db *gorm.DB
}
// NewAppDownloadRepository 构造函数
func NewAppDownloadRepository(db *gorm.DB) *AppDownloadRepository {
return &AppDownloadRepository{db: db}
}
// FindByType 按包类型查询(用于公开接口,只返回 native_app
func (r *AppDownloadRepository) FindByType(ctx context.Context, pkgType string) ([]models.AppDownloadConfig, error) {
var configs []models.AppDownloadConfig
err := r.db.WithContext(ctx).
Where("type = ?", pkgType).
Find(&configs).Error
return configs, err
}
// UpsertAll 批量 upsertplatform + type 唯一)
func (r *AppDownloadRepository) UpsertAll(ctx context.Context, configs []models.AppDownloadConfig) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, cfg := range configs {
if err := tx.Where("platform = ? AND type = ?", cfg.Platform, cfg.Type).
Assign(map[string]interface{}{
"download_url": cfg.DownloadURL,
"version": cfg.Version,
"updated_at": cfg.UpdatedAt,
}).
FirstOrCreate(&cfg).Error; err != nil {
return err
}
}
return nil
})
}
```
- [ ] **Step 2: 编译验证**
```bash
cd backend/gateway && go build ./repository/
```
预期:编译成功
- [ ] **Step 3: Commit**
```bash
git add backend/gateway/repository/app_download_repository.go
git commit -m "feat: add AppDownloadRepository with FindByType and UpsertAll
Co-Authored-By: Claude <noreply@anthropic.com>"
```
---
### Task 4: Service 层
**Files:**
- Create: `backend/gateway/service/app_download_service.go`
- [ ] **Step 1: 编写 Service**
```go
package service
import (
"context"
"time"
"github.com/topfans/backend/gateway/repository"
"github.com/topfans/backend/pkg/models"
)
// SyncVersionRequest uniCloud 同步请求体
// 注:定义在 service 包(而非 controller 包),避免 controller ↔ service 循环依赖
type SyncVersionRequest struct {
Android *PlatformVersionInfo `json:"android"`
IOS *PlatformVersionInfo `json:"ios"`
}
// PlatformVersionInfo 单个平台的版本信息
type PlatformVersionInfo struct {
URL string `json:"url" binding:"required"`
Version string `json:"version"`
Type string `json:"type" binding:"required,oneof=native_app wgt"`
}
// AppDownloadService App下载页业务逻辑
type AppDownloadService struct {
repo *repository.AppDownloadRepository
}
// NewAppDownloadService 构造函数
func NewAppDownloadService(repo *repository.AppDownloadRepository) *AppDownloadService {
return &AppDownloadService{repo: repo}
}
// GetAllNativeApp 获取所有 native_app 类型的下载配置
func (s *AppDownloadService) GetAllNativeApp(ctx context.Context) ([]models.AppDownloadConfig, error) {
return s.repo.FindByType(ctx, models.AppPackageTypeNativeApp)
}
// SyncVersion 同步版本信息upsert
func (s *AppDownloadService) SyncVersion(ctx context.Context, req *SyncVersionRequest) error {
now := time.Now().UnixMilli()
var configs []models.AppDownloadConfig
if req.Android != nil {
configs = append(configs, models.AppDownloadConfig{
Platform: "android",
Type: req.Android.Type,
DownloadURL: req.Android.URL,
Version: req.Android.Version,
UpdatedAt: now,
})
}
if req.IOS != nil {
configs = append(configs, models.AppDownloadConfig{
Platform: "ios",
Type: req.IOS.Type,
DownloadURL: req.IOS.URL,
Version: req.IOS.Version,
UpdatedAt: now,
})
}
return s.repo.UpsertAll(ctx, configs)
}
```
- [ ] **Step 2: 编译验证**
```bash
cd backend/gateway && go build ./service/
```
预期:编译成功
- [ ] **Step 3: Commit**
```bash
git add backend/gateway/service/app_download_service.go
git commit -m "feat: add AppDownloadService with GetAllNativeApp and SyncVersion
Co-Authored-By: Claude <noreply@anthropic.com>"
```
---
## 阶段 BController + 路由注册Task 5-6
### Task 5: Controller 层
**Files:**
- Create: `backend/gateway/controller/app_download_controller.go`
- [ ] **Step 1: 编写 Controller**
```go
package controller
import (
"github.com/gin-gonic/gin"
"github.com/topfans/backend/gateway/pkg/response"
"github.com/topfans/backend/gateway/service"
)
// DownloadUrlResponse 下载页公开接口响应
type DownloadUrlResponse struct {
URL string `json:"url"`
Version string `json:"version"`
Type string `json:"type"`
}
// AppDownloadController App下载页控制器
// 注意:不走 Dubbo RPCspec §12.5),不依赖 Dubbo client因此构造函数无需返回 error
// (与 NewSegmentController / NewLaserGenerateController 等无 Dubbo 依赖的 controller 一致)
type AppDownloadController struct {
svc *service.AppDownloadService
}
// NewAppDownloadController 构造函数
func NewAppDownloadController(svc *service.AppDownloadService) *AppDownloadController {
return &AppDownloadController{svc: svc}
}
// GetDownloadUrls GET /api/v1/app/download-urls
// @Summary 获取 App 下载地址
// @Description 返回 Android/iOS 最新 native_app 下载地址,供 HTML 分享页调用
// @Tags App下载页
// @Produce json
// @Success 200 {object} response.Response{data=map[string]DownloadUrlResponse}
// @Router /api/v1/app/download-urls [get]
// 只返回 type = native_app 的记录
func (ctrl *AppDownloadController) GetDownloadUrls(c *gin.Context) {
ctx := c.Request.Context()
configs, err := ctrl.svc.GetAllNativeApp(ctx)
if err != nil {
response.InternalError(c, "获取下载地址失败")
return
}
// 组装响应map[platform] -> {url, version, type}
data := make(map[string]interface{})
for _, cfg := range configs {
data[cfg.Platform] = &DownloadUrlResponse{
URL: cfg.DownloadURL,
Version: cfg.Version,
Type: cfg.Type,
}
}
// 确保 android/ios 键始终存在
for _, p := range []string{"android", "ios"} {
if _, ok := data[p]; !ok {
data[p] = nil
}
}
response.Success(c, data)
}
// SyncVersion POST /api/v1/admin/app/versions/sync
// @Summary 同步 App 下载地址
// @Description 接收 uniCloud 云函数推送的最新版本下载地址(内部接口,走 Nginx IP 白名单)
// @Tags Admin
// @Accept json
// @Produce json
// @Param body body service.SyncVersionRequest true "版本信息"
// @Success 200 {object} response.Response
// @Router /api/v1/admin/app/versions/sync [post]
func (ctrl *AppDownloadController) SyncVersion(c *gin.Context) {
var req service.SyncVersionRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "invalid request: "+err.Error())
return
}
ctx := c.Request.Context()
if err := ctrl.svc.SyncVersion(ctx, &req); err != nil {
response.InternalError(c, "同步版本信息失败")
return
}
response.Success(c, nil)
}
```
- [ ] **Step 2: 编译验证**
```bash
cd backend/gateway && go build ./controller/
```
预期:编译成功
- [ ] **Step 3: Commit**
```bash
git add backend/gateway/controller/app_download_controller.go
git commit -m "feat: add AppDownloadController with GetDownloadUrls and SyncVersion
Co-Authored-By: Claude <noreply@anthropic.com>"
```
---
### Task 6: 路由注册与装配
**Files:**
- Modify: `backend/gateway/router/router.go`
- [ ] **Step 1: 在 SetupRouter 中添加三层装配**
定位到 `SetupRouter` 函数中现有 controller 初始化代码块末尾(`modCtrl.SetService(modSvc)` 之后,`v1 := r.Group("/api/v1")` 之前),添加:
```go
// App 下载页 — gateway 直连 PG无 Dubbo RPC参见 spec §12.5
appDownloadRepo := repository.NewAppDownloadRepository(database.GetDB())
appDownloadSvc := service.NewAppDownloadService(appDownloadRepo)
appDownloadCtrl := controller.NewAppDownloadController(appDownloadSvc)
```
- [ ] **Step 2: 添加 import**
`router.go` 的 import 块中添加:
```go
"github.com/topfans/backend/gateway/repository"
"github.com/topfans/backend/pkg/database"
```
> 注:`router.go` 已有 `"github.com/topfans/backend/gateway/controller"` 和 `"github.com/topfans/backend/gateway/service"` 的 import只需补充 `repository` 和 `database` 两个新包。
- [ ] **Step 3: 注册公开路由**
`v1` 路由组中,与其他公开路由并列(如 `v1.POST("/segment", ...)` 附近)添加:
```go
// App 下载页 — HTML 分享页使用(公开,无需认证)
v1.GET("/app/download-urls", appDownloadCtrl.GetDownloadUrls)
```
- [ ] **Step 4: 注册 Admin 内部路由**
在现有 `admin := v1.Group("/admin")` 块内(约 router.go:282-285追加一行
```go
admin := v1.Group("/admin")
{
admin.POST("/notifications", notificationCtrl.AdminCreateNotification) // 已有
admin.POST("/app/versions/sync", appDownloadCtrl.SyncVersion) // ← 新增
}
```
- [ ] **Step 5: 编译验证**
```bash
cd backend/gateway && go build ./...
```
预期:编译成功,无 import 错误
- [ ] **Step 6: 本地启动并测试接口**
启动 gateway
```bash
cd backend/gateway && go run main.go
```
测试公开接口GET
```bash
curl -s http://localhost:8080/api/v1/app/download-urls | jq .
```
预期(数据库为空时):
```json
{
"code": 0,
"message": "ok",
"data": {
"android": null,
"ios": null
}
}
```
测试 Admin 同步接口POST
```bash
curl -s -X POST http://localhost:8080/api/v1/admin/app/versions/sync \
-H "Content-Type: application/json" \
-d '{
"android": {"url": "https://cdn.example.com/app-1.0.5.apk", "version": "1.0.5", "type": "native_app"},
"ios": {"url": "https://apps.apple.com/cn/app/id1234567890", "version": "1.0.5", "type": "native_app"}
}' | jq .
```
预期:
```json
{
"code": 0,
"message": "ok"
}
```
再次 GET 验证数据已写入:
```bash
curl -s http://localhost:8080/api/v1/app/download-urls | jq .
```
预期android 和 ios 均有数据
- [ ] **Step 7: 验证 Swagger 文档生成**
```bash
cd backend/gateway && swag init --parseDependency --parseInternal
```
预期:生成/更新 `docs/docs.go`、`docs/swagger.json`、`docs/swagger.yaml`
- [ ] **Step 8: Commit**
```bash
git add backend/gateway/router/router.go backend/gateway/docs/
git commit -m "feat: register app download routes and wire up controller
Co-Authored-By: Claude <noreply@anthropic.com>"
```
---
## 阶段 C测试 + 接口重构Task 7-9
### Task 7: Repository 单元测试
**Files:**
- Create: `backend/gateway/repository/app_download_repository_test.go`
- [ ] **Step 1: 编写测试**
```go
package repository
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/topfans/backend/pkg/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
err = db.AutoMigrate(&models.AppDownloadConfig{})
assert.NoError(t, err)
return db
}
func TestFindByType_NativeApp(t *testing.T) {
db := setupTestDB(t)
repo := NewAppDownloadRepository(db)
// seed: 一条 native_app + 一条 wgt
db.Create(&models.AppDownloadConfig{
Platform: "android", Type: "native_app", DownloadURL: "https://cdn.example.com/app.apk", Version: "1.0.5",
})
db.Create(&models.AppDownloadConfig{
Platform: "android", Type: "wgt", DownloadURL: "https://cdn.example.com/wgt.wgt", Version: "1.0.5",
})
configs, err := repo.FindByType(context.Background(), models.AppPackageTypeNativeApp)
assert.NoError(t, err)
assert.Len(t, configs, 1)
assert.Equal(t, "native_app", configs[0].Type)
}
func TestFindByType_Wgt(t *testing.T) {
db := setupTestDB(t)
repo := NewAppDownloadRepository(db)
db.Create(&models.AppDownloadConfig{
Platform: "android", Type: "wgt", DownloadURL: "https://cdn.example.com/wgt.wgt", Version: "1.0.5",
})
configs, err := repo.FindByType(context.Background(), models.AppPackageTypeWgt)
assert.NoError(t, err)
assert.Len(t, configs, 1)
assert.Equal(t, "wgt", configs[0].Type)
}
func TestUpsertAll_Insert(t *testing.T) {
db := setupTestDB(t)
repo := NewAppDownloadRepository(db)
err := repo.UpsertAll(context.Background(), []models.AppDownloadConfig{
{Platform: "android", Type: "native_app", DownloadURL: "https://v1.apk", Version: "1.0.0"},
})
assert.NoError(t, err)
var count int64
db.Model(&models.AppDownloadConfig{}).Count(&count)
assert.Equal(t, int64(1), count)
var cfg models.AppDownloadConfig
db.First(&cfg)
assert.Equal(t, "https://v1.apk", cfg.DownloadURL)
}
func TestUpsertAll_Update(t *testing.T) {
db := setupTestDB(t)
repo := NewAppDownloadRepository(db)
// 首次插入
err := repo.UpsertAll(context.Background(), []models.AppDownloadConfig{
{Platform: "android", Type: "native_app", DownloadURL: "https://v1.apk", Version: "1.0.0"},
})
assert.NoError(t, err)
// 更新同一条
err = repo.UpsertAll(context.Background(), []models.AppDownloadConfig{
{Platform: "android", Type: "native_app", DownloadURL: "https://v2.apk", Version: "2.0.0"},
})
assert.NoError(t, err)
// 验证只有 1 条且已更新
var count int64
db.Model(&models.AppDownloadConfig{}).Count(&count)
assert.Equal(t, int64(1), count)
var cfg models.AppDownloadConfig
db.First(&cfg)
assert.Equal(t, "https://v2.apk", cfg.DownloadURL)
assert.Equal(t, "2.0.0", cfg.Version)
}
func TestFindByType_Empty(t *testing.T) {
db := setupTestDB(t)
repo := NewAppDownloadRepository(db)
configs, err := repo.FindByType(context.Background(), models.AppPackageTypeNativeApp)
assert.NoError(t, err)
assert.Len(t, configs, 0)
}
```
- [ ] **Step 2: 运行测试**
```bash
cd backend/gateway && go test ./repository/ -run TestFindByType -v
cd backend/gateway && go test ./repository/ -run TestUpsertAll -v
```
预期:全部 PASS
- [ ] **Step 3: Commit**
```bash
git add backend/gateway/repository/app_download_repository_test.go
git commit -m "test: add AppDownloadRepository unit tests
Co-Authored-By: Claude <noreply@anthropic.com>"
```
---
### Task 8: Service 单元测试
**Files:**
- Create: `backend/gateway/service/app_download_service_test.go`
- [ ] **Step 1: 编写测试**
> 参照项目现有测试风格laser_generate_controller_test.go使用 in-memory fake 而非 testify/mock。
```go
package service
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/topfans/backend/pkg/models"
)
// fakeRepo 是 AppDownloadRepository 的 in-memory fake 实现
type fakeRepo struct {
configs []models.AppDownloadConfig
}
func (f *fakeRepo) FindByType(ctx context.Context, pkgType string) ([]models.AppDownloadConfig, error) {
var result []models.AppDownloadConfig
for _, c := range f.configs {
if c.Type == pkgType {
result = append(result, c)
}
}
return result, nil
}
func (f *fakeRepo) UpsertAll(ctx context.Context, configs []models.AppDownloadConfig) error {
for _, incoming := range configs {
found := false
for i, existing := range f.configs {
if existing.Platform == incoming.Platform && existing.Type == incoming.Type {
f.configs[i].DownloadURL = incoming.DownloadURL
f.configs[i].Version = incoming.Version
f.configs[i].UpdatedAt = incoming.UpdatedAt
found = true
break
}
}
if !found {
f.configs = append(f.configs, incoming)
}
}
return nil
}
// 确保 fakeRepo 实现 AppDownloadRepository 所需方法
// 注:生产者代码中 AppDownloadService 持有具体 *repository.AppDownloadRepository
// 测试时需要在 service 包内定义 interface 以支持 fake 注入。
// 为简化此 task暂时直接在 service_test.go 中定义 interface 并适配。
type appDownloadRepo interface {
FindByType(ctx context.Context, pkgType string) ([]models.AppDownloadConfig, error)
UpsertAll(ctx context.Context, configs []models.AppDownloadConfig) error
}
type testableService struct {
repo appDownloadRepo
}
func newTestableService(repo appDownloadRepo) *testableService {
return &testableService{repo: repo}
}
func (s *testableService) GetAllNativeApp(ctx context.Context) ([]models.AppDownloadConfig, error) {
return s.repo.FindByType(ctx, models.AppPackageTypeNativeApp)
}
func (s *testableService) SyncVersion(ctx context.Context, req *SyncVersionRequest) error {
now := 1700000000000 // 固定时间戳便于断言
var configs []models.AppDownloadConfig
if req.Android != nil {
configs = append(configs, models.AppDownloadConfig{
Platform: "android", Type: req.Android.Type,
DownloadURL: req.Android.URL, Version: req.Android.Version, UpdatedAt: now,
})
}
if req.IOS != nil {
configs = append(configs, models.AppDownloadConfig{
Platform: "ios", Type: req.IOS.Type,
DownloadURL: req.IOS.URL, Version: req.IOS.Version, UpdatedAt: now,
})
}
return s.repo.UpsertAll(ctx, configs)
}
func TestGetAllNativeApp_FiltersWgt(t *testing.T) {
repo := &fakeRepo{configs: []models.AppDownloadConfig{
{Platform: "android", Type: "native_app", DownloadURL: "https://app.apk"},
{Platform: "android", Type: "wgt", DownloadURL: "https://wgt.wgt"},
}}
svc := newTestableService(repo)
configs, err := svc.GetAllNativeApp(context.Background())
assert.NoError(t, err)
assert.Len(t, configs, 1)
assert.Equal(t, "native_app", configs[0].Type)
}
func TestGetAllNativeApp_Empty(t *testing.T) {
repo := &fakeRepo{}
svc := newTestableService(repo)
configs, err := svc.GetAllNativeApp(context.Background())
assert.NoError(t, err)
assert.Len(t, configs, 0)
}
func TestSyncVersion_BothPlatforms(t *testing.T) {
repo := &fakeRepo{}
svc := newTestableService(repo)
err := svc.SyncVersion(context.Background(), &SyncVersionRequest{
Android: &PlatformVersionInfo{URL: "https://a.apk", Version: "1.0.5", Type: "native_app"},
IOS: &PlatformVersionInfo{URL: "https://apps.apple.com/...", Version: "1.0.5", Type: "native_app"},
})
assert.NoError(t, err)
assert.Len(t, repo.configs, 2)
}
func TestSyncVersion_PartialUpdate(t *testing.T) {
repo := &fakeRepo{
configs: []models.AppDownloadConfig{
{Platform: "android", Type: "native_app", DownloadURL: "https://old.apk", Version: "1.0.0"},
},
}
svc := newTestableService(repo)
// 只更新 android不传 ios
err := svc.SyncVersion(context.Background(), &SyncVersionRequest{
Android: &PlatformVersionInfo{URL: "https://new.apk", Version: "2.0.0", Type: "native_app"},
})
assert.NoError(t, err)
assert.Len(t, repo.configs, 1) // 仍然是 1 条
assert.Equal(t, "https://new.apk", repo.configs[0].DownloadURL)
assert.Equal(t, "2.0.0", repo.configs[0].Version)
}
func TestSyncVersion_NilPlatform(t *testing.T) {
repo := &fakeRepo{}
svc := newTestableService(repo)
// 只传 iOSandroid 为 nil
err := svc.SyncVersion(context.Background(), &SyncVersionRequest{
IOS: &PlatformVersionInfo{URL: "https://apps.apple.com/...", Version: "1.0.5", Type: "native_app"},
})
assert.NoError(t, err)
assert.Len(t, repo.configs, 1)
assert.Equal(t, "ios", repo.configs[0].Platform)
}
```
- [ ] **Step 2: 运行测试**
```bash
cd backend/gateway && go test ./service/ -run TestGetAllNativeApp -v
cd backend/gateway && go test ./service/ -run TestSyncVersion -v
```
预期:全部 PASS
- [ ] **Step 3: Commit**
```bash
git add backend/gateway/service/app_download_service_test.go
git commit -m "test: add AppDownloadService unit tests
Co-Authored-By: Claude <noreply@anthropic.com>"
```
---
### Task 9: 将 Controller/Service 依赖改为 interface
> **目的**:通过 interface 解耦,使 controller 测试可以注入 mock serviceTask 12 中的测试依赖此重构)。
**Files:**
- Modify: `backend/gateway/service/app_download_service.go`
- Modify: `backend/gateway/controller/app_download_controller.go`
- [ ] **Step 1: 在 service 包中定义 repository interface**
`app_download_service.go` 的 import 块后添加 interface 定义,并修改 `AppDownloadService` 的字段类型:
```go
// AppDownloadRepo AppDownloadService 依赖的 repository 接口
type AppDownloadRepo interface {
FindByType(ctx context.Context, pkgType string) ([]models.AppDownloadConfig, error)
UpsertAll(ctx context.Context, configs []models.AppDownloadConfig) error
}
// AppDownloadService App下载页业务逻辑
type AppDownloadService struct {
repo AppDownloadRepo // ← 改为 interface
}
// NewAppDownloadService 构造函数
func NewAppDownloadService(repo AppDownloadRepo) *AppDownloadService { // ← 参数类型改为 interface
return &AppDownloadService{repo: repo}
}
```
- [ ] **Step 2: 在 controller 包中定义 service interface**
`app_download_controller.go` 的 import 块后添加 interface 定义,并修改 `AppDownloadController` 的字段类型:
```go
// AppDownloadSvc AppDownloadController 依赖的 service 接口
type AppDownloadSvc interface {
GetAllNativeApp(ctx context.Context) ([]models.AppDownloadConfig, error)
SyncVersion(ctx context.Context, req *service.SyncVersionRequest) error
}
// AppDownloadController App下载页控制器
type AppDownloadController struct {
svc AppDownloadSvc // ← 改为 interface
}
// NewAppDownloadController 构造函数
func NewAppDownloadController(svc AppDownloadSvc) *AppDownloadController { // ← 参数类型改为 interface
return &AppDownloadController{svc: svc}
}
```
> **注意**Controller 中需要 import `"github.com/topfans/backend/pkg/models"`(因为 `AppDownloadSvc` 接口引用了 `models.AppDownloadConfig`)。
- [ ] **Step 3: 编译验证(确认未破坏调用方)**
```bash
cd backend/gateway && go build ./...
```
预期:编译成功。`*repository.AppDownloadRepository` 自动满足 `AppDownloadRepo` 接口;`*service.AppDownloadService` 自动满足 `AppDownloadSvc` 接口。Go 的 structural typing 无需显式 `implements` 声明。
- [ ] **Step 4: Commit**
```bash
git add backend/gateway/service/app_download_service.go \
backend/gateway/controller/app_download_controller.go
git commit -m "refactor: use interface for AppDownloadController/Service dependencies
Co-Authored-By: Claude <noreply@anthropic.com>"
```
---
## 阶段 DHTML 分享页Task 10
### Task 10: HTML 下载页
**Files:**
- Create: `frontend/static/html/download.html`
- [ ] **Step 1: 编写 HTML 页面**
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>TopFans — 下载</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 50%, #0a0a0a 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.container { text-align: center; padding: 40px 24px; max-width: 360px; width: 100%; }
.logo { font-size: 32px; font-weight: 800; letter-spacing: 2px; margin-bottom: 8px; }
.slogan { font-size: 14px; color: #888; margin-bottom: 32px; }
.version { font-size: 13px; color: #666; margin-bottom: 24px; min-height: 20px; }
.download-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 16px 24px;
margin-bottom: 16px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
text-decoration: none;
transition: transform 0.15s, opacity 0.15s;
}
.download-btn:active { transform: scale(0.97); }
.download-btn.android {
background: #3ddc84;
color: #000;
}
.download-btn.ios {
background: #fff;
color: #000;
}
.download-btn.disabled {
opacity: 0.4;
pointer-events: none;
}
.footer { margin-top: 40px; font-size: 12px; color: #555; }
</style>
</head>
<body>
<div class="container">
<div class="logo">TopFans</div>
<div class="slogan">粉丝共创平台</div>
<div class="version" id="version-info">加载中…</div>
<a id="btn-android" class="download-btn android disabled" href="javascript:void(0)">
🤖 Android 下载
</a>
<a id="btn-ios" class="download-btn ios disabled" href="javascript:void(0)">
 iOS 下载
</a>
<div class="footer">TopFans © 2026</div>
</div>
<script>
(async function() {
// ★ 部署时修改为实际 API 地址
var API_URL = 'https://api.topfans.com/api/v1/app/download-urls';
var btnAndroid = document.getElementById('btn-android');
var btnIOS = document.getElementById('btn-ios');
var versionInfo = document.getElementById('version-info');
var versions = [];
function updateUI() {
var hasAndroid = false;
var hasIOS = false;
if (versions.length > 0) {
for (var i = 0; i < versions.length; i++) {
var v = versions[i];
if (v.platform === 'android') {
btnAndroid.href = v.url;
btnAndroid.classList.remove('disabled');
hasAndroid = true;
}
if (v.platform === 'ios') {
btnIOS.href = v.url;
btnIOS.classList.remove('disabled');
hasIOS = true;
}
}
}
if (hasAndroid || hasIOS) {
var parts = [];
if (hasAndroid) parts.push('Android ' + getVersionText('android'));
if (hasIOS) parts.push('iOS ' + getVersionText('ios'));
versionInfo.textContent = '最新版本:' + parts.join(' ');
} else {
versionInfo.textContent = '暂无可用下载';
}
}
function getVersionText(platform) {
for (var i = 0; i < versions.length; i++) {
if (versions[i].platform === platform) {
return versions[i].version || '';
}
}
return '';
}
try {
var res = await fetch(API_URL);
if (!res.ok) {
throw new Error('HTTP ' + res.status);
}
var json = await res.json();
if (json.code === 0 && json.data) {
['android', 'ios'].forEach(function(p) {
if (json.data[p]) {
versions.push({
platform: p,
url: json.data[p].url,
version: json.data[p].version,
type: json.data[p].type
});
}
});
updateUI();
} else {
versionInfo.textContent = '暂无可用下载';
}
} catch (err) {
console.error('获取下载地址失败', err);
versionInfo.textContent = '获取下载地址失败,请稍后重试';
}
})();
</script>
</body>
</html>
```
- [ ] **Step 2: 本地预览**
用浏览器打开 `frontend/static/html/download.html`,确认:
- 初始显示"加载中…"
- API 不可达时显示"获取下载地址失败,请稍后重试"
- 按钮处于 disabled灰色状态
- [ ] **Step 3: 部署到 Nginx**
```bash
# 部署到服务器
scp frontend/static/html/download.html <server>:/var/www/topfans/download.html
```
Nginx 配置参考:
```nginx
location /download {
alias /var/www/topfans/download.html;
add_header Cache-Control "public, max-age=300";
}
```
- [ ] **Step 4: 端到端验证**
1. 在手机浏览器访问 `https://h5.topfans.com/download`
2. 确认下载按钮可点击,跳转到正确的下载地址
3. 确认版本号显示正确
- [ ] **Step 5: Commit**
```bash
git add frontend/static/html/download.html
git commit -m "feat: add app download share page (HTML)
Co-Authored-By: Claude <noreply@anthropic.com>"
```
---
## 阶段 ESwagger + Controller 测试Task 11-12
### Task 11: Swagger 文档生成
**Files:**
- Modify: `backend/gateway/docs/`(自动生成)
- [ ] **Step 1: 生成 Swagger 文档**
```bash
cd backend/gateway && swag init --parseDependency --parseInternal
```
- [ ] **Step 2: 启动 gateway访问 Swagger UI**
```bash
cd backend/gateway && go run main.go
```
浏览器访问 `http://localhost:8080/swagger/index.html`,确认新的两个接口出现在文档中:
| 接口 | Tags |
|------|------|
| `GET /api/v1/app/download-urls` | App下载页 |
| `POST /api/v1/admin/app/versions/sync` | Admin |
- [ ] **Step 3: Commit**
```bash
git add backend/gateway/docs/
git commit -m "docs: update swagger for app download endpoints
Co-Authored-By: Claude <noreply@anthropic.com>"
```
---
### Task 12: Controller 集成测试
> **前置条件**Task 9interface 重构已完成controller 已支持 mock service 注入。
**Files:**
- Create: `backend/gateway/controller/app_download_controller_test.go`
- [ ] **Step 1: 编写测试fake service + 6 个用例)**
```go
package controller
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/topfans/backend/pkg/models"
"github.com/topfans/backend/gateway/service"
)
// fakeAppDownloadSvc 是 AppDownloadSvc 的 in-memory fake
type fakeAppDownloadSvc struct {
configs []models.AppDownloadConfig
getAllErr error
syncErr error
syncCalled bool
lastSyncReq *service.SyncVersionRequest
}
func (f *fakeAppDownloadSvc) GetAllNativeApp(ctx context.Context) ([]models.AppDownloadConfig, error) {
if f.getAllErr != nil {
return nil, f.getAllErr
}
return f.configs, nil
}
func (f *fakeAppDownloadSvc) SyncVersion(ctx context.Context, req *service.SyncVersionRequest) error {
f.syncCalled = true
f.lastSyncReq = req
return f.syncErr
}
func setupTestRouter(ctrl *AppDownloadController) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/api/v1/app/download-urls", ctrl.GetDownloadUrls)
r.POST("/api/v1/admin/app/versions/sync", ctrl.SyncVersion)
return r
}
// ========== GET /api/v1/app/download-urls ==========
func TestGetDownloadUrls_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
fakeSvc := &fakeAppDownloadSvc{
configs: []models.AppDownloadConfig{
{Platform: "android", Type: "native_app", DownloadURL: "https://app.apk", Version: "1.0.5"},
{Platform: "ios", Type: "native_app", DownloadURL: "https://apps.apple.com/...", Version: "1.0.5"},
},
}
ctrl := NewAppDownloadController(fakeSvc)
router := setupTestRouter(ctrl)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/app/download-urls", nil)
router.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, float64(0), resp["code"])
data := resp["data"].(map[string]interface{})
assert.NotNil(t, data["android"])
assert.NotNil(t, data["ios"])
}
func TestGetDownloadUrls_EmptyData(t *testing.T) {
gin.SetMode(gin.TestMode)
fakeSvc := &fakeAppDownloadSvc{configs: []models.AppDownloadConfig{}}
ctrl := NewAppDownloadController(fakeSvc)
router := setupTestRouter(ctrl)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/app/download-urls", nil)
router.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, float64(0), resp["code"])
data := resp["data"].(map[string]interface{})
assert.Nil(t, data["android"])
assert.Nil(t, data["ios"])
}
func TestGetDownloadUrls_ServiceError(t *testing.T) {
gin.SetMode(gin.TestMode)
fakeSvc := &fakeAppDownloadSvc{getAllErr: errors.New("db down")}
ctrl := NewAppDownloadController(fakeSvc)
router := setupTestRouter(ctrl)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/app/download-urls", nil)
router.ServeHTTP(w, req)
assert.Equal(t, 500, w.Code)
}
// ========== POST /api/v1/admin/app/versions/sync ==========
func TestSyncVersion_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
fakeSvc := &fakeAppDownloadSvc{}
ctrl := NewAppDownloadController(fakeSvc)
router := setupTestRouter(ctrl)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/admin/app/versions/sync",
strings.NewReader(`{"android": "not_an_object"}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, 400, w.Code) // BadRequest — JSON 类型不匹配
}
func TestSyncVersion_MissingRequiredField(t *testing.T) {
gin.SetMode(gin.TestMode)
fakeSvc := &fakeAppDownloadSvc{}
ctrl := NewAppDownloadController(fakeSvc)
router := setupTestRouter(ctrl)
// 缺少必填字段 url
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/admin/app/versions/sync",
strings.NewReader(`{"android": {"version": "1.0.5", "type": "native_app"}}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, 400, w.Code) // BadRequest — url is required
}
func TestSyncVersion_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
fakeSvc := &fakeAppDownloadSvc{}
ctrl := NewAppDownloadController(fakeSvc)
router := setupTestRouter(ctrl)
body := `{
"android": {"url": "https://app.apk", "version": "1.0.5", "type": "native_app"},
"ios": {"url": "https://apps.apple.com/...", "version": "1.0.5", "type": "native_app"}
}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/admin/app/versions/sync",
strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.True(t, fakeSvc.syncCalled)
assert.NotNil(t, fakeSvc.lastSyncReq.Android)
assert.NotNil(t, fakeSvc.lastSyncReq.IOS)
}
```
- [ ] **Step 2: 运行全部 Controller 测试**
```bash
cd backend/gateway && go test ./controller/ -run "TestGetDownloadUrls|TestSyncVersion" -v
```
预期:全部 6 个用例 PASS
- [ ] **Step 3: 运行全部 app_download 测试套件**
```bash
cd backend/gateway && go test ./repository/ ./service/ ./controller/ -v -count=1
```
预期:全部 PASS14 个用例repo 5 + service 5 + controller 6减去重复的 `TestMain`
- [ ] **Step 4: Commit**
```bash
git add backend/gateway/controller/app_download_controller_test.go
git commit -m "test: add AppDownloadController integration tests (6 cases)
Co-Authored-By: Claude <noreply@anthropic.com>"
```
---
## 阶段 FuniCloud 云函数独立仓库Task 13-14
> **注意**uni-admin 是独立仓库(不在当前工作目录)。以下 Task 在 uni-admin 仓库中执行。
### Task 13: uniCloud 云函数
**Files (uni-admin 仓库):**
- Create: `uniCloud-alipay/cloudfunctions/sync-download-urls/index.js`
- Create: `uniCloud-alipay/cloudfunctions/sync-download-urls/package.json`
- [ ] **Step 1: 创建 `package.json`**
```json
{
"name": "sync-download-urls",
"version": "1.0.0",
"extensions": {
"uni-cloud-httpclient": {}
}
}
```
- [ ] **Step 2: 创建 `index.js`**
```javascript
'use strict';
/**
* sync-download-urls — 同步最新下载地址到 Go Backend
*
* 触发方式uni-admin 版本发布成功后调用
* 环境变量uniCloud 云函数配置):
* BACKEND_URL — Go Backend 地址,如 https://api.topfans.com
*/
const APPID = '__UNI__B99B0DD'; // topfans appid以实际为准
exports.main = async (event, context) => {
const db = uniCloud.database();
const backendURL = process.env.BACKEND_URL || 'https://api.topfans.com';
const result = { android: null, ios: null, synced: false, error: null };
try {
// 1. 查询 Android + iOS 最新 stable_publish 记录
for (const platform of ['Android', 'iOS']) {
const platformKey = platform.toLowerCase();
const res = await db.collection('opendb-app-versions')
.where({
appid: APPID,
platform: platform,
stable_publish: true,
})
.orderBy('create_date', 'desc')
.get();
if (res.data && res.data.length > 0) {
const latest = res.data[0];
result[platformKey] = {
url: latest.url || '',
version: latest.version || '',
type: latest.type || 'native_app',
};
}
}
// 2. 推送到 Go Backend
const payload = {
android: result.android || null,
ios: result.ios || null,
};
if (!payload.android && !payload.ios) {
result.error = 'No published versions found';
return result;
}
const httpRes = await uniCloud.httpclient.request(
`${backendURL}/api/v1/admin/app/versions/sync`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: payload,
dataType: 'json',
timeout: 10000,
}
);
if (httpRes.status === 200 && httpRes.data && httpRes.data.code === 0) {
result.synced = true;
} else {
result.error = `Backend returned status=${httpRes.status}, data=${JSON.stringify(httpRes.data)}`;
}
} catch (err) {
result.error = err.message || String(err);
}
return result;
};
```
- [ ] **Step 3: 上传云函数到 uniCloud**
在 HBuilderX 或 uniCloud 控制台上传 `sync-download-urls` 云函数。
- [ ] **Step 4: 配置环境变量**
在 uniCloud 控制台 → 云函数 → sync-download-urls → 环境变量,添加:
```
BACKEND_URL = https://api.topfans.com
```
- [ ] **Step 5: 网络连通性验证**
在 uniCloud 云函数控制台执行测试:
```javascript
const res = await uniCloud.httpclient.request(
`${process.env.BACKEND_URL}/health`,
{ method: 'GET', timeout: 5000 }
);
console.log('status:', res.status); // 期望 200
```
- [ ] **Step 6: Commit (uni-admin 仓库)**
```bash
git add uniCloud-alipay/cloudfunctions/sync-download-urls/
git commit -m "feat: add sync-download-urls cloud function"
```
---
### Task 14: uni-admin 触发同步
**Files (uni-admin 仓库):**
- Modify: `uni_modules/uni-upgrade-center/pages/version/add.vue`
- [ ] **Step 1: 在 `submitForm` 成功后插入同步调用**
定位到 `add.vue``dbOperate.then(async (res) => { ... })` 回调,在 `uni.showToast` 之前添加:
```javascript
// ★ 新增:同步下载地址到 Go Backend异步不阻塞 UI
if (value.stable_publish) {
this.syncDownloadUrls();
}
```
完整修改后diff 风格):
```javascript
dbOperate.then(async (res) => {
if (value.stable_publish && this.lastVersionId) {
await collectionDB.doc(this.lastVersionId).update({
stable_publish: false
})
}
// ★ 新增:同步下载地址到 Go Backend异步不阻塞 UI
if (value.stable_publish) {
this.syncDownloadUrls();
}
uni.showToast({
title: '新增成功'
})
this.getOpenerEventChannel().emit('refreshData')
setTimeout(() => uni.navigateBack(), 500)
})
```
- [ ] **Step 2: 在 methods 中新增 `syncDownloadUrls` 方法**
在现有 methods 块末尾(`back()` 之前)添加:
```javascript
/**
* 同步下载地址到 Go Backend异步调用不阻塞 UI
*/
async syncDownloadUrls() {
try {
const res = await uniCloud.callFunction({
name: 'sync-download-urls',
});
if (res.result && !res.result.synced) {
console.warn('[sync-download-urls] 同步失败:', res.result.error);
}
} catch (err) {
console.warn('[sync-download-urls] 云函数调用异常:', err.message);
}
},
```
- [ ] **Step 3: 端到端测试**
1. 在 uni-admin 发布一个新版本
2. 查看云函数日志确认同步成功
3. curl Go Backend 验证数据已写入:
```bash
curl -s https://api.topfans.com/api/v1/app/download-urls | jq .
```
预期:返回最新发布的版本号
- [ ] **Step 4: Commit (uni-admin 仓库)**
```bash
git add uni_modules/uni-upgrade-center/pages/version/add.vue
git commit -m "feat: trigger sync-download-urls after version publish"
```
---
## 阶段 G运维配置Task 15
### Task 15: Nginx Rate Limit + 最终验证
- [ ] **Step 1: 配置 Nginx rate limit公开接口防刷**
在 Nginx 配置中添加:
```nginx
# 在 http 块中定义 limit zone
limit_req_zone $binary_remote_addr zone=download_api:10m rate=100r/m;
# 在 server 块中对公开接口应用
location /api/v1/app/download-urls {
limit_req zone=download_api burst=20 nodelay;
proxy_pass http://gateway:8080;
}
```
- [ ] **Step 2: 重载 Nginx**
```bash
nginx -t && nginx -s reload
```
- [ ] **Step 3: 验证 Admin 接口 IP 白名单**
确认 `/api/v1/admin/*` 已配置 IP 白名单:
```nginx
location /api/v1/admin/ {
allow <uniCloud出口IP>;
allow <内网IP段>;
deny all;
proxy_pass http://gateway:8080;
}
```
- [ ] **Step 4: 全链路端到端验证**
```
uni-admin 发布新版本
→ 云函数 sync-download-urls 执行
→ POST /api/v1/admin/app/versions/sync (HTTP 200)
→ PG app_download_configs 表写入
浏览器访问 https://h5.topfans.com/download
→ fetch GET /api/v1/app/download-urls
→ 渲染 Android/iOS 下载按钮(可点击)
```
- [ ] **Step 5: 验证序列健康**
```sql
SELECT
schemaname, sequencename, last_value,
(SELECT MAX(id) FROM app_download_configs) AS table_max_id,
last_value >= (SELECT MAX(id) FROM app_download_configs) AS is_healthy
FROM pg_sequences
WHERE sequencename = 'app_download_configs_id_seq';
```
预期:`is_healthy = true`
---
## 任务依赖关系
```
Task 1 (migration)
└─ Task 2 (model)
└─ Task 3 (repository)
├─ Task 4 (service)
│ └─ Task 5 (controller)
│ └─ Task 6 (router + e2e test)
│ └─ Task 11 (swagger)
├─ Task 7 (repo tests)
└─ Task 8 (service tests)
Task 5 ────────────────────────────────────────┐
Task 9 (interface refactor) ← after Task 4,5 │
└─ Task 12 (controller tests) │
Task 10 (HTML) ────── independent │
Task 13 (云函数) ─── independent (uni-admin) │
Task 14 (add.vue) ── after Task 13 │
Task 15 (运维) ───── after Task 6 + 10 + 14 ───┘
```
> **关键依赖**Task 12controller 测试)依赖 Task 9interface 重构),因为测试需要注入 mock service。
---
## 时间估算
| 阶段 | 任务 | 预估 |
|------|------|------|
| A | Task 1-4: 基础设施 | 1h |
| B | Task 5-6: Controller + 路由 | 45min |
| C | Task 7-9: 测试 + 接口重构 | 1h15min |
| D | Task 10: HTML 页 | 30min |
| E | Task 11-12: Swagger + Controller 测试 | 1h |
| F | Task 13-14: uniCloud (独立仓库) | 1h15min |
| G | Task 15: 运维 + 验证 | 30min |
| **合计** | | **~6h** |
> Task 13-14 在 uni-admin 独立仓库执行,不占用本仓库开发时间。
> 本仓库实际开发时间约 **~4h45min**。