feat(api,db-cn): persist synced source profile snapshot on mappings
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user