Sfoglia il codice sorgente

fix: protect reconciled lesson credits and audit all students

彭峰 2 ore fa
parent
commit
50f098032d

+ 55 - 0
docs/operations/all-student-credit-audit-2026-11-11.md

@@ -0,0 +1,55 @@
+# 全体学员课时与记录核对(2026-11-11)
+
+已扫描674条member账户(含删除和残留)、42,767条原始课时流水、19,892条课后记录、20,130条单词学习明细及32,824条预约。管理端在册636个账户,其中8个没有旧学员ID,按对象身份核对,未合并为同一个学员。
+
+## 本次实际修复
+
+- 按旧已完成预约、扣课和学习明细三方凭据,为19名学员补录19节缺失课后记录,注明历史补录及原评语未留存。没有重扣课时。
+- 丁俊言193返还3小时:2→5;陆鹏渤1447返还1小时:15→16;杨熠诚1835返还1小时:20→21。对应陪练时长同步返还,共3名学员、5小时,6条新调整流水,原始凭据保留。
+- 曾梓涵1785的2小时在执行期间已由另一笔调整补回,复查后跳过;黄家兴266确认多扣1小时,但标准接口拒绝停用账户,未擅自启用,待处理。
+- 李泽航1840昨晚预约881544补同步:导入2条原始扣课流水,60分钟课时及陪练时长14→13;补入旧源课后记录890165和1条学习明细,并将尚未同步的旧预约完成状态更新。没有重放完课扣费。
+
+此前已修复的熊奕赫1847剩26次30分钟、卓越易宇凡1828剩4小时、樊文昊1826剩0小时,本次回读保持正确。
+
+## 全量核对结论及边界
+
+552个可映射旧账户、2,208个账户分组已重新读取42,418条旧原始流水,分页/用户归属完整;与原新库比较关键金额、扣前值、时间、备注均无改写,原缺2条即李泽航本次补齐。未发现同账户同金额同一时刻同备注的重复正向充值;这不替代银行或收款凭据核验。
+
+636个在册账户的概览课次数与按有效预约去重的课后记录全部一致。单词明细允许同节多批,不能当成多节课;此前概览计数修复已覆盖全体。
+
+重复扣课/历史调账仍有需业务核实的项目:[逐项复核清单](all-student-credit-review-2026-11-11.md)。不能把这些疑点一律当欠课,更不能用原始流水简单求和覆盖余额。本次没有宣称历史账全部无误。旧学习接口全表拉取曾超时,未据此声称完成旧业务全表镜像校验;新库全部课次与流水已扫描,19项修复均单独读取旧预约和学习证据核实。
+
+## 同步保护与验证
+
+- 返还使用标准课时调整API,带稳定幂等键、预览版本、完整凭据;同步按来源变化加本地流水合并,保留返还。
+- direct-v1新增同预约补录保护:以后旧课后记录恢复但编号不同,记录冲突并保留已核实补录,避免再生成一份;不同学员的冲突也不自动覆盖。线上仅替换5行对应代码,回读一致。
+- 旧源新增同订单重复负向流水时返回new_duplicate_source_charge;新增“新系统已上/已扣/补扣”手工负向记录返回manual_cross_system_charge_requires_review,保存来源但保留当前余额等待核实。已有核过的历史重复记录不会被重新触发;正常不同订单继续同步。此保护已在direct-v1及两个管理网关发布并回读。
+- 2,208个账户分组用当前余额、来源快照、完整流水和状态重放计算:全部ready,变化0。另有测试覆盖随后新增一笔旧扣课及再次重跑。此为重放计算验证,不宣称未来夜间任务已发生。
+- 19项补录重复执行全部幂等;原42,767条流水、19,892条课后记录哈希保持;20,130条学习明细在排除并行发布新增的空字段reportMetadata后,原字段哈希全部保持。修复过程的正常并发新增已保留。
+- 19项针对性测试通过;11个云函数定义校验通过。
+
+## 补录明细
+
+| 学员 | 旧预约 | 补录标记 |
+|---|---|---|
+| 万皓宇(235) | 267215 | 历史证据重建,不重扣费 |
+| 刘杰予(153) | 477928 | 历史证据重建,不重扣费 |
+| 立德胡梓萱(1504) | 487798 | 历史证据重建,不重扣费 |
+| 德培彭婧雯(1513) | 726475 | 历史证据重建,不重扣费 |
+| 灵灵(1582) | 765917 | 历史证据重建,不重扣费 |
+| 杨杨(1599) | 561114 | 历史证据重建,不重扣费 |
+| 邓钰晨高中(1681) | 690467 | 历史证据重建,不重扣费 |
+| 赵张策(190) | 168655 | 历史证据重建,不重扣费 |
+| 胡衍豪(244) | 432188 | 历史证据重建,不重扣费 |
+| 宗睿阳(1431) | 463300 | 历史证据重建,不重扣费 |
+| 德培黄子焜(1450) | 630008 | 历史证据重建,不重扣费 |
+| 当代吴语馨(1506) | 727022 | 历史证据重建,不重扣费 |
+| 高安王禹凡(1512) | 509444 | 历史证据重建,不重扣费 |
+| 饶煦洁(1516) | 680706 | 历史证据重建,不重扣费 |
+| 维恩刘欣悦(1590) | 564587 | 历史证据重建,不重扣费 |
+| 金芮熙高三(1644) | 702805 | 历史证据重建,不重扣费 |
+| 周欣宇初中(1677) | 760857 | 历史证据重建,不重扣费 |
+| 刘丽君宜春(1691) | 680456 | 历史证据重建,不重扣费 |
+| 吴宇轩(120) | 125055 | 历史证据重建,不重扣费 |
+
+原始证据、事务试运行、写入/回读结果与回放验证保存在私有目录 `~/.local/state/xiaoshu/all-student-audit-20261111/`。临时运维函数执行后删除。没有修改settlementReady,没有覆盖原始记录或手工解决其他同步冲突。

