Преглед изворни кода

feat: migrate normalized content creation

彭峰 пре 2 месеци
родитељ
комит
d385049383

+ 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:30 个已映射,源码清单内 61 个显式返回 501 阻塞原因;动态调用的兼容 action `content_add_zt` 也已映射,`product_add`、`product_upd` 仍显式阻塞,因此线上为 31 个已实现、63 个阻塞项。课程词库进度、学习次数/生词状态、带 15 段复习计划的学习记录双表写入、7 类规范化内容更新及旧版课次/时长/佣金统计公式均已完成;预约结束还涉及课时账本与抗遗忘批量创建,继续显式阻塞,避免半写。详见 `wxapp-cloud-action-matrix.md`。
+- 第 9 个 `xiaoshu.app.gateway` 已覆盖旧 H5 源码的全部 91 个 action:31 个已映射,源码清单内 60 个显式返回 501 阻塞原因;动态调用的兼容 action `content_add_zt` 也已映射,`product_add`、`product_upd` 仍显式阻塞,因此线上为 32 个已实现、62 个阻塞项。课程词库进度、学习次数/生词状态、带 15 段复习计划的学习记录双表写入、7 类规范化内容新增/更新及旧版课次/时长/佣金统计公式均已完成;缺副表模型按请求明确 501,预约结束还涉及课时账本与抗遗忘批量创建,继续阻塞以避免半写。详见 `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 仍由显式迁移路由保留旧端点。
 
 

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

@@ -5,7 +5,7 @@
 - 基线来自旧 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`。
-- 源码清单内已有云函数映射 30 个,另有 61 个以 HTTP 501 和 `migration_blocked: <原因>` 显式拒绝。动态调用的兼容 action `content_add_zt` 也已映射;`product_add`、`product_upd` 仍为兼容阻塞项,因此线上共 31 个已实现、63 个阻塞项。矩阵不存在未覆盖的源码 action。
+- 源码清单内已有云函数映射 31 个,另有 60 个以 HTTP 501 和 `migration_blocked: <原因>` 显式拒绝。动态调用的兼容 action `content_add_zt` 也已映射;`product_add`、`product_upd` 仍为兼容阻塞项,因此线上共 32 个已实现、62 个阻塞项。矩阵不存在未覆盖的源码 action。
 - “已映射”表示已有云函数代码与权限边界;完成 H5 切换前仍需逐页响应字段回归。
 - “已映射”表示已有云函数代码与权限边界;完成 H5 切换前仍需逐页响应字段回归。
 
 
 ## 已映射的 29 个 action
 ## 已映射的 29 个 action
@@ -13,7 +13,7 @@
 | 领域 | action |
 | 领域 | action |
 |---|---|
 |---|---|
 | 版本 | `app_update` |
 | 版本 | `app_update` |
-| 内容 | `content_get`, `content_list`, `content_list_llk`, `content_update`, `content_uphis`, `node_get`, `node_list` |
+| 内容 | `content_add`, `content_get`, `content_list`, `content_list_llk`, `content_update`, `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` |
 | 学习/预约 | `content_add_zt`, `e_add_words`, `e_ck_list`, `e_get_21list`, `e_order_detail`, `e_order_tongji`, `e_record_detail`, `e_words_list`, `stu_record_update_v2`, `user_point_list` |
 | 学习/预约 | `content_add_zt`, `e_add_words`, `e_ck_list`, `e_get_21list`, `e_order_detail`, `e_order_tongji`, `e_record_detail`, `e_words_list`, `stu_record_update_v2`, `user_point_list` |
