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 — 下载 + + + +
+ +
粉丝共创平台
+
加载中…
+ + + 🤖 Android 下载 + + +  iOS 下载 + + + +
+ + + + +``` + +### 7.2 设计要点 + +| 要点 | 说明 | +|------|------| +| **移动端优先** | 分享页主要在微信/浏览器中打开,布局以手机屏幕为基准,禁用缩放 | +| **版本号展示** | 显示各平台最新版本号,用"|"分隔 | +| **优雅降级** | API 失败时按钮保持 disabled 状态(灰色 + 不可点击),版本区显示友好提示 | +| **HTTP 错误处理** | `fetch` 后先检查 `res.ok`,非 200 抛出异常走 catch 分支 | +| **按钮安全** | 默认 `href="javascript:void(0)"`,仅在获取到 URL 后才设置为真实地址 | +| **type 过滤** | 后端只返回 `native_app`,前端无需额外过滤 | +| **缓存策略** | HTML 页面服务端设置 `Cache-Control: max-age=300`(5 分钟) | +| **部署路径** | `https://h5.topfans.com/download` 或 `https://www.topfans.com/download.html` | + +--- + +## §8 完整数据流 + +### 8.1 正常发布流程(自动同步) + +``` +时间轴 → + 0s 管理员在 uni-admin 填写新版本信息,点击"发布" + 0.1s 数据写入 uniCloud opendb-app-versions(原有逻辑) + 0.2s submitForm 成功后调用云函数 sync-download-urls + 0.3s 云函数查询 Android + iOS 最新 stable_publish 记录 + 0.5s 云函数 POST /api/v1/admin/app/versions/sync → Go Backend + 0.6s Go Backend upsert 到 PostgreSQL app_download_configs + 0.6s 完成!HTML 分享页下次加载时会获取到新地址 + +用户访问 HTML 分享页: + 0s 页面加载,JS 执行 fetch("/api/v1/app/download-urls") + 0.05s Go Backend 查询 PostgreSQL(WHERE type = 'native_app'),返回最新 URL + 0.06s JS 渲染下载按钮 href +``` + +### 8.2 异常恢复流程 + +``` +场景:Go Backend 短暂不可用,云函数同步失败 + 1. 云函数返回 { synced: false, error: "connection timeout" } + 2. uni-admin 侧静默失败(管理员看到"发布成功",同步失败不影响发布流程) + 3. 恢复方式 A:管理员在版本管理列表找到该版本 → 编辑 → 重新保存(触发再次同步) + 4. 恢复方式 B(未来可选):点击版本行的"同步到下载页"按钮 +``` + +--- + +## §9 部署与配置 + +### 9.1 Go Backend 侧 + +| 步骤 | 操作 | +|------|------| +| 1 | 执行 migration: `psql -h -U -d topfans -f backend/migrations/2026_07_08_001_app_download_configs.sql` | +| 2 | 部署新代码,确认路由 `/api/v1/app/download-urls` 和 `/api/v1/admin/app/versions/sync` 生效 | +| 3 | 为公开接口配置 rate limit(防刷),建议 100 req/min | +| 4 | 确认 Nginx 已对 `/api/v1/admin/*` 做 IP 白名单限制(仅允许 uniCloud 出口 IP 或内网 IP) | + +### 9.2 uniCloud 侧 + +| 步骤 | 操作 | +|------|------| +| 1 | 上传云函数 `sync-download-urls` 到 uniCloud | +| 2 | 在 uniCloud 控制台配置云函数**环境变量** `BACKEND_URL` = Go Backend 地址(如 `https://api.topfans.com`) | +| 3 | 确认云函数有权访问 `opendb-app-versions` 表 | +| 4 | 确认 uniCloud 出口网络能访问 Go Backend 的 admin 接口 | + +### 9.3 网络连通性验证(关键!) + +uniCloud 云函数 → Go Backend 的网络路径: + +``` +uniCloud (阿里云/支付宝云) → 公网/专线 → Go Backend (k8s/服务器) +``` + +**验证命令**(在云函数中临时执行): + +```javascript +// 在 uniCloud 云函数控制台执行,测试到 Go Backend 的连通性 +const res = await uniCloud.httpclient.request( + `${process.env.BACKEND_URL}/health`, // 复用现有 health check + { method: 'GET', timeout: 5000 } +); +console.log('status:', res.status); // 期望 200 +``` + +**如果不能连通**: +- 检查 Go Backend 的 Nginx/防火墙是否放行了 uniCloud 出口 IP +- 或者改用方案 B:创建 URL-ified 云函数,Go Backend 侧定时拉取(改动较大,仅在 push 模式不可行时考虑) + +### 9.4 HTML 页面部署 + +- HTML 文件可部署到 Nginx、CDN 或 OSS 静态托管 +- 建议路径:`https://h5.topfans.com/download` +- Nginx 配置示例: + ```nginx + location /download { + alias /var/www/topfans/download.html; + add_header Cache-Control "public, max-age=300"; + } + ``` + +--- + +## §10 目录与文件清单 + +### 新增文件 + +``` +backend/ +├── migrations/ +│ └── 2026_07_08_001_app_download_configs.sql # 建表 migration +├── pkg/models/ +│ └── app_download_config.go # GORM model + 包类型常量 +├── gateway/ +│ ├── controller/ +│ │ └── app_download_controller.go # handler: DTO + 两个接口 +│ ├── service/ +│ │ └── app_download_service.go # 业务层: sync + query +│ └── repository/ +│ └── app_download_repository.go # 数据层: upsert + find + +uni-admin/ +├── uniCloud-alipay/cloudfunctions/ +│ └── sync-download-urls/ +│ ├── index.js # 云函数主逻辑 +│ └── package.json # 云函数配置 + +frontend/static/html/ +└── download.html # 分享下载页 +``` + +### 修改文件 + +``` +backend/gateway/router/router.go # 注册新路由 +backend/gateway/main.go # 装配依赖注入(或等效入口文件) +uni-admin/uni_modules/uni-upgrade-center/pages/version/add.vue # submitForm 后触发同步 +``` + +--- + +## §11 实施步骤 + +### 阶段 A:Go Backend(优先级 P0) + +| # | 任务 | 预估 | 产出 | +|---|------|------|------| +| A1 | 编写 migration SQL | 15min | `2026_07_08_001_app_download_configs.sql` | +| A2 | 编写 Go Model + 常量 | 10min | `app_download_config.go` | +| A3 | 编写 Repository(含 struct 定义 + 构造函数) | 20min | `app_download_repository.go` | +| A4 | 编写 Service | 15min | `app_download_service.go` | +| A5 | 编写 Controller + DTO + 构造函数 | 20min | `app_download_controller.go` | +| A6 | 注册路由 + main.go 装配 | 15min | `router.go` + `main.go` 修改 | +| A7 | `go build` + 本地测试(curl 两条 API) | 15min | 验证 | + +> **Phase A 小计**:~1h50min + +### 阶段 B:uniCloud 自动同步(优先级 P0) + +| # | 任务 | 预估 | 产出 | +|---|------|------|------| +| B1 | 编写云函数 `sync-download-urls` | 30min | `index.js` + `package.json` | +| B2 | 上传云函数 + 配置环境变量 `BACKEND_URL` | 10min | | +| B3 | 修改 `add.vue`:`.then()` 回调中插入同步调用 + 新增 methods | 15min | `add.vue` 修改 | +| B4 | 端到端测试(发布版本 → 查云函数日志 → curl Go Backend 验证) | 20min | 验证 | + +> **Phase B 小计**:~1h15min + +### 阶段 C:HTML 分享页(优先级 P0) + +| # | 任务 | 预估 | 产出 | +|---|------|------|------| +| C1 | 编写 `download.html` | 30min | 完整 HTML | +| C2 | 部署到 Nginx/CDN + 浏览器/手机测试 | 15min | | + +> **Phase C 小计**:~45min + +### 阶段 D:运维配置(优先级 P1) + +| # | 任务 | 预估 | 产出 | +|---|------|------|------| +| D1 | Nginx rate limit 配置(公开接口防刷) | 10min | | +| D2 | 网络连通性验证(§9.3 命令) | 10min | | + +> **Phase D 小计**:~20min + +### 总预估:~4h + +--- + +## §12 考虑与讨论 + +### 12.1 为什么不直接让 HTML 调 uniCloud URL-ified 云函数? + +**优点**:更简单,不需要经过 Go Backend,不需要新建 PG 表 + +**缺点(致命)**: +1. **可靠性**:uniCloud URL-ified 云函数的公网可达性不如自己的 Go Backend +2. **性能**:云函数冷启动延迟 200ms-2s,影响分享页加载体验 +3. **可控性**:Go Backend 可以做缓存、限流、监控;云函数这些都需要额外配置 +4. **架构一致性**:项目所有对外 API 都在 Go Backend,走云函数是开一个例外 + +### 12.2 为什么用 push 而不是 poll? + +**Push(云函数主动推)**: +- 实时性好:发布版本后立即同步 +- 无浪费:只有在版本变更时才触发 + +**Poll(定时拉)**: +- 需要 Go Backend 定时查询 uniCloud +- 浪费资源(大部分时间没有新版本) +- 引入了反向依赖(Go Backend → uniCloud) + +### 12.3 缓存策略 + +- **HTML 页面**:CDN/Nginx 缓存 5 分钟(`Cache-Control: max-age=300`) +- **API 响应**:Go Backend 不设置缓存(由 HTML 页面自行控制刷新频率) +- **未来优化**:可在 Go Backend 内存中缓存 60s,减少 PG 查询 + +### 12.4 安全考量 + +- `GET /api/v1/app/download-urls` 无需认证,需配置 rate limit(防刷),建议 100 req/min +- `POST /api/v1/admin/app/versions/sync` 走现有 admin 路由组,依赖 Nginx IP 白名单保护 +- 云函数 `BACKEND_URL` 环境变量中不应包含敏感路径或凭据 + +### 12.5 为什么 Gateway 直连 PG 而不是走 Dubbo RPC? + +`app_download_configs` 表的特点: +- 只有 **2 条记录**(android + ios) +- 只做简单 KV 读写(upsert + select) +- 不需要跨服务事务编排 +- 只在 gateway 层使用,没有其他 service 需要访问 + +如果为此创建一个 Dubbo RPC service(proto 定义 + server 实现 + client proxy + 注册中心),代码量约 300-400 行,是当前方案(约 150 行)的 2-3 倍,**0 业务价值**。 + +这遵循 CLAUDE.md 的 MVP 先行原则:**不为"未来可能"的复杂度提前建设抽象层**。如果未来有多服务需要读写此表,再抽成独立 RPC service。 + +--- + +## §13 自审清单(修订版) + +### 修改的章节:全部(§1-§12 重写) + +### 未改动的章节:无 + +### 跨章节引用一致性 + +- [x] §2.1 数据模型与 §3.1 migration 字段一致(含 `created_at` + `type`) +- [x] §2.1 数据模型与 §3.2 Go model 一致 +- [x] §2.2 同步链路的 payload 格式与 §4.3 请求体一致(嵌套对象,非平铺字段) +- [x] §2.3 读取链路查询条件(`WHERE type = 'native_app'`)与 §4.4.4 repository 的 `FindByType` 一致 +- [x] §1 mermaid 图写 "HTTP POST" 与 §4.1 接口定义、§4.3 请求体一致 +- [x] §1 核心架构图与 §3.1 表结构一致(含 `type` 字段) +- [x] §4.4.1 DTO `PlatformVersionInfo` 结构(URL + Version + Type)与 §5.1 云函数 payload 一致 +- [x] §5.1 云函数使用 `process.env.BACKEND_URL` 与 §5.2 注释、§9.2 部署步骤一致(均为环境变量方式) +- [x] §10 文件清单与 §4.4 分层目录、§3.2 model 路径、§7.1 HTML 路径一致 +- [x] §11 实施步骤中的文件名与 §10 文件清单一致 +- [x] §8.2 异常恢复不再依赖已删除的独立同步页(§6),改用编辑重保存 + 列表按钮 +- [x] §4.4 架构说明指向 §12.5(gateway 直连 PG 的决策理由) + +### Go 代码完整性 + +- [x] DTO `SyncVersionRequest` / `PlatformVersionInfo` / `DownloadUrlResponse` 有完整 struct 定义(§4.4.1) +- [x] Controller / Service / Repository 均有 constructor(NewXxx)函数 +- [x] Repository struct 的 `db *gorm.DB` 字段已定义 +- [x] 所有文件 import 块完整(`net/http`, `context`, `time`, `gorm.io/gorm`, models, service, repository) +- [x] `UpsertAll` 使用 `Assign(map[string]interface{}{...})` 避免覆盖 `created_at` +- [x] sync API 的 `type` 字段带 `binding:"required,oneof=native_app wgt"` 校验 + +### Vue / JS 代码完整性 + +- [x] `add.vue` 改为 diff 风格(标注原代码位置 + 修改后的代码块),不会覆盖原有 `submitForm` 逻辑 +- [x] `syncDownloadUrls()` 是独立方法,插入现有 methods 块 +- [x] HTML `fetch` 先检查 `res.ok` +- [x] HTML 版本号字符串拼接正确(取 platforms 数组,支持多平台同时显示) +- [x] HTML 按钮默认 `href="javascript:void(0)"`,API 失败时保持不可点击 + +### 其他 + +- [x] §5.2 package.json 去掉了不存在的 `uni-cloud-db` 依赖 +- [x] §4.4 controller 使用 `http.StatusOK` 等常量而非魔法数字 +- [x] 文档开头方案概述(§1)符合 CLAUDE.md 要求 +- [x] 未违反 MVP 原则 +- [x] Admin 路由与现有 pattern 一致 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} [params] - 请求参数 + * @returns {{ data: Ref, loading: Ref, error: Ref, refresh: Function }} + * + * @example + * const { data, loading, error, refresh } = usePreload('asset.detail', { id: 123 }) + * // With reactive params: + * const { data, loading, error } = usePreload('asset.detail', () => ({ id: route.params.id })) + */ +export function usePreload(key, params) { + // 校验上下文 + if (!getCurrentInstance()) { + console.warn('[preload] usePreload must be called in setup()') + } + + const data = ref(null) + const loading = ref(true) + const error = ref(null) + + let mounted = true + let fetchVersion = 0 + + /** + * 执行获取(不阻塞 setup) + * @param {boolean} [force=false] - 跳过 TTL 缓存 + */ + function doFetch(force = false) { + // 先清理上一轮 in-flight 请求 + abortRequest(key, params) + + const version = ++fetchVersion + loading.value = true + error.value = null + + // 用 .then() 异步更新 data,不阻塞 setup + const promise = force + ? coreRefresh(key, params, true) + : get(key, params) + + promise + .then((result) => { + if (!mounted || version !== fetchVersion) return + data.value = result + loading.value = false + }) + .catch((err) => { + if (!mounted || version !== fetchVersion) return + error.value = err + loading.value = false + }) + + return promise + } + + // 监听 key/params 变化(当传入 ref 或 computed 时) + // 注:toRef/toValue 在 uni-app Vue 3 中可用 + const resolvedParams = typeof params === 'function' ? params : () => params + watch( + [key, resolvedParams], + () => { + if (mounted) doFetch() + }, + { deep: true } + ) + + // 初始加载 + doFetch() + + // 组件卸载时清理 + onBeforeUnmount(() => { + mounted = false + abortRequest(key, params) + }) + + /** + * 手动刷新 + * @param {boolean} [force=false] - 跳过 TTL + */ + function refresh(force = false) { + return doFetch(force) + } + + return { data, loading, error, refresh } +} +``` + +--- + +### Task 9: Create `config/preload.config.js` — business config + +**Files:** +- Create: `frontend/config/preload.config.js` + +- [ ] **Step 1: Create preload.config.js** + +```js +// frontend/config/preload.config.js +// API 预加载业务配置 +// 声明每个预拉 key 的:fetcher / ttl / persistence / 触发时机 + +import { + getCastloveConfigApi, + getUserProfileApi, + getHotRankingApi, + getAssetLikersApi, + getActivityDetailApi, + getActivityItemsApi +} from '@/utils/api' + +export const preloadConfig = { + // ── 全局默认 ── + defaults: { + ttl: 5 * 60 * 1000, + persistence: 'memory', + concurrency: 4, + timeout: 10000, + silent: true, + limits: { + maxEntrySizeKB: 1024, + maxMemoryEntries: 100, + maxFileCacheMB: 50 + } + }, + + // ── 启动期预热清单(App.vue onLaunch 跑)── + startup: [ + { + key: 'castlove.config', + fetcher: getCastloveConfigApi, + ttl: 60 * 60 * 1000, + persistence: 'file' + }, + { + key: 'me.profile', + fetcher: getUserProfileApi, + ttl: 10 * 60 * 1000 + } + ], + + // ── idle 预拉清单(首屏渲染完后跑)── + idle: [ + { + key: 'ranking.hot', + fetcher: () => getHotRankingApi('total', null, 1, 10), + ttl: 10 * 60 * 1000 + } + ], + + // ── 页面切换预拉映射(wrappedNavigateTo 命中时触发)── + pages: { + '/pages/asset-detail/asset-detail': [ + { + key: 'asset.likers', + fetcher: (params) => getAssetLikersApi(Number(params.id)) + } + ], + '/pages/activity-detail/activity-detail': [ + { + key: 'activity.detail', + fetcher: (params) => getActivityDetailApi(params.id) + }, + { + key: 'activity.items', + fetcher: (params) => getActivityItemsApi(params.id) + } + ] + } +} +``` + +--- + +### Task 10: Modify `App.vue` — integrate preloadApi + +**Files:** +- Modify: `frontend/App.vue:1-20` (import section) + `onLaunch` + `onShow` + +- [ ] **Step 1: Add preloadApi import and initialization** + +Insert at the top of ` + + +``` + +### 2. 手动失效缓存 + +```js +import { getPreloadApi } from '@/utils/preloadApi/index' + +const api = getPreloadApi() + +// 失效单个 key +api.invalidate('ranking.hot') + +// 失效某个前缀的所有 key +api.invalidatePrefix('asset.') + +// 清空全部内存缓存 +api.invalidateAll() +``` + +### 3. 添加新的预拉配置 + +在 `frontend/config/preload.config.js` 中: + +```js +pages: { + '/pages/new-page/new-page': [ + { key: 'new.data', fetcher: (params) => getNewDataApi(params.id) } + ] +} +``` + +### 4. 替换页面跳转 + +```js +// 旧写法 +uni.navigateTo({ url: '/pages/detail/detail?id=123' }) + +// 新写法(自动触发预拉) +import { getPreloadApi } from '@/utils/preloadApi/index' +const api = getPreloadApi() +api.navigateTo({ url: '/pages/detail/detail?id=123' }) +``` + +## API 速查 + +| 方法 | 说明 | +|------|------| +| `preloadApi.run(key, params?)` | 触发预拉(fire-and-forget),不返回数据 | +| `preloadApi.get(key, params?)` | 读缓存,未命中则拉取 | +| `preloadApi.invalidate(key, params?)` | 失效单个 key(内存) | +| `preloadApi.invalidatePrefix(prefix)` | 失效前缀匹配的所有 key(内存) | +| `preloadApi.invalidateAll()` | 清空全部内存缓存 | +| `preloadApi.clearUser(userId)` | 登出:清空内存 + 删文件缓存目录 | +| `preloadApi.navigateTo(opts)` | 替代 uni.navigateTo | +| `preloadApi.switchTab(opts)` | 替代 uni.switchTab | +| `preloadApi.reLaunch(opts)` | 替代 uni.reLaunch | + +## 配置字段 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `key` | string | (必填) | 逻辑 key,业务引用缓存的唯一标识 | +| `fetcher` | (params) => Promise | (必填) | 请求函数 | +| `ttl` | number | 300000 | 缓存有效期 (ms) | +| `persistence` | 'memory'\|'file' | 'memory' | 缓存存储方式 | +| `timeout` | number | 10000 | 单接口超时 (ms) | +| `silent` | boolean | true | 失败是否静默 | +``` + +--- + +## Verification Checklist + +After all tasks are complete, verify against the acceptance checklist (§8.2): + +- [ ] `preloadApi.run` / `get` / `invalidate*` pass unit tests +- [ ] wrappedNavigateTo hit/miss both correct +- [ ] usePreload composable exposes reactive state correctly +- [ ] 401 / code 7 / code 16 swallowed by `_swallowAuth` +- [ ] query string with `encodeURIComponent` characters parsed correctly +- [ ] App.vue device test: `onLaunch` → startup items hit memory cache +- [ ] wrappedNavigateTo device test: detail page hits preload cache (< 50ms loading flash) +- [ ] Logout → `_doc/preload/{userId}/` directory deleted +- [ ] LRU eviction: 100 keys → 1st kept; 101st key → 1st evicted +- [ ] warmIdle idempotent: 3 consecutive calls → fetcher called only once +- [ ] User switch (onSwitchUser): me.* prefix fully invalidated, oldUser file cache deleted, newUser cache unaffected diff --git a/docs/superpowers/specs/2026-07-02-preload-api-design.md b/docs/superpowers/specs/2026-07-02-preload-api-design.md index 281048e..b480276 100644 --- a/docs/superpowers/specs/2026-07-02-preload-api-design.md +++ b/docs/superpowers/specs/2026-07-02-preload-api-design.md @@ -6,6 +6,79 @@ --- +## ★ 方案概述(必读) + +### 要解决的问题 + +**业务问题**: +- 用户进入详情页时白屏 loading 闪烁(网络请求耗时 200-800ms),体验差 +- 冷启动后首页数据(排行榜、用户配置等公共数据)每次都重新拉取,浪费带宽 +- 用户在页面间反复切换时,相同接口被多次调用,服务端压力大 + +**技术问题**: +- uniapp 没有内置的请求缓存层,每个 `uni.request` 都是独立调用 +- `uni.navigateTo` 跳转和页面数据加载是串行的(先跳转 → 再 `onLoad` 发请求),无法利用"用户点击到页面渲染"之间的时间窗口 +- 没有统一的请求去重机制,同一数据源被多个组件同时请求时产生冗余网络调用 + +### 整体实现路径 + +| 阶段 | 内容 | 预估时间 | +|------|------|----------| +| Phase 1:核心缓存引擎 | `storage.js` + `core.js`(内存 Map + LRU + inFlight 去重 + TTL) | 1 天 | +| Phase 2:调度 & 路由集成 | `scheduler.js` + `navigate.js` + `config.js`(启动预热 / idle 预拉 / 页面切换预拉) | 0.5 天 | +| Phase 3:Vue 集成 | `usePreload.js` composable + `preload.config.js` 业务配置 + `App.vue` / `store/user.js` 集成 | 0.5 天 | +| Phase 4:测试 & 验收 | 单元测试 + 集成冒烟 + 真机验收 | 0.5 天 | +| **合计** | | **2.5 天** | + +### 关键决策 + +| 决策 | 理由 | 详见 | +|------|------|------| +| 核心模块(core.js)零外部依赖 | 可独立测试、不耦合 uniapp / Vue,未来可迁移 | §2.2 | +| wrap navigateTo 不 await 预拉结果 | 点击立刻跳转,预拉后台并发,不增加跳转延迟 | §2.5 | +| 401/业务码7/16 统一 swallow | `api.js` 已同步 `reLaunch` 跳登录页,预加载层再抛错会造成双重跳转 | §5 | +| 内存 LRU + 文件 FIFO 双轨淘汰 | 内存注重热数据命中率(LRU),文件注重简单可靠(FIFO) | §7.1 | +| inFlight 去重(同 key 并发共享 Promise) | 避免启动期 / 页面切换时同一接口被多次调用 | §4.4 | +| 按 userId 物理隔离文件缓存 | 防止用户 A 读到用户 B 的缓存数据 | §6.5 | + +### 核心架构图(TL;DR) + +``` + ┌──────────────────────────┐ + │ preload.config.js │ + │ (业务声明 key/fetcher/ttl) │ + └──────────┬───────────────┘ + │ + ┌───────────┐ ┌─────────────▼──────────────┐ + │ App.vue │────►│ scheduler.js │ + │ onLaunch │ │ warmStartup() / warmIdle() │ + │ onShow │ └─────────────┬──────────────┘ + └───────────┘ │ + │ + ┌───────────┐ ┌─────────────▼──────────────┐ + │ navigate │────►│ core.js │ + │ .js │ │ run / get / invalidate │ + │ (wrap │ │ Map + LRU │ + │ uni.nav*)│ │ inFlight dedup │ + └───────────┘ └──────┬──────────┬──────────┘ + │ │ + ┌────────▼──┐ ┌────▼──────────┐ + │ storage.js│ │ usePreload.js │ + │ (_doc/ │ │ (composable: │ + │ preload/ │ │ data/loading │ + │ {uid}/) │ │ /error/refresh│ + └───────────┘ └───────────────┘ +``` + +### 文档说明 + +- **适用范围**:`frontend/` 下所有 API 调用场景(页面数据加载、启动预热、页面切换预拉),以 App 端(Android/iOS)为主,H5 / 小程序降级兼容 +- **工作量估算**:约 2.5 天(核心 1 天 + 调度&路由 0.5 天 + Vue 集成 0.5 天 + 测试 0.5 天),约 800-1000 行新代码 +- **前置版本/历史**:无。这是项目首个 API 预加载方案 +- **目标读者**:前端开发(需了解如何配置新的预拉 key 和使用 `usePreload`)、架构评审 + +--- + ## 1. 目标与范围 为 uniapp + Vue 3 项目提供一套**通用、可配置、按场景分层的 API 预加载方案**,覆盖 4 类典型场景: @@ -69,9 +142,10 @@ Page onLoad (composable) ─┘ | 文件 | 改动 | | --- | --- | -| `App.vue` | ~10 行:import + `warmStartup()` 到 `onLaunch` + `warmIdle()` 到 `onShow` + patch `SET_USER_INFO` / `CLEAR_AUTH` 注入失效调用 | +| `App.vue` | ~10 行(Options API):import `preloadApi` → `onLaunch` 中调用 `warmStartup()` → `onShow` 中调用 `warmIdle()`。注意 App.vue 使用 Options API(`export default { … }`),直接 `import` + 在生命周期方法内调用即可,**不**是 ` diff --git a/frontend/pages/square/components/HotCategoryBlock.vue b/frontend/pages/square/components/HotCategoryBlock.vue index 92aa880..58db4c8 100644 --- a/frontend/pages/square/components/HotCategoryBlock.vue +++ b/frontend/pages/square/components/HotCategoryBlock.vue @@ -161,9 +161,12 @@ import { getAssetCoverRealUrl, getInstantAssetCoverUrl, } from "@/utils/assetImageHelper.js"; +import { usePreload } from "@/composables/usePreload.js"; + +// ★ 首屏数据走 usePreload(自动缓存 + 切换 tab 回来自动命中) +const { data: firstPageData, loading: firstPageLoading } = usePreload("ranking.xingbang"); // 把后端返回的 cover_url / cover_image 转成真实可访问的 URL -// 处理 3 种形态:/static/... (本地)、相对路径 (需 presign)、完整 URL (可能过期) async function resolveItemUrls(item) { if (!item) return item; const cover = item.cover_url || item.cover_image || ""; @@ -176,30 +179,21 @@ async function resolveItemUrls(item) { const emit = defineEmits(["cardClick"]); const items = ref([]); -const loading = ref(false); +const loading = ref(true); const likingMap = ref({}); const activeTabKey = ref(""); -// 下拉刷新状态:与 scroll-view 的 :refresher-triggered 双向绑定 const refreshing = ref(false); // ===== 分页状态 ===== -// currentPage : 已加载到的页码(从 1 开始) -// hasMore : 是否还有下一页(接口返回数量 < PAGE_SIZE 或累计已 >= total 时置 false) -// loadingMore : 是否正在加载下一页(防止 scrolltolower 重复触发) const currentPage = ref(1); const hasMore = ref(true); const loadingMore = ref(false); -// scroll-into-view 目标元素 id —— 切换 tab 时设为 'grid-top' 把 scroll-view 滚回顶部 -// 通过先置空 → nextTick 设回目标 id 来强制触发(即使上一次已经是同一个 id 也能生效) const scrollIntoView = ref(""); -// 每页数量 const PAGE_SIZE = 10; -// Tab 配置(直接写死在组件内) -// 新增 tab 在这里 push 一项即可:{ key, label, icon, iconWidth, iconHeight, fetch } -// fetch 接受 page 参数,由组件内部分页逻辑统一传入。 +// Tab 配置 const tabs = [ { key: "hot", @@ -253,7 +247,7 @@ const activeTab = computed( () => tabs.find((t) => t.key === activeTabKey.value) || tabs[0], ); -// 初始化 activeTabKey:默认选第一个 tab +// 初始化 activeTabKey watch( () => tabs, (newTabs) => { @@ -267,6 +261,65 @@ watch( { immediate: true }, ); +// ★ 首屏数据:watch usePreload 结果,自动填充 items +let firstPageConsumed = false; +watch(firstPageLoading, (val) => { loading.value = val }); // 同步骨架屏 +watch(firstPageData, (newData) => { + if (!newData || newData.code !== 0 || firstPageConsumed) return; + firstPageConsumed = true; + loading.value = false; + processRawItems(newData.data?.items || [], false); +}, { immediate: true }); + +// fallback:1.5s 后如果 usePreload 还没数据,走手动 fetch +let fallbackTimer = null; +onMounted(() => { + fallbackTimer = setTimeout(() => { + if (firstPageConsumed) return; + loadData({ append: false }); + }, 1500); +}); + +onUnmounted(() => { + if (fallbackTimer) clearTimeout(fallbackTimer); +}); + +// 处理原始 items:转成渲染需要的格式 +function processRawItems(rawItems, append) { + const instant = rawItems.map((it) => { + const id = it.id || it.asset_id; + const rawCover = it.cover_url || it.cover_image || ""; + return { + ...it, + id, + _rawCover: rawCover, + cover_url: getInstantAssetCoverUrl(rawCover), + }; + }); + + const baseOffset = append ? items.value.length : 0; + if (append) { + items.value = [...items.value, ...instant]; + } else { + items.value = instant; + } + + // 后台异步解析预签名 URL + instant.forEach((it, idx) => { + const raw = it._rawCover; + if (!raw) return; + getAssetCoverRealUrl(raw) + .then((realUrl) => { + const targetIdx = baseOffset + idx; + const target = items.value[targetIdx]; + if (target && target.id === it.id && realUrl) { + target.cover_url = realUrl; + } + }) + .catch(() => {}); + }); +} + // 切换 tab const handleTabClick = (e) => { const key = e.currentTarget.dataset.key; @@ -275,12 +328,11 @@ const handleTabClick = (e) => { } }; -// activeTab 变化时重新加载数据(重置分页) +// activeTab 变化时重新加载(重置分页,走手动 fetch) watch(activeTab, () => { resetAndLoad(); }); -// 格式化数量 const formatCount = (count) => { if (!count) return "0"; if (count >= 10000) return (count / 10000).toFixed(1) + "w"; @@ -292,7 +344,7 @@ const handleCardClick = (item) => { emit("cardClick", item); }; -// 监听全局点赞事件,更新状态 +// 监听全局点赞事件 const onAssetLiked = ({ asset_id, data }) => { const index = items.value.findIndex( (item) => (item.asset_id || item.id) === asset_id, @@ -306,7 +358,6 @@ const onAssetLiked = ({ asset_id, data }) => { data?.new_like_count ?? (updatedItems[index].like_count || 0) + 1, }; items.value = updatedItems; - // 触发动画 likingMap.value = { ...likingMap.value, [asset_id]: true }; setTimeout(() => { likingMap.value = { ...likingMap.value, [asset_id]: false }; @@ -314,14 +365,11 @@ const onAssetLiked = ({ asset_id, data }) => { } }; -// 重置分页并重新加载第 1 页 -// 切换 tab 时调用:清空 items、把 currentPage 拉回 1、hasMore 设回 true、滚动到顶。 const resetAndLoad = () => { currentPage.value = 1; hasMore.value = true; loadingMore.value = false; items.value = []; - // 强制滚回顶部:先清空再设回 id,避免上一次值就是 'grid-top' 时不触发 scrollIntoView.value = ""; nextTick(() => { scrollIntoView.value = "grid-top"; @@ -329,23 +377,13 @@ const resetAndLoad = () => { loadData({ append: false }); }; -// 加载数据 -// append=false:首页加载,覆盖 items;append=true:分页追加,拼接到 items 末尾。 -// -// 性能关键:不再 await 预签名!流程: -// 1) 接口返回后立即用 getInstantAssetCoverUrl 同步拿到「能马上渲染的 URL」(命中缓存/未过期URL/占位图) -// 2) 立刻 set items.value → 列表瞬时出现,骨架屏立即下线 -// 3) 后台并行跑 getAssetCoverRealUrl 拿到精确的预签名 URL,逐个 patch 回 items.value[i].cover_url -// (Vue 3 ref 数组里的对象是 reactive 代理,单个属性变更会触发该卡片重渲染) +const PLACEHOLDER_IMAGE = "/static/nft/collection.png"; + +// 手动 fetch(切换 tab / 分页 / 下拉刷新时使用) const loadData = async ({ append = false, silent = false } = {}) => { const tab = activeTab.value; - if (!tab || typeof tab.fetch !== "function") { - console.warn("[HotCategoryBlock] 当前 tab 未配置 fetch:", tab); - if (!append && !silent) items.value = []; - return false; - } - // 首页用 loading 整屏骨架;分页用 loadingMore 底部小指示器 - // silent=true 时跳过骨架屏、保留旧 items(用于下拉刷新,避免列表闪烁/抖动) + if (!tab || typeof tab.fetch !== "function") return false; + if (append) { loadingMore.value = true; } else if (!silent) { @@ -354,60 +392,13 @@ const loadData = async ({ append = false, silent = false } = {}) => { try { const res = await tab.fetch(currentPage.value); if (res && res.code === 0 && res.data?.items) { - // ① 立刻准备好「能马上渲染的」items —— 不等任何网络 - const rawItems = res.data.items.map((it) => { - const id = it.id || it.asset_id; - const rawCover = it.cover_url || it.cover_image || ""; - return { - ...it, - id, - _rawCover: rawCover, // 留一份原始 URL,后台异步任务会用它去预签名 - cover_url: getInstantAssetCoverUrl(rawCover), - }; - }); + processRawItems(res.data.items, append); - // ② 立即上屏 - const baseOffset = append ? items.value.length : 0; - if (append) { - items.value = [...items.value, ...rawItems]; - } else { - items.value = rawItems; - } - - // ③ 后台并行解析预签名 URL,单张图回来就 patch 一张 - // 不 await —— 不阻塞任何渲染 - rawItems.forEach((it, idx) => { - const raw = it._rawCover; - if (!raw) return; - // 同步路径下已经有精确 URL(命中缓存 / 完整未过期)则无需再请求 - const instant = it.cover_url; - if ( - instant && - instant !== PLACEHOLDER_IMAGE && - instant === getInstantAssetCoverUrl(raw) - ) { - // 已是精确 URL;仍然异步校准一次以处理过期(getAssetCoverRealUrl 内部命中缓存就是同步快速返回) - } - getAssetCoverRealUrl(raw) - .then((realUrl) => { - const targetIdx = baseOffset + idx; - const target = items.value[targetIdx]; - // 防御:用户已切 tab / 列表被清空时,跳过 patch - if (target && target.id === it.id && realUrl) { - target.cover_url = realUrl; - } - }) - .catch(() => { - /* 单张失败不影响其它图 */ - }); - }); - - // 判定是否还有下一页 const total = Number(res.data.total ?? 0); if (total > 0) { hasMore.value = items.value.length < total; } else { - hasMore.value = rawItems.length >= PAGE_SIZE; + hasMore.value = res.data.items.length >= PAGE_SIZE; } return true; } else if (!append) { @@ -426,22 +417,12 @@ const loadData = async ({ append = false, silent = false } = {}) => { } }; -// 占位图常量(与 helper 内部 DEFAULT_IMAGE 一致) -const PLACEHOLDER_IMAGE = "/static/nft/collection.png"; - -// 滚动到底部触发加载下一页 const handleScrollToLower = () => { if (loading.value || loadingMore.value || !hasMore.value) return; currentPage.value += 1; loadData({ append: true }); }; -// 处理下拉刷新 -// 设计原则(避免抖动): -// - 不清空 items.value:保留旧列表让用户视觉连续,避免内容高度突变 -// - 不重置 scrollIntoView:用户下拉时已经在顶部,强制滚动会和手势冲突 -// - silent=true 跳过骨架屏:loadData 内部不会触发 loading 状态切换 -// - 新数据回来后整体替换 items.value,scroll-view 平滑过渡到新列表 const handleRefresh = async () => { if (refreshing.value || loading.value) return; refreshing.value = true; @@ -451,25 +432,13 @@ const handleRefresh = async () => { loadingMore.value = false; const success = await loadData({ append: false, silent: true }); if (success) { - uni.showToast({ - title: "刷新成功", - icon: "success", - duration: 1200, - }); + uni.showToast({ title: "刷新成功", icon: "success", duration: 1200 }); } else { - uni.showToast({ - title: "刷新失败", - icon: "none", - duration: 1500, - }); + uni.showToast({ title: "刷新失败", icon: "none", duration: 1500 }); } } catch (e) { console.error("[HotCategoryBlock] 刷新失败", e?.message ?? e); - uni.showToast({ - title: "刷新失败", - icon: "none", - duration: 1500, - }); + uni.showToast({ title: "刷新失败", icon: "none", duration: 1500 }); } finally { setTimeout(() => { refreshing.value = false; @@ -479,7 +448,8 @@ const handleRefresh = async () => { onMounted(() => { uni.$on("assetLiked", onAssetLiked); - loadData(); + // 首屏由 usePreload 提供,不再手动 loadData() + // fallback timer 在 1.5s 后触发 }); onShow(() => { @@ -488,6 +458,7 @@ onShow(() => { onUnmounted(() => { uni.$off("assetLiked", onAssetLiked); + if (fallbackTimer) clearTimeout(fallbackTimer); }); diff --git a/frontend/pages/square/components/StarGalaxy/index.vue b/frontend/pages/square/components/StarGalaxy/index.vue index 5a32b33..372726f 100644 --- a/frontend/pages/square/components/StarGalaxy/index.vue +++ b/frontend/pages/square/components/StarGalaxy/index.vue @@ -9,8 +9,8 @@ - - + + @@ -18,7 +18,7 @@ 加载失败,点击重试 - 重试 + 重试 @@ -53,65 +53,60 @@ + + +``` + +### 2. 手动失效缓存 + +```js +import { getPreloadApi } from '@/utils/preloadApi/index' + +const api = getPreloadApi() + +// 失效单个 key +api.invalidate('ranking.hot') + +// 失效某个前缀的所有 key +api.invalidatePrefix('asset.') + +// 清空全部内存缓存 +api.invalidateAll() +``` + +### 3. 添加新的预拉配置 + +在 `frontend/config/preload.config.js` 中: + +```js +pages: { + '/pages/new-page/new-page': [ + { key: 'new.data', fetcher: (params) => getNewDataApi(params.id) } + ] +} +``` + +### 4. 替换页面跳转 + +```js +// 旧写法 +uni.navigateTo({ url: '/pages/detail/detail?id=123' }) + +// 新写法(自动触发预拉) +import { getPreloadApi } from '@/utils/preloadApi/index' +const api = getPreloadApi() +api.navigateTo({ url: '/pages/detail/detail?id=123' }) +``` + +## API 速查 + +| 方法 | 说明 | +|------|------| +| `preloadApi.run(key, params?)` | 触发预拉(fire-and-forget),不返回数据 | +| `preloadApi.get(key, params?)` | 读缓存,未命中则拉取 | +| `preloadApi.invalidate(key, params?)` | 失效单个 key(内存) | +| `preloadApi.invalidatePrefix(prefix)` | 失效前缀匹配的所有 key(内存) | +| `preloadApi.invalidateAll()` | 清空全部内存缓存 | +| `preloadApi.clearUser(userId)` | 登出:清空内存 + 删文件缓存目录 | +| `preloadApi.navigateTo(opts)` | 替代 uni.navigateTo | +| `preloadApi.switchTab(opts)` | 替代 uni.switchTab | +| `preloadApi.reLaunch(opts)` | 替代 uni.reLaunch | + +## 配置字段 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `key` | string | (必填) | 逻辑 key,业务引用缓存的唯一标识 | +| `fetcher` | (params) => Promise | (必填) | 请求函数 | +| `ttl` | number | 300000 | 缓存有效期 (ms) | +| `persistence` | 'memory'\|'file' | 'memory' | 缓存存储方式 | +| `timeout` | number | 10000 | 单接口超时 (ms) | +| `silent` | boolean | true | 失败是否静默 | diff --git a/frontend/utils/preloadApi/config.js b/frontend/utils/preloadApi/config.js new file mode 100644 index 0000000..28146c3 --- /dev/null +++ b/frontend/utils/preloadApi/config.js @@ -0,0 +1,64 @@ +// 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 } +} diff --git a/frontend/utils/preloadApi/core.js b/frontend/utils/preloadApi/core.js new file mode 100644 index 0000000..8b28f2f --- /dev/null +++ b/frontend/utils/preloadApi/core.js @@ -0,0 +1,508 @@ +// 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, force) +} + +/** + * 失效单个 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, skipFileCache = false) { + 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 && !skipFileCache && 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()) + }) + } +} diff --git a/frontend/utils/preloadApi/index.js b/frontend/utils/preloadApi/index.js new file mode 100644 index 0000000..ee96339 --- /dev/null +++ b/frontend/utils/preloadApi/index.js @@ -0,0 +1,74 @@ +// 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 } diff --git a/frontend/utils/preloadApi/navigate.js b/frontend/utils/preloadApi/navigate.js new file mode 100644 index 0000000..946bc42 --- /dev/null +++ b/frontend/utils/preloadApi/navigate.js @@ -0,0 +1,97 @@ +// 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) + } +} diff --git a/frontend/utils/preloadApi/scheduler.js b/frontend/utils/preloadApi/scheduler.js new file mode 100644 index 0000000..db0df37 --- /dev/null +++ b/frontend/utils/preloadApi/scheduler.js @@ -0,0 +1,40 @@ +// 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) + } + }) +} diff --git a/frontend/utils/preloadApi/storage.js b/frontend/utils/preloadApi/storage.js new file mode 100644 index 0000000..2663021 --- /dev/null +++ b/frontend/utils/preloadApi/storage.js @@ -0,0 +1,368 @@ +// 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) + } +}