Selaa lähdekoodia

fix(vocabulary): batch imports and resume saved row results

彭峰 3 päivää sitten
vanhempi
commit
89ef58edfb

+ 23 - 0
docs/qa/vocabulary-import-reliability-2026-10-30.md

@@ -0,0 +1,23 @@
+# Excel 词库导入可靠性修复(2026-10-30)
+
+## 原因与改动
+
+旧预检逐行查询词库,80 行线上实测 27.502 秒,超过管理端 20 秒请求限时。改为一次集合查询匹配 GeneralID / 单元与单词;保留旧表头、有效目录、重复行和跨帐套校验。保存与预检共用字段长度规则(单词 200、释义 4000、音标 500、例句 8000、音频地址 2000)。
+
+新客户端通过 `incremental:true` 分批提交,每次最多 5 行、约 5 秒后在行边界停止,逐行保存结果。事务级 advisory lock 防止同一批次重叠写入;连接终止后锁自动释放。失败重试只处理失败行,已成功 / 无变化行跳过。每行响应丢失后的恢复仍通过现有“单元+单词”查重避免重复创建。旧客户端仍收到完整终态,失败返回 409,不会把部分完成当全部成功。
+
+页面显示真实进度、失败行号、单词及具体原因;超时先回读状态,不能把失去响应判成整批失败。只将批次 ID 存入按当前管理员与帐套隔离的 sessionStorage,刷新或重进页面可恢复,完成后清除;不缓存原始 Excel 或凭证。导入中的弹窗、目标单元和文件选择不能切换。导入请求单独使用 60 秒上限,其他接口维持 20 秒。
+
+## 验证
+
+- 后端 11 项:旧表头 / 目录 / 重复规则、2000 行单次查询、所有长度边界、分批进度、仅失败行重试、并发互斥和旧客户端兼容。
+- 管理端:相关集合 45 项通过;新增按管理员恢复批次测试后,OperationsService 单独 22 项通过(相关测试合计 46 项)。覆盖已完成响应丢失回读、未完成进度保留、逐行错误、实际成功数量、阻止重复点击、刷新恢复入口,以及导入专用超时。
+- 管理端生产构建、11 个云函数定义语法校验通过;管理读取回归 5 项通过,包括词库读取不得重复加表锁。
+- 隔离临时管理员函数连接真实数据库,未替换正式网关:80 行预检 2.040 秒;2000 行 1.428 秒。这是本次样本测量,不是持续性能承诺。
+- 隔离创建 8 条专用测试词条,模拟 1 行字段超限:4 次提交分别约 6.5 / 6.3 / 8.1 / 6.1 秒,最终 7 成功、1 失败并返回“释义不能超过 4000 个字符”。修正后仅补该行,8 条完成;重放提交后仍只有 8 条。临时分类、词条及附表、批次均已清理,审计记录保留;回读词汇总数仍为 106,521,测试分类和批次数为 0。
+
+本机详细证据:忽略目录 `tmp/vocabulary-audit/`,其中 `fixed-performance.json`、`fixed-commit.json` 为真实数据库隔离验证记录。正式接口和管理端尚未发布本次修复。
+
+## 发布要求
+
+先对线上运营网关选择性更新词库校验 / 预检 / 分批提交 / 状态函数,保留线上其他独立修复,再发布管理端。无需新建数据表或迁移字段,进度复用 ContentImportBatch 的既有字段。不要整份覆盖线上网关;保留回退基线。新前端依赖新接口的增量协议,应按此顺序发布。

+ 11 - 0
projects/xiaoshu-admin/src/app/admin.models.ts

@@ -931,6 +931,17 @@ export interface VocabularyTree {
   refreshedAt: string;
 }
 
+export interface VocabularyImportResult {
+  batchId: string;
+  fileName?: string;
+  reason?: string;
+  previewSummary?: VocabularyImportPreview['summary'];
+  status: string;
+  busy?: boolean;
+  summary: { total: number; created: number; updated: number; skipped: number; failed: number; processed: number };
+  items: Array<{ row: number; word?: string; status?: string; message?: string }>;
+}
+
 export interface VocabularyImportPreview {
   batchId: string;
   status: string;

+ 5 - 0
projects/xiaoshu-admin/src/app/cloud-functions.service.spec.ts

@@ -69,4 +69,9 @@ describe('CloudFunctionsService', () => {
     const req=http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);tick(20001);expect(req.cancelled).toBeTrue();expect(String(failure)).toContain('结果待核对');http.expectNone(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);expect(TestBed.inject(AdminSessionService).logout).not.toHaveBeenCalled();
   }));
 
