import { createHash } from 'node:crypto'; export const MAX_MEMBER_ID = 9223372036854775807n; export const USER_COLUMNS = ['user_name', 'nick_name', 'head_image', 'head_image_thumb', 'member_state', 'sync_version', 'auth_version', 'access_disabled']; export const FRIEND_COLUMNS = ['friend_nick_name', 'friend_head_image']; export const GROUP_MEMBER_COLUMNS = ['user_nick_name', 'head_image']; export function stable(value) { if (Array.isArray(value)) return value.map(stable); if (value !== null && typeof value === 'object') { return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])])); } return value; } export function fingerprint(value) { return createHash('sha256').update(JSON.stringify(stable(value)) ?? 'undefined').digest('hex'); } export function assertSame(actual, expected, label) { if (fingerprint(actual) !== fingerprint(expected)) { const differences = []; const inspect = (left, right, path) => { if (differences.length >= 10 || fingerprint(left) === fingerprint(right)) return; if (left != null && right != null && typeof left === 'object' && typeof right === 'object') { for (const key of new Set([...Object.keys(left), ...Object.keys(right)])) inspect(left[key], right[key], `${path}.${key}`); } else { differences.push({ path, expected: right ?? null, actual: left ?? null }); } }; inspect(actual, expected, label); throw new Error(`${label}与清单不一致,请保持维护状态并检查差异 ${JSON.stringify(differences)}`); } } export function memberId(value) { const text = String(value); if (!/^-?[0-9]+$/.test(text)) throw new Error('用户编号须为精确的十进制整数'); const id = BigInt(text); if (id > MAX_MEMBER_ID || id < -MAX_MEMBER_ID) throw new Error('用户编号超出 Long 范围'); if (id <= 0n && id !== -100n && id !== -200n) throw new Error(`异常用户编号 ${text}`); return id.toString(); } export function profile(member) { const id = memberId(member.id); if (BigInt(id) <= 0n) throw new Error(`业务会员编号须为正数 ${id}`); const nickname = member.nickname?.trim() ? member.nickname : `u${id}`; if (nickname.length > 30) throw new Error(`会员 ${id} 的昵称超过接口支持的30字符`); return { userId: id, userName: `u${id}`, nickName: nickname, avatar: member.avatar ?? '', phone: member.mobile ?? null, memberState: member.deleted ? 2 : Number(member.status) === 0 ? 0 : 1, }; } export function temporaryName(id, runId) { return `~${runId.slice(0, 4)}_${BigInt(id).toString(36)}`; } export function createPlan(before, identities, runId) { const members = new Map(before.members.map(row => [memberId(row.id), row])); if (members.size !== before.members.length) throw new Error('业务会员编号重复'); const users = new Map(before.users.map(row => [memberId(row.id), row])); if (users.size !== before.users.length) throw new Error('IM 用户编号重复'); const tasks = new Map(before.tasks.map(row => [memberId(row.member_id), row])); const profiles = new Map([...members].map(([id, member]) => [id, profile(member)])); for (const [id, task] of tasks) { if (!profiles.has(id)) { memberId(id); const previous = JSON.parse(task.profile_json); const user = users.get(id); profiles.set(id, { ...previous, userId: id, userName: `u${id}`, nickName: user?.nick_name ?? previous.nickName, avatar: user?.head_image ?? previous.avatar, memberState: 3 }); } } const targetVersions = new Map(); const taskChanges = []; let maximum = 0n; const referenced = [ ...before.friends.flatMap(row => [row.user_id, row.friend_id]), ...before.groupMembers.map(row => row.user_id), ...before.groups.map(row => row.owner_id), ...Object.values(before.messages).flatMap(row => [row.maximumSender, row.maximumReceiver]), ].filter(value => value != null); for (const id of [...members.keys(), ...users.keys(), ...tasks.keys(), ...referenced.map(memberId)]) { if (BigInt(id) > maximum) maximum = BigInt(id); } if (maximum === MAX_MEMBER_ID) throw new Error('会员编号已经耗尽,无法安全提高自增起点'); for (const [id, desired] of profiles) { const userVersion = BigInt(users.get(id)?.sync_version ?? 0); const taskVersion = BigInt(tasks.get(id)?.version ?? 0); const version = (userVersion > taskVersion ? userVersion : taskVersion) + 1n; if (version > MAX_MEMBER_ID) throw new Error(`会员 ${id} 的同步版本已耗尽`); targetVersions.set(id, version.toString()); taskChanges.push({ member_id: id, tenant_id: String(members.get(id)?.tenant_id ?? tasks.get(id).tenant_id), profile_json: JSON.stringify(desired), member_state: desired.memberState, version: version.toString(), synced_version: '0', initialized: Boolean(tasks.get(id)?.initialized), retry_count: 0, last_error: '', }); } const userChanges = []; for (const user of before.users) { const id = memberId(user.id); if (BigInt(id) < 0n) continue; const desired = profiles.get(id); const state = desired?.memberState ?? 3; const disabled = state !== 0 || Boolean(user.is_banned); let authVersion = BigInt(user.auth_version ?? 0); if (Number(user.member_state ?? 0) !== state || Boolean(user.access_disabled) !== disabled) authVersion++; if (disabled && authVersion === 0n) authVersion = 1n; const version = targetVersions.get(id) ?? (BigInt(user.sync_version ?? 0) + 1n).toString(); userChanges.push({ id, user_name: `u${id}`, nick_name: desired?.nickName ?? user.nick_name, head_image: desired?.avatar ?? user.head_image, head_image_thumb: desired?.avatar ?? user.head_image_thumb, member_state: state, sync_version: version, auth_version: authVersion.toString(), access_disabled: disabled, }); } const changes = new Map(userChanges.map(row => [row.id, row])); const friendChanges = before.friends.filter(row => changes.has(String(row.friend_id))).map(row => { const user = changes.get(String(row.friend_id)); return { id: row.id, friend_nick_name: user.nick_name, friend_head_image: user.head_image_thumb }; }); const groupMemberChanges = before.groupMembers.filter(row => changes.has(String(row.user_id))).map(row => { const user = changes.get(String(row.user_id)); return { id: row.id, user_nick_name: user.nick_name, head_image: user.head_image_thumb }; }); const reserved = new Set(before.users.filter(row => BigInt(row.id) < 0n).map(row => row.user_name.toLowerCase())); for (const task of taskChanges) { if (reserved.has(`u${task.member_id}`)) throw new Error(`会员 ${task.member_id} 的目标用户名与保留账号冲突`); } const temporary = new Set(); for (const user of userChanges) { const name = temporaryName(user.id, runId); if (reserved.has(user.user_name.toLowerCase()) || reserved.has(name.toLowerCase()) || temporary.has(name)) { throw new Error(`会员 ${user.id} 的目标用户名与保留账号冲突`); } temporary.add(name); } const plan = { formatVersion: 1, runId, identities, createdAt: new Date().toISOString(), before, userChanges, friendChanges, groupMemberChanges, taskChanges, nextMemberId: (maximum + 1n).toString(), }; plan.hash = fingerprint(plan); return plan; } export function validatePlan(plan) { const { hash, ...content } = plan; if (plan.formatVersion !== 1 || fingerprint(content) !== hash) throw new Error('修复清单格式或校验值错误'); memberId(plan.nextMemberId); } function applyChanges(rows, changes) { const byId = new Map(changes.map(row => [String(row.id), row])); return rows.map(row => ({ ...row, ...byId.get(String(row.id)) })); } export function expectedIm(plan) { return { users: applyChanges(plan.before.users, plan.userChanges), friends: applyChanges(plan.before.friends, plan.friendChanges), groupMembers: applyChanges(plan.before.groupMembers, plan.groupMemberChanges), groups: plan.before.groups, messages: plan.before.messages, }; } export function imPart(snapshot) { const { users, friends, groupMembers, groups, messages } = snapshot; return { users, friends, groupMembers, groups, messages }; } export function rollbackChanges(plan, current) { const expected = expectedIm(plan); const liveUsers = new Map(current.users.map(row => [String(row.id), row])); const expectedUsers = new Map(expected.users.map(row => [String(row.id), row])); const users = []; for (const before of plan.before.users) { if (BigInt(before.id) < 0n) continue; const live = liveUsers.get(String(before.id)); if (!live) throw new Error(`会员 ${before.id} 已不存在,停止回滚`); const after = expectedUsers.get(String(before.id)); const comparable = { ...live, auth_version: after.auth_version }; assertSame(comparable, after, `会员 ${before.id}`); const authVersion = BigInt(live.auth_version) + 1n; const syncVersion = BigInt(live.sync_version) + 1n; users.push({ ...live, user_name: before.user_name, nick_name: before.nick_name, head_image: before.head_image, head_image_thumb: before.head_image_thumb, auth_version: authVersion.toString(), sync_version: syncVersion.toString() }); } assertSame(current.friends, expected.friends, '好友资料'); assertSame(current.groupMembers, expected.groupMembers, '群成员资料'); assertSame(current.groups, expected.groups, '群关系'); assertSame(current.messages, expected.messages, '消息数量'); return { users, friends: plan.before.friends, groupMembers: plan.before.groupMembers }; } export function summary(plan) { const states = { active: 0, disabled: 0, deleted: 0, orphan: 0 }; const names = ['active', 'disabled', 'deleted', 'orphan']; for (const row of plan.userChanges) states[names[row.member_state]]++; const nicknames = new Map(); for (const member of plan.before.members) { const same = nicknames.get(member.nickname) ?? []; same.push(member.id); nicknames.set(member.nickname, same); } const duplicateNicknames = [...nicknames].filter(([, ids]) => ids.length > 1) .map(([nickname, memberIds]) => ({ nickname, memberIds })); return { runId: plan.runId, businessMembers: plan.before.members.length, imUsers: plan.before.users.length, ...states, missingImUsers: plan.taskChanges.filter(row => !plan.before.users.some(user => user.id === row.member_id)).length, nextMemberId: plan.nextMemberId, duplicateNicknameGroups: duplicateNicknames.length, duplicateNicknameMembers: duplicateNicknames.reduce((count, group) => count + group.memberIds.length, 0), duplicateNicknames, }; }