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

fix: enable teacher feedback for historical completed lessons

彭峰 3 часов назад
Родитель
Сommit
8c0538e34a

+ 28 - 0
docs/qa/lesson-feedback-entry-2026-10-15.md

@@ -0,0 +1,28 @@
+# 历史已结课课程补反馈入口修复(2026-10-15)
+
+## 问题和结果
+
+销课列表把没有 `LessonCompletionReport` 的历史课程标为反馈待补,但 `lessonOrderCapabilities` 只有在报告已存在时才返回 `editFeedback`。详情页也要求存在报告对象才能打开编辑器,数据库更新函数则要求恰好一份报告,导致历史课程没有补交路径。
+
+- 已结课(30/40)、未取消的本课老师获得补交/修改反馈权限。
+- 学习报告“完课反馈”右侧显示“补充反馈”;已提交时显示“修改报告”。未提交状态明确标为老师反馈待补。
+- 历史无报告时,编辑器使用已验证课程详情的预约版本;已有报告沿用报告版本。
+- 首次提交在反馈事务中核对身份、老师归属、课程状态、预约版本及唯一上课记录归属,创建一份报告并保存反馈;不重跑结课、扣课时、课酬或复习任务生成。
+- 旧记录中无法确认的报告快照字段留空,不用当前时间伪造历史上下课时间;学习统计仍读原学习记录和复习任务。
+- 保留请求幂等、并发序列化、旧版本拒绝和事务回滚。学生仍通过课程详情提交独立评价。
+
+## 验证
+
+- `npm run test:lesson-workflow`:16/16 通过。新增历史无报告首次补交、同请求并发重试、不同请求旧版本拒绝、越权拒绝、失败回滚;核对课时凭据、学习记录、复习任务、评价、工资快照未改变。
+- `npm run build:mobile`:通过;现有样式预算/CommonJS 警告。
+- `npm run cloud:validate`:11 个函数定义通过。
+- `node scripts/check-lesson-feedback-browser.mjs`:通过。390px 浏览器验证报告入口、自动打开表单、失败保留输入、重试保存、已提交报告展示、pending 报告入口及学生评价。仅调用反馈接口,不调用结课接口。
+- 线上实际教师会话只读回读 `app_lesson_report` 和 `e_order_detail`:预约 858318(截图对应熊小俊,2026-10-04 14:00)两处均返回 `editFeedback`;未代老师填写或提交业务反馈。
+
+## 发布和回滚
+
+已发布 `2026-10-15-feedback-entry-v13`:只更新数据库 `xs_lesson_feedback_update` 定义和 app gateway 中权限判断及版本标记,两个 ops gateway 代码保持原样。发布前确认没有运行中的旧系统同步任务,备份实际线上函数及 SQL 定义;发布后核对保存代码、数据库定义及实际网关版本响应。
+
+私有备份和读回结果位于 `~/.local/state/xiaoshu/cloud-releases/2026-10-15-feedback-entry/`,不提交账号会话和生产数据。回滚时恢复备份的单一 SQL 函数定义及 app gateway 代码,保留新产生的报告、原教学/计薪凭据及幂等命令,不删除业务记录。
+
+前端完成本地构建和浏览器验收,未部署线上前端;实际微信 WebView 未验收。上述浏览器保存使用隔离接口数据,不等同于真实用户写入验收。

+ 5 - 5
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.html

@@ -86,7 +86,7 @@
       <dialog #completionDialog class="completion-dialog" aria-labelledby="completion-title" aria-describedby="completion-description" (cancel)="closeCompletionFeedback($event)">
         <section class="completion-feedback">
           <header>