+
+  it('allows bounded vocabulary import calls to finish after the ordinary 20 second limit',fakeAsync(()=>{
+    let result:unknown;service.operations('ops/vocabulary/import-preview').then(value=>result=value);
+    const req=http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);tick(27500);expect(req.cancelled).toBeFalse();req.flush({success:true,data:{batchId:'batch'}});tick();expect(result).toEqual({batchId:'batch'});
+  }));
 });

+ 1 - 1
projects/xiaoshu-admin/src/app/cloud-functions.service.ts

@@ -72,7 +72,7 @@ export class CloudFunctionsService {
       const envelope = await firstValueFrom(this.http.post<CloudEnvelope<T>>(
         `${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`,
         { token, params: { operation, ...params } },
-      ).pipe(timeout({first:20000,with:()=>throwError(()=>new CloudFunctionError('请求暂未完成,结果待核对;请刷新后重试,已填写内容请保留。'))})));
+      ).pipe(timeout({first:/^ops\/vocabulary\/import-(preview|commit)$/.test(operation)?60000:20000,with:()=>throwError(()=>new CloudFunctionError('请求暂未完成,结果待核对;请刷新后重试,已填写内容请保留。'))})));
       if (!envelope.success) { await this.handleAuthenticationFailure(token, 0, envelope); throw new CloudFunctionError(envelope.message || envelope.error || '运营云函数执行失败'); }
       if (this.isMutation(operation)) this.announceDataChange();
       return envelope.data;

+ 34 - 0
projects/xiaoshu-admin/src/app/operations.service.spec.ts

@@ -183,4 +183,38 @@ describe('OperationsService', () => {
     expect(functions.operations.calls.argsFor(0)).toEqual(['ops/users/import-preview', { reason: '秋季统一开户', idempotencyKey: 'stable-member-import-key', payload: { fileName: 'members.xlsx', rows } }]);
     expect(functions.operations.calls.argsFor(1)).toEqual(['ops/users/import-commit', { targetId: 'member-batch-1', reason: '秋季统一开户', idempotencyKey: 'stable-member-import-key', payload: { batchId: 'member-batch-1', fileName: 'members.xlsx', rows } }]);
   });
+
+  it('continues chunked imports and publishes saved progress',async()=>{
+    const partial={batchId:'b',status:'partial',summary:{processed:5,total:8},items:[]} as any;
+    const complete={...partial,status:'completed',summary:{processed:8,total:8}};
+    functions.operations.and.returnValues(Promise.resolve(partial),Promise.resolve(complete));
+    const progress=jasmine.createSpy();
+    expect(await service.commitVocabularyImport('b','导入',progress,true)).toBe(complete);
+    expect(progress.calls.allArgs()).toEqual([[partial],[complete]]);
+    expect(functions.operations.calls.argsFor(0)[1]?.['retryFailed']).toBeTrue();
+    expect(functions.operations.calls.argsFor(1)[1]?.['retryFailed']).toBeFalse();
+  });
+  it('reads the saved result after a lost commit response without repeating the write',async()=>{
+    const complete={batchId:'b',status:'completed',summary:{processed:8,total:8},items:[]} as any;
+    functions.operations.and.callFake((operation:string)=>operation.endsWith('import-commit')?Promise.reject(new Error('network')):Promise.resolve(complete));
+    const progress=jasmine.createSpy();expect(await service.commitVocabularyImport('b','导入',progress)).toBe(complete);
+    expect(functions.operations.calls.count()).toBe(2);expect(functions.operations.calls.argsFor(1)[0]).toBe('ops/vocabulary/import-status');
+  });
+  it('keeps partial progress visible after a network interruption',async()=>{
+    const partial={batchId:'b',status:'partial',summary:{processed:5,total:8},items:[]} as any;
+    functions.operations.and.callFake((operation:string)=>operation.endsWith('import-commit')?Promise.reject(new Error('network')):Promise.resolve(partial));
+    const progress=jasmine.createSpy();await expectAsync(service.commitVocabularyImport('b','导入',progress)).toBeRejectedWithError('network');expect(progress).toHaveBeenCalledWith(partial);
+  });
+
+  it('remembers only the batch ID for the signed-in operator and restores it after interruption',async()=>{
+    const session=TestBed.inject(AdminSessionService);spyOn(session,'session').and.returnValue({objectId:'import-operator',username:'test',sessionToken:'secret'});
+    const key='xiaoshu:vocabulary-import::import-operator';
+    const partial={batchId:'resume-batch',status:'partial',summary:{processed:1,total:2},items:[]} as any;
+    try{
+      functions.operations.and.callFake((op:string)=>op.endsWith('import-commit')?Promise.reject(new Error('network')):Promise.resolve(partial));
+      await expectAsync(service.commitVocabularyImport('resume-batch','导入')).toBeRejected();expect(sessionStorage.getItem(key)).toBe('resume-batch');
+      expect(await service.resumeVocabularyImport()).toBe(partial);
+      functions.operations.and.resolveTo({...partial,status:'completed'});expect(await service.resumeVocabularyImport()).toBeNull();expect(sessionStorage.getItem(key)).toBeNull();
+    }finally{sessionStorage.removeItem(key);}
+  });
 });

