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

fix(mobile): preserve historical assigned vocabulary visibility

彭峰 1 неделя назад
Родитель
Сommit
96d67c25a6

+ 18 - 0
scripts/cloud/mobile-teaching.js

@@ -2,6 +2,24 @@
 function mobilePage(input) { return { index: Math.max(1, Math.floor(number(input.page || input.cpage, 1))), size: Math.min(100, Math.max(1, Math.floor(number(input.pageSize || input.psize, 20)))) }; }
 function mobilePageMeta(total, p) { return { itemCount:Number(total),pageCount:Math.ceil(Number(total)/p.size),pageIndex:p.index,pageSize:p.size }; }
 function mobileSource() { return {source:'database',refreshedAt:new Date().toISOString(),complete:true}; }
+// Pending migration classification affects scheduling, not a student's access to
+// previously assigned content. Explicitly unpublished/recycled content stays hidden.
+function mobileReadableBindingWhere(includeHistorical = true) {
+  const status = includeHistorical
+    ? `COALESCE(b."enrollmentStatus",'') IN ('active','pending','')`
+    : `b."enrollmentStatus"='active'`;
+  return `b."company"=$1 AND b."yhid"::text=$2 AND ${status} AND COALESCE(c."status",99)=99 AND COALESCE(n."zstatus",99)=99`;
+}
+function mobileReadableBindingFrom() {
+  return `FROM "CourseBinding" b LEFT JOIN "CommonModel" c ON c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id" LEFT JOIN "Node" n ON n."company"=b."company" AND n."nodeId"=CASE WHEN COALESCE(b."kcid"::text,'')~'^[0-9]+(?:[.]0+)?$' THEN b."kcid"::numeric ELSE -1 END`;
+}
+async function mobileReadableCourseBindings(uid) {
+  return Psql.query(`SELECT c."generalId",c."title",c."createTime",b.*,b."objectId" AS "bindingObjectId",n."nodeName" AS "courseTitle" ${mobileReadableBindingFrom()} WHERE ${mobileReadableBindingWhere()} ORDER BY b."id" DESC`,[DEFAULT_COMPANY_ID,String(uid)]);
+}
+async function mobileUnitBindingRows(uid,bindingId,parentId,current) {
+  const ownStudent = number(current.get('legacyGroupId') || current.get('groupId'))===1 && number(uid)===ownLegacyId(current);
+  return Psql.query(`SELECT b.* ${mobileReadableBindingFrom()} WHERE ${mobileReadableBindingWhere(ownStudent)} AND (($3<>'' AND (b."id"::text=$3 OR b."objectId"=$3)) OR ($3='' AND b."kcid"::text=$4))`,[DEFAULT_COMPANY_ID,String(uid),bindingId,String(parentId)]);
+}
 const MOBILE_DISABLED_ACTIONS = new Set(['cart_add','payment_cart','payment_cart_again','payment_success','user_group_usr_supply','user_group_usr_upgrade']);
 async function appCreditOverview(input,current) {
   const uid=await authorizeRequestedUser(current,input.studentId||input.uid),owner=uid===ownLegacyId(current)?current:await legacyUserObject(uid);

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

@@ -2493,7 +2493,7 @@ async function nodePage(input) {
 }
 async function nodeUnitPage(input,current) {
   const uid = await authorizeRequestedUser(current,input.userId || input.uid); const paging = pageInput(input); const bindingId = String(input.bindingId || '').trim(); let parentId = number(input.pid); let binding = null;
-  const bindingRows=await Psql.query('SELECT b.* FROM "CourseBinding" b JOIN "CommonModel" c ON c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id" WHERE b."company"=$1 AND b."yhid"::text=$2 AND b."enrollmentStatus"=\'active\' AND c."status"=99 AND (($3<>\'\' AND (b."id"::text=$3 OR b."objectId"=$3)) OR ($3=\'\' AND b."kcid"::text=$4))',[DEFAULT_COMPANY_ID,String(uid),bindingId,String(parentId)]);
+  const bindingRows=await mobileUnitBindingRows(uid,bindingId,parentId,current);
   if(bindingRows.length>1)fail(409,'该内容有多个开课分类,请选择具体开课记录');binding=bindingRows[0]||null;
   if (!binding) fail(403,'该课程尚未分配给当前学员'); parentId = number(binding.kcid,parentId);
   if (parentId) {
@@ -2765,7 +2765,7 @@ async function appLearningOverview(input,current) {
     Psql.query('SELECT "dqrq" AS date,COUNT(*)::int AS count FROM "DailyStudyRecord" WHERE '+dateWhere.join(' AND ')+' GROUP BY "dqrq"',dateValues),
     Psql.query('SELECT "bxrq","yysj","dszt",COUNT(*)::int AS count FROM "CourseAppointment" WHERE NOT EXISTS (SELECT 1 FROM "CommonModel" cancelled WHERE cancelled."company"="CourseAppointment"."company" AND cancelled."modelId"=54 AND cancelled."itemId"="CourseAppointment"."id" AND cancelled."status"=-2) AND '+groupWhere.join(' AND ')+' GROUP BY "bxrq","yysj","dszt"',groupValues),
     schedulePromise,
-    Psql.query('SELECT c."objectId",c."generalId",c."title",c."createTime",c."createdAt",b.*,b."objectId" AS "bindingObjectId",n."nodeName" AS "courseTitle" FROM "CourseBinding" b LEFT JOIN "CommonModel" c ON c."company"=$1 AND c."modelId"=58 AND c."itemId"=b."id" LEFT JOIN "Node" n ON n."company"=$1 AND n."nodeId"=CASE WHEN COALESCE(b."kcid",\'\')~\'^\\d+(?:\\.0+)?$\' THEN b."kcid"::numeric ELSE -1 END WHERE b."company"=$1 AND b."yhid"=$2 AND b."enrollmentStatus"=\'active\' AND c."status"=99 AND COALESCE(n."zstatus",99)=99 ORDER BY b."id" DESC',[DEFAULT_COMPANY_ID,String(targetId)]),
+    mobileReadableCourseBindings(targetId),
     Psql.oneOrNone('SELECT c."objectId",c."generalId",c."title",c."createTime",c."createdAt",d."id",d."pl",d."con",d."djq",d."ygg",d."dqrq",d."dsid",d."fxrl",d."xxqs",d."szmdid",d."userId",d."learned" FROM "DailyStudyRecord" d LEFT JOIN "CommonModel" c ON c."company"=$1 AND c."modelId"=56 AND c."itemId"=d."id" WHERE d."company"=$1 AND d."userId"=$2 AND COALESCE(NULLIF(d."xxqs",\'\'),\'[]\')<>\'[]\' ORDER BY COALESCE(c."createTime",c."createdAt",d."updatedAt",d."createdAt") DESC LIMIT 1',[DEFAULT_COMPANY_ID,targetId])
   ]);
   const normalizedRecords=records.map(appOverviewStudyRow),normalizedLatestReading=appOverviewStudyRow(latestReading),readingMap=await appOverviewReadingMap(normalizedLatestReading?[...normalizedRecords,normalizedLatestReading]:normalizedRecords,targetId),attachReading=(row)=>{if(!row)return row;const reading=readingMap.get(String(row.GeneralID));return reading?{...row,...reading}:row;};
@@ -2780,7 +2780,7 @@ async function appStudentCompanionOverview(input,current,uid,p) {
   let base=merged.filter((row)=>history?(row.cancelled===true||number(row.dszt)>=30):row.cancelled!==true);if(search)base=base.filter((row)=>(String(row.Title||'')+' '+String(row.Title2||'')+' '+String(row.plxm||'')).toLocaleLowerCase('zh-CN').includes(search));if(month)base=base.filter((row)=>companionDateKey(row).startsWith(month+'-'));
   const dateMap=new Map();for(const row of base){const date=companionDateKey(row);if(!date)continue;const value=dateMap.get(date)||{date,finished:0,planned:0};if(number(row.dszt)>=30&&row.cancelled!==true)value.finished++;else if(row.cancelled!==true)value.planned++;dateMap.set(date,value);}const dates=[...dateMap.values()].sort((left,right)=>left.date.localeCompare(right.date));
   let filtered=base;if(input.dateStart)filtered=filtered.filter((row)=>companionDateKey(row)>=String(input.dateStart));if(input.dateEnd)filtered=filtered.filter((row)=>companionDateKey(row)<=String(input.dateEnd));if(input.filter==='finished')filtered=filtered.filter((row)=>number(row.dszt)>=30&&row.cancelled!==true);if(input.filter==='pending')filtered=filtered.filter((row)=>number(row.dszt)<30&&row.cancelled!==true&&companionDateKey(row)<today);filtered.sort((left,right)=>{const order=(companionDateKey(left)+' '+String(left.yysj||left.sdsd||'')).localeCompare(companionDateKey(right)+' '+String(right.yysj||right.sdsd||''));return history?-order:order;});
-  const total=filtered.length,offset=(p.index-1)*p.size,schedules=filtered.slice(offset,offset+p.size),courses=await Psql.query('SELECT c."generalId",c."title",b.*,b."objectId" AS "bindingObjectId",n."nodeName" AS "courseTitle" FROM "CourseBinding" b JOIN "CommonModel" c ON c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id" LEFT JOIN "Node" n ON n."company"=b."company" AND n."nodeId"::text=b."kcid" WHERE b."company"=$1 AND b."yhid"=$2 AND b."enrollmentStatus"=\'active\' AND c."status"=99 AND COALESCE(n."zstatus",99)=99 ORDER BY b."id" DESC',[DEFAULT_COMPANY_ID,String(uid)]),scopes=['courses','appointments'],live=withLegacyAppointmentVersion(await appLiveVersionSnapshot(uid,scopes,current),scopes,legacyAppointmentSync);
+  const total=filtered.length,offset=(p.index-1)*p.size,schedules=filtered.slice(offset,offset+p.size),courses=await mobileReadableCourseBindings(uid),scopes=['courses','appointments'],live=withLegacyAppointmentVersion(await appLiveVersionSnapshot(uid,scopes,current),scopes,legacyAppointmentSync);
   return{schedules,courses:courses.map(appOverviewCourseRow),page:mobilePageMeta(total,p),dates,studentOptions:[],...mobileSource(),...live};
 }
 async function appCompanionOverview(input,current) {
@@ -2803,7 +2803,7 @@ async function appCompanionOverview(input,current) {
   if(input.filter==='pending'){clauses.push('a."dszt"::numeric<30 AND COALESCE(c."status",99)<>-2');add(date+'<?',appOverviewToday());}
   const where=clauses.join(' AND '),count=await Psql.one('SELECT COUNT(*)::int total'+join+' WHERE '+where,args);
   const rows=await Psql.query('SELECT c."generalId",c."title",c."createTime",c."status" AS "commonStatus",a.*,n."nodeName" AS "courseTitle"'+join+' LEFT JOIN "Node" n ON n."company"=a."company" AND n."nodeId"::text=a."kcid" WHERE '+where+' ORDER BY a."yysj" '+(history?'DESC':'ASC')+',a."id" DESC LIMIT $'+(args.length+1)+' OFFSET $'+(args.length+2),[...args,p.size,(p.index-1)*p.size]);
-  const courses=coach?[]:await Psql.query('SELECT c."generalId",c."title",b.*,b."objectId" AS "bindingObjectId",n."nodeName" AS "courseTitle" FROM "CourseBinding" b JOIN "CommonModel" c ON c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id" LEFT JOIN "Node" n ON n."company"=b."company" AND n."nodeId"::text=b."kcid" WHERE b."company"=$1 AND b."yhid"=$2 AND b."enrollmentStatus"=\'active\' AND c."status"=99 AND COALESCE(n."zstatus",99)=99 ORDER BY b."id" DESC',[DEFAULT_COMPANY_ID,String(uid)]);
+  const courses=coach?[]:await mobileReadableCourseBindings(uid);
   const live=await appLiveVersionSnapshot(uid,['courses','appointments'],current);return {schedules:rows.map(appOverviewScheduleRow),courses:courses.map(appOverviewCourseRow),page:mobilePageMeta(count.total,p),dates,studentOptions,...mobileSource(),...live};
 }
 async function appAccountOverview(input,current) {

+ 61 - 0
scripts/tests/mobile-course-bindings.test.mjs

@@ -0,0 +1,61 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import vm from 'node:vm';
+import { readFileSync } from 'node:fs';
+import { PGlite } from '@electric-sql/pglite';
+
+const extension = readFileSync(new URL('../cloud/mobile-teaching.js', import.meta.url), 'utf8');
+const gateway = readFileSync(new URL('../deploy-admin-functions.mjs', import.meta.url), 'utf8');
+
+test('学生已分配词库保留待分类和缺少通用记录的历史开通,排除回收、停用及其他学员', async () => {
+  const db = new PGlite();
+  try {
+    await db.exec(`CREATE TABLE "CourseBinding"("objectId" text,company text,id bigint,yhid numeric,kcid text,"enrollmentStatus" text,syjd text,yxx numeric);
+      CREATE TABLE "CommonModel"(company text,"modelId" int,"itemId" bigint,"generalId" bigint,title text,"createTime" timestamptz,status int);
+      CREATE TABLE "Node"(company text,"nodeId" numeric,"nodeName" text,zstatus int);
+      INSERT INTO "Node" VALUES ('co',81,'历史词库',99),('co',82,'停用词库',0),('other',81,'其他帐套',99);`);
+    const states = ['active', 'pending', null, '', 'recycled', 'disabled', 'active', 'pending', 'pending', 'pending', 'pending'];
+    for (const [i,state] of states.entries()) {
+      const id = i + 1;
+      await db.query('INSERT INTO "CourseBinding" VALUES($1,$2,$3,$4,$5,$6,$7,$8)', [`b${id}`,id===11?'other':'co',id,id===10?43:42,id===9?'82':id===2?'81.0':'81',state,'{"91":"91@已分配单元"}',12]);
+      if (id !== 8) await db.query('INSERT INTO "CommonModel" VALUES($1,58,$2,$2,$3,now(),$4)', [id===11?'other':'co',id,'已开通词库',id===7?-2:99]);
+    }
+    // An explicitly unpublished common record must remain unavailable.
+    await db.exec(`INSERT INTO "CourseBinding" VALUES('draft','co',12,42,'81','pending','',0); INSERT INTO "CommonModel" VALUES('co',58,12,12,'未发布',now(),0);`);
+    const context = {
+      DEFAULT_COMPANY_ID: 'co',
+      Psql: { query: async (sql,args) => (await db.query(sql,args)).rows },
+      number: value => Number(value) || 0,
+      ownLegacyId: current => Number(current.get('legacyUserId')),
+    };
+    vm.runInNewContext(extension,context);
+    const courses = await context.mobileReadableCourseBindings(42);
+    assert.deepEqual(courses.map(row=>row.bindingObjectId),['b8','b4','b3','b2','b1']);
+    assert.ok(courses.every(row=>Number(row.yxx)===12 && row.syjd.includes('已分配单元')));
+    assert.equal(courses.find(row=>row.bindingObjectId==='b2').courseTitle,'历史词库');
+
+    const student = {get:key=>({legacyGroupId:1,legacyUserId:42})[key]};
+    const coach = {get:key=>({legacyGroupId:3,legacyUserId:99})[key]};
+    assert.equal((await context.mobileUnitBindingRows(42,'b2',81,student)).length,1);
+    assert.equal((await context.mobileUnitBindingRows(42,'8',81,student)).length,1);
+    assert.equal((await context.mobileUnitBindingRows(42,'b7',81,student)).length,0);
+    assert.equal((await context.mobileUnitBindingRows(42,'b5',81,student)).length,0);
+    assert.equal((await context.mobileUnitBindingRows(42,'b9',82,student)).length,0);
+    assert.equal((await context.mobileUnitBindingRows(42,'b10',81,student)).length,0);
+    assert.equal((await context.mobileUnitBindingRows(42,'b2',81,coach)).length,0);
+    assert.equal((await context.mobileUnitBindingRows(42,'b1',81,coach)).length,1);
+    assert.equal((await context.mobileUnitBindingRows(43,'b2',81,student)).length,0);
+    const retained = (await db.query('SELECT "enrollmentStatus",yxx FROM "CourseBinding" WHERE "objectId"=\'b2\'' )).rows[0];
+    assert.equal(retained.enrollmentStatus,'pending');
+    assert.equal(Number(retained.yxx),12);
+  } finally { await db.close(); }
+});
+
+test('伴学中心、学习记录和单元入口采用统一的词库读取规则', () => {
+  const studentOverview = gateway.slice(gateway.indexOf('async function appStudentCompanionOverview'),gateway.indexOf('async function appCompanionOverview'));
+  const learningOverview = gateway.slice(gateway.indexOf('async function appLearningOverview'),gateway.indexOf('async function appStudentCompanionOverview'));
+  const unitPage = gateway.slice(gateway.indexOf('async function nodeUnitPage'),gateway.indexOf('function legacyAppRow'));
+  assert.match(studentOverview,/courses=await mobileReadableCourseBindings\(uid\)/);
+  assert.match(learningOverview,/mobileReadableCourseBindings\(targetId\)/);
+  assert.match(unitPage,/mobileUnitBindingRows\(uid,bindingId,parentId,current\)/);
+});