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

chore: 清理旧协议残留 + 代理/环境变量重整 + DMS token 改前端自换(用户确认的一批改动)

本批含此前几轮未提交的工作,按用户 2026-09-29 要求一并提交。

清理(用户逐条确认):
- 删掉旧协议 `<policy-list-updated>` 整条链(组件/标签规则/枚举),新协议从不产出它
- 删掉百度统计脚本(数据进的是厂商账号,本项目从未用过;全站唯一往第三方发请求的东西)
- 删掉死配置 VITE_DOWNLOAD_URL(全仓库无读者)

代理与环境变量(用户指定):
- 代理前缀与真实路径同名:`/oauth` 直通 10086、`/dms` 直通 10081(注入 token)、
  `/api` 剥前缀打 10011(语音 token 与取 IP 共用)、`/chat-api` 保留
- 代理 target 写死在 vite.config.ts(用户要求不再从 env 读);删掉四个 *_TARGET 变量
- `/dms-api` → `/dms` 全量重命名(env、6 个工具、文档);生产 env 首次配上 VITE_DMS_API

DMS token(用户选择"前端换"):
- 新增 service-token.ts:未登录访客用内置默认账号经 /oauth 换 JWT,内存缓存 + 失效重试一次
- 代理只在请求没带 token 时注入;新增 verify-dms-service-token.mjs(5/5,含"代理不覆盖前端 token"断言)
- ⚠️ 服务账号密码因此进入公开产物(用户已知悉;技术债 #8 已更新为"暴露面扩大")

其他:
- NLS appkey 内置进代码;本机 AK/SK 从磁盘删除(凭据不落盘)
- 修 Vite 依赖冷扫描失败(.vue 的 script 里裸 HTML 注释起始符),新增护栏脚本
- 新增 verify-stream-filter-rules / verify-dms-service-token / verify-vue-script-html-comments
- harness 记录同步(progress 续 51~66、README 20260928/20260929、architecture/技术债等)

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao пре 1 месец
родитељ
комит
e05a5c2390
52 измењених фајлова са 2198 додато и 897 уклоњено
  1. 43 30
      .env.development
  2. 53 24
      .env.production
  3. 3 2
      .gitignore
  4. 11 8
      CLAUDE.md
  5. 130 0
      README.md
  6. 56 14
      harness/docs/architecture.md
  7. 69 34
      harness/docs/exec-plans/tech-debt-tracker.md
  8. 1 0
      harness/docs/reference/README.md
  9. 13 9
      harness/docs/reference/asr-nls.md
  10. 61 19
      harness/docs/reference/current-api-call-sites.md
  11. 81 0
      harness/docs/reference/legacy-content-markers.md
  12. 675 0
      harness/progress.md
  13. 1 1
      harness/sops/handoff.md
  14. 3 1
      harness/sops/session-end.md
  15. 1 0
      harness/sops/verification.md
  16. 1 0
      harness/tools/_entry-dms-service-token.ts
  17. 1 0
      harness/tools/_entry-stream-filter.ts
  18. 1 1
      harness/tools/audit-company-info-coverage.mjs
  19. 53 67
      harness/tools/check-policy-sources.mjs
  20. 29 5
      harness/tools/dms-login.mjs
  21. 10 0
      harness/tools/dms-token.mjs
  22. 1 1
      harness/tools/gen-company-info-mapping-doc.mjs
  23. 5 2
      harness/tools/get-nls-token.mjs
  24. 43 7
      harness/tools/nls-token.mjs
  25. 5 3
      harness/tools/probe-nls-realtime.mjs
  26. 63 0
      harness/tools/upload-policy-library.mjs
  27. 4 4
      harness/tools/verify-attachment-question.mjs
  28. 2 2
      harness/tools/verify-company-classify-dms.mjs
  29. 1 1
      harness/tools/verify-company-classify-e2e.mjs
  30. 2 2
      harness/tools/verify-dms-chat-storage.mjs
  31. 1 1
      harness/tools/verify-dms-file-upload.mjs
  32. 110 0
      harness/tools/verify-dms-service-token.mjs
  33. 14 0
      harness/tools/verify-inline-scripts.mjs
  34. 6 5
      harness/tools/verify-nls-realtime.mjs
  35. 88 0
      harness/tools/verify-policy-library.mjs
  36. 120 0
      harness/tools/verify-stream-filter-rules.mjs
  37. 96 0
      harness/tools/verify-vue-script-html-comments.mjs
  38. 29 110
      index.html
  39. 0 112
      package-lock.json
  40. 0 1
      package.json
  41. 2 1
      src/asr/nls.ts
  42. 14 19
      src/components/Chat/BusinessRecord.vue
  43. 0 123
      src/components/Chat/PolicyListUpdatedMatch.vue
  44. 40 66
      src/components/Chat/PolicyMatch.vue
  45. 0 136
      src/components/Chat/policy-match-utils.ts
  46. 2 2
      src/components/business-assistant/shared.ts
  47. 46 12
      src/network/api/dms/client.ts
  48. 3 2
      src/network/api/dms/file-upload.ts
  49. 112 0
      src/network/api/dms/service-token.ts
  50. 25 10
      src/utils/runtime-config.ts
  51. 20 6
      src/utils/stream-xml-filter.ts
  52. 48 54
      vite.config.ts

+ 43 - 30
.env.development

@@ -1,46 +1,59 @@
+# 开发环境(npm run dev)。生产见 .env.production。
+# 本机专属覆盖:**需要时新建** `.env.development.local`(`.gitignore` 已覆盖 `.env.*.local` 模式,
+# 不会入库)—— 那个文件 2026-09-28 已删除,因为里面原本的 NLS 凭据现已不再需要。
+#
+# 分区:① 构建信息 ② 接口地址(进产物) ③ 只给 dev 代理读的 target ④ 云服务的同源路径
+# 字段含义与「谁在读」见 harness/docs/architecture.md 的「环境变量」表。
+
+# ── ① 构建信息 ──────────────────────────────────────────────
 VITE_VERSION=${npm_package_version}
-# VITE_API="//192.168.14.25:4006"
-# VITE_API="//human-card-mini.metamaker.cn"
-# VITE_API="//fifth-hospital.test.metamaker.cn"
+
+# ── ② 接口地址(会进产物,改完要重新构建)─────────────────────
+# 主业务 API 网关(企业登录 / 企业信息,见 src/network/api/enterprise.ts)
+# 备用地址(切换时取消注释、注释掉当前这行):
+#   VITE_API="//192.168.14.25:4006"
+#   VITE_API="//human-card-mini.metamaker.cn"
+#   VITE_API="//fifth-hospital.test.metamaker.cn"
+# ⚠️ 不要写 `VITE_API="/api"` —— 那个前缀现在归「后端网关代理」(见下面 ③)用,语义不同
 VITE_API="//qingpu-data-api.metamaker.cn"
-VITE_MODEL_SERVER="https://qingpu-data-api.metamaker.cn/llms/qingpu"
-
-# VITE_API="/api"
-VITE_USE_KNOWLEDGE_API=true
-VITE_DOWNLOAD_URL="https://qingpu-data-api.metamaker.cn/llm/download/"
-# 政策库优先用仓库内的本地副本(/merchant-agent/policies_public.v1.json)
-# 原因:远端那份 327 条**全都没有** data.市级政策id,取不到 id 就拼不出政策详情地址,
-# 表现为详情面板的「政策名称」是纯文本、点不开;本地这份 261/262 有该字段。
-# 仅开发环境打开(见 index.html),生产仍远端优先
-VITE_POLICY_LOCAL_FIRST="true"
-VITE_PARENT_ORIGIN = 'https://192.168.14.50:5174'
+
+# 政策库的"远端"—— **DMS 文件服务里的那份**(用户 2026-09-24 指定:把 policies_public.v2.json
+# 上传到 DMS,那个路径就是新的远端政策接口)。同源路径,dev 走 vite 代理 `/dms/`(直通)。
+# 加载顺序固定为「先远端、取不到再用随包发布的本地副本」(见 index.html)。
+# 重新上传(库内容更新时):node harness/tools/upload-policy-library.mjs
+VITE_POLICY_REMOTE_URL="/dms/static/file/1889-c_question-83859739-17d2-4b7f-b427-7ee27e098e79.json"
+
+# 嵌入 iframe 时父页面的 origin(index.html 的 postMessage 目标;**不要写成 '*'**)
+VITE_PARENT_ORIGIN='https://192.168.14.50:5174'
 
 # 新版聊天协议后端(POST /api/chat,SSE)
 # 开发环境走 vite 同源代理(见 vite.config.ts 的 /chat-api/),
 # 避免 HTTPS 页面直连 HTTP 后端被浏览器拦截,同时绕开后端未实现 CORS 的问题
 VITE_CHAT_API="/chat-api"
-# 聊天后端真实地址。dev 走同源代理(VITE_CHAT_API 是代理前缀,见 vite.config.ts),
-# 代理的 target 从**这个变量**读,不再硬编码在 vite.config.ts 里
-VITE_CHAT_TARGET="http://192.168.2.23:8000"
 
 # DMS 数据管理服务(会话/问答记录/反馈的落地库)
 # 走 vite 同源代理:HTTPS 页面直连 http:// 的 DMS 会被浏览器按混合内容拦截,
 # 且 token 由代理侧注入(见 vite.config.ts),不进浏览器产物
-VITE_DMS_API="/dms-api"
-VITE_DMS_TARGET="http://121.43.55.7:10081"
+VITE_DMS_API="/dms"
 
-# 取 OSS 上传签名的接口(当前为厂商 metamaker 提供,实测无需鉴权)。
-# 换自建签名服务时只改这里(见 harness/docs/reference/current-api-call-sites.md)
+# ── ③ 同源路径(生产由 nginx 做等价转发)─────────────────────
+# ⚠️ 代理的 **target 地址不在这里**,直接写在 `vite.config.ts` 的 proxy 里
+# (用户 2026-09-29 要求「vite.config.ts 里面不要加环境变量」,本文件不再被它读取)。
 
 # 取本机 IP 的接口(DMS 访客归属:访客_<IP>,见 src/utils/client-ip.ts)
-# 它是 **HTTP**,而页面是 HTTPS —— 直连会被混合内容拦掉,所以走同源代理
-VITE_IP_API="/ip-api"
-VITE_IP_TARGET="http://121.43.55.7:10011"
+# 它是 **HTTP**,而页面是 HTTPS —— 直连会被混合内容拦掉,所以走同源代理。
+# ⚠️ `/api` 这条代理会**剥掉前缀**(网关真实路径是 /proxy/verify/…),所以这里写前缀就够。
+VITE_IP_API="/api"
+
+# 未登录访客换 DMS token 的登录端点(**同源路径**,dev 走 vite 代理 `/oauth/` → 121.43.55.7:10086)。
+# 前端 `src/network/api/dms/service-token.ts` 用它 + 内置服务账号换 token。
+VITE_OAUTH_LOGIN_URL="/oauth/api/user/login"
 
-# 阿里云 NLS(实时语音识别)的 token 端点 —— 后端网关提供,**同源路径**。
-# 网关只有 HTTP、页面是 HTTPS,直连会被混合内容拦掉,所以走 vite 代理(见 vite.config.ts 的 /aly-api/)。
-# 生产要在 nginx 里把 /aly-api/ 转发到 http://121.43.55.7:10011/(去掉前缀),前端代码不用改。
-VITE_NLS_TOKEN_API="/aly-api/proxy/verify/getALYToken"
+# 阿里云 NLS(实时语音识别)的 token 端点 —— 后端网关提供,**同源路径**(与取 IP 同一台机器)。
+# 网关只有 HTTP、页面是 HTTPS,直连会被混合内容拦掉,所以走 vite 代理(见 vite.config.ts 的 /api/)。
+# 生产要在 nginx 里把 /api/ 转发到 http://121.43.55.7:10011/(去掉前缀),前端代码不用改。
+VITE_NLS_TOKEN_API="/api/proxy/verify/getALYToken"
 
-# NLS 的 appkey(识别请求要带)。它**不是密钥**,但按约定不写进仓库文件:
-# 开发期放 .env.development.local(gitignored),生产由部署侧注入 VITE_NLS_APPKEY。
+# NLS 的 appkey:**已内置进代码**(`src/utils/runtime-config.ts` 的 `NLS_APPKEY_BUILTIN`,
+# 用户 2026-09-28 要求「直接写入使用的地方,不再放在环境变量里」)。
+# 真要用 env 临时覆盖也可以:`VITE_NLS_APPKEY` 仍然优先于内置值。

+ 53 - 24
.env.production

@@ -1,34 +1,63 @@
+# 生产环境(npm run build --mode production)。开发见 .env.development。
+#
+# 分区(与 .env.development 对齐):① 构建信息 ② 接口地址(进产物) ③ 云服务的同源路径
+# ⚠️ 生产**没有** vite 代理(`server.proxy` 只对 `npm run dev` 生效,`npm run preview` 会复用同一份,
+#    容易误判"生产已通")—— 下面这些同源路径**都要由部署侧 nginx 转发**,见各条注释。
+# 字段含义与「谁在读」见 harness/docs/architecture.md 的「环境变量」表。
+
+# ── ① 构建信息 ──────────────────────────────────────────────
 VITE_VERSION=${npm_package_version}
+
+# ── ② 接口地址(会进产物)────────────────────────────────────
+# 主业务 API 网关(企业登录 / 企业信息)
 VITE_API="//qingpu-data-api.metamaker.cn"
-# 政策库来源顺序:false = 远端优先、本地兜底(见 index.html)
-VITE_POLICY_LOCAL_FIRST="false"
-VITE_PARENT_ORIGIN = 'https://aixq.shqp.gov.cn/'
-VITE_MODEL_SERVER="https://qingpu-data-api.metamaker.cn/llms/qingpu"
-VITE_USE_KNOWLEDGE_API=true
+
+# 政策库的"远端"—— DMS 文件服务里的那份(同 dev,见 .env.development 的说明)。
+# ⚠️ 生产要能用它,需要两件事(与技术债 #9 同源):① `VITE_DMS_API` 配好(下面 ③);
+#    ② nginx 把 `/dms/` 转发到 DMS 主机(2026-09-29 起**直通、不再补前缀**)。
+# 取不到就自动退回随包发布的本地副本(`/merchant-agent/policies_public.v2.json`),功能不受影响。
+VITE_POLICY_REMOTE_URL="/dms/static/file/1889-c_question-83859739-17d2-4b7f-b427-7ee27e098e79.json"
+
+# 嵌入 iframe 时父页面的 origin(index.html 的 postMessage 目标;**不要写成 '*'**)
+VITE_PARENT_ORIGIN='https://aixq.shqp.gov.cn/'
+
 # 新版聊天协议后端(POST /api/chat,SSE),直连模式
-# 生产部署时请改为实际后端地址(当前为内网测试地址)
+# ⚠️ 生产部署时请改为实际后端地址(当前仍指向内网测试机,见技术债 #11)
 VITE_CHAT_API="http://192.168.2.23:8000"
 
-# 政策库 json 的下载前缀(index.html 用它拼 `merchant-agent/policies_public.v1.json`)。
-# ⚠️ 这个值**原先只写在 `.env.qingpu` 里**:2026-09-24 用户要求「环境只保留开发和生产」,
-#    合并进 production。走同源 `/api/download/`(由部署侧 nginx 转发,与 `/ip-api/` 同理),
-#    而不是直连厂商域名 —— 如果部署侧没有这个转发,改回绝对地址即可。
-VITE_DOWNLOAD_URL="/api/download/"
-
 # 跳转映射(`redirect.html` 用:`?redirect=<key>` → 目标地址)。
-# ⚠️ 同样原先只在 `.env.qingpu` 里,2026-09-24 合并进 production。
-# **不能删**:缺了它 redirect.html 里的 `%VITE_REDIRECT_MAP%` 会原样留下、页面脚本直接语法错误。
+# **不能删**:缺了它 redirect.html 里的 `%VITE_REDIRECT_MAP%` 会原样留下、页面脚本直接语法错误
+# (⚠️ 本项只有生产有,dev 缺 → 本地打开 `/redirect.html` 会是语法错误页;登录链不受影响)
+# 值里的 `#` 必须写成 `%23`(.env 里 `#` 是注释符),页面里会还原。
 VITE_REDIRECT_MAP={"demo":"https://demo.heijingai.com/FTA-c2p/%23/overview","zhaoshang":"https://demo.heijingai.com/policy-application/%23/"}
 
+# ── ③ 同源路径(都需 nginx 转发;2026-09-29 起前缀与服务真实路径对齐)──
+#   /api/**  → http://121.43.55.7:10011(**剥掉 /api**:网关真实路径是 /proxy/verify/…)
+#   /dms/**  → http://121.43.55.7:10081(**直通**:DMS 真实路径就带 /dms,并注入 token 头)
+#   (聊天后端 VITE_CHAT_API 上面是绝对地址;若要改成同源,另配一条 nginx 规则)
+#
 # 取本机 IP 的接口(DMS 访客归属:访客_<IP>,见 src/utils/client-ip.ts)
-# ⚠️ 它只有 HTTP,页面是 HTTPS —— 生产**必须由 nginx 把 /ip-api/ 转发到
-#    http://121.43.55.7:10011/ 并去掉前缀**,否则请求会被浏览器按混合内容拦掉
+# ⚠️ 它只有 HTTP,页面是 HTTPS —— 生产**必须由 nginx 转发 /api/ 到 121.43.55.7:10011(去前缀)**,
+#    否则请求会被浏览器按混合内容拦掉
 #    (拦掉不致命:拿不到 IP 会退回 访客_<浏览器随机 id>,只是访客归属不稳定)
-VITE_IP_API="/ip-api"
-
-# 阿里云 NLS 的 token 端点(实时语音识别)—— 后端网关提供,同源路径
-# ⚠️ 生产**必须由 nginx 把 /aly-api/ 转发到 http://121.43.55.7:10011/** (去前缀,与 /ip-api/ 同法)
-VITE_NLS_TOKEN_API="/aly-api/proxy/verify/getALYToken"
-# ⚠️ NLS 的 appkey:**不是密钥**,但按约定不写进仓库文件 —— 由部署侧注入
-#    (开发期在 .env.development.local)。缺了它识别请求会被网关拒。
-# VITE_NLS_APPKEY="<部署时注入>"
+VITE_IP_API="/api"
+
+# 未登录访客换 DMS token 的登录端点(**同源路径**)
+# ⚠️ 生产**必须由 nginx 把 `/oauth/` 直通到 http://121.43.55.7:10086**(无需改前缀),
+#    否则未登录访客换不到 token ⇒ DMS 读写全失败
+VITE_OAUTH_LOGIN_URL="/oauth/api/user/login"
+
+# 阿里云 NLS 的 token 端点(实时语音识别)—— 后端网关提供,同源路径(与取 IP 同一台机器)
+# ⚠️ 生产同样由 nginx 转发 `/api/`(去前缀)—— NLS token 与取 IP **共用这一条**(2026-09-29 合并)
+VITE_NLS_TOKEN_API="/api/proxy/verify/getALYToken"
+
+# DMS 数据服务(会话/问答记录/反馈,由代理/nginx 注入 token 头)
+VITE_DMS_API="/dms"
+# ✅ NLS 的 appkey:**已内置进代码**(`src/utils/runtime-config.ts` 的 `NLS_APPKEY_BUILTIN`,
+#    用户 2026-09-28 要求)⇒ **生产不再需要部署侧注入**,语音识别开箱可用。
+#    如临时要换:设 `VITE_NLS_APPKEY`(env / 或 index.html 注入 globalThis)即可覆盖内置值。
+
+# ── 已删(留说明,别再加回来)────────────────────────────────
+# [已删] VITE_DOWNLOAD_URL="/api/download/"(政策库 json 的下载前缀)——
+# 2026-09-28 用户确认删除:**全仓库没有任何读者**(index.html 的本地兜底早已写死
+# `/merchant-agent/policies_public.v2.json`),生产侧 nginx 也就不再需要转发 `/api/download/`。

+ 3 - 2
.gitignore

@@ -45,8 +45,9 @@ components.d.ts
 # Claude Code 本机权限配置(.local 约定不入库)
 .claude/settings.local.json
 
-# 构建产物:rollup-plugin-visualizer 生成的体积报告
-stats.html
+# Vite 在 dev 运行中改配置时写的临时文件(正常会自清;进程被强杀时会留下)
+# 例:vite.config.ts.timestamp-1790651074252-f8183f1c87662.mjs
+*.timestamp-*.mjs
 
 # 注:harness/、CLAUDE.md、agents.md 自 2026-09-17 起**纳入版本控制**
 # (此前排除是本机工作流的临时安排;参考资料的原则是「计划、质量、技术债

+ 11 - 8
CLAUDE.md

@@ -15,7 +15,8 @@
 - **没有可运行证据(构建/测试的实际输出)时,不要声称完成**
 - **每次改动完成后立刻更新记录,不要攒到会话结束** —— 攒到最后必然漏记、
   且已写下的描述会过期(真实发生过)
-- **每次改动完成后立即提交并推送**(见下方"提交约定")
+- **不要自动提交 / 推送** —— 改动完成只跑验证、更新记录,把变更**留在工作区**;
+  **只有用户明确要求时才 `git commit` / `git push`**(见下方"提交约定")
 - **计划类文件只能写进 `harness/docs/exec-plans/active/`** ——
   写到仓库外(如工具自带的 `~/.claude/plans/`)或项目根目录都算没写:
   换个会话接手的人看不到,等于计划不存在。工具生成的草稿在收尾前必须落进仓库。
@@ -26,16 +27,18 @@
 
 ## 提交约定
 
-**每次改动完成就提交,并推送到 origin** —— 不要攒成一个大提交,也不要留给用户手动提交。
+**提交时机由用户决定**(用户 2026-09-24 指示:「后续不要再自动提交和推送了,
+只有我要求你做你才做」)—— 这条**取代**了原先「每次改动完成就提交并推送」的约定:
 
-提交前的最低要求:
-
-- `npm run build` 通过
-- 已按上面的规则更新 `harness/progress.md` 与 `harness/feature_list.json`
-
-提交信息写清「改了什么、为什么」,用中文;结尾带 `Co-Authored-By` 行。
+- 改完代码:照常跑构建/验证、更新 `harness/progress.md` 与 `harness/feature_list.json`
+  (记录仍要写,它是仓库的事实来源),**但不要 `git add/commit/push`**
+- 变更留在工作区,收尾时**说明「改动未提交」**,等用户说「提交」或「提交并推送」再执行
+- 用户的一次「提交」指示**只对那一轮生效**;之后回到默认(不提交)
+- 用户明确要求提交时,按原要求执行:`npm run build` 通过 + 记录已更新,
+  提交信息写清「改了什么、为什么」(中文),结尾带 `Co-Authored-By` 行
 
 ```bash
+# 仅在用户明确要求提交时执行
 git add -A && git commit -m "..." && git push origin main
 ```
 

+ 130 - 0
README.md

@@ -523,3 +523,133 @@
 - **接口清单按当前代码重核**(用户:「再列出现在使用的接口」):13 个在用接口 + 5 条 vite 代理,
   见 [`current-api-call-sites.md`](harness/docs/reference/current-api-call-sites.md) §0 A 表;
   相比清点时新增一条漏记的(正文里 `<policy-list-updated>` 给的动态 link)
+
+## 20260928
+
+- **删掉政策列表「第三条腿」与 index.html 两处死枝**(同日早些时候,用户:「动手删 1、2」)——
+  详见 [`progress.md`](harness/progress.md) 续 50,本文不重复。
+- **删掉打包体积报告 `stats.html` 及相关配置**(用户:「这个不需要,把它和相关代码删掉」):
+  `vite.config.ts` 的 `rollup-plugin-visualizer` 插件块 + 该 devDependency
+  (`package.json` / `package-lock.json`)+ `.gitignore` 那两行 + 仓库根目录 1.78 MB 的报告本体。
+  - 顺带查明它**一直没落在配置写的 `dist/`**:插件 v5 的选项名是 `filename`(不是 `file`),
+    `file: "./dist/stats.html"` 被忽略 → 回落到默认名、按 cwd 解析 → 仓库根目录
+  - **产物零影响**:删除前后三个 chunk 的 hash 完全一致;构建 17.4s → 9.2s(少了报告的两遍压缩)
+- **修「Vite 依赖冷扫描失败」(`Failed to scan for dependencies`)+ 加护栏**:
+  - 根因:Vite 的依赖预扫描把 **`.vue` 也当 HTML**,扫描前先 `replace(/<!--.*?-->/gs, "<!---->")`
+    (可跨行)—— `.vue` 的 `<script>` 里一个**不成对**的 HTML 注释起始符会和后面任意一个结束符
+    (哪怕隔着几十行)配成注释**整段吃掉**。`BusinessRecord.vue` 的注释与 9 行后的正则就这么配上了对,
+    于是正则那行被并进注释、下一行以 `.replace(` 开头 → `Unexpected "."`
+  - 触发时机很阴:只在**依赖缓存失效**时炸(改 package.json / `--force`),`npm run build` **抓不到**
+  - 修法:政策表的两个标记字面量从 `.vue` 挪到 `stream-xml-filter.ts`
+    (`POLICY_TABLE_START` / `POLICY_TABLE_END`,顺带消掉重复);新增护栏脚本
+    [`verify-vue-script-html-comments.mjs`](harness/tools/verify-vue-script-html-comments.mjs),
+    并写进验证 SOP 与架构「关键约束」
+  - 验证:`npx vite --force` 冷扫描 **0 错误**;产物 hash 变了但**只差 70 字节**(字面量去重),语义等价
+- **删掉百度统计**(用户确认「删掉」):`index.html` 里那段第三方脚本(`hm.baidu.com/hm.js?d6307cb…`)整块移除
+  - 排查结论:抓 hm.js 本体看到站点配置 **`dm:["demo.heijingai.com"]`** ⇒ 数据进的是**厂商账号**,
+    本项目**从未用过**它的数据(`git log -S '_hmt.push'` 全历史为空,代码里也无人读 `_hmt`)
+  - jsdom 实测三个域(含青浦域):都会写 `HMACCOUNT`/`Hm_lvt_*`/`Hm_lpvt_*` cookie 并上报 `hm.gif`;
+    传的是访问元信息(URL / 来源 / 标题 / 屏幕 / 语言 + utm 渠道参数),**不含任何业务数据**
+  - 它是全站**唯一**往第三方发请求、并在青浦域名上写 cookie 的东西 ⇒ 政府站点又无告知环节,删除更省事
+- **删掉死配置 `VITE_DOWNLOAD_URL`**(用户确认):它只定义在 `.env.production`、**全仓库没有任何读者**
+  (`index.html` 的本地兜底早已写死 `/merchant-agent/policies_public.v2.json`)⇒
+  生产侧 nginx 也不再需要转发 `/api/download/`
+  - 护栏:`verify-policy-library.mjs` 新增 §10(含"政策库远端/本地兜底都还在"两条防误伤断言),
+    变异测试(把配置放回去)确认会红
+- **整理环境变量**(用户要求):先把「两份 env 的定义 × 代码里的读者」逐项对清,再动手
+  - 两份 env **重排为同一套分区**(构建信息 / 接口地址 / dev 代理 target / 云服务同源路径 / 已删说明);
+    改写前后**用脚本比对 key→value**:删除 0、值变化 0,dev 11 → 12(仅新增 `VITE_ALY_TARGET`)
+  - 补 `VITE_ALY_TARGET`:`vite.config.ts` 的 `/aly-api/` 代理只认它、缺了就落到硬编码兜底,
+    与另外三个 target 不对称;填入值同原兜底 ⇒ **零行为变化**
+  - 删 `runtime-config.ts` 的 `getApiBaseUrl()`:无人调用,且它读的 `globalThis.VITE_API`
+    **从来没有被赋值过**(index.html 只注入 CHAT / DMS / IP)⇒ 纯死代码
+  - `architecture.md` 的环境变量表按代码重写(原表还列着已删的 `VITE_MODEL_SERVER` /
+    `VITE_ASR` / `VITE_STREAM_SERVER`,并缺一半现役变量)
+  - dev 实测:页面 200 · `/aly-api` 经新 target 取到真实 token · `/ip-api` 取到真实 IP ·
+    产物 hash 未变(`main-D0U2AkA8`)
+  - ⚠️ 记一笔防误删:`.env.development.local` 里的 `ALIYUN_NLS_*` **不是残留** ——
+    是 harness 的 NLS 测试脚本在读
+- **NLS appkey 内置进代码 + 修 3 处过期说明**(用户要求):
+  - appkey 写进 `runtime-config.ts` 的 `NLS_APPKEY_BUILTIN` ⇒ **生产不再需要部署侧注入**
+    (覆盖链保留:`globalThis` 注入 → env → 内置值,临时换值不必改代码)
+  - ⚠️ 它**不是密钥**(换 token 不需要它、网关也不校验,且每次识别请求本就把它明文发出),
+    所以进产物/进仓库是可接受的;真正的 **AccessKeySecret 没有进仓库**,仍在 gitignored 的 `.env.development.local`
+  - 过期说明:`asr/nls.ts` 注释里的 `/nls-token/`、`asr-nls.md` 的「旧实现保留 / `VITE_ASR`·`VITE_XF_ASR` 仍在」
+    (两者早在 2026-09-24 就删了)、NLS 校验脚本里的断言标签
+  - 技术债 #10 的待办 ② 随之消掉,**生产只剩 nginx 转发 `/aly-api/`**
+  - 验证:产物里内置 appkey 命中 1 次、`verify-nls-realtime` **44/44**(含真实网关连接)
+- **删掉本机阿里云凭据**(用户确认「三个都删(含 AK/SK)」):`.env.development.local` 里
+  **不再保存任何凭据**(现在只剩一段说明)—— `ALIYUN_NLS_AK`/`SK` 是"网关取不到 token 时自签"的兜底,
+  `ALIYUN_NLS_APPKEY` 已内置在代码里
+  - harness 脚本改为从**代码内置值**读 appkey(`readBuiltinAppkey()`),启用条件放宽为"只要有 appkey"
+    ⇒ 删掉凭据后**真实网关段仍然会跑**:`verify-nls-realtime` 44/44、`verify-nls-browser-path` 4/4,
+    两者都是真实联网识别
+  - 顺手修 `dms/client.ts` 一条指错位置的提示(DMS token 在本机 `.dms-token`,不在 `.env.development.local`)
+  - **该文件随后整体删除**(用户:「文件没用的话也删掉」):确认无硬依赖(`loadEnv` / `readDotEnvFile` 都容忍文件缺失,
+    `.gitignore` 的 `.env.*.local` 模式仍在)⇒ 仓库只剩 `.env.development` / `.env.production` 两份;
+    同时修掉 4 处"凭据在那个文件里"的旧注释(`vite.config.ts` 的 DMS token 来源、两个 NLS 脚本头部、`.env.development` 头部)
+  - 验证(删文件后跑):`get-nls-token --check` 仍读得到内置 appkey · `verify-nls-realtime` 44/44(真实网关)·
+    dev 冒烟页面 200 且 `/dms-api` 返回**真实数据** · 构建 + `validate-harness` 通过
+- **DMS 默认服务账号写进代码**(用户给账号并要求「直接写进代码里」):
+  - `harness/tools/dms-login.mjs` 新增 `DEFAULT_ACCOUNT`;取值顺序变为
+    **参数 → 环境变量 → 代码内置默认 → 本机 `.dms-account`(兜底)**
+  - ⚠️ **这是本项目「凭据不落盘」规则的唯一例外**(2026-09-23 定的规则):用户确认要写进入库源码,
+    已知悉密码会进 git 历史(**本轮尚未提交**,提交后就撤不回来了);代码注释里写明了
+    「只准 node 侧用、绝不要复制进 `src/`」
+  - 验证:登录成功后 token 用户为 `user_yszs_web`(登录前是 `user_gtx`)· 直连 DMS `code=200` 取到数据 ·
+    dev 经 `/dms-api` 代理读写正常
+  - 📌 影响:**之后 DMS 新写入的行作者会变成 `user_yszs_web`**
+  - **随后删掉 `.dms-account`**(用户:「没用就删掉」):它已被代码内置的默认账号覆盖
+    (参数 → env → 内置 → 文件),本机那份是永远轮不到的旧账号;
+    **`.dms-token` 保留** —— 它是 dev 代理每个请求现读的当前 token(自动生成物,删了会自愈但没意义)
+- **删掉 `<policy-list-updated>` 整条链**(用户:「老对话仅测试,后续会全部清空」):
+  - 该块是旧协议里**后端在正文给出地址、前端再去取**的路径(接口清单 #11),
+    与已删的 #10 是同一个接口,只是地址由后端下发。新协议适配层**从不产出**它,
+    唯一的触发源就是旧会话历史 —— 保留理由随「旧对话要清空」一起消失。
+  - 删除范围:`PolicyListUpdatedMatch.vue` 整文件 · `BusinessRecord` 的挂载点/import/
+    `hasPolicyTableRow` 分支 · `StreamXMLFilter` 的枚举成员与标签规则 · 相关注释与文档。
+  - ⚠️ **副作用(用户已确认接受)**:标签规则删掉后,**清空之前**打开旧测试会话,
+    正文里那串 `<policy-list-updated>{"link":…}</policy-list-updated>` 会当**普通文本**显示。
+  - 顺带**只读排查**了其余只服务旧协议的标记(`<form>` / `<link-url>` / `<file-docx>` /
+    `<ui-form>` / `<followup>` / `<silence>` / `<qingpu_enterprise_notificaion>`),
+    结论写成 [`legacy-content-markers.md`](harness/docs/reference/legacy-content-markers.md):
+    前三个「只有旧历史能触发」,后四个**已经是死链**(消费端空实现或无人注册)——
+    本清单**没有据此改任何代码**。
+
+## 20260929
+
+- **代理前缀统一为「与服务真实路径同名」(用户指定)**:原来是 `/dms-api`(要补 `/dms` 前缀)+
+  `/ip-api` + `/aly-api`(后两条打的还是同一台 `121.43.55.7:10011`)。现在四条:
+
+  | 前缀 | 目标 | 是否改写 |
+  |---|---|---|
+  | `/oauth/**` | `121.43.55.7:10086`(DMS 登录,实测 `POST /oauth/api/user/login`) | **直通**(真实路径就带 `/oauth`) |
+  | `/dms/**` | `121.43.55.7:10081`(DMS 数据 + 政策库 json),代理侧注入 `token` 头 | **直通**(真实路径就带 `/dms`) |
+  | `/api/**` | `121.43.55.7:10011`(语音 token + 取本机 IP **共用**) | **剥掉 `/api`**(实测直连 `/api/proxy/…` 是 404) |
+  | `/chat-api/**` | 聊天后端(保留) | 剥前缀 |
+
+  - 名字跟着改:`VITE_DMS_API` `/dms-api`→`/dms`、`VITE_POLICY_REMOTE_URL` `/dms/static/…`、
+    `VITE_IP_API`→`/api`、`VITE_NLS_TOKEN_API`→`/api/proxy/verify/getALYToken`
+  - **代理 target 不再走环境变量**(用户同日要求「vite.config.ts 里面不要加环境变量」):
+    四个 target 直接写成 `vite.config.ts` 里的字面量,该文件不再 `loadEnv`;
+    原先的 `VITE_CHAT_TARGET` / `VITE_DMS_TARGET` / `VITE_OAUTH_TARGET` / `VITE_API_TARGET`
+    **四个变量已删除**(`.env.development` 里那条"target 区"改成说明)
+  - **连带**:`.env.production` 首次配上 `VITE_DMS_API="/dms"`(原来生产根本没有 ⇒ 技术债 #9 的
+    "未配置"那半已还,只剩 nginx 转发 + 注入 token)
+  - 实测(dev,四条全通):`/dms/content/selectContentList` 取到真实数据 · `/dms/static/…json` 393 KB ·
+    `/api/proxy/verify/getALYToken` 取到 token · `/api/proxy/verify/getIPByBrowser` 取到真实 IP ·
+    `/oauth/api/user/login` 用默认账号拿到 JWT
+  - 校验:`verify-policy-library` 54/54 · `verify-attachment-question` 57/57 · `verify-dms-payload-fields` 19/19 ·
+    `verify-question-cards` 36/36 · `validate-harness` 通过 · `npm run build` 通过(hash 变,因 env 值变)
+  - **硬编码 target 后复测**(页面 200 · `/dms` 真实数据 · `/api` token 与真实 IP · `/oauth` 到达业务层)全通过
+- **未登录访客的 DMS token 改由前端自己换**(用户要求):
+  - 新增 `src/network/api/dms/service-token.ts`:用**内置服务账号**经 `POST /oauth/api/user/login`
+    换 JWT(`message` 字段),缓存在内存、过期前 5 分钟刷新、并发共用同一个在途请求
+  - `client.ts` 随请求带 `token` 头;遇 201/212(token 失效)**丢掉重换一次**再试
+  - `vite.config.ts` 的 `/dms/` 代理改成**只在请求没带 token 时注入**(命令行走代理的排查方式不变)
+  - 新增 `VITE_OAUTH_LOGIN_URL`(两份 env);生产需 nginx **直通 `/oauth/` → `121.43.55.7:10086`**
+  - 📌 **该账号是用户专门准备的「默认账号」**(用户 2026-09-29 确认:已处理过、就是给这个用途的)
+  - ⚠️ 仍存在的两个代价:① 密码在公开产物里(要收紧就改密码并同步改两处代码);
+    ② token 落在浏览器。技术债 **#8**(已同步更新:暴露面扩大、生产接入前必须解决)
+  - 验证:新增 `harness/tools/verify-dms-service-token.mjs` **5/5** —— 含关键断言
+    「带假 token 被 DMS 拒(212)⇒ 代理没有覆盖前端带的 token」

+ 56 - 14
harness/docs/architecture.md

@@ -102,20 +102,62 @@ src/
 ### 开发环境
 
 - dev server 为 **HTTPS**(自签证书),浏览器首次访问会有证书告警,属正常现象
-- 聊天接口走**同源代理** `/chat-api` → `http://192.168.2.23:8000`(见 `vite.config.ts`)。
-  代理由服务端发起,因此局域网同事访问也不会有混合内容或跨域问题
-- 生产环境 vite 代理不生效,需由 nginx 做等价转发
+- 四个后端的接口都走**同源代理**(见 `vite.config.ts`;代理由服务端发起,所以局域网同事访问也不会有混合内容问题):
+  `/oauth/**` → `121.43.55.7:10086` · `/dms/**` → `121.43.55.7:10081` ·
+  `/api/**` → `121.43.55.7:10011`(剥前缀)· `/chat-api/**` → 聊天后端(剥前缀)
+- 生产环境 vite 代理不生效,需由 nginx 做等价转发(含 DMS 的 token 注入)
+- **`.vue` 的 `<script>` 里不要出现裸的 HTML 注释起始符**(那四个字符):
+  Vite 的依赖预扫描把 `.vue` 也当 HTML,扫描前先执行 `replace(/<!--.*?-->/gs, "<!---->")` ——
+  一个**不成对**的起始符会和**后面任意一个结束符**(哪怕隔着几十行)配成"HTML 注释"整段吃掉,
+  冷扫描随即报 `Failed to scan for dependencies … Unexpected "."`(2026-09-28 踩过)。
+  标记类字面量统一放 `.ts`(如 `stream-xml-filter.ts` 的 `POLICY_TABLE_START` / `POLICY_TABLE_END`),
+  组件里 import;改完用 `node harness/tools/verify-vue-script-html-comments.mjs` 自查
+  (`<template>` 里的常规注释不受影响,脚本只查 `<script>` 区块)
 
 ## 环境变量
 
-| 变量 | 用途 |
-|---|---|
-| `VITE_API` | 主业务 API 网关 |
-| `VITE_CHAT_API` | 对话接口。服务前缀(`/chat-api`、`http://host:8000`)会自动追加 `/api/chat`;已是完整地址则原样使用 |
-| `VITE_CHAT_TARGET` | **聊天后端真实地址**(仅 dev 用)。`VITE_CHAT_API` 在开发环境是代理前缀 `/chat-api`,代理的 target 从本变量读(见 `vite.config.ts`),**不再硬编码在配置文件里** |
-| `VITE_DMS_API` / `VITE_DMS_TARGET` | DMS 数据服务(会话/问答记录/反馈落地)。dev 走 `/dms-api` 代理、token 由代理注入,见 `vite.config.ts` |
-| `VITE_MODEL_SERVER` | 模型服务 |
-| `VITE_ASR` / `VITE_STREAM_SERVER` | 语音识别 / 流媒体 |
-
-> 📌 **地址往 env 里放、不要写死在代码里**:`VITE_DMS_TARGET` 与 `VITE_CHAT_TARGET`
-> 都是为此加的。vite.config.ts 里保留同值兜底,但优先读 env。
+**2026-09-28 按代码重核过一遍**(此前这张表列着已删的 `VITE_MODEL_SERVER` / `VITE_ASR` /
+`VITE_STREAM_SERVER`,且缺了一半现役变量)。**改 env 时请同步更新本表。**
+
+| 变量 | 用途 | 定义在 | 谁在读 / 备注 |
+|---|---|---|---|
+| `VITE_VERSION` | 页面标题里的版本号 | 两份 | `index.html` 的 `%VITE_VERSION%` |
+| `VITE_API` | 主业务网关(企业登录 / 企业信息,**老接口**) | 两份 | `enterprise.ts:51,83`、两个壳组件的 `apiBaseUrl` |
+| `VITE_POLICY_REMOTE_URL` | 惠企政策库的**远端那份**(DMS 文件服务里的 json) | 两份 | `index.html`;取不到自动退回随包本地副本 |
+| `VITE_PARENT_ORIGIN` | 嵌 iframe 时父页面的 origin(postMessage 目标) | 两份 | `index.html`;**不要写成 `*`** |
+| `VITE_CHAT_API` | 对话后端(SSE)。**服务前缀**(`/chat-api`、`http://host:8000`)会自动补 `/api/chat`,完整地址则原样用 | 两份 | `api-chat-coordinator.ts`;**企业分类也走它**(`company-classification.ts`) |
+| `VITE_DMS_API` | DMS 数据服务(会话/问答/反馈/企业信息)。值是**同源前缀 `/dms`** | 两份 ✅ | `dms/client.ts`、`dms/file-upload.ts`;token 由前端自己带(见下) |
+| `VITE_OAUTH_LOGIN_URL` | 未登录访客换 DMS token 的登录端点(`/oauth/api/user/login`) | 两份 | `dms/service-token.ts`;**同源路径**,生产需 nginx 直通 `/oauth/` |
+| `VITE_IP_API` | 取本机 IP(DMS 访客归属 `访客_<IP>`)的**前缀**(代码再拼 `/proxy/verify/getIPByBrowser`) | 两份 | `client-ip.ts`;**必须同源**(网关只有 HTTP) |
+| `VITE_NLS_TOKEN_API` | 语音识别 token 端点(后端网关签发)**完整路径** | 两份 | `nls-token.ts`;**必须同源**,与取 IP 共用 `/api` 那条代理 |
+| `VITE_NLS_APPKEY` | NLS 的 appkey(**不是密钥**)。**2026-09-28 起已内置进代码**(`runtime-config.ts` 的 `NLS_APPKEY_BUILTIN`),本变量降级为"可选覆盖" | 可不出现在任何 env | 语音识别请求要带;`globalThis` 注入 → env → 内置值 的覆盖链 |
+| `VITE_REDIRECT_MAP` | `redirect.html` 的跳转映射(`#key` → 目标地址) | **仅生产** ⚠️ | `redirect.html`;dev 缺它 → 本地直接打开该页是语法错误页(登录链不受影响) |
+
+> 📌 **代理前缀方案(2026-09-29 用户指定)**:前缀**尽量与服务真实路径一致**,只有服务端本身不带前缀时才剥:
+> `/oauth/**`(→10086,直通)· `/dms/**`(→10081,直通,并注入 token)·
+> `/api/**`(→10011,**剥掉 `/api`**:网关真实路径是 `/proxy/verify/…`)· `/chat-api/**`(→聊天后端,剥前缀)。
+> 合并前是 `/dms-api`(补 `/dms`)+ `/ip-api` + `/aly-api`(后两条打的是同一台 10011)。
+>
+> 📌 **代理 target 直接写在 `vite.config.ts` 里,不走环境变量**(用户 2026-09-29 明确要求
+> 「vite.config.ts 里面不要加环境变量」)—— 该文件已不 `loadEnv`,四个 target 就是四行字面量。
+> **换地址=改那四行**,改完**重启 dev server**。
+> `.env.*` 只管**产物里用到的值**(`VITE_DMS_API` 等同源前缀)、以及注入用的 `globalThis`。
+>
+> ⚠️ **DMS 的 token:2026-09-29 起由「前端自己换」(用户要求)** —— 未登录访客用**默认账号**
+> (`src/network/api/dms/service-token.ts` 里内置)经 `POST /oauth/api/user/login` 换一个 JWT,
+> 缓存在内存里,随 DMS 请求带 `token` 头;代理(dev 的 vite / 生产的 nginx)**只在请求没带时才注入**。
+> 📌 **该账号是用户专门准备的「默认账号」**(用户 2026-09-29 确认:已处理过、就是给这个用途的)。
+> 仍存在的两个代价:① 密码在公开产物里(要收紧就改密码并同步改两处代码);
+> ② token 落在浏览器(能打开站点的人都能拿到一个 DMS 令牌)。
+> 技术债 **#8**(后端按调用者身份做行级授权)仍是"生产接入前必须处理"。
+>
+> 📌 **`.dms-token` 保留**(用户 2026-09-29 决定):前端已不依赖它,但它仍是**命令行工具**
+> 经 dev 代理读写 DMS 的凭据缓存(predev 会自动续期)。要彻底不落盘需要改的三件事,
+> 记在 `harness/tools/dms-token.mjs` 头部注释里。
+>
+> ⚠️ **生产没有 vite 代理**:`server.proxy` 只对 `npm run dev` 生效,而 `npm run preview`
+> 会**复用同一份**(容易误判"生产已通")—— 生产侧必须由 nginx 做等价转发。
+>
+> 🧹 **2026-09-28 顺带清掉的**:`VITE_DOWNLOAD_URL`(无读者,见
+> [`reference/current-api-call-sites.md`](reference/current-api-call-sites.md))、
+> `runtime-config.ts` 里无人调用的 `getApiBaseUrl()`(它读的 `globalThis.VITE_API` 从来没被赋值过)。

+ 69 - 34
harness/docs/exec-plans/tech-debt-tracker.md

@@ -90,56 +90,56 @@
   仍无 `text-size-adjust`、思考卡片仍靠 JS 测量切样式,`padding-bottom: 180px` 也仍是写死的)。
   **换台手机、或用户调大系统字号,仍可能变样**;方案 A/B/C 依然有效,等用户决定要不要做。
 
-### 8. DMS 代理注入全权限 token:站点可访问 = DMS 数据可读(部署前必须处理)
-
-- **是什么**:前端所有 DMS 请求都走 `/dms-api/` 代理,**token 由代理服务端注入**
-  (dev 是 `vite.config.ts`,生产计划是 nginx 同款转发)。这个 token **不区分调用者** ——
-  任何能打开站点的人,在浏览器 devtools 里对 `/dms-api/content/selectContentList` 发任意查询,
-  就能读到 DMS 的**全部**数据:1887 会话、1888 企业工商信息、1889 问答记录,
-  **包括别家企业的**。代理只做「补 `/dms` 前缀 + 注入 token」,不做任何鉴权。
-- **为什么先欠着**:还在联调阶段、**生产尚未接入**(`.env.production` 没配 `VITE_DMS_API`)。
-  dev 环境无所谓(本机、只有自己)。
+### 8. DMS 全权限 token:站点可访问 = DMS 数据可读(部署前必须处理)—— **2026-09-29 暴露面扩大**
+
+- **是什么**:DMS 的鉴权 token **不区分调用者** —— 任何能打开站点的人,在 devtools 里对
+  `/dms/content/selectContentList` 发任意查询,就能读到 DMS 的**全部**数据:1887 会话、
+  1888 企业工商信息、1889 问答记录,**包括别家企业的**。
+- **2026-09-29 的变化(暴露面扩大,用户知悉后仍要求这样做)**:
+  - 原来 token 只由**服务端**注入(dev 的 vite 代理 / 生产计划中的 nginx),浏览器不持有它;
+  - 现在**未登录访客由前端自己换 token**(`src/network/api/dms/service-token.ts`):
+    服务账号(`user_yszs_web`)**账号+密码内置在前端源码里** ⇒ **公开产物里能直接抠出密码**,
+    任何人可以自己登录 DMS;token 也落在浏览器内存里(可被 devtools/XSS 取走)。
+  - 📌 **该账号是用户专门准备的「默认账号」**(用户 2026-09-29 确认:已处理过,就是干这个用的)
+    —— 不再需要按"临时借用的个人账号"看待;要收紧时改密码 → 同步改
+    `service-token.ts`(前端)与 `harness/tools/dms-login.mjs`(node 侧)→ 重新构建。
+- **为什么先欠着**:还在联调阶段、生产尚未接入;用户明确选择"先这样做"。
 - **欠着会怎样**:**生产一旦按现方案接上,DMS 数据等同公开** —— 含企业工商信息与全部问答记录。
   这与「访客 / 登录」无关,是**部署层面**的暴露,登录态也挡不住(token 是共享的)。
 - **该还的条件(两条路)**:
   1. **推荐**:后端提供 DMS 转发接口,按调用者身份(登录企业 / 访客 id)做**行级授权**,
-     前端不再直连 DMS —— 与 `docs/plans/active/dms-api-replacement.md` 里"前端直连还是后端转发"
-     那道待拍板的问题是同一件事,**这里给出了必须选后端的理由**;
+     前端不再直连 DMS、也不再持有服务账号 —— 与 `docs/plans/active/dms-api-replacement.md` 里
+     "前端直连还是后端转发"那道待拍板的问题是同一件事,**这里给出了必须选后端的理由**;
   2. 临时缓解:nginx 只放行前端真正用到的端点**且**强制按调用者身份改写查询条件 ——
      实践上很难做对,不建议。
-- **现状**:**未处理**,生产接入前必须解决。
+- **现状**:**未处理**(暴露面比原来更大),生产接入前必须解决。
 
-### 9. 生产 / 青浦构建没配 DMS:附件传不上去、DMS 落库静默跳过
+### 9. 生产构建没配 DMS(附件传不上去、DMS 落库静默跳过)—— **2026-09-29 已还一半**
 
-- **是什么**:`.env.development` 里有 `VITE_DMS_API="/dms-api"` + `VITE_DMS_TARGET`,
-  而 `.env.production` **也没有**(2026-09-24 起只剩开发/生产两份 env,见 progress.md 续 38)。
-  这些环境构建出的包里 `getDmsApiBaseUrl()` 返回空串,后果两条:
-  ①`uploadDmsAttachment` 直接抛「未配置 DMS 接口地址,无法上传附件」→ **附件功能在这些环境整体不可用**;
+- **原来是什么**:`.env.production` 里没有 `VITE_DMS_API`(2026-09-24 起只剩开发/生产两份 env,
+  见 progress.md 续 38)⇒ 生产包里 `getDmsApiBaseUrl()` 返回空串,后果两条:
+  ①`uploadDmsAttachment` 直接抛「未配置 DMS 接口地址,无法上传附件」→ **附件整体不可用**;
   ②DMS 会话/问答/反馈请求走 `client.ts` 的「未配置就跳过」,**静默不落库**。
-  (构建产物可自证:`dist/index.html` 里 `globalThis.VITE_DMS_API = "%VITE_DMS_API%"` 原样留着。)
-- **为什么先欠着**:这两处是 2026-09-23 才接进来的(附件换 DMS、气泡图片修复),
-  当时只在开发环境验证过;生产怎么接需要**部署侧的决定**,不是改一行 env 就完。
-- **欠着会怎样**:部署到青浦后,用户传图片会弹「未配置 DMS 接口地址」,
-  且所有会话/问答记录不落 DMS(看起来像"功能没了")。
-- **该还的条件 / 要先拍板的两件事**:
-  ①DMS 地址(`http://121.43.55.7:10081`)从部署环境是否可达;
-  ②**token 谁注入** —— 开发环境是 **vite 代理在转发时加 `token` 头**(浏览器不持有 token,
-  见 `vite.config.ts` 的 `/dms-api/`),生产要由 nginx(或后端网关)做同样的事,
-  否则 DMS 会因鉴权失败而拒;顺带 `/dms-api` 还要把 `/dms` 前缀补回去。
-  ③`index.html` 的 `%VITE_DMS_API%` 支持运行时注入(`globalThis.VITE_DMS_API` 优先),
-  所以也可以**不改构建、只改部署产物里的这一行**——但同样绕不开 ②。
-- **当前状态**:仅登记,未处理。**需要用户决定生产怎么接。**
+- **✅ 2026-09-29 已配**:`.env.production` 现在也有 `VITE_DMS_API="/dms"`(与 dev 同值,
+  前缀直通,见 #11 的新前缀方案)⇒ "未配置"这条消失。
+- **还欠的(部署侧,只有一件)**:**nginx 把 `/dms/` 转发到 DMS 主机 `http://121.43.55.7:10081`
+  并在转发时注入 `token` 头**(前端不持有 token)。
+  - 转发**直通、不需要补前缀**(2026-09-29 起前缀与服务真实路径同名)
+  - 没配这一条会怎样:不再"静默不落库"而是**每次 DMS 请求都失败并告警**(client.ts 仍不抛,
+    聊天主链路不受影响);附件上传会报错。
+- **当前状态**:**配置已就位,差 nginx**。生产接入见 **#11**(含要拍板的地址/端口)。
 
 ### 10.(2026-09-24 已大半还清)生产的 NLS token 端点
 
 > **进展**:用户 2026-09-24 给了**后端网关的 token 端点**
 > `GET http://121.43.55.7:10011/proxy/verify/getALYToken` → `{code:200, content:"<token>"}`,
-> 前端已改为调它(同源 `/aly-api/…`,dev 走 vite 代理)。**前端侧不再需要 AK/SK**,
+> 前端已改为调它(同源 `/api/…`,dev 走 vite 代理)。**前端侧不再需要 AK/SK**,
 > vite 里那段自签中间件已删除。
 > **剩下的(部署侧,仍未做)**:
-> ① nginx 把 `/aly-api/` 转发到 `http://121.43.55.7:10011/`(去掉前缀,与 `/ip-api/` 同法);
-> ② **生产 env 要配 `VITE_NLS_APPKEY`**(实测:`.env.production`
-> 都没有 → 生产构建打出来的包里 appkey 为空 → 识别会失败)。
+> ① nginx 把 `/api/` 转发到 `http://121.43.55.7:10011/`(去掉前缀,与 `/api/` 同法)。
+> ~~② 生产 env 要配 `VITE_NLS_APPKEY`~~ —— **✅ 2026-09-28 已消**:appkey 直接内置进
+> `src/utils/runtime-config.ts` 的 `NLS_APPKEY_BUILTIN`(用户要求「直接写入使用的地方,
+> 不再放在环境变量里」)⇒ 生产不再需要注入,空 appkey 导致识别失败的风险也没了。
 > 详见 [`../reference/asr-nls.md`](../reference/asr-nls.md)。
 
 <details>
@@ -161,6 +161,41 @@
 
 </details>
 
+### 11. 生产环境接入(DMS + AI 对话):地址已给,**暂缓执行**(用户 2026-09-28)
+
+- **是什么**:把生产产物接到真实的生产后端。用户 2026-09-28 给出的地址:
+  **DMS `10.235.245.174`**、**AI 对话 `10.249.33.148`** —— **两个都没给端口**。
+- **为什么先欠着**:用户同日明确——「生产环境适配先不管,先标记待办,我们先处理别的问题」。
+- **欠着会怎样**:生产产物现在 ①`VITE_CHAT_API` 还指着**测试后端** `http://192.168.2.23:8000`
+  (`.env.production` 里就写着"生产部署时请改为实际后端地址");
+  ②**根本没有 `VITE_DMS_API`** → `getDmsApiBaseUrl()` 返回空串,附件弹「未配置 DMS 接口地址」、
+  会话/问答**静默不落库**(与 **#9** 是同一件事,#9 是 DMS 那一半)。
+- **动手前要先拍板的两件事**:
+  1. **端口**:沿用测试环境的 `8000`(对话)/ `10081`(DMS),还是别的;
+  2. **连接方式**:
+     - **同源路径**(`VITE_CHAT_API="/chat-api"`、`VITE_DMS_API="/dms"`)→ 部署侧 nginx 分别转到
+       两台机器;**DMS 那条还要注入 token**(前端不持有 token)。这也正是 **#8** 的暴露面所在
+       (token 不分调用者 → 站点可访问 = DMS 数据可读,接入前应一并解决);
+     - **直连绝对地址**(`http://10.249.33.148:<端口>`)→ 生产页面是 **HTTPS**
+       (`VITE_PARENT_ORIGIN = https://aixq.shqp.gov.cn/`),直连 `http://10.x` 会被浏览器
+       按**混合内容拦掉**,除非页面本身就跑在 http 下。
+- **2026-09-28 核对过的事实(复用时不用再查一遍)**:
+  - `vite.config.ts` 的 `server.proxy` **只对 `npm run dev` 生效**,`npm run build` 的产物里
+    没有任何代理逻辑;**生产要转发只能靠部署侧 nginx**。
+  - ⚠️ **`npm run preview` 会复用同一份 `server.proxy`**(vite 5.4.21 源码:`proxy: preview?.proxy ?? server.proxy`)
+    → "本地 preview 能通、上生产 404" 就是这个原因,别被它当成"生产已通"。
+  - ~~`*_TARGET` 只有 dev 代理读~~ → **2026-09-29 起那四个变量已删除**:
+    代理 target 直接写在 `vite.config.ts`(用户要求「不要加环境变量」)。
+    产物只认 `VITE_CHAT_API` / `VITE_DMS_API` / `VITE_IP_API` / `VITE_NLS_TOKEN_API` 的最终值。
+  - 生产 env 里的同源路径与 dev **同名同法**(2026-09-29 统一):
+    `/dms/**`(DMS + 政策库 json,直通并注入 token)· `/api/**`(网关:语音 token + 取 IP,**剥前缀**)。
+    nginx 照这两条配即可,**不再需要补 `/dms` 前缀**。
+  - **本机探测不可达**:`10.249.33.148:8000/80`、`10.235.245.174:10081/80` 均超时
+    (生产网段)→ 配完**只能由部署侧实测**,本机无法端到端验证。
+- **当前状态**:**部分完成** —— `.env.production` 的 DMS/网关同源路径已于 2026-09-29 配好
+  (见 #9),**剩下的仍是部署侧**:地址/端口、nginx 转发规则、以及"要不要给后端上 HTTPS"。
+  用户 2026-09-28 指示暂缓,等重新发起。
+
 ## 已还
 
 ### 语音识别已换阿里云 NLS(2026-09-24 已还)

+ 1 - 0
harness/docs/reference/README.md

@@ -17,6 +17,7 @@
 | `DMS_COLUMNS.md` | 📄 **栏目现状** | 查 DMS 五个栏目的 columnId / 字段时 |
 | `DMS_MAPPING.md` | 📄 **表→栏目映射** | 做数据迁移、核对字段映射时 |
 | `current-api-call-sites.md` | 📋 **现状清单** | 想知道「还有哪些功能在用老接口 / 哪些是死代码」时 |
+| `legacy-content-markers.md` | 📋 **只读清单(2026-09-28)** | 想知道「旧协议的内容标记(`<form>` / `<ui-form>` / `<link-url>` / `<file-docx>` / `<followup>` …)还有谁产出、谁消费」时 |
 | `asr-nls.md` | ✅ **现行实现(阿里云 NLS)** | 改实时语音识别之前(协议、事件、音频格式、验证方式) |
 | `asr-realtime.md` | 📦 **旧实现(厂商 SDK)** | 只在需要翻"换之前那套怎么工作"时读 |
 | `asr-realtime.md` | ✅ **现状记录 + 契约** | 改输入框语音(麦克风)相关代码之前 —— 含 SDK 的 WebSocket 协议、事件契约与 6 条已知坑 |

+ 13 - 9
harness/docs/reference/asr-nls.md

@@ -14,7 +14,7 @@
 token 由**后端网关**签发(2026-09-24 起,用户指定):
 
 ```
-浏览器 ──GET /aly-api/proxy/verify/getALYToken──▶ 同源代理
+浏览器 ──GET /api/proxy/verify/getALYToken──▶ 同源代理
         (dev:vite 代理 → http://121.43.55.7:10011;生产:nginx 转发同一路径)
                                                   ▼
                                     后端网关 ──(AK/SK 签名)──▶ 阿里云 nls-meta
@@ -22,9 +22,10 @@ token 由**后端网关**签发(2026-09-24 起,用户指定):
 浏览器 ──wss://nls-gateway-cn-shanghai.aliyuncs.com/ws/v1?token=…──▶ NLS 网关
 ```
 
-> ⚠️ 端点**只有 HTTP**、页面是 HTTPS → **必须走同源路径**,直连会被混合内容拦掉
-> (与 `/ip-api/` 同一个坑、同一台机器)。生产要在 nginx 里把 `/aly-api/`
-> 转发到 `http://121.43.55.7:10011/`(去掉前缀)。
+> ⚠️ 端点**只有 HTTP**、页面是 HTTPS → **必须走同源路径**,直连会被混合内容拦掉。
+> 它与**取本机 IP 是同一台机器(121.43.55.7:10011),2026-09-29 起共用 `/api/` 这一条代理**
+> (代理会**剥掉 `/api` 前缀**:网关真实路径是 `/proxy/verify/…`)。
+> 生产要在 nginx 里做同样的事:把 `/api/` 转发到 `http://121.43.55.7:10011/`(去前缀)。
 > 网关**不返回过期时间**,前端按官方文档的 24 小时估(`TOKEN_LIFETIME_MS`)。
 
 | 件 | 在哪 |
@@ -40,8 +41,8 @@ token 由**后端网关**签发(2026-09-24 起,用户指定):
 
 | 变量 | 值 | 说明 |
 |---|---|---|
-| `VITE_NLS_TOKEN_API` | `/aly-api/proxy/verify/getALYToken` | 同源路径;已在 `.env.development`,**生产三份 env 也要写** |
-| `VITE_NLS_APPKEY` | NLS 的 appkey | **不是密钥**(换 token 不需要它、网关也不校验它),但按约定不写进仓库:开发期在 `.env.development.local`(gitignored),**生产必须由部署侧注入,否则识别会因空 appkey 失败** |
+| `VITE_NLS_TOKEN_API` | `/api/proxy/verify/getALYToken` | 同源路径(与取本机 IP 共用 `/api` 代理,2026-09-29 起);**两份 env 都写了** |
+| `VITE_NLS_APPKEY` | NLS 的 appkey | **不是密钥**(换 token 不需要它、网关也不校验它),而且每次识别请求本就会把它明文发出去。**2026-09-28 起直接内置在代码里**(`src/utils/runtime-config.ts` 的 `NLS_APPKEY_BUILTIN`)—— **不再需要部署侧注入,也不要求 `.env.*.local` 里有它**;env / `globalThis` 仍可覆盖 |
 
 ## 2. 连接与指令
 
@@ -188,6 +189,9 @@ node harness/tools/probe-nls-realtime.mjs --say="青浦区高新技术企业奖
   —— 有源码级断言守着(`verify-nls-realtime.mjs` 的【4】)
 - ⏳ **需要用户在浏览器实测一次**:点麦克风说一句 → 输入框应边说边出字;再点一次停止 →
   文本保留。桌面端还应看到波形在动(波形走的是 `audioContext`/`source`,契约没变)
-- ⏳ **生产要由后端/网关提供 `/nls-token/`**(技术债 #10),否则线上拿不到 token
-- 📌 旧实现(`asr/index.ts`)**保留**:切回它只改 import,且它承载着"厂商中转"那条退路
-  (旧实现依赖的 `VITE_ASR` / `VITE_XF_ASR` 仍在四份 env 里)
+- ⏳ **生产只剩一件事**(技术债 #10):nginx 把 `/api/` 转发到 `http://121.43.55.7:10011/`(去前缀)。
+  **token 已由后端网关签发**(`/proxy/verify/getALYToken`,2026-09-24 起),前端不持有 AK/SK;
+  **appkey 也已内置进代码**(2026-09-28)⇒ 不再需要部署侧注入
+- ✅ **旧厂商实现及其退路已删除**(2026-09-24):`three-libs/asr/index.ts`、`VITE_ASR`、`VITE_XF_ASR`
+  与 vite 的 `/asr/` 代理都没了 —— `src/asr/` 下现在**只有** NLS 实现(`nls.ts` + `nls-protocol.ts`),
+  两个壳组件 import 的是 `@/asr/nls`

+ 61 - 19
harness/docs/reference/current-api-call-sites.md

@@ -14,22 +14,52 @@
 |---|---|---|---|
 | 1 | `POST {VITE_CHAT_API}/api/chat`(SSE) | 对话主链路 | `src/components/api-chat-coordinator.ts` |
 | 2 | `POST {同一后端}/api/company/classify` | 企业多标签分类 | `src/network/api/company-classification.ts` |
-| 3 | `{VITE_DMS_API}/content/*` | DMS 读写(1886/1887/1888/1889 会话·问答·反馈·企业信息) | `src/network/api/dms/client.ts` |
+| 3 | `{VITE_DMS_API}/content/*` | DMS 读写(1886/1887/1888/1889 会话·问答·反馈·企业信息)。**2026-09-29 起 token 由前端自己带**(未登录访客用服务账号现换,见 #17) | `src/network/api/dms/client.ts` |
 | 4 | `POST {VITE_DMS_API}/file/uploadFile` | 附件上传到 DMS 文件服务 | `src/network/api/dms/file-upload.ts` |
 | 5 | `GET {VITE_NLS_TOKEN_API}`(→ `http://121.43.55.7:10011/proxy/verify/getALYToken`) | 语音识别的 token(后端网关签发) | `src/network/api/nls-token.ts` |
 | 6 | `wss://nls-gateway-cn-shanghai.aliyuncs.com/ws/v1` | 实时语音识别(阿里云 NLS) | `src/asr/nls.ts` |
 | 7 | `GET {VITE_IP_API}/proxy/verify/getIPByBrowser`(→ 同上 10011) | 取本机 IP(DMS 访客归属) | `src/utils/client-ip.ts` |
 | 8 | `POST {VITE_API}/enterprise/login`、`GET {VITE_API}/fta_ent_policy/enterprise_info` | 企业登录换码 / 拉企业信息(**老接口**) | `src/network/api/enterprise.ts` |
-| 9 | `GET {VITE_DOWNLOAD_URL}merchant-agent/policies_public.v2.json` | 惠企政策库(本地 `/merchant-agent/…` 兜底;`VITE_POLICY_LOCAL_FIRST` 决定远端/本地优先级) | `index.html` + `Chat/PolicyMatch.vue` |
-| 10 | `GET {VITE_MODEL_SERVER}/v1/policies?…` | 政策「更多」面板列表(远端优先,取不到退本地库) | `Chat/PolicyMatch.vue` |
-| 11 | `GET <后端在正文里给的 link>` | 「政策列表(更新版)」:正文里 `<policy-list-updated>{link,payload}</policy-list-updated>` 由后端给出地址,前端照着取 json | `Chat/PolicyListUpdatedMatch.vue` |
+| 9 | `GET {VITE_POLICY_REMOTE_URL}`(= `/dms/static/file/1889-c_question-83859739-….json`) | 惠企政策库:**先取 DMS 里的那份,取不到再用本地副本**(`/merchant-agent/policies_public.v2.json`)。原厂商 `VITE_DOWNLOAD_URL` 与 `VITE_POLICY_LOCAL_FIRST` 开关已随本次改动删除(2026-09-24 用户要求) | `index.html` + `Chat/PolicyMatch.vue` |
+| ~~10~~ | ~~`GET {VITE_MODEL_SERVER}/v1/policies?…`~~ | **✅ 2026-09-28 已删**(政策列表的"第三档":库与卡片都为空才走到,实测从未触发)——连带删掉 4 个 helper 与两份 env 的 `VITE_MODEL_SERVER` | — |
+| ~~11~~ | ~~`GET <后端在正文里给的 link>`~~ | **✅ 2026-09-28 已删**(「政策列表(更新版)」:旧协议正文里 `<policy-list-updated>{link,payload}</policy-list-updated>` 给出地址、前端照着取 json)。与 #10 是**同一个接口**,只是地址由后端下发;用户同日确认「老对话仅测试、后续会全部清空」→ 唯一的触发源消失,组件与标签规则一并删除(见 [legacy-content-markers.md](legacy-content-markers.md)) | — |
 | 12 | `postMessage(…, VITE_PARENT_ORIGIN)` | 与父页面通信(嵌 iframe 场景) | `index.html` |
 | 13 | `VITE_REDIRECT_MAP` | 跳转映射页(纯本地,不发请求) | `redirect.html` |
+| ~~14~~ | ~~`GET https://hm.baidu.com/hm.js?d6307cb…`~~ | **✅ 2026-09-28 已删**(**百度统计**第三方脚本)。理由:数据进的是**厂商账号**(站点配置 `dm:["demo.heijingai.com"]`)、本项目从未用过它的数据(`git log -S '_hmt.push'` 全历史为空)、且它是全站**唯一**往第三方发请求、并在青浦域名上写 cookie 的东西 —— 用户当日确认「删掉」 | — |
+| 15 | `GET https://zwdt.sh.gov.cn/qykj/…/policy-detail?id=<市级政策id>` | 政策详情页(**外部导航**,`target=_blank`,不是接口) | `Chat/policy-library-utils.ts` 的 `buildPolicyDetailUrl` |
+| 16 | `https://zwdtuser.sh.gov.cn/uc/login/login.jsp?redirect_uri=…` | 企业登录(**外部导航**,跳一网通办,登录后经 `redirect.html` 回来) | `components/useEnterpriseAuth.ts` 的 `redirectToLogin` |
+| 17 | `POST {VITE_OAUTH_LOGIN_URL}`(= `/oauth/api/user/login` → `121.43.55.7:10086`) | **未登录访客换 DMS token**(前端用**内置服务账号**换,2026-09-29 起) | `src/network/api/dms/service-token.ts` |
+
+> 📌 **2026-09-28 按代码重核时补的三条**(此前清单漏记,复核方法见 §四):
+> - **#14 百度统计**(**已删**,原始排查记录留在这里备查):它是**唯一会加载第三方脚本、
+>   把页面访问数据发给外部服务**的东西,且**没有环境开关**(`index.html` 底部无条件插入,dev 也一样发请求)。
+>   它和 2026-09-18 删掉的自家「使用统计埋点」不是一回事,之前清点时漏了。
+>   - **删除时间/范围**:2026-09-28 用户确认删;`index.html` 里那 9 行整块移除(原位留一条 `[已删]` 说明),
+>     全仓库无其它引用。复核:`grep -rn 'hm\.baidu\|_hmt'` 只剩注释;产物里也只剩注释。
+>   - 站点 id `d6307cb011c91628ce6be043f40d510a` 抓下来的 hm.js 里写着
+>     `dm:["demo.heijingai.com"]`(**厂商演示站的域名**,不是青浦的域),
+>     `etrk/cetrk/cptrk/ctrk` 全空 ⇒ **没有配置任何事件/转化跟踪**,只有默认 PV/UV;
+>     数据进的是**那个厂商账号**。
+>   - **2026-09-28 用 jsdom 实测三个域**(拦了 img/xhr/sendBeacon、不真发请求):
+>     `aixq.shqp.gov.cn` / `demo.heijingai.com` / `localhost` **都会**写 cookie
+>     (`Hm_lvt_<id>` / `Hm_lpvt_<id>` / `HMACCOUNT`,写在当前域上)并尝试上报
+>     `https://hm.baidu.com/hm.gif?hca=…&cl=24-bit&ds=…&vl=…` ⇒
+>     **`dm` 白名单不阻止客户端上报**(若有校验也在服务端,未验证)。
+>   - 脚本 CDN 有个观察:请求**不带 Referer 时返回 0 字节**(curl 实测两次),带 Referer 才给 ~30 KB。
+> - **#15 / #16 是浏览器导航、不是 XHR**:前者由政策库条目的 `data.市级政策id` 拼出(261/262 有该字段,
+>   见 `policy-library-utils.ts`);后者是把当前地址交给一网通办做 `redirect_uri`,
+>   所以 `redirect.html` 必须是一个能直接打开的静态页(见 §一 的说明)。
+> - ⚠️ ~~**`VITE_DOWNLOAD_URL` 是死配置**~~ → **✅ 2026-09-28 已删**:它只定义在 `.env.production`,
+>   全仓库**没有任何读者**(`index.html` 的本地兜底早写死 `/merchant-agent/policies_public.v2.json`)。
+>   现在两份 env 都没有它,**生产侧 nginx 也不再需要转发 `/api/download/`**。
+>   护栏:`harness/tools/verify-policy-library.mjs` §10(变异测试:放回去立刻变红)
 
 > 📌 **2026-09-24 的两处更正**(都因为只 grep `src/` 会误判):
 > ① `VITE_APP_KEY` 曾被我写成"死配置",其实 `index.html` 的 `openid_to_token` 在用 ——
 > **后来那条 `openid` 引导链整段删除**(两个环境都走不到,见 §6),`VITE_APP_KEY` 随之删除;
-> ② `VITE_USE_KNOWLEDGE_API` 也不是死配置 —— `index.html` 在用它切「政策解读」卡片。
+> ② `VITE_USE_KNOWLEDGE_API` 当时也不是死配置 —— `index.html` 在用它切「政策解读」卡片;
+> **但那段的产物(硬编码 `cardInfo`)全仓库无读者、`document.title` 上面已设过 →
+> 2026-09-28 连同 `globalThis.isKnowledgeApi` / `globalThis.modelServer` / `cardURL` 一起删除**。
 
 ### B. 已停用 / 死代码 —— **✅ 2026-09-24 已按用户要求全部删除**
 
@@ -66,12 +96,19 @@
 
 ### D. 仍在用、别误删的环境变量
 
-`VITE_API`(#8 + index.html 的 modelServer)· `VITE_CHAT_API` · `VITE_DMS_API` · `VITE_IP_API` ·
-`VITE_NLS_TOKEN_API` · `VITE_NLS_APPKEY` · `VITE_DOWNLOAD_URL` · `VITE_POLICY_LOCAL_FIRST` ·
-`VITE_MODEL_SERVER` · `VITE_PARENT_ORIGIN` · `VITE_REDIRECT_MAP`(`redirect.html` 用)· `VITE_VERSION` ·
-四个 `*_TARGET`(vite 代理用)· `VITE_USE_KNOWLEDGE_API`(经 `globalThis.isKnowledgeApi`,
-**`index.html` 在用它切「政策解读」卡片**)。
-已删:`VITE_ASR` / `VITE_XF_ASR`(随旧 ASR)、`VITE_APP_KEY`(随 §6 的 `openid` 链)。
+`VITE_API`(#8 企业登录在用)· `VITE_CHAT_API` · `VITE_DMS_API` · `VITE_IP_API` ·
+`VITE_NLS_TOKEN_API` · `VITE_NLS_APPKEY`(**2026-09-28 起已内置进代码**,本变量只作可选覆盖) ·
+`VITE_POLICY_REMOTE_URL`(政策库远端,2026-09-24 新增) ·
+`VITE_PARENT_ORIGIN` · `VITE_REDIRECT_MAP`(`redirect.html` 用)· `VITE_VERSION` ·
+`VITE_OAUTH_LOGIN_URL`(#17 换 token 用,2026-09-29 新增)。
+(原有的四个 `*_TARGET` **已于 2026-09-29 删除** —— 代理 target 直接写在 `vite.config.ts` 里,见下。)
+
+> 📌 **同源前缀(2026-09-29 统一)**:`/oauth/**` → 10086(直通)· `/dms/**` → 10081(直通 + 注入 token)·
+> `/api/**` → 10011(**剥前缀**;语音 token 与取 IP 共用)· `/chat-api/**` → 聊天后端(剥前缀)。
+> 注意 `/api` 这条**不是** #8 的企业登录网关(那个走 `VITE_API` 的绝对地址)。
+已删:`VITE_ASR` / `VITE_XF_ASR`(随旧 ASR)、`VITE_APP_KEY`(随 §6 的 `openid` 链)、
+**`VITE_MODEL_SERVER`(随 #10 的第三条腿)、`VITE_USE_KNOWLEDGE_API`(随 index.html 那段死枝,
+见 #6)—— 两者都是 2026-09-28 删的**。
 
 **环境只剩两份(2026-09-24 用户要求)**:`.env.development` + `.env.development.local`(本机凭据,
 gitignored)+ `.env.production`。`qingpu` / `test` 两个模式已删除,脚本也随之调整
@@ -90,7 +127,7 @@ gitignored)+ `.env.production`。`qingpu` / `test` 两个模式已删除,脚
 | 点赞/点踩/取消 | DMS 1889 的 `c_feedback_*` | |
 | 删除会话 | DMS(清 1889 记录 + 1887 旧行) | |
 | 政策卡片与协议内容 | 随 `/api/chat` 下发 | |
-| 惠企政策库数据 | 静态 json `policies_public.v2.json`(2026-09-24 用户更换:170 条、中文键;**远端 404,实际走打包进 dist 的本地副本**) | |
+| 惠企政策库数据 | 静态 json `policies_public.v2.json`(2026-09-24:170 条、中文键;远端换成 **DMS 文件服务**里的那份,取不到退回打包进 dist 的本地副本) | |
 
 > ⚠️ **1887 助手会话的写入范围(2026-09-23 时的准)**
 >
@@ -192,11 +229,15 @@ wss://{VITE_XF_ASR}/api/human_asr/v2/asr?Authorization=...
 - 但代码与 OSS 地址仍活着(地址还是**硬编码**在 `useBusinessAssistantUpload.ts` 里的),
   等后端定了附件契约才能接上
 
-### 5. 政策事项列表的「远端查询」—— **几乎不可达**
+### 5. ~~政策事项列表的「远端查询」~~ —— **✅ 2026-09-28 已删**
 
 - 原设计:列表数据本地都没有时,去模型服务查 `{VITE_MODEL_SERVER}/v1/policies`
-- 现状:`fetchPolicyList` 里前面两档(惠企政策库 262 条 / 本轮卡片兜底)**几乎总能命中**,
-  第三档基本走不到
+- 现状:`fetchPolicyList` 里前面两档(惠企政策库 / 本轮卡片兜底)**几乎总能命中**,
+  第三档基本走不到 —— 用户 2026-09-28 要求「不再依赖原来的政策相关接口」→ 整条删除
+  (连带 `policy-match-utils.ts` 的 4 个 helper 与两份 env 的 `VITE_MODEL_SERVER`)
+- ⚠️ **注意 #11 也同日删了**:后端在正文里下发的 `<policy-list-updated>` 给的**也是这个接口的地址**,
+  它一度仍在用、由 `PolicyListUpdatedMatch.vue` 按 link 取;同日晚些时候用户确认
+  「老对话仅测试、后续会全部清空」→ 那条也删掉了。**现在前端没有任何"后端给地址再去取"的路径**。
 
 ### 6. ~~`openid` 换 token~~ —— **✅ 2026-09-24 已删除**
 
@@ -210,7 +251,7 @@ wss://{VITE_XF_ASR}/api/human_asr/v2/asr?Authorization=...
 ### 6b. 附件上传 —— **已改走 DMS 文件服务(2026-09-23)**
 
 - **现在**:`POST {VITE_DMS_API}/file/uploadFile`(→ `http://121.43.55.7:10081/dms/file/uploadFile`,
-  经 `/dms-api` 代理)。`multipart/form-data`,**五个字段都必填**:
+  经 `/dms` 代理)。`multipart/form-data`,**五个字段都必填**:
   `file` / `type`(0 图·1 音·2 视·3 文件)/ `columnId`(1889) / `contentId`(0) / `paraName`(c_question)。
   返回 `{"code":200,"content":"/static/photo/1889-c_question-<uuid>.png"}` —— **相对路径**,
   它随聊天请求体的 `pictures` / `files` 发给后端(见 `api-chat.md` 的多模态节)
@@ -228,7 +269,7 @@ wss://{VITE_XF_ASR}/api/human_asr/v2/asr?Authorization=...
   (响应头 Content-Type 标的是 `application/json`,但正文就是裸 IP —— 按 text 读,别 JSON.parse)
 - **必须走同源代理**:接口是 `http://121.43.55.7:10011`,页面是 HTTPS ——
   接口带 `Access-Control-Allow-Origin: *`(跨域没问题),但**混合内容**会被浏览器拦掉。
-  dev 见 `vite.config.ts` 的 `/ip-api/`;生产需要 nginx 把 `/ip-api/` 转发到
+  dev 见 `vite.config.ts` 的 `/api/`;生产需要 nginx 把 `/api/` 转发到
   `http://121.43.55.7:10011/`(**去掉前缀**)
 - 用途:DMS 1887 的 `c_ip` 存这个 IP;访客身份(1889 的 `c_credit_code`)用 `访客_<IP>`
 - **拿不到 IP 不致命**:退回 `访客_<浏览器随机 id>`,只是归属不稳定(见 `src/utils/client-ip.ts`)
@@ -239,10 +280,11 @@ wss://{VITE_XF_ASR}/api/human_asr/v2/asr?Authorization=...
 |---|---|---|
 | `src/network/api/card/index.js`(`getMediaList` / `giveFeedback` / `appendHistory`) | 卡片媒体信息、点赞点踩的**旧接口版落库**、追加服务端历史 | ✅ **2026-09-18 整文件删除**(连同 `useBusinessAssistantChat.ts` 里那个未使用的 `import cardAPI`) |
 | `src/network/api/assistant-statistics.ts` | 使用统计埋点 | ✅ **2026-09-18 整文件删除**(访客 id 那部分已搬进 DMS 模块,见第二节) |
-| `PolicyListUpdatedMatch.vue` | 旧协议 `<policy-list-updated>` 卡片列表 | ⚠️ **保留**:仍被 `BusinessRecord` 挂载,**老会话的历史内容里可能还有这种块**,删了那些卡片列表就渲染不出来 |
+| ~~`PolicyListUpdatedMatch.vue`~~ | 旧协议 `<policy-list-updated>` 卡片列表 | ✅ **2026-09-28 已删** —— 它一度被"有意保留"(对旧历史仍有效),但用户当日确认「老对话仅测试、后续会全部清空」,保留理由消失。连带删除:`BusinessRecord` 的挂载点/import、`StreamXMLFilter` 的枚举成员与标签规则。**副作用**:清空前打开旧测试会话会看到那串原始 JSON 文本(用户已确认接受) |
 
 > 删除的代码都在 git 历史里(`git log --diff-filter=D --name-only` 可查)。
-> 上面「保留」那条不是遗漏 —— 它虽对新协议无效,但**对旧历史仍然有效**。
+> 上一条曾是「保留」——现在已删;其余旧协议标记的存活情况见
+> [`legacy-content-markers.md`](legacy-content-markers.md)(只读清单,未动代码)。
 
 ## 四、复核方法(可重跑)
 

+ 81 - 0
harness/docs/reference/legacy-content-markers.md

@@ -0,0 +1,81 @@
+# 旧协议内容标记存活清单(只读)
+
+> **核对时间**:2026-09-28(用户「老对话仅测试,后续会全部清空」→ 要求顺带把
+> 其他只服务旧协议的块查一遍)
+> **性质**:**只读排查** —— 除 `<policy-list-updated>`(本次已删,见 §一)外,本清单
+> **没有据此改任何代码**;结论供用户决定下一步删什么。
+> **方法**:全仓库 grep `<标记`,逐个反查三件事:
+> ① `stream-xml-filter.ts` 里有没有规则 → ② 谁消费(渲染组件 / `customProcessor`)→
+> ③ **现在谁产出**(新协议适配层?旧会话历史?开发环境 mock 文本?)。复核命令见 §四。
+
+## 一、结论表
+
+| 标记 | 过滤器规则 | 消费端 | 现在的产出者 | 结论 |
+|---|---|---|---|---|
+| `<scope>` | block([stream-xml-filter.ts:53](../../../src/utils/stream-xml-filter.ts)) | `ScopeContent` | **适配层**(progress / heartbeat → 思考中卡片) | ✅ 在用 |
+| `<!-- POLICY_TABLE … -->` | 特殊块 | `PolicyMatch` | **适配层**(item 事件) | ✅ 在用 |
+| `<ref_links>` | block(:58) | `BusinessRecord` 参考资料 | **适配层**(source 事件) | ✅ 在用 |
+| `<question-cards>` | block(:77) | `QuestionCard` | **适配层**(interrupt 事件) | ✅ 在用 |
+| `<image-scope>` | block(:55) | `ImageContent` | **适配层**(result.cipa → 企业微信二维码) | ✅ 在用 |
+| ~~`<policy-list-updated>`~~ | ~~block~~ | ~~`PolicyListUpdatedMatch`~~ | 无 | 🗑 **2026-09-28 已整条删掉**(见下节) |
+| `<form>` | block(:52) | `FormContent`(BusinessRecord:116) | **无** —— src 里没有产出代码 | ⚠️ 只有旧会话历史能触发 |
+| `<link-url>` | block(:54) | `LinkURLContent`(:117) | **无** | ⚠️ 同上 |
+| `<file-docx>` | block(:59-64) | `OfficeURLContent`(:125) | **无** —— 附件走请求体 `pictures`/`files`,不走正文标记 | ⚠️ 同上 |
+| `<ui-form>` | skip(:65-70) | `customProcessor` → `setFormOptions`(BusinessRecord:561-574) | **无** | 💀 **整条链是死的**:两个壳组件 provide 的 `setFormOptions` **都是空函数**(`BusinessAssistantMobile.vue:424` / `BusinessAssistantPC.vue:366`),即便有内容也不产生任何界面变化 |
+| `<followup>` | skip(:71-76) | **无人注册** —— `customProcessor` 里只有 `UIForm` 一项 | 只有**开发环境测试按钮**的 mock 文本(`test-document-line-stream.ts:20`、`useBusinessAssistantChat.ts:1466`) | 💀 **全链死**:`followupSuggestions` 全仓库**没有任何写入点**(旧协调器删除后就没接回),块被静默丢弃 |
+| `<silence>` | skip(:51,透明容器) | 无(只是壳) | 同上,仅那两处 mock 文本 | 💀 只剩 mock;`interrupted-message.ts:19` 还会剥它,属无害残留 |
+| `<qingpu_enterprise_notificaion>` | skip(:78-83) | **无人注册** `customProcessor` | **无** | 💀 死规则(`AccountInfo.vue` 里的 `ENTERPRISE_BUBBLE_EVENT` 是另一个 DOM 事件,与此无关) |
+
+> 行号是 **2026-09-28 改动后**的(删掉 policy-list-updated 规则会让后面的行号前移一位)。
+
+## 二、本次已删:`<policy-list-updated>`
+
+- **原用途**:旧协议里后端在正文下发 `<policy-list-updated>{link, payload}</policy-list-updated>`,
+  给出一个**地址**,前端(`PolicyListUpdatedMatch.vue`)照着去取 json 渲染卡片列表,
+  并把结果写回 `globalThis.policiesPublic`。这是接口清单 #11(见
+  [`current-api-call-sites.md`](current-api-call-sites.md))。
+- **为什么能删**:新协议适配层**从不产出**这个块(它只产 `scope` / `POLICY_TABLE` /
+  `ref_links` / `question-cards` / `image-scope`,见
+  [`api-chat-coordinator.ts:10-21`](../../../src/components/api-chat-coordinator.ts))。
+  它唯一的触发源是**旧会话的历史内容**——用户 2026-09-28 确认「老对话仅测试,后续会全部清空」,
+  这条保留理由随之消失。
+- **删除范围**:`src/components/Chat/PolicyListUpdatedMatch.vue` 整文件 ·
+  `BusinessRecord.vue` 的挂载点 / import / `hasPolicyTableRow` 里的分支 ·
+  `StreamXMLFilter` 的枚举成员与标签规则 · `PolicyMatch.vue` 的说明注释 ·
+  接口清单 #11 与本文档。
+- **⚠️ 已知副作用(用户已确认接受)**:规则删掉后,**清空之前**若有人打开旧测试会话,
+  正文里那串 `<policy-list-updated>{"link":…}</policy-list-updated>` 会**当普通文本显示**
+  (既没有卡片列表,也不再被吞掉)。新会话不受影响。
+
+## 三、剩下的怎么处理(建议,未动手)
+
+按「删了会不会有人看出来」分两档:
+
+1. **`<ui-form>` / `<followup>` / `<silence>` / `<qingpu_enterprise_notificaion>`** ——
+   已经没有任何效果(消费端空实现或无人注册),删规则不会有任何可见变化。
+   要动的话删规则即可,**枚举成员与 `FormContent` / `LinkURLContent` / `OfficeURLContent`
+   不要跟着删**(理由见下)。
+2. **`<form>` / `<link-url>` / `<file-docx>`** —— 渲染组件是活的(旧历史里仍能画出来)。
+   删规则 = 旧会话那几处变成原始文本,与 policy-list-updated 同一个副作用。
+   既然旧对话要清空,这批也可以走同一条路;**但建议等清空动作真的做完再删**,
+   免得清空前有人翻旧会话时看到一片标记。
+
+> 另有一批**与旧协议无关**的历史包袱在 [`current-api-call-sites.md`](current-api-call-sites.md)
+> §二/§三(已停用接口、死代码),不在本文范围。
+
+## 四、复核方法(可重跑)
+
+```bash
+# ① 每个标记的产出者:排除过滤器与消费组件后应当为空(或只剩开发环境 mock)
+grep -rn "<followup\|<ui-form\|<link-url\|<file-docx\|<form" src/ \
+  | grep -v "stream-xml-filter\|Content.vue\|test-document-line-stream"
+
+# ② 谁注册了 customProcessor
+grep -n "RecordRowTypes\.[A-Za-z]*," src/components/Chat/BusinessRecord.vue
+
+# ③ 两个壳组件的 setFormOptions 是不是空函数
+grep -n -A1 "formOptionsControl" src/components/BusinessAssistantMobile.vue src/components/BusinessAssistantPC.vue
+
+# ④ followupSuggestions 有没有写入点(应当没有)
+grep -rn "followupSuggestions\s*=" src/
+```

+ 675 - 0
harness/progress.md

@@ -4280,6 +4280,156 @@ payload 里含模型里**不存在**的列时,`addContent` 会忽略该列照
   切换说明)、`current-api-call-sites.md`(§0 A 表第 6 行、§三 的旧路径标注为已删)、
   `asr-realtime.md`(历史文档里指向已删文件的**链接**改成纯文本,避免 404 链接)
 
+### 续 50:删掉政策列表「第三条腿」与 index.html 两处死枝(用户要求「动手删 1、2」)
+
+**用户指示**:在我列的"现存接口 + 可删项"清单里,让我**删 ① 和 ②**。
+
+**① 政策列表「第三条腿」:`{VITE_MODEL_SERVER}/v1/policies`**
+- 它需要「惠企政策库为空 **且** 本会话卡片也为空」才会走到 —— 实测从未触发
+- 删掉:`PolicyMatch.vue` 的 `fetchPolicyList` 里整段远端/本地分支(现在只剩"库 → 卡片兜底"两档)、
+  `policy-match-utils.ts` 的 4 个 helper(`buildPolicyListSearchParams` / `buildPolicyListUrl` /
+  `getLocalPolicyListResponse` / `extractPolicyListResponse`,共 136 行)及只被它们用到的
+  类型与内部函数(`PolicyListQuery` / `PolicyListResponse` / `getAutoGrantedParam` /
+  `getPolicyText` / `readItems` / `readTotal`)、两份 env 的 `VITE_MODEL_SERVER`;
+  顺带清掉随之失去写入点的 `policyListLoading` / `policyListRequestId`
+- ⚠️ **没有**碰 `<policy-list-updated>`(#11):它虽然也打这个接口,但走的是**后端给地址**那条
+  独立路径(`PolicyListUpdatedMatch.vue`),删第三条腿不影响它
+
+**② `index.html` 里"写了没人读"的两处死枝**
+- `var cardURL = globalThis.modelServer;`(无读者)
+- `if (globalThis.isKnowledgeApi) { document.title = …; globalThis.cardInfo = {…} }` 整块 84 行
+  (`document.title` 上面已设过;`cardInfo` 全仓库无读者)
+- 连带删掉:`globalThis.isKnowledgeApi` 注入行、`globalThis.modelServer` 赋值、
+  两份 env 的 `VITE_USE_KNOWLEDGE_API`
+- ⚠️ `VITE_API` **保留** —— 企业登录接口(`enterprise.ts`)在用,只是不再往 `globalThis.modelServer` 上放
+
+**过程中踩的坑(第 4 次 shell 引号问题)**:先用 `awk` 找"顶格 `}`"当块边界,结果 270 行那个 `}` 是
+**内层**的、删多了 → 内联脚本 `node --check` 报 `Unexpected end of input`;改用 `node -e '…'` 重写时
+脚本里的**单引号**又把 shell 字符串截断。最终**把一次性脚本写成 `harness/tools/_strip-*.mjs`
+(Write 工具落盘,跑完即删)**,用**括号配平**(跳过字符串与注释)定位块边界才删对。
+教训:改动 HTML/JS 块的边界不要靠"看缩进",要靠配平;一次性脚本落盘比塞进 shell 稳。
+
+**运行过的验证**:
+- `verify-policy-library` 43 → **49/49**(新增第 9 节 6 项:PolicyMatch 无 `VITE_MODEL_SERVER`/`v1/policies`
+  调用、utils 不再导出那 4 个 helper、两份 env 都没有 `VITE_MODEL_SERVER`、index.html 无
+  `globalThis.modelServer`/`isKnowledgeApi`/`cardURL`/`cardInfo`、两份 env 都没有
+  `VITE_USE_KNOWLEDGE_API`、`VITE_API` 仍保留)。
+  ⚠️ 这 6 条断言第一版被**我自己写的「已移除」注释**绊红(约定要保留注释)→ 改成**先剥注释再查**
+- **变异测试**:把 `VITE_MODEL_SERVER` 引用放回 `PolicyMatch.vue` → 立刻 FAIL,还原后 49/49
+- `npm run build` 通过;`verify-inline-scripts` **6/6**(index.html 动过,这个必跑);
+  政策卡列表 24/24、卡字段 8/8、详情反查 10/10;`vue-tsc` 仅既有 webpack-env
+- 顺手核对 dev server 提供出来的页面:`isKnowledgeApi`/`cardURL`/`modelServer`/`cardInfo`
+  **只剩注释里那两行**,`globalThis.orgTitle`、`globalThis.policiesPublic` 等关键全局都还在
+
+**🔴 删 ② 时误删了加载遮罩的收尾信号(同轮修回)**:
+用户反馈「遮罩层一直都在」→ 查证:`if (globalThis.isKnowledgeApi) { 硬编码 cardInfo }` 这一块的**最后 4 行**
+其实是**活的**(`globalThis.hasSequence = true;` + `dispatchEvent(dataLoaded)` +
+`dispatchEvent(sequenceLoaded)` + `addVersionInfo()`)—— 它们是**隐藏 `#loading-cover` 的信号**
+(`BusinessAssistant.vue` 的 `initSequence()` 靠 `hasSequence` 或等 `sequenceLoaded` 事件),
+被我用括号配平整块删掉了 → 页面永久卡在「正在初始化,请稍候...」。
+**为什么没被验证拦住**:`verify-inline-scripts` 只查**语法**(删完语法是对的),语义丢了它看不见 ✗
+⇒ 修法:把这 4 行移到 `initMessageChannel()` 之后**无条件执行**(原来是 `isKnowledgeApi` 为真才走,
+而两个环境都是 true → 语义等价);并在 `verify-inline-scripts.mjs` 加【3】3 条断言钉住
+(产物里必须有 `hasSequence = true`、`sequenceLoaded` 派发、`addVersionInfo()` 调用),
+**变异测试**:阉掉 `hasSequence` → 立刻 FAIL,还原后 9/9。
+另:逐条核对了被删块里的 5 条语句(`document.title` 重复设置 / `cardInfo` 76 行死数据 /
+上面那 4 行活信号),确认没有别的遗漏。
+
+**教训(同一天第二次"删多了")**:删代码块前要**逐条列出块内语句并判断活性**,
+不能只看"块名听起来是死的";语法检查拦不住语义丢失。
+
+**本轮改动全部未提交**(用户 2026-09-24 起要求不自动提交)。
+
+### 续 49:修「部门筛选出现了没有内容的项」(我 2026-09-24 的选项收集有 bug)
+
+**用户反馈**(附截图:下拉里 5 个正常部门之后,跟了一条又长又怪、还带换行的"部门"):
+「政策事项列表的部门筛选有问题,你确定使用的是政策所属字段吗」
+
+**先按用户的问题核对字段(结论:筛选用的字段没错)**:
+- 那个接口的 `department` 与 `政策所属部门` 实测**完全一致**(20/20)
+- 本地/DMS 那份 v2 库**没有**英文 `department`,`normalizePolicy` 的 `source` 取值链
+  (`department → 部门 → source → 政策所属部门`)最终落到 **`政策所属部门`** ✓
+- 拿真实数据逐个部门跑一遍筛选:**每个选项都筛得出内容**(区市场监管局 48、区经委 33、
+  区文旅局 32、区人社局 22、区商务委 20、区农业农村委 7、区人才局 5、区卫健委/区民政局/区财政局 各 1)
+
+**真正的 bug 在"选项收集",不在字段**:我在 `collectDeptOptions` 里把
+**卡片里出现过的部门**(`policies.value.map(item => item?.source)`)也并进了下拉当兜底 ——
+而卡片的 `source` 可能是**多部门串**。实测原始回答里就有:
+
+```
+"source":"上海市商务委员会、上海市发展和改革委员会、上海市经济和信息化委员会、上海市科学技术委员会\n市商务委等"
+```
+
+于是下拉里出现了一个又长、又带换行、**还筛不出任何东西**的假部门(截图里那条就是它)。
+
+**修法**(`PolicyMatch.vue` 的 `collectDeptOptions`):
+1. **下拉只取政策库里 `政策所属部门` 的值**(按用户 2026-09-24 的要求:只列 JSON 里存在的部门),
+   彻底不再并入卡片来源
+2. 顺手加 `looksLikeSingleDept` 守卫(长度 ≤12 且不含 `,,、` 与空白)—— 库里将来若真混进
+   这种"多部门串",也不会被列成假选项
+
+**运行过的验证**:`verify-policy-library` 40 → **43/43**(新增 3 项:下拉只来自政策库、
+脏值守卫存在、**数据层面断言库里的部门取值都是单一部门名**);
+**变异测试**:把卡片并入恢复 + 去掉守卫 → **2 项立刻 FAIL**,还原后 43/43。
+回归:政策卡列表 24/24、问卷 36/36、内联脚本 6/6、`npm run build` 通过、
+`vue-tsc` 仅既有 webpack-env、`validate-harness` 通过。
+
+**⚠️ 仍需用户在浏览器确认**:下拉里应只剩那 10 个真部门,且每个都能筛出内容。
+
+**本轮改动全部未提交**(用户 2026-09-24 起要求不自动提交)。
+
+### 续 48:政策库远端改为 DMS 文件服务(用户要求,为"不再依赖旧政策接口"铺路)
+
+**用户要求**:「把 policies_public.v2.json 上传到 DMS,记住那个路径,那个路径就是新的远端政策接口,
+后续先从那个接口拿,拿不到再用本地的兜底,最终目标是不再依赖原来的政策相关接口。」
+
+**上传**(新增可复跑脚本 [`upload-policy-library.mjs`](tools/upload-policy-library.mjs),
+用**已有的上传模块** `uploadDmsAttachment`,与附件同一套实测契约):
+- 相对路径:`/static/file/1889-c_question-83859739-17d2-4b7f-b427-7ee27e098e79.json`
+- 前端取用(同源、拼了代理前缀):`/dms-api/static/file/1889-c_question-83859739-….json`
+- 脚本自带**自检**:上传后立刻 fetch 回来比对条数(170 ✓ 与本地一致),不一致就退出码 1
+
+**接线**(`index.html` + `.env.development` / `.env.production`):
+- 新增 `VITE_POLICY_REMOTE_URL` = 上面那个同源路径;`remoteURL = "%VITE_POLICY_REMOTE_URL%"`
+- **顺序固定为「先 dms、取不到再用 local」**;同时**删掉 `VITE_POLICY_LOCAL_FIRST` 开关**
+  (它当年是为了绕开"厂商远端那份没有市级政策id",远端换成 DMS 那份后不再需要)
+- 删掉 `VITE_DOWNLOAD_URL`(旧厂商下载域名)——它是"原来的政策相关接口"之一
+- `check-policy-sources.mjs` **改职责**:从"判断本地优先开关要不要留"改成
+  **核对 DMS 远端与本地副本内容是否一致**(跑通:170/170、部门 10 个,✅ 一致)
+
+**运行过的验证**:`verify-policy-library` 35 → **40/40**(新增第 8 节 5 项:远端用
+VITE_POLICY_REMOTE_URL、指向 `/dms-api/static/…`、本地兜底仍在、顺序固定 dms→local、
+本地优先开关已删);`check-policy-sources` ✅ 一致;政策卡列表 24/24、卡字段 8/8、
+详情反查 10/10;`npm run build` 通过且**产物核对**(`remoteURL`/`localFallbackURL` 两行与
+`label: "dms"` → `label: "local"` 的降级顺序);`verify-inline-scripts` 6/6;`validate-harness` 通过。
+
+**⚠️ 还剩两条"原来的政策相关接口"(用户目标是最终不再依赖,等指示再动)**:
+① `{VITE_MODEL_SERVER}/v1/policies`(政策「更多」列表的**第三档**——库与卡片都为空才走到,几乎不可达)
+② 正文里 `<policy-list-updated>{link,payload}` 给的地址(**后端驱动**,取出后还会写回
+`globalThis.policiesPublic`)—— 这条要动就得先改后端下发方式
+
+**⚠️ 生产注意**:`.env.production` 也写了同一个 `/dms-api/…` 路径,但要能用需
+① `VITE_DMS_API` 配好(技术债 #9)② nginx 转发 `/dms-api/`;取不到会自动退回本地副本,功能不受影响。
+
+**本轮改动全部未提交**(用户 2026-09-24 起要求不自动提交)。
+
+### 续 47:约定变更 —— 不再自动提交 / 推送(用户指示)
+
+**用户指示**:「后续不要再自动提交和推送了,只有我要求你做你才做」。
+
+**这条取代了仓库原先「每次改动完成后立即提交并推送」的约定**(该约定写在 `CLAUDE.md` 的
+"规则"与"提交约定"两处,2026-09-20 那次会话还专门把它写进了 `sops/session-end.md` 的首项)。
+改动落点(**本次全部未提交**,按新规矩留在工作区):
+- `CLAUDE.md`:规则里的那条改成「不要自动提交/推送 …… 只有用户明确要求时才 git commit/push」;
+  「提交约定」整节重写:改完仍要跑验证 + 更新记录,但**不执行 git 命令**,
+  收尾要**说明"改动未提交"**;用户的一次"提交"指示只对那一轮生效
+- `harness/sops/session-end.md`:收尾清单首项从「已提交并推送」改成
+  「改动都在工作区、且已向用户说明」
+- `harness/sops/handoff.md`:命令区的 `git commit/push` 那行加了「⚠️ 只在用户明确要求提交时才执行」
+
+**验证**:`validate-harness` 通过;grep 确认没有别的"活文档"还在要求自动提交
+(`progress.md` 里那处是历史记录,保留)。
+
 ### 续 46:部门筛选改为「只列 JSON 里实际存在的部门」(用户要求)
 
 **用户要求**:「部门筛选只包含 JSON 里面存在的所有部门。」
@@ -4585,4 +4735,529 @@ asrServerOptions 及其默认值)|方法表(`openMicroPhone` / `startRecor
 harness 里也**没有** ASR 的自动化脚本(依赖麦克风与厂商服务,node 里无法稳定复现),
 所以文档给的是一条**手动自查清单**。
 
+### 续 51:删掉 `<policy-list-updated>` 整条链 + 顺带只读排查旧协议标记(用户:「老对话仅测试,后续会全部清空」)
+
+**用户先问**:「`policy-list-updated` 在哪使用的,新的 AI 对话接口有没有返回类似的内容」
+→ 查证后回答:① 三处用(`stream-xml-filter.ts` 的标签规则、`BusinessRecord.vue` 的挂载点、
+`PolicyListUpdatedMatch.vue` 解析 `{link,payload}` → 拼 query → fetch → 交给 `PolicyMatch` 渲染
+→ 写回 `globalThis.policiesPublic`,即接口清单 #11);② 新接口**没有对应物**:政策卡片随
+`item` 事件**内联**到达,适配层翻成 `<!-- POLICY_TABLE -->`,从不产出 `policy-list-updated`。
+⇒ 它的唯一触发源就是旧会话历史。
+
+**用户决定**:回答里点明"要删得先确认历史不再回放该块"→ 用户给出「老对话仅测试,后续会全部清空」。
+再问清两种删法与范围,用户选:**全删(含标签规则)** + **顺带出一份只读清单**。
+
+**删除范围**:
+- `src/components/Chat/PolicyListUpdatedMatch.vue` 整文件
+- `BusinessRecord.vue`:挂载点 / import / `hasPolicyTableRow` 里的分支
+- `StreamXMLFilter`:`RecordRowTypes.PolicyListUpdated` 枚举成员 + 标签规则(原位留一行注释说明为何删)
+- `PolicyMatch.vue` 里那段"后端给地址那条走另一个组件"的说明注释(改为"已删")
+- 文档:`current-api-call-sites.md` 的 #11 / #10 注 / §5 注 / 三、保留表;
+  `README.md` 新增 `## 20260928` 节;新增 `docs/reference/legacy-content-markers.md` 并挂进该目录索引
+
+**⚠️ 副作用(用户已确认接受)**:标签规则删掉后,**清空之前**打开旧测试会话,
+正文里那串 `<policy-list-updated>{"link":…}</policy-list-updated>` 会**当普通文本显示**
+(既没有卡片列表,也不再被吞掉)。新会话不受影响。
+
+**顺带排查(只读,没改代码)—— 结论写进 [legacy-content-markers.md](docs/reference/legacy-content-markers.md)**:
+- ✅ 仍在用(适配层产出):`<scope>` / `<!-- POLICY_TABLE -->` / `<ref_links>` / `<question-cards>` / `<image-scope>`
+- ⚠️ 只有旧历史能触发(有渲染组件、src 里**无产出者**):`<form>` / `<link-url>` / `<file-docx>`
+- 💀 已经是死链:`<ui-form>`(两个壳组件 provide 的 `setFormOptions` **都是空函数**)、
+  `<followup>`(无人注册 `customProcessor`,`followupSuggestions` 全仓库无写入点)、
+  `<silence>`(只剩开发环境 mock 文本)、`<qingpu_enterprise_notificaion>`(无人注册、无产出者)
+- 建议:死链那批随时可删;`<form>`/`<link-url>`/`<file-docx>` 建议**等清空动作真的做完再删**
+
+**运行过的验证**:
+- `npm run build` 通过
+- 新增 `harness/tools/verify-stream-filter-rules.mjs`(**真实切行器**,配 `_entry-stream-filter.ts`)
+  —— **15/15**:新协议 5 个标记仍产出对应行 · 旧块不再产出行且整串当文本留下 · 保留的 3 个旧标记
+  仍产出 · 源码级(组件文件已删、`BusinessRecord` 零引用、`PolicyMatch` 只在注释里提"已删")
+  - **变异测试**:用 `new StreamXMLFilter({tagRules:[旧的 policy-list-updated 规则]})` 跑一遍
+    → 立刻产出 `policy-list-updated` 类型 ⇒ 断言对规则是否回归**是敏感的**
+- `verify-question-cards` **36/36**(同一份切行器 + 真实往返,属回归)、`verify-policy-card-list` **24/24**
+- **产物核对**:`grep -rl "policy-list-updated|PolicyListUpdated" dist/` **0 命中**
+- `vue-tsc --noEmit`:仅既有 `webpack-env` 报错,涉及文件无报错
+- dev server 冒烟(8083 被占用,起在 **8084**,跑完已收掉):页面 200 ·
+  `BusinessRecord.vue` 开发态编译产物 0 命中该标记 · 切行器模块只剩注释里那处说明
+
+**⚠️ 本轮没做的**:
+- **真实浏览器交互**(打开页面发一轮对话)没做 —— 仓库里没有 playwright / puppeteer / chromium-cli,
+  本轮不装浏览器驱动
+- 旧会话"显示原始 JSON"的实际观感**没有实测**(需要浏览器 + 带该块的旧会话)
+
+**功能状态无变化**:本次是死代码清理,`feature_list.json` 不改(仍 23 项、`in_progress` 1 项)。
+**本轮改动全部未提交**(用户 2026-09-24 起要求不自动提交)。
+
+### 续 52:生产接入暂缓,登记为技术债 #11(用户:「先标记待办」)
+
+**背景**:用户给出生产地址 —— **DMS `10.235.245.174`**、**AI 对话 `10.249.33.148`**,
+要求"配置好环境变量"。我先核了事实,并追问两件事(**端口**、**同源转发 vs 直连**),
+用户随后指示「**生产环境适配先不管,先标记待办,我们先处理别的问题**」
+→ **本轮不动 `.env.production`、无任何代码改动**。
+
+**顺便查明的状态(还没做,只是记下来)**:
+- `.env.production` **根本没有 `VITE_DMS_API`** → 生产产物走 DMS 那步直接 warn 跳过;
+  `VITE_CHAT_API` 还指着**测试后端** `192.168.2.23:8000`
+- `vite.config.ts` 的 `server.proxy` **只对 `npm run dev` 生效**,产物里没有代理
+  ⇒ 生产要转发**只能靠部署侧 nginx**;⚠️ `npm run preview` 会**复用同一份 proxy**
+  (vite 5.4.21:`proxy: preview?.proxy ?? server.proxy`)—— "本地 preview 通、上生产 404" 的坑
+- `VITE_CHAT_TARGET` / `VITE_DMS_TARGET` / `VITE_IP_TARGET` / `VITE_ALY_TARGET` **只有 dev 代理读**,
+  写进 `.env.production` 对产物零作用
+- 生产页面是 HTTPS(`aixq.shqp.gov.cn` 内嵌 iframe)⇒ 直连 `http://10.x` 会被**混合内容拦掉**;
+  DMS 还要求**转发侧注入 token**(前端不持有 token)
+- 两台机器**本机探测不可达**(生产网段),配完只能由部署侧实测
+
+**登记位置**:[`tech-debt-tracker.md`](docs/exec-plans/tech-debt-tracker.md) **#11**(新建,
+"生产环境接入:地址已给,暂缓执行"),并在 **#9**(生产没配 DMS)末尾加了指向 #11 的补充。
+**没动的**:`.env.production` / 代码 / `feature_list.json`(仍 23 项、`in_progress` 1 项)。
+
+**运行过的验证**:`node harness/tools/validate-harness.mjs` 通过。
+**本轮改动全部未提交**(用户 2026-09-24 起要求不自动提交)。
+
+### 续 53:删掉打包体积报告 `stats.html` 及其配置(用户:「这个不需要,把它和相关代码删掉」)
+
+**起因**:用户问「`stats.html` 是干什么的」→ 答完顺手核出两件事:
+① 它是 `rollup-plugin-visualizer` 的体积报告(每次 `npm run build` 重新生成);
+② **它其实没落在配置写的 `dist/` 里** —— 插件 v5 的选项名是 **`filename`**,
+配置里的 `file: "./dist/stats.html"` 被忽略(`node_modules/.../index.js:42` 只读 `opts.filename`)
+→ 回落到默认名 `stats.html`,又因是相对路径、按 **cwd** 解析(`index.js:153` 直接 `writeFile`)
+→ 落在**仓库根目录**;`dist/stats.html` 从未存在过。
+用户据此决定:不需要,连同相关代码一起删。
+
+**删除范围**:
+- `vite.config.ts`:`import { visualizer }` + 插件块(7 行)
+- `package.json` / `package-lock.json`:`rollup-plugin-visualizer` devDependency
+  (lock `-112` 行,含它专用的 picomatch / source-map 副本)
+- `.gitignore`:`stats.html` 那两行(注释 + 规则)—— 已经没有生成者了
+- 仓库根目录的 `stats.html` 本体(1.78 MB)
+
+**运行过的验证**:
+- `npm run build` 通过,**9.17s**(此前 17.39s —— 少了报告要做的两遍压缩)
+- **构建后根目录与 `dist/` 都不再有 `stats.html`**;产物里 `grep "Rollup Visualizer"` 零命中
+- **产物零影响**:删除前后三个 chunk 的 hash 完全一致
+  (`main-o_7Ljas1` / `vendor-CtXnEyoM` / `three-DTC7gdj9`)⇒ 该插件只产报告、不改产物
+- `grep visualizer`:`package-lock.json` 0 命中、`node_modules` 已无该包
+- `node harness/tools/validate-harness.mjs` 通过
+
+**顺带查明(不影响功能,备查)**:Vite 报告的 chunk 大小与磁盘字节数对 `main` 对不上
+(报 389.29 kB、磁盘 449.22 kB),而 `vendor` / `three` 两边相等 —— 因为 Vite 报的是
+JS 字符串长度(UTF-16 码元),**中文一个字算 1、磁盘上算 3 字节**;`main` 里中文多才差这么多。
+不是缺陷,别当 bug 修。
+
+**本轮改动全部未提交**(用户 2026-09-24 起要求不自动提交)。
+
+### 续 54:修「Vite 依赖冷扫描失败」+ 加护栏(用户报错 → 问原因 → 问影响 → 「修改」)
+
+**现象**:用户贴出 dev server 的
+`Error: Failed to scan for dependencies from entries: index.html / redirect.html`,问「什么原因」。
+
+**根因(逐层验证,不是猜的)**:
+1. Vite 的依赖预扫描把 **`.vue` 也当 HTML**:`htmlTypesRE = /\.(html|vue|svelte|astro|imba)$/`(vite 5.4.21 源码 49455)
+2. 扫描前先做一次 `raw.replace(/<!--.*?-->/gs, "<!---->")`(49617 / 49702;带 `s`,**可跨行**)
+3. `BusinessRecord.vue` 的 `<script>` 里有两个**不成对**的标记,隔着 9 行:
+   602 行注释里的起始符 + 611 行正则里的结束符 → 被整段吃掉 → 611 那行并进上面那条 `//` 注释
+   → 下一行以 `.replace(` 开头 → esbuild 报 `Unexpected "."`
+   - **数字对得上**:抽出脚本里那行原本是第 260 行,Vite 报 251 —— 正好少 9 行(9 个换行被吞)
+4. **为什么现在才炸**:这步只在依赖缓存失效时跑。本轮删 `rollup-plugin-visualizer` 改了
+   package.json / package-lock → 缓存失效 → 强制重扫 → 潜在问题浮出。`npx vite --force` 可稳定复现
+5. **与生产构建无关**:构建走 vue 插件路径,不做这步清扫(所以 `npm run build` 一直是绿的)
+6. 影响面只有 2 行(全仓库 `.vue` 逐行核对):另一处是 `const policyStartToken = "<!-- POLICY_TABLE"` ——
+   它被吃掉后**不报语法错**,只是静默少两个常量(扫描内容只用于抽 import,所以 APP 行为不受影响)
+
+**修法(用户先问「是否会对功能造成影响」,确认后「修改」)**:
+- 两个字面量从 `.vue` 挪到 `.ts`:`stream-xml-filter.ts` 新增导出 `POLICY_TABLE_START` / `POLICY_TABLE_END`
+  (该文件内 3 处使用点改引用),`BusinessRecord.vue` 删掉本地两份、改 import ⇒ **顺带消掉一处重复**
+- 602 行注释改写(不再含那四个字符),并在 `.vue` 里注明「不要写字面量」
+- 新增 `harness/tools/verify-vue-script-html-comments.mjs`(纯 node,无需打包):扫 `src/**/*.vue` 的
+  `<script>` 区块,报出"同行没有配对结束符"的裸起始符(`<template>` 里的正常注释放行);
+  并写进 `sops/verification.md` 的「改动类型 → 该跑什么」表与 `docs/architecture.md` 的关键约束
+
+**运行过的验证**:
+- 🔑 **冷扫描**(本次问题的直接证据):`npx vite --force` → **0 错误**,`Scan completed in 188.71ms`、
+  `Dependencies bundled in 763.18ms`(修前同一条命令必然报错)
+- **新护栏**:`verify-vue-script-html-comments` 通过(28 个 .vue / 0 违规)。
+  ⚠️ **天然变异测试**:它第一次运行就抓出**我自己新写注释里的一个裸起始符**(`BusinessRecord.vue:1137`),
+  修掉后才过 —— 说明断言真的有效
+- `npm run build` 通过;回归 `verify-stream-filter-rules` **15/15**、
+  `verify-question-cards` **36/36**(同一份切行器 + 真实往返)、`verify-policy-card-list` **24/24**;
+  `validate-harness` 通过
+- dev 冒烟:页面 200 · `BusinessRecord.vue` 开发态产物里**裸标记 0 处**(改走 import)·
+  切行器模块里 `POLICY_TABLE_START` 出现 4 次
+- ⚠️ **产物 hash 变了**:`main-o_7Ljas1` → `main-D0U2AkA8`,449224 → 449154 字节(**-70**)。
+  性质核实过:新产物里能看到 `.ts` 的模块级常量 `oo="<!-- POLICY_TABLE", io="POLICY_TABLE -->"`、
+  `.vue` 侧已无字面量 ⇒ 是**字面量去重 + 声明位置变化**导致的字节流差异,不是行为差异
+  (语义等价是构造性的:字符串值逐字不变,使用点只是 `indexOf` / `slice` 的比较对象)
+- ⚠️ **没做到、也不声称**:字节级基线对照。本打算 `git stash` 回退这两个文件重建旧产物,
+  但旧版 `BusinessRecord.vue` import 了已删的 `PolicyListUpdatedMatch.vue` → 构建失败
+  (stash 已 `pop` 还原,工作区无损)。所以给的是"构造性等价 + 产物核对 + 运行期回归"三条证据
+
+**本轮改动全部未提交**(用户 2026-09-24 起要求不自动提交)。
+
+### 续 55:删掉百度统计(用户问「列出现在使用的接口」→ 逐层追问 → 「删掉」)
+
+**经过**:用户先要「列出现在使用的接口」→ 我按代码重核时发现清单**漏了一条**(第三方脚本);
+用户接着问「详细介绍百度统计接口」→「统计的是什么数据,传的是什么」→「现在有什么用」→ **「删掉」**。
+排查结论全部写进 [`current-api-call-sites.md`](docs/reference/current-api-call-sites.md) 的 #14。
+
+**查出来的事实(都有实测支撑)**:
+- `index.html` 底部**无条件**加载 `hm.baidu.com/hm.js?d6307cb…`(dev / 生产 / iframe 内都发)
+- 抓 hm.js 本体解出站点配置:**`dm:["demo.heijingai.com"]`(厂商演示域名,不是青浦的域)**、
+  `etrk/cetrk/cptrk/ctrk` 全空 ⇒ 只有默认 PV/UV,**数据进的是那个厂商账号**
+- **jsdom 实测三个域**(拦掉 img/xhr/sendBeacon,不真发请求):`aixq.shqp.gov.cn` / `demo.heijingai.com` /
+  `localhost` **都会**写 cookie(`HMACCOUNT` / `Hm_lvt_<id>` / `Hm_lpvt_<id>`,写在当前域)并上报
+  `hm.gif` ⇒ **`dm` 白名单不阻止客户端上报**(推翻了我最初的猜测;服务端是否过滤未验证)
+- 完整上报参数(抓到的 URL):`si` 站点 id · `hca` 访客 id · `u` 当前页 URL · `su` 来源页 ·
+  `tt` 标题 · `ds`/`ww`/`cl`/`ln`/`ja`/`ck` 环境 · `lv`/`vl` 深度与时长 · `et`/`ep` 事件 ·
+  `lo` 是否顶层;另有 `cm/cp/cw/ci/cf/cu` 六个**渠道参数**(从 URL/referrer 里解析
+  `utm_source/medium/campaign/term/content` + 百度自家 `hmsr/hmmd/hmpl/hmkw/hmci/hmcu`)
+- **业务数据一个都不传**:脚本字段表里没有聊天/输入/企业信息;唯一能塞业务数据的通道
+  `_hmt.push(['_setCustomVar',…])` —— `git log -S '_hmt.push'` **全历史为空**,一次都没用过
+- 结论:**对本项目零用途**(我们看不到厂商账号里的数据),且是全站**唯一**往第三方发请求、
+  并在青浦域名上写 cookie 的东西 ⇒ 用户确认删除
+
+**改动**:`index.html` 删掉那 9 行整块(原位留一条 `[已删]` 说明注释,与文件里既有的
+`[已移除]` 风格一致)。全仓库无其它引用。
+
+**运行过的验证**:
+- `npm run build` 通过
+- **`verify-inline-scripts` 8/8**(改了 index.html 必跑;含"加载遮罩收尾信号仍在"那 3 条)
+- 引用复核:`grep -rn 'hm\.baidu|_hmt'`(排除 node_modules/dist)**只剩注释**;
+  `dist/index.html` 里同样只剩注释 —— 脚本已不在产物里
+- `node harness/tools/validate-harness.mjs` 通过
+
+**本轮改动全部未提交**(用户 2026-09-24 起要求不自动提交)。
+
+### 续 56:删掉死配置 `VITE_DOWNLOAD_URL`(用户:「删掉吧」)
+
+**背景**:续 55 的接口复核里顺带发现它是**死配置**(只定义在 `.env.production`,全仓库无读者 ——
+`index.html` 的本地兜底早已写死 `/merchant-agent/policies_public.v2.json`),向用户报告后确认删除。
+
+**改动**:
+- `.env.production`:那 5 行(含说明注释)换成 3 行 `[已删]` 说明 —— 与该文件里既有的
+  `[已删]` / `[已移除]` 风格一致;连带**生产侧 nginx 不必再转发 `/api/download/`**
+- `harness/tools/verify-policy-library.mjs`:新增**§10** 5 条断言(百度统计不在 index.html、
+  两份 env 都没有 `VITE_DOWNLOAD_URL`、index.html 无 `/api/download/` 依赖,
+  外加两条**防误伤**:政策库远端与本地兜底必须都还在)
+  - 顺带把 `stripComments` 提升到模块级,并新增 **`stripEnv`**(`.env` 用的是 `#` 注释)——
+    ⚠️ **第一版断言被我自己写进 env 的 `[已删] VITE_DOWNLOAD_URL=…` 说明注释绊红了**,加 `stripEnv` 才过
+    (这是本项目第三次踩"注释绊倒断言",`verify-policy-library` §9 的注释里记过前两次)
+
+**运行过的验证**:
+- `verify-policy-library` **54/54**(新增 §10 的 5 条)
+- **变异测试**:把 `VITE_DOWNLOAD_URL="/api/download/"` 追加回 `.env.production` → §10 立刻 **1 FAIL**;还原后 54/54
+- `npm run build` 通过,**主 chunk hash 未变**(`main-D0U2AkA8`)⇒ 删这条配置对产物零影响(本就无人读)
+- `node harness/tools/validate-harness.mjs` 通过
+
+**本轮改动全部未提交**(用户 2026-09-24 起要求不自动提交)。
+
+### 续 57:环境变量整理(用户:「整理一下环境变量」)
+
+**做法**:先把「两份 env 的实际定义 × 代码里的读者」逐项对一遍,再动手;改动用脚本**比对改写前后的
+key→value 映射**,确认没丢变量、没改值。
+
+**核查结果(12 个变量,全部有活读者,无死项)**:`VITE_VERSION` · `VITE_API` ·
+`VITE_POLICY_REMOTE_URL` · `VITE_PARENT_ORIGIN` · `VITE_CHAT_API` · `VITE_DMS_API`(**仅 dev**)·
+`VITE_IP_API` · `VITE_NLS_TOKEN_API` · `VITE_REDIRECT_MAP`(**仅生产**)·
+`VITE_CHAT_TARGET` / `VITE_DMS_TARGET` / `VITE_IP_TARGET`(仅 dev、只给代理读);
+`VITE_NLS_APPKEY` 只在 `.env.development.local` 与部署侧。
+
+- ⚠️ **差点误删**:`.env.development.local` 里的 `ALIYUN_NLS_AK` / `ALIYUN_NLS_SK` / `ALIYUN_NLS_APPKEY`
+  看着像残留(前端已不自己换 token),但它们是 **harness 的 NLS 测试脚本**在读
+  (`nls-token.mjs` / `get-nls-token.mjs` / `probe-nls-realtime.mjs` / `verify-nls-*`)⇒ **保留**。
+  写进记录,免得下次又被当死配置。
+
+**做了四件事**:
+1. **两份 env 重排为同一套分区**:① 构建信息 ② 接口地址(进产物)③(dev)只给代理读的 target
+   ④ 云服务同源路径 ⑤ 已删说明。逐项比对结果:dev **11 → 12**(只新增 `VITE_ALY_TARGET`)、
+   生产 **8 → 8**,**删除 0 / 值变化 0**
+2. **补 `VITE_ALY_TARGET`**:`vite.config.ts` 的 `/aly-api/` 代理只认这个变量、缺了就落到硬编码兜底,
+   与另外三个 target 不对称;填入的值就是原兜底地址 ⇒ **零行为变化**
+3. **删掉 `runtime-config.ts` 的 `getApiBaseUrl()`**:它读 `globalThis.VITE_API`,而全仓库
+   **没有任何地方给这个 global 赋值**(index.html 只注入 CHAT / DMS / IP 三个)⇒ 恒返回空串,纯死代码。
+   `VITE_API` 的 4 个真实读者走的是 `import.meta.env.VITE_API`。
+4. **重写 `architecture.md` 的「环境变量」表**:原表还列着已删的 `VITE_MODEL_SERVER` / `VITE_ASR` /
+   `VITE_STREAM_SERVER`,且缺一半现役变量 —— 现按「变量 / 用途 / 定义在哪些环境 / 谁在读」重列,
+   并写明**改 env 要同步更新这张表**
+
+**运行过的验证**:
+- `npm run build` 通过,**主 chunk hash 未变**(`main-D0U2AkA8`)⇒ 删死函数 + 补 target 对产物零影响
+- `vue-tsc` 仅既有 `webpack-env` 报错
+- **dev 冒烟(含代理实测)**:页面 200 · `/aly-api/proxy/verify/getALYToken` 经**新加的 `VITE_ALY_TARGET`**
+  返回真实 token `{"code":200,…}` · `/ip-api/…/getIPByBrowser` 返回真实 IP `116.232.248.128` · 日志无报错
+- `verify-policy-library` **54/54** · `verify-stream-filter-rules` **15/15** ·
+  `verify-vue-script-html-comments` 通过 · `validate-harness` 通过
+
+**未处理的(留给用户决定)**:`VITE_REDIRECT_MAP` **只有生产有** ⇒ 本地直接打开 `/redirect.html`
+是语法错误页(登录链不受影响,那行写的是线上硬编码地址)。要不要给 dev 也补一份?
+
+**本轮改动全部未提交**(用户 2026-09-24 起要求不自动提交)。
+
+### 续 58:修过期说明 + 把 NLS appkey 内置进代码(用户:「更改过期的说明,同时,将 accesskey 直接写入使用的地方」)
+
+**① 过期说明(3 处)**:
+- `src/asr/nls.ts` 顶部注释仍写「dev 是 vite 的 `/nls-token/`」→ 改为「后端网关 `getALYToken`;
+  dev 走 vite 代理 `/aly-api/`,生产由 nginx 转发同一路径」
+- `harness/docs/reference/asr-nls.md`:①「生产要由后端/网关提供 `/nls-token/`」→ 改为
+  「token 已由网关签发,生产只剩 nginx 转发」;②「旧实现(`asr/index.ts`)保留:切回它只改 import」
+  +「`VITE_ASR`/`VITE_XF_ASR` 仍在四份 env 里」→ **两项都已不成立**(2026-09-24 删掉,`src/asr/` 现在
+  只有 `nls.ts` + `nls-protocol.ts`)
+- `harness/tools/verify-nls-realtime.mjs` 的断言标签「不再硬编码 appKey(改由端点下发)」→
+  改为「由 runtime-config 统一提供」
+
+**② appkey 内置进代码** —— ⚠️ **先澄清再动手**:用户说「accesskey 直接写入使用的地方」,
+我确认后才知道指的是 **appkey**(不是 AK/SK)。**AK/SK 我没有写进仓库、也不会写**:
+`harness/` 是入库的且会推到共享远端 ⇒ 凭据会进 git 历史(事后只能轮换密钥补救),
+且这与用户 2026-09-23 自己的约定(「任何地方都不要把凭据值写进仓库文件」,就写在该文件顶部)相反。
+- 改动:`src/utils/runtime-config.ts` 新增 `NLS_APPKEY_BUILTIN = "<16 字符>"`,
+  `getNlsAppkey()` 的兜底从空串改成它;**覆盖链保留**(`globalThis` 注入 → 构建期 env → 内置值),
+  临时换 appkey 仍不必改代码
+- 连带清掉「必须由部署侧注入」的旧口径:`.env.development` / `.env.production` 的注释、
+  `architecture.md` 的环境变量表、`asr-nls.md` 的表 + 待办、`tech-debt #10` 的待办 ②(**已消**)、
+  `current-api-call-sites.md` §D;并从 `.env.development.local` 删掉那行冗余的 `VITE_NLS_APPKEY`
+  (**AK/SK 保留** —— 那是 harness 测试脚本的本地签名兜底)
+- ⚠️ **如实记录**:appkey 现在**进产物、也进 git 历史**。可接受的理由:它**不是密钥**
+  (换 token 不需要它、网关也不校验它),而且每次识别请求本就把它明文发出去 ⇒ 它一直就在公开产物里。
+  真正的密钥只有 AK/SK,那两个**仍在 gitignored 的 `.env.development.local`**,没进仓库。
+
+**运行过的验证**:
+- `npm run build` 通过;**产物核对**:内置 appkey 在 main chunk 命中 **1 次**、`index.html` **0 次**
+  (不再需要注入);主 chunk hash `main-D0U2AkA8` → `main-DLILXHYb`(+~20 字节,正是那个 16 字符常量)——预期内
+- `verify-nls-realtime` **44/44**(含**真实网关连接**:token + appkey + 识别全链路)
+- dev 冒烟:页面 200 · dev 下发的 `runtime-config.ts` 里内置值命中 1 次
+- `vue-tsc` 仅既有 `webpack-env` 报错;`validate-harness` 通过
+
+**本轮改动未提交**(用户 2026-09-24 起要求不自动提交)。
+
+### 续 59:删掉 `.env.development.local` 里的阿里云凭据(用户:「三个都删(含 AK/SK)」)
+
+**背景**:续 58 把 appkey 内置进代码后,用户问「local 的那个环境变量是不是可以删除了」。
+我先把三个 key 的**实际用途**查清再答:
+
+| key | 用途 |
+|---|---|
+| `ALIYUN_NLS_APPKEY` | harness 脚本的**启用条件** + StartTranscription 的 header 值 |
+| `ALIYUN_NLS_AK` / `SK` | ① `resolveNlsToken` 的**第三步兜底**(网关失败才自签)② `verify-nls-realtime`/`probe-nls-realtime` 的启用条件 ③ `get-nls-token.mjs` 存在的意义 |
+
+说明代价(删了会失去"网关挂了自签"的退路、那两个脚本的真实联网段会跳过)后,用户选择**三个都删**。
+
+**改动**:
+- `.env.development.local`:三个凭据 key 全删,改写成一段说明(为什么是空的、需要时怎么临时用 env 传、
+  为什么凭据不进文件)⇒ 至此**磁盘上不再保存任何阿里云凭据**
+  (📌 该文件随后在**下一轮被整体删除**,见 续 60)
+- `harness/tools/nls-token.mjs`:新增 `readBuiltinAppkey()`(从 `src/utils/runtime-config.ts` 的
+  `NLS_APPKEY_BUILTIN` 读),`readNlsCredentials()` 的 appkey 改为「env → 内置值」;头部口径改写
+- `verify-nls-realtime.mjs` / `probe-nls-realtime.mjs`:启用条件从「`ak && sk && appkey`」放宽为
+  **只要有 appkey** ⇒ 删掉 AK/SK 后**真实网关段仍然会跑**(关键:那段是本仓库唯一的真实识别自动化证据)
+- `get-nls-token.mjs`:头部改为"平时不需要它;凭据临时用 env 传"
+- `src/network/api/dms/client.ts`:修掉一条**指错地方**的提示 —— DMS token 在本机 `.dms-token`
+  (gitignored,`npm run dms:token` 写入),不在 `.env.development.local`
+
+**运行过的验证**:
+- `get-nls-token --check`:AK/SK 报「未配置」、**AppKey 从内置值读到**(掩码显示 `3jRt****dh12`)
+  ⇒ `readBuiltinAppkey()` 生效
+- `verify-nls-realtime` **44/44**,且【3】**真实网关段跑起来了**(「收到 TranscriptionStarted(token/appkey 有效)」)
+  —— 全程**没有任何磁盘凭据**
+- `verify-nls-browser-path` **4/4**(同样真实联网,识别文本含关键词)
+- 清空 `.local` 后 dev 仍正常:页面 200;`/dms-api/...` 请求到达 DMS(DMS token 本就在 `.dms-token`,不受影响)
+- `npm run build` 通过 · `validate-harness` · `verify-vue-script-html-comments` · `verify-stream-filter-rules` 15/15
+
+**本轮改动未提交**(用户 2026-09-24 起要求不自动提交)。
+
+### 续 60:删掉 `.env.development.local` 整个文件(用户:「文件没用的话也删掉」)
+
+**先确认没有硬依赖**(都走 missing-file 容错):`vite` 的 `loadEnv` 找不到 `.env.*.local` 不会报错;
+`nls-token.mjs` 的 `readDotEnvFile` 有 try/catch;`.gitignore` 的 `.env.*.local` 模式仍在
+⇒ 将来谁要放本机覆盖,新建即可、仍然不会入库。
+
+**删除**:`.env.development.local`(删掉后仓库里只剩 `.env.development` / `.env.production` 两份)。
+**顺带修 4 处"凭据在那个文件里"的旧注释**:
+- `vite.config.ts` 的 DMS 代理注释:token 来源是 `.dms-token` / `DMS_TOKEN`,**不在** `.env.development.local`
+- `probe-nls-realtime.mjs` / `verify-nls-realtime.mjs` 的头部:改成"appkey 从代码内置值读"
+- `.env.development` 头部:本机覆盖改成"**需要时新建**"
+(`nls-token.mjs` 的 `readDotEnvFile` 保留 —— 它现在只对"你自己新建了某个 `.env.*.local`"有意义)
+
+**运行过的验证(都是删掉文件之后跑的)**:
+- `get-nls-token --check`:AK/SK 未配置、**AppKey 仍从代码内置值读到**(`3jRt****dh12`)
+- `verify-nls-realtime` **44/44**(含真实网关段)· `verify-nls-browser-path` 之前那轮 4/4
+- **dev 冒烟**:页面 200 · `/dms-api/content/selectContentList` 返回**真实数据**(`{"code":200,…}`,
+  DMS token 走 `.dms-token`,不受影响)· `/aly-api` 取到真实 token · dev 日志 0 条 error
+- `npm run build` 通过 · `validate-harness` 通过
+
+**本轮改动未提交**(用户 2026-09-24 起要求不自动提交)。
+
+### 续 61:DMS 默认服务账号写进代码(用户给账号并要求「直接写进代码里」)
+
+**用户要求**:给了默认账号(`user_yszs_web`)与密码,原话「以后未登录时 token 用这个账号获取,
+**直接写进代码里**」,并确认用途是 **DMS 那条链**(会话/问答/企业信息落库)。
+
+**⚠️ 我先拦了一次,用户明确选择"写进入库源码"**:
+- 这是**密码**(不是 appkey 那种非密钥),而 `harness/` 入库、仓库还推到共享远端
+  ⇒ 密码会**永久进 git 历史**,事后只能靠改密码补救;写进 `src/` 更糟(前端产物公开 = 把账号公开,
+  而按技术债 #8,DMS 那个 token 权限极大)
+- 我给了三个选项(`.dms-account` / gitignored 的代码文件 / 入库源码),**用户选了第三个**
+  ⇒ 按用户要求执行,并在代码与本文里写明这是「凭据不落盘」规则的**唯一例外**
+- 同时把护栏写进代码注释:只准 node 侧(换 token)用,**绝不要复制进 `src/`**;建议它只作权限受限的服务账号
+
+**改动**(`harness/tools/dms-login.mjs`):
+- 新增 `DEFAULT_ACCOUNT = { userName, password }`(带一段 ⚠️ 例外说明)
+- `readAccount()` 优先级改为:**参数 → 环境变量 → 代码内置默认 → 本机 `.dms-account`(兜底)**
+- 头部注释的"凭据从哪来"补上 ③ 内置默认、并说明 ④ 已被覆盖
+
+**运行过的验证**:
+- `node harness/tools/dms-login.mjs` 登录成功:token 明示 **`用户 user_yszs_web`**,有效期 12.0 小时
+  (登录前那个 token 是 `user_gtx`)⇒ 内置账号可用,`.dms-token` 已换成服务账号的
+- **直连 DMS**(带新 token 的 `selectContentList`):HTTP 200 / `code=200` / 取到 10 行 ⇒ 有权限
+- **dev 冒烟**:页面 200 · 经 `/dms-api` 代理读 1887 也返回真实数据 · predev 因 token 仍有效而跳过续期(符合预期)
+- `npm run build` 通过 · `validate-harness` 通过
+
+**⚠️ 三条影响,写在这里免得以后惊讶**:
+1. **之后 DMS 里新写入的行,作者会变成 `user_yszs_web`**(旧行仍是 `user_gtx`)
+2. **密码现在还没进 git 历史**(本轮未提交)—— 一旦提交就撤不回来了;要反悔现在说
+3. 本机 `.dms-account` 里的旧账号**已被内置值覆盖**(它现在只是兜底),要不要删由用户定
+
+**本轮改动未提交**(用户 2026-09-24 起要求不自动提交)。
+
+### 续 62:删掉 `.dms-account`,**保留** `.dms-token`(用户:「没用就删掉」)
+
+**先分清两者性质不同**:
+- **`.dms-account`**(账号密码的本机文件):自 续 61 把默认服务账号写进代码后**已被内置值覆盖**
+  (优先级:参数 → env → **内置** → 文件),本机那份是永远轮不到的旧账号 ⇒ **删**
+- **`.dms-token`**(当前 token):**不能删** —— `vite.config.ts` 的 `/dms-api/` 代理**每个请求现读**它
+  来注入 `token` 头(`readDmsToken`),删了 dev 的 DMS 读写立刻失灵。它是**自动生成物**
+  (`npm run dms:login` / `npm run dev` 的 predev 会写回),所以真删了也能自愈,但没有任何意义
+
+**改动**:删 `.dms-account`;`dms-login.mjs` 头部两处注释同步(④ 标为"仅当内置值为空时才用得上、
+本机已删";把"密码明文存在 `.dms-account`"改成"密码现在写在 `DEFAULT_ACCOUNT` 里,那条路已删除")。
+`.gitignore` 里 `/.dms-token` 与 `/.dms-account` 两个模式**都保留**(将来要用还有)。
+
+**运行过的验证**:
+- 删掉 `.dms-account` 后,无参 `node harness/tools/dms-login.mjs` 仍登录成功(走内置账号 → 用户 `user_yszs_web`)
+- `node harness/tools/dms-login.mjs --if-needed`:token 仍有效 → 跳过 ✓
+- **dev 冒烟**:页面 200 · 经 `/dms-api` 读到真实数据(`.dms-token` 未受影响)
+- `node harness/tools/validate-harness.mjs` 通过
+
+**本轮改动未提交**(用户 2026-09-24 起要求不自动提交)。
+
+### 续 63:核对登录端点路径(用户:「token 使用接口获取 http://121.43.55.7:10086/api/user/login」)
+
+**用户说的路径与代码里的不一致**,所以先实测再改(没直接改):
+- `POST http://121.43.55.7:10086/api/user/login` → **HTTP 404**(该路径不存在)
+- `POST http://121.43.55.7:10086/oauth/api/user/login` → **HTTP 200 / code=200 / 返回 JWT(183 字符)** ✅
+
+⇒ **代码现状已经满足用户要求**(既走这个接口、又用他们给的默认账号),无需改动。
+只在 `dms-login.mjs` 的接口说明处加了一行防误改:**路径必须带 `/oauth`,别"顺手改短"**。
+
+**待用户澄清**:如果他们说的 `/api/user/login` 来自**另一个 host / 另一个服务**
+(比如管理端或网关的另一端口),把地址给我,我改 `OAUTH_TARGET`(现在支持 env 覆盖)或路径后实测。
+
+**本轮改动未提交**(用户 2026-09-24 起要求不自动提交)。
+
+### 续 64:代理前缀统一为「与服务真实路径同名」(用户指定)
+
+**用户要求**:`121.43.55.7:10086` 是 `/oauth`、`121.43.55.7:10081` 是 `/dms`,
+"只做这两个代理",随后补充:**再加一条 `/api`,指向 `121.43.55.7:10011`**,并提醒
+"/ip-api 和 /aly-api 的网址是一样的"(确实:两条都打 10011,只是端点不同)。
+
+**先实测决定改写规则**(没凭感觉):
+| 探测 | 结果 | 推论 |
+|---|---|---|
+| `POST :10086/oauth/api/user/login` | 200 + JWT | `/oauth` **直通**(真实路径带该前缀) |
+| `POST :10086/api/user/login` | **404** | 用户说的那条路径不存在(已在 续 63 记过) |
+| `GET :10011/proxy/verify/getALYToken` | 200 | —— |
+| `GET :10011/api/proxy/verify/getALYToken` | **404** | 网关不带 `/api` ⇒ `/api` 这条要**剥前缀** |
+
+**改动**:
+- `vite.config.ts`:四条代理(`/oauth` 直通 · `/dms` 直通 + 注入 token · `/api` 剥前缀 ·
+  `/chat-api` 保留剥前缀);删掉 `/dms-api`、`/ip-api`、`/aly-api` 与旧的 aixq `/api`
+- 两份 env:`VITE_DMS_API` `/dms-api`→`/dms`、`VITE_POLICY_REMOTE_URL`→`/dms/static/…`、
+  `VITE_IP_API`→`/api`、`VITE_NLS_TOKEN_API`→`/api/proxy/verify/getALYToken`;
+  target 收敛为 `VITE_CHAT_TARGET` / `VITE_DMS_TARGET` / `VITE_OAUTH_TARGET` / `VITE_API_TARGET`
+- **`.env.production` 首次配上 `VITE_DMS_API="/dms"`**(生产原来根本没有 ⇒ 技术债 #9 的"未配置"那半已还,
+  只剩 nginx 转发 + 注入 token);⚠️ 影响:生产 DMS 请求从"静默跳过"变成"会尝试并发告警"
+- 名字跟着改的地方(共 16 个文件):6 个 verify 脚本的默认 base、`upload-policy-library.mjs`、
+  `verify-attachment-question.mjs`、`gen-company-info-mapping-doc.mjs`、`audit-company-info-coverage.mjs`、
+  `verify-policy-library.mjs` 的 §8 断言、以及 `shared.ts` / `client.ts` / `file-upload.ts` / `runtime-config.ts` 的注释
+- 活文档同步:`architecture.md`(env 表 + 代理方案 + 开发环境)、`asr-nls.md`、`current-api-call-sites.md` §D、
+  `tech-debt #9/#11`(#9 改成"已还一半")
+
+**运行过的验证**:
+- **四条代理 dev 实测全通**:页面 200 · `/dms/content/selectContentList` 取到**真实数据**(代理注入 token)·
+  `/dms/static/…json` **200 / 392,833 字节** · `/api/proxy/verify/getALYToken` 取到 token ·
+  `/api/proxy/verify/getIPByBrowser` 取到真实 IP `116.232.248.128` · `/oauth/api/user/login` 用默认账号拿到 JWT
+- 校验套件:`verify-policy-library` **54/54** · `verify-attachment-question` **57/57** ·
+  `verify-dms-payload-fields` **19/19** · `verify-question-cards` **36/36** ·
+  `verify-vue-script-html-comments` 通过 · `validate-harness` 通过
+- `npm run build` 通过(主 chunk hash 变化属预期:env 值改了)
+- 悬空引用检查:`VITE_IP_TARGET` / `VITE_ALY_TARGET` 已无引用(`vite.config.ts` 顶部一处旧注释同步修掉)
+
+**本轮改动未提交**(用户 2026-09-24 起要求不自动提交)。
+
+### 续 65:`vite.config.ts` 不再读环境变量(用户:「不要加环境变量」)
+
+**用户要求**:代理的 target 不要从 env 读,直接写在 `vite.config.ts` 里。
+
+**改动**:
+- `vite.config.ts`:删掉 `loadEnv` 导入与 `const env = loadEnv(...)`;`defineConfig(({ mode }) => …)`
+  改为 `defineConfig(() => …)`;四个 target 改成**字面量**(`/api`→`http://121.43.55.7:10011`、
+  `/oauth`→`…:10086`、`/dms`→`…:10081`、`/chat-api`→`http://192.168.2.23:8000`);
+  DMS token 那处 `readDmsToken(env.DMS_TOKEN)` → `readDmsToken()`(函数内部仍会兜底读
+  `process.env.DMS_TOKEN`,见 `dms-token.mjs`,不受影响)
+- `.env.development`:**删掉四个 `*_TARGET`**(`VITE_CHAT_TARGET` / `VITE_DMS_TARGET` /
+  `VITE_OAUTH_TARGET` / `VITE_API_TARGET`),原"target 区"改成一行说明(target 在 vite.config 里)
+- 文档同步:`architecture.md`(env 表删掉 target 行;把"地址往 env 里放"那条**反向**成
+  "target 写死在 `vite.config.ts`,换地址=改那四行")、`current-api-call-sites.md` §D、`tech-debt #11`
+
+**运行过的验证**:
+- `grep 'env\.' vite.config.ts`:只剩注释里的字样(无实际读取)
+- `npm run build` 通过;主 chunk hash 与上一轮**完全一致**(`main-BadLEO6B`)⇒ 只动了 dev 侧配置,对产物零影响
+- **dev 冒烟(硬编码后复测四条)**:页面 200 · `/dms/content/selectContentList` 真实数据 ·
+  `/api/proxy/verify/getALYToken` 取到 token · `/api/proxy/verify/getIPByBrowser` 真实 IP ·
+  `/oauth/api/user/login` 到达业务层(用假凭据返回"用户不存在"= 链路通)· dev 日志 0 条 error
+- `node harness/tools/validate-harness.mjs` 通过
+
+**本轮改动未提交**(用户 2026-09-24 起要求不自动提交)。
+
+### 续 66:未登录访客的 DMS token 改由**前端自己换**(用户要求)
+
+**用户要求**:「未登录时 token 通过 oauth 的默认账号获取」。我给了两条路
+(代理侧换 / 前端换)并写清代价,**用户选前端换**。
+
+**实现**:
+- 新增 `src/network/api/dms/service-token.ts`:内置服务账号(`user_yszs_web`),
+  经 `POST {VITE_OAUTH_LOGIN_URL}`(默认 `/oauth/api/user/login`,同源代理)换 JWT
+  (⚠️ token 在响应 **`message`** 里,过期时间取 `content.failureTime`),内存缓存 +
+  提前 5 分钟刷新 + 并发共用同一个在途请求;文件顶部写明两个已知代价
+- `src/utils/runtime-config.ts`:新增 `getOauthLoginUrl()`(globalThis → env → 默认值)
+- `src/network/api/dms/client.ts`:请求带 `token` 头;遇 **201/212 → 丢缓存重换一次**再试;
+  换 token 失败时降级为不带 token(dev 由代理兜底并 warn)
+- `vite.config.ts` 的 `/dms/`:代理**只在请求没带 `token` 时注入**(否则会覆盖前端刚换的)
+- 两份 env 新增 `VITE_OAUTH_LOGIN_URL`;生产需 nginx **直通 `/oauth/` → 121.43.55.7:10086**
+
+**⚠️ 两个已知代价(用户知悉后仍要求这样做)**:
+1. **服务账号的密码进了公开产物**(构建核对:main chunk 命中 1 次)—— 任何人抠出来都能登录 DMS
+2. **token 落在浏览器内存**(能打开站点的人都能拿到一个"能读 DMS 数据"的令牌)
+⇒ 已在 `service-token.ts` 顶部、`architecture.md`、技术债 **#8**(改为"暴露面扩大")写明;
+#8 的后端行级授权方案仍是"生产接入前必须处理"。
+
+**📌 用户随后澄清(本轮内)**:该账号**是专门准备的「默认账号」**("已处理过了")——
+按要求把上面几处的措辞从"**建议**它只作权限受限的服务账号"改成**事实陈述**
+(`service-token.ts` / `dms-login.mjs` / `architecture.md` / 技术债 #8 各一处),
+避免后来人把它当未决风险反复提;"要收紧就改密码并同步改两处代码"这条操作说明保留。
+
+**📌 同日另一个决定:`.dms-token` 保留、机制不动**(用户问"可以删除吗" → 选"先不删,保持现状")。
+查清后的口径:**前端已不依赖它**(自己经 `/oauth` 换),但它是**命令行工具**经 dev 代理读写 DMS
+的凭据缓存(6 个走代理的工具 + 4 个直接读它的脚本),且 `predev` 会自动重建 ⇒ 单独删等于白删。
+"要彻底不落盘得改哪三件事"已写进 `harness/tools/dms-token.mjs` 头部与 `architecture.md`,
+下次不用再从头查。
+
+**运行过的验证**:
+- **新增 `harness/tools/verify-dms-service-token.mjs` 5/5**(打真实服务、经 dev 代理):
+  ① 前端模块换到 JWT(长度 183)② 二次调用命中缓存 ③ 带真 token → `/dms` code 200 有数据
+  ④ **带假 token → DMS 拒(code 212 无效token)⇒ 证明代理没有覆盖前端带的 token**(关键断言)
+  ⑤ 不带 token → 代理兜底注入 → 200(命令行排查方式未受影响)
+- `npm run build` 通过(main chunk 389.24 → **390.63 kB**:新增模块 + 服务账号)
+- `vue-tsc` 仅既有 `webpack-env` 报错;`validate-harness` 通过
+
+**本轮改动未提交**(用户 2026-09-24 起要求不自动提交)。
+
 

+ 1 - 1
harness/sops/handoff.md

@@ -39,7 +39,7 @@
 
 ```bash
 bash harness/init.sh     # 安装依赖 + 验证 + 打印启动命令
-git add -A && git commit -m "..." && git push origin main   # 每次改动完成即提交推送
+git add -A && git commit -m "..." && git push origin main   # ⚠️ 只在用户明确要求提交时才执行
 npm run dev              # 开发服务器 → https://localhost:8083
 npm run build            # 生产构建(提交前必过)
 npm run test             # jest(可选)

+ 3 - 1
harness/sops/session-end.md

@@ -5,7 +5,9 @@
 
 ## 逐项检查
 
-- [ ] **已提交并推送** —— 每次改动完成就该提交(不攒、也不留给用户手动提交):
+- [ ] **改动都在工作区、且已向用户说明** —— **不要自动提交/推送**(用户 2026-09-24 指示:
+      「后续不要再自动提交和推送了,只有我要求你做你才做」)。收尾时明确告诉用户
+      「本轮改动未提交」,等他说「提交 / 提交并推送」再执行:
       `git add -A && git commit -m "..." && git push origin main`
 - [ ] **标准启动路径还能用** —— `npm run dev` 能起在 8083,页面能打开
 - [ ] **标准验证还能跑** —— `npm run build` 通过(不是"上次通过",是这次跑过)

+ 1 - 0
harness/sops/verification.md

@@ -12,6 +12,7 @@
 | 业务逻辑(不改协议) | 构建通过 + 在 `tools/` 写脚本对真实数据断言 |
 | **任何接口 / 协议相关** | 上面全部 **+ 对真实后端端到端跑一遍**(见下) |
 | **改了 `index.html` / `redirect.html`(含内联脚本)** | 上面全部 **+ `npm run build && node harness/tools/verify-inline-scripts.mjs`** —— ⚠️ **构建不会解析内联 `<script>`**(原样拷贝),删代码删出个不配对的 `}` 也能构建通过,但页面一加载就抛语法错误、加载遮罩永远不隐藏(2026-09-24 实际踩过) |
+| **改了 `.vue` 的 `<script>` 块**(尤其注释/字符串里带标记) | 上面全部 **+ `node harness/tools/verify-vue-script-html-comments.mjs`** —— `.vue` 的 script 里出现**裸的 HTML 注释起始符**时,Vite 的依赖预扫描会把它和后面某个结束符配成注释、**整段吃掉**;`npm run build` **抓不到**,只在冷扫描(改依赖 / `--force`)时炸一次(2026-09-28 实际踩过) |
 
 ## 构建
 

+ 1 - 0
harness/tools/_entry-dms-service-token.ts

@@ -0,0 +1 @@
+export { getServiceDmsToken, clearServiceDmsToken } from '../../src/network/api/dms/service-token';

+ 1 - 0
harness/tools/_entry-stream-filter.ts

@@ -0,0 +1 @@
+export { StreamXMLFilter, RecordRowTypes } from '../../src/utils/stream-xml-filter';

+ 1 - 1
harness/tools/audit-company-info-coverage.mjs

@@ -20,7 +20,7 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
 import { readFileSync } from 'node:fs';
 
 const payloadPath = process.argv[2];
-const dmsBase = process.argv[3] || process.env.DMS_BASE || 'https://localhost:8083/dms-api';
+const dmsBase = process.argv[3] || process.env.DMS_BASE || 'https://localhost:8083/dms';
 if (!payloadPath) {
   console.error('用法:node audit-company-info-coverage.mjs <company-info.json> [dms 代理地址]');
   process.exit(2);

+ 53 - 67
harness/tools/check-policy-sources.mjs

@@ -1,107 +1,93 @@
 /**
- * 对比政策库的**两个来源**,确认「开发环境本地优先」这个开关还有没有必要。
+ * 政策库的**远端(DMS 里那份)与本地副本**是否一致 —— 保证前端"远端优先"拿到的是同一份数据。
  *
- * 背景(2026-09-17):
- *   政策详情面板的「政策名称」跳转地址由 `data.市级政策id` 拼出
- *   (见 src/components/Chat/policy-library-utils.ts 的 buildPolicyDetailUrl)。
- *   但两个来源的字段并不一致:
- *     - 本地 public/merchant-agent/policies_public.v2.json → 有 市级政策id
- *     - 远端 VITE_DOWNLOAD_URL                          → 327 条全无该字段
- *   而 index.html 原本是**远端优先**,于是浏览器里取不到 id,「政策名称」是纯文本。
- *   为此加了 VITE_POLICY_LOCAL_FIRST(仅 .env.development 为 true)。
+ * 背景沿革:
+ *   2026-09-17 这个脚本比的是「本地副本 vs 厂商远端(VITE_DOWNLOAD_URL)」,用来判断
+ *   「开发环境本地优先」那个开关还有没有必要(当年厂商远端 327 条全都没有 `data.市级政策id`,
+ *   导致政策详情点不开,所以开发环境一度本地优先)。
+ *   2026-09-24 用户把政策库的远端换成 **DMS 文件服务里的那份**
+ *   (`node harness/tools/upload-policy-library.mjs` 上传,路径记在 `VITE_POLICY_REMOTE_URL`),
+ *   顺序也固定成"先远端、取不到再用本地兜底",那个开关随之删除。
+ *   于是本脚本的职责变成:**核对两边内容是否一致**(远端上传后忘了同步、或本地被换过,这里会报)。
  *
- * 这个脚本干什么:
- *   把两个来源的条数与 市级政策id 覆盖率打出来,并判断那个开关是否仍然需要。
- *
- * 怎么跑(在项目根目录):
+ * 怎么跑(需要 dev server 起着;远端地址从 .env.development 读,不写死在脚本里):
  *   node harness/tools/check-policy-sources.mjs
  *
- * 退出码:0 = 与预期一致;1 = 情况变了,需要有人看一眼(见输出末尾的提示)
+ * 退出码:0 = 两边一致;1 = 不一致或取不到远端(需要有人看一眼)
  */
 
+process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // dev server 是自签证书
+
 import { readFileSync } from 'node:fs';
 
 const LOCAL_PATH = 'public/merchant-agent/policies_public.v2.json';
 const ENV_PATH = '.env.development';
 
-/** 从 .env.development 读 VITE_DOWNLOAD_URL,避免把地址写死在脚本里 */
-const readDownloadUrl = () => {
+/** 从 .env.development 读 VITE_POLICY_REMOTE_URL(只取路径,脚本不关心 host) */
+const readRemoteUrl = () => {
   const env = readFileSync(ENV_PATH, 'utf8');
-  const line = env.split(/\r?\n/).find((l) => l.trim().startsWith('VITE_DOWNLOAD_URL'));
+  const line = env.split(/\r?\n/).find((l) => l.trim().startsWith('VITE_POLICY_REMOTE_URL'));
   const raw = line?.split('=').slice(1).join('=').trim().replace(/^["']|["']$/g, '');
-  return raw ? raw + 'merchant-agent/policies_public.v2.json' : '';
+  return raw;
 };
 
 const toList = (json) => (Array.isArray(json) ? json : json?.data) || [];
-
-/** 统计 data.市级政策id 的覆盖率(buildPolicyDetailUrl 读的就是这个字段) */
-const countCityId = (list) => {
-  const missing = [];
-  let has = 0;
-  for (const item of list) {
-    const value = (item?.data || {})['市级政策id'];
-    if (value && String(value).trim()) has++;
-    else missing.push(item?.name || '(无名)');
-  }
-  return { has, total: list.length, missing };
-};
+const nameOf = (item) => String(item?.['政策名称'] ?? item?.name ?? '').trim();
+const deptOf = (item) => String(item?.['政策所属部门'] ?? item?.department ?? '').trim();
 
 const localList = toList(JSON.parse(readFileSync(LOCAL_PATH, 'utf8')));
-const localId = countCityId(localList);
+const remoteUrl = readRemoteUrl();
 
 console.log('本地 ' + LOCAL_PATH);
-console.log('  条数: ' + localList.length + ' | 市级政策id: ' + localId.has + '/' + localId.total);
+console.log('  条数: ' + localList.length + ' | 政策名去重: ' + new Set(localList.map(nameOf)).size + ' 个');
 
 let remoteList = [];
-let remoteId = { has: 0, total: 0, missing: [] };
 let remoteError = '';
-const url = readDownloadUrl();
-console.log('\n远端 ' + (url || '(未在 ' + ENV_PATH + ' 找到 VITE_DOWNLOAD_URL)'));
-if (url) {
+console.log('\n远端 ' + (remoteUrl || '(未在 ' + ENV_PATH + ' 找到 VITE_POLICY_REMOTE_URL)'));
+if (remoteUrl) {
+  // 环境变量里存的是同源相对路径(dev 由 vite 代理),脚本里补上 dev server 的 origin 再取
+  const absolute = /^https?:\/\//.test(remoteUrl) ? remoteUrl : 'https://localhost:8083' + remoteUrl;
   try {
-    const res = await fetch(url);
+    const res = await fetch(absolute);
+    const text = await res.text();
     if (!res.ok) throw new Error('HTTP ' + res.status);
-    remoteList = toList(await res.json());
-    remoteId = countCityId(remoteList);
-    console.log('  条数: ' + remoteList.length + ' | 市级政策id: ' + remoteId.has + '/' + remoteId.total);
+    remoteList = toList(JSON.parse(text));
+    console.log('  条数: ' + remoteList.length + ' | 政策名去重: ' + new Set(remoteList.map(nameOf)).size + ' 个');
   } catch (err) {
-    remoteError = err.message;
-    console.log('  拉取失败: ' + remoteError + '(跳过远端对比)');
+    remoteError = err instanceof Error ? err.message : String(err);
+    console.log('  拉取失败: ' + remoteError);
   }
 }
 
-const localOk = localId.has > 0;
-const remoteHasId = remoteId.has > 0;
 console.log('\n结论:');
-console.log('  本地这份带 市级政策id:' + (localOk ? '是' : '否'));
-if (url && !remoteError) {
-  console.log('  远端这份带 市级政策id:' + (remoteHasId ? '是' : '否'));
-}
-
 let exitCode = 0;
-if (!localOk) {
-  console.log('  ⚠️ 本地这份也没有 id 了 —— 「本地优先」这个开关失去意义,需重新找 id 来源');
+if (!remoteUrl) {
+  console.log('  ⚠️ 没有配 VITE_POLICY_REMOTE_URL —— 前端会走本地兜底(功能可用,但没有"远端优先"了)');
   exitCode = 1;
-} else if (url && !remoteError && remoteHasId) {
-  console.log('  ⚠️ 远端现在也带 id 了 —— 「开发环境本地优先」已无必要,可以去掉 VITE_POLICY_LOCAL_FIRST');
+} else if (remoteError) {
+  console.log('  ⚠️ 远端取不到(' + remoteError + ')—— 前端会退回本地副本;检查 DMS/代理是否正常');
+  exitCode = 1;
+} else if (localList.length !== remoteList.length) {
+  console.log('  ⚠️ 两边条数不同(本地 ' + localList.length + ' / 远端 ' + remoteList.length + ')');
+  const localNames = new Set(localList.map(nameOf));
+  const remoteNames = new Set(remoteList.map(nameOf));
+  console.log('     仅远端有的政策名 ' + [...remoteNames].filter((n) => !localNames.has(n)).length + ' 个,' +
+    '仅本地有的 ' + [...localNames].filter((n) => !remoteNames.has(n)).length + ' 个');
   exitCode = 1;
 } else {
-  console.log('  ✅ 与预期一致:远端无 id,开发环境仍需本地优先');
+  const localDepts = [...new Set(localList.map(deptOf))].sort().join('、');
+  const remoteDepts = [...new Set(remoteList.map(deptOf))].sort().join('、');
+  if (localDepts !== remoteDepts) {
+    console.log('  ⚠️ 部门取值不同:本地 ' + localDepts + ' / 远端 ' + remoteDepts);
+    exitCode = 1;
+  } else {
+    console.log('  ✅ 远端与本地内容一致(' + localList.length + ' 条,部门 ' +
+      [...new Set(localList.map(deptOf))].filter(Boolean).length + ' 个)—— 远端优先拿到的就是这份');
+  }
 }
 
-if (remoteList.length && localList.length !== remoteList.length) {
-  const localNames = new Set(localList.map((x) => x.name));
-  const remoteNames = new Set(remoteList.map((x) => x.name));
-  console.log(
-    '\n条数差异(本地少 ' +
-      (remoteList.length - localList.length) +
-      ' 条):仅远端有的政策名 ' +
-      [...remoteNames].filter((n) => !localNames.has(n)).length +
-      ' 个,仅本地有的 ' +
-      [...localNames].filter((n) => !remoteNames.has(n)).length +
-      ' 个'
-  );
-  console.log('  ⚠️ 开发环境用本地这份意味着比远端少 ' + (remoteList.length - localList.length) + ' 条申报事项');
+if (exitCode && remoteList.length && localList.length === remoteList.length) {
+  console.log('  (内容有差异时:重新上传本地这份即可 —— node harness/tools/upload-policy-library.mjs)');
 }
 
 process.exit(exitCode);

+ 29 - 5
harness/tools/dms-login.mjs

@@ -7,6 +7,8 @@
  * 接口来源:隔壁项目 `zhaoshang_admin_ui` 的 `src/services/auth.ts` —— 那边已实测通过
  * (`harness/product-specs/auth.md` 的 `auth-ams-001`),本脚本照抄它的请求形态:
  *   POST {OAUTH_TARGET}/oauth/api/user/login
+ *   ⚠️ **路径必须带 `/oauth`**:2026-09-29 用户提到过 `{OAUTH_TARGET}/api/user/login`,
+ *      实测那条**是 404**(同一个 host,两条路径都打过),能用的是下面这条 —— 别"顺手改短"。
  *   Content-Type: application/x-www-form-urlencoded
  *   body: userName / password / clientId
  *   响应: { code: 200, message: <token>, content: { id, userName, identityName, failureTime } }
@@ -15,14 +17,17 @@
  * 凭据从哪来(按顺序):
  *   ① 参数:node harness/tools/dms-login.mjs <userName> <password>
  *   ② 环境变量:DMS_USERNAME / DMS_PASSWORD
- *   ③ 本机文件 `.dms-account`(gitignore,两行 `username=` / `password=`)
+ *   ③ **代码内置的默认服务账号**(见下面的 `DEFAULT_ACCOUNT`)—— 用户 2026-09-28 要求写进代码
+ *   ④ 本机文件 `.dms-account`(gitignore,两行 `username=` / `password=`)—— **仅当 ③ 为空时**才用得上;
+ *      本机那份已于 2026-09-28 删除(用户:「没用就删掉」),`.gitignore` 里仍留着该模式
  *
  * 用法:
  *   npm run dms:login              # 登录并写入 .dms-token
  *   npm run dms:login -- --if-needed   # 当前 token 还有效就跳过(predev 用这个)
  *
- * ⚠️ 密码是**明文**存在 `.dms-account` 里的(与 `.env.*.local` 同一性质:本机、不入库)。
- * 不愿意存密码就别用这个脚本,改用 `npm run dms:token` 从剪贴板粘贴 —— 那条路不碰密码。
+ * ⚠️ 密码的下场:2026-09-28 起**默认服务账号的密码直接写在下面的 `DEFAULT_ACCOUNT` 里**(用户要求),
+ * 本机文件那条路(`.dms-account`)已删除;`npm run dms:token`(从剪贴板粘 token)那条路**不碰密码**,
+ * 仍是"不想让密码落任何文件"时的替代方案。
  */
 
 import { existsSync, readFileSync, writeFileSync } from 'node:fs';
@@ -34,6 +39,24 @@ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
 /** 本机账号文件(gitignore):两行 `username=` / `password=` */
 const ACCOUNT_FILE = join(repoRoot, '.dms-account');
 
+/* ────────────────────────────────────────────────────────────────────────────
+ * 默认服务账号(团队统一)—— **用户 2026-09-28 明确要求直接写进代码**:
+ *   「我给你一个默认账号…以后未登录时 token 用这个账号获取,直接写进代码里」
+ *
+ * ⚠️ 这是本项目「凭据不落盘」规则(用户 2026-09-23 定)的**唯一例外**,代价已知悉:
+ *    - 本仓库会推到共享远端 ⇒ **这个密码会永久留在 git 历史里**(事后只能靠改密码补救);
+ *      如果哪天这个远端不再可信,第一件事就是改这个账号的密码。
+ *    - 📌 **它正是用户专门准备的「默认账号」**(用户 2026-09-29 确认:已处理过、就是干这个用的)
+ *      —— 别拿它去申请别的权限,也别在别处复用它的密码。
+ *    - 只在 **node 侧**(本脚本换 DMS token)用;**绝不要**把它复制进 `src/` ——
+ *      前端的产物是公开的,写进去等于把这个账号公开。
+ *   想临时用自己的账号:用参数或环境变量(①② 优先级高于这里)。
+ * ──────────────────────────────────────────────────────────────────────────── */
+const DEFAULT_ACCOUNT = {
+  userName: 'user_yszs_web',
+  password: 'Yszs@123456',
+};
+
 const OAUTH_TARGET = (process.env.OAUTH_TARGET || 'http://121.43.55.7:10086').replace(/\/+$/, '');
 const CLIENT_ID = process.env.OAUTH_CLIENT_ID || '0';
 
@@ -53,8 +76,9 @@ const parseAccountFile = (text) => {
 const readAccount = () => {
   const fromFile = existsSync(ACCOUNT_FILE) ? parseAccountFile(readFileSync(ACCOUNT_FILE, 'utf8')) : {};
   return {
-    userName: String(args[0] || process.env.DMS_USERNAME || fromFile.username || '').trim(),
-    password: String(args[1] || process.env.DMS_PASSWORD || fromFile.password || '').trim(),
+    // 顺序:参数 → 环境变量 → **代码内置默认服务账号** → 本机文件(兜底)
+    userName: String(args[0] || process.env.DMS_USERNAME || DEFAULT_ACCOUNT.userName || fromFile.username || '').trim(),
+    password: String(args[1] || process.env.DMS_PASSWORD || DEFAULT_ACCOUNT.password || fromFile.password || '').trim(),
   };
 };
 

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

@@ -10,6 +10,16 @@
  *
  * vite 代理**每个请求**调用一次 `readDmsToken()`(见 vite.config.ts),
  * 所以换 token 之后 **dev server 不用重启**。
+ *
+ * 📌 **2026-09-29 用户确认:`.dms-token` 保留、机制不动**(问过"能不能删")——
+ *    因为**前端已改成自己经 `/oauth` 换 token**(`src/network/api/dms/service-token.ts`),
+ *    这个文件现在只为**命令行工具**服务:它们默认打 `localhost:8083/dms` 且不带 token 头,
+ *    靠 vite 代理从这里注入(`npm run dev` 的 predev 也会自动续期)。
+ *    真要彻底不落盘,需要三件事(都还没做):
+ *      ① 代理改成"没带 token 时用服务账号现换 + 内存缓存"(不再读文件);
+ *      ② 6 个走代理的工具(verify-dms-* / verify-company-classify-* / audit-company-info-coverage /
+ *         gen-company-info-mapping-doc / upload-policy-library)改成自己换 token 并带 `token` 头;
+ *      ③ 去掉 predev 的自动续期与 `dms-login.mjs` 的写文件逻辑。
  */
 
 import { readFileSync } from 'node:fs';

+ 1 - 1
harness/tools/gen-company-info-mapping-doc.mjs

@@ -14,7 +14,7 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
 
 import { writeFileSync } from 'node:fs';
 
-const dmsBase = process.env.DMS_BASE || 'https://localhost:8083/dms-api';
+const dmsBase = process.env.DMS_BASE || 'https://localhost:8083/dms';
 globalThis.VITE_DMS_API = dmsBase;
 
 // ⚠️ 取的是 modelId(2036/2032),不是 columnId —— 传错会静默拿到别的模型

+ 5 - 2
harness/tools/get-nls-token.mjs

@@ -1,9 +1,12 @@
 /**
  * 换一个阿里云 NLS(智能语音交互)的 token —— 命令行版。
  *
- * 签名与缓存逻辑在 `nls-token.mjs`(dev server 的 `/nls-token/` 端点用同一份)。
+ * 签名逻辑在 `nls-token.mjs`(harness 工具共用一份)。
  *
- * 怎么跑(凭据只从环境变量读,脚本里不存任何值):
+ * ⚠️ **平时不需要跑它**:正式的取 token 方式是**后端网关**(`resolveNlsToken` 会优先走它),
+ * 本脚本是"网关取不到时自己签"的排查工具。
+ *
+ * 怎么跑(凭据**只从环境变量读**,2026-09-28 起 `.env.development.local` 里已不保存凭据):
  *   ALIYUN_NLS_AK=xxx ALIYUN_NLS_SK=yyy node harness/tools/get-nls-token.mjs
  *   # 只想拿 token 本身(给别的命令用):... | tail -1
  *

+ 43 - 7
harness/tools/nls-token.mjs

@@ -9,9 +9,14 @@
  * 所以:凭据只在 **node 侧**读(`vite.config.ts` 由它转成 `/nls-token/` 端点;前端只拿 token)。
  * 生产环境要换成后端/网关做同一件事——**不要把 dev 端点直接当生产方案**。
  *
- * 凭据来源:显式的 `env` 参数 → `process.env.ALIYUN_NLS_AK / ALIYUN_NLS_SK / ALIYUN_NLS_APPKEY`
- * (开发期写在 `.env.development.local`,该文件按 `.gitignore` 的 `.env.*.local` **不入库**)。
+ * 凭据来源(2026-09-28 起**磁盘上不再保存任何阿里云凭据**):
+ *   - `appkey`:从**代码里的内置值**读(`src/utils/runtime-config.ts` 的 `NLS_APPKEY_BUILTIN`),
+ *     见 `readBuiltinAppkey()`;`ALIYUN_NLS_APPKEY` 仍可覆盖。
+ *   - `AK/SK`:只在**显式传入**时可用(`ALIYUN_NLS_AK / ALIYUN_NLS_SK` 环境变量),
+ *     仅用于"网关取不到 token 时自签"的兜底;平时走网关,**不需要它们**。
  * **任何地方都不要把凭据值写进仓库文件**(用户 2026-09-23 明确要求)。
+ * ⚠️ 唯一例外:DMS 的默认服务账号 —— 用户 2026-09-28 要求写进 `dms-login.mjs`(见那里的 ⚠️ 说明)。
+ *    NLS/Aliyun 这边的 AK/SK **仍然不落盘**,不要援引那个例外。
  *
  * 签名算法(阿里云 RPC 风格):
  *   - 公共参数:Action / Version / Format / AccessKeyId / SignatureMethod /
@@ -26,9 +31,37 @@ import { readFileSync } from 'node:fs';
 import { dirname, join } from 'node:path';
 import { fileURLToPath } from 'node:url';
 
-/** 本机凭据文件(gitignore,仓库根)—— 与 `.dms-token` 同一性质 */
+/** 本机凭据文件(gitignore,仓库根)—— 与 `.dms-token` 同一性质。
+ *  ⚠️ 2026-09-28 起它**是空的**(凭据不再落盘),保留常量只为兼容旧调用与提示路径。 */
 export const NLS_LOCAL_ENV_FILE = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.env.development.local');
 
+/** 前端内置 appkey 的所在文件(用户 2026-09-28 要求把 appkey 写进代码) */
+export const NLS_APPKEY_SOURCE_FILE = join(
+  dirname(fileURLToPath(import.meta.url)),
+  '..',
+  '..',
+  'src',
+  'utils',
+  'runtime-config.ts'
+);
+
+/**
+ * 读**代码里内置的 appkey**(`NLS_APPKEY_BUILTIN`)。
+ *
+ * appkey 不是密钥(换 token 不需要它、网关也不校验它),所以直接写在代码里;
+ * 脚本从这份唯一来源读,不再要求 `.env.development.local` 里有一份。
+ * 读不到就返回空串(由调用方决定报错还是降级)。
+ */
+export const readBuiltinAppkey = () => {
+  try {
+    const source = readFileSync(NLS_APPKEY_SOURCE_FILE, 'utf8');
+    const matched = source.match(/NLS_APPKEY_BUILTIN\s*=\s*["']([^"']+)["']/);
+    return matched ? matched[1].trim() : '';
+  } catch {
+    return '';
+  }
+};
+
 export const NLS_TOKEN_HOST = 'https://nls-meta.cn-shanghai.aliyuncs.com/';
 export const NLS_TOKEN_REGION = 'cn-shanghai';
 export const NLS_TOKEN_API_VERSION = '2019-02-28';
@@ -60,15 +93,18 @@ export const maskCredential = (value) => {
 export const readNlsCredentials = (env = process.env) => ({
   ak: String(env?.ALIYUN_NLS_AK || '').trim(),
   sk: String(env?.ALIYUN_NLS_SK || '').trim(),
-  appkey: String(env?.ALIYUN_NLS_APPKEY || '').trim(),
+  // appkey 不再是"凭据":缺 env 就回退到**代码里的内置值**(唯一来源)
+  appkey: String(env?.ALIYUN_NLS_APPKEY || '').trim() || readBuiltinAppkey(),
 });
 
 /**
  * 读一个 `.env` 风格的文件,只取 `KEY=VALUE`(忽略注释与空行)。
  *
- * 给**命令行工具**用:vite 侧有 `loadEnv` 读 `.env.development.local`,
- * 但脚本直接跑时 `process.env` 里没有这些值 —— 于是 `--check` 会误报"未配置"。
- * 优先级仍是**环境变量 > 文件**(CI/临时覆盖更方便)。
+ * 给**命令行工具**用:脚本直接跑时 `process.env` 里没有 `.env` 里的值 ——
+ * 于是 `--check` 会误报"未配置"。优先级仍是**环境变量 > 文件**(CI/临时覆盖更方便)。
+ *
+ * ⚠️ 2026-09-28 起 `.env.development.local` **已删除**(凭据不落盘),这个读取器现在
+ * 只对"你自己新建了某个 `.env.*.local` 想临时用"的场景有意义(文件不存在时返回 `{}`)。
  */
 export const readDotEnvFile = (file) => {
   const result = {};

+ 5 - 3
harness/tools/probe-nls-realtime.mjs

@@ -6,7 +6,7 @@
  * 网关返回的是 `TaskFailed` / 直接断连,光看封装层代码排查不出来
  * (本项目在别处踩过同样的坑:协议问题必须读原始 SSE/WS 载荷)。
  *
- * 怎么跑(凭据从 .env.development.local 读,见 harness/tools/nls-token.mjs):
+ * 怎么跑(appkey 从代码内置值读;AK/SK 只在网关取不到 token 时才需要,临时用 env 传):
  *   node harness/tools/probe-nls-realtime.mjs
  *   node harness/tools/probe-nls-realtime.mjs --audio=<16k单声道16bit的pcm/raw文件>
  *
@@ -27,8 +27,10 @@ import { readAudioAsPcm16kMono, synthesizeSpeechToWav } from './nls-test-audio.m
 
 
 const credentials = readNlsCredentials({ ...readDotEnvFile(NLS_LOCAL_ENV_FILE), ...process.env });
-if (!credentials.ak || !credentials.sk || !credentials.appkey) {
-  console.error('缺凭据:ALIYUN_NLS_AK / ALIYUN_NLS_SK / ALIYUN_NLS_APPKEY');
+// 2026-09-28 起**只需要 appkey**(从代码内置值读,见 nls-token.mjs 的 readBuiltinAppkey);
+// AK/SK 只在"网关取不到 token 时自签"才需要,临时用 env 传即可(磁盘上不存)。
+if (!credentials.appkey) {
+  console.error('缺 appkey(应从 src/utils/runtime-config.ts 的 NLS_APPKEY_BUILTIN 读到)');
   process.exit(2);
 }
 

+ 63 - 0
harness/tools/upload-policy-library.mjs

@@ -0,0 +1,63 @@
+/**
+ * 把本地政策库(`public/merchant-agent/policies_public.v2.json`)**上传到 DMS 文件服务**,
+ * 打印出它的**相对路径**与**可访问地址** —— 那个地址就是前端的"远端政策接口"。
+ *
+ * 用户 2026-09-24 的要求:
+ *   「把 policies_public.v2.json 上传到 DMS,记住那个路径,那个路径就是新的远端政策接口,
+ *     后续先从那个接口拿,拿不到再用本地的兜底,最终目标是不再依赖原来的政策相关接口。」
+ *
+ * 用的是**已有的上传模块**(`src/network/api/dms/file-upload.ts`,与附件上传同一份实现、
+ * 同一套实测契约:multipart 五个字段全必填);路径本身取不到,要拼 DMS 代理前缀才能访问
+ * (见 `buildDmsFileDisplayUrl` 的注释与实测结论)。
+ *
+ * 怎么跑(需要 dev server 起着,代理负责补 `/dms` 前缀并注入 token):
+ *   node harness/tools/upload-policy-library.mjs                        # 默认打 https://localhost:8083/dms
+ *   node harness/tools/upload-policy-library.mjs https://host/dms   # 也可以显式给代理地址
+ *
+ * 跑完把打印出来的「相对路径」写进 `.env.*` 的 `VITE_POLICY_REMOTE_URL`(拼好代理前缀的那份)。
+ */
+
+process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // dev server 是自签证书
+
+const base = String(process.argv[2] || 'https://localhost:8083/dms').replace(/\/+$/, '');
+globalThis.VITE_DMS_API = base;
+
+const { uploadDmsAttachment, buildDmsFileDisplayUrl } = await import('./_dms.mjs');
+const { readFileSync } = await import('node:fs');
+
+const LOCAL_FILE = 'public/merchant-agent/policies_public.v2.json';
+const bytes = readFileSync(LOCAL_FILE);
+const list = JSON.parse(bytes.toString('utf8'));
+
+console.log(`上传 ${LOCAL_FILE}(${(bytes.length / 1024).toFixed(0)} KB / ${Array.isArray(list) ? list.length : '?'} 条)`);
+console.log(`经代理 ${base}(代理负责补 /dms 前缀并注入 token)`);
+
+const file = new File([bytes], 'policies_public.v2.json', { type: 'application/json' });
+const relativePath = await uploadDmsAttachment(file, { isImage: false });
+const displayUrl = buildDmsFileDisplayUrl(relativePath);
+
+console.log('\n相对路径(发给后端那种,也是 DMS 里存的位置):');
+console.log('  ' + relativePath);
+console.log('可访问地址(前端实际 fetch 的,拼了代理前缀):');
+console.log('  ' + displayUrl);
+
+// 立刻自检一次:这个地址真的能取回同一份 JSON(不是 404、也不是 HTML 兜底)
+const response = await fetch(displayUrl);
+const contentType = response.headers.get('content-type') || '';
+const text = await response.text();
+let parsed = null;
+try {
+  parsed = JSON.parse(text);
+} catch {
+  /* 下面报出来 */
+}
+const count = Array.isArray(parsed) ? parsed.length : null;
+console.log('\n自检:');
+console.log(`  HTTP ${response.status} · content-type=${contentType}`);
+console.log(`  条数=${count ?? '(解析失败)'}${count === list.length ? ' ✓ 与本地一致' : ' ✗ 与本地不一致!'}`);
+if (count !== list.length) {
+  console.error('  ⚠️ 远端内容与本地不一致,先别写进 VITE_POLICY_REMOTE_URL');
+  process.exit(1);
+}
+console.log('\n把下面这行写进 .env.development / .env.production(生产换成 nginx 映射的同源路径):');
+console.log(`  VITE_POLICY_REMOTE_URL="${displayUrl}"`);

+ 4 - 4
harness/tools/verify-attachment-question.mjs

@@ -292,17 +292,17 @@ console.log('\n【7】消息附件:`url`(能显示)与 `fileUrl`(发给
   // 换 DMS 后 `fileUrl` 从 OSS 绝对地址变成了 `/static/photo/…` 这种**相对路径**,
   // 而 `consumeSuccessfulFileTransmission` 当时仍然只填 `url: file.fileUrl` ——
   // 于是气泡里的 `<img src="/static/photo/…">` 经 dev server 拿到的是 index.html(200 但非图片),
-  // 图**解不出来**。实测:相对路径 → text/html;`/dms-api` + 相对路径 → image/png。
+  // 图**解不出来**。实测:相对路径 → text/html;`/dms` + 相对路径 → image/png。
   const stubFetchOk = () =>
     new Response(JSON.stringify({ code: 200, content: '/static/photo/1889-c_question-x.png' }), { status: 200 });
   const restoreFetch = globalThis.fetch;
   globalThis.fetch = async () => stubFetchOk();
 
   const RELATIVE = '/static/photo/1889-c_question-x.png';
-  const DISPLAY = '/dms-api' + RELATIVE;
+  const DISPLAY = '/dms' + RELATIVE;
 
   // 7.1 配好 DMS 前缀(开发环境的取值,见 .env.development 的 VITE_DMS_API)
-  globalThis.VITE_DMS_API = '/dms-api';
+  globalThis.VITE_DMS_API = '/dms';
   {
     const { uploadedFiles, handleFiles, consumeSuccessfulFileTransmission } = useBusinessAssistantUpload({
       notify: () => {},
@@ -323,7 +323,7 @@ console.log('\n【7】消息附件:`url`(能显示)与 `fileUrl`(发给
     const payload = toChatAttachmentUrls(transmission);
     check('请求体 pictures 里是相对路径(不是展示地址)',
       payload.pictures.length === 1 && payload.pictures[0] === RELATIVE, JSON.stringify(payload));
-    check('展示地址**没有**混进请求体', !JSON.stringify(payload).includes('/dms-api'), JSON.stringify(payload));
+    check('展示地址**没有**混进请求体', !JSON.stringify(payload).includes('/dms'), JSON.stringify(payload));
     check('非图片附件不占 pictures', payload.files.length === 0, JSON.stringify(payload));
   }
 

+ 2 - 2
harness/tools/verify-company-classify-dms.mjs

@@ -17,7 +17,7 @@
  *
  * 怎么跑(先按 _entry-company-classify.ts 头部注释重新打包):
  *   1) npm run dev        # 代理负责注入 DMS token
- *   2) node harness/tools/verify-company-classify-dms.mjs https://localhost:8083/dms-api
+ *   2) node harness/tools/verify-company-classify-dms.mjs https://localhost:8083/dms
  *
  * ⚠️ 脚本**不持有 token**(和浏览器一样只打代理地址);测试数据用可识别的信用代码前缀,
  * 结束时自行清理。
@@ -25,7 +25,7 @@
 
 process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
 
-const base = process.argv[2] || 'https://localhost:8083/dms-api';
+const base = process.argv[2] || 'https://localhost:8083/dms';
 globalThis.VITE_DMS_API = base;
 
 const {

+ 1 - 1
harness/tools/verify-company-classify-e2e.mjs

@@ -25,7 +25,7 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
 import { readFileSync } from "node:fs";
 
 const chatBase = process.env.CHAT_BASE || "https://localhost:8083/chat-api";
-const dmsBase = process.env.DMS_BASE || "https://localhost:8083/dms-api";
+const dmsBase = process.env.DMS_BASE || "https://localhost:8083/dms";
 globalThis.VITE_CHAT_API = chatBase;
 globalThis.VITE_DMS_API = dmsBase;
 

+ 2 - 2
harness/tools/verify-dms-chat-storage.mjs

@@ -17,14 +17,14 @@
  *   1) npm run dev
  *   2) npx esbuild harness/tools/_entry-dms.ts --bundle --format=esm \
  *        --outfile=harness/tools/_dms.mjs --alias:@=./src --define:import.meta.env='{}'
- *   3) node harness/tools/verify-dms-chat-storage.mjs https://localhost:8083/dms-api
+ *   3) node harness/tools/verify-dms-chat-storage.mjs https://localhost:8083/dms
  *
  * 脚本**不持有 token**——和浏览器一样只打代理地址。
  */
 
 process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
 
-const base = process.argv[2] || 'https://localhost:8083/dms-api';
+const base = process.argv[2] || 'https://localhost:8083/dms';
 globalThis.VITE_DMS_API = base;
 // 取本机 IP 的接口(DMS 访客归属用)。与浏览器同一条代理路径:页面是 HTTPS、接口是 HTTP,
 // 直连会被混合内容拦掉。node 里没有这个限制,但走代理更接近真实链路。

+ 1 - 1
harness/tools/verify-dms-file-upload.mjs

@@ -23,7 +23,7 @@
 
 process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
 
-const base = process.argv[2] || 'https://localhost:8083/dms-api';
+const base = process.argv[2] || 'https://localhost:8083/dms';
 globalThis.VITE_DMS_API = base;
 
 const realFetch = globalThis.fetch;

+ 110 - 0
harness/tools/verify-dms-service-token.mjs

@@ -0,0 +1,110 @@
+/**
+ * 验证「未登录访客由前端自己换 DMS token」这条链(用户 2026-09-29 要求)。
+ *
+ * 验证什么(都打**真实服务**,经过 dev server 的同源代理):
+ *   ① 前端模块 `getServiceDmsToken()` 能经 `/oauth/...` 用服务账号换到 JWT;
+ *   ② 拿这个 token **自己带 `token` 头**请求 `/dms/...` 能取到数据;
+ *   ③ ⚠️ 关键:**代理不覆盖**前端带的 token —— 故意带一个假 token,DMS 必须拒绝
+ *      (若代理仍无条件注入自己的 token,这里会错误地返回 200)。
+ *
+ * 前置:先起 dev server(脚本会自己起,端口 8101)。自签证书 → 用
+ * `NODE_TLS_REJECT_UNAUTHORIZED=0` 跑(脚本内部已设置)。
+ *
+ * 怎么跑:
+ *   npx esbuild harness/tools/_entry-dms-service-token.ts --bundle --format=esm \
+ *     --outfile=harness/tools/_dms-service-token.mjs --alias:@=./src --define:import.meta.env='{}'
+ *   node harness/tools/verify-dms-service-token.mjs
+ */
+
+import { spawn } from 'node:child_process';
+import { setTimeout as sleep } from 'node:timers/promises';
+
+process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
+
+const PORT = 8101;
+const ORIGIN = `https://localhost:${PORT}`;
+
+let pass = 0;
+let fail = 0;
+const check = (label, ok, extra = '') => {
+  if (ok) {
+    pass += 1;
+    console.log(`  ok   ${label}${extra ? ` —— ${extra}` : ''}`);
+  } else {
+    fail += 1;
+    console.log(`  FAIL ${label}${extra ? ` —— ${extra}` : ''}`);
+  }
+};
+
+const dev = spawn('npx', ['vite', '--port', String(PORT), '--mode', 'development'], {
+  cwd: process.cwd(),
+  shell: true,
+  stdio: 'ignore',
+});
+
+const stopDev = () => {
+  try {
+    if (process.platform === 'win32') spawn('taskkill', ['/PID', String(dev.pid), '/T', '/F'], { shell: true });
+    else dev.kill('SIGTERM');
+  } catch {
+    /* 尽力而为 */
+  }
+};
+
+try {
+  // 等 dev server 起来
+  for (let i = 0; i < 40; i += 1) {
+    await sleep(1000);
+    try {
+      const res = await fetch(`${ORIGIN}/`);
+      if (res.ok) break;
+    } catch {
+      /* 还没起来 */
+    }
+  }
+
+  // 模块在 node 里跑:相对路径没法直接 fetch,所以用 globalThis 覆盖成绝对地址
+  globalThis.VITE_OAUTH_LOGIN_URL = `${ORIGIN}/oauth/api/user/login`;
+  const { getServiceDmsToken, clearServiceDmsToken } = await import('./_dms-service-token.mjs');
+
+  console.log('\n【1】前端模块换 token(经 /oauth 同源代理)');
+  let token = '';
+  try {
+    token = await getServiceDmsToken();
+  } catch (error) {
+    check('换 token 成功', false, error instanceof Error ? error.message : String(error));
+  }
+  check('换 token 成功且是 JWT 形状', /^ey[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\./.test(token), `长度 ${token.length}`);
+  check('再次调用命中缓存(同一 token)', (await getServiceDmsToken()) === token);
+
+  console.log('\n【2】带着这个 token 走 /dms 代理读写');
+  const dmsCall = async (tokenHeader) => {
+    const res = await fetch(`${ORIGIN}/dms/content/selectContentList`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(tokenHeader ? { token: tokenHeader } : {}) },
+      body: new URLSearchParams({ columnId: '1889', page: '1', size: '1' }).toString(),
+    });
+    return res.json();
+  };
+  const okBody = await dmsCall(token);
+  check('带真 token → code 200 且有数据', okBody?.code === 200 && (okBody?.content?.data?.length ?? 0) > 0, `code=${okBody?.code}`);
+
+  console.log('\n【3】代理不覆盖前端带的 token(关键断言)');
+  const bogus = await dmsCall('this-is-not-a-valid-token');
+  check(
+    '带假 token → DMS 拒绝(201/212),说明代理没有偷偷注入自己的 token',
+    bogus?.code === 201 || bogus?.code === 212,
+    `code=${bogus?.code} message=${String(bogus?.message ?? '').slice(0, 40)}`
+  );
+
+  console.log('\n【4】不带 token → 仍由代理兜底注入(命令行走代理的排查方式)');
+  const viaProxy = await dmsCall('');
+  check('不带 token → 代理注入 `.dms-token` 后 200', viaProxy?.code === 200, `code=${viaProxy?.code}`);
+
+  clearServiceDmsToken();
+} finally {
+  stopDev();
+}
+
+console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
+process.exit(fail ? 1 : 0);

+ 14 - 0
harness/tools/verify-inline-scripts.mjs

@@ -84,5 +84,19 @@ console.log('\n【2】本仓库这次踩过的具体症状:占位符没被替
   }
 }
 
+console.log('\n【3】加载遮罩的**收尾信号**必须还在(少了它页面永远卡在"正在初始化")');
+{
+  // 契约:BusinessAssistant.vue 的 initSequence() 靠 `globalThis.hasSequence`
+  // (或等 `sequenceLoaded` 事件)隐藏 #loading-cover。
+  // ⚠️ 2026-09-28 真实踩过:删 index.html 里一段死代码(`if (isKnowledgeApi) { 硬编码 cardInfo }`)时,
+  //    把**紧跟在它后面的**这三行+addVersionInfo 一起删掉了 → 页面永久卡在加载遮罩。
+  //    (当时的 inline-script 语法检查是全绿的 —— 语法没错,是**语义丢了**。)
+  const indexHtml = readFileSync(join(DIST, 'index.html'), 'utf8');
+  check('设置了 globalThis.hasSequence(遮罩收尾判据)', /globalThis\.hasSequence\s*=\s*true/.test(indexHtml));
+  check('派发了 sequenceLoaded 事件(另一条收尾路径)',
+    /dispatchEvent\(new CustomEvent\("sequenceLoaded"\)\)/.test(indexHtml));
+  check('addVersionInfo() 仍被调用(标题带版本号)', /addVersionInfo\(\)/.test(indexHtml));
+}
+
 console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
 process.exit(fail ? 1 : 0);

+ 6 - 5
harness/tools/verify-nls-realtime.mjs

@@ -12,8 +12,9 @@
  *     --outfile=harness/tools/_nls.mjs --alias:@=./src --define:import.meta.env='{}'
  *   node harness/tools/verify-nls-realtime.mjs
  *
- * 凭据从 `.env.development.local` 读(见 harness/tools/nls-token.mjs);
- * 没有凭据或没有 TTS 时,【2】会**明确跳过**而不是假装通过。
+ * appkey 从**代码内置值**读(`src/utils/runtime-config.ts` 的 `NLS_APPKEY_BUILTIN`)——
+ * 2026-09-28 起磁盘上不再保存任何阿里云凭据;没有 appkey 或没有 TTS 时,
+ * 【2】会**明确跳过**而不是假装通过。
  */
 
 import { existsSync } from 'node:fs';
@@ -164,8 +165,8 @@ console.log('\n【2】16k / 16bit / 单声道 编码');
 // ── 3. 真实端到端(打真实网关)───────────────────────────────────────────────
 console.log('\n【3】打真实网关:说一句话 → 看识别结果');
 const credentials = readNlsCredentials({ ...readDotEnvFile(NLS_LOCAL_ENV_FILE), ...process.env });
-if (!credentials.ak || !credentials.sk || !credentials.appkey) {
-  console.log('  ⏭  跳过:没配 NLS 凭据(.env.development.local 的 ALIYUN_NLS_AK / SK / APPKEY)');
+if (!credentials.appkey) {
+  console.log('  ⏭  跳过:读不到 appkey(src/utils/runtime-config.ts 的 NLS_APPKEY_BUILTIN)');
 } else if (!canSynthesizeSpeech()) {
   console.log('  ⏭  跳过:真实识别这一段需要 Windows 自带 TTS 造语音');
 } else {
@@ -267,7 +268,7 @@ console.log('\n【4】两个壳组件已切到 NLS 实现(源码级断言)')
     check(`${file}:不再 import 旧的厂商 SDK`,
       !/import ASR from '@\/three-libs\/asr\/index';/.test(source) &&
         !/import ASR from '@\/asr\/index';/.test(source));
-    check(`${file}:不再硬编码 appKey(改由端点下发)`,
+    check(`${file}:不再硬编码 appKey(由 runtime-config 统一提供:env/注入 → 内置值)`,
       !/new ASR\("/.test(source) && /new ASR\(undefined/.test(source));
     // 契约没变:这几个调用点必须还在(换了实现就得跟着改)
     check(`${file}:openMicroPhone / startRecord / openSocket / transform 调用点齐全`,

+ 88 - 0
harness/tools/verify-policy-library.mjs

@@ -182,6 +182,94 @@ console.log('\n=== 7. 部门筛选项:只列数据里实际存在的部门(
   check('选中的部门若不在新清单里 → 退回「全部部门」(否则会筛出空列表还没提示)',
     /if \(!next\.includes\(currentDeptFilter\.value\)\)/.test(source));
   check('部门匹配仍是字面相等(清单与数据取值同一口径)', /!== dept/.test(source));
+
+  // ② 2026-09-28 用户报「部门筛选出现了没有内容的项」:
+  //    我曾把**卡片**里出现过的部门也并进下拉,而卡片的 source 可能是**多部门串**
+  //    (实测库里就有 `"上海市商务委员会、…、上海市科学技术委员会\n市商务委等"` 这种值)→ 变成假选项。
+  check('**下拉只来自政策库**(不再并入卡片里的 source)',
+    !/fromCards/.test(source));
+  check('挡掉多部门串/换行这类脏值(looksLikeSingleDept 守卫)',
+    /const looksLikeSingleDept = \(value: string\) =>/.test(source) &&
+      /looksLikeSingleDept\(value\)/.test(source));
+
+  // 数据层面:库里若真出现这种脏值,先报出来(别只在组件里悄悄丢掉)
+  const dirty = departments.filter((d) => /[,,、\s]/.test(d) || d.length > 12);
+  check('库里的部门取值都是单一部门名(没有多部门串/换行)', dirty.length === 0, dirty.join(' | '));
+}
+
+console.log('\n=== 8. 政策库的来源:DMS 远端优先 + 本地兜底(用户 2026-09-24 要求)===');
+{
+  const html = readFileSync('index.html', 'utf8');
+  const env = readFileSync('.env.development', 'utf8');
+
+  check('远端用 VITE_POLICY_REMOTE_URL(不再走厂商 VITE_DOWNLOAD_URL)',
+    /var remoteURL = "%VITE_POLICY_REMOTE_URL%";/.test(html) && !/var downloadURL = "%VITE_DOWNLOAD_URL%"/.test(html));
+  check('远端地址指向 DMS 文件服务(/dms/static/…)',
+    /VITE_POLICY_REMOTE_URL="\/dms\/static\/file\/[^"]+\.json"/.test(env),
+    (env.match(/VITE_POLICY_REMOTE_URL=.*/) || ['(缺)'])[0]);
+  check('本地兜底仍是随包发布的那份', /var localFallbackURL = "\/merchant-agent\/policies_public\.v2\.json";/.test(html));
+  check('**顺序固定:先 dms 再 local**(loadPolicies 按数组顺序降级)',
+    /\{ url: remoteURL, label: "dms" \},\s*\{ url: localFallbackURL, label: "local" \},/.test(html));
+  check('「本地优先」开关已删除(VITE_POLICY_LOCAL_FIRST 不再出现在配置里)',
+    !/VITE_POLICY_LOCAL_FIRST\s*=/.test(env) && !/localFirst/.test(html));
+}
+
+// ⚠️ 先剥注释再查:源码里留着「[已移除] xxx」「[已删] xxx」这类**说明**(约定要保留),
+//    不剥的话断言会被自己的注释绊倒(第一次跑就是这么红的)
+const stripComments = (src) =>
+  String(src)
+    .replace(/<!--[\s\S]*?-->/g, ' ')
+    .replace(/\/\*[\s\S]*?\*\//g, ' ')
+    .replace(/(^|[^:])\/\/[^\n]*/g, '$1 ');
+
+/** `.env` 用的是 `#` 注释(我们会在里面留「[已删] xxx」这类说明),断言前要一并剥掉 */
+const stripEnv = (src) => stripComments(src).replace(/(^|\s)#[^\n]*/g, '$1');
+
+console.log('\n=== 9. 2026-09-28 删掉的两处:不许悄悄回来 ===');
+{
+  const match = stripComments(readFileSync('src/components/Chat/PolicyMatch.vue', 'utf8'));
+  const utils = stripComments(readFileSync('src/components/Chat/policy-match-utils.ts', 'utf8'));
+  const html = stripComments(readFileSync('index.html', 'utf8'));
+  const dev = stripEnv(readFileSync('.env.development', 'utf8'));
+  const prod = stripEnv(readFileSync('.env.production', 'utf8'));
+
+  // ① 政策列表「第三条腿」:前端自己拼 {VITE_MODEL_SERVER}/v1/policies
+  check('PolicyMatch 不再出现 VITE_MODEL_SERVER / v1/policies 调用',
+    !/VITE_MODEL_SERVER/.test(match) && !/buildPolicyListUrl\(/.test(match) && !/buildPolicyListSearchParams\(/.test(match));
+  check('policy-match-utils 不再导出那四个 helper',
+    !/buildPolicyListSearchParams|buildPolicyListUrl|getLocalPolicyListResponse|extractPolicyListResponse/.test(utils));
+  check('两份 env 都没有 VITE_MODEL_SERVER',
+    !/VITE_MODEL_SERVER/.test(dev) && !/VITE_MODEL_SERVER/.test(prod));
+
+  // ② index.html 里"写了没人读"的死枝
+  check('index.html 不再有 globalThis.modelServer / isKnowledgeApi / cardURL / cardInfo',
+    !/globalThis\.modelServer/.test(html) && !/globalThis\.isKnowledgeApi/.test(html) &&
+      !/\bcardURL\b/.test(html) && !/globalThis\.cardInfo/.test(html));
+  check('两份 env 都没有 VITE_USE_KNOWLEDGE_API',
+    !/VITE_USE_KNOWLEDGE_API/.test(dev) && !/VITE_USE_KNOWLEDGE_API/.test(prod));
+  check('VITE_API 仍然保留(企业登录接口在用)',
+    /VITE_API=/.test(dev) && /VITE_API=/.test(prod));
+}
+
+console.log('\n=== 10. 2026-09-28 再删的两条:百度统计 + 死配置 VITE_DOWNLOAD_URL ===');
+{
+  const html = stripComments(readFileSync('index.html', 'utf8'));
+  const dev = stripEnv(readFileSync('.env.development', 'utf8'));
+  const prod = stripEnv(readFileSync('.env.production', 'utf8'));
+
+  // 百度统计:数据进的是厂商账号(dm 指向 demo.heijingai.com),本项目从未用过它的数据
+  check('index.html 不再加载百度统计(hm.baidu / _hmt)',
+    !/hm\.baidu/.test(html) && !/_hmt/.test(html));
+  // 死配置:全仓库没有读者,生产侧 nginx 也就不必再转发 /api/download/
+  check('两份 env 都没有 VITE_DOWNLOAD_URL',
+    !/VITE_DOWNLOAD_URL\s*=/.test(dev) && !/VITE_DOWNLOAD_URL\s*=/.test(prod));
+  check('index.html 里没有 /api/download/ 依赖',
+    !/api\/download\//.test(html));
+  // 防误伤:政策库的"远端 + 本地兜底"两条腿必须都还在
+  check('政策库远端配置仍在(VITE_POLICY_REMOTE_URL 两份 env 都有)',
+    /VITE_POLICY_REMOTE_URL=/.test(dev) && /VITE_POLICY_REMOTE_URL=/.test(prod));
+  check('政策库本地兜底仍在(index.html 里的 localFallbackURL)',
+    /var localFallbackURL = "\/merchant-agent\/policies_public\.v2\.json";/.test(html));
 }
 
 console.log('\n通过 ' + pass + ' 项,失败 ' + fail + ' 项');

+ 120 - 0
harness/tools/verify-stream-filter-rules.mjs

@@ -0,0 +1,120 @@
+/**
+ * 验证切行器(`StreamXMLFilter`)的标签规则表在删掉 `<policy-list-updated>` 之后仍然正确。
+ *
+ * 验证什么:
+ *   2026-09-28 用户确认「老对话仅测试,后续会全部清空」→ 删掉 `<policy-list-updated>`
+ *   整条链(组件 + 标签规则 + 枚举成员)。这个脚本锁定两件事:
+ *     ① **新协议在用的标记一个都不能少**:`<scope>` / `<question-cards>` / `<ref_links>` /
+ *        `<image-scope>` / `<!-- POLICY_TABLE -->` 必须照常产出对应行类型(回归护栏);
+ *     ② **旧标记按预期处理**:`<policy-list-updated>` 不再产出任何行类型,
+ *        整串标记**当普通文本**留在正文里 —— 这是用户已确认接受的副作用,
+ *        写成断言是为了防止后人「顺手加回来」时无人察觉。
+ *   同时锁住仍**有意保留**的旧标记(`<form>` / `<link-url>` / `<file-docx>`)——
+ *   它们的渲染组件还在,旧会话历史里仍画得出来(见 docs/reference/legacy-content-markers.md)。
+ *
+ * 怎么跑(在项目根目录,先打包):
+ *   npx esbuild harness/tools/_entry-stream-filter.ts --bundle --format=esm \
+ *     --outfile=harness/tools/_stream-filter.mjs
+ *   node harness/tools/verify-stream-filter-rules.mjs
+ */
+
+import { readFileSync, existsSync } from 'node:fs';
+
+const { StreamXMLFilter, RecordRowTypes } = await import('./_stream-filter.mjs');
+
+let passed = 0;
+let failed = 0;
+
+const ok = (label, condition, extra = '') => {
+  if (condition) {
+    passed += 1;
+    console.log(`  ok   ${label}${extra ? ` —— ${extra}` : ''}`);
+  } else {
+    failed += 1;
+    console.log(`  FAIL ${label}${extra ? ` —— ${extra}` : ''}`);
+  }
+};
+
+/** 走一遍真实切行器,返回产出行的 { type, content } 列表 */
+const parse = (raw) => new StreamXMLFilter().processChunk(raw);
+
+console.log('\n【1】新协议在用的标记(一个都不能少)');
+
+const alive = [
+  ['<scope title="正在检索">\n</scope>\n', RecordRowTypes.Scope],
+  ['<question-cards>{"questions":[]}</question-cards>', RecordRowTypes.QuestionCards],
+  ['<ref_links>[{"title":"a"}]</ref_links>', RecordRowTypes.RefLinks],
+  ['<image-scope>\n/assets/x.png\n</image-scope>', RecordRowTypes.ImageContent],
+  ['<!-- POLICY_TABLE {"data":[]} POLICY_TABLE -->', RecordRowTypes.PolicyTable],
+];
+for (const [raw, expected] of alive) {
+  const rows = parse(raw);
+  ok(
+    `${expected} 行仍产出`,
+    rows.some((r) => r.type === expected),
+    `实际类型:${[...new Set(rows.map((r) => r.type))].join(', ')}`
+  );
+}
+
+console.log('\n【2】已删的 <policy-list-updated>:规则没了,且不残留行类型');
+{
+  const legacy = '<policy-list-updated>{"link":"https://example.com/p","payload":{"a":"1"}}</policy-list-updated>';
+  const rows = parse(legacy);
+  const types = [...new Set(rows.map((r) => r.type))];
+  ok('不再产出 policy-list-updated 行', !types.includes('policy-list-updated'), `实际类型:${types.join(', ')}`);
+  ok(
+    '枚举里也没有这个成员了',
+    !Object.prototype.hasOwnProperty.call(RecordRowTypes, 'PolicyListUpdated')
+  );
+  const text = rows
+    .filter((r) => r.type === RecordRowTypes.Text)
+    .map((r) => r.content)
+    .join('');
+  ok(
+    '整串标记当普通文本留在正文里(用户已确认接受)',
+    text.includes('<policy-list-updated>') && text.includes('"link"'),
+    `正文长度 ${text.length}`
+  );
+  ok('正文里没有丢字符(除标记外无截断)', text.trim() === legacy.trim());
+}
+
+console.log('\n【3】有意保留的旧标记(旧会话历史仍要画得出来)');
+
+const kept = [
+  ['<form id="a">{"x":1}</form>', RecordRowTypes.Form],
+  ['<link-url>查看详情</link-url>', RecordRowTypes.LinkURL],
+  ['<file-docx>https://example.com/a.docx</file-docx>', RecordRowTypes.OfficeURL],
+];
+for (const [raw, expected] of kept) {
+  const rows = parse(raw);
+  ok(
+    `${expected} 行仍产出`,
+    rows.some((r) => r.type === expected),
+    `实际类型:${[...new Set(rows.map((r) => r.type))].join(', ')}`
+  );
+}
+
+console.log('\n【4】源码级:组件与引用确已消失');
+{
+  const paths = [
+    'src/components/Chat/PolicyListUpdatedMatch.vue',
+    'src/components/Chat/BusinessRecord.vue',
+    'src/components/Chat/PolicyMatch.vue',
+  ];
+  ok('组件文件已删除', !existsSync(paths[0]));
+  ok(
+    'BusinessRecord 不再引用它',
+    !readFileSync(paths[1], 'utf8').includes('PolicyListUpdated')
+  );
+  const policyMatch = readFileSync(paths[2], 'utf8');
+  ok(
+    'PolicyMatch 只在注释里提到「已删」',
+    policyMatch
+      .split(/\r?\n/)
+      .filter((line) => !line.trim().startsWith('*') && !line.trim().startsWith('//'))
+      .every((line) => !line.includes('PolicyListUpdated'))
+  );
+}
+
+console.log(`\n===== 通过 ${passed} 项,失败 ${failed} 项 =====`);
+process.exit(failed === 0 ? 0 : 1);

+ 96 - 0
harness/tools/verify-vue-script-html-comments.mjs

@@ -0,0 +1,96 @@
+/**
+ * 验证:`.vue` 的 `<script>` 里**没有裸的 HTML 注释起始符**。
+ *
+ * 为什么要有这条(2026-09-28 实际踩过):
+ *   Vite 的依赖预扫描把 **`.vue` 也当 HTML**(`htmlTypesRE = /\.(html|vue|svelte|astro|imba)$/`),
+ *   扫描前会先做一次 `raw.replace(/<!--.*?-->/gs, "<!---->")`(带 `s`,**可跨行**)。
+ *   于是 script 里一个**不成对的** `<!--`,会和**后面任意一个 `-->`**(哪怕隔着几十行)
+ *   配成"HTML 注释"被整段吃掉 —— 行数因此变少、后面的代码可能并进上一行的 `//` 注释,
+ *   esbuild 随即报 `Unexpected "."`,整个依赖扫描失败。
+ *
+ *   症状特点(所以必须单独有个脚本盯着):
+ *     - **`npm run build` 抓不到**:构建走 vue 插件路径,不做这步清扫
+ *     - **平时启动也看不到**:只在依赖缓存失效、真正重新扫描时炸一次(改 package.json/lock、
+ *       `npx vite --force`、换分支等)
+ *     - **`<template>` 里的 `<!-- … -->` 是合法的**,本脚本只查 `<script>` 区块,
+ *       且只报"同行没有配对 `-->`"的那些(同行配对的会被替换成 `<!---->`,无害)
+ *
+ * 怎么跑(纯 node,不需要打包):
+ *   node harness/tools/verify-vue-script-html-comments.mjs
+ * 退出码:0 = 干净;1 = 有裸起始符(会打印 file:line)
+ */
+
+import { readdirSync, readFileSync, statSync } from 'node:fs';
+import { join } from 'node:path';
+
+const OPEN = '<!-' + '-';
+const CLOSE = '--' + '>';
+const SCRIPT_OPEN_RE = /<script\b[^>]*>/g;
+const SCRIPT_CLOSE = '</' + 'script>';
+
+const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'harness']);
+
+/** 收集 src 下所有 .vue */
+const collectVueFiles = (dir) => {
+  const out = [];
+  for (const entry of readdirSync(dir)) {
+    if (SKIP_DIRS.has(entry)) continue;
+    const full = join(dir, entry);
+    if (statSync(full).isDirectory()) out.push(...collectVueFiles(full));
+    else if (entry.endsWith('.vue')) out.push(full);
+  }
+  return out;
+};
+
+const files = collectVueFiles('src');
+const offenders = [];
+let pairedButHarmless = 0;
+
+for (const file of files) {
+  const text = readFileSync(file, 'utf8');
+
+  // 1) 找出所有 <script> 区块的字符区间
+  const ranges = [];
+  SCRIPT_OPEN_RE.lastIndex = 0;
+  let m;
+  while ((m = SCRIPT_OPEN_RE.exec(text))) {
+    const start = m.index + m[0].length;
+    const end = text.indexOf(SCRIPT_CLOSE, start);
+    ranges.push([start, end === -1 ? text.length : end]);
+  }
+  if (!ranges.length) continue;
+
+  // 2) 在 script 区间内逐行找裸起始符
+  let offset = 0;
+  const lines = text.split('\n');
+  for (let i = 0; i < lines.length; i += 1) {
+    const line = lines[i];
+    let idx = line.indexOf(OPEN);
+    while (idx !== -1) {
+      const abs = offset + idx;
+      const inScript = ranges.some(([a, b]) => abs >= a && abs < b);
+      if (inScript) {
+        if (line.indexOf(CLOSE, idx + OPEN.length) === -1) {
+          offenders.push({ file, line: i + 1, text: line.trim().slice(0, 100) });
+        } else {
+          pairedButHarmless += 1;
+        }
+      }
+      idx = line.indexOf(OPEN, idx + OPEN.length);
+    }
+    offset += line.length + 1;
+  }
+}
+
+console.log(`扫描 ${files.length} 个 .vue 文件的 <script> 区块`);
+console.log(`  同行已配对的起始符(会被替换成 <!---->,无害):${pairedButHarmless} 处`);
+
+if (offenders.length) {
+  console.log(`\n❌ 发现 ${offenders.length} 处裸起始符 —— 会让 Vite 的依赖扫描吃掉后面的代码:`);
+  for (const o of offenders) console.log(`   ${o.file}:${o.line}  ${o.text}`);
+  console.log('\n修法:把字面量挪到 .ts(参考 src/utils/stream-xml-filter.ts 的 POLICY_TABLE_START),');
+  console.log('或改写这段文字,别让它在 .vue 的 script 里出现。');
+  process.exit(1);
+}
+
+console.log('\n✅ 通过:没有裸的 HTML 注释起始符');

+ 29 - 110
index.html

@@ -13,8 +13,10 @@
     <title></title>
     <script>
       let globalThis = window;
-      globalThis.modelServer = "%VITE_API%" || "";
-      globalThis.isKnowledgeApi = "%VITE_USE_KNOWLEDGE_API%" === "true";
+      // [已移除] globalThis.modelServer / globalThis.isKnowledgeApi 及其消费方
+      // (`cardURL`、`if (isKnowledgeApi) { 硬编码 cardInfo }`)——都是"写了没人读"的死代码,
+      // 2026-09-28 按用户要求删除;`VITE_API` 本身保留(企业登录接口在用)
+
       // 新版聊天协议后端基础地址(POST /api/chat,SSE)
       globalThis.VITE_CHAT_API = "%VITE_CHAT_API%" || "";
       // DMS 数据管理服务(会话/问答记录/反馈)。token 由 vite 代理注入,前端不持有
@@ -28,7 +30,6 @@
         return globalThis.__ZHAOSHANG_LLM_MODELS.indexOf(model || globalThis.llmModel || "") > -1;
       };
 
-      var cardURL = globalThis.modelServer;
       function getQueryObj(querykey) {
         if (!querykey || typeof querykey !== "string") {
           console.warn("Invalid querykey provided to getQueryObj");
@@ -128,7 +129,10 @@
           }
           // 预下载完整政策列表到内存中
           (function () {
-            var downloadURL = "%VITE_DOWNLOAD_URL%" + "merchant-agent/policies_public.v2.json";
+            // 远端 = **DMS 文件服务**里的那份(用户 2026-09-24:把 policies_public.v2.json 传到 DMS,
+            // 那个路径就是新的"远端政策接口")。同源路径:dev 走 vite 代理、生产走 nginx。
+            var remoteURL = "%VITE_POLICY_REMOTE_URL%";
+            // 兜底 = 随包发布的本地副本(`public/merchant-agent/…`)
             var localFallbackURL = "/merchant-agent/policies_public.v2.json";
 
             function applyPolicies(data, source) {
@@ -156,19 +160,14 @@
                 });
             }
 
-            // 开发环境优先读本地副本:远端那份 327 条全都没有 data.市级政策id,
-            // 政策详情面板的「政策名称」会因此拼不出地址、点不开;本地这份有 261/262。
-            // 由 VITE_POLICY_LOCAL_FIRST 控制(仅 .env.development 为 true)
-            var localFirst = "%VITE_POLICY_LOCAL_FIRST%" === "true";
-            var sources = localFirst
-              ? [
-                  { url: localFallbackURL, label: "local" },
-                  { url: downloadURL, label: "remote" },
-                ]
-              : [
-                  { url: downloadURL, label: "remote" },
-                  { url: localFallbackURL, label: "local" },
-                ];
+            // 顺序固定:**先远端(DMS),取不到再用本地兜底**(用户 2026-09-24 明确要求)。
+            // 原先那个「开发环境本地优先」的开关(VITE_POLICY_LOCAL_FIRST)随旧远端一起删掉了 ——
+            // 它当年是为了绕开"厂商远端那份没有市级政策id"的问题,现在远端换成我们自己传的 DMS 那份,
+            // 两边内容一致,不再需要这个开关。
+            var sources = [
+              { url: remoteURL, label: "dms" },
+              { url: localFallbackURL, label: "local" },
+            ];
 
             loadPolicies(sources[0]).catch(function (err) {
               console.warn(
@@ -186,90 +185,15 @@
         document.title = globalThis.orgTitle;
         updateFavicon();
         initMessageChannel();
-        if (globalThis.isKnowledgeApi) {
-          document.title = globalThis.orgTitle;
-          globalThis.cardInfo = {
-            id: 100161,
-            human_gender: 1,
-            card_id: 297,
-            card_name: "政策解读",
-            name: "政策解读",
-            background: "",
-            url: "https://timg.metamaker.cn/mms_threejs_res/1c792f578384242a92f1fb2e1ad8f70d",
-            human_avatar:
-              "//img.metamaker.cn/resources/52116488a01573be4d6754d7de9090464a54ccc3.png",
-            title: "1",
-            duty: "1",
-            org: "1",
-            mobile: "1",
-            wx_id: "1",
-            logo: "",
-            tts_args: {
-              speed: 30,
-              volume: 100,
-              voice_name: "S_x8pOUDlm1",
-            },
-            share_conf: {
-              qrcode: "https://img.metamaker.cn/human_card/b380126e1c138a4c1373f1b6bc8e1a4b.jpg",
-              share_img: "https://img.metamaker.cn/human_card/3ce6f8c86bd3525d113dde450aeadbd7.jpg",
-              share_title: "政策解读",
-              card_img: "https://img.metamaker.cn/human_card/91c4f4e2ccf393168f01761e54802351.jpg",
-            },
-            func_sets: {
-              opt_area: [
-                {
-                  name: "拨打电话",
-                  type: 1,
-                  alias: "",
-                  check: false,
-                },
-                {
-                  name: "复制微信",
-                  type: 2,
-                  alias: "",
-                  check: false,
-                },
-                {
-                  name: "查看文档",
-                  type: 3,
-                  alias: "",
-                  check: false,
-                },
-              ],
-              question_guide: {
-                guess_ask: {
-                  check: false,
-                  value: "",
-                },
-                common_problem: {
-                  check: false,
-                  value: [],
-                },
-              },
-              hide_name_brand: 0,
-              human_interaction: {
-                welcome: {
-                  check: true,
-                  value: globalThis.greeting,
-                },
-              },
-            },
-            dialog_sets: {
-              strict: false,
-              only_kl: false,
-              unable_answer: {
-                text: "很抱歉,我不太理解您的意思,请问您想了解的是什么问题呢?",
-                check: false,
-                amount: 1,
-              },
-              belief_threshold: 0.88,
-            },
-          };
-          globalThis.hasSequence = true;
-          document.dispatchEvent(new CustomEvent("dataLoaded"));
-          document.dispatchEvent(new CustomEvent("sequenceLoaded"));
-          addVersionInfo();
-        }
+        // ⚠️ 这三行+addVersionInfo 是**加载遮罩的收尾信号**,删不得:
+        // BusinessAssistant.vue 的 initSequence() 靠 `globalThis.hasSequence`(或等 `sequenceLoaded` 事件)
+        // 来隐藏 #loading-cover —— 少了它页面会永远卡在"正在初始化,请稍候..."。
+        // (原来它们挂在 `if (globalThis.isKnowledgeApi) { … }` 里、在硬编码 cardInfo 之后;
+        //   2026-09-28 删那段死枝时**误删过一次**,现已移到这里无条件执行,语义与原来一致。)
+        globalThis.hasSequence = true;
+        document.dispatchEvent(new CustomEvent("dataLoaded"));
+        document.dispatchEvent(new CustomEvent("sequenceLoaded"));
+        addVersionInfo();
       })();
 
       (function () {
@@ -326,15 +250,10 @@
         });
       })();
     </script>
-    <script>
-      var _hmt = _hmt || [];
-      (function () {
-        var hm = document.createElement("script");
-        hm.src = "https://hm.baidu.com/hm.js?d6307cb011c91628ce6be043f40d510a";
-        var s = document.getElementsByTagName("script")[0];
-        s.parentNode.insertBefore(hm, s);
-      })();
-    </script>
+    <!-- [已删] 百度统计(hm.baidu.com/hm.js)——2026-09-28 用户确认删除:
+         数据进的是厂商账号(其站点配置 dm 指向 demo.heijingai.com),本项目从未用过它的数据,
+         且它是全站唯一往第三方发请求、且在青浦域名上写 cookie 的东西。理由与排查见
+         harness/docs/reference/current-api-call-sites.md 的 #14 -->
     <script type="module" src="/src/main.ts"></script>
 
     <style>

+ 0 - 112
package-lock.json

@@ -29,7 +29,6 @@
         "@vitejs/plugin-vue": "^4.1.0",
         "jest": "^29.0.0",
         "jest-environment-jsdom": "^29.0.0",
-        "rollup-plugin-visualizer": "^5.9.2",
         "ts-jest": "^29.2.6",
         "typescript": "^4.9.3",
         "unplugin-vue-components": "^0.25.1",
@@ -3528,16 +3527,6 @@
         "node": ">=0.10.0"
       }
     },
-    "node_modules/define-lazy-prop": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmmirror.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
-      "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
     "node_modules/delayed-stream": {
       "version": "1.0.0",
       "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -4478,22 +4467,6 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
-    "node_modules/is-docker": {
-      "version": "2.2.1",
-      "resolved": "https://registry.npmmirror.com/is-docker/-/is-docker-2.2.1.tgz",
-      "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
-      "dev": true,
-      "license": "MIT",
-      "bin": {
-        "is-docker": "cli.js"
-      },
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/is-extglob": {
       "version": "2.1.1",
       "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -4588,19 +4561,6 @@
         "url": "https://github.com/sponsors/mesqueeb"
       }
     },
-    "node_modules/is-wsl": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-2.2.0.tgz",
-      "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "is-docker": "^2.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
     "node_modules/isexe": {
       "version": "2.0.0",
       "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
@@ -5915,24 +5875,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/open": {
-      "version": "8.4.2",
-      "resolved": "https://registry.npmmirror.com/open/-/open-8.4.2.tgz",
-      "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "define-lazy-prop": "^2.0.0",
-        "is-docker": "^2.1.1",
-        "is-wsl": "^2.2.0"
-      },
-      "engines": {
-        "node": ">=12"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/p-limit": {
       "version": "3.1.0",
       "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz",
@@ -6478,60 +6420,6 @@
         "fsevents": "~2.3.2"
       }
     },
-    "node_modules/rollup-plugin-visualizer": {
-      "version": "5.14.0",
-      "resolved": "https://registry.npmmirror.com/rollup-plugin-visualizer/-/rollup-plugin-visualizer-5.14.0.tgz",
-      "integrity": "sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "open": "^8.4.0",
-        "picomatch": "^4.0.2",
-        "source-map": "^0.7.4",
-        "yargs": "^17.5.1"
-      },
-      "bin": {
-        "rollup-plugin-visualizer": "dist/bin/cli.js"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "peerDependencies": {
-        "rolldown": "1.x",
-        "rollup": "2.x || 3.x || 4.x"
-      },
-      "peerDependenciesMeta": {
-        "rolldown": {
-          "optional": true
-        },
-        "rollup": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/rollup-plugin-visualizer/node_modules/picomatch": {
-      "version": "4.0.7",
-      "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz",
-      "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=12"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/jonschlinkert"
-      }
-    },
-    "node_modules/rollup-plugin-visualizer/node_modules/source-map": {
-      "version": "0.7.6",
-      "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.7.6.tgz",
-      "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "engines": {
-        "node": ">= 12"
-      }
-    },
     "node_modules/run-parallel": {
       "version": "1.2.0",
       "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz",

+ 0 - 1
package.json

@@ -36,7 +36,6 @@
     "@vitejs/plugin-vue": "^4.1.0",
     "jest": "^29.0.0",
     "jest-environment-jsdom": "^29.0.0",
-    "rollup-plugin-visualizer": "^5.9.2",
     "ts-jest": "^29.2.6",
     "typescript": "^4.9.3",
     "unplugin-vue-components": "^0.25.1",

+ 2 - 1
src/asr/nls.ts

@@ -9,7 +9,8 @@
  *
  * 与旧实现的实质差别:
  *   - 凭据:**前端不持有任何阿里云凭据**,token + appkey 由服务端端点下发
- *     (`src/network/api/nls-token.ts` → dev 是 vite 的 `/nls-token/`)
+ *     (`src/network/api/nls-token.ts` → 后端网关 `getALYToken`;dev 走 vite 代理 `/aly-api/`,
+ *      生产由 nginx 转发同一路径,见技术债 #10)
  *   - 重采样按**实际采样率**算(旧实现写死 44100,见 asr-realtime.md §9.1)
  *   - 定稿只认 `SentenceEnd`(中间结果会重叠,不能拼)
  *   - 采样位深/声道:仍然 16k / 16bit / 单声道

+ 14 - 19
src/components/Chat/BusinessRecord.vue

@@ -145,12 +145,6 @@
                   :recordMode="true"
                   :sessionId="policyMatchSessionId"
                 />
-                <PolicyListUpdatedMatch
-                  v-if="row.type === RecordRowTypes.PolicyListUpdated"
-                  :content="row.content"
-                  :recordMode="true"
-                  :sessionId="policyMatchSessionId"
-                />
                 <QuestionCard
                   v-if="row.type === RecordRowTypes.QuestionCards"
                   :content="row.content"
@@ -390,12 +384,13 @@ import ImageContent from "./ImageContent.vue";
 import OfficeURLContent from "./OfficeURLContent.vue";
 import ScopeContent from "./ScopeContent.vue";
 import PolicyMatch from "./PolicyMatch.vue";
-import PolicyListUpdatedMatch from "./PolicyListUpdatedMatch.vue";
 import QuestionCard from "./QuestionCard.vue";
 import {
   parseRefLinksContent,
   RecordRowTypes,
   StreamXMLFilter,
+  POLICY_TABLE_START,
+  POLICY_TABLE_END,
   type RefLinkItem,
 } from "@/utils/stream-xml-filter";
 import { jsonrepair } from "jsonrepair";
@@ -606,8 +601,11 @@ const getRefLinkItems = (content: string) => parseRefLinksContent(content);
 //    用户 2026-09-22 反馈「复制出来的内容还带着 md 符号」。转换见 markdown-plain-text.ts。
 //
 // 注意不能用 getStreamPlainText():它对整段内容做一次性解析,而过滤器在遇到
-// <!-- POLICY_TABLE 时会把同批 buffer 里位于它之前的内容直接当纯文本输出,
-// 导致 scope 标记被当成正文保留下来。这里直接剥离标记,结果与内容分片方式无关。
+// POLICY_TABLE 那个 HTML 注释式的块起点时,会把同批 buffer 里位于它之前的内容
+// 直接当纯文本输出,导致 scope 标记被当成正文保留下来。
+// 这里直接剥离标记,结果与内容分片方式无关。
+// (⚠️ 本文件里**不要**写出裸的 HTML 注释起始符:Vite 的依赖预扫描会把 .vue 当 HTML,
+//   把「起始符…后面的某个结束符」整段吃掉 —— 2026-09-28 冷扫描就是被这里搞挂的)
 const copyableContent = computed(() => {
   const raw = String(record.value.content || props.content || "");
   const withoutMarkers = raw
@@ -860,10 +858,7 @@ const handleScopeAutoExit = (scopeRow: DisplayRecordRow | null) => {
   beginScopeExit(scopeRow);
 };
 const hasPolicyTableRow = computed(() =>
-  displayRecordRows.value.some(
-    (row) =>
-      row.type === RecordRowTypes.PolicyTable || row.type === RecordRowTypes.PolicyListUpdated
-  )
+  displayRecordRows.value.some((row) => row.type === RecordRowTypes.PolicyTable)
 );
 const hasHistoricalNonScopeContent = computed(() => {
   if (!record.value.history) {
@@ -1138,12 +1133,12 @@ const processRecordSections = (input: string) => {
     return;
   }
 
-  const policyStartToken = "<!-- POLICY_TABLE";
-  const policyEndToken = "POLICY_TABLE -->";
+  // 起止标记取自 `@/utils/stream-xml-filter`(**不要在 .vue 里再写字面量**:
+  // 裸的 HTML 注释起始符会被 Vite 的依赖预扫描当注释、吃掉后面若干行,见该文件顶部说明)
   let rest = input;
 
   while (rest.length > 0) {
-    const policyStart = rest.indexOf(policyStartToken);
+    const policyStart = rest.indexOf(POLICY_TABLE_START);
     if (policyStart === -1) {
       processTextSections(rest);
       return;
@@ -1154,14 +1149,14 @@ const processRecordSections = (input: string) => {
       processTextSections(normalText);
     }
 
-    const policyEnd = rest.indexOf(policyEndToken, policyStart);
+    const policyEnd = rest.indexOf(POLICY_TABLE_END, policyStart);
     if (policyEnd === -1) {
       recordParser(rest.slice(policyStart));
       return;
     }
 
-    recordParser(rest.slice(policyStart, policyEnd + policyEndToken.length));
-    rest = rest.slice(policyEnd + policyEndToken.length);
+    recordParser(rest.slice(policyStart, policyEnd + POLICY_TABLE_END.length));
+    rest = rest.slice(policyEnd + POLICY_TABLE_END.length);
   }
 };
 

+ 0 - 123
src/components/Chat/PolicyListUpdatedMatch.vue

@@ -1,123 +0,0 @@
-<template>
-  <div v-if="loading" class="policy-list-loading">
-    <DotsAnim />
-    <span class="loading-text">正在加载政策数据...</span>
-  </div>
-  <PolicyMatch
-    v-else-if="policyData && policyData.length > 0"
-    :policyData="policyData"
-    :recordMode="recordMode"
-    :sessionId="resolvedSessionId"
-  />
-  <div v-else-if="!loading && error" class="policy-list-error">
-    获取政策数据失败
-  </div>
-</template>
-
-<script setup lang="ts">
-import { ref, onMounted, watch, computed } from 'vue';
-import PolicyMatch from './PolicyMatch.vue';
-import DotsAnim from '../Common/DotsAnim.vue';
-import { jsonrepair } from 'jsonrepair';
-
-const props = defineProps<{
-  content: string;
-  recordMode?: boolean;
-  sessionId?: string;
-}>();
-
-const loading = ref(false);
-const error = ref(false);
-const policyData = ref<any[] | null>(null);
-const payloadSessionId = ref<string | undefined>(undefined);
-
-const resolvedSessionId = computed(() => payloadSessionId.value || props.sessionId);
-
-const fetchData = async () => {
-  try {
-    loading.value = true;
-    error.value = false;
-    let jsonStr = props.content;
-    const match = jsonStr.match(/<policy-list-updated>([\s\S]*?)<\/policy-list-updated>/);
-    if (match) {
-      jsonStr = match[1];
-    }
-    const jsonObj = JSON.parse(jsonrepair(jsonStr));
-    let link = jsonObj.link;
-    const payload = jsonObj.payload;
-    if (!link && payload && payload.link) {
-      link = payload.link;
-    }
-    
-    if (payload && payload.session_id) {
-      payloadSessionId.value = payload.session_id;
-    }
-
-    if (link) {
-      let cleanLink = link.replace(/`/g, '').trim();
-      
-      try {
-        const urlObj = new URL(cleanLink, window.location.origin);
-        if (payload) {
-          Object.keys(payload).forEach(key => {
-            if (key !== 'link' && payload[key] !== undefined && payload[key] !== null) {
-              urlObj.searchParams.set(key, String(payload[key]));
-            }
-          });
-        }
-        cleanLink = urlObj.toString();
-      } catch (e) {
-        console.warn('Invalid URL:', cleanLink, e);
-      }
-
-      const res = await fetch(cleanLink);
-      if (!res.ok) {
-        throw new Error('Network response was not ok');
-      }
-      const data = await res.json();
-      if (data && data.data) {
-        policyData.value = data.data;
-        // Update the cached public policies with the fetched data
-        if (Array.isArray(data.data)) {
-          globalThis.policiesPublic = data.data;
-        }
-      } else {
-        error.value = true;
-      }
-    } else {
-      error.value = true;
-    }
-  } catch (e) {
-    console.error('Fetch policy-list-updated error:', e);
-    error.value = true;
-  } finally {
-    loading.value = false;
-  }
-};
-
-onMounted(() => {
-  fetchData();
-});
-
-watch(() => props.content, () => {
-  fetchData();
-});
-</script>
-
-<style scoped>
-.policy-list-loading, .policy-list-error {
-  padding: 16px;
-  text-align: left;
-  color: #666;
-  display: flex;
-  align-items: center;
-  gap: 8px;
-}
-.loading-text {
-  font-size: 14px;
-  color: #999;
-}
-.policy-list-error {
-  color: #ff4d4f;
-}
-</style>

+ 40 - 66
src/components/Chat/PolicyMatch.vue

@@ -470,10 +470,6 @@ import {
   pinMatchedFirst,
 } from "./policy-library-utils";
 import {
-  buildPolicyListSearchParams,
-  buildPolicyListUrl,
-  extractPolicyListResponse,
-  getLocalPolicyListResponse,
   resolvePolicyDetailIndex,
   buildPolicyCardDedupKey,
   dedupePolicyItems,
@@ -531,7 +527,8 @@ const emit = defineEmits<{
 const policies = ref<PolicyItem[]>([]);
 const policyListItems = ref<PolicyItem[]>([]);
 const policyListTotal = ref(0);
-const policyListLoading = ref(false);
+// [已移除] policyListLoading / policyListRequestId —— 它们是「第三条腿」拉远端列表时的加载态与
+// 竞态守卫;那条链路 2026-09-28 删除后就没有写入点了(模板里也不再读)
 const panelVisible = ref(false);
 const policyModalId = Symbol("policy-viewer");
 const modalMutex = inject(MODAL_MUTEX_KEY, null);
@@ -571,13 +568,26 @@ const showFilterMenu = ref(false);
 const deptOptions = ref<string[]>(["全部部门"]);
 
 const collectDeptOptions = (): string[] => {
-  const clean = (values: unknown[]) =>
-    values.map((value) => String(value ?? "").trim()).filter(Boolean);
-  const fromLibrary = clean(getLibraryPolicies().map((item) => item?.["政策所属部门"]));
-  const fromCards = clean(policies.value.map((item) => item?.source));
-  const unique = [...new Set([...fromLibrary, ...fromCards])].sort((a, b) =>
-    a.localeCompare(b, "zh-CN")
-  );
+  /**
+   * 只取**政策库**里 `政策所属部门` 的取值。
+   *
+   * ⚠️ 曾经还把"本会话卡片里出现过的部门"并进来当兜底 —— 那是个 bug(用户 2026-09-28 报
+   * 「部门筛选出现了没有内容的项」):卡片的 `source` 可能是**多部门串**,实测库里就有
+   * `"上海市商务委员会、上海市发展和改革委员会、…\n市商务委等"` 这种值,
+   * 并进来会变成一个又长又筛不出东西的选项。
+   *
+   * 另外顺手挡掉这类脏值(含顿号/逗号/换行、或过长):宁可少一个选项,也不要列出没内容的假部门。
+   */
+  const looksLikeSingleDept = (value: string) => value.length <= 12 && !/[,,、\s]/.test(value);
+
+  const unique = [
+    ...new Set(
+      getLibraryPolicies()
+        .map((item) => String(item?.["政策所属部门"] ?? "").trim())
+        .filter((value) => value && looksLikeSingleDept(value))
+    ),
+  ].sort((a, b) => a.localeCompare(b, "zh-CN"));
+
   return ["全部部门", ...unique];
 };
 
@@ -1051,68 +1061,32 @@ const syncPolicies = () => {
   }
 };
 
-let policyListRequestId = 0;
-
+/**
+ * 刷新「政策事项列表」。
+ *
+ * 数据只有两个来源(2026-09-28 起):
+ *   ① 惠企政策库(`policies_public.v2.json`,远端 DMS / 本地副本)—— 主来源
+ *   ② 本会话卡片 —— 库还没加载完时的兜底,避免列表空白
+ *
+ * ⚠️ 原来还有**第三条腿**:前端自己拼 `{VITE_MODEL_SERVER}/v1/policies` 去拉一份列表
+ * (配 `buildPolicyListSearchParams` / `buildPolicyListUrl` / `extractPolicyListResponse` /
+ * `getLocalPolicyListResponse` 四个 helper)。它需要「库为空 **且** 卡片也为空」才会走到,
+ * 实测从未触发;用户 2026-09-28 要求「不再依赖原来的政策相关接口」→ **整条删掉**
+ * (连带两份 env 里的 `VITE_MODEL_SERVER`)。
+ * 注意:原来还有一条**后端驱动**的路径 —— 后端在正文里下发 `<policy-list-updated>{link,payload}`,
+ * 由另一个组件(`PolicyListUpdatedMatch.vue`)照着 link 去取(地址同样是上面那条已删接口)。
+ * 2026-09-28 用户确认「旧对话仅测试、后续会全部清空」→ 该组件与过滤器的标签规则一并删除,
+ * 接口清单 #11 归零。现在没有「后端给地址、前端再去取」这种二次取数了。
+ */
 const fetchPolicyList = async () => {
   if (!props.recordMode || !panelVisible.value || panelMode.value !== "list") return;
 
-  // 政策事项列表统一来自惠企政策库(policies_public.v2.json)
   if (getLibraryPolicies().length) {
     buildLibraryPolicyList();
     return;
   }
 
-  // 兜底:库还没加载完时,先列出本轮匹配到的卡片,避免列表空白
-  if (policies.value.length) {
-    buildRecordPolicyList();
-    return;
-  }
-
-  const requestId = ++policyListRequestId;
-  const sessionId = props.sessionId || globalThis.currentSession || "";
-  const query = {
-    page: currentPage.value,
-    pageSize: itemsPerPage,
-    keyword: searchKeyword.value,
-    department: currentDeptFilter.value,
-    filterOption: currentFilterOption.value,
-    sessionId,
-  };
-
-  policyListLoading.value = true;
-
-  try {
-    const modelServer = (import.meta.env.VITE_MODEL_SERVER || globalThis.modelServer) as string | undefined;
-
-    if (modelServer) {
-      const params = buildPolicyListSearchParams(query);
-      const url = buildPolicyListUrl(modelServer, params);
-      const response = await fetch(url);
-      if (!response.ok) throw new Error(`HTTP ${response.status}`);
-      const json = await response.json();
-      if (requestId !== policyListRequestId) return;
-      const { items, total } = extractPolicyListResponse<RawPolicyItem>(json);
-      policyListItems.value = items.map(normalizePolicy);
-      policyListTotal.value = total;
-    } else {
-      if (requestId !== policyListRequestId) return;
-      const { items, total } = getLocalPolicyListResponse<RawPolicyItem>(
-        globalThis.policiesPublic,
-        query
-      );
-      policyListItems.value = items.map(normalizePolicy);
-      policyListTotal.value = total;
-    }
-  } catch (error) {
-    console.error("Failed to load public policy list:", error);
-    if (requestId !== policyListRequestId) return;
-    policyListItems.value = [];
-    policyListTotal.value = 0;
-  } finally {
-    if (requestId === policyListRequestId) {
-      policyListLoading.value = false;
-    }
-  }
+  buildRecordPolicyList();
 };
 
 const panelData = computed(() => {

+ 0 - 136
src/components/Chat/policy-match-utils.ts

@@ -1,141 +1,5 @@
 export type PolicyListFilterOption = "所有分类" | "只看免申即享" | "非免申即享";
 
-export type PolicyListQuery = {
-  page: number;
-  pageSize: number;
-  keyword: string;
-  department: string;
-  filterOption: PolicyListFilterOption;
-  sessionId?: string;
-};
-
-export type PolicyListResponse<T> = {
-  items: T[];
-  total: number;
-};
-
-const getAutoGrantedParam = (filterOption: PolicyListFilterOption) => {
-  if (filterOption === "只看免申即享") return "true";
-  if (filterOption === "非免申即享") return "false";
-  return "";
-};
-
-export const buildPolicyListSearchParams = ({
-  page,
-  pageSize,
-  keyword,
-  department,
-  filterOption,
-  sessionId,
-}: PolicyListQuery) => {
-  const params = new URLSearchParams();
-  const normalizedKeyword = keyword.trim();
-  const normalizedDepartment = department === "全部部门" ? "" : department;
-  const autoGranted = getAutoGrantedParam(filterOption);
-  const normalizedSessionId = sessionId?.trim();
-
-  params.set("page", String(page));
-  params.set("page_size", String(pageSize));
-
-  if (normalizedKeyword) {
-    params.set("keyword", normalizedKeyword);
-  }
-
-  if (normalizedDepartment) {
-    params.set("department", normalizedDepartment);
-  }
-
-  if (autoGranted) {
-    params.set("auto_granted", autoGranted);
-  }
-
-  if (normalizedSessionId) {
-    params.set("session_id", normalizedSessionId);
-  }
-
-  return params;
-};
-
-export const buildPolicyListUrl = (modelServer: string | undefined, params: URLSearchParams) => {
-  const baseUrl = (modelServer || "").replace(/\/+$/, "");
-  return `${baseUrl}/v1/policies?${params.toString()}`;
-};
-
-const getPolicyText = (policy: any, fields: string[]) => {
-  return fields
-    .map((field) => policy?.[field])
-    .filter(Boolean)
-    .join(" ")
-    .toLowerCase();
-};
-
-export const getLocalPolicyListResponse = <T extends Record<string, any>>(
-  policies: T[] | null | undefined,
-  query: PolicyListQuery
-): PolicyListResponse<T> => {
-  const keyword = query.keyword.trim().toLowerCase();
-  const department = query.department === "全部部门" ? "" : query.department;
-  const autoGranted = getAutoGrantedParam(query.filterOption);
-
-  const filtered = (Array.isArray(policies) ? policies : []).filter((policy) => {
-    if (keyword) {
-      const searchableText = getPolicyText(policy, [
-        "title",
-        "name",
-        "desc",
-        "declaration_item",
-        "政策名称",
-        "申报事项",
-      ]);
-      if (!searchableText.includes(keyword)) return false;
-    }
-
-    if (department) {
-      const policyDepartment = policy.department || policy["部门"] || policy.source || "";
-      if (policyDepartment !== department) return false;
-    }
-
-    if (autoGranted) {
-      if (String(Boolean(policy.auto_granted)) !== autoGranted) return false;
-    }
-
-    return true;
-  });
-
-  const start = (query.page - 1) * query.pageSize;
-  return {
-    items: filtered.slice(start, start + query.pageSize),
-    total: filtered.length,
-  };
-};
-
-const readItems = <T>(source: any): T[] | null => {
-  if (Array.isArray(source)) return source;
-  if (Array.isArray(source?.items)) return source.items;
-  if (Array.isArray(source?.list)) return source.list;
-  if (Array.isArray(source?.records)) return source.records;
-  if (Array.isArray(source?.results)) return source.results;
-  if (Array.isArray(source?.data)) return source.data;
-  return null;
-};
-
-const readTotal = (source: any, fallback: number) => {
-  const total = source?.total ?? source?.count ?? source?.total_count ?? source?.totalCount;
-  return Number.isFinite(Number(total)) ? Number(total) : fallback;
-};
-
-export const extractPolicyListResponse = <T>(response: any): PolicyListResponse<T> => {
-  const payload = response?.data && !Array.isArray(response.data) ? response.data : response;
-  const items =
-    readItems<T>(payload) ||
-    readItems<T>(payload?.data) ||
-    readItems<T>(payload?.result) ||
-    [];
-  const total = readTotal(response, readTotal(payload, readTotal(payload?.data, readTotal(payload?.result, items.length))));
-
-  return { items, total };
-};
-
 /**
  * 由「被点击的条目」反查它在 `policies` 数组里的下标;查不到返回 -1。
  *

+ 2 - 2
src/components/business-assistant/shared.ts

@@ -82,7 +82,7 @@ export interface BusinessAssistantMessageAttachment {
   isImage: boolean;
   /**
    * **可显示地址**(气泡里 `<img src>` 用的那个)—— 图片才有。
-   * 2026-09-23 换 DMS 后它可能是 `/dms-api/static/photo/…` 这样的**代理地址**,
+   * 2026-09-23 换 DMS 后它可能是 `/dms/static/photo/…` 这样的**代理地址**,
    * 只有拼接前缀才能取到图,`/static/photo/…` 这种相对路径直接当 src 是取不到的
    * (实测:相对路径经 dev server 返回的是 index.html,`<img>` 解不出来 → 图不显示)。
    */
@@ -391,7 +391,7 @@ export interface ChatAttachmentUrls {
  *
  * ⚠️ **取的是 `fileUrl`(相对路径),不是 `url`(可显示地址)**:
  * 换 DMS 之后两个字段分工不同 —— 后端要的是 `/static/photo/…`,
- * 而 `url` 是拼了 `/dms-api` 代理前缀给 `<img>` 用的。
+ * 而 `url` 是拼了 `/dms` 代理前缀给 `<img>` 用的。
  * 2026-09-23 的 bug 正是这里取错:气泡里的图不显示(相对路径解不出来),
  * 详见 progress.md 续 28 与 verify-attachment-question.mjs 的【7】。
  */

+ 46 - 12
src/network/api/dms/client.ts

@@ -5,8 +5,9 @@
  * (`harness/tools/dms-step0-verify.mjs`、`dms-delete-probe.mjs`)。
  * 三处与常规 REST 不同,改代码前先记住:
  *
- * 1. **鉴权头叫 `token`**(不是 `Authorization`)。开发环境由 vite 代理注入
- *    (见 vite.config.ts 的 `/dms-api/`),因此这里**不手动带 token**,前端也拿不到它。
+ * 1. **鉴权头叫 `token`**(不是 `Authorization`)。**2026-09-29 起由前端自己带**:
+ *    未登录访客用服务账号换一个(`service-token.ts`),随请求发 `token` 头;
+ *    代理(dev 的 vite / 生产的 nginx)**只在请求没带时才注入**,命令行走代理的方式不受影响。
  * 2. **参数走 form body**(`application/x-www-form-urlencoded`),不是 JSON。
  * 3. **成功判定是 `code === 200`**;`202` 表示「数据不存在」(栏目为空),不是错误。
  *
@@ -15,6 +16,7 @@
  */
 
 import { getDmsApiBaseUrl } from "@/utils/runtime-config";
+import { getServiceDmsToken, clearServiceDmsToken } from "./service-token";
 
 /** 栏目与栏目模型 id(实测自 DMS,见 DMS_COLUMNS.md) */
 export const DMS_COLUMN_SESSION = 1887;
@@ -54,6 +56,19 @@ export type DmsRow = Record<string, any>;
 
 type DmsMethod = "GET" | "POST" | "DELETE";
 
+/** 取一个可用的 token;拿不到就返回空串(并降级——dev 下代理还会兜底注入) */
+const ensureToken = async (): Promise<string> => {
+  try {
+    return await getServiceDmsToken();
+  } catch (error) {
+    console.warn(
+      "[dms] 换服务账号 token 失败,本次不带 token(dev 由代理兜底注入,生产会失败)",
+      error instanceof Error ? error.message : error
+    );
+    return "";
+  }
+};
+
 /** 发一个 DMS 请求;任何失败都返回 null(调用方无需 try/catch) */
 async function dmsRequest(
   path: string,
@@ -66,22 +81,36 @@ async function dmsRequest(
     return null;
   }
 
-  const init: RequestInit = { method, headers: {} };
-  if (form) {
-    (init.headers as Record<string, string>)["Content-Type"] =
-      "application/x-www-form-urlencoded";
-    init.body = new URLSearchParams(form).toString();
-  }
+  /** 发一次,返回解析结果;`token` 为空就不带这个头(交给代理兜底) */
+  const sendOnce = async (token: string) => {
+    const init: RequestInit = { method, headers: {} };
+    const headers = init.headers as Record<string, string>;
+    if (token) headers["token"] = token;
+    if (form) {
+      headers["Content-Type"] = "application/x-www-form-urlencoded";
+      init.body = new URLSearchParams(form).toString();
+    }
 
-  try {
     const response = await fetch(`${base}${path}`, init);
     const text = await response.text();
     let data: DmsResponse | null = null;
     try {
-      data = JSON.parse(text);
+      data = JSON.parse(text) as DmsResponse;
     } catch {
       console.warn("[dms] 响应不是 JSON", path, text.slice(0, 120));
-      return null;
+    }
+    return { data, text };
+  };
+
+  try {
+    let token = await ensureToken();
+    let { data, text } = await sendOnce(token);
+
+    // token 失效(201/212):丢掉缓存、重换一次再试一遍 —— 只重试一次
+    if (data && (data.code === 201 || data.code === 212) && token) {
+      clearServiceDmsToken();
+      token = await ensureToken();
+      if (token) ({ data, text } = await sendOnce(token));
     }
 
     if (!data) return null;
@@ -93,7 +122,12 @@ async function dmsRequest(
       // 把原始片段打出来,否则只看到 undefined 无从排查
       console.warn("[dms] 非标准响应", path, text.slice(0, 200));
     } else if (data.code === 201 || data.code === 212) {
-      console.warn("[dms] token 无效或无权限(检查 .env.development.local 的 DMS_TOKEN)", data.code, data.message);
+      // 前端换 token 的路径见 service-token.ts;换成命令行排查时也可以 `npm run dms:token`
+      console.warn(
+        "[dms] token 无效或无权限(服务账号可能被停用/改密,见 src/network/api/dms/service-token.ts)",
+        data.code,
+        data.message
+      );
     } else {
       console.warn("[dms] 请求失败", path, data.code, data.message);
     }

+ 3 - 2
src/network/api/dms/file-upload.ts

@@ -10,7 +10,7 @@
  *   —— **`content` 就是那个相对路径**
  * - ⚠️ 返回的相对路径**当前直接取不到**(10081 / 10011 都 404)——
  *   所以聊天里的缩略图**不能拿它当 `<img>` 的 src**,预览要用本地 blob(见上传模块)
- * - ⚠️ 走 `/dms-api` 代理:页面是 HTTPS、接口是 HTTP,直连会被混合内容拦掉
+ * - ⚠️ 走 `/dms` 代理:页面是 HTTPS、接口是 HTTP,直连会被混合内容拦掉
  *   (代理已注入 DMS token,实测这个端点不校验 token 也无妨)
  *
  * 这个相对路径要放进聊天请求体的 `pictures` / `files` 里(用户 2026-09-23 指定)。
@@ -38,7 +38,8 @@ export const DMS_ATTACHMENT_TYPE_FILE = "3";
  * 实测(2026-09-23):相对路径本身取不到(10081 / 10011 的根路径都 404),
  * 但**在 DMS 主机前加上 `/dms` 就能取到** ——
  * `http://121.43.55.7:10081/dms/static/photo/….png` → **200**。
- * 而 `/dms-api` 代理补的正好就是 `/dms`,所以这里直接拼代理前缀即可。
+ * 而代理前缀 `/dms` 与 DMS 的真实路径前缀**同名且直通**(2026-09-29 起,不再需要补前缀),
+ * 所以这里直接拼 `/dms` 即可。
  *
  * ⚠️ 存进消息里的就是这个地址:它依赖代理前缀保持不变;
  * 发给**后端**的仍是相对路径(见 `toChatAttachmentUrls` 用的 `fileUrl` 字段),两者不要混。

+ 112 - 0
src/network/api/dms/service-token.ts

@@ -0,0 +1,112 @@
+/**
+ * 「未登录访客」用的 DMS token —— **由前端自己换**(用户 2026-09-29 指定)。
+ *
+ * 换取方式:`POST {VITE_OAUTH_LOGIN_URL}`(默认 `/oauth/api/user/login`,**同源路径**:
+ * dev 走 vite 代理 → `http://121.43.55.7:10086`,生产由 nginx 做同样转发),
+ * 用**服务账号**换一个 JWT —— 响应体里 `message` 就是 token(不是 `content`),
+ * 另有 `content.failureTime`(epoch 毫秒)可当过期时间。
+ *
+ * ⚠️⚠️ **这个方案有两个已知代价,用户已知悉并要求这样做**:
+ *   1. **服务账号的密码就在下面的 `SERVICE_ACCOUNT` 里** —— 前端产物是公开的,
+ *      任何人从 bundle 里抠出来都能登录 DMS。
+ *      📌 **该账号是用户专门准备的「默认账号」(用户 2026-09-29 确认:已处理过,就是给这个用途的)**
+ *      ⇒ 不要再拿它去申请别的权限;**要收紧/改密码时**:改密码 → 同步改这里与
+ *      `harness/tools/dms-login.mjs` 的 `DEFAULT_ACCOUNT` → 重新构建。
+ *   2. **token 会落到浏览器**(本模块缓存在内存里)—— 任何能打开站点的人都能拿到一个
+ *      DMS 令牌(技术债 #8 记的就是这件事)。
+ *   正路是后端按调用者身份做**行级授权**(技术债 #8 的推荐方案),届时本模块可以删掉。
+ *
+ * ⚠️ 与**代理注入**的关系(dev / 生产都适用):请求带上 `token` 头时,
+ * vite 代理**不再覆盖**它(见 vite.config.ts 的 `/dms/` 注释);没带时才由代理注入
+ * (命令行走代理的排查方式因此不受影响)。
+ */
+
+import { getOauthLoginUrl } from "@/utils/runtime-config";
+
+/**
+ * 服务账号(**用户 2026-09-29 给的默认账号**)。
+ * ⚠️ 它会被打进公开的前端产物 —— 见文件顶部的代价说明,别拿它当有权限的个人账号用。
+ */
+const SERVICE_ACCOUNT = {
+  userName: "user_yszs_web",
+  password: "Yszs@123456",
+};
+
+/** 登录响应的形状(只列用到的字段) */
+interface OauthLoginResponse {
+  code?: number;
+  /** ⚠️ token 在 message 里,不在 content 里 */
+  message?: string;
+  content?: { failureTime?: number; userName?: string } | null;
+}
+
+interface CachedToken {
+  token: string;
+  /** 过期时间(epoch 毫秒);拿不到就按 12 小时估 */
+  expireAt: number;
+}
+
+/** 兜底有效期:拿不到 `content.failureTime` 时按 12 小时估(实测签发的 token 就是 12 小时) */
+const FALLBACK_LIFETIME_MS = 12 * 60 * 60 * 1000;
+/** 提前多久视为过期(留出重试余量) */
+const REFRESH_AHEAD_MS = 5 * 60 * 1000;
+
+let cached: CachedToken | null = null;
+let inflight: Promise<string> | null = null;
+
+const isValid = () => !!cached && cached.expireAt - Date.now() > REFRESH_AHEAD_MS;
+
+/**
+ * 取一个服务账号 token(带缓存;并发调用共用同一个在途请求)。
+ * 失败时抛错 —— 调用方(`client.ts`)自己决定是降级还是重试。
+ */
+export const getServiceDmsToken = async (): Promise<string> => {
+  if (isValid()) return (cached as CachedToken).token;
+  if (inflight) return inflight;
+
+  inflight = (async () => {
+    const url = getOauthLoginUrl();
+    const response = await fetch(url, {
+      method: "POST",
+      headers: { "Content-Type": "application/x-www-form-urlencoded" },
+      body: new URLSearchParams({ ...SERVICE_ACCOUNT, clientId: "0" }).toString(),
+    });
+
+    let body: OauthLoginResponse | null = null;
+    try {
+      body = (await response.json()) as OauthLoginResponse;
+    } catch {
+      /* 非 JSON:下面按状态码/空 body 报错 */
+    }
+
+    const token = String(body?.message || "").trim();
+    if (!response.ok || Number(body?.code) !== 200 || !token) {
+      throw new Error(
+        `换 DMS token 失败(HTTP ${response.status},code=${body?.code ?? "?"}):${String(
+          body?.message ?? ""
+        ).slice(0, 80)}`
+      );
+    }
+
+    const failureTime = Number(body?.content?.failureTime);
+    cached = {
+      token,
+      expireAt:
+        Number.isFinite(failureTime) && failureTime > Date.now()
+          ? failureTime
+          : Date.now() + FALLBACK_LIFETIME_MS,
+    };
+    return token;
+  })();
+
+  try {
+    return await inflight;
+  } finally {
+    inflight = null;
+  }
+};
+
+/** 丢掉缓存(token 被判无效时调用,下次请求会重新登录) */
+export const clearServiceDmsToken = (): void => {
+  cached = null;
+};

+ 25 - 10
src/utils/runtime-config.ts

@@ -1,7 +1,3 @@
-export function getApiBaseUrl() {
-  return ((globalThis as any).VITE_API || "") as string;
-}
-
 /**
  * 新版聊天协议后端基础地址(POST /api/chat,SSE)。
  * 优先取 index.html 注入的 globalThis.VITE_CHAT_API(部署时可改),
@@ -17,8 +13,8 @@ export function getChatApiBaseUrl() {
 /**
  * DMS(数据管理服务)基础地址。
  *
- * 开发环境是 `/dms-api`(vite 同源代理,见 vite.config.ts)——代理负责
- * 补 `/dms` 前缀并把 token 注入到请求头,**前端不持有 token**。
+ * 开发环境是 `/dms`(vite 同源代理,见 vite.config.ts)——代理负责
+ * 把 `/dms/...` 直通到 DMS 主机,并把 token 注入到请求头,**前端不持有 token**。
  * 写法与 getChatApiBaseUrl 一致(globalThis 注入优先,构建期环境变量兜底)。
  */
 export function getDmsApiBaseUrl() {
@@ -48,15 +44,34 @@ export function getNlsTokenApiUrl() {
 /**
  * NLS 的 appkey(识别请求里要带)。
  *
- * 它**不是密钥**(换 token 不需要它,网关也不校验),但按用户 2026-09-24 的口径
- * 与其它凭据一样**不写进仓库文件** —— 值放 `.env.development.local`(gitignored),
- * 生产环境由部署侧注入。
+ * **值直接内置在这里**(用户 2026-09-28 要求:「将 accesskey 直接写入使用的地方,
+ * 不再放在环境变量里」)—— 它**不是密钥**:换 token 不需要它(签名在后端网关做),
+ * 而且每次识别请求本就会把它明文发出去,所以它本来就在公开产物里。
+ * 内置后**不再需要部署侧注入**,也不要求 `.env.*.local` 里有它。
+ *
+ * 仍保留覆盖链(`globalThis` 注入 → 构建期 env → 内置值),方便临时换 appkey 而不改代码。
  */
+const NLS_APPKEY_BUILTIN = "3jRt1atwVuD9dh12";
+
 export function getNlsAppkey() {
   const bootstrap = ((globalThis as any).VITE_NLS_APPKEY || "") as string;
   const fromEnv = (import.meta.env.VITE_NLS_APPKEY || "") as string;
   const value = bootstrap && bootstrap !== "%VITE_NLS_APPKEY%" ? bootstrap : fromEnv;
-  return String(value || "").trim();
+  return String(value || NLS_APPKEY_BUILTIN).trim();
+}
+
+/**
+ * DMS 登录(OAuth)的**完整路径** —— 前端给「未登录访客」换 DMS token 用
+ * (见 `src/network/api/dms/service-token.ts`)。
+ *
+ * 默认 `/oauth/api/user/login`:**同源路径**,dev 走 vite 代理 → `http://121.43.55.7:10086`,
+ * 生产由 nginx 转发同样路径(网关只有 HTTP,而页面是 HTTPS ⇒ 直连会被混合内容拦掉)。
+ */
+export function getOauthLoginUrl() {
+  const bootstrap = ((globalThis as any).VITE_OAUTH_LOGIN_URL || "") as string;
+  const fromEnv = (import.meta.env.VITE_OAUTH_LOGIN_URL || "") as string;
+  const value = bootstrap && bootstrap !== "%VITE_OAUTH_LOGIN_URL%" ? bootstrap : fromEnv;
+  return (value || "/oauth/api/user/login").trim();
 }
 
 /**

+ 20 - 6
src/utils/stream-xml-filter.ts

@@ -9,7 +9,6 @@ export enum RecordRowTypes {
   OfficeURL = "file-docx",
   UIForm = "ui-form",
   PolicyTable = "policy-table",
-  PolicyListUpdated = "policy-list-updated",
   Silence = "silence",
   FollowUp = "followup",
   RefLinks = "ref-links",
@@ -17,6 +16,20 @@ export enum RecordRowTypes {
   EnterpriseNotification = "qingpu_enterprise_notificaion",
 }
 
+/**
+ * `<!-- POLICY_TABLE … -->` 块的起止标记 —— **全仓库唯一一份**。
+ *
+ * ⚠️ **为什么放在这个 `.ts` 里,而不是各组件里各写一份**:Vite 的依赖预扫描把
+ * **`.vue` 也当 HTML**(`htmlTypesRE = /\.(html|vue|svelte|astro|imba)$/`),扫描前会先做一次
+ * `raw.replace(/<!--.*?-->/gs, "<!---->")` —— 于是 `.vue` 的 `<script>` 里只要出现一个**裸 `<!--`**,
+ * 它就会与**后面任意一个 `-->`**(哪怕隔着很多行)配成"HTML 注释"被整段吃掉。
+ * 2026-09-28 实际踩过:BusinessRecord.vue 里一句注释的 `<!--` 与 9 行后正则里的 `-->` 配了对,
+ * 冷扫描直接报 `Failed to scan for dependencies … Unexpected "."`。
+ * **`.ts` 不在那份名单里** ⇒ 字面量收在这里是安全的;组件请 import 本文件的常量。
+ */
+export const POLICY_TABLE_START = "<!-- POLICY_TABLE";
+export const POLICY_TABLE_END = "POLICY_TABLE -->";
+
 type TagRule = {
   name: RecordRowTypes;
   behavior: "skip" | "block";
@@ -54,7 +67,8 @@ export class StreamXMLFilter {
       { name: RecordRowTypes.Scope, behavior: "block", open: "scope", close: "/scope" },
       { name: RecordRowTypes.LinkURL, behavior: "block", open: "link-url", close: "/link-url" },
       { name: RecordRowTypes.ImageContent, behavior: "block", open: "image-scope", close: "/image-scope" },
-      { name: RecordRowTypes.PolicyListUpdated, behavior: "block", open: "policy-list-updated", close: "/policy-list-updated" },
+      // 旧协议的 <policy-list-updated> 规则已删(2026-09-28,用户确认旧对话仅测试、后续清空):
+      // 新协议适配层从不产出该块。旧测试会话清空之前,若有人打开,那串原始 JSON 会当普通文本显示。
       { name: RecordRowTypes.RefLinks, behavior: "block", open: "ref_links", close: "/ref_links" },
       {
         name: RecordRowTypes.OfficeURL,
@@ -87,8 +101,8 @@ export class StreamXMLFilter {
   }
 
   private processPolicyTableBlock(result: { type: RecordRowTypes; content: string }[]) {
-    const startToken = "<!-- POLICY_TABLE";
-    const endToken = "POLICY_TABLE -->";
+    const startToken = POLICY_TABLE_START;
+    const endToken = POLICY_TABLE_END;
     const startIndex = this.buffer.indexOf(startToken);
 
     if (startIndex === -1) {
@@ -135,7 +149,7 @@ export class StreamXMLFilter {
   }
 
   private isPossiblePolicyTablePrefix(buffer: string): boolean {
-    const startToken = "<!-- POLICY_TABLE";
+    const startToken = POLICY_TABLE_START;
     if (!buffer.startsWith("<")) return false;
 
     return startToken.toLowerCase().startsWith(buffer.toLowerCase());
@@ -260,7 +274,7 @@ export class StreamXMLFilter {
 
     while (this.buffer.length > 0) {
       if (this.tagStack.length === 0 && this.processPolicyTableBlock(result)) {
-        if (this.buffer.startsWith("<!-- POLICY_TABLE")) {
+        if (this.buffer.startsWith(POLICY_TABLE_START)) {
           break;
         }
         continue;

+ 48 - 54
vite.config.ts

@@ -1,9 +1,8 @@
-import { defineConfig, loadEnv } from "vite";
+import { defineConfig } from "vite";
 // import { darkTheme } from "./src/theme.js";
 import vue from "@vitejs/plugin-vue";
 import Components from "unplugin-vue-components/vite";
 import { AntDesignVueResolver } from "unplugin-vue-components/resolvers";
-import { visualizer } from "rollup-plugin-visualizer";
 import svgLoader from "vite-svg-loader";
 import path from "path";
 
@@ -15,8 +14,8 @@ import basicSsl from "@vitejs/plugin-basic-ssl";
 import { DMS_TOKEN_FILE, readDmsToken, parseJwtPayload, describeDmsToken } from "./harness/tools/dms-token.mjs";
 
 // [已移除] dev 期自己签 NLS token 的中间件(`/nls-token/`)。
-// 2026-09-24 起 token 改由**后端网关**提供:`GET {VITE_ALY_TARGET}/proxy/verify/getALYToken`
-// → `{code:200, content:"<token>"}`,前端走同源 `/aly-api/` 代理拿(见下面的 proxy)。
+// 2026-09-24 起 token 改由**后端网关**提供:`GET http://121.43.55.7:10011/proxy/verify/getALYToken`
+// → `{code:200, content:"<token>"}`,前端走同源 `/api/` 代理拿(2026-09-29 前叫 `/aly-api/`)。
 // 于是前端侧**不再需要 AccessKeySecret**,vite 也不必持凭据。
 // AK/SK 自签的能力仍留在 harness/tools/nls-token.mjs(工具侧退路,仅在没有网关时用)。
 
@@ -59,82 +58,77 @@ const warnDmsTokenIfNeeded = (token: string): void => {
 };
 
 // https://vitejs.dev/config/
-export default defineConfig(({ mode }) => {
-  const env = loadEnv(mode, process.cwd(), "");
-
+export default defineConfig(() => {
   return {
     plugins: [
       vue(),
       Components({
         resolvers: [AntDesignVueResolver()],
       }),
-      visualizer({
-        open: false,
-        gzipSize: true,
-        file: "./dist/stats.html",
-        brotliSize: true,
-      }),
       svgLoader(),
       basicSsl(),
     ],
     server: {
       proxy: {
-        //配置本地代理
+        // ── 代理总原则(2026-09-29 用户指定)────────────────────────────────
+        // 前缀**尽量与服务真实路径一致**,只有服务端本身不带前缀时才剥:
+        //   /oauth/**  → 121.43.55.7:10086,**直通**(真实路径就带 /oauth)
+        //   /dms/**    → 121.43.55.7:10081,**直通**(真实路径就带 /dms)
+        //   /api/**    → 121.43.55.7:10011,**剥掉 /api**(网关真实路径是 /proxy/verify/…,
+        //                实测直连 /api/proxy/… 是 404)
+        //   /chat-api/** → 聊天后端,剥前缀(后端真实路径是 /api/chat,与前端前缀不同名)
+        // 三台后端都只有 HTTP,而 dev server 是 HTTPS ⇒ 直连会被浏览器按混合内容拦掉,
+        // 必须由服务端这一跳(dev 是 vite 代理,生产是 nginx)代劳。
+        // 2026-09-29 之前是 /dms-api(补 /dms) + /ip-api + /aly-api(两条打同一台) —— 现在合并。
+        //
+        // ⚠️ **target 直接写在这里,不从环境变量读**(用户 2026-09-29 明确要求
+        //    「vite.config.ts 里面不要加环境变量」)—— 所以本文件不再 `loadEnv`。
+        //    换地址就改这四处;`.env.*` 只管**产物里**用到的值(VITE_DMS_API 等同源前缀)。
+
+        // 后端网关 121.43.55.7:10011:语音识别 token(/proxy/verify/getALYToken)
+        // 与取本机 IP(/proxy/verify/getIPByBrowser)**在同一台**,所以共用这一条。
         "/api/": {
-          //匹配的路径
-          target: "http://aixq.shqp.gov.cn", //目标url
-          changeOrigin: true, //跨域
-          rewrite: (path) => path.replace(/^\/api/, ""), //重写路径
+          target: "http://121.43.55.7:10011",
+          changeOrigin: true,
+          rewrite: (path) => path.replace(/^\/api/, ""),
         },
-        // 新版聊天后端代理(POST /api/chat,SSE)
-        // 开发服务器是 HTTPS 而后端是 HTTP,浏览器会拦截直连(混合内容),
-        // 且后端未实现 CORS,因此开发环境统一走同源代理。
-        // 对应 .env.development 中 VITE_CHAT_API="/chat-api"
-        // target 取 VITE_CHAT_TARGET(地址收在 env 里,不再写死在配置文件中)
-        "/chat-api/": {
-          target: env.VITE_CHAT_TARGET || "http://192.168.2.23:8000",
+        // DMS 登录服务 121.43.55.7:10086(真实路径带 /oauth,所以直通、不改写)。
+        // 前端目前不直接调它(token 是 node 侧换的);留着是为了本地能自测登录接口。
+        "/oauth/": {
+          target: "http://121.43.55.7:10086",
           changeOrigin: true,
-          rewrite: (path) => path.replace(/^\/chat-api/, ""),
         },
-        // DMS 数据管理服务(会话/问答记录/反馈)
-        // 两件事都在代理侧做,浏览器拿不到:
-        //   ① 补回 /dms 前缀(DMS 的真实路由都在 /dms/ 下)
-        //   ② 注入 token 头(DMS 的鉴权头名就叫 token,不是 Authorization)
-        // token 来源:.env.development.local 的 DMS_TOKEN(不入库)
-        "/dms-api/": {
-          target: env.VITE_DMS_TARGET || "http://121.43.55.7:10081",
+        // DMS 数据管理服务(会话/问答记录/反馈)——真实路由都在 /dms/ 下,前缀同名,直通。
+        // token(DMS 的鉴权头名就叫 token,不是 Authorization):
+        //   ① **前端自己带**:未登录访客用服务账号现换一个(`src/network/api/dms/service-token.ts`,
+        //      2026-09-29 用户要求改成这套)—— 这种请求**代理不覆盖**它的 token;
+        //   ② 请求没带时才由代理兜底注入:读仓库根 `.dms-token`(gitignored)或进程环境变量
+        //      DMS_TOKEN(`readDmsToken()` 自己会读,见 harness/tools/dms-token.mjs)
+        //      —— 命令行走代理排查(curl / verify-dms-*.mjs)靠的就是这条。
+        // 现读而非静态 headers:换 token 不用重启 dev server。
+        "/dms/": {
+          target: "http://121.43.55.7:10081",
           changeOrigin: true,
-          rewrite: (path) => path.replace(/^\/dms-api/, "/dms"),
-          // token 在**每个请求**现读(见 readDmsToken 的说明):换 token 不用重启 dev server。
-          // 用 configure 而不是静态 headers —— 后者的值在启动时就定死了。
           configure: (proxy) => {
             proxy.on("proxyReq", (proxyReq) => {
-              // 现读:换 token 后不需要重启 dev server
-              const token = readDmsToken(env.DMS_TOKEN);
+              // 前端已经带了 token 就别覆盖(否则前端的刷新逻辑白做)
+              const fromClient =
+                typeof proxyReq.getHeader === "function" ? proxyReq.getHeader("token") : undefined;
+              if (fromClient) return;
+              const token = readDmsToken();
               if (token) {
-                // DMS 的鉴权头名就叫 token(不是 Authorization)
                 proxyReq.setHeader("token", token);
               }
               warnDmsTokenIfNeeded(token);
             });
           },
         },
-        // 取本机 IP 的接口(DMS 访客归属用,见 src/utils/client-ip.ts)
-        // 走同源代理的原因与聊天后端一样:页面是 HTTPS,而它是 HTTP —— 直连会被混合内容拦掉
-        // (接口本身带 Access-Control-Allow-Origin: *,跨域不是问题,混合内容才是)
-        "/ip-api/": {
-          target: env.VITE_IP_TARGET || "http://121.43.55.7:10011",
-          changeOrigin: true,
-          rewrite: (path) => path.replace(/^\/ip-api/, ""),
-        },
-        // 阿里云 NLS 的 token 端点(`/proxy/verify/getALYToken`)——**后端网关提供**,
-        // 与上面 `/ip-api/` 同一台机器(121.43.55.7:10011)、同样只有 HTTP:
-        // 页面是 HTTPS,直连会被混合内容拦掉,所以走同源代理。
-        // 生产要在 nginx 里做同样的事(前端调的是同源路径 `/aly-api/...`)。
-        "/aly-api/": {
-          target: env.VITE_ALY_TARGET || "http://121.43.55.7:10011",
+        // 新版聊天后端(POST /api/chat,SSE):后端真实路径是 /api/chat,与前端前缀不同名 ⇒ 剥前缀。
+        // 对应 .env.development 中 VITE_CHAT_API="/chat-api"。
+        "/chat-api/": {
+          target: "http://192.168.2.23:8000",
           changeOrigin: true,
-          rewrite: (path) => path.replace(/^\/aly-api/, ""),
+          rewrite: (path) => path.replace(/^\/chat-api/, ""),
         },
         // [已移除] "/stream/" 代理(数字人视频流):src/index.html 里没有任何代码往它发请求,
         // 相关 env(VITE_STREAM_SERVER / VITE_STREAM_TOKEN / VITE_HUMAN_IDLE_MP4_URL)也已删除。