From a1ac722317d34ecaf26fed5a1dd08027175cf1e4 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 15 Jul 2026 18:46:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E9=92=89=E9=92=89):=20=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=80=9A=E7=9F=A5=E4=B8=AD=E5=BF=83=E7=81=B0?= =?UTF-8?q?=E5=BA=A6=E4=B8=8E=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- backend/src/controllers/dingtalkController.ts | 7 +- .../__tests__/dingtalkClient.channel.test.ts | 42 +++++ .../__tests__/dingtalkClient.config.test.ts | 45 +++++- .../__tests__/dingtalkClient.robot.test.ts | 140 ----------------- ...eliveryService.notification-center.test.ts | 14 ++ .../youyidianNotificationClient.test.ts | 80 +++++++++- backend/src/services/dingtalkClient.ts | 144 +++-------------- .../src/services/dingtalkDeliveryService.ts | 147 +++++++++++------- .../services/youyidianNotificationClient.ts | 120 ++++++++++++-- docker-compose.yml | 2 +- ...‰钉机器人单聊通知接入实施说明-2026-07-15.md | 14 +- frontend/src/api/dingtalk.ts | 4 +- .../settings/components/DingTalkSettings.vue | 25 ++- 14 files changed, 447 insertions(+), 339 deletions(-) delete mode 100644 backend/src/services/__tests__/dingtalkClient.robot.test.ts create mode 100644 backend/src/services/__tests__/dingtalkDeliveryService.notification-center.test.ts diff --git a/.env.example b/.env.example index e766941..a00cc97 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,6 @@ HR_SYNC_EXCLUDED_NAME_KEYWORDS= DINGTALK_APP_KEY= DINGTALK_APP_SECRET= DINGTALK_AGENT_ID= -DINGTALK_ROBOT_CODE= DINGTALK_CORP_ID= DINGTALK_PUBLIC_BASE_URL=https://asset.example.com DINGTALK_SEND_ENABLED=false @@ -32,3 +31,4 @@ 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 diff --git a/backend/src/controllers/dingtalkController.ts b/backend/src/controllers/dingtalkController.ts index e2f863d..1a8efea 100644 --- a/backend/src/controllers/dingtalkController.ts +++ b/backend/src/controllers/dingtalkController.ts @@ -7,6 +7,7 @@ import { getDingTalkConfigStatus, getDingTalkPublicConfig } from '../services/dingtalkClient' +import { prepareNotificationCenterManualRetryPayload } from '../services/youyidianNotificationClient' import { normalizeInternalRedirect } from '../utils/dingtalkRedirect' export const getPublicDingTalkConfig = (_req: Request, res: Response) => { @@ -166,6 +167,9 @@ 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' }, data: { @@ -179,7 +183,8 @@ export const retryDingTalkDelivery = async (req: Request, res: Response) => { sent_at: null, read_at: null, last_error_code: null, - last_error_message: null + last_error_message: null, + payload_json: retryPayloadJson } }) if (updated.count !== 1) return errorResponse(res, '投递任务状态已变化,请刷新后重试', 409) diff --git a/backend/src/services/__tests__/dingtalkClient.channel.test.ts b/backend/src/services/__tests__/dingtalkClient.channel.test.ts index c38e7ce..6da1ce6 100644 --- a/backend/src/services/__tests__/dingtalkClient.channel.test.ts +++ b/backend/src/services/__tests__/dingtalkClient.channel.test.ts @@ -5,8 +5,10 @@ import { getDingTalkNotificationChannelForUser } 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' try { assert.equal(getDingTalkNotificationChannelForUser('ding-test-1'), 'notification_center') assert.equal(getDingTalkNotificationChannelForUser('ding-other'), 'work_notice') @@ -15,5 +17,45 @@ test('通知中心灰度名单以外的员工继续使用工作通知', () => { 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 } }) diff --git a/backend/src/services/__tests__/dingtalkClient.config.test.ts b/backend/src/services/__tests__/dingtalkClient.config.test.ts index a1a8e74..f2ca8a9 100644 --- a/backend/src/services/__tests__/dingtalkClient.config.test.ts +++ b/backend/src/services/__tests__/dingtalkClient.config.test.ts @@ -1,7 +1,7 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { getDingTalkPublicConfig } from '../dingtalkClient' +import { getDingTalkConfigStatus, getDingTalkPublicConfig } from '../dingtalkClient' test('钉钉 H5 公开配置返回 clientId 但不泄露 AppSecret', () => { const keys = [ @@ -34,3 +34,46 @@ test('钉钉 H5 公开配置返回 clientId 但不泄露 AppSecret', () => { } } }) + +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' + + try { + const status = getDingTalkConfigStatus() + assert.deepEqual(status, { + configured: true, + channel: 'notification_center', + send_enabled: true, + rollout_mode: 'test', + test_user_count: 2, + 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) + } finally { + 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.robot.test.ts b/backend/src/services/__tests__/dingtalkClient.robot.test.ts deleted file mode 100644 index f010db8..0000000 --- a/backend/src/services/__tests__/dingtalkClient.robot.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - DingTalkApiError, - getDingTalkConfigStatus, - getDingTalkRobotSendResult, - sendDingTalkRobotNotification -} from '../dingtalkClient' - -const envKeys = [ - 'DINGTALK_APP_KEY', - 'DINGTALK_APP_SECRET', - 'DINGTALK_AGENT_ID', - 'DINGTALK_ROBOT_CODE', - 'DINGTALK_CORP_ID', - 'DINGTALK_PUBLIC_BASE_URL', - 'DINGTALK_SEND_ENABLED', - 'DINGTALK_NOTIFICATION_CHANNEL' -] as const - -const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])) -const originalFetch = global.fetch - -const configureDingTalk = () => { - process.env.DINGTALK_APP_KEY = 'ding-app-key' - process.env.DINGTALK_APP_SECRET = 'ding-app-secret' - process.env.DINGTALK_AGENT_ID = '10001' - process.env.DINGTALK_ROBOT_CODE = 'ding-robot-code' - process.env.DINGTALK_CORP_ID = 'ding-corp' - process.env.DINGTALK_PUBLIC_BASE_URL = 'https://asset.example.com' - process.env.DINGTALK_SEND_ENABLED = 'true' - process.env.DINGTALK_NOTIFICATION_CHANNEL = 'robot_oto' -} - -test.after(() => { - global.fetch = originalFetch - for (const key of envKeys) { - if (originalEnv[key] === undefined) delete process.env[key] - else process.env[key] = originalEnv[key] - } -}) - -test('机器人单聊配置状态要求 RobotCode 且不返回任何密钥值', () => { - configureDingTalk() - const status = getDingTalkConfigStatus() - assert.deepEqual(status, { - configured: true, - channel: 'robot_oto', - send_enabled: true, - missing: [] - }) - assert.equal(JSON.stringify(status).includes('ding-app-secret'), false) - assert.equal(JSON.stringify(status).includes('ding-robot-code'), false) -}) - -test('机器人单聊使用 batchSend 并返回 processQueryKey', async () => { - configureDingTalk() - const calls: Array<{ url: string; init?: RequestInit }> = [] - global.fetch = (async (url: string | URL | Request, init?: RequestInit) => { - calls.push({ url: String(url), init }) - if (String(url).includes('/gettoken?')) { - return new Response(JSON.stringify({ errcode: 0, access_token: 'access-token', expires_in: 7200 }), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }) - } - return new Response(JSON.stringify({ processQueryKey: 'query-key-1' }), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }) - }) as typeof fetch - - const taskId = await sendDingTalkRobotNotification({ - userId: 'ding-user-1', - title: '报废审批提醒', - content: '有一条报废申请等待审批', - actionUrl: '/workflows/disposals/53' - }) - - assert.equal(taskId, 'query-key-1') - const sendCall = calls.find((call) => call.url.includes('/v1.0/robot/oToMessages/batchSend')) - assert.ok(sendCall) - assert.equal((sendCall.init?.headers as Record)['x-acs-dingtalk-access-token'], 'access-token') - const body = JSON.parse(String(sendCall.init?.body)) - assert.equal(body.robotCode, 'ding-robot-code') - assert.deepEqual(body.userIds, ['ding-user-1']) - assert.equal(body.msgKey, 'sampleActionCard') - const msgParam = JSON.parse(body.msgParam) - assert.equal(msgParam.singleTitle, '查看详情') - assert.equal( - msgParam.singleURL, - 'https://asset.example.com/dingtalk-entry?redirect=%2Fworkflows%2Fdisposals%2F53' - ) -}) - -test('机器人单聊通过 processQueryKey 查询已读状态', async () => { - configureDingTalk() - global.fetch = (async (url: string | URL | Request) => { - assert.ok(String(url).includes('/v1.0/robot/oToMessages/readStatus')) - return new Response(JSON.stringify({ - sendStatus: 'success', - messageReadInfoList: [ - { userId: 'ding-read', readStatus: 'read' }, - { userId: 'ding-unread', readStatus: 'unread' } - ] - }), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }) - }) as typeof fetch - - const result = await getDingTalkRobotSendResult('query-key-1') - assert.deepEqual(result, { - read_user_id_list: ['ding-read'], - unread_user_id_list: ['ding-unread'], - delivery_failed: false - }) -}) - -test('无效收件人会作为确定性错误停止重试', async () => { - configureDingTalk() - global.fetch = (async () => new Response(JSON.stringify({ - invalidStaffIdList: ['ding-invalid'] - }), { - status: 200, - headers: { 'Content-Type': 'application/json' } - })) as typeof fetch - - await assert.rejects( - sendDingTalkRobotNotification({ - userId: 'ding-invalid', - title: '通知', - content: '内容', - actionUrl: '/dashboard' - }), - (error: unknown) => error instanceof DingTalkApiError && - error.code === 'INVALID_USER' && error.deterministic - ) -}) diff --git a/backend/src/services/__tests__/dingtalkDeliveryService.notification-center.test.ts b/backend/src/services/__tests__/dingtalkDeliveryService.notification-center.test.ts new file mode 100644 index 0000000..d358e2d --- /dev/null +++ b/backend/src/services/__tests__/dingtalkDeliveryService.notification-center.test.ts @@ -0,0 +1,14 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { resolveNotificationCenterLocalStatus } from '../dingtalkDeliveryService' + +test('通知中心未完成状态在本地保持发送中而不是误记为已发送', () => { + for (const status of ['pending', 'processing', 'sending', 'retrying'] as const) { + assert.equal(resolveNotificationCenterLocalStatus(status), 'sending') + } + assert.equal(resolveNotificationCenterLocalStatus('sent'), 'sent') + assert.equal(resolveNotificationCenterLocalStatus('unread'), 'unread') + assert.equal(resolveNotificationCenterLocalStatus('read'), 'read') + assert.equal(resolveNotificationCenterLocalStatus('failed'), 'failed') +}) diff --git a/backend/src/services/__tests__/youyidianNotificationClient.test.ts b/backend/src/services/__tests__/youyidianNotificationClient.test.ts index 0453e14..c0aee6f 100644 --- a/backend/src/services/__tests__/youyidianNotificationClient.test.ts +++ b/backend/src/services/__tests__/youyidianNotificationClient.test.ts @@ -1,6 +1,12 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { submitYouyidianNotification } from '../youyidianNotificationClient' +import { + createNotificationCenterSignature, + getNotificationCenterConfigStatus, + NotificationCenterError, + prepareNotificationCenterManualRetryPayload, + submitYouyidianNotification +} from '../youyidianNotificationClient' const originalFetch = global.fetch const originalEnv = { @@ -22,6 +28,26 @@ test.after(() => { else process.env.DINGTALK_PUBLIC_BASE_URL = originalEnv.publicBaseUrl }) +test('HMAC签名固定覆盖POST正文与GET空正文', () => { + const base = { + secret: '123456789012345678901234', + timestamp: '1721000000', + nonce: 'nonce_1234567890abcdef' + } + assert.equal(createNotificationCenterSignature({ + ...base, + method: 'POST', + path: '/v1/notifications', + body: '{"hello":"世界"}' + }), 'fc4e83aab1b91b42ad37ce85025d027b81138e289bfb53a72d4832e5b26b0f0b') + assert.equal(createNotificationCenterSignature({ + ...base, + method: 'GET', + path: '/v1/notifications/notification-1', + body: '' + }), '4d04963f2f238a7003ff7efdbf5ff4ce5ef0e1e5107f7945558b94b9134c192f') +}) + test('固定资产通过独立项目签名提交到由壹点通知中心', async () => { process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com' process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'fixed_asset' @@ -59,3 +85,55 @@ test('固定资产通过独立项目签名提交到由壹点通知中心', async const body = JSON.parse(String(captured.init?.body)) assert.equal(body.actionUrl, 'https://asset.example.com/dingtalk-entry?redirect=%2Fworkflows%2Fdisposals%2F53') }) + +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' + const status = getNotificationCenterConfigStatus() + assert.equal(status.configured, false) + assert.ok(status.missing.some((item) => item.includes('固定资产必须为fixed_asset'))) +}) + +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' + global.fetch = (async () => new Response(JSON.stringify({ + id: 'notification-1', + projectCode: 'material_library', + status: 'pending' + }), { + status: 202, + headers: { 'Content-Type': 'application/json' } + })) as typeof fetch + + await assert.rejects( + submitYouyidianNotification({ + idempotencyKey: 'disposal_applied:disposal:53:1', + recipientUserId: 'ding-user-1', + title: '待审批报废', + content: '有一项报废申请等待审批', + actionUrl: '/workflows/disposals/53' + }), + (error: unknown) => error instanceof NotificationCenterError && + error.code === 'PROJECT_MISMATCH' && 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 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, 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) +}) diff --git a/backend/src/services/dingtalkClient.ts b/backend/src/services/dingtalkClient.ts index bffef3d..b1ce195 100644 --- a/backend/src/services/dingtalkClient.ts +++ b/backend/src/services/dingtalkClient.ts @@ -19,27 +19,31 @@ export class DingTalkApiError extends Error { } const API_BASE_URL = 'https://oapi.dingtalk.com' -const ROBOT_API_BASE_URL = 'https://api.dingtalk.com' const REQUEST_TIMEOUT_MS = 8_000 -export type DingTalkNotificationChannel = 'work_notice' | 'robot_oto' | 'notification_center' +export type DingTalkNotificationChannel = 'work_notice' | 'notification_center' + +const getNotificationCenterTestUserIds = () => Array.from(new Set( + String(process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS || '') + .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 === 'robot_oto' || value === 'notification_center') return value - throw new Error('DINGTALK_NOTIFICATION_CHANNEL 仅支持 work_notice、robot_oto 或 notification_center') + 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 - const testUserIds = Array.from(new Set( - String(process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS || '') - .split(',') - .map((item) => item.trim()) - .filter(Boolean) - )) - if (testUserIds.length === 0) return channel + if (isNotificationCenterFullRollout()) return channel + const testUserIds = getNotificationCenterTestUserIds() return testUserIds.includes(String(dingtalkUserId || '').trim()) ? 'notification_center' : 'work_notice' } @@ -47,7 +51,6 @@ const requiredConfig = () => ({ appKey: process.env.DINGTALK_APP_KEY || '', appSecret: process.env.DINGTALK_APP_SECRET || '', agentId: process.env.DINGTALK_AGENT_ID || '', - robotCode: process.env.DINGTALK_ROBOT_CODE || '', corpId: process.env.DINGTALK_CORP_ID || '', publicBaseUrl: (process.env.DINGTALK_PUBLIC_BASE_URL || '').replace(/\/$/, '') }) @@ -63,14 +66,22 @@ export const getDingTalkConfigStatus = () => { if (!config.appKey) missing.push('AppKey') if (!config.appSecret) missing.push('AppSecret') if (!config.agentId) missing.push('AgentId') - if (channel === 'robot_oto' && !config.robotCode) missing.push('RobotCode') if (!config.corpId) missing.push('CorpId') if (!config.publicBaseUrl) missing.push('PublicBaseUrl') } + const testUserIds = getNotificationCenterTestUserIds() return { configured: missing.length === 0, channel, send_enabled: process.env.DINGTALK_SEND_ENABLED === 'true', + rollout_mode: channel !== 'notification_center' + ? 'legacy' + : isNotificationCenterFullRollout() + ? 'full' + : testUserIds.length > 0 + ? 'test' + : 'disabled', + test_user_count: testUserIds.length, missing } } @@ -192,113 +203,6 @@ const buildCardUrl = (actionUrl: string) => { return `dingtalk://dingtalkclient/action/openapp?corpid=${encodeURIComponent(corpId)}&container_type=work_platform&app_id=0_${encodeURIComponent(agentId)}&redirect_type=jump&redirect_url=${encodeURIComponent(entryUrl)}` } -const buildRobotActionUrl = (actionUrl: string) => { - const { publicBaseUrl } = requiredConfig() - const safeActionUrl = actionUrl.startsWith('/') && !actionUrl.startsWith('//') ? actionUrl : '/' - return `${publicBaseUrl}/dingtalk-entry?redirect=${encodeURIComponent(safeActionUrl)}` -} - -const truncateText = (value: string, maxLength: number) => { - const normalized = String(value || '').trim() - return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized -} - -const stringList = (value: unknown) => Array.isArray(value) - ? value.map((item) => String(item || '').trim()).filter(Boolean) - : [] - -export const sendDingTalkRobotNotification = async (input: { - userId: string - title: string - content: string - actionUrl: string -}) => { - const config = requiredConfig() - if (!config.robotCode) { - throw new DingTalkApiError('钉钉机器人单聊未配置 RobotCode', 'CONFIG_INCOMPLETE', true) - } - const userId = String(input.userId || '').trim() - if (!userId) { - throw new DingTalkApiError('机器人单聊收件人不能为空', 'RECIPIENT_MISSING', true) - } - - const token = await getDingTalkAccessToken() - const payload = await requestJson( - `${ROBOT_API_BASE_URL}/v1.0/robot/oToMessages/batchSend`, - { - method: 'POST', - headers: { 'x-acs-dingtalk-access-token': token }, - body: JSON.stringify({ - robotCode: config.robotCode, - userIds: [userId], - msgKey: 'sampleActionCard', - msgParam: JSON.stringify({ - title: truncateText(input.title, 120), - text: truncateText(input.content, 4800), - singleTitle: '查看详情', - singleURL: buildRobotActionUrl(input.actionUrl) - }) - }) - } - ) - - const invalidUserIds = stringList(payload.invalidStaffIdList) - const filteredUserIds = stringList(payload.filteredStaffIdList) - const flowControlledUserIds = stringList(payload.flowControlledStaffIdList) - if (invalidUserIds.includes(userId)) { - throw new DingTalkApiError('收件人钉钉 userid 无效', 'INVALID_USER', true) - } - if (filteredUserIds.includes(userId)) { - throw new DingTalkApiError('收件人不在机器人可见范围内', 'FILTERED_USER', true) - } - if (flowControlledUserIds.includes(userId)) { - throw new DingTalkApiError('钉钉机器人单聊发送被限流', 'FLOW_CONTROLLED', false) - } - - const processQueryKey = String(payload.processQueryKey || '').trim() - if (!processQueryKey) { - throw new DingTalkApiError('钉钉未返回机器人单聊任务编号', 'TASK_ID_MISSING', false) - } - return processQueryKey -} - -export const getDingTalkRobotSendResult = async (processQueryKey: string) => { - const config = requiredConfig() - if (!config.robotCode) { - throw new DingTalkApiError('钉钉机器人单聊未配置 RobotCode', 'CONFIG_INCOMPLETE', true) - } - const normalizedKey = String(processQueryKey || '').trim() - if (!normalizedKey) { - throw new DingTalkApiError('机器人单聊任务编号不能为空', 'TASK_ID_MISSING', true) - } - - const token = await getDingTalkAccessToken() - const url = new URL(`${ROBOT_API_BASE_URL}/v1.0/robot/oToMessages/readStatus`) - url.searchParams.set('robotCode', config.robotCode) - url.searchParams.set('processQueryKey', normalizedKey) - const payload = await requestJson(url.toString(), { - method: 'GET', - headers: { 'x-acs-dingtalk-access-token': token } - }) - - const readUserIds: string[] = [] - const unreadUserIds: string[] = [] - const rows = Array.isArray(payload.messageReadInfoList) ? payload.messageReadInfoList : [] - for (const row of rows) { - const userId = String(row?.userId || '').trim() - const readStatus = String(row?.readStatus || '').trim().toLowerCase() - if (!userId) continue - if (readStatus === 'read') readUserIds.push(userId) - if (readStatus === 'unread') unreadUserIds.push(userId) - } - - return { - read_user_id_list: readUserIds, - unread_user_id_list: unreadUserIds, - delivery_failed: String(payload.sendStatus || '').trim().toLowerCase().includes('fail') - } -} - export const sendDingTalkWorkNotification = async (input: { userId: string title: string diff --git a/backend/src/services/dingtalkDeliveryService.ts b/backend/src/services/dingtalkDeliveryService.ts index 0c8617f..2a91b9f 100644 --- a/backend/src/services/dingtalkDeliveryService.ts +++ b/backend/src/services/dingtalkDeliveryService.ts @@ -3,15 +3,15 @@ import type { Prisma } from '@prisma/client' import prisma from '../utils/prisma' import { DingTalkApiError, - getDingTalkRobotSendResult, getDingTalkSendResult, - sendDingTalkRobotNotification, sendDingTalkWorkNotification } from './dingtalkClient' import { getYouyidianNotificationStatus, NotificationCenterError, - submitYouyidianNotification + submitYouyidianNotification, + type NotificationCenterDelivery, + type NotificationCenterStatus } from './youyidianNotificationClient' import { createBusinessNotification, @@ -22,6 +22,45 @@ const workerId = `${os.hostname()}:${process.pid}:${Math.random().toString(36).s const LEASE_MS = 2 * 60 * 1000 const BACKOFF_MINUTES = [1, 5, 15, 30] +export const resolveNotificationCenterLocalStatus = (status: NotificationCenterStatus) => { + if (status === 'pending' || status === 'processing' || status === 'sending' || status === 'retrying') { + return 'sending' + } + return status +} + +const parseExternalDate = (value: string | null | undefined) => { + if (!value) return null + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? null : parsed +} + +const buildNotificationCenterUpdate = ( + result: NotificationCenterDelivery, + fallbackSentAt: Date | null +) => { + const status = resolveNotificationCenterLocalStatus(result.status) + const sentAt = parseExternalDate(result.sentAt) || ( + status === 'sent' || status === 'unread' || status === 'read' + ? fallbackSentAt || new Date() + : fallbackSentAt + ) + 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(), + last_error_code: status === 'failed' + ? result.lastErrorCode || 'NOTIFICATION_CENTER_FAILED' + : null, + last_error_message: status === 'failed' + ? result.lastErrorMessage || '由壹点通知中心返回发送失败' + : null + } +} + export const updateSendingDeliveryIfLeaseOwned = async ( deliveryId: number, data: Prisma.DingTalkDeliveryUpdateManyMutationInput @@ -87,7 +126,11 @@ 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 : 'UNKNOWN', + last_error_code: error instanceof DingTalkApiError + ? error.code + : error instanceof NotificationCenterError + ? error.code + : 'UNKNOWN', last_error_message: String(error?.message || '钉钉投递失败').slice(0, 2000) }) } @@ -103,29 +146,38 @@ const processDelivery = async ( title?: string content?: string action_url?: string + notification_center_idempotency_key?: string } - const taskId = delivery.provider_type === 'notification_center' - ? (await submitYouyidianNotification({ - idempotencyKey: delivery.idempotency_key, - recipientUserId: delivery.recipient_dingtalk_userid, - title: payload.title || '固定资产通知', - content: payload.content || '', - actionTitle: '查看详情', - actionUrl: payload.action_url || '/' - })).id - : delivery.provider_type === 'robot_oto' - ? await sendDingTalkRobotNotification({ - userId: delivery.recipient_dingtalk_userid, - title: payload.title || '固定资产通知', - content: payload.content || '', - actionUrl: payload.action_url || '/' - }) - : await sendDingTalkWorkNotification({ - userId: delivery.recipient_dingtalk_userid, - title: payload.title || '固定资产通知', - content: payload.content || '', - actionUrl: payload.action_url || '/' - }) + 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 || '/' + }) await updateSendingDeliveryIfLeaseOwned(delivery.id, { status: 'sent', dingtalk_task_id: taskId, @@ -144,7 +196,7 @@ const processDelivery = async ( const refreshOneDeliveryResult = async () => { const delivery = await prisma.dingTalkDelivery.findFirst({ where: { - status: { in: ['sent', 'unread'] }, + status: { in: ['sending', 'sent', 'unread'] }, dingtalk_task_id: { not: null }, next_attempt_at: { lte: new Date() } }, @@ -155,38 +207,21 @@ const refreshOneDeliveryResult = async () => { try { if (delivery.provider_type === 'notification_center') { const result = await getYouyidianNotificationStatus(delivery.dingtalk_task_id) - if (result.status === 'read') { - await prisma.dingTalkDelivery.update({ - where: { id: delivery.id }, - data: { status: 'read', read_at: result.readAt ? new Date(result.readAt) : new Date() } - }) - return - } - if (result.status === 'failed') { - await prisma.dingTalkDelivery.update({ - where: { id: delivery.id }, - data: { - status: 'failed', - last_error_code: result.lastErrorCode || 'NOTIFICATION_CENTER_FAILED', - last_error_message: result.lastErrorMessage || '由壹点通知中心返回发送失败' - } - }) - return - } await prisma.dingTalkDelivery.update({ where: { id: delivery.id }, - data: { - status: result.status === 'unread' ? 'unread' : 'sent', - sent_at: result.sentAt ? new Date(result.sentAt) : delivery.sent_at, - next_attempt_at: new Date(Date.now() + 5 * 60_000) - } + data: buildNotificationCenterUpdate(result, delivery.sent_at) }) return } - const result = delivery.provider_type === 'robot_oto' - ? await getDingTalkRobotSendResult(delivery.dingtalk_task_id) - : await getDingTalkSendResult(delivery.dingtalk_task_id) + 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) @@ -227,7 +262,11 @@ const refreshOneDeliveryResult = async () => { await prisma.dingTalkDelivery.update({ where: { id: delivery.id }, data: { - last_error_code: error instanceof DingTalkApiError ? error.code : 'RESULT_QUERY_FAILED', + 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) } diff --git a/backend/src/services/youyidianNotificationClient.ts b/backend/src/services/youyidianNotificationClient.ts index 1dfd9c6..8523e64 100644 --- a/backend/src/services/youyidianNotificationClient.ts +++ b/backend/src/services/youyidianNotificationClient.ts @@ -1,9 +1,12 @@ import crypto from 'crypto' +export type NotificationCenterStatus = + 'pending' | 'processing' | 'sending' | 'retrying' | 'sent' | 'unread' | 'read' | 'failed' + export type NotificationCenterDelivery = { id: string projectCode: string - status: 'pending' | 'sending' | 'retrying' | 'sent' | 'unread' | 'read' | 'failed' + status: NotificationCenterStatus recipientUserId: string title: string processQueryKey?: string | null @@ -14,7 +17,11 @@ export type NotificationCenterDelivery = { } export class NotificationCenterError extends Error { - constructor(message: string, public readonly deterministic = false) { + constructor( + message: string, + public readonly code: string, + public readonly deterministic = false + ) { super(message) this.name = 'NotificationCenterError' } @@ -30,19 +37,59 @@ export const getNotificationCenterConfigStatus = () => { const value = config() const missing: string[] = [] if (!value.baseUrl) missing.push('YYD_NOTIFICATION_CENTER_URL') + if (value.baseUrl) { + try { + const url = new URL(value.baseUrl) + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) { + 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 && value.projectCode !== 'fixed_asset') { + missing.push('YYD_NOTIFICATION_CENTER_PROJECT_CODE(固定资产必须为fixed_asset)') + } if (!value.secret) missing.push('YYD_NOTIFICATION_CENTER_PROJECT_SECRET') + if (value.secret && value.secret.length < 24) { + missing.push('YYD_NOTIFICATION_CENTER_PROJECT_SECRET(长度不足)') + } return { configured: missing.length === 0, missing } } const sha256 = (value: string) => crypto.createHash('sha256').update(value).digest('hex') +export const createNotificationCenterSignature = (input: { + secret: string + timestamp: string + nonce: string + method: string + path: string + body: string +}) => { + const canonical = [ + input.timestamp, + input.nonce, + input.method.toUpperCase(), + input.path, + sha256(input.body) + ].join('\n') + return crypto.createHmac('sha256', input.secret).update(canonical, 'utf8').digest('hex') +} + const signedHeaders = (method: string, path: string, body: string) => { const value = config() const timestamp = String(Math.floor(Date.now() / 1000)) const nonce = crypto.randomBytes(18).toString('base64url') - const canonical = [timestamp, nonce, method.toUpperCase(), path, sha256(body)].join('\n') - const signature = crypto.createHmac('sha256', value.secret).update(canonical).digest('hex') + const signature = createNotificationCenterSignature({ + secret: value.secret, + timestamp, + nonce, + method, + path, + body + }) return { 'Content-Type': 'application/json', 'x-yyd-project-code': value.projectCode, @@ -55,7 +102,13 @@ const signedHeaders = (method: string, path: string, body: string) => { const request = async (path: string, method: 'GET' | 'POST', payload?: Record) => { const value = config() const status = getNotificationCenterConfigStatus() - if (!status.configured) throw new NotificationCenterError(`由壹点通知中心配置不完整: ${status.missing.join(', ')}`, true) + if (!status.configured) { + throw new NotificationCenterError( + `由壹点通知中心配置不完整: ${status.missing.join(', ')}`, + 'CONFIG_INCOMPLETE', + true + ) + } const body = payload ? JSON.stringify(payload) : '' const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 8_000) @@ -68,18 +121,43 @@ const request = async (path: string, method: 'GET' | 'POST', payload?: Record ({})) as Record if (!response.ok) { + const code = String(data.error || `HTTP_${response.status}`) throw new NotificationCenterError( - `由壹点通知中心请求失败: ${data.error || response.status}`, + `由壹点通知中心请求失败: ${code}`, + code, response.status >= 400 && response.status < 500 && response.status !== 429 ) } + return data +} + +const validStatuses = new Set([ + 'pending', 'processing', 'sending', 'retrying', 'sent', 'unread', 'read', 'failed' +]) + +const validateDelivery = (data: Record) => { + const value = config() + const id = String(data.id || '').trim() + const projectCode = String(data.projectCode || '').trim() + const status = String(data.status || '').trim() as NotificationCenterStatus + if (!id || !validStatuses.has(status)) { + throw new NotificationCenterError('由壹点通知中心返回了无效任务数据', 'INVALID_RESPONSE') + } + if (projectCode !== value.projectCode) { + throw new NotificationCenterError('由壹点通知中心返回的项目代码不匹配', 'PROJECT_MISMATCH', true) + } return data as NotificationCenterDelivery } @@ -89,23 +167,39 @@ const buildActionUrl = (actionUrl: string) => { return `${publicBaseUrl}/dingtalk-entry?redirect=${encodeURIComponent(safePath)}` } -export const submitYouyidianNotification = (input: { +export const submitYouyidianNotification = async (input: { idempotencyKey: string recipientUserId: string title: string content: string actionTitle?: string actionUrl: string -}) => request('/v1/notifications', 'POST', { +}) => validateDelivery(await request('/v1/notifications', 'POST', { idempotencyKey: input.idempotencyKey, recipientUserId: input.recipientUserId, title: input.title, content: input.content, actionTitle: input.actionTitle || '查看详情', actionUrl: buildActionUrl(input.actionUrl) -}) +})) -export const getYouyidianNotificationStatus = (id: string) => { +export const getYouyidianNotificationStatus = async (id: string) => { const path = `/v1/notifications/${encodeURIComponent(id)}` - return request(path, 'GET') + return validateDelivery(await request(path, 'GET')) +} + +export const prepareNotificationCenterManualRetryPayload = ( + payloadJson: string, + localIdempotencyKey: string +) => { + 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 + return JSON.stringify({ + ...payload, + notification_center_manual_retry_sequence: sequence, + notification_center_idempotency_key: `manual-retry:${sha256(localIdempotencyKey)}:${sequence}` + }) } diff --git a/docker-compose.yml b/docker-compose.yml index 4202d78..3624d7c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,7 +54,6 @@ services: DINGTALK_APP_KEY: ${DINGTALK_APP_KEY:-} DINGTALK_APP_SECRET: ${DINGTALK_APP_SECRET:-} DINGTALK_AGENT_ID: ${DINGTALK_AGENT_ID:-} - DINGTALK_ROBOT_CODE: ${DINGTALK_ROBOT_CODE:-} DINGTALK_CORP_ID: ${DINGTALK_CORP_ID:-} DINGTALK_PUBLIC_BASE_URL: ${DINGTALK_PUBLIC_BASE_URL:-} DINGTALK_SEND_ENABLED: ${DINGTALK_SEND_ENABLED:-false} @@ -63,6 +62,7 @@ services: 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} DOWNLOADS_DIR: /app/downloads depends_on: mysql: diff --git a/docs/钉钉机器人单聊通知接入实施说明-2026-07-15.md b/docs/钉钉机器人单聊通知接入实施说明-2026-07-15.md index 8f19164..8b44f00 100644 --- a/docs/钉钉机器人单聊通知接入实施说明-2026-07-15.md +++ b/docs/钉钉机器人单聊通知接入实施说明-2026-07-15.md @@ -118,6 +118,9 @@ YYD_NOTIFICATION_CENTER_PROJECT_SECRET= # 灰度钉钉 userId,多个用英文逗号分隔;不要提交真实值 YYD_NOTIFICATION_CENTER_TEST_USER_IDS= + +# 必须显式设为 true 才允许全量;灰度名单为空时默认仍走旧通道 +YYD_NOTIFICATION_CENTER_FULL_ROLLOUT=false ``` 要求: @@ -126,6 +129,7 @@ YYD_NOTIFICATION_CENTER_TEST_USER_IDS= - 浏览器不能直接调用通知中心; - 生产地址必须是 HTTPS; - 切换通知通道和调整灰度名单必须可独立完成; +- 灰度名单为空且未显式开启 `FULL_ROLLOUT` 时,所有员工继续走旧通道; - 通知中心不可用时,不要自动跨通道再发一遍,避免用户收到重复消息。 ## 5. HTTP 接口契约 @@ -298,18 +302,22 @@ 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.length === 0 || allowlist.includes(dingTalkUserId) + return allowlist.includes(dingTalkUserId) ? 'notification_center' : 'work_notice' } ``` -灰度名单外走旧通道是发布策略;通知中心调用已经开始后,失败时禁止自动改走旧通道补发。 +灰度名单为空或名单外均走旧通道;只有显式设置 `YYD_NOTIFICATION_CENTER_FULL_ROLLOUT=true` 才允许全量。通知中心调用已经开始后,失败时禁止自动改走旧通道补发。 ### 7.4 超时与错误 @@ -431,4 +439,4 @@ const channelFor = (dingTalkUserId: string) => { 业务项目只依赖本文定义的通知中心接口,不依赖钉钉开放平台的底层字段。钉钉机器人接口、访问令牌、限流和已读状态的变化统一由通知中心适配,避免每个项目重复维护。 -当前实现已在 2026-07-15 完成本地构建、签名、项目隔离、幂等、域名限制、数据库健康和业务适配器联调,并已部署到 `https://sc.jiansun.vip/notification-center`。项目密钥和灰度人员属于各项目的生产配置,不写入本文;接入 Agent 必须向通知中心管理员单独申请,不能复制其它项目的密钥。 +通知中心服务已在 2026-07-15 部署到 `https://sc.jiansun.vip/notification-center`;固定资产生产适配器仍需独立完成迁移、配置和灰度验收。固定资产当前公网域名为 `asset.jiansun.vip`,通知中心项目白名单必须包含该域名。项目密钥和灰度人员属于各项目的生产配置,不写入本文;接入 Agent 必须向通知中心管理员单独申请,不能复制其它项目的密钥。 diff --git a/frontend/src/api/dingtalk.ts b/frontend/src/api/dingtalk.ts index a0c1c72..7cfd700 100644 --- a/frontend/src/api/dingtalk.ts +++ b/frontend/src/api/dingtalk.ts @@ -20,7 +20,9 @@ export interface DingTalkLoginResponse { export interface DingTalkDeliveryStatus { configured: boolean - channel?: 'robot_oto' | 'work_notice' | 'notification_center' + channel?: 'work_notice' | 'notification_center' + rollout_mode?: 'legacy' | 'disabled' | 'test' | 'full' + test_user_count?: number send_enabled?: boolean enabled?: boolean missing?: string[] diff --git a/frontend/src/views/settings/components/DingTalkSettings.vue b/frontend/src/views/settings/components/DingTalkSettings.vue index 6adaf23..7e5668b 100644 --- a/frontend/src/views/settings/components/DingTalkSettings.vue +++ b/frontend/src/views/settings/components/DingTalkSettings.vue @@ -11,6 +11,7 @@

{{ status?.configured ? '完整' : '不完整' }}

+

{{ getChannelText() }}

待发送

@@ -68,6 +69,7 @@

收件人:{{ getRecipientText(item) }}

业务单:{{ item.business_type || '-' }} #{{ item.business_id || '-' }}

+

通道:{{ getProviderText(item.provider_type) }}

尝试次数:{{ item.attempt_count ?? item.attempts ?? 0 }}

{{ item.last_error }}

@@ -77,12 +79,13 @@