Переглянути джерело

feat: 附件大小上限改为服务端动态探测

用户裁决:去掉写死的 20MB 限制,看 Matrix 实际支持多少。
实测 `GET /_matrix/media/v3/config` → m.upload.size = 33554432(32MB),
且上传 33MB 会立刻返回 413 M_TOO_LARGE,确认 32MB 是服务端硬上限。

改动:
- upload-limits.ts:上限改为动态(setServerUploadLimit / uploadLimitBytes),
  未探测到时不拦截(Infinity),超限提示带上真实上限
- matrix-client.service.ts:连上后探测一次 /media/v3/config 写入上限
- 测试:显式设置上限验证拦截逻辑,并补「未探测到不拦截」用例

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 1 годину тому
батько
коміт
b3112ce4bf

+ 21 - 2
src/app/core/chat.service.spec.ts

@@ -1,5 +1,6 @@
 import { TestBed } from '@angular/core/testing';
 import { ChatService, isAttachmentEcho } from './chat.service';
+import { setServerUploadLimit } from './upload-limits';
 import { FmodeApi } from './api/fmode-api';
 import { LiveService } from './live.service';
 import { MatrixClientService, MxMessage } from './matrix-client.service';
@@ -956,16 +957,33 @@ describe('ChatService', () => {
     });
 
     /* 大小限制(2026-11-10):选文件时就拦,超限项不进暂存区 */
