Преглед изворни кода

fix: 登录 CORS 拦截(白名单式)+ 历史会话不再重放打字动画

## 1. 线上无法登录(P0)
现象:`/api/fmode/mobile` 被网关拒绝
  Access-Control-Allow-Headers 白名单里没有 x-parse-application-id
  → ERR_FAILED,用户完全无法登录

根因:`rawFetch()` 的判据是「url === FUNCTION_API」,
只覆盖云函数入口,登录接口走了"带 X-Parse-*"的分支。

两次事故:
  ① 只剥离云函数入口     → /api/apig/message 被拦
  ② 改成「/api/ 路径」   → /api/fmode/mobile 仍被拦(本次)

教训:枚举「哪些不带」永远会漏,必须反过来枚举「哪些带」。
现在改为**白名单式**:只有 Parse REST(/parse/...)才带 X-Parse-Application-Id,
其余一切(含未来新增接口)默认不带。
Parse REST 的 X-Parse-Session-Token 不受影响(走 needsParseHeaders=true 分支)。

新增 2 条防回归测试:登录接口、云函数入口均不得带 X-Parse-*。

## 2. 查看历史会话时文字有打字动画
用户反馈:「只有输出的时候才该有动画,查看历史记录应该直接显示」

根因:组件新建时 shownMap 为空 → advance() 把所有块当「新出现的块」
从 0 开始推 → 整篇逐字重放。

修法:advance() 增加 settled 参数,**已结束的块一律满格**,
只有 live 块从 0 开始演。回合级 !live 的快速路径保留,块级作兜底
(同回合里另一块还在跑时,已结束的块也不重放)。

## 验证
tsc ✅ · 1655 条测试全过 ✅ · 已部署(main-TZVZKFNE.js)

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct пре 1 час
родитељ
комит
fb77d745fa

+ 48 - 3
src/app/components/domain/assistant-turn/assistant-turn.ts

