feat(api): members and categories management
成员(/v1/albums/{albumId}/members):owner/管理员邀请已开通同事为协作者
(重复 409 MEMBER_CONFLICT、owner 目标 400、未开通/停用账号 400)、成员列表
((addedAt,id) DESC 键集分页)、移除协作者(幂等 204;成员行是唯一权限来源,
删除行即失去可见性 A02;owner 不可移除 403)。分类(/albums/{albumId}/categories
与 /categories/{categoryId}):创建/部分更新/删除,translations 全量替换,
相册详情按 sortOrder 升序带出;404 统一 ALBUM_NOT_FOUND(分类不存在与相册
不可见同码)。审计 album.member.add/remove、album.category.add/update/remove
(resourceId=albumId,metadata 记目标 id/字段)。共用错误/P2002 判定抽至
support.ts(修复 meta.target 为 DB 约束名时的误判)。
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,20 @@
|
|||||||
// services/api/src/albums/albums.module.ts —— 相册模块(Task 14 起步,Task 15 补全)。
|
// services/api/src/albums/albums.module.ts —— 相册模块(Task 14 起步,Task 15
|
||||||
// PermissionsService(A02 权限基石)+ AlbumsService/AlbumsController(创建/详情/
|
// 补全,Task 16 增补 PATCH,Task 17 增补成员/分类)。PermissionsService(A02
|
||||||
// 列表;Task 16 增补 PATCH)。export PermissionsService 供 uploads/photos 等后续
|
// 权限基石)+ AlbumsService/MembersService/CategoriesService 与对应控制器。
|
||||||
// 模块经模块间 DI 复用同一份判定逻辑;AlbumsService 暂无外部消费者不 export。
|
// export PermissionsService 供 uploads/photos 等后续模块经模块间 DI 复用同一
|
||||||
|
// 份判定逻辑;AlbumsService 等暂无外部消费者不 export。
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AlbumsController } from './albums.controller';
|
import { AlbumsController } from './albums.controller';
|
||||||
import { AlbumsService } from './albums.service';
|
import { AlbumsService } from './albums.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 { PermissionsService } from './permissions.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AlbumsController],
|
controllers: [AlbumsController, MembersController, CategoriesController],
|
||||||
providers: [PermissionsService, AlbumsService],
|
providers: [PermissionsService, AlbumsService, MembersService, CategoriesService],
|
||||||
exports: [PermissionsService],
|
exports: [PermissionsService],
|
||||||
})
|
})
|
||||||
export class AlbumsModule {}
|
export class AlbumsModule {}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// services/api/src/albums/albums.service.ts —— 相册创建/详情/列表核心逻辑(Task 15)。
|
// services/api/src/albums/albums.service.ts —— 相册创建/详情/更新/列表核心逻辑
|
||||||
// 契约权威:packages/contracts/openapi.yaml tags [albums](createAlbum/getAlbum/
|
// (Task 15;update 自 Task 16)。契约权威:packages/contracts/openapi.yaml
|
||||||
// listAlbums;updateAlbum 属 Task 16)。要点:
|
// tags [albums](createAlbum/getAlbum/updateAlbum/listAlbums)。要点:
|
||||||
// · 创建走单事务:albums + album_translations(批量)+ album_members(owner)
|
// · 创建走单事务:albums + album_translations(批量)+ album_members(owner)
|
||||||
// + audit_logs(album.create)——四表要么全成要么全无,杜绝「无主相册」
|
// + audit_logs(album.create)——四表要么全成要么全无,杜绝「无主相册」
|
||||||
// (权限全部源自成员行,Task 14 结论:漏写 owner 行会让创建者自己不可见)。
|
// (权限全部源自成员行,Task 14 结论:漏写 owner 行会让创建者自己不可见)。
|
||||||
@@ -29,6 +29,12 @@ import {
|
|||||||
} from '../shared/cursor';
|
} from '../shared/cursor';
|
||||||
import type { AlbumCreateRequestDto, AlbumUpdateRequestDto } from './dto';
|
import type { AlbumCreateRequestDto, AlbumUpdateRequestDto } from './dto';
|
||||||
import { PermissionsService } from './permissions.service';
|
import { PermissionsService } from './permissions.service';
|
||||||
|
import {
|
||||||
|
albumForbidden,
|
||||||
|
albumNotFound,
|
||||||
|
isUniqueViolationOn,
|
||||||
|
validationFailed,
|
||||||
|
} from './support';
|
||||||
|
|
||||||
export type AlbumTranslationResponse =
|
export type AlbumTranslationResponse =
|
||||||
components['schemas']['AlbumTranslation'];
|
components['schemas']['AlbumTranslation'];
|
||||||
@@ -69,32 +75,6 @@ function generatePublicToken(): string {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
function albumNotFound(): ApiException {
|
|
||||||
return new ApiException(
|
|
||||||
ErrorCodes.ALBUM_NOT_FOUND,
|
|
||||||
'相册不存在或不可见',
|
|
||||||
HttpStatus.NOT_FOUND,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** P2002 是否命中指定唯一索引(meta.target 形如 ['publicToken'])。 */
|
|
||||||
function isUniqueViolationOn(err: unknown, field: string): boolean {
|
|
||||||
if (typeof err !== 'object' || err === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const { code, meta } = err as { code?: unknown; meta?: { target?: unknown } };
|
|
||||||
if (code !== 'P2002') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const target = meta?.target;
|
|
||||||
const fields = Array.isArray(target)
|
|
||||||
? target
|
|
||||||
: typeof target === 'string'
|
|
||||||
? [target]
|
|
||||||
: [];
|
|
||||||
return fields.includes(field);
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlbumRow = Prisma.albumsGetPayload<Prisma.albumsDefaultArgs>;
|
type AlbumRow = Prisma.albumsGetPayload<Prisma.albumsDefaultArgs>;
|
||||||
type AlbumTranslationRow = Prisma.album_translationsGetPayload<
|
type AlbumTranslationRow = Prisma.album_translationsGetPayload<
|
||||||
Prisma.album_translationsDefaultArgs
|
Prisma.album_translationsDefaultArgs
|
||||||
@@ -146,7 +126,8 @@ export class AlbumsService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建相册:事务内四写(albums/translations/members/audit),返回完整
|
* 创建相册:事务内四写(albums/translations/members/audit),返回完整
|
||||||
* Album 形状(categories 恒 []——分类自 Task 17 起才有写入入口)。
|
* Album 形状(categories 恒 []——新建相册尚无分类,写入入口在
|
||||||
|
* CategoriesService,Task 17)。
|
||||||
*/
|
*/
|
||||||
async create(
|
async create(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -156,11 +137,7 @@ export class AlbumsService {
|
|||||||
// ——那是 500 而非 400,必须在入事务前以校验错误拒绝。
|
// ——那是 500 而非 400,必须在入事务前以校验错误拒绝。
|
||||||
const locales = dto.translations.map((t) => t.locale);
|
const locales = dto.translations.map((t) => t.locale);
|
||||||
if (new Set(locales).size !== locales.length) {
|
if (new Set(locales).size !== locales.length) {
|
||||||
throw new ApiException(
|
throw validationFailed('translations 存在重复 locale');
|
||||||
ErrorCodes.VALIDATION_FAILED,
|
|
||||||
'translations 存在重复 locale',
|
|
||||||
HttpStatus.BAD_REQUEST,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let albumId: string;
|
let albumId: string;
|
||||||
@@ -274,11 +251,7 @@ export class AlbumsService {
|
|||||||
(key) => dto[key] !== undefined,
|
(key) => dto[key] !== undefined,
|
||||||
) as string[];
|
) as string[];
|
||||||
if (fields.length === 0) {
|
if (fields.length === 0) {
|
||||||
throw new ApiException(
|
throw validationFailed('请求体至少需要包含一个待更新字段');
|
||||||
ErrorCodes.VALIDATION_FAILED,
|
|
||||||
'请求体至少需要包含一个待更新字段',
|
|
||||||
HttpStatus.BAD_REQUEST,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (dto.translations !== undefined) {
|
if (dto.translations !== undefined) {
|
||||||
// 同 create:重复 locale 会在事务内 createMany 撞唯一索引变 500,先 400。
|
// 同 create:重复 locale 会在事务内 createMany 撞唯一索引变 500,先 400。
|
||||||
@@ -296,11 +269,7 @@ export class AlbumsService {
|
|||||||
throw albumNotFound();
|
throw albumNotFound();
|
||||||
}
|
}
|
||||||
if (!(await this.permissions.canManageAlbum(user, albumId))) {
|
if (!(await this.permissions.canManageAlbum(user, albumId))) {
|
||||||
throw new ApiException(
|
throw albumForbidden('仅创建者或管理员可修改相册');
|
||||||
ErrorCodes.AUTH_FORBIDDEN,
|
|
||||||
'仅创建者或管理员可修改相册',
|
|
||||||
HttpStatus.FORBIDDEN,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
@@ -318,21 +287,15 @@ export class AlbumsService {
|
|||||||
? null
|
? null
|
||||||
: new Date(dto.endAt);
|
: new Date(dto.endAt);
|
||||||
if (endAt !== null && endAt.getTime() <= startAt.getTime()) {
|
if (endAt !== null && endAt.getTime() <= startAt.getTime()) {
|
||||||
throw new ApiException(
|
throw validationFailed('endAt 必须晚于 startAt');
|
||||||
ErrorCodes.VALIDATION_FAILED,
|
|
||||||
'endAt 必须晚于 startAt',
|
|
||||||
HttpStatus.BAD_REQUEST,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 合并校验②:合并后的 defaultLocale 必须被合并后的翻译集覆盖。
|
// 合并校验②:合并后的 defaultLocale 必须被合并后的翻译集覆盖。
|
||||||
const defaultLocale = dto.defaultLocale ?? album.defaultLocale;
|
const defaultLocale = dto.defaultLocale ?? album.defaultLocale;
|
||||||
if (dto.translations !== undefined) {
|
if (dto.translations !== undefined) {
|
||||||
if (!dto.translations.some((t) => t.locale === defaultLocale)) {
|
if (!dto.translations.some((t) => t.locale === defaultLocale)) {
|
||||||
throw new ApiException(
|
throw validationFailed(
|
||||||
ErrorCodes.VALIDATION_FAILED,
|
|
||||||
'translations 必须包含(更新后的)defaultLocale 对应语言的条目',
|
'translations 必须包含(更新后的)defaultLocale 对应语言的条目',
|
||||||
HttpStatus.BAD_REQUEST,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (dto.defaultLocale !== undefined) {
|
} else if (dto.defaultLocale !== undefined) {
|
||||||
@@ -342,11 +305,7 @@ export class AlbumsService {
|
|||||||
select: { locale: true },
|
select: { locale: true },
|
||||||
});
|
});
|
||||||
if (!rows.some((r) => r.locale === defaultLocale)) {
|
if (!rows.some((r) => r.locale === defaultLocale)) {
|
||||||
throw new ApiException(
|
throw validationFailed('defaultLocale 必须有对应翻译条目');
|
||||||
ErrorCodes.VALIDATION_FAILED,
|
|
||||||
'defaultLocale 必须有对应翻译条目',
|
|
||||||
HttpStatus.BAD_REQUEST,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,8 +433,8 @@ export class AlbumsService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 组装完整 Album 形状:主行 + 翻译(locale 升序,确定性输出)+ 分类
|
* 组装完整 Album 形状:主行 + 翻译(locale 升序,确定性输出)+ 分类
|
||||||
* (sortOrder 升序,Task 17 前无写入入口故实际为空数组——join 逻辑先就位,
|
* (sortOrder 升序;写入入口为 CategoriesService,Task 17 起)。相册域无
|
||||||
* 分类上线即生效)。相册域无 Prisma relation,全部二次查询应用层组装。
|
* Prisma relation,全部二次查询应用层组装。
|
||||||
*/
|
*/
|
||||||
private async loadAlbum(albumId: string): Promise<AlbumResponse | null> {
|
private async loadAlbum(albumId: string): Promise<AlbumResponse | null> {
|
||||||
const album = await this.prisma.albums.findUnique({
|
const album = await this.prisma.albums.findUnique({
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// services/api/src/albums/categories.controller.ts —— 分类端点(Task 17)。
|
||||||
|
// 方法名与契约 operationId 一一对应(createCategory/updateCategory/
|
||||||
|
// deleteCategory)。契约把创建挂在 /albums/{albumId}/categories、更新/删除
|
||||||
|
// 挂在 /categories/{categoryId}——单控制器 + 显式全路径(两个基地址)。
|
||||||
|
// 控制器级 SessionGuard:三端点全需认证;权限与 HTTP 映射(404/403/400)
|
||||||
|
// 由 CategoriesService 抛 ApiException。
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
|
||||||
|
import { userOf } from './albums.controller';
|
||||||
|
import { CategoriesService, type CategoryResponse } from './categories.service';
|
||||||
|
import {
|
||||||
|
CategoryCreateRequestDto,
|
||||||
|
CategoryUpdateRequestDto,
|
||||||
|
} from './dto';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
@UseGuards(SessionGuard)
|
||||||
|
export class CategoriesController {
|
||||||
|
constructor(private readonly categoriesService: CategoriesService) {}
|
||||||
|
|
||||||
|
@Post('albums/:albumId/categories')
|
||||||
|
createCategory(
|
||||||
|
@Param('albumId') albumId: string,
|
||||||
|
@Body() dto: CategoryCreateRequestDto,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
): Promise<CategoryResponse> {
|
||||||
|
return this.categoriesService.create(userOf(req), albumId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('categories/:categoryId')
|
||||||
|
updateCategory(
|
||||||
|
@Param('categoryId') categoryId: string,
|
||||||
|
@Body() dto: CategoryUpdateRequestDto,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
): Promise<CategoryResponse> {
|
||||||
|
return this.categoriesService.update(userOf(req), categoryId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('categories/:categoryId')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
deleteCategory(
|
||||||
|
@Param('categoryId') categoryId: string,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.categoriesService.remove(userOf(req), categoryId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
// services/api/src/albums/categories.service.ts —— 相册分类管理(Task 17)。
|
||||||
|
// 契约权威:openapi.yaml tags [categories](createCategory/updateCategory/
|
||||||
|
// deleteCategory)。要点:
|
||||||
|
// · /categories/{categoryId} 端点的 404 语义与契约一致:分类不存在或所属
|
||||||
|
// 相册不可见统一 ALBUM_NOT_FOUND(先读分类行→404,再 canView→404、
|
||||||
|
// canManage→403;非成员视角「分类不存在」与「不可见」同码防枚举)。
|
||||||
|
// · translations 全量替换(deleteMany+createMany);重复 locale 入事务前
|
||||||
|
// 400(同 albums 翻译处理:撞唯一索引是 500 不是 400)。
|
||||||
|
// · 审计 resourceId=albumId(与 album.* 动作族统一,便于「某相册全部操作」
|
||||||
|
// 检索),metadata.categoryId 指向具体分类;update 额外记 fields。
|
||||||
|
// · 删除:分类+翻译行同事务删除。契约注「分类下仍有照片时是否允许删除由
|
||||||
|
// 服务端策略决定」——P1a 照片写入路径(Task 18+)尚未给 photos.categoryId
|
||||||
|
// 赋值,此处不做照片计数检查,Task 20 照片分类赋值时再补。
|
||||||
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
|
import { ErrorCodes } from '@photo/contracts';
|
||||||
|
import { ApiException } from '../api-exception';
|
||||||
|
import type { components } from '@photo/contracts';
|
||||||
|
import type { Prisma } from '@photo/db-cn';
|
||||||
|
import type { AuthUser } from '../auth/session.guard';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import type {
|
||||||
|
CategoryCreateRequestDto,
|
||||||
|
CategoryUpdateRequestDto,
|
||||||
|
} from './dto';
|
||||||
|
import { PermissionsService } from './permissions.service';
|
||||||
|
import {
|
||||||
|
albumForbidden,
|
||||||
|
albumNotFound,
|
||||||
|
validationFailed,
|
||||||
|
} from './support';
|
||||||
|
|
||||||
|
export type CategoryResponse = components['schemas']['Category'];
|
||||||
|
export type CategoryTranslationResponse =
|
||||||
|
components['schemas']['CategoryTranslation'];
|
||||||
|
|
||||||
|
type CategoryRow = Prisma.categoriesGetPayload<Prisma.categoriesDefaultArgs>;
|
||||||
|
type CategoryTranslationRow = Prisma.category_translationsGetPayload<
|
||||||
|
Prisma.category_translationsDefaultArgs
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** 契约 CategoryUpdateRequest 属性序(审计 metadata.fields 序列,同 albums)。 */
|
||||||
|
const CATEGORY_UPDATE_FIELD_ORDER = [
|
||||||
|
'sortOrder',
|
||||||
|
'translations',
|
||||||
|
] as const satisfies readonly (keyof CategoryUpdateRequestDto)[];
|
||||||
|
|
||||||
|
/** translations 重复 locale → 400(入事务前,理由同 albums.service)。 */
|
||||||
|
function assertNoDuplicateLocale(
|
||||||
|
translations: Array<{ locale: string }>,
|
||||||
|
): void {
|
||||||
|
const locales = translations.map((t) => t.locale);
|
||||||
|
if (new Set(locales).size !== locales.length) {
|
||||||
|
throw validationFailed('translations 存在重复 locale');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCategoryResponse(
|
||||||
|
category: CategoryRow,
|
||||||
|
translations: CategoryTranslationRow[],
|
||||||
|
): CategoryResponse {
|
||||||
|
return {
|
||||||
|
id: category.id,
|
||||||
|
albumId: category.albumId,
|
||||||
|
sortOrder: category.sortOrder,
|
||||||
|
translations: translations.map((row) => ({
|
||||||
|
// locale 取值来自入参枚举校验,收窄为契约 Locale(同 albums 翻译)。
|
||||||
|
locale: row.locale as CategoryTranslationResponse['locale'],
|
||||||
|
name: row.name,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CategoriesService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** 创建分类(契约 createCategory):201 返回 Category。 */
|
||||||
|
async create(
|
||||||
|
user: AuthUser,
|
||||||
|
albumId: string,
|
||||||
|
dto: CategoryCreateRequestDto,
|
||||||
|
): Promise<CategoryResponse> {
|
||||||
|
assertNoDuplicateLocale(dto.translations);
|
||||||
|
await this.assertAlbumManaged(user, albumId);
|
||||||
|
|
||||||
|
const categoryId = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const category = await tx.categories.create({
|
||||||
|
data: { albumId, sortOrder: dto.sortOrder ?? 0 },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await tx.category_translations.createMany({
|
||||||
|
data: dto.translations.map((t) => ({
|
||||||
|
categoryId: category.id,
|
||||||
|
locale: t.locale,
|
||||||
|
name: t.name,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
await tx.audit_logs.create({
|
||||||
|
data: {
|
||||||
|
actorId: user.id,
|
||||||
|
action: 'album.category.add',
|
||||||
|
resourceId: albumId,
|
||||||
|
metadata: { categoryId: category.id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return category.id;
|
||||||
|
});
|
||||||
|
const category = await this.loadCategory(categoryId);
|
||||||
|
if (category === null) {
|
||||||
|
// 刚提交就读不到只可能是并发异常路径,按 5xx 兜底(同 albums.create)。
|
||||||
|
throw new ApiException(
|
||||||
|
ErrorCodes.INTERNAL_ERROR,
|
||||||
|
'Internal server error',
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新分类(契约 updateCategory):部分更新,translations 全量替换。 */
|
||||||
|
async update(
|
||||||
|
user: AuthUser,
|
||||||
|
categoryId: string,
|
||||||
|
dto: CategoryUpdateRequestDto,
|
||||||
|
): Promise<CategoryResponse> {
|
||||||
|
// 契约 minProperties:1(判据同 albums.service.update:plainToInstance 后
|
||||||
|
// 声明属性以 undefined 成键,以「任一字段已赋值」为准)。
|
||||||
|
const fields = CATEGORY_UPDATE_FIELD_ORDER.filter(
|
||||||
|
(key) => dto[key] !== undefined,
|
||||||
|
);
|
||||||
|
if (fields.length === 0) {
|
||||||
|
throw validationFailed('请求体至少需要包含一个待更新字段');
|
||||||
|
}
|
||||||
|
if (dto.translations !== undefined) {
|
||||||
|
assertNoDuplicateLocale(dto.translations);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.assertCategoryManaged(user, categoryId);
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.categories.update({
|
||||||
|
where: { id: categoryId },
|
||||||
|
data: { sortOrder: dto.sortOrder },
|
||||||
|
});
|
||||||
|
if (dto.translations !== undefined) {
|
||||||
|
await tx.category_translations.deleteMany({ where: { categoryId } });
|
||||||
|
await tx.category_translations.createMany({
|
||||||
|
data: dto.translations.map((t) => ({
|
||||||
|
categoryId,
|
||||||
|
locale: t.locale,
|
||||||
|
name: t.name,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.audit_logs.create({
|
||||||
|
data: {
|
||||||
|
actorId: user.id,
|
||||||
|
action: 'album.category.update',
|
||||||
|
resourceId: existing.albumId,
|
||||||
|
metadata: { categoryId, fields },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const category = await this.loadCategory(categoryId);
|
||||||
|
if (category === null) {
|
||||||
|
throw albumNotFound();
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除分类(契约 deleteCategory):204 无响应体。 */
|
||||||
|
async remove(user: AuthUser, categoryId: string): Promise<void> {
|
||||||
|
const existing = await this.assertCategoryManaged(user, categoryId);
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.category_translations.deleteMany({ where: { categoryId } });
|
||||||
|
await tx.categories.delete({ where: { id: categoryId } });
|
||||||
|
await tx.audit_logs.create({
|
||||||
|
data: {
|
||||||
|
actorId: user.id,
|
||||||
|
action: 'album.category.remove',
|
||||||
|
resourceId: existing.albumId,
|
||||||
|
metadata: { categoryId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /albums/{albumId}/categories 的 404/403 顺序映射 + admin 空相册兜底。 */
|
||||||
|
private async assertAlbumManaged(
|
||||||
|
user: AuthUser,
|
||||||
|
albumId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!(await this.permissions.canViewAlbum(user, albumId))) {
|
||||||
|
throw albumNotFound();
|
||||||
|
}
|
||||||
|
if (!(await this.permissions.canManageAlbum(user, albumId))) {
|
||||||
|
throw albumForbidden('仅创建者或管理员可管理分类');
|
||||||
|
}
|
||||||
|
const album = await this.prisma.albums.findUnique({
|
||||||
|
where: { id: albumId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (album === null) {
|
||||||
|
// admin 视角 canView 恒真,空值兜底同码 404。
|
||||||
|
throw albumNotFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /categories/{categoryId} 的 404/403 顺序映射:分类行不存在 → 404(契约
|
||||||
|
* 语义:分类不存在或所属相册不存在/不可见同码);行存在再按所属相册判
|
||||||
|
* canView → 404 / canManage → 403。返回分类行(含 albumId 供审计)。
|
||||||
|
*/
|
||||||
|
private async assertCategoryManaged(
|
||||||
|
user: AuthUser,
|
||||||
|
categoryId: string,
|
||||||
|
): Promise<CategoryRow> {
|
||||||
|
const category = await this.prisma.categories.findUnique({
|
||||||
|
where: { id: categoryId },
|
||||||
|
});
|
||||||
|
if (category === null) {
|
||||||
|
throw albumNotFound();
|
||||||
|
}
|
||||||
|
if (!(await this.permissions.canViewAlbum(user, category.albumId))) {
|
||||||
|
throw albumNotFound();
|
||||||
|
}
|
||||||
|
if (!(await this.permissions.canManageAlbum(user, category.albumId))) {
|
||||||
|
throw albumForbidden('仅创建者或管理员可管理分类');
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组装 Category 形状:主行 + 翻译(locale 升序,确定性输出)。 */
|
||||||
|
private async loadCategory(categoryId: string): Promise<CategoryResponse | null> {
|
||||||
|
const category = await this.prisma.categories.findUnique({
|
||||||
|
where: { id: categoryId },
|
||||||
|
});
|
||||||
|
if (category === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const translations = await this.prisma.category_translations.findMany({
|
||||||
|
where: { categoryId },
|
||||||
|
orderBy: { locale: 'asc' },
|
||||||
|
});
|
||||||
|
return toCategoryResponse(category, translations);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
// services/api/src/albums/dto.ts —— 相册端点入参 DTO(Task 15;Task 16 增补更新版)。
|
// services/api/src/albums/dto.ts —— 相册端点入参 DTO(Task 15;Task 16 增补更新版;
|
||||||
// 校验约束对齐契约 AlbumCreateRequest / AlbumUpdateRequest / AlbumTranslation:
|
// Task 17 增补成员/分类)。校验约束对齐契约
|
||||||
|
// AlbumCreateRequest / AlbumUpdateRequest / MemberAddRequest /
|
||||||
|
// CategoryCreateRequest / CategoryUpdateRequest / AlbumTranslation:
|
||||||
// · translations 至少 1 条(minItems: 1),locale 限 P1a 双语枚举;
|
// · translations 至少 1 条(minItems: 1),locale 限 P1a 双语枚举;
|
||||||
// · defaultLocale 必须有对应翻译条目(跨字段校验,契约 required 语义);
|
// · defaultLocale 必须有对应翻译条目(跨字段校验,契约 required 语义);
|
||||||
// · endAt 可空(契约 [string,'null']),给值时须晚于 startAt(跨字段校验);
|
// · endAt 可空(契约 [string,'null']),给值时须晚于 startAt(跨字段校验);
|
||||||
@@ -13,11 +15,14 @@ import {
|
|||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsIn,
|
IsIn,
|
||||||
|
IsInt,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
Validate,
|
Validate,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
ValidatorConstraint,
|
ValidatorConstraint,
|
||||||
@@ -222,3 +227,70 @@ export class AlbumUpdateRequestDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
originalDownloadEnabled?: boolean;
|
originalDownloadEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 成员(Task 17)──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 契约 MemberAddRequest:userId 必填(已开通账号);role 缺省 collaborator,
|
||||||
|
* 枚举仅 collaborator——owner 由创建流程产生,经本端点加 owner 属 P4a 管理员
|
||||||
|
* 操作,role='owner' 在 DTO 层即 400。
|
||||||
|
*/
|
||||||
|
export class MemberAddRequestDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(64) // users.id VarChar(64)
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['collaborator'])
|
||||||
|
role?: 'collaborator';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 分类(Task 17)──
|
||||||
|
|
||||||
|
/** 契约 CategoryTranslation:name minLength 1;列宽 VarChar(128)。 */
|
||||||
|
export class CategoryTranslationDto {
|
||||||
|
@IsIn(ALBUM_LOCALES)
|
||||||
|
locale!: AlbumLocale;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(128)
|
||||||
|
name!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sortOrder:契约 integer 无界;Int 列物理边界内收为 400(而非溢出 500)。 */
|
||||||
|
const SORT_ORDER_MIN = -2_147_483_648;
|
||||||
|
const SORT_ORDER_MAX = 2_147_483_647;
|
||||||
|
|
||||||
|
export class CategoryCreateRequestDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(SORT_ORDER_MIN)
|
||||||
|
@Max(SORT_ORDER_MAX)
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CategoryTranslationDto)
|
||||||
|
translations!: CategoryTranslationDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 契约 CategoryUpdateRequest:全字段可选 + minProperties:1。空体拒绝在服务层
|
||||||
|
* (同 AlbumUpdateRequestDto:plainToInstance 后声明属性以 undefined 成键,
|
||||||
|
* Object.keys 不可判空,以「任一字段已赋值」为判据)。
|
||||||
|
*/
|
||||||
|
export class CategoryUpdateRequestDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(SORT_ORDER_MIN)
|
||||||
|
@Max(SORT_ORDER_MAX)
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CategoryTranslationDto)
|
||||||
|
translations?: CategoryTranslationDto[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// services/api/src/albums/members.controller.ts —— /albums/{albumId}/members
|
||||||
|
// 端点(Task 17)。方法名与契约 operationId 一一对应(addAlbumMember/
|
||||||
|
// listAlbumMembers/removeAlbumMember)。控制器级 SessionGuard:三端点全需
|
||||||
|
// 认证。控制器只做认证用户透传 + 查询参数规整(复用 albums.controller 的
|
||||||
|
// parseLimit/parseCursor,同一契约 bounds/游标语义);权限与 HTTP 映射由
|
||||||
|
// MembersService 抛 ApiException(404 ALBUM_NOT_FOUND / 403 AUTH_FORBIDDEN /
|
||||||
|
// 409 MEMBER_CONFLICT)。
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
|
||||||
|
import { parseCursor, parseLimit, userOf } from './albums.controller';
|
||||||
|
import { MemberAddRequestDto } from './dto';
|
||||||
|
import {
|
||||||
|
type MemberListResponse,
|
||||||
|
type MemberResponse,
|
||||||
|
MembersService,
|
||||||
|
} from './members.service';
|
||||||
|
|
||||||
|
@Controller('albums/:albumId/members')
|
||||||
|
@UseGuards(SessionGuard)
|
||||||
|
export class MembersController {
|
||||||
|
constructor(private readonly membersService: MembersService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
addAlbumMember(
|
||||||
|
@Param('albumId') albumId: string,
|
||||||
|
@Body() dto: MemberAddRequestDto,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
): Promise<MemberResponse> {
|
||||||
|
return this.membersService.add(userOf(req), albumId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
listAlbumMembers(
|
||||||
|
@Param('albumId') albumId: string,
|
||||||
|
@Query('cursor') cursor: string | undefined,
|
||||||
|
@Query('limit') limit: string | undefined,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
): Promise<MemberListResponse> {
|
||||||
|
return this.membersService.list(
|
||||||
|
userOf(req),
|
||||||
|
albumId,
|
||||||
|
parseCursor(cursor),
|
||||||
|
parseLimit(limit),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':userId')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
removeAlbumMember(
|
||||||
|
@Param('albumId') albumId: string,
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.membersService.remove(userOf(req), albumId, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
// services/api/src/albums/members.service.ts —— 相册成员管理(Task 17)。
|
||||||
|
// 契约权威:openapi.yaml tags [members](addAlbumMember/listAlbumMembers/
|
||||||
|
// removeAlbumMember)。要点:
|
||||||
|
// · 权限顺序与 albums 一致防枚举:canViewAlbum=false → 404;canManageAlbum
|
||||||
|
// =false → 403(协作者可见不可管);admin 视角再由空行兜底 404。
|
||||||
|
// · 成员行(album_members)是唯一权限来源(Task 14 结论):移除=删行,
|
||||||
|
// 被移除者下一次请求即刻失去可见性(A02,PermissionsService 无缓存)。
|
||||||
|
// · owner 行不可经本端点产生/删除:邀请 owner 目标 → 400(仅 collaborator
|
||||||
|
// 可加,owner 转移属 P4a 管理员操作);移除 owner → 403。
|
||||||
|
// · 被邀请账号必须已开通(credentialStatus=active 且 sourceStatus≠disabled):
|
||||||
|
// 邀请属管理动作,明示目标账号有效性不构成成员枚举泄露。
|
||||||
|
// · 移除幂等:目标成员行不存在仍 204(契约语义);仅真实删除落审计
|
||||||
|
// (幂等重删不重复记 album.member.remove)。
|
||||||
|
// · 列表:(addedAt,id) DESC 键集游标(shared/cursor,与相册列表同构);
|
||||||
|
// users 无 relation(扁平外键),二次查询应用层组装 Member。
|
||||||
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
|
import { ErrorCodes } from '@photo/contracts';
|
||||||
|
import type { components } from '@photo/contracts';
|
||||||
|
import type { Prisma } from '@photo/db-cn';
|
||||||
|
import { ApiException } from '../api-exception';
|
||||||
|
import type { AuthUser } from '../auth/session.guard';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
decodeKeysetCursor,
|
||||||
|
encodeKeysetCursor,
|
||||||
|
keysetWhereDesc,
|
||||||
|
} from '../shared/cursor';
|
||||||
|
import type { MemberAddRequestDto } from './dto';
|
||||||
|
import { PermissionsService } from './permissions.service';
|
||||||
|
import {
|
||||||
|
albumForbidden,
|
||||||
|
albumNotFound,
|
||||||
|
isUniqueViolationOn,
|
||||||
|
validationFailed,
|
||||||
|
} from './support';
|
||||||
|
|
||||||
|
export type MemberResponse = components['schemas']['Member'];
|
||||||
|
export type MemberListResponse = components['schemas']['MemberList'];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MembersService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** 添加协作者(契约 addAlbumMember):201 返回 Member。 */
|
||||||
|
async add(
|
||||||
|
user: AuthUser,
|
||||||
|
albumId: string,
|
||||||
|
dto: MemberAddRequestDto,
|
||||||
|
): Promise<MemberResponse> {
|
||||||
|
const { ownerId } = await this.assertManageable(user, albumId);
|
||||||
|
|
||||||
|
// 目标账号须存在且已开通(pending 凭据/已停用均不可加)。
|
||||||
|
const target = await this.prisma.users.findUnique({
|
||||||
|
where: { id: dto.userId },
|
||||||
|
select: {
|
||||||
|
account: true,
|
||||||
|
displayName: true,
|
||||||
|
credentialStatus: true,
|
||||||
|
sourceStatus: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
target === null ||
|
||||||
|
target.credentialStatus !== 'active' ||
|
||||||
|
target.sourceStatus === 'disabled'
|
||||||
|
) {
|
||||||
|
throw validationFailed('被邀请的用户不存在或不可用(须为已开通账号)');
|
||||||
|
}
|
||||||
|
// owner 不能经本端点被(重)添加:防重复 owner 行与角色歧义。
|
||||||
|
if (dto.userId === ownerId) {
|
||||||
|
throw validationFailed('owner 由创建流程产生,不能经由本端点添加');
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = dto.role ?? 'collaborator';
|
||||||
|
try {
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.album_members.create({
|
||||||
|
data: { albumId, userId: dto.userId, role, status: 'active' },
|
||||||
|
});
|
||||||
|
await tx.audit_logs.create({
|
||||||
|
data: {
|
||||||
|
actorId: user.id,
|
||||||
|
action: 'album.member.add',
|
||||||
|
resourceId: albumId,
|
||||||
|
metadata: { userId: dto.userId, role },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// 并发窗口内重复邀请:唯一索引 @@unique([albumId,userId]) 兜底成契约 409。
|
||||||
|
if (isUniqueViolationOn(err, 'albumId_userId')) {
|
||||||
|
throw new ApiException(
|
||||||
|
ErrorCodes.MEMBER_CONFLICT,
|
||||||
|
'该用户已是相册成员',
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
userId: dto.userId,
|
||||||
|
account: target.account,
|
||||||
|
displayName: target.displayName,
|
||||||
|
role,
|
||||||
|
status: 'active',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成员列表(契约 listAlbumMembers):任何成员/admin 可见;(addedAt,id) DESC。 */
|
||||||
|
async list(
|
||||||
|
user: AuthUser,
|
||||||
|
albumId: string,
|
||||||
|
cursor: string | undefined,
|
||||||
|
limit: number,
|
||||||
|
): Promise<MemberListResponse> {
|
||||||
|
if (!(await this.permissions.canViewAlbum(user, albumId))) {
|
||||||
|
throw albumNotFound();
|
||||||
|
}
|
||||||
|
// admin 对不存在的 id 权限恒真(非 admin 的可见成员行本身蕴含相册存在),
|
||||||
|
// 空相册兜底同码 404——契约「相册不存在,或当前用户不可见」。
|
||||||
|
const albumExists = await this.prisma.albums.findUnique({
|
||||||
|
where: { id: albumId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (albumExists === null) {
|
||||||
|
throw albumNotFound();
|
||||||
|
}
|
||||||
|
let decoded: ReturnType<typeof decodeKeysetCursor> = null;
|
||||||
|
if (cursor !== undefined && cursor !== '') {
|
||||||
|
decoded = decodeKeysetCursor(cursor);
|
||||||
|
if (decoded === null) {
|
||||||
|
throw validationFailed('cursor 不合法');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: Prisma.album_membersWhereInput = { albumId };
|
||||||
|
if (decoded !== null) {
|
||||||
|
Object.assign(where, keysetWhereDesc('addedAt', 'id', decoded));
|
||||||
|
}
|
||||||
|
const rows = await this.prisma.album_members.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ addedAt: 'desc' }, { id: 'desc' }],
|
||||||
|
take: limit + 1, // 多取一条探测 hasMore(同相册列表,免 count 查询)
|
||||||
|
});
|
||||||
|
const hasMore = rows.length > limit;
|
||||||
|
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||||
|
|
||||||
|
const users = await this.prisma.users.findMany({
|
||||||
|
where: { id: { in: page.map((m) => m.userId) } },
|
||||||
|
select: { id: true, account: true, displayName: true },
|
||||||
|
});
|
||||||
|
const byId = new Map(users.map((u) => [u.id, u]));
|
||||||
|
|
||||||
|
const items = page.flatMap((row) => {
|
||||||
|
const u = byId.get(row.userId);
|
||||||
|
// 用户行缺失(P1a 无用户删除,仅可能来自手工改库):跳过而非报错。
|
||||||
|
return u === undefined
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
userId: row.userId,
|
||||||
|
account: u.account,
|
||||||
|
displayName: u.displayName,
|
||||||
|
role: row.role as MemberResponse['role'],
|
||||||
|
status: row.status as MemberResponse['status'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const last = page.at(-1);
|
||||||
|
const nextCursor =
|
||||||
|
hasMore && last !== undefined
|
||||||
|
? encodeKeysetCursor({
|
||||||
|
sort: last.addedAt.toISOString(),
|
||||||
|
id: last.id,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
return { items, nextCursor, hasMore: nextCursor !== null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移除成员(契约 removeAlbumMember):幂等 204;owner 不可移除(403)。 */
|
||||||
|
async remove(
|
||||||
|
user: AuthUser,
|
||||||
|
albumId: string,
|
||||||
|
targetUserId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const { ownerId } = await this.assertManageable(user, albumId);
|
||||||
|
if (targetUserId === ownerId) {
|
||||||
|
throw albumForbidden('不能移除相册创建者(owner 转移属管理员操作)');
|
||||||
|
}
|
||||||
|
// Task 14:成员行是唯一权限来源,移除=删行(无软删除状态)。
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const removed = await tx.album_members.deleteMany({
|
||||||
|
where: { albumId, userId: targetUserId },
|
||||||
|
});
|
||||||
|
if (removed.count > 0) {
|
||||||
|
// 幂等重删(count=0)不重复落审计。
|
||||||
|
await tx.audit_logs.create({
|
||||||
|
data: {
|
||||||
|
actorId: user.id,
|
||||||
|
action: 'album.member.remove',
|
||||||
|
resourceId: albumId,
|
||||||
|
metadata: { userId: targetUserId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 404/403 顺序映射 + admin 空相册兜底(增删均为管理动作,共用入口)。 */
|
||||||
|
private async assertManageable(
|
||||||
|
user: AuthUser,
|
||||||
|
albumId: string,
|
||||||
|
): Promise<{ ownerId: string }> {
|
||||||
|
if (!(await this.permissions.canViewAlbum(user, albumId))) {
|
||||||
|
throw albumNotFound();
|
||||||
|
}
|
||||||
|
if (!(await this.permissions.canManageAlbum(user, albumId))) {
|
||||||
|
throw albumForbidden('仅创建者或管理员可管理成员');
|
||||||
|
}
|
||||||
|
const album = await this.prisma.albums.findUnique({
|
||||||
|
where: { id: albumId },
|
||||||
|
select: { ownerId: true },
|
||||||
|
});
|
||||||
|
if (album === null) {
|
||||||
|
// admin 对不存在的 id 权限恒真,由空值兜底同码 404。
|
||||||
|
throw albumNotFound();
|
||||||
|
}
|
||||||
|
return album;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// services/api/src/albums/support.ts —— 相册域服务共用小工具(Task 15 起自
|
||||||
|
// albums.service.ts 抽出,Task 17 起 members/categories 服务同用)。
|
||||||
|
import { HttpStatus } from '@nestjs/common';
|
||||||
|
import { ErrorCodes } from '@photo/contracts';
|
||||||
|
import { ApiException } from '../api-exception';
|
||||||
|
|
||||||
|
/** 统一 404:不可见(含不存在)同码防 id 枚举(契约 albums/categories 404 语义)。 */
|
||||||
|
export function albumNotFound(): ApiException {
|
||||||
|
return new ApiException(
|
||||||
|
ErrorCodes.ALBUM_NOT_FOUND,
|
||||||
|
'相册不存在或不可见',
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一 403:可见但无管理权限(协作者视角)。 */
|
||||||
|
export function albumForbidden(message = '仅创建者或管理员可操作'): ApiException {
|
||||||
|
return new ApiException(ErrorCodes.AUTH_FORBIDDEN, message, HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一 400(契约 VALIDATION_FAILED)。 */
|
||||||
|
export function validationFailed(message: string): ApiException {
|
||||||
|
return new ApiException(
|
||||||
|
ErrorCodes.VALIDATION_FAILED,
|
||||||
|
message,
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P2002 是否命中指定唯一索引。uniqueName 用 Prisma 唯一选择器名(单列
|
||||||
|
* 'publicToken' / 复合 'albumId_userId')。meta.target 两种形态都兼容:
|
||||||
|
* 字段数组(部分引擎/版本)或 DB 约束名字符串(MySQL 实测形如
|
||||||
|
* 'albums_publicToken_key' / 'album_members_albumId_userId_key'——
|
||||||
|
* 表名前缀 + 选择器名 + _key 后缀,两种后缀都匹配)。
|
||||||
|
*/
|
||||||
|
export function isUniqueViolationOn(err: unknown, uniqueName: string): boolean {
|
||||||
|
if (typeof err !== 'object' || err === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { code, meta } = err as { code?: unknown; meta?: { target?: unknown } };
|
||||||
|
if (code !== 'P2002') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const target = meta?.target;
|
||||||
|
if (Array.isArray(target)) {
|
||||||
|
return target.map(String).includes(uniqueName);
|
||||||
|
}
|
||||||
|
if (typeof target === 'string') {
|
||||||
|
return (
|
||||||
|
target === uniqueName ||
|
||||||
|
target.endsWith(`${uniqueName}_key`) ||
|
||||||
|
target.endsWith(`_${uniqueName}_key`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
// services/api/test/members-categories.e2e-spec.ts —— Task 17 成员与分类 e2e(先写后实现)。
|
||||||
|
// 认证与 Task 15 相同:Prisma 直种 sessions 行换 Bearer 令牌。覆盖:
|
||||||
|
// 成员:owner 邀请已开通同事(userId)→ 201 契约 Member 形状 + DB 行 +
|
||||||
|
// audit album.member.add;重复邀请 409 MEMBER_CONFLICT;邀请 owner/
|
||||||
|
// role=owner/未知账号/未开通/已停用 → 400;成员列表(成员视角 200、
|
||||||
|
// 外人 404)与游标分页;移除协作者 204 幂等 + 被移除者即刻失去相册
|
||||||
|
// 可见性(A02:GET album → 404)+ audit album.member.remove;owner
|
||||||
|
// 自移除 403(转移属 P4a 管理员操作);协作者增删成员 403。
|
||||||
|
// 分类:owner 创建 {sortOrder, translations} → 201 契约 Category;相册详情
|
||||||
|
// 按 sortOrder 升序带出分类;PATCH 重排/改名(translations 全量替换);
|
||||||
|
// DELETE 204 且详情不再列出;不存在/越权 404/403 同相册语义
|
||||||
|
// (ALBUM_NOT_FOUND);audit album.category.add/update/remove。
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { type INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { PrismaClient } from '@photo/db-cn';
|
||||||
|
import { ErrorCodes } from '@photo/contracts';
|
||||||
|
import { AppCnModule } from '../src/app-cn.module';
|
||||||
|
import { hashSessionToken } from '../src/auth/session.guard';
|
||||||
|
import { API_PREFIX } from '../src/shared/constants';
|
||||||
|
import { resetDb } from './reset';
|
||||||
|
|
||||||
|
/** 契约 Member 的字段全集(形状精确断言用)。 */
|
||||||
|
const MEMBER_KEYS = ['userId', 'account', 'displayName', 'role', 'status'].sort();
|
||||||
|
/** 契约 Category 的字段全集。 */
|
||||||
|
const CATEGORY_KEYS = ['id', 'albumId', 'sortOrder', 'translations'].sort();
|
||||||
|
|
||||||
|
describe('members & categories (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
|
||||||
|
let ownerId: string;
|
||||||
|
let inviteeId: string;
|
||||||
|
let collabId: string;
|
||||||
|
let outsiderId: string;
|
||||||
|
let ownerToken: string;
|
||||||
|
let inviteeToken: string;
|
||||||
|
let collabToken: string;
|
||||||
|
let outsiderToken: string;
|
||||||
|
let adminToken: string;
|
||||||
|
|
||||||
|
let albumId: string;
|
||||||
|
/** 依次创建的分类 id(cat1=sortOrder 5、cat2=sortOrder 1)。 */
|
||||||
|
let cat1Id: string;
|
||||||
|
let cat2Id: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await resetDb();
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppCnModule],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
app.setGlobalPrefix(API_PREFIX);
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const mkUser = (
|
||||||
|
account: string,
|
||||||
|
extra: { role?: string; sourceStatus?: string; credentialStatus?: string } = {},
|
||||||
|
) =>
|
||||||
|
prisma.users.create({
|
||||||
|
data: {
|
||||||
|
account,
|
||||||
|
role: extra.role ?? 'staff',
|
||||||
|
passwordHash: 'x',
|
||||||
|
displayName: account,
|
||||||
|
sourceStatus: extra.sourceStatus,
|
||||||
|
credentialStatus: extra.credentialStatus,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
ownerId = (await mkUser('mc-owner')).id;
|
||||||
|
inviteeId = (await mkUser('mc-invitee')).id;
|
||||||
|
collabId = (await mkUser('mc-collab')).id;
|
||||||
|
outsiderId = (await mkUser('mc-outsider')).id;
|
||||||
|
// 未开通 / 已停用账号:邀请应 400。
|
||||||
|
await mkUser('mc-pending', { credentialStatus: 'pending' });
|
||||||
|
await mkUser('mc-disabled', { sourceStatus: 'disabled' });
|
||||||
|
const adminId = (await mkUser('mc-admin', { role: 'admin' })).id;
|
||||||
|
|
||||||
|
const mkToken = async (userId: string): Promise<string> => {
|
||||||
|
const token = randomBytes(32).toString('hex');
|
||||||
|
await prisma.sessions.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
tokenHash: hashSessionToken(token),
|
||||||
|
expiresAt: new Date(Date.now() + 3_600_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return token;
|
||||||
|
};
|
||||||
|
ownerToken = await mkToken(ownerId);
|
||||||
|
inviteeToken = await mkToken(inviteeId);
|
||||||
|
collabToken = await mkToken(collabId);
|
||||||
|
outsiderToken = await mkToken(outsiderId);
|
||||||
|
adminToken = await mkToken(adminId);
|
||||||
|
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post(`/${API_PREFIX}/albums`)
|
||||||
|
.set('Authorization', `Bearer ${ownerToken}`)
|
||||||
|
.send({
|
||||||
|
translations: [
|
||||||
|
{ locale: 'zh-CN', title: '成员与分类相册' },
|
||||||
|
{ locale: 'en', title: 'Members and Categories' },
|
||||||
|
],
|
||||||
|
defaultLocale: 'zh-CN',
|
||||||
|
startAt: '2026-12-01T02:00:00.000Z',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
albumId = res.body.id as string;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 成员 ──
|
||||||
|
|
||||||
|
const postMember = (token: string, body: object) =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.post(`/${API_PREFIX}/albums/${albumId}/members`)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send(body);
|
||||||
|
|
||||||
|
const listMembers = (
|
||||||
|
token: string,
|
||||||
|
query?: Record<string, string | number>,
|
||||||
|
) =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.get(`/${API_PREFIX}/albums/${albumId}/members`)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.query(query ?? {});
|
||||||
|
|
||||||
|
const deleteMember = (token: string, userId: string) =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.delete(`/${API_PREFIX}/albums/${albumId}/members/${userId}`)
|
||||||
|
.set('Authorization', `Bearer ${token}`);
|
||||||
|
|
||||||
|
it('owner 邀请已开通同事 → 201 Member;DB 成员行;audit album.member.add', async () => {
|
||||||
|
const res = await postMember(ownerToken, { userId: inviteeId });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(Object.keys(res.body).sort()).toEqual(MEMBER_KEYS);
|
||||||
|
expect(res.body).toMatchObject({
|
||||||
|
userId: inviteeId,
|
||||||
|
account: 'mc-invitee',
|
||||||
|
displayName: 'mc-invitee',
|
||||||
|
role: 'collaborator',
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = await prisma.album_members.findUnique({
|
||||||
|
where: { albumId_userId: { albumId, userId: inviteeId } },
|
||||||
|
});
|
||||||
|
expect(row).toMatchObject({ role: 'collaborator', status: 'active' });
|
||||||
|
|
||||||
|
const audit = await prisma.audit_logs.findFirst({
|
||||||
|
where: {
|
||||||
|
action: 'album.member.add',
|
||||||
|
actorId: ownerId,
|
||||||
|
resourceId: albumId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(audit).not.toBeNull();
|
||||||
|
expect((audit?.metadata as { userId?: string })?.userId).toBe(inviteeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('重复邀请 → 409 MEMBER_CONFLICT', async () => {
|
||||||
|
const res = await postMember(ownerToken, { userId: inviteeId });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body.code).toBe(ErrorCodes.MEMBER_CONFLICT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('owner 邀请自己 / role=owner / 未知账号 / 未开通 / 已停用 → 400', async () => {
|
||||||
|
const self = await postMember(ownerToken, { userId: ownerId });
|
||||||
|
expect(self.status).toBe(400);
|
||||||
|
expect(self.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
// 本端点仅可添加 collaborator(owner 转移属 P4a 管理员操作)。
|
||||||
|
const asOwner = await postMember(ownerToken, {
|
||||||
|
userId: collabId,
|
||||||
|
role: 'owner',
|
||||||
|
});
|
||||||
|
expect(asOwner.status).toBe(400);
|
||||||
|
expect(asOwner.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
const unknown = await postMember(ownerToken, { userId: 'no-such-user' });
|
||||||
|
expect(unknown.status).toBe(400);
|
||||||
|
expect(unknown.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
expect(typeof unknown.body.message).toBe('string');
|
||||||
|
|
||||||
|
// 凭据未开通(credentialStatus=pending)与已停用(sourceStatus=disabled)。
|
||||||
|
const pendingUser = await prisma.users.findUnique({
|
||||||
|
where: { account: 'mc-pending' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const pending = await postMember(ownerToken, {
|
||||||
|
userId: pendingUser?.id,
|
||||||
|
});
|
||||||
|
expect(pending.status).toBe(400);
|
||||||
|
|
||||||
|
const disabledUser = await prisma.users.findUnique({
|
||||||
|
where: { account: 'mc-disabled' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const disabled = await postMember(ownerToken, {
|
||||||
|
userId: disabledUser?.id,
|
||||||
|
});
|
||||||
|
expect(disabled.status).toBe(400);
|
||||||
|
expect(disabled.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成员列表:成员/管理员视角 200(owner+协作者);外人员工 404;admin 200', async () => {
|
||||||
|
const ownerList = await listMembers(ownerToken);
|
||||||
|
expect(ownerList.status).toBe(200);
|
||||||
|
expect(ownerList.body.items).toHaveLength(2);
|
||||||
|
const ids = ownerList.body.items.map((m: { userId: string }) => m.userId);
|
||||||
|
expect(ids.sort()).toEqual([inviteeId, ownerId].sort());
|
||||||
|
expect(Object.keys(ownerList.body.items[0]).sort()).toEqual(MEMBER_KEYS);
|
||||||
|
// owner 行的 account/displayName 来自 users 表(扁平外键应用层组装)。
|
||||||
|
const ownerRow = ownerList.body.items.find(
|
||||||
|
(m: { userId: string }) => m.userId === ownerId,
|
||||||
|
);
|
||||||
|
expect(ownerRow).toMatchObject({
|
||||||
|
account: 'mc-owner',
|
||||||
|
displayName: 'mc-owner',
|
||||||
|
role: 'owner',
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 被邀请的协作者本人也是成员 → 可见。
|
||||||
|
const memberList = await listMembers(inviteeToken);
|
||||||
|
expect(memberList.status).toBe(200);
|
||||||
|
expect(memberList.body.items).toHaveLength(2);
|
||||||
|
|
||||||
|
const outsiderList = await listMembers(outsiderToken);
|
||||||
|
expect(outsiderList.status).toBe(404);
|
||||||
|
expect(outsiderList.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
|
||||||
|
|
||||||
|
const adminList = await listMembers(adminToken);
|
||||||
|
expect(adminList.status).toBe(200);
|
||||||
|
expect(adminList.body.items).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成员列表游标分页:addedAt DESC 稳定序,hasMore/nextCursor 成对', async () => {
|
||||||
|
// 显式回写互不相同的 addedAt:排序断言与真实写入时序解耦。
|
||||||
|
await prisma.album_members.updateMany({
|
||||||
|
where: { albumId, userId: ownerId },
|
||||||
|
data: { addedAt: new Date('2026-09-10T08:00:00.000Z') },
|
||||||
|
});
|
||||||
|
await prisma.album_members.updateMany({
|
||||||
|
where: { albumId, userId: inviteeId },
|
||||||
|
data: { addedAt: new Date('2026-09-10T10:00:00.000Z') },
|
||||||
|
});
|
||||||
|
|
||||||
|
const page1 = await listMembers(ownerToken, { limit: 1 });
|
||||||
|
expect(page1.status).toBe(200);
|
||||||
|
expect(page1.body.items.map((m: { userId: string }) => m.userId)).toEqual([
|
||||||
|
inviteeId,
|
||||||
|
]);
|
||||||
|
expect(page1.body.hasMore).toBe(true);
|
||||||
|
expect(typeof page1.body.nextCursor).toBe('string');
|
||||||
|
|
||||||
|
const page2 = await listMembers(ownerToken, {
|
||||||
|
limit: 1,
|
||||||
|
cursor: page1.body.nextCursor,
|
||||||
|
});
|
||||||
|
expect(page2.status).toBe(200);
|
||||||
|
expect(page2.body.items.map((m: { userId: string }) => m.userId)).toEqual([
|
||||||
|
ownerId,
|
||||||
|
]);
|
||||||
|
expect(page2.body.hasMore).toBe(false);
|
||||||
|
expect(page2.body.nextCursor).toBeNull();
|
||||||
|
|
||||||
|
const badCursor = await listMembers(ownerToken, { cursor: 'garbage' });
|
||||||
|
expect(badCursor.status).toBe(400);
|
||||||
|
expect(badCursor.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('owner 移除协作者 → 204;被移除者即刻失去相册可见性(A02);重复移除幂等 204;audit album.member.remove', async () => {
|
||||||
|
const res = await deleteMember(ownerToken, inviteeId);
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
|
||||||
|
// A02 链路:成员行是唯一权限来源,删除行后下一次请求即刻 404。
|
||||||
|
const albumRes = await request(app.getHttpServer())
|
||||||
|
.get(`/${API_PREFIX}/albums/${albumId}`)
|
||||||
|
.set('Authorization', `Bearer ${inviteeToken}`);
|
||||||
|
expect(albumRes.status).toBe(404);
|
||||||
|
expect(albumRes.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
|
||||||
|
|
||||||
|
// 成员列表回到仅 owner。
|
||||||
|
const ownerList = await listMembers(ownerToken);
|
||||||
|
expect(ownerList.body.items).toHaveLength(1);
|
||||||
|
|
||||||
|
// 幂等:再次移除同一用户仍 204(目标成员行已不存在)。
|
||||||
|
const again = await deleteMember(ownerToken, inviteeId);
|
||||||
|
expect(again.status).toBe(204);
|
||||||
|
|
||||||
|
// 移除成功的那次落审计(幂等重删不重复落)。
|
||||||
|
const audits = await prisma.audit_logs.findMany({
|
||||||
|
where: {
|
||||||
|
action: 'album.member.remove',
|
||||||
|
actorId: ownerId,
|
||||||
|
resourceId: albumId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(audits).toHaveLength(1);
|
||||||
|
expect((audits[0]?.metadata as { userId?: string })?.userId).toBe(inviteeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('owner 自移除 → 403 AUTH_FORBIDDEN(owner 转移属 P4a 管理员操作)', async () => {
|
||||||
|
const res = await deleteMember(ownerToken, ownerId);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
||||||
|
// owner 成员行仍在。
|
||||||
|
const row = await prisma.album_members.findUnique({
|
||||||
|
where: { albumId_userId: { albumId, userId: ownerId } },
|
||||||
|
});
|
||||||
|
expect(row).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('协作者增删成员 → 403;外人增删 → 404;未认证 → 401', async () => {
|
||||||
|
// 先把 standing collaborator 加进来(owner 操作,复用前述成功路径)。
|
||||||
|
const invite = await postMember(ownerToken, { userId: collabId });
|
||||||
|
expect(invite.status).toBe(201);
|
||||||
|
|
||||||
|
const collabAdd = await postMember(collabToken, { userId: outsiderId });
|
||||||
|
expect(collabAdd.status).toBe(403);
|
||||||
|
expect(collabAdd.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
||||||
|
|
||||||
|
const collabRemove = await deleteMember(collabToken, ownerId);
|
||||||
|
expect(collabRemove.status).toBe(403);
|
||||||
|
expect(collabRemove.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
||||||
|
|
||||||
|
const outsiderAdd = await postMember(outsiderToken, { userId: collabId });
|
||||||
|
expect(outsiderAdd.status).toBe(404);
|
||||||
|
expect(outsiderAdd.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
|
||||||
|
|
||||||
|
const outsiderRemove = await deleteMember(outsiderToken, collabId);
|
||||||
|
expect(outsiderRemove.status).toBe(404);
|
||||||
|
expect(outsiderRemove.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
|
||||||
|
|
||||||
|
const anon = await request(app.getHttpServer())
|
||||||
|
.post(`/${API_PREFIX}/albums/${albumId}/members`)
|
||||||
|
.send({ userId: collabId });
|
||||||
|
expect(anon.status).toBe(401);
|
||||||
|
expect(anon.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不存在的相册:邀请/列表/移除 → 404 ALBUM_NOT_FOUND', async () => {
|
||||||
|
const invite = await request(app.getHttpServer())
|
||||||
|
.post(`/${API_PREFIX}/albums/no-such-album/members`)
|
||||||
|
.set('Authorization', `Bearer ${ownerToken}`)
|
||||||
|
.send({ userId: collabId });
|
||||||
|
expect(invite.status).toBe(404);
|
||||||
|
expect(invite.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
|
||||||
|
|
||||||
|
const list = await request(app.getHttpServer())
|
||||||
|
.get(`/${API_PREFIX}/albums/no-such-album/members`)
|
||||||
|
.set('Authorization', `Bearer ${ownerToken}`);
|
||||||
|
expect(list.status).toBe(404);
|
||||||
|
|
||||||
|
const remove = await request(app.getHttpServer())
|
||||||
|
.delete(`/${API_PREFIX}/albums/no-such-album/members/${collabId}`)
|
||||||
|
.set('Authorization', `Bearer ${ownerToken}`);
|
||||||
|
expect(remove.status).toBe(404);
|
||||||
|
|
||||||
|
// admin 对不存在的 id 权限恒真:同样兜底 404(而非空列表 200)。
|
||||||
|
const adminList = await request(app.getHttpServer())
|
||||||
|
.get(`/${API_PREFIX}/albums/no-such-album/members`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`);
|
||||||
|
expect(adminList.status).toBe(404);
|
||||||
|
expect(adminList.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 分类 ──
|
||||||
|
|
||||||
|
const postCategory = (token: string, body: object) =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.post(`/${API_PREFIX}/albums/${albumId}/categories`)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send(body);
|
||||||
|
|
||||||
|
const patchCategory = (token: string, categoryId: string, body: object) =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.patch(`/${API_PREFIX}/categories/${categoryId}`)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send(body);
|
||||||
|
|
||||||
|
const deleteCategory = (token: string, categoryId: string) =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.delete(`/${API_PREFIX}/categories/${categoryId}`)
|
||||||
|
.set('Authorization', `Bearer ${token}`);
|
||||||
|
|
||||||
|
const getAlbumCategories = async (): Promise<
|
||||||
|
Array<{ id: string; sortOrder: number; translations: Array<{ locale: string; name: string }> }>
|
||||||
|
> => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get(`/${API_PREFIX}/albums/${albumId}`)
|
||||||
|
.set('Authorization', `Bearer ${ownerToken}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
return res.body.categories;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('owner 创建分类 → 201 Category;DB 行;audit album.category.add', async () => {
|
||||||
|
const res = await postCategory(ownerToken, {
|
||||||
|
sortOrder: 5,
|
||||||
|
translations: [
|
||||||
|
{ locale: 'zh-CN', name: '外场' },
|
||||||
|
{ locale: 'en', name: 'Outdoor' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(Object.keys(res.body).sort()).toEqual(CATEGORY_KEYS);
|
||||||
|
expect(res.body.albumId).toBe(albumId);
|
||||||
|
expect(res.body.sortOrder).toBe(5);
|
||||||
|
// 翻译按 locale 升序(确定性输出)。
|
||||||
|
expect(res.body.translations).toEqual([
|
||||||
|
{ locale: 'en', name: 'Outdoor' },
|
||||||
|
{ locale: 'zh-CN', name: '外场' },
|
||||||
|
]);
|
||||||
|
cat1Id = res.body.id as string;
|
||||||
|
|
||||||
|
const rows = await prisma.category_translations.findMany({
|
||||||
|
where: { categoryId: cat1Id },
|
||||||
|
});
|
||||||
|
expect(rows.map((r) => r.locale).sort()).toEqual(['en', 'zh-CN']);
|
||||||
|
|
||||||
|
const audit = await prisma.audit_logs.findFirst({
|
||||||
|
where: {
|
||||||
|
action: 'album.category.add',
|
||||||
|
actorId: ownerId,
|
||||||
|
resourceId: albumId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(audit).not.toBeNull();
|
||||||
|
expect((audit?.metadata as { categoryId?: string })?.categoryId).toBe(
|
||||||
|
cat1Id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 第二个分类 sortOrder=1:相册详情按 sortOrder 升序带出。
|
||||||
|
const res2 = await postCategory(ownerToken, {
|
||||||
|
sortOrder: 1,
|
||||||
|
translations: [{ locale: 'zh-CN', name: '签到台' }],
|
||||||
|
});
|
||||||
|
expect(res2.status).toBe(201);
|
||||||
|
cat2Id = res2.body.id as string;
|
||||||
|
|
||||||
|
const categories = await getAlbumCategories();
|
||||||
|
expect(categories.map((c) => c.id)).toEqual([cat2Id, cat1Id]);
|
||||||
|
expect(categories[0]).toMatchObject({ sortOrder: 1 });
|
||||||
|
expect(categories[0].translations).toEqual([
|
||||||
|
{ locale: 'zh-CN', name: '签到台' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('分类校验:重复 locale / 空 translations / 空更新体 → 400', async () => {
|
||||||
|
const dup = await postCategory(ownerToken, {
|
||||||
|
translations: [
|
||||||
|
{ locale: 'zh-CN', name: '重复' },
|
||||||
|
{ locale: 'zh-CN', name: '重复2' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(dup.status).toBe(400);
|
||||||
|
expect(dup.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
const empty = await postCategory(ownerToken, { translations: [] });
|
||||||
|
expect(empty.status).toBe(400);
|
||||||
|
expect(empty.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
const emptyPatch = await patchCategory(ownerToken, cat1Id, {});
|
||||||
|
expect(emptyPatch.status).toBe(400);
|
||||||
|
expect(emptyPatch.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('owner PATCH 分类:重排 + 改名(translations 全量替换)→ 200;详情次序更新;audit album.category.update', async () => {
|
||||||
|
const res = await patchCategory(ownerToken, cat1Id, {
|
||||||
|
sortOrder: 0,
|
||||||
|
translations: [{ locale: 'zh-CN', name: '主会场' }],
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.sortOrder).toBe(0);
|
||||||
|
// 全量替换:en 名称不复存在。
|
||||||
|
expect(res.body.translations).toEqual([{ locale: 'zh-CN', name: '主会场' }]);
|
||||||
|
|
||||||
|
// DB:旧 en 翻译行已删。
|
||||||
|
const rows = await prisma.category_translations.findMany({
|
||||||
|
where: { categoryId: cat1Id },
|
||||||
|
});
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]).toMatchObject({ locale: 'zh-CN', name: '主会场' });
|
||||||
|
|
||||||
|
// 相册详情:cat1(0) 现在排在 cat2(1) 之前。
|
||||||
|
const categories = await getAlbumCategories();
|
||||||
|
expect(categories.map((c) => c.id)).toEqual([cat1Id, cat2Id]);
|
||||||
|
|
||||||
|
const audit = await prisma.audit_logs.findFirst({
|
||||||
|
where: {
|
||||||
|
action: 'album.category.update',
|
||||||
|
actorId: ownerId,
|
||||||
|
resourceId: albumId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(audit).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
(audit?.metadata as { categoryId?: string })?.categoryId,
|
||||||
|
).toBe(cat1Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('owner DELETE 分类 → 204;详情不再列出;再删 → 404;audit album.category.remove', async () => {
|
||||||
|
const res = await deleteCategory(ownerToken, cat2Id);
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
|
||||||
|
const categories = await getAlbumCategories();
|
||||||
|
expect(categories.map((c) => c.id)).toEqual([cat1Id]);
|
||||||
|
|
||||||
|
const again = await deleteCategory(ownerToken, cat2Id);
|
||||||
|
expect(again.status).toBe(404);
|
||||||
|
expect(again.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
|
||||||
|
|
||||||
|
// DB:分类与翻译行俱删。
|
||||||
|
const categoryRow = await prisma.categories.findUnique({
|
||||||
|
where: { id: cat2Id },
|
||||||
|
});
|
||||||
|
expect(categoryRow).toBeNull();
|
||||||
|
const translationRows = await prisma.category_translations.findMany({
|
||||||
|
where: { categoryId: cat2Id },
|
||||||
|
});
|
||||||
|
expect(translationRows).toHaveLength(0);
|
||||||
|
|
||||||
|
const audit = await prisma.audit_logs.findFirst({
|
||||||
|
where: {
|
||||||
|
action: 'album.category.remove',
|
||||||
|
actorId: ownerId,
|
||||||
|
resourceId: albumId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(audit).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不存在的分类 id:PATCH/DELETE → 404 ALBUM_NOT_FOUND(契约分类 404 语义)', async () => {
|
||||||
|
const patch = await patchCategory(ownerToken, 'no-such-category', {
|
||||||
|
sortOrder: 2,
|
||||||
|
});
|
||||||
|
expect(patch.status).toBe(404);
|
||||||
|
expect(patch.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
|
||||||
|
|
||||||
|
const del = await deleteCategory(ownerToken, 'no-such-category');
|
||||||
|
expect(del.status).toBe(404);
|
||||||
|
expect(del.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('协作者分类增删改 → 403;外人员工 → 404;admin 创建 → 201', async () => {
|
||||||
|
const collabCreate = await postCategory(collabToken, {
|
||||||
|
translations: [{ locale: 'zh-CN', name: '协作者的分类' }],
|
||||||
|
});
|
||||||
|
expect(collabCreate.status).toBe(403);
|
||||||
|
expect(collabCreate.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
||||||
|
|
||||||
|
const collabPatch = await patchCategory(collabToken, cat1Id, {
|
||||||
|
sortOrder: 9,
|
||||||
|
});
|
||||||
|
expect(collabPatch.status).toBe(403);
|
||||||
|
expect(collabPatch.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
||||||
|
|
||||||
|
const collabDelete = await deleteCategory(collabToken, cat1Id);
|
||||||
|
expect(collabDelete.status).toBe(403);
|
||||||
|
expect(collabDelete.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
||||||
|
|
||||||
|
const outsiderCreate = await postCategory(outsiderToken, {
|
||||||
|
translations: [{ locale: 'zh-CN', name: '外人的分类' }],
|
||||||
|
});
|
||||||
|
expect(outsiderCreate.status).toBe(404);
|
||||||
|
expect(outsiderCreate.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
|
||||||
|
|
||||||
|
const adminCreate = await postCategory(adminToken, {
|
||||||
|
sortOrder: 2,
|
||||||
|
translations: [{ locale: 'en', name: 'Admin Corner' }],
|
||||||
|
});
|
||||||
|
expect(adminCreate.status).toBe(201);
|
||||||
|
|
||||||
|
const anon = await request(app.getHttpServer())
|
||||||
|
.post(`/${API_PREFIX}/albums/${albumId}/categories`)
|
||||||
|
.send({ translations: [{ locale: 'zh-CN', name: '匿名分类' }] });
|
||||||
|
expect(anon.status).toBe(401);
|
||||||
|
expect(anon.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user