Przeglądaj źródła

feat: migrate appointment status transitions

彭峰 1 miesiąc temu
rodzic
commit
fb3639a477

+ 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:28 个已映射,63 个显式返回 501 阻塞原因。课程词库进度、学习次数/生词状态以及预约学习记录的 Model 56 双表写入均已完成;旧源码从未实际调用的单元聚合仍等待业务规则。详见 `wxapp-cloud-action-matrix.md`。
+- 第 9 个 `xiaoshu.app.gateway` 已覆盖旧 H5 源码的全部 91 个 action:29 个已映射,62 个显式返回 501 阻塞原因。课程词库进度、学习次数/生词状态、预约学习记录双表写入,以及由学员/陪练角色约束的预约状态机均已完成;旧源码从未实际调用的单元聚合仍等待业务规则。详见 `wxapp-cloud-action-matrix.md`。
 - 旧数据的 `isDeleted` 在部分物理表与 Parse Schema 不一致,把它放入 Parse 查询条件会返回 500;网关改为公司条件查询后在结果侧兼容过滤。
 - 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。
 - 统一新入口为 `POST /api/functions/xiaoshu/app/gateway`。
 - 请求体统一使用 `{ "token": "<sessionToken>", "params": { "action": "...", ... } }`;执行器会占用顶层 `id`,业务参数不得放在顶层。公开接口可省略 `token`,但仍必须保留 `params`。
-- 已有云函数映射 28 个,另有 63 个以 HTTP 501 和 `migration_blocked: <原因>` 显式拒绝。矩阵不存在未知 action。
+- 已有云函数映射 29 个,另有 62 个以 HTTP 501 和 `migration_blocked: <原因>` 显式拒绝。矩阵不存在未知 action。
 - “已映射”表示已有云函数代码与权限边界;完成 H5 切换前仍需逐页响应字段回归。
 
-## 已映射的 28 个 action
+## 已映射的 29 个 action
 
 | 领域 | action |
 |---|---|
@@ -16,7 +16,7 @@
 | 内容 | `content_get`, `content_list`, `content_list_llk`, `content_uphis`, `node_get`, `node_list` |
 | 商品只读 | `product_get`, `product_list`, `product_stock_list` |
 | 账号 | `user_get`, `user_info_name`, `user_list`, `user_login_passwd`, `user_register`, `user_update`, `user_update_pwd`, `user_update_pwdall`, `e_user_list` |
