|
@@ -2827,30 +2827,81 @@ async function syncLegacyLearning(targetId,current,force=false){const key=String
|
|
|
const LEGACY_REVIEW_PULLS=new Map();
|
|
const LEGACY_REVIEW_PULLS=new Map();
|
|
|
function legacyReviewRecord(source,targetId){const generalId=number(source&&firstValue(source.GeneralID,source.generalId)),studentId=number(source&&firstValue(source.yhid,source.UserID,source.userId));if(!generalId||studentId!==number(targetId))return null;const createdAt=legacyLearningIso(firstValue(source.CreateTime,source.createTime,source.UpDateTime,source.updatedAt))||new Date().toISOString();return{generalId,itemId:generalId,sourceKey:'legacy:model:60:general:'+generalId,createdAt,common:{generalId,itemId:generalId,modelId:60,nodeId:number(firstValue(source.NodeID,source.nodeId),388)||388,tableName:'ZL_C_gywjl',title:String(firstValue(source.Title,source.title,'抗遗忘复习')||'抗遗忘复习').slice(0,200),subtitle:String(firstValue(source.Subtitle,source.subtitle,'')||'').slice(0,500),inputer:String(firstValue(source.Inputer,source.inputer,'')||'').slice(0,100),hits:number(firstValue(source.Hits,source.hits)),status:number(firstValue(source.Status,source.status),99),createTime:createdAt},addon:{fxzt:String(firstValue(source.fxzt,'0')||'0'),kcid:number(source.kcid),plid:number(source.plid),wcsj:String(source.wcsj||''),yhid:studentId,kywrq:String(source.kywrq||'').slice(0,20),kywsj:String(source.kywsj||'').slice(0,40),xxjlid:number(source.xxjlid),orderId:number(source.orderId)}};}
|
|
function legacyReviewRecord(source,targetId){const generalId=number(source&&firstValue(source.GeneralID,source.generalId)),studentId=number(source&&firstValue(source.yhid,source.UserID,source.userId));if(!generalId||studentId!==number(targetId))return null;const createdAt=legacyLearningIso(firstValue(source.CreateTime,source.createTime,source.UpDateTime,source.updatedAt))||new Date().toISOString();return{generalId,itemId:generalId,sourceKey:'legacy:model:60:general:'+generalId,createdAt,common:{generalId,itemId:generalId,modelId:60,nodeId:number(firstValue(source.NodeID,source.nodeId),388)||388,tableName:'ZL_C_gywjl',title:String(firstValue(source.Title,source.title,'抗遗忘复习')||'抗遗忘复习').slice(0,200),subtitle:String(firstValue(source.Subtitle,source.subtitle,'')||'').slice(0,500),inputer:String(firstValue(source.Inputer,source.inputer,'')||'').slice(0,100),hits:number(firstValue(source.Hits,source.hits)),status:number(firstValue(source.Status,source.status),99),createTime:createdAt},addon:{fxzt:String(firstValue(source.fxzt,'0')||'0'),kcid:number(source.kcid),plid:number(source.plid),wcsj:String(source.wcsj||''),yhid:studentId,kywrq:String(source.kywrq||'').slice(0,20),kywsj:String(source.kywsj||'').slice(0,40),xxjlid:number(source.xxjlid),orderId:number(source.orderId)}};}
|
|
|
function legacyReviewSignature(row){if(!row)return'';return JSON.stringify({title:String(row.title||''),subtitle:String(row.subtitle||''),inputer:String(row.inputer||''),hits:number(row.hits),status:number(row.status,99),fxzt:String(row.fxzt||'0'),kcid:number(row.kcid),plid:number(row.plid),wcsj:String(row.wcsj||''),yhid:number(row.yhid),kywrq:String(row.kywrq||''),kywsj:String(row.kywsj||''),xxjlid:number(row.xxjlid),orderId:number(row.orderId)});}
|
|
function legacyReviewSignature(row){if(!row)return'';return JSON.stringify({title:String(row.title||''),subtitle:String(row.subtitle||''),inputer:String(row.inputer||''),hits:number(row.hits),status:number(row.status,99),fxzt:String(row.fxzt||'0'),kcid:number(row.kcid),plid:number(row.plid),wcsj:String(row.wcsj||''),yhid:number(row.yhid),kywrq:String(row.kywrq||''),kywsj:String(row.kywsj||''),xxjlid:number(row.xxjlid),orderId:number(row.orderId)});}
|
|
|
-async function legacyReviewPage(config,targetId,page,pageSize){const params=new URLSearchParams({action:'content_list',modelId:'60',nid:'388',myfield2:'yhid='+targetId,orders:'CreateTime=desc',cpage:String(page),psize:String(pageSize),apiId:config.apiId,apiKey:config.apiKey}),url=config.url+(config.url.includes('?')?'&':'?')+params.toString();let response,raw,payload;try{response=await fetch(url,{headers:{accept:'application/json'},signal:typeof AbortSignal!=='undefined'&&AbortSignal.timeout?AbortSignal.timeout(12000):undefined});raw=await response.text();payload=JSON.parse(raw);}catch(error){throw new Error('旧系统抗遗忘记录读取失败');}if(!response.ok||number(payload&&payload.retcode)===-1)throw new Error(String(payload&&payload.retmsg||'旧系统抗遗忘记录读取失败'));let result=payload&&payload.result;if(typeof result==='string')try{result=JSON.parse(result);}catch(_){result=[];}const rows=Array.isArray(result)?result:Array.isArray(result&&result.items)?result.items:[],meta=payload&&payload.page||result&&result.page||{};return{rows,pageCount:Math.max(1,number(meta.pageCount,1)),total:number(meta.itemCount,rows.length)};}
|
|
|
|
|
|
|
+async function legacyReviewPage(config,filter,page,pageSize){const params=new URLSearchParams({action:'content_list',modelId:'60',nid:'388',myfield2:filter,orders:'CreateTime=desc',cpage:String(page),psize:String(pageSize),apiId:config.apiId,apiKey:config.apiKey}),url=config.url+(config.url.includes('?')?'&':'?')+params.toString();let response,raw,payload;try{response=await fetch(url,{headers:{accept:'application/json'},signal:typeof AbortSignal!=='undefined'&&AbortSignal.timeout?AbortSignal.timeout(12000):undefined});raw=await response.text();payload=JSON.parse(raw);}catch(error){throw new Error('旧系统抗遗忘记录读取失败');}if(!response.ok||number(payload&&payload.retcode)===-1)throw new Error(String(payload&&payload.retmsg||'旧系统抗遗忘记录读取失败'));let result=payload&&payload.result;if(typeof result==='string')try{result=JSON.parse(result);}catch(_){result=[];}const rows=Array.isArray(result)?result:Array.isArray(result&&result.items)?result.items:[],meta=payload&&payload.page||result&&result.page||{};return{rows,pageCount:Math.max(1,number(meta.pageCount,1)),total:number(meta.itemCount,rows.length)};}
|
|
|
async function performLegacyReviewSync(targetId,current,force=false){
|
|
async function performLegacyReviewSync(targetId,current,force=false){
|
|
|
const config=await appLegacyConfig();if(!config.url||!config.apiId||!config.apiKey)throw new Error('旧系统抗遗忘同步尚未配置');
|
|
const config=await appLegacyConfig();if(!config.url||!config.apiId||!config.apiKey)throw new Error('旧系统抗遗忘同步尚未配置');
|
|
|
- const pageSize=500,first=await legacyReviewPage(config,targetId,1,pageSize),targetCount=await Psql.one('SELECT COUNT(*)::int AS total FROM "MemoryPracticeRecord" WHERE "company"=$1 AND "yhid"=$2',[DEFAULT_COMPANY_ID,targetId]),needsFull=force||number(targetCount.total)<first.total,pageCount=needsFull?Math.min(40,first.pageCount):1,pages=[first];
|
|
|
|
|
- if(pageCount>1)pages.push(...await Promise.all(Array.from({length:pageCount-1},(_,index)=>legacyReviewPage(config,targetId,index+2,pageSize))));
|
|
|
|
|
|
|
+ const pageSize=500,first=await legacyReviewPage(config,'yhid='+targetId,1,pageSize),targetCount=await Psql.one('SELECT COUNT(*)::int AS total FROM "MemoryPracticeRecord" WHERE "company"=$1 AND "yhid"=$2',[DEFAULT_COMPANY_ID,targetId]),needsFull=force||number(targetCount.total)<first.total,pageCount=needsFull?Math.min(40,first.pageCount):1,pages=[first];
|
|
|
|
|
+ if(pageCount>1)pages.push(...await Promise.all(Array.from({length:pageCount-1},(_,index)=>legacyReviewPage(config,'yhid='+targetId,index+2,pageSize))));
|
|
|
const byId=new Map();for(const source of pages.flatMap(item=>item.rows)){const row=legacyReviewRecord(source,targetId);if(row)byId.set(row.generalId,row);}
|
|
const byId=new Map();for(const source of pages.flatMap(item=>item.rows)){const row=legacyReviewRecord(source,targetId);if(row)byId.set(row.generalId,row);}
|
|
|
- const records=[...byId.values()],ids=records.map(row=>String(row.generalId));
|
|
|
|
|
- const existing=ids.length?await Psql.query('SELECT c."generalId",c."itemId",to_jsonb(m) AS "taskSnapshot" FROM "CommonModel" c LEFT JOIN "MemoryPracticeRecord" m ON m.company=c.company AND m.id=c."itemId" WHERE c.company=$1 AND c."modelId"=60 AND c."generalId"::text=ANY($2::text[])',[DEFAULT_COMPANY_ID,ids]):[];
|
|
|
|
|
- const known=new Map(existing.map(r=>[number(r.generalId),r])),usedIds=new Set((ids.length?await Psql.query('SELECT CAST("id" AS text) AS id FROM "MemoryPracticeRecord" WHERE company=$1 AND id::text=ANY($2::text[])',[DEFAULT_COMPANY_ID,ids]):[]).map(r=>String(r.id)));
|
|
|
|
|
- let written=0,conflicts=0;
|
|
|
|
|
- for(const row of records){const saved=known.get(row.generalId);if(!saved?.taskSnapshot)continue;
|
|
|
|
|
- const fields=Object.keys(row.addon).filter(f=>String(row.addon[f]??'')!==String(saved.taskSnapshot[f]??''));if(!fields.length)continue;
|
|
|
|
|
- const values=Object.fromEntries(fields.map(f=>[f,row.addon[f]]));
|
|
|
|
|
- const result=await Psql.tx(async t=>{const applied=(await t.one('SELECT xs_legacy_review_patch($1,$2,$3::jsonb,$4::jsonb) result',[DEFAULT_COMPANY_ID,saved.taskSnapshot.objectId,JSON.stringify(values),JSON.stringify(saved.taskSnapshot)])).result;
|
|
|
|
|
- if(!applied.applied)await t.none('INSERT INTO "ReviewAssignmentConflict" ("objectId",company,"taskId",incoming,preserved) VALUES(md5($1||$2||$3),$1,$2,$3::jsonb,$4::jsonb) ON CONFLICT DO NOTHING',[DEFAULT_COMPANY_ID,saved.taskSnapshot.objectId,JSON.stringify(row.addon),JSON.stringify(saved.taskSnapshot)]);return applied;});
|
|
|
|
|
- if(result.applied)written++;else conflicts++;
|
|
|
|
|
- }
|
|
|
|
|
- const missing=records.filter(r=>!known.get(r.generalId)?.taskSnapshot);
|
|
|
|
|
- for(let index=0;index<missing.length;index+=50){const operations=[];for(const row of missing.slice(index,index+50)){
|
|
|
|
|
- const saved=known.get(row.generalId),preferred=saved?number(saved.itemId):row.generalId,itemId=!saved&&usedIds.has(String(preferred))?900000000000+row.generalId*100+60:preferred;usedIds.add(String(itemId));
|
|
|
|
|
- const common={objectId:randomObjectId(),createdAt:row.createdAt,company:DEFAULT_COMPANY_ID,sourceKey:row.sourceKey,...row.common,itemId},addon={objectId:randomObjectId(),createdAt:row.createdAt,company:DEFAULT_COMPANY_ID,sourceKey:row.sourceKey,id:itemId,...row.addon};
|
|
|
|
|
- operations.push({table:'MemoryPracticeRecord',data:addon,match:{company:DEFAULT_COMPANY_ID,id:itemId}},{table:'CommonModel',data:common,match:{company:DEFAULT_COMPANY_ID,modelId:60,generalId:row.generalId},reference:{itemId:'id'}});
|
|
|
|
|
- }if(operations.length){await mobileAtomicWrite({requestKey:'legacy-review:'+targetId+':'+Date.now()+':'+index},current,operations,String(targetId));written+=operations.length/2;}}
|
|
|
|
|
- return{state:conflicts?'conflict':'current',conflicts,lastSuccessAt:new Date().toISOString(),lagSeconds:0,pending:0,pulled:records.length,written,sourceTotal:first.total,complete:pageCount===first.pageCount};
|
|
|
|
|
|
|
+ const records=[...byId.values()],{written,conflicts}=await applyLegacyReviewRecords(records,targetId,current);
|
|
|
|
|
+ return {state:conflicts?'conflict':'current',conflicts,lastSuccessAt:new Date().toISOString(),lagSeconds:0,pending:0,pulled:records.length,written,sourceTotal:first.total,complete:pageCount===first.pageCount};
|
|
|
|
|
+}
|
|
|
|
|
+async function applyLegacyReviewRecords(records,targetId,current,coachRefresh=false){
|
|
|
|
|
+ if(!records.length)return {written:0,conflicts:0};
|
|
|
|
|
+ return Psql.tx(async t=>{
|
|
|
|
|
+ // Match the direct importer lock order and serialize each learner's upserts.
|
|
|
|
|
+ await t.none("SET LOCAL lock_timeout='3s'");
|
|
|
|
|
+ if(coachRefresh)await t.one("SELECT pg_advisory_xact_lock(hashtext('xs-credit:'||$1))",[DEFAULT_COMPANY_ID]);
|
|
|
|
|
+ await t.one('SELECT pg_advisory_xact_lock(hashtextextended($1,0))',[DEFAULT_COMPANY_ID+':'+targetId]);
|
|
|
|
|
+ const ids=records.map(row=>String(row.generalId));
|
|
|
|
|
+ const existing=await t.query('SELECT c."generalId",c."itemId",c.status,c."sourceKey",c."teachingManaged",to_jsonb(m) AS "taskSnapshot" FROM "CommonModel" c LEFT JOIN "MemoryPracticeRecord" m ON m.company=c.company AND m.id=c."itemId" WHERE c.company=$1 AND c."modelId"=60 AND c."generalId"::text=ANY($2::text[])',[DEFAULT_COMPANY_ID,ids]);
|
|
|
|
|
+ const alternateId=row=>900000000000+row.generalId*100+60;
|
|
|
|
|
+ const known=new Map(existing.map(r=>[number(r.generalId),r])),usedIds=new Set((await t.query('SELECT CAST("id" AS text) AS id FROM "MemoryPracticeRecord" WHERE company=$1 AND id::text=ANY($2::text[])',[DEFAULT_COMPANY_ID,[...ids,...records.map(row=>String(alternateId(row)))]])).map(r=>String(r.id)));
|
|
|
|
|
+ let written=0,conflicts=0;
|
|
|
|
|
+ for(const row of records){const saved=known.get(row.generalId);if(!saved?.taskSnapshot)continue;
|
|
|
|
|
+ const fields=Object.keys(row.addon).filter(f=>String(row.addon[f]??'')!==String(saved.taskSnapshot[f]??''));if(!fields.length)continue;
|
|
|
|
|
+ const values=Object.fromEntries(fields.map(f=>[f,row.addon[f]]));
|
|
|
|
|
+ const result=(await t.one('SELECT xs_legacy_review_patch($1,$2,$3::jsonb,$4::jsonb) result',[DEFAULT_COMPANY_ID,saved.taskSnapshot.objectId,JSON.stringify(values),JSON.stringify(saved.taskSnapshot)])).result;
|
|
|
|
|
+ if(!result.applied)await t.none('INSERT INTO "ReviewAssignmentConflict" ("objectId",company,"taskId",incoming,preserved) VALUES(md5($1||$2||$3),$1,$2,$3::jsonb,$4::jsonb) ON CONFLICT DO NOTHING',[DEFAULT_COMPANY_ID,saved.taskSnapshot.objectId,JSON.stringify(row.addon),JSON.stringify(saved.taskSnapshot)]);
|
|
|
|
|
+ if(result.applied)written++;else conflicts++;
|
|
|
|
|
+ }
|
|
|
|
|
+ const missing=records.filter(row=>{
|
|
|
|
|
+ const saved=known.get(row.generalId);
|
|
|
|
|
+ if(saved?.taskSnapshot)return false;
|
|
|
|
|
+ if(coachRefresh&&saved&&(saved.teachingManaged||number(saved.status)===-2||saved.sourceKey!==row.sourceKey)){conflicts++;return false;}
|
|
|
|
|
+ return true;
|
|
|
|
|
+ });
|
|
|
|
|
+ for(let index=0;index<missing.length;index+=50){const operations=[];for(const row of missing.slice(index,index+50)){
|
|
|
|
|
+ const saved=known.get(row.generalId),preferred=saved?number(saved.itemId):row.generalId,itemId=!saved&&usedIds.has(String(preferred))?alternateId(row):preferred;
|
|
|
|
|
+ if(usedIds.has(String(itemId))){conflicts++;continue;}
|
|
|
|
|
+ usedIds.add(String(itemId));
|
|
|
|
|
+ const common={objectId:randomObjectId(),createdAt:row.createdAt,company:DEFAULT_COMPANY_ID,sourceKey:row.sourceKey,...row.common,itemId},addon={objectId:randomObjectId(),createdAt:row.createdAt,company:DEFAULT_COMPANY_ID,sourceKey:row.sourceKey,id:itemId,...row.addon};
|
|
|
|
|
+ operations.push({table:'MemoryPracticeRecord',data:addon,match:{company:DEFAULT_COMPANY_ID,id:itemId}},{table:'CommonModel',data:common,match:{company:DEFAULT_COMPANY_ID,modelId:60,generalId:row.generalId},reference:{itemId:'id'}});
|
|
|
|
|
+ }if(operations.length){await mobileAtomicWrite({requestKey:'legacy-review:'+targetId+':'+Date.now()+':'+index},current,operations,String(targetId),t);written+=operations.length/2;}}
|
|
|
|
|
+ return {written,conflicts};
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+const LEGACY_COACH_TODAY_REVIEW_PULLS = new Map();
|
|
|
|
|
+async function syncLegacyCoachTodayReviews(coachId,current,today) {
|
|
|
|
|
+ const key=coachId+':'+today;
|
|
|
|
|
+ if(LEGACY_COACH_TODAY_REVIEW_PULLS.has(key))return LEGACY_COACH_TODAY_REVIEW_PULLS.get(key);
|
|
|
|
|
+ const promise=(async()=>{
|
|
|
|
|
+ const config=await appLegacyConfig();
|
|
|
|
|
+ if(!config.url||!config.apiId||!config.apiKey)throw new Error('旧系统抗遗忘同步尚未配置');
|
|
|
|
|
+ const filter='plid='+coachId+'|kywrq='+today,pageSize=500;
|
|
|
|
|
+ const first=await legacyReviewPage(config,filter,1,pageSize);
|
|
|
|
|
+ if(first.pageCount>4)throw new Error('旧系统当天复习记录超出刷新范围');
|
|
|
|
|
+ const pages=[first];
|
|
|
|
|
+ for(let page=2;page<=first.pageCount;page++)pages.push(await legacyReviewPage(config,filter,page,pageSize));
|
|
|
|
|
+ const source=pages.flatMap(page=>page.rows);
|
|
|
|
|
+ if(source.length!==first.total||pages.some(page=>page.total!==first.total||page.pageCount!==first.pageCount))throw new Error('旧系统当天复习分页不完整');
|
|
|
|
|
+ const byLearner=new Map();
|
|
|
|
|
+ for(const item of source){
|
|
|
|
|
+ const learner=number(firstValue(item.yhid,item.UserID,item.userId)),row=legacyReviewRecord(item,learner);
|
|
|
|
|
+ if(!row||!learner||row.common.status!==99||row.addon.plid!==coachId||(row.addon.kywrq||row.addon.kywsj).slice(0,10)!==today)continue;
|
|
|
|
|
+ if(!byLearner.has(learner))byLearner.set(learner,new Map());
|
|
|
|
|
+ byLearner.get(learner).set(row.generalId,row);
|
|
|
|
|
+ }
|
|
|
|
|
+ let written=0,conflicts=0,pulled=0;
|
|
|
|
|
+ for(const [learner,rows] of byLearner){
|
|
|
|
|
+ const result=await applyLegacyReviewRecords([...rows.values()],learner,current,true);
|
|
|
|
|
+ written+=result.written;conflicts+=result.conflicts;pulled+=rows.size;
|
|
|
|
|
+ }
|
|
|
|
|
+ return {state:conflicts?'conflict':'current',conflicts,written,pulled,complete:true,lastSuccessAt:new Date().toISOString(),lagSeconds:0,pending:0};
|
|
|
|
|
+ })().catch(error=>({state:'unavailable',complete:false,message:String(error&&error.message||'旧系统抗遗忘记录同步失败')}))
|
|
|
|
|
+ .finally(()=>LEGACY_COACH_TODAY_REVIEW_PULLS.delete(key));
|
|
|
|
|
+ LEGACY_COACH_TODAY_REVIEW_PULLS.set(key,promise);
|
|
|
|
|
+ return promise;
|
|
|
}
|
|
}
|
|
|
async function syncLegacyReviews(targetId,current,force=false){const key=String(targetId),now=Date.now(),cached=LEGACY_REVIEW_PULLS.get(key);if(!force&&cached&&now-cached.startedAt<10000)return cached.promise;const promise=performLegacyReviewSync(targetId,current,force).catch((error)=>({state:'unavailable',lastSuccessAt:'',lagSeconds:null,pending:0,pulled:0,written:0,message:String(error&&error.message||'旧系统抗遗忘记录同步失败')}));LEGACY_REVIEW_PULLS.set(key,{startedAt:now,promise});return promise;}
|
|
async function syncLegacyReviews(targetId,current,force=false){const key=String(targetId),now=Date.now(),cached=LEGACY_REVIEW_PULLS.get(key);if(!force&&cached&&now-cached.startedAt<10000)return cached.promise;const promise=performLegacyReviewSync(targetId,current,force).catch((error)=>({state:'unavailable',lastSuccessAt:'',lagSeconds:null,pending:0,pulled:0,written:0,message:String(error&&error.message||'旧系统抗遗忘记录同步失败')}));LEGACY_REVIEW_PULLS.set(key,{startedAt:now,promise});return promise;}
|
|
|
const LEGACY_APPOINTMENT_PULLS=new Map(),LEGACY_APPOINTMENT_VERSIONS=new Map();
|
|
const LEGACY_APPOINTMENT_PULLS=new Map(),LEGACY_APPOINTMENT_VERSIONS=new Map();
|