Forráskód Böngészése

feat: migrate normalized content updates

彭峰 1 hónapja
szülő
commit
1d6e23d7f1

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

@@ -15,7 +15,7 @@
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。
 - 8 个管理/CMS 函数均验证未认证请求返回 401;临时帐套管理员完成真实只读冒烟测试后已删除。
-- 第 9 个 `xiaoshu.app.gateway` 已覆盖旧 H5 源码的全部 91 个 action:29 个已映射,源码清单内 62 个显式返回 501 阻塞原因;动态调用的兼容 action `content_add_zt` 也已映射,`product_add`、`product_upd` 仍显式阻塞,因此线上为 30 个已实现、64 个阻塞项。课程词库进度、学习次数/生词状态、带 15 段复习计划的学习记录双表写入及旧版课次/时长/佣金统计公式均已完成;预约结束还涉及课时账本与抗遗忘批量创建,继续显式阻塞,避免半写。详见 `wxapp-cloud-action-matrix.md`。
+- 第 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`。
 - 旧数据的 `isDeleted` 在部分物理表与 Parse Schema 不一致,把它放入 Parse 查询条件会返回 500;网关改为公司条件查询后在结果侧兼容过滤。
 - 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。
 - 统一新入口为 `POST /api/functions/xiaoshu/app/gateway`。
 - 请求体统一使用 `{ "token": "<sessionToken>", "params": { "action": "...", ... } }`;执行器会占用顶层 `id`,业务参数不得放在顶层。公开接口可省略 `token`,但仍必须保留 `params`。
-- 源码清单内已有云函数映射 29 个,另有 62 个以 HTTP 501 和 `migration_blocked: <原因>` 显式拒绝。动态调用的兼容 action `content_add_zt` 也已映射;`product_add`、`product_upd` 仍为兼容阻塞项,因此线上共 30 个已实现、64 个阻塞项。矩阵不存在未覆盖的源码 action。
+- 源码清单内已有云函数映射 30 个,另有 61 个以 HTTP 501 和 `migration_blocked: <原因>` 显式拒绝。动态调用的兼容 action `content_add_zt` 也已映射;`product_add`、`product_upd` 仍为兼容阻塞项,因此线上共 31 个已实现、63 个阻塞项。矩阵不存在未覆盖的源码 action。
 - “已映射”表示已有云函数代码与权限边界;完成 H5 切换前仍需逐页响应字段回归。
 
 ## 已映射的 29 个 action
@@ -13,7 +13,7 @@
 | 领域 | action |
 |---|---|
 | 版本 | `app_update` |
-| 内容 | `content_get`, `content_list`, `content_list_llk`, `content_uphis`, `node_get`, `node_list` |
+| 内容 | `content_get`, `content_list`, `content_list_llk`, `content_update`, `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` |
 | 学习/预约 | `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` |
@@ -44,6 +44,7 @@
 - `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`;任一步失败都会补偿删除已创建记录,避免孤立数据。
 - `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。
 - `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_get_21list`:以 `MemoryPracticeRecord` 为主记录,联查 Model 60、原学习记录和课程节点;学员按 `yhid`、教练收入列表按 `plid` 隔离。
@@ -84,7 +85,7 @@
 
 | 能力 | action | 待确认/待实现 |
 |---|---|---|
-| 内容写入 | `content_add`, `content_update` | `CommonModel` 与 addon 表双写事务 |
+| 内容新增 | `content_add` | Model 55/57 与 Node 77 缺少目标副表;其余模型仍需完成逐模型新增权限与补偿写入 |
 | 商品写入 | `product_del`, `product_sale_change`, `product_stock_change` | 门店管理员权限与库存账本 |
 | 单元进度 | `unit_record_update` | 旧源码在接口调用前无条件 `return`;需业务方确认废弃或提供新的聚合规则 |
 | 预约状态 | `e_order_update_v2` | 旧状态 11 同时扣减 `_User.legacyUserData` 课时、写余额账本并按学习记录创建最多 15 条抗遗忘双表记录;云运行时实际没有事务,需完成可补偿的一致性方案 |
@@ -96,7 +97,7 @@ Angular H5 已启用混合迁移路由:
 
 - `user_login_passwd` 已优先调用云函数:已完成 Parse 密码迁移的账号保存真实 `sessionToken` 并用该会话读取 `user_get`;尚未完成密码重置的旧账号在云函数返回 401/403 时自动回退旧登录,不中断存量用户。
 - `app_update`、`node_list`、`node_get` 已切换至云函数。
