// Shared teaching reads. Business state lives in the new database, not browser caches. 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}; } 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); if(!owner)fail(404,'学员不存在'); const data=legacyData(owner),reserved=await Psql.query('SELECT cost->>\'account\' AS account,SUM((cost->>\'amount\')::numeric) AS amount FROM "CourseCreditReservation" r CROSS JOIN LATERAL jsonb_array_elements(r."creditCosts") cost WHERE r."company"=$1 AND r."studentId"=$2 AND r."state"=\'reserved\' GROUP BY cost->>\'account\'',[DEFAULT_COMPANY_ID,uid]); const labels={Purse:'30 分钟课时',SilverCoin:'60 分钟课时',UserExp:'体验课时',UserPoint:'陪练时长'}; return {studentId:uid,accounts:Object.entries(labels).map(([account,label],i)=>{const balance=number(data[account]),held=number(reserved.find(r=>r.account===account)?.amount);return{account,label,historyType:i+1,balance,reserved:held,available:balance-held,unit:account==='UserPoint'?'小时':'次'};}),...mobileSource(),...await appLiveVersionSnapshot(uid,['account'],current)}; } async function appLessonReport(input,current) { const order=await orderDetailRow(input.id||input.orderId,current);if(!order)fail(404,'预约不存在'); const id=String(order.GeneralID||input.id||input.orderId); const [feedback,records,reviews]=await Promise.all([ Psql.oneOrNone('SELECT * FROM "LessonCompletionReport" WHERE "company"=$1 AND "appointmentGeneralId"::text=$2 ORDER BY "submittedAt" DESC LIMIT 1',[DEFAULT_COMPANY_ID,id]), Psql.query('SELECT c."generalId",d."con",d."dqrq",d."learned",d."ygg",d."djq" FROM "DailyStudyRecord" d LEFT 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."userId"=$3 AND COALESCE(to_jsonb(d)->>\'recordScope\',\'\')<>\'lesson-alias\' ORDER BY d."dqrq" DESC',[DEFAULT_COMPANY_ID,id,number(order.szyh)]), Psql.one('SELECT COUNT(*)::int total,COUNT(*) FILTER(WHERE "fxzt"=\'1\')::int completed FROM "MemoryPracticeRecord" WHERE "company"=$1 AND "orderId"::text=$2 AND "yhid"=$3',[DEFAULT_COMPANY_ID,id,number(order.szyh)]) ]); return {order:await lessonOrderCapabilities(order,current),feedback:feedback?safe(feedback):null,records,antiForget:reviews,...mobileSource()}; } 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(); const reviewTarget=coach&&number(input.studentId)?await appReviewTarget(current,input.studentId):coach?0:uid,legacyReviewSync=reviewTarget?await syncLegacyReviews(reviewTarget,current,input.refresh===true):undefined; const args=[DEFAULT_COMPANY_ID,String(uid)],clauses=['m."company"=$1','m."'+(coach?'plid':'yhid')+'"::text=$2']; // A legacy writing lesson can have a ten-round plan but no saved words. Do not list it as a word-review task. clauses.push('NOT EXISTS (SELECT 1 FROM "CommonModel" sc JOIN "DailyStudyRecord" d ON d."company"=sc."company" AND d."id"=sc."itemId" WHERE sc."company"=m."company" AND sc."modelId"=56 AND sc."generalId"::text=m."xxjlid"::text AND COALESCE(d."learned",0)=0 AND COALESCE(NULLIF(BTRIM(d."xxqs"),\'\'),\'[]\')=\'[]\')'); const add=(sql,value)=>{args.push(value);clauses.push(sql.replace('?', '$'+args.length));}; const studentsPromise=coach&&includeMetadata? Psql.query('SELECT m."yhid"::text AS id,COALESCE(MAX(NULLIF(BTRIM(u."realName"),\'\')),MAX(NULLIF(BTRIM(u."nickname"),\'\')),MAX(NULLIF(BTRIM(u."legacyUserData"->>\'HoneyName\'),\'\')),MAX(NULLIF(BTRIM(u."username"),\'\')),\'学员 \'||m."yhid"::text) AS name,COUNT(*)::int AS "taskCount" FROM "MemoryPracticeRecord" m LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+clauses.join(' AND ')+' GROUP BY m."yhid"',[...args]):Promise.resolve([]); if(coach&&number(input.studentId))add('m."yhid"::text=?',String(input.studentId)); const baseWhere=clauses.join(' AND '),baseArgs=[...args]; const dateSql='LEFT(COALESCE(NULLIF(m."kywrq",\'\'),m."kywsj"),10)'; const summaryPromise= Psql.one('SELECT COUNT(*)::int total,COUNT(*) FILTER(WHERE m."fxzt"=\'1\')::int completed,COUNT(*) FILTER(WHERE COALESCE(m."fxzt",\'0\')<>\'1\' AND '+dateSql+'<=$'+(args.length+1)+')::int due,COUNT(*) FILTER(WHERE '+dateSql+'=$'+(args.length+1)+')::int today,COUNT(*) FILTER(WHERE '+dateSql+'=$'+(args.length+1)+' AND m."fxzt"=\'1\')::int \"todayCompleted\",COUNT(*) FILTER(WHERE '+dateSql+'<$'+(args.length+1)+' AND COALESCE(m."fxzt",\'0\')<>\'1\')::int \"overduePending\" FROM "MemoryPracticeRecord" m WHERE '+baseWhere,[...args,today]); const datesPromise=includeMetadata? Psql.query('SELECT '+dateSql+' AS date,COUNT(*)::int total,COUNT(*) FILTER(WHERE m."fxzt"=\'1\')::int completed FROM "MemoryPracticeRecord" m WHERE '+baseWhere+' GROUP BY '+dateSql,baseArgs):Promise.resolve([]); if(input.scope==='today')add(dateSql+'=?',today); else if(input.scope==='overdue') {add(dateSql+'\'1\'');} else if(input.date)add(dateSql+'=?',String(input.date)); if(input.month)add('LEFT('+dateSql+',7)=?',String(input.month)); if(input.status==='completed')clauses.push('m."fxzt"=\'1\''); if(input.status==='pending')clauses.push('COALESCE(m."fxzt",\'0\')<>\'1\''); const where=clauses.join(' AND '),countPromise=Psql.one('SELECT COUNT(*)::int total FROM "MemoryPracticeRecord" m WHERE '+where,args); const rowsPromise=Psql.query('SELECT c.*,row_to_json(m) AS "__addon",n."nodeName" AS "kc_title",d."dqrq" AS "learned_date",d."createdAt" AS "learned_at",r."amount" AS money,r."status" AS "rewardStatus",r."ineligibleReason" AS "ineligibleReason",r."completedAt" AS "completedAt",r."originalAmount" AS "originalAmount",r."correctionReason" AS "correctionReason",r."correctionAmount" AS "correctionAmount",COALESCE(u."realName",u."nickname",u."username") AS "honeyName",(SELECT COUNT(*) FROM "MemoryPracticeRecord" sibling WHERE sibling."company"=m."company" AND sibling."yhid"=m."yhid" AND sibling."xxjlid"=m."xxjlid" AND (sibling."kywsj",sibling."id")<=(m."kywsj",m."id"))::int AS "reviewNo" FROM "MemoryPracticeRecord" m JOIN "CommonModel" c ON c."company"=m."company" AND c."modelId"=60 AND c."itemId"=m."id" LEFT JOIN "Node" n ON n."company"=m."company" AND n."nodeId"::text=m."kcid"::text LEFT JOIN "CommonModel" dc ON dc."company"=m."company" AND dc."modelId"=56 AND dc."generalId"::text=m."xxjlid"::text LEFT JOIN "DailyStudyRecord" d ON d."company"=m."company" AND d."id"=dc."itemId" LEFT JOIN "ReviewRewardIncome" r ON r."company"=m."company" AND r."taskId"=m."objectId" LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+where+(input.scope==='today'?' ORDER BY CASE WHEN m."fxzt"=\'1\' THEN 1 ELSE 0 END,m."kywsj",m."id"':' ORDER BY m."kywsj" DESC,m."id" DESC')+' LIMIT $'+(args.length+1)+' OFFSET $'+(args.length+2),[...args,p.size,(p.index-1)*p.size]); const versionTarget=coach&&number(input.studentId)?number(input.studentId):uid; const [students,summary,dates,count,rows,income,versions]=await Promise.all([studentsPromise,summaryPromise,datesPromise,countPromise,rowsPromise,coach&&input.includeIncome!==false?reviewIncomeTotal(DEFAULT_COMPANY_ID,uid):0,appLiveVersionSnapshot(versionTarget,['review','account'],current)]); return {items:rows.map(row=>{const addon=row.__addon;delete row.__addon;return{...legacyAliases({...row,...addon},'CommonModel'),money:number(row.money),rewardStatus:row.rewardStatus||'pending'};}),page:mobilePageMeta(count.total,p),summary:{...summary,todayTotal:number(summary.today),todayCompleted:number(summary.todayCompleted),todayPending:number(summary.today)-number(summary.todayCompleted),overduePending:number(summary.overduePending),income},dates,students,legacyReviewSync,serverToday:today,...mobileSource(),...versions}; } async function appReviewComplete(input,current) { assertLessonTenant(current); const gid=String(input.id||''); const task=await Psql.oneOrNone('SELECT m."objectId" FROM "CommonModel" c 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=$2',[DEFAULT_COMPANY_ID,gid]); if(!task)fail(404,'复习任务不存在'); // Completion and receipt readback share one statement/transaction. Old clients use the same SQL command. try { const result=await Psql.one('SELECT xs_review_complete_result($1,$2,$3) AS result',[DEFAULT_COMPANY_ID,task.objectId,current.id]);return result.result; } catch(error){fail(409,String(error.message||error));} } function mobileCommandKey(input) {const key=String(input.requestKey||'');return /^[A-Za-z0-9:_-]{10,160}$/.test(key)?key:null;} function mobileObjectData(row) { const json=row.toJSON(),data={...json,objectId:row.id||Math.random().toString(36).slice(2,12),createdAt:json.createdAt||new Date().toISOString()}; delete data.ACL;delete data.className; for(const [key,value] of Object.entries(data))if(value&&typeof value==='object'&&value.__type==='Pointer')data[key]=value.className+'$'+value.objectId;else if(value&&typeof value==='object'&&value.__type==='Date')data[key]=value.iso; if(['CommonModel','DailyStudyRecord','PracticeRecord'].includes(row.className))data.company=DEFAULT_COMPANY_ID; return data; } async function mobileAtomicWrite(input,current,operations,scope='') { const key=mobileCommandKey(input)||('legacy:'+randomObjectId()); const semantic={...input};delete semantic.requestKey;delete semantic.token; const row=await Psql.one('SELECT xs_mobile_teaching_write($1,$2,$3,md5($4),$5::jsonb) AS result',[DEFAULT_COMPANY_ID,scope||String(ownLegacyId(current)),String(current.id)+':'+key,JSON.stringify(semantic),JSON.stringify(operations)]); return row.result; } // Empty profile fields must not hide the student's legacy display name. function companionStudentNameSql() { return `COALESCE(NULLIF(BTRIM(u."realName"),''),NULLIF(BTRIM(u."nickname"),''),NULLIF(BTRIM(u."legacyUserData"->>'HoneyName'),''),NULLIF(BTRIM(SPLIT_PART(REPLACE(c."title",'(','('),'(',1)),''),NULLIF(BTRIM(u."username"),''),'学员 '||a."szyh")`; }