支持钉钉工作通知独立应用凭证

This commit is contained in:
chenpei 2026-07-06 11:54:53 +08:00
parent 01c9af89a6
commit 222df24ba8
3 changed files with 168 additions and 10 deletions

View File

@ -0,0 +1,74 @@
/** @jest-environment node */
describe('dingtalk client', () => {
const originalEnv = process.env
beforeEach(() => {
jest.resetModules()
process.env = { ...originalEnv }
delete process.env.DINGTALK_CLIENT_ID
delete process.env.DINGTALK_CLIENT_SECRET
delete process.env.DINGTALK_WORK_NOTICE_CLIENT_ID
delete process.env.DINGTALK_WORK_NOTICE_CLIENT_SECRET
delete process.env.DINGTALK_WORK_NOTICE_AGENT_ID
global.fetch = jest.fn()
})
afterEach(() => {
process.env = originalEnv
jest.restoreAllMocks()
})
test('工作通知优先使用独立应用凭证获取 token', async () => {
process.env.DINGTALK_CLIENT_ID = 'login-client-id'
process.env.DINGTALK_CLIENT_SECRET = 'login-client-secret'
process.env.DINGTALK_WORK_NOTICE_CLIENT_ID = 'notice-client-id'
process.env.DINGTALK_WORK_NOTICE_CLIENT_SECRET = 'notice-client-secret'
process.env.DINGTALK_WORK_NOTICE_AGENT_ID = '4279191869'
;(global.fetch as jest.Mock)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, access_token: 'notice-token', expires_in: 7200 }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, task_id: 123, request_id: 'request-1' }),
})
const { sendDingtalkWorkNotice } = await import('@/lib/dingtalk-client')
await sendDingtalkWorkNotice({
userIds: ['ding-user-1'],
title: '测试通知',
text: '这是一条测试通知',
})
expect(global.fetch).toHaveBeenNthCalledWith(
1,
expect.stringContaining('appkey=notice-client-id'),
)
expect(global.fetch).toHaveBeenNthCalledWith(
1,
expect.not.stringContaining('appkey=login-client-id'),
)
const [, sendOptions] = (global.fetch as jest.Mock).mock.calls[1]
expect(JSON.parse(sendOptions.body)).toEqual(expect.objectContaining({
agent_id: 4279191869,
userid_list: 'ding-user-1',
}))
})
test('工作通知未配置独立凭证时仍可复用登录应用凭证', async () => {
process.env.DINGTALK_CLIENT_ID = 'login-client-id'
process.env.DINGTALK_CLIENT_SECRET = 'login-client-secret'
process.env.DINGTALK_WORK_NOTICE_AGENT_ID = '123'
const { getDingtalkWorkNoticeConfigStatus } = await import('@/lib/dingtalk-client')
expect(getDingtalkWorkNoticeConfigStatus()).toEqual(expect.objectContaining({
ready: true,
appSource: 'login',
}))
})
})

View File