-            <div><h2 id="completion-title">{{ editingFeedback() ? '修改学习报告' : '完课反馈' }}</h2><p id="completion-description">{{ editingFeedback() ? '修改本次课堂反馈,保存后学员即可查看。' : '填写反馈并提交后,结束本次课程。' }}</p></div>
+            <div><h2 id="completion-title">{{ editingFeedback() ? (feedbackPending() ? '补充课堂反馈' : '修改学习报告') : '完课反馈' }}</h2><p id="completion-description">{{ editingFeedback() ? '填写本次课堂反馈,保存后学员即可查看。' : '填写反馈并提交后,结束本次课程。' }}</p></div>
             <button type="button" class="feedback-close" aria-label="关闭完课反馈" [disabled]="submitting()" (click)="closeCompletionFeedback()"><lucide-icon [img]="icons.X" [size]="20" /></button>
           </header>
           <p class="feedback-hint">{{ editingFeedback() ? '本次修改仅更新反馈内容,课时和学习记录保持不变。' : '日期、时间、课时扣减、生词和抗遗忘数据由系统自动记录' }}</p>
@@ -121,7 +121,7 @@
           @if (feedbackError()) { <p class="feedback-error" role="alert">{{ feedbackError() }}</p> }
           <footer class="feedback-actions">
             <button type="button" class="secondary-button" [disabled]="submitting()" (click)="closeCompletionFeedback()">{{ editingFeedback() ? '取消修改' : '返回课程' }}</button>
-            <button type="button" class="primary-button" [disabled]="submitting() || (!editingFeedback() && (classStudyLoading() || !!classStudyError() || !!classStudy()?.pending))" (click)="finishCoachLesson()">{{ submitting() ? (editingFeedback() ? '正在保存…' : '正在提交…') : (editingFeedback() ? '保存修改' : '提交并结束课程') }}</button>
+            <button type="button" class="primary-button" [disabled]="submitting() || (!editingFeedback() && (classStudyLoading() || !!classStudyError() || !!classStudy()?.pending))" (click)="finishCoachLesson()">{{ submitting() ? (editingFeedback() ? '正在保存…' : '正在提交…') : (editingFeedback() ? (feedbackPending() ? '提交反馈' : '保存修改') : '提交并结束课程') }}</button>
           </footer>
         </section>
       </dialog>
@@ -147,9 +147,9 @@
     @if (feedback(); as f) {
       <section class="lesson-feedback">
         <header class="feedback-heading"><div><h2>完课反馈</h2><small>课堂内容与下次学习安排</small></div>
-          @if (!isStudentView && can('editFeedback')) { <button type="button" class="edit-report" (click)="openFeedbackEditor()" [disabled]="submitting()">{{feedback()?.['feedbackStatus']==='pending'?'补充反馈':'修改报告'}}</button> }
+          @if (!isStudentView && can('editFeedback')) { <button type="button" class="edit-report" (click)="openFeedbackEditor()" [disabled]="submitting()">{{feedbackPending()?'补充反馈':'修改报告'}}</button> }
         </header>
-        @if (feedbackSaved()) { <p class="saved-notice" role="status">报告修改已保存</p> }
+        @if (feedbackSaved()) { <p class="saved-notice" role="status">课堂反馈已保存</p> }
         @if(f['feedbackStatus']==='pending'){<p role="status">课程已结束,课堂反馈待老师补充。</p>}@else{<app-lesson-feedback [feedback]="f" />}
       </section>
     }
@@ -158,7 +158,7 @@
       @if (can('continue')) { <button type="button" [class.primary-button]="!can('complete')" [class.secondary-button]="can('complete')" [disabled]="submitting() || coursesLoading() || unitsLoading()" (click)="startOrContinue()">继续课程</button> }
       @if (can('complete')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="endLesson()">结束课程</button> }
       @if (can('review')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="finishStudentLesson()">{{submitting()?'正在提交…':'提交评价'}}</button> }
