feat: 完成全部接口替换为新后端接口

This commit is contained in:
R524809
2026-10-19 13:19:07 +08:00
parent 8de06ce66b
commit 997042789d
40 changed files with 1946 additions and 785 deletions
+49
View File
@@ -2,3 +2,52 @@
直接使用view等原生代替canvas进行渲染,方便用户操作,只在最后导出图片时才渲染canvas。或者每次修改渲染隐藏canvas。 直接使用view等原生代替canvas进行渲染,方便用户操作,只在最后导出图片时才渲染canvas。或者每次修改渲染隐藏canvas。
以此减少交互和适配成本。 以此减少交互和适配成本。
### 公开预览图配置
预览图和固定字体的公开地址统一配置在 `miniprogram/config/assets.ts`:
```typescript
publicDomain: 'https://cdn.joeyone.cn',
keyPrefix: 'doodle-assets/',
```
首页预拉取、旧 pageConfig 缓存、新后端动态兜底,以及年龄推荐、收藏、下载记录和内容管理页的旧预览图,展示前统一通过 `resolvePreviewUrl` 转成七牛地址。旧云存储临时链接也会转换并去掉旧签名;其他环境、外部图片、本地图片和头像不参与此次预览图替换。分类页继续使用新后端返回的七牛地址。重新编译小程序即可使用,无需手工清除 pageConfig 缓存。
`cache/page-config.json` 中的 34 处预览引用已替换。兼容逻辑不修改线上云数据库、云函数或已上传的配置 JSON;旧数据由客户端读取时转换。新的预览上传由管理员上传接口写入七牛,再以 ready 资产 ID 发布元数据。
运行预览图回归测试:`npm run test:assets`;分类页回归:`npm run test:category`。
### 新后端静默登录与鉴权
启动及回到前台时通过 `utils/auth.ts` 非阻塞预热:`wx.login` 取一次性 code,调用 `POST /api/v1/auth/wechat/login`。首次使用 `start`,已有会话续期和 401 恢复使用 `resume`;明确无效 code 最多重新取一次。JWT 与用户摘要按 AppID、接口地址分开保存,凭证到期前 60 秒按需续登。不缓存角色或微信 OpenID,不记录凭证、code 或完整登录响应。
统一请求层 `services/api-request.ts` 默认要求登录并携带 Bearer;公开分类与题型接口显式指定 `auth: 'none'`,不携带凭证、不触发登录。受保护请求遇到可恢复 401 时最多重登并重发一次,并发请求合并登录,迟到的旧 401 不会清掉新凭证。恢复后身份变化时不补发原业务写入。403、限流、服务错误或网络失败不触发自动重登链。
```typescript
import { apiRequest } from './services/api-request';
import { getUser, refreshUser } from './utils/auth';
// 获取新后端用户摘要;必要时静默登录。
const user = await getUser();
// 受保护的 GET /me,读取最新资料;不会先清掉有效凭证。
await refreshUser();
// 新增本人业务接口默认使用统一鉴权;公开接口显式 auth=none。
await apiRequest('/me');
```
账户不存在或禁用时停止自动恢复,不自动切回 start 重建账户;账户历史标记保留在同一个版本化缓存中。个人中心仅在需要用户主动重新开始时显示“开始使用账户”。登录失败不阻塞公开浏览、本地生成和导出。
用户资料、头像上传、收藏、下载记录和账户设置现已全部使用新后端及 `globalData.user`,旧登录兼容层已移除。界面保留少量 `_id` 展示字段映射,来源始终是新后端 ID,不再调用旧云业务。正式环境切换前仍需单独迁移历史用户及关联数据,线上 API 地址当前仍待配置。
验证:`npm run test:auth`;联合回归可执行 `node --import tsx --test tests/auth-api.test.mjs tests/category-api.test.mjs tests/preview-assets.test.mjs`。微信兑换在测试中使用模拟响应,真实一次性 code 需要在微信开发者工具或真机联调;没有加入绕过微信验证的测试登录接口。
### 业务接口切换
本地开发中的小程序运行代码已不再调用 `wx.cloud`;首页继续使用 `wx.getBackgroundFetchData` 获取预拉取首屏,在后台使用新 `/page-config` 刷新,管理改动也会刷新首页。旧云端的预拉取地址、生成与发布链路、定时任务保持原样,新管理开关仅控制新后端的快照刷新任务。
当前已发布的线上版仍使用旧云开发接口。`cloudfunctions/` 中全部旧云函数源码、云开发配置及线上服务均须保留;本地开发版切换接口不代表线上旧版本已停止调用。未经另行明确安排,不删除、停用旧云函数,不改变其接口契约,不清理旧云数据库或云存储,也不迁移现有预拉取模式。
用户接口由 `services/member-api.ts` 与 `utils/auth.ts` 管理,管理页由 `services/admin-api.ts` 适配具名 REST 路由。图片通过本站 multipart 上传接口进入七牛,不暴露 AK/SK;同一次上传恢复沿用 UUID uploadId。管理页只能由数据库中当前角色为 admin 的账户操作,开发版不会绕过权限。
服务端路由、权限与数据迁移说明见 doodle-store 的 `docs/v3.5/小程序云接口迁移清单.md`。旧云端用户、收藏与下载历史本次未自动导入,正式切换前需要另行核对迁移。
+34 -34
View File
@@ -52,7 +52,7 @@
"ageBand": "3-5岁", "ageBand": "3-5岁",
"difficulty": "入门", "difficulty": "入门",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-coloring.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-coloring.jpg",
"badge": "", "badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=number-coloring", "path": "/mathPages/mathDraw/mathDraw?id=number-coloring",
"available": true "available": true
@@ -65,7 +65,7 @@
"ageBand": "4-6岁", "ageBand": "4-6岁",
"difficulty": "基础", "difficulty": "基础",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-sort.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-sort.jpg",
"badge": "", "badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=number-sort", "path": "/mathPages/mathDraw/mathDraw?id=number-sort",
"available": true "available": true
@@ -78,7 +78,7 @@
"ageBand": "5-7岁", "ageBand": "5-7岁",
"difficulty": "进阶", "difficulty": "进阶",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/code-connect.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/code-connect.jpg",
"badge": "", "badge": "",
"path": "/focusPages/focusDraw/focusDraw?id=code-connect", "path": "/focusPages/focusDraw/focusDraw?id=code-connect",
"available": true "available": true
@@ -91,7 +91,7 @@
"ageBand": "4-8岁", "ageBand": "4-8岁",
"difficulty": "基础", "difficulty": "基础",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/chinese/hanzi-sheet.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/chinese/hanzi-sheet.jpg",
"badge": "", "badge": "",
"path": "/chinesePages/handwritingSheet/handwritingSheet?id=hanzi-sheet", "path": "/chinesePages/handwritingSheet/handwritingSheet?id=hanzi-sheet",
"available": true "available": true
@@ -104,7 +104,7 @@
"ageBand": "6-8岁", "ageBand": "6-8岁",
"difficulty": "基础", "difficulty": "基础",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-daily-checkin.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/english/letter-tracing-daily-checkin.jpg",
"badge": "", "badge": "",
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-daily-checkin", "path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-daily-checkin",
"available": true "available": true
@@ -117,7 +117,7 @@
"ageBand": "4-6岁", "ageBand": "4-6岁",
"difficulty": "入门", "difficulty": "入门",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/dot-connect.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/dot-connect.jpg",
"badge": "", "badge": "",
"path": "/focusPages/focusDraw/focusDraw?id=dot-connect", "path": "/focusPages/focusDraw/focusDraw?id=dot-connect",
"available": true "available": true
@@ -132,7 +132,7 @@
"ageBand": "4-6岁", "ageBand": "4-6岁",
"difficulty": "基础", "difficulty": "基础",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-sort.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-sort.jpg",
"badge": "", "badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=number-sort", "path": "/mathPages/mathDraw/mathDraw?id=number-sort",
"available": true "available": true
@@ -145,7 +145,7 @@
"ageBand": "3-5岁", "ageBand": "3-5岁",
"difficulty": "入门", "difficulty": "入门",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-coloring.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-coloring.jpg",
"badge": "", "badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=number-coloring", "path": "/mathPages/mathDraw/mathDraw?id=number-coloring",
"available": true "available": true
@@ -158,7 +158,7 @@
"ageBand": "5-7岁", "ageBand": "5-7岁",
"difficulty": "进阶", "difficulty": "进阶",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/make-ten.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/make-ten.jpg",
"badge": "", "badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=make-ten", "path": "/mathPages/mathDraw/mathDraw?id=make-ten",
"available": true "available": true
@@ -171,7 +171,7 @@
"ageBand": "5-7岁", "ageBand": "5-7岁",
"difficulty": "进阶", "difficulty": "进阶",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/code-connect.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/code-connect.jpg",
"badge": "", "badge": "",
"path": "/focusPages/focusDraw/focusDraw?id=code-connect", "path": "/focusPages/focusDraw/focusDraw?id=code-connect",
"available": true "available": true
@@ -184,7 +184,7 @@
"ageBand": "3-5岁", "ageBand": "3-5岁",
"difficulty": "入门", "difficulty": "入门",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/counting-matching.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/counting-matching.jpg",
"badge": "", "badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=counting-matching", "path": "/mathPages/mathDraw/mathDraw?id=counting-matching",
"available": true "available": true
@@ -205,7 +205,7 @@
"ageBand": "4-6岁", "ageBand": "4-6岁",
"difficulty": "基础", "difficulty": "基础",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-sort.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-sort.jpg",
"badge": "", "badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=number-sort", "path": "/mathPages/mathDraw/mathDraw?id=number-sort",
"available": true "available": true
@@ -218,7 +218,7 @@
"ageBand": "3-5岁", "ageBand": "3-5岁",
"difficulty": "入门", "difficulty": "入门",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-coloring.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-coloring.jpg",
"badge": "", "badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=number-coloring", "path": "/mathPages/mathDraw/mathDraw?id=number-coloring",
"available": true "available": true
@@ -231,7 +231,7 @@
"ageBand": "5-7岁", "ageBand": "5-7岁",
"difficulty": "进阶", "difficulty": "进阶",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/make-ten.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/make-ten.jpg",
"badge": "", "badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=make-ten", "path": "/mathPages/mathDraw/mathDraw?id=make-ten",
"available": true "available": true
@@ -244,7 +244,7 @@
"ageBand": "5-7岁", "ageBand": "5-7岁",
"difficulty": "入门", "difficulty": "入门",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-decompose.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-decompose.jpg",
"badge": "", "badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=number-decompose", "path": "/mathPages/mathDraw/mathDraw?id=number-decompose",
"available": true "available": true
@@ -257,7 +257,7 @@
"ageBand": "3-5岁", "ageBand": "3-5岁",
"difficulty": "入门", "difficulty": "入门",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-find.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-find.jpg",
"badge": "", "badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=number-find", "path": "/mathPages/mathDraw/mathDraw?id=number-find",
"available": true "available": true
@@ -278,7 +278,7 @@
"ageBand": "4-6岁", "ageBand": "4-6岁",
"difficulty": "基础", "difficulty": "基础",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/position-coloring.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/position-coloring.jpg",
"badge": "", "badge": "",
"path": "/focusPages/focusDraw/focusDraw?id=position-coloring", "path": "/focusPages/focusDraw/focusDraw?id=position-coloring",
"available": true "available": true
@@ -291,7 +291,7 @@
"ageBand": "3-6岁", "ageBand": "3-6岁",
"difficulty": "入门", "difficulty": "入门",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/line-recognition.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/line-recognition.jpg",
"badge": "", "badge": "",
"path": "/focusPages/focusDraw/focusDraw?id=line-recognition", "path": "/focusPages/focusDraw/focusDraw?id=line-recognition",
"available": true "available": true
@@ -304,7 +304,7 @@
"ageBand": "5-7岁", "ageBand": "5-7岁",
"difficulty": "进阶", "difficulty": "进阶",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/grid-reasoning.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/grid-reasoning.jpg",
"badge": "", "badge": "",
"path": "/focusPages/focusDraw/focusDraw?id=grid-reasoning", "path": "/focusPages/focusDraw/focusDraw?id=grid-reasoning",
"available": true "available": true
@@ -317,7 +317,7 @@
"ageBand": "5-7岁", "ageBand": "5-7岁",
"difficulty": "进阶", "difficulty": "进阶",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/code-connect.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/code-connect.jpg",
"badge": "", "badge": "",
"path": "/focusPages/focusDraw/focusDraw?id=code-connect", "path": "/focusPages/focusDraw/focusDraw?id=code-connect",
"available": true "available": true
@@ -330,7 +330,7 @@
"ageBand": "4-6岁", "ageBand": "4-6岁",
"difficulty": "基础", "difficulty": "基础",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/shape-symbol.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/shape-symbol.jpg",
"badge": "", "badge": "",
"path": "/focusPages/focusDraw/focusDraw?id=shape-symbol", "path": "/focusPages/focusDraw/focusDraw?id=shape-symbol",
"available": true "available": true
@@ -351,7 +351,7 @@
"ageBand": "5-7岁", "ageBand": "5-7岁",
"difficulty": "入门", "difficulty": "入门",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/pinyin/pinyin-tracing.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/pinyin/pinyin-tracing.jpg",
"badge": "", "badge": "",
"path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-tracing", "path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-tracing",
"available": true "available": true
@@ -364,7 +364,7 @@
"ageBand": "6-8岁", "ageBand": "6-8岁",
"difficulty": "基础", "difficulty": "基础",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/pinyin/pinyin-dictation.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/pinyin/pinyin-dictation.jpg",
"badge": "", "badge": "",
"path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-dictation", "path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-dictation",
"available": true "available": true
@@ -377,7 +377,7 @@
"ageBand": "5-7岁", "ageBand": "5-7岁",
"difficulty": "入门", "difficulty": "入门",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/pinyin/pinyin-tracing-v2.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/pinyin/pinyin-tracing-v2.jpg",
"badge": "", "badge": "",
"path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-tracing-v2", "path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-tracing-v2",
"available": true "available": true
@@ -390,7 +390,7 @@
"ageBand": "6-8岁", "ageBand": "6-8岁",
"difficulty": "基础", "difficulty": "基础",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/pinyin/pinyin-dictation-v2.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/pinyin/pinyin-dictation-v2.jpg",
"badge": "", "badge": "",
"path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-dictation-v2", "path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-dictation-v2",
"available": true "available": true
@@ -403,7 +403,7 @@
"ageBand": "6-8岁", "ageBand": "6-8岁",
"difficulty": "基础", "difficulty": "基础",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/pinyin/pinyin-daily.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/pinyin/pinyin-daily.jpg",
"badge": "", "badge": "",
"path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-daily", "path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-daily",
"available": true "available": true
@@ -424,7 +424,7 @@
"ageBand": "3-6岁", "ageBand": "3-6岁",
"difficulty": "入门", "difficulty": "入门",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/chinese/word-coloring-grid.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/chinese/word-coloring-grid.jpg",
"badge": "", "badge": "",
"path": "/chinesePages/wordColoring/wordColoring?id=word-coloring-grid", "path": "/chinesePages/wordColoring/wordColoring?id=word-coloring-grid",
"available": true "available": true
@@ -437,7 +437,7 @@
"ageBand": "3-6岁", "ageBand": "3-6岁",
"difficulty": "入门", "difficulty": "入门",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/chinese/word-coloring-find.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/chinese/word-coloring-find.jpg",
"badge": "", "badge": "",
"path": "/chinesePages/wordColoring/wordColoring?id=word-coloring-find", "path": "/chinesePages/wordColoring/wordColoring?id=word-coloring-find",
"available": true "available": true
@@ -450,7 +450,7 @@
"ageBand": "4-8岁", "ageBand": "4-8岁",
"difficulty": "基础", "difficulty": "基础",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/chinese/hanzi-sheet.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/chinese/hanzi-sheet.jpg",
"badge": "", "badge": "",
"path": "/chinesePages/handwritingSheet/handwritingSheet?id=hanzi-sheet", "path": "/chinesePages/handwritingSheet/handwritingSheet?id=hanzi-sheet",
"available": true "available": true
@@ -471,7 +471,7 @@
"ageBand": "6-8岁", "ageBand": "6-8岁",
"difficulty": "基础", "difficulty": "基础",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-daily-checkin.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/english/letter-tracing-daily-checkin.jpg",
"badge": "", "badge": "",
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-daily-checkin", "path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-daily-checkin",
"available": true "available": true
@@ -484,7 +484,7 @@
"ageBand": "4-7岁", "ageBand": "4-7岁",
"difficulty": "入门", "difficulty": "入门",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-single.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/english/letter-tracing-single.jpg",
"badge": "", "badge": "",
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-single", "path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-single",
"available": true "available": true
@@ -497,7 +497,7 @@
"ageBand": "5-7岁", "ageBand": "5-7岁",
"difficulty": "入门", "difficulty": "入门",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-case-pairing.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/english/letter-tracing-case-pairing.jpg",
"badge": "", "badge": "",
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-case-pairing", "path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-case-pairing",
"available": true "available": true
@@ -510,7 +510,7 @@
"ageBand": "5-8岁", "ageBand": "5-8岁",
"difficulty": "基础", "difficulty": "基础",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-half.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/english/letter-tracing-half.jpg",
"badge": "", "badge": "",
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-half", "path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-half",
"available": true "available": true
@@ -523,7 +523,7 @@
"ageBand": "5-7岁", "ageBand": "5-7岁",
"difficulty": "入门", "difficulty": "入门",
"icon": "", "icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-two-column.jpg", "previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/english/letter-tracing-two-column.jpg",
"badge": "", "badge": "",
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-two-column", "path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-two-column",
"available": true "available": true
+54 -22
View File
@@ -1,7 +1,11 @@
import { fetchPageConfig } from './services/site-api';
import { normalizePreviewImages } from './config/assets';
import { defaultPrintConfig } from './config/config'; import { defaultPrintConfig } from './config/config';
import { getUser } from './utils/auth'; import { getUser } from './utils/auth';
import { generateUUID, getAppUUID, setAppUUID } from './utils/uuid'; import { generateUUID, getAppUUID, setAppUUID } from './utils/uuid';
let _refreshGeneration = 0;
const STORAGE_KEY_PAGE_CONFIG = 'pageConfig'; const STORAGE_KEY_PAGE_CONFIG = 'pageConfig';
// Pending resolvers for getPageConfig() callers waiting on config // Pending resolvers for getPageConfig() callers waiting on config
@@ -18,11 +22,12 @@ App<IAppOption>({
pageConfigReady: false, pageConfigReady: false,
}, },
onLaunch() { onLaunch() {
wx.cloud.init({ try {
env: 'cloud1-9gifs7a2756e2c87', wx.removeStorageSync('user_info');
traceUser: true, wx.removeStorageSync('user_info_expire');
}); } catch {
/* 旧云登录缓存清理失败不影响新凭证命名空间。 */
}
const accountInfo = wx.getAccountInfoSync(); const accountInfo = wx.getAccountInfoSync();
const env = accountInfo.miniProgram.envVersion || 'release'; const env = accountInfo.miniProgram.envVersion || 'release';
this.globalData.env = env; this.globalData.env = env;
@@ -41,19 +46,22 @@ App<IAppOption>({
this.globalData.printConfig = printConfig; this.globalData.printConfig = printConfig;
} }
// 静默登录(不阻塞页面渲染) // 启动与回到前台共用登录 Promise,不阻塞公开页面渲染。
getUser() this.warmAuthentication();
.then((user) => {
this.globalData.user = user;
})
.catch((err) => {
console.error('静默登录失败', err);
});
void this.loadPageConfig(); void this.loadPageConfig();
}, },
onShow() {}, onShow() {
this.warmAuthentication();
if (this.globalData.pageConfigReady) void this.refreshPageConfig();
},
warmAuthentication() {
void getUser().catch(() => {
console.warn('静默登录暂未完成,公开浏览仍可使用');
});
},
onHide() {}, onHide() {},
@@ -100,20 +108,19 @@ App<IAppOption>({
} }
} }
// L3: 云函数兜底 // L3: 新后端动态兜底;微信预拉取机制和地址保持不变。
const hadCachedConfig = !!config;
if (!config) { if (!config) {
try { try {
const res = (await wx.cloud.callFunction({ config = await fetchPageConfig();
name: 'pageConfigFetch',
})) as { result?: { success?: boolean; data?: any } };
if (res.result?.success && res.result.data) {
config = res.result.data;
}
} catch { } catch {
// 云函数也失败,config 保持 null /* 新后端不可用时继续展示空状态,本地生成仍可使用。 */
} }
} }
// 三种数据入口都先转换预览图,旧缓存会随成功加载写回新地址。
config = normalizePreviewImages(config);
// 写入 globalData // 写入 globalData
if (config) { if (config) {
this.globalData.pageConfig = config; this.globalData.pageConfig = config;
@@ -131,6 +138,31 @@ App<IAppOption>({
resolve(config); resolve(config);
} }
_configResolvers = []; _configResolvers = [];
// 预拉取/缓存先渲染,再用新后端刷新,避免旧静态配置长期覆盖管理修改。
if (hadCachedConfig) void this.refreshPageConfig();
},
async refreshPageConfig() {
const generation = ++_refreshGeneration;
try {
const config = normalizePreviewImages(await fetchPageConfig());
if (generation !== _refreshGeneration) return;
this.globalData.pageConfig = config;
try {
wx.setStorageSync(STORAGE_KEY_PAGE_CONFIG, config);
} catch {
/* 缓存失败不影响当前数据。 */
}
for (const page of getCurrentPages()) {
if (
page.route === 'pages/home/home' &&
typeof page.initData === 'function'
)
void page.initData(config);
}
} catch {
/* 保留已显示的数据,网络错误不清空预拉取首屏。 */
}
}, },
getPageConfig(): Promise<PageConfigData | null> { getPageConfig(): Promise<PageConfigData | null> {
-6
View File
@@ -1,6 +0,0 @@
/**
* 桥接文件 - 保持向后兼容
* 实际实现已迁移到 platform/cloud-adapter.ts
*/
import { callCloud } from '../platform/cloud-adapter';
export default callCloud;
+25 -34
View File
@@ -1,3 +1,5 @@
import { uploadAsset } from '../services/member-api';
import { apiRequest } from '../services/api-request';
import { PAPER_SIZE } from '../constants/colors'; import { PAPER_SIZE } from '../constants/colors';
import { downloadPrint, tryUnlockWithRewardedAd } from '../utils/downloadPrint'; import { downloadPrint, tryUnlockWithRewardedAd } from '../utils/downloadPrint';
import { shouldShowShareGuide, shouldUnlockWithAd } from '../utils/shareGuide'; import { shouldShowShareGuide, shouldUnlockWithAd } from '../utils/shareGuide';
@@ -5,7 +7,6 @@ import { BaseDrawService } from '../core/draw/baseDraw';
import tracker from '../utils/tracker'; import tracker from '../utils/tracker';
import { defaultShareConfig } from '../config/config'; import { defaultShareConfig } from '../config/config';
import { import {
buildWorksheetPreviewCloudPath,
isDebugPublishEnabled, isDebugPublishEnabled,
type DebugPublishConfirmDetail, type DebugPublishConfirmDetail,
type DebugPublishMeta, type DebugPublishMeta,
@@ -117,8 +118,7 @@ export function buildPageSharePath(page?: SharePathPage): string {
const normalized = route.startsWith('/') ? route : `/${route}`; const normalized = route.startsWith('/') ? route : `/${route}`;
const data = page?.data || {}; const data = page?.data || {};
const functionId = (data.functionId || data.worksheetId) as const functionId = (data.functionId || data.worksheetId) as
| string string | undefined;
| undefined;
const currentMode = data.currentMode as string | undefined; const currentMode = data.currentMode as string | undefined;
if (!functionId) { if (!functionId) {
@@ -126,9 +126,7 @@ export function buildPageSharePath(page?: SharePathPage): string {
} }
const modeQuery = const modeQuery =
currentMode && currentMode !== functionId currentMode && currentMode !== functionId ? `&mode=${currentMode}` : '';
? `&mode=${currentMode}`
: '';
return `${normalized}?id=${functionId}${modeQuery}`; return `${normalized}?id=${functionId}${modeQuery}`;
} }
@@ -509,34 +507,28 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
settings: detail.settings, settings: detail.settings,
}); });
const cloudPath = buildWorksheetPreviewCloudPath( const asset = await uploadAsset(
publishMeta.category, processed.tempFilePath,
publishMeta.id, 'preview',
); );
const uploadRes = await wx.cloud.uploadFile({ const {
cloudPath, category,
filePath: processed.tempFilePath, previewImg: _oldPreview,
}); ...metadata
} = publishMeta;
const cloudCall = (await wx.cloud.callFunction({ await apiRequest('/admin/worksheets/publish', {
name: 'worksheetsPublish', method: 'POST',
data: { data: {
...publishMeta, ...metadata,
previewImg: uploadRes.fileID, categoryId: category,
previewAssetId: asset.id,
}, },
})) as { });
result?: { success?: boolean; message?: string };
};
if (!cloudCall.result?.success) {
throw new Error(
cloudCall.result?.message || '云端写入失败',
);
}
this.setData({ this.setData({
debugPublishVisible: false, debugPublishVisible: false,
debugPublishMeta: { debugPublishMeta: {
...publishMeta, ...publishMeta,
previewImg: uploadRes.fileID, previewImg: asset.url,
}, },
}); });
@@ -566,8 +558,7 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
initPageInfo( initPageInfo(
this: PageCanvasInstance, this: PageCanvasInstance,
functionIdOrOptions: functionIdOrOptions:
| string string | { title: string; desc?: string; functionId: string },
| { title: string; desc?: string; functionId: string },
defaultTitle?: string, defaultTitle?: string,
) { ) {
let functionId: string; let functionId: string;
@@ -605,7 +596,9 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
tracker.reportPreviewView(this.route || '', { tracker.reportPreviewView(this.route || '', {
worksheet_id: functionId, worksheet_id: functionId,
worksheet_title: title, worksheet_title: title,
...(this.data.currentMode ? { mode: this.data.currentMode } : {}), ...(this.data.currentMode
? { mode: this.data.currentMode }
: {}),
}); });
}, },
}; };
@@ -670,8 +663,7 @@ export function applyPageMixin(
try { try {
const result = await originalDrawCanvas.apply(this, args); const result = await originalDrawCanvas.apply(this, args);
const startedAt = (this as any)[generationStartKey] as const startedAt = (this as any)[generationStartKey] as
| number number | undefined;
| undefined;
if (startedAt) { if (startedAt) {
delete (this as any)[generationStartKey]; delete (this as any)[generationStartKey];
const worksheetId = const worksheetId =
@@ -693,8 +685,7 @@ export function applyPageMixin(
return result; return result;
} catch (error) { } catch (error) {
const startedAt = (this as any)[generationStartKey] as const startedAt = (this as any)[generationStartKey] as
| number number | undefined;
| undefined;
if (startedAt) { if (startedAt) {
delete (this as any)[generationStartKey]; delete (this as any)[generationStartKey];
tracker.reportGenerateResult(this.route || '', false, { tracker.reportGenerateResult(this.route || '', false, {
+65
View File
@@ -0,0 +1,65 @@
/** 与后端及 asset-sync 的公开资源配置保持一致;此处不包含上传凭据。 */
export const assetConfig = {
publicDomain: 'https://cdn.joeyone.cn',
keyPrefix: 'doodle-assets/',
};
/** 固定字体和页面预览使用相对路径;接口返回的资源 URL 直接使用。 */
export function publicAssetUrl(relativePath: string): string {
const parts = relativePath.split('/');
if (
relativePath.includes('\\') ||
[...relativePath].some(
(char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127,
) ||
parts.some((part) => !part || part === '.' || part === '..')
)
throw new Error('资源相对路径不正确');
const domain = assetConfig.publicDomain.replace(/\/+$/, '');
const prefix = assetConfig.keyPrefix.replace(/\/+$/, '');
return `${domain}/${[...(prefix ? prefix.split('/') : []), ...parts].map(encodeURIComponent).join('/')}`;
}
const LEGACY_PREVIEW_PREFIX =
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/';
const LEGACY_PREVIEW_HTTPS =
/^https?:\/\/636c-cloud1-9gifs7a2756e2c87-1351593184\.tcb\.qcloud\.la\/([^?#]+)(?:[?#].*)?$/;
/** 旧预拉取、缓存和云函数中的预览图指向已迁移的公开目录;其他资源不作替换。 */
export function resolvePreviewUrl(value: unknown): string {
if (typeof value !== 'string') return '';
const raw = value.trim();
let path: string;
if (raw.startsWith(LEGACY_PREVIEW_PREFIX)) {
path = raw.slice(LEGACY_PREVIEW_PREFIX.length);
if (/[?#]/.test(path)) return '';
} else {
const match = raw.match(LEGACY_PREVIEW_HTTPS);
if (!match) return raw;
path = match[1];
}
try {
path = path.split('/').map(decodeURIComponent).join('/');
if (!path.startsWith('assets/previews/')) return raw;
return publicAssetUrl(path);
} catch {
// 畸形路径不继续请求旧云存储,也不生成越界的 CDN 地址。
return '';
}
}
/** 统一处理预拉取/缓存中的嵌套 previewImg;不修改原数据、头像和其他云文件引用。 */
export function normalizePreviewImages<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => normalizePreviewImages(item)) as T;
}
if (!value || typeof value !== 'object') return value;
const result = { ...value } as Record<string, unknown>;
for (const key of Object.keys(result)) {
result[key] =
key === 'previewImg'
? resolvePreviewUrl(result[key])
: normalizePreviewImages(result[key]);
}
return result as T;
}
+5 -15
View File
@@ -3,36 +3,26 @@
* *
* 使用 wx.loadFontFace 将字体注册到 Canvas native 渲染管线, * 使用 wx.loadFontFace 将字体注册到 Canvas native 渲染管线,
* 按 URL 去重,同一字体只加载一次。 * 按 URL 去重,同一字体只加载一次。
* 支持 HTTP/HTTPS 直链和 cloud:// 云存储文件 ID。 * 仅使用已配置的 HTTPS 字体地址。
*/ */
/** 字体加载所需的最小描述 */ /** 字体加载所需的最小描述 */
export interface FontFace { export interface FontFace {
/** wx.loadFontFace 的 family 参数 */ /** wx.loadFontFace 的 family 参数 */
name: string; name: string;
/** 字体文件 URL(HTTP/HTTPS)或 cloud:// 文件 ID */ /** 字体文件 HTTPS URL */
url: string; url: string;
} }
const _loadedIds = new Set<string>(); const _loadedIds = new Set<string>();
/**
* 将 cloud:// 文件 ID 解析为临时 HTTPS URL。
* 非 cloud:// 的 URL 原样返回。
*/
async function resolveUrl(raw: string): Promise<string> {
if (!raw.startsWith('cloud://')) return raw;
const res = await wx.cloud.getTempFileURL({ fileList: [raw] });
const file = res.fileList?.[0];
if (file?.tempFileURL) return file.tempFileURL;
throw new Error(`cloud file resolve failed: ${raw}`);
}
/** 加载字体,同一 url 只加载一次 */ /** 加载字体,同一 url 只加载一次 */
export async function loadFontFace(font: FontFace): Promise<void> { export async function loadFontFace(font: FontFace): Promise<void> {
if (_loadedIds.has(font.url)) return; if (_loadedIds.has(font.url)) return;
const resolved = await resolveUrl(font.url); if (!font.url.startsWith('https://') || /["\r\n]/.test(font.url))
throw new Error('字体地址须为 HTTPS');
const resolved = font.url;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
wx.loadFontFace({ wx.loadFontFace({
+4 -8
View File
@@ -1,3 +1,5 @@
import { publicAssetUrl } from '../../config/assets';
/** /**
* 字体渲染配置(Font Profile) * 字体渲染配置(Font Profile)
* *
@@ -12,10 +14,7 @@
/** 字母分类 */ /** 字母分类 */
export type LetterCategory = export type LetterCategory =
| 'uppercase' 'uppercase' | 'tallLower' | 'descender' | 'lowercase';
| 'tallLower'
| 'descender'
| 'lowercase';
/** 单个分类的渲染参数 */ /** 单个分类的渲染参数 */
export interface CategoryMetrics { export interface CategoryMetrics {
@@ -48,9 +47,6 @@ export function fontFamilyOf(profile: FontProfile): string {
return `${profile.name}, Roboto, sans-serif`; return `${profile.name}, Roboto, sans-serif`;
} }
const CLOUD_PREFIX =
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/';
// ── 字母分类集合 ── // ── 字母分类集合 ──
const TALL_LOWERCASE = new Set(['b', 'd', 'h', 'k', 'l']); const TALL_LOWERCASE = new Set(['b', 'd', 'h', 'k', 'l']);
const DESCENDERS = new Set(['g', 'p', 'q', 'y']); const DESCENDERS = new Set(['g', 'p', 'q', 'y']);
@@ -213,7 +209,7 @@ export const PRINT_CLEARLY_DASHED: FontProfile = {
/** ToneOZ Pinyin 拼音字体(含声调字母、ü 变体等专用字形) */ /** ToneOZ Pinyin 拼音字体(含声调字母、ü 变体等专用字形) */
export const TONEOZ_PINYIN: FontProfile = { export const TONEOZ_PINYIN: FontProfile = {
name: 'ToneOZPinyin', name: 'ToneOZPinyin',
url: `${CLOUD_PREFIX}assets/fonts/ToneOZ-Pinyin-Regular.ttf`, url: publicAssetUrl('assets/fonts/ToneOZ-Pinyin-Regular.ttf'),
categories: { categories: {
uppercase: { scale: 1, baselineOffset: 0 }, uppercase: { scale: 1, baselineOffset: 0 },
tallLower: { scale: 1, baselineOffset: 0 }, tallLower: { scale: 1, baselineOffset: 0 },
+9 -15
View File
@@ -1,3 +1,5 @@
import { fetchAgeRecommendations } from '../../services/site-api';
import { resolvePreviewUrl } from '../../config/assets';
import { appSharePageMethods } from '../../base/pageMixin'; import { appSharePageMethods } from '../../base/pageMixin';
import { AGE_BANDS, AgeBandKey } from '../../core/data/difficulty'; import { AGE_BANDS, AgeBandKey } from '../../core/data/difficulty';
import { parseMiniProgramUrl } from '../../utils/index'; import { parseMiniProgramUrl } from '../../utils/index';
@@ -91,24 +93,17 @@ async function fetchAgeRecommended(
ageKey: AgeBandKey, ageKey: AgeBandKey,
): Promise<RecommendedItemView[]> { ): Promise<RecommendedItemView[]> {
try { try {
const res = await wx.cloud.callFunction({ const response = await fetchAgeRecommendations(ageKey);
name: 'ageRecommendedQuery', const items = response.items;
data: { ageKey },
});
const payload = (res && (res.result as any)) || null;
if (!payload?.success) return [];
const items = (payload.data?.items || []) as any[];
return items return items
.map((it) => ({ .map((it) => ({
title: String(it.title || ''), title: String(it.title || ''),
image: String(it.previewImg || ''), image: resolvePreviewUrl(it.previewImg),
subtitle: String(it.subtitle || ''), subtitle: String(it.subtitle || ''),
ageBand: String(it.ageBand || it.ageRange || ''), ageBand: `${it.ageMin}-${it.ageMax}岁`,
difficulty: toDifficultyLabel( difficulty: toDifficultyLabel(it.difficulty || ''),
it.difficultyLabel || it.difficulty || '',
),
path: String(it.path || ''), path: String(it.path || ''),
worksheetId: String(it.worksheetId || ''), worksheetId: String(it.id || ''),
})) }))
.filter((it) => it.title); .filter((it) => it.title);
} catch (e) { } catch (e) {
@@ -174,8 +169,7 @@ Page({
onTapCard(e: WechatMiniprogram.CustomEvent) { onTapCard(e: WechatMiniprogram.CustomEvent) {
const detail = (e.detail || {}) as { title?: string; path?: string }; const detail = (e.detail || {}) as { title?: string; path?: string };
const path = const path =
detail.path || detail.path || (e.currentTarget.dataset.path as string | undefined);
(e.currentTarget.dataset.path as string | undefined);
const title = const title =
detail.title || detail.title ||
(e.currentTarget.dataset.title as string | undefined); (e.currentTarget.dataset.title as string | undefined);
+2 -1
View File
@@ -1,3 +1,4 @@
import { resolvePreviewUrl } from '../../config/assets';
import type { ApiCategory, ApiWorksheet } from '../../services/content-api'; import type { ApiCategory, ApiWorksheet } from '../../services/content-api';
export interface CategoryTab { export interface CategoryTab {
@@ -95,7 +96,7 @@ export function buildCategoryDataset(
title: row.title, title: row.title,
subtitle: row.subtitle, subtitle: row.subtitle,
icon: icons.get(row.category) || '📄', icon: icons.get(row.category) || '📄',
previewImg: row.previewImg || '', previewImg: resolvePreviewUrl(row.previewImg),
ageBand: `${row.ageMin}-${row.ageMax}岁`, ageBand: `${row.ageMin}-${row.ageMax}岁`,
difficultyLabel: DIFFICULTY_LABELS[row.difficulty - 1] || '基础', difficultyLabel: DIFFICULTY_LABELS[row.difficulty - 1] || '基础',
path: row.path, path: row.path,
+6
View File
@@ -222,6 +222,9 @@ Page({
this.setData({ this.setData({
favoriteList: records.map((r) => mapFavoriteToItem(r)), favoriteList: records.map((r) => mapFavoriteToItem(r)),
}); });
} catch {
this.setData({ favoriteList: [] });
wx.showToast({ title: '收藏加载失败,请重试', icon: 'none' });
} finally { } finally {
this.setData({ favLoading: false }); this.setData({ favLoading: false });
} }
@@ -232,6 +235,9 @@ Page({
try { try {
const records = await getDownloadLogs(1, DOWNLOAD_HISTORY_MAX); const records = await getDownloadLogs(1, DOWNLOAD_HISTORY_MAX);
this.setData({ downloadSections: groupDownloadsByDate(records) }); this.setData({ downloadSections: groupDownloadsByDate(records) });
} catch {
this.setData({ downloadSections: [] });
wx.showToast({ title: '下载记录加载失败,请重试', icon: 'none' });
} finally { } finally {
this.setData({ dlLoading: false }); this.setData({ dlLoading: false });
} }
+8 -6
View File
@@ -1,3 +1,4 @@
import { publicAssetUrl } from '../../config/assets';
import { CATEGORY_LIST_WITH_ALL } from '../../core/data/categories'; import { CATEGORY_LIST_WITH_ALL } from '../../core/data/categories';
import { AGE_BANDS } from '../../core/data/difficulty'; import { AGE_BANDS } from '../../core/data/difficulty';
import { CategoryId } from '../../core/models/category'; import { CategoryId } from '../../core/models/category';
@@ -91,8 +92,9 @@ export const HOME_DAILY_CHECKIN_ITEMS: HomeDisplayItem[] = [
ageBand: '5-8岁', ageBand: '5-8岁',
difficulty: '基础', difficulty: '基础',
icon: '📝', icon: '📝',
previewImg: previewImg: publicAssetUrl(
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/chinese/hanzi-daily-checkin.jpg', 'assets/previews/chinese/hanzi-daily-checkin.jpg',
),
path: '/chinesePages/handwritingSheet/handwritingSheet?id=hanzi-daily-checkin', path: '/chinesePages/handwritingSheet/handwritingSheet?id=hanzi-daily-checkin',
available: true, available: true,
}, },
@@ -104,8 +106,7 @@ export const HOME_DAILY_CHECKIN_ITEMS: HomeDisplayItem[] = [
ageBand: '6-8岁', ageBand: '6-8岁',
difficulty: '基础', difficulty: '基础',
icon: '🔤', icon: '🔤',
previewImg: previewImg: publicAssetUrl('assets/previews/pinyin/pinyin-daily.jpg'),
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/pinyin/pinyin-daily.jpg',
path: '/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-daily', path: '/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-daily',
available: true, available: true,
}, },
@@ -117,8 +118,9 @@ export const HOME_DAILY_CHECKIN_ITEMS: HomeDisplayItem[] = [
ageBand: '6-8岁', ageBand: '6-8岁',
difficulty: '基础', difficulty: '基础',
icon: '🔠', icon: '🔠',
previewImg: previewImg: publicAssetUrl(
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-daily-checkin.jpg', 'assets/previews/english/letter-tracing-daily-checkin.jpg',
),
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-daily-checkin', path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-daily-checkin',
available: true, available: true,
}, },
+20 -5
View File
@@ -1,3 +1,4 @@
import { resolvePreviewUrl } from '../../config/assets';
import { appSharePageMethods } from '../../base/pageMixin'; import { appSharePageMethods } from '../../base/pageMixin';
import { NAV_INNER_PX } from '../../utils/navMetrics'; import { NAV_INNER_PX } from '../../utils/navMetrics';
import { import {
@@ -52,6 +53,7 @@ const TAB_BAR_PATHS = new Set([
function toItemView(item: HomeDisplayItem): HomeDisplayItemView { function toItemView(item: HomeDisplayItem): HomeDisplayItemView {
return { return {
...item, ...item,
previewImg: resolvePreviewUrl(item.previewImg),
}; };
} }
@@ -218,11 +220,11 @@ Page({
void this.initData(); void this.initData();
}, },
async initData() { async initData(override?: PageConfigData) {
wx.showLoading({ title: '加载中', mask: false }); if (!override) wx.showLoading({ title: '加载中', mask: false });
const app = getApp<IAppOption>(); const app = getApp<IAppOption>();
const config = await app.getPageConfig(); const config = override || (await app.getPageConfig());
let homeData: HomeDisplayDataset; let homeData: HomeDisplayDataset;
@@ -249,13 +251,26 @@ Page({
searchPlaceholder: homeData.searchPlaceholder, searchPlaceholder: homeData.searchPlaceholder,
tabs, tabs,
ageBands, ageBands,
activeAgeBand: homeData.ageBands[0]?.key || '3-4', activeAgeBand:
override &&
homeData.ageBands.some(
(band) => band.key === this.data.activeAgeBand,
)
? this.data.activeAgeBand
: homeData.ageBands[0]?.key || '3-4',
activeTab: tabs.some((tab) => tab.id === this.data.activeTab)
? this.data.activeTab
: 'all',
heroSwiperCurrent: Math.min(
this.data.heroSwiperCurrent,
Math.max(0, homeData.featured.length - 1),
),
featuredItems: homeData.featured.map(toItemView), featuredItems: homeData.featured.map(toItemView),
hotRecommends: homeData.hot.slice(0, 3).map(toItemView), hotRecommends: homeData.hot.slice(0, 3).map(toItemView),
sections, sections,
}); });
wx.hideLoading(); if (!override) wx.hideLoading();
}, },
// ── Tab & Navigation ── // ── Tab & Navigation ──
+14 -2
View File
@@ -123,7 +123,7 @@ page {
box-sizing: border-box; box-sizing: border-box;
} }
.profile-hero__stat+.profile-hero__stat { .profile-hero__stat + .profile-hero__stat {
margin-left: 32rpx; margin-left: 32rpx;
} }
@@ -164,7 +164,7 @@ page {
box-sizing: border-box; box-sizing: border-box;
} }
.profile-grid__cell+.profile-grid__cell { .profile-grid__cell + .profile-grid__cell {
margin-left: 32rpx; margin-left: 32rpx;
} }
@@ -462,3 +462,15 @@ page {
.profile-editor__action--disabled { .profile-editor__action--disabled {
opacity: 0.6; opacity: 0.6;
} }
/* 仅在账户恢复需要用户主动确认时展示,正常静默登录不出现。 */
.profile-account-restart {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 24rpx;
margin-bottom: @section-gap-xs;
border-radius: @radius-xl;
background: @bg-white;
font-size: 26rpx;
}
+52 -30
View File
@@ -1,7 +1,7 @@
import { uploadAsset, memberStats } from '../../services/member-api';
import { getUser as getServerUser, startAccount } from '../../utils/auth';
import { disablePageShareMenu } from '../../base/pageMixin'; import { disablePageShareMenu } from '../../base/pageMixin';
import { APP_VERSION } from '../../config/config'; import { APP_VERSION } from '../../config/config';
import { getDownloadLogCount } from '../../utils/downloadLogs';
import { getFavoriteCount } from '../../utils/favorites';
import { import {
getCachedUser, getCachedUser,
getUser, getUser,
@@ -22,6 +22,8 @@ Page({
draftNickName: '', draftNickName: '',
draftAvatarUrl: '/assets/imgs/doodle-head.png', draftAvatarUrl: '/assets/imgs/doodle-head.png',
isSavingProfile: false, isSavingProfile: false,
serverAccountActionRequired: false,
isStartingAccount: false,
}, },
onLoad() { onLoad() {
@@ -35,17 +37,52 @@ Page({
}, },
onShow() { onShow() {
void this.checkServerAccount();
this.syncUserInfoFromApp(); this.syncUserInfoFromApp();
void this.refreshStats(); void this.refreshStats();
}, },
/** 正常登录始终静默;账户不存在时才展示用户主动重新开始的入口。 */
async checkServerAccount() {
try {
await getServerUser();
this.setData({ serverAccountActionRequired: false });
} catch (error) {
const reason = (error as { reason?: string }).reason;
this.setData({
serverAccountActionRequired:
reason === 'ACCOUNT_NOT_FOUND' ||
reason === 'ACCOUNT_RESTART_REQUIRED',
});
}
},
async onStartServerAccount() {
if (this.data.isStartingAccount) return;
this.setData({ isStartingAccount: true });
try {
await startAccount();
this.setData({ serverAccountActionRequired: false });
wx.showToast({ title: '账户已就绪', icon: 'success' });
} catch {
wx.showToast({ title: '账户暂不可用,请稍后重试', icon: 'none' });
} finally {
this.setData({ isStartingAccount: false });
}
},
async syncUserInfoFromApp() { async syncUserInfoFromApp() {
const app = getApp<IAppOption>(); const app = getApp<IAppOption>();
const cachedUser = app.globalData.user || getCachedUser(); const cachedUser = app.globalData.user || getCachedUser();
if (cachedUser) { if (cachedUser) this.applyUserInfo(cachedUser);
this.applyUserInfo(cachedUser); else
} this.setData({
userName: '微信用户',
avatarUrl: this.DEFAULT_AVATAR_URL,
printCount: 0,
favoriteCount: 0,
});
try { try {
const latestUser = await getUser(); const latestUser = await getUser();
@@ -69,13 +106,11 @@ Page({
async refreshStats() { async refreshStats() {
try { try {
const [printCount, favoriteCount] = await Promise.all([ const stats = await memberStats();
getDownloadLogCount(), const { downloadCount: printCount, favoriteCount } = stats;
getFavoriteCount(),
]);
this.setData({ printCount, favoriteCount }); this.setData({ printCount, favoriteCount });
} catch (e) { } catch {
console.error('refreshStats failed', e); this.setData({ printCount: 0, favoriteCount: 0 });
} }
}, },
@@ -121,12 +156,12 @@ Page({
this.setData({ isSavingProfile: true }); this.setData({ isSavingProfile: true });
try { try {
const avatarUrl = await this.persistAvatarIfNeeded( const avatarAssetId = await this.persistAvatarIfNeeded(
this.data.draftAvatarUrl, this.data.draftAvatarUrl,
); );
const user = await updateUserProfile({ const user = await updateUserProfile({
nickName, nickName,
avatarUrl, avatarAssetId,
}); });
this.applyUserInfo(user); this.applyUserInfo(user);
@@ -140,24 +175,11 @@ Page({
} }
}, },
async persistAvatarIfNeeded(avatarUrl: string): Promise<string> { async persistAvatarIfNeeded(avatarUrl: string): Promise<string | null> {
if (!avatarUrl) return avatarUrl;
if (
avatarUrl.startsWith('cloud://') ||
avatarUrl.startsWith('http://') ||
avatarUrl.startsWith('https://')
) {
return avatarUrl;
}
const user = await getUser(); const user = await getUser();
const cloudPath = `user-avatars/${user._id}-${Date.now()}.png`; if (!avatarUrl || avatarUrl === this.DEFAULT_AVATAR_URL) return null;
const uploadRes = await wx.cloud.uploadFile({ if (avatarUrl === user.avatarUrl) return user.avatarAssetId || null;
cloudPath, return (await uploadAsset(avatarUrl, 'avatar')).id;
filePath: avatarUrl,
});
return uploadRes.fileID;
}, },
onGoToDownloads() { onGoToDownloads() {
+13
View File
@@ -38,6 +38,19 @@
</view> </view>
</view> </view>
<view
wx:if="{{serverAccountActionRequired}}"
class="profile-account-restart">
<text>账户需要重新开始使用</text>
<button
size="mini"
loading="{{isStartingAccount}}"
disabled="{{isStartingAccount}}"
bindtap="onStartServerAccount">
开始使用账户
</button>
</view>
<!-- 双格入口 --> <!-- 双格入口 -->
<view class="profile-grid"> <view class="profile-grid">
<view class="profile-grid__cell" bindtap="onGoToFavorites"> <view class="profile-grid__cell" bindtap="onGoToFavorites">
-70
View File
@@ -1,70 +0,0 @@
/**
* 云服务平台适配器
* 封装微信云开发 API
*/
export interface CloudResult {
code?: string | number;
message?: string;
info?: string;
data?: any;
[key: string]: any;
}
/**
* 调用云函数
*/
export async function callCloud(
action: string,
options?: Record<string, any>,
): Promise<CloudResult> {
try {
const name = 'doodle';
const { errMsg, result } = await wx.cloud.callFunction({
name,
data: {
action,
...options,
},
});
if (!/ok/.test(errMsg)) {
wx.showToast({
title: '服务器错误',
icon: 'none',
duration: 2000,
});
return {
code: '500',
info: '服务器繁忙',
message: errMsg,
};
}
return result as CloudResult;
} catch (e: any) {
return { code: '500', info: e.message || '服务器繁忙' };
}
}
/**
* 查询云数据库集合
*/
export async function queryCollection(
collection: string,
where?: Record<string, any>,
limit: number = 20,
orderBy?: { field: string; order: 'asc' | 'desc' },
): Promise<any[]> {
try {
const db = wx.cloud.database();
let query = db.collection(collection).where(where || {}).limit(limit);
if (orderBy) {
query = query.orderBy(orderBy.field, orderBy.order);
}
const { data } = await query.get();
return data;
} catch (e) {
console.error('queryCollection failed:', collection, e);
return [];
}
}
+170
View File
@@ -0,0 +1,170 @@
import { apiRequest } from './api-request';
import type { ApiWorksheet } from './content-api';
export type AdminAction =
| 'categories.list'
| 'categories.create'
| 'categories.update'
| 'worksheets.list'
| 'worksheets.status'
| 'worksheets.batch'
| 'site.config'
| 'site.rebuild'
| 'site.timer';
interface AdminWorksheet extends ApiWorksheet {
status: 'draft' | 'active' | 'hidden';
likesDisplay: string;
downloadsDisplay: string;
likesSeed: string;
downloadsSeed: string;
}
interface AdminPage {
items: AdminWorksheet[];
total: number;
hasNext: boolean;
page: number;
pageSize: number;
}
/** 管理页迁移适配:只调用具名 REST 接口,旧页面展示字段在客户端映射,不传旧用户身份。 */
export async function managementRequest<T>(
action: AdminAction,
payload: Record<string, unknown> = {},
): Promise<{
success: boolean;
data?: T;
stats?: { total: number; active: number; draft: number; hidden: number };
message?: string;
}> {
let data: unknown;
let stats;
switch (action) {
case 'categories.list': {
const rows =
await apiRequest<Array<{ id: string }>>('/admin/categories');
data = rows.map((row) => ({ ...row, _id: row.id }));
break;
}
case 'categories.create':
data = await apiRequest('/admin/categories', {
method: 'POST',
data: payload,
});
break;
case 'categories.update': {
const { id, ...fields } = payload;
data = await apiRequest(
`/admin/categories/${encodeURIComponent(String(id))}`,
{ method: 'PUT', data: fields },
);
break;
}
case 'worksheets.list': {
const rows: AdminWorksheet[] = [];
let expected: number | undefined;
for (let page = 1; ; page++) {
const response = await apiRequest<AdminPage>(
'/admin/catalog/worksheets',
{
data: {
page,
pageSize: 100,
...(payload.category
? { categoryId: payload.category }
: {}),
...(payload.status
? { status: payload.status }
: {}),
},
},
);
if (
response.page !== page ||
response.pageSize !== 100 ||
!Array.isArray(response.items) ||
!Number.isSafeInteger(response.total) ||
(expected !== undefined && response.total !== expected) ||
response.hasNext !== page * 100 < response.total
)
throw new Error('管理目录发生变化,请重试');
expected = response.total;
rows.push(...response.items);
if (!response.hasNext) break;
}
if (
rows.length !== expected ||
new Set(rows.map((row) => row.id)).size !== rows.length
)
throw new Error('管理目录发生变化,请重试');
data = rows.map((row) => ({
...row,
_id: row.id,
likes: payload.rawStats ? row.likes : row.likesDisplay,
downloads: payload.rawStats
? row.downloads
: row.downloadsDisplay,
}));
stats = {
total: rows.length,
active: rows.filter((row) => row.status === 'active').length,
draft: rows.filter((row) => row.status === 'draft').length,
hidden: rows.filter((row) => row.status === 'hidden').length,
};
break;
}
case 'worksheets.status':
data = await apiRequest(
`/admin/worksheets/${encodeURIComponent(String(payload.id))}/status`,
{ method: 'PUT', data: { status: payload.status } },
);
break;
case 'worksheets.batch': {
const patches = (
payload.patches as Array<Record<string, unknown>>
).map(({ likes_seed, downloads_seed, ...row }) => ({
...row,
...(likes_seed === undefined ? {} : { likesSeed: likes_seed }),
...(downloads_seed === undefined
? {}
: { downloadsSeed: downloads_seed }),
}));
data = await apiRequest('/admin/worksheets/batch', {
method: 'POST',
timeout: 60000,
data: { patches, dryRun: payload.dryRun === true },
});
break;
}
case 'site.config':
data = await apiRequest('/admin/site/config');
break;
case 'site.rebuild':
data = await apiRequest('/admin/site/rebuild', {
method: 'POST',
timeout: 60000,
data: payload,
});
break;
case 'site.timer':
data = await apiRequest('/admin/site/timer', {
method: 'PUT',
data: { enabled: payload.enabled },
});
break;
default:
throw new Error('不支持的管理操作');
}
if (
[
'categories.create',
'categories.update',
'worksheets.status',
'worksheets.batch',
'site.rebuild',
].includes(action)
) {
const app = getApp<IAppOption>();
if (typeof app.refreshPageConfig === 'function')
await app.refreshPageConfig();
}
return { success: true, data: data as T, ...(stats ? { stats } : {}) };
}
+89 -61
View File
@@ -1,74 +1,102 @@
import { getApiConfig } from '../config/api'; import { getApiConfig } from '../config/api';
import {
ApiRequestError,
requestApi,
type RequestOptions,
} from './api-transport';
import {
cachedServerUser,
clearSession,
ensureSession,
getAuthContext,
recoverSession,
rejectSession,
} from './auth-session';
export { ApiRequestError } from './api-transport';
interface ApiEnvelope<T> { const recoverable = new Set([
code: number; 'AUTH_REQUIRED',
message: string; 'AUTH_INVALID',
data: T; 'AUTH_EXPIRED',
requestId?: string; 'AUTH_REVOKED',
} ]);
const blocked = new Set([
'ACCOUNT_NOT_FOUND',
'ACCOUNT_DISABLED',
'WECHAT_LOGIN_BLOCKED',
]);
/** 保留有限的状态用于页面反馈,不把服务器原始响应或凭证输出到界面。 */ /** 公开接口显式 auth=none;其余接口合并登录,最多恢复并重发一次,不跨账户补发。 */
export class ApiRequestError extends Error {
constructor(
message: string,
readonly statusCode: number = 0,
) {
super(message);
this.name = 'ApiRequestError';
}
}
/** 新后端统一请求层:校验 HTTP 与业务状态,旧云函数/CDN 请求保持独立。 */
export async function apiRequest<T>( export async function apiRequest<T>(
path: string, path: string,
options: { options: RequestOptions & { auth?: 'required' | 'none' } = {},
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
data?: Record<string, unknown>;
header?: Record<string, string>;
} = {},
): Promise<T> { ): Promise<T> {
const config = getApiConfig(); // Authorization 由会话层统一提供;公开请求不附带调用方误传的凭证。
if (!path.startsWith('/') || path.startsWith('//')) const header = Object.fromEntries(
throw new Error('接口路径不正确'); Object.entries(options.header || {}).filter(
return new Promise((resolve, reject) => { ([key]) => key.toLowerCase() !== 'authorization',
wx.request<ApiEnvelope<T>>({
url: `${config.baseUrl}${path}`,
method: options.method || 'GET',
data: options.data,
header: { 'Content-Type': 'application/json', ...options.header },
timeout: config.timeout,
success(res) {
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(
new ApiRequestError(
res.statusCode === 404
? '内容暂不可用'
: '接口请求失败,请稍后重试',
res.statusCode,
), ),
); );
return; if (options.auth === 'none')
return requestApi(path, { ...options, header }, getApiConfig());
const context = getAuthContext();
const previousUserId = cachedServerUser()?.id;
const initial = await ensureSession(context);
if (previousUserId && initial.user.id !== previousUserId) {
clearSession(false, context);
throw new ApiRequestError(
'账户已变化,请重新操作',
401,
'ACCOUNT_CHANGED',
);
} }
const send = async (accessToken: string) => {
if (getAuthContext().key !== context.key)
throw new ApiRequestError('接口环境已变化,请重新操作');
const result = await requestApi<T>(
path,
{
...options,
header: { ...header, Authorization: `Bearer ${accessToken}` },
},
context,
);
if ( if (
!res.data || getAuthContext().key !== context.key ||
res.data.code !== 0 || cachedServerUser()?.id !== initial.user.id
!Object.prototype.hasOwnProperty.call(res.data, 'data') )
) { throw new ApiRequestError('账户状态已变化,请重新操作');
reject( return result;
new ApiRequestError( };
'接口返回异常,请稍后重试', try {
res.statusCode, return await send(initial.accessToken);
), } catch (error) {
); if (!(error instanceof ApiRequestError) || error.statusCode !== 401)
return; throw error;
if (!recoverable.has(error.reason)) {
if (blocked.has(error.reason))
rejectSession(context, initial.accessToken, true);
throw error;
} }
resolve(res.data.data); const renewed = await recoverSession(context, initial);
}, if (renewed.user.id !== initial.user.id) {
fail() { clearSession(false, context);
reject( throw new ApiRequestError(
new ApiRequestError('连接失败,请确认后端已启动及网络配置'), '账户已变化,请重新操作',
401,
'ACCOUNT_CHANGED',
); );
}, }
}); try {
}); return await send(renewed.accessToken);
} catch (second) {
if (second instanceof ApiRequestError && second.statusCode === 401)
rejectSession(
context,
renewed.accessToken,
blocked.has(second.reason),
);
throw second;
}
}
} }
+100
View File
@@ -0,0 +1,100 @@
import { getApiConfig } from '../config/api';
export class ApiRequestError extends Error {
constructor(
message: string,
readonly statusCode = 0,
readonly reason = '',
) {
super(message);
this.name = 'ApiRequestError';
}
}
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
data?: Record<string, unknown>;
header?: Record<string, string>;
timeout?: number;
upload?: { filePath: string; uploadId: string };
}
/** 底层只发请求,不触发登录;登录接口与鉴权包装共用,避免循环依赖。 */
export function requestApi<T>(
path: string,
options: RequestOptions = {},
config = getApiConfig(),
): Promise<T> {
if (!path.startsWith('/') || path.startsWith('//'))
throw new Error('接口路径不正确');
return new Promise((resolve, reject) => {
const success = (res: { statusCode: number; data: unknown }) => {
const body = res.data as {
code?: number;
data?: T;
reason?: string;
} | null;
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(
new ApiRequestError(
res.statusCode === 404
? '内容暂不可用'
: '接口请求失败,请稍后重试',
res.statusCode,
typeof body?.reason === 'string' &&
/^[A-Z_]{1,64}$/.test(body.reason)
? body.reason
: '',
),
);
} else if (
!body ||
body.code !== 0 ||
!Object.prototype.hasOwnProperty.call(body, 'data')
) {
reject(
new ApiRequestError(
'接口返回异常,请稍后重试',
res.statusCode,
),
);
} else resolve(body.data as T);
};
const fail = () =>
reject(new ApiRequestError('连接失败,请确认后端已启动及网络配置'));
if (options.upload) {
// wx.uploadFile 由运行时生成 multipart boundary,不能自行设置 JSON Content-Type。
wx.uploadFile({
url: `${config.baseUrl}${path}`,
filePath: options.upload.filePath,
name: 'file',
formData: { uploadId: options.upload.uploadId },
header: options.header,
timeout: 60000,
success(res) {
let data: unknown;
try {
data = JSON.parse(res.data);
} catch {
reject(new ApiRequestError('上传响应格式异常'));
return;
}
success({ statusCode: res.statusCode, data });
},
fail,
});
} else
wx.request({
url: `${config.baseUrl}${path}`,
method: options.method || 'GET',
data: options.data,
header: {
'Content-Type': 'application/json',
...options.header,
},
timeout: options.timeout ?? config.timeout,
success,
fail,
});
});
}
+301
View File
@@ -0,0 +1,301 @@
import { getApiConfig } from '../config/api';
import { ApiRequestError, requestApi } from './api-transport';
export interface ServerUser {
id: string;
nickName: string | null;
avatarUrl: string | null;
avatarAssetId?: string | null;
avatarUrlExpiresAt: string | null;
createdAt: string;
}
export interface AuthSession {
accessToken: string;
expiresAt: number;
user: ServerUser;
}
type Context = ReturnType<typeof getAuthContext>;
type History = 'new' | 'used' | 'blocked';
interface SessionState {
session: AuthSession | null;
history: History;
generation: number;
flight?: Promise<AuthSession>;
}
const states = new Map<string, SessionState>();
const EARLY_RENEW_MS = 60000;
/** 环境、完整接口入口和 AppID 隔离;正式版不会读取开发版凭证。 */
export function getAuthContext() {
const config = getApiConfig();
const appId = wx.getAccountInfoSync().miniProgram.appId;
if (!appId) throw new ApiRequestError('无法识别小程序身份');
return {
...config,
key: `doodle_auth_v1:${encodeURIComponent(appId)}:${encodeURIComponent(config.baseUrl)}`,
};
}
/** 用户资料白名单只供展示,不缓存角色、微信身份或服务端内部字段。 */
export function parseServerUser(value: unknown): ServerUser {
const row = value as ServerUser | null;
if (
!row ||
typeof row.id !== 'string' ||
!row.id ||
(row.nickName !== null && typeof row.nickName !== 'string') ||
(row.avatarUrl !== null && typeof row.avatarUrl !== 'string') ||
(row.avatarUrlExpiresAt !== null &&
typeof row.avatarUrlExpiresAt !== 'string') ||
typeof row.createdAt !== 'string' ||
!Number.isFinite(Date.parse(row.createdAt))
)
throw new ApiRequestError('登录资料格式异常');
return {
id: row.id,
nickName: row.nickName,
avatarUrl: row.avatarUrl,
avatarAssetId:
typeof row.avatarAssetId === 'string' ? row.avatarAssetId : null,
avatarUrlExpiresAt: row.avatarUrlExpiresAt,
createdAt: row.createdAt,
};
}
function readState(context: Context): SessionState {
const existing = states.get(context.key);
if (existing) return existing;
const state: SessionState = {
session: null,
history: 'new',
generation: 0,
};
try {
const stored = wx.getStorageSync(context.key);
if (stored) {
state.history = 'used';
if (
stored.version === 1 &&
['new', 'used', 'blocked'].includes(stored.history)
) {
state.history = stored.history;
const session = stored.session;
if (
state.history === 'used' &&
session &&
typeof session.accessToken === 'string' &&
session.accessToken &&
!/\s/.test(session.accessToken) &&
Number.isFinite(session.expiresAt)
)
state.session = {
accessToken: session.accessToken,
expiresAt: session.expiresAt,
user: parseServerUser(session.user),
};
}
}
} catch {
/* 损坏资料不作为有效凭证;保留已使用账户标记。 */
}
states.set(context.key, state);
return state;
}
function syncGlobal(context: Context, user: ServerUser | null) {
try {
if (getAuthContext().key === context.key)
getApp<IAppOption>().globalData.user = user;
} catch {
/* App 未初始化时不影响认证流程。 */
}
}
function persist(context: Context, state: SessionState) {
try {
wx.setStorageSync(context.key, {
version: 1,
history: state.history,
session: state.session,
});
} catch {
/* Storage 不可写时仍可使用本次内存会话,不记录任何凭据。 */
}
syncGlobal(context, state.session?.user || null);
}
function usable(session: AuthSession | null): session is AuthSession {
return !!session && session.expiresAt > Date.now() + EARLY_RENEW_MS;
}
function wechatCode(): Promise<string> {
return new Promise((resolve, reject) =>
wx.login({
timeout: 10000,
success(res) {
res.code
? resolve(res.code)
: reject(new ApiRequestError('微信登录失败'));
},
fail() {
reject(new ApiRequestError('微信登录失败'));
},
}),
);
}
/** 合并启动、续期及 401 恢复;每次尝试取新 code,明确无效 code 最多补试一次。 */
export async function ensureSession(
context = getAuthContext(),
force = false,
explicitStart = false,
): Promise<AuthSession> {
if (getAuthContext().key !== context.key)
throw new ApiRequestError('接口环境已变化,请重新操作');
const state = readState(context);
if (state.history === 'blocked' && !explicitStart)
throw new ApiRequestError(
'账户需要重新确认后才能使用',
401,
'ACCOUNT_RESTART_REQUIRED',
);
if (!force && usable(state.session)) {
syncGlobal(context, state.session.user);
return state.session;
}
if (state.flight) return state.flight;
const generation = state.generation;
const mode = explicitStart || state.history === 'new' ? 'start' : 'resume';
const flight = (async () => {
try {
let result:
| {
accessToken: string;
tokenType: string;
expiresIn: number;
expiresAt: string;
user: ServerUser;
}
| undefined;
for (let attempt = 0; attempt < 2; attempt++) {
try {
result = await requestApi(
'/auth/wechat/login',
{
method: 'POST',
data: { code: await wechatCode(), mode },
},
context,
);
break;
} catch (error) {
if (!(
error instanceof ApiRequestError &&
error.reason === 'WECHAT_CODE_INVALID' &&
attempt === 0
))
throw error;
}
}
if (
!result ||
result.tokenType !== 'Bearer' ||
typeof result.accessToken !== 'string' ||
!result.accessToken ||
/\s/.test(result.accessToken) ||
!Number.isSafeInteger(result.expiresIn) ||
result.expiresIn < 60 ||
result.expiresIn > 604800 ||
!Number.isFinite(Date.parse(result.expiresAt))
)
throw new ApiRequestError('登录响应格式异常');
const session = {
accessToken: result.accessToken,
expiresAt: Date.now() + result.expiresIn * 1000,
user: parseServerUser(result.user),
};
// 清理、切换环境期间迟到的登录响应不得恢复会话或写入当前界面。
if (
state.generation !== generation ||
getAuthContext().key !== context.key
)
throw new ApiRequestError('登录状态已变化,请重新操作');
state.session = session;
state.history = 'used';
persist(context, state);
return session;
} catch (error) {
if (
state.generation === generation &&
error instanceof ApiRequestError &&
[
'ACCOUNT_NOT_FOUND',
'ACCOUNT_DISABLED',
'WECHAT_LOGIN_BLOCKED',
].includes(error.reason)
) {
state.session = null;
state.history = 'blocked';
persist(context, state);
}
throw error;
}
})();
state.flight = flight;
try {
return await flight;
} finally {
if (state.flight === flight) state.flight = undefined;
}
}
/** 同一原请求只恢复一次;迟到的旧 401 直接复用较新凭证。 */
export async function recoverSession(context: Context, failed: AuthSession) {
const state = readState(context);
if (
usable(state.session) &&
state.session.accessToken !== failed.accessToken
)
return state.session;
// 已获得过凭证就只恢复账户,绝不在恢复失败后自动切回 start 建号。
state.session = null;
state.history = state.history === 'blocked' ? 'blocked' : 'used';
persist(context, state);
return ensureSession(context, true);
}
/** 清理保留账户历史;注销/账号消失后只能由用户主动调用 startAccount 再建号。 */
export function clearSession(blocked = false, context = getAuthContext()) {
const state = readState(context);
state.generation++;
state.flight = undefined;
state.session = null;
state.history = blocked
? 'blocked'
: state.history === 'new'
? 'new'
: 'used';
persist(context, state);
}
export function cachedServerUser(): ServerUser | null {
const context = getAuthContext();
return readState(context).session?.user || null;
}
/** GET /me 结果只更新同一环境、同一用户的现有会话,不混入旧云业务资料。 */
export function updateSessionUser(
value: unknown,
context = getAuthContext(),
): ServerUser {
const user = parseServerUser(value);
const state = readState(context);
if (!state.session || state.session.user.id !== user.id)
throw new ApiRequestError('账户已变化,请重新操作');
state.session = { ...state.session, user };
persist(context, state);
return user;
}
/** 只清理本次请求使用的凭证;旧请求迟到的错误不能撤销新登录。 */
export function rejectSession(
context: Context,
token: string,
blocked: boolean,
) {
if (readState(context).session?.accessToken === token)
clearSession(blocked, context);
}
+5 -1
View File
@@ -36,7 +36,9 @@ interface WorksheetPage {
/** 分类直接由服务端发布状态决定,不用本地目录补齐或恢复下架分类。 */ /** 分类直接由服务端发布状态决定,不用本地目录补齐或恢复下架分类。 */
export async function listCategories(): Promise<ApiCategory[]> { export async function listCategories(): Promise<ApiCategory[]> {
const rows = await apiRequest<ApiCategory[]>('/categories'); const rows = await apiRequest<ApiCategory[]>('/categories', {
auth: 'none',
});
if ( if (
!Array.isArray(rows) || !Array.isArray(rows) ||
rows.some( rows.some(
@@ -87,6 +89,7 @@ export async function listAllWorksheets(): Promise<ApiWorksheet[]> {
let expectedTotal: number | undefined; let expectedTotal: number | undefined;
for (let page = 1; ; page++) { for (let page = 1; ; page++) {
const result = await apiRequest<WorksheetPage>('/worksheets', { const result = await apiRequest<WorksheetPage>('/worksheets', {
auth: 'none',
data: { page, pageSize }, data: { page, pageSize },
}); });
if ( if (
@@ -121,6 +124,7 @@ export async function listAllWorksheets(): Promise<ApiWorksheet[]> {
export async function getWorksheet(id: string): Promise<ApiWorksheet> { export async function getWorksheet(id: string): Promise<ApiWorksheet> {
const row = await apiRequest<ApiWorksheet>( const row = await apiRequest<ApiWorksheet>(
`/worksheets/${encodeURIComponent(id)}`, `/worksheets/${encodeURIComponent(id)}`,
{ auth: 'none' },
); );
validateWorksheet(row); validateWorksheet(row);
if (row.id !== id) throw new Error('题型数据异常,请稍后重试'); if (row.id !== id) throw new Error('题型数据异常,请稍后重试');
+53
View File
@@ -0,0 +1,53 @@
import { apiRequest } from './api-request';
import { generateUUID } from '../utils/uuid';
import type { ApiWorksheet } from './content-api';
export interface UserStats {
favoriteCount: number;
downloadCount: number;
totalDownloads: string;
}
export interface MemberRecord {
id: string;
worksheetId: string;
exportId?: string | null;
createdAt: string;
worksheet: ApiWorksheet | null;
}
export interface MemberPage {
items: MemberRecord[];
total: number;
page: number;
pageSize: number;
hasNext: boolean;
}
export const memberStats = () => apiRequest<UserStats>('/me/stats');
/** uploadId 在一次操作中固定;401 重发沿用同一 ID,网络失败不自动重复上传。 */
export async function uploadAsset(
filePath: string,
purpose: 'avatar' | 'preview',
) {
return apiRequest<{
id: string;
url: string;
mimeType: string;
sizeBytes: number;
}>(
purpose === 'avatar'
? '/me/avatar-upload'
: '/admin/assets/preview-upload',
{ method: 'POST', upload: { filePath, uploadId: generateUUID() } },
);
}
export function legacyWorksheet(row: ApiWorksheet | null) {
return row
? {
...row,
_id: row.id,
subcategory:
(row as ApiWorksheet & { subcategory?: string | null })
.subcategory || '',
tags: (row as ApiWorksheet & { tags?: string[] }).tags || [],
previewImg: row.previewImg || '',
}
: null;
}
+12
View File
@@ -0,0 +1,12 @@
import { apiRequest } from './api-request';
import type { ApiWorksheet } from './content-api';
/** 保留微信预拉取,动态兜底与后台刷新改由新后端提供。 */
export function fetchPageConfig() {
return apiRequest<PageConfigData>('/page-config', { auth: 'none' });
}
export function fetchAgeRecommendations(ageBand: string) {
return apiRequest<{ ageBand: string; items: ApiWorksheet[] }>(
'/recommendations/age',
{ auth: 'none', data: { ageBand } },
);
}
@@ -1,3 +1,6 @@
import { compareCountDescending } from '../../pages/category/category.model';
import { managementRequest } from '../../services/admin-api';
import { resolvePreviewUrl } from '../../config/assets';
import { CATEGORY_LIST } from '../../core/data/categories'; import { CATEGORY_LIST } from '../../core/data/categories';
type WorksheetItem = { type WorksheetItem = {
@@ -13,8 +16,8 @@ type WorksheetItem = {
tagsText: string; tagsText: string;
status: 'draft' | 'active' | 'hidden'; status: 'draft' | 'active' | 'hidden';
sortOrder: number; sortOrder: number;
downloads: number; downloads: string;
likes: number; likes: string;
updatedAt: string; updatedAt: string;
// display helpers // display helpers
ageBand: string; ageBand: string;
@@ -32,13 +35,6 @@ type Stats = {
hidden: number; hidden: number;
}; };
type CloudFunctionResult<T> = {
success?: boolean;
message?: string;
data?: T;
stats?: Stats;
};
const DIFFICULTY_LABELS: Record<number, string> = { const DIFFICULTY_LABELS: Record<number, string> = {
1: '入门', 1: '入门',
2: '基础', 2: '基础',
@@ -82,7 +78,7 @@ function formatWorksheet(raw: Record<string, unknown>): WorksheetItem {
title: String(raw.title || ''), title: String(raw.title || ''),
subtitle: String(raw.subtitle || ''), subtitle: String(raw.subtitle || ''),
category: String(raw.category || ''), category: String(raw.category || ''),
previewImg: String(raw.previewImg || ''), previewImg: resolvePreviewUrl(raw.previewImg),
ageMin, ageMin,
ageMax, ageMax,
difficulty, difficulty,
@@ -92,8 +88,8 @@ function formatWorksheet(raw: Record<string, unknown>): WorksheetItem {
: '', : '',
status: status as WorksheetItem['status'], status: status as WorksheetItem['status'],
sortOrder: Number(raw.sortOrder) || 0, sortOrder: Number(raw.sortOrder) || 0,
downloads: Number(raw.downloads) || 0, downloads: String(raw.downloads || '0'),
likes: Number(raw.likes) || 0, likes: String(raw.likes || '0'),
updatedAt: raw.updatedAt ? String(raw.updatedAt) : '', updatedAt: raw.updatedAt ? String(raw.updatedAt) : '',
ageBand: `${ageMin}-${ageMax}岁`, ageBand: `${ageMin}-${ageMax}岁`,
difficultyLabel: DIFFICULTY_LABELS[difficulty] || '基础', difficultyLabel: DIFFICULTY_LABELS[difficulty] || '基础',
@@ -104,17 +100,6 @@ function formatWorksheet(raw: Record<string, unknown>): WorksheetItem {
}; };
} }
async function callCloudFunction<T>(
name: string,
data?: Record<string, unknown>,
): Promise<CloudFunctionResult<T>> {
const response = (await wx.cloud.callFunction({
name,
data: data || {},
})) as { result?: CloudFunctionResult<T> };
return response.result || {};
}
Page({ Page({
data: { data: {
categories: CATEGORY_LIST, categories: CATEGORY_LIST,
@@ -139,8 +124,8 @@ Page({
this.setData({ loading: true }); this.setData({ loading: true });
try { try {
const result = await callCloudFunction<WorksheetItem[]>( const result = await managementRequest<WorksheetItem[]>(
'worksheetsQuery', 'worksheets.list',
{ {
category: this.data.activeCategoryId, category: this.data.activeCategoryId,
rawStats: true, rawStats: true,
@@ -155,7 +140,9 @@ Page({
.map((raw) => .map((raw) =>
formatWorksheet(raw as unknown as Record<string, unknown>), formatWorksheet(raw as unknown as Record<string, unknown>),
) )
.sort((a, b) => b.downloads - a.downloads); .sort((a, b) =>
compareCountDescending(a.downloads, b.downloads),
);
this.setData({ this.setData({
loading: false, loading: false,
@@ -204,7 +191,7 @@ Page({
this.setData({ updatingId: id }); this.setData({ updatingId: id });
try { try {
const result = await callCloudFunction('worksheetsUpdateStatus', { const result = await managementRequest('worksheets.status', {
id, id,
status: target, status: target,
}); });
@@ -1,3 +1,4 @@
import { managementRequest } from '../../services/admin-api';
import { CATEGORY_LIST } from '../../core/data/categories'; import { CATEGORY_LIST } from '../../core/data/categories';
type CloudCategory = { type CloudCategory = {
@@ -23,12 +24,6 @@ type CompareItem = {
cloudCategory: CloudCategory | null; cloudCategory: CloudCategory | null;
}; };
type CloudFunctionResult<T> = {
success?: boolean;
message?: string;
data?: T;
};
const DEFAULT_CATEGORY_COLORS = [ const DEFAULT_CATEGORY_COLORS = [
'#F7EE47', '#F7EE47',
'#93D333', '#93D333',
@@ -133,18 +128,6 @@ function buildCompareItems(cloudCategories: CloudCategory[]): {
}; };
} }
async function callCategoryFunction<T>(
name: string,
data?: Record<string, unknown>,
): Promise<CloudFunctionResult<T>> {
const response = (await wx.cloud.callFunction({
name,
data: data || {},
})) as { result?: CloudFunctionResult<T> };
return response.result || {};
}
Page({ Page({
data: { data: {
loading: false, loading: false,
@@ -170,7 +153,7 @@ Page({
try { try {
const result = const result =
await callCategoryFunction<CloudCategory[]>('categoriesQuery'); await managementRequest<CloudCategory[]>('categories.list');
if (!result.success) { if (!result.success) {
throw new Error(result.message || '获取分类失败'); throw new Error(result.message || '获取分类失败');
@@ -204,7 +187,7 @@ Page({
const confirmed = await new Promise<boolean>((resolve) => { const confirmed = await new Promise<boolean>((resolve) => {
wx.showModal({ wx.showModal({
title: `${item.actionText}分类`, title: `${item.actionText}分类`,
content: `确认将「${item.name}」同步到云端吗?`, content: `确认将「${item.name}」同步到新后端吗?`,
success: (res) => resolve(!!res.confirm), success: (res) => resolve(!!res.confirm),
fail: () => resolve(false), fail: () => resolve(false),
}); });
@@ -228,9 +211,9 @@ Page({
try { try {
const functionName = const functionName =
item.status === 'missing' item.status === 'missing'
? 'categoriesCreate' ? 'categories.create'
: 'categoriesUpdate'; : 'categories.update';
const result = await callCategoryFunction<CloudCategory>( const result = await managementRequest<CloudCategory>(
functionName, functionName,
payload, payload,
); );
@@ -1,3 +1,7 @@
import { compareCountDescending } from '../../pages/category/category.model';
import { apiRequest } from '../../services/api-request';
import { managementRequest } from '../../services/admin-api';
import { resolvePreviewUrl } from '../../config/assets';
import { CATEGORY_LIST } from '../../core/data/categories'; import { CATEGORY_LIST } from '../../core/data/categories';
type SimpleWorksheet = { type SimpleWorksheet = {
@@ -13,8 +17,8 @@ type SimpleWorksheet = {
isHot: boolean; isHot: boolean;
path: string; path: string;
ageBand: string; ageBand: string;
downloads: number; downloads: string;
likes: number; likes: string;
}; };
type SectionConfig = { type SectionConfig = {
@@ -43,12 +47,6 @@ type PageConfig = {
[key: string]: unknown; [key: string]: unknown;
}; };
type CloudFunctionResult<T> = {
success?: boolean;
message?: string;
data?: T;
};
function formatSimple(raw: Record<string, unknown>): SimpleWorksheet { function formatSimple(raw: Record<string, unknown>): SimpleWorksheet {
const ageMin = Number(raw.ageMin) || 0; const ageMin = Number(raw.ageMin) || 0;
const ageMax = Number(raw.ageMax) || 0; const ageMax = Number(raw.ageMax) || 0;
@@ -57,7 +55,7 @@ function formatSimple(raw: Record<string, unknown>): SimpleWorksheet {
title: String(raw.title || ''), title: String(raw.title || ''),
subtitle: String(raw.subtitle || ''), subtitle: String(raw.subtitle || ''),
category: String(raw.category || ''), category: String(raw.category || ''),
previewImg: String(raw.previewImg || ''), previewImg: resolvePreviewUrl(raw.previewImg),
ageMin, ageMin,
ageMax, ageMax,
difficulty: Number(raw.difficulty) || 2, difficulty: Number(raw.difficulty) || 2,
@@ -65,22 +63,11 @@ function formatSimple(raw: Record<string, unknown>): SimpleWorksheet {
isHot: !!raw.isHot, isHot: !!raw.isHot,
path: String(raw.path || ''), path: String(raw.path || ''),
ageBand: `${ageMin}-${ageMax}岁`, ageBand: `${ageMin}-${ageMax}岁`,
downloads: Number(raw.downloads) || 0, downloads: String(raw.downloads || '0'),
likes: Number(raw.likes) || 0, likes: String(raw.likes || '0'),
}; };
} }
async function callCloudFunction<T>(
name: string,
data?: Record<string, unknown>,
): Promise<CloudFunctionResult<T>> {
const response = (await wx.cloud.callFunction({
name,
data: data || {},
})) as { result?: CloudFunctionResult<T> };
return response.result || {};
}
Page({ Page({
data: { data: {
featured: [] as SimpleWorksheet[], featured: [] as SimpleWorksheet[],
@@ -122,15 +109,15 @@ Page({
async loadTimerStatus() { async loadTimerStatus() {
try { try {
const db = wx.cloud.database(); const data = await apiRequest<{ enabled: boolean }>(
const { data } = await db '/admin/site/timer',
.collection('settings') );
.doc('homeAutoRefresh') this.setData({
.get(); timerEnabled: (data as { enabled?: boolean }).enabled !== false,
this.setData({ timerEnabled: (data as { enabled?: boolean }).enabled !== false }); });
} catch { } catch {
// Document doesn't exist — default enabled // 权限或网络失败不能被当成已启用。
this.setData({ timerEnabled: true }); this.setData({ timerEnabled: false });
} }
}, },
@@ -139,13 +126,14 @@ Page({
try { try {
// Fetch in parallel: active worksheets + saved page config // Fetch in parallel: active worksheets + saved page config
const [wsResult, configResult] = await Promise.all([ const [wsResult, configResult] = await Promise.all([
callCloudFunction<SimpleWorksheet[]>('worksheetsQuery', { managementRequest<SimpleWorksheet[]>('worksheets.list', {
status: 'active', status: 'active',
}), }),
callCloudFunction<PageConfig>('pageConfigFetch'), managementRequest<PageConfig>('site.config'),
]); ]);
if (!wsResult.success) throw new Error(wsResult.message || '查询失败'); if (!wsResult.success)
throw new Error(wsResult.message || '查询失败');
const allActive = (wsResult.data || []).map((raw) => const allActive = (wsResult.data || []).map((raw) =>
formatSimple(raw as unknown as Record<string, unknown>), formatSimple(raw as unknown as Record<string, unknown>),
@@ -156,16 +144,21 @@ Page({
// featured: always top-5 by downloads; hot: always top-5 by likes // featured: always top-5 by downloads; hot: always top-5 by likes
const featured: SimpleWorksheet[] = [...allActive] const featured: SimpleWorksheet[] = [...allActive]
.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0)) .sort((a, b) =>
compareCountDescending(a.downloads, b.downloads),
)
.slice(0, 5); .slice(0, 5);
const hot: SimpleWorksheet[] = [...allActive] const hot: SimpleWorksheet[] = [...allActive]
.sort((a, b) => (b.likes ?? 0) - (a.likes ?? 0)) .sort((a, b) => compareCountDescending(a.likes, b.likes))
.slice(0, 5); .slice(0, 5);
// Restore saved sections from config // Restore saved sections from config
const savedHome = (configResult.data as PageConfig | undefined)?.home; const savedHome = (configResult.data as PageConfig | undefined)
?.home;
const savedSections: HomeConfigItem[] = savedHome?.sections || []; const savedSections: HomeConfigItem[] = savedHome?.sections || [];
const savedSectionMap = new Map(savedSections.map((s) => [s.id, s])); const savedSectionMap = new Map(
savedSections.map((s) => [s.id, s]),
);
const sections: SectionConfig[] = CATEGORY_LIST.map((c) => { const sections: SectionConfig[] = CATEGORY_LIST.map((c) => {
const saved = savedSectionMap.get(c.id); const saved = savedSectionMap.get(c.id);
@@ -184,7 +177,13 @@ Page({
}; };
}); });
this.setData({ loading: false, allActive, featured, hot, sections }); this.setData({
loading: false,
allActive,
featured,
hot,
sections,
});
} catch (error) { } catch (error) {
this.setData({ loading: false }); this.setData({ loading: false });
wx.showToast({ wx.showToast({
@@ -202,7 +201,7 @@ Page({
this.setData({ timerLoading: true }); this.setData({ timerLoading: true });
try { try {
const result = await callCloudFunction('homeTimerControl', { const result = await managementRequest('site.timer', {
enabled: newEnabled, enabled: newEnabled,
}); });
if (!result.success) { if (!result.success) {
@@ -335,9 +334,9 @@ Page({
this.setData({ building: true }); this.setData({ building: true });
try { try {
const result = await callCloudFunction<{ const result = await managementRequest<{
version: number; version: number;
}>('pageContentBuild', { }>('site.rebuild', {
page: 'home', page: 'home',
featuredIds: this.data.featured.map((w) => w._id), featuredIds: this.data.featured.map((w) => w._id),
hotIds: this.data.hot.map((w) => w._id), hotIds: this.data.hot.map((w) => w._id),
+6 -19
View File
@@ -1,5 +1,6 @@
import { apiRequest } from '../../services/api-request';
import { disablePageShareMenu } from '../../base/pageMixin'; import { disablePageShareMenu } from '../../base/pageMixin';
import { clearUser } from '../../utils/auth'; import { clearUser, refreshUser } from '../../utils/auth';
Page({ Page({
onLoad() { onLoad() {
@@ -22,15 +23,8 @@ Page({
async doClearProfile() { async doClearProfile() {
wx.showLoading({ title: '处理中...', mask: true }); wx.showLoading({ title: '处理中...', mask: true });
try { try {
const res = await wx.cloud.callFunction({ await apiRequest('/me/profile', { method: 'DELETE' });
name: 'userSettings', await refreshUser();
data: { action: 'clearProfile' },
});
const result = (res?.result as any) || {};
if (result.code !== 0) {
throw new Error(result.message || '操作失败');
}
clearUser();
wx.hideLoading(); wx.hideLoading();
wx.showToast({ title: '已删除', icon: 'success' }); wx.showToast({ title: '已删除', icon: 'success' });
} catch (e) { } catch (e) {
@@ -58,15 +52,8 @@ Page({
async doDeleteAccount() { async doDeleteAccount() {
wx.showLoading({ title: '处理中...', mask: true }); wx.showLoading({ title: '处理中...', mask: true });
try { try {
const res = await wx.cloud.callFunction({ await apiRequest('/me', { method: 'DELETE' });
name: 'userSettings', clearUser(true);
data: { action: 'deleteAccount' },
});
const result = (res?.result as any) || {};
if (result.code !== 0) {
throw new Error(result.message || '操作失败');
}
clearUser();
wx.hideLoading(); wx.hideLoading();
wx.reLaunch({ url: '/pages/home/home' }); wx.reLaunch({ url: '/pages/home/home' });
@@ -1,3 +1,4 @@
import { managementRequest } from '../../services/admin-api';
import { MOCK_SEED_COUNTS } from './mockLikes'; import { MOCK_SEED_COUNTS } from './mockLikes';
import { import {
MATH_WORKSHEET_DEFINITIONS, MATH_WORKSHEET_DEFINITIONS,
@@ -21,17 +22,6 @@ type CloudFunctionResult<T> = {
results?: Array<{ id: string | null; ok: boolean; error?: string }>; results?: Array<{ id: string | null; ok: boolean; error?: string }>;
}; };
async function callCloudFunction<T>(
name: string,
data?: Record<string, unknown>,
): Promise<CloudFunctionResult<T>> {
const response = (await wx.cloud.callFunction({
name,
data: data || {},
})) as { result?: CloudFunctionResult<T> };
return response.result || {};
}
function inferGradeFromAge(ageMin: number, ageMax: number): number { function inferGradeFromAge(ageMin: number, ageMax: number): number {
const centerAge = Math.round((ageMin + ageMax) / 2); const centerAge = Math.round((ageMin + ageMax) / 2);
const ageGradeMap: Record<number, number> = { const ageGradeMap: Record<number, number> = {
@@ -224,7 +214,7 @@ Page({
const startedAt = Date.now(); const startedAt = Date.now();
try { try {
const patchRes = await callCloudFunction('worksheetsBatchPatch', { const patchRes = await managementRequest('worksheets.batch', {
patches, patches,
dryRun: false, dryRun: false,
}); });
@@ -262,7 +252,7 @@ Page({
const startedAt = Date.now(); const startedAt = Date.now();
try { try {
const rebuildRes = await callCloudFunction('pageContentBuild', { const rebuildRes = await managementRequest('site.rebuild', {
page: 'all', page: 'all',
}); });
const elapsed = Math.round((Date.now() - startedAt) / 1000); const elapsed = Math.round((Date.now() - startedAt) / 1000);
@@ -358,7 +348,7 @@ Page({
}), }),
); );
const patchRes = await callCloudFunction('worksheetsBatchPatch', { const patchRes = await managementRequest('worksheets.batch', {
patches, patches,
dryRun: false, dryRun: false,
}); });
@@ -415,8 +405,8 @@ Page({
const startedAt = Date.now(); const startedAt = Date.now();
try { try {
const queryRes = await callCloudFunction<Array<{ _id: string }>>( const queryRes = await managementRequest<Array<{ _id: string }>>(
'worksheetsQuery', 'worksheets.list',
{}, {},
); );
@@ -438,7 +428,7 @@ Page({
return; return;
} }
const patchRes = await callCloudFunction('worksheetsBatchPatch', { const patchRes = await managementRequest('worksheets.batch', {
patches, patches,
dryRun: false, dryRun: false,
}); });
+39 -148
View File
@@ -1,165 +1,56 @@
interface UserInfo { import { apiRequest } from '../services/api-request';
_id: string; import {
openid: string; cachedServerUser,
unionid: string | null; clearSession,
nickName: string | null; ensureSession,
avatarUrl: string | null; getAuthContext,
totalDownloads: number; updateSessionUser,
createdAt: string; type ServerUser,
lastActiveAt: string; } from '../services/auth-session';
}
const STORAGE_KEY = 'user_info'; export type UserInfo = ServerUser;
const STORAGE_EXPIRE_KEY = 'user_info_expire'; export { ensureSession as ensureAuthenticated } from '../services/auth-session';
const EXPIRE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 天
/** 内存中的用户数据(App 生命周期内有效) */ /** 用户摘要与新后端认证绑定,所有远端用户业务只使用本站用户 ID。 */
let currentUser: UserInfo | null = null;
/** 登录进行中的 Promise(防止并发重复调用) */
let loginPromise: Promise<UserInfo> | null = null;
function syncAppGlobalUser(user: UserInfo | null): void {
try {
const app = getApp<IAppOption>();
if (app?.globalData) {
app.globalData.user = user;
}
} catch {
// App 尚未初始化时忽略
}
}
function syncUserState(user: UserInfo, persist = true): UserInfo {
currentUser = user;
if (persist) {
saveToStorage(user);
}
syncAppGlobalUser(user);
return user;
}
/**
* 获取当前用户,优先内存 → Storage → 云函数登录
*/
export async function getUser(): Promise<UserInfo> { export async function getUser(): Promise<UserInfo> {
// 1. 内存中有,直接返回 return (await ensureSession()).user;
if (currentUser) {
syncAppGlobalUser(currentUser);
return currentUser;
}
// 2. 尝试从 Storage 读取
const cached = loadFromStorage();
if (cached) {
return syncUserState(cached, false);
}
// 3. 静默登录(防止并发)
if (!loginPromise) {
loginPromise = silentLogin().finally(() => {
loginPromise = null;
});
}
return loginPromise;
} }
/** 缓存只供展示,不能证明服务端仍认可当前凭证。 */
/**
* 静默登录:调用云函数
*/
async function silentLogin(): Promise<UserInfo> {
const res = await wx.cloud.callFunction({
name: 'userLogin',
});
const result = (res?.result as any) || {};
if (result.code !== 0) {
throw new Error('登录失败');
}
const user = result.data as UserInfo;
return syncUserState(user);
}
export function getCachedUser(): UserInfo | null { export function getCachedUser(): UserInfo | null {
if (currentUser) {
syncAppGlobalUser(currentUser);
return currentUser;
}
const cached = loadFromStorage();
if (!cached) return null;
return syncUserState(cached, false);
}
/**
* 从 Storage 读取用户数据(检查过期)
*/
function loadFromStorage(): UserInfo | null {
try { try {
const expire = wx.getStorageSync(STORAGE_EXPIRE_KEY); return cachedServerUser();
if (!expire || Date.now() > expire) {
wx.removeStorageSync(STORAGE_KEY);
wx.removeStorageSync(STORAGE_EXPIRE_KEY);
return null;
}
const user = wx.getStorageSync(STORAGE_KEY);
return user || null;
} catch { } catch {
return null; return null;
} }
} }
/** 读取本人资料;GET /me 会按统一规则完成过期与 401 恢复。 */
/**
* 写入 Storage
*/
function saveToStorage(user: UserInfo): void {
try {
wx.setStorageSync(STORAGE_KEY, user);
wx.setStorageSync(STORAGE_EXPIRE_KEY, Date.now() + EXPIRE_DURATION);
} catch {
// Storage 写入失败不影响主流程
}
}
/**
* 清除登录状态
*/
export function clearUser(): void {
currentUser = null;
syncAppGlobalUser(null);
wx.removeStorageSync(STORAGE_KEY);
wx.removeStorageSync(STORAGE_EXPIRE_KEY);
}
/**
* 强制刷新用户数据(跳过缓存)
*/
export async function refreshUser(): Promise<UserInfo> { export async function refreshUser(): Promise<UserInfo> {
clearUser(); const context = getAuthContext();
return await silentLogin(); const user = await apiRequest<UserInfo>('/me');
if (getAuthContext().key !== context.key)
throw new Error('接口环境已变化,请重新操作');
return updateSessionUser(user, context);
}
/** 暂停账户自动恢复时保留标记,不能通过清缓存自动注册。 */
export function clearUser(blocked = false): void {
clearSession(blocked);
}
/** 仅供用户主动确认“重新开始使用账户”的动作调用,禁止在失败回调自动调用。 */
export async function startAccount(): Promise<UserInfo> {
return (await ensureSession(getAuthContext(), true, true)).user;
} }
/** 本人资料更新只提交头像资产 ID,不允许传任意外链或用户身份字段。 */
export async function updateUserProfile(payload: { export async function updateUserProfile(payload: {
nickName?: string | null; nickName?: string;
avatarUrl?: string | null; avatarAssetId?: string | null;
}): Promise<UserInfo> { }): Promise<UserInfo> {
await getUser(); const context = getAuthContext();
const user = await apiRequest<UserInfo>('/me/profile', {
const res = await wx.cloud.callFunction({ method: 'PUT',
name: 'userLogin', data: payload,
data: {
action: 'updateProfile',
...payload,
},
}); });
const result = (res?.result as any) || {}; if (getAuthContext().key !== context.key)
throw new Error('接口环境已变化,请重新操作');
if (result.code !== 0 || !result.data) { return updateSessionUser(user, context);
throw new Error(result.message || '更新用户资料失败');
}
return syncUserState(result.data as UserInfo);
} }
export type { UserInfo };
+5 -7
View File
@@ -1,7 +1,5 @@
import type { import type { CategoryId } from '../core/models/category';
WorksheetCloudCategory, import type { WorksheetConfig } from '../core/models/worksheet';
WorksheetStatus,
} from '../core/models/worksheet';
/** /**
* 调试发布时的裁剪模式。 * 调试发布时的裁剪模式。
@@ -19,7 +17,7 @@ export interface DebugPublishMeta {
id: string; id: string;
title: string; title: string;
subtitle: string; subtitle: string;
category: WorksheetCloudCategory; category: CategoryId;
subcategory?: string; subcategory?: string;
path: string; path: string;
ageMin: number; ageMin: number;
@@ -31,7 +29,7 @@ export interface DebugPublishMeta {
isNew: boolean; isNew: boolean;
isHot: boolean; isHot: boolean;
sortOrder?: number; sortOrder?: number;
status: WorksheetStatus; status: WorksheetConfig['status'];
} }
/** 发布弹窗里可调的图片处理参数。 */ /** 发布弹窗里可调的图片处理参数。 */
@@ -118,7 +116,7 @@ export function getCropRatios(mode: DebugCropMode): {
/** 统一构造预览图在云存储中的路径。 */ /** 统一构造预览图在云存储中的路径。 */
export function buildWorksheetPreviewCloudPath( export function buildWorksheetPreviewCloudPath(
category: WorksheetCloudCategory, category: CategoryId,
id: string, id: string,
): string { ): string {
return `assets/previews/${category}/${id}.jpg`; return `assets/previews/${category}/${id}.jpg`;
+26 -47
View File
@@ -1,10 +1,16 @@
import { getUser } from './auth'; import { getUser } from './auth';
import { apiRequest } from '../services/api-request';
import {
legacyWorksheet,
memberStats,
type MemberPage,
} from '../services/member-api';
import { generateUUID } from './uuid';
export interface DownloadLogRecord { export interface DownloadLogRecord {
_id: string; _id: string;
userId: string;
worksheetId: string; worksheetId: string;
exportId?: string; exportId?: string | null;
createdAt: string; createdAt: string;
worksheet?: { worksheet?: {
_id: string; _id: string;
@@ -20,66 +26,39 @@ export interface DownloadLogRecord {
} | null; } | null;
} }
/** /** 一次成功保存沿用同一个 exportId,服务端保证记录及下载计数幂等。 */
* 记录下载日志
*/
export async function addDownloadLog( export async function addDownloadLog(
worksheetId: string, worksheetId: string,
exportId?: string, exportId = generateUUID(),
expectedUserId?: string,
): Promise<boolean> { ): Promise<boolean> {
if (!worksheetId) return false; if (!worksheetId) return false;
try { try {
await getUser(); const user = await getUser();
const res = await wx.cloud.callFunction({ if (expectedUserId && user.id !== expectedUserId) return false;
name: 'userDownloadLogs', await apiRequest('/me/downloads', {
data: { action: 'add', worksheetId, exportId }, method: 'POST',
data: { worksheetId, exportId },
}); });
const result = (res?.result as any) || {}; return true;
return result.code === 0; } catch {
} catch (e) {
console.error('addDownloadLog failed', e);
return false; return false;
} }
} }
/** 收藏页下载历史展示条数上限 */
export const DOWNLOAD_HISTORY_MAX = 30; export const DOWNLOAD_HISTORY_MAX = 30;
/**
* 获取下载历史(默认最近 30 条)
*/
export async function getDownloadLogs( export async function getDownloadLogs(
page = 1, page = 1,
pageSize = DOWNLOAD_HISTORY_MAX, pageSize = DOWNLOAD_HISTORY_MAX,
): Promise<DownloadLogRecord[]> { ): Promise<DownloadLogRecord[]> {
try { const result = await apiRequest<MemberPage>('/me/downloads', {
await getUser(); data: { page, pageSize },
const res = await wx.cloud.callFunction({
name: 'userDownloadLogs',
data: { action: 'list', page, pageSize },
}); });
const result = (res?.result as any) || {}; return result.items.map((row) => ({
return result.code === 0 ? result.data : []; ...row,
} catch (e) { _id: row.id,
console.error('getDownloadLogs failed', e); worksheet: legacyWorksheet(row.worksheet),
return []; }));
}
} }
/**
* 获取当前用户下载(打印)总数
*/
export async function getDownloadLogCount(): Promise<number> { export async function getDownloadLogCount(): Promise<number> {
try { return (await memberStats()).downloadCount;
await getUser();
const res = await wx.cloud.callFunction({
name: 'userDownloadLogs',
data: { action: 'count' },
});
const result = (res?.result as any) || {};
return result.code === 0 ? Number(result.data?.count || 0) : 0;
} catch (e) {
console.error('getDownloadLogCount failed', e);
return 0;
}
} }
+10 -2
View File
@@ -1,3 +1,4 @@
import { getCachedUser } from './auth';
/** /**
* 下载打印通用工具 * 下载打印通用工具
* 支持下载次数限制和激励广告 * 支持下载次数限制和激励广告
@@ -67,7 +68,9 @@ function showVideoAd(): Promise<boolean> {
mask: true, mask: true,
}); });
const onClose: WechatMiniprogram.RewardedVideoAdOnCloseCallback = (res) => { const onClose: WechatMiniprogram.RewardedVideoAdOnCloseCallback = (
res,
) => {
videoAd.offClose(onClose); videoAd.offClose(onClose);
wx.hideLoading(); wx.hideLoading();
resolve(!!(res && res.isEnded)); resolve(!!(res && res.isEnded));
@@ -137,6 +140,7 @@ function doDownload(
}, },
): Promise<boolean> { ): Promise<boolean> {
const exportId = generateUUID(); const exportId = generateUUID();
const expectedUserId = getCachedUser()?.id;
const trackerPage = options.trackerPage || options.trackerName || ''; const trackerPage = options.trackerPage || options.trackerName || '';
const trackerParams = { const trackerParams = {
export_id: exportId, export_id: exportId,
@@ -154,7 +158,11 @@ function doDownload(
recordDownloadSuccess(); recordDownloadSuccess();
tracker.reportExportResult(trackerPage, true, trackerParams); tracker.reportExportResult(trackerPage, true, trackerParams);
if (options.worksheetId) { if (options.worksheetId) {
await addDownloadLog(options.worksheetId, exportId); await addDownloadLog(
options.worksheetId,
exportId,
expectedUserId,
);
} }
} else { } else {
tracker.reportExportResult(trackerPage, false, trackerParams); tracker.reportExportResult(trackerPage, false, trackerParams);
+36 -100
View File
@@ -1,14 +1,13 @@
import { getUser } from './auth'; import { apiRequest } from '../services/api-request';
import {
legacyWorksheet,
memberStats,
type MemberPage,
} from '../services/member-api';
import tracker from './tracker'; import tracker from './tracker';
function getCurrentPageRoute(): string {
const pages = getCurrentPages();
return pages[pages.length - 1]?.route || '';
}
export interface FavoriteRecord { export interface FavoriteRecord {
_id: string; _id: string;
userId: string;
worksheetId: string; worksheetId: string;
createdAt: string; createdAt: string;
worksheet?: { worksheet?: {
@@ -26,122 +25,59 @@ export interface FavoriteRecord {
} | null; } | null;
} }
/** /** 收藏操作使用新后端当前账户;调用失败不能向其他用户补发。 */
* 添加收藏
*/
export async function addFavorite(worksheetId: string): Promise<boolean> { export async function addFavorite(worksheetId: string): Promise<boolean> {
try { try {
await getUser(); // 确保已登录 await apiRequest(`/me/favorites/${encodeURIComponent(worksheetId)}`, {
const res = await wx.cloud.callFunction({ method: 'PUT',
name: 'userFavorites',
data: { action: 'add', worksheetId },
}); });
const result = (res?.result as any) || {}; tracker.reportFavorite(currentRoute(), worksheetId, true);
const success = result.code === 0; return true;
if (success) tracker.reportFavorite(getCurrentPageRoute(), worksheetId, true); } catch {
return success;
} catch (e) {
console.error('addFavorite failed', e);
return false; return false;
} }
} }
/**
* 取消收藏
*/
export async function removeFavorite(worksheetId: string): Promise<boolean> { export async function removeFavorite(worksheetId: string): Promise<boolean> {
try { try {
await getUser(); await apiRequest(`/me/favorites/${encodeURIComponent(worksheetId)}`, {
const res = await wx.cloud.callFunction({ method: 'DELETE',
name: 'userFavorites',
data: { action: 'remove', worksheetId },
}); });
const result = (res?.result as any) || {}; tracker.reportFavorite(currentRoute(), worksheetId, false);
const success = result.code === 0; return true;
if (success) { } catch {
tracker.reportFavorite(getCurrentPageRoute(), worksheetId, false);
}
return success;
} catch (e) {
console.error('removeFavorite failed', e);
return false; return false;
} }
} }
/**
* 获取收藏列表
*/
export async function getFavoriteList( export async function getFavoriteList(
page = 1, page = 1,
pageSize = 20, pageSize = 20,
): Promise<FavoriteRecord[]> { ): Promise<FavoriteRecord[]> {
try { const result = await apiRequest<MemberPage>('/me/favorites', {
await getUser(); data: { page, pageSize },
const res = await wx.cloud.callFunction({
name: 'userFavorites',
data: { action: 'list', page, pageSize },
}); });
const result = (res?.result as any) || {}; return result.items.map((row) => ({
return result.code === 0 ? result.data || [] : []; ...row,
} catch (e) { _id: row.id,
console.error('getFavoriteList failed', e); worksheet: legacyWorksheet(row.worksheet),
return []; }));
}
} }
/**
* 获取当前用户收藏总数
*/
export async function getFavoriteCount(): Promise<number> { export async function getFavoriteCount(): Promise<number> {
try { return (await memberStats()).favoriteCount;
await getUser();
const res = await wx.cloud.callFunction({
name: 'userFavorites',
data: { action: 'count' },
});
const result = (res?.result as any) || {};
return result.code === 0 ? Number(result.data?.count || 0) : 0;
} catch (e) {
console.error('getFavoriteCount failed', e);
return 0;
}
} }
/**
* 检查是否已收藏
*/
export async function checkFavorited(worksheetId: string): Promise<boolean> {
try {
await getUser();
const res = await wx.cloud.callFunction({
name: 'userFavorites',
data: { action: 'check', worksheetId },
});
const result = (res?.result as any) || {};
return result.code === 0 && result.data?.favorited;
} catch (e) {
console.error('checkFavorited failed', e);
return false;
}
}
/**
* 批量检查是否已收藏,返回 { worksheetId: true } 的 map
*/
export async function batchCheckFavorited( export async function batchCheckFavorited(
worksheetIds: string[], worksheetIds: string[],
): Promise<Record<string, boolean>> { ): Promise<Record<string, boolean>> {
if (!worksheetIds.length) return {}; if (!worksheetIds.length) return {};
try { return apiRequest('/me/favorites/check', {
await getUser(); method: 'POST',
const res = await wx.cloud.callFunction({ data: { worksheetIds },
name: 'userFavorites',
data: { action: 'batchCheck', worksheetIds },
}); });
const result = (res?.result as any) || {}; }
return result.code === 0 ? result.data || {} : {}; export async function checkFavorited(worksheetId: string): Promise<boolean> {
} catch (e) { return (await batchCheckFavorited([worksheetId]))[worksheetId] === true;
console.error('batchCheckFavorited failed', e); }
return {};
} function currentRoute() {
const pages = getCurrentPages();
return pages[pages.length - 1]?.route || '';
} }
-31
View File
@@ -1,31 +0,0 @@
type WorksheetStatsField = 'likes' | 'downloads';
export async function updateWorksheetStats(
id: string,
field: WorksheetStatsField,
): Promise<boolean> {
const worksheetId = String(id || '').trim();
if (!worksheetId) return false;
try {
const { result } = await wx.cloud.callFunction({
name: 'worksheetsStatsUpdate',
data: {
id: worksheetId,
field,
},
});
return !!(result as { success?: boolean } | undefined)?.success;
} catch (error) {
console.error('update worksheet stats failed', field, worksheetId, error);
return false;
}
}
export function incrementWorksheetLikes(id: string): Promise<boolean> {
return updateWorksheetStats(id, 'likes');
}
export function incrementWorksheetDownloads(id: string): Promise<boolean> {
return updateWorksheetStats(id, 'downloads');
}
+3 -1
View File
@@ -8,7 +8,9 @@
"worksheet:patch:tags": "tsx scripts/worksheet-sync-cloud-payload.ts --tags", "worksheet:patch:tags": "tsx scripts/worksheet-sync-cloud-payload.ts --tags",
"worksheet:patch:all": "tsx scripts/worksheet-sync-cloud-payload.ts --all", "worksheet:patch:all": "tsx scripts/worksheet-sync-cloud-payload.ts --all",
"worksheet:patch:dry": "tsx scripts/worksheet-sync-cloud-payload.ts --all --dry-run", "worksheet:patch:dry": "tsx scripts/worksheet-sync-cloud-payload.ts --all --dry-run",
"test:category": "tsx --test tests/category-api.test.mjs" "test:category": "tsx --test tests/category-api.test.mjs",
"test:assets": "tsx --test tests/preview-assets.test.mjs",
"test:auth": "tsx --test tests/auth-api.test.mjs"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
+513
View File
@@ -0,0 +1,513 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { test } from 'node:test';
import vm from 'node:vm';
import ts from 'typescript';
const root = resolve('miniprogram');
const user = (id = 'server-user') => ({
id,
nickName: null,
avatarUrl: null,
avatarUrlExpiresAt: null,
createdAt: '2026-10-19T00:00:00Z',
});
const result = (token, id) => ({
accessToken: token,
tokenType: 'Bearer',
expiresIn: 86400,
expiresAt: '2026-10-20T00:00:00Z',
user: { ...user(id), openid: 'must-not-persist', role: 'admin' },
});
const failure = (reason, statusCode = 401) => ({
statusCode,
raw: { code: statusCode, reason },
});
const deferred = () => {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
};
/** 隔离微信与时间;登录、缓存、HTTP 包装都加载实际源码,不使用真实凭据。 */
function fixture(initialStorage = new Map()) {
let now = Date.now();
const storage = initialStorage;
const app = { globalData: {} };
let loginCalls = 0;
let handler = async (options) =>
options.url.endsWith('/auth/wechat/login')
? { data: result(`token-${loginCalls}`) }
: { data: user() };
const requests = [];
const uploads = [];
const wx = {
getAccountInfoSync: () => ({
miniProgram: { appId: 'wx-test', envVersion: 'develop' },
}),
getStorageSync: (key) => storage.get(key),
setStorageSync(key, value) {
storage.set(key, JSON.parse(JSON.stringify(value)));
},
login(options) {
loginCalls++;
options.success({ code: `one-use-code-${loginCalls}` });
},
uploadFile(options) {
uploads.push(options);
Promise.resolve()
.then(() => handler({ ...options, data: options.formData }))
.then((value) => {
if (value.networkError) options.fail({});
else
options.success({
statusCode: value.statusCode ?? 200,
data: JSON.stringify(
value.raw ?? { code: 0, data: value.data },
),
});
});
},
request(options) {
requests.push(options);
Promise.resolve()
.then(() => handler(options))
.then((value) => {
if (value.networkError) options.fail({});
else
options.success({
statusCode: value.statusCode ?? 200,
data: value.raw ?? { code: 0, data: value.data },
});
});
},
};
class Clock extends Date {
static now() {
return now;
}
}
const context = vm.createContext({
wx,
getApp: () => app,
Date: Clock,
console,
});
const modules = new Map();
function load(file) {
file = resolve(root, file);
if (!file.endsWith('.ts')) file += '.ts';
if (modules.has(file)) return modules.get(file);
const exports = {};
modules.set(file, exports);
const source = readFileSync(file, 'utf8');
const code = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
},
}).outputText;
const run = vm.runInContext(
`(function(require,exports){${code}\n})`,
context,
);
run((name) => load(resolve(dirname(file), name)), exports);
return exports;
}
return {
auth: load('utils/auth'),
session: load('services/auth-session'),
api: load('services/api-request'),
member: load('services/member-api'),
admin: load('services/admin-api'),
site: load('services/site-api'),
config: load('config/api').apiConfig,
wx,
requests,
uploads,
storage,
app,
get loginCalls() {
return loginCalls;
},
handle(fn) {
handler = fn;
},
advance(ms) {
now += ms;
},
};
}
const loginRequests = (f) =>
f.requests.filter((r) => r.url.endsWith('/auth/wechat/login'));
test('静默登录:首次 start,并发合并、缓存复用,过期后 resume 且不缓存微信身份/角色', async () => {
const f = fixture();
await Promise.all(Array.from({ length: 20 }, () => f.auth.getUser()));
assert.equal(f.loginCalls, 1);
assert.equal(loginRequests(f)[0].data.mode, 'start');
assert.equal(f.app.globalData.user.id, 'server-user');
assert.ok(
!JSON.stringify([...f.storage.values()]).includes('must-not-persist'),
);
assert.ok(!JSON.stringify([...f.storage.values()]).includes('admin'));
assert.equal(f.auth.getCachedUser().id, 'server-user');
const reload = fixture(f.storage);
await reload.auth.getUser();
assert.equal(reload.loginCalls, 0);
f.advance(86400 * 1000);
await f.auth.getUser();
assert.equal(f.loginCalls, 2);
assert.equal(loginRequests(f)[1].data.mode, 'resume');
assert.notEqual(
loginRequests(f)[0].data.code,
loginRequests(f)[1].data.code,
);
});
test('公开请求不登录不带凭证;受保护请求登录并 GET /me 刷新用户', async () => {
const f = fixture();
await f.api.apiRequest('/categories', {
auth: 'none',
header: { authorization: 'do-not-forward' },
});
assert.equal(f.loginCalls, 0);
assert.equal(
Object.keys(f.requests[0].header).some(
(k) => k.toLowerCase() === 'authorization',
),
false,
);
const refreshed = await f.auth.refreshUser();
assert.equal(refreshed.id, 'server-user');
assert.equal(f.requests.at(-1).header.Authorization, 'Bearer token-1');
await f.api.apiRequest('/categories', { auth: 'none' });
assert.equal(f.requests.at(-1).header.Authorization, undefined);
});
test('多个并发 401 共用一次 resume,迟到的旧 401 复用新 token', async () => {
const f = fixture();
await f.auth.getUser();
const late = deferred();
const receivedLate = deferred();
f.handle(async (options) => {
if (options.url.endsWith('/auth/wechat/login'))
return { data: result('token-2') };
if (options.header.Authorization === 'Bearer token-1') {
if (options.url.endsWith('/late')) {
receivedLate.resolve();
await late.promise;
}
return failure('AUTH_EXPIRED');
}
return { data: 'ok' };
});
const delayed = f.api.apiRequest('/late');
await receivedLate.promise;
const values = await Promise.all(
Array.from({ length: 8 }, () => f.api.apiRequest('/me')),
);
assert.ok(values.every((v) => v === 'ok'));
assert.equal(f.loginCalls, 2);
late.resolve();
assert.equal(await delayed, 'ok');
assert.equal(f.loginCalls, 2);
});
test('第二次 401 停止恢复;403、429、503 和网络错误不自动重登', async () => {
for (const status of [401, 403, 429, 503, 0]) {
const f = fixture();
await f.auth.getUser();
f.handle(async (options) =>
options.url.endsWith('/auth/wechat/login')
? { data: result('token-2') }
: status
? failure('AUTH_EXPIRED', status)
: { networkError: true },
);
await assert.rejects(f.api.apiRequest('/me'));
assert.equal(f.loginCalls, status === 401 ? 2 : 1);
const business = f.requests.filter((r) => r.url.endsWith('/me'));
assert.equal(business.length, status === 401 ? 2 : 1);
}
});
test('账户被删除或禁用时阻止自动重建,标记重启后保留;用户可主动重新开始', async () => {
const f = fixture();
await f.auth.getUser();
f.handle(async (options) =>
options.url.endsWith('/auth/wechat/login')
? failure('ACCOUNT_NOT_FOUND')
: failure('AUTH_EXPIRED'),
);
await assert.rejects(f.api.apiRequest('/me'));
await assert.rejects(f.auth.getUser(), /重新确认/);
assert.equal(f.loginCalls, 2);
assert.equal(loginRequests(f)[1].data.mode, 'resume');
const reload = fixture(f.storage);
await assert.rejects(reload.auth.getUser(), /重新确认/);
assert.equal(reload.loginCalls, 0);
await reload.auth.startAccount();
assert.equal(loginRequests(reload)[0].data.mode, 'start');
});
test('恢复后身份变化中止原业务写入,不把请求补发到另一个账户', async () => {
const f = fixture();
await f.auth.getUser();
f.handle(async (options) =>
options.url.endsWith('/auth/wechat/login')
? { data: result('token-other', 'another-user') }
: failure('AUTH_EXPIRED'),
);
await assert.rejects(
f.api.apiRequest('/me/action', { method: 'POST', data: { value: 1 } }),
(e) => e.reason === 'ACCOUNT_CHANGED',
);
assert.equal(
f.requests.filter((r) => r.url.endsWith('/me/action')).length,
1,
);
assert.equal(f.auth.getCachedUser(), null);
});
test('微信无效 code 只补试一次且使用新 code,网络失败不重试登录', async () => {
const f = fixture();
let count = 0;
f.handle(async () =>
++count === 1
? failure('WECHAT_CODE_INVALID')
: { data: result('token-2') },
);
await f.auth.getUser();
assert.equal(f.loginCalls, 2);
assert.notEqual(
loginRequests(f)[0].data.code,
loginRequests(f)[1].data.code,
);
const g = fixture();
g.handle(async () => ({ networkError: true }));
await assert.rejects(g.auth.getUser());
assert.equal(g.loginCalls, 1);
});
test('接口环境与 AppID 隔离;清理或切换环境后迟到登录不得恢复原会话', async () => {
const f = fixture();
await f.auth.getUser();
f.config.localBaseUrl = 'http://127.0.0.1:8801/api/v1';
assert.equal(f.auth.getCachedUser(), null);
await f.auth.getUser();
assert.equal(loginRequests(f)[1].data.mode, 'start');
assert.equal(f.storage.size, 2);
f.wx.getAccountInfoSync = () => ({
miniProgram: { appId: 'wx-other', envVersion: 'develop' },
});
assert.equal(f.auth.getCachedUser(), null);
const pending = deferred();
const received = deferred();
f.handle(async () => {
received.resolve();
return pending.promise;
});
const login = f.auth.getUser();
await received.promise;
f.auth.clearUser();
pending.resolve({ data: result('late') });
await assert.rejects(login, /状态已变化/);
assert.equal(f.auth.getCachedUser(), null);
});
test('畸形登录响应不保存凭证;账户禁用不会触发新的自动注册', async () => {
const f = fixture();
f.handle(async () => ({
data: { ...result('invalid'), tokenType: 'Basic' },
}));
await assert.rejects(f.auth.getUser(), /格式异常/);
assert.equal(f.auth.getCachedUser(), null);
assert.equal(f.storage.size, 0);
const g = fixture();
await g.auth.getUser();
g.handle(async () => failure('ACCOUNT_DISABLED'));
await assert.rejects(g.api.apiRequest('/me'));
assert.equal(g.loginCalls, 1);
await assert.rejects(g.auth.getUser(), /重新确认/);
assert.equal(g.loginCalls, 1);
});
test('切换环境后原业务的 401 不向旧接口发起新登录', async () => {
const f = fixture();
await f.auth.getUser();
const pending = deferred();
const received = deferred();
f.handle(async () => {
received.resolve();
return pending.promise;
});
const request = f.api.apiRequest('/me');
await received.promise;
f.config.localBaseUrl = 'http://127.0.0.1:8801/api/v1';
pending.resolve(failure('AUTH_EXPIRED'));
await assert.rejects(request, /环境已变化/);
assert.equal(f.loginCalls, 1);
});
test('提前续登后身份变化时,原页面发起的业务请求也不会首次发送到新账户', async () => {
const f = fixture();
await f.auth.getUser();
f.advance(86400 * 1000);
f.handle(async () => ({ data: result('another-token', 'another-user') }));
await assert.rejects(
f.api.apiRequest('/me/action', { method: 'POST' }),
(e) => e.reason === 'ACCOUNT_CHANGED',
);
assert.equal(
f.requests.filter((r) => r.url.endsWith('/me/action')).length,
0,
);
});
test('七牛头像/预览上传沿用统一鉴权;401 重试沿用 uploadId,不自行设置 multipart boundary', async () => {
const f = fixture();
await f.auth.getUser();
f.handle(async (options) => {
if (options.url.endsWith('/auth/wechat/login'))
return { data: result('token-2') };
if (options.header.Authorization === 'Bearer token-1')
return failure('AUTH_EXPIRED');
return {
data: {
id: 'avatar-resource',
url: 'https://cdn.test/avatar.png',
mimeType: 'image/png',
sizeBytes: 100,
},
};
});
const uploaded = await f.member.uploadAsset(
'wxfile://chosen-avatar.png',
'avatar',
);
assert.equal(uploaded.id, 'avatar-resource');
assert.equal(f.uploads.length, 2);
assert.equal(
f.uploads[0].formData.uploadId,
f.uploads[1].formData.uploadId,
);
assert.equal(f.uploads[0].header['Content-Type'], undefined);
assert.equal(f.uploads[1].header.Authorization, 'Bearer token-2');
assert.ok(f.uploads[0].url.endsWith('/me/avatar-upload'));
await f.member.uploadAsset('wxfile://preview.jpg', 'preview');
assert.ok(f.uploads.at(-1).url.endsWith('/admin/assets/preview-upload'));
});
test('资料更新只提交资源 ID,服务端响应更新当前用户摘要', async () => {
const f = fixture();
await f.auth.getUser();
f.handle(async () => ({
data: {
...user(),
nickName: '新名字',
avatarUrl: 'https://cdn.test/avatar.png',
avatarAssetId: 'avatar-resource',
role: 'admin',
},
}));
const updated = await f.auth.updateUserProfile({
nickName: '新名字',
avatarAssetId: 'avatar-resource',
});
const request = f.requests.at(-1);
assert.ok(request.url.endsWith('/me/profile'));
assert.equal(request.method, 'PUT');
assert.equal(request.data.avatarAssetId, 'avatar-resource');
assert.equal(request.data.avatarUrl, undefined);
assert.equal(updated.avatarAssetId, 'avatar-resource');
assert.equal(f.auth.getCachedUser().nickName, '新名字');
assert.equal(f.auth.getCachedUser().role, undefined);
});
test('管理目录完整分页,真实计数和展示数分开;批次与 PUT 状态映射至新接口', async () => {
const f = fixture();
const rows = Array.from({ length: 121 }, (_, index) => ({
id: `item-${index}`,
likes: '2',
downloads: '3',
likesDisplay: '22',
downloadsDisplay: '33',
status: 'active',
}));
f.handle(async (options) => {
if (options.url.endsWith('/auth/wechat/login'))
return { data: result('token-1') };
if (options.url.endsWith('/admin/catalog/worksheets')) {
const { page } = options.data;
return {
data: {
items: rows.slice((page - 1) * 100, page * 100),
total: rows.length,
page,
pageSize: 100,
hasNext: page * 100 < rows.length,
},
};
}
return { data: {} };
});
const display = await f.admin.managementRequest('worksheets.list');
assert.equal(display.data.length, 121);
assert.equal(display.data[0]._id, 'item-0');
assert.equal(display.data[0].downloads, '33');
const raw = await f.admin.managementRequest('worksheets.list', {
rawStats: true,
});
assert.equal(raw.data[0].downloads, '3');
await f.admin.managementRequest('worksheets.batch', {
patches: [{ id: 'item-0', likes_seed: 10, downloads_seed: 20 }],
dryRun: false,
});
assert.ok(f.requests.at(-1).url.endsWith('/admin/worksheets/batch'));
assert.equal(f.requests.at(-1).data.patches[0].likesSeed, 10);
assert.equal(f.requests.at(-1).data.patches[0].likes_seed, undefined);
await f.admin.managementRequest('worksheets.status', {
id: 'item-0',
status: 'hidden',
});
assert.equal(f.requests.at(-1).method, 'PUT');
await f.admin.managementRequest('categories.update', {
id: 'cat-0',
name: '分类',
});
assert.ok(f.requests.at(-1).url.endsWith('/admin/categories/cat-0'));
assert.equal(f.requests.at(-1).data.id, undefined);
});
test('首页兜底与分龄推荐不携带个人凭证,也不触发登录', async () => {
const f = fixture();
f.handle(async () => ({ data: { home: {}, items: [] } }));
await f.site.fetchPageConfig();
await f.site.fetchAgeRecommendations('5-6');
assert.equal(f.loginCalls, 0);
assert.ok(f.requests[0].url.endsWith('/page-config'));
assert.ok(f.requests[1].url.endsWith('/recommendations/age'));
assert.equal(f.requests[1].data.ageBand, '5-6');
assert.ok(f.requests.every((r) => !r.header.Authorization));
});
test('清理账户后迟到的成功响应不能回填旧账户视图', async () => {
const f = fixture();
await f.auth.getUser();
const pending = deferred();
const received = deferred();
f.handle(async () => {
received.resolve();
return pending.promise;
});
const request = f.api.apiRequest('/me/stats');
await received.promise;
f.auth.clearUser(true);
pending.resolve({ data: { favoriteCount: 10 } });
await assert.rejects(request, /账户状态已变化/);
});
+12 -3
View File
@@ -173,7 +173,10 @@ test('环境配置:开发版默认本地,正式/体验/未知环境不回退
test('请求层:HTTP、业务错误、畸形响应、网络错误均拒绝,不当作空成功', async () => { test('请求层:HTTP、业务错误、畸形响应、网络错误均拒绝,不当作空成功', async () => {
useRows([]); useRows([]);
assert.deepEqual(await apiRequest('/categories'), categories); assert.deepEqual(
await apiRequest('/categories', { auth: 'none' }),
categories,
);
assert.equal(requests[0].url, 'http://127.0.0.1:8800/api/v1/categories'); assert.equal(requests[0].url, 'http://127.0.0.1:8800/api/v1/categories');
for (const result of [ for (const result of [
{ statusCode: 404 }, { statusCode: 404 },
@@ -183,12 +186,18 @@ test('请求层:HTTP、业务错误、畸形响应、网络错误均拒绝,
{ raw: '<html>error</html>' }, { raw: '<html>error</html>' },
]) { ]) {
handler = () => result; handler = () => result;
await assert.rejects(apiRequest('/categories'), ApiRequestError); await assert.rejects(
apiRequest('/categories', { auth: 'none' }),
ApiRequestError,
);
} }
handler = () => { handler = () => {
throw new Error('offline'); throw new Error('offline');
}; };
await assert.rejects(apiRequest('/categories'), /连接失败/); await assert.rejects(
apiRequest('/categories', { auth: 'none' }),
/连接失败/,
);
}); });
test('完整分页:121 条无遗漏,计数字符串保留精度,空数据正常结束', async () => { test('完整分页:121 条无遗漏,计数字符串保留精度,空数据正常结束', async () => {
+130
View File
@@ -0,0 +1,130 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import vm from 'node:vm';
import ts from 'typescript';
import {
assetConfig,
resolvePreviewUrl,
normalizePreviewImages,
} from '../miniprogram/config/assets.ts';
const legacyRoot =
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/';
const preview = 'assets/previews/math/number-find.jpg';
const expected = `https://cdn.joeyone.cn/doodle-assets/${preview}`;
test('预览图:旧 fileID 和云存储临时链接映射到统一七牛配置', () => {
assert.equal(resolvePreviewUrl(`${legacyRoot}${preview}`), expected);
assert.equal(
resolvePreviewUrl(
`https://636c-cloud1-9gifs7a2756e2c87-1351593184.tcb.qcloud.la/${preview}?sign=old&t=123`,
),
expected,
);
assert.equal(resolvePreviewUrl(expected), expected);
const previous = { ...assetConfig };
try {
assetConfig.publicDomain = 'https://assets.example.test';
assetConfig.keyPrefix = 'next/';
assert.equal(
resolvePreviewUrl(`${legacyRoot}${preview}`),
`https://assets.example.test/next/${preview}`,
);
} finally {
Object.assign(assetConfig, previous);
}
});
test('预览图:外部地址、本地图片和头像不替换,畸形预览不产生 CDN 请求', () => {
for (const raw of [
'/assets/local.png',
'https://elsewhere.test/assets/previews/a.jpg',
'https://636c-cloud1-9gifs7a2756e2c87-1351593184.tcb.qcloud.la.evil.test/assets/previews/a.jpg',
`${legacyRoot}user-avatars/u.png`,
'cloud://another-env/assets/previews/a.jpg',
'',
])
assert.equal(resolvePreviewUrl(raw), raw);
for (const raw of [
`${legacyRoot}assets/previews/../private.jpg`,
`${legacyRoot}assets/previews/%2e%2e/private.jpg`,
`${legacyRoot}assets/previews/%broken.jpg`,
])
assert.equal(resolvePreviewUrl(raw), '');
assert.equal(resolvePreviewUrl(null), '');
});
test('预览图:嵌套首页分区、收藏及下载记录转换且不修改原数据', () => {
const raw = {
home: {
featured: [{ previewImg: `${legacyRoot}${preview}` }],
sections: [{ items: [{ previewImg: `${legacyRoot}${preview}` }] }],
},
records: [{ worksheet: { previewImg: `${legacyRoot}${preview}` } }],
avatarUrl: `${legacyRoot}user-avatars/u.png`,
};
const converted = normalizePreviewImages(raw);
assert.equal(converted.home.featured[0].previewImg, expected);
assert.equal(converted.home.sections[0].items[0].previewImg, expected);
assert.equal(converted.records[0].worksheet.previewImg, expected);
assert.equal(raw.home.featured[0].previewImg, `${legacyRoot}${preview}`);
assert.equal(converted.avatarUrl, raw.avatarUrl);
});
test('首页配置:预拉取、旧 storage 缓存及新后端兜底均写回七牛预览图', async () => {
const source = readFileSync(
new URL('../miniprogram/app.ts', import.meta.url),
'utf8',
);
const code = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
},
}).outputText;
for (const entry of ['prefetch', 'storage', 'backend']) {
const old = {
home: { featured: [{ previewImg: `${legacyRoot}${preview}` }] },
};
let app;
let stored;
const sandbox = {
exports: {},
getCurrentPages: () => [],
require(name) {
if (name === './services/site-api')
return { fetchPageConfig: async () => old };
if (name === './config/assets')
return { normalizePreviewImages };
return {};
},
App(definition) {
app = definition;
},
wx: {
getBackgroundFetchData({ success, fail }) {
if (entry === 'prefetch')
success({ fetchedData: JSON.stringify({ data: old }) });
else fail(new Error('not available'));
},
getStorageSync() {
return entry === 'storage' ? old : null;
},
setStorageSync(key, value) {
assert.equal(key, 'pageConfig');
stored = value;
},
},
};
vm.runInNewContext(code, sandbox);
const waiting = app.getPageConfig();
await app.loadPageConfig();
assert.equal((await waiting).home.featured[0].previewImg, expected);
assert.equal(
app.globalData.pageConfig.home.featured[0].previewImg,
expected,
);
assert.equal(stored.home.featured[0].previewImg, expected);
}
});
+1 -1
View File
@@ -13,7 +13,7 @@ interface PageConfigData {
}>; }>;
}; };
age?: Record<string, any>; age?: Record<string, any>;
version?: number; version?: number | string;
updatedAt?: string; updatedAt?: string;
} }