diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml index 9579980..0d40a60 100644 --- a/packages/contracts/openapi.yaml +++ b/packages/contracts/openapi.yaml @@ -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 diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts index 45c16c7..f5270dd 100644 --- a/packages/contracts/src/generated/openapi.d.ts +++ b/packages/contracts/src/generated/openapi.d.ts @@ -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; diff --git a/services/api/src/admin/admin-users.service.ts b/services/api/src/admin/admin-users.service.ts index bbef705..dca48b9 100644 --- a/services/api/src/admin/admin-users.service.ts +++ b/services/api/src/admin/admin-users.service.ts @@ -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 { + 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>(); + for (const mapping of mappings) { + if (!byUser.has(mapping.userId) && mapping.profile !== null) { + byUser.set(mapping.userId, mapping.profile as NonNullable); + } + } + 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 { 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` + 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` 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 { + 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); } } diff --git a/services/api/src/admin/users-lookup.controller.ts b/services/api/src/admin/users-lookup.controller.ts index 5b46d30..0897671 100644 --- a/services/api/src/admin/users-lookup.controller.ts +++ b/services/api/src/admin/users-lookup.controller.ts @@ -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 { 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), ); } } diff --git a/services/api/test/admin-users.e2e-spec.ts b/services/api/test/admin-users.e2e-spec.ts index 534219b..393ad5b 100644 --- a/services/api/test/admin-users.e2e-spec.ts +++ b/services/api/test/admin-users.e2e-spec.ts @@ -384,6 +384,7 @@ describe('账号管理 /admin/users 治理端点 (e2e)', () => { 'locale', 'role', 'sourceStatus', + 'synced', ]); const back = await patchUser(adminToken, patchTargetId, { role: 'staff' }); diff --git a/services/api/test/user-sync.e2e-spec.ts b/services/api/test/user-sync.e2e-spec.ts index 4c348dc..6e437af 100644 --- a/services/api/test/user-sync.e2e-spec.ts +++ b/services/api/test/user-sync.e2e-spec.ts @@ -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('王二对账名'); + }); }); diff --git a/services/api/test/users-lookup.e2e-spec.ts b/services/api/test/users-lookup.e2e-spec.ts index a861939..ee77127 100644 --- a/services/api/test/users-lookup.e2e-spec.ts +++ b/services/api/test/users-lookup.e2e-spec.ts @@ -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); + }); });