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

feat: migrate appointment study record writes

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

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

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

+ 7 - 6
docs/migration/wxapp-cloud-action-matrix.md

@@ -5,10 +5,10 @@
 - 基线来自旧 uni-app 源码静态扫描生成的 `SOURCE_API_ACTIONS`,共 91 个唯一 action。
 - 基线来自旧 uni-app 源码静态扫描生成的 `SOURCE_API_ACTIONS`,共 91 个唯一 action。
 - 统一新入口为 `POST /api/functions/xiaoshu/app/gateway`。
 - 统一新入口为 `POST /api/functions/xiaoshu/app/gateway`。
 - 请求体统一使用 `{ "token": "<sessionToken>", "params": { "action": "...", ... } }`;执行器会占用顶层 `id`,业务参数不得放在顶层。公开接口可省略 `token`,但仍必须保留 `params`。
 - 请求体统一使用 `{ "token": "<sessionToken>", "params": { "action": "...", ... } }`;执行器会占用顶层 `id`,业务参数不得放在顶层。公开接口可省略 `token`,但仍必须保留 `params`。
-- 已有云函数映射 27 个,另有 64 个以 HTTP 501 和 `migration_blocked: <原因>` 显式拒绝。矩阵不存在未知 action。
+- 已有云函数映射 28 个,另有 63 个以 HTTP 501 和 `migration_blocked: <原因>` 显式拒绝。矩阵不存在未知 action。
 - “已映射”表示已有云函数代码与权限边界;完成 H5 切换前仍需逐页响应字段回归。
 - “已映射”表示已有云函数代码与权限边界;完成 H5 切换前仍需逐页响应字段回归。
 
 
-## 已映射的 27 个 action
+## 已映射的 28 个 action
 
 
 | 领域 | action |
 | 领域 | action |
 |---|---|
 |---|---|
@@ -16,7 +16,7 @@
 | 内容 | `content_get`, `content_list`, `content_list_llk`, `content_uphis`, `node_get`, `node_list` |
 | 内容 | `content_get`, `content_list`, `content_list_llk`, `content_uphis`, `node_get`, `node_list` |
 | 商品只读 | `product_get`, `product_list`, `product_stock_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` |
 | 账号 | `user_get`, `user_info_name`, `user_list`, `user_login_passwd`, `user_register`, `user_update`, `user_update_pwd`, `user_update_pwdall`, `e_user_list` |
-| 学习/预约 | `e_add_words`, `e_ck_list`, `e_get_21list`, `e_order_detail`, `e_record_detail`, `e_words_list`, `user_point_list` |
+| 学习/预约 | `e_add_words`, `e_ck_list`, `e_get_21list`, `e_order_detail`, `e_record_detail`, `e_words_list`, `stu_record_update_v2`, `user_point_list` |
 | 反馈 | `guestbook_add` |
 | 反馈 | `guestbook_add` |
 
 
 已实测 `content_list` 在公司帐套下返回 92,435 条,`modelId=56` 返回 108 条。读取用 Psql 并强制 `company=7pIbDBJmKx`,所有客户端输入均使用参数化查询。
 已实测 `content_list` 在公司帐套下返回 92,435 条,`modelId=56` 返回 108 条。读取用 Psql 并强制 `company=7pIbDBJmKx`,所有客户端输入均使用参数化查询。
@@ -42,6 +42,7 @@
 - `e_words_list`:只返回指定用户 `PracticeRecord.jrscb=1` 的生词,按 `scid -> CommonModel.generalId -> VocabularyWord.id` 联查词库详情,不再误返回 89,620 条全量词库。
 - `e_words_list`:只返回指定用户 `PracticeRecord.jrscb=1` 的生词,按 `scid -> CommonModel.generalId -> VocabularyWord.id` 联查词库详情,不再误返回 89,620 条全量词库。
 - `e_ck_list`:按一个或多个词库单元返回 Model 52 词条,并按 `用户 + 单词 GeneralID` 联查 `PracticeRecord.xxcs/jrscb`,恢复旧页面的 `w_learned` 与生词状态。
 - `e_ck_list`:按一个或多个词库单元返回 Model 52 词条,并按 `用户 + 单词 GeneralID` 联查 `PracticeRecord.xxcs/jrscb`,恢复旧页面的 `w_learned` 与生词状态。
 - `e_add_words`:验证词库 ID 与用户归属后批量更新 `PracticeRecord`;`save=1` 递增学习次数,`ifnew=1/0` 加入/移出生词,首次学习会创建带云端来源标识的进度记录。
 - `e_add_words`:验证词库 ID 与用户归属后批量更新 `PracticeRecord`;`save=1` 递增学习次数,`ifnew=1/0` 加入/移出生词,首次学习会创建带云端来源标识的进度记录。
