Files
photo-live/services/api/src/albums/assets.service.ts
T
浅念 2618ad3163 feat(assets): album asset status — show current banner/cover/logo in admin branding tab
- 契约 listAlbumAssetStatus(GET /albums/{albumId}/assets):每 (kind,locale)
  最新 effective 行 + 现签 URL;权限同详情(可见即读);deleted → 404
- 服务端 AssetsService.status(createdAt,id DESC 组内取首;presignGet 现签)
- Admin 品牌资产页签改造:进入即加载现状——三种 kind 卡片(预览图/未设置
  空态/设置时间),banner 分全局/zh/en 槽位;上传成功自动刷新(替换语义)
- API 56/56、admin 87/87 + build 全绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 10:51:53 +08:00

262 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<components['schemas']['AssetStatusResponse']> {
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<string>();
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<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 },
});
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<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。
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(),
};
}
}