@@ -490,7 +490,8 @@ export class AssistantTurnComponent implements OnDestroy {
     effect(() => {
       const t = this.turn();
       const targets = this.targetsOf(t);
-      untracked(() => this.advance(targets));
+      const settled = this.settledKeys(t);
+      untracked(() => this.advance(targets, settled));
     });
 
     /*
@@ -537,6 +538,28 @@ export class AssistantTurnComponent implements OnDestroy {
     return out;
   }
 
+  /**
+   * 已结束的块 key 集合(2026-11-11 用户反馈)。
+   *
+   * 用户原话:「打开其他的对话记录的时候,文字还会有动画效果,这个是不对的。
+   * 只有我们在输出的时候才会有动画;查看会话历史记录时应该直接显示。」
+   *
+   * ## 根因
+   *
+   * 组件是新建的,`shownMap` 恒为空 → `advance()` 把所有块都当「新出现的块」
+   * 从 0 开始推 → **历史会话打开时整篇逐字重放**。
+   *
+   * ## 修法
+   *
+   * 推进时,**不属于 `live` 的块直接给满格**,只有 live 块才从 0 演。
+   * 这样:
+   *   · 打开历史会话 → 所有块都已结束 → 一次到位,无动画
+   *   · 发新消息 → 新块 live → 正常逐段生成
+   */
+  private settledKeys(t: AssistantTurn): Set<string> {
+    return new Set(t.blocks.filter((b) => !b.live).map((b) => b.key));
+  }
+
   /**
    * 推进各块的显示长度。
    *
@@ -556,18 +579,33 @@ export class AssistantTurnComponent implements OnDestroy {
    * · 不是实时输出(历史消息、刷新后回放)→ 一次到位
    * · 内容变短(重新生成)→ 直接跳转,不倒退
    */
-  private advance(targets: Record<string, number>): void {
+  private advance(targets: Record<string, number>, settled: Set<string> = new Set()): void {
     this.cancel();
 
     const live = this.turn().live;
     const cur = this.shownMap();
 
+    /*
+     * 🔴 回合一结束 → **所有块**一次到位,不逐字重放(2026-11-11)。
+     *
+     * 用户反馈:「打开别的对话记录时文字还在动画,只有输出时才该有动画。」
+     * 除了整回合判 live,下面还有一层按块的兜底(`settled`),
+     * 两者叠加保证:历史会话里任何已结束的块都不会演动画。
+     */
     if (this.reduceMotion || !live) {
-      // 历史回合直接完整显示,不逐字重放
       this.shownMap.set({ ...targets });
       return;
     }
 
+    /*
+     * 块级兜底:即使回合整体仍 live(比如同回合里另一块还在跑),
+     * **已经结束的块**也直接满格 —— 不能因为旁边在动就把历史内容重放一遍。
+     */
+    const preSettled: Record<string, number> = {};
+    for (const k of settled) {
+      if (targets[k] !== undefined) preSettled[k] = targets[k];
+    }
+
     /*
      * 🔴 实时回合的**首次**求值:从 0 开始逐字推进(2026-11-08 修复)。
      *
@@ -582,10 +620,17 @@ export class AssistantTurnComponent implements OnDestroy {
     if (Object.keys(cur).length === 0) {
       const zero: Record<string, number> = {};
       for (const k of Object.keys(targets)) zero[k] = 0;
+      /* 已结束的块从满格起,只有 live 块从 0 开始演 */
+      Object.assign(zero, preSettled);
       this.shownMap.set(zero);
     }
 
     const base = this.shownMap();
+    /*
+     * 已结束的块无论之前推到哪,一律对齐到目标长度(不残留半截)。
+     * 放在 base 之后、reset 之前,保证下面的「变短不收缩」不会把它们截回去。
+     */
+    Object.assign(base, preSettled);
 
     /*
      * 目标变化:增长要动画;变短**不收缩**(2026-11-08)。

+ 34 - 0
src/app/core/api/parse-api.spec.ts

@@ -90,6 +90,40 @@ describe('ParseApi', () => {
       const headers = calls[0].init.headers as Record<string, string>;
       expect(headers['X-Parse-Session-Token']).toBeUndefined();
     });
+
+    /*
+     * 🔴 CORS 事故防回归(2026-11-11 线上两次)。
+     *
+     * server.fmode.cn 的网关有多个实例,预检响应不一致:
+     *   固定白名单里**没有任何 `x-parse-*`**,命中那一台就把整条请求拦死,
+     * 浏览器报 `ERR_FAILED`。
+     *
+     * 事故经过:
+     *   ① 只剥离「云函数入口」→ `/api/apig/message` 被拦
+     *   ② 改成「`/api/` 路径」→ 登录接口 `/api/fmode/mobile` 仍被拦,
+     *      **用户完全无法登录**(就是本节这条用例要守住的场景)
+     *
+     * 现在的规则是**白名单式**:只有 Parse REST(`/parse/...`)才带,
+     * 其余一律不带。新增任何接口默认安全。
+     */
+    it('🔴 登录接口 /api/fmode/mobile 不得带 X-Parse-Application-Id', async () => {
+      stubFetch([{ body: { code: 200, data: { token: 'r:x' } } }]);
+      await api.loginWithMobile('13800000000', '123456').catch(() => undefined);
+      const loginCall = calls.find((c) => c.url.includes('/api/fmode/mobile'));
+      expect(loginCall).withContext('登录接口应被调用').toBeDefined();
+      const headers = (loginCall!.init.headers ?? {}) as Record<string, string>;
+      expect(headers['X-Parse-Application-Id'])
+        .withContext('带了就会被网关的固定白名单拒掉 → 用户无法登录')
+        .toBeUndefined();
+    });
+
+    it('🔴 云函数入口不得带任何 X-Parse-* 头', async () => {
+      stubFetch([{ body: { code: 200, agents: [] } }]);
+      await api.fetchAgents();
+      const headers = (calls[0].init.headers ?? {}) as Record<string, string>;
+      const parseKeys = Object.keys(headers).filter((k) => k.toLowerCase().startsWith('x-parse-'));
+      expect(parseKeys).withContext('云函数鉴权靠 body.token,不靠头').toEqual([]);
+    });
   });
 
   // ---------- 错误文案(msg → mess → message) ----------

