合記(heji)同步设计规范

April 13, 2026 · View on GitHub

本文档是前后端开发的唯一准绳。所有同步相关的实现必须遵循本文档的约定。


1. 总则

1.1 核心原则

#原则说明
1本地优先所有 CRUD 先写入本地 Room,再异步同步到服务端
2HTTP 同步数据的上行(客户端→服务端)一律通过 HTTP REST API
3MQTT 推送服务端向其他客户端推送变更通知,MQTT 仅做下行通知通道
4单写者模型每条账单/账本只有创建人(crt_user)可修改/删除,天然无并发写冲突
5增量拉取兜底HTTP 拉取接口补偿 MQTT 推送丢失的消息,保证最终一致
6服务端优先增量拉取时,服务端数据直接覆盖本地(含 synced=0 的记录)

1.2 数据流总览

 用户A (创建人)                服务端                 用户B (成员)
    │                           │                      │
    │  1. 本地 Room CRUD        │                      │
    │  2. HTTP POST/PUT/DELETE  │                      │
    │  ────────────────────►    │                      │
    │                           │  3. 持久化             │
    │  4. HTTP 200 ◄──────────  │                      │
    │     本地标记 SYNCED        │                      │
    │                           │  5. MQTT 推送通知      │
    │                           │  ──────────────────►  │
    │                           │                      │
    │                           │     6. 写入本地 Room   │

2. 权限模型

实体创建人账本成员
账本 (Book)增、删、改只读 + 在其下创建账单
账单 (Bill)增、删、改只读
账单类型 (Category)增、删、改只读
图片 (Image)跟随所属账单跟随所属账单

规则:

  • 客户端在 UI 层和 DAO 层双重校验所有权
  • 服务端在 HTTP 中间件校验 JWT + 所有权,不满足返回 403

3. 同步状态机

只需两个状态:

  ┌──────────────────┐
  │  NOT_SYNCED (0)  │ ◄── 新增 / 本地修改 / 本地删除(deleted=1)
  └────────┬─────────┘
           │ HTTP 请求成功
  ┌────────▼─────────┐
  │   SYNCED (1)     │ ◄── 服务端确认 / MQTT 推送写入 / 增量拉取写入
  └──────────────────┘

3.1 字段定义

字段类型值含义
syncedInt0未同步(新增/修改后待上传)
syncedInt1已同步(服务端已确认)
deletedInt0未删除
deletedInt1已标记删除(待同步删除到服务端)

3.2 状态转换

操作转换说明
新增→ synced=0, deleted=0写入 Room 后等待上传
修改synced=1 → synced=0本地改完后等待上传
删除→ deleted=1, synced=0软删除,等待上传
HTTP 上传成功(增/改)synced=0 → synced=1服务端已持久化
HTTP 删除成功deleted=1 → 硬删除从 Room 物理删除
HTTP 失败(网络错误)保持 synced=0Flow 退避后自动重试
HTTP 返回 409(ID 冲突)synced=0 → synced=1服务端已有相同数据,视为幂等成功
HTTP 返回 404→ 硬删除服务端无此记录,本地清除
MQTT 推送写入→ synced=1来自其他用户的变更,直接标记已同步
增量拉取写入→ synced=1兜底拉取的数据,直接标记已同步(服务端优先)

4. HTTP 同步接口

4.1 CRUD 接口

账本 (Book)

操作方法路径权限成功码
创建POST/api/v1/books/已登录用户201
更新PUT/api/v1/books/:id仅创建人200
删除DELETE/api/v1/books/:id仅创建人200
列表GET/api/v1/books/返回我创建的 + 我参与的200

账单 (Bill)

操作方法路径权限成功码
创建POST/api/v1/bills/账本成员201
更新PUT/api/v1/bills/:id仅创建人200
删除DELETE/api/v1/bills/:id仅创建人200
查询GET/api/v1/bills/?book_id=xx&limit=50&offset=0账本成员200

账单类型 (Category)

操作方法路径权限成功码
创建POST/api/v1/categories/账本成员201
更新PUT/api/v1/categories/:id仅创建人200
删除DELETE/api/v1/categories/:id仅创建人200
查询GET/api/v1/categories/?book_id=xx账本成员200

图片 (Image)

操作方法路径权限成功码
上传POST/api/v1/images/upload账单创建人201
删除DELETE/api/v1/images/:id账单创建人200
下载GET/api/v1/images/:id账本成员200

错误码约定

HTTP 状态码含义客户端行为
400请求参数错误记录日志,不重试
401未登录 / Token 过期刷新 Token 后重试
403无权限(非创建人尝试修改/删除)记录日志,不重试
404资源不存在硬删除本地记录
409ID 冲突(客户端生成的 Xid 重复)视为幂等成功,标记 synced=1

4.2 增量拉取接口

