refactor(api): per-source sourceSystem on user source port, retire pending-activation dead code

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 16:24:37 +08:00
parent b7754d52d1
commit 52cd31679a
4 changed files with 31 additions and 23 deletions
+1 -1
View File
@@ -132,7 +132,7 @@ export class AuthService {
// ③ 先验密码再查状态:状态不合格与密码错误同样不可区分(同 401 同文案;
// argon2 在两条路径都真实跑过,时延也一致)。
// 哨兵哈希(P4a Task 7:同步创建的待激活账号 passwordHash='!:pending:…')
// 历史哨兵哈希行(P4a 待激活已退役;非法 argon2 编码统一按密码错误处理)
// 不是合法 argon2 编码——verify 会抛错而非返回 false。此处按密码错误
// 处理并补跑等代价 dummy verify(时延与密码错误路径一致,不泄露
// 「该账号待激活」),杜绝 500。
@@ -113,6 +113,9 @@ function encodePageCursor(watermark: number | null, page: number): string {
@Injectable()
export class MockUserSource implements UserSourcePort {
/** 端口 sourceSystem:mock 源命名空间(e2e 断言 'mock:ext-*' 字面量)。 */
readonly sourceSystem = 'mock';
private scenario: MockScenario = DEFAULT_SCENARIO;
/** e2e 断言用:历次 listPage 收到的游标(null=全量形态)。 */
readonly receivedCursors: Array<string | null> = [];
@@ -34,6 +34,11 @@ export interface UserSourcePage {
/** 用户源适配器端口(mock 实现见 mock-user-source.ts;G4 后替换为真实适配器)。 */
export interface UserSourcePort {
/** 本源在 external_user_mappings.sourceSystem 的命名空间标识——映射锚点
* (sourceSystem, externalUserId) 的组成部分(引擎红线 4)。mock='mock',
* uniservice 真实源='uniservice'(http-user-source.ts)。 */
readonly sourceSystem: string;
/**
* 拉取一页。cursor 语义见文件头;抛错 = 分页中断(引擎记 failed 批次,
* 不动任何用户,水位不前进)。
+22 -22
View File
@@ -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,
},