feat(钉钉): 接入由壹点统一通知中心

This commit is contained in:
WuYongJie 2026-07-16 20:06:36 +08:00
parent 0b83ef7f88
commit d25d600c7a
36 changed files with 2265 additions and 1432 deletions

View File

@ -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

View File

@ -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;

View File

@ -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")
}

View File

@ -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)
})

View File

@ -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
}
})

View File

@ -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
}
})

View File

@ -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
}
})

View File

@ -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
)
}
}

View File

@ -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
})

View File

@ -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, '更新用户失败')
}
}

View File

@ -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
})) {

View File

@ -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))

View File

@ -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\\)')
})

View File

@ -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]
}
}
})

View File

@ -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]

View File

@ -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')
})

View File

@ -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',

View File

@ -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
}])
})

View File

@ -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
}

View File

@ -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 || {}
}

View File

@ -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
}

View File

@ -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
}
]
})
}

View File

@ -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)
})

View File

@ -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({

View File

@ -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 (

View File

@ -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') {

View File

@ -3,7 +3,6 @@ const allowedRedirectPrefixes = [
'/maintenance',
'/my-assets',
'/equipment',
'/assets',
'/notifications',
'/dashboard'
]

View File

@ -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:

View File

@ -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 代替最终送达状态。
- 禁止未核对通知中心登记和生产队列就直接全量启用。

View File

@ -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
}

View File

@ -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'

View File

@ -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
}

View File

@ -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 || '钉钉免登失败,请联系管理员检查员工绑定'
}

View File

@ -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: '操作日志' }

View File

@ -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: '未读',

View File

@ -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)