diff --git a/app/api/dingtalk/h5-login/route.ts b/app/api/dingtalk/h5-login/route.ts index 463f652..6846d29 100644 --- a/app/api/dingtalk/h5-login/route.ts +++ b/app/api/dingtalk/h5-login/route.ts @@ -16,7 +16,9 @@ export async function POST(request: NextRequest) { ) } - const response = await loginDingtalkUserByH5AuthCode(request, code) + const response = await loginDingtalkUserByH5AuthCode(request, code, { + appSource: 'workNotice', + }) response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') return response } catch (error: any) { diff --git a/app/api/dingtalk/login/route.ts b/app/api/dingtalk/login/route.ts index 6e3f587..89c2486 100644 --- a/app/api/dingtalk/login/route.ts +++ b/app/api/dingtalk/login/route.ts @@ -1,5 +1,9 @@ import { NextRequest, NextResponse } from 'next/server' import { loginDingtalkUserByOAuthCode } from '@/lib/dingtalk-login' +import { + DINGTALK_DIRECT_LOGIN_SOURCE, + parseDingtalkOAuthState, +} from '@/lib/dingtalk-oauth' /** * R25修复:移除硬编码的钉钉密钥,强制使用环境变量 @@ -11,7 +15,7 @@ import { loginDingtalkUserByOAuthCode } from '@/lib/dingtalk-login' export async function POST(request: NextRequest) { try { - const { code } = await request.json() + const { code, state } = await request.json() if (!code) { return NextResponse.json( @@ -20,7 +24,10 @@ export async function POST(request: NextRequest) { ) } - return loginDingtalkUserByOAuthCode(request, String(code)) + const { source } = parseDingtalkOAuthState(state) + return loginDingtalkUserByOAuthCode(request, String(code), { + appSource: source === DINGTALK_DIRECT_LOGIN_SOURCE ? 'workNotice' : 'login', + }) } catch (error: any) { console.error('钉钉登录失败:', error) return NextResponse.json( diff --git a/app/dingtalk-entry/route.ts b/app/dingtalk-entry/route.ts index 0c2beec..37d8c6b 100644 --- a/app/dingtalk-entry/route.ts +++ b/app/dingtalk-entry/route.ts @@ -7,8 +7,8 @@ import { import { DINGTALK_DIRECT_LOGIN_SOURCE, buildDingtalkOAuthUrl, - getDingtalkOAuthClientId, } from '@/lib/dingtalk-oauth' +import { getDingtalkWorkNoticeClientId } from '@/lib/dingtalk-client' import { getConfiguredDingtalkCorpId, normalizeDingtalkCorpId, @@ -45,10 +45,12 @@ function stringifyForInlineScript(value: string) { function createH5LoginPage({ redirectPath, corpId, + clientId, fallbackOAuthUrl, }: { redirectPath: string corpId: string + clientId: string fallbackOAuthUrl: string }) { return new NextResponse( @@ -217,7 +219,7 @@ function createH5LoginPage({ try { dd.requestAuthCode({ corpId: corpId, - clientId: ${stringifyForInlineScript(getDingtalkOAuthClientId())}, + clientId: ${stringifyForInlineScript(clientId)}, onSuccess: function (result) { var code = resolveAuthCode(result); if (!code) { @@ -372,6 +374,7 @@ export async function GET(request: NextRequest) { const fallbackOAuthUrl = buildDingtalkOAuthUrl({ redirectPath, callbackPath: '/api/dingtalk/callback', + clientId: getDingtalkWorkNoticeClientId(), source: DINGTALK_DIRECT_LOGIN_SOURCE, }) @@ -391,6 +394,7 @@ export async function GET(request: NextRequest) { return createH5LoginPage({ redirectPath, corpId, + clientId: getDingtalkWorkNoticeClientId(), fallbackOAuthUrl, }) } diff --git a/lib/__tests__/dingtalk-client.test.ts b/lib/__tests__/dingtalk-client.test.ts index 14a1655..c1768ae 100644 --- a/lib/__tests__/dingtalk-client.test.ts +++ b/lib/__tests__/dingtalk-client.test.ts @@ -76,6 +76,17 @@ describe('dingtalk client', () => { })) }) + test('工作通知免登入口优先使用工作通知应用 clientId', 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' + + const { getDingtalkWorkNoticeClientId } = await import('@/lib/dingtalk-client') + + expect(getDingtalkWorkNoticeClientId()).toBe('notice-client-id') + }) + test('带跳转地址的工作通知优先使用钉钉应用内打开链接', async () => { process.env.DINGTALK_CLIENT_ID = 'login-client-id' process.env.DINGTALK_CLIENT_SECRET = 'login-client-secret' diff --git a/lib/__tests__/dingtalk-oauth.test.ts b/lib/__tests__/dingtalk-oauth.test.ts new file mode 100644 index 0000000..8f09fee --- /dev/null +++ b/lib/__tests__/dingtalk-oauth.test.ts @@ -0,0 +1,32 @@ +/** @jest-environment node */ + +describe('dingtalk oauth helpers', () => { + const originalEnv = process.env + + beforeEach(() => { + jest.resetModules() + process.env = { + ...originalEnv, + NEXT_PUBLIC_APP_URL: 'https://sc.jiansun.vip', + } + }) + + afterEach(() => { + process.env = originalEnv + jest.restoreAllMocks() + }) + + test('buildDingtalkOAuthUrl 支持覆盖 client_id', async () => { + process.env.DINGTALK_CLIENT_ID = 'login-client-id' + + const { buildDingtalkOAuthUrl } = await import('@/lib/dingtalk-oauth') + const url = new URL(buildDingtalkOAuthUrl({ + redirectPath: '/performance-survey', + clientId: 'notice-client-id', + source: 'work_notice_direct', + })) + + expect(url.searchParams.get('client_id')).toBe('notice-client-id') + expect(url.searchParams.get('client_id')).not.toBe('login-client-id') + }) +}) diff --git a/lib/dingtalk-client.ts b/lib/dingtalk-client.ts index b0fbbee..99a9bb8 100644 --- a/lib/dingtalk-client.ts +++ b/lib/dingtalk-client.ts @@ -139,6 +139,20 @@ async function getDingtalkWorkNoticeAccessToken() { return getDingtalkAccessToken(getDingtalkWorkNoticeAppConfig()) } +export function getDingtalkWorkNoticeClientId() { + return ( + process.env.DINGTALK_WORK_NOTICE_CLIENT_ID || + process.env.DINGTALK_WORK_NOTICE_APP_KEY || + process.env.DINGTALK_CLIENT_ID || + process.env.DINGTALK_APP_KEY || + '' + ) +} + +export async function getDingtalkWorkNoticeAppAccessToken() { + return getDingtalkWorkNoticeAccessToken() +} + async function getDingtalkAccessToken(config: DingtalkAppConfig) { const cacheKey = `${config.source}:${config.appKey}:${config.appSecret.slice(-8)}` const cachedAppToken = cachedAppTokens.get(cacheKey) diff --git a/lib/dingtalk-login.ts b/lib/dingtalk-login.ts index 48bea7b..79ab0fd 100644 --- a/lib/dingtalk-login.ts +++ b/lib/dingtalk-login.ts @@ -1,6 +1,9 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' -import { getDingtalkAppAccessToken } from '@/lib/dingtalk-client' +import { + getDingtalkAppAccessToken, + getDingtalkWorkNoticeAppAccessToken, +} from '@/lib/dingtalk-client' import { ensurePerformanceSurveyEmployeeProfileForUser } from '@/lib/performance-survey-report-sync' import { getAuthSessionCookieName, @@ -17,16 +20,32 @@ type DingtalkUserInfo = { email?: string | null } +type DingtalkLoginAppSource = 'login' | 'workNotice' + let configValidated = false -function validateDingtalkConfig(): { clientId: string; clientSecret: string } | null { - const clientId = process.env.DINGTALK_CLIENT_ID - const clientSecret = process.env.DINGTALK_CLIENT_SECRET +function validateDingtalkConfig( + appSource: DingtalkLoginAppSource = 'login', +): { clientId: string; clientSecret: string } | null { + const clientId = appSource === 'workNotice' + ? process.env.DINGTALK_WORK_NOTICE_CLIENT_ID || + process.env.DINGTALK_WORK_NOTICE_APP_KEY || + process.env.DINGTALK_CLIENT_ID + : process.env.DINGTALK_CLIENT_ID + const clientSecret = appSource === 'workNotice' + ? process.env.DINGTALK_WORK_NOTICE_CLIENT_SECRET || + process.env.DINGTALK_WORK_NOTICE_APP_SECRET || + process.env.DINGTALK_CLIENT_SECRET + : process.env.DINGTALK_CLIENT_SECRET if (!clientId || !clientSecret) { if (!configValidated) { console.error('[Dingtalk] 配置错误:缺少必需的环境变量') - console.error('[Dingtalk] 请配置 DINGTALK_CLIENT_ID 和 DINGTALK_CLIENT_SECRET') + console.error( + appSource === 'workNotice' + ? '[Dingtalk] 请配置 DINGTALK_WORK_NOTICE_CLIENT_ID 和 DINGTALK_WORK_NOTICE_CLIENT_SECRET' + : '[Dingtalk] 请配置 DINGTALK_CLIENT_ID 和 DINGTALK_CLIENT_SECRET', + ) configValidated = true } return null @@ -40,16 +59,22 @@ function validateDingtalkConfig(): { clientId: string; clientSecret: string } | return { clientId, clientSecret } } -function getDingtalkConfig(): { clientId: string; clientSecret: string } { - const config = validateDingtalkConfig() +function getDingtalkConfig( + appSource: DingtalkLoginAppSource = 'login', +): { clientId: string; clientSecret: string } { + const config = validateDingtalkConfig(appSource) if (!config) { - throw new Error('钉钉登录未配置,请联系管理员配置 DINGTALK_CLIENT_ID 和 DINGTALK_CLIENT_SECRET') + throw new Error( + appSource === 'workNotice' + ? '钉钉工作通知免登未配置,请联系管理员配置 DINGTALK_WORK_NOTICE_CLIENT_ID 和 DINGTALK_WORK_NOTICE_CLIENT_SECRET' + : '钉钉登录未配置,请联系管理员配置 DINGTALK_CLIENT_ID 和 DINGTALK_CLIENT_SECRET', + ) } return config } -async function getUserAccessToken(code: string) { - const config = getDingtalkConfig() +async function getUserAccessToken(code: string, appSource: DingtalkLoginAppSource = 'login') { + const config = getDingtalkConfig(appSource) const response = await fetch('https://api.dingtalk.com/v1.0/oauth2/userAccessToken', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -422,10 +447,21 @@ async function createOrUpdateUserSession( }) } -export async function loginDingtalkUserByOAuthCode(request: NextRequest, code: string) { - console.log('开始钉钉OAuth登录流程') +function getAppAccessToken(appSource: DingtalkLoginAppSource) { + return appSource === 'workNotice' + ? getDingtalkWorkNoticeAppAccessToken() + : getDingtalkAppAccessToken() +} - const tokenData = await getUserAccessToken(code) +export async function loginDingtalkUserByOAuthCode( + request: NextRequest, + code: string, + options?: { appSource?: DingtalkLoginAppSource }, +) { + console.log('开始钉钉OAuth登录流程') + const appSource = options?.appSource || 'login' + + const tokenData = await getUserAccessToken(code, appSource) if (!tokenData?.accessToken) { console.error('获取用户访问令牌返回数据异常:', tokenData) return NextResponse.json( @@ -435,7 +471,7 @@ export async function loginDingtalkUserByOAuthCode(request: NextRequest, code: s } console.log('获取用户访问令牌成功') - const appAccessToken = await getDingtalkAppAccessToken() + const appAccessToken = await getAppAccessToken(appSource) console.log('获取应用访问令牌成功') const unionId = await getUnionId(tokenData.accessToken) @@ -450,10 +486,15 @@ export async function loginDingtalkUserByOAuthCode(request: NextRequest, code: s return createOrUpdateUserSession(request, userId, userInfo) } -export async function loginDingtalkUserByH5AuthCode(request: NextRequest, code: string) { +export async function loginDingtalkUserByH5AuthCode( + request: NextRequest, + code: string, + options?: { appSource?: DingtalkLoginAppSource }, +) { console.log('开始钉钉H5免登流程') + const appSource = options?.appSource || 'login' - const appAccessToken = await getDingtalkAppAccessToken() + const appAccessToken = await getAppAccessToken(appSource) console.log('获取应用访问令牌成功') const userId = await getUserIdByH5AuthCode(code, appAccessToken) diff --git a/lib/dingtalk-oauth.ts b/lib/dingtalk-oauth.ts index 137059c..d1d7946 100644 --- a/lib/dingtalk-oauth.ts +++ b/lib/dingtalk-oauth.ts @@ -65,11 +65,13 @@ export function parseDingtalkOAuthState(value: unknown) { export function buildDingtalkOAuthUrl({ redirectPath, callbackPath = '/api/dingtalk/callback', + clientId, prompt, source, }: { redirectPath: unknown callbackPath?: string + clientId?: string prompt?: string source?: string }) { @@ -77,7 +79,7 @@ export function buildDingtalkOAuthUrl({ const redirectUrl = `${publicOrigin}${callbackPath}` const params = new URLSearchParams({ redirect_uri: redirectUrl, - client_id: getDingtalkOAuthClientId(), + client_id: clientId || getDingtalkOAuthClientId(), scope: 'openid', response_type: 'code', state: encodeDingtalkOAuthState(redirectPath, publicOrigin, { source }),