Files
photo-live/services/api/src/albums/assets.service.ts
T

218 lines
9.4 KiB
TypeScript
Raw Normal View History

2026-09-18 08:40:32 +08:00
// 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';
2026-09-18 08:40:32 +08:00
import type { StoragePort } from '@photo/storage';
import type { AuthUser } from '../auth/session.guard';
import { buildAssetUpdatedPayload } from '../sync/payloads';
2026-09-18 08:40:32 +08:00
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,
) {}
/**
* 初始化:权限/参数校验 → 建行(state=pending)→ presign。
* 每次 init 都是新行新键——无幂等裁决,天然无并发冲突锚。
*/
async init(
user: AuthUser,
albumId: string,
dto: AssetInitRequestDto,
): Promise<AssetInitResponse> {
// ① 相册可见性:不可见(含不存在)统一 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 },
2026-09-18 08:40:32 +08:00
});
if (album === null || album.status === 'deleted') {
// 已删除与不存在同码 404(软删除语义,deleteAlbum)。
2026-09-18 08:40:32 +08:00
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<AssetCompleteResponse> {
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。
2026-09-18 08:40:32 +08:00
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 } },
2026-09-18 08:40:32 +08:00
});
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,
},
});
2026-09-18 08:40:32 +08:00
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(),
};
}
}