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:
浅念
2026-09-18 08:40:32 +08:00
parent c2a9cc51f3
commit 361a72f300
10 changed files with 974 additions and 24 deletions
+24 -5
View File
@@ -1,20 +1,39 @@
// services/api/src/albums/albums.module.ts —— 相册模块(Task 14 起步,Task 15
// 补全,Task 16 增补 PATCH,Task 17 增补成员/分类)。PermissionsService(A02
// 权限基石)+ AlbumsService/MembersService/CategoriesService 与对应控制器。
// 补全,Task 16 增补 PATCH,Task 17 增补成员/分类,P1b Task 3 增补资产上传)。
// PermissionsService(A02 权限基石)+ AlbumsService/MembersService/
// CategoriesService/AssetsService 与对应控制器。
// export PermissionsService 供 uploads/photos 等后续模块经模块间 DI 复用同一
// 份判定逻辑;AlbumsService 等暂无外部消费者不 export。
// 份权限判定逻辑;AlbumsService 等暂无外部消费者不 export。
// StoragePort 装配说明(P1b Task 3):UploadsModule 为 PermissionsService 反向
// import 本模块,本模块若再 import UploadsModule 取 STORAGE_PORT 会成环;
// 故直接复用同一 provider 工厂(env 构造、S3Client 懒连接,双实例零成本)。
import { Module } from '@nestjs/common';
import { AlbumsController } from './albums.controller';
import { AlbumsService } from './albums.service';
import { AssetsController } from './assets.controller';
import { AssetsService } from './assets.service';
import { CategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
import { MembersController } from './members.controller';
import { MembersService } from './members.service';
import { PermissionsService } from './permissions.service';
import { storageProvider } from '../uploads/storage.provider';
@Module({
controllers: [AlbumsController, MembersController, CategoriesController],
providers: [PermissionsService, AlbumsService, MembersService, CategoriesService],
controllers: [
AlbumsController,
MembersController,
CategoriesController,
AssetsController,
],
providers: [
PermissionsService,
AlbumsService,
MembersService,
CategoriesService,
AssetsService,
storageProvider,
],
exports: [PermissionsService],
})
export class AlbumsModule {}
@@ -0,0 +1,51 @@
// services/api/src/albums/assets.controller.ts —— /albums/{albumId}/assets
// 端点(P1b Task 3)。方法名与契约 operationId 一一对应(initAlbumAssetUpload/
// completeAlbumAssetUpload)。控制器级 SessionGuard:两端点全需认证。控制器只做
// 认证用户与路径参数透传;权限与 HTTP 映射由 AssetsService 抛 ApiException
// (404 ALBUM_NOT_FOUND / 403 AUTH_FORBIDDEN / 400 VALIDATION_FAILED 或
// UPLOAD_VERIFY_FAILED)。
import {
Body,
Controller,
HttpCode,
HttpStatus,
Param,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
import { userOf } from './albums.controller';
import { AssetInitRequestDto } from './dto';
import {
AssetsService,
type AssetCompleteResponse,
type AssetInitResponse,
} from './assets.service';
@Controller('albums/:albumId/assets')
@UseGuards(SessionGuard)
export class AssetsController {
constructor(private readonly assetsService: AssetsService) {}
/** 契约 201:会话行已建(每次 init 新行新键,无幂等键)。 */
@Post()
initAlbumAssetUpload(
@Param('albumId') albumId: string,
@Body() dto: AssetInitRequestDto,
@Req() req: AuthenticatedRequest,
): Promise<AssetInitResponse> {
return this.assetsService.init(userOf(req), albumId, dto);
}
/** 完成核验:幂等(已生效行现签新 URL 返回,即签名刷新入口)。 */
@Post(':assetId/complete')
@HttpCode(HttpStatus.OK)
completeAlbumAssetUpload(
@Param('albumId') albumId: string,
@Param('assetId') assetId: string,
@Req() req: AuthenticatedRequest,
): Promise<AssetCompleteResponse> {
return this.assetsService.complete(userOf(req), albumId, assetId);
}
}
+199
View File
@@ -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(),
};
}
}
+41 -2
View File
@@ -1,7 +1,8 @@
// services/api/src/albums/dto.ts —— 相册端点入参 DTO(Task 15;Task 16 增补更新版;
// Task 17 增补成员/分类)。校验约束对齐契约
// Task 17 增补成员/分类;P1b Task 3 增补资产)。校验约束对齐契约
// AlbumCreateRequest / AlbumUpdateRequest / MemberAddRequest /
// CategoryCreateRequest / CategoryUpdateRequest / AlbumTranslation:
// CategoryCreateRequest / CategoryUpdateRequest / AlbumTranslation /
// AssetInitRequest:
// · translations 至少 1 条(minItems: 1),locale 限 P1a 双语枚举;
// · defaultLocale 必须有对应翻译条目(跨字段校验,契约 required 语义);
// · endAt 可空(契约 [string,'null']),给值时须晚于 startAt(跨字段校验);
@@ -29,6 +30,10 @@ import {
type ValidatorConstraintInterface,
type ValidationArguments,
} from 'class-validator';
import {
UPLOAD_CONTENT_TYPES,
type UploadContentType,
} from '../uploads/support';
/** 契约 Locale 枚举(P1a 双语)。 */
export const ALBUM_LOCALES = ['zh-CN', 'en'] as const;
@@ -294,3 +299,37 @@ export class CategoryUpdateRequestDto {
@Type(() => CategoryTranslationDto)
translations?: CategoryTranslationDto[];
}
// ── 资产(P1b Task 3)──
/** 契约 AssetKind:banner(观众页宣传图,PublicAlbum.banner 消费方);cover/logo 建链就绪,展示消费随后续阶段。 */
export const ASSET_KINDS = ['banner', 'cover', 'logo'] as const;
export type AssetKind = (typeof ASSET_KINDS)[number];
/**
* 契约 AssetInitRequest。大小限额 UPLOAD_MAX_BYTES 与 filename 扩展名白名单是
* 环境相关/派生校验,在服务层做(同 UploadInitRequestDto 约定);contentType
* 与照片直传共用白名单(image/heic 暂定,G2 真机验证)。
*/
export class AssetInitRequestDto {
@IsIn(ASSET_KINDS)
kind!: AssetKind;
/** 可选语言维度:同 (kind, locale) 再上传即替换(新行生效);语言无关资产(如 logo)不传。 */
@IsOptional()
@IsIn(ALBUM_LOCALES)
locale?: AlbumLocale;
/** 原始文件名,仅元数据;扩展名白名单校验在服务层(对象键后缀派生)。 */
@IsString()
@IsNotEmpty()
@MaxLength(255)
filename!: string;
@IsInt()
@Min(1)
bytes!: number;
@IsIn(UPLOAD_CONTENT_TYPES)
contentType!: UploadContentType;
}
+17 -8
View File
@@ -9,8 +9,10 @@
// 决策),与缺省同样走回退;resolvedLocale 向客户端指示实际返回语言。
// · 分类名称同语言解析:resolved locale → defaultLocale → 任一既有条目
// (确定性:locale 升序取首);分类创建必附翻译,末级分支纯防御。
// · banner:album_assets(kind=banner) 多行时取最新一条(createdAt DESC,
// id DESC 决胜);P1a 无按语言选封面概念(locale 列为 P1b 上传链路预留)。
// · banner(P1b Task 3 起对接上传链路):album_assets(kind=banner,
// state='effective') 中按语言偏好解析(需求 4.7)——resolved locale 行 →
// 全局行(locale NULL)→ null;同级取最新(createdAt,id DESC 决胜,同
// (kind, locale) 再上传即替换语义)。
// · 签名 URL 一律现签(SIGNED_URL_TTL_SECONDS 惰性读取,SigV4 本地计算无
// 网络副作用);单图定位端点是契约规定的过期刷新入口。
// · viewCount:P1a 占位 null(§7.1 /visits 端点后续提供)。
@@ -100,15 +102,22 @@ export class PublicService {
bucket.set(row.locale, row.name);
}
// banner:最新 banner 资产行现签(无行 → null)。
const bannerAsset = await this.prisma.album_assets.findFirst({
where: { albumId: album.id, kind: 'banner' },
// banner(P1b Task 3 起对接上传链路):仅 state='effective' 行参与;语言
// 偏好解析(需求 4.7)——resolved locale 行 → 全局行(locale NULL)→ null。
// 以「实际展示语言」(translation 解析结果)为偏好驱动:banner 跟随观众
// 看到的文案语言,而非原始请求值(请求 fr 回退 zh 时配 zh banner)。
// 同级多行取最新(createdAt,id DESC 决胜):同 (kind, locale) 再上传即替换
// 语义——新行生效、旧行保留,此处取最新 effective 行。
const bannerAssets = await this.prisma.album_assets.findMany({
where: { albumId: album.id, kind: 'banner', state: 'effective' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});
const bannerRow =
bannerAssets.find((row) => row.locale === resolved.locale) ??
bannerAssets.find((row) => row.locale === null) ??
null;
const banner =
bannerAsset === null
? null
: await this.presign(bannerAsset.objectKey);
bannerRow === null ? null : await this.presign(bannerRow.objectKey);
return {
publicToken: album.publicToken,
+14
View File
@@ -80,6 +80,20 @@ export function originalsObjectKey(
return `${OBJECT_KEY_ROOT}/albums/${albumId}/originals/${photoId}.${ext}`;
}
/**
* assets 正式键(album_assets.objectKey,P1b Task 3)。与照片域不同,资产无
* staging→originals 受控复制:键含 assetId(每次 init 新键,不存在覆盖他行
* 对象的风险),行 state='pending' 期间公开查询不可见即「逻辑 staging」,
* complete 核验通过置 effective 后同一键即最终键。
*/
export function assetsObjectKey(
albumId: string,
assetId: string,
ext: string,
): string {
return `${OBJECT_KEY_ROOT}/albums/${albumId}/assets/${assetId}.${ext}`;
}
/** 取对象键尾缀扩展名(幂等冲突比对用:filename 未持久化,以扩展名近似锚定)。 */
export function extOfObjectKey(objectKey: string): string | null {
const dot = objectKey.lastIndexOf('.');