Przeglądaj źródła

fix(reading): allow error dismissal and surface service authorization failures

彭峰 3 dni temu
rodzic
commit
218de18f62

+ 2 - 1
projects/xiaoshu-mobile/src/app/core/api.service.ts

@@ -53,7 +53,8 @@ export class ApiService {
         'X-Parse-Application-Id': API_CONFIG.parseAppId,
       },
     });
-    return this.resilient<T>(request.pipe(timeout({first:20_000,with:()=>throwError(()=>new ApiError(this.isReadAction(action)?'请求暂未完成,请刷新重试。':'请求暂未完成,结果待核对;请刷新核对后重试,已填写内容会保留。'))})), sessionToken.startsWith('r:') || requiresCloudSession(action, params), this.isReadAction(action), sessionToken).pipe(tap(() => {
+    const requestTimeout = action === 'reading/ai' ? 130_000 : 20_000;
+    return this.resilient<T>(request.pipe(timeout({first:requestTimeout,with:()=>throwError(()=>new ApiError(this.isReadAction(action)?'请求暂未完成,请刷新重试。':'请求暂未完成,结果待核对;请刷新核对后重试,已填写内容会保留。'))})), sessionToken.startsWith('r:') || requiresCloudSession(action, params), this.isReadAction(action), sessionToken).pipe(tap(() => {
       if (!this.isReadAction(action)) {
         this.invalidateOverviews();
         this.announceDataChange();

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

@@ -1,6 +1,6 @@
 import { provideHttpClient } from '@angular/common/http';
 import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
-import { TestBed } from '@angular/core/testing';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
 import { API_CONFIG } from '../../core/app.constants';
 import { SessionService } from '../../core/session.service';
 import { AiStreamMessage, FmodeAiService } from './fmode-ai.service';
@@ -44,4 +44,16 @@ describe('FmodeAiService', () => {
     request.flush({ retcode: 0, result: { content: '{"questions":[]}' } });
     expect(await result).toEqual({ questions: [] });
   });
+  it('allows AI generation to finish after the normal 20-second request limit without duplicate requests', fakeAsync(() => {
+    let content = '', failure: unknown;
+    service.stream('write an article').subscribe({next: value => content = value.content, error: error => failure = error});
+    const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    tick(25_000);
+    expect(request.cancelled).toBeFalse();
+    expect(failure).toBeUndefined();
+    http.expectNone(API_CONFIG.cloudFunctionUrl);
+    request.flush({retcode:0,result:{content:'A complete article'}});
+    expect(content).toBe('A complete article');
+  }));
+
 });

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/english-reading/reading-exercise.component.html

@@ -47,4 +47,4 @@
 
 @if (historyOpen()) { <div class="dialog-backdrop" (click)="closeHistory()"><section class="history-dialog" role="dialog" aria-modal="true" (click)="$event.stopPropagation()"><header><div><h2>阅读历史</h2><p>相同生词最多生成三篇文章</p></div><button type="button" (click)="closeHistory()" aria-label="关闭" title="关闭"><lucide-icon [img]="icons.X" [size]="20" /></button></header><div class="history-list">@if (historyLoading()) { <div class="dialog-state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="24" />正在加载</div> } @else if (!historyItems().length) { <div class="dialog-state"><lucide-icon [img]="icons.BookOpen" [size]="28" />暂无阅读历史</div> } @else { @for (item of historyItems(); track item.article.objectId) { <button type="button" class="history-item" (click)="selectHistory(item)"><div><strong>{{ item.article.title }}</strong><p>@if (studentName(item.article)) { {{ studentName(item.article) }} · }@if (item.article.createdAt) { {{ item.article.createdAt | date:'yyyy-MM-dd' }} · }{{ item.article.createOptions.params.level }} · {{ item.article.createOptions.params.type }}</p></div><span>{{ item.attemptCount ? item.bestScore + '分' : '未练习' }}</span><lucide-icon [img]="icons.ChevronRight" [size]="18" /></button> } @if (historyHasMore()) { <button type="button" class="load-more" [disabled]="historyLoadingMore()" (click)="loadMoreHistory()">{{ historyLoadingMore() ? '加载中...' : '加载更多' }}</button> } @else { <p class="no-more">已经显示全部历史记录</p> } }</div></section></div> }
 
-@if (dialog(); as popup) { <div class="result-backdrop"><section class="result-dialog" [class]="'result-dialog ' + popup.tone" role="dialog" aria-modal="true"><div class="result-icon">@if (popup.tone === 'success') { <lucide-icon [img]="icons.CheckCircle2" [size]="38" /> } @else { <lucide-icon [img]="icons.XCircle" [size]="38" /> }</div><h2>{{ popup.title }}</h2><p>{{ popup.message }}</p><button type="button" class="primary-button" (click)="handleDialog()">{{ popup.buttonText }}</button></section></div> }
+@if (dialog(); as popup) { <div class="result-backdrop" (click)="closeDialog()"><section class="result-dialog" [class]="'result-dialog ' + popup.tone" role="dialog" aria-modal="true" aria-labelledby="reading-dialog-title" (click)="$event.stopPropagation()"><button type="button" class="dialog-close" (click)="closeDialog()" aria-label="关闭弹窗" title="关闭"><lucide-icon [img]="icons.X" [size]="22" /></button><div class="result-icon">@if (popup.tone === 'success') { <lucide-icon [img]="icons.CheckCircle2" [size]="38" /> } @else { <lucide-icon [img]="icons.XCircle" [size]="38" /> }</div><h2 id="reading-dialog-title">{{ popup.title }}</h2><p>{{ popup.message }}</p><button type="button" class="primary-button" (click)="handleDialog()">{{ popup.buttonText }}</button>@if (popup.action === 'retry') { <button type="button" class="secondary-button dialog-return" (click)="returnToOrigin()">返回原页面</button> }</section></div> }

+ 4 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/reading-exercise.component.scss

@@ -64,3 +64,7 @@
 .result-backdrop { position: fixed; z-index: 40; display: grid; inset: 0; padding: 18px; background: rgb(14 25 22 / 60%); place-items: center; }.result-dialog { width: min(420px, 100%); padding: 25px 22px 22px; text-align: center; background: white; border-top: 4px solid var(--danger); border-radius: var(--radius-md); box-shadow: var(--shadow-lg); }.result-dialog.success { border-top-color: var(--brand-strong); }.result-dialog.warning { border-top-color: var(--accent); }.result-dialog h2 { margin: 10px 0 7px; font-size: 21px; letter-spacing: 0; }.result-dialog p { min-height: 42px; margin: 0 0 18px; color: var(--ink-soft); line-height: 1.5; white-space: pre-line; }.result-dialog button { width: 100%; }.result-icon { color: var(--danger); }.result-dialog.success .result-icon { color: var(--brand-strong); }.result-dialog.warning .result-icon { color: var(--accent); }
 .spin { animation: spin .8s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }@keyframes pulse { 50% { opacity: .45; } }
 @media (max-width: 620px) { .exercise-toolbar { padding-inline: 10px; }.toolbar-back span { display: none; }.toolbar-title small { max-width: 190px; overflow: hidden; text-overflow: ellipsis; }.article-section, .question-block { padding-inline: 16px; }.article-section h2 { font-size: 20px; }.article-content { font-size: 16px; }.options-grid { grid-template-columns: 1fr; }.submit-button { min-width: 0; }.answer-footer { padding-inline: 10px; }.footer-tool { min-width: 58px; }.footer-tool.retry-button { min-width: 76px; }.new-button { position: absolute; right: 10px; bottom: 74px; background: white; box-shadow: 0 5px 15px rgb(24 54 47 / 14%); } }
+
+.result-dialog { position: relative; }
+.result-dialog .dialog-close { position: absolute; top: 6px; right: 6px; display: grid; width: 44px; height: 44px; padding: 0; color: var(--ink-soft); background: transparent; border: 0; border-radius: var(--radius-md); place-items: center; }
+.result-dialog .dialog-return { margin-top: 10px; }

+ 31 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/reading-exercise.component.spec.ts

@@ -193,4 +193,35 @@ describe('ReadingExerciseComponent', () => {
     }));
     expect(reload).toHaveBeenCalled();
   });
+  it('lets the reader close a generation error and return without another generation request', async () => {
+    const params = component.params;
+    fixture.detectChanges();await fixture.whenStable();
+    component.params = params;reading.createArticle.calls.reset();
+    reading.createArticle.and.rejectWith(new Error('模拟生成失败'));
+    await component.load();
+    fixture.detectChanges();
+    expect(component.dialog()?.action).toBe('retry');
+    (fixture.nativeElement.querySelector('[aria-label="关闭弹窗"]') as HTMLButtonElement).click();
+    fixture.detectChanges();
+    expect(fixture.nativeElement.querySelector('.result-backdrop')).toBeNull();
+    expect(component.error()).toBe('模拟生成失败');
+    expect(reading.createArticle).toHaveBeenCalledTimes(1);
+    (fixture.nativeElement.querySelector('[aria-label="返回原页面"]') as HTMLButtonElement).click();
+    await fixture.whenStable();
+    expect(router.navigateByUrl).toHaveBeenCalledWith('/pages/his/his?date=2026-09-10', {replaceUrl:true});
+  });
+
+  it('offers an exit instead of a retry loop when the generation service needs administrator attention', async () => {
+    const params = component.params;
+    fixture.detectChanges();await fixture.whenStable();
+    component.params = params;reading.createArticle.calls.reset();
+    reading.createArticle.and.rejectWith(new Error('阅读生成服务授权异常,请联系管理员处理'));
+    await component.load();fixture.detectChanges();
+    expect(component.dialog()?.action).toBe('back');
+    expect(fixture.nativeElement.querySelector('.result-dialog')?.textContent).not.toContain('立即重试');
+    document.dispatchEvent(new KeyboardEvent('keydown', {key:'Escape'}));fixture.detectChanges();
+    expect(component.dialog()).toBeNull();
+    expect(reading.createArticle).toHaveBeenCalledTimes(1);
+  });
+
 });

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

@@ -228,6 +228,11 @@ export class ReadingExerciseComponent implements OnInit {
     await this.load();
   }
 
+  @HostListener('document:keydown.escape')
+  closeDialog(): void {
+    this.dialog.set(null);
+  }
+
   async handleDialog(): Promise<void> {
     const current = this.dialog();
     this.dialog.set(null);
@@ -255,7 +260,7 @@ export class ReadingExerciseComponent implements OnInit {
       await this.refreshHistoryCount();
       let article = await this.withTimeout(
         this.params.sid&&!this.params.renew?this.cloud.get<ReadingArticle>('SurveyItem',this.params.sid):this.reading.createArticle(this.params, (value) => this.updateArticleProgress(value)),
-        120000, '短文生成',
+        150000, '短文生成',
       );
       this.article.set(article);
       const attachArticleToRoute = !this.params.sid || this.params.renew;
@@ -274,7 +279,7 @@ export class ReadingExerciseComponent implements OnInit {
           const missing = this.params.wordList.filter((word) => !article.content.toLowerCase().includes(word.toLowerCase()));
           article = await this.withTimeout(
             this.reading.supplyMissingWords(article, missing, (value) => this.updateArticleProgress(value)),
-            120000, '词汇补全',
+            150000, '词汇补全',
           );
           this.article.set(article);
         }
@@ -296,7 +301,7 @@ export class ReadingExerciseComponent implements OnInit {
         await this.returnToOrigin();
       } else {
         this.error.set(message);
-        const canRetry = this.retryCount < this.maxRetry;
+        const canRetry = this.retryCount < this.maxRetry && !message.includes('联系管理员');
         this.dialog.set({
           title: '遇到问题',
           message: `${message}${canRetry ? `(剩余重试次数:${this.maxRetry - this.retryCount})` : ''}`,
@@ -351,6 +356,7 @@ export class ReadingExerciseComponent implements OnInit {
   }
 
   async returnToOrigin(): Promise<void> {
+    this.closeDialog();
     await this.router.navigateByUrl(normalizeReadingReturnUrl(this.params.returnUrl), { replaceUrl: true });
   }
 

Plik diff jest za duży
+ 2 - 1
scripts/deploy-admin-functions.mjs


+ 16 - 0
scripts/tests/reading-ai-errors.test.mjs

@@ -0,0 +1,16 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import vm from 'node:vm';
+const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
+const start=source.indexOf('async function readingAi(input,current){'),end=source.indexOf('\nasync function uploadAppMedia',start);
+function setup(status,token='fixture-token'){
+ let calls=0;
+ const context=vm.createContext({AbortSignal,Parse:{Config:{get:async()=>({get:()=>token})}},fetch:async()=>{calls++;return{status,ok:status===200,text:async()=>JSON.stringify(status===200?{choices:[{message:{content:'A complete article'}}]}:{error:{message:'private-provider-details'}})};},fail:(status,message)=>{throw Object.assign(new Error(message),{status});}});
+ vm.runInContext(source.slice(start,end),context);return{run:()=>context.readingAi({prompt:'Read a short story'},{id:'teacher'}),calls:()=>calls};
+}
+for(const status of [401,403])test('生成授权失败 '+status+' 返回可处理的配置错误,不泄露上游内容',async()=>{
+ const {run,calls}=setup(status);await assert.rejects(run(),error=>error.status===424&&error.message==='阅读生成服务授权异常,请联系管理员处理');assert.equal(calls(),1);
+});
+test('阅读服务缺少配置时不发送生成请求',async()=>{const {run,calls}=setup(200,'');await assert.rejects(run(),error=>error.status===424&&error.message.includes('尚未配置'));assert.equal(calls(),0);});
+test('正常阅读生成仍返回原有内容',async()=>{const {run,calls}=setup(200);assert.equal((await run()).content,'A complete article');assert.equal(calls(),1);});

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików