feat: 完成全部接口替换为新后端接口
This commit is contained in:
@@ -2,3 +2,52 @@
|
||||
|
||||
直接使用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`。旧云端用户、收藏与下载历史本次未自动导入,正式切换前需要另行核对迁移。
|
||||
|
||||
Vendored
+34
-34
@@ -52,7 +52,7 @@
|
||||
"ageBand": "3-5岁",
|
||||
"difficulty": "入门",
|
||||
"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": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=number-coloring",
|
||||
"available": true
|
||||
@@ -65,7 +65,7 @@
|
||||
"ageBand": "4-6岁",
|
||||
"difficulty": "基础",
|
||||
"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": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=number-sort",
|
||||
"available": true
|
||||
@@ -78,7 +78,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "进阶",
|
||||
"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": "",
|
||||
"path": "/focusPages/focusDraw/focusDraw?id=code-connect",
|
||||
"available": true
|
||||
@@ -91,7 +91,7 @@
|
||||
"ageBand": "4-8岁",
|
||||
"difficulty": "基础",
|
||||
"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": "",
|
||||
"path": "/chinesePages/handwritingSheet/handwritingSheet?id=hanzi-sheet",
|
||||
"available": true
|
||||
@@ -104,7 +104,7 @@
|
||||
"ageBand": "6-8岁",
|
||||
"difficulty": "基础",
|
||||
"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": "",
|
||||
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-daily-checkin",
|
||||
"available": true
|
||||
@@ -117,7 +117,7 @@
|
||||
"ageBand": "4-6岁",
|
||||
"difficulty": "入门",
|
||||
"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": "",
|
||||
"path": "/focusPages/focusDraw/focusDraw?id=dot-connect",
|
||||
"available": true
|
||||
@@ -132,7 +132,7 @@
|
||||
"ageBand": "4-6岁",
|
||||
"difficulty": "基础",
|
||||
"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": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=number-sort",
|
||||
"available": true
|
||||
@@ -145,7 +145,7 @@
|
||||
"ageBand": "3-5岁",
|
||||
"difficulty": "入门",
|
||||
"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": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=number-coloring",
|
||||
"available": true
|
||||
@@ -158,7 +158,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "进阶",
|
||||
"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": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=make-ten",
|
||||
"available": true
|
||||
@@ -171,7 +171,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "进阶",
|
||||
"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": "",
|
||||
"path": "/focusPages/focusDraw/focusDraw?id=code-connect",
|
||||
"available": true
|
||||
@@ -184,7 +184,7 @@
|
||||
"ageBand": "3-5岁",
|
||||
"difficulty": "入门",
|
||||
"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": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=counting-matching",
|
||||
"available": true
|
||||
@@ -205,7 +205,7 @@
|
||||
"ageBand": "4-6岁",
|
||||
"difficulty": "基础",
|
||||
"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": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=number-sort",
|
||||
"available": true
|
||||
@@ -218,7 +218,7 @@
|
||||
"ageBand": "3-5岁",
|
||||
"difficulty": "入门",
|
||||
"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": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=number-coloring",
|
||||
"available": true
|
||||
@@ -231,7 +231,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "进阶",
|
||||
"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": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=make-ten",
|
||||
"available": true
|
||||
@@ -244,7 +244,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "入门",
|
||||
"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": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=number-decompose",
|
||||
"available": true
|
||||
@@ -257,7 +257,7 @@
|
||||
"ageBand": "3-5岁",
|
||||
"difficulty": "入门",
|
||||
"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": "",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=number-find",
|
||||
"available": true
|
||||
@@ -278,7 +278,7 @@
|
||||
"ageBand": "4-6岁",
|
||||
"difficulty": "基础",
|
||||
"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": "",
|
||||
"path": "/focusPages/focusDraw/focusDraw?id=position-coloring",
|
||||
"available": true
|
||||
@@ -291,7 +291,7 @@
|
||||
"ageBand": "3-6岁",
|
||||
"difficulty": "入门",
|
||||
"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": "",
|
||||
"path": "/focusPages/focusDraw/focusDraw?id=line-recognition",
|
||||
"available": true
|
||||
@@ -304,7 +304,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "进阶",
|
||||
"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": "",
|
||||
"path": "/focusPages/focusDraw/focusDraw?id=grid-reasoning",
|
||||
"available": true
|
||||
@@ -317,7 +317,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "进阶",
|
||||
"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": "",
|
||||
"path": "/focusPages/focusDraw/focusDraw?id=code-connect",
|
||||
"available": true
|
||||
@@ -330,7 +330,7 @@
|
||||
"ageBand": "4-6岁",
|
||||
"difficulty": "基础",
|
||||
"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": "",
|
||||
"path": "/focusPages/focusDraw/focusDraw?id=shape-symbol",
|
||||
"available": true
|
||||
@@ -351,7 +351,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "入门",
|
||||
"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": "",
|
||||
"path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-tracing",
|
||||
"available": true
|
||||
@@ -364,7 +364,7 @@
|
||||
"ageBand": "6-8岁",
|
||||
"difficulty": "基础",
|
||||
"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": "",
|
||||
"path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-dictation",
|
||||
"available": true
|
||||
@@ -377,7 +377,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "入门",
|
||||
"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": "",
|
||||
"path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-tracing-v2",
|
||||
"available": true
|
||||
@@ -390,7 +390,7 @@
|
||||
"ageBand": "6-8岁",
|
||||
"difficulty": "基础",
|
||||
"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": "",
|
||||
"path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-dictation-v2",
|
||||
"available": true
|
||||
@@ -403,7 +403,7 @@
|
||||
"ageBand": "6-8岁",
|
||||
"difficulty": "基础",
|
||||
"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": "",
|
||||
"path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-daily",
|
||||
"available": true
|
||||
@@ -424,7 +424,7 @@
|
||||
"ageBand": "3-6岁",
|
||||
"difficulty": "入门",
|
||||
"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": "",
|
||||
"path": "/chinesePages/wordColoring/wordColoring?id=word-coloring-grid",
|
||||
"available": true
|
||||
@@ -437,7 +437,7 @@
|
||||
"ageBand": "3-6岁",
|
||||
"difficulty": "入门",
|
||||
"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": "",
|
||||
"path": "/chinesePages/wordColoring/wordColoring?id=word-coloring-find",
|
||||
"available": true
|
||||
@@ -450,7 +450,7 @@
|
||||
"ageBand": "4-8岁",
|
||||
"difficulty": "基础",
|
||||
"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": "",
|
||||
"path": "/chinesePages/handwritingSheet/handwritingSheet?id=hanzi-sheet",
|
||||
"available": true
|
||||
@@ -471,7 +471,7 @@
|
||||
"ageBand": "6-8岁",
|
||||
"difficulty": "基础",
|
||||
"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": "",
|
||||
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-daily-checkin",
|
||||
"available": true
|
||||
@@ -484,7 +484,7 @@
|
||||
"ageBand": "4-7岁",
|
||||
"difficulty": "入门",
|
||||
"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": "",
|
||||
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-single",
|
||||
"available": true
|
||||
@@ -497,7 +497,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "入门",
|
||||
"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": "",
|
||||
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-case-pairing",
|
||||
"available": true
|
||||
@@ -510,7 +510,7 @@
|
||||
"ageBand": "5-8岁",
|
||||
"difficulty": "基础",
|
||||
"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": "",
|
||||
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-half",
|
||||
"available": true
|
||||
@@ -523,7 +523,7 @@
|
||||
"ageBand": "5-7岁",
|
||||
"difficulty": "入门",
|
||||
"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": "",
|
||||
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-two-column",
|
||||
"available": true
|
||||
|
||||
+54
-22
@@ -1,7 +1,11 @@
|
||||
import { fetchPageConfig } from './services/site-api';
|
||||
import { normalizePreviewImages } from './config/assets';
|
||||
import { defaultPrintConfig } from './config/config';
|
||||
import { getUser } from './utils/auth';
|
||||
import { generateUUID, getAppUUID, setAppUUID } from './utils/uuid';
|
||||
|
||||
let _refreshGeneration = 0;
|
||||
|
||||
const STORAGE_KEY_PAGE_CONFIG = 'pageConfig';
|
||||
|
||||
// Pending resolvers for getPageConfig() callers waiting on config
|
||||
@@ -18,11 +22,12 @@ App<IAppOption>({
|
||||
pageConfigReady: false,
|
||||
},
|
||||
onLaunch() {
|
||||
wx.cloud.init({
|
||||
env: 'cloud1-9gifs7a2756e2c87',
|
||||
traceUser: true,
|
||||
});
|
||||
|
||||
try {
|
||||
wx.removeStorageSync('user_info');
|
||||
wx.removeStorageSync('user_info_expire');
|
||||
} catch {
|
||||
/* 旧云登录缓存清理失败不影响新凭证命名空间。 */
|
||||
}
|
||||
const accountInfo = wx.getAccountInfoSync();
|
||||
const env = accountInfo.miniProgram.envVersion || 'release';
|
||||
this.globalData.env = env;
|
||||
@@ -41,19 +46,22 @@ App<IAppOption>({
|
||||
this.globalData.printConfig = printConfig;
|
||||
}
|
||||
|
||||
// 静默登录(不阻塞页面渲染)
|
||||
getUser()
|
||||
.then((user) => {
|
||||
this.globalData.user = user;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('静默登录失败', err);
|
||||
});
|
||||
// 启动与回到前台共用登录 Promise,不阻塞公开页面渲染。
|
||||
this.warmAuthentication();
|
||||
|
||||
void this.loadPageConfig();
|
||||
},
|
||||
|
||||
onShow() {},
|
||||
onShow() {
|
||||
this.warmAuthentication();
|
||||
if (this.globalData.pageConfigReady) void this.refreshPageConfig();
|
||||
},
|
||||
|
||||
warmAuthentication() {
|
||||
void getUser().catch(() => {
|
||||
console.warn('静默登录暂未完成,公开浏览仍可使用');
|
||||
});
|
||||
},
|
||||
|
||||
onHide() {},
|
||||
|
||||
@@ -100,20 +108,19 @@ App<IAppOption>({
|
||||
}
|
||||
}
|
||||
|
||||
// L3: 云函数兜底
|
||||
// L3: 新后端动态兜底;微信预拉取机制和地址保持不变。
|
||||
const hadCachedConfig = !!config;
|
||||
if (!config) {
|
||||
try {
|
||||
const res = (await wx.cloud.callFunction({
|
||||
name: 'pageConfigFetch',
|
||||
})) as { result?: { success?: boolean; data?: any } };
|
||||
if (res.result?.success && res.result.data) {
|
||||
config = res.result.data;
|
||||
}
|
||||
config = await fetchPageConfig();
|
||||
} catch {
|
||||
// 云函数也失败,config 保持 null
|
||||
/* 新后端不可用时继续展示空状态,本地生成仍可使用。 */
|
||||
}
|
||||
}
|
||||
|
||||
// 三种数据入口都先转换预览图,旧缓存会随成功加载写回新地址。
|
||||
config = normalizePreviewImages(config);
|
||||
|
||||
// 写入 globalData
|
||||
if (config) {
|
||||
this.globalData.pageConfig = config;
|
||||
@@ -131,6 +138,31 @@ App<IAppOption>({
|
||||
resolve(config);
|
||||
}
|
||||
_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> {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* 桥接文件 - 保持向后兼容
|
||||
* 实际实现已迁移到 platform/cloud-adapter.ts
|
||||
*/
|
||||
import { callCloud } from '../platform/cloud-adapter';
|
||||
export default callCloud;
|
||||
@@ -1,3 +1,5 @@
|
||||
import { uploadAsset } from '../services/member-api';
|
||||
import { apiRequest } from '../services/api-request';
|
||||
import { PAPER_SIZE } from '../constants/colors';
|
||||
import { downloadPrint, tryUnlockWithRewardedAd } from '../utils/downloadPrint';
|
||||
import { shouldShowShareGuide, shouldUnlockWithAd } from '../utils/shareGuide';
|
||||
@@ -5,7 +7,6 @@ import { BaseDrawService } from '../core/draw/baseDraw';
|
||||
import tracker from '../utils/tracker';
|
||||
import { defaultShareConfig } from '../config/config';
|
||||
import {
|
||||
buildWorksheetPreviewCloudPath,
|
||||
isDebugPublishEnabled,
|
||||
type DebugPublishConfirmDetail,
|
||||
type DebugPublishMeta,
|
||||
@@ -117,8 +118,7 @@ export function buildPageSharePath(page?: SharePathPage): string {
|
||||
const normalized = route.startsWith('/') ? route : `/${route}`;
|
||||
const data = page?.data || {};
|
||||
const functionId = (data.functionId || data.worksheetId) as
|
||||
| string
|
||||
| undefined;
|
||||
string | undefined;
|
||||
const currentMode = data.currentMode as string | undefined;
|
||||
|
||||
if (!functionId) {
|
||||
@@ -126,9 +126,7 @@ export function buildPageSharePath(page?: SharePathPage): string {
|
||||
}
|
||||
|
||||
const modeQuery =
|
||||
currentMode && currentMode !== functionId
|
||||
? `&mode=${currentMode}`
|
||||
: '';
|
||||
currentMode && currentMode !== functionId ? `&mode=${currentMode}` : '';
|
||||
return `${normalized}?id=${functionId}${modeQuery}`;
|
||||
}
|
||||
|
||||
@@ -509,34 +507,28 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
|
||||
settings: detail.settings,
|
||||
});
|
||||
|
||||
const cloudPath = buildWorksheetPreviewCloudPath(
|
||||
publishMeta.category,
|
||||
publishMeta.id,
|
||||
const asset = await uploadAsset(
|
||||
processed.tempFilePath,
|
||||
'preview',
|
||||
);
|
||||
const uploadRes = await wx.cloud.uploadFile({
|
||||
cloudPath,
|
||||
filePath: processed.tempFilePath,
|
||||
});
|
||||
|
||||
const cloudCall = (await wx.cloud.callFunction({
|
||||
name: 'worksheetsPublish',
|
||||
const {
|
||||
category,
|
||||
previewImg: _oldPreview,
|
||||
...metadata
|
||||
} = publishMeta;
|
||||
await apiRequest('/admin/worksheets/publish', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
...publishMeta,
|
||||
previewImg: uploadRes.fileID,
|
||||
...metadata,
|
||||
categoryId: category,
|
||||
previewAssetId: asset.id,
|
||||
},
|
||||
})) as {
|
||||
result?: { success?: boolean; message?: string };
|
||||
};
|
||||
if (!cloudCall.result?.success) {
|
||||
throw new Error(
|
||||
cloudCall.result?.message || '云端写入失败',
|
||||
);
|
||||
}
|
||||
});
|
||||
this.setData({
|
||||
debugPublishVisible: false,
|
||||
debugPublishMeta: {
|
||||
...publishMeta,
|
||||
previewImg: uploadRes.fileID,
|
||||
previewImg: asset.url,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -566,8 +558,7 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
|
||||
initPageInfo(
|
||||
this: PageCanvasInstance,
|
||||
functionIdOrOptions:
|
||||
| string
|
||||
| { title: string; desc?: string; functionId: string },
|
||||
string | { title: string; desc?: string; functionId: string },
|
||||
defaultTitle?: string,
|
||||
) {
|
||||
let functionId: string;
|
||||
@@ -605,7 +596,9 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
|
||||
tracker.reportPreviewView(this.route || '', {
|
||||
worksheet_id: functionId,
|
||||
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 {
|
||||
const result = await originalDrawCanvas.apply(this, args);
|
||||
const startedAt = (this as any)[generationStartKey] as
|
||||
| number
|
||||
| undefined;
|
||||
number | undefined;
|
||||
if (startedAt) {
|
||||
delete (this as any)[generationStartKey];
|
||||
const worksheetId =
|
||||
@@ -693,8 +685,7 @@ export function applyPageMixin(
|
||||
return result;
|
||||
} catch (error) {
|
||||
const startedAt = (this as any)[generationStartKey] as
|
||||
| number
|
||||
| undefined;
|
||||
number | undefined;
|
||||
if (startedAt) {
|
||||
delete (this as any)[generationStartKey];
|
||||
tracker.reportGenerateResult(this.route || '', false, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3,36 +3,26 @@
|
||||
*
|
||||
* 使用 wx.loadFontFace 将字体注册到 Canvas native 渲染管线,
|
||||
* 按 URL 去重,同一字体只加载一次。
|
||||
* 支持 HTTP/HTTPS 直链和 cloud:// 云存储文件 ID。
|
||||
* 仅使用已配置的 HTTPS 字体地址。
|
||||
*/
|
||||
|
||||
/** 字体加载所需的最小描述 */
|
||||
export interface FontFace {
|
||||
/** wx.loadFontFace 的 family 参数 */
|
||||
name: string;
|
||||
/** 字体文件 URL(HTTP/HTTPS)或 cloud:// 文件 ID */
|
||||
/** 字体文件 HTTPS URL */
|
||||
url: 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 只加载一次 */
|
||||
export async function loadFontFace(font: FontFace): Promise<void> {
|
||||
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) => {
|
||||
wx.loadFontFace({
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { publicAssetUrl } from '../../config/assets';
|
||||
|
||||
/**
|
||||
* 字体渲染配置(Font Profile)
|
||||
*
|
||||
@@ -12,10 +14,7 @@
|
||||
|
||||
/** 字母分类 */
|
||||
export type LetterCategory =
|
||||
| 'uppercase'
|
||||
| 'tallLower'
|
||||
| 'descender'
|
||||
| 'lowercase';
|
||||
'uppercase' | 'tallLower' | 'descender' | 'lowercase';
|
||||
|
||||
/** 单个分类的渲染参数 */
|
||||
export interface CategoryMetrics {
|
||||
@@ -48,9 +47,6 @@ export function fontFamilyOf(profile: FontProfile): string {
|
||||
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 DESCENDERS = new Set(['g', 'p', 'q', 'y']);
|
||||
@@ -213,7 +209,7 @@ export const PRINT_CLEARLY_DASHED: FontProfile = {
|
||||
/** ToneOZ Pinyin 拼音字体(含声调字母、ü 变体等专用字形) */
|
||||
export const TONEOZ_PINYIN: FontProfile = {
|
||||
name: 'ToneOZPinyin',
|
||||
url: `${CLOUD_PREFIX}assets/fonts/ToneOZ-Pinyin-Regular.ttf`,
|
||||
url: publicAssetUrl('assets/fonts/ToneOZ-Pinyin-Regular.ttf'),
|
||||
categories: {
|
||||
uppercase: { scale: 1, baselineOffset: 0 },
|
||||
tallLower: { scale: 1, baselineOffset: 0 },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { fetchAgeRecommendations } from '../../services/site-api';
|
||||
import { resolvePreviewUrl } from '../../config/assets';
|
||||
import { appSharePageMethods } from '../../base/pageMixin';
|
||||
import { AGE_BANDS, AgeBandKey } from '../../core/data/difficulty';
|
||||
import { parseMiniProgramUrl } from '../../utils/index';
|
||||
@@ -91,24 +93,17 @@ async function fetchAgeRecommended(
|
||||
ageKey: AgeBandKey,
|
||||
): Promise<RecommendedItemView[]> {
|
||||
try {
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'ageRecommendedQuery',
|
||||
data: { ageKey },
|
||||
});
|
||||
const payload = (res && (res.result as any)) || null;
|
||||
if (!payload?.success) return [];
|
||||
const items = (payload.data?.items || []) as any[];
|
||||
const response = await fetchAgeRecommendations(ageKey);
|
||||
const items = response.items;
|
||||
return items
|
||||
.map((it) => ({
|
||||
title: String(it.title || ''),
|
||||
image: String(it.previewImg || ''),
|
||||
image: resolvePreviewUrl(it.previewImg),
|
||||
subtitle: String(it.subtitle || ''),
|
||||
ageBand: String(it.ageBand || it.ageRange || ''),
|
||||
difficulty: toDifficultyLabel(
|
||||
it.difficultyLabel || it.difficulty || '',
|
||||
),
|
||||
ageBand: `${it.ageMin}-${it.ageMax}岁`,
|
||||
difficulty: toDifficultyLabel(it.difficulty || ''),
|
||||
path: String(it.path || ''),
|
||||
worksheetId: String(it.worksheetId || ''),
|
||||
worksheetId: String(it.id || ''),
|
||||
}))
|
||||
.filter((it) => it.title);
|
||||
} catch (e) {
|
||||
@@ -174,8 +169,7 @@ Page({
|
||||
onTapCard(e: WechatMiniprogram.CustomEvent) {
|
||||
const detail = (e.detail || {}) as { title?: string; path?: string };
|
||||
const path =
|
||||
detail.path ||
|
||||
(e.currentTarget.dataset.path as string | undefined);
|
||||
detail.path || (e.currentTarget.dataset.path as string | undefined);
|
||||
const title =
|
||||
detail.title ||
|
||||
(e.currentTarget.dataset.title as string | undefined);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolvePreviewUrl } from '../../config/assets';
|
||||
import type { ApiCategory, ApiWorksheet } from '../../services/content-api';
|
||||
|
||||
export interface CategoryTab {
|
||||
@@ -95,7 +96,7 @@ export function buildCategoryDataset(
|
||||
title: row.title,
|
||||
subtitle: row.subtitle,
|
||||
icon: icons.get(row.category) || '📄',
|
||||
previewImg: row.previewImg || '',
|
||||
previewImg: resolvePreviewUrl(row.previewImg),
|
||||
ageBand: `${row.ageMin}-${row.ageMax}岁`,
|
||||
difficultyLabel: DIFFICULTY_LABELS[row.difficulty - 1] || '基础',
|
||||
path: row.path,
|
||||
|
||||
@@ -222,6 +222,9 @@ Page({
|
||||
this.setData({
|
||||
favoriteList: records.map((r) => mapFavoriteToItem(r)),
|
||||
});
|
||||
} catch {
|
||||
this.setData({ favoriteList: [] });
|
||||
wx.showToast({ title: '收藏加载失败,请重试', icon: 'none' });
|
||||
} finally {
|
||||
this.setData({ favLoading: false });
|
||||
}
|
||||
@@ -232,6 +235,9 @@ Page({
|
||||
try {
|
||||
const records = await getDownloadLogs(1, DOWNLOAD_HISTORY_MAX);
|
||||
this.setData({ downloadSections: groupDownloadsByDate(records) });
|
||||
} catch {
|
||||
this.setData({ downloadSections: [] });
|
||||
wx.showToast({ title: '下载记录加载失败,请重试', icon: 'none' });
|
||||
} finally {
|
||||
this.setData({ dlLoading: false });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { publicAssetUrl } from '../../config/assets';
|
||||
import { CATEGORY_LIST_WITH_ALL } from '../../core/data/categories';
|
||||
import { AGE_BANDS } from '../../core/data/difficulty';
|
||||
import { CategoryId } from '../../core/models/category';
|
||||
@@ -91,8 +92,9 @@ export const HOME_DAILY_CHECKIN_ITEMS: HomeDisplayItem[] = [
|
||||
ageBand: '5-8岁',
|
||||
difficulty: '基础',
|
||||
icon: '📝',
|
||||
previewImg:
|
||||
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/chinese/hanzi-daily-checkin.jpg',
|
||||
previewImg: publicAssetUrl(
|
||||
'assets/previews/chinese/hanzi-daily-checkin.jpg',
|
||||
),
|
||||
path: '/chinesePages/handwritingSheet/handwritingSheet?id=hanzi-daily-checkin',
|
||||
available: true,
|
||||
},
|
||||
@@ -104,8 +106,7 @@ export const HOME_DAILY_CHECKIN_ITEMS: HomeDisplayItem[] = [
|
||||
ageBand: '6-8岁',
|
||||
difficulty: '基础',
|
||||
icon: '🔤',
|
||||
previewImg:
|
||||
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/pinyin/pinyin-daily.jpg',
|
||||
previewImg: publicAssetUrl('assets/previews/pinyin/pinyin-daily.jpg'),
|
||||
path: '/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-daily',
|
||||
available: true,
|
||||
},
|
||||
@@ -117,8 +118,9 @@ export const HOME_DAILY_CHECKIN_ITEMS: HomeDisplayItem[] = [
|
||||
ageBand: '6-8岁',
|
||||
difficulty: '基础',
|
||||
icon: '🔠',
|
||||
previewImg:
|
||||
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-daily-checkin.jpg',
|
||||
previewImg: publicAssetUrl(
|
||||
'assets/previews/english/letter-tracing-daily-checkin.jpg',
|
||||
),
|
||||
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-daily-checkin',
|
||||
available: true,
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolvePreviewUrl } from '../../config/assets';
|
||||
import { appSharePageMethods } from '../../base/pageMixin';
|
||||
import { NAV_INNER_PX } from '../../utils/navMetrics';
|
||||
import {
|
||||
@@ -52,6 +53,7 @@ const TAB_BAR_PATHS = new Set([
|
||||
function toItemView(item: HomeDisplayItem): HomeDisplayItemView {
|
||||
return {
|
||||
...item,
|
||||
previewImg: resolvePreviewUrl(item.previewImg),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -218,11 +220,11 @@ Page({
|
||||
void this.initData();
|
||||
},
|
||||
|
||||
async initData() {
|
||||
wx.showLoading({ title: '加载中', mask: false });
|
||||
async initData(override?: PageConfigData) {
|
||||
if (!override) wx.showLoading({ title: '加载中', mask: false });
|
||||
|
||||
const app = getApp<IAppOption>();
|
||||
const config = await app.getPageConfig();
|
||||
const config = override || (await app.getPageConfig());
|
||||
|
||||
let homeData: HomeDisplayDataset;
|
||||
|
||||
@@ -249,13 +251,26 @@ Page({
|
||||
searchPlaceholder: homeData.searchPlaceholder,
|
||||
tabs,
|
||||
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),
|
||||
hotRecommends: homeData.hot.slice(0, 3).map(toItemView),
|
||||
sections,
|
||||
});
|
||||
|
||||
wx.hideLoading();
|
||||
if (!override) wx.hideLoading();
|
||||
},
|
||||
|
||||
// ── Tab & Navigation ──
|
||||
|
||||
@@ -123,7 +123,7 @@ page {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-hero__stat+.profile-hero__stat {
|
||||
.profile-hero__stat + .profile-hero__stat {
|
||||
margin-left: 32rpx;
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ page {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-grid__cell+.profile-grid__cell {
|
||||
.profile-grid__cell + .profile-grid__cell {
|
||||
margin-left: 32rpx;
|
||||
}
|
||||
|
||||
@@ -462,3 +462,15 @@ page {
|
||||
.profile-editor__action--disabled {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 { APP_VERSION } from '../../config/config';
|
||||
import { getDownloadLogCount } from '../../utils/downloadLogs';
|
||||
import { getFavoriteCount } from '../../utils/favorites';
|
||||
import {
|
||||
getCachedUser,
|
||||
getUser,
|
||||
@@ -22,6 +22,8 @@ Page({
|
||||
draftNickName: '',
|
||||
draftAvatarUrl: '/assets/imgs/doodle-head.png',
|
||||
isSavingProfile: false,
|
||||
serverAccountActionRequired: false,
|
||||
isStartingAccount: false,
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
@@ -35,17 +37,52 @@ Page({
|
||||
},
|
||||
|
||||
onShow() {
|
||||
void this.checkServerAccount();
|
||||
this.syncUserInfoFromApp();
|
||||
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() {
|
||||
const app = getApp<IAppOption>();
|
||||
const cachedUser = app.globalData.user || getCachedUser();
|
||||
|
||||
if (cachedUser) {
|
||||
this.applyUserInfo(cachedUser);
|
||||
}
|
||||
if (cachedUser) this.applyUserInfo(cachedUser);
|
||||
else
|
||||
this.setData({
|
||||
userName: '微信用户',
|
||||
avatarUrl: this.DEFAULT_AVATAR_URL,
|
||||
printCount: 0,
|
||||
favoriteCount: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
const latestUser = await getUser();
|
||||
@@ -69,13 +106,11 @@ Page({
|
||||
|
||||
async refreshStats() {
|
||||
try {
|
||||
const [printCount, favoriteCount] = await Promise.all([
|
||||
getDownloadLogCount(),
|
||||
getFavoriteCount(),
|
||||
]);
|
||||
const stats = await memberStats();
|
||||
const { downloadCount: printCount, favoriteCount } = stats;
|
||||
this.setData({ printCount, favoriteCount });
|
||||
} catch (e) {
|
||||
console.error('refreshStats failed', e);
|
||||
} catch {
|
||||
this.setData({ printCount: 0, favoriteCount: 0 });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -121,12 +156,12 @@ Page({
|
||||
this.setData({ isSavingProfile: true });
|
||||
|
||||
try {
|
||||
const avatarUrl = await this.persistAvatarIfNeeded(
|
||||
const avatarAssetId = await this.persistAvatarIfNeeded(
|
||||
this.data.draftAvatarUrl,
|
||||
);
|
||||
const user = await updateUserProfile({
|
||||
nickName,
|
||||
avatarUrl,
|
||||
avatarAssetId,
|
||||
});
|
||||
|
||||
this.applyUserInfo(user);
|
||||
@@ -140,24 +175,11 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
async persistAvatarIfNeeded(avatarUrl: string): Promise<string> {
|
||||
if (!avatarUrl) return avatarUrl;
|
||||
if (
|
||||
avatarUrl.startsWith('cloud://') ||
|
||||
avatarUrl.startsWith('http://') ||
|
||||
avatarUrl.startsWith('https://')
|
||||
) {
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
async persistAvatarIfNeeded(avatarUrl: string): Promise<string | null> {
|
||||
const user = await getUser();
|
||||
const cloudPath = `user-avatars/${user._id}-${Date.now()}.png`;
|
||||
const uploadRes = await wx.cloud.uploadFile({
|
||||
cloudPath,
|
||||
filePath: avatarUrl,
|
||||
});
|
||||
|
||||
return uploadRes.fileID;
|
||||
if (!avatarUrl || avatarUrl === this.DEFAULT_AVATAR_URL) return null;
|
||||
if (avatarUrl === user.avatarUrl) return user.avatarAssetId || null;
|
||||
return (await uploadAsset(avatarUrl, 'avatar')).id;
|
||||
},
|
||||
|
||||
onGoToDownloads() {
|
||||
|
||||
@@ -38,6 +38,19 @@
|
||||
</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__cell" bindtap="onGoToFavorites">
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}) };
|
||||
}
|
||||
@@ -1,74 +1,102 @@
|
||||
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> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
requestId?: string;
|
||||
}
|
||||
const recoverable = new Set([
|
||||
'AUTH_REQUIRED',
|
||||
'AUTH_INVALID',
|
||||
'AUTH_EXPIRED',
|
||||
'AUTH_REVOKED',
|
||||
]);
|
||||
const blocked = new Set([
|
||||
'ACCOUNT_NOT_FOUND',
|
||||
'ACCOUNT_DISABLED',
|
||||
'WECHAT_LOGIN_BLOCKED',
|
||||
]);
|
||||
|
||||
/** 保留有限的状态用于页面反馈,不把服务器原始响应或凭证输出到界面。 */
|
||||
export class ApiRequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number = 0,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
/** 新后端统一请求层:校验 HTTP 与业务状态,旧云函数/CDN 请求保持独立。 */
|
||||
/** 公开接口显式 auth=none;其余接口合并登录,最多恢复并重发一次,不跨账户补发。 */
|
||||
export async function apiRequest<T>(
|
||||
path: string,
|
||||
options: {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
data?: Record<string, unknown>;
|
||||
header?: Record<string, string>;
|
||||
} = {},
|
||||
options: RequestOptions & { auth?: 'required' | 'none' } = {},
|
||||
): Promise<T> {
|
||||
const config = getApiConfig();
|
||||
if (!path.startsWith('/') || path.startsWith('//'))
|
||||
throw new Error('接口路径不正确');
|
||||
return new Promise((resolve, reject) => {
|
||||
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 (
|
||||
!res.data ||
|
||||
res.data.code !== 0 ||
|
||||
!Object.prototype.hasOwnProperty.call(res.data, 'data')
|
||||
) {
|
||||
reject(
|
||||
new ApiRequestError(
|
||||
'接口返回异常,请稍后重试',
|
||||
res.statusCode,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve(res.data.data);
|
||||
// Authorization 由会话层统一提供;公开请求不附带调用方误传的凭证。
|
||||
const header = Object.fromEntries(
|
||||
Object.entries(options.header || {}).filter(
|
||||
([key]) => key.toLowerCase() !== 'authorization',
|
||||
),
|
||||
);
|
||||
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}` },
|
||||
},
|
||||
fail() {
|
||||
reject(
|
||||
new ApiRequestError('连接失败,请确认后端已启动及网络配置'),
|
||||
context,
|
||||
);
|
||||
if (
|
||||
getAuthContext().key !== context.key ||
|
||||
cachedServerUser()?.id !== initial.user.id
|
||||
)
|
||||
throw new ApiRequestError('账户状态已变化,请重新操作');
|
||||
return result;
|
||||
};
|
||||
try {
|
||||
return await send(initial.accessToken);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiRequestError) || error.statusCode !== 401)
|
||||
throw error;
|
||||
if (!recoverable.has(error.reason)) {
|
||||
if (blocked.has(error.reason))
|
||||
rejectSession(context, initial.accessToken, true);
|
||||
throw error;
|
||||
}
|
||||
const renewed = await recoverSession(context, initial);
|
||||
if (renewed.user.id !== initial.user.id) {
|
||||
clearSession(false, context);
|
||||
throw 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -36,7 +36,9 @@ interface WorksheetPage {
|
||||
|
||||
/** 分类直接由服务端发布状态决定,不用本地目录补齐或恢复下架分类。 */
|
||||
export async function listCategories(): Promise<ApiCategory[]> {
|
||||
const rows = await apiRequest<ApiCategory[]>('/categories');
|
||||
const rows = await apiRequest<ApiCategory[]>('/categories', {
|
||||
auth: 'none',
|
||||
});
|
||||
if (
|
||||
!Array.isArray(rows) ||
|
||||
rows.some(
|
||||
@@ -87,6 +89,7 @@ export async function listAllWorksheets(): Promise<ApiWorksheet[]> {
|
||||
let expectedTotal: number | undefined;
|
||||
for (let page = 1; ; page++) {
|
||||
const result = await apiRequest<WorksheetPage>('/worksheets', {
|
||||
auth: 'none',
|
||||
data: { page, pageSize },
|
||||
});
|
||||
if (
|
||||
@@ -121,6 +124,7 @@ export async function listAllWorksheets(): Promise<ApiWorksheet[]> {
|
||||
export async function getWorksheet(id: string): Promise<ApiWorksheet> {
|
||||
const row = await apiRequest<ApiWorksheet>(
|
||||
`/worksheets/${encodeURIComponent(id)}`,
|
||||
{ auth: 'none' },
|
||||
);
|
||||
validateWorksheet(row);
|
||||
if (row.id !== id) throw new Error('题型数据异常,请稍后重试');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
type WorksheetItem = {
|
||||
@@ -13,8 +16,8 @@ type WorksheetItem = {
|
||||
tagsText: string;
|
||||
status: 'draft' | 'active' | 'hidden';
|
||||
sortOrder: number;
|
||||
downloads: number;
|
||||
likes: number;
|
||||
downloads: string;
|
||||
likes: string;
|
||||
updatedAt: string;
|
||||
// display helpers
|
||||
ageBand: string;
|
||||
@@ -32,13 +35,6 @@ type Stats = {
|
||||
hidden: number;
|
||||
};
|
||||
|
||||
type CloudFunctionResult<T> = {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
stats?: Stats;
|
||||
};
|
||||
|
||||
const DIFFICULTY_LABELS: Record<number, string> = {
|
||||
1: '入门',
|
||||
2: '基础',
|
||||
@@ -82,7 +78,7 @@ function formatWorksheet(raw: Record<string, unknown>): WorksheetItem {
|
||||
title: String(raw.title || ''),
|
||||
subtitle: String(raw.subtitle || ''),
|
||||
category: String(raw.category || ''),
|
||||
previewImg: String(raw.previewImg || ''),
|
||||
previewImg: resolvePreviewUrl(raw.previewImg),
|
||||
ageMin,
|
||||
ageMax,
|
||||
difficulty,
|
||||
@@ -92,8 +88,8 @@ function formatWorksheet(raw: Record<string, unknown>): WorksheetItem {
|
||||
: '',
|
||||
status: status as WorksheetItem['status'],
|
||||
sortOrder: Number(raw.sortOrder) || 0,
|
||||
downloads: Number(raw.downloads) || 0,
|
||||
likes: Number(raw.likes) || 0,
|
||||
downloads: String(raw.downloads || '0'),
|
||||
likes: String(raw.likes || '0'),
|
||||
updatedAt: raw.updatedAt ? String(raw.updatedAt) : '',
|
||||
ageBand: `${ageMin}-${ageMax}岁`,
|
||||
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({
|
||||
data: {
|
||||
categories: CATEGORY_LIST,
|
||||
@@ -139,8 +124,8 @@ Page({
|
||||
this.setData({ loading: true });
|
||||
|
||||
try {
|
||||
const result = await callCloudFunction<WorksheetItem[]>(
|
||||
'worksheetsQuery',
|
||||
const result = await managementRequest<WorksheetItem[]>(
|
||||
'worksheets.list',
|
||||
{
|
||||
category: this.data.activeCategoryId,
|
||||
rawStats: true,
|
||||
@@ -155,7 +140,9 @@ Page({
|
||||
.map((raw) =>
|
||||
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({
|
||||
loading: false,
|
||||
@@ -204,7 +191,7 @@ Page({
|
||||
this.setData({ updatingId: id });
|
||||
|
||||
try {
|
||||
const result = await callCloudFunction('worksheetsUpdateStatus', {
|
||||
const result = await managementRequest('worksheets.status', {
|
||||
id,
|
||||
status: target,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { managementRequest } from '../../services/admin-api';
|
||||
import { CATEGORY_LIST } from '../../core/data/categories';
|
||||
|
||||
type CloudCategory = {
|
||||
@@ -23,12 +24,6 @@ type CompareItem = {
|
||||
cloudCategory: CloudCategory | null;
|
||||
};
|
||||
|
||||
type CloudFunctionResult<T> = {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
const DEFAULT_CATEGORY_COLORS = [
|
||||
'#F7EE47',
|
||||
'#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({
|
||||
data: {
|
||||
loading: false,
|
||||
@@ -170,7 +153,7 @@ Page({
|
||||
|
||||
try {
|
||||
const result =
|
||||
await callCategoryFunction<CloudCategory[]>('categoriesQuery');
|
||||
await managementRequest<CloudCategory[]>('categories.list');
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || '获取分类失败');
|
||||
@@ -204,7 +187,7 @@ Page({
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
wx.showModal({
|
||||
title: `${item.actionText}分类`,
|
||||
content: `确认将「${item.name}」同步到云端吗?`,
|
||||
content: `确认将「${item.name}」同步到新后端吗?`,
|
||||
success: (res) => resolve(!!res.confirm),
|
||||
fail: () => resolve(false),
|
||||
});
|
||||
@@ -228,9 +211,9 @@ Page({
|
||||
try {
|
||||
const functionName =
|
||||
item.status === 'missing'
|
||||
? 'categoriesCreate'
|
||||
: 'categoriesUpdate';
|
||||
const result = await callCategoryFunction<CloudCategory>(
|
||||
? 'categories.create'
|
||||
: 'categories.update';
|
||||
const result = await managementRequest<CloudCategory>(
|
||||
functionName,
|
||||
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';
|
||||
|
||||
type SimpleWorksheet = {
|
||||
@@ -13,8 +17,8 @@ type SimpleWorksheet = {
|
||||
isHot: boolean;
|
||||
path: string;
|
||||
ageBand: string;
|
||||
downloads: number;
|
||||
likes: number;
|
||||
downloads: string;
|
||||
likes: string;
|
||||
};
|
||||
|
||||
type SectionConfig = {
|
||||
@@ -43,12 +47,6 @@ type PageConfig = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type CloudFunctionResult<T> = {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
function formatSimple(raw: Record<string, unknown>): SimpleWorksheet {
|
||||
const ageMin = Number(raw.ageMin) || 0;
|
||||
const ageMax = Number(raw.ageMax) || 0;
|
||||
@@ -57,7 +55,7 @@ function formatSimple(raw: Record<string, unknown>): SimpleWorksheet {
|
||||
title: String(raw.title || ''),
|
||||
subtitle: String(raw.subtitle || ''),
|
||||
category: String(raw.category || ''),
|
||||
previewImg: String(raw.previewImg || ''),
|
||||
previewImg: resolvePreviewUrl(raw.previewImg),
|
||||
ageMin,
|
||||
ageMax,
|
||||
difficulty: Number(raw.difficulty) || 2,
|
||||
@@ -65,22 +63,11 @@ function formatSimple(raw: Record<string, unknown>): SimpleWorksheet {
|
||||
isHot: !!raw.isHot,
|
||||
path: String(raw.path || ''),
|
||||
ageBand: `${ageMin}-${ageMax}岁`,
|
||||
downloads: Number(raw.downloads) || 0,
|
||||
likes: Number(raw.likes) || 0,
|
||||
downloads: String(raw.downloads || '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({
|
||||
data: {
|
||||
featured: [] as SimpleWorksheet[],
|
||||
@@ -122,15 +109,15 @@ Page({
|
||||
|
||||
async loadTimerStatus() {
|
||||
try {
|
||||
const db = wx.cloud.database();
|
||||
const { data } = await db
|
||||
.collection('settings')
|
||||
.doc('homeAutoRefresh')
|
||||
.get();
|
||||
this.setData({ timerEnabled: (data as { enabled?: boolean }).enabled !== false });
|
||||
const data = await apiRequest<{ enabled: boolean }>(
|
||||
'/admin/site/timer',
|
||||
);
|
||||
this.setData({
|
||||
timerEnabled: (data as { enabled?: boolean }).enabled !== false,
|
||||
});
|
||||
} catch {
|
||||
// Document doesn't exist — default enabled
|
||||
this.setData({ timerEnabled: true });
|
||||
// 权限或网络失败不能被当成已启用。
|
||||
this.setData({ timerEnabled: false });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -139,13 +126,14 @@ Page({
|
||||
try {
|
||||
// Fetch in parallel: active worksheets + saved page config
|
||||
const [wsResult, configResult] = await Promise.all([
|
||||
callCloudFunction<SimpleWorksheet[]>('worksheetsQuery', {
|
||||
managementRequest<SimpleWorksheet[]>('worksheets.list', {
|
||||
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) =>
|
||||
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
|
||||
const featured: SimpleWorksheet[] = [...allActive]
|
||||
.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0))
|
||||
.sort((a, b) =>
|
||||
compareCountDescending(a.downloads, b.downloads),
|
||||
)
|
||||
.slice(0, 5);
|
||||
const hot: SimpleWorksheet[] = [...allActive]
|
||||
.sort((a, b) => (b.likes ?? 0) - (a.likes ?? 0))
|
||||
.sort((a, b) => compareCountDescending(a.likes, b.likes))
|
||||
.slice(0, 5);
|
||||
|
||||
// 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 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 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) {
|
||||
this.setData({ loading: false });
|
||||
wx.showToast({
|
||||
@@ -202,7 +201,7 @@ Page({
|
||||
|
||||
this.setData({ timerLoading: true });
|
||||
try {
|
||||
const result = await callCloudFunction('homeTimerControl', {
|
||||
const result = await managementRequest('site.timer', {
|
||||
enabled: newEnabled,
|
||||
});
|
||||
if (!result.success) {
|
||||
@@ -335,9 +334,9 @@ Page({
|
||||
this.setData({ building: true });
|
||||
|
||||
try {
|
||||
const result = await callCloudFunction<{
|
||||
const result = await managementRequest<{
|
||||
version: number;
|
||||
}>('pageContentBuild', {
|
||||
}>('site.rebuild', {
|
||||
page: 'home',
|
||||
featuredIds: this.data.featured.map((w) => w._id),
|
||||
hotIds: this.data.hot.map((w) => w._id),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiRequest } from '../../services/api-request';
|
||||
import { disablePageShareMenu } from '../../base/pageMixin';
|
||||
import { clearUser } from '../../utils/auth';
|
||||
import { clearUser, refreshUser } from '../../utils/auth';
|
||||
|
||||
Page({
|
||||
onLoad() {
|
||||
@@ -22,15 +23,8 @@ Page({
|
||||
async doClearProfile() {
|
||||
wx.showLoading({ title: '处理中...', mask: true });
|
||||
try {
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userSettings',
|
||||
data: { action: 'clearProfile' },
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.message || '操作失败');
|
||||
}
|
||||
clearUser();
|
||||
await apiRequest('/me/profile', { method: 'DELETE' });
|
||||
await refreshUser();
|
||||
wx.hideLoading();
|
||||
wx.showToast({ title: '已删除', icon: 'success' });
|
||||
} catch (e) {
|
||||
@@ -58,15 +52,8 @@ Page({
|
||||
async doDeleteAccount() {
|
||||
wx.showLoading({ title: '处理中...', mask: true });
|
||||
try {
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userSettings',
|
||||
data: { action: 'deleteAccount' },
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.message || '操作失败');
|
||||
}
|
||||
clearUser();
|
||||
await apiRequest('/me', { method: 'DELETE' });
|
||||
clearUser(true);
|
||||
wx.hideLoading();
|
||||
|
||||
wx.reLaunch({ url: '/pages/home/home' });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { managementRequest } from '../../services/admin-api';
|
||||
import { MOCK_SEED_COUNTS } from './mockLikes';
|
||||
import {
|
||||
MATH_WORKSHEET_DEFINITIONS,
|
||||
@@ -21,17 +22,6 @@ type CloudFunctionResult<T> = {
|
||||
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 {
|
||||
const centerAge = Math.round((ageMin + ageMax) / 2);
|
||||
const ageGradeMap: Record<number, number> = {
|
||||
@@ -224,7 +214,7 @@ Page({
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const patchRes = await callCloudFunction('worksheetsBatchPatch', {
|
||||
const patchRes = await managementRequest('worksheets.batch', {
|
||||
patches,
|
||||
dryRun: false,
|
||||
});
|
||||
@@ -262,7 +252,7 @@ Page({
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const rebuildRes = await callCloudFunction('pageContentBuild', {
|
||||
const rebuildRes = await managementRequest('site.rebuild', {
|
||||
page: 'all',
|
||||
});
|
||||
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,
|
||||
dryRun: false,
|
||||
});
|
||||
@@ -415,8 +405,8 @@ Page({
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const queryRes = await callCloudFunction<Array<{ _id: string }>>(
|
||||
'worksheetsQuery',
|
||||
const queryRes = await managementRequest<Array<{ _id: string }>>(
|
||||
'worksheets.list',
|
||||
{},
|
||||
);
|
||||
|
||||
@@ -438,7 +428,7 @@ Page({
|
||||
return;
|
||||
}
|
||||
|
||||
const patchRes = await callCloudFunction('worksheetsBatchPatch', {
|
||||
const patchRes = await managementRequest('worksheets.batch', {
|
||||
patches,
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
+39
-148
@@ -1,165 +1,56 @@
|
||||
interface UserInfo {
|
||||
_id: string;
|
||||
openid: string;
|
||||
unionid: string | null;
|
||||
nickName: string | null;
|
||||
avatarUrl: string | null;
|
||||
totalDownloads: number;
|
||||
createdAt: string;
|
||||
lastActiveAt: string;
|
||||
}
|
||||
import { apiRequest } from '../services/api-request';
|
||||
import {
|
||||
cachedServerUser,
|
||||
clearSession,
|
||||
ensureSession,
|
||||
getAuthContext,
|
||||
updateSessionUser,
|
||||
type ServerUser,
|
||||
} from '../services/auth-session';
|
||||
|
||||
const STORAGE_KEY = 'user_info';
|
||||
const STORAGE_EXPIRE_KEY = 'user_info_expire';
|
||||
const EXPIRE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 天
|
||||
export type UserInfo = ServerUser;
|
||||
export { ensureSession as ensureAuthenticated } from '../services/auth-session';
|
||||
|
||||
/** 内存中的用户数据(App 生命周期内有效) */
|
||||
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 → 云函数登录
|
||||
*/
|
||||
/** 用户摘要与新后端认证绑定,所有远端用户业务只使用本站用户 ID。 */
|
||||
export async function getUser(): Promise<UserInfo> {
|
||||
// 1. 内存中有,直接返回
|
||||
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;
|
||||
return (await ensureSession()).user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 静默登录:调用云函数
|
||||
*/
|
||||
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 {
|
||||
if (currentUser) {
|
||||
syncAppGlobalUser(currentUser);
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
const cached = loadFromStorage();
|
||||
if (!cached) return null;
|
||||
return syncUserState(cached, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Storage 读取用户数据(检查过期)
|
||||
*/
|
||||
function loadFromStorage(): UserInfo | null {
|
||||
try {
|
||||
const expire = wx.getStorageSync(STORAGE_EXPIRE_KEY);
|
||||
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;
|
||||
return cachedServerUser();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制刷新用户数据(跳过缓存)
|
||||
*/
|
||||
/** 读取本人资料;GET /me 会按统一规则完成过期与 401 恢复。 */
|
||||
export async function refreshUser(): Promise<UserInfo> {
|
||||
clearUser();
|
||||
return await silentLogin();
|
||||
const context = getAuthContext();
|
||||
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: {
|
||||
nickName?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
nickName?: string;
|
||||
avatarAssetId?: string | null;
|
||||
}): Promise<UserInfo> {
|
||||
await getUser();
|
||||
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userLogin',
|
||||
data: {
|
||||
action: 'updateProfile',
|
||||
...payload,
|
||||
},
|
||||
const context = getAuthContext();
|
||||
const user = await apiRequest<UserInfo>('/me/profile', {
|
||||
method: 'PUT',
|
||||
data: payload,
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
|
||||
if (result.code !== 0 || !result.data) {
|
||||
throw new Error(result.message || '更新用户资料失败');
|
||||
}
|
||||
|
||||
return syncUserState(result.data as UserInfo);
|
||||
if (getAuthContext().key !== context.key)
|
||||
throw new Error('接口环境已变化,请重新操作');
|
||||
return updateSessionUser(user, context);
|
||||
}
|
||||
|
||||
export type { UserInfo };
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type {
|
||||
WorksheetCloudCategory,
|
||||
WorksheetStatus,
|
||||
} from '../core/models/worksheet';
|
||||
import type { CategoryId } from '../core/models/category';
|
||||
import type { WorksheetConfig } from '../core/models/worksheet';
|
||||
|
||||
/**
|
||||
* 调试发布时的裁剪模式。
|
||||
@@ -19,7 +17,7 @@ export interface DebugPublishMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
category: WorksheetCloudCategory;
|
||||
category: CategoryId;
|
||||
subcategory?: string;
|
||||
path: string;
|
||||
ageMin: number;
|
||||
@@ -31,7 +29,7 @@ export interface DebugPublishMeta {
|
||||
isNew: boolean;
|
||||
isHot: boolean;
|
||||
sortOrder?: number;
|
||||
status: WorksheetStatus;
|
||||
status: WorksheetConfig['status'];
|
||||
}
|
||||
|
||||
/** 发布弹窗里可调的图片处理参数。 */
|
||||
@@ -118,7 +116,7 @@ export function getCropRatios(mode: DebugCropMode): {
|
||||
|
||||
/** 统一构造预览图在云存储中的路径。 */
|
||||
export function buildWorksheetPreviewCloudPath(
|
||||
category: WorksheetCloudCategory,
|
||||
category: CategoryId,
|
||||
id: string,
|
||||
): string {
|
||||
return `assets/previews/${category}/${id}.jpg`;
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
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 {
|
||||
_id: string;
|
||||
userId: string;
|
||||
worksheetId: string;
|
||||
exportId?: string;
|
||||
exportId?: string | null;
|
||||
createdAt: string;
|
||||
worksheet?: {
|
||||
_id: string;
|
||||
@@ -20,66 +26,39 @@ export interface DownloadLogRecord {
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录下载日志
|
||||
*/
|
||||
/** 一次成功保存沿用同一个 exportId,服务端保证记录及下载计数幂等。 */
|
||||
export async function addDownloadLog(
|
||||
worksheetId: string,
|
||||
exportId?: string,
|
||||
exportId = generateUUID(),
|
||||
expectedUserId?: string,
|
||||
): Promise<boolean> {
|
||||
if (!worksheetId) return false;
|
||||
try {
|
||||
await getUser();
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userDownloadLogs',
|
||||
data: { action: 'add', worksheetId, exportId },
|
||||
const user = await getUser();
|
||||
if (expectedUserId && user.id !== expectedUserId) return false;
|
||||
await apiRequest('/me/downloads', {
|
||||
method: 'POST',
|
||||
data: { worksheetId, exportId },
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
return result.code === 0;
|
||||
} catch (e) {
|
||||
console.error('addDownloadLog failed', e);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 收藏页下载历史展示条数上限 */
|
||||
export const DOWNLOAD_HISTORY_MAX = 30;
|
||||
|
||||
/**
|
||||
* 获取下载历史(默认最近 30 条)
|
||||
*/
|
||||
export async function getDownloadLogs(
|
||||
page = 1,
|
||||
pageSize = DOWNLOAD_HISTORY_MAX,
|
||||
): Promise<DownloadLogRecord[]> {
|
||||
try {
|
||||
await getUser();
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userDownloadLogs',
|
||||
data: { action: 'list', page, pageSize },
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
return result.code === 0 ? result.data : [];
|
||||
} catch (e) {
|
||||
console.error('getDownloadLogs failed', e);
|
||||
return [];
|
||||
}
|
||||
const result = await apiRequest<MemberPage>('/me/downloads', {
|
||||
data: { page, pageSize },
|
||||
});
|
||||
return result.items.map((row) => ({
|
||||
...row,
|
||||
_id: row.id,
|
||||
worksheet: legacyWorksheet(row.worksheet),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户下载(打印)总数
|
||||
*/
|
||||
export async function getDownloadLogCount(): Promise<number> {
|
||||
try {
|
||||
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;
|
||||
}
|
||||
return (await memberStats()).downloadCount;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCachedUser } from './auth';
|
||||
/**
|
||||
* 下载打印通用工具
|
||||
* 支持下载次数限制和激励广告
|
||||
@@ -67,7 +68,9 @@ function showVideoAd(): Promise<boolean> {
|
||||
mask: true,
|
||||
});
|
||||
|
||||
const onClose: WechatMiniprogram.RewardedVideoAdOnCloseCallback = (res) => {
|
||||
const onClose: WechatMiniprogram.RewardedVideoAdOnCloseCallback = (
|
||||
res,
|
||||
) => {
|
||||
videoAd.offClose(onClose);
|
||||
wx.hideLoading();
|
||||
resolve(!!(res && res.isEnded));
|
||||
@@ -137,6 +140,7 @@ function doDownload(
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const exportId = generateUUID();
|
||||
const expectedUserId = getCachedUser()?.id;
|
||||
const trackerPage = options.trackerPage || options.trackerName || '';
|
||||
const trackerParams = {
|
||||
export_id: exportId,
|
||||
@@ -154,7 +158,11 @@ function doDownload(
|
||||
recordDownloadSuccess();
|
||||
tracker.reportExportResult(trackerPage, true, trackerParams);
|
||||
if (options.worksheetId) {
|
||||
await addDownloadLog(options.worksheetId, exportId);
|
||||
await addDownloadLog(
|
||||
options.worksheetId,
|
||||
exportId,
|
||||
expectedUserId,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracker.reportExportResult(trackerPage, false, trackerParams);
|
||||
|
||||
+38
-102
@@ -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';
|
||||
|
||||
function getCurrentPageRoute(): string {
|
||||
const pages = getCurrentPages();
|
||||
return pages[pages.length - 1]?.route || '';
|
||||
}
|
||||
|
||||
export interface FavoriteRecord {
|
||||
_id: string;
|
||||
userId: string;
|
||||
worksheetId: string;
|
||||
createdAt: string;
|
||||
worksheet?: {
|
||||
@@ -26,122 +25,59 @@ export interface FavoriteRecord {
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加收藏
|
||||
*/
|
||||
/** 收藏操作使用新后端当前账户;调用失败不能向其他用户补发。 */
|
||||
export async function addFavorite(worksheetId: string): Promise<boolean> {
|
||||
try {
|
||||
await getUser(); // 确保已登录
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userFavorites',
|
||||
data: { action: 'add', worksheetId },
|
||||
await apiRequest(`/me/favorites/${encodeURIComponent(worksheetId)}`, {
|
||||
method: 'PUT',
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
const success = result.code === 0;
|
||||
if (success) tracker.reportFavorite(getCurrentPageRoute(), worksheetId, true);
|
||||
return success;
|
||||
} catch (e) {
|
||||
console.error('addFavorite failed', e);
|
||||
tracker.reportFavorite(currentRoute(), worksheetId, true);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消收藏
|
||||
*/
|
||||
export async function removeFavorite(worksheetId: string): Promise<boolean> {
|
||||
try {
|
||||
await getUser();
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userFavorites',
|
||||
data: { action: 'remove', worksheetId },
|
||||
await apiRequest(`/me/favorites/${encodeURIComponent(worksheetId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
const success = result.code === 0;
|
||||
if (success) {
|
||||
tracker.reportFavorite(getCurrentPageRoute(), worksheetId, false);
|
||||
}
|
||||
return success;
|
||||
} catch (e) {
|
||||
console.error('removeFavorite failed', e);
|
||||
tracker.reportFavorite(currentRoute(), worksheetId, false);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取收藏列表
|
||||
*/
|
||||
export async function getFavoriteList(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
): Promise<FavoriteRecord[]> {
|
||||
try {
|
||||
await getUser();
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userFavorites',
|
||||
data: { action: 'list', page, pageSize },
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
return result.code === 0 ? result.data || [] : [];
|
||||
} catch (e) {
|
||||
console.error('getFavoriteList failed', e);
|
||||
return [];
|
||||
}
|
||||
const result = await apiRequest<MemberPage>('/me/favorites', {
|
||||
data: { page, pageSize },
|
||||
});
|
||||
return result.items.map((row) => ({
|
||||
...row,
|
||||
_id: row.id,
|
||||
worksheet: legacyWorksheet(row.worksheet),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户收藏总数
|
||||
*/
|
||||
export async function getFavoriteCount(): Promise<number> {
|
||||
try {
|
||||
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;
|
||||
}
|
||||
return (await memberStats()).favoriteCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已收藏
|
||||
*/
|
||||
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(
|
||||
worksheetIds: string[],
|
||||
): Promise<Record<string, boolean>> {
|
||||
if (!worksheetIds.length) return {};
|
||||
try {
|
||||
await getUser();
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'userFavorites',
|
||||
data: { action: 'batchCheck', worksheetIds },
|
||||
});
|
||||
const result = (res?.result as any) || {};
|
||||
return result.code === 0 ? result.data || {} : {};
|
||||
} catch (e) {
|
||||
console.error('batchCheckFavorited failed', e);
|
||||
return {};
|
||||
}
|
||||
return apiRequest('/me/favorites/check', {
|
||||
method: 'POST',
|
||||
data: { worksheetIds },
|
||||
});
|
||||
}
|
||||
export async function checkFavorited(worksheetId: string): Promise<boolean> {
|
||||
return (await batchCheckFavorited([worksheetId]))[worksheetId] === true;
|
||||
}
|
||||
|
||||
function currentRoute() {
|
||||
const pages = getCurrentPages();
|
||||
return pages[pages.length - 1]?.route || '';
|
||||
}
|
||||
|
||||
@@ -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
@@ -8,7 +8,9 @@
|
||||
"worksheet:patch:tags": "tsx scripts/worksheet-sync-cloud-payload.ts --tags",
|
||||
"worksheet:patch:all": "tsx scripts/worksheet-sync-cloud-payload.ts --all",
|
||||
"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": [],
|
||||
"author": "",
|
||||
|
||||
@@ -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, /账户状态已变化/);
|
||||
});
|
||||
@@ -173,7 +173,10 @@ test('环境配置:开发版默认本地,正式/体验/未知环境不回退
|
||||
|
||||
test('请求层:HTTP、业务错误、畸形响应、网络错误均拒绝,不当作空成功', async () => {
|
||||
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');
|
||||
for (const result of [
|
||||
{ statusCode: 404 },
|
||||
@@ -183,12 +186,18 @@ test('请求层:HTTP、业务错误、畸形响应、网络错误均拒绝,
|
||||
{ raw: '<html>error</html>' },
|
||||
]) {
|
||||
handler = () => result;
|
||||
await assert.rejects(apiRequest('/categories'), ApiRequestError);
|
||||
await assert.rejects(
|
||||
apiRequest('/categories', { auth: 'none' }),
|
||||
ApiRequestError,
|
||||
);
|
||||
}
|
||||
handler = () => {
|
||||
throw new Error('offline');
|
||||
};
|
||||
await assert.rejects(apiRequest('/categories'), /连接失败/);
|
||||
await assert.rejects(
|
||||
apiRequest('/categories', { auth: 'none' }),
|
||||
/连接失败/,
|
||||
);
|
||||
});
|
||||
|
||||
test('完整分页:121 条无遗漏,计数字符串保留精度,空数据正常结束', async () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Vendored
+1
-1
@@ -13,7 +13,7 @@ interface PageConfigData {
|
||||
}>;
|
||||
};
|
||||
age?: Record<string, any>;
|
||||
version?: number;
|
||||
version?: number | string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user