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', () => ({ jest.mock('@/lib/dingtalk-performance-session', () => ({
createDingtalkPerformanceSessionToken: jest.fn(), 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', () => ({ jest.mock('@/lib/public-app-url', () => ({
@ -21,7 +34,9 @@ jest.mock('@/lib/public-app-url', () => ({
})) }))
jest.mock('@/lib/safe-redirect', () => ({ 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', () => { describe('DingTalk H5 public login body limit', () => {
@ -121,6 +136,7 @@ describe('DingTalk H5 public login body limit', () => {
expect(authorizeDingtalkH5UserForNextAuth).toHaveBeenCalledWith('short-code', { expect(authorizeDingtalkH5UserForNextAuth).toHaveBeenCalledWith('short-code', {
appSource: 'workNotice', appSource: 'workNotice',
clientIp: null, clientIp: null,
allowRestrictedPerformanceAccess: true,
}) })
}) })
}) })

View File

@ -3,7 +3,10 @@ import {
authorizeDingtalkH5UserForNextAuth, authorizeDingtalkH5UserForNextAuth,
loginDingtalkUserByH5AuthCode, loginDingtalkUserByH5AuthCode,
} from '@/lib/dingtalk-login' } 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 { DINGTALK_PERFORMANCE_SESSION_COOKIE } from '@/lib/dingtalk-performance-session-constants'
import { getPublicAppOrigin } from '@/lib/public-app-url' import { getPublicAppOrigin } from '@/lib/public-app-url'
import { normalizeInternalRedirectPath } from '@/lib/safe-redirect' import { normalizeInternalRedirectPath } from '@/lib/safe-redirect'
@ -435,8 +438,16 @@ async function handleH5LoginPage(request: NextRequest, code: string) {
const user = await authorizeDingtalkH5UserForNextAuth(code, { const user = await authorizeDingtalkH5UserForNextAuth(code, {
appSource: 'workNotice', appSource: 'workNotice',
clientIp, 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 }) return createPerformanceSurveySessionTransferPage({ request, redirectPath, token })
} }

View File

@ -4,6 +4,7 @@ import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { import {
DINGTALK_PERFORMANCE_SESSION_HEADER, DINGTALK_PERFORMANCE_SESSION_HEADER,
type DingtalkPerformanceSessionScope,
verifyDingtalkPerformanceSessionToken, verifyDingtalkPerformanceSessionToken,
} from '@/lib/dingtalk-performance-session' } from '@/lib/dingtalk-performance-session'
import { DINGTALK_PERFORMANCE_SESSION_COOKIE } from '@/lib/dingtalk-performance-session-constants' 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> { async function getSession(request: Request): Promise<Session | null> {
const session = await getServerSession(authOptions) // 钉钉工作通知会话必须优先于浏览器里可能残留的素材库/品牌中心会话。
if ((session?.user as any)?.id) { // 否则用户从他人的评分通知进入时,API 会把旧的普通登录身份当成当前评分人。
return session 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)) return getServerSession(authOptions)
if (!payload) { }
return session
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 { const isScopedResultsPath = scope.kind === 'results' && (
user: { (path.length === 2 && path[0] === 'results' && path[1] === scope.surveyId)
id: payload.userId, || (path.length === 3 && path[0] === 'surveys' && path[1] === scope.surveyId && path[2] === 'results')
email: payload.email, )
name: payload.name, if (isScopedResultsPath && (!scope.targetId || targetId === scope.targetId)) return
avatar: payload.avatar || null,
role: payload.role, throw new PerformanceSurveyApiError('当前钉钉通知仅允许访问对应的考核调研项', 403)
department: payload.department || '', }
managedDepartment: payload.managedDepartment || null,
isSuperAdmin: payload.isSuperAdmin ?? false, function assertScopedPostAccess(session: Session | null, path: string[]) {
}, const scope = getDingtalkPerformanceScope(session)
expires: new Date(payload.exp * 1000).toISOString(), 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 session = await getSession(request)
const path = getPath(context) const path = getPath(context)
const url = new URL(request.url) const url = new URL(request.url)
assertScopedGetAccess(session, path, url.searchParams)
if (path.length === 0 || path[0] === 'me') { if (path.length === 0 || path[0] === 'me') {
return ok(await getPerformanceSurveyMe(session)) 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) { 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') { if (path[0] === 'surveys' && path[1] && path[2] === 'results') {
@ -236,6 +300,7 @@ export async function POST(request: Request, context: RouteContext) {
try { try {
const session = await getSession(request) const session = await getSession(request)
const path = getPath(context) const path = getPath(context)
const assignmentScope = assertScopedPostAccess(session, path)
if (path[0] === 'surveys' && path[1] && path[2] === 'work-items' && path.length === 3) { if (path[0] === 'surveys' && path[1] && path[2] === 'work-items' && path.length === 3) {
return ok(await createPerformanceSurveyWorkItemFromRequest(session, path[1], request), { status: 201 }) 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') { 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) { 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') { 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 ( if (
@ -333,6 +404,7 @@ export async function POST(request: Request, context: RouteContext) {
export async function PATCH(request: Request, context: RouteContext) { export async function PATCH(request: Request, context: RouteContext) {
try { try {
const session = await getSession(request) const session = await getSession(request)
assertScopedAdministrativeMutation(session)
const path = getPath(context) const path = getPath(context)
const body = await request.json().catch(() => ({})) 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) { export async function DELETE(request: Request, context: RouteContext) {
try { try {
const session = await getSession(request) const session = await getSession(request)
assertScopedAdministrativeMutation(session)
const path = getPath(context) const path = getPath(context)
const body = await request.json().catch(() => ({})) const body = await request.json().catch(() => ({}))

View File

@ -144,7 +144,12 @@ export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEn
try { try {
setMessage('正在打开页面...') setMessage('正在打开页面...')
if (await hasActiveSession()) { // 绩效通知必须重新走 workNotice 免登,不能因为浏览器里已有素材库会话
// 就直接打开普通会话。否则外部人员会被素材库会话带回默认素材库页面,
// 也无法把本次通知对应的 surveyId/targetId 绑定到评分权限。
const isPerformanceSurveyNotice = redirectPath === '/performance-survey'
|| redirectPath.startsWith('/performance-survey/')
if (!isPerformanceSurveyNotice && await hasActiveSession()) {
window.location.replace(redirectPath) window.location.replace(redirectPath)
return return
} }

View File

@ -5099,7 +5099,10 @@ function SurveySubmit({ surveyId, session, canCreateSurvey }: { surveyId: string
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const targetIdParam = searchParams.get('targetId') 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 [targetId, setTargetId] = useState(targetIdParam || '')
const [scores, setScores] = useState<Record<string, number>>({}) const [scores, setScores] = useState<Record<string, number>>({})
const [comment, setComment] = useState('') const [comment, setComment] = useState('')

View File

@ -1,11 +1,14 @@
/** @jest-environment node */ /** @jest-environment node */
import { getServerSession } from 'next-auth' 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 { import {
getPerformanceSurveyDetail,
previewPerformanceSurveyEmployeeProfiles, previewPerformanceSurveyEmployeeProfiles,
submitPerformanceSurveyResponse,
syncPerformanceSurveyEmployeeProfiles, syncPerformanceSurveyEmployeeProfiles,
} from '@/lib/performance-survey-service' } from '@/lib/performance-survey-service'
import { verifyDingtalkPerformanceSessionToken } from '@/lib/dingtalk-performance-session'
jest.mock('next-auth', () => ({ jest.mock('next-auth', () => ({
getServerSession: jest.fn(), getServerSession: jest.fn(),
@ -29,7 +32,9 @@ jest.mock('@/lib/performance-survey-service', () => ({
this.status = status this.status = status
} }
}, },
getPerformanceSurveyDetail: jest.fn(),
previewPerformanceSurveyEmployeeProfiles: jest.fn(), previewPerformanceSurveyEmployeeProfiles: jest.fn(),
submitPerformanceSurveyResponse: jest.fn(),
syncPerformanceSurveyEmployeeProfiles: jest.fn(), syncPerformanceSurveyEmployeeProfiles: jest.fn(),
})) }))
@ -40,6 +45,91 @@ describe('performance survey employee sync route', () => {
user: { id: 'admin-1' }, user: { id: 'admin-1' },
expires: '2099-01-01T00:00:00.000Z', 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 () => { test('preview 子路径只执行只读预检', async () => {

View File

@ -241,7 +241,19 @@ function warmPerformanceSurveyProfile(user: {
username: string username: string
realName: string | null 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, userId: user.id,
dingtalkUserId: user.dingtalkUserId, dingtalkUserId: user.dingtalkUserId,
email: user.email, email: user.email,
@ -249,14 +261,9 @@ function warmPerformanceSurveyProfile(user: {
realName: user.realName, realName: user.realName,
displayName: user.realName || user.username, displayName: user.realName || user.username,
}) })
.then(() => // 档案就绪后按钉钉唯一值建立账号与权威人员的正式绑定,
// 档案就绪后按钉钉唯一值建立账号与权威人员的正式绑定, // 取代此前"每次用到时按姓名/邮箱现猜"的软匹配。
// 取代此前"每次用到时按姓名/邮箱现猜"的软匹配。 await ensureBindingByDingtalkUserId(user.id, user.dingtalkUserId)
ensureBindingByDingtalkUserId(user.id, user.dingtalkUserId),
)
.catch((error) => {
console.warn('[performance-survey] 钉钉登录后预热人员档案或建立绑定失败:', error)
})
} }
export async function setDingtalkAuthenticatedSessionCookies( export async function setDingtalkAuthenticatedSessionCookies(
@ -541,6 +548,7 @@ async function resolveDingtalkUserForSession(
userId: string, userId: string,
userInfo: DingtalkUserInfo, userInfo: DingtalkUserInfo,
clientIp?: string | null, clientIp?: string | null,
options?: { allowRestrictedPerformanceAccess?: boolean },
): Promise<DingtalkNextAuthUser> { ): Promise<DingtalkNextAuthUser> {
const displayName = String(userInfo.name || '').trim() const displayName = String(userInfo.name || '').trim()
if (!displayName) { if (!displayName) {
@ -557,7 +565,7 @@ async function resolveDingtalkUserForSession(
where: { username: displayName }, where: { username: displayName },
}) })
if (existingUserByName) { if (existingUserByName && !options?.allowRestrictedPerformanceAccess) {
console.warn( console.warn(
`[dingtalk-session] 拒绝按姓名自动合并身份:username=${displayName},` + `[dingtalk-session] 拒绝按姓名自动合并身份:username=${displayName},` +
`既有账号=${existingUserByName.id},` + `既有账号=${existingUserByName.id},` +
@ -570,6 +578,20 @@ async function resolveDingtalkUserForSession(
console.log('创建新用户(使用钉钉姓名):', displayName) 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() let emailToUse = userInfo.email || generateRandomEmail()
const emailExists = await prisma.user.findUnique({ where: { email: emailToUse } }) const emailExists = await prisma.user.findUnique({ where: { email: emailToUse } })
if (emailExists) { if (emailExists) {
@ -578,7 +600,7 @@ async function resolveDingtalkUserForSession(
user = await prisma.user.create({ user = await prisma.user.create({
data: { data: {
username: displayName, username: usernameToUse,
email: emailToUse, email: emailToUse,
password: await createDingtalkOnlyPasswordHash(), password: await createDingtalkOnlyPasswordHash(),
realName: displayName, realName: displayName,
@ -588,13 +610,17 @@ async function resolveDingtalkUserForSession(
role: 'USER', role: 'USER',
isActive: false, isActive: false,
status: 'pending', status: 'pending',
loginType: 'dingtalk', loginType: options?.allowRestrictedPerformanceAccess ? 'dingtalk_work_notice' : 'dingtalk',
dingtalkUserId: userId, dingtalkUserId: userId,
}, },
}) })
warmPerformanceSurveyProfile(user) if (options?.allowRestrictedPerformanceAccess) {
throw new Error('您的账号尚未激活,请联系管理员激活后再登录') warmPerformanceSurveyProfile(user)
} else {
warmPerformanceSurveyProfile(user)
throw new Error('您的账号尚未激活,请联系管理员激活后再登录')
}
} else { } else {
console.log('更新现有用户:', user.username) console.log('更新现有用户:', user.username)
user = await prisma.user.update({ user = await prisma.user.update({
@ -610,6 +636,10 @@ async function resolveDingtalkUserForSession(
let userStatus = (user as any).status || (user.isActive ? 'active' : 'pending') let userStatus = (user as any).status || (user.isActive ? 'active' : 'pending')
let isUserActive = userStatus === 'active' let isUserActive = userStatus === 'active'
if (userStatus === 'suspended' || userStatus === 'archived') {
throw new Error('您的账号已被暂停或归档,无法登录')
}
// 自动人员规则:花名册在册且在职 → 自动激活;并按 M/P 等级自动同步角色 // 自动人员规则:花名册在册且在职 → 自动激活;并按 M/P 等级自动同步角色
if (!isUserActive) { if (!isUserActive) {
try { try {
@ -630,12 +660,11 @@ async function resolveDingtalkUserForSession(
} }
if (!isUserActive) { if (!isUserActive) {
if (!options?.allowRestrictedPerformanceAccess) {
warmPerformanceSurveyProfile(user)
throw new Error('您的账号尚未激活,请联系管理员激活后再登录')
}
warmPerformanceSurveyProfile(user) warmPerformanceSurveyProfile(user)
throw new Error('您的账号尚未激活,请联系管理员激活后再登录')
}
if (userStatus === 'suspended' || userStatus === 'archived') {
throw new Error('您的账号已被暂停或归档,无法登录')
} }
await prisma.user.update({ await prisma.user.update({
@ -644,8 +673,7 @@ async function resolveDingtalkUserForSession(
lastLoginAt: new Date(), lastLoginAt: new Date(),
lastLoginIp: clientIp || null, lastLoginIp: clientIp || null,
loginCount: { increment: 1 }, loginCount: { increment: 1 },
status: 'active', ...(isUserActive ? { status: 'active', isActive: true } : {}),
isActive: true,
}, },
}).catch((error) => { }).catch((error) => {
console.error('记录登录审计信息失败:', error) console.error('记录登录审计信息失败:', error)
@ -668,7 +696,11 @@ async function resolveDingtalkUserForSession(
export async function authorizeDingtalkH5UserForNextAuth( export async function authorizeDingtalkH5UserForNextAuth(
code: string, code: string,
options?: { appSource?: DingtalkLoginAppSource; clientIp?: string | null }, options?: {
appSource?: DingtalkLoginAppSource
clientIp?: string | null
allowRestrictedPerformanceAccess?: boolean
},
) { ) {
console.log('开始钉钉H5免登NextAuth登录流程') console.log('开始钉钉H5免登NextAuth登录流程')
const appSource = options?.appSource || 'login' const appSource = options?.appSource || 'login'
@ -682,7 +714,9 @@ export async function authorizeDingtalkH5UserForNextAuth(
const userInfo = await getUserInfo(userId, appAccessToken) const userInfo = await getUserInfo(userId, appAccessToken)
console.log('获取用户信息成功:', userInfo.name) 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( 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 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 = { export type DingtalkPerformanceSessionPayload = {
v: 1 v: 1
purpose: 'performance-survey' purpose: 'performance-survey'
@ -16,6 +31,7 @@ export type DingtalkPerformanceSessionPayload = {
department?: string | null department?: string | null
managedDepartment?: string | null managedDepartment?: string | null
isSuperAdmin?: boolean | null isSuperAdmin?: boolean | null
scope: DingtalkPerformanceSessionScope
iat: number iat: number
exp: number exp: number
nonce: string nonce: string
@ -81,7 +97,44 @@ function normalizeRole(role: string): DingtalkPerformanceRole {
: 'USER' : '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 now = Math.floor(Date.now() / 1000)
const payload: DingtalkPerformanceSessionPayload = { const payload: DingtalkPerformanceSessionPayload = {
v: 1, v: 1,
@ -94,6 +147,7 @@ export function createDingtalkPerformanceSessionToken(user: DingtalkPerformanceU
department: user.department || '', department: user.department || '',
managedDepartment: user.managedDepartment || null, managedDepartment: user.managedDepartment || null,
isSuperAdmin: user.isSuperAdmin ?? false, isSuperAdmin: user.isSuperAdmin ?? false,
scope,
iat: now, iat: now,
exp: now + getTtlSeconds(), exp: now + getTtlSeconds(),
nonce: randomUUID(), nonce: randomUUID(),
@ -122,6 +176,7 @@ export function verifyDingtalkPerformanceSessionToken(token: string | null | und
!payload.name || !payload.name ||
!payload.role || !payload.role ||
normalizeRole(payload.role) !== payload.role || normalizeRole(payload.role) !== payload.role ||
!isValidScope(payload.scope) ||
typeof payload.exp !== 'number' || typeof payload.exp !== 'number' ||
payload.exp <= now payload.exp <= now
) { ) {
@ -133,3 +188,15 @@ export function verifyDingtalkPerformanceSessionToken(token: string | null | und
return null 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) 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 ctx = await getPerformanceSurveyContext(session)
const targetIdFilter = clean(targetId)
const survey = await prisma.performanceSurvey.findUnique({ const survey = await prisma.performanceSurvey.findUnique({
where: { id: surveyId }, where: { id: surveyId },
include: { include: {
targets: { targets: {
where: targetIdFilter ? { id: targetIdFilter } : undefined,
orderBy: { sortOrder: 'asc' }, orderBy: { sortOrder: 'asc' },
include: { include: {
assignments: { assignments: {
@ -3325,7 +3331,9 @@ export async function getPerformanceSurveyDetail(session: Session | null, survey
const visibleTargets = isCreator const visibleTargets = isCreator
? survey.targets ? survey.targets
: getPersonalSurveyTargets(ctx, survey, 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( const isTarget = Boolean(
ctx.employee && visibleTargets.some((target) => target.employeeProfileId === ctx.employee?.id), ctx.employee && visibleTargets.some((target) => target.employeeProfileId === ctx.employee?.id),
) )
@ -3377,7 +3385,7 @@ export async function getPerformanceSurveyDetail(session: Session | null, survey
}, },
canManage, canManage,
canCancel: ctx.isDepartmentAdmin && canManage && survey.status !== 'cancelled', 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), canViewResults: survey.status === 'closed' && (isCreator || isTarget),
targets: visibleTargets.map((target) => { targets: visibleTargets.map((target) => {
const currentAssignment = currentAssignments.get(target.id) const currentAssignment = currentAssignments.get(target.id)
@ -3648,10 +3656,18 @@ export async function cancelPerformanceSurvey(session: Session | null, surveyId:
return getPerformanceSurveyDetail(session, 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 ctx = await getPerformanceSurveyContext(session)
const targetId = clean(body?.targetId) const targetId = clean(body?.targetId)
if (!targetId) throw new PerformanceSurveyApiError('缺少评价对象') if (!targetId) throw new PerformanceSurveyApiError('缺少评价对象')
if (requiredTargetId && targetId !== requiredTargetId) {
throw new PerformanceSurveyApiError('当前钉钉通知仅允许提交对应的评价对象', 403)
}
const survey = await prisma.performanceSurvey.findUnique({ const survey = await prisma.performanceSurvey.findUnique({
where: { id: surveyId }, where: { id: surveyId },
@ -4531,6 +4547,7 @@ export async function submitPerformanceSurveyWorkItemScores(
session: Session | null, session: Session | null,
surveyId: string, surveyId: string,
body: any, body: any,
requiredTargetId?: string | null,
) { ) {
const ctx = await getPerformanceSurveyContext(session) const ctx = await getPerformanceSurveyContext(session)
const survey = await prisma.performanceSurvey.findUnique({ const survey = await prisma.performanceSurvey.findUnique({
@ -4559,6 +4576,9 @@ export async function submitPerformanceSurveyWorkItemScores(
throw new PerformanceSurveyApiError('三个案例必须属于同一位被考核人') throw new PerformanceSurveyApiError('三个案例必须属于同一位被考核人')
} }
const targetId = targetIds[0] const targetId = targetIds[0]
if (requiredTargetId && targetId !== requiredTargetId) {
throw new PerformanceSurveyApiError('当前钉钉通知仅允许提交对应的评价对象', 403)
}
const assignment = await prisma.performanceSurveyAssignment.findFirst({ const assignment = await prisma.performanceSurveyAssignment.findFirst({
where: { where: {
@ -4732,6 +4752,7 @@ export async function submitPerformanceSurveyWorkItemScore(
surveyId: string, surveyId: string,
workItemId: string, workItemId: string,
body: any, body: any,
requiredTargetId?: string | null,
) { ) {
const ctx = await getPerformanceSurveyContext(session) const ctx = await getPerformanceSurveyContext(session)
const survey = await prisma.performanceSurvey.findUnique({ const survey = await prisma.performanceSurvey.findUnique({
@ -4747,6 +4768,9 @@ export async function submitPerformanceSurveyWorkItemScore(
include: { target: true }, include: { target: true },
}) })
if (!workItem || workItem.surveyId !== surveyId) throw new PerformanceSurveyApiError('案例不存在', 404) if (!workItem || workItem.surveyId !== surveyId) throw new PerformanceSurveyApiError('案例不存在', 404)
if (requiredTargetId && workItem.targetId !== requiredTargetId) {
throw new PerformanceSurveyApiError('当前钉钉通知仅允许提交对应的评价对象', 403)
}
const assignment = await prisma.performanceSurveyAssignment.findFirst({ const assignment = await prisma.performanceSurveyAssignment.findFirst({
where: { where: {

View File

@ -33,7 +33,7 @@ describe('品牌中心门禁与钉钉绩效调研入口', () => {
username: '跨部门同事', username: '跨部门同事',
role: 'USER', role: 'USER',
department: '运营中心', department: '运营中心',
}) }, { kind: 'assignment', surveyId: 'survey-1', targetId: 'target-1' })
const response = await middleware(new NextRequest( const response = await middleware(new NextRequest(
'https://sc.jiansun.vip/performance-survey/survey/survey-1?targetId=target-1', 'https://sc.jiansun.vip/performance-survey/survey/survey-1?targetId=target-1',
{ headers: { cookie: `dingtalk-performance-session=${token}` } }, { headers: { cookie: `dingtalk-performance-session=${token}` } },
@ -70,13 +70,32 @@ describe('品牌中心门禁与钉钉绩效调研入口', () => {
username: '跨部门同事', username: '跨部门同事',
role: 'USER', role: 'USER',
department: '运营中心', department: '运营中心',
}) }, { kind: 'assignment', surveyId: 'survey-1', targetId: 'target-1' })
const response = await middleware(new NextRequest( 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}` } }, { headers: { cookie: `dingtalk-performance-session=${token}` } },
)) ))
expect(response.status).toBe(200) expect(response.status).toBe(200)
expect(response.headers.get('location')).toBeNull() 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) 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 token = request.cookies.get(DINGTALK_PERFORMANCE_SESSION_COOKIE)?.value || ''
const secret = process.env.NEXTAUTH_SECRET || '' const secret = process.env.NEXTAUTH_SECRET || ''
const [encodedPayload, signature, extra] = token.split('.') const [encodedPayload, signature, extra] = token.split('.')
if (!secret || !encodedPayload || !signature || extra) return false if (!secret || !encodedPayload || !signature || extra) return null
try { try {
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
@ -52,10 +73,10 @@ async function hasValidDingtalkPerformanceSession(request: NextRequest) {
const expectedSignature = encodeBase64Url(new Uint8Array( const expectedSignature = encodeBase64Url(new Uint8Array(
await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(encodedPayload)), 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> const payload = JSON.parse(decodeBase64Url(encodedPayload)) as Record<string, unknown>
return ( if (!(
payload.v === 1 payload.v === 1
&& payload.purpose === 'performance-survey' && payload.purpose === 'performance-survey'
&& typeof payload.userId === 'string' && typeof payload.userId === 'string'
@ -63,12 +84,39 @@ async function hasValidDingtalkPerformanceSession(request: NextRequest) {
&& typeof payload.name === 'string' && typeof payload.name === 'string'
&& typeof payload.exp === 'number' && typeof payload.exp === 'number'
&& payload.exp > Math.floor(Date.now() / 1000) && payload.exp > Math.floor(Date.now() / 1000)
) && isValidDingtalkPerformanceScope(payload.scope)
)) return null
return { scope: payload.scope }
} catch { } 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中启动内存监控
// middleware使用Edge Runtime,不支持Node.js API // middleware使用Edge Runtime,不支持Node.js API
// 内存监控会在lib/memory-monitor.ts模块加载时自动启动(仅生产环境) // 内存监控会在lib/memory-monitor.ts模块加载时自动启动(仅生产环境)
@ -141,9 +189,10 @@ export async function middleware(request: NextRequest) {
) { ) {
const isPerformanceSurveyPath = const isPerformanceSurveyPath =
pathname === '/performance-survey' || pathname.startsWith('/performance-survey/') pathname === '/performance-survey' || pathname.startsWith('/performance-survey/')
const hasDingtalkPerformanceSession = isPerformanceSurveyPath const dingtalkPerformanceSession = isPerformanceSurveyPath
? await hasValidDingtalkPerformanceSession(request) ? await getValidDingtalkPerformanceSession(request)
: false : null
const hasDingtalkPerformanceSession = Boolean(dingtalkPerformanceSession)
const preferredSecureCookie = resolveSecureAuthCookieFromRequest(request) const preferredSecureCookie = resolveSecureAuthCookieFromRequest(request)
const cookieCandidates = getAuthSessionCookieCandidates(preferredSecureCookie) const cookieCandidates = getAuthSessionCookieCandidates(preferredSecureCookie)
let token = null let token = null
@ -171,6 +220,11 @@ export async function middleware(request: NextRequest) {
return redirectResponse return redirectResponse
} }
if (isPerformanceSurveyPath && dingtalkPerformanceSession) {
const scopedRedirect = getScopedPerformanceRedirect(request, dingtalkPerformanceSession)
if (scopedRedirect) return scopedRedirect
}
if ( if (
token token
&& !(isPerformanceSurveyPath && hasDingtalkPerformanceSession) && !(isPerformanceSurveyPath && hasDingtalkPerformanceSession)