-- 持有真实 Parse 会话时,`content_add_zt`、`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_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 查询切到云函数,仍使用旧会话的存量账号自动保留旧端点。
 - 云函数返回同时提供规范化 camelCase 与旧系统 `GeneralID`/`NodeID`/`Title` 等字段别名。
 - `product_list` 尚未切换:当前 `Product` 类只有库存变体字段,缺少旧页依赖的商品名称、价格、图片和正文联表。

+ 24 - 4
scripts/deploy-admin-functions.mjs

@@ -241,7 +241,7 @@ const BLOCKED = {
   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: '需要独立支付密码哈希服务',
   vote_add: '目标 Schema 无可证明的投票记录类', vote_ask: '目标 Schema 无可证明的投票记录类', vote_question: '目标 Schema 无可证明的投票记录类',
-  content_add: 'content/addon 双表写入关系尚待字段级回归', content_update: 'content/addon 双表写入关系尚待字段级回归',
+  content_add: '部分内容模型副表未迁移,新增动作尚未完成逐模型权限与补偿写入',
   product_del: '尚未建立门店管理员授权关系', product_stock_change: '尚未建立门店管理员授权关系', product_sale_change: '尚未建立门店管理员授权关系',
   unit_record_update: '旧源码在调用前无条件 return;需确认是否废弃或提供新的单元聚合规则', e_order_update_v2: '状态 11 还需原子扣减课时、写余额账本并创建 21 天抗遗忘记录;云运行时未提供事务',
   e_get_21list_tj: '迁移数据未包含复习收入金额字段或可验证的计价规则',
@@ -284,6 +284,10 @@ const CONTENT_ADDONS = {
 };
 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 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:[] }
+};
 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 legacyFilterPairs(input) {
@@ -392,6 +396,22 @@ async function createStudyContent(input, current) {
   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:content_add_zt:' + uid + ':' + generalId); common.set('generalId', generalId); common.set('itemId', recordId); common.set('modelId', 56); common.set('nodeId', number(content.nodeId ?? content.NodeID, 291) || 291); common.set('tableName', 'ZL_C_ss'); 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, record.get('learned'))); common.set('status', number(content.Status ?? content.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 String(generalId);
 }
+function writeAddonField(config, requested) { const normalized = String(requested).replace(/[^A-Za-z0-9]/g, '').toLowerCase(); return config && config.fields.find((field) => field.replace(/[^A-Za-z0-9]/g, '').toLowerCase() === normalized); }
+function writeValue(value, definition) {
+  if (!definition) return value; if (definition.type === 'Number') { const parsed = Number(value); if (!Number.isFinite(parsed)) fail(400, '数值字段格式错误'); return parsed; }
+  if (definition.type === 'Boolean') return [true,1,'1','true','True','TRUE'].includes(value); if (definition.type === 'Date') { const date = new Date(value); if (Number.isNaN(date.getTime())) fail(400, '日期字段格式错误'); return date; }
+  if (definition.type === 'String') return typeof value === 'string' ? value.slice(0, 50000) : JSON.stringify(value).slice(0, 50000); fail(400, '不支持写入字段类型 ' + definition.type);
+}
+function snapshotFields(object, fields) { const snapshot = {}; for (const field of fields) snapshot[field] = Object.prototype.hasOwnProperty.call(object.attributes || {}, field) ? { exists:true, value:object.get(field) } : { exists:false }; return snapshot; }
+function restoreFields(object, snapshot) { for (const [field,state] of Object.entries(snapshot)) { if (state.exists) object.set(field, state.value); else object.unset(field); } }
+async function updateContentPair(input, current) {
+  const content = parseObject(input.content, '内容'); const addonInput = parseObject(input.addon, '附表内容'); const generalId = String(content.GeneralID ?? content.generalId ?? input.id ?? input.generalId ?? '').trim(); if (!generalId) fail(400, '缺少内容 GeneralID');
+  const resolved = await resolveContent({ id: generalId }, true); const common = resolved.object; if (!common) fail(404, '指定内容不存在'); const modelId = number(common.get('modelId')); const config = CONTENT_ADDONS[modelId]; const auth = CONTENT_WRITE_AUTH[modelId]; if (!config || !auth) fail(501, '目标 Schema 未迁移内容模型 ' + modelId); if (modelId === 59 && number(common.get('nodeId')) !== 296) fail(501, 'Model 59 的非课次节点缺少独立目标 Schema');
+  const addonRows = await Psql.query('SELECT "objectId" FROM "' + config.className + '" WHERE "company" = $1 AND CAST("id" AS text) = $2 LIMIT 1', [DEFAULT_COMPANY_ID, String(common.get('itemId'))]); const addon = addonRows[0] ? await new Parse.Query(config.className).get(addonRows[0].objectId, { useMasterKey: true }) : null; if (!addon) fail(404, '内容附表不存在'); const existing = safe(addon); await authorizeBusinessRow(current, existing, auth.subjects, auth.actors);
+  const addonSchema = await fieldsOf(config.className); const addonChanged = []; const addonSnapshot = {}; for (const [requested,value] of Object.entries(addonInput)) { if (String(requested).toLowerCase() === 'id') continue; const field = writeAddonField(config, requested); if (!field || !addonSchema[field] || ['id','company','sourceKey'].includes(field)) fail(400, '不允许更新附表字段 ' + requested); if (auth.subjects.concat(auth.actors).includes(field) && String(existing[field] ?? '') !== String(value ?? '')) fail(403, '不允许变更内容所属用户'); if (!addonSnapshot[field]) Object.assign(addonSnapshot, snapshotFields(addon, [field])); addon.set(field, writeValue(value, addonSchema[field])); addonChanged.push(field); }
+  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'));
+}
 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;
   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; }
@@ -403,11 +423,11 @@ 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 resolveContent(input, requireOne = false) { const fields = await fieldsOf('CommonModel'); const id = String(input.id || input.gid || input.generalId || ''); if (!id) { const query = new Parse.Query('CommonModel'); tenant(query, fields); if (requireOne) fail(400, '缺少内容 ID'); return { query, fields }; } if (!/^\d+$/.test(id) && /^[A-Za-z0-9_-]{10,40}$/.test(id)) { const byObjectId = new Parse.Query('CommonModel'); tenant(byObjectId, fields); try { return { object: await byObjectId.get(id, { useMasterKey: true }), fields }; } catch (_) {} } const rows = await Psql.query('SELECT "objectId" FROM "CommonModel" WHERE "company" = $1 AND CAST("generalId" AS text) = $2 LIMIT 1', [DEFAULT_COMPANY_ID, id]); if (!rows[0]) return { object:null, fields }; return { object:await new Parse.Query('CommonModel').get(rows[0].objectId, { 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','content_add_zt','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_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 (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('请输入账号和密码'));
@@ -426,7 +446,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_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_add' || action === 'content_update') { if (!current) fail(401,'需要登录'); const resolved = action === 'content_update' ? await resolveContent(input, true) : { object: new Parse.Object('CommonModel'), fields: await fieldsOf('CommonModel') }; const object = resolved.object; if (!object) return response.status(404).json(reject('内容不存在')); const allowed = ['title','subtitle','nodeId','modelId','status','topImg','tagKey','template']; for (const name of allowed) if (input[name] !== undefined && resolved.fields[name]) object.set(name, ['nodeId','modelId','status'].includes(name) ? number(input[name]) : input[name]); object.set('company', companyPointer()); object.set('inputer', String(current.get('username') || '')); await object.save(null, { useMasterKey: true }); return response.json(envelope(safe(object))); }
+    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_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 === 'product_list' || action === 'product_stock_list') { const result = await sqlPage('Product', input); return response.json(envelope(result.rows, undefined, result.page)); }

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

@@ -151,7 +151,7 @@ try {
   const compatibilityActions = blockedActions.filter((action) => !sourceActionList.includes(action)).sort();
   const sourceImplemented = implementedActions.filter((action) => sourceActionList.includes(action));
   const compatibilityImplemented = implementedActions.filter((action) => !sourceActionList.includes(action)).sort();
-  if (sourceImplemented.length !== 29 || sourceBlocked.length !== 62) throw new Error(`app gateway 源 action 计数异常:${sourceImplemented.length} 已映射 / ${sourceBlocked.length} 阻塞`);
+  if (sourceImplemented.length !== 30 || sourceBlocked.length !== 61) 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 (compatibilityActions.join(',') !== 'product_add,product_upd') throw new Error(`app gateway 阻塞兼容 action 计数异常:${compatibilityActions.join(',')}`);
 
@@ -209,6 +209,11 @@ try {
   const scheduledDetail = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_get', id: scheduledStudy.result });
   const reviewDates = String(scheduledDetail.result?.fxrl || '').split(',').filter(Boolean);
   if (reviewDates.length !== 15 || !reviewDates.every((date) => /^\d{8}$/.test(date))) throw new Error('15 段抗遗忘复习日期生成失败');
+  await callLegacyFunction(coachLogin.sessionToken, { action: 'content_update', content: JSON.stringify({ GeneralID: scheduledStudy.result }), addon: JSON.stringify({ con: '越权更新' }) }, 403);
+  const scheduledUpdate = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_update', content: JSON.stringify({ GeneralID: scheduledStudy.result, Title: '已更新学习记录' }), addon: JSON.stringify({ con: '规范化内容更新', learned: 1 }) });
+  if (String(scheduledUpdate.result) !== String(scheduledStudy.result)) throw new Error('规范化内容更新返回值异常');
+  const updatedScheduledDetail = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_get', id: scheduledStudy.result });
+  if (updatedScheduledDetail.result?.con !== '规范化内容更新' || updatedScheduledDetail.result?.Title !== '已更新学习记录') 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('预约学习记录首次双表写入校验失败');

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

@@ -104,6 +104,14 @@ describe('ApiService', () => {
     request.flush({ retcode: 0, result: '123456' });
   });
 
+  it('routes normalized content updates with a Parse session', () => {
+    sessionToken = 'r:session-token';
+    api.post('content_update', { content: '{"GeneralID":123456}', addon: '{"learned":2}' }).subscribe();
+    const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'content_update', content: '{"GeneralID":123456}', addon: '{"learned":2}' } });
+    request.flush({ retcode: 0, result: '123456' });
+  });
+
   it('routes appointment statistics with a Parse session', () => {
     sessionToken = 'r:session-token';
     api.post('e_order_tongji', { uid: 23 }).subscribe();

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

@@ -12,6 +12,7 @@ export const H5_CLOUD_ROUTED_ACTIONS = new Set([
 
 const SESSION_CLOUD_ROUTED_ACTIONS = new Set([
   'content_add_zt',
+  'content_update',
   'e_add_words',
   'e_ck_list',
   'e_get_21list',