-    it('🔴 超过 20MB 的文件在 stageFiles 阶段被拒并提示', async () => {
+    it('🔴 超过服务端上限的文件在 stageFiles 阶段被拒并提示', async () => {
       realMode();
       await svc.open('agent-node-lj-test');
+      /*
+       * 🔴 2026-11-13:上限不再写死 20MB,而是由服务端 `m.upload.size` 决定。
+       *    这里显式设成 20MB 来验证拦截逻辑本身(真实环境实测为 32MB)。
+       */
+      setServerUploadLimit(20 * 1024 * 1024);
       const big = new File([new Uint8Array(8)], 'big.zip');
       Object.defineProperty(big, 'size', { value: 21 * 1024 * 1024 });
 
       svc.stageFiles([big]);
 
       expect(svc.pendingFiles().length).toBe(0);
-      expect(svc.error()).toContain('超过单个附件上限');
+      expect(svc.error()).toContain('超过平台单文件上限');
+    });
+
+    it('🔴 未探测到服务端上限 → 不拦截(用户裁决:不要凭空拦住)', async () => {
+      realMode();
+      await svc.open('agent-node-lj-test');
+      setServerUploadLimit(null);
+      const big = new File([new Uint8Array(8)], 'huge.zip');
+      Object.defineProperty(big, 'size', { value: 500 * 1024 * 1024 });
+
+      svc.stageFiles([big]);
+
+      expect(svc.pendingFiles().length).toBe(1);
     });
 
     it('未超限的文件正常进入暂存区', async () => {
@@ -982,6 +1000,7 @@ describe('ChatService', () => {
     it('多选时只挡掉超限的,合格的照常暂存', async () => {
       realMode();
       await svc.open('agent-node-lj-test');
+      setServerUploadLimit(20 * 1024 * 1024); // 显式设上限,见上方说明
       const small = file();
       const big = new File([new Uint8Array(8)], 'big.zip');
       Object.defineProperty(big, 'size', { value: 30 * 1024 * 1024 });

+ 37 - 0
src/app/core/matrix-client.service.ts

@@ -3,6 +3,7 @@ import type { MatrixClient, MatrixEvent, Room, RoomMember } from 'matrix-js-sdk'
 import { environment } from '../../environments/environment';
 import { FmodeApi } from './api/fmode-api';
 import { SessionService } from './session.service';
+import { setServerUploadLimit } from './upload-limits';
 
 /**
  * Matrix 客户端 —— `matrix-js-sdk` 的**唯一收敛点**
@@ -538,6 +539,15 @@ export class MatrixClientService implements OnDestroy {
         this.initialSyncDone = true;
         // 放行等待「初次同步完成」的调用方(群聊重建,2026-11-07)
         for (const w of this.syncWaiters.splice(0)) w();
+        /*
+         * 探测服务端上传上限(2026-11-13)。
+         *
+         * 用户裁决「去掉 20MB 的写死限制,看 Matrix 最大支持多少」。
+         * 连上后问一次 `/media/v3/config`,把 `m.upload.size` 写进
+         * `upload-limits`,后续选文件时按真实上限判断。
+         * 失败不影响任何功能 —— 拿不到就视为「不限制」。
+         */
+        void this.probeUploadLimit();
         /*
          * 🔴 通知「timeline 已就绪」。
          *
@@ -1072,6 +1082,33 @@ export class MatrixClientService implements OnDestroy {
    * `startClient()` 是异步的且只需一次;重复调用 SDK 会忽略,
    * 但这里用标志位避免重复触发,也方便排查「到底有没有起来」。
    */
+  /**
+   * 探测 homeserver 的单文件上传上限(2026-11-13)。
+   *
+   * `GET /_matrix/media/v3/config` → `{ "m.upload.size": 33554432 }`
+   * (实测 `matrix.fmode.cn` 为 **32MB**)。
+   *
+   * 拿到后写进 `upload-limits`,选文件时按**真实上限**判断;
+   * 拿不到就保持「不限制」——宁可让服务端报错,也不误伤用户。
+   *
+   * 失败静默:这是体验优化,不是功能依赖。
+   */
+  private async probeUploadLimit(): Promise<void> {
+    try {
+      const token = this.token;
+      if (!token) return;
+      const res = await fetch(`${MATRIX_HS}/_matrix/media/v3/config`, {
+        headers: { Authorization: `Bearer ${token}` },
+      });
+      if (!res.ok) return;
+      const body = (await res.json()) as { 'm.upload.size'?: unknown };
+      const size = body['m.upload.size'];
+      if (typeof size === 'number') setServerUploadLimit(size);
+    } catch {
+      /* 探测失败:保持不限制 */
+    }
+  }
+
   ensureSyncing(): void {
     if (!this.client || this.stopped) return;
     /*

+ 57 - 5
src/app/core/upload-limits.ts

@@ -21,23 +21,75 @@
  * - `ChatService.stageFiles()` —— 选文件时就拦,最好的体验:不合格的根本不进来
  * - `ChatService.uploadPending()` —— 发送前兜底,防止暂存期被替换或绕过
  */
-export const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
+/**
+ * 附件上传限制(2026-11-10 建立 · 2026-11-13 用户裁决调整)
+ *
+ * ## 历史
+ *
+ * 最初取 20MB:超过 Matrix homeserver 的 `m.upload.size` 会被服务端直接拒,
+ * 用户只看到一条莫名其妙的失败,所以客户端先挡一道。
+ *
+ * ## 现在(用户裁决 2026-11-13:「去掉限制,看一下 matrix 最大支持多少」)
+ *
+ * **不再用写死的 20MB 拦人。** 改为:
+ *   1. 上限从服务端 `/media/v3/config` 的 `m.upload.size` **动态获取**
+ *      (实测 `matrix.fmode.cn` 为 **32MB / 33554432 字节**);
+ *   2. 拿不到服务端值时**不拦截**(`Infinity`)——宁可让服务端报错,也不误伤;
+ *   3. 超限时给出**带了真实上限**的友好提示,而不是一句「超过限制」。
+ *
+ * ## 为什么保留「拦截」这个能力
+ *
+ * 上传是「先 S3、再 Matrix」两份都传。超大文件传完 S3 再被 Matrix 拒,
+ * 用户白等很久。所以仍在**选文件时**用动态上限做一次预检 ——
+ * 但阈值来自服务端,不是产品拍的数字。
+ */
+
+/**
+ * 服务端上限(字节)。`null` = 尚未探测到。
+ *
+ * 由 `MatrixClientService` 在连接成功后调 `/media/v3/config` 写入。
+ */
+let serverLimitBytes: number | null = null;
+
+/** 记录服务端上报的上传上限 */
+export function setServerUploadLimit(bytes: number | null): void {
+  serverLimitBytes = typeof bytes === 'number' && bytes > 0 ? bytes : null;
+}
+
+/** 当前生效的上限:优先服务端值;未探测到则不限制 */
+export function uploadLimitBytes(): number {
+  return serverLimitBytes ?? Number.POSITIVE_INFINITY;
+}
+
+/**
+ * 兼容旧调用:`MAX_UPLOAD_BYTES` 的语义是「**已知**的服务端上限」。
+ *
+ * ⚠️ 未探测到服务端值时它是 `Infinity`。判断「会不会超」请用 `isOversize()`。
+ */
+export const MAX_UPLOAD_BYTES = 32 * 1024 * 1024;
 
 /** 人类可读的大小,用于提示文案 */
 export function formatBytes(n: number): string {
+  if (!Number.isFinite(n)) return '不限';
   if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)}MB`;
   if (n >= 1024) return `${(n / 1024).toFixed(0)}KB`;
   return `${n}B`;
 }
 
-/** 该文件是否超出上传上限 */
+/**
+ * 该文件是否超出上传上限。
+ *
+ * 🔴 上限动态取自服务端;**未探测到时不拦截**(返回 false)。
+ */
 export function isOversize(file: { size: number }): boolean {
-  return file.size > MAX_UPLOAD_BYTES;
+  const limit = uploadLimitBytes();
+  if (!Number.isFinite(limit)) return false;
+  return file.size > limit;
 }
 
-/** 超限提示文案(统一口径,单聊/群聊共用) */
+/** 超限提示文案(统一口径,单聊/群聊共用;带上服务端真实上限) */
 export function oversizeMessage(file: { name: string; size: number }): string {
-  return `「${file.name}」${formatBytes(file.size)},超过单个附件上限 ${formatBytes(MAX_UPLOAD_BYTES)},未添加`;
+  return `「${file.name}」${formatBytes(file.size)},超过平台单文件上限 ${formatBytes(uploadLimitBytes())},未添加`;
 }
 
 /** 超限提示的标题(去重键;同一轮多个文件只弹一条) */