@@ -45,6 +45,7 @@
 - `stu_record_update_v2`:按 `预约 GeneralID + 学员 ID` 幂等新增或更新 Model 56;校验预约归属和词库 ID,根据 `xxqs.check` 重算 `learned/ygg/djq`。线上运行时实际未暴露文档中的 `Psql.transaction`,因此新记录采用安全整数范围内的高熵数字 ID,并顺序写入 `DailyStudyRecord` 与 `CommonModel`;任一步失败都会补偿删除已创建记录,避免孤立数据。
 - `stu_record_update_v2`:按 `预约 GeneralID + 学员 ID` 幂等新增或更新 Model 56;校验预约归属和词库 ID,根据 `xxqs.check` 重算 `learned/ygg/djq`。线上运行时实际未暴露文档中的 `Psql.transaction`,因此新记录采用安全整数范围内的高熵数字 ID,并顺序写入 `DailyStudyRecord` 与 `CommonModel`;任一步失败都会补偿删除已创建记录,避免孤立数据。
 - `content_add_zt`:恢复旧源码专用于 Model 56 的学习记录创建逻辑,按北京时间当天生成 `+1/+2/+3/+5/+7/+9/+11/+14/+17/+21/+30/+40/+50/+60/+90` 共 15 个 `fxrl` 日期;验证用户归属和词库 ID 后补偿式双写 `DailyStudyRecord + CommonModel`,不把缺 Schema 的其他内容模型混入该接口。
 - `content_add_zt`:恢复旧源码专用于 Model 56 的学习记录创建逻辑,按北京时间当天生成 `+1/+2/+3/+5/+7/+9/+11/+14/+17/+21/+30/+40/+50/+60/+90` 共 15 个 `fxrl` 日期;验证用户归属和词库 ID 后补偿式双写 `DailyStudyRecord + CommonModel`,不把缺 Schema 的其他内容模型混入该接口。
 - `content_update`:按 `GeneralID` 解析现有 Model 53/54/56/58/59/60/61 的规范化副表,先以副表原有学员/陪练字段鉴权,再按实际 Parse Schema 转换类型并批量更新主副表;拒绝变更所属用户,失败时恢复更新前字段。Model 59 仅开放已有 `ZL_C_skjl` 对应的 Node 296,缺独立 Schema 的 Node 77 继续返回 501。
 - `content_update`:按 `GeneralID` 解析现有 Model 53/54/56/58/59/60/61 的规范化副表,先以副表原有学员/陪练字段鉴权,再按实际 Parse Schema 转换类型并批量更新主副表;拒绝变更所属用户,失败时恢复更新前字段。Model 59 仅开放已有 `ZL_C_skjl` 对应的 Node 296,缺独立 Schema 的 Node 77 继续返回 501。
+- `content_add`:对 Model 53/54/56/58/59/60/61 按目标 Schema 白名单转换 addon 字段,验证所属学员与业务操作人后补偿式新增副表和 `CommonModel`,返回旧契约字符串 `GeneralID`。Model 55、57 及 Model 59/Node 77 因目标库没有对应副表而逐请求返回 501,不把数据错写到相似模型。
 - `e_order_detail`:以预约内容 `GeneralID` 联查 `CourseAppointment`,返回数组契约、旧字段别名和可恢复的课程名称。
 - `e_order_detail`:以预约内容 `GeneralID` 联查 `CourseAppointment`,返回数组契约、旧字段别名和可恢复的课程名称。
 - `e_order_tongji`:按交付包 `Pages/API/WXAPP.cshtml` 原公式恢复六个字符串字段。陪练按 `LessonRecord.jsmz` 统计课型 1/2/3,佣金分别为 20/40/40、时长分别为 0.5/1/1 小时;学员按 `CourseAppointment.szyh` 且 `dszt>10` 统计课型和时长,`t_total` 继续按旧逻辑统计 `PracticeRecord.yhid`。
 - `e_order_tongji`:按交付包 `Pages/API/WXAPP.cshtml` 原公式恢复六个字符串字段。陪练按 `LessonRecord.jsmz` 统计课型 1/2/3,佣金分别为 20/40/40、时长分别为 0.5/1/1 小时;学员按 `CourseAppointment.szyh` 且 `dszt>10` 统计课型和时长,`t_total` 继续按旧逻辑统计 `PracticeRecord.yhid`。
 - `e_get_21list`:以 `MemoryPracticeRecord` 为主记录,联查 Model 60、原学习记录和课程节点;学员按 `yhid`、教练收入列表按 `plid` 隔离。
 - `e_get_21list`:以 `MemoryPracticeRecord` 为主记录,联查 Model 60、原学习记录和课程节点;学员按 `yhid`、教练收入列表按 `plid` 隔离。
@@ -85,7 +86,7 @@
 
 
 | 能力 | action | 待确认/待实现 |
 | 能力 | action | 待确认/待实现 |
 |---|---|---|
 |---|---|---|
-| 内容新增 | `content_add` | Model 55/57 与 Node 77 缺少目标副表;其余模型仍需完成逐模型新增权限与补偿写入 |
+| 缺失内容副表 | `content_add` 的 Model 55、57 与 Model 59/Node 77 分支 | 数据库迁移未提供生词集合、单元聚合与连连看成绩目标类;接口逐请求返回 501 |
 | 商品写入 | `product_del`, `product_sale_change`, `product_stock_change` | 门店管理员权限与库存账本 |
 | 商品写入 | `product_del`, `product_sale_change`, `product_stock_change` | 门店管理员权限与库存账本 |
 | 单元进度 | `unit_record_update` | 旧源码在接口调用前无条件 `return`;需业务方确认废弃或提供新的聚合规则 |
 | 单元进度 | `unit_record_update` | 旧源码在接口调用前无条件 `return`;需业务方确认废弃或提供新的聚合规则 |
 | 预约状态 | `e_order_update_v2` | 旧状态 11 同时扣减 `_User.legacyUserData` 课时、写余额账本并按学习记录创建最多 15 条抗遗忘双表记录;云运行时实际没有事务,需完成可补偿的一致性方案 |
 | 预约状态 | `e_order_update_v2` | 旧状态 11 同时扣减 `_User.legacyUserData` 课时、写余额账本并按学习记录创建最多 15 条抗遗忘双表记录;云运行时实际没有事务,需完成可补偿的一致性方案 |
