feat(api): management photo list and account lookup
P1b Task 4: listAlbumPhotos (GET /albums/{albumId}/photos, tag
[photos]) + lookupUsersByAccount (GET /admin/users, tag [admin]).
- management list: all visibility states visible (unpublished/
published/hidden/deleted) for owner/admin; collaborator 403,
outsider/ghost album 404 (canView -> canManage ordering, same
anti-enumeration as members). Items = PhotoRecord + uploader
{userId, account, displayName} (flat-FK second query, redacted).
Keyset (uploadedAt,id) DESC via shared/cursor; visibility filter
validated against PhotoVisibility enum (400 otherwise), categoryId
must belong to the album (ghost/cross-album same-code 400).
pending_upload rows (uploadedAt NULL) excluded: the sort key is
both the contract-required field and the keyset predicate - NULL
can neither be shaped as PhotoRecord nor compared; in-flight
uploads are covered by the uploads history endpoint.
- account lookup (new src/admin module): prefix search over active
users only (credentialStatus=active, sourceStatus!=disabled,
!localLocked - same eligibility as SessionGuard/members.add),
account ASC, no cursor (small cap). $queryRaw LIKE prefix% with
explicit ESCAPE '!': !/%/_/\ escaped to literal matching (Prisma
startsWith escaping is not contractual); utf8mb4_0900_bin makes
the prefix match case-sensitive ('AL' misses 'alice'), documented
in contract + tests. Access policy per contract: any authenticated
staff (deviation from plan's admin/owner), unauthenticated 401.
- e2e: photos-admin (6) + users-lookup (6) cases; api e2e 136 -> 148.
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
// services/api/src/admin/admin.module.ts —— 管理辅助域模块(P1b Task 4)。
|
||||
// 装配 UsersLookupController/UsersLookupService;仅依赖 @Global 的 PrismaService
|
||||
// (SessionGuard 同样只依赖 Prisma),无需导入其他功能模块。
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersLookupController } from './users-lookup.controller';
|
||||
import { UsersLookupService } from './users-lookup.service';
|
||||
|
||||
@Module({
|
||||
controllers: [UsersLookupController],
|
||||
providers: [UsersLookupService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
@@ -0,0 +1,78 @@
|
||||
// services/api/src/admin/users-lookup.controller.ts —— /admin/users 端点
|
||||
// (P1b Task 4)。方法名与契约 operationId 一一对应(lookupUsersByAccount)。
|
||||
// 控制器级 SessionGuard:访问策略=任何已认证工作人员(契约描述的 P1b 决策,
|
||||
// 见服务文件头),认证通过即可,无角色判定。控制器只做查询参数规整
|
||||
// (accountPrefix 必填 2-64 字符;limit 缺省 10、1..20——本端点独立 bounds,
|
||||
// 同公开照片列表在 public.controller 本地维护 parsePublicPhotoLimit 的先例,
|
||||
// 勿混用 shared/pagination 的 1..100);语义在服务层。
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ErrorCodes } from '@photo/contracts';
|
||||
import { ApiException } from '../api-exception';
|
||||
import { SessionGuard } from '../auth/session.guard';
|
||||
import { validationFailed } from '../albums/support';
|
||||
import {
|
||||
UsersLookupService,
|
||||
type AdminUserLookupListResponse,
|
||||
} from './users-lookup.service';
|
||||
|
||||
/** 契约 lookupUsersByAccount.limit:缺省 10,上下界 1..20。 */
|
||||
export const DEFAULT_LOOKUP_LIMIT = 10;
|
||||
export const MAX_LOOKUP_LIMIT = 20;
|
||||
|
||||
/** accountPrefix 查询参数:必填(缺失/空串 400),长度 2..64(契约 bounds)。 */
|
||||
export function parseAccountPrefix(raw: string | undefined): string {
|
||||
if (raw === undefined || raw === '') {
|
||||
throw validationFailed('accountPrefix 必填(2-64 字符)');
|
||||
}
|
||||
if (raw.length < 2 || raw.length > 64) {
|
||||
throw validationFailed('accountPrefix 须为 2-64 字符');
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** limit 查询参数:缺省 10,须为 1..20 的十进制整数(契约 bounds)。 */
|
||||
export function parseLookupLimit(raw: string | undefined): number {
|
||||
if (raw === undefined || raw === '') {
|
||||
return DEFAULT_LOOKUP_LIMIT;
|
||||
}
|
||||
if (!/^\d+$/.test(raw)) {
|
||||
throw new ApiException(
|
||||
ErrorCodes.VALIDATION_FAILED,
|
||||
'limit 必须是 1-20 的整数',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
const limit = Number.parseInt(raw, 10);
|
||||
if (limit < 1 || limit > MAX_LOOKUP_LIMIT) {
|
||||
throw new ApiException(
|
||||
ErrorCodes.VALIDATION_FAILED,
|
||||
'limit 必须是 1-20 的整数',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
@Controller('admin/users')
|
||||
@UseGuards(SessionGuard)
|
||||
export class UsersLookupController {
|
||||
constructor(private readonly usersLookupService: UsersLookupService) {}
|
||||
|
||||
/** 账号前缀查询:激活用户脱敏列表(account 升序,无游标)。 */
|
||||
@Get()
|
||||
lookupUsersByAccount(
|
||||
@Query('accountPrefix') accountPrefix: string | undefined,
|
||||
@Query('limit') limit: string | undefined,
|
||||
): Promise<AdminUserLookupListResponse> {
|
||||
return this.usersLookupService.lookup(
|
||||
parseAccountPrefix(accountPrefix),
|
||||
parseLookupLimit(limit),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// services/api/src/admin/users-lookup.service.ts —— 账号前缀查询(P1b Task 4,
|
||||
// 契约 lookupUsersByAccount,tag [admin])。邀请协作者的前置查询:按 account
|
||||
// 前缀搜索激活用户,返回 {id,account,displayName} 脱敏列表(不含邮箱/手机/
|
||||
// 状态/凭据字段)。
|
||||
// 访问策略(P1b 决策,契约描述):任何已认证工作人员可用——邀请发起方不限于
|
||||
// 创建者/管理员,泄露面仅为激活用户的最小展示字段、接收方为已认证内部人员;
|
||||
// 未认证一律 401(控制器级 SessionGuard 承担,服务层不再重复角色判定)。
|
||||
// 实现要点:
|
||||
// · 前缀匹配大小写敏感:库级 collation utf8mb4_0900_bin(schema.prisma 头注
|
||||
// 约定),LIKE 按二进制逐字节比较——'AL' 不命中 'alice'(契约已注明)。
|
||||
// · LIKE 通配符显式转义:Prisma startsWith 对 %/_ 的转义行为无契约保证
|
||||
// (引擎版本相关),此处 $queryRaw 显式 `ESCAPE '!'`,前缀中 !/%/_/\ 四
|
||||
// 字符前置 ! 后按字面匹配——'esc_' 只命中字面下划线账号,不作单字符通配。
|
||||
// · 资格过滤与 SessionGuard 同一谓词(credentialStatus=active 且
|
||||
// sourceStatus≠disabled 且 !localLocked):被停用/待激活/锁定账号不可被
|
||||
// 邀请,也就不应出现在候选列表(与 members.add 的目标校验一致)。
|
||||
// · 无游标:前缀搜索 + 小上限(1..20 缺省 10)即取即用(契约决策),按
|
||||
// account 升序返回确定性输出。
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { components } from '@photo/contracts';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export type AdminUserLookupListResponse =
|
||||
components['schemas']['AdminUserLookupList'];
|
||||
|
||||
/** $queryRaw 行形状(SELECT 列即脱敏字段全集——不选取任何凭据/状态列)。 */
|
||||
interface LookupRow {
|
||||
id: string;
|
||||
account: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LIKE 前缀转义(ESCAPE '!'):! 自身、% 与 _(MySQL 通配符)及 \(历史转义
|
||||
* 字符,防御不同 SQL_MODE 下的语义漂移)前置 ! 后按字面匹配。
|
||||
*/
|
||||
export function escapeLikePrefix(prefix: string): string {
|
||||
return prefix.replace(/[!%_\\]/g, (ch) => `!${ch}`);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UsersLookupService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** 前缀查询(契约 lookupUsersByAccount):激活用户、account 升序、limit 截断。 */
|
||||
async lookup(
|
||||
accountPrefix: string,
|
||||
limit: number,
|
||||
): Promise<AdminUserLookupListResponse> {
|
||||
const pattern = `${escapeLikePrefix(accountPrefix)}%`;
|
||||
// 参数经 $queryRaw 模板绑定(无拼接注入面);LIMIT ? 为 MySQL 合法占位。
|
||||
const rows = await this.prisma.$queryRaw<LookupRow[]>`
|
||||
SELECT id, account, displayName
|
||||
FROM users
|
||||
WHERE account LIKE ${pattern} ESCAPE '!'
|
||||
AND credentialStatus = 'active'
|
||||
AND sourceStatus <> 'disabled'
|
||||
AND localLocked = false
|
||||
ORDER BY account ASC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
account: row.account,
|
||||
displayName: row.displayName,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ 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 { AdminModule } from './admin/admin.module';
|
||||
import { AllExceptionsFilter } from './all-exceptions.filter';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { HealthController } from './health.controller';
|
||||
@@ -38,8 +39,11 @@ import type { ServerRequestWithId } from './shared/request-with-id';
|
||||
UploadsModule,
|
||||
// 公开(观众端)域(Task 22 getPublicAlbum;Task 23 公开照片读)。
|
||||
PublicModule,
|
||||
// 照片管理域(Task 24:可见性生命周期 PATCH/DELETE)。
|
||||
// 照片管理域(Task 24:可见性生命周期 PATCH/DELETE;P1b Task 4 增补
|
||||
// /albums/{albumId}/photos 管理列表)。
|
||||
PhotosModule,
|
||||
// 管理辅助域(P1b Task 4:/admin/users 账号前缀查询)。
|
||||
AdminModule,
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
// 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// services/api/src/photos/album-photos.controller.ts —— /albums/{albumId}/photos
|
||||
// 管理列表端点(P1b Task 4)。方法名与契约 operationId 一一对应
|
||||
// (listAlbumPhotos,tag [photos]——路由挂在相册子路径下,域仍属照片管理)。
|
||||
// 控制器级 SessionGuard:端点需认证。控制器只做认证用户透传 + 查询参数规整
|
||||
// (visibility 本域 dto / categoryId 空串归一 / cursor+limit 走 shared/pagination,
|
||||
// 同契约 bounds);权限与 HTTP 映射由 PhotosService 抛 ApiException
|
||||
// (404 ALBUM_NOT_FOUND / 403 AUTH_FORBIDDEN / 400 VALIDATION_FAILED)。
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { userOf } from '../albums/albums.controller';
|
||||
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
|
||||
import { parseCursor, parseLimit } from '../shared/pagination';
|
||||
import { parseVisibilityFilter } from './dto';
|
||||
import { PhotosService, type AdminPhotoListResponse } from './photos.service';
|
||||
|
||||
@Controller('albums/:albumId/photos')
|
||||
@UseGuards(SessionGuard)
|
||||
export class AlbumPhotosController {
|
||||
constructor(private readonly photosService: PhotosService) {}
|
||||
|
||||
/**
|
||||
* 管理照片列表:全部可见性状态可见(区别于公开列表仅 published),
|
||||
* (uploadedAt,id) 稳定倒序键集游标;条目内嵌 uploader 脱敏信息。
|
||||
*/
|
||||
@Get()
|
||||
listAlbumPhotos(
|
||||
@Param('albumId') albumId: string,
|
||||
@Query('visibility') visibility: string | undefined,
|
||||
@Query('categoryId') categoryId: string | undefined,
|
||||
@Query('cursor') cursor: string | undefined,
|
||||
@Query('limit') limit: string | undefined,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<AdminPhotoListResponse> {
|
||||
return this.photosService.listAlbumPhotos(userOf(req), albumId, {
|
||||
visibility: parseVisibilityFilter(visibility),
|
||||
// 空串视为缺省(同公开列表 category 规整惯例)。
|
||||
categoryId: categoryId === '' ? undefined : categoryId,
|
||||
cursor: parseCursor(cursor),
|
||||
limit: parseLimit(limit),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
// services/api/src/photos/dto.ts —— 照片可见性端点入参 DTO(Task 24)。
|
||||
// 校验约束对齐契约 PhotoVisibilityUpdateRequest:visibility 仅允许
|
||||
// hidden/published(enum 之外任何值 → 400 VALIDATION_FAILED);deleted 只能经
|
||||
// DELETE 进入,不开放 PATCH 直达(单向终态,见 photos.service 状态机)。
|
||||
// services/api/src/photos/dto.ts —— 照片域入参规整(Task 24 起步:PATCH body;
|
||||
// P1b Task 4 增补:管理列表 visibility 查询参数)。校验约束对齐契约:
|
||||
// PhotoVisibilityUpdateRequest.visibility 仅允许 hidden/published(enum 之外
|
||||
// 任何值 → 400 VALIDATION_FAILED);deleted 只能经 DELETE 进入,不开放 PATCH
|
||||
// 直达(单向终态,见 photos.service 状态机)。
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import { IsIn } from 'class-validator';
|
||||
import { ErrorCodes } from '@photo/contracts';
|
||||
import { ApiException } from '../api-exception';
|
||||
|
||||
/** 契约 PhotoVisibilityUpdateRequest.visibility 取值(hidden↔published 间切换)。 */
|
||||
export const PHOTO_VISIBILITY_TARGETS = ['hidden', 'published'] as const;
|
||||
@@ -12,3 +16,33 @@ export class PhotoVisibilityUpdateDto {
|
||||
@IsIn(PHOTO_VISIBILITY_TARGETS)
|
||||
visibility!: PhotoVisibilityTarget;
|
||||
}
|
||||
|
||||
/** 契约 PhotoVisibility 全集(管理列表 visibility 过滤参数的合法值域)。 */
|
||||
export const PHOTO_VISIBILITIES = [
|
||||
'unpublished',
|
||||
'published',
|
||||
'hidden',
|
||||
'deleted',
|
||||
] as const;
|
||||
export type PhotoVisibility = (typeof PHOTO_VISIBILITIES)[number];
|
||||
|
||||
/**
|
||||
* 管理列表 visibility 查询参数(P1b Task 4):空串视为缺省(同 categoryId
|
||||
* 的规整惯例,?visibility= 不构成显式过滤);非空须为契约 PhotoVisibility
|
||||
* 枚举值,否则 400 VALIDATION_FAILED(契约 listAlbumPhotos 400 语义)。
|
||||
*/
|
||||
export function parseVisibilityFilter(
|
||||
raw: string | undefined,
|
||||
): PhotoVisibility | undefined {
|
||||
if (raw === undefined || raw === '') {
|
||||
return undefined;
|
||||
}
|
||||
if (!(PHOTO_VISIBILITIES as readonly string[]).includes(raw)) {
|
||||
throw new ApiException(
|
||||
ErrorCodes.VALIDATION_FAILED,
|
||||
'visibility 取值非法',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
return raw as PhotoVisibility;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// services/api/src/photos/photos.module.ts —— 照片管理模块(Task 24)。
|
||||
// 装配:PhotosController/PhotosService + PermissionsService(经 AlbumsModule
|
||||
// 导入复用 A02 逐次权限判定;同 uploads 模块的装配先例)。
|
||||
// services/api/src/photos/photos.module.ts —— 照片管理模块(Task 24;P1b Task 4
|
||||
// 增补管理列表控制器)。装配:PhotosController/AlbumPhotosController/
|
||||
// PhotosService + PermissionsService(经 AlbumsModule 导入复用 A02 逐次权限
|
||||
// 判定;同 uploads 模块的装配先例)。
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AlbumsModule } from '../albums/albums.module';
|
||||
import { AlbumPhotosController } from './album-photos.controller';
|
||||
import { PhotosController } from './photos.controller';
|
||||
import { PhotosService } from './photos.service';
|
||||
|
||||
@Module({
|
||||
imports: [AlbumsModule],
|
||||
controllers: [PhotosController],
|
||||
controllers: [PhotosController, AlbumPhotosController],
|
||||
providers: [PhotosService],
|
||||
})
|
||||
export class PhotosModule {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// services/api/src/photos/photos.service.ts —— 照片可见性生命周期(Task 24:
|
||||
// updatePhoto 隐藏/恢复发布、deletePhoto 软删除)。契约权威:openapi.yaml tags
|
||||
// [photos]。状态机(visibility,docs §5.5「对外可见性」):
|
||||
// updatePhoto 隐藏/恢复发布、deletePhoto 软删除)+ 管理照片列表(P1b Task 4:
|
||||
// listAlbumPhotos,全状态可见 + uploader 脱敏内嵌 + (uploadedAt,id) 键集游标)。
|
||||
// 契约权威:openapi.yaml tags [photos]。状态机(visibility,docs §5.5「对外可见性」):
|
||||
// unpublished ──(worker 发布事务, Task 21)──▶ published
|
||||
// published ◀──────(manager)──────▶ hidden 双向,管理者可来回
|
||||
// unpublished/published/hidden ──(manager)──▶ deleted 单向终态(软删除)
|
||||
@@ -26,8 +27,18 @@ import type { Prisma } from '@photo/db-cn';
|
||||
import { ApiException } from '../api-exception';
|
||||
import type { AuthUser } from '../auth/session.guard';
|
||||
import { PermissionsService } from '../albums/permissions.service';
|
||||
import {
|
||||
albumForbidden,
|
||||
albumNotFound,
|
||||
validationFailed,
|
||||
} from '../albums/support';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { PhotoVisibilityTarget } from './dto';
|
||||
import {
|
||||
decodeKeysetCursor,
|
||||
encodeKeysetCursor,
|
||||
keysetWhereDesc,
|
||||
} from '../shared/cursor';
|
||||
import type { PhotoVisibility, PhotoVisibilityTarget } from './dto';
|
||||
import {
|
||||
photoForbidden,
|
||||
photoNotFound,
|
||||
@@ -35,6 +46,7 @@ import {
|
||||
} from './support';
|
||||
|
||||
export type PhotoRecord = components['schemas']['PhotoRecord'];
|
||||
export type AdminPhotoListResponse = components['schemas']['AdminPhotoList'];
|
||||
|
||||
type PhotoRow = Prisma.photosGetPayload<Prisma.photosDefaultArgs>;
|
||||
type PhotoVariantRow = Prisma.photo_variantsGetPayload<
|
||||
@@ -68,6 +80,126 @@ export class PhotosService {
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 管理照片列表(P1b Task 4,契约 listAlbumPhotos):区别于公开列表——全部
|
||||
* 可见性状态可见(unpublished/published/hidden/deleted),条目内嵌 uploader
|
||||
* 脱敏信息(account/displayName,无凭据字段)。权限顺序防枚举(同 members):
|
||||
* canViewAlbum=false → 404 ALBUM_NOT_FOUND;可见但 canManageAlbum=false
|
||||
* (协作者)→ 403 AUTH_FORBIDDEN;admin 对幽灵 id 权限恒真,空相册兜底 404。
|
||||
* 排序 (uploadedAt,id) DESC 键集游标(shared/cursor)。
|
||||
*
|
||||
* 未完成上传(pending_upload、uploadedAt NULL)不入列表:uploadedAt 既是契约
|
||||
* 必返字段又是键集排序键——NULL 行既无法以契约形状返回(toPhotoRecord 的
|
||||
* new Date(0) 兜底会伪造 1970 时间轴),也无法参与键集谓词(SQL 对 NULL 的
|
||||
* 比较恒 UNKNOWN,跨页会被静默跳过)。进行中的会话由 uploads 历史端点表达。
|
||||
*/
|
||||
async listAlbumPhotos(
|
||||
user: AuthUser,
|
||||
albumId: string,
|
||||
query: {
|
||||
visibility?: PhotoVisibility;
|
||||
categoryId?: string;
|
||||
cursor?: string;
|
||||
limit: number;
|
||||
},
|
||||
): Promise<AdminPhotoListResponse> {
|
||||
if (!(await this.permissions.canViewAlbum(user, albumId))) {
|
||||
throw albumNotFound();
|
||||
}
|
||||
if (!(await this.permissions.canManageAlbum(user, albumId))) {
|
||||
throw albumForbidden('仅创建者或管理员可访问管理列表');
|
||||
}
|
||||
// admin 对不存在 id 的 canView/canManage 恒真(非 admin 的可见成员行本身
|
||||
// 蕴含相册存在),空相册兜底同码 404——契约「相册不存在,或当前用户不可见」。
|
||||
const albumExists = await this.prisma.albums.findUnique({
|
||||
where: { id: albumId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (albumExists === null) {
|
||||
throw albumNotFound();
|
||||
}
|
||||
|
||||
// categoryId:须为本相册分类;幽灵/跨册同码 400(同公开列表语义,不区分
|
||||
// 两种失败,防探测)。
|
||||
if (query.categoryId !== undefined) {
|
||||
const category = await this.prisma.categories.findUnique({
|
||||
where: { id: query.categoryId },
|
||||
select: { albumId: true },
|
||||
});
|
||||
if (category === null || category.albumId !== albumId) {
|
||||
throw validationFailed('categoryId 不属于该相册');
|
||||
}
|
||||
}
|
||||
|
||||
let decoded: ReturnType<typeof decodeKeysetCursor> = null;
|
||||
if (query.cursor !== undefined && query.cursor !== '') {
|
||||
decoded = decodeKeysetCursor(query.cursor);
|
||||
if (decoded === null) {
|
||||
throw validationFailed('cursor 不合法');
|
||||
}
|
||||
}
|
||||
|
||||
const where: Prisma.photosWhereInput = {
|
||||
albumId,
|
||||
uploadedAt: { not: null },
|
||||
...(query.visibility !== undefined
|
||||
? { visibility: query.visibility }
|
||||
: {}),
|
||||
...(query.categoryId !== undefined
|
||||
? { categoryId: query.categoryId }
|
||||
: {}),
|
||||
};
|
||||
if (decoded !== null) {
|
||||
Object.assign(where, keysetWhereDesc('uploadedAt', 'id', decoded));
|
||||
}
|
||||
const rows = await this.prisma.photos.findMany({
|
||||
where,
|
||||
orderBy: [{ uploadedAt: 'desc' }, { id: 'desc' }],
|
||||
take: query.limit + 1, // 多取一条探测 hasMore(同相册/成员列表)
|
||||
});
|
||||
const hasMore = rows.length > query.limit;
|
||||
const page = hasMore ? rows.slice(0, query.limit) : rows;
|
||||
|
||||
// users 无 relation(扁平外键):二次查询应用层组装 uploader 脱敏信息。
|
||||
const users =
|
||||
page.length === 0
|
||||
? []
|
||||
: await this.prisma.users.findMany({
|
||||
where: { id: { in: page.map((p) => p.uploaderId) } },
|
||||
select: { id: true, account: true, displayName: true },
|
||||
});
|
||||
const userById = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
const items = page.flatMap((row) => {
|
||||
const uploader = userById.get(row.uploaderId);
|
||||
// 用户行缺失(P1a 无用户删除,仅可能来自手工改库):跳过而非 5xx(同
|
||||
// 成员列表先例,单条脏数据不拖垮整页)。
|
||||
return uploader === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
...toPhotoRecord(row),
|
||||
uploader: {
|
||||
userId: uploader.id,
|
||||
account: uploader.account,
|
||||
displayName: uploader.displayName,
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const last = page.at(-1);
|
||||
const nextCursor =
|
||||
hasMore && last !== undefined
|
||||
? encodeKeysetCursor({
|
||||
// where 已排除 NULL:断言收窄供类型系统(键集取页末行 DB 位置)。
|
||||
sort: (last.uploadedAt ?? new Date(0)).toISOString(),
|
||||
id: last.id,
|
||||
})
|
||||
: null;
|
||||
return { items, nextCursor, hasMore: nextCursor !== null };
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整可见性(契约 updatePhoto):hidden↔published 间切换。权限 → 状态机 →
|
||||
* 条件更新事务(entityVersion+1 + outbox + audit)→ 回读回显。
|
||||
|
||||
Reference in New Issue
Block a user