# 技术债 > 记在这里的债都是**有意欠的**,不是忘了。每条写清:是什么、为什么先欠着、 > 什么条件下该还。 ## 未还 ### 1. 公司候选提交的是文本而非序号(协议标注的做法) - **是什么**:接口文档写的是"选择第 1 家公司提交 `question="1"`,**API 由序号映射到原候选**", 而当前前端提交的是**选项全部内容拼接的文本**(公司名 + 信用代码 + 状态 + 负责人)。 - **为什么先欠着**:这是产品明确要求的形态。文本在语义上无法表达"我选这一个", 后端可能把它当成"换关键词"走 refine 路径重新检索。 - **欠着会怎样**:**可能出现"选一次又弹回同一批候选"的死循环。** - **该还的条件**:上游公司数据服务恢复后**必须补测** —— 走一遍有候选(`status: found`)的流程, 选中一家,确认返回的是选定公司后的结果而不是又弹候选列表。 若循环,两条路:①改回提交序号(序号取自当前显示顺序);②后端扩语义识别全字段文本。 - **当前状态**:上游持续 `provider_failure`,**`status: found` 一次都没出现过**,因此从未验证。 ### 2. `answer.text` 与 `summary.text` 首句重复 - **是什么**:后端把同一段话既放进 `answer.text`(渲染在消息正文)又放进 `summary.text` 的开头, 实测两者首句**逐字相同**,用户会看到同一段话出现两次。 - **为什么先欠着**:已确认是**后端内容重复**,不是前端渲染问题(前端对两个字段各渲染一次)。 - **欠着会怎样**:回答开头重复一段,观感差。 - **该还的条件**:后端修(推荐,最干净),或前端加去重(权宜:比对首句并剥离, 但属启发式判断,可能误删)。**需用户拍板走哪条。** ### 4.(2026-09-24 已还)部门筛选在新数据下筛不出结果 > **还清了**:用户 2026-09-24 明确要求「部门筛选只包含 JSON 里面存在的所有部门」—— > 前端不再用那份**硬编码的部门列表**,改为**从惠企政策库(`政策所属部门`)现算** > (打开部门菜单/面板时算一次;选中项若不在新清单里自动退回「全部部门」)。 > 于是"固定列表 vs 数据取值口径不一致"这个根因消失了:清单里不会再有库里没有的部门, > 也不会漏掉库里新增的部门。验证见 `verify-policy-library.mjs` 第 7 节(35/35)。 > > ⚠️ 早先"不要当 bug 修"的告诫**已被这条用户指示取代** —— 别再按老口径改回固定列表。
当初的欠账记录 ### 4. 部门筛选在新数据下筛不出结果 - **是什么**:政策「更多」面板的部门下拉是**固定的最初 15 个部门**, 按字面相等匹配;而接口返回的部门是全称(如"青浦区科学技术委员会"),字面不相等。 - **为什么先欠着**:**用户明确要求**保持最初的实现,暂时不做匹配。 - **⚠️ 不要当 bug 修**:曾试过①按接口数据动态生成选项、②保留固定选项但加关键词归一化匹配, 两个方案都被否掉。 - **该还的条件**:用户明确要求时再动。
### 5. `Question.message` 字段未被渲染 - **是什么**:`QuestionCard` 的 `Question` 接口里定义了 `message` 字段, 但模板**从未渲染它** —— 适配层写的提示文案一直不可见。 - **为什么先欠着**:只影响观感,不影响功能;改模板会扩大改动面。 - **该还的条件**:需要展示这类提示时。 ### 7. 移动端真机样式问题:没有兼容基线,且布局依赖 JS 测量 - **是什么**:用户反馈**真机**上(F12 模拟正常)①首页展示不全 ②思考过程文案超出屏幕 ③**不同浏览器问题还不一样**。排查后确认三个相互独立的根因: 1. **`height: 100dvh` 没有 `100vh` 兜底**(`BusinessAssistantMobile.vue`)—— `dvh` 是较新的单位(Chrome 108+ / Safari 15.4+),国产浏览器内核多基于更老的版本 → 该声明被整条丢弃 → 容器高度塌陷,而外层是 `overflow: hidden` → **内容被裁掉** 2. **没有 `text-size-adjust: 100%`** —— 移动端浏览器会按各自策略自动放大文字, 各家不同 → 同一份文案在不同手机上长度不同(也是"不同浏览器不一样"的直接来源) 3. **思考过程卡片的样式靠 JS 测量 + `width: max-content` 决定**「单行省略 vs 换行」: `updateMaxWidthReached()` 用 `getBoundingClientRect()` 比卡片与父容器宽度。 而字体首选 `"HarmonyOS Sans SC"`(**手机多无此字体 → 回退到系统字体**)→ **字宽不同** → 测量结果不同 → 该换行时判成"没撑满" → 单行 `nowrap` 撑出屏幕 - **为什么先欠着**:**需用户提供信息/确认改法**(见下),且其中一条会改变观感 - **欠着会怎样**:移动端(本项目的主要使用场景)体验不稳定,且**换台手机就可能变样** - **该还的条件**: 1. 用户提供**具体浏览器与版本**、以及能否**真机远程调试**(Android 用 `chrome://inspect` 最省事)—— 没有这个只能猜 2. 方案 A(低风险、可立刻做):`dvh` 加 `vh` 兜底、全局 `text-size-adjust: 100%`、 给 flex 链补 `min-width: 0` 3. 方案 B(**建议做**,但要用户确认观感变化):**去掉「按宽度切样式」这套测量**, 改为纯 CSS —— 标题恒定单行省略、正文恒定可换行,删掉 `is-max-width-reached` 与 `width: max-content`。现在这套**先天不稳定**(字体/时机/内核都会影响测量) 4. 方案 C(机制):定**浏览器兼容基线** + 加 `browserslist`; 项目当前**没有任何 browserslist / postcss 降级配置**,而代码里用了 `dvh`(4) / `gap`(151) / `inset`(22) / `backdrop-filter`(24) 等较新特性 - **当前进度(2026-09-18)**:已按**用户自己的方案**给首页布局"瘦身" (顶栏 64→52px、logo 130×88→96×65、标题 28→22px 且恒定单行、建议词区间距收窄, 随后再加两侧留白、标题 22→20px、介绍文案 14→13px),**用户真机确认"不遮了"**。 ⚠️ 但这只是**让内容放得下**——上面三个根因**一个都没动**(`dvh` 仍无 `vh` 兜底、 仍无 `text-size-adjust`、思考卡片仍靠 JS 测量切样式,`padding-bottom: 180px` 也仍是写死的)。 **换台手机、或用户调大系统字号,仍可能变样**;方案 A/B/C 依然有效,等用户决定要不要做。 ### 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` 里 "前端直连还是后端转发"那道待拍板的问题是同一件事,**这里给出了必须选后端的理由**; 2. 临时缓解:nginx 只放行前端真正用到的端点**且**强制按调用者身份改写查询条件 —— 实践上很难做对,不建议。 - **现状**:**未处理**(暴露面比原来更大),生产接入前必须解决。 ### 9. 生产构建没配 DMS(附件传不上去、DMS 落库静默跳过)—— **2026-09-29 已还一半** - **原来是什么**:`.env.production` 里没有 `VITE_DMS_API`(2026-09-24 起只剩开发/生产两份 env, 见 progress.md 续 38)⇒ 生产包里 `getDmsApiBaseUrl()` 返回空串,后果两条: ①`uploadDmsAttachment` 直接抛「未配置 DMS 接口地址,无法上传附件」→ **附件整体不可用**; ②DMS 会话/问答/反馈请求走 `client.ts` 的「未配置就跳过」,**静默不落库**。 - **✅ 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:""}`, > 前端已改为调它(同源 `/api/…`,dev 走 vite 代理)。**前端侧不再需要 AK/SK**, > vite 里那段自签中间件已删除。 > **剩下的(部署侧,仍未做)**: > ① 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)。
原来的记录(2026-09-23) - **是什么**:语音识别要换成阿里云 NLS,而 NLS 的 token 必须用 **AccessKeySecret** 签名去换 (`CreateToken`,24 小时过期)。secret **不能进前端产物**(bundle 是公开的), 所以 `vite.config.ts` 里加了一个 **dev-only** 的端点 `GET /nls-token/` (`apply: 'serve'`,返回 `{token, expireAt, appkey}`)。 - **为什么先欠着**:开发期够用了(前端零凭据,凭据在 `.env.development.local`), 但生产没这个端点 —— 打包产物里没有、也不该有任何换 token 的能力。 - **欠着会怎样**:**生产环境语音识别拿不到 token,功能不可用**(前端会拿到 404/503)。 - **该还的条件**:后端/网关提供**同样形态**的端点:前端仍是同源 `GET /nls-token/` → `{token, expireAt, appkey}`,secret 留在服务端。 这样前端的代码一行都不用改,只是 dev 与生产的实现者不同。 - **当前状态**:dev 已通(端点 200、token 能真调通识别、构建产物零凭据泄漏); 生产未做,**需要用户/后端拍板由谁实现**。
### 11. 生产环境接入(DMS + AI 对话):地址已给,**暂缓执行**(用户 2026-09-28) - **是什么**:把生产产物接到真实的生产后端。用户 2026-09-28 给出的地址: **DMS `10.235.245.174`**、**AI 对话 `10.249.33.148`** —— **两个都没给端口**。 - **为什么先欠着**:用户同日明确——「生产环境适配先不管,先标记待办,我们先处理别的问题」。 - **欠着会怎样**:生产产物现在 ①`VITE_CHAT_API` 已改成同源 `/chat-api`(与 dev 一致), 但部署侧 nginx 还没配上真实生产后端(对话机 `10.249.33.148`,端口未定); ②~~根本没有 `VITE_DMS_API`~~ → **已补**同源 `/dms`(见 #9),仍差 nginx。 未配 nginx 时聊天/DMS 会 404,会话/问答**静默不落库**。 - **动手前要先拍板的两件事**: 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** (页面是 HTTPS;原来的父页面 origin 变量 `VITE_PARENT_ORIGIN` 已于 2026-09-29 随"不再嵌入"删除), 直连 `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_GATEWAY_API` 的最终值 (NLS token 路径由代码拼在 `VITE_GATEWAY_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 已还) - **换了什么**:语音识别从厂商 metamaker 的 `asr_hub` 中转,换成**阿里云 NLS 直连** (协议与实现见 [`../reference/asr-nls.md`](../reference/asr-nls.md))。 - **当初欠的两点都解决了**:① 厂商 token 硬编码(`exp 2034-12-29`)已随旧实现删除; ② token 改由**后端网关** `/proxy/verify/getALYToken` 签发,前端不持有任何云凭据。 - **旧实现已按用户要求删除**(2026-09-24),要看它原来怎么工作查 git 历史。
当初的欠账记录 ### 6. 语音识别仍跑在厂商(metamaker)的服务上 - **是什么**:语音输入走 `wss://qingpu-data-api.metamaker.cn/common/asr_hub` (引擎 `aliyun_dashscope`),且**鉴权 token 与 appKey 都硬编码在源码里** (`src/three-libs/asr/index.ts` 的 token:`iss: human-large-screen`,**exp 2034-12-29**,入库可见)。 - **为什么先欠着**:**用户 2026-09-18 明确「先不动,先用原来的语音识别功能」**—— 现在能用、无成本、前端零维护;独立的成本主要在「要有人长期维护一个 ASR 网关 + 引擎账号」。 - **欠着会怎样**:⚠️ **厂商若失效那条 token、或停掉那个网关,语音功能会突然不可用,且没有任何预警**; 另外用户语音音频流经厂商服务器(政务场景下涉及数据合规评估)。 - **该还的条件**:出现以下任一情况就该动 —— ①厂商停服/改价/要求换凭据;②合规要求音频不出自己的服务器; ③要换引擎。**退路已经准备好**:协议契约已存档在 [`../reference/current-api-call-sites.md`](../reference/current-api-call-sites.md) 的「ASR 协议契约」, 实现方照着就能写自建网关,**前端一行都不用改**(只把 `VITE_ASR` 指向新地址)。 - **一个低成本的前置动作(尚未做)**:把 `index.html` 里写死的 `globalThis.isXF = false` **改成环境变量开关** —— 这样万一厂商出问题,改配置就能切到讯飞分支,不必改代码重新发版。
### 访客行会被企业看到(2026-09-22 已还) - **当时的问题**:1887 口径改为「`c_credit_code` 只放真实信用代码」后,访客行也带着 对话提到的那家公司的代码;而 `fetchDmsSessions`(读「我的会话」)按 `c_credit_code` 查、 且只在登录后调用 → **那家公司登录后会看到陌生访客的会话**。 - **怎么还的**:`fetchDmsSessions` 增加一道过滤 —— **排除「明确是访客」的行** (`c_is_login === "0"`)。只排除显式访客行(`c_is_login` 缺失的历史行照旧返回), 因此可见范围与改造前**完全一致**。 - **同时修掉一个更隐蔽的**:登录认领过的行(`c_is_login === "1"`)不再允许访客对话改写 它的公司字段 —— 否则退登后再问到别的公司,会把这行改到那家公司名下、而 `c_is_login` 仍是 `"1"`,既串台又会被读侧当登录行返回。 - **验证**:`verify-dms-chat-storage.mjs` 打真实 DMS,两个方向都断言(访客行查不到 / 登录行查得到),且实测「拿掉过滤该断言立刻失败」——是真守卫。 ### 文件上传 / 多模态输入(2026-09-23 已还) - **当时的问题**:接口请求体只接受 `{thread_id, question}`,多一个字段 422, 旧协议的 `transmission.files / file_pos` 发不出去 —— 上传入口虽然活着, 但附件只能**把 OSS 地址拼进 question 文本**(方案②),模型会把 URL 当正文读, 本地消息、会话标题、DMS 记录也都跟着被 URL 污染。 - **怎么还的**:后端扩展了请求体(方案①),新增两个多模态字段: - `pictures: string[]` —— 图片 URL 列表(**后端自己去取图**,须公网可达) - `files: string[]` —— 非图片文件 URL 列表 - 实测:元素必须是 URL **字符串**(对象数组 `[{url,name}]` 会被 `422 invalid_request` 拒掉); 带上 `pictures` 问「这张图片里是什么」,模型确实描述出了图的内容(**不是收下就忽略**); 空数组与不传都接受。 - **前端改动**:`toChatAttachmentUrls()` 按 `isImage` 拆成两个列表随请求体发出; 地址**不再拼进 question**。旧函数 `buildQuestionWithAttachments` 按约定注释保留。 - **不变量(有断言守着)**:没有附件时请求体**只有** `thread_id` + `question` —— 既不多传无意义字段,也让「后端若回滚」只影响带附件的那几轮。 - **验证**:`harness/tools/verify-attachment-question.mjs` **19/19**(拆分纯逻辑 + 真正发出的 请求体 + 「旧函数无活跃调用点」的源码级断言);字段生效是打真实后端探针实测的。 --- ## 已知且**已接受**的行为(不是债,勿当 bug 修) ### A1. 流式草稿会被最终文本**整段替换** - **现象**(用户 2026-09-21 报):卡片下方先渲染出一大段正文,随后**整段消失**, 变成另一段(可能是更短的降级答复)。 - **为什么会这样(契约要求,不是渲染 bug)**: - `token-streaming.md`:「完整校验后**绑定真实链接,随最终替换出现**」; 「`answer_end` 无论 status 是 completed 还是 fallback,**均用 text 覆盖已有内容**…… **不能只停止光标而保留草稿**」 - 实测复现(同一问法):草稿 1494 字 → 最终 1943 字,**公共前缀只有 175 字(11.7%)**—— 最终版给标题补了序号(`### 高新技术企业认定…` → `### 一、高新技术企业认定…`)、 把纯文本渠道换成了 markdown 链接(`- 国家高新技术企业认定管理工作网先行注册…` → `- [查看办事指南](https://zwdt.sh.gov.cn/…)`)。**链接只在最终校验后才绑定**,草稿必然没有。 - `status: fallback` 那一类更直接:草稿被判不可用 → 换成降级答复 (「已查到相关资料,但这次未能完成答复整理。您可以查看下方原文资料,或稍后重试。」) - **客户端能做的只有缓解、改不了「内容会变」**:曾评估过 ①加提示 ②渲染层保留公共前缀行只重建 分歧点之后 —— 实测公共前缀仅 11.7%,②收益很小。**用户 2026-09-21 决定接受现状**。 - **复核方式**:抓一轮 `/api/chat` 原始载荷,比对 `concat(answer_delta)` 与 `answer_end.text` 的**公共前缀长度**(`harness/tools/probe-answer-tail-timing.mjs` 可改造成此用途)。 - **若要根治**:得后端在流式阶段就带上最终链接、或减少降级 —— 属后端侧决定。