Bläddra i källkod

fix(review): restore imported writing tasks and notes review access

彭峰 7 timmar sedan
förälder
incheckning
a6cd179910

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

@@ -25,10 +25,13 @@ function reviewStudentNameSql() {
   return `COALESCE(NULLIF(BTRIM(u."realName"),''),NULLIF(BTRIM(u."nickname"),''),NULLIF(BTRIM(u."legacyUserData"->>'HoneyName'),''),CASE WHEN POSITION('@' IN c."title")>0 THEN NULLIF(BTRIM(SPLIT_PART(c."title",'@',1)),'') END,NULLIF(BTRIM(u."username"),''),'学员 '||m."yhid"::text)`;
 }
 function notesReviewSourceSql() {
+  // Completed imports of legacy 阅读写作 (398) need no inferred billing stage to review notes.
   return `EXISTS (SELECT 1 FROM "CommonModel" ac JOIN "CourseAppointment" a ON a.company=ac.company AND a.id=ac."itemId"
     WHERE ac.company=d.company AND ac."modelId"=54 AND ac."generalId"::text=d.dsid::text AND COALESCE(ac.status,99)<>-2
     AND a.szyh::text=d."userId"::text AND a.dszt IN ('11','20','30','40')
-    AND a."courseCategoryKey" IN ('primary_writing','middle_writing','high_writing'))`;
+    AND (a."courseCategoryKey" IN ('primary_writing','middle_writing','high_writing')
+      OR (NULLIF(BTRIM(a."courseCategoryKey"),'') IS NULL AND a.kcid::text='398' AND a.dszt IN ('30','40')
+        AND a."sourceKey"='legacy:model:54:general:'||ac."generalId"::text AND ac."sourceKey"=a."sourceKey")))`;
 }
 async function appReviewPage(input,current) {
   const uid=await authorizeRequestedUser(current,input.uid),coach=number(current.get('legacyGroupId'))===3,p=mobilePage(input),includeMetadata=input.includeMetadata!==false,today=appOverviewToday();

+ 3 - 3
scripts/deploy-admin-functions.mjs

@@ -2631,19 +2631,19 @@ async function legacyStudyRecordWords(id, ownerId) {
   } catch (_) { return []; }
 }
 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]);
+  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",' + notesReviewSourceSql() + ' AS "__notesReviewSource" 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]);
   if (!rows.length) {
     const assigned=number(current.get('legacyGroupId'))===3?await Psql.oneOrNone('SELECT yhid FROM "MemoryPracticeRecord" WHERE company=$1 AND plid::text=$2 AND xxjlid::text=$3 LIMIT 1',[DEFAULT_COMPANY_ID,String(ownLegacyId(current)),id]):null;
     const targetId = assigned?number(assigned.yhid):await appLearningTarget(current,input.studentId);
     const words = await legacyStudyRecordWords(id,targetId);
     return words.length ? { words:words.map((item) => ({ ...item, Title:item.Title || item.title || item.word || '' })), addon:[] } : null;
   }
-  const source = rows[0]; const recordAddon = source.__addon || {}; if(recordAddon.canonicalStudyId && String(recordAddon.canonicalStudyId)!==id) return recordDetailData({...input,id:recordAddon.canonicalStudyId},current); delete source.__addon; const record = legacyAliases({ ...source, ...recordAddon }, 'CommonModel'); await authorizeLearningRecordAccess(current, record);
+  const source = rows[0]; const notesSource=source.__notesReviewSource===true; delete source.__notesReviewSource; const recordAddon = source.__addon || {}; if(recordAddon.canonicalStudyId && String(recordAddon.canonicalStudyId)!==id) return recordDetailData({...input,id:recordAddon.canonicalStudyId},current); delete source.__addon; const record = legacyAliases({ ...source, ...recordAddon }, 'CommonModel'); await authorizeLearningRecordAccess(current, record);
   const order = recordAddon.dsid ? await orderDetailRow(recordAddon.dsid, current, false) : null;
   let storedWords = parseArray(recordAddon.xxqs);
   const notesReview = !storedWords.length && number(recordAddon.learned)===0 && order
     && !order.cancelled && number(order.szyh)===number(record.userId ?? record.UserID) && [11,20,30,40].includes(number(order.dszt))
-    && ['primary_writing','middle_writing','high_writing'].includes(String(order.courseCategoryKey));
+    && (['primary_writing','middle_writing','high_writing'].includes(String(order.courseCategoryKey)) || notesSource);
   if (!storedWords.length && !notesReview) storedWords = await legacyStudyRecordWords(id,number(record.userId ?? record.UserID)); const ids = [...new Set(storedWords.map((item) => String(item && (item.GeneralID ?? item.generalId ?? item.id) || '')).filter(Boolean))]; let wordRows = [];
   if (ids.length) wordRows = await Psql.query('SELECT c.*, row_to_json(v) AS "__addon" FROM "CommonModel" c LEFT JOIN "VocabularyWord" v ON v."company" = $1 AND CAST(v."id" AS text) = CAST(c."itemId" AS text) WHERE c."company" = $1 AND CAST(c."modelId" AS text) = \'52\' AND CAST(c."generalId" AS text) = ANY($2::text[])', [DEFAULT_COMPANY_ID, ids]);
   const details = new Map(wordRows.map((row) => { const normalized = normalizeWordRow(row); return [String(normalized.GeneralID || normalized.generalId), normalized]; })); const words = storedWords.map((item) => { const raw = item && typeof item === 'object' ? safe(item) : {}; const wordId = String(raw.GeneralID ?? raw.generalId ?? raw.id ?? ''); const detail = details.get(wordId) || legacyAliases({ generalId: wordId, title: raw.Title || raw.title || '' }, 'CommonModel'); return { ...detail, ...raw, GeneralID: number(wordId, wordId), Title: raw.Title || raw.title || detail.Title || detail.title || '', detail: [detail] }; });

+ 2 - 1
scripts/tests/fixtures/teacher-review-schema.sql

@@ -8,5 +8,6 @@ CREATE TABLE "MemoryPracticeRecord"("objectId" text,company text,id numeric,plid
   INSERT INTO "MemoryPracticeRecord" VALUES ('other','co',100,31,21,101,1,'0','2026-10-14','2026-10-14');
   INSERT INTO "CommonModel" (company,"modelId","generalId","itemId") SELECT company,60,id+1000,id FROM "MemoryPracticeRecord";
 ALTER TABLE "CommonModel" ADD COLUMN status numeric;
+ALTER TABLE "CommonModel" ADD COLUMN "sourceKey" text;
 ALTER TABLE "DailyStudyRecord" ADD COLUMN dsid text, ADD COLUMN "userId" numeric;
-CREATE TABLE "CourseAppointment" (company text,id numeric,szyh text,dszt text,"courseCategoryKey" text);
+CREATE TABLE "CourseAppointment" (company text,id numeric,szyh text,dszt text,"courseCategoryKey" text,kcid text,"sourceKey" text);

+ 50 - 0
scripts/tests/legacy-writing-review.test.mjs

@@ -0,0 +1,50 @@
+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';
+
+const mobile=readFileSync(new URL('../cloud/mobile-teaching.js',import.meta.url),'utf8');
+const gateway=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8').match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1].replace("const DEFAULT_COMPANY_ID = '7pIbDBJmKx';","const DEFAULT_COMPANY_ID = 'co';");
+async function fixture(run){
+ const db=new PGlite();try{
+  await db.exec(readFileSync(new URL('./fixtures/teacher-review-schema.sql',import.meta.url),'utf8'));
+  await db.exec(`ALTER TABLE "DailyStudyRecord" ADD COLUMN pl text,ADD COLUMN "canonicalStudyId" text;
+   INSERT INTO "CommonModel"(company,"modelId","generalId","itemId",status,"sourceKey") VALUES('co',56,100,101,99,'legacy:model:56:general:100'),('co',54,200,201,99,'legacy:model:54:general:200');
+   INSERT INTO "DailyStudyRecord"(company,id,learned,xxqs,dsid,"userId",pl) VALUES('co',101,0,'','200',20,'30');
+   INSERT INTO "CourseAppointment" VALUES('co',201,'20','30',NULL,'398','legacy:model:54:general:200');
+   INSERT INTO "Node" VALUES('co',398,'阅读写作');`);
+  const query=async(s,a)=>(await db.query(s,a)).rows;
+  const ctx=vm.createContext({Date,Promise,Parse:{},Psql:{query,one:async(s,a)=>(await query(s,a))[0],oneOrNone:async(s,a)=>(await query(s,a))[0]||null}});
+  vm.runInContext(gateway+'\n'+mobile,ctx);
+  ctx.authorizeRequestedUser=async()=>30;ctx.appOverviewToday=()=> '2026-10-14';ctx.appLiveVersionSnapshot=async()=>({});ctx.legacyStudyRecordWords=async()=>[];ctx.appLearningTarget=async()=>{throw Error('forbidden');};
+  const coach={get:k=>({legacyUserId:30,legacyGroupId:3}[k])};
+  await run({db,ctx,coach});
+ }finally{await db.close();}
+}
+
+test('旧阅读写作398无学段分类时正常列出任务、统计和学员,并能直接进入笔记复习',()=>fixture(async({db,ctx,coach})=>{
+ const page=await ctx.appReviewPage({scope:'today',pageSize:1,includeIncome:false},coach);
+ assert.equal(page.page.itemCount,2);assert.equal(page.items.length,1);assert.equal(page.summary.todayTotal,2);assert.equal(page.summary.todayPending,1);assert.equal(page.students[0].id,'20');
+ const next=await ctx.appReviewPage({scope:'today',page:2,pageSize:1,includeIncome:false},coach);assert.equal(next.items.length,1);
+ const detail=await ctx.recordDetailData({id:100},coach);assert.equal(detail.words.length,0);assert.equal(detail.addon[0].reviewContentMode,'notes');assert.equal(detail.record.__notesReviewSource,undefined);
+ assert.equal((await db.query('SELECT "courseCategoryKey" FROM "CourseAppointment"')).rows[0].courseCategoryKey,null);
+ await assert.rejects(ctx.recordDetailData({id:100},{get:k=>({legacyUserId:99,legacyGroupId:3}[k])}),/forbidden/);
+}));
+
+test('普通空词表、错误学生、未完课、回收和无可信来源的398不得冒充旧写作复习',()=>fixture(async({db,ctx,coach})=>{
+ const reset=`UPDATE "CourseAppointment" SET szyh='20',dszt='30',"courseCategoryKey"=NULL,kcid='398',"sourceKey"='legacy:model:54:general:200';UPDATE "CommonModel" SET status=99,"sourceKey"='legacy:model:54:general:200' WHERE "modelId"=54;`;
+ for(const patch of [
+  `UPDATE "CourseAppointment" SET "courseCategoryKey"='word'`,
+  `UPDATE "CourseAppointment" SET kcid='399'`,
+  `UPDATE "CourseAppointment" SET szyh='21'`,
+  `UPDATE "CourseAppointment" SET dszt='10'`,
+  `UPDATE "CourseAppointment" SET dszt='20'`,
+  `UPDATE "CourseAppointment" SET "sourceKey"='cloud:appointment:new'`,
+  `UPDATE "CommonModel" SET "sourceKey"='cloud:appointment:new' WHERE "modelId"=54`,
+  `UPDATE "CommonModel" SET status=-2 WHERE "modelId"=54`,
+ ]){
+  await db.exec(reset+patch);const page=await ctx.appReviewPage({scope:'today',includeIncome:false},coach);assert.equal(page.items.length,0,patch);assert.equal(page.summary.todayTotal,0,patch);assert.equal(page.students.length,0,patch);
+  const detail=await ctx.recordDetailData({id:100},coach);assert.equal(detail.addon[0].reviewContentMode,undefined,patch);
+ }
+}));

+ 1 - 1
scripts/tests/review-student-names.test.mjs

@@ -58,7 +58,7 @@ test('无单词语法写作任务进入日历、今日统计和学员筛选,
   await db.exec(readFileSync(new URL('./fixtures/teacher-review-schema.sql',import.meta.url),'utf8'));
   await db.exec(`INSERT INTO "DailyStudyRecord"(company,id,learned,xxqs,dsid,"userId") VALUES('co',101,0,'[]','200',20);
    INSERT INTO "CommonModel"(company,"modelId","generalId","itemId",status) VALUES('co',56,100,101,99),('co',54,200,201,99);
