Просмотр исходного кода

feat: rebuild learning read contracts

彭峰 2 месяцев назад
Родитель
Сommit
db1cc3b6ce

+ 2 - 2
docs/migration/admin-migration-status.md

@@ -15,9 +15,9 @@
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。
 - 8 个管理/CMS 函数均验证未认证请求返回 401;临时帐套管理员完成真实只读冒烟测试后已删除。
-- 第 9 个 `xiaoshu.app.gateway` 已覆盖旧 H5 源码的全部 91 个 action:26 个已映射,65 个显式返回 501 阻塞原因。详见 `wxapp-cloud-action-matrix.md`。
+- 第 9 个 `xiaoshu.app.gateway` 已覆盖旧 H5 源码的全部 91 个 action:25 个已映射,66 个显式返回 501 阻塞原因。复习收入统计因迁移数据没有金额和计价规则,从原先的错误通用映射改为明确阻塞。详见 `wxapp-cloud-action-matrix.md`。
 - 旧数据的 `isDeleted` 在部分物理表与 Parse Schema 不一致,把它放入 Parse 查询条件会返回 500;网关改为公司条件查询后在结果侧兼容过滤。
-- Angular H5 已开始按页回归切换:账号密码登录、版本、栏目、公开内容及 8 类已映射 addon 联表可走云函数,其余 action 仍由显式迁移路由保留旧端点。
+- Angular H5 已开始按页回归切换:账号密码登录、版本、栏目、公开内容、8 类 addon 联表,以及学习记录详情、预约详情、生词本、21 天抗遗忘列表可走云函数;私有读取只在真实 Parse 会话下切换,其余 action 仍由显式迁移路由保留旧端点。
 
 ## SQL 视图 / 函数替代状态
 

+ 15 - 4
docs/migration/wxapp-cloud-action-matrix.md

@@ -5,10 +5,10 @@
 - 基线来自旧 uni-app 源码静态扫描生成的 `SOURCE_API_ACTIONS`,共 91 个唯一 action。
 - 统一新入口为 `POST /api/functions/xiaoshu/app/gateway`。
 - 请求体统一使用 `{ "token": "<sessionToken>", "params": { "action": "...", ... } }`;执行器会占用顶层 `id`,业务参数不得放在顶层。公开接口可省略 `token`,但仍必须保留 `params`。
-- 已有云函数映射 26 个,另有 65 个以 HTTP 501 和 `migration_blocked: <原因>` 显式拒绝。矩阵不存在未知 action。
+- 已有云函数映射 25 个,另有 66 个以 HTTP 501 和 `migration_blocked: <原因>` 显式拒绝。矩阵不存在未知 action。
 - “已映射”表示已有云函数代码与权限边界;完成 H5 切换前仍需逐页响应字段回归。
 
-## 已映射的 26 个 action
+## 已映射的 25 个 action
 
 | 领域 | action |
 |---|---|
@@ -16,7 +16,7 @@
 | 内容 | `content_get`, `content_list`, `content_list_llk`, `content_uphis`, `node_get`, `node_list` |
 | 商品只读 | `product_get`, `product_list`, `product_stock_list` |
 | 账号 | `user_get`, `user_info_name`, `user_list`, `user_login_passwd`, `user_register`, `user_update`, `user_update_pwd`, `user_update_pwdall`, `e_user_list` |
-| 学习/预约只读 | `e_get_21list`, `e_get_21list_tj`, `e_order_detail`, `e_record_detail`, `e_words_list`, `user_point_list` |
+| 学习/预约只读 | `e_get_21list`, `e_order_detail`, `e_record_detail`, `e_words_list`, `user_point_list` |
 | 反馈 | `guestbook_add` |
 
 已实测 `content_list` 在公司帐套下返回 92,435 条,`modelId=56` 返回 108 条。读取用 Psql 并强制 `company=7pIbDBJmKx`,所有客户端输入均使用参数化查询。
