Files
lijiaoqiao/supply-api/internal/domain/settlement.go
Your Name efa4edcc15 fix: 修复提现唯一性检查问题 (PRD P0)
问题:Withdraw函数没有检查是否已有处理中的提现,可能导致并发提现

修复内容:
1. 添加新错误码 ErrWithdrawAlreadyProcessing (SUP_SET_4093)
2. 在 SettlementStore 接口添加 HasPendingOrProcessingWithdraw 方法
3. 在 Withdraw 函数中添加检查:已有pending/processing状态提现时拒绝新的提现
4. 在 Repository 中实现 HasPendingOrProcessingWithdraw(检查 pending 和 processing 状态)
5. 在所有 mock 实现中添加该方法

修改的文件:
- domain/settlement.go: 接口定义和 Withdraw 逻辑
- domain/invariants.go: 新错误码
- repository/settlement.go: HasPendingOrProcessingWithdraw 实现
- storage/store.go: InMemorySettlementStore 实现
- cmd/supply-api/main.go: DBSettlementStore 和 InMemorySettlementStoreAdapter 实现
- test mocks: 添加 HasPendingOrProcessingWithdraw
2026-04-08 20:26:50 +08:00

298 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package domain
import (
"context"
"errors"
"log"
"net/netip"
"time"
"lijiaoqiao/supply-api/internal/audit"
)
// 结算状态
type SettlementStatus string
const (
SettlementStatusPending SettlementStatus = "pending"
SettlementStatusProcessing SettlementStatus = "processing"
SettlementStatusCompleted SettlementStatus = "completed"
SettlementStatusFailed SettlementStatus = "failed"
)
// 支付方式
type PaymentMethod string
const (
PaymentMethodBank PaymentMethod = "bank"
PaymentMethodAlipay PaymentMethod = "alipay"
PaymentMethodWechat PaymentMethod = "wechat"
)
// 结算单
type Settlement struct {
ID int64 `json:"settlement_id"`
SupplierID int64 `json:"supplier_id"`
SettlementNo string `json:"settlement_no"`
Status SettlementStatus `json:"status"`
TotalAmount float64 `json:"total_amount"`
FeeAmount float64 `json:"fee_amount"`
NetAmount float64 `json:"net_amount"`
PaymentMethod PaymentMethod `json:"payment_method"`
PaymentAccount string `json:"payment_account,omitempty"`
PaymentTransactionID string `json:"payment_transaction_id,omitempty"`
PaidAt *time.Time `json:"paid_at,omitempty"`
// 账期 (XR-001)
PeriodStart time.Time `json:"period_start"`
PeriodEnd time.Time `json:"period_end"`
TotalOrders int `json:"total_orders"`
TotalUsageRecords int `json:"total_usage_records"`
// 单位与币种 (XR-001)
CurrencyCode string `json:"currency_code"`
AmountUnit string `json:"amount_unit"`
// 幂等字段 (XR-001)
RequestID string `json:"request_id,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
// 审计字段 (XR-001)
AuditTraceID string `json:"audit_trace_id,omitempty"`
Version int `json:"version"`
CreatedIP *netip.Addr `json:"created_ip,omitempty"`
UpdatedIP *netip.Addr `json:"updated_ip,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// 收益记录
type EarningRecord struct {
ID int64 `json:"record_id"`
SupplierID int64 `json:"supplier_id"`
SettlementID int64 `json:"settlement_id,omitempty"`
EarningsType string `json:"earnings_type"` // usage, bonus, refund
Amount float64 `json:"amount"`
Status string `json:"status"` // pending, available, withdrawn, frozen
Description string `json:"description,omitempty"`
EarnedAt time.Time `json:"earned_at"`
}
// 结算服务接口
type SettlementService interface {
Withdraw(ctx context.Context, supplierID int64, req *WithdrawRequest) (*Settlement, error)
Cancel(ctx context.Context, supplierID, settlementID int64) (*Settlement, error)
GetByID(ctx context.Context, supplierID, settlementID int64) (*Settlement, error)
List(ctx context.Context, supplierID int64) ([]*Settlement, error)
}
// 收益服务接口
type EarningService interface {
ListRecords(ctx context.Context, supplierID int64, startDate, endDate string, page, pageSize int) ([]*EarningRecord, int, error)
GetBillingSummary(ctx context.Context, supplierID int64, startDate, endDate string) (*BillingSummary, error)
}
// 提现请求
type WithdrawRequest struct {
Amount float64
PaymentMethod PaymentMethod
PaymentAccount string
SMSCode string
}
// 账单汇总
type BillingSummary struct {
Period BillingPeriod `json:"period"`
Summary BillingTotal `json:"summary"`
ByPlatform []PlatformStat `json:"by_platform,omitempty"`
}
type BillingPeriod struct {
Start string `json:"start"`
End string `json:"end"`
}
type BillingTotal struct {
TotalRevenue float64 `json:"total_revenue"`
TotalOrders int `json:"total_orders"`
TotalUsage int64 `json:"total_usage"`
TotalRequests int64 `json:"total_requests"`
AvgSuccessRate float64 `json:"avg_success_rate"`
PlatformFee float64 `json:"platform_fee"`
NetEarnings float64 `json:"net_earnings"`
}
type PlatformStat struct {
Platform string `json:"platform"`
Revenue float64 `json:"revenue"`
Orders int `json:"orders"`
Tokens int64 `json:"tokens"`
SuccessRate float64 `json:"success_rate"`
}
// 结算仓储接口
// P1-005: 乐观锁支持 - Update需要expectedVersion参数防止并发更新
type SettlementStore interface {
Create(ctx context.Context, s *Settlement) error
GetByID(ctx context.Context, supplierID, id int64) (*Settlement, error)
// Update 使用乐观锁expectedVersion是更新前的版本号如果版本不匹配返回ErrConcurrencyConflict
Update(ctx context.Context, s *Settlement, expectedVersion int) error
List(ctx context.Context, supplierID int64) ([]*Settlement, error)
GetWithdrawableBalance(ctx context.Context, supplierID int64) (float64, error)
// HasPendingOrProcessingWithdraw 检查是否有待处理或处理中的提现单
HasPendingOrProcessingWithdraw(ctx context.Context, supplierID int64) (bool, error)
}
// 收益仓储接口
type EarningStore interface {
ListRecords(ctx context.Context, supplierID int64, startDate, endDate string, page, pageSize int) ([]*EarningRecord, int, error)
GetBillingSummary(ctx context.Context, supplierID int64, startDate, endDate string) (*BillingSummary, error)
}
// 结算服务实现
type settlementService struct {
store SettlementStore
earningStore EarningStore
auditStore audit.AuditStore
}
func NewSettlementService(store SettlementStore, earningStore EarningStore, auditStore audit.AuditStore) SettlementService {
return &settlementService{
store: store,
earningStore: earningStore,
auditStore: auditStore,
}
}
// emitAudit 安全记录审计日志(失败只记录错误,不影响主流程)
func (s *settlementService) emitAudit(ctx context.Context, event audit.Event) {
if err := s.auditStore.Emit(ctx, event); err != nil {
log.Printf("[AUDIT_ERROR] failed to emit audit event: %v, object_type=%s, object_id=%d, action=%s",
err, event.ObjectType, event.ObjectID, event.Action)
}
}
func (s *settlementService) Withdraw(ctx context.Context, supplierID int64, req *WithdrawRequest) (*Settlement, error) {
if req.SMSCode != "123456" {
return nil, errors.New("invalid sms code")
}
// INV-SET-004: 检查是否已有待处理或处理中的提现
hasPending, err := s.store.HasPendingOrProcessingWithdraw(ctx, supplierID)
if err != nil {
return nil, err
}
if hasPending {
return nil, ErrWithdrawAlreadyProcessing
}
// 验证金额:必须为正数
if req.Amount <= 0 {
return nil, errors.New("SUP_SET_4003: withdraw amount must be positive")
}
balance, err := s.store.GetWithdrawableBalance(ctx, supplierID)
if err != nil {
return nil, err
}
if req.Amount > balance {
return nil, errors.New("SUP_SET_4001: withdraw amount exceeds available balance")
}
settlement := &Settlement{
SupplierID: supplierID,
SettlementNo: generateSettlementNo(),
Status: SettlementStatusPending,
TotalAmount: req.Amount,
FeeAmount: req.Amount * 0.01, // 1% fee
NetAmount: req.Amount * 0.99,
PaymentMethod: req.PaymentMethod,
PaymentAccount: req.PaymentAccount,
Version: 1,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.store.Create(ctx, settlement); err != nil {
return nil, err
}
s.emitAudit(ctx, audit.Event{
TenantID: supplierID,
ObjectType: "supply_settlement",
ObjectID: settlement.ID,
Action: "withdraw",
ResultCode: "OK",
})
return settlement, nil
}
func (s *settlementService) Cancel(ctx context.Context, supplierID, settlementID int64) (*Settlement, error) {
settlement, err := s.store.GetByID(ctx, supplierID, settlementID)
if err != nil {
return nil, err
}
if settlement.Status == SettlementStatusProcessing || settlement.Status == SettlementStatusCompleted {
return nil, errors.New("SUP_SET_4092: cannot cancel processing or completed settlements")
}
// 保存更新前的版本号用于乐观锁
expectedVersion := settlement.Version
settlement.Status = SettlementStatusFailed
settlement.UpdatedAt = time.Now()
// 注意Version++由Repository的Update方法自动处理
if err := s.store.Update(ctx, settlement, expectedVersion); err != nil {
return nil, err
}
s.emitAudit(ctx, audit.Event{
TenantID: supplierID,
ObjectType: "supply_settlement",
ObjectID: settlementID,
Action: "cancel",
ResultCode: "OK",
})
// 重新获取更新后的settlement
return s.store.GetByID(ctx, supplierID, settlementID)
}
func (s *settlementService) GetByID(ctx context.Context, supplierID, settlementID int64) (*Settlement, error) {
return s.store.GetByID(ctx, supplierID, settlementID)
}
func (s *settlementService) List(ctx context.Context, supplierID int64) ([]*Settlement, error) {
return s.store.List(ctx, supplierID)
}
func (s *settlementService) GetBillingSummary(ctx context.Context, supplierID int64, startDate, endDate string) (*BillingSummary, error) {
return s.earningStore.GetBillingSummary(ctx, supplierID, startDate, endDate)
}
// 收益服务实现
type earningService struct {
store EarningStore
}
func NewEarningService(store EarningStore) EarningService {
return &earningService{store: store}
}
func (s *earningService) ListRecords(ctx context.Context, supplierID int64, startDate, endDate string, page, pageSize int) ([]*EarningRecord, int, error) {
return s.store.ListRecords(ctx, supplierID, startDate, endDate, page, pageSize)
}
func (s *earningService) GetBillingSummary(ctx context.Context, supplierID int64, startDate, endDate string) (*BillingSummary, error) {
return s.store.GetBillingSummary(ctx, supplierID, startDate, endDate)
}
func generateSettlementNo() string {
return time.Now().Format("20060102150405")
}