import { NextRequest, NextResponse } from 'next/server' import { authorizeDingtalkH5UserForNextAuth, loginDingtalkUserByH5AuthCode, } from '@/lib/dingtalk-login' import { createDingtalkPerformanceSessionToken, resolveDingtalkPerformanceSessionScope, } from '@/lib/dingtalk-performance-session' import { DINGTALK_PERFORMANCE_SESSION_API_COOKIE, DINGTALK_PERFORMANCE_SESSION_COOKIE, DINGTALK_PERFORMANCE_SESSION_HINT_COOKIE, } from '@/lib/dingtalk-performance-session-constants' import { getPublicAppOrigin } from '@/lib/public-app-url' import { normalizeInternalRedirectPath } from '@/lib/safe-redirect' import { resolveSecureAuthCookieFromRequest } from '@/lib/auth-cookie' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' const H5_LOGIN_MAX_BODY_BYTES = 64 * 1024 class H5LoginRequestError extends Error { constructor( message: string, readonly status: number, readonly code: string, ) { super(message) } } const SURVEY_OPENING_PAGE_STYLE = ` html,body{margin:0;min-height:100%;background:#f6f8fb;color:#111827;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} body{min-height:100vh;min-height:100dvh;overflow:hidden} .topbar{height:64px;box-sizing:border-box;display:flex;align-items:center;padding:0 20px;color:#fff;background:#000} .topbar span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .topbar strong{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:18px;font-weight:650;line-height:1.15;letter-spacing:-.01em} main{box-sizing:border-box;width:min(430px,100%);margin:0 auto;padding:32px 20px 0} .shell{padding:24px;border:1px solid #e3eaf3;border-radius:26px;background:#fff;box-shadow:0 18px 48px rgba(15,23,42,.10)} .kicker{font-size:14px;font-weight:650;color:#1677ff} h1{margin:12px 0 0;font-size:28px;line-height:1.18;font-weight:700;letter-spacing:-.01em;color:#101828} #message{margin:12px 0 0;color:#667085;font-size:14px;font-weight:500;line-height:1.7} .open-box{margin-top:24px;padding:16px;border-radius:22px;background:#f6f8fb} .open-row{display:flex;align-items:center;justify-content:space-between;gap:16px} .open-row strong{font-size:15px;font-weight:650;color:#101828} .open-row em{font-style:normal;color:#1677ff;font-size:14px;font-weight:650} .progress{height:6px;margin-top:16px;overflow:hidden;border-radius:999px;background:#e8f1ff} .progress span{display:block;width:66%;height:100%;border-radius:inherit;background:#1677ff;animation:pulse 1.1s ease-in-out infinite} .ghost-card{margin-top:28px;padding:20px;border:1px solid #eef2f7;border-radius:18px;background:#fff} .ghost-title{font-size:15px;font-weight:600;color:#7b8494} .ghost-line{height:12px;border-radius:999px;background:#eef3f9} .ghost-line.one{margin-top:24px} .ghost-line.two{width:74%;margin-top:12px} .ghost-list{margin-top:32px;display:grid;gap:20px} .ghost-row{display:flex;align-items:center;gap:12px} .ghost-dot{width:16px;height:16px;border-radius:999px;border:1px solid #e3eaf3} .ghost-row .ghost-line{flex:1;margin:0} .ghost-row:last-child .ghost-line{flex:.67} @keyframes pulse{0%,100%{opacity:.55}50%{opacity:1}} ` const SURVEY_OPENING_PAGE_MARKUP = `
由壹点「素材库」
考核调研

正在打开问卷

正在打开问卷...

