/** * 端到端验证「会话 / 问答记录 / 反馈」在 DMS 里的读写(栏目 1887 / 1889)。 * * 存储粒度约定(2026-09-17 用户最终确认): * - 1887 助手会话:一个会话一行(标题、归属、更新时间) * - 1889 助手问答记录:**一问一答一条**(幂等键 c_record_id), * c_question / c_answer 各存一半;反馈写在该行的 c_feedback_* 列上 * (曾一度改成「一个会话一行、整段对话 JSON」,按用户要求已改回本版) * * 验证什么:前端真实调用的那些函数(引用 src 里同一份实现,不是复制逻辑) * 打到真实 DMS 上能否正确 增/改/查/删,尤其是: * ① 反复保存同一轮**只有一行**(不是每问一次多一行) * ② 补 answer 时不覆盖 question、不变成两行 * ③ 反馈能命中该轮那一行 * * 怎么跑(需要 dev server 起着,代理会注入 token): * 1) npm run dev * 2) npx esbuild harness/tools/_entry-dms.ts --bundle --format=esm \ * --outfile=harness/tools/_dms.mjs --alias:@=./src --define:import.meta.env='{}' * 3) node harness/tools/verify-dms-chat-storage.mjs https://localhost:8083/dms * * 脚本**不持有 token**——和浏览器一样只打代理地址。 */ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; const base = process.argv[2] || 'https://localhost:8083/dms'; globalThis.VITE_DMS_API = base; // 取本机 IP 的接口(DMS 访客归属用)。与浏览器同一条代理路径:页面是 HTTPS、接口是 HTTP, // 直连会被混合内容拦掉。node 里没有这个限制,但走代理更接近真实链路。 globalThis.VITE_IP_API = '/ip-api'; // 取 IP 接口**打桩**(TEST-NET-3 段,不会与真实 IP 混淆): // 真实服务已用 curl 验过(`https://localhost:8083/ip-api/proxy/verify/getIPByBrowser` 返回本机 IP), // 但这支测试要的是**确定性** —— 依赖外部服务会让断言偶发失败(实测抖过一次)。 const STUB_IP = '203.0.113.9'; const realFetch = globalThis.fetch; globalThis.fetch = async (url, init) => { if (String(url).includes('/proxy/verify/getIPByBrowser')) { return new Response(STUB_IP, { status: 200, headers: { 'Content-Type': 'application/json' } }); } return realFetch(url, init); }; const { upsertDmsSession, upsertDmsRecord, fetchDmsSessions, fetchDmsSessionRecords, deleteDmsSession, writeDmsFeedback, resolveDmsCreditCode, parseDmsTimestamp, nowDmsTimestamp, searchDmsContents, deleteDmsContent, DMS_COLUMN_SESSION, DMS_COLUMN_RECORD, DMS_MODEL_RECORD, enterpriseInfo, resolveVisitorLabel, } = await import('./_dms.mjs'); let pass = 0; let fail = 0; const check = (name, ok, extra = '') => { if (ok) { pass++; console.log(' ok ' + name); } else { fail++; console.log(' FAIL ' + name + ' ' + extra); } }; const TEST_CREDIT = `verify_cc_${Date.now().toString(36)}`; const SESSION_ID = `verify_sess_${Date.now().toString(36)}`; const REC_1 = 'rec1_' + Date.now().toString(36); const REC_2 = 'rec2_' + Date.now().toString(36); const TITLE_A = '验证会话标题'; const TITLE_B = '验证会话标题(改过)'; const Q1 = '高新技术企业的奖励是多少?'; const A1 = '\n\n\n第一轮回答。'; const Q2 = '那申报条件呢?'; const A2 = '第二轮回答,带换行。\n第二行。'; console.log(`\nDMS 会话/问答记录/反馈 端到端验证\n 代理地址: ${base}\n 测试标记: ${TEST_CREDIT}\n`); const rowsForSession = () => searchDmsContents(DMS_COLUMN_RECORD, { search: [{ field: 'c_session_id', searchType: 1, content: { value: SESSION_ID } }], page: 0, pageSize: 50, }); const cleanup = async () => { await deleteDmsSession(SESSION_ID); for (const col of [DMS_COLUMN_SESSION, DMS_COLUMN_RECORD]) { const rows = await searchDmsContents(col, { search: [{ field: 'c_credit_code', searchType: 1, content: { value: TEST_CREDIT } }], page: 0, pageSize: 50, }); for (const row of rows) if (row.id) await deleteDmsContent(col, String(row.id)); } }; try { // ── 1. 会话行 ────────────────────────────────────────────────────── // ⚠️ 先置登录态:1887 的读取会**排除访客行**(c_is_login="0"), // 这一段测的是「企业自己的会话」,所以照登录态造数据。 console.log('【1】会话(1887)'); enterpriseInfo.value = { credit_code: TEST_CREDIT, name: '验证企业' }; check('upsertDmsSession 新增', (await upsertDmsSession({ sessionId: SESSION_ID, sessionTitle: TITLE_A, creditCode: TEST_CREDIT })) === true); let sessions = await fetchDmsSessions(TEST_CREDIT); check('列表能查到', sessions.some((s) => s.session_id === SESSION_ID)); check('updated_at 合理', Math.abs((sessions.find((s) => s.session_id === SESSION_ID)?.updated_at || 0) - Date.now()) < 10 * 60 * 1000); // 标题规则(用户 2026-09-22):**c_title 不写**(标题由运营侧生成); // DMS 系统字段 `title` 只在创建那一刻带上会话标题(不写会落成字符串 "null",1889 踩过)。 let rowSession = (await searchDmsContents(DMS_COLUMN_SESSION, { search: [{ field: 'c_session_id', searchType: 1, content: { value: SESSION_ID } }], page: 0, pageSize: 1, }))[0]; check('创建时带上了系统 title', rowSession?.title === TITLE_A, JSON.stringify(rowSession?.title)); check('**c_title 不写**(前端不碰标题)', !rowSession?.c_title, JSON.stringify(rowSession?.c_title)); await upsertDmsSession({ sessionId: SESSION_ID, sessionTitle: TITLE_B, creditCode: TEST_CREDIT }); sessions = await fetchDmsSessions(TEST_CREDIT); check('再次 upsert 只有一行', sessions.filter((s) => s.session_id === SESSION_ID).length === 1); rowSession = (await searchDmsContents(DMS_COLUMN_SESSION, { search: [{ field: 'c_session_id', searchType: 1, content: { value: SESSION_ID } }], page: 0, pageSize: 1, }))[0]; check('**再 upsert 也不写 c_title**', !rowSession?.c_title, JSON.stringify(rowSession?.c_title)); check('系统 title 保持创建时的值(只在创建那一刻初始化)', rowSession?.title === TITLE_A, JSON.stringify(rowSession?.title)); // ── 2. 粒度:一问一答一条 ────────────────────────────────────────── console.log('\n【2】问答记录(1889)—— 粒度必须是「一问一答一条」'); await upsertDmsRecord({ recordId: REC_1, sessionId: SESSION_ID, creditCode: TEST_CREDIT, question: Q1 }); let rows = await rowsForSession(); check('第 1 轮提交后:1 行', rows.length === 1, `实际 ${rows.length} 行`); check('c_question 正确', rows[0]?.c_question === Q1, JSON.stringify(rows[0]?.c_question)); check('c_record_id 存的是该轮标识', rows[0]?.c_record_id === REC_1, String(rows[0]?.c_record_id)); check('此时 c_answer 为空', !rows[0]?.c_answer, JSON.stringify(rows[0]?.c_answer)); // 回答完成 → 补 answer,不能变成两行、不能冲掉 question await upsertDmsRecord({ recordId: REC_1, sessionId: SESSION_ID, creditCode: TEST_CREDIT, answer: A1 }); rows = await rowsForSession(); check('补 answer 后仍是 1 行', rows.length === 1, `实际 ${rows.length} 行`); check('answer 完整往返(含协议标记)', rows[0]?.c_answer === A1, `长 ${String(rows[0]?.c_answer || '').length}/${A1.length}`); check('question 未被覆盖', rows[0]?.c_question === Q1, JSON.stringify(rows[0]?.c_question)); // 第 2 轮 → 应该是**新的一行**(一问一答一条) await upsertDmsRecord({ recordId: REC_2, sessionId: SESSION_ID, creditCode: TEST_CREDIT, question: Q2 }); await upsertDmsRecord({ recordId: REC_2, sessionId: SESSION_ID, creditCode: TEST_CREDIT, answer: A2 }); rows = await rowsForSession(); check('第 2 轮后:2 行(一问一答一条)', rows.length === 2, `实际 ${rows.length} 行`); // ── 3. 历史读回 ─────────────────────────────────────────────────── console.log('\n【3】历史读回(按创建时间正序)'); const records = await fetchDmsSessionRecords(SESSION_ID); check('读出两轮', records.length === 2, `实际 ${records.length} 轮`); check('第一轮 question/answer 正确', records[0]?.question === Q1 && records[0]?.answer === A1); check('第二轮 question/answer 正确', records[1]?.question === Q2 && records[1]?.answer === A2); check('record_id 回填(反馈闭环)', records[0]?.record_id === REC_1 && records[1]?.record_id === REC_2, `${records[0]?.record_id} / ${records[1]?.record_id}`); check('created_at 被解析成合理时间戳', Math.abs((records[0]?.created_at || 0) - Date.now()) < 10 * 60 * 1000, String(records[0]?.created_at)); // ── 4. 反馈:写在该轮那一行 ──────────────────────────────────────── console.log('\n【4】反馈(写在第 2 轮那一行)'); await writeDmsFeedback({ recordId: REC_2, sessionId: SESSION_ID, status: 2, option: '没有回答我的问题', remark: '验证备注' }); rows = await rowsForSession(); const target = rows.find((r) => r.c_record_id === REC_2); const untouched = rows.find((r) => r.c_record_id === REC_1); check('行数不变(仍 2 行)', rows.length === 2, `实际 ${rows.length} 行`); check('c_feedback_status = 2', Number(target?.c_feedback_status) === 2, String(target?.c_feedback_status)); check('c_feedback_option 正确', target?.c_feedback_option === '没有回答我的问题', JSON.stringify(target?.c_feedback_option)); check('c_feedback_remark 正确', target?.c_feedback_remark === '验证备注', JSON.stringify(target?.c_feedback_remark)); check('c_feedback_at 已写入', !!target?.c_feedback_at, String(target?.c_feedback_at)); check('**另一轮未被误改**', untouched && untouched.c_feedback_status == null, JSON.stringify(untouched?.c_feedback_status)); check('问答内容未被反馈覆盖', target?.c_question === Q2 && target?.c_answer === A2); await writeDmsFeedback({ recordId: REC_2, sessionId: SESSION_ID, status: 0 }); rows = await rowsForSession(); check('取消反馈 status=0', Number(rows.find((r) => r.c_record_id === REC_2)?.c_feedback_status) === 0); const before = (await rowsForSession()).length; await writeDmsFeedback({ recordId: 'not_exist_rec', sessionId: SESSION_ID, status: 1 }); check('定位不到时不新增行', (await rowsForSession()).length === before); // ── 5. 删除 ─────────────────────────────────────────────────────── console.log('\n【5】删除会话'); check('deleteDmsSession 返回 true', (await deleteDmsSession(SESSION_ID)) === true); check('会话行已删', !(await fetchDmsSessions(TEST_CREDIT)).some((s) => s.session_id === SESSION_ID)); check('问答记录已连带删除', (await rowsForSession()).length === 0); // ── 6. 工具函数 ─────────────────────────────────────────────────── console.log('\n【6】时间戳与访客归属'); check('nowDmsTimestamp 格式', /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(nowDmsTimestamp()), nowDmsTimestamp()); check('parseDmsTimestamp 毫秒', parseDmsTimestamp(1789651800000) === 1789651800000); check('parseDmsTimestamp 秒', parseDmsTimestamp(1789651800) === 1789651800000); check('parseDmsTimestamp 字符串', parseDmsTimestamp('2026-09-17 21:30:00') === 1789651800000, String(parseDmsTimestamp('2026-09-17 21:30:00'))); enterpriseInfo.value = null; // 下面这条断言的是**未登录**时的归属形态 check( '访客归属形态 = 访客_<本机 IP>', /^访客_\d{1,3}(\.\d{1,3}){3}$/.test(resolveDmsCreditCode()), resolveDmsCreditCode() ); // ── 8. 1887 的企业字段(2026-09-22 用户要求)────────────────────── // 规则:登录态的值优先级最高(覆盖);对话带出的 company_info **只填空**(未登录不修改); // 归属键 c_credit_code 不变(公司代码另存 c_chat_credit_code 一列)。 console.log('\n【8】1887 企业字段(名称 / 公司代码 / 图片 / 是否登录)'); const SID8 = `verify_ent_${Date.now().toString(36)}`; const sessionRowOf = async (sid) => ( await searchDmsContents(DMS_COLUMN_SESSION, { search: [{ field: 'c_session_id', searchType: 1, content: { value: sid } }], page: 0, pageSize: 1, }) )[0]; const COMPANY_A = { profile: { data: { Name: '甲测试公司', CreditCode: '91310000AAAAAAAAAA', ImageUrl: 'https://example.com/a.png' } }, }; const COMPANY_B = { profile: { data: { Name: '乙测试公司', CreditCode: '91310000BBBBBBBBBB', ImageUrl: 'https://example.com/b.png' } }, }; // 8.1 未登录 + company_info → 填进 1887(c_credit_code 放**真实**代码,访客标识进 c_ip) enterpriseInfo.value = null; await upsertDmsSession({ sessionId: SID8, sessionTitle: '企业字段验证', companyInfo: COMPANY_A }); let entRow = await sessionRowOf(SID8); check('未登录:企业名称已填', entRow?.c_ent_name === '甲测试公司', JSON.stringify(entRow?.c_ent_name)); check( '未登录:真实信用代码写进 c_credit_code', entRow?.c_credit_code === '91310000AAAAAAAAAA', JSON.stringify(entRow?.c_credit_code) ); check( '未登录:**c_ip 存的是真实 IP**', /^\d{1,3}(\.\d{1,3}){3}$/.test(String(entRow?.c_ip || '')), JSON.stringify(entRow?.c_ip) ); check('未登录:企业图片已填', entRow?.c_ent_image_url === 'https://example.com/a.png', JSON.stringify(entRow?.c_ent_image_url)); check('未登录:是否登录 = "0"', entRow?.c_is_login === '0', JSON.stringify(entRow?.c_is_login)); // ⚠️ 不断言「等于 resolveVisitorLabel() 的当前返回值」:node 里没有 localStorage, // 每次调用都会新生成一个访客 id(浏览器里有 localStorage,是稳定的)。只查前缀形态。 // 8.2 未登录再来一轮、带的是**另一家**公司 → **值不同就更新**(用户 2026-09-22 修订) await upsertDmsSession({ sessionId: SID8, companyInfo: COMPANY_B }); entRow = await sessionRowOf(SID8); check('未登录:值不同 → 企业名称被更新', entRow?.c_ent_name === '乙测试公司', JSON.stringify(entRow?.c_ent_name)); check('未登录:值不同 → 信用代码被更新', entRow?.c_credit_code === '91310000BBBBBBBBBB', JSON.stringify(entRow?.c_credit_code)); // 8.2b 同一家公司再来一轮(值相同)→ 不该产生无谓写入:读回值不变即可 await upsertDmsSession({ sessionId: SID8, companyInfo: COMPANY_B }); entRow = await sessionRowOf(SID8); check('未登录:值相同 → 保持原值', entRow?.c_ent_name === '乙测试公司', JSON.stringify(entRow?.c_ent_name)); // 8.3 登录 → 登录态的值优先级最高,**覆盖**库里已有的 enterpriseInfo.value = { credit_code: '91310000LOGIN000001', name: '登录态公司' }; await upsertDmsSession({ sessionId: SID8, companyInfo: COMPANY_B }); entRow = await sessionRowOf(SID8); check('登录:企业名称被登录值**覆盖**', entRow?.c_ent_name === '登录态公司', JSON.stringify(entRow?.c_ent_name)); check('登录:归属键被登录值**覆盖**', entRow?.c_credit_code === '91310000LOGIN000001', JSON.stringify(entRow?.c_credit_code)); check('登录:是否登录 = "1"', entRow?.c_is_login === '1', JSON.stringify(entRow?.c_is_login)); check( '登录:对话带出的公司代码**不会**顶掉登录信用代码', entRow?.c_credit_code === '91310000LOGIN000001', JSON.stringify(entRow?.c_credit_code) ); // 8.4 退出登录后再写(对话里是**另一家**公司)→ 这行已被登录态认领,公司字段**不许被改** // 否则会把这行改到别的公司名下、而 c_is_login 还是 "1" → 串台 enterpriseInfo.value = null; await upsertDmsSession({ sessionId: SID8, companyInfo: COMPANY_B }); entRow = await sessionRowOf(SID8); check('退出后:登录认领过的行,企业名称**不被**对话值改写', entRow?.c_ent_name === '登录态公司', JSON.stringify(entRow?.c_ent_name)); check('退出后:信用代码**不被**对话值改写', entRow?.c_credit_code === '91310000LOGIN000001', JSON.stringify(entRow?.c_credit_code)); check('退出后:是否登录仍为 "1"(该字段只填空)', entRow?.c_is_login === '1', JSON.stringify(entRow?.c_is_login)); // 8.4b **纯访客行**(从没被登录认领过)→ 公司字段跟着对话走,值不同就更新 const SID_GUEST = `verify_guest_${Date.now().toString(36)}`; const COMPANY_C = { profile: { data: { Name: '丙测试公司', CreditCode: '91310000CCCCCCCCCC' } } }; const COMPANY_D = { profile: { data: { Name: '丁测试公司', CreditCode: '91310000DDDDDDDDDD' } } }; await upsertDmsSession({ sessionId: SID_GUEST, sessionTitle: '纯访客', companyInfo: COMPANY_C }); await upsertDmsSession({ sessionId: SID_GUEST, companyInfo: COMPANY_D }); const guestRow = await sessionRowOf(SID_GUEST); check('纯访客行:值不同 → 企业名称更新', guestRow?.c_ent_name === '丁测试公司', JSON.stringify(guestRow?.c_ent_name)); check('纯访客行:值不同 → 信用代码更新', guestRow?.c_credit_code === '91310000DDDDDDDDDD', JSON.stringify(guestRow?.c_credit_code)); check('纯访客行:是否登录 = "0"', guestRow?.c_is_login === '0', JSON.stringify(guestRow?.c_is_login)); // 8.5 访客行**不该**出现在那家公司的会话列表里(用户 2026-09-22 明确要求挡住) // ⚠️ 要查**纯访客行实际带着的**公司代码(第一版查错了代码 → 断言恒真 = 假通过; // 第二版查 SID8 → 它已被登录认领,不更新是对的) const guestRows = await fetchDmsSessions('91310000DDDDDDDDDD'); check( '**企业登录后看不到访客的会话**(按 c_is_login 过滤)', !guestRows.some((r) => r.session_id === SID_GUEST), `列表里有 ${guestRows.filter((r) => r.session_id === SID_GUEST).length} 行命中` ); // 反向:登录态造的行**能**查到(否则就是过滤写反了) // ⚠️ 用新建的行,别用 SESSION_ID —— 它在【5】删除会话那节已经被删掉了 const SID9 = `verify_login_${Date.now().toString(36)}`; enterpriseInfo.value = { credit_code: TEST_CREDIT, name: '验证企业' }; await upsertDmsSession({ sessionId: SID9, sessionTitle: '登录态会话' }); const ownRows = await fetchDmsSessions(TEST_CREDIT); check('登录态造的会话**能**查到(过滤没写反)', ownRows.some((r) => r.session_id === SID9), `共 ${ownRows.length} 行`); enterpriseInfo.value = null; await deleteDmsSession(SID9); await deleteDmsSession(SID_GUEST); await deleteDmsSession(SID8); } finally { console.log('\n【清理】'); await cleanup(); const left = [ ...(await searchDmsContents(DMS_COLUMN_SESSION, { search: [{ field: 'c_credit_code', searchType: 1, content: { value: TEST_CREDIT } }], page: 0, pageSize: 50 })), ...(await searchDmsContents(DMS_COLUMN_RECORD, { search: [{ field: 'c_credit_code', searchType: 1, content: { value: TEST_CREDIT } }], page: 0, pageSize: 50 })), ]; console.log(` 测试数据已清理(残留 ${left.length} 行)`); } // ── 7. title 字段(2026-09-17 用户要求:改名要跟随、记录写问题文本)────── console.log('\n【7】title 字段'); { const SID2 = SESSION_ID + '_t'; const REC3 = 'rec3_' + Date.now().toString(36); const rowOf = async (columnId, field, value) => (await searchDmsContents(columnId, { search: [{ field, searchType: 1, content: { value } }], page: 0, pageSize: 1, }))[0]; await upsertDmsSession({ sessionId: SID2, sessionTitle: '标题甲', creditCode: TEST_CREDIT }); let row = await rowOf(DMS_COLUMN_SESSION, 'c_session_id', SID2); check('新建会话带上了系统 title', row?.title === '标题甲', JSON.stringify(row?.title)); // 四个必填运营字段(summary/qpyszx/qyzt/rzqpyx)**故意传空值/默认值**: // 它们是 must=true(不带就 214),但取值属业务口径、前端不猜。 // 所以这里断言的是「写进去了、且确实是空的」——而不是断言某个业务值。 check('摘要字段已写入**且为空**(不猜业务值)', row?.summary === '', JSON.stringify(row?.summary)); check('三个布尔运营字段为默认 false', row?.qpyszx === false && row?.qyzt === false && row?.rzqpyx === false, JSON.stringify([row?.qpyszx, row?.qyzt, row?.rzqpyx])); await upsertDmsSession({ sessionId: SID2, sessionTitle: '标题乙', creditCode: TEST_CREDIT }); row = await rowOf(DMS_COLUMN_SESSION, 'c_session_id', SID2); check('再 upsert 仍不写 c_title', !row?.c_title, JSON.stringify(row?.c_title)); check('系统 title 保持创建时的值', row?.title === '标题甲', JSON.stringify(row?.title)); await upsertDmsRecord({ recordId: REC3, sessionId: SID2, creditCode: TEST_CREDIT, question: '记录的问题文本' }); const recRow = await rowOf(DMS_COLUMN_RECORD, 'c_record_id', REC3); check('记录的系统 title = 问题文本(原先写的是 "null")', recRow?.title === '记录的问题文本', JSON.stringify(recRow?.title)); // 1889 的模型里**没有** summary 字段(只有 1887 有),DMS 会静默忽略未知字段 —— // 所以写入仍成功,但读回来不会有这个键。这条断言把这个差异钉下来。 check( '1889 无 summary 字段,多发的字段被 DMS 忽略(写入不受影响)', recRow !== undefined && recRow.summary === undefined, JSON.stringify(recRow?.summary) ); await deleteDmsSession(SID2); } // ── 8. 附件路径落库:c_image / c_file(用户 2026-09-23:新建了这两列,要求把路径存进去)── console.log('\n【8】附件路径 → c_image / c_file'); { const SID10 = SESSION_ID + '_att'; const REC_A = 'recA_' + Date.now().toString(36); const REC_B = 'recB_' + Date.now().toString(36); const IMG_1 = '/static/photo/1889-c_question-aaaa.png'; const IMG_2 = '/static/photo/1889-c_question-bbbb.jpg'; const FILE_1 = '/static/file/1889-c_question-cccc.xlsx'; const rowOf = async (columnId, field, value) => ( await searchDmsContents(columnId, { search: [{ field, searchType: 1, content: { value } }], page: 0, pageSize: 5, }) )[0]; // 8.1 ⚠️ 先确认这两列**确实在模型里** —— 这是这次改动的硬前提。 // `updateContent` 遇到模型里不存在的列会**静默丢弃整个 patch**(返回仍 code 200), // 见 docs/reference/DMS_API.md 的写入陷阱:真那样,补 answer 这条主链路会被连累。 { const res = await fetch(`${base}/model/getModelById?modelId=${DMS_MODEL_RECORD}`, { method: 'POST' }); const body = await res.json().catch(() => ({})); let fields = {}; try { fields = JSON.parse(body?.content?.fieldList || '{}'); } catch { /* 取不到就当空 —— 下面的断言会报出来 */ } check('模型 2038 里 c_image 列存在(否则前端不该发它)', !!fields.c_image, Object.keys(fields).join(',')); check('模型 2038 里 c_file 列存在', !!fields.c_file); check('两列都是文本列(多值用逗号拼是安全的)', fields.c_image?.frontType === 'text' && fields.c_file?.frontType === 'text', `${fields.c_image?.frontType} / ${fields.c_file?.frontType}`); } // 8.2 带图 + 带文件:两张图 + 一个文件 enterpriseInfo.value = { credit_code: TEST_CREDIT, name: '验证企业' }; await upsertDmsRecord({ recordId: REC_A, sessionId: SID10, question: '这两张图里是什么?', images: [IMG_1, IMG_2], files: [FILE_1], }); let row = await rowOf(DMS_COLUMN_RECORD, 'c_record_id', REC_A); check('c_image 存的是两张图的相对路径(逗号分隔)', row?.c_image === `${IMG_1},${IMG_2}`, JSON.stringify(row?.c_image)); check('c_file 存的是文件的相对路径', row?.c_file === FILE_1, JSON.stringify(row?.c_file)); check('question 照旧', row?.c_question === '这两张图里是什么?', JSON.stringify(row?.c_question)); // 8.3 补 answer 时**不能**把附件路径冲掉、也不能把整条 patch 弄失败 await upsertDmsRecord({ recordId: REC_A, sessionId: SID10, answer: '第一轮回答。' }); row = await rowOf(DMS_COLUMN_RECORD, 'c_record_id', REC_A); check('补 answer 后 c_image 还在(没被覆盖)', row?.c_image === `${IMG_1},${IMG_2}`, JSON.stringify(row?.c_image)); check('补 answer 后 c_file 还在', row?.c_file === FILE_1, JSON.stringify(row?.c_file)); check('**补 answer 本身写进去了**(附件列没连累主链路)', row?.c_answer === '第一轮回答。', JSON.stringify(row?.c_answer)); // 8.4 没有附件的一轮:这两列保持不写(别写空串、别写 null) await upsertDmsRecord({ recordId: REC_B, sessionId: SID10, question: '只发文字', images: [], files: [] }); const rowB = await rowOf(DMS_COLUMN_RECORD, 'c_record_id', REC_B); check('无附件时 c_image 不写', !rowB?.c_image, JSON.stringify(rowB?.c_image)); check('无附件时 c_file 不写', !rowB?.c_file, JSON.stringify(rowB?.c_file)); // 8.5 脏值过滤:空串 / 纯空格 / 夹带的空值都不能拼进去 const REC_C = 'recC_' + Date.now().toString(36); await upsertDmsRecord({ recordId: REC_C, sessionId: SID10, question: '脏值', images: [' ', IMG_1, ''], files: undefined, }); const rowC = await rowOf(DMS_COLUMN_RECORD, 'c_record_id', REC_C); check('脏值被过滤(只剩真路径,没有多余逗号)', rowC?.c_image === IMG_1, JSON.stringify(rowC?.c_image)); check('没传 files 时不写 c_file', !rowC?.c_file, JSON.stringify(rowC?.c_file)); enterpriseInfo.value = null; for (const rec of [REC_A, REC_B, REC_C]) { const r = await rowOf(DMS_COLUMN_RECORD, 'c_record_id', rec); if (r?.id) await deleteDmsContent(DMS_COLUMN_RECORD, r.id); } console.log(' (本节测试数据已清理)'); } // ── 9. 只要对话就建行(用户 2026-09-23 决定,推翻 2026-09-18「访客不建行」)── console.log('\n【9】未登录 + 无企业信息:也要在 1887 建行'); { const SID11 = `verify_onlychat_${Date.now().toString(36)}`; const sessionRowOf = async (sid) => ( await searchDmsContents(DMS_COLUMN_SESSION, { search: [{ field: 'c_session_id', searchType: 1, content: { value: sid } }], page: 0, pageSize: 5, }) )[0]; enterpriseInfo.value = null; // 未登录 await upsertDmsSession({ sessionId: SID11, sessionTitle: '随手一问' }); const row = await sessionRowOf(SID11); check('**建了行**(此前这里直接 return,不建)', !!row, JSON.stringify(row && Object.keys(row).slice(0, 5))); if (row) { check('c_is_login = "0"', row.c_is_login === '0', JSON.stringify(row.c_is_login)); check('c_ip 存的是真实 IP(脚本里打桩成 203.0.113.9)', row.c_ip === STUB_IP, JSON.stringify(row.c_ip)); check('**c_credit_code 留空**(用户 2026-09-23 明确:不写访客标签)', !String(row.c_credit_code ?? '').trim(), JSON.stringify(row.c_credit_code)); check('企业名称 / 企业图片留空', !String(row.c_ent_name ?? '').trim() && !String(row.c_ent_image_url ?? '').trim(), JSON.stringify([row.c_ent_name, row.c_ent_image_url])); check('系统 title = 会话标题', row.title === '随手一问', JSON.stringify(row.title)); check('必填运营位:summary 空串 + 三个布尔 false(空值兜底,不猜业务值)', !String(row.summary ?? '').trim() && row.qpyszx === false && row.qyzt === false && row.rzqpyx === false, JSON.stringify([row.summary, row.qpyszx, row.qyzt, row.rzqpyx])); check('不碰运营分析回填列(c_title / c_description 仍空)', !String(row.c_title ?? '').trim() && !String(row.c_description ?? '').trim(), JSON.stringify([row.c_title, row.c_description])); } // 幂等:再写一次仍是同一行(不能每轮多一行) await upsertDmsSession({ sessionId: SID11, sessionTitle: '随手一问' }); const rows = await searchDmsContents(DMS_COLUMN_SESSION, { search: [{ field: 'c_session_id', searchType: 1, content: { value: SID11 } }], page: 0, pageSize: 5, }); check('重复 upsert 仍只有一行(幂等键 c_session_id)', rows.length === 1, `实际 ${rows.length} 行`); // 后续轮次带回企业信息时,这个"纯访客行"要能跟着被填上(别因为先建了行就写不进去) await upsertDmsSession({ sessionId: SID11, sessionTitle: '随手一问', companyInfo: { company: { Name: '戊测试公司', CreditCode: '91310000EEEEEEEEEE' } }, }); const filled = await sessionRowOf(SID11); check('后续轮次带回企业信息 → 企业名称被填上', filled?.c_ent_name === '戊测试公司', JSON.stringify(filled?.c_ent_name)); check('后续轮次带回企业信息 → 信用代码被填上', filled?.c_credit_code === '91310000EEEEEEEEEE', JSON.stringify(filled?.c_credit_code)); await deleteDmsSession(SID11); console.log(' (本节测试数据已清理)'); } console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`); process.exit(fail ? 1 : 0);