feat(contracts): p4a admin governance, watermark, download and usage endpoints

P4a Task 1 契约扩展批次(36 → 51 operations,gen/redocly/枚举同步守护绿):

- photos:POST /albums/{albumId}/photos/hide-batch(photoIds 1..200,逐项
  BatchHideResult {photoId, ok, code?},部分成功语义,A16);
- admin:POST /admin/albums/{albumId}/transfer(owner 交接,历史上传人不动,
  keepAsCollaborator,A17);
- albums:POST/GET /albums/{albumId}/watermark-jobs(WatermarkConfig 版本化
  + 历史重生成任务 state/progress/total/failed,原子切换、原图永不覆盖,
  A18/A20);
- interactions:GET /public/albums/{token}/photos/{photoId}/download
  (variant=browse|original 两级开关校验;**新增错误码 DOWNLOAD_DISABLED**,
  errors.ts 与 Error enum 两侧同步,A18);
- admin 账号治理:GET /admin/users 统一为 listAdminUsers 双模式 + PATCH
  /admin/users/{userId} 与 reset-password / activate / deactivate;
- admin 用户同步:GET /admin/user-sync/{status,runs,errors} + POST runs
  手动补拉(互斥复用进行中批次,A29);
- admin 用量:GET /admin/usage/summary + PUT thresholds(null 清除、
  即刻重评估,A23)。

偏差登记①——/admin/users 路径冲突(P1b lookupUsersByAccount):P4a 决策
统一为双模式端点(前缀模式沿用 P1b 语义与任何已认证工作人员可用;管理
列表模式 query/status 过滤 + 游标 + 仅 admin),响应统一 AdminUserList
(AdminUser 字段超集,游标元信息随行)。消费方同步:admin 邀请搜索
(fetchLookupUsers/类型镜像,行为不变)与 api 过渡实现(仍仅前缀模式、
limit 对齐 1..100 缺省 20;管理列表模式与仅 admin 判定随 Task 6 落地,
users-lookup e2e 已按统一形状修订)。

