# 技术债 > 记在这里的债都是**有意欠的**,不是忘了。每条写清:是什么、为什么先欠着、 > 什么条件下该还。 ## 未还 ### 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` 可改造成此用途)。 - **若要根治**:得后端在流式阶段就带上最终链接、或减少降级 —— 属后端侧决定。