ソースを参照

fix: normalize reading questions and pointer queries

彭峰 3 週間 前
親
コミット
b7c02f484c

+ 4 - 3
projects/xiaoshu-mobile/src/app/features/english-reading/english-reading.service.ts

@@ -8,7 +8,7 @@ import {
   ReadingArticle, ReadingHistoryEntry, ReadingLog, ReadingOption, ReadingProgress,
   ReadingArticle, ReadingHistoryEntry, ReadingLog, ReadingOption, ReadingProgress,
   ReadingQuestion, ReadingRouteParams,
   ReadingQuestion, ReadingRouteParams,
 } from './reading.models';
 } from './reading.models';
-import { readingStage, readingWordLimit, stageQuestionLimit } from './reading.utils';
+import { normalizeReadingQuestions, readingStage, readingWordLimit, stageQuestionLimit } from './reading.utils';
 
 
 export const ARTICLE_TEMPLATE = 'english-reading-article-tpl';
 export const ARTICLE_TEMPLATE = 'english-reading-article-tpl';
 export const SUPPLY_TEMPLATE = 'english-reading-article-supply-tpl';
 export const SUPPLY_TEMPLATE = 'english-reading-article-supply-tpl';
@@ -105,10 +105,11 @@ export class EnglishReadingService {
   }
   }
 
 
   async getQuestions(article: ReadingArticle): Promise<ReadingQuestion[]> {
   async getQuestions(article: ReadingArticle): Promise<ReadingQuestion[]> {
-    return this.cloud.query<ReadingQuestion>('SurveyItem', {
+    const questions = await this.cloud.query<ReadingQuestion>('SurveyItem', {
       parent: this.cloud.pointer('SurveyItem', article.objectId),
       parent: this.cloud.pointer('SurveyItem', article.objectId),
       isDeleted: { '$ne': true },
       isDeleted: { '$ne': true },
-    }, { order: 'index' });
+    }, { order: 'index', limit: 100 });
+    return normalizeReadingQuestions(questions);
   }
   }
 
 
   async createQuestions(article: ReadingArticle, onProgress: (progress: ReadingProgress) => void): Promise<ReadingQuestion[]> {
   async createQuestions(article: ReadingArticle, onProgress: (progress: ReadingProgress) => void): Promise<ReadingQuestion[]> {

+ 3 - 3
projects/xiaoshu-mobile/src/app/features/english-reading/reading-pdf.component.ts

@@ -6,6 +6,7 @@ import { ArrowLeft, Download, LoaderCircle, LucideAngularModule } from 'lucide-a
 import { PlatformService } from '../../core/platform.service';
 import { PlatformService } from '../../core/platform.service';
 import { SessionService } from '../../core/session.service';
 import { SessionService } from '../../core/session.service';
 import { ReadingCloudService } from './reading-cloud.service';
 import { ReadingCloudService } from './reading-cloud.service';
+import { EnglishReadingService } from './english-reading.service';
 import { ReadingArticle, ReadingQuestion } from './reading.models';
 import { ReadingArticle, ReadingQuestion } from './reading.models';
 import { highlightWords } from './reading.utils';
 import { highlightWords } from './reading.utils';
 
 
@@ -30,6 +31,7 @@ export class ReadingPdfComponent implements OnInit {
 
 
   private readonly sid = inject(ActivatedRoute).snapshot.paramMap.get('sid') ?? '';
   private readonly sid = inject(ActivatedRoute).snapshot.paramMap.get('sid') ?? '';
   private readonly cloud = inject(ReadingCloudService);
   private readonly cloud = inject(ReadingCloudService);
+  private readonly reading = inject(EnglishReadingService);
   private readonly session = inject(SessionService);
   private readonly session = inject(SessionService);
   private readonly platform = inject(PlatformService);
   private readonly platform = inject(PlatformService);
 
 
@@ -43,9 +45,7 @@ export class ReadingPdfComponent implements OnInit {
     try {
     try {
       await this.cloud.ensureUser(this.session.student() ?? this.session.user());
       await this.cloud.ensureUser(this.session.student() ?? this.session.user());
       const article = await this.cloud.get<ReadingArticle>('SurveyItem', this.sid);
       const article = await this.cloud.get<ReadingArticle>('SurveyItem', this.sid);
-      const questions = await this.cloud.query<ReadingQuestion>('SurveyItem', {
-        parent: this.cloud.pointer('SurveyItem', this.sid), isDeleted: { '$ne': true },
-      }, { order: 'index' });
+      const questions = await this.reading.getQuestions(article);
       this.article.set(article);
       this.article.set(article);
       this.questions.set(questions);
       this.questions.set(questions);
     } catch (error) {
     } catch (error) {

+ 13 - 1
projects/xiaoshu-mobile/src/app/features/english-reading/reading.utils.spec.ts

@@ -1,4 +1,5 @@
-import { highlightWords, normalizeReadingParams, readingStage, readingWordLimit, splitWords } from './reading.utils';
+import { ReadingQuestion } from './reading.models';
+import { highlightWords, normalizeReadingParams, normalizeReadingQuestions, readingStage, readingWordLimit, splitWords } from './reading.utils';
 
 
 describe('reading utilities', () => {
 describe('reading utilities', () => {
   it('normalizes packed UniApp params and common word separators', () => {
   it('normalizes packed UniApp params and common word separators', () => {
@@ -23,4 +24,15 @@ describe('reading utilities', () => {
     expect(html).not.toContain('<script>');
     expect(html).not.toContain('<script>');
     expect(html.match(/<b>/g)?.length).toBe(2);
     expect(html.match(/<b>/g)?.length).toBe(2);
   });
   });
+
+  it('keeps one newest complete five-question set when legacy retries created duplicates', () => {
+    const question = (index: number, objectId: string, createdAt: string): ReadingQuestion => ({
+      objectId, createdAt, type: 'reading-item', index, title: objectId, options: [],
+      parent: { __type: 'Pointer', className: 'SurveyItem', objectId: 'article-1' },
+    });
+    const older = Array.from({ length: 5 }, (_, index) => question(index, `old-${index}`, '2026-09-04T08:00:00.000Z'));
+    const newer = Array.from({ length: 5 }, (_, index) => question(index, `new-${index}`, '2026-09-04T09:00:00.000Z'));
+    expect(normalizeReadingQuestions([...older, ...newer]).map((item) => item.objectId))
+      .toEqual(['new-0', 'new-1', 'new-2', 'new-3', 'new-4']);
+  });
 });
 });

+ 14 - 1
projects/xiaoshu-mobile/src/app/features/english-reading/reading.utils.ts

@@ -1,4 +1,4 @@
-import { ReadingRouteParams } from './reading.models';
+import { ReadingQuestion, ReadingRouteParams } from './reading.models';
 
 
 export const READING_LEVELS = [
 export const READING_LEVELS = [
   '一年级', '二年级', '三年级', '四年级', '五年级', '六年级',
   '一年级', '二年级', '三年级', '四年级', '五年级', '六年级',
@@ -63,6 +63,19 @@ export function shuffle<T>(values: T[]): T[] {
   return result;
   return result;
 }
 }
 
 
+export function normalizeReadingQuestions(values: ReadingQuestion[], limit = 5): ReadingQuestion[] {
+  const newest = [...values].sort((left, right) => {
+    const dateDifference = Date.parse(right.createdAt || '') - Date.parse(left.createdAt || '');
+    return (Number.isFinite(dateDifference) ? dateDifference : 0) || right.objectId.localeCompare(left.objectId);
+  });
+  const byIndex = new Map<number, ReadingQuestion>();
+  for (const question of newest) {
+    const index = Number(question.index);
+    if (Number.isInteger(index) && index >= 0 && !byIndex.has(index)) byIndex.set(index, question);
+  }
+  return [...byIndex.values()].sort((left, right) => Number(left.index) - Number(right.index)).slice(0, limit);
+}
+
 export function highlightWords(content: string, words: string[]): string {
 export function highlightWords(content: string, words: string[]): string {
   const safeContent = sanitizeMarkup(content.replaceAll('**', ''));
   const safeContent = sanitizeMarkup(content.replaceAll('**', ''));
   const exactKeywords = new Set(words.map((word) => word.toLowerCase()));
   const exactKeywords = new Set(words.map((word) => word.toLowerCase()));

+ 14 - 2
scripts/deploy-admin-functions.mjs

@@ -2723,8 +2723,20 @@ function readingValue(value,depth=0){if(depth>6)fail(400,'阅读数据嵌套过
 function readingClean(value,depth=0){if(value==null||depth>7)return value;if(Array.isArray(value))return value.map((item)=>readingClean(item,depth+1));if(value&&typeof value==='object'){if(depth>0&&value.objectId&&value.className){const className=String(value.className),rawId=String(value.objectId),prefix=className+'$',objectId=rawId.startsWith(prefix)?rawId.slice(prefix.length):rawId;return{__type:'Pointer',className,objectId};}const output={};for(const [key,item] of Object.entries(value)){if(key==='ACL'||SENSITIVE.test(key))continue;output[key]=readingClean(item,depth+1);}return output;}return value;}
 function readingClean(value,depth=0){if(value==null||depth>7)return value;if(Array.isArray(value))return value.map((item)=>readingClean(item,depth+1));if(value&&typeof value==='object'){if(depth>0&&value.objectId&&value.className){const className=String(value.className),rawId=String(value.objectId),prefix=className+'$',objectId=rawId.startsWith(prefix)?rawId.slice(prefix.length):rawId;return{__type:'Pointer',className,objectId};}const output={};for(const [key,item] of Object.entries(value)){if(key==='ACL'||SENSITIVE.test(key))continue;output[key]=readingClean(item,depth+1);}return output;}return value;}
 function readingRecord(row){return readingClean(row&&typeof row.toJSON==='function'?row.toJSON():row);}
 function readingRecord(row){return readingClean(row&&typeof row.toJSON==='function'?row.toJSON():row);}
 async function readingOwned(className,objectId,current){if(!/^[A-Za-z0-9_-]{10,40}$/.test(String(objectId||'')))fail(400,'阅读记录 ID 无效');const row=await new Parse.Query(className).get(String(objectId),{useMasterKey:true}),owner=row.get('user'),ownerId=String(owner&&(owner.id||owner.objectId)||owner||'').replace(/^_User\$/,'');if(className!=='PromptTemplate'&&ownerId!==String(current.id))fail(403,'无权访问该阅读记录');return row;}
 async function readingOwned(className,objectId,current){if(!/^[A-Za-z0-9_-]{10,40}$/.test(String(objectId||'')))fail(400,'阅读记录 ID 无效');const row=await new Parse.Query(className).get(String(objectId),{useMasterKey:true}),owner=row.get('user'),ownerId=String(owner&&(owner.id||owner.objectId)||owner||'').replace(/^_User\$/,'');if(className!=='PromptTemplate'&&ownerId!==String(current.id))fail(403,'无权访问该阅读记录');return row;}
-async function readingQuery(input,current){const className=readingClass(input.className),where=input.where&&typeof input.where==='object'?input.where:{},query=new Parse.Query(className),allowed=READING_FIELDS[className];for(const [key,raw] of Object.entries(where)){if(key==='user')continue;const root=key.split('.')[0];if(!allowed.has(root))fail(400,'阅读筛选字段不允许:'+key);if(raw&&typeof raw==='object'&&!Array.isArray(raw)&&!raw.__type){const operators=Object.keys(raw);if(operators.length!==1||!['$ne','$in'].includes(operators[0]))fail(400,'阅读筛选操作不允许');const value=readingValue(raw[operators[0]]);if(operators[0]==='$ne')query.notEqualTo(key,value);else query.containedIn(key,Array.isArray(value)?value:[value]);}else query.equalTo(key,readingValue(raw));}if(className!=='PromptTemplate'){const owned=await Psql.query('SELECT "objectId" FROM "'+className+'" WHERE "user" IN ($1,$2) LIMIT 5000',[current.id,'_User$'+current.id]),ids=owned.map((row)=>String(row.objectId));if(!ids.length)return[];query.containedIn('objectId',ids);}const order=String(input.order||'');if(order){const descending=order.startsWith('-'),field=order.replace(/^[-+]/,'');if(!['createdAt','updatedAt','index'].includes(field))fail(400,'阅读排序字段不允许');descending?query.descending(field):query.ascending(field);}query.limit(Math.min(100,Math.max(1,number(input.limit,20))));query.skip(Math.min(5000,Math.max(0,number(input.skip))));return(await query.find({useMasterKey:true})).map(readingRecord);}
-async function readingCreate(input,current){const className=readingClass(input.className,true),value=input.value&&typeof input.value==='object'?input.value:{},allowed=READING_FIELDS[className],row=new Parse.Object(className),deferred=value.createOptions,relations=[];for(const [key,item] of Object.entries(value)){if(key==='user'||key==='createOptions'||!allowed.has(key))continue;if(key==='parent'||key==='surveyItem'){const expected='SurveyItem',pointer=item&&typeof item==='object'?item:{};if(pointer.__type!=='Pointer'||pointer.className!==expected||!/^[A-Za-z0-9_-]{10,40}$/.test(String(pointer.objectId||'')))fail(400,'阅读关联记录无效');relations.push([key,pointer.objectId]);continue;}row.set(key,readingValue(item));}try{try{await row.save(null,{useMasterKey:true});}catch(error){if(!/valid Pointer/i.test(String(error&&error.message||error)))throw error;await new Promise((resolve)=>setTimeout(resolve,1200));await current.fetch({useMasterKey:true});await row.save(null,{useMasterKey:true});}if(deferred!==undefined&&allowed.has('createOptions')){row.set('createOptions',readingValue(deferred));await row.save(null,{useMasterKey:true});}await Psql.none('UPDATE "'+className+'" SET "user"=$1,"updatedAt"=CURRENT_TIMESTAMP WHERE "objectId"=$2',[className==='SurveyLog'?current.id:'_User$'+current.id,row.id]);for(const [field,pointer] of relations)await Psql.none('UPDATE "'+className+'" SET "'+field+'"=$1,"updatedAt"=CURRENT_TIMESTAMP WHERE "objectId"=$2',[pointer,row.id]);await row.fetch({useMasterKey:true});}catch(error){if(row.id)await row.destroy({useMasterKey:true}).catch(()=>undefined);fail(400,'阅读记录保存失败:'+String(error&&error.message||error));}return readingRecord(row);}
+async function readingQuery(input,current){
+  const className=readingClass(input.className),where=input.where&&typeof input.where==='object'?input.where:{},query=new Parse.Query(className),allowed=READING_FIELDS[className];let pointerIds=null;
+  for(const [key,raw] of Object.entries(where)){
+    if(key==='user')continue;const root=key.split('.')[0];if(!allowed.has(root))fail(400,'阅读筛选字段不允许:'+key);
+    if(['parent','surveyItem'].includes(key)&&raw&&raw.__type==='Pointer'){
+      if(raw.className!=='SurveyItem'||!/^[A-Za-z0-9_-]{10,40}$/.test(String(raw.objectId||'')))fail(400,'阅读关联记录无效');
+      const linked=await Psql.query('SELECT "objectId" FROM "'+className+'" WHERE "'+key+'" IN ($1,$2) LIMIT 5000',[String(raw.objectId),'SurveyItem$'+String(raw.objectId)]),ids=linked.map((row)=>String(row.objectId));pointerIds=pointerIds===null?ids:pointerIds.filter((id)=>ids.includes(id));continue;
+    }
+    if(raw&&typeof raw==='object'&&!Array.isArray(raw)&&!raw.__type){const operators=Object.keys(raw);if(operators.length!==1||!['$ne','$in'].includes(operators[0]))fail(400,'阅读筛选操作不允许');const value=readingValue(raw[operators[0]]);if(operators[0]==='$ne')query.notEqualTo(key,value);else query.containedIn(key,Array.isArray(value)?value:[value]);}else query.equalTo(key,readingValue(raw));
+  }
+  if(className!=='PromptTemplate'){const owned=await Psql.query('SELECT "objectId" FROM "'+className+'" WHERE "user" IN ($1,$2) LIMIT 5000',[current.id,'_User$'+current.id]),ownedIds=owned.map((row)=>String(row.objectId)),ids=pointerIds===null?ownedIds:ownedIds.filter((id)=>pointerIds.includes(id));if(!ids.length)return[];query.containedIn('objectId',ids);}else if(pointerIds!==null){if(!pointerIds.length)return[];query.containedIn('objectId',pointerIds);}
+  const order=String(input.order||'');if(order){const descending=order.startsWith('-'),field=order.replace(/^[-+]/,'');if(!['createdAt','updatedAt','index'].includes(field))fail(400,'阅读排序字段不允许');descending?query.descending(field):query.ascending(field);}query.limit(Math.min(100,Math.max(1,number(input.limit,20))));query.skip(Math.min(5000,Math.max(0,number(input.skip))));return(await query.find({useMasterKey:true})).map(readingRecord);
+}
+async function readingCreate(input,current){const className=readingClass(input.className,true),value=input.value&&typeof input.value==='object'?input.value:{},allowed=READING_FIELDS[className],row=new Parse.Object(className),deferred=value.createOptions,relations=[];for(const [key,item] of Object.entries(value)){if(key==='user'||key==='createOptions'||!allowed.has(key))continue;if(key==='parent'||key==='surveyItem'){const expected='SurveyItem',pointer=item&&typeof item==='object'?item:{};if(pointer.__type!=='Pointer'||pointer.className!==expected||!/^[A-Za-z0-9_-]{10,40}$/.test(String(pointer.objectId||'')))fail(400,'阅读关联记录无效');relations.push([key,expected+'$'+pointer.objectId]);continue;}row.set(key,readingValue(item));}try{try{await row.save(null,{useMasterKey:true});}catch(error){if(!/valid Pointer/i.test(String(error&&error.message||error)))throw error;await new Promise((resolve)=>setTimeout(resolve,1200));await current.fetch({useMasterKey:true});await row.save(null,{useMasterKey:true});}if(deferred!==undefined&&allowed.has('createOptions')){row.set('createOptions',readingValue(deferred));await row.save(null,{useMasterKey:true});}await Psql.none('UPDATE "'+className+'" SET "user"=$1,"updatedAt"=CURRENT_TIMESTAMP WHERE "objectId"=$2',['_User$'+current.id,row.id]);for(const [field,pointer] of relations)await Psql.none('UPDATE "'+className+'" SET "'+field+'"=$1,"updatedAt"=CURRENT_TIMESTAMP WHERE "objectId"=$2',[pointer,row.id]);await row.fetch({useMasterKey:true});}catch(error){if(row.id)await row.destroy({useMasterKey:true}).catch(()=>undefined);fail(400,'阅读记录保存失败:'+String(error&&error.message||error));}return readingRecord(row);}
 async function readingUpdate(input,current){const className=readingClass(input.className,true),row=await readingOwned(className,input.objectId,current),patch=input.patch&&typeof input.patch==='object'?input.patch:{},allowed=READING_FIELDS[className];for(const [key,item] of Object.entries(patch)){if(key==='user'||!allowed.has(key))continue;row.set(key,readingValue(item));}await row.save(null,{useMasterKey:true});return readingRecord(row);}
 async function readingUpdate(input,current){const className=readingClass(input.className,true),row=await readingOwned(className,input.objectId,current),patch=input.patch&&typeof input.patch==='object'?input.patch:{},allowed=READING_FIELDS[className];for(const [key,item] of Object.entries(patch)){if(key==='user'||!allowed.has(key))continue;row.set(key,readingValue(item));}await row.save(null,{useMasterKey:true});return readingRecord(row);}
 async function readingUpdateUser(input,current){const patch=input.patch&&typeof input.patch==='object'?input.patch:{};for(const key of ['nickname','level'])if(patch[key]!==undefined)current.set(key,String(patch[key]).slice(0,100));await current.save(null,{useMasterKey:true});return readingRecord(current);}
 async function readingUpdateUser(input,current){const patch=input.patch&&typeof input.patch==='object'?input.patch:{};for(const key of ['nickname','level'])if(patch[key]!==undefined)current.set(key,String(patch[key]).slice(0,100));await current.save(null,{useMasterKey:true});return readingRecord(current);}
 async function readingAi(input,current){
 async function readingAi(input,current){