外观
CodeNote App 离线功能方案
版本:v1.1 范围:Android 客户端全功能离线能力设计(离线可用、离线缓存、联网同步、上传下载) 核心要求:全链路管控、全功能覆盖、扩展性强、适配全部
1. 目标与设计原则
| 原则 | 含义 |
|---|---|
| 全链路管控 | 从「数据产生 → 本地落库 → 离线可用 → 联网同步 → 服务端确认」每一条链路都有明确状态与兜底,不允许数据产生后丢失 |
| 全功能覆盖 | 每个功能模块都明确「离线可用 / 降级可用 / 必须联网」三档定义,不留模糊地带 |
| 扩展性强 | 同步协议按 entityType 注册表驱动,新增实体 = 注册 + 实现 handler,不改协议骨架 |
| 适配全部 | 适配匿名用户(设备级)、登录用户(账号级)、多账号/多设备、弱网/断网/恢复联网全场景 |
统一心智模型:
用户操作 → 本地优先写入(Room,带 syncStatus)→ 界面即时反馈(乐观 UI)
→ 联网时后台同步(push → pull)→ 冲突按策略解决 → 最终一致离线 ≠ 功能不可用;离线 = 本地可用 + 延迟一致。只有强校验/强实时类功能才必须联网。
2. 现状盘点
2.1 客户端已实现
| 能力 | 位置 | 说明 |
|---|---|---|
| 本地数据库 | core/data/.../CodeNoteDatabase.kt | Room,11 实体:categories / qrcodes / scan_records / assets / checkin_activities / checkin_records / passes / album_photos / orgs / org_members / notifications |
| 同步状态机 | 各实体 syncStatus | PendingCreate / PendingUpdate / PendingDelete / Synced,DAO 提供 getUnsynced() |
| 同步管理器 | core/data/sync/SyncManager.kt | pushLocalChanges() / pullChanges() / fullSync() |
| 同步调度 | SyncScheduler.kt + SyncWorker.kt | WorkManager 30 分钟周期(联网约束)+ 手动触发;先 push 后 pull |
| 手动同步页 | feature/sync/SyncScreen.kt | 上次同步时间展示、手动触发 |
| 本地登录态 | TokenStore / UserProfileStore / DeviceUserManager | token、用户资料、设备标识本地持久化 |
| 文件上传 | core/data/.../FileManager.kt | OSS STS 直传 + 压缩 + 重试 + 服务端兜底 + 远程删除 |
| 配额缓存 | QuotaConfig | 本地缓存配额/额度 |
2.2 服务端已实现
| 接口 | 说明 |
|---|---|
GET /api/sync/pull?since= | 增量拉取:qrCodes / categories / scanRecords + serverTime |
POST /api/sync/push | 推送本地变更,支持 QR_CODE / CATEGORY / SCAN_RECORD,返回冲突列表 |
GET /api/sync/conflicts / POST /api/sync/resolve | 冲突查询与解决(acceptServer) |
| 冲突策略 | LWW(Last Writer Wins),默认服务端优先 |
| 表结构 | sync_mapping(本地 id ↔ 服务端 id)、unsynced_records、token_blacklist |
2.3 现状缺陷(必须修复)
| # | 缺陷 | 影响 |
|---|---|---|
| 1 | 契约不一致:客户端 push 发送 entityType="qr_code"(小写),服务端匹配 "QR_CODE"(大写) | 除分类外推送全部落 else → Unknown entity type,数据同步不上去 |
| 2 | pull 契约不一致:服务端返回 Map(qrCodes/categories/scanRecords),客户端按 List<SyncConflictResponse> 解析 | 增量拉取不可用 |
| 3 | 实体覆盖不全:assets / checkin_records / album_photos 客户端已带 syncStatus 且 push 已发送,服务端不处理;org / notification / pass 未接入 | 数据仅本地存在,多设备/换机丢失 |
| 4 | 无上传队列持久化 | 弱网上传成功率低,无断点续传 |
| 5 | 无同步结果可观测 | 用户无法感知数据是否已同步 |
| 6 | 同步时机粗糙 | 仅 30 分钟周期 + 手动 |
3. 安装时预置数据(本地数据基本要求)
3.1 原则
- 业务数据不预置(账号隔离);预置的是「运行时骨架 + 静态资源」
3.2 预置清单
| 类别 | 内容 |
|---|---|
| 数据库 Schema | Room 全部表结构 + 版本迁移脚本 |
| 默认分类 | 若产品需要,预置并标记 isPreset(或首次登录 pull 下发,二选一) |
| 静态资源 | 隐私政策/用户协议/关于页、引导图、WebView 本地页、主题资源 |
| 配置模板 | 默认主题、设置默认值、上传限制默认值(可被服务端覆盖) |
| 同步锚点 | lastSyncTime = 0(首次联网自动全量拉取) |
3.3 本地数据基本要求(运行时)
| 要求 | 说明 |
|---|---|
| 本地为主 | 用户可产生的数据先落 Room(含 syncStatus),界面只读本地 |
| 写队列 | 未同步数据 = 隐式持久化队列(syncStatus != Synced) |
| 容量控制 | 本地缓存设上限(缩略图 LRU、扫描记录归档),防存储膨胀 |
| 账号隔离 | 数据按 ownerId(用户)/deviceId(匿名)分区,切换账号不串数据 |
| 加密 | token 等敏感数据加密存储(EncryptedSharedPreferences,必要时 SQLCipher) |
4. 核心处理模型(全链路)
一句话模型:「本地产生 → 打标入队 → 联网分发 → 服务端/OSS 处理 → 结果回写 → 归档」
4.1 统一处理链路(同步 / 上传 / 下载)
4.1.1 同步链路(业务数据)
来源:本地 Room 未同步数据(syncStatus != Synced,按 entityType 分组)
│
▼ 请求:POST /api/sync/push(SyncPushItem[]:entityType/entityLocalId/operation/data/updatedAt)
发往:服务端 SyncService(按 entityType 分派 handler)
│
▼ 处理:upsert/delete 落正式业务表 + sync_mapping 维护(本地id↔remoteId)
结果:成功 / 冲突列表(SyncConflictResponse)
│
▼ 回写:本地标记 Synced + 写入 remoteId;冲突进冲突表待用户决策
归档:服务端业务表 + sync_mapping;本地 syncStatus=Synced + lastSyncTime 更新反向(拉取):
来源:服务端增量(updatedAt >= lastSyncTime)
│
▼ 请求:GET /api/sync/pull?since=lastSyncTime
发往:SyncService.pull()
│
▼ 处理:按实体查询增量 → 组装响应
结果:增量数据列表 + serverTime
│
▼ 回写:本地 upsert(LWW:按 updatedAt 比较)→ 覆盖/保留
归档:本地 lastSyncTime = serverTime(断点续拉锚点)4.1.2 上传链路(文件 → OSS)
来源:本地图片/文件(相机拍摄、相册选择、头像裁剪)
│
▼ 请求:POST /api/files/sts-token → 获取 OSS STS 临时凭证
发往:阿里云 OSS(STS 直传,分片/put,含压缩与指数重试)
│
▼ 处理:OSS 落对象(bucket: codenote-server / 前缀 codenote/)
结果:OSS URL(https://codenote-server.oss-cn-guangzhou.aliyuncs.com/...)
│
▼ 回写:POST /api/files/callback 通知服务端登记文件;业务接口(相册/资产/头像)更新 url 字段
归档:本地 upload_tasks 标记完成 + url 写入实体 + syncStatus 流转;服务端 files 表登记4.1.3 下载链路(OSS → 本地缓存)
来源:OSS URL / 服务端返回的 url 字段
│
▼ 请求:缩略图/原图 GET(OSS 直读或 /api/files/signed-url 签名 URL)
发往:OSS(CDN/直连)
│
▼ 处理:Coil/Glide 磁盘缓存(LRU)
结果:本地文件
│
▼ 回写:缓存命中(离线可读)
归档:磁盘缓存 LRU 淘汰;本地数据库存 url 引用,不存文件本体4.2 三大数据端
| 端 | 载体 | 内容 | 一致性角色 |
|---|---|---|---|
| 本地数据 | Room 11 实体 + DataStore(token/资料/偏好)+ 本地文件(图片缓存、上传队列) | 全量可离线数据 + 待同步队列 + 登录态 | 主写端(用户操作先落本地) |
| 服务器数据 | MySQL ~40 表(users/qr_codes/categories/scan_records/assets/checkin/pass/album/org/rbac/notifications/sync_mapping/unsynced_records…) | 账号权威数据、跨设备共享、RBAC 权限、通知 | 权威端(最终一致目标、冲突仲裁) |
| OSS 数据 | 阿里云 OSS bucket codenote-server,前缀 codenote/ | 图片/头像/文件二进制 | 文件权威端(URL 为引用,业务表存 url) |
端间关系:
本地(写) ──push──▶ 服务器(权威) ──pull──▶ 本地(读)
本地(文件) ──STS直传──▶ OSS(权威) ──url 登记──▶ 服务器(files 表/业务表)
OSS(读) ──缓存──▶ 本地(缩略图/原图)4.3 请求功能全景(请求什么 → 核心处理)
按功能域归类(以
ApiService+RepositoryImpls现状为准)。标 ⚡ = 必须联网,🟡 = 可离线入队,✅ = 本地闭环。
认证域(⚡ 全部联网)
| 请求 | 核心处理 |
|---|---|
| login / phone-login / phone-password-login / email-login / one-click-login / device-login | 服务端验凭据 → 签发 token/refreshToken → 本地 TokenStore 持久化 + 拉取用户资料 + 首次 fullSync |
| register | 创建账号 → 同上 |
| refresh | 刷新 token(401 时自动续期) |
| logout | 服务端注销会话 + 本地清 token/资料/会话 |
| 短信/邮件验证码(send/bind/link/forgot/change) | 服务端下发验证码(运营商/邮件通道) |
| 绑定/解绑(phone/email) | 服务端校验验证码 → 更新 user_identities |
| 密码(set/reset/change) | 服务端校验并更新凭据 |
| 会话管理(sessions CRUD/switch/remove) | 多端会话生命周期;切换 = 本地换 token + 拉资料 |
| 账号链接/切换(link/switch) | 多账号关联;切换后本地数据按 userId 隔离 |
| 设备管理(devices/remove) | 服务端登记/解绑设备(配合 device-login) |
| quotas | 配额下发 → 本地 QuotaConfig 缓存(🟡 离线可用缓存值) |
用户域
| 请求 | 核心处理 |
|---|---|
| userinfo GET/PUT | 拉取/更新资料 → 本地 UserProfileStore 同步(🟡 本地已缓存可离线看) |
| current-org / username | 更新用户偏好/标识 → 本地同步 |
二维码/分类域(✅ 离线为主 + 同步)
| 请求 | 核心处理 |
|---|---|
| categories CRUD/排序/置顶/树 | 本地写(Pending*)→ push 同步;列表本地读 |
| qr_codes CRUD/收藏/置顶/排序/相邻 | 本地写(Pending*)→ push 同步;列表/搜索本地读 |
扫描域
| 请求 | 核心处理 |
|---|---|
| 扫码记录 create(本地) | 本地落 scan_records(PendingCreate)→ push 同步(🟡) |
| 记录列表 | 本地读 |
资产域(🟡 已缓存可读,写本地待同步)
| 请求 | 核心处理 |
|---|---|
| assets CRUD/转移/报废/导出/地图 | 服务端处理;客户端本地缓存 + 变更打标(部分已带 syncStatus) |
| 资产分类/字段定义/盘点 | 服务端处理 + 本地缓存 |
活动/签到/通行证域
| 请求 | 核心处理 |
|---|---|
| activities CRUD/状态 | 服务端(⚡ 活动实时性);已缓存可读 |
| check-in 提交 | 本地落 checkin_records(PendingCreate)→ push(🟡),重复提交防抖(活动+用户唯一) |
| pass 签发/核验 | 签发服务端(⚡);核验必须联网(防伪时效校验);本地通行证可展示(🟡) |
相册域(🟡 上传入队)
| 请求 | 核心处理 |
|---|---|
| 照片列表 | 本地读 + 服务端拉取合并 |
| 上传 | savePhotoLocally(本地文件)→ upload_tasks 队列 → OSS → callback → 更新 url(🟡) |
| 删除 | 本地 PendingDelete → 同步删 OSS + 服务端(🟡) |
| syncPhoto | 单张照片补同步 |
组织域(🟡 缓存可读,操作需联网)
| 请求 | 核心处理 |
|---|---|
| orgs CRUD/加入/退出/转让/统计/二维码 | 服务端权威(⚡ 撮合/权限实时性);本地 orgs/org_members 缓存 |
| 成员/审批/邀请 | 服务端(⚡);缓存可读 |
| 组织 RBAC 角色 | 服务端权限表(⚡ 权限判定在服务端) |
| 位置/分享 | 服务端 + 本地缓存 |
通知域(❌ 实时推送需联网,缓存可读)
| 请求 | 核心处理 |
|---|---|
| 列表/未读数/总数 | 服务端 + 本地 notifications 表缓存(🟡 已缓存离线可读) |
| WS 实时推送 | 联网在线收;离线错过 → 恢复联网 pull 补齐 |
同步域
| 请求 | 核心处理 |
|---|---|
| sync pull/push/conflicts/resolve | 见 4.1.1 |
文件域
| 请求 | 核心处理 |
|---|---|
| sts-token | 签发 OSS 临时凭证(上传前必调,⚡) |
| upload(服务端兜底) | 直传失败时走服务端中转上传(⚡) |
| callback | 上传后通知服务端登记 files 表 |
| signed-url / files DELETE | 签名下载 / 删除远程对象 |
系统域
| 请求 | 核心处理 |
|---|---|
| app/update/check | 版本检查(⚡,离线跳过) |
| login-records | 登录记录上报 |
4.4 状态机全集
S1 业务数据同步状态机(客户端 Room)
┌──────────────┐
新建 ───▶ │ PendingCreate│──┐
修改 ───▶ │ PendingUpdate│──┤ push 成功 ──▶ Synced
删除 ───▶ │ PendingDelete│──┘ │
└──────────────┘ │ 失败/断网
▲ ▼
└──── 保持 Pending,指数退避重试(幂等)- 删除 = 软删除标记(deleted=1 + PendingDelete),服务端确认后物理清理
- 每行带
updatedAt+remoteId(sync_mapping 映射)
S2 文件上传状态机(客户端 upload_tasks)
Pending(待传) ──▶ Uploading(上传中) ──▶ Uploaded(已传 OSS) ──▶ Notified(已通知服务端登记) ──▶ Done(业务字段更新,归档)
│ │ │
└────── Failed ────┴── retry < N ────────┘
(持久化,恢复联网续传;超过 N 次转人工/降级服务端兜底上传)S3 登录态状态机(客户端 IdentityManager)
LoggedOut ──登录/注册──▶ LoggedIn(账号)
│ │
└──设备登录──▶ Anonymous(设备用户) ──绑定/登录──▶ LoggedIn
▲ │
└────────── logout / 会话失效 ◀───────────────┘
切换账号/链接账号:LoggedIn(uidA) ⇄ LoggedIn(uidB),本地数据按 ownerId 隔离S4 冲突处理状态机(同步)
无冲突 ──检测──▶ 冲突
│ ├─ 自动可解(LWW:updatedAt 新者胜 / 本地删优先)──▶ 归档
│ └─ 需用户决策(本地改 vs 服务端删等)──▶ conflicts 列表 ──resolve(acceptServer)──▶ 归档S5 组织邀请/审批状态机(服务端)
PENDING(待处理) ──接受/批准──▶ APPROVED(成员生效:绑 MEMBER 角色)
│
└──拒绝/取消──▶ REJECTED / CANCELLEDS6 通知状态机
Unread ──已读──▶ Read;离线收到(pull 补齐)默认 Unread,联网实时推送即时更新未读数S7 服务端离线记录状态机(unsynced_records / sync_mapping)
客户端 push 到达 → 落正式表 + sync_mapping(本地id↔remoteId)
未匹配(重复/脏数据)→ unsynced_records 登记 → 人工/自动修复4.5 归档机制
| 层 | 归档内容 | 归档方式 |
|---|---|---|
| 本地 | 已同步数据、上传任务、同步锚点 | syncStatus=Synced、upload_tasks 标记 Done、lastSyncTime 更新;LRU 缓存淘汰;软删除物理清理 |
| 服务端 | 业务数据 + 映射 | 正式业务表 + sync_mapping;unsynced_records 登记异常;admin_logs 审计 |
| OSS | 文件对象 | 对象 + 生命周期规则(可选:按保留期清理未引用对象) |
5. 离线功能矩阵
✅ 完全离线可用 | 🟡 降级可用 | ❌ 必须联网
| 功能模块 | 档位 | 说明 |
|---|---|---|
| 本地登录态维持 | ✅ | token 本地,断网不登出;首次登录需联网 |
| 二维码生成/扫描 | ✅ | 本地闭环;记录待同步 |
| 分类管理 | ✅ | 本地闭环 |
| 水印相机 | ✅ | 拍照+水印本地合成 |
| 相册浏览 | ✅ | 本地照片/缩略图 |
| 相册上传/原图下载 | 🟡 | 上传入队;原图下载需联网 |
| 签到 | 🟡 | 动作本地记录;活动实时状态需联网 |
| 通行证 | 🟡 | 已下发可展示;核验必须联网 |
| 资产 | 🟡 | 缓存可看;写操作待同步 |
| 组织 | 🟡 | 缓存可浏览;加入/邀请/变更需联网 |
| 通知 | ❌→🟡 | 实时推送需联网;缓存列表可读,恢复联网 pull 补齐 |
| 活动 | 🟡 | 缓存可看;报名/更新需联网 |
| 登录/注册/验证码 | ❌ | 强联网 |
| 账号切换 | 🟡 | 本地多账号可切;未缓存账号需联网 |
| 分享链接/文件下载 | ❌ | 需联网 |
判定标准(新功能照此归类):
- 数据只在本机产生、服务端仅备份 → ✅ 离线可用 + 待同步
- 需要服务端校验(核验/防伪/权限实时性)→ ❌(或 🟡 先记后验)
- 需要他人数据(成员/通知/组织状态)→ ❌ 需联网,但缓存上次结果降级 🟡
- 强实时(推送/实时状态)→ ❌
6. 离线数据处理(写入、冲突、恢复)
6.1 统一状态机(见 4.4 S1)
6.2 冲突策略(见 4.4 S4,LWW 增强)
| 场景 | 策略 |
|---|---|
| 同字段不同值 | 取 updatedAt 较新者;相等服务端优先 |
| 本地删 vs 服务端改 | 本地删优先(显式删除意图) |
| 本地改 vs 服务端删 | 冲突列表,用户决策 |
| 新增撞唯一键 | 按 clientId 映射复用服务端 id 后转 UPDATE |
6.3 匿名用户数据
- 匿名产生:
ownerId=0+deviceId标识,本地正常使用 - 登录/绑定后:归属迁移(ownerId 0→userId),并入账号同步链
6.4 数据完整性兜底
- 同步失败不丢数据(Pending 保持 + 退避重试)
- Room 完整性校验失败:备份损坏文件 → 重建库 → 全量 pull
- pull 成功写
lastSyncTime,支持断点续拉
7. 联网后同步与上传下载
7.1 触发时机
| 时机 | 方式 |
|---|---|
| 网络恢复 | ConnectivityManager 监听 → 立即触发 |
| App 进入前台 | 触发一次(节流 ≥5 分钟) |
| 登录成功/切换账号 | fullSync(先 push 匿名数据再 pull 全量) |
| 周期任务 | WorkManager 30 分钟(保留) |
| 关键写操作后 | 轻量单类型 push(扫码/签到) |
| 手动 | 同步页「立即同步」 |
7.2 同步顺序(固定管道)
上传待传文件 → push 本地变更(按 entityType 分组)→ pull 增量(since)→ 冲突处理 → 更新 lastSyncTime7.3 上传下载(见 4.1.2 / 4.1.3)
- 上传队列
upload_tasks表 + 后台消费 + 持久化失败 + 断点续传(OSS 分片) - 缩略图 LRU 缓存;原图按需下载;配额本地缓存预检
7.4 同步结果可观测
- 同步页:上次同步时间、各 entityType 待同步数、失败原因、冲突列表(含解决入口)
8. 绕不过去的功能(必须联网)的处理
| 功能 | 为什么绕不过 | 处理策略 |
|---|---|---|
| 登录/注册/验证码 | 身份认证必须服务端 | 离线引导登录页 + 提示联网;已登录用户离线不受影响 |
| 短信/一键登录 | 运营商通道服务端接入 | 同上 |
| 通行证核验 | 防伪/时效校验服务端 | 离线展示本地通行证;核验提示联网 |
| 组织加入/邀请 | 服务端撮合 + 权限校验 | 缓存可看,操作提示联网 |
| 通知实时推送 | WS/推送服务 | 离线收不到实时;恢复联网 pull 补齐 |
| 分享到组织 | 组织存在性与权限 | 本地生成分享图 ✅;同步到组织需联网 |
| 原图下载 | 文件在 OSS | 缩略图离线可看,原图提示联网 |
| 会员/支付(后续) | 资金订单服务端闭环 | 下单必联网;离线仅展示缓存 |
通用降级原则:❌ 功能页面必须给「网络不可用」提示 + 重试入口;已缓存的查看类能力不因离线而不可用。
9. 扩展性设计
9.1 同步协议注册表
服务端 SyncService.push / Android SyncManager.pushLocalChanges
= when(entityType) 分派
新增实体只需:
1. 两端注册 entityType 常量(统一大写,如 "QR_CODE",消除大小写不一致)
2. 服务端 handler(userId, item):upsert/delete + sync_mapping 维护
3. 客户端 DAO getUnsynced + SyncPushItem 构建
4. pull 增量查询 + 响应解析(两端 DTO 对齐)9.2 协议版本
/api/sync/pull?since=&version=1,服务端按 version 决定响应字段- 客户端最低兼容版本校验,不兼容提示升级
9.3 接入 Checklist(新实体)
- [ ] Room 实体带
syncStatus/updatedAt/remoteId/ownerId - [ ] 两端 entityType 常量一致(大写全链路唯一)
- [ ] 服务端 pull 增量 + push handler + 冲突处理
- [ ] 客户端 push 构建 + pull 解析 + 待同步数展示
- [ ] 离线三档归类写入矩阵(§5)
10. 实施路线
| 阶段 | 内容 | 验收标准 |
|---|---|---|
| P0 打通现状 | 修复缺陷 1/2(entityType 大小写、pull 契约对齐);二维码/分类/扫码记录全链路通 | 断网产生 → 联网后服务端可见 |
| P1 全实体覆盖 | assets/checkin/album_photos 服务端 handler;org/notification/pass 接入同步 | 全部带 syncStatus 实体可同步 |
| P2 上传队列 | upload_tasks + 后台消费 + 断点续传 + 网络恢复触发 | 弱网上传成功率 ≥ 95% |
| P3 体验增强 | 网络恢复自动同步、前台触发、同步结果页/通知、匿名数据归属迁移 | 用户无感同步,数据不丢 |
| P4 扩展治理 | 协议版本化、冲突 UI 完善、容量/加密治理、矩阵文档维护 | 新实体按 Checklist 半小时接入 |
11. 风险与决策点
| 项 | 说明 | 待决策 |
|---|---|---|
| 同步契约重构 | push/pull 契约两端不一致,需协同改 | 直接对齐新契约(推荐,无历史负担) |
| 软删除 | 服务端业务表需支持 deleted 标记 | 统一软删除语义 |
| 通知离线补齐 | 实时推送走 WS;离线靠 pull 增量 | pull 扩展 notifications |
| 容量策略 | 本地缓存上限与清理策略 | 图片/记录保留策略需产品确认 |
| OSS 生命周期 | 未引用对象清理 | 是否启用生命周期规则 |