1728 lines
50 KiB
Markdown
1728 lines
50 KiB
Markdown
# 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**
|
||
|
||
```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 批量 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: 编译验证**
|
||
|
||
```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>"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段 B:Controller + 路由注册(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 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: 编译验证**
|
||
|
||
```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)
|
||
|
||
// 只传 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: 运行测试**
|
||
|
||
```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 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` 的字段类型:
|
||
|
||
```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>"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段 D:HTML 分享页(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>"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段 E:Swagger + 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 9(interface 重构)已完成,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
|
||
```
|
||
|
||
预期:全部 PASS(14 个用例: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>"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段 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`**
|
||
|
||
```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 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**。
|