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

chore(dms): DMS token 改成一处配好、之后不用再填(免重启)

用户反馈「每次都要重新填 token 太麻烦了」。查下来有两层原因:

1. token 是外部 OAuth 签发的 JWT,约 24 小时过期,而 vite 的 loadEnv 只在 dev server
   启动时读一次 —— 以前换 token 必须「改 .env.development.local + 重启」。
2. 顺带发现真问题:.env.development.local 里当时写成了
     DMS_TOKEN=
     eyJ0eXAi...
   token 在下一行,dotenv 读出来是空串(实测 DMS_TOKEN 长度 = 0)——
   等于这个文件里的 token 从来没生效过,重启 dev server 就会静默降级。

做法:
- 新增 harness/tools/dms-token.mjs:唯一的 token 读取口径 —— 显式参数 → DMS_TOKEN 环境变量
  → 本机单行文件 .dms-token(gitignore)。JWT 状态描述也在这里,供代理与脚本共用。
- 新增 harness/tools/set-dms-token.mjs 与 npm run dms:token:无参数时读剪贴板
  (浏览器复制完直接跑)、--check 只看状态、非 JWT 拒绝写入。
- vite.config.ts 的 /dms-api/ 代理改用 configure + proxyReq 每请求现读 token
  (原来是启动时定死的静态 headers),并加了 [dms] 告警:没 token / 已过期 /
  2 小时内将过期都会在控制台明确说明,不再是静默失败。
- .dms-token 加入 .gitignore;清理 .env.development.local 里那两行坏掉的配置
  (换成指向新机制的注释);三个直接打 DMS 的探测脚本也改走同一口径。

验证:
- 每请求现读:假 token → 代理打 DMS 返 212;换回真 token(未重启 dev server)→ 200
- 剪贴板路径:假 token 放剪贴板 → npm run dms:token → 写入的确是该 token
- 告警:移走 .dms-token 另起 dev server(8099,已关)→ 控制台出现一次明确提示
- 端到端:verify-dms-chat-storage 经代理跑真实 DMS 43/43,清理后残留 0 行
- npm run build、validate-harness、流式 131/131 均通过

方案 C(脚本自动登录)待用户提供登录接口/入口地址后实施。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 1 день тому
батько
коміт
2f755c8e50

+ 3 - 0
.gitignore

@@ -10,6 +10,9 @@ node_modules
 /harness/tools/_*.mjs
 
 
+# DMS token(本机单行文件,由 npm run dms:token 写入;带凭据,不入库)
+/.dms-token
+
 # local env files
 .env.local
 .env.*.local

+ 11 - 0
harness/docs/reference/DMS_API.md