+- `stu_record_update_v2`:按 `预约 GeneralID + 学员 ID` 幂等新增或更新 Model 56;校验预约归属和词库 ID,根据 `xxqs.check` 重算 `learned/ygg/djq`。线上运行时实际未暴露文档中的 `Psql.transaction`,因此新记录采用安全整数范围内的高熵数字 ID,并顺序写入 `DailyStudyRecord` 与 `CommonModel`;任一步失败都会补偿删除已创建记录,避免孤立数据。
 - `e_order_detail`:以预约内容 `GeneralID` 联查 `CourseAppointment`,返回数组契约、旧字段别名和可恢复的课程名称。
 - `e_order_detail`:以预约内容 `GeneralID` 联查 `CourseAppointment`,返回数组契约、旧字段别名和可恢复的课程名称。
 - `e_get_21list`:以 `MemoryPracticeRecord` 为主记录,联查 Model 60、原学习记录和课程节点;学员按 `yhid`、教练收入列表按 `plid` 隔离。
 - `e_get_21list`:以 `MemoryPracticeRecord` 为主记录,联查 Model 60、原学习记录和课程节点;学员按 `yhid`、教练收入列表按 `plid` 隔离。
 
 
@@ -83,7 +84,7 @@
 |---|---|---|
 |---|---|---|
 | 内容写入 | `content_add`, `content_update` | `CommonModel` 与 addon 表双写事务 |
 | 内容写入 | `content_add`, `content_update` | `CommonModel` 与 addon 表双写事务 |
 | 商品写入 | `product_del`, `product_sale_change`, `product_stock_change` | 门店管理员权限与库存账本 |
 | 商品写入 | `product_del`, `product_sale_change`, `product_stock_change` | 门店管理员权限与库存账本 |
-| 学习写入 | `stu_record_update_v2`, `unit_record_update` | 学习主记录、addon 和单元聚合的原子写入 |
+| 单元进度 | `unit_record_update` | 旧源码在接口调用前无条件 `return`;需业务方确认废弃或提供新的聚合规则 |
 | 预约 | `e_order_tongji`, `e_order_update_v2` | 统计口径、状态机和角色权限 |
 | 预约 | `e_order_tongji`, `e_order_update_v2` | 统计口径、状态机和角色权限 |
 | 发布 | `pub_add`, `pub_list` | `Pub` 业务类型、审核与可见范围 |
 | 发布 | `pub_add`, `pub_list` | `Pub` 业务类型、审核与可见范围 |
 
 
@@ -93,12 +94,12 @@ Angular H5 已启用混合迁移路由:
 
 
 - `user_login_passwd` 已优先调用云函数:已完成 Parse 密码迁移的账号保存真实 `sessionToken` 并用该会话读取 `user_get`;尚未完成密码重置的旧账号在云函数返回 401/403 时自动回退旧登录,不中断存量用户。
 - `user_login_passwd` 已优先调用云函数:已完成 Parse 密码迁移的账号保存真实 `sessionToken` 并用该会话读取 `user_get`;尚未完成密码重置的旧账号在云函数返回 401/403 时自动回退旧登录,不中断存量用户。
 - `app_update`、`node_list`、`node_get` 已切换至云函数。
 - `app_update`、`node_list`、`node_get` 已切换至云函数。
