44 KiB
App 下载分享页 — 自动同步方案设计
文档状态:Draft · 2026-07-08 适用版本:topfans v1.0.5+ 目标读者:后端 / uni-admin / 前端开发
§1 方案概述(必读)
要解决的问题
| 问题 | 说明 |
|---|---|
| 业务问题 | 需要一个 HTML 分享页,展示 Android/iOS 下载按钮。但 APK/IPA 的下载地址会随着版本更新而变化,不能硬编码在 HTML 里 |
| 技术问题 | 下载地址的来源是 uni-admin(uniCloud opendb-app-versions 表),HTML 页面无法直接访问 uniCloud 数据库。需要一个自动同步机制,让下载地址始终保持最新 |
整体实现路径
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
关键路径:
- 自动同步(正常路径):uni-admin 点"发布"→ 云函数自动推送最新 URL 到 Go 后端 → 写入 PG
- 手动回退(异常路径):同步失败时,管理员在 uni-admin 版本列表页点击"重新同步到下载页"按钮重试
- 缓存兜底: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
-- 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
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(公开,无需认证)
请求:无参数
响应:
{
"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"
}
}
}
响应(数据为空时):
{
"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 白名单)
请求:
{
"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透传。
响应:
{
"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
// 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
// (续 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
// 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
// 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 的 v1 路由组中添加:
// ============ 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 中添加:
// 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
'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
{
"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 行):
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 行):
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() 之前):
/**
* 同步下载地址到 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 静态目录)
<!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/服务器)
验证命令(在云函数中临时执行):
// 在 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 配置示例:
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 实施步骤
阶段 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 | 编写 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
阶段 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
总预估:~4h
§12 考虑与讨论
12.1 为什么不直接让 HTML 调 uniCloud URL-ified 云函数?
优点:更简单,不需要经过 Go Backend,不需要新建 PG 表
缺点(致命):
- 可靠性:uniCloud URL-ified 云函数的公网可达性不如自己的 Go Backend
- 性能:云函数冷启动延迟 200ms-2s,影响分享页加载体验
- 可控性:Go Backend 可以做缓存、限流、监控;云函数这些都需要额外配置
- 架构一致性:项目所有对外 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/minPOST /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 自审清单(修订版)
修改的章节:全部(§1-§12 重写)
未改动的章节:无
跨章节引用一致性
- §2.1 数据模型与 §3.1 migration 字段一致(含
created_at+type) - §2.1 数据模型与 §3.2 Go model 一致
- §2.2 同步链路的 payload 格式与 §4.3 请求体一致(嵌套对象,非平铺字段)
- §2.3 读取链路查询条件(
WHERE type = 'native_app')与 §4.4.4 repository 的FindByType一致 - §1 mermaid 图写 "HTTP POST" 与 §4.1 接口定义、§4.3 请求体一致
- §1 核心架构图与 §3.1 表结构一致(含
type字段) - §4.4.1 DTO
PlatformVersionInfo结构(URL + Version + Type)与 §5.1 云函数 payload 一致 - §5.1 云函数使用
process.env.BACKEND_URL与 §5.2 注释、§9.2 部署步骤一致(均为环境变量方式) - §10 文件清单与 §4.4 分层目录、§3.2 model 路径、§7.1 HTML 路径一致
- §11 实施步骤中的文件名与 §10 文件清单一致
- §8.2 异常恢复不再依赖已删除的独立同步页(§6),改用编辑重保存 + 列表按钮
- §4.4 架构说明指向 §12.5(gateway 直连 PG 的决策理由)
Go 代码完整性
- DTO
SyncVersionRequest/PlatformVersionInfo/DownloadUrlResponse有完整 struct 定义(§4.4.1) - Controller / Service / Repository 均有 constructor(NewXxx)函数
- Repository struct 的
db *gorm.DB字段已定义 - 所有文件 import 块完整(
net/http,context,time,gorm.io/gorm, models, service, repository) UpsertAll使用Assign(map[string]interface{}{...})避免覆盖created_at- sync API 的
type字段带binding:"required,oneof=native_app wgt"校验
Vue / JS 代码完整性
add.vue改为 diff 风格(标注原代码位置 + 修改后的代码块),不会覆盖原有submitForm逻辑syncDownloadUrls()是独立方法,插入现有 methods 块- HTML
fetch先检查res.ok - HTML 版本号字符串拼接正确(取 platforms 数组,支持多平台同时显示)
- HTML 按钮默认
href="javascript:void(0)",API 失败时保持不可点击
其他
- §5.2 package.json 去掉了不存在的
uni-cloud-db依赖 - §4.4 controller 使用
http.StatusOK等常量而非魔法数字 - 文档开头方案概述(§1)符合 CLAUDE.md 要求
- 未违反 MVP 原则
- Admin 路由与现有 pattern 一致