@@ -43,6 +43,17 @@ token 通过环境变量传递,**不要写进任何文件**:
 export DMS_TOKEN='...'
 ```
 
+> 📌 **本仓库开发环境的口径(2026-09-21 起)**:token 放在仓库根的**单行文件 `.dms-token`**
+> (按 `.gitignore` 不入库),由 `npm run dms:token` 写入(无参数时从**剪贴板**读)。
+> vite 代理**每个请求现读**它,所以换 token **不用重启 dev server**;
+> harness 的 DMS 脚本也默认读它(`harness/tools/dms-token.mjs` 是唯一读取口径)。
+> 上面那句「不要写进任何文件」的落地口径是**不写进任何会入库的文件** ——
+> 与 `.env.*.local` 同一性质。
+>
+> ⚠️ token 约 **24 小时**过期(JWT 的 `exp`)。过期后 DMS 读写会失败:
+> dev server 控制台会打一条明确的 `[dms]` 告警(以前是静默失败)。
+> 查看当前 token 状态:`npm run dms:token -- --check`。
+
 ### 1.2 请求格式
 
 | 场景 | Content-Type |

+ 43 - 0
harness/progress.md

@@ -3170,3 +3170,46 @@ abort 对不上时不动);`npm run build` 通过。
 **⚠️ 待浏览器确认**:带卡片的问答应当是「思考中 → 卡片 → 正文逐字流出 → 参考资料」,
 **中间不再有整块重渲**。若仍看到重渲,请把控制台 `[answer-stream] compose` 一行发来
 (有这行 = 走了兜底重排,说明卡片没能提前上屏)。
+
+### 续 7:DMS token 不用再每次手填(用户:「每次都要重新填 token 太麻烦了」)
+
+**用户拍板的两个方案**:A 免重启 + 独立 token 文件(本轮做完);C 脚本自动登录(待拿到登录接口)。
+
+**现状查明(顺带发现一个真问题)**:token 是外部 OAuth 签发的 JWT,**约 24 小时过期**
+(当前这个 `exp` = 2026-09-21 21:40)。而 `.env.development.local` 里当时是:
+
+```
+DMS_TOKEN=
+eyJ0eXAi...        ← token 在**下一行**
+```
+
+`loadEnv` 读出来是**空串**(实测 `DMS_TOKEN 长度 = 0`)—— 也就是说这个文件里的 token
+**从来没生效过**;用户可能一直靠 dev server 启动时读到的**旧值**在跑,
+一旦重启就会静默降级。这解释了「为什么总觉得 token 填了没用」。
+
+**已完成(方案 A)**:
+- 新增 `harness/tools/dms-token.mjs` —— **唯一**的 token 读取口径:
+  **显式参数 → `DMS_TOKEN` 环境变量 → 本机文件 `.dms-token`**(JWT 状态描述也在这里)
+- 新增 `harness/tools/set-dms-token.mjs` + `npm run dms:token`:
+  无参数时**读剪贴板**(浏览器复制完直接跑),`--check` 只看当前状态,非 JWT 一律拒绝写入
+- `vite.config.ts` 的 `/dms-api/` 代理改为 **`configure` + `proxyReq` 每请求现读** token
+  (原来是启动时读一次的静态 `headers`),并加了 `[dms]` 告警:**没 token / 已过期 / 2 小时内将过期**
+  都会在 dev server 控制台明确说出来(以前是静默失败)
+- `.dms-token` 加入 `.gitignore`;`.env.development.local` 里那两行坏掉的 `DMS_TOKEN=` 已清理,
+  换成指向新机制的注释
+- 三个直接打 DMS 的探测脚本(`dms-step0-verify` / `probe-dms-title` / `dms-delete-probe`)
+  不再强制手填 token,也走同一口径
+
+**运行过的验证**:
+- **每请求现读**(A 的核心):把假 token 写进 `.dms-token` → 经 dev server 代理打 DMS 返回
+  `212 无效token`;换回真 token(**全程没重启 dev server**)→ `200 成功` ✅
+- **剪贴板路径**:把假 token 放进剪贴板 → `npm run dms:token` → 文件内容确为剪贴板那个 ✅
+- **告警**:临时移走 `.dms-token` 另起一个 dev server(8099,测完已关)→ 控制台出现
+  「没有 DMS token,DMS 读写会失败…跑 `npm run dms:token`」且**只打一次** ✅
+- **端到端**:`verify-dms-chat-storage.mjs` 经代理跑真实 DMS **43/43 通过**(清理后残留 0 行)✅
+- `npm run build` 通过;`validate-harness` exit 0;流式 131/131(未受影响)
+
+**待办(方案 C)**:需要**登录接口**才能脚本化取 token。已确认:
+DMS 主机(`121.43.55.7:10081`)是纯 API、根路径 404,没有登录页;
+两个仓库的文档里都没写登录端点(DMS 项目的 RUNBOOK 也标着「尚未实测」)。
+**需要用户提供**登录那个网站的地址,或(最快)在登录页 F12 → Network → 复制登录请求为 cURL。

+ 17 - 0
harness/tools/README.md

@@ -75,6 +75,23 @@ npx esbuild harness/tools/_entry-company-classify.ts --bundle --format=esm \
 | `_coordinator.mjs` | `verify-answer-stream.mjs` | ❌ 纯逻辑(增量规整器/状态机)+ 假 SSE 喂真实协调器(F073 正文流式) |
 | `_coordinator.mjs` | `verify-answer-stream-payload.mjs` | ❌ 回放**真实 SSE 抓包**(协调器 + 应用侧组装);默认用 `fixtures/answer-stream-tail.payload.md` |
 
+### DMS token:一处配好,之后不用再填
+
+`dms-token.mjs` 是**唯一**的 token 读取口径(vite 代理与下面几个脚本共用):
+**显式参数 → `process.env.DMS_TOKEN` → 本机文件 `.dms-token`**。
+
+| 文件 | 用途 |
+|---|---|
+| `dms-token.mjs` | 读取口径 + JWT 状态描述(不写文件) |
+| `set-dms-token.mjs` | 写入 `.dms-token`;`npm run dms:token`(无参数时**读剪贴板**)、`--check` 只看状态 |
+
+`.dms-token` 是仓库根的**单行**文件(gitignore)。vite 代理**每个请求现读**,
+所以换 token 之后 **dev server 不用重启** —— 这正是它存在的理由(以前要改 `.env.development.local` 再重启)。
+
+> ⚠️ 2026-09-21 发现过一次「填了 token 却不生效」:`.env.development.local` 里写成了
+> `DMS_TOKEN=` 换行后再跟 token,dotenv 读出来是**空串**。这类错误很难看出来 ——
+> 用 `npm run dms:token -- --check` 一眼就能确认当前用的是谁、什么时候过期。
+
 ### 载荷夹具 `fixtures/`
 
 `fixtures/answer-stream-tail.payload.md` 是一份**合成**的原始 SSE 载荷,形态取自真抓包:

+ 4 - 2
harness/tools/dms-delete-probe.mjs

@@ -7,12 +7,14 @@
  *       —— “删除”很可能是把 state 改成 4,而不是物理删除。
  *
  * 本脚本会:造测试行 → 逐一试候选形态 → 校验行是否真的消失 → 清理残留。
- * 用法:node harness/tools/dms-delete-probe.mjs <DMS_TOKEN>
+ * 用法:node harness/tools/dms-delete-probe.mjs [DMS_TOKEN]   # 不传则从 env / .dms-token 取
  */
 
+import { readDmsToken, DMS_TOKEN_FILE } from './dms-token.mjs';
+
 const HOST = process.env.DMS_HOST || '121.43.55.7:10081';
 const BASE = `http://${HOST}/dms`;
