import { getServerSession } from 'next-auth' import type { Session } from 'next-auth' import { NextResponse } from 'next/server' import { authOptions } from '@/lib/auth' import { DINGTALK_PERFORMANCE_SESSION_HEADER, type DingtalkPerformanceSessionScope, verifyDingtalkPerformanceSessionToken, } from '@/lib/dingtalk-performance-session' import { DINGTALK_PERFORMANCE_SESSION_API_COOKIE, DINGTALK_PERFORMANCE_SESSION_COOKIE, } from '@/lib/dingtalk-performance-session-constants' import { PerformanceSurveyApiError, createPerformanceSurveyWorkItemFromRequest, createPerformanceSurvey, cancelPerformanceSurvey, exportPerformanceSurveyCsv, getPerformanceSurveyDashboard, getPerformanceSurveyDashboardExport, getPerformanceSurveyDashboardDetail, getPerformanceSurveyAdminConfiguration, getPerformanceSurveyDetail, getPerformanceSurveyMe, getPerformanceSurveyNotificationStatus, getPerformanceSurveyResults, getPerformanceSurveyTemplates, getPerformanceSurveyPositionGroups, listPerformanceSurveyDepartments, listPerformanceSurveyNotifications, listPerformanceSurveys, retryPerformanceSurveyNotificationById, runPerformanceSurveyNotificationMaintenance, searchPerformanceSurveyEmployees, submitPerformanceSurveyWorkItemScores, submitPerformanceSurveyWorkItemScore, submitPerformanceSurveyResponse, previewPerformanceSurveyEmployeeProfiles, syncPerformanceSurveyEmployeeProfiles, updatePerformanceSurvey, savePerformanceSurveyAdminPositionGroup, publishPerformanceSurveyAdminPositionGroup, savePerformanceSurveyAdminTemplate, copyPerformanceSurveyAdminTemplateVersion, publishPerformanceSurveyAdminTemplateVersion, archivePerformanceSurveyAdminPositionAssignment, copyPerformanceSurveyAdminPositionGroup, createPerformanceSurveyAdminPositionAssignments, createPerformanceSurveyAdminPositionGroup, listPerformanceSurveyAdminConfigAuditLogs, listPerformanceSurveyAdminPositionAssignments, previewPerformanceSurveyAdminPositionGroup, removePerformanceSurveyAdminPositionGroup, removePerformanceSurveyAdminTemplate, removePerformanceSurveyPublicDashboardAssessment, updatePerformanceSurveyAdminPositionAssignment, } from '@/lib/performance-survey-service' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' export const revalidate = 0 type RouteContext = { params: { path?: string[] } } function ok(data: unknown, init?: ResponseInit) { return NextResponse.json({ success: true, data }, init) } function errorResponse(error: unknown) { if (error instanceof PerformanceSurveyApiError) { return NextResponse.json({ success: false, error: error.message }, { status: error.status }) } if (error && typeof error === 'object' && typeof (error as { status?: unknown }).status === 'number') { return NextResponse.json( { success: false, error: error instanceof Error ? error.message : '请求失败' }, { status: (error as { status: number }).status }, ) } const message = error instanceof Error ? error.message : '服务器错误' console.error('[performance-survey] API error:', error) return NextResponse.json({ success: false, error: message }, { status: 500 }) } function readDingtalkPerformanceTokens(request: Request) { const tokens: string[] = [] const add = (value: string | null | undefined) => { const token = String(value || '').trim() if (token && !tokens.includes(token)) tokens.push(token) } const headerToken = request.headers.get(DINGTALK_PERFORMANCE_SESSION_HEADER) add(headerToken) const authorization = request.headers.get('authorization') || '' const match = authorization.match(/^Bearer\s+(.+)$/i) add(match?.[1]) const cookieHeader = request.headers.get('cookie') || '' const cookies = cookieHeader .split(';') .map((part) => part.trim()) const readCookie = (name: string) => { const cookie = cookies.find((part) => part.startsWith(`${name}=`)) if (!cookie) return null const separator = cookie.indexOf('=') return separator >= 0 ? cookie.slice(separator + 1).trim() || null : null } // 新 Cookie 优先;旧版本可能还残留同名 API Cookie,不能让它遮蔽新凭证。 add(readCookie(DINGTALK_PERFORMANCE_SESSION_API_COOKIE)) add(readCookie(DINGTALK_PERFORMANCE_SESSION_COOKIE)) return tokens } async function getSession(request: Request): Promise { // 钉钉工作通知会话必须优先于浏览器里可能残留的素材库/品牌中心会话。 // 否则用户从他人的评分通知进入时,API 会把旧的普通登录身份当成当前评分人。 const performanceTokens = readDingtalkPerformanceTokens(request) for (const performanceToken of performanceTokens) { const payload = verifyDingtalkPerformanceSessionToken(performanceToken) if (!payload) continue return { user: { id: payload.userId, email: payload.email, name: payload.name, avatar: payload.avatar || null, role: payload.role, department: payload.department || '', managedDepartment: payload.managedDepartment || null, isSuperAdmin: payload.isSuperAdmin ?? false, }, expires: new Date(payload.exp * 1000).toISOString(), dingtalkPerformanceScope: payload.scope, } as Session & { dingtalkPerformanceScope: DingtalkPerformanceSessionScope } } // 请求带有绩效凭证但全部无效时,不降级到普通素材库会话,避免把通知身份错配。 if (performanceTokens.length > 0) return null return getServerSession(authOptions) } function getDingtalkPerformanceScope(session: Session | null) { return (session as (Session & { dingtalkPerformanceScope?: DingtalkPerformanceSessionScope }) | null)?.dingtalkPerformanceScope || null } function assertScopedGetAccess( session: Session | null, path: string[], searchParams: URLSearchParams, ) { const scope = getDingtalkPerformanceScope(session) if (!scope || scope.kind === 'workspace') return if (path.length === 0 || (path.length === 1 && path[0] === 'me')) return const targetId = searchParams.get('targetId')?.trim() || '' if ( scope.kind === 'assignment' && path.length === 2 && path[0] === 'surveys' && path[1] === scope.surveyId && targetId === scope.targetId ) { return } const isScopedResultsPath = scope.kind === 'results' && ( (path.length === 2 && path[0] === 'results' && path[1] === scope.surveyId) || (path.length === 3 && path[0] === 'surveys' && path[1] === scope.surveyId && path[2] === 'results') ) if (isScopedResultsPath && (!scope.targetId || targetId === scope.targetId)) return throw new PerformanceSurveyApiError('当前钉钉通知仅允许访问对应的考核调研项', 403) } function assertScopedPostAccess(session: Session | null, path: string[]) { const scope = getDingtalkPerformanceScope(session) if (!scope || scope.kind === 'workspace') return null if (scope.kind !== 'assignment') { throw new PerformanceSurveyApiError('当前钉钉通知不包含评分权限', 403) } const isScopedSubmission = path[0] === 'surveys' && path[1] === scope.surveyId && ( (path.length === 3 && (path[2] === 'responses' || path[2] === 'work-item-scores')) || (path.length === 5 && path[2] === 'work-items' && Boolean(path[3]) && path[4] === 'scores') ) if (!isScopedSubmission) { throw new PerformanceSurveyApiError('当前钉钉通知仅允许提交对应的考核调研项', 403) } return scope } function assertScopedAdministrativeMutation(session: Session | null) { const scope = getDingtalkPerformanceScope(session) if (scope && scope.kind !== 'workspace') { throw new PerformanceSurveyApiError('当前钉钉通知不包含管理权限', 403) } } function getPath(context: RouteContext) { return context.params.path || [] } export async function GET(request: Request, context: RouteContext) { try { const session = await getSession(request) const path = getPath(context) const url = new URL(request.url) assertScopedGetAccess(session, path, url.searchParams) if (path.length === 0 || path[0] === 'me') { return ok(await getPerformanceSurveyMe(session)) } if (path[0] === 'templates') { return ok(await getPerformanceSurveyTemplates(session, url.searchParams)) } if (path[0] === 'position-groups') { return ok(await getPerformanceSurveyPositionGroups(session, url.searchParams)) } if (path[0] === 'employees') { return ok(await searchPerformanceSurveyEmployees(session, url.searchParams)) } if (path[0] === 'departments') { return ok(await listPerformanceSurveyDepartments(session, url.searchParams)) } if (path[0] === 'dashboard' && path[1] === 'export') { return ok(await getPerformanceSurveyDashboardExport(session, url.searchParams)) } if (path[0] === 'dashboard' && path[1] === 'detail') { return ok(await getPerformanceSurveyDashboardDetail(session, url.searchParams)) } if (path[0] === 'dashboard') { return ok(await getPerformanceSurveyDashboard(session, url.searchParams)) } if (path[0] === 'surveys' && path.length === 1) { return ok(await listPerformanceSurveys(session, url.searchParams)) } if (path[0] === 'surveys' && path[1] && path.length === 2) { return ok(await getPerformanceSurveyDetail(session, path[1], url.searchParams.get('targetId'))) } if (path[0] === 'surveys' && path[1] && path[2] === 'results') { return ok(await getPerformanceSurveyResults(session, path[1], url.searchParams)) } if (path[0] === 'results' && path[1]) { return ok(await getPerformanceSurveyResults(session, path[1], url.searchParams)) } if (path[0] === 'admin' && path[1] === 'notifications' && path[2] === 'status') { return ok(await getPerformanceSurveyNotificationStatus(session)) } if ( path[0] === 'admin' && path[1] === 'position-groups' && path[2] && path[3] === 'preview' && path.length === 4 ) { return ok(await previewPerformanceSurveyAdminPositionGroup(session, path[2], url.searchParams)) } if (path[0] === 'admin' && path[1] === 'position-assignments' && path.length === 2) { return ok(await listPerformanceSurveyAdminPositionAssignments(session, url.searchParams)) } if (path[0] === 'admin' && path[1] === 'config-audit-logs' && path.length === 2) { return ok(await listPerformanceSurveyAdminConfigAuditLogs(session, url.searchParams)) } if (path[0] === 'admin' && path[1] === 'config') { return ok(await getPerformanceSurveyAdminConfiguration(session, url.searchParams)) } if (path[0] === 'admin' && path[1] === 'notifications') { return ok(await listPerformanceSurveyNotifications(session, url.searchParams)) } if (path[0] === 'admin' && path[1] === 'export') { const csv = await exportPerformanceSurveyCsv(session) return new NextResponse(csv, { headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': 'attachment; filename="performance-survey-export.csv"', }, }) } return NextResponse.json({ success: false, error: '接口不存在' }, { status: 404 }) } catch (error) { return errorResponse(error) } } export async function POST(request: Request, context: RouteContext) { try { const session = await getSession(request) const path = getPath(context) const assignmentScope = assertScopedPostAccess(session, path) if (path[0] === 'surveys' && path[1] && path[2] === 'work-items' && path.length === 3) { return ok(await createPerformanceSurveyWorkItemFromRequest(session, path[1], request), { status: 201 }) } const body = await request.json().catch(() => ({})) if (path[0] === 'surveys' && path.length === 1) { return ok(await createPerformanceSurvey(session, body), { status: 201 }) } if (path[0] === 'surveys' && path[1] && path[2] === 'cancel' && path.length === 3) { return ok(await cancelPerformanceSurvey(session, path[1], body)) } if (path[0] === 'surveys' && path[1] && path[2] === 'responses') { return ok(await submitPerformanceSurveyResponse(session, path[1], body, assignmentScope?.targetId)) } if (path[0] === 'surveys' && path[1] && path[2] === 'work-item-scores' && path.length === 3) { return ok(await submitPerformanceSurveyWorkItemScores(session, path[1], body, assignmentScope?.targetId)) } if (path[0] === 'surveys' && path[1] && path[2] === 'work-items' && path[3] && path[4] === 'scores') { return ok(await submitPerformanceSurveyWorkItemScore( session, path[1], path[3], body, assignmentScope?.targetId, )) } if ( path[0] === 'admin' && path[1] === 'sync-employees' && path[2] === 'preview' && path.length === 3 ) { return ok(await previewPerformanceSurveyEmployeeProfiles(session, body)) } if (path[0] === 'admin' && path[1] === 'sync-employees' && path.length === 2) { return ok(await syncPerformanceSurveyEmployeeProfiles(session, body)) } if (path[0] === 'admin' && path[1] === 'position-groups' && path.length === 2) { return ok(await createPerformanceSurveyAdminPositionGroup(session, body), { status: 201 }) } if ( path[0] === 'admin' && path[1] === 'position-groups' && path[2] && path[3] === 'copy' && path.length === 4 ) { return ok(await copyPerformanceSurveyAdminPositionGroup(session, path[2], body), { status: 201 }) } if ( path[0] === 'admin' && path[1] === 'position-groups' && path[2] && path[3] === 'publish' && path.length === 4 ) { return ok(await publishPerformanceSurveyAdminPositionGroup(session, path[2], body)) } if (path[0] === 'admin' && path[1] === 'position-assignments' && path.length === 2) { return ok(await createPerformanceSurveyAdminPositionAssignments(session, body), { status: 201 }) } if (path[0] === 'admin' && path[1] === 'templates' && path.length === 2) { return ok(await savePerformanceSurveyAdminTemplate(session, body), { status: 201 }) } if (path[0] === 'admin' && path[1] === 'templates' && path[2] && path[3] === 'copy') { return ok(await copyPerformanceSurveyAdminTemplateVersion(session, path[2]), { status: 201 }) } if (path[0] === 'admin' && path[1] === 'templates' && path[2] && path[3] === 'publish') { return ok(await publishPerformanceSurveyAdminTemplateVersion(session, path[2], body)) } if (path[0] === 'admin' && path[1] === 'notifications' && path[2] === 'maintenance') { return ok(await runPerformanceSurveyNotificationMaintenance(session)) } if (path[0] === 'admin' && path[1] === 'notifications' && path[2] && path[3] === 'retry') { return ok(await retryPerformanceSurveyNotificationById(session, path[2])) } return NextResponse.json({ success: false, error: '接口不存在' }, { status: 404 }) } catch (error) { return errorResponse(error) } } export async function PATCH(request: Request, context: RouteContext) { try { const session = await getSession(request) assertScopedAdministrativeMutation(session) const path = getPath(context) const body = await request.json().catch(() => ({})) if (path[0] === 'surveys' && path[1] && path.length === 2) { return ok(await updatePerformanceSurvey(session, path[1], body)) } if (path[0] === 'admin' && path[1] === 'position-groups' && path[2] && path.length === 3) { return ok(await savePerformanceSurveyAdminPositionGroup(session, path[2], body)) } if (path[0] === 'admin' && path[1] === 'templates' && path[2] && path.length === 3) { return ok(await savePerformanceSurveyAdminTemplate(session, { ...body, versionId: path[2] })) } if (path[0] === 'admin' && path[1] === 'position-assignments' && path[2] && path.length === 3) { return ok(await updatePerformanceSurveyAdminPositionAssignment(session, path[2], body)) } return NextResponse.json({ success: false, error: '接口不存在' }, { status: 404 }) } catch (error) { return errorResponse(error) } } export async function DELETE(request: Request, context: RouteContext) { try { const session = await getSession(request) assertScopedAdministrativeMutation(session) const path = getPath(context) const body = await request.json().catch(() => ({})) if (path[0] === 'admin' && path[1] === 'position-groups' && path[2] && path.length === 3) { return ok(await removePerformanceSurveyAdminPositionGroup(session, path[2], body)) } if (path[0] === 'admin' && path[1] === 'templates' && path[2] && path.length === 3) { return ok(await removePerformanceSurveyAdminTemplate(session, path[2], body)) } if (path[0] === 'admin' && path[1] === 'position-assignments' && path[2] && path.length === 3) { return ok(await archivePerformanceSurveyAdminPositionAssignment(session, path[2], body)) } if ( path[0] === 'dashboard' && path[1] === 'public' && path[2] === 'assessments' && path[3] && path.length === 4 ) { return ok(await removePerformanceSurveyPublicDashboardAssessment(session, path[3], body)) } return NextResponse.json({ success: false, error: '接口不存在' }, { status: 404 }) } catch (error) { return errorResponse(error) } }