-- 持有真实 Parse 会话时,`e_add_words`、`e_ck_list`、`e_record_detail`、`e_order_detail`、`e_words_list`、`e_get_21list` 已切换至云函数;旧会话继续留在旧端点,避免存量账号在密码重置前中断。
+- 持有真实 Parse 会话时,`e_add_words`、`e_ck_list`、`e_record_detail`、`e_order_detail`、`e_words_list`、`e_get_21list`、`stu_record_update_v2` 已切换至云函数;旧会话继续留在旧端点,避免存量账号在密码重置前中断。
 - 不含 addon 条件的 `content_list` 和 Model 52 公开词库已切换;已持有 Parse 会话的用户会把字段白名单内的 Model 53/54/56/58/59/60/61 addon 查询切到云函数,仍使用旧会话的存量账号自动保留旧端点。
 - 不含 addon 条件的 `content_list` 和 Model 52 公开词库已切换;已持有 Parse 会话的用户会把字段白名单内的 Model 53/54/56/58/59/60/61 addon 查询切到云函数,仍使用旧会话的存量账号自动保留旧端点。
 - 云函数返回同时提供规范化 camelCase 与旧系统 `GeneralID`/`NodeID`/`Title` 等字段别名。
 - 云函数返回同时提供规范化 camelCase 与旧系统 `GeneralID`/`NodeID`/`Title` 等字段别名。
 - `product_list` 尚未切换:当前 `Product` 类只有库存变体字段,缺少旧页依赖的商品名称、价格、图片和正文联表。
 - `product_list` 尚未切换:当前 `Product` 类只有库存变体字段,缺少旧页依赖的商品名称、价格、图片和正文联表。
 
 
-在 64 个阻塞 action 中包含订单、支付和学习写入等主流程时,直接全量切换会导致页面大面积中断。剩余切换条件是:
+在 63 个阻塞 action 中包含订单、支付和预约写入等主流程时,直接全量切换会导致页面大面积中断。剩余切换条件是:
 
 
 1. 先对齐缺失 Schema/数据和第三方凭据。
 1. 先对齐缺失 Schema/数据和第三方凭据。
 2. 完成剩余 action 的云函数事务、权限和字段回归。
 2. 完成剩余 action 的云函数事务、权限和字段回归。

+ 16 - 3
scripts/deploy-admin-functions.mjs

@@ -243,7 +243,7 @@ const BLOCKED = {
   vote_add: '目标 Schema 无可证明的投票记录类', vote_ask: '目标 Schema 无可证明的投票记录类', vote_question: '目标 Schema 无可证明的投票记录类',
   vote_add: '目标 Schema 无可证明的投票记录类', vote_ask: '目标 Schema 无可证明的投票记录类', vote_question: '目标 Schema 无可证明的投票记录类',
   content_add: 'content/addon 双表写入关系尚待字段级回归', content_add_zt: 'content/addon 双表写入关系尚待字段级回归', content_update: 'content/addon 双表写入关系尚待字段级回归',
   content_add: 'content/addon 双表写入关系尚待字段级回归', content_add_zt: 'content/addon 双表写入关系尚待字段级回归', content_update: 'content/addon 双表写入关系尚待字段级回归',
   product_del: '尚未建立门店管理员授权关系', product_stock_change: '尚未建立门店管理员授权关系', product_sale_change: '尚未建立门店管理员授权关系',
   product_del: '尚未建立门店管理员授权关系', product_stock_change: '尚未建立门店管理员授权关系', product_sale_change: '尚未建立门店管理员授权关系',
-  stu_record_update_v2: '需重建学习主记录与 addon 的原子写入', unit_record_update: '需确认单元进度聚合规则', e_order_update_v2: '需确认预约状态机与角色权限', e_order_tongji: '需确认预约统计口径',
+  unit_record_update: '旧源码在调用前无条件 return;需确认是否废弃或提供新的单元聚合规则', e_order_update_v2: '需确认预约状态机与角色权限', e_order_tongji: '需确认预约统计口径',
   e_get_21list_tj: '迁移数据未包含复习收入金额字段或可验证的计价规则',
   e_get_21list_tj: '迁移数据未包含复习收入金额字段或可验证的计价规则',
   pub_add: '需确认 Pub 业务类型与审核规则', pub_list: '需确认 Pub 业务类型与可见范围', user_dept: '目标 Schema 无可证明的用户部门关系',
   pub_add: '需确认 Pub 业务类型与审核规则', pub_list: '需确认 Pub 业务类型与可见范围', user_dept: '目标 Schema 无可证明的用户部门关系',
   user_rnauth_add: '实名认证需新的合规审核与敏感数据存储', user_rnauth_get: '实名认证需新的合规审核与敏感数据存储', user_rnauth_upd: '实名认证需新的合规审核与敏感数据存储',
   user_rnauth_add: '实名认证需新的合规审核与敏感数据存储', user_rnauth_get: '实名认证需新的合规审核与敏感数据存储', user_rnauth_upd: '实名认证需新的合规审核与敏感数据存储',
@@ -345,7 +345,8 @@ async function authorizeBusinessRow(current, row, subjectFields, actorFields = [
 function normalizeWordRow(source) { const addon = source.__addon || {}; const merged = { ...source, ...addon }; delete merged.__addon; return legacyAliases(merged, 'CommonModel'); }
 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) {
 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]);
   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;
+  let source = rows[0]; if (!source && /^[A-Za-z0-9_-]{10,40}$/.test(id)) { const direct = await Psql.query('SELECT a.*, n."nodeName" AS "__courseTitle" FROM "CourseAppointment" a LEFT JOIN "Node" n ON n."company" = $1 AND CAST(n."nodeId" AS text) = CAST(a."kcid" AS text) WHERE a."company" = $1 AND a."objectId" = $2 LIMIT 1', [DEFAULT_COMPANY_ID, id]); if (direct[0]) source = { ...direct[0], generalId: id, GeneralID: id, title: direct[0].title || '课程预约' }; }
+  if (!source) return null; 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) {
 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]);
   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]);