准备问卷内容 马上进入
` function getLoginSetCookies(source: Response): string[] { const getSetCookie = (source.headers as any).getSetCookie const cookies = typeof getSetCookie === 'function' ? getSetCookie.call(source.headers) : [source.headers.get('set-cookie')].filter(Boolean) return cookies.filter((cookie: unknown): cookie is string => typeof cookie === 'string' && cookie.length > 0) } function stringifyForInlineScript(value: string) { return JSON.stringify(value).replace(/ 正在打开问卷 ${SURVEY_OPENING_PAGE_MARKUP} `, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', }, }, ) const cookieNames = appendSetCookies(response, cookies) console.log('[Dingtalk H5] 已在免登落地页写入会话Cookie:', { redirectPath, redirectUrl, cookieNames, requestHeaders: getRequestHeaderSnapshot(request), }) return response } function createPerformanceSurveySessionTransferPage({ request, redirectPath, token, }: { request: NextRequest redirectPath: string token: string }) { const redirectUrl = buildPublicUrl(redirectPath, request) console.log('[Dingtalk H5] 已生成考核调研钉钉访问凭证:', { redirectPath, redirectUrl, requestHeaders: getRequestHeaderSnapshot(request), }) const response = new NextResponse( ` 正在打开问卷 ${SURVEY_OPENING_PAGE_MARKUP} `, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', }, }, ) // 页面首跳发生在浏览器导航中,不能依赖自定义请求头。这个短期、限定路径的 // HttpOnly Cookie 只负责让 middleware 放行绩效页面,数据接口仍会重新验签。 response.cookies.set(DINGTALK_PERFORMANCE_SESSION_COOKIE, token, { httpOnly: true, sameSite: 'lax', secure: resolveSecureAuthCookieFromRequest(request), path: '/performance-survey', maxAge: 12 * 60 * 60, }) response.cookies.set(DINGTALK_PERFORMANCE_SESSION_API_COOKIE, token, { httpOnly: true, sameSite: 'lax', secure: resolveSecureAuthCookieFromRequest(request), path: '/api', maxAge: 12 * 60 * 60, }) response.cookies.set(DINGTALK_PERFORMANCE_SESSION_HINT_COOKIE, '1', { httpOnly: false, sameSite: 'lax', secure: resolveSecureAuthCookieFromRequest(request), path: '/performance-survey', maxAge: 12 * 60 * 60, }) return response } function createPublicRedirectResponse({ request, redirectPath, }: { request: NextRequest redirectPath: string }) { const redirectUrl = buildPublicUrl(redirectPath, request) const response = NextResponse.redirect(redirectUrl, { status: 303 }) response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') return response } function createH5LoginErrorPage(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', }, }, ) } async function readH5LoginCode(request: NextRequest) { assertBoundedH5LoginBody(request) const contentType = request.headers.get('content-type') || '' if (contentType.includes('application/json')) { const body = await request.json().catch(() => ({})) return String(body?.code || '').trim() } if ( contentType.includes('application/x-www-form-urlencoded') || contentType.includes('multipart/form-data') ) { const body = await request.formData().catch(() => null) return String(body?.get('code') || '').trim() } const body = await request.text().catch(() => '') return new URLSearchParams(body).get('code')?.trim() || '' } function assertBoundedH5LoginBody(request: NextRequest): void { const rawContentLength = request.headers.get('content-length') if (!rawContentLength || !/^\d+$/.test(rawContentLength.trim())) { throw new H5LoginRequestError( '登录请求缺少有效的 Content-Length', 411, 'CONTENT_LENGTH_REQUIRED', ) } const contentLength = Number(rawContentLength) if (!Number.isSafeInteger(contentLength) || contentLength < 0) { throw new H5LoginRequestError( '登录请求的 Content-Length 无效', 411, 'CONTENT_LENGTH_REQUIRED', ) } if (contentLength > H5_LOGIN_MAX_BODY_BYTES) { throw new H5LoginRequestError( '登录请求内容过大', 413, 'REQUEST_TOO_LARGE', ) } } function isHtmlNavigationRequest(request: NextRequest) { const accept = request.headers.get('accept') || '' const fetchMode = request.headers.get('sec-fetch-mode') || '' return accept.includes('text/html') || fetchMode === 'navigate' } async function handleH5LoginPage(request: NextRequest, code: string) { const redirectPath = normalizeInternalRedirectPath( request.nextUrl.searchParams.get('redirect'), '/performance-survey', ) const source = request.nextUrl.searchParams.get('source') || '' console.log('[Dingtalk H5] 收到免登入口请求:', { hasCode: Boolean(code), redirectPath, source, requestHeaders: getRequestHeaderSnapshot(request), }) if (!code) { return createH5LoginErrorPage('暂时无法打开页面,请从最新通知重新进入。') } try { if (redirectPath.startsWith('/performance-survey')) { const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || null const user = await authorizeDingtalkH5UserForNextAuth(code, { appSource: 'workNotice', clientIp, allowRestrictedPerformanceAccess: true, }) const performanceScope = resolveDingtalkPerformanceSessionScope(redirectPath) if (!performanceScope) { return createH5LoginErrorPage('当前通知没有可验证的考核调研项,请从最新的待填写通知重新进入。') } const token = createDingtalkPerformanceSessionToken( user, performanceScope, ) return createPerformanceSurveySessionTransferPage({ request, redirectPath, token }) } const loginResponse = await loginDingtalkUserByH5AuthCode(request, code, { appSource: 'workNotice', }) const data = await loginResponse.json().catch(() => null) if (!loginResponse.ok || !data?.success) { console.warn('[Dingtalk H5] 免登登录未成功:', { status: loginResponse.status, redirectPath, message: data?.message || '', }) return createH5LoginErrorPage('暂时无法打开页面,请稍后重试或联系管理员。') } if (data.needCompleteProfile) { const userInfo = encodeURIComponent(JSON.stringify(data.user || {})) const redirectResponse = createPublicRedirectResponse({ request, redirectPath: `/complete-profile?userInfo=${userInfo}`, }) appendSetCookies(redirectResponse, getLoginSetCookies(loginResponse)) return redirectResponse } return createSessionTicketTransferPage({ request, redirectPath, loginResponse }) } catch (error: any) { console.error('钉钉H5免登页面跳转失败:', error) return createH5LoginErrorPage('暂时无法打开页面,请稍后重试或联系管理员。') } } export async function GET(request: NextRequest) { const code = String( request.nextUrl.searchParams.get('code') || request.nextUrl.searchParams.get('authCode') || '', ).trim() return handleH5LoginPage(request, code) } export async function POST(request: NextRequest) { try { const code = await readH5LoginCode(request) if (isHtmlNavigationRequest(request)) { return handleH5LoginPage(request, code) } if (!code) { return NextResponse.json( { success: false, message: '缺少钉钉免登授权码' }, { status: 400 }, ) } 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) { console.error('钉钉H5免登失败:', error) if (error instanceof H5LoginRequestError) { return NextResponse.json( { success: false, message: error.message, code: error.code }, { status: error.status }, ) } return NextResponse.json( { success: false, message: error.message || '钉钉免登失败,请稍后再试', }, { status: 500 }, ) } }