Ctrl K
ComunidadGuías…

src/sync/syncEngine.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 同步引擎:本地优先 + 增量双向同步(LWW)
// 流程:push 本地水位后的变更 → pull 服务端变更 → 本地 upsert(LWW)→ 更新水位
import { DeviceEventEmitter } from 'react-native';
import { LEDGER_EVENTS, SETTING_KEYS } from '../constants';
import { showToast } from '../components/Toast';
import {
  getDB,
  saveSetting,
  getSetting,
  getActiveLedgerId,
  setActiveLedgerId,
  adoptUnassignedRowsIntoLedger,
  clampFutureTimestamps,
} from '../database/ledgerDB';
import { apiSyncPull, apiSyncPush, apiGetLedgers, getSyncConfig, ApiError } from './apiClient';
import type { SyncChanges, SyncCustomCategoryDTO, SyncRecordDTO, SyncRecurringDTO } from './serverTypes';

export interface SyncResult {
  ok: boolean;
  pushed: number;
  pulled: number;
  /** 因字段超出服务端限制而没能上传的条数(本地已存、家人看不到,需要如实提示) */
  invalid?: number;
  invalidIds?: string[];
  error?: string;
}

let syncing = false;

export function isSyncing(): boolean {
  return syncing;
}

// 解析当前同步账本 id(未显式选择 → 取个人账本兜底)
async function resolveActiveLedgerId(baseUrl: string, token: string): Promise<number> {
  const saved = await getSetting(SETTING_KEYS.SYNC_ACTIVE_LEDGER_ID);
  const savedId = Number(saved ?? '0') || 0;
  if (savedId > 0) return savedId;
  const { ledgers } = await apiGetLedgers(baseUrl, token).catch((e) => {
    // 凭证失效(401)交给主 catch 清理登录态,不再吞掉
    if (e instanceof ApiError && e.status === 401) throw e;
    return { ledgers: [] as { type: string; id: number }[] };
  });
  const personal = ledgers.find((l) => l.type === 'personal');
  if (personal) {
    await saveSetting(SETTING_KEYS.SYNC_ACTIVE_LEDGER_ID, String(personal.id));
  }
  return personal?.id ?? 0;
}

// 每本账本独立水位 key(避免个人/家庭切换后互相污染)
function watermarkKey(base: string, ledgerId: number): string {
  return `${base}.${ledgerId}`;
}

// ===== push:收集本地 updated_at > 水位 的变更 =====

async function collectPushChanges(
  sinceTs: number,
): Promise<{ changes: Partial<SyncChanges>; maxLocalTs: number }> {
  const db = await getDB();
  const ledgerId = getActiveLedgerId();

  const records = await db.getAllAsync<SyncRecordDTO & { user_id: number }>(
    `SELECT uuid, user_id as userId, amount, category, type, note, date, timestamp,
            reimbursable, reimbursed, updated_at as updatedAt, deleted
     FROM ledger_records WHERE updated_at > ? AND uuid != '' AND ledger_id = ? ORDER BY updated_at ASC`,
    [sinceTs, ledgerId],
  );

  const recurring = await db.getAllAsync<SyncRecurringDTO & { user_id: number }>(
    `SELECT uuid, user_id as userId, name, amount, type, category,
            frequency, day_of_week as dayOfWeek, day_of_month as dayOfMonth, month_of_year as monthOfYear,
            note, enabled, last_generated as lastGenerated, updated_at as updatedAt, deleted
     FROM recurring_rules WHERE updated_at > ? AND uuid != '' AND ledger_id = ? ORDER BY updated_at ASC`,
    [sinceTs, ledgerId],
  );

  const customCategories = await db.getAllAsync<SyncCustomCategoryDTO>(
    `SELECT uuid, key, label, emoji, color, type, updated_at as updatedAt, deleted
     FROM custom_categories WHERE updated_at > ? AND uuid != '' AND ledger_id = ? ORDER BY updated_at ASC`,
    [sinceTs, ledgerId],
  );

  // 水位推进:本次推送行中的最大 updated_at
  let maxLocalTs = sinceTs;
  const rows = [...records, ...recurring, ...customCategories];
  for (const r of rows) {
    if (r.updatedAt > maxLocalTs) maxLocalTs = r.updatedAt;
  }

  return {
    changes: { records, recurring, customCategories },
    maxLocalTs,
  };
}

