Преглед изворни кода

fix(lessons): surface blocked student accounts before starting

彭峰 пре 18 часа
родитељ
комит
19a614d65e

+ 20 - 3
projects/xiaoshu-mobile/src/app/core/lesson-workflow.integration.spec.ts

@@ -16,12 +16,12 @@ import { HomeDailyCacheService } from './home-daily-cache.service';
 
 describe('appointment workflow screens',()=>{
  let dataChanges:Subject<{source:'local'|'broadcast';changedAt:string}>;
- let failFeedback:boolean;
+ let failFeedback:boolean,failStart:boolean;
  let order:Record<string,unknown>,failDetail:boolean,failCourses:boolean,courses:Record<string,unknown>[]|null,delayedUnits:Subject<unknown>|null,calls:Array<{action:string;params:Record<string,unknown>}>;
  const user=signal({userId:30,groupId:3}),student=signal<unknown>(null),groupId=signal(3);
  const response=(result:unknown)=>of({retcode:0,result});
  beforeEach(()=>{
-  localStorage.clear();sessionStorage.clear();calls=[];failFeedback=false;failDetail=false;failCourses=false;courses=null;delayedUnits=null;dataChanges=new Subject();user.set({userId:30,groupId:3});groupId.set(3);
+  localStorage.clear();sessionStorage.clear();calls=[];failFeedback=false;failStart=false;failDetail=false;failCourses=false;courses=null;delayedUnits=null;dataChanges=new Subject();user.set({userId:30,groupId:3});groupId.set(3);
   order={GeneralID:901,szyh:20,pl:30,HoneyName:'测试学员',kcid:801,bindingId:'binding-1',courseCategoryKey:'word',yykcmc:'词表',dszt:0,bxrq:'2099-10-11',sdsd:'19:00',allowedActions:['start'],updatedAt:'2026-10-11T00:00:00Z'};
   const invoke=(action:string,params:Record<string,unknown>={})=>{
    calls.push({action,params});
@@ -32,7 +32,7 @@ describe('appointment workflow screens',()=>{
    if(action==='app_lesson_courses')return failCourses?throwError(()=>new ApiError('课程读取失败')):response({items:courses||[{bindingId:'binding-1',GeneralID:5801,kcid:801,Title:'词表',categoryKey:order['courseCategoryKey'],scheduled:true,syjd:'{}'},{bindingId:'binding-2',GeneralID:5802,kcid:802,Title:'另一词库',categoryKey:'word',syjd:'{"822":"822@Unit 2@1"}'}]});
    if(action==='app_lesson_unit_complete')return response({nodeId:params['nodeId'],total:300,learned:300,percent:100,syjd:JSON.stringify({[String(params['nodeId'])]:params['nodeId']+'@Unit@1'})});
    if(action==='node_list')return delayedUnits||response([{NodeID:params['pid']==='802'?822:811,NodeName:'Unit 1'}]);
-   if(action==='e_order_update_v2'){order={...order,dszt:10,allowedActions:['continue','complete']};return response({status:10});}
+   if(action==='e_order_update_v2'){if(failStart)return throwError(()=>new ApiError('学员账号已停用,请联系管理员核对账号状态'));order={...order,dszt:10,allowedActions:['continue','complete']};return response({status:10});}
    if(action==='app_lesson_complete'){order={...order,dszt:30,allowedActions:['editFeedback'],reviewStatus:'pending',feedback:params['feedback']?{...JSON.parse(String(params['feedback'])),feedbackStatus:'submitted'}:{feedbackStatus:'pending',revision:'2026-10-13T00:00:00Z'}};return response({status:30});}
    if(action==='app_lesson_feedback_update'){if(failFeedback)return throwError(()=>new ApiError('报告已被更新,请刷新详情后重试'));order={...order,feedback:{...JSON.parse(String(params['feedback'])),revision:'2026-10-13T00:00:00Z'}};return response({saved:true});}
    if(action==='app_lesson_review'){order={...order,allowedActions:[],reviewStatus:'submitted',studentReview:{score:params['score'],comment:params['comment']}};return response({status:30});}
@@ -188,6 +188,23 @@ describe('appointment workflow screens',()=>{
   component.toggleUnit({NodeID:811});component.startOrContinue();await harness.fixture.whenStable();
   expect(TestBed.inject(Router).url).toContain('orderId=901');expect(TestBed.inject(Router).url).toContain('bindingId=binding-1');
  });
+ it('keeps a start failure beside the fixed action button while preserving selected units for retry',async()=>{
+  failStart=true;
+  const {harness,component}=await screen();component.toggleUnit({NodeID:811});
+  (harness.routeNativeElement?.querySelector('.appointment-actions .primary-button') as HTMLButtonElement).click();
+  await harness.fixture.whenStable();harness.fixture.detectChanges();
+  expect(harness.routeNativeElement?.querySelector('.appointment-actions [role="alert"]')?.textContent).toContain('学员账号已停用');
+  expect(component.selectedUnits().has('811')).toBeTrue();expect(component.submitting()).toBeFalse();
+  expect(TestBed.inject(Router).url).not.toContain('/pages/stu/stu_abc');
+  failStart=false;component.startOrContinue();await harness.fixture.whenStable();
+  expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_abc');
+ });
+ it('shows a blocked student account in the fixed action area without offering start',async()=>{
+  order={...order,allowedActions:[],blockedReason:'学员账号已停用,请联系管理员核对账号状态'};
+  const {harness}=await screen();
+  expect(harness.routeNativeElement?.querySelector('.appointment-actions [role="alert"]')?.textContent).toContain('学员账号已停用');
+  expect(harness.routeNativeElement?.querySelector('.appointment-actions .primary-button')).toBeNull();
+ });
  it('lists every opened enrollment and switches units, progress and the classroom binding together',async()=>{
   const {harness,component}=await screen();
   const select=harness.routeNativeElement?.querySelector('select[aria-label="本次学习课本或词库"]') as HTMLSelectElement;

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

@@ -11,7 +11,6 @@
       <button type="button" class="refresh-detail" (click)="load()" [disabled]="submitting()"><lucide-icon [img]="icons.RefreshCw" [size]="16" aria-hidden="true" />刷新详情</button>
     </header>
     @if (detailWarning()) { <p class="detail-warning" role="alert">{{ detailWarning() }}</p> }
-    @if(actionError()){<p class="detail-warning" role="alert">{{actionError()}}</p>}
     @if(order()['blockedReason']){<p class="detail-warning">{{order()['blockedReason']}}</p>}
     @if (can('start')) { <p class="schedule-hint"><lucide-icon [img]="icons.Clock3" [size]="16" aria-hidden="true" /><span>预约当天(北京时间)均可训练,可提前开始,也可在计划时间之后开始。</span></p> }
 
@@ -179,6 +178,7 @@
       </section>
     }
     <div class="appointment-actions">
+      @if (actionError() || (!isStudentView && order()['blockedReason'])) { <p class="detail-warning action-error" role="alert">{{actionError() || order()['blockedReason']}}</p> }
       @if (can('start')) { <button type="button" class="primary-button" [disabled]="submitting() || coursesLoading() || unitsLoading()" (click)="startOrContinue()">{{submitting()?'正在开始…':'开始课程'}}</button> }
       @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> }

+ 1 - 0
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.scss

@@ -69,6 +69,7 @@
 .report-panel p { margin: 7px 0 14px; padding: 11px; color: var(--ink); line-height: 1.6; background: #f6f8f7; border-radius: var(--radius-sm); }
 .appointment-actions { position: fixed; z-index: 20; right: 0; bottom: var(--app-tabbar-offset,0px); left: 0; display: flex; flex-wrap: wrap; align-items: center; padding: 12px var(--page-gutter) max(12px, env(safe-area-inset-bottom)); background: rgb(255 255 255 / 96%); border-top: 1px solid var(--line); gap: 10px; }
 .appointment-actions:empty { display: none; }
+.appointment-actions .action-error { flex-basis: 100%; border-radius: var(--radius-sm); line-height: 1.6; overflow-wrap: anywhere; }
 .appointment-actions > span { flex-basis: 100%; color: var(--ink-soft); text-align: center; font-size: 12px; }
 .appointment-actions button { display: flex; min-height: 49px; align-items: center; justify-content: center; flex: 1; gap: 6px; }
 .empty-state.error > div { display: grid; justify-items: center; gap: 9px; }

+ 6 - 3
scripts/cloud/lesson-workflow.js

@@ -62,8 +62,10 @@ async function lessonOrderCapabilities(order, current) {
   const stateSql='SELECT c."objectId",a."sourceKey",a.kcid,a."courseCategoryKey",to_jsonb(a)->>\'categoryEvidence\' AS "categoryEvidence",a."updatedAt"::text AS "updatedAt",xs_appointment_valid_at(a.bxrq,a.yysj) AS "validToday",a."creditHoldStatus",a."creditHoldReason",a."bindingId",a."creditCostsSnapshot",a."kssj",a."jssj" FROM "CourseAppointment" a JOIN "CommonModel" c ON c.company=a.company AND c."modelId"=54 AND c."itemId"=a.id WHERE c.company=$1 AND c."generalId"::text=$2';
   let state = await Psql.oneOrNone(stateSql, [DEFAULT_COMPANY_ID,id]);
   if (!state) fail(404, '预约不存在');
+  const learner = [0,10].includes(status) ? await Psql.oneOrNone('SELECT "objectId","isDisabled",to_jsonb(u)->>\'isDeleted\' AS "isDeleted" FROM "_User" u WHERE company=$1 AND "legacyUserId"::text=$2', [DEFAULT_COMPANY_ID,String(order.szyh)]) : null;
+  const studentBlocked = [0,10].includes(status) && (!learner || learner.isDisabled === true || ['true','1'].includes(String(learner.isDeleted)));
   let preparationReason='';
-  if(!order.cancelled&&status===0&&state.validToday&&/^legacy[:\-]/.test(state.sourceKey||'')&&(uid===number(order.pl)||(String(state.kcid)!=='398'&&uid===number(order.szyh)))&&(!state.bindingId||!state.creditCostsSnapshot?.length||state.creditHoldStatus!=='ready')){
+  if(!order.cancelled&&!studentBlocked&&status===0&&state.validToday&&/^legacy[:\-]/.test(state.sourceKey||'')&&(uid===number(order.pl)||(String(state.kcid)!=='398'&&uid===number(order.szyh)))&&(!state.bindingId||!state.creditCostsSnapshot?.length||state.creditHoldStatus!=='ready')){
     const prepared=await prepareLegacyAppointment({...order,objectId:state.objectId},current);
     preparationReason=prepared.blockedReason||'';
     if(!preparationReason)state=await Psql.oneOrNone(stateSql,[DEFAULT_COMPANY_ID,id]);
@@ -78,10 +80,11 @@ async function lessonOrderCapabilities(order, current) {
   let blockedReason = '';
   if (order.cancelled) blockedReason = '预约已取消';
   else if (teacher) {
-    if (status === 0 && !state.validToday) blockedReason = '课程仅在预约当天可开始,其他日期请联系管理员改期';
+    if (studentBlocked) blockedReason = '学员账号已停用或不存在,请联系管理员核对账号状态';
+    else if (status === 0 && !state.validToday) blockedReason = '课程仅在预约当天可开始,其他日期请联系管理员改期';
     else if (status === 0 && (preparationReason||(!state.bindingId&&!freeWriting) || !Array.isArray(state.creditCostsSnapshot) || !state.creditCostsSnapshot.length || state.creditHoldStatus && state.creditHoldStatus !== 'ready')) blockedReason = preparationReason||state.creditHoldReason||'开课记录或课时凭据尚未核实,请联系管理员';
     else if (status === 0) actions.push('start');
-    if (status === 10) actions.push('continue','complete');
+    if (status === 10 && !studentBlocked) actions.push('continue','complete');
     if ([11,20].includes(status)) {
       const endedInLegacyWriting = String(state.kcid)==='398' && state.sourceKey==='legacy:model:54:general:'+id
         && lesson && state.kssj && state.jssj && !state.creditCostsSnapshot?.length;

+ 59 - 0
scripts/tests/lesson-start-eligibility.test.mjs

@@ -0,0 +1,59 @@
+import { test, before, after } 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';
+
+const db = new PGlite();
+const query = async (sql, args = []) => (await db.query(sql, args)).rows;
+const one = async (sql, args = []) => (await query(sql, args))[0];
+const context = vm.createContext({
+  DEFAULT_COMPANY_ID: 'co', number: Number, ownLegacyId: user => user.uid,
+  fail: (_, message) => { throw Error(message); },
+  Psql: { query, one, oneOrNone: one },
+});
+const teacher = { uid: 30, get: key => key === 'company' ? { id: 'co' } : undefined };
+const order = { GeneralID: '901', szyh: '20', pl: '30', dszt: '0' };
+
+before(async () => {
+  await db.exec(`
+    CREATE TABLE "_User" ("objectId" text, company text, "legacyUserId" numeric, "isDisabled" boolean, "isDeleted" boolean);
+    CREATE TABLE "CommonModel" ("objectId" text, company text, "modelId" numeric, "itemId" numeric, "generalId" numeric);
+    CREATE TABLE "CourseAppointment" (company text, id numeric, "sourceKey" text, kcid text, "courseCategoryKey" text, "updatedAt" timestamptz, bxrq text, yysj text, "creditHoldStatus" text, "creditHoldReason" text, "bindingId" text, "creditCostsSnapshot" jsonb, kssj text, jssj text);
+    CREATE TABLE "LessonRecord" (company text, yyds text, pf numeric, pjnr text, pldp text, "createdAt" timestamptz);
+    CREATE TABLE "LessonReview" (company text, "appointmentGeneralId" text, score numeric, comment text, "submittedAt" timestamptz);
+    CREATE FUNCTION xs_appointment_valid_at(text,text) RETURNS boolean LANGUAGE sql AS $$ SELECT true $$;
+    INSERT INTO "_User" VALUES ('student','co',20,false,false),('foreign','other',20,false,false);
+    INSERT INTO "CommonModel" VALUES ('appointment','co',54,1,901);
+    INSERT INTO "CourseAppointment" VALUES ('co',1,'schedule:test','405','word',now(),'2026-11-12','2026-11-12 22:00',NULL,NULL,'binding','[{"account":"SilverCoin","amount":1}]',NULL,NULL);
+  `);
+  vm.runInContext(readFileSync(new URL('../cloud/lesson-workflow.js', import.meta.url), 'utf8'), context);
+});
+after(() => db.close());
+
+test('a scheduled course stops offering start or continue while its student is disabled, and recovers when enabled', async () => {
+  assert.ok((await context.lessonOrderCapabilities(order, teacher)).allowedActions.includes('start'));
+  await db.exec(`UPDATE "_User" SET "isDisabled"=true WHERE company='co'`);
+  try {
+    for (const dszt of ['0', '10']) {
+      const detail = await context.lessonOrderCapabilities({ ...order, dszt }, teacher);
+      assert.equal(detail.allowedActions.length, 0);
+      assert.match(detail.blockedReason, /学员.*停用/);
+    }
+  } finally {
+    await db.exec(`UPDATE "_User" SET "isDisabled"=false WHERE company='co'`);
+  }
+  assert.ok((await context.lessonOrderCapabilities(order, teacher)).allowedActions.includes('start'));
+});
+
+test('deleted and missing students cannot be made eligible by a matching student in another tenant', async () => {
+  await db.exec(`UPDATE "_User" SET "isDeleted"=true WHERE company='co'`);
+  try {
+    assert.equal((await context.lessonOrderCapabilities(order, teacher)).allowedActions.length, 0);
+    const missing = await context.lessonOrderCapabilities({ ...order, szyh: '21' }, teacher);
+    assert.equal(missing.allowedActions.length, 0);
+    assert.match(missing.blockedReason, /学员/);
+  } finally {
+    await db.exec(`UPDATE "_User" SET "isDeleted"=false WHERE company='co'`);
+  }
+});

+ 1 - 1
scripts/tests/lesson-workflow.test.mjs

@@ -408,7 +408,7 @@ test('学生评价后锁定报告,包括旧评分;先评价仍允许一次
 test('教师可以开始免教材语法写作,单词课仍要求有效开课凭据',async()=>{
  const {default:vm}=await import('node:vm');
  let state={kcid:'0',courseCategoryKey:'primary_writing',bindingId:'',validToday:true,creditHoldStatus:'ready',creditCostsSnapshot:[{account:'Purse',amount:1}]};
- const context={DEFAULT_COMPANY_ID:'co',number:Number,ownLegacyId:()=>30,fail:(_c,m)=>{throw Error(m);},Psql:{oneOrNone:async(sql)=>sql.includes('FROM "CourseAppointment"')?state:null}};
+ const context={DEFAULT_COMPANY_ID:'co',number:Number,ownLegacyId:()=>30,fail:(_c,m)=>{throw Error(m);},Psql:{oneOrNone:async(sql,args)=>sql.includes('FROM "CourseAppointment"')?state:sql.includes('FROM "_User"')?one(sql,args):null}};
  vm.createContext(context);vm.runInContext(readFileSync(new URL('../cloud/lesson-workflow.js',import.meta.url),'utf8'),context);
  const teacher={get:k=>k==='company'?{id:'co'}:null},order={GeneralID:1,pl:30,szyh:20,dszt:0};
  for(const category of ['primary_writing','middle_writing','high_writing']){state.courseCategoryKey=category;assert.ok((await context.lessonOrderCapabilities(order,teacher)).allowedActions.includes('start'));}