/**
* 端到端验证「会话 / 问答记录 / 反馈」在 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-api
*
* 脚本**不持有 token**——和浏览器一样只打代理地址。
*/
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
const base = process.argv[2] || 'https://localhost:8083/dms-api';
globalThis.VITE_DMS_API = base;
const {
upsertDmsSession,
upsertDmsRecord,
fetchDmsSessions,
fetchDmsSessionRecords,
deleteDmsSession,
writeDmsFeedback,
resolveDmsCreditCode,
parseDmsTimestamp,
nowDmsTimestamp,
searchDmsContents,
deleteDmsContent,
DMS_COLUMN_SESSION,
DMS_COLUMN_RECORD,
} = 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. 会话行 ──────────────────────────────────────────────────────
console.log('【1】会话(1887)');
check('upsertDmsSession 新增', (await upsertDmsSession({ sessionId: SESSION_ID, title: TITLE_A, creditCode: TEST_CREDIT })) === true);
let sessions = await fetchDmsSessions(TEST_CREDIT);
check('列表能查到', sessions.some((s) => s.session_id === SESSION_ID));
check('标题正确', sessions.find((s) => s.session_id === SESSION_ID)?.title === TITLE_A);
check('updated_at 合理', Math.abs((sessions.find((s) => s.session_id === SESSION_ID)?.updated_at || 0) - Date.now()) < 10 * 60 * 1000);
await upsertDmsSession({ sessionId: SESSION_ID, title: TITLE_B, creditCode: TEST_CREDIT });
sessions = await fetchDmsSessions(TEST_CREDIT);
check('改名后只有一行', sessions.filter((s) => s.session_id === SESSION_ID).length === 1);
check('标题已更新', sessions.find((s) => s.session_id === SESSION_ID)?.title === TITLE_B);
// ── 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')));
check('访客归属形态 访客_', /^访客_.+/.test(resolveDmsCreditCode()), resolveDmsCreditCode());
} 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, title: '标题甲', creditCode: TEST_CREDIT });
let row = await rowOf(DMS_COLUMN_SESSION, 'c_session_id', SID2);
check('新建会话带上了系统 title', row?.title === '标题甲', JSON.stringify(row?.title));
check('摘要字段已写入', row?.summary === '标题甲', JSON.stringify(row?.summary));
// 注:qpyszx / qyzt / rzqpyx 三个字段按要求**前端不传值**(2026-09-18 用户指示),
// 它们在 DMS 模型里是 must=true —— 写入若出现 214,先看这里,别急着改业务逻辑
await upsertDmsSession({ sessionId: SID2, title: '标题乙', creditCode: TEST_CREDIT });
row = await rowOf(DMS_COLUMN_SESSION, 'c_session_id', SID2);
check('改名后 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);
}
console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
process.exit(fail ? 1 : 0);