Просмотр исходного кода

feat: cache missing vocabulary audio with iFlytek TTS

彭峰 3 дней назад
Родитель
Сommit
ca6a259e4f

+ 8 - 0
deployment/word-audio/Dockerfile

@@ -0,0 +1,8 @@
+FROM node:22-alpine
+WORKDIR /app
+COPY --chown=node:node scripts/word-audio-worker.mjs ./scripts/word-audio-worker.mjs
+COPY --chown=node:node scripts/cloud/word-audio.js ./scripts/cloud/word-audio.js
+USER node
+ENV HOST=0.0.0.0 PORT=4381 XIAOSHU_TTS_CONFIG_FILE=/run/secrets/word-audio.json
+EXPOSE 4381
+CMD ["node", "scripts/word-audio-worker.mjs"]

+ 45 - 0
docs/word-audio-tts.md

@@ -0,0 +1,45 @@
+# 单词缺失读音(讯飞在线 TTS)
+
+## 行为
+
+`LearningPageComponent.play` 优先播放原有音频。空缺音频或实际播放失败才调用认证接口 `app_word_audio_ensure`,仅传 `wordId` 和可选 `playbackFailed`。接口返回 `{state:'ready',url,source}` 或 `{state:'pending',retryAfterMs}`,客户端合并同词请求并有界等待。原有只读课程预览的点读限制保持不变,历史词表的缺音频按钮可以点击生成。切换词条、阶段或离开页面后不播放旧结果。自动播放被浏览器阻止只提示再点喇叭,不生成录音。
+
+服务端读取本帐套已发布的 Model 52 词条(既有词库访问模型),仅允许登录的学生、老师和管理员触发。不开启任意文本合成。原有音频未报错时不读取 TTS 配置、不调用供应商;报错时仅探测受信任媒体域名,禁跟随重定向,仅将 404/410 认定为失效,403/500、浏览器限制、网络超时均不重做。第三方域名失效需要管理员手动清空原地址后生成,防止 SSRF。
+
+缓存键由帐套、NFC/空白规范化后的文本、音标、固定语音配置计算 SHA256;保留大小写与音标区别。该键是数据库主键。不同课本中相同词与音标共用,不合并不同读音。讯飞仅接收词条文本;同形异音词仍需试听验收,有误可用现有后台音频上传覆盖。
+
+短事务认领任务、90秒租约、令牌条件写入、每租户并发限制,供应商请求在事务外执行。先保存合成字节,再上传音频、回填 `VocabularyWord.yp`。中间字节保留在仅服务端可读的缓存内,用于上传失败或已生成文件丢失后的恢复。回填比较旧音频、原文、音标,避免覆盖人工修改。失败冷却一分钟;成功持久化后反复复用,不重复调用讯飞。调用成功但字节尚未持久化就崩溃仍可能产生一次额外调用,不能声称供应商计费严格 exactly-once。
+
+## 配置与架构
+
+本机私密配置:`~/.config/xiaoshu/xfyun-word-audio.json`,权限 600,绝不提交。
+
+必要字段:`XFYUN_APP_ID`、`XFYUN_API_KEY`、`XFYUN_API_SECRET`、`XFYUN_TTS_VCN`。当前验收声音为 `x4_EnUs_Laura_education`(Laura 教育、美式英语)。固定语速50、MP3/16k、UTF8、单词模式 `reg=0`,不加背景音。
+
+控制字段:`XIAOSHU_TTS_ENABLED` 默认 false,`XIAOSHU_TTS_DAILY_LIMIT` 默认500,`XIAOSHU_TTS_USER_MINUTE_LIMIT` 默认30,`XIAOSHU_TTS_CONCURRENCY` 默认1。按北京时间每天统计实际合成尝试;失败尝试也计入限额以避免费用失控。上传重试不计入合成额度。`WordAudioAttempt` 保存调用、失败、恢复事件。
+
+2026-10-29 实际服务器探测:云函数提供 fetch 和 WebCrypto,但不提供 WebSocket/require;没有配置 XIAOSHU_S3_*;词库表没有 isDeleted 字段(按 status 过滤)。因此实际部署需要 Node 22+ 合成适配器 `scripts/word-audio-worker.mjs`。云函数持有 `XIAOSHU_TTS_WORKER_URL`(HTTPS `/synthesize`)和随机32字符以上 `XIAOSHU_TTS_WORKER_TOKEN`,通过认证 HTTP 调用适配器,适配器负责讯飞 WebSocket。独立部署适配器时讯飞密钥只需留在适配器;云函数不需要它们。
+
+适配器默认仅监听 `127.0.0.1:4381`,公网部署在 HTTPS 反向代理之后,不开放 CORS。支持 `/healthz`。POST 仅接受共享凭证、固定发音人/语速、最长200字符,限制并发和每分钟请求量,供应商错误只返回固定提示。不要记录 Authorization 或签名 WebSocket URL。
+
+存储先使用现有 XIAOSHU_S3_* 配置(固定对象键),未配置则复用已验证支持 MP3 的现有媒体上传服务。后者可能在上传响应丢失后产生重复文件,但仍会复用数据库中保存的音频字节,不重新调用讯飞。原有通用上传的文件白名单不放宽。
+
+## 部署顺序与回退
+
+1. 将私密配置安装到适配器主机(权限600、运行用户可读),生成并共享服务端随机 token。运行 `node scripts/word-audio-worker.mjs` 或从仓库根目录 `docker build -f deployment/word-audio/Dockerfile -t xiaoshu-word-audio .`;Docker 配置只读挂载到 `/run/secrets/word-audio.json`,文件属主须为容器 node 用户 UID1000。端口仅绑定宿主机回环地址,通过现有 HTTPS 代理转发 `/synthesize`。镜像中不包含配置文件。
+2. 设置云函数的 worker URL/token、vcn 和限额,保持生成关闭。`node scripts/configure-word-audio.mjs` 默认只检查;`--apply` 使用操作者 XIAOSHU_MASTER_KEY 写入 Parse masterKeyOnly 私密配置;`--gateway-only` 仅传适配器参数与非讯飞密钥配置。`--enable` 必须与 `--apply` 一起才启用,禁用用不带 `--enable` 的 `--apply`。
+3. `node scripts/migrate-word-audio.mjs` 默认不写库;`--apply` 创建缓存、用量表与认领函数,Parse CLP 禁止客户端直接读写。迁移临时函数仅允许当前管理员、10分钟有效,finally 清理。应用变更前先完成迁移。
+4. 对线上网关做选择性合并并发布移动前端,保留其他已发布修复。禁止用本地整个旧网关盲目覆盖线上独立改动。先验证适配器与媒体存储,再小范围真实词条验证后开启开关。
+5. 回退只关闭生成开关,保留缓存表、MP3 和回填地址,已有音频继续播放。回退网关/前端不删除缓存或媒体文件。
+
+本任务未自动部署适配器、迁移线上数据库或开启线上生成。授权上线时依照此顺序执行。
+
+## 验证命令
+
+- `node --test scripts/tests/word-audio.test.mjs scripts/tests/word-audio-worker.test.mjs`
+- `npm run test:mobile:ci`
+- `npm run build:mobile && npm run cloud:validate`
+- `node scripts/check-word-audio-browser.mjs`(375px,隔离接口与音频样本)
+- `node scripts/check-word-audio-live.mjs` 默认不调用供应商;`--generate` 使用一次 apple 合成,`--extended` 再生成 comfortable/read/NASA/Good morning,已有样音复用。输出只含声音名称、文件路径、字节数和哈希。
+
+真实样音与文件存储回读证据位于忽略目录 `tmp/word-audio-qa/`。MP3 结构验证不等同真人对所有词汇发音准确性的验收,也不等同手机微信实机验收。

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

@@ -56,9 +56,9 @@ export class ApiService {
         'X-Parse-Application-Id': API_CONFIG.parseAppId,
       },
     });
