🚀2026-10-18
企微问数应用合并 + NLQ 全表跨表查数 + 问数日志 / 时间口径追问 / 结果导出 / 同比环比;工单知识沉淀 → 官网 FAQ 发布链路(候选池 + 两级人工审核 + 类型占位脱敏)
v2026-10-18
- 配置企微问数自建应用合并:删除 1000009、问数迁至 1000007(唯一应用):原 1000009(问数机器人)与 1000007(扫码登录)都曾把「接收消息」回调指向
https://crm.shangbon.com/api/wecom/msg-crm,各自用不同 EncodingAESKey,导致 CRM 单 key 解密时而成功时而失败(同一句「最近3日商机线索情况」既成功又失败,已坐实双 key 抢同一回调)。现删除 1000009、问数迁到 1000007,后台仅剩一个应用配该回调,根因闭环。CRM 侧对齐wecom_bot.agentid=1000007、Token=hjrAYPDhekdXvzVuIZCKHcP、EncodingAESKey=nxa91laemyQJ2V463JFuuSqJ18CuEAFAdsomXIXbAMs、Secret(已用主密钥加密入库,与wecom_scan_login.secret一致);模拟回调握手 + 真实企微推送均验证recv → briefing/nlq → passive reply ok。临时诊断日志(记完整密文enc_full)已还原为只记enc_len,避免敏感密文堆积。重启 server.js 生效(潘总授权重启)。 - 修复NLQ 自然语言查数:成交表「金额」字段报错
no such column: amount:NLQ_TABLES.deals声明了amount字段,但deals表无独立amount列(金额 =qty * unit_price计算值),导致对「成交」做金额相关问数生成的 SQLSELECT SUM(amount) FROM deals直接报错;其余 5 张表字段与真实列均对得上。修复(server.js):① 给NLQ_TABLES.deals.amount增加expr:'(qty * unit_price)';②nlqBuild()新增colExpr()解析函数,拼指标 / 维度 / 过滤 / 排序时优先使用字段的expr真实 SQL 表达式。实测「成交金额」对应的SELECT SUM((qty * unit_price)) FROM deals及其分组、过滤变体均执行成功,旧写法确认报错(预期)。重启 server.js 生效。 - 排查企微「今日简报」解密失败根因定位:回调 URL 被多入口复用 + 多 EncodingAESKey 不一致(非命中规则、非第二应用、非传播延迟):现象为「今日简报」100% 无反应、「今日简报信息」100% 命中,长期稳定按内容一刀切。日志对照(
data/wecom-bot.log)铁证:① 成功与失败消息的ToUserName(corpid)均为wwd49eea7010ea633b(同一企业);② 失败消息post decrypt fail: bad decrypt (tried N keys)且无 content——死在解密、根本没走到关键词匹配正则,故"命中规则"假设不成立;③ AES 解密只认 Key、与明文无关,按内容一刀切只能说明两把不同的 EncodingAESKey 在加密,即存在复用同一回调 URLhttps://crm.shangbon.com/api/wecom/msg-crm的第二入口。④ 模拟闭环:用bD6xMS9KmSSjWUn6aYb37hBQfsj5n4BuXvd8B2qpF59加密一条「今日简报」POST 给线上服务,日志命中post decrypt ok via fallback key[1]→briefing ok→passive reply ok,实锤 bD6xMS 就是那把第二 Key。⑤ 但另一条真实消息tried 6 keys仍失败,说明除 bD6xMS 外还有第三把未知 Key(Key Y)也在推消息,即回调 URL 被 ≥2 个非 1000007 入口复用、各持不同 Key。止血(server.js 改动,潘总授权):CANDIDATE_KEYS新增bD6xMS...候选(现 6 把),重启用计划任务CRM-AutoStart(沙箱禁 cmd/schtasks/wmic,工具 shell 退出会回收子进程,故走系统会话的计划任务才稳)。治本(需潘总后台操作):企业微信管理后台「微信插件」(不在应用管理列表)及所有应用的「接收消息→回调 URL」逐一核对,把指向 crm 回调的多余入口删掉、或 EncodingAESKey 统一为CmaaLaEPgwFyWzBBLJvunSBQq6mQsnHVvbhqRRLaS51,否则 Key Y 入口的「今日简报」仍偶发失败。此条修正此前「根因闭环」的过早判断。 - 排查企微回调新增独立短地址
/panchengbiao+ 来源路径日志,把「回调 URL 被多入口复用」变成可判定的对照实验:针对「今日简报」持续解密失败、而「今日简报信息」稳定成功,不再靠猜,改为换地址做隔离实验。改动(server.js,潘总授权):① 入口路径归一化——const pRaw = url.pathname; const p = (pRaw === '/panchengbiao') ? '/api/wecom/msg-crm' : pRaw;,新地址与旧地址走完全相同的验签 / 解密 / 关键词分发 / 被动回复逻辑,零逻辑分叉;② 日志全部打via=来源标记(post raw_len/sign fail/decrypt fail/content四行),命中哪把 Key 同步记key=N;③ 解密失败的完整报文追加落盘到data/wecom-fail-raw.log(此前只记 300 字符截断,无法离线复解),便于事后逐把 Key 试解,不必再让用户反复发消息复现。nginx 无需改动(conf/nginx.conf的 crm server 块location /已反代crm_cloud,/panchengbiao可直达 3000);harden-include.conf攻击特征正则不含该路径,不会被 444。端到端验证(本地模拟真实企微报文,主 Key=CmaaLaEP、corpid=wwd49eea7010ea633b):/panchengbiao与/api/wecom/msg-crm各跑一轮,GET 验签echo_match=true、POST「今日简报」与「今日简报信息」passive_reply=true(被动回包 XML 536 字节)、日志分别出现via=/panchengbiao/via=/api/wecom/msg-crm且key=0(主 Key)——证明同一地址、同一把 Key 下两种短语行为完全一致,代码层不存在「按内容分流」。重启用计划任务CRM-AutoStart生效(3000 端口 PID 29984、首页 HTTP 200)。后续判读(潘总后台把 1000007「接收消息」URL 改为https://crm.shangbon.com/panchengbiao并保存后):若两种短语均正常→原地址确被其他入口复用;若「今日简报」无反应且日志为via=/panchengbiao ... decrypt fail→1000007 实际生效 Key ≠ CmaaLaEP;若日志出现via=/api/wecom/msg-crm ... decrypt fail→实锤另有入口仍在推旧地址。 - 修复【真正根因 · 已根治】企微消息解密失败与 Key 完全无关:企业微信 AES 用 32 字节块 PKCS#7 补位,而 Node
setAutoPadding(true)按 16 字节块校验,补位值落在 17~32 时被误判为bad decrypt。破案路径(推翻此前全部「多 Key / 第二入口」结论):把「解密失败的完整报文」落盘后离线尝试——用主 KeyCmaaLaEP就把那两条「失败」报文完整解开了,明文是标准的<Content><![CDATA[今日简报]]></Content><AgentID>1000007</AgentID>。说明 Key 从头到尾都是对的,是解密方式错了。字节级证据:明文msgLen=292→ 有效载荷16(random)+4(len)+292+18(receiveid)=330字节;330 % 32 = 10 → 补位 22 字节;实测尾部 8 字节1616161616161616(0x16=22),330+22=352正好等于密文长度,100% 吻合 32 字节块。为什么「按内容一刀切」(精确解释):「今日简报」4 字 → 载荷 330 → 补位 22 > 16 → Node 拒绝;「今日简报信息」6 字 → 载荷 336 → 336 % 32 = 16 → 补位 16 ≤ 16 → Node 通过。差 2 个字,补位值恰好跨过 16 这条线,于是长期稳定地「一条必挂、一条必中」。这也解释了此前 NLQ 长句子「有时成功有时失败」——不同文本长度落在不同补位区间。修复(server.js三处):① 新增WECOM_PAD_BLOCK=32+wecomPad()/wecomUnpad()(后者校验补位字节全等,Key 错时仍会抛错,多 Key 容错逻辑不受影响);②wecomDecrypt()改setAutoPadding(false)+wecomUnpad(),并对msgLen做范围校验;③wecomEncrypt()同步改为手工 32 字节块补位 +setAutoPadding(false)(否则被动回复文本长度不同时,企微端同样解不开)。决定性验证:把潘总 13:39:53 / 13:41:07 两条真实失败报文原样重放给修复后的服务 →post content=今日简报 key=0 via=/panchengbiao→briefing ok len=316→passive reply ok,并成功解出 316 字简报正文。结论:与回调 URL、第二应用、微信插件、密钥传播延迟均无关;此前据此做的「加 bD6xMS 容错」「改 URL」属于方向性误判(保留下来的容错与 via 日志无害且有诊断价值)。 - 增强NLQ 全面升级:业务表全字段可查 + 跨表 JOIN(关系图约束)+「我」自动解析 + 排名取首条,修掉「本月成交额最大的客户」答成 ¥0 的错答。问题:问「查询本月成交额最大的客户」返回「成都王经理 ¥0」——该客户一条成交记录都没有。三个根因:① 原 NLQ 只支持 6 张表、每表 10 来个固定字段,
deals只有customer_id,取不到客户名,只能退回客户表读total_deal_amount;② 该累计字段是历史缓存(导入成交不回写,实测大量为 0)且没有时间口径,问「本月」永远套不上;③ 无「我」的语义,也没有「排名第一 → 只取 1 条」。改造(server.js):① schema 改为按 PRAGMA 动态生成——19 张业务表全字段可查(客户/联系人/线索/商机/合同/成交/回款/发票/工单/跟进/任务/拜访/费用/产品/方案/案例/文档/工作汇报/人员),自动隐藏租户、审计、大文本、路径及密码令牌类列,users仅开放id/name/role;② 新增关系图(NLQ_EDGES21 条外键 + owner/follower 自动补边),查询计划用joins:[{table,via}]声明跨表,正反两向均可,模型只能沿既有外键走,无法自造关系;③ 字段一律按「表.列」解析并限定表名,修掉 JOIN 后ambiguous column name: owner_id;④ 新增@me:「我/我的/我负责」自动解析为当前登录用户(企微无登录态时提示改说姓名,不静默出错);⑤ 防行放大:一对多 JOIN 不直接进 FROM,改写为相关标量子查询——实测「每个客户的成交额+工单数」原写法把 19300 放大成 57900(被 3 条工单放大 3 倍),改后与手工 SQL 完全一致;⑥ 统计对象数用COUNT(DISTINCT)(「有工单的客户数」原会返回工单条数,现为 32 个客户);⑦ 客户累计字段改实时子查询,口径与SUM(qty*unit_price)一致。验证(真模型+真库,tools/nlq-probe.js,未触碰生产进程):14 条问句逐一与手工 SQL 比对通过——「我跟进成交第一的客户」山东海德 ¥19,300、「本月成交额最大客户」河南四方达 ¥19,200、「有工单的客户」32 个、「本月回款」¥12,640、「合同金额最大客户」山东豪迈 ¥28,000、「每个客户的合同数+工单数」「各行业客户数」「本月成交额按产品排名」「上季度各阶段商机金额」均正确。前端提示语同步更新(public/app.js,index.html?v=178→179)。改动待重启后生效。 - 新增全局问数挂件:所有页面右下角常驻浮球 → 对话式问数,后端零改动复用
/api/ai/nlq。形态:52px 圆球(深蓝渐变,与「AI 助手→自然语言查数」卡片同色系)→ 展开 440×640 面板,含消息流、快捷问句、输入框;Ctrl/Cmd+K开关、Esc关闭;z-index:45(低于.modal的 50,弹窗时自动让位);移动端铺满全屏。示例:客户详情页打开时,浮球显示黄点、面板顶部出现「📎 正在查看 客户:XXX」,说「这家客户」会自动替换成真实名称,并把替换结果回显在气泡里(可点「不引用」解除)。文件:新增public/styles/modules/nlq-widget.css(独立文件,不改components.css,降低回归风险)、public/app.js新增initNlqWidget()等 8 个函数并在showApp()末尾幂等初始化、public/index.html引入样式(?v=1)与app.js?v=182。踩坑与修复:①api()内部已拼/api前缀,误写成/api/ai/nlq导致请求打到/api/api/ai/nlq返回 404,已改/ai/nlq;② 详情页 hash 会被go()改写为#/模块名、丢掉?id=,只靠 hash 识别上下文会在二次打开面板时误清空,改为「hash 优先 +window._detailCtx兜底 + DOM 含『返回列表』三重判定」。真机验证(headless Edge + 现成 admin 会话,未写库):浮球存在、面板可开、问「本月成交额最大的客户」/api/ai/nlq 200且渲染出表格(河南四方达 ¥19,200)、客户详情页上下文条正确显示「济南贝斯特节能技术有限公司」并生成 3 条专属问句。 - 增强NLQ 覆盖面对齐页面:6 张表 → 30 张业务表,新增成本/毛利口径、多轮上下文、「他/这家客户」指代解析,并对工资与财务流水做角色硬隔离。起因:潘总要求「问数覆盖面要比企微广,成本、费用、工单、绩效、利润、客户回访都要能查」,且要支持「先问当月成交第一的客户,再问他的利润多少」。① 表覆盖(19→30):新增
authorizations(软件授权)/employees/departments/perf_scores/perf_learn/ledger/vendor_prepayments/vendor_shipments/kbdocs/announcements/seals;同步补 9 条关系边(员工→部门、授权→客户、厂商出货→成交/客户、知识库/公告→人、费用→员工等),并补 40 余个中文字段标签(缺标签会退化成英文列名、命中率明显下降)。② 成本/毛利口径:deals新增虚拟字段cost(实施费+商务费+发货价×发货数量)、gross_profit(毛利),customers新增total_profit,公式与profitReport(server.js:1574)逐字一致;prompt 明确禁止模型自行用qty*unit_price-implement_fee推算(会漏商务费与发货成本,系统性高估利润)。抽查 4 条真实成交:6000−2520=3480、12000−6000=6000、16000−9000=7000、11327−5976=5351,全部吻合。③ 多轮上下文:前端只传结构化提示{entity:{label,name}, timeRange, lastQ}(不传整段对话),服务端用确定性正则把「他/她/它/这个客户/第一名那个」替换为上一轮结果首行实体名;本轮无时间词时默认沿用上一轮时间区间;接口回传resolvedQ/ctxNote/entity,气泡里回显「🔗 承接上文」。结果行数 >5 时不锁定实体(避免「各行业客户数」之后指代错乱)。④ 敏感数据硬隔离:perf_scores/perf_learn含工资与系数、employees.salary_base含基本工资、ledger为财务流水——企微群消息人人可见,故按角色在服务端硬拦(不依赖模型自觉):流水与工资类字段仅admin;非管理员查绩效自动强制追加user_id=当前用户;企微等无登录态渠道查绩效直接拒绝并提示到系统内查;employees仅开放岗位/部门/入职日期等非薪资列。验证(真模型+真库,tools/nlq-probe.js,未触碰生产进程):回归 4 条全对(本月最大客户 19200 / 每客户成交额+工单数 / 我跟进第一客户 19300 / 本月回款 12640);新增问法全部通过(本月毛利最高客户 19200、各费用类型金额排名、30 天内到期授权 4 条、各岗位员工人数);多轮实测「当月成交排名第一的客户是谁」→ 河南四方达 →「他的利润多少」→ 同一客户gross_profit=19200;权限实测 manager 问「本月工资合计」→ 拒绝、问「本月流水收支合计」→ 拒绝、问「绩效总分最高员工」→ SQL 中被强制加入perf_scores.user_id = ?、企微问「我的绩效得分」→ 明确拒绝。「本月拜访次数最多的客户」返回 0 行为真实数据(本月无拜访记录),非缺陷。改动待重启后生效。 - 可观测问数日志:谁问的、从哪问的、问了什么、答了什么,后台一处可查(页面挂件 + 企微问数 + 企微简报统一留痕)。改前实况(逐项核实):
ai_logs虽有scene='nlq'记录,但 ① 只存{sql, rows},不存 AI 结论;② 前端无任何入口(public/app.js全文件无ai/logs引用),后台根本看不到;③ 企微分支user_id硬编码为1,分不清是谁问的;④ 权限拒绝 / SQL 报错 / 未查到三类一律不落库(都在写日志之前 return);⑤ 36 条历史记录site全为null,分不清挂件还是企微。改动(server.js):新增nlqLog()统一写入——site记渠道(crm挂件 /wecom企微问数 /wecom_briefing企微简报),response存 JSON(sql / summary / count / understand / resolvedQ / ctxNote / timeRange / rows 前 20,超 12000 字符逐级瘦身,避免存成半截 JSON);新增nlqWecomUser()用users.wecom_userid反查真实姓名(PanChengBiao → 潘成标 id=4),查不到就原样记企微 UserID,不再冒充管理员;成功 /need_time(待补时间口径)/rejected(拒绝)/error(失败)四态全部落库;企微「经营简报」也补记(scene='briefing'),此前只写文本文件data/wecom-bot.log。后台(public/app.js):系统设置新增「📊 问数日志」标签页,仅 admin 可见,支持按来源 / 状态 / 起止日期 / 问题关键字筛选,顶部概览「今日次数 · 提问人数 · 成功率 · 平均耗时」,列表显示时间 / 来源 / 问话人 / 问题 / 结果 / 行数 / 耗时;点任意一行弹出详情:原问题、系统理解、指代消解后的问句、时间口径、生成 SQL、AI 结论、结果明细前 20 行、失败原因。/api/ai/logs同步增强(返回prompt/response解析后的字段,支持scene/site/uid/from/to/kw/limit,scene=nlq时兼容简报)。 - 功能问数结果可直接用起来:导出 Excel(CSV)/ 存为「我的报表」一键重跑 / 打印另存 PDF。① 导出:新增
POST /api/ai/nlq/export,把当前结果转 CSV(UTF-8 BOM,Excel 双击不乱码),对= + - @开头的单元格加前缀防公式注入;只导出当前用户已看到的结果,服务端不重新执行 SQL(不把导出变成第二个查询入口)。② 存为报表:新增表nlq_reports(user_id / name / question / time_from / time_to / run_count / last_run_at),存的是问句与时间口径,不存 SQL——表结构与口径调整后存 SQL 会静默失效,存问句每次重跑都重新走 NLQ 管线,结果永远跟当前数据一致;挂件底部「⭐ 我的报表」列出已存报表,点一下即重跑并累计使用次数,同名去重、只能删自己的。接口:GET/POST /api/ai/nlq/reports、DELETE /api/ai/nlq/reports/:id、POST /api/ai/nlq/reports/:id/touch。③ 打印 / PDF:开新窗口生成浅底黑字表格 + 结论 + 区间信息,Ctrl+P直接另存 PDF(不打印深色挂件面板本身,否则整页黑、无法阅读也费墨)。未做:多问合成一张表(按潘总要求先做性价比最高的三项)。 - 修复时间口径缺失先追问,不再默认按全量答;工单新增「处理时长」字段,让「上周工单数量 + 合计总时长」答得出来。问题:问「上周工单数量是多少,合计总时长多少」答不出总时长——
tickets表只有started_at / resolved_at / closed_at起止时间,没有可求和的时长列,模型只能猜或拒答;同时该问题的时间口径容易只套在「数量」上,时长却按全量算。① 新增虚拟字段tickets.handle_hours(处理时长/小时):(julianday(COALESCE(resolved_at, closed_at)) - julianday(COALESCE(started_at, created_at))) * 24,口径与evalTicketScore()逐字一致(started_at 起算、resolved_at 优先);未结单返回NULL而非 0,SUM/AVG自动忽略,避免未结单把「平均处理时长」拉低。② 时间口径追问:新增nlqNeedTime()——问题本身无时间词、且不属于「全部/累计/历史/当前」这类豁免说法、且指标是金额或时长(或强时间语义表)时,不查库先追问,返回 8 个可点区间(今天/本周/上周/本月/上月/本季度/今年/全部时间);用户点选后,服务端强制把timeField + timeRange注入查询计划(nlqDefaultTimeField()兜底选日期列),不依赖模型是否采纳,保证「数量」与「时长」统计的是同一批工单;企微侧无法点按钮,改为文本提示「请带上时间再问一次」。③ 结果回显时间口径:气泡内新增「🕒 统计区间:2026-10-12 ~ 2026-10-18(按 tickets.created_at)」,口径不再藏在 SQL 里。日志中此类请求单独标记need_time,便于统计「多少人问数不带时间」。 - 权限财务数据收口:成交/合同/回款/发票/费用/流水/厂商预付与出库只允许管理员问,其他角色只能问自己负责的业务数据(客户/线索/商机/工单/跟进等)。背景:潘总 2026-10-18 拍板「销售利润、成交合同这类财务数据只给 Admin,其他人只能问工单、线索、商机」。核对代码发现两处必须一起改:① 原有
NLQ_ADMIN_ONLY_TABLES只挡了ledger一张表;②nlqBuild()生成 SQL 时没有任何基于角色的负责人过滤,而客户/成交/工单列表页早就在用scopeWhere(user, resource)(销售只看自己、经理看本部门)——同一个系统两套口径,等于问数成了权限后门。① 表级:NLQ_FINANCE_TABLES覆盖deals / contracts / deal_payments / invoices / expenses / ledger / vendor_prepayments / vendor_shipments;无权者的 schema 里直接不出现这些表(模型看不到就不会生成计划,比事后拒绝更稳)。② 跨表绕道:主表是业务表、但joins里出现财务表(如「每个客户的成交额和工单数」用客户 join 成交)同样拒绝。③ 字段级:customers的deal_count / total_deal_amount / total_profit(累计口径读的是成交表)与products.price属财务型字段,一并屏蔽。④ 行级:新增nlqScopeCond()复用OWNER_SCOPED+deptMemberIds()(与列表页同一套规则,不另造),销售owner_id=自己、经理owner_id IN (本部门)、tickets保留「无负责人的公共工单人人可见」原口径。⑤ 企微:nlqWecomUser()补取role/username,企微问数与页面走同一套判定;潘成标账号在系统里是manager,直按角色会被自己拦住,故新增NLQ_FINANCE_ALLOW_USERNAMES = ['panchengbiao'](沿用LEDGER_ALLOW_USERNAMES的做法,不改 role,避免连带影响其它模块权限语义);薪酬字段仍只认admin,白名单不自动继承工资权限。⑥ 前端:推荐问句按canFinance()分两套,无权者不再被推「本月成交额最大的客户」这类必被拒的问句(前端判断只用于文案,真正的拦截在服务端)。改动待重启后生效。 - 修复结果被截断不再静默:明确告诉用户「共 M 条,只显示前 N 条」,AI 摘要也不再拿前 20 行当全量下结论。问题:问数结果上限 200 行(
plan.limit,最大 200),超出部分静默丢弃且无任何提示;同时nlqSummarize()只把前 20 行喂给模型。客户 300 家时问「各客户成交额」,用户看到 200 行会当成全部;模型看着金额最高的 20 行说「平均每客户 X 万」——错得很自然,没人会怀疑。改动:① 新增nlqTotalRows(),把原 SQL 去掉LIMIT后外层包一层COUNT(*)取真实条数,接口回传total / truncated;② 气泡内新增琥珀色提示「共 X 条,这里只显示前 N 条……下方的合计、平均值与导出文件都只含这 N 条,不是全量」;③ TopN 不算截断:limit < 20的查询(问「最大客户是谁」本来就只要 1 条)不提示,否则会被误认为结果不全;④ 摘要输入改为「总条数 + 是否截断 + 数值分布(合计/最大/最小)+ 前 10 行」,并在提示里明令禁止把局部说成全量、禁止推算总数;⑤ 企微文本同步(r.truncated才提示)。 - 功能问数支持同比 / 环比 / 占比:能问「本月比上月增长多少」「今年比去年同期怎么样」「谁占大头」。原状:查询计划只有一个
timeRange,只能算单区间一个数,无法两期对比(业内 FineChatBI / Pulse 的标配能力)。实现:① 计划新增compare: "prev" | "yoy" | null,由模型按「比上月/环比 → prev、同比/去年同期 → yoy」判定;规则 18/19 已写入 prompt,并按权限分别给示例(有财务权限用成交示例,无权限用商机/线索示例,避免示例里出现他看不见的表);② 服务端nlqApplyCompare()拿同一份计划换区间重跑一次,再按维度对齐,追加「上期 / 增减 / 增减幅」三列(增减幅带正负号,涨红跌绿);③ 环比区间按月对齐:10/1~10/31 → 9/1~9/30、10/1~10/18(月内至今)→ 9/1~9/18——最初按天数等长前移会算出8/31~9/30,跨了两个月的碎片,口径直接错,已修;同比按「年份 -1、2/29 落到 2/28」处理;④ 占比:有分组且结果未被截断时,自动追加「占比」列(nlqAddShare(),基于当前结果合计);⑤ 页面显示「🔁 对比:上期 2026-09-01 ~ 2026-09-30」,企微文本追加一行「对比期 + 上期值 + 增减幅」(群里只报一句,不逐行刷屏)。验证(副本库 + 临时端口,未碰生产):tools/_verify_nlq_perm_20261018.js19 项全通过、企微加密回调tools/_verify_wecom_log_20261018.js8 项全通过——admin 与潘总可问成交额、高明花问成交/合同/跨表全部被拒且提示「财务数据仅管理员可查询」、高明花问商机与工单正常、viewer 客户数 0 而 admin 404(行级隔离生效)、total/truncated字段正确、环比区间 09-01~09-30、同比 2025-10、增减幅列齐全、占比合计 100%。前端app.js?v=183→184。 - 新增工单知识沉淀 → 官网 FAQ 发布链路:取消逐单弹窗改「候选池 + 两级人工审核 + 类型占位脱敏」(潘总拍板三条:少而准 / 必须人工 / 终审 panchengbiao)。起因与实测:潘总问「工单结单时提示沉淀知识写个方案对么?能否加工后自动发到官网 FAQ」。先做只读取证(
data/crm.db):工单 63 条、全部已结单(已解决 57 / 已关闭 6)、solution全有但 ≥100 字的仅 26 条;faqs59 active + 76 deprecated,来源只有 manual / xlsx_import,工单来源 0 条——「工单→FAQ→待一审」这条链路一次都没跑通。三个断点:①db.js:115与db.js:1488建表/补列ai_corpus_enabled INTEGER DEFAULT 0,而app.js:160前端字段default:1、server.js原注释写「全量默认开启」——三者矛盾,实际 52/63 条工单被开关挡死;② 归档提示条只在打开已结单工单详情页时出现(app.js:5264),与「点保存结单」那一秒脱钩;③ 生成后停在pending_review,CRM 到 MetInfo 之间零对接代码(全项目搜metinfo/met_无命中)。形态决策(推翻原「结单即弹窗」方案):63 条只筛出 6 条够格,逐单弹 = 57 次无效打扰,最后一定被无脑点掉;故门槛放在筛选、不放在提示,改为「系统挑好、人挑剩」。① 数据层(db.js):faqs新增 6 列承载「对外发布」维度——visibility(internal/public)、public_status(draft→tech_ok→pm_ok→published/unpublished)、reviewed_by、approved_by、published_url、published_at;建表 DDL 同步补,另加幂等迁移块migrateFaqPublicFields()(db.js:1526)。② 候选池(server.js:4922):新增GET /api/kb/public-candidates,按 5 条硬门槛过全部工单并返回淘汰原因——结果确定(无「暂时关闭/等待反馈/已超期」)、方案 ≥100 字且 ≥1 个步骤词、无敏感信息、无友商品牌(华途)、无异常内容(solution >50000 字符,防 base64 污染)。实测 63 → 6:通过[105,103,101,82,80,78];淘汰分布「步骤或篇幅不足 48 / 含敏感信息 14 / 含友商品牌 5 / 问题未闭环 4 / 已归档为案例 4 / 内容异常 1」。③ 对外流转(server.js:4959):新增POST /api/kb/faqs/:id/public-review,五个动作mark_public / tech_ok / pm_ok / published / unpublished。权限设计:二审限manager/admin;终审与发布登记硬编码user.id === 4——因为manager还有高明花/李丹/刘振,只判 role 会让他们也能终审。mark_public前先过脱敏门禁,不过则 400 拒掉。④ 队列(server.js:5007):新增GET /api/kb/public-queue,按流转阶段排序,回传can_pm供前端显隐按钮。⑤ 脱敏重构为「类型占位」(server.js:4225,潘总要求):账号→账号、密码→密码、IP→IP、端口→端口、手机号→手机号、邮箱→邮箱。顺序有讲究:必须先做 IP(含端口),否则「账号:密码」组合模式会把192.168.1.203:9444吃成「账号:密码」。三轮误伤修复(都拿真实工单文本验过):① 组合凭据右侧原本不挑内容,把 Unix 路径/var/cache/nginx/proxy_temp/和列举符号off; + access_log当成账号/密码——正是最有对外价值的 #105 被误杀的原因;改为右侧必须含数字或@!$%^&*。② 左侧原本不挑内容,端口列举9296/9297/9299被当成账号/密码;改为左侧必须含字母。③ 右侧字符类含.会把句末句点一起吃掉;端口只替换有明确上下文的(端口8443/8444端口/:8443/端口默认8443),且冒号前不能是数字以避开 12:30 这类时间,模块号 126、授权数 556 一律不碰(宁漏勿错)。⑥ 顺带修的 bug:server.js:4907查重只认status='active',草稿已生成未发布时会重复提示归档、点进去又生成一份重复草稿;改为IN ('active','pending_review')。⑦ 口径对齐:ai_corpus_enabled保持默认 0 不变(按「少而准」),app.js:160前端 default 由 1 改 0 与库一致,server.js:4990注释改为「显式 =1 才纳入」,消除「注释说全开、数据实际全关」的矛盾。⑧ 前端(public/app.js):知识库新增「🌐 官网候选池」Tab(renderKbPublicCandidates+kbPublicFaqForm,约app.js:9318/9437)——上半候选工单(含淘汰原因折叠明细、脱敏后原文摘要),下半对外队列(按状态显示二审/终审/登记发布/撤回按钮,can_pm=false时显示「待 panchengbiao 终审」);移除工单详情页「本工单可归档到知识库」顶部 banner(原app.js:5264),改为上述 Tab 承接,手动归档入口「📚 归档知识库」按钮保留。⑨ 验证(副本库 + 随机高端口临时实例,未碰生产进程、副本 webhook 置空):tools/_verify_kb_public_pipeline.js47 项断言全通过——无 token 打新路由返 401(证明路由真挂着)、admin 与 manager 双身份候选池口径一致、viewer 拉队列 403、draft→tech_ok→pm_ok→published全链路、admin 与其他 manager 调pm_ok均 403、缺 URL / 非 http(s) URL 登记被拒 400、含nas/Admin@123与192.168.1.203:9444的 FAQ 被脱敏门禁 400 拦下、库终值public_status=published & approved_by=4、生产库查不到本次验证指纹;tools/_verify_kb_anonymize.js38 项脱敏断言全通过(含 4 类不得误伤:nginx 路径 / 端口列举 / 模块号 / 时间)。已生效:2026-10-18 21:45 经潘总口头授权重启(停 PID 25208 → 手动带齐环境变量拉起,DEEPSEEK key 走库内ai.api_key加密存储不依赖环境变量;计划任务CRM-AutoStart已不在本机任务列表,重启脚本会自动回退start /B)。复验:两个新路由无 token 返 401(证明路由真挂着)、首页 /app.js?v=185/ 公网crm.shangbon.com均 200、faqs6 列迁移到位。待办(潘总留口后补):官网自动发布通道——实测www.shangbon.com出口为 47.104.176.227(另一台 ECS,nginx+PHP 7.2.34+MetInfo),从本机 22 超时 / 3306 过滤 //admin/403,三通道均不通;需潘总提供 SSH 凭据(安全组放行 22)或宝塔面板,拿到后按outputs/官网FAQ自动发布-接入说明.md实施(写met_help表 → 静态化 → 回写 URL)。 - 修复点「🌐 官网候选池」Tab 整个 CRM 卡死 → 图片与正文分离三层改造 + 知识库 Tab 左栏空白修复。报障与根因:潘总报「只要点击知识库首选 tab,web 就卡死」。只读取证:#39(GD-260829-003「pdm问题」,刘振经手)的
solution字段长达 1,754,861 字符——正文其实只有「先登录SQL数据库 / 新建查询-输入框中命令,点击执行」一句话,其余全是 2 个内联 base64 截图(富文本编辑器粘贴截图时fileToB64后直接insertImage内联,app.js:6083旧行为)。而候选池接口对全部工单无差别跑脱敏/检测正则,百万字符连过六七个大正则 → event loop 阻塞数秒,症状为进程活着、端口在听、所有请求 6-8 秒超时(看门狗 22:30 实测抓到并自动恢复)。潘总决策(三层一起做):「工单有图片很正常,甚至会有视频,不能直接就这样处理,卡死系统」。① 接口层剥离(server.js):新增kbStripInlineImages()——data:image/...;base64,XXX→[图片]占位(base64 字符集不含< > " ',贪婪匹配天然在标签/引号处停下,线性扫描无回溯风险,实测 175 万字符 4ms);kbAnonymize入口先剥再脱敏;kbHasSensitive判据改为「先剥离再比较脱敏前后」——否则带图正文一律「前后不一致」,会把所有带图工单误判敏感全拦下。候选池改按剥离后正文判质量(#39 正文仅 93 字符,淘汰原因变为准确的「步骤或篇幅不足」);门槛回归 v2 定稿设计值 80 字(实施时误写 100,误杀 #78「加密狗掉线」97 字含 docker 恢复命令、#101「init.htm 删除」93 字完整排查路径);补「等待厂家/待厂家」进问题未闭环词(#31「等厂家清缓存」漏网修正);撤掉此前加的 archive 接口 6 万字符拒绝闸(潘总指出「一带图就拒」是把正常业务当异常)。② 存量迁移(tools/_migrate_inline_images.js,一次性):VACUUM INTO 全库备份 → 只读扫描 tickets/cases/faqs/activities 全部正文字段 → 全库仅 #39 一条中招,2 张图提取落盘uploads/ticket_imgs/tickets/39/,正文替换为<img src="/files/ticket-img/tickets/39/xxx.png">,1,754,861 → 201 字符;原值备份backups/inline-img-migration-20261018145902/original-values.json可整体回滚;sha256 逐张比对落盘文件与原 base64 解码内容,无损一致;复核全库 base64 残留 0。③ 编辑器防新增(app.jscreateRichText paste):粘贴截图改为 POST /api/inline-images 先落盘(uploads/inline/)再插 URL,不再内联 base64;插入的 img 强制max-width:100%防大图撑破布局;工具条提示补「视频请用附件上传」。④ 知识库 Tab 左栏空白修复:潘总截图报「待一审和候选池页面都有这个(空白)是什么」。根因:renderKb两栏布局里左栏kb-side(分类树)的渲染条件是_kbTab!=='faqs'(app.js:9071),但分类树只有 docs(分类浏览)和 cases(按客户浏览)两个 tab 有内容可填,review/public 两个功能型 tab 在load()里提前 return、左栏永远为空 → 空 div 占一大块。修复:条件收窄为docs||cases。⑤ 两个配套修复(不放行就等于白迁移):sanitizeHtml的 src 白名单原本只认data:image/...;base64,和https?://,/files/ticket-img/相对路径会被直接丢弃、详情页图全没——已放行/files/前缀并自动拼接?token=(<img>请求带不了 Authorization header,与 ticket_images 同模式);新增登录态路由POST /api/inline-images、GET /files/inline/<file>、GET /files/ticket-img/<table>/<rowid>/<file>(含路径穿越防护)。踩坑记录:文件下载路由最初误插在p.startsWith('/api/')大区块内部(server.js:3332),而/files/...不是/api/开头永远不执行,请求掉进server.js:9881的/files/兜底 404——已移至正确的/files/区块;教训:CRM 路由分 /api/ 与 /files/ 两个区块,注册位置比路由逻辑更易错。⑥ 验证:脱敏单测 46/46(新增 8 条 base64 用例:img src/单引号/裸 dataurl/混合口令/jpeg/webp/纯图不判敏感/图埋不住口令);端到端 47/47;无头浏览器(playwright-core + 系统 Edge)四 Tab 布局断言全过(待一审/候选池无左栏、文档库/案例库有左栏)+ #39 两张迁移图真实加载(554×364 / 553×229,token 拼接生效);候选池接口生产实测 32ms(原卡死)、inline 上传→访问 200、存量图访问 200(808,373 字节)。候选口径现状:80 字门槛下 12 条过筛(#106/#105/#103/#101/#100/#94/#82/#80/#79/#78/#75 + #31 修复后剔除),人工抽查确认 5 条高价值(#106 v6 策略下发、#94 手机端映射端口、#75 加密狗+策略等)、2 条流水账(#100 电源烧了、#79 实施记录)——系统筛宽度、人工定深度,流水账不选即可;另请潘总裁定 #94 出现工具名「金万维」是否违反「仅上邦品牌」规约。前端app.js?v=186。当前 CRM 带看门狗监护运行(卡死自动恢复,实测 16 秒)。