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