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

feat: complete appointment state migration

彭峰 1 месяц назад
Родитель
Сommit
e2fee5dcca

+ 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:31 个已映射,源码清单内 60 个显式返回 501 阻塞原因;动态调用的兼容 action `content_add_zt` 也已映射,`product_add`、`product_upd` 仍显式阻塞,因此线上为 32 个已实现、62 个阻塞项。课程词库进度、学习次数/生词状态、带 15 段复习计划的学习记录双表写入、7 类规范化内容新增/更新及旧版课次/时长/佣金统计公式均已完成;缺副表模型按请求明确 501,预约结束还涉及课时账本与抗遗忘批量创建,继续阻塞以避免半写。详见 `wxapp-cloud-action-matrix.md`。
+- 第 9 个 `xiaoshu.app.gateway` 已覆盖旧 H5 源码的全部 91 个 action:32 个已映射,源码清单内 59 个显式返回 501 阻塞原因;动态调用的兼容 action `content_add_zt` 也已映射,`product_add`、`product_upd` 仍显式阻塞,因此线上为 33 个已实现、61 个阻塞项。课程词库进度、学习次数/生词状态、15 段复习计划、7 类规范化内容新增/更新、预约状态/课时账本/抗遗忘批量创建及旧版统计公式均已完成;缺副表模型继续按请求明确 501。详见 `wxapp-cloud-action-matrix.md`。
 - 旧数据的 `isDeleted` 在部分物理表与 Parse Schema 不一致,把它放入 Parse 查询条件会返回 500;网关改为公司条件查询后在结果侧兼容过滤。
 - Angular H5 已开始按页回归切换:账号密码登录、版本、栏目、公开内容、8 类 addon 联表,以及学习记录详情、预约详情、生词本、21 天抗遗忘列表可走云函数;私有读取只在真实 Parse 会话下切换,其余 action 仍由显式迁移路由保留旧端点。
 

+ 4 - 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`。
-- 源码清单内已有云函数映射 31 个,另有 60 个以 HTTP 501 和 `migration_blocked: <原因>` 显式拒绝。动态调用的兼容 action `content_add_zt` 也已映射;`product_add`、`product_upd` 仍为兼容阻塞项,因此线上共 32 个已实现、62 个阻塞项。矩阵不存在未覆盖的源码 action。
+- 源码清单内已有云函数映射 32 个,另有 59 个以 HTTP 501 和 `migration_blocked: <原因>` 显式拒绝。动态调用的兼容 action `content_add_zt` 也已映射;`product_add`、`product_upd` 仍为兼容阻塞项,因此线上共 33 个已实现、61 个阻塞项。矩阵不存在未覆盖的源码 action。
 - “已映射”表示已有云函数代码与权限边界;完成 H5 切换前仍需逐页响应字段回归。
 
 ## 已映射的 29 个 action
@@ -16,7 +16,7 @@
 | 内容 | `content_add`, `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` |
+| 学习/预约 | `content_add_zt`, `e_add_words`, `e_ck_list`, `e_get_21list`, `e_order_detail`, `e_order_tongji`, `e_order_update_v2`, `e_record_detail`, `e_words_list`, `stu_record_update_v2`, `user_point_list` |
 | 反馈 | `guestbook_add` |
 
 已实测 `content_list` 在公司帐套下返回 92,435 条,`modelId=56` 返回 108 条。读取用 Psql 并强制 `company=7pIbDBJmKx`,所有客户端输入均使用参数化查询。
@@ -48,6 +48,7 @@
 - `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_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_update_v2`:恢复 `0→10→11→20→30` 角色状态机。状态 11 以单条 PostgreSQL CTE 原子写入课时账本并更新 `_User.legacyUserData`:类型 1/2/3/4 分别扣 `Purse/SilverCoin/UserExp/UserPoint`,类型 1/2 同步扣 0.5/1 `UserPoint`;账本 `sourceKey` 作为幂等声明。随后按学习记录 `fxrl` 补齐最多 15 组 `MemoryPracticeRecord + CommonModel`,中断重试会修复缺失主记录,当前调用失败则补偿余额、账本及本次新增复习记录。
 - `e_get_21list`:以 `MemoryPracticeRecord` 为主记录,联查 Model 60、原学习记录和课程节点;学员按 `yhid`、教练收入列表按 `plid` 隔离。
 
 迁移数据没有保存复习收入金额,也没有提供可验证的计价公式,因此 `e_get_21list_tj` 改为显式 501;收入列表仍返回复习事实,但用 `incomeRuleUnavailable=true` 标记金额不可恢复,不伪造收入。
@@ -89,7 +90,6 @@
 | 缺失内容副表 | `content_add` 的 Model 55、57 与 Model 59/Node 77 分支 | 数据库迁移未提供生词集合、单元聚合与连连看成绩目标类;接口逐请求返回 501 |
 | 商品写入 | `product_del`, `product_sale_change`, `product_stock_change` | 门店管理员权限与库存账本 |
 | 单元进度 | `unit_record_update` | 旧源码在接口调用前无条件 `return`;需业务方确认废弃或提供新的聚合规则 |
-| 预约状态 | `e_order_update_v2` | 旧状态 11 同时扣减 `_User.legacyUserData` 课时、写余额账本并按学习记录创建最多 15 条抗遗忘双表记录;云运行时实际没有事务,需完成可补偿的一致性方案 |
 | 发布 | `pub_add`, `pub_list` | `Pub` 业务类型、审核与可见范围 |
 
 ## 前端切换决策
@@ -98,7 +98,7 @@ Angular H5 已启用混合迁移路由:
 
 - `user_login_passwd` 已优先调用云函数:已完成 Parse 密码迁移的账号保存真实 `sessionToken` 并用该会话读取 `user_get`;尚未完成密码重置的旧账号在云函数返回 401/403 时自动回退旧登录,不中断存量用户。
 - `app_update`、`node_list`、`node_get` 已切换至云函数。
-- 持有真实 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` 已切换至云函数;旧会话继续留在旧端点,避免存量账号在密码重置前中断。
+- 持有真实 Parse 会话时,`content_add`、`content_add_zt`、`content_update`、`e_add_words`、`e_ck_list`、`e_order_detail`、`e_order_tongji`、`e_order_update_v2`、`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` 类只有库存变体字段,缺少旧页依赖的商品名称、价格、图片和正文联表。

