写在前面:为什么账户系统让我掉了不少头发
作为一个在支付行业摸爬滚打多年的架构师,我可以负责任地说:账户系统是整个支付体系里最容易让人秃头的模块。没有之一。
为什么?因为它涉及到钱。而钱这个东西,多一分少一分都是事故。记得我刚入行的时候,前辈语重心长地跟我说:
小伙子,做支付系统,差一分钱找三天是常态,差一块钱能让你过不好整个春节。
所以今天,我想用最通俗的方式,把账户系统的设计思路分享给大家。争取让各位少踩坑,多长发。
一、先搞清楚:你的系统里到底有哪些账户?
在开始写代码之前,我们得先理清一个问题:支付系统里到底有多少种账户?说实话,刚开始做的时候我也是一脸懵——不就是用户账户和商户账户吗?
图样图森破。让我给你画张图:
graph TB
subgraph internal ["内部账户-我们自己的钱袋子"]
A1["备付金账户"]
A2["手续费收入账户"]
A3["待清算账户"]
A4["在途资金账户"]
A5["差错账户"]
A6["渠道成本账户"]
end
subgraph external ["外部账户-别人的钱袋子"]
B1["商户账户"]
B2["用户账户"]
B3["渠道账户"]
B4["代理商账户"]
end
A1 -.-> B2
B2 --> B1
B1 --> A4
1.1 内部账户:公司的钱袋子们
内部账户是我们支付公司自己管理的账户,主要用来记录公司的资产、负债和收入。你可以把它想象成公司财务部的账本。
| 账户 | 人话翻译 | 干啥用的 |
|---|---|---|
| 备付金账户 | 保险柜 | 客户充的钱都存这,现在统一交给央行管了,安全得很 |
| 手续费收入 | 工资卡 | 每笔交易收的手续费都进这里,公司就靠它发工资了 |
| 待清算账户 | 中转站 | 交易完成但还没结算给商户的钱,先在这等着 |
| 在途资金 | 快递中 | 提现申请已经提了,钱在去银行的路上 |
| 差错账户 | 待处理箱 | 对账对不上的钱先扔这,等财务小姐姐来处理 |
| 渠道成本 | 过路费 | 给银行、网联等渠道的手续费支出 |
1.2 外部账户:别人的钱袋子
外部账户代表的是外部用户在我们系统里的资金。虽然钱在我们系统里记着,但本质上是人家的钱,我们只是帮忙管着。
这里有个很重要的概念:外部账户本质上是我们对外部用户的「负债」。用户存了100块,我们就欠他100块,这个要记清楚。
二、账户层级:像俄罗斯套娃一样设计
账户不能一锅粥,得分层管理。我喜欢把它比作公司组织架构——有集团、有部门、有小组、有个人。
graph TD
subgraph L1 ["一级账户-集团老大"]
G1["资产总账"]
G2["负债总账"]
G3["收入总账"]
end
subgraph L2 ["二级账户-部门经理"]
D1["备付金"]
D2["应收款"]
D3["商户负债"]
D4["用户负债"]
end
subgraph L3 ["三级账户-小组长"]
T1["工行备付金"]
T2["建行备付金"]
T3["待结算户"]
end
subgraph L4 ["四级账户-具体干活的"]
S1["商户A待结算"]
S2["商户B待结算"]
end
G1 --> D1
G1 --> D2
G2 --> D3
G2 --> D4
D1 --> T1
D1 --> T2
D3 --> T3
T3 --> S1
T3 --> S2
2.1 每一级账户都有自己的使命
| 层级 | 类比 | 主要职责 | 示例 |
|---|---|---|---|
| 一级 | 集团总部 | 对应会计科目,给监管看的 | 资产总账、负债总账、收入总账 |
| 二级 | 事业部 | 按业务类型分类,方便统计 | 备付金、应收款、待清算 |
| 三级 | 部门 | 具体业务场景,日常记账用 | 工行备付金、网联通道 |
| 四级 | 员工 | 最小粒度,对应具体用户/商户 | 张三钱包、李四店铺结算户 |
为什么要分这么多级?一句话:方便汇总和下钻。
三、领域模型设计
下面是账户系统的核心领域模型:
classDiagram
class AccountTitle {
-String title_code
-String parent_title_code
-String title_type
-String currency
-String balance_direction
}
class Account {
-String account_no
-String title_code
-String balance_direction
}
class InnerAccount {
}
class OuterAccount {
-String member_id
-String account_type
}
class Product {
-String product_code
-String revenue_account_no
}
class FundsChannel {
-String channel_code
-String channel_type
-String pending_account_no
-String cash_account_no
-String fee_account_no
-String vat_account_no
}
class Member {
-String member_id
-String member_name
}
class AccountingEntry {
-String entry_id
-String transaction_id
-String account_no
-Integer direction
-Decimal amount
-Decimal balance_before
-Decimal balance_after
}
Account <|-- InnerAccount
Account <|-- OuterAccount
AccountTitle "1" --> "0..*" AccountTitle : parent
AccountTitle "1" -- "*" Account
InnerAccount "0..1" -- "0..*" Product
InnerAccount "*" -- "0..1" FundsChannel
OuterAccount "*" -- "1" Member
Account "1" -- "*" AccountingEntry
3.1 模型说明
| 实体 | 说明 |
|---|---|
| AccountTitle | 会计科目,定义账户的模板和分类规则 |
| Account | 账户基类,包含账号、科目、余额方向等通用属性 |
| InnerAccount | 内部账户,系统自有账户 |
| OuterAccount | 外部账户,关联会员,区分账户类型 |
| FundsChannel | 资金渠道,包含待清算、现金、手续费、增值税四个账户 |
| Product | 产品,关联收入账户用于分润 |
| AccountingEntry | 会计分录,记录每一笔账务变动 |
3.2 上代码:账户数据模型(Go版)
// account.go - 账户核心模型
package account
import (
"time"
"github.com/shopspring/decimal"
)
// AccountTitle 会计科目
type AccountTitle struct {
TitleCode string `gorm:"primaryKey;size:32"`
ParentTitleCode string `gorm:"size:32;index"`
TitleName string `gorm:"size:128"`
TitleType string `gorm:"size:16"` // ASSET/LIABILITY/INCOME/EXPENSE
Currency string `gorm:"size:3;default:CNY"`
BalanceDirection int `gorm:"default:1"` // 1:借方 2:贷方
}
// Account 账户基类
type Account struct {
AccountNo string `gorm:"primaryKey;size:32"`
AccountName string `gorm:"size:128"`
TitleCode string `gorm:"size:32;index"`
BalanceDirection int `gorm:"default:1"`
Balance decimal.Decimal `gorm:"type:decimal(20,2)"`
FrozenAmount decimal.Decimal `gorm:"type:decimal(20,2)"`
Status int `gorm:"default:1"`
CreatedAt time.Time
UpdatedAt time.Time
}
// InnerAccount 内部账户
type InnerAccount struct {
Account
AccountCategory string `gorm:"size:32"` // RESERVE/FEE_INCOME/PENDING...
}
// OuterAccount 外部账户
type OuterAccount struct {
Account
MemberID string `gorm:"size:32;index"`
AccountType string `gorm:"size:16"` // SETTLEMENT/DEPOSIT/WALLET
}
// FundsChannel 资金渠道
type FundsChannel struct {
ChannelCode string `gorm:"primaryKey;size:32"`
ChannelName string `gorm:"size:64"`
ChannelType string `gorm:"size:16"` // BANK/NUCC/CUPS
PendingAccountNo string `gorm:"size:32"` // 待清算账户
CashAccountNo string `gorm:"size:32"` // 现金账户
FeeAccountNo string `gorm:"size:32"` // 手续费账户
VatAccountNo string `gorm:"size:32"` // 增值税账户
Status int `gorm:"default:1"`
}
// Product 产品
type Product struct {
ProductCode string `gorm:"primaryKey;size:32"`
ProductName string `gorm:"size:64"`
RevenueAccountNo string `gorm:"size:32"` // 收入账户
}四、记账:复式记账法其实没那么玄乎
说到记账,很多程序员就头大。别怕,核心就一句话:
有借必有贷,借贷必相等
翻译成人话:钱不会凭空出现也不会凭空消失,从一个口袋拿出来,必然要装进另一个口袋。
graph LR
subgraph tx ["用户充值100元"]
U["用户银行卡"]
R["备付金账户"]
W["用户钱包"]
end
U -->|扣款100| R
R -->|记录欠款100| W
subgraph entry ["会计分录"]
D["借: 备付金 +100"]
C["贷: 用户账户 +100"]
end
D --- C
4.1 借贷方向速记表
| 账户类型 | 借方 Debit | 贷方 Credit |
|---|---|---|
| 资产类 我有的 | 增加 钱进来了 | 减少 钱出去了 |
| 负债类 我欠的 | 减少 还钱了 | 增加 又欠了 |
| 收入类 我赚的 | 冲销 退钱了 | 增加 又赚了 |
| 成本类 我花的 | 增加 花钱了 | 冲销 省回来了 |
记住一个口诀:资产借增贷减,负债贷增借减。
4.2 记账核心代码(Go版)
// ledger.go - 记账引擎核心
package ledger
import (
"context"
"fmt"
"time"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
const (
Debit = 1 // 借
Credit = 2 // 贷
)
// AccountingEntry 会计分录
type AccountingEntry struct {
EntryID string `gorm:"primaryKey;size:64"`
TxnID string `gorm:"size:64;index"`
AccountNo string `gorm:"size:32;index"`
Direction int `gorm:"index"`
Amount decimal.Decimal `gorm:"type:decimal(20,2)"`
BalanceBefore decimal.Decimal `gorm:"type:decimal(20,2)"`
BalanceAfter decimal.Decimal `gorm:"type:decimal(20,2)"`
Summary string `gorm:"size:128"`
AccountingDate string `gorm:"size:10;index"`
CreatedAt time.Time
}
// LedgerService 记账服务
type LedgerService struct {
db *gorm.DB
}
// Post 核心记账方法
func (s *LedgerService) Post(ctx context.Context, req *PostingRequest) error {
// 1. 检查借贷是否平衡
if err := s.checkBalance(req.Entries); err != nil {
return fmt.Errorf("借贷不平衡: %w", err)
}
// 2. 幂等检查
if exists := s.checkExists(ctx, req.BizOrderNo); exists {
return nil
}
// 3. 开事务执行
return s.db.Transaction(func(tx *gorm.DB) error {
for _, entry := range req.Entries {
if err := s.processEntry(tx, entry); err != nil {
return err
}
}
return nil
})
}
// checkBalance 校验借贷平衡
func (s *LedgerService) checkBalance(entries []EntryRequest) error {
var debit, credit decimal.Decimal
for _, e := range entries {
if e.Direction == Debit {
debit = debit.Add(e.Amount)
} else {
credit = credit.Add(e.Amount)
}
}
if !debit.Equal(credit) {
return fmt.Errorf("借方%s != 贷方%s", debit, credit)
}
return nil
}五、常见支付场景怎么记账?
5.1 场景一:用户充值100块
sequenceDiagram
participant U as 用户
participant S as 支付系统
participant B as 银行
U->>S: 充值100元
S->>B: 请求扣款
B-->>S: 扣款成功
Note over S: 开始记账
S->>S: 借 备付金 +100
S->>S: 贷 用户账户 +100
S->>S: 借 渠道成本 +1
S->>S: 贷 备付金 -1
Note over S: 记账完成
S-->>U: 充值成功
会计分录:
| 账户 | 借方 | 贷方 | 说人话 |
|---|---|---|---|
| 备付金账户 | 100.00 | 钱到账了 | |
| 用户钱包 | 100.00 | 我们欠用户100 | |
| 渠道成本 | 1.00 | 银行手续费 | |
| 备付金账户 | 1.00 | 手续费从备付金出 |
5.2 场景二:用户消费100块
graph LR
A["用户钱包 -100"] --> B["商户账户 +99.4"]
A --> C["手续费收入 +0.6"]
| 账户 | 借方 | 贷方 | 说明 |
|---|---|---|---|
| 用户账户 | 100.00 | 用户花钱了 | |
| 商户账户 | 99.40 | 商户收款 | |
| 手续费收入 | 0.60 | 我们赚了 |
5.3 场景三:商户提现1000块
sequenceDiagram
participant M as 商户
participant S as 支付系统
participant B as 银行
Note over S: 阶段1 发起提现
M->>S: 提现1000元
S->>S: 借 商户账户 1002
S->>S: 贷 在途资金 1000
S->>S: 贷 手续费收入 2
S->>B: 发起打款
Note over S: 阶段2 到账确认
B-->>S: 打款成功
S->>S: 借 在途资金 1000
S->>S: 贷 备付金 1000
S-->>M: 提现成功
六、日终对账:每天都要做的体检
graph TD
A["开始日终对账"] --> B{"检查借贷平衡"}
B -->|平衡| C["渠道对账"]
B -->|不平衡| E["生成差错记录"]
C --> D{"核对流水"}
D -->|一致| F["账户余额核验"]
D -->|长款| E
D -->|短款| E
F --> G{"余额校验"}
G -->|正常| H["生成日终报表"]
G -->|异常| E
E --> I["等待人工处理"]
H --> J["日终完成"]
6.1 差错类型
| 类型 | 人话解释 | 怎么处理 |
|---|---|---|
| 长款 | 银行说收了,我们没记 | 补记账,挂待清算 |
| 短款 | 我们记了,银行没收到 | 挂差错账户,核实后处理 |
| 金额不符 | 都有记录,金额对不上 | 调差额 |
| 借贷不平 | 借贷加起来不等 | 大事!马上查bug |
七、系统架构总览
graph TB
subgraph biz ["业务层"]
A["充值"]
B["支付"]
C["提现"]
D["退款"]
end
subgraph engine ["记账引擎"]
E["记账入口"]
F{"借贷平衡检查"}
G["事务执行"]
H["拒绝记账"]
I["更新余额"]
J["写入分录"]
end
subgraph recon ["对账系统"]
K["日终调度"]
L["借贷核验"]
M["渠道对账"]
N["余额校验"]
O["生成报表"]
end
A --> E
B --> E
C --> E
D --> E
E --> F
F -->|平衡| G
F -->|不平衡| H
G --> I
G --> J
K --> L
K --> M
K --> N
L --> O
M --> O
N --> O
八、血泪教训
- 金额一定用Decimal。
0.1 + 0.2 != 0.3这个坑你不会想踩。 - 记账必须在事务里。要么全成功,要么全失败。
- 幂等性是刚需。同一笔单重复请求不能重复入账。
- 每笔账都要可追溯。记账前余额、记账后余额全都要记。
- 日终对账是救命的。再好的系统也会出bug。
问题
如何支持多币种?
如何支持非金额记账?
如何解决记账性能问题?