@@ -368,6 +369,17 @@ async function updateLearningWords(input, current) {
   for (const wordId of wordIds) { let item = byWord.get(wordId); if (!item) { if (!kcid) fail(400, '首次记录单词时缺少课程 ID'); item = new Parse.Object('PracticeRecord'); item.set('company', current.get('company')); item.set('sourceKey', 'cloud:e_add_words:' + uid + ':' + wordId); item.set('yhid', uid); item.set('scid', number(wordId)); item.set('kcid', String(kcid)); item.set('xxcs', 0); item.set('jrscb', '0'); created += 1; } else if (kcid && !item.get('kcid')) item.set('kcid', String(kcid)); if (saveLearning) item.set('xxcs', number(item.get('xxcs')) + 1); if (newWordFlag !== null) item.set('jrscb', newWordFlag); changed.push(item); }
   for (const wordId of wordIds) { let item = byWord.get(wordId); if (!item) { if (!kcid) fail(400, '首次记录单词时缺少课程 ID'); item = new Parse.Object('PracticeRecord'); item.set('company', current.get('company')); item.set('sourceKey', 'cloud:e_add_words:' + uid + ':' + wordId); item.set('yhid', uid); item.set('scid', number(wordId)); item.set('kcid', String(kcid)); item.set('xxcs', 0); item.set('jrscb', '0'); created += 1; } else if (kcid && !item.get('kcid')) item.set('kcid', String(kcid)); if (saveLearning) item.set('xxcs', number(item.get('xxcs')) + 1); if (newWordFlag !== null) item.set('jrscb', newWordFlag); changed.push(item); }
   const saved = await Parse.Object.saveAll(changed, { useMasterKey: true }); return { affected: saved.length, created, learnedIncremented: saveLearning ? saved.length : 0, newWordState: newWordFlag === null ? undefined : number(newWordFlag), objectIds: saved.map((item) => item.id) };
   const saved = await Parse.Object.saveAll(changed, { useMasterKey: true }); return { affected: saved.length, created, learnedIncremented: saveLearning ? saved.length : 0, newWordState: newWordFlag === null ? undefined : number(newWordFlag), objectIds: saved.map((item) => item.id) };
 }
 }
