feat(api): album asset upload chain
P1b Task 3: initAlbumAssetUpload / completeAlbumAssetUpload (tags
[assets]) over album_assets with in-row one-shot sessions.
- migration asset_state: album_assets + state (pending|effective),
expectedBytes, contentType (utf8mb4_0900_bin); pre-existing rows
backfilled to effective. Complete head-verifies bytes AND
contentType vs the init declaration (P1a Task 11 server-side
fallback), then conditionally flips pending->effective with the
winner writing the album.asset.add audit (resourceId=albumId,
metadata assetId/kind/locale). Effective rows re-complete
idempotently (re-signed URL, no re-verify, survives object
cleanup).
- object key is the final key (albums/{albumId}/assets/{assetId}.{ext});
the pending row is the logical staging - no copy step, keys embed
assetId so re-init can never overwrite another row's object.
- permissions: owner/admin only (collaborator 403, outsider 404);
uploadEnabled does NOT gate assets (switch governs photo uploads
per 4.9 - both endpoints carry no ALBUM_UPLOAD_DISABLED).
- public banner now locale-aware (req 4.7): effective rows resolved
as display-locale row -> global (locale NULL) row -> null, latest
wins per tier (same-kind re-upload replaces, old rows retained).
- e2e: test/assets.e2e-spec.ts (14 cases, real jpegs via sharp,
MinIO round-trips); public spec seed marked effective.
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
// 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 { StoragePort } from '@photo/storage';
|
||||
import type { AuthUser } from '../auth/session.guard';
|
||||
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 },
|
||||
});
|
||||
if (album === null) {
|
||||
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 恰一人胜出)+ 胜者独占 audit。
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const race = await tx.album_assets.updateMany({
|
||||
where: { id: asset.id, state: 'pending' },
|
||||
data: { state: 'effective' },
|
||||
});
|
||||
if (race.count === 1) {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user