+ 124 - 0
docs/operations/all-student-credit-review-2026-11-11.md

@@ -0,0 +1,124 @@
+# 2026-11-11 全量课时核对待核实明细
+
+这是一份疑点清单,不代表每项都仍欠学员课时。重复流水不能直接等同重复扣余额;历史管理员补录、加课、清零与退款需要业务凭据。原始数据均保留。
+
+同预约重复扣款涉及 95 个预约:18 个已找到新旧补偿/跨系统排重证据,其余 77 个保留复核。后者含黄家兴停用账户已确认待返还1小时。
+
+| 学员 | 预约 | 原预约时间 | 账户 | 处理状态 | 复核事项 |
+|---|---|---|---|---|---|
+| 小黄(110) | 452428 | 2025-07-28 14:30 | Purse / UserPoint | 待核实 | 需确认多次结束是否属于加课、清零/退课及真实余额扣减 |
+| 卢锦泽小学(1719) | 881692 | 2026-11-03 20:00 | Purse / UserPoint | 同步已抵扣 | 旧新系统同预约扣课已在余额对账中排重,原始记录保留 |
+| 徐晨哲小学(1697) | 648481 | 2026-02-10 09:30 | Purse / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 熊奕赫小学(1847) | 859068 | 2026-09-20 11:00 | Purse / UserPoint | 已调整 | 新系统已有对应订单的返还流水,禁止再次返还 |
+| 刘杰予(153) | 477928 | 2025-08-15 10:00 | Purse / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 谢晨晨(58) | 124031 | 2024-06-25 00:30 | Purse / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 张优培姐姐(1735) | 691200 | 2026-05-23 10:00 | Purse / UserPoint | 待核实 | 需确认多次结束是否属于加课、清零/退课及真实余额扣减 |
+| 万子昊小学(1845) | 855057 | 2026-10-03 19:00 | Purse / UserPoint | 已调整 | 新系统已有对应订单的返还流水,禁止再次返还 |
+| 王奕雄(215) | 663446 | 2026-02-13 15:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 王奕雄(215) | 481502 | 2025-08-01 14:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 王奕雄(215) | 472326 | 2025-07-28 14:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 王奕雄(215) | 256778 | 2025-01-04 19:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 丁俊言(193) | 742368 | 2026-06-14 15:00 | SilverCoin / UserPoint | 已调整 | 新系统已有对应订单的返还流水,禁止再次返还 |
+| 舒钰轩(260) | 659571 | 2026-02-14 17:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 闻雨嘉(316) | 854645 | 2026-09-19 20:30 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 李昱乐雅思(1416) | 354312 | 2025-04-15 08:00 | SilverCoin / UserPoint | 待核实 | 需确认多次结束是否属于加课、清零/退课及真实余额扣减 |
+| 李昱乐雅思(1416) | 354311 | 2025-04-14 08:00 | SilverCoin / UserPoint | 待核实 | 需确认多次结束是否属于加课、清零/退课及真实余额扣减 |
+| 奉新黄怡嘉(1427) | 419857 | 2025-06-22 10:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 郭淳铭(1446) | 476878 | 2025-08-03 15:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 陆鹏渤(1447) | 545759 | 2025-11-16 15:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 陆鹏渤(1447) | 379916 | 2025-05-25 10:00 | SilverCoin / UserPoint | 已调整 | 新系统已有对应订单的返还流水,禁止再次返还 |
+| 宗亲王(1464) | 615654 | 2025-12-20 17:00 | SilverCoin / UserPoint | 待核实 | 需确认多次结束是否属于加课、清零/退课及真实余额扣减 |
+| 曾浩铭(1495) | 487695 | 2025-08-06 10:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 立德胡梓萱(1504) | 516704 | 2025-08-18 19:30 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 立德胡梓萱(1504) | 448891 | 2025-07-18 08:30 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 翟乐瑄(1507) | 553932 | 2025-10-02 16:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 德培彭婧雯(1513) | 434537 | 2025-08-12 19:30 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 闵浩五中(1519) | 711096 | 2026-04-12 11:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 闵浩五中(1519) | 507467 | 2025-08-13 14:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 喻浩文民德(1523) | 641145 | 2026-01-24 10:30 | SilverCoin / UserPoint | 旧账已有明确补偿备注 | 保留原补偿,不能再按重复条数追加返还 |
+| 喻浩文民德(1523) | 443528 | 2025-07-19 16:00 | SilverCoin / UserPoint | 旧账已有明确补偿备注 | 保留原补偿,不能再按重复条数追加返还 |
+| 余孟君(1546) | 482586 | 2025-08-10 17:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 余孟君(1546) | 482584 | 2025-08-06 17:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 余孟君(1546) | 482572 | 2025-08-04 16:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 王品婻高中(1565) | 663478 | 2026-02-13 14:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 杨杨(1599) | 646697 | 2026-01-31 10:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 刘忆萱高中(1687) | 685171 | 2026-03-21 09:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 刘丽雯宜春初中(1694) | 650906 | 2026-02-24 13:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 曾梓恒初一(1705) | 713835 | 2026-04-19 14:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 刘语宁高中(1763) | 739639 | 2026-06-20 08:30 | SilverCoin / UserPoint | 旧账已有明确补偿备注 | 保留原补偿,不能再按重复条数追加返还 |
+| 曾梓涵高中(1785) | 861323 | 2026-10-03 22:00 | SilverCoin / UserPoint | 已调整 | 新系统已有对应订单的返还流水,禁止再次返还 |
+| 郑雨沁高安中学(1790) | 802751 | 2026-08-02 20:30 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 熊佳皓高三(1797) | 870426 | 2026-10-03 11:00 | SilverCoin / UserPoint | 旧账已有明确补偿备注 | 保留原补偿,不能再按重复条数追加返还 |
+| 杨熠诚(1835) | 859697 | 2026-09-27 20:00 | SilverCoin / UserPoint | 已调整 | 新系统已有对应订单的返还流水,禁止再次返还 |
+| 花花(64) | 244666 | 2025-01-04 14:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 花花(64) | 123949 | 2024-06-28 11:30 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 谢晨晨(58) | 338484 | 2025-03-30 20:30 | SilverCoin / UserPoint | 待核实 | 需确认多次结束是否属于加课、清零/退课及真实余额扣减 |
+| 谢谢(59) | 122369 | 2024-06-22 13:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 谢谢(59) | 122376 | 2024-06-21 07:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 小徐(60) | 338434 | 2025-03-26 17:30 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 小赵(85) | 123983 | 2024-06-28 09:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 小涂(86) | 323881 | 2025-03-18 21:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 小涂(86) | 123674 | 2024-06-28 10:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 小涂(86) | 123673 | 2024-06-27 10:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 吴钦琪(106) | 124411 | 2024-06-28 10:30 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 余维钧(168) | 451018 | 2025-07-19 16:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 吴越(183) | 159308 | 2024-08-20 14:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 吴越(183) | 159300 | 2024-08-12 14:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 胡衍豪(244) | 420199 | 2025-06-22 11:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 胡衍豪(244) | 220734 | 2024-11-24 09:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 黄家兴(266) | 543634 | 2025-10-04 13:00 | SilverCoin / UserPoint | 确认多扣1小时,停用账户待处理 | 需确认多次结束是否属于加课、清零/退课及真实余额扣减 |
+| 黄家兴(266) | 233043 | 2024-12-07 10:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 徐佩媛(270) | 234161 | 2024-12-15 17:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 陈嘉轩(283) | 378362 | 2025-05-18 15:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 史雨泽(286) | 384537 | 2025-05-10 10:30 | SilverCoin / UserPoint | 待核实 | 需确认多次结束是否属于加课、清零/退课及真实余额扣减 |
+| 官言溪(297) | 283679 | 2025-02-07 15:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 魏铭泽(333) | 339769 | 2025-04-12 11:30 | SilverCoin / UserPoint | 待核实 | 需确认多次结束是否属于加课、清零/退课及真实余额扣减 |
+| 万梓萱(330) | 537367 | 2025-09-21 16:30 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 吴青城(348) | 332993 | 2025-03-23 10:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 德培鄢静茹(1387) | 313901 | 2025-03-04 21:30 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 雷钰瑄(1399) | 380209 | 2025-06-29 10:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 宗睿阳(1431) | 396338 | 2025-05-25 13:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 德培徐梓轩(1448) | 666343 | 2026-02-20 09:30 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 德培王惠宁(1476) | 468776 | 2025-07-28 15:30 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 熊小俊(252) | 858344 | 2026-10-30 14:00 | SilverCoin / UserPoint | 同步已抵扣 | 旧新系统同预约扣课已在余额对账中排重,原始记录保留 |
+| 胡彦祺初三(1855) | 878629 | 2026-11-01 14:00 | SilverCoin / UserPoint | 同步已抵扣 | 旧新系统同预约扣课已在余额对账中排重,原始记录保留 |
+| 胡彦祺初三(1855) | 878636 | 2026-11-01 15:00 | SilverCoin / UserPoint | 同步已抵扣 | 旧新系统同预约扣课已在余额对账中排重,原始记录保留 |
+| 刘晨丰(1491) | 465726 | 2025-07-25 11:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 张若野(1503) | 534914 | 2025-09-06 14:00 | SilverCoin / UserPoint | 待核实 | 需确认多次结束是否属于加课、清零/退课及真实余额扣减 |
+| 李菓吉安(1568) | 535731 | 2025-09-21 11:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 朱子坚赣州(1577) | 612807 | 2025-12-21 09:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 朱子坚赣州(1577) | 553566 | 2025-10-01 11:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 王猷奇(1586) | 603192 | 2026-01-09 11:00 | SilverCoin / UserPoint | 旧账已有明确补偿备注 | 保留原补偿,不能再按重复条数追加返还 |
+| 欧阳尚毅(1603) | 561071 | 2025-10-12 10:00 | SilverCoin / UserPoint | 待核实 | 需确认多次结束是否属于加课、清零/退课及真实余额扣减 |
+| 维恩章颂杰(1660) | 618234 | 2025-12-28 10:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 章清阳高中(1715) | 703600 | 2026-05-30 11:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 章清阳高中(1715) | 703583 | 2026-04-11 10:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 张舜雅临川一中(1726) | 683848 | 2026-03-29 11:00 | SilverCoin / UserPoint | 旧账已有明确补偿备注 | 保留原补偿,不能再按重复条数追加返还 |
+| 维恩李瑾萱(1724) | 685748 | 2026-03-28 15:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 维恩王嘉彤(1723) | 721504 | 2026-06-14 10:00 | SilverCoin / UserPoint | 待核实 | 需确认多次结束是否属于加课、清零/退课及真实余额扣减 |
+| 维恩王嘉彤(1723) | 678936 | 2026-03-22 10:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 袁子语高三(1737) | 694998 | 2026-04-12 09:00 | SilverCoin / UserPoint | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+| 刘希悦九江(1846) | 858275 | 2026-10-17 10:00 | SilverCoin / UserPoint | 已调整 | 新系统已有对应订单的返还流水,禁止再次返还 |
+| 曲函谊(1488) | 615971 | 2025-12-20 08:30 | SilverCoin / UserPoint | 旧账已有明确补偿备注 | 保留原补偿,不能再按重复条数追加返还 |
+| 黄周硕(329) | 265902 | 2025-01-17 15:00 | UserExp | 待核实 | 后续有充值/管理员调账,缺少明确用途,无法确认未补偿 |
+
+## 缺记录疑点的剩余处理
+
+| 学员 | 预约 | 原预约状态 | 说明 |
+|---|---|---|---|
+| 傅宁(23) | 920 | 11 | 身份来源或完成状态不足;保留扣课和学习明细,不能据此虚构完课 |
+| 傅宁(23) | 925 | 11 | 身份来源或完成状态不足;保留扣课和学习明细,不能据此虚构完课 |
+| 傅宁(23) | 114241 | 30 | 身份来源或完成状态不足;保留扣课和学习明细,不能据此虚构完课 |
+| 灵灵芝芝(53) | 120606 | 11 | 身份来源或完成状态不足;保留扣课和学习明细,不能据此虚构完课 |
+| 灵灵芝芝(53) | 120609 | 11 | 身份来源或完成状态不足;保留扣课和学习明细,不能据此虚构完课 |
+| 小徐(60) | 123679 | 10 | 身份来源或完成状态不足;保留扣课和学习明细,不能据此虚构完课 |
+| 小赵(85) | 123983 | 10 | 身份来源或完成状态不足;保留扣课和学习明细,不能据此虚构完课 |
+| 小涂(86) | 123674 | 10 | 身份来源或完成状态不足;保留扣课和学习明细,不能据此虚构完课 |
+| 金芮熙高三(1644) | 747251 | 30 | 同预约旧、新课后记录归属闻雨嘉316,扣课与学习明细归属金芮熙1644;需确认真实学员,暂不移动或再建记录 |
+| 王麓淇高中(1862) | 881397 | 0 | 已确认未上课,原错误扣课已返还,不补造上课记录 |
+
+另有刘泽鑫113的预约404649课后记录未找到对应扣课流水,保留记录,不补扣。旧账户23的余额与原始流水存在4次差额;旧账户63为负1次且身份来源不足,未强制归零。
+
+## 需要的业务凭据
+
+待核实预约的原始收款/赠课、补课或退款记录;对仅写“管理员操作”的小额增加,需要确认是充值还是纠错。金芮熙/闻雨嘉的预约747251需要当时授课学员凭据。停用学员黄家兴应通过支持停用账户的课时调整流程处理,不为此擅自恢复其登录。

