fix: scope dingtalk performance survey access
This commit is contained in:
parent
a2e7d197cb
commit
74ec2f0c58
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@ -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(() => ({}))
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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('')
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user