import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import type { ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { OSS_PROVIDER } from '../../integrations/integrations.constants'; import type { IOssProvider } from '../../integrations/oss/oss.interface'; import { logOssUpload, type OssActorRef } from '../../integrations/oss/oss-log.util'; import type { ResourceListQueryDto } from './dto/common-query.dto'; import type { RegisterResourceDto, UpdateResourceDto, UploadFileDto, UploadTokenDto } from './dto/common-mutate.dto'; const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; export type OssUploadActor = OssActorRef & { clientApp?: string; }; @Injectable() export class ResourceService { constructor( private readonly prisma: PrismaService, @Inject(OSS_PROVIDER) private readonly oss: IOssProvider, ) {} getUploadToken(dto: UploadTokenDto, actor?: OssUploadActor) { try { const result = this.oss.getUploadToken(dto); void logOssUpload(this.prisma, { scene: 'UPLOAD_TOKEN', actorRef: actor, requestBody: { bizType: dto.bizType, mediaType: dto.mediaType, fileName: dto.fileName, clientApp: actor?.clientApp, }, responseBody: { bucket: result.bucket, ossKey: result.ossKey, url: result.url, mock: result.mock ?? false, }, externalNo: result.ossKey, status: 'SUCCESS', }); return result; } catch (err) { void logOssUpload(this.prisma, { scene: 'UPLOAD_TOKEN', actorRef: actor, requestBody: { bizType: dto.bizType, mediaType: dto.mediaType, fileName: dto.fileName, clientApp: actor?.clientApp, }, status: 'FAILED', errorMessage: err instanceof Error ? err.message : String(err), }); throw err; } } async uploadFile( file: Express.Multer.File | undefined, dto: UploadFileDto, actor?: OssUploadActor, ) { const baseRequest = { bizType: dto.bizType, mediaType: dto.mediaType, clientApp: actor?.clientApp, }; if (!file) { await logOssUpload(this.prisma, { scene: 'UPLOAD_PUT_OBJECT', actorRef: actor, requestBody: baseRequest, status: 'FAILED', errorMessage: '请选择要上传的文件', }); throw new BadRequestException('请选择要上传的文件'); } const maxUploadBytes = Number(process.env.OSS_MAX_UPLOAD_BYTES ?? DEFAULT_MAX_BYTES); const requestBody = { ...baseRequest, fileName: file.originalname || 'upload.bin', fileSize: file.size, mimeType: file.mimetype, }; if (file.size > maxUploadBytes) { const message = `文件不能超过 ${Math.floor(maxUploadBytes / 1024 / 1024)}MB`; await logOssUpload(this.prisma, { scene: 'UPLOAD_PUT_OBJECT', actorRef: actor, requestBody, status: 'FAILED', errorMessage: message, }); throw new BadRequestException(message); } if (dto.bizType === 'AVATAR' && !file.mimetype?.startsWith('image/')) { throw new BadRequestException('头像仅支持图片文件'); } try { const result = await this.oss.putObject({ bizType: dto.bizType, mediaType: dto.mediaType, fileName: file.originalname || 'upload.bin', buffer: file.buffer, mimeType: file.mimetype, }); await logOssUpload(this.prisma, { scene: 'UPLOAD_PUT_OBJECT', actorRef: actor, requestBody, responseBody: { bucket: result.bucket, region: result.region, ossKey: result.ossKey, url: result.url, mock: result.mock ?? false, }, externalNo: result.ossKey, status: 'SUCCESS', }); if (actor?.refType === 'USER' && dto.bizType === 'AVATAR' && dto.mediaType === 'IMAGE') { const resource = await this.prisma.commonResource.create({ data: { ownerType: 'USER', ownerId: actor.refId, bizType: 'AVATAR', mediaType: 'IMAGE', ossBucket: result.bucket, ossKey: result.ossKey, url: result.url, fileName: file.originalname || 'avatar', fileSize: BigInt(file.size), mimeType: file.mimetype, status: 'ACTIVE', }, }); return serializeBigInt({ ...result, resourceId: resource.id }); } return result; } catch (err) { await logOssUpload(this.prisma, { scene: 'UPLOAD_PUT_OBJECT', actorRef: actor, requestBody, status: 'FAILED', errorMessage: err instanceof Error ? err.message : String(err), }); throw err; } } async getOwnedActiveAvatar(resourceId: bigint, userId: bigint) { const resource = await this.prisma.commonResource.findFirst({ where: { id: resourceId, ownerType: 'USER', ownerId: userId, bizType: 'AVATAR', mediaType: 'IMAGE', status: 'ACTIVE', }, }); if (!resource) throw new BadRequestException('头像资源无效或不属于当前用户'); return resource; } async getOwnedActiveAvatarByUrl(url: string, userId: bigint) { const resource = await this.prisma.commonResource.findFirst({ where: { url, ownerType: 'USER', ownerId: userId, bizType: 'AVATAR', mediaType: 'IMAGE', status: 'ACTIVE', }, orderBy: { createdAt: 'desc' }, }); if (!resource) throw new BadRequestException('头像资源无效或不属于当前用户'); return resource; } async register(dto: RegisterResourceDto) { const resource = await this.prisma.commonResource.create({ data: { ownerType: dto.ownerType as ResourceOwnerType, ownerId: BigInt(dto.ownerId), bizType: dto.bizType as ResourceBizType, mediaType: dto.mediaType as ResourceMediaType, ossBucket: dto.ossBucket ?? process.env.OSS_BUCKET ?? 'mock-dukang', ossKey: dto.ossKey, url: dto.url || this.oss.buildPublicUrl(dto.ossKey), fileName: dto.fileName, sortOrder: dto.sortOrder ?? 0, }, }); return serializeBigInt(resource); } async list(query: ResourceListQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.CommonResourceWhereInput = { status: (query.status ?? 'ACTIVE') as Prisma.EnumResourceStatusFilter['equals'], }; if (query.ownerType) where.ownerType = query.ownerType as Prisma.EnumResourceOwnerTypeFilter['equals']; if (query.ownerId) where.ownerId = BigInt(query.ownerId); if (query.bizType) where.bizType = query.bizType as Prisma.EnumResourceBizTypeFilter['equals']; const [items, total] = await Promise.all([ this.prisma.commonResource.findMany({ where, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.commonResource.count({ where }), ]); return serializeBigInt({ items, total, page, pageSize }); } async detail(id: bigint) { const resource = await this.prisma.commonResource.findUnique({ where: { id } }); if (!resource) throw new NotFoundException('资源不存在'); return serializeBigInt(resource); } async update(id: bigint, dto: UpdateResourceDto) { await this.detail(id); const resource = await this.prisma.commonResource.update({ where: { id }, data: { ...(dto.url !== undefined ? { url: dto.url, ossKey: dto.url } : {}), ...(dto.mediaType !== undefined ? { mediaType: dto.mediaType as 'IMAGE' | 'VIDEO' | 'FILE' } : {}), ...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}), ...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DELETED' } : {}), }, }); return serializeBigInt(resource); } async remove(id: bigint) { await this.detail(id); await this.prisma.commonResource.update({ where: { id }, data: { status: 'DELETED' }, }); return { ok: true }; } }