248 lines
11 KiB
JavaScript
248 lines
11 KiB
JavaScript
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,
|
|
};
|
|
}
|