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
+98 -10
View File
@@ -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),
);
}
}