Przeglądaj źródła

fix(review): avoid fee summary query timeout

彭峰 3 godzin temu
rodzic
commit
2cada0749d

+ 44 - 0
perf/review-fee-loading-2026-11-11/REPORT.md

@@ -0,0 +1,44 @@
+# 老师抗遗忘费用页加载修复
+
+## 范围与判定
+
+用户反馈老师端 `/pages/member/ke_fuxi` 全部月份长时间加载后显示“请求暂未完成,请刷新重试”。判定能用:首批费用明细、总记录数及全部分页费用合计正常返回。目标是消除 20 秒客户端超时,并将关键查询 p75 至少降低一半;页面展示、分页和计费规则保持一致。本轮只发布后端读取修复,已有用户指令授权修复线上问题。
+
+## 根因与基线
+
+按老师 1535 的真实 Parse.User 对象及真实页面参数提取 6 条数据查询。其余查询可返回:学员元数据约 1.13 秒、汇总 0.54 秒、日期 0.42 秒、计数 0.38 秒、明细 1.31 秒;费用合计单独执行 12 秒仍未返回,事务超时取消。客户端等待上限为 20 秒。
+
+费用合计将 MemoryPracticeRecord 与包含历史核定、差额及纠正关系的 ReviewRewardIncome 直接连接,优化器展平后不能保持按选定老师任务逐个查费用的执行范围。将费用视图放入与任务关联的 LATERAL 子查询并用 OFFSET 0 保留该关联,金额求和及原有来源、身份、状态、月份条件完全保持。
+
+## 交替配对测量
+
+同一生产数据库、同一老师及同一筛选参数,10 组交替 before/after 只读事务。为控制诊断负载,配对时旧查询限制为 3 秒,全部超时;新查询全部成功且每次费用合计为 174。旧版本耗时是删失下界,不能将 3 秒当作实际完成耗时。
+
+| 指标 | 旧查询 | 新查询 |
+| --- | ---: | ---: |
+| 成功次数 | 0/10(3 秒取消) | 10/10 |
+| p50 | >3,000 ms | 438 ms |
+| p75 | >3,000 ms | 492 ms |
+| p95 | >3,000 ms | 782 ms |
+| 金额 | 超时未返回 | 每次 174 元 |
+
+新查询 p75 相对旧下界至少减少 83.6%。另一次旧查询 12 秒取消、新查询 580 ms 成功,作为定位证据,不并入配对分位数。这里是服务器查询测量,不冒充手机端可交互时间或 CrUX 数据;本次没有 CrUX 数据。
+
+## 回归与上线后核验
+
+新增真实 PGlite 集成回归:费用求和、月份及学员过滤、空月份零元、不同帐套隔离,以及保持费用读取关联范围的性能护栏。先确认旧查询未满足护栏,再修改;相关 24 项测试全部通过,cloud:validate 的 11 个函数定义通过。
+
+仅更新生产 `xiaoshu.app.gateway` 中 `appReviewPage`,其他函数逐项一致,发布后完整代码逐字回读一致。未修改前端资源或数据库业务记录。
+
+从发布后的完整网关代码执行原 handler,使用数据库中老师本人对象进行只读检查,包括认证/帐套检查、明细、合计、元数据及实时版本查询;短期维护封装仅当前超级管理员可调用并在 finally 清理。以下是服务端完整处理耗时,不含手机网络及渲染:
+
+| 范围 | 耗时 | 已复习记录 | 首批条数 | 合计 |
+| --- | ---: | ---: | ---: | ---: |
+| 全部月份 | 2,648 ms | 1,569 | 20 | 174 元 |
+| 2026-11 | 1,521 ms | 36 | 20 | 36 元 |
+| 2026-10 | 1,582 ms | 76 | 20 | 76 元 |
+| 2026-09 | 1,290 ms | 62 | 20 | 62 元 |
+
+前后正常业务输出由原有跨版本逐字段测试保证一致,涵盖每页、统计、收入、日期、格式化身份及语法写作来源。页面及金额口径无变更;本次没有手机实机或人工视觉验收,不宣称手机实际耗时为上述数字。
+
+发布备份、精确查询、只读执行计划、10 组原始样本、源代码回执和完整网关读取结果在本机受保护目录 `~/.local/state/xiaoshu/qa/2026-11-11-review-fee-loading/`,不提交会话及个人明细。若回退,仅恢复备份中的 appReviewPage;先检查最新代码,保留后续其他函数改动。性能护栏回归位于 `scripts/tests/review-income-query.test.mjs`。

+ 2 - 1
scripts/cloud/mobile-teaching.js

