FieldForce Mobile App Design
FieldForce is the first Flutter app in the Gremlin mobile workspace: an offline-first field-worker client that syncs directly with the existing Go/Postgres/NATS backend via PowerSync — no separate mobile backend. The MVP proves offline-first work cycles, realtime updates when connected, and feature-flagged shift-scoped background location, inside a melos monorepo built for multiple apps from day one.
FieldForce 是 Gremlin 移动端工作区中的第一个 Flutter 应用:一个离线优先的现场工作者客户端, 通过 PowerSync 直接与现有的 Go/Postgres/NATS 后端同步——没有独立的移动后端,也没有手工搭建的实时通道。 该 MVP 验证三件事:离线优先的工作流程、无需轮询的实时更新,以及受功能开关控制的、按班次范围限定的后台定位, 并构建于一个从第一天起就为多应用而设计的 melos monorepo 中。
主导 ADR
本设计受移动端 ADR 集合的约束;在生成代码前请先阅读它们。不值得单独设立 ADR 的约定(始终使用 UTC、提交生成的文件、配置注入、Brand 包装器规则、代码生成纪律、UUIDv7 主键)记录在工作区的 CLAUDE.md 中。
| ADR | 决策 |
|---|---|
| ADR-0037 | 移动客户端选用 Flutter(优于 RN / Capacitor / 原生) |
| ADR-0038 | melos monorepo,多应用,共享 core_* 包 |
| ADR-0039 | 整洁架构 + 向内指向的包依赖 DAG |
| ADR-0040 | 使用 PowerSync 实现离线 + 实时同步 |
| ADR-0041 | Go 签发的 JWT、401 时刷新、离线宽限期、已验证身份门禁 |
| ADR-0042 | 使用 Riverpod 实现依赖注入 + 状态管理 |
| ADR-0043 | 后台定位——三道门激活,遥测绕过 PowerSync 传输 |
| ADR-0044 | 推送通知——FCM + APNs、收到推送即同步、深链路由 |
| ADR-0045 | 密封式 Result<T> + AppFailure 错误建模 |
| ADR-0046 | 使用 slang 实现 i18n |
| ADR-0047 | 使用 Codemagic 实现移动端 CI/CD |
范围与目标
FieldForce 是一款面向在低连接或无连接环境中作业的现场工作者的离线优先移动应用。它的表现如同一个完全本地的应用,在网络允许时透明地与 Gremlin 后端同步,在网络不可用时仍保持可用。该 MVP 验证三种行为:
- 离线优先的工作:工作者可以在无任何连接的情况下查看分配的任务(Task)、创建和编辑任务记录并完成任务,并在重新连接时自动同步且无数据丢失。
- 实时更新:在连接时,后端推送的分配和变更会无需手动刷新或定时轮询地出现。
- 后台定位(按班次限定):在上班期间并经同意后,应用向后端上报位置,并受远程功能开关控制。
成功标准
- 现场工作者能够完全离线地完成完整的任务周期(查看 → 开始 → 更新 → 提交),并在重新连接时正确同步。
- 已连接的设备无需轮询即可实时接收后端驱动的更新。
- 离线创建的记录携带稳定 ID(UUIDv7),在服务器看到之前已在设备端生成。
- 冲突的编辑以服务器为权威进行解决,不丢失也不重复数据。
- 后台定位仅在功能开关 + 操作系统权限 + 班次状态全部满足时运行,并在任一被撤销时干净地停止。
- 应用在各平台(iOS/Android)上具有原生质感,同时保持一致的 Gremlin 品牌。
- 崩溃和错误可远程观测(Crashlytics),无需重现工作者的设备状态。
MVP 范围之外
- 独立的移动后端或设备端的第二个事实来源。
- 手工搭建的离线同步、冲突解决或架构迁移机制(这些由 PowerSync 负责——ADR-0040)。
- 实时的瞬态信号(在线状态、正在输入、实时状态)——推迟到出现具体需求时再做。
- 完整的分析事件分类体系——分析工作开始时仅提供一个带类型的事件接口。
- Shorebird / OTA Dart 代码热更新——以后可用,非 MVP。
- 在第二个应用需要之前构建共享的
feature_*包。 - Digital Worker 功能(独立应用;仅共享
core_*)。
所选方案
针对一个氛围式编码(vibe-coded)、离线优先、运行在与 TypeScript 邻近的 Go 后端上的实时现场应用,考虑了若干跨平台和原生方案(完整理由见 ADR-0037)。
Flutter + PowerSync,melos monorepo,整洁架构
优势:生态系统内聚 → 更少的 bug、更紧凑的开发循环;编译期空安全;两个平台上像素级一致且具原生质感的 UI;PowerSync 将离线+实时+迁移收敛为配置。
代价:Dart 是与 TS monorepo 分离的语言孤岛;客户端与后端之间无类型共享。
已选:生态系统内聚以及 PowerSync 对离线+实时+迁移的收敛,胜过失去 TS 类型共享的代价。
React Native + Expo + WatermelonDB
优势:保持在 TypeScript 内;与后端共享类型;最大的原生模块生态;通过 EAS 实现 OTA。
代价:生态系统动荡 → LLM 拉取不兼容/已废弃的库;离线同步需手工拼装;开发循环内聚性较弱。
对本项目而言,调试成本和离线同步风险胜过语言统一带来的收益。
Capacitor(Web 外壳)
优势:能最快复用现有的 Web UI。
代价:对关键的离线优先 + 最佳体验需求而言,WebView 是最差的选择。
无法满足两项硬性需求。
原生 Kotlin + Swift
优势:最佳的平台集成。
代价:两套代码库、两种语言、最慢交付。
对一个单人/主导的氛围式编码 MVP 而言不划算。
所选设计将所有身份、写入权威和业务逻辑都保留在 Go 后端,Flutter 应用作为离线优先的客户端。PowerSync 是一个薄而可替换的数据同步边界;Go 后端拥有事实。每个 core_* 包都是一个整洁、向内指向的边界,映射后端的六边形架构(ADR-0038、ADR-0039)。
架构
异构数据路径
这是采用离线优先 + 定位的关键后果:并非所有数据都走同一条管道。
| 数据 | 路径 | 实时性 | 离线行为 |
|---|---|---|---|
| 交互式实体(工单、任务) | PowerSync ↔ 本地 SQLite ↔ Postgres | PowerSync 响应式查询 | 离线时本地 SQLite 是来源;重连时上传队列重放 |
| 后台定位遥测 | flutter_background_geolocation 自有队列 → 专用 Go 端点 | 不适用(仅追加) | 引擎自有的离线缓冲;不在 PowerSync 桶中 |
| 事件唤醒(任务分配等) | 来自 Go 后端的 FCM/APNs 推送 | 推送(可在后台存活) | 在重连/唤醒时送达;触发 PowerSync 同步 |
原本统一的 PowerSync 模型对交互式数据成立,但定位遥测是例外——高频的仅追加打点绕过 PowerSync,以避免把桶撑大超过其最佳区间(ADR-0040)。
包的归属
Flutter 工作区在 core_* 包中拥有:
core_models- 领域实体、
Result/AppFailure类型(纯 Dart,零依赖) core_network- API 客户端 + DTO
core_auth- JWT/会话 + 已验证身份
core_database- PowerSync 架构 + 同步接线
core_ui- Brand UI 组件 + 主题
core_i18n- slang i18n 基础设施
core_notifications- FCM/APNs + 深链路由 + 收到推送即同步(ADR-0044)
core_location- 后台定位能力(仅 FieldForce)
Go 后端拥有一切权威性的部分:身份、写入权威、冲突解决、Sync Rules / 桶作用域、uploadData() 目标端点、定位遥测端点,以及所有业务逻辑。
路线图
-
阶段 1 — 离线优先 MVP
基于 PowerSync 的离线优先工作周期、连接时的实时更新、已验证账户门禁、品牌 UI、崩溃上报。后台定位受功能开关控制。包含让定位安全的后端工作:构建
ff_shifts(或等价的 Shift 实体)、上班/下班 API、将活动 Shift 同步到移动端、把 Shift 纳入 Sync Rules + 测试。在 Shift 存在、能同步并通过三道门测试前,生产环境定位保持关闭。 -
阶段 2 — 推送与通知强化
完整的 FCM/APNs 路径,覆盖前台/后台/被杀状态,静默预同步推送,来自通知的深链路由,审批者/任务分配通知。
-
阶段 3 — 定位与遥测成熟化
OEM 省电策略处理、按班次限定的同意流程、通过
flutter_background_geolocation调优地理围栏/活动识别。 -
阶段 4 — 第二个应用(Digital Worker)
随着 Digital Worker(聊天观察者应用)复用共享功能,将其提升为
feature_*包;验证 monorepo 的共享模型。 -
阶段 5 — OTA 与发布速度
评估 Shorebird 用于 Dart 代码热更新,在无需完整重新提交商店的情况下缩短迭代发布周期。
-
阶段 6 — 扩展的现场能力
更丰富的离线媒体采集、条码/BLE 设备集成,以及随数据量增长的高级同步作用域。
安全与权限
- JWT 生命周期:访问令牌 30–60 分钟,刷新令牌 7–30 天。401 时刷新拦截器透明刷新。同一个 Go 签发的 JWT 同时认证 API 和 PowerSync 同步(它驱动 Sync Rules 的桶访问)。
- JWT 来源与声明:沿用现有的 SolidStart / Better Auth 模式。移动端通过相同的认证来源登录(
POST /api/auth/sign-in-email,通过POST /api/auth/refresh刷新;会话形状{ user, session { token, refreshToken, expiresAt } })。JWT 保留标准声明(sub、iss、aud、iat、exp)和自定义声明(userId、roles、organizationId、isActive),并新增来源标记platform: "mobile"。 - 离线宽限期:当访问令牌过期但刷新令牌仍有效时,应用仍可在缓存数据上使用;仅当刷新令牌过期或刷新被明确拒绝时才硬锁定。「能否触达服务器」与「会话是否仍有效」是解耦的。
- 安全存储:令牌仅存放在
flutter_secure_storage(由 Keychain/Keystore 支撑)——绝不用 SharedPreferences。 - 已验证 Gremlin 账户门禁:安全脊梁,通过集中式 go_router 重定向守卫强制执行。在阶段 1,当后端签发的认证状态符合现有 SolidStart 账户模型时用户即为已验证:有效的 JWT/会话、
isActive = true、且platform = "mobile"。应用也可调用GET /api/auth/me刷新用户状态。设备绝不在本地断言验证。 - 同步可见性:MVP 必须确保工作者只看到其被允许看到的记录。细粒度 RBAC Sync Rules 不是阶段 1 的阻塞项;先从最简单安全的桶形状加上移动端可见性过滤开始,同时保持 Go API 写入以服务器为权威。更深入的 RBAC 是后续的强化步骤。
- 功能开关熔断:后台定位能力由一个服务器驱动的开关控制,该开关同时充当远程熔断(见后台定位章节)。
数据、同步与错误模型
PowerSync 负责离线持久性、重连重放、幂等性和实时投递(ADR-0040)。应用负责写入契约和错误语义。状态和依赖注入由 Riverpod 管理(ADR-0042)。
- 读取路径:本地 SQLite ← PowerSync ← Postgres;响应式查询以
Stream<List<T>>呈现;UI 实时更新。 - 写入路径:本地写入 → PowerSync 上传队列 →
uploadData()(Flutter 侧桥接)→ 现有 Go API 端点 → Postgres;以服务器为权威进行冲突解决。
- 操作到端点的映射(阶段 1 要求):必须在实现前为每个同步实体定义。已知映射:任务的创建/更新走基于事件的 PATCH API(ADR-0013),在服务器端保留状态机校验。新实体(如 Shift 上班/下班)需要新的 Go 端点,仅在每个实体引入时才添加。
- 任务状态冲突:ADR-0013 的服务器端任务状态机是任务状态转换的冲突解决者——而非最后写入者胜出(LWW)。LWW 只可应用于非状态字段。如果一个排队的离线任务事件被 Go API 拒绝为非法转换,
uploadData()必须向表现层抛出一个带类型的AppFailure,而不能悄悄丢弃它。 - ID:UUIDv7 由客户端生成,Postgres 与客户端 SQLite 使用相同的主键类型。
- 迁移:客户端不手写迁移——PowerSync 以无架构方式同步,并将架构投影为 SQLite 视图。通过架构定义 + Sync Rules 演进。数据转换逻辑保留在 Go 后端。
- 所有本地状态都在 PowerSync 中——除非有明确理由,否则不设独立的 Drift/sqflite 数据库。
错误处理——密封式 Result<T> + AppFailure
位于 core_models(ADR-0045):
- 对可能有意义地失败的操作,仓储返回
Result<T>。 - 异常被捕获并仅在数据层边界映射为带类型的
AppFailure;领域层和表现层从不try/catch或throw,并进行穷尽式模式匹配。 Result适用于写入路径和非同步的 API 调用;PowerSync 响应式读取以Stream呈现,在没有真正失败模式的地方不进行包装。
后台定位(仅 FieldForce)
core_location 包;引擎 flutter_background_geolocation。Digital Worker 不依赖它。(完整决策见 ADR-0043。)
-
门 1 — 功能开关
平台级熔断(
admin_feature_flags)和按组织的fieldforce.location开关(org_feature_flags,ADR-0016)都必须开启。从专用的 Go API 开关端点读取;本地缓存。未知 = 关闭。它同时是远程熔断。 -
门 2 — 操作系统权限
用户授予的「始终」(Always)定位权限。
-
门 3 — 班次状态
工作者在已同步的本地 SQLite 中有一个活动的 Shift(
ff_shifts或等价物)。Shift 是由 Go 后端签发的、以服务器为权威的实体;移动端的门从 PowerSync 同步的本地 SQLite 读取它。该实体在当前后端中尚不存在——它是阶段 1 的后端要求。在它被实现并同步之前,生产环境的后台定位必须保持功能开关关闭。
- 将开关切换为关闭会干净地拆除前台服务、停止 GPS 并移除追踪通知——而不仅仅是停止上传。
- 开关值被本地缓存,离线时从缓存评估(最后已知值;未知 = 关闭,绝不默认开启)。缓存在认证、应用启动和恢复时刷新。
- 定位上传与 PowerSync 分离——引擎自有的离线队列 → 一个专用 Go 端点(仅追加遥测)。
- Android 在追踪时需要一个带可见通知的前台服务;iOS 需要「始终」权限 + 定位后台模式。
UI、品牌与平台行为
- 品牌令牌通过
ThemeData+ 一个ThemeExtension流转(单一事实来源位于core_ui)。 - Brand 包装组件(
BrandButton、BrandSwitch、BrandTextField、BrandSelect、BrandDatePicker等)——应用代码使用它们,绝不使用原始的Switch/TextField/DropdownButton(CLAUDE.md中的 Brand 包装器规则)。 - 包装器对外壳应用品牌样式,并在原生肌肉记忆重要的地方将交互原语委托给平台适配的控件(iOS 上用 Cupertino,Android 上用 Material)。所有
Platform.isIOS分支都集中在包装器内部。 - i18n:slang(ADR-0046)——带类型的点记法,无需
BuildContext;语言 EN / ZH(简体)/ MS;可从控制器和core_models调用。 - 时间:始终 UTC用于存储/传输;仅在表现层转换;服务器时间为权威。
配置与可观测性
- 非机密配置通过
--dart-define-from-file=config/<flavor>.json。应用在组合根读取配置并注入到各个包中;包绝不直接读取环境变量。真正的机密存放在 Codemagic CI 机密中,绝不进入打包产物。 - 风味(Flavors):dev / staging / prod,各自有独立的 bundle ID 和入口点。
- 崩溃上报:Firebase Crashlytics(Firebase 因 FCM 已存在)。远程可观测性是主要的调试通道——从追踪信息修复,而非复现设备。
- 功能开关:使用现有的 ADR-0016 两层开关基础设施。移动端在认证 / 应用启动 / 恢复时读取一个轻量级开关端点;缓存最后已知值以供离线评估。未知开关值 = 关闭。v1 范围:平台级熔断(
admin_feature_flags)加上按组织的fieldforce.location。MVP 中不做按用户/按角色的定向。不要把org_feature_flags加入 PowerSync——拉取并缓存即足够。
Crashlytics 调试上下文
Crashlytics 捕获崩溃并支持自定义键/日志,但 FieldForce 必须设置自己的非 PII 调试元数据。必需的键:应用风味、应用版本/构建号、platform = "mobile"、操作系统名称/版本、设备型号、组织 ID、用户 ID 哈希(绝不用原始邮箱/姓名/电话)、当前路由/屏幕、活动任务 ID、活动 Shift ID、PowerSync 连接状态、待上传队列计数、最后同步时间戳、定位门状态(功能开关 / 权限 / Shift),以及相关的功能开关。在认证、切换组织、路由变更、同步状态变更、Shift 变更和定位门变更时更新这些键。
错误处理
| 情形 | 行为 |
|---|---|
| 设备离线 | 应用完全在本地 SQLite 上运行;写入在 PowerSync 中排队;除离线指示外不抛出错误。 |
| 离线后重连 | PowerSync 重放上传队列并协调;应用以服务器为权威的冲突解决。 |
| 任务上传被拒 | 如果排队的离线任务事件在重放时已不再有效,显示清晰的说明解释为何同步不再有效,展示最新的服务器版本,将本地提交标记为失败,在适当时允许复查/重新提交,且绝不悄悄删除用户的操作。 |
| 写入冲突 | 在 Go API 的服务器端解决。任务状态转换使用 ADR-0013 状态机;非状态字段冲突可用 LWW。被拒的排队写入以带类型的 AppFailure 抛出,并遵循上述被拒上传的 UX。 |
| 访问令牌过期、刷新令牌有效 | 401 时刷新透明地刷新;用户不受打扰。 |
| 访问令牌与刷新令牌均无效/过期 | 硬锁定到验证/登录;缓存数据不再可信。 |
| 无法刷新(离线) | 离线宽限期:在缓存数据上继续工作,直到刷新令牌过期。 |
| 未验证的 Gremlin 账户 | 路由到验证流程;受限操作被阻止(默认拒绝)。 |
| 收到推送(前台/后台/被杀) | 无头处理器触发 PowerSync 同步;点击时通过 go_router 深链。静默推送尽力而为(iOS 会限流)。 |
| 定位开关关闭 | 拆除前台服务、停止 GPS、移除通知。 |
| 定位权限被拒 | 不启动追踪;向用户呈现状态;无前台服务。 |
| OEM 省电杀手挂起追踪 | 已知的现场设备现实;通过 flutter_background_geolocation + 各 OEM 指南缓解,无法在代码中完全解决。 |
| 仓储操作失败 | 数据层将异常映射为带类型的 AppFailure;控制器进行模式匹配并显示相应的 UI。 |
| 崩溃 | 由 Crashlytics 携带上下文捕获,用于远程诊断。 |
隐私边界
- 定位按班次限定且经同意——仅在上班期间追踪,绝非始终开启;功能开关是一个硬性的远程关闭开关。
- 令牌和敏感数据仅存放在
flutter_secure_storage中。 - Sync Rules 确保设备只同步其自身的用户/团队子集——设备上无跨租户数据。
- 追踪前台服务通知在定位使用期间让其对工作者保持可见。
- MVP 中不依据定位或活动对员工进行绩效评分。
测试(MVP 下限)
- 针对关键用户流程的 Widget 测试(离线工作周期、验证门、品牌组件)。
- 针对 PowerSync 上传路径和 MVP 同步可见性(用户只看到其被允许看到的记录)的集成测试。细粒度 RBAC Sync Rules 推迟到后续强化阶段。
- 共享的
core_*包承载最严格的覆盖率——它们对两个应用都是承重的。 - 定位:针对三道门激活和开关关闭时干净拆除的测试。
- 可观测性:测试或冒烟检查确认 Crashlytics 自定义键在认证、路由变更、同步状态变更、Shift 变更和定位门变更后已设置;验证未将原始 PII 作为键或用户标识发送。
CI/CD
(见 ADR-0047。)
- Codemagic 用于构建、iOS/Android 代码签名和商店分发。
- 基于路径的触发:
apps/fieldforce/**下或其依赖的某个packages/*的变更只构建 FieldForce。 - 后端(Go/TS)仍留在 GitHub Actions 上。
- Shorebird(Dart 代码热更新)推迟到阶段 5。