# 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 数据库。需要一个自动同步机制,让下载地址始终保持最新 | ### 整体实现路径 ```mermaid graph LR A[uni-admin
发布新版本] -->|① 触发| B[uniCloud 云函数
sync-download-urls] B -->|② 查询最新 URL| C[opendb-app-versions] B -->|③ HTTP POST| D[Go Backend
POST /api/v1/admin/app/versions/sync] D -->|④ 写入| E[(PostgreSQL
app_download_configs)] F[HTML 分享页] -->|⑤ GET 请求| G[Go Backend
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 ( "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 批量 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` 路由组中添加: ```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 TopFans — 下载
粉丝共创平台
加载中…
🤖 Android 下载  iOS 下载
``` ### 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 -U -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 + 两个接口 │ ├── 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 表 **缺点(致命)**: 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 自审清单(修订版) ### 修改的章节:全部(§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.5(gateway 直连 PG 的决策理由) ### Go 代码完整性 - [x] DTO `SyncVersionRequest` / `PlatformVersionInfo` / `DownloadUrlResponse` 有完整 struct 定义(§4.4.1) - [x] Controller / Service / Repository 均有 constructor(NewXxx)函数 - [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 一致