+ 32 - 11
src/app/core/api/parse-api.ts

@@ -893,22 +893,43 @@ export class ParseApi extends FmodeApi {
 
   private rawFetch(url: string, init: RequestInit): Promise<Response> {
     /*
-     * 🔴 云函数入口**不带任何 `X-Parse-*` 头**(2026-11-11 线上 CORS 实测)。
+     * 🔴 **默认不带任何 `X-Parse-*` 头**,只有 Parse REST 白名单才带。
      *
-     * 服务端网关有多个实例、预检响应不一致:固定白名单里没有任何 `x-parse-*`,
-     * 命中那一台就把整条请求拦掉(实测 10 次里 6 次被拒)。
-     * 逐个删头是治标 —— 这里统一剥离。
+     * ## 为什么(两次线上的 CORS 事故)
      *
-     * 云函数鉴权只需 URL 的函数 id + body 里的 `token`(已实测)。
-     * 其它接口(Parse REST、登录等)照旧带 —— 它们确实需要。
+     * 服务端网关有多个实例、预检响应不一致:
+     *   Access-Control-Allow-Headers: *                → 放行(4/10 次)
+     *   Authorization,DNT,...,Content-Type,Range       → 拒绝(6/10 次)
+     * 固定白名单里**没有任何 `x-parse-*`**,命中那一台就把整条请求拦掉。
+     *
+     * ## 踩过的坑(两次,都是上线后才炸)
+     *
+     * ① 第一版只剥离「云函数入口」→ `/api/apig/message` 被拦
+     * ② 第二版改成「`/api/` 路径」→ 新接口只要不在这条规则里,照样会被拦
+     *
+     * **教训:枚举「哪些不带」永远会漏,必须反过来枚举「哪些带」。**
+     *
+     * ## 现在的规则(白名单式,默认安全)
+     *
+     * | 目标 | 带 X-Parse-* |
+     * |---|---|
+     * | Parse REST(`/parse/users/me`、`/parse/roles` 等) | ✅ 带(Parse Server 硬性要求) |
+     * | **其它一切**(`/api/functions`、`/api/fmode/*`、`/api/apig/*`、未来新增) | ❌ 不带 |
+     *
+     * 这样**新增任何接口都默认不会被 CORS 拦**,除非它确实属于 Parse REST。
+     * `/api/*` 下的端点鉴权都在 body / query 里(`token` 字段),不依赖这些头。
      */
-    const isFn = url === FUNCTION_API;
+    const needsParseHeaders = /\/parse(\/|$)/.test(url);
     const incoming = (init.headers as Record<string, string>) ?? {};
-    const headers: Record<string, string> = isFn
-      ? {}
-      : { 'X-Parse-Application-Id': PARSE_APP_ID };
+    const headers: Record<string, string> = needsParseHeaders
+      ? { 'X-Parse-Application-Id': PARSE_APP_ID }
+      : {};
     for (const [k, v] of Object.entries(incoming)) {
-      if (isFn && k.toLowerCase().startsWith('x-parse-')) continue;
+      /*
+       * 非 Parse REST:调用方误传的 `X-Parse-*` 也一律丢掉 ——
+       * 宁可鉴权失败(有明确报错),也不要被 CORS 静默拦成 ERR_FAILED。
+       */
+      if (!needsParseHeaders && k.toLowerCase().startsWith('x-parse-')) continue;
       headers[k] = v;
     }
     /*