diff --git a/lib/__tests__/dingtalk-client.test.ts b/lib/__tests__/dingtalk-client.test.ts new file mode 100644 index 0000000..130cba4 --- /dev/null +++ b/lib/__tests__/dingtalk-client.test.ts @@ -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', + })) + }) +}) diff --git a/lib/dingtalk-client.ts b/lib/dingtalk-client.ts index a359fba..a983cb1 100644 --- a/lib/dingtalk-client.ts +++ b/lib/dingtalk-client.ts @@ -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() + +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}`) diff --git a/lib/env-validation.ts b/lib/env-validation.ts index 94902d3..dad586b 100644 --- a/lib/env-validation.ts +++ b/lib/env-validation.ts @@ -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',