486 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Session | null> {
// 钉钉工作通知会话必须优先于浏览器里可能残留的素材库/品牌中心会话。
// 否则用户从他人的评分通知进入时,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)
}
}