用途

MQTT 推送是尽力而为的,以下场景会丢失推送:

  • 客户端离线期间(地铁、飞行模式)
  • MQTT broker 重启
  • 网络抖动导致连接断开的瞬间

增量拉取接口让客户端主动补回缺失的变更。

触发时机

场景触发方式
App 冷启动onCreate 中调用
MQTT 断连重连onConnected 回调中调用
用户手动下拉刷新主动触发

不需要定时轮询 — 在线期间 MQTT 推送已覆盖实时性。

请求

GET /api/v1/sync/changes?since={timestamp_ms}&limit=100
Authorization: Bearer {token}
参数类型必填说明
sinceLong是毫秒时间戳,首次传 0 拉取全量
limitInt否每页条数,默认 100

服务端处理逻辑

1. 从 JWT 提取 userId
2. 查询该用户参与的所有账本 ID 列表
3. 查询这些账本下 updated_at > since 的 book、bill、category
4. 按 updated_at ASC 排序,最多返回 limit 条
5. 已软删除的记录只返回 { _id, action: "delete" }
6. 返回结果 + 服务端当前时间 + 分页标记
-- 伪 SQL(bill 为例,category 同理)
SELECT * FROM bill
WHERE book_id IN ({用户参与的账本IDs})
  AND updated_at > {since}
ORDER BY updated_at ASC
LIMIT {limit}

响应

{
  "code": 0,
  "data": {
    "books": [
      {
        "_id": "book_xid_1",
        "action": "update",
        "name": "家庭账本",
        "crt_user_id": "user_a",
        "members": ["user_a", "user_b"],
        "upd_time": "2026-02-18T06:00:00Z"
      },
      {
        "_id": "book_xid_2",
        "action": "delete"
      }
    ],
    "bills": [
      {
        "_id": "bill_xid_1",
        "action": "add",
        "book_id": "book_xid_1",
        "money": 50.0,
        "type": 1,
        "category_id": "cat_xid_1",
        "crt_user": "user_b",
        "time": "2026-02-18",
        "upd_time": "2026-02-18T05:30:00Z"
      },
      {
        "_id": "bill_xid_3",
        "action": "delete"
      }
    ],
    "categories": [
      {
        "_id": "cat_xid_1",
        "action": "add",
        "book_id": "book_xid_1",
        "type": 1,
        "name": "午餐",
        "level": 1,
        "index": 0,
        "upd_time": "2026-02-18T05:00:00Z"
      },
      {
        "_id": "cat_xid_2",
        "action": "delete"
      }
    ],
    "server_time": 1739858400000,
    "has_more": true,
    "next_since": 1739855000000
  }
}
字段说明
has_moretrue 表示还有更多变更,需要继续翻页
next_since下一页的 since 值(本页最后一条的 updated_at 毫秒值)
server_time服务端当前时间戳,最后一页时客户端用此值更新 lastSyncTime

action 判定规则(服务端)

条件action
deleted = true"delete"
created_at = updated_at"add"
其他"update"

客户端对 add 和 update 均执行 upsert,所以 add/update 判定不精确不影响正确性。 delete 必须准确。

客户端处理

冲突策略(服务端优先):增量拉取时,服务端数据直接覆盖本地,包括本地 synced=0 的记录。 基于单写者模型,同一条记录的创建人是唯一修改者,因此不会出现两台设备对同一条记录各自存在未同步修改的情况,服务端版本即权威版本。

suspend fun pullAllChanges() {
    var since = dataStore.lastSyncTime  // 首次为 0(拉取全量)
    do {
        val resp = apiServer.getChanges(since, limit = 100)
        if (!resp.isSuccess) return

        val data = resp.data

        for (book in data.books) {
            when (book.action) {
                "delete" -> bookDao.hardDelete(book.id)
                else     -> bookDao.upsert(book.toEntity().copy(synced = SYNCED))
            }
        }
        for (bill in data.bills) {
            when (bill.action) {
                "delete" -> billDao.hardDelete(bill.id)
                else     -> billDao.upsert(bill.toEntity().copy(synced = SYNCED))
            }
        }
        for (category in data.categories) {
            when (category.action) {
                "delete" -> categoryDao.hardDelete(category.id)
                else     -> categoryDao.upsert(category.toEntity().copy(synced = SYNCED))
            }
        }

        since = data.nextSince ?: data.serverTime
    } while (data.hasMore)

    dataStore.lastSyncTime = since
}

幂等性

  • upsert:多次执行结果一致
  • hardDelete:删除不存在的记录不报错
  • MQTT 推送和增量拉取可能重叠(同一条数据两个渠道都收到),upsert 天然去重
  • lastSyncTime 在全部处理成功后才更新,失败则下次从断点重拉

典型场景

