修复钉钉工作通知免登入口
This commit is contained in:
parent
b4cb919265
commit
bca088c96d
@ -71,12 +71,53 @@ function createCookieLandingResponse(redirectPath: string, loginResponse: Respon
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDingtalkDirectErrorPage(message: string) {
|
||||||
|
const safeMessage = JSON.stringify(message || '钉钉免登失败,请联系管理员检查配置').replace(/</g, '\\u003c')
|
||||||
|
return new NextResponse(
|
||||||
|
`<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>钉钉免登失败</title>
|
||||||
|
<meta http-equiv="Cache-Control" content="no-store" />
|
||||||
|
<style>
|
||||||
|
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f8fafc;color:#0f172a;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
||||||
|
main{width:min(440px,calc(100vw - 48px));padding:28px;text-align:center}
|
||||||
|
h1{font-size:22px;margin:0 0 12px}
|
||||||
|
p{margin:0;color:#475569;line-height:1.8}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>钉钉免登失败</h1>
|
||||||
|
<p id="message"></p>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
document.getElementById('message').textContent = ${safeMessage};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
{
|
||||||
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
const code = request.nextUrl.searchParams.get('code') || request.nextUrl.searchParams.get('authCode')
|
const code = request.nextUrl.searchParams.get('code') || request.nextUrl.searchParams.get('authCode')
|
||||||
const { redirectPath, source } = parseDingtalkOAuthState(request.nextUrl.searchParams.get('state'))
|
const { redirectPath, source } = parseDingtalkOAuthState(request.nextUrl.searchParams.get('state'))
|
||||||
const publicOrigin = getPublicAppOrigin()
|
const publicOrigin = getPublicAppOrigin()
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
|
if (source === DINGTALK_DIRECT_LOGIN_SOURCE) {
|
||||||
|
return createDingtalkDirectErrorPage('钉钉授权缺少授权码,请从钉钉工作通知重新打开')
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.redirect(new URL(buildDingtalkAutoLoginPath(redirectPath), publicOrigin))
|
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)
|
const data = await loginResponse.json().catch(() => null)
|
||||||
|
|
||||||
if (!loginResponse.ok || !data?.success || data.needCompleteProfile) {
|
if (!loginResponse.ok || !data?.success || data.needCompleteProfile) {
|
||||||
|
if (source === DINGTALK_DIRECT_LOGIN_SOURCE) {
|
||||||
|
return createDingtalkDirectErrorPage(data?.message || '钉钉登录未能创建素材库会话')
|
||||||
|
}
|
||||||
|
|
||||||
const loginPath = buildDingtalkAutoLoginPath(redirectPath)
|
const loginPath = buildDingtalkAutoLoginPath(redirectPath)
|
||||||
const url = new URL(loginPath, publicOrigin)
|
const url = new URL(loginPath, publicOrigin)
|
||||||
if (data?.message) {
|
if (data?.message) {
|
||||||
@ -111,6 +156,10 @@ export async function GET(request: NextRequest) {
|
|||||||
return redirectResponse
|
return redirectResponse
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Dingtalk] 服务端授权回调失败:', error)
|
console.error('[Dingtalk] 服务端授权回调失败:', error)
|
||||||
|
if (source === DINGTALK_DIRECT_LOGIN_SOURCE) {
|
||||||
|
return createDingtalkDirectErrorPage('钉钉服务端授权失败,请稍后重试或联系管理员')
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.redirect(new URL(buildDingtalkAutoLoginPath(redirectPath), publicOrigin))
|
return NextResponse.redirect(new URL(buildDingtalkAutoLoginPath(redirectPath), publicOrigin))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -126,17 +126,13 @@ function createH5LoginPage({
|
|||||||
var detail = stringifyError(error);
|
var detail = stringifyError(error);
|
||||||
console.log('DingTalk H5 login fallback:', message, detail || error || '');
|
console.log('DingTalk H5 login fallback:', message, detail || error || '');
|
||||||
if (status) {
|
if (status) {
|
||||||
status.textContent = message + ',正在改用网页授权登录...';
|
status.textContent = message + '。可返回钉钉重新打开通知,或点击下方改用网页授权登录。';
|
||||||
if (detail) {
|
if (detail) {
|
||||||
status.appendChild(document.createElement('br'));
|
status.appendChild(document.createElement('br'));
|
||||||
status.appendChild(document.createTextNode('错误详情:' + detail.slice(0, 180)));
|
status.appendChild(document.createTextNode('错误详情:' + detail.slice(0, 180)));
|
||||||
}
|
}
|
||||||
appendFallbackLink();
|
appendFallbackLink();
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(function () {
|
|
||||||
window.location.replace(fallbackOAuthUrl);
|
|
||||||
}, 900);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.__failDingtalkLogin = showError;
|
window.__failDingtalkLogin = showError;
|
||||||
@ -173,7 +169,7 @@ function createH5LoginPage({
|
|||||||
setStatus('登录成功,正在打开考核调研...');
|
setStatus('登录成功,正在打开考核调研...');
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
window.location.replace(target);
|
window.location.replace(target);
|
||||||
}, 180);
|
}, 350);
|
||||||
})
|
})
|
||||||
.catch(function (error) {
|
.catch(function (error) {
|
||||||
showError(error && error.message ? error.message : '钉钉免登失败');
|
showError(error && error.message ? error.message : '钉钉免登失败');
|
||||||
@ -289,9 +285,9 @@ function createH5LoginPage({
|
|||||||
|
|
||||||
setStatus('正在加载钉钉免登组件...');
|
setStatus('正在加载钉钉免登组件...');
|
||||||
var scriptUrls = [
|
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.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.36/dingtalk.open.js'
|
||||||
'https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.35/dingtalk.open.js'
|
|
||||||
];
|
];
|
||||||
var scriptIndex = 0;
|
var scriptIndex = 0;
|
||||||
var loadTimer = setTimeout(function () {
|
var loadTimer = setTimeout(function () {
|
||||||
@ -375,7 +371,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const fallbackOAuthUrl = buildDingtalkOAuthUrl({
|
const fallbackOAuthUrl = buildDingtalkOAuthUrl({
|
||||||
redirectPath,
|
redirectPath,
|
||||||
callbackPath: '/dingtalk-callback',
|
callbackPath: '/api/dingtalk/callback',
|
||||||
source: DINGTALK_DIRECT_LOGIN_SOURCE,
|
source: DINGTALK_DIRECT_LOGIN_SOURCE,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,10 @@ describe('dingtalk client', () => {
|
|||||||
delete process.env.DINGTALK_WORK_NOTICE_CLIENT_ID
|
delete process.env.DINGTALK_WORK_NOTICE_CLIENT_ID
|
||||||
delete process.env.DINGTALK_WORK_NOTICE_CLIENT_SECRET
|
delete process.env.DINGTALK_WORK_NOTICE_CLIENT_SECRET
|
||||||
delete process.env.DINGTALK_WORK_NOTICE_AGENT_ID
|
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()
|
global.fetch = jest.fn()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -71,4 +75,38 @@ describe('dingtalk client', () => {
|
|||||||
appSource: 'login',
|
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'))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -57,6 +57,32 @@ function getDingtalkWorkNoticeAgentId() {
|
|||||||
return process.env.DINGTALK_WORK_NOTICE_AGENT_ID || process.env.DINGTALK_AGENT_ID || ''
|
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() {
|
export function getDingtalkWorkNoticeConfigStatus() {
|
||||||
const workNoticeAppKey =
|
const workNoticeAppKey =
|
||||||
process.env.DINGTALK_WORK_NOTICE_CLIENT_ID || process.env.DINGTALK_WORK_NOTICE_APP_KEY
|
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 token = await getDingtalkWorkNoticeAccessToken()
|
||||||
const actionText = input.actionUrl ? `\n\n[打开素材库查看](${input.actionUrl})` : ''
|
const messageTitle = truncateText(input.title, 120)
|
||||||
const markdownText = truncateText(`${input.text}${actionText}`)
|
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(
|
const response = await fetch(
|
||||||
`https://oapi.dingtalk.com/topapi/message/corpconversation/asyncsend_v2?access_token=${encodeURIComponent(token)}`,
|
`https://oapi.dingtalk.com/topapi/message/corpconversation/asyncsend_v2?access_token=${encodeURIComponent(token)}`,
|
||||||
@ -185,13 +230,7 @@ export async function sendDingtalkWorkNotice(
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
agent_id: numericAgentId,
|
agent_id: numericAgentId,
|
||||||
userid_list: userIds.join(','),
|
userid_list: userIds.join(','),
|
||||||
msg: {
|
msg,
|
||||||
msgtype: 'markdown',
|
|
||||||
markdown: {
|
|
||||||
title: truncateText(input.title, 120),
|
|
||||||
text: markdownText,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -16,6 +16,11 @@ const API_CORS_ALLOWED_HEADERS =
|
|||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
const { pathname, origin } = request.nextUrl
|
const { pathname, origin } = request.nextUrl
|
||||||
const requestOrigin = request.headers.get('origin')
|
const requestOrigin = request.headers.get('origin')
|
||||||
|
const isAuthEntryPage =
|
||||||
|
pathname === '/login' ||
|
||||||
|
pathname === '/dingtalk-entry' ||
|
||||||
|
pathname === '/dingtalk-callback' ||
|
||||||
|
pathname.startsWith('/performance-survey')
|
||||||
|
|
||||||
// 处理API路由的CORS
|
// 处理API路由的CORS
|
||||||
if (pathname.startsWith('/api/')) {
|
if (pathname.startsWith('/api/')) {
|
||||||
@ -109,8 +114,15 @@ export async function middleware(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 对于 HTML 页面,根据是否有新版本决定缓存策略
|
// 对于 HTML 页面,根据是否有新版本决定缓存策略
|
||||||
if (pathname === '/' || pathname.startsWith('/admin') || pathname.startsWith('/share') || pathname.startsWith('/edit') || pathname.endsWith('.html')) {
|
if (
|
||||||
if (shouldDisableCache) {
|
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('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0')
|
||||||
response.headers.set('Pragma', 'no-cache')
|
response.headers.set('Pragma', 'no-cache')
|
||||||
@ -136,5 +148,13 @@ export async function middleware(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ['/', '/admin/:path*', '/api/:path*'],
|
matcher: [
|
||||||
|
'/',
|
||||||
|
'/login',
|
||||||
|
'/admin/:path*',
|
||||||
|
'/api/:path*',
|
||||||
|
'/dingtalk-entry',
|
||||||
|
'/dingtalk-callback',
|
||||||
|
'/performance-survey/:path*',
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user