+function parseObject(value, label) { if (value && typeof value === 'object' && !Array.isArray(value)) return value; if (typeof value !== 'string' || !value.trim()) return {}; try { const parsed = JSON.parse(value); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; } catch (_) {} fail(400, label + '格式错误'); }
+function cleanText(value, max) { return String(value == null ? '' : value).trim().slice(0, max); }
+async function validateStudyWords(raw) { const words = parseArray(raw); if (words.length > 500) fail(400, '单次学习记录最多 500 个单词'); const normalized = words.map((item) => ({ GeneralID: number(item && (item.GeneralID ?? item.generalId ?? item.id)), Title: cleanText(item && (item.Title ?? item.title), 200), check: number(item && item.check) })).filter((item) => item.GeneralID); if (!normalized.length && words.length) fail(400, '学习记录中的单词 ID 无效'); if (normalized.length) { const ids = [...new Set(normalized.map((item) => String(item.GeneralID)))]; const rows = await Psql.query('SELECT CAST("generalId" AS text) AS id FROM "CommonModel" WHERE "company" = $1 AND CAST("modelId" AS text) = \'52\' AND CAST("generalId" AS text) = ANY($2::text[])', [DEFAULT_COMPANY_ID, ids]); if (new Set(rows.map((row) => String(row.id))).size !== ids.length) fail(400, '学习记录包含不存在的词库 ID'); } return normalized; }
+async function upsertStudyRecord(input, current) {
+  const uid = await authorizeRequestedUser(current, input.uid); const orderId = String(input.orderId || input.id || '').trim(); if (!orderId) fail(400, '缺少预约 ID'); const order = await orderDetailRow(orderId, current); if (!order) fail(404, '预约记录不存在'); const orderOwner = number(order.szyh); if (orderOwner && orderOwner !== uid) fail(403, '预约记录与学员不匹配'); const addon = parseObject(input.addon, '学习记录'); const wordsProvided = addon.xxqs !== undefined; const words = wordsProvided ? await validateStudyWords(addon.xxqs) : null; const inputer = cleanText(input.inputer || current.get('nickname') || current.get('realName') || current.get('username'), 100); const title = cleanText(input.title || inputer, 200) || '学习记录';
+  const rows = await Psql.query('SELECT c."objectId" AS "commonObjectId", c."generalId", d."objectId" AS "recordObjectId" 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(d."dsid" AS text) = $2 AND CAST(d."userId" AS text) = $3 ORDER BY c."updatedAt" DESC LIMIT 1', [DEFAULT_COMPANY_ID, orderId, String(uid)]); const found = rows[0] || null; let record; let common; let created = false;
+  if (found) { record = await new Parse.Query('DailyStudyRecord').get(found.recordObjectId, { useMasterKey: true }); common = await new Parse.Query('CommonModel').get(found.commonObjectId, { useMasterKey: true }); }
+  const applyFields = (target) => { const actorId = ownLegacyId(current); target.set('userId', uid); target.set('dsid', orderId); target.set('pl', actorId && actorId !== uid ? String(actorId) : '0'); if (addon.con !== undefined) target.set('con', cleanText(addon.con, 1000)); if (addon.dqrq !== undefined) { const date = cleanText(addon.dqrq, 20); if (!/^\d{8}$/.test(date)) fail(400, '学习日期必须为 yyyymmdd'); target.set('dqrq', date); } if (addon.szmdid !== undefined) target.set('szmdid', cleanText(addon.szmdid, 100)); if (addon.fxrl !== undefined) target.set('fxrl', cleanText(addon.fxrl, 1000)); if (wordsProvided) { const learned = words.length; target.set('xxqs', JSON.stringify(words)); target.set('learned', learned); target.set('ygg', words.filter((item) => item.check === 1).length); target.set('djq', words.filter((item) => item.check === 2).length); } else { for (const name of ['learned','ygg','djq']) if (addon[name] !== undefined) target.set(name, Math.max(0, number(addon[name]))); } };
+  if (record) { applyFields(record); common.set('title', title); common.set('inputer', inputer); common.set('hits', number(record.get('learned'))); await Parse.Object.saveAll([record, common], { useMasterKey: true }); return { GeneralID: number(found.generalId), generalId: number(found.generalId), objectId: common.id, recordObjectId: record.id, created: false }; }
+  created = true; const baseId = Math.floor(100000000000000 + Math.random() * 800000000000000); const recordId = baseId; const generalId = baseId + 1; record = new Parse.Object('DailyStudyRecord'); record.set('company', current.get('company')); record.set('sourceKey', 'cloud:stu_record:' + uid + ':' + orderId); applyFields(record); await record.save(null, { useMasterKey: true }); try { await Psql.none('UPDATE "DailyStudyRecord" SET "id" = $1 WHERE "objectId" = $2', [recordId, record.id]); common = new Parse.Object('CommonModel'); common.set('company', current.get('company')); common.set('sourceKey', 'cloud:stu_record:' + uid + ':' + orderId); common.set('generalId', generalId); common.set('itemId', recordId); common.set('modelId', 56); common.set('nodeId', 291); common.set('tableName', 'ZL_C_ss'); common.set('title', title); common.set('inputer', inputer); common.set('hits', number(record.get('learned'))); common.set('status', 99); await common.save(null, { useMasterKey: true }); } catch (error) { if (common && common.id) await common.destroy({ useMasterKey: true }).catch(() => undefined); await record.destroy({ useMasterKey: true }).catch(() => undefined); throw error; } return { GeneralID: generalId, generalId, objectId: common.id, recordObjectId: record.id, created };
+}
 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); }
 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) {
 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 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]);