-    const requestTimeout = action === 'reading/ai' ? 130_000 : 20_000;
+    const requestTimeout = action === 'reading/ai' ? 130_000 : action === 'app_word_audio_ensure' ? 60_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)) {
+      if (!this.isReadAction(action) && action !== 'app_word_audio_ensure') {
         this.invalidateOverviews();
         this.announceDataChange();
       }

+ 2 - 2
projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts

@@ -623,8 +623,8 @@ describe('home learning modules', () => {
     const learning = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
     learning.words.update((words) => words.map((word) => ({ ...word, audio: '' })));
     harness.fixture.detectChanges();
-    expect(button.disabled).toBeTrue();
-    expect(harness.routeNativeElement?.textContent).toContain('暂无音频');
+    expect(button.disabled).toBeFalse();
+    expect(harness.routeNativeElement?.textContent).toContain('点击生成读音');
     button.click();
     expect(play).toHaveBeenCalledTimes(2);
 

+ 14 - 3
projects/xiaoshu-mobile/src/app/core/platform.service.ts

@@ -36,10 +36,21 @@ export class PlatformService {
     await this.copy(data.url || data.text || data.title || '');
   }
 
-  play(url: string): void {
+  async play(url: string, silentFailure = false): Promise<'played' | 'blocked' | 'failed' | 'cancelled'> {
     this.audio?.pause();
-    this.audio = new Audio(url);
-    void this.audio.play().catch(() => this.toast('音频播放失败', 'error'));
+    const audio = this.audio = new Audio(url);
+    try {
+      await audio.play();
+      return this.audio === audio ? 'played' : 'cancelled';
+    } catch (error) {
+      if (this.audio !== audio || (error as DOMException)?.name === 'AbortError') return 'cancelled';
+      if ((error as DOMException)?.name === 'NotAllowedError') {
+        this.toast('请再点一次喇叭播放读音', 'info');
+        return 'blocked';
+      }
+      if (!silentFailure) this.toast('音频播放失败', 'error');
+      return 'failed';
+    }
   }
 
   playEffect(kind: 'success' | 'error'): void {

+ 90 - 0
projects/xiaoshu-mobile/src/app/core/word-audio.service.spec.ts

@@ -0,0 +1,90 @@
+import { TestBed, fakeAsync, flushMicrotasks, tick } from '@angular/core/testing';
+import { signal } from '@angular/core';
+import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
+import { of, Subject, throwError } from 'rxjs';
+import { WordAudioService } from './word-audio.service';
+import { ApiService } from './api.service';
+import { PlatformService } from './platform.service';
+import { SessionService } from './session.service';
+import { TeacherTodayReviewService } from './teacher-today-review.service';
+import { LearningPageComponent } from '../features/learning/learning-page.component';
+
+describe('单词读音生成请求', () => {
+  let api: { post: jasmine.Spy }, service: WordAudioService;
+  beforeEach(() => {
+    api = { post: jasmine.createSpy() };
+    TestBed.configureTestingModule({ providers: [{ provide: ApiService, useValue: api }] });
+    service = TestBed.inject(WordAudioService);
+  });
+  it('同一个词并发点读合并请求,完成后再次读取服务端而不保留失效的本地结果', async () => {
+    const pending = new Subject<any>(); api.post.and.returnValue(pending);
+    const first = service.ensure('101'), second = service.ensure('101');
+    expect(first).toBe(second); expect(api.post).toHaveBeenCalledTimes(1);
+    pending.next({ result: { state: 'ready', url: 'https://cdn.test/apple.mp3' } });
+    expect(await first).toContain('apple.mp3');
+    api.post.and.returnValue(of({ result: { state: 'ready', url: 'https://cdn.test/new.mp3' } }));
+    expect(await service.ensure('101')).toContain('new.mp3');
+  });
+  it('其他请求正在合成时等待结果,不发起客户端任意文本合成', fakeAsync(() => {
+    api.post.and.returnValues(of({ result: { state: 'pending', retryAfterMs: 1500 } }), of({ result: { state: 'ready', url: '/done.mp3' } }));
+    let url = ''; void service.ensure('101', true).then(value => url = value);
+    flushMicrotasks(); tick(1500); flushMicrotasks();
+    expect(url).toBe('/done.mp3');
+    expect(api.post).toHaveBeenCalledWith('app_word_audio_ensure', { wordId: '101', playbackFailed: true });
+  }));
+  it('失败后允许重新点击,且不无限自动重试', async () => {
+    api.post.and.returnValue(throwError(() => new Error('quota')));
+    await expectAsync(service.ensure('101')).toBeRejectedWithError('quota');
+    api.post.and.returnValue(of({ result: { state: 'ready', url: '/retry.mp3' } }));
+    expect(await service.ensure('101')).toBe('/retry.mp3');
+  });
+});
+
+describe('学习页面自动读音', () => {
+  let component: LearningPageComponent, platform: any, audio: any;
+  const word = (id = '101', url = '') => ({ id, wordId: id, word: 'apple', audio: url, phonetic: '', meaning: '', nodeId: '' });
+  beforeEach(() => {
+    platform = { play: jasmine.createSpy().and.resolveTo('played'), stopAudio: jasmine.createSpy(), toast: jasmine.createSpy() };
+    audio = { ensure: jasmine.createSpy().and.resolveTo('https://cdn.test/apple.mp3') };
+    TestBed.configureTestingModule({ providers: [
+      { provide: ActivatedRoute, useValue: { snapshot: { data: { meta: { path: 'pages/his/his_words' } }, queryParamMap: convertToParamMap({}) } } },
+      { provide: Router, useValue: {} }, { provide: ApiService, useValue: { assetUrl: (value: string) => value } },
+      { provide: SessionService, useValue: { groupId: signal(1), user: signal({userId: 1}) } },
+      { provide: TeacherTodayReviewService, useValue: {} }, { provide: PlatformService, useValue: platform },
+      { provide: WordAudioService, useValue: audio },
+    ] });
+    component = TestBed.runInInjectionContext(() => new LearningPageComponent());
+    component.words.set([word()]);
+  });
+  it('已有正常音频直接播放,空白外层字段仍使用内层已有音频', async () => {
+    const mapped = (component as any).toWord({ GeneralID: 101, yp: ' ', detail: { yp: 'apple.mp3' } });
+    expect(mapped.audio).toBe('apple.mp3');
+    await component.play(word('101', 'apple.mp3'));
+    expect(audio.ensure).not.toHaveBeenCalled(); expect(platform.play).toHaveBeenCalledTimes(1);
+  });
+  it('首次生成有加载状态,成功后同页第二次点读直接复用', async () => {
+    const first = component.play(); expect(component.audioPreparing()).toBeTrue(); await first;
+    expect(component.audioPreparing()).toBeFalse(); expect(component.current()?.audio).toContain('apple.mp3');
+    await component.play(); expect(audio.ensure).toHaveBeenCalledTimes(1); expect(platform.play).toHaveBeenCalledTimes(2);
+  });
+  it('切换到其他词或离开页面后,不播放旧词', async () => {
+    let resolve!: (url: string) => void; audio.ensure.and.returnValue(new Promise<string>(done => resolve = done));
+    const first = component.play(); component.words.set([word('102')]); resolve('/old.mp3'); await first;
+    expect(platform.play).not.toHaveBeenCalled();
+    audio.ensure.and.returnValue(new Promise<string>(done => resolve = done));
+    const second = component.play(); component.ngOnDestroy(); resolve('/old.mp3'); await second;
+    expect(platform.play).not.toHaveBeenCalled();
+  });
+  it('浏览器自动播放限制不触发TTS;真实播放失败才由后端核实', async () => {
+    platform.play.and.resolveTo('blocked'); await component.play(word('101', 'old.mp3')); expect(audio.ensure).not.toHaveBeenCalled();
+    platform.play.and.returnValues(Promise.resolve('failed'), Promise.resolve('played'));
+    await component.play(word('101', 'old.mp3'));
+    expect(audio.ensure).toHaveBeenCalledWith('101', true);
+  });
+  it('生成失败保留词条并提示重试,重新点击可以成功', async () => {
+    audio.ensure.and.rejectWith(new Error('额度不足')); await component.play();
+    expect(component.current()?.audio).toBe(''); expect(component.audioPreparing()).toBeFalse();
+    expect(platform.toast).toHaveBeenCalledWith('额度不足', 'error');
+    audio.ensure.and.resolveTo('/retry.mp3'); await component.play(); expect(platform.play).toHaveBeenCalledWith('/retry.mp3');
+  });
+});

+ 31 - 0
projects/xiaoshu-mobile/src/app/core/word-audio.service.ts

@@ -0,0 +1,31 @@
+import { inject, Injectable } from '@angular/core';
+import { firstValueFrom } from 'rxjs';
+import { ApiService } from './api.service';
+
+interface WordAudioResult { state: 'ready' | 'pending'; url?: string; retryAfterMs?: number; }
+
+@Injectable({ providedIn: 'root' })
+export class WordAudioService {
+  private readonly api = inject(ApiService);
+  private readonly pending = new Map<string, Promise<string>>();
+
+  ensure(wordId: string, playbackFailed = false): Promise<string> {
+    const key = wordId + ':' + playbackFailed;
+    const existing = this.pending.get(key);
+    if (existing) return existing;
+    const request = this.resolve(wordId, playbackFailed).finally(() => this.pending.delete(key));
+    this.pending.set(key, request);
+    return request;
+  }
+
+  private async resolve(wordId: string, playbackFailed: boolean): Promise<string> {
+    const deadline = Date.now() + 65_000;
+    do {
+      const response = await firstValueFrom(this.api.post<WordAudioResult>('app_word_audio_ensure', { wordId, playbackFailed }));
+      if (response.result?.state === 'ready' && response.result.url) return response.result.url;
+      if (response.result?.state !== 'pending') throw new Error('读音暂时不可用,请重试');
+      await new Promise(resolve => setTimeout(resolve, Math.max(1000, Math.min(3000, response.result.retryAfterMs || 1500))));
+    } while (Date.now() < deadline);
+    throw new Error('读音仍在准备中,请稍后再点一次');
+  }
+}

+ 3 - 2
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html

@@ -1,4 +1,5 @@
 <section class="learning-page">
+  @if (audioPreparing()) { <p class="readonly-word-note" role="status" aria-live="polite">正在准备读音,首次生成后会自动保存…</p> }
   @if (loading()) {
     <div class="empty-state"><p>正在加载学习内容...</p></div>
   } @else if (error()) {
@@ -72,11 +73,11 @@
           <article>
             <div>
               @if (isHistoryWordView) {
-                <button type="button" class="history-word-audio" [disabled]="!word.audio" [attr.aria-label]="word.audio ? '播放单词读音:' + word.word : word.word + '暂无音频'" (click)="play(word)">
+                <button type="button" class="history-word-audio" [attr.aria-label]="'播放单词读音:' + word.word" (click)="play(word)">
                   <span><strong>{{ word.word }}</strong>@if (word.phonetic) { <small>{{ word.phonetic }}</small> }</span>
                   <lucide-icon [img]="icons.Volume2" [size]="20" aria-hidden="true" />
                 </button>
-                @if (!word.audio) { <small>暂无音频</small> }
+                @if (!word.audio) { <small>点击生成读音</small> }
               } @else {
                 <strong>{{ word.word }}</strong>@if (word.phonetic) { <small>{{ word.phonetic }}</small> }
               }

+ 30 - 7
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -1,3 +1,4 @@
+import { WordAudioService } from '../../core/word-audio.service';
 import { lessonDateLabel } from '../appointment/lesson-display';
 import { TeacherTodayReviewService } from '../../core/teacher-today-review.service';
 import { lessonStudySources } from '../appointment/lesson-study-sources';
@@ -317,6 +318,9 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   private readonly session = inject(SessionService);
   private readonly todayReviews = inject(TeacherTodayReviewService);
   private readonly platform = inject(PlatformService);
+  private readonly wordAudio = inject(WordAudioService);
+  readonly audioPreparing = signal(false);
+  private audioRequest = 0;
   private readonly wordPreviewPageSize = 30;
   private examTimer: ReturnType<typeof setInterval> | null = null;
   private antiStartedAt = Date.now();
@@ -347,6 +351,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   ngOnDestroy(): void {
+    this.audioRequest++;
     this.calendarRequestId++;this.calendarRequest?.unsubscribe();this.selectionRequest?.unsubscribe();
     this.choiceOptionsVersion++;
     this.choiceOptionsRequest?.unsubscribe();
@@ -775,13 +780,31 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     });
   }
 
-  play(word = this.current()): void {
-    if (this.isReadOnlySelectionPage || (this.isReadOnlyWordView && !this.isHistoryWordView)) return;
-    if (!word?.audio) {
-      this.platform.toast('该单词暂无音频', 'error');
-      return;
+  async play(word = this.current()): Promise<void> {
+    if (this.isReadOnlySelectionPage || (this.isReadOnlyWordView && !this.isHistoryWordView) || !word) return;
+    const request = ++this.audioRequest, focusedWord = this.current(), step = this.studyStep();
+    const active = () => request === this.audioRequest && this.current() === focusedWord && this.studyStep() === step;
+    this.audioPreparing.set(false);
+    const original = word.audio?.trim();
+    if (original) {
+      const status = await this.platform.play(this.wordAudioUrl(original), true);
+      if (status !== 'failed' || !active()) return;
+    } else this.platform.stopAudio();
+    this.audioPreparing.set(true);
+    try {
+      const url = await this.wordAudio.ensure(word.wordId, !!original);
+      if (!active()) return;
+      word.audio = url;
+      if (original && this.wordAudioUrl(original) === this.wordAudioUrl(url)) {
+        this.platform.toast('音频暂时无法播放,请检查网络后重试', 'error');
+        return;
+      }
+      await this.platform.play(this.wordAudioUrl(url));
+    } catch (error) {
+      if (active()) this.platform.toast(error instanceof Error ? error.message : '读音暂时不可用,请再点喇叭重试', 'error');
+    } finally {
+      if (request === this.audioRequest) this.audioPreparing.set(false);
     }
-    this.platform.play(this.wordAudioUrl(word.audio));
   }
 
   toggleRecordAnswer(word: Word): void {
@@ -2220,7 +2243,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       word: String(item['Title'] ?? detail['Title'] ?? item['word'] ?? detail['word'] ?? item['Word'] ?? ''),
       meaning: String(item['sy'] ?? detail['sy'] ?? item['jssy'] ?? detail['jssy'] ?? item['Translate'] ?? detail['Translate'] ?? item['Meaning'] ?? ''),
       phonetic: String(item['yb'] ?? detail['yb'] ?? item['Phonetic'] ?? detail['Phonetic'] ?? ''),
-      audio: String(item['yp'] ?? detail['yp'] ?? item['dy'] ?? detail['dy'] ?? ''),
+      audio: [item['yp'], detail['yp'], item['dy'], detail['dy']].map(value => String(value ?? '').trim()).find(Boolean) || '',
     };
   }
 

+ 52 - 0
scripts/check-word-audio-browser.mjs

@@ -0,0 +1,52 @@
+// Isolated browser fixtures: no production reads/writes or provider calls.
+import {chromium} from 'playwright';
+import {createServer} from 'node:http';
+import {readFileSync,existsSync,mkdirSync,writeFileSync} from 'node:fs';
+import {resolve,extname} from 'node:path';
+import assert from 'node:assert/strict';
+const root=resolve('dist/xiaoshu-mobile/browser'),out=resolve('tmp/word-audio-qa');mkdirSync(out,{recursive:true});
+const wav=Buffer.alloc(44+3200);wav.write('RIFF');wav.writeUInt32LE(wav.length-8,4);wav.write('WAVEfmt ',8);wav.writeUInt32LE(16,16);wav.writeUInt16LE(1,20);wav.writeUInt16LE(1,22);wav.writeUInt32LE(16000,24);wav.writeUInt32LE(32000,28);wav.writeUInt16LE(2,32);wav.writeUInt16LE(16,34);wav.write('data',36);wav.writeUInt32LE(3200,40);
+const server=createServer((req,res)=>{
+ let file=resolve(root,'.'+decodeURIComponent(new URL(req.url,'http://local').pathname));
+ if(!file.startsWith(root+'/')||!existsSync(file)||!extname(file))file=root+'/index.html';
+ res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml'})[extname(file)]||'application/octet-stream');res.end(readFileSync(file));
+});
+await new Promise(done=>server.listen(0,'127.0.0.1',done));const origin='http://127.0.0.1:'+server.address().port;
+const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
+const saved=new Map([['101',origin+'/fixtures/existing.wav'],['103',origin+'/fixtures/broken.wav']]),calls=[],errors=[];let fail=false;
+try{
+ const context=await browser.newContext({viewport:{width:375,height:812},reducedMotion:'reduce'});
+ await context.addInitScript(()=>localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:word-audio-fixture',user:{userId:100,groupId:1,honeyName:'测试学生'}})));
+ await context.route('**/*',async route=>{
+  const url=new URL(route.request().url());if(url.origin!==origin)return route.abort();
+  if(url.pathname==='/fixtures/broken.wav')return route.fulfill({status:404,body:''});
+  if(url.pathname.startsWith('/fixtures/'))return route.fulfill({contentType:'audio/wav',body:wav});
+  if(!url.pathname.startsWith('/cloud-functions/'))return route.continue();
+  const {params:p}=route.request().postDataJSON();calls.push(p);let result=[];
+  if(p.action==='app_session_validate')result={valid:true};
+  if(p.action==='app_live_versions')result={version:'1',scopeVersions:{},syncStatus:{state:'current'}};
+  if(p.action==='e_record_detail')result=['apple','banana','orange','pear'].map((Title,index)=>({GeneralID:101+index,Title,sy:'测试单词',yp:saved.get(String(101+index))||''}));
+  if(p.action==='app_word_audio_ensure'){
+   if(fail)return route.fulfill({status:429,json:{retcode:-1,retmsg:'今日自动读音额度已用完,请稍后再试'}});
+   await new Promise(done=>setTimeout(done,180));const url=origin+'/fixtures/'+p.wordId+'.wav';saved.set(p.wordId,url);result={state:'ready',url,source:'xfyun'};
+  }
+  return route.fulfill({json:{retcode:0,result}});
+ });
+ const page=await context.newPage();page.on('pageerror',error=>errors.push(error.message));
+ await page.goto(origin+'/pages/his/his_words?id=201');await page.getByRole('button',{name:'播放单词读音:apple'}).waitFor();
+ const generated=()=>calls.filter(p=>p.action==='app_word_audio_ensure');
+ await page.getByRole('button',{name:'播放单词读音:apple'}).click();assert.equal(generated().length,0);
+ await page.getByRole('button',{name:'播放单词读音:banana'}).click();await page.getByRole('status').filter({hasText:'正在准备读音'}).waitFor();
+ await page.getByRole('status').filter({hasText:'正在准备读音'}).waitFor({state:'hidden'});assert.equal(generated().length,1);
+ await page.getByRole('button',{name:'播放单词读音:banana'}).click();assert.equal(generated().length,1);
+ await page.getByRole('button',{name:'播放单词读音:orange'}).click();await page.waitForFunction(()=>document.body.textContent.includes('正在准备读音'));
+ await page.getByRole('status').filter({hasText:'正在准备读音'}).waitFor({state:'hidden'});assert.equal(generated().length,2);assert.equal(generated()[1].playbackFailed,true);
+ await page.reload();await page.getByRole('button',{name:'播放单词读音:banana'}).click();assert.equal(generated().length,2);
+ fail=true;await page.getByRole('button',{name:'播放单词读音:pear'}).click();await page.getByText('今日自动读音额度已用完,请稍后再试',{exact:true}).waitFor();
+ fail=false;await page.getByRole('button',{name:'播放单词读音:pear'}).click();await page.getByRole('status').filter({hasText:'正在准备读音'}).waitFor();await page.getByRole('status').filter({hasText:'正在准备读音'}).waitFor({state:'hidden'});assert.equal(generated().length,4);assert.ok(saved.get('104'));await page.getByText('今日自动读音额度已用完,请稍后再试',{exact:true}).waitFor({state:'hidden'});
+ assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'mobile horizontal overflow');
+ await page.screenshot({path:out+'/history-375.png'});assert.deepEqual(errors,[]);
+ assert.equal(calls.filter(p=>['e_add_words','content_add','app_lesson_complete'].includes(p.action)).length,0);
+ writeFileSync(out+'/browser-result.json',JSON.stringify({passed:true,checks:['existing playback','first synthesis and reuse','404 recovery','reload reuse','quota error and retry','375px layout','no teaching writes'],environment:'local build with isolated API/audio fixtures'},null,2));
+ console.log('Word audio browser checks passed');
+}finally{await browser.close();await new Promise(done=>server.close(done));}

+ 24 - 0
scripts/check-word-audio-live.mjs

@@ -0,0 +1,24 @@
+#!/usr/bin/env node
+// Explicit, small real-provider smoke test. Never changes production vocabulary.
+import {readFileSync,mkdirSync,writeFileSync,existsSync,statSync} from 'node:fs';
+import {homedir} from 'node:os';
+import {resolve} from 'node:path';
+import {createHash,webcrypto} from 'node:crypto';
+import vm from 'node:vm';
+const file=resolve(process.argv.find(x=>x.startsWith('--file='))?.slice(7)||homedir()+'/.config/xiaoshu/xfyun-word-audio.json');
+if(statSync(file).mode&0o077)throw Error('请将配置文件权限设置为600');
+const settings=JSON.parse(readFileSync(file,'utf8'));
+const ctx=vm.createContext({crypto:webcrypto,WebSocket,TextEncoder,URL,URLSearchParams,Uint8Array,btoa,atob,AbortSignal,setTimeout,clearTimeout,fetch,Parse:{Config:{get:async()=>({get:key=>settings[key]})}},fail:(_code,message)=>{throw Error(message);}});
+vm.runInContext(readFileSync(new URL('./cloud/word-audio.js',import.meta.url),'utf8'),ctx);
+const config=await ctx.wordAudioConfig();
+if(!config.appId||!config.apiKey||!config.apiSecret||!config.voice)throw Error('讯飞配置不完整');
+if(!process.argv.includes('--generate')){console.log(JSON.stringify({configured:true,voice:config.voice,generated:false,message:'--generate 生成 apple 样音;--extended 追加四个代表性词句。'}));process.exit(0);}
+const output=resolve('tmp/word-audio-qa');mkdirSync(output,{recursive:true});
+const words=process.argv.includes('--extended')?['apple','comfortable','read','NASA','Good morning']:['apple'];
+for(const word of words){
+ const key=createHash('sha256').update(JSON.stringify([word,config.voice,config.speed])).digest('hex').slice(0,16),path=output+'/'+key+'.mp3';
+ let cached=existsSync(path);
+ if(!cached){const audio=await ctx.wordAudioSynthesize(word,config);writeFileSync(path,Buffer.from(audio,'base64'),{mode:0o600});}
+ const bytes=readFileSync(path);
+ console.log(JSON.stringify({word,voice:config.voice,path,bytes:bytes.length,cached,sha256:createHash('sha256').update(bytes).digest('hex')}));
+}

+ 140 - 0
scripts/cloud/word-audio.js

@@ -0,0 +1,140 @@
+// This module is assembled into the authenticated app gateway. No credentials reach clients.
+async function wordAudioConfig() {
+ const config=await Parse.Config.get({useMasterKey:true}),env=typeof process!=='undefined'?process.env||{}:{};
+ const pick=(key,fallback='')=>String(env[key]||config.get(key)||fallback).trim();
+ const limit=(key,value,max)=>Math.max(1,Math.min(max,Number(pick(key,value))||value));
+ return {enabled:pick('XIAOSHU_TTS_ENABLED')==='true',appId:pick('XFYUN_APP_ID'),apiKey:pick('XFYUN_API_KEY'),apiSecret:pick('XFYUN_API_SECRET'),
+  workerUrl:pick('XIAOSHU_TTS_WORKER_URL'),workerToken:pick('XIAOSHU_TTS_WORKER_TOKEN'),voice:pick('XFYUN_TTS_VCN'),speed:50,dayLimit:limit('XIAOSHU_TTS_DAILY_LIMIT',500,100000),minuteLimit:limit('XIAOSHU_TTS_USER_MINUTE_LIMIT',30,100),concurrency:limit('XIAOSHU_TTS_CONCURRENCY',1,20),
+  storage:{endpoint:pick('XIAOSHU_S3_ENDPOINT').replace(/\/$/,''),bucket:pick('XIAOSHU_S3_BUCKET'),region:pick('XIAOSHU_S3_REGION','auto'),accessKey:pick('XIAOSHU_S3_ACCESS_KEY_ID'),secretKey:pick('XIAOSHU_S3_SECRET_ACCESS_KEY'),publicBase:pick('XIAOSHU_S3_PUBLIC_BASE_URL').replace(/\/$/,'')}};
+}
+function wordAudioText(value) { return String(value||'').normalize('NFC').trim().replace(/\s+/g,' '); }
+function wordAudioHex(bytes) { return Array.from(new Uint8Array(bytes),v=>v.toString(16).padStart(2,'0')).join(''); }
+async function wordAudioHash(value) { return wordAudioHex(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(value))); }
+async function wordAudioHmac(key,value) {
+ const imported=await crypto.subtle.importKey('raw',typeof key==='string'?new TextEncoder().encode(key):key,{name:'HMAC',hash:'SHA-256'},false,['sign']);
+ return crypto.subtle.sign('HMAC',imported,new TextEncoder().encode(value));
+}
+function wordAudioBase64(bytes) { let binary='';for(const byte of new Uint8Array(bytes))binary+=String.fromCharCode(byte);return btoa(binary); }
+function wordAudioBytes(base64) { return Uint8Array.from(atob(base64),c=>c.charCodeAt(0)); }
+function wordAudioUrl(raw) {
+ const value=String(raw||'').trim().replace(/\\/g,'/');if(!value)return '';
+ if(/^https?:\/\//i.test(value))return value.replace(/^http:\/\/a018\.2018\.z01\.com/i,'https://a018.2018.z01.com');
+ if(value.startsWith('//'))return 'https:'+value;
+ if(/^[a-z]+:/i.test(value))return value;
+ return 'https://a018.2018.z01.com/UploadFiles/'+value.replace(/^\/?(?:backend\/)?(?:UploadFiles\/)?/i,'');
+}
+async function wordAudioMissing(url,config) {
+ // Never probe client URLs, arbitrary hosts, or redirects into private networks.
+ let target;try{target=new URL(url);}catch(_){return false;}
+ const hosts=new Set(['https://a018.2018.z01.com']);
+ if(config.storage.publicBase){try{hosts.add(new URL(config.storage.publicBase).origin);}catch(_){}}
+ if(target.protocol!=='https:'||target.username||target.password||!hosts.has(target.origin))return false;
+ try {
+  const response=await fetch(url,{method:'GET',headers:{Range:'bytes=0-31'},redirect:'manual',signal:AbortSignal.timeout(5000)});
+  await response.body?.cancel();return response.status===404||response.status===410;
+ } catch(_) {return false;}
+}
+async function wordAudioTarget(input,current) {
+ if(!current||current.get('company')?.id!==DEFAULT_COMPANY_ID||current.get('isDisabled')===true||current.get('isDeleted')===true)fail(403,'无权访问词库读音');
+ if(![1,3].includes(Number(current.get('legacyGroupId')))&&!isAdminUser(current))fail(403,'无权生成词库读音');
+ const id=String(input.wordId||'');if(!/^\d{1,16}$/.test(id))fail(400,'缺少有效词条 ID');
+ // Model 52 is public vocabulary in the existing access model; drafts/deleted records are excluded here.
+ const row=await Psql.oneOrNone('SELECT c."generalId",c.title,v."objectId" AS "vocabularyId",v.yb,v.yp FROM "CommonModel" c JOIN "VocabularyWord" v ON v.company=c.company AND v.id=c."itemId" WHERE c.company=$1 AND c."modelId"=52 AND c."generalId"::text=$2 AND c.status=99 LIMIT 1',[DEFAULT_COMPANY_ID,id]);
+ if(!row)fail(404,'词条不存在或尚未发布');return row;
+}
+function wordAudioValidMp3(audio) {
+ try{const bytes=wordAudioBytes(audio);return bytes.length>=4&&bytes.length<=512000&&(String.fromCharCode(...bytes.slice(0,3))==='ID3'||bytes[0]===255&&(bytes[1]&224)===224);}catch(_){return false;}
+}
+async function wordAudioSynthesize(word,config) {
+ if(typeof WebSocket==='undefined') {
+  if(!config.workerUrl?.startsWith('https://')||!config.workerToken)fail(503,'读音合成适配器尚未配置');
+  let response;try{response=await fetch(config.workerUrl,{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+config.workerToken},body:JSON.stringify({word,voice:config.voice,speed:config.speed}),redirect:'error',signal:AbortSignal.timeout(25000)});}catch(_){fail(502,'读音合成服务连接失败');}
+  if(!response.ok)fail(502,'读音合成服务暂时不可用');
+  const value=await response.json(),audio=String(value.audio||'');
+  if(audio.length>700000||!wordAudioValidMp3(audio))fail(502,'读音合成服务返回无效音频');return audio;
+ }
+ const host='tts-api.xfyun.cn',date=new Date().toUTCString();
+ const signature=wordAudioBase64(await wordAudioHmac(config.apiSecret,'host: '+host+'\ndate: '+date+'\nGET /v2/tts HTTP/1.1'));
+ const auth=btoa('api_key="'+config.apiKey+'", algorithm="hmac-sha256", headers="host date request-line", signature="'+signature+'"');
+ const url='wss://'+host+'/v2/tts?'+new URLSearchParams({authorization:auth,date,host});
+ return new Promise((resolve,reject)=>{
+  const socket=new WebSocket(url),chunks=[];let length=0,settled=false;
+  const finish=(error)=>{if(settled)return;settled=true;clearTimeout(timer);try{socket.close();}catch(_){}if(error)return reject(error);
+   const bytes=new Uint8Array(length);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.length;}
+   if(length<4||!(String.fromCharCode(...bytes.slice(0,3))==='ID3'||bytes[0]===255&&(bytes[1]&224)===224))return reject(new Error('讯飞未返回有效 MP3'));resolve(wordAudioBase64(bytes));};
+  const timer=setTimeout(()=>finish(new Error('读音生成超时,请稍后重试')),20000);
+  socket.onopen=()=>{try{socket.send(JSON.stringify({common:{app_id:config.appId},business:{aue:'lame',sfl:1,auf:'audio/L16;rate=16000',vcn:config.voice,speed:config.speed,tte:'UTF8',reg:'0',bgs:0},data:{status:2,text:wordAudioBase64(new TextEncoder().encode(word))}}));}catch(_){finish(new Error('读音服务连接失败'));}};
+  socket.onmessage=event=>{try{const message=JSON.parse(event.data);if(message.code!==0)return finish(new Error('讯飞合成失败('+Number(message.code)+')'));
+   if(message.data?.audio){const bytes=wordAudioBytes(message.data.audio);length+=bytes.length;if(length>512000)return finish(new Error('读音文件过大'));chunks.push(bytes);}
+   if(message.data?.status===2)finish();
+  }catch(_){finish(new Error('读音服务返回无效数据'));}};
+  socket.onerror=()=>finish(new Error('读音服务连接失败'));socket.onclose=()=>{if(!settled)finish(new Error('读音生成中断,请稍后重试'));};
+ });
+}
+function wordAudioStorageReady(storage) {
+ try {return Object.values(storage).every(Boolean)&&new URL(storage.endpoint).protocol==='https:'&&new URL(storage.publicBase).protocol==='https:'&&/^[a-zA-Z0-9._-]+$/.test(storage.bucket);}catch(_){return false;}
+}
+async function wordAudioUpload(id,base64,config) {
+ if(!wordAudioStorageReady(config.storage)) {
+  if(!wordAudioValidMp3(base64))fail(502,'读音文件无效');
+  return uploadAppMedia({fileName:id+'.mp3',contentType:'audio/mpeg',base64},{'audio/mpeg':''});
+ }
+ const s=config.storage,key=DEFAULT_COMPANY_ID+'/audio/words/'+id+'.mp3',stamp=new Date().toISOString().replace(/[:-]|\.\d{3}/g,''),day=stamp.slice(0,8);
+ const path='/'+s.bucket+'/'+key,scope=day+'/'+s.region+'/s3/aws4_request',host=new URL(s.endpoint).host;
+ const query='X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential='+encodeURIComponent(s.accessKey+'/'+scope)+'&X-Amz-Date='+stamp+'&X-Amz-Expires=60&X-Amz-SignedHeaders=host';
+ const canonical='PUT\n'+path+'\n'+query+'\nhost:'+host+'\n\nhost\nUNSIGNED-PAYLOAD';
+ const signed='AWS4-HMAC-SHA256\n'+stamp+'\n'+scope+'\n'+await wordAudioHash(canonical);
+ const dateKey=await wordAudioHmac('AWS4'+s.secretKey,day),regionKey=await wordAudioHmac(dateKey,s.region),serviceKey=await wordAudioHmac(regionKey,'s3'),signingKey=await wordAudioHmac(serviceKey,'aws4_request');
+ const signature=wordAudioHex(await wordAudioHmac(signingKey,signed));
+ const response=await fetch(s.endpoint+path+'?'+query+'&X-Amz-Signature='+signature,{method:'PUT',headers:{'Content-Type':'audio/mpeg'},body:wordAudioBytes(base64),redirect:'error',signal:AbortSignal.timeout(10000)});
+ await response.body?.cancel();if(!response.ok)fail(502,'读音已生成,保存失败,请稍后重试');return s.publicBase+'/'+key;
+}
+async function wordAudioBind(row,url) {
+ // Compare-and-set: never overwrite an editor's newer audio or a changed word/phonetic.
+ const changed=await Psql.query('UPDATE "VocabularyWord" v SET yp=$1,"updatedAt"=now() FROM "CommonModel" c WHERE v."objectId"=$2 AND v.company=$3 AND c.company=v.company AND c."modelId"=52 AND c."itemId"=v.id AND c."generalId"::text=$4 AND c.title=$5 AND COALESCE(v.yb,\'\')=$6 AND COALESCE(v.yp,\'\')=$7 RETURNING v.yp',[url,row.vocabularyId,DEFAULT_COMPANY_ID,String(row.generalId),row.title,row.yb||'',row.yp||'']);
+ if(!changed.length)fail(409,'词条已更新,请刷新后点读');
+ return {state:'ready',url,source:'xfyun'};
+}
+async function appWordAudioEnsure(input,current) {
+ const row=await wordAudioTarget(input,current),oldUrl=wordAudioUrl(row.yp);
+ if(oldUrl&&input.playbackFailed!==true)return {state:'ready',url:oldUrl,source:'existing'};
+ const config=await wordAudioConfig();
+ if(oldUrl&&!await wordAudioMissing(oldUrl,config))return {state:'ready',url:oldUrl,source:'existing'};
+ const word=wordAudioText(row.title),phonetic=wordAudioText(row.yb);
+ if(!word||word.length>200||/[\x00-\x1f<>\[\]{}]/.test(word))fail(400,'该词条暂不支持自动读音');
+ const voice=JSON.stringify({provider:'xfyun-v2',vcn:config.voice,accent:'en-US',speed:config.speed,format:'mp3',version:1});
+ const id=await wordAudioHash(JSON.stringify([DEFAULT_COMPANY_ID,word,phonetic,voice]));
+ let cached=await Psql.oneOrNone('SELECT * FROM "WordAudioCache" WHERE "objectId"=$1 AND company=$2',[id,DEFAULT_COMPANY_ID]);
+ if(cached?.status==='ready'){
+  if(oldUrl&&oldUrl===cached.url){await Psql.none('UPDATE "WordAudioCache" SET status=\'pending\',"updatedAt"=now() WHERE "objectId"=$1 AND status=\'ready\'',[id]);}
+  else return wordAudioBind(row,cached.url);
+ }
+ if(!config.enabled)fail(503,'自动读音尚未开启,请联系老师');
+ if(!config.voice)fail(503,'自动读音尚未配置完成');
+ if(typeof WebSocket==='undefined') {if(!config.workerUrl?.startsWith('https://')||!config.workerToken)fail(503,'读音合成适配器尚未配置');}
+ else if(!config.appId||!config.apiKey||!config.apiSecret)fail(503,'自动读音尚未配置完成');
+ if(!wordAudioStorageReady(config.storage)) {const legacy=await appLegacyConfig();if(!legacy.url||!legacy.apiId||!legacy.apiKey)fail(503,'读音存储尚未配置');}
+ const token=randomObjectId();
+ const {result}=await Psql.one('SELECT xs_word_audio_claim($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) AS result',[DEFAULT_COMPANY_ID,id,current.id,token,word,phonetic,voice,config.dayLimit,config.minuteLimit,config.concurrency]);
+ if(result.state==='ready')return wordAudioBind(row,result.url);
+ if(result.state==='pending')return {state:'pending',retryAfterMs:1500};
+ if(result.state==='quota')fail(429,'今日自动读音额度已用完,请稍后再试');
+ if(result.state==='limited')fail(429,'点读太频繁,请稍后再试');
+ if(result.state==='retry')fail(503,'读音暂时不可用,请一分钟后重试');
+ try {
+  const audio=result.audio||await wordAudioSynthesize(word,config);
+  // Persist the provider result before uploading, so retries can resume without TTS.
+  const saved=await Psql.query('UPDATE "WordAudioCache" SET audio=$3,"updatedAt"=now() WHERE "objectId"=$1 AND lease=$2 AND "leaseUntil">now() RETURNING "objectId"',[id,token,audio]);
+  if(!saved.length)fail(409,'读音任务已过期,请重试');
+  const url=await wordAudioUpload(id,audio,config);
+  const ready=await Psql.query('UPDATE "WordAudioCache" SET status=\'ready\',url=$3,lease=NULL,"leaseUntil"=NULL,"retryAfter"=NULL,"lastError"=NULL,"updatedAt"=now() WHERE "objectId"=$1 AND lease=$2 RETURNING "objectId"',[id,token,url]);
+  if(!ready.length)fail(409,'读音任务已更新,请重试');
+  await Psql.none('UPDATE "WordAudioAttempt" SET status=\'ready\',"updatedAt"=now() WHERE "objectId"=$1',[token]);
+  return await wordAudioBind(row,url);
+ } catch(error) {
+  await Psql.none('UPDATE "WordAudioCache" SET status=\'failed\',lease=NULL,"leaseUntil"=NULL,"retryAfter"=now()+interval \'1 minute\',"lastError"=\'generation_or_storage_failed\',"updatedAt"=now() WHERE "objectId"=$1 AND lease=$2',[id,token]);
+  await Psql.none('UPDATE "WordAudioAttempt" SET status=\'failed\',"updatedAt"=now() WHERE "objectId"=$1',[token]);
+  // Transport exceptions may include signed URLs. Only emit our own controlled errors.
+  fail(502,'读音生成或保存暂时失败,请稍后点击喇叭重试');
+ }
+}

+ 21 - 0
scripts/configure-word-audio.mjs

@@ -0,0 +1,21 @@
+#!/usr/bin/env node
+import {readFileSync,statSync} from 'node:fs';
+import {homedir} from 'node:os';
+import {resolve} from 'node:path';
+const file=resolve(process.argv.find(value=>value.startsWith('--file='))?.slice(7)||homedir()+'/.config/xiaoshu/xfyun-word-audio.json');
+if((statSync(file).mode&0o077)!==0)throw Error('配置文件须仅限当前用户读取,请执行 chmod 600 后重试');
+const input=JSON.parse(readFileSync(file,'utf8'));
+const gatewayOnly=process.argv.includes('--gateway-only');
+const required=gatewayOnly?['XIAOSHU_TTS_WORKER_URL','XIAOSHU_TTS_WORKER_TOKEN','XFYUN_TTS_VCN']:['XFYUN_APP_ID','XFYUN_API_KEY','XFYUN_API_SECRET','XFYUN_TTS_VCN'];
+const optional=['XIAOSHU_TTS_WORKER_URL','XIAOSHU_TTS_WORKER_TOKEN','XIAOSHU_TTS_ENABLED','XIAOSHU_TTS_DAILY_LIMIT','XIAOSHU_TTS_USER_MINUTE_LIMIT','XIAOSHU_TTS_CONCURRENCY','XIAOSHU_S3_ENDPOINT','XIAOSHU_S3_BUCKET','XIAOSHU_S3_REGION','XIAOSHU_S3_ACCESS_KEY_ID','XIAOSHU_S3_SECRET_ACCESS_KEY','XIAOSHU_S3_PUBLIC_BASE_URL'];
+const missing=required.filter(key=>!String(input[key]||'').trim());
+if(missing.length)throw Error('请填写配置项:'+missing.join('、'));
+const params=Object.fromEntries([...new Set([...required,...optional])].filter(key=>input[key]!=null).map(key=>[key,String(input[key]).trim()]));
+params.XIAOSHU_TTS_ENABLED=process.argv.includes('--enable')?'true':'false';
+if(!process.argv.includes('--apply')){console.log(JSON.stringify({valid:true,applied:false,enabled:false,message:'配置格式检查通过;--apply 写入服务端私密配置,--enable 另行开启生成。不会调用讯飞。'}));process.exit(0);}
+const master=process.env.XIAOSHU_MASTER_KEY;if(!master)throw Error('缺少 XIAOSHU_MASTER_KEY');
+const url=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,'');
+const response=await fetch(url+'/config',{method:'PUT',headers:{'Content-Type':'application/json','X-Parse-Application-Id':process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main','X-Parse-Master-Key':master},body:JSON.stringify({params,masterKeyOnly:Object.fromEntries(Object.keys(params).map(key=>[key,true]))})});
+if(!response.ok)throw Error('私密配置保存失败(HTTP '+response.status+')');
+const payload=await response.json();if(payload.error)throw Error('私密配置保存失败');
+console.log(JSON.stringify({configured:true,enabled:params.XIAOSHU_TTS_ENABLED==='true',secretsPrinted:false}));

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
scripts/deploy-admin-functions.mjs


+ 51 - 0
scripts/migrate-word-audio.mjs

@@ -0,0 +1,51 @@
+#!/usr/bin/env node
+import { readFileSync } from 'node:fs';
+import { randomBytes } from 'node:crypto';
+import { request as httpsRequest } from 'node:https';
+const sql=readFileSync(new URL('./sql/word-audio.sql',import.meta.url),'utf8');
+if(!process.argv.includes('--apply')) {
+  console.log('只读检查:单词读音缓存迁移已就绪。--apply 创建服务端缓存与用量表,不生成音频。');
+  process.exit(0);
+}
+const master=process.env.XIAOSHU_MASTER_KEY;
+if(!master)throw new Error('缺少 XIAOSHU_MASTER_KEY');
+const parseUrl=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,'');
+const appId=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main';
+async function request(path,options={}) {
+  const res=await fetch(parseUrl+path,{...options,headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId,'X-Parse-Master-Key':master}});
+  const data=await res.json();if(!res.ok||data.error)throw new Error(data.error||data.message||data.retmsg||`HTTP ${res.status}`);return data;
+}
+function invokeMigration(url,token) {
+  return new Promise((resolve,reject)=>{const body=JSON.stringify({token,params:{}}),req=httpsRequest(url,{method:'POST',headers:{'Content-Type':'application/json','Content-Length':Buffer.byteLength(body)}},(res)=>{let raw='';res.setEncoding('utf8');res.on('data',(chunk)=>raw+=chunk);res.on('end',()=>{try{resolve({ok:Number(res.statusCode)>=200&&Number(res.statusCode)<300,data:JSON.parse(raw)})}catch(error){reject(error)}});});req.on('error',reject);req.write(body);req.end();});
+}
+// Register additive Parse fields before SQL, so schema metadata and physical columns agree.
+const knownSchemas=(await request('/schemas')).results||[];
+const permissions={find:{},count:{},get:{},create:{},update:{},delete:{},addField:{}};
+for(const [name,fields] of Object.entries({
+ WordAudioCache:{company:{type:'String'},word:{type:'String'},phonetic:{type:'String'},voice:{type:'String'},status:{type:'String'},url:{type:'String'},audio:{type:'String'},lease:{type:'String'},leaseUntil:{type:'Date'},retryAfter:{type:'Date'},lastError:{type:'String'}},
+ WordAudioAttempt:{company:{type:'String'},actor:{type:'String'},cacheId:{type:'String'},providerCall:{type:'Boolean'},status:{type:'String'}},
+})){
+ const schema=knownSchemas.find(row=>row.className===name);
+ const missing=Object.fromEntries(Object.entries(fields).filter(([key])=>!schema?.fields?.[key]));
+ await request(schema?'/schemas/'+name:'/schemas',{method:schema?'PUT':'POST',body:JSON.stringify({...(schema?{}:{className:name}),fields:missing,classLevelPermissions:permissions})});
+}
+const company=(await request('/classes/Company?limit=1&keys=objectId')).results?.[0];
+if(!company)throw new Error('未找到帐套');
+const suffix=Date.now()+'_'+randomBytes(5).toString('hex'),path='xiaoshu/system/word-audio-'+suffix;
+let userId='',functionId='';
+try {
+  const sessions=await request('/classes/_Session?include=user&limit=200&order=-updatedAt');
+  const selected=sessions.results.find(s=>s.sessionToken&&s.user?.isAdmin&&!s.user?.isDisabled&&new Date(s.expiresAt.iso)>new Date());
+  if(!selected)throw new Error('没有可用管理员会话');
+  const account={objectId:selected.user.objectId,sessionToken:selected.sessionToken};const authorizedId=account.objectId;
+  const code=`async function handler(request,response){try{const current=request.user;if(!current)return response.status(401).json({success:false});await current.fetch({useMasterKey:true});if(Date.now()>${Date.now()+600000}||current.id!==${JSON.stringify(authorizedId)})return response.status(403).json({success:false});await Psql.tx(async tx=>{await tx.none("SET LOCAL lock_timeout = '5s'");await tx.none(${JSON.stringify(sql)});});const counts=await Psql.query('SELECT COUNT(*)::int AS cached FROM "WordAudioCache"');return response.json({success:true,counts});}catch(error){return response.status(500).json({success:false,message:String(error.message||error)});}}`;
+  const fn=await request('/classes/Function',{method:'POST',body:JSON.stringify({name:path,path,code,type:'standalone',desc:'单词读音缓存一次性数据库迁移',enabled:true,params:[],paramList:[],respType:'json'})});functionId=fn.objectId;
+  const res=await invokeMigration(parseUrl.replace(/\/parse$/,'/api/functions/')+path,account.sessionToken);
+  if(!res.ok||!res.data.success)throw new Error(res.data.message||'迁移失败');console.log(JSON.stringify(res.data,null,2));
+} finally {
+  // Cleanup failures are reported, including IDs, so no temporary privileged surface is silently left behind.
+  const failures=[];
+  if(functionId)try{await request('/classes/Function/'+functionId,{method:'DELETE'});}catch{failures.push('临时函数 '+functionId);}
+  if(userId)try{await request('/users/'+userId,{method:'DELETE'});}catch{failures.push('临时管理员 '+userId);}
+  if(failures.length)throw new Error('需要清理:'+failures.join('、'));
+}

+ 44 - 0
scripts/sql/word-audio.sql

@@ -0,0 +1,44 @@
+-- Server-only cache. Provider work runs outside short SQL transactions.
+CREATE TABLE IF NOT EXISTS "WordAudioCache" (
+ "objectId" text PRIMARY KEY, company text NOT NULL, word text NOT NULL, phonetic text NOT NULL,
+ voice text NOT NULL, status text NOT NULL DEFAULT 'pending', url text NOT NULL DEFAULT '',
+ audio text NOT NULL DEFAULT '', lease text, "leaseUntil" timestamptz,
+ "retryAfter" timestamptz, "lastError" text, "createdAt" timestamptz NOT NULL DEFAULT now(),
+ "updatedAt" timestamptz NOT NULL DEFAULT now()
+);
+CREATE TABLE IF NOT EXISTS "WordAudioAttempt" (
+ "objectId" text PRIMARY KEY, company text NOT NULL, actor text NOT NULL, "cacheId" text NOT NULL,
+ "providerCall" boolean NOT NULL, status text NOT NULL DEFAULT 'started',
+ "createdAt" timestamptz NOT NULL DEFAULT now(), "updatedAt" timestamptz NOT NULL DEFAULT now()
+);
+-- Parse may have registered these tables before this migration; apply defaults there too.
+ALTER TABLE "WordAudioCache" ALTER COLUMN status SET DEFAULT 'pending', ALTER COLUMN url SET DEFAULT '', ALTER COLUMN audio SET DEFAULT '', ALTER COLUMN "createdAt" SET DEFAULT now(), ALTER COLUMN "updatedAt" SET DEFAULT now();
+ALTER TABLE "WordAudioAttempt" ALTER COLUMN status SET DEFAULT 'started', ALTER COLUMN "createdAt" SET DEFAULT now(), ALTER COLUMN "updatedAt" SET DEFAULT now();
+UPDATE "WordAudioCache" SET status=COALESCE(status,'pending'),url=COALESCE(url,''),audio=COALESCE(audio,'');
+CREATE INDEX IF NOT EXISTS word_audio_attempt_usage ON "WordAudioAttempt" (company,"createdAt",actor);
+CREATE INDEX IF NOT EXISTS word_audio_leases ON "WordAudioCache" (company,"leaseUntil");
+CREATE OR REPLACE FUNCTION xs_word_audio_claim(co text, cache_id text, actor_id text, token text,
+ word_text text, phonetic_text text, voice_text text, daily_limit int, minute_limit int, concurrency_limit int)
+RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE r "WordAudioCache"; n int;
+BEGIN
+ PERFORM pg_advisory_xact_lock(hashtext('word-audio:'||co));
+ INSERT INTO "WordAudioCache" ("objectId",company,word,phonetic,voice)
+ VALUES(cache_id,co,word_text,phonetic_text,voice_text) ON CONFLICT DO NOTHING;
+ SELECT * INTO r FROM "WordAudioCache" WHERE "objectId"=cache_id AND company=co FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION 'audio cache tenant mismatch'; END IF;
+ IF r.status='ready' THEN RETURN jsonb_build_object('state','ready','url',r.url); END IF;
+ IF r."leaseUntil">now() THEN RETURN jsonb_build_object('state','pending'); END IF;
+ IF r."retryAfter">now() THEN RETURN jsonb_build_object('state','retry'); END IF;
+ SELECT count(*) INTO n FROM "WordAudioAttempt" WHERE company=co AND actor=actor_id AND "createdAt">now()-interval '1 minute';
+ IF n>=minute_limit THEN RETURN jsonb_build_object('state','limited'); END IF;
+ SELECT count(*) INTO n FROM "WordAudioCache" WHERE company=co AND "leaseUntil">now();
+ IF n>=concurrency_limit THEN RETURN jsonb_build_object('state','pending'); END IF;
+ IF r.audio='' THEN
+  SELECT count(*) INTO n FROM "WordAudioAttempt" WHERE company=co AND "providerCall" AND "createdAt">=date_trunc('day',now() AT TIME ZONE 'Asia/Shanghai') AT TIME ZONE 'Asia/Shanghai';
+  IF n>=daily_limit THEN RETURN jsonb_build_object('state','quota'); END IF;
+ END IF;
+ UPDATE "WordAudioCache" SET status='generating',lease=token,"leaseUntil"=now()+interval '90 seconds',"updatedAt"=now() WHERE "objectId"=cache_id;
+ INSERT INTO "WordAudioAttempt" ("objectId",company,actor,"cacheId","providerCall") VALUES(token,co,actor_id,cache_id,r.audio='');
+ RETURN jsonb_build_object('state','claimed','audio',r.audio);
+END $$;

+ 27 - 0
scripts/tests/word-audio-worker.test.mjs

@@ -0,0 +1,27 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import {once} from 'node:events';
+import {createWordAudioWorker} from '../word-audio-worker.mjs';
+const token='test-token-abcdefghijklmnopqrstuvwxyz0123456789';
+async function setup(t,synthesize){
+ const server=createWordAudioWorker({token,voice:'english',concurrency:1},synthesize);
+ server.listen(0,'127.0.0.1');await once(server,'listening');t.after(()=>new Promise(resolve=>server.close(resolve)));
+ const base='http://127.0.0.1:'+server.address().port;
+ return {base,post:(input={},headers={})=>fetch(base+'/synthesize',{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token,...headers},body:JSON.stringify({word:'apple',voice:'english',speed:50,...input})})};
+}
+test('worker只接受鉴权后的固定发音配置,错误不泄漏供应商信息',async t=>{
+ let calls=0;const {base,post}=await setup(t,async()=>{calls++;return 'SUQzYWJj';});
+ assert.equal((await fetch(base+'/healthz')).status,200);
+ assert.equal((await post({}, {Authorization:'Bearer invalid'})).status,401);
+ assert.equal((await post({voice:'unapproved'})).status,400);
+ assert.equal((await post({word:'<speak>injection</speak>'})).status,400);
+ const valid=await post();assert.equal(valid.status,200);assert.equal((await valid.json()).audio,'SUQzYWJj');assert.equal(calls,1);
+});
+test('worker并发受限;未完成的任务不重复占用讯飞连接',async t=>{
+ let done;const {post}=await setup(t,()=>new Promise(resolve=>done=resolve));const first=post();
+ while(!done)await new Promise(resolve=>setTimeout(resolve,5));assert.equal((await post()).status,429);
+ done('SUQzYWJj');assert.equal((await first).status,200);
+});
+test('worker向调用方只返回固定错误,不暴露密钥或签名URL',async t=>{
+ const {post}=await setup(t,async()=>{throw Error('sensitive-provider-url');});const response=await post();assert.equal(response.status,502);assert.equal(await response.text(),'{"error":"synthesis_failed"}');
+});

+ 121 - 0
scripts/tests/word-audio.test.mjs

@@ -0,0 +1,121 @@
+import {test,before,after} from 'node:test';
+import assert from 'node:assert/strict';
+import vm from 'node:vm';
+import {readFileSync} from 'node:fs';
+import {webcrypto} from 'node:crypto';
+import {PGlite} from '@electric-sql/pglite';
+const code=readFileSync(new URL('../cloud/word-audio.js',import.meta.url),'utf8');
+const migration=readFileSync(new URL('../sql/word-audio.sql',import.meta.url),'utf8');
+const db=new PGlite();
+const one=async(sql,args)=>(await db.query(sql,args)).rows[0];
+let serial=0;
+const user={id:'student',get:key=>({company:{id:'co'},legacyGroupId:1}[key])};
+const config={enabled:true,appId:'test',apiKey:'test',apiSecret:'test',voice:'english-test',speed:50,dayLimit:500,minuteLimit:30,concurrency:1,storage:{endpoint:'https://s3.test',bucket:'test',region:'test',accessKey:'test',secretKey:'test',publicBase:'https://cdn.test'}};
+function context(){return vm.createContext({WebSocket:class {},crypto:webcrypto,TextEncoder,URL,URLSearchParams,Uint8Array,AbortSignal,setTimeout,clearTimeout,btoa,atob,DEFAULT_COMPANY_ID:'co',isAdminUser:()=>false,randomObjectId:()=>`attempt-${++serial}`,fail:(status,message)=>{throw Object.assign(new Error(message),{status});},fetch:async()=>{throw Error('Unexpected network call');},Psql:{one,oneOrNone:one,query:async(sql,args)=>(await db.query(sql,args)).rows,none:async(sql,args)=>db.query(sql,args)}});}
+function setup(){
+ const c=context();vm.runInContext(code,c);c.wordAudioConfig=async()=>structuredClone(config);
+ let calls=0,uploads=0;c.wordAudioSynthesize=async()=>{calls++;return 'SUQzYWJj';};c.wordAudioUpload=async id=>{uploads++;return 'https://cdn.test/'+id+'.mp3';};
+ return {c,calls:()=>calls,uploads:()=>uploads,ensure:(wordId='101',input={},actor=user)=>c.appWordAudioEnsure({wordId,...input},actor)};
+}
+before(async()=>{
+ await db.exec(`CREATE TABLE "CommonModel" (company text,"modelId" int,"generalId" int,"itemId" int,title text,status int,"isDeleted" boolean);
+ CREATE TABLE "VocabularyWord" ("objectId" text,company text,id int,yb text,yp text,"isDeleted" boolean,"updatedAt" timestamptz);`);
+ await db.exec(migration);
+});
+after(()=>db.close());
+async function reset(){
+ await db.exec(`TRUNCATE "WordAudioCache","WordAudioAttempt","CommonModel","VocabularyWord";
+ INSERT INTO "CommonModel" VALUES ('co',52,101,1,'apple',99,false),('co',52,102,2,'apple',99,false),('co',52,103,3,'Apple',99,false),('co',52,104,4,'apple',99,false),('co',52,105,5,'draft',0,false),('other',52,106,6,'foreign',99,false);
+ INSERT INTO "VocabularyWord" VALUES ('v1','co',1,'a','',false,now()),('v2','co',2,'a','',false,now()),('v3','co',3,'a','',false,now()),('v4','co',4,'different','',false,now()),('v5','co',5,'','',false,now()),('v6','other',6,'','',false,now());`);
+}
+test('已有读音不加载讯飞配置也不生成;空缺首次保存后跨词条/用户复用',async()=>{
+ await reset();const {c,ensure,calls,uploads}=setup();
+ const first=await ensure();assert.equal(first.state,'ready');assert.equal(calls(),1);assert.equal(uploads(),1);
+ assert.equal((await ensure('102',{}, {id:'teacher',get:key=>({company:{id:'co'},legacyGroupId:3}[key])})).url,first.url);assert.equal(calls(),1);
+ c.wordAudioConfig=async()=>{throw Error('must not load');};assert.equal((await ensure()).url,first.url);assert.equal(calls(),1);
+});
+test('保留大小写与音标区分;不接受客户端任意文本',async()=>{
+ await reset();const {ensure,calls}=setup();await ensure('101',{text:'injected'});await ensure('103');await ensure('104');assert.equal(calls(),3);
+ const rows=(await db.query('SELECT word,phonetic FROM "WordAudioCache"')).rows;assert.equal(rows.some(row=>row.word==='injected'),false);
+});
+test('未登录、跨租户、非师生、草稿和无效词条被拒绝',async()=>{
+ await reset();const {ensure,calls}=setup();
+ for(const actor of [null,{get:()=>null},{get:key=>({company:{id:'other'},legacyGroupId:1}[key])},{get:key=>({company:{id:'co'},legacyGroupId:2}[key])}])await assert.rejects(ensure('101',{},actor),/无权/);
+ for(const id of ['105','106','999','101 OR 1=1'])await assert.rejects(ensure(id),/词条/);assert.equal(calls(),0);
+});
+test('同时点击只发起一次合成,其余返回pending;完成后可复用',async()=>{
+ await reset();const {c,ensure}=setup();let release,calls=0;
+ c.wordAudioSynthesize=()=>{calls++;return new Promise(resolve=>release=resolve);};
+ const first=ensure();while(!release)await new Promise(resolve=>setTimeout(resolve,5));
+ assert.equal((await ensure()).state,'pending');assert.equal((await ensure('102')).state,'pending');assert.equal(calls,1);
+ release('SUQzYWJj');await first;assert.equal((await ensure('102')).state,'ready');assert.equal(calls,1);
+});
+test('上传失败保留音频;冷却后重试保存而不重复调用讯飞',async()=>{
+ await reset();const {c,ensure,calls}=setup();c.wordAudioUpload=async()=>{throw Error('secret signed URL');};
+ await assert.rejects(ensure(),error=>!error.message.includes('secret')&&error.status===502);
+ await assert.rejects(ensure(),/一分钟/);await db.exec('UPDATE "WordAudioCache" SET "retryAfter"=now()-interval \'1 second\'');
+ c.wordAudioUpload=async()=> 'https://cdn.test/recovered.mp3';assert.equal((await ensure()).url,'https://cdn.test/recovered.mp3');assert.equal(calls(),1);
+});
+test('生成后文件404可用保存的字节修复,不重新合成',async()=>{
+ await reset();const {c,ensure,calls,uploads}=setup();await ensure();c.wordAudioMissing=async()=>true;
+ await ensure('101',{playbackFailed:true});assert.equal(calls(),1);assert.equal(uploads(),2);
+});
+test('关闭开关仍可复用缓存;新单词不生成;缺配置拒绝生成',async()=>{
+ await reset();const {c,ensure,calls}=setup();await ensure();c.wordAudioConfig=async()=>({...config,enabled:false});
+ assert.equal((await ensure('102')).state,'ready');await assert.rejects(ensure('103'),/尚未开启/);
+ c.wordAudioConfig=async()=>({...config,apiSecret:''});await assert.rejects(ensure('103'),/配置/);assert.equal(calls(),1);
+});
+test('日额度/用户速率限制生效;并发上限不启动额外任务',async()=>{
+ await reset();const {c,ensure,calls}=setup();c.wordAudioConfig=async()=>({...config,dayLimit:1});await ensure();await assert.rejects(ensure('103'),/额度/);
+ c.wordAudioConfig=async()=>({...config,minuteLimit:1});await assert.rejects(ensure('103'),/频繁/);assert.equal(calls(),1);
+});
+test('回填时词条被编辑,不覆盖编辑者的录音',async()=>{
+ await reset();const {c,ensure}=setup();c.wordAudioUpload=async()=>{await db.query('UPDATE "VocabularyWord" SET yp=$1 WHERE id=1',['https://cdn.test/manual.mp3']);return 'https://cdn.test/generated.mp3';};
+ await assert.rejects(ensure());assert.equal((await one('SELECT yp FROM "VocabularyWord" WHERE id=1')).yp,'https://cdn.test/manual.mp3');
+ assert.equal((await ensure('102')).url,'https://cdn.test/generated.mp3');
+});
+test('仅可信主机的404/410确认为失效,不将超时、403、500或跳转视作失效',async()=>{
+ const c=context();vm.runInContext(code,c);let calls=0;
+ for(const status of [200,403,404,410,500,302]){c.fetch=async()=>{calls++;return{status,body:{cancel:async()=>{}}};};assert.equal(await c.wordAudioMissing('https://cdn.test/word.mp3',config),[404,410].includes(status));}
+ const before=calls;assert.equal(await c.wordAudioMissing('http://127.0.0.1/admin',config),false);assert.equal(await c.wordAudioMissing('https://untrusted.test/audio',config),false);assert.equal(calls,before);
+ c.fetch=async()=>{throw Error('timeout');};assert.equal(await c.wordAudioMissing('https://cdn.test/word.mp3',config),false);
+});
+test('失效旧链接才补齐;网络错误不生成',async()=>{
+ await reset();const {c,ensure,calls}=setup();await db.exec('UPDATE "VocabularyWord" SET yp=\'missing.mp3\' WHERE id=1');c.wordAudioMissing=async()=>false;
+ assert.equal((await ensure('101',{playbackFailed:true})).source,'existing');assert.equal(calls(),0);
+ c.wordAudioMissing=async()=>true;assert.equal((await ensure('101',{playbackFailed:true})).source,'xfyun');assert.equal(calls(),1);
+});
+test('讯飞WebSocket签名与MP3分片组装;不完整/错误数据拒绝',async()=>{
+ const c=context();let sent,opened;
+ class Socket { constructor(url){opened=url;queueMicrotask(()=>this.onopen());} send(data){sent=JSON.parse(data);queueMicrotask(()=>{this.onmessage({data:JSON.stringify({code:0,data:{audio:'SUQz',status:1}})});this.onmessage({data:JSON.stringify({code:0,data:{audio:'YWJj',status:2}})});});} close(){} }
+ c.WebSocket=Socket;vm.runInContext(code,c);
+ assert.equal(await c.wordAudioSynthesize('apple',config),'SUQzYWJj');assert.match(opened,/^wss:\/\/tts-api.xfyun.cn\/v2\/tts\?/);assert.equal(sent.business.aue,'lame');assert.equal(sent.business.sfl,1);assert.equal(sent.business.reg,'0');assert.equal(atob(sent.data.text),'apple');
+ c.WebSocket=class extends Socket {send(){queueMicrotask(()=>this.onmessage({data:JSON.stringify({code:11200,message:'secret'})}));}};
+ await assert.rejects(c.wordAudioSynthesize('apple',config),/11200/);
+ c.WebSocket=class extends Socket {send(){queueMicrotask(()=>this.onclose());}};await assert.rejects(c.wordAudioSynthesize('apple',config),/中断/);
+});
+test('对象存储使用固定词条对象键,MP3上传可重试覆盖同一文件',async()=>{
+ const c=context();vm.runInContext(code,c);let requestUrl,request;c.fetch=async(url,options)=>{requestUrl=url;request=options;return{ok:true}};
+ const url=await c.wordAudioUpload('hash','SUQzYWJj',config);assert.equal(url,'https://cdn.test/co/audio/words/hash.mp3');assert.match(requestUrl,/X-Amz-Signature=/);assert.equal(request.method,'PUT');assert.equal(request.headers['Content-Type'],'audio/mpeg');assert.equal(Buffer.from(request.body).toString(),'ID3abc');
+});
+test('无WebSocket的云函数通过受保护适配器合成,并验证返回音频',async()=>{
+ const c=context();vm.runInContext(code,c);c.WebSocket=undefined;let sent;
+ const bridge={...config,workerUrl:'https://worker.test/synthesize',workerToken:'server-only-secret'};
+ c.fetch=async(url,options)=>{assert.equal(url,bridge.workerUrl);assert.equal(options.headers.Authorization,'Bearer '+bridge.workerToken);sent=JSON.parse(options.body);return{ok:true,json:async()=>({audio:'SUQzYWJj'})};};
+ assert.equal(await c.wordAudioSynthesize('apple',bridge),'SUQzYWJj');assert.equal(sent.voice,config.voice);assert.equal(sent.word,'apple');
+ c.fetch=async()=>({ok:true,json:async()=>({audio:'bm90LWF1ZGlv'})});await assert.rejects(c.wordAudioSynthesize('apple',bridge),/无效音频/);
+});
+test('没有S3配置时复用现有媒体存储;客户端通用上传规则保持独立',async()=>{
+ const c=context();vm.runInContext(code,c);let params;
+ c.uploadAppMedia=async(input,types)=>{params=input;assert.equal(types['audio/mpeg'],'');return 'https://legacy.test/file.mp3';};
+ assert.equal(await c.wordAudioUpload('hash','SUQzYWJj',{...config,storage:{}}),'https://legacy.test/file.mp3');assert.equal(params.fileName,'hash.mp3');
+ await assert.rejects(c.wordAudioUpload('hash','bm90LWF1ZGlv',{...config,storage:{}}),/无效/);
+});
+test('租约过期后接管保存过的音频,旧任务不能覆盖新任务;新词遵守全局并发上限',async()=>{
+ await reset();const claim=(id,token)=>one('SELECT xs_word_audio_claim($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) result',['co',id,'student',token,'apple','a','voice',500,30,1]);
+ assert.equal((await claim('key1','old-token')).result.state,'claimed');assert.equal((await claim('key2','next-token')).result.state,'pending');
+ await db.exec('UPDATE "WordAudioCache" SET audio=\'SUQzYWJj\',"leaseUntil"=now()-interval \'1 second\' WHERE "objectId"=\'key1\'');
+ const resumed=(await claim('key1','new-token')).result;assert.equal(resumed.state,'claimed');assert.equal(resumed.audio,'SUQzYWJj');
+ const oldWrite=await db.query('UPDATE "WordAudioCache" SET url=\'stale\' WHERE "objectId"=\'key1\' AND lease=\'old-token\' RETURNING "objectId"');assert.equal(oldWrite.rows.length,0);
+ const events=(await db.query('SELECT "providerCall" FROM "WordAudioAttempt" ORDER BY "createdAt"')).rows;assert.deepEqual(events.map(row=>row.providerCall),[true,false]);
+});

+ 53 - 0
scripts/word-audio-worker.mjs

@@ -0,0 +1,53 @@
+#!/usr/bin/env node
+// Node 22+ adapter for cloud runtimes without WebSocket. The gateway owns caching and access control.
+import {createServer} from 'node:http';
+import {timingSafeEqual,webcrypto} from 'node:crypto';
+import {readFileSync,statSync} from 'node:fs';
+import {homedir} from 'node:os';
+import {resolve} from 'node:path';
+import {pathToFileURL} from 'node:url';
+import vm from 'node:vm';
+
+export function createWordAudioWorker(config,synthesize) {
+ if(String(config.token||'').length<32)throw Error('TTS worker token must have at least 32 characters');
+ const expected=Buffer.from('Bearer '+config.token);let active=0;
+ const maxConcurrent=Math.max(1,Math.min(20,Number(config.concurrency)||1));
+ const recent=[];
+ const send=(res,status,body)=>{res.writeHead(status,{'Content-Type':'application/json','Cache-Control':'no-store'});res.end(JSON.stringify(body));};
+ const server=createServer(async(req,res)=>{
+  if(req.url==='/healthz'&&req.method==='GET')return send(res,200,{ok:true});
+  if(req.url!=='/synthesize'||req.method!=='POST')return send(res,404,{error:'not_found'});
+  const supplied=Buffer.from(String(req.headers.authorization||''));
+  if(supplied.length!==expected.length||!timingSafeEqual(supplied,expected))return send(res,401,{error:'unauthorized'});
+  if(!/^application\/json(?:;|$)/i.test(String(req.headers['content-type']||'')))return send(res,415,{error:'json_required'});
+  const chunks=[];let bytes=0;
+  try{for await(const chunk of req){bytes+=chunk.length;if(bytes>4096){send(res,413,{error:'too_large'});req.destroy();return;}chunks.push(chunk);}}catch(_){return;}
+  let input;try{input=JSON.parse(Buffer.concat(chunks).toString('utf8'));}catch(_){return send(res,400,{error:'invalid_json'});}
+  const word=String(input.word||'').normalize('NFC').trim().replace(/\s+/g,' ');
+  if(!word||word.length>200||/[\x00-\x1f<>\[\]{}]/.test(word)||input.voice!==config.voice||Number(input.speed)!==50)return send(res,400,{error:'invalid_word_or_voice'});
+  const now=Date.now();while(recent.length&&recent[0]<now-60000)recent.shift();
+  if(active>=maxConcurrent||recent.length>=60)return send(res,429,{error:'busy'});
+  active++;recent.push(now);
+  try{const audio=await synthesize(word);send(res,200,{audio});}
+  catch(_){send(res,502,{error:'synthesis_failed'});}
+  finally{active--;}
+ });
+ server.requestTimeout=10000;server.headersTimeout=10000;server.keepAliveTimeout=5000;
+ return server;
+}
+
+async function main(){
+ if(typeof WebSocket==='undefined')throw Error('请使用 Node.js 22 或更新版本');
+ const file=resolve(process.env.XIAOSHU_TTS_CONFIG_FILE||homedir()+'/.config/xiaoshu/xfyun-word-audio.json');
+ if(statSync(file).mode&0o077)throw Error('配置文件权限须为600');
+ const settings=JSON.parse(readFileSync(file,'utf8'));
+ const ctx=vm.createContext({crypto:webcrypto,WebSocket,TextEncoder,URL,URLSearchParams,Uint8Array,btoa,atob,AbortSignal,setTimeout,clearTimeout,Parse:{Config:{get:async()=>({get:key=>settings[key]})}},fail:(_code,message)=>{throw Error(message);}});
+ vm.runInContext(readFileSync(new URL('./cloud/word-audio.js',import.meta.url),'utf8'),ctx);
+ const config=await ctx.wordAudioConfig();
+ if(!config.appId||!config.apiKey||!config.apiSecret||!config.voice)throw Error('讯飞配置不完整');
+ const server=createWordAudioWorker({token:settings.XIAOSHU_TTS_WORKER_TOKEN,voice:config.voice,concurrency:config.concurrency},word=>ctx.wordAudioSynthesize(word,config));
+ const port=Number(process.env.PORT)||4381,host=process.env.HOST||'127.0.0.1';
+ server.listen(port,host,()=>console.log(JSON.stringify({listening:true,host,port,voice:config.voice,secretsPrinted:false})));
+ for(const signal of ['SIGTERM','SIGINT'])process.on(signal,()=>server.close(()=>process.exit(0)));
+}
+if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href)main().catch(()=>{console.error('TTS适配器启动失败,请检查Node版本和本机私密配置。');process.exitCode=1;});

Некоторые файлы не были показаны из-за большого количества измененных файлов