import { NextRequest, NextResponse } from 'next/server'
import {
authorizeDingtalkH5UserForNextAuth,
loginDingtalkUserByH5AuthCode,
} from '@/lib/dingtalk-login'
import { createDingtalkPerformanceSessionToken } from '@/lib/dingtalk-performance-session'
import { getPublicAppOrigin } from '@/lib/public-app-url'
import { normalizeInternalRedirectPath } from '@/lib/safe-redirect'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
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),
})
return 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',
},
},
)
}
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) {
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 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,
})
const token = createDingtalkPerformanceSessionToken(user)
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)
return NextResponse.json(
{
success: false,
message: error.message || '钉钉免登失败,请稍后再试',
},
{ status: 500 },
)
}
}