Files
Friendship-and-chat-api/script/repair-im-users/core.mjs
T

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,
};
}