// ===== pull:服务端变更 → 本地 upsert(LWW)=====

async function applyPullChanges(changes: SyncChanges, serverTime: number): Promise<number> {
  const db = await getDB();
  const activeLedger = getActiveLedgerId();
  // v0.11.21:本地时间「跑到服务端时刻之后」说明这台设备时钟超前。
  // 此时本地 updated_at 不可信:它会让 r.updatedAt > local.updated_at 永远为假,
  // 家人的后续编辑被长期忽略;而服务端 push 时其实已把该行钳到服务端时间。
  // 故只要本地时间超前,就接受服务端版本(服务端时间是权威),再由 runSync 统一回落本地时间。
  const localIsFuture = (localTs: number): boolean => localTs > serverTime;
  let applied = 0;
  // 整体包事务:任一条失败即整体回滚,避免半程写入导致下次漏拉(水位不一致)
  await db.withTransactionAsync(async () => {
    // 查找一律带 ledger_id:uuid 在本地没有唯一约束、也不分账本,
    // 少了这个条件,家庭账本的 pull 会改写个人账本里的同 uuid 行(与服务端同源的问题)
    // 1) 记录
    for (const r of changes.records) {
      const local = await db.getFirstAsync<{ id: number; updated_at: number }>(
        'SELECT id, updated_at FROM ledger_records WHERE uuid = ? AND ledger_id = ?',
        [r.uuid, activeLedger],
      );
      if (local) {
        if (r.updatedAt > local.updated_at || localIsFuture(local.updated_at)) {
          await db.runAsync(
            `UPDATE ledger_records SET amount = ?, category = ?, type = ?, note = ?, date = ?, timestamp = ?,
             reimbursable = ?, reimbursed = ?, user_id = ?, updated_at = ?, deleted = ?
             WHERE id = ?`,
            [
              r.amount,
              r.category,
              r.type,
              r.note,
              r.date,
              r.timestamp,
              r.reimbursable,
              r.reimbursed,
              r.userId,
              r.updatedAt,
              r.deleted,
              local.id,
            ],
          );
          applied++;
        }
      } else {
        await db.runAsync(
          `INSERT INTO ledger_records (amount, category, type, note, date, timestamp, reimbursable, reimbursed, uuid, user_id, updated_at, deleted, ledger_id)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
          [
            r.amount,
            r.category,
            r.type,
            r.note,
            r.date,
            r.timestamp,
            r.reimbursable,
            r.reimbursed,
            r.uuid,
            r.userId,
            r.updatedAt,
            r.deleted,
            activeLedger,
          ],
        );
        applied++;
      }
    }

    // 2) 周期规则
    for (const r of changes.recurring) {
      const local = await db.getFirstAsync<{ id: number; updated_at: number }>(
        'SELECT id, updated_at FROM recurring_rules WHERE uuid = ? AND ledger_id = ?',
        [r.uuid, activeLedger],
      );
      if (local) {
        if (r.updatedAt > local.updated_at || localIsFuture(local.updated_at)) {
          await db.runAsync(
            `UPDATE recurring_rules SET name = ?, amount = ?, type = ?, category = ?,
             frequency = ?, day_of_week = ?, day_of_month = ?, month_of_year = ?, note = ?, enabled = ?,
             last_generated = ?, user_id = ?, updated_at = ?, deleted = ? WHERE id = ?`,
            [
              r.name,
              r.amount,
              r.type,
              r.category,
              r.frequency,
              r.dayOfWeek,
              r.dayOfMonth,
              r.monthOfYear,
              r.note,
              r.enabled,
              r.lastGenerated,
              r.userId,
              r.updatedAt,
              r.deleted,
              local.id,
            ],
          );
          applied++;
        }
      } else {
        await db.runAsync(
          `INSERT INTO recurring_rules (name, amount, type, category, frequency, day_of_week, day_of_month, month_of_year, note, enabled, last_generated, created_at, uuid, user_id, updated_at, deleted, ledger_id)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
          [
            r.name,
            r.amount,
            r.type,
            r.category,
            r.frequency,
            r.dayOfWeek,
            r.dayOfMonth,
            r.monthOfYear,
            r.note,
            r.enabled,
            r.lastGenerated,
            Date.now(),
            r.uuid,
            r.userId,
            r.updatedAt,
            r.deleted,
            activeLedger,
          ],
        );
        applied++;
      }
    }

    // 3) 自定义分类:主键是 (key, ledger_id),改名会换主键 → 先按 uuid 清掉同账本里的旧 key 行,
    //    再按 (key, ledger_id) 做 LWW upsert。此前是 ON CONFLICT(key) DO NOTHING:
    //    另一本账本已有同 key 分类时这条会被永久吞掉(本地永远看不到,服务端每轮都还带着它)。
    for (const c of changes.customCategories) {
      const renamed = await db.getFirstAsync<{ rowid: number; updated_at: number }>(
        'SELECT rowid, updated_at FROM custom_categories WHERE uuid = ? AND key <> ? AND ledger_id = ?',
        [c.uuid, c.key, activeLedger],
      );
      if (renamed && (c.updatedAt > renamed.updated_at || localIsFuture(renamed.updated_at))) {
        await db.runAsync('DELETE FROM custom_categories WHERE rowid = ?', [renamed.rowid]);
        applied++;
      }
      const res = await db.runAsync(
        `INSERT INTO custom_categories (key, uuid, ledger_id, label, emoji, color, type, created_at, updated_at, deleted)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
         ON CONFLICT(key, ledger_id) DO UPDATE SET
           uuid = excluded.uuid, label = excluded.label, emoji = excluded.emoji, color = excluded.color,
           type = excluded.type, updated_at = excluded.updated_at, deleted = excluded.deleted
         WHERE excluded.updated_at > custom_categories.updated_at
            OR custom_categories.updated_at > ?`,
        [
          c.key,
          c.uuid,
          activeLedger,
          c.label,
          c.emoji,
          c.color,
          c.type,
          Date.now(),
          c.updatedAt,
          c.deleted,
          serverTime,
        ],
      );
      if (res.changes > 0) applied++;
    }
  });

  return applied;
}

