feat(api,db-cn): persist synced source profile snapshot on mappings

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 17:33:27 +08:00
parent ccf0c29659
commit f6ff1d44f2
6 changed files with 99 additions and 4 deletions
@@ -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[][] = [];
@@ -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,
@@ -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 {
@@ -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<string, unknown>;
const fields: Array<keyof ExternalUserProfile> = [
'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 };