跳转至

Gerege Wallet

部分完成 · 第 4 层 — 垂直产品 · 仓库:wallet-gerege-mn · wallet.gerege.mn · api.wallet.gerege.mn

公民数字钱包——以 eID 登录、查看余额、通过 IBAN 转账的产品。 金融内核由 Apache Fineract 承担。

手机应用(iOS SwiftUI、Android Compose)是主界面;网页只是辅助控制台。

资金架构——最重要的一条规则

Fineract 是资金的唯一权威来源。 余额、交易与账簿全在那里。PostgreSQL 只保存「公民 ↔ Fineract ID」的映射、幂等记录与用户偏好设置。

负责内容
Wallet 后端(Go) 认证、权限、业务流程、审计
Apache Fineract 1.15 账户、交易、余额、复式记账、账簿
PostgreSQL 仅映射 + 偏好设置——余额绝不存放在此

由此导出三条规则:

  • 不缓存余额。 /accounts/balance 直接从 Fineract 读取——两套系统产生 分歧的条件从根本上就不存在。
  • 不把金额表示为浮点数。 直接把 JSON 中的文本形式转换为 int64 的最小 货币单位(对 ₮ 而言,金额 = ₮×100)。通往舍入误差的路被彻底封住。
  • 每笔交易都是幂等的。 详见下文。

为什么采用现成的核心银行系统?

金融账簿是一个难做对、做错代价高昂的领域:复式记账、平衡校验、期末结账、 审计留痕。Fineract 已在多年的生产使用中把这些问题解决。我们只在其上补入 身份层与服务层

公民 ↔ 账户 ↔ IBAN

每位公民对应恰好一个 Fineract 客户、一个储蓄账户、一个 IBAN。 关联的键是公民的 civil_id

civil_id ──► externalId = PNOMN-<CIVIL_ID> ──► Fineract 客户 + 账户
                                              savings account ID
                                                      IBAN

蒙古国的 IBAN 为 20 个字符:

MN | kk | bbbb | aaaaaaaaaaaa
 2 |  2 |    4 |           12
 │    │     │      └─ 账号(Fineract savings ID,左侧补零)
 │    │     └──────── 银行/机构代码(4 位)
 │    └────────────── mod-97 校验位
 └─────────────────── 国家代码

账号的 12 位由 Fineract 的 ID 推导而来,因此无需额外的序列(sequence), 反向映射也只是纯算术。

键是 civil_id,而不是内部的 user_id

此前把 Fineract 的 externalId 由 PostgreSQL 的 user_id(每次建行都会 生成新 UUID)推导而来,这一做法已被纠正。在那种方案下,数据库一旦重建, 「公民 ↔ 账户」的关联便彻底断裂:再次登录会生成新账户、新 IBAN, 原有余额则被孤立。

civil_id 是终身标识(每位 eID 用户必有),因此由它推导的键不依赖数据库。 在开户之前,会先用这个键在 Fineract 中查找已有的客户与账户,若找到便 复用——这样即使映射丢失,公民仍能取回同一个 IBAN。

转账授权

转账由 JWT 会话授权。应用以登录时取得的 access token 调用 /transfer/iban

防重复机制:每个请求都带 Idempotency-Key 请求头。使用同一键的第二个请求 不会生成新交易,而是返回第一个请求的结果——因此网络中断、应用重发时, 钱不会走两次。这一保证最终由数据库中的 UNIQUE (user_id, idempotency_key) 约束来兜底。

已移除签名绑定

此前每笔转账都会以规范化的 GWT 格式重新哈希,并与公民的 eID PIN2 签名 相互校验(WYSIWYS——「所见即所签」)。随之还有 Go/Kotlin/Swift 三个移植版 逐字节一致的实现,以及针对 golden fixture 的 CI 校验。

这一整套已按产品决策移除。其后果是:持有有效 access token 的一方 即可动用资金——而在此之前,即便令牌被盗,没有 PIN2 也无法完成转账。

eID 登录仍然保留——被移除的只是转账签名。

API 界面

认证来自 Gerege Platform 的底座层;钱包自身的 endpoint 则在本仓库中。