Разница между файлами не показана из-за своего большого размера
+ 5 - 2
scripts/deploy-admin-functions.mjs


+ 45 - 11
scripts/smoke-admin-functions.mjs

@@ -61,6 +61,7 @@ let memberLegacyId = 0;
 let coachId = '';
 let coachLegacyId = 0;
 let temporaryAppointmentId = '';
+let temporaryAppointmentObjectId = '';
 const temporaryLessonIds = [];
 try {
   const sample = await jsonRequest(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
@@ -87,7 +88,7 @@ try {
   memberLegacyId = 900000000 + Math.floor(Date.now() / 1000) % 90000000;
   const member = await jsonRequest(`${PARSE_URL}/users`, {
     method: 'POST',
-    body: JSON.stringify({ username: memberUsername, password: memberPassword, legacyUserId: memberLegacyId, legacyGroupId: 1, company }),
+    body: JSON.stringify({ username: memberUsername, password: memberPassword, legacyUserId: memberLegacyId, legacyGroupId: 1, legacyUserData: { Purse: 2, SilverCoin: 2, UserExp: 2, UserPoint: 2 }, company }),
   }, true);
   memberId = member.objectId;
   const coachUsername = `codex_coach_smoke_${Date.now()}`;
@@ -105,11 +106,6 @@ try {
     }, true);
     temporaryLessonIds.push(lesson.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', dslx: '1', dszt: '30', pl: String(coachLegacyId) }),
-  }, true);
-  temporaryAppointmentId = createdAppointment.objectId;
   const memberLogin = await jsonRequest(`${PARSE_URL}/login`, {
     method: 'POST',
     body: JSON.stringify({ username: memberUsername, password: memberPassword }),
@@ -118,6 +114,13 @@ try {
     method: 'POST',
     body: JSON.stringify({ username: coachUsername, password: coachPassword }),
   });
+  const createdAppointment = await callLegacyFunction(login.sessionToken, { action: 'content_add', content: JSON.stringify({ ModelID: 54, nodeId: 29, inputer: coachUsername, status: 99, title: memberUsername }), addon: JSON.stringify({ szyh: memberLegacyId, kcid: 16, dslx: 1, dszt: 0, pl: coachLegacyId, fxpl: coachLegacyId, sdsd: '19:00' }) });
+  temporaryAppointmentId = String(createdAppointment.result);
+  const appointmentSourceKey = `cloud:content_add:${memberLegacyId}:${temporaryAppointmentId}`;
+  const appointmentWhere = encodeURIComponent(JSON.stringify({ sourceKey: appointmentSourceKey }));
+  const appointmentRows = await jsonRequest(`${PARSE_URL}/classes/CourseAppointment?where=${appointmentWhere}&limit=1`, {}, true);
+  temporaryAppointmentObjectId = appointmentRows.results?.[0]?.objectId || '';
+  if (!temporaryAppointmentObjectId) throw new Error('临时预约副表创建失败');
 
   const meta = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'meta' });
   if (meta.identity?.objectId !== userId || !meta.cloudFunctions) throw new Error('管理员 meta 校验失败');
