2618ad3163
- 契约 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>
262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
// 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(),
|
||
};
|
||
}
|
||
}
|