@@ -97,7 +98,7 @@ 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 会话时,`content_add_zt`、`content_update`、`e_add_words`、`e_ck_list`、`e_order_detail`、`e_order_tongji`、`e_record_detail`、`e_words_list`、`e_get_21list`、`stu_record_update_v2` 已切换至云函数;旧会话继续留在旧端点,避免存量账号在密码重置前中断。
+- 持有真实 Parse 会话时,`content_add`、`content_add_zt`、`content_update`、`e_add_words`、`e_ck_list`、`e_order_detail`、`e_order_tongji`、`e_record_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` 类只有库存变体字段,缺少旧页依赖的商品名称、价格、图片和正文联表。

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

@@ -241,7 +241,6 @@ const BLOCKED = {
   user_group_usr_supply: '目标 Schema 无会员续费订单类', user_group_usr_upgrade: '目标 Schema 无会员升级订单类', user_shop_order: '目标 Schema 无订单类', user_shop_sales: '目标 Schema 无销售明细类',
   user_group_usr_supply: '目标 Schema 无会员续费订单类', user_group_usr_upgrade: '目标 Schema 无会员升级订单类', user_shop_order: '目标 Schema 无订单类', user_shop_sales: '目标 Schema 无销售明细类',
   user_star_add: '目标 Schema 无收藏关系类', user_star_del: '目标 Schema 无收藏关系类', user_star_is: '目标 Schema 无收藏关系类', user_update_paypwd: '需要独立支付密码哈希服务',
   user_star_add: '目标 Schema 无收藏关系类', user_star_del: '目标 Schema 无收藏关系类', user_star_is: '目标 Schema 无收藏关系类', user_update_paypwd: '需要独立支付密码哈希服务',
   vote_add: '目标 Schema 无可证明的投票记录类', vote_ask: '目标 Schema 无可证明的投票记录类', vote_question: '目标 Schema 无可证明的投票记录类',
   vote_add: '目标 Schema 无可证明的投票记录类', vote_ask: '目标 Schema 无可证明的投票记录类', vote_question: '目标 Schema 无可证明的投票记录类',
-  content_add: '部分内容模型副表未迁移,新增动作尚未完成逐模型权限与补偿写入',
   product_del: '尚未建立门店管理员授权关系', product_stock_change: '尚未建立门店管理员授权关系', product_sale_change: '尚未建立门店管理员授权关系',
   product_del: '尚未建立门店管理员授权关系', product_stock_change: '尚未建立门店管理员授权关系', product_sale_change: '尚未建立门店管理员授权关系',
   unit_record_update: '旧源码在调用前无条件 return;需确认是否废弃或提供新的单元聚合规则', e_order_update_v2: '状态 11 还需原子扣减课时、写余额账本并创建 21 天抗遗忘记录;云运行时未提供事务',
   unit_record_update: '旧源码在调用前无条件 return;需确认是否废弃或提供新的单元聚合规则', e_order_update_v2: '状态 11 还需原子扣减课时、写余额账本并创建 21 天抗遗忘记录;云运行时未提供事务',
   e_get_21list_tj: '迁移数据未包含复习收入金额字段或可验证的计价规则',
   e_get_21list_tj: '迁移数据未包含复习收入金额字段或可验证的计价规则',
@@ -285,8 +284,8 @@ const CONTENT_ADDONS = {
 const NODE_MODELS = { 28:58, 29:54, 32:53, 291:56, 296:59, 327:52, 388:60, 389:61 };
 const NODE_MODELS = { 28:58, 29:54, 32:53, 291:56, 296:59, 327:52, 388:60, 389:61 };
 const PRIVATE_CONTENT_MODELS = new Set([53,54,56,58,59,60,61]);
 const PRIVATE_CONTENT_MODELS = new Set([53,54,56,58,59,60,61]);
 const CONTENT_WRITE_AUTH = {
 const CONTENT_WRITE_AUTH = {
-  53: { subjects:['yhid'], actors:[] }, 54: { subjects:['szyh'], actors:['pl'] }, 56: { subjects:['userId'], actors:['pl'] }, 58: { subjects:['yhid'], actors:[] },
-  59: { subjects:['xymz'], actors:['jsmz'] }, 60: { subjects:['yhid'], actors:['plid'] }, 61: { subjects:['userId'], actors:[] }
+  53: { subjects:['yhid'], actors:[], tableName:'ZL_C_lxjl', nodeId:32 }, 54: { subjects:['szyh'], actors:['pl'], tableName:'ZL_C_order', nodeId:29 }, 56: { subjects:['userId'], actors:['pl'], tableName:'ZL_C_ss', nodeId:291 }, 58: { subjects:['yhid'], actors:[], tableName:'ZL_C_kcbd', nodeId:28 },
+  59: { subjects:['xymz'], actors:['jsmz'], tableName:'ZL_C_skjl', nodeId:296 }, 60: { subjects:['yhid'], actors:['plid'], tableName:'ZL_C_kywjl', nodeId:388 }, 61: { subjects:['userId'], actors:[], tableName:'ZL_C_cespj', nodeId:389 }
 };
 };
 function contentModel(input, nodeIds) { const explicit = number(input.modelId || input.modelid || input.ModelID); return explicit || (nodeIds.length === 1 ? NODE_MODELS[number(nodeIds[0])] || 0 : 0); }
 function contentModel(input, nodeIds) { const explicit = number(input.modelId || input.modelid || input.ModelID); return explicit || (nodeIds.length === 1 ? NODE_MODELS[number(nodeIds[0])] || 0 : 0); }
 function requestedContentModel(input) { const nodes = String(input.nodeid || input.nid || input.nodes || '').split(',').map(number).filter(Boolean); return contentModel(input, nodes); }
 function requestedContentModel(input) { const nodes = String(input.nodeid || input.nid || input.nodes || '').split(',').map(number).filter(Boolean); return contentModel(input, nodes); }
@@ -412,6 +411,14 @@ async function updateContentPair(input, current) {
   const commonSnapshot = snapshotFields(common, ['upDateTime','title','template','createTime']); const commonChanged = ['upDateTime']; common.set('upDateTime', new Date()); const title = content.Title ?? content.title; if (title !== undefined && String(title).trim()) { common.set('title', cleanText(title, 200)); commonChanged.push('title'); } const template = content.Template ?? content.template; if (template !== undefined && template !== null) { common.set('template', String(template) === '-100' ? '' : cleanText(template, 500)); commonChanged.push('template'); } const createTime = content.CreateTime ?? content.createTime; if (createTime) { const date = new Date(createTime); if (Number.isNaN(date.getTime()) || date.getUTCFullYear() <= 1970) fail(400, '内容创建时间格式错误'); common.set('createTime', date); commonChanged.push('createTime'); }
   const commonSnapshot = snapshotFields(common, ['upDateTime','title','template','createTime']); const commonChanged = ['upDateTime']; common.set('upDateTime', new Date()); const title = content.Title ?? content.title; if (title !== undefined && String(title).trim()) { common.set('title', cleanText(title, 200)); commonChanged.push('title'); } const template = content.Template ?? content.template; if (template !== undefined && template !== null) { common.set('template', String(template) === '-100' ? '' : cleanText(template, 500)); commonChanged.push('template'); } const createTime = content.CreateTime ?? content.createTime; if (createTime) { const date = new Date(createTime); if (Number.isNaN(date.getTime()) || date.getUTCFullYear() <= 1970) fail(400, '内容创建时间格式错误'); common.set('createTime', date); commonChanged.push('createTime'); }
   if (!addonChanged.length && commonChanged.length === 1) fail(400, '没有可更新的内容字段'); try { await Parse.Object.saveAll([addon, common], { useMasterKey: true }); } catch (error) { restoreFields(addon, addonSnapshot); restoreFields(common, commonSnapshot); await Parse.Object.saveAll([addon, common], { useMasterKey: true }).catch(() => undefined); throw error; } return String(common.get('generalId'));
   if (!addonChanged.length && commonChanged.length === 1) fail(400, '没有可更新的内容字段'); try { await Parse.Object.saveAll([addon, common], { useMasterKey: true }); } catch (error) { restoreFields(addon, addonSnapshot); restoreFields(common, commonSnapshot); await Parse.Object.saveAll([addon, common], { useMasterKey: true }).catch(() => undefined); throw error; } return String(common.get('generalId'));
 }
 }
+async function createContentPair(input, current) {
+  const content = parseObject(input.content, '内容'); const addonInput = parseObject(input.addon, '附表内容'); const modelId = number(content.ModelID ?? content.modelId); const config = CONTENT_ADDONS[modelId]; const auth = CONTENT_WRITE_AUTH[modelId]; if (!config || !auth) fail(501, '目标 Schema 未迁移内容模型 ' + modelId); const nodeId = number(content.NodeID ?? content.nodeId, auth.nodeId); if (modelId === 59 && nodeId !== 296) fail(501, 'Model 59 的非课次节点缺少独立目标 Schema');
+  const ownerField = auth.subjects[0]; const ownerRequested = Object.entries(addonInput).find(([key]) => writeAddonField(config, key) === ownerField); const ownerId = number(ownerRequested && ownerRequested[1]); if (!ownerId) fail(400, '附表缺少所属用户字段 ' + ownerField); await authorizeRequestedUser(current, ownerId); const actorId = ownLegacyId(current); const schema = await fieldsOf(config.className); const recordId = Math.floor(100000000000000 + Math.random() * 800000000000000); const generalId = recordId + 1; let addon; let common;
+  addon = new Parse.Object(config.className); addon.set('company', current.get('company')); addon.set('sourceKey', 'cloud:content_add:' + ownerId + ':' + generalId); for (const [requested,value] of Object.entries(addonInput)) { if (String(requested).toLowerCase() === 'id') continue; const field = writeAddonField(config, requested); if (!field || !schema[field] || ['id','company','sourceKey'].includes(field)) fail(400, '不允许写入附表字段 ' + requested); if (auth.subjects.includes(field) && number(value) !== ownerId) fail(403, '不允许变更内容所属用户'); if (auth.actors.includes(field) && number(value) && !isAdminUser(current) && number(value) !== actorId) fail(403, '不允许冒用其他业务操作人'); addon.set(field, writeValue(value, schema[field])); }
+  if (modelId === 56 && addonInput.xxqs !== undefined) { const words = await validateStudyWords(addonInput.xxqs); addon.set('xxqs', JSON.stringify(words)); addon.set('learned', words.length); addon.set('ygg', words.filter((item) => item.check === 1).length); addon.set('djq', words.filter((item) => item.check === 2).length); }
+  await addon.save(null, { useMasterKey: true }); try { await Psql.none('UPDATE "' + config.className + '" SET "id" = $1 WHERE "objectId" = $2', [recordId, addon.id]); common = new Parse.Object('CommonModel'); common.set('company', current.get('company')); common.set('sourceKey', 'cloud:content_add:' + ownerId + ':' + generalId); common.set('generalId', generalId); common.set('itemId', recordId); common.set('modelId', modelId); common.set('nodeId', nodeId || auth.nodeId); common.set('tableName', auth.tableName); common.set('title', cleanText(content.Title ?? content.title, 200) || cleanText(current.get('nickname') || current.get('username'), 200) || '内容记录'); common.set('inputer', cleanText(content.Inputer ?? content.inputer ?? current.get('username'), 100)); common.set('hits', number(content.Hits ?? content.hits)); common.set('status', number(content.Status ?? content.status, 99)); if (content.Template ?? content.template) common.set('template', cleanText(content.Template ?? content.template, 500)); await common.save(null, { useMasterKey: true }); } catch (error) { if (common && common.id) await common.destroy({ useMasterKey: true }).catch(() => undefined); await addon.destroy({ useMasterKey: true }).catch(() => undefined); throw error; }
+  return String(generalId);
+}
 async function orderStatistics(input, current) {
 async function orderStatistics(input, current) {
   const uid = await authorizeRequestedUser(current, input.uid); let target = current; if (uid !== ownLegacyId(current)) { const fields = await fieldsOf('_User'); target = await findByLegacyId('_User', fields, uid, ['legacyUserId','userid','num']); } if (!target) fail(404, '用户不存在'); const groupId = number(target.get('legacyGroupId') || target.get('groupId')); let t30 = 0; let t60 = 0; let tTiyan = 0; let total = 0; let commission = 0; let duration = 0;
   const uid = await authorizeRequestedUser(current, input.uid); let target = current; if (uid !== ownLegacyId(current)) { const fields = await fieldsOf('_User'); target = await findByLegacyId('_User', fields, uid, ['legacyUserId','userid','num']); } if (!target) fail(404, '用户不存在'); const groupId = number(target.get('legacyGroupId') || target.get('groupId')); let t30 = 0; let t60 = 0; let tTiyan = 0; let total = 0; let commission = 0; let duration = 0;
   if (groupId === 3) { const row = await Psql.one('SELECT COUNT(*) FILTER (WHERE CAST("kclx" AS text) = \'1\')::int AS t30, COUNT(*) FILTER (WHERE CAST("kclx" AS text) = \'2\')::int AS t60, COUNT(*) FILTER (WHERE CAST("kclx" AS text) = \'3\')::int AS tiyan, COUNT(*)::int AS total FROM "LessonRecord" WHERE "company" = $1 AND CAST("jsmz" AS text) = $2', [DEFAULT_COMPANY_ID, String(uid)]); t30 = number(row.t30); t60 = number(row.t60); tTiyan = number(row.tiyan); total = number(row.total); commission = t30 * 20 + t60 * 40 + tTiyan * 40; duration = t30 * 0.5 + t60 + tTiyan; }
   if (groupId === 3) { const row = await Psql.one('SELECT COUNT(*) FILTER (WHERE CAST("kclx" AS text) = \'1\')::int AS t30, COUNT(*) FILTER (WHERE CAST("kclx" AS text) = \'2\')::int AS t60, COUNT(*) FILTER (WHERE CAST("kclx" AS text) = \'3\')::int AS tiyan, COUNT(*)::int AS total FROM "LessonRecord" WHERE "company" = $1 AND CAST("jsmz" AS text) = $2', [DEFAULT_COMPANY_ID, String(uid)]); t30 = number(row.t30); t60 = number(row.t60); tTiyan = number(row.tiyan); total = number(row.total); commission = t30 * 20 + t60 * 40 + tTiyan * 40; duration = t30 * 0.5 + t60 + tTiyan; }
@@ -427,7 +434,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','content_add_zt','content_update','node_list','node_get','product_list','product_get','product_stock_list','e_add_words','e_ck_list','e_get_21list','e_order_detail','e_order_tongji','e_record_detail','e_words_list','stu_record_update_v2','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','content_add','content_add_zt','content_update','node_list','node_get','product_list','product_get','product_stock_list','e_add_words','e_ck_list','e_get_21list','e_order_detail','e_order_tongji','e_record_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('请输入账号和密码'));
@@ -446,6 +453,7 @@ async function handler(request, response) {
     if (action === 'content_list' || action === 'content_list_llk') { const result = await contentPage(input, !current); return response.json(envelope(result.rows, undefined, result.page)); }
     if (action === 'content_list' || action === 'content_list_llk') { const result = await contentPage(input, !current); return response.json(envelope(result.rows, undefined, result.page)); }
     if (action === 'content_get') { const id = String(input.id || input.gid || input.generalId || ''); if (!id) return response.status(400).json(reject('缺少内容 ID')); const identity = /^\d+$/.test(id) ? { generalId: id } : { objectId: id }; const base = await contentPage({ page: 1, pageSize: 1, ...identity }); let detail = base.rows[0]; if (!detail) return response.status(404).json(reject('内容不存在')); const detailModel = number(detail.modelId || detail.ModelID); if (CONTENT_ADDONS[detailModel]) detail = (await contentPage({ page: 1, pageSize: 1, modelId: detailModel, ...identity })).rows[0] || detail; if (PRIVATE_CONTENT_MODELS.has(detailModel)) { if (!current) return response.status(401).json(reject('该内容详情需要登录')); await authorizeContentDetail(current, detail); } return response.json(envelope(detail)); }
     if (action === 'content_get') { const id = String(input.id || input.gid || input.generalId || ''); if (!id) return response.status(400).json(reject('缺少内容 ID')); const identity = /^\d+$/.test(id) ? { generalId: id } : { objectId: id }; const base = await contentPage({ page: 1, pageSize: 1, ...identity }); let detail = base.rows[0]; if (!detail) return response.status(404).json(reject('内容不存在')); const detailModel = number(detail.modelId || detail.ModelID); if (CONTENT_ADDONS[detailModel]) detail = (await contentPage({ page: 1, pageSize: 1, modelId: detailModel, ...identity })).rows[0] || detail; if (PRIVATE_CONTENT_MODELS.has(detailModel)) { if (!current) return response.status(401).json(reject('该内容详情需要登录')); await authorizeContentDetail(current, detail); } return response.json(envelope(detail)); }
     if (action === 'content_uphis') { const resolved = await resolveContent(input, true); if (!resolved.object) return response.status(404).json(reject('内容不存在')); resolved.object.increment('hits', Math.max(1, number(input.num, 1))); await resolved.object.save(null, { useMasterKey: true }); return response.json(envelope({ hits: resolved.object.get('hits') })); }
     if (action === 'content_uphis') { const resolved = await resolveContent(input, true); if (!resolved.object) return response.status(404).json(reject('内容不存在')); resolved.object.increment('hits', Math.max(1, number(input.num, 1))); await resolved.object.save(null, { useMasterKey: true }); return response.json(envelope({ hits: resolved.object.get('hits') })); }
+    if (action === 'content_add') return response.json(envelope(await createContentPair(input, current)));
     if (action === 'content_update') return response.json(envelope(await updateContentPair(input, current)));
     if (action === 'content_update') return response.json(envelope(await updateContentPair(input, current)));
     if (action === 'node_list') { const filters = input.pid !== undefined ? [{ field: 'parentId', value: number(input.pid) }] : []; const result = await sqlPage('Node', input, filters); return response.json(envelope(result.rows, undefined, result.page)); }
     if (action === 'node_list') { const filters = input.pid !== undefined ? [{ field: 'parentId', value: number(input.pid) }] : []; const result = await sqlPage('Node', input, filters); return response.json(envelope(result.rows, undefined, result.page)); }
     if (action === 'node_get') { const result = await sqlPage('Node', { page: 1, pageSize: 1 }, [{ field: 'nodeId', value: number(input.id || input.nid) }]); return result.rows[0] ? response.json(envelope(result.rows[0])) : response.status(404).json(reject('栏目不存在')); }
     if (action === 'node_get') { const result = await sqlPage('Node', { page: 1, pageSize: 1 }, [{ field: 'nodeId', value: number(input.id || input.nid) }]); return result.rows[0] ? response.json(envelope(result.rows[0])) : response.status(404).json(reject('栏目不存在')); }
@@ -466,7 +474,7 @@ async function handler(request, response) {
     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)); }
     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)); }
     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))); }
     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));
     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)); }
