diff --git a/docs/specs/2026-07-08-app-download-page-design.md b/docs/specs/2026-07-08-app-download-page-design.md
new file mode 100644
index 0000000..ac55cdb
--- /dev/null
+++ b/docs/specs/2026-07-08-app-download-page-design.md
@@ -0,0 +1,1198 @@
+# 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 — 下载
+
+
+
+
+
+
+
+
+```
+
+### 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 一致
diff --git a/docs/superpowers/plans/2026-07-08-preload-api-implementation.md b/docs/superpowers/plans/2026-07-08-preload-api-implementation.md
new file mode 100644
index 0000000..ba18603
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-08-preload-api-implementation.md
@@ -0,0 +1,2240 @@
+# API 预加载方案 — 实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 实现通用 API 预加载系统(内存缓存 + 文件缓存 + 启动预热 + 页面切换预拉 + Vue 3 composable)
+
+**Architecture:** 6 个核心模块 + 1 个 composable + 1 个业务配置文件。core.js 为纯 JS 缓存引擎(Map + LRU + inFlight 去重),storage.js 负责文件持久化(`_doc/preload/{userId}/`),scheduler.js/navigate.js 负责触发时机,usePreload.js 提供 Vue 响应式包装
+
+**Tech Stack:** uniapp (Vue 3 + Vite)、plus.io (APP-PLUS)、uni.getFileSystemManager (降级)
+
+**Spec:** `docs/superpowers/specs/2026-07-02-preload-api-design.md`
+
+---
+
+## File Structure
+
+```
+frontend/
+├── utils/
+│ ├── api.js # MODIFY: add .abort() to request()
+│ └── preloadApi/
+│ ├── storage.js # CREATE: file cache adapter
+│ ├── core.js # CREATE: core cache engine
+│ ├── config.js # CREATE: config loader
+│ ├── scheduler.js # CREATE: startup/idle scheduler
+│ ├── navigate.js # CREATE: wrapped navigation
+│ ├── index.js # CREATE: unified export
+│ ├── README.md # CREATE: developer docs
+│ └── __tests__/
+│ ├── core.test.js # CREATE: core unit tests
+│ ├── storage.test.js # CREATE: storage unit tests
+│ └── navigate.test.js # CREATE: navigate + composable tests
+├── composables/
+│ └── usePreload.js # CREATE: Vue 3 composable
+├── config/
+│ └── preload.config.js # CREATE: business config
+├── store/modules/
+│ └── user.js # MODIFY: patch mutations
+└── App.vue # MODIFY: integrate warmStartup/warmIdle
+```
+
+---
+
+### Task 1: Modify `utils/api.js` — add `.abort()` to `request()`
+
+**Files:**
+- Modify: `frontend/utils/api.js:55-145`
+
+- [ ] **Step 1: Add abort support to request()**
+
+Replace the `request()` function body (lines 55-145 of api.js). The key changes:
+1. Capture `requestTask` from `uni.request()` return value
+2. Add `_aborted` flag
+3. Guard `fail` callback against abort-triggered errors
+4. Attach `.abort()` method to the returned Promise
+
+```js
+// frontend/utils/api.js — request() 函数替换
+
+export function request(options) {
+ let _aborted = false
+ let requestTask = null
+
+ // 构建请求头
+ const headers = {
+ 'Content-Type': 'application/json',
+ // 风控限流(spec §9.1 v2.3):设备指纹维度
+ 'X-Device-Fingerprint': getDeviceFingerprint(),
+ ...options.header
+ }
+
+ // 判断是否为登录或注册接口
+ const isAuthRequest = options.url.includes('/api/v1/auth/login') || options.url.includes(
+ '/api/v1/auth/register') || options.url.includes('/api/v1/auth/send-code') || options.url.includes('/api/v1/auth/verify-code')
+
+ // 如果不是登录/注册接口,则自动添加JWT token
+ if (!isAuthRequest) {
+ const token = uni.getStorageSync('access_token')
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`
+ }
+ }
+
+ const p = new Promise((resolve, reject) => {
+ requestTask = uni.request({
+ url: baseURL + options.url,
+ method: options.method || 'GET',
+ data: options.data || {},
+ header: headers,
+ timeout: 60000,
+ success: (res) => {
+ // 处理 token 过期(HTTP 401)
+ if (res.statusCode === 401) {
+ uni.removeStorageSync('access_token')
+ uni.removeStorageSync('user')
+ uni.reLaunch({ url: '/pages/login/portal' })
+ reject(new Error('登录已过期,请重新登录'))
+ return
+ }
+
+ if (res.statusCode === 200 || res.statusCode === 202) {
+ if (res.data && res.data.code !== undefined) {
+ if (res.data.code === 0) {
+ resolve(res.data)
+ } else if (res.data.code === 16 || res.data.code === 7) {
+ uni.removeStorageSync('access_token')
+ uni.removeStorageSync('user')
+ const errorMsg = res.data.message || '登录已过期,请重新登录'
+ uni.reLaunch({
+ url: '/pages/login/portal?error=' + encodeURIComponent(errorMsg)
+ })
+ const authErr = new Error(errorMsg)
+ authErr.code = res.data.code
+ reject(authErr)
+ return
+ } else {
+ const bizErr = new Error(res.data.message || '请求失败')
+ bizErr.code = res.data.code
+ reject(bizErr)
+ }
+ } else {
+ resolve(res.data)
+ }
+ } else {
+ const errorMessage = res.data?.message || `请求失败 (${res.statusCode})`
+ const httpErr = new Error(errorMessage)
+ if (res.data?.code !== undefined) httpErr.code = res.data.code
+ reject(httpErr)
+ }
+ },
+ fail: (err) => {
+ // ★ 新增:abort 触发的 fail 静默忽略
+ if (_aborted) return
+ reject(new Error(err.errMsg || '网络请求失败'))
+ }
+ })
+ })
+
+ // ★ 新增:挂载 abort 方法
+ p.abort = () => {
+ _aborted = true
+ if (requestTask) requestTask.abort()
+ }
+
+ return p
+}
+```
+
+- [ ] **Step 2: Verify api.js works correctly**
+
+Run the existing app to confirm no regressions:
+
+```bash
+# In HBuilderX: 运行 → 运行到手机或模拟器 → 选择设备
+# Verify: login, navigate between pages, API calls still work
+# No console errors related to request()
+```
+
+---
+
+### Task 2: Create `utils/preloadApi/storage.js` — file cache adapter
+
+**Files:**
+- Create: `frontend/utils/preloadApi/storage.js`
+
+- [ ] **Step 1: Create storage.js**
+
+```js
+// frontend/utils/preloadApi/storage.js
+// 文件缓存适配器 — _doc/preload/{userId}/ 目录下的 JSON 文件读写
+// APP-PLUS: 优先 plus.io(promisify),降级 uni.getFileSystemManager
+// H5/小程序: uni.getFileSystemManager
+
+const BASE_DIR = '_doc/preload'
+const NAMESPACE = 'preload'
+
+// ── djb2 hash(与 core.js 共用逻辑,此处独立一份避免循环依赖)──
+function hashStr(str) {
+ let hash = 5381
+ for (let i = 0; i < str.length; i++) {
+ hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0
+ }
+ return (hash >>> 0).toString(16)
+}
+
+// ── 路径工具 ──
+function getUserDir(userId) {
+ return `${BASE_DIR}/${userId || 'guest'}`
+}
+
+function getFilePath(userId, cacheKey) {
+ return `${getUserDir(userId)}/${hashStr(cacheKey)}.json`
+}
+
+// ── plus.io promisify 工具 ──
+function promisifyPlusIO(fn) {
+ return new Promise((resolve, reject) => {
+ try {
+ fn(resolve, reject)
+ } catch (e) {
+ reject(e)
+ }
+ })
+}
+
+// ── 确保目录存在 ──
+async function ensureDir(dirPath) {
+ // #ifdef APP-PLUS
+ return promisifyPlusIO((resolve, reject) => {
+ plus.io.resolveLocalFileSystemURL(
+ `_doc/`,
+ (docEntry) => {
+ // 逐级创建 preload/{userId}
+ const parts = dirPath.replace('_doc/', '').split('/')
+ let currentEntry = docEntry
+ const createNext = (idx) => {
+ if (idx >= parts.length) return resolve()
+ currentEntry.getDirectory(
+ parts[idx],
+ { create: true },
+ (dirEntry) => {
+ currentEntry = dirEntry
+ createNext(idx + 1)
+ },
+ (err) => reject(err)
+ )
+ }
+ createNext(0)
+ },
+ (err) => reject(err)
+ )
+ })
+ // #endif
+
+ // #ifndef APP-PLUS
+ try {
+ const fs = uni.getFileSystemManager()
+ // uni.getFileSystemManager 的 mkdir 需要父目录已存在,逐级创建
+ const parts = dirPath.replace('_doc/', '').split('/')
+ let current = '_doc'
+ for (const part of parts) {
+ current += '/' + part
+ try { fs.accessSync(current) } catch (e) { fs.mkdirSync(current) }
+ }
+ } catch (e) {
+ // 目录已存在或创建失败,静默
+ }
+ // #endif
+}
+
+// ── 公共 API ──
+
+/**
+ * 读文件缓存条目
+ * @returns {Promise<{data, ts, ttl}|null>} null = 未命中
+ */
+export async function readEntry(userId, cacheKey) {
+ const filePath = getFilePath(userId, cacheKey)
+ try {
+ // #ifdef APP-PLUS
+ const content = await promisifyPlusIO((resolve, reject) => {
+ plus.io.resolveLocalFileSystemURL(
+ filePath,
+ (fileEntry) => {
+ fileEntry.file(
+ (file) => {
+ const reader = new plus.io.FileReader()
+ reader.onloadend = (e) => resolve(e.target.result)
+ reader.onerror = (e) => reject(e)
+ reader.readAsText(file, 'utf-8')
+ },
+ (err) => reject(err)
+ )
+ },
+ (err) => reject(err) // 文件不存在 = 未命中
+ )
+ })
+ return JSON.parse(content)
+ // #endif
+
+ // #ifndef APP-PLUS
+ const fs = uni.getFileSystemManager()
+ const raw = fs.readFileSync(filePath, 'utf-8')
+ return JSON.parse(raw)
+ // #endif
+ } catch (e) {
+ return null // 文件不存在 / 损坏 → 未命中
+ }
+}
+
+/**
+ * 写文件缓存条目(fire-and-forget,调用方不 await)
+ * 内部自建 .catch 防止 unhandled rejection
+ */
+export function writeEntry(userId, cacheKey, data, ts, ttl) {
+ const dirPath = getUserDir(userId)
+ const filePath = getFilePath(userId, cacheKey)
+ const content = JSON.stringify({ data, ts, ttl })
+
+ ensureDir(dirPath).then(() => {
+ // #ifdef APP-PLUS
+ return promisifyPlusIO((resolve, reject) => {
+ plus.io.resolveLocalFileSystemURL(
+ dirPath,
+ (dirEntry) => {
+ dirEntry.getFile(
+ hashStr(cacheKey) + '.json',
+ { create: true },
+ (fileEntry) => {
+ fileEntry.createWriter(
+ (writer) => {
+ writer.onwriteend = () => resolve()
+ writer.onerror = (e) => reject(e)
+ writer.write(content)
+ },
+ (err) => reject(err)
+ )
+ },
+ (err) => reject(err)
+ )
+ },
+ (err) => reject(err)
+ )
+ })
+ // #endif
+
+ // #ifndef APP-PLUS
+ const fs = uni.getFileSystemManager()
+ fs.writeFileSync(filePath, content, 'utf-8')
+ // #endif
+ }).catch((err) => {
+ console.warn('[preload] storage write failed:', filePath, err.message)
+ })
+}
+
+/**
+ * 删除指定用户的文件缓存目录
+ */
+export async function clearForUser(userId) {
+ const dirPath = getUserDir(userId)
+ try {
+ // #ifdef APP-PLUS
+ await promisifyPlusIO((resolve, reject) => {
+ plus.io.resolveLocalFileSystemURL(
+ dirPath,
+ (dirEntry) => {
+ dirEntry.removeRecursively(
+ () => resolve(),
+ (err) => reject(err)
+ )
+ },
+ // 目录不存在不算错误
+ () => resolve()
+ )
+ })
+ // #endif
+
+ // #ifndef APP-PLUS
+ const fs = uni.getFileSystemManager()
+ try { fs.rmdirSync(dirPath, true) } catch (e) { /* absent = ok */ }
+ // #endif
+ } catch (e) {
+ console.warn('[preload] clearForUser failed:', userId, e.message)
+ }
+}
+
+/**
+ * 获取用户缓存目录总大小(字节)
+ * 用于 FIFO 容量检查
+ * 注意:累加所有文件的 file.size,异步回调全部完成后才 resolve
+ */
+export async function getTotalCacheSize(userId) {
+ const dirPath = getUserDir(userId)
+ let totalSize = 0
+ try {
+ // #ifdef APP-PLUS
+ await promisifyPlusIO((resolve, reject) => {
+ plus.io.resolveLocalFileSystemURL(
+ dirPath,
+ (dirEntry) => {
+ const reader = dirEntry.createReader()
+ let pending = 0
+ let done = false
+
+ const readAll = () => {
+ reader.readEntries(
+ (entries) => {
+ if (entries.length === 0) {
+ done = true
+ if (pending === 0) resolve()
+ return
+ }
+ for (const entry of entries) {
+ if (entry.isFile) {
+ pending++
+ entry.file(
+ (f) => {
+ totalSize += (f.size || 0)
+ pending--
+ if (done && pending === 0) resolve()
+ },
+ () => {
+ pending--
+ if (done && pending === 0) resolve()
+ }
+ )
+ }
+ }
+ readAll() // 递归读下一批
+ },
+ (err) => reject(err)
+ )
+ }
+ readAll()
+ },
+ () => resolve() // 目录不存在 → size = 0
+ )
+ })
+ // #endif
+
+ // #ifndef APP-PLUS
+ const fs = uni.getFileSystemManager()
+ try {
+ const files = fs.readdirSync(dirPath)
+ for (const f of files) {
+ try {
+ const stat = fs.statSync(dirPath + '/' + f)
+ totalSize += stat.size || 0
+ } catch (e) { /* skip */ }
+ }
+ } catch (e) { /* absent = ok */ }
+ // #endif
+ } catch (e) {
+ // ignore
+ }
+ return totalSize
+}
+
+/**
+ * FIFO 淘汰最旧文件,直到总大小 < maxSize 字节
+ * APP-PLUS:递归 readEntries 收集所有文件 → 按 mtime 排序 → 从最旧的开始删除
+ * 非 APP-PLUS:readdir + stat → 按 mtime 排序 → 删除最旧的
+ * 注:getTotalCacheSize 的异步回调方式不适用于"删除后重算"循环,
+ * 此处改为一次 scan 出文件列表 → 排序 → 按需删除
+ */
+export async function evictOldest(userId, maxSize) {
+ const dirPath = getUserDir(userId)
+ try {
+ // #ifdef APP-PLUS
+ // 1. 收集所有文件及其 mtime 和 size
+ const files = await promisifyPlusIO((resolve, reject) => {
+ plus.io.resolveLocalFileSystemURL(
+ dirPath,
+ (dirEntry) => {
+ const reader = dirEntry.createReader()
+ const collected = []
+ let pending = 0
+ let done = false
+
+ const readAll = () => {
+ reader.readEntries(
+ (entries) => {
+ if (entries.length === 0) {
+ done = true
+ if (pending === 0) resolve(collected)
+ return
+ }
+ for (const entry of entries) {
+ if (entry.isFile) {
+ pending++
+ entry.file(
+ (f) => {
+ // plus.io File 的 modificationTime 或直接用 lastModified
+ const mtime = f.lastModified || f.lastModifiedDate?.getTime?.() || 0
+ collected.push({ name: entry.name, entry, size: f.size || 0, mtime })
+ pending--
+ if (done && pending === 0) resolve(collected)
+ },
+ () => {
+ pending--
+ if (done && pending === 0) resolve(collected)
+ }
+ )
+ }
+ }
+ readAll()
+ },
+ (err) => reject(err)
+ )
+ }
+ readAll()
+ },
+ () => resolve([]) // 目录不存在 → 空列表
+ )
+ })
+
+ // 2. 按 mtime 升序排列(最旧的在前)
+ files.sort((a, b) => a.mtime - b.mtime)
+
+ // 3. 计算当前总大小,按 FIFO 删除直到 < maxSize * 0.8
+ let totalSize = files.reduce((sum, f) => sum + f.size, 0)
+ for (const f of files) {
+ if (totalSize <= maxSize * 0.8) break
+ f.entry.remove(() => {}, () => {})
+ totalSize -= f.size
+ }
+ // #endif
+
+ // #ifndef APP-PLUS
+ const fs = uni.getFileSystemManager()
+ try {
+ const fileNames = fs.readdirSync(dirPath)
+ const files = fileNames.map(name => {
+ try {
+ const stat = fs.statSync(dirPath + '/' + name)
+ return { name, size: stat.size || 0, mtime: stat.lastModified || stat.lastModifiedTime || 0 }
+ } catch (e) {
+ return { name, size: 0, mtime: 0 }
+ }
+ })
+
+ // 按 mtime 升序(最旧的在前)
+ files.sort((a, b) => a.mtime - b.mtime)
+
+ let totalSize = files.reduce((sum, f) => sum + f.size, 0)
+ for (const f of files) {
+ if (totalSize <= maxSize * 0.8) break
+ try { fs.unlinkSync(dirPath + '/' + f.name) } catch (e) { /* skip */ }
+ totalSize -= f.size
+ }
+ } catch (e) { /* absent = ok */ }
+ // #endif
+ } catch (e) {
+ console.warn('[preload] evictOldest failed:', userId, e.message)
+ }
+}
+```
+
+- [ ] **Step 2: Verify storage.js syntax**
+
+```bash
+cd frontend
+# No build errors expected (the file uses #ifdef blocks, valid in uniapp)
+```
+
+---
+
+### Task 3: Create `utils/preloadApi/core.js` — core cache engine
+
+**Files:**
+- Create: `frontend/utils/preloadApi/core.js`
+
+- [ ] **Step 1: Create core.js**
+
+```js
+// frontend/utils/preloadApi/core.js
+// 核心缓存引擎 — 零外部依赖(不依赖 Vue / uni API / api.js / store)
+// 内存 Map + LRU + inFlight 去重 + TTL
+// 依赖关系(内模块):import { readEntry, writeEntry, clearForUser as storageClearForUser, getTotalCacheSize, evictOldest } from './storage'
+
+import {
+ readEntry,
+ writeEntry,
+ clearForUser as storageClearForUser,
+ getTotalCacheSize,
+ evictOldest
+} from './storage'
+
+// ── 常量 ──
+const NAMESPACE = 'preload'
+const DEFAULT_MAX_MEMORY_ENTRIES = 100
+const DEFAULT_MAX_ENTRY_SIZE_KB = 1024
+const DEFAULT_MAX_FILE_CACHE_MB = 50
+
+// ── 内部状态 ──
+const memoryMap = new Map() // Map
+const inFlightMap = new Map() // Map
+
+// 统计
+let hitCount = 0
+let missCount = 0
+
+// 运行时配置(由外部 setConfig 写入)
+let _config = {
+ defaults: {
+ ttl: 5 * 60 * 1000,
+ persistence: 'memory',
+ concurrency: 4,
+ timeout: 10000,
+ silent: true,
+ limits: {
+ maxEntrySizeKB: DEFAULT_MAX_ENTRY_SIZE_KB,
+ maxMemoryEntries: DEFAULT_MAX_MEMORY_ENTRIES,
+ maxFileCacheMB: DEFAULT_MAX_FILE_CACHE_MB
+ }
+ }
+}
+
+// fetcher 注册表:{ [logicalKey]: fetcherFunction }
+let _fetchers = {}
+
+// userId 获取函数(由外部注入)
+let _getUserId = () => {
+ try {
+ const userStr = uni.getStorageSync('user')
+ if (userStr) {
+ const user = JSON.parse(userStr)
+ return user?.uid || null
+ }
+ } catch (e) { /* ignore */ }
+ return null
+}
+
+// ── 并发控制 semaphore ──
+function createSemaphore(max) {
+ let running = 0
+ const queue = []
+ return {
+ acquire: () => new Promise(resolve => {
+ if (running < max) { running++; resolve() }
+ else { queue.push(resolve) }
+ }),
+ release: () => {
+ running--
+ const next = queue.shift()
+ if (next) { running++; next() }
+ }
+ }
+}
+
+let _semaphore = createSemaphore(_config.defaults.concurrency)
+
+// ── hash 工具 ──
+function djb2(str) {
+ let hash = 5381
+ for (let i = 0; i < str.length; i++) {
+ hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0
+ }
+ return (hash >>> 0).toString(16)
+}
+
+function hashParams(params) {
+ if (!params || Object.keys(params).length === 0) return ''
+ const sorted = {}
+ Object.keys(params).sort().forEach(k => { sorted[k] = params[k] })
+ return djb2(JSON.stringify(sorted))
+}
+
+function buildCacheKey(userId, logicalKey, params) {
+ const uid = userId || 'guest'
+ const paramHash = hashParams(params)
+ return `${uid}::${NAMESPACE}::${logicalKey}::${paramHash}`
+}
+
+// ── LRU touch ──
+function touchLRU(map, key, value) {
+ if (map.has(key)) map.delete(key)
+ map.set(key, value)
+ if (map.size > _config.defaults.limits.maxMemoryEntries) {
+ const oldestKey = map.keys().next().value
+ map.delete(oldestKey)
+ if (typeof console !== 'undefined') {
+ console.log('[preload] LRU evict:', oldestKey)
+ }
+ }
+}
+
+// ── 401/7/16 swallow ──
+function _swallowAuth(err) {
+ if (err && (err.code === 7 || err.code === 16)) return null
+ if (err && /登录已过期/.test(err.message || '')) return null
+ return err
+}
+
+// ── 数据大小检查 ──
+function getDataSizeKB(data) {
+ try {
+ return new Blob([JSON.stringify(data)]).size / 1024
+ } catch (e) {
+ return JSON.stringify(data || '').length / 1024
+ }
+}
+
+// ── 配置 API ──
+
+/**
+ * 设置运行时配置 + fetcher 注册表
+ * 由 config.js 在初始化时调用
+ */
+export function setConfig(config, fetchers) {
+ if (config) {
+ _config = {
+ defaults: {
+ ..._config.defaults,
+ ...(config.defaults || {}),
+ limits: {
+ ..._config.defaults.limits,
+ ...((config.defaults && config.defaults.limits) || {})
+ }
+ },
+ startup: config.startup || _config.startup || [],
+ idle: config.idle || _config.idle || [],
+ pages: config.pages || _config.pages || {}
+ }
+ _semaphore = createSemaphore(_config.defaults.concurrency)
+ }
+ if (fetchers) {
+ _fetchers = { ..._fetchers, ...fetchers }
+ }
+}
+
+/**
+ * 设置 userId 获取函数(用于测试注入)
+ */
+export function setUserIdGetter(fn) {
+ if (typeof fn === 'function') _getUserId = fn
+}
+
+// ── 核心 API ──
+
+/**
+ * 获取缓存值(组件使用)
+ * 命中内存 → 同步 resolve;文件缓存命中 / fetch → async resolve
+ */
+export function get(logicalKey, params) {
+ const userId = _getUserId()
+ const cacheKey = buildCacheKey(userId, logicalKey, params)
+
+ // 1. 查内存
+ const memEntry = memoryMap.get(cacheKey)
+ if (memEntry && (Date.now() - memEntry.ts) < memEntry.ttl) {
+ touchLRU(memoryMap, cacheKey, memEntry)
+ hitCount++
+ return Promise.resolve(memEntry.data)
+ }
+
+ // 2. inFlight 去重
+ const inFlight = inFlightMap.get(cacheKey)
+ if (inFlight) {
+ return inFlight.promise.then(result => result.data)
+ }
+
+ // 3. 发起 fetch(含文件缓存回退)
+ return _doFetch(logicalKey, params, cacheKey, userId, false)
+}
+
+/**
+ * 触发预拉(fire-and-forget)
+ * 查内存 → inFlight → 发起 fetch
+ */
+export function run(logicalKey, params) {
+ const userId = _getUserId()
+ const cacheKey = buildCacheKey(userId, logicalKey, params)
+
+ // 1. 查内存
+ const memEntry = memoryMap.get(cacheKey)
+ if (memEntry && (Date.now() - memEntry.ts) < memEntry.ttl) {
+ return // 未过期,跳过
+ }
+
+ // 2. inFlight 去重
+ if (inFlightMap.has(cacheKey)) {
+ return // 已在请求中
+ }
+
+ // 3. 发起 fetch(fire-and-forget,不返回 Promise)
+ _doFetch(logicalKey, params, cacheKey, userId, true)
+}
+
+/**
+ * 命令式刷新
+ */
+export function refresh(logicalKey, params, force = false) {
+ const userId = _getUserId()
+ const cacheKey = buildCacheKey(userId, logicalKey, params)
+
+ // force=true 时先删除内存缓存
+ if (force) {
+ memoryMap.delete(cacheKey)
+ }
+
+ return _doFetch(logicalKey, params, cacheKey, userId, false)
+}
+
+/**
+ * 失效单个 key(仅内存)
+ */
+export function invalidate(logicalKey, params) {
+ const cacheKey = buildCacheKey(_getUserId(), logicalKey, params)
+ memoryMap.delete(cacheKey)
+}
+
+/**
+ * 按前缀失效(仅内存)
+ */
+export function invalidatePrefix(prefix) {
+ const fullPrefix = `${_getUserId() || 'guest'}::${NAMESPACE}::${prefix}`
+ for (const key of memoryMap.keys()) {
+ if (key.startsWith(fullPrefix)) {
+ memoryMap.delete(key)
+ }
+ }
+}
+
+/**
+ * 清空全部内存缓存(不动文件缓存)
+ */
+export function invalidateAll() {
+ memoryMap.clear()
+}
+
+/**
+ * 删除指定用户的文件缓存目录(不动内存)
+ */
+export function clearForUser(userId) {
+ storageClearForUser(userId)
+}
+
+/**
+ * 登出专用:清空内存 + 删除文件缓存目录
+ */
+export function clearUser(userId) {
+ memoryMap.clear()
+ storageClearForUser(userId)
+}
+
+/**
+ * 按目标页路径触发预拉(navigate.js 内部调用)
+ */
+export function prefetchFor(targetPath, params) {
+ const pages = _config.pages || {}
+ const entries = pages[targetPath]
+ if (!entries || !Array.isArray(entries)) return
+
+ for (const entry of entries) {
+ run(entry.key, params)
+ }
+}
+
+/**
+ * 取消指定 key 的 in-flight 请求(供 composable unmount / params 变化时使用)
+ */
+export function abortRequest(logicalKey, params) {
+ const userId = _getUserId()
+ const cacheKey = buildCacheKey(userId, logicalKey, params)
+ const entry = inFlightMap.get(cacheKey)
+ if (entry) {
+ entry.abort()
+ inFlightMap.delete(cacheKey)
+ }
+}
+
+/**
+ * 获取调试统计
+ */
+export function getStats() {
+ return {
+ hits: hitCount,
+ misses: missCount,
+ memorySize: memoryMap.size,
+ inFlightSize: inFlightMap.size,
+ fileCacheBytes: _lastFileCacheSize
+ }
+}
+
+// 上次文件缓存大小(由 checkAndEvict 异步更新)
+let _lastFileCacheSize = 0
+
+// 内部:更新文件缓存大小跟踪
+function _updateFileCacheSize() {
+ const userId = _getUserId()
+ if (userId) {
+ getTotalCacheSize(userId).then(size => {
+ _lastFileCacheSize = size
+ }).catch(() => {})
+ }
+}
+
+/**
+ * dump 内存缓存(调试用)
+ */
+export function dumpMemory() {
+ const result = []
+ for (const [key, entry] of memoryMap.entries()) {
+ result.push({
+ key,
+ age: Date.now() - entry.ts,
+ ttl: entry.ttl,
+ persistence: entry.persistence
+ })
+ }
+ return result
+}
+
+// ── 内部:执行 fetch ──
+async function _doFetch(logicalKey, params, cacheKey, userId, isRun) {
+ const fetcher = _fetchers[logicalKey]
+ if (!fetcher) {
+ if (!isRun) throw new Error(`[preload] unknown key: ${logicalKey}`)
+ console.warn(`[preload] unknown key: ${logicalKey}`)
+ return
+ }
+
+ const cfg = _resolveEntryConfig(logicalKey)
+ const ttl = cfg.ttl || _config.defaults.ttl
+ const persistence = cfg.persistence || _config.defaults.persistence
+ const silent = cfg.silent !== undefined ? cfg.silent : _config.defaults.silent
+ const timeout = cfg.timeout || _config.defaults.timeout
+
+ // 3. 先查本地文件缓存(非 run 路径)
+ if (!isRun && persistence === 'file') {
+ try {
+ const fileEntry = await readEntry(userId, cacheKey)
+ if (fileEntry && (Date.now() - fileEntry.ts) < fileEntry.ttl) {
+ // 文件命中 → 写回内存
+ touchLRU(memoryMap, cacheKey, {
+ data: fileEntry.data,
+ ts: fileEntry.ts,
+ ttl: fileEntry.ttl,
+ persistence: 'file'
+ })
+ hitCount++
+ return fileEntry.data
+ }
+ } catch (e) {
+ // 文件读取失败 → 走 fetch
+ }
+ }
+
+ // 4. 创建 inFlight 条目
+ let resolveInFlight, rejectInFlight
+ const sharedPromise = new Promise((res, rej) => {
+ resolveInFlight = res
+ rejectInFlight = rej
+ })
+
+ let abortFn = () => {}
+ const inFlightEntry = {
+ promise: sharedPromise.then(data => ({ data })),
+ abort: () => abortFn()
+ }
+ inFlightMap.set(cacheKey, inFlightEntry)
+
+ const cleanup = () => {
+ inFlightMap.delete(cacheKey)
+ }
+
+ // 5. 并发控制 + 超时
+ await _semaphore.acquire()
+
+ try {
+ const startTime = Date.now()
+ const fetchPromise = fetcher(params)
+
+ // 设置 abort
+ abortFn = () => {
+ if (fetchPromise && typeof fetchPromise.abort === 'function') {
+ fetchPromise.abort()
+ }
+ cleanup()
+ }
+
+ // 超时控制
+ let timeoutId
+ const timeoutPromise = new Promise((_, reject) => {
+ timeoutId = setTimeout(() => reject(new Error('timeout')), timeout)
+ })
+
+ const result = await Promise.race([fetchPromise, timeoutPromise])
+ clearTimeout(timeoutId)
+
+ const elapsed = Date.now() - startTime
+ console.log('[preload] fetch done:', logicalKey, elapsed + 'ms')
+
+ // 6. 写内存缓存
+ const entry = { data: result, ts: Date.now(), ttl, persistence }
+ touchLRU(memoryMap, cacheKey, entry)
+
+ // 7. 异步写文件缓存(fire-and-forget,不阻塞 fetch 返回)
+ if (persistence === 'file') {
+ const sizeKB = getDataSizeKB(result)
+ if (sizeKB <= _config.defaults.limits.maxEntrySizeKB) {
+ writeEntry(userId, cacheKey, result, entry.ts, ttl)
+ // fire-and-forget 容量检查:延迟到下一 tick 确保 writeEntry 已启动
+ setTimeout(() => {
+ checkAndEvict(userId)
+ }, 0)
+ }
+ }
+
+ missCount++
+ resolveInFlight(result)
+ return result
+ } catch (err) {
+ // 8. 错误处理
+ const swallowed = _swallowAuth(err)
+ if (swallowed === null) {
+ // 401/7/16 → swallow
+ console.warn('[preload] auth-expired, swallowed:', logicalKey)
+ resolveInFlight(null)
+ return null
+ }
+
+ if (silent || isRun) {
+ // run / silent → 静默
+ console.warn('[preload] fetch fail (swallowed):', logicalKey, err.message)
+ resolveInFlight(null)
+ return null
+ }
+
+ // get 路径 → 抛错给调用方
+ rejectInFlight(err)
+ throw err
+ } finally {
+ cleanup()
+ _semaphore.release()
+ }
+}
+
+// ── 内部:fire-and-forget 容量检查 + 淘汰 ──
+async function checkAndEvict(userId) {
+ try {
+ const totalSize = await getTotalCacheSize(userId)
+ _lastFileCacheSize = totalSize
+ const maxBytes = _config.defaults.limits.maxFileCacheMB * 1024 * 1024
+ if (totalSize > maxBytes) {
+ await evictOldest(userId, maxBytes)
+ // 淘汰后更新大小
+ const newSize = await getTotalCacheSize(userId)
+ _lastFileCacheSize = newSize
+ }
+ } catch (e) {
+ console.warn('[preload] eviction check failed:', e.message)
+ }
+}
+
+// ── 内部:解析 per-key 配置 ──
+function _resolveEntryConfig(logicalKey) {
+ // 从 _config 中查找该 key 的配置(startup/idle/pages 任一数组)
+ const all = [
+ ...(_config.startup || []),
+ ...(_config.idle || []),
+ ]
+ if (_config.pages) {
+ for (const entries of Object.values(_config.pages)) {
+ if (Array.isArray(entries)) all.push(...entries)
+ }
+ }
+ const found = all.find(e => e.key === logicalKey)
+ return found || {}
+}
+
+// ── 开发调试 ──
+if (typeof window !== 'undefined' && (typeof import.meta === 'undefined' || import.meta.env?.DEV)) {
+ window.__PRELOAD_DEBUG__ = {
+ dumpMemory,
+ stats: getStats,
+ all: () => ({
+ memory: dumpMemory(),
+ inFlight: Array.from(inFlightMap.keys())
+ })
+ }
+}
+```
+
+- [ ] **Step 2: Verify core.js syntax**
+
+```bash
+cd frontend
+# No syntax errors expected
+```
+
+---
+
+### Task 4: Create `utils/preloadApi/config.js` — config loader
+
+**Files:**
+- Create: `frontend/utils/preloadApi/config.js`
+
+- [ ] **Step 1: Create config.js**
+
+```js
+// frontend/utils/preloadApi/config.js
+// 配置加载器:合并用户配置与内置默认值,提取 fetcher 映射表
+
+/**
+ * 内置默认值(与设计文档 §3 defaults 一致)
+ */
+const BUILTIN_DEFAULTS = {
+ ttl: 5 * 60 * 1000,
+ persistence: 'memory',
+ concurrency: 4,
+ timeout: 10000,
+ silent: true,
+ limits: {
+ maxEntrySizeKB: 1024,
+ maxMemoryEntries: 100,
+ maxFileCacheMB: 50
+ }
+}
+
+/**
+ * 加载并合并配置
+ * @param {object} userConfig - 用户提供的 preload.config.js export
+ * @returns {{ config: object, fetchers: object }}
+ */
+export function loadConfig(userConfig) {
+ if (!userConfig) {
+ throw new Error('[preload] config is required')
+ }
+
+ // 合并 defaults
+ const mergedDefaults = {
+ ...BUILTIN_DEFAULTS,
+ ...(userConfig.defaults || {}),
+ limits: {
+ ...BUILTIN_DEFAULTS.limits,
+ ...((userConfig.defaults && userConfig.defaults.limits) || {})
+ }
+ }
+
+ const config = {
+ defaults: mergedDefaults,
+ startup: userConfig.startup || [],
+ idle: userConfig.idle || [],
+ pages: userConfig.pages || {}
+ }
+
+ // 提取 fetcher 映射表
+ const fetchers = {}
+ const allEntries = [
+ ...(config.startup || []),
+ ...(config.idle || [])
+ ]
+ for (const entries of Object.values(config.pages || {})) {
+ if (Array.isArray(entries)) allEntries.push(...entries)
+ }
+
+ for (const entry of allEntries) {
+ if (entry.key && typeof entry.fetcher === 'function') {
+ fetchers[entry.key] = entry.fetcher
+ }
+ }
+
+ return { config, fetchers }
+}
+```
+
+---
+
+### Task 5: Create `utils/preloadApi/scheduler.js` — startup/idle scheduler
+
+**Files:**
+- Create: `frontend/utils/preloadApi/scheduler.js`
+
+- [ ] **Step 1: Create scheduler.js**
+
+```js
+// frontend/utils/preloadApi/scheduler.js
+// 调度器:启动期预热 + idle 预拉
+// 依赖 core.js 的 run()
+
+import { run } from './core'
+
+// App 端 fallback:没有 requestIdleCallback,用 setTimeout
+const idle =
+ typeof requestIdleCallback === 'function'
+ ? requestIdleCallback
+ : (cb) => setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0)
+
+/**
+ * 启动期预热
+ * @param {Array} startupList - config.startup 数组
+ */
+export function warmStartup(startupList) {
+ if (!startupList || !Array.isArray(startupList)) return
+
+ console.log('[preload] warmStartup:', startupList.length, 'keys')
+ for (const entry of startupList) {
+ // fire-and-forget:不 await,并发由 core 内部 semaphore 控制
+ run(entry.key, entry.params)
+ }
+}
+
+/**
+ * idle 预拉(幂等:run 内部处理去重和缓存命中)
+ * @param {Array} idleList - config.idle 数组
+ */
+export function warmIdle(idleList) {
+ if (!idleList || !Array.isArray(idleList)) return
+
+ idle(() => {
+ console.log('[preload] warmIdle:', idleList.length, 'keys')
+ for (const entry of idleList) {
+ run(entry.key, entry.params)
+ }
+ })
+}
+```
+
+---
+
+### Task 6: Create `utils/preloadApi/navigate.js` — wrapped navigation
+
+**Files:**
+- Create: `frontend/utils/preloadApi/navigate.js`
+
+- [ ] **Step 1: Create navigate.js**
+
+```js
+// frontend/utils/preloadApi/navigate.js
+// 包装 uni.navigateTo / switchTab / reLaunch
+// 跳转前 fire-and-forget 预拉目标页数据,不 await
+
+import { prefetchFor } from './core'
+
+/**
+ * 从 URL 中解析 query string → params 对象
+ * 例:'/pages/foo/bar?id=123&type=hot' → { id: '123', type: 'hot' }
+ */
+function parseQueryParams(url) {
+ const idx = url.indexOf('?')
+ if (idx === -1) return {}
+
+ const qs = url.substring(idx + 1)
+ const params = {}
+ // 使用 URLSearchParams(uniapp 环境支持)
+ try {
+ const usp = new URLSearchParams(qs)
+ for (const [k, v] of usp) {
+ // URLSearchParams 已自动解码,不需要再 decodeURIComponent
+ params[k] = v
+ }
+ } catch (e) {
+ // fallback:手动解析
+ for (const pair of qs.split('&')) {
+ const eqIdx = pair.indexOf('=')
+ if (eqIdx === -1) continue
+ const k = decodeURIComponent(pair.substring(0, eqIdx))
+ const v = decodeURIComponent(pair.substring(eqIdx + 1))
+ if (k) params[k] = v
+ }
+ }
+ return params
+}
+
+/**
+ * 从 URL 中提取目标页路径(去掉 query string)
+ */
+function extractPath(url) {
+ const idx = url.indexOf('?')
+ return idx === -1 ? url : url.substring(0, idx)
+}
+
+/**
+ * 替代 uni.navigateTo
+ * 内部:解析目标页 → 触发预拉(fire-and-forget)→ 立即跳转
+ */
+export function navigateTo(opts) {
+ const url = typeof opts === 'string' ? opts : opts.url
+ const targetPath = extractPath(url)
+ const params = parseQueryParams(url)
+
+ // 触发预拉(fire-and-forget,不 await)
+ prefetchFor(targetPath, params)
+
+ // 立即跳转
+ if (typeof opts === 'string') {
+ uni.navigateTo({ url: opts })
+ } else {
+ uni.navigateTo(opts)
+ }
+}
+
+/**
+ * 替代 uni.switchTab
+ */
+export function switchTab(opts) {
+ const url = typeof opts === 'string' ? opts : opts.url
+ const targetPath = extractPath(url)
+ const params = parseQueryParams(url)
+
+ prefetchFor(targetPath, params)
+
+ if (typeof opts === 'string') {
+ uni.switchTab({ url: opts })
+ } else {
+ uni.switchTab(opts)
+ }
+}
+
+/**
+ * 替代 uni.reLaunch
+ */
+export function reLaunch(opts) {
+ const url = typeof opts === 'string' ? opts : opts.url
+ const targetPath = extractPath(url)
+ const params = parseQueryParams(url)
+
+ prefetchFor(targetPath, params)
+
+ if (typeof opts === 'string') {
+ uni.reLaunch({ url: opts })
+ } else {
+ uni.reLaunch(opts)
+ }
+}
+```
+
+---
+
+### Task 7: Create `utils/preloadApi/index.js` — unified export
+
+**Files:**
+- Create: `frontend/utils/preloadApi/index.js`
+
+- [ ] **Step 1: Create index.js**
+
+```js
+// frontend/utils/preloadApi/index.js
+// 统一导出 preloadApi(命令式 API)
+
+import { loadConfig } from './config'
+import { setConfig, setUserIdGetter } from './core'
+import {
+ get,
+ run,
+ refresh,
+ abortRequest,
+ invalidate,
+ invalidatePrefix,
+ invalidateAll,
+ clearForUser,
+ clearUser,
+ prefetchFor,
+ getStats,
+ dumpMemory
+} from './core'
+import { warmStartup, warmIdle } from './scheduler'
+import { navigateTo, switchTab, reLaunch } from './navigate'
+
+/**
+ * 初始化 preloadApi
+ * @param {object} userConfig - preload.config.js 导出的配置
+ * @returns {object} preloadApi 实例
+ */
+export function initPreloadApi(userConfig) {
+ const { config, fetchers } = loadConfig(userConfig)
+ setConfig(config, fetchers)
+
+ return {
+ // 核心 API
+ get,
+ run,
+ refresh,
+ abortRequest,
+ invalidate,
+ invalidatePrefix,
+ invalidateAll,
+ clearForUser,
+ clearUser,
+ prefetchFor,
+
+ // 调度
+ warmStartup: () => warmStartup(config.startup),
+ warmIdle: () => warmIdle(config.idle),
+
+ // 路由
+ navigateTo,
+ switchTab,
+ reLaunch,
+
+ // 调试
+ getStats,
+ dumpMemory,
+
+ // 配置引用
+ config
+ }
+}
+
+// 默认单例(由 App.vue 初始化)
+let _instance = null
+
+export function getPreloadApi() {
+ return _instance
+}
+
+export function setPreloadApi(api) {
+ _instance = api
+}
+
+export { setUserIdGetter }
+```
+
+---
+
+### Task 8: Create `composables/usePreload.js` — Vue 3 composable
+
+**Files:**
+- Create: `frontend/composables/usePreload.js`
+
+- [ ] **Step 1: Create usePreload.js**
+
+```js
+// frontend/composables/usePreload.js
+// Vue 3 组合式 API:包装 core.get(),暴露响应式 state { data, loading, error, refresh }
+
+import { ref, getCurrentInstance, onBeforeUnmount, watch } from 'vue'
+import { get, refresh as coreRefresh, abortRequest } from '@/utils/preloadApi/core'
+
+/**
+ * @param {string|Ref} key - 逻辑 key
+ * @param {object|Ref