Parcourir la source

fix(reading): use DeepSeek and respect authorized study record access

彭峰 il y a 4 jours
Parent
commit
1905ecef23

+ 9 - 2
scripts/cloud/lesson-study.js

@@ -25,8 +25,15 @@ async function appLessonStudy(input,current) {
 async function appReadingSource(input,current) {
  const detail=await recordDetailData({id:input.sourceStudyId,studentId:input.studentId},current);
  if(!detail?.record)fail(404,'学习记录不存在');
- const record=detail.record,order=record.dsid?await orderDetailRow(record.dsid,current):null;
- if(record.recordScope==='lesson'&&number(order?.dszt)<30)fail(409,'请先完成课程,再使用完整课堂词表生成阅读');
+ const record=detail.record,ownerId=number(record.userId??record.UserID);
+ if(number(input.studentId)&&number(input.studentId)!==ownerId)fail(403,'阅读学员与学习记录不一致,请返回学习记录重新打开');
+ // recordDetailData already authorizes access to this learner's study record.
+ // Linked teachers can read the learner's history without access to another teacher's appointment details.
+ if(record.recordScope==='lesson'){
+  const order=record.dsid?await orderDetailRow(record.dsid,current,false):null;
+  if(!order||number(order.szyh)!==ownerId)fail(409,'课堂学习记录与课程学员不一致,请联系管理员核对');
+  if(number(order.dszt)<30)fail(409,'请先完成课程,再使用完整课堂词表生成阅读');
+ }
  const words=detail.words||[];
  return {sourceStudyId:String(record.GeneralID||record.generalId),sourceVersion:String(record.studyVersion||record.updatedAt||''),wordIds:words.map(w=>String(w.GeneralID)),wordList:words.map(w=>w.Title),studentId:number(record.userId??record.UserID)};
 }

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

@@ -2950,7 +2950,7 @@ async function readingAi(input,current){
   if(!current)fail(401,'阅读学习需要登录');
   let prompt=String(input.prompt||'').trim();if(input.sourceStudyId){const source=await appReadingSource(input,current);if(String(input.sourceVersion)!==source.sourceVersion)fail(409,'课堂词表已更新,请重新打开阅读');prompt+='\n本次课堂完整目标词表(必须全部覆盖):'+source.wordList.join(', ');}if(!prompt)fail(400,'阅读生成内容不能为空');if(prompt.length>60000)fail(400,'阅读生成内容过长');
   const config=await Parse.Config.get({useMasterKey:true}),token=String((typeof process!=='undefined'&&process.env&&process.env.FMODE_API_TOKEN)||config.get('fmodeApiToken')||'').trim();if(!token)fail(424,'阅读生成服务尚未配置,请联系管理员处理');
-  const body={model:'doubao-seed-2-0-lite-260215',messages:[{role:'user',content:prompt}],stream:false,max_tokens:32768,thinking:{type:'disabled'}};if(input.jsonMode===true)body.response_format={type:'json_object'};
+  const body={model:'deepseek-flash',messages:[{role:'user',content:prompt}],stream:false,max_tokens:32768,thinking:{type:'disabled'}};if(input.jsonMode===true)body.response_format={type:'json_object'};
   let response,payload;try{response=await fetch('https://api.fmode.cn/v1/chat/completions',{method:'POST',headers:{Authorization:'Bearer '+token,'Content-Type':'application/json'},body:JSON.stringify(body),signal:typeof AbortSignal!=='undefined'&&AbortSignal.timeout?AbortSignal.timeout(120000):undefined});const raw=await response.text();try{payload=raw?JSON.parse(raw):{};}catch(_){payload={};}}catch(_){fail(502,'阅读生成服务暂时不可用');}
   if(response&&[401,403].includes(response.status))fail(424,'阅读生成服务授权异常,请联系管理员处理');
   if(!response||!response.ok)fail(502,'阅读生成服务暂时不可用'+(response?'('+response.status+')':''));const content=String(payload&&payload.choices&&payload.choices[0]&&payload.choices[0].message&&payload.choices[0].message.content||'');if(!content.trim())fail(502,'阅读生成服务未返回内容');return{content};

+ 30 - 0
scripts/tests/lesson-reading-source.test.mjs

@@ -8,3 +8,33 @@ test('real AI adapter appends all server-resolved words and rejects stale source
  assert.equal(sent.length,1);for(const word of wordList)assert(sent[0].messages[0].content.includes(word));
  await assert.rejects(ctx.readingAi({sourceStudyId:'fixture',sourceVersion:'3',prompt:'Write a story'},{}),/已更新/);assert.equal(sent.length,1);
 });
+
+const lessonStudy=readFileSync(new URL('../cloud/lesson-study.js',import.meta.url),'utf8');
+function readingSourceContext(record,order,authorizeRecord=true,studentId=86){
+ const calls=[];
+ const ctx=vm.createContext({number:Number,recordDetailData:async()=>{if(!authorizeRecord)throw Object.assign(Error('无权查看该学员的学习记录'),{status:403});return{record,words:[{GeneralID:1,Title:'apple'},{GeneralID:2,Title:'school'}]};},orderDetailRow:async(id,current,authorize=true)=>{calls.push({id,authorize});if(authorize)throw Object.assign(Error('无权查看该业务记录'),{status:403});return order;},fail:(status,message)=>{throw Object.assign(Error(message),{status});}});
+ vm.runInContext(lessonStudy,ctx);return{run:()=>ctx.appReadingSource({sourceStudyId:record.GeneralID,studentId},{id:'linked-teacher'}),calls};
+}
+test('关联老师可以用已授权的学员课堂词表生成阅读,无需读取其他老师的课程详情',async()=>{
+ const {run,calls}=readingSourceContext({GeneralID:'study',userId:86,pl:104,dsid:'lesson',recordScope:'lesson',studyVersion:9},{szyh:86,pl:104,dszt:30});
+ const result=await run();assert.equal(result.studentId,86);assert.equal(result.sourceVersion,'9');assert.deepEqual(Array.from(result.wordList),['apple','school']);assert.equal(calls.length,1);assert.equal(calls[0].authorize,false);
+});
+test('未通过学习记录授权的老师仍被拒绝,不继续读取课程',async()=>{
+ const {run,calls}=readingSourceContext({GeneralID:'study',userId:86,dsid:'lesson',recordScope:'lesson'},{szyh:86,dszt:30},false);
+ await assert.rejects(run(),e=>e.status===403);assert.equal(calls.length,0);
+});
+test('阅读词表不能使用其他学员的课程或尚未结束的课程',async()=>{
+ const record={GeneralID:'study',userId:86,dsid:'lesson',recordScope:'lesson'};
+ await assert.rejects(readingSourceContext(record,{szyh:85,dszt:30}).run(),/课程学员不一致/);
+ await assert.rejects(readingSourceContext(record,null).run(),/课程学员不一致/);
+ await assert.rejects(readingSourceContext(record,{szyh:86,dszt:10}).run(),/请先完成课程/);
+});
+test('历史非课堂记录沿用学习记录权限,不误触发预约详情权限',async()=>{
+ const {run,calls}=readingSourceContext({GeneralID:'historical',userId:86,dsid:'old-lesson',updatedAt:'old-version'},null);
+ assert.equal((await run()).studentId,86);assert.equal(calls.length,0);
+});
+
+test('阅读入口指定的学员必须与学习记录一致,不能串用关联学员词表',async()=>{
+ const {run,calls}=readingSourceContext({GeneralID:'study',userId:86,dsid:'lesson',recordScope:'lesson'},{szyh:86,dszt:30},true,85);
+ await assert.rejects(run(),e=>e.status===403&&e.message.includes('阅读学员与学习记录不一致'));assert.equal(calls.length,0);
+});

+ 4 - 4
scripts/tests/reading-ai-errors.test.mjs

@@ -5,12 +5,12 @@ import vm from 'node:vm';
 const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
 const start=source.indexOf('async function readingAi(input,current){'),end=source.indexOf('\nasync function uploadAppMedia',start);
 function setup(status,token='fixture-token'){
- let calls=0;
- const context=vm.createContext({AbortSignal,Parse:{Config:{get:async()=>({get:()=>token})}},fetch:async()=>{calls++;return{status,ok:status===200,text:async()=>JSON.stringify(status===200?{choices:[{message:{content:'A complete article'}}]}:{error:{message:'private-provider-details'}})};},fail:(status,message)=>{throw Object.assign(new Error(message),{status});}});
- vm.runInContext(source.slice(start,end),context);return{run:()=>context.readingAi({prompt:'Read a short story'},{id:'teacher'}),calls:()=>calls};
+ let calls=0,requestBody;
+ const context=vm.createContext({AbortSignal,Parse:{Config:{get:async()=>({get:()=>token})}},fetch:async(_url,options)=>{calls++;requestBody=JSON.parse(options.body);return{status,ok:status===200,text:async()=>JSON.stringify(status===200?{choices:[{message:{content:'A complete article'}}]}:{error:{message:'private-provider-details'}})};},fail:(status,message)=>{throw Object.assign(new Error(message),{status});}});
+ vm.runInContext(source.slice(start,end),context);return{run:()=>context.readingAi({prompt:'Read a short story'},{id:'teacher'}),calls:()=>calls,body:()=>requestBody};
 }
 for(const status of [401,403])test('生成授权失败 '+status+' 返回可处理的配置错误,不泄露上游内容',async()=>{
  const {run,calls}=setup(status);await assert.rejects(run(),error=>error.status===424&&error.message==='阅读生成服务授权异常,请联系管理员处理');assert.equal(calls(),1);
 });
 test('阅读服务缺少配置时不发送生成请求',async()=>{const {run,calls}=setup(200,'');await assert.rejects(run(),error=>error.status===424&&error.message.includes('尚未配置'));assert.equal(calls(),0);});
-test('正常阅读生成仍返回原有内容',async()=>{const {run,calls}=setup(200);assert.equal((await run()).content,'A complete article');assert.equal(calls(),1);});
+test('阅读生成使用已验证的 DeepSeek 模型并返回内容',async()=>{const {run,calls,body}=setup(200);assert.equal((await run()).content,'A complete article');assert.equal(calls(),1);assert.equal(body().model,'deepseek-flash');assert.equal(body().thinking.type,'disabled');assert.equal(body().stream,false);});