diff --git a/backend/prisma/migrations/20260716_mainline_stage2_notifications_sessions/migration.sql b/backend/prisma/migrations/20260716_mainline_stage2_notifications_sessions/migration.sql new file mode 100644 index 0000000..cc9b933 --- /dev/null +++ b/backend/prisma/migrations/20260716_mainline_stage2_notifications_sessions/migration.sql @@ -0,0 +1,30 @@ +-- Web 会话迁移到现有 MySQL,后端重启后本地登录、钉钉免登和公开盘点会话仍可恢复。 +CREATE TABLE `web_session` ( + `sid` VARCHAR(191) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `data` LONGTEXT NOT NULL, + `expires_at` DATETIME(3) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + PRIMARY KEY (`sid`), + INDEX `web_session_expires_at_idx` (`expires_at`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 每个钉钉投递失败异常只生成一次站内待处理记录。 +ALTER TABLE `dingtalk_delivery` + ADD COLUMN `failure_notified_at` DATETIME(3) NULL; + +-- 旧失败任务已在历史设置页中可查,本次上线不把它们重新制造成未读告警。 +UPDATE `dingtalk_delivery` +SET `failure_notified_at` = COALESCE(`updated_at`, `created_at`, CURRENT_TIMESTAMP(3)) +WHERE `status` = 'failed'; + +-- 历史组织同步消息保留为系统记录,但不再继续制造未读红点。 +UPDATE `user_notification` user_notification +INNER JOIN `notification` notification + ON notification.`id` = user_notification.`notification_id` +SET + user_notification.`is_read` = 1, + user_notification.`read_at` = COALESCE(user_notification.`read_at`, CURRENT_TIMESTAMP(3)) +WHERE notification.`type` = 'hr_sync' + AND user_notification.`is_read` = 0; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e830f90..65dae77 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -750,6 +750,7 @@ model DingTalkDelivery { payload_json String @db.Text last_error_code String? @db.VarChar(100) last_error_message String? @db.Text + failure_notified_at DateTime? sent_at DateTime? read_at DateTime? created_at DateTime @default(now()) @@ -766,6 +767,17 @@ model DingTalkDelivery { @@map("dingtalk_delivery") } +model WebSession { + sid String @id @db.VarChar(191) + data String @db.LongText + expires_at DateTime + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([expires_at]) + @@map("web_session") +} + // 维修记录 model MaintenanceRecord { id Int @id @default(autoincrement()) diff --git a/backend/src/controllers/__tests__/dingtalkController.manual-retry.test.ts b/backend/src/controllers/__tests__/dingtalkController.manual-retry.test.ts index db36a04..06caced 100644 --- a/backend/src/controllers/__tests__/dingtalkController.manual-retry.test.ts +++ b/backend/src/controllers/__tests__/dingtalkController.manual-retry.test.ts @@ -109,6 +109,7 @@ test('未取得中心任务ID时复用原行和原幂等键恢复投递', async assert.equal(createCount, 0) assert.equal(updateArgs.where.dingtalk_task_id, null) assert.equal(updateArgs.data.status, 'pending') + assert.equal(updateArgs.data.failure_notified_at, null) assert.equal(updateArgs.data.last_manual_action_by, 99) assert.equal(updateArgs.data.last_manual_action_type, 'resume_submit') const resumePayload = JSON.parse(updateArgs.data.payload_json) @@ -207,6 +208,7 @@ test('状态查询错误时恢复原中心任务回查且绝不新建补发消 assert.equal(createCount, 0) assert.equal(updateArgs.where.dingtalk_task_id, 'center-task-5') assert.equal(updateArgs.data.status, 'pending') + assert.equal(updateArgs.data.failure_notified_at, null) assert.equal(updateArgs.data.last_manual_action_by, 99) assert.equal(updateArgs.data.last_manual_action_type, 'resume_status_query') assert.equal( diff --git a/backend/src/controllers/__tests__/disposalController.state-machine.test.ts b/backend/src/controllers/__tests__/disposalController.state-machine.test.ts index 24ea0fa..1a30223 100644 --- a/backend/src/controllers/__tests__/disposalController.state-machine.test.ts +++ b/backend/src/controllers/__tests__/disposalController.state-machine.test.ts @@ -355,6 +355,7 @@ test('驳回报废时按每个 item.pre_status 分别恢复', async () => { test('行政已处理列表包含全部已审批、已驳回和已完成记录,不按处理人收窄', async () => { const originalFindMany = prisma.disposalRecord.findMany const originalCount = prisma.disposalRecord.count + const originalGroupBy = prisma.disposalRecord.groupBy let capturedWhere: any = null ;(prisma.disposalRecord.findMany as any) = async (args: any) => { @@ -367,6 +368,9 @@ test('行政已处理列表包含全部已审批、已驳回和已完成记录 }] } ;(prisma.disposalRecord.count as any) = async () => 1 + ;(prisma.disposalRecord.groupBy as any) = async () => [ + { status: 'completed', _count: { _all: 1 } } + ] try { const { res, state } = createResponse('administrative') @@ -385,12 +389,14 @@ test('行政已处理列表包含全部已审批、已驳回和已完成记录 } finally { ;(prisma.disposalRecord.findMany as any) = originalFindMany ;(prisma.disposalRecord.count as any) = originalCount + ;(prisma.disposalRecord.groupBy as any) = originalGroupBy } }) test('IT即使请求all范围也只能查询本人发起的报废记录', async () => { const originalFindMany = prisma.disposalRecord.findMany const originalCount = prisma.disposalRecord.count + const originalGroupBy = prisma.disposalRecord.groupBy let capturedWhere: any = null ;(prisma.disposalRecord.findMany as any) = async (args: any) => { @@ -398,6 +404,7 @@ test('IT即使请求all范围也只能查询本人发起的报废记录', async return [] } ;(prisma.disposalRecord.count as any) = async () => 0 + ;(prisma.disposalRecord.groupBy as any) = async () => [] try { const { res, state } = createResponse('it') @@ -411,6 +418,7 @@ test('IT即使请求all范围也只能查询本人发起的报废记录', async } finally { ;(prisma.disposalRecord.findMany as any) = originalFindMany ;(prisma.disposalRecord.count as any) = originalCount + ;(prisma.disposalRecord.groupBy as any) = originalGroupBy } }) diff --git a/backend/src/controllers/__tests__/myAssetController.hierarchy.test.ts b/backend/src/controllers/__tests__/myAssetController.hierarchy.test.ts new file mode 100644 index 0000000..7f15143 --- /dev/null +++ b/backend/src/controllers/__tests__/myAssetController.hierarchy.test.ts @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import prisma from '../../utils/prisma' +import { getMyAssets } from '../myAssetController' + +const createResponse = () => { + const state = { statusCode: 200, body: null as any } + const res = { + locals: { user: { id: 1, role: 'user', person_id: 8 } }, + status(code: number) { + state.statusCode = code + return this + }, + json(payload: any) { + state.body = payload + return this + } + } + return { res, state } +} + +test('本人资产同时返回当前递归配件和借出时递归快照并标识关系变化', async () => { + const originalPersonFindUnique = prisma.person.findUnique + const originalBorrowFindMany = prisma.borrowRecord.findMany + const originalEquipmentFindMany = prisma.equipment.findMany + + const child = { + id: 21, + equipment_code: 'JS-RAM-0021', + equipment_name: '内存', + equipment_model: '32G', + equipment_type: '内存条', + status: '已借出', + parent_id: 20, + mount_status: 'mounted', + department_id: 3, + storage_location_id: 1 + } + const grandchild = { + ...child, + id: 22, + equipment_code: 'JS-SSD-0022', + equipment_name: '固态硬盘', + equipment_model: '1T', + equipment_type: '固态硬盘', + parent_id: 21 + } + + ;(prisma.person.findUnique as any) = async () => ({ + id: 8, + real_name: '测试员工', + nickname: '魔礼红', + job_number: 'E0008', + status: 'active', + source_employee_status: '在职', + mainDepartment: { id: 3, name: '信息部' } + }) + ;(prisma.borrowRecord.findMany as any) = async () => [{ + id: 701, + equipment_id: 20, + borrow_time: new Date('2026-07-01T00:00:00Z'), + expected_return_time: null, + is_long_term: true, + notes: null, + children_snapshot: JSON.stringify([{ + id: 21, + equipment_code: child.equipment_code, + equipment_name: child.equipment_name, + equipment_model: child.equipment_model, + equipment_type: child.equipment_type, + parent_id: 20, + depth: 1 + }]), + equipment: { + id: 20, + equipment_code: 'JS-PC-0020', + equipment_name: '主机', + children: [child], + storageLocation: { id: 1, name: '仓库', code: 'WH' }, + department: { id: 3, name: '信息部' } + } + }] + ;(prisma.equipment.findMany as any) = async (args: any) => { + const parentIds = args.where.parent_id.in as number[] + if (parentIds.includes(20)) return [child] + if (parentIds.includes(21)) return [grandchild] + return [] + } + + try { + const { res, state } = createResponse() + await getMyAssets({ query: {} } as any, res as any) + + assert.equal(state.statusCode, 200) + const asset = state.body.data.assets[0] + assert.deepEqual( + asset.current_accessories.map((item: any) => ({ id: item.id, parent_id: item.parent_id, depth: item.depth })), + [ + { id: 21, parent_id: 20, depth: 1 }, + { id: 22, parent_id: 21, depth: 2 } + ] + ) + assert.deepEqual( + asset.borrowed_accessories_snapshot.map((item: any) => ({ + id: item.id, + parent_id: item.parent_id, + root_equipment_id: item.root_equipment_id, + depth: item.depth + })), + [{ id: 21, parent_id: 20, root_equipment_id: 20, depth: 1 }] + ) + assert.equal(asset.accessory_relationship_changed, true) + assert.equal(asset.borrowed_accessories_snapshot_valid, true) + assert.equal(asset.children.length, 1) + assert.equal(asset.equipment.children.length, 1) + } finally { + ;(prisma.person.findUnique as any) = originalPersonFindUnique + ;(prisma.borrowRecord.findMany as any) = originalBorrowFindMany + ;(prisma.equipment.findMany as any) = originalEquipmentFindMany + } +}) diff --git a/backend/src/controllers/__tests__/userController.list.test.ts b/backend/src/controllers/__tests__/userController.list.test.ts new file mode 100644 index 0000000..4eb26d0 --- /dev/null +++ b/backend/src/controllers/__tests__/userController.list.test.ts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import prisma from '../../utils/prisma' +import { getPersonBindingOptions, getUserList } from '../userController' + +const createResponse = () => { + const state = { statusCode: 200, body: null as any } + const res = { + locals: { user: { id: 1, role: 'admin' } }, + status(code: number) { + state.statusCode = code + return this + }, + json(payload: any) { + state.body = payload + return this + } + } + return { res, state } +} + +test('用户列表服务端分页并支持关键词、角色和状态筛选', async () => { + const originalFindMany = prisma.user.findMany + const originalCount = prisma.user.count + let capturedArgs: any = null + + ;(prisma.user.findMany as any) = async (args: any) => { + capturedArgs = args + return [] + } + ;(prisma.user.count as any) = async () => 23 + + try { + const { res, state } = createResponse() + await getUserList({ + query: { page: '3', pageSize: '10', keyword: '魔礼红', role: 'it', status: 'active' } + } as any, res as any) + + assert.equal(state.statusCode, 200) + assert.equal(capturedArgs.skip, 20) + assert.equal(capturedArgs.take, 10) + assert.equal(capturedArgs.where.role, 'it') + assert.equal(capturedArgs.where.status, 'active') + assert.match(JSON.stringify(capturedArgs.where.OR), /real_name|nickname|job_number/) + assert.deepEqual(state.body.data, { list: [], total: 23, page: 3, pageSize: 10 }) + } finally { + ;(prisma.user.findMany as any) = originalFindMany + ;(prisma.user.count as any) = originalCount + } +}) + +test('用户列表缺省参数保持旧调用可用,非法筛选返回 422', async () => { + const originalFindMany = prisma.user.findMany + const originalCount = prisma.user.count + ;(prisma.user.findMany as any) = async () => [] + ;(prisma.user.count as any) = async () => 0 + + try { + const legacy = createResponse() + await getUserList({ query: {} } as any, legacy.res as any) + assert.equal(legacy.state.statusCode, 200) + assert.equal(legacy.state.body.data.page, 1) + assert.equal(legacy.state.body.data.pageSize, 10) + + const invalid = createResponse() + await getUserList({ query: { role: 'super-admin' } } as any, invalid.res as any) + assert.equal(invalid.state.statusCode, 422) + } finally { + ;(prisma.user.findMany as any) = originalFindMany + ;(prisma.user.count as any) = originalCount + } +}) + +test('员工绑定选项分页返回就绪和绑定状态但不泄露钉钉 userid', async () => { + const originalFindMany = prisma.person.findMany + const originalCount = prisma.person.count + let capturedArgs: any = null + + ;(prisma.person.findMany as any) = async (args: any) => { + capturedArgs = args + return [{ + id: 8, + real_name: '测试员工', + nickname: '魔礼红', + job_number: 'E0008', + status: 'active', + source_userid: 'secret-dingtalk-userid', + mainDepartment: { id: 3, name: '信息部' }, + user: { id: 2, username: 'it-user', name: 'IT用户', role: 'it', status: 'active' } + }] + } + ;(prisma.person.count as any) = async () => 1 + + try { + const { res, state } = createResponse() + await getPersonBindingOptions({ + query: { + page: '1', + pageSize: '20', + keyword: '魔礼红', + bound: 'bound', + dingtalk_ready: 'true' + } + } as any, res as any) + + assert.equal(state.statusCode, 200) + assert.match(JSON.stringify(capturedArgs.where), /source_userid/) + assert.match(JSON.stringify(capturedArgs.where), /isNot/) + assert.equal(state.body.data.list[0].dingtalk_ready, true) + assert.equal(state.body.data.list[0].is_bound, true) + assert.deepEqual(state.body.data.list[0].bound_user, { + id: 2, + username: 'it-user', + name: 'IT用户', + role: 'it', + status: 'active' + }) + assert.equal(JSON.stringify(state.body).includes('secret-dingtalk-userid'), false) + assert.equal(Object.prototype.hasOwnProperty.call(state.body.data.list[0], 'source_userid'), false) + } finally { + ;(prisma.person.findMany as any) = originalFindMany + ;(prisma.person.count as any) = originalCount + } +}) diff --git a/backend/src/controllers/__tests__/workflowList.summary.test.ts b/backend/src/controllers/__tests__/workflowList.summary.test.ts new file mode 100644 index 0000000..9e231b6 --- /dev/null +++ b/backend/src/controllers/__tests__/workflowList.summary.test.ts @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import prisma from '../../utils/prisma' +import { getDisposalList } from '../disposalController' +import { getMaintenanceList } from '../maintenanceController' + +const createResponse = (role: string) => { + const state = { statusCode: 200, body: null as any } + const res = { + locals: { user: { id: 6, role } }, + status(code: number) { + state.statusCode = code + return this + }, + json(payload: any) { + state.body = payload + return this + } + } + return { res, state } +} + +test('报废 summary 按角色范围和关键词统计但不受单个状态筛选影响', async () => { + const originalFindMany = prisma.disposalRecord.findMany + const originalCount = prisma.disposalRecord.count + const originalGroupBy = prisma.disposalRecord.groupBy + let listWhere: any = null + let summaryWhere: any = null + + ;(prisma.disposalRecord.findMany as any) = async (args: any) => { + listWhere = args.where + return [] + } + ;(prisma.disposalRecord.count as any) = async () => 2 + ;(prisma.disposalRecord.groupBy as any) = async (args: any) => { + summaryWhere = args.where + return [ + { status: 'pending', _count: { _all: 2 } }, + { status: 'approved', _count: { _all: 1 } }, + { status: 'completed', _count: { _all: 4 } } + ] + } + + try { + const { res, state } = createResponse('it') + await getDisposalList({ + query: { scope: 'all', status: 'pending', keyword: '主机', page: '2', pageSize: '20' } + } as any, res as any) + + assert.equal(state.statusCode, 200) + assert.match(JSON.stringify(listWhere), /pending/) + assert.doesNotMatch(JSON.stringify(summaryWhere), /"status":"pending"/) + assert.match(JSON.stringify(summaryWhere), /"applied_by":6/) + assert.match(JSON.stringify(summaryWhere), /主机/) + assert.deepEqual(state.body.data.summary, { + total: 7, + pending: 2, + approved: 1, + rejected: 0, + completed: 4 + }) + assert.equal(state.body.data.total, 2) + assert.equal(state.body.data.page, 2) + assert.equal(state.body.data.pageSize, 20) + } finally { + ;(prisma.disposalRecord.findMany as any) = originalFindMany + ;(prisma.disposalRecord.count as any) = originalCount + ;(prisma.disposalRecord.groupBy as any) = originalGroupBy + } +}) + +test('维修 summary 按关键词和资产统计但不受单个状态筛选影响', async () => { + const originalFindMany = prisma.maintenanceRecord.findMany + const originalCount = prisma.maintenanceRecord.count + const originalGroupBy = prisma.maintenanceRecord.groupBy + let listWhere: any = null + let summaryWhere: any = null + + ;(prisma.maintenanceRecord.findMany as any) = async (args: any) => { + listWhere = args.where + return [] + } + ;(prisma.maintenanceRecord.count as any) = async () => 1 + ;(prisma.maintenanceRecord.groupBy as any) = async (args: any) => { + summaryWhere = args.where + return [ + { status: 'in_progress', _count: { _all: 1 } }, + { status: 'completed', _count: { _all: 3 } }, + { status: 'cancelled', _count: { _all: 2 } } + ] + } + + try { + const { res, state } = createResponse('it') + await getMaintenanceList({ + query: { status: 'in_progress', keyword: '无法开机', equipment_id: '10' } + } as any, res as any) + + assert.equal(state.statusCode, 200) + assert.match(JSON.stringify(listWhere), /in_progress/) + assert.doesNotMatch(JSON.stringify(summaryWhere), /in_progress/) + assert.match(JSON.stringify(summaryWhere), /无法开机/) + assert.equal(summaryWhere.equipment_id, 10) + assert.deepEqual(state.body.data.summary, { + total: 6, + in_progress: 1, + completed: 3, + cancelled: 2 + }) + assert.equal(state.body.data.total, 1) + } finally { + ;(prisma.maintenanceRecord.findMany as any) = originalFindMany + ;(prisma.maintenanceRecord.count as any) = originalCount + ;(prisma.maintenanceRecord.groupBy as any) = originalGroupBy + } +}) + +test('流程列表非法筛选给出明确 422,维修越权给出 403', async () => { + const disposal = createResponse('admin') + await getDisposalList({ query: { page: '0' } } as any, disposal.res as any) + assert.equal(disposal.state.statusCode, 422) + + const maintenanceStatus = createResponse('it') + await getMaintenanceList({ query: { status: 'broken' } } as any, maintenanceStatus.res as any) + assert.equal(maintenanceStatus.state.statusCode, 422) + + const maintenanceRole = createResponse('administrative') + await getMaintenanceList({ query: {} } as any, maintenanceRole.res as any) + assert.equal(maintenanceRole.state.statusCode, 403) +}) diff --git a/backend/src/controllers/dingtalkController.ts b/backend/src/controllers/dingtalkController.ts index 503a39a..602c69c 100644 --- a/backend/src/controllers/dingtalkController.ts +++ b/backend/src/controllers/dingtalkController.ts @@ -304,6 +304,7 @@ export const retryDingTalkDelivery = async (req: Request, res: Response) => { lease_expires_at: null, last_error_code: null, last_error_message: null, + failure_notified_at: null, last_manual_action_by: operatorUserId, last_manual_action_type: 'resume_submit', last_manual_action_at: manualActionAt, @@ -337,6 +338,7 @@ export const retryDingTalkDelivery = async (req: Request, res: Response) => { lease_expires_at: null, last_error_code: null, last_error_message: null, + failure_notified_at: null, last_manual_action_by: operatorUserId, last_manual_action_type: 'resume_status_query', last_manual_action_at: manualActionAt, diff --git a/backend/src/controllers/disposalController.ts b/backend/src/controllers/disposalController.ts index 3a35cb0..f151a1f 100644 --- a/backend/src/controllers/disposalController.ts +++ b/backend/src/controllers/disposalController.ts @@ -202,6 +202,22 @@ const getCurrentUser = (res: Response) => ({ role: String(res.locals.user?.role || '') }) +const DISPOSAL_STATUSES = ['pending', 'approved', 'rejected', 'completed'] as const + +const parseListPositiveInteger = ( + value: unknown, + fieldName: string, + fallback: number, + max?: number +) => { + if (value === undefined || value === null || value === '') return fallback + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0 || (max !== undefined && parsed > max)) { + throw Object.assign(new Error(`${fieldName}无效`), { status: 422 }) + } + return parsed +} + const withHistoricalDisposalNotes = { export const getDisposalList = async (req: Request, res: Response) => { try { const { page = 1, pageSize = 10, status, keyword, equipment_id, scope } = req.query + const currentPage = parseListPositiveInteger(page, '页码', 1) + const currentPageSize = parseListPositiveInteger(pageSize, '每页数量', 10, 100) const currentUser = getCurrentUser(res) + if (!Number.isInteger(currentUser.id) || currentUser.id <= 0) { + return errorResponse(res, '用户未登录', 401) + } const resolvedScope = String(scope || (currentUser.role === 'admin' ? 'all' : currentUser.role === 'administrative' @@ -399,50 +420,61 @@ export const getDisposalList = async (req: Request, res: Response) => { return errorResponse(res, '报废列表范围无效', 422) } - const scopeConditions: Prisma.DisposalRecordWhereInput[] = [] + const summaryConditions: Prisma.DisposalRecordWhereInput[] = [] if (currentUser.role === 'it') { - scopeConditions.push({ applied_by: currentUser.id }) + summaryConditions.push({ applied_by: currentUser.id }) } else if (currentUser.role === 'administrative') { if (resolvedScope === 'pending') { - scopeConditions.push({ status: 'pending' }) + summaryConditions.push({ status: 'pending' }) } else if (resolvedScope === 'processed') { - scopeConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } }) + summaryConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } }) } else { return errorResponse(res, '行政仅可查看全部待审批和全部已处理报废流程', 403) } } else if (currentUser.role === 'admin') { if (resolvedScope === 'mine') { - scopeConditions.push({ applied_by: currentUser.id }) + summaryConditions.push({ applied_by: currentUser.id }) } else if (resolvedScope === 'pending') { - scopeConditions.push({ status: 'pending' }) + summaryConditions.push({ status: 'pending' }) } else if (resolvedScope === 'processed') { - scopeConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } }) + summaryConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } }) } } else { return errorResponse(res, '无权查看报废流程', 403) } - if (status) scopeConditions.push({ status: String(status) }) - if (equipment_id) scopeConditions.push({ equipment_id: Number(equipment_id) }) - if (keyword) { - const keywordWhere = [ - { equipment_code: { contains: String(keyword) } }, - { disposal_reason: { contains: String(keyword) } }, - { items: { some: { equipment_code: { contains: String(keyword) } } } } - ] - scopeConditions.push({ OR: keywordWhere }) + const normalizedStatus = String(status || '').trim() + if (normalizedStatus && !DISPOSAL_STATUSES.includes(normalizedStatus as any)) { + return errorResponse(res, '报废状态无效', 422) } - const where: Prisma.DisposalRecordWhereInput = scopeConditions.length > 0 - ? { AND: scopeConditions } + if (equipment_id !== undefined && equipment_id !== '') { + summaryConditions.push({ + equipment_id: parseListPositiveInteger(equipment_id, '资产ID', 0) + }) + } + const normalizedKeyword = String(keyword || '').trim() + if (normalizedKeyword) { + const keywordWhere = [ + { equipment_code: { contains: normalizedKeyword } }, + { disposal_reason: { contains: normalizedKeyword } }, + { items: { some: { equipment_code: { contains: normalizedKeyword } } } } + ] + summaryConditions.push({ OR: keywordWhere }) + } + const summaryWhere: Prisma.DisposalRecordWhereInput = summaryConditions.length > 0 + ? { AND: summaryConditions } : {} + const where: Prisma.DisposalRecordWhereInput = normalizedStatus + ? { AND: [summaryWhere, { status: normalizedStatus }] } + : summaryWhere - const skip = (Number(page) - 1) * Number(pageSize) - const [list, total] = await Promise.all([ + const skip = (currentPage - 1) * currentPageSize + const [list, total, statusGroups] = await Promise.all([ prisma.disposalRecord.findMany({ where, skip, - take: Number(pageSize), + take: currentPageSize, orderBy: { created_at: 'desc' }, include: { equipment: { select: { equipment_name: true, equipment_type: true, unit_price: true } }, @@ -452,15 +484,40 @@ export const getDisposalList = async (req: Request, res: Response) => { _count: { select: { items: true } } } }), - prisma.disposalRecord.count({ where }) + prisma.disposalRecord.count({ where }), + prisma.disposalRecord.groupBy({ + by: ['status'], + where: summaryWhere, + _count: { _all: true } + }) ]) + const summary = { + total: 0, + pending: 0, + approved: 0, + rejected: 0, + completed: 0 + } + for (const group of statusGroups) { + const count = group._count._all + summary.total += count + if (DISPOSAL_STATUSES.includes(group.status as any)) { + summary[group.status as (typeof DISPOSAL_STATUSES)[number]] = count + } + } successResponse( res, - { list: list.map(withHistoricalDisposalNotes), total, page: Number(page), pageSize: Number(pageSize) }, + { + list: list.map(withHistoricalDisposalNotes), + total, + page: currentPage, + pageSize: currentPageSize, + summary + }, '获取成功' ) } catch (error: any) { - errorResponse(res, error.message || '获取报废记录列表失败') + errorResponse(res, error.message || '获取报废记录列表失败', error.status || 500) } } diff --git a/backend/src/controllers/maintenanceController.ts b/backend/src/controllers/maintenanceController.ts index 5d78126..c4cc61b 100644 --- a/backend/src/controllers/maintenanceController.ts +++ b/backend/src/controllers/maintenanceController.ts @@ -10,6 +10,21 @@ class MaintenanceConflictError extends Error { } const blockedMaintenanceStatuses = new Set(['已借出', '维护中', '待报废', '已报废']) +const MAINTENANCE_STATUSES = ['in_progress', 'completed', 'cancelled'] as const + +const parseListPositiveInteger = ( + value: unknown, + fieldName: string, + fallback: number, + max?: number +) => { + if (value === undefined || value === null || value === '') return fallback + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0 || (max !== undefined && parsed > max)) { + throw Object.assign(new Error(`${fieldName}无效`), { status: 422 }) + } + return parsed +} // 创建维修记录 export const createMaintenance = async (req: Request, res: Response) => { @@ -133,36 +148,74 @@ export const createMaintenance = async (req: Request, res: Response) => { export const getMaintenanceList = async (req: Request, res: Response) => { try { const { page = 1, pageSize = 10, status, keyword, equipment_id } = req.query - const skip = (Number(page) - 1) * Number(pageSize) + const currentUserRole = String(res.locals.user?.role || '') + if (!['admin', 'it'].includes(currentUserRole)) { + return errorResponse(res, '无权查看维修记录', 403) + } + const currentPage = parseListPositiveInteger(page, '页码', 1) + const currentPageSize = parseListPositiveInteger(pageSize, '每页数量', 10, 100) + const skip = (currentPage - 1) * currentPageSize - const where: any = {} - if (status) where.status = status - if (equipment_id) where.equipment_id = Number(equipment_id) - if (keyword) { - where.OR = [ - { equipment_code: { contains: String(keyword) } }, - { issue_description: { contains: String(keyword) } }, - { repair_vendor: { contains: String(keyword) } } + const normalizedStatus = String(status || '').trim() + if (normalizedStatus && !MAINTENANCE_STATUSES.includes(normalizedStatus as any)) { + return errorResponse(res, '维修状态无效', 422) + } + const summaryWhere: any = {} + if (equipment_id !== undefined && equipment_id !== '') { + summaryWhere.equipment_id = parseListPositiveInteger(equipment_id, '资产ID', 0) + } + const normalizedKeyword = String(keyword || '').trim() + if (normalizedKeyword) { + summaryWhere.OR = [ + { equipment_code: { contains: normalizedKeyword } }, + { issue_description: { contains: normalizedKeyword } }, + { repair_vendor: { contains: normalizedKeyword } } ] } + const where = normalizedStatus + ? { AND: [summaryWhere, { status: normalizedStatus }] } + : summaryWhere - const [list, total] = await Promise.all([ + const [list, total, statusGroups] = await Promise.all([ prisma.maintenanceRecord.findMany({ where, skip, - take: Number(pageSize), + take: currentPageSize, orderBy: { created_at: 'desc' }, include: { equipment: { select: { equipment_name: true, equipment_type: true } }, creator: { select: { id: true, name: true, username: true } } } }), - prisma.maintenanceRecord.count({ where }) + prisma.maintenanceRecord.count({ where }), + prisma.maintenanceRecord.groupBy({ + by: ['status'], + where: summaryWhere, + _count: { _all: true } + }) ]) - successResponse(res, { list, total, page: Number(page), pageSize: Number(pageSize) }, '获取成功') + const summary = { + total: 0, + in_progress: 0, + completed: 0, + cancelled: 0 + } + for (const group of statusGroups) { + const count = group._count._all + summary.total += count + if (MAINTENANCE_STATUSES.includes(group.status as any)) { + summary[group.status as (typeof MAINTENANCE_STATUSES)[number]] = count + } + } + + successResponse( + res, + { list, total, page: currentPage, pageSize: currentPageSize, summary }, + '获取成功' + ) } catch (error: any) { - errorResponse(res, error.message || '获取维修记录列表失败') + errorResponse(res, error.message || '获取维修记录列表失败', error.status || 500) } } diff --git a/backend/src/controllers/myAssetController.ts b/backend/src/controllers/myAssetController.ts index 70db286..9810f05 100644 --- a/backend/src/controllers/myAssetController.ts +++ b/backend/src/controllers/myAssetController.ts @@ -2,15 +2,121 @@ import type { Request, Response } from 'express' import prisma from '../utils/prisma' import { errorResponse, successResponse } from '../utils/response' -const parseChildrenSnapshot = (value: string | null) => { - if (!value) return [] +const parseChildrenSnapshot = (value: string | null, rootEquipmentId: number) => { + if (!value) return { items: [] as any[], valid: true } try { const parsed = JSON.parse(value) - return Array.isArray(parsed) ? parsed : [] + if (!Array.isArray(parsed)) return { items: [] as any[], valid: false } + + const depthById = new Map([[rootEquipmentId, 0]]) + const items = parsed + .filter((item) => item && typeof item === 'object') + .map((item: Record) => { + const id = Number(item.id) + const rawParentId = Number(item.parent_id ?? item.parent_equipment_id) + const parentId = Number.isInteger(rawParentId) && rawParentId > 0 + ? rawParentId + : rootEquipmentId + const rawDepth = Number(item.depth) + const parentDepth = depthById.get(parentId) + const depth = Number.isInteger(rawDepth) && rawDepth > 0 + ? rawDepth + : (parentDepth ?? 0) + 1 + if (Number.isInteger(id) && id > 0) depthById.set(id, depth) + return { + ...item, + id, + parent_id: parentId, + parent_equipment_id: parentId, + root_equipment_id: rootEquipmentId, + depth, + relation_source: 'borrow_snapshot' as const + } + }) + .filter((item) => Number.isInteger(item.id) && item.id > 0) + + return { items, valid: true } } catch { - return [] + return { items: [] as any[], valid: false } } } + +const currentAccessorySelect = { + id: true, + equipment_code: true, + equipment_name: true, + equipment_model: true, + equipment_type: true, + status: true, + parent_id: true, + mount_status: true, + department_id: true, + storage_location_id: true +} as const + +const loadCurrentAccessories = async (rootEquipmentIds: number[]) => { + const uniqueRootIds = [...new Set(rootEquipmentIds)] + const result = new Map(uniqueRootIds.map((id) => [id, []])) + const nodeMeta = new Map( + uniqueRootIds.map((id) => [id, { rootEquipmentId: id, depth: 0 }]) + ) + const visited = new Set(uniqueRootIds) + let parentIds = uniqueRootIds + let depth = 0 + + while (parentIds.length > 0) { + if (depth >= 100) { + throw Object.assign(new Error('资产父子关系超过 100 层,请先检查资产关系'), { status: 409 }) + } + const children = await prisma.equipment.findMany({ + where: { parent_id: { in: parentIds } }, + select: currentAccessorySelect, + orderBy: { id: 'asc' } + }) + if (children.length === 0) break + + for (const child of children) { + if (visited.has(child.id)) { + throw Object.assign( + new Error(`资产 ${child.equipment_code} 存在循环或重复父子关系,请先检查资产关系`), + { status: 409 } + ) + } + const parentMeta = child.parent_id ? nodeMeta.get(child.parent_id) : null + if (!parentMeta) { + throw Object.assign( + new Error(`资产 ${child.equipment_code} 的父子关系不完整,请先检查资产关系`), + { status: 409 } + ) + } + const item = { + ...child, + parent_equipment_id: child.parent_id, + root_equipment_id: parentMeta.rootEquipmentId, + depth: parentMeta.depth + 1, + relation_source: 'current' as const + } + visited.add(child.id) + nodeMeta.set(child.id, { + rootEquipmentId: parentMeta.rootEquipmentId, + depth: parentMeta.depth + 1 + }) + result.get(parentMeta.rootEquipmentId)?.push(item) + } + + parentIds = children.map((child) => child.id) + depth += 1 + } + + return result +} + +const getRelationshipFingerprint = (items: Array<{ id: number; parent_id?: number | null }>) => + items + .map((item) => `${item.id}:${item.parent_id ?? 'null'}`) + .sort() + .join('|') + export const getMyAssets = async (_req: Request, res: Response) => { try { const personId = Number(res.locals.user?.person_id) @@ -45,38 +151,44 @@ export const getMyAssets = async (_req: Request, res: Response) => { include: { storageLocation: { select: { id: true, name: true, code: true } }, department: { select: { id: true, name: true } }, - children: { - orderBy: { id: 'asc' }, - select: { - id: true, - equipment_code: true, - equipment_name: true, - equipment_model: true, - equipment_type: true, - status: true, - parent_id: true - } - } + children: { orderBy: { id: 'asc' }, select: currentAccessorySelect } } } } }) + const currentAccessoriesByRoot = await loadCurrentAccessories( + records.map((record) => record.equipment_id) + ) + successResponse(res, { person, - assets: records.map((record) => ({ - borrow_record_id: record.id, - borrow_time: record.borrow_time, - expected_return_time: record.expected_return_time, - is_long_term: record.is_long_term, - notes: record.notes, - equipment: record.equipment, - children: record.equipment.children.length > 0 - ? record.equipment.children - : parseChildrenSnapshot(record.children_snapshot) - })) + assets: records.map((record) => { + const currentAccessories = currentAccessoriesByRoot.get(record.equipment_id) || [] + const snapshot = parseChildrenSnapshot(record.children_snapshot, record.equipment_id) + const relationshipChanged = !snapshot.valid || + getRelationshipFingerprint(currentAccessories) !== getRelationshipFingerprint(snapshot.items) + const currentDirectChildren = currentAccessories.filter((item) => item.depth === 1) + const legacyChildren = currentDirectChildren.length > 0 + ? currentDirectChildren + : snapshot.items + + return { + borrow_record_id: record.id, + borrow_time: record.borrow_time, + expected_return_time: record.expected_return_time, + is_long_term: record.is_long_term, + notes: record.notes, + equipment: { ...record.equipment, children: currentDirectChildren }, + children: legacyChildren, + current_accessories: currentAccessories, + borrowed_accessories_snapshot: snapshot.items, + borrowed_accessories_snapshot_valid: snapshot.valid, + accessory_relationship_changed: relationshipChanged + } + }) }, '获取成功') } catch (error: any) { - errorResponse(res, error.message || '获取本人资产失败') + errorResponse(res, error.message || '获取本人资产失败', error.status || 500) } } diff --git a/backend/src/controllers/userController.ts b/backend/src/controllers/userController.ts index 58b657c..b5ab8ac 100644 --- a/backend/src/controllers/userController.ts +++ b/backend/src/controllers/userController.ts @@ -7,6 +7,7 @@ import { getPermissionsForRole, isUserRole } from '../utils/permissions' const SALT_ROUNDS = 10 const USER_STATUSES = ['active', 'inactive'] as const +const PERSON_BOUND_FILTERS = ['all', 'bound', 'unbound'] as const class UserValidationError extends Error { constructor(message: string, public readonly status = 422) { @@ -83,6 +84,28 @@ const getCurrentUserId = (res: Response) => { return Number.isInteger(userId) && userId > 0 ? userId : null } +const parseListPositiveInteger = ( + value: unknown, + fieldName: string, + fallback: number, + max?: number +) => { + if (value === undefined || value === null || value === '') return fallback + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0 || (max !== undefined && parsed > max)) { + throw new UserValidationError(`${fieldName}无效`) + } + return parsed +} + +const parseOptionalBoolean = (value: unknown, fieldName: string) => { + if (value === undefined || value === null || value === '') return undefined + const normalized = String(value).trim().toLowerCase() + if (normalized === 'true') return true + if (normalized === 'false') return false + throw new UserValidationError(`${fieldName}必须是 true 或 false`) +} + const lockActiveAdministrators = async (tx: Prisma.TransactionClient) => { return tx.$queryRawUnsafe>( "SELECT id FROM `user` WHERE role = 'admin' AND status = 'active' ORDER BY id FOR UPDATE" @@ -141,33 +164,47 @@ const sendUserWriteError = (res: Response, error: any, fallbackMessage: string) export const getUserList = async (req: Request, res: Response) => { try { - const { page = 1, pageSize = 10, keyword, role, status } = req.query + const { page = 1, pageSize = 10, keyword, role, roles, status } = req.query + const currentPage = parseListPositiveInteger(page, '页码', 1) + const currentPageSize = parseListPositiveInteger(pageSize, '每页数量', 10, 100) - const where: any = {} + const where: Prisma.UserWhereInput = {} - if (keyword) { + const normalizedKeyword = String(keyword || '').trim() + if (normalizedKeyword) { where.OR = [ - { username: { contains: keyword as string } }, - { name: { contains: keyword as string } }, - { email: { contains: keyword as string } } + { username: { contains: normalizedKeyword } }, + { name: { contains: normalizedKeyword } }, + { email: { contains: normalizedKeyword } }, + { person: { is: { real_name: { contains: normalizedKeyword } } } }, + { person: { is: { nickname: { contains: normalizedKeyword } } } }, + { person: { is: { job_number: { contains: normalizedKeyword } } } } ] } - if (role) { - where.role = role + const normalizedRole = String(role || (Array.isArray(roles) ? roles[0] : roles) || '').trim() + if (normalizedRole) { + if (!isUserRole(normalizedRole)) { + return errorResponse(res, '角色必须是 admin、administrative、it 或 user', 422) + } + where.role = normalizedRole } - if (status) { - where.status = status + const normalizedStatus = String(status || '').trim() + if (normalizedStatus) { + if (!USER_STATUSES.includes(normalizedStatus as (typeof USER_STATUSES)[number])) { + return errorResponse(res, '状态必须是 active 或 inactive', 422) + } + where.status = normalizedStatus } - const skip = (Number(page) - 1) * Number(pageSize) + const skip = (currentPage - 1) * currentPageSize const [list, total] = await Promise.all([ prisma.user.findMany({ where, skip, - take: Number(pageSize), + take: currentPageSize, orderBy: { created_at: 'desc' }, include: { person: { select: personSelect } } }), @@ -179,13 +216,109 @@ export const getUserList = async (req: Request, res: Response) => { { list: list.map(sanitizeUser), total, - page: Number(page), - pageSize: Number(pageSize) + page: currentPage, + pageSize: currentPageSize }, '获取成功' ) } catch (error: any) { - errorResponse(res, error.message || '获取用户列表失败') + errorResponse(res, error.message || '获取用户列表失败', error.status || 500) + } +} + +export const getPersonBindingOptions = async (req: Request, res: Response) => { + try { + const { page = 1, pageSize = 20, keyword, status, bound = 'all', dingtalk_ready } = req.query + const currentPage = parseListPositiveInteger(page, '页码', 1) + const currentPageSize = parseListPositiveInteger(pageSize, '每页数量', 20, 100) + const normalizedBound = String(bound || 'all').trim().toLowerCase() + if (!PERSON_BOUND_FILTERS.includes(normalizedBound as (typeof PERSON_BOUND_FILTERS)[number])) { + return errorResponse(res, '绑定状态必须是 all、bound 或 unbound', 422) + } + const normalizedStatus = String(status || '').trim() + if (normalizedStatus && !USER_STATUSES.includes(normalizedStatus as (typeof USER_STATUSES)[number])) { + return errorResponse(res, '员工状态必须是 active 或 inactive', 422) + } + const dingtalkReady = parseOptionalBoolean(dingtalk_ready, 'dingtalk_ready') + + const conditions: Prisma.PersonWhereInput[] = [] + const normalizedKeyword = String(keyword || '').trim() + if (normalizedKeyword) { + conditions.push({ + OR: [ + { real_name: { contains: normalizedKeyword } }, + { nickname: { contains: normalizedKeyword } }, + { job_number: { contains: normalizedKeyword } }, + { mainDepartment: { is: { name: { contains: normalizedKeyword } } } } + ] + }) + } + if (normalizedStatus) conditions.push({ status: normalizedStatus }) + if (normalizedBound === 'bound') conditions.push({ user: { isNot: null } }) + if (normalizedBound === 'unbound') conditions.push({ user: { is: null } }) + if (dingtalkReady === true) { + conditions.push({ + status: 'active', + AND: [{ source_userid: { not: null } }, { source_userid: { not: '' } }] + }) + } else if (dingtalkReady === false) { + conditions.push({ + OR: [ + { status: { not: 'active' } }, + { source_userid: null }, + { source_userid: '' } + ] + }) + } + const where: Prisma.PersonWhereInput = conditions.length > 0 ? { AND: conditions } : {} + const skip = (currentPage - 1) * currentPageSize + + const [list, total] = await Promise.all([ + prisma.person.findMany({ + where, + skip, + take: currentPageSize, + orderBy: [{ status: 'asc' }, { nickname: 'asc' }, { id: 'asc' }], + select: { + id: true, + real_name: true, + nickname: true, + job_number: true, + status: true, + source_userid: true, + mainDepartment: { select: { id: true, name: true } }, + user: { + select: { + id: true, + username: true, + name: true, + role: true, + status: true + } + } + } + }), + prisma.person.count({ where }) + ]) + + successResponse(res, { + list: list.map((person) => ({ + id: person.id, + real_name: person.real_name, + nickname: person.nickname, + job_number: person.job_number, + status: person.status, + main_department: person.mainDepartment, + dingtalk_ready: person.status === 'active' && Boolean(person.source_userid?.trim()), + is_bound: Boolean(person.user), + bound_user: person.user + })), + total, + page: currentPage, + pageSize: currentPageSize + }, '获取成功') + } catch (error: any) { + errorResponse(res, error.message || '获取员工绑定选项失败', error.status || 500) } } diff --git a/backend/src/index.ts b/backend/src/index.ts index f4ebbdb..d56e3df 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -32,7 +32,7 @@ import printAgentRoutes from './routes/printAgent' import printDownloadRoutes from './routes/printDownloads' import myAssetRoutes from './routes/myAssets' import dingtalkRoutes from './routes/dingtalk' -import { keycloak, memoryStore } from './utils/keycloak' +import { keycloak } from './utils/keycloak' import { protect } from './middleware/auth' import { startHrSyncScheduler } from './services/hrSyncService' import { ensurePublicAssetPersonExists } from './services/systemReservedPersonService' @@ -40,6 +40,7 @@ import { startDingTalkDeliveryWorker } from './services/dingtalkDeliveryService' import { isCorsOriginAllowed } from './utils/corsPolicy' import { unsafeOriginGuard } from './middleware/originGuard' import { assertSupportedAuthMode, resolveSessionSecret } from './utils/authMode' +import { persistentSessionStore, startSessionCleanupWorker } from './utils/sessionStore' const app = express() const PORT = process.env.PORT || 3000 @@ -63,7 +64,7 @@ app.use( secret: SESSION_SECRET, resave: false, saveUninitialized: false, - store: memoryStore, + store: persistentSessionStore, cookie: { secure: SESSION_COOKIE_SECURE, httpOnly: true, @@ -129,6 +130,8 @@ app.listen(PORT, () => { console.log(`Server is running on http://localhost:${PORT}`) }) +startSessionCleanupWorker() + ensurePublicAssetPersonExists().catch((error) => { console.error('初始化系统保留人员失败:', error) }) diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 9c1f834..e6e1496 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -1,6 +1,7 @@ import { Router } from 'express' import { getUserList, + getPersonBindingOptions, createUser, updateUser, resetPassword, @@ -15,6 +16,7 @@ const router = Router() router.use(requirePermission(PERMISSIONS.USERS_MANAGE)) router.get('/', getUserList) +router.get('/person-options', getPersonBindingOptions) router.post('/', createUser) router.put('/:id', updateUser) router.post('/:id/reset-password', resetPassword) diff --git a/backend/src/services/__tests__/businessNotificationService.test.ts b/backend/src/services/__tests__/businessNotificationService.test.ts index 58e55e7..f537178 100644 --- a/backend/src/services/__tests__/businessNotificationService.test.ts +++ b/backend/src/services/__tests__/businessNotificationService.test.ts @@ -141,7 +141,99 @@ test('站内收件人与钉钉收件人相互独立且动态字段会转义Markd actionTitle: '立即审批' } }) - assert.deepEqual(stationRows, [{ user_id: 11 }]) + assert.equal(stationRows.length, 1) + assert.equal(stationRows[0].user_id, 11) + assert.equal(stationRows[0].is_read, true) + assert.ok(stationRows[0].read_at instanceof Date) assert.equal(deliveryCount, -1) assert.equal(escapeNotificationMarkdownInline('[资产](https://evil.example)'), '\\[资产\\]\\(https://evil\\.example\\)') }) + +test('全量模式没有有效钉钉收件人时生成管理员未读待处理记录', async () => { + const notificationRows: any[] = [] + let deliveryCount = 0 + const client = { + user: { + findMany: async () => [{ id: 99 }] + }, + notification: { + create: async (args: any) => { + notificationRows.push(args.data) + return { id: 10 + notificationRows.length } + } + }, + dingTalkDelivery: { + createMany: async (args: any) => { + deliveryCount += args.data.length + return { count: args.data.length } + } + } + } + + await createBusinessNotification(client as any, { + eventType: 'hr_sync_failed', + businessType: 'hr_sync', + businessId: 81, + title: '花名册同步失败', + content: '请管理员处理', + actionUrl: '/notifications', + stationRecipients: [{ id: 3 }], + dingTalkRecipients: [], + rollout: { enabled: true, fullRollout: true, testUserIds: [] }, + dingTalk: { + title: '花名册同步失败', + content: '正文', + actionTitle: '查看同步结果' + } + }) + + assert.equal(deliveryCount, 0) + assert.equal(notificationRows.length, 2) + assert.equal(notificationRows[0].title, '花名册同步失败') + assert.equal(notificationRows[0].recipients.create[0].is_read, true) + assert.ok(notificationRows[0].recipients.create[0].read_at instanceof Date) + assert.match(notificationRows[1].title, /^钉钉未发送:/) + assert.equal(notificationRows[1].level, 'error') + assert.deepEqual(notificationRows[1].recipients.create, [{ user_id: 99 }]) + const metadata = JSON.parse(notificationRows[1].metadata) + assert.equal(metadata.record_state, 'attention') + assert.equal(metadata.attention_reason, 'NO_VALID_DINGTALK_RECIPIENT') +}) + +test('测试灰度名单外没有收件人时只保存已读系统记录且不积压任务', async () => { + let notificationData: any + let deliveryCount = 0 + const client = { + notification: { + create: async (args: any) => { + notificationData = args.data + return { id: 11 } + } + }, + dingTalkDelivery: { + createMany: async (args: any) => { + deliveryCount += args.data.length + return { count: args.data.length } + } + } + } + + await createBusinessNotification(client as any, { + eventType: 'disposal_applied', + businessType: 'disposal', + businessId: 82, + title: '报废申请', + content: '测试灰度外人员', + actionUrl: '/workflows/disposals/82', + stationRecipients: [{ id: 4 }], + dingTalkRecipients: [], + rollout: { enabled: true, fullRollout: false, testUserIds: ['ding-test-user'] }, + dingTalk: { title: '报废申请', content: '正文', actionTitle: '查看' } + }) + + assert.equal(deliveryCount, 0) + assert.equal(notificationData.title, '报废申请') + assert.equal(notificationData.recipients.create[0].is_read, true) + assert.ok(notificationData.recipients.create[0].read_at instanceof Date) + assert.equal(JSON.parse(notificationData.metadata).record_state, 'record') +}) diff --git a/backend/src/services/__tests__/dingtalkDeliveryService.notification-center.test.ts b/backend/src/services/__tests__/dingtalkDeliveryService.notification-center.test.ts index be5cf67..a32a80a 100644 --- a/backend/src/services/__tests__/dingtalkDeliveryService.notification-center.test.ts +++ b/backend/src/services/__tests__/dingtalkDeliveryService.notification-center.test.ts @@ -1,10 +1,14 @@ import test from 'node:test' import assert from 'node:assert/strict' +import prisma from '../../utils/prisma' import { buildClaimableDeliveryWhere, + buildDeliveryFailureAttention, buildRefreshableDeliveryWhere, buildRefreshErrorUpdate, + buildUnreportedFailureWhere, + ensureDeliveryFailureEscalation, isDeliveryRecipientStillEligible, resolveDeliveryActionTitle, resolveMonotonicNotificationCenterStatus, @@ -78,3 +82,80 @@ test('投递前按当前账号、审批角色、员工状态和userid精确复 assert.equal(capturedWhere.person.is.status, 'active') assert.equal(capturedWhere.person.is.source_userid, 'ding-user-7') }) + +test('投递终态失败与状态回查异常使用不同的管理员提示', () => { + const failed = buildDeliveryFailureAttention({ + recipientName: '魔礼红', + payloadJson: JSON.stringify({ title: '报废审批' }) + }) + assert.equal(failed.reason, 'DINGTALK_DELIVERY_FAILED') + assert.match(failed.content, /最终投递失败/) + + const queryFailed = buildDeliveryFailureAttention({ + recipientName: '魔礼红', + payloadJson: JSON.stringify({ notification_center_status_query_error: 'NETWORK' }) + }) + assert.equal(queryFailed.reason, 'DINGTALK_STATUS_QUERY_FAILED') + assert.match(queryFailed.content, /请勿重复补发/) +}) + +test('失败告警扫描只处理统一通知中心的新失败任务', () => { + assert.deepEqual(buildUnreportedFailureWhere(), { + provider_type: 'notification_center', + status: 'failed', + failure_notified_at: null + }) +}) + +test('投递失败告警通过CAS只创建一次且忽略历史工作通知', async () => { + const originalTransaction = prisma.$transaction + let providerType = 'notification_center' + let failureNotifiedAt: Date | null = null + let notificationCount = 0 + const tx = { + dingTalkDelivery: { + findUnique: async () => ({ + id: 51, + status: 'failed', + provider_type: providerType, + failure_notified_at: failureNotifiedAt, + event_type: 'disposal_applied', + business_type: 'disposal', + business_id: 5, + recipient_user_id: 7, + last_error_code: 'TIMEOUT', + last_error_message: '超时', + payload_json: JSON.stringify({ title: '报废审批' }), + recipient: { name: '魔礼红', username: 'admin' } + }), + updateMany: async () => { + if (failureNotifiedAt) return { count: 0 } + failureNotifiedAt = new Date() + return { count: 1 } + } + }, + user: { + findMany: async () => [{ id: 1 }] + }, + notification: { + create: async () => { + notificationCount += 1 + return { id: notificationCount } + } + } + } + ;(prisma.$transaction as any) = async (callback: any) => callback(tx) + + try { + await ensureDeliveryFailureEscalation(51) + await ensureDeliveryFailureEscalation(51) + assert.equal(notificationCount, 1) + + providerType = 'work_notice' + failureNotifiedAt = null + await ensureDeliveryFailureEscalation(52) + assert.equal(notificationCount, 1) + } finally { + ;(prisma.$transaction as any) = originalTransaction + } +}) diff --git a/backend/src/services/__tests__/hrSyncService.test.ts b/backend/src/services/__tests__/hrSyncService.test.ts index d6dbb90..a7f6270 100644 --- a/backend/src/services/__tests__/hrSyncService.test.ts +++ b/backend/src/services/__tests__/hrSyncService.test.ts @@ -3,9 +3,11 @@ import assert from 'node:assert/strict' import { buildHrSyncPreviewLine, + buildHrSyncDingTalkContent, formatBorrowedAssetSummary, resolveHrSyncPersonDisplayName, resolveSourcePersonStatus, + shouldNotifyHrSync, shouldUseSourceDepartmentBinding } from '../hrSyncHelpers' @@ -128,3 +130,22 @@ test('buildHrSyncPreviewLine 在没有结构化资产时仍兼容旧的详情文 assert.equal(line, '岗位变动:悟空(名下资产:JS-PC-2001 / 主机)') }) + +test('花名册同步无变化只保存日志,有变化或失败才通知管理员', () => { + assert.equal(shouldNotifyHrSync('success', 0), false) + assert.equal(shouldNotifyHrSync('success', 2), true) + assert.equal(shouldNotifyHrSync('failed', 0), true) +}) + +test('花名册钉钉汇总按时间、结果和待处理内容排版', () => { + const content = buildHrSyncDingTalkContent({ + status: 'success', + occurredAt: new Date('2026-07-16T02:00:00.000Z'), + changesCount: 3, + summary: '新增人员 2 项,部门变化 1 项。' + }) + + assert.match(content, /\*\*同步时间:\*\* 2026年7月16日 10:00/) + assert.match(content, /\*\*执行结果:\*\* 发现 3 项变化,需要管理员核对/) + assert.match(content, /\*\*待处理内容:\*\* 新增人员 2 项,部门变化 1 项。/) +}) diff --git a/backend/src/services/businessNotificationService.ts b/backend/src/services/businessNotificationService.ts index ef125fd..d712267 100644 --- a/backend/src/services/businessNotificationService.ts +++ b/backend/src/services/businessNotificationService.ts @@ -1,5 +1,5 @@ import type { Prisma } from '@prisma/client' -import { getDingTalkConfigStatus, getDingTalkSendRolloutStatus } from './dingtalkClient' +import { getDingTalkSendRolloutStatus } from './dingtalkClient' type TransactionClient = Prisma.TransactionClient @@ -28,10 +28,11 @@ export type DingTalkRecipientRollout = { } export const getCurrentDingTalkRecipientRollout = (): DingTalkRecipientRollout => { - const config = getDingTalkConfigStatus() const rollout = getDingTalkSendRolloutStatus() return { - enabled: config.send_enabled, + // rollout 表示业务是否要求发送;配置完整性由 Worker/客户端单独处理。 + // 这样全量模式临时缺配置时仍会留下可审计的投递任务,而不是静默丢消息。 + enabled: rollout.enabled, fullRollout: rollout.fullRollout, testUserIds: rollout.testUserIds } @@ -154,6 +155,7 @@ export const createBusinessNotification = async ( content: string level?: string actionUrl: string + relatedSyncJobId?: number | null metadata?: Record stationRecipients: StationNotificationRecipient[] dingTalkRecipients: NotificationRecipient[] @@ -163,11 +165,21 @@ export const createBusinessNotification = async ( actionTitle: string messageVersion?: string } + rollout?: DingTalkRecipientRollout } ) => { const stationRecipients = mergeRecipients(input.stationRecipients) const dingTalkRecipients = mergeRecipients(input.dingTalkRecipients) - if (stationRecipients.length === 0 && dingTalkRecipients.length === 0) return null + const rollout = input.rollout || getCurrentDingTalkRecipientRollout() + const missingRecipientAttention = rollout.enabled && rollout.fullRollout && dingTalkRecipients.length === 0 + const attentionRecipients = missingRecipientAttention + ? await findActiveStationUsersByRoles(client, ['admin']) + : [] + if ( + stationRecipients.length === 0 && + dingTalkRecipients.length === 0 && + attentionRecipients.length === 0 + ) return null const messageVersion = compactNotificationText(input.dingTalk.messageVersion, 'v1', 30) const deliveries = dingTalkRecipients.map((recipient) => { @@ -202,14 +214,49 @@ export const createBusinessNotification = async ( type: input.businessType, level: input.level || 'info', action_url: input.actionUrl, + related_sync_job_id: input.relatedSyncJobId ?? null, metadata: JSON.stringify({ event_type: input.eventType, business_type: input.businessType, business_id: input.businessId, + record_state: 'record', + attention_reason: null, + delivery_count: deliveries.length, ...(input.metadata || {}) }), recipients: { - create: stationRecipients.map((recipient) => ({ user_id: recipient.id })) + create: stationRecipients.map((recipient) => ({ + user_id: recipient.id, + is_read: true, + read_at: new Date() + })) + } + } + }) + : null + + const attentionNotification = attentionRecipients.length > 0 + ? await client.notification.create({ + data: { + title: `钉钉未发送:${input.title}`, + content: `${input.content}\n\n系统未找到有效的钉钉收件人,请管理员检查角色、员工绑定和在职状态。`, + type: 'dingtalk_delivery', + level: 'error', + action_url: '/settings?tab=users', + // 异常记录点击后应进入人员绑定处理页,不能被同步详情弹窗截获。 + related_sync_job_id: null, + metadata: JSON.stringify({ + event_type: input.eventType, + business_type: input.businessType, + business_id: input.businessId, + record_state: 'attention', + attention_reason: 'NO_VALID_DINGTALK_RECIPIENT', + delivery_count: deliveries.length + }), + recipients: { + create: attentionRecipients.map((recipient) => ({ + user_id: recipient.id + })) } } }) @@ -222,5 +269,5 @@ export const createBusinessNotification = async ( }) } - return notification + return notification || attentionNotification } diff --git a/backend/src/services/dingtalkDeliveryService.ts b/backend/src/services/dingtalkDeliveryService.ts index 9d56d3f..be33135 100644 --- a/backend/src/services/dingtalkDeliveryService.ts +++ b/backend/src/services/dingtalkDeliveryService.ts @@ -143,6 +143,131 @@ const errorDetails = (error: unknown) => { } } +export const ensureDeliveryFailureEscalation = async (deliveryId: number) => { + await prisma.$transaction(async (tx) => { + const delivery = await tx.dingTalkDelivery.findUnique({ + where: { id: deliveryId }, + select: { + id: true, + status: true, + provider_type: true, + failure_notified_at: true, + event_type: true, + business_type: true, + business_id: true, + recipient_user_id: true, + last_error_code: true, + last_error_message: true, + payload_json: true, + recipient: { select: { name: true, username: true } } + } + }) + if ( + !delivery || + delivery.provider_type !== 'notification_center' || + delivery.status !== 'failed' || + delivery.failure_notified_at + ) return + + const administrators = await tx.user.findMany({ + where: { role: 'admin', status: 'active' }, + select: { id: true } + }) + if (administrators.length === 0) return + + const escalatedAt = new Date() + const claimed = await tx.dingTalkDelivery.updateMany({ + where: { + id: delivery.id, + provider_type: 'notification_center', + status: 'failed', + failure_notified_at: null + }, + data: { failure_notified_at: escalatedAt } + }) + if (claimed.count !== 1) return + + const recipientName = delivery.recipient?.name || delivery.recipient?.username || `用户 #${delivery.recipient_user_id}` + const attention = buildDeliveryFailureAttention({ + recipientName, + payloadJson: delivery.payload_json + }) + await tx.notification.create({ + data: { + title: attention.title, + content: attention.content, + type: 'dingtalk_delivery', + level: 'error', + action_url: '/settings?tab=dingtalk', + metadata: JSON.stringify({ + record_state: 'attention', + attention_reason: attention.reason, + delivery_id: delivery.id, + event_type: delivery.event_type, + business_type: delivery.business_type, + business_id: delivery.business_id, + recipient_user_id: delivery.recipient_user_id, + error_code: delivery.last_error_code, + error_message: delivery.last_error_message + }), + recipients: { + create: administrators.map((administrator) => ({ user_id: administrator.id })) + } + } + }) + }) +} + +const reportDeliveryFailure = async (deliveryId: number) => { + try { + await ensureDeliveryFailureEscalation(deliveryId) + } catch (error) { + console.error(`[NotificationCenterWorker] 创建投递失败待处理记录失败 delivery=${deliveryId}:`, error) + } +} + +export const buildUnreportedFailureWhere = (): Prisma.DingTalkDeliveryWhereInput => ({ + provider_type: 'notification_center', + status: 'failed', + failure_notified_at: null +}) + +const escalateNextUnreportedFailure = async () => { + const delivery = await prisma.dingTalkDelivery.findFirst({ + where: buildUnreportedFailureWhere(), + orderBy: { updated_at: 'asc' }, + select: { id: true } + }) + if (delivery) await reportDeliveryFailure(delivery.id) +} + +export const buildDeliveryFailureAttention = (input: { + recipientName: string + payloadJson: string +}) => { + let statusQueryFailed = false + try { + const payload = JSON.parse(input.payloadJson) as Record + statusQueryFailed = Boolean(payload.notification_center_status_query_error) + } catch { + // 无法解析的任务正文属于真正的投递失败,不按“状态未知”处理。 + } + + if (statusQueryFailed) { + return { + title: '钉钉通知状态回查异常', + content: `发送给 ${input.recipientName} 的通知已经在通知中心建立任务,但系统暂时无法确认最终状态。请勿重复补发;修复配置后在投递记录中恢复状态回查。`, + reason: 'DINGTALK_STATUS_QUERY_FAILED' + } + } + + return { + title: '钉钉通知投递失败', + content: `发送给 ${input.recipientName} 的业务通知最终投递失败,请管理员检查通知配置或人员绑定后处理。`, + reason: 'DINGTALK_DELIVERY_FAILED' + } +} + const scheduleDeliveryFailure = async ( delivery: NonNullable>>, error: unknown @@ -152,7 +277,7 @@ const scheduleDeliveryFailure = async ( const failed = details.deterministic || exhausted const delayMinutes = BACKOFF_MINUTES[Math.min(delivery.attempt_count - 1, BACKOFF_MINUTES.length - 1)] - await updateSendingDeliveryIfLeaseOwned(delivery.id, { + const updated = await updateSendingDeliveryIfLeaseOwned(delivery.id, { status: failed ? 'failed' : 'retrying', next_attempt_at: failed ? delivery.next_attempt_at @@ -162,6 +287,9 @@ const scheduleDeliveryFailure = async ( last_error_code: details.code, last_error_message: details.message.slice(0, 2000) }) + if (updated && failed) { + await reportDeliveryFailure(delivery.id) + } } const parsePayload = (payloadJson: string) => { @@ -257,6 +385,13 @@ const processDelivery = async ( delivery: NonNullable>> ) => { try { + const sendConfig = getDingTalkConfigStatus() + if (!sendConfig.send_enabled) { + throw new DeliveryPayloadError( + `通知发送配置不完整: ${sendConfig.missing.join(', ')}`, + 'CONFIG_INCOMPLETE' + ) + } if (!delivery.recipient_dingtalk_userid?.trim()) { throw new DeliveryPayloadError('收件人未绑定有效的在职员工档案', 'RECIPIENT_UNBOUND') } @@ -276,8 +411,9 @@ const processDelivery = async ( actionTitle: resolveDeliveryActionTitle(delivery.event_type, payload.actionTitle), actionUrl: payload.actionUrl }) - await updateSendingDeliveryIfLeaseOwned(delivery.id, { - ...buildNotificationCenterUpdate(result, null), + const update = buildNotificationCenterUpdate(result, null) + const updated = await updateSendingDeliveryIfLeaseOwned(delivery.id, { + ...update, dingtalk_task_id: result.id, payload_json: updatePayloadMetadata(delivery.payload_json, { notification_center_last_status: result.status @@ -285,6 +421,9 @@ const processDelivery = async ( lease_owner: null, lease_expires_at: null }) + if (updated && update.status === 'failed') { + await reportDeliveryFailure(delivery.id) + } } catch (error: unknown) { await scheduleDeliveryFailure(delivery, error) } @@ -358,8 +497,9 @@ const refreshOneDeliveryResult = async () => { const monotonicResult = monotonicStatus === result.status ? result : { ...result, status: monotonicStatus } - await updateRefreshDeliveryIfLeaseOwned(delivery.id, { - ...buildNotificationCenterUpdate(monotonicResult, delivery.sent_at), + const update = buildNotificationCenterUpdate(monotonicResult, delivery.sent_at) + const updated = await updateRefreshDeliveryIfLeaseOwned(delivery.id, { + ...update, payload_json: updatePayloadMetadata(delivery.payload_json, { notification_center_last_status: monotonicStatus, notification_center_status_query_error: null @@ -367,14 +507,20 @@ const refreshOneDeliveryResult = async () => { lease_owner: null, lease_expires_at: null }) + if (updated.count === 1 && update.status === 'failed') { + await reportDeliveryFailure(delivery.id) + } } catch (error: unknown) { const update = buildRefreshErrorUpdate(error) - await updateRefreshDeliveryIfLeaseOwned(delivery.id, { + const updated = await updateRefreshDeliveryIfLeaseOwned(delivery.id, { ...update, payload_json: updatePayloadMetadata(delivery.payload_json, { notification_center_status_query_error: update.last_error_code }) }) + if (updated.count === 1 && update.status === 'failed') { + await reportDeliveryFailure(delivery.id) + } } } @@ -474,16 +620,17 @@ export const startDingTalkDeliveryWorker = () => { if (deliveryBusy) return deliveryBusy = true try { - if (!getNotificationCenterConfigStatus().configured) return - const configStatus = getDingTalkConfigStatus() const sendRollout = getDingTalkSendRolloutStatus() - if (configStatus.send_enabled && sendRollout.enabled) { + if (sendRollout.enabled) { const delivery = await claimNextDelivery( sendRollout.fullRollout ? null : sendRollout.testUserIds ) if (delivery) await processDelivery(delivery) } - await refreshOneDeliveryResult() + if (sendRollout.enabled || getNotificationCenterConfigStatus().configured) { + await refreshOneDeliveryResult() + } + await escalateNextUnreportedFailure() } catch (error) { console.error('[NotificationCenterWorker] 投递循环失败:', error) } finally { diff --git a/backend/src/services/hrSyncHelpers.ts b/backend/src/services/hrSyncHelpers.ts index 602f0e7..a8f6a90 100644 --- a/backend/src/services/hrSyncHelpers.ts +++ b/backend/src/services/hrSyncHelpers.ts @@ -1,3 +1,9 @@ +import { + compactNotificationText, + escapeNotificationMarkdownInline, + formatBeijingDateTime +} from './businessNotificationService' + export type OpenBorrowAssetPreview = { equipmentId?: number | null borrowRecordId?: number | null @@ -114,3 +120,29 @@ export const buildHrSyncPreviewLine = (change: { return assetSummary ? `${change.title}(名下资产:${assetSummary})` : change.title } + +export const shouldNotifyHrSync = ( + status: 'success' | 'failed', + changesCount: number +) => status === 'failed' || changesCount > 0 + +export const buildHrSyncDingTalkContent = (input: { + status: 'success' | 'failed' + occurredAt: Date + changesCount: number + summary: string +}) => { + const resultText = input.status === 'failed' + ? '同步失败,需要管理员处理' + : `发现 ${input.changesCount} 项变化,需要管理员核对` + const summary = input.status === 'failed' + ? '请进入固定资产系统查看错误详情,并检查上游连接或同步配置。' + : compactNotificationText(input.summary, '请进入系统查看本次变化明细。', 300) + + return [ + `**同步时间:** ${formatBeijingDateTime(input.occurredAt)}`, + `**执行结果:** ${escapeNotificationMarkdownInline(resultText)}`, + `**待处理内容:** ${escapeNotificationMarkdownInline(summary)}`, + '> 这是一条需要人工关注的花名册同步汇总。' + ].join('\n\n') +} diff --git a/backend/src/services/hrSyncService.ts b/backend/src/services/hrSyncService.ts index b472345..2419498 100644 --- a/backend/src/services/hrSyncService.ts +++ b/backend/src/services/hrSyncService.ts @@ -9,11 +9,18 @@ import { } from '../utils/organization' import { buildHrSyncPreviewLine, + buildHrSyncDingTalkContent, resolveHrSyncPersonDisplayName, resolveSourcePersonStatus, + shouldNotifyHrSync, shouldUseSourceDepartmentBinding, type OpenBorrowAssetPreview } from './hrSyncHelpers' +import { + createBusinessNotification, + findActiveDingTalkUsersByRoles, + findActiveStationUsersByRoles +} from './businessNotificationService' type SourceDepartment = { sourceGroupId: number | null @@ -1113,41 +1120,40 @@ const persistHrSyncArtifacts = async ({ }) } - const notification = await tx.notification.create({ - data: { + let notificationId: number | null = null + if (shouldNotifyHrSync(status, changes.length)) { + const stationRecipients = await findActiveStationUsersByRoles(tx, ['admin']) + const dingTalkRecipients = await findActiveDingTalkUsersByRoles(tx, ['admin']) + const notification = await createBusinessNotification(tx, { + eventType: status === 'failed' ? 'hr_sync_failed' : 'hr_sync_attention', + businessType: 'hr_sync', + businessId: syncJob.id, title: summary.title, content: summary.content, - type: 'hr_sync', - level: status === 'failed' ? 'error' : changes.length > 0 ? 'warning' : 'info', - action_url: '/notifications', - related_sync_job_id: syncJob.id, - metadata: toJsonText({ + level: status === 'failed' ? 'error' : 'warning', + actionUrl: '/notifications', + relatedSyncJobId: syncJob.id, + metadata: { status, changes_count: changes.length, counts: summary.counts, preview_changes: summary.previewChanges - }) - } - }) - - const notificationId: number | null = notification.id - - const adminUsers = await tx.user.findMany({ - where: { - role: 'admin', - status: 'active' - }, - select: { id: true } - }) - - if (adminUsers.length > 0) { - await tx.userNotification.createMany({ - data: adminUsers.map((user) => ({ - user_id: user.id, - notification_id: notification.id - })), - skipDuplicates: true + }, + stationRecipients, + dingTalkRecipients, + dingTalk: { + title: status === 'failed' ? '花名册同步失败' : '花名册同步待处理', + content: buildHrSyncDingTalkContent({ + status, + occurredAt: new Date(), + changesCount: changes.length, + summary: summary.content + }), + actionTitle: '查看同步结果', + messageVersion: 'summary-v1' + } }) + notificationId = notification?.id ?? null } return { diff --git a/backend/src/types/express-session.d.ts b/backend/src/types/express-session.d.ts index 55f9462..d046819 100644 --- a/backend/src/types/express-session.d.ts +++ b/backend/src/types/express-session.d.ts @@ -1,44 +1,8 @@ +import 'express-session' + declare module 'express-session' { - import { RequestHandler } from 'express' - - interface SessionOptions { - secret: string - resave?: boolean - saveUninitialized?: boolean - store?: Store - cookie?: { - secure?: boolean - httpOnly?: boolean - sameSite?: boolean | 'lax' | 'strict' | 'none' - maxAge?: number - } + interface SessionData { + user?: Record + inventoryParticipant?: Record } - - interface Store { - get: (sid: string, callback: (err: any, sess: any | null) => void) => void - set: (sid: string, sess: any, callback?: (err?: any) => void) => void - destroy: (sid: string, callback?: (err?: any) => void) => void - } - - class MemoryStore implements Store { - get: Store['get'] - set: Store['set'] - destroy: Store['destroy'] - } - - interface Session { - [key: string]: any - } - - interface SessionRequest extends Express.Request { - session?: Session - } - - function session(options: SessionOptions): RequestHandler - - namespace session { - export { MemoryStore } - } - - export = session } diff --git a/backend/src/utils/__tests__/sessionStore.test.ts b/backend/src/utils/__tests__/sessionStore.test.ts new file mode 100644 index 0000000..5353bae --- /dev/null +++ b/backend/src/utils/__tests__/sessionStore.test.ts @@ -0,0 +1,87 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import prisma from '../prisma' +import { + PrismaSessionStore, + deserializeSession, + resolveSessionExpiresAt, + serializeSession +} from '../sessionStore' + +test('会话过期时间优先使用cookie.expires并兼容maxAge', () => { + const now = new Date('2026-07-16T00:00:00.000Z') + const explicit = resolveSessionExpiresAt({ + cookie: { expires: new Date('2026-07-16T02:00:00.000Z') } as any + }, now) + const byMaxAge = resolveSessionExpiresAt({ cookie: { maxAge: 30_000 } as any }, now) + + assert.equal(explicit.toISOString(), '2026-07-16T02:00:00.000Z') + assert.equal(byMaxAge.toISOString(), '2026-07-16T00:00:30.000Z') +}) + +test('会话序列化往返保留登录身份', () => { + const source = { + cookie: { maxAge: 60_000 }, + user: { id: 7, role: 'it' } + } as any + assert.deepEqual(deserializeSession(serializeSession(source)), source) + assert.throws(() => deserializeSession('[]'), /会话内容无效/) +}) + +test('持久化Store会读取有效会话并删除已过期会话', async () => { + const originalFindUnique = prisma.webSession.findUnique + const originalDeleteMany = prisma.webSession.deleteMany + let stored: any = { + data: JSON.stringify({ cookie: { maxAge: 60_000 }, user: { id: 9 } }), + expires_at: new Date(Date.now() + 60_000) + } + let deleteCount = 0 + ;(prisma.webSession.findUnique as any) = async () => stored + ;(prisma.webSession.deleteMany as any) = async () => { + deleteCount += 1 + return { count: 1 } + } + + try { + const store = new PrismaSessionStore() + const active = await new Promise((resolve, reject) => { + store.get('active', (error, value) => error ? reject(error) : resolve(value)) + }) + assert.equal(active.user.id, 9) + assert.equal(deleteCount, 0) + + stored = { ...stored, expires_at: new Date(Date.now() - 1000) } + const expired = await new Promise((resolve, reject) => { + store.get('expired', (error, value) => error ? reject(error) : resolve(value)) + }) + assert.equal(expired, null) + assert.equal(deleteCount, 1) + } finally { + ;(prisma.webSession.findUnique as any) = originalFindUnique + ;(prisma.webSession.deleteMany as any) = originalDeleteMany + } +}) + +test('touch只续期而不以旧请求覆盖已更新的会话内容', async () => { + const originalUpdateMany = prisma.webSession.updateMany + let updateArgs: any + ;(prisma.webSession.updateMany as any) = async (args: any) => { + updateArgs = args + return { count: 1 } + } + + try { + const store = new PrismaSessionStore() + await new Promise((resolve, reject) => { + store.touch('sid-1', { + cookie: { maxAge: 60_000 }, + user: { id: 1, stale: true } + } as any, (error) => error ? reject(error) : resolve()) + }) + assert.ok(updateArgs.data.expires_at instanceof Date) + assert.equal(Object.prototype.hasOwnProperty.call(updateArgs.data, 'data'), false) + } finally { + ;(prisma.webSession.updateMany as any) = originalUpdateMany + } +}) diff --git a/backend/src/utils/__tests__/stage2Migration.test.ts b/backend/src/utils/__tests__/stage2Migration.test.ts new file mode 100644 index 0000000..6240fef --- /dev/null +++ b/backend/src/utils/__tests__/stage2Migration.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import test from 'node:test' + +const migrationPath = path.resolve( + __dirname, + '../../../prisma/migrations/20260716_mainline_stage2_notifications_sessions/migration.sql' +) + +test('第二阶段迁移创建持久化会话并增加投递失败告警标记', () => { + const sql = readFileSync(migrationPath, 'utf8') + assert.match(sql, /CREATE TABLE `web_session`/i) + assert.match(sql, /`sid` VARCHAR\(191\) CHARACTER SET ascii COLLATE ascii_bin NOT NULL/i) + assert.match(sql, /`expires_at` DATETIME\(3\) NOT NULL/i) + assert.match(sql, /ADD COLUMN `failure_notified_at` DATETIME\(3\) NULL/i) + assert.match(sql, /SET `failure_notified_at` = COALESCE\(`updated_at`, `created_at`/i) + assert.match(sql, /WHERE `status` = 'failed'/i) +}) + +test('第二阶段迁移只归档旧花名册未读消息且不删除记录', () => { + const sql = readFileSync(migrationPath, 'utf8') + assert.match(sql, /notification\.`type` = 'hr_sync'/i) + assert.match(sql, /user_notification\.`is_read` = 0/i) + assert.match(sql, /user_notification\.`is_read` = 1/i) + assert.doesNotMatch(sql, /DELETE\s+FROM\s+`?user_notification`?/i) +}) diff --git a/backend/src/utils/keycloak.ts b/backend/src/utils/keycloak.ts index a38f2ae..cf4054e 100644 --- a/backend/src/utils/keycloak.ts +++ b/backend/src/utils/keycloak.ts @@ -1,6 +1,6 @@ -import session from 'express-session' import Keycloak from 'keycloak-connect' import { assertKeycloakConfiguration } from './authMode' +import { persistentSessionStore } from './sessionStore' type KeycloakEnvConfig = { serverUrl: string @@ -18,11 +18,9 @@ const keycloakConfig: KeycloakEnvConfig = { clientSecret: validatedKeycloakConfig?.clientSecret } -const memoryStore = new session.MemoryStore() - const keycloak = new Keycloak( { - store: memoryStore, + store: persistentSessionStore, scope: 'openid profile email' }, { @@ -41,4 +39,4 @@ const keycloak = new Keycloak( } as any ) -export { keycloak, memoryStore } +export { keycloak } diff --git a/backend/src/utils/sessionStore.ts b/backend/src/utils/sessionStore.ts new file mode 100644 index 0000000..becb2c7 --- /dev/null +++ b/backend/src/utils/sessionStore.ts @@ -0,0 +1,122 @@ +import session, { type SessionData } from 'express-session' +import prisma from './prisma' + +const DEFAULT_SESSION_MAX_AGE_MS = 60 * 60 * 1000 +const CLEANUP_INTERVAL_MS = 15 * 60 * 1000 + +type StoreCallback = (error?: unknown) => void +type StoreGetCallback = (error: unknown, session?: SessionData | null) => void + +const finish = (callback: StoreCallback | undefined, error?: unknown) => { + if (callback) callback(error) +} + +export const resolveSessionExpiresAt = ( + sessionData: Partial | null | undefined, + now: Date = new Date() +) => { + const cookie = sessionData?.cookie as SessionData['cookie'] | undefined + const explicitExpires = cookie?.expires ? new Date(cookie.expires) : null + if (explicitExpires && !Number.isNaN(explicitExpires.getTime())) { + return explicitExpires + } + + const maxAge = Number(cookie?.maxAge) + return new Date( + now.getTime() + (Number.isFinite(maxAge) && maxAge > 0 ? maxAge : DEFAULT_SESSION_MAX_AGE_MS) + ) +} + +export const serializeSession = (sessionData: SessionData) => JSON.stringify(sessionData) + +export const deserializeSession = (value: string): SessionData => { + const parsed = JSON.parse(value) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('持久化会话内容无效') + } + return parsed as SessionData +} + +export class PrismaSessionStore extends session.Store { + get(sid: string, callback: StoreGetCallback) { + void (async () => { + try { + const stored = await prisma.webSession.findUnique({ + where: { sid }, + select: { data: true, expires_at: true } + }) + if (!stored) { + callback(null, null) + return + } + if (stored.expires_at.getTime() <= Date.now()) { + await prisma.webSession.deleteMany({ where: { sid } }) + callback(null, null) + return + } + callback(null, deserializeSession(stored.data)) + } catch (error) { + callback(error) + } + })() + } + + set(sid: string, sessionData: SessionData, callback?: StoreCallback) { + void (async () => { + try { + const data = serializeSession(sessionData) + const expiresAt = resolveSessionExpiresAt(sessionData) + await prisma.webSession.upsert({ + where: { sid }, + create: { sid, data, expires_at: expiresAt }, + update: { data, expires_at: expiresAt } + }) + finish(callback) + } catch (error) { + finish(callback, error) + } + })() + } + + destroy(sid: string, callback?: StoreCallback) { + void (async () => { + try { + await prisma.webSession.deleteMany({ where: { sid } }) + finish(callback) + } catch (error) { + finish(callback, error) + } + })() + } + + touch(sid: string, sessionData: SessionData, callback?: StoreCallback) { + void (async () => { + try { + await prisma.webSession.updateMany({ + where: { sid, expires_at: { gt: new Date() } }, + data: { + expires_at: resolveSessionExpiresAt(sessionData) + } + }) + finish(callback) + } catch (error) { + finish(callback, error) + } + })() + } +} + +export const persistentSessionStore = new PrismaSessionStore() + +export const startSessionCleanupWorker = () => { + const cleanup = async () => { + try { + await prisma.webSession.deleteMany({ where: { expires_at: { lte: new Date() } } }) + } catch (error) { + console.error('[session] 清理过期会话失败:', error) + } + } + + setTimeout(cleanup, 30_000).unref() + setInterval(cleanup, CLEANUP_INTERVAL_MS).unref() +} diff --git a/frontend/scripts/test-phase-two-presentation.ts b/frontend/scripts/test-phase-two-presentation.ts new file mode 100644 index 0000000..887459e --- /dev/null +++ b/frontend/scripts/test-phase-two-presentation.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import { + getDashboardRoleActions, + getDashboardRoleTitle +} from '../src/views/dashboard/dashboardRoleConfig.ts' +import { + getNotificationActionLabel, + getNotificationRecordBadge, + isNotificationAttentionRequired +} from '../src/views/notifications/notificationPresentation.ts' +import { getUserPersonOptionAvailability } from '../src/utils/userPersonOption.ts' + +const administrativeActions = getDashboardRoleActions('administrative') +assert.ok(administrativeActions.some((action) => action.id === 'asset-create'), '行政首页应突出资产入库') +assert.ok(administrativeActions.some((action) => action.id === 'inventory'), '行政首页应提供盘点入口') +assert.ok(administrativeActions.some((action) => action.id === 'disposal-approval'), '行政首页应提供报废审批入口') + +const itActions = getDashboardRoleActions('it') +assert.ok(itActions.some((action) => action.id === 'borrow'), 'IT 首页应提供借出入口') +assert.ok(itActions.some((action) => action.id === 'transfer'), 'IT 首页应提供调拨入口') +assert.ok(itActions.some((action) => action.id === 'maintenance'), 'IT 首页应提供维修入口') +assert.equal(getDashboardRoleTitle('admin'), '管理总览') + +const availablePerson = { + id: 1, + real_name: '测试员工', + nickname: '测试', + status: 'active' as const, + dingtalk_ready: true, + is_bound: false, + bound_user: null +} +assert.equal(getUserPersonOptionAvailability(availablePerson).selectable, true, '在职且钉钉就绪的未绑定员工应可选择') + +const boundToOther = { + ...availablePerson, + is_bound: true, + bound_user: { id: 9, username: 'other', name: '其他账号', role: 'it', status: 'active' as const } +} +assert.equal(getUserPersonOptionAvailability(boundToOther, 10).selectable, false, '已绑定其他账号的员工不可重复选择') +assert.equal(getUserPersonOptionAvailability(boundToOther, 9).selectable, true, '编辑当前账号时应保留自己的员工绑定') +assert.equal(getUserPersonOptionAvailability({ ...availablePerson, dingtalk_ready: false }).selectable, false) + +const ordinaryRecord = { level: 'info', related_sync_job_id: null, action_url: '/workflows/disposals/1' } +assert.equal(getNotificationRecordBadge(ordinaryRecord).label, '系统记录', '普通流程记录不应强调未读') +assert.equal(isNotificationAttentionRequired(ordinaryRecord), false) +assert.equal(getNotificationActionLabel(ordinaryRecord), '查看相关记录') + +const errorRecord = { level: 'error', related_sync_job_id: null, action_url: '/settings' } +assert.equal(getNotificationRecordBadge(errorRecord).label, '异常待处理') +assert.equal(isNotificationAttentionRequired(errorRecord), true) +assert.equal(getNotificationActionLabel(errorRecord), '前往处理') + +console.log('phase two presentation tests: ok') diff --git a/frontend/src/api/disposal.ts b/frontend/src/api/disposal.ts index ac26e9d..4ae1f49 100644 --- a/frontend/src/api/disposal.ts +++ b/frontend/src/api/disposal.ts @@ -1,5 +1,5 @@ import request from './index' -import type { ApiResponse, PaginatedResponse } from '@/types' +import type { ApiResponse } from '@/types' export interface DisposalRecord { id: number @@ -75,36 +75,44 @@ export interface CreateDisposalParams { notes?: string } -// 创建报废申请 -export const createDisposal = (data: CreateDisposalParams) => { - return request.post('/disposal', data) +export interface DisposalSummary { + total: number + pending: number + approved: number + rejected: number + completed: number } -// 获取报废记录列表 -export const getDisposalList = (params: { page?: number; pageSize?: number; status?: string; keyword?: string; equipment_id?: number; scope?: 'pending' | 'mine' | 'processed' | 'all' }) => { - return request - .get>>('/disposal/list', { params }) - .then((response) => response.data) +export interface DisposalListData { + list: DisposalRecord[] + total: number + page: number + pageSize: number + summary: DisposalSummary } -// 获取报废记录详情 -export const getDisposalDetail = (id: number) => { - return request - .get>(`/disposal/${id}`) - .then((response) => response.data) -} +export const createDisposal = (data: CreateDisposalParams) => request.post('/disposal', data) -// 审批报废申请 -export const approveDisposal = (id: number, data: { approved: boolean; reject_reason?: string }) => { - return request.put(`/disposal/${id}/approve`, data) -} +export const getDisposalList = (params: { + page?: number + pageSize?: number + status?: string + keyword?: string + equipment_id?: number + scope?: 'pending' | 'mine' | 'processed' | 'all' +}) => request + .get>('/disposal/list', { params }) + .then((response) => response.data) -// 完成报废处置 -export const completeDisposal = (id: number, data: { actual_value?: number; completion_notes?: string }) => { - return request.put(`/disposal/${id}/complete`, data) -} +export const getDisposalDetail = (id: number) => request + .get>(`/disposal/${id}`) + .then((response) => response.data) -// 获取资产报废历史 -export const getEquipmentDisposalHistory = (equipmentId: number) => { - return request.get(`/disposal/equipment/${equipmentId}/history`) -} +export const approveDisposal = (id: number, data: { approved: boolean; reject_reason?: string }) => + request.put(`/disposal/${id}/approve`, data) + +export const completeDisposal = (id: number, data: { actual_value?: number; completion_notes?: string }) => + request.put(`/disposal/${id}/complete`, data) + +export const getEquipmentDisposalHistory = (equipmentId: number) => + request.get(`/disposal/equipment/${equipmentId}/history`) diff --git a/frontend/src/api/maintenance.ts b/frontend/src/api/maintenance.ts index 2d44da2..d343db5 100644 --- a/frontend/src/api/maintenance.ts +++ b/frontend/src/api/maintenance.ts @@ -1,5 +1,5 @@ import request from './index' -import type { ApiResponse, PaginatedResponse } from '@/types' +import type { ApiResponse } from '@/types' export interface MaintenanceRecord { id: number @@ -30,39 +30,43 @@ export interface CreateMaintenanceParams { expected_end_date?: string } -// 创建维修记录 -export const createMaintenance = (data: CreateMaintenanceParams) => { - return request.post('/maintenance', data) +export interface MaintenanceSummary { + total: number + in_progress: number + completed: number + cancelled: number } -// 获取维修记录列表 -export const getMaintenanceList = (params: { page?: number; pageSize?: number; status?: string; keyword?: string; equipment_id?: number }) => { - return request - .get>>('/maintenance/list', { params }) - .then((response) => response.data) +export interface MaintenanceListData { + list: MaintenanceRecord[] + total: number + page: number + pageSize: number + summary: MaintenanceSummary } -// 获取维修记录详情 -export const getMaintenanceDetail = (id: number) => { - return request.get(`/maintenance/${id}`) -} +export const createMaintenance = (data: CreateMaintenanceParams) => request.post('/maintenance', data) -// 更新维修记录 -export const updateMaintenance = (id: number, data: Partial) => { - return request.put(`/maintenance/${id}`, data) -} +export const getMaintenanceList = (params: { + page?: number + pageSize?: number + status?: string + keyword?: string + equipment_id?: number +}) => request + .get>('/maintenance/list', { params }) + .then((response) => response.data) -// 完成维修 -export const completeMaintenance = (id: number, data: { actual_cost?: number; repair_result?: string }) => { - return request.put(`/maintenance/${id}/complete`, data) -} +export const getMaintenanceDetail = (id: number) => request.get(`/maintenance/${id}`) -// 取消维修 -export const cancelMaintenance = (id: number, data: { reason?: string }) => { - return request.put(`/maintenance/${id}/cancel`, data) -} +export const updateMaintenance = (id: number, data: Partial) => + request.put(`/maintenance/${id}`, data) -// 获取资产维修历史 -export const getEquipmentMaintenanceHistory = (equipmentId: number) => { - return request.get(`/maintenance/equipment/${equipmentId}/history`) -} +export const completeMaintenance = (id: number, data: { actual_cost?: number; repair_result?: string }) => + request.put(`/maintenance/${id}/complete`, data) + +export const cancelMaintenance = (id: number, data: { reason?: string }) => + request.put(`/maintenance/${id}/cancel`, data) + +export const getEquipmentMaintenanceHistory = (equipmentId: number) => + request.get(`/maintenance/equipment/${equipmentId}/history`) diff --git a/frontend/src/api/myAssets.ts b/frontend/src/api/myAssets.ts index 72fc1eb..463ba00 100644 --- a/frontend/src/api/myAssets.ts +++ b/frontend/src/api/myAssets.ts @@ -1,6 +1,24 @@ import api from './index' import type { ApiResponse, BoundPersonSummary, Equipment, EquipmentTreeNode } from '@/types' +export interface MyAssetAccessory { + id: number + equipment_code: string + equipment_name: string + equipment_model?: string | null + equipment_type: string + status?: string | null + parent_id?: number | null + parent_equipment_id?: number | null + root_equipment_id?: number | null + depth: number + mount_status?: string | null + department_id?: number | null + department?: { id: number; name: string } | null + storage_location_id?: number | null + storageLocation?: { id: number; name: string; code?: string | null } | null +} + export interface MyAssetRecord { borrow_record_id: number borrow_time: string @@ -8,6 +26,10 @@ export interface MyAssetRecord { is_long_term?: boolean equipment: Equipment children?: EquipmentTreeNode[] + current_accessories: MyAssetAccessory[] + borrowed_accessories_snapshot: MyAssetAccessory[] + borrowed_accessories_snapshot_valid: boolean + accessory_relationship_changed: boolean } export interface MyAssetsResponse { diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index 7f2b5ac..d514374 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -10,11 +10,54 @@ export interface UserListParams { person_id?: number | null } +export interface UserPersonOptionBoundUser { + id: number | string + username: string + name: string + role: string + status: 'active' | 'inactive' +} + +export interface UserPersonOption { + id: number + real_name: string + nickname: string + job_number?: string | null + status: 'active' | 'inactive' + main_department?: { id: number; name: string } | null + dingtalk_ready: boolean + is_bound: boolean + bound_user?: UserPersonOptionBoundUser | null +} + +export interface UserPersonOptionListParams { + page?: number + pageSize?: number + keyword?: string + status?: 'active' | 'inactive' + bound?: 'all' | 'bound' | 'unbound' + dingtalkReady?: boolean +} + export const getUserList = async (params: UserListParams = {}) => { const response = await api.get>>('/users', { params }) return response.data } +export const getUserPersonOptions = async (params: UserPersonOptionListParams = {}) => { + const response = await api.get>>('/users/person-options', { + params: { + page: params.page, + pageSize: params.pageSize, + keyword: params.keyword, + status: params.status, + bound: params.bound, + dingtalk_ready: typeof params.dingtalkReady === 'boolean' ? String(params.dingtalkReady) : undefined + } + }) + return response.data +} + export const createUser = async (data: { username: string password: string @@ -58,7 +101,6 @@ export const resetUserPassword = async (id: number | string, newPassword: string return response.data } - export const deleteUser = async (id: number | string) => { const response = await api.delete>(`/users/${id}`) return response.data diff --git a/frontend/src/components/MobileActionSheet.vue b/frontend/src/components/MobileActionSheet.vue new file mode 100644 index 0000000..f7afdb2 --- /dev/null +++ b/frontend/src/components/MobileActionSheet.vue @@ -0,0 +1,55 @@ + + + diff --git a/frontend/src/components/PersonBindingPicker.vue b/frontend/src/components/PersonBindingPicker.vue index ec55b5c..8bfeeea 100644 --- a/frontend/src/components/PersonBindingPicker.vue +++ b/frontend/src/components/PersonBindingPicker.vue @@ -8,6 +8,12 @@

当前绑定

{{ selectedLabel }}

{{ selectedMeta }}

+
+ {{ selectedAvailability.label }} + + {{ selectedPerson.dingtalk_ready ? '钉钉已就绪' : '钉钉未就绪' }} + +
@@ -79,26 +85,29 @@ diff --git a/frontend/src/views/settings/components/MaintenanceManagement.vue b/frontend/src/views/settings/components/MaintenanceManagement.vue index 8e82ba3..b79521e 100644 --- a/frontend/src/views/settings/components/MaintenanceManagement.vue +++ b/frontend/src/views/settings/components/MaintenanceManagement.vue @@ -7,17 +7,21 @@

维修管理

+
+

总记录

+

{{ formatSummaryCount(summary?.total) }}

+

维修中

-

{{ pageSummary.inProgress }}

+

{{ formatSummaryCount(summary?.in_progress) }}

已完成

-

{{ pageSummary.completed }}

+

{{ formatSummaryCount(summary?.completed) }}

已取消

-

{{ pageSummary.cancelled }}

+

{{ formatSummaryCount(summary?.cancelled) }}

@@ -59,10 +63,21 @@
-

维修记录

+
+

维修记录

+

全部 {{ summary?.total || 0 }} 条,筛选结果 {{ total }} 条

+
+
正在加载维修记录...
+
+

{{ listError }}

+ +
+ +
+ + + +
@@ -231,37 +258,47 @@ diff --git a/frontend/src/views/settings/components/UserManagement.vue b/frontend/src/views/settings/components/UserManagement.vue index b198b7a..47cb5c6 100644 --- a/frontend/src/views/settings/components/UserManagement.vue +++ b/frontend/src/views/settings/components/UserManagement.vue @@ -1,9 +1,54 @@