@ -13,9 +13,15 @@ type DingtalkWorkNoticeResult = {
raw?: unknown
}
let cachedAppToken: { token: string; expiresAt: number } | null = null
type DingtalkAppConfig = {
appKey: string
appSecret: string
source: 'login' | 'workNotice'
}
function getDingtalkAppConfig() {
const cachedAppTokens = new Map<string, { token: string; expiresAt: number }>()
function getDingtalkAppConfig(): DingtalkAppConfig {
const appKey = process.env.DINGTALK_CLIENT_ID || process.env.DINGTALK_APP_KEY
const appSecret = process.env.DINGTALK_CLIENT_SECRET || process.env.DINGTALK_APP_SECRET
@ -23,26 +29,99 @@ function getDingtalkAppConfig() {
throw new Error('钉钉应用未配置 DINGTALK_CLIENT_ID/DINGTALK_CLIENT_SECRET')
}
return { appKey, appSecret }
return { appKey, appSecret, source: 'login' }
}
function getDingtalkWorkNoticeAppConfig(): DingtalkAppConfig {
const workNoticeAppKey =
process.env.DINGTALK_WORK_NOTICE_CLIENT_ID || process.env.DINGTALK_WORK_NOTICE_APP_KEY
const workNoticeAppSecret =
process.env.DINGTALK_WORK_NOTICE_CLIENT_SECRET || process.env.DINGTALK_WORK_NOTICE_APP_SECRET
if (workNoticeAppKey || workNoticeAppSecret) {
if (!workNoticeAppKey || !workNoticeAppSecret) {
throw new Error('钉钉工作通知应用未完整配置 DINGTALK_WORK_NOTICE_CLIENT_ID/DINGTALK_WORK_NOTICE_CLIENT_SECRET')
}
return {
appKey: workNoticeAppKey,
appSecret: workNoticeAppSecret,
source: 'workNotice',
}
}
return getDingtalkAppConfig()
}
function getDingtalkWorkNoticeAgentId() {
return process.env.DINGTALK_WORK_NOTICE_AGENT_ID || process.env.DINGTALK_AGENT_ID || ''
}
export function getDingtalkWorkNoticeConfigStatus() {
const workNoticeAppKey =
process.env.DINGTALK_WORK_NOTICE_CLIENT_ID || process.env.DINGTALK_WORK_NOTICE_APP_KEY
const workNoticeAppSecret =
process.env.DINGTALK_WORK_NOTICE_CLIENT_SECRET || process.env.DINGTALK_WORK_NOTICE_APP_SECRET
const loginAppKey = process.env.DINGTALK_CLIENT_ID || process.env.DINGTALK_APP_KEY
const loginAppSecret = process.env.DINGTALK_CLIENT_SECRET || process.env.DINGTALK_APP_SECRET
const agentId = getDingtalkWorkNoticeAgentId()
const numericAgentId = Number(agentId)
const workNoticeAppPartiallyConfigured = Boolean(workNoticeAppKey || workNoticeAppSecret)
const workNoticeAppConfigured = Boolean(workNoticeAppKey && workNoticeAppSecret)
const loginAppConfigured = Boolean(loginAppKey && loginAppSecret)
const appConfigured = workNoticeAppPartiallyConfigured
? workNoticeAppConfigured
: loginAppConfigured
const agentConfigured = Boolean(agentId && Number.isFinite(numericAgentId) && numericAgentId > 0)
const missing: string[] = []
if (!appConfigured) {
missing.push(
workNoticeAppPartiallyConfigured
? 'DINGTALK_WORK_NOTICE_CLIENT_ID/DINGTALK_WORK_NOTICE_CLIENT_SECRET'
: 'DINGTALK_WORK_NOTICE_CLIENT_ID/DINGTALK_WORK_NOTICE_CLIENT_SECRET 或 DINGTALK_CLIENT_ID/DINGTALK_CLIENT_SECRET',
)
}
if (!agentConfigured) {
missing.push('DINGTALK_WORK_NOTICE_AGENT_ID')
}
return {
ready: appConfigured && agentConfigured,
appConfigured,
agentConfigured,
appSource: workNoticeAppPartiallyConfigured ? 'workNotice' : loginAppConfigured ? 'login' : null,
agentSource: process.env.DINGTALK_WORK_NOTICE_AGENT_ID
? 'DINGTALK_WORK_NOTICE_AGENT_ID'
: process.env.DINGTALK_AGENT_ID
? 'DINGTALK_AGENT_ID'
: null,
missing,
}
}
function truncateText(value: string, maxLength = 4800) {
const normalized = String(value || '').trim()
return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized
}
export async function getDingtalkAppAccessToken() {
return getDingtalkAccessToken(getDingtalkAppConfig())
}
async function getDingtalkWorkNoticeAccessToken() {
return getDingtalkAccessToken(getDingtalkWorkNoticeAppConfig())
}
async function getDingtalkAccessToken(config: DingtalkAppConfig) {
const cacheKey = `${config.source}:${config.appKey}:${config.appSecret.slice(-8)}`
const cachedAppToken = cachedAppTokens.get(cacheKey)
if (cachedAppToken && cachedAppToken.expiresAt > Date.now() + 60_000) {
return cachedAppToken.token
}
const { appKey, appSecret } = getDingtalkAppConfig()
const response = await fetch(
`https://oapi.dingtalk.com/gettoken?appkey=${encodeURIComponent(appKey)}&appsecret=${encodeURIComponent(appSecret)}`,
`https://oapi.dingtalk.com/gettoken?appkey=${encodeURIComponent(config.appKey)}&appsecret=${encodeURIComponent(config.appSecret)}`,
)
if (!response.ok) {
@ -55,12 +134,13 @@ export async function getDingtalkAppAccessToken() {
}
const expiresIn = Number(data.expires_in || 7200)
cachedAppToken = {
token: String(data.access_token),
const nextToken = String(data.access_token)
cachedAppTokens.set(cacheKey, {
token: nextToken,
expiresAt: Date.now() + Math.max(60, expiresIn - 120) * 1000,
}
})
return cachedAppToken.token
return nextToken
}
export async function sendDingtalkWorkNotice(
@ -93,7 +173,7 @@ export async function sendDingtalkWorkNotice(
}
}
const token = await getDingtalkAppAccessToken()
const token = await getDingtalkWorkNoticeAccessToken()
const actionText = input.actionUrl ? `\n\n[打开素材库查看](${input.actionUrl})` : ''
const markdownText = truncateText(`${input.text}${actionText}`)

View File

@ -66,6 +66,10 @@ const OPTIONAL_ENV_VARS = [
'DINGTALK_APP_SECRET',
'DINGTALK_CLIENT_ID',
'DINGTALK_CLIENT_SECRET',
'DINGTALK_WORK_NOTICE_CLIENT_ID',
'DINGTALK_WORK_NOTICE_CLIENT_SECRET',
'DINGTALK_WORK_NOTICE_APP_KEY',
'DINGTALK_WORK_NOTICE_APP_SECRET',
'DINGTALK_WORK_NOTICE_AGENT_ID',
'DINGTALK_AGENT_ID',
'REPORT_DB_SERVER',