diff --git a/app/api/dingtalk/h5-login/route.test.ts b/app/api/dingtalk/h5-login/route.test.ts index 69025de..fd83002 100644 --- a/app/api/dingtalk/h5-login/route.test.ts +++ b/app/api/dingtalk/h5-login/route.test.ts @@ -14,6 +14,19 @@ jest.mock('@/lib/dingtalk-login', () => ({ jest.mock('@/lib/dingtalk-performance-session', () => ({ createDingtalkPerformanceSessionToken: jest.fn(), + resolveDingtalkPerformanceSessionScope: jest.fn((redirectPath: string) => { + const url = new URL(redirectPath, 'https://performance-survey.test') + const segments = url.pathname.split('/').filter(Boolean) + const surveyId = segments[2] || '' + const targetId = url.searchParams.get('targetId')?.trim() || '' + if (segments[0] === 'performance-survey' && segments[1] === 'survey' && surveyId && targetId) { + return { kind: 'assignment', surveyId, targetId } + } + if (segments[0] === 'performance-survey' && segments[1] === 'results' && surveyId) { + return { kind: 'results', surveyId, targetId: targetId || null } + } + return null + }), })) jest.mock('@/lib/public-app-url', () => ({ @@ -21,7 +34,9 @@ jest.mock('@/lib/public-app-url', () => ({ })) jest.mock('@/lib/safe-redirect', () => ({ - normalizeInternalRedirectPath: jest.fn((_value: unknown, fallback: string) => fallback), + normalizeInternalRedirectPath: jest.fn((value: unknown, fallback: string) => ( + typeof value === 'string' && value.startsWith('/') ? value : fallback + )), })) describe('DingTalk H5 public login body limit', () => { @@ -121,6 +136,7 @@ describe('DingTalk H5 public login body limit', () => { expect(authorizeDingtalkH5UserForNextAuth).toHaveBeenCalledWith('short-code', { appSource: 'workNotice', clientIp: null, + allowRestrictedPerformanceAccess: true, }) }) }) diff --git a/app/api/dingtalk/h5-login/route.ts b/app/api/dingtalk/h5-login/route.ts index 745956e..b39ee07 100644 --- a/app/api/dingtalk/h5-login/route.ts +++ b/app/api/dingtalk/h5-login/route.ts @@ -3,7 +3,10 @@ import { authorizeDingtalkH5UserForNextAuth, loginDingtalkUserByH5AuthCode, } from '@/lib/dingtalk-login' -import { createDingtalkPerformanceSessionToken } from '@/lib/dingtalk-performance-session' +import { + createDingtalkPerformanceSessionToken, + resolveDingtalkPerformanceSessionScope, +} from '@/lib/dingtalk-performance-session' import { DINGTALK_PERFORMANCE_SESSION_COOKIE } from '@/lib/dingtalk-performance-session-constants' import { getPublicAppOrigin } from '@/lib/public-app-url' import { normalizeInternalRedirectPath } from '@/lib/safe-redirect' @@ -435,8 +438,16 @@ async function handleH5LoginPage(request: NextRequest, code: string) { const user = await authorizeDingtalkH5UserForNextAuth(code, { appSource: 'workNotice', clientIp, + allowRestrictedPerformanceAccess: true, }) - const token = createDingtalkPerformanceSessionToken(user) + const performanceScope = resolveDingtalkPerformanceSessionScope(redirectPath) + if (!performanceScope) { + return createH5LoginErrorPage('当前通知没有可验证的考核调研项,请从最新的待填写通知重新进入。') + } + const token = createDingtalkPerformanceSessionToken( + user, + performanceScope, + ) return createPerformanceSurveySessionTransferPage({ request, redirectPath, token }) } diff --git a/app/api/performance-survey/[...path]/route.ts b/app/api/performance-survey/[...path]/route.ts index 885925e..7a6d8ca 100644 --- a/app/api/performance-survey/[...path]/route.ts +++ b/app/api/performance-survey/[...path]/route.ts @@ -4,6 +4,7 @@ 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_COOKIE } from '@/lib/dingtalk-performance-session-constants' @@ -103,28 +104,90 @@ function readDingtalkPerformanceToken(request: Request) { } async function getSession(request: Request): Promise { - const session = await getServerSession(authOptions) - if ((session?.user as any)?.id) { - return session + // 钉钉工作通知会话必须优先于浏览器里可能残留的素材库/品牌中心会话。 + // 否则用户从他人的评分通知进入时,API 会把旧的普通登录身份当成当前评分人。 + const performanceToken = readDingtalkPerformanceToken(request) + if (performanceToken) { + const payload = verifyDingtalkPerformanceSessionToken(performanceToken) + if (!payload) return null + + 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 } } - const payload = verifyDingtalkPerformanceSessionToken(readDingtalkPerformanceToken(request)) - if (!payload) { - return session + 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 } - 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(), + 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) } } @@ -137,6 +200,7 @@ export async function GET(request: Request, context: RouteContext) { 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)) @@ -175,7 +239,7 @@ export async function GET(request: Request, context: RouteContext) { } if (path[0] === 'surveys' && path[1] && path.length === 2) { - return ok(await getPerformanceSurveyDetail(session, path[1])) + return ok(await getPerformanceSurveyDetail(session, path[1], url.searchParams.get('targetId'))) } if (path[0] === 'surveys' && path[1] && path[2] === 'results') { @@ -236,6 +300,7 @@ 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 }) @@ -252,15 +317,21 @@ export async function POST(request: Request, context: RouteContext) { } if (path[0] === 'surveys' && path[1] && path[2] === 'responses') { - return ok(await submitPerformanceSurveyResponse(session, path[1], body)) + 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)) + 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)) + return ok(await submitPerformanceSurveyWorkItemScore( + session, + path[1], + path[3], + body, + assignmentScope?.targetId, + )) } if ( @@ -333,6 +404,7 @@ export async function POST(request: Request, context: RouteContext) { 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(() => ({})) @@ -361,6 +433,7 @@ export async function PATCH(request: Request, context: RouteContext) { 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(() => ({})) diff --git a/app/dingtalk-entry/DingtalkEntryClient.tsx b/app/dingtalk-entry/DingtalkEntryClient.tsx index ac2d472..e460936 100644 --- a/app/dingtalk-entry/DingtalkEntryClient.tsx +++ b/app/dingtalk-entry/DingtalkEntryClient.tsx @@ -144,7 +144,12 @@ export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEn try { setMessage('正在打开页面...') - if (await hasActiveSession()) { + // 绩效通知必须重新走 workNotice 免登,不能因为浏览器里已有素材库会话 + // 就直接打开普通会话。否则外部人员会被素材库会话带回默认素材库页面, + // 也无法把本次通知对应的 surveyId/targetId 绑定到评分权限。 + const isPerformanceSurveyNotice = redirectPath === '/performance-survey' + || redirectPath.startsWith('/performance-survey/') + if (!isPerformanceSurveyNotice && await hasActiveSession()) { window.location.replace(redirectPath) return } diff --git a/app/performance-survey/[[...path]]/page.tsx b/app/performance-survey/[[...path]]/page.tsx index bf57b4d..21d3c9c 100644 --- a/app/performance-survey/[[...path]]/page.tsx +++ b/app/performance-survey/[[...path]]/page.tsx @@ -5099,7 +5099,10 @@ function SurveySubmit({ surveyId, session, canCreateSurvey }: { surveyId: string const router = useRouter() const searchParams = useSearchParams() const targetIdParam = searchParams.get('targetId') - const detail = useApi(`/surveys/${surveyId}`, [surveyId]) + const detailPath = targetIdParam + ? `/surveys/${surveyId}?targetId=${encodeURIComponent(targetIdParam)}` + : `/surveys/${surveyId}` + const detail = useApi(detailPath, [detailPath]) const [targetId, setTargetId] = useState(targetIdParam || '') const [scores, setScores] = useState>({}) const [comment, setComment] = useState('') diff --git a/lib/__tests__/performance-survey-sync-route.test.ts b/lib/__tests__/performance-survey-sync-route.test.ts index 65b78a6..fc998f9 100644 --- a/lib/__tests__/performance-survey-sync-route.test.ts +++ b/lib/__tests__/performance-survey-sync-route.test.ts @@ -1,11 +1,14 @@ /** @jest-environment node */ import { getServerSession } from 'next-auth' -import { POST } from '@/app/api/performance-survey/[...path]/route' +import { GET, POST } from '@/app/api/performance-survey/[...path]/route' import { + getPerformanceSurveyDetail, previewPerformanceSurveyEmployeeProfiles, + submitPerformanceSurveyResponse, syncPerformanceSurveyEmployeeProfiles, } from '@/lib/performance-survey-service' +import { verifyDingtalkPerformanceSessionToken } from '@/lib/dingtalk-performance-session' jest.mock('next-auth', () => ({ getServerSession: jest.fn(), @@ -29,7 +32,9 @@ jest.mock('@/lib/performance-survey-service', () => ({ this.status = status } }, + getPerformanceSurveyDetail: jest.fn(), previewPerformanceSurveyEmployeeProfiles: jest.fn(), + submitPerformanceSurveyResponse: jest.fn(), syncPerformanceSurveyEmployeeProfiles: jest.fn(), })) @@ -40,6 +45,91 @@ describe('performance survey employee sync route', () => { user: { id: 'admin-1' }, expires: '2099-01-01T00:00:00.000Z', }) + ;(verifyDingtalkPerformanceSessionToken as jest.Mock).mockReturnValue(null) + }) + + test('钉钉评分凭证优先于浏览器普通会话并绑定指定评价对象', async () => { + ;(verifyDingtalkPerformanceSessionToken as jest.Mock).mockReturnValue({ + userId: 'external-1', + email: 'external@example.com', + name: '跨部门同事', + avatar: null, + role: 'USER', + department: '运营中心', + managedDepartment: null, + isSuperAdmin: false, + exp: Math.floor(Date.now() / 1000) + 3600, + scope: { kind: 'assignment', surveyId: 'survey-1', targetId: 'target-1' }, + }) + ;(getPerformanceSurveyDetail as jest.Mock).mockResolvedValue({ id: 'survey-1' }) + + const response = await GET( + new Request('https://example.test/api/performance-survey/surveys/survey-1?targetId=target-1', { + headers: { 'x-performance-token': 'signed-token' }, + }), + { params: { path: ['surveys', 'survey-1'] } }, + ) + + expect(response.status).toBe(200) + expect(getServerSession).not.toHaveBeenCalled() + expect(getPerformanceSurveyDetail).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ id: 'external-1' }) }), + 'survey-1', + 'target-1', + ) + }) + + test('钉钉评分凭证不能读取通知以外的评价对象', async () => { + ;(verifyDingtalkPerformanceSessionToken as jest.Mock).mockReturnValue({ + userId: 'external-1', + email: 'external@example.com', + name: '跨部门同事', + role: 'USER', + exp: Math.floor(Date.now() / 1000) + 3600, + scope: { kind: 'assignment', surveyId: 'survey-1', targetId: 'target-1' }, + }) + + const response = await GET( + new Request('https://example.test/api/performance-survey/surveys/survey-1?targetId=target-other', { + headers: { 'x-performance-token': 'signed-token' }, + }), + { params: { path: ['surveys', 'survey-1'] } }, + ) + + expect(response.status).toBe(403) + expect(getPerformanceSurveyDetail).not.toHaveBeenCalled() + }) + + test('钉钉评分凭证提交时把通知评价对象传给服务层复核', async () => { + ;(verifyDingtalkPerformanceSessionToken as jest.Mock).mockReturnValue({ + userId: 'external-1', + email: 'external@example.com', + name: '跨部门同事', + role: 'USER', + exp: Math.floor(Date.now() / 1000) + 3600, + scope: { kind: 'assignment', surveyId: 'survey-1', targetId: 'target-1' }, + }) + ;(submitPerformanceSurveyResponse as jest.Mock).mockResolvedValue({ id: 'response-1' }) + + const response = await POST( + new Request('https://example.test/api/performance-survey/surveys/survey-1/responses', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-performance-token': 'signed-token', + }, + body: JSON.stringify({ targetId: 'target-1', scores: {} }), + }), + { params: { path: ['surveys', 'survey-1', 'responses'] } }, + ) + + expect(response.status).toBe(200) + expect(submitPerformanceSurveyResponse).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ id: 'external-1' }) }), + 'survey-1', + { targetId: 'target-1', scores: {} }, + 'target-1', + ) }) test('preview 子路径只执行只读预检', async () => { diff --git a/lib/dingtalk-login.ts b/lib/dingtalk-login.ts index e398002..3875b32 100644 --- a/lib/dingtalk-login.ts +++ b/lib/dingtalk-login.ts @@ -241,7 +241,19 @@ function warmPerformanceSurveyProfile(user: { username: string realName: string | null }) { - void ensurePerformanceSurveyEmployeeProfileForUser({ + void preparePerformanceSurveyProfile(user).catch((error) => { + console.warn('[performance-survey] 钉钉登录后预热人员档案或建立绑定失败:', error) + }) +} + +async function preparePerformanceSurveyProfile(user: { + id: string + dingtalkUserId: string | null + email: string + username: string + realName: string | null +}) { + await ensurePerformanceSurveyEmployeeProfileForUser({ userId: user.id, dingtalkUserId: user.dingtalkUserId, email: user.email, @@ -249,14 +261,9 @@ function warmPerformanceSurveyProfile(user: { realName: user.realName, displayName: user.realName || user.username, }) - .then(() => - // 档案就绪后按钉钉唯一值建立账号与权威人员的正式绑定, - // 取代此前"每次用到时按姓名/邮箱现猜"的软匹配。 - ensureBindingByDingtalkUserId(user.id, user.dingtalkUserId), - ) - .catch((error) => { - console.warn('[performance-survey] 钉钉登录后预热人员档案或建立绑定失败:', error) - }) + // 档案就绪后按钉钉唯一值建立账号与权威人员的正式绑定, + // 取代此前"每次用到时按姓名/邮箱现猜"的软匹配。 + await ensureBindingByDingtalkUserId(user.id, user.dingtalkUserId) } export async function setDingtalkAuthenticatedSessionCookies( @@ -541,6 +548,7 @@ async function resolveDingtalkUserForSession( userId: string, userInfo: DingtalkUserInfo, clientIp?: string | null, + options?: { allowRestrictedPerformanceAccess?: boolean }, ): Promise { const displayName = String(userInfo.name || '').trim() if (!displayName) { @@ -557,7 +565,7 @@ async function resolveDingtalkUserForSession( where: { username: displayName }, }) - if (existingUserByName) { + if (existingUserByName && !options?.allowRestrictedPerformanceAccess) { console.warn( `[dingtalk-session] 拒绝按姓名自动合并身份:username=${displayName},` + `既有账号=${existingUserByName.id},` + @@ -570,6 +578,20 @@ async function resolveDingtalkUserForSession( console.log('创建新用户(使用钉钉姓名):', displayName) + // 评分通知只需要一个与钉钉唯一值绑定的受限身份。若素材库里已有同名账号, + // 不能自动合并,也不能因为 username 唯一约束把外部人员挡在评分入口外。 + // 为该受限身份生成不可用于普通登录的内部用户名,展示名仍使用钉钉返回姓名。 + let usernameToUse = displayName + if (existingUserByName) { + const suffix = userId.replace(/[^A-Za-z0-9_-]/g, '').slice(-12) || randomBytes(6).toString('hex') + usernameToUse = `${displayName}·${suffix}` + let collision = await prisma.user.findUnique({ where: { username: usernameToUse } }) + while (collision) { + usernameToUse = `${displayName}·${randomBytes(6).toString('hex')}` + collision = await prisma.user.findUnique({ where: { username: usernameToUse } }) + } + } + let emailToUse = userInfo.email || generateRandomEmail() const emailExists = await prisma.user.findUnique({ where: { email: emailToUse } }) if (emailExists) { @@ -578,7 +600,7 @@ async function resolveDingtalkUserForSession( user = await prisma.user.create({ data: { - username: displayName, + username: usernameToUse, email: emailToUse, password: await createDingtalkOnlyPasswordHash(), realName: displayName, @@ -588,13 +610,17 @@ async function resolveDingtalkUserForSession( role: 'USER', isActive: false, status: 'pending', - loginType: 'dingtalk', + loginType: options?.allowRestrictedPerformanceAccess ? 'dingtalk_work_notice' : 'dingtalk', dingtalkUserId: userId, }, }) - warmPerformanceSurveyProfile(user) - throw new Error('您的账号尚未激活,请联系管理员激活后再登录') + if (options?.allowRestrictedPerformanceAccess) { + warmPerformanceSurveyProfile(user) + } else { + warmPerformanceSurveyProfile(user) + throw new Error('您的账号尚未激活,请联系管理员激活后再登录') + } } else { console.log('更新现有用户:', user.username) user = await prisma.user.update({ @@ -610,6 +636,10 @@ async function resolveDingtalkUserForSession( let userStatus = (user as any).status || (user.isActive ? 'active' : 'pending') let isUserActive = userStatus === 'active' + if (userStatus === 'suspended' || userStatus === 'archived') { + throw new Error('您的账号已被暂停或归档,无法登录') + } + // 自动人员规则:花名册在册且在职 → 自动激活;并按 M/P 等级自动同步角色 if (!isUserActive) { try { @@ -630,12 +660,11 @@ async function resolveDingtalkUserForSession( } if (!isUserActive) { + if (!options?.allowRestrictedPerformanceAccess) { + warmPerformanceSurveyProfile(user) + throw new Error('您的账号尚未激活,请联系管理员激活后再登录') + } warmPerformanceSurveyProfile(user) - throw new Error('您的账号尚未激活,请联系管理员激活后再登录') - } - - if (userStatus === 'suspended' || userStatus === 'archived') { - throw new Error('您的账号已被暂停或归档,无法登录') } await prisma.user.update({ @@ -644,8 +673,7 @@ async function resolveDingtalkUserForSession( lastLoginAt: new Date(), lastLoginIp: clientIp || null, loginCount: { increment: 1 }, - status: 'active', - isActive: true, + ...(isUserActive ? { status: 'active', isActive: true } : {}), }, }).catch((error) => { console.error('记录登录审计信息失败:', error) @@ -668,7 +696,11 @@ async function resolveDingtalkUserForSession( export async function authorizeDingtalkH5UserForNextAuth( code: string, - options?: { appSource?: DingtalkLoginAppSource; clientIp?: string | null }, + options?: { + appSource?: DingtalkLoginAppSource + clientIp?: string | null + allowRestrictedPerformanceAccess?: boolean + }, ) { console.log('开始钉钉H5免登NextAuth登录流程') const appSource = options?.appSource || 'login' @@ -682,7 +714,9 @@ export async function authorizeDingtalkH5UserForNextAuth( const userInfo = await getUserInfo(userId, appAccessToken) console.log('获取用户信息成功:', userInfo.name) - return resolveDingtalkUserForSession(userId, userInfo, options?.clientIp || null) + return resolveDingtalkUserForSession(userId, userInfo, options?.clientIp || null, { + allowRestrictedPerformanceAccess: options?.allowRestrictedPerformanceAccess === true, + }) } export async function loginDingtalkUserByOAuthCode( diff --git a/lib/dingtalk-performance-session.ts b/lib/dingtalk-performance-session.ts index 94ab9fd..f9c50cd 100644 --- a/lib/dingtalk-performance-session.ts +++ b/lib/dingtalk-performance-session.ts @@ -5,6 +5,21 @@ export const DINGTALK_PERFORMANCE_SESSION_HEADER = 'x-performance-survey-dingtal export type DingtalkPerformanceRole = 'USER' | 'ADMIN' | 'DEPT_ADMIN' | 'SYSTEM_ADMIN' +export type DingtalkPerformanceSessionScope = + | { + kind: 'assignment' + surveyId: string + targetId: string + } + | { + kind: 'results' + surveyId: string + targetId?: string | null + } + | { + kind: 'workspace' + } + export type DingtalkPerformanceSessionPayload = { v: 1 purpose: 'performance-survey' @@ -16,6 +31,7 @@ export type DingtalkPerformanceSessionPayload = { department?: string | null managedDepartment?: string | null isSuperAdmin?: boolean | null + scope: DingtalkPerformanceSessionScope iat: number exp: number nonce: string @@ -81,7 +97,44 @@ function normalizeRole(role: string): DingtalkPerformanceRole { : 'USER' } -export function createDingtalkPerformanceSessionToken(user: DingtalkPerformanceUser) { +export function resolveDingtalkPerformanceSessionScope( + redirectPath: string, +): DingtalkPerformanceSessionScope | null { + try { + const url = new URL(redirectPath, 'https://performance-survey.local') + const segments = url.pathname.split('/').filter(Boolean) + const surveyId = segments[2] || '' + const targetId = url.searchParams.get('targetId')?.trim() || '' + + if ( + segments.length === 3 + && segments[0] === 'performance-survey' + && segments[1] === 'survey' + && surveyId + && targetId + ) { + return { kind: 'assignment', surveyId, targetId } + } + + if ( + segments.length === 3 + && segments[0] === 'performance-survey' + && segments[1] === 'results' + && surveyId + ) { + return { kind: 'results', surveyId, targetId: targetId || null } + } + } catch { + // redirectPath 已由入口层约束;异常值不能获得绩效工作区权限。 + } + + return null +} + +export function createDingtalkPerformanceSessionToken( + user: DingtalkPerformanceUser, + scope: DingtalkPerformanceSessionScope = { kind: 'workspace' }, +) { const now = Math.floor(Date.now() / 1000) const payload: DingtalkPerformanceSessionPayload = { v: 1, @@ -94,6 +147,7 @@ export function createDingtalkPerformanceSessionToken(user: DingtalkPerformanceU department: user.department || '', managedDepartment: user.managedDepartment || null, isSuperAdmin: user.isSuperAdmin ?? false, + scope, iat: now, exp: now + getTtlSeconds(), nonce: randomUUID(), @@ -122,6 +176,7 @@ export function verifyDingtalkPerformanceSessionToken(token: string | null | und !payload.name || !payload.role || normalizeRole(payload.role) !== payload.role || + !isValidScope(payload.scope) || typeof payload.exp !== 'number' || payload.exp <= now ) { @@ -133,3 +188,15 @@ export function verifyDingtalkPerformanceSessionToken(token: string | null | und return null } } + +function isValidScope(scope: unknown): scope is DingtalkPerformanceSessionScope { + if (!scope || typeof scope !== 'object' || Array.isArray(scope)) return false + const value = scope as Record + if (value.kind === 'workspace') return true + if (value.kind !== 'assignment' && value.kind !== 'results') return false + if (typeof value.surveyId !== 'string' || !value.surveyId.trim()) return false + if (value.kind === 'assignment') { + return typeof value.targetId === 'string' && Boolean(value.targetId.trim()) + } + return value.targetId == null || typeof value.targetId === 'string' +} diff --git a/lib/performance-survey-service.ts b/lib/performance-survey-service.ts index bd0face..d075449 100644 --- a/lib/performance-survey-service.ts +++ b/lib/performance-survey-service.ts @@ -3273,12 +3273,18 @@ export async function createPerformanceSurvey(session: Session | null, body: any return getPerformanceSurveyDetail(session, surveyId) } -export async function getPerformanceSurveyDetail(session: Session | null, surveyId: string) { +export async function getPerformanceSurveyDetail( + session: Session | null, + surveyId: string, + targetId?: string | null, +) { const ctx = await getPerformanceSurveyContext(session) + const targetIdFilter = clean(targetId) const survey = await prisma.performanceSurvey.findUnique({ where: { id: surveyId }, include: { targets: { + where: targetIdFilter ? { id: targetIdFilter } : undefined, orderBy: { sortOrder: 'asc' }, include: { assignments: { @@ -3325,7 +3331,9 @@ export async function getPerformanceSurveyDetail(session: Session | null, survey const visibleTargets = isCreator ? survey.targets : getPersonalSurveyTargets(ctx, survey, survey.targets) - const isAssigned = survey.assignments.length > 0 + const visibleTargetIds = new Set(visibleTargets.map((target) => target.id)) + const visibleAssignments = survey.assignments.filter((assignment) => visibleTargetIds.has(assignment.targetId)) + const isAssigned = visibleAssignments.length > 0 const isTarget = Boolean( ctx.employee && visibleTargets.some((target) => target.employeeProfileId === ctx.employee?.id), ) @@ -3377,7 +3385,7 @@ export async function getPerformanceSurveyDetail(session: Session | null, survey }, canManage, canCancel: ctx.isDepartmentAdmin && canManage && survey.status !== 'cancelled', - canSubmit: isAssigned && survey.status === 'open' && await canSubmitCurrentVisualStage(survey, survey.assignments), + canSubmit: isAssigned && survey.status === 'open' && await canSubmitCurrentVisualStage(survey, visibleAssignments), canViewResults: survey.status === 'closed' && (isCreator || isTarget), targets: visibleTargets.map((target) => { const currentAssignment = currentAssignments.get(target.id) @@ -3648,10 +3656,18 @@ export async function cancelPerformanceSurvey(session: Session | null, surveyId: return getPerformanceSurveyDetail(session, surveyId) } -export async function submitPerformanceSurveyResponse(session: Session | null, surveyId: string, body: any) { +export async function submitPerformanceSurveyResponse( + session: Session | null, + surveyId: string, + body: any, + requiredTargetId?: string | null, +) { const ctx = await getPerformanceSurveyContext(session) const targetId = clean(body?.targetId) if (!targetId) throw new PerformanceSurveyApiError('缺少评价对象') + if (requiredTargetId && targetId !== requiredTargetId) { + throw new PerformanceSurveyApiError('当前钉钉通知仅允许提交对应的评价对象', 403) + } const survey = await prisma.performanceSurvey.findUnique({ where: { id: surveyId }, @@ -4531,6 +4547,7 @@ export async function submitPerformanceSurveyWorkItemScores( session: Session | null, surveyId: string, body: any, + requiredTargetId?: string | null, ) { const ctx = await getPerformanceSurveyContext(session) const survey = await prisma.performanceSurvey.findUnique({ @@ -4559,6 +4576,9 @@ export async function submitPerformanceSurveyWorkItemScores( throw new PerformanceSurveyApiError('三个案例必须属于同一位被考核人') } const targetId = targetIds[0] + if (requiredTargetId && targetId !== requiredTargetId) { + throw new PerformanceSurveyApiError('当前钉钉通知仅允许提交对应的评价对象', 403) + } const assignment = await prisma.performanceSurveyAssignment.findFirst({ where: { @@ -4732,6 +4752,7 @@ export async function submitPerformanceSurveyWorkItemScore( surveyId: string, workItemId: string, body: any, + requiredTargetId?: string | null, ) { const ctx = await getPerformanceSurveyContext(session) const survey = await prisma.performanceSurvey.findUnique({ @@ -4747,6 +4768,9 @@ export async function submitPerformanceSurveyWorkItemScore( include: { target: true }, }) if (!workItem || workItem.surveyId !== surveyId) throw new PerformanceSurveyApiError('案例不存在', 404) + if (requiredTargetId && workItem.targetId !== requiredTargetId) { + throw new PerformanceSurveyApiError('当前钉钉通知仅允许提交对应的评价对象', 403) + } const assignment = await prisma.performanceSurveyAssignment.findFirst({ where: { diff --git a/middleware.test.ts b/middleware.test.ts index ca5f031..3dec74b 100644 --- a/middleware.test.ts +++ b/middleware.test.ts @@ -33,7 +33,7 @@ describe('品牌中心门禁与钉钉绩效调研入口', () => { username: '跨部门同事', role: 'USER', department: '运营中心', - }) + }, { kind: 'assignment', surveyId: 'survey-1', targetId: 'target-1' }) const response = await middleware(new NextRequest( 'https://sc.jiansun.vip/performance-survey/survey/survey-1?targetId=target-1', { headers: { cookie: `dingtalk-performance-session=${token}` } }, @@ -70,13 +70,32 @@ describe('品牌中心门禁与钉钉绩效调研入口', () => { username: '跨部门同事', role: 'USER', department: '运营中心', - }) + }, { kind: 'assignment', surveyId: 'survey-1', targetId: 'target-1' }) const response = await middleware(new NextRequest( - 'https://sc.jiansun.vip/performance-survey/survey/survey-1', + 'https://sc.jiansun.vip/performance-survey/survey/survey-1?targetId=target-1', { headers: { cookie: `dingtalk-performance-session=${token}` } }, )) expect(response.status).toBe(200) expect(response.headers.get('location')).toBeNull() }) + + test('评分通知专用会话离开对应评分项时会被送回原评分项', async () => { + const token = createDingtalkPerformanceSessionToken({ + id: 'external-user', + email: 'external@example.com', + username: '跨部门同事', + role: 'USER', + department: '运营中心', + }, { kind: 'assignment', surveyId: 'survey-1', targetId: 'target-1' }) + const response = await middleware(new NextRequest( + 'https://sc.jiansun.vip/performance-survey?tab=manage', + { headers: { cookie: `dingtalk-performance-session=${token}` } }, + )) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://sc.jiansun.vip/performance-survey/survey/survey-1?targetId=target-1', + ) + }) }) diff --git a/middleware.ts b/middleware.ts index e21fb9c..1e53911 100644 --- a/middleware.ts +++ b/middleware.ts @@ -35,11 +35,32 @@ function decodeBase64Url(value: string) { return atob(normalized + padding) } -async function hasValidDingtalkPerformanceSession(request: NextRequest) { +type DingtalkPerformanceEdgeScope = + | { kind: 'assignment'; surveyId: string; targetId: string } + | { kind: 'results'; surveyId: string; targetId?: string | null } + | { kind: 'workspace' } + +type DingtalkPerformanceEdgePayload = { + scope: DingtalkPerformanceEdgeScope +} + +function isValidDingtalkPerformanceScope(scope: unknown): scope is DingtalkPerformanceEdgeScope { + if (!scope || typeof scope !== 'object' || Array.isArray(scope)) return false + const value = scope as Record + if (value.kind === 'workspace') return true + if (value.kind !== 'assignment' && value.kind !== 'results') return false + if (typeof value.surveyId !== 'string' || !value.surveyId.trim()) return false + if (value.kind === 'assignment') { + return typeof value.targetId === 'string' && Boolean(value.targetId.trim()) + } + return value.targetId == null || typeof value.targetId === 'string' +} + +async function getValidDingtalkPerformanceSession(request: NextRequest): Promise { const token = request.cookies.get(DINGTALK_PERFORMANCE_SESSION_COOKIE)?.value || '' const secret = process.env.NEXTAUTH_SECRET || '' const [encodedPayload, signature, extra] = token.split('.') - if (!secret || !encodedPayload || !signature || extra) return false + if (!secret || !encodedPayload || !signature || extra) return null try { const key = await crypto.subtle.importKey( @@ -52,10 +73,10 @@ async function hasValidDingtalkPerformanceSession(request: NextRequest) { const expectedSignature = encodeBase64Url(new Uint8Array( await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(encodedPayload)), )) - if (expectedSignature !== signature) return false + if (expectedSignature !== signature) return null const payload = JSON.parse(decodeBase64Url(encodedPayload)) as Record - return ( + if (!( payload.v === 1 && payload.purpose === 'performance-survey' && typeof payload.userId === 'string' @@ -63,12 +84,39 @@ async function hasValidDingtalkPerformanceSession(request: NextRequest) { && typeof payload.name === 'string' && typeof payload.exp === 'number' && payload.exp > Math.floor(Date.now() / 1000) - ) + && isValidDingtalkPerformanceScope(payload.scope) + )) return null + return { scope: payload.scope } } catch { - return false + return null } } +function getScopedPerformanceRedirect( + request: NextRequest, + payload: DingtalkPerformanceEdgePayload | null, +) { + const scope = payload?.scope + if (!scope || scope.kind === 'workspace') return null + + const expectedPathname = scope.kind === 'assignment' + ? `/performance-survey/survey/${encodeURIComponent(scope.surveyId)}` + : `/performance-survey/results/${encodeURIComponent(scope.surveyId)}` + const expectedTargetId = scope.targetId || '' + const currentTargetId = request.nextUrl.searchParams.get('targetId') || '' + if (request.nextUrl.pathname === expectedPathname && currentTargetId === expectedTargetId) { + return null + } + + const url = request.nextUrl.clone() + url.pathname = expectedPathname + url.search = '' + if (expectedTargetId) url.searchParams.set('targetId', expectedTargetId) + const response = NextResponse.redirect(url) + response.headers.set('Cache-Control', 'private, no-store, no-cache, must-revalidate, max-age=0') + return response +} + // 注意:不在middleware中启动内存监控 // middleware使用Edge Runtime,不支持Node.js API // 内存监控会在lib/memory-monitor.ts模块加载时自动启动(仅生产环境) @@ -141,9 +189,10 @@ export async function middleware(request: NextRequest) { ) { const isPerformanceSurveyPath = pathname === '/performance-survey' || pathname.startsWith('/performance-survey/') - const hasDingtalkPerformanceSession = isPerformanceSurveyPath - ? await hasValidDingtalkPerformanceSession(request) - : false + const dingtalkPerformanceSession = isPerformanceSurveyPath + ? await getValidDingtalkPerformanceSession(request) + : null + const hasDingtalkPerformanceSession = Boolean(dingtalkPerformanceSession) const preferredSecureCookie = resolveSecureAuthCookieFromRequest(request) const cookieCandidates = getAuthSessionCookieCandidates(preferredSecureCookie) let token = null @@ -171,6 +220,11 @@ export async function middleware(request: NextRequest) { return redirectResponse } + if (isPerformanceSurveyPath && dingtalkPerformanceSession) { + const scopedRedirect = getScopedPerformanceRedirect(request, dingtalkPerformanceSession) + if (scopedRedirect) return scopedRedirect + } + if ( token && !(isPerformanceSurveyPath && hasDingtalkPerformanceSession)