-const TOKEN = process.argv[2] || '';
+const TOKEN = readDmsToken(process.argv[2]);
 const COL = '1887';
 const MODEL = '2034';
 const TAG = `deltest_${Date.now().toString(36)}`;

+ 9 - 3
harness/tools/dms-step0-verify.mjs

@@ -8,15 +8,21 @@
  *   ② addContent 返回体里有没有记录 uuid(有则省掉一次反查)
  *   ③ timestamp 写入格式、states 默认值、长文本往返
  *
- * 怎么跑(在项目根目录;token 不落盘,从参数读):
- *   node harness/tools/dms-step0-verify.mjs <DMS_TOKEN>
+ * 怎么跑(在项目根目录):
+ *   node harness/tools/dms-step0-verify.mjs              # token 自动取(见下)
+ *   node harness/tools/dms-step0-verify.mjs <DMS_TOKEN>  # 也可以显式传
+ *
+ * token 的取法见 `dms-token.mjs`:参数 → 环境变量 → 本机文件 `.dms-token`
+ * (由 `npm run dms:token` 写入,可从剪贴板读)。**不必每次手填**。
  *
  * 脚本会自行清理它写入的测试数据(session_id/record_id 以 step0_ 开头)。
  */
 
+import { readDmsToken, DMS_TOKEN_FILE } from './dms-token.mjs';
+
 const HOST = process.env.DMS_HOST || '121.43.55.7:10081';
 const BASE = `http://${HOST}/dms`;
-const TOKEN = process.argv[2] || process.env.DMS_TOKEN || '';
+const TOKEN = readDmsToken(process.argv[2]);
 
 const COL_SESSION = 1887;
 const MODEL_SESSION = 2034;

+ 61 - 0
harness/tools/dms-token.mjs

