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', () => ({
|
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,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,15 +104,12 @@ 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)
|
||||||
const payload = verifyDingtalkPerformanceSessionToken(readDingtalkPerformanceToken(request))
|
if (!payload) return null
|
||||||
if (!payload) {
|
|
||||||
return session
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
@ -125,6 +123,71 @@ async function getSession(request: Request): Promise<Session | null> {
|
|||||||
isSuperAdmin: payload.isSuperAdmin ?? false,
|
isSuperAdmin: payload.isSuperAdmin ?? false,
|
||||||
},
|
},
|
||||||
expires: new Date(payload.exp * 1000).toISOString(),
|
expires: new Date(payload.exp * 1000).toISOString(),
|
||||||
|
dingtalkPerformanceScope: payload.scope,
|
||||||
|
} as Session & { dingtalkPerformanceScope: DingtalkPerformanceSessionScope }
|
||||||
|
}
|
||||||
|
|
||||||
|
return getServerSession(authOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDingtalkPerformanceScope(session: Session | null) {
|
||||||
|
return (session as (Session & {
|
||||||
|
dingtalkPerformanceScope?: DingtalkPerformanceSessionScope
|
||||||
|
}) | null)?.dingtalkPerformanceScope || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertScopedGetAccess(
|
||||||
|
session: Session | null,
|
||||||
|
path: string[],
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
) {
|
||||||
|
const scope = getDingtalkPerformanceScope(session)
|
||||||
|
if (!scope || scope.kind === 'workspace') return
|
||||||
|
if (path.length === 0 || (path.length === 1 && path[0] === 'me')) return
|
||||||
|
|
||||||
|
const targetId = searchParams.get('targetId')?.trim() || ''
|
||||||
|
if (
|
||||||
|
scope.kind === 'assignment'
|
||||||
|
&& path.length === 2
|
||||||
|
&& path[0] === 'surveys'
|
||||||
|
&& path[1] === scope.surveyId
|
||||||
|
&& targetId === scope.targetId
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isScopedResultsPath = scope.kind === 'results' && (
|
||||||
|
(path.length === 2 && path[0] === 'results' && path[1] === scope.surveyId)
|
||||||
|
|| (path.length === 3 && path[0] === 'surveys' && path[1] === scope.surveyId && path[2] === 'results')
|
||||||
|
)
|
||||||
|
if (isScopedResultsPath && (!scope.targetId || targetId === scope.targetId)) return
|
||||||
|
|
||||||
|
throw new PerformanceSurveyApiError('当前钉钉通知仅允许访问对应的考核调研项', 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertScopedPostAccess(session: Session | null, path: string[]) {
|
||||||
|
const scope = getDingtalkPerformanceScope(session)
|
||||||
|
if (!scope || scope.kind === 'workspace') return null
|
||||||
|
if (scope.kind !== 'assignment') {
|
||||||
|
throw new PerformanceSurveyApiError('当前钉钉通知不包含评分权限', 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isScopedSubmission = path[0] === 'surveys'
|
||||||
|
&& path[1] === scope.surveyId
|
||||||
|
&& (
|
||||||
|
(path.length === 3 && (path[2] === 'responses' || path[2] === 'work-item-scores'))
|
||||||
|
|| (path.length === 5 && path[2] === 'work-items' && Boolean(path[3]) && path[4] === 'scores')
|
||||||
|
)
|
||||||
|
if (!isScopedSubmission) {
|
||||||
|
throw new PerformanceSurveyApiError('当前钉钉通知仅允许提交对应的考核调研项', 403)
|
||||||
|
}
|
||||||
|
return scope
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertScopedAdministrativeMutation(session: Session | null) {
|
||||||
|
const scope = getDingtalkPerformanceScope(session)
|
||||||
|
if (scope && scope.kind !== 'workspace') {
|
||||||
|
throw new PerformanceSurveyApiError('当前钉钉通知不包含管理权限', 403)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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(() => ({}))
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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('')
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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(() =>
|
|
||||||
// 档案就绪后按钉钉唯一值建立账号与权威人员的正式绑定,
|
// 档案就绪后按钉钉唯一值建立账号与权威人员的正式绑定,
|
||||||
// 取代此前"每次用到时按姓名/邮箱现猜"的软匹配。
|
// 取代此前"每次用到时按姓名/邮箱现猜"的软匹配。
|
||||||
ensureBindingByDingtalkUserId(user.id, user.dingtalkUserId),
|
await 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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (options?.allowRestrictedPerformanceAccess) {
|
||||||
|
warmPerformanceSurveyProfile(user)
|
||||||
|
} else {
|
||||||
warmPerformanceSurveyProfile(user)
|
warmPerformanceSurveyProfile(user)
|
||||||
throw new Error('您的账号尚未激活,请联系管理员激活后再登录')
|
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)
|
warmPerformanceSurveyProfile(user)
|
||||||
throw new Error('您的账号尚未激活,请联系管理员激活后再登录')
|
throw new Error('您的账号尚未激活,请联系管理员激活后再登录')
|
||||||
}
|
}
|
||||||
|
warmPerformanceSurveyProfile(user)
|
||||||
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(
|
||||||
|
|||||||
@ -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'
|
||||||
|
}
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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',
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user