@@ -151,7 +154,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 !== 31 || sourceBlocked.length !== 60) throw new Error(`app gateway 源 action 计数异常:${sourceImplemented.length} 已映射 / ${sourceBlocked.length} 阻塞`);
+  if (sourceImplemented.length !== 32 || sourceBlocked.length !== 59) 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(',')}`);
 
@@ -220,7 +223,7 @@ try {
   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', fxrl: '20260819,20260820', 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 });
@@ -230,8 +233,29 @@ try {
   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 blockedOrderUpdate = await callLegacyFunction(coachLogin.sessionToken, { action: 'e_order_update_v2', uid: coachLegacyId, status: 10, content: JSON.stringify({ GeneralID: temporaryAppointmentId }) }, 501);
-  if (!String(blockedOrderUpdate.retmsg).includes('课时')) throw new Error('预约状态写入未显式阻塞隐藏副作用');
+  const orderContent = JSON.stringify({ GeneralID: temporaryAppointmentId, UpDateTime: '2026-08-18 13:30' });
+  await callLegacyFunction(memberLogin.sessionToken, { action: 'e_order_update_v2', uid: memberLegacyId, status: 10, content: orderContent }, 403);
+  const startedOrder = await callLegacyFunction(coachLogin.sessionToken, { action: 'e_order_update_v2', uid: coachLegacyId, status: 10, content: orderContent });
+  if (startedOrder.result?.previousStatus !== 0 || startedOrder.result?.status !== 10) throw new Error('预约开始状态迁移失败');
+  const endedOrder = await callLegacyFunction(coachLogin.sessionToken, { action: 'e_order_update_v2', uid: coachLegacyId, status: 11, content: orderContent });
+  if (endedOrder.result?.reviewCount !== 2 || !endedOrder.result?.periodDeducted) throw new Error('预约结束课时与抗遗忘副作用失败');
+  const repeatedEnd = await callLegacyFunction(coachLogin.sessionToken, { action: 'e_order_update_v2', uid: coachLegacyId, status: 11, content: orderContent });
+  if (!repeatedEnd.result?.unchanged) throw new Error('预约结束状态未保持幂等');
+  const updatedMember = await jsonRequest(`${PARSE_URL}/users/${memberId}`, {}, true);
+  if (Number(updatedMember.legacyUserData?.Purse) !== 1 || Number(updatedMember.legacyUserData?.UserPoint) !== 1.5) throw new Error(`预约结束课时扣减异常:${JSON.stringify(updatedMember.legacyUserData)}`);
+  const sideEffectWhere = encodeURIComponent(JSON.stringify({ sourceKey: { $regex: `^cloud:e_order_update:${temporaryAppointmentId}:` } }));
+  const [periodLogs, pointLogs, memoryRows, memoryCommonRows] = await Promise.all([
+    jsonRequest(`${PARSE_URL}/classes/UserExpDomP?where=${sideEffectWhere}&count=1&limit=0`, {}, true),
+    jsonRequest(`${PARSE_URL}/classes/UserUserPoint?where=${sideEffectWhere}&count=1&limit=0`, {}, true),
+    jsonRequest(`${PARSE_URL}/classes/MemoryPracticeRecord?where=${sideEffectWhere}&count=1&limit=0`, {}, true),
+    jsonRequest(`${PARSE_URL}/classes/CommonModel?where=${sideEffectWhere}&count=1&limit=0`, {}, true),
+  ]);
+  if (periodLogs.count !== 1 || pointLogs.count !== 1 || memoryRows.count !== 2 || memoryCommonRows.count !== 2) throw new Error(`预约结束账本/抗遗忘数量异常:${periodLogs.count}/${pointLogs.count}/${memoryRows.count}/${memoryCommonRows.count}`);
+  await callLegacyFunction(coachLogin.sessionToken, { action: 'e_order_update_v2', uid: coachLegacyId, status: 20, content: orderContent }, 403);
+  await callLegacyFunction(memberLogin.sessionToken, { action: 'e_order_update_v2', uid: memberLegacyId, status: 20, content: orderContent });
+  await callLegacyFunction(coachLogin.sessionToken, { action: 'e_order_update_v2', uid: coachLegacyId, status: 30, content: orderContent });
+  const completedOrder = await callLegacyFunction(memberLogin.sessionToken, { action: 'e_order_detail', id: temporaryAppointmentId });
+  if (Number(completedOrder.result?.[0]?.dszt) !== 30) throw new Error('预约状态机写后读失败');
   const coachStats = await callLegacyFunction(coachLogin.sessionToken, { action: 'e_order_tongji', uid: coachLegacyId });
   if (coachStats.result?.t30 !== '1' || coachStats.result?.t60 !== '1' || coachStats.result?.t_tiyan !== '1' || coachStats.result?.t_total !== '3' || coachStats.result?.t_shichang !== '2.5' || coachStats.result?.t_yongji !== '100') throw new Error('陪练预约统计公式校验失败');
   const memberStats = await callLegacyFunction(memberLogin.sessionToken, { action: 'e_order_tongji', uid: memberLegacyId });
@@ -267,10 +291,20 @@ try {
       process.exitCode = 1;
     });
   }
-  if (temporaryAppointmentId) await jsonRequest(`${PARSE_URL}/classes/CourseAppointment/${temporaryAppointmentId}`, { method: 'DELETE' }, true).catch((error) => {
+  if (temporaryAppointmentObjectId) await jsonRequest(`${PARSE_URL}/classes/CourseAppointment/${temporaryAppointmentObjectId}`, { method: 'DELETE' }, true).catch((error) => {
     console.error(`临时预约清理失败:${error.message}`);
     process.exitCode = 1;
   });
+  if (temporaryAppointmentId) {
+    const sideEffectWhere = encodeURIComponent(JSON.stringify({ sourceKey: { $regex: `^cloud:e_order_update:${temporaryAppointmentId}:` } }));
+    for (const className of ['CommonModel', 'MemoryPracticeRecord', 'UserExpDomP', 'UserUserPoint']) {
+      const rows = await jsonRequest(`${PARSE_URL}/classes/${className}?where=${sideEffectWhere}&limit=1000`, {}, true).catch(() => ({ results: [] }));
+      for (const row of rows.results || []) await jsonRequest(`${PARSE_URL}/classes/${className}/${row.objectId}`, { method: 'DELETE' }, true).catch((error) => {
+        console.error(`临时预约副作用清理失败(${className}):${error.message}`);
+        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: [] }));

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

@@ -128,6 +128,14 @@ describe('ApiService', () => {
     request.flush({ retcode: 0, result: { t30: '1', t_shichang: '0.5', t_total: '1' } });
   });
 
+  it('routes complete appointment transitions with a Parse session', () => {
+    sessionToken = 'r:session-token';
+    api.post('e_order_update_v2', { uid: 23, status: 11, content: '{"GeneralID":918}' }).subscribe();
+    const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'e_order_update_v2', uid: 23, status: 11, content: '{"GeneralID":918}' } });
+    request.flush({ retcode: 0, result: { GeneralID: '918', status: 11, reviewCount: 15, periodDeducted: true } });
+  });
+
   it('keeps learning detail reads on legacy until a real Parse session exists', () => {
     sessionToken = 'legacy-session';
     api.get('e_words_list', { uid: 58 }).subscribe();

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

@@ -19,6 +19,7 @@ const SESSION_CLOUD_ROUTED_ACTIONS = new Set([
   'e_get_21list',
   'e_order_detail',
   'e_order_tongji',
+  'e_order_update_v2',
   'e_record_detail',
   'e_words_list',
   'stu_record_update_v2',

Некоторые файлы не были показаны из-за большого количества измененных файлов