From 997042789d5c13578df96f9639dad90d7cc8d166 Mon Sep 17 00:00:00 2001 From: R524809 Date: Mon, 19 Oct 2026 13:19:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E6=9B=BF=E6=8D=A2=E4=B8=BA=E6=96=B0=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 49 ++ cache/page-config.json | 68 +-- miniprogram/app.ts | 76 ++- miniprogram/base/callCloud.ts | 6 - miniprogram/base/pageMixin.ts | 59 +- miniprogram/config/assets.ts | 65 +++ miniprogram/core/font/fontLoader.ts | 20 +- miniprogram/core/font/fontProfiles.ts | 12 +- miniprogram/pages/age/age.ts | 24 +- miniprogram/pages/category/category.model.ts | 3 +- miniprogram/pages/favorites/favorites.ts | 6 + miniprogram/pages/home/home.data.ts | 14 +- miniprogram/pages/home/home.ts | 25 +- miniprogram/pages/profile/profile.less | 18 +- miniprogram/pages/profile/profile.ts | 82 ++- miniprogram/pages/profile/profile.wxml | 13 + miniprogram/platform/cloud-adapter.ts | 70 --- miniprogram/services/admin-api.ts | 170 ++++++ miniprogram/services/api-request.ts | 158 +++--- miniprogram/services/api-transport.ts | 100 ++++ miniprogram/services/auth-session.ts | 301 ++++++++++ miniprogram/services/content-api.ts | 6 +- miniprogram/services/member-api.ts | 53 ++ miniprogram/services/site-api.ts | 12 + .../categoryContentManage.ts | 41 +- .../categoryManage/categoryManage.ts | 29 +- .../homeContentManage/homeContentManage.ts | 81 ++- miniprogram/supportPages/settings/settings.ts | 25 +- .../worksheetSync/worksheetSync.ts | 24 +- miniprogram/utils/auth.ts | 187 ++----- miniprogram/utils/debugPublish.ts | 12 +- miniprogram/utils/downloadLogs.ts | 75 +-- miniprogram/utils/downloadPrint.ts | 12 +- miniprogram/utils/favorites.ts | 140 ++--- miniprogram/utils/worksheetStats.ts | 31 -- package.json | 4 +- tests/auth-api.test.mjs | 513 ++++++++++++++++++ tests/category-api.test.mjs | 15 +- tests/preview-assets.test.mjs | 130 +++++ typings/index.d.ts | 2 +- 40 files changed, 1946 insertions(+), 785 deletions(-) delete mode 100644 miniprogram/base/callCloud.ts create mode 100644 miniprogram/config/assets.ts delete mode 100644 miniprogram/platform/cloud-adapter.ts create mode 100644 miniprogram/services/admin-api.ts create mode 100644 miniprogram/services/api-transport.ts create mode 100644 miniprogram/services/auth-session.ts create mode 100644 miniprogram/services/member-api.ts create mode 100644 miniprogram/services/site-api.ts delete mode 100644 miniprogram/utils/worksheetStats.ts create mode 100644 tests/auth-api.test.mjs create mode 100644 tests/preview-assets.test.mjs diff --git a/README.md b/README.md index 0932420..ec8a510 100644 --- a/README.md +++ b/README.md @@ -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`。旧云端用户、收藏与下载历史本次未自动导入,正式切换前需要另行核对迁移。 diff --git a/cache/page-config.json b/cache/page-config.json index 5b5fc4b..d125276 100644 --- a/cache/page-config.json +++ b/cache/page-config.json @@ -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 diff --git a/miniprogram/app.ts b/miniprogram/app.ts index da0a635..70156b0 100644 --- a/miniprogram/app.ts +++ b/miniprogram/app.ts @@ -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({ 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({ 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({ } } - // 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({ 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 { diff --git a/miniprogram/base/callCloud.ts b/miniprogram/base/callCloud.ts deleted file mode 100644 index d22cb9d..0000000 --- a/miniprogram/base/callCloud.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * 桥接文件 - 保持向后兼容 - * 实际实现已迁移到 platform/cloud-adapter.ts - */ -import { callCloud } from '../platform/cloud-adapter'; -export default callCloud; diff --git a/miniprogram/base/pageMixin.ts b/miniprogram/base/pageMixin.ts index 25434ef..a14cc79 100644 --- a/miniprogram/base/pageMixin.ts +++ b/miniprogram/base/pageMixin.ts @@ -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, { diff --git a/miniprogram/config/assets.ts b/miniprogram/config/assets.ts new file mode 100644 index 0000000..4f68b7d --- /dev/null +++ b/miniprogram/config/assets.ts @@ -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(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; + for (const key of Object.keys(result)) { + result[key] = + key === 'previewImg' + ? resolvePreviewUrl(result[key]) + : normalizePreviewImages(result[key]); + } + return result as T; +} diff --git a/miniprogram/core/font/fontLoader.ts b/miniprogram/core/font/fontLoader.ts index a2024bc..bd02ebb 100644 --- a/miniprogram/core/font/fontLoader.ts +++ b/miniprogram/core/font/fontLoader.ts @@ -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(); -/** - * 将 cloud:// 文件 ID 解析为临时 HTTPS URL。 - * 非 cloud:// 的 URL 原样返回。 - */ -async function resolveUrl(raw: string): Promise { - 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 { 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({ diff --git a/miniprogram/core/font/fontProfiles.ts b/miniprogram/core/font/fontProfiles.ts index b9c521c..33b6e9b 100644 --- a/miniprogram/core/font/fontProfiles.ts +++ b/miniprogram/core/font/fontProfiles.ts @@ -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 }, diff --git a/miniprogram/pages/age/age.ts b/miniprogram/pages/age/age.ts index df4b10b..d8cfc59 100644 --- a/miniprogram/pages/age/age.ts +++ b/miniprogram/pages/age/age.ts @@ -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 { 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); diff --git a/miniprogram/pages/category/category.model.ts b/miniprogram/pages/category/category.model.ts index dda497d..5bb06dd 100644 --- a/miniprogram/pages/category/category.model.ts +++ b/miniprogram/pages/category/category.model.ts @@ -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, diff --git a/miniprogram/pages/favorites/favorites.ts b/miniprogram/pages/favorites/favorites.ts index c9c5d8b..8d5dce4 100644 --- a/miniprogram/pages/favorites/favorites.ts +++ b/miniprogram/pages/favorites/favorites.ts @@ -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 }); } diff --git a/miniprogram/pages/home/home.data.ts b/miniprogram/pages/home/home.data.ts index cc8f931..9af85ed 100644 --- a/miniprogram/pages/home/home.data.ts +++ b/miniprogram/pages/home/home.data.ts @@ -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, }, diff --git a/miniprogram/pages/home/home.ts b/miniprogram/pages/home/home.ts index eda4f59..12e05ba 100644 --- a/miniprogram/pages/home/home.ts +++ b/miniprogram/pages/home/home.ts @@ -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(); - 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 ── diff --git a/miniprogram/pages/profile/profile.less b/miniprogram/pages/profile/profile.less index 30b6ee1..12ed229 100644 --- a/miniprogram/pages/profile/profile.less +++ b/miniprogram/pages/profile/profile.less @@ -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; } @@ -461,4 +461,16 @@ page { .profile-editor__action--disabled { opacity: 0.6; -} \ No newline at end of file +} +/* 仅在账户恢复需要用户主动确认时展示,正常静默登录不出现。 */ +.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; +} diff --git a/miniprogram/pages/profile/profile.ts b/miniprogram/pages/profile/profile.ts index 8ced53e..0fb2716 100644 --- a/miniprogram/pages/profile/profile.ts +++ b/miniprogram/pages/profile/profile.ts @@ -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(); 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 { - if (!avatarUrl) return avatarUrl; - if ( - avatarUrl.startsWith('cloud://') || - avatarUrl.startsWith('http://') || - avatarUrl.startsWith('https://') - ) { - return avatarUrl; - } - + async persistAvatarIfNeeded(avatarUrl: string): Promise { 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() { diff --git a/miniprogram/pages/profile/profile.wxml b/miniprogram/pages/profile/profile.wxml index c95c5df..d1ebace 100644 --- a/miniprogram/pages/profile/profile.wxml +++ b/miniprogram/pages/profile/profile.wxml @@ -38,6 +38,19 @@ + + diff --git a/miniprogram/platform/cloud-adapter.ts b/miniprogram/platform/cloud-adapter.ts deleted file mode 100644 index 3596f82..0000000 --- a/miniprogram/platform/cloud-adapter.ts +++ /dev/null @@ -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, -): Promise { - 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, - limit: number = 20, - orderBy?: { field: string; order: 'asc' | 'desc' }, -): Promise { - 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 []; - } -} diff --git a/miniprogram/services/admin-api.ts b/miniprogram/services/admin-api.ts new file mode 100644 index 0000000..a188e97 --- /dev/null +++ b/miniprogram/services/admin-api.ts @@ -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( + action: AdminAction, + payload: Record = {}, +): 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>('/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( + '/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> + ).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(); + if (typeof app.refreshPageConfig === 'function') + await app.refreshPageConfig(); + } + return { success: true, data: data as T, ...(stats ? { stats } : {}) }; +} diff --git a/miniprogram/services/api-request.ts b/miniprogram/services/api-request.ts index 73c349c..65619fc 100644 --- a/miniprogram/services/api-request.ts +++ b/miniprogram/services/api-request.ts @@ -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 { - 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( path: string, - options: { - method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; - data?: Record; - header?: Record; - } = {}, + options: RequestOptions & { auth?: 'required' | 'none' } = {}, ): Promise { - const config = getApiConfig(); - if (!path.startsWith('/') || path.startsWith('//')) - throw new Error('接口路径不正确'); - return new Promise((resolve, reject) => { - wx.request>({ - 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( + 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; + } + } } diff --git a/miniprogram/services/api-transport.ts b/miniprogram/services/api-transport.ts new file mode 100644 index 0000000..05b5f7b --- /dev/null +++ b/miniprogram/services/api-transport.ts @@ -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; + header?: Record; + timeout?: number; + upload?: { filePath: string; uploadId: string }; +} + +/** 底层只发请求,不触发登录;登录接口与鉴权包装共用,避免循环依赖。 */ +export function requestApi( + path: string, + options: RequestOptions = {}, + config = getApiConfig(), +): Promise { + 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, + }); + }); +} diff --git a/miniprogram/services/auth-session.ts b/miniprogram/services/auth-session.ts new file mode 100644 index 0000000..21d5af8 --- /dev/null +++ b/miniprogram/services/auth-session.ts @@ -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; +type History = 'new' | 'used' | 'blocked'; +interface SessionState { + session: AuthSession | null; + history: History; + generation: number; + flight?: Promise; +} +const states = new Map(); +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().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 { + 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 { + 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); +} diff --git a/miniprogram/services/content-api.ts b/miniprogram/services/content-api.ts index 6995e4b..f3327c9 100644 --- a/miniprogram/services/content-api.ts +++ b/miniprogram/services/content-api.ts @@ -36,7 +36,9 @@ interface WorksheetPage { /** 分类直接由服务端发布状态决定,不用本地目录补齐或恢复下架分类。 */ export async function listCategories(): Promise { - const rows = await apiRequest('/categories'); + const rows = await apiRequest('/categories', { + auth: 'none', + }); if ( !Array.isArray(rows) || rows.some( @@ -87,6 +89,7 @@ export async function listAllWorksheets(): Promise { let expectedTotal: number | undefined; for (let page = 1; ; page++) { const result = await apiRequest('/worksheets', { + auth: 'none', data: { page, pageSize }, }); if ( @@ -121,6 +124,7 @@ export async function listAllWorksheets(): Promise { export async function getWorksheet(id: string): Promise { const row = await apiRequest( `/worksheets/${encodeURIComponent(id)}`, + { auth: 'none' }, ); validateWorksheet(row); if (row.id !== id) throw new Error('题型数据异常,请稍后重试'); diff --git a/miniprogram/services/member-api.ts b/miniprogram/services/member-api.ts new file mode 100644 index 0000000..f15dcac --- /dev/null +++ b/miniprogram/services/member-api.ts @@ -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('/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; +} diff --git a/miniprogram/services/site-api.ts b/miniprogram/services/site-api.ts new file mode 100644 index 0000000..1f12259 --- /dev/null +++ b/miniprogram/services/site-api.ts @@ -0,0 +1,12 @@ +import { apiRequest } from './api-request'; +import type { ApiWorksheet } from './content-api'; +/** 保留微信预拉取,动态兜底与后台刷新改由新后端提供。 */ +export function fetchPageConfig() { + return apiRequest('/page-config', { auth: 'none' }); +} +export function fetchAgeRecommendations(ageBand: string) { + return apiRequest<{ ageBand: string; items: ApiWorksheet[] }>( + '/recommendations/age', + { auth: 'none', data: { ageBand } }, + ); +} diff --git a/miniprogram/supportPages/categoryContentManage/categoryContentManage.ts b/miniprogram/supportPages/categoryContentManage/categoryContentManage.ts index c9f2158..cb879e8 100644 --- a/miniprogram/supportPages/categoryContentManage/categoryContentManage.ts +++ b/miniprogram/supportPages/categoryContentManage/categoryContentManage.ts @@ -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 = { - success?: boolean; - message?: string; - data?: T; - stats?: Stats; -}; - const DIFFICULTY_LABELS: Record = { 1: '入门', 2: '基础', @@ -82,7 +78,7 @@ function formatWorksheet(raw: Record): 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): 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): WorksheetItem { }; } -async function callCloudFunction( - name: string, - data?: Record, -): Promise> { - const response = (await wx.cloud.callFunction({ - name, - data: data || {}, - })) as { result?: CloudFunctionResult }; - return response.result || {}; -} - Page({ data: { categories: CATEGORY_LIST, @@ -139,8 +124,8 @@ Page({ this.setData({ loading: true }); try { - const result = await callCloudFunction( - 'worksheetsQuery', + const result = await managementRequest( + 'worksheets.list', { category: this.data.activeCategoryId, rawStats: true, @@ -155,7 +140,9 @@ Page({ .map((raw) => formatWorksheet(raw as unknown as Record), ) - .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, }); diff --git a/miniprogram/supportPages/categoryManage/categoryManage.ts b/miniprogram/supportPages/categoryManage/categoryManage.ts index b70181d..e6565c4 100644 --- a/miniprogram/supportPages/categoryManage/categoryManage.ts +++ b/miniprogram/supportPages/categoryManage/categoryManage.ts @@ -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 = { - success?: boolean; - message?: string; - data?: T; -}; - const DEFAULT_CATEGORY_COLORS = [ '#F7EE47', '#93D333', @@ -133,18 +128,6 @@ function buildCompareItems(cloudCategories: CloudCategory[]): { }; } -async function callCategoryFunction( - name: string, - data?: Record, -): Promise> { - const response = (await wx.cloud.callFunction({ - name, - data: data || {}, - })) as { result?: CloudFunctionResult }; - - return response.result || {}; -} - Page({ data: { loading: false, @@ -170,7 +153,7 @@ Page({ try { const result = - await callCategoryFunction('categoriesQuery'); + await managementRequest('categories.list'); if (!result.success) { throw new Error(result.message || '获取分类失败'); @@ -204,7 +187,7 @@ Page({ const confirmed = await new Promise((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( + ? 'categories.create' + : 'categories.update'; + const result = await managementRequest( functionName, payload, ); diff --git a/miniprogram/supportPages/homeContentManage/homeContentManage.ts b/miniprogram/supportPages/homeContentManage/homeContentManage.ts index f68bef8..93c2138 100644 --- a/miniprogram/supportPages/homeContentManage/homeContentManage.ts +++ b/miniprogram/supportPages/homeContentManage/homeContentManage.ts @@ -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 = { - success?: boolean; - message?: string; - data?: T; -}; - function formatSimple(raw: Record): SimpleWorksheet { const ageMin = Number(raw.ageMin) || 0; const ageMax = Number(raw.ageMax) || 0; @@ -57,7 +55,7 @@ function formatSimple(raw: Record): 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): 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( - name: string, - data?: Record, -): Promise> { - const response = (await wx.cloud.callFunction({ - name, - data: data || {}, - })) as { result?: CloudFunctionResult }; - 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('worksheetsQuery', { + managementRequest('worksheets.list', { status: 'active', }), - callCloudFunction('pageConfigFetch'), + managementRequest('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), @@ -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), diff --git a/miniprogram/supportPages/settings/settings.ts b/miniprogram/supportPages/settings/settings.ts index d10c14f..e514a4c 100644 --- a/miniprogram/supportPages/settings/settings.ts +++ b/miniprogram/supportPages/settings/settings.ts @@ -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' }); diff --git a/miniprogram/supportPages/worksheetSync/worksheetSync.ts b/miniprogram/supportPages/worksheetSync/worksheetSync.ts index be8e41b..f2f44ae 100644 --- a/miniprogram/supportPages/worksheetSync/worksheetSync.ts +++ b/miniprogram/supportPages/worksheetSync/worksheetSync.ts @@ -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 = { results?: Array<{ id: string | null; ok: boolean; error?: string }>; }; -async function callCloudFunction( - name: string, - data?: Record, -): Promise> { - const response = (await wx.cloud.callFunction({ - name, - data: data || {}, - })) as { result?: CloudFunctionResult }; - return response.result || {}; -} - function inferGradeFromAge(ageMin: number, ageMax: number): number { const centerAge = Math.round((ageMin + ageMax) / 2); const ageGradeMap: Record = { @@ -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>( - 'worksheetsQuery', + const queryRes = await managementRequest>( + 'worksheets.list', {}, ); @@ -438,7 +428,7 @@ Page({ return; } - const patchRes = await callCloudFunction('worksheetsBatchPatch', { + const patchRes = await managementRequest('worksheets.batch', { patches, dryRun: false, }); diff --git a/miniprogram/utils/auth.ts b/miniprogram/utils/auth.ts index 0bac1d7..cd71331 100644 --- a/miniprogram/utils/auth.ts +++ b/miniprogram/utils/auth.ts @@ -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 | null = null; - -function syncAppGlobalUser(user: UserInfo | null): void { - try { - const app = getApp(); - 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 { - // 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 { - 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 { - clearUser(); - return await silentLogin(); + const context = getAuthContext(); + const user = await apiRequest('/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 { + 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 { - await getUser(); - - const res = await wx.cloud.callFunction({ - name: 'userLogin', - data: { - action: 'updateProfile', - ...payload, - }, + const context = getAuthContext(); + const user = await apiRequest('/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 }; diff --git a/miniprogram/utils/debugPublish.ts b/miniprogram/utils/debugPublish.ts index 66503c7..27c019f 100644 --- a/miniprogram/utils/debugPublish.ts +++ b/miniprogram/utils/debugPublish.ts @@ -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`; diff --git a/miniprogram/utils/downloadLogs.ts b/miniprogram/utils/downloadLogs.ts index 3f0f823..6dcb5d3 100644 --- a/miniprogram/utils/downloadLogs.ts +++ b/miniprogram/utils/downloadLogs.ts @@ -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 { 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 { - 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('/me/downloads', { + data: { page, pageSize }, + }); + return result.items.map((row) => ({ + ...row, + _id: row.id, + worksheet: legacyWorksheet(row.worksheet), + })); } - -/** - * 获取当前用户下载(打印)总数 - */ export async function getDownloadLogCount(): Promise { - 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; } diff --git a/miniprogram/utils/downloadPrint.ts b/miniprogram/utils/downloadPrint.ts index 58f8fa6..3945ad4 100644 --- a/miniprogram/utils/downloadPrint.ts +++ b/miniprogram/utils/downloadPrint.ts @@ -1,3 +1,4 @@ +import { getCachedUser } from './auth'; /** * 下载打印通用工具 * 支持下载次数限制和激励广告 @@ -67,7 +68,9 @@ function showVideoAd(): Promise { 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 { 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); diff --git a/miniprogram/utils/favorites.ts b/miniprogram/utils/favorites.ts index afc81e8..831a7c8 100644 --- a/miniprogram/utils/favorites.ts +++ b/miniprogram/utils/favorites.ts @@ -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 { 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 { 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 { - 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('/me/favorites', { + data: { page, pageSize }, + }); + return result.items.map((row) => ({ + ...row, + _id: row.id, + worksheet: legacyWorksheet(row.worksheet), + })); } - -/** - * 获取当前用户收藏总数 - */ export async function getFavoriteCount(): Promise { - 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 { - 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> { 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 { + return (await batchCheckFavorited([worksheetId]))[worksheetId] === true; +} + +function currentRoute() { + const pages = getCurrentPages(); + return pages[pages.length - 1]?.route || ''; } diff --git a/miniprogram/utils/worksheetStats.ts b/miniprogram/utils/worksheetStats.ts deleted file mode 100644 index bbd67f8..0000000 --- a/miniprogram/utils/worksheetStats.ts +++ /dev/null @@ -1,31 +0,0 @@ -type WorksheetStatsField = 'likes' | 'downloads'; - -export async function updateWorksheetStats( - id: string, - field: WorksheetStatsField, -): Promise { - 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 { - return updateWorksheetStats(id, 'likes'); -} - -export function incrementWorksheetDownloads(id: string): Promise { - return updateWorksheetStats(id, 'downloads'); -} diff --git a/package.json b/package.json index c29e583..f40ac52 100644 --- a/package.json +++ b/package.json @@ -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": "", diff --git a/tests/auth-api.test.mjs b/tests/auth-api.test.mjs new file mode 100644 index 0000000..69e9310 --- /dev/null +++ b/tests/auth-api.test.mjs @@ -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, /账户状态已变化/); +}); diff --git a/tests/category-api.test.mjs b/tests/category-api.test.mjs index dab1ddf..d1072ef 100644 --- a/tests/category-api.test.mjs +++ b/tests/category-api.test.mjs @@ -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: 'error' }, ]) { 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 () => { diff --git a/tests/preview-assets.test.mjs b/tests/preview-assets.test.mjs new file mode 100644 index 0000000..e167e0a --- /dev/null +++ b/tests/preview-assets.test.mjs @@ -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); + } +}); diff --git a/typings/index.d.ts b/typings/index.d.ts index f15f8f5..615b16a 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -13,7 +13,7 @@ interface PageConfigData { }>; }; age?: Record; - version?: number; + version?: number | string; updatedAt?: string; }