feat(contracts,api): page-mode pagination + synced profile on admin users list

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 18:06:12 +08:00
parent 91c85e196a
commit 2f6b6afc92
7 changed files with 337 additions and 15 deletions
@@ -384,6 +384,7 @@ describe('账号管理 /admin/users 治理端点 (e2e)', () => {
'locale',
'role',
'sourceStatus',
'synced',
]);
const back = await patchUser(adminToken, patchTargetId, { role: 'staff' });
+58 -2
View File
@@ -257,7 +257,12 @@ describe('用户定期同步 /admin/user-sync (e2e)', () => {
const U3 = (version: number) => ext('ext-3', 'sync-u3', '赵三', version);
it('① 首轮全量:1 account 关联 + 2 新增(V1.1 直接 active);counts/水位/映射/红线(不动凭据角色会话)', async () => {
scenario([U1(1), U2(1), U3(1)]); // pageSize 2 → 两页(1,2 | 3),全 v1
// U2 携带档案快照(G4 profile:随映射行落库,纯展示维度)。
scenario([
U1(1),
{ ...U2(1), sourceProfile: { sn: 'S-002', email: 'u2@x.com', companyAbbr: '摄制' } },
U3(1),
]); // pageSize 2 → 两页(1,2 | 3),全 v1
const { run } = await triggerRun();
expect(run.state).toBe('success');
@@ -303,6 +308,13 @@ describe('用户定期同步 /admin/user-sync (e2e)', () => {
for (const m of mappings) {
expect(m.sourceVersion).toBe(1);
}
// profile 快照落库(G4):U2 的映射行携带源档案,字段原样。
const m2 = await prisma.external_user_mappings.findUnique({
where: {
sourceSystem_externalUserId: { sourceSystem: 'mock', externalUserId: 'ext-2' },
},
});
expect(m2?.profile).toEqual({ sn: 'S-002', email: 'u2@x.com', companyAbbr: '摄制' });
// status 端点:首轮即 success + 水位 + 计数。
const status = await getStatus();
@@ -320,7 +332,11 @@ describe('用户定期同步 /admin/user-sync (e2e)', () => {
});
it('② 改名幂等:版本更高 → displayName 更新;同版本重跑 → 全跳过', async () => {
scenario([U1(1), U2(2, '王二改'), U3(1)]);
scenario([
U1(1),
{ ...U2(2, '王二改'), sourceProfile: { sn: 'S-002', email: 'u2@x.com', companyAbbr: '摄制二部' } },
U3(1),
]);
const first = await triggerRun();
expect(first.run.state).toBe('success');
expect(first.run.counts).toEqual({ added: 0, updated: 1, disabled: 0, failed: 0 });
@@ -331,6 +347,8 @@ describe('用户定期同步 /admin/user-sync (e2e)', () => {
},
});
expect(mapping?.sourceVersion).toBe(2);
// 快照随版本推进刷新(companyAbbr 变化生效)。
expect(mapping?.profile).toEqual({ sn: 'S-002', email: 'u2@x.com', companyAbbr: '摄制二部' });
// 同版本重跑:幂等跳过,counts 全 0,displayName 不动。
const again = await triggerRun();
@@ -725,4 +743,42 @@ describe('用户定期同步 /admin/user-sync (e2e)', () => {
expect(anonPost.status).toBe(401);
expect(anonPost.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
});
it('⑬ profile 展示补偿写:全量批同版本(版本门跳过)+ 快照缺失 → 回填且 counts 全 0', async () => {
// 回填只发生在**行被送到引擎**的批次——增量批过滤 v > 水位(11)会滤掉
// 同版本行;生产 http 源每批全量拉取天然送达。此处仿 ⑩ 把下一批顶成第
// 10 批全量对账批(cursor=null → 全场景行不过滤)。
const stateRow = await prisma.user_sync_state.findUniqueOrThrow({
where: { id: USER_SYNC_STATE_SINGLETON_ID },
});
const fillers = (10 - ((stateRow.runCount + 1) % 10)) % 10;
scenario([]);
for (let i = 0; i < fillers; i++) {
await triggerRun();
}
// 构造 G4 存量形态:ext-2 映射版本已最新(11)但 profile 为 null
// (JSON 列置 NULL 经原生 SQL——Prisma DbNull 需 @prisma/client 运行时
// 导入,api 包非其直接依赖)。
await prisma.$executeRaw`UPDATE external_user_mappings SET profile = NULL
WHERE sourceSystem = 'mock' AND externalUserId = 'ext-2'`;
// 全量批同版本行 + 快照:版本门跳过(不进 updated 桶)→ 仅回填 profile。
scenario([
{ ...U2(11, '王二对账名'), sourceProfile: { sn: 'S-002R', companyAbbr: '摄制' } },
]);
const { run } = await triggerRun();
expect(run.state).toBe('success');
expect(run.counts).toEqual({ added: 0, updated: 0, disabled: 0, failed: 0 });
const backfilled = await prisma.external_user_mappings.findUnique({
where: {
sourceSystem_externalUserId: { sourceSystem: 'mock', externalUserId: 'ext-2' },
},
});
expect(backfilled?.profile).toEqual({ sn: 'S-002R', companyAbbr: '摄制' });
// users 行未被牵连:版本未动、displayName 维持 ⑩ 对账值。
expect(backfilled?.sourceVersion).toBe(11);
expect((await userByAccount('sync-u2'))?.displayName).toBe('王二对账名');
});
});
@@ -211,6 +211,7 @@ describe('账号查询 GET /admin/users (e2e)', () => {
'hasMore',
'items',
'nextCursor',
'total',
]);
// 管理列表包含前缀模式排除的非激活用户(本 spec 的排除组)。
const adminAccounts = (
@@ -285,4 +286,73 @@ describe('账号查询 GET /admin/users (e2e)', () => {
expect(admin.status).toBe(200);
expect(admin.body.items).toHaveLength(2);
});
it('page 模式:page/pageSize 偏移分页 + total;synced 档案快照合并;游标/前缀模式形状不受染', async () => {
// 档案种子:albert 挂映射带 profile 快照(G4);alice 无映射。
await prisma.external_user_mappings.create({
data: {
userId: albertId,
sourceSystem: 'uniservice',
externalUserId: 'ext-albert',
sourceVersion: 1,
profile: { sn: 'A001', email: 'albert@x.com', companyAbbr: '合塑' },
},
});
// 'al' 前缀域恒 5 账号:alice/albert/aldis/alpend/allock(顺序按 createdAt
// +ID 倒序,不依赖种子时序断言具体页位)。
const page1 = await lookup(adminToken, { query: 'al', page: 1, pageSize: 2 });
expect(page1.status).toBe(200);
expect(page1.body.total).toBe(5);
expect(page1.body.hasMore).toBe(false);
expect(page1.body.nextCursor).toBeNull();
expect(page1.body.items).toHaveLength(2);
const page2 = await lookup(adminToken, { query: 'al', page: 2, pageSize: 2 });
const page3 = await lookup(adminToken, { query: 'al', page: 3, pageSize: 2 });
expect(page2.body.items).toHaveLength(2);
expect(page3.body.items).toHaveLength(1);
// 走全量:三页并集 = 5 账号、无重复。
const all = [
...(page1.body.items as { account: string }[]),
...(page2.body.items as { account: string }[]),
...(page3.body.items as { account: string }[]),
];
expect(new Set(all.map((item) => item.account)).size).toBe(5);
// synced 合并:albert(有映射)带快照且字段原样;其余行 synced=null。
const albert = all
.concat() as unknown as { account: string; synced: unknown }[];
const albertRow = albert.find((item) => item.account === 'albert');
expect(albertRow?.synced).toEqual({
sn: 'A001',
email: 'albert@x.com',
companyAbbr: '合塑',
});
const aliceRow = albert.find((item) => item.account === 'alice');
expect(aliceRow?.synced).toBeNull();
// 越界页:空 items、total 仍返回。
const beyond = await lookup(adminToken, { query: 'al', page: 4, pageSize: 2 });
expect(beyond.status).toBe(200);
expect(beyond.body.items).toEqual([]);
expect(beyond.body.total).toBe(5);
// 游标模式(不带 page):total=null(键存在但空——兼容旧客户端语义)。
const cursorMode = await lookup(adminToken, { query: 'al', limit: 2 });
expect(cursorMode.status).toBe(200);
expect(cursorMode.body.total).toBeNull();
// 前缀模式条目干净:不合并 synced(邀请候选无需档案)。
const prefix = await lookup(staffToken, { accountPrefix: 'al' });
expect(prefix.status).toBe(200);
for (const item of prefix.body.items as object[]) {
expect('synced' in item).toBe(false);
}
// 参数校验:page=0 / pageSize=101 / 非整数 → 400。
expect((await lookup(adminToken, { page: 0 })).status).toBe(400);
expect((await lookup(adminToken, { page: 1, pageSize: 101 })).status).toBe(400);
expect((await lookup(adminToken, { page: 'x' })).status).toBe(400);
});
});