verify-dms-payload-fields.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. /**
  2. * 验证写 DMS 时**到底发了哪些字段**(不需要 token,也不碰真实 DMS)。
  3. *
  4. * 为什么要单独测这个:写入字段的规矩是**踩出来的**——
  5. * 1887 的模型把 summary/qpyszx/qyzt/rzqpyx 设为 must=true(不带就 214),
  6. * 但它们**只能传空值**(业务口径由业务方定,前端不猜);而 1889 不需要这些字段。
  7. * 多发/少发字段没有直观的行为反馈、极易被"顺手"改掉,所以直接把请求体抓下来断言。
  8. *
  9. * 做法:把 `globalThis.fetch` 打桩,捕获模块真实发出的 form body,
  10. * 解析出 `content` 的键集合,断言「不多不少」。
  11. *
  12. * 怎么跑(在项目根目录):
  13. * npx esbuild harness/tools/_entry-dms.ts --bundle --format=esm \
  14. * --outfile=harness/tools/_dms.mjs --alias:@=./src --define:import.meta.env='{}'
  15. * node harness/tools/verify-dms-payload-fields.mjs
  16. */
  17. globalThis.VITE_DMS_API = 'http://dms.invalid/dms'; // 只为让 base 非空;请求会被打桩拦住
  18. globalThis.VITE_GATEWAY_API = '/ip-api'; // 后端网关(取 IP / NLS);下面同样打桩
  19. const STUB_IP = '203.0.113.9'; // TEST-NET-3 段,不会与真实 IP 混淆
  20. // ── 打桩 fetch:捕获请求,返回可控响应 ────────────────────────────────
  21. const captured = [];
  22. const json = (obj) => new Response(JSON.stringify(obj), { status: 200, headers: { 'Content-Type': 'application/json' } });
  23. globalThis.fetch = async (url, init = {}) => {
  24. const body = init.body ? String(init.body) : '';
  25. captured.push({ url: String(url), body });
  26. // 取 IP 的接口:返回**纯文本 IP**(真实服务也是裸 IP,不带 JSON)
  27. if (String(url).includes('/proxy/verify/getIPByBrowser')) {
  28. return new Response(STUB_IP, { status: 200, headers: { 'Content-Type': 'application/json' } });
  29. }
  30. if (String(url).includes('/content/selectContentList')) {
  31. return json({ code: 202, message: '数据不存在', content: '数据不存在' }); // 空 → 走新增分支
  32. }
  33. if (String(url).includes('/content/addContent')) {
  34. return json({ code: 200, message: '成功', content: 'stub-uuid-0001' });
  35. }
  36. if (String(url).includes('/content/updateContent')) {
  37. return json({ code: 200, message: '成功', content: '执行成功' });
  38. }
  39. return json({ code: 200, message: '成功', content: '' });
  40. };
  41. import { readFileSync } from 'node:fs';
  42. const { upsertDmsSession, upsertDmsRecord } = await import('./_dms.mjs');
  43. let pass = 0;
  44. let fail = 0;
  45. const check = (name, cond, extra = '') => {
  46. if (cond) {
  47. pass++;
  48. console.log(' ok ' + name);
  49. } else {
  50. fail++;
  51. console.log(' FAIL ' + name + ' ' + extra);
  52. }
  53. };
  54. /** 取出最近一次某端点的请求体里 content 的字段名(排序后) */
  55. const keysOfLast = (endpoint) => {
  56. const hit = [...captured].reverse().find((c) => c.url.includes(endpoint));
  57. if (!hit) return null;
  58. const params = new URLSearchParams(hit.body);
  59. const content = JSON.parse(params.get('content') || '{}');
  60. return Object.keys(content).sort();
  61. };
  62. const DISALLOWED = ['summary', 'qpyszx', 'qyzt', 'rzqpyx'];
  63. console.log('\n【1】会话新增:字段必须「不多不少」');
  64. await upsertDmsSession({ sessionId: 'sess-A', sessionTitle: '会话标题甲', creditCode: '91310118TEST' });
  65. let keys = keysOfLast('/content/addContent');
  66. const EXPECT_SESSION_ADD = [
  67. 'c_created_at', 'c_credit_code', 'c_session_id', 'c_source', 'c_updated_at',
  68. // 访客标识(2026-09-22 起进 c_ip)与登录标记
  69. 'c_ip', 'c_is_login',
  70. // DMS 的系统字段:只在创建那一刻带,不带会落成字符串 "null"
  71. 'title',
  72. // 1887 的模型把这四个设为 must=true,四个全带才写得进去(少一个就 214);
  73. // 值故意留空 —— 运营口径由业务方定,前端不猜
  74. 'summary', 'qpyszx', 'qyzt', 'rzqpyx',
  75. ].sort();
  76. check('字段集合与预期完全一致', JSON.stringify(keys) === JSON.stringify(EXPECT_SESSION_ADD), `实际 ${JSON.stringify(keys)}`);
  77. check('**带系统 title**(不带会落成字符串 "null")', keys?.includes('title') === true);
  78. check('**不带 c_title**(用户 2026-09-22:标题由运营侧生成)', keys?.includes('c_title') !== true, JSON.stringify(keys));
  79. console.log('\n【2】会话更新:不碰标题、不带运营字段、已有值不重写');
  80. captured.length = 0;
  81. // 让这次走「更新」分支:search 返回一行,且 c_ip / c_is_login 都已有值
  82. const existingRow = { id: 'row-uuid', c_session_id: 'sess-A', c_ip: '访客_既有值', c_is_login: '0', c_ent_name: '甲测试公司' };
  83. globalThis.fetch = async (url, init = {}) => {
  84. const body = init.body ? String(init.body) : '';
  85. captured.push({ url: String(url), body });
  86. if (String(url).includes('/content/selectContentList')) {
  87. return json({ code: 200, message: '成功', content: { data: [existingRow], count: 1 } });
  88. }
  89. return json({ code: 200, message: '成功', content: '执行成功' });
  90. };
  91. await upsertDmsSession({ sessionId: 'sess-A', sessionTitle: '会话标题乙', creditCode: '91310118TEST' });
  92. keys = keysOfLast('/content/updateContent');
  93. check('更新走的是 updateContent', keys !== null);
  94. check('**更新不带 title / c_title**(前端不碰标题)', !keys?.includes('title') && !keys?.includes('c_title'), JSON.stringify(keys));
  95. check('**更新不带这四个字段**(否则会把后端回填的值冲成空)', !DISALLOWED.some((k) => keys?.includes(k)), JSON.stringify(keys));
  96. check('**c_ip 已有值 → 不重写**(可能是后端回填的真实 IP)', !keys?.includes('c_ip'), JSON.stringify(keys));
  97. check('**c_is_login 已有值 → 不重写**', !keys?.includes('c_is_login'), JSON.stringify(keys));
  98. console.log('\n【2b】值不同就更新 / 值相同不写');
  99. {
  100. await upsertDmsSession({
  101. sessionId: 'sess-A',
  102. sessionTitle: '会话标题乙',
  103. creditCode: '91310118TEST',
  104. companyInfo: { profile: { data: { Name: '乙测试公司', CreditCode: '91310118CHAT' } } },
  105. });
  106. keys = keysOfLast('/content/updateContent');
  107. check('**值不同 → 企业名称写进 patch**', keys?.includes('c_ent_name'), JSON.stringify(keys));
  108. check('**值不同 → 真实信用代码写进 patch**', keys?.includes('c_credit_code'), JSON.stringify(keys));
  109. existingRow.c_ent_name = '乙测试公司';
  110. existingRow.c_credit_code = '91310118CHAT';
  111. await upsertDmsSession({
  112. sessionId: 'sess-A',
  113. sessionTitle: '会话标题乙',
  114. companyInfo: { profile: { data: { Name: '乙测试公司', CreditCode: '91310118CHAT' } } },
  115. });
  116. keys = keysOfLast('/content/updateContent');
  117. check('**值相同 → 不写 c_ent_name**(不产生无谓写入)', !keys?.includes('c_ent_name'), JSON.stringify(keys));
  118. check('**值相同 → 不写 c_credit_code**', !keys?.includes('c_credit_code'), JSON.stringify(keys));
  119. }
  120. console.log('\n【2c】登录认领过的行:对话值不许改它的公司字段');
  121. {
  122. // 否则:用户登录过、之后退登又问到别的公司 → 这行会被改到那家公司名下,
  123. // 而 c_is_login 仍是 "1" → 既串台、又会被读侧当登录行返回(用户 2026-09-22 要挡住的)
  124. existingRow.c_is_login = '1';
  125. existingRow.c_credit_code = 'LOGIN-CODE-0001';
  126. await upsertDmsSession({
  127. sessionId: 'sess-A',
  128. sessionTitle: '会话标题乙',
  129. companyInfo: { profile: { data: { Name: '别家公司', CreditCode: 'ANOTHER-CODE' } } },
  130. });
  131. keys = keysOfLast('/content/updateContent');
  132. check('**不写 c_ent_name**(登录认领过的行)', !keys?.includes('c_ent_name'), JSON.stringify(keys));
  133. check('**不写 c_credit_code**', !keys?.includes('c_credit_code'), JSON.stringify(keys));
  134. }
  135. console.log('\n【3】问答记录新增:title = 问题文本,且不多发字段');
  136. captured.length = 0;
  137. globalThis.fetch = async (url, init = {}) => {
  138. const body = init.body ? String(init.body) : '';
  139. captured.push({ url: String(url), body });
  140. if (String(url).includes('/content/selectContentList')) return json({ code: 202, message: '数据不存在', content: '数据不存在' });
  141. if (String(url).includes('/content/addContent')) return json({ code: 200, message: '成功', content: 'stub-uuid-0002' });
  142. return json({ code: 200, message: '成功', content: '' });
  143. };
  144. await upsertDmsRecord({ recordId: 'rec-A', sessionId: 'sess-A', creditCode: '91310118TEST', question: '这是问题文本' });
  145. keys = keysOfLast('/content/addContent');
  146. const EXPECT_RECORD_ADD = ['c_created_at', 'c_credit_code', 'c_question', 'c_record_id', 'c_session_id', 'c_source', 'title'].sort();
  147. check('字段集合与预期完全一致', JSON.stringify(keys) === JSON.stringify(EXPECT_RECORD_ADD), `实际 ${JSON.stringify(keys)}`);
  148. check('不含 summary 等多余字段', !DISALLOWED.some((k) => keys?.includes(k)), JSON.stringify(keys));
  149. const hit = [...captured].reverse().find((c) => c.url.includes('/content/addContent'));
  150. const content = JSON.parse(new URLSearchParams(hit.body).get('content'));
  151. check('**title 的值就是问题文本**', content.title === '这是问题文本', JSON.stringify(content.title));
  152. console.log('\n【4】会话写入(1887)只在回答完成处发生一次 —— 源码级断言');
  153. {
  154. // 2026-09-22:1887 改为写企业字段,触发点**只有一处** —— 回答完成的 totalResponse
  155. // (那时才知道本轮有没有 company_info)。
  156. // 曾经在 sendMessage / submitQuestionAnswers 里也各写一次,那会让同一次会话写两遍,
  157. // 极易在后续改动里被顺手恢复,所以在源码上钉死。
  158. const file = 'src/components/business-assistant/useBusinessAssistantChat.ts';
  159. const src = readFileSync(file, 'utf8');
  160. const lines = src.split('\n');
  161. const live = lines
  162. .map((l, i) => ({ l, n: i + 1 }))
  163. .filter(({ l }) => !l.trim().startsWith('//') && /upsertDmsSession\s*\(/.test(l));
  164. check('活跃调用点只有 1 处', live.length === 1, live.map(({ l, n }) => `第${n}行: ${l.trim()}`).join(' | '));
  165. // 它必须在 totalResponse 监听器之后、下一个 addEventListener 之前
  166. const at = live.length ? live[0].n - 1 : -1;
  167. const totalResponseAt = lines.findIndex((l) => l.includes("addEventListener('totalResponse'"));
  168. const nextListenerAt = lines.findIndex((l, i) => i > totalResponseAt && l.includes("addEventListener('"));
  169. check(
  170. '那一处在 totalResponse 监听器内(回答完成时才写)',
  171. at > totalResponseAt && (nextListenerAt === -1 || at < nextListenerAt),
  172. `调用@第${at + 1}行 / totalResponse@第${totalResponseAt + 1}行 / 下一个监听器@第${nextListenerAt + 1}行`
  173. );
  174. }
  175. console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
  176. process.exit(fail ? 1 : 0);