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