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

50 KiB
Raw Blame History

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

-- 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 批量 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: 编译验证
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>"

阶段 BController + 路由注册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 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: 编译验证
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只需补充 repositorydatabase 两个新包。

  • 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.godocs/swagger.jsondocs/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)

	// 只传 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: 运行测试
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 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 的字段类型:

// 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>"

阶段 DHTML 分享页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: 端到端验证
  1. 在手机浏览器访问 https://h5.topfans.com/download
  2. 确认下载按钮可点击,跳转到正确的下载地址
  3. 确认版本号显示正确
  • 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>"

阶段 ESwagger + 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 9interface 重构已完成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

预期:全部 PASS14 个用例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>"

阶段 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

{
  "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.vuedbOperate.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: 端到端测试
  1. 在 uni-admin 发布一个新版本
  2. 查看云函数日志确认同步成功
  3. 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 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