Compare commits

...

1 Commits

Author SHA1 Message Date
chenpei
d624e2d82b fix: 修复上传事务连接池耗尽 2026-07-30 18:00:28 +08:00
10 changed files with 749 additions and 61 deletions

View File

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

View File

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

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

View File

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

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

View File

@ -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: '系统繁忙,本次上传未完成,请稍后重新上传',
})
})
})

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

View File

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

View File

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

View File

@ -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`);