Compare commits
1 Commits
main
...
codex/uplo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d624e2d82b |
@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
buildMaterialWhereFromSearchParams,
|
||||
createMaterialQueryContext,
|
||||
MaterialFilterDimension,
|
||||
MaterialQueryError,
|
||||
normalizeMaterialTypeForUi,
|
||||
@ -85,9 +86,13 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const user = session.user as any
|
||||
const context = await createMaterialQueryContext(searchParams, user)
|
||||
|
||||
const buildWhere = async (exclude?: MaterialFilterDimension) =>
|
||||
buildMaterialWhereFromSearchParams(searchParams, user, exclude ? { exclude } : {})
|
||||
buildMaterialWhereFromSearchParams(searchParams, user, {
|
||||
context,
|
||||
...(exclude ? { exclude } : {}),
|
||||
})
|
||||
|
||||
const countWithFilters = async (exclude?: MaterialFilterDimension) => {
|
||||
const result = await buildWhere(exclude)
|
||||
@ -142,35 +147,28 @@ export async function GET(request: NextRequest) {
|
||||
return toMonthItems(rows)
|
||||
}
|
||||
|
||||
const [
|
||||
total,
|
||||
categories,
|
||||
scenes,
|
||||
platforms,
|
||||
productCategories,
|
||||
months,
|
||||
materialTypes,
|
||||
materialTypeTotal,
|
||||
categoryTotal,
|
||||
sceneTotal,
|
||||
platformTotal,
|
||||
productCategoryTotal,
|
||||
monthTotal,
|
||||
] = await Promise.all([
|
||||
// 每批最多并发 3 条统计查询,避免多个首页请求同时耗尽 Prisma 连接池。
|
||||
const [total, categories, scenes] = await Promise.all([
|
||||
countWithFilters(),
|
||||
groupByField('categoryName'),
|
||||
groupByField('sceneName'),
|
||||
])
|
||||
const [platforms, productCategories, months] = await Promise.all([
|
||||
groupByField('platformName'),
|
||||
groupByField('productCategoryName'),
|
||||
loadMonths(),
|
||||
])
|
||||
const [materialTypes, materialTypeTotal, categoryTotal] = await Promise.all([
|
||||
loadMaterialTypes(),
|
||||
countWithFilters('materialType'),
|
||||
countWithFilters('category'),
|
||||
])
|
||||
const [sceneTotal, platformTotal, productCategoryTotal] = await Promise.all([
|
||||
countWithFilters('scene'),
|
||||
countWithFilters('platform'),
|
||||
countWithFilters('productCategory'),
|
||||
countWithFilters('month'),
|
||||
])
|
||||
const monthTotal = await countWithFilters('month')
|
||||
|
||||
return NextResponse.json({
|
||||
total,
|
||||
|
||||
@ -24,6 +24,33 @@ interface CompleteRequestBody {
|
||||
assets: unknown
|
||||
}
|
||||
|
||||
function getProductPackageCompleteError(error: unknown) {
|
||||
const code =
|
||||
error && typeof error === 'object' && 'code' in error
|
||||
? String((error as { code?: unknown }).code || '')
|
||||
: ''
|
||||
const message = error instanceof Error ? error.message : ''
|
||||
const isDatabaseBusy =
|
||||
code === 'P2024' ||
|
||||
code === 'P2028' ||
|
||||
message.includes('Unable to start a transaction in the given time') ||
|
||||
message.includes('Timed out fetching a new connection from the connection pool')
|
||||
|
||||
if (isDatabaseBusy) {
|
||||
return {
|
||||
code: 'DATABASE_BUSY',
|
||||
message: '系统繁忙,本次上传未完成,请稍后重新上传',
|
||||
status: 503,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code: 'UPLOAD_COMPLETE_FAILED',
|
||||
message: message || '服务器错误',
|
||||
status: 500,
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
@ -109,12 +136,17 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Product Package Complete] Error:', error)
|
||||
const publicError = getProductPackageCompleteError(error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '服务器错误',
|
||||
code: publicError.code,
|
||||
error: publicError.message,
|
||||
},
|
||||
{
|
||||
status: publicError.status,
|
||||
headers: publicError.status === 503 ? { 'Retry-After': '5' } : undefined,
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
168
lib/__tests__/department-permission-batch.test.ts
Normal file
168
lib/__tests__/department-permission-batch.test.ts
Normal file
@ -0,0 +1,168 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import {
|
||||
getDepartmentPermissions,
|
||||
getUserViewableMaterialTypes,
|
||||
} from '@/lib/department-permission'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
material: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
department: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
departmentMaterialPermission: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const departments = [
|
||||
{ id: 'parent', name: '视觉中心', parentId: null },
|
||||
{ id: 'child', name: '视觉三部', parentId: 'parent' },
|
||||
{ id: 'linked', name: '共享权限部', parentId: null },
|
||||
]
|
||||
|
||||
function permission(input: Partial<{
|
||||
department: string
|
||||
materialType: string
|
||||
canView: boolean
|
||||
canDownload: boolean
|
||||
canShare: boolean
|
||||
canDownloadSource: boolean
|
||||
linkedToDepartment: string | null
|
||||
}>) {
|
||||
return {
|
||||
department: '视觉三部',
|
||||
materialType: '详情模板',
|
||||
canView: true,
|
||||
canDownload: false,
|
||||
canShare: true,
|
||||
canDownloadSource: false,
|
||||
linkedToDepartment: null,
|
||||
...input,
|
||||
}
|
||||
}
|
||||
|
||||
describe('department permission batch resolver', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ isSuperAdmin: false })
|
||||
;(prisma.material.findMany as jest.Mock).mockResolvedValue([
|
||||
{ materialType: '详情模板' },
|
||||
])
|
||||
;(prisma.department.findMany as jest.Mock).mockResolvedValue(departments)
|
||||
})
|
||||
|
||||
test('一次批量查询保持直接权限、关联部门、父部门继承和视频兼容语义', async () => {
|
||||
;(prisma.departmentMaterialPermission.findMany as jest.Mock).mockResolvedValue([
|
||||
permission({
|
||||
department: '视觉三部',
|
||||
materialType: '标准模型',
|
||||
canView: false,
|
||||
canDownload: true,
|
||||
}),
|
||||
permission({
|
||||
department: '视觉三部',
|
||||
materialType: '详情模板',
|
||||
canView: false,
|
||||
linkedToDepartment: '共享权限部',
|
||||
}),
|
||||
permission({
|
||||
department: '共享权限部',
|
||||
materialType: '详情模板',
|
||||
canView: true,
|
||||
canDownload: true,
|
||||
canDownloadSource: true,
|
||||
}),
|
||||
permission({
|
||||
department: '视觉中心',
|
||||
materialType: '淘系-逛逛视频',
|
||||
canView: true,
|
||||
}),
|
||||
])
|
||||
|
||||
const result = await getDepartmentPermissions(
|
||||
'视觉三部',
|
||||
departments,
|
||||
['标准模型', '详情模板', '种草视频'],
|
||||
)
|
||||
|
||||
expect(result['标准模型']).toMatchObject({
|
||||
department: '视觉三部',
|
||||
materialType: '标准模型',
|
||||
canView: false,
|
||||
canDownload: true,
|
||||
})
|
||||
expect(result['详情模板']).toMatchObject({
|
||||
department: '视觉三部',
|
||||
materialType: '详情模板',
|
||||
canView: true,
|
||||
canDownload: true,
|
||||
canDownloadSource: true,
|
||||
linkedToDepartment: '共享权限部',
|
||||
})
|
||||
expect(result['种草视频']).toMatchObject({
|
||||
department: '视觉三部',
|
||||
materialType: '种草视频',
|
||||
canView: true,
|
||||
linkedToDepartment: '视觉中心',
|
||||
})
|
||||
expect(prisma.departmentMaterialPermission.findMany).toHaveBeenCalledTimes(1)
|
||||
expect(prisma.departmentMaterialPermission.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('关联部门缺少对应配置时保留当前部门自己的权限', async () => {
|
||||
;(prisma.departmentMaterialPermission.findMany as jest.Mock).mockResolvedValue([
|
||||
permission({
|
||||
department: '视觉三部',
|
||||
materialType: '详情模板',
|
||||
canView: false,
|
||||
canDownload: true,
|
||||
linkedToDepartment: '共享权限部',
|
||||
}),
|
||||
])
|
||||
|
||||
const result = await getDepartmentPermissions(
|
||||
'视觉三部',
|
||||
departments,
|
||||
['详情模板'],
|
||||
)
|
||||
|
||||
expect(result['详情模板']).toMatchObject({
|
||||
department: '视觉三部',
|
||||
canView: false,
|
||||
canDownload: true,
|
||||
linkedToDepartment: '共享权限部',
|
||||
})
|
||||
})
|
||||
|
||||
test('同一用户的并发可见类型查询合并为一轮数据库读取', async () => {
|
||||
;(prisma.departmentMaterialPermission.findMany as jest.Mock).mockResolvedValue([
|
||||
permission({
|
||||
department: '视觉中心',
|
||||
materialType: '详情模板',
|
||||
canView: true,
|
||||
}),
|
||||
])
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 13 }, () =>
|
||||
getUserViewableMaterialTypes('视觉三部', 'USER', null, 'user-1'),
|
||||
),
|
||||
)
|
||||
|
||||
expect(results).toEqual(Array.from({ length: 13 }, () => ['详情模板']))
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1)
|
||||
expect(prisma.department.findMany).toHaveBeenCalledTimes(1)
|
||||
expect(prisma.material.findMany).toHaveBeenCalledTimes(1)
|
||||
expect(prisma.departmentMaterialPermission.findMany).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -1,5 +1,14 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { buildMaterialSearchOrConditions } from '@/lib/material-query'
|
||||
import {
|
||||
buildMaterialSearchOrConditions,
|
||||
buildMaterialWhereFromSearchParams,
|
||||
createMaterialQueryContext,
|
||||
} from '@/lib/material-query'
|
||||
import {
|
||||
getUserViewableMaterialTypes,
|
||||
isSuperAdmin,
|
||||
isSystemAdmin,
|
||||
} from '@/lib/department-permission'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
@ -23,6 +32,9 @@ describe('material search conditions', () => {
|
||||
jest.clearAllMocks()
|
||||
;(prisma.materialSku.findMany as jest.Mock).mockResolvedValue([])
|
||||
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
|
||||
;(getUserViewableMaterialTypes as jest.Mock).mockResolvedValue(['任务-详情'])
|
||||
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
|
||||
;(isSystemAdmin as unknown as jest.Mock).mockReturnValue(false)
|
||||
})
|
||||
|
||||
test('完整素材 UUID 应使用精确匹配', async () => {
|
||||
@ -39,4 +51,66 @@ describe('material search conditions', () => {
|
||||
|
||||
expect(conditions).toEqual(expect.arrayContaining([{ id: materialId }]))
|
||||
})
|
||||
|
||||
test('同一请求的多组筛选条件复用权限和搜索上下文', async () => {
|
||||
const searchParams = new URLSearchParams('search=GBGJ-BG0070&category=顾家')
|
||||
const user = {
|
||||
id: 'user-1',
|
||||
role: 'USER',
|
||||
department: '品牌创意部',
|
||||
}
|
||||
|
||||
const context = await createMaterialQueryContext(searchParams, user)
|
||||
const first = await buildMaterialWhereFromSearchParams(searchParams, user, { context })
|
||||
const second = await buildMaterialWhereFromSearchParams(searchParams, user, {
|
||||
context,
|
||||
exclude: 'category',
|
||||
})
|
||||
|
||||
expect(first.empty).toBe(false)
|
||||
expect(first.where.categoryName).toBe('顾家')
|
||||
expect(second.where.categoryName).toBeUndefined()
|
||||
expect(getUserViewableMaterialTypes).toHaveBeenCalledTimes(1)
|
||||
expect(isSuperAdmin).toHaveBeenCalledTimes(1)
|
||||
expect(prisma.materialSku.findMany).toHaveBeenCalledTimes(1)
|
||||
expect(prisma.user.findMany).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('复用上下文后仍拒绝普通用户查看回收站素材', async () => {
|
||||
await expect(
|
||||
createMaterialQueryContext(
|
||||
new URLSearchParams('showDeleted=true'),
|
||||
{ id: 'user-1', role: 'USER', department: '品牌创意部' },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN_DELETED_MATERIALS',
|
||||
status: 403,
|
||||
})
|
||||
})
|
||||
|
||||
test('复用上下文后仍拦截用户无权查看的素材类型', async () => {
|
||||
const searchParams = new URLSearchParams('materialType=3D场景')
|
||||
const user = {
|
||||
id: 'user-1',
|
||||
role: 'USER',
|
||||
department: '品牌创意部',
|
||||
}
|
||||
const context = await createMaterialQueryContext(searchParams, user)
|
||||
|
||||
const result = await buildMaterialWhereFromSearchParams(searchParams, user, { context })
|
||||
|
||||
expect(result.empty).toBe(true)
|
||||
})
|
||||
|
||||
test('超级管理员不读取部门素材权限', async () => {
|
||||
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
|
||||
|
||||
const context = await createMaterialQueryContext(
|
||||
new URLSearchParams(),
|
||||
{ id: 'super-admin', role: 'ADMIN' },
|
||||
)
|
||||
|
||||
expect(context.isSuperAdmin).toBe(true)
|
||||
expect(getUserViewableMaterialTypes).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
92
lib/__tests__/materials-facets-route.test.ts
Normal file
92
lib/__tests__/materials-facets-route.test.ts
Normal file
@ -0,0 +1,92 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { GET } from '@/app/api/materials/facets/route'
|
||||
import {
|
||||
buildMaterialWhereFromSearchParams,
|
||||
createMaterialQueryContext,
|
||||
} from '@/lib/material-query'
|
||||
|
||||
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
|
||||
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
material: {
|
||||
count: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
groupBy: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
jest.mock('@/lib/material-query', () => {
|
||||
class MaterialQueryError extends Error {
|
||||
status: number
|
||||
code: string
|
||||
|
||||
constructor(status: number, code: string, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
buildMaterialWhereFromSearchParams: jest.fn(),
|
||||
createMaterialQueryContext: jest.fn(),
|
||||
MaterialQueryError,
|
||||
normalizeMaterialTypeForUi: (value: string) => value,
|
||||
normalizeSelectableMaterialTypes: (values: string[]) => values,
|
||||
}
|
||||
})
|
||||
|
||||
describe('materials facets route', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||||
user: { id: 'user-1', department: '品牌创意部', role: 'USER' },
|
||||
})
|
||||
;(createMaterialQueryContext as jest.Mock).mockResolvedValue({
|
||||
canAccessDeleted: false,
|
||||
expandedViewableTypes: ['任务-详情'],
|
||||
isSuperAdmin: false,
|
||||
})
|
||||
;(buildMaterialWhereFromSearchParams as jest.Mock).mockResolvedValue({
|
||||
empty: false,
|
||||
where: { deletedAt: null, materialType: '任务-详情' },
|
||||
})
|
||||
})
|
||||
|
||||
test('一次请求只解析一次权限上下文,并把统计查询并发限制为 3', async () => {
|
||||
let activeQueries = 0
|
||||
let maxActiveQueries = 0
|
||||
const runQuery = async <T,>(result: T): Promise<T> => {
|
||||
activeQueries += 1
|
||||
maxActiveQueries = Math.max(maxActiveQueries, activeQueries)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1))
|
||||
activeQueries -= 1
|
||||
return result
|
||||
}
|
||||
|
||||
;(prisma.material.count as jest.Mock).mockImplementation(() => runQuery(0))
|
||||
;(prisma.material.findMany as jest.Mock).mockImplementation(() => runQuery([]))
|
||||
;(prisma.material.groupBy as jest.Mock).mockImplementation(() => runQuery([]))
|
||||
|
||||
const response = await GET({
|
||||
nextUrl: {
|
||||
searchParams: new URLSearchParams('materialType=任务-详情'),
|
||||
},
|
||||
} as any)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(createMaterialQueryContext).toHaveBeenCalledTimes(1)
|
||||
expect(buildMaterialWhereFromSearchParams).toHaveBeenCalledTimes(13)
|
||||
expect(maxActiveQueries).toBeLessThanOrEqual(3)
|
||||
|
||||
const context = (createMaterialQueryContext as jest.Mock).mock.results[0].value
|
||||
const resolvedContext = await context
|
||||
for (const call of (buildMaterialWhereFromSearchParams as jest.Mock).mock.calls) {
|
||||
expect(call[2]).toEqual(expect.objectContaining({ context: resolvedContext }))
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -92,4 +92,35 @@ describe('product-package complete public route security', () => {
|
||||
expect(body.error).toContain('缺少需求运营人员')
|
||||
expect(completeProductPackageUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('数据库连接繁忙时返回可理解的临时故障提示', async () => {
|
||||
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
|
||||
id: 'material-1',
|
||||
userId: 'user-1',
|
||||
materialType: '任务-详情',
|
||||
taskDetailRequesterUserId: 'requester-1',
|
||||
})
|
||||
;(completeProductPackageUpload as jest.Mock).mockRejectedValue(
|
||||
Object.assign(
|
||||
new Error('Transaction API error: Unable to start a transaction in the given time.'),
|
||||
{ code: 'P2028' },
|
||||
),
|
||||
)
|
||||
|
||||
const response = await POST({
|
||||
json: jest.fn().mockResolvedValue({
|
||||
materialId: 'material-1',
|
||||
assets: { main: [] },
|
||||
}),
|
||||
} as any)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
expect(response.headers.get('Retry-After')).toBe('5')
|
||||
expect(body).toEqual({
|
||||
success: false,
|
||||
code: 'DATABASE_BUSY',
|
||||
error: '系统繁忙,本次上传未完成,请稍后重新上传',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
64
lib/__tests__/task-material-processor-transaction.test.ts
Normal file
64
lib/__tests__/task-material-processor-transaction.test.ts
Normal file
@ -0,0 +1,64 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { saveProductPackageAssets } from '@/lib/task-material-processor'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
$transaction: jest.fn(),
|
||||
},
|
||||
}))
|
||||
jest.mock('@/lib/s3', () => ({ deleteFromS3: jest.fn() }))
|
||||
jest.mock('@/lib/public-display', () => ({
|
||||
buildMaterialPublicDisplayPath: jest.fn(),
|
||||
uploadPublicDisplayToS3: jest.fn(),
|
||||
}))
|
||||
jest.mock('@/lib/image-processor', () => ({ compressImage: jest.fn() }))
|
||||
jest.mock('@/lib/remote-upload-sync', () => ({
|
||||
deleteStoredUploadFileFromRemote: jest.fn(),
|
||||
syncStoredUploadFileToRemote: jest.fn(),
|
||||
}))
|
||||
|
||||
describe('task material database transaction', () => {
|
||||
test('任务详情完成事务允许等待短暂的连接池高峰', async () => {
|
||||
const tx = {
|
||||
materialAsset: {
|
||||
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||
create: jest.fn(),
|
||||
},
|
||||
materialAssetBarcode: {
|
||||
createMany: jest.fn(),
|
||||
},
|
||||
material: {
|
||||
update: jest.fn().mockResolvedValue({ id: 'material-1' }),
|
||||
},
|
||||
}
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation(
|
||||
async (callback: (client: typeof tx) => Promise<unknown>) => callback(tx),
|
||||
)
|
||||
|
||||
await saveProductPackageAssets(
|
||||
'material-1',
|
||||
{
|
||||
main: [{ fileUrl: 'https://s3.example/main.webp', fileName: 'main.webp' }],
|
||||
sku: [],
|
||||
detail: [],
|
||||
},
|
||||
{
|
||||
all: {
|
||||
filePath: 'materials/all.zip',
|
||||
fileName: 'all.zip',
|
||||
fileSize: 1024,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
{
|
||||
maxWait: 15000,
|
||||
timeout: 30000,
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -2,6 +2,9 @@ import { prisma } from './prisma';
|
||||
import { filterSelectableMaterialTypes, MATERIAL_TYPE_OPTIONS } from './material-type-options';
|
||||
import { getCompatibleVideoMaterialTypes, isVideoMaterialType } from './video-material-types';
|
||||
|
||||
const superAdminChecksInFlight = new Map<string, Promise<boolean>>();
|
||||
const viewableMaterialTypesInFlight = new Map<string, Promise<string[]>>();
|
||||
|
||||
/**
|
||||
* 判断是否是超级管理员(从数据库读取)
|
||||
*/
|
||||
@ -9,13 +12,25 @@ export async function isSuperAdmin(userId: string | null | undefined): Promise<b
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
|
||||
const existingCheck = superAdminChecksInFlight.get(userId);
|
||||
if (existingCheck) {
|
||||
return existingCheck;
|
||||
}
|
||||
|
||||
const check = prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { isSuperAdmin: true },
|
||||
});
|
||||
|
||||
return user?.isSuperAdmin ?? false;
|
||||
}).then((user) => user?.isSuperAdmin ?? false);
|
||||
|
||||
superAdminChecksInFlight.set(userId, check);
|
||||
try {
|
||||
return await check;
|
||||
} finally {
|
||||
if (superAdminChecksInFlight.get(userId) === check) {
|
||||
superAdminChecksInFlight.delete(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -139,6 +154,130 @@ export interface DepartmentPermission {
|
||||
linkedToDepartment: string | null;
|
||||
}
|
||||
|
||||
type DepartmentRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId?: string | null;
|
||||
};
|
||||
|
||||
type DepartmentPermissionRecord = {
|
||||
department: string;
|
||||
materialType: string;
|
||||
canView: boolean;
|
||||
canDownload: boolean;
|
||||
canShare: boolean;
|
||||
canDownloadSource: boolean;
|
||||
linkedToDepartment: string | null;
|
||||
};
|
||||
|
||||
function getDepartmentPermissionKey(department: string, materialType: string): string {
|
||||
return `${department}\u0000${materialType}`;
|
||||
}
|
||||
|
||||
function findDepartmentPermissionRecordFromMap(
|
||||
permissionMap: Map<string, DepartmentPermissionRecord>,
|
||||
department: string,
|
||||
materialType: string,
|
||||
): DepartmentPermissionRecord | null {
|
||||
const normalizedType = String(materialType || '').trim();
|
||||
const lookupTypes = isVideoMaterialType(normalizedType)
|
||||
? getCompatibleVideoMaterialTypes(normalizedType)
|
||||
: [normalizedType];
|
||||
|
||||
for (const lookupType of lookupTypes) {
|
||||
const permission = permissionMap.get(getDepartmentPermissionKey(department, lookupType));
|
||||
if (permission) {
|
||||
return permission;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveDepartmentPermissionFromMap(
|
||||
department: string,
|
||||
materialType: string,
|
||||
permissionMap: Map<string, DepartmentPermissionRecord>,
|
||||
departmentByName: Map<string, DepartmentRecord>,
|
||||
departmentById: Map<string, DepartmentRecord>,
|
||||
visitedDepartments: Set<string> = new Set(),
|
||||
): DepartmentPermission | null {
|
||||
if (visitedDepartments.has(department)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextVisitedDepartments = new Set(visitedDepartments);
|
||||
nextVisitedDepartments.add(department);
|
||||
|
||||
const permission = findDepartmentPermissionRecordFromMap(
|
||||
permissionMap,
|
||||
department,
|
||||
materialType,
|
||||
);
|
||||
|
||||
if (permission?.linkedToDepartment) {
|
||||
const linkedPermission = findDepartmentPermissionRecordFromMap(
|
||||
permissionMap,
|
||||
permission.linkedToDepartment,
|
||||
materialType,
|
||||
);
|
||||
|
||||
if (linkedPermission) {
|
||||
return {
|
||||
department,
|
||||
materialType,
|
||||
canView: linkedPermission.canView,
|
||||
canDownload: linkedPermission.canDownload,
|
||||
canShare: linkedPermission.canShare,
|
||||
canDownloadSource: linkedPermission.canDownloadSource,
|
||||
linkedToDepartment: permission.linkedToDepartment,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (permission) {
|
||||
return {
|
||||
department: permission.department,
|
||||
materialType: permission.materialType,
|
||||
canView: permission.canView,
|
||||
canDownload: permission.canDownload,
|
||||
canShare: permission.canShare,
|
||||
canDownloadSource: permission.canDownloadSource,
|
||||
linkedToDepartment: permission.linkedToDepartment,
|
||||
};
|
||||
}
|
||||
|
||||
const currentDepartment = departmentByName.get(department);
|
||||
const parentDepartment = currentDepartment?.parentId
|
||||
? departmentById.get(currentDepartment.parentId)
|
||||
: undefined;
|
||||
if (!parentDepartment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parentPermission = resolveDepartmentPermissionFromMap(
|
||||
parentDepartment.name,
|
||||
materialType,
|
||||
permissionMap,
|
||||
departmentByName,
|
||||
departmentById,
|
||||
nextVisitedDepartments,
|
||||
);
|
||||
if (!parentPermission) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
department,
|
||||
materialType,
|
||||
canView: parentPermission.canView,
|
||||
canDownload: parentPermission.canDownload,
|
||||
canShare: parentPermission.canShare,
|
||||
canDownloadSource: parentPermission.canDownloadSource,
|
||||
linkedToDepartment: parentPermission.linkedToDepartment || parentDepartment.name,
|
||||
};
|
||||
}
|
||||
|
||||
async function findDepartmentPermissionRecord(
|
||||
department: string,
|
||||
materialType: string
|
||||
@ -219,8 +358,6 @@ export async function getDepartmentPermission(
|
||||
|
||||
// 权限继承:如果没有找到当前部门的权限配置,尝试从父部门继承
|
||||
if (departments && departments.length > 0) {
|
||||
const { buildDepartmentDescendants, isDepartmentWithinScope } = await import('@/lib/departments');
|
||||
|
||||
// 构建部门映射
|
||||
const deptMap = new Map<string, { id: string; name: string; parentId?: string | null }>();
|
||||
departments.forEach(dept => {
|
||||
@ -233,8 +370,6 @@ export async function getDepartmentPermission(
|
||||
// 查找父部门
|
||||
const parentDept = departments.find(d => d.id === currentDept.parentId);
|
||||
if (parentDept) {
|
||||
console.log(`[Permission Inheritance] 部门 ${department} 无权限配置,尝试从父部门 ${parentDept.name} 继承 ${materialType} 权限`);
|
||||
|
||||
// 递归获取父部门的权限
|
||||
const parentPermission = await getDepartmentPermission(
|
||||
parentDept.name,
|
||||
@ -245,7 +380,6 @@ export async function getDepartmentPermission(
|
||||
|
||||
// 如果父部门有权限配置,继承父部门的权限
|
||||
if (parentPermission) {
|
||||
console.log(`[Permission Inheritance] 成功从父部门 ${parentDept.name} 继承权限: canDownload=${parentPermission.canDownload}`);
|
||||
return {
|
||||
department,
|
||||
materialType,
|
||||
@ -255,19 +389,12 @@ export async function getDepartmentPermission(
|
||||
canDownloadSource: parentPermission.canDownloadSource,
|
||||
linkedToDepartment: parentPermission.linkedToDepartment || parentDept.name,
|
||||
};
|
||||
} else {
|
||||
console.log(`[Permission Inheritance] 父部门 ${parentDept.name} 也没有 ${materialType} 权限配置`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[Permission Inheritance] 找不到父部门 (parentId=${currentDept.parentId})`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[Permission Inheritance] 部门 ${department} 没有父部门`);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果都没有找到,返回 null(表示没有权限配置)
|
||||
console.log(`[Permission Inheritance] 部门 ${department} 对于 ${materialType} 没有找到任何权限配置`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -286,22 +413,41 @@ export async function getDepartmentPermissions(
|
||||
): Promise<Record<string, DepartmentPermission>> {
|
||||
// 如果没有提供素材类型列表,从数据库获取所有素材类型
|
||||
const allMaterialTypes = materialTypes || await getAllMaterialTypes();
|
||||
|
||||
// 获取部门后裔映射(用于加速权限继承检查)
|
||||
const { buildDepartmentDescendants } = await import('@/lib/departments');
|
||||
const descendantMap = departments && departments.length > 0
|
||||
? buildDepartmentDescendants(departments)
|
||||
: undefined;
|
||||
|
||||
const permissionRecords = await prisma.departmentMaterialPermission.findMany({
|
||||
select: {
|
||||
department: true,
|
||||
materialType: true,
|
||||
canView: true,
|
||||
canDownload: true,
|
||||
canShare: true,
|
||||
canDownloadSource: true,
|
||||
linkedToDepartment: true,
|
||||
},
|
||||
});
|
||||
const permissionMap = new Map<string, DepartmentPermissionRecord>(
|
||||
permissionRecords.map((permission) => [
|
||||
getDepartmentPermissionKey(permission.department, permission.materialType),
|
||||
permission,
|
||||
]),
|
||||
);
|
||||
const departmentByName = new Map<string, DepartmentRecord>();
|
||||
const departmentById = new Map<string, DepartmentRecord>();
|
||||
for (const departmentRecord of departments || []) {
|
||||
departmentByName.set(departmentRecord.name, departmentRecord);
|
||||
departmentById.set(departmentRecord.id, departmentRecord);
|
||||
}
|
||||
|
||||
const result: Record<string, DepartmentPermission> = {};
|
||||
|
||||
// 为每种素材类型获取权限(包括继承的权限)
|
||||
// 权限记录一次性批量读取后,在内存中解析直接配置、关联部门和父部门继承。
|
||||
for (const materialType of allMaterialTypes) {
|
||||
const permission = await getDepartmentPermission(
|
||||
const permission = resolveDepartmentPermissionFromMap(
|
||||
department,
|
||||
materialType,
|
||||
departments,
|
||||
descendantMap
|
||||
permissionMap,
|
||||
departmentByName,
|
||||
departmentById,
|
||||
);
|
||||
|
||||
if (permission) {
|
||||
@ -340,6 +486,37 @@ export async function getUserViewableMaterialTypes(
|
||||
userRole: string,
|
||||
managedDepartment: string | null | undefined,
|
||||
userId?: string | null | undefined
|
||||
): Promise<string[]> {
|
||||
const requestKey = JSON.stringify([
|
||||
userDepartment || '',
|
||||
userRole || '',
|
||||
managedDepartment || '',
|
||||
userId || '',
|
||||
]);
|
||||
const existingRequest = viewableMaterialTypesInFlight.get(requestKey);
|
||||
if (existingRequest) {
|
||||
return [...(await existingRequest)];
|
||||
}
|
||||
|
||||
const request = loadUserViewableMaterialTypes(
|
||||
userDepartment,
|
||||
managedDepartment,
|
||||
userId,
|
||||
);
|
||||
viewableMaterialTypesInFlight.set(requestKey, request);
|
||||
try {
|
||||
return [...(await request)];
|
||||
} finally {
|
||||
if (viewableMaterialTypesInFlight.get(requestKey) === request) {
|
||||
viewableMaterialTypesInFlight.delete(requestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserViewableMaterialTypes(
|
||||
userDepartment: string | null | undefined,
|
||||
managedDepartment: string | null | undefined,
|
||||
userId?: string | null | undefined,
|
||||
): Promise<string[]> {
|
||||
// 超级管理员可以看到所有素材类型
|
||||
if (userId && await isSuperAdmin(userId)) {
|
||||
|
||||
@ -31,6 +31,13 @@ export interface MaterialWhereResult {
|
||||
empty: boolean
|
||||
}
|
||||
|
||||
export interface MaterialQueryContext {
|
||||
canAccessDeleted: boolean
|
||||
expandedViewableTypes: string[]
|
||||
isSuperAdmin: boolean
|
||||
searchOrConditions?: any[]
|
||||
}
|
||||
|
||||
export class MaterialQueryError extends Error {
|
||||
status: number
|
||||
code: string
|
||||
@ -201,20 +208,69 @@ function applyMonthFilter(where: any, month: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMaterialQueryContext(
|
||||
searchParams: URLSearchParams,
|
||||
user: MaterialSessionUser | null | undefined,
|
||||
): Promise<MaterialQueryContext> {
|
||||
if (!user?.id) {
|
||||
throw new MaterialQueryError(401, 'UNAUTHORIZED', '未登录')
|
||||
}
|
||||
|
||||
const requestedDeleted = isDeletedMaterialRequest(searchParams)
|
||||
const search = searchParams.get('search') || ''
|
||||
const [superAdmin, searchOrConditions] = await Promise.all([
|
||||
isSessionUserSuperAdmin(user),
|
||||
buildMaterialSearchOrConditions(search),
|
||||
])
|
||||
const canAccessDeleted = superAdmin || isSystemAdmin(user.role || '')
|
||||
|
||||
if (requestedDeleted && !canAccessDeleted) {
|
||||
throw new MaterialQueryError(403, 'FORBIDDEN_DELETED_MATERIALS', '无权限查看回收站素材')
|
||||
}
|
||||
|
||||
if (superAdmin) {
|
||||
return {
|
||||
canAccessDeleted,
|
||||
expandedViewableTypes: [],
|
||||
isSuperAdmin: true,
|
||||
searchOrConditions,
|
||||
}
|
||||
}
|
||||
|
||||
const viewableTypes = await getUserViewableMaterialTypes(
|
||||
user.department || '',
|
||||
user.role || 'USER',
|
||||
user.managedDepartment || null,
|
||||
// 上方已确认当前用户不是超级管理员,避免再次查询同一用户记录。
|
||||
null,
|
||||
)
|
||||
|
||||
return {
|
||||
canAccessDeleted,
|
||||
expandedViewableTypes: expandCompatibleMaterialTypes(viewableTypes),
|
||||
isSuperAdmin: false,
|
||||
searchOrConditions,
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildMaterialWhereFromSearchParams(
|
||||
searchParams: URLSearchParams,
|
||||
user: MaterialSessionUser | null | undefined,
|
||||
options: { exclude?: MaterialFilterDimension } = {},
|
||||
options: {
|
||||
context?: MaterialQueryContext
|
||||
exclude?: MaterialFilterDimension
|
||||
} = {},
|
||||
): Promise<MaterialWhereResult> {
|
||||
if (!user?.id) {
|
||||
throw new MaterialQueryError(401, 'UNAUTHORIZED', '未登录')
|
||||
}
|
||||
|
||||
const context = options.context || await createMaterialQueryContext(searchParams, user)
|
||||
const where: any = {}
|
||||
const requestedDeleted = isDeletedMaterialRequest(searchParams)
|
||||
|
||||
if (requestedDeleted) {
|
||||
if (!(await canAccessDeletedMaterials(user))) {
|
||||
if (!context.canAccessDeleted) {
|
||||
throw new MaterialQueryError(403, 'FORBIDDEN_DELETED_MATERIALS', '无权限查看回收站素材')
|
||||
}
|
||||
where.deletedAt = { not: null }
|
||||
@ -222,7 +278,6 @@ export async function buildMaterialWhereFromSearchParams(
|
||||
where.deletedAt = null
|
||||
}
|
||||
|
||||
const search = searchParams.get('search') || ''
|
||||
const category = searchParams.get('category') || ''
|
||||
const scene = searchParams.get('scene') || ''
|
||||
const materialType = searchParams.get('materialType') || ''
|
||||
@ -230,9 +285,8 @@ export async function buildMaterialWhereFromSearchParams(
|
||||
const productCategory = searchParams.get('productCategory') || ''
|
||||
const month = searchParams.get('month') || ''
|
||||
|
||||
const searchOrConditions = await buildMaterialSearchOrConditions(search)
|
||||
if (searchOrConditions) {
|
||||
where.OR = searchOrConditions
|
||||
if (context.searchOrConditions) {
|
||||
where.OR = context.searchOrConditions
|
||||
}
|
||||
|
||||
if (category && options.exclude !== 'category') {
|
||||
@ -260,17 +314,11 @@ export async function buildMaterialWhereFromSearchParams(
|
||||
where.materialType = materialTypeWhere(requestedTypes)
|
||||
}
|
||||
|
||||
if (await isSessionUserSuperAdmin(user)) {
|
||||
if (context.isSuperAdmin) {
|
||||
return { where, empty: false }
|
||||
}
|
||||
|
||||
const viewableTypes = await getUserViewableMaterialTypes(
|
||||
user.department || '',
|
||||
user.role || 'USER',
|
||||
user.managedDepartment || null,
|
||||
user.id,
|
||||
)
|
||||
const expandedViewableTypes = expandCompatibleMaterialTypes(viewableTypes)
|
||||
const expandedViewableTypes = context.expandedViewableTypes
|
||||
|
||||
if (expandedViewableTypes.length === 0) {
|
||||
return { where, empty: true }
|
||||
|
||||
@ -866,6 +866,10 @@ export async function saveProductPackageAssets(
|
||||
}
|
||||
|
||||
return createdAssets;
|
||||
}, {
|
||||
// 上传完成是长耗时文件处理的最后一步,允许短暂流量高峰后等待可用连接。
|
||||
maxWait: 15000,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
console.log(`[Database] Saved ${assetRecords.length + createdSkuPreviewAssets.length} asset records for product package`);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user