@@ -377,7 +389,7 @@ async function resolveContent(input, requireOne = false) { const fields = await
 async function handler(request, response) {
 async function handler(request, response) {
   try {
   try {
     const input = inputOf(request); const action = String(input.action || ''); if (!action) return response.status(400).json(reject('缺少 action'));
     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_add_words','e_ck_list','e_get_21list','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_add_words','e_ck_list','e_get_21list','e_record_detail','e_order_detail','e_words_list','stu_record_update_v2','user_point_list','guestbook_add'], blocked: BLOCKED }));
     if (BLOCKED[action]) return response.status(501).json(reject('migration_blocked: ' + BLOCKED[action]));
     if (BLOCKED[action]) return response.status(501).json(reject('migration_blocked: ' + BLOCKED[action]));
     if (action === 'user_login_passwd') {
     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('请输入账号和密码'));
       const username = String(input.name || input.username || '').trim(); const password = String(input.passwd || input.password || ''); if (!username || !password) return response.status(400).json(reject('请输入账号和密码'));
@@ -403,6 +415,7 @@ async function handler(request, response) {
     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_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 })); }
     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 = ownLegacyId(current);
     const legacyId = ownLegacyId(current);
+    if (action === 'stu_record_update_v2') return response.json(envelope(await upsertStudyRecord(input, current)));
     if (action === 'e_add_words') return response.json(envelope(await updateLearningWords(input, current)));
     if (action === 'e_add_words') return response.json(envelope(await updateLearningWords(input, current)));
     if (action === 'e_ck_list') { const result = await courseWordPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }
     if (action === 'e_ck_list') { const result = await courseWordPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }
     if (action === 'e_get_21list') { const result = await memoryPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }
     if (action === 'e_get_21list') { const result = await memoryPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }

+ 34 - 0
scripts/smoke-admin-functions.mjs

@@ -58,6 +58,7 @@ async function sourceActions() {
 let userId = '';
 let userId = '';
 let memberId = '';
 let memberId = '';
 let memberLegacyId = 0;
 let memberLegacyId = 0;
+let temporaryAppointmentId = '';
 try {
 try {
   const sample = await jsonRequest(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
   const sample = await jsonRequest(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
   const companyId = sample.results?.[0]?.objectId;
   const companyId = sample.results?.[0]?.objectId;
@@ -86,6 +87,11 @@ try {
     body: JSON.stringify({ username: memberUsername, password: memberPassword, legacyUserId: memberLegacyId, company }),
     body: JSON.stringify({ username: memberUsername, password: memberPassword, legacyUserId: memberLegacyId, company }),
   }, true);
   }, true);
   memberId = member.objectId;
   memberId = member.objectId;
+  const createdAppointment = await jsonRequest(`${PARSE_URL}/classes/CourseAppointment`, {
+    method: 'POST',
+    body: JSON.stringify({ company, sourceKey: `cloud:smoke-appointment:${memberLegacyId}`, szyh: String(memberLegacyId), kcid: '16', dszt: '0', pl: '0' }),
+  }, true);
+  temporaryAppointmentId = createdAppointment.objectId;
   const memberLogin = await jsonRequest(`${PARSE_URL}/login`, {
   const memberLogin = await jsonRequest(`${PARSE_URL}/login`, {
     method: 'POST',
     method: 'POST',
     body: JSON.stringify({ username: memberUsername, password: memberPassword }),
     body: JSON.stringify({ username: memberUsername, password: memberPassword }),
@@ -166,6 +172,16 @@ try {
   await callLegacyFunction(memberLogin.sessionToken, { action: 'e_add_words', uid: memberLegacyId, wordsId: '2505', kcid: 16, ifnew: 0 });
   await callLegacyFunction(memberLogin.sessionToken, { action: 'e_add_words', uid: memberLegacyId, wordsId: '2505', kcid: 16, ifnew: 0 });
   const removedNewWord = await callLegacyFunction(memberLogin.sessionToken, { action: 'e_words_list', uid: memberLegacyId, page: 1, pageSize: 10 });
   const removedNewWord = await callLegacyFunction(memberLogin.sessionToken, { action: 'e_words_list', uid: memberLegacyId, page: 1, pageSize: 10 });
   if (removedNewWord.page?.itemCount !== 0) throw new Error('移出生词写后读校验失败');
   if (removedNewWord.page?.itemCount !== 0) throw new Error('移出生词写后读校验失败');
+  const studyAddon = { con: '云函数学习记录冒烟测试', dqrq: '20260818', UserID: memberLegacyId, learned: 1, ygg: 0, djq: 0, xxqs: JSON.stringify([{ GeneralID: 2505, Title: 'woman', check: 0 }]) };
+  const createdStudy = await callLegacyFunction(memberLogin.sessionToken, { action: 'stu_record_update_v2', orderId: temporaryAppointmentId, uid: memberLegacyId, inputer: memberUsername, addon: JSON.stringify(studyAddon) });
+  if (!createdStudy.result?.created || !createdStudy.result?.GeneralID || !createdStudy.result?.recordObjectId) throw new Error('预约学习记录首次双表写入校验失败');
+  const studyDetail = await callLegacyFunction(memberLogin.sessionToken, { action: 'e_record_detail', id: createdStudy.result.GeneralID });
+  if (studyDetail.result?.[0]?.detail?.[0]?.Title !== 'woman') throw new Error('预约学习记录写后详情校验失败');
+  studyAddon.xxqs = JSON.stringify([{ GeneralID: 2505, Title: 'woman', check: 1 }]);
+  const updatedStudy = await callLegacyFunction(memberLogin.sessionToken, { action: 'stu_record_update_v2', orderId: temporaryAppointmentId, uid: memberLegacyId, inputer: memberUsername, addon: JSON.stringify(studyAddon) });
+  if (updatedStudy.result?.created || updatedStudy.result?.GeneralID !== createdStudy.result.GeneralID) throw new Error('预约学习记录幂等更新校验失败');
+  const studyList = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_list', modelId: 56, myfield2: `UserId=${memberLegacyId}|dsid=${temporaryAppointmentId}`, page: 1, pageSize: 2 });
+  if (studyList.page?.itemCount !== 1 || Number(studyList.result?.[0]?.ygg) !== 1 || Number(studyList.result?.[0]?.learned) !== 1) throw new Error('预约学习记录聚合字段写后读校验失败');
   const deniedCourseWords = await callLegacyFunction(memberLogin.sessionToken, { action: 'e_ck_list', uid: 58, nids: 40, page: 1, pageSize: 1 }, 403);
   const deniedCourseWords = await callLegacyFunction(memberLogin.sessionToken, { action: 'e_ck_list', uid: 58, nids: 40, page: 1, pageSize: 1 }, 403);
   if (!String(deniedCourseWords.retmsg).includes('无权')) throw new Error('普通用户跨用户课程词库未被拒绝');
   if (!String(deniedCourseWords.retmsg).includes('无权')) throw new Error('普通用户跨用户课程词库未被拒绝');
   const denied = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_list', modelId: 56, myfield2: 'UserId=3', page: 1, pageSize: 1 }, 403);
   const denied = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_list', modelId: 56, myfield2: 'UserId=3', page: 1, pageSize: 1 }, 403);
@@ -179,6 +195,24 @@ try {
 
 
   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.');
   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 {
 } finally {
+  if (memberLegacyId) {
+    const commonWhere = encodeURIComponent(JSON.stringify({ sourceKey: { $regex: `^cloud:stu_record:${memberLegacyId}:` } }));
+    const commonRows = await jsonRequest(`${PARSE_URL}/classes/CommonModel?where=${commonWhere}&limit=1000`, {}, true).catch(() => ({ results: [] }));
+    for (const row of commonRows.results || []) await jsonRequest(`${PARSE_URL}/classes/CommonModel/${row.objectId}`, { method: 'DELETE' }, true).catch((error) => {
+      console.error(`临时学习主记录清理失败:${error.message}`);
+      process.exitCode = 1;
+    });
+    const studyWhere = encodeURIComponent(JSON.stringify({ userId: memberLegacyId }));
+    const studyRows = await jsonRequest(`${PARSE_URL}/classes/DailyStudyRecord?where=${studyWhere}&limit=1000`, {}, true).catch(() => ({ results: [] }));
+    for (const row of studyRows.results || []) await jsonRequest(`${PARSE_URL}/classes/DailyStudyRecord/${row.objectId}`, { method: 'DELETE' }, true).catch((error) => {
+      console.error(`临时学习 addon 清理失败:${error.message}`);
+      process.exitCode = 1;
+    });
+  }
+  if (temporaryAppointmentId) await jsonRequest(`${PARSE_URL}/classes/CourseAppointment/${temporaryAppointmentId}`, { method: 'DELETE' }, true).catch((error) => {
+    console.error(`临时预约清理失败:${error.message}`);
+    process.exitCode = 1;
+  });
   if (memberLegacyId) {
   if (memberLegacyId) {
     const where = encodeURIComponent(JSON.stringify({ yhid: String(memberLegacyId) }));
     const where = encodeURIComponent(JSON.stringify({ yhid: String(memberLegacyId) }));
     const practices = await jsonRequest(`${PARSE_URL}/classes/PracticeRecord?where=${where}&limit=1000`, {}, true).catch(() => ({ results: [] }));
     const practices = await jsonRequest(`${PARSE_URL}/classes/PracticeRecord?where=${where}&limit=1000`, {}, true).catch(() => ({ results: [] }));

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

@@ -88,6 +88,14 @@ describe('ApiService', () => {
     request.flush({ retcode: 0, result: { affected: 2, newWordState: 1 } });
     request.flush({ retcode: 0, result: { affected: 2, newWordState: 1 } });
   });
   });
 
 
+  it('routes study record upserts with a Parse session', () => {
+    sessionToken = 'r:session-token';
+    api.post('stu_record_update_v2', { orderId: 918, uid: 23, addon: '{"xxqs":[]}' }).subscribe();
+    const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'stu_record_update_v2', orderId: 918, uid: 23, addon: '{"xxqs":[]}' } });
+    request.flush({ retcode: 0, result: { GeneralID: 123456, created: true } });
+  });
+
   it('keeps learning detail reads on legacy until a real Parse session exists', () => {
   it('keeps learning detail reads on legacy until a real Parse session exists', () => {
     sessionToken = 'legacy-session';
     sessionToken = 'legacy-session';
     api.get('e_words_list', { uid: 58 }).subscribe();
     api.get('e_words_list', { uid: 58 }).subscribe();

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

@@ -17,6 +17,7 @@ const SESSION_CLOUD_ROUTED_ACTIONS = new Set([
   'e_order_detail',
   'e_order_detail',
   'e_record_detail',
   'e_record_detail',
   'e_words_list',
   'e_words_list',
+  'stu_record_update_v2',
 ]);
 ]);
 
 
 const CONTENT_MODEL_FIELDS: Record<number, Set<string>> = {
 const CONTENT_MODEL_FIELDS: Record<number, Set<string>> = {