ARTICLE · 1271438
(带源码)一张企业卡背后的系统:从申请、授权到清算退款的 Kind 实战
(带源码)一张企业卡背后的系统:从申请、授权到清算退款的 Kind 实战用户点击“申请企业卡”,几秒钟后看到卡片已经激活;刷卡时系统立即返回批准或拒绝;商户随后发起清算,偶尔还会发生撤销与退款。 表面上这是几个接口,实际上它同时涉及外部渠道、异步任务、状态机、余额、冻结、幂等、乱序事件和数据库事务。 这篇文章采用教练式视角,带你在本地 Kind 集群里走完一条通用企业卡链路。所有名称、数据和渠道均为本地模拟,不对应任何真实系统。 项目地址:https://github.com/wilebeast/local-platform-lab
设计卡系统时,最容易犯的错误是先画状态枚举,或者直接把渠道返回结果写进一张“大而全”的卡表。 更可靠的做法,是先区分四类事实:
渠道卡号只是外部映射,不是本地卡片主键;账户余额是资金事实,也不能由卡片展示字段代替。 本实验最终形成的主链路如下:
申请接口不会等待渠道完成全部工作。它只保证: 因此,HTTP 接口返回成功不等于渠道已经完成发卡。
网络超时后,客户端通常不知道服务端是否已经成功创建卡。如果它换一个请求编号再次提交,可能生成两张卡。 本实验要求客户端提供稳定的 Idempotency-Key: 数据库通过申请表主键和操作表唯一约束守住最后一道边界。即使两个请求并发到达,也只有一个业务事实能够成立。
渠道调用可能遇到超时、进程重启或临时失败。不能只把任务放在内存 goroutine 中,否则服务一重启,操作就丢失了。 渠道操作记录至少需要: Worker 使用数据库领取任务: 多 Worker 并发时,使用 FOR UPDATE SKIP LOCKED,避免两台 Worker 同时执行同一个渠道操作。 生产环境还必须区分“明确失败”和“结果未知”。网络超时不等于渠道失败;如果渠道提供查询接口,应使用原请求编号先查询,再决定是否重发。
渠道状态回调使用以下签名原文: 以 HMAC-SHA256 为例: 接收流程: 假设渠道先发出 active,后发出 frozen,但网络导致旧的 active 最后才到。如果按到达顺序覆盖,冻结卡会被错误恢复。 本地卡保存两个游标: 状态更新只接受更晚的业务事件时间: 同时,provider_card_id 在数据库中建立非空条件唯一索引,避免一个渠道卡号错误绑定到多张本地卡。
本实验使用三个关键金额: 核心公式: 所有金额均使用最小货币单位的整数保存。例如 USD 25.00 表示为: 避免使用浮点数处理资金。
实验先向卡账户加入 USD 1000.00: 同一个 entry_id 重放时,余额不能再次增加;如果复用同一个 entry_id,却把金额从 100000 改为 200000,系统返回 409 Conflict。 这说明幂等不是“看见相同编号就返回成功”,而是:
授权请求进入系统后,依次检查: 一笔 USD 25.00 的授权结果: 余额不足不是协议错误,而是一个正常业务决策: 因此实验返回 HTTP 200,并在响应体中表达批准或拒绝。签名错误才返回 401,请求结构错误返回 400。 不同授权编号会在同一账户行上串行加锁,防止两个并发授权同时看到同一份可用余额并导致超额冻结。
授权撤销适用于交易尚未清算的场景: 状态变化: 撤销记录使用独立 reversal_id。原样重放返回第一次结果;换一个撤销编号再次撤销同一授权,则被拒绝。
清算不是再次扣减 Available。授权阶段已经降低了可用余额;清算阶段需要同时减少 Posted 和 Held: 如果清算时再次减少 Available,就会造成重复扣款。 清算事务同时完成: 任一步失败,整个事务回滚。
本轮实现全额退款: 退款通过 settlement_id 找到原清算事实,而不是由客户端自由指定退款金额。这样能够在第一阶段天然防止超额退款。 退款事务同时完成: 退款编号原样重放时返回原结果;第二个退款编号试图退款同一清算记录会被拒绝。
最终查询可能得到: 二者并不冲突: decision 表示授权发生时平台确实批准了请求; authorization_status 表示它后来经历清算和退款后的当前生命周期。 如果直接把 approved 改成 refunded,就会丢失原始决策,审计时无法回答“这笔交易当初有没有被批准”。
实验使用三类保护: 以清算为例: 进程即使在事务中途崩溃,PostgreSQL 也会回滚未提交的账户、账本和状态变化,不会只扣余额却漏写账本。
本地 Kind 演练覆盖了: 申请卡的 HTTP 幂等; Worker 领取、渠道调用、重试和死信; Provider Webhook HMAC 验签; 重复回调和乱序事件; Funding 入账与参数冲突; 授权批准、余额不足和冻结卡拒绝; 授权重复请求不重复冻结; 撤销不改变 Posted; 清算不重复降低 Available; 退款恢复 Posted; 重复撤销、清算和退款不产生第二次资金副作用; 并发测试及 Go race detector; Argo CD Synced / Healthy; PostgreSQL 审计记录、账本和余额相互印证。 验证一套资金系统,不能只看接口返回 200。至少要形成以下证据链:
本实验刻意保持范围可理解,生产化仍需补齐: 部分清算、多次清算、增量清算; 部分退款、多次退款与累计退款上限; 授权过期与自动释放; 清算金额与授权金额不一致的容差策略; 复式账本或双录账务; 渠道请求查询、未知结果恢复与对账; 多币种、汇率、费用和精度规则; 风控、额度、MCC、国家和商户限制; PCI DSS 边界与敏感卡数据隔离; 管理接口鉴权、审计、指标和告警; 人工补偿必须经过审批和双人复核。 本地 Funding 接口仅用于实验造数,不应直接出现在生产入口。
企业卡系统真正困难的部分,不是“调用渠道创建一张卡”,而是让多类事实在重试、重复、并发、乱序和故障中仍然保持一致。 一条可靠的卡资金链路应该能够清楚回答: 当这些问题都有持久化证据,而不是只存在于代码注释或日志里,我们才真正开始拥有一套可以演进的企业卡系统。
一、先建立正确的事实模型
申请卡↓本地卡 pending + 渠道操作 pending↓ Worker模拟渠道创建卡↓本地卡 active↓入账 funding↓授权 authorized├── 撤销 reversal → reversed└── 清算 settlement → settled↓退款 refund → refunded
二、架构总览:同步入口与异步渠道操作分开
客户端│ HTTP▼APISIX│▼enterprise-card-service├── PostgreSQL:卡、操作、账户、账本和事件└── 创建渠道操作记录│▼enterprise-card-worker│ HTTP▼card-provider-simulator
本地申请事实已经落库;本地卡片处于 pending;后续渠道操作可以由 Worker 恢复执行。
HTTP 接受成功 ≠ 渠道最终成功 ≠ 本地业务最终完成
三、第一关:申请卡为什么必须幂等
第一次请求→ 保存申请→ 创建 card→ 创建 channel_operation完全相同的重复请求→ 返回第一次创建的 card_id→ 不再创建卡相同 Key、不同参数→ HTTP 409
四、第二关:渠道操作为什么需要持久化 Worker
operation_idcard_idoperation_typestatusattempt_countnext_attempt_atlast_error
pending / retry↓ 原子领取processing├── 渠道成功 → completed├── 可重试失败 → retry + next_attempt_at└── 超过上限 → dead_letter
五、第三关:Provider Webhook 的验签、幂等与乱序
timestamp + ”.” + event_id + ”.” + raw_body
Provider 与平台持有同一个 SecretProvider 计算签名平台使用原始请求 Body 重新计算常量时间比较两个签名
收到 HTTP 请求↓校验时间窗口↓对原始 Body 验签↓校验 Header Event ID 与 Body ID 一致↓按 event_id 幂等写入 Inbox↓Worker 异步更新卡状态
为什么还要防乱序
provider_status_occurred_atprovider_status_event_id
新事件 occurred_at > 当前游标 → applied旧事件 occurred_at <= 当前游标 → ignored_stale
六、资金模型:Posted、Held 与 Available
Posted:已经入账的余额Held:已被授权冻结、尚未清算的金额Available:当前可继续使用的金额
Available = Posted - Held
amount_minor = 2500
七、第四关:Funding 入账与幂等冲突
Posted = 100000Held = 0Available = 100000
相同编号 + 相同业务参数 → 返回原结果相同编号 + 不同业务参数 → 拒绝
八、第五关:实时授权如何冻结资金
HMAC 验签↓授权编号幂等检查↓卡片是否 active↓币种是否匹配↓Available 是否足够↓Held += amount
{”decision”: ”approved”,”posted_balance_minor”: 100000,”held_amount_minor”: 2500,”available_balance_minor”: 97500}
{”decision”: ”declined”,”reason”: ”insufficient_funds”}
九、第六关:撤销只释放冻结,不改变 Posted
撤销前:Posted 100000,Held 2500,Available 97500撤销后:Posted 100000,Held 0,Available 100000
decision = approved 保留原始决策authorization_status:authorized → reversed 记录当前生命周期
十、第七关:清算把冻结转换为正式扣账
清算前:Posted 100000,Held 3000,Available 97000清算动作:Posted -= 3000Held -= 3000清算后:Posted 97000,Held 0,Available 97000
锁定原授权↓更新账户 Posted 与 Held↓写 settlement 审计记录↓写 settlement 账本↓authorization_status = settled↓统一提交
十一、第八关:退款如何恢复余额
退款前:Posted 97000,Held 0,Available 97000退款: Posted += 3000退款后:Posted 100000,Held 0,Available 100000
锁定 settlement_id↓确认原授权状态为 settled↓Posted += settled_amount↓写 refund 审计记录↓写 refund 账本↓authorization_status = refunded
十二、为什么要同时保存“原始决策”和“当前状态”
decision = approvedauthorization_status = refunded
十三、数据库事务如何守住并发边界
业务编号主键或唯一约束;PostgreSQL advisory transaction lock;账户行 FOR UPDATE或带条件的原子更新。
两个清算请求并发↓按 authorization_id 串行↓第一个:authorized → settled,资金生效↓第二个:读取到 settled,拒绝再次清算
十四、我们亲手验证了什么
HTTP 响应↕业务状态↕操作/事件审计↕账本记录↕账户最终余额
十五、这仍然不是生产级卡系统
结语
谁发起了操作?渠道是否已经确认?本地何时算完成?资金副作用发生在哪个事务?同一请求重放会怎样?系统崩溃后如何恢复?最终结果能否从审计和账本重建?















































































