// services/api/src/albums/assets.service.ts —— 相册资产上传链路(P1b Task 3)。 // 契约权威:packages/contracts/openapi.yaml tags [assets]。设计要点: // · 行内一次性会话:init 即建 album_assets 行(state='pending'),assetId 就是 // 会话寻址凭据——无 clientTaskId 幂等键(契约:失败重试重新 init 即可, // 新行新键,旧行随下一次成功上传自然沉底)。为支撑 complete 核验,行内 // 持久化申明值(expectedBytes/contentType,migration asset_state)。 // · 对象键即最终键:photo-live/dev/albums/{albumId}/assets/{assetId}.{ext} // (契约 AssetInitResponse.objectKey)。与照片域不同,资产无 staging→ // originals 受控复制——键含 assetId 保证不覆盖他行对象,行 state='pending' // 期间公开查询不可见,即「逻辑 staging」;complete 核验通过置 effective。 // · 权限:资产是相册配置(非照片上传)——owner/admin 专属(canManageAlbum); // 协作者可见但无权 → 403 AUTH_FORBIDDEN;局外人不可见 → 404 // ALBUM_NOT_FOUND(同码防枚举)。uploadEnabled 开关不拦截资产:开关语义 // 是 §4.9 照片直传的相册级运营状态,banner/cover/logo 配置不受「收图」 // 影响(契约 assets 两端点均无 ALBUM_UPLOAD_DISABLED 错误码)。 // · 校验顺序(契约错误优先级,同 uploads 域):相册不可见 404 → 非属主 403 // → bytes/扩展名 400(contentType 白名单在 DTO 层)。 // · complete 核验(P1a Task 11 服务端兜底沿用):head 对象——缺失 400 // UPLOAD_VERIFY_FAILED、bytes 与申明不符 400、contentType 与申明不符 400 // (presign 不签名 content-type);失败是请求级错误,行保持 pending 可重试。 // 通过后事务内条件更新 state pending→effective(updateMany 抢占,并发 // complete 恰一人胜出)+ 胜者独占 audit(album.asset.add:actorId=操作者、 // resourceId=albumId、metadata 含 assetId/kind/locale——同 album.category.add // 锚式)。幂等:effective 行重复 complete 不再 head/不重复 audit(对象已被 // 清理也不受影响),仅现签新 URL 返回——本端点即签名刷新入口。 // · 替换语义:同 (kind, locale) 再上传 → 新行生效、旧行保留(P1b 无删除 // 端点);公开响应取最新 effective 行(public.service 按 locale 偏好解析)。 // · UPLOAD_MAX_BYTES/SIGNED_URL_TTL_SECONDS 逐请求惰性读取(uploads/support); // presign 为 SigV4 本地计算,无网络副作用。 import { Inject, Injectable } from '@nestjs/common'; import type { components } from '@photo/contracts'; import type { Prisma } from '@photo/db-cn'; import type { StoragePort } from '@photo/storage'; import type { AuthUser } from '../auth/session.guard'; import { buildAssetUpdatedPayload } from '../sync/payloads'; import { STORAGE_PORT } from '../uploads/storage.provider'; import { assetsObjectKey, cuidLikeId, extOfFilename, signedUrlTtlSeconds, uploadMaxBytes, uploadVerifyFailed, } from '../uploads/support'; import { PrismaService } from '../prisma/prisma.service'; import { albumForbidden, albumNotFound, validationFailed, } from './support'; import { PermissionsService } from './permissions.service'; import type { AssetInitRequestDto } from './dto'; export type AssetInitResponse = components['schemas']['AssetInitResponse']; export type AssetCompleteResponse = components['schemas']['AssetCompleteResponse']; @Injectable() export class AssetsService { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionsService, @Inject(STORAGE_PORT) private readonly storage: StoragePort, ) {} /** * 各 (kind, locale) 组合最新生效资产(契约 listAlbumAssetStatus):管理端 * 现状展示。权限同相册详情(可见即可读);deleted → 404。签名现签 * (SigV4 本地计算);同组取 createdAt,id DESC 首行(替换语义)。 */ async status( user: AuthUser, albumId: string, ): Promise { if (!(await this.permissions.canViewAlbum(user, albumId))) { throw albumNotFound(); } const album = await this.prisma.albums.findUnique({ where: { id: albumId }, select: { id: true, status: true }, }); if (album === null || album.status === 'deleted') { throw albumNotFound(); } const rows = await this.prisma.album_assets.findMany({ where: { albumId, state: 'effective' }, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], }); // 每 (kind, locale|null) 组合取首行(排序已保证最新在前) const seen = new Set(); const ttl = signedUrlTtlSeconds(); const items: components['schemas']['AssetStatusItem'][] = []; for (const row of rows) { const key = `${row.kind}|${row.locale ?? ''}`; if (seen.has(key)) continue; seen.add(key); const url = await this.storage.presignGet(row.objectKey, ttl); items.push({ kind: row.kind as components['schemas']['AssetStatusItem']['kind'], locale: (row.locale ?? null) as string | null, url, expiresAt: new Date(Date.now() + ttl * 1000).toISOString(), contentType: row.contentType, createdAt: row.createdAt.toISOString(), }); } return { items }; } /** * 初始化:权限/参数校验 → 建行(state=pending)→ presign。 * 每次 init 都是新行新键——无幂等裁决,天然无并发冲突锚。 */ async init( user: AuthUser, albumId: string, dto: AssetInitRequestDto, ): Promise { // ① 相册可见性:不可见(含不存在)统一 404,防 id 枚举(同相册域语义)。 if (!(await this.permissions.canViewAlbum(user, albumId))) { throw albumNotFound(); } // ② 资产管理权:owner/admin 专属(协作者可见但无权 → 403)。 if (!(await this.permissions.canManageAlbum(user, albumId))) { throw albumForbidden('仅创建者或管理员可管理资产'); } // ③ 相册行存在性:admin 对任意 id 权限恒真,须由空值兜底同码 404。 // 注意不查 uploadEnabled——开关只管照片直传(§4.9),不管资产配置。 const album = await this.prisma.albums.findUnique({ where: { id: albumId }, select: { id: true, status: true }, }); if (album === null || album.status === 'deleted') { // 已删除与不存在同码 404(软删除语义,deleteAlbum)。 throw albumNotFound(); } // ④ 大小限额(惰性读 env)与 filename 扩展名白名单(对象键后缀派生)。 const maxBytes = uploadMaxBytes(); if (dto.bytes > maxBytes) { throw validationFailed(`文件超过大小限额(limit=${maxBytes})`); } const ext = extOfFilename(dto.filename); if (ext === null) { throw validationFailed( 'filename 扩展名不在白名单(jpg/jpeg/png/heic)', ); } // ⑤ 建行:assetId 应用侧先生成(对象键含自引用键段,一次 insert 成形)。 const assetId = cuidLikeId(); const objectKey = assetsObjectKey(albumId, assetId, ext); await this.prisma.album_assets.create({ data: { id: assetId, albumId, kind: dto.kind, locale: dto.locale ?? null, objectKey, state: 'pending', expectedBytes: dto.bytes, contentType: dto.contentType, }, }); const credential = await this.storage.presignPut( objectKey, dto.bytes, dto.contentType, signedUrlTtlSeconds(), ); return { assetId, objectKey, credential }; } /** * 完成核验:head 核验 → 事务(条件置 effective + 胜者 audit)→ 现签 URL。 * 幂等:已生效行直接现签返回(不 head——对象清理后仍是合法刷新入口)。 */ async complete( user: AuthUser, albumId: string, assetId: string, ): Promise { if (!(await this.permissions.canViewAlbum(user, albumId))) { throw albumNotFound(); } if (!(await this.permissions.canManageAlbum(user, albumId))) { throw albumForbidden('仅创建者或管理员可管理资产'); } const asset = await this.prisma.album_assets.findUnique({ where: { id: assetId }, }); // 资产不存在或不在目标相册:与无权同码 403(契约:不区分以避免枚举)。 if (asset === null || asset.albumId !== albumId) { throw albumForbidden('无权操作该资产'); } if (asset.state !== 'effective') { // ① 事务外存储核验:存在性/字节数/contentType(申明值行内持久化)。 const head = await this.storage.head(asset.objectKey); if (head === null) { throw uploadVerifyFailed('资产对象不存在或尚未上传'); } if (head.bytes !== asset.expectedBytes) { throw uploadVerifyFailed( `字节数与申明不符(expected=${asset.expectedBytes}, actual=${head.bytes})`, ); } if (head.contentType !== asset.contentType) { throw uploadVerifyFailed( `Content-Type 与申明不符(expected=${asset.contentType}, actual=${head.contentType})`, ); } // ② 事务:条件更新抢占(并发 complete 恰一人胜出,state pending→effective // 迁移同时自增 entityVersion 并写 outbox asset.updated——P2 Task 3 起仅 // 生效迁移产生事件,幂等重复 complete 不再发)+ 胜者独占 audit。 await this.prisma.$transaction(async (tx) => { const race = await tx.album_assets.updateMany({ where: { id: asset.id, state: 'pending' }, data: { state: 'effective', entityVersion: { increment: 1 } }, }); if (race.count === 1) { const effective = await tx.album_assets.findUniqueOrThrow({ where: { id: asset.id }, }); await tx.outbox_events.create({ data: { type: 'asset.updated', albumId, entityVersion: effective.entityVersion, payload: buildAssetUpdatedPayload( effective, ) as unknown as Prisma.InputJsonValue, }, }); await tx.audit_logs.create({ data: { actorId: user.id, action: 'album.asset.add', resourceId: albumId, metadata: { assetId: asset.id, kind: asset.kind, locale: asset.locale, }, }, }); } }); } // ③ 现签短期 GET(幂等路径亦现签:本端点即签名刷新入口)。 const ttl = signedUrlTtlSeconds(); const url = await this.storage.presignGet(asset.objectKey, ttl); return { assetId: asset.id, url, expiresAt: new Date(Date.now() + ttl * 1000).toISOString(), }; } }