+  } catch (error) { const status = Number(error.status || (error.code === 101 ? 404 : 500)); const message = status === 501 ? 'migration_blocked: ' + error.message : status >= 500 ? '云函数执行失败' : error.message; return response.status(status).json(reject(message)); }
 }
 }
 `;
 `;
 
 

+ 16 - 2
scripts/smoke-admin-functions.mjs

@@ -151,7 +151,7 @@ try {
   const compatibilityActions = blockedActions.filter((action) => !sourceActionList.includes(action)).sort();
   const compatibilityActions = blockedActions.filter((action) => !sourceActionList.includes(action)).sort();
   const sourceImplemented = implementedActions.filter((action) => sourceActionList.includes(action));
   const sourceImplemented = implementedActions.filter((action) => sourceActionList.includes(action));
   const compatibilityImplemented = implementedActions.filter((action) => !sourceActionList.includes(action)).sort();
   const compatibilityImplemented = implementedActions.filter((action) => !sourceActionList.includes(action)).sort();
-  if (sourceImplemented.length !== 30 || sourceBlocked.length !== 61) throw new Error(`app gateway 源 action 计数异常:${sourceImplemented.length} 已映射 / ${sourceBlocked.length} 阻塞`);
+  if (sourceImplemented.length !== 31 || sourceBlocked.length !== 60) throw new Error(`app gateway 源 action 计数异常:${sourceImplemented.length} 已映射 / ${sourceBlocked.length} 阻塞`);
   if (compatibilityImplemented.join(',') !== 'content_add_zt') throw new Error(`app gateway 已实现兼容 action 计数异常:${compatibilityImplemented.join(',')}`);
   if (compatibilityImplemented.join(',') !== 'content_add_zt') throw new Error(`app gateway 已实现兼容 action 计数异常:${compatibilityImplemented.join(',')}`);
   if (compatibilityActions.join(',') !== 'product_add,product_upd') throw new Error(`app gateway 阻塞兼容 action 计数异常:${compatibilityActions.join(',')}`);
   if (compatibilityActions.join(',') !== 'product_add,product_upd') throw new Error(`app gateway 阻塞兼容 action 计数异常:${compatibilityActions.join(',')}`);
 
 
@@ -214,6 +214,12 @@ try {
   if (String(scheduledUpdate.result) !== String(scheduledStudy.result)) throw new Error('规范化内容更新返回值异常');
   if (String(scheduledUpdate.result) !== String(scheduledStudy.result)) throw new Error('规范化内容更新返回值异常');
   const updatedScheduledDetail = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_get', id: scheduledStudy.result });
   const updatedScheduledDetail = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_get', id: scheduledStudy.result });
   if (updatedScheduledDetail.result?.con !== '规范化内容更新' || updatedScheduledDetail.result?.Title !== '已更新学习记录') throw new Error('规范化内容双表更新写后读失败');
   if (updatedScheduledDetail.result?.con !== '规范化内容更新' || updatedScheduledDetail.result?.Title !== '已更新学习记录') throw new Error('规范化内容双表更新写后读失败');
+  const assessment = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_add', content: JSON.stringify({ ModelID: 61, nodeId: 389, inputer: memberUsername, status: 99, Hits: 1, title: memberUsername }), addon: JSON.stringify({ UserID: memberLegacyId, askid: 1, prev_score: 0, totalScore: 10, wrong: 2, dontKnow: 1, answerid: 7, df: 7 }) });
+  if (!/^\d{15}$/.test(String(assessment.result))) throw new Error('规范化测评内容新增结果异常');
+  const assessmentDetail = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_get', id: assessment.result });
+  if (String(assessmentDetail.result?.df) !== '7' || String(assessmentDetail.result?.prevScore) !== '0' || Number(assessmentDetail.result?.UserID ?? assessmentDetail.result?.userId) !== memberLegacyId) throw new Error('规范化测评内容新增写后读失败');
+  const missingSchemaCreate = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_add', content: JSON.stringify({ ModelID: 55, nodeId: 255 }), addon: JSON.stringify({ UserID: memberLegacyId, scnr: '[]' }) }, 501);
+  if (!String(missingSchemaCreate.retmsg).includes('未迁移内容模型 55')) throw new Error('缺 Schema 内容新增未明确拒绝');
   const studyAddon = { con: '云函数学习记录冒烟测试', dqrq: '20260818', UserID: memberLegacyId, learned: 1, ygg: 0, djq: 0, xxqs: JSON.stringify([{ GeneralID: 2505, Title: 'woman', check: 0 }]) };
   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) });
   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('预约学习记录首次双表写入校验失败');
   if (!createdStudy.result?.created || !createdStudy.result?.GeneralID || !createdStudy.result?.recordObjectId) throw new Error('预约学习记录首次双表写入校验失败');
