Files
lijiaoqiao/supply-api/internal/domain/invariants.go
Your Name 0196ee5d47 feat(supply-api): 完成核心模块实现
新增/修改内容:
- config: 添加配置管理(config.example.yaml, config.go)
- cache: 添加Redis缓存层(redis.go)
- domain: 添加invariants不变量验证及测试
- middleware: 添加auth认证和idempotency幂等性中间件及测试
- repository: 添加完整数据访问层(account, package, settlement, idempotency, db)
- sql: 添加幂等性表DDL脚本

代码覆盖:
- auth middleware实现凭证边界验证
- idempotency middleware实现请求幂等性
- invariants实现业务不变量检查
- repository层实现完整的数据访问逻辑

关联issue: Round-1 R1-ISSUE-006 凭证边界硬门禁
2026-04-01 08:53:28 +08:00

213 lines
6.4 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"
"fmt"
)
// 领域不变量错误
var (
// INV-ACC-001: active账号不可删除
ErrAccountCannotDeleteActive = errors.New("SUP_ACC_4092: cannot delete active accounts")
// INV-ACC-002: disabled账号仅管理员可恢复
ErrAccountDisabledRequiresAdmin = errors.New("SUP_ACC_4031: disabled account requires admin to restore")
// INV-PKG-001: sold_out只能系统迁移
ErrPackageSoldOutSystemOnly = errors.New("SUP_PKG_4092: sold_out status can only be changed by system")
// INV-PKG-002: expired套餐不可直接恢复
ErrPackageExpiredCannotRestore = errors.New("SUP_PKG_4093: expired package cannot be directly restored")
// INV-PKG-003: 售价不得低于保护价
ErrPriceBelowProtection = errors.New("SUP_PKG_4001: price cannot be below protected price")
// INV-SET-001: processing/completed不可撤销
ErrSettlementCannotCancel = errors.New("SUP_SET_4092: cannot cancel processing or completed settlements")
// INV-SET-002: 提现金额不得超过可提现余额
ErrWithdrawExceedsBalance = errors.New("SUP_SET_4001: withdraw amount exceeds available balance")
// INV-SET-003: 结算单金额与余额流水必须平衡
ErrSettlementBalanceMismatch = errors.New("SUP_SET_5002: settlement amount does not match balance ledger")
)
// InvariantChecker 领域不变量检查器
type InvariantChecker struct {
accountStore AccountStore
packageStore PackageStore
settlementStore SettlementStore
}
// NewInvariantChecker 创建不变量检查器
func NewInvariantChecker(
accountStore AccountStore,
packageStore PackageStore,
settlementStore SettlementStore,
) *InvariantChecker {
return &InvariantChecker{
accountStore: accountStore,
packageStore: packageStore,
settlementStore: settlementStore,
}
}
// CheckAccountDelete 检查账号删除不变量
func (c *InvariantChecker) CheckAccountDelete(ctx context.Context, accountID, supplierID int64) error {
account, err := c.accountStore.GetByID(ctx, supplierID, accountID)
if err != nil {
return err
}
// INV-ACC-001: active账号不可删除
if account.Status == AccountStatusActive {
return ErrAccountCannotDeleteActive
}
return nil
}
// CheckAccountActivate 检查账号激活不变量
func (c *InvariantChecker) CheckAccountActivate(ctx context.Context, accountID, supplierID int64) error {
account, err := c.accountStore.GetByID(ctx, supplierID, accountID)
if err != nil {
return err
}
// INV-ACC-002: disabled账号仅管理员可恢复简化处理实际需要检查角色
if account.Status == AccountStatusDisabled {
return ErrAccountDisabledRequiresAdmin
}
return nil
}
// CheckPackagePublish 检查套餐发布不变量
func (c *InvariantChecker) CheckPackagePublish(ctx context.Context, packageID, supplierID int64) error {
pkg, err := c.packageStore.GetByID(ctx, supplierID, packageID)
if err != nil {
return err
}
// INV-PKG-002: expired套餐不可直接恢复
if pkg.Status == PackageStatusExpired {
return ErrPackageExpiredCannotRestore
}
return nil
}
// CheckPackagePrice 检查套餐价格不变量
func (c *InvariantChecker) CheckPackagePrice(ctx context.Context, pkg *Package, newPricePer1MInput, newPricePer1MOutput float64) error {
// INV-PKG-003: 售价不得低于保护价(这里简化处理,实际需要查询保护价配置)
minPrice := 0.01
if newPricePer1MInput > 0 && newPricePer1MInput < minPrice {
return fmt.Errorf("%w: input price %.6f is below minimum %.6f",
ErrPriceBelowProtection, newPricePer1MInput, minPrice)
}
if newPricePer1MOutput > 0 && newPricePer1MOutput < minPrice {
return fmt.Errorf("%w: output price %.6f is below minimum %.6f",
ErrPriceBelowProtection, newPricePer1MOutput, minPrice)
}
return nil
}
// CheckSettlementCancel 检查结算撤销不变量
func (c *InvariantChecker) CheckSettlementCancel(ctx context.Context, settlementID, supplierID int64) error {
settlement, err := c.settlementStore.GetByID(ctx, supplierID, settlementID)
if err != nil {
return err
}
// INV-SET-001: processing/completed不可撤销
if settlement.Status == SettlementStatusProcessing || settlement.Status == SettlementStatusCompleted {
return ErrSettlementCannotCancel
}
return nil
}
// CheckWithdrawBalance 检查提现余额不变量
func (c *InvariantChecker) CheckWithdrawBalance(ctx context.Context, supplierID int64, amount float64) error {
balance, err := c.settlementStore.GetWithdrawableBalance(ctx, supplierID)
if err != nil {
return err
}
// INV-SET-002: 提现金额不得超过可提现余额
if amount > balance {
return fmt.Errorf("%w: requested %.2f but available %.2f",
ErrWithdrawExceedsBalance, amount, balance)
}
return nil
}
// InvariantViolation 领域不变量违反事件
type InvariantViolation struct {
RuleCode string
ObjectType string
ObjectID int64
Message string
OccurredAt string
}
// EmitInvariantViolation 发射不变量违反事件
func EmitInvariantViolation(ruleCode, objectType string, objectID int64, err error) *InvariantViolation {
return &InvariantViolation{
RuleCode: ruleCode,
ObjectType: objectType,
ObjectID: objectID,
Message: err.Error(),
OccurredAt: "now", // 实际应使用时间戳
}
}
// ValidateStateTransition 验证状态转换是否合法
func ValidateStateTransition(from, to AccountStatus) bool {
validTransitions := map[AccountStatus][]AccountStatus{
AccountStatusPending: {AccountStatusActive, AccountStatusDisabled},
AccountStatusActive: {AccountStatusSuspended, AccountStatusDisabled},
AccountStatusSuspended: {AccountStatusActive, AccountStatusDisabled},
AccountStatusDisabled: {AccountStatusActive}, // 需要管理员权限
}
allowed, ok := validTransitions[from]
if !ok {
return false
}
for _, status := range allowed {
if status == to {
return true
}
}
return false
}
// ValidatePackageStateTransition 验证套餐状态转换
func ValidatePackageStateTransition(from, to PackageStatus) bool {
validTransitions := map[PackageStatus][]PackageStatus{
PackageStatusDraft: {PackageStatusActive},
PackageStatusActive: {PackageStatusPaused, PackageStatusSoldOut, PackageStatusExpired},
PackageStatusPaused: {PackageStatusActive, PackageStatusExpired},
PackageStatusSoldOut: {}, // 只能由系统迁移
PackageStatusExpired: {}, // 不能直接恢复,需要通过克隆
}
allowed, ok := validTransitions[from]
if !ok {
return false
}
for _, status := range allowed {
if status == to {
return true
}
}
return false
}