fix(钉钉): 收紧统一通知中心灰度与状态
This commit is contained in:
parent
346e830328
commit
a1ac722317
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@ -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]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -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<string, string>)['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
|
||||
)
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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<DingTalkResponse>(
|
||||
`${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<DingTalkResponse>(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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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<string, unknown>) => {
|
||||
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<st
|
||||
signal: controller.signal
|
||||
})
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') throw new NotificationCenterError('由壹点通知中心请求超时')
|
||||
throw new NotificationCenterError(error?.message || '由壹点通知中心网络请求失败')
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new NotificationCenterError('由壹点通知中心请求超时', 'TIMEOUT')
|
||||
}
|
||||
throw new NotificationCenterError(
|
||||
error?.message || '由壹点通知中心网络请求失败',
|
||||
'NETWORK'
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
const data = await response.json().catch(() => ({})) as Record<string, any>
|
||||
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<NotificationCenterStatus>([
|
||||
'pending', 'processing', 'sending', 'retrying', 'sent', 'unread', 'read', 'failed'
|
||||
])
|
||||
|
||||
const validateDelivery = (data: Record<string, any>) => {
|
||||
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<string, unknown>
|
||||
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}`
|
||||
})
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 必须向通知中心管理员单独申请,不能复制其它项目的密钥。
|
||||
|
||||
@ -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[]
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
<p class="page-meta__value text-base" :class="status?.configured ? 'text-status-success' : 'text-status-error'">
|
||||
{{ status?.configured ? '完整' : '不完整' }}
|
||||
</p>
|
||||
<p class="page-meta__subvalue">{{ getChannelText() }}</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">待发送</p>
|
||||
@ -68,6 +69,7 @@
|
||||
<div class="mt-3 space-y-1 text-xs text-text-secondary">
|
||||
<p>收件人:{{ getRecipientText(item) }}</p>
|
||||
<p>业务单:{{ item.business_type || '-' }} #{{ item.business_id || '-' }}</p>
|
||||
<p>通道:{{ getProviderText(item.provider_type) }}</p>
|
||||
<p>尝试次数:{{ item.attempt_count ?? item.attempts ?? 0 }}</p>
|
||||
<p v-if="item.last_error" class="text-status-error">{{ item.last_error }}</p>
|
||||
</div>
|
||||
@ -77,12 +79,13 @@
|
||||
|
||||
<div class="hidden md:block">
|
||||
<div class="table-container">
|
||||
<table class="data-table min-w-[900px]">
|
||||
<table class="data-table min-w-[980px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>事件</th>
|
||||
<th>业务单</th>
|
||||
<th>收件人</th>
|
||||
<th>通道</th>
|
||||
<th>状态</th>
|
||||
<th>尝试</th>
|
||||
<th>错误摘要</th>
|
||||
@ -94,6 +97,7 @@
|
||||
<td class="text-sm text-text-primary">{{ item.event_type || item.event || '-' }}</td>
|
||||
<td class="font-mono text-xs text-text-secondary">{{ item.business_type || '-' }} #{{ item.business_id || '-' }}</td>
|
||||
<td class="text-sm text-text-secondary">{{ getRecipientText(item) }}</td>
|
||||
<td class="text-xs text-text-secondary">{{ getProviderText(item.provider_type) }}</td>
|
||||
<td><span class="badge" :class="getDeliveryBadgeClass(item.status)">{{ getDeliveryStatusText(item.status) }}</span></td>
|
||||
<td class="text-sm text-text-secondary">{{ item.attempt_count ?? item.attempts ?? 0 }}</td>
|
||||
<td class="max-w-[280px]"><p class="truncate text-xs text-status-error">{{ item.last_error || '-' }}</p></td>
|
||||
@ -102,7 +106,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && deliveries.length === 0">
|
||||
<td colspan="7" class="py-8 text-center text-text-tertiary">暂无投递记录。</td>
|
||||
<td colspan="8" class="py-8 text-center text-text-tertiary">暂无投递记录。</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@ -148,7 +152,10 @@ const loadData = async () => {
|
||||
}
|
||||
|
||||
const retry = async (item: any) => {
|
||||
if (!confirm('确定重新投递这条钉钉通知吗?')) return
|
||||
const message = item.provider_type === 'notification_center'
|
||||
? '将通过通知中心创建一次新的人工补发;已送达的旧消息不会撤回。确定继续吗?'
|
||||
: '确定重新投递这条钉钉通知吗?'
|
||||
if (!confirm(message)) return
|
||||
try {
|
||||
await retryDingTalkDelivery(Number(item.id))
|
||||
await loadData()
|
||||
@ -165,6 +172,18 @@ const getRecipientText = (item: any) =>
|
||||
item.recipient_id ||
|
||||
'-'
|
||||
|
||||
const getProviderText = (value?: string) => ({
|
||||
work_notice: '旧工作通知',
|
||||
notification_center: '由壹点通知中心'
|
||||
}[value || ''] || value || '-')
|
||||
|
||||
const getChannelText = () => {
|
||||
if (status.value?.channel !== 'notification_center') return '旧工作通知'
|
||||
if (status.value.rollout_mode === 'full') return '通知中心 · 全量'
|
||||
if (status.value.rollout_mode === 'test') return `通知中心 · 灰度 ${status.value.test_user_count || 0} 人`
|
||||
return '通知中心 · 灰度未启用'
|
||||
}
|
||||
|
||||
const getDeliveryBadgeClass = (value?: string) => {
|
||||
if (value === 'failed') return 'badge-error'
|
||||
if (value === 'sent' || value === 'read') return 'badge-success'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user