# 技术债
> 记在这里的债都是**有意欠的**,不是忘了。每条写清:是什么、为什么先欠着、
> 什么条件下该还。
## 未还
### 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 数据可读(部署前必须处理)
- **是什么**:前端所有 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 环境无所谓(本机、只有自己)。
- **欠着会怎样**:**生产一旦按现方案接上,DMS 数据等同公开** —— 含企业工商信息与全部问答记录。
这与「访客 / 登录」无关,是**部署层面**的暴露,登录态也挡不住(token 是共享的)。
- **该还的条件(两条路)**:
1. **推荐**:后端提供 DMS 转发接口,按调用者身份(登录企业 / 访客 id)做**行级授权**,
前端不再直连 DMS —— 与 `docs/plans/active/dms-api-replacement.md` 里"前端直连还是后端转发"
那道待拍板的问题是同一件事,**这里给出了必须选后端的理由**;
2. 临时缓解:nginx 只放行前端真正用到的端点**且**强制按调用者身份改写查询条件 ——
实践上很难做对,不建议。
- **现状**:**未处理**,生产接入前必须解决。
### 9. 生产 / 青浦构建没配 DMS:附件传不上去、DMS 落库静默跳过
- **是什么**:`.env.development` 里有 `VITE_DMS_API="/dms-api"` + `VITE_DMS_TARGET`,
而 `.env.production` **也没有**(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` 优先),
所以也可以**不改构建、只改部署产物里的这一行**——但同样绕不开 ②。
- **当前状态**:仅登记,未处理。**需要用户决定生产怎么接。**
### 10.(2026-09-24 已大半还清)生产的 NLS token 端点
> **进展**:用户 2026-09-24 给了**后端网关的 token 端点**
> `GET http://121.43.55.7:10011/proxy/verify/getALYToken` → `{code:200, content:""}`,
> 前端已改为调它(同源 `/aly-api/…`,dev 走 vite 代理)。**前端侧不再需要 AK/SK**,
> vite 里那段自签中间件已删除。
> **剩下的(部署侧,仍未做)**:
> ① nginx 把 `/aly-api/` 转发到 `http://121.43.55.7:10011/`(去掉前缀,与 `/ip-api/` 同法);
> ② **生产 env 要配 `VITE_NLS_APPKEY`**(实测:`.env.production`
> 都没有 → 生产构建打出来的包里 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 能真调通识别、构建产物零凭据泄漏);
生产未做,**需要用户/后端拍板由谁实现**。
## 已还
### 语音识别已换阿里云 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` 可改造成此用途)。
- **若要根治**:得后端在流式阶段就带上最终链接、或减少降级 —— 属后端侧决定。