#!/usr/bin/env node /** * 宿舍通知 家长端闭环自清理测试(P8 深测)。 * 教师建临时楼/房 → 把登录学生分配到该房 → 发布 confirm + fill 两条通知到该房 → * 学生 MyNotice(自动确认)/SubmitReceipt(填报) → 教师 Receipts/Statis 校验 → * finally 逆序彻底清理:删通知(级联删回执) → 学生退宿 → 删房间 → 删楼栋。 * * 用法:TEACHER_COOKIE='…' STUDENT_COOKIE='…' [STUDENT_ID=10069] node smoke/closed-loop.mjs */ const BASE = (process.env.BASE || "http://localhost:8105").replace(/\/+$/, ""); const T = process.env.TEACHER_COOKIE || ""; const S = process.env.STUDENT_COOKIE || ""; const SID = Number(process.env.STUDENT_ID || 10069); if (!T || !S) { console.error("需 TEACHER_COOKIE 与 STUDENT_COOKIE"); process.exit(2); } const stamp = Date.now(); const BLD = "闭环测试楼" + stamp; let buildingId = null, roomId = null, noticeConfirm = null, noticeFill = null; let assigned = false; const log = (ok, name, detail = "") => console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`); async function call(cookie, method, path, { body, form } = {}) { const headers = { Accept: "application/json", Cookie: cookie }; let payload; if (body !== undefined) { headers["Content-Type"] = "application/json;charset=UTF-8"; payload = JSON.stringify(body); } else if (form !== undefined) { headers["Content-Type"] = "application/x-www-form-urlencoded;charset=UTF-8"; payload = new URLSearchParams(form).toString(); } const r = await fetch(BASE + path, { method, headers, body: payload, redirect: "manual" }); const txt = await r.text(); let j = null; try { j = JSON.parse(txt); } catch { /* non-json */ } if (r.status !== 200 || !j || j.code !== 0) throw new Error(`${method} ${path} → HTTP ${r.status} ${txt.slice(0, 160)}`); return j.data; } async function setup() { // 楼栋 await call(T, "POST", "/api/dorm/AddBuilding", { form: { buildingName: BLD, gender: "混合", floorCount: 1, roomsPerFloor: 1, bedsPerRoom: 2 } }); const bl = await call(T, "GET", "/api/dorm/BuildingList"); buildingId = (bl.find(x => x.buildingName === BLD) || {}).buildingId; if (!buildingId) throw new Error("楼栋未创建成功"); log(true, "dorm/AddBuilding", "buildingId=" + buildingId); // 房间 await call(T, "POST", "/api/dorm/AddRoom", { form: { buildingId, floor: 1, roomNo: "998", bedCount: 2 } }); const rl = await call(T, "GET", "/api/dorm/RoomList?buildingId=" + buildingId); roomId = (rl.find(x => x.roomNo === "998") || {}).roomId; if (!roomId) throw new Error("房间未创建成功"); log(true, "dorm/AddRoom", "roomId=" + roomId); // 分配学生(需在 dorm_student 池内) await call(T, "POST", "/api/dorm/AssignStudent", { form: { roomId, studentIds: String(SID) } }); assigned = true; const rs = await call(T, "GET", "/api/dorm/RoomStudents?roomId=" + roomId); log(rs.some(s => s.studentId === SID), "dorm/AssignStudent", "房内=" + JSON.stringify(rs.map(s => s.studentId))); } async function run() { // confirm 通知 noticeConfirm = await call(T, "POST", "/api/notice/Create", { body: { title: "【闭环】需确认通知" + stamp, content: "
confirm
", noticeType: "安全排查", level: "important", receiptMode: "confirm", deptId: "D001", receiveType: "room", receiveList: String(roomId), needSupervisor: 1, draft: false } }); log(!!noticeConfirm, "notice/Create(confirm)", "id=" + noticeConfirm); // fill 通知 noticeFill = await call(T, "POST", "/api/notice/Create", { body: { title: "【闭环】需填报通知" + stamp, content: "fill
", noticeType: "留宿申请", level: "normal", receiptMode: "fill", formType: "stay", deptId: "D001", receiveType: "room", receiveList: String(roomId), needSupervisor: 0, draft: false } }); log(!!noticeFill, "notice/Create(fill/stay)", "id=" + noticeFill); // 学生收件箱应见两条 const mn = await call(S, "GET", "/api/notice/MyNotices"); const ids = (mn.list || []).map(x => x.id); log(ids.includes(noticeConfirm) && ids.includes(noticeFill), "notice/MyNotices(学生可见两条)", "buckets=" + JSON.stringify(mn.buckets)); // 打开 confirm 通知 → 自动确认 const mc = await call(S, "GET", "/api/notice/MyNotice?serid=" + noticeConfirm); log(mc.my && mc.my.status === "confirmed", "notice/MyNotice(confirm 打开即自动确认)", "status=" + (mc.my && mc.my.status)); // 填报 fill 通知 const sr = await call(S, "POST", "/api/notice/SubmitReceipt", { body: { serid: noticeFill, guardianName: "测试家长", guardianPhone: "13900000000", relation: "父亲", fillData: { conclusion: "同意", stayStart: "2026-10-01", stayEnd: "2026-10-03", stayCount: "1", stayReason: "家远不便往返" }, remark: "闭环自测" } }); log(sr && sr.status === "filled", "notice/SubmitReceipt(fill)", "status=" + (sr && sr.status)); // 教师看回执名单 const rc = await call(T, "GET", "/api/notice/Receipts?serid=" + noticeFill + "&status=all"); log(rc.summary.should >= 1 && rc.summary.filled >= 1, "notice/Receipts(fill 汇总)", "should=" + rc.summary.should + " filled=" + rc.summary.filled + " rate=" + rc.summary.receiptRate + "%"); const rc2 = await call(T, "GET", "/api/notice/Receipts?serid=" + noticeConfirm + "&status=all"); log(rc2.summary.confirmed >= 1, "notice/Receipts(confirm 汇总)", "confirmed=" + rc2.summary.confirmed); // 阅读情况(房间级) const rd = await call(T, "GET", "/api/notice/ReadDetail?serid=" + noticeConfirm); log(Array.isArray(rd) && rd.length >= 1, "notice/ReadDetail", JSON.stringify(rd).slice(0, 120)); // 统计 const st = await call(T, "GET", "/api/notice/Statis"); log(st.publishedCount >= 2, "notice/Statis", "publishedCount=" + st.publishedCount + " totalReceipts=" + st.totalReceipts); // 导出回执 CSV const csv = await (await fetch(BASE + "/api/notice/ExportReceipts?serid=" + noticeFill, { headers: { Cookie: T } })).text(); log(/回执状态|留宿/.test(csv), "notice/ExportReceipts", "len=" + csv.length); } async function cleanup() { const errs = []; try { if (noticeConfirm || noticeFill) await call(T, "POST", "/api/notice/Delete", { body: [noticeConfirm, noticeFill].filter(Boolean) }); } catch (e) { errs.push("delNotice:" + e.message); } try { if (assigned && roomId) await call(T, "POST", "/api/dorm/RemoveStudent", { form: { roomId, studentId: SID } }); } catch (e) { errs.push("removeStudent:" + e.message); } try { if (roomId) await call(T, "POST", "/api/dorm/DelRoom", { form: { roomId } }); } catch (e) { errs.push("delRoom:" + e.message); } try { if (buildingId) await call(T, "POST", "/api/dorm/DelBuilding", { form: { buildingId } }); } catch (e) { errs.push("delBuilding:" + e.message); } // 校验清理干净 try { const bl = await call(T, "GET", "/api/dorm/BuildingList"); const gone = !bl.some(x => x.buildingId === buildingId); log(gone, "cleanup 楼栋已删", gone ? "" : "残留 buildingId=" + buildingId); } catch (e) { errs.push("verify:" + e.message); } if (errs.length) console.log("清理告警:", errs.join(" | ")); } (async () => { console.log(`\n=== 闭环自清理测试 @ ${BASE} · 学生 studentId=${SID} ===`); let failed = 0; try { await setup(); await run(); } catch (e) { failed++; log(false, "流程异常", e.message); } finally { console.log("\n--- 清理 ---"); await cleanup(); } console.log(failed ? "\n结果:有失败" : "\n结果:闭环全部通过(数据已清理)"); process.exit(failed ? 1 : 0); })();