verify-dms-chat-storage.mjs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. /**
  2. * 端到端验证「会话 / 问答记录 / 反馈」在 DMS 里的读写(栏目 1887 / 1889)。
  3. *
  4. * 存储粒度约定(2026-09-17 用户最终确认):
  5. * - 1887 助手会话:一个会话一行(标题、归属、更新时间)
  6. * - 1889 助手问答记录:**一问一答一条**(幂等键 c_record_id),
  7. * c_question / c_answer 各存一半;反馈写在该行的 c_feedback_* 列上
  8. * (曾一度改成「一个会话一行、整段对话 JSON」,按用户要求已改回本版)
  9. *
  10. * 验证什么:前端真实调用的那些函数(引用 src 里同一份实现,不是复制逻辑)
  11. * 打到真实 DMS 上能否正确 增/改/查/删,尤其是:
  12. * ① 反复保存同一轮**只有一行**(不是每问一次多一行)
  13. * ② 补 answer 时不覆盖 question、不变成两行
  14. * ③ 反馈能命中该轮那一行
  15. *
  16. * 怎么跑(需要 dev server 起着,代理会注入 token):
  17. * 1) npm run dev
  18. * 2) npx esbuild harness/tools/_entry-dms.ts --bundle --format=esm \
  19. * --outfile=harness/tools/_dms.mjs --alias:@=./src --define:import.meta.env='{}'
  20. * 3) node harness/tools/verify-dms-chat-storage.mjs https://localhost:8083/dms-api
  21. *
  22. * 脚本**不持有 token**——和浏览器一样只打代理地址。
  23. */
  24. process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
  25. const base = process.argv[2] || 'https://localhost:8083/dms-api';
  26. globalThis.VITE_DMS_API = base;
  27. // 取本机 IP 的接口(DMS 访客归属用)。与浏览器同一条代理路径:页面是 HTTPS、接口是 HTTP,
  28. // 直连会被混合内容拦掉。node 里没有这个限制,但走代理更接近真实链路。
  29. globalThis.VITE_IP_API = '/ip-api';
  30. // 取 IP 接口**打桩**(TEST-NET-3 段,不会与真实 IP 混淆):
  31. // 真实服务已用 curl 验过(`https://localhost:8083/ip-api/proxy/verify/getIPByBrowser` 返回本机 IP),
  32. // 但这支测试要的是**确定性** —— 依赖外部服务会让断言偶发失败(实测抖过一次)。
  33. const STUB_IP = '203.0.113.9';
  34. const realFetch = globalThis.fetch;
  35. globalThis.fetch = async (url, init) => {
  36. if (String(url).includes('/proxy/verify/getIPByBrowser')) {
  37. return new Response(STUB_IP, { status: 200, headers: { 'Content-Type': 'application/json' } });
  38. }
  39. return realFetch(url, init);
  40. };
  41. const {
  42. upsertDmsSession,
  43. upsertDmsRecord,
  44. fetchDmsSessions,
  45. fetchDmsSessionRecords,
  46. deleteDmsSession,
  47. writeDmsFeedback,
  48. resolveDmsCreditCode,
  49. parseDmsTimestamp,
  50. nowDmsTimestamp,
  51. searchDmsContents,
  52. deleteDmsContent,
  53. DMS_COLUMN_SESSION,
  54. DMS_COLUMN_RECORD,
  55. enterpriseInfo,
  56. resolveVisitorLabel,
  57. } = await import('./_dms.mjs');
  58. let pass = 0;
  59. let fail = 0;
  60. const check = (name, ok, extra = '') => {
  61. if (ok) {
  62. pass++;
  63. console.log(' ok ' + name);
  64. } else {
  65. fail++;
  66. console.log(' FAIL ' + name + ' ' + extra);
  67. }
  68. };
  69. const TEST_CREDIT = `verify_cc_${Date.now().toString(36)}`;
  70. const SESSION_ID = `verify_sess_${Date.now().toString(36)}`;
  71. const REC_1 = 'rec1_' + Date.now().toString(36);
  72. const REC_2 = 'rec2_' + Date.now().toString(36);
  73. const TITLE_A = '验证会话标题';
  74. const TITLE_B = '验证会话标题(改过)';
  75. const Q1 = '高新技术企业的奖励是多少?';
  76. const A1 = '<!-- POLICY_TABLE {"data":[]} -->\n<scope title="思考中">\n</scope>\n第一轮回答。';
  77. const Q2 = '那申报条件呢?';
  78. const A2 = '第二轮回答,带换行。\n第二行。';
  79. console.log(`\nDMS 会话/问答记录/反馈 端到端验证\n 代理地址: ${base}\n 测试标记: ${TEST_CREDIT}\n`);
  80. const rowsForSession = () =>
  81. searchDmsContents(DMS_COLUMN_RECORD, {
  82. search: [{ field: 'c_session_id', searchType: 1, content: { value: SESSION_ID } }],
  83. page: 0,
  84. pageSize: 50,
  85. });
  86. const cleanup = async () => {
  87. await deleteDmsSession(SESSION_ID);
  88. for (const col of [DMS_COLUMN_SESSION, DMS_COLUMN_RECORD]) {
  89. const rows = await searchDmsContents(col, {
  90. search: [{ field: 'c_credit_code', searchType: 1, content: { value: TEST_CREDIT } }],
  91. page: 0,
  92. pageSize: 50,
  93. });
  94. for (const row of rows) if (row.id) await deleteDmsContent(col, String(row.id));
  95. }
  96. };
  97. try {
  98. // ── 1. 会话行 ──────────────────────────────────────────────────────
  99. // ⚠️ 先置登录态:1887 的读取会**排除访客行**(c_is_login="0"),
  100. // 这一段测的是「企业自己的会话」,所以照登录态造数据。
  101. console.log('【1】会话(1887)');
  102. enterpriseInfo.value = { credit_code: TEST_CREDIT, name: '验证企业' };
  103. check('upsertDmsSession 新增', (await upsertDmsSession({ sessionId: SESSION_ID, sessionTitle: TITLE_A, creditCode: TEST_CREDIT })) === true);
  104. let sessions = await fetchDmsSessions(TEST_CREDIT);
  105. check('列表能查到', sessions.some((s) => s.session_id === SESSION_ID));
  106. check('updated_at 合理', Math.abs((sessions.find((s) => s.session_id === SESSION_ID)?.updated_at || 0) - Date.now()) < 10 * 60 * 1000);
  107. // 标题规则(用户 2026-09-22):**c_title 不写**(标题由运营侧生成);
  108. // DMS 系统字段 `title` 只在创建那一刻带上会话标题(不写会落成字符串 "null",1889 踩过)。
  109. let rowSession = (await searchDmsContents(DMS_COLUMN_SESSION, {
  110. search: [{ field: 'c_session_id', searchType: 1, content: { value: SESSION_ID } }],
  111. page: 0,
  112. pageSize: 1,
  113. }))[0];
  114. check('创建时带上了系统 title', rowSession?.title === TITLE_A, JSON.stringify(rowSession?.title));
  115. check('**c_title 不写**(前端不碰标题)', !rowSession?.c_title, JSON.stringify(rowSession?.c_title));
  116. await upsertDmsSession({ sessionId: SESSION_ID, sessionTitle: TITLE_B, creditCode: TEST_CREDIT });
  117. sessions = await fetchDmsSessions(TEST_CREDIT);
  118. check('再次 upsert 只有一行', sessions.filter((s) => s.session_id === SESSION_ID).length === 1);
  119. rowSession = (await searchDmsContents(DMS_COLUMN_SESSION, {
  120. search: [{ field: 'c_session_id', searchType: 1, content: { value: SESSION_ID } }],
  121. page: 0,
  122. pageSize: 1,
  123. }))[0];
  124. check('**再 upsert 也不写 c_title**', !rowSession?.c_title, JSON.stringify(rowSession?.c_title));
  125. check('系统 title 保持创建时的值(只在创建那一刻初始化)', rowSession?.title === TITLE_A, JSON.stringify(rowSession?.title));
  126. // ── 2. 粒度:一问一答一条 ──────────────────────────────────────────
  127. console.log('\n【2】问答记录(1889)—— 粒度必须是「一问一答一条」');
  128. await upsertDmsRecord({ recordId: REC_1, sessionId: SESSION_ID, creditCode: TEST_CREDIT, question: Q1 });
  129. let rows = await rowsForSession();
  130. check('第 1 轮提交后:1 行', rows.length === 1, `实际 ${rows.length} 行`);
  131. check('c_question 正确', rows[0]?.c_question === Q1, JSON.stringify(rows[0]?.c_question));
  132. check('c_record_id 存的是该轮标识', rows[0]?.c_record_id === REC_1, String(rows[0]?.c_record_id));
  133. check('此时 c_answer 为空', !rows[0]?.c_answer, JSON.stringify(rows[0]?.c_answer));
  134. // 回答完成 → 补 answer,不能变成两行、不能冲掉 question
  135. await upsertDmsRecord({ recordId: REC_1, sessionId: SESSION_ID, creditCode: TEST_CREDIT, answer: A1 });
  136. rows = await rowsForSession();
  137. check('补 answer 后仍是 1 行', rows.length === 1, `实际 ${rows.length} 行`);
  138. check('answer 完整往返(含协议标记)', rows[0]?.c_answer === A1, `长 ${String(rows[0]?.c_answer || '').length}/${A1.length}`);
  139. check('question 未被覆盖', rows[0]?.c_question === Q1, JSON.stringify(rows[0]?.c_question));
  140. // 第 2 轮 → 应该是**新的一行**(一问一答一条)
  141. await upsertDmsRecord({ recordId: REC_2, sessionId: SESSION_ID, creditCode: TEST_CREDIT, question: Q2 });
  142. await upsertDmsRecord({ recordId: REC_2, sessionId: SESSION_ID, creditCode: TEST_CREDIT, answer: A2 });
  143. rows = await rowsForSession();
  144. check('第 2 轮后:2 行(一问一答一条)', rows.length === 2, `实际 ${rows.length} 行`);
  145. // ── 3. 历史读回 ───────────────────────────────────────────────────
  146. console.log('\n【3】历史读回(按创建时间正序)');
  147. const records = await fetchDmsSessionRecords(SESSION_ID);
  148. check('读出两轮', records.length === 2, `实际 ${records.length} 轮`);
  149. check('第一轮 question/answer 正确', records[0]?.question === Q1 && records[0]?.answer === A1);
  150. check('第二轮 question/answer 正确', records[1]?.question === Q2 && records[1]?.answer === A2);
  151. check('record_id 回填(反馈闭环)', records[0]?.record_id === REC_1 && records[1]?.record_id === REC_2, `${records[0]?.record_id} / ${records[1]?.record_id}`);
  152. check('created_at 被解析成合理时间戳', Math.abs((records[0]?.created_at || 0) - Date.now()) < 10 * 60 * 1000, String(records[0]?.created_at));
  153. // ── 4. 反馈:写在该轮那一行 ────────────────────────────────────────
  154. console.log('\n【4】反馈(写在第 2 轮那一行)');
  155. await writeDmsFeedback({ recordId: REC_2, sessionId: SESSION_ID, status: 2, option: '没有回答我的问题', remark: '验证备注' });
  156. rows = await rowsForSession();
  157. const target = rows.find((r) => r.c_record_id === REC_2);
  158. const untouched = rows.find((r) => r.c_record_id === REC_1);
  159. check('行数不变(仍 2 行)', rows.length === 2, `实际 ${rows.length} 行`);
  160. check('c_feedback_status = 2', Number(target?.c_feedback_status) === 2, String(target?.c_feedback_status));
  161. check('c_feedback_option 正确', target?.c_feedback_option === '没有回答我的问题', JSON.stringify(target?.c_feedback_option));
  162. check('c_feedback_remark 正确', target?.c_feedback_remark === '验证备注', JSON.stringify(target?.c_feedback_remark));
  163. check('c_feedback_at 已写入', !!target?.c_feedback_at, String(target?.c_feedback_at));
  164. check('**另一轮未被误改**', untouched && untouched.c_feedback_status == null, JSON.stringify(untouched?.c_feedback_status));
  165. check('问答内容未被反馈覆盖', target?.c_question === Q2 && target?.c_answer === A2);
  166. await writeDmsFeedback({ recordId: REC_2, sessionId: SESSION_ID, status: 0 });
  167. rows = await rowsForSession();
  168. check('取消反馈 status=0', Number(rows.find((r) => r.c_record_id === REC_2)?.c_feedback_status) === 0);
  169. const before = (await rowsForSession()).length;
  170. await writeDmsFeedback({ recordId: 'not_exist_rec', sessionId: SESSION_ID, status: 1 });
  171. check('定位不到时不新增行', (await rowsForSession()).length === before);
  172. // ── 5. 删除 ───────────────────────────────────────────────────────
  173. console.log('\n【5】删除会话');
  174. check('deleteDmsSession 返回 true', (await deleteDmsSession(SESSION_ID)) === true);
  175. check('会话行已删', !(await fetchDmsSessions(TEST_CREDIT)).some((s) => s.session_id === SESSION_ID));
  176. check('问答记录已连带删除', (await rowsForSession()).length === 0);
  177. // ── 6. 工具函数 ───────────────────────────────────────────────────
  178. console.log('\n【6】时间戳与访客归属');
  179. check('nowDmsTimestamp 格式', /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(nowDmsTimestamp()), nowDmsTimestamp());
  180. check('parseDmsTimestamp 毫秒', parseDmsTimestamp(1789651800000) === 1789651800000);
  181. check('parseDmsTimestamp 秒', parseDmsTimestamp(1789651800) === 1789651800000);
  182. check('parseDmsTimestamp 字符串', parseDmsTimestamp('2026-09-17 21:30:00') === 1789651800000, String(parseDmsTimestamp('2026-09-17 21:30:00')));
  183. enterpriseInfo.value = null; // 下面这条断言的是**未登录**时的归属形态
  184. check(
  185. '访客归属形态 = 访客_<本机 IP>',
  186. /^访客_\d{1,3}(\.\d{1,3}){3}$/.test(resolveDmsCreditCode()),
  187. resolveDmsCreditCode()
  188. );
  189. // ── 8. 1887 的企业字段(2026-09-22 用户要求)──────────────────────
  190. // 规则:登录态的值优先级最高(覆盖);对话带出的 company_info **只填空**(未登录不修改);
  191. // 归属键 c_credit_code 不变(公司代码另存 c_chat_credit_code 一列)。
  192. console.log('\n【8】1887 企业字段(名称 / 公司代码 / 图片 / 是否登录)');
  193. const SID8 = `verify_ent_${Date.now().toString(36)}`;
  194. const sessionRowOf = async (sid) =>
  195. (
  196. await searchDmsContents(DMS_COLUMN_SESSION, {
  197. search: [{ field: 'c_session_id', searchType: 1, content: { value: sid } }],
  198. page: 0,
  199. pageSize: 1,
  200. })
  201. )[0];
  202. const COMPANY_A = {
  203. profile: { data: { Name: '甲测试公司', CreditCode: '91310000AAAAAAAAAA', ImageUrl: 'https://example.com/a.png' } },
  204. };
  205. const COMPANY_B = {
  206. profile: { data: { Name: '乙测试公司', CreditCode: '91310000BBBBBBBBBB', ImageUrl: 'https://example.com/b.png' } },
  207. };
  208. // 8.1 未登录 + company_info → 填进 1887(c_credit_code 放**真实**代码,访客标识进 c_ip)
  209. enterpriseInfo.value = null;
  210. await upsertDmsSession({ sessionId: SID8, sessionTitle: '企业字段验证', companyInfo: COMPANY_A });
  211. let entRow = await sessionRowOf(SID8);
  212. check('未登录:企业名称已填', entRow?.c_ent_name === '甲测试公司', JSON.stringify(entRow?.c_ent_name));
  213. check(
  214. '未登录:真实信用代码写进 c_credit_code',
  215. entRow?.c_credit_code === '91310000AAAAAAAAAA',
  216. JSON.stringify(entRow?.c_credit_code)
  217. );
  218. check(
  219. '未登录:**c_ip 存的是真实 IP**',
  220. /^\d{1,3}(\.\d{1,3}){3}$/.test(String(entRow?.c_ip || '')),
  221. JSON.stringify(entRow?.c_ip)
  222. );
  223. check('未登录:企业图片已填', entRow?.c_ent_image_url === 'https://example.com/a.png', JSON.stringify(entRow?.c_ent_image_url));
  224. check('未登录:是否登录 = "0"', entRow?.c_is_login === '0', JSON.stringify(entRow?.c_is_login));
  225. // ⚠️ 不断言「等于 resolveVisitorLabel() 的当前返回值」:node 里没有 localStorage,
  226. // 每次调用都会新生成一个访客 id(浏览器里有 localStorage,是稳定的)。只查前缀形态。
  227. // 8.2 未登录再来一轮、带的是**另一家**公司 → **值不同就更新**(用户 2026-09-22 修订)
  228. await upsertDmsSession({ sessionId: SID8, companyInfo: COMPANY_B });
  229. entRow = await sessionRowOf(SID8);
  230. check('未登录:值不同 → 企业名称被更新', entRow?.c_ent_name === '乙测试公司', JSON.stringify(entRow?.c_ent_name));
  231. check('未登录:值不同 → 信用代码被更新', entRow?.c_credit_code === '91310000BBBBBBBBBB', JSON.stringify(entRow?.c_credit_code));
  232. // 8.2b 同一家公司再来一轮(值相同)→ 不该产生无谓写入:读回值不变即可
  233. await upsertDmsSession({ sessionId: SID8, companyInfo: COMPANY_B });
  234. entRow = await sessionRowOf(SID8);
  235. check('未登录:值相同 → 保持原值', entRow?.c_ent_name === '乙测试公司', JSON.stringify(entRow?.c_ent_name));
  236. // 8.3 登录 → 登录态的值优先级最高,**覆盖**库里已有的
  237. enterpriseInfo.value = { credit_code: '91310000LOGIN000001', name: '登录态公司' };
  238. await upsertDmsSession({ sessionId: SID8, companyInfo: COMPANY_B });
  239. entRow = await sessionRowOf(SID8);
  240. check('登录:企业名称被登录值**覆盖**', entRow?.c_ent_name === '登录态公司', JSON.stringify(entRow?.c_ent_name));
  241. check('登录:归属键被登录值**覆盖**', entRow?.c_credit_code === '91310000LOGIN000001', JSON.stringify(entRow?.c_credit_code));
  242. check('登录:是否登录 = "1"', entRow?.c_is_login === '1', JSON.stringify(entRow?.c_is_login));
  243. check(
  244. '登录:对话带出的公司代码**不会**顶掉登录信用代码',
  245. entRow?.c_credit_code === '91310000LOGIN000001',
  246. JSON.stringify(entRow?.c_credit_code)
  247. );
  248. // 8.4 退出登录后再写(对话里是**另一家**公司)→ 这行已被登录态认领,公司字段**不许被改**
  249. // 否则会把这行改到别的公司名下、而 c_is_login 还是 "1" → 串台
  250. enterpriseInfo.value = null;
  251. await upsertDmsSession({ sessionId: SID8, companyInfo: COMPANY_B });
  252. entRow = await sessionRowOf(SID8);
  253. check('退出后:登录认领过的行,企业名称**不被**对话值改写', entRow?.c_ent_name === '登录态公司', JSON.stringify(entRow?.c_ent_name));
  254. check('退出后:信用代码**不被**对话值改写', entRow?.c_credit_code === '91310000LOGIN000001', JSON.stringify(entRow?.c_credit_code));
  255. check('退出后:是否登录仍为 "1"(该字段只填空)', entRow?.c_is_login === '1', JSON.stringify(entRow?.c_is_login));
  256. // 8.4b **纯访客行**(从没被登录认领过)→ 公司字段跟着对话走,值不同就更新
  257. const SID_GUEST = `verify_guest_${Date.now().toString(36)}`;
  258. const COMPANY_C = { profile: { data: { Name: '丙测试公司', CreditCode: '91310000CCCCCCCCCC' } } };
  259. const COMPANY_D = { profile: { data: { Name: '丁测试公司', CreditCode: '91310000DDDDDDDDDD' } } };
  260. await upsertDmsSession({ sessionId: SID_GUEST, sessionTitle: '纯访客', companyInfo: COMPANY_C });
  261. await upsertDmsSession({ sessionId: SID_GUEST, companyInfo: COMPANY_D });
  262. const guestRow = await sessionRowOf(SID_GUEST);
  263. check('纯访客行:值不同 → 企业名称更新', guestRow?.c_ent_name === '丁测试公司', JSON.stringify(guestRow?.c_ent_name));
  264. check('纯访客行:值不同 → 信用代码更新', guestRow?.c_credit_code === '91310000DDDDDDDDDD', JSON.stringify(guestRow?.c_credit_code));
  265. check('纯访客行:是否登录 = "0"', guestRow?.c_is_login === '0', JSON.stringify(guestRow?.c_is_login));
  266. // 8.5 访客行**不该**出现在那家公司的会话列表里(用户 2026-09-22 明确要求挡住)
  267. // ⚠️ 要查**纯访客行实际带着的**公司代码(第一版查错了代码 → 断言恒真 = 假通过;
  268. // 第二版查 SID8 → 它已被登录认领,不更新是对的)
  269. const guestRows = await fetchDmsSessions('91310000DDDDDDDDDD');
  270. check(
  271. '**企业登录后看不到访客的会话**(按 c_is_login 过滤)',
  272. !guestRows.some((r) => r.session_id === SID_GUEST),
  273. `列表里有 ${guestRows.filter((r) => r.session_id === SID_GUEST).length} 行命中`
  274. );
  275. // 反向:登录态造的行**能**查到(否则就是过滤写反了)
  276. // ⚠️ 用新建的行,别用 SESSION_ID —— 它在【5】删除会话那节已经被删掉了
  277. const SID9 = `verify_login_${Date.now().toString(36)}`;
  278. enterpriseInfo.value = { credit_code: TEST_CREDIT, name: '验证企业' };
  279. await upsertDmsSession({ sessionId: SID9, sessionTitle: '登录态会话' });
  280. const ownRows = await fetchDmsSessions(TEST_CREDIT);
  281. check('登录态造的会话**能**查到(过滤没写反)', ownRows.some((r) => r.session_id === SID9), `共 ${ownRows.length} 行`);
  282. enterpriseInfo.value = null;
  283. await deleteDmsSession(SID9);
  284. await deleteDmsSession(SID_GUEST);
  285. await deleteDmsSession(SID8);
  286. } finally {
  287. console.log('\n【清理】');
  288. await cleanup();
  289. const left = [
  290. ...(await searchDmsContents(DMS_COLUMN_SESSION, { search: [{ field: 'c_credit_code', searchType: 1, content: { value: TEST_CREDIT } }], page: 0, pageSize: 50 })),
  291. ...(await searchDmsContents(DMS_COLUMN_RECORD, { search: [{ field: 'c_credit_code', searchType: 1, content: { value: TEST_CREDIT } }], page: 0, pageSize: 50 })),
  292. ];
  293. console.log(` 测试数据已清理(残留 ${left.length} 行)`);
  294. }
  295. // ── 7. title 字段(2026-09-17 用户要求:改名要跟随、记录写问题文本)──────
  296. console.log('\n【7】title 字段');
  297. {
  298. const SID2 = SESSION_ID + '_t';
  299. const REC3 = 'rec3_' + Date.now().toString(36);
  300. const rowOf = async (columnId, field, value) =>
  301. (await searchDmsContents(columnId, {
  302. search: [{ field, searchType: 1, content: { value } }],
  303. page: 0,
  304. pageSize: 1,
  305. }))[0];
  306. await upsertDmsSession({ sessionId: SID2, sessionTitle: '标题甲', creditCode: TEST_CREDIT });
  307. let row = await rowOf(DMS_COLUMN_SESSION, 'c_session_id', SID2);
  308. check('新建会话带上了系统 title', row?.title === '标题甲', JSON.stringify(row?.title));
  309. // 四个必填运营字段(summary/qpyszx/qyzt/rzqpyx)**故意传空值/默认值**:
  310. // 它们是 must=true(不带就 214),但取值属业务口径、前端不猜。
  311. // 所以这里断言的是「写进去了、且确实是空的」——而不是断言某个业务值。
  312. check('摘要字段已写入**且为空**(不猜业务值)', row?.summary === '', JSON.stringify(row?.summary));
  313. check('三个布尔运营字段为默认 false', row?.qpyszx === false && row?.qyzt === false && row?.rzqpyx === false,
  314. JSON.stringify([row?.qpyszx, row?.qyzt, row?.rzqpyx]));
  315. await upsertDmsSession({ sessionId: SID2, sessionTitle: '标题乙', creditCode: TEST_CREDIT });
  316. row = await rowOf(DMS_COLUMN_SESSION, 'c_session_id', SID2);
  317. check('再 upsert 仍不写 c_title', !row?.c_title, JSON.stringify(row?.c_title));
  318. check('系统 title 保持创建时的值', row?.title === '标题甲', JSON.stringify(row?.title));
  319. await upsertDmsRecord({ recordId: REC3, sessionId: SID2, creditCode: TEST_CREDIT, question: '记录的问题文本' });
  320. const recRow = await rowOf(DMS_COLUMN_RECORD, 'c_record_id', REC3);
  321. check('记录的系统 title = 问题文本(原先写的是 "null")', recRow?.title === '记录的问题文本', JSON.stringify(recRow?.title));
  322. // 1889 的模型里**没有** summary 字段(只有 1887 有),DMS 会静默忽略未知字段 ——
  323. // 所以写入仍成功,但读回来不会有这个键。这条断言把这个差异钉下来。
  324. check(
  325. '1889 无 summary 字段,多发的字段被 DMS 忽略(写入不受影响)',
  326. recRow !== undefined && recRow.summary === undefined,
  327. JSON.stringify(recRow?.summary)
  328. );
  329. await deleteDmsSession(SID2);
  330. }
  331. console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
  332. process.exit(fail ? 1 : 0);