+ 18 - 0
docs/superpowers/plans/2026-11-11-all-student-credit-audit.md

@@ -0,0 +1,18 @@
+# 全量课时核对与同步保护实施计划
+
+> **For agentic workers:** Use superpowers:executing-plans to implement this plan task-by-task.
+
+目标:核对全部在册学员及历史残留账户的充值、扣课、余额和课次;只修复证据完整的差错,保留原始流水、未知补偿与身份冲突。
+
+- [x] 取得一致性只读数据库快照、当前学员列表、旧账户快照;按对象身份区分无旧 ID 的账户。
+- [x] 拉取全部可映射旧账户四类原始流水,核对来源 ID、内容及分页完整性。
+- [x] 核实缺课记录,事务补录;验证流水、余额、学习明细不变,重复执行不新增。
+- [x] 核实重复扣款的真实余额变化及后续补偿;仅对未补偿且余额链完整的差错使用标准课时调整 API。
+- [x] 在 `scripts/cloud/legacy-direct-sync.js` 为已补录的同预约课次增加导入保护;在 `scripts/tests/legacy-repaired-lesson-sync.test.mjs` 覆盖旧课后记录恢复、错误学员和正常导入。先验证失败,再改代码。
+- [x] 在 `scripts/tests/legacy-credit-balance.test.mjs` 验证补偿调整在旧系统后续扣课及重跑中保留。
+- [x] 仅发布同步保护对应片段,验证线上源代码、全体学员统计、修复结果与同步重放计划。
+- [x] 输出核对报告及未决清单,单独提交并推送本次文件。
+
+原始证据存放在私有 `~/.local/state/xiaoshu/all-student-audit-20261111/`;不提交含个人信息、会话或密钥的原始响应。历史重建标记 `repair:legacy-completed-appointment:`,不重放完课扣费、不生成虚构评语。
+
+执行结果与无法自动认定的历史疑点见 `docs/operations/all-student-credit-audit-2026-11-11.md`。黄家兴停用账户的已确认返还未执行;未绕过停用保护。新增旧订单重复扣款及“新系统已上”补扣的余额保护已纳入测试与发布。