-| 学习/预约 | `e_add_words`, `e_ck_list`, `e_get_21list`, `e_order_detail`, `e_record_detail`, `e_words_list`, `stu_record_update_v2`, `user_point_list` |
+| 学习/预约 | `e_add_words`, `e_ck_list`, `e_get_21list`, `e_order_detail`, `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`,所有客户端输入均使用参数化查询。
@@ -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`;任一步失败都会补偿删除已创建记录,避免孤立数据。
 - `e_order_detail`:以预约内容 `GeneralID` 联查 `CourseAppointment`,返回数组契约、旧字段别名和可恢复的课程名称。
+- `e_order_update_v2`:恢复旧页面可相互印证的 `0 未开始 → 10 已开始 → 11 已上课 → 20 已评价 → 30 已结课` 状态机;10/11/30 只允许预约 `pl` 陪练操作,20 只允许 `szyh` 学员操作,管理员可审计代办,重复提交保持幂等并拒绝越级流转。
 - `e_get_21list`:以 `MemoryPracticeRecord` 为主记录,联查 Model 60、原学习记录和课程节点;学员按 `yhid`、教练收入列表按 `plid` 隔离。
 
 迁移数据没有保存复习收入金额,也没有提供可验证的计价公式,因此 `e_get_21list_tj` 改为显式 501;收入列表仍返回复习事实,但用 `incomeRuleUnavailable=true` 标记金额不可恢复,不伪造收入。
@@ -85,7 +86,7 @@
 | 内容写入 | `content_add`, `content_update` | `CommonModel` 与 addon 表双写事务 |
 | 商品写入 | `product_del`, `product_sale_change`, `product_stock_change` | 门店管理员权限与库存账本 |
 | 单元进度 | `unit_record_update` | 旧源码在接口调用前无条件 `return`;需业务方确认废弃或提供新的聚合规则 |
-| 预约 | `e_order_tongji`, `e_order_update_v2` | 统计口径、状态机和角色权限 |
+| 预约统计 | `e_order_tongji` | 首页统计口径与返回字段 |
 | 发布 | `pub_add`, `pub_list` | `Pub` 业务类型、审核与可见范围 |
 
 ## 前端切换决策
@@ -94,12 +95,12 @@ Angular H5 已启用混合迁移路由:
 
 - `user_login_passwd` 已优先调用云函数:已完成 Parse 密码迁移的账号保存真实 `sessionToken` 并用该会话读取 `user_get`;尚未完成密码重置的旧账号在云函数返回 401/403 时自动回退旧登录,不中断存量用户。
 - `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`、`stu_record_update_v2` 已切换至云函数;旧会话继续留在旧端点,避免存量账号在密码重置前中断。
+- 持有真实 Parse 会话时,`e_add_words`、`e_ck_list`、`e_order_detail`、`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` 类只有库存变体字段,缺少旧页依赖的商品名称、价格、图片和正文联表。
 
-在 63 个阻塞 action 中包含订单、支付和预约写入等主流程时,直接全量切换会导致页面大面积中断。剩余切换条件是:
+在 62 个阻塞 action 中包含订单、支付和预约统计等主流程时,直接全量切换会导致页面大面积中断。剩余切换条件是:
 
 1. 先对齐缺失 Schema/数据和第三方凭据。
 2. 完成剩余 action 的云函数事务、权限和字段回归。

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

@@ -243,7 +243,7 @@ const BLOCKED = {
   vote_add: '目标 Schema 无可证明的投票记录类', vote_ask: '目标 Schema 无可证明的投票记录类', vote_question: '目标 Schema 无可证明的投票记录类',
   content_add: 'content/addon 双表写入关系尚待字段级回归', content_add_zt: 'content/addon 双表写入关系尚待字段级回归', content_update: 'content/addon 双表写入关系尚待字段级回归',
   product_del: '尚未建立门店管理员授权关系', product_stock_change: '尚未建立门店管理员授权关系', product_sale_change: '尚未建立门店管理员授权关系',
-  unit_record_update: '旧源码在调用前无条件 return;需确认是否废弃或提供新的单元聚合规则', e_order_update_v2: '需确认预约状态机与角色权限', e_order_tongji: '需确认预约统计口径',
+  unit_record_update: '旧源码在调用前无条件 return;需确认是否废弃或提供新的单元聚合规则', e_order_tongji: '需确认预约统计口径',
   e_get_21list_tj: '迁移数据未包含复习收入金额字段或可验证的计价规则',
   pub_add: '需确认 Pub 业务类型与审核规则', pub_list: '需确认 Pub 业务类型与可见范围', user_dept: '目标 Schema 无可证明的用户部门关系',
   user_rnauth_add: '实名认证需新的合规审核与敏感数据存储', user_rnauth_get: '实名认证需新的合规审核与敏感数据存储', user_rnauth_upd: '实名认证需新的合规审核与敏感数据存储',
@@ -344,9 +344,9 @@ 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'); }
 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]);
-  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;
+  const id = String(inputId || ''); if (!id) fail(400, '缺少预约 ID'); const rows = await Psql.query('SELECT c.*, a."objectId" AS "__appointmentObjectId", 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]);
+  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], __appointmentObjectId: direct[0].objectId, generalId: id, GeneralID: id, title: direct[0].title || '课程预约' }; }
+  if (!source) return null; const addon = source.__addon || {}; const appointmentObjectId = source.__appointmentObjectId || addon.objectId; const courseTitle = source.__courseTitle || source.subtitle || ''; delete source.__addon; delete source.__appointmentObjectId; delete source.__courseTitle; const row = legacyAliases({ ...source, ...addon }, 'CommonModel'); row.appointmentObjectId = appointmentObjectId; row.kcmc = row.kcmc || courseTitle; row.yykcmc = row.yykcmc || courseTitle; row.HoneyName = row.HoneyName || String(row.Title || '').split('(')[0]; if (authorize) await authorizeBusinessRow(current, row, ['szyh'], ['pl']); return row;
 }
 async function recordDetailData(input, current) {
   const id = String(input.id || input.gid || ''); if (!id) fail(400, '缺少学习记录 ID'); const rows = await Psql.query('SELECT c.*, row_to_json(d) AS "__addon" FROM "CommonModel" c JOIN "DailyStudyRecord" d ON d."company" = $1 AND CAST(d."id" AS text) = CAST(c."itemId" AS text) WHERE c."company" = $1 AND CAST(c."modelId" AS text) = \'56\' AND CAST(c."generalId" AS text) = $2 LIMIT 1', [DEFAULT_COMPANY_ID, id]);
@@ -380,6 +380,10 @@ async function upsertStudyRecord(input, current) {
   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 };
 }
+async function updateOrderStatus(input, current) {
+  const content = parseObject(input.content, '预约更新内容'); const orderId = String(content.GeneralID ?? content.generalId ?? input.orderId ?? input.id ?? '').trim(); if (!orderId) fail(400, '缺少预约 ID'); const status = number(input.status, -1); if (![10,11,20,30].includes(status)) fail(400, '不支持的预约状态'); const requestedActor = number(input.uid || ownLegacyId(current)); if (!isAdminUser(current) && requestedActor !== ownLegacyId(current)) fail(403, '无权代替其他用户更新预约'); const order = await orderDetailRow(orderId, current, false); if (!order) fail(404, '预约记录不存在'); const studentId = number(order.szyh); const coachId = number(order.pl); const actorId = ownLegacyId(current); const requiredActor = status === 20 ? studentId : coachId; if (!isAdminUser(current) && (!requiredActor || actorId !== requiredActor)) fail(403, status === 20 ? '仅预约学员可以确认评价' : '仅预约陪练可以更新课程状态'); const previous = number(order.dszt); const allowedPrevious = { 10:[0,10], 11:[10,11], 20:[11,20], 30:[11,20,30] }[status]; if (!allowedPrevious.includes(previous)) fail(409, '预约状态不能从 ' + previous + ' 更新为 ' + status); if (previous === status) return { GeneralID: order.GeneralID ?? order.generalId ?? orderId, previousStatus: previous, status, unchanged: true };
+  const appointment = await new Parse.Query('CourseAppointment').get(String(order.appointmentObjectId), { useMasterKey: true }); appointment.set('dszt', String(status)); const updatedAt = cleanText(content.UpDateTime ?? content.updateTime, 30); if (updatedAt && !/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/.test(updatedAt)) fail(400, '预约更新时间格式错误'); if (status === 10 && updatedAt && !appointment.get('kssj')) appointment.set('kssj', updatedAt); if (status === 11 && updatedAt) appointment.set('jssj', updatedAt); await appointment.save(null, { useMasterKey: true }); return { GeneralID: order.GeneralID ?? order.generalId ?? orderId, previousStatus: previous, status, unchanged: false, objectId: appointment.id };
+}
 function learnedDate(row) { const raw = String(row.dqrq || '').trim(); return /^\d{8}$/.test(raw) ? raw.slice(0,4) + '-' + raw.slice(4,6) + '-' + raw.slice(6,8) : String(row.kywsj || '').slice(0,10); }
 async function memoryPage(input, current) {
   const uid = await authorizeRequestedUser(current, input.uid); const paging = largePageInput(input); const income = number(input.shouru) === 1; const values = [DEFAULT_COMPANY_ID, String(uid)]; const clauses = ['c."company" = $1', 'CAST(c."modelId" AS text) = \'60\'', 'CAST(m."' + (income ? 'plid' : 'yhid') + '" AS text) = $2']; if (input.status !== undefined && input.status !== '') { values.push(String(number(input.status))); clauses.push('CAST(m."fxzt" AS text) = $' + values.length); } const where = clauses.join(' AND '); const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" c JOIN "MemoryPracticeRecord" m ON m."company" = $1 AND CAST(m."id" AS text) = CAST(c."itemId" AS text) WHERE ' + where, values); const rowValues = values.concat([paging.size, (paging.index - 1) * paging.size]);
@@ -389,7 +393,7 @@ async function resolveContent(input, requireOne = false) { const fields = await
 async function handler(request, response) {
   try {
     const input = inputOf(request); const action = String(input.action || ''); if (!action) return response.status(400).json(reject('缺少 action'));
-    if (action === 'migration_status') return response.json(envelope({ implemented: ['app_update','user_login_passwd','user_register','user_info_name','user_get','user_list','e_user_list','user_update','user_update_pwd','user_update_pwdall','content_list','content_list_llk','content_get','content_uphis','node_list','node_get','product_list','product_get','product_stock_list','e_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 (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_order_detail','e_order_update_v2','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('请输入账号和密码'));
@@ -415,6 +419,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_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);
+    if (action === 'e_order_update_v2') return response.json(envelope(await updateOrderStatus(input, 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_ck_list') { const result = await courseWordPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }

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

@@ -58,6 +58,8 @@ async function sourceActions() {
 let userId = '';
 let memberId = '';
 let memberLegacyId = 0;
+let coachId = '';
+let coachLegacyId = 0;
 let temporaryAppointmentId = '';
 try {
   const sample = await jsonRequest(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
@@ -87,15 +89,27 @@ try {
     body: JSON.stringify({ username: memberUsername, password: memberPassword, legacyUserId: memberLegacyId, company }),
   }, true);
   memberId = member.objectId;
+  const coachUsername = `codex_coach_smoke_${Date.now()}`;
+  const coachPassword = randomBytes(24).toString('base64url');
+  coachLegacyId = memberLegacyId + 1;
+  const coach = await jsonRequest(`${PARSE_URL}/users`, {
+    method: 'POST',
+    body: JSON.stringify({ username: coachUsername, password: coachPassword, legacyUserId: coachLegacyId, legacyGroupId: 3, company }),
+  }, true);
+  coachId = coach.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' }),
+    body: JSON.stringify({ company, sourceKey: `cloud:smoke-appointment:${memberLegacyId}`, szyh: String(memberLegacyId), kcid: '16', dszt: '0', pl: String(coachLegacyId) }),
   }, true);
   temporaryAppointmentId = createdAppointment.objectId;
   const memberLogin = await jsonRequest(`${PARSE_URL}/login`, {
     method: 'POST',
     body: JSON.stringify({ username: memberUsername, password: memberPassword }),
   });
+  const coachLogin = await jsonRequest(`${PARSE_URL}/login`, {
+    method: 'POST',
+    body: JSON.stringify({ username: coachUsername, password: coachPassword }),
+  });
 
   const meta = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'meta' });
   if (meta.identity?.objectId !== userId || !meta.cloudFunctions) throw new Error('管理员 meta 校验失败');
@@ -182,6 +196,19 @@ 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 orderContent = JSON.stringify({ GeneralID: temporaryAppointmentId, UpDateTime: '2026-08-18 12:00' });
+  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 || startedOrder.result?.unchanged) throw new Error('预约开始状态迁移校验失败');
+  const repeatedStart = await callLegacyFunction(coachLogin.sessionToken, { action: 'e_order_update_v2', uid: coachLegacyId, status: 10, content: orderContent });
+  if (!repeatedStart.result?.unchanged) throw new Error('预约状态幂等更新校验失败');
+  await callLegacyFunction(memberLogin.sessionToken, { action: 'e_order_update_v2', uid: memberLegacyId, status: 11, content: orderContent }, 403);
+  await callLegacyFunction(coachLogin.sessionToken, { action: 'e_order_update_v2', uid: coachLegacyId, status: 11, content: orderContent });
+  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 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('普通用户跨用户课程词库未被拒绝');
   const denied = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_list', modelId: 56, myfield2: 'UserId=3', page: 1, pageSize: 1 }, 403);
@@ -227,6 +254,12 @@ try {
       process.exitCode = 1;
     });
   }
+  if (coachId) {
+    await jsonRequest(`${PARSE_URL}/users/${coachId}`, { method: 'DELETE' }, true).catch((error) => {
+      console.error(`临时陪练用户清理失败:${error.message}`);
+      process.exitCode = 1;
+    });
+  }
   if (userId) {
     await jsonRequest(`${PARSE_URL}/users/${userId}`, { method: 'DELETE' }, true).catch((error) => {
       console.error(`临时管理员清理失败:${error.message}`);

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

@@ -96,6 +96,14 @@ describe('ApiService', () => {
     request.flush({ retcode: 0, result: { GeneralID: 123456, created: true } });
   });
 
+  it('routes appointment status transitions with a Parse session', () => {
+    sessionToken = 'r:session-token';
+    api.post('e_order_update_v2', { uid: 23, status: 10, 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: 10, content: '{"GeneralID":918}' } });
+    request.flush({ retcode: 0, result: { GeneralID: 918, previousStatus: 0, status: 10 } });
+  });
+
   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

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