50 KiB
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 → repository),gateway 直连 PG(无 Dubbo RPC),uniCloud 云函数 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 |
Handler:GetDownloadUrls + 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 后触发同步 |
阶段 A:Go Backend 基础设施(Task 1-4)
Task 1: 数据库 Migration
Files:
-
Create:
backend/migrations/2026_07_08_001_app_download_configs.sql -
Step 1: 编写 migration 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
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: 验证表结构
\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
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
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 编译通过
cd backend/gateway && go build ./...
预期:编译成功(model 被其他包引用,单独编译可能无输出,用 go vet 校验语法)
cd backend/gateway && go vet ./...
- Step 3: Commit
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
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 批量 upsert(platform + 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: 编译验证
cd backend/gateway && go build ./repository/
预期:编译成功
- Step 3: Commit
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
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: 编译验证
cd backend/gateway && go build ./service/
预期:编译成功
- Step 3: Commit
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>"
阶段 B:Controller + 路由注册(Task 5-6)
Task 5: Controller 层
Files:
-
Create:
backend/gateway/controller/app_download_controller.go -
Step 1: 编写 Controller
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 RPC(spec §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: 编译验证
cd backend/gateway && go build ./controller/
预期:编译成功
- Step 3: Commit
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") 之前),添加:
// 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 块中添加:
"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", ...) 附近)添加:
// App 下载页 — HTML 分享页使用(公开,无需认证)
v1.GET("/app/download-urls", appDownloadCtrl.GetDownloadUrls)
- Step 4: 注册 Admin 内部路由
在现有 admin := v1.Group("/admin") 块内(约 router.go:282-285)追加一行:
admin := v1.Group("/admin")
{
admin.POST("/notifications", notificationCtrl.AdminCreateNotification) // 已有
admin.POST("/app/versions/sync", appDownloadCtrl.SyncVersion) // ← 新增
}
- Step 5: 编译验证
cd backend/gateway && go build ./...
预期:编译成功,无 import 错误
- Step 6: 本地启动并测试接口
启动 gateway:
cd backend/gateway && go run main.go
测试公开接口(GET):
curl -s http://localhost:8080/api/v1/app/download-urls | jq .
预期(数据库为空时):
{
"code": 0,
"message": "ok",
"data": {
"android": null,
"ios": null
}
}
测试 Admin 同步接口(POST):
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 .
预期:
{
"code": 0,
"message": "ok"
}
再次 GET 验证数据已写入:
curl -s http://localhost:8080/api/v1/app/download-urls | jq .
预期:android 和 ios 均有数据
- Step 7: 验证 Swagger 文档生成
cd backend/gateway && swag init --parseDependency --parseInternal
预期:生成/更新 docs/docs.go、docs/swagger.json、docs/swagger.yaml
- Step 8: Commit
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: 编写测试
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: 运行测试
cd backend/gateway && go test ./repository/ -run TestFindByType -v
cd backend/gateway && go test ./repository/ -run TestUpsertAll -v
预期:全部 PASS
- Step 3: Commit
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。
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)
// 只传 iOS,android 为 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: 运行测试
cd backend/gateway && go test ./service/ -run TestGetAllNativeApp -v
cd backend/gateway && go test ./service/ -run TestSyncVersion -v
预期:全部 PASS
- Step 3: Commit
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 service(Task 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 的字段类型:
// 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 的字段类型:
// 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: 编译验证(确认未破坏调用方)
cd backend/gateway && go build ./...
预期:编译成功。*repository.AppDownloadRepository 自动满足 AppDownloadRepo 接口;*service.AppDownloadService 自动满足 AppDownloadSvc 接口。Go 的 structural typing 无需显式 implements 声明。
- Step 4: Commit
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>"
阶段 D:HTML 分享页(Task 10)
Task 10: HTML 下载页
Files:
-
Create:
frontend/static/html/download.html -
Step 1: 编写 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
# 部署到服务器
scp frontend/static/html/download.html <server>:/var/www/topfans/download.html
Nginx 配置参考:
location /download {
alias /var/www/topfans/download.html;
add_header Cache-Control "public, max-age=300";
}
- Step 4: 端到端验证
- 在手机浏览器访问
https://h5.topfans.com/download - 确认下载按钮可点击,跳转到正确的下载地址
- 确认版本号显示正确
- Step 5: Commit
git add frontend/static/html/download.html
git commit -m "feat: add app download share page (HTML)
Co-Authored-By: Claude <noreply@anthropic.com>"
阶段 E:Swagger + Controller 测试(Task 11-12)
Task 11: Swagger 文档生成
Files:
-
Modify:
backend/gateway/docs/(自动生成) -
Step 1: 生成 Swagger 文档
cd backend/gateway && swag init --parseDependency --parseInternal
- Step 2: 启动 gateway,访问 Swagger UI
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
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 9(interface 重构)已完成,controller 已支持 mock service 注入。
Files:
-
Create:
backend/gateway/controller/app_download_controller_test.go -
Step 1: 编写测试(fake service + 6 个用例)
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 测试
cd backend/gateway && go test ./controller/ -run "TestGetDownloadUrls|TestSyncVersion" -v
预期:全部 6 个用例 PASS
- Step 3: 运行全部 app_download 测试套件
cd backend/gateway && go test ./repository/ ./service/ ./controller/ -v -count=1
预期:全部 PASS(14 个用例:repo 5 + service 5 + controller 6,减去重复的 TestMain)
- Step 4: Commit
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>"
阶段 F:uniCloud 云函数(独立仓库,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
{
"name": "sync-download-urls",
"version": "1.0.0",
"extensions": {
"uni-cloud-httpclient": {}
}
}
- Step 2: 创建
index.js
'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 云函数控制台执行测试:
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 仓库)
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 之前添加:
// ★ 新增:同步下载地址到 Go Backend(异步,不阻塞 UI)
if (value.stable_publish) {
this.syncDownloadUrls();
}
完整修改后(diff 风格):
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() 之前)添加:
/**
* 同步下载地址到 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: 端到端测试
- 在 uni-admin 发布一个新版本
- 查看云函数日志确认同步成功
- curl Go Backend 验证数据已写入:
curl -s https://api.topfans.com/api/v1/app/download-urls | jq .
预期:返回最新发布的版本号
- Step 4: Commit (uni-admin 仓库)
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 配置中添加:
# 在 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
nginx -t && nginx -s reload
- Step 3: 验证 Admin 接口 IP 白名单
确认 /api/v1/admin/* 已配置 IP 白名单:
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: 验证序列健康
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 12(controller 测试)依赖 Task 9(interface 重构),因为测试需要注入 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。