8e674a902e
契约 deleteAlbum(DELETE /albums/{albumId} → 204 幂等)+ status enum 'deleted':
- 服务端:softDelete(canView 404→canManage 403 防枚举;乐观锁 claim +
outbox album.updated priority=high + audit album.delete);列表/详情/更新/
成员/资产/分类/水印/照片/展示位绑定全部 deleted→404;公开端 cn
(status!=='active')与 ov(isAlbumVisibleOv)天然屏蔽;ov 副本经
album.updated 载荷 status 直投自动下线
- Admin:列表表格操作「删除」+ 详情页头「删除相册」(确认含不可恢复说明)
- App:操作面板「删除相册」(红字、二次确认、成功退回列表并刷新)
- 验证:API 56/56、App 397/397(analyze 0)、admin typecheck/lint/test/build 绿
Co-Authored-By: Claude Code <noreply@anthropic.com>
236 lines
8.5 KiB
TypeScript
236 lines
8.5 KiB
TypeScript
// 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, status: true },
|
||
});
|
||
if (albumExists === null || albumExists.status === 'deleted') {
|
||
// 已删除与不存在同码 404(软删除语义,deleteAlbum)。
|
||
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;
|
||
}
|
||
}
|