@@ -0,0 +1,61 @@
+/**
+ * DMS token 的**唯一**读取口径(vite 代理与 harness 脚本共用这一份)。
+ *
+ * 为什么要有这个模块:token 是外部 OAuth 签发的 JWT,**约 24 小时就过期**,
+ * 而它以前散在三处(`.env.development.local` 的 `DMS_TOKEN`、脚本的参数、
+ * `process.env.DMS_TOKEN`),每处都要人肉填一遍 —— 用户的原话是「每次都要重新填 token 太麻烦了」。
+ *
+ * 现在的顺序:**显式参数 → 环境变量 → 本机文件 `.dms-token`**。
+ * `.dms-token` 是被 gitignore 的单行文件,由 `npm run dms:token` 写入(可从剪贴板读)。
+ *
+ * vite 代理**每个请求**调用一次 `readDmsToken()`(见 vite.config.ts),
+ * 所以换 token 之后 **dev server 不用重启**。
+ */
+
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+/** 本机单行 token 文件(gitignore;仓库根) */
+export const DMS_TOKEN_FILE = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.dms-token');
+
+/**
+ * 取 token。`explicit` 通常来自命令行参数;都拿不到时返回空串(调用方自己决定是报错还是告警)。
+ * 每次调用都**现读文件**,所以运行中换 token 立即生效。
+ */
+export const readDmsToken = (explicit) => {
+  const fromArg = String(explicit || '').trim();
+  if (fromArg) return fromArg;
+  const fromEnv = String(process.env.DMS_TOKEN || '').trim();
+  if (fromEnv) return fromEnv;
+  try {
+    return readFileSync(DMS_TOKEN_FILE, 'utf8').trim();
+  } catch {
+    return '';
+  }
+};
+
+/** 只解 JWT payload(不校验签名;拿不到返回 null)—— 只为给出人能看懂的过期提示 */
+export const parseJwtPayload = (token) => {
+  const parts = String(token || '').split('.');
+  if (parts.length !== 3 || !parts[1]) return null;
+  try {
+    return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
+  } catch {
+    return null;
+  }
+};
+
+/** 人类可读的 token 状态:用户名 + 有效期 + 剩余时长(脚本与 vite 告警共用同一套措辞) */
+export const describeDmsToken = (token) => {
+  const payload = parseJwtPayload(token);
+  if (!payload) return '⚠️ 不像 JWT(应为 a.b.c 三段),请确认复制的是 access_token';
+  const who = payload.username || payload.userId || '未知用户';
+  const exp = Number(payload.exp);
+  if (!Number.isFinite(exp)) return `用户 ${who};payload 里没有 exp,无法判断有效期`;
+  const leftMs = exp * 1000 - Date.now();
+  const at = new Date(exp * 1000).toLocaleString('zh-CN');
+  return leftMs > 0
+    ? `用户 ${who};有效期至 ${at}(还剩 ${(leftMs / 3600000).toFixed(1)} 小时)`
+    : `用户 ${who};**已于 ${at} 过期**(${(Math.abs(leftMs) / 3600000).toFixed(1)} 小时前)`;
+};

+ 6 - 3
harness/tools/probe-dms-title.mjs

@@ -8,15 +8,18 @@
  *   ③ 改 `c_title`(改名路径)后,系统的 `title` 会不会跟着变?
  *   ④ 1889 的 `title` 现在恒为 "null",能否写成问题文本?
  *
- * 怎么跑(token 从参数读,不落盘):
- *   node harness/tools/probe-dms-title.mjs <DMS_TOKEN>
+ * 怎么跑:
+ *   node harness/tools/probe-dms-title.mjs               # token 自动取(参数 → env → .dms-token)
+ *   node harness/tools/probe-dms-title.mjs <DMS_TOKEN>   # 也可以显式传
  *
  * 脚本只写 `probe-title-*` 标记的数据,**结束时全部清理**。
  */
 
+import { readDmsToken, DMS_TOKEN_FILE } from './dms-token.mjs';
+
 const HOST = process.env.DMS_HOST || '121.43.55.7:10081';
 const BASE = `http://${HOST}/dms`;