+ 45 - 3
projects/xiaoshu-admin/src/app/operations.service.ts

@@ -1,5 +1,5 @@
 import { inject, Injectable } from '@angular/core';
-import { CoachAvailability, CoachDetailData, CoachPage, LearningReportDetail, LearningReportPage, LegacySyncRunResult, LegacySyncStatus, MediaUploadTicket, MemberImportPreview, MemberImportResult, MemberListSort, MemberPage, OperationsDashboardData, OperationsPage, OperationsUserDetail, PayRate, PayrollDetail, PayrollExportData, PayrollOverview, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod, SpecialTrainingPage, StoreDetailData, StorePage, TeachingStandardsData, VocabularyImportPreview, VocabularyTree, VocabularyWord } from './admin.models';
+import { CoachAvailability, CoachDetailData, CoachPage, LearningReportDetail, LearningReportPage, LegacySyncRunResult, LegacySyncStatus, MediaUploadTicket, MemberImportPreview, MemberImportResult, MemberListSort, MemberPage, OperationsDashboardData, OperationsPage, OperationsUserDetail, PayRate, PayrollDetail, PayrollExportData, PayrollOverview, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod, SpecialTrainingPage, StoreDetailData, StorePage, TeachingStandardsData, VocabularyImportPreview, VocabularyImportResult, VocabularyTree, VocabularyWord } from './admin.models';
 import { CloudFunctionsService } from './cloud-functions.service';
 import { AdminSessionService } from './admin-session.service';
 
@@ -192,8 +192,50 @@ export class OperationsService {
     return this.functions.operations<VocabularyImportPreview>('ops/vocabulary/import-preview', { reason, idempotencyKey: crypto.randomUUID(), payload: { fileName, rows, ...(targetNodeId ? { targetNodeId } : {}) } });
   }
 