@@ -36,6 +36,15 @@
 
 `myfield`/`myfield2` 只允许白名单字段和参数化等值过滤,`orders` 只允许已映射字段的 `ASC`/`DESC` 或旧 `NEWID()` 随机排序。Model 53/54/56/58/59/60/61 的列表与详情禁止匿名读取,非管理员仅能访问本人或指派给自己的学员记录。
 
+学习专用只读接口已按旧页面的真实响应形状重建:
+
+- `e_record_detail`:由 Model 56 学习记录解析 `xxqs`,再按其中的 `GeneralID` 联查 Model 52 与 `VocabularyWord`,保持原顺序并为每项返回旧页面要求的 `detail[0]`。
+- `e_words_list`:只返回指定用户 `PracticeRecord.jrscb=1` 的生词,按 `scid -> CommonModel.generalId -> VocabularyWord.id` 联查词库详情,不再误返回 89,620 条全量词库。
+- `e_order_detail`:以预约内容 `GeneralID` 联查 `CourseAppointment`,返回数组契约、旧字段别名和可恢复的课程名称。
+- `e_get_21list`:以 `MemoryPracticeRecord` 为主记录,联查 Model 60、原学习记录和课程节点;学员按 `yhid`、教练收入列表按 `plid` 隔离。
+
+迁移数据没有保存复习收入金额,也没有提供可验证的计价公式,因此 `e_get_21list_tj` 改为显式 501;收入列表仍返回复习事实,但用 `incomeRuleUnavailable=true` 标记金额不可恢复,不伪造收入。
+
 ## 因目标 Schema/历史数据缺失而阻塞
 
 | 能力 | action | 缺失项 |
@@ -62,6 +71,7 @@
 | 短信验证码 | `mcode_send`, `user_login_mobile`, `user_register_mobile` | 新短信服务商凭据、验证码存储和限流 |
 | 微信授权 | `user_sync2` | 新微信容器流程和凭据 |
 | 资金/积分 | `user_cash_add`, `user_cash_list`, `user_coin_recharge`, `user_exp_transfer`, `user_money_recharge`, `user_money_transfer` | 支付凭据、不可变事务账本和业务规则 |
+| 复习收入 | `e_get_21list_tj` | 旧迁移数据中的收入金额字段或经业务方确认的计价公式 |
 | 支付密码 | `user_update_paypwd` | 独立哈希服务和重置流程 |
 | 实名认证 | `user_rnauth_add`, `user_rnauth_get`, `user_rnauth_upd` | 合规审核、加密存储和访问审计 |
 
@@ -82,11 +92,12 @@ Angular H5 已启用混合迁移路由:
 
 - `user_login_passwd` 已优先调用云函数:已完成 Parse 密码迁移的账号保存真实 `sessionToken` 并用该会话读取 `user_get`;尚未完成密码重置的旧账号在云函数返回 401/403 时自动回退旧登录,不中断存量用户。
 - `app_update`、`node_list`、`node_get` 已切换至云函数。
+- 持有真实 Parse 会话时,`e_record_detail`、`e_order_detail`、`e_words_list`、`e_get_21list` 已切换至云函数;旧会话继续留在旧端点,避免存量账号在密码重置前中断。
 - 不含 addon 条件的 `content_list` 和 Model 52 公开词库已切换;已持有 Parse 会话的用户会把字段白名单内的 Model 53/54/56/58/59/60/61 addon 查询切到云函数,仍使用旧会话的存量账号自动保留旧端点。
 - 云函数返回同时提供规范化 camelCase 与旧系统 `GeneralID`/`NodeID`/`Title` 等字段别名。
 - `product_list` 尚未切换:当前 `Product` 类只有库存变体字段,缺少旧页依赖的商品名称、价格、图片和正文联表。
 
-在 65 个阻塞 action 中包含订单、支付和学习写入等主流程时,直接全量切换会导致页面大面积中断。剩余切换条件是:
+在 66 个阻塞 action 中包含订单、支付和学习写入等主流程时,直接全量切换会导致页面大面积中断。剩余切换条件是:
 
 1. 先对齐缺失 Schema/数据和第三方凭据。
 2. 完成剩余 action 的云函数事务、权限和字段回归。