18:00  小明坐地铁,手机没信号,MQTT 断开
18:30  小红记了 3 笔账单(晚餐、水果、打车)
19:00  小红改了"晚餐"的金额
19:30  小红删了"水果"
       ↑ 这 5 次 MQTT 推送全部丢失

20:00  小明到家,打开 App
       → pullAllChanges(since=上次同步时间)
       → 服务端返回最新快照(不是操作历史):
         - ADD  晚餐(已含改后金额)
         - ADD  打车
         - DELETE 水果
       → 小明数据追平

5. MQTT 推送通知

5.1 职责

MQTT 仅做下行通知通道:

  • 客户端只 subscribe,不 publish
  • 上行全部走 HTTP
  • 推送携带完整实体元数据,客户端收到后直接写入 Room
  • 图片二进制文件不经过 MQTT,通过 HTTP 单独传输(见第 8 节)

5.2 主题

heji/notify/{userId}/book      # 账本变更通知
heji/notify/{userId}/bill      # 账单变更通知
heji/notify/{userId}/category  # 账单类型变更通知
heji/notify/{userId}/image     # 图片变更通知

5.3 消息格式

{
  "id": "bt0j9l2s5bo37fcla7q0",
  "type": "ADD_BILL",
  "book_id": "book_xid",
  "content": "{...实体元数据 JSON,不含二进制字段...}",
  "timestamp": 1708234567890
}

5.4 消息类型

主题消息类型
heji/notify/{userId}/bookADD_BOOK, UPDATE_BOOK, DELETE_BOOK
heji/notify/{userId}/billADD_BILL, UPDATE_BILL, DELETE_BILL
heji/notify/{userId}/categoryADD_CATEGORY, UPDATE_CATEGORY, DELETE_CATEGORY
heji/notify/{userId}/imageIMAGE_UPLOADED, IMAGE_DELETED

5.5 ACL

# 客户端(只订阅自己的下行主题)
user:{userId}:
  subscribe: heji/notify/{userId}/#

# 服务端(只发布到下行主题)
user:server:
  publish: heji/notify/#

6. 客户端实现规范

6.1 架构

┌──────────────────────────────────────────────────────┐
│                                                      │
│   ┌────────┐     ┌──────────┐     ┌──────────────┐   │
│   │   UI   │────►│  Room DB │────►│ SyncTrigger  │   │
│   │ (CRUD) │     │ (本地优先) │     │ (Flow 观察)  │   │
│   └────────┘     └──────────┘     └──────┬───────┘   │
│                                         │            │
│                                  HTTP API 调用        │
│                                         │            │
│                                  ┌──────▼───────┐    │
│                                  │  ApiServer   │    │
│                                  │  (Retrofit)  │    │
│                                  └──────────────┘    │
│                                                      │
│   ┌──────────────┐     ┌─────────────────────────┐   │
│   │  MqttClient  │────►│ SyncReceiver            │   │
│   │ (subscribe)  │     │ → Handler → Room upsert │   │
│   └──────────────┘     └─────────────────────────┘   │
│                                                      │
└──────────────────────────────────────────────────────┘

6.2 SyncTrigger — 本地变更 → HTTP 上传

观察 Room 中 synced != 1 的数据,通过 HTTP 上传到服务端。 debounce(300ms) 避免快速连续操作触发高频 HTTP 请求:

fun observeAndSync() {
    billDao.flowNotSynced()
        .distinctUntilChanged()
        .debounce(300)
        .collect { bills ->
            for (bill in bills) {
                val result = when {
                    bill.deleted == DELETED   -> apiServer.deleteBill(bill.id)
                    bill.synced == NOT_SYNCED -> apiServer.upsertBill(bill)
                    else -> continue  // 不应出现,跳过
                }
                when {
                    result.isSuccess     -> {
                        if (bill.deleted == DELETED) billDao.hardDelete(bill.id)
                        else billDao.updateSyncStatus(bill.id, SYNCED)
                    }
                    result.isConflict    -> billDao.updateSyncStatus(bill.id, SYNCED)  // 409:幂等成功
                    result.isNotFound    -> billDao.hardDelete(bill.id)               // 404:本地清除
                    result.isClientError -> { /* 400/403:记录日志,不重试 */ }
                    // 其他网络错误:保持 synced=0,Flow 下次自动重试
                }
            }
        }
}

Category 同理,categoryDao.flowNotSynced() 观察并上传。

flowNotSynced SQL

SELECT * FROM bill WHERE synced != 1

6.3 SyncReceiver — MQTT 通知 → 写入本地