-      @if (status()>=30 && !order()['cancelled']) { @if (!isStudentView && can('editFeedback')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="openFeedbackEditor()">{{feedback()?.['feedbackStatus']==='pending'?'补充反馈':'修改报告'}}</button> } <button type="button" class="secondary-button" (click)="openReport()">查看学习报告</button><span>{{order()['reviewStatus']==='submitted'?'学生已评价':'已结课 · 待学生评价'}}</span> }
+      @if (status()>=30 && !order()['cancelled']) { @if (!isStudentView && can('editFeedback')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="openFeedbackEditor()">{{feedbackPending()?'补充反馈':'修改报告'}}</button> } <button type="button" class="secondary-button" (click)="openReport()">查看学习报告</button><span>{{order()['reviewStatus']==='submitted'?'学生已评价':'已结课 · 待学生评价'}}</span> }
     </div>
   }
 </section>

+ 7 - 5
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts

@@ -63,6 +63,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
   readonly status = computed(() => Number(this.order()['dszt'] ?? this.order()['status'] ?? 0));
 
   readonly feedback=computed(()=>this.order()['feedback'] as Record<string,unknown>|null);
+  readonly feedbackPending = computed(() => !this.feedback() || this.feedback()?.['feedbackStatus'] === 'pending');
   selectedCourse = '';
   selectedBindingId = '';
   coachComment = '';
@@ -86,7 +87,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
   constructor() {
     effect(() => {
       const dialog = this.completionDialog()?.nativeElement;
-      if (dialog && !this.autoEditOpened && this.can('editFeedback') && this.feedback() && this.route.snapshot.queryParamMap.get('editReport') === '1') { this.autoEditOpened = true; this.openFeedbackEditor(); }
+      if (dialog && !this.autoEditOpened && this.can('editFeedback') && this.route.snapshot.queryParamMap.get('editReport') === '1') { this.autoEditOpened = true; this.openFeedbackEditor(); }
       if (dialog && !this.isStudentView && !this.order()['cancelled'] && this.can('complete') && this.route.snapshot.queryParamMap.get('feedback') === '1') dialog.showModal();
     }, { allowSignalWrites: true });
   }
@@ -315,8 +316,8 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
   }
 
   openFeedbackEditor(): void {
-    if (this.isStudentView || !this.can('editFeedback') || !this.feedback() || this.submitting()) return;
-    const feedback = this.feedback()!;
+    if (this.isStudentView || !this.can('editFeedback') || this.submitting()) return;
+    const feedback = this.feedback() || {};
     this.contentSummary = String(feedback['contentSummary'] || '');
     this.reviewStatus = String(feedback['reviewStatus'] || '');
     this.reviewSummary = String(feedback['reviewSummary'] || '');
@@ -326,7 +327,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
     this.noHomework = feedback['noHomework'] === true;
     this.coachComment = String(feedback['coachComment'] ?? this.practiceRecord()['pldp'] ?? '');
     this.notes = String(feedback['notes'] || '');
-    this.feedbackRevision = String(feedback['revision'] || feedback['updatedAt'] || '');
+    this.feedbackRevision = String(feedback['revision'] || feedback['updatedAt'] || this.order()['updatedAt'] || '');
     this.editingFeedback.set(true);
     this.feedbackError.set('');
     this.feedbackSaved.set(false);
@@ -362,6 +363,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
     if (!this.noHomework && !this.homework.trim()) { this.feedbackError.set('请填写作业安排,或选择“无作业”'); return; }
     this.submitting.set(true);
     const editing = this.editingFeedback();
+    const supplement = this.feedbackPending();
     this.api.post(editing ? 'app_lesson_feedback_update' : 'app_lesson_complete', {
       orderId: this.orderId,
       expectedUpdatedAt: editing ? this.feedbackRevision : this.order()['updatedAt'],
@@ -377,7 +379,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
         coachComment: this.coachComment.trim(),
       }),
     }).pipe(finalize(() => this.submitting.set(false))).subscribe({
-      next: () => { this.completionDialog()?.nativeElement.close(); this.feedbackSaved.set(editing); this.platform.toast(editing ? '报告修改已保存' : '课程已结课,学习报告已生成'); this.load(); },
+      next: () => { this.completionDialog()?.nativeElement.close(); this.feedbackSaved.set(editing); this.platform.toast(editing ? (supplement ? '课堂反馈已提交' : '报告修改已保存') : '课程已结课,学习报告已生成'); this.load(); },
       error: (error: ApiError) => this.feedbackError.set(error.message),
     });
   }