方法 路径 作用
POST /api/v1/auth/initiate 按登记号发送 eID 推送
GET /api/v1/auth/status/{sid} 状态 + 令牌 + IBAN(钱包在此开立)
GET /api/v1/accounts/balance 余额(直接取自 Fineract)
GET /api/v1/accounts/transactions 账户流水
GET /api/v1/accounts/lookup 校验收款方 IBAN
POST /api/v1/transfer/iban 转账(需要 Idempotency-Key
GET/DELETE /api/v1/beneficiaries 已保存的收款人
POST/DELETE /api/v1/devices/register 推送令牌注册
POST /api/v1/pay/code/initiate 一次性付款 QR 令牌

两种响应形态

  • 扁平 JSON(无外层包装)——供手机应用使用。钱包与移动端 auth endpoint 采用这种形态。
  • {status, message, data} 包装——供 Web BFF 使用。

新增面向应用的 endpoint 时,请沿用扁平形态。Web BFF 会把扁平响应装入自己的 客户端包装后再转发。

应用的状态词汇

应用只把 CONFIRMED / REFUSED / TIMEOUT 视为终态。后端内部的 eID 命名 (COMPLETE/EXPIRED/……)对外映射只在一处完成。

契约由应用定义

iOS 与 Android 应用先于后端构建,且预期上述形态。应用与后端不一致时, 修改的是后端

手机应用

iOS Android
技术 SwiftUI Kotlin + Compose
登录
余额 / 流水
转账 ⏳ 界面尚未完成
QR(EMVCo)支付
推送注册

应用不会直接访问 eID 的域名——所有交互都经由 api.wallet.gerege.mn。 登录时,公民在 eID 应用收到的推送中输入自己的 PIN。

安全

  • 行级安全(RLS)。 API 以 superuser 的角色连接数据库(生产环境由 启动守卫校验),因此 RLS 策略确实生效。每张按用户划分的表都有自己的策略。 服务端可信写入——开户、生成转账记录等——单独以 service 角色执行; 并未授予公民对这些表的写入权限。
  • 数据库 TLS。 PostgreSQL 通过私有 CA 启用了 TLS,因此 sslmode=verify-full 才有实际意义。
  • 机密存放位置。 所有机密都在 /etc/gerege-wallet/*.env。应用的 .env 在每次部署时都会重新生成,因此手工改动会被下一次部署抹掉。
  • 限流。 /auth/* 约每分钟 5 次(请求体上限 4 KiB),/auth/status 的 long-poll 另有较宽松的限额,动用资金的 endpoint 约每分钟 30 次。
  • 幂等性。 每笔转账都要求 Idempotency-Key——网络中断、应用重发时钱不会 走两次。由于签名绑定已不存在,这是防重复的首要机制。
  • Fineract 隔离。 只监听 127.0.0.1:8090——外部没有可达路径。

部署

不用 Docker,而是原生 systemd

gerege-wallet.slice
├── gerege-wallet-fineract.service   # Fineract 1.15(JAR,127.0.0.1:8090)
├── gerege-wallet-api.service        # Go API(127.0.0.1:8080)
└── gerege-wallet-web.service        # Next.js BFF(127.0.0.1:3000)

PostgreSQL、Redis、nginx 均为主机服务。nginx 以 Let's Encrypt TLS 对外提供 wallet.gerege.mnapi.wallet.gerege.mn

CD:合并进 main 后,CI 一转绿 Deploy workflow 便会运行。所有构建都在 runner 上完成,只有产物送达服务器——服务器上无需 Go/Node 工具链,中断窗口也短。 只有在校验了 /health 以及受保护路径确实响应正常之后,部署才被记为成功。

当前状态

能力 状态
eID 登录(具备 RP 凭据) 已运行
钱包自动开立 + 分配 IBAN 已运行
余额 / 流水 已运行
新钱包欢迎奖励 已运行
IBAN 转账(由 JWT 授权) 已实现,测试不足
Android 转账 / QR 计划中
App Store / TestFlight 准备中

IBAN 中的银行代码为临时值

当前的银行/机构代码是在从蒙古银行取得正式代码之前的临时值。已分配给 公民的 IBAN 不应变更,因此在真实用户接入之后不会再更换该值。

详细文档

ARCHITECTUREDEVELOPMENTAPI_CONTRACTSECURITY 等文档位于 wallet-gerege-mn 仓库的 backend/docs/ 目录(EN/MN 成对)。移动端构建说明见 ios/README.md,部署说明见 docs/DEPLOYMENT.md

相关平台:eID Mongolia · G-Sign · Gerege Platform · Gerege Verify