fix: scope dingtalk performance survey access

This commit is contained in:
江林铖 2026-09-04 11:50:50 +08:00
parent a2e7d197cb
commit 74ec2f0c58
11 changed files with 464 additions and 68 deletions

View File

@ -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,
})
})
})

View File

@ -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 })
}

View File

@ -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<Session | null> {
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(() => ({}))

View File

@ -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
}

View File

@ -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<any>(`/surveys/${surveyId}`, [surveyId])
const detailPath = targetIdParam
? `/surveys/${surveyId}?targetId=${encodeURIComponent(targetIdParam)}`
: `/surveys/${surveyId}`
const detail = useApi<any>(detailPath, [detailPath])
const [targetId, setTargetId] = useState(targetIdParam || '')
const [scores, setScores] = useState<Record<string, number>>({})
const [comment, setComment] = useState('')

View File

@ -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 () => {

View File

@ -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<DingtalkNextAuthUser> {
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(

View File

@ -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<string, unknown>
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'
}

View File

@ -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: {

View File

@ -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',
)
})
})

View File

@ -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<string, unknown>
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<DingtalkPerformanceEdgePayload | null> {
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<string, unknown>
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)