486 lines
17 KiB
TypeScript
486 lines
17 KiB
TypeScript
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)
|
||
}
|
||
}
|