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:
@@ -1242,7 +1242,25 @@ paths:
|
||||
- $ref: '#/components/parameters/CursorQuery'
|
||||
- name: limit
|
||||
in: query
|
||||
description: 每页条数。
|
||||
description: 每页条数(游标模式)。
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 20
|
||||
- name: page
|
||||
in: query
|
||||
description: |
|
||||
页码(偏移分页模式):提供 page 且不带 cursor 时启用,响应返回
|
||||
total 且 nextCursor=null / hasMore=false(翻页由 total 驱动)。
|
||||
未提供 → 游标模式(keyset,兼容旧客户端);提供 cursor 时忽略
|
||||
page。仅管理列表模式有意义。
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
- name: pageSize
|
||||
in: query
|
||||
description: 偏移分页每页条数(仅 page 模式生效,缺省 20)。
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
@@ -3665,6 +3683,29 @@ components:
|
||||
format: date-time
|
||||
readOnly: true
|
||||
description: 创建时间。
|
||||
synced:
|
||||
type: [object, 'null']
|
||||
readOnly: true
|
||||
description: |
|
||||
同步源档案快照(G4:工号/邮箱/公司/头像,来自用户同步、落
|
||||
external_user_mappings.profile 列);本地创建/未同步用户为 null。
|
||||
纯展示维度,不参与任何治理判定。
|
||||
properties:
|
||||
sn:
|
||||
type: string
|
||||
description: 工号。
|
||||
email:
|
||||
type: string
|
||||
description: 企业邮箱。
|
||||
companyName:
|
||||
type: string
|
||||
description: 公司全称。
|
||||
companyAbbr:
|
||||
type: string
|
||||
description: 公司简称。
|
||||
avatar:
|
||||
type: string
|
||||
description: 头像 URL。
|
||||
|
||||
AdminUserList:
|
||||
allOf:
|
||||
@@ -3677,6 +3718,9 @@ components:
|
||||
description: 用户条目(前缀模式按 account 升序且仅激活资格用户;管理列表模式按 createdAt 与 ID 稳定倒序)。
|
||||
items:
|
||||
$ref: '#/components/schemas/AdminUser'
|
||||
total:
|
||||
type: [integer, 'null']
|
||||
description: 过滤命中总数——仅管理列表 page 模式返回;游标/前缀模式为 null。
|
||||
|
||||
AdminUserStatusFilter:
|
||||
type: string
|
||||
|
||||
+29
-1
@@ -1833,10 +1833,29 @@ export interface components {
|
||||
* @description 创建时间。
|
||||
*/
|
||||
readonly createdAt: string;
|
||||
/**
|
||||
* @description 同步源档案快照(G4:工号/邮箱/公司/头像,来自用户同步、落
|
||||
* external_user_mappings.profile 列);本地创建/未同步用户为 null。
|
||||
* 纯展示维度,不参与任何治理判定。
|
||||
*/
|
||||
readonly synced?: {
|
||||
/** @description 工号。 */
|
||||
sn?: string;
|
||||
/** @description 企业邮箱。 */
|
||||
email?: string;
|
||||
/** @description 公司全称。 */
|
||||
companyName?: string;
|
||||
/** @description 公司简称。 */
|
||||
companyAbbr?: string;
|
||||
/** @description 头像 URL。 */
|
||||
avatar?: string;
|
||||
} | null;
|
||||
};
|
||||
AdminUserList: components["schemas"]["CursorPageMeta"] & {
|
||||
/** @description 用户条目(前缀模式按 account 升序且仅激活资格用户;管理列表模式按 createdAt 与 ID 稳定倒序)。 */
|
||||
items: components["schemas"]["AdminUser"][];
|
||||
/** @description 过滤命中总数——仅管理列表 page 模式返回;游标/前缀模式为 null。 */
|
||||
total?: number | null;
|
||||
};
|
||||
/**
|
||||
* @description 管理列表治理状态过滤(单值归类):pending=待激活;disabled=上游停用;
|
||||
@@ -3968,8 +3987,17 @@ export interface operations {
|
||||
status?: components["schemas"]["AdminUserStatusFilter"];
|
||||
/** @description 分页游标(上一页响应的 nextCursor)。 */
|
||||
cursor?: components["parameters"]["CursorQuery"];
|
||||
/** @description 每页条数。 */
|
||||
/** @description 每页条数(游标模式)。 */
|
||||
limit?: number;
|
||||
/**
|
||||
* @description 页码(偏移分页模式):提供 page 且不带 cursor 时启用,响应返回
|
||||
* total 且 nextCursor=null / hasMore=false(翻页由 total 驱动)。
|
||||
* 未提供 → 游标模式(keyset,兼容旧客户端);提供 cursor 时忽略
|
||||
* page。仅管理列表模式有意义。
|
||||
*/
|
||||
page?: number;
|
||||
/** @description 偏移分页每页条数(仅 page 模式生效,缺省 20)。 */
|
||||
pageSize?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -39,10 +39,40 @@ import type { AdminPasswordResetDto, AdminUserUpdateDto } from './admin-users.dt
|
||||
import {
|
||||
escapeLikePrefix,
|
||||
toAdminUser,
|
||||
type AdminUserItem,
|
||||
type AdminUserListResponse,
|
||||
type AdminUserRow,
|
||||
} from './users-lookup.service';
|
||||
|
||||
/** page 模式缺省每页条数(契约 listAdminUsers.pageSize default)。 */
|
||||
const DEFAULT_ADMIN_PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* 批量合并同步档案快照(AdminUser.synced):按 userId 批查映射表,每用户取
|
||||
* sourceVersion 最大行(多源并存时的确定性选择——最高版本即权威快照);
|
||||
* 无映射/无 profile → synced=null。管理列表两模式与治理回显共用;前缀
|
||||
* lookup 模式不套(邀请候选无需档案,users-lookup 文件头)。
|
||||
*/
|
||||
export async function attachSyncedProfiles(
|
||||
prisma: PrismaService,
|
||||
items: AdminUserItem[],
|
||||
): Promise<AdminUserItem[]> {
|
||||
if (items.length === 0) {
|
||||
return items;
|
||||
}
|
||||
const mappings = await prisma.external_user_mappings.findMany({
|
||||
where: { userId: { in: items.map((item) => item.id) } },
|
||||
orderBy: { sourceVersion: 'desc' },
|
||||
});
|
||||
const byUser = new Map<string, NonNullable<AdminUserItem['synced']>>();
|
||||
for (const mapping of mappings) {
|
||||
if (!byUser.has(mapping.userId) && mapping.profile !== null) {
|
||||
byUser.set(mapping.userId, mapping.profile as NonNullable<AdminUserItem['synced']>);
|
||||
}
|
||||
}
|
||||
return items.map((item) => ({ ...item, synced: byUser.get(item.id) ?? null }));
|
||||
}
|
||||
|
||||
export type AdminUserResponse = components['schemas']['AdminUser'];
|
||||
export type AdminPasswordResetResponse =
|
||||
components['schemas']['AdminPasswordResetResponse'];
|
||||
@@ -103,13 +133,17 @@ type UserChanges = Record<
|
||||
export class AdminUsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** 管理列表模式:全量用户 + query/status 过滤 + createdAt+ID 倒序游标分页。 */
|
||||
/** 管理列表模式:全量用户 + query/status 过滤 + createdAt+ID 倒序——游标
|
||||
* keyset(缺省,兼容旧客户端)或 page/pageSize 偏移分页(返回 total);
|
||||
* 两模式均合并 synced 档案快照。 */
|
||||
async list(
|
||||
user: AuthUser,
|
||||
query: string | undefined,
|
||||
status: AdminUserStatusFilter | undefined,
|
||||
cursor: string | undefined,
|
||||
limit: number,
|
||||
page?: number,
|
||||
pageSize?: number,
|
||||
): Promise<AdminUserListResponse> {
|
||||
requireAdmin(user);
|
||||
|
||||
@@ -130,6 +164,50 @@ export class AdminUsersService {
|
||||
const cursorSort = decoded === null ? null : new Date(decoded.sort);
|
||||
const cursorId = decoded === null ? '' : decoded.id;
|
||||
|
||||
// page 模式(提供 page 且无 cursor):同一过滤 + COUNT 总数 + OFFSET 切片。
|
||||
// 守 CursorPageMeta 不变式 hasMore⟺nextCursor≠null → 恒 null/false,
|
||||
// 翻页由 total 驱动(契约 listAdminUsers page 参数描述)。
|
||||
if (page !== undefined && decoded === null) {
|
||||
const size = pageSize ?? DEFAULT_ADMIN_PAGE_SIZE;
|
||||
const offset = (page - 1) * size;
|
||||
const [{ total }] = await this.prisma.$queryRaw<{ total: bigint }[]>`
|
||||
SELECT COUNT(*) AS total
|
||||
FROM users
|
||||
WHERE (${hasQuery ? 1 : 0} = 0
|
||||
OR account LIKE ${pattern} ESCAPE '!'
|
||||
OR displayName LIKE ${pattern} ESCAPE '!')
|
||||
AND (${statusFilter} IS NULL
|
||||
OR (CASE
|
||||
WHEN credentialStatus = 'pending' THEN 'pending'
|
||||
WHEN sourceStatus = 'disabled' THEN 'disabled'
|
||||
WHEN localLocked = TRUE THEN 'locked'
|
||||
ELSE 'active'
|
||||
END) = ${statusFilter})
|
||||
`;
|
||||
const pageRows = await this.prisma.$queryRaw<AdminUserRow[]>`
|
||||
SELECT id, account, displayName, role, sourceStatus, localLocked,
|
||||
credentialStatus, locale, createdAt
|
||||
FROM users
|
||||
WHERE (${hasQuery ? 1 : 0} = 0
|
||||
OR account LIKE ${pattern} ESCAPE '!'
|
||||
OR displayName LIKE ${pattern} ESCAPE '!')
|
||||
AND (${statusFilter} IS NULL
|
||||
OR (CASE
|
||||
WHEN credentialStatus = 'pending' THEN 'pending'
|
||||
WHEN sourceStatus = 'disabled' THEN 'disabled'
|
||||
WHEN localLocked = TRUE THEN 'locked'
|
||||
ELSE 'active'
|
||||
END) = ${statusFilter})
|
||||
ORDER BY createdAt DESC, id DESC
|
||||
LIMIT ${size} OFFSET ${offset}
|
||||
`;
|
||||
const items = await attachSyncedProfiles(
|
||||
this.prisma,
|
||||
pageRows.map((row) => toAdminUser(row)),
|
||||
);
|
||||
return { items, nextCursor: null, hasMore: false, total: Number(total) };
|
||||
}
|
||||
|
||||
const rows = await this.prisma.$queryRaw<AdminUserRow[]>`
|
||||
SELECT id, account, displayName, role, sourceStatus, localLocked,
|
||||
credentialStatus, locale, createdAt
|
||||
@@ -151,8 +229,8 @@ export class AdminUsersService {
|
||||
LIMIT ${limit + 1}
|
||||
`;
|
||||
const hasMore = rows.length > limit;
|
||||
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||
const last = page.at(-1);
|
||||
const pageItems = hasMore ? rows.slice(0, limit) : rows;
|
||||
const last = pageItems.at(-1);
|
||||
// CursorPageMeta 不变式:hasMore ⟺ nextCursor≠null(同 albums.list 推导)。
|
||||
const nextCursor =
|
||||
hasMore && last !== undefined
|
||||
@@ -162,12 +240,22 @@ export class AdminUsersService {
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
items: page.map((row) => toAdminUser(row)),
|
||||
items: await attachSyncedProfiles(
|
||||
this.prisma,
|
||||
pageItems.map((row) => toAdminUser(row)),
|
||||
),
|
||||
nextCursor,
|
||||
hasMore: nextCursor !== null,
|
||||
total: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 单条治理回显 + synced 档案快照(响应统一形状)。 */
|
||||
private async echo(row: AdminUserRow): Promise<AdminUserResponse> {
|
||||
const [item] = await attachSyncedProfiles(this.prisma, [toAdminUser(row)]);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 用户治理属性部分更新(updateAdminUser)。 */
|
||||
async update(
|
||||
user: AuthUser,
|
||||
@@ -226,7 +314,7 @@ export class AdminUsersService {
|
||||
data.displayName = dto.displayName;
|
||||
}
|
||||
if (Object.keys(data).length === 0) {
|
||||
return toAdminUser(target);
|
||||
return this.echo(target);
|
||||
}
|
||||
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
@@ -248,7 +336,7 @@ export class AdminUsersService {
|
||||
});
|
||||
return row;
|
||||
});
|
||||
return toAdminUser(updated);
|
||||
return this.echo(updated);
|
||||
}
|
||||
|
||||
/** 管理员重置用户密码(resetAdminUserPassword):指定或生成 + 撤全部会话。 */
|
||||
@@ -305,7 +393,7 @@ export class AdminUsersService {
|
||||
}
|
||||
if (target.credentialStatus === 'active') {
|
||||
// 幂等:已是 active 返回当前条目,不重复落审计。
|
||||
return toAdminUser(target);
|
||||
return this.echo(target);
|
||||
}
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const row = await tx.users.update({
|
||||
@@ -321,7 +409,7 @@ export class AdminUsersService {
|
||||
});
|
||||
return row;
|
||||
});
|
||||
return toAdminUser(updated);
|
||||
return this.echo(updated);
|
||||
}
|
||||
|
||||
/** 停用用户(deactivateAdminUser):localLocked=true + 撤全部会话,幂等。 */
|
||||
@@ -335,7 +423,7 @@ export class AdminUsersService {
|
||||
}
|
||||
if (target.localLocked) {
|
||||
// 幂等:已锁定直接返回(锁定用户无法登录,不存在待撤的新会话)。
|
||||
return toAdminUser(target);
|
||||
return this.echo(target);
|
||||
}
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const row = await tx.users.update({
|
||||
@@ -355,6 +443,6 @@ export class AdminUsersService {
|
||||
});
|
||||
return row;
|
||||
});
|
||||
return toAdminUser(updated);
|
||||
return this.echo(updated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,36 @@ export function parseListLimit(raw: string | undefined): number {
|
||||
return limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* page 查询参数(page 模式):**未提供 → undefined**(保持游标模式兼容——
|
||||
* 缺省值 1 只在服务层 page 模式选定后应用);提供则须为 ≥1 的整数。
|
||||
*/
|
||||
export function parsePage(raw: string | undefined): number | undefined {
|
||||
if (raw === undefined || raw === '') {
|
||||
return undefined;
|
||||
}
|
||||
if (!/^\d+$/.test(raw) || Number.parseInt(raw, 10) < 1) {
|
||||
throw validationFailed('page 必须是 ≥1 的整数');
|
||||
}
|
||||
return Number.parseInt(raw, 10);
|
||||
}
|
||||
|
||||
/** pageSize 查询参数(page 模式):未提供 → undefined(服务层缺省 20);
|
||||
* 提供则须为 1..100 的整数(对齐 MAX_LIST_LIMIT)。 */
|
||||
export function parsePageSize(raw: string | undefined): number | undefined {
|
||||
if (raw === undefined || raw === '') {
|
||||
return undefined;
|
||||
}
|
||||
if (!/^\d+$/.test(raw)) {
|
||||
throw validationFailed('pageSize 必须是 1-100 的整数');
|
||||
}
|
||||
const size = Number.parseInt(raw, 10);
|
||||
if (size < 1 || size > MAX_LIST_LIMIT) {
|
||||
throw validationFailed('pageSize 必须是 1-100 的整数');
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
@Controller('admin/users')
|
||||
@UseGuards(SessionGuard)
|
||||
export class UsersLookupController {
|
||||
@@ -111,10 +141,13 @@ export class UsersLookupController {
|
||||
@Query('status') status: string | undefined,
|
||||
@Query('cursor') cursor: string | undefined,
|
||||
@Query('limit') limit: string | undefined,
|
||||
@Query('page') page: string | undefined,
|
||||
@Query('pageSize') pageSize: string | undefined,
|
||||
): Promise<AdminUserListResponse> {
|
||||
const parsedLimit = parseListLimit(limit);
|
||||
if (accountPrefix !== undefined) {
|
||||
// 前缀模式(P1b 语义保留):任何已认证工作人员。
|
||||
// 前缀模式(P1b 语义保留):任何已认证工作人员(page 参数无意义,
|
||||
// 忽略——响应恒 nextCursor=null/hasMore=false/无 synced)。
|
||||
return this.usersLookupService.lookup(
|
||||
parseAccountPrefix(accountPrefix),
|
||||
parsedLimit,
|
||||
@@ -127,6 +160,8 @@ export class UsersLookupController {
|
||||
parseStatusFilter(status),
|
||||
parseCursor(cursor),
|
||||
parsedLimit,
|
||||
parsePage(page),
|
||||
parsePageSize(pageSize),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,6 +384,7 @@ describe('账号管理 /admin/users 治理端点 (e2e)', () => {
|
||||
'locale',
|
||||
'role',
|
||||
'sourceStatus',
|
||||
'synced',
|
||||
]);
|
||||
|
||||
const back = await patchUser(adminToken, patchTargetId, { role: 'staff' });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user