// ===== 主入口:执行一轮 push + pull =====

export async function runSync(): Promise<SyncResult> {
  const config = await getSyncConfig();
  if (!config) {
    return { ok: false, pushed: 0, pulled: 0, error: '未配置同步' };
  }
  if (syncing) {
    return { ok: false, pushed: 0, pulled: 0, error: '同步进行中' };
  }
  syncing = true;
  try {
    // --- 确定当前账本 ---
    const ledgerId = await resolveActiveLedgerId(config.baseUrl, config.token);
    if (!ledgerId) {
      return { ok: false, pushed: 0, pulled: 0, error: '未选择账本,请在同步页选择个人/家庭账本' };
    }
    // 解析/切换活动账本后:DB 层读写限定到该账本,并把本地未归属存量一次性认领进来(幂等)
    setActiveLedgerId(ledgerId);
    await adoptUnassignedRowsIntoLedger(ledgerId);

    // --- push ---
    const lastPushStr = await getSetting(watermarkKey(SETTING_KEYS.SYNC_LAST_PUSH_AT, ledgerId));
    const lastPushAt = Number(lastPushStr ?? '0') || 0;
    const { changes, maxLocalTs } = await collectPushChanges(lastPushAt);
    const pushCount = Object.values(changes).reduce((s, arr) => s + (arr?.length ?? 0), 0);
    let invalid = 0;
    let invalidIds: string[] | undefined;
    if (pushCount > 0) {
      const pushRes = await apiSyncPush(config.baseUrl, config.token, changes, ledgerId);
      // v0.11.8:此前完全不看 push 响应,字段超限被服务端拒收时本地水位照样推进,
      // 那条数据就永远停在「我有、家人没有」且没有任何提示。
      invalid = pushRes.invalid ?? 0;
      invalidIds = pushRes.invalidIds;
      // 水位仍然推进:不推进会让这条每轮同步重发一次、每轮都被拒,同步彻底卡死。
      // 真正的修复是导入/录入入口已按服务端上限归一(见 exportData.SERVER_LIMITS),
      // 这里只把残余的异常如实报给 UI。
      await saveSetting(watermarkKey(SETTING_KEYS.SYNC_LAST_PUSH_AT, ledgerId), String(maxLocalTs));
      // 首次登录/老用户:同步即视为已确认归属,后续无需重复 claim
    } else {
      // 无变更也推进水位(本地无新数据时水位无意义,保持)
    }

    // --- pull ---
    const lastPullStr = await getSetting(watermarkKey(SETTING_KEYS.SYNC_LAST_PULL_AT, ledgerId));
    const lastPullAt = Number(lastPullStr ?? '0') || 0;
    const pullRes = await apiSyncPull(config.baseUrl, config.token, lastPullAt, ledgerId);
    const serverTime = pullRes.serverTime || Date.now();
    const pulled = await applyPullChanges(pullRes.changes, serverTime);
    await saveSetting(watermarkKey(SETTING_KEYS.SYNC_LAST_PULL_AT, ledgerId), String(serverTime));
    await saveSetting(SETTING_KEYS.SYNC_LAST_SYNC_TIME, String(Date.now()));

    // v0.11.21:时钟超前设备的「未来时间」善后。
    // ① 本地行 updated_at 回落到服务端时间,避免长期压制家人编辑;
    // ② push 水位若已被未来值顶到前面,回落后这些行才能在下一轮重新推上去;
    //    (否则会出现「同步过一次以后本地新记账永远传不出去」)
    try {
      await clampFutureTimestamps(serverTime);
      const pushKey = watermarkKey(SETTING_KEYS.SYNC_LAST_PUSH_AT, ledgerId);
      const pushMark = Number((await getSetting(pushKey)) ?? '0') || 0;
      if (pushMark > serverTime) {
        // 回落到 serverTime - 1:push 取的是 updated_at > 水位,回落后的行正好等于
        // serverTime,若把水位设成 serverTime 它们会被跳过(下一轮再也传不上去)
        await saveSetting(pushKey, String(serverTime - 1));
      }
    } catch {
      // 善后失败不影响本轮同步结果,下一轮再试
    }

    // 有数据落库 → 通知页面刷新
    if (pulled > 0) {
      const { setCustomCategoriesCache } = await import('../database/ledgerDB');
      await setCustomCategoriesCache();
      DeviceEventEmitter.emit(LEDGER_EVENTS.RECORDED);
    }

    // 刷新家庭成员缓存(成员增减/改名后各端标识同步,v0.5)
    try {
      const { refreshMembersCache } = await import('./memberUtils');
      await refreshMembersCache(config.baseUrl, config.token);
    } catch {
      // 成员缓存失败不影响同步主流程
    }

    return { ok: true, pushed: pushCount, pulled, invalid, invalidIds };
  } catch (e) {
    // 凭证失效(401)→ 自动清理登录态并通知 UI,避免同步静默失败、用户却以为仍登录
    // (v0.11.3;服务端 0.5.5 起默认 365 天长效)
    //
    // v0.11.11:把服务端给的**具体原因**原样透出来,并主动弹提示。
    // 三种 401 的处置方式完全不同,此前统一压成「登录已过期,请到同步页重新登录」,
    // 结果「服务端账号不见了」这种要紧的事和「token 到期了,重登就好」长得一模一样:
    //  · 登录已过期,请重新登录      = token 到期,或服务端换过 JWT_SECRET(全员一起掉线通常是这个)
    //  · 登录状态已在其他设备变更     = 有人在别处改了密码(token_version 递增,属预期)
    //  · 用户不存在                 = 服务端账号/数据库不见了(换 DATA_DIR、卷没挂上、从备份回滚)
    if (e instanceof ApiError && e.status === 401) {
      const reason = (e.message || '').trim() || '登录状态失效';
      const accountMissing = reason.includes('用户不存在');
      await Promise.all([
        saveSetting(SETTING_KEYS.SYNC_TOKEN, ''),
        saveSetting(SETTING_KEYS.SYNC_USER_ID, '0'),
        saveSetting(SETTING_KEYS.SYNC_USER_DISPLAY, ''),
        saveSetting(SETTING_KEYS.SYNC_USER_AVATAR, ''),
        saveSetting(SETTING_KEYS.SYNC_FAMILY_NAME, ''),
        saveSetting(SETTING_KEYS.SYNC_MEMBERS_JSON, ''),
        saveSetting(SETTING_KEYS.SYNC_ACTIVE_LEDGER_ID, '0'),
        saveSetting(SETTING_KEYS.SYNC_ACTIVE_LEDGER_NAME, ''),
      ]);
      setActiveLedgerId(0);
      DeviceEventEmitter.emit(LEDGER_EVENTS.AUTH_CHANGED);
      // 后台自动同步这条路径原本没有任何 UI 出口(返回值被 debounce 丢弃),
      // 用户只会发现"不知道什么时候被退出了"。这里补一次明确的提示。
      showToast(
        accountMissing
          ? '⚠️ 服务端查不到此账号(本地账本仍在)。请先检查 NAS 上的数据卷/数据库是否还在,再决定是否重新注册'
          : `登录已失效:${reason}。本地账本仍在,重新登录即可继续同步`,
        'error',
        6000,
      );
      return { ok: false, pushed: 0, pulled: 0, error: reason };
    }
    const msg = e instanceof ApiError ? e.message : '同步失败';
    return { ok: false, pushed: 0, pulled: 0, error: msg };
  } finally {
    syncing = false;
    // 一轮同步结束(无论成败)→ 通知 UI 刷新同步状态与成员缓存(v0.5)
    DeviceEventEmitter.emit(LEDGER_EVENTS.SYNC_DONE);
  }
}

// 登录后归属:本地未记账人的存量(user_id=0)划归当前用户(记账人标记)
// 只处理「尚未归属任何账本」的那批(ledger_id=0,登录后会被 adopt 进当前账本);
// 不加这个条件时,另一本账本里别人留下的无主记录也会被一起标成我的,报销对账口径就错了。
export async function claimLocalRecordsAsUser(userId: number): Promise<void> {
  const db = await getDB();
  await db.runAsync(
    'UPDATE ledger_records SET user_id = ? WHERE user_id = 0 AND deleted = 0 AND ledger_id = 0',
    [userId],
  );
}

// 墓碑清理:删除 90 天前的墓碑行(启动时调用,避免本地库无限膨胀)
export async function purgeOldTombstones(): Promise<void> {
  const db = await getDB();
  const cutoff = Date.now() - 90 * 24 * 3600 * 1000;
  await db.runAsync('DELETE FROM ledger_records WHERE deleted = 1 AND updated_at < ?', [cutoff]);
  await db.runAsync('DELETE FROM recurring_rules WHERE deleted = 1 AND updated_at < ?', [cutoff]);
  await db.runAsync('DELETE FROM custom_categories WHERE deleted = 1 AND updated_at < ?', [cutoff]);
}