-const TOKEN = process.argv[2] || '';
+const TOKEN = readDmsToken(process.argv[2]);
 if (!TOKEN) {
   console.error('用法:node harness/tools/probe-dms-title.mjs <DMS_TOKEN>');
   process.exit(2);

+ 74 - 0
harness/tools/set-dms-token.mjs

@@ -0,0 +1,74 @@
+/**
+ * 写入 DMS token(本机开发用),配套 `vite.config.ts` 的「每请求现读 `.dms-token`」。
+ *
+ * 为什么要有它:DMS token 是外部 OAuth 签发的 JWT,**约 24 小时就过期**;
+ * 以前只能手改 `.env.development.local` 再**重启 dev server**,麻烦且容易忘。
+ * 现在 token 放在单行文件 `.dms-token`(gitignore),代理每个请求现读 —— 换 token 不用重启。
+ *
+ * 用法:
+ *   npm run dms:token                # 从**剪贴板**读(浏览器里复制完直接跑,最省事)
+ *   npm run dms:token -- <token>     # 显式传(注意 -- 不能省)
+ *   npm run dms:token -- --check     # 只看当前 token 的状态,不写
+ *
+ * ⚠️ token 落在**被 gitignore 的本机文件**里,与 `.env.*.local` 同一性质:不随仓库分发。
+ * DMS 的文档要求「token 不写进任何文件」,本仓库的落地口径是
+ * **不写进任何会入库的文件**(见 harness/docs/reference/DMS_API.md 的鉴权节)。
+ */
+
+import { readFileSync, writeFileSync, existsSync } from 'node:fs';
+import { execFileSync } from 'node:child_process';
+import { DMS_TOKEN_FILE, readDmsToken, parseJwtPayload, describeDmsToken } from './dms-token.mjs';
+
+const args = process.argv.slice(2);
+const explicit = args.find((a) => !a.startsWith('--'));
+const checkOnly = args.includes('--check');
+
+if (checkOnly) {
+  const token = readDmsToken();
+  if (!token) {
+    console.error(`当前没有可用的 token:文件 ${DMS_TOKEN_FILE} 不存在或为空。`);
+    process.exit(1);
+  }
+  console.log(`${DMS_TOKEN_FILE}\n  ${describeDmsToken(token)}`);
+  process.exit(0);
+}
+
+/** 从剪贴板取(Windows / macOS / Linux 各一条命令;取不到返回空串) */
+const readClipboard = () => {
+  const cmds =
+    process.platform === 'win32'
+      ? [['powershell', ['-NoProfile', '-Command', 'Get-Clipboard']]]
+      : process.platform === 'darwin'
+        ? [['pbpaste', []]]
+        : [
+            ['xclip', ['-selection', 'clipboard', '-o']],
+            ['xsel', ['--clipboard', '--output']],
+          ];
+  for (const [cmd, cmdArgs] of cmds) {
+    try {
+      return execFileSync(cmd, cmdArgs, { encoding: 'utf8' }).trim();
+    } catch {
+      /* 换下一条命令 */
+    }
+  }
+  return '';
+};
+
+const token = String(explicit || readClipboard() || '').trim();
+if (!token) {
+  console.error('没拿到 token:既没有参数,剪贴板也是空的。');
+  console.error('用法:npm run dms:token -- <token>   (或先把 token 复制到剪贴板,再跑 npm run dms:token)');
+  process.exit(1);
+}
+
+const summary = describeDmsToken(token);
+if (!parseJwtPayload(token)) {
+  console.error(summary);
+  console.error('已中止,没有写入 —— 写进去也只会让 DMS 静默失败。');
+  process.exit(1);
+}
+
+writeFileSync(DMS_TOKEN_FILE, `${token}\n`, 'utf8');
+console.log(`已写入 ${DMS_TOKEN_FILE}`);
+console.log(`  ${summary}`);
+console.log('dev server **不用重启**(代理每个请求现读这个文件)。');

+ 3 - 2
package.json

@@ -10,7 +10,8 @@
     "build:test": "vite build --mode test",
     "build": "vite build --mode production ",
     "preview": "vite preview",
-    "test": "jest"
+    "test": "jest",
+    "dms:token": "node harness/tools/set-dms-token.mjs"
   },
   "dependencies": {
     "@microsoft/fetch-event-source": "^2.0.1",
@@ -44,4 +45,4 @@
     "vite-svg-loader": "^5.1.0",
     "vue-tsc": "^1.0.13"
   }
