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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user