Hyperf对接清算
Hyperf 清算系统完整案例)清算核心是复式记账Double-Entry 幂等 原子事务PHP 最好的基础库是 https://github.com/php-finance/double-entry但金融级清算通常需要自建账务引擎下面给出完整实现。 --- 架构设计 交易订单 ↓ ClearingService清算入口 ↓ LedgerService复式记账→ MySQL 事务原子 ↓ SettlementService结算T1 批量 ↓ ReconcileService对账与第三方核对 --- 数据库设计 -- 账户表 CREATE TABLE accounts(idBIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL,typeENUM(cash,frozen,fee,platform)NOT NULL, currency CHAR(3)NOT NULL DEFAULTCNY, balance DECIMAL(20,4)NOT NULL DEFAULT0, version INT UNSIGNED NOT NULL DEFAULT0, -- 乐观锁 UNIQUE KEY uk_user_type_currency(user_id, type, currency));-- 分类账每笔交易产生两条借贷平衡 CREATE TABLE ledger_entries(idBIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, txn_id VARCHAR(64)NOT NULL, -- 幂等键 account_id BIGINT UNSIGNED NOT NULL, direction ENUM(debit,credit)NOT NULL, -- 借/贷 amount DECIMAL(20,4)NOT NULL, balance_after DECIMAL(20,4)NOT NULL, memo VARCHAR(255), created_at DATETIME NOT NULL, INDEX idx_txn(txn_id), INDEX idx_account(account_id, created_at));-- 清算批次 CREATE TABLE clearing_batches(idBIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, batch_no VARCHAR(32)NOT NULL UNIQUE, status ENUM(pending,processing,done,failed)DEFAULTpending, settle_date DATE NOT NULL, total_amount DECIMAL(20,4)NOT NULL DEFAULT0, total_fee DECIMAL(20,4)NOT NULL DEFAULT0, created_at DATETIME NOT NULL);--- 完整代码1. 安装依赖composerrequire hyperf/database hyperf/redis hyperf/async-queue brick/money ▎ brick/money 处理货币精度避免浮点误差这是金融场景必用库。 ---2. Money 值对象 // app/Clearing/ValueObject/Money.php namespace App\Clearing\ValueObject;use Brick\Money\Money as BrickMoney;use Brick\Math\RoundingMode;class Money{private BrickMoney$inner;publicfunction__construct(string|int$amount, string$currencyCNY){$this-innerBrickMoney::ofMinor((int)($amount*100),$currency);} public static function of(string $amount,string $currencyCNY):self { $mnew self(0,$currency);$m-innerBrickMoney::of($amount,$currency,null,RoundingMode::HALF_UP);return $m;} public function toDecimal():string { return(string)$this-inner-getAmount();} public function currency():string { return $this-inner-getCurrency()-getCurrencyCode();} public function add(self $other):self { return self::of((string)$this-inner-plus($other-inner)-getAmount());}publicfunctionsubtract(self$other): self{returnself::of((string)$this-inner-minus($other-inner)-getAmount());}publicfunctionisNegative(): bool{return$this-inner-isNegative();}publicfunctionisZero(): bool{return$this-inner-isZero();}}---3. 分类账服务核心 // app/Clearing/LedgerService.php namespace App\Clearing;use App\Clearing\ValueObject\Money;use Hyperf\DbConnection\Db;use Hyperf\Redis\Redis;class LedgerService{publicfunction__construct(private Redis$redis){}/** * 复式记账一次调用产生借贷两条分录保证借贷平衡 * throws\RuntimeException */ publicfunctiontransfer(string$txnId, int$fromAccountId, int$toAccountId, Money$amount, string$memo): void{// 幂等同一 txnId 只执行一次if($this-redis-set(ledger:txn:{$txnId},1,[NX,EX86400])false){return;}Db::transaction(function()use($txnId,$fromAccountId,$toAccountId,$amount,$memo){$from$this-lockAccount($fromAccountId);$to$this-lockAccount($toAccountId);$fromBalanceMoney::of($from-balance);if($fromBalance-subtract($amount)-isNegative()){throw new\RuntimeException(账户 {$fromAccountId} 余额不足);}$newFromBalance$fromBalance-subtract($amount);$newToBalanceMoney::of($to-balance)-add($amount);// 乐观锁更新$affectedDb::table(accounts)-where(id,$fromAccountId)-where(version,$from-version)-update([balance$newFromBalance-toDecimal(),version$from-version 1,]);if($affected0){throw new\RuntimeException(并发冲突请重试);}Db::table(accounts)-where(id,$toAccountId)-where(version,$to-version)-update([balance$newToBalance-toDecimal(),version$to-version 1,]);$nowdate(Y-m-d H:i:s);// 借方付款方 Db::table(ledger_entries)-insert([txn_id$txnId,account_id$fromAccountId,directiondebit,amount$amount-toDecimal(),balance_after$newFromBalance-toDecimal(),memo$memo,created_at$now,]);// 贷方收款方 Db::table(ledger_entries)-insert([txn_id$txnId,account_id$toAccountId,directioncredit,amount$amount-toDecimal(),balance_after$newToBalance-toDecimal(),memo$memo,created_at$now,]);});}privatefunctionlockAccount(int$id): object{$accountDb::table(accounts)-where(id,$id)-lockForUpdate()-first();if(!$account){throw new\RuntimeException(账户 {$id} 不存在);}return$account;}}---4. 清算服务 // app/Clearing/ClearingService.php namespace App\Clearing;use App\Clearing\ValueObject\Money;use Hyperf\DbConnection\Db;class ClearingService{// 手续费率 const FEE_RATE0.006;//0.6% publicfunction__construct(private LedgerService$ledger){}/** * 支付清算用户付款 → 平台收款 手续费拆分 */ publicfunctionclearPayment(string$orderId, int$payerUserId, int$payeeUserId, Money$amount,): void{$feeMoney::of(bcmul($amount-toDecimal(), self::FEE_RATE,4));$net$amount-subtract($fee);$payerCash$this-getAccountId($payerUserId,cash);$payeeCash$this-getAccountId($payeeUserId,cash);$platformFee$this-getAccountId(0,fee);// 平台手续费账户 //1. 付款方 → 收款方净额$this-ledger-transfer(txnId:pay:{$orderId}:net, fromAccountId:$payerCash, toAccountId:$payeeCash, amount:$net, memo:订单 {$orderId} 收款);//2. 付款方 → 平台手续费$this-ledger-transfer(txnId:pay:{$orderId}:fee, fromAccountId:$payerCash, toAccountId:$platformFee, amount:$fee, memo:订单 {$orderId} 手续费);}/** * 退款清算 */ publicfunctionclearRefund(string$refundId, string$originalOrderId, int$payeeUserId, int$payerUserId, Money$amount): void{$payeeCash$this-getAccountId($payeeUserId,cash);$payerCash$this-getAccountId($payerUserId,cash);$this-ledger-transfer(txnId:refund:{$refundId}, fromAccountId:$payeeCash, toAccountId:$payerCash, amount:$amount, memo:退款 原订单 {$originalOrderId});}privatefunctiongetAccountId(int$userId, string$type): int{$accountDb::table(accounts)-where(user_id,$userId)-where(type,$type)-value(id);if(!$account){throw new\RuntimeException(账户不存在: user{$userId} type{$type});}return(int)$account;}}---5. T1 结算批处理 // app/Clearing/SettlementService.php namespace App\Clearing;use App\Clearing\ValueObject\Money;use Hyperf\DbConnection\Db;class SettlementService{publicfunction__construct(private LedgerService$ledger){}/** * T1 结算将商户 cash 账户余额划转到结算账户 */ publicfunctionrunDailySettlement(string$settleDate): void{$batchNoSETTLE-.date(Ymd, strtotime($settleDate));// 防重入$existsDb::table(clearing_batches)-where(batch_no,$batchNo)-exists();if($exists){return;}Db::table(clearing_batches)-insert([batch_no$batchNo,statusprocessing,settle_date$settleDate,total_amount0,total_fee0,created_atdate(Y-m-d H:i:s),]);// 查询昨日有交易的商户$merchantsDb::table(ledger_entries as le)-join(accounts as a,le.account_id,,a.id)-where(le.direction,credit)-whereDate(le.created_at,$settleDate)-where(a.type,cash)-groupBy(a.user_id)-selectRaw(a.user_id, SUM(le.amount) as total)-get();$totalAmountMoney::of(0);foreach($merchantsas$merchant){$amountMoney::of($merchant-total);$cashId$this-getAccountId($merchant-user_id,cash);$settleId$this-getAccountId($merchant-user_id,settlement);$this-ledger-transfer(txnId:settle:{$batchNo}:{$merchant-user_id}, fromAccountId:$cashId, toAccountId:$settleId, amount:$amount, memo:T1结算 {$settleDate});$totalAmount$totalAmount-add($amount);}Db::table(clearing_batches)-where(batch_no,$batchNo)-update([statusdone,total_amount$totalAmount-toDecimal()]);}privatefunctiongetAccountId(int$userId, string$type): int{return(int)Db::table(accounts)-where(user_id,$userId)-where(type,$type)-value(id);}}---6. 对账服务 // app/Clearing/ReconcileService.php namespace App\Clearing;use Hyperf\DbConnection\Db;class ReconcileService{/** * 验证借贷平衡会计恒等式借方总额贷方总额 */ publicfunctionverifyBalance(string$date): array{$debitDb::table(ledger_entries)-whereDate(created_at,$date)-where(direction,debit)-sum(amount);$creditDb::table(ledger_entries)-whereDate(created_at,$date)-where(direction,credit)-sum(amount);$diffbcsub((string)$debit,(string)$credit,4);return [ date$date,debit$debit,credit$credit,diff$diff,balanced$diff0.0000,];}/***与第三方流水对账传入第三方账单*/public function reconcileWithThirdParty(array $thirdPartyBills,string $date):array { $missing[];//第三方有本地无 $mismatch[];//金额不符 foreach($thirdPartyBills as $bill){ $localDb::table(ledger_entries)-where(txn_id,$bill[txn_id])-first();if(!$local){ $missing[]$bill[txn_id];continue;} if(bccomp($local-amount,$bill[amount],4)!0){ $mismatch[][ txn_id$bill[txn_id],local$local-amount,third$bill[amount],];} } return compact(missing,mismatch);} }---7.定时任务注册//app/Task/SettlementTask.php namespace App\Task;use App\Clearing\ReconcileService;use App\Clearing\SettlementService;use Hyperf\Crontab\Annotation\Crontab;#[Crontab(rule:02***,name:DailySettlement,memo:T1日结)] class SettlementTask { public function __construct(private SettlementService $settlement,private ReconcileService $reconcile,){} public function execute():void { $yesterdaydate(Y-m-d,strtotime(-1day));$this-settlement-runDailySettlement($yesterday);$result$this-reconcile-verifyBalance($yesterday);if(!$result[balanced]){// 触发告警借贷不平衡\Hyperf\Utils\ApplicationContext::getContainer()-get(\Hyperf\AsyncQueue\Driver\DriverFactory::class)-get(default)-push(new\App\Job\AlertJob(借贷不平衡 diff{$result[diff]}));}}}---8. 对外 API // app/Controller/ClearingController.php namespace App\Controller;use App\Clearing\ClearingService;use App\Clearing\ValueObject\Money;use Hyperf\HttpServer\Annotation\Controller;use Hyperf\HttpServer\Annotation\PostMapping;#[Controller(prefix: /clearing)]class ClearingController{publicfunction__construct(private ClearingService$clearing){}#[PostMapping(path: /pay)]publicfunctionpay(): array{$bodyrequest()-all();$this-clearing-clearPayment(orderId:$body[order_id], payerUserId:(int)$body[payer_id], payeeUserId:(int)$body[payee_id], amount: Money::of($body[amount]),);return[code0,messageok];}}--- 关键设计原则 ┌──────────┬─────────────────────────────────────────┐ │ 原则 │ 实现方式 │ ├──────────┼─────────────────────────────────────────┤ │ 幂等 │ Redis SET NX 锁 txnId重复请求直接跳过 │ ├──────────┼─────────────────────────────────────────┤ │ 原子性 │ MySQL 事务 lockForUpdate │ ├──────────┼─────────────────────────────────────────┤ │ 并发安全 │ 乐观锁 version 字段冲突抛异常重试 │ ├──────────┼─────────────────────────────────────────┤ │ 精度 │ brick/money bcmath禁用 float 运算 │ ├──────────┼─────────────────────────────────────────┤ │ 借贷平衡 │ 每笔交易强制产生借贷两条分录 │ ├──────────┼─────────────────────────────────────────┤ │ 可审计 │ ledger_entries 永不删除只追加 │ └──────────┴─────────────────────────────────────────┘ --- 最核心的一句话 金融清算里 float 是禁忌所有金额用 brick/money bcmath 字符串运算数据库用 DECIMAL(20,4)。