topfans/docs/specs/2026-07-08-app-download-page-design.md

1199 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# App 下载分享页 — 自动同步方案设计
> **文档状态**Draft · 2026-07-08
> **适用版本**topfans v1.0.5+
> **目标读者**:后端 / uni-admin / 前端开发
---
## §1 方案概述(*必读*
### 要解决的问题
| 问题 | 说明 |
|------|------|
| **业务问题** | 需要一个 HTML 分享页,展示 Android/iOS 下载按钮。但 APK/IPA 的下载地址会随着版本更新而变化,不能硬编码在 HTML 里 |
| **技术问题** | 下载地址的来源是 uni-adminuniCloud `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/syncAdmin 内部接口)
> 复用现有 `/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 (
"net/http"
"github.com/gin-gonic/gin"
"github.com/topfans/backend/gateway/service"
)
// SyncVersionRequest uniCloud 同步请求体
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"`
}
// DownloadUrlResponse 下载页公开接口响应
type DownloadUrlResponse struct {
URL string `json:"url"`
Version string `json:"version"`
Type string `json:"type"`
}
// AppDownloadController App下载页控制器
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
// 只返回 type = native_app 的记录
func (ctrl *AppDownloadController) GetDownloadUrls(c *gin.Context) {
ctx := c.Request.Context()
configs, err := ctrl.svc.GetAllNativeApp(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"message": "internal error",
})
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
}
}
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": data})
}
// SyncVersion POST /api/v1/admin/app/versions/sync
func (ctrl *AppDownloadController) SyncVersion(c *gin.Context) {
var req SyncVersionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"message": "invalid request: " + err.Error(),
})
return
}
ctx := c.Request.Context()
if err := ctrl.svc.SyncVersion(ctx, &req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"message": "sync failed",
})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok"})
}
```
#### 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"
)
// 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 批量 upsertplatform + type 唯一)
func (r *AppDownloadRepository) UpsertAll(ctx context.Context, configs []models.AppDownloadConfig) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, cfg := range configs {
if err := tx.Where("platform = ? AND type = ?", cfg.Platform, cfg.Type).
Assign(map[string]interface{}{
"download_url": cfg.DownloadURL,
"version": cfg.Version,
"updated_at": cfg.UpdatedAt,
}).
FirstOrCreate(&cfg).Error; err != nil {
return err
}
}
return nil
})
}
```
### 4.5 路由注册
`router.go` `v1` 路由组中添加
```go
// ============ App 下载页接口 ============
// 公开接口 — HTML 分享页使用
v1.GET("/app/download-urls", appDownloadCtrl.GetDownloadUrls)
// Admin 内部接口 — uniCloud 云函数同步使用
admin := v1.Group("/admin")
{
admin.POST("/notifications", notificationCtrl.AdminCreateNotification)
admin.POST("/app/versions/sync", appDownloadCtrl.SyncVersion) // ← 新增
}
```
### 4.6 main.go 装配(依赖注入)
`gateway/main.go` 中添加
```go
// App 下载页 — 三层装配
appDownloadRepo := repository.NewAppDownloadRepository(db)
appDownloadSvc := service.NewAppDownloadService(appDownloadRepo)
appDownloadCtrl := controller.NewAppDownloadController(appDownloadSvc)
```
> 如果 gateway 目前没有直连 PG`db *gorm.DB`),需要先初始化一个只读连接。详见 §12.5。
---
## §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 查询 PostgreSQLWHERE 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 文件可部署到 NginxCDN 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 + 两个接口
│ ├── service/
│ │ └── app_download_service.go # 业务层: sync + query
│ └── repository/
│ └── app_download_repository.go # 数据层: upsert + find
uni-admin/
├── uniCloud-alipay/cloudfunctions/
│ └── sync-download-urls/
│ ├── index.js # 云函数主逻辑
│ └── package.json # 云函数配置
frontend/static/html/
└── download.html # 分享下载页
```
### 修改文件
```
backend/gateway/router/router.go # 注册新路由
backend/gateway/main.go # 装配依赖注入(或等效入口文件)
uni-admin/uni_modules/uni-upgrade-center/pages/version/add.vue # submitForm 后触发同步
```
---
## §11 实施步骤
### 阶段 AGo 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 | 编写 Service | 15min | `app_download_service.go` |
| A5 | 编写 Controller + DTO + 构造函数 | 20min | `app_download_controller.go` |
| A6 | 注册路由 + main.go 装配 | 15min | `router.go` + `main.go` 修改 |
| A7 | `go build` + 本地测试curl 两条 API | 15min | 验证 |
> **Phase A 小计**~1h50min
### 阶段 BuniCloud 自动同步(优先级 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
### 阶段 CHTML 分享页(优先级 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
### 总预估:~4h
---
## §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 serviceproto 定义 + server 实现 + client proxy + 注册中心代码量约 300-400 是当前方案 150 2-3 **0 业务价值**。
这遵循 CLAUDE.md MVP 先行原则**不为"未来可能"的复杂度提前建设抽象层**。如果未来有多服务需要读写此表再抽成独立 RPC service
---
## §13 自审清单(修订版)
### 修改的章节全部§1-§12 重写)
### 未改动的章节:无
### 跨章节引用一致性
- [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 `PlatformVersionInfo` 结构URL + Version + Type §5.1 云函数 payload 一致
- [x] §5.1 云函数使用 `process.env.BACKEND_URL` §5.2 注释、§9.2 部署步骤一致均为环境变量方式
- [x] §10 文件清单与 §4.4 分层目录、§3.2 model 路径、§7.1 HTML 路径一致
- [x] §11 实施步骤中的文件名与 §10 文件清单一致
- [x] §8.2 异常恢复不再依赖已删除的独立同步页(§6改用编辑重保存 + 列表按钮
- [x] §4.4 架构说明指向 §12.5gateway 直连 PG 的决策理由
### Go 代码完整性
- [x] DTO `SyncVersionRequest` / `PlatformVersionInfo` / `DownloadUrlResponse` 有完整 struct 定义(§4.4.1
- [x] Controller / Service / Repository 均有 constructorNewXxx函数
- [x] Repository struct `db *gorm.DB` 字段已定义
- [x] 所有文件 import 块完整`net/http`, `context`, `time`, `gorm.io/gorm`, models, service, repository
- [x] `UpsertAll` 使用 `Assign(map[string]interface{}{...})` 避免覆盖 `created_at`
- [x] sync API `type` 字段带 `binding:"required,oneof=native_app wgt"` 校验
### 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] §4.4 controller 使用 `http.StatusOK` 等常量而非魔法数字
- [x] 文档开头方案概述(§1符合 CLAUDE.md 要求
- [x] 未违反 MVP 原则
- [x] Admin 路由与现有 pattern 一致