From f6ff1d44f27887781689700351446799e50470c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 22 Sep 2026 17:33:27 +0800 Subject: [PATCH] feat(api,db-cn): persist synced source profile snapshot on mappings Co-Authored-By: Claude Code --- .../migration.sql | 7 +++ packages/db-cn/prisma/schema.prisma | 1 + .../api/src/user-sync/http-user-source.ts | 7 +++ .../api/src/user-sync/mock-user-source.ts | 9 ++- .../api/src/user-sync/user-source.port.ts | 18 ++++++ .../api/src/user-sync/user-sync.service.ts | 61 ++++++++++++++++++- 6 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 packages/db-cn/prisma/migrations/20260922174000_mapping_profile/migration.sql diff --git a/packages/db-cn/prisma/migrations/20260922174000_mapping_profile/migration.sql b/packages/db-cn/prisma/migrations/20260922174000_mapping_profile/migration.sql new file mode 100644 index 0000000..cb5ecb6 --- /dev/null +++ b/packages/db-cn/prisma/migrations/20260922174000_mapping_profile/migration.sql @@ -0,0 +1,7 @@ +-- G4 后续:映射表增 profile JSON 列——同步源档案快照(工号/邮箱/公司/头像), +-- 纯展示维度:不参与同步决策(引擎幂等仍只看 sourceVersion),users 表零改动 +-- (源身份隔离红线,见 user-source.port.ts / user-sync.service.ts 文件头)。 +-- nullable:mock 源/历史行无快照;存量行由引擎「幂等跳过路径的展示补偿写」 +-- 在下批同步自然回填(版本门跳过但 profile 缺失/变化时仅刷映射行)。 +-- JSON 列无 collation,不涉 utf8mb4_0900_bin 手决项。 +ALTER TABLE `external_user_mappings` ADD COLUMN `profile` JSON NULL; diff --git a/packages/db-cn/prisma/schema.prisma b/packages/db-cn/prisma/schema.prisma index d59691d..fd386f4 100644 --- a/packages/db-cn/prisma/schema.prisma +++ b/packages/db-cn/prisma/schema.prisma @@ -319,6 +319,7 @@ model external_user_mappings { sourceSystem String @default("mock") @db.VarChar(32) // G4 后换真实源标识 externalUserId String @db.VarChar(64) sourceVersion Int @default(0) // 幂等比较位:仅更高版本应用 + profile Json? // 同步源档案快照(工号/邮箱/公司/头像)——纯展示,不参与同步决策(users 表零改动红线) createdAt DateTime @default(now()) @db.DateTime(6) @@unique([sourceSystem, externalUserId]) diff --git a/services/api/src/user-sync/http-user-source.ts b/services/api/src/user-sync/http-user-source.ts index 4ba2f56..0581819 100644 --- a/services/api/src/user-sync/http-user-source.ts +++ b/services/api/src/user-sync/http-user-source.ts @@ -164,6 +164,13 @@ export class HttpUserSource implements UserSourcePort { sourceStatus: raw.status === 1 ? 'active' : 'disabled', sourceVersion: Math.floor(Date.parse(raw.updated_at) / 1000), updatedAt: new Date(raw.updated_at), + sourceProfile: { + sn: raw.sn || undefined, + email: raw.email || undefined, + companyName: raw.company_name || undefined, + companyAbbr: raw.company_abbr || undefined, + avatar: raw.avatar || undefined, + }, })); const chunks: ExternalUserRecord[][] = []; diff --git a/services/api/src/user-sync/mock-user-source.ts b/services/api/src/user-sync/mock-user-source.ts index da6bbd5..4a22c56 100644 --- a/services/api/src/user-sync/mock-user-source.ts +++ b/services/api/src/user-sync/mock-user-source.ts @@ -19,7 +19,12 @@ // gateFirstPage(第 0 页等待该 Promise,e2e 用它制造「运行中」批次以测互斥)。 // receivedCursors 记录每次 listPage 收到的游标(e2e 断言增量/全量形态)。 import { Injectable } from '@nestjs/common'; -import type { ExternalUserRecord, UserSourcePage, UserSourcePort } from './user-source.port'; +import type { + ExternalUserRecord, + ExternalUserProfile, + UserSourcePage, + UserSourcePort, +} from './user-source.port'; /** 场景行:全字段可选——坏行(缺 displayName/非法 sourceStatus 等)原样透传,校验在引擎。 */ export interface MockExternalRecord { @@ -30,6 +35,7 @@ export interface MockExternalRecord { sourceStatus?: string; sourceVersion?: number; updatedAt?: Date; + sourceProfile?: ExternalUserProfile; } export interface MockScenario { @@ -174,6 +180,7 @@ export class MockUserSource implements UserSourcePort { sourceStatus: user.sourceStatus as ExternalUserRecord['sourceStatus'], sourceVersion: user.sourceVersion ?? Number.NaN, updatedAt: user.updatedAt ?? new Date(), + sourceProfile: user.sourceProfile, })); return { nextCursor: hasMore ? encodePageCursor(watermark, parsed.page + 1) : null, diff --git a/services/api/src/user-sync/user-source.port.ts b/services/api/src/user-sync/user-source.port.ts index 0e19229..a159d05 100644 --- a/services/api/src/user-sync/user-source.port.ts +++ b/services/api/src/user-sync/user-source.port.ts @@ -12,6 +12,21 @@ // department 字段:users 表无此列,端口保留以对齐真实源自形状,引擎忽略 // (登记偏差,见验收记录)。 +/** 同步源档案快照(纯展示维度:落 mapping.profile 供管理端展示,绝不参与 + * 同步决策——版本门只看 sourceVersion;http 源填充,mock 可不填)。 */ +export interface ExternalUserProfile { + /** 工号。 */ + sn?: string; + /** 企业邮箱。 */ + email?: string; + /** 公司全称。 */ + companyName?: string; + /** 公司简称。 */ + companyAbbr?: string; + /** 头像 URL。 */ + avatar?: string; +} + /** 外部用户行(源侧形状;字段可能不合规——校验是引擎的职责,见 validateItem)。 */ export interface ExternalUserRecord { externalUserId: string; @@ -24,6 +39,9 @@ export interface ExternalUserRecord { /** 幂等比较位:仅高于映射行 sourceVersion 的行会被应用。 */ sourceVersion: number; updatedAt: Date; + /** 档案快照:引擎原样落 external_user_mappings.profile(含幂等跳过路径的 + * 展示补偿写),不据此做任何同步决策。 */ + sourceProfile?: ExternalUserProfile; } export interface UserSourcePage { diff --git a/services/api/src/user-sync/user-sync.service.ts b/services/api/src/user-sync/user-sync.service.ts index edc9cf3..8677766 100644 --- a/services/api/src/user-sync/user-sync.service.ts +++ b/services/api/src/user-sync/user-sync.service.ts @@ -29,6 +29,10 @@ // 哨兵哈希」闭环退役(auth.service 对非法哈希的等代价 dummy verify 防御保留, // 兼容历史行)。account 关联路径仍绝不动既有凭据(红线 1 不变)。 // +// profile(G4 后续):源档案快照(工号/邮箱/公司/头像)随映射行写入,幂等 +// 跳过路径也做展示补偿写(版本门跳过但快照缺失/变化 → 仅刷 mapping.profile) +// ——纯展示维度,绝不参与同步决策(版本门只看 sourceVersion)。 +// // department 字段:users 表无此列,忽略(端口保留对齐真实源形状,登记偏差)。 import { randomBytes } from 'node:crypto'; import { @@ -39,7 +43,7 @@ import { type OnApplicationBootstrap, type OnModuleDestroy, } from '@nestjs/common'; -import { USER_SYNC_STATE_SINGLETON_ID } from '@photo/db-cn'; +import { USER_SYNC_STATE_SINGLETON_ID, type Prisma } from '@photo/db-cn'; import { ErrorCodes } from '@photo/contracts'; import { ApiException } from '../api-exception'; import type { AuthUser } from '../auth/session.guard'; @@ -48,6 +52,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { SyncLock } from './sync-lock'; import { USER_SOURCE, + type ExternalUserProfile, type ExternalUserRecord, type UserSourcePort, } from './user-source.port'; @@ -87,6 +92,35 @@ function initialPassword(loginAccount: string | undefined): string { return loginAccount?.slice(-6) || '000000'; } +/** 档案快照逐字段比较(MySQL JSON 列键序不确定,不能 stringify 整比; + * undefined/null 归一后比——快照字段恒 string|undefined)。 */ +function profileDiffers( + next: ExternalUserProfile, + current: unknown, +): boolean { + const cur = (current ?? {}) as Record; + const fields: Array = [ + 'sn', + 'email', + 'companyName', + 'companyAbbr', + 'avatar', + ]; + return fields.some( + (field) => (next[field] ?? null) !== ((cur[field] as string | undefined) ?? null), + ); +} + +/** Prisma JSON 输入桥:接口无字符串索引签名,InputJsonObject 需显式断言 + * (浅拷贝后断言,undefined=不改列)。 */ +function profileJson( + profile: ExternalUserProfile | undefined, +): Prisma.InputJsonObject | undefined { + return profile !== undefined + ? ({ ...profile } as Prisma.InputJsonObject) + : undefined; +} + /** 引擎侧批次 ID:预生成以兼作锁值(24 hex,撞库概率可忽略)。 */ function generateRunId(): string { return randomBytes(12).toString('hex'); @@ -387,11 +421,27 @@ export class UserSyncEngine { }); await this.prisma.external_user_mappings.update({ where: { id: mapping.id }, - data: { userId: created.id, sourceVersion: validated.version }, + data: { + userId: created.id, + sourceVersion: validated.version, + profile: profileJson(item.sourceProfile), + }, }); return { bucket: 'added', version: validated.version }; } if (validated.version <= mapping.sourceVersion) { + // 展示维度补偿写:存量映射已处于当前版本(版本门跳过),但档案快照 + // 缺失/变化时回填——仅刷映射行 profile,绝不动 sourceVersion/users/ + // 会话/counts(bucket 仍 null,同步决策只看 sourceVersion 的红线不破)。 + if ( + item.sourceProfile !== undefined && + profileDiffers(item.sourceProfile, mapping.profile) + ) { + await this.prisma.external_user_mappings.update({ + where: { id: mapping.id }, + data: { profile: profileJson(item.sourceProfile) }, + }); + } return { bucket: null, version: validated.version }; // 幂等跳过 } const becomingDisabled = @@ -407,7 +457,10 @@ export class UserSyncEngine { }), this.prisma.external_user_mappings.update({ where: { id: mapping.id }, - data: { sourceVersion: validated.version }, + data: { + sourceVersion: validated.version, + profile: profileJson(item.sourceProfile), + }, }), ...(becomingDisabled ? [ @@ -445,6 +498,7 @@ export class UserSyncEngine { sourceSystem, externalUserId: item.externalUserId, sourceVersion: validated.version, + profile: profileJson(item.sourceProfile), }, }), ...(becomingDisabled @@ -480,6 +534,7 @@ export class UserSyncEngine { sourceSystem, externalUserId: item.externalUserId, sourceVersion: validated.version, + profile: profileJson(item.sourceProfile), }, }); return { bucket: 'added', version: validated.version };