@@ -1,5 +1,5 @@
// services/api/src/user-sync/user-sync.service.ts —— 用户同步引擎(P4a Task 7,
// A29; T30 语义全量,mock 源先行——G4 真实接口到位后仅替换 USER_SOURCE provider )。
// A29; G4 后双源: USER_SYNC_ SOURCE 装配 mock 或 http——引擎/契约不变 )。
//
// 红线(技术方案 3.1A,e2e 全量守护):
// 1. 同步绝不写 password_hash / role / album_members / localLocked( display
@@ -23,10 +23,11 @@
// → 终态:success(无坏行)/ partial(有坏行)/ failed( listPage 抛错等
// 批次级故障:error 摘要落账,会话绝不被批次故障牵连撤销)。
//
// 待激活闭环(§10):新建用户 credentialStatus=pending + 哨兵哈希
// `!:pending:<random>`(非法 argon2 编码,任何口令都无法通过校验——auth.service
// 对 verify 抛错按密码错误处理并跑等代价 dummy verify)。初始凭据由管理员
// activate + reset-password 出具(Task 6 端点)。
// 初始凭据(V1.1, 2026-09-22 G4 真实接口上线):同步新建用户直接
// credentialStatus=active、初始口令=登录账号(手机号)后 6 位(argon2 哈希,
// 不足 6 位回退 '000000');管理员可经 reset-password 轮换。P4a 的「待激活 +
// 哨兵哈希」闭环退役(auth.service 对非法哈希的等代价 dummy verify 防御保留,
// 兼容历史行)。account 关联路径仍绝不动既有凭据(红线 1 不变)。
//
// department 字段:users 表无此列,忽略(端口保留对齐真实源形状,登记偏差)。
import { randomBytes } from 'node:crypto' ;
@@ -42,6 +43,7 @@ import { USER_SYNC_STATE_SINGLETON_ID } from '@photo/db-cn';
import { ErrorCodes } from '@photo/contracts' ;
import { ApiException } from '../api-exception' ;
import type { AuthUser } from '../auth/session.guard' ;
import { hashPassword } from '../auth/password' ;
import { PrismaService } from '../prisma/prisma.service' ;
import { SyncLock } from './sync-lock' ;
import {
@@ -50,12 +52,6 @@ import {
type UserSourcePort ,
} from './user-source.port' ;
/** 同步源系统标识(external_user_mappings.sourceSystem; G4 后换真实源标识)。 */
export const USER_SYNC_SOURCE_SYSTEM = 'mock' ;
/** 哨兵哈希前缀:同步创建的待激活账号(非法 argon2 编码,不可被猜中登录)。 */
export const PENDING_PASSWORD_HASH_PREFIX = '!:pending:' ;
/** 定时同步间隔缺省 5 分钟(env SYNC_USERS_INTERVAL_MS 可调)。 */
const DEFAULT_SYNC_INTERVAL_MS = 300 _000 ;
@@ -86,8 +82,9 @@ interface ItemOutcome {
detail? : string ;
}
function pendingPasswordHash ( ) : string {
return ` ${ PENDING_PASSWORD_HASH_PREFIX } ${ randomBytes ( 16 ) . toString ( 'hex' ) } ` ;
/** V1.1 初始口令:登录账号(手机号)后 6 位;缺失/空回退 '000000'。 */
function initialPassword ( loginAccount : string | undefined ) : string {
return loginAccount ? . slice ( - 6 ) || '000000' ;
}
/** 引擎侧批次 ID:预生成以兼作锁值(24 hex,撞库概率可忽略)。 */
@@ -95,7 +92,8 @@ function generateRunId(): string {
return randomBytes ( 12 ) . toString ( 'hex' ) ;
}
/** 水位串 → 契约 int64( mock 恒数值串;非数值游标——G4 真实源——投影为 null)。 */
/** 水位串 → 契约 int64( 源水位恒数值串——mock 自增版本 / http epoch 秒;
* 非数值游标投影为 null)。 */
export function watermarkToInt ( cursor : string | null ) : number | null {
if ( cursor === null || cursor === '' ) {
return null ;
@@ -360,9 +358,10 @@ export class UserSyncEngine {
return { bucket : 'failed' , version : null , code : 'invalid_record' , detail : validated.detail } ;
}
try {
const sourceSystem = this . source . sourceSystem ;
const mappingWhere = {
sourceSystem_externalUserId : {
sourceSystem : USER_SYNC_SOURCE_SYSTEM ,
sourceSystem ,
externalUserId : item.externalUserId ,
} ,
} ;
@@ -379,11 +378,11 @@ export class UserSyncEngine {
const created = await this . prisma . users . create ( {
data : {
account : item.loginAccount ? ? item . externalUserId ,
passwordHash : pendingPasswordHash ( ) ,
passwordHash : await hashPassword ( initialPassword ( item . loginAccount ) ) ,
displayName : item.displayName ,
role : 'staff' ,
sourceStatus : item.sourceStatus ,
credentialStatus : 'pending ' ,
credentialStatus : 'active ' ,
} ,
} ) ;
await this . prisma . external_user_mappings . update ( {
@@ -443,7 +442,7 @@ export class UserSyncEngine {
this . prisma . external_user_mappings . create ( {
data : {
userId : existing.id ,
sourceSystem : USER_SYNC_SOURCE_SYSTEM ,
sourceSystem ,
externalUserId : item.externalUserId ,
sourceVersion : validated.version ,
} ,
@@ -463,21 +462,22 @@ export class UserSyncEngine {
} ;
}
// 全新外部身份 → 建待激活用户(§10 闭环:activate + reset-password 出凭据 )。
// 全新外部身份 → 建用户;初始密码 = 手机号后 6 位(argon2 哈希 )。
// V1.1( 2026-09-22):真实接口上线,账号=手机号,密码=后6位,直接 active。
const created = await this . prisma . users . create ( {
data : {
account ,
passwordHash : pendingPasswordHash ( ) ,
passwordHash : await hashPassword ( initialPassword ( item . loginAccount ) ) ,
displayName : item.displayName ,
role : 'staff' ,
sourceStatus : item.sourceStatus ,
credentialStatus : 'pending ' ,
credentialStatus : 'active ' ,
} ,
} ) ;
await this . prisma . external_user_mappings . create ( {
data : {
userId : created.id ,
sourceSystem : USER_SYNC_SOURCE_SYSTEM ,
sourceSystem ,
externalUserId : item.externalUserId ,
sourceVersion : validated.version ,
} ,