/** * 教室管理后端冒烟脚本(Node 18+,无第三方依赖)。 * * 用法: * node smoke/smoke.mjs # 无认证模式(本地 YH_SSO_URLPATTERN=/none/* + ACACLASSROOM_DEVCONTEXT_ENABLED=true) * TEACHER_COOKIE="CASTGC=..." node smoke/smoke.mjs # 真实 SSO 会话(浏览器复制 Cookie) * * 覆盖:健康检查 → 基础/校区/类型/楼栋/教室 全链路(自建数据 → 断言 → 逆序清理)。 */ const BASE = process.env.SMOKE_BASE || "http://localhost:8106"; const COOKIE = process.env.TEACHER_COOKIE || ""; const headers = { ...(COOKIE ? { Cookie: COOKIE } : {}) }; let passed = 0, failed = 0; const ok = (name, cond, detail = "") => { if (cond) { passed++; console.log(` ✓ ${name}`); } else { failed++; console.error(` ✗ ${name} ${detail}`); } }; async function api(method, path, { form, json, params } = {}) { const url = new URL(BASE + path); if (params) Object.entries(params).forEach(([k, v]) => v != null && url.searchParams.set(k, v)); const opt = { method, headers: { ...headers } }; if (json != null) { opt.headers["Content-Type"] = "application/json;charset=UTF-8"; opt.body = JSON.stringify(json); } else if (form != null) { opt.headers["Content-Type"] = "application/x-www-form-urlencoded;charset=UTF-8"; opt.body = new URLSearchParams(form).toString(); } const res = await fetch(url, opt); const text = await res.text(); try { return JSON.parse(text); } catch { return { code: -1, msg: text.substring(0, 120) }; } } console.log(`[smoke] target=${BASE} auth=${COOKIE ? "cookie" : "none(dev-context)"}`); // 1. 健康 const health = await fetch(`${BASE}/actuator/health`).then(r => r.json()).catch(() => null); ok("actuator/health UP", health && health.status === "UP"); // 2. 基础 const info = await api("GET", "/api/base/GetUserInfo"); ok("GetUserInfo code=0", info.code === 0, JSON.stringify(info).substring(0, 80)); const mods = await api("GET", "/api/base/GetUserMod"); ok("GetUserMod code=0", mods.code === 0); // 3. 校区 const stamp = Date.now().toString(36); const campusName = `冒烟校区${stamp}`; const cAdd = await api("POST", "/api/campus/Add", { form: { campusName, sortOrder: 99 } }); ok("campus Add", cAdd.code === 0 && cAdd.data > 0, cAdd.msg); const campusId = cAdd.data; const cDup = await api("POST", "/api/campus/Add", { form: { campusName } }); ok("campus Add 重名拒绝", cDup.code !== 0); const cList = await api("GET", "/api/campus/List"); ok("campus List 含新校区", cList.code === 0 && cList.data.some(c => c.serID === campusId)); // 4. 教室类型 const typeName = `冒烟类型${stamp}`; const tAdd = await api("POST", "/api/classtype/Add", { form: { typeName } }); ok("classtype Add", tAdd.code === 0, tAdd.msg); const typeId = tAdd.data; // 5. 楼栋 const bAdd = await api("POST", "/api/building/Add", { form: { campusId, buildingName: `冒烟楼栋${stamp}`, buildingCode: `S${stamp.slice(-3)}`, floorCount: 2 } }); ok("building Add", bAdd.code === 0, bAdd.msg); const buildingId = bAdd.data; // 6. 教室批量添加(带设备)+ 列表 const eqSeed = await api("POST", "/api/equipment/Add", { form: { equipmentName: `冒烟设备${stamp}` } }); ok("equipment Add(独立台账)", eqSeed.code === 0, eqSeed.msg); const eqSeedId = eqSeed.data; const eqSeedDup = await api("POST", "/api/equipment/Add", { form: { equipmentName: `冒烟设备${stamp}` } }); ok("equipment Add 校内重名拒绝", eqSeedDup.code !== 0); const batch = await api("POST", "/api/classroom/BatchAdd", { json: { buildingId, floorFrom: 1, floorTo: 2, countPerFloor: 3, typeId, equipments: [{ equipmentId: eqSeedId, quantity: 2 }] } }); ok("classroom BatchAdd 6 间(带设备,每间数量 2)", batch.code === 0 && batch.data.created === 6, batch.msg); const batchBad = await api("POST", "/api/classroom/BatchAdd", { json: { buildingId, floorFrom: 1, floorTo: 9, countPerFloor: 1, typeId } }); ok("BatchAdd 楼层超限拒绝", batchBad.code !== 0); const rooms = await api("GET", "/api/classroom/List", { params: { buildingId, page: 1, count: 20 } }); ok("classroom List 6 间", rooms.code === 0 && rooms.data.total === 6, `total=${rooms.data && rooms.data.total}`); const room1 = rooms.data.list[0].serID; ok("教室行含设备关联(数量 2,名称×2)", rooms.data.list.every(r => Array.isArray(r.equipmentIds) && r.equipmentIds.includes(eqSeedId) && r.equipmentQtys && r.equipmentQtys[eqSeedId] === 2 && /冒烟设备.*×2$/.test(r.equipmentNames)), JSON.stringify(rooms.data.list[0] && rooms.data.list[0].equipmentNames)); const floors = await api("GET", "/api/classroom/Floors", { params: { buildingId } }); ok("classroom Floors [1,2]", floors.code === 0 && JSON.stringify(floors.data) === "[1,2]"); const bStats = await api("GET", "/api/building/Stats", { params: { campusId } }); ok("building Stats total=6", bStats.code === 0 && bStats.data.some(b => b.serID === buildingId && b.total === 6)); // 7. 教室编辑(含设备全量替换)+ 编号守卫 const edit = await api("POST", "/api/classroom/Edit", { json: { serID: room1, roomName: `冒烟教室${stamp}`, status: "closed", seatCount: 45, equipmentIds: [] } }); ok("classroom Edit(清空设备)", edit.code === 0, edit.msg); const roomsAfterEdit = await api("GET", "/api/classroom/List", { params: { buildingId, page: 1, count: 20 } }); const room1After = roomsAfterEdit.data.list.find(r => r.serID === room1); ok("Edit 后教室设备已清空", room1After && room1After.equipmentIds.length === 0); const editQty = await api("POST", "/api/classroom/Edit", { json: { serID: room1, equipments: [{ equipmentId: eqSeedId, quantity: 3 }] } }); ok("classroom Edit equipments 按数量替换", editQty.code === 0, editQty.msg); const roomsAfterQty = await api("GET", "/api/classroom/List", { params: { buildingId, page: 1, count: 20 } }); const room1Qty = roomsAfterQty.data.list.find(r => r.serID === room1); ok("Edit 后设备数量为 3(名称×3)", room1Qty && room1Qty.equipmentQtys && room1Qty.equipmentQtys[eqSeedId] === 3 && /冒烟设备.*×3$/.test(room1Qty.equipmentNames), JSON.stringify(room1Qty && room1Qty.equipmentQtys)); // 回归:编辑时保留原有设备关联(先删后插撞 (classroom_id, equipment_id) 唯一键) const editKeep = await api("POST", "/api/classroom/Edit", { json: { serID: room1, status: "closed", equipments: [{ equipmentId: eqSeedId, quantity: 3 }] } }); ok("classroom Edit 保留原设备关联", editKeep.code === 0, editKeep.msg); const editClear = await api("POST", "/api/classroom/Edit", { json: { serID: room1, equipmentIds: [] } }); ok("classroom Edit 旧字段 equipmentIds 清空设备(兼容)", editClear.code === 0, editClear.msg); const room2code = roomsAfterEdit.data.list[1].roomCode; const editDup = await api("POST", "/api/classroom/Edit", { json: { serID: roomsAfterEdit.data.list[1].serID, roomCode: roomsAfterEdit.data.list[2].roomCode } }); ok("Edit 楼栋内编号重复拒绝", editDup.code !== 0); // 8. 设备编辑 + 引用删除守卫(设备被教室引用时不可删) const eqList = await api("GET", "/api/equipment/List"); ok("equipment List 含引用数与统计数量(5 间 × 每间 2 = 10)", eqList.code === 0 && eqList.data.list.some(e => e.serID === eqSeedId && e.classroomCount === 5 && e.quantity === 10), JSON.stringify(eqList.data && eqList.data.list.find(e => e.serID === eqSeedId))); const eqEdit = await api("POST", "/api/equipment/Edit", { form: { serID: eqSeedId, equipmentName: `冒烟设备${stamp}` } }); ok("equipment Edit", eqEdit.code === 0, eqEdit.msg); const eqDelGuarded = await api("POST", "/api/equipment/Delete", { form: { serid: eqSeedId } }); ok("equipment Delete 被引用守卫拒绝", eqDelGuarded.code !== 0); // 9. 新增接口:列表排序 / 楼栋楼层分布 / 教室批量编辑 const sortedDesc = await api("GET", "/api/classroom/List", { params: { buildingId, page: 1, count: 20, sortField: "seatCount", order: "desc" }, }); ok( "List 排序 seatCount desc", sortedDesc.code === 0 && sortedDesc.data.list[0].seatCount >= sortedDesc.data.list[sortedDesc.data.list.length - 1].seatCount, JSON.stringify(sortedDesc.data && sortedDesc.data.list.map(r => r.seatCount)) ); const sortedFloorDesc = await api("GET", "/api/classroom/List", { params: { buildingId, page: 1, count: 20, sortField: "floor", order: "desc" }, }); ok("List 排序 floor desc 首行为 2 层", sortedFloorDesc.code === 0 && sortedFloorDesc.data.list[0].floor === 2); const defaultOrder = await api("GET", "/api/classroom/List", { params: { buildingId, page: 1, count: 20 } }); ok( "List 不传排序参数保持默认序(楼层升序 + 编号升序)", defaultOrder.code === 0 && defaultOrder.data.list[0].floor === 1 && defaultOrder.data.list[0].roomCode <= defaultOrder.data.list[1].roomCode ); const bStatsFloors = await api("GET", "/api/building/Stats", { params: { campusId } }); const mineBuilding = bStatsFloors.data && bStatsFloors.data.find(b => b.serID === buildingId); ok( "building Stats 新增 floors 楼层分布(2 层,每层 3 间)", mineBuilding && Array.isArray(mineBuilding.floors) && mineBuilding.floors.length === 2 && mineBuilding.floors[0].floor === 1 && mineBuilding.floors[0].total === 3 && mineBuilding.floors[1].floor === 2, JSON.stringify(mineBuilding && mineBuilding.floors) ); // 9.5 空间作用域查询(教室管理三栏布局):按校区 List + Tree 扁平数据 const byCampus = await api("GET", "/api/classroom/List", { params: { campusId, page: 1, count: 20 } }); ok( "List 按校区作用域命中 6 间", byCampus.code === 0 && byCampus.data.total === 6, `total=${byCampus.data && byCampus.data.total}` ); ok( "List 行含校区/楼栋名称", byCampus.data.list.every(r => r.campusName === campusName && r.buildingName === `冒烟楼栋${stamp}`), JSON.stringify(byCampus.data.list[0]) ); const byCampusFloor = await api("GET", "/api/classroom/List", { params: { campusId, floor: 1, page: 1, count: 20 } }); ok("List 校区+楼层作用域命中 3 间", byCampusFloor.code === 0 && byCampusFloor.data.total === 3); ok("List 校区/楼栋均缺拒绝", (await api("GET", "/api/classroom/List", { params: { page: 1, count: 20 } })).code !== 0); const treeRows = await api("GET", "/api/classroom/Tree"); const myTreeRows = treeRows.data ? treeRows.data.filter(r => r.buildingId === buildingId) : []; ok( "Tree 扁平数据含本楼栋 6 间(轻量字段)", treeRows.code === 0 && myTreeRows.length === 6 && myTreeRows.every(r => r.campusId === campusId && r.roomCode && r.status), JSON.stringify(myTreeRows[0]) ); // 9.7 绑定班级:班级数据源=班级管理平台接口(bschclass,本应用不落库、无 Add/Delete) // → BindClass/UnbindClass/BatchUnbind → 年级 Tab 过滤 // 前置:dev 身份 schId=3(云海中学)在平台须有高一/高二班级 const clsListAll = await api("GET", "/api/class/List"); ok("class List code=0(平台班级)", clsListAll.code === 0 && Array.isArray(clsListAll.data), clsListAll.msg); ok( "class List 仅含高中学段(gradeId 10/11/12 之外的学段被过滤)", clsListAll.code === 0 && (clsListAll.data || []).every(c => [1, 2, 3].includes(c.grade)), JSON.stringify(clsListAll.data && clsListAll.data.find(c => ![1, 2, 3].includes(c.grade))) ); const hs = (clsListAll.data || []).filter(c => c.grade === 1 || c.grade === 2); const clsA = hs.find(c => c.grade === 1 && c.classroomId == null); const clsB = hs.find(c => c.grade === 2 && c.classroomId == null); ok( "平台班级数据可用(高一/高二各有未绑定班级)", !!clsA && !!clsB, JSON.stringify(hs && hs.map(c => `${c.serID}:${c.className}:room${c.classroomId}`)) ); ok( "class List 行含 serID/className/studentCount(可空)/headTeacher", clsA && clsA.serID > 0 && !!clsA.className && (clsA.studentCount == null || Number.isInteger(clsA.studentCount)), JSON.stringify(clsA) ); const clsListG1 = await api("GET", "/api/class/List", { params: { grade: 1 } }); ok( "class List grade=1 只含高一且新班级未绑定(classroomId=null)", clsListG1.code === 0 && (clsListG1.data || []).length > 0 && (clsListG1.data || []).every(c => c.grade === 1) && (clsListG1.data || []).find(c => c.serID === clsA.serID)?.classroomId == null, JSON.stringify(clsListG1.data && clsListG1.data.length) ); const bind = await api("POST", "/api/classroom/BindClass", { json: { serID: room1, classId: clsA.serID } }); ok("classroom BindClass 返回班级名(平台展示名)", bind.code === 0 && bind.data && bind.data.className === clsA.className, bind.msg); const bindDup = await api("POST", "/api/classroom/BindClass", { json: { serID: rooms.data.list[1].serID, classId: clsA.serID } }); ok("BindClass 班级已被其他教室占用拒绝", bindDup.code !== 0); ok("BindClass 缺参拒绝", (await api("POST", "/api/classroom/BindClass", { json: { serID: room1 } })).code !== 0); ok( "BindClass 班级不在平台列表拒绝", (await api("POST", "/api/classroom/BindClass", { json: { serID: rooms.data.list[2].serID, classId: 999999999 } })).code !== 0 ); const roomsG1 = await api("GET", "/api/classroom/List", { params: { buildingId, grade: 1, page: 1, count: 20 } }); const boundRow = roomsG1.data ? roomsG1.data.list.find(r => r.serID === room1) : null; ok( "List grade=1 含已绑教室且行含班级信息(名/人数/班主任同平台源)", roomsG1.code === 0 && roomsG1.data.total === 6 && boundRow && boundRow.classId === clsA.serID && boundRow.className === clsA.className && (boundRow.studentCount ?? null) === (clsA.studentCount ?? null) && boundRow.headTeacher === clsA.headTeacher, JSON.stringify(boundRow) ); const roomsG2 = await api("GET", "/api/classroom/List", { params: { buildingId, grade: 2, page: 1, count: 20 } }); ok( "List grade=2 排除高一已绑定教室(6-1=5,其余未绑定)", roomsG2.code === 0 && roomsG2.data.total === 5 && roomsG2.data.list.every(r => r.serID !== room1 && r.classId == null), `total=${roomsG2.data && roomsG2.data.total}` ); const roomsBoundOnly = await api("GET", "/api/classroom/List", { params: { buildingId, grade: 1, bindStatus: "bound", page: 1, count: 20 } }); ok("List bindStatus=bound 只含已绑 1 间", roomsBoundOnly.code === 0 && roomsBoundOnly.data.total === 1); ok("classroom UnbindClass", (await api("POST", "/api/classroom/UnbindClass", { form: { serid: room1 } })).code === 0); ok("UnbindClass 未绑定教室静默成功", (await api("POST", "/api/classroom/UnbindClass", { form: { serid: room1 } })).code === 0); // 批量绑定两间 → 批量解绑 3 间(含 1 间未绑定 → skipped);班级属平台数据,解绑即可、无需清理 await api("POST", "/api/classroom/BindClass", { json: { serID: room1, classId: clsA.serID } }); await api("POST", "/api/classroom/BindClass", { json: { serID: rooms.data.list[1].serID, classId: clsB.serID } }); const bUnbind = await api("POST", "/api/classroom/BatchUnbind", { json: [room1, rooms.data.list[1].serID, rooms.data.list[2].serID] }); ok( "BatchUnbind 3 间(updated=2, skipped=1)", bUnbind.code === 0 && bUnbind.data.updated === 2 && bUnbind.data.skipped === 1, JSON.stringify(bUnbind.data) ); ok("BatchUnbind 空集合拒绝", (await api("POST", "/api/classroom/BatchUnbind", { json: [] })).code !== 0); // 菜单兜底(仅本地免 SSO 模式):绑定班级在第二位 if (!COOKIE) { const modsTree = await api("GET", "/api/base/GetUserMod"); const names = (modsTree.data || []).map(m => m.name); ok( "GetUserMod 菜单序 教室管理→绑定班级→基础设置", modsTree.code === 0 && JSON.stringify(names) === JSON.stringify(["教室管理", "绑定班级", "基础设置"]), JSON.stringify(names) ); } const allIds = roomsAfterEdit.data.list.map(r => r.serID); const bEditStatus = await api("POST", "/api/classroom/BatchEdit", { json: { serIDs: allIds, status: "closed" } }); ok("classroom BatchEdit 批量关闭 6 间", bEditStatus.code === 0 && bEditStatus.data.updated === 6, bEditStatus.msg); const afterBatch = await api("GET", "/api/classroom/List", { params: { buildingId, page: 1, count: 20, status: "closed" } }); ok("BatchEdit 后按 closed 过滤命中 6 间", afterBatch.code === 0 && afterBatch.data.total === 6); const bEditType = await api("POST", "/api/classroom/BatchEdit", { json: { serIDs: allIds.slice(0, 2), typeId } }); ok("classroom BatchEdit 批量改类型 2 间", bEditType.code === 0 && bEditType.data.updated === 2, bEditType.msg); ok("BatchEdit 空集合拒绝", (await api("POST", "/api/classroom/BatchEdit", { json: { serIDs: [] } })).code !== 0); ok("BatchEdit 未指定修改项拒绝", (await api("POST", "/api/classroom/BatchEdit", { json: { serIDs: [room1] } })).code !== 0); ok( "BatchEdit 非法状态拒绝", (await api("POST", "/api/classroom/BatchEdit", { json: { serIDs: [room1], status: "xxx" } })).code !== 0 ); const bEditSkip = await api("POST", "/api/classroom/BatchEdit", { json: { serIDs: [room1, 99999999], status: "open" } }); ok( "BatchEdit 不存在 id 计入 skipped(不整批失败)", bEditSkip.code === 0 && bEditSkip.data.updated === 1 && bEditSkip.data.skipped === 1, JSON.stringify(bEditSkip.data) ); // 10. 其余删除守卫(楼栋有教室 → 类型被引用 → 校区有楼栋) const delBuilding = await api("POST", "/api/building/Delete", { form: { serid: buildingId } }); ok("building Delete 守卫拒绝", delBuilding.code !== 0); const delType = await api("POST", "/api/classtype/Delete", { form: { serid: typeId } }); ok("classtype Delete 守卫拒绝", delType.code !== 0); const delCampus = await api("POST", "/api/campus/Delete", { form: { serid: campusId } }); ok("campus Delete 守卫拒绝", delCampus.code !== 0); // 11. 逆序清理:教室(关联随删)→ 楼栋 → 类型 → 设备 → 校区 const delRooms = await api("POST", "/api/classroom/Delete", { json: allIds }); ok("classroom Delete 全部(关联自动清理)", delRooms.code === 0, delRooms.msg); ok("equipment Delete(解除引用后可删)", (await api("POST", "/api/equipment/Delete", { form: { serid: eqSeedId } })).code === 0); ok("building Delete", (await api("POST", "/api/building/Delete", { form: { serid: buildingId } })).code === 0); ok("classtype Delete", (await api("POST", "/api/classtype/Delete", { form: { serid: typeId } })).code === 0); ok("campus Delete", (await api("POST", "/api/campus/Delete", { form: { serid: campusId } })).code === 0); console.log(`\n[smoke] 通过 ${passed} / ${passed + failed}`); process.exit(failed ? 1 : 0);