-   INSERT INTO "CourseAppointment" VALUES('co',201,'20','30','high_writing');`);
+   INSERT INTO "CourseAppointment"(company,id,szyh,dszt,"courseCategoryKey") VALUES('co',201,'20','30','high_writing');`);
   const query=async(s,a)=>(await db.query(s,a)).rows;
   const ctx=vm.createContext({Date,Promise,DEFAULT_COMPANY_ID:'co',number:(v,f=0)=>Number(v)||f,authorizeRequestedUser:async()=>30,appOverviewToday:()=> '2026-10-14',legacyAliases:r=>r,appLiveVersionSnapshot:async()=>({}),Psql:{query,one:async(s,a)=>(await query(s,a))[0]}});vm.runInContext(source,ctx);
   const page=()=>ctx.appReviewPage({scope:'today',includeIncome:false},{get:()=>3});

+ 1 - 1
scripts/tests/writing-review-detail.test.mjs

@@ -6,7 +6,7 @@ const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.ur
 function context({category='primary_writing',learned=0,student=20,status=30,authorized=true,words='[]'}={}) {
  let legacyCalls=0;
  const ctx=vm.createContext({Parse:{},Psql:{query:async()=>[{company:'co',generalId:100,__addon:{userId:20,dsid:200,xxqs:words,learned}}]}});
- vm.runInContext(source,ctx);
+ vm.runInContext(source+'\n'+readFileSync(new URL('../cloud/mobile-teaching.js',import.meta.url),'utf8'),ctx);
  ctx.authorizeLearningRecordAccess=async()=>{if(!authorized)throw Error('无权查看');};
  ctx.orderDetailRow=async()=>({szyh:student,dszt:status,courseCategoryKey:category});
  ctx.legacyStudyRecordWords=async()=>{legacyCalls++;return[];};