# Taro 前端对接指南 更新时间:2026-06-29 目标:用一套 Taro 工程同时服务微信小程序和 H5 Web 题库,并采用“Supabase Auth/JWT + `apps/api` 业务 API 优先”的混合架构,支持多租户、品牌主题、地区题库、会员权益、销售追踪和对象存储资源。 建议新建: ```text F:\project\apps\taro ``` 旧前端参考: ```text F:\project\参考\旧题库项目\src ``` ## 启动流程 ### H5 1. 从 `window.location.host` 获取当前域名。 2. 调用 `GET /api/tenant/resolve?host=`。 3. 保存 `tenant.id`、`tenant.slug`、`branding`、`features`、`publicConfig`。 4. 初始化主题、Logo、页面标题、功能开关。 5. 检查本地 session token,调用 `GET /api/auth/me`。 6. 如果未登录,进入登录页;如果已登录,加载个人中心和首页数据。 ### 微信小程序 1. 从编译环境或小程序启动参数读取 `tenantCode`。 2. 推广码、销售码、分享码从 `options` 或 `scene` 中解析。 3. 调用 `GET /api/tenant/resolve?tenantCode=`。 4. 如存在 referral 参数,先调用 `/api/referral/resolve` 和 `/api/referral/track-event`。 5. 登录后再调用 `/api/referral/bind` 完成首绑保护。 ## 请求封装 本项目不采用“前端直接写 Supabase 表替代业务命令层”的模式。Supabase 官方允许前端在 RLS 和最小权限下使用 Data API,但本系统的订单、支付、权益、租户后台、内容导入、CRM、对象存储签名等都需要服务端事务、密钥、审计和幂等,所以复杂业务命令默认调用 RPC、`apps/api`、Edge Function 或 worker。 前端可以使用 Supabase client 的范围: - H5 Auth session/JWT。 - 小程序端在兼容性验证通过后的 Auth session/JWT。 - 低风险公开只读数据,且必须已经有 RLS、grant、跨租户测试。 - Realtime 非敏感通知。 前端必须调用 `apps/api` 的范围: - 题库练习、答题、错题、收藏。 - 订单、支付、激活码、优惠券、权益。 - 私有 PDF、资料、视频、对象存储签名。 - 租户后台、平台后台、内容导入、CRM、销售/代理、数据看板。 前端应封装一个统一 API client,所有页面禁止直接散写 `Taro.request`。 本地迁移期仍可兼容旧请求头,但新的 Taro 请求封装必须按下面目标实现: ```text Authorization: Bearer x-tenant-id: # 仅作为登录前/公开目录租户上下文;登录后必须与 session 租户一致 ``` 生产目标: ```text Authorization: Bearer x-tenant-id: # 作为租户上下文,不能作为身份依据 ``` 前端不应再传 `x-user-id`、query/body `userId` 来表示当前用户。后端已经实现 Supabase JWT 和迁移 session 优先解析:如果 Authorization 存在,用户态接口以 token 映射出的业务用户为准;如果请求里伪造了不同的 `userId` 会返回 `AUTH_USER_MISMATCH`,伪造不同租户会返回 `AUTH_TENANT_MISMATCH` 或 `AUTH_SESSION_INVALID`。 H5 使用 Supabase Auth 时,推荐请求流程: ```ts const { data } = await supabase.auth.getSession(); const accessToken = data.session?.access_token; await api.request('/api/profile/me', { headers: { Authorization: `Bearer ${accessToken}`, 'x-tenant-id': tenantStore.tenantId, }, }); ``` 后端会通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射用户身份。`x-tenant-id` 只能帮助确定当前租户上下文,不能让用户访问自己没有 membership 的租户。 生产或云端测试建议设置: ```text ALLOW_LEGACY_AUTH_HEADERS=false ALLOW_PLATFORM_ADMIN_KEY=false ``` 这样旧式 `x-user-id` 和平台管理 key 会被拒绝,前端可以提前发现未按 session 接入的页面。 前端环境变量只允许包含: ```text TARO_APP_API_BASE_URL TARO_APP_SUPABASE_URL TARO_APP_SUPABASE_PUBLISHABLE_KEY ``` 禁止把 Supabase secret key、service role key、数据库连接串、对象存储密钥、支付私钥放进 Taro。 统一错误处理: | HTTP | 前端动作 | | --- | --- | | 400 | 展示表单错误或参数错误 | | 401 | 清 session,跳登录 | | 403 | 展示无权限或会员升级 | | 404 | 展示空状态 | | 409 | 展示业务冲突,例如激活码已用 | | 413 | 提示上传/导入文件过大 | | 429 | 倒计时重试,例如短信冷却 | | 500 | 展示系统异常并上报日志 | ## 全局状态建议 | Store | 内容 | | --- | --- | | tenantStore | tenant、branding、theme、features、publicConfig | | authStore | session、user、roles、permissions、loginState | | regionStore | 当前地区、可选地区、地区权益 | | catalogStore | content entries、nodes、collections、blueprints | | entitlementStore | SVIP 权益、视频权益、资料下载权益 | | referralStore | inviteCode、referrer、scene、bindState | | uiStore | 当前主题、tab、loading、toast、modal | 注意缓存必须带租户维度,例如: ```text tenant::catalog:entries tenant::profile tenant::theme ``` 切换租户或切换小程序环境时必须清理旧租户缓存。 ## 页面/API 映射 | 页面 | 主要接口 | | --- | --- | | 启动页 | `GET /api/tenant/resolve` | | 登录页 | `POST /api/auth/sms/send`、`POST /api/auth/sms/verify`、`POST /api/auth/oauth/wechat-miniapp`、`POST /api/auth/oauth/wechat`、`POST /api/auth/oauth/qq` | | 首页 | `/api/catalog/content-entries`、`/api/catalog/banners`、`/api/catalog/announcements`、`/api/catalog/exam-dates`、`/api/profile/me` | | 选地区 | `/api/catalog/regions`、`/api/commerce/entitlements/check` | | 题库入口 | `/api/catalog/content-entries` | | 分类树 | `/api/catalog/content-nodes?entryId=...&parentId=root` | | 题目列表 | `/api/catalog/question-collections`、`/api/catalog/question-collections/questions` | | 开始练习 | `POST /api/learning/practice-sessions` | | 提交答案 | `POST /api/learning/answers` | | 交卷/报告 | `POST /api/learning/practice-sessions/submit`、`GET /api/learning/practice-sessions/report`、`GET /api/learning/practice-reports` | | 错题本 | `GET /api/learning/wrong-questions`、`POST /api/learning/wrong-questions/resolve` | | 错题复习 | `GET /api/learning/wrong-questions/review-plan`、`POST /api/learning/practice-sessions` with `mode=wrong_review` | | 收藏夹 | `GET/POST /api/learning/favorites/questions` | | 练习历史/统计 | `GET /api/learning/practice-sessions/history`、`GET /api/learning/stats`、`GET /api/learning/trend` | | 学习排行榜 | `GET /api/learning/leaderboard?metric=questions&period=all` | | 题目视频 | `GET /api/questions/{questionId}/videos`、`POST /api/questions/videos/batch`、`POST /api/videos/play` | | 题目反馈 | `POST /api/profile/feedbacks`、`GET /api/profile/feedbacks` | | 分佣结算 | `GET /api/commission/settings`、`PUT /api/commission/settings`、`PUT /api/commission/member-rate`、`GET /api/commission/summary`、`GET /api/commission/orders`、`GET /api/commission/settlements`、`POST /api/commission/settlements/generate`、`POST /api/commission/settlements/status` | | 背单词 | `/api/catalog/vocabulary-units`、`/api/catalog/vocabulary-words` | | 单词进度/计划 | `/api/learning/vocabulary/progress`、`/api/learning/vocabulary/stats`、`/api/learning/vocabulary/review-plan`、`POST /api/learning/vocabulary/review` | | 单词收藏 | `/api/learning/vocabulary/favorites` | | 知识手册 | `/api/catalog/handbook-subjects`、`handbook-chapters`、`handbook-entries` | | 分数线 | `/api/scoreline/fields`、`schools`、`majors`、`records`、`trend`、`years` | | 资料下载/预览 | `/api/catalog/assets`、`/api/catalog/assets/preview`、`/api/catalog/assets/download` | | 商城 | `/api/catalog/svip-plans`、`POST /api/commerce/coupons/claim`、`POST /api/commerce/orders`、`POST /api/commerce/payments/create` | | 订单/权益 | `/api/commerce/orders`、`/api/commerce/orders/detail`、`/api/commerce/orders/status`、`/api/commerce/entitlements` | | 激活码 | `POST /api/commerce/activation-codes/check`、`POST /api/commerce/activation-codes/redeem` | | 个人中心 | `GET/PATCH /api/profile/me`、`POST /api/profile/check-in`、`GET /api/profile/score-events`、`GET /api/profile/exam-countdowns`、`GET /api/profile/badges` | | 销售分享 | `/api/referral/resolve`、`track-event`、`bind` | | 租户数据看板 | `GET /api/tenant-admin/dashboard?timeRange=30d®ionId=...` | | 租户班级 | `GET/PUT /api/tenant-admin/classes`、`POST /api/tenant-admin/classes/disable` | | 班级成员 | `GET/PUT /api/tenant-admin/classes/members`、`POST /api/tenant-admin/classes/members/remove`、`POST /api/tenant-admin/classes/members/bulk-assign` | | 租户学生 | `GET/PUT /api/tenant-admin/students`、`POST /api/tenant-admin/students/bulk-upsert`、`POST /api/tenant-admin/students/status` | | 学生备注 | `GET/PUT /api/tenant-admin/students/notes` | | 学生跟进任务 | `GET/PUT /api/tenant-admin/students/followups` | | 租户教师 | `GET /api/tenant-admin/teachers` | | 租户考试日期 | `GET/PUT /api/tenant-admin/exam-dates` | | 租户反馈处理 | `GET /api/tenant-admin/feedbacks`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events` | | 租户勋章 | `GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants` | | 公共题库采纳/同步 | `GET /api/tenant-content/public-question-banks`、`POST /api/tenant-content/public-question-banks/adopt`、`POST /api/tenant-content/public-question-banks/sync`、`GET /api/tenant-content/public-question-banks/conflicts?adoptionId=...` | | 题库导出 | `POST /api/tenant-content/exports/questions`、`GET /api/tenant-content/exports/jobs` | ## 练习访问控制契约 前端不要先拉完整题目列表再自行判断免费额度。用户点击顺序刷题、随机刷题、全真模拟时,统一调用 `POST /api/learning/practice-sessions`,后端会根据 `content_entries.accessRules`、`content_nodes.accessRules`、`question_collections.accessRules`、`practice_blueprints.accessRules` 和当前用户权益决定最终题目快照。 请求示例: ```json { "mode": "sequential", "collectionId": "00000000-0000-0000-0000-000000000615", "questionLimit": 50 } ``` 响应关键字段: ```json { "item": { "id": "...", "mode": "sequential", "questionIds": ["..."], "questionCount": 25, "accessMode": "free", "consumedFreeQuota": 25, "accessSnapshot": { "grantedBy": "free_quota", "requestedCount": 50, "grantedCount": 25, "truncated": true, "dailyLimit": 25 } } } ``` 前端处理规则: - 以返回的 `questionIds` 为准渲染本次练习,不要自行追加题目。 - `accessSnapshot.truncated=true` 时,可提示“今日免费额度有限,已为你开放 N 题”并引导开通 SVIP。 - `PRACTICE_FREE_LIMIT_REACHED`:弹出会员购买/激活码兑换入口。 - `PRACTICE_SVIP_REQUIRED`:提示该内容需要对应地区/科目/题库 SVIP。 - `PRACTICE_SESSION_QUESTION_FORBIDDEN`:说明提交答案的题目不在本次 session 快照内,应清理本地异常进度并重新开始。 - 提交答案必须传 `practiceSessionId`;后端会拒绝不属于本人有效 session 的题目。 ## 勋章 学生个人中心或学习成就页调用: ```text GET /api/profile/badges?includeLocked=true&category=practice ``` 说明: - `includeLocked=true` 时返回已解锁和未解锁勋章;不传时只返回已解锁。 - `category` 可选:`learning`、`practice`、`vocabulary`、`mock_exam`、`activity`、`feedback`、`sales`、`system`、`custom`。 - 前端只展示后端返回的 `unlocked/grantId/grantedAt`,不要在本地自行认定用户已经获得勋章。 租户后台勋章管理: ```text GET /api/tenant-admin/badges?category=practice&includeInactive=true PUT /api/tenant-admin/badges GET /api/tenant-admin/badge-grants?userId=...&badgeId=... POST /api/tenant-admin/badge-grants ``` `PUT /api/tenant-admin/badges` 支持同租户内 `legacyId` 幂等更新;如果 `id` 与 `legacyId` 指向不同记录会返回 `BADGE_ID_CONFLICT`。`POST /api/tenant-admin/badge-grants` 对同一用户同一勋章幂等,不会重复生成多条发放记录。当前后端支持手动发放,自动发放规则后续由 worker/事件流补齐。 ### 模考交卷与报告 全真模拟、试卷模式、顺序练习的最终报告都走后端交卷接口。前端不得传分数、正确数或题目范围;后端只信任 `practice_sessions.question_ids` 快照和 `answer_records` 最新答题记录。 交卷请求: ```json { "practiceSessionId": "00000000-0000-0000-0000-000000000000" } ``` 响应关键字段: ```json { "item": { "id": "...", "practiceSessionId": "...", "mode": "mock_exam", "totalQuestions": 3, "answeredCount": 2, "correctCount": 1, "wrongCount": 1, "unansweredCount": 1, "score": 2, "totalScore": 100, "accuracy": 0.3333, "sectionStats": [ { "key": "choice", "title": "单选题", "questionCount": 3, "correctCount": 1, "score": 2, "totalScore": 6 } ], "wrongQuestionIds": ["..."], "questionResults": [ { "questionId": "...", "sectionKey": "choice", "answered": true, "isCorrect": false, "score": 0, "totalScore": 2, "selectedOptions": ["0"], "correctOptionIndices": [1], "explanation": "..." } ] } } ``` 前端处理规则: - 重复交卷是幂等的,后端会返回同一份报告。 - 报告页刷新时调用 `GET /api/learning/practice-sessions/report?practiceSessionId=...`。 - 个人中心/模考历史调用 `GET /api/learning/practice-reports?mode=mock_exam&limit=20`,也可以传 `blueprintId` 筛选某套模拟卷。 - `score` 是逐题得分合计;`totalScore` 保留后台配置的卷面总分。测试或预发数据题量不足时,两者不一定按百分制等比换算,前端展示时不要自行重算。 - 错题复盘优先使用 `wrongQuestionIds` 和 `questionResults`,题目详情仍可按现有题目接口或 session 快照加载。 ### 练习历史、统计和错题复习 个人中心和学习报告页优先使用后端聚合接口,不要让前端遍历全部答题记录自行统计。 接口用途: | 页面/组件 | 接口 | 说明 | | --- | --- | --- | | 练习历史列表 | `GET /api/learning/practice-sessions/history?limit=20` | 返回 session、报告、已答数量、正确数、状态 | | 学习概览卡片 | `GET /api/learning/stats?days=30` | 返回总答题、正确率、报告数、错题数、收藏数、题型分布 | | 正确率趋势图 | `GET /api/learning/trend?days=14` | 返回每日答题数、正确数、错题数、session 数、报告数 | | 错题复习入口 | `GET /api/learning/wrong-questions/review-plan?limit=20` | 返回建议复习题和后端组卷 nextAction | | 排行榜 | `GET /api/learning/leaderboard?metric=questions&period=7d®ionId=...&classId=...` | 返回排名、用户展示信息、当前用户排名和范围信息 | 错题复习创建 session: ```json { "mode": "wrong_review", "questionLimit": 20 } ``` 前端处理规则: - 不要把错题 ID 列表从前端传回后端组卷;`wrong_review` 会由后端按当前用户错题本安全组卷。 - `review-plan.nextAction` 可直接用于按钮配置,但仍需使用当前登录 session 调用。 - 收藏夹复习同理可调用 `POST /api/learning/practice-sessions`,body 为 `{ "mode": "favorite_review", "questionLimit": 20 }`。 - 趋势图以接口返回日期桶为准,缺失日期后端会补 0,不需要前端补点。 ### 排行榜 排行榜由后端统一聚合,前端不要读取答题记录、单词进度或模考报告后自行排名,避免越权、口径漂移和跨租户数据泄露。 可选参数: | 参数 | 可选值 | 说明 | | --- | --- | --- | | `metric` | `questions`、`score`、`vocabulary`、`mock_exam` | 分别表示累计答题、积分、掌握单词、模考最高分 | | `period` | `all`、`7d`、`30d` | 统计周期 | | `regionId` | UUID | 地区范围,可选 | | `classId` | UUID | 班级范围,可选,后端按当前租户校验 | | `limit` / `page` | 正整数 | 分页 | 响应会包含 `items` 和 `currentUser`。即使当前用户未进入前 N 名,也应优先展示 `currentUser` 作为“我的排名”。后台后续会补日/周榜预聚合和防刷策略,前端只消费接口返回口径。 ### 租户数据看板 租户后台数据看板由后端统一聚合,前端不要直接读取订单、答题记录、学生列表后自行统计,避免权限越权、敏感信息外泄和各页面口径不一致。 请求: ```http GET /api/tenant-admin/dashboard?timeRange=30d®ionId=<可选地区ID>&limit=10 ``` 可选参数: | 参数 | 可选值 | 说明 | | --- | --- | --- | | `timeRange` | `7d`、`30d`、`90d` | 统计区间,默认 `30d` | | `regionId` | UUID | 可选地区筛选,后端会校验地区属于当前租户 | | `limit` | 1-50 | 题型、科目、地区、套餐和运营动态的返回条数 | 响应主要结构: ```json { "item": { "scope": { "tenantId": "...", "regionId": "...", "timeRange": "30d", "timezone": "Asia/Shanghai" }, "cards": { "students": {}, "learning": {}, "content": {}, "activationCodes": {}, "feedback": {} }, "paymentStats": {}, "trends": [], "activeHours": [], "questionDistribution": [], "subjectTop": [], "regionStats": [], "planSales": [], "recentActivities": [] } } ``` 前端处理规则: - 管理台菜单显示可按 `/api/tenant-admin/permissions` 的 `dashboard:read` 判断,但真正权限以后端返回为准。 - `trends` 已补齐自然日桶,`activeHours` 固定 24 项,前端不需要补点。 - `revenueCents`、`amountCents` 都是分,前端统一格式化成人民币展示,不要自行重算订单金额。 - `recentActivities.details` 只包含可展示的低敏汇总信息,不包含手机号、支付密钥、对象存储 key 等敏感字段。 - 大租户正式上线后会补预聚合 worker,前端不应依赖任何临时 SQL 口径或自己维护缓存口径。 ### 销售/代理分佣结算 分佣结算由后端统一计算,前端不要读取订单、激活码或客资后自行算佣金。当前后端已支持订单和激活码两类来源,并且只统计客资首绑保护后的成交,避免后绑抢单。 常用接口: | 页面/动作 | 接口 | 权限 | | --- | --- | --- | | 查看租户分佣设置 | `GET /api/commission/settings` | `commission:read` | | 修改默认分佣设置 | `PUT /api/commission/settings` | `commission:write` | | 设置销售/代理个人比例 | `PUT /api/commission/member-rate` | `commission:write` | | 分佣汇总 | `GET /api/commission/summary?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD&referrerUserId=...` | `commission:read` 或 `commission:self` | | 分佣来源明细 | `GET /api/commission/orders?...` | `commission:read` 或 `commission:self` | | 结算单列表 | `GET /api/commission/settlements?...` | `commission:read` 或 `commission:self` | | 生成结算单 | `POST /api/commission/settlements/generate` | `commission:write` | | 审核/打款状态 | `POST /api/commission/settlements/status` | `commission:review` | 金额字段统一为分: ```text grossAmountCents commissionAmountCents minSettlementCents ``` 比例字段统一为 0 到 1 的数字: ```text defaultRate = 0.2 commissionRate = 0.35 ``` 结算状态: ```text draft -> pending_review -> approved -> paid pending_review -> rejected/cancelled approved -> cancelled ``` 前端处理规则: - 销售/代理默认只有 `commission:self`,只能查看自己的分佣;租户运营/管理员拥有 `commission:read` 才能查看全局。 - `startDate/endDate` 使用 `YYYY-MM-DD`,后端按 `Asia/Shanghai` 业务日计算账期。 - 分佣比例优先级由后端处理:激活码批次比例 > 成员个人比例 > 租户默认比例。 - `sourceType=order` 表示学生订单;`sourceType=activation_code` 表示激活码兑换。 - 已进入结算单的来源会返回 `settlementId/settlementStatus`,前端不要重复发起生成。 - 已打款结算单不可再修改状态;遇到 `COMMISSION_SETTLEMENT_LOCKED` 展示“已打款,不可变更”。 - `COMMISSION_NO_UNSETTLED_SOURCES` 表示当前账期无未结算来源,不是系统异常。 - 当前版本仅支持线下打款状态登记;真实银行/微信/支付宝打款、导出、发票/凭证和财务复核后续由 worker/provider 增强。 ### 背单词计划与复习上报 背单词页面分三类数据:单元列表、每日计划、单词进度。前端不需要计算下次复习日期,只提交“认识/不认识”,由后端统一更新 `nextReviewDate`、连续正确、掌握状态和每日复习计划。 取今日计划: ```http GET /api/learning/vocabulary/review-plan?unitId=&reviewLimit=30&newLimit=20 ``` 响应关键字段: ```json { "item": { "dueCount": 3, "newCount": 20, "totalPlanned": 23, "dueWords": [{ "wordId": "...", "status": "reviewing", "dueLevel": "soon" }], "newWords": [{ "wordId": "...", "status": "new", "dueLevel": "new" }], "words": [] } } ``` 上报单词复习结果: ```json { "wordId": "00000000-0000-0000-0000-000000000812", "result": "known" } ``` `result` 可传: - `known`:认识/答对。 - `unknown`:不认识/答错。 前端处理规则: - `review-plan.words` 是本轮学习队列;卡片翻转、上一个、跳转、收藏状态属于前端交互。 - 每个单词点击“认识/不认识”后调用 `POST /api/learning/vocabulary/review`。 - 返回的 `status`、`dueLevel`、`nextReviewDate` 作为后续展示依据,不在前端重算间隔。 - 旧的 `POST /api/learning/vocabulary/progress` 保留给兼容和后台手工修正;普通学习流优先用 `vocabulary/review`。 ## 视频播放契约 题目视频分为 `free`、`svip`、`video_quota` 三种访问模式。列表接口只用于展示标题、封面、时长、访问模式和试看秒数;除免费公开视频外,列表和搜索接口不会返回可播放 URL。 播放步骤: 1. 进入题目页后调用 `GET /api/questions/{questionId}/videos` 或批量预加载 `POST /api/questions/videos/batch`。 2. 用户点击播放时调用 `POST /api/videos/play`。 3. 后端校验当前 session 用户、租户、题目绑定关系、SVIP 权益或视频次数权益。 4. 后端返回短期签名 URL、播放 token、权益来源和过期时间。 5. 前端播放器只使用本次返回的 `playback.url`,不要缓存为长期资源地址。 请求示例: ```json { "videoId": "00000000-0000-0000-0000-000000000821", "questionId": "00000000-0000-0000-0000-000000000401" } ``` 响应关键字段: ```json { "item": { "id": "...", "title": "...", "accessMode": "svip", "freePreviewSeconds": 15 }, "playToken": "vp_...", "playback": { "url": "https://...", "expiresAt": "2026-06-28T12:00:00.000Z", "signatureMode": "signed" }, "access": { "mode": "svip", "entitlementId": "...", "quotaAccountId": null, "consumedQuota": 0 } } ``` 前端处理规则: - `VIDEO_SVIP_REQUIRED`:弹出开通或升级会员。 - `VIDEO_QUOTA_REQUIRED`:提示购买视频次数包或套餐。 - `VIDEO_ASSET_REQUIRED`:展示“视频暂不可播放”,同时上报前端日志。 - 签名 URL 过期后必须重新调用 `/api/videos/play`,不要重试旧 URL。 - 小程序/H5 不保存对象存储真实 key,不把播放 URL 写入本地持久缓存。 ## 资料上传、预览和下载契约 学生端资料只读取目录、预览和下载签名,不接触对象存储真实密钥,也不自行拼接私有 bucket 地址。 学生端展示资料列表: ```http GET /api/catalog/assets?assetType=pdf®ionId=&includeLocked=true ``` 学生端 PDF/图片预览: ```http GET /api/catalog/assets/preview?assetId= ``` 学生端下载: ```http GET /api/catalog/assets/download?assetId= ``` 前端处理规则: - `preview.url` 是短期 inline URL,只给预览组件使用,不持久化。 - `download.url` 是短期 attachment URL,只给下载动作使用。 - `ASSET_SVIP_REQUIRED`:提示开通对应地区/科目权益。 - `ASSET_UPLOAD_NOT_VERIFIED`:展示“资料正在处理中”,并上报前端日志。 - `ASSET_NOT_FOUND` 或列表中资源从 `active` 消失:展示“资源异常已下架”或刷新列表,不要继续使用旧签名 URL。 - `ASSET_PREVIEW_NOT_SUPPORTED`:隐藏预览按钮,仅保留下载或提示不支持预览。 - `previewUrl` 字段只作为公开/托管预览提示,不代表可以绕过接口直接访问。 租户后台上传资料必须走五步: ```text sign-upload -> 直传对象存储 -> PUT assets 登记草稿 -> confirm-upload -> sign-preview 验收 ``` 后台上传确认: ```json { "assetId": "", "fileSizeBytes": 4096, "mimeType": "application/pdf", "checksumSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "publish": true } ``` 托管对象在确认前会保持 `status=draft`、`uploadStatus=pending`,学生端不会看到。确认失败时后端返回 `UPLOAD_VERIFICATION_FAILED`,后台必须展示失败原因并允许重新上传,不能前端强行改为已发布。 生产环境会定时运行 assets worker 复检对象存储元数据。复检发现对象丢失、跨租户 objectKey、大小/MIME/checksum 不一致时,后端会把资源置为 `uploadStatus=failed` 并从 `active` 退回 `draft`,同时写入 `securityFlags.assetRecheckFailed=true`。租户后台资源列表应对 failed 资源展示异常原因和重新上传入口;学生端不要缓存资料列表和签名 URL 作为长期状态。 ## 考试倒计时、签到积分和反馈 首页可用 `GET /api/catalog/exam-dates?regionId=` 展示地区公开考试日期;个人中心优先用 `GET /api/profile/exam-countdowns`,后端会按学生当前 `regionId/selectedSchoolId` 返回匹配倒计时。 签到入口调用: ```http POST /api/profile/check-in ``` 返回关键字段: ```json { "item": { "checkedIn": true, "alreadyCheckedIn": false, "pointsAdded": 10, "streak": 1, "score": 10, "lastCheckInDate": "2026-06-29" } } ``` 前端处理规则: - `alreadyCheckedIn=true` 时展示今日已签到,不要本地再加分。 - 积分明细调用 `GET /api/profile/score-events`。 - 积分最终余额以后端 `score` 和流水为准,前端只做展示。 题目页、资料页或视频页可提交反馈: ```json { "questionId": "...", "type": "question_error", "category": "answer", "title": "题目解析有误", "description": "请填写具体问题", "attachments": [] } ``` 前端处理规则: - `questionId` 如存在,后端会校验题目必须属于当前租户。 - 反馈状态由租户后台处理,学生可用 `GET /api/profile/feedbacks` 查看自己的反馈历史。 - 租户后台处理反馈时,奖励积分由后端 `idempotency_key` 保证不会重复发放,前端不要重复叠加。 ## 题库新模型接入方式 旧项目常按“地区 -> 科目 -> 章节/试卷”固定层级处理。新项目不要写死层级,按下面模型渲染: ```text content_entries -> content_nodes 任意深度分类树 -> question_collections 题目列表/试卷/章节/题型集合 -> practice_blueprints 顺序/随机/全真模拟规则 ``` 前端建议: - `entryType=question_practice` 渲染为刷题入口。 - `entryType=vocabulary` 渲染为背单词入口。 - `entryType=handbook` 渲染为知识手册入口。 - `markerType=school` 或 `markerType=exam_track` 可作为学生目标院校/专业意向采集。 - 不同地区节点层级可以不同,页面组件必须支持递归树和面包屑。 ## 多租户前端优化 - Logo、标题、主题色、客服信息全部来自 `tenant/resolve`。 - 功能开关控制菜单显示,但接口权限仍以后端为准。 - 私有图片、PDF、视频不要直接拼 URL,一律通过后端签名。 - 支付渠道从后端返回或租户配置读取,不在页面硬编码。 - 小程序分享路径必须带 tenantCode 和 referral code。 - 用户首绑归属由后端保护,前端不要提供“换绑销售”入口。 - 管理后台菜单按 `GET /api/tenant-admin/permissions` 返回的 `current.permissions`、`current.templatePermissions`、`current.menuPermissions`、`current.modulePermissions` 渲染;接口权限仍以后端校验为准。 - 教师、班主任、助教类账号进入租户后台时,学生列表以 `GET /api/tenant-admin/students` 返回的 `scoped` 和 `items` 为准;前端不要自行用本地班级 ID 放大查询范围。 - 学生手机号、订单金额、客资归属等敏感字段按 `fieldPermissions` 控制显示;字段被后端返回为 `null` 时前端展示脱敏占位,不要从其它接口补取。 - 学生批量导入和批量分班接口会返回 `total/successCount/errorCount/results`,前端必须展示逐行错误,不要在浏览器端静默丢弃失败行。 - 教师可以为范围内学生创建备注和跟进任务,但是否能禁用学生、批量导入、查看手机号由后端权限和字段权限决定;前端只按返回值渲染。 - H5 自定义域名下要注意缓存隔离,不能把 A 租户主题缓存用到 B 租户。 ## 租户内容导入对接 租户后台导入统一使用 preview -> issues -> import 流程,前端不要直接写 Supabase 表或绕过 `apps/api`。当前 JSON、CSV 和 Excel 都进入同一套后端规范化、逐行 issue、幂等和审计管线。 当前可联调: ```text POST /api/tenant-content/imports/preview/questions POST /api/tenant-content/imports/questions POST /api/tenant-content/imports/preview/vocabulary POST /api/tenant-content/imports/vocabulary POST /api/tenant-content/imports/preview/handbook POST /api/tenant-content/imports/handbook POST /api/tenant-content/imports/preview/scoreline POST /api/tenant-content/imports/scoreline POST /api/tenant-content/imports/preview/videos POST /api/tenant-content/imports/videos GET /api/tenant-content/imports GET /api/tenant-content/imports/issues GET /api/tenant-content/imports/field-mapping GET /api/tenant-content/imports/templates POST /api/tenant-content/imports/post-check GET /api/tenant-content/imports/post-check ``` 前端流程: 1. 页面初始化调用 `field-mapping`,渲染字段说明、别名、必填项和示例。 2. 下载模板调用 `templates?importType=...&format=csv|json`,用 `contentBase64` 生成文件。 3. 上传或粘贴 JSON/CSV/Excel,先调用对应 preview。 4. 展示 `job.totalCount/validCount/errorCount/warningCount`。 5. 展示 `job.sourceFormat`、`job.parserMetadata`、逐行 `issues`,错误行必须让运营修正;如果后端允许 `allowPartial`,也要二次确认。 6. 小批量确认后直接调用 import;大批量确认时传 `executionMode=async` 排队,前端轮询 job 状态。 7. 导入进入 `completed/completed_with_errors` 后调用 `POST /api/tenant-content/imports/post-check`。 8. 展示 `summary.importPostCheck` 或 `GET /api/tenant-content/imports/post-check?jobId=...` 返回的复检结果,再刷新内容列表、分数线列表或题目视频列表。 CSV 请求示例: ```json { "sourceFormat": "csv", "sourceName": "questions.csv", "csvText": "legacyId,题型,题干,选项A,选项B,答案\nq1,choice,题干,A,B,B", "subjectId": "...", "categoryId": "...", "entryId": "...", "contentNodeId": "...", "collectionId": "..." } ``` Excel 请求示例: ```json { "sourceFormat": "excel", "sourceName": "scoreline.xlsx", "fileBase64": "", "sheetName": "records", "regionId": "..." } ``` 异步确认导入示例: ```json { "previewJobId": "uuid", "executionMode": "async", "allowPartial": false } ``` 异步导入状态: ```text pending/importing:展示处理中,不允许重复同步执行同一 job。 completed:刷新目标内容列表。 completed_with_errors:刷新成功内容,并提示查看 issues。 failed/rejected:展示 errorMessage 和 issues,允许运营修正后重新 preview。 ``` 导入复检状态: ```text passed:可以展示为导入验收通过。 warning:导入已落库,但存在可运营确认的风险,例如 allowPartial 导入。 failed:导入结果和目标表不一致,必须提示管理员排查,不要静默刷新页面。 ``` 前端不要自行判断导入成功率,也不要只看 `completed` 就认为可上线;以复检结果和目标内容刷新结果共同作为运营提示。 前端文件限制应与后端一致:单文件最大 8MB,最多 5000 行、160 列。后端不会保存原始 `fileBase64`,但前端仍不要把含隐私的导入文件写入长期缓存。 分数线导入前端注意: - 后端支持 `fields/schools/majors/records` 分桶,也支持 `items` 列表;Excel 可用 `fields`、`schools`、`majors`、`records` 多 Sheet。 - 页面筛选字段仍以 `/api/scoreline/fields` 为准,不要从导入 JSON 临时生成筛选 UI。 - `record` 至少需要 `schoolId`、`schoolLegacyId` 或 `schoolName`,否则 preview 会返回 issue。 视频导入前端注意: - 列表和搜索接口不会给付费视频可播放 URL;播放统一调 `POST /api/videos/play`。 - 绑定题目必须提供 `questionId` 或 `legacyQuestionId`。 - 生产建议把私有视频先入 `content_assets`,导入时传 `assetId`,避免长期暴露源站 URL。 ## 题库导出对接 租户后台题库导出统一走后端生成结构化 payload,前端不要直接查 Supabase 表拼导出文件。当前后端已支持 JSON、`paper_json` 和 `print_payload` 三类基础导出,适合先做后台“导出 JSON/试卷预览”功能;PDF/Word 二进制、水印和发布到资料下载后续由 worker 增强。 可用接口: ```text POST /api/tenant-content/exports/questions GET /api/tenant-content/exports/jobs ``` 导出范围: | scopeType | scopeId | 用途 | | --- | --- | --- | | `collection` | `question_collections.id` | 导出某个题目列表或试卷集合 | | `entry` | `content_entries.id` | 导出某个题库入口下全部已发布题目 | | `content_node` | `content_nodes.id` | 导出某个分类节点及其子节点下全部已发布题目 | 普通题库 JSON 导出: ```json { "scopeType": "collection", "scopeId": "", "format": "json", "exportType": "questions", "includeAnswers": false, "includeExplanations": false, "options": { "title": "天津专升本题库导出" } } ``` 试卷 payload 导出: ```json { "scopeType": "content_node", "scopeId": "", "format": "paper_json", "exportType": "paper", "includeAnswers": true, "includeExplanations": true, "options": { "title": "全真模拟试卷", "durationMinutes": 120, "watermarkText": "仅供内部使用" } } ``` 响应关键结构: ```json { "job": { "id": "...", "status": "completed", "questionCount": 1, "outputHash": "..." }, "export": { "_tikuExport": "3.0", "jobId": "...", "summary": { "questionCount": 1, "sectionCount": 1 }, "sections": [], "questions": [], "files": [ { "filename": "天津专升本题库导出.json", "mimeType": "application/json", "encoding": "base64", "contentBase64": "..." } ], "renderHints": { "pdfLayout": "paper", "pageSize": "A4", "answerPlacement": "inline_or_appendix" } } } ``` 前端处理规则: - 导出按钮只给具备租户内容编辑权限的后台成员展示;接口仍以后端 `TENANT_CONTENT_EDITOR_REQUIRED` 为准。 - 下载 JSON 时使用 `files[0].contentBase64` 生成 Blob,文件名使用后端返回的 `filename`。 - `includeAnswers=false` 时,顶层答案字段和阅读理解/案例分析的子题答案都会被后端脱敏;前端不要在本地重新合并答案。 - `includeExplanations=false` 时,不展示解析,也不要从题目详情接口额外补解析。 - `paper_json` 可先用于后台试卷预览和打印;正式 PDF/Word 导出等后端 worker 完成后再接二进制文件下载。 - `GET /api/tenant-content/exports/jobs?scopeType=collection&scopeId=...` 用于后台导出历史;当前记录 metadata 和输出 hash,不长期保存完整导出内容。 - 跨租户导出会返回 404 或 403,前端不要重试其它租户 ID。 ## 公共题库采纳对接 平台超级管理员后台使用: ```text GET /api/platform-admin/question-banks GET /api/platform-admin/question-bank-grants PUT /api/platform-admin/question-bank-grants ``` 授权参数建议: ```json { "sourceQuestionBankId": "<平台公共题库ID>", "grantScope": "plans", "allowedPlanCodes": ["starter_yearly", "pro_yearly"], "status": "active" } ``` `grantScope` 可选: - `plans`:按 SaaS 套餐授权。 - `tenants`:指定租户授权。 - `mixed`:套餐和指定租户同时生效。 - `all_active_tenants`:所有有效订阅租户可见。 租户内容后台使用: ```text GET /api/tenant-content/public-question-banks POST /api/tenant-content/public-question-banks/adopt POST /api/tenant-content/public-question-banks/sync GET /api/tenant-content/public-question-banks/conflicts?adoptionId=... ``` 采纳请求: ```json { "grantId": "<授权ID>", "entryName": "天津专升本公共题库", "collectionName": "天津专升本公共题目", "copyLimit": 500 } ``` 前端处理规则: - 租户只能看到后端判定为已授权的公共题库,不要在前端用套餐码自行过滤。 - 采纳成功后后端会生成本租户自己的 `questionBankId`、`entryId`、`collectionId` 和题目快照,学生端直接按普通 `/api/catalog/content-entries`、`question-collections`、`practice-sessions` 接入。 - 重复采纳返回 `QUESTION_BANK_ALREADY_ADOPTED`,前端展示“已采纳”即可。 - 已采纳公共题库可以手动同步平台后续新增/更新题目;同步会重新校验当前租户仍有授权,且只写入租户自己的题目副本。 - 后端也可以由 `apps/worker --job public-banks` 自动同步待更新的采纳题库;前端不需要轮询平台源库,只需要在租户后台展示同步状态、最近同步时间和冲突数量。 同步请求: ```json { "adoptionId": "", "copyLimit": 1000 } ``` 同步响应关键字段: ```json { "item": { "id": "...", "syncStatus": "synced | failed", "copiedQuestionCount": 120, "targetQuestionBankId": "...", "targetEntryId": "...", "targetCollectionId": "..." }, "sync": { "status": "synced | conflict", "counts": { "inserted": 1, "updated": 3, "skipped": 116, "conflicts": 0 }, "results": [ { "sourceQuestionId": "...", "targetQuestionId": "...", "action": "inserted | updated | skipped | conflict", "sourceHash": "...", "previousSourceHash": "...", "targetHash": "..." } ] } } ``` 前端处理规则: - `sync.status=synced`:刷新公共题库列表、题目集合和题目列表。 - `sync.status=conflict` 或 `item.syncStatus=failed`:展示冲突数量和冲突题目,不要把它当系统异常。冲突表示租户已经改过这道采纳题,后端已跳过并保留租户内容。 - `action=conflict` 的记录可以进入后续“冲突处理”页面:展示平台源题 ID、租户目标题 ID、上次平台 hash、当前平台 hash、租户当前 hash。当前后端只负责保护不覆盖,批量接受平台版本/保留租户版本的操作台后续补。 - 页面初始化或 worker 后台同步完成后,可以调用 `GET /api/tenant-content/public-question-banks/conflicts?adoptionId=...` 查询最近一次同步状态、`counts` 和 `conflicts`。这个接口只返回当前租户自己的采纳记录,跨租户会返回 `QUESTION_BANK_ADOPTION_NOT_FOUND`。 - `QUESTION_BANK_GRANT_NOT_AVAILABLE`:说明 SaaS 套餐/授权已失效,提示联系平台或升级套餐。 - `QUESTION_BANK_ADOPTION_NOT_FOUND`:说明不是当前租户的采纳记录或记录已归档,前端不要跨租户重试。 - 后续会补版本通知、冲突操作台和批量确认策略;当前租户后台可以先提供手动“同步平台更新”按钮,并展示 worker 自动同步后的冲突查询结果。 ## 登录对接 ### 短信登录 开发环境可以先使用 mock 短信,接口会返回 `debugCode`。生产环境禁止依赖 `debugCode`。 ```text POST /api/auth/sms/send body: { "phone": "13800000000", "purpose": "login" } POST /api/auth/sms/verify body: { "phone": "13800000000", "code": "123456", "purpose": "login" } ``` 成功后保存: ```text session.token session.expiresAt user ``` 后续请求统一带: ```text Authorization: Bearer x-tenant-id: ``` ### 绑定或更换手机号 微信/QQ 登录后强制绑定手机号、个人中心更换手机号,都走同一个后端命令。前端先发送 `bind_phone` 用途验证码,再提交绑定: ```text POST /api/auth/sms/send body: { "phone": "13800000000", "purpose": "bind_phone" } POST /api/auth/phone/bind Authorization: Bearer body: { "phone": "13800000000", "code": "123456" } ``` 前端规则: - 绑定接口必须带当前登录态,不能用 `x-user-id` 伪造用户。 - 绑定接口只接受 `bind_phone` 验证码,不接受 `login` 验证码。 - 新手机号如果已属于其它账号,后端返回 `PHONE_ALREADY_BOUND`。 - 换绑成功后旧手机号登录身份会被移除;迁移期 `tk_` 其它设备 session 会被撤销,当前 session 继续可用。 - 微信手机号授权后也应由后端 adapter 换取手机号,再复用同一类绑定命令;不要在页面里持久化明文手机号授权中间数据。 ### 微信小程序登录 微信小程序端调用 `Taro.login()` 获取 code,然后交给后端: ```text POST /api/auth/oauth/wechat-miniapp body: { "code": "", "profile": { "nickName": "...", "avatarUrl": "..." } } ``` 成功响应包含: ```text provider user isNewUser session.token session.expiresAt identity.openId identity.unionId ``` 注意: - 前端不接触 `appSecret`。 - 前端不会拿到微信 `session_key`。 - 如果登录前已经解析到推广码,登录成功后再调用 `/api/referral/bind` 完成首绑保护。 - 如果用户没有手机号,跳转到上面的“绑定或更换手机号”流程。 ### 微信网页登录 H5 端在微信开放平台授权回调页拿到 `code` 后,交给后端: ```text POST /api/auth/oauth/wechat body: { "code": "", "lang": "zh_CN" } ``` 成功响应与小程序登录一致,包含 `provider=user/identity/session`。后端会使用租户 `wechat-web/wechat_web/wechat` provider 配置换取 `access_token` 和 `openid`,再拉取用户资料;如果返回 `unionid`,会和同一开放平台下的小程序身份合并。 前端注意: - H5 回调页只短暂读取 `code/state`,不要持久化微信 `access_token`。 - `state` 应在前端本地或服务端中转页校验,避免跨站授权回调混淆。 - 多租户自定义域名下,授权回调域名必须与租户微信开放平台配置一致;如果未来使用统一授权中转域名,需要在回调后再解析目标租户。 ### QQ 登录 H5 端在 QQ 互联授权回调页拿到 `code` 后,交给后端: ```text POST /api/auth/oauth/qq body: { "code": "", "redirectUri": "https://h5.example.com/auth/qq/callback" } ``` 后端会完成 `code -> access_token -> openid -> userinfo`,并签发本项目 session。 前端注意: - `redirectUri` 必须与 QQ 互联后台登记地址一致;也可以由租户后台 provider 配置固定,前端不传。 - 前端不要接触 QQ `clientSecret/AppKey` 或 `access_token`。 - 登录后如果没有手机号,同样进入“绑定或更换手机号”流程。 ## 支付对接 支付流程必须以后端订单金额和后端回调为准,前端只负责拉起支付。 ### 创建订单 ```text POST /api/commerce/orders body: { "planId": "", "quantity": 1, "payProvider": "wechat_pay | alipay", "payMethod": "jsapi | wap", "regionId": "", "couponCode": "<可选,优惠券码>", "couponRedemptionId": "<可选,已领取优惠券 redemptionId>" } ``` 前端可以先领取优惠券,再下单: ```text POST /api/commerce/coupons/claim body: { "code": "", "planId": "", "regionId": "" } ``` `coupons/claim` 对同一用户同一优惠券是幂等的;已使用的券会返回 `COUPON_ALREADY_USED`。下单时后端会重新计算套餐原价、优惠金额和最终应付,前端展示金额只能使用接口返回的 `originalAmountCents`、`discountCents`、`amountCents`。 如果优惠后 `amountCents=0`,后端会立即把订单置为 `paid` 并发放权益,前端不要再调用 `payments/create`。 返回未支付 `orderNo` 后,再创建支付参数: ```text POST /api/commerce/payments/create body: { "orderNo": "", "provider": "wechat_pay", "openId": "<微信小程序登录后的 openId>" } ``` 微信小程序返回的 `paymentParams` 可直接映射到 `Taro.requestPayment`: ```text appId timeStamp nonceStr package signType paySign ``` 支付宝 H5/WAP 返回: ```text paymentParams.url ``` H5 可以跳转到该 URL。小程序端如果后续要接支付宝小程序,需要新增独立 provider/method,不要复用 H5 WAP URL。 支付完成后前端不要自行开通会员。前端应轮询或重新请求: ```text GET /api/commerce/orders/status?orderNo= GET /api/commerce/orders/detail?orderNo= GET /api/commerce/entitlements ``` 订单详情会返回 `pricing`、`payments`、`items`、`couponRedemptions`,可用于收银台、订单详情页和售后排查。订单状态轮询页只需消费 `status/payment`,避免频繁拉取全量明细。 后端已提供 commerce worker 作为兜底补偿:如果微信/支付宝支付成功但 webhook 漏通知,worker 会按租户商户配置查询供应商订单并幂等更新订单、支付和权益。前端仍然只轮询 `orders/status` 或 `orders/detail`,不要直接调用供应商查询接口,也不要在页面里自行开通会员。 ### 退款和售后 学生端不直接发起后台退款命令。普通用户订单页只展示 `GET /api/commerce/orders/status` 和 `GET /api/commerce/orders/detail` 返回的订单状态、支付状态、`refundedAmountCents`,并提供客服/工单入口。租户后台或运营后台才接退款接口。 租户后台退款列表: ```text GET /api/commerce/refunds?status=requested&orderNo= 权限:tenant:refund:read ``` 创建退款申请: ```text POST /api/commerce/refunds 权限:tenant:refund:write body: { "orderNo": "", "refundNo": "<可选,前端幂等键>", "amountCents": 500, "reason": "用户协商退款", "entitlementAction": "revoke_on_success | none" } ``` 退款状态流转: ```text POST /api/commerce/refunds/status body: { "refundId": "", "action": "approve | reject | submit_provider_refund | query_provider_refund | mark_processing | mark_succeeded | mark_failed | cancel", "providerRefundNo": "<支付平台退款单号,可选>", "providerNotifyUrl": "<微信退款通知地址,可选>", "note": "<处理备注>" } ``` 状态说明: ```text requested -> approved -> processing -> succeeded requested -> approved -> submit_provider_refund -> processing/succeeded processing -> query_provider_refund -> processing/succeeded/failed provider refund notify -> processing/succeeded/failed requested/approved -> rejected requested/approved -> cancelled approved/processing -> failed ``` 注意: - 金额单位一律是分,前端不要传元。 - `refundNo` 是幂等键;同一订单同一金额重复提交会返回原退款申请。 - 后端会限制累计退款金额不能超过实付金额。 - 全额退款成功后订单和支付会进入 `refunded`,相关订单权益会被置为 `revoked`;部分退款进入 `partially_refunded`,默认不撤销权益。 - `submit_provider_refund` 会由后端使用租户支付账户密钥调用微信/支付宝;前端不要保存商户私钥、API v3 key 或支付宝应用私钥。 - 微信退款通常先进入 `processing`,租户后台可以调用 `query_provider_refund` 主动向微信查询,确认成功后后端才会更新订单退款金额和权益。 - 支付宝普通退款如果响应 `fund_change=Y` 会同步进入 `succeeded`;处于 `processing` 的退款也可以用 `query_provider_refund` 调用 `alipay.trade.fastpay.refund.query` 确认。 - 退款通知地址由支付账户或 `submit_provider_refund.providerNotifyUrl` 配置,后端公开接收路径为 `POST /api/commerce/refunds/notify/wechat_pay?tenantId=`、`POST /api/commerce/refunds/notify/alipay?tenantId=`。这是支付平台回调地址,Taro 前端不要主动调用。 - 退款通知只会推进已经审核/处理中的退款申请;未审核的 `requested` 退款不能被外部通知直接落账。 - 已经 `succeeded` 的退款不能再次查询或再次标记成功,避免订单退款金额重复累加。前端应按接口返回状态展示,不要假设点击后立即到账。 - 自动补偿 worker 已接入:支付漏通知和处理中退款会由后端定时查询供应商并幂等落账。完整资金流水对账、账单下载比对和异常订单运营台后续继续补;生产联调时仍需保留人工确认/失败登记入口。 ### 激活码预检查与兑换 兑换前建议先调用: ```text POST /api/commerce/activation-codes/check body: { "code": "", "regionId": "" } ``` 可根据返回的 `valid/reasonCode/days/regionName/saleType` 展示确认弹窗。常见 `reasonCode`: ```text ACTIVATION_CODE_NOT_FOUND ACTIVATION_CODE_USED ACTIVATION_CODE_SELF_REDEEM_FORBIDDEN ACTIVATION_CODE_REGION_MISMATCH ``` 用户确认后再调用 `POST /api/commerce/activation-codes/redeem`。兑换成功后重新请求 `/api/commerce/entitlements` 和个人中心,不要在前端本地伪造会员状态。 后端支付回调地址由租户支付账户配置: ```text /api/commerce/payments/notify/wechat_pay?tenantId= /api/commerce/payments/notify/alipay?tenantId= ``` 前端禁止: - 传入自定义金额。 - 伪造支付成功状态。 - 调用 `/api/commerce/payments/manual-confirm`;这个接口只给租户后台线下收款/迁移期使用,后端要求 `tenant:payment:write`。 - 保存商户号私钥、API v3 key、支付宝应用私钥。 - 在页面里实现 webhook 验签或权益开通。 ## 第一阶段页面建议 1. `pages/bootstrap/index` - 租户解析、主题初始化、登录态恢复。 2. `pages/login/index` - 先接短信登录;后续接微信小程序登录。 3. `pages/home/index` - Banner、公告、题库入口、会员入口、资料入口。 4. `pages/region/index` - 地区选择和权益提示。 5. `pages/catalog/index` - entry/node/collection/blueprint 通用导航。 6. `pages/practice/index` - 刷题、答题、解析、错题、收藏。 7. `pages/vocabulary/index` - 单词单元、学习、收藏。 8. `pages/handbook/index` - 手册目录和阅读。 9. `pages/scoreline/index` - 动态字段筛选和趋势。 10. `pages/profile/index` - 会员、订单、激活码、学习数据、勋章。 ## 当前 Taro 实现进度 截至 2026-06-29,`apps/taro` 已完成学生端第一阶段页面: ```text pages/student/login/index pages/student/home/index pages/student/catalog/index pages/student/practice/index pages/student/vocabulary/index pages/student/handbook/index pages/student/scoreline/index pages/student/assets/index pages/student/profile/index ``` 已新增服务层: ```text src/services/catalog.ts 目录、题库、单词、手册、分数线、资料 src/services/learning.ts 练习 session、答题、收藏、单词复习 src/services/commerce.ts 套餐、订单、权益、激活码 src/services/profile.ts 个人中心、签到、勋章、倒计时 src/services/tenantAdmin.ts 租户后台看板、学生、内容、营销、设置 src/services/platformAdmin.ts 平台后台租户、套餐账单、用量、公共题库授权 ``` 验证命令: ```bash npm run check:taro npm run build:taro:h5:student npm run build:taro:h5:tenant npm run build:taro:h5:platform ``` 已通过。构建仍有 Taro H5 入口体积 warning,属于当前 Taro 工程既有警告,不阻断联调。 下一批前端开发重点: - 学生端:选地区、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、订单收银台和订单详情。 - 租户后台:写入表单、字段映射 UI、导入 preview/import/issues 操作台、公共题库采纳/同步、学生批量导入、角色模板配置 UI、CRM 分配和分佣结算操作。 - 平台后台:租户创建、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户详情/编辑、平台审计、自动计费和批量账单操作。 - 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。 ## 租户后台前端建议 租户后台可以先做 H5 管理台,也可以后续使用 Taro H5 复用部分组件。优先页面: - 概览:`/api/tenant-admin/overview` - 数据看板:`/api/tenant-admin/dashboard`,展示收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 - 品牌/主题/域名/公开设置 - 支付账户/登录 provider/密钥引用 - 用户与成员权限 - 班级/教师/学生:`/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`、`students/notes`、`students/followups` - 角色模板:`GET/PUT /api/tenant-admin/role-templates`、`POST /api/tenant-admin/role-templates/disable` - 内容入口/分类树/题目集合/练习蓝图 - 题目/单词/知识手册/分数线/视频维护 - 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入 preview/import/issues - Banner/FAQ/公告/激活码/优惠券 - 勋章:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants` - 考试日期:`GET/PUT /api/tenant-admin/exam-dates` - 题目反馈:`GET /api/tenant-admin/feedbacks`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events` - 销售/代理/CRM 队列 租户后台不应在前端自行决定权限;隐藏菜单只是体验优化,接口仍会校验权限。角色模板用于让租户配置“运营、教师、销售、代理”等自定义后台体验,成员绑定模板后,前端按模板的菜单/模块/字段权限渲染,后端按 permission keys 执行真正的访问控制。班级/学生范围权限由后端根据角色、模板 `dataScope.classIds` 和 `tenant_class_members` 计算,教师默认只能看到自己负责班级。 ## 联调顺序 1. 启动页和租户解析。 2. 短信登录和 `auth/me`。 3. 首页、地区、内容入口、题库树。 4. 练习 session、答题、错题、收藏。 5. 背单词、知识手册、分数线。 6. 会员套餐、订单、激活码。 7. 资料下载、视频解析。 8. 销售追踪和分享链路。 9. 租户后台内容维护和导入。 10. 正式鉴权、真实支付、对象存储生产联调。 ## Supabase Client 验证任务 前端 scaffold 后先做一个最小兼容性验证: - H5:`@supabase/supabase-js` 初始化、session 持久化、token refresh、logout。 - 微信小程序:验证自定义 storage/fetch/URL polyfill 是否稳定。 - API:用 Supabase access token 调 `apps/api`,后端解析出可信用户。 - 安全:确认前端 bundle 中不存在 secret/service role/database/payment/storage 私钥。 如果微信小程序端 `supabase-js` 兼容性不稳定,小程序端改走 `apps/api/auth/*` 登录适配层,H5 继续使用 Supabase client 管理 Auth。