diff --git a/furniture-3d-system/src/app/api/auth/dingtalk/callback/route.test.ts b/furniture-3d-system/src/app/api/auth/dingtalk/callback/route.test.ts index ef5e34d5..333c77b7 100644 --- a/furniture-3d-system/src/app/api/auth/dingtalk/callback/route.test.ts +++ b/furniture-3d-system/src/app/api/auth/dingtalk/callback/route.test.ts @@ -24,6 +24,7 @@ jest.mock('@/server/services/dingtalk-auth.service', () => ({ })) const { GET } = require('./route') +const { AppError, ErrorCode } = require('@/server/core/errors') function createMockRequest(url: string) { return { @@ -56,6 +57,10 @@ describe('GET /api/auth/dingtalk/callback', () => { ) }) + afterEach(() => { + jest.restoreAllMocks() + }) + it('应支持官方回调里的 authCode 参数', async () => { const response = await GET( createMockRequest('http://180.97.215.57:5246/api/auth/dingtalk/callback?authCode=auth-code-1&state=state-1'), @@ -79,7 +84,10 @@ describe('GET /api/auth/dingtalk/callback', () => { }) it('待激活账号应回跳登录页并提示管理员激活', async () => { - mockAuthenticate.mockRejectedValue(new Error('账号待管理员激活')) + jest.spyOn(console, 'error').mockImplementation(() => undefined) + mockAuthenticate.mockRejectedValue( + new AppError(ErrorCode.AUTH_PENDING_ACTIVATION, '账号待管理员激活'), + ) const response = await GET( createMockRequest('https://cp.jiansun.vip/api/auth/dingtalk/callback?authCode=auth-code-2&state=state-2'), @@ -90,4 +98,21 @@ describe('GET /api/auth/dingtalk/callback', () => { 'https://cp.jiansun.vip/login?dingtalkError=%E8%B4%A6%E5%8F%B7%E5%BE%85%E7%AE%A1%E7%90%86%E5%91%98%E6%BF%80%E6%B4%BB', ) }) + + it('未知底层错误不应原样显示或写入敏感日志', async () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined) + const rawError = Object.assign(new TypeError('fetch failed'), { + config: { data: { clientSecret: 'must-not-appear' } }, + }) + mockAuthenticate.mockRejectedValue(rawError) + + const response = await GET( + createMockRequest('https://cp.jiansun.vip/api/auth/dingtalk/callback?authCode=auth-code-3&state=state-3'), + ) + + const redirectUrl = new URL(response.headers.get('location')!) + expect(redirectUrl.searchParams.get('dingtalkError')).toBe('钉钉登录失败,请稍后重试') + expect(JSON.stringify(consoleError.mock.calls)).not.toContain('must-not-appear') + expect(JSON.stringify(consoleError.mock.calls)).not.toContain('fetch failed') + }) }) diff --git a/furniture-3d-system/src/app/api/auth/dingtalk/callback/route.ts b/furniture-3d-system/src/app/api/auth/dingtalk/callback/route.ts index d82d1e06..3da34ba5 100644 --- a/furniture-3d-system/src/app/api/auth/dingtalk/callback/route.ts +++ b/furniture-3d-system/src/app/api/auth/dingtalk/callback/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { getAuthTokenCookieOptions } from '@/server/auth/auth-cookie' +import { isAppError } from '@/server/core/errors' import { resolveWorkspaceRedirectForUser } from '@/lib/workspace-route-permissions' import { dingtalkAuthService, @@ -15,6 +16,33 @@ function buildLoginRedirect(request: NextRequest, message?: string) { return url } +function getSafeDingtalkCallbackLog(error: unknown) { + if (isAppError(error)) { + const integration = error.details?.integration === 'dingtalk' ? 'dingtalk' : undefined + const operation = typeof error.details?.operation === 'string' ? error.details.operation : undefined + const reasonCode = typeof error.details?.reasonCode === 'string' ? error.details.reasonCode : undefined + + return { + name: error.name, + code: error.code, + statusCode: error.statusCode, + message: error.message, + integration, + operation, + reasonCode, + } + } + + const rawCode = error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + ? error.code + : '' + + return { + name: error instanceof Error ? error.name : 'UnknownError', + code: /^[A-Z0-9_:-]{1,64}$/.test(rawCode) ? rawCode : undefined, + } +} + export async function GET(request: NextRequest) { const code = request.nextUrl.searchParams.get('authCode') || request.nextUrl.searchParams.get('code') const state = request.nextUrl.searchParams.get('state') @@ -43,7 +71,8 @@ export async function GET(request: NextRequest) { return response } catch (error) { - const message = error instanceof Error ? error.message : '钉钉登录失败,请稍后重试' + console.error('[DingtalkAuth] 回调处理失败', getSafeDingtalkCallbackLog(error)) + const message = isAppError(error) ? error.message : '钉钉登录失败,请稍后重试' return NextResponse.redirect(buildLoginRedirect(request, message)) } } diff --git a/furniture-3d-system/src/server/services/__tests__/dingtalk-auth.service.test.ts b/furniture-3d-system/src/server/services/__tests__/dingtalk-auth.service.test.ts index e600cdf6..ae3bbc18 100644 --- a/furniture-3d-system/src/server/services/__tests__/dingtalk-auth.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/dingtalk-auth.service.test.ts @@ -1,4 +1,6 @@ -import { afterAll, beforeEach, describe, expect, it, jest } from '@jest/globals' +import { afterAll, afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals' + +jest.mock('server-only', () => ({})) jest.mock('@/lib/prisma', () => ({ prisma: { @@ -22,10 +24,16 @@ jest.mock('../auth-redirect.service', () => ({ })) const originalEnv = process.env -const { dingtalkAuthService, resolveDingtalkCallbackOrigin } = require('../dingtalk-auth.service') +const originalFetch = global.fetch +const { + DINGTALK_TEMPORARILY_UNAVAILABLE_MESSAGE, + dingtalkAuthService, + resolveDingtalkCallbackOrigin, +} = require('../dingtalk-auth.service') describe('dingtalk-auth.service', () => { beforeEach(() => { + jest.clearAllMocks() process.env = { ...originalEnv } delete process.env.DINGTALK_CALLBACK_ORIGIN delete process.env.DINGTALK_LOGIN_CLIENT_ID @@ -37,6 +45,11 @@ describe('dingtalk-auth.service', () => { process.env.JWT_SECRET = 'test-jwt-secret' }) + afterEach(() => { + jest.restoreAllMocks() + global.fetch = originalFetch + }) + afterAll(() => { process.env = originalEnv }) @@ -84,4 +97,24 @@ describe('dingtalk-auth.service', () => { dingtalkAuthService.buildAuthorizationUrl({ requestOrigin: 'https://request.local' }), ).toThrow('DINGTALK_LOGIN_CLIENT_ID') }) + + it('钉钉网络超时时应重试并返回可理解的中文提示', async () => { + process.env.DINGTALK_LOGIN_CLIENT_ID = 'login-client-id' + process.env.DINGTALK_LOGIN_CLIENT_SECRET = 'login-client-secret' + const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + const networkError = new TypeError('fetch failed') + Object.defineProperty(networkError, 'cause', { + value: Object.assign(new Error('connect timeout'), { code: 'ETIMEDOUT' }), + }) + global.fetch = jest.fn(async () => { + throw networkError + }) as typeof fetch + + await expect(dingtalkAuthService.authenticate('auth-code')).rejects.toMatchObject({ + message: DINGTALK_TEMPORARILY_UNAVAILABLE_MESSAGE, + }) + expect(global.fetch).toHaveBeenCalledTimes(3) + expect(JSON.stringify(consoleWarn.mock.calls)).toContain('ETIMEDOUT') + expect(JSON.stringify(consoleWarn.mock.calls)).not.toContain('login-client-secret') + }) }) diff --git a/furniture-3d-system/src/server/services/dingtalk-auth.service.ts b/furniture-3d-system/src/server/services/dingtalk-auth.service.ts index c080c20a..97f31d65 100644 --- a/furniture-3d-system/src/server/services/dingtalk-auth.service.ts +++ b/furniture-3d-system/src/server/services/dingtalk-auth.service.ts @@ -18,6 +18,20 @@ const DINGTALK_USER_BY_UNION_ID_URL = 'https://oapi.dingtalk.com/topapi/user/get const DINGTALK_USER_INFO_URL = 'https://oapi.dingtalk.com/topapi/v2/user/get' const STATE_TTL_MS = 10 * 60 * 1000 const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ +const DINGTALK_REQUEST_TIMEOUT_MS = process.env.NODE_ENV === 'test' ? 1_000 : 10_000 +const DINGTALK_REQUEST_RETRY_DELAYS_MS = process.env.NODE_ENV === 'test' ? [0, 0] : [300, 1_200] +const RETRYABLE_DINGTALK_ERROR_CODES = new Set([ + 'ABORT_ERR', + 'ECONNABORTED', + 'ECONNRESET', + 'EAI_AGAIN', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'ETIMEDOUT', + 'UND_ERR_CONNECT_TIMEOUT', +]) + +export const DINGTALK_TEMPORARILY_UNAVAILABLE_MESSAGE = '钉钉服务暂时连接不上,请稍后重新登录' interface DingtalkStatePayload { redirectPath: string @@ -33,6 +47,92 @@ interface DingtalkProfile { mobile?: string | null } +function getDingtalkRequestErrorCode(error: unknown): string { + if (!error || typeof error !== 'object') return '' + + const directCode = 'code' in error && typeof error.code === 'string' ? error.code : '' + if (directCode) return directCode + + const cause = 'cause' in error && error.cause && typeof error.cause === 'object' + ? error.cause + : null + return cause && 'code' in cause && typeof cause.code === 'string' ? cause.code : '' +} + +function isRetryableDingtalkRequestError(error: unknown): boolean { + const name = error instanceof Error ? error.name : '' + return error instanceof TypeError + || name === 'AbortError' + || name === 'TimeoutError' + || RETRYABLE_DINGTALK_ERROR_CODES.has(getDingtalkRequestErrorCode(error)) +} + +function wait(durationMs: number) { + return new Promise((resolve) => setTimeout(resolve, durationMs)) +} + +async function requestDingtalk( + operation: string, + input: string | URL, + init: RequestInit, +): Promise { + const maxAttempts = DINGTALK_REQUEST_RETRY_DELAYS_MS.length + 1 + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const response = await fetch(input, { + ...init, + cache: 'no-store', + signal: AbortSignal.timeout(DINGTALK_REQUEST_TIMEOUT_MS), + }) + const retryableStatus = response.status === 429 || response.status >= 500 + if (!retryableStatus) return response + + const reasonCode = `HTTP_${response.status}` + await response.body?.cancel().catch(() => undefined) + if (attempt >= maxAttempts) { + throw new AppError( + ErrorCode.SYSTEM_INTERNAL, + DINGTALK_TEMPORARILY_UNAVAILABLE_MESSAGE, + { integration: 'dingtalk', operation, reasonCode }, + ) + } + + console.warn('[DingtalkAuth] 请求异常,准备重试', { + operation, + attempt, + maxAttempts, + reasonCode, + }) + } catch (error) { + if (error instanceof AppError) throw error + if (!isRetryableDingtalkRequestError(error)) throw error + + const reasonCode = getDingtalkRequestErrorCode(error) + || (error instanceof Error ? error.name : '') + || 'NETWORK_ERROR' + if (attempt >= maxAttempts) { + throw new AppError( + ErrorCode.SYSTEM_INTERNAL, + DINGTALK_TEMPORARILY_UNAVAILABLE_MESSAGE, + { integration: 'dingtalk', operation, reasonCode }, + ) + } + + console.warn('[DingtalkAuth] 请求异常,准备重试', { + operation, + attempt, + maxAttempts, + reasonCode, + }) + } + + await wait(DINGTALK_REQUEST_RETRY_DELAYS_MS[attempt - 1]) + } + + throw new AppError(ErrorCode.SYSTEM_INTERNAL, DINGTALK_TEMPORARILY_UNAVAILABLE_MESSAGE) +} + export function resolveDingtalkCallbackOrigin(requestOrigin: string): string { const configuredOrigin = process.env.DINGTALK_CALLBACK_ORIGIN?.trim() if (configuredOrigin) { @@ -175,7 +275,7 @@ class DingtalkAuthService { } private async getUserAccessToken(code: string): Promise { - const response = await fetch(DINGTALK_USER_ACCESS_TOKEN_URL, { + const response = await requestDingtalk('获取用户令牌', DINGTALK_USER_ACCESS_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -184,7 +284,6 @@ class DingtalkAuthService { code, grantType: 'authorization_code', }), - cache: 'no-store', }) const data = await response.json().catch(() => null) @@ -199,11 +298,10 @@ class DingtalkAuthService { } private async getUnionId(userAccessToken: string): Promise { - const response = await fetch(DINGTALK_CONTACT_ME_URL, { + const response = await requestDingtalk('获取用户身份', DINGTALK_CONTACT_ME_URL, { headers: { 'x-acs-dingtalk-access-token': userAccessToken, }, - cache: 'no-store', }) const data = await response.json().catch(() => null) @@ -215,9 +313,10 @@ class DingtalkAuthService { } private async getAppAccessToken(): Promise { - const response = await fetch( + const response = await requestDingtalk( + '获取应用令牌', `${DINGTALK_APP_ACCESS_TOKEN_URL}?appkey=${encodeURIComponent(this.getClientId())}&appsecret=${encodeURIComponent(this.getClientSecret())}`, - { cache: 'no-store' } + {}, ) const data = await response.json().catch(() => null) @@ -229,11 +328,10 @@ class DingtalkAuthService { } private async getUserIdByUnionId(unionId: string, appAccessToken: string): Promise { - const response = await fetch(`${DINGTALK_USER_BY_UNION_ID_URL}?access_token=${encodeURIComponent(appAccessToken)}`, { + const response = await requestDingtalk('获取用户编号', `${DINGTALK_USER_BY_UNION_ID_URL}?access_token=${encodeURIComponent(appAccessToken)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ unionid: unionId }), - cache: 'no-store', }) const data = await response.json().catch(() => null) @@ -245,11 +343,10 @@ class DingtalkAuthService { } private async getUserInfo(userId: string, appAccessToken: string): Promise> { - const response = await fetch(`${DINGTALK_USER_INFO_URL}?access_token=${encodeURIComponent(appAccessToken)}`, { + const response = await requestDingtalk('获取用户详情', `${DINGTALK_USER_INFO_URL}?access_token=${encodeURIComponent(appAccessToken)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userid: userId }), - cache: 'no-store', }) const data = await response.json().catch(() => null) diff --git a/furniture-3d-system/tsconfig.json b/furniture-3d-system/tsconfig.json index af9e6f77..5c26b9d5 100644 --- a/furniture-3d-system/tsconfig.json +++ b/furniture-3d-system/tsconfig.json @@ -22,7 +22,7 @@ ], "paths": { "@/*": ["./src/*"], - "server-only": ["./src/types/server-only-stub.d.ts"] + "server-only": ["./src/types/server-only-stub.ts"] } }, "include": [