From bca088c96de11c6acb23e93a105aecd7fc6cea16 Mon Sep 17 00:00:00 2001 From: chenpei Date: Mon, 6 Jul 2026 18:07:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=92=89=E9=92=89=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E9=80=9A=E7=9F=A5=E5=85=8D=E7=99=BB=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/dingtalk/callback/route.ts | 49 +++++++++++++++++++++++ app/dingtalk-entry/route.ts | 14 +++---- lib/__tests__/dingtalk-client.test.ts | 38 ++++++++++++++++++ lib/dingtalk-client.ts | 57 ++++++++++++++++++++++----- middleware.ts | 26 ++++++++++-- 5 files changed, 163 insertions(+), 21 deletions(-) diff --git a/app/api/dingtalk/callback/route.ts b/app/api/dingtalk/callback/route.ts index 94daf66..6b31f3d 100644 --- a/app/api/dingtalk/callback/route.ts +++ b/app/api/dingtalk/callback/route.ts @@ -71,12 +71,53 @@ function createCookieLandingResponse(redirectPath: string, loginResponse: Respon return response } +function createDingtalkDirectErrorPage(message: string) { + const safeMessage = JSON.stringify(message || '钉钉免登失败,请联系管理员检查配置').replace(/ + + + + + 钉钉免登失败 + + + + +
+

钉钉免登失败

+

+
+ + +`, + { + status: 401, + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + }, + }, + ) +} + export async function GET(request: NextRequest) { const code = request.nextUrl.searchParams.get('code') || request.nextUrl.searchParams.get('authCode') const { redirectPath, source } = parseDingtalkOAuthState(request.nextUrl.searchParams.get('state')) const publicOrigin = getPublicAppOrigin() if (!code) { + if (source === DINGTALK_DIRECT_LOGIN_SOURCE) { + return createDingtalkDirectErrorPage('钉钉授权缺少授权码,请从钉钉工作通知重新打开') + } + return NextResponse.redirect(new URL(buildDingtalkAutoLoginPath(redirectPath), publicOrigin)) } @@ -93,6 +134,10 @@ export async function GET(request: NextRequest) { const data = await loginResponse.json().catch(() => null) if (!loginResponse.ok || !data?.success || data.needCompleteProfile) { + if (source === DINGTALK_DIRECT_LOGIN_SOURCE) { + return createDingtalkDirectErrorPage(data?.message || '钉钉登录未能创建素材库会话') + } + const loginPath = buildDingtalkAutoLoginPath(redirectPath) const url = new URL(loginPath, publicOrigin) if (data?.message) { @@ -111,6 +156,10 @@ export async function GET(request: NextRequest) { return redirectResponse } catch (error) { console.error('[Dingtalk] 服务端授权回调失败:', error) + if (source === DINGTALK_DIRECT_LOGIN_SOURCE) { + return createDingtalkDirectErrorPage('钉钉服务端授权失败,请稍后重试或联系管理员') + } + return NextResponse.redirect(new URL(buildDingtalkAutoLoginPath(redirectPath), publicOrigin)) } } diff --git a/app/dingtalk-entry/route.ts b/app/dingtalk-entry/route.ts index 79fe7eb..0c2beec 100644 --- a/app/dingtalk-entry/route.ts +++ b/app/dingtalk-entry/route.ts @@ -126,17 +126,13 @@ function createH5LoginPage({ var detail = stringifyError(error); console.log('DingTalk H5 login fallback:', message, detail || error || ''); if (status) { - status.textContent = message + ',正在改用网页授权登录...'; + status.textContent = message + '。可返回钉钉重新打开通知,或点击下方改用网页授权登录。'; if (detail) { status.appendChild(document.createElement('br')); status.appendChild(document.createTextNode('错误详情:' + detail.slice(0, 180))); } appendFallbackLink(); } - - setTimeout(function () { - window.location.replace(fallbackOAuthUrl); - }, 900); } window.__failDingtalkLogin = showError; @@ -173,7 +169,7 @@ function createH5LoginPage({ setStatus('登录成功,正在打开考核调研...'); setTimeout(function () { window.location.replace(target); - }, 180); + }, 350); }) .catch(function (error) { showError(error && error.message ? error.message : '钉钉免登失败'); @@ -289,9 +285,9 @@ function createH5LoginPage({ setStatus('正在加载钉钉免登组件...'); var scriptUrls = [ + 'https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.12/dingtalk.open.js', 'https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.39/dingtalk.open.js', - 'https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.36/dingtalk.open.js', - 'https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.35/dingtalk.open.js' + 'https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.36/dingtalk.open.js' ]; var scriptIndex = 0; var loadTimer = setTimeout(function () { @@ -375,7 +371,7 @@ export async function GET(request: NextRequest) { const fallbackOAuthUrl = buildDingtalkOAuthUrl({ redirectPath, - callbackPath: '/dingtalk-callback', + callbackPath: '/api/dingtalk/callback', source: DINGTALK_DIRECT_LOGIN_SOURCE, }) diff --git a/lib/__tests__/dingtalk-client.test.ts b/lib/__tests__/dingtalk-client.test.ts index 130cba4..14a1655 100644 --- a/lib/__tests__/dingtalk-client.test.ts +++ b/lib/__tests__/dingtalk-client.test.ts @@ -11,6 +11,10 @@ describe('dingtalk client', () => { delete process.env.DINGTALK_WORK_NOTICE_CLIENT_ID delete process.env.DINGTALK_WORK_NOTICE_CLIENT_SECRET delete process.env.DINGTALK_WORK_NOTICE_AGENT_ID + delete process.env.DINGTALK_WORK_NOTICE_CORP_ID + delete process.env.DINGTALK_CORP_ID + delete process.env.DINGTALK_CORPID + delete process.env.NEXT_PUBLIC_DINGTALK_CORP_ID global.fetch = jest.fn() }) @@ -71,4 +75,38 @@ describe('dingtalk client', () => { appSource: 'login', })) }) + + 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 = '4279191869' + process.env.DINGTALK_CORP_ID = 'ding-test-corp' + + ;(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: '这是一条测试通知', + actionUrl: 'https://sc.jiansun.vip/dingtalk-entry?redirect=%2Fperformance-survey', + }) + + const [, sendOptions] = (global.fetch as jest.Mock).mock.calls[1] + const body = JSON.parse(sendOptions.body) + expect(body.msg.msgtype).toBe('action_card') + expect(body.msg.action_card.single_url).toContain('dingtalk://dingtalkclient/action/openapp') + expect(body.msg.action_card.single_url).toContain('corpid=ding-test-corp') + expect(body.msg.action_card.single_url).toContain('app_id=0_4279191869') + expect(body.msg.action_card.single_url).toContain(encodeURIComponent('https://sc.jiansun.vip/dingtalk-entry?redirect=%2Fperformance-survey')) + }) }) diff --git a/lib/dingtalk-client.ts b/lib/dingtalk-client.ts index a983cb1..b0fbbee 100644 --- a/lib/dingtalk-client.ts +++ b/lib/dingtalk-client.ts @@ -57,6 +57,32 @@ function getDingtalkWorkNoticeAgentId() { return process.env.DINGTALK_WORK_NOTICE_AGENT_ID || process.env.DINGTALK_AGENT_ID || '' } +function getDingtalkWorkNoticeCorpId() { + return ( + process.env.DINGTALK_WORK_NOTICE_CORP_ID || + process.env.DINGTALK_CORP_ID || + process.env.DINGTALK_CORPID || + process.env.NEXT_PUBLIC_DINGTALK_CORP_ID || + '' + ).trim() +} + +function buildDingtalkWorkNoticeActionUrl(actionUrl: string, agentId: number) { + const corpId = getDingtalkWorkNoticeCorpId() + if (!corpId.startsWith('ding')) { + return actionUrl + } + + return [ + 'dingtalk://dingtalkclient/action/openapp', + `?corpid=${encodeURIComponent(corpId)}`, + '&container_type=work_platform', + `&app_id=${encodeURIComponent(`0_${agentId}`)}`, + '&redirect_type=jump', + `&redirect_url=${encodeURIComponent(actionUrl)}`, + ].join('') +} + export function getDingtalkWorkNoticeConfigStatus() { const workNoticeAppKey = process.env.DINGTALK_WORK_NOTICE_CLIENT_ID || process.env.DINGTALK_WORK_NOTICE_APP_KEY @@ -174,8 +200,27 @@ export async function sendDingtalkWorkNotice( } const token = await getDingtalkWorkNoticeAccessToken() - const actionText = input.actionUrl ? `\n\n[打开素材库查看](${input.actionUrl})` : '' - const markdownText = truncateText(`${input.text}${actionText}`) + const messageTitle = truncateText(input.title, 120) + const actionUrl = input.actionUrl + ? buildDingtalkWorkNoticeActionUrl(input.actionUrl, numericAgentId) + : null + const msg = actionUrl + ? { + msgtype: 'action_card', + action_card: { + title: messageTitle, + markdown: truncateText(input.text), + single_title: '打开素材库查看', + single_url: actionUrl, + }, + } + : { + msgtype: 'markdown', + markdown: { + title: messageTitle, + text: truncateText(input.text), + }, + } const response = await fetch( `https://oapi.dingtalk.com/topapi/message/corpconversation/asyncsend_v2?access_token=${encodeURIComponent(token)}`, @@ -185,13 +230,7 @@ export async function sendDingtalkWorkNotice( body: JSON.stringify({ agent_id: numericAgentId, userid_list: userIds.join(','), - msg: { - msgtype: 'markdown', - markdown: { - title: truncateText(input.title, 120), - text: markdownText, - }, - }, + msg, }), }, ) diff --git a/middleware.ts b/middleware.ts index 7252f79..99e211e 100644 --- a/middleware.ts +++ b/middleware.ts @@ -16,6 +16,11 @@ const API_CORS_ALLOWED_HEADERS = export async function middleware(request: NextRequest) { const { pathname, origin } = request.nextUrl const requestOrigin = request.headers.get('origin') + const isAuthEntryPage = + pathname === '/login' || + pathname === '/dingtalk-entry' || + pathname === '/dingtalk-callback' || + pathname.startsWith('/performance-survey') // 处理API路由的CORS if (pathname.startsWith('/api/')) { @@ -109,8 +114,15 @@ export async function middleware(request: NextRequest) { } // 对于 HTML 页面,根据是否有新版本决定缓存策略 - if (pathname === '/' || pathname.startsWith('/admin') || pathname.startsWith('/share') || pathname.startsWith('/edit') || pathname.endsWith('.html')) { - if (shouldDisableCache) { + if ( + pathname === '/' || + pathname.startsWith('/admin') || + pathname.startsWith('/share') || + pathname.startsWith('/edit') || + isAuthEntryPage || + pathname.endsWith('.html') + ) { + if (isAuthEntryPage || shouldDisableCache) { // 有新版本:禁用缓存,强制浏览器获取最新代码 response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0') response.headers.set('Pragma', 'no-cache') @@ -136,5 +148,13 @@ export async function middleware(request: NextRequest) { } export const config = { - matcher: ['/', '/admin/:path*', '/api/:path*'], + matcher: [ + '/', + '/login', + '/admin/:path*', + '/api/:path*', + '/dingtalk-entry', + '/dingtalk-callback', + '/performance-survey/:path*', + ], }