diff --git a/.env.example b/.env.example index 42ba4aa..fd40a7c 100644 --- a/.env.example +++ b/.env.example @@ -22,18 +22,13 @@ HR_SYNC_ALLOWED_STATUS_VALUES=3 HR_SYNC_INACTIVE_STATUS_VALUES=5 HR_SYNC_EXCLUDED_DEPARTMENTS=供应商,快递公司,外部交流 HR_SYNC_EXCLUDED_NAME_KEYWORDS= -DINGTALK_APP_KEY= -DINGTALK_APP_SECRET= -DINGTALK_AGENT_ID= -DINGTALK_CORP_ID= -DINGTALK_PUBLIC_BASE_URL=https://asset.example.com -DINGTALK_SEND_ENABLED=false -DINGTALK_NOTIFICATION_CHANNEL=work_notice +APP_PUBLIC_BASE_URL=https://asset.example.com YYD_NOTIFICATION_CENTER_URL= -YYD_NOTIFICATION_CENTER_PROJECT_CODE=fixed_asset -YYD_NOTIFICATION_CENTER_PROJECT_SECRET= -YYD_NOTIFICATION_CENTER_TEST_USER_IDS= -YYD_NOTIFICATION_CENTER_FULL_ROLLOUT=false -YYD_NOTIFICATION_CENTER_LOGIN_ENABLED=false -YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS= -YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT=false +YYD_NOTIFICATION_PROJECT_CODE=fixed_asset +YYD_NOTIFICATION_PROJECT_SECRET= +YYD_NOTIFICATION_SEND_ENABLED=false +YYD_NOTIFICATION_SEND_TEST_USER_IDS= +YYD_NOTIFICATION_SEND_FULL_ROLLOUT=false +YYD_NOTIFICATION_LOGIN_ENABLED=false +YYD_NOTIFICATION_LOGIN_TEST_USER_IDS= +YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT=false diff --git a/backend/prisma/migrations/20260716_notification_center_only/migration.sql b/backend/prisma/migrations/20260716_notification_center_only/migration.sql new file mode 100644 index 0000000..5b81e1e --- /dev/null +++ b/backend/prisma/migrations/20260716_notification_center_only/migration.sql @@ -0,0 +1,31 @@ +-- 新投递固定走由壹点统一通知中心;历史 work_notice 记录原值保留用于审计。 +ALTER TABLE `dingtalk_delivery` + MODIFY COLUMN `provider_type` VARCHAR(20) NOT NULL DEFAULT 'notification_center', + ADD COLUMN `retry_of_delivery_id` INT NULL, + ADD COLUMN `manual_created_by` INT NULL, + ADD COLUMN `manual_created_at` DATETIME(3) NULL, + ADD COLUMN `last_manual_action_by` INT NULL, + ADD COLUMN `last_manual_action_type` VARCHAR(30) NULL, + ADD COLUMN `last_manual_action_at` DATETIME(3) NULL; + +CREATE INDEX `dingtalk_delivery_retry_of_delivery_id_idx` + ON `dingtalk_delivery`(`retry_of_delivery_id`); + +-- 兼容上一版已经排队、尚未提交到通知中心的任务;旧 payload 没有 action_title。 +UPDATE `dingtalk_delivery` +SET `payload_json` = JSON_SET( + `payload_json`, + '$.action_title', + CASE `event_type` + WHEN 'disposal_applied' THEN '立即审批' + WHEN 'disposal_overdue_reminder' THEN '立即审批' + WHEN 'disposal_approved' THEN '查看审批结果' + WHEN 'disposal_rejected' THEN '查看申请' + WHEN 'disposal_completed' THEN '查看结果' + ELSE '查看详情' + END +) +WHERE `provider_type` = 'notification_center' + AND `status` IN ('pending', 'retrying') + AND JSON_VALID(`payload_json`) + AND JSON_EXTRACT(`payload_json`, '$.action_title') IS NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d993261..e830f90 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -739,8 +739,14 @@ model DingTalkDelivery { next_attempt_at DateTime @default(now()) lease_owner String? @db.VarChar(100) lease_expires_at DateTime? - provider_type String @default("work_notice") @db.VarChar(20) + provider_type String @default("notification_center") @db.VarChar(20) dingtalk_task_id String? @db.VarChar(100) + retry_of_delivery_id Int? + manual_created_by Int? + manual_created_at DateTime? + last_manual_action_by Int? + last_manual_action_type String? @db.VarChar(30) + last_manual_action_at DateTime? payload_json String @db.Text last_error_code String? @db.VarChar(100) last_error_message String? @db.Text @@ -756,6 +762,7 @@ model DingTalkDelivery { @@index([business_type, business_id]) @@index([recipient_user_id, created_at]) @@index([dingtalk_task_id]) + @@index([retry_of_delivery_id]) @@map("dingtalk_delivery") } diff --git a/backend/src/controllers/__tests__/dingtalkController.broker-login.test.ts b/backend/src/controllers/__tests__/dingtalkController.broker-login.test.ts index e403efd..663abd8 100644 --- a/backend/src/controllers/__tests__/dingtalkController.broker-login.test.ts +++ b/backend/src/controllers/__tests__/dingtalkController.broker-login.test.ts @@ -8,21 +8,21 @@ const originalFindFirst = prisma.user.findFirst const originalFindUnique = prisma.user.findUnique const envKeys = [ 'YYD_NOTIFICATION_CENTER_URL', - 'YYD_NOTIFICATION_CENTER_PROJECT_CODE', - 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET', - 'YYD_NOTIFICATION_CENTER_LOGIN_ENABLED', - 'YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS', - 'YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT' + 'YYD_NOTIFICATION_PROJECT_CODE', + 'YYD_NOTIFICATION_PROJECT_SECRET', + 'YYD_NOTIFICATION_LOGIN_ENABLED', + 'YYD_NOTIFICATION_LOGIN_TEST_USER_IDS', + 'YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT' ] as const const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])) -const configureLogin = (testUserId = 'ding-user-1') => { +const configureLogin = () => { process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com' - process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'fixed_asset' - process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = '123456789012345678901234' - process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = 'true' - process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS = testUserId - process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT = 'false' + process.env.YYD_NOTIFICATION_PROJECT_CODE = 'fixed_asset' + process.env.YYD_NOTIFICATION_PROJECT_SECRET = '123456789012345678901234' + process.env.YYD_NOTIFICATION_LOGIN_ENABLED = 'true' + process.env.YYD_NOTIFICATION_LOGIN_TEST_USER_IDS = 'ding-user-1' + process.env.YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT = 'false' } const createRequest = (ticket = 'A'.repeat(43)) => { @@ -128,8 +128,8 @@ test('一次性票据兑换成功后建立绑定userid的会话并303进入内 assert.equal(capturedUrl.includes('A'.repeat(43)), false) }) -test('票据属于灰度范围外用户时不建立会话也不查询系统账号', async () => { - configureLogin('ding-test-only') +test('票据用户未绑定启用且在职账号时不建立会话', async () => { + configureLogin() global.fetch = (async () => new Response(JSON.stringify({ dingtalkUserId: 'ding-user-1', redirectPath: '/dashboard' @@ -137,20 +137,20 @@ test('票据属于灰度范围外用户时不建立会话也不查询系统账 let userLookups = 0 ;(prisma.user.findFirst as any) = async () => { userLookups += 1 - return { id: 3 } + return null } const req = createRequest('B'.repeat(43)) const { res, state } = createResponse() await loginWithYouyidianTicket(req as any, res as any) - assert.equal(userLookups, 0) + assert.equal(userLookups, 1) assert.equal(req.session.user, undefined) assert.equal(state.statusCode, 403) - assert.match(state.body, /不在本次免登灰度范围/) + assert.match(state.body, /未绑定启用且在职/) }) -test('通知中心返回外部跳转地址时拒绝建立会话', async () => { +test('通知中心返回外部跳转地址时按服务异常拒绝建立会话', async () => { configureLogin() global.fetch = (async () => new Response(JSON.stringify({ dingtalkUserId: 'ding-user-1', @@ -162,6 +162,67 @@ test('通知中心返回外部跳转地址时拒绝建立会话', async () => { await loginWithYouyidianTicket(req as any, res as any) assert.equal(req.session.user, undefined) - assert.equal(state.statusCode, 401) - assert.match(state.body, /登录凭证无效或已过期/) + assert.equal(state.statusCode, 502) + assert.match(state.body, /服务暂时异常/) +}) + +test('通知中心服务端异常不会误报成用户票据无效', async () => { + configureLogin() + global.fetch = (async () => new Response(JSON.stringify({ error: 'INTERNAL_ERROR' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + })) as typeof fetch + + const req = createRequest('D'.repeat(43)) + const { res, state } = createResponse() + await loginWithYouyidianTicket(req as any, res as any) + + assert.equal(req.session.user, undefined) + assert.equal(state.statusCode, 502) + assert.match(state.body, /服务暂时异常/) + assert.doesNotMatch(state.body, /票据无效/) +}) + +test('票据兑换遇到nonce重放时使用新nonce立即重试一次', async () => { + configureLogin() + let calls = 0 + global.fetch = (async () => { + calls += 1 + if (calls === 1) { + return new Response(JSON.stringify({ error: 'NONCE_REPLAYED' }), { + status: 409, + headers: { 'Content-Type': 'application/json' } + }) + } + return new Response(JSON.stringify({ + dingtalkUserId: 'ding-user-1', + redirectPath: '/dashboard' + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + }) as typeof fetch + ;(prisma.user.findFirst as any) = async () => ({ id: 3 }) + ;(prisma.user.findUnique as any) = async () => ({ + id: 3, + username: 'magic-red', + name: '魔礼红', + email: null, + phone: null, + role: 'admin', + status: 'active', + person_id: 108, + person: { + id: 108, + real_name: '吴永杰', + nickname: '魔礼红', + status: 'active', + source_employee_status: '3', + source_userid: 'ding-user-1' + } + }) + + const req = createRequest('E'.repeat(43)) + const { res, state } = createResponse() + await loginWithYouyidianTicket(req as any, res as any) + + assert.equal(calls, 2) + assert.equal(state.statusCode, 303) }) diff --git a/backend/src/controllers/__tests__/dingtalkController.manual-retry.test.ts b/backend/src/controllers/__tests__/dingtalkController.manual-retry.test.ts new file mode 100644 index 0000000..db36a04 --- /dev/null +++ b/backend/src/controllers/__tests__/dingtalkController.manual-retry.test.ts @@ -0,0 +1,261 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import prisma from '../../utils/prisma' +import { retryDingTalkDelivery } from '../dingtalkController' + +const createResponse = () => { + const state = { status: 200, body: null as any } + const response = { + locals: { user: { id: 99 } }, + status(code: number) { + state.status = code + return this + }, + json(body: any) { + state.body = body + return this + } + } + return { response, state } +} + +test('人工补发必须由管理员显式确认', async () => { + const originalFindUnique = prisma.dingTalkDelivery.findUnique + let lookupCount = 0 + ;(prisma.dingTalkDelivery.findUnique as any) = async () => { + lookupCount += 1 + return null + } + try { + const { response, state } = createResponse() + await retryDingTalkDelivery({ params: { id: '1' }, body: {} } as any, response as any) + assert.equal(state.status, 422) + assert.match(state.body.message, /必须明确确认/) + assert.equal(lookupCount, 0) + } finally { + ;(prisma.dingTalkDelivery.findUnique as any) = originalFindUnique + } +}) + +test('历史工作通知只读,不能通过统一通知中心重新补发', async () => { + const originalFindUnique = prisma.dingTalkDelivery.findUnique + ;(prisma.dingTalkDelivery.findUnique as any) = async () => ({ + id: 1, + status: 'failed', + provider_type: 'work_notice', + recipient: { status: 'active', person: { status: 'active', source_userid: 'ding-user-1' } } + }) + try { + const { response, state } = createResponse() + await retryDingTalkDelivery({ + params: { id: '1' }, + body: { confirmed: true } + } as any, response as any) + assert.equal(state.status, 409) + assert.match(state.body.message, /历史工作通知仅供审计/) + } finally { + ;(prisma.dingTalkDelivery.findUnique as any) = originalFindUnique + } +}) + +const failedDelivery = (overrides: Record = {}) => ({ + id: 5, + status: 'failed', + provider_type: 'notification_center', + event_type: 'disposal_applied', + business_type: 'disposal', + business_id: 53, + recipient_user_id: 7, + recipient_dingtalk_userid: 'ding-user-7', + idempotency_key: 'disposal_applied:53:ding-user-7:v1', + payload_json: JSON.stringify({ + title: '资产报废待审批', + content: '### 待你审批', + action_title: '立即审批', + action_url: '/workflows/disposals/53' + }), + attempt_count: 5, + max_attempts: 5, + last_error_code: 'TIMEOUT', + last_error_message: '响应超时', + dingtalk_task_id: null, + recipient: { status: 'active', role: 'administrative', person: { status: 'active', source_userid: 'ding-user-7' } }, + ...overrides +}) + +test('未取得中心任务ID时复用原行和原幂等键恢复投递', async () => { + const originalFindUnique = prisma.dingTalkDelivery.findUnique + const originalUpdateMany = prisma.dingTalkDelivery.updateMany + const originalCreate = prisma.dingTalkDelivery.create + let updateArgs: any + let createCount = 0 + ;(prisma.dingTalkDelivery.findUnique as any) = async () => failedDelivery() + ;(prisma.dingTalkDelivery.updateMany as any) = async (args: any) => { + updateArgs = args + return { count: 1 } + } + ;(prisma.dingTalkDelivery.create as any) = async () => { + createCount += 1 + return { id: 6 } + } + try { + const { response, state } = createResponse() + await retryDingTalkDelivery({ + params: { id: '5' }, + body: { confirmed: true } + } as any, response as any) + assert.equal(state.status, 200) + assert.equal(createCount, 0) + assert.equal(updateArgs.where.dingtalk_task_id, null) + assert.equal(updateArgs.data.status, 'pending') + 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) + assert.equal(resumePayload.notification_center_resume_count, 1) + assert.equal(resumePayload.notification_center_resume_history[0].operator_user_id, 99) + } finally { + ;(prisma.dingTalkDelivery.findUnique as any) = originalFindUnique + ;(prisma.dingTalkDelivery.updateMany as any) = originalUpdateMany + ;(prisma.dingTalkDelivery.create as any) = originalCreate + } +}) + +test('中心明确失败时保留旧行并创建幂等键一致的新补发记录', async () => { + const originalFindUnique = prisma.dingTalkDelivery.findUnique + const originalUpdateMany = prisma.dingTalkDelivery.updateMany + const originalCreate = prisma.dingTalkDelivery.create + let updateCount = 0 + let createArgs: any + ;(prisma.dingTalkDelivery.findUnique as any) = async () => failedDelivery({ + dingtalk_task_id: 'center-task-5', + last_error_code: 'DELIVERY_OUTCOME_UNKNOWN', + payload_json: JSON.stringify({ + title: '资产报废待审批', + content: '### 待你审批', + action_title: '立即审批', + action_url: '/workflows/disposals/53', + notification_center_last_status: 'failed' + }) + }) + ;(prisma.dingTalkDelivery.updateMany as any) = async () => { + updateCount += 1 + return { count: 1 } + } + ;(prisma.dingTalkDelivery.create as any) = async (args: any) => { + createArgs = args + return { id: 6 } + } + try { + const { response, state } = createResponse() + await retryDingTalkDelivery({ + params: { id: '5' }, + body: { confirmed: true } + } as any, response as any) + assert.equal(state.status, 200) + assert.equal(updateCount, 0) + assert.equal(createArgs.data.idempotency_key, 'disposal_applied:53:ding-user-7:legacy:manual-v1') + assert.equal(createArgs.data.retry_of_delivery_id, 5) + assert.equal(createArgs.data.manual_created_by, 99) + const manualPayload = JSON.parse(createArgs.data.payload_json) + assert.equal(manualPayload.notification_center_manual_retry_source_delivery_id, 5) + assert.equal(manualPayload.notification_center_manual_retry_by, 99) + assert.equal( + JSON.parse(createArgs.data.payload_json).notification_center_idempotency_key, + createArgs.data.idempotency_key + ) + } finally { + ;(prisma.dingTalkDelivery.findUnique as any) = originalFindUnique + ;(prisma.dingTalkDelivery.updateMany as any) = originalUpdateMany + ;(prisma.dingTalkDelivery.create as any) = originalCreate + } +}) + +test('状态查询错误时恢复原中心任务回查且绝不新建补发消息', async () => { + const originalFindUnique = prisma.dingTalkDelivery.findUnique + const originalUpdateMany = prisma.dingTalkDelivery.updateMany + const originalCreate = prisma.dingTalkDelivery.create + let createCount = 0 + let updateArgs: any + ;(prisma.dingTalkDelivery.findUnique as any) = async () => failedDelivery({ + dingtalk_task_id: 'center-task-5', + last_error_code: 'NETWORK', + payload_json: JSON.stringify({ + title: '资产报废待审批', + content: '### 待你审批', + action_title: '立即审批', + action_url: '/workflows/disposals/53', + notification_center_status_query_error: 'NETWORK' + }) + }) + ;(prisma.dingTalkDelivery.create as any) = async () => { + createCount += 1 + return { id: 6 } + } + ;(prisma.dingTalkDelivery.updateMany as any) = async (args: any) => { + updateArgs = args + return { count: 1 } + } + try { + const { response, state } = createResponse() + await retryDingTalkDelivery({ + params: { id: '5' }, + body: { confirmed: true } + } as any, response as any) + assert.equal(state.status, 200) + assert.match(state.body.message, /恢复原任务状态回查/) + assert.equal(createCount, 0) + assert.equal(updateArgs.where.dingtalk_task_id, 'center-task-5') + assert.equal(updateArgs.data.status, 'pending') + assert.equal(updateArgs.data.last_manual_action_by, 99) + assert.equal(updateArgs.data.last_manual_action_type, 'resume_status_query') + assert.equal( + JSON.parse(updateArgs.data.payload_json).notification_center_status_query_error, + null + ) + assert.equal( + JSON.parse(updateArgs.data.payload_json).notification_center_query_resume_history[0].operator_user_id, + 99 + ) + } finally { + ;(prisma.dingTalkDelivery.findUnique as any) = originalFindUnique + ;(prisma.dingTalkDelivery.updateMany as any) = originalUpdateMany + ;(prisma.dingTalkDelivery.create as any) = originalCreate + } +}) + +test('待审批通知收件人已降为普通用户时禁止人工恢复或补发', async () => { + const originalFindUnique = prisma.dingTalkDelivery.findUnique + const originalUpdateMany = prisma.dingTalkDelivery.updateMany + const originalCreate = prisma.dingTalkDelivery.create + let writeCount = 0 + ;(prisma.dingTalkDelivery.findUnique as any) = async () => failedDelivery({ + recipient: { + status: 'active', + role: 'user', + person: { status: 'active', source_userid: 'ding-user-7' } + } + }) + ;(prisma.dingTalkDelivery.updateMany as any) = async () => { + writeCount += 1 + return { count: 1 } + } + ;(prisma.dingTalkDelivery.create as any) = async () => { + writeCount += 1 + return { id: 6 } + } + try { + const { response, state } = createResponse() + await retryDingTalkDelivery({ + params: { id: '5' }, + body: { confirmed: true } + } as any, response as any) + assert.equal(state.status, 422) + assert.match(state.body.message, /不具备报废审批权限/) + assert.equal(writeCount, 0) + } finally { + ;(prisma.dingTalkDelivery.findUnique as any) = originalFindUnique + ;(prisma.dingTalkDelivery.updateMany as any) = originalUpdateMany + ;(prisma.dingTalkDelivery.create as any) = originalCreate + } +}) diff --git a/backend/src/controllers/__tests__/dingtalkController.status.test.ts b/backend/src/controllers/__tests__/dingtalkController.status.test.ts new file mode 100644 index 0000000..0ea39ec --- /dev/null +++ b/backend/src/controllers/__tests__/dingtalkController.status.test.ts @@ -0,0 +1,42 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import prisma from '../../utils/prisma' +import { getDingTalkDeliveryStatus } from '../dingtalkController' + +test('统一通知概览不把历史work_notice记录计入当前待发送和失败数量', async () => { + const originalCount = prisma.dingTalkDelivery.count + const originalGroupBy = prisma.dingTalkDelivery.groupBy + const countWhere: any[] = [] + let groupWhere: any + ;(prisma.dingTalkDelivery.count as any) = async (args: any) => { + countWhere.push(args.where) + return 0 + } + ;(prisma.dingTalkDelivery.groupBy as any) = async (args: any) => { + groupWhere = args.where + return [] + } + const state = { status: 200, body: null as any } + const response = { + status(code: number) { + state.status = code + return this + }, + json(body: any) { + state.body = body + return this + } + } + + try { + await getDingTalkDeliveryStatus({} as any, response as any) + assert.equal(state.status, 200) + assert.equal(countWhere.length, 5) + assert.ok(countWhere.every((where) => where.provider_type === 'notification_center')) + assert.equal(groupWhere.provider_type, 'notification_center') + } finally { + ;(prisma.dingTalkDelivery.count as any) = originalCount + ;(prisma.dingTalkDelivery.groupBy as any) = originalGroupBy + } +}) diff --git a/backend/src/controllers/__tests__/userController.dingtalk-binding.test.ts b/backend/src/controllers/__tests__/userController.dingtalk-binding.test.ts new file mode 100644 index 0000000..db467a1 --- /dev/null +++ b/backend/src/controllers/__tests__/userController.dingtalk-binding.test.ts @@ -0,0 +1,94 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import prisma from '../../utils/prisma' +import { createUser, updateUser } from '../userController' + +const createResponse = () => { + const state = { status: 200, body: null as any } + const response = { + status(code: number) { + state.status = code + return this + }, + json(body: any) { + state.body = body + return this + } + } + return { response, state } +} + +test('管理员不能把系统用户绑定到离职或缺少钉钉userId的员工档案', async () => { + const originalUserFindUnique = prisma.user.findUnique + const originalPersonFindUnique = prisma.person.findUnique + const originalUserCreate = prisma.user.create + let createCount = 0 + ;(prisma.user.findUnique as any) = async () => null + ;(prisma.user.create as any) = async () => { + createCount += 1 + return null + } + + try { + for (const person of [ + { id: 9, status: 'inactive', source_userid: 'ding-user-9' }, + { id: 9, status: 'active', source_userid: null } + ]) { + ;(prisma.person.findUnique as any) = async () => person + const { response, state } = createResponse() + await createUser({ + body: { + username: `test-${person.status}-${person.source_userid || 'empty'}`, + password: 'test-password', + name: '测试用户', + person_id: 9 + } + } as any, response as any) + assert.equal(state.status, 422) + assert.match(state.body.message, /在职且启用|缺少钉钉 userId/) + } + assert.equal(createCount, 0) + } finally { + ;(prisma.user.findUnique as any) = originalUserFindUnique + ;(prisma.person.findUnique as any) = originalPersonFindUnique + ;(prisma.user.create as any) = originalUserCreate + } +}) + +test('并发绑定同一员工触发数据库唯一约束时返回409而不是500', async () => { + const originalUserFindUnique = prisma.user.findUnique + const originalPersonFindUnique = prisma.person.findUnique + const originalUserFindFirst = prisma.user.findFirst + const originalUserUpdate = prisma.user.update + ;(prisma.user.findUnique as any) = async () => ({ + id: 7, + name: '管理员', + email: null, + phone: null + }) + ;(prisma.person.findUnique as any) = async () => ({ + id: 9, + status: 'active', + source_userid: 'ding-user-9' + }) + ;(prisma.user.findFirst as any) = async () => null + ;(prisma.user.update as any) = async () => { + const error: any = new Error('Unique constraint failed') + error.code = 'P2002' + error.meta = { target: ['person_id'] } + throw error + } + + try { + const { response, state } = createResponse() + await updateUser({ params: { id: '7' }, body: { person_id: 9 } } as any, response as any) + assert.equal(state.status, 409) + assert.match(state.body.message, /已绑定其他用户/) + } finally { + ;(prisma.user.findUnique as any) = originalUserFindUnique + ;(prisma.person.findUnique as any) = originalPersonFindUnique + ;(prisma.user.findFirst as any) = originalUserFindFirst + ;(prisma.user.update as any) = originalUserUpdate + } +}) diff --git a/backend/src/controllers/dingtalkController.ts b/backend/src/controllers/dingtalkController.ts index b4aca62..503a39a 100644 --- a/backend/src/controllers/dingtalkController.ts +++ b/backend/src/controllers/dingtalkController.ts @@ -2,16 +2,14 @@ import type { Request, Response } from 'express' import prisma from '../utils/prisma' import { errorResponse, successResponse } from '../utils/response' import { resolveAuthUserById } from '../utils/authIdentity' -import { - exchangeDingTalkLoginCode, - getDingTalkConfigStatus, - getDingTalkPublicConfig -} from '../services/dingtalkClient' +import { getDingTalkConfigStatus, getDingTalkPublicConfig } from '../services/dingtalkClient' import { NotificationCenterError, getNotificationCenterLoginConfigStatus, isNotificationCenterLoginAllowedForUser, prepareNotificationCenterManualRetryPayload, + prepareNotificationCenterQueryResumePayload, + prepareNotificationCenterResumePayload, redeemYouyidianLoginTicket } from '../services/youyidianNotificationClient' import { findEligibleDingTalkLoginUser } from '../services/dingtalkBrokerLoginService' @@ -77,41 +75,15 @@ const sendBrokerLoginError = (res: Response, status: number, message: string) => export const getPublicDingTalkConfig = (req: Request, res: Response) => { try { - const safeRedirect = normalizeInternalRedirect(req.query.redirectPath ?? req.query.redirect) + const safeRedirect = normalizeInternalRedirect( + req.query.redirectPath ?? req.query.redirect ?? '/dashboard' + ) successResponse(res, getDingTalkPublicConfig(safeRedirect), '获取成功') } catch (error: any) { errorResponse(res, error.message || '获取钉钉免登配置失败', 422) } } -export const loginWithDingTalkCode = async (req: Request, res: Response) => { - try { - const { code, redirectPath, redirect_path } = req.body as Record - if (typeof code !== 'string' || !code.trim()) { - return errorResponse(res, '免登码不能为空', 422) - } - const safeRedirect = normalizeInternalRedirect(redirectPath ?? redirect_path) - const dingTalkUserId = await exchangeDingTalkLoginCode(code.trim()) - - const user = await findEligibleDingTalkLoginUser(dingTalkUserId) - if (!user) { - return errorResponse(res, '未找到启用且绑定在职员工档案的系统账号', 403) - } - - const authUser = await resolveAuthUserById(user.id) - await establishUserSession(req, user.id, 'dingtalk', dingTalkUserId) - - successResponse(res, { - user: authUser, - redirectPath: safeRedirect, - redirect_path: safeRedirect - }, '免登成功') - } catch (error: any) { - const status = /跳转地址/.test(error?.message || '') ? 422 : (error?.status || 401) - errorResponse(res, error.message || '钉钉免登失败', status) - } -} - export const loginWithYouyidianTicket = async (req: Request, res: Response) => { res.set({ 'Cache-Control': 'no-store', @@ -148,7 +120,13 @@ export const loginWithYouyidianTicket = async (req: Request, res: Response) => { if (error.code === 'NETWORK') { return sendBrokerLoginError(res, 502, '暂时无法连接统一通知登录服务,请稍后重试。') } - return sendBrokerLoginError(res, 401, '登录凭证无效或已过期,请返回钉钉重新打开通知。') + if (error.code === 'TICKET_INVALID_OR_EXPIRED') { + return sendBrokerLoginError(res, 401, '登录凭证无效或已过期,请返回钉钉重新打开通知。') + } + if (error.httpStatus === 401) { + return sendBrokerLoginError(res, 503, '统一通知登录服务认证配置异常,请联系管理员。') + } + return sendBrokerLoginError(res, 502, '统一通知登录服务暂时异常,请稍后重试。') } const status = Number.isInteger(error?.status) ? error.status : 500 return sendBrokerLoginError( @@ -161,15 +139,16 @@ export const loginWithYouyidianTicket = async (req: Request, res: Response) => { export const getDingTalkDeliveryStatus = async (_req: Request, res: Response) => { try { + const currentProvider = { provider_type: 'notification_center' } const [pending, failed, sent, read, unread, errorGroups] = await Promise.all([ - prisma.dingTalkDelivery.count({ where: { status: { in: ['pending', 'retrying', 'sending'] } } }), - prisma.dingTalkDelivery.count({ where: { status: 'failed' } }), - prisma.dingTalkDelivery.count({ where: { status: 'sent' } }), - prisma.dingTalkDelivery.count({ where: { status: 'read' } }), - prisma.dingTalkDelivery.count({ where: { status: 'unread' } }), + prisma.dingTalkDelivery.count({ where: { ...currentProvider, status: { in: ['pending', 'retrying', 'sending'] } } }), + prisma.dingTalkDelivery.count({ where: { ...currentProvider, status: 'failed' } }), + prisma.dingTalkDelivery.count({ where: { ...currentProvider, status: 'sent' } }), + prisma.dingTalkDelivery.count({ where: { ...currentProvider, status: 'read' } }), + prisma.dingTalkDelivery.count({ where: { ...currentProvider, status: 'unread' } }), prisma.dingTalkDelivery.groupBy({ by: ['last_error_code', 'last_error_message'], - where: { status: 'failed' }, + where: { ...currentProvider, status: 'failed' }, _count: { _all: true }, orderBy: { _count: { id: 'desc' } }, take: 10 @@ -182,6 +161,8 @@ export const getDingTalkDeliveryStatus = async (_req: Request, res: Response) => channel: config.channel, send_enabled: config.send_enabled, enabled: config.send_enabled, + rollout_mode: config.rollout_mode, + test_user_count: config.test_user_count, missing: config.missing, login_configured: loginConfig.configured, login_enabled: loginConfig.enabled, @@ -239,6 +220,12 @@ export const getDingTalkDeliveries = async (req: Request, res: Response) => { next_attempt_at: item.next_attempt_at, provider_type: item.provider_type, dingtalk_task_id: item.dingtalk_task_id, + retry_of_delivery_id: item.retry_of_delivery_id, + manual_created_by: item.manual_created_by, + manual_created_at: item.manual_created_at, + last_manual_action_by: item.last_manual_action_by, + last_manual_action_type: item.last_manual_action_type, + last_manual_action_at: item.last_manual_action_at, last_error_code: item.last_error_code, last_error: item.last_error_message, sent_at: item.sent_at, @@ -257,12 +244,15 @@ export const getDingTalkDeliveries = async (req: Request, res: Response) => { export const retryDingTalkDelivery = async (req: Request, res: Response) => { try { + if (req.body?.confirmed !== true) { + return errorResponse(res, '人工补发前必须明确确认可能产生一条新的单聊通知', 422) + } const id = Number(req.params.id) const delivery = await prisma.dingTalkDelivery.findUnique({ where: { id }, include: { recipient: { - include: { person: { select: { source_userid: true } } } + include: { person: { select: { source_userid: true, status: true } } } } } }) @@ -272,29 +262,134 @@ export const retryDingTalkDelivery = async (req: Request, res: Response) => { if (delivery.status !== 'failed') { return errorResponse(res, '只有失败的投递任务可以手工补发', 409) } - const retryPayloadJson = delivery.provider_type === 'notification_center' - ? prepareNotificationCenterManualRetryPayload(delivery.payload_json, delivery.idempotency_key) - : delivery.payload_json - const updated = await prisma.dingTalkDelivery.updateMany({ - where: { id, status: 'failed' }, + if (delivery.provider_type !== 'notification_center') { + return errorResponse(res, '历史工作通知仅供审计,不能通过统一通知中心补发', 409) + } + const currentDingTalkUserId = delivery.recipient.person?.source_userid?.trim() || '' + if ( + delivery.recipient.status !== 'active' || + delivery.recipient.person?.status !== 'active' || + !currentDingTalkUserId + ) { + return errorResponse(res, '收件人未绑定启用且在职的钉钉员工档案,不能补发', 422) + } + if ( + ['disposal_applied', 'disposal_overdue_reminder'].includes(delivery.event_type) && + !['administrative', 'admin'].includes(delivery.recipient.role) + ) { + return errorResponse(res, '收件人已不具备报废审批权限,不能继续投递该审批通知', 422) + } + const operatorUserId = Number(res.locals.user?.id) + if (!Number.isInteger(operatorUserId) || operatorUserId <= 0) { + return errorResponse(res, '当前管理员身份无效,请重新登录', 401) + } + const manualActionAt = new Date() + const currentPayload = JSON.parse(delivery.payload_json) as Record + if (!delivery.dingtalk_task_id) { + const resumePayloadJson = prepareNotificationCenterResumePayload(delivery.payload_json, { + code: delivery.last_error_code, + message: delivery.last_error_message, + attemptCount: delivery.attempt_count, + operatorUserId, + resumedAt: manualActionAt + }) + const updated = await prisma.dingTalkDelivery.updateMany({ + where: { id, status: 'failed', dingtalk_task_id: null }, + data: { + status: 'pending', + recipient_dingtalk_userid: currentDingTalkUserId, + attempt_count: 0, + next_attempt_at: new Date(), + lease_owner: null, + lease_expires_at: null, + last_error_code: null, + last_error_message: null, + last_manual_action_by: operatorUserId, + last_manual_action_type: 'resume_submit', + last_manual_action_at: manualActionAt, + payload_json: resumePayloadJson + } + }) + if (updated.count !== 1) return errorResponse(res, '投递任务状态已变化,请刷新后重试', 409) + return successResponse(res, { delivery_id: delivery.id }, '已按原幂等键恢复投递') + } + + if (currentPayload.notification_center_status_query_error) { + const refreshableStatuses = new Set(['pending', 'sending', 'retrying', 'sent', 'unread']) + const lastKnownStatus = String(currentPayload.notification_center_last_status || '') + const restoredStatus = refreshableStatuses.has(lastKnownStatus) ? lastKnownStatus : 'pending' + const resumePayloadJson = prepareNotificationCenterQueryResumePayload(delivery.payload_json, { + code: delivery.last_error_code, + message: delivery.last_error_message, + operatorUserId, + resumedAt: manualActionAt + }) + const updated = await prisma.dingTalkDelivery.updateMany({ + where: { + id, + status: 'failed', + dingtalk_task_id: delivery.dingtalk_task_id + }, + data: { + status: restoredStatus, + next_attempt_at: new Date(), + lease_owner: null, + lease_expires_at: null, + last_error_code: null, + last_error_message: null, + last_manual_action_by: operatorUserId, + last_manual_action_type: 'resume_status_query', + last_manual_action_at: manualActionAt, + payload_json: resumePayloadJson + } + }) + if (updated.count !== 1) return errorResponse(res, '投递任务状态已变化,请刷新后重试', 409) + return successResponse(res, { delivery_id: delivery.id }, '已恢复原任务状态回查,不会重新发送通知') + } + + const externalFailed = currentPayload.notification_center_last_status === 'failed' + if (!externalFailed) { + return errorResponse(res, '通知中心尚未明确返回失败,不能创建新的补发消息', 409) + } + const retryPayloadJson = prepareNotificationCenterManualRetryPayload( + delivery.payload_json, + { + eventType: delivery.event_type, + businessId: delivery.business_id, + recipientUserId: currentDingTalkUserId, + sourceDeliveryId: delivery.id, + operatorUserId, + createdAt: manualActionAt + } + ) + const retryPayload = JSON.parse(retryPayloadJson) as Record + const retryIdempotencyKey = String(retryPayload.notification_center_idempotency_key || '').trim() + const created = await prisma.dingTalkDelivery.create({ data: { - status: 'pending', - recipient_dingtalk_userid: delivery.recipient.person?.source_userid || null, - attempt_count: 0, - next_attempt_at: new Date(), - lease_owner: null, - lease_expires_at: null, - dingtalk_task_id: null, - sent_at: null, - read_at: null, - last_error_code: null, - last_error_message: null, + idempotency_key: retryIdempotencyKey, + event_type: delivery.event_type, + business_type: delivery.business_type, + business_id: delivery.business_id, + recipient_user_id: delivery.recipient_user_id, + recipient_dingtalk_userid: currentDingTalkUserId, + max_attempts: delivery.max_attempts, + provider_type: 'notification_center', + retry_of_delivery_id: delivery.id, + manual_created_by: operatorUserId, + manual_created_at: manualActionAt, + last_manual_action_by: operatorUserId, + last_manual_action_type: 'create_retry', + last_manual_action_at: manualActionAt, payload_json: retryPayloadJson } }) - if (updated.count !== 1) return errorResponse(res, '投递任务状态已变化,请刷新后重试', 409) - successResponse(res, null, '已加入补发队列') + successResponse(res, { delivery_id: created.id }, '已创建一条可审计的人工补发任务') } catch (error: any) { - errorResponse(res, error.message || '补发失败') + const status = error?.code === 'P2002' ? 409 : 500 + errorResponse( + res, + status === 409 ? '该人工补发任务已经创建,请刷新列表' : (error.message || '补发失败'), + status + ) } } diff --git a/backend/src/controllers/disposalController.ts b/backend/src/controllers/disposalController.ts index 770b1a8..2fc8404 100644 --- a/backend/src/controllers/disposalController.ts +++ b/backend/src/controllers/disposalController.ts @@ -7,8 +7,13 @@ import { calculateEquipmentValues } from '../utils/assetValue' import { loadAndLockAssetTree } from '../services/assetMovementService' import { createBusinessNotification, - findActiveUsersByIds, - findActiveUsersByRoles, + escapeNotificationMarkdownInline, + findActiveDingTalkUsersByIds, + findActiveDingTalkUsersByRoles, + findActiveStationUsersByIds, + findActiveStationUsersByRoles, + formatBeijingDateTime, + getCurrentDingTalkRecipientRollout, mergeRecipients } from '../services/businessNotificationService' @@ -94,19 +99,48 @@ const updateEquipmentLifecycle = async ( } const getSubmissionRecipients = async (tx: Prisma.TransactionClient) => { - const administrativeUsers = await findActiveUsersByRoles(tx, ['administrative']) - if (administrativeUsers.length > 0) return administrativeUsers - return findActiveUsersByRoles(tx, ['admin']) + const rollout = getCurrentDingTalkRecipientRollout() + const [stationAdministrative, dingTalkAdministrative] = await Promise.all([ + findActiveStationUsersByRoles(tx, ['administrative']), + findActiveDingTalkUsersByRoles(tx, ['administrative'], rollout) + ]) + const stationRecipients = stationAdministrative.length > 0 + ? stationAdministrative + : await findActiveStationUsersByRoles(tx, ['admin']) + const dingTalkRecipients = dingTalkAdministrative.length > 0 + ? dingTalkAdministrative + : await findActiveDingTalkUsersByRoles(tx, ['admin'], rollout) + return { stationRecipients, dingTalkRecipients } } const getResultRecipients = async (tx: Prisma.TransactionClient, applierId: number) => { - const [applier, administrativeUsers] = await Promise.all([ - findActiveUsersByIds(tx, [applierId]), - findActiveUsersByRoles(tx, ['administrative']) + const rollout = getCurrentDingTalkRecipientRollout() + const [stationApplier, stationAdministrative, dingTalkApplier, dingTalkAdministrative] = await Promise.all([ + findActiveStationUsersByIds(tx, [applierId]), + findActiveStationUsersByRoles(tx, ['administrative']), + findActiveDingTalkUsersByIds(tx, [applierId], rollout), + findActiveDingTalkUsersByRoles(tx, ['administrative'], rollout) ]) - return mergeRecipients(applier, administrativeUsers) + let stationRecipients = mergeRecipients(stationApplier, stationAdministrative) + if (stationApplier.length === 0 || stationAdministrative.length === 0) { + stationRecipients = mergeRecipients( + stationRecipients, + await findActiveStationUsersByRoles(tx, ['admin']) + ) + } + let dingTalkRecipients = mergeRecipients(dingTalkApplier, dingTalkAdministrative) + if (dingTalkApplier.length === 0 || dingTalkAdministrative.length === 0) { + dingTalkRecipients = mergeRecipients( + dingTalkRecipients, + await findActiveDingTalkUsersByRoles(tx, ['admin'], rollout) + ) + } + return { stationRecipients, dingTalkRecipients } } +const assetNotificationName = (name: unknown, code: unknown) => + `${escapeNotificationMarkdownInline(name, '资产')}(${escapeNotificationMarkdownInline(code, '无编码')})` + const getCurrentUser = (res: Response) => ({ id: Number(res.locals.user?.id), role: String(res.locals.user?.role || '') @@ -243,8 +277,24 @@ export const createDisposal = async (req: Request, res: Response) => { content: `${res.locals.user?.name || res.locals.user?.username} 提交了 ${root.equipment_code} 的报废申请`, level: 'warning', actionUrl: `/workflows/disposals/${disposal.id}`, - recipients, - metadata: { equipment_code: root.equipment_code } + stationRecipients: recipients.stationRecipients, + dingTalkRecipients: recipients.dingTalkRecipients, + metadata: { equipment_code: root.equipment_code }, + dingTalk: { + title: '资产报废待审批', + content: [ + '### 待你审批', + '', + `**资产:** ${assetNotificationName(root.equipment_name, root.equipment_code)}`, + `**申请人:** ${escapeNotificationMarkdownInline(res.locals.user?.name || res.locals.user?.username)}`, + `**申请时间:** ${formatBeijingDateTime(disposal.applied_at)}`, + `**报废原因:** ${escapeNotificationMarkdownInline(disposal_reason)}`, + '', + '> 请核对资产及配件明细后处理。' + ].join('\n'), + actionTitle: '立即审批', + messageVersion: 'v1' + } }) return disposal.id @@ -382,12 +432,13 @@ export const approveDisposal = async (req: Request, res: Response) => { } const nextStatus = approved ? 'approved' : 'rejected' + const approvedAt = new Date() const updated = await tx.disposalRecord.updateMany({ where: { id: disposalId, status: 'pending' }, data: { status: nextStatus, approved_by: userId, - approved_at: new Date(), + approved_at: approvedAt, reject_reason: approved ? null : reject_reason?.trim() } }) @@ -419,6 +470,7 @@ export const approveDisposal = async (req: Request, res: Response) => { }) const recipients = await getResultRecipients(tx, disposal.applied_by) + const rootItem = disposal.items.find((item) => item.equipment_id === disposal.equipment_id) await createBusinessNotification(tx, { eventType: approved ? 'disposal_approved' : 'disposal_rejected', businessType: 'disposal', @@ -427,7 +479,40 @@ export const approveDisposal = async (req: Request, res: Response) => { content: approved ? '报废申请已审批通过,等待完成处置' : `驳回原因:${reject_reason?.trim()}`, level: approved ? 'success' : 'warning', actionUrl: `/workflows/disposals/${disposal.id}`, - recipients + stationRecipients: recipients.stationRecipients, + dingTalkRecipients: recipients.dingTalkRecipients, + dingTalk: approved + ? { + title: '资产报废审批已通过', + content: [ + '### 处理完成', + '', + `**资产:** ${assetNotificationName(rootItem?.equipment.equipment_name, disposal.equipment_code)}`, + '**审批结果:** 已通过', + `**审批人:** ${escapeNotificationMarkdownInline(res.locals.user?.name || res.locals.user?.username)}`, + `**审批时间:** ${formatBeijingDateTime(approvedAt)}`, + '', + '> 报废单现已进入待处置阶段。' + ].join('\n'), + actionTitle: '查看审批结果', + messageVersion: 'v1' + } + : { + title: '资产报废申请已驳回', + content: [ + '### 结果已更新', + '', + `**资产:** ${assetNotificationName(rootItem?.equipment.equipment_name, disposal.equipment_code)}`, + '**审批结果:** 已驳回', + `**驳回原因:** ${escapeNotificationMarkdownInline(reject_reason)}`, + `**审批人:** ${escapeNotificationMarkdownInline(res.locals.user?.name || res.locals.user?.username)}`, + `**审批时间:** ${formatBeijingDateTime(approvedAt)}`, + '', + '> 资产状态已恢复为申请前状态。' + ].join('\n'), + actionTitle: '查看申请', + messageVersion: 'v1' + } }) return disposal }) @@ -464,12 +549,13 @@ export const completeDisposal = async (req: Request, res: Response) => { }) if (!disposal) throw Object.assign(new Error('报废记录不存在'), { status: 404 }) + const completedAt = new Date() const updated = await tx.disposalRecord.updateMany({ where: { id: disposalId, status: 'approved' }, data: { status: 'completed', actual_value: actualValue !== undefined ? actualValue : disposal.estimated_value, - completed_at: new Date(), + completed_at: completedAt, completed_by: userId, completion_notes: completion_notes?.trim() || notes?.trim() || null } @@ -499,6 +585,7 @@ export const completeDisposal = async (req: Request, res: Response) => { }) const recipients = await getResultRecipients(tx, disposal.applied_by) + const rootItem = disposal.items.find((item) => item.equipment_id === disposal.equipment_id) await createBusinessNotification(tx, { eventType: 'disposal_completed', businessType: 'disposal', @@ -507,7 +594,24 @@ export const completeDisposal = async (req: Request, res: Response) => { content: completion_notes?.trim() || notes?.trim() || '资产已完成报废处置', level: 'success', actionUrl: `/workflows/disposals/${disposal.id}`, - recipients + stationRecipients: recipients.stationRecipients, + dingTalkRecipients: recipients.dingTalkRecipients, + dingTalk: { + title: '资产报废处置已完成', + content: [ + '### 处理完成', + '', + `**资产:** ${assetNotificationName(rootItem?.equipment.equipment_name, disposal.equipment_code)}`, + '**处置结果:** 已完成报废', + `**完成人:** ${escapeNotificationMarkdownInline(res.locals.user?.name || res.locals.user?.username)}`, + `**完成时间:** ${formatBeijingDateTime(completedAt)}`, + `**完成说明:** ${escapeNotificationMarkdownInline(completion_notes?.trim() || notes?.trim(), '无')}`, + '', + '> 相关资产现已统一标记为已报废。' + ].join('\n'), + actionTitle: '查看结果', + messageVersion: 'v1' + } }) return disposal }) diff --git a/backend/src/controllers/userController.ts b/backend/src/controllers/userController.ts index 54e3638..66dbadf 100644 --- a/backend/src/controllers/userController.ts +++ b/backend/src/controllers/userController.ts @@ -6,30 +6,54 @@ import { getPermissionsForRole, isUserRole } from '../utils/permissions' const SALT_ROUNDS = 10 -const sanitizeUser = (user: any) => ({ - id: user.id, - username: user.username, - name: user.name, - email: user.email, - phone: user.phone, - role: user.role, - roles: [user.role], - permissions: getPermissionsForRole(user.role), - status: user.status, - person_id: user.person_id, - person_bound: Boolean(user.person_id), - person: user.person || null, - last_login_at: user.last_login_at, - created_at: user.created_at, - updated_at: user.updated_at -}) +class UserValidationError extends Error { + constructor(message: string, public readonly status = 422) { + super(message) + this.name = 'UserValidationError' + } +} + +const sanitizeUser = (user: any) => { + const person = user.person + ? { + id: user.person.id, + real_name: user.person.real_name, + nickname: user.person.nickname, + status: user.person.status, + source_employee_status: user.person.source_employee_status + } + : null + return { + id: user.id, + username: user.username, + name: user.name, + email: user.email, + phone: user.phone, + role: user.role, + roles: [user.role], + permissions: getPermissionsForRole(user.role), + status: user.status, + person_id: user.person_id, + person_bound: Boolean(user.person_id), + dingtalk_ready: Boolean( + user.status === 'active' && + user.person?.status === 'active' && + user.person?.source_userid?.trim() + ), + person, + last_login_at: user.last_login_at, + created_at: user.created_at, + updated_at: user.updated_at + } +} const personSelect = { id: true, real_name: true, nickname: true, status: true, - source_employee_status: true + source_employee_status: true, + source_userid: true } as const const normalizePersonId = (value: unknown) => { @@ -37,7 +61,7 @@ const normalizePersonId = (value: unknown) => { if (value === null || value === '') return null const personId = Number(value) if (!Number.isInteger(personId) || personId <= 0) { - throw new Error('员工绑定无效') + throw new UserValidationError('员工绑定无效') } return personId } @@ -45,7 +69,13 @@ const normalizePersonId = (value: unknown) => { const ensurePersonCanBind = async (personId: number, excludeUserId?: number) => { const person = await prisma.person.findUnique({ where: { id: personId } }) if (!person) { - throw new Error('绑定的员工档案不存在') + throw new UserValidationError('绑定的员工档案不存在') + } + if (person.status !== 'active') { + throw new UserValidationError('只能绑定在职且启用的员工档案') + } + if (!person.source_userid?.trim()) { + throw new UserValidationError('该员工档案缺少钉钉 userId,暂时不能绑定') } const occupied = await prisma.user.findFirst({ @@ -56,10 +86,25 @@ const ensurePersonCanBind = async (personId: number, excludeUserId?: number) => select: { id: true } }) if (occupied) { - throw new Error('该员工档案已绑定其他用户') + throw new UserValidationError('该员工档案已绑定其他用户', 409) } } +const sendUserWriteError = (res: Response, error: any, fallbackMessage: string) => { + if (error?.code === 'P2002') { + const targets = Array.isArray(error?.meta?.target) + ? error.meta.target.map(String) + : [String(error?.meta?.target || '')] + const message = targets.some((target: string) => target.includes('person_id')) + ? '该员工档案已绑定其他用户' + : targets.some((target: string) => target.includes('username')) + ? '用户名已存在' + : '用户资料与现有记录冲突' + return errorResponse(res, message, 409) + } + return errorResponse(res, error.message || fallbackMessage, error.status || 500) +} + export const getUserList = async (req: Request, res: Response) => { try { const { page = 1, pageSize = 10, keyword, role, status } = req.query @@ -155,7 +200,7 @@ export const createUser = async (req: Request, res: Response) => { successResponse(res, sanitizeUser(created), '创建成功') } catch (error: any) { - errorResponse(res, error.message || '创建用户失败') + sendUserWriteError(res, error, '创建用户失败') } } @@ -203,7 +248,7 @@ export const updateUser = async (req: Request, res: Response) => { successResponse(res, sanitizeUser(updated), '更新成功') } catch (error: any) { - errorResponse(res, error.message || '更新用户失败') + sendUserWriteError(res, error, '更新用户失败') } } diff --git a/backend/src/middleware/__tests__/originGuard.test.ts b/backend/src/middleware/__tests__/originGuard.test.ts index dae6c0e..fbc19ad 100644 --- a/backend/src/middleware/__tests__/originGuard.test.ts +++ b/backend/src/middleware/__tests__/originGuard.test.ts @@ -4,14 +4,14 @@ import { unsafeOriginGuard } from '../originGuard' const originalEnv = { nodeEnv: process.env.NODE_ENV, - publicBaseUrl: process.env.DINGTALK_PUBLIC_BASE_URL, + publicBaseUrl: process.env.APP_PUBLIC_BASE_URL, centerUrl: process.env.YYD_NOTIFICATION_CENTER_URL, corsOrigins: process.env.CORS_ALLOWED_ORIGINS } test.before(() => { process.env.NODE_ENV = 'production' - process.env.DINGTALK_PUBLIC_BASE_URL = 'https://asset.example.com' + process.env.APP_PUBLIC_BASE_URL = 'https://asset.example.com' process.env.YYD_NOTIFICATION_CENTER_URL = 'https://sc.example.com/notification-center' process.env.CORS_ALLOWED_ORIGINS = '' }) @@ -19,7 +19,7 @@ test.before(() => { test.after(() => { for (const [key, value] of Object.entries({ NODE_ENV: originalEnv.nodeEnv, - DINGTALK_PUBLIC_BASE_URL: originalEnv.publicBaseUrl, + APP_PUBLIC_BASE_URL: originalEnv.publicBaseUrl, YYD_NOTIFICATION_CENTER_URL: originalEnv.centerUrl, CORS_ALLOWED_ORIGINS: originalEnv.corsOrigins })) { diff --git a/backend/src/routes/dingtalk.ts b/backend/src/routes/dingtalk.ts index c4ab552..a0e0b9f 100644 --- a/backend/src/routes/dingtalk.ts +++ b/backend/src/routes/dingtalk.ts @@ -3,7 +3,6 @@ import { getDingTalkDeliveries, getDingTalkDeliveryStatus, getPublicDingTalkConfig, - loginWithDingTalkCode, loginWithYouyidianTicket, retryDingTalkDelivery } from '../controllers/dingtalkController' @@ -13,7 +12,6 @@ import { PERMISSIONS } from '../utils/permissions' const router = Router() router.get('/public-config', getPublicDingTalkConfig) -router.post('/h5-login', loginWithDingTalkCode) router.post('/broker-login', loginWithYouyidianTicket) router.use('/deliveries', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS)) diff --git a/backend/src/services/__tests__/businessNotificationService.test.ts b/backend/src/services/__tests__/businessNotificationService.test.ts new file mode 100644 index 0000000..cdf5df3 --- /dev/null +++ b/backend/src/services/__tests__/businessNotificationService.test.ts @@ -0,0 +1,147 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + createBusinessNotification, + escapeNotificationMarkdownInline, + findActiveDingTalkUsersByRoles +} from '../businessNotificationService' + +test('收件人查询同时要求启用账号、在职员工和非空钉钉userId', async () => { + let capturedWhere: any + const client = { + user: { + findMany: async (args: any) => { + capturedWhere = args.where + return [ + { id: 1, person: { source_userid: 'ding-user-1' } }, + { id: 2, person: { source_userid: ' ' } }, + { id: 3, person: null } + ] + } + } + } + + const recipients = await findActiveDingTalkUsersByRoles(client as any, ['administrative'], { + enabled: true, + fullRollout: true, + testUserIds: [] + }) + + assert.deepEqual(recipients, [{ id: 1, person: { source_userid: 'ding-user-1' } }]) + assert.equal(capturedWhere.status, 'active') + assert.deepEqual(capturedWhere.role, { in: ['administrative'] }) + assert.equal(capturedWhere.person.is.status, 'active') + assert.deepEqual(capturedWhere.person.is.source_userid, { not: null }) +}) + +test('灰度期只查询测试userid,发送关闭时不生成任何钉钉收件人', async () => { + let queryCount = 0 + let capturedWhere: any + const client = { + user: { + findMany: async (args: any) => { + queryCount += 1 + capturedWhere = args.where + return [{ id: 1, person: { source_userid: 'ding-test-user' } }] + } + } + } + + const disabled = await findActiveDingTalkUsersByRoles(client as any, ['administrative'], { + enabled: false, + fullRollout: false, + testUserIds: ['ding-test-user'] + }) + assert.deepEqual(disabled, []) + assert.equal(queryCount, 0) + + const testRecipients = await findActiveDingTalkUsersByRoles(client as any, ['administrative'], { + enabled: true, + fullRollout: false, + testUserIds: ['ding-test-user'] + }) + assert.equal(testRecipients.length, 1) + assert.deepEqual(capturedWhere.person.is.source_userid, { in: ['ding-test-user'] }) +}) + +test('业务通知按事件、业务ID、钉钉userId和消息版本生成稳定幂等键', async () => { + let deliveryRows: any[] = [] + const client = { + notification: { + create: async (args: any) => ({ id: 8, ...args.data }) + }, + dingTalkDelivery: { + createMany: async (args: any) => { + deliveryRows = args.data + return { count: args.data.length } + } + } + } + + await createBusinessNotification(client as any, { + eventType: 'disposal_applied', + businessType: 'disposal', + businessId: 53, + title: '站内标题', + content: '站内正文', + actionUrl: '/workflows/disposals/53', + stationRecipients: [{ id: 7 }], + dingTalkRecipients: [{ id: 7, person: { source_userid: 'ding-user-7' } }], + dingTalk: { + title: '资产报废待审批', + content: '### 待你审批\n\n**资产:** 主机(JS-PC-0053)', + actionTitle: '立即审批', + messageVersion: 'v1' + } + }) + + assert.equal(deliveryRows.length, 1) + assert.equal(deliveryRows[0].idempotency_key, 'disposal_applied:53:ding-user-7:v1') + assert.equal(deliveryRows[0].provider_type, 'notification_center') + assert.equal(deliveryRows[0].recipient_dingtalk_userid, 'ding-user-7') + assert.deepEqual(JSON.parse(deliveryRows[0].payload_json), { + title: '资产报废待审批', + content: '### 待你审批\n\n**资产:** 主机(JS-PC-0053)', + action_title: '立即审批', + action_url: '/workflows/disposals/53', + message_version: 'v1' + }) +}) + +test('站内收件人与钉钉收件人相互独立且动态字段会转义Markdown', async () => { + let stationRows: any[] = [] + let deliveryCount = -1 + const client = { + notification: { + create: async (args: any) => { + stationRows = args.data.recipients.create + return { id: 9 } + } + }, + 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: 54, + title: '站内标题', + content: '站内正文', + actionUrl: '/workflows/disposals/54', + stationRecipients: [{ id: 11 }], + dingTalkRecipients: [], + dingTalk: { + title: '资产报废待审批', + content: '正文', + actionTitle: '立即审批' + } + }) + assert.deepEqual(stationRows, [{ user_id: 11 }]) + assert.equal(deliveryCount, -1) + assert.equal(escapeNotificationMarkdownInline('[资产](https://evil.example)'), '\\[资产\\]\\(https://evil\\.example\\)') +}) diff --git a/backend/src/services/__tests__/dingtalkClient.channel.test.ts b/backend/src/services/__tests__/dingtalkClient.channel.test.ts index 6da1ce6..cac7ae6 100644 --- a/backend/src/services/__tests__/dingtalkClient.channel.test.ts +++ b/backend/src/services/__tests__/dingtalkClient.channel.test.ts @@ -1,61 +1,45 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { getDingTalkNotificationChannelForUser } from '../dingtalkClient' +import { getDingTalkConfigStatus, getDingTalkSendRolloutStatus } from '../dingtalkClient' -test('通知中心灰度名单以外的员工继续使用工作通知', () => { - const originalChannel = process.env.DINGTALK_NOTIFICATION_CHANNEL - const originalUsers = process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS - const originalFullRollout = process.env.YYD_NOTIFICATION_CENTER_FULL_ROLLOUT - process.env.DINGTALK_NOTIFICATION_CHANNEL = 'notification_center' - process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS = 'ding-test-1,ding-test-2' - process.env.YYD_NOTIFICATION_CENTER_FULL_ROLLOUT = 'false' +test('业务后端投递通道固定为由壹点通知中心', () => { + const keys = [ + 'YYD_NOTIFICATION_CENTER_URL', + 'YYD_NOTIFICATION_PROJECT_CODE', + 'YYD_NOTIFICATION_PROJECT_SECRET', + 'APP_PUBLIC_BASE_URL', + 'YYD_NOTIFICATION_SEND_ENABLED', + 'YYD_NOTIFICATION_SEND_TEST_USER_IDS', + 'YYD_NOTIFICATION_SEND_FULL_ROLLOUT', + 'YYD_NOTIFICATION_LOGIN_ENABLED', + 'YYD_NOTIFICATION_LOGIN_TEST_USER_IDS', + 'YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT' + ] as const + const original = Object.fromEntries(keys.map((key) => [key, process.env[key]])) + process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com' + process.env.YYD_NOTIFICATION_PROJECT_CODE = 'fixed_asset' + process.env.YYD_NOTIFICATION_PROJECT_SECRET = '123456789012345678901234' + process.env.APP_PUBLIC_BASE_URL = 'https://asset.example.com' + process.env.YYD_NOTIFICATION_SEND_ENABLED = 'true' + process.env.YYD_NOTIFICATION_SEND_TEST_USER_IDS = 'ding-test-user' + process.env.YYD_NOTIFICATION_SEND_FULL_ROLLOUT = 'false' + process.env.YYD_NOTIFICATION_LOGIN_ENABLED = 'true' + process.env.YYD_NOTIFICATION_LOGIN_TEST_USER_IDS = 'ding-test-user' + process.env.YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT = 'false' try { - assert.equal(getDingTalkNotificationChannelForUser('ding-test-1'), 'notification_center') - assert.equal(getDingTalkNotificationChannelForUser('ding-other'), 'work_notice') + assert.equal(getDingTalkConfigStatus().channel, 'notification_center') + assert.deepEqual(getDingTalkSendRolloutStatus(), { + requested: true, + enabled: true, + rollout_mode: 'test', + test_user_count: 1, + testUserIds: ['ding-test-user'], + fullRollout: false + }) } finally { - if (originalChannel === undefined) delete process.env.DINGTALK_NOTIFICATION_CHANNEL - else process.env.DINGTALK_NOTIFICATION_CHANNEL = originalChannel - if (originalUsers === undefined) delete process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS - else process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS = originalUsers - if (originalFullRollout === undefined) delete process.env.YYD_NOTIFICATION_CENTER_FULL_ROLLOUT - else process.env.YYD_NOTIFICATION_CENTER_FULL_ROLLOUT = originalFullRollout - } -}) - -test('通知中心灰度名单为空时默认不切换任何员工', () => { - const originalChannel = process.env.DINGTALK_NOTIFICATION_CHANNEL - const originalUsers = process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS - const originalFullRollout = process.env.YYD_NOTIFICATION_CENTER_FULL_ROLLOUT - process.env.DINGTALK_NOTIFICATION_CHANNEL = 'notification_center' - process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS = '' - process.env.YYD_NOTIFICATION_CENTER_FULL_ROLLOUT = 'false' - try { - assert.equal(getDingTalkNotificationChannelForUser('ding-test-1'), 'work_notice') - } finally { - if (originalChannel === undefined) delete process.env.DINGTALK_NOTIFICATION_CHANNEL - else process.env.DINGTALK_NOTIFICATION_CHANNEL = originalChannel - if (originalUsers === undefined) delete process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS - else process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS = originalUsers - if (originalFullRollout === undefined) delete process.env.YYD_NOTIFICATION_CENTER_FULL_ROLLOUT - else process.env.YYD_NOTIFICATION_CENTER_FULL_ROLLOUT = originalFullRollout - } -}) - -test('只有显式开启全量开关时空灰度名单才会全量切换', () => { - const originalChannel = process.env.DINGTALK_NOTIFICATION_CHANNEL - const originalUsers = process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS - const originalFullRollout = process.env.YYD_NOTIFICATION_CENTER_FULL_ROLLOUT - process.env.DINGTALK_NOTIFICATION_CHANNEL = 'notification_center' - process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS = '' - process.env.YYD_NOTIFICATION_CENTER_FULL_ROLLOUT = 'true' - try { - assert.equal(getDingTalkNotificationChannelForUser('ding-any'), 'notification_center') - } finally { - if (originalChannel === undefined) delete process.env.DINGTALK_NOTIFICATION_CHANNEL - else process.env.DINGTALK_NOTIFICATION_CHANNEL = originalChannel - if (originalUsers === undefined) delete process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS - else process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS = originalUsers - if (originalFullRollout === undefined) delete process.env.YYD_NOTIFICATION_CENTER_FULL_ROLLOUT - else process.env.YYD_NOTIFICATION_CENTER_FULL_ROLLOUT = originalFullRollout + for (const key of keys) { + if (original[key] === undefined) delete process.env[key] + else process.env[key] = original[key] + } } }) diff --git a/backend/src/services/__tests__/dingtalkClient.config.test.ts b/backend/src/services/__tests__/dingtalkClient.config.test.ts index 68999c9..564f439 100644 --- a/backend/src/services/__tests__/dingtalkClient.config.test.ts +++ b/backend/src/services/__tests__/dingtalkClient.config.test.ts @@ -3,63 +3,31 @@ import assert from 'node:assert/strict' import { getDingTalkConfigStatus, getDingTalkPublicConfig } from '../dingtalkClient' -test('钉钉 H5 公开配置返回 clientId 但不泄露 AppSecret', () => { - const keys = [ - 'DINGTALK_APP_KEY', - 'DINGTALK_APP_SECRET', - 'DINGTALK_AGENT_ID', - 'DINGTALK_CORP_ID', - 'DINGTALK_PUBLIC_BASE_URL', - 'YYD_NOTIFICATION_CENTER_LOGIN_ENABLED' - ] as const +const keys = [ + 'YYD_NOTIFICATION_CENTER_URL', + 'YYD_NOTIFICATION_PROJECT_CODE', + 'YYD_NOTIFICATION_PROJECT_SECRET', + 'YYD_NOTIFICATION_SEND_ENABLED', + 'YYD_NOTIFICATION_SEND_TEST_USER_IDS', + 'YYD_NOTIFICATION_SEND_FULL_ROLLOUT', + 'YYD_NOTIFICATION_LOGIN_ENABLED', + 'YYD_NOTIFICATION_LOGIN_TEST_USER_IDS', + 'YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT', + 'APP_PUBLIC_BASE_URL' +] as const + +test('设置状态只返回通知中心连接状态且不泄露项目密钥', () => { const original = Object.fromEntries(keys.map((key) => [key, process.env[key]])) - - process.env.DINGTALK_APP_KEY = 'ding-app-key' - process.env.DINGTALK_APP_SECRET = 'must-not-leak' - process.env.DINGTALK_AGENT_ID = '10001' - process.env.DINGTALK_CORP_ID = 'ding-corp' - process.env.DINGTALK_PUBLIC_BASE_URL = 'https://assets.example.com' - process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = 'false' - - try { - const config = getDingTalkPublicConfig() - assert.deepEqual(config, { - configured: true, - mode: 'work_notice', - corpId: 'ding-corp', - clientId: 'ding-app-key' - }) - assert.equal(JSON.stringify(config).includes('must-not-leak'), false) - } finally { - for (const key of keys) { - if (original[key] === undefined) delete process.env[key] - else process.env[key] = original[key] - } - } -}) - -test('通知中心配置状态只返回灰度数量且不泄露密钥或员工userid', () => { - const keys = [ - 'DINGTALK_NOTIFICATION_CHANNEL', - 'DINGTALK_PUBLIC_BASE_URL', - 'DINGTALK_SEND_ENABLED', - 'YYD_NOTIFICATION_CENTER_URL', - 'YYD_NOTIFICATION_CENTER_PROJECT_CODE', - 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET', - 'YYD_NOTIFICATION_CENTER_TEST_USER_IDS', - 'YYD_NOTIFICATION_CENTER_FULL_ROLLOUT' - ] as const - const original = Object.fromEntries(keys.map((key) => [key, process.env[key]])) - - process.env.DINGTALK_NOTIFICATION_CHANNEL = 'notification_center' - process.env.DINGTALK_PUBLIC_BASE_URL = 'https://asset.example.com' - process.env.DINGTALK_SEND_ENABLED = 'true' process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com' - process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'fixed_asset' - process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = 'must-not-leak-project-secret' - process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS = 'must-not-leak-user-1,must-not-leak-user-2' - process.env.YYD_NOTIFICATION_CENTER_FULL_ROLLOUT = 'false' - + process.env.YYD_NOTIFICATION_PROJECT_CODE = 'fixed_asset' + process.env.YYD_NOTIFICATION_PROJECT_SECRET = 'must-not-leak-project-secret' + process.env.YYD_NOTIFICATION_SEND_ENABLED = 'true' + process.env.YYD_NOTIFICATION_SEND_TEST_USER_IDS = 'ding-test-user' + process.env.YYD_NOTIFICATION_SEND_FULL_ROLLOUT = 'false' + process.env.YYD_NOTIFICATION_LOGIN_ENABLED = 'true' + process.env.YYD_NOTIFICATION_LOGIN_TEST_USER_IDS = 'ding-test-user' + process.env.YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT = 'false' + process.env.APP_PUBLIC_BASE_URL = 'https://asset.example.com' try { const status = getDingTalkConfigStatus() assert.deepEqual(status, { @@ -67,12 +35,15 @@ test('通知中心配置状态只返回灰度数量且不泄露密钥或员工us channel: 'notification_center', send_enabled: true, rollout_mode: 'test', - test_user_count: 2, + test_user_count: 1, missing: [] }) - const serialized = JSON.stringify(status) - assert.equal(serialized.includes('must-not-leak-project-secret'), false) - assert.equal(serialized.includes('must-not-leak-user-1'), false) + assert.equal(JSON.stringify(status).includes('must-not-leak-project-secret'), false) + + process.env.APP_PUBLIC_BASE_URL = 'https://asset.example.com/unexpected-prefix' + const invalidPublicBase = getDingTalkConfigStatus() + assert.equal(invalidPublicBase.configured, false) + assert.ok(invalidPublicBase.missing.some((item) => item.startsWith('APP_PUBLIC_BASE_URL'))) } finally { for (const key of keys) { if (original[key] === undefined) delete process.env[key] @@ -81,51 +52,56 @@ test('通知中心配置状态只返回灰度数量且不泄露密钥或员工us } }) -test('免登灰度不接管旧入口,只有全量开关才统一跳转通知中心', () => { - const keys = [ - 'DINGTALK_APP_KEY', - 'DINGTALK_APP_SECRET', - 'DINGTALK_AGENT_ID', - 'DINGTALK_CORP_ID', - 'DINGTALK_PUBLIC_BASE_URL', - 'YYD_NOTIFICATION_CENTER_URL', - 'YYD_NOTIFICATION_CENTER_PROJECT_CODE', - 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET', - 'YYD_NOTIFICATION_CENTER_LOGIN_ENABLED', - 'YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS', - 'YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT' - ] as const +test('公开免登配置只返回通知中心入口,不返回钉钉应用字段', () => { const original = Object.fromEntries(keys.map((key) => [key, process.env[key]])) - - delete process.env.DINGTALK_APP_KEY - delete process.env.DINGTALK_APP_SECRET - delete process.env.DINGTALK_AGENT_ID - delete process.env.DINGTALK_CORP_ID - process.env.DINGTALK_PUBLIC_BASE_URL = 'https://asset.example.com' process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com' - process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'fixed_asset' - process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = '123456789012345678901234' - process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = 'true' - process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS = 'ding-test-user' - process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT = 'false' - + process.env.YYD_NOTIFICATION_PROJECT_CODE = 'fixed_asset' + process.env.YYD_NOTIFICATION_PROJECT_SECRET = '123456789012345678901234' + process.env.APP_PUBLIC_BASE_URL = 'https://asset.example.com' + process.env.YYD_NOTIFICATION_LOGIN_ENABLED = 'true' + process.env.YYD_NOTIFICATION_LOGIN_TEST_USER_IDS = 'ding-test-user' + process.env.YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT = 'false' try { - assert.deepEqual(getDingTalkPublicConfig('/dashboard'), { - configured: false, - mode: 'work_notice', - corpId: '', - clientId: '' + const config = getDingTalkPublicConfig('/dashboard') + assert.deepEqual(config, { + configured: true, + mode: 'notification_center', + loginEntryUrl: 'https://notify.example.com/v1/auth/h5-entry?projectCode=fixed_asset&redirectPath=%2Fdashboard', + rolloutMode: 'test' }) - - process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT = 'true' - const fullConfig = getDingTalkPublicConfig('/dashboard') - assert.equal(fullConfig.configured, true) - assert.equal(fullConfig.mode, 'notification_center') - assert.equal(fullConfig.rolloutMode, 'full') - assert.equal( - fullConfig.loginEntryUrl, - 'https://notify.example.com/v1/auth/h5-entry?projectCode=fixed_asset&redirectPath=%2Fdashboard' - ) + assert.equal('clientId' in config, false) + assert.equal('corpId' in config, false) + } finally { + for (const key of keys) { + if (original[key] === undefined) delete process.env[key] + else process.env[key] = original[key] + } + } +}) + +test('发送灰度必须被免登灰度覆盖,全量发送必须先全量开放免登', () => { + const original = Object.fromEntries(keys.map((key) => [key, process.env[key]])) + process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com' + process.env.YYD_NOTIFICATION_PROJECT_CODE = 'fixed_asset' + process.env.YYD_NOTIFICATION_PROJECT_SECRET = '123456789012345678901234' + process.env.APP_PUBLIC_BASE_URL = 'https://asset.example.com' + process.env.YYD_NOTIFICATION_SEND_ENABLED = 'true' + process.env.YYD_NOTIFICATION_LOGIN_ENABLED = 'true' + try { + process.env.YYD_NOTIFICATION_SEND_TEST_USER_IDS = 'ding-user-a,ding-user-b' + process.env.YYD_NOTIFICATION_SEND_FULL_ROLLOUT = 'false' + process.env.YYD_NOTIFICATION_LOGIN_TEST_USER_IDS = 'ding-user-a' + process.env.YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT = 'false' + const testMismatch = getDingTalkConfigStatus() + assert.equal(testMismatch.configured, false) + assert.equal(testMismatch.send_enabled, false) + assert.ok(testMismatch.missing.some((item) => item.includes('必须覆盖发送灰度名单'))) + + process.env.YYD_NOTIFICATION_SEND_FULL_ROLLOUT = 'true' + const fullMismatch = getDingTalkConfigStatus() + assert.equal(fullMismatch.configured, false) + assert.equal(fullMismatch.send_enabled, false) + assert.ok(fullMismatch.missing.some((item) => item.includes('全量发送前必须全量启用免登'))) } finally { for (const key of keys) { if (original[key] === undefined) delete process.env[key] diff --git a/backend/src/services/__tests__/dingtalkDeliveryService.notification-center.test.ts b/backend/src/services/__tests__/dingtalkDeliveryService.notification-center.test.ts index d358e2d..be5cf67 100644 --- a/backend/src/services/__tests__/dingtalkDeliveryService.notification-center.test.ts +++ b/backend/src/services/__tests__/dingtalkDeliveryService.notification-center.test.ts @@ -1,14 +1,80 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { resolveNotificationCenterLocalStatus } from '../dingtalkDeliveryService' +import { + buildClaimableDeliveryWhere, + buildRefreshableDeliveryWhere, + buildRefreshErrorUpdate, + isDeliveryRecipientStillEligible, + resolveDeliveryActionTitle, + resolveMonotonicNotificationCenterStatus, + resolveNotificationCenterLocalStatus +} from '../dingtalkDeliveryService' +import { NotificationCenterError } from '../youyidianNotificationClient' -test('通知中心未完成状态在本地保持发送中而不是误记为已发送', () => { - for (const status of ['pending', 'processing', 'sending', 'retrying'] as const) { - assert.equal(resolveNotificationCenterLocalStatus(status), 'sending') +test('本地完整保留通知中心的投递状态', () => { + for (const status of ['pending', 'sending', 'retrying', 'sent', 'unread', 'read', 'failed'] as const) { + assert.equal(resolveNotificationCenterLocalStatus(status), status) } - assert.equal(resolveNotificationCenterLocalStatus('sent'), 'sent') - assert.equal(resolveNotificationCenterLocalStatus('unread'), 'unread') - assert.equal(resolveNotificationCenterLocalStatus('read'), 'read') - assert.equal(resolveNotificationCenterLocalStatus('failed'), 'failed') +}) + +test('回查结果只能前进,不能把已发送或未读状态倒退', () => { + assert.equal(resolveMonotonicNotificationCenterStatus('sent', 'pending'), 'sent') + assert.equal(resolveMonotonicNotificationCenterStatus('unread', 'sent'), 'unread') + assert.equal(resolveMonotonicNotificationCenterStatus('retrying', 'sending'), 'sending') + assert.equal(resolveMonotonicNotificationCenterStatus('sent', 'read'), 'read') + assert.equal(resolveMonotonicNotificationCenterStatus('unread', 'failed'), 'failed') +}) + +test('已有通知中心任务ID的记录不会被再次领取并重复提交', () => { + const where = buildClaimableDeliveryWhere( + new Date('2026-07-16T10:00:00.000Z'), + ['ding-test-user'] + ) + assert.equal(where.provider_type, 'notification_center') + assert.equal(where.dingtalk_task_id, null) + assert.deepEqual(where.recipient_dingtalk_userid, { in: ['ding-test-user'] }) +}) + +test('外部状态查询也通过租约原子领取且确定性错误进入失败终态', () => { + const now = new Date('2026-07-16T10:00:00.000Z') + const where = buildRefreshableDeliveryWhere(now) + assert.deepEqual(where.status, { in: ['pending', 'sending', 'retrying', 'sent', 'unread'] }) + assert.deepEqual(where.dingtalk_task_id, { not: null }) + assert.ok(Array.isArray(where.OR)) + + const update = buildRefreshErrorUpdate( + new NotificationCenterError('签名错误', 'SIGNATURE_INVALID', true, 401) + ) + assert.equal(update.status, 'failed') + assert.equal(update.last_error_code, 'SIGNATURE_INVALID') + assert.equal(update.lease_owner, null) +}) + +test('旧队列缺少action_title时按业务事件补齐安全按钮', () => { + assert.equal(resolveDeliveryActionTitle('disposal_applied', ''), '立即审批') + assert.equal(resolveDeliveryActionTitle('disposal_completed', ''), '查看结果') + assert.equal(resolveDeliveryActionTitle('unknown_event', ''), '查看详情') +}) + +test('投递前按当前账号、审批角色、员工状态和userid精确复核收件人', async () => { + let capturedWhere: any + const eligible = await isDeliveryRecipientStillEligible({ + recipientUserId: 7, + recipientDingTalkUserId: 'ding-user-7', + eventType: 'disposal_applied' + }, { + user: { + findFirst: async (args: any) => { + capturedWhere = args.where + return { id: 7 } + } + } + } as any) + assert.equal(eligible, true) + assert.equal(capturedWhere.id, 7) + assert.equal(capturedWhere.status, 'active') + assert.deepEqual(capturedWhere.role, { in: ['administrative', 'admin'] }) + assert.equal(capturedWhere.person.is.status, 'active') + assert.equal(capturedWhere.person.is.source_userid, 'ding-user-7') }) diff --git a/backend/src/services/__tests__/dingtalkDeliveryService.reminder.test.ts b/backend/src/services/__tests__/dingtalkDeliveryService.reminder.test.ts index e0c15fb..d55b32f 100644 --- a/backend/src/services/__tests__/dingtalkDeliveryService.reminder.test.ts +++ b/backend/src/services/__tests__/dingtalkDeliveryService.reminder.test.ts @@ -55,8 +55,14 @@ test('24小时催办在无行政时回退通知启用管理员', async () => { }) try { - await createOverdueDisposalReminders() + await createOverdueDisposalReminders({ + enabled: true, + fullRollout: true, + testUserIds: [] + }) assert.deepEqual(events, [ + 'recipients:administrative', + 'recipients:admin', 'recipients:administrative', 'recipients:admin', 'claim', diff --git a/backend/src/services/__tests__/youyidianNotificationClient.test.ts b/backend/src/services/__tests__/youyidianNotificationClient.test.ts index 17bb915..ea385d1 100644 --- a/backend/src/services/__tests__/youyidianNotificationClient.test.ts +++ b/backend/src/services/__tests__/youyidianNotificationClient.test.ts @@ -5,9 +5,9 @@ import { createNotificationCenterSignature, getNotificationCenterConfigStatus, getNotificationCenterLoginConfigStatus, - isNotificationCenterLoginAllowedForUser, NotificationCenterError, prepareNotificationCenterManualRetryPayload, + prepareNotificationCenterResumePayload, redeemYouyidianLoginTicket, submitYouyidianNotification } from '../youyidianNotificationClient' @@ -15,30 +15,36 @@ import { const originalFetch = global.fetch const originalEnv = { url: process.env.YYD_NOTIFICATION_CENTER_URL, - code: process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE, - secret: process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET, - publicBaseUrl: process.env.DINGTALK_PUBLIC_BASE_URL, - loginEnabled: process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED, - loginTestUsers: process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS, - loginFullRollout: process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT + code: process.env.YYD_NOTIFICATION_PROJECT_CODE, + secret: process.env.YYD_NOTIFICATION_PROJECT_SECRET, + publicBaseUrl: process.env.APP_PUBLIC_BASE_URL, + loginEnabled: process.env.YYD_NOTIFICATION_LOGIN_ENABLED, + loginTestUserIds: process.env.YYD_NOTIFICATION_LOGIN_TEST_USER_IDS, + loginFullRollout: process.env.YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT } +test.beforeEach(() => { + process.env.YYD_NOTIFICATION_LOGIN_ENABLED = 'true' + process.env.YYD_NOTIFICATION_LOGIN_TEST_USER_IDS = 'ding-test-user,ding-user-1' + process.env.YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT = 'false' +}) + test.after(() => { global.fetch = originalFetch if (originalEnv.url === undefined) delete process.env.YYD_NOTIFICATION_CENTER_URL else process.env.YYD_NOTIFICATION_CENTER_URL = originalEnv.url - if (originalEnv.code === undefined) delete process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE - else process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = originalEnv.code - if (originalEnv.secret === undefined) delete process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET - else process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = originalEnv.secret - if (originalEnv.publicBaseUrl === undefined) delete process.env.DINGTALK_PUBLIC_BASE_URL - else process.env.DINGTALK_PUBLIC_BASE_URL = originalEnv.publicBaseUrl - if (originalEnv.loginEnabled === undefined) delete process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED - else process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = originalEnv.loginEnabled - if (originalEnv.loginTestUsers === undefined) delete process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS - else process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS = originalEnv.loginTestUsers - if (originalEnv.loginFullRollout === undefined) delete process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT - else process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT = originalEnv.loginFullRollout + if (originalEnv.code === undefined) delete process.env.YYD_NOTIFICATION_PROJECT_CODE + else process.env.YYD_NOTIFICATION_PROJECT_CODE = originalEnv.code + if (originalEnv.secret === undefined) delete process.env.YYD_NOTIFICATION_PROJECT_SECRET + else process.env.YYD_NOTIFICATION_PROJECT_SECRET = originalEnv.secret + if (originalEnv.publicBaseUrl === undefined) delete process.env.APP_PUBLIC_BASE_URL + else process.env.APP_PUBLIC_BASE_URL = originalEnv.publicBaseUrl + if (originalEnv.loginEnabled === undefined) delete process.env.YYD_NOTIFICATION_LOGIN_ENABLED + else process.env.YYD_NOTIFICATION_LOGIN_ENABLED = originalEnv.loginEnabled + if (originalEnv.loginTestUserIds === undefined) delete process.env.YYD_NOTIFICATION_LOGIN_TEST_USER_IDS + else process.env.YYD_NOTIFICATION_LOGIN_TEST_USER_IDS = originalEnv.loginTestUserIds + if (originalEnv.loginFullRollout === undefined) delete process.env.YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT + else process.env.YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT = originalEnv.loginFullRollout }) test('HMAC签名固定覆盖POST正文与GET空正文', () => { @@ -63,9 +69,9 @@ test('HMAC签名固定覆盖POST正文与GET空正文', () => { test('固定资产通过独立项目签名提交到由壹点通知中心', async () => { process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com' - process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'fixed_asset' - process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = '123456789012345678901234' - process.env.DINGTALK_PUBLIC_BASE_URL = 'https://asset.example.com' + process.env.YYD_NOTIFICATION_PROJECT_CODE = 'fixed_asset' + process.env.YYD_NOTIFICATION_PROJECT_SECRET = '123456789012345678901234' + process.env.APP_PUBLIC_BASE_URL = 'https://asset.example.com' let captured: { url?: string; init?: RequestInit } = {} global.fetch = (async (url: string | URL | Request, init?: RequestInit) => { captured = { url: String(url), init } @@ -83,7 +89,7 @@ test('固定资产通过独立项目签名提交到由壹点通知中心', async }) as typeof fetch const result = await submitYouyidianNotification({ - idempotencyKey: 'disposal_applied:disposal:53:1', + idempotencyKey: 'disposal_applied:53:ding-user-1:v1', recipientUserId: 'ding-user-1', title: '待审批报废', content: '有一项报废申请等待审批', @@ -99,29 +105,33 @@ test('固定资产通过独立项目签名提交到由壹点通知中心', async assert.equal(body.actionUrl, 'https://asset.example.com/workflows/disposals/53') }) -test('统一免登默认关闭且灰度时只放行指定钉钉用户', () => { +test('统一免登使用独立开关并默认保持关闭', () => { process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com' - process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'fixed_asset' - process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = '123456789012345678901234' - process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = 'false' - process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS = '' - process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT = 'false' + process.env.YYD_NOTIFICATION_PROJECT_CODE = 'fixed_asset' + process.env.YYD_NOTIFICATION_PROJECT_SECRET = '123456789012345678901234' + process.env.YYD_NOTIFICATION_LOGIN_ENABLED = 'false' + process.env.YYD_NOTIFICATION_LOGIN_TEST_USER_IDS = '' + process.env.YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT = 'false' assert.equal(getNotificationCenterLoginConfigStatus().configured, false) - process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = 'true' - process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS = 'ding-test-user' - assert.equal(getNotificationCenterLoginConfigStatus().rollout_mode, 'test') - assert.equal(isNotificationCenterLoginAllowedForUser('ding-test-user'), true) - assert.equal(isNotificationCenterLoginAllowedForUser('other-user'), false) + process.env.YYD_NOTIFICATION_LOGIN_ENABLED = 'true' + process.env.YYD_NOTIFICATION_LOGIN_TEST_USER_IDS = 'ding-test-user' + assert.deepEqual(getNotificationCenterLoginConfigStatus(), { + configured: true, + enabled: true, + rollout_mode: 'test', + test_user_count: 1, + missing: [] + }) }) test('统一免登入口只携带项目代码和白名单内路径', () => { process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com' - process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'fixed_asset' - process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = '123456789012345678901234' - process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = 'true' - process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS = 'ding-test-user' - process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT = 'false' + process.env.YYD_NOTIFICATION_PROJECT_CODE = 'fixed_asset' + process.env.YYD_NOTIFICATION_PROJECT_SECRET = '123456789012345678901234' + process.env.YYD_NOTIFICATION_LOGIN_ENABLED = 'true' + process.env.YYD_NOTIFICATION_LOGIN_TEST_USER_IDS = 'ding-test-user' + process.env.YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT = 'false' assert.equal( buildYouyidianLoginEntryUrl('/workflows/disposals/53?source=ding'), 'https://notify.example.com/v1/auth/h5-entry?projectCode=fixed_asset&redirectPath=%2Fworkflows%2Fdisposals%2F53%3Fsource%3Dding' @@ -134,11 +144,8 @@ test('统一免登入口只携带项目代码和白名单内路径', () => { test('固定资产以项目HMAC兑换一次性票据并再次校验跳转地址', async () => { process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com' - process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'fixed_asset' - process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = '123456789012345678901234' - process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = 'true' - process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS = 'ding-user-1' - process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT = 'false' + process.env.YYD_NOTIFICATION_PROJECT_CODE = 'fixed_asset' + process.env.YYD_NOTIFICATION_PROJECT_SECRET = '123456789012345678901234' let captured: { url?: string; init?: RequestInit } = {} global.fetch = (async (url: string | URL | Request, init?: RequestInit) => { captured = { url: String(url), init } @@ -170,8 +177,8 @@ test('固定资产以项目HMAC兑换一次性票据并再次校验跳转地址' test('固定资产客户端拒绝误用其它项目代码', () => { process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com' - process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'material_library' - process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = '123456789012345678901234' + process.env.YYD_NOTIFICATION_PROJECT_CODE = 'material_library' + process.env.YYD_NOTIFICATION_PROJECT_SECRET = '123456789012345678901234' const status = getNotificationCenterConfigStatus() assert.equal(status.configured, false) assert.ok(status.missing.some((item) => item.includes('固定资产必须为fixed_asset'))) @@ -179,9 +186,9 @@ test('固定资产客户端拒绝误用其它项目代码', () => { test('通知中心返回错误项目时拒绝保存任务', async () => { process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com' - process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'fixed_asset' - process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = '123456789012345678901234' - process.env.DINGTALK_PUBLIC_BASE_URL = 'https://asset.example.com' + process.env.YYD_NOTIFICATION_PROJECT_CODE = 'fixed_asset' + process.env.YYD_NOTIFICATION_PROJECT_SECRET = '123456789012345678901234' + process.env.APP_PUBLIC_BASE_URL = 'https://asset.example.com' global.fetch = (async () => new Response(JSON.stringify({ id: 'notification-1', projectCode: 'material_library', @@ -193,7 +200,7 @@ test('通知中心返回错误项目时拒绝保存任务', async () => { await assert.rejects( submitYouyidianNotification({ - idempotencyKey: 'disposal_applied:disposal:53:1', + idempotencyKey: 'disposal_applied:53:ding-user-1:v1', recipientUserId: 'ding-user-1', title: '待审批报废', content: '有一项报废申请等待审批', @@ -204,18 +211,95 @@ test('通知中心返回错误项目时拒绝保存任务', async () => { ) }) +test('nonce重放可换nonce重试,但幂等冲突必须停止自动重试', async () => { + process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com' + process.env.YYD_NOTIFICATION_PROJECT_CODE = 'fixed_asset' + process.env.YYD_NOTIFICATION_PROJECT_SECRET = '123456789012345678901234' + process.env.APP_PUBLIC_BASE_URL = 'https://asset.example.com' + const input = { + idempotencyKey: 'disposal_applied:53:ding-user-1:v1', + recipientUserId: 'ding-user-1', + title: '资产报废待审批', + content: '### 待你审批', + actionTitle: '立即审批', + actionUrl: '/workflows/disposals/53' + } + + global.fetch = (async () => new Response(JSON.stringify({ error: 'NONCE_REPLAYED' }), { + status: 409, + headers: { 'Content-Type': 'application/json' } + })) as typeof fetch + await assert.rejects( + submitYouyidianNotification(input), + (error: unknown) => error instanceof NotificationCenterError && + error.code === 'NONCE_REPLAYED' && !error.deterministic + ) + + global.fetch = (async () => new Response(JSON.stringify({ error: 'IDEMPOTENCY_CONFLICT' }), { + status: 409, + headers: { 'Content-Type': 'application/json' } + })) as typeof fetch + await assert.rejects( + submitYouyidianNotification(input), + (error: unknown) => error instanceof NotificationCenterError && + error.code === 'IDEMPOTENCY_CONFLICT' && error.deterministic + ) +}) + test('人工补发生成新的稳定通知中心幂等键且自动重试不改变它', () => { - const original = JSON.stringify({ title: '待审批报废', content: '内容', action_url: '/workflows/53' }) - const first = prepareNotificationCenterManualRetryPayload(original, 'disposal_applied:disposal:53:1') - const repeated = prepareNotificationCenterManualRetryPayload(original, 'disposal_applied:disposal:53:1') - const second = prepareNotificationCenterManualRetryPayload(first, 'disposal_applied:disposal:53:1') + const original = JSON.stringify({ title: '待审批报废', content: '内容', action_url: '/workflows/53', message_version: 'v2' }) + const identity = { + eventType: 'disposal_applied', + businessId: 53, + recipientUserId: 'ding-user-1', + sourceDeliveryId: 15, + operatorUserId: 99, + createdAt: new Date('2026-07-16T12:30:00.000Z') + } + const first = prepareNotificationCenterManualRetryPayload(original, identity) + const repeated = prepareNotificationCenterManualRetryPayload(original, identity) + const second = prepareNotificationCenterManualRetryPayload(first, identity) const firstPayload = JSON.parse(first) const repeatedPayload = JSON.parse(repeated) const secondPayload = JSON.parse(second) assert.equal(firstPayload.notification_center_manual_retry_sequence, 1) + assert.equal(firstPayload.notification_center_idempotency_key, 'disposal_applied:53:ding-user-1:v2:manual-v1') + assert.equal(firstPayload.notification_center_manual_retry_source_delivery_id, 15) + assert.equal(firstPayload.notification_center_manual_retry_by, 99) assert.equal(firstPayload.notification_center_idempotency_key, repeatedPayload.notification_center_idempotency_key) assert.equal(secondPayload.notification_center_manual_retry_sequence, 2) assert.notEqual(firstPayload.notification_center_idempotency_key, secondPayload.notification_center_idempotency_key) assert.ok(firstPayload.notification_center_idempotency_key.length <= 160) + + const longIdentityPayload = JSON.parse(prepareNotificationCenterManualRetryPayload(original, { + ...identity, + eventType: 'event_'.repeat(20), + recipientUserId: 'recipient_'.repeat(20) + })) + assert.match(longIdentityPayload.notification_center_idempotency_key, /^manual:15:v2:/) + assert.ok(longIdentityPayload.notification_center_idempotency_key.length <= 160) +}) + +test('提交结果未知时恢复投递保留原幂等键并记录上次失败证据', () => { + const original = JSON.stringify({ + title: '资产报废待审批', + notification_center_idempotency_key: 'disposal_applied:53:ding-user-1:v1' + }) + const resumed = JSON.parse(prepareNotificationCenterResumePayload(original, { + code: 'TIMEOUT', + message: '响应超时', + attemptCount: 5, + operatorUserId: 99, + resumedAt: new Date('2026-07-16T12:00:00.000Z') + })) + assert.equal(resumed.notification_center_idempotency_key, 'disposal_applied:53:ding-user-1:v1') + assert.equal(resumed.notification_center_resume_count, 1) + assert.deepEqual(resumed.notification_center_resume_history, [{ + resumed_at: '2026-07-16T12:00:00.000Z', + previous_error_code: 'TIMEOUT', + previous_error_message: '响应超时', + previous_attempt_count: 5, + operator_user_id: 99 + }]) }) diff --git a/backend/src/services/businessNotificationService.ts b/backend/src/services/businessNotificationService.ts index 0a3a075..ef125fd 100644 --- a/backend/src/services/businessNotificationService.ts +++ b/backend/src/services/businessNotificationService.ts @@ -1,49 +1,149 @@ import type { Prisma } from '@prisma/client' -import { getDingTalkNotificationChannelForUser } from './dingtalkClient' +import { getDingTalkConfigStatus, getDingTalkSendRolloutStatus } from './dingtalkClient' type TransactionClient = Prisma.TransactionClient -export type NotificationRecipient = { +export type StationNotificationRecipient = { + id: number +} + +export type NotificationRecipient = StationNotificationRecipient & { + person: { source_userid: string } +} + +const isEligibleRecipient = (recipient: { id: number person: { source_userid: string | null } | null +}): recipient is NotificationRecipient => Boolean(recipient.person?.source_userid?.trim()) + +const recipientSelect = { + id: true, + person: { select: { source_userid: true } } +} as const + +export type DingTalkRecipientRollout = { + enabled: boolean + fullRollout: boolean + testUserIds: string[] } -export const findActiveUsersByRoles = async ( + +export const getCurrentDingTalkRecipientRollout = (): DingTalkRecipientRollout => { + const config = getDingTalkConfigStatus() + const rollout = getDingTalkSendRolloutStatus() + return { + enabled: config.send_enabled, + fullRollout: rollout.fullRollout, + testUserIds: rollout.testUserIds + } +} + +const dingTalkPersonWhere = (rollout: DingTalkRecipientRollout) => ({ + status: 'active', + source_userid: rollout.fullRollout + ? { not: null } + : { in: rollout.testUserIds } +}) + +export const findActiveStationUsersByRoles = async ( client: TransactionClient, roles: string[] -): Promise => client.user.findMany({ +): Promise => client.user.findMany({ where: { status: 'active', role: { in: roles } }, - select: { - id: true, - person: { select: { source_userid: true } } - } + select: { id: true } }) -export const findActiveUsersByIds = async ( +export const findActiveStationUsersByIds = async ( client: TransactionClient, userIds: number[] -): Promise => { +): Promise => { if (userIds.length === 0) return [] return client.user.findMany({ - where: { - id: { in: userIds }, - status: 'active' - }, - select: { - id: true, - person: { select: { source_userid: true } } - } + where: { id: { in: userIds }, status: 'active' }, + select: { id: true } }) } -export const mergeRecipients = (...groups: NotificationRecipient[][]) => { - const recipients = new Map() +export const findActiveDingTalkUsersByRoles = async ( + client: TransactionClient, + roles: string[], + rollout: DingTalkRecipientRollout = getCurrentDingTalkRecipientRollout() +): Promise => { + if (!rollout.enabled) return [] + const users = await client.user.findMany({ + where: { + status: 'active', + role: { in: roles }, + person: { + is: dingTalkPersonWhere(rollout) + } + }, + select: recipientSelect + }) + return users.filter(isEligibleRecipient) +} + +export const findActiveDingTalkUsersByIds = async ( + client: TransactionClient, + userIds: number[], + rollout: DingTalkRecipientRollout = getCurrentDingTalkRecipientRollout() +): Promise => { + if (userIds.length === 0 || !rollout.enabled) return [] + const users = await client.user.findMany({ + where: { + id: { in: userIds }, + status: 'active', + person: { + is: dingTalkPersonWhere(rollout) + } + }, + select: recipientSelect + }) + return users.filter(isEligibleRecipient) +} + +export const mergeRecipients = (...groups: T[][]) => { + const recipients = new Map() groups.flat().forEach((recipient) => recipients.set(recipient.id, recipient)) return [...recipients.values()] } +export const compactNotificationText = ( + value: unknown, + fallback = '未填写', + maxLength = 500 +) => { + const text = String(value ?? '').replace(/\s+/g, ' ').trim() + const resolved = text || fallback + return resolved.length <= maxLength + ? resolved + : `${resolved.slice(0, Math.max(1, maxLength - 1))}…` +} + +export const escapeNotificationMarkdownInline = ( + value: unknown, + fallback = '未填写', + maxLength = 500 +) => compactNotificationText(value, fallback, maxLength) + .replace(/([\\`*_{}\[\]()#+\-.!|<>])/g, '\\$1') + +export const formatBeijingDateTime = (value: Date) => { + const parts = new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23' + }).formatToParts(value) + const part = (type: Intl.DateTimeFormatPartTypes) => + parts.find((item) => item.type === type)?.value || '' + return `${part('year')}年${part('month')}月${part('day')}日 ${part('hour')}:${part('minute')}` +} + export const createBusinessNotification = async ( client: TransactionClient, input: { @@ -55,49 +155,72 @@ export const createBusinessNotification = async ( level?: string actionUrl: string metadata?: Record - recipients: NotificationRecipient[] + stationRecipients: StationNotificationRecipient[] + dingTalkRecipients: NotificationRecipient[] + dingTalk: { + title: string + content: string + actionTitle: string + messageVersion?: string + } } ) => { - if (input.recipients.length === 0) { - return null - } + const stationRecipients = mergeRecipients(input.stationRecipients) + const dingTalkRecipients = mergeRecipients(input.dingTalkRecipients) + if (stationRecipients.length === 0 && dingTalkRecipients.length === 0) return null - const notification = await client.notification.create({ - data: { - title: input.title, - content: input.content, - type: input.businessType, - level: input.level || 'info', - action_url: input.actionUrl, - metadata: JSON.stringify({ - event_type: input.eventType, - business_type: input.businessType, - business_id: input.businessId, - ...(input.metadata || {}) - }), - recipients: { - create: input.recipients.map((recipient) => ({ user_id: recipient.id })) - } + const messageVersion = compactNotificationText(input.dingTalk.messageVersion, 'v1', 30) + const deliveries = dingTalkRecipients.map((recipient) => { + const dingtalkUserId = recipient.person.source_userid.trim() + const idempotencyKey = `${input.eventType}:${input.businessId}:${dingtalkUserId}:${messageVersion}` + if (idempotencyKey.length > 160) { + throw new Error('通知幂等键超过160字符,请缩短事件名或消息版本') } - }) - - await client.dingTalkDelivery.createMany({ - data: input.recipients.map((recipient) => ({ - idempotency_key: `${input.eventType}:${input.businessType}:${input.businessId}:${recipient.id}`, + return { + idempotency_key: idempotencyKey, event_type: input.eventType, business_type: input.businessType, business_id: input.businessId, recipient_user_id: recipient.id, - recipient_dingtalk_userid: recipient.person?.source_userid || null, - provider_type: getDingTalkNotificationChannelForUser(recipient.person?.source_userid), + recipient_dingtalk_userid: dingtalkUserId, + provider_type: 'notification_center', payload_json: JSON.stringify({ - title: input.title, - content: input.content, - action_url: input.actionUrl + title: input.dingTalk.title, + content: input.dingTalk.content, + action_title: input.dingTalk.actionTitle, + action_url: input.actionUrl, + message_version: messageVersion }) - })), - skipDuplicates: true + } }) + const notification = stationRecipients.length > 0 + ? await client.notification.create({ + data: { + title: input.title, + content: input.content, + type: input.businessType, + level: input.level || 'info', + action_url: input.actionUrl, + metadata: JSON.stringify({ + event_type: input.eventType, + business_type: input.businessType, + business_id: input.businessId, + ...(input.metadata || {}) + }), + recipients: { + create: stationRecipients.map((recipient) => ({ user_id: recipient.id })) + } + } + }) + : null + + if (deliveries.length > 0) { + await client.dingTalkDelivery.createMany({ + data: deliveries, + skipDuplicates: true + }) + } + return notification } diff --git a/backend/src/services/dingtalkClient.ts b/backend/src/services/dingtalkClient.ts index 356fad9..d3386f3 100644 --- a/backend/src/services/dingtalkClient.ts +++ b/backend/src/services/dingtalkClient.ts @@ -1,273 +1,102 @@ import { buildYouyidianLoginEntryUrl, getNotificationCenterConfigStatus, - getNotificationCenterLoginConfigStatus + getNotificationCenterLoginConfigStatus, + getNotificationCenterLoginRollout } from './youyidianNotificationClient' -type DingTalkResponse = { - errcode?: number - errmsg?: string - [key: string]: any -} - -export class DingTalkApiError extends Error { - code: string - deterministic: boolean - - constructor(message: string, code: string, deterministic = false) { - super(message) - this.name = 'DingTalkApiError' - this.code = code - this.deterministic = deterministic - } -} - -const API_BASE_URL = 'https://oapi.dingtalk.com' -const REQUEST_TIMEOUT_MS = 8_000 - -export type DingTalkNotificationChannel = 'work_notice' | 'notification_center' - -const getNotificationCenterTestUserIds = () => Array.from(new Set( - String(process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS || '') +const parseUserIdList = (value: unknown) => [...new Set( + String(value || '') .split(',') .map((item) => item.trim()) .filter(Boolean) -)) +)] -const isNotificationCenterFullRollout = () => - String(process.env.YYD_NOTIFICATION_CENTER_FULL_ROLLOUT || '').trim().toLowerCase() === 'true' - -export const getDingTalkNotificationChannel = (): DingTalkNotificationChannel => { - const value = String(process.env.DINGTALK_NOTIFICATION_CHANNEL || 'work_notice').trim().toLowerCase() - if (value === 'work_notice' || value === 'notification_center') return value - throw new Error('DINGTALK_NOTIFICATION_CHANNEL 仅支持 work_notice 或 notification_center') -} - -export const getDingTalkNotificationChannelForUser = (dingtalkUserId: string | null | undefined) => { - const channel = getDingTalkNotificationChannel() - if (channel !== 'notification_center') return channel - if (isNotificationCenterFullRollout()) return channel - const testUserIds = getNotificationCenterTestUserIds() - return testUserIds.includes(String(dingtalkUserId || '').trim()) ? 'notification_center' : 'work_notice' -} - -const requiredConfig = () => ({ - appKey: process.env.DINGTALK_APP_KEY || '', - appSecret: process.env.DINGTALK_APP_SECRET || '', - agentId: process.env.DINGTALK_AGENT_ID || '', - corpId: process.env.DINGTALK_CORP_ID || '', - publicBaseUrl: (process.env.DINGTALK_PUBLIC_BASE_URL || '').replace(/\/$/, '') -}) - -export const getDingTalkConfigStatus = () => { - const config = requiredConfig() - const channel = getDingTalkNotificationChannel() - const missing: string[] = [] - if (channel === 'notification_center') { - missing.push(...getNotificationCenterConfigStatus().missing) - if (!config.publicBaseUrl) missing.push('PublicBaseUrl') - } else { - if (!config.appKey) missing.push('AppKey') - if (!config.appSecret) missing.push('AppSecret') - if (!config.agentId) missing.push('AgentId') - if (!config.corpId) missing.push('CorpId') - if (!config.publicBaseUrl) missing.push('PublicBaseUrl') - } - const testUserIds = getNotificationCenterTestUserIds() +export const getDingTalkSendRolloutStatus = () => { + const requested = String(process.env.YYD_NOTIFICATION_SEND_ENABLED || '').trim().toLowerCase() === 'true' + const fullRollout = String(process.env.YYD_NOTIFICATION_SEND_FULL_ROLLOUT || '').trim().toLowerCase() === 'true' + const testUserIds = parseUserIdList(process.env.YYD_NOTIFICATION_SEND_TEST_USER_IDS) return { - configured: missing.length === 0, - channel, - send_enabled: process.env.DINGTALK_SEND_ENABLED === 'true', - rollout_mode: channel !== 'notification_center' - ? 'legacy' - : isNotificationCenterFullRollout() - ? 'full' + requested, + enabled: requested && (fullRollout || testUserIds.length > 0), + rollout_mode: !requested + ? 'disabled' as const + : fullRollout + ? 'full' as const : testUserIds.length > 0 - ? 'test' - : 'disabled', + ? 'test' as const + : 'disabled' as const, test_user_count: testUserIds.length, + testUserIds, + fullRollout + } +} + +const getPublicBaseUrlStatus = () => { + const value = String(process.env.APP_PUBLIC_BASE_URL || '').trim() + if (!value) return 'APP_PUBLIC_BASE_URL' + try { + const url = new URL(value) + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash || + (url.port && url.port !== '443') + ) { + return 'APP_PUBLIC_BASE_URL(必须为不含路径、凭证、查询参数且使用标准443端口的HTTPS地址)' + } + } catch { + return 'APP_PUBLIC_BASE_URL(格式无效)' + } + return null +} + +/** + * 保留原接口名,供设置页读取配置状态;投递通道已经固定为由壹点通知中心。 + * 这里不得读取或返回任何钉钉应用凭证。 + */ +export const getDingTalkConfigStatus = () => { + const missing = [...getNotificationCenterConfigStatus().missing] + const publicBaseUrlError = getPublicBaseUrlStatus() + if (publicBaseUrlError) missing.push(publicBaseUrlError) + const sendRollout = getDingTalkSendRolloutStatus() + const loginStatus = getNotificationCenterLoginConfigStatus() + const loginRollout = getNotificationCenterLoginRollout() + if (sendRollout.requested && !sendRollout.fullRollout && sendRollout.testUserIds.length === 0) { + missing.push('YYD_NOTIFICATION_SEND_TEST_USER_IDS') + } + if (sendRollout.enabled && !loginStatus.configured) { + missing.push('YYD_NOTIFICATION_LOGIN_ENABLED(发送通知前必须同步启用免登)') + } else if (sendRollout.enabled && sendRollout.fullRollout && !loginRollout.fullRollout) { + missing.push('YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT(全量发送前必须全量启用免登)') + } else if (sendRollout.enabled && !sendRollout.fullRollout && !loginRollout.fullRollout) { + const uncovered = sendRollout.testUserIds.filter((userId) => !loginRollout.testUserIds.includes(userId)) + if (uncovered.length > 0) { + missing.push('YYD_NOTIFICATION_LOGIN_TEST_USER_IDS(必须覆盖发送灰度名单)') + } + } + const configured = missing.length === 0 + return { + configured, + channel: 'notification_center' as const, + send_enabled: configured && sendRollout.enabled, + rollout_mode: sendRollout.rollout_mode, + test_user_count: sendRollout.test_user_count, missing } } -export const getDingTalkPublicConfig = (redirectPath: string = '/') => { - const config = requiredConfig() - const notificationCenterLoginConfig = getNotificationCenterLoginConfigStatus() - if ( - notificationCenterLoginConfig.configured && - notificationCenterLoginConfig.rollout_mode === 'full' - ) { - return { - configured: true, - mode: 'notification_center' as const, - loginEntryUrl: buildYouyidianLoginEntryUrl(redirectPath), - rolloutMode: notificationCenterLoginConfig.rollout_mode - } - } +export const getDingTalkPublicConfig = (redirectPath: string = '/dashboard') => { + const loginConfig = getNotificationCenterLoginConfigStatus() return { - configured: Boolean( - config.appKey && config.appSecret && config.agentId && config.corpId && config.publicBaseUrl - ), - mode: 'work_notice' as const, - corpId: config.corpId, - // requestAuthCode JSAPI 要求传入企业内部应用 AppKey;AppKey 是客户端标识,不包含 AppSecret。 - clientId: config.appKey + configured: loginConfig.configured, + mode: 'notification_center' as const, + loginEntryUrl: loginConfig.configured + ? buildYouyidianLoginEntryUrl(redirectPath) + : null, + rolloutMode: loginConfig.rollout_mode } } - -const isDeterministicError = (code: number | string, message: string) => { - const deterministicCodes = new Set([ - '40014', '40056', '40057', '40078', '41001', '41009', '60111', '60121', '70020' - ]) - const normalized = String(code) - return deterministicCodes.has(normalized) || - /不存在|不在可见范围|无权限|invalid user|forbidden user|userid/i.test(message) -} - -const requestJson = async ( - url: string, - init?: RequestInit -): Promise => { - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) - try { - const response = await fetch(url, { - ...init, - signal: controller.signal, - headers: { - 'Content-Type': 'application/json', - ...(init?.headers || {}) - } - }) - const payload = await response.json() as T - if (!response.ok) { - throw new DingTalkApiError( - `钉钉接口返回 HTTP ${response.status}`, - `HTTP_${response.status}`, - response.status >= 400 && response.status < 500 && response.status !== 429 - ) - } - if (payload.errcode !== undefined && payload.errcode !== 0) { - throw new DingTalkApiError( - payload.errmsg || '钉钉接口调用失败', - String(payload.errcode), - isDeterministicError(payload.errcode, payload.errmsg || '') - ) - } - return payload - } catch (error: any) { - if (error instanceof DingTalkApiError) throw error - if (error?.name === 'AbortError') { - throw new DingTalkApiError('钉钉请求超时', 'TIMEOUT', false) - } - throw new DingTalkApiError(error?.message || '钉钉网络请求失败', 'NETWORK', false) - } finally { - clearTimeout(timeout) - } -} - -let cachedToken: { value: string; expiresAt: number } | null = null -let tokenPromise: Promise | null = null - -export const getDingTalkAccessToken = async () => { - if (cachedToken && cachedToken.expiresAt > Date.now() + 60_000) { - return cachedToken.value - } - if (tokenPromise) return tokenPromise - - tokenPromise = (async () => { - const { appKey, appSecret } = requiredConfig() - if (!appKey || !appSecret) { - throw new DingTalkApiError('钉钉应用配置不完整', 'CONFIG_INCOMPLETE', true) - } - const payload = await requestJson( - `${API_BASE_URL}/gettoken?appkey=${encodeURIComponent(appKey)}&appsecret=${encodeURIComponent(appSecret)}` - ) - if (!payload.access_token) { - throw new DingTalkApiError('钉钉未返回 access_token', 'TOKEN_MISSING', false) - } - const expiresIn = Math.max(300, Number(payload.expires_in) || 7200) - cachedToken = { - value: String(payload.access_token), - expiresAt: Date.now() + expiresIn * 1000 - } - return cachedToken.value - })() - - try { - return await tokenPromise - } finally { - tokenPromise = null - } -} - -export const exchangeDingTalkLoginCode = async (code: string) => { - const token = await getDingTalkAccessToken() - const payload = await requestJson( - `${API_BASE_URL}/topapi/v2/user/getuserinfo?access_token=${encodeURIComponent(token)}`, - { method: 'POST', body: JSON.stringify({ code }) } - ) - const userId = payload.result?.userid - if (!userId) { - throw new DingTalkApiError('未能识别钉钉用户', 'USERID_MISSING', true) - } - return String(userId) -} - -const buildCardUrl = (actionUrl: string) => { - const { corpId, agentId, publicBaseUrl } = requiredConfig() - const entryUrl = `${publicBaseUrl}/dingtalk-entry?redirect=${encodeURIComponent(actionUrl)}` - return `dingtalk://dingtalkclient/action/openapp?corpid=${encodeURIComponent(corpId)}&container_type=work_platform&app_id=0_${encodeURIComponent(agentId)}&redirect_type=jump&redirect_url=${encodeURIComponent(entryUrl)}` -} - -export const sendDingTalkWorkNotification = async (input: { - userId: string - title: string - content: string - actionUrl: string -}) => { - const config = requiredConfig() - if (!config.agentId) { - throw new DingTalkApiError('钉钉 AgentId 未配置', 'CONFIG_INCOMPLETE', true) - } - const token = await getDingTalkAccessToken() - const payload = await requestJson( - `${API_BASE_URL}/topapi/message/corpconversation/asyncsend_v2?access_token=${encodeURIComponent(token)}`, - { - method: 'POST', - body: JSON.stringify({ - agent_id: Number(config.agentId), - userid_list: input.userId, - msg: { - msgtype: 'action_card', - action_card: { - title: input.title, - markdown: `### ${input.title}\n\n${input.content}`, - single_title: '查看详情', - single_url: buildCardUrl(input.actionUrl) - } - } - }) - } - ) - if (payload.task_id === undefined || payload.task_id === null) { - throw new DingTalkApiError('钉钉未返回任务编号', 'TASK_ID_MISSING', false) - } - return String(payload.task_id) -} - -export const getDingTalkSendResult = async (taskId: string) => { - const config = requiredConfig() - const token = await getDingTalkAccessToken() - const payload = await requestJson( - `${API_BASE_URL}/topapi/message/corpconversation/getsendresult?access_token=${encodeURIComponent(token)}`, - { - method: 'POST', - body: JSON.stringify({ - agent_id: Number(config.agentId), - task_id: taskId - }) - } - ) - return payload.send_result || payload.result || {} -} diff --git a/backend/src/services/dingtalkDeliveryService.ts b/backend/src/services/dingtalkDeliveryService.ts index 2a91b9f..be61293 100644 --- a/backend/src/services/dingtalkDeliveryService.ts +++ b/backend/src/services/dingtalkDeliveryService.ts @@ -2,11 +2,7 @@ import os from 'os' import type { Prisma } from '@prisma/client' import prisma from '../utils/prisma' import { - DingTalkApiError, - getDingTalkSendResult, - sendDingTalkWorkNotification -} from './dingtalkClient' -import { + getNotificationCenterConfigStatus, getYouyidianNotificationStatus, NotificationCenterError, submitYouyidianNotification, @@ -15,20 +11,33 @@ import { } from './youyidianNotificationClient' import { createBusinessNotification, - findActiveUsersByRoles + escapeNotificationMarkdownInline, + findActiveDingTalkUsersByRoles, + findActiveStationUsersByRoles, + formatBeijingDateTime, + getCurrentDingTalkRecipientRollout, + type DingTalkRecipientRollout } from './businessNotificationService' +import { getDingTalkConfigStatus, getDingTalkSendRolloutStatus } from './dingtalkClient' const workerId = `${os.hostname()}:${process.pid}:${Math.random().toString(36).slice(2, 10)}` const LEASE_MS = 2 * 60 * 1000 const BACKOFF_MINUTES = [1, 5, 15, 30] +const REFRESHABLE_STATUSES = ['pending', 'sending', 'retrying', 'sent', 'unread'] -export const resolveNotificationCenterLocalStatus = (status: NotificationCenterStatus) => { - if (status === 'pending' || status === 'processing' || status === 'sending' || status === 'retrying') { - return 'sending' +class DeliveryPayloadError extends Error { + deterministic = true + code: string + + constructor(message: string, code: string) { + super(message) + this.name = 'DeliveryPayloadError' + this.code = code } - return status } +export const resolveNotificationCenterLocalStatus = (status: NotificationCenterStatus) => status + const parseExternalDate = (value: string | null | undefined) => { if (!value) return null const parsed = new Date(value) @@ -45,13 +54,12 @@ const buildNotificationCenterUpdate = ( ? fallbackSentAt || new Date() : fallbackSentAt ) + const terminal = status === 'read' || status === 'failed' return { status, sent_at: sentAt, read_at: status === 'read' ? parseExternalDate(result.readAt) || new Date() : null, - next_attempt_at: status === 'sending' || status === 'sent' || status === 'unread' - ? new Date(Date.now() + 5 * 60_000) - : new Date(), + next_attempt_at: terminal ? new Date() : new Date(Date.now() + 5 * 60_000), last_error_code: status === 'failed' ? result.lastErrorCode || 'NOTIFICATION_CENTER_FAILED' : null, @@ -76,20 +84,9 @@ export const updateSendingDeliveryIfLeaseOwned = async ( return updated.count === 1 } -const claimNextDelivery = async () => { +const claimNextDelivery = async (allowedDingTalkUserIds: string[] | null) => { const now = new Date() - const claimable = { - OR: [ - { - status: { in: ['pending', 'retrying'] }, - next_attempt_at: { lte: now } - }, - { - status: 'sending', - lease_expires_at: { lte: now } - } - ] - } + const claimable = buildClaimableDeliveryWhere(now, allowedDingTalkUserIds) const candidate = await prisma.dingTalkDelivery.findFirst({ where: claimable, orderBy: [{ next_attempt_at: 'asc' }, { id: 'asc' }] @@ -109,14 +106,50 @@ const claimNextDelivery = async () => { return prisma.dingTalkDelivery.findUnique({ where: { id: candidate.id } }) } +export const buildClaimableDeliveryWhere = ( + now: Date, + allowedDingTalkUserIds: string[] | null = null +): Prisma.DingTalkDeliveryWhereInput => ({ + provider_type: 'notification_center', + dingtalk_task_id: null, + ...(allowedDingTalkUserIds + ? { recipient_dingtalk_userid: { in: allowedDingTalkUserIds } } + : {}), + OR: [ + { + status: { in: ['pending', 'retrying'] }, + next_attempt_at: { lte: now } + }, + { + status: 'sending', + lease_expires_at: { lte: now } + } + ] +}) + +const errorDetails = (error: unknown) => { + if (error instanceof NotificationCenterError || error instanceof DeliveryPayloadError) { + return { + code: error.code, + message: error.message, + deterministic: error.deterministic + } + } + const value = error as any + return { + code: 'UNKNOWN', + message: String(value?.message || '通知中心投递失败'), + deterministic: false + } +} + const scheduleDeliveryFailure = async ( delivery: NonNullable>>, - error: any + error: unknown ) => { - const deterministic = (error instanceof DingTalkApiError && error.deterministic) || - (error instanceof NotificationCenterError && error.deterministic) + const details = errorDetails(error) const exhausted = delivery.attempt_count >= delivery.max_attempts - const failed = deterministic || exhausted + const failed = details.deterministic || exhausted const delayMinutes = BACKOFF_MINUTES[Math.min(delivery.attempt_count - 1, BACKOFF_MINUTES.length - 1)] await updateSendingDeliveryIfLeaseOwned(delivery.id, { @@ -126,155 +159,228 @@ const scheduleDeliveryFailure = async ( : new Date(Date.now() + delayMinutes * 60 * 1000), lease_owner: null, lease_expires_at: null, - last_error_code: error instanceof DingTalkApiError - ? error.code - : error instanceof NotificationCenterError - ? error.code - : 'UNKNOWN', - last_error_message: String(error?.message || '钉钉投递失败').slice(0, 2000) + last_error_code: details.code, + last_error_message: details.message.slice(0, 2000) }) } +const parsePayload = (payloadJson: string) => { + let payload: Record + try { + payload = JSON.parse(payloadJson) as Record + } catch { + throw new DeliveryPayloadError('通知任务正文不是有效JSON', 'PAYLOAD_INVALID') + } + const title = String(payload.title || '').trim() + const content = String(payload.content || '').trim() + const actionTitle = String(payload.action_title || '').trim() + const actionUrl = String(payload.action_url || '').trim() + if (!title || !content || !actionUrl) { + throw new DeliveryPayloadError('通知任务缺少标题、正文或跳转地址', 'PAYLOAD_INVALID') + } + return { + title, + content, + actionTitle, + actionUrl, + idempotencyKey: String(payload.notification_center_idempotency_key || '').trim() + } +} + +const LEGACY_ACTION_TITLES: Record = { + disposal_applied: '立即审批', + disposal_overdue_reminder: '立即审批', + disposal_approved: '查看审批结果', + disposal_rejected: '查看申请', + disposal_completed: '查看结果' +} + +export const resolveDeliveryActionTitle = (eventType: string, actionTitle: string) => + actionTitle.trim() || LEGACY_ACTION_TITLES[eventType] || '查看详情' + +const STATUS_RANK: Record, number> = { + pending: 0, + sending: 1, + retrying: 1, + sent: 2, + unread: 3, + read: 4 +} + +export const resolveMonotonicNotificationCenterStatus = ( + currentStatus: string, + incomingStatus: NotificationCenterStatus +): NotificationCenterStatus => { + if (incomingStatus === 'failed' || incomingStatus === 'read') return incomingStatus + if (!(currentStatus in STATUS_RANK)) return incomingStatus + const current = currentStatus as Exclude + return STATUS_RANK[incomingStatus] < STATUS_RANK[current] ? current : incomingStatus +} + +const updatePayloadMetadata = ( + payloadJson: string, + metadata: Record +) => { + try { + const payload = JSON.parse(payloadJson) as Record + return JSON.stringify({ ...payload, ...metadata }) + } catch { + return payloadJson + } +} + +type RecipientLookupClient = Pick + +const APPROVAL_EVENT_TYPES = new Set(['disposal_applied', 'disposal_overdue_reminder']) + +export const isDeliveryRecipientStillEligible = async ( + input: { recipientUserId: number; recipientDingTalkUserId: string; eventType?: string }, + client: RecipientLookupClient = prisma +) => Boolean(await client.user.findFirst({ + where: { + id: input.recipientUserId, + status: 'active', + ...(input.eventType && APPROVAL_EVENT_TYPES.has(input.eventType) + ? { role: { in: ['administrative', 'admin'] } } + : {}), + person: { + is: { + status: 'active', + source_userid: input.recipientDingTalkUserId + } + } + }, + select: { id: true } +})) + const processDelivery = async ( delivery: NonNullable>> ) => { try { - if (!delivery.recipient_dingtalk_userid) { - throw new DingTalkApiError('收件人未绑定有效的钉钉员工档案', 'RECIPIENT_UNBOUND', true) + if (!delivery.recipient_dingtalk_userid?.trim()) { + throw new DeliveryPayloadError('收件人未绑定有效的在职员工档案', 'RECIPIENT_UNBOUND') } - const payload = JSON.parse(delivery.payload_json) as { - title?: string - content?: string - action_url?: string - notification_center_idempotency_key?: string + if (!await isDeliveryRecipientStillEligible({ + recipientUserId: delivery.recipient_user_id, + recipientDingTalkUserId: delivery.recipient_dingtalk_userid.trim(), + eventType: delivery.event_type + })) { + throw new DeliveryPayloadError('收件人账号、在职状态或钉钉绑定已变化', 'RECIPIENT_BINDING_CHANGED') } - if (delivery.provider_type === 'notification_center') { - const result = await submitYouyidianNotification({ - idempotencyKey: payload.notification_center_idempotency_key || delivery.idempotency_key, - recipientUserId: delivery.recipient_dingtalk_userid, - title: payload.title || '固定资产通知', - content: payload.content || '', - actionTitle: '查看详情', - actionUrl: payload.action_url || '/' - }) - await updateSendingDeliveryIfLeaseOwned(delivery.id, { - ...buildNotificationCenterUpdate(result, null), - dingtalk_task_id: result.id, - lease_owner: null, - lease_expires_at: null - }) - return - } - if (delivery.provider_type !== 'work_notice') { - throw new DingTalkApiError( - `不支持的钉钉投递通道: ${delivery.provider_type}`, - 'PROVIDER_UNSUPPORTED', - true - ) - } - const taskId = await sendDingTalkWorkNotification({ - userId: delivery.recipient_dingtalk_userid, - title: payload.title || '固定资产通知', - content: payload.content || '', - actionUrl: payload.action_url || '/' + const payload = parsePayload(delivery.payload_json) + const result = await submitYouyidianNotification({ + idempotencyKey: payload.idempotencyKey || delivery.idempotency_key, + recipientUserId: delivery.recipient_dingtalk_userid, + title: payload.title, + content: payload.content, + actionTitle: resolveDeliveryActionTitle(delivery.event_type, payload.actionTitle), + actionUrl: payload.actionUrl }) await updateSendingDeliveryIfLeaseOwned(delivery.id, { - status: 'sent', - dingtalk_task_id: taskId, - sent_at: new Date(), - next_attempt_at: new Date(Date.now() + 60_000), + ...buildNotificationCenterUpdate(result, null), + dingtalk_task_id: result.id, + payload_json: updatePayloadMetadata(delivery.payload_json, { + notification_center_last_status: result.status + }), lease_owner: null, - lease_expires_at: null, - last_error_code: null, - last_error_message: null + lease_expires_at: null }) - } catch (error: any) { + } catch (error: unknown) { await scheduleDeliveryFailure(delivery, error) } } -const refreshOneDeliveryResult = async () => { - const delivery = await prisma.dingTalkDelivery.findFirst({ - where: { - status: { in: ['sending', 'sent', 'unread'] }, - dingtalk_task_id: { not: null }, - next_attempt_at: { lte: new Date() } - }, +export const buildRefreshableDeliveryWhere = ( + now: Date +): Prisma.DingTalkDeliveryWhereInput => ({ + provider_type: 'notification_center', + status: { in: REFRESHABLE_STATUSES }, + dingtalk_task_id: { not: null }, + next_attempt_at: { lte: now }, + OR: [ + { lease_owner: null }, + { lease_expires_at: null }, + { lease_expires_at: { lte: now } } + ] +}) + +const claimNextDeliveryForRefresh = async () => { + const now = new Date() + const claimable = buildRefreshableDeliveryWhere(now) + const candidate = await prisma.dingTalkDelivery.findFirst({ + where: claimable, orderBy: { updated_at: 'asc' } }) - if (!delivery?.dingtalk_task_id || !delivery.recipient_dingtalk_userid) return + if (!candidate?.dingtalk_task_id) return null + const claimed = await prisma.dingTalkDelivery.updateMany({ + where: { id: candidate.id, ...claimable }, + data: { + lease_owner: workerId, + lease_expires_at: new Date(Date.now() + LEASE_MS) + } + }) + if (claimed.count !== 1) return null + return prisma.dingTalkDelivery.findUnique({ where: { id: candidate.id } }) +} + +const updateRefreshDeliveryIfLeaseOwned = async ( + deliveryId: number, + data: Prisma.DingTalkDeliveryUpdateManyMutationInput +) => prisma.dingTalkDelivery.updateMany({ + where: { + id: deliveryId, + lease_owner: workerId, + status: { in: REFRESHABLE_STATUSES }, + dingtalk_task_id: { not: null } + }, + data +}) + +export const buildRefreshErrorUpdate = (error: unknown) => { + const details = errorDetails(error) + return { + status: details.deterministic ? 'failed' : undefined, + next_attempt_at: details.deterministic ? new Date() : new Date(Date.now() + 5 * 60_000), + last_error_code: details.code === 'UNKNOWN' ? 'RESULT_QUERY_FAILED' : details.code, + last_error_message: details.message.slice(0, 2000), + lease_owner: null, + lease_expires_at: null + } +} + +const refreshOneDeliveryResult = async () => { + const delivery = await claimNextDeliveryForRefresh() + if (!delivery?.dingtalk_task_id) return try { - if (delivery.provider_type === 'notification_center') { - const result = await getYouyidianNotificationStatus(delivery.dingtalk_task_id) - await prisma.dingTalkDelivery.update({ - where: { id: delivery.id }, - data: buildNotificationCenterUpdate(result, delivery.sent_at) - }) - return - } - - if (delivery.provider_type !== 'work_notice') { - throw new DingTalkApiError( - `不支持的钉钉投递通道: ${delivery.provider_type}`, - 'PROVIDER_UNSUPPORTED', - true - ) - } - const result = await getDingTalkSendResult(delivery.dingtalk_task_id) - const includesUser = (value: unknown) => - Array.isArray(value) && value.map(String).includes(delivery.recipient_dingtalk_userid as string) - - if (includesUser(result.read_user_id_list)) { - await prisma.dingTalkDelivery.update({ - where: { id: delivery.id }, - data: { status: 'read', read_at: new Date() } - }) - return - } - if ( - result.delivery_failed === true || - includesUser(result.invalid_user_id_list) || includesUser(result.forbidden_user_id_list) || - includesUser(result.failed_user_id_list) - ) { - await prisma.dingTalkDelivery.update({ - where: { id: delivery.id }, - data: { - status: 'failed', - last_error_code: 'SEND_RESULT_FAILED', - last_error_message: '钉钉返回收件人无效、应用不可见或发送失败' - } - }) - return - } - if (includesUser(result.unread_user_id_list)) { - await prisma.dingTalkDelivery.update({ - where: { id: delivery.id }, - data: { status: 'unread', next_attempt_at: new Date(Date.now() + 5 * 60_000) } - }) - return - } - await prisma.dingTalkDelivery.update({ - where: { id: delivery.id }, - data: { next_attempt_at: new Date(Date.now() + 60_000) } + const result = await getYouyidianNotificationStatus(delivery.dingtalk_task_id) + const monotonicStatus = resolveMonotonicNotificationCenterStatus(delivery.status, result.status) + const monotonicResult = monotonicStatus === result.status + ? result + : { ...result, status: monotonicStatus } + await updateRefreshDeliveryIfLeaseOwned(delivery.id, { + ...buildNotificationCenterUpdate(monotonicResult, delivery.sent_at), + payload_json: updatePayloadMetadata(delivery.payload_json, { + notification_center_last_status: monotonicStatus, + notification_center_status_query_error: null + }), + lease_owner: null, + lease_expires_at: null }) - } catch (error: any) { - await prisma.dingTalkDelivery.update({ - where: { id: delivery.id }, - data: { - last_error_code: error instanceof DingTalkApiError - ? error.code - : error instanceof NotificationCenterError - ? error.code - : 'RESULT_QUERY_FAILED', - last_error_message: String(error?.message || '查询钉钉发送结果失败').slice(0, 2000), - next_attempt_at: new Date(Date.now() + 5 * 60_000) - } + } catch (error: unknown) { + const update = buildRefreshErrorUpdate(error) + await updateRefreshDeliveryIfLeaseOwned(delivery.id, { + ...update, + payload_json: updatePayloadMetadata(delivery.payload_json, { + notification_center_status_query_error: update.last_error_code + }) }) } } -export const createOverdueDisposalReminders = async () => { +export const createOverdueDisposalReminders = async ( + rollout: DingTalkRecipientRollout = getCurrentDingTalkRecipientRollout() +) => { const overdueAt = new Date(Date.now() - 24 * 60 * 60 * 1000) const candidates = await prisma.disposalRecord.findMany({ where: { @@ -282,19 +388,25 @@ export const createOverdueDisposalReminders = async () => { applied_at: { lte: overdueAt }, reminded_at: null }, + include: { + equipment: { select: { equipment_name: true } }, + applier: { select: { name: true, username: true } } + }, orderBy: { applied_at: 'asc' }, take: 50 }) for (const candidate of candidates) { await prisma.$transaction(async (tx) => { - let recipients = await findActiveUsersByRoles(tx, ['administrative']) - if (recipients.length === 0) { - recipients = await findActiveUsersByRoles(tx, ['admin']) + let stationRecipients = await findActiveStationUsersByRoles(tx, ['administrative']) + if (stationRecipients.length === 0) { + stationRecipients = await findActiveStationUsersByRoles(tx, ['admin']) } - if (recipients.length === 0) { - return + let dingTalkRecipients = await findActiveDingTalkUsersByRoles(tx, ['administrative'], rollout) + if (dingTalkRecipients.length === 0) { + dingTalkRecipients = await findActiveDingTalkUsersByRoles(tx, ['admin'], rollout) } + if (stationRecipients.length === 0 && dingTalkRecipients.length === 0) return const claimed = await tx.disposalRecord.updateMany({ where: { @@ -315,6 +427,8 @@ export const createOverdueDisposalReminders = async () => { detail: '提交满24小时仍未审批,系统自动催办一次' } }) + const assetName = `${escapeNotificationMarkdownInline(candidate.equipment?.equipment_name, '资产')}(${escapeNotificationMarkdownInline(candidate.equipment_code, '无编码')})` + const applierName = escapeNotificationMarkdownInline(candidate.applier?.name || candidate.applier?.username) await createBusinessNotification(tx, { eventType: 'disposal_overdue_reminder', businessType: 'disposal', @@ -323,7 +437,23 @@ export const createOverdueDisposalReminders = async () => { content: '该报废申请已等待审批超过24小时,请尽快处理', level: 'warning', actionUrl: `/workflows/disposals/${candidate.id}`, - recipients + stationRecipients, + dingTalkRecipients, + dingTalk: { + title: '资产报废待审批', + content: [ + '### 待你审批', + '', + `**资产:** ${assetName}`, + `**申请人:** ${applierName}`, + `**申请时间:** ${formatBeijingDateTime(candidate.applied_at)}`, + `**等待时长:** 已超过24小时`, + '', + '> 请核对资产及配件明细后尽快处理。' + ].join('\n'), + actionTitle: '立即审批', + messageVersion: 'reminder-v1' + } }) }) } @@ -334,14 +464,21 @@ let reminderBusy = false export const startDingTalkDeliveryWorker = () => { const processTick = async () => { - if (deliveryBusy || process.env.DINGTALK_SEND_ENABLED !== 'true') return + if (deliveryBusy) return deliveryBusy = true try { - const delivery = await claimNextDelivery() - if (delivery) await processDelivery(delivery) + if (!getNotificationCenterConfigStatus().configured) return + const configStatus = getDingTalkConfigStatus() + const sendRollout = getDingTalkSendRolloutStatus() + if (configStatus.send_enabled && sendRollout.enabled) { + const delivery = await claimNextDelivery( + sendRollout.fullRollout ? null : sendRollout.testUserIds + ) + if (delivery) await processDelivery(delivery) + } await refreshOneDeliveryResult() } catch (error) { - console.error('[DingTalkWorker] 投递循环失败:', error) + console.error('[NotificationCenterWorker] 投递循环失败:', error) } finally { deliveryBusy = false } @@ -353,7 +490,7 @@ export const startDingTalkDeliveryWorker = () => { try { await createOverdueDisposalReminders() } catch (error) { - console.error('[DingTalkWorker] 报废催办生成失败:', error) + console.error('[NotificationCenterWorker] 报废催办生成失败:', error) } finally { reminderBusy = false } diff --git a/backend/src/services/youyidianNotificationClient.ts b/backend/src/services/youyidianNotificationClient.ts index b36b097..ef68af8 100644 --- a/backend/src/services/youyidianNotificationClient.ts +++ b/backend/src/services/youyidianNotificationClient.ts @@ -2,7 +2,7 @@ import crypto from 'crypto' import { normalizeInternalRedirect } from '../utils/dingtalkRedirect' export type NotificationCenterStatus = - 'pending' | 'processing' | 'sending' | 'retrying' | 'sent' | 'unread' | 'read' | 'failed' + 'pending' | 'sending' | 'retrying' | 'sent' | 'unread' | 'read' | 'failed' export type NotificationCenterDelivery = { id: string @@ -21,7 +21,8 @@ export class NotificationCenterError extends Error { constructor( message: string, public readonly code: string, - public readonly deterministic = false + public readonly deterministic = false, + public readonly httpStatus?: number ) { super(message) this.name = 'NotificationCenterError' @@ -29,20 +30,11 @@ export class NotificationCenterError extends Error { } const config = () => ({ - baseUrl: String(process.env.YYD_NOTIFICATION_CENTER_URL || '').trim().replace(/\/$/, ''), - projectCode: String(process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE || '').trim(), - secret: String(process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET || '').trim() + baseUrl: String(process.env.YYD_NOTIFICATION_CENTER_URL || '').trim().replace(/\/+$/, ''), + projectCode: String(process.env.YYD_NOTIFICATION_PROJECT_CODE || '').trim(), + secret: String(process.env.YYD_NOTIFICATION_PROJECT_SECRET || '').trim() }) -const envFlag = (name: string) => String(process.env[name] || '').trim().toLowerCase() === 'true' - -const notificationCenterLoginTestUserIds = () => Array.from(new Set( - String(process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS || '') - .split(',') - .map((item) => item.trim()) - .filter(Boolean) -)) - export const getNotificationCenterConfigStatus = () => { const value = config() const missing: string[] = [] @@ -50,47 +42,69 @@ export const getNotificationCenterConfigStatus = () => { if (value.baseUrl) { try { const url = new URL(value.baseUrl) - if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) { + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.search || + url.hash || + (url.port && url.port !== '443') + ) { missing.push('YYD_NOTIFICATION_CENTER_URL(必须为不含凭证和查询参数的HTTPS地址)') } } catch { missing.push('YYD_NOTIFICATION_CENTER_URL(格式无效)') } } - if (!value.projectCode) missing.push('YYD_NOTIFICATION_CENTER_PROJECT_CODE') + if (!value.projectCode) missing.push('YYD_NOTIFICATION_PROJECT_CODE') if (value.projectCode && value.projectCode !== 'fixed_asset') { - missing.push('YYD_NOTIFICATION_CENTER_PROJECT_CODE(固定资产必须为fixed_asset)') + missing.push('YYD_NOTIFICATION_PROJECT_CODE(固定资产必须为fixed_asset)') } - if (!value.secret) missing.push('YYD_NOTIFICATION_CENTER_PROJECT_SECRET') + if (!value.secret) missing.push('YYD_NOTIFICATION_PROJECT_SECRET') if (value.secret && value.secret.length < 24) { - missing.push('YYD_NOTIFICATION_CENTER_PROJECT_SECRET(长度不足)') + missing.push('YYD_NOTIFICATION_PROJECT_SECRET(长度不足)') } return { configured: missing.length === 0, missing } } +export const getNotificationCenterLoginRollout = () => { + const enabled = String(process.env.YYD_NOTIFICATION_LOGIN_ENABLED || '').trim().toLowerCase() === 'true' + const fullRollout = String(process.env.YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT || '').trim().toLowerCase() === 'true' + const testUserIds = [...new Set( + String(process.env.YYD_NOTIFICATION_LOGIN_TEST_USER_IDS || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + )] + return { enabled, fullRollout, testUserIds } +} + export const getNotificationCenterLoginConfigStatus = () => { - const enabled = envFlag('YYD_NOTIFICATION_CENTER_LOGIN_ENABLED') - const fullRollout = envFlag('YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT') - const testUserIds = notificationCenterLoginTestUserIds() const missing = [...getNotificationCenterConfigStatus().missing] - if (!enabled) missing.push('YYD_NOTIFICATION_CENTER_LOGIN_ENABLED') - if (enabled && !fullRollout && testUserIds.length === 0) { - missing.push('YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS') + const rollout = getNotificationCenterLoginRollout() + if (!rollout.enabled) missing.push('YYD_NOTIFICATION_LOGIN_ENABLED') + if (rollout.enabled && !rollout.fullRollout && rollout.testUserIds.length === 0) { + missing.push('YYD_NOTIFICATION_LOGIN_TEST_USER_IDS') } return { configured: missing.length === 0, - enabled, - rollout_mode: fullRollout ? 'full' as const : testUserIds.length > 0 ? 'test' as const : 'disabled' as const, - test_user_count: testUserIds.length, + enabled: rollout.enabled, + rollout_mode: !rollout.enabled + ? 'disabled' as const + : rollout.fullRollout + ? 'full' as const + : 'test' as const, + test_user_count: rollout.testUserIds.length, missing } } export const isNotificationCenterLoginAllowedForUser = (dingtalkUserId: string) => { const status = getNotificationCenterLoginConfigStatus() - if (!status.configured) return false - if (status.rollout_mode === 'full') return true - return notificationCenterLoginTestUserIds().includes(String(dingtalkUserId || '').trim()) + const normalizedUserId = String(dingtalkUserId || '').trim() + if (!status.configured || !normalizedUserId) return false + const rollout = getNotificationCenterLoginRollout() + return rollout.fullRollout || rollout.testUserIds.includes(normalizedUserId) } const sha256 = (value: string) => crypto.createHash('sha256').update(value).digest('hex') @@ -169,17 +183,20 @@ const request = async (path: string, method: 'GET' | 'POST', payload?: Record ({})) as Record if (!response.ok) { const code = String(data.error || `HTTP_${response.status}`) + const deterministic = response.status === 401 || response.status === 404 || response.status === 422 || + (response.status === 409 && code !== 'NONCE_REPLAYED') throw new NotificationCenterError( `由壹点通知中心请求失败: ${code}`, code, - response.status >= 400 && response.status < 500 && response.status !== 429 + deterministic, + response.status ) } return data } const validStatuses = new Set([ - 'pending', 'processing', 'sending', 'retrying', 'sent', 'unread', 'read', 'failed' + 'pending', 'sending', 'retrying', 'sent', 'unread', 'read', 'failed' ]) const validateDelivery = (data: Record) => { @@ -197,7 +214,7 @@ const validateDelivery = (data: Record) => { } const buildActionUrl = (actionUrl: string) => { - const publicBaseUrl = String(process.env.DINGTALK_PUBLIC_BASE_URL || '').trim().replace(/\/$/, '') + const publicBaseUrl = String(process.env.APP_PUBLIC_BASE_URL || '').trim().replace(/\/$/, '') const safePath = normalizeInternalRedirect(actionUrl) let baseUrl: URL try { @@ -209,10 +226,12 @@ const buildActionUrl = (actionUrl: string) => { baseUrl.protocol !== 'https:' || baseUrl.username || baseUrl.password || + baseUrl.pathname !== '/' || baseUrl.search || - baseUrl.hash + baseUrl.hash || + (baseUrl.port && baseUrl.port !== '443') ) { - throw new NotificationCenterError('固定资产公开地址必须为不含凭证和查询参数的HTTPS地址', 'PUBLIC_BASE_URL_INVALID', true) + throw new NotificationCenterError('固定资产公开地址必须为不含路径、凭证和查询参数的HTTPS地址', 'PUBLIC_BASE_URL_INVALID', true) } return new URL(safePath, `${baseUrl.toString().replace(/\/$/, '')}/`).toString() } @@ -249,7 +268,13 @@ export const redeemYouyidianLoginTicket = async (ticket: string): Promise + try { + data = await request('/v1/auth/tickets/redeem', 'POST', { ticket }) + } catch (error) { + if (!(error instanceof NotificationCenterError) || error.code !== 'NONCE_REPLAYED') throw error + data = await request('/v1/auth/tickets/redeem', 'POST', { ticket }) + } const dingtalkUserId = String(data.dingtalkUserId || '').trim() if (!dingtalkUserId) { throw new NotificationCenterError('由壹点通知中心返回了无效的登录身份', 'INVALID_RESPONSE', true) @@ -286,16 +311,110 @@ export const getYouyidianNotificationStatus = async (id: string) => { export const prepareNotificationCenterManualRetryPayload = ( payloadJson: string, - localIdempotencyKey: string + identity: { + eventType: string + businessId: number + recipientUserId: string + sourceDeliveryId: number + operatorUserId: number + createdAt?: Date + } ) => { const payload = JSON.parse(payloadJson) as Record const previousSequence = Number(payload.notification_center_manual_retry_sequence) const sequence = Number.isSafeInteger(previousSequence) && previousSequence >= 0 ? previousSequence + 1 : 1 + const messageVersion = String(payload.message_version || 'legacy') + .trim() + .replace(/[^A-Za-z0-9._-]/g, '_') + .slice(0, 30) || 'legacy' + let idempotencyKey = [ + String(identity.eventType || '').trim(), + String(identity.businessId), + String(identity.recipientUserId || '').trim(), + messageVersion, + `manual-v${sequence}` + ].join(':') + if (idempotencyKey.length > 160) { + const identityHash = sha256([ + identity.eventType, + identity.businessId, + identity.recipientUserId + ].join(':')).slice(0, 32) + idempotencyKey = `manual:${identity.sourceDeliveryId}:${messageVersion}:${identityHash}:v${sequence}` + } return JSON.stringify({ ...payload, notification_center_manual_retry_sequence: sequence, - notification_center_idempotency_key: `manual-retry:${sha256(localIdempotencyKey)}:${sequence}` + notification_center_idempotency_key: idempotencyKey, + notification_center_manual_retry_source_delivery_id: identity.sourceDeliveryId, + notification_center_manual_retry_by: identity.operatorUserId, + notification_center_manual_retry_at: (identity.createdAt || new Date()).toISOString() + }) +} + +export const prepareNotificationCenterResumePayload = ( + payloadJson: string, + failure: { + code?: string | null + message?: string | null + attemptCount: number + operatorUserId: number + resumedAt?: Date + } +) => { + const payload = JSON.parse(payloadJson) as Record + const previousHistory = Array.isArray(payload.notification_center_resume_history) + ? payload.notification_center_resume_history.slice(-19) + : [] + const resumeCount = Number(payload.notification_center_resume_count) + return JSON.stringify({ + ...payload, + notification_center_resume_count: Number.isSafeInteger(resumeCount) && resumeCount >= 0 + ? resumeCount + 1 + : 1, + notification_center_resume_history: [ + ...previousHistory, + { + resumed_at: (failure.resumedAt || new Date()).toISOString(), + previous_error_code: String(failure.code || 'UNKNOWN').slice(0, 100), + previous_error_message: String(failure.message || '').slice(0, 500), + previous_attempt_count: failure.attemptCount, + operator_user_id: failure.operatorUserId + } + ] + }) +} + +export const prepareNotificationCenterQueryResumePayload = ( + payloadJson: string, + failure: { + code?: string | null + message?: string | null + operatorUserId: number + resumedAt?: Date + } +) => { + const payload = JSON.parse(payloadJson) as Record + const previousHistory = Array.isArray(payload.notification_center_query_resume_history) + ? payload.notification_center_query_resume_history.slice(-19) + : [] + const resumeCount = Number(payload.notification_center_query_resume_count) + return JSON.stringify({ + ...payload, + notification_center_status_query_error: null, + notification_center_query_resume_count: Number.isSafeInteger(resumeCount) && resumeCount >= 0 + ? resumeCount + 1 + : 1, + notification_center_query_resume_history: [ + ...previousHistory, + { + resumed_at: (failure.resumedAt || new Date()).toISOString(), + previous_error_code: String(failure.code || 'UNKNOWN').slice(0, 100), + previous_error_message: String(failure.message || '').slice(0, 500), + operator_user_id: failure.operatorUserId + } + ] }) } diff --git a/backend/src/utils/__tests__/authIdentity.dingtalk-ready.test.ts b/backend/src/utils/__tests__/authIdentity.dingtalk-ready.test.ts new file mode 100644 index 0000000..3608a8f --- /dev/null +++ b/backend/src/utils/__tests__/authIdentity.dingtalk-ready.test.ts @@ -0,0 +1,29 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { toAuthIdentityUser } from '../authIdentity' + +test('当前用户接口只返回钉钉可用布尔值,不暴露员工钉钉userId', () => { + const result = toAuthIdentityUser({ + id: 3, + username: 'magic-red', + name: '魔礼红', + email: null, + phone: null, + role: 'admin', + status: 'active', + person_id: 108, + person: { + id: 108, + real_name: '吴永杰', + nickname: '魔礼红', + status: 'active', + source_employee_status: '3', + source_userid: 'ding-secret-user-id' + } + }) + + assert.equal(result.dingtalk_ready, true) + assert.equal('source_userid' in (result.person as object), false) + assert.equal(JSON.stringify(result).includes('ding-secret-user-id'), false) +}) diff --git a/backend/src/utils/__tests__/corsPolicy.test.ts b/backend/src/utils/__tests__/corsPolicy.test.ts index 970ea98..1508b12 100644 --- a/backend/src/utils/__tests__/corsPolicy.test.ts +++ b/backend/src/utils/__tests__/corsPolicy.test.ts @@ -5,7 +5,7 @@ import { isCorsOriginAllowed, isUnsafeRequestOriginAllowed } from '../corsPolicy test('生产环境只允许固定资产源和显式白名单进行跨源凭证请求', () => { const env = { NODE_ENV: 'production', - DINGTALK_PUBLIC_BASE_URL: 'https://asset.example.com', + APP_PUBLIC_BASE_URL: 'https://asset.example.com', CORS_ALLOWED_ORIGINS: 'https://admin.example.com' } assert.equal(isCorsOriginAllowed(undefined, env), true) @@ -24,7 +24,7 @@ test('开发环境默认允许本地Vite,但不反射任意来源', () => { test('跨源写操作默认拒绝,只单独放行通知中心票据回调', () => { const env = { NODE_ENV: 'production', - DINGTALK_PUBLIC_BASE_URL: 'https://asset.example.com', + APP_PUBLIC_BASE_URL: 'https://asset.example.com', YYD_NOTIFICATION_CENTER_URL: 'https://sc.example.com/notification-center' } assert.equal(isUnsafeRequestOriginAllowed({ diff --git a/backend/src/utils/authIdentity.ts b/backend/src/utils/authIdentity.ts index a86efe3..25412da 100644 --- a/backend/src/utils/authIdentity.ts +++ b/backend/src/utils/authIdentity.ts @@ -20,6 +20,7 @@ export type AuthIdentityUser = { permissions: Permission[] person_id: number | null person_bound: boolean + dingtalk_ready: boolean person: { id: number real_name: string @@ -46,7 +47,8 @@ const userInclude = { real_name: true, nickname: true, status: true, - source_employee_status: true + source_employee_status: true, + source_userid: true } } } as const @@ -63,7 +65,20 @@ export const toAuthIdentityUser = (user: any): AuthIdentityUser => ({ permissions: getPermissionsForRole(user.role), person_id: user.person_id, person_bound: Boolean(user.person_id), - person: user.person || null + dingtalk_ready: Boolean( + user.status === 'active' && + user.person?.status === 'active' && + user.person?.source_userid?.trim() + ), + person: user.person + ? { + id: user.person.id, + real_name: user.person.real_name, + nickname: user.person.nickname, + status: user.person.status, + source_employee_status: user.person.source_employee_status + } + : null }) export const resolveOrProvisionAuthUser = async ( diff --git a/backend/src/utils/corsPolicy.ts b/backend/src/utils/corsPolicy.ts index 2694303..e676fe9 100644 --- a/backend/src/utils/corsPolicy.ts +++ b/backend/src/utils/corsPolicy.ts @@ -16,7 +16,7 @@ export const getAllowedCorsOrigins = (env: NodeJS.ProcessEnv = process.env) => { .map((item) => parseOrigin(item.trim())) .filter((item): item is string => Boolean(item)) - const publicOrigin = parseOrigin(String(env.DINGTALK_PUBLIC_BASE_URL || '').trim()) + const publicOrigin = parseOrigin(String(env.APP_PUBLIC_BASE_URL || '').trim()) if (publicOrigin) configured.push(publicOrigin) if (String(env.NODE_ENV || '').trim().toLowerCase() !== 'production') { diff --git a/backend/src/utils/dingtalkRedirect.ts b/backend/src/utils/dingtalkRedirect.ts index 17c03e6..8339d60 100644 --- a/backend/src/utils/dingtalkRedirect.ts +++ b/backend/src/utils/dingtalkRedirect.ts @@ -3,7 +3,6 @@ const allowedRedirectPrefixes = [ '/maintenance', '/my-assets', '/equipment', - '/assets', '/notifications', '/dashboard' ] diff --git a/docker-compose.yml b/docker-compose.yml index aa6cdcd..8748036 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,21 +53,16 @@ services: HR_SYNC_INACTIVE_STATUS_VALUES: ${HR_SYNC_INACTIVE_STATUS_VALUES:-5} HR_SYNC_EXCLUDED_DEPARTMENTS: ${HR_SYNC_EXCLUDED_DEPARTMENTS:-供应商,快递公司,外部交流} HR_SYNC_EXCLUDED_NAME_KEYWORDS: ${HR_SYNC_EXCLUDED_NAME_KEYWORDS:-} - DINGTALK_APP_KEY: ${DINGTALK_APP_KEY:-} - DINGTALK_APP_SECRET: ${DINGTALK_APP_SECRET:-} - DINGTALK_AGENT_ID: ${DINGTALK_AGENT_ID:-} - DINGTALK_CORP_ID: ${DINGTALK_CORP_ID:-} - DINGTALK_PUBLIC_BASE_URL: ${DINGTALK_PUBLIC_BASE_URL:-} - DINGTALK_SEND_ENABLED: ${DINGTALK_SEND_ENABLED:-false} - DINGTALK_NOTIFICATION_CHANNEL: ${DINGTALK_NOTIFICATION_CHANNEL:-work_notice} + APP_PUBLIC_BASE_URL: ${APP_PUBLIC_BASE_URL:-} YYD_NOTIFICATION_CENTER_URL: ${YYD_NOTIFICATION_CENTER_URL:-} - YYD_NOTIFICATION_CENTER_PROJECT_CODE: ${YYD_NOTIFICATION_CENTER_PROJECT_CODE:-fixed_asset} - YYD_NOTIFICATION_CENTER_PROJECT_SECRET: ${YYD_NOTIFICATION_CENTER_PROJECT_SECRET:-} - YYD_NOTIFICATION_CENTER_TEST_USER_IDS: ${YYD_NOTIFICATION_CENTER_TEST_USER_IDS:-} - YYD_NOTIFICATION_CENTER_FULL_ROLLOUT: ${YYD_NOTIFICATION_CENTER_FULL_ROLLOUT:-false} - YYD_NOTIFICATION_CENTER_LOGIN_ENABLED: ${YYD_NOTIFICATION_CENTER_LOGIN_ENABLED:-false} - YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS: ${YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS:-} - YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT: ${YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT:-false} + YYD_NOTIFICATION_PROJECT_CODE: ${YYD_NOTIFICATION_PROJECT_CODE:-fixed_asset} + YYD_NOTIFICATION_PROJECT_SECRET: ${YYD_NOTIFICATION_PROJECT_SECRET:-} + YYD_NOTIFICATION_SEND_ENABLED: ${YYD_NOTIFICATION_SEND_ENABLED:-false} + YYD_NOTIFICATION_SEND_TEST_USER_IDS: ${YYD_NOTIFICATION_SEND_TEST_USER_IDS:-} + YYD_NOTIFICATION_SEND_FULL_ROLLOUT: ${YYD_NOTIFICATION_SEND_FULL_ROLLOUT:-false} + YYD_NOTIFICATION_LOGIN_ENABLED: ${YYD_NOTIFICATION_LOGIN_ENABLED:-false} + YYD_NOTIFICATION_LOGIN_TEST_USER_IDS: ${YYD_NOTIFICATION_LOGIN_TEST_USER_IDS:-} + YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT: ${YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT:-false} DOWNLOADS_DIR: /app/downloads depends_on: mysql: diff --git a/docs/钉钉机器人单聊通知接入实施说明-2026-07-15.md b/docs/钉钉机器人单聊通知接入实施说明-2026-07-15.md index 8b44f00..897de23 100644 --- a/docs/钉钉机器人单聊通知接入实施说明-2026-07-15.md +++ b/docs/钉钉机器人单聊通知接入实施说明-2026-07-15.md @@ -1,442 +1,97 @@ -# 由壹点统一钉钉单聊通知中心接入指南 +# 固定资产接入“由壹点统一通知中心”说明 -更新日期:2026-07-15 +更新日期:2026-07-16 -适用对象:由壹点旗下素材库、产品开发平台、固定资产、脉象、AI 平台及后续新项目的开发 Agent +> 2026-07-15 的“旧工作通知 + 通知中心双通道”方案已经废止。本文件只记录固定资产项目的落地边界;接口、签名、消息格式、错误状态和上线检查以通知中心仓库的当前文档为唯一准则。 -通知中心源码:`D:\AI Code\YouYiDianNotificationCenter` +## 1. 权威资料 -> 本文是可直接交给其他项目 Agent 的实施契约。文中不包含真实钉钉密钥、项目密钥、员工 `userId`、服务器密码或数据库连接;这些内容必须通过生产环境变量配置,禁止写入 Git、日志或交接文档。 +接入或维护前必须完整阅读通知中心仓库中的: -## 1. 统一结论 +- `README.md` +- `docs/接入指南.md` +- `docs/消息规范.md` +- `docs/错误与状态规范.md` +- `docs/新项目接入清单.md` +- `examples/node/notification-client.mjs` -由壹点旗下所有项目共用一个显示名称为“由壹点”的企业内部应用机器人,但业务项目不直接保存或调用该机器人的 `AppKey / AppSecret / RobotCode`。 +通知中心与固定资产是两个独立仓库、独立发布单元。不得把通知中心源码、数据库迁移或钉钉应用凭证复制到本项目。 -所有钉钉单聊统一由独立的“由壹点通知中心”发送。每个业务项目只持有自己的: +## 2. 当前固定方案 -- `projectCode`; -- HMAC 项目密钥; -- 允许跳转的公网 HTTPS 域名; -- 本项目员工的钉钉 `userId`; -- 本项目业务消息、幂等键和跳转地址。 +- 固定资产的 `projectCode` 固定为 `fixed_asset`。 +- 业务项目只保存通知中心分配给 `fixed_asset` 的独立 HMAC 密钥。 +- 钉钉 `AppKey / AppSecret / AgentId / CorpId / RobotCode` 只由通知中心保管,本项目不得读取、配置或调用。 +- 新通知统一使用 `notification_center`;历史 `work_notice` 记录仅供审计,不允许从本系统重新补发。 +- 站内消息与钉钉投递分别计算收件人。未绑定钉钉员工档案不会导致站内消息丢失。 +- 收件人的钉钉 `userId` 只来自管理员手工绑定用户对应的在职员工档案 `Person.source_userid`,不能用姓名、花名、手机号或本地用户 ID 代替。 +- 浏览器不直接调用通知中心,项目密钥不得返回前端。 -通知中心统一负责: +## 3. 生产环境变量 -- 保管钉钉应用密钥和 `RobotCode`; -- 获取并缓存钉钉 `accessToken`; -- 发送机器人单聊; -- 自动给标题加项目来源,例如 `【由壹点·固定资产】`; -- 项目鉴权、请求防重放和项目隔离; -- 异步投递、失败重试、幂等和已读状态查询; -- 限制按钮只能跳转到该项目登记过的 HTTPS 域名。 - -```mermaid -flowchart LR - A[业务项目事件] --> B[项目本地通知记录] - B --> C[项目投递 Worker] - C -->|HMAC HTTPS| D[由壹点通知中心] - D --> E[(通知中心数据库)] - D --> F[由壹点机器人] - F --> G[员工钉钉单聊] - G --> H[已读状态] - H --> D - D --> C -``` - -## 2. 钉钉开发者后台由谁负责 - -### 2.1 新业务项目通常不需要创建机器人 - -只要新项目属于同一企业,并通过通知中心发送消息,就不需要: - -- 新建钉钉机器人; -- 复制统一机器人的 `AppKey / AppSecret / RobotCode`; -- 申请机器人发送权限; -- 自己调用 `/v1.0/robot/oToMessages/batchSend`; -- 自己维护钉钉访问令牌。 - -新项目只需要由通知中心管理员登记项目配置,然后按本文接口接入。 - -### 2.2 统一机器人只在通知中心配置一次 - -钉钉开发者后台需要人工确认以下事项: - -1. 机器人显示名称为“由壹点”; -2. 使用统一头像和介绍; -3. 应用可见范围包含实际收件人; -4. 已开通机器人消息发送权限,常见权限标识为 `qyapi_robot_sendmsg`; -5. 如控制台单独列出已读状态查询权限,也一并开通; -6. 修改机器人、权限或可见范围后,创建并发布应用版本; -7. `AppKey / AppSecret / RobotCode` 只写入通知中心生产环境。 - -这些后台操作不会因为接入一个新业务项目而重复执行。只有收件人超出现有可见范围、权限发生变化或统一机器人资料需要调整时,才需要再次修改钉钉开发者后台。 - -### 2.3 登录应用与通知机器人相互独立 - -业务项目现有的钉钉登录应用继续使用自己的 `AppKey / AppSecret / AgentId / CorpId`。不要为了发送通知而替换登录配置。 - -- 登录应用:负责免登、身份绑定和获取本项目员工钉钉 `userId`; -- 通知中心机器人:只负责统一发送个人单聊通知。 - -## 3. 新项目接入前需要提供的信息 - -业务项目 Agent 需要先整理: - -| 信息 | 示例 | 说明 | -|---|---|---| -| 项目代码 | `fixed_asset` | 小写字母、数字和下划线,创建后不要随意修改 | -| 项目显示名 | `固定资产` | 通知中心用于生成 `【由壹点·固定资产】` | -| 允许跳转域名 | `asset.example.com` | 必须是公网 HTTPS 域名,不能使用 IP、内网地址或任意外链 | -| 通知事件 | `workflow_pending` | 说明哪些业务节点需要通知 | -| 收件人来源 | 员工钉钉 `userId` | 不能用邮箱、手机号、花名、工号或本地用户 ID 代替 | -| 幂等业务键 | `审批单ID + 审批节点 + 收件人` | 同一业务事件重试时必须保持不变 | -| 灰度人员 | 1~3 个测试员工 | 上线初期只让这些人员走通知中心 | - -通知中心管理员据此新增项目配置,并向业务项目生产环境安全交付独立项目密钥。不同项目不能共用项目密钥。 - -当前已约定项目代码: - -| 项目 | `projectCode` | -|---|---| -| 素材库 | `material_library` | -| 固定资产 | `fixed_asset` | - -## 4. 业务项目环境变量 - -每个项目至少增加: +真实地址、HMAC 密钥和灰度人员 `userId` 只写入固定资产生产环境,不进入 Git、日志、截图或交接文档。 ```dotenv -# 默认先保持项目当前旧通知通道,上线灰度时再切换 -DINGTALK_NOTIFICATION_CHANNEL=work_notice +APP_PUBLIC_BASE_URL= -# 当前生产通知中心公网 HTTPS 地址 -YYD_NOTIFICATION_CENTER_URL=https://sc.jiansun.vip/notification-center +YYD_NOTIFICATION_CENTER_URL= +YYD_NOTIFICATION_PROJECT_CODE=fixed_asset +YYD_NOTIFICATION_PROJECT_SECRET= -# 本项目独立身份 -YYD_NOTIFICATION_CENTER_PROJECT_CODE=your_project_code -YYD_NOTIFICATION_CENTER_PROJECT_SECRET= +YYD_NOTIFICATION_SEND_ENABLED=false +YYD_NOTIFICATION_SEND_TEST_USER_IDS= +YYD_NOTIFICATION_SEND_FULL_ROLLOUT=false -# 灰度钉钉 userId,多个用英文逗号分隔;不要提交真实值 -YYD_NOTIFICATION_CENTER_TEST_USER_IDS= - -# 必须显式设为 true 才允许全量;灰度名单为空时默认仍走旧通道 -YYD_NOTIFICATION_CENTER_FULL_ROLLOUT=false +YYD_NOTIFICATION_LOGIN_ENABLED=false +YYD_NOTIFICATION_LOGIN_TEST_USER_IDS= +YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT=false ``` 要求: -- `PROJECT_SECRET` 只存在服务器环境变量,不进入前端和 Git; -- 浏览器不能直接调用通知中心; -- 生产地址必须是 HTTPS; -- 切换通知通道和调整灰度名单必须可独立完成; -- 灰度名单为空且未显式开启 `FULL_ROLLOUT` 时,所有员工继续走旧通道; -- 通知中心不可用时,不要自动跨通道再发一遍,避免用户收到重复消息。 - -## 5. HTTP 接口契约 - -### 5.1 创建通知 - -```http -POST /v1/notifications -Content-Type: application/json -x-yyd-project-code: -x-yyd-timestamp: -x-yyd-nonce: <每次请求唯一随机值> -x-yyd-signature: -``` - -请求体: - -```json -{ - "idempotencyKey": "workflow_pending:order-123:node-2:user-456", - "recipientUserId": "DINGTALK_USER_ID", - "title": "新的待审批任务", - "content": "### ◆ 固定资产待审批\n\n**资产编号** ZC-2026-001\n\n> 请及时处理", - "actionTitle": "立即处理", - "actionUrl": "https://asset.example.com/dingtalk-entry?redirect=%2Fworkflows%2F123" -} -``` - -规则: - -- `idempotencyKey` 必须稳定,不能使用当前时间或随机 UUID; -- `recipientUserId` 是一个钉钉员工 `userId`;如需通知多人,由业务项目为每名收件人创建独立投递记录和幂等键; -- 标题只写业务标题,不要自己添加 `【由壹点】`; -- 以 `【由壹点` 开头的标题会被通知中心拒绝,品牌前缀由中心统一生成; -- `actionUrl` 必须属于该项目登记的 HTTPS 域名; -- 同一个 `projectCode + idempotencyKey` 重试只会返回原通知,不重复发送。 - -成功响应示例: - -```json -{ - "id": "通知中心任务ID", - "projectCode": "fixed_asset", - "status": "pending", - "recipientUserId": "DINGTALK_USER_ID", - "title": "【由壹点·固定资产】新的待审批任务" -} -``` - -业务项目应把通知中心任务 `id` 保存到本地投递记录,不能只凭 HTTP 200 标记最终送达。 - -### 5.2 查询通知状态 - -```http -GET /v1/notifications/ -x-yyd-project-code: -x-yyd-timestamp: -x-yyd-nonce: <每次请求唯一随机值> -x-yyd-signature: <签名> -``` - -状态按通知中心实际响应处理,业务侧至少映射: - -| 通知中心状态 | 业务项目建议状态 | -|---|---| -| `pending` / `processing` | 保留待处理,延迟查询 | -| `sent` / `unread` | 已发送、未读 | -| `read` | 已读,同时保存已读时间 | -| `failed` | 失败,保存可脱敏错误摘要 | - -查询只能读取本项目创建的通知。跨项目任务 ID 会被拒绝。 - -## 6. HMAC 签名算法 - -签名原文按以下顺序拼接,中间使用换行符 `\n`: - -```text -TIMESTAMP -NONCE -HTTP_METHOD -REQUEST_PATH -SHA256_HEX_OF_RAW_BODY -``` - -然后计算: - -```text -signature = HMAC_SHA256_HEX(projectSecret, canonicalString) -``` - -约束: - -- 方法统一使用大写; -- 路径只包含实际 API path,不包含协议、域名和查询字符串;当前接口本身不使用查询字符串; -- POST 的 body hash 必须基于实际发送的原始 UTF-8 字符串; -- GET 没有正文时,对空字符串计算 SHA-256; -- 时间戳使用 Unix 秒; -- 每次请求生成新 nonce; -- 通知中心只接受允许时间偏差内的请求,并拒绝 nonce 重放。 - -TypeScript 参考实现: - -```ts -import crypto from 'node:crypto' - -const sha256 = (value: string) => - crypto.createHash('sha256').update(value, 'utf8').digest('hex') - -const sign = (input: { - method: string - path: string - timestamp: string - nonce: string - rawBody: string - secret: string -}) => { - const canonical = [ - input.timestamp, - input.nonce, - input.method.toUpperCase(), - input.path, - sha256(input.rawBody) - ].join('\n') - - return crypto - .createHmac('sha256', input.secret) - .update(canonical, 'utf8') - .digest('hex') -} -``` - -不要在日志中打印 `canonicalString`、签名请求头、项目密钥或完整消息正文。 - -## 7. 业务项目必须实施的代码结构 - -### 7.1 保留本地投递记录 - -业务事件发生时,先在本项目数据库创建本地投递记录,再由 Worker 调用通知中心。不要在用户请求接口内同步等待钉钉发送完成。 - -本地记录至少包含: - -- 本地投递 ID; -- 事件类型和业务键; -- 收件人钉钉 `userId`; -- 稳定幂等键; -- `provider_type`,例如 `work_notice` 或 `notification_center`; -- 通知中心任务 ID; -- `pending / sent / unread / read / failed`; -- 尝试次数、下次重试时间、错误摘要和已读时间。 - -已有通知队列、租约、重试和幂等机制的项目应复用现有能力,只增加通知中心 provider,不要重造第二套队列。 - -### 7.2 收件人必须来自可靠身份绑定 - -通知中心不会根据邮箱、姓名或手机号反查钉钉员工。业务项目必须提前把本地用户与钉钉唯一 `userId` 可靠绑定。 - -缺少有效 `userId` 时应: - -1. 不调用通知中心; -2. 把本地投递记录标为明确的确定性失败; -3. 提示管理员修复人员绑定; -4. 禁止猜测姓名或用邮箱代替。 - -### 7.3 灰度切换 - -推荐通道选择: - -```ts -const channelFor = (dingTalkUserId: string) => { - const configured = process.env.DINGTALK_NOTIFICATION_CHANNEL || 'work_notice' - if (configured !== 'notification_center') return 'work_notice' - - if (process.env.YYD_NOTIFICATION_CENTER_FULL_ROLLOUT === 'true') { - return 'notification_center' - } - - const allowlist = (process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS || '') - .split(',') - .map((item) => item.trim()) - .filter(Boolean) - - return allowlist.includes(dingTalkUserId) - ? 'notification_center' - : 'work_notice' -} -``` - -灰度名单为空或名单外均走旧通道;只有显式设置 `YYD_NOTIFICATION_CENTER_FULL_ROLLOUT=true` 才允许全量。通知中心调用已经开始后,失败时禁止自动改走旧通道补发。 - -### 7.4 超时与错误 - -- 单次 HTTP 请求建议超时 8 秒; -- 连接超时、5xx 和暂时性限流进入延迟重试; -- 签名错误、域名不允许、收件人缺失等确定性错误直接失败; -- 重试必须复用同一 `idempotencyKey`; -- 日志只记录项目代码、本地投递 ID、通知中心任务 ID、状态和脱敏错误; -- 不记录 Secret、钉钉令牌或完整个人信息。 - -## 8. 项目 Agent 的标准实施步骤 - -收到本文后,接入 Agent 按以下顺序执行: - -1. 只读检查项目现有登录、人员绑定、通知入口、队列、数据库和部署方式; -2. 列出所有需要通知的业务事件、真实收件人规则和稳定幂等键; -3. 确认能够取得员工钉钉 `userId`; -4. 向通知中心管理员申请 `projectCode / 项目名 / allowedHost / 项目密钥`; -5. 新增独立的通知中心后端客户端和 HMAC 单元测试; -6. 复用本地投递表,增加 `provider_type` 与通知中心任务 ID; -7. 在 Worker 中增加 `notification_center` 分支和状态轮询; -8. 增加环境变量及灰度名单,但默认保持旧通道; -9. 跑数据库迁移、类型检查、单元测试和构建; -10. 用本地或测试通知中心验证签名、幂等、域名限制和项目隔离; -11. 先部署代码但不切全量; -12. 只配置 1~3 名测试员工灰度; -13. 验证收到单聊、来源标题、按钮跳转、数据库状态及已读回写; -14. 扩大灰度,观察无重复投递后再全量切换; -15. 保留旧通道一个观察周期,再评估是否下线。 - -## 9. 最低测试与发布门禁 - -### 9.1 单元和集成测试 - -至少覆盖: - -- 签名原文、body hash 和 HMAC 结果固定; -- POST 与 GET 空正文签名; -- 缺配置时拒绝发送; -- 请求超时会结束,不会永久挂起; -- 同一幂等键不会生成第二条通知; -- 不同项目不能读取彼此任务; -- 非 HTTPS 或未登记域名被拒绝; -- 标题不会重复添加“由壹点”前缀; -- 灰度人员走通知中心,其他人员走旧通道; -- 通知中心失败不会自动跨通道重复发送; -- `pending / sent / unread / read / failed` 映射正确; -- 项目密钥和钉钉凭证不会进入日志、前端包或 Git。 - -### 9.2 生产验证 - -1. 通知中心 `/health` 显示服务和数据库可用; -2. 业务项目先保持旧通道部署成功; -3. 只加入开发人员自己的钉钉 `userId`; -4. 发送明确标注为测试且不产生真实业务影响的通知; -5. 确认消息进入“由壹点”机器人个人单聊,不进入公司群; -6. 确认标题包含正确项目来源; -7. 确认按钮进入本项目 HTTPS 地址并完成免登; -8. 确认本地保存通知中心任务 ID; -9. 点击后确认已读状态能够回写; -10. 用重复幂等键验证不会收到第二条; -11. 观察日志无密钥、令牌和完整个人信息; -12. 再扩大到不同部门的 2~3 名测试员工。 - -## 10. 明确禁止的做法 - -- 不要为每个由壹点项目创建一个同名机器人; -- 不要把统一机器人凭证复制到业务项目; -- 不要让前端直接调用通知中心或钉钉接口; -- 不要使用群机器人 Webhook 代替个人单聊; -- 不要用邮箱、手机号、花名或本地用户 ID 冒充钉钉 `userId`; -- 不要用随机值或当前时间作为业务幂等键; -- 不要在通知中心失败后自动换通道再发一次; -- 不要允许任意外部链接作为通知按钮地址; -- 不要仅凭 HTTP 200 就认为员工已经收到或已读; -- 不要在未发布钉钉应用版本时认为后台权限修改已经生效。 - -## 11. 常见故障定位 - -| 现象 | 优先检查 | -|---|---| -| 通知中心返回 401 | 项目代码、项目密钥、时间戳、nonce 和签名原文 | -| 返回域名不允许 | `actionUrl` 是否 HTTPS,host 是否已登记在该项目配置中 | -| 返回重复请求 | nonce 被复用;重试必须生成新 nonce,但保留相同幂等键 | -| 通知一直 pending | 通知中心 Worker、数据库和钉钉访问令牌状态 | -| 钉钉返回无权限 | 统一机器人权限、应用版本和机器人所在应用凭证是否匹配 | -| 收件人无效或被过滤 | 是否使用真实钉钉 `userId`,应用可见范围是否包含该员工 | -| 用户收到两条 | 业务幂等键不稳定、多个 Worker 未正确抢占,或失败后跨通道补发 | -| 按钮打不开 | 公网 HTTPS、反向代理、项目免登入口、重定向白名单 | -| 能收到但查不到已读 | 是否保存通知中心任务 ID,通知中心已读轮询是否正常 | - -## 12. 当前实现位置 - -### 通知中心 - -`D:\AI Code\YouYiDianNotificationCenter` - -关键能力和部署方式见该项目 `README.md`、`.env.example`、`docker-compose.yml` 和 `prisma/schema.prisma`。 - -### 素材库适配器 - -`D:\AI Code\gongxiangp2\lib\youyidian-notification-center.ts` - -素材库通过现有考核调研通知链路调用通知中心,默认仍保持旧通知通道,支持按钉钉 `userId` 灰度。 - -### 固定资产适配器 - -`D:\AI Code\GuDingZiChan\backend\src\services\youyidianNotificationClient.ts` - -固定资产复用原有 `DingTalkDelivery`、Worker、租约、重试和状态刷新,只新增通知中心 provider。 - -## 13. 交给下一个 Agent 的任务模板 - -可把下面这段连同本文路径一起交给其他项目 Agent: - -> 请按《由壹点统一钉钉单聊通知中心接入指南》为当前项目接入钉钉个人单聊。先只读排查项目的钉钉身份绑定、通知事件、投递队列、数据库、幂等和部署方式;业务项目不得保存统一机器人的 AppKey、AppSecret 或 RobotCode,不得直接调用钉钉机器人接口。请复用现有通知投递机制,增加 notification_center provider、稳定幂等键、HMAC 客户端、状态轮询和钉钉 userId 灰度名单。首次部署保持旧通道,完成测试后只对指定测试员工灰度,不允许失败后自动跨通道补发。实施前先列出 projectCode、项目显示名、允许跳转的 HTTPS 域名、通知事件、收件人来源和数据库迁移影响;修改后完成单元测试、构建、部署和生产灰度验证,并确保任何密钥都不进入 Git、前端或日志。 - -## 14. 关于接口真值 - -业务项目只依赖本文定义的通知中心接口,不依赖钉钉开放平台的底层字段。钉钉机器人接口、访问令牌、限流和已读状态的变化统一由通知中心适配,避免每个项目重复维护。 - -通知中心服务已在 2026-07-15 部署到 `https://sc.jiansun.vip/notification-center`;固定资产生产适配器仍需独立完成迁移、配置和灰度验收。固定资产当前公网域名为 `asset.jiansun.vip`,通知中心项目白名单必须包含该域名。项目密钥和灰度人员属于各项目的生产配置,不写入本文;接入 Agent 必须向通知中心管理员单独申请,不能复制其它项目的密钥。 +- `APP_PUBLIC_BASE_URL` 与通知中心登记的固定资产允许域名一致,必须使用不含额外路径的标准 443 端口公网 HTTPS 地址。 +- 发送灰度名单必须被免登灰度名单完整覆盖;全量发送前必须先全量开放免登。 +- 首次上线时发送和免登都只配置同一名指定测试员工,不在代码中硬编码其 `userId`。 +- 灰度期间只为灰度名单内的目标收件人生成钉钉投递任务,非灰度人员不会形成待发送积压;切换全量后只影响新业务事件。 +- 只有灰度单聊、按钮跳转、Broker(中转)免登、状态回写和无重复消息全部通过后,才能切换全量开关。 + +## 4. 登录与跳转 + +- 钉钉通知按钮先进入通知中心 H5 入口。 +- 通知中心生成一次性票据后,回调固定资产后端 `/api/dingtalk/broker-login`。 +- 后端兑换票据并再次核验:系统用户启用、已绑定员工档案、员工在职、钉钉 `userId` 精确一致。 +- 票据只通过 POST 传递,不写入 URL 或日志。 +- 最终跳转只允许项目内部白名单路径;禁止外链跳转。 +- 固定资产当前内部白名单为:`/workflows/disposals`、`/maintenance`、`/my-assets`、`/equipment`、`/notifications`、`/dashboard`;通知中心项目登记必须与此保持一致。 +- 本项目不保留旧的钉钉免登码直换接口。 + +## 5. 投递可靠性 + +- 业务事件、业务单、收件人和消息版本组成稳定幂等键;自动重试不得生成新键。 +- 本地 Worker 通过租约原子领取,超时按既定退避重试,进程重启后可以继续。 +- 提交成功后保存通知中心任务 ID,再按任务 ID 回查 `pending / sending / retrying / sent / unread / read / failed`。 +- 状态回写只允许向前推进;确定性错误直接失败,网络、限流和服务端错误按规则重试。 +- 投递前必须重新核验当前用户、员工状态和 `source_userid`,避免向失效绑定发送。 +- 人工补发必须由管理员显式确认。尚未取得中心任务 ID 时复用原行和原幂等键;已经取得任务 ID 时,只有通知中心明确返回 `failed` 才创建新的、可审计的补发记录。 +- 已有中心任务仅因状态回查配置错误而失败时,只恢复原任务回查,不创建新消息。 +- 手工恢复和补发记录管理员、动作时间与动作类型;新补发行显式关联源投递,并把原消息版本纳入补发幂等键。 +- 旧投递记录不得被覆盖或伪装成新任务。 + +## 6. 上线检查 + +1. 备份固定资产生产数据库和项目环境文件,且备份内容不得输出到终端记录。 +2. 只读核对通知中心已登记 `fixed_asset`、允许域名、Broker 回调和内部跳转前缀。 +3. 从通知中心生产配置中安全取得 `fixed_asset` 独立密钥;不得复制任何其他项目密钥。 +4. 从固定资产生产环境移除所有直接钉钉凭证和旧通道开关。 +5. 核查现有待处理投递队列,避免旧任务在开关打开后意外全量发送。 +6. 执行 Prisma 生成、迁移核查、后端测试、前后端构建和敏感信息扫描。 +7. 部署只允许操作 `/opt/equipment-management` 与 `equipment-*`,不得进入或操作同机其他系统。 +8. 先用指定测试员工完成一次正式格式通知闭环,再决定是否全量启用。 + +## 7. 禁止事项 + +- 禁止在本项目保存或直接使用钉钉应用凭证。 +- 禁止复制素材库或其他业务项目的 HMAC 密钥。 +- 禁止在通知中心失败时自动切换旧通道再发一次。 +- 禁止以 HTTP 200 代替最终送达状态。 +- 禁止未核对通知中心登记和生产队列就直接全量启用。 diff --git a/frontend/src/api/dingtalk.ts b/frontend/src/api/dingtalk.ts index b9be4fd..b2b7788 100644 --- a/frontend/src/api/dingtalk.ts +++ b/frontend/src/api/dingtalk.ts @@ -1,29 +1,16 @@ import api from './index' -import type { ApiResponse, User } from '@/types' +import type { ApiResponse } from '@/types' export interface DingTalkPublicConfig { configured: boolean - mode?: 'work_notice' | 'notification_center' + mode: 'notification_center' loginEntryUrl?: string - clientId?: string - client_id?: string - corpId?: string - corp_id?: string - agentId?: string - agent_id?: string -} - -export interface DingTalkLoginResponse { - user: User - token?: string - redirectPath?: string - redirect_path?: string } export interface DingTalkDeliveryStatus { configured: boolean - channel?: 'work_notice' | 'notification_center' - rollout_mode?: 'legacy' | 'disabled' | 'test' | 'full' + channel: 'notification_center' + rollout_mode?: 'disabled' | 'test' | 'full' test_user_count?: number send_enabled?: boolean enabled?: boolean @@ -34,6 +21,8 @@ export interface DingTalkDeliveryStatus { login_test_user_count?: number login_missing?: string[] pending: number + sending?: number + retrying?: number failed: number sent?: number read?: number @@ -48,11 +37,6 @@ export const getDingTalkPublicConfig = async (redirectPath: string) => { return response.data } -export const loginWithDingTalk = async (data: { code: string; redirectPath: string }) => { - const response = await api.post>('/dingtalk/h5-login', data) - return response.data -} - export const getDingTalkDeliveryStatus = async () => { const response = await api.get>('/dingtalk/deliveries/status') return response.data @@ -64,6 +48,6 @@ export const getDingTalkDeliveries = async (params: { page?: number; pageSize?: } export const retryDingTalkDelivery = async (id: number) => { - const response = await api.post>(`/dingtalk/deliveries/${id}/retry`) + const response = await api.post>(`/dingtalk/deliveries/${id}/retry`, { confirmed: true }) return response.data } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 76263e1..660e2eb 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -90,8 +90,7 @@ api.interceptors.response.use( localStorage.removeItem('token') { const isAuthStatusCheck = requestUrl.includes('/auth/user') - const isLoginAttempt = - requestUrl.includes('/auth/local/login') || requestUrl.includes('/dingtalk/h5-login') + const isLoginAttempt = requestUrl.includes('/auth/local/login') const shouldRedirect = !isAuthStatusCheck && !isLoginAttempt if (shouldRedirect && window.location.pathname !== '/login') { window.location.href = '/login' diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ca695bb..1859fb6 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -534,6 +534,7 @@ export interface User { person_id?: number | null person?: BoundPersonSummary | null person_bound?: boolean + dingtalk_ready?: boolean last_login_at?: string | null } diff --git a/frontend/src/views/auth/DingTalkEntry.vue b/frontend/src/views/auth/DingTalkEntry.vue index 1ac9afa..f6aa91c 100644 --- a/frontend/src/views/auth/DingTalkEntry.vue +++ b/frontend/src/views/auth/DingTalkEntry.vue @@ -14,16 +14,9 @@