fun onNotification(topic: String, message: SyncMessage) {
    when (message.type) {
        "ADD_BILL", "UPDATE_BILL" -> {
            val bill = Json.decodeFromString<Bill>(message.content)
            billDao.upsert(bill.copy(synced = SYNCED))
        }
        "DELETE_BILL" -> {
            billDao.hardDelete(message.content)
        }
        "ADD_CATEGORY", "UPDATE_CATEGORY" -> {
            val category = Json.decodeFromString<Category>(message.content)
            categoryDao.upsert(category.copy(synced = SYNCED))
        }
        "DELETE_CATEGORY" -> {
            categoryDao.hardDelete(message.content)
        }
        // book、image 同理
    }
}

6.4 启动同步流程

App 启动:
  1. pullAllChanges()       ← 增量拉取,补回离线期间的变更
  2. SyncTrigger.start()    ← 开始观察本地未同步数据并上传
  3. MqttClient.connect()   ← 连接 MQTT,开始接收实时推送

MQTT 重连时:
  1. pullAllChanges()       ← 补回断连期间的变更

6.5 lastSyncTime

// DataStore 持久化
var lastSyncTime: Long  // 毫秒时间戳,取自服务端 server_time
                        // 首次安装为 0,触发全量拉取

7. 服务端实现规范

7.1 HTTP Controller — 持久化后推送

以创建账单为例(Category Controller 遵循相同模式):

func (c *BillController) CreateBill(ctx *gin.Context) {
    userID := getUserID(ctx)
    bill, err := c.BillUsecase.CreateBill(ctx, userID, &req)
    if err != nil {
        errorResponse(ctx, err)
        return
    }

    // 持久化成功后,推送通知给其他账本成员
    c.SyncService.NotifyBookMembers(bill.BookID, userID, NotifyMessage{
        Type:    "ADD_BILL",
        BookID:  bill.BookID,
        Content: toJSON(bill),
    })

    ctx.JSON(201, successResponse(bill))
}

规则:必须先持久化成功,再推送 MQTT。推送失败不影响 HTTP 响应。

7.2 SyncService — 纯推送

type SyncService struct {
    mqttClient mqtt.Client
    bookRepo   domain.BookRepository
}

func (s *SyncService) NotifyBookMembers(bookID, senderID string, msg NotifyMessage) {
    book, err := s.bookRepo.GetByID(ctx, bookID)
    if err != nil {
        log.Errorf("Failed to get book %s: %v", bookID, err)
        return
    }
    entity := entityFromType(msg.Type)
    for _, memberID := range book.Members {
        if memberID != senderID {
            topic := fmt.Sprintf("heji/notify/%s/%s", memberID, entity)
            s.mqttClient.Publish(topic, 1, false, toJSON(msg))
        }
    }
}

规则:

  • 服务端不订阅任何 MQTT 主题,只做 Publish
  • 推送失败静默处理(客户端有增量拉取兜底)
  • 排除 senderID,不推送给操作发起人

7.3 软删除

服务端 DELETE 接口改为软删除(设置 deleted=true, updated_at=now()),不做物理删除。 增量拉取接口依赖 deleted 字段来发现已删除的记录。 适用于:Book、Bill、Category(Image 由文件存储层管理,不做软删除)。

7.4 数据库索引

表索引用途
bill(book_id, updated_at)增量拉取按账本过滤+时间排序
book(updated_at)增量拉取按时间排序
category(book_id, updated_at)增量拉取按账本过滤+时间排序

8. 图片同步

图片同步分两步:文件传输走 HTTP,元数据通知走 MQTT。 MQTT 消息中 content 仅包含元数据(image_id、bill_id、online_path 等),不含二进制图片数据。

8.1 上传

1. 本地保存图片文件 + Room 记录 (synced=0)
2. HTTP POST /api/v1/images/upload → 服务端返回 onlinePath
3. 本地更新 onlinePath, synced=1
4. 服务端推送 MQTT IMAGE_UPLOADED 通知给其他成员
   content: { "image_id": "xx", "bill_id": "xx", "online_path": "xx" }
5. 其他成员收到通知 → HTTP GET /api/v1/images/:id 下载图片

8.2 删除

1. 本地 deleted=1, synced=0
2. SyncTrigger → HTTP DELETE /api/v1/images/:id
3. 成功 → 硬删除本地记录
4. 服务端推送 IMAGE_DELETED 通知 → 其他成员删除本地缓存

9. ID 生成

端方式说明
客户端Xid20 字符 base32,基于时间戳+随机值,无需中心协调
服务端接受客户端 ID唯一性校验,冲突返回 409,客户端视为幂等成功

10. 离线与重试

场景行为
无网络时 CRUD正常操作本地 Room,数据标记 synced=0
HTTP 上传失败(网络错误)保持 synced=0,Flow + debounce 退避后自动重试
HTTP 返回 4xx(客户端错误)根据错误码决定是否重试(见 §4.1 错误码表)
网络不可用跳过 HTTP 调用,避免无效请求
网络恢复Flow 自动触发上传 + pullAllChanges() 拉取缺失变更