1504 lines
55 KiB
Markdown
1504 lines
55 KiB
Markdown
# App 下载分享页 — 自动同步方案设计
|
||
|
||
> **文档状态**:Draft v1.1 · 2026-07-08(修订 2026-07-09)
|
||
> **适用版本**:topfans v1.0.5+
|
||
> **目标读者**:后端 / uni-admin / 前端开发
|
||
|
||
---
|
||
|
||
## §1 方案概述(*必读*)
|
||
|
||
### 要解决的问题
|
||
|
||
| 问题 | 说明 |
|
||
|------|------|
|
||
| **业务问题** | 需要一个 HTML 分享页,展示 Android/iOS 下载按钮。但 APK/IPA 的下载地址会随着版本更新而变化,不能硬编码在 HTML 里 |
|
||
| **技术问题** | 下载地址的来源是 uni-admin(uniCloud `opendb-app-versions` 表),HTML 页面无法直接访问 uniCloud 数据库。需要一个自动同步机制,让下载地址始终保持最新 |
|
||
|
||
### 整体实现路径
|
||
|
||
```mermaid
|
||
graph LR
|
||
A[uni-admin<br/>发布新版本] -->|① 触发| B[uniCloud 云函数<br/>sync-download-urls]
|
||
B -->|② 查询最新 URL| C[opendb-app-versions]
|
||
B -->|③ HTTP POST| D[Go Backend<br/>POST /api/v1/admin/app/versions/sync]
|
||
D -->|④ 写入| E[(PostgreSQL<br/>app_download_configs)]
|
||
F[HTML 分享页] -->|⑤ GET 请求| G[Go Backend<br/>GET /api/v1/app/download-urls]
|
||
G -->|⑥ 读取| E
|
||
```
|
||
|
||
**关键路径**:
|
||
1. **自动同步**(正常路径):uni-admin 点"发布"→ 云函数自动推送最新 URL 到 Go 后端 → 写入 PG
|
||
2. **手动回退**(异常路径):同步失败时,管理员在 uni-admin 版本列表页点击"重新同步到下载页"按钮重试
|
||
3. **缓存兜底**:Go 后端返回 URL 时附带 `updated_at`,HTML 页面可按需刷新
|
||
|
||
### 关键决策
|
||
|
||
| 决策 | 选择 | 原因 |
|
||
|------|------|------|
|
||
| HTML 页调谁的 API? | **Go Backend** | Go 后端部署在自己的服务器上,可靠性和可控性远高于 uniCloud URL-ified 函数 |
|
||
| 同步触发方式 | **uniCloud 云函数 push** | 发布版本后立即同步,延迟 < 1s;不需要定时轮询 |
|
||
| 同步数据粒度 | **只同步最新 Android + iOS 的 `url` + `version` + `type`** | MVP 只做下载页需要的数据,不搬运整个版本表 |
|
||
| 数据存储 | **新建 `app_download_configs` 表** | 现有 `system_configs` 的 `config_value` 是 `float64`,无法存字符串 URL |
|
||
| 是否区分 wgt/安装包 | **是,存储 `type` 字段** | 下载页只展示 `native_app`(整包下载),区分于 wgt(热更新资源包);同时也为未来按类型过滤预留 |
|
||
|
||
### 核心架构图(TL;DR)
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────────────────┐
|
||
│ uni-admin (uniCloud) │
|
||
│ ┌──────────────────────┐ ┌──────────────────────────────┐ │
|
||
│ │ version/add.vue │ │ cloudfunction: │ │
|
||
│ │ submitForm() ───────────▶│ sync-download-urls │ │
|
||
│ │ (发布版本后触发) │ │ ├─ 查 opendb-app-versions │ │
|
||
│ └──────────────────────┘ │ └─ POST → Go Backend │ │
|
||
│ └──────────────┬───────────────┘ │
|
||
└─────────────────────────────────────────────│─────────────────────┘
|
||
│ HTTP (内网或公网)
|
||
┌─────────────────────────────────────────────▼─────────────────────┐
|
||
│ Go Backend (gateway) │
|
||
│ ┌──────────────────────┐ ┌─────────────────────────────┐ │
|
||
│ │ POST /api/v1/admin/ │ │ GET /api/v1/app/ │ │
|
||
│ │ app/versions/sync │ │ download-urls │ │
|
||
│ │ (接收 uniCloud 同步) │ │ (HTML 页面调用,公开接口) │ │
|
||
│ └──────────┬───────────┘ └──────────────┬──────────────┘ │
|
||
│ │ │ │
|
||
│ ┌──────────▼───────────────────────────────▼──────────────┐ │
|
||
│ │ app_download_configs (PostgreSQL) │ │
|
||
│ │ platform | type | url | version │ │
|
||
│ │ android | native_app | https://...apk | 1.0.5 │ │
|
||
│ │ ios | native_app | https://apps.... | 1.0.5 │ │
|
||
│ └──────────────────────────────────────────────────────────┘ │
|
||
└──────────────────────────────────────────────────────────────────┘
|
||
|
||
┌──────────────────────────────────────────────────────────────────┐
|
||
│ HTML 分享页 (static/dynamic) │
|
||
│ window.onload → fetch(GET /api/v1/app/download-urls) │
|
||
│ → 只展示 type=native_app 的条目 │
|
||
│ → 渲染 Android 下载按钮 (href=android_url) │
|
||
│ → 渲染 iOS 下载按钮 (href=ios_url) │
|
||
└──────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## §2 核心架构
|
||
|
||
### 2.1 数据模型
|
||
|
||
```
|
||
app_download_configs
|
||
├── id: BIGSERIAL PK
|
||
├── platform: VARCHAR(20) NOT NULL -- 'android' | 'ios'
|
||
├── type: VARCHAR(20) NOT NULL -- 'native_app' | 'wgt'
|
||
├── download_url: TEXT NOT NULL -- 下载地址
|
||
├── version: VARCHAR(50) -- 版本号(如 "1.0.5")
|
||
├── created_at: BIGINT NOT NULL -- Unix 毫秒时间戳
|
||
├── updated_at: BIGINT NOT NULL -- Unix 毫秒时间戳
|
||
└── UNIQUE(platform, type)
|
||
```
|
||
|
||
**设计说明**:
|
||
- 每个平台的每种包类型只存**一条**记录(UNIQUE(platform, type)),更新即 overwrite
|
||
- `type` 区分 `native_app`(整包安装)和 `wgt`(热更新资源包)——下载页只展示 `native_app`
|
||
- 不需要多余字段,MVP 只有 `url` + `version` + `type`
|
||
- `updated_at` 用于 HTML 页面判断是否需要刷新(可选)
|
||
|
||
### 2.2 同步链路
|
||
|
||
```
|
||
submitForm() 成功
|
||
│
|
||
├─ 1. 数据已写入 opendb-app-versions(原有逻辑)
|
||
│
|
||
└─ 2. 调用 uniCloud 云函数 sync-download-urls
|
||
│
|
||
├─ 2a. 在 opendb-app-versions 中查询:
|
||
│ WHERE appid = $appid
|
||
│ AND stable_publish = true
|
||
│ AND platform 数组包含 'Android' (或 'iOS')
|
||
│ ORDER BY create_date DESC LIMIT 1
|
||
│ (每个平台 + 每种 type 各取一条)
|
||
│
|
||
├─ 2b. 组装 payload:
|
||
│ { "android": { "url": "...", "version": "1.0.5", "type": "native_app" },
|
||
│ "ios": { "url": "...", "version": "1.0.5", "type": "native_app" } }
|
||
│
|
||
└─ 2c. HTTP POST → Go Backend POST /api/v1/admin/app/versions/sync
|
||
```
|
||
|
||
> **注**:以上为设计伪代码。MongoDB 对数组字段做等值查询 `platform: 'Android'` 即可匹配包含该值的数组记录。
|
||
|
||
### 2.3 读取链路
|
||
|
||
```
|
||
HTML 页面 onload
|
||
│
|
||
└─ fetch("https://api.topfans.com/api/v1/app/download-urls")
|
||
│
|
||
└─ Go Backend:
|
||
SELECT * FROM app_download_configs
|
||
WHERE platform IN ('android','ios') AND type = 'native_app'
|
||
→ { "data": { "android": { "url": "...", "version": "1.0.5", "type": "native_app" },
|
||
"ios": { "url": "...", "version": "1.0.5", "type": "native_app" } } }
|
||
```
|
||
|
||
---
|
||
|
||
## §3 数据库设计
|
||
|
||
### 3.1 Migration
|
||
|
||
**文件**:`backend/migrations/2026_07_08_001_app_download_configs.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;
|
||
```
|
||
|
||
### 3.2 Go Model
|
||
|
||
**文件**:`backend/pkg/models/app_download_config.go`
|
||
|
||
```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" // 热更新资源包
|
||
)
|
||
```
|
||
|
||
---
|
||
|
||
## §4 Go Backend API
|
||
|
||
### 4.1 接口定义
|
||
|
||
| 接口 | 方法 | 路径 | 认证 | 说明 |
|
||
|------|------|------|------|------|
|
||
| 公开接口 | GET | `/api/v1/app/download-urls` | 无 | HTML 分享页使用 |
|
||
| Admin 同步接口 | POST | `/api/v1/admin/app/versions/sync` | Nginx IP 白名单 | uniCloud 云函数调用 |
|
||
|
||
### 4.2 GET /api/v1/app/download-urls(公开,无需认证)
|
||
|
||
**请求**:无参数
|
||
|
||
**响应**:
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"message": "ok",
|
||
"data": {
|
||
"android": {
|
||
"url": "https://cdn.topfans.com/app/releases/topfans-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"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**响应(数据为空时)**:
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"message": "ok",
|
||
"data": {
|
||
"android": null,
|
||
"ios": null
|
||
}
|
||
}
|
||
```
|
||
|
||
**说明**:公开接口只返回 `type = native_app` 的记录(下载页不需要展示 wgt 热更新地址)。
|
||
|
||
### 4.3 POST /api/v1/admin/app/versions/sync(Admin 内部接口)
|
||
|
||
> 复用现有 `/api/v1/admin/*` 路由组(无鉴权,依赖 Nginx IP 白名单)
|
||
|
||
**请求**:
|
||
```json
|
||
{
|
||
"android": {
|
||
"url": "https://cdn.topfans.com/app/releases/topfans-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"
|
||
}
|
||
}
|
||
```
|
||
|
||
> `type` 字段取值为 `"native_app"` 或 `"wgt"`,由 uniCloud 云函数从 `opendb-app-versions.type` 透传。
|
||
|
||
**响应**:
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"message": "ok"
|
||
}
|
||
```
|
||
|
||
### 4.4 Handler / Service / Repository 分层
|
||
|
||
按项目规范(`CLAUDE.md` 接口开发规范),分层如下:
|
||
|
||
```
|
||
gateway/
|
||
├── controller/
|
||
│ └── app_download_controller.go ← handler: 参数绑定、校验、调用 service、组装响应
|
||
├── service/
|
||
│ └── app_download_service.go ← 业务层: upsert 逻辑
|
||
├── repository/
|
||
│ └── app_download_repository.go ← 数据层: 纯 PostgreSQL 操作
|
||
```
|
||
|
||
> **架构说明**(§12.5):`app_download_configs` 只有 2 条记录,仅做简单 KV 读写,不需要事务编排和跨服务调用。为此创建 Dubbo RPC service 明显过度设计。因此 repository 在 gateway 中直连 PG,遵循 MVP 原则。
|
||
|
||
#### 4.4.1 DTO
|
||
|
||
```go
|
||
// app_download_controller.go
|
||
package controller
|
||
|
||
import (
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/topfans/backend/gateway/pkg/response"
|
||
"github.com/topfans/backend/gateway/service"
|
||
)
|
||
|
||
// SyncVersionRequest uniCloud 同步请求体(定义在 service 包,此处引用)
|
||
// 见 service/app_download_service.go
|
||
|
||
// DownloadUrlResponse 下载页公开接口响应
|
||
type DownloadUrlResponse struct {
|
||
URL string `json:"url"`
|
||
Version string `json:"version"`
|
||
Type string `json:"type"`
|
||
}
|
||
|
||
// AppDownloadController App下载页控制器
|
||
// 注意:不走 Dubbo RPC(§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}
|
||
}
|
||
```
|
||
|
||
#### 4.4.2 Controller
|
||
|
||
```go
|
||
// (续 app_download_controller.go)
|
||
|
||
// 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 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, "sync failed")
|
||
return
|
||
}
|
||
|
||
response.Success(c, nil)
|
||
}
|
||
```
|
||
|
||
#### 4.4.3 Service
|
||
|
||
```go
|
||
// app_download_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)
|
||
}
|
||
```
|
||
|
||
#### 4.4.4 Repository
|
||
|
||
```go
|
||
// app_download_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
|
||
})
|
||
}
|
||
```
|
||
|
||
### 4.5 路由注册
|
||
|
||
在 `router.go` 的 `SetupRouter` 函数中,按以下方式集成。
|
||
|
||
**Step 1 — 创建 controller**(在现有 controller 初始化代码块末尾添加):
|
||
|
||
```go
|
||
// App 下载页 — gateway 直连 PG(无 Dubbo RPC,参见 §12.5)
|
||
appDownloadRepo := repository.NewAppDownloadRepository(database.GetDB())
|
||
appDownloadSvc := service.NewAppDownloadService(appDownloadRepo)
|
||
appDownloadCtrl := controller.NewAppDownloadController(appDownloadSvc)
|
||
```
|
||
|
||
> `database.GetDB()` 是项目已有的全局 PG 连接获取函数(`backend/pkg/database/database.go`),无需修改 `SetupRouter` 函数签名。
|
||
|
||
**Step 2 — 注册公开路由**(在 `v1` 路由组内,与其他公开路由并列添加):
|
||
|
||
```go
|
||
// App 下载页 — HTML 分享页使用(公开,无需认证)
|
||
v1.GET("/app/download-urls", appDownloadCtrl.GetDownloadUrls)
|
||
```
|
||
|
||
**Step 3 — 注册 Admin 内部路由**(在现有 `admin := v1.Group("/admin")` 块内添加一行):
|
||
|
||
```go
|
||
admin := v1.Group("/admin")
|
||
{
|
||
admin.POST("/notifications", notificationCtrl.AdminCreateNotification) // 已有
|
||
admin.POST("/app/versions/sync", appDownloadCtrl.SyncVersion) // ← 新增
|
||
}
|
||
```
|
||
|
||
> **关键**:不要新建 admin group,在现有块内追加即可。现有 admin group 已在 `v1` 下、无鉴权中间件,依赖 Nginx IP 白名单保护。
|
||
|
||
### 4.6 依赖注入(装配)
|
||
|
||
三层装配在 `SetupRouter` 内部完成(与项目现有 controller 创建模式一致):
|
||
|
||
```
|
||
database.GetDB() ← 全局 PG 连接
|
||
│
|
||
▼
|
||
repository.NewAppDownloadRepository ← 数据层
|
||
│
|
||
▼
|
||
service.NewAppDownloadService ← 业务层
|
||
│
|
||
▼
|
||
controller.NewAppDownloadController ← 控制器(注册路由)
|
||
```
|
||
|
||
> 不需要在 `main.go` 中额外装配。`SetupRouter` 已接收所有 Dubbo client 参数并在内部创建 controller,本功能的 controller 也在此创建,无需修改 `main.go`。
|
||
|
||
---
|
||
|
||
## §5 uniCloud 云函数(自动同步)
|
||
|
||
### 5.1 云函数:sync-download-urls
|
||
|
||
**文件**:`uni-admin/uniCloud-alipay/cloudfunctions/sync-download-urls/index.js`
|
||
|
||
```javascript
|
||
'use strict';
|
||
|
||
/**
|
||
* sync-download-urls — 同步最新下载地址到 Go Backend
|
||
*
|
||
* 触发方式:uni-admin 版本发布成功后调用
|
||
* 动作:
|
||
* 1. 查询 opendb-app-versions 中最新 stable_publish 的 Android/iOS 记录
|
||
* (native_app 和 wgt 各取一条)
|
||
* 2. 组装 payload 推送到 Go Backend 的 admin API
|
||
*
|
||
* 环境变量(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 记录(不区分 type)
|
||
for (const platform of ['Android', 'iOS']) {
|
||
const platformKey = platform.toLowerCase(); // 'android' | 'ios'
|
||
|
||
// MongoDB 数组字段直接用等值查询即可匹配包含该值的记录
|
||
const res = await db.collection('opendb-app-versions')
|
||
.where({
|
||
appid: APPID,
|
||
platform: platform, // ← MongoDB 等值匹配数组元素
|
||
stable_publish: true,
|
||
})
|
||
.orderBy('create_date', 'desc')
|
||
.get();
|
||
|
||
if (res.data && res.data.length > 0) {
|
||
// 取最新的一条(不论是 native_app 还是 wgt)
|
||
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;
|
||
};
|
||
```
|
||
|
||
### 5.2 云函数配置
|
||
|
||
**文件**:`uni-admin/uniCloud-alipay/cloudfunctions/sync-download-urls/package.json`
|
||
|
||
```json
|
||
{
|
||
"name": "sync-download-urls",
|
||
"version": "1.0.0",
|
||
"extensions": {
|
||
"uni-cloud-httpclient": {}
|
||
}
|
||
}
|
||
```
|
||
|
||
> `uniCloud.database()` 是内置 API,无需额外依赖。`uni-cloud-httpclient` 用于调用 Go Backend 的 HTTP 接口。
|
||
|
||
### 5.3 uni-admin 触发同步
|
||
|
||
**改动文件**:`uni_modules/uni-upgrade-center/pages/version/add.vue`
|
||
|
||
**改动方式**:在 `submitForm` 方法的 `.then()` 回调中的发布成功后,增加一行同步调用。
|
||
|
||
原代码(`add.vue` 的 `dbOperate.then(...)` 回调内,约 331 行):
|
||
|
||
```javascript
|
||
dbOperate.then(async (res) => {
|
||
// 如果新增版本为上线发行,且之前有该平台的上线发行,则自动将上一版设为下线
|
||
if (value.stable_publish && this.lastVersionId) {
|
||
await collectionDB.doc(this.lastVersionId).update({
|
||
stable_publish: false
|
||
})
|
||
}
|
||
uni.showToast({
|
||
title: '新增成功'
|
||
})
|
||
this.getOpenerEventChannel().emit('refreshData')
|
||
setTimeout(() => uni.navigateBack(), 500)
|
||
})
|
||
```
|
||
|
||
改为(在 `uni.showToast` 之前插入 3 行):
|
||
|
||
```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)
|
||
})
|
||
```
|
||
|
||
在 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);
|
||
}
|
||
},
|
||
```
|
||
|
||
---
|
||
|
||
## §6 异常恢复(不依赖额外页面)
|
||
|
||
### 6.1 异常场景
|
||
|
||
| 场景 | 表现 | 恢复方式 |
|
||
|------|------|----------|
|
||
| 云函数调用 Go Backend 超时 | `add.vue` 静默失败,管理员看到"发布成功" | **方式 A**:重新编辑版本并保存(触发再次同步)**方式 B**:在版本管理列表页点击"同步到下载页" |
|
||
| Go Backend 宕机数小时后恢复 | PG 中数据过期 | 同上 |
|
||
| 云函数代码 bug | 每次同步都失败 | 修复云函数后重新触发 |
|
||
|
||
### 6.2 恢复方式 A:重新保存版本(零开发成本)
|
||
|
||
在 uni-admin 版本详情页打开已有版本,不做修改直接点"保存"(如果 `add.vue` 的更新逻辑与新增走同一路径)。或者修改版本号再发布一次。
|
||
|
||
### 6.3 恢复方式 B:版本列表页添加"同步到下载页"按钮(可选,P1)
|
||
|
||
在 `version/list.vue` 中,为每个已上线的 `native_app` 版本行添加"同步到下载页"操作按钮(调用同一云函数)。这是轻量改动,无需独立页面。
|
||
|
||
> §6(旧版设计)中规划的独立手动同步页 `download-sync.vue` 已移除——其设计存在逻辑不自洽(网络不通时无法获取 Go Backend 数据),且过度设计。上述 A/B 两种恢复方式即可覆盖异常场景。
|
||
|
||
---
|
||
|
||
## §7 HTML 分享页
|
||
|
||
### 7.1 完整代码
|
||
|
||
**页面位置**:`frontend/static/html/download.html`(最终部署到 CDN 或 Nginx 静态目录)
|
||
|
||
```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;
|
||
}
|
||
.badge {
|
||
display: inline-block;
|
||
font-size: 11px;
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
margin-left: 6px;
|
||
vertical-align: middle;
|
||
}
|
||
.badge-wgt { background: #ff9800; color: #000; }
|
||
.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) {
|
||
// 收集所有平台的 native_app 数据
|
||
['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>
|
||
```
|
||
|
||
### 7.2 设计要点
|
||
|
||
| 要点 | 说明 |
|
||
|------|------|
|
||
| **移动端优先** | 分享页主要在微信/浏览器中打开,布局以手机屏幕为基准,禁用缩放 |
|
||
| **版本号展示** | 显示各平台最新版本号,用"|"分隔 |
|
||
| **优雅降级** | API 失败时按钮保持 disabled 状态(灰色 + 不可点击),版本区显示友好提示 |
|
||
| **HTTP 错误处理** | `fetch` 后先检查 `res.ok`,非 200 抛出异常走 catch 分支 |
|
||
| **按钮安全** | 默认 `href="javascript:void(0)"`,仅在获取到 URL 后才设置为真实地址 |
|
||
| **type 过滤** | 后端只返回 `native_app`,前端无需额外过滤 |
|
||
| **缓存策略** | HTML 页面服务端设置 `Cache-Control: max-age=300`(5 分钟) |
|
||
| **部署路径** | `https://h5.topfans.com/download` 或 `https://www.topfans.com/download.html` |
|
||
|
||
---
|
||
|
||
## §8 完整数据流
|
||
|
||
### 8.1 正常发布流程(自动同步)
|
||
|
||
```
|
||
时间轴 →
|
||
0s 管理员在 uni-admin 填写新版本信息,点击"发布"
|
||
0.1s 数据写入 uniCloud opendb-app-versions(原有逻辑)
|
||
0.2s submitForm 成功后调用云函数 sync-download-urls
|
||
0.3s 云函数查询 Android + iOS 最新 stable_publish 记录
|
||
0.5s 云函数 POST /api/v1/admin/app/versions/sync → Go Backend
|
||
0.6s Go Backend upsert 到 PostgreSQL app_download_configs
|
||
0.6s 完成!HTML 分享页下次加载时会获取到新地址
|
||
|
||
用户访问 HTML 分享页:
|
||
0s 页面加载,JS 执行 fetch("/api/v1/app/download-urls")
|
||
0.05s Go Backend 查询 PostgreSQL(WHERE type = 'native_app'),返回最新 URL
|
||
0.06s JS 渲染下载按钮 href
|
||
```
|
||
|
||
### 8.2 异常恢复流程
|
||
|
||
```
|
||
场景:Go Backend 短暂不可用,云函数同步失败
|
||
1. 云函数返回 { synced: false, error: "connection timeout" }
|
||
2. uni-admin 侧静默失败(管理员看到"发布成功",同步失败不影响发布流程)
|
||
3. 恢复方式 A:管理员在版本管理列表找到该版本 → 编辑 → 重新保存(触发再次同步)
|
||
4. 恢复方式 B(未来可选):点击版本行的"同步到下载页"按钮
|
||
```
|
||
|
||
---
|
||
|
||
## §9 部署与配置
|
||
|
||
### 9.1 Go Backend 侧
|
||
|
||
| 步骤 | 操作 |
|
||
|------|------|
|
||
| 1 | 执行 migration: `psql -h <host> -U <user> -d topfans -f backend/migrations/2026_07_08_001_app_download_configs.sql` |
|
||
| 2 | 部署新代码,确认路由 `/api/v1/app/download-urls` 和 `/api/v1/admin/app/versions/sync` 生效 |
|
||
| 3 | 为公开接口配置 rate limit(防刷),建议 100 req/min |
|
||
| 4 | 确认 Nginx 已对 `/api/v1/admin/*` 做 IP 白名单限制(仅允许 uniCloud 出口 IP 或内网 IP) |
|
||
|
||
### 9.2 uniCloud 侧
|
||
|
||
| 步骤 | 操作 |
|
||
|------|------|
|
||
| 1 | 上传云函数 `sync-download-urls` 到 uniCloud |
|
||
| 2 | 在 uniCloud 控制台配置云函数**环境变量** `BACKEND_URL` = Go Backend 地址(如 `https://api.topfans.com`) |
|
||
| 3 | 确认云函数有权访问 `opendb-app-versions` 表 |
|
||
| 4 | 确认 uniCloud 出口网络能访问 Go Backend 的 admin 接口 |
|
||
|
||
### 9.3 网络连通性验证(关键!)
|
||
|
||
uniCloud 云函数 → Go Backend 的网络路径:
|
||
|
||
```
|
||
uniCloud (阿里云/支付宝云) → 公网/专线 → Go Backend (k8s/服务器)
|
||
```
|
||
|
||
**验证命令**(在云函数中临时执行):
|
||
|
||
```javascript
|
||
// 在 uniCloud 云函数控制台执行,测试到 Go Backend 的连通性
|
||
const res = await uniCloud.httpclient.request(
|
||
`${process.env.BACKEND_URL}/health`, // 复用现有 health check
|
||
{ method: 'GET', timeout: 5000 }
|
||
);
|
||
console.log('status:', res.status); // 期望 200
|
||
```
|
||
|
||
**如果不能连通**:
|
||
- 检查 Go Backend 的 Nginx/防火墙是否放行了 uniCloud 出口 IP
|
||
- 或者改用方案 B:创建 URL-ified 云函数,Go Backend 侧定时拉取(改动较大,仅在 push 模式不可行时考虑)
|
||
|
||
### 9.4 HTML 页面部署
|
||
|
||
- HTML 文件可部署到 Nginx、CDN 或 OSS 静态托管
|
||
- 建议路径:`https://h5.topfans.com/download`
|
||
- Nginx 配置示例:
|
||
```nginx
|
||
location /download {
|
||
alias /var/www/topfans/download.html;
|
||
add_header Cache-Control "public, max-age=300";
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## §10 目录与文件清单
|
||
|
||
### 新增文件
|
||
|
||
```
|
||
backend/
|
||
├── migrations/
|
||
│ └── 2026_07_08_001_app_download_configs.sql # 建表 migration
|
||
├── pkg/models/
|
||
│ └── app_download_config.go # GORM model + 包类型常量
|
||
├── gateway/
|
||
│ ├── controller/
|
||
│ │ ├── app_download_controller.go # handler: DTO + 两个接口(含 Swagger 注解)
|
||
│ │ └── app_download_controller_test.go # controller 集成测试
|
||
│ ├── service/
|
||
│ │ ├── app_download_service.go # 业务层: sync + query(含请求 DTO)
|
||
│ │ └── app_download_service_test.go # service 单元测试
|
||
│ └── repository/
|
||
│ ├── app_download_repository.go # 数据层: upsert + find
|
||
│ └── app_download_repository_test.go # repository 单元测试
|
||
|
||
uni-admin/
|
||
├── uniCloud-alipay/cloudfunctions/
|
||
│ └── sync-download-urls/
|
||
│ ├── index.js # 云函数主逻辑
|
||
│ └── package.json # 云函数配置
|
||
|
||
frontend/static/html/
|
||
└── download.html # 分享下载页
|
||
```
|
||
|
||
### 修改文件
|
||
|
||
```
|
||
backend/gateway/router/router.go # 注册新路由 + 三层装配(§4.5)
|
||
uni-admin/uni_modules/uni-upgrade-center/pages/version/add.vue # submitForm 后触发同步(§5.3)
|
||
```
|
||
|
||
---
|
||
|
||
## §11 实施步骤
|
||
|
||
### 阶段 A:Go Backend(优先级 P0)
|
||
|
||
| # | 任务 | 预估 | 产出 |
|
||
|---|------|------|------|
|
||
| A1 | 编写 migration SQL | 15min | `2026_07_08_001_app_download_configs.sql` |
|
||
| A2 | 编写 Go Model + 常量 | 10min | `app_download_config.go` |
|
||
| A3 | 编写 Repository(含 struct 定义 + 构造函数) | 20min | `app_download_repository.go` |
|
||
| A4 | 编写 Repository 单元测试(2 个用例) | 20min | `app_download_repository_test.go` |
|
||
| A5 | 编写 Service(含请求 DTO + 构造函数) | 20min | `app_download_service.go` |
|
||
| A6 | 编写 Service 单元测试(2 个用例) | 20min | `app_download_service_test.go` |
|
||
| A7 | 编写 Controller + DTO + Swagger 注解 | 25min | `app_download_controller.go` |
|
||
| A8 | 编写 Controller 集成测试(4 个用例) | 25min | `app_download_controller_test.go` |
|
||
| A9 | 在 `SetupRouter` 中三层装配 + 注册路由 | 15min | `router.go` 修改 |
|
||
| A10 | `swag init` + `go build` + 本地测试(curl 两条 API) | 15min | 验证 |
|
||
|
||
> **Phase A 小计**:~3h05min(较初版增加 ~1h15min 测试时间)
|
||
|
||
### 阶段 B:uniCloud 自动同步(优先级 P0)
|
||
|
||
| # | 任务 | 预估 | 产出 |
|
||
|---|------|------|------|
|
||
| B1 | 编写云函数 `sync-download-urls` | 30min | `index.js` + `package.json` |
|
||
| B2 | 上传云函数 + 配置环境变量 `BACKEND_URL` | 10min | |
|
||
| B3 | 修改 `add.vue`:`.then()` 回调中插入同步调用 + 新增 methods | 15min | `add.vue` 修改 |
|
||
| B4 | 端到端测试(发布版本 → 查云函数日志 → curl Go Backend 验证) | 20min | 验证 |
|
||
|
||
> **Phase B 小计**:~1h15min
|
||
|
||
### 阶段 C:HTML 分享页(优先级 P0)
|
||
|
||
| # | 任务 | 预估 | 产出 |
|
||
|---|------|------|------|
|
||
| C1 | 编写 `download.html` | 30min | 完整 HTML |
|
||
| C2 | 部署到 Nginx/CDN + 浏览器/手机测试 | 15min | |
|
||
|
||
> **Phase C 小计**:~45min
|
||
|
||
### 阶段 D:运维配置(优先级 P1)
|
||
|
||
| # | 任务 | 预估 | 产出 |
|
||
|---|------|------|------|
|
||
| D1 | Nginx rate limit 配置(公开接口防刷) | 10min | |
|
||
| D2 | 网络连通性验证(§9.3 命令) | 10min | |
|
||
|
||
> **Phase D 小计**:~20min
|
||
|
||
### 总预估:~5h30min
|
||
|
||
---
|
||
|
||
## §12 考虑与讨论
|
||
|
||
### 12.1 为什么不直接让 HTML 调 uniCloud URL-ified 云函数?
|
||
|
||
**优点**:更简单,不需要经过 Go Backend,不需要新建 PG 表
|
||
|
||
**缺点(致命)**:
|
||
1. **可靠性**:uniCloud URL-ified 云函数的公网可达性不如自己的 Go Backend
|
||
2. **性能**:云函数冷启动延迟 200ms-2s,影响分享页加载体验
|
||
3. **可控性**:Go Backend 可以做缓存、限流、监控;云函数这些都需要额外配置
|
||
4. **架构一致性**:项目所有对外 API 都在 Go Backend,走云函数是开一个例外
|
||
|
||
### 12.2 为什么用 push 而不是 poll?
|
||
|
||
**Push(云函数主动推)**:
|
||
- 实时性好:发布版本后立即同步
|
||
- 无浪费:只有在版本变更时才触发
|
||
|
||
**Poll(定时拉)**:
|
||
- 需要 Go Backend 定时查询 uniCloud
|
||
- 浪费资源(大部分时间没有新版本)
|
||
- 引入了反向依赖(Go Backend → uniCloud)
|
||
|
||
### 12.3 缓存策略
|
||
|
||
- **HTML 页面**:CDN/Nginx 缓存 5 分钟(`Cache-Control: max-age=300`)
|
||
- **API 响应**:Go Backend 不设置缓存(由 HTML 页面自行控制刷新频率)
|
||
- **未来优化**:可在 Go Backend 内存中缓存 60s,减少 PG 查询
|
||
|
||
### 12.4 安全考量
|
||
|
||
- `GET /api/v1/app/download-urls` 无需认证,需配置 rate limit(防刷),建议 100 req/min
|
||
- `POST /api/v1/admin/app/versions/sync` 走现有 admin 路由组,依赖 Nginx IP 白名单保护
|
||
- 云函数 `BACKEND_URL` 环境变量中不应包含敏感路径或凭据
|
||
|
||
### 12.5 为什么 Gateway 直连 PG 而不是走 Dubbo RPC?
|
||
|
||
`app_download_configs` 表的特点:
|
||
- 只有 **2 条记录**(android + ios)
|
||
- 只做简单 KV 读写(upsert + select)
|
||
- 不需要跨服务事务编排
|
||
- 只在 gateway 层使用,没有其他 service 需要访问
|
||
|
||
如果为此创建一个 Dubbo RPC service(proto 定义 + server 实现 + client proxy + 注册中心),代码量约 300-400 行,是当前方案(约 150 行)的 2-3 倍,**0 业务价值**。
|
||
|
||
这遵循 CLAUDE.md 的 MVP 先行原则:**不为"未来可能"的复杂度提前建设抽象层**。如果未来有多服务需要读写此表,再抽成独立 RPC service。
|
||
|
||
---
|
||
|
||
## §13 测试策略
|
||
|
||
### 13.1 测试范围
|
||
|
||
按 CLAUDE.md 接口开发规范,本功能测试覆盖如下:
|
||
|
||
| 层级 | 测试类型 | 覆盖内容 | 工具 |
|
||
|------|---------|---------|------|
|
||
| Repository | 单元测试 | `FindByType` / `UpsertAll` 的 SQL 正确性 | `go test` + SQLite 内存库 或 PG 测试容器 |
|
||
| Service | 单元测试 | `GetAllNativeApp` 返回过滤 / `SyncVersion` upsert 逻辑 | `go test` + mock repository |
|
||
| Controller | 集成测试 | GET 公开接口 happy path + empty case;POST admin 接口参数校验 + 正常同步 | `go test` + `httptest` + mock service |
|
||
|
||
### 13.2 Repository 测试
|
||
|
||
**文件**:`backend/gateway/repository/app_download_repository_test.go`
|
||
|
||
```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 {
|
||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||
assert.NoError(t, err)
|
||
db.AutoMigrate(&models.AppDownloadConfig{})
|
||
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 TestUpsertAll_InsertAndUpdate(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)
|
||
}
|
||
```
|
||
|
||
### 13.3 Service 测试
|
||
|
||
**文件**:`backend/gateway/service/app_download_service_test.go`
|
||
|
||
```go
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/mock"
|
||
"github.com/topfans/backend/pkg/models"
|
||
)
|
||
|
||
// mock repository
|
||
type mockRepo struct {
|
||
mock.Mock
|
||
}
|
||
|
||
func (m *mockRepo) FindByType(ctx context.Context, pkgType string) ([]models.AppDownloadConfig, error) {
|
||
args := m.Called(ctx, pkgType)
|
||
return args.Get(0).([]models.AppDownloadConfig), args.Error(1)
|
||
}
|
||
|
||
func (m *mockRepo) UpsertAll(ctx context.Context, configs []models.AppDownloadConfig) error {
|
||
args := m.Called(ctx, configs)
|
||
return args.Error(0)
|
||
}
|
||
|
||
func TestGetAllNativeApp_ReturnsFiltered(t *testing.T) {
|
||
repo := new(mockRepo)
|
||
svc := NewAppDownloadService(repo) // 注:需要用 interface 替代具体类型,见下方说明
|
||
|
||
expected := []models.AppDownloadConfig{
|
||
{Platform: "android", Type: "native_app", DownloadURL: "https://cdn.example.com/app.apk"},
|
||
}
|
||
repo.On("FindByType", mock.Anything, models.AppPackageTypeNativeApp).Return(expected, nil)
|
||
|
||
configs, err := svc.GetAllNativeApp(context.Background())
|
||
assert.NoError(t, err)
|
||
assert.Len(t, configs, 1)
|
||
assert.Equal(t, "native_app", configs[0].Type)
|
||
}
|
||
|
||
func TestSyncVersion_BothPlatforms(t *testing.T) {
|
||
repo := new(mockRepo)
|
||
svc := NewAppDownloadService(repo)
|
||
|
||
repo.On("UpsertAll", mock.Anything, mock.MatchedBy(func(configs []models.AppDownloadConfig) bool {
|
||
return len(configs) == 2
|
||
})).Return(nil)
|
||
|
||
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)
|
||
repo.AssertExpectations(t)
|
||
}
|
||
```
|
||
|
||
> **注意**:以上 mock 示例依赖具体 struct,生产代码建议将 `AppDownloadService.repo` 改为 interface 类型以支持 mock 注入。具体做法:在 `app_download_service.go` 中定义 `AppDownloadRepository interface { FindByType(...); UpsertAll(...) }`,`AppDownloadService` 持有该 interface。
|
||
|
||
### 13.4 Controller 测试
|
||
|
||
**文件**:`backend/gateway/controller/app_download_controller_test.go`
|
||
|
||
```go
|
||
package controller
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/topfans/backend/pkg/models"
|
||
)
|
||
|
||
// 注:与 service 层同理,controller 也应依赖 interface 以便注入 mock service
|
||
|
||
func TestGetDownloadUrls_Success(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
// mockSvc := new(MockAppDownloadService)
|
||
// mockSvc.On("GetAllNativeApp", ...).Return(...)
|
||
// ctrl := NewAppDownloadController(mockSvc)
|
||
|
||
// w := httptest.NewRecorder()
|
||
// req, _ := http.NewRequest("GET", "/api/v1/app/download-urls", nil)
|
||
// router := gin.New()
|
||
// router.GET("/api/v1/app/download-urls", ctrl.GetDownloadUrls)
|
||
// 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"])
|
||
}
|
||
|
||
func TestGetDownloadUrls_EmptyData(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
// mockSvc := new(MockAppDownloadService)
|
||
// mockSvc.On("GetAllNativeApp", ...).Return([]models.AppDownloadConfig{}, nil)
|
||
// ... 验证 android/ios 键均为 null,HTTP 200
|
||
}
|
||
|
||
func TestSyncVersion_InvalidJSON(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
// ... 发送非法 JSON,验证 HTTP 400
|
||
}
|
||
|
||
func TestSyncVersion_Success(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
// ... 发送合法 JSON,验证 HTTP 200
|
||
}
|
||
```
|
||
|
||
> Controller 测试骨架已给出。实际编写时注入 mock service 即可。
|
||
|
||
### 13.5 测试运行
|
||
|
||
```bash
|
||
# 运行全部 app_download 相关测试
|
||
cd backend/gateway
|
||
go test ./repository/ -run AppDownload -v
|
||
go test ./service/ -run AppDownload -v
|
||
go test ./controller/ -run AppDownload -v
|
||
|
||
# 带覆盖率
|
||
go test ./repository/ ./service/ ./controller/ -coverprofile=coverage.out
|
||
go tool cover -func=coverage.out | grep app_download
|
||
```
|
||
|
||
---
|
||
|
||
## §14 Swagger API 文档
|
||
|
||
### 14.1 注解位置
|
||
|
||
Swagger 注解已内嵌在 §4.4.2 Controller 代码中(`@Summary` / `@Tags` / `@Param` / `@Success` / `@Router`)。执行以下命令自动生成文档:
|
||
|
||
```bash
|
||
cd backend/gateway
|
||
swag init --parseDependency --parseInternal
|
||
```
|
||
|
||
### 14.2 生成后的文档
|
||
|
||
生成后 Swagger UI 可在 `https://api.topfans.com/swagger/index.html` 查看:
|
||
|
||
| 接口 | Tags | 认证 |
|
||
|------|------|------|
|
||
| `GET /api/v1/app/download-urls` | App下载页 | 无 |
|
||
| `POST /api/v1/admin/app/versions/sync` | Admin | Nginx IP 白名单 |
|
||
|
||
### 14.3 错误码
|
||
|
||
| HTTP 状态码 | body.code | 说明 |
|
||
|------------|-----------|------|
|
||
| 200 | 0 | 成功 |
|
||
| 400 | 400 | 请求参数错误(`type` 不在枚举范围 / JSON 格式错误 / 必填字段缺失) |
|
||
| 500 | 500 | 服务器内部错误(PG 连接异常 / upsert 失败) |
|
||
|
||
---
|
||
|
||
## §15 自审清单(修订版)
|
||
|
||
### 修改的章节:§1-§14(全部重写 + 新增 §13 测试策略、§14 Swagger 文档)
|
||
|
||
### 未改动的章节:无
|
||
|
||
### 跨章节引用一致性
|
||
|
||
- [x] §2.1 数据模型与 §3.1 migration 字段一致(含 `created_at` + `type`)
|
||
- [x] §2.1 数据模型与 §3.2 Go model 一致
|
||
- [x] §2.2 同步链路的 payload 格式与 §4.3 请求体一致(嵌套对象,非平铺字段)
|
||
- [x] §2.3 读取链路查询条件(`WHERE type = 'native_app'`)与 §4.4.4 repository 的 `FindByType` 一致
|
||
- [x] §1 mermaid 图写 "HTTP POST" 与 §4.1 接口定义、§4.3 请求体一致
|
||
- [x] §1 核心架构图与 §3.1 表结构一致(含 `type` 字段)
|
||
- [x] §4.4.1 DTO `DownloadUrlResponse` 结构与 §4.4.2 Controller 组装逻辑一致
|
||
- [x] §4.4.3 Service `SyncVersionRequest` / `PlatformVersionInfo` 与 §4.4.2 Controller 引用一致(`service.SyncVersionRequest`)
|
||
- [x] §4.4.3 Service 请求 DTO 与 §5.1 云函数 payload 一致(URL + Version + Type)
|
||
- [x] §5.1 云函数使用 `process.env.BACKEND_URL` 与 §5.2 注释、§9.2 部署步骤一致(均为环境变量方式)
|
||
- [x] §10 文件清单与 §4.4 分层目录、§3.2 model 路径、§7.1 HTML 路径、§13 测试文件一致
|
||
- [x] §11 实施步骤中的文件名与 §10 文件清单一致(含测试文件)
|
||
- [x] §8.2 异常恢复不再依赖已删除的独立同步页(§6),改用编辑重保存 + 列表按钮
|
||
- [x] §4.4 架构说明指向 §12.5(gateway 直连 PG 的决策理由)
|
||
- [x] §14 Swagger 注解与 §4.4.2 Controller 代码内嵌的 `@Summary` / `@Tags` / `@Router` 一致
|
||
- [x] §13 测试文件路径与 §10 文件清单一致
|
||
|
||
### Go 代码完整性
|
||
|
||
- [x] 请求 DTO 定义在 service 包(`SyncVersionRequest` / `PlatformVersionInfo`),避免 controller ↔ service 循环依赖
|
||
- [x] 响应 DTO(`DownloadUrlResponse`)定义在 controller 包
|
||
- [x] Controller / Service / Repository 均有 constructor(NewXxx)函数
|
||
- [x] Repository struct 的 `db *gorm.DB` 字段已定义,通过 `database.GetDB()` 注入
|
||
- [x] Controller 使用 `response.Success` / `response.BadRequest` / `response.InternalError`(与项目统一响应包一致)
|
||
- [x] Controller constructor 注明不返回 error 的原因(无 Dubbo 依赖,与 `NewSegmentController` 等一致)
|
||
- [x] 所有文件 import 块完整(`context`, `time`, `gorm.io/gorm`, models, service, repository, response)
|
||
- [x] `UpsertAll` 使用 `Assign(map[string]interface{}{...})` 避免覆盖 `created_at`
|
||
- [x] sync API 的 `type` 字段带 `binding:"required,oneof=native_app wgt"` 校验
|
||
|
||
### 测试代码完整性
|
||
|
||
- [x] Repository 测试覆盖 `FindByType` 过滤逻辑 + `UpsertAll` insert/update
|
||
- [x] Service 测试覆盖 `GetAllNativeApp` 过滤 + `SyncVersion` 双平台场景
|
||
- [x] Controller 测试骨架覆盖 4 个场景(happy path / empty / invalid / success)
|
||
- [x] 测试建议将 repo/svc 改为 interface 以支持 mock 注入
|
||
|
||
### Vue / JS 代码完整性
|
||
|
||
- [x] `add.vue` 改为 diff 风格(标注原代码位置 + 修改后的代码块),不会覆盖原有 `submitForm` 逻辑
|
||
- [x] `syncDownloadUrls()` 是独立方法,插入现有 methods 块
|
||
- [x] HTML `fetch` 先检查 `res.ok`
|
||
- [x] HTML 版本号字符串拼接正确(取 platforms 数组,支持多平台同时显示)
|
||
- [x] HTML 按钮默认 `href="javascript:void(0)"`,API 失败时保持不可点击
|
||
|
||
### 其他
|
||
|
||
- [x] §5.2 package.json 去掉了不存在的 `uni-cloud-db` 依赖
|
||
- [x] Controller 使用 `response` 包统一响应格式(非裸 `c.JSON` + 魔法数字)
|
||
- [x] 文档开头方案概述(§1)符合 CLAUDE.md 要求
|
||
- [x] 未违反 MVP 原则(§12.5 充分论证 gateway 直连 PG)
|
||
- [x] Admin 路由与现有 group 集成(不新建 group)
|
||
- [x] 装配在 `SetupRouter` 内部完成(不修改 `main.go` / `SetupRouter` 签名)
|
||
- [x] Swagger 注解与 `swag init` 生成流程已说明
|
||
- [x] 测试运行命令已给出
|