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

55 KiB
Raw Blame History

App 下载分享页 — 自动同步方案设计

文档状态Draft v1.1 · 2026-07-08修订 2026-07-09 适用版本topfans v1.0.5+ 目标读者:后端 / uni-admin / 前端开发


§1 方案概述(必读

要解决的问题

问题 说明
业务问题 需要一个 HTML 分享页,展示 Android/iOS 下载按钮。但 APK/IPA 的下载地址会随着版本更新而变化,不能硬编码在 HTML 里
技术问题 下载地址的来源是 uni-adminuniCloud opendb-app-versionsHTML 页面无法直接访问 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

关键路径

  1. 自动同步正常路径uni-admin 点"发布"→ 云函数自动推送最新 URL 到 Go 后端 → 写入 PG
  2. 手动回退(异常路径):同步失败时,管理员在 uni-admin 版本列表页点击"重新同步到下载页"按钮重试
  3. 缓存兜底Go 后端返回 URL 时附带 updated_atHTML 页面可按需刷新

关键决策

决策 选择 原因
HTML 页调谁的 API Go Backend Go 后端部署在自己的服务器上,可靠性和可控性远高于 uniCloud URL-ified 函数
同步触发方式 uniCloud 云函数 push 发布版本后立即同步,延迟 < 1s不需要定时轮询
同步数据粒度 只同步最新 Android + iOS 的 url + version + type MVP 只做下载页需要的数据,不搬运整个版本表
数据存储 新建 app_download_configs 现有 system_configsconfig_valuefloat64,无法存字符串 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/syncAdmin 内部接口)

复用现有 /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.5app_download_configs 只有 2 条记录,仅做简单 KV 读写,不需要事务编排和跨服务调用。为此创建 Dubbo RPC service 明显过度设计。因此 repository 在 gateway 中直连 PG遵循 MVP 原则。

4.4.1 DTO

// 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

// (续 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

// 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

// 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.goSetupRouter 函数中,按以下方式集成。

Step 1 — 创建 controller(在现有 controller 初始化代码块末尾添加):

// 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 路由组内,与其他公开路由并列添加):

// App 下载页 — HTML 分享页使用(公开,无需认证)
v1.GET("/app/download-urls", appDownloadCtrl.GetDownloadUrls)

Step 3 — 注册 Admin 内部路由(在现有 admin := v1.Group("/admin") 块内添加一行):

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

'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.vuedbOperate.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=3005 分钟)
部署路径 https://h5.topfans.com/downloadhttps://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/服务器)

验证命令(在云函数中临时执行):

// 在 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 + 两个接口(含 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 实施步骤

阶段 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 编写 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 测试时间)

阶段 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

总预估:~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 serviceproto 定义 + 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 casePOST admin 接口参数校验 + 正常同步 go test + httptest + mock service

13.2 Repository 测试

文件backend/gateway/repository/app_download_repository_test.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

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

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 键均为 nullHTTP 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 测试运行

# 运行全部 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)。执行以下命令自动生成文档:

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 文档)

未改动的章节:无

跨章节引用一致性

  • §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 DownloadUrlResponse 结构与 §4.4.2 Controller 组装逻辑一致
  • §4.4.3 Service SyncVersionRequest / PlatformVersionInfo 与 §4.4.2 Controller 引用一致(service.SyncVersionRequest
  • §4.4.3 Service 请求 DTO 与 §5.1 云函数 payload 一致URL + Version + Type
  • §5.1 云函数使用 process.env.BACKEND_URL 与 §5.2 注释、§9.2 部署步骤一致(均为环境变量方式)
  • §10 文件清单与 §4.4 分层目录、§3.2 model 路径、§7.1 HTML 路径、§13 测试文件一致
  • §11 实施步骤中的文件名与 §10 文件清单一致(含测试文件)
  • §8.2 异常恢复不再依赖已删除的独立同步页§6改用编辑重保存 + 列表按钮
  • §4.4 架构说明指向 §12.5gateway 直连 PG 的决策理由)
  • §14 Swagger 注解与 §4.4.2 Controller 代码内嵌的 @Summary / @Tags / @Router 一致
  • §13 测试文件路径与 §10 文件清单一致

Go 代码完整性

  • 请求 DTO 定义在 service 包(SyncVersionRequest / PlatformVersionInfo),避免 controller ↔ service 循环依赖
  • 响应 DTODownloadUrlResponse)定义在 controller 包
  • Controller / Service / Repository 均有 constructorNewXxx函数
  • Repository struct 的 db *gorm.DB 字段已定义,通过 database.GetDB() 注入
  • Controller 使用 response.Success / response.BadRequest / response.InternalError(与项目统一响应包一致)
  • Controller constructor 注明不返回 error 的原因(无 Dubbo 依赖,与 NewSegmentController 等一致)
  • 所有文件 import 块完整(context, time, gorm.io/gorm, models, service, repository, response
  • UpsertAll 使用 Assign(map[string]interface{}{...}) 避免覆盖 created_at
  • sync API 的 type 字段带 binding:"required,oneof=native_app wgt" 校验

测试代码完整性

  • Repository 测试覆盖 FindByType 过滤逻辑 + UpsertAll insert/update
  • Service 测试覆盖 GetAllNativeApp 过滤 + SyncVersion 双平台场景
  • Controller 测试骨架覆盖 4 个场景happy path / empty / invalid / success
  • 测试建议将 repo/svc 改为 interface 以支持 mock 注入

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 依赖
  • Controller 使用 response 包统一响应格式(非裸 c.JSON + 魔法数字)
  • 文档开头方案概述§1符合 CLAUDE.md 要求
  • 未违反 MVP 原则§12.5 充分论证 gateway 直连 PG
  • Admin 路由与现有 group 集成(不新建 group
  • 装配在 SetupRouter 内部完成(不修改 main.go / SetupRouter 签名)
  • Swagger 注解与 swag init 生成流程已说明
  • 测试运行命令已给出