+ 5 - 0
scripts/cloud/legacy-direct-sync.js

@@ -26,6 +26,11 @@ try{
    const gid=Number(incoming.generalId);if(!Number.isSafeInteger(gid)||gid<1)throw Error('Invalid incoming ID');
    const cc=(await t.oneOrNone('SELECT to_jsonb(c) data FROM "CommonModel" c WHERE company=$1 AND "modelId"=$2 AND "generalId"=$3 FOR UPDATE',[co,m,gid]))?.data;
    if(p.action==='append'){
+    // A repaired historical class already represents this appointment, even if the old source later gains a different generalId.
+    if(m===59&&incoming.addon?.yyds&&String(incoming.addon.yyds)!=='0'){
+     const repaired=await t.oneOrNone('SELECT "objectId",xymz FROM "LessonRecord" WHERE company=$1 AND yyds=$2 AND "sourceKey" LIKE \'repair:legacy-completed-appointment:%\' LIMIT 1 FOR UPDATE',[co,String(incoming.addon.yyds)]);
+     if(repaired){out.conflicts.push({generalId:gid,reason:'同预约已有核实补录课次,保留补录并人工核对恢复的旧记录'});continue;}
+    }
     if(cc&&(cc.teachingManaged||Number(cc.status)===-2||!legacy(cc))){out.conflicts.push({generalId:gid,reason:'新系统课次已受保护'});continue;}
     let item=cc?Number(cc.itemId):Number(incoming.addon?.id||gid);
     let aa=(await t.oneOrNone('SELECT to_jsonb(a) data FROM '+q(s.className)+' a WHERE company=$1 AND id=$2 FOR UPDATE',[co,item]))?.data;

+ 8 - 1
scripts/cloud/legacy-finance.js

@@ -27,6 +27,13 @@ function legacyCreditBalancePlan(p) {
   }
   if(!equal(current,baseline+localNet))return conflict('balance_not_import_checkpoint');
  }
+ // Hold new ambiguous charges instead of carrying an old-system duplicate into the live balance.
+ const orderOf=row=>/订单[::]\s*(\d+)(?!\d)/.exec(String(row.detail||''))?.[1];
+ for(const row of changes.filter(r=>Number(r.score)<0)){
+  if(/新系统.{0,12}(?:已上|已扣|补扣)/.test(String(row.detail||'')))return conflict('manual_cross_system_charge_requires_review');
+  const order=orderOf(row);
+  if(order&&legacy.some(other=>String(other.legacyFinanceId)!==String(row.legacyFinanceId)&&Number(other.score)<0&&orderOf(other)===order))return conflict('new_duplicate_source_charge');
+ }
  let running=sourceBalance;
  for(const row of [...changes].reverse()){
   const after=running,before=running-Number(row.score);running=before;
@@ -54,7 +61,7 @@ async function reconcileLegacyCreditBalances(co,uid,runId) {
   const accounts=[],next={...u.legacyUserData};let applied=0;
   for(const [account,table] of [['Purse','UserExpDomP'],['SilverCoin','UserSIcon'],['UserExp','UserExpHis'],['UserPoint','UserUserPoint']]){
    const receipt=await t.oneOrNone('SELECT ids,checksum FROM "LegacyFinanceReceipt" WHERE company=$1 AND "studentId"=$2 AND account=$3 AND "runId"=$4',[co,uid,account,runId]);
-   const rows=await t.any('SELECT "objectId","sourceKey",score,"scoreBefore","hisTime","legacyFinanceId","legacyFinanceHash" FROM "'+table+'" WHERE company=$1 AND "userId"=$2',[co,uid]);
+   const rows=await t.any('SELECT "objectId","sourceKey",score,"scoreBefore","hisTime",detail,"legacyFinanceId","legacyFinanceHash" FROM "'+table+'" WHERE company=$1 AND "userId"=$2',[co,uid]);
    const previous=await t.oneOrNone('SELECT balance,"sourceBalance","localNet","sourceIds" FROM "LegacyCreditBalanceState" WHERE company=$1 AND "studentId"=$2 AND account=$3',[co,uid,account]);
    const legacy=rows.filter(r=>r.legacyFinanceId),local=rows.filter(r=>!r.legacyFinanceId);
    const actual=await t.one('SELECT md5(COALESCE(jsonb_agg(jsonb_build_object(\'id\',"legacyFinanceId",\'hash\',"legacyFinanceHash") ORDER BY "legacyFinanceId"::numeric),\'[]\'::jsonb)::text) checksum FROM "'+table+'" WHERE company=$1 AND "userId"=$2 AND "legacyFinanceId" IS NOT NULL',[co,uid]);

+ 21 - 0
scripts/tests/legacy-credit-balance.test.mjs

@@ -98,3 +98,24 @@ test('数据库修复原子更新两类课时,保留业务流水,重复执
   const protectedResult=await live.reconcileLegacyCreditBalances('co',1826,'repair-0001');assert.equal(protectedResult.conflicts,1);assert.equal((await one('SELECT "legacyUserData" b FROM "_User"')).b.SilverCoin,7);
  }finally{await db.close();}
 });
+
+test('核实重复扣课后的本地补偿,在重跑及后续旧系统正常扣课时保持',()=>{
+ const previous={balance:24,sourceBalance:24,localNet:0,sourceIds:['1']};
+ const legacy=[{legacyFinanceId:'1',score:24,scoreBefore:0,hisTime:'2026-09-01T00:00:00Z'}];
+ const local=[{objectId:'refund',sourceKey:'credit:adjust:verified-duplicate',score:2}];
+ const p={account:'Purse',current:26,sourceBalance:24,importedAt:'2026-09-01T00:00:00Z',legacy,local,receiptIds:['1'],previous};
+ const replay=plan(p);assert.equal(replay.status,'ready');assert.equal(replay.balance,26);assert.equal(replay.delta,0);
+ const next=plan({...p,previous:replay,sourceBalance:23,receiptIds:['1','2'],legacy:[...legacy,{legacyFinanceId:'2',score:-1,scoreBefore:24,hisTime:'2026-11-12T00:00:00Z'}]});assert.equal(next.status,'ready');assert.equal(next.balance,25);assert.equal(next.delta,-1);
+ assert.equal(plan({...p,current:25,previous:next,sourceBalance:23,receiptIds:['1','2'],legacy:[...legacy,{legacyFinanceId:'2',score:-1,scoreBefore:24,hisTime:'2026-11-12T00:00:00Z'}]}).delta,0);
+});
+
+test('已核过的旧订单再次新增扣课流水时,保留当前余额并标记重复来源',()=>{
+ const legacy=[{legacyFinanceId:'1',score:-1,scoreBefore:11,hisTime:'2026-11-01T00:00:00Z',detail:'学习结束,订单:123'},{legacyFinanceId:'2',score:-1,scoreBefore:10,hisTime:'2026-11-12T00:00:00Z',detail:'学习结束,订单:123'}];
+ const p={account:'SilverCoin',current:10,sourceBalance:9,importedAt:'2026-09-01T00:00:00Z',legacy,local:[],receiptIds:['1','2'],previous:{balance:10,sourceBalance:10,localNet:0,sourceIds:['1']}};
+ assert.equal(plan(p).reason,'new_duplicate_source_charge');
+ assert.equal(plan({...p,legacy:[legacy[0],{...legacy[1],detail:'学习结束,订单:1234'}]}).balance,9);
+});
+test('新增手工补记新系统已上课时进入复核,不再自动叠加扣减',()=>{
+ const p={account:'SilverCoin',current:0,sourceBalance:-2,importedAt:'2026-09-01T00:00:00Z',legacy:[{legacyFinanceId:'1',score:-2,scoreBefore:0,hisTime:'2026-11-12T00:00:00Z',detail:'管理员操作,备注:新系统已上2小时'}],local:[],receiptIds:['1'],previous:{balance:0,sourceBalance:0,localNet:0,sourceIds:[]}};
+ assert.equal(plan(p).reason,'manual_cross_system_charge_requires_review');
+});

+ 14 - 0
scripts/tests/legacy-repaired-lesson-sync.test.mjs

@@ -0,0 +1,14 @@
+import {test} from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import vm from 'node:vm';
+async function run({model=59,repaired=true,student='1828',order='840456'}={}){
+ let result;const db={none:async()=>{},any:async()=>[],one:async()=>({fingerprint:'f'}),oneOrNone:async(sql,args)=>{
+ if(sql.includes('LegacyDirectBatch'))return null;
+ if(sql.includes('FROM "LessonRecord"')&&sql.includes('sourceKey'))return repaired&&args[1]==='840456'?{objectId:'repair',xymz:'1828',sourceKey:'repair:legacy-completed-appointment:co:840456'}:null;
+ if(sql.startsWith('SELECT to_jsonb(c)'))return null;if(sql.startsWith('SELECT to_jsonb(a)'))return null;throw Error('STOP_BEFORE_INSERT');},tx:async f=>f(db)};
+ const source=fs.readFileSync(new URL('../cloud/legacy-direct-sync.js',import.meta.url),'utf8').replaceAll('__COMPANY_ID__',"'co'").replaceAll('__SPECS__',JSON.stringify({59:{className:'LessonRecord'},56:{className:'DailyStudyRecord'}})).replaceAll('__WINDOWS__','[]');
+ const ctx=vm.createContext({Psql:db,directLease:async()=>{},console});vm.runInContext(source,ctx);
+ const user={fetch:async()=>{},get:k=>k==='adminRoleKey'?'super-admin':k==='company'?{id:'co'}:false};const res={status(){return this},json(j){result=j;return j}};
+ await ctx.handler({user,body:{params:{modelId:model,action:'append',runId:'r',batchKey:'b',rows:[{generalId:999,addon:{id:999,xymz:student,yyds:order}}]}}},res);return result;
+}
+test('旧课后记录重新出现时,同预约已补录课次被保护并留下冲突',async()=>{const r=await run();assert.equal(r.success,true);assert.equal(r.data.createdAddon,0);assert.equal(r.data.conflicts.length,1);assert.match(r.data.conflicts[0].reason,/补录/);});
+test('同预约来源学生错误也必须保留补录并要求复核',async()=>{const r=await run({student:'999'});assert.equal(r.success,true);assert.equal(r.data.conflicts.length,1);});
+test('正常历史课次和单词学习明细仍进入原导入流程',async()=>{for(const opts of [{repaired:false},{order:'other'},{model:56}]){const r=await run(opts);assert.equal(r.success,false);assert.match(r.message,/Missing object IDs/);}});