first commit

This commit is contained in:
douboer
2026-03-21 18:57:10 +08:00
commit c49aa1a5e9
570 changed files with 107167 additions and 0 deletions

View File

@@ -0,0 +1,554 @@
/* global wx, console, setTimeout, clearTimeout, require, module, getApp */
const { ensureSyncAuthToken, resolveSyncBaseUrl, requestJson } = require("./syncAuth");
const { emitSyncConfigApplied } = require("./syncConfigBus");
const {
getSettings,
saveSettings,
listServers,
saveServers,
listRecords,
saveRecords
} = require("./storage");
const DELETED_SERVERS_KEY = "remoteconn.sync.deleted.servers.v1";
const DELETED_RECORDS_KEY = "remoteconn.sync.deleted.records.v1";
const TOMBSTONE_LIMIT = 500;
let bootstrapPromise = null;
let settingsTimer = null;
let serversTimer = null;
let recordsTimer = null;
function logSync(level, message, extra) {
if (level === "info") return;
const payload = extra && typeof extra === "object" ? extra : {};
const writer = level === "warn" ? console.warn : level === "error" ? console.error : console.info;
writer("[sync]", message, payload);
}
/**
* 云端同步总开关:
* 1. 默认开启,保持既有“本地 + 云端”双写行为;
* 2. 用户关闭后,仅暂停配置类数据上云,不影响本地保存与终端会话;
* 3. 读取 settings 时统一走 storage 归一化,避免旧版本缺字段导致误判。
*/
function isSyncConfigEnabled() {
const settings = getSettings();
return settings.syncConfigEnabled !== false;
}
/**
* 定向清理排队中的同步定时器。
* 说明:
* 1. 用户关闭同步后,不应继续执行已经排队但尚未触发的上传;
* 2. 这里不做网络取消,仅清理本地 200ms 防抖队列。
*/
function clearScheduledTimer(timerName) {
if (timerName === "settingsTimer" && settingsTimer) {
clearTimeout(settingsTimer);
settingsTimer = null;
}
if (timerName === "serversTimer" && serversTimer) {
clearTimeout(serversTimer);
serversTimer = null;
}
if (timerName === "recordsTimer" && recordsTimer) {
clearTimeout(recordsTimer);
recordsTimer = null;
}
}
/**
* 一次性清理全部排队任务。
* 说明:
* 1. 用于“关闭同步”或“重新打开同步前先清空旧队列”;
* 2. 只处理本地防抖队列,不会中断已经发出的网络请求。
*/
function clearAllScheduledTimers() {
clearScheduledTimer("settingsTimer");
clearScheduledTimer("serversTimer");
clearScheduledTimer("recordsTimer");
}
function readArrayStorage(key) {
try {
const raw = wx.getStorageSync(key);
return Array.isArray(raw) ? raw : [];
} catch {
return [];
}
}
function writeArrayStorage(key, value) {
try {
wx.setStorageSync(key, Array.isArray(value) ? value : []);
} catch {
// ignore
}
}
function upsertTombstone(key, id, deletedAt) {
const rows = readArrayStorage(key).filter((item) => item && item.id !== id);
rows.unshift({ id, deletedAt: String(deletedAt || new Date().toISOString()) });
writeArrayStorage(key, rows.slice(0, TOMBSTONE_LIMIT));
}
function clearTombstones(key, ids) {
const removed = new Set(Array.isArray(ids) ? ids : []);
if (removed.size === 0) return;
const rows = readArrayStorage(key).filter((item) => item && !removed.has(item.id));
writeArrayStorage(key, rows);
}
function getServerTombstones() {
return readArrayStorage(DELETED_SERVERS_KEY);
}
function getRecordTombstones() {
return readArrayStorage(DELETED_RECORDS_KEY);
}
function buildSettingsPayload() {
const settings = getSettings();
const updatedAt = String(settings.updatedAt || new Date().toISOString());
return {
updatedAt,
data: settings
};
}
function buildServersPayload() {
const rows = listServers().map((item) => ({ ...item, deletedAt: null }));
const tombstones = getServerTombstones().map((item) => ({
id: item.id,
name: "",
tags: [],
host: "",
port: 22,
username: "",
authType: "password",
password: "",
privateKey: "",
passphrase: "",
certificate: "",
projectPath: "",
timeoutSeconds: 15,
heartbeatSeconds: 10,
transportMode: "gateway",
jumpHost: {
enabled: false,
host: "",
port: 22,
username: "",
authType: "password"
},
jumpPassword: "",
jumpPrivateKey: "",
jumpPassphrase: "",
jumpCertificate: "",
sortOrder: 0,
lastConnectedAt: "",
updatedAt: item.deletedAt,
deletedAt: item.deletedAt
}));
return rows.concat(tombstones);
}
function buildRecordsPayload() {
const rows = listRecords().map((item) => ({ ...item, deletedAt: null }));
const tombstones = getRecordTombstones().map((item) => ({
id: item.id,
content: "",
serverId: "",
category: "未分类",
contextLabel: "",
processed: false,
discarded: false,
createdAt: item.deletedAt,
updatedAt: item.deletedAt,
deletedAt: item.deletedAt
}));
return rows.concat(tombstones);
}
function indexById(rows) {
const map = {};
(Array.isArray(rows) ? rows : []).forEach((item) => {
if (!item || !item.id) return;
map[item.id] = item;
});
return map;
}
function pickLatest(current, candidate) {
// bootstrap 合并时,本地存在而远端缺项是正常情况;这里必须先兜底 null。
if (!current) return candidate;
if (!candidate) return current;
const currentUpdated = +new Date(current.updatedAt || current.deletedAt || 0);
const candidateUpdated = +new Date(candidate.updatedAt || candidate.deletedAt || 0);
return candidateUpdated >= currentUpdated ? candidate : current;
}
/**
* 闪念终态(已处理 / 已废弃)当前没有“恢复普通态”的 UI
* 1. 只要一侧已经进入终态,就不应被另一侧的普通态刷回;
* 2. 终态之间的切换仍按 updatedAt 取较新值;
* 3. 删除 tombstone 继续走通用时间比较,避免破坏删除同步。
*/
function pickLatestRecord(current, candidate) {
if (!current) return candidate;
if (!candidate) return current;
if (current.deletedAt || candidate.deletedAt) {
return pickLatest(current, candidate);
}
const currentTerminal = current.discarded === true || current.processed === true;
const candidateTerminal = candidate.discarded === true || candidate.processed === true;
if (currentTerminal && !candidateTerminal) return current;
if (candidateTerminal && !currentTerminal) return candidate;
return pickLatest(current, candidate);
}
function mergeServers(remoteRows) {
const localRows = listServers();
const localMap = indexById(listServers().map((item) => ({ ...item, deletedAt: null })));
const tombstones = getServerTombstones();
const remoteMap = indexById(remoteRows);
tombstones.forEach((item) => {
remoteMap[item.id] = pickLatest(remoteMap[item.id], {
id: item.id,
deletedAt: item.deletedAt,
updatedAt: item.deletedAt
});
});
const allIds = new Set([...Object.keys(localMap), ...Object.keys(remoteMap)]);
const merged = [];
const clearedTombstones = [];
allIds.forEach((id) => {
const localItem = localMap[id] || null;
const remoteItem = remoteMap[id] || null;
const winner = pickLatest(localItem, remoteItem);
if (!winner) return;
if (winner.deletedAt) {
clearedTombstones.push(id);
return;
}
merged.push({ ...winner, deletedAt: null });
clearedTombstones.push(id);
});
saveServers(merged, { silentSync: true });
clearTombstones(DELETED_SERVERS_KEY, clearedTombstones);
logSync("info", "服务器合并完成", {
localCount: localRows.length,
remoteCount: Array.isArray(remoteRows) ? remoteRows.length : 0,
mergedCount: merged.length,
tombstoneCount: tombstones.length
});
}
function mergeRecords(remoteRows) {
const localRows = listRecords();
const localMap = indexById(listRecords().map((item) => ({ ...item, deletedAt: null })));
const tombstones = getRecordTombstones();
const remoteMap = indexById(remoteRows);
tombstones.forEach((item) => {
remoteMap[item.id] = pickLatest(remoteMap[item.id], {
id: item.id,
deletedAt: item.deletedAt,
updatedAt: item.deletedAt
});
});
const allIds = new Set([...Object.keys(localMap), ...Object.keys(remoteMap)]);
const merged = [];
const clearedTombstones = [];
allIds.forEach((id) => {
const localItem = localMap[id] || null;
const remoteItem = remoteMap[id] || null;
const winner = pickLatestRecord(localItem, remoteItem);
if (!winner) return;
if (winner.deletedAt) {
clearedTombstones.push(id);
return;
}
merged.push({ ...winner, deletedAt: null });
clearedTombstones.push(id);
});
saveRecords(merged, { silentSync: true });
clearTombstones(DELETED_RECORDS_KEY, clearedTombstones);
logSync("info", "闪念合并完成", {
localCount: localRows.length,
remoteCount: Array.isArray(remoteRows) ? remoteRows.length : 0,
mergedCount: merged.length,
tombstoneCount: tombstones.length
});
}
function mergeSettings(remotePayload) {
if (!remotePayload || !remotePayload.updatedAt || !remotePayload.data) {
logSync("info", "服务端未返回设置,跳过本地设置合并");
return;
}
const local = getSettings() || {};
const localUpdated = +new Date(local.updatedAt || 0);
const remoteUpdated = +new Date(remotePayload.updatedAt || 0);
if (Number.isFinite(localUpdated) && localUpdated > remoteUpdated) {
logSync("info", "本地设置更新时间更新,保留本地设置", {
localUpdatedAt: local.updatedAt || "",
remoteUpdatedAt: remotePayload.updatedAt
});
return;
}
saveSettings(
{ ...remotePayload.data, updatedAt: remotePayload.updatedAt },
{ preserveUpdatedAt: true, silentSync: true }
);
logSync("info", "设置合并完成", {
remoteUpdatedAt: remotePayload.updatedAt
});
}
async function authedRequest(path, options) {
const token = await ensureSyncAuthToken();
const baseUrl = resolveSyncBaseUrl();
if (!baseUrl) {
throw new Error("sync base url missing");
}
return requestJson(`${baseUrl}${path}`, {
method: (options && options.method) || "GET",
data: options && options.data,
header: {
"content-type": "application/json",
Authorization: `Bearer ${token}`
}
});
}
async function pushSettingsNow() {
const payload = buildSettingsPayload();
logSync("info", "开始上传设置", {
updatedAt: payload.updatedAt
});
await authedRequest("/api/miniprogram/sync/settings", {
method: "PUT",
data: payload
});
logSync("info", "设置上传完成", {
updatedAt: payload.updatedAt
});
}
async function pushServersNow() {
const payload = buildServersPayload();
logSync("info", "开始上传服务器列表", {
count: payload.length,
tombstoneCount: getServerTombstones().length
});
const response = await authedRequest("/api/miniprogram/sync/servers", {
method: "PUT",
data: { servers: payload }
});
if (response && Array.isArray(response.servers)) {
mergeServers(response.servers);
}
logSync("info", "服务器列表上传完成", {
uploadedCount: payload.length,
remoteCount: response && Array.isArray(response.servers) ? response.servers.length : 0
});
}
async function pushRecordsNow() {
const payload = buildRecordsPayload();
logSync("info", "开始上传闪念列表", {
count: payload.length,
tombstoneCount: getRecordTombstones().length
});
const response = await authedRequest("/api/miniprogram/sync/records", {
method: "PUT",
data: { records: payload }
});
if (response && Array.isArray(response.records)) {
mergeRecords(response.records);
}
logSync("info", "闪念列表上传完成", {
uploadedCount: payload.length,
remoteCount: response && Array.isArray(response.records) ? response.records.length : 0
});
}
function scheduleTask(timerName, task) {
/**
* 关闭同步后,新的排队请求应立刻失效,并顺手清掉同名旧定时器,
* 避免用户刚关掉开关200ms 防抖里的旧任务仍继续上云。
*/
if (!isSyncConfigEnabled()) {
clearScheduledTimer(timerName);
logSync("info", "同步总开关关闭,跳过任务入队", { timerName });
return;
}
clearScheduledTimer(timerName);
logSync("info", "同步任务已入队", { timerName });
const timeout = setTimeout(async () => {
try {
/**
* 定时器真正触发时再次检查开关,确保“先排队、后关闭”的任务不会继续执行。
*/
if (!isSyncConfigEnabled()) {
logSync("info", "同步总开关关闭,跳过任务执行", { timerName });
return;
}
logSync("info", "同步任务开始执行", { timerName });
await task();
logSync("info", "同步任务执行完成", { timerName });
} catch (error) {
logSync("warn", "同步任务执行失败", {
timerName,
error: error instanceof Error ? error.message : String(error || "")
});
} finally {
if (timerName === "settingsTimer") settingsTimer = null;
if (timerName === "serversTimer") serversTimer = null;
if (timerName === "recordsTimer") recordsTimer = null;
}
}, 200);
if (timerName === "settingsTimer") settingsTimer = timeout;
if (timerName === "serversTimer") serversTimer = timeout;
if (timerName === "recordsTimer") recordsTimer = timeout;
}
/**
* 拉取一次云端 bootstrap并把结果合并回本地。
* options.force=true 时忽略历史缓存,重新发起拉取。
*/
async function ensureSyncBootstrap(options) {
const extra = options && typeof options === "object" ? options : {};
/**
* 启动 bootstrap 只同步配置类数据,用户明确关闭后应直接跳过。
*/
if (!isSyncConfigEnabled()) {
logSync("info", "同步总开关关闭,跳过启动 bootstrap");
return Promise.resolve(null);
}
if (extra.force) {
bootstrapPromise = null;
}
if (bootstrapPromise) return bootstrapPromise;
const opsBaseUrl = resolveSyncBaseUrl();
if (!opsBaseUrl) {
logSync("warn", "未配置同步基地址,跳过启动 bootstrap");
return Promise.resolve(null);
}
bootstrapPromise = (async () => {
try {
logSync("info", "开始执行启动 bootstrap", { baseUrl: opsBaseUrl });
const payload = await authedRequest("/api/miniprogram/sync/bootstrap", { method: "GET" });
if (!payload || payload.ok !== true) return null;
mergeSettings(payload.settings || null);
mergeServers(Array.isArray(payload.servers) ? payload.servers : []);
mergeRecords(Array.isArray(payload.records) ? payload.records : []);
logSync("info", "启动 bootstrap 完成", {
hasSettings: Boolean(payload.settings),
serverCount: Array.isArray(payload.servers) ? payload.servers.length : 0,
recordCount: Array.isArray(payload.records) ? payload.records.length : 0
});
/**
* 启动 bootstrap 是“异步合并回本地”的:
* 1. 首页/底栏可能已经先渲染了旧本地快照;
* 2. 合并完成后必须主动广播一次,让当前可见页立刻重读 storage
* 3. 否则用户会看到“同步已完成,但必须重新进页面才显示”的假象。
*/
emitSyncConfigApplied({
source: extra.force ? "bootstrap.force" : "bootstrap",
hasSettings: Boolean(payload.settings),
serverCount: Array.isArray(payload.servers) ? payload.servers.length : 0,
recordCount: Array.isArray(payload.records) ? payload.records.length : 0,
appliedAt: Date.now()
});
return payload;
} catch (error) {
logSync("warn", "启动 bootstrap 失败", {
error: error instanceof Error ? error.message : String(error || "")
});
return null;
} finally {
const app = typeof getApp === "function" ? getApp() : null;
if (app && app.globalData) {
app.globalData.syncBootstrappedAt = Date.now();
}
}
})();
return bootstrapPromise;
}
/**
* 重新打开同步后的恢复流程:
* 1. 先强制拉取一次最新云端配置,按 updatedAt/逐项 merge 规则合并;
* 2. 再把当前合并后的本地结果重新排队上传,避免直接用“关闭期间的本地旧视图”覆盖云端。
* 3. 若 bootstrap 失败,则退化为仅恢复本地补推,保证用户不会因为一次拉取失败而一直无法恢复同步。
*/
async function resumeSyncConfig() {
if (!isSyncConfigEnabled()) {
logSync("info", "同步总开关关闭,跳过恢复同步");
clearAllScheduledTimers();
return null;
}
clearAllScheduledTimers();
let bootstrapPayload = null;
try {
bootstrapPayload = await ensureSyncBootstrap({ force: true });
} catch (error) {
logSync("warn", "恢复同步前 bootstrap 抛出异常,改为直接补推本地数据", {
error: error instanceof Error ? error.message : String(error || "")
});
}
scheduleSettingsSync();
scheduleServersSync();
scheduleRecordsSync();
return bootstrapPayload;
}
function scheduleSettingsSync() {
scheduleTask("settingsTimer", pushSettingsNow);
}
function scheduleServersSync() {
scheduleTask("serversTimer", pushServersNow);
}
function scheduleRecordsSync() {
scheduleTask("recordsTimer", pushRecordsNow);
}
function markServerDeleted(id, deletedAt) {
if (!id) return;
upsertTombstone(DELETED_SERVERS_KEY, id, deletedAt);
logSync("info", "已记录服务器删除 tombstone", { id, deletedAt });
scheduleServersSync();
}
function markRecordDeleted(id, deletedAt) {
if (!id) return;
upsertTombstone(DELETED_RECORDS_KEY, id, deletedAt);
logSync("info", "已记录闪念删除 tombstone", { id, deletedAt });
scheduleRecordsSync();
}
module.exports = {
ensureSyncBootstrap,
resumeSyncConfig,
scheduleSettingsSync,
scheduleServersSync,
scheduleRecordsSync,
markServerDeleted,
markRecordDeleted,
__test__: {
clearAllScheduledTimers,
clearScheduledTimer,
isSyncConfigEnabled,
pickLatest,
pickLatestRecord
}
};