# 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