Files
photo-live/services/api/src/albums/members.service.ts
T
浅念 8e674a902e feat(albums): soft-delete albums (admin+owner, hidden & irreversible, both app entries)
契约 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>
2026-09-23 10:13:08 +08:00

236 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}
}