@@ -248,7 +254,7 @@ try {
     process.exitCode = 1;
     process.exitCode = 1;
   });
   });
   if (memberLegacyId) {
   if (memberLegacyId) {
-    const commonWhere = encodeURIComponent(JSON.stringify({ sourceKey: { $regex: `^cloud:(stu_record|content_add_zt):${memberLegacyId}:` } }));
+    const commonWhere = encodeURIComponent(JSON.stringify({ sourceKey: { $regex: `^cloud:(stu_record|content_add_zt|content_add):${memberLegacyId}:` } }));
     const commonRows = await jsonRequest(`${PARSE_URL}/classes/CommonModel?where=${commonWhere}&limit=1000`, {}, true).catch(() => ({ results: [] }));
     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) => {
     for (const row of commonRows.results || []) await jsonRequest(`${PARSE_URL}/classes/CommonModel/${row.objectId}`, { method: 'DELETE' }, true).catch((error) => {
       console.error(`临时学习主记录清理失败:${error.message}`);
       console.error(`临时学习主记录清理失败:${error.message}`);
@@ -265,6 +271,14 @@ try {
     console.error(`临时预约清理失败:${error.message}`);
     console.error(`临时预约清理失败:${error.message}`);
     process.exitCode = 1;
     process.exitCode = 1;
   });
   });
+  if (memberLegacyId) {
+    const assessmentWhere = encodeURIComponent(JSON.stringify({ sourceKey: { $regex: `^cloud:content_add:${memberLegacyId}:` } }));
+    const assessments = await jsonRequest(`${PARSE_URL}/classes/AssessmentProfile?where=${assessmentWhere}&limit=1000`, {}, true).catch(() => ({ results: [] }));
+    for (const assessment of assessments.results || []) await jsonRequest(`${PARSE_URL}/classes/AssessmentProfile/${assessment.objectId}`, { 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

@@ -112,6 +112,14 @@ describe('ApiService', () => {
     request.flush({ retcode: 0, result: '123456' });
     request.flush({ retcode: 0, result: '123456' });
   });
   });
 
 
+  it('routes normalized content creation with a Parse session', () => {
+    sessionToken = 'r:session-token';
+    api.post('content_add', { content: '{"ModelID":61,"nodeId":389}', addon: '{"UserID":23,"df":8}' }).subscribe();
+    const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'content_add', content: '{"ModelID":61,"nodeId":389}', addon: '{"UserID":23,"df":8}' } });
+    request.flush({ retcode: 0, result: '123456' });
+  });
+
   it('routes appointment statistics with a Parse session', () => {
   it('routes appointment statistics with a Parse session', () => {
     sessionToken = 'r:session-token';
     sessionToken = 'r:session-token';
     api.post('e_order_tongji', { uid: 23 }).subscribe();
     api.post('e_order_tongji', { uid: 23 }).subscribe();

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

@@ -11,6 +11,7 @@ export const H5_CLOUD_ROUTED_ACTIONS = new Set([
 ]);
 ]);
 
 
 const SESSION_CLOUD_ROUTED_ACTIONS = new Set([
 const SESSION_CLOUD_ROUTED_ACTIONS = new Set([
+  'content_add',
   'content_add_zt',
   'content_add_zt',
   'content_update',
   'content_update',
   'e_add_words',
   'e_add_words',