@@ -69,7 +69,8 @@ async function appReviewPage(input,current) {
   // Historical createdAt is the import time; the study model preserves the original learning time.
   const rowsPromise=Psql.query('SELECT c.*,row_to_json(m) AS "__addon",COALESCE(n."nodeName",CASE WHEN '+notesSource+' THEN \'语法写作复习\' END) AS "kc_title",d."dqrq" AS "learned_date",dc."createTime" AS "learned_at",r."amount" AS money,r."status" AS "rewardStatus",r."ineligibleReason" AS "ineligibleReason",r."completedAt" AS "completedAt",r."originalAmount" AS "originalAmount",r."correctionReason" AS "correctionReason",r."correctionAmount" AS "correctionAmount",'+reviewStudentNameSql()+' AS "honeyName",(SELECT COUNT(*) FROM "MemoryPracticeRecord" sibling WHERE sibling."company"=m."company" AND sibling."yhid"=m."yhid" AND sibling."xxjlid"=m."xxjlid" AND (sibling."kywsj",sibling."id")<=(m."kywsj",m."id"))::int AS "reviewNo" FROM "MemoryPracticeRecord" m JOIN LATERAL (SELECT * FROM "CommonModel" c WHERE c."company"=m."company" AND c."modelId"=60 AND c."itemId"=m."id" OFFSET 0) c ON TRUE LEFT JOIN "Node" n ON n."company"=m."company" AND n."nodeId"::text=m."kcid"::text LEFT JOIN LATERAL (SELECT * FROM "CommonModel" dc WHERE dc."company"=m."company" AND dc."modelId"=56 AND dc."generalId"::text=m."xxjlid"::text OFFSET 0) dc ON TRUE LEFT JOIN "DailyStudyRecord" d ON d."company"=m."company" AND d."id"=dc."itemId" LEFT JOIN "ReviewRewardIncome" r ON r."company"=m."company" AND r."taskId"=m."objectId" LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+where+(input.scope==='today'?' ORDER BY CASE WHEN m."fxzt"=\'1\' THEN 1 ELSE 0 END,m."kywsj",m."id"':' ORDER BY m."kywsj" DESC,m."id" DESC')+' LIMIT $'+(args.length+1)+' OFFSET $'+(args.length+2),[...args,p.size,(p.index-1)*p.size]);
   const versionTarget=coach&&number(input.studentId)?number(input.studentId):uid;
-  const [students,summary,dates,count,rows,income,versions]=await Promise.all([studentsPromise,summaryPromise,datesPromise,countPromise,rowsPromise,coach&&input.includeIncome!==false?Psql.one('SELECT COALESCE(SUM(r."amount"),0) AS income FROM "MemoryPracticeRecord" m JOIN "ReviewRewardIncome" r ON r."company"=m."company" AND r."taskId"=m."objectId" WHERE '+where,args).then(row=>number(row.income)):0,appLiveVersionSnapshot(versionTarget,['review','account'],current)]);
+  // Keep the receipt view correlated to the selected tasks; flattening it can scan tenant-wide history.
+  const [students,summary,dates,count,rows,income,versions]=await Promise.all([studentsPromise,summaryPromise,datesPromise,countPromise,rowsPromise,coach&&input.includeIncome!==false?Psql.one('SELECT COALESCE(SUM(r."amount"),0) AS income FROM "MemoryPracticeRecord" m JOIN LATERAL (SELECT r."amount" FROM "ReviewRewardIncome" r WHERE r."company"=m."company" AND r."taskId"=m."objectId" OFFSET 0) r ON TRUE WHERE '+where,args).then(row=>number(row.income)):0,appLiveVersionSnapshot(versionTarget,['review','account'],current)]);
   return {items:rows.map(row=>{const addon=row.__addon;delete row.__addon;return{...legacyAliases({...row,...addon},'CommonModel'),money:number(row.money),rewardStatus:row.rewardStatus||'pending'};}),page:mobilePageMeta(count.total,p),summary:{...summary,todayTotal:number(summary.today),todayCompleted:number(summary.todayCompleted),todayPending:number(summary.today)-number(summary.todayCompleted),overduePending:number(summary.overduePending),income,incomeScope:input.month||'all'},dates,students,legacyReviewSync,serverToday:today,...mobileSource(),...versions};
 }
 

+ 21 - 0
scripts/tests/review-income-query.test.mjs

@@ -0,0 +1,21 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import vm from 'node:vm';
+import {PGlite} from '@electric-sql/pglite';
+
+test('income lookup stays correlated to visible teacher tasks and preserves all/month/student totals',async()=>{
+ const db=new PGlite();try{
+  await db.exec(readFileSync(new URL('./fixtures/teacher-review-schema.sql',import.meta.url),'utf8'));
+  await db.exec(`INSERT INTO "ReviewRewardIncome"(company,"taskId",amount,status) VALUES('co','r1',1,'earned'),('co','r2',0.5,'earned'),('other','r1',99,'earned');`);
+  const query=async(s,a)=>(await db.query(s,a)).rows;const incomeQueries=[];
+  const ctx=vm.createContext({Date,Promise,DEFAULT_COMPANY_ID:'co',number:(v,f=0)=>Number(v)||f,authorizeRequestedUser:async()=>30,appReviewTarget:async()=>20,appOverviewToday:()=> '2026-10-14',legacyAliases:r=>r,appLiveVersionSnapshot:async()=>({}),syncLegacyReviews:async()=>({}),Psql:{query,one:async(s,a)=>{if(s.includes(' AS income'))incomeQueries.push(s);return (await query(s,a))[0];}}});
+  vm.runInContext(readFileSync(new URL('../cloud/mobile-teaching.js',import.meta.url),'utf8'),ctx);
+  const coach={get:()=>3};const first=await ctx.appReviewPage({status:'completed'},coach);
+  assert.equal(first.summary.income,0.5);assert.equal(first.summary.incomeScope,'all');
+  await ctx.appReviewPage({status:'completed',month:'2026-10',studentId:20},coach);
+  assert.equal((await ctx.appReviewPage({status:'completed',month:'2020-01'},coach)).summary.income,0);
+  assert.equal(incomeQueries.length,3);
+  for(const sql of incomeQueries)assert.match(sql,/JOIN LATERAL \(SELECT r\."amount" FROM "ReviewRewardIncome"[\s\S]*r\."taskId"=m\."objectId" OFFSET 0\) r ON TRUE/,'fee view must be looked up after teacher task selection, not expanded across all tenant rewards');
+ }finally{await db.close();}
+});