偏差登记②——apps/mobile 手写镜像未随本次更新:其邀请流程仍按旧 3 字段
投影消费(AdminUserList 为超集,运行时兼容,mobile IT 不经该路径);列
为 P4a 跟进项,仅当其 e2e 破坏时处理。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-20 15:56:38 +08:00
parent 45d46518b1
commit 8a8ad8a18d
8 changed files with 2959 additions and 166 deletions
+2 -2
View File
@@ -66,9 +66,9 @@ export function fetchRemoveAlbumMember(albumId: string, userId: string) {
return request.del<void>({ url: `/v1/albums/${albumId}/members/${userId}` })
}
/** 账号前缀查询(邀请协作者前置;≥2 字符,激活用户) */
/** 账号前缀查询(邀请协作者前置;≥2 字符,激活用户;P4a 契约统一 AdminUserList) */
export function fetchLookupUsers(accountPrefix: string, limit = 10) {
return request.get<{ items: Api.Album.UserLookupItem[] }>({
return request.get<Api.Album.UserLookupListResponse>({
url: '/v1/admin/users',
params: { accountPrefix, limit },
showErrorMessage: false
+9 -1
View File
@@ -147,13 +147,21 @@ declare namespace Api {
hasMore: boolean
}
/** 账号前缀查询条目(脱敏) */
/** 账号前缀查询条目(脱敏投影;P4a 契约统一为 AdminUserList/AdminUser,
邀请搜索仅消费以下三字段,完整 AdminUser 类型随 P4a Task 8 账号治理页落地) */
interface UserLookupItem {
id: string
account: string
displayName: string
}
/** 用户查询响应(P4a 契约统一 AdminUserList 形状:游标元信息 + 条目) */
interface UserLookupListResponse {
items: UserLookupItem[]
nextCursor: string | null
hasMore: boolean
}
/** 分类创建请求 */
interface CategoryCreatePayload {
sortOrder?: number
File diff suppressed because it is too large Load Diff
+6
View File
@@ -24,6 +24,12 @@ export const ErrorCodes = {
PHOTO_NOT_FOUND: 'PHOTO_NOT_FOUND',
PHOTO_NOT_PUBLIC: 'PHOTO_NOT_PUBLIC',
VALIDATION_FAILED: 'VALIDATION_FAILED',
// P4a Task 1:公开照片下载(/download)相册下载开关关闭时的 403。相册/照片
// 不可见仍走 404(ALBUM_NOT_PUBLIC/PHOTO_NOT_PUBLIC 不区分枚举);「可见但
// 禁止下载」是显式语义——复用 404 会对客户端撒谎,复用 AUTH_FORBIDDEN 则
// 语义错位(匿名观众无「权限」概念)。与 openapi.yaml Error enum 同步增补
// (1:1 守护见 __tests__/events.test.ts)。
DOWNLOAD_DISABLED: 'DOWNLOAD_DISABLED',
// P2 Task 3:内部同步接口(/internal/**)HMAC 鉴权失败的 401。AUTH_TOKEN_EXPIRED
// 语义绑死观众/员工会话,服务间签名失败复用它会对客户端撒谎;与 openapi.yaml
// Error enum 同步增补(1:1 守护见 __tests__/events.test.ts)。
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,14 @@
// 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);语义在服务层。
// (P1b Task 4 建立;P4a Task 1 契约统一为 listAdminUsers 双模式)。
// 方法名与契约 operationId 一一对应(listAdminUsers)。
// 控制器级 SessionGuard:访问策略=任何已认证工作人员(P1b 决策,见服务文件
// 头——管理列表模式的仅 admin 判定由 Task 6 补齐)。控制器只做查询参数规整,
// 语义在服务层。
// 过渡期边界(Task 6 收口):契约 P4a 起 accountPrefix 可选(缺省进入管理
// 列表模式:query/status 过滤 + 游标分页 + 仅 admin);当前实现仅前缀模式,
// 缺 accountPrefix 仍 400——管理列表模式随 Task 6 账号管理落地。
// limit 边界已对齐统一契约:缺省 20、上下界 1..100(P1b 时为 10/1..20;
// 邀请搜索调用方显式传 limit=10,行为不变)。
import {
Controller,
Get,
@@ -16,18 +20,16 @@ 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';
import { UsersLookupService, type AdminUserListResponse } from './users-lookup.service';
/** 契约 lookupUsersByAccount.limit:缺省 10,上下界 1..20。 */
export const DEFAULT_LOOKUP_LIMIT = 10;
export const MAX_LOOKUP_LIMIT = 20;
/** 契约 listAdminUsers.limit:缺省 20,上下界 1..100(P4a 统一分页 bounds)。 */
export const DEFAULT_LIST_LIMIT = 20;
export const MAX_LIST_LIMIT = 100;
/** accountPrefix 查询参数:必填(缺失/空串 400),长度 2..64(契约 bounds)。 */
/** accountPrefix 查询参数(前缀模式):2..64(契约 bounds)。 */
export function parseAccountPrefix(raw: string | undefined): string {
if (raw === undefined || raw === '') {
// Task 6 起此分支消失(缺省进入管理列表模式);当前实现仅前缀模式。
throw validationFailed('accountPrefix 必填(2-64 字符)');
}
if (raw.length < 2 || raw.length > 64) {
@@ -36,23 +38,23 @@ export function parseAccountPrefix(raw: string | undefined): string {
return raw;
}
/** limit 查询参数:缺省 10,须为 1..20 的十进制整数(契约 bounds)。 */
export function parseLookupLimit(raw: string | undefined): number {
/** limit 查询参数:缺省 20,须为 1..100 的十进制整数(契约 bounds)。 */
export function parseListLimit(raw: string | undefined): number {
if (raw === undefined || raw === '') {
return DEFAULT_LOOKUP_LIMIT;
return DEFAULT_LIST_LIMIT;
}
if (!/^\d+$/.test(raw)) {
throw new ApiException(
ErrorCodes.VALIDATION_FAILED,
'limit 必须是 1-20 的整数',
'limit 必须是 1-100 的整数',
HttpStatus.BAD_REQUEST,
);
}
const limit = Number.parseInt(raw, 10);
if (limit < 1 || limit > MAX_LOOKUP_LIMIT) {
if (limit < 1 || limit > MAX_LIST_LIMIT) {
throw new ApiException(
ErrorCodes.VALIDATION_FAILED,
'limit 必须是 1-20 的整数',
'limit 必须是 1-100 的整数',
HttpStatus.BAD_REQUEST,
);
}
@@ -64,15 +66,15 @@ export function parseLookupLimit(raw: string | undefined): number {
export class UsersLookupController {
constructor(private readonly usersLookupService: UsersLookupService) {}
/** 账号前缀查询:激活用户脱敏列表(account 升序,无游标)。 */
/** 用户查询/管理列表(统一响应 AdminUserList;当前仅前缀模式,见文件头)。 */
@Get()
lookupUsersByAccount(
listAdminUsers(
@Query('accountPrefix') accountPrefix: string | undefined,
@Query('limit') limit: string | undefined,
): Promise<AdminUserLookupListResponse> {
): Promise<AdminUserListResponse> {
return this.usersLookupService.lookup(
parseAccountPrefix(accountPrefix),
parseLookupLimit(limit),
parseListLimit(limit),
);
}
}
+37 -17
View File
@@ -1,10 +1,9 @@
// services/api/src/admin/users-lookup.service.ts —— 账号前缀查询(P1b Task 4,
// 契约 lookupUsersByAccount,tag [admin])。邀请协作者的前置查询:按 account
// 前缀搜索激活用户,返回 {id,account,displayName} 脱敏列表(不含邮箱/手机/
// 状态/凭据字段)。
// 访问策略(P1b 决策,契约描述):任何已认证工作人员可用——邀请发起方不限于
// 创建者/管理员,泄露面仅为激活用户的最小展示字段、接收方为已认证内部人员;
// 未认证一律 401(控制器级 SessionGuard 承担,服务层不再重复角色判定)。
// services/api/src/admin/users-lookup.service.ts —— 用户查询/管理列表(P1b
// Task 4 前缀查询;P4a Task 1 契约统一为 listAdminUsers 双模式端点)。
// P4a 契约决策:/admin/users 响应统一 AdminUserList(AdminUser 字段超集,
// 管理端 UI 按场景裁剪展示);本文件为**过渡实现**——仅落地前缀模式
// (P1b 语义原样:邀请协作者前置查询),管理列表模式(query/status 过滤 +
// 游标分页 + 仅 admin 访问)由 P4a Task 6 账号管理实现补齐。
// 实现要点:
// · 前缀匹配大小写敏感:库级 collation utf8mb4_0900_bin(schema.prisma 头注
// 约定),LIKE 按二进制逐字节比较——'AL' 不命中 'alice'(契约已注明)。
@@ -14,20 +13,28 @@
// · 资格过滤与 SessionGuard 同一谓词(credentialStatus=active 且
// sourceStatus≠disabled 且 !localLocked):被停用/待激活/锁定账号不可被
// 邀请,也就不应出现在候选列表(与 members.add 的目标校验一致)。
// · 无游标:前缀搜索 + 小上限(1..20 缺省 10)即取即用(契约决策),按
// account 升序返回确定性输出。
// · 前缀模式即取即用(limit 上限内一次取回):nextCursor=null、
// hasMore=false;游标翻页随 Task 6 管理列表模式落地。
import { Injectable } from '@nestjs/common';
import type { components } from '@photo/contracts';
import { PrismaService } from '../prisma/prisma.service';
export type AdminUserLookupListResponse =
components['schemas']['AdminUserLookupList'];
export type AdminUserListResponse = components['schemas']['AdminUserList'];
/** $queryRaw 行形状(SELECT 列即脱敏字段全集——不选取任何凭据/状态列)。 */
interface LookupRow {
/** AdminUser 契约条目类型(枚举字段的窄化转换目标)。 */
type AdminUserItem = AdminUserListResponse['items'][number];
/** $queryRaw 行形状(SELECT 列即 AdminUser 契约字段——不含任何凭据列)。 */
interface AdminUserRow {
id: string;
account: string;
displayName: string;
role: string;
sourceStatus: string;
localLocked: number; // MySQL BOOLEAN 经 $queryRaw 返回 0/1
credentialStatus: string;
locale: string | null;
createdAt: Date;
}
/**
@@ -42,15 +49,19 @@ export function escapeLikePrefix(prefix: string): string {
export class UsersLookupService {
constructor(private readonly prisma: PrismaService) {}
/** 前缀查询(契约 lookupUsersByAccount):激活用户、account 升序、limit 截断。 */
/**
* 前缀查询(契约 listAdminUsers 前缀模式):激活资格用户、account 升序、
* limit 截断;响应为统一 AdminUserList 形状(P4a)。
*/
async lookup(
accountPrefix: string,
limit: number,
): Promise<AdminUserLookupListResponse> {
): Promise<AdminUserListResponse> {
const pattern = `${escapeLikePrefix(accountPrefix)}%`;
// 参数经 $queryRaw 模板绑定(无拼接注入面);LIMIT ? 为 MySQL 合法占位。
const rows = await this.prisma.$queryRaw<LookupRow[]>`
SELECT id, account, displayName
const rows = await this.prisma.$queryRaw<AdminUserRow[]>`
SELECT id, account, displayName, role, sourceStatus, localLocked,
credentialStatus, locale, createdAt
FROM users
WHERE account LIKE ${pattern} ESCAPE '!'
AND credentialStatus = 'active'
@@ -64,7 +75,16 @@ export class UsersLookupService {
id: row.id,
account: row.account,
displayName: row.displayName,
// 库列为自由字符串(schema 约定取值集),窄化到契约枚举联合。
role: row.role as AdminUserItem['role'],
sourceStatus: row.sourceStatus as AdminUserItem['sourceStatus'],
localLocked: row.localLocked !== 0,
credentialStatus: row.credentialStatus as AdminUserItem['credentialStatus'],
locale: row.locale,
createdAt: row.createdAt.toISOString(),
})),
nextCursor: null,
hasMore: false,
};
}
}
+47 -24
View File
@@ -1,16 +1,18 @@
// services/api/test/users-lookup.e2e-spec.ts —— P1b Task 4 账号前缀查询 e2e
// (先写后实现)。真实 app + MySQL。覆盖:
// ① 前缀命中:prefix 'al' → albert+alice(account 升序);条目仅
// id/account/displayName(脱敏,无邮箱/手机/状态/凭据字段);响应仅 items
// (无游标——前缀搜索 + 小上限即取即用);
// (先写后实现);P4a Task 1 契约统一后同步修订。真实 app + MySQL。覆盖:
// ① 前缀命中:prefix 'al' → albert+alice(account 升序);条目为统一
// AdminUser 字段集(id/account/displayName + 治理字段 role/sourceStatus/
// localLocked/credentialStatus/locale/createdAt;无邮箱/手机/凭据字段);
// 响应为 AdminUserList(items + nextCursor/hasMore 游标元信息);
// ② 账号资格过滤:credentialStatus=pending / sourceStatus=disabled /
// localLocked=true 均排除(只留「激活」用户);
// ③ 大小写敏感(库级 utf8mb4_0900_bin 二进制校对,契约已注明):'AL' → 空、
// 'carol' → 空、'Carol' → 命中;
// ④ LIKE 通配符转义(ESCAPE 子句):前缀中的 _ / % 按字面匹配,不作单字符/
// 任意串通配(esc_ 仅命中字面下划线账号;al% 空);
// ⑤ 参数边界:accountPrefix 缺失/1 字符/65 字符 → 400;2 与 64 字符合法;
// limit 缺省 10、1..20 界内、0/21/非整数 → 400;
// ⑤ 参数边界:accountPrefix 缺失(过渡期实现仍 400,Task 6 起进入管理列表
// 模式)/1 字符/65 字符 → 400;2 与 64 字符合法;limit 缺省 20(P1b 时
// 为 10,P4a 统一分页 bounds)、1..100 界内、0/101/非整数 → 400;
// ⑥ 访问策略(P1b 决策,契约描述):未认证 401;任何已认证工作人员(含非
// admin 的 staff)200;admin 200。
import { randomBytes } from 'node:crypto';
@@ -121,27 +123,37 @@ describe('账号查询 GET /admin/users (e2e)', () => {
return req.query(query ?? {});
}
it("前缀 'al' → albert+alice 升序;禁用/待激活/锁定排除;条目与响应形状脱敏", async () => {
it("前缀 'al' → albert+alice 升序;禁用/待激活/锁定排除;条目与响应形状(AdminUserList)", async () => {
const res = await lookup(staffToken, { accountPrefix: 'al' });
expect(res.status).toBe(200);
// 响应仅 items(契约 AdminUserLookupList:无游标元信息)。
expect(Object.keys(res.body).sort()).toEqual(['items']);
const items = res.body.items as {
id: string;
account: string;
displayName: string;
}[];
// 响应为统一 AdminUserList(P4a):items + 游标元信息(前缀模式即取即用
// → nextCursor=null / hasMore=false)。
expect(Object.keys(res.body).sort()).toEqual(['hasMore', 'items', 'nextCursor']);
expect(res.body.nextCursor).toBeNull();
expect(res.body.hasMore).toBe(false);
const items = res.body.items as Record<string, unknown>[];
// account 升序:albert < alice;恰好两条(排除组全不出现在结果)。
expect(items.map((item) => item.account)).toEqual(['albert', 'alice']);
expect(items.map((item) => item.id)).toEqual([albertId, aliceId]);
expect(items.map((item) => item.displayName)).toEqual(['艾伯', '爱丽']);
// 条目形状:仅三个脱敏字段(无密码哈希/状态/角色等)。
// 条目形状:AdminUser 字段集(治理字段随 P4a 统一暴露;仍无邮箱/手机/
// 密码哈希等凭据字段)。
for (const item of items) {
expect(Object.keys(item).sort()).toEqual([
'account',
'createdAt',
'credentialStatus',
'displayName',
'id',
'locale',
'localLocked',
'role',
'sourceStatus',
]);
expect(item.role).toBe('staff');
expect(item.sourceStatus).toBe('local');
expect(item.localLocked).toBe(false);
expect(item.credentialStatus).toBe('active');
}
});
@@ -156,8 +168,13 @@ describe('账号查询 GET /admin/users (e2e)', () => {
const exact = await lookup(staffToken, { accountPrefix: 'Carol' });
expect(exact.status).toBe(200);
// 条目为 AdminUser 全字段:以关键字段子集断言(字段全集见用例 ①)。
expect(exact.body.items).toEqual([
{ id: carolId, account: 'Carol', displayName: '卡萝' },
expect.objectContaining({
id: carolId,
account: 'Carol',
displayName: '卡萝',
}),
]);
});
@@ -166,7 +183,11 @@ describe('账号查询 GET /admin/users (e2e)', () => {
const underscore = await lookup(staffToken, { accountPrefix: 'esc_' });
expect(underscore.status).toBe(200);
expect(underscore.body.items).toEqual([
{ id: escUnderscoreId, account: 'esc_ape', displayName: '下划线' },
expect.objectContaining({
id: escUnderscoreId,
account: 'esc_ape',
displayName: '下划线',
}),
]);
// 未转义时 al% 作任意串通配会命中 albert/alice 等——转义后按字面前缀匹配。
@@ -175,7 +196,9 @@ describe('账号查询 GET /admin/users (e2e)', () => {
expect(percent.body.items).toEqual([]);
});
it('accountPrefix 边界:缺失/空串/1 字符/65 字符 → 400;64 字符 → 200 空', async () => {
it('accountPrefix 边界:过渡期缺失/空串仍 400;1 字符/65 字符 → 400;64 字符 → 200 空', async () => {
// Task 6 起缺失 accountPrefix 进入管理列表模式(仅 admin);当前实现仅
// 前缀模式,缺失/空串仍 400(控制器文件头登记的过渡期边界)。
const missing = await lookup(staffToken, {});
expect(missing.status).toBe(400);
expect(missing.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
@@ -197,13 +220,13 @@ describe('账号查询 GET /admin/users (e2e)', () => {
expect(max.body.items).toEqual([]);
});
it('limit:缺省 10 且升序截断;5 合法;0/21/非整数 → 400;20 合法', async () => {
// zz-look-* 共 12 个激活账号:缺省 limit=10 → 恰前 10 个(01..10 升序)。
it('limit:缺省 20(P4a 统一 bounds);5 合法;0/101/非整数 → 400;100 合法', async () => {
// zz-look-* 共 12 个激活账号:缺省 limit=20 > 存量 12 → 全量升序返回。
const def = await lookup(staffToken, { accountPrefix: 'zz-look-' });
expect(def.status).toBe(200);
expect(
(def.body.items as { account: string }[]).map((item) => item.account),
).toEqual(Array.from({ length: 10 }, (_, i) => `zz-look-${String(i + 1).padStart(2, '0')}`));
).toEqual(Array.from({ length: 12 }, (_, i) => `zz-look-${String(i + 1).padStart(2, '0')}`));
const five = await lookup(staffToken, {
accountPrefix: 'zz-look-',
@@ -214,12 +237,12 @@ describe('账号查询 GET /admin/users (e2e)', () => {
const top = await lookup(staffToken, {
accountPrefix: 'zz-look-',
limit: 20,
limit: 100,
});
expect(top.status).toBe(200);
expect(top.body.items).toHaveLength(12); // 上限 20 > 存量 12,全量返回
expect(top.body.items).toHaveLength(12); // 上限 100 > 存量 12,全量返回
for (const limit of [0, 21, 'abc']) {
for (const limit of [0, 101, 'abc']) {
const res = await lookup(staffToken, {
accountPrefix: 'zz-look-',
limit,