-  commitVocabularyImport(batchId: string, reason: string): Promise<Record<string, unknown>> {
-    return this.functions.operations<Record<string, unknown>>('ops/vocabulary/import-commit', { targetId: batchId, reason, idempotencyKey: crypto.randomUUID() });
+  private vocabularyImportStorageKey(): string | null {
+    const session=this.sessions.session();return session?.objectId?`xiaoshu:vocabulary-import:${session.company?.objectId||''}:${session.objectId}`:null;
+  }
+  private rememberVocabularyImport(batchId: string | null): void {
+    const key=this.vocabularyImportStorageKey();if(!key)return;
+    try { if(batchId)sessionStorage.setItem(key,batchId);else sessionStorage.removeItem(key); } catch { /* storage may be restricted */ }
+  }
+  async resumeVocabularyImport(): Promise<VocabularyImportResult|null> {
+    const key=this.vocabularyImportStorageKey();let id:string|null=null;
+    try { if(key)id=sessionStorage.getItem(key); } catch { return null; }
+    if(!id)return null;
+    const result=await this.vocabularyImportStatus(id);
+    if(result.status==='completed'){this.rememberVocabularyImport(null);return null;}
+    return result;
+  }
+
+  vocabularyImportStatus(batchId: string): Promise<VocabularyImportResult> {
+    return this.functions.operations<VocabularyImportResult>('ops/vocabulary/import-status', { targetId: batchId });
+  }
+
+  async commitVocabularyImport(batchId: string, reason: string, onProgress?: (result: VocabularyImportResult) => void, retryFailed = false): Promise<VocabularyImportResult> {
+    this.rememberVocabularyImport(batchId);
+    let idle = 0, processed = -1;
+    for (let step = 0; step < 2500; step++) {
+      let result: VocabularyImportResult;
+      try {
+        result = await this.functions.operations<VocabularyImportResult>('ops/vocabulary/import-commit', { targetId: batchId, reason, incremental: true, retryFailed: step === 0 && retryFailed, idempotencyKey: crypto.randomUUID() });
+      } catch (error) {
+        // A lost response is not evidence that the write failed. Read the saved checkpoint.
+        const saved = await this.vocabularyImportStatus(batchId).catch(() => null);
+        if (saved) onProgress?.(saved);
+        if(saved?.status==='completed')this.rememberVocabularyImport(null);
+        if (saved?.status === 'completed' || saved?.status === 'failed') return saved;
+        throw error;
+      }
+      onProgress?.(result);
+      if(result.status==='completed')this.rememberVocabularyImport(null);
+      if (result.status === 'completed' || result.status === 'failed') return result;
+      idle = result.summary.processed === processed ? idle + 1 : 0;
+      processed = result.summary.processed;
+      if (idle >= 60) throw new Error('导入仍在处理中,已保存进度,请稍后点击继续导入');
+      if (result.busy) await new Promise(resolve => setTimeout(resolve, 1000));
+    }
+    throw new Error('本次导入已暂停,进度已保存,请点击继续导入');
   }
 
   async uploadCover(file: File, reason: string, width: number, height: number): Promise<string> {

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 1 - 1
projects/xiaoshu-admin/src/app/pages/operations-vocabulary.component.html


+ 22 - 1
projects/xiaoshu-admin/src/app/pages/operations-vocabulary.component.spec.ts

@@ -9,7 +9,8 @@ describe('OperationsVocabularyComponent', () => {
   let operations: jasmine.SpyObj<OperationsService>;
 
   beforeEach(async () => {
-    operations = jasmine.createSpyObj<OperationsService>('OperationsService', ['vocabularyTree', 'vocabulary', 'previewVocabularyImport']);
+    operations = jasmine.createSpyObj<OperationsService>('OperationsService', ['vocabularyTree', 'vocabulary', 'previewVocabularyImport','commitVocabularyImport','resumeVocabularyImport']);
+    operations.resumeVocabularyImport.and.resolveTo(null);
     operations.vocabularyTree.and.resolveTo({ items: [], total: 0, totalWords: 89620, refreshedAt: '2026-08-22T12:00:00.000Z' });
     operations.vocabulary.and.resolveTo({ page: 1, pageSize: 30, total: 89620, items: [], refreshedAt: '2026-08-22T12:00:00.000Z' });
     await TestBed.configureTestingModule({
@@ -78,4 +79,24 @@ describe('OperationsVocabularyComponent', () => {
     component.preview.set({ items } as any);
     expect(component.importPreviewItems()[0]['row']).toBe(112);
   });
+
+  it('displays per-row failure reasons and retries only the failed portion',async()=>{
+    const failed={batchId:'b',status:'failed',summary:{total:2,processed:2,created:1,updated:0,skipped:0,failed:1},items:[{row:3,word:'time',status:'failed',message:'单元已停用'}]};
+    component.preview.set({batchId:'b',summary:{error:0},items:[]} as any);component.importOpen.set(true);component.reason.set('导入');
+    operations.commitVocabularyImport.and.resolveTo(failed);
+    await component.commitImport();fixture.detectChanges();expect(component.importOpen()).toBeTrue();expect(fixture.nativeElement.textContent).toContain('第 3 行');expect(fixture.nativeElement.textContent).toContain('单元已停用');
+    await component.commitImport();expect(operations.commitVocabularyImport.calls.mostRecent().args[3]).toBeTrue();
+  });
+  it('keeps the modal and target stable while committing and reports actual result counts',async()=>{
+    component.preview.set({batchId:'b',summary:{error:0,create:99},items:[]} as any);component.importOpen.set(true);component.reason.set('导入');
+    let finish!:(value:any)=>void;operations.commitVocabularyImport.and.returnValue(new Promise(resolve=>finish=resolve));
+    const pending=component.commitImport();component.closeImport();component.changeImportTarget(42);await component.commitImport();
+    expect(component.importOpen()).toBeTrue();expect(component.importTargetNodeId()).toBe(0);expect(operations.commitVocabularyImport.calls.count()).toBe(1);
+    finish({batchId:'b',status:'completed',summary:{total:1,processed:1,created:1,updated:0,skipped:0,failed:0},items:[]});await pending;expect(component.message()).toContain('新增 1,');expect(component.importOpen()).toBeFalse();
+  });
+
+  it('restores the server checkpoint on returning to the vocabulary page',async()=>{
+    operations.resumeVocabularyImport.and.resolveTo({batchId:'resume',fileName:'words.xlsx',reason:'导入',status:'partial',summary:{total:8,processed:5,created:5,updated:0,skipped:0,failed:0},items:[]});
+    await component.restoreImport();fixture.detectChanges();expect(component.preview()?.batchId).toBe('resume');expect(component.importResult()?.summary.processed).toBe(5);expect(fixture.nativeElement.textContent).toContain('查看进度并继续导入');
+  });
 });

+ 29 - 6
projects/xiaoshu-admin/src/app/pages/operations-vocabulary.component.ts

@@ -7,7 +7,7 @@ import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal }
 import { FormsModule } from '@angular/forms';
 import { ChevronLeft, ChevronRight, Download, FileSpreadsheet, FolderPlus, Headphones, Languages, LucideAngularModule, Pencil, Plus, RefreshCw, RotateCcw, Search, Trash2, Upload, X } from 'lucide-angular';
 import { LEGACY_ASSET_BASE_URL } from '../admin.constants';
-import { OperationsPage, VocabularyImportPreview, VocabularyNode, VocabularyWord } from '../admin.models';
+import { OperationsPage, VocabularyImportPreview, VocabularyImportResult, VocabularyNode, VocabularyWord } from '../admin.models';
 import { OperationsService } from '../operations.service';
 
 type WordDraft = Pick<VocabularyWord, 'targetId'|'word'|'meaning'|'phonetic'|'example'|'audioUrl'|'nodeId'|'orderId'|'status'|'updatedAt'>;
@@ -30,6 +30,9 @@ export class OperationsVocabularyComponent implements OnInit {
   readonly importPreviewItems = computed(() => [...(this.preview()?.items || [])].sort((a,b) => Number(b['action']==='error')-Number(a['action']==='error')).slice(0,100));
   readonly reason = signal(''); readonly importFileName = signal(''); readonly importRows = signal<Record<string, unknown>[]>([]);
   readonly importTargetNodeId = signal(0);
+  readonly importResult = signal<VocabularyImportResult|null>(null);
+  readonly importFailures = computed(() => this.importResult()?.items.filter(row => row.status === 'failed') || []);
+  closeImport(): void { if (!this.saving()) this.importOpen.set(false); }
   readonly importTargets = computed(() => this.tree().filter(node => node.canContainWords).map(node => ({ nodeId: node.nodeId, label: `${vocabularyNodePath(this.tree(), node)}(ID ${node.nodeId})` })));
   readonly draft = signal<WordDraft>(this.emptyDraft()); readonly nodeDraft = signal({ targetId:'', name:'', parentId:0, orderId:0, canContainWords:true, updatedAt:'' });
   readonly selectedNode = computed(() => this.tree().find((node) => node.nodeId === this.selectedNodeId()) || null);
@@ -67,7 +70,14 @@ export class OperationsVocabularyComponent implements OnInit {
   private readonly operations = inject(OperationsService);
   private activeAudio: HTMLAudioElement | null = null;
 
-  ngOnInit(): void { void this.initialLoad(); }
+  ngOnInit(): void { void this.initialLoad().then(()=>this.restoreImport()); }
+  async restoreImport(): Promise<void> {
+    try {
+      const result=await this.operations.resumeVocabularyImport();if(!result||this.importOpen())return;
+      this.importResult.set(result);this.importFileName.set(result.fileName||'上次导入');this.reason.set(result.reason||'继续导入词库');
+      this.preview.set({batchId:result.batchId,status:result.status,summary:result.previewSummary||{total:result.summary.total,create:0,update:0,unchanged:0,duplicate:0,error:0,missingMedia:0},items:result.items,createdAt:''});
+    }catch{this.error.set('上次导入进度暂时无法读取,请稍后重新打开词库页面核对');}
+  }
   async initialLoad(): Promise<void> {
     this.loading.set(true); this.error.set('');
     this.selectedNodeId.set(0);
@@ -143,13 +153,26 @@ export class OperationsVocabularyComponent implements OnInit {
   async saveNode():Promise<void>{const draft=this.nodeDraft();if(!draft.name.trim()||!this.reason().trim()){this.error.set('分类名称和操作原因必填');return;}this.saving.set(true);try{await this.operations.mutate('ops/vocabulary/node-save',draft.targetId,this.reason(),draft,draft.updatedAt);this.nodeOpen.set(false);await this.initialLoad();this.message.set('词库分类已保存');}catch(error){this.error.set(this.errorText(error,'分类保存失败'));}finally{this.saving.set(false);}}
   async chooseWorkbook(event: Event): Promise<void> {
     const file=(event.target as HTMLInputElement).files?.[0]; if(!file)return;
+    if(this.saving())return;
     this.error.set('');
-    try { const rows=await readBoundedWorkbook(file,2000); this.importFileName.set(file.name);this.importRows.set(rows);this.preview.set(null);this.importTargetNodeId.set(0);this.importOpen.set(true);this.reason.set('导入词库'); }
+    try { const rows=await readBoundedWorkbook(file,2000); this.importResult.set(null);this.importFileName.set(file.name);this.importRows.set(rows);this.preview.set(null);this.importTargetNodeId.set(0);this.importOpen.set(true);this.reason.set('导入词库'); }
     catch(error){this.error.set(error instanceof Error && error.message==='Excel 中没有可导入数据'?'模板尚未填写词汇,请阅读“填写说明”,在“词库导入”工作表第 2 行起填写后重试':this.errorText(error,'Excel 读取失败'));} finally{(event.target as HTMLInputElement).value='';}
   }
-  changeImportTarget(nodeId: number): void { this.importTargetNodeId.set(nodeId); this.preview.set(null); this.error.set(''); }
-  async previewImport(): Promise<void> { if(!this.reason().trim()){this.error.set('请填写导入原因');return;}this.saving.set(true);this.error.set('');this.preview.set(null);try{this.preview.set(await this.operations.previewVocabularyImport(this.importFileName(),this.importRows(),this.reason(),this.importTargetNodeId()));}catch(error){this.error.set(this.errorText(error,'导入预检失败'));}finally{this.saving.set(false);} }
-  async commitImport(): Promise<void> {const preview=this.preview();if(!preview||preview.summary.error){this.error.set('请先解决全部阻断错误');return;}this.saving.set(true);try{await this.operations.commitVocabularyImport(preview.batchId,this.reason());this.importOpen.set(false);this.message.set(`导入完成:新增 ${preview.summary.create},更新 ${preview.summary.update}`);await this.initialLoad();}catch(error){this.error.set(this.errorText(error,'导入发布失败'));}finally{this.saving.set(false);}}
+  changeImportTarget(nodeId: number): void { if(this.saving()||this.importResult())return; this.importTargetNodeId.set(nodeId); this.preview.set(null); this.error.set(''); }
+  async previewImport(): Promise<void> { if(this.saving()||this.importResult())return; if(!this.reason().trim()){this.error.set('请填写导入原因');return;}this.saving.set(true);this.error.set('');this.preview.set(null);try{this.preview.set(await this.operations.previewVocabularyImport(this.importFileName(),this.importRows(),this.reason(),this.importTargetNodeId()));}catch(error){this.error.set(this.errorText(error,'导入预检失败'));}finally{this.saving.set(false);} }
+  async commitImport(): Promise<void> {
+    if(this.saving())return;
+    const preview=this.preview();if(!preview||preview.summary.error){this.error.set('请先解决全部阻断错误');return;}
+    this.saving.set(true);this.error.set('');
+    try {
+      const result=await this.operations.commitVocabularyImport(preview.batchId,this.reason(),progress=>this.importResult.set(progress),this.importResult()?.status==='failed');
+      this.importResult.set(result);
+      if(result.status==='completed'){
+        this.importOpen.set(false);this.message.set(`导入完成:新增 ${result.summary.created},更新 ${result.summary.updated},跳过 ${result.summary.skipped}`);await this.initialLoad();
+      }else this.error.set(`导入有 ${result.summary.failed} 行失败,原因见下方;已成功的行已保存,重试只处理失败行`);
+    }catch(error){this.error.set(this.errorText(error,'导入暂未完成,进度已保存,请点击继续导入'));}
+    finally{this.saving.set(false);}
+  }
   async downloadTemplate():Promise<void>{this.error.set('');try{const tree=await this.operations.vocabularyTree();const book=await vocabularyImportTemplate(tree.items,this.selectedNodeId());const {writeFile}=await import('xlsx');writeFile(book,'小树陪练词库导入模板.xlsx');this.message.set('模板已下载:请先阅读“填写说明”,再从“词库目录”复制单元ID填写词汇');}catch(error){this.error.set(this.errorText(error,'模板下载失败'));}}
   async exportCurrent():Promise<void>{try{const {utils,writeFile}=await import('xlsx');const rows=(this.page()?.items||[]).map((row)=>({'GeneralID':row.generalId,'单元ID':row.nodeId,'学段':row.stageName,'教材':row.bookName,'单元':row.unitName,'单词':row.word,'释义':row.meaning,'音标':row.phonetic,'例句':row.example,'音频URL':row.audioUrl,'状态':row.statusLabel}));const book=utils.book_new();utils.book_append_sheet(book,utils.json_to_sheet(rows),'词库');writeFile(book,`词库_${this.selectedNode()?.name||'筛选结果'}.xlsx`);}catch(error){this.error.set(this.errorText(error,'词库导出失败'));}}
   depthStyle(node:VocabularyNode):Record<string,string>{return{'--depth':String(Math.max(0,Number(node['displayDepth']||0)))};}

+ 2 - 2
projects/xiaoshu-admin/src/app/shared/vocabulary-import-template.ts

@@ -27,11 +27,11 @@ export async function vocabularyImportTemplate(nodes: VocabularyNode[], selected
     ['3. 全部导入同一单元', '上传后在页面选择“目标单元”。该选择会将整份文件导入此单元,覆盖表格中的目录和单元ID。'],
     ['4. 按名称匹配目录', '未填写单元ID且页面未指定目标时,学段、教材、单元三列必须与“词库目录”完全一致;重名时请使用单元ID。'],
     ['5. 新教材或新单元', '请先在词库管理中通过“新增分类”创建实际目录,再重新下载模板获取单元ID;导入不会自动创建目录。'],
-    ['6. 必填与可选', '单词必填;释义、音标、例句、音频可选。缺音频只会提醒,不阻止导入。'],
+    ['6. 必填与可选', '单词必填;释义、音标、例句、音频可选。缺音频只会提醒,不阻止导入。单词最多200字符,释义4000字符,音标500字符,例句8000字符,音频地址2000字符。'],
     ['7. GeneralID(可选)', '新增单词留空。更新已有单词时可填导出的GeneralID;不填则按“单元ID+单词”匹配更新。'],
     ['8. 重复与覆盖', '同一单元内相同单词不能在文件中重复。预检会指出重复行;更新已有单词时,留空的释义、音标、例句、音频会清空原值。'],
     ['9. 文件限制', '仅支持.xlsx,最大5 MB,每次最多2000行词汇。不要修改表头,不要合并单元格。'],
-    ['10. 提交步骤', '填写并保存 → Excel导入 → 确认目标单元 → 开始预检 → 检查新增、更新及错误 → 确认发布。'],
+    ['10. 提交步骤', '填写并保存 → Excel导入 → 确认目标单元 → 开始预检 → 检查新增、更新及错误 → 确认发布。导入时显示进度;中断后点击继续导入,失败行可查看原因并重试,已成功行不会重复提交。'],
     ['当前选中目录', selected ? vocabularyNodePath(nodes, selected) : '未选择。请在“词库目录”查找目标,或在上传页面选择。'],
     ['当前选中单元ID', selected?.nodeId ?? ''],
     ['填写示例(仅说明,不导入)', '以下示例的单元ID请替换为“词库目录”中的真实ID。'],

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
scripts/deploy-admin-functions.mjs


+ 42 - 3
scripts/tests/vocabulary-import.test.mjs

@@ -4,12 +4,12 @@ import { readFileSync } from 'node:fs';
 import vm from 'node:vm';
 
 const source = readFileSync(new URL('../deploy-admin-functions.mjs', import.meta.url), 'utf8');
-const functions = source.slice(source.indexOf('function importCell('), source.indexOf('async function commitVocabularyImport('));
+const functions = source.slice(source.indexOf('function vocabularyFieldErrors('), source.indexOf('async function commitVocabularyImport('));
 function importer(nodes = [{ nodeId: 42, nodeName: '第一单元', bookName: '真实教材', stageName: '小学' }], existing = null) {
   const queries = [];
   class Batch { constructor() { this.id = 'batch'; this.createdAt = new Date(); } set() {} async save() {} }
   const context = vm.createContext({
-    Parse: { Object: Batch }, Psql: { query: async () => nodes, oneOrNone: async (sql, args) => { queries.push({ sql, args }); return existing; } },
+    Parse: { Object: Batch }, Psql: { query: async (sql,args) => { if(!sql.startsWith('WITH requested'))return nodes; queries.push({sql,args});return existing?JSON.parse(args[1]).map(r=>({...existing,matchKey:r.key})):[]; } },
     text: value => String(value ?? '').trim(), number: value => Number(value) || 0,
     requireRole() {}, reasonOf: () => 'test', companyIdOf: () => 'tenant', companyPointer: () => ({}),
     fail: (_status, message) => { throw new Error(message); }, randomObjectId: () => 'random', audit: async () => {},
@@ -23,7 +23,7 @@ test('old template headers preserve GeneralID and exported audio URLs', async ()
   const { run, queries } = importer(undefined, { objectId: 'word', title: 'time', nodeId: 42, sy: '时间', yp: 'time.mp3' });
   const result = await run([{ ' GeneralID(可选) ': '123', 单元ID: '42', 单词: 'time', 释义: '时间', 音频URL: 'https://a018.2018.z01.com/UploadFiles/time.mp3' }]);
   assert.equal(result.summary.unchanged, 1);
-  assert.equal(queries[0].args[1], '123');
+  assert.equal(JSON.parse(queries[0].args[1])[0].generalId, '123');
   assert.match(queries[0].sql, /generalId/);
 });
 test('old sample paths fail with actionable guidance, explicit chosen target resolves them', async () => {
@@ -53,3 +53,42 @@ test('unknown GeneralID blocks import rather than creating an unexpected word',
   const result = await importer().run([{ 'GeneralID(可选)': 999, 单元ID: 42, 单词: 'time' }]);
   assert.equal(result.summary.error, 1); assert.match(result.items[0].errors[0], /GeneralID.*不存在/);
 });
+
+test('2000-row preview uses one lookup rather than a query for every word',async()=>{
+ const {run,queries}=importer();const result=await run(Array.from({length:2000},(_,i)=>({单元ID:42,单词:'word'+i})));
+ assert.equal(result.summary.create,2000);assert.equal(queries.length,1);
+});
+test('all save limits are checked before commit, at the exact boundary',async()=>{
+ const limits=[['单词',200],['释义',4000],['音标',500],['例句',8000],['音频URL',2000]];
+ for(const [field,max] of limits){const {run}=importer();assert.equal((await run([{单元ID:42,单词:'word',[field]:'x'.repeat(max)}])).summary.error,0);const bad=await run([{单元ID:42,单词:'word',[field]:'x'.repeat(max+1)}]);assert.equal(bad.summary.error,1);assert.match(bad.items[0].errors[0],new RegExp(String(max)));}
+});
+
+function committer(items,save){
+ const data={kind:'vocabulary',status:'preview',items};let locked=false;
+ const batch={id:'batch',createdAt:new Date(),updatedAt:new Date(),get:k=>data[k],set:(k,v)=>{data[k]=v},save:async()=>{},fetch:async()=>{}};
+ const context=vm.createContext({requireRole(){},reasonOf:()=> 'test',text:v=>String(v??'').trim(),number:v=>Number(v)||0,companyIdOf:()=> 'tenant',scopedObject:async()=>batch,dateIso:v=>v.toISOString(),audit:async()=>{},fail:(status,message)=>{throw Error(message)},saveVocabulary:save,Psql:{tx:async cb=>{let owned=false;try{return await cb({one:async()=>{if(locked)return{acquired:false};locked=true;owned=true;return{acquired:true}}})}finally{if(owned)locked=false}}}});
+ vm.runInContext(source.slice(source.indexOf('function vocabularyImportResult('),source.indexOf('async function saveVocabularyNode(')),context);
+ return {run:async (retryFailed,incremental=true)=>JSON.parse(JSON.stringify(await context.commitVocabularyImport({}, {targetId:'batch',retryFailed,incremental}))),status:async()=>JSON.parse(JSON.stringify(await context.vocabularyImportStatus({},{})))};
+}
+test('commit checkpoints bounded chunks, reports failed rows and retries only failures',async()=>{
+ const calls=[];let fail=true;
+ const items=Array.from({length:12},(_,i)=>({row:i+2,word:'word'+i,action:i===0?'unchanged':'create',errors:[]}));
+ const c=committer(items,async(_c,_i,row)=>{calls.push(row.row);if(row.row===5&&fail)throw Error('单元已停用');return{targetId:'saved'+row.row}});
+ let result=await c.run();assert.equal(result.status,'partial');assert.equal(result.summary.processed,5);
+ while(result.status==='partial')result=await c.run();
+ assert.equal(result.status,'failed');assert.equal(result.summary.created,10);assert.equal(result.summary.skipped,1);
+ assert.equal(result.items.find(x=>x.row===5).message,'单元已停用');assert.equal((await c.status()).items.find(x=>x.row===5).status,'failed');
+ fail=false;const before=calls.length;result=await c.run(true);assert.equal(result.status,'completed');assert.equal(calls.length,before+1);assert.equal(result.summary.created,11);
+ await c.run(true);assert.equal(calls.length,before+1);
+});
+test('concurrent calls cannot process the same batch twice',async()=>{
+ let release;let calls=0;const c=committer([{row:2,word:'time',action:'create',errors:[]}],async()=>{calls++;await new Promise(r=>release=r);return{targetId:'saved'}});
+ const first=c.run();while(!release)await new Promise(r=>setTimeout(r,0));
+ const second=await c.run();assert.equal(second.busy,true);assert.equal(calls,1);release();assert.equal((await first).status,'completed');
+});
+
+test('older clients receive a terminal result, never a partial success',async()=>{
+ const c=committer(Array.from({length:7},(_,i)=>({row:i+2,action:'create',errors:[]})),async()=>({targetId:'word'}));
+ const result=await c.run(false,false);assert.equal(result.status,'completed');assert.equal(result.summary.processed,7);
+ const failing=committer([{row:2,action:'create',errors:[]}],async()=>{throw Error('invalid')});await assert.rejects(failing.run(false,false),/逐行原因/);
+});

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä