feat(api): server-side album role permission checks

Task 14 (A02 foundation): PermissionsService with fresh-DB-per-call
canViewAlbum/canManageAlbum/canUploadToAlbum/canManagePhoto over the
album_members @@unique([albumId,userId]) compound key; admin bypass,
owner/collaborator matrix per requirements doc section 2, removed
members lose access on the very next call (no caching). Boolean-only
returns; callers map 404/403. AlbumsModule shell wired into app module
(controllers arrive in Task 15). jest maxWorkers=1: DB-backed specs
share one MySQL database and reset it via TRUNCATE, so parallel spec
files wipe each other's seed data.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 21:34:22 +08:00
parent 39cae26106
commit 2d9b9f9fad
5 changed files with 349 additions and 2 deletions
+12
View File
@@ -0,0 +1,12 @@
// services/api/src/albums/albums.module.ts —— 相册模块壳(Task 14)。
// 本任务只落 PermissionsService(A02 权限基石,依赖 @Global 的 PrismaService),
// 控制器与相册 CRUD 自 Task 15 起加入本模块。export 供 uploads/photos 等后续
// 模块经模块间 DI 复用同一份判定逻辑。
import { Module } from '@nestjs/common';
import { PermissionsService } from './permissions.service';
@Module({
providers: [PermissionsService],
exports: [PermissionsService],
})
export class AlbumsModule {}
@@ -0,0 +1,100 @@
// services/api/src/albums/permissions.service.ts —— 相册角色权限判定(Task 14,
// A02 基石)。权限矩阵来源:docs/01-需求说明书.md §2 角色与权限表:
// canViewAlbum 浏览(成员/管理员可见);canManageAlbum 配置相册+管理成员;
// canUploadToAlbum 上传(创建者自己相册/协作者已加入相册);
// canManagePhoto 管理照片(管理员全部;创建者相册全部;协作者仅自己上传)。
// 原则(§2「服务端必须逐次校验权限」):每个方法每次调用都直查数据库,
// 不做任何跨调用缓存/记忆化——成员被移除或账号状态变化后的下一次请求即刻
// 生效;上传凭证等既有签发物的拦截由各端点自行补充(A20)。
// 本服务只返回 boolean,不抛 ApiException:404(资源不存在/不可见)与 403
// (可见但无权)的映射是控制器层(Task 15)的职责——保持纯判定便于复用与测试。
// 查询一律走 schema 的复合唯一/索引:album_members @@unique([albumId,userId])
// 的 findUnique(albumId_userId),photos 按 @id 主键。
// canDownloadOriginal 属 P4a(原图下载策略),P1a 不实现。
import { Injectable } from '@nestjs/common';
import type { AuthUser } from '../auth/session.guard';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class PermissionsService {
constructor(private readonly prisma: PrismaService) {}
/** 浏览相册:公司管理员恒真;否则需 album_members 有效行(status=active)。 */
async canViewAlbum(user: AuthUser, albumId: string): Promise<boolean> {
if (user.role === 'admin') {
return true;
}
const member = await this.findActiveMember(user.id, albumId);
return member !== null;
}
/**
* 配置相册 / 管理成员(邀请、移除协作者):公司管理员恒真;
* 否则需 album_members 行 role='owner' 且 status='active'。
*/
async canManageAlbum(user: AuthUser, albumId: string): Promise<boolean> {
if (user.role === 'admin') {
return true;
}
const member = await this.findActiveMember(user.id, albumId);
return member !== null && member.role === 'owner';
}
/**
* 上传照片:公司管理员恒真;否则任一有效成员行(owner 或 collaborator,
* 「已加入的相册」)。注意:这里不检查 albums.uploadEnabled 开关——
* 上传开关是相册级运营状态而非角色权限,Task 18 的上传端点在权限通过后
* 单独校验开关(关注点分离:本服务只回答“角色能不能”,不回答“相册收不收”)。
*/
async canUploadToAlbum(user: AuthUser, albumId: string): Promise<boolean> {
if (user.role === 'admin') {
return true;
}
const member = await this.findActiveMember(user.id, albumId);
return member !== null;
}
/**
* 管理照片(修改分类/隐藏/软删除——“管理自己照片”的边界,§2):
* 公司管理员全部;相册创建者管本相册全部;协作者仅自己上传的照片
* (uploaderId=user.id);照片不存在返回 false(由调用方映射 404)。
*/
async canManagePhoto(user: AuthUser, photoId: string): Promise<boolean> {
if (user.role === 'admin') {
return true;
}
const photo = await this.prisma.photos.findUnique({
where: { id: photoId },
select: { albumId: true, uploaderId: true },
});
if (photo === null) {
return false;
}
if (photo.uploaderId === user.id) {
// 上传者本人:仍须是(当时的)有效成员——被移除后不能再管理历史上传。
const member = await this.findActiveMember(user.id, photo.albumId);
return member !== null;
}
// 他人照片:仅相册创建者可管理(协作者对他人上传无权,A02)。
const member = await this.findActiveMember(user.id, photo.albumId);
return member !== null && member.role === 'owner';
}
/**
* album_members 复合唯一查找(albumId_userId)。每次调用直查——
* A02 逐次校验:不缓存,成员行删除/状态变化下一次调用即刻生效。
*/
private async findActiveMember(
userId: string,
albumId: string,
): Promise<{ role: string } | null> {
const member = await this.prisma.album_members.findUnique({
where: { albumId_userId: { albumId, userId } },
select: { role: true, status: true },
});
if (member === null || member.status !== 'active') {
return null;
}
return member;
}
}
+3
View File
@@ -15,6 +15,7 @@ import {
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
import { LoggerModule } from 'nestjs-pino';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { AlbumsModule } from './albums/albums.module';
import { AllExceptionsFilter } from './all-exceptions.filter';
import { AuthModule } from './auth/auth.module';
import { HealthController } from './health.controller';
@@ -28,6 +29,8 @@ import type { ServerRequestWithId } from './shared/request-with-id';
// @Global:功能模块(Task 12+)直接注入 PrismaService,无需 re-import。
PrismaModule,
AuthModule,
// 相册域(Task 14 起步:PermissionsService;控制器自 Task 15 加入)。
AlbumsModule,
LoggerModule.forRoot({
pinoHttp: {
// 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id,