feat(api): album update with independent upload/public switches

PATCH /v1/albums/{albumId}(契约 updateAlbum):部分更新,各开关互相独立;
translations 全量替换(deleteMany+createMany),defaultLocale 可同请求切换但
替换后的翻译集必须覆盖最终 defaultLocale。权限顺序 404(不可见)→403(可见
不可管)与 getAlbum 一致防枚举;合并校验(endAt vs 存量 startAt、单边提交)
在事务内对最终状态裁决。audit album.update 记录 metadata.fields(契约属性序)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 22:05:36 +08:00
parent 1c48a4108b
commit 1faefa4227
4 changed files with 544 additions and 12 deletions
+25 -9
View File
@@ -1,6 +1,6 @@
// services/api/src/albums/albums.controller.ts —— /albums 端点(Task 15)。
// 方法名与契约 operationId 一一对应(createAlbum/listAlbums/getAlbum;
// updateAlbum 属 Task 16)。控制器级 SessionGuard:三端点全需认证
// services/api/src/albums/albums.controller.ts —— /albums 端点(Task 15;Task 16
// 增补 updateAlbum)。方法名与契约 operationId 一一对应(createAlbum/
// listAlbums/getAlbum/updateAlbum)。控制器级 SessionGuard:四端点全需认证
// (契约均有 bearerAuth、无 security: [] 例外)。职责仅剩:
// · 透传认证用户(req.user,守卫挂载);
// · 查询参数规整(limit 1..100 整数、cursor 长度),非法 → 400
@@ -12,6 +12,7 @@ import {
Get,
HttpStatus,
Param,
Patch,
Post,
Query,
Req,
@@ -28,11 +29,14 @@ import {
type AlbumResponse,
AlbumsService,
} from './albums.service';
import { AlbumCreateRequestDto } from './dto';
import { AlbumCreateRequestDto, AlbumUpdateRequestDto } from './dto';
import { MAX_CURSOR_LENGTH } from '../shared/cursor';
/** 守卫放行后提取 req.user;防御性兜底(守卫未挂载属装配错误,按未认证拒绝)。 */
function userOf(req: AuthenticatedRequest): AuthUser {
/**
* 守卫放行后提取 req.user;防御性兜底(守卫未挂载属装配错误,按未认证拒绝)。
* members/categories 控制器(Task 17)复用同一判定,导出。
*/
export function userOf(req: AuthenticatedRequest): AuthUser {
if (req.user === undefined) {
throw new ApiException(
ErrorCodes.AUTH_TOKEN_EXPIRED,
@@ -43,8 +47,9 @@ function userOf(req: AuthenticatedRequest): AuthUser {
return req.user;
}
/** limit 查询参数:缺省 20,须为 1..100 的十进制整数(契约 bounds)。 */
function parseLimit(raw: string | undefined): number {
/** limit 查询参数:缺省 20,须为 1..100 的十进制整数(契约 bounds)。导出供
* members 列表(Task 17,同契约 bounds)复用。 */
export function parseLimit(raw: string | undefined): number {
if (raw === undefined || raw === '') {
return DEFAULT_ALBUM_PAGE_SIZE;
}
@@ -66,7 +71,9 @@ function parseLimit(raw: string | undefined): number {
return limit;
}
function parseCursor(raw: string | undefined): string | undefined {
/** cursor 查询参数:仅做长度规整(语义校验在服务层解码时);导出供 members
* 列表(Task 17)复用。 */
export function parseCursor(raw: string | undefined): string | undefined {
if (raw === undefined || raw === '') {
return undefined;
}
@@ -113,4 +120,13 @@ export class AlbumsController {
): Promise<AlbumResponse> {
return this.albumsService.get(userOf(req), albumId);
}
@Patch(':albumId')
updateAlbum(
@Param('albumId') albumId: string,
@Body() dto: AlbumUpdateRequestDto,
@Req() req: AuthenticatedRequest,
): Promise<AlbumResponse> {
return this.albumsService.update(userOf(req), albumId, dto);
}
}
+172 -1
View File
@@ -27,7 +27,7 @@ import {
encodeKeysetCursor,
keysetWhereDesc,
} from '../shared/cursor';
import type { AlbumCreateRequestDto } from './dto';
import type { AlbumCreateRequestDto, AlbumUpdateRequestDto } from './dto';
import { PermissionsService } from './permissions.service';
export type AlbumTranslationResponse =
@@ -43,6 +43,22 @@ export const MAX_ALBUM_PAGE_SIZE = 100;
const PUBLIC_TOKEN_LENGTH = 24;
const PUBLIC_TOKEN_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
/**
* updateAlbum 审计 metadata.fields 的字段名序列(契约 AlbumUpdateRequest
* 属性序):固定顺序保证同形请求产出同形审计,便于比对/去重。
*/
const UPDATE_FIELD_ORDER = [
'translations',
'defaultLocale',
'startAt',
'endAt',
'timezone',
'uploadEnabled',
'publicEnabled',
'downloadEnabled',
'originalDownloadEnabled',
] as const satisfies readonly (keyof AlbumUpdateRequestDto)[];
function generatePublicToken(): string {
const bytes = randomBytes(PUBLIC_TOKEN_LENGTH);
let token = '';
@@ -231,6 +247,161 @@ export class AlbumsService {
return album;
}
/**
* 部分更新(契约 updateAlbum):仅提交的字段被修改,各开关互相独立;
* translations 全量替换(deleteMany+createMany,提交即整组换血)。
* 权限判定顺序与 get/契约一致,防 id 枚举:
* 1. canViewAlbum=false → 404(非成员与非存在同码);
* 2. canManageAlbum=false → 403(协作者可见不可管);
* 3. 事务内再读行判存在(admin 视角 canView 恒真,空值兜底 404)。
* minProperties:1(空体 400)与跨字段合并校验(endAt vs 存量 startAt、
* defaultLocale vs 存量翻译行)都在事务内对「合并后的最终状态」裁决——
* 单边提交与同请求提交两种路径同一出口。事务:更新标量 + 替换翻译 +
* audit(album.update, metadata.fields=本次提交字段名)。
*/
async update(
user: AuthUser,
albumId: string,
dto: AlbumUpdateRequestDto,
): Promise<AlbumResponse> {
// 契约 minProperties:1。注意不能用 Object.keys(dto).length:经
// plainToInstance 后 DTO 声明的属性以 undefined 值成为自有键,空体同样
// 「有键」;以「存在任一已赋值字段」为空体判据(whitelist 已剥离未知
// 字段,只含未知字段的请求体同样按空体 400)。属性级装饰器在空体时
// 不运行(DTO 层无法表达 minProperties),在此拒绝——与创建路径的重复
// locale 检查同一 400 出口。
const fields = UPDATE_FIELD_ORDER.filter(
(key) => dto[key] !== undefined,
) as string[];
if (fields.length === 0) {
throw new ApiException(
ErrorCodes.VALIDATION_FAILED,
'请求体至少需要包含一个待更新字段',
HttpStatus.BAD_REQUEST,
);
}
if (dto.translations !== undefined) {
// 同 create:重复 locale 会在事务内 createMany 撞唯一索引变 500,先 400。
const locales = dto.translations.map((t) => t.locale);
if (new Set(locales).size !== locales.length) {
throw new ApiException(
ErrorCodes.VALIDATION_FAILED,
'translations 存在重复 locale',
HttpStatus.BAD_REQUEST,
);
}
}
if (!(await this.permissions.canViewAlbum(user, albumId))) {
throw albumNotFound();
}
if (!(await this.permissions.canManageAlbum(user, albumId))) {
throw new ApiException(
ErrorCodes.AUTH_FORBIDDEN,
'仅创建者或管理员可修改相册',
HttpStatus.FORBIDDEN,
);
}
await this.prisma.$transaction(async (tx) => {
const album = await tx.albums.findUnique({ where: { id: albumId } });
if (album === null) {
throw albumNotFound();
}
// 合并校验①:endAt(给值时)晚于合并后的 startAt(单边提交对比存量)。
const startAt = dto.startAt !== undefined ? new Date(dto.startAt) : album.startAt;
const endAt =
dto.endAt === undefined
? album.endAt
: dto.endAt === null
? null
: new Date(dto.endAt);
if (endAt !== null && endAt.getTime() <= startAt.getTime()) {
throw new ApiException(
ErrorCodes.VALIDATION_FAILED,
'endAt 必须晚于 startAt',
HttpStatus.BAD_REQUEST,
);
}
// 合并校验②:合并后的 defaultLocale 必须被合并后的翻译集覆盖。
const defaultLocale = dto.defaultLocale ?? album.defaultLocale;
if (dto.translations !== undefined) {
if (!dto.translations.some((t) => t.locale === defaultLocale)) {
throw new ApiException(
ErrorCodes.VALIDATION_FAILED,
'translations 必须包含(更新后的)defaultLocale 对应语言的条目',
HttpStatus.BAD_REQUEST,
);
}
} else if (dto.defaultLocale !== undefined) {
// 只切 defaultLocale:存量翻译行必须已含新语言。
const rows = await tx.album_translations.findMany({
where: { albumId },
select: { locale: true },
});
if (!rows.some((r) => r.locale === defaultLocale)) {
throw new ApiException(
ErrorCodes.VALIDATION_FAILED,
'defaultLocale 必须有对应翻译条目',
HttpStatus.BAD_REQUEST,
);
}
}
await tx.albums.update({
where: { id: albumId },
data: {
startAt: dto.startAt === undefined ? undefined : new Date(dto.startAt),
endAt:
dto.endAt === undefined
? undefined
: dto.endAt === null
? null
: new Date(dto.endAt),
timezone: dto.timezone,
defaultLocale: dto.defaultLocale,
uploadEnabled: dto.uploadEnabled,
publicEnabled: dto.publicEnabled,
downloadEnabled: dto.downloadEnabled,
originalDownloadEnabled: dto.originalDownloadEnabled,
},
});
if (dto.translations !== undefined) {
await tx.album_translations.deleteMany({ where: { albumId } });
await tx.album_translations.createMany({
data: dto.translations.map((t) => ({
albumId,
locale: t.locale,
title: t.title,
description: t.description ?? null,
venue: t.venue ?? null,
address: t.address ?? null,
})),
});
}
await tx.audit_logs.create({
data: {
actorId: user.id,
action: 'album.update',
resourceId: albumId,
metadata: { fields },
},
});
});
const album = await this.loadAlbum(albumId);
if (album === null) {
throw new ApiException(
ErrorCodes.INTERNAL_ERROR,
'Internal server error',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
return album;
}
/** 相册列表:admin 全量;否则成员行(status=active)对应的相册。 */
async list(
user: AuthUser,
+86 -2
View File
@@ -1,5 +1,5 @@
// services/api/src/albums/dto.ts —— 相册端点入参 DTO(Task 15)。
// 校验约束对齐契约 AlbumCreateRequest / AlbumTranslation:
// services/api/src/albums/dto.ts —— 相册端点入参 DTO(Task 15;Task 16 增补更新版)。
// 校验约束对齐契约 AlbumCreateRequest / AlbumUpdateRequest / AlbumTranslation:
// · translations 至少 1 条(minItems: 1),locale 限 P1a 双语枚举;
// · defaultLocale 必须有对应翻译条目(跨字段校验,契约 required 语义);
// · endAt 可空(契约 [string,'null']),给值时须晚于 startAt(跨字段校验);
@@ -138,3 +138,87 @@ export class AlbumCreateRequestDto {
@IsBoolean()
originalDownloadEnabled?: boolean;
}
/**
* 更新版 defaultLocale 覆盖约束(契约 AlbumUpdateRequest):
* 仅当同一请求里 defaultLocale 与 translations 同时出现时裁决——
* defaultLocale 未提交时本约束放行(服务层对存量 defaultLocale 做合并校验,
* 覆盖「只换 translations」与「只切 defaultLocale」两种单边情形)。
*/
@ValidatorConstraint({ name: 'albumUpdateDefaultLocaleCovered', async: false })
class UpdateDefaultLocaleCoveredConstraint
implements ValidatorConstraintInterface
{
validate(value: unknown, args: ValidationArguments): boolean {
if (value === undefined || value === null) {
return true; // 本次请求不改 defaultLocale。
}
const { translations } = args.object as { translations?: unknown };
if (!Array.isArray(translations) || translations.length === 0) {
return true; // translations 不在本次请求里——交服务层对存量行校验。
}
return translations.some(
(entry) =>
entry !== null &&
typeof entry === 'object' &&
(entry as { locale?: unknown }).locale === value,
);
}
defaultMessage(): string {
return 'translations 必须包含(更新后的)defaultLocale 对应语言的条目';
}
}
/**
* 契约 AlbumUpdateRequest:全部字段可选 + minProperties:1。
* minProperties 无法用 class-validator 内建表达(空体时任何属性级装饰器都
* 不会运行),空体拒绝由服务层 update() 入口承担(与创建路径的重复 locale
* 检查同一模式);跨字段约束分两层:
* · DTO 层——同请求内 endAt>startAt(复用创建版约束:startAt 缺席时放行)、
* defaultLocale+translations 同请求时的覆盖关系;
* · 服务层——与存量行合并后的时间先后 / defaultLocale 覆盖(单边提交场景:
* 只改 endAt 对比存量 startAt、只切 defaultLocale 对比存量翻译行)。
*/
export class AlbumUpdateRequestDto {
@IsOptional()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => AlbumTranslationDto)
translations?: AlbumTranslationDto[];
@IsOptional()
@IsIn(ALBUM_LOCALES)
@Validate(UpdateDefaultLocaleCoveredConstraint)
defaultLocale?: AlbumLocale;
@IsOptional()
@IsISO8601({ strict: true })
startAt?: string;
@IsOptional()
@IsISO8601({ strict: true })
@Validate(EndAfterStartConstraint)
endAt?: string | null;
@IsOptional()
@IsString()
@MaxLength(TIMEZONE_MAX_LENGTH)
timezone?: string;
@IsOptional()
@IsBoolean()
uploadEnabled?: boolean;
@IsOptional()
@IsBoolean()
publicEnabled?: boolean;
@IsOptional()
@IsBoolean()
downloadEnabled?: boolean;
@IsOptional()
@IsBoolean()
originalDownloadEnabled?: boolean;
}