+ 40 - 5
scripts/deploy-admin-functions.mjs

@@ -244,6 +244,7 @@ const BLOCKED = {
   content_add: 'content/addon 双表写入关系尚待字段级回归', content_add_zt: 'content/addon 双表写入关系尚待字段级回归', content_update: 'content/addon 双表写入关系尚待字段级回归',
   product_del: '尚未建立门店管理员授权关系', product_stock_change: '尚未建立门店管理员授权关系', product_sale_change: '尚未建立门店管理员授权关系',
   e_ck_list: '需重建课程、词库与用户进度联表语义', e_add_words: '需按 CourseBinding 与 VocabularyWord 关系重建生词状态', stu_record_update_v2: '需重建学习主记录与 addon 的原子写入', unit_record_update: '需确认单元进度聚合规则', e_order_update_v2: '需确认预约状态机与角色权限', e_order_tongji: '需确认预约统计口径',
+  e_get_21list_tj: '迁移数据未包含复习收入金额字段或可验证的计价规则',
   pub_add: '需确认 Pub 业务类型与审核规则', pub_list: '需确认 Pub 业务类型与可见范围', user_dept: '目标 Schema 无可证明的用户部门关系',
   user_rnauth_add: '实名认证需新的合规审核与敏感数据存储', user_rnauth_get: '实名认证需新的合规审核与敏感数据存储', user_rnauth_upd: '实名认证需新的合规审核与敏感数据存储',
   product_add: '需建立门店管理员授权与商品附加表事务', product_upd: '需建立门店管理员授权与商品附加表事务'
@@ -330,11 +331,43 @@ async function sqlPage(className, input, filters = []) {
   const rows = await Psql.query('SELECT * FROM "' + className + '" WHERE ' + where + ' ORDER BY "updatedAt" DESC LIMIT $' + (values.length + 1) + ' OFFSET $' + (values.length + 2), rowValues); const total = number(countRow.total);
   return { rows: rows.filter(isVisible).map((row) => legacyAliases(row, className)), page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
 }
+function parseArray(value) { if (Array.isArray(value)) return value; if (typeof value !== 'string' || !value.trim()) return []; try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : []; } catch (_) { return []; } }
+function largePageInput(input) { return { index: Math.max(1, number(input.cpage || input.page, 1)), size: Math.min(500, Math.max(1, number(input.psize || input.pageSize, 20))) }; }
+function ownLegacyId(current) { return number(current && (current.get('legacyUserId') || current.get('userid') || current.get('num'))); }
+async function authorizeRequestedUser(current, requestedId) {
+  const targetId = number(requestedId || ownLegacyId(current)); if (!targetId) fail(400, '缺少用户 ID'); if (isAdminUser(current) || targetId === ownLegacyId(current)) return targetId;
+  const fields = await fieldsOf('_User'); const target = await findByLegacyId('_User', fields, targetId, ['legacyUserId','userid','num']); const agent = target && target.get('agent'); if (!target || !agent || agent.id !== current.id) fail(403, '无权查看该学员的业务记录'); return targetId;
+}
+async function authorizeBusinessRow(current, row, subjectFields, actorFields = []) {
+  if (isAdminUser(current)) return; const ownId = ownLegacyId(current); const allIds = subjectFields.concat(actorFields).map((name) => number(row[name] ?? row[name.toLowerCase()] ?? row[name.toUpperCase()])).filter(Boolean); if (allIds.includes(ownId)) return;
+  const subjectIds = subjectFields.map((name) => number(row[name] ?? row[name.toLowerCase()] ?? row[name.toUpperCase()])).filter(Boolean); if (!subjectIds.length) fail(403, '业务记录缺少可验证的所属用户'); const fields = await fieldsOf('_User'); for (const targetId of [...new Set(subjectIds)]) { const target = await findByLegacyId('_User', fields, targetId, ['legacyUserId','userid','num']); const agent = target && target.get('agent'); if (target && agent && agent.id === current.id) return; } fail(403, '无权查看该业务记录');
+}
+function normalizeWordRow(source) { const addon = source.__addon || {}; const merged = { ...source, ...addon }; delete merged.__addon; return legacyAliases(merged, 'CommonModel'); }
+async function orderDetailRow(inputId, current, authorize = true) {
+  const id = String(inputId || ''); if (!id) fail(400, '缺少预约 ID'); const rows = await Psql.query('SELECT c.*, row_to_json(a) AS "__addon", n."nodeName" AS "__courseTitle" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company" = $1 AND CAST(a."id" AS text) = CAST(c."itemId" AS text) LEFT JOIN "Node" n ON n."company" = $1 AND CAST(n."nodeId" AS text) = CAST(a."kcid" AS text) WHERE c."company" = $1 AND CAST(c."modelId" AS text) = \'54\' AND CAST(c."generalId" AS text) = $2 LIMIT 1', [DEFAULT_COMPANY_ID, id]);
+  if (!rows.length) return null; const source = rows[0]; const addon = source.__addon || {}; const courseTitle = source.__courseTitle || source.subtitle || ''; delete source.__addon; delete source.__courseTitle; const row = legacyAliases({ ...source, ...addon }, 'CommonModel'); row.kcmc = row.kcmc || courseTitle; row.yykcmc = row.yykcmc || courseTitle; row.HoneyName = row.HoneyName || String(row.Title || '').split('(')[0]; if (authorize) await authorizeBusinessRow(current, row, ['szyh'], ['pl']); return row;
+}
+async function recordDetailData(input, current) {
+  const id = String(input.id || input.gid || ''); if (!id) fail(400, '缺少学习记录 ID'); const rows = await Psql.query('SELECT c.*, row_to_json(d) AS "__addon" FROM "CommonModel" c JOIN "DailyStudyRecord" d ON d."company" = $1 AND CAST(d."id" AS text) = CAST(c."itemId" AS text) WHERE c."company" = $1 AND CAST(c."modelId" AS text) = \'56\' AND CAST(c."generalId" AS text) = $2 LIMIT 1', [DEFAULT_COMPANY_ID, id]);
+  if (!rows.length) return null; const source = rows[0]; const recordAddon = source.__addon || {}; delete source.__addon; const record = legacyAliases({ ...source, ...recordAddon }, 'CommonModel'); await authorizeBusinessRow(current, record, ['userId'], ['pl']); const storedWords = parseArray(recordAddon.xxqs); const ids = [...new Set(storedWords.map((item) => String(item && (item.GeneralID ?? item.generalId ?? item.id) || '')).filter(Boolean))]; let wordRows = [];
+  if (ids.length) wordRows = await Psql.query('SELECT c.*, row_to_json(v) AS "__addon" FROM "CommonModel" c LEFT JOIN "VocabularyWord" v ON v."company" = $1 AND CAST(v."id" AS text) = CAST(c."itemId" AS text) WHERE c."company" = $1 AND CAST(c."modelId" AS text) = \'52\' AND CAST(c."generalId" AS text) = ANY($2::text[])', [DEFAULT_COMPANY_ID, ids]);
+  const details = new Map(wordRows.map((row) => { const normalized = normalizeWordRow(row); return [String(normalized.GeneralID || normalized.generalId), normalized]; })); const words = storedWords.map((item) => { const raw = item && typeof item === 'object' ? safe(item) : {}; const wordId = String(raw.GeneralID ?? raw.generalId ?? raw.id ?? ''); const detail = details.get(wordId) || legacyAliases({ generalId: wordId, title: raw.Title || raw.title || '' }, 'CommonModel'); return { ...detail, ...raw, GeneralID: number(wordId, wordId), Title: raw.Title || raw.title || detail.Title || detail.title || '', detail: [detail] }; });
+  const order = recordAddon.dsid ? await orderDetailRow(recordAddon.dsid, current, false) : null; return { words, addon: order ? [order] : [], record };
+}
+async function newWordPage(input, current) {
+  const uid = await authorizeRequestedUser(current, input.uid); const paging = largePageInput(input); const values = [DEFAULT_COMPANY_ID, String(uid)]; const where = 'p."company" = $1 AND CAST(p."yhid" AS text) = $2 AND LOWER(CAST(p."jrscb" AS text)) IN (\'1\',\'true\')'; const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "PracticeRecord" p WHERE ' + where, values); const rows = await Psql.query('SELECT p.*, pc."generalId" AS "__practiceGeneralId", c.*, row_to_json(v) AS "__addon" FROM "PracticeRecord" p JOIN "CommonModel" c ON c."company" = $1 AND CAST(c."modelId" AS text) = \'52\' AND CAST(c."generalId" AS text) = CAST(p."scid" AS text) LEFT JOIN "VocabularyWord" v ON v."company" = $1 AND CAST(v."id" AS text) = CAST(c."itemId" AS text) LEFT JOIN "CommonModel" pc ON pc."company" = $1 AND CAST(pc."modelId" AS text) = \'53\' AND CAST(pc."itemId" AS text) = CAST(p."id" AS text) WHERE ' + where + ' ORDER BY p."updatedAt" DESC LIMIT $3 OFFSET $4', values.concat([paging.size, (paging.index - 1) * paging.size]));
+  const result = rows.map((source) => { const practice = { id: source.id, kcid: source.kcid, scid: source.scid, xxcs: source.xxcs, yhid: source.yhid, jrscb: source.jrscb }; const detail = normalizeWordRow(source); return { ...practice, GeneralID: number(source.__practiceGeneralId || source.id), Title: detail.Title || detail.title || '', detail: [detail] }; }); const total = number(countRow.total); return { rows: result, page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
+}
+function learnedDate(row) { const raw = String(row.dqrq || '').trim(); return /^\d{8}$/.test(raw) ? raw.slice(0,4) + '-' + raw.slice(4,6) + '-' + raw.slice(6,8) : String(row.kywsj || '').slice(0,10); }
+async function memoryPage(input, current) {
+  const uid = await authorizeRequestedUser(current, input.uid); const paging = largePageInput(input); const income = number(input.shouru) === 1; const values = [DEFAULT_COMPANY_ID, String(uid)]; const clauses = ['c."company" = $1', 'CAST(c."modelId" AS text) = \'60\'', 'CAST(m."' + (income ? 'plid' : 'yhid') + '" AS text) = $2']; if (input.status !== undefined && input.status !== '') { values.push(String(number(input.status))); clauses.push('CAST(m."fxzt" AS text) = $' + values.length); } const where = clauses.join(' AND '); const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" c JOIN "MemoryPracticeRecord" m ON m."company" = $1 AND CAST(m."id" AS text) = CAST(c."itemId" AS text) WHERE ' + where, values); const rowValues = values.concat([paging.size, (paging.index - 1) * paging.size]);
+  const rows = await Psql.query('SELECT c.*, row_to_json(m) AS "__addon", d."learned", d."dqrq", n."nodeName" AS "kc_title" FROM "CommonModel" c JOIN "MemoryPracticeRecord" m ON m."company" = $1 AND CAST(m."id" AS text) = CAST(c."itemId" AS text) LEFT JOIN "CommonModel" dc ON dc."company" = $1 AND CAST(dc."modelId" AS text) = \'56\' AND CAST(dc."generalId" AS text) = CAST(m."xxjlid" AS text) LEFT JOIN "DailyStudyRecord" d ON d."company" = $1 AND CAST(d."id" AS text) = CAST(dc."itemId" AS text) LEFT JOIN "Node" n ON n."company" = $1 AND CAST(n."nodeId" AS text) = CAST(m."kcid" AS text) WHERE ' + where + ' ORDER BY m."kywsj" DESC, c."updatedAt" DESC LIMIT $' + (values.length + 1) + ' OFFSET $' + (values.length + 2), rowValues); const result = rows.map((source) => { const addon = source.__addon || {}; delete source.__addon; const row = legacyAliases({ ...source, ...addon }, 'CommonModel'); row.learned_date = learnedDate(row); if (income) { row.money = 0; row.incomeRuleUnavailable = true; } return row; }); const total = number(countRow.total); return { rows: result, page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
+}
 async function resolveContent(input, requireOne = false) { const fields = await fieldsOf('CommonModel'); const query = new Parse.Query('CommonModel'); tenant(query, fields); const id = String(input.id || input.gid || input.generalId || ''); if (!id) { if (requireOne) fail(400, '缺少内容 ID'); return { query, fields }; } if (/^[A-Za-z0-9_-]{10,40}$/.test(id)) { try { return { object: await query.get(id, { useMasterKey: true }), fields }; } catch (_) {} } query.equalTo('generalId', number(id)); return { object: await query.first({ useMasterKey: true }), fields }; }
 async function handler(request, response) {
   try {
     const input = inputOf(request); const action = String(input.action || ''); if (!action) return response.status(400).json(reject('缺少 action'));
-    if (action === 'migration_status') return response.json(envelope({ implemented: ['app_update','user_login_passwd','user_register','user_info_name','user_get','user_list','e_user_list','user_update','user_update_pwd','user_update_pwdall','content_list','content_list_llk','content_get','content_uphis','node_list','node_get','product_list','product_get','product_stock_list','e_get_21list','e_get_21list_tj','e_record_detail','e_order_detail','e_words_list','user_point_list','guestbook_add'], blocked: BLOCKED }));
+    if (action === 'migration_status') return response.json(envelope({ implemented: ['app_update','user_login_passwd','user_register','user_info_name','user_get','user_list','e_user_list','user_update','user_update_pwd','user_update_pwdall','content_list','content_list_llk','content_get','content_uphis','node_list','node_get','product_list','product_get','product_stock_list','e_get_21list','e_record_detail','e_order_detail','e_words_list','user_point_list','guestbook_add'], blocked: BLOCKED }));
     if (BLOCKED[action]) return response.status(501).json(reject('migration_blocked: ' + BLOCKED[action]));
     if (action === 'user_login_passwd') {
       const username = String(input.name || input.username || '').trim(); const password = String(input.passwd || input.password || ''); if (!username || !password) return response.status(400).json(reject('请输入账号和密码'));
@@ -359,11 +392,13 @@ async function handler(request, response) {
     if (action === 'product_list' || action === 'product_stock_list') { const result = await sqlPage('Product', input); return response.json(envelope(result.rows, undefined, result.page)); }
     if (action === 'product_get') { const result = await sqlPage('Product', { page: 1, pageSize: 1 }, [{ field: 'id', value: number(input.id) }]); return result.rows[0] ? response.json(envelope(result.rows[0])) : response.status(404).json(reject('商品不存在')); }
     if (action === 'product_del' || action === 'product_stock_change' || action === 'product_sale_change') { const fields = await fieldsOf('Product'); const found = await findByLegacyId('Product', fields, input.id, ['id']); if (!found) return response.status(404).json(reject('商品不存在')); if (action === 'product_del') await found.destroy({ useMasterKey: true }); else { const field = action === 'product_stock_change' ? 'nums' : 'isChu'; if (fields[field]) found.set(field, action === 'product_stock_change' ? number(input.nums || input.stock) : Boolean(input.value ?? input.status)); await found.save(null, { useMasterKey: true }); } return response.json(envelope({ objectId: found.id })); }
-    const legacyId = number(current && (current.get('legacyUserId') || current.get('userid') || current.get('num')));
-    const listMappings = { e_get_21list:['MemoryPracticeRecord','yhid'], e_get_21list_tj:['MemoryPracticeRecord','yhid'], e_words_list:['VocabularyWord',null], user_point_list:['UserUserPoint','userId'] };
+    const legacyId = ownLegacyId(current);
+    if (action === 'e_get_21list') { const result = await memoryPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }
+    if (action === 'e_words_list') { const result = await newWordPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }
+    if (action === 'e_order_detail') { const found = await orderDetailRow(input.id, current); return found ? response.json(envelope([found])) : response.status(404).json(reject('预约记录不存在')); }
+    if (action === 'e_record_detail') { const found = await recordDetailData(input, current); return found ? response.json(envelope(found.words, found.addon)) : response.status(404).json(reject('学习记录不存在')); }
+    const listMappings = { user_point_list:['UserUserPoint','userId'] };
     if (listMappings[action]) { const [className,userField] = listMappings[action]; const result = await paged(className, input, (query, fields) => { if (userField && fields[userField]) query.equalTo(userField, legacyId); if (input.kcid && fields.kcid) query.equalTo('kcid', number(input.kcid)); }); return response.json(envelope(result.rows, undefined, result.page)); }
-    const detailMappings = { e_record_detail:['DailyStudyRecord',['id','dsid']], e_order_detail:['CourseAppointment',['id','yykcid']] };
-    if (detailMappings[action]) { const [className, aliases] = detailMappings[action]; const fields = await fieldsOf(className); const found = await findByLegacyId(className, fields, input.id, aliases); return found ? response.json(envelope(safe(found))) : response.status(404).json(reject('记录不存在')); }
     if (action === 'guestbook_add') { const fields = await fieldsOf('Guestbook'); const item = new Parse.Object('Guestbook'); item.set('company',companyPointer()); if (fields.userid) item.set('userid',legacyId); if (fields.title) item.set('title',String(input.title || '用户反馈')); if (fields.tcontent) item.set('tcontent',String(input.content || input.tcontent || '')); if (fields.status) item.set('status',0); await item.save(null,{useMasterKey:true}); return response.json(envelope(safe(item))); }
     return response.status(501).json(reject('migration_blocked: 尚未完成动作映射 ' + action));
   } catch (error) { const status = Number(error.status || (error.code === 101 ? 404 : 500)); return response.status(status).json(reject(status >= 500 ? '云函数执行失败' : error.message)); }

+ 17 - 1
scripts/smoke-admin-functions.mjs

@@ -131,6 +131,22 @@ try {
   const learning = await callLegacyFunction(login.sessionToken, { action: 'content_list', modelId: 56, page: 1, pageSize: 1 });
   if (!learning.result?.[0]?.GeneralID || learning.result?.[0]?.xxqs === undefined) throw new Error('学习记录 addon 联表校验失败');
 
+  const recordDetail = await callLegacyFunction(login.sessionToken, { action: 'e_record_detail', id: learning.result[0].GeneralID });
+  if (!Array.isArray(recordDetail.result) || !recordDetail.result.length || !recordDetail.result[0]?.detail?.[0]?.Title) throw new Error('学习记录单词详情联查校验失败');
+
+  const appointments = await callLegacyFunction(login.sessionToken, { action: 'content_list', modelId: 54, page: 1, pageSize: 1 });
+  const appointment = await callLegacyFunction(login.sessionToken, { action: 'e_order_detail', id: appointments.result?.[0]?.GeneralID });
+  if (!appointment.result?.[0]?.GeneralID || appointment.result[0].kcid === undefined) throw new Error('预约详情联查校验失败');
+
+  const newWords = await callLegacyFunction(login.sessionToken, { action: 'e_words_list', uid: 58, page: 1, pageSize: 2 });
+  if (Number(newWords.page?.itemCount) < 1 || !newWords.result?.[0]?.detail?.[0]?.Title) throw new Error('用户生词联查校验失败');
+
+  const memory = await callLegacyFunction(login.sessionToken, { action: 'e_get_21list', uid: 58, page: 1, pageSize: 2 });
+  if (Number(memory.page?.itemCount) < 1 || !memory.result?.[0]?.GeneralID || memory.result[0].learned === undefined) throw new Error('21 天抗遗忘联查校验失败');
+
+  const incomeBlocked = await callLegacyFunction(login.sessionToken, { action: 'e_get_21list_tj', uid: 58 }, 501);
+  if (!String(incomeBlocked.retmsg).includes('计价规则')) throw new Error('复习收入缺失规则未显式阻塞');
+
   const ownEmpty = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_list', modelId: 56, myfield2: `UserId=${memberLegacyId}`, page: 1, pageSize: 1 });
   if (!Array.isArray(ownEmpty.result) || ownEmpty.page?.itemCount !== 0) throw new Error('普通用户本人记录权限校验失败');
   const denied = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_list', modelId: 56, myfield2: 'UserId=3', page: 1, pageSize: 1 }, 403);
@@ -142,7 +158,7 @@ try {
   const blocked = await callLegacyFunction('', { action: 'cart_list' }, 501);
   if (!String(blocked.retmsg).startsWith('migration_blocked:')) throw new Error('阻塞接口未返回 migration_blocked');
 
-  console.log('Cloud smoke passed: admin auth/tenant/CRUD reads, CMS projections, app action coverage, public content, session scope, explicit blocked APIs.');
+  console.log('Cloud smoke passed: admin auth/tenant/CRUD reads, CMS projections, normalized learning joins, app action coverage, public content, session scope, explicit blocked APIs.');
 } finally {
   if (memberId) {
     await jsonRequest(`${PARSE_URL}/users/${memberId}`, { method: 'DELETE' }, true).catch((error) => {

+ 16 - 0
src/app/core/api.service.spec.ts

@@ -64,6 +64,22 @@ describe('ApiService', () => {
     request.flush({ retcode: 0, result: [] });
   });
 
+  it('routes verified learning detail reads with a Parse session', () => {
+    sessionToken = 'r:session-token';
+    api.get('e_record_detail', { id: 931 }).subscribe();
+    const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'e_record_detail', id: 931 } });
+    request.flush({ retcode: 0, result: [{ GeneralID: 149, detail: [{ Title: 'beautiful' }] }] });
+  });
+
+  it('keeps learning detail reads on legacy until a real Parse session exists', () => {
+    sessionToken = 'legacy-session';
+    api.get('e_words_list', { uid: 58 }).subscribe();
+    const request = http.expectOne((candidate) => candidate.urlWithParams.includes('action=e_words_list'));
+    expect(request.request.url).toContain(API_CONFIG.endpoint);
+    request.flush({ retcode: 0, result: [] });
+  });
+
   it('routes public vocabulary addon content without a session', () => {
     api.get('content_list', { modelId: 52, nid: 327 }).subscribe();
     const request = http.expectOne(API_CONFIG.cloudFunctionUrl);

+ 10 - 1
src/app/core/cloud-action-migration.ts

@@ -10,6 +10,13 @@ export const H5_CLOUD_ROUTED_ACTIONS = new Set([
   'user_login_passwd',
 ]);
 
+const SESSION_CLOUD_ROUTED_ACTIONS = new Set([
+  'e_get_21list',
+  'e_order_detail',
+  'e_record_detail',
+  'e_words_list',
+]);
+
 const CONTENT_MODEL_FIELDS: Record<number, Set<string>> = {
   52: new Set(['id', 'lj', 'sy', 'yb', 'yp']),
   53: new Set(['id', 'kcid', 'scid', 'xxcs', 'yhid', 'jrscb']),
@@ -24,6 +31,7 @@ const NODE_MODELS: Record<number, number> = { 28: 58, 29: 54, 32: 53, 291: 56, 2
 
 export function usesCloudGateway(action: string, params: Record<string, unknown> = {}, hasParseSession = false): boolean {
   if (H5_CLOUD_ROUTED_ACTIONS.has(action)) return true;
+  if (SESSION_CLOUD_ROUTED_ACTIONS.has(action)) return hasParseSession;
   if (action !== 'content_list') return false;
   const modelId = contentModel(params);
   if (!modelId) return !params['myfield'] && !params['myfield2'];
@@ -33,7 +41,8 @@ export function usesCloudGateway(action: string, params: Record<string, unknown>
 }
 
 export function requiresCloudSession(action: string, params: Record<string, unknown> = {}): boolean {
-  return action === 'content_list' && contentModel(params) !== 52 && Boolean(contentModel(params));
+  return SESSION_CLOUD_ROUTED_ACTIONS.has(action)
+    || (action === 'content_list' && contentModel(params) !== 52 && Boolean(contentModel(params)));
 }
 
 function contentModel(params: Record<string, unknown>): number {