-}
+}

+ 56 - 1
vite.config.ts

@@ -9,6 +9,49 @@ import path from "path";
 
 import basicSsl from "@vitejs/plugin-basic-ssl";
 
+// DMS token 的读取口径收在 harness/tools/dms-token.mjs(与本仓库的 DMS 脚本共用一份):
+// **显式参数 → 环境变量 → 本机文件 `.dms-token`**。
+// 关键是「每个请求现读」而不是启动时读一次 —— token 约 24 小时过期,以前换一次就要重启 dev server。
+import { DMS_TOKEN_FILE, readDmsToken, parseJwtPayload, describeDmsToken } from "./harness/tools/dms-token.mjs";
+
+/** 同一个状态只提醒一次,避免每个请求都刷屏 */
+let dmsTokenWarnedFor = "";
+
+/**
+ * token 有问题时给出**明确**提示 —— 以前 token 过期是**静默失败**(DMS 写入不生效、
+ * 只有一条埋在别处控制台里的 warn),排查「为什么数据没存上」要花很久。
+ */
+const warnDmsTokenIfNeeded = (token: string): void => {
+  if (!token) {
+    if (dmsTokenWarnedFor === "missing") return;
+    dmsTokenWarnedFor = "missing";
+    console.warn(
+      `\n[dms] ⚠️ 没有 DMS token,DMS 读写(会话/问答记录/反馈)会失败,聊天本身不受影响。\n` +
+        `[dms]    最省事:在浏览器里复制 token,然后跑 \`npm run dms:token\`(从剪贴板读)。\n` +
+        `[dms]    文件位置:${DMS_TOKEN_FILE}\n`
+    );
+    return;
+  }
+  const exp = Number(parseJwtPayload(token)?.exp);
+  if (!Number.isFinite(exp)) return;
+  const leftMs = exp * 1000 - Date.now();
+  const at = new Date(exp * 1000).toLocaleString("zh-CN");
+  if (leftMs <= 0) {
+    if (dmsTokenWarnedFor === `expired:${exp}`) return;
+    dmsTokenWarnedFor = `expired:${exp}`;
+    console.warn(
+      `\n[dms] ⚠️ DMS token 已于 ${at} 过期(DMS 读写会失败)。复制新 token 后跑 \`npm run dms:token\`。\n`
+    );
+    return;
+  }
+  if (leftMs < 2 * 3600 * 1000) {
+    const key = `soon:${exp}`;
+    if (dmsTokenWarnedFor === key) return;
+    dmsTokenWarnedFor = key;
+    console.warn(`\n[dms] ⏳ DMS token 将于 ${at} 过期(还剩 ${(leftMs / 3600000).toFixed(1)} 小时)。\n`);
+  }
+};
+
 // https://vitejs.dev/config/
 export default defineConfig(({ mode }) => {
   // 第三个参数传空串:不加前缀过滤,才能读到 DMS_TOKEN 这类非 VITE_ 前缀的变量
@@ -58,7 +101,19 @@ export default defineConfig(({ mode }) => {
           target: env.VITE_DMS_TARGET || "http://121.43.55.7:10081",
           changeOrigin: true,
           rewrite: (path) => path.replace(/^\/dms-api/, "/dms"),
-          headers: env.DMS_TOKEN ? { token: env.DMS_TOKEN } : {},
+          // token 在**每个请求**现读(见 readDmsToken 的说明):换 token 不用重启 dev server。
+          // 用 configure 而不是静态 headers —— 后者的值在启动时就定死了。
+          configure: (proxy) => {
+            proxy.on("proxyReq", (proxyReq) => {
+              // 现读:换 token 后不需要重启 dev server
+              const token = readDmsToken(env.DMS_TOKEN);
+              if (token) {
+                // DMS 的鉴权头名就叫 token(不是 Authorization)
+                proxyReq.setHeader("token", token);
+              }
+              warnDmsTokenIfNeeded(token);
+            });
+          },
         },
         "/asr/": {
           //匹配的路径