async function upsertStudyRecord(input,current) { assertLessonTenant(current); const uid=await authorizeRequestedUser(current,input.uid),gid=String(input.orderId||input.id||''); const order=await orderDetailRow(gid,current); if(!order||number(order.szyh)!==uid||ownLegacyId(current)!==number(order.pl))fail(403,'仅预约老师可以保存课堂学习'); const addon=parseObject(input.addon,'学习记录'),words=await validateStudyWords(addon.xxqs||[]); // The transaction checks lesson state after command replay, so a confirmed // request can be retried after concurrent completion without a second write. const binding=await lessonStudyBinding(addon,current,uid,{...order,dszt:10},words); const mode=String(input.saveMode||'legacy'),batchId=String(input.batchId||('legacy:'+binding.objectId)); const key=mobileCommandKey(input)||('legacy:'+randomObjectId()); try{return (await Psql.one('SELECT xs_lesson_study_save($1,$2,$3,$4,$5,$6::jsonb) result',[DEFAULT_COMPANY_ID,gid,current.id,ownLegacyId(current),key,JSON.stringify({mode,batchId,bindingId:binding.objectId,expectedVersion:input.expectedVersion??0,words})])).result;} catch(e){fail(409,String(e.message||e));} } async function appLessonStudy(input,current) { assertLessonTenant(current); const gid=String(input.orderId||''),order=await orderDetailRow(gid,current); if(!order)fail(404,'预约不存在'); const batches=await Psql.query('SELECT b.*,cb.kcid AS "courseId",n."nodeName" AS "courseName" FROM "LessonStudyBatch" b LEFT JOIN "CourseBinding" cb ON cb.company=b.company AND cb."objectId"=b."bindingId" LEFT JOIN "Node" n ON n.company=cb.company AND n."nodeId"::text=cb.kcid WHERE b.company=$1 AND b."orderId"=$2 AND b.status<>\'discarded\' ORDER BY b."updatedAt",b."batchId"',[DEFAULT_COMPANY_ID,gid]); const record=await Psql.oneOrNone('SELECT d.*,c."generalId" AS "GeneralID" FROM "DailyStudyRecord" d JOIN "CommonModel" c ON c.company=d.company AND c."modelId"=56 AND c."itemId"=d.id WHERE d.company=$1 AND d.dsid::text=$2 AND d."recordScope"=\'lesson\'',[DEFAULT_COMPANY_ID,gid]); const nodes=await Psql.query('SELECT "nodeId","nodeName" FROM "Node" WHERE company=$1 AND "nodeId"::text=ANY($2::text[])',[DEFAULT_COMPANY_ID,[...new Set(batches.flatMap(b=>parseArray(b.words).map(w=>String(w.nodeId||''))))]]); const names=new Map(nodes.map(n=>[String(n.nodeId),n.nodeName])); return {orderId:gid,record:record?{...record,words:parseArray(record.xxqs)}:null,batches:batches.map(b=>({...b,words:parseArray(b.words).map(w=>({...w,nodeName:names.get(String(w.nodeId))||''}))})),pending:batches.filter(b=>b.status==='draft').length}; } 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 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)}; }