+ 3 - 3
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.html

@@ -6,7 +6,7 @@
       <h1>{{ r.order['kcmc'] || r.order['yykcmc'] || '课程学习报告' }}</h1>
       <p>{{ r.order['HoneyName'] || '学员' }}<span aria-hidden="true"> · </span>{{ dateLabel(r.order['yysj']) }}</p>
       @if (r.order['cancelled']) { <span class="status-badge">此预约已取消</span> }
-      @else { <span class="status-badge">{{ r.feedback ? '已结课' : '反馈待提交' }}</span> }
+      @else { <span class="status-badge">{{ r.feedback && r.feedback['feedbackStatus'] !== 'pending' ? '老师反馈已提交' : '老师反馈待补' }}</span> }
     </header>
     <div class="learning-stats" aria-label="本次学习统计">
       <div><strong>{{ count('learned') }}</strong><span>学习单词</span></div>
@@ -15,9 +15,9 @@
     </div>
     <section class="report-card">
       <header><h2><lucide-icon [img]="icons.ClipboardList" [size]="19" aria-hidden="true" />完课反馈</h2>
-        @if ($any(r.order['allowedActions'])?.includes('editFeedback')) { <a class="edit-link" routerLink="/pages/shop/get_order" [queryParams]="{id:orderId,editReport:1}">修改报告</a> }
+        @if ($any(r.order['allowedActions'])?.includes('editFeedback')) { <a class="edit-link" routerLink="/pages/shop/get_order" [queryParams]="{id:orderId,editReport:1}">{{ !r.feedback || r.feedback['feedbackStatus'] === 'pending' ? '补充反馈' : '修改报告' }}</a> }
       </header>
-      @if (r.feedback; as f) { <app-lesson-feedback [feedback]="f" /> }
+      @if (r.feedback && r.feedback['feedbackStatus'] !== 'pending') { <app-lesson-feedback [feedback]="r.feedback" /> }
       @else { <p class="empty-copy">老师尚未提交反馈,提交后将在这里展示。</p> }
     </section>
     <section class="report-card">

+ 59 - 0
scripts/check-lesson-feedback-browser.mjs

@@ -0,0 +1,59 @@
+#!/usr/bin/env node
+// Isolated local fixture browser acceptance. Does not read or write online accounts.
+import {chromium} from 'playwright';
+import {createServer} from 'node:http';
+import {readFileSync,existsSync,mkdirSync,writeFileSync} from 'node:fs';
+import {resolve,extname} from 'node:path';
+import assert from 'node:assert/strict';
+const root=resolve('dist/xiaoshu-mobile/browser'),out=resolve('tmp/lesson-feedback-qa');mkdirSync(out,{recursive:true});
+const server=createServer((req,res)=>{let file=resolve(root,'.'+decodeURIComponent(new URL(req.url,'http://local').pathname));if(!file.startsWith(root+'/'))file=root+'/index.html';if(!existsSync(file)||!extname(file))file=root+'/index.html';try{res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.css':'text/css','.png':'image/png','.svg':'image/svg+xml','.woff2':'font/woff2'})[extname(file)]||'application/octet-stream');res.end(readFileSync(file));}catch{res.statusCode=404;res.end();}});
+await new Promise(r=>server.listen(0,'127.0.0.1',r));const origin='http://127.0.0.1:'+server.address().port;
+const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
+const errors=[],calls=[];
+let feedback=null,failSave=false,role=3;
+const revision='2026-10-04 06:00:00.123456+00';
+const order=()=>({GeneralID:901,szyh:100,pl:300,HoneyName:'测试学员',kcid:801,kcmc:'历史课程',dszt:30,updatedAt:revision,allowedActions:role===3?['editFeedback']:['review'],reviewStatus:'pending',yysj:'2026-10-04 14:00',feedback});
+try {
+ const context=await browser.newContext({viewport:{width:390,height:844}});
+ await context.addInitScript(()=>{if(!localStorage.getItem('xiaoshu.angular.session'))localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:local-fixture-only',user:{userId:300,groupId:3,honeyName:'测试老师'}}));});
+ await context.route('**/*',async route=>{
+  const u=new URL(route.request().url());if(u.origin!==origin)return route.abort();
+  if(!u.pathname.startsWith('/cloud-functions/'))return route.continue();
+  const {params:p}=route.request().postDataJSON();calls.push(p);let result=[];
+  if(p.action==='app_lesson_report')result={order:order(),feedback,records:[],antiForget:{total:10,completed:0}};
+  if(p.action==='e_order_detail')result=[order()];
+  if(p.action==='app_lesson_study')result={batches:[],record:null,pending:0};
+  if(p.action==='app_live_versions')result={version:'1',scopeVersions:{},syncStatus:{state:'current'}};
+  if(p.action==='app_lesson_feedback_update'){
+   if(failSave)return route.fulfill({status:409,json:{retcode:-1,retmsg:'模拟保存失败'}});
+   assert.equal(p.expectedUpdatedAt,revision);
+   feedback={...JSON.parse(p.feedback),feedbackStatus:'submitted',revision:'2026-10-15 06:00:00.987654+00'};result={saved:true};
+  }
+  await route.fulfill({json:{retcode:1,result}});
+ });
+ const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));
+ await page.goto(origin+'/pages/shop/baobiao?sid=901');
+ await page.getByRole('link',{name:'补充反馈',exact:true}).waitFor();
+ assert.match(await page.locator('.status-badge').innerText(),/老师反馈待补/);
+ await page.screenshot({path:out+'/missing-report-390.png',fullPage:true});
+ await page.getByRole('link',{name:'补充反馈',exact:true}).click();
+ await page.locator('dialog[open]').waitFor();assert.match(await page.locator('#completion-title').innerText(),/补充课堂反馈/);
+ await page.getByPlaceholder('例:人教版三年级 Unit 3,重点词汇与句型').fill('历史课程补充学习内容');
+ await page.getByPlaceholder('说明当前进度与下次起点').fill('已学完第一单元');
+ await page.getByText('本次无作业',{exact:true}).click();
+ failSave=true;await page.getByRole('button',{name:'提交反馈',exact:true}).click();await page.locator('.feedback-error').waitFor();
+ assert.equal(await page.locator('dialog[open]').count(),1);assert.equal(await page.getByPlaceholder('说明当前进度与下次起点').inputValue(),'已学完第一单元');
+ failSave=false;await page.getByRole('button',{name:'提交反馈',exact:true}).click();await page.locator('dialog[open]').waitFor({state:'hidden'});
+ assert.equal(calls.filter(c=>c.action==='app_lesson_complete').length,0);
+ await page.goto(origin+'/pages/shop/baobiao?sid=901');await page.getByRole('link',{name:'修改报告',exact:true}).waitFor();
+ assert.match(await page.locator('.lesson-report').innerText(),/历史课程补充学习内容/);
+ // Pending modern reports use the same clear supplement action.
+ feedback={feedbackStatus:'pending',revision};await page.reload();await page.getByRole('link',{name:'补充反馈',exact:true}).waitFor();
+ assert.match(await page.locator('.status-badge').innerText(),/老师反馈待补/);
+ role=1;feedback=null;await page.evaluate(()=>localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:student-fixture',user:{userId:100,groupId:1,honeyName:'测试学员'}})));
+ await page.reload();await page.locator('.report-card').first().waitFor();assert.equal(await page.getByRole('link',{name:'补充反馈',exact:true}).count(),0);
+ await page.goto(origin+'/pages/shop/get_order?id=901');await page.getByRole('button',{name:'提交评价',exact:true}).waitFor();assert.equal(await page.getByRole('button',{name:'补充反馈',exact:true}).count(),0);
+ assert.deepEqual(errors,[]);
+ writeFileSync(out+'/result.json',JSON.stringify({passed:true,checks:['历史报告补交入口与自动打开表单','保存失败保留输入,重试成功后报告可见','仅调用反馈接口,不调用结课接口','待补报告文案','学生没有老师补交入口,保留独立评价'],environment:'local production build + isolated API fixtures; not real WeChat'},null,2));
+ console.log('Feedback browser checks passed');
+} finally {await browser.close();await new Promise(r=>server.close(r));}

+ 1 - 1
scripts/cloud/lesson-workflow.js

@@ -73,7 +73,7 @@ async function lessonOrderCapabilities(order, current) {
     else if (status === 0) actions.push('start');
     if (status === 10) actions.push('continue','complete');
     if ([11,20].includes(status)) actions.push('complete');
-    if ([30,40].includes(status) && await Psql.oneOrNone('SELECT \"objectId\" FROM \"LessonCompletionReport\" WHERE company=$1 AND \"appointmentGeneralId\"::text=$2 LIMIT 1',[DEFAULT_COMPANY_ID,id])) actions.push('editFeedback');
+    if ([30,40].includes(status)) actions.push('editFeedback');
   }
   const reviewed = Boolean(review || number(lesson && lesson.pf) > 0);
   if (!order.cancelled && student && [30,40].includes(status) && !reviewed) actions.push('review');

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


+ 18 - 3
scripts/sql/lesson-workflow.sql

@@ -207,7 +207,7 @@ REVOKE ALL ON FUNCTION xs_admin_lesson_correction(text,text,text,text) FROM PUBL
 -- Feedback-only amendment: never re-run completion, credit or learning projections.
 CREATE OR REPLACE FUNCTION xs_lesson_feedback_update(co text,gid text,actor text,actor_uid numeric,expected timestamptz,request_key text,feedback jsonb)
 RETURNS jsonb LANGUAGE plpgsql AS $$
-DECLARE a "CourseAppointment"; c "CommonModel"; report "LessonCompletionReport";
+DECLARE a "CourseAppointment"; c "CommonModel"; report "LessonCompletionReport"; lesson "LessonRecord";
  previous "LessonWorkflowCommand"; result jsonb; old_feedback jsonb;
 BEGIN
  IF request_key IS NULL OR length(request_key)<10 OR length(request_key)>160 THEN RAISE EXCEPTION '缺少有效请求编号';END IF;
@@ -227,9 +227,24 @@ BEGIN
   IF previous.fingerprint<>jsonb_build_object('action','feedback_update','id',gid,'feedback',feedback) THEN RAISE EXCEPTION '请求编号已用于其他内容';END IF;
   RETURN previous.result - 'previousFeedback';
  END IF;
- IF (SELECT count(*) FROM "LessonCompletionReport" WHERE company=co AND "appointmentGeneralId"::text=gid)<>1 THEN RAISE EXCEPTION '报告不存在或不唯一,请联系管理员核对';END IF;
+ IF (SELECT count(*) FROM "LessonCompletionReport" WHERE company=co AND "appointmentGeneralId"::text=gid)>1 THEN RAISE EXCEPTION '报告不存在或不唯一,请联系管理员核对';END IF;
  SELECT * INTO report FROM "LessonCompletionReport" WHERE company=co AND "appointmentGeneralId"::text=gid FOR UPDATE;
- IF expected IS NULL OR report."updatedAt" IS DISTINCT FROM expected THEN RAISE EXCEPTION '报告已被更新,请关闭编辑并刷新详情后重试';END IF;
+ -- Old completed lessons may have no report. First submission uses the appointment
+ -- revision from the verified detail; later edits use the report revision.
+ IF report."objectId" IS NULL THEN
+  IF expected IS NULL OR a."updatedAt" IS DISTINCT FROM expected THEN RAISE EXCEPTION '预约已更新,请刷新详情后重试';END IF;
+  IF (SELECT count(*) FROM "LessonRecord" WHERE company=co AND yyds::text=gid)<>1 THEN RAISE EXCEPTION '历史上课记录缺失或不唯一,请联系管理员核对';END IF;
+  SELECT * INTO lesson FROM "LessonRecord" WHERE company=co AND yyds::text=gid FOR UPDATE;
+  IF lesson.xymz::text IS DISTINCT FROM a.szyh::text OR lesson.jsmz::text IS DISTINCT FROM a.pl::text THEN RAISE EXCEPTION '历史上课记录归属不一致,请联系管理员核对';END IF;
+  INSERT INTO "LessonCompletionReport" SELECT * FROM jsonb_populate_record(NULL::"LessonCompletionReport",jsonb_build_object(
+   'objectId',xs_object_id(),'company',co,'createdAt',clock_timestamp(),'updatedAt',clock_timestamp(),
+   'sourceKey','cloud:lesson-feedback:'||co||':'||gid,'appointmentId',a."objectId",'appointmentGeneralId',gid,
+   'lessonId',lesson."objectId",'studentId',a.szyh,'coachId',a.pl,'courseCategoryKey',a."courseCategoryKey",
+   'courseCategoryName',a."courseCategoryName",'deliveryMode',a."deliveryMode",'durationMinutes',a."durationMinutes",
+   'creditCosts',a."creditCostsSnapshot",'feedbackStatus','pending')) RETURNING * INTO report;
+ ELSE
+  IF expected IS NULL OR report."updatedAt" IS DISTINCT FROM expected THEN RAISE EXCEPTION '报告已被更新,请关闭编辑并刷新详情后重试';END IF;
+ END IF;
  SELECT jsonb_object_agg(key,value) INTO old_feedback FROM jsonb_each(to_jsonb(report)) WHERE key IN ('contentSummary','reviewStatus','reviewSummary','trainingStatus','trainingSummary','homework','noHomework','coachComment','notes');
  UPDATE "LessonCompletionReport" SET
  "contentSummary"=feedback->>'contentSummary',"reviewStatus"=feedback->>'reviewStatus',"reviewSummary"=feedback->>'reviewSummary',

+ 40 - 0
scripts/tests/lesson-workflow.test.mjs

@@ -257,3 +257,43 @@ test('组合迁移在既有安装上一笔事务执行,可重跑且保留完
  assert.deepEqual(await one('SELECT count(*)::int AS n FROM "LessonCompletionReport"'),before);
  assert.ok((await one("SELECT to_regprocedure('xs_review_complete_result(text,text,text)') AS f")).f);
 });
+
+test('历史已结课且无报告可首次补交,重复并发只生成一份且不改变结算或学习成果',async()=>{
+ const a=await appointment();await complete(a.gid,a.updatedAt,'historical-setup-'+a.gid);await review(a.gid,5,'原评价');
+ await db.query('DELETE FROM "LessonCompletionReport" WHERE company=$1 AND "appointmentGeneralId"::text=$2',['co',a.gid]);
+ const revision=(await one('SELECT "updatedAt"::text revision FROM "CourseAppointment" WHERE "objectId"=$1',[a.objectId])).revision;
+ const beforeBalance=await balance(),beforeLesson=await one('SELECT * FROM "LessonRecord" WHERE yyds=$1',[a.gid]);
+ const protectedTables=['CourseCreditReservation','UserExpDomP','DailyStudyRecord','MemoryPracticeRecord','LessonReview'];
+ const snapshots=async()=>Promise.all(protectedTables.map(t=>one(`SELECT COALESCE(jsonb_agg(to_jsonb(t) ORDER BY to_jsonb(t)::text),'[]') data FROM "${t}" t`)));
+ const before=await snapshots();
+ const edit=(key,version=revision,actor='teacher',uid=30)=>one('SELECT xs_lesson_feedback_update($1,$2,$3,$4,$5,$6,$7::jsonb) result',['co',a.gid,actor,uid,version,key,JSON.stringify(feedback)]);
+ await assert.rejects(edit('history-other-teacher',revision,'other-teacher',31),/仅预约老师/);
+ await assert.rejects(edit('history-student-denied',revision,'student',20),/身份/);
+ await assert.rejects(edit('history-stale-version','2000-01-01'),/预约已更新/);
+ await db.exec(`ALTER TABLE "LessonCompletionReport" ADD CONSTRAINT fail_first_report CHECK("contentSummary" IS NULL) NOT VALID`);
+ await assert.rejects(edit('history-rollback-test'),/fail_first_report/);
+ assert.equal((await one('SELECT count(*)::int n FROM "LessonCompletionReport" WHERE "appointmentGeneralId"::text=$1',[a.gid])).n,0);
+ await db.exec('ALTER TABLE "LessonCompletionReport" DROP CONSTRAINT fail_first_report');
+ const results=await Promise.all([edit('history-rollback-test'),edit('history-rollback-test')]);
+ assert.ok(results.every(r=>r.result.saved));assert.equal(results[0].result.feedbackId,results[1].result.feedbackId);
+ await assert.rejects(edit('history-distinct-request'),/报告已被更新/);
+ const reports=(await db.query('SELECT * FROM "LessonCompletionReport" WHERE "appointmentGeneralId"::text=$1',[a.gid])).rows;
+ assert.equal(reports.length,1);assert.equal(reports[0].feedbackStatus,'submitted');assert.equal(reports[0].contentSummary,feedback.contentSummary);
+ assert.equal(reports[0].lessonId,beforeLesson.objectId);assert.equal(await balance(),beforeBalance);assert.deepEqual(await snapshots(),before);
+ const afterLesson=await one('SELECT * FROM "LessonRecord" WHERE yyds=$1',[a.gid]);
+ for(const key of Object.keys(beforeLesson).filter(k=>!['pldp','plpjsj','updatedAt'].includes(k)))assert.deepEqual(afterLesson[key],beforeLesson[key],key);
+ assert.equal((await one('SELECT dszt FROM "CourseAppointment" WHERE "objectId"=$1',[a.objectId])).dszt,'30');
+});
+
+test('历史补交入口仅属于本课老师,学生保持独立评价入口',async()=>{
+ const {default:vm}=await import('node:vm');
+ const a=await appointment();await complete(a.gid,a.updatedAt,'capabilities-setup-'+a.gid);
+ await db.query('DELETE FROM "LessonCompletionReport" WHERE "appointmentGeneralId"::text=$1',[a.gid]);
+ const context={DEFAULT_COMPANY_ID:'co',number:Number,ownLegacyId:u=>u.uid,fail:(_c,m)=>{throw Error(m);},Psql:{oneOrNone:async(s,p)=>(await db.query(s,p)).rows[0]||null}};
+ vm.createContext(context);vm.runInContext(readFileSync(new URL('../cloud/lesson-workflow.js',import.meta.url),'utf8'),context);
+ const order={GeneralID:a.gid,pl:30,szyh:20,dszt:30};const user=uid=>({uid,get:k=>k==='company'?{id:'co'}:null});
+ assert.ok((await context.lessonOrderCapabilities(order,user(30))).allowedActions.includes('editFeedback'));
+ assert.ok(!(await context.lessonOrderCapabilities(order,user(31))).allowedActions.includes('editFeedback'));
+ const student=await context.lessonOrderCapabilities(order,user(20));assert.ok(student.allowedActions.includes('review'));assert.ok(!student.allowedActions.includes('editFeedback'));
+ assert.equal((await context.lessonOrderCapabilities({...order,cancelled:true},user(30))).allowedActions.length,0);
+});

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