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*',
+ ],
}