diff --git a/go.mod b/go.mod index 5952533..65e09b3 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,6 @@ require ( github.com/jackc/pgx/v5 v5.7.6 github.com/redis/go-redis/v9 v9.17.3 github.com/spf13/viper v1.21.0 - github.com/stretchr/testify v1.11.1 github.com/swaggest/openapi-go v0.2.60 github.com/valyala/fasthttp v1.66.0 github.com/xuri/excelize/v2 v2.8.1 @@ -25,7 +24,6 @@ require ( gopkg.in/yaml.v3 v3.0.1 gorm.io/datatypes v1.2.7 gorm.io/driver/postgres v1.6.0 - gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 ) @@ -40,7 +38,6 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect @@ -60,13 +57,11 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/richardlehane/mscfb v1.0.4 // indirect github.com/richardlehane/msoleps v1.0.4 // indirect github.com/rivo/uniseg v0.2.0 // indirect @@ -77,7 +72,6 @@ require ( github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/swaggest/jsonschema-go v0.3.74 // indirect github.com/swaggest/refl v1.3.1 // indirect diff --git a/go.sum b/go.sum index 531fb3b..633c8c0 100644 --- a/go.sum +++ b/go.sum @@ -221,7 +221,6 @@ github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjb github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 426d03b..d1176b6 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -98,8 +98,8 @@ func initServices(s *stores, deps *Dependencies) *services { PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.VerificationService, deps.JWTManager, deps.WechatOfficialAccount, deps.Logger), Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role), Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, deps.TokenManager, deps.Logger), - ShopCommission: shopCommissionSvc.New(s.Shop, s.Account, s.Wallet, s.CommissionWithdrawalRequest, s.CommissionRecord), - CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.Wallet, s.WalletTransaction, s.CommissionWithdrawalRequest), + ShopCommission: shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionRecord), + CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest), CommissionWithdrawalSetting: commissionWithdrawalSettingSvc.New(deps.DB, s.Account, s.CommissionWithdrawalSetting), CommissionCalculation: commissionCalculationSvc.New( deps.DB, @@ -110,8 +110,8 @@ func initServices(s *stores, deps *Dependencies) *services { s.PackageSeries, s.IotCard, s.Device, - s.Wallet, - s.WalletTransaction, + s.AgentWallet, + s.AgentWalletTransaction, s.Order, s.OrderItem, s.Package, @@ -123,7 +123,7 @@ func initServices(s *stores, deps *Dependencies) *services { EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization), EnterpriseDevice: enterpriseDeviceSvc.New(deps.DB, s.Enterprise, s.Device, s.DeviceSimBinding, s.EnterpriseDeviceAuthorization, s.EnterpriseCardAuthorization, deps.Logger), Authorization: enterpriseCardSvc.NewAuthorizationService(s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger), - MyCommission: myCommissionSvc.New(deps.DB, s.Shop, s.Wallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.WalletTransaction), + MyCommission: myCommissionSvc.New(deps.DB, s.Shop, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction), IotCard: iotCard, IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient), Device: deviceSvc.New(deps.DB, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries), @@ -140,8 +140,8 @@ func initServices(s *stores, deps *Dependencies) *services { ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop), CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats), PurchaseValidation: purchaseValidation, - Order: orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.Wallet, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, deps.WechatPayment, deps.QueueClient, deps.Logger), - Recharge: rechargeSvc.New(deps.DB, s.Recharge, s.Wallet, s.WalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, deps.Logger), + Order: orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.CardWallet, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, deps.WechatPayment, deps.QueueClient, deps.Logger), + Recharge: rechargeSvc.New(deps.DB, s.CardRecharge, s.CardWallet, s.CardWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, deps.Logger), PollingConfig: pollingSvc.NewConfigService(s.PollingConfig), PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis), PollingMonitoring: pollingSvc.NewMonitoringService(deps.Redis), diff --git a/internal/bootstrap/stores.go b/internal/bootstrap/stores.go index 71378c3..316534c 100644 --- a/internal/bootstrap/stores.go +++ b/internal/bootstrap/stores.go @@ -15,10 +15,8 @@ type stores struct { RolePermission *postgres.RolePermissionStore PersonalCustomer *postgres.PersonalCustomerStore PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore - Wallet *postgres.WalletStore CommissionWithdrawalRequest *postgres.CommissionWithdrawalRequestStore CommissionRecord *postgres.CommissionRecordStore - WalletTransaction *postgres.WalletTransactionStore CommissionWithdrawalSetting *postgres.CommissionWithdrawalSettingStore Enterprise *postgres.EnterpriseStore EnterpriseCardAuthorization *postgres.EnterpriseCardAuthorizationStore @@ -40,7 +38,6 @@ type stores struct { ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore Order *postgres.OrderStore OrderItem *postgres.OrderItemStore - Recharge *postgres.RechargeStore PollingConfig *postgres.PollingConfigStore PollingConcurrencyConfig *postgres.PollingConcurrencyConfigStore PollingAlertRule *postgres.PollingAlertRuleStore @@ -48,6 +45,14 @@ type stores struct { DataCleanupConfig *postgres.DataCleanupConfigStore DataCleanupLog *postgres.DataCleanupLogStore PollingManualTriggerLog *postgres.PollingManualTriggerLogStore + // 代理钱包系统 + AgentWallet *postgres.AgentWalletStore + AgentWalletTransaction *postgres.AgentWalletTransactionStore + AgentRecharge *postgres.AgentRechargeStore + // 卡钱包系统 + CardWallet *postgres.CardWalletStore + CardWalletTransaction *postgres.CardWalletTransactionStore + CardRecharge *postgres.CardRechargeStore } func initStores(deps *Dependencies) *stores { @@ -62,10 +67,8 @@ func initStores(deps *Dependencies) *stores { RolePermission: postgres.NewRolePermissionStore(deps.DB, deps.Redis), PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis), PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB), - Wallet: postgres.NewWalletStore(deps.DB, deps.Redis), CommissionWithdrawalRequest: postgres.NewCommissionWithdrawalRequestStore(deps.DB, deps.Redis), CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis), - WalletTransaction: postgres.NewWalletTransactionStore(deps.DB, deps.Redis), CommissionWithdrawalSetting: postgres.NewCommissionWithdrawalSettingStore(deps.DB, deps.Redis), Enterprise: postgres.NewEnterpriseStore(deps.DB, deps.Redis), EnterpriseCardAuthorization: postgres.NewEnterpriseCardAuthorizationStore(deps.DB, deps.Redis), @@ -87,7 +90,6 @@ func initStores(deps *Dependencies) *stores { ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB), Order: postgres.NewOrderStore(deps.DB, deps.Redis), OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis), - Recharge: postgres.NewRechargeStore(deps.DB, deps.Redis), PollingConfig: postgres.NewPollingConfigStore(deps.DB), PollingConcurrencyConfig: postgres.NewPollingConcurrencyConfigStore(deps.DB), PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB), @@ -95,5 +97,13 @@ func initStores(deps *Dependencies) *stores { DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB), DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB), PollingManualTriggerLog: postgres.NewPollingManualTriggerLogStore(deps.DB), + // 代理钱包系统 + AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis), + AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis), + AgentRecharge: postgres.NewAgentRechargeStore(deps.DB, deps.Redis), + // 卡钱包系统 + CardWallet: postgres.NewCardWalletStore(deps.DB, deps.Redis), + CardWalletTransaction: postgres.NewCardWalletTransactionStore(deps.DB, deps.Redis), + CardRecharge: postgres.NewCardRechargeStore(deps.DB, deps.Redis), } } diff --git a/internal/bootstrap/worker_services.go b/internal/bootstrap/worker_services.go index 493d377..de23526 100644 --- a/internal/bootstrap/worker_services.go +++ b/internal/bootstrap/worker_services.go @@ -30,8 +30,8 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q stores.PackageSeries, stores.IotCard, stores.Device, - stores.Wallet, - stores.WalletTransaction, + stores.AgentWallet, + stores.AgentWalletTransaction, stores.Order, stores.OrderItem, stores.Package, diff --git a/internal/bootstrap/worker_stores.go b/internal/bootstrap/worker_stores.go index 35e14b1..e968439 100644 --- a/internal/bootstrap/worker_stores.go +++ b/internal/bootstrap/worker_stores.go @@ -17,8 +17,6 @@ type workerStores struct { Shop *postgres.ShopStore ShopSeriesAllocation *postgres.ShopSeriesAllocationStore PackageSeries *postgres.PackageSeriesStore - Wallet *postgres.WalletStore - WalletTransaction *postgres.WalletTransactionStore Order *postgres.OrderStore OrderItem *postgres.OrderItemStore Package *postgres.PackageStore @@ -28,6 +26,8 @@ type workerStores struct { PollingAlertHistory *postgres.PollingAlertHistoryStore DataCleanupConfig *postgres.DataCleanupConfigStore DataCleanupLog *postgres.DataCleanupLogStore + AgentWallet *postgres.AgentWalletStore + AgentWalletTransaction *postgres.AgentWalletTransactionStore } func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores { @@ -43,8 +43,6 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores { Shop: postgres.NewShopStore(deps.DB, deps.Redis), ShopSeriesAllocation: postgres.NewShopSeriesAllocationStore(deps.DB), PackageSeries: postgres.NewPackageSeriesStore(deps.DB), - Wallet: postgres.NewWalletStore(deps.DB, deps.Redis), - WalletTransaction: postgres.NewWalletTransactionStore(deps.DB, deps.Redis), Order: postgres.NewOrderStore(deps.DB, deps.Redis), OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis), Package: postgres.NewPackageStore(deps.DB), @@ -54,6 +52,8 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores { PollingAlertHistory: postgres.NewPollingAlertHistoryStore(deps.DB), DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB), DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB), + AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis), + AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis), } return &queue.WorkerStores{ @@ -68,8 +68,6 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores { Shop: stores.Shop, ShopSeriesAllocation: stores.ShopSeriesAllocation, PackageSeries: stores.PackageSeries, - Wallet: stores.Wallet, - WalletTransaction: stores.WalletTransaction, Order: stores.Order, OrderItem: stores.OrderItem, Package: stores.Package, @@ -79,5 +77,7 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores { PollingAlertHistory: stores.PollingAlertHistory, DataCleanupConfig: stores.DataCleanupConfig, DataCleanupLog: stores.DataCleanupLog, + AgentWallet: stores.AgentWallet, + AgentWalletTransaction: stores.AgentWalletTransaction, } } diff --git a/internal/handler/admin/polling_cleanup.go b/internal/handler/admin/polling_cleanup.go index 217b7de..30edbc6 100644 --- a/internal/handler/admin/polling_cleanup.go +++ b/internal/handler/admin/polling_cleanup.go @@ -3,11 +3,11 @@ package admin import ( "github.com/gofiber/fiber/v2" - "github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/service/polling" "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/break/junhong_cmp_fiber/pkg/response" ) diff --git a/internal/model/agent_wallet.go b/internal/model/agent_wallet.go new file mode 100644 index 0000000..b9ec71f --- /dev/null +++ b/internal/model/agent_wallet.go @@ -0,0 +1,91 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// AgentWallet 代理钱包模型 +// 管理店铺级别的主钱包和分佣钱包 +type AgentWallet struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + ShopID uint `gorm:"column:shop_id;not null;index;comment:店铺ID" json:"shop_id"` + WalletType string `gorm:"column:wallet_type;type:varchar(20);not null;comment:钱包类型(main-主钱包 | commission-分佣钱包)" json:"wallet_type"` + Balance int64 `gorm:"column:balance;type:bigint;not null;default:0;comment:余额(单位:分)" json:"balance"` + FrozenBalance int64 `gorm:"column:frozen_balance;type:bigint;not null;default:0;comment:冻结余额(单位:分)" json:"frozen_balance"` + Currency string `gorm:"column:currency;type:varchar(10);not null;default:'CNY';comment:币种" json:"currency"` + Status int `gorm:"column:status;type:int;not null;default:1;comment:钱包状态(1-正常 2-冻结 3-关闭)" json:"status"` + Version int `gorm:"column:version;type:int;not null;default:0;comment:版本号(乐观锁)" json:"version"` + ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"` + EnterpriseIDTag *uint `gorm:"column:enterprise_id_tag;index;comment:企业ID标签(多租户过滤)" json:"enterprise_id_tag,omitempty"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"` +} + +// TableName 指定表名 +func (AgentWallet) TableName() string { + return "tb_agent_wallet" +} + +// GetAvailableBalance 获取可用余额 = balance - frozen_balance +func (w *AgentWallet) GetAvailableBalance() int64 { + return w.Balance - w.FrozenBalance +} + +// AgentWalletTransaction 代理钱包交易记录模型 +// 记录所有代理钱包余额变动 +type AgentWalletTransaction struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + AgentWalletID uint `gorm:"column:agent_wallet_id;not null;index;comment:代理钱包ID" json:"agent_wallet_id"` + ShopID uint `gorm:"column:shop_id;not null;index;comment:店铺ID(冗余字段,便于查询)" json:"shop_id"` + UserID uint `gorm:"column:user_id;not null;comment:操作人用户ID" json:"user_id"` + TransactionType string `gorm:"column:transaction_type;type:varchar(20);not null;comment:交易类型(recharge-充值 | deduct-扣款 | refund-退款 | commission-分佣 | withdrawal-提现)" json:"transaction_type"` + Amount int64 `gorm:"column:amount;type:bigint;not null;comment:变动金额(单位:分,正数为增加,负数为减少)" json:"amount"` + BalanceBefore int64 `gorm:"column:balance_before;type:bigint;not null;comment:变动前余额(单位:分)" json:"balance_before"` + BalanceAfter int64 `gorm:"column:balance_after;type:bigint;not null;comment:变动后余额(单位:分)" json:"balance_after"` + Status int `gorm:"column:status;type:int;not null;default:1;comment:交易状态(1-成功 2-失败 3-处理中)" json:"status"` + ReferenceType *string `gorm:"column:reference_type;type:varchar(50);comment:关联业务类型(order | commission | withdrawal | topup)" json:"reference_type,omitempty"` + ReferenceID *uint `gorm:"column:reference_id;comment:关联业务ID" json:"reference_id,omitempty"` + Remark *string `gorm:"column:remark;type:text;comment:备注" json:"remark,omitempty"` + Metadata *string `gorm:"column:metadata;type:jsonb;comment:扩展信息(如手续费、支付方式等)" json:"metadata,omitempty"` + Creator uint `gorm:"column:creator;not null;comment:创建人ID" json:"creator"` + ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"` + EnterpriseIDTag *uint `gorm:"column:enterprise_id_tag;index;comment:企业ID标签(多租户过滤)" json:"enterprise_id_tag,omitempty"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"` +} + +// TableName 指定表名 +func (AgentWalletTransaction) TableName() string { + return "tb_agent_wallet_transaction" +} + +// AgentRechargeRecord 代理充值记录模型 +// 记录所有代理充值操作 +type AgentRechargeRecord struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + UserID uint `gorm:"column:user_id;not null;index;comment:操作人用户ID" json:"user_id"` + AgentWalletID uint `gorm:"column:agent_wallet_id;not null;comment:代理钱包ID" json:"agent_wallet_id"` + ShopID uint `gorm:"column:shop_id;not null;index;comment:店铺ID(冗余字段,便于查询)" json:"shop_id"` + RechargeNo string `gorm:"column:recharge_no;type:varchar(50);not null;uniqueIndex;comment:充值订单号(格式:ARCH+时间戳+随机数)" json:"recharge_no"` + Amount int64 `gorm:"column:amount;type:bigint;not null;comment:充值金额(单位:分,最小10000分=100元)" json:"amount"` + PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null;comment:支付方式(alipay-支付宝 | wechat-微信 | bank-银行转账 | offline-线下)" json:"payment_method"` + PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"` + PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"` + Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)" json:"status"` + PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"` + CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"` + ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"` + EnterpriseIDTag *uint `gorm:"column:enterprise_id_tag;index;comment:企业ID标签(多租户过滤)" json:"enterprise_id_tag,omitempty"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"` +} + +// TableName 指定表名 +func (AgentRechargeRecord) TableName() string { + return "tb_agent_recharge_record" +} diff --git a/internal/model/card_wallet.go b/internal/model/card_wallet.go new file mode 100644 index 0000000..7bdbc3f --- /dev/null +++ b/internal/model/card_wallet.go @@ -0,0 +1,93 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// CardWallet 卡钱包模型 +// 管理物联网卡和设备级别的钱包 +type CardWallet struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + ResourceType string `gorm:"column:resource_type;type:varchar(20);not null;index;comment:资源类型(iot_card-物联网卡 | device-设备)" json:"resource_type"` + ResourceID uint `gorm:"column:resource_id;not null;index;comment:资源ID(关联tb_iot_card.id或tb_device.id)" json:"resource_id"` + Balance int64 `gorm:"column:balance;type:bigint;not null;default:0;comment:余额(单位:分)" json:"balance"` + FrozenBalance int64 `gorm:"column:frozen_balance;type:bigint;not null;default:0;comment:冻结余额(单位:分)" json:"frozen_balance"` + Currency string `gorm:"column:currency;type:varchar(10);not null;default:'CNY';comment:币种" json:"currency"` + Status int `gorm:"column:status;type:int;not null;default:1;comment:钱包状态(1-正常 2-冻结 3-关闭)" json:"status"` + Version int `gorm:"column:version;type:int;not null;default:0;comment:版本号(乐观锁)" json:"version"` + ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"` + EnterpriseIDTag *uint `gorm:"column:enterprise_id_tag;index;comment:企业ID标签(多租户过滤)" json:"enterprise_id_tag,omitempty"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"` +} + +// TableName 指定表名 +func (CardWallet) TableName() string { + return "tb_card_wallet" +} + +// GetAvailableBalance 获取可用余额 = balance - frozen_balance +func (w *CardWallet) GetAvailableBalance() int64 { + return w.Balance - w.FrozenBalance +} + +// CardWalletTransaction 卡钱包交易记录模型 +// 记录所有卡钱包余额变动 +type CardWalletTransaction struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + CardWalletID uint `gorm:"column:card_wallet_id;not null;index;comment:卡钱包ID" json:"card_wallet_id"` + ResourceType string `gorm:"column:resource_type;type:varchar(20);not null;index;comment:资源类型(冗余字段,便于查询)" json:"resource_type"` + ResourceID uint `gorm:"column:resource_id;not null;index;comment:资源ID(冗余字段,便于查询)" json:"resource_id"` + UserID uint `gorm:"column:user_id;not null;comment:操作人用户ID" json:"user_id"` + TransactionType string `gorm:"column:transaction_type;type:varchar(20);not null;comment:交易类型(recharge-充值 | deduct-扣款 | refund-退款)" json:"transaction_type"` + Amount int64 `gorm:"column:amount;type:bigint;not null;comment:变动金额(单位:分,正数为增加,负数为减少)" json:"amount"` + BalanceBefore int64 `gorm:"column:balance_before;type:bigint;not null;comment:变动前余额(单位:分)" json:"balance_before"` + BalanceAfter int64 `gorm:"column:balance_after;type:bigint;not null;comment:变动后余额(单位:分)" json:"balance_after"` + Status int `gorm:"column:status;type:int;not null;default:1;comment:交易状态(1-成功 2-失败 3-处理中)" json:"status"` + ReferenceType *string `gorm:"column:reference_type;type:varchar(50);comment:关联业务类型(order | topup)" json:"reference_type,omitempty"` + ReferenceID *uint `gorm:"column:reference_id;comment:关联业务ID" json:"reference_id,omitempty"` + Remark *string `gorm:"column:remark;type:text;comment:备注" json:"remark,omitempty"` + Metadata *string `gorm:"column:metadata;type:jsonb;comment:扩展信息(如套餐信息、支付方式等)" json:"metadata,omitempty"` + Creator uint `gorm:"column:creator;not null;comment:创建人ID" json:"creator"` + ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"` + EnterpriseIDTag *uint `gorm:"column:enterprise_id_tag;index;comment:企业ID标签(多租户过滤)" json:"enterprise_id_tag,omitempty"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"` +} + +// TableName 指定表名 +func (CardWalletTransaction) TableName() string { + return "tb_card_wallet_transaction" +} + +// CardRechargeRecord 卡充值记录模型 +// 记录所有卡充值操作 +type CardRechargeRecord struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + UserID uint `gorm:"column:user_id;not null;index;comment:操作人用户ID" json:"user_id"` + CardWalletID uint `gorm:"column:card_wallet_id;not null;comment:卡钱包ID" json:"card_wallet_id"` + ResourceType string `gorm:"column:resource_type;type:varchar(20);not null;index;comment:资源类型(冗余字段)" json:"resource_type"` + ResourceID uint `gorm:"column:resource_id;not null;index;comment:资源ID(冗余字段)" json:"resource_id"` + RechargeNo string `gorm:"column:recharge_no;type:varchar(50);not null;uniqueIndex;comment:充值订单号(格式:CRCH+时间戳+随机数)" json:"recharge_no"` + Amount int64 `gorm:"column:amount;type:bigint;not null;comment:充值金额(单位:分,最小100分=1元)" json:"amount"` + PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null;comment:支付方式(alipay-支付宝 | wechat-微信)" json:"payment_method"` + PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"` + PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"` + Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)" json:"status"` + PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"` + CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"` + ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"` + EnterpriseIDTag *uint `gorm:"column:enterprise_id_tag;index;comment:企业ID标签(多租户过滤)" json:"enterprise_id_tag,omitempty"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"` +} + +// TableName 指定表名 +func (CardRechargeRecord) TableName() string { + return "tb_card_recharge_record" +} diff --git a/internal/model/dto/iot_card_dto.go b/internal/model/dto/iot_card_dto.go index 93cb740..d2fb153 100644 --- a/internal/model/dto/iot_card_dto.go +++ b/internal/model/dto/iot_card_dto.go @@ -20,24 +20,24 @@ type ListStandaloneIotCardRequest struct { } type StandaloneIotCardResponse struct { - ID uint `json:"id" description:"卡ID"` - ICCID string `json:"iccid" description:"ICCID"` - CardCategory string `json:"card_category" description:"卡业务类型 (normal:普通卡, industry:行业卡)"` - CarrierID uint `json:"carrier_id" description:"运营商ID"` - CarrierType string `json:"carrier_type,omitempty" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"` - CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"` - IMSI string `json:"imsi,omitempty" description:"IMSI"` - MSISDN string `json:"msisdn,omitempty" description:"卡接入号"` - BatchNo string `json:"batch_no,omitempty" description:"批次号"` - Supplier string `json:"supplier,omitempty" description:"供应商"` - CostPrice int64 `json:"cost_price" description:"成本价(分)"` - DistributePrice int64 `json:"distribute_price" description:"分销价(分)"` - Status int `json:"status" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"` - ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"` - ShopName string `json:"shop_name,omitempty" description:"店铺名称"` - ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"` - ActivationStatus int `json:"activation_status" description:"激活状态 (0:未激活, 1:已激活)"` - RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"` + ID uint `json:"id" description:"卡ID"` + ICCID string `json:"iccid" description:"ICCID"` + CardCategory string `json:"card_category" description:"卡业务类型 (normal:普通卡, industry:行业卡)"` + CarrierID uint `json:"carrier_id" description:"运营商ID"` + CarrierType string `json:"carrier_type,omitempty" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"` + CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"` + IMSI string `json:"imsi,omitempty" description:"IMSI"` + MSISDN string `json:"msisdn,omitempty" description:"卡接入号"` + BatchNo string `json:"batch_no,omitempty" description:"批次号"` + Supplier string `json:"supplier,omitempty" description:"供应商"` + CostPrice int64 `json:"cost_price" description:"成本价(分)"` + DistributePrice int64 `json:"distribute_price" description:"分销价(分)"` + Status int `json:"status" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"` + ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"` + ShopName string `json:"shop_name,omitempty" description:"店铺名称"` + ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"` + ActivationStatus int `json:"activation_status" description:"激活状态 (0:未激活, 1:已激活)"` + RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"` NetworkStatus int `json:"network_status" description:"网络状态 (0:停机, 1:开机)"` DataUsageMB int64 `json:"data_usage_mb" description:"累计流量使用(MB)"` CurrentMonthUsageMB float64 `json:"current_month_usage_mb" description:"本月已用流量(MB)"` @@ -47,11 +47,11 @@ type StandaloneIotCardResponse struct { LastRealNameCheckAt *time.Time `json:"last_real_name_check_at,omitempty" description:"最后实名检查时间"` EnablePolling bool `json:"enable_polling" description:"是否参与轮询"` SeriesID *uint `json:"series_id,omitempty" description:"套餐系列ID"` - SeriesName string `json:"series_name,omitempty" description:"套餐系列名称"` - FirstCommissionPaid bool `json:"first_commission_paid" description:"一次性佣金是否已发放"` - AccumulatedRecharge int64 `json:"accumulated_recharge" description:"累计充值金额(分)"` - CreatedAt time.Time `json:"created_at" description:"创建时间"` - UpdatedAt time.Time `json:"updated_at" description:"更新时间"` + SeriesName string `json:"series_name,omitempty" description:"套餐系列名称"` + FirstCommissionPaid bool `json:"first_commission_paid" description:"一次性佣金是否已发放"` + AccumulatedRecharge int64 `json:"accumulated_recharge" description:"累计充值金额(分)"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` + UpdatedAt time.Time `json:"updated_at" description:"更新时间"` } type ListStandaloneIotCardResponse struct { diff --git a/internal/model/dto/polling_cleanup_dto.go b/internal/model/dto/polling_cleanup_dto.go index b610135..e986ac2 100644 --- a/internal/model/dto/polling_cleanup_dto.go +++ b/internal/model/dto/polling_cleanup_dto.go @@ -26,15 +26,15 @@ type UpdateDataCleanupConfigParams struct { // DataCleanupConfigResp 数据清理配置响应 type DataCleanupConfigResp struct { - ID uint `json:"id" description:"配置ID"` - TargetTable string `json:"table_name" description:"表名"` - RetentionDays int `json:"retention_days" description:"保留天数"` - BatchSize int `json:"batch_size" description:"每批删除条数"` - Enabled int `json:"enabled" description:"是否启用:0-禁用,1-启用"` - Description string `json:"description" description:"配置说明"` - CreatedAt time.Time `json:"created_at" description:"创建时间"` - UpdatedAt time.Time `json:"updated_at" description:"更新时间"` - UpdatedBy *uint `json:"updated_by,omitempty" description:"更新人ID"` + ID uint `json:"id" description:"配置ID"` + TargetTable string `json:"table_name" description:"表名"` + RetentionDays int `json:"retention_days" description:"保留天数"` + BatchSize int `json:"batch_size" description:"每批删除条数"` + Enabled int `json:"enabled" description:"是否启用:0-禁用,1-启用"` + Description string `json:"description" description:"配置说明"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` + UpdatedAt time.Time `json:"updated_at" description:"更新时间"` + UpdatedBy *uint `json:"updated_by,omitempty" description:"更新人ID"` } // DataCleanupConfigListResp 数据清理配置列表响应 diff --git a/internal/model/package.go b/internal/model/package.go index 3160326..6cce6bc 100644 --- a/internal/model/package.go +++ b/internal/model/package.go @@ -57,27 +57,27 @@ func (Package) TableName() string { // 跟踪单卡套餐和设备级套餐的流量使用 type PackageUsage struct { gorm.Model - BaseModel `gorm:"embedded"` - OrderID uint `gorm:"column:order_id;index;not null;comment:订单ID" json:"order_id"` - PackageID uint `gorm:"column:package_id;index;not null;comment:套餐ID" json:"package_id"` - UsageType string `gorm:"column:usage_type;type:varchar(20);not null;comment:使用类型 single_card-单卡套餐 device-设备级套餐" json:"usage_type"` - IotCardID uint `gorm:"column:iot_card_id;index;comment:IoT卡ID(单卡套餐时有值)" json:"iot_card_id"` - DeviceID uint `gorm:"column:device_id;index;comment:设备ID(设备级套餐时有值)" json:"device_id"` - DataLimitMB int64 `gorm:"column:data_limit_mb;type:bigint;not null;comment:流量限额(MB)" json:"data_limit_mb"` - DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;default:0;comment:已使用流量(MB)" json:"data_usage_mb"` - RealDataUsageMB int64 `gorm:"column:real_data_usage_mb;type:bigint;default:0;comment:真流量使用(MB)" json:"real_data_usage_mb"` - VirtualDataUsageMB int64 `gorm:"column:virtual_data_usage_mb;type:bigint;default:0;comment:虚流量使用(MB)" json:"virtual_data_usage_mb"` - ActivatedAt time.Time `gorm:"column:activated_at;not null;comment:套餐生效时间" json:"activated_at"` - ExpiresAt time.Time `gorm:"column:expires_at;not null;comment:套餐过期时间" json:"expires_at"` - Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 0-待生效 1-生效中 2-已用完 3-已过期 4-已失效" json:"status"` - LastPackageCheckAt *time.Time `gorm:"column:last_package_check_at;comment:最后一次套餐流量检查时间" json:"last_package_check_at"` - Priority int `gorm:"column:priority;type:int;default:1;index:idx_package_usage_priority;comment:优先级(主套餐和加油包按此字段排队,数字越小优先级越高)" json:"priority"` - MasterUsageID *uint `gorm:"column:master_usage_id;type:bigint;index:idx_package_usage_master_usage_id;comment:主套餐使用记录ID(加油包关联主套餐,主套餐此字段为NULL)" json:"master_usage_id"` - HasIndependentExpiry bool `gorm:"column:has_independent_expiry;type:boolean;default:false;comment:加油包是否有独立有效期(true-有独立到期时间 false-跟随主套餐)" json:"has_independent_expiry"` - PendingRealnameActivation bool `gorm:"column:pending_realname_activation;type:boolean;default:false;comment:是否等待实名激活(true-待实名后激活 false-已激活或不需实名)" json:"pending_realname_activation"` - DataResetCycle string `gorm:"column:data_reset_cycle;type:varchar(20);comment:流量重置周期(从Package复制,用于历史记录)" json:"data_reset_cycle"` - LastResetAt *time.Time `gorm:"column:last_reset_at;comment:最后一次流量重置时间" json:"last_reset_at"` - NextResetAt *time.Time `gorm:"column:next_reset_at;index:idx_package_usage_next_reset_at;comment:下次流量重置时间(用于定时任务查询)" json:"next_reset_at"` + BaseModel `gorm:"embedded"` + OrderID uint `gorm:"column:order_id;index;not null;comment:订单ID" json:"order_id"` + PackageID uint `gorm:"column:package_id;index;not null;comment:套餐ID" json:"package_id"` + UsageType string `gorm:"column:usage_type;type:varchar(20);not null;comment:使用类型 single_card-单卡套餐 device-设备级套餐" json:"usage_type"` + IotCardID uint `gorm:"column:iot_card_id;index;comment:IoT卡ID(单卡套餐时有值)" json:"iot_card_id"` + DeviceID uint `gorm:"column:device_id;index;comment:设备ID(设备级套餐时有值)" json:"device_id"` + DataLimitMB int64 `gorm:"column:data_limit_mb;type:bigint;not null;comment:流量限额(MB)" json:"data_limit_mb"` + DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;default:0;comment:已使用流量(MB)" json:"data_usage_mb"` + RealDataUsageMB int64 `gorm:"column:real_data_usage_mb;type:bigint;default:0;comment:真流量使用(MB)" json:"real_data_usage_mb"` + VirtualDataUsageMB int64 `gorm:"column:virtual_data_usage_mb;type:bigint;default:0;comment:虚流量使用(MB)" json:"virtual_data_usage_mb"` + ActivatedAt time.Time `gorm:"column:activated_at;not null;comment:套餐生效时间" json:"activated_at"` + ExpiresAt time.Time `gorm:"column:expires_at;not null;comment:套餐过期时间" json:"expires_at"` + Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 0-待生效 1-生效中 2-已用完 3-已过期 4-已失效" json:"status"` + LastPackageCheckAt *time.Time `gorm:"column:last_package_check_at;comment:最后一次套餐流量检查时间" json:"last_package_check_at"` + Priority int `gorm:"column:priority;type:int;default:1;index:idx_package_usage_priority;comment:优先级(主套餐和加油包按此字段排队,数字越小优先级越高)" json:"priority"` + MasterUsageID *uint `gorm:"column:master_usage_id;type:bigint;index:idx_package_usage_master_usage_id;comment:主套餐使用记录ID(加油包关联主套餐,主套餐此字段为NULL)" json:"master_usage_id"` + HasIndependentExpiry bool `gorm:"column:has_independent_expiry;type:boolean;default:false;comment:加油包是否有独立有效期(true-有独立到期时间 false-跟随主套餐)" json:"has_independent_expiry"` + PendingRealnameActivation bool `gorm:"column:pending_realname_activation;type:boolean;default:false;comment:是否等待实名激活(true-待实名后激活 false-已激活或不需实名)" json:"pending_realname_activation"` + DataResetCycle string `gorm:"column:data_reset_cycle;type:varchar(20);comment:流量重置周期(从Package复制,用于历史记录)" json:"data_reset_cycle"` + LastResetAt *time.Time `gorm:"column:last_reset_at;comment:最后一次流量重置时间" json:"last_reset_at"` + NextResetAt *time.Time `gorm:"column:next_reset_at;index:idx_package_usage_next_reset_at;comment:下次流量重置时间(用于定时任务查询)" json:"next_reset_at"` } // TableName 指定表名 @@ -88,13 +88,13 @@ func (PackageUsage) TableName() string { // PackageUsageDailyRecord 套餐流量日记录模型 // 记录每个套餐每天的流量使用情况,用于流量详单查询 type PackageUsageDailyRecord struct { - ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` - PackageUsageID uint `gorm:"column:package_usage_id;not null;uniqueIndex:idx_package_usage_daily_record_unique;index:idx_package_usage_daily_record_date;comment:套餐使用记录ID" json:"package_usage_id"` - Date time.Time `gorm:"column:date;type:date;not null;uniqueIndex:idx_package_usage_daily_record_unique;comment:日期" json:"date"` - DailyUsageMB int `gorm:"column:daily_usage_mb;type:int;default:0;comment:当日流量使用量(MB)" json:"daily_usage_mb"` - CumulativeUsageMB int64 `gorm:"column:cumulative_usage_mb;type:bigint;default:0;comment:截止当日的累计流量(MB)" json:"cumulative_usage_mb"` - CreatedAt time.Time `gorm:"column:created_at;default:CURRENT_TIMESTAMP" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;default:CURRENT_TIMESTAMP" json:"updated_at"` + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + PackageUsageID uint `gorm:"column:package_usage_id;not null;uniqueIndex:idx_package_usage_daily_record_unique;index:idx_package_usage_daily_record_date;comment:套餐使用记录ID" json:"package_usage_id"` + Date time.Time `gorm:"column:date;type:date;not null;uniqueIndex:idx_package_usage_daily_record_unique;comment:日期" json:"date"` + DailyUsageMB int `gorm:"column:daily_usage_mb;type:int;default:0;comment:当日流量使用量(MB)" json:"daily_usage_mb"` + CumulativeUsageMB int64 `gorm:"column:cumulative_usage_mb;type:bigint;default:0;comment:截止当日的累计流量(MB)" json:"cumulative_usage_mb"` + CreatedAt time.Time `gorm:"column:created_at;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;default:CURRENT_TIMESTAMP" json:"updated_at"` } // TableName 指定表名 diff --git a/internal/model/polling.go b/internal/model/polling.go index bca1083..d51c755 100644 --- a/internal/model/polling.go +++ b/internal/model/polling.go @@ -6,21 +6,21 @@ import ( // PollingConfig 轮询配置表 type PollingConfig struct { - ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` - ConfigName string `gorm:"column:config_name;type:varchar(100);not null;comment:配置名称" json:"config_name"` - CardCondition string `gorm:"column:card_condition;type:varchar(50);comment:卡状态条件:not_real_name/real_name/activated/suspended" json:"card_condition"` - CardCategory string `gorm:"column:card_category;type:varchar(50);comment:卡业务类型:normal/industry" json:"card_category"` - CarrierID *uint `gorm:"column:carrier_id;comment:运营商ID(可选,精确匹配)" json:"carrier_id"` - Priority int `gorm:"column:priority;not null;default:100;comment:优先级(数字越小优先级越高)" json:"priority"` - RealnameCheckInterval *int `gorm:"column:realname_check_interval;comment:实名检查间隔(秒),NULL表示不检查" json:"realname_check_interval"` - CarddataCheckInterval *int `gorm:"column:carddata_check_interval;comment:流量检查间隔(秒),NULL表示不检查" json:"carddata_check_interval"` - PackageCheckInterval *int `gorm:"column:package_check_interval;comment:套餐检查间隔(秒),NULL表示不检查" json:"package_check_interval"` - Status int16 `gorm:"column:status;type:smallint;not null;default:1;comment:状态:0-禁用,1-启用" json:"status"` - Description string `gorm:"column:description;type:text;comment:配置说明" json:"description"` - CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP;comment:更新时间" json:"updated_at"` - CreatedBy *uint `gorm:"column:created_by;comment:创建人ID" json:"created_by"` - UpdatedBy *uint `gorm:"column:updated_by;comment:更新人ID" json:"updated_by"` + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + ConfigName string `gorm:"column:config_name;type:varchar(100);not null;comment:配置名称" json:"config_name"` + CardCondition string `gorm:"column:card_condition;type:varchar(50);comment:卡状态条件:not_real_name/real_name/activated/suspended" json:"card_condition"` + CardCategory string `gorm:"column:card_category;type:varchar(50);comment:卡业务类型:normal/industry" json:"card_category"` + CarrierID *uint `gorm:"column:carrier_id;comment:运营商ID(可选,精确匹配)" json:"carrier_id"` + Priority int `gorm:"column:priority;not null;default:100;comment:优先级(数字越小优先级越高)" json:"priority"` + RealnameCheckInterval *int `gorm:"column:realname_check_interval;comment:实名检查间隔(秒),NULL表示不检查" json:"realname_check_interval"` + CarddataCheckInterval *int `gorm:"column:carddata_check_interval;comment:流量检查间隔(秒),NULL表示不检查" json:"carddata_check_interval"` + PackageCheckInterval *int `gorm:"column:package_check_interval;comment:套餐检查间隔(秒),NULL表示不检查" json:"package_check_interval"` + Status int16 `gorm:"column:status;type:smallint;not null;default:1;comment:状态:0-禁用,1-启用" json:"status"` + Description string `gorm:"column:description;type:text;comment:配置说明" json:"description"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP;comment:更新时间" json:"updated_at"` + CreatedBy *uint `gorm:"column:created_by;comment:创建人ID" json:"created_by"` + UpdatedBy *uint `gorm:"column:updated_by;comment:更新人ID" json:"updated_by"` } // TableName 指定表名 diff --git a/internal/model/wallet.go b/internal/model/wallet.go deleted file mode 100644 index 97fa47d..0000000 --- a/internal/model/wallet.go +++ /dev/null @@ -1,99 +0,0 @@ -package model - -import ( - "database/sql/driver" - "encoding/json" - "time" - - "gorm.io/gorm" -) - -// Wallet 钱包模型 -// 个人客户和代理商的资金账户,支持充值、消费、提现等操作 -// 使用乐观锁(version字段)防止并发余额冲突 -// 钱包归属资源(卡/设备/店铺),支持资源转手场景 -type Wallet struct { - gorm.Model - BaseModel `gorm:"embedded"` - ResourceType string `gorm:"column:resource_type;type:varchar(20);not null;uniqueIndex:idx_wallet_resource_type_currency,priority:1;index:idx_wallet_resource,priority:1;comment:资源类型 iot_card-物联网卡 device-设备 shop-店铺" json:"resource_type"` - ResourceID uint `gorm:"column:resource_id;not null;uniqueIndex:idx_wallet_resource_type_currency,priority:2;index:idx_wallet_resource,priority:2;comment:资源ID" json:"resource_id"` - WalletType string `gorm:"column:wallet_type;type:varchar(20);not null;uniqueIndex:idx_wallet_resource_type_currency,priority:3;comment:钱包类型 main-主钱包 commission-分佣钱包" json:"wallet_type"` - Balance int64 `gorm:"column:balance;type:bigint;not null;default:0;comment:余额(分)" json:"balance"` - FrozenBalance int64 `gorm:"column:frozen_balance;type:bigint;not null;default:0;comment:冻结余额(分)" json:"frozen_balance"` - Currency string `gorm:"column:currency;type:varchar(10);not null;default:'CNY';uniqueIndex:idx_wallet_resource_type_currency,priority:4;comment:币种" json:"currency"` - Status int `gorm:"column:status;type:int;not null;default:1;index:idx_wallet_status;comment:钱包状态 1-正常 2-冻结 3-关闭" json:"status"` - Version int `gorm:"column:version;type:int;not null;default:0;comment:版本号(乐观锁)" json:"version"` -} - -// TableName 指定表名 -func (Wallet) TableName() string { - return "tb_wallet" -} - -// WalletMetadata 钱包交易扩展信息 -// 用于存储交易相关的额外数据(JSONB格式) -type WalletMetadata map[string]interface{} - -// Value 实现 driver.Valuer 接口 -func (m WalletMetadata) Value() (driver.Value, error) { - return json.Marshal(m) -} - -// Scan 实现 sql.Scanner 接口 -func (m *WalletMetadata) Scan(value interface{}) error { - if value == nil { - *m = make(WalletMetadata) - return nil - } - bytes, ok := value.([]byte) - if !ok { - return nil - } - return json.Unmarshal(bytes, m) -} - -// WalletTransaction 钱包交易记录模型 -// 记录所有钱包余额变动,包含变动前后余额用于对账 -// 支持关联业务对象(订单、分佣、提现等) -type WalletTransaction struct { - gorm.Model - WalletID uint `gorm:"column:wallet_id;not null;index:idx_wallet_tx_wallet;comment:钱包ID" json:"wallet_id"` - UserID uint `gorm:"column:user_id;not null;index:idx_wallet_tx_user;comment:用户ID" json:"user_id"` - TransactionType string `gorm:"column:transaction_type;type:varchar(20);not null;comment:交易类型 recharge-充值 deduct-扣款 refund-退款 commission-分佣 withdrawal-提现" json:"transaction_type"` - Amount int64 `gorm:"column:amount;type:bigint;not null;comment:变动金额(分)正数为增加 负数为减少" json:"amount"` - BalanceBefore int64 `gorm:"column:balance_before;type:bigint;not null;comment:变动前余额(分)" json:"balance_before"` - BalanceAfter int64 `gorm:"column:balance_after;type:bigint;not null;comment:变动后余额(分)" json:"balance_after"` - Status int `gorm:"column:status;type:int;not null;default:1;comment:交易状态 1-成功 2-失败 3-处理中" json:"status"` - ReferenceType *string `gorm:"column:reference_type;type:varchar(50);index:idx_wallet_tx_ref,priority:1;comment:关联业务类型 order/commission/withdrawal/topup" json:"reference_type,omitempty"` - ReferenceID *uint `gorm:"column:reference_id;type:bigint;index:idx_wallet_tx_ref,priority:2;comment:关联业务ID" json:"reference_id,omitempty"` - Remark *string `gorm:"column:remark;type:text;comment:备注" json:"remark,omitempty"` - Metadata WalletMetadata `gorm:"column:metadata;type:jsonb;comment:扩展信息" json:"metadata,omitempty"` - Creator uint `gorm:"column:creator;comment:创建人ID" json:"creator"` -} - -// TableName 指定表名 -func (WalletTransaction) TableName() string { - return "tb_wallet_transaction" -} - -// RechargeRecord 充值记录模型 -// 用户和代理的钱包充值订单,记录支付流程和状态 -type RechargeRecord struct { - gorm.Model - BaseModel `gorm:"embedded"` - UserID uint `gorm:"column:user_id;not null;index:idx_recharge_user;comment:用户ID" json:"user_id"` - WalletID uint `gorm:"column:wallet_id;not null;comment:钱包ID" json:"wallet_id"` - RechargeNo string `gorm:"column:recharge_no;type:varchar(50);not null;uniqueIndex:idx_recharge_no;comment:充值订单号" json:"recharge_no"` - Amount int64 `gorm:"column:amount;type:bigint;not null;comment:充值金额(分)" json:"amount"` - PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null;comment:支付方式 alipay-支付宝 wechat-微信 bank-银行转账 offline-线下" json:"payment_method"` - PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"` - PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"` - Status int `gorm:"column:status;type:int;not null;default:1;index:idx_recharge_status;comment:充值状态 1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款" json:"status"` - PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"` - CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"` -} - -// TableName 指定表名 -func (RechargeRecord) TableName() string { - return "tb_recharge_record" -} diff --git a/internal/polling/package_activation_handler.go b/internal/polling/package_activation_handler.go index 8e83a36..7dda0a6 100644 --- a/internal/polling/package_activation_handler.go +++ b/internal/polling/package_activation_handler.go @@ -19,18 +19,18 @@ import ( // PackageActivationHandler 套餐激活检查处理器 // 任务 19: 处理主套餐过期、加油包级联失效、待生效主套餐激活 type PackageActivationHandler struct { - db *gorm.DB - redis *redis.Client - queueClient *asynq.Client - packageUsageStore *postgres.PackageUsageStore - activationService *packagepkg.ActivationService - logger *zap.Logger + db *gorm.DB + redis *redis.Client + queueClient *asynq.Client + packageUsageStore *postgres.PackageUsageStore + activationService *packagepkg.ActivationService + logger *zap.Logger } // PackageActivationPayload 套餐激活任务载荷 type PackageActivationPayload struct { PackageUsageID uint `json:"package_usage_id"` - CarrierType string `json:"carrier_type"` // "iot_card" 或 "device" + CarrierType string `json:"carrier_type"` // "iot_card" 或 "device" CarrierID uint `json:"carrier_id"` ActivationType string `json:"activation_type"` // "queue" 或 "realname" Timestamp int64 `json:"timestamp"` @@ -102,7 +102,7 @@ func (h *PackageActivationHandler) findExpiredMainPackages(ctx context.Context) Where("status = ?", constants.PackageUsageStatusActive). Where("expires_at <= ?", now). Where("master_usage_id IS NULL"). // 主套餐没有 master_usage_id - Limit(1000). // 每次最多处理 1000 个,避免长事务 + Limit(1000). // 每次最多处理 1000 个,避免长事务 Find(&packages).Error return packages, err @@ -353,9 +353,9 @@ func (h *PackageActivationHandler) HandlePackageFirstActivation(ctx context.Cont // ActivationService 未注入,直接更新状态(备用逻辑) now := time.Now() if err := h.db.Model(&pkg).Updates(map[string]any{ - "status": constants.PackageUsageStatusActive, - "activated_at": now, - "pending_realname_activation": false, + "status": constants.PackageUsageStatusActive, + "activated_at": now, + "pending_realname_activation": false, }).Error; err != nil { return err } diff --git a/internal/polling/scheduler.go b/internal/polling/scheduler.go index 14f2260..5e69dd3 100644 --- a/internal/polling/scheduler.go +++ b/internal/polling/scheduler.go @@ -20,13 +20,13 @@ import ( // Scheduler 轮询调度器 // 负责管理 IoT 卡的定期检查任务(实名、流量、套餐) type Scheduler struct { - db *gorm.DB - redis *redis.Client - queueClient *asynq.Client - logger *zap.Logger - configStore *postgres.PollingConfigStore - iotCardStore *postgres.IotCardStore - concurrencyStore *postgres.PollingConcurrencyConfigStore + db *gorm.DB + redis *redis.Client + queueClient *asynq.Client + logger *zap.Logger + configStore *postgres.PollingConfigStore + iotCardStore *postgres.IotCardStore + concurrencyStore *postgres.PollingConcurrencyConfigStore // 任务 19: 套餐激活检查处理器 packageActivationHandler *PackageActivationHandler @@ -50,12 +50,12 @@ type Scheduler struct { // InitProgress 初始化进度 type InitProgress struct { mu sync.RWMutex - TotalCards int64 `json:"total_cards"` // 总卡数 - LoadedCards int64 `json:"loaded_cards"` // 已加载卡数 - StartTime time.Time `json:"start_time"` // 开始时间 + TotalCards int64 `json:"total_cards"` // 总卡数 + LoadedCards int64 `json:"loaded_cards"` // 已加载卡数 + StartTime time.Time `json:"start_time"` // 开始时间 LastBatchTime time.Time `json:"last_batch_time"` // 最后一批处理时间 - Status string `json:"status"` // 状态: pending, running, completed, failed - ErrorMessage string `json:"error_message"` // 错误信息 + Status string `json:"status"` // 状态: pending, running, completed, failed + ErrorMessage string `json:"error_message"` // 错误信息 } // SchedulerConfig 调度器配置 @@ -74,13 +74,13 @@ type SchedulerConfig struct { // 单 Worker 设计吞吐:50000 张/秒,支持多 Worker 水平扩展 func DefaultSchedulerConfig() *SchedulerConfig { return &SchedulerConfig{ - ScheduleInterval: 1 * time.Second, // 1秒调度一次,提高响应速度 - InitBatchSize: 100000, // 10万张/批初始化 + ScheduleInterval: 1 * time.Second, // 1秒调度一次,提高响应速度 + InitBatchSize: 100000, // 10万张/批初始化 InitBatchSleepDuration: 500 * time.Millisecond, // 500ms 间隔,加快初始化 ConfigCacheTTL: 5 * time.Minute, CardCacheTTL: 7 * 24 * time.Hour, - ScheduleBatchSize: 50000, // 每次取 5 万张,每秒可调度 5 万张 - MaxManualBatchSize: 1000, // 手动触发每次处理 1000 张 + ScheduleBatchSize: 50000, // 每次取 5 万张,每秒可调度 5 万张 + MaxManualBatchSize: 1000, // 手动触发每次处理 1000 张 } } @@ -383,8 +383,8 @@ func (s *Scheduler) enqueueTask(ctx context.Context, taskType, cardID string, is } task := asynq.NewTask(taskType, mustMarshal(payload), - asynq.MaxRetry(0), // 不重试,失败后重新入队 - asynq.Timeout(30*time.Second), // 30秒超时 + asynq.MaxRetry(0), // 不重试,失败后重新入队 + asynq.Timeout(30*time.Second), // 30秒超时 asynq.Queue(constants.QueueDefault), ) @@ -681,13 +681,13 @@ func (s *Scheduler) cacheCardInfo(ctx context.Context, card *model.IotCard, cach config := DefaultSchedulerConfig() data := map[string]interface{}{ - "id": card.ID, - "iccid": card.ICCID, - "card_category": card.CardCategory, - "real_name_status": card.RealNameStatus, - "network_status": card.NetworkStatus, - "carrier_id": card.CarrierID, - "cached_at": cachedAt.Unix(), + "id": card.ID, + "iccid": card.ICCID, + "card_category": card.CardCategory, + "real_name_status": card.RealNameStatus, + "network_status": card.NetworkStatus, + "carrier_id": card.CarrierID, + "cached_at": cachedAt.Unix(), } pipe := s.redis.Pipeline() diff --git a/internal/service/commission_calculation/service.go b/internal/service/commission_calculation/service.go index b2dd435..ac14c6c 100644 --- a/internal/service/commission_calculation/service.go +++ b/internal/service/commission_calculation/service.go @@ -14,22 +14,22 @@ import ( ) type Service struct { - db *gorm.DB - commissionRecordStore *postgres.CommissionRecordStore - shopStore *postgres.ShopStore - shopPackageAllocationStore *postgres.ShopPackageAllocationStore - shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore - packageSeriesStore *postgres.PackageSeriesStore - iotCardStore *postgres.IotCardStore - deviceStore *postgres.DeviceStore - walletStore *postgres.WalletStore - walletTransactionStore *postgres.WalletTransactionStore - orderStore *postgres.OrderStore - orderItemStore *postgres.OrderItemStore - packageStore *postgres.PackageStore - commissionStatsStore *postgres.ShopSeriesCommissionStatsStore - commissionStatsService *commission_stats.Service - logger *zap.Logger + db *gorm.DB + commissionRecordStore *postgres.CommissionRecordStore + shopStore *postgres.ShopStore + shopPackageAllocationStore *postgres.ShopPackageAllocationStore + shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore + packageSeriesStore *postgres.PackageSeriesStore + iotCardStore *postgres.IotCardStore + deviceStore *postgres.DeviceStore + agentWalletStore *postgres.AgentWalletStore + agentWalletTransactionStore *postgres.AgentWalletTransactionStore + orderStore *postgres.OrderStore + orderItemStore *postgres.OrderItemStore + packageStore *postgres.PackageStore + commissionStatsStore *postgres.ShopSeriesCommissionStatsStore + commissionStatsService *commission_stats.Service + logger *zap.Logger } func New( @@ -41,8 +41,8 @@ func New( packageSeriesStore *postgres.PackageSeriesStore, iotCardStore *postgres.IotCardStore, deviceStore *postgres.DeviceStore, - walletStore *postgres.WalletStore, - walletTransactionStore *postgres.WalletTransactionStore, + agentWalletStore *postgres.AgentWalletStore, + agentWalletTransactionStore *postgres.AgentWalletTransactionStore, orderStore *postgres.OrderStore, orderItemStore *postgres.OrderItemStore, packageStore *postgres.PackageStore, @@ -51,22 +51,22 @@ func New( logger *zap.Logger, ) *Service { return &Service{ - db: db, - commissionRecordStore: commissionRecordStore, - shopStore: shopStore, - shopPackageAllocationStore: shopPackageAllocationStore, - shopSeriesAllocationStore: shopSeriesAllocationStore, - packageSeriesStore: packageSeriesStore, - iotCardStore: iotCardStore, - deviceStore: deviceStore, - walletStore: walletStore, - walletTransactionStore: walletTransactionStore, - orderStore: orderStore, - orderItemStore: orderItemStore, - packageStore: packageStore, - commissionStatsStore: commissionStatsStore, - commissionStatsService: commissionStatsService, - logger: logger, + db: db, + commissionRecordStore: commissionRecordStore, + shopStore: shopStore, + shopPackageAllocationStore: shopPackageAllocationStore, + shopSeriesAllocationStore: shopSeriesAllocationStore, + packageSeriesStore: packageSeriesStore, + iotCardStore: iotCardStore, + deviceStore: deviceStore, + agentWalletStore: agentWalletStore, + agentWalletTransactionStore: agentWalletTransactionStore, + orderStore: orderStore, + orderItemStore: orderItemStore, + packageStore: packageStore, + commissionStatsStore: commissionStatsStore, + commissionStatsService: commissionStatsService, + logger: logger, } } @@ -562,25 +562,20 @@ func (s *Service) matchOneTimeCommissionTier(ctx context.Context, shopID uint, s } func (s *Service) creditCommissionInTx(ctx context.Context, tx *gorm.DB, record *model.CommissionRecord) error { - var wallet model.Wallet - if err := tx.Where("resource_type = ? AND resource_id = ? AND wallet_type = ?", "shop", record.ShopID, "commission").First(&wallet).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "获取店铺钱包失败") + // 获取店铺的分佣钱包 + var wallet model.AgentWallet + if err := tx.Where("shop_id = ? AND wallet_type = ?", record.ShopID, "commission").First(&wallet).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "获取店铺分佣钱包失败") } balanceBefore := wallet.Balance - result := tx.Model(&model.Wallet{}). - Where("id = ? AND version = ?", wallet.ID, wallet.Version). - Updates(map[string]any{ - "balance": gorm.Expr("balance + ?", record.Amount), - "version": gorm.Expr("version + 1"), - }) - if result.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新钱包余额失败") - } - if result.RowsAffected == 0 { - return errors.New(errors.CodeInternalError, "钱包版本冲突,请重试") + + // 使用 AgentWalletStore 的方法增加余额(带事务) + if err := s.agentWalletStore.AddBalanceWithTx(ctx, tx, wallet.ID, record.Amount); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "更新钱包余额失败") } + // 更新佣金记录状态 now := time.Now() if err := tx.Model(record).Updates(map[string]any{ "balance_after": balanceBefore + record.Amount, @@ -590,9 +585,11 @@ func (s *Service) creditCommissionInTx(ctx context.Context, tx *gorm.DB, record return errors.Wrap(errors.CodeDatabaseError, err, "更新佣金记录失败") } + // 创建代理钱包交易记录 remark := "佣金入账" - transaction := &model.WalletTransaction{ - WalletID: wallet.ID, + transaction := &model.AgentWalletTransaction{ + AgentWalletID: wallet.ID, + ShopID: record.ShopID, UserID: record.Creator, TransactionType: "commission", Amount: record.Amount, @@ -603,8 +600,9 @@ func (s *Service) creditCommissionInTx(ctx context.Context, tx *gorm.DB, record ReferenceID: &record.ID, Remark: &remark, Creator: record.Creator, + ShopIDTag: record.ShopID, } - if err := tx.Create(transaction).Error; err != nil { + if err := s.agentWalletTransactionStore.CreateWithTx(ctx, tx, transaction); err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败") } diff --git a/internal/service/commission_withdrawal/service.go b/internal/service/commission_withdrawal/service.go index d579b65..c76e493 100644 --- a/internal/service/commission_withdrawal/service.go +++ b/internal/service/commission_withdrawal/service.go @@ -19,8 +19,8 @@ type Service struct { db *gorm.DB shopStore *postgres.ShopStore accountStore *postgres.AccountStore - walletStore *postgres.WalletStore - walletTransactionStore *postgres.WalletTransactionStore + agentWalletStore *postgres.AgentWalletStore + agentWalletTransactionStore *postgres.AgentWalletTransactionStore commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore } @@ -28,16 +28,16 @@ func New( db *gorm.DB, shopStore *postgres.ShopStore, accountStore *postgres.AccountStore, - walletStore *postgres.WalletStore, - walletTransactionStore *postgres.WalletTransactionStore, + agentWalletStore *postgres.AgentWalletStore, + agentWalletTransactionStore *postgres.AgentWalletTransactionStore, commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore, ) *Service { return &Service{ db: db, shopStore: shopStore, accountStore: accountStore, - walletStore: walletStore, - walletTransactionStore: walletTransactionStore, + agentWalletStore: agentWalletStore, + agentWalletTransactionStore: agentWalletTransactionStore, commissionWithdrawalReqStore: commissionWithdrawalReqStore, } } @@ -157,7 +157,8 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveWithdraw return nil, errors.New(errors.CodeInvalidStatus, "申请状态不允许此操作") } - wallet, err := s.walletStore.GetShopCommissionWallet(ctx, withdrawal.ShopID) + // 获取店铺分佣钱包 + wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, withdrawal.ShopID) if err != nil { return nil, errors.New(errors.CodeNotFound, "店铺佣金钱包不存在") } @@ -173,14 +174,17 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveWithdraw now := time.Now() err = s.db.Transaction(func(tx *gorm.DB) error { - if err := s.walletStore.DeductFrozenBalanceWithTx(ctx, tx, wallet.ID, amount); err != nil { + // 从冻结余额扣款 + if err := s.agentWalletStore.DeductFrozenBalanceWithTx(ctx, tx, wallet.ID, amount); err != nil { return errors.Wrap(errors.CodeInternalError, err, "扣除冻结余额失败") } + // 创建代理钱包交易记录 refType := "withdrawal" refID := withdrawal.ID - transaction := &model.WalletTransaction{ - WalletID: wallet.ID, + transaction := &model.AgentWalletTransaction{ + AgentWalletID: wallet.ID, + ShopID: withdrawal.ShopID, UserID: currentUserID, TransactionType: "withdrawal", Amount: -amount, @@ -190,8 +194,9 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveWithdraw ReferenceType: &refType, ReferenceID: &refID, Creator: currentUserID, + ShopIDTag: withdrawal.ShopID, } - if err := s.walletTransactionStore.CreateWithTx(ctx, tx, transaction); err != nil { + if err := s.agentWalletTransactionStore.CreateWithTx(ctx, tx, transaction); err != nil { return errors.Wrap(errors.CodeInternalError, err, "创建交易流水失败") } @@ -265,21 +270,22 @@ func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectWithdrawal return nil, errors.New(errors.CodeInvalidStatus, "申请状态不允许此操作") } - wallet, err := s.walletStore.GetShopCommissionWallet(ctx, withdrawal.ShopID) + wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, withdrawal.ShopID) if err != nil { return nil, errors.New(errors.CodeNotFound, "店铺佣金钱包不存在") } now := time.Now() err = s.db.Transaction(func(tx *gorm.DB) error { - if err := s.walletStore.UnfreezeBalanceWithTx(ctx, tx, wallet.ID, withdrawal.Amount); err != nil { + if err := s.agentWalletStore.UnfreezeBalanceWithTx(ctx, tx, wallet.ID, withdrawal.Amount); err != nil { return errors.Wrap(errors.CodeInternalError, err, "解冻余额失败") } refType := "withdrawal" refID := withdrawal.ID - transaction := &model.WalletTransaction{ - WalletID: wallet.ID, + transaction := &model.AgentWalletTransaction{ + AgentWalletID: wallet.ID, + ShopID: withdrawal.ShopID, UserID: currentUserID, TransactionType: "refund", Amount: withdrawal.Amount, @@ -289,8 +295,9 @@ func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectWithdrawal ReferenceType: &refType, ReferenceID: &refID, Creator: currentUserID, + ShopIDTag: withdrawal.ShopID, } - if err := s.walletTransactionStore.CreateWithTx(ctx, tx, transaction); err != nil { + if err := s.agentWalletTransactionStore.CreateWithTx(ctx, tx, transaction); err != nil { return errors.Wrap(errors.CodeInternalError, err, "创建交易流水失败") } diff --git a/internal/service/iot_card/stop_resume_service.go b/internal/service/iot_card/stop_resume_service.go index c2ab460..d090d8c 100644 --- a/internal/service/iot_card/stop_resume_service.go +++ b/internal/service/iot_card/stop_resume_service.go @@ -42,7 +42,7 @@ func NewStopResumeService( iotCardStore: iotCardStore, gatewayClient: gatewayClient, logger: logger, - maxRetries: 3, // 默认最多重试 3 次 + maxRetries: 3, // 默认最多重试 3 次 retryInterval: 2 * time.Second, // 默认重试间隔 2 秒 } } diff --git a/internal/service/my_commission/service.go b/internal/service/my_commission/service.go index 13cafdf..7a6ac7b 100644 --- a/internal/service/my_commission/service.go +++ b/internal/service/my_commission/service.go @@ -19,30 +19,30 @@ import ( type Service struct { db *gorm.DB shopStore *postgres.ShopStore - walletStore *postgres.WalletStore + agentWalletStore *postgres.AgentWalletStore commissionWithdrawalRequestStore *postgres.CommissionWithdrawalRequestStore commissionWithdrawalSettingStore *postgres.CommissionWithdrawalSettingStore commissionRecordStore *postgres.CommissionRecordStore - walletTransactionStore *postgres.WalletTransactionStore + agentWalletTransactionStore *postgres.AgentWalletTransactionStore } func New( db *gorm.DB, shopStore *postgres.ShopStore, - walletStore *postgres.WalletStore, + agentWalletStore *postgres.AgentWalletStore, commissionWithdrawalRequestStore *postgres.CommissionWithdrawalRequestStore, commissionWithdrawalSettingStore *postgres.CommissionWithdrawalSettingStore, commissionRecordStore *postgres.CommissionRecordStore, - walletTransactionStore *postgres.WalletTransactionStore, + agentWalletTransactionStore *postgres.AgentWalletTransactionStore, ) *Service { return &Service{ db: db, shopStore: shopStore, - walletStore: walletStore, + agentWalletStore: agentWalletStore, commissionWithdrawalRequestStore: commissionWithdrawalRequestStore, commissionWithdrawalSettingStore: commissionWithdrawalSettingStore, commissionRecordStore: commissionRecordStore, - walletTransactionStore: walletTransactionStore, + agentWalletTransactionStore: agentWalletTransactionStore, } } @@ -63,8 +63,8 @@ func (s *Service) GetCommissionSummary(ctx context.Context) (*dto.MyCommissionSu return nil, errors.New(errors.CodeShopNotFound, "店铺不存在") } - // 使用 GetShopCommissionWallet 获取店铺佣金钱包 - wallet, err := s.walletStore.GetShopCommissionWallet(ctx, shopID) + // 使用 GetCommissionWallet 获取店铺佣金钱包 + wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, shopID) if err != nil { // 钱包不存在时返回空数据 return &dto.MyCommissionSummaryResp{ @@ -120,7 +120,7 @@ func (s *Service) CreateWithdrawalRequest(ctx context.Context, req *dto.CreateMy } // 获取钱包 - wallet, err := s.walletStore.GetShopCommissionWallet(ctx, shopID) + wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, shopID) if err != nil { return nil, errors.New(errors.CodeInsufficientBalance, "钱包不存在") } @@ -160,7 +160,7 @@ func (s *Service) CreateWithdrawalRequest(ctx context.Context, req *dto.CreateMy err = s.db.Transaction(func(tx *gorm.DB) error { // 冻结余额 - if err := tx.WithContext(ctx).Model(&model.Wallet{}). + if err := tx.WithContext(ctx).Model(&model.AgentWallet{}). Where("id = ? AND balance >= ?", wallet.ID, req.Amount). Updates(map[string]interface{}{ "balance": gorm.Expr("balance - ?", req.Amount), @@ -192,8 +192,9 @@ func (s *Service) CreateWithdrawalRequest(ctx context.Context, req *dto.CreateMy // 创建钱包流水记录 remark := fmt.Sprintf("提现冻结,单号:%s", withdrawalNo) refType := constants.ReferenceTypeWithdrawal - transaction := &model.WalletTransaction{ - WalletID: wallet.ID, + transaction := &model.AgentWalletTransaction{ + AgentWalletID: wallet.ID, + ShopID: shopID, UserID: currentUserID, TransactionType: constants.TransactionTypeWithdrawal, Amount: -req.Amount, // 冻结为负数 @@ -204,6 +205,7 @@ func (s *Service) CreateWithdrawalRequest(ctx context.Context, req *dto.CreateMy ReferenceID: &withdrawalRequest.ID, Remark: &remark, Creator: currentUserID, + ShopIDTag: shopID, } if err := tx.WithContext(ctx).Create(transaction).Error; err != nil { diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 55d1667..892c533 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -30,7 +30,8 @@ type Service struct { redis *redis.Client orderStore *postgres.OrderStore orderItemStore *postgres.OrderItemStore - walletStore *postgres.WalletStore + agentWalletStore *postgres.AgentWalletStore + cardWalletStore *postgres.CardWalletStore purchaseValidationService *purchase_validation.Service shopPackageAllocationStore *postgres.ShopPackageAllocationStore shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore @@ -49,7 +50,8 @@ func New( redisClient *redis.Client, orderStore *postgres.OrderStore, orderItemStore *postgres.OrderItemStore, - walletStore *postgres.WalletStore, + agentWalletStore *postgres.AgentWalletStore, + cardWalletStore *postgres.CardWalletStore, purchaseValidationService *purchase_validation.Service, shopPackageAllocationStore *postgres.ShopPackageAllocationStore, shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore, @@ -67,7 +69,8 @@ func New( redis: redisClient, orderStore: orderStore, orderItemStore: orderItemStore, - walletStore: walletStore, + agentWalletStore: agentWalletStore, + cardWalletStore: cardWalletStore, purchaseValidationService: purchaseValidationService, shopPackageAllocationStore: shopPackageAllocationStore, shopSeriesAllocationStore: shopSeriesAllocationStore, @@ -430,64 +433,128 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, return errors.New(errors.CodeInvalidParam, "不支持的买家类型") } - wallet, err := s.walletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID, "main") - if err != nil { - if err == gorm.ErrRecordNotFound { - return errors.New(errors.CodeWalletNotFound, "钱包不存在") - } - return err - } - - if wallet.Balance < order.TotalAmount { - return errors.New(errors.CodeInsufficientBalance, "余额不足") - } - + // 根据资源类型选择对应的钱包系统 now := time.Now() - err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - result := tx.Model(&model.Order{}). - Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending). - Updates(map[string]any{ - "payment_status": model.PaymentStatusPaid, - "payment_method": model.PaymentMethodWallet, - "paid_at": now, - }) - if result.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单支付状态失败") + + if resourceType == "shop" { + // 代理钱包系统(店铺) + wallet, err := s.agentWalletStore.GetMainWallet(ctx, resourceID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeWalletNotFound, "钱包不存在") + } + return err } - if result.RowsAffected == 0 { - var currentOrder model.Order - if err := tx.First(¤tOrder, orderID).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "查询订单失败") + if wallet.Balance < order.TotalAmount { + return errors.New(errors.CodeInsufficientBalance, "余额不足") + } + + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.Order{}). + Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending). + Updates(map[string]any{ + "payment_status": model.PaymentStatusPaid, + "payment_method": model.PaymentMethodWallet, + "paid_at": now, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单支付状态失败") } - switch currentOrder.PaymentStatus { - case model.PaymentStatusPaid: - return nil - case model.PaymentStatusCancelled: - return errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付") - case model.PaymentStatusRefunded: - return errors.New(errors.CodeInvalidStatus, "订单已退款,无法支付") - default: - return errors.New(errors.CodeInvalidStatus, "订单状态异常") + if result.RowsAffected == 0 { + var currentOrder model.Order + if err := tx.First(¤tOrder, orderID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询订单失败") + } + + switch currentOrder.PaymentStatus { + case model.PaymentStatusPaid: + return nil + case model.PaymentStatusCancelled: + return errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付") + case model.PaymentStatusRefunded: + return errors.New(errors.CodeInvalidStatus, "订单已退款,无法支付") + default: + return errors.New(errors.CodeInvalidStatus, "订单状态异常") + } } + + walletResult := tx.Model(&model.AgentWallet{}). + Where("id = ? AND balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version). + Updates(map[string]any{ + "balance": gorm.Expr("balance - ?", order.TotalAmount), + "version": gorm.Expr("version + 1"), + }) + if walletResult.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, walletResult.Error, "扣减钱包余额失败") + } + if walletResult.RowsAffected == 0 { + return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突") + } + + return s.activatePackage(ctx, tx, order) + }) + } else { + // 卡钱包系统(iot_card 或 device) + wallet, err := s.cardWalletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeWalletNotFound, "钱包不存在") + } + return err } - walletResult := tx.Model(&model.Wallet{}). - Where("id = ? AND balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version). - Updates(map[string]any{ - "balance": gorm.Expr("balance - ?", order.TotalAmount), - "version": gorm.Expr("version + 1"), - }) - if walletResult.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, walletResult.Error, "扣减钱包余额失败") - } - if walletResult.RowsAffected == 0 { - return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突") + if wallet.Balance < order.TotalAmount { + return errors.New(errors.CodeInsufficientBalance, "余额不足") } - return s.activatePackage(ctx, tx, order) - }) + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.Order{}). + Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending). + Updates(map[string]any{ + "payment_status": model.PaymentStatusPaid, + "payment_method": model.PaymentMethodWallet, + "paid_at": now, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单支付状态失败") + } + + if result.RowsAffected == 0 { + var currentOrder model.Order + if err := tx.First(¤tOrder, orderID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询订单失败") + } + + switch currentOrder.PaymentStatus { + case model.PaymentStatusPaid: + return nil + case model.PaymentStatusCancelled: + return errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付") + case model.PaymentStatusRefunded: + return errors.New(errors.CodeInvalidStatus, "订单已退款,无法支付") + default: + return errors.New(errors.CodeInvalidStatus, "订单状态异常") + } + } + + walletResult := tx.Model(&model.CardWallet{}). + Where("id = ? AND balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version). + Updates(map[string]any{ + "balance": gorm.Expr("balance - ?", order.TotalAmount), + "version": gorm.Expr("version + 1"), + }) + if walletResult.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, walletResult.Error, "扣减钱包余额失败") + } + if walletResult.RowsAffected == 0 { + return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突") + } + + return s.activatePackage(ctx, tx, order) + }) + } if err != nil { return err diff --git a/internal/service/package/reset_service.go b/internal/service/package/reset_service.go index d6a1ff0..b030667 100644 --- a/internal/service/package/reset_service.go +++ b/internal/service/package/reset_service.go @@ -71,10 +71,10 @@ func (s *ResetService) resetDailyUsageWithDB(ctx context.Context, db *gorm.DB) e // 批量更新 updates := map[string]interface{}{ - "data_usage_mb": 0, - "last_reset_at": now, - "next_reset_at": nextReset, - "status": constants.PackageUsageStatusActive, // 重置后恢复为生效中 + "data_usage_mb": 0, + "last_reset_at": now, + "next_reset_at": nextReset, + "status": constants.PackageUsageStatusActive, // 重置后恢复为生效中 } if err := tx.Model(&model.PackageUsage{}). @@ -221,4 +221,3 @@ func (s *ResetService) resetYearlyUsageWithDB(ctx context.Context, db *gorm.DB) return nil }) } - diff --git a/internal/service/polling/cleanup_service.go b/internal/service/polling/cleanup_service.go index 24005a6..32ebc90 100644 --- a/internal/service/polling/cleanup_service.go +++ b/internal/service/polling/cleanup_service.go @@ -152,13 +152,13 @@ func (s *CleanupService) Preview(ctx context.Context) ([]*CleanupPreview, error) // CleanupProgress 清理进度 type CleanupProgress struct { - IsRunning bool `json:"is_running"` - CurrentTable string `json:"current_table,omitempty"` - TotalTables int `json:"total_tables"` - ProcessedTables int `json:"processed_tables"` - TotalDeleted int64 `json:"total_deleted"` - StartedAt *time.Time `json:"started_at,omitempty"` - LastLog *model.DataCleanupLog `json:"last_log,omitempty"` + IsRunning bool `json:"is_running"` + CurrentTable string `json:"current_table,omitempty"` + TotalTables int `json:"total_tables"` + ProcessedTables int `json:"processed_tables"` + TotalDeleted int64 `json:"total_deleted"` + StartedAt *time.Time `json:"started_at,omitempty"` + LastLog *model.DataCleanupLog `json:"last_log,omitempty"` } // GetProgress 获取清理进度 diff --git a/internal/service/polling/concurrency_service.go b/internal/service/polling/concurrency_service.go index ed15f52..8bcd141 100644 --- a/internal/service/polling/concurrency_service.go +++ b/internal/service/polling/concurrency_service.go @@ -28,11 +28,11 @@ func NewConcurrencyService(store *postgres.PollingConcurrencyConfigStore, redis // ConcurrencyStatus 并发状态 type ConcurrencyStatus struct { - TaskType string `json:"task_type"` - TaskTypeName string `json:"task_type_name"` - MaxConcurrency int `json:"max_concurrency"` - Current int64 `json:"current"` - Available int64 `json:"available"` + TaskType string `json:"task_type"` + TaskTypeName string `json:"task_type_name"` + MaxConcurrency int `json:"max_concurrency"` + Current int64 `json:"current"` + Available int64 `json:"available"` Utilization float64 `json:"utilization"` } diff --git a/internal/service/polling/manual_trigger_service.go b/internal/service/polling/manual_trigger_service.go index 67e801f..b0a50d2 100644 --- a/internal/service/polling/manual_trigger_service.go +++ b/internal/service/polling/manual_trigger_service.go @@ -207,13 +207,13 @@ func (s *ManualTriggerService) processBatchTrigger(ctx context.Context, logID ui // ConditionFilter 条件筛选参数 type ConditionFilter struct { - CardStatus string `json:"card_status,omitempty"` // 卡状态 - CarrierCode string `json:"carrier_code,omitempty"` // 运营商代码 - CardType string `json:"card_type,omitempty"` // 卡类型 - ShopID *uint `json:"shop_id,omitempty"` // 店铺ID - PackageIDs []uint `json:"package_ids,omitempty"` // 套餐ID列表 - EnablePolling *bool `json:"enable_polling,omitempty"` // 是否启用轮询 - Limit int `json:"limit,omitempty"` // 限制数量 + CardStatus string `json:"card_status,omitempty"` // 卡状态 + CarrierCode string `json:"carrier_code,omitempty"` // 运营商代码 + CardType string `json:"card_type,omitempty"` // 卡类型 + ShopID *uint `json:"shop_id,omitempty"` // 店铺ID + PackageIDs []uint `json:"package_ids,omitempty"` // 套餐ID列表 + EnablePolling *bool `json:"enable_polling,omitempty"` // 是否启用轮询 + Limit int `json:"limit,omitempty"` // 限制数量 } // TriggerByCondition 条件筛选触发 diff --git a/internal/service/recharge/service.go b/internal/service/recharge/service.go index 249a9c9..a546920 100644 --- a/internal/service/recharge/service.go +++ b/internal/service/recharge/service.go @@ -29,26 +29,26 @@ type ForceRechargeRequirement struct { } // Service 充值服务 -// 负责充值订单的创建、预检、支付回调处理等业务逻辑 +// 负责卡钱包(IoT卡/设备)的充值订单创建、预检、支付回调处理等业务逻辑 type Service struct { - db *gorm.DB - rechargeStore *postgres.RechargeStore - walletStore *postgres.WalletStore - walletTransactionStore *postgres.WalletTransactionStore - iotCardStore *postgres.IotCardStore - deviceStore *postgres.DeviceStore - shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore - packageSeriesStore *postgres.PackageSeriesStore - commissionRecordStore *postgres.CommissionRecordStore - logger *zap.Logger + db *gorm.DB + cardRechargeStore *postgres.CardRechargeStore + cardWalletStore *postgres.CardWalletStore + cardWalletTransactionStore *postgres.CardWalletTransactionStore + iotCardStore *postgres.IotCardStore + deviceStore *postgres.DeviceStore + shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore + packageSeriesStore *postgres.PackageSeriesStore + commissionRecordStore *postgres.CommissionRecordStore + logger *zap.Logger } // New 创建充值服务实例 func New( db *gorm.DB, - rechargeStore *postgres.RechargeStore, - walletStore *postgres.WalletStore, - walletTransactionStore *postgres.WalletTransactionStore, + cardRechargeStore *postgres.CardRechargeStore, + cardWalletStore *postgres.CardWalletStore, + cardWalletTransactionStore *postgres.CardWalletTransactionStore, iotCardStore *postgres.IotCardStore, deviceStore *postgres.DeviceStore, shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore, @@ -57,16 +57,16 @@ func New( logger *zap.Logger, ) *Service { return &Service{ - db: db, - rechargeStore: rechargeStore, - walletStore: walletStore, - walletTransactionStore: walletTransactionStore, - iotCardStore: iotCardStore, - deviceStore: deviceStore, - shopSeriesAllocationStore: shopSeriesAllocationStore, - packageSeriesStore: packageSeriesStore, - commissionRecordStore: commissionRecordStore, - logger: logger, + db: db, + cardRechargeStore: cardRechargeStore, + cardWalletStore: cardWalletStore, + cardWalletTransactionStore: cardWalletTransactionStore, + iotCardStore: iotCardStore, + deviceStore: deviceStore, + shopSeriesAllocationStore: shopSeriesAllocationStore, + packageSeriesStore: packageSeriesStore, + commissionRecordStore: commissionRecordStore, + logger: logger, } } @@ -81,14 +81,14 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRechargeRequest, us return nil, errors.New(errors.CodeRechargeAmountInvalid, "充值金额不能超过100000元") } - // 2. 获取资源(卡或设备) - var wallet *model.Wallet + // 2. 获取资源(卡或设备)钱包 + var wallet *model.CardWallet var err error if req.ResourceType == "iot_card" { - wallet, err = s.walletStore.GetByResourceTypeAndID(ctx, "iot_card", req.ResourceID, "main") + wallet, err = s.cardWalletStore.GetByResourceTypeAndID(ctx, "iot_card", req.ResourceID) } else if req.ResourceType == "device" { - wallet, err = s.walletStore.GetByResourceTypeAndID(ctx, "device", req.ResourceID, "main") + wallet, err = s.cardWalletStore.GetByResourceTypeAndID(ctx, "device", req.ResourceID) } else { return nil, errors.New(errors.CodeInvalidParam, "无效的资源类型") } @@ -115,20 +115,20 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRechargeRequest, us rechargeNo := s.generateRechargeNo() // 5. 创建充值订单 - recharge := &model.RechargeRecord{ - BaseModel: model.BaseModel{ - Creator: userID, - Updater: userID, - }, - UserID: userID, - WalletID: wallet.ID, - RechargeNo: rechargeNo, - Amount: req.Amount, - PaymentMethod: req.PaymentMethod, - Status: constants.RechargeStatusPending, + recharge := &model.CardRechargeRecord{ + UserID: userID, + CardWalletID: wallet.ID, + ResourceType: req.ResourceType, + ResourceID: req.ResourceID, + RechargeNo: rechargeNo, + Amount: req.Amount, + PaymentMethod: req.PaymentMethod, + Status: constants.RechargeStatusPending, + ShopIDTag: wallet.ShopIDTag, + EnterpriseIDTag: wallet.EnterpriseIDTag, } - if err := s.rechargeStore.Create(ctx, recharge); err != nil { + if err := s.cardRechargeStore.Create(ctx, recharge); err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败") } @@ -173,7 +173,7 @@ func (s *Service) GetRechargeCheck(ctx context.Context, resourceType string, res // GetByID 根据ID查询充值订单详情 // 支持数据权限过滤 func (s *Service) GetByID(ctx context.Context, id uint, userID uint) (*dto.RechargeResponse, error) { - recharge, err := s.rechargeStore.GetByID(ctx, id) + recharge, err := s.cardRechargeStore.GetByID(ctx, id) if err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeRechargeNotFound, "充值订单不存在") @@ -201,7 +201,7 @@ func (s *Service) List(ctx context.Context, req *dto.RechargeListRequest, userID pageSize = constants.DefaultPageSize } - params := &postgres.ListRechargeParams{ + params := &postgres.ListCardRechargeParams{ Page: page, PageSize: pageSize, UserID: &userID, // 数据权限:只能查看自己的 @@ -211,7 +211,8 @@ func (s *Service) List(ctx context.Context, req *dto.RechargeListRequest, userID params.Status = req.Status } if req.WalletID != nil { - params.WalletID = req.WalletID + walletID := *req.WalletID + params.CardWalletID = &walletID } if req.StartTime != nil { params.StartTime = req.StartTime @@ -220,7 +221,7 @@ func (s *Service) List(ctx context.Context, req *dto.RechargeListRequest, userID params.EndTime = req.EndTime } - recharges, total, err := s.rechargeStore.List(ctx, params) + recharges, total, err := s.cardRechargeStore.List(ctx, params) if err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单列表失败") } @@ -248,13 +249,13 @@ func (s *Service) List(ctx context.Context, req *dto.RechargeListRequest, userID // 支持幂等性检查、事务处理、更新余额、触发佣金 func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string) error { // 1. 查询充值订单 - recharge, err := s.rechargeStore.GetByRechargeNo(ctx, rechargeNo) + recharge, err := s.cardRechargeStore.GetByRechargeNo(ctx, rechargeNo) if err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeRechargeNotFound, "充值订单不存在") + } return errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单失败") } - if recharge == nil { - return errors.New(errors.CodeRechargeNotFound, "充值订单不存在") - } // 2. 幂等性检查:已支付则直接返回成功 if recharge.Status == constants.RechargeStatusPaid || recharge.Status == constants.RechargeStatusCompleted { @@ -271,21 +272,21 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, } // 4. 获取钱包信息 - wallet, err := s.walletStore.GetByID(ctx, recharge.WalletID) + wallet, err := s.cardWalletStore.GetByID(ctx, recharge.CardWalletID) if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败") } - // 5. 获取钱包对应的资源类型和ID - resourceType := wallet.ResourceType - resourceID := wallet.ResourceID + // 5. 获取钱包对应的资源类型和ID(从充值记录中直接获取) + resourceType := recharge.ResourceType + resourceID := recharge.ResourceID // 6. 事务处理:更新订单状态、增加余额、更新累计充值、触发佣金 now := time.Now() err = s.db.Transaction(func(tx *gorm.DB) error { // 6.1 更新充值订单状态(带状态检查,实现乐观锁) oldStatus := constants.RechargeStatusPending - if err := s.rechargeStore.UpdateStatus(ctx, recharge.ID, &oldStatus, constants.RechargeStatusPaid, &now, nil); err != nil { + if err := s.cardRechargeStore.UpdateStatusWithOptimisticLock(ctx, recharge.ID, &oldStatus, constants.RechargeStatusPaid, &now, nil); err != nil { if err == gorm.ErrRecordNotFound { // 状态已变更,幂等处理 return nil @@ -294,13 +295,13 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, } // 6.2 更新支付信息 - if err := s.rechargeStore.UpdatePaymentInfo(ctx, recharge.ID, &paymentMethod, &paymentTransactionID); err != nil { + if err := s.cardRechargeStore.UpdatePaymentInfo(ctx, recharge.ID, &paymentMethod, &paymentTransactionID); err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新支付信息失败") } // 6.3 增加钱包余额(使用乐观锁) balanceBefore := wallet.Balance - result := tx.Model(&model.Wallet{}). + result := tx.Model(&model.CardWallet{}). Where("id = ? AND version = ?", wallet.ID, wallet.Version). Updates(map[string]any{ "balance": gorm.Expr("balance + ?", recharge.Amount), @@ -316,8 +317,10 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, // 6.4 创建钱包交易记录 remark := "钱包充值" refType := "recharge" - transaction := &model.WalletTransaction{ - WalletID: wallet.ID, + transaction := &model.CardWalletTransaction{ + CardWalletID: wallet.ID, + ResourceType: resourceType, + ResourceID: resourceID, UserID: recharge.UserID, TransactionType: "recharge", Amount: recharge.Amount, @@ -327,7 +330,8 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, ReferenceType: &refType, ReferenceID: &recharge.ID, Remark: &remark, - Creator: recharge.UserID, + ShopIDTag: wallet.ShopIDTag, + EnterpriseIDTag: wallet.EnterpriseIDTag, } if err := tx.Create(transaction).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败") @@ -344,7 +348,7 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, } // 6.7 更新充值订单状态为已完成 - if err := tx.Model(&model.RechargeRecord{}). + if err := tx.Model(&model.CardRechargeRecord{}). Where("id = ?", recharge.ID). Update("status", constants.RechargeStatusCompleted).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新充值订单完成状态失败") @@ -590,8 +594,8 @@ func (s *Service) triggerOneTimeCommissionIfNeededInTx(ctx context.Context, tx * return nil } - var commissionWallet model.Wallet - if err := tx.Where("resource_type = ? AND resource_id = ? AND wallet_type = ?", "shop", *shopID, "commission"). + var commissionWallet model.AgentWallet + if err := tx.Where("shop_id = ? AND wallet_type = ?", *shopID, constants.AgentWalletTypeCommission). First(&commissionWallet).Error; err != nil { if err == gorm.ErrRecordNotFound { s.logger.Warn("店铺佣金钱包不存在,跳过佣金发放", @@ -629,7 +633,7 @@ func (s *Service) triggerOneTimeCommissionIfNeededInTx(ctx context.Context, tx * // 11. 佣金入账到店铺佣金钱包 balanceBefore := commissionWallet.Balance - result := tx.Model(&model.Wallet{}). + result := tx.Model(&model.AgentWallet{}). Where("id = ? AND version = ?", commissionWallet.ID, commissionWallet.Version). Updates(map[string]any{ "balance": gorm.Expr("balance + ?", commissionAmount), @@ -654,8 +658,9 @@ func (s *Service) triggerOneTimeCommissionIfNeededInTx(ctx context.Context, tx * // 13. 创建佣金钱包交易记录 remark := "一次性佣金入账(充值触发)" refType := "commission" - commissionTransaction := &model.WalletTransaction{ - WalletID: commissionWallet.ID, + commissionTransaction := &model.AgentWalletTransaction{ + AgentWalletID: commissionWallet.ID, + ShopID: *shopID, UserID: userID, TransactionType: "commission", Amount: commissionAmount, @@ -665,7 +670,7 @@ func (s *Service) triggerOneTimeCommissionIfNeededInTx(ctx context.Context, tx * ReferenceType: &refType, ReferenceID: &commissionRecord.ID, Remark: &remark, - Creator: userID, + ShopIDTag: *shopID, } if err := tx.Create(commissionTransaction).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建佣金钱包交易记录失败") @@ -724,7 +729,7 @@ func (s *Service) generateRechargeNo() string { } // buildRechargeResponse 构建充值订单响应 -func (s *Service) buildRechargeResponse(recharge *model.RechargeRecord) *dto.RechargeResponse { +func (s *Service) buildRechargeResponse(recharge *model.CardRechargeRecord) *dto.RechargeResponse { statusText := "" switch recharge.Status { case constants.RechargeStatusPending: @@ -743,7 +748,7 @@ func (s *Service) buildRechargeResponse(recharge *model.RechargeRecord) *dto.Rec ID: recharge.ID, RechargeNo: recharge.RechargeNo, UserID: recharge.UserID, - WalletID: recharge.WalletID, + WalletID: recharge.CardWalletID, Amount: recharge.Amount, PaymentMethod: recharge.PaymentMethod, PaymentChannel: recharge.PaymentChannel, diff --git a/internal/service/shop_commission/service.go b/internal/service/shop_commission/service.go index b49ce21..c4a4846 100644 --- a/internal/service/shop_commission/service.go +++ b/internal/service/shop_commission/service.go @@ -16,7 +16,7 @@ import ( type Service struct { shopStore *postgres.ShopStore accountStore *postgres.AccountStore - walletStore *postgres.WalletStore + agentWalletStore *postgres.AgentWalletStore commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore commissionRecordStore *postgres.CommissionRecordStore } @@ -24,14 +24,14 @@ type Service struct { func New( shopStore *postgres.ShopStore, accountStore *postgres.AccountStore, - walletStore *postgres.WalletStore, + agentWalletStore *postgres.AgentWalletStore, commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore, commissionRecordStore *postgres.CommissionRecordStore, ) *Service { return &Service{ shopStore: shopStore, accountStore: accountStore, - walletStore: walletStore, + agentWalletStore: agentWalletStore, commissionWithdrawalReqStore: commissionWithdrawalReqStore, commissionRecordStore: commissionRecordStore, } @@ -74,7 +74,7 @@ func (s *Service) ListShopCommissionSummary(ctx context.Context, req *dto.ShopCo shopIDs = append(shopIDs, shop.ID) } - walletSummaries, err := s.walletStore.GetShopCommissionSummaryBatch(ctx, shopIDs) + walletSummaries, err := s.agentWalletStore.GetShopCommissionSummaryBatch(ctx, shopIDs) if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "查询店铺钱包汇总失败") } @@ -123,7 +123,7 @@ func (s *Service) ListShopCommissionSummary(ctx context.Context, req *dto.ShopCo }, nil } -func (s *Service) buildCommissionSummaryItem(shop *model.Shop, walletSummary *postgres.ShopCommissionSummary, withdrawnAmount, withdrawingAmount int64, account *model.Account) dto.ShopCommissionSummaryItem { +func (s *Service) buildCommissionSummaryItem(shop *model.Shop, walletSummary *model.AgentWallet, withdrawnAmount, withdrawingAmount int64, account *model.Account) dto.ShopCommissionSummaryItem { var balance, frozenBalance int64 if walletSummary != nil { balance = walletSummary.Balance diff --git a/internal/store/postgres/agent_recharge_store.go b/internal/store/postgres/agent_recharge_store.go new file mode 100644 index 0000000..2d8689e --- /dev/null +++ b/internal/store/postgres/agent_recharge_store.go @@ -0,0 +1,125 @@ +package postgres + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +// AgentRechargeStore 代理充值记录数据访问层 +type AgentRechargeStore struct { + db *gorm.DB + redis *redis.Client +} + +// NewAgentRechargeStore 创建代理充值记录 Store +func NewAgentRechargeStore(db *gorm.DB, redis *redis.Client) *AgentRechargeStore { + return &AgentRechargeStore{ + db: db, + redis: redis, + } +} + +// Create 创建充值记录 +func (s *AgentRechargeStore) Create(ctx context.Context, record *model.AgentRechargeRecord) error { + return s.db.WithContext(ctx).Create(record).Error +} + +// CreateWithTx 创建充值记录(带事务) +func (s *AgentRechargeStore) CreateWithTx(ctx context.Context, tx *gorm.DB, record *model.AgentRechargeRecord) error { + return tx.WithContext(ctx).Create(record).Error +} + +// GetByRechargeNo 根据充值订单号查询 +func (s *AgentRechargeStore) GetByRechargeNo(ctx context.Context, rechargeNo string) (*model.AgentRechargeRecord, error) { + var record model.AgentRechargeRecord + err := s.db.WithContext(ctx). + Where("recharge_no = ?", rechargeNo). + First(&record).Error + if err != nil { + return nil, err + } + return &record, nil +} + +// GetByID 根据 ID 查询 +func (s *AgentRechargeStore) GetByID(ctx context.Context, id uint) (*model.AgentRechargeRecord, error) { + var record model.AgentRechargeRecord + if err := s.db.WithContext(ctx).First(&record, id).Error; err != nil { + return nil, err + } + return &record, nil +} + +// UpdateStatus 更新充值状态 +func (s *AgentRechargeStore) UpdateStatus(ctx context.Context, id uint, status int) error { + return s.db.WithContext(ctx). + Model(&model.AgentRechargeRecord{}). + Where("id = ?", id). + Update("status", status).Error +} + +// UpdateStatusWithTx 更新充值状态(带事务) +func (s *AgentRechargeStore) UpdateStatusWithTx(ctx context.Context, tx *gorm.DB, id uint, status int) error { + return tx.WithContext(ctx). + Model(&model.AgentRechargeRecord{}). + Where("id = ?", id). + Update("status", status).Error +} + +// Update 更新充值记录 +func (s *AgentRechargeStore) Update(ctx context.Context, record *model.AgentRechargeRecord) error { + return s.db.WithContext(ctx).Save(record).Error +} + +// UpdateWithTx 更新充值记录(带事务) +func (s *AgentRechargeStore) UpdateWithTx(ctx context.Context, tx *gorm.DB, record *model.AgentRechargeRecord) error { + return tx.WithContext(ctx).Save(record).Error +} + +// ListByShopID 按店铺查询充值记录(支持分页) +func (s *AgentRechargeStore) ListByShopID(ctx context.Context, shopID uint, offset, limit int) ([]*model.AgentRechargeRecord, error) { + var records []*model.AgentRechargeRecord + err := s.db.WithContext(ctx). + Where("shop_id = ?", shopID). + Order("created_at DESC"). + Offset(offset). + Limit(limit). + Find(&records).Error + if err != nil { + return nil, err + } + return records, nil +} + +// ListByUserID 按用户查询充值记录(支持分页) +func (s *AgentRechargeStore) ListByUserID(ctx context.Context, userID uint, offset, limit int) ([]*model.AgentRechargeRecord, error) { + var records []*model.AgentRechargeRecord + err := s.db.WithContext(ctx). + Where("user_id = ?", userID). + Order("created_at DESC"). + Offset(offset). + Limit(limit). + Find(&records).Error + if err != nil { + return nil, err + } + return records, nil +} + +// ListByStatus 按状态查询充值记录(支持分页) +func (s *AgentRechargeStore) ListByStatus(ctx context.Context, status int, offset, limit int) ([]*model.AgentRechargeRecord, error) { + var records []*model.AgentRechargeRecord + err := s.db.WithContext(ctx). + Where("status = ?", status). + Order("created_at DESC"). + Offset(offset). + Limit(limit). + Find(&records).Error + if err != nil { + return nil, err + } + return records, nil +} diff --git a/internal/store/postgres/agent_wallet_store.go b/internal/store/postgres/agent_wallet_store.go new file mode 100644 index 0000000..613f1d1 --- /dev/null +++ b/internal/store/postgres/agent_wallet_store.go @@ -0,0 +1,238 @@ +package postgres + +import ( + "context" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +// AgentWalletStore 代理钱包数据访问层 +type AgentWalletStore struct { + db *gorm.DB + redis *redis.Client +} + +// NewAgentWalletStore 创建代理钱包 Store +func NewAgentWalletStore(db *gorm.DB, redis *redis.Client) *AgentWalletStore { + return &AgentWalletStore{ + db: db, + redis: redis, + } +} + +// GetCommissionWallet 获取店铺的分佣钱包 +func (s *AgentWalletStore) GetCommissionWallet(ctx context.Context, shopID uint) (*model.AgentWallet, error) { + return s.GetByShopIDAndType(ctx, shopID, constants.AgentWalletTypeCommission) +} + +// GetMainWallet 获取店铺的主钱包 +func (s *AgentWalletStore) GetMainWallet(ctx context.Context, shopID uint) (*model.AgentWallet, error) { + return s.GetByShopIDAndType(ctx, shopID, constants.AgentWalletTypeMain) +} + +// GetByShopIDAndType 根据店铺 ID 和钱包类型查询钱包 +func (s *AgentWalletStore) GetByShopIDAndType(ctx context.Context, shopID uint, walletType string) (*model.AgentWallet, error) { + // 尝试从缓存获取 + cacheKey := constants.RedisAgentWalletBalanceKey(shopID, walletType) + // 注意:这里简化处理,实际项目中可以缓存完整的钱包信息 + + var wallet model.AgentWallet + err := s.db.WithContext(ctx). + Where("shop_id = ? AND wallet_type = ?", shopID, walletType). + First(&wallet).Error + if err != nil { + return nil, err + } + + // 更新缓存(可选) + // 这里简化处理,不缓存完整对象 + _ = cacheKey + + return &wallet, nil +} + +// GetByID 根据钱包 ID 查询 +func (s *AgentWalletStore) GetByID(ctx context.Context, id uint) (*model.AgentWallet, error) { + var wallet model.AgentWallet + if err := s.db.WithContext(ctx).First(&wallet, id).Error; err != nil { + return nil, err + } + return &wallet, nil +} + +// Create 创建代理钱包 +func (s *AgentWalletStore) Create(ctx context.Context, wallet *model.AgentWallet) error { + return s.db.WithContext(ctx).Create(wallet).Error +} + +// CreateWithTx 创建代理钱包(带事务) +func (s *AgentWalletStore) CreateWithTx(ctx context.Context, tx *gorm.DB, wallet *model.AgentWallet) error { + return tx.WithContext(ctx).Create(wallet).Error +} + +// DeductFrozenBalanceWithTx 从冻结余额扣款(带事务) +// 用于提现完成后,从冻结余额中扣除金额 +func (s *AgentWalletStore) DeductFrozenBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64) error { + // 扣除冻结余额和总余额 + result := tx.WithContext(ctx).Model(&model.AgentWallet{}). + Where("id = ? AND frozen_balance >= ?", walletID, amount). + Updates(map[string]interface{}{ + "balance": gorm.Expr("balance - ?", amount), + "frozen_balance": gorm.Expr("frozen_balance - ?", amount), + "updated_at": time.Now(), + }) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound // 冻结余额不足 + } + + // 删除缓存 + s.clearWalletCache(ctx, walletID) + + return nil +} + +// UnfreezeBalanceWithTx 解冻余额到可用余额(带事务) +// 用于提现取消,将冻结余额转回可用余额 +func (s *AgentWalletStore) UnfreezeBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64) error { + // 减少冻结余额(总余额不变) + result := tx.WithContext(ctx).Model(&model.AgentWallet{}). + Where("id = ? AND frozen_balance >= ?", walletID, amount). + Updates(map[string]interface{}{ + "frozen_balance": gorm.Expr("frozen_balance - ?", amount), + "updated_at": time.Now(), + }) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound // 冻结余额不足 + } + + // 删除缓存 + s.clearWalletCache(ctx, walletID) + + return nil +} + +// FreezeBalanceWithTx 冻结余额(带事务,使用乐观锁) +// 用于提现申请,将可用余额转为冻结状态 +func (s *AgentWalletStore) FreezeBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64, version int) error { + // 增加冻结余额(总余额不变),使用乐观锁 + result := tx.WithContext(ctx).Model(&model.AgentWallet{}). + Where("id = ? AND balance - frozen_balance >= ? AND version = ?", walletID, amount, version). + Updates(map[string]interface{}{ + "frozen_balance": gorm.Expr("frozen_balance + ?", amount), + "version": gorm.Expr("version + 1"), + "updated_at": time.Now(), + }) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound // 可用余额不足或版本冲突 + } + + // 删除缓存 + s.clearWalletCache(ctx, walletID) + + return nil +} + +// AddBalanceWithTx 增加余额(带事务) +// 用于充值、退款等增加余额的操作 +func (s *AgentWalletStore) AddBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64) error { + result := tx.WithContext(ctx).Model(&model.AgentWallet{}). + Where("id = ?", walletID). + Updates(map[string]interface{}{ + "balance": gorm.Expr("balance + ?", amount), + "updated_at": time.Now(), + }) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + + // 删除缓存 + s.clearWalletCache(ctx, walletID) + + return nil +} + +// DeductBalanceWithTx 扣除余额(带事务,使用乐观锁) +// 用于扣款操作,检查可用余额是否充足 +func (s *AgentWalletStore) DeductBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64, version int) error { + // 使用乐观锁,检查可用余额是否充足 + result := tx.WithContext(ctx).Model(&model.AgentWallet{}). + Where("id = ? AND balance - frozen_balance >= ? AND version = ?", walletID, amount, version). + Updates(map[string]interface{}{ + "balance": gorm.Expr("balance - ?", amount), + "version": gorm.Expr("version + 1"), + "updated_at": time.Now(), + }) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound // 余额不足或版本冲突 + } + + // 删除缓存 + s.clearWalletCache(ctx, walletID) + + return nil +} + +// GetShopCommissionSummaryBatch 批量获取店铺佣金钱包汇总 +// 返回 map[shopID]*AgentWallet +func (s *AgentWalletStore) GetShopCommissionSummaryBatch(ctx context.Context, shopIDs []uint) (map[uint]*model.AgentWallet, error) { + if len(shopIDs) == 0 { + return make(map[uint]*model.AgentWallet), nil + } + + var wallets []model.AgentWallet + err := s.db.WithContext(ctx). + Where("shop_id IN ? AND wallet_type = ?", shopIDs, constants.AgentWalletTypeCommission). + Find(&wallets).Error + if err != nil { + return nil, err + } + + // 转换为 map + result := make(map[uint]*model.AgentWallet, len(wallets)) + for i := range wallets { + result[wallets[i].ShopID] = &wallets[i] + } + + return result, nil +} + +// clearWalletCache 清除钱包缓存 +func (s *AgentWalletStore) clearWalletCache(ctx context.Context, walletID uint) { + // 查询钱包信息以获取 shop_id 和 wallet_type + var wallet model.AgentWallet + if err := s.db.WithContext(ctx).Select("shop_id, wallet_type").First(&wallet, walletID).Error; err != nil { + return + } + + cacheKey := constants.RedisAgentWalletBalanceKey(wallet.ShopID, wallet.WalletType) + _ = s.redis.Del(ctx, cacheKey).Err() +} diff --git a/internal/store/postgres/agent_wallet_transaction_store.go b/internal/store/postgres/agent_wallet_transaction_store.go new file mode 100644 index 0000000..01945ec --- /dev/null +++ b/internal/store/postgres/agent_wallet_transaction_store.go @@ -0,0 +1,80 @@ +package postgres + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +// AgentWalletTransactionStore 代理钱包交易记录数据访问层 +type AgentWalletTransactionStore struct { + db *gorm.DB + redis *redis.Client +} + +// NewAgentWalletTransactionStore 创建代理钱包交易记录 Store +func NewAgentWalletTransactionStore(db *gorm.DB, redis *redis.Client) *AgentWalletTransactionStore { + return &AgentWalletTransactionStore{ + db: db, + redis: redis, + } +} + +// CreateWithTx 创建代理钱包交易记录(带事务) +func (s *AgentWalletTransactionStore) CreateWithTx(ctx context.Context, tx *gorm.DB, transaction *model.AgentWalletTransaction) error { + return tx.WithContext(ctx).Create(transaction).Error +} + +// ListByShopID 按店铺查询交易记录(支持分页) +func (s *AgentWalletTransactionStore) ListByShopID(ctx context.Context, shopID uint, offset, limit int) ([]*model.AgentWalletTransaction, error) { + var transactions []*model.AgentWalletTransaction + err := s.db.WithContext(ctx). + Where("shop_id = ?", shopID). + Order("created_at DESC"). + Offset(offset). + Limit(limit). + Find(&transactions).Error + if err != nil { + return nil, err + } + return transactions, nil +} + +// CountByShopID 统计店铺的交易记录数量 +func (s *AgentWalletTransactionStore) CountByShopID(ctx context.Context, shopID uint) (int64, error) { + var count int64 + err := s.db.WithContext(ctx). + Model(&model.AgentWalletTransaction{}). + Where("shop_id = ?", shopID). + Count(&count).Error + return count, err +} + +// ListByWalletID 按钱包查询交易记录(支持分页) +func (s *AgentWalletTransactionStore) ListByWalletID(ctx context.Context, walletID uint, offset, limit int) ([]*model.AgentWalletTransaction, error) { + var transactions []*model.AgentWalletTransaction + err := s.db.WithContext(ctx). + Where("agent_wallet_id = ?", walletID). + Order("created_at DESC"). + Offset(offset). + Limit(limit). + Find(&transactions).Error + if err != nil { + return nil, err + } + return transactions, nil +} + +// GetByReference 根据关联业务查询交易记录 +func (s *AgentWalletTransactionStore) GetByReference(ctx context.Context, referenceType string, referenceID uint) (*model.AgentWalletTransaction, error) { + var transaction model.AgentWalletTransaction + err := s.db.WithContext(ctx). + Where("reference_type = ? AND reference_id = ?", referenceType, referenceID). + First(&transaction).Error + if err != nil { + return nil, err + } + return &transaction, nil +} diff --git a/internal/store/postgres/card_recharge_store.go b/internal/store/postgres/card_recharge_store.go new file mode 100644 index 0000000..6eb685c --- /dev/null +++ b/internal/store/postgres/card_recharge_store.go @@ -0,0 +1,240 @@ +package postgres + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +// CardRechargeStore 卡充值记录数据访问层 +type CardRechargeStore struct { + db *gorm.DB + redis *redis.Client +} + +// NewCardRechargeStore 创建卡充值记录 Store +func NewCardRechargeStore(db *gorm.DB, redis *redis.Client) *CardRechargeStore { + return &CardRechargeStore{ + db: db, + redis: redis, + } +} + +// Create 创建充值记录 +func (s *CardRechargeStore) Create(ctx context.Context, record *model.CardRechargeRecord) error { + return s.db.WithContext(ctx).Create(record).Error +} + +// CreateWithTx 创建充值记录(带事务) +func (s *CardRechargeStore) CreateWithTx(ctx context.Context, tx *gorm.DB, record *model.CardRechargeRecord) error { + return tx.WithContext(ctx).Create(record).Error +} + +// GetByRechargeNo 根据充值订单号查询 +func (s *CardRechargeStore) GetByRechargeNo(ctx context.Context, rechargeNo string) (*model.CardRechargeRecord, error) { + var record model.CardRechargeRecord + err := s.db.WithContext(ctx). + Where("recharge_no = ?", rechargeNo). + First(&record).Error + if err != nil { + return nil, err + } + return &record, nil +} + +// GetByID 根据 ID 查询 +func (s *CardRechargeStore) GetByID(ctx context.Context, id uint) (*model.CardRechargeRecord, error) { + var record model.CardRechargeRecord + if err := s.db.WithContext(ctx).First(&record, id).Error; err != nil { + return nil, err + } + return &record, nil +} + +// UpdateStatus 更新充值状态 +func (s *CardRechargeStore) UpdateStatus(ctx context.Context, id uint, status int) error { + return s.db.WithContext(ctx). + Model(&model.CardRechargeRecord{}). + Where("id = ?", id). + Update("status", status).Error +} + +// UpdateStatusWithTx 更新充值状态(带事务) +func (s *CardRechargeStore) UpdateStatusWithTx(ctx context.Context, tx *gorm.DB, id uint, status int) error { + return tx.WithContext(ctx). + Model(&model.CardRechargeRecord{}). + Where("id = ?", id). + Update("status", status).Error +} + +// Update 更新充值记录 +func (s *CardRechargeStore) Update(ctx context.Context, record *model.CardRechargeRecord) error { + return s.db.WithContext(ctx).Save(record).Error +} + +// UpdateWithTx 更新充值记录(带事务) +func (s *CardRechargeStore) UpdateWithTx(ctx context.Context, tx *gorm.DB, record *model.CardRechargeRecord) error { + return tx.WithContext(ctx).Save(record).Error +} + +// ListByResourceID 按资源查询充值记录(支持分页) +func (s *CardRechargeStore) ListByResourceID(ctx context.Context, resourceType string, resourceID uint, offset, limit int) ([]*model.CardRechargeRecord, error) { + var records []*model.CardRechargeRecord + err := s.db.WithContext(ctx). + Where("resource_type = ? AND resource_id = ?", resourceType, resourceID). + Order("created_at DESC"). + Offset(offset). + Limit(limit). + Find(&records).Error + if err != nil { + return nil, err + } + return records, nil +} + +// ListByUserID 按用户查询充值记录(支持分页) +func (s *CardRechargeStore) ListByUserID(ctx context.Context, userID uint, offset, limit int) ([]*model.CardRechargeRecord, error) { + var records []*model.CardRechargeRecord + err := s.db.WithContext(ctx). + Where("user_id = ?", userID). + Order("created_at DESC"). + Offset(offset). + Limit(limit). + Find(&records).Error + if err != nil { + return nil, err + } + return records, nil +} + +// ListByStatus 按状态查询充值记录(支持分页) +func (s *CardRechargeStore) ListByStatus(ctx context.Context, status int, offset, limit int) ([]*model.CardRechargeRecord, error) { + var records []*model.CardRechargeRecord + err := s.db.WithContext(ctx). + Where("status = ?", status). + Order("created_at DESC"). + Offset(offset). + Limit(limit). + Find(&records).Error + if err != nil { + return nil, err + } + return records, nil +} + +// ListRechargeParams 充值记录列表查询参数 +type ListCardRechargeParams struct { + Page int + PageSize int + UserID *uint + CardWalletID *uint + ResourceType *string + ResourceID *uint + Status *int + StartTime interface{} + EndTime interface{} +} + +// List 查询充值记录列表(支持分页和筛选) +func (s *CardRechargeStore) List(ctx context.Context, params *ListCardRechargeParams) ([]*model.CardRechargeRecord, int64, error) { + var records []*model.CardRechargeRecord + var total int64 + + query := s.db.WithContext(ctx).Model(&model.CardRechargeRecord{}) + + if params.UserID != nil { + query = query.Where("user_id = ?", *params.UserID) + } + if params.CardWalletID != nil { + query = query.Where("card_wallet_id = ?", *params.CardWalletID) + } + if params.ResourceType != nil { + query = query.Where("resource_type = ?", *params.ResourceType) + } + if params.ResourceID != nil { + query = query.Where("resource_id = ?", *params.ResourceID) + } + if params.Status != nil { + query = query.Where("status = ?", *params.Status) + } + if params.StartTime != nil { + query = query.Where("created_at >= ?", params.StartTime) + } + if params.EndTime != nil { + query = query.Where("created_at <= ?", params.EndTime) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + page := params.Page + if page < 1 { + page = 1 + } + pageSize := params.PageSize + if pageSize < 1 { + pageSize = 20 + } + + offset := (page - 1) * pageSize + if err := query.Order("id DESC").Offset(offset).Limit(pageSize).Find(&records).Error; err != nil { + return nil, 0, err + } + + return records, total, nil +} + +// UpdatePaymentInfo 更新支付信息 +func (s *CardRechargeStore) UpdatePaymentInfo(ctx context.Context, id uint, paymentMethod *string, paymentTransactionID *string) error { + updates := map[string]interface{}{} + if paymentMethod != nil { + updates["payment_method"] = paymentMethod + } + if paymentTransactionID != nil { + updates["payment_transaction_id"] = paymentTransactionID + } + + if len(updates) == 0 { + return nil + } + + result := s.db.WithContext(ctx).Model(&model.CardRechargeRecord{}).Where("id = ?", id).Updates(updates) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +// UpdateStatusWithOptimisticLock 更新充值状态(支持乐观锁) +func (s *CardRechargeStore) UpdateStatusWithOptimisticLock(ctx context.Context, id uint, oldStatus *int, newStatus int, paidAt interface{}, completedAt interface{}) error { + updates := map[string]interface{}{ + "status": newStatus, + } + if paidAt != nil { + updates["paid_at"] = paidAt + } + if completedAt != nil { + updates["completed_at"] = completedAt + } + + query := s.db.WithContext(ctx).Model(&model.CardRechargeRecord{}).Where("id = ?", id) + + if oldStatus != nil { + query = query.Where("status = ?", *oldStatus) + } + + result := query.Updates(updates) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} diff --git a/internal/store/postgres/card_wallet_store.go b/internal/store/postgres/card_wallet_store.go new file mode 100644 index 0000000..ee0147e --- /dev/null +++ b/internal/store/postgres/card_wallet_store.go @@ -0,0 +1,116 @@ +package postgres + +import ( + "context" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +// CardWalletStore 卡钱包数据访问层 +type CardWalletStore struct { + db *gorm.DB + redis *redis.Client +} + +// NewCardWalletStore 创建卡钱包 Store +func NewCardWalletStore(db *gorm.DB, redis *redis.Client) *CardWalletStore { + return &CardWalletStore{ + db: db, + redis: redis, + } +} + +// GetByResourceTypeAndID 根据资源类型和 ID 查询钱包 +func (s *CardWalletStore) GetByResourceTypeAndID(ctx context.Context, resourceType string, resourceID uint) (*model.CardWallet, error) { + var wallet model.CardWallet + err := s.db.WithContext(ctx). + Where("resource_type = ? AND resource_id = ?", resourceType, resourceID). + First(&wallet).Error + if err != nil { + return nil, err + } + return &wallet, nil +} + +// GetByID 根据钱包 ID 查询 +func (s *CardWalletStore) GetByID(ctx context.Context, id uint) (*model.CardWallet, error) { + var wallet model.CardWallet + if err := s.db.WithContext(ctx).First(&wallet, id).Error; err != nil { + return nil, err + } + return &wallet, nil +} + +// Create 创建卡钱包 +func (s *CardWalletStore) Create(ctx context.Context, wallet *model.CardWallet) error { + return s.db.WithContext(ctx).Create(wallet).Error +} + +// CreateWithTx 创建卡钱包(带事务) +func (s *CardWalletStore) CreateWithTx(ctx context.Context, tx *gorm.DB, wallet *model.CardWallet) error { + return tx.WithContext(ctx).Create(wallet).Error +} + +// DeductBalanceWithTx 扣款(带事务,使用乐观锁) +func (s *CardWalletStore) DeductBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64, version int) error { + // 使用乐观锁,检查可用余额是否充足 + result := tx.WithContext(ctx).Model(&model.CardWallet{}). + Where("id = ? AND balance - frozen_balance >= ? AND version = ?", walletID, amount, version). + Updates(map[string]interface{}{ + "balance": gorm.Expr("balance - ?", amount), + "version": gorm.Expr("version + 1"), + "updated_at": time.Now(), + }) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound // 余额不足或版本冲突 + } + + // 删除缓存 + s.clearWalletCache(ctx, walletID) + + return nil +} + +// AddBalanceWithTx 增加余额(带事务) +func (s *CardWalletStore) AddBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64) error { + result := tx.WithContext(ctx).Model(&model.CardWallet{}). + Where("id = ?", walletID). + Updates(map[string]interface{}{ + "balance": gorm.Expr("balance + ?", amount), + "updated_at": time.Now(), + }) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + + // 删除缓存 + s.clearWalletCache(ctx, walletID) + + return nil +} + +// clearWalletCache 清除钱包缓存 +func (s *CardWalletStore) clearWalletCache(ctx context.Context, walletID uint) { + // 查询钱包信息以获取 resource_type 和 resource_id + var wallet model.CardWallet + if err := s.db.WithContext(ctx).Select("resource_type, resource_id").First(&wallet, walletID).Error; err != nil { + return + } + + cacheKey := constants.RedisCardWalletBalanceKey(wallet.ResourceType, wallet.ResourceID) + _ = s.redis.Del(ctx, cacheKey).Err() +} diff --git a/internal/store/postgres/card_wallet_transaction_store.go b/internal/store/postgres/card_wallet_transaction_store.go new file mode 100644 index 0000000..89c9a6e --- /dev/null +++ b/internal/store/postgres/card_wallet_transaction_store.go @@ -0,0 +1,80 @@ +package postgres + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +// CardWalletTransactionStore 卡钱包交易记录数据访问层 +type CardWalletTransactionStore struct { + db *gorm.DB + redis *redis.Client +} + +// NewCardWalletTransactionStore 创建卡钱包交易记录 Store +func NewCardWalletTransactionStore(db *gorm.DB, redis *redis.Client) *CardWalletTransactionStore { + return &CardWalletTransactionStore{ + db: db, + redis: redis, + } +} + +// CreateWithTx 创建卡钱包交易记录(带事务) +func (s *CardWalletTransactionStore) CreateWithTx(ctx context.Context, tx *gorm.DB, transaction *model.CardWalletTransaction) error { + return tx.WithContext(ctx).Create(transaction).Error +} + +// ListByResourceID 按资源查询交易记录(支持分页) +func (s *CardWalletTransactionStore) ListByResourceID(ctx context.Context, resourceType string, resourceID uint, offset, limit int) ([]*model.CardWalletTransaction, error) { + var transactions []*model.CardWalletTransaction + err := s.db.WithContext(ctx). + Where("resource_type = ? AND resource_id = ?", resourceType, resourceID). + Order("created_at DESC"). + Offset(offset). + Limit(limit). + Find(&transactions).Error + if err != nil { + return nil, err + } + return transactions, nil +} + +// CountByResourceID 统计资源的交易记录数量 +func (s *CardWalletTransactionStore) CountByResourceID(ctx context.Context, resourceType string, resourceID uint) (int64, error) { + var count int64 + err := s.db.WithContext(ctx). + Model(&model.CardWalletTransaction{}). + Where("resource_type = ? AND resource_id = ?", resourceType, resourceID). + Count(&count).Error + return count, err +} + +// ListByWalletID 按钱包查询交易记录(支持分页) +func (s *CardWalletTransactionStore) ListByWalletID(ctx context.Context, walletID uint, offset, limit int) ([]*model.CardWalletTransaction, error) { + var transactions []*model.CardWalletTransaction + err := s.db.WithContext(ctx). + Where("card_wallet_id = ?", walletID). + Order("created_at DESC"). + Offset(offset). + Limit(limit). + Find(&transactions).Error + if err != nil { + return nil, err + } + return transactions, nil +} + +// GetByReference 根据关联业务查询交易记录 +func (s *CardWalletTransactionStore) GetByReference(ctx context.Context, referenceType string, referenceID uint) (*model.CardWalletTransaction, error) { + var transaction model.CardWalletTransaction + err := s.db.WithContext(ctx). + Where("reference_type = ? AND reference_id = ?", referenceType, referenceID). + First(&transaction).Error + if err != nil { + return nil, err + } + return &transaction, nil +} diff --git a/internal/store/postgres/recharge_store.go b/internal/store/postgres/recharge_store.go deleted file mode 100644 index 130ab1d..0000000 --- a/internal/store/postgres/recharge_store.go +++ /dev/null @@ -1,166 +0,0 @@ -package postgres - -import ( - "context" - "time" - - "github.com/break/junhong_cmp_fiber/internal/model" - "github.com/redis/go-redis/v9" - "gorm.io/gorm" -) - -type RechargeStore struct { - db *gorm.DB - redis *redis.Client -} - -// NewRechargeStore 创建充值订单 Store 实例 -func NewRechargeStore(db *gorm.DB, redis *redis.Client) *RechargeStore { - return &RechargeStore{ - db: db, - redis: redis, - } -} - -// Create 创建充值订单 -func (s *RechargeStore) Create(ctx context.Context, recharge *model.RechargeRecord) error { - return s.db.WithContext(ctx).Create(recharge).Error -} - -// GetByRechargeNo 根据充值订单号查询充值订单 -// 不存在时返回 nil, nil -func (s *RechargeStore) GetByRechargeNo(ctx context.Context, rechargeNo string) (*model.RechargeRecord, error) { - var recharge model.RechargeRecord - err := s.db.WithContext(ctx).Where("recharge_no = ?", rechargeNo).First(&recharge).Error - if err != nil { - if err == gorm.ErrRecordNotFound { - return nil, nil - } - return nil, err - } - return &recharge, nil -} - -// GetByID 根据 ID 查询充值订单 -func (s *RechargeStore) GetByID(ctx context.Context, id uint) (*model.RechargeRecord, error) { - var recharge model.RechargeRecord - if err := s.db.WithContext(ctx).First(&recharge, id).Error; err != nil { - return nil, err - } - return &recharge, nil -} - -// ListRechargeParams 充值订单列表查询参数 -type ListRechargeParams struct { - Page int // 页码(从 1 开始) - PageSize int // 每页数量 - UserID *uint // 用户 ID 筛选 - WalletID *uint // 钱包 ID 筛选 - Status *int // 状态筛选 - StartTime *time.Time // 开始时间 - EndTime *time.Time // 结束时间 -} - -// List 查询充值订单列表(支持分页和筛选) -func (s *RechargeStore) List(ctx context.Context, params *ListRechargeParams) ([]*model.RechargeRecord, int64, error) { - var recharges []*model.RechargeRecord - var total int64 - - query := s.db.WithContext(ctx).Model(&model.RechargeRecord{}) - - // 应用筛选条件 - if params.UserID != nil { - query = query.Where("user_id = ?", *params.UserID) - } - if params.WalletID != nil { - query = query.Where("wallet_id = ?", *params.WalletID) - } - if params.Status != nil { - query = query.Where("status = ?", *params.Status) - } - if params.StartTime != nil { - query = query.Where("created_at >= ?", *params.StartTime) - } - if params.EndTime != nil { - query = query.Where("created_at <= ?", *params.EndTime) - } - - // 统计总数 - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - - // 分页查询 - page := params.Page - if page < 1 { - page = 1 - } - pageSize := params.PageSize - if pageSize < 1 { - pageSize = 20 - } - - offset := (page - 1) * pageSize - if err := query.Order("id DESC").Offset(offset).Limit(pageSize).Find(&recharges).Error; err != nil { - return nil, 0, err - } - - return recharges, total, nil -} - -// UpdateStatus 更新充值订单状态(支持乐观锁检查) -// oldStatus: 原状态(用于乐观锁检查,传 nil 则跳过检查) -// newStatus: 新状态 -// paidAt: 支付时间(状态变为已支付时传入) -// completedAt: 完成时间(状态变为已完成时传入) -func (s *RechargeStore) UpdateStatus(ctx context.Context, id uint, oldStatus *int, newStatus int, paidAt *time.Time, completedAt *time.Time) error { - updates := map[string]interface{}{ - "status": newStatus, - } - if paidAt != nil { - updates["paid_at"] = paidAt - } - if completedAt != nil { - updates["completed_at"] = completedAt - } - - query := s.db.WithContext(ctx).Model(&model.RechargeRecord{}).Where("id = ?", id) - - // 乐观锁检查 - if oldStatus != nil { - query = query.Where("status = ?", *oldStatus) - } - - result := query.Updates(updates) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound - } - return nil -} - -// UpdatePaymentInfo 更新支付信息 -func (s *RechargeStore) UpdatePaymentInfo(ctx context.Context, id uint, paymentChannel *string, paymentTransactionID *string) error { - updates := map[string]interface{}{} - if paymentChannel != nil { - updates["payment_channel"] = paymentChannel - } - if paymentTransactionID != nil { - updates["payment_transaction_id"] = paymentTransactionID - } - - if len(updates) == 0 { - return nil - } - - result := s.db.WithContext(ctx).Model(&model.RechargeRecord{}).Where("id = ?", id).Updates(updates) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound - } - return nil -} diff --git a/internal/store/postgres/wallet_store.go b/internal/store/postgres/wallet_store.go deleted file mode 100644 index 929abb0..0000000 --- a/internal/store/postgres/wallet_store.go +++ /dev/null @@ -1,108 +0,0 @@ -package postgres - -import ( - "context" - - "github.com/break/junhong_cmp_fiber/internal/model" - "github.com/redis/go-redis/v9" - "gorm.io/gorm" -) - -type WalletStore struct { - db *gorm.DB - redis *redis.Client -} - -func NewWalletStore(db *gorm.DB, redis *redis.Client) *WalletStore { - return &WalletStore{ - db: db, - redis: redis, - } -} - -func (s *WalletStore) GetByResourceTypeAndID(ctx context.Context, resourceType string, resourceID uint, walletType string) (*model.Wallet, error) { - var wallet model.Wallet - err := s.db.WithContext(ctx). - Where("resource_type = ? AND resource_id = ? AND wallet_type = ?", resourceType, resourceID, walletType). - First(&wallet).Error - if err != nil { - return nil, err - } - return &wallet, nil -} - -func (s *WalletStore) GetShopCommissionWallet(ctx context.Context, shopID uint) (*model.Wallet, error) { - return s.GetByResourceTypeAndID(ctx, "shop", shopID, "commission") -} - -type ShopCommissionSummary struct { - ShopID uint - Balance int64 - FrozenBalance int64 -} - -func (s *WalletStore) GetShopCommissionSummaryBatch(ctx context.Context, shopIDs []uint) (map[uint]*ShopCommissionSummary, error) { - if len(shopIDs) == 0 { - return make(map[uint]*ShopCommissionSummary), nil - } - - var wallets []model.Wallet - err := s.db.WithContext(ctx). - Where("resource_type = ? AND resource_id IN ? AND wallet_type = ?", "shop", shopIDs, "commission"). - Find(&wallets).Error - if err != nil { - return nil, err - } - - result := make(map[uint]*ShopCommissionSummary) - for _, w := range wallets { - result[w.ResourceID] = &ShopCommissionSummary{ - ShopID: w.ResourceID, - Balance: w.Balance, - FrozenBalance: w.FrozenBalance, - } - } - - return result, nil -} - -func (s *WalletStore) GetByID(ctx context.Context, id uint) (*model.Wallet, error) { - var wallet model.Wallet - if err := s.db.WithContext(ctx).First(&wallet, id).Error; err != nil { - return nil, err - } - return &wallet, nil -} - -func (s *WalletStore) DeductFrozenBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64) error { - result := tx.WithContext(ctx). - Model(&model.Wallet{}). - Where("id = ? AND frozen_balance >= ?", walletID, amount). - Updates(map[string]interface{}{ - "frozen_balance": gorm.Expr("frozen_balance - ?", amount), - }) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound - } - return nil -} - -func (s *WalletStore) UnfreezeBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64) error { - result := tx.WithContext(ctx). - Model(&model.Wallet{}). - Where("id = ? AND frozen_balance >= ?", walletID, amount). - Updates(map[string]interface{}{ - "balance": gorm.Expr("balance + ?", amount), - "frozen_balance": gorm.Expr("frozen_balance - ?", amount), - }) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound - } - return nil -} diff --git a/internal/store/postgres/wallet_transaction_store.go b/internal/store/postgres/wallet_transaction_store.go deleted file mode 100644 index 39ef183..0000000 --- a/internal/store/postgres/wallet_transaction_store.go +++ /dev/null @@ -1,37 +0,0 @@ -package postgres - -import ( - "context" - - "github.com/break/junhong_cmp_fiber/internal/model" - "github.com/redis/go-redis/v9" - "gorm.io/gorm" -) - -type WalletTransactionStore struct { - db *gorm.DB - redis *redis.Client -} - -func NewWalletTransactionStore(db *gorm.DB, redis *redis.Client) *WalletTransactionStore { - return &WalletTransactionStore{ - db: db, - redis: redis, - } -} - -func (s *WalletTransactionStore) CreateWithTx(ctx context.Context, tx *gorm.DB, transaction *model.WalletTransaction) error { - return tx.WithContext(ctx).Create(transaction).Error -} - -func (s *WalletTransactionStore) Create(ctx context.Context, transaction *model.WalletTransaction) error { - return s.db.WithContext(ctx).Create(transaction).Error -} - -func (s *WalletTransactionStore) GetByID(ctx context.Context, id uint) (*model.WalletTransaction, error) { - var tx model.WalletTransaction - if err := s.db.WithContext(ctx).First(&tx, id).Error; err != nil { - return nil, err - } - return &tx, nil -} diff --git a/internal/task/polling_handler.go b/internal/task/polling_handler.go index c4d8962..4a72fe7 100644 --- a/internal/task/polling_handler.go +++ b/internal/task/polling_handler.go @@ -28,16 +28,16 @@ type PollingTaskPayload struct { // PollingHandler 轮询任务处理器 type PollingHandler struct { - db *gorm.DB - redis *redis.Client - gatewayClient *gateway.Client - iotCardStore *postgres.IotCardStore - concurrencyStore *postgres.PollingConcurrencyConfigStore - deviceSimBindingStore *postgres.DeviceSimBindingStore - dataUsageRecordStore *postgres.DataUsageRecordStore - packageUsageStore *postgres.PackageUsageStore - usageService *packagepkg.UsageService - logger *zap.Logger + db *gorm.DB + redis *redis.Client + gatewayClient *gateway.Client + iotCardStore *postgres.IotCardStore + concurrencyStore *postgres.PollingConcurrencyConfigStore + deviceSimBindingStore *postgres.DeviceSimBindingStore + dataUsageRecordStore *postgres.DataUsageRecordStore + packageUsageStore *postgres.PackageUsageStore + usageService *packagepkg.UsageService + logger *zap.Logger } // NewPollingHandler 创建轮询任务处理器 @@ -925,14 +925,14 @@ func (h *PollingHandler) getCardWithCache(ctx context.Context, cardID uint) (*mo go func() { cacheCtx := context.Background() cacheData := map[string]any{ - "id": card.ID, - "iccid": card.ICCID, - "card_category": card.CardCategory, - "real_name_status": card.RealNameStatus, - "network_status": card.NetworkStatus, - "carrier_id": card.CarrierID, + "id": card.ID, + "iccid": card.ICCID, + "card_category": card.CardCategory, + "real_name_status": card.RealNameStatus, + "network_status": card.NetworkStatus, + "carrier_id": card.CarrierID, "current_month_usage_mb": card.CurrentMonthUsageMB, - "cached_at": time.Now().Unix(), + "cached_at": time.Now().Unix(), } if card.SeriesID != nil { cacheData["series_id"] = *card.SeriesID diff --git a/migrations/000059_create_tb_agent_wallet.down.sql b/migrations/000059_create_tb_agent_wallet.down.sql new file mode 100644 index 0000000..102eab0 --- /dev/null +++ b/migrations/000059_create_tb_agent_wallet.down.sql @@ -0,0 +1,2 @@ +-- 删除代理钱包主表 +DROP TABLE IF EXISTS tb_agent_wallet; diff --git a/migrations/000059_create_tb_agent_wallet.up.sql b/migrations/000059_create_tb_agent_wallet.up.sql new file mode 100644 index 0000000..3d4699d --- /dev/null +++ b/migrations/000059_create_tb_agent_wallet.up.sql @@ -0,0 +1,48 @@ +-- 创建代理钱包主表 +-- 用于管理店铺级别的主钱包和分佣钱包 + +CREATE TABLE IF NOT EXISTS tb_agent_wallet ( + id BIGSERIAL PRIMARY KEY, + shop_id BIGINT NOT NULL, + wallet_type VARCHAR(20) NOT NULL, + balance BIGINT NOT NULL DEFAULT 0, + frozen_balance BIGINT NOT NULL DEFAULT 0, + currency VARCHAR(10) NOT NULL DEFAULT 'CNY', + status INT NOT NULL DEFAULT 1, + version INT NOT NULL DEFAULT 0, + shop_id_tag BIGINT NOT NULL, + enterprise_id_tag BIGINT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + + -- 约束 + CONSTRAINT chk_agent_wallet_balance CHECK (balance >= 0), + CONSTRAINT chk_agent_wallet_frozen_balance CHECK (frozen_balance >= 0 AND frozen_balance <= balance), + CONSTRAINT chk_agent_wallet_status CHECK (status IN (1, 2, 3)), + CONSTRAINT chk_agent_wallet_type CHECK (wallet_type IN ('main', 'commission')) +); + +-- 唯一索引:shop_id + wallet_type 在未删除时唯一 +CREATE UNIQUE INDEX idx_agent_wallet_shop_type ON tb_agent_wallet (shop_id, wallet_type) WHERE deleted_at IS NULL; + +-- 状态索引 +CREATE INDEX idx_agent_wallet_status ON tb_agent_wallet (status); + +-- 多租户过滤索引 +CREATE INDEX idx_agent_wallet_shop_tag ON tb_agent_wallet (shop_id_tag); + +-- 企业标签索引(用于多租户过滤) +CREATE INDEX idx_agent_wallet_enterprise_tag ON tb_agent_wallet (enterprise_id_tag) WHERE enterprise_id_tag IS NOT NULL; + +-- 添加注释 +COMMENT ON TABLE tb_agent_wallet IS '代理钱包主表'; +COMMENT ON COLUMN tb_agent_wallet.shop_id IS '店铺 ID'; +COMMENT ON COLUMN tb_agent_wallet.wallet_type IS '钱包类型:main-主钱包 | commission-分佣钱包'; +COMMENT ON COLUMN tb_agent_wallet.balance IS '余额(单位:分)'; +COMMENT ON COLUMN tb_agent_wallet.frozen_balance IS '冻结余额(单位:分)'; +COMMENT ON COLUMN tb_agent_wallet.currency IS '币种'; +COMMENT ON COLUMN tb_agent_wallet.status IS '钱包状态:1-正常 2-冻结 3-关闭'; +COMMENT ON COLUMN tb_agent_wallet.version IS '版本号(乐观锁)'; +COMMENT ON COLUMN tb_agent_wallet.shop_id_tag IS '店铺 ID 标签(多租户过滤)'; +COMMENT ON COLUMN tb_agent_wallet.enterprise_id_tag IS '企业 ID 标签(多租户过滤)'; diff --git a/migrations/000060_create_tb_agent_wallet_transaction.down.sql b/migrations/000060_create_tb_agent_wallet_transaction.down.sql new file mode 100644 index 0000000..8e1564a --- /dev/null +++ b/migrations/000060_create_tb_agent_wallet_transaction.down.sql @@ -0,0 +1,2 @@ +-- 删除代理钱包交易记录表 +DROP TABLE IF EXISTS tb_agent_wallet_transaction; diff --git a/migrations/000060_create_tb_agent_wallet_transaction.up.sql b/migrations/000060_create_tb_agent_wallet_transaction.up.sql new file mode 100644 index 0000000..e48dc63 --- /dev/null +++ b/migrations/000060_create_tb_agent_wallet_transaction.up.sql @@ -0,0 +1,60 @@ +-- 创建代理钱包交易记录表 +-- 记录所有代理钱包余额变动 + +CREATE TABLE IF NOT EXISTS tb_agent_wallet_transaction ( + id BIGSERIAL PRIMARY KEY, + agent_wallet_id BIGINT NOT NULL, + shop_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + transaction_type VARCHAR(20) NOT NULL, + amount BIGINT NOT NULL, + balance_before BIGINT NOT NULL, + balance_after BIGINT NOT NULL, + status INT NOT NULL DEFAULT 1, + reference_type VARCHAR(50), + reference_id BIGINT, + remark TEXT, + metadata JSONB, + creator BIGINT NOT NULL, + shop_id_tag BIGINT NOT NULL, + enterprise_id_tag BIGINT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + + -- 约束 + CONSTRAINT chk_agent_tx_status CHECK (status IN (1, 2, 3)), + CONSTRAINT chk_agent_tx_type CHECK (transaction_type IN ('recharge', 'deduct', 'refund', 'commission', 'withdrawal')) +); + +-- 按钱包查询交易历史索引 +CREATE INDEX idx_agent_tx_wallet ON tb_agent_wallet_transaction (agent_wallet_id, created_at DESC); + +-- 按店铺汇总交易索引 +CREATE INDEX idx_agent_tx_shop ON tb_agent_wallet_transaction (shop_id, created_at DESC); + +-- 按关联业务查询索引 +CREATE INDEX idx_agent_tx_ref ON tb_agent_wallet_transaction (reference_type, reference_id) WHERE reference_type IS NOT NULL; + +-- 按交易类型统计索引 +CREATE INDEX idx_agent_tx_type ON tb_agent_wallet_transaction (transaction_type, created_at DESC); + +-- 多租户过滤索引 +CREATE INDEX idx_agent_tx_shop_tag ON tb_agent_wallet_transaction (shop_id_tag); + +-- 企业标签索引 +CREATE INDEX idx_agent_tx_enterprise_tag ON tb_agent_wallet_transaction (enterprise_id_tag) WHERE enterprise_id_tag IS NOT NULL; + +-- 添加注释 +COMMENT ON TABLE tb_agent_wallet_transaction IS '代理钱包交易记录表'; +COMMENT ON COLUMN tb_agent_wallet_transaction.agent_wallet_id IS '代理钱包 ID'; +COMMENT ON COLUMN tb_agent_wallet_transaction.shop_id IS '店铺 ID(冗余字段,便于查询)'; +COMMENT ON COLUMN tb_agent_wallet_transaction.user_id IS '操作人用户 ID'; +COMMENT ON COLUMN tb_agent_wallet_transaction.transaction_type IS '交易类型:recharge-充值 | deduct-扣款 | refund-退款 | commission-分佣 | withdrawal-提现'; +COMMENT ON COLUMN tb_agent_wallet_transaction.amount IS '变动金额(单位:分,正数为增加,负数为减少)'; +COMMENT ON COLUMN tb_agent_wallet_transaction.balance_before IS '变动前余额(单位:分)'; +COMMENT ON COLUMN tb_agent_wallet_transaction.balance_after IS '变动后余额(单位:分)'; +COMMENT ON COLUMN tb_agent_wallet_transaction.status IS '交易状态:1-成功 2-失败 3-处理中'; +COMMENT ON COLUMN tb_agent_wallet_transaction.reference_type IS '关联业务类型(如 order | commission | withdrawal | topup)'; +COMMENT ON COLUMN tb_agent_wallet_transaction.reference_id IS '关联业务 ID'; +COMMENT ON COLUMN tb_agent_wallet_transaction.metadata IS '扩展信息(如手续费、支付方式等)'; diff --git a/migrations/000061_create_tb_agent_recharge_record.down.sql b/migrations/000061_create_tb_agent_recharge_record.down.sql new file mode 100644 index 0000000..2f93342 --- /dev/null +++ b/migrations/000061_create_tb_agent_recharge_record.down.sql @@ -0,0 +1,2 @@ +-- 删除代理充值记录表 +DROP TABLE IF EXISTS tb_agent_recharge_record; diff --git a/migrations/000061_create_tb_agent_recharge_record.up.sql b/migrations/000061_create_tb_agent_recharge_record.up.sql new file mode 100644 index 0000000..28a1d4a --- /dev/null +++ b/migrations/000061_create_tb_agent_recharge_record.up.sql @@ -0,0 +1,59 @@ +-- 创建代理充值记录表 +-- 记录所有代理充值操作 + +CREATE TABLE IF NOT EXISTS tb_agent_recharge_record ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + agent_wallet_id BIGINT NOT NULL, + shop_id BIGINT NOT NULL, + recharge_no VARCHAR(50) NOT NULL UNIQUE, + amount BIGINT NOT NULL, + payment_method VARCHAR(20) NOT NULL, + payment_channel VARCHAR(50), + payment_transaction_id VARCHAR(100), + status INT NOT NULL DEFAULT 1, + paid_at TIMESTAMP, + completed_at TIMESTAMP, + shop_id_tag BIGINT NOT NULL, + enterprise_id_tag BIGINT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + + -- 约束 + CONSTRAINT chk_agent_recharge_amount CHECK (amount >= 10000), + CONSTRAINT chk_agent_recharge_status CHECK (status IN (1, 2, 3, 4, 5)), + CONSTRAINT chk_agent_recharge_method CHECK (payment_method IN ('alipay', 'wechat', 'bank', 'offline')) +); + +-- 按用户查询充值记录索引 +CREATE INDEX idx_agent_recharge_user ON tb_agent_recharge_record (user_id, created_at DESC); + +-- 按店铺查询充值记录索引 +CREATE INDEX idx_agent_recharge_shop ON tb_agent_recharge_record (shop_id, created_at DESC); + +-- 按状态过滤充值记录索引 +CREATE INDEX idx_agent_recharge_status ON tb_agent_recharge_record (status, created_at DESC); + +-- 按订单号查询索引 +CREATE UNIQUE INDEX idx_agent_recharge_no ON tb_agent_recharge_record (recharge_no); + +-- 多租户过滤索引 +CREATE INDEX idx_agent_recharge_shop_tag ON tb_agent_recharge_record (shop_id_tag); + +-- 企业标签索引 +CREATE INDEX idx_agent_recharge_enterprise_tag ON tb_agent_recharge_record (enterprise_id_tag) WHERE enterprise_id_tag IS NOT NULL; + +-- 添加注释 +COMMENT ON TABLE tb_agent_recharge_record IS '代理充值记录表'; +COMMENT ON COLUMN tb_agent_recharge_record.user_id IS '操作人用户 ID'; +COMMENT ON COLUMN tb_agent_recharge_record.agent_wallet_id IS '代理钱包 ID'; +COMMENT ON COLUMN tb_agent_recharge_record.shop_id IS '店铺 ID(冗余字段,便于查询)'; +COMMENT ON COLUMN tb_agent_recharge_record.recharge_no IS '充值订单号(格式:ARCH+时间戳+随机数)'; +COMMENT ON COLUMN tb_agent_recharge_record.amount IS '充值金额(单位:分,最小 10000 分 = 100 元)'; +COMMENT ON COLUMN tb_agent_recharge_record.payment_method IS '支付方式:alipay-支付宝 | wechat-微信 | bank-银行转账 | offline-线下'; +COMMENT ON COLUMN tb_agent_recharge_record.payment_channel IS '支付渠道'; +COMMENT ON COLUMN tb_agent_recharge_record.payment_transaction_id IS '第三方支付交易号'; +COMMENT ON COLUMN tb_agent_recharge_record.status IS '充值状态:1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款'; +COMMENT ON COLUMN tb_agent_recharge_record.paid_at IS '支付时间'; +COMMENT ON COLUMN tb_agent_recharge_record.completed_at IS '完成时间'; diff --git a/migrations/000062_create_tb_card_wallet.down.sql b/migrations/000062_create_tb_card_wallet.down.sql new file mode 100644 index 0000000..f68400e --- /dev/null +++ b/migrations/000062_create_tb_card_wallet.down.sql @@ -0,0 +1,2 @@ +-- 删除卡钱包主表 +DROP TABLE IF EXISTS tb_card_wallet; diff --git a/migrations/000062_create_tb_card_wallet.up.sql b/migrations/000062_create_tb_card_wallet.up.sql new file mode 100644 index 0000000..bb7d6fe --- /dev/null +++ b/migrations/000062_create_tb_card_wallet.up.sql @@ -0,0 +1,48 @@ +-- 创建卡钱包主表 +-- 用于管理物联网卡和设备级别的钱包 + +CREATE TABLE IF NOT EXISTS tb_card_wallet ( + id BIGSERIAL PRIMARY KEY, + resource_type VARCHAR(20) NOT NULL, + resource_id BIGINT NOT NULL, + balance BIGINT NOT NULL DEFAULT 0, + frozen_balance BIGINT NOT NULL DEFAULT 0, + currency VARCHAR(10) NOT NULL DEFAULT 'CNY', + status INT NOT NULL DEFAULT 1, + version INT NOT NULL DEFAULT 0, + shop_id_tag BIGINT NOT NULL, + enterprise_id_tag BIGINT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + + -- 约束 + CONSTRAINT chk_card_wallet_balance CHECK (balance >= 0), + CONSTRAINT chk_card_wallet_frozen_balance CHECK (frozen_balance >= 0 AND frozen_balance <= balance), + CONSTRAINT chk_card_wallet_status CHECK (status IN (1, 2, 3)), + CONSTRAINT chk_card_wallet_type CHECK (resource_type IN ('iot_card', 'device')) +); + +-- 唯一索引:resource_type + resource_id 在未删除时唯一 +CREATE UNIQUE INDEX idx_card_wallet_resource ON tb_card_wallet (resource_type, resource_id) WHERE deleted_at IS NULL; + +-- 状态索引 +CREATE INDEX idx_card_wallet_status ON tb_card_wallet (status); + +-- 多租户过滤索引 +CREATE INDEX idx_card_wallet_shop_tag ON tb_card_wallet (shop_id_tag); + +-- 企业标签索引 +CREATE INDEX idx_card_wallet_enterprise_tag ON tb_card_wallet (enterprise_id_tag) WHERE enterprise_id_tag IS NOT NULL; + +-- 添加注释 +COMMENT ON TABLE tb_card_wallet IS '卡钱包主表'; +COMMENT ON COLUMN tb_card_wallet.resource_type IS '资源类型:iot_card-物联网卡 | device-设备'; +COMMENT ON COLUMN tb_card_wallet.resource_id IS '资源 ID(关联 tb_iot_card.id 或 tb_device.id)'; +COMMENT ON COLUMN tb_card_wallet.balance IS '余额(单位:分)'; +COMMENT ON COLUMN tb_card_wallet.frozen_balance IS '冻结余额(单位:分)'; +COMMENT ON COLUMN tb_card_wallet.currency IS '币种'; +COMMENT ON COLUMN tb_card_wallet.status IS '钱包状态:1-正常 2-冻结 3-关闭'; +COMMENT ON COLUMN tb_card_wallet.version IS '版本号(乐观锁)'; +COMMENT ON COLUMN tb_card_wallet.shop_id_tag IS '店铺 ID 标签(多租户过滤)'; +COMMENT ON COLUMN tb_card_wallet.enterprise_id_tag IS '企业 ID 标签(多租户过滤)'; diff --git a/migrations/000063_create_tb_card_wallet_transaction.down.sql b/migrations/000063_create_tb_card_wallet_transaction.down.sql new file mode 100644 index 0000000..7921674 --- /dev/null +++ b/migrations/000063_create_tb_card_wallet_transaction.down.sql @@ -0,0 +1,2 @@ +-- 删除卡钱包交易记录表 +DROP TABLE IF EXISTS tb_card_wallet_transaction; diff --git a/migrations/000063_create_tb_card_wallet_transaction.up.sql b/migrations/000063_create_tb_card_wallet_transaction.up.sql new file mode 100644 index 0000000..09e5df1 --- /dev/null +++ b/migrations/000063_create_tb_card_wallet_transaction.up.sql @@ -0,0 +1,62 @@ +-- 创建卡钱包交易记录表 +-- 记录所有卡钱包余额变动 + +CREATE TABLE IF NOT EXISTS tb_card_wallet_transaction ( + id BIGSERIAL PRIMARY KEY, + card_wallet_id BIGINT NOT NULL, + resource_type VARCHAR(20) NOT NULL, + resource_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + transaction_type VARCHAR(20) NOT NULL, + amount BIGINT NOT NULL, + balance_before BIGINT NOT NULL, + balance_after BIGINT NOT NULL, + status INT NOT NULL DEFAULT 1, + reference_type VARCHAR(50), + reference_id BIGINT, + remark TEXT, + metadata JSONB, + creator BIGINT NOT NULL, + shop_id_tag BIGINT NOT NULL, + enterprise_id_tag BIGINT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + + -- 约束 + CONSTRAINT chk_card_tx_status CHECK (status IN (1, 2, 3)), + CONSTRAINT chk_card_tx_type CHECK (transaction_type IN ('recharge', 'deduct', 'refund')) +); + +-- 按钱包查询交易历史索引 +CREATE INDEX idx_card_tx_wallet ON tb_card_wallet_transaction (card_wallet_id, created_at DESC); + +-- 按资源查询交易索引 +CREATE INDEX idx_card_tx_resource ON tb_card_wallet_transaction (resource_type, resource_id, created_at DESC); + +-- 按关联业务查询索引 +CREATE INDEX idx_card_tx_ref ON tb_card_wallet_transaction (reference_type, reference_id) WHERE reference_type IS NOT NULL; + +-- 按交易类型统计索引 +CREATE INDEX idx_card_tx_type ON tb_card_wallet_transaction (transaction_type, created_at DESC); + +-- 多租户过滤索引 +CREATE INDEX idx_card_tx_shop_tag ON tb_card_wallet_transaction (shop_id_tag); + +-- 企业标签索引 +CREATE INDEX idx_card_tx_enterprise_tag ON tb_card_wallet_transaction (enterprise_id_tag) WHERE enterprise_id_tag IS NOT NULL; + +-- 添加注释 +COMMENT ON TABLE tb_card_wallet_transaction IS '卡钱包交易记录表'; +COMMENT ON COLUMN tb_card_wallet_transaction.card_wallet_id IS '卡钱包 ID'; +COMMENT ON COLUMN tb_card_wallet_transaction.resource_type IS '资源类型(冗余字段,便于查询)'; +COMMENT ON COLUMN tb_card_wallet_transaction.resource_id IS '资源 ID(冗余字段,便于查询)'; +COMMENT ON COLUMN tb_card_wallet_transaction.user_id IS '操作人用户 ID'; +COMMENT ON COLUMN tb_card_wallet_transaction.transaction_type IS '交易类型:recharge-充值 | deduct-扣款 | refund-退款'; +COMMENT ON COLUMN tb_card_wallet_transaction.amount IS '变动金额(单位:分,正数为增加,负数为减少)'; +COMMENT ON COLUMN tb_card_wallet_transaction.balance_before IS '变动前余额(单位:分)'; +COMMENT ON COLUMN tb_card_wallet_transaction.balance_after IS '变动后余额(单位:分)'; +COMMENT ON COLUMN tb_card_wallet_transaction.status IS '交易状态:1-成功 2-失败 3-处理中'; +COMMENT ON COLUMN tb_card_wallet_transaction.reference_type IS '关联业务类型(如 order | topup)'; +COMMENT ON COLUMN tb_card_wallet_transaction.reference_id IS '关联业务 ID'; +COMMENT ON COLUMN tb_card_wallet_transaction.metadata IS '扩展信息(如套餐信息、支付方式等)'; diff --git a/migrations/000064_create_tb_card_recharge_record.down.sql b/migrations/000064_create_tb_card_recharge_record.down.sql new file mode 100644 index 0000000..7cf6a2b --- /dev/null +++ b/migrations/000064_create_tb_card_recharge_record.down.sql @@ -0,0 +1,2 @@ +-- 删除卡充值记录表 +DROP TABLE IF EXISTS tb_card_recharge_record; diff --git a/migrations/000064_create_tb_card_recharge_record.up.sql b/migrations/000064_create_tb_card_recharge_record.up.sql new file mode 100644 index 0000000..10e3e6c --- /dev/null +++ b/migrations/000064_create_tb_card_recharge_record.up.sql @@ -0,0 +1,61 @@ +-- 创建卡充值记录表 +-- 记录所有卡充值操作 + +CREATE TABLE IF NOT EXISTS tb_card_recharge_record ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + card_wallet_id BIGINT NOT NULL, + resource_type VARCHAR(20) NOT NULL, + resource_id BIGINT NOT NULL, + recharge_no VARCHAR(50) NOT NULL UNIQUE, + amount BIGINT NOT NULL, + payment_method VARCHAR(20) NOT NULL, + payment_channel VARCHAR(50), + payment_transaction_id VARCHAR(100), + status INT NOT NULL DEFAULT 1, + paid_at TIMESTAMP, + completed_at TIMESTAMP, + shop_id_tag BIGINT NOT NULL, + enterprise_id_tag BIGINT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + + -- 约束 + CONSTRAINT chk_card_recharge_amount CHECK (amount >= 100), + CONSTRAINT chk_card_recharge_status CHECK (status IN (1, 2, 3, 4, 5)), + CONSTRAINT chk_card_recharge_method CHECK (payment_method IN ('alipay', 'wechat')) +); + +-- 按用户查询充值记录索引 +CREATE INDEX idx_card_recharge_user ON tb_card_recharge_record (user_id, created_at DESC); + +-- 按资源查询充值记录索引 +CREATE INDEX idx_card_recharge_resource ON tb_card_recharge_record (resource_type, resource_id, created_at DESC); + +-- 按状态过滤充值记录索引 +CREATE INDEX idx_card_recharge_status ON tb_card_recharge_record (status, created_at DESC); + +-- 按订单号查询索引 +CREATE UNIQUE INDEX idx_card_recharge_no ON tb_card_recharge_record (recharge_no); + +-- 多租户过滤索引 +CREATE INDEX idx_card_recharge_shop_tag ON tb_card_recharge_record (shop_id_tag); + +-- 企业标签索引 +CREATE INDEX idx_card_recharge_enterprise_tag ON tb_card_recharge_record (enterprise_id_tag) WHERE enterprise_id_tag IS NOT NULL; + +-- 添加注释 +COMMENT ON TABLE tb_card_recharge_record IS '卡充值记录表'; +COMMENT ON COLUMN tb_card_recharge_record.user_id IS '操作人用户 ID'; +COMMENT ON COLUMN tb_card_recharge_record.card_wallet_id IS '卡钱包 ID'; +COMMENT ON COLUMN tb_card_recharge_record.resource_type IS '资源类型(冗余字段)'; +COMMENT ON COLUMN tb_card_recharge_record.resource_id IS '资源 ID(冗余字段)'; +COMMENT ON COLUMN tb_card_recharge_record.recharge_no IS '充值订单号(格式:CRCH+时间戳+随机数)'; +COMMENT ON COLUMN tb_card_recharge_record.amount IS '充值金额(单位:分,最小 100 分 = 1 元)'; +COMMENT ON COLUMN tb_card_recharge_record.payment_method IS '支付方式:alipay-支付宝 | wechat-微信'; +COMMENT ON COLUMN tb_card_recharge_record.payment_channel IS '支付渠道'; +COMMENT ON COLUMN tb_card_recharge_record.payment_transaction_id IS '第三方支付交易号'; +COMMENT ON COLUMN tb_card_recharge_record.status IS '充值状态:1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款'; +COMMENT ON COLUMN tb_card_recharge_record.paid_at IS '支付时间'; +COMMENT ON COLUMN tb_card_recharge_record.completed_at IS '完成时间'; diff --git a/migrations/000065_drop_old_wallet_tables.down.sql b/migrations/000065_drop_old_wallet_tables.down.sql new file mode 100644 index 0000000..74a5bc2 --- /dev/null +++ b/migrations/000065_drop_old_wallet_tables.down.sql @@ -0,0 +1,6 @@ +-- 回滚:恢复旧的钱包表结构 +-- 注意:此回滚脚本仅用于迁移失败的情况,实际使用中应避免回滚 +-- 旧系统已废弃,不建议回滚到旧表结构 + +-- 此处故意留空,因为钱包系统已完全重构 +-- 如需回滚,请手动参考旧版本的迁移文件重建表结构 diff --git a/migrations/000065_drop_old_wallet_tables.up.sql b/migrations/000065_drop_old_wallet_tables.up.sql new file mode 100644 index 0000000..fda2d3b --- /dev/null +++ b/migrations/000065_drop_old_wallet_tables.up.sql @@ -0,0 +1,6 @@ +-- 删除旧的钱包相关表 +-- 新的钱包系统已完全迁移至 tb_agent_wallet、tb_card_wallet 等 6 张新表 + +DROP TABLE IF EXISTS tb_recharge_record; +DROP TABLE IF EXISTS tb_wallet_transaction; +DROP TABLE IF EXISTS tb_wallet; diff --git a/openspec/changes/archive/2026-02-25-separate-agent-card-wallets/.openspec.yaml b/openspec/changes/archive/2026-02-25-separate-agent-card-wallets/.openspec.yaml new file mode 100644 index 0000000..69e221f --- /dev/null +++ b/openspec/changes/archive/2026-02-25-separate-agent-card-wallets/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-24 diff --git a/openspec/changes/archive/2026-02-25-separate-agent-card-wallets/design.md b/openspec/changes/archive/2026-02-25-separate-agent-card-wallets/design.md new file mode 100644 index 0000000..c7291b3 --- /dev/null +++ b/openspec/changes/archive/2026-02-25-separate-agent-card-wallets/design.md @@ -0,0 +1,540 @@ +# 钱包系统分离 - 技术设计 + +## Context + +### 当前架构 + +当前系统使用统一钱包表设计,所有钱包类型存储在同一张表中: + +``` +tb_wallet (统一钱包表) +├─ resource_type (iot_card / device / shop) +├─ resource_id +├─ wallet_type (main / commission) +├─ balance, frozen_balance +└─ version (乐观锁) + +tb_wallet_transaction (统一交易记录) +tb_recharge_record (统一充值记录) +``` + +**代码层面**: +- 统一的 `WalletStore`,所有钱包类型共用相同的数据访问方法 +- 多个 Service 依赖同一个 Store: + - `commission_*` Service(代理钱包) + - `order` Service(卡钱包) + - `recharge` Service(代理+卡) + +### 现存问题 + +1. **隔离性不足**:代理钱包和卡钱包在数据层和代码层耦合,某个钱包类型的问题可能影响其他类型 +2. **性能优化受限**:单表数据量大,索引优化需要兼顾多种查询模式,难以针对性优化 +3. **业务语义模糊**:代理钱包(店铺级别)和卡钱包(资源级别)在业务上差异很大,但使用同一套字段设计 +4. **稳定性风险**:代理钱包作为核心资产(涉及资金结算),需要更高的隔离级别 + +### 约束条件 + +- ✅ 项目处于开发阶段,无生产数据,可直接删除旧表 +- ✅ 必须遵循 Handler → Service → Store → Model 分层架构 +- ✅ 禁止使用外键约束和 GORM 关联关系 +- ✅ 必须使用乐观锁(version 字段)防止并发余额冲突 +- ✅ 所有常量定义在 `pkg/constants/` + +## Goals / Non-Goals + +### Goals + +1. **数据层完全隔离**:代理钱包和卡钱包使用独立的数据表,包括交易记录表和充值记录表 +2. **代码层完全隔离**:独立的 Model、Store,类型不兼容,编译期防止混用 +3. **业务语义清晰化**:代理钱包简化为 shop_id 主键设计,卡钱包保留 resource_type + resource_id 设计 +4. **独立优化能力**:两种钱包的索引、缓存策略、监控指标完全独立 +5. **保持 API 兼容性**:对外接口不变,仅内部实现重构 + +### Non-Goals + +1. **不做服务拆分**:不将钱包系统拆分为独立的微服务,仍在单体应用内 +2. **不引入抽象层**:不创建统一的 `WalletService` 接口,两种钱包完全独立实现 +3. **不做数据迁移**:当前处于开发阶段,直接删除旧表,不考虑迁移脚本 +4. **不修改对外 API**:Handler 层接口保持不变,仅内部调用改为新的 Store + +## Decisions + +### 决策 1:交易记录表和充值记录表也完全分离 + +**决策**:不仅分离钱包主表,交易记录表和充值记录表也按钱包类型分离 + +**理由**: +- **数据量级差异大**:卡钱包交易记录可能是代理钱包的 100 倍以上(每次套餐扣费都有记录) +- **查询隔离**:代理的对账报表不应该被卡交易的查询拖慢 +- **索引优化独立**:代理钱包的交易查询模式(按店铺、按时间范围)与卡钱包(按资源、按订单)完全不同 + +**替代方案**: +- ❌ 保留统一交易记录表,通过 `wallet_table_type` 字段区分:隔离不彻底,查询性能受限 + +**代价**: +- 如果未来需要跨钱包类型的全局交易统计,需要跨表查询(UNION)或创建视图 + +--- + +### 决策 2:代理钱包简化为 shop_id 主键设计 + +**当前设计**(统一表): +```sql +tb_wallet ( + resource_type = 'shop', + resource_id = shop_id, + wallet_type = 'main' | 'commission' +) +``` + +**新设计**(简化): +```sql +tb_agent_wallet ( + shop_id, + wallet_type = 'main' | 'commission' +) +``` + +**理由**: +- 代理钱包只归属店铺,不需要 `resource_type` 字段 +- 简化查询逻辑,直接用 `shop_id` 查询 +- 类型更明确,编译期防止误用 + +**索引设计**: +```sql +UNIQUE INDEX idx_agent_wallet_shop_type (shop_id, wallet_type, deleted_at) +INDEX idx_agent_wallet_status (status) +INDEX idx_agent_wallet_shop_tag (shop_id_tag) -- 多租户过滤 +``` + +--- + +### 决策 3:卡钱包保留 resource_type + resource_id 设计 + +**设计**: +```sql +tb_card_wallet ( + resource_type = 'iot_card' | 'device', + resource_id, + balance, frozen_balance, version +) +``` + +**理由**: +- 卡钱包需要支持两种资源类型(物联网卡、设备) +- 保留灵活性,未来可能增加其他资源类型(如企业设备) +- 与现有业务逻辑保持一致 + +**索引设计**: +```sql +UNIQUE INDEX idx_card_wallet_resource (resource_type, resource_id, deleted_at) +INDEX idx_card_wallet_status (status) +INDEX idx_card_wallet_shop_tag (shop_id_tag) -- 多租户过滤 +``` + +--- + +### 决策 4:Model 层类型完全独立 + +**设计**: +```go +// internal/model/agent_wallet.go +type AgentWallet struct { + gorm.Model + ShopID uint + WalletType string + Balance int64 + FrozenBalance int64 + Version int +} + +// internal/model/card_wallet.go +type CardWallet struct { + gorm.Model + ResourceType string + ResourceID uint + Balance int64 + FrozenBalance int64 + Version int +} +``` + +**理由**: +- 两个独立类型,编译期防止混用(`AgentWallet` 不能传给 `CardWalletStore`) +- 字段设计针对各自业务场景优化 +- 清晰的类型语义,代码可读性更高 + +**替代方案**: +- ❌ 共用一个 `Wallet` 基类 + 接口抽象:过度设计,增加复杂度 + +--- + +### 决策 5:Store 层完全独立 + +**设计**: +```go +// internal/store/postgres/agent_wallet_store.go +type AgentWalletStore struct { + db *gorm.DB + redis *redis.Client +} + +func (s *AgentWalletStore) GetCommissionWallet(ctx, shopID) (*model.AgentWallet, error) +func (s *AgentWalletStore) FreezeBalanceWithTx(ctx, tx, walletID, amount, version) error + +// internal/store/postgres/card_wallet_store.go +type CardWalletStore struct { + db *gorm.DB + redis *redis.Client +} + +func (s *CardWalletStore) GetByResourceTypeAndID(ctx, resourceType, resourceID) (*model.CardWallet, error) +func (s *CardWalletStore) DeductBalanceWithTx(ctx, tx, walletID, amount, version) error +``` + +**理由**: +- 方法名更具体(`GetCommissionWallet` vs `GetByResourceTypeAndID`),减少参数传递 +- 每个 Store 只处理自己的表,职责单一 +- 独立优化查询逻辑和缓存策略 + +**事务处理**: +- 所有需要事务的方法接收 `tx *gorm.DB` 参数 +- 调用方(Service 层)负责开启和提交事务 +- Store 层只执行数据库操作,不管理事务生命周期 + +--- + +### 决策 6:充值服务拆分 + +**当前**: +```go +internal/service/recharge/ +└─ service.go // 处理所有类型钱包的充值 +``` + +**新设计**: +```go +internal/service/agent_recharge/ +└─ service.go // 只处理代理钱包充值 + +internal/service/card_recharge/ +└─ service.go // 只处理卡钱包充值 +``` + +**理由**: +- 代理充值和卡充值的业务流程差异大: + - 代理充值:金额限制更高(100元起)、支持线下转账、需要审核 + - 卡充值:金额限制更低(1元起)、仅支持在线支付、自动到账 +- 拆分后代码更清晰,避免 if-else 分支判断 +- 独立部署和监控(如果未来微服务化) + +**Handler 层不变**: +- Handler 层根据用户类型调用不同的 Service +- 对外 API 接口保持不变 + +--- + +### 决策 7:Redis Key 按钱包类型隔离 + +**当前**: +```go +wallet:balance:{wallet_id} +wallet:lock:{wallet_id} +``` + +**新设计**: +```go +agent_wallet:balance:{shop_id}:{wallet_type} +agent_wallet:lock:{shop_id}:{wallet_type} + +card_wallet:balance:{resource_type}:{resource_id} +card_wallet:lock:{resource_type}:{resource_id} +``` + +**理由**: +- 从 Key 就能明确区分钱包类型,避免误操作 +- 独立的 Key 前缀便于监控和清理 +- 支持针对性的 TTL 策略(代理钱包缓存时间可能更长) + +**常量定义**(`pkg/constants/wallet.go`): +```go +func RedisAgentWalletBalanceKey(shopID uint, walletType string) string { + return fmt.Sprintf("agent_wallet:balance:%d:%s", shopID, walletType) +} + +func RedisCardWalletBalanceKey(resourceType string, resourceID uint) string { + return fmt.Sprintf("card_wallet:balance:%s:%d", resourceType, resourceID) +} +``` + +--- + +### 决策 8:索引策略独立优化 + +**代理钱包索引**(低频、大金额): +```sql +-- 主查询:按店铺查询钱包 +idx_agent_wallet_shop_type (shop_id, wallet_type, deleted_at) + +-- 次要查询:按状态过滤异常钱包 +idx_agent_wallet_status (status) + +-- 多租户过滤:GORM Callback 需要 +idx_agent_wallet_shop_tag (shop_id_tag) +``` + +**卡钱包索引**(高频、小金额): +```sql +-- 主查询:按资源查询钱包 +idx_card_wallet_resource (resource_type, resource_id, deleted_at) + +-- 次要查询:按状态过滤 +idx_card_wallet_status (status) + +-- 多租户过滤 +idx_card_wallet_shop_tag (shop_id_tag) +``` + +**交易记录索引**: + +代理钱包交易: +```sql +idx_agent_tx_wallet (agent_wallet_id, created_at) -- 按钱包查询交易历史 +idx_agent_tx_shop (shop_id, created_at) -- 按店铺汇总交易 +idx_agent_tx_ref (reference_type, reference_id) -- 按关联业务查询 +idx_agent_tx_type (transaction_type, created_at) -- 按交易类型统计 +``` + +卡钱包交易: +```sql +idx_card_tx_wallet (card_wallet_id, created_at) -- 按钱包查询 +idx_card_tx_resource (resource_type, resource_id, created_at) -- 按资源查询 +idx_card_tx_ref (reference_type, reference_id) -- 按订单查询 +idx_card_tx_type (transaction_type, created_at) -- 按类型统计 +``` + +--- + +### 决策 9:乐观锁继续使用 version 字段 + +**设计**: +```go +// 扣款时检查 version +result := tx.Model(&model.AgentWallet{}). + Where("id = ? AND balance >= ? AND version = ?", walletID, amount, currentVersion). + Updates(map[string]interface{}{ + "balance": gorm.Expr("balance - ?", amount), + "version": gorm.Expr("version + 1"), + }) + +if result.RowsAffected == 0 { + return errors.New(errors.CodeConcurrentConflict, "余额不足或并发冲突") +} +``` + +**理由**: +- 与现有钱包系统保持一致 +- 乐观锁适用于读多写少的场景(钱包查询频繁,扣款相对低频) +- 相比悲观锁(SELECT FOR UPDATE),性能更好 + +**并发处理**: +- Service 层捕获 `RowsAffected == 0` 错误,重试 1-2 次 +- 重试前重新读取最新余额和 version + +--- + +### 决策 10:不引入抽象层 + +**决策**:不创建统一的 `WalletService` 接口或抽象基类 + +**理由**: +- 代理钱包和卡钱包的业务场景差异太大,强行抽象反而增加复杂度 +- Go 推崇组合优于继承,过度抽象违背 Go 惯用法 +- 两种钱包的方法签名不同(`shop_id` vs `resource_type + resource_id`) + +**替代方案**: +- ❌ 创建 `WalletService` 接口,定义 `GetBalance(id WalletID)`:过度抽象,实际调用时仍需类型断言 + +**如果未来需要通用逻辑**: +- 可以提取为独立的 helper 函数,而不是接口 +- 例如:`pkg/wallet/helper.go` 中定义 `ValidateAmount(amount int64) error` + +--- + +## Risks / Trade-offs + +### 风险 1:代码重构引入 Bug + +**风险**:重构涉及多个模块(Model、Store、Service、Bootstrap),可能引入逻辑错误 + +**缓解措施**: +- 逐步重构,先完成 Model 和 Store,再重构 Service +- 每个模块完成后进行编译检查 +- 手动测试核心流程: + - 代理钱包:佣金发放、提现、余额查询 + - 卡钱包:订单支付、充值、余额查询 + - 边界场景:余额不足、并发扣款 + +--- + +### 风险 2:遗漏依赖点导致编译失败 + +**风险**:可能有隐藏的依赖点未被发现,删除旧 Model 后编译失败 + +**缓解措施**: +- 先创建新 Model 和 Store,保留旧代码 +- 逐步替换依赖点,每次替换后编译验证 +- 最后删除旧 Model 和 Store,确保编译通过 + +--- + +### 风险 3:性能回退 + +**风险**:新表的索引设计不当,导致查询性能下降 + +**缓解措施**: +- 索引设计参考现有表,保持覆盖常用查询 +- 分离后单表数据量减少,预期性能持平或提升 +- 如有性能问题,可以针对性优化索引(不影响其他钱包类型) + +--- + +### 权衡 1:完全分离交易记录表 vs 统一表 + +**选择**:完全分离 + +**代价**: +- 如果未来需要全局交易统计(跨钱包类型),需要 UNION 查询或创建视图 +- 增加表数量(6 张表 vs 3 张表) + +**收益**: +- 查询性能独立优化 +- 数据量隔离,避免单表过大 +- 代理钱包的对账查询不受卡钱包高频交易影响 + +**结论**:收益大于代价,全局交易统计并非高频需求,可以通过定时汇总解决 + +--- + +### 权衡 2:不引入抽象层 vs 统一接口 + +**选择**:不引入抽象层 + +**代价**: +- 如果未来需要第三种钱包类型(如企业钱包),需要独立实现,不能复用接口 +- 代码重复度略高(如余额校验逻辑) + +**收益**: +- 代码简单,符合 Go 惯用法 +- 编译期类型安全,避免接口断言 +- 每个钱包类型独立演进,互不影响 + +**结论**:当前场景不需要抽象层,如果未来真的需要,再重构也不迟(YAGNI 原则) + +--- + +### 权衡 3:充值服务拆分 vs 统一服务 + +**选择**:拆分为两个独立服务 + +**代价**: +- 增加 Service 文件数量 +- Handler 层需要根据用户类型调用不同 Service + +**收益**: +- 代码逻辑清晰,避免 if-else 分支 +- 业务规则独立(金额限制、支付方式、审核流程) +- 独立测试和监控 + +**结论**:拆分更符合单一职责原则,代价可接受 + +--- + +## Migration Plan + +### 实施步骤 + +由于当前处于开发阶段,无需数据迁移,直接重构: + +**阶段 1:数据库层(0.5 天)** +1. 编写迁移文件创建 6 张新表 +2. 本地执行迁移验证表结构 +3. 删除旧表的迁移文件(`tb_wallet`, `tb_wallet_transaction`, `tb_recharge_record`) + +**阶段 2:Model 和 Store 层(2 天)** +1. 创建新 Model:`agent_wallet.go`、`card_wallet.go` +2. 创建新 Store: + - `agent_wallet_store.go` + - `agent_wallet_transaction_store.go` + - `agent_recharge_store.go` + - `card_wallet_store.go` + - `card_wallet_transaction_store.go` + - `card_recharge_store.go` +3. 更新 `pkg/constants/wallet.go`(常量定义和 Redis Key 函数) +4. 保留旧 Model 和 Store(暂不删除) + +**阶段 3:Service 层重构(2 天)** +1. 更新 `internal/bootstrap/stores.go`(注册新 Store) +2. 重构 `commission_*` Service(改用 `AgentWalletStore`) +3. 重构 `order` Service(改用 `CardWalletStore`) +4. 拆分 `recharge` Service 为两个独立服务 +5. 更新 `internal/bootstrap/services.go`(依赖注入) +6. 编译验证,逐步替换依赖点 + +**阶段 4:清理和测试(1.5 天)** +1. 删除旧 Model:`internal/model/wallet.go` +2. 删除旧 Store:`wallet_store.go`、`wallet_transaction_store.go` +3. 编译检查,确保无引用残留 +4. 手动测试核心流程 +5. 数据一致性验证 + +**总计**:6 天 + +### 回滚策略 + +**如果重构失败**(仅适用于代码未合并到主分支前): +1. 恢复旧 Model 和 Store 代码 +2. 恢复旧的数据库表(从备份或重新运行旧迁移) +3. 恢复旧的依赖注入配置 + +**预防措施**: +- 在独立分支上进行重构 +- 每个阶段完成后提交代码 +- 保留旧代码直到新代码验证通过 + +--- + +## Open Questions + +### Q1:是否需要创建数据库视图方便全局统计? + +**场景**:未来可能需要全局交易统计(跨代理钱包和卡钱包) + +**选项**: +- **选项 A**:创建 VIEW `v_all_wallet_transactions`(UNION 两张交易表) +- **选项 B**:不创建视图,需要时在应用层 UNION 查询 +- **选项 C**:创建定时任务,汇总到独立的统计表 + +**建议**:等到有明确需求时再决定,当前不创建(YAGNI 原则) + +--- + +### Q2:监控指标如何实现? + +**场景**:提案中建议新增监控指标(`agent_wallet_error_rate`、`card_wallet_error_rate`) + +**问题**: +- 是否在本次重构中实现监控埋点? +- 还是只预留接口,后续专门做监控系统? + +**建议**:本次重构不包含监控实现,只确保代码层面可以区分钱包类型(便于未来埋点) + +--- + +### Q3:是否需要为 BaseModel 字段添加到新表? + +**当前**:旧表包含 `BaseModel`(`shop_id_tag`、`enterprise_id_tag` 等多租户字段) + +**问题**:新表是否需要保留这些字段? + +**建议**:保留,因为系统使用 GORM Callback 自动过滤多租户数据,这些字段是必需的 diff --git a/openspec/changes/archive/2026-02-25-separate-agent-card-wallets/proposal.md b/openspec/changes/archive/2026-02-25-separate-agent-card-wallets/proposal.md new file mode 100644 index 0000000..999c562 --- /dev/null +++ b/openspec/changes/archive/2026-02-25-separate-agent-card-wallets/proposal.md @@ -0,0 +1,153 @@ +# 钱包系统分离提案 + +## Why + +当前所有钱包类型(代理钱包、卡钱包)混在同一张表 `tb_wallet` 中,通过 `resource_type` 和 `wallet_type` 字段区分。业务方担心未来某个钱包业务的代码问题会影响其他钱包类型,特别是代理钱包作为核心资产需要最高等级的稳定性保障。在系统复杂度增长前,需要提前做预防性架构优化,将代理钱包和卡钱包在数据层和代码层完全隔离。 + +## What Changes + +### 数据库层变更 + +**新增表**: +- `tb_agent_wallet` - 代理钱包主表(店铺级别) +- `tb_agent_wallet_transaction` - 代理钱包交易记录表 +- `tb_agent_recharge_record` - 代理充值记录表 +- `tb_card_wallet` - 卡钱包主表(物联网卡和设备) +- `tb_card_wallet_transaction` - 卡钱包交易记录表 +- `tb_card_recharge_record` - 卡充值记录表 + +**删除表** - **BREAKING**: +- `tb_wallet` - 统一钱包表(由新的两张表替代) +- `tb_wallet_transaction` - 统一交易记录表(由新的两张表替代) +- `tb_recharge_record` - 统一充值记录表(由新的两张表替代) + +### 代码层变更 + +**新增 Model**: +- `internal/model/agent_wallet.go` - 代理钱包相关 Model(AgentWallet、AgentWalletTransaction、AgentRechargeRecord) +- `internal/model/card_wallet.go` - 卡钱包相关 Model(CardWallet、CardWalletTransaction、CardRechargeRecord) + +**删除 Model** - **BREAKING**: +- `internal/model/wallet.go` - 统一钱包 Model(由新的两个文件替代) + +**新增 Store**: +- `internal/store/postgres/agent_wallet_store.go` - 代理钱包数据访问层 +- `internal/store/postgres/agent_wallet_transaction_store.go` - 代理钱包交易记录访问层 +- `internal/store/postgres/agent_recharge_store.go` - 代理充值记录访问层 +- `internal/store/postgres/card_wallet_store.go` - 卡钱包数据访问层 +- `internal/store/postgres/card_wallet_transaction_store.go` - 卡钱包交易记录访问层 +- `internal/store/postgres/card_recharge_store.go` - 卡充值记录访问层 + +**删除 Store** - **BREAKING**: +- `internal/store/postgres/wallet_store.go` - 统一钱包 Store(由新的 Store 替代) +- `internal/store/postgres/wallet_transaction_store.go` - 统一交易记录 Store(由新的 Store 替代) + +**重构 Service**: +- `internal/service/commission_*` - 佣金相关服务改用 `AgentWalletStore` +- `internal/service/order` - 订单服务改用 `CardWalletStore` +- `internal/service/recharge` - 充值服务拆分为代理充值和卡充值两个独立服务 + +**更新常量定义**: +- `pkg/constants/wallet.go` - 按钱包类型隔离常量定义和 Redis Key 生成函数 + +**更新依赖注入**: +- `internal/bootstrap/stores.go` - 注册新的 Store 实例 +- `internal/bootstrap/services.go` - 更新 Service 依赖注入 + +### 监控和运维 + +**新增监控指标**(建议): +- `agent_wallet_transaction_count` - 代理钱包交易量 +- `agent_wallet_error_rate` - 代理钱包错误率 +- `card_wallet_transaction_count` - 卡钱包交易量 +- `card_wallet_error_rate` - 卡钱包错误率 + +**告警策略差异化**: +- 代理钱包错误率阈值更严格(建议 P0 级别告警) +- 卡钱包错误率阈值相对宽松(建议 P1 级别告警) + +## Capabilities + +### New Capabilities + +- `agent-wallet`: 代理钱包系统,提供店铺级别的主钱包和分佣钱包管理,支持充值、扣款、冻结、提现等操作 +- `card-wallet`: 卡钱包系统,提供物联网卡和设备级别的钱包管理,支持充值、套餐扣费、余额查询等操作 + +### Modified Capabilities + +- `wallet`: 需求变更 - 废弃统一钱包设计,拆分为 `agent-wallet` 和 `card-wallet` 两个完全独立的系统,要求在数据表、Model、Store、部分 Service 层面完全隔离 + +## Impact + +### 受影响的模块 + +| 模块类型 | 受影响组件 | 变更类型 | +|---------|-----------|---------| +| **数据库** | `tb_wallet`、`tb_wallet_transaction`、`tb_recharge_record` | 删除并替换为 6 张新表 | +| **Model** | `internal/model/wallet.go` | 删除并替换为 2 个新文件 | +| **Store** | `internal/store/postgres/wallet_*.go` | 删除并替换为 6 个新 Store | +| **Service** | `internal/service/commission_*` | 依赖注入改为 `AgentWalletStore` | +| **Service** | `internal/service/order` | 依赖注入改为 `CardWalletStore` | +| **Service** | `internal/service/recharge` | 拆分为两个独立服务 | +| **Bootstrap** | `internal/bootstrap/stores.go`、`services.go` | 更新依赖注入配置 | +| **常量** | `pkg/constants/wallet.go` | 按钱包类型重构常量定义 | +| **Redis Key** | 钱包相关缓存 Key | 按钱包类型隔离(`agent_wallet:*`、`card_wallet:*`) | + +### API 影响 + +**无 API 破坏性变更** - 所有对外 API 接口保持不变,仅内部实现重构 + +### 性能影响 + +**预期正向影响**: +- 代理钱包和卡钱包数据量独立,查询性能提升 +- 索引优化可针对不同钱包类型的查询模式独立调优 +- 减少单表数据量,降低锁竞争概率 + +### 部署影响 + +**当前处于开发阶段**: +- 无需数据迁移 +- 删除旧表,创建新表 +- 一次性部署代码变更 + +**如果未来生产环境部署**(预留方案): +- 需要制定数据迁移脚本 +- 需要停机窗口或在线双写迁移方案 + +### 风险评估 + +| 风险项 | 风险等级 | 缓解措施 | +|-------|---------|---------| +| 代码重构引入 Bug | 中 | 充分的手动测试,验证核心流程 | +| 遗漏依赖点导致编译失败 | 低 | 编译检查,逐步重构 | +| 数据表设计遗漏字段 | 低 | 参考现有 Model 设计,保持字段完整性 | +| 性能回退 | 低 | 索引设计参考现有表,预期性能持平或提升 | + +### 测试策略 + +**手动测试覆盖**: +- 代理钱包:佣金发放、提现、余额查询 +- 卡钱包:订单支付、充值、余额查询 +- 边界场景:余额不足、并发扣款、冻结/解冻 + +**数据一致性验证**: +- 验证代理钱包交易记录的 `balance_before` 和 `balance_after` 准确性 +- 验证卡钱包交易记录的余额变动准确性 +- 验证乐观锁(version 字段)在并发场景下的有效性 + +### 时间估算 + +**预估工作量**: +- 数据库迁移文件编写:0.5 天 +- Model 和 Store 重构:2 天 +- Service 层重构:2 天 +- Bootstrap 和常量更新:0.5 天 +- 手动测试和验证:1 天 +- **总计:6 天** + +### 依赖和前置条件 + +- ✅ 项目处于开发阶段,可直接重构 +- ✅ 无生产数据,无需迁移方案 +- ✅ 技术栈符合项目规范(GORM、PostgreSQL、Redis) diff --git a/openspec/changes/archive/2026-02-25-separate-agent-card-wallets/specs/agent-wallet/spec.md b/openspec/changes/archive/2026-02-25-separate-agent-card-wallets/specs/agent-wallet/spec.md new file mode 100644 index 0000000..0a3c416 --- /dev/null +++ b/openspec/changes/archive/2026-02-25-separate-agent-card-wallets/specs/agent-wallet/spec.md @@ -0,0 +1,318 @@ +# agent-wallet Specification + +## Purpose +代理钱包系统,提供店铺级别的主钱包和分佣钱包管理,支持充值、扣款、冻结、提现等操作。与卡钱包完全隔离,独立的数据表和代码实现。 + +## ADDED Requirements + +### Requirement: 代理钱包实体定义 + +系统 SHALL 定义代理钱包(AgentWallet)实体,管理店铺级别的钱包,支持主钱包和分佣钱包两种类型。 + +**核心概念**: +- **主钱包(main)**:店铺的主要资金账户,用于预充值和购买套餐 +- **分佣钱包(commission)**:店铺的佣金账户,用于接收分佣和提现 + +**实体字段**: +- `id`:钱包 ID(主键,BIGINT,自增) +- `shop_id`:店铺 ID(BIGINT,关联 tb_shop.id,唯一约束之一) +- `wallet_type`:钱包类型(VARCHAR(20),枚举值:"main"-主钱包 | "commission"-分佣钱包,唯一约束之一) +- `balance`:余额(BIGINT,单位:分,默认 0,≥ 0) +- `frozen_balance`:冻结余额(BIGINT,单位:分,默认 0,≥ 0) +- `currency`:币种(VARCHAR(10),默认 "CNY") +- `status`:钱包状态(INT,1-正常 2-冻结 3-关闭,默认 1) +- `version`:版本号(INT,默认 0,乐观锁字段,用于防止并发扣款) +- `shop_id_tag`:店铺 ID 标签(BIGINT,多租户过滤用,与 shop_id 相同) +- `enterprise_id_tag`:企业 ID 标签(BIGINT,多租户过滤用,可空) +- `created_at`:创建时间(TIMESTAMP,自动填充) +- `updated_at`:更新时间(TIMESTAMP,自动填充) +- `deleted_at`:删除时间(TIMESTAMP,可空,软删除) + +**唯一约束**:`(shop_id, wallet_type)` 在 `deleted_at IS NULL` 条件下唯一 + +**可用余额计算**:可用余额 = balance - frozen_balance + +**表名**:`tb_agent_wallet` + +#### Scenario: 创建店铺主钱包 + +- **WHEN** 店铺(ID 为 10)首次充值 +- **THEN** 系统创建代理钱包记录,`shop_id` 为 10,`wallet_type` 为 "main",`balance` 为 0,`status` 为 1(正常),`shop_id_tag` 为 10 + +#### Scenario: 创建店铺分佣钱包 + +- **WHEN** 店铺(ID 为 10)首次获得佣金 +- **THEN** 系统创建代理钱包记录,`shop_id` 为 10,`wallet_type` 为 "commission",`balance` 为 0,`status` 为 1(正常) + +#### Scenario: 计算可用余额 + +- **WHEN** 代理钱包余额为 100000 分(1000 元),冻结余额为 30000 分(300 元) +- **THEN** 系统计算可用余额为 70000 分(700 元) + +#### Scenario: 防止同一店铺创建重复钱包类型 + +- **WHEN** 店铺(ID 为 10)已有 wallet_type 为 "main" 的钱包,尝试再次创建 wallet_type 为 "main" 的钱包 +- **THEN** 系统拒绝创建,返回错误信息"该店铺已存在主钱包" + +--- + +### Requirement: 代理钱包交易记录 + +系统 SHALL 记录所有代理钱包余额变动,包括充值、扣款、退款、分佣、提现等操作,确保完整的审计追踪。 + +**实体字段**: +- `id`:交易记录 ID(主键,BIGINT,自增) +- `agent_wallet_id`:代理钱包 ID(BIGINT,关联 tb_agent_wallet.id) +- `shop_id`:店铺 ID(BIGINT,冗余字段,便于按店铺查询) +- `user_id`:操作人用户 ID(BIGINT,关联 tb_account.id) +- `transaction_type`:交易类型(VARCHAR(20),枚举值:"recharge"-充值 | "deduct"-扣款 | "refund"-退款 | "commission"-分佣 | "withdrawal"-提现) +- `amount`:变动金额(BIGINT,单位:分,正数为增加,负数为减少) +- `balance_before`:变动前余额(BIGINT,单位:分) +- `balance_after`:变动后余额(BIGINT,单位:分) +- `status`:交易状态(INT,1-成功 2-失败 3-处理中,默认 1) +- `reference_type`:关联业务类型(VARCHAR(50),如 "order" | "commission" | "withdrawal" | "topup",可空) +- `reference_id`:关联业务 ID(BIGINT,可空) +- `remark`:备注(TEXT,可空) +- `metadata`:扩展信息(JSONB,如手续费、支付方式等,可空) +- `creator`:创建人 ID(BIGINT) +- `shop_id_tag`:店铺 ID 标签(BIGINT,多租户过滤用) +- `enterprise_id_tag`:企业 ID 标签(BIGINT,多租户过滤用,可空) +- `created_at`:创建时间(TIMESTAMP,自动填充) +- `updated_at`:更新时间(TIMESTAMP,自动填充) +- `deleted_at`:删除时间(TIMESTAMP,可空,软删除) + +**表名**:`tb_agent_wallet_transaction` + +**索引**: +- `idx_agent_tx_wallet (agent_wallet_id, created_at)`:按钱包查询交易历史 +- `idx_agent_tx_shop (shop_id, created_at)`:按店铺汇总交易 +- `idx_agent_tx_ref (reference_type, reference_id)`:按关联业务查询 +- `idx_agent_tx_type (transaction_type, created_at)`:按交易类型统计 + +#### Scenario: 充值创建交易记录 + +- **WHEN** 店铺(ID 为 10)主钱包充值 100000 分(1000 元) +- **THEN** 系统创建代理钱包交易记录,`transaction_type` 为 "recharge",`amount` 为 100000,`balance_before` 为 0,`balance_after` 为 100000,`status` 为 1(成功),`shop_id` 为 10 + +#### Scenario: 分佣发放创建交易记录 + +- **WHEN** 店铺(ID 为 10)的分佣钱包收到佣金 50000 分(500 元) +- **THEN** 系统创建代理钱包交易记录,`transaction_type` 为 "commission",`amount` 为 50000,`balance_before` 为 200000,`balance_after` 为 250000,`reference_type` 为 "commission",`reference_id` 为分佣记录 ID + +#### Scenario: 提现创建交易记录 + +- **WHEN** 店铺(ID 为 10)从分佣钱包提现 30000 分(300 元) +- **THEN** 系统创建代理钱包交易记录,`transaction_type` 为 "withdrawal",`amount` 为 -30000,`balance_before` 为 250000,`balance_after` 为 220000,`reference_type` 为 "withdrawal",`reference_id` 为提现申请 ID + +#### Scenario: 按店铺查询交易历史 + +- **WHEN** 管理员查询店铺(ID 为 10)的所有钱包交易记录,按时间倒序 +- **THEN** 系统使用索引 `idx_agent_tx_shop` 查询,返回该店铺的主钱包和分佣钱包的所有交易记录,按 `created_at` 降序排序 + +--- + +### Requirement: 代理充值记录管理 + +系统 SHALL 记录所有代理充值操作,包括充值订单号、金额、支付方式、支付状态等信息。 + +**实体字段**: +- `id`:充值记录 ID(主键,BIGINT,自增) +- `user_id`:操作人用户 ID(BIGINT,关联 tb_account.id) +- `agent_wallet_id`:代理钱包 ID(BIGINT,关联 tb_agent_wallet.id) +- `shop_id`:店铺 ID(BIGINT,冗余字段,便于查询) +- `recharge_no`:充值订单号(VARCHAR(50),唯一,格式:ARCH+时间戳+随机数) +- `amount`:充值金额(BIGINT,单位:分,≥ 10000) +- `payment_method`:支付方式(VARCHAR(20),枚举值:"alipay"-支付宝 | "wechat"-微信 | "bank"-银行转账 | "offline"-线下) +- `payment_channel`:支付渠道(VARCHAR(50),可空) +- `payment_transaction_id`:第三方支付交易号(VARCHAR(100),可空) +- `status`:充值状态(INT,1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款,默认 1) +- `paid_at`:支付时间(TIMESTAMP,可空) +- `completed_at`:完成时间(TIMESTAMP,可空) +- `shop_id_tag`:店铺 ID 标签(BIGINT,多租户过滤用) +- `enterprise_id_tag`:企业 ID 标签(BIGINT,多租户过滤用,可空) +- `created_at`:创建时间(TIMESTAMP,自动填充) +- `updated_at`:更新时间(TIMESTAMP,自动填充) +- `deleted_at`:删除时间(TIMESTAMP,可空,软删除) + +**表名**:`tb_agent_recharge_record` + +**充值金额限制**: +- 最小充值金额:10000 分(100 元) +- 最大充值金额:100000000 分(1000000 元) + +**索引**: +- `idx_agent_recharge_user (user_id, created_at)`:按用户查询充值记录 +- `idx_agent_recharge_shop (shop_id, created_at)`:按店铺查询充值记录 +- `idx_agent_recharge_status (status, created_at)`:按状态过滤充值记录 +- `idx_agent_recharge_no (recharge_no)`:按订单号查询 + +#### Scenario: 创建代理充值订单 + +- **WHEN** 店铺(ID 为 10)的管理员发起充值 100000 分(1000 元),选择支付宝支付 +- **THEN** 系统创建代理充值记录,生成唯一的 `recharge_no`(如 "ARCH20260224123456789012"),`amount` 为 100000,`payment_method` 为 "alipay",`status` 为 1(待支付),`shop_id` 为 10 + +#### Scenario: 充值金额低于最小限制 + +- **WHEN** 店铺管理员尝试充值 5000 分(50 元) +- **THEN** 系统拒绝创建充值订单,返回错误信息"充值金额不能低于 100 元" + +#### Scenario: 充值支付完成 + +- **WHEN** 店铺管理员完成支付宝支付 +- **THEN** 系统将充值记录状态从 1(待支付)变更为 2(已支付),记录 `paid_at` 时间和 `payment_transaction_id` + +#### Scenario: 充值到账 + +- **WHEN** 充值记录状态为 2(已支付),系统处理充值到账 +- **THEN** 系统将代理钱包余额增加 100000 分,创建代理钱包交易记录,将充值记录状态变更为 3(已完成),记录 `completed_at` 时间 + +--- + +### Requirement: 代理钱包余额操作 + +系统 SHALL 支持代理钱包余额的充值、扣款、退款、冻结、解冻等操作,使用乐观锁防止并发问题。 + +**操作类型**: +- **充值**:增加钱包余额 +- **扣款**:减少钱包余额(如购买套餐) +- **退款**:增加钱包余额(如订单退款) +- **冻结**:将部分余额转为冻结状态(如提现申请中) +- **解冻**:将冻结余额转回可用余额(如提现取消) + +**并发控制**: +- 使用 `version` 字段实现乐观锁 +- 每次更新余额时,检查 `version` 是否匹配 +- 如果 `version` 不匹配,说明有并发更新,操作失败并重试 + +**操作约束**: +- 扣款时,检查可用余额(balance - frozen_balance)是否充足 +- 冻结时,检查可用余额是否充足 +- 所有余额变动必须创建交易记录 + +#### Scenario: 代理钱包充值 + +- **WHEN** 店铺主钱包当前余额为 100000 分,充值 50000 分 +- **THEN** 系统将钱包余额更新为 150000 分,`version` 从 1 变更为 2,创建交易记录(`transaction_type` 为 "recharge",`amount` 为 50000) + +#### Scenario: 代理钱包扣款 + +- **WHEN** 店铺主钱包当前余额为 150000 分,购买套餐扣款 30000 分 +- **THEN** 系统检查可用余额(150000 - 0 = 150000)≥ 30000,将钱包余额更新为 120000 分,`version` 从 2 变更为 3,创建交易记录(`transaction_type` 为 "deduct",`amount` 为 -30000) + +#### Scenario: 余额不足扣款失败 + +- **WHEN** 店铺主钱包当前余额为 20000 分,购买套餐需要扣款 30000 分 +- **THEN** 系统检查可用余额(20000 - 0 = 20000)< 30000,拒绝扣款,返回错误信息"余额不足" + +#### Scenario: 并发扣款乐观锁生效 + +- **WHEN** 店铺主钱包当前余额为 100000 分,version 为 1,两个并发请求同时扣款 30000 分和 50000 分 +- **THEN** 第一个请求成功,余额变为 70000 分,version 变为 2;第二个请求因 version 不匹配失败,需重新读取最新余额(70000 分)和 version(2)后重试 + +#### Scenario: 冻结余额用于提现 + +- **WHEN** 店铺分佣钱包余额为 100000 分,申请提现 30000 分 +- **THEN** 系统将钱包的 `frozen_balance` 增加 30000 分,可用余额减少 30000 分,`version` 增加 1 + +#### Scenario: 解冻余额(提现取消) + +- **WHEN** 店铺分佣钱包冻结余额为 30000 分,用户取消提现申请 +- **THEN** 系统将钱包的 `frozen_balance` 减少 30000 分,可用余额增加 30000 分,`version` 增加 1 + +--- + +### Requirement: 代理钱包数据校验 + +系统 SHALL 对代理钱包数据进行校验,确保数据完整性和一致性。 + +**校验规则**: +- `shop_id`:必填,≥ 1,必须是有效的店铺 ID +- `wallet_type`:必填,枚举值 "main" | "commission" +- `balance`:必填,≥ 0 +- `frozen_balance`:必填,≥ 0,≤ balance +- `currency`:必填,长度 1-10 字符,默认 "CNY" +- `status`:必填,枚举值 1-3 +- `version`:必填,≥ 0 + +#### Scenario: 创建钱包时 shop_id 无效 + +- **WHEN** 创建代理钱包,`shop_id` 为 0 +- **THEN** 系统拒绝创建,返回错误信息"店铺 ID 无效,必须 ≥ 1" + +#### Scenario: 创建钱包时 wallet_type 无效 + +- **WHEN** 创建代理钱包,`wallet_type` 为 "invalid" +- **THEN** 系统拒绝创建,返回错误信息"钱包类型无效,必须是 main 或 commission" + +#### Scenario: 冻结余额超过总余额 + +- **WHEN** 代理钱包余额为 100000 分,尝试冻结 150000 分 +- **THEN** 系统拒绝操作,返回错误信息"冻结余额不能超过总余额" + +#### Scenario: 余额为负数 + +- **WHEN** 尝试将代理钱包余额设置为 -10000 分 +- **THEN** 系统拒绝操作,返回错误信息"余额不能为负数" + +--- + +### Requirement: 代理钱包归属店铺规则 + +系统 SHALL 确保代理钱包归属店铺,不支持转手,店铺的多个员工账号共享钱包。 + +**归属规则**: +- 代理钱包归属店铺(shop_id),不归属个人用户 +- 同一店铺的所有员工账号共享该店铺的主钱包和分佣钱包 +- 店铺钱包不支持转手,归属关系固定 + +#### Scenario: 店铺的多个员工账号共享钱包 + +- **WHEN** 店铺(ID 为 10)有 3 个员工账号(账号 ID 为 201、202、203),店铺主钱包余额为 500000 分 +- **THEN** 3 个员工账号登录后查询店铺主钱包,余额都是 500000 分,可以共享使用 + +#### Scenario: 员工账号只能访问自己店铺的钱包 + +- **WHEN** 员工账号(ID 为 201,归属店铺 10)尝试访问店铺 20 的钱包 +- **THEN** 系统拒绝访问,返回错误信息"无权限访问该店铺的钱包" + +--- + +### Requirement: 代理钱包 Redis 缓存策略 + +系统 SHALL 使用 Redis 缓存代理钱包余额,提升查询性能,并使用 Redis 分布式锁防止并发操作冲突。 + +**缓存 Key 定义**: +- 余额缓存:`agent_wallet:balance:{shop_id}:{wallet_type}` +- 分布式锁:`agent_wallet:lock:{shop_id}:{wallet_type}` + +**缓存 TTL**: +- 余额缓存:300 秒(5 分钟) +- 分布式锁:10 秒 + +**缓存更新策略**: +- 余额变动时,删除缓存(Cache-Aside 模式) +- 下次查询时重新加载到缓存 + +**常量定义位置**:`pkg/constants/wallet.go` + +```go +func RedisAgentWalletBalanceKey(shopID uint, walletType string) string +func RedisAgentWalletLockKey(shopID uint, walletType string) string +``` + +#### Scenario: 查询余额时使用缓存 + +- **WHEN** 查询店铺(ID 为 10)主钱包余额,缓存中存在该余额 +- **THEN** 系统直接从 Redis 返回余额,不查询数据库 + +#### Scenario: 余额变动后删除缓存 + +- **WHEN** 店铺(ID 为 10)主钱包余额增加 50000 分 +- **THEN** 系统删除 Redis 缓存 Key `agent_wallet:balance:10:main`,下次查询时重新加载 + +#### Scenario: 使用分布式锁防止并发冻结 + +- **WHEN** 两个并发请求同时尝试冻结店铺(ID 为 10)主钱包的余额 +- **THEN** 系统使用 Redis 分布式锁 `agent_wallet:lock:10:main`,第一个请求获得锁,第二个请求等待或失败 + +--- diff --git a/openspec/changes/archive/2026-02-25-separate-agent-card-wallets/specs/card-wallet/spec.md b/openspec/changes/archive/2026-02-25-separate-agent-card-wallets/specs/card-wallet/spec.md new file mode 100644 index 0000000..8a9542b --- /dev/null +++ b/openspec/changes/archive/2026-02-25-separate-agent-card-wallets/specs/card-wallet/spec.md @@ -0,0 +1,333 @@ +# card-wallet Specification + +## Purpose +卡钱包系统,提供物联网卡和设备级别的钱包管理,支持充值、套餐扣费、余额查询等操作。与代理钱包完全隔离,独立的数据表和代码实现。 + +## ADDED Requirements + +### Requirement: 卡钱包实体定义 + +系统 SHALL 定义卡钱包(CardWallet)实体,管理物联网卡和设备级别的钱包,支持资源转手场景。 + +**核心概念**: +- **物联网卡钱包**:归属单张物联网卡,卡转手时钱包跟着卡走 +- **设备钱包**:归属设备(含1-4张卡),设备的多张卡共享钱包,设备转手时钱包跟着设备走 + +**实体字段**: +- `id`:钱包 ID(主键,BIGINT,自增) +- `resource_type`:资源类型(VARCHAR(20),枚举值:"iot_card"-物联网卡 | "device"-设备,唯一约束之一) +- `resource_id`:资源 ID(BIGINT,关联 tb_iot_card.id 或 tb_device.id,唯一约束之一) +- `balance`:余额(BIGINT,单位:分,默认 0,≥ 0) +- `frozen_balance`:冻结余额(BIGINT,单位:分,默认 0,≥ 0) +- `currency`:币种(VARCHAR(10),默认 "CNY") +- `status`:钱包状态(INT,1-正常 2-冻结 3-关闭,默认 1) +- `version`:版本号(INT,默认 0,乐观锁字段,用于防止并发扣款) +- `shop_id_tag`:店铺 ID 标签(BIGINT,多租户过滤用) +- `enterprise_id_tag`:企业 ID 标签(BIGINT,多租户过滤用,可空) +- `created_at`:创建时间(TIMESTAMP,自动填充) +- `updated_at`:更新时间(TIMESTAMP,自动填充) +- `deleted_at`:删除时间(TIMESTAMP,可空,软删除) + +**唯一约束**:`(resource_type, resource_id)` 在 `deleted_at IS NULL` 条件下唯一 + +**可用余额计算**:可用余额 = balance - frozen_balance + +**表名**:`tb_card_wallet` + +#### Scenario: 创建物联网卡钱包 + +- **WHEN** 个人客户通过 ICCID "8986001234567890" 登录(首次登录),为该卡充值 +- **THEN** 系统创建卡钱包记录,`resource_type` 为 "iot_card",`resource_id` 为卡 ID,`balance` 为 0,`status` 为 1(正常) + +#### Scenario: 创建设备钱包 + +- **WHEN** 个人客户通过设备号 "DEV-001" 登录(首次登录),该设备绑定 3 张卡,为设备充值 +- **THEN** 系统创建卡钱包记录,`resource_type` 为 "device",`resource_id` 为设备 ID,设备的 3 张卡共享该钱包 + +#### Scenario: 计算可用余额 + +- **WHEN** 卡钱包余额为 10000 分(100 元),冻结余额为 3000 分(30 元) +- **THEN** 系统计算可用余额为 7000 分(70 元) + +#### Scenario: 防止同一资源创建重复钱包 + +- **WHEN** 物联网卡(ID 为 100)已有钱包,尝试再次创建钱包 +- **THEN** 系统拒绝创建,返回错误信息"该资源已存在钱包" + +--- + +### Requirement: 卡钱包交易记录 + +系统 SHALL 记录所有卡钱包余额变动,包括充值、套餐扣费、退款等操作,确保完整的审计追踪。 + +**实体字段**: +- `id`:交易记录 ID(主键,BIGINT,自增) +- `card_wallet_id`:卡钱包 ID(BIGINT,关联 tb_card_wallet.id) +- `resource_type`:资源类型(VARCHAR(20),冗余字段,便于查询) +- `resource_id`:资源 ID(BIGINT,冗余字段,便于查询) +- `user_id`:操作人用户 ID(BIGINT,关联 tb_account.id) +- `transaction_type`:交易类型(VARCHAR(20),枚举值:"recharge"-充值 | "deduct"-扣款 | "refund"-退款) +- `amount`:变动金额(BIGINT,单位:分,正数为增加,负数为减少) +- `balance_before`:变动前余额(BIGINT,单位:分) +- `balance_after`:变动后余额(BIGINT,单位:分) +- `status`:交易状态(INT,1-成功 2-失败 3-处理中,默认 1) +- `reference_type`:关联业务类型(VARCHAR(50),如 "order" | "topup",可空) +- `reference_id`:关联业务 ID(BIGINT,可空) +- `remark`:备注(TEXT,可空) +- `metadata`:扩展信息(JSONB,如套餐信息、支付方式等,可空) +- `creator`:创建人 ID(BIGINT) +- `shop_id_tag`:店铺 ID 标签(BIGINT,多租户过滤用) +- `enterprise_id_tag`:企业 ID 标签(BIGINT,多租户过滤用,可空) +- `created_at`:创建时间(TIMESTAMP,自动填充) +- `updated_at`:更新时间(TIMESTAMP,自动填充) +- `deleted_at`:删除时间(TIMESTAMP,可空,软删除) + +**表名**:`tb_card_wallet_transaction` + +**索引**: +- `idx_card_tx_wallet (card_wallet_id, created_at)`:按钱包查询交易历史 +- `idx_card_tx_resource (resource_type, resource_id, created_at)`:按资源查询交易 +- `idx_card_tx_ref (reference_type, reference_id)`:按关联业务查询 +- `idx_card_tx_type (transaction_type, created_at)`:按交易类型统计 + +#### Scenario: 充值创建交易记录 + +- **WHEN** 物联网卡(ICCID "8986001234567890")充值 10000 分(100 元) +- **THEN** 系统创建卡钱包交易记录,`transaction_type` 为 "recharge",`amount` 为 10000,`balance_before` 为 0,`balance_after` 为 10000,`status` 为 1(成功) + +#### Scenario: 套餐扣费创建交易记录 + +- **WHEN** 物联网卡(ICCID "8986001234567890")购买套餐,钱包支付扣款 3000 分(30 元) +- **THEN** 系统创建卡钱包交易记录,`transaction_type` 为 "deduct",`amount` 为 -3000,`balance_before` 为 10000,`balance_after` 为 7000,`reference_type` 为 "order",`reference_id` 为订单 ID + +#### Scenario: 订单退款创建交易记录 + +- **WHEN** 物联网卡订单(ID 为 1001)退款 3000 分(30 元) +- **THEN** 系统创建卡钱包交易记录,`transaction_type` 为 "refund",`amount` 为 3000,`balance_before` 为 7000,`balance_after` 为 10000,`reference_type` 为 "order",`reference_id` 为 1001 + +#### Scenario: 按资源查询交易历史 + +- **WHEN** 个人客户查询物联网卡(ICCID "8986001234567890")的交易历史 +- **THEN** 系统使用索引 `idx_card_tx_resource` 查询,返回该卡的所有钱包交易记录,按 `created_at` 降序排序 + +--- + +### Requirement: 卡充值记录管理 + +系统 SHALL 记录所有卡充值操作,包括充值订单号、金额、支付方式、支付状态等信息。 + +**实体字段**: +- `id`:充值记录 ID(主键,BIGINT,自增) +- `user_id`:操作人用户 ID(BIGINT,关联 tb_account.id) +- `card_wallet_id`:卡钱包 ID(BIGINT,关联 tb_card_wallet.id) +- `resource_type`:资源类型(VARCHAR(20),冗余字段) +- `resource_id`:资源 ID(BIGINT,冗余字段) +- `recharge_no`:充值订单号(VARCHAR(50),唯一,格式:CRCH+时间戳+随机数) +- `amount`:充值金额(BIGINT,单位:分,≥ 100) +- `payment_method`:支付方式(VARCHAR(20),枚举值:"alipay"-支付宝 | "wechat"-微信) +- `payment_channel`:支付渠道(VARCHAR(50),可空) +- `payment_transaction_id`:第三方支付交易号(VARCHAR(100),可空) +- `status`:充值状态(INT,1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款,默认 1) +- `paid_at`:支付时间(TIMESTAMP,可空) +- `completed_at`:完成时间(TIMESTAMP,可空) +- `shop_id_tag`:店铺 ID 标签(BIGINT,多租户过滤用) +- `enterprise_id_tag`:企业 ID 标签(BIGINT,多租户过滤用,可空) +- `created_at`:创建时间(TIMESTAMP,自动填充) +- `updated_at`:更新时间(TIMESTAMP,自动填充) +- `deleted_at`:删除时间(TIMESTAMP,可空,软删除) + +**表名**:`tb_card_recharge_record` + +**充值金额限制**: +- 最小充值金额:100 分(1 元) +- 最大充值金额:10000000 分(100000 元) + +**索引**: +- `idx_card_recharge_user (user_id, created_at)`:按用户查询充值记录 +- `idx_card_recharge_resource (resource_type, resource_id, created_at)`:按资源查询充值记录 +- `idx_card_recharge_status (status, created_at)`:按状态过滤充值记录 +- `idx_card_recharge_no (recharge_no)`:按订单号查询 + +#### Scenario: 创建卡充值订单 + +- **WHEN** 个人客户为物联网卡(ICCID "8986001234567890")发起充值 10000 分(100 元),选择微信支付 +- **THEN** 系统创建卡充值记录,生成唯一的 `recharge_no`(如 "CRCH20260224123456789012"),`amount` 为 10000,`payment_method` 为 "wechat",`status` 为 1(待支付),`resource_type` 为 "iot_card" + +#### Scenario: 充值金额低于最小限制 + +- **WHEN** 个人客户尝试充值 50 分(0.5 元) +- **THEN** 系统拒绝创建充值订单,返回错误信息"充值金额不能低于 1 元" + +#### Scenario: 充值支付完成 + +- **WHEN** 个人客户完成微信支付 +- **THEN** 系统将充值记录状态从 1(待支付)变更为 2(已支付),记录 `paid_at` 时间和 `payment_transaction_id` + +#### Scenario: 充值到账 + +- **WHEN** 充值记录状态为 2(已支付),系统处理充值到账 +- **THEN** 系统将卡钱包余额增加 10000 分,创建卡钱包交易记录,将充值记录状态变更为 3(已完成),记录 `completed_at` 时间 + +--- + +### Requirement: 卡钱包余额操作 + +系统 SHALL 支持卡钱包余额的充值、扣款、退款等操作,使用乐观锁防止并发问题。 + +**操作类型**: +- **充值**:增加钱包余额 +- **扣款**:减少钱包余额(如购买套餐) +- **退款**:增加钱包余额(如订单退款) + +**并发控制**: +- 使用 `version` 字段实现乐观锁 +- 每次更新余额时,检查 `version` 是否匹配 +- 如果 `version` 不匹配,说明有并发更新,操作失败并重试 + +**操作约束**: +- 扣款时,检查可用余额(balance - frozen_balance)是否充足 +- 所有余额变动必须创建交易记录 + +#### Scenario: 卡钱包充值 + +- **WHEN** 卡钱包当前余额为 10000 分,充值 5000 分 +- **THEN** 系统将钱包余额更新为 15000 分,`version` 从 1 变更为 2,创建交易记录(`transaction_type` 为 "recharge",`amount` 为 5000) + +#### Scenario: 卡钱包扣款 + +- **WHEN** 卡钱包当前余额为 15000 分,购买套餐扣款 3000 分 +- **THEN** 系统检查可用余额(15000 - 0 = 15000)≥ 3000,将钱包余额更新为 12000 分,`version` 从 2 变更为 3,创建交易记录(`transaction_type` 为 "deduct",`amount` 为 -3000) + +#### Scenario: 余额不足扣款失败 + +- **WHEN** 卡钱包当前余额为 2000 分,购买套餐需要扣款 3000 分 +- **THEN** 系统检查可用余额(2000 - 0 = 2000)< 3000,拒绝扣款,返回错误信息"余额不足" + +#### Scenario: 并发扣款乐观锁生效 + +- **WHEN** 卡钱包当前余额为 10000 分,version 为 1,两个并发请求同时扣款 3000 分和 5000 分 +- **THEN** 第一个请求成功,余额变为 7000 分,version 变为 2;第二个请求因 version 不匹配失败,需重新读取最新余额(7000 分)和 version(2)后重试 + +#### Scenario: 订单退款 + +- **WHEN** 卡钱包当前余额为 7000 分,订单退款 3000 分 +- **THEN** 系统将钱包余额更新为 10000 分,`version` 增加 1,创建交易记录(`transaction_type` 为 "refund",`amount` 为 3000) + +--- + +### Requirement: 卡钱包数据校验 + +系统 SHALL 对卡钱包数据进行校验,确保数据完整性和一致性。 + +**校验规则**: +- `resource_type`:必填,枚举值 "iot_card" | "device" +- `resource_id`:必填,≥ 1,必须是有效的资源 ID +- `balance`:必填,≥ 0 +- `frozen_balance`:必填,≥ 0,≤ balance +- `currency`:必填,长度 1-10 字符,默认 "CNY" +- `status`:必填,枚举值 1-3 +- `version`:必填,≥ 0 + +#### Scenario: 创建钱包时 resource_type 无效 + +- **WHEN** 创建卡钱包,`resource_type` 为 "invalid" +- **THEN** 系统拒绝创建,返回错误信息"资源类型无效,必须是 iot_card 或 device" + +#### Scenario: 创建钱包时 resource_id 无效 + +- **WHEN** 创建卡钱包,`resource_type` 为 "iot_card",`resource_id` 为 0 +- **THEN** 系统拒绝创建,返回错误信息"资源 ID 无效,必须 ≥ 1" + +#### Scenario: 冻结余额超过总余额 + +- **WHEN** 卡钱包余额为 10000 分,尝试冻结 15000 分 +- **THEN** 系统拒绝操作,返回错误信息"冻结余额不能超过总余额" + +#### Scenario: 余额为负数 + +- **WHEN** 尝试将卡钱包余额设置为 -10000 分 +- **THEN** 系统拒绝操作,返回错误信息"余额不能为负数" + +--- + +### Requirement: 卡钱包归属资源转手规则 + +系统 SHALL 支持卡钱包随资源(物联网卡、设备)转手,新用户登录后可以看到钱包余额。 + +**归属规则**: + +| 资源类型 | ResourceType | 适用场景 | 转手规则 | +|---------|-------------|---------|---------| +| 物联网卡 | iot_card | 个人客户购买单卡 | 钱包归属卡,卡转手时钱包跟着卡走 | +| 设备 | device | 个人客户购买设备(含1-4张卡) | 钱包归属设备,设备的多张卡共享钱包,设备转手时钱包跟着设备走 | + +**资源转手场景**: +- 物联网卡转手:新用户通过 ICCID 登录后可以看到卡的钱包余额 +- 设备转手:新用户通过设备号登录后可以看到设备的钱包余额(包含绑定的所有卡) + +#### Scenario: 个人客户购买单卡并充值 + +- **WHEN** 个人客户通过 ICCID "8986001234567890" 登录(首次登录),为该卡充值 10000 分 +- **THEN** 系统创建卡钱包记录,`resource_type` 为 "iot_card",`resource_id` 为卡 ID,`balance` 为 10000 + +#### Scenario: 个人客户购买设备并充值 + +- **WHEN** 个人客户通过设备号 "DEV-001" 登录(首次登录),该设备绑定 3 张卡,为设备充值 20000 分 +- **THEN** 系统创建卡钱包记录,`resource_type` 为 "device",`resource_id` 为设备 ID,设备的 3 张卡共享该钱包,`balance` 为 20000 + +#### Scenario: 卡转手后新用户查询余额 + +- **WHEN** 个人客户 A(微信 OpenID 为 "wx_a")的卡(ICCID 为 "8986001234567890")转手给个人客户 B(微信 OpenID 为 "wx_b"),卡钱包余额为 5000 分 +- **THEN** 个人客户 B 通过 ICCID "8986001234567890" 登录后查询钱包,余额为 5000 分,可以继续使用 + +#### Scenario: 设备转手后新用户查询余额 + +- **WHEN** 个人客户 A 的设备(设备号 "DEV-001",绑定 3 张卡)转手给个人客户 B,设备钱包余额为 15000 分 +- **THEN** 个人客户 B 通过设备号 "DEV-001" 登录后查询钱包,余额为 15000 分,3 张卡共享该余额 + +#### Scenario: 设备的多张卡共享钱包 + +- **WHEN** 设备(设备号 "DEV-001")绑定 3 张卡(ICCID 为 "111"、"222"、"333"),设备钱包余额为 20000 分 +- **THEN** 用户通过任意一张卡的 ICCID 登录,查询钱包余额都是 20000 分(设备级别钱包) + +--- + +### Requirement: 卡钱包 Redis 缓存策略 + +系统 SHALL 使用 Redis 缓存卡钱包余额,提升查询性能,并使用 Redis 分布式锁防止并发操作冲突。 + +**缓存 Key 定义**: +- 余额缓存:`card_wallet:balance:{resource_type}:{resource_id}` +- 分布式锁:`card_wallet:lock:{resource_type}:{resource_id}` + +**缓存 TTL**: +- 余额缓存:180 秒(3 分钟) +- 分布式锁:10 秒 + +**缓存更新策略**: +- 余额变动时,删除缓存(Cache-Aside 模式) +- 下次查询时重新加载到缓存 + +**常量定义位置**:`pkg/constants/wallet.go` + +```go +func RedisCardWalletBalanceKey(resourceType string, resourceID uint) string +func RedisCardWalletLockKey(resourceType string, resourceID uint) string +``` + +#### Scenario: 查询余额时使用缓存 + +- **WHEN** 查询物联网卡(ICCID "8986001234567890")钱包余额,缓存中存在该余额 +- **THEN** 系统直接从 Redis 返回余额,不查询数据库 + +#### Scenario: 余额变动后删除缓存 + +- **WHEN** 物联网卡(ID 为 100)钱包余额增加 5000 分 +- **THEN** 系统删除 Redis 缓存 Key `card_wallet:balance:iot_card:100`,下次查询时重新加载 + +#### Scenario: 使用分布式锁防止并发扣款 + +- **WHEN** 两个并发请求同时尝试从物联网卡(ID 为 100)钱包扣款 +- **THEN** 系统使用 Redis 分布式锁 `card_wallet:lock:iot_card:100`,第一个请求获得锁,第二个请求等待或失败 + +--- diff --git a/openspec/changes/archive/2026-02-25-separate-agent-card-wallets/specs/wallet/spec.md b/openspec/changes/archive/2026-02-25-separate-agent-card-wallets/specs/wallet/spec.md new file mode 100644 index 0000000..01d02c9 --- /dev/null +++ b/openspec/changes/archive/2026-02-25-separate-agent-card-wallets/specs/wallet/spec.md @@ -0,0 +1,78 @@ +# wallet Specification (Delta) + +## Purpose +钱包系统架构变更:废弃统一钱包设计,拆分为 `agent-wallet`(代理钱包)和 `card-wallet`(卡钱包)两个完全独立的系统,实现数据层和代码层的完全隔离。 + +## REMOVED Requirements + +### Requirement: 钱包实体定义 + +**Reason**: 废弃统一钱包设计,拆分为代理钱包(AgentWallet)和卡钱包(CardWallet)两个独立实体,使用独立的数据表。 + +**Migration**: +- 代理钱包(shop 类型)迁移到 `tb_agent_wallet` 表,参见 `agent-wallet` spec +- 卡钱包(iot_card 和 device 类型)迁移到 `tb_card_wallet` 表,参见 `card-wallet` spec +- 代码层使用新的 Model:`model.AgentWallet` 和 `model.CardWallet` +- 代码层使用新的 Store:`AgentWalletStore` 和 `CardWalletStore` + +--- + +### Requirement: 钱包明细记录 + +**Reason**: 废弃统一交易记录表,拆分为代理钱包交易记录(tb_agent_wallet_transaction)和卡钱包交易记录(tb_card_wallet_transaction)两个独立表。 + +**Migration**: +- 代理钱包交易记录迁移到 `tb_agent_wallet_transaction` 表,参见 `agent-wallet` spec +- 卡钱包交易记录迁移到 `tb_card_wallet_transaction` 表,参见 `card-wallet` spec +- 代码层使用新的 Model:`model.AgentWalletTransaction` 和 `model.CardWalletTransaction` + +--- + +### Requirement: 充值记录管理 + +**Reason**: 废弃统一充值记录表,拆分为代理充值记录(tb_agent_recharge_record)和卡充值记录(tb_card_recharge_record)两个独立表。 + +**Migration**: +- 代理充值记录迁移到 `tb_agent_recharge_record` 表,参见 `agent-wallet` spec +- 卡充值记录迁移到 `tb_card_recharge_record` 表,参见 `card-wallet` spec +- 代码层使用新的 Model:`model.AgentRechargeRecord` 和 `model.CardRechargeRecord` +- 充值服务拆分为 `agent_recharge` 和 `card_recharge` 两个独立 Service + +--- + +### Requirement: 钱包余额操作 + +**Reason**: 余额操作逻辑拆分到代理钱包和卡钱包两个独立系统,使用各自的 Store 实现。 + +**Migration**: +- 代理钱包余额操作使用 `AgentWalletStore`,参见 `agent-wallet` spec +- 卡钱包余额操作使用 `CardWalletStore`,参见 `card-wallet` spec +- 并发控制(乐观锁)机制保持不变,继续使用 `version` 字段 + +--- + +### Requirement: 钱包数据校验 + +**Reason**: 数据校验规则拆分到代理钱包和卡钱包两个独立系统,针对各自的字段设计优化。 + +**Migration**: +- 代理钱包数据校验:使用 `shop_id` + `wallet_type`,参见 `agent-wallet` spec +- 卡钱包数据校验:使用 `resource_type` + `resource_id`,参见 `card-wallet` spec + +--- + +### Requirement: 钱包归属资源规则 + +**Reason**: 归属规则拆分到代理钱包和卡钱包两个独立系统,业务语义更清晰。 + +**Migration**: +- 代理钱包归属店铺(shop_id),不支持转手,参见 `agent-wallet` spec +- 卡钱包归属资源(iot_card / device),支持转手,参见 `card-wallet` spec + +--- + +## MODIFIED Requirements + +无修改的 requirements,所有原有 requirements 均已废弃并在新系统中重新定义。 + +--- diff --git a/openspec/changes/archive/2026-02-25-separate-agent-card-wallets/tasks.md b/openspec/changes/archive/2026-02-25-separate-agent-card-wallets/tasks.md new file mode 100644 index 0000000..eb5a1f0 --- /dev/null +++ b/openspec/changes/archive/2026-02-25-separate-agent-card-wallets/tasks.md @@ -0,0 +1,208 @@ +# 钱包系统分离 - 实施任务清单 + +## 1. 数据库迁移 - 创建新表结构 + +- [x] 1.1 创建代理钱包主表迁移文件(`tb_agent_wallet`),包含 shop_id、wallet_type、balance、frozen_balance、version 等字段,添加唯一索引和多租户索引 +- [x] 1.2 创建代理钱包交易记录表迁移文件(`tb_agent_wallet_transaction`),包含 agent_wallet_id、shop_id、transaction_type、amount、balance_before、balance_after 等字段,添加 4 个查询索引 +- [x] 1.3 创建代理充值记录表迁移文件(`tb_agent_recharge_record`),包含 recharge_no、amount、payment_method、status 等字段,添加状态和店铺索引 +- [x] 1.4 创建卡钱包主表迁移文件(`tb_card_wallet`),包含 resource_type、resource_id、balance、frozen_balance、version 等字段,添加唯一索引和多租户索引 +- [x] 1.5 创建卡钱包交易记录表迁移文件(`tb_card_wallet_transaction`),包含 card_wallet_id、resource_type、resource_id、transaction_type、amount 等字段,添加 4 个查询索引 +- [x] 1.6 创建卡充值记录表迁移文件(`tb_card_recharge_record`),包含 recharge_no、amount、payment_method、status 等字段,添加状态和资源索引 +- [x] 1.7 执行数据库迁移,验证 6 张新表创建成功,检查索引和约束 + +## 2. 代理钱包系统 - Model 层实现 + +- [x] 2.1 创建 `internal/model/agent_wallet.go`,定义 AgentWallet 结构体,包含 ShopID、WalletType、Balance、FrozenBalance、Version、BaseModel 等字段 +- [x] 2.2 在 `agent_wallet.go` 中定义 AgentWalletTransaction 结构体,包含 AgentWalletID、ShopID、TransactionType、Amount、BalanceBefore、BalanceAfter 等字段 +- [x] 2.3 在 `agent_wallet.go` 中定义 AgentRechargeRecord 结构体,包含 UserID、AgentWalletID、ShopID、RechargeNo、Amount、PaymentMethod、Status 等字段 +- [x] 2.4 为所有 Model 实现 TableName() 方法,返回对应的表名(tb_agent_wallet、tb_agent_wallet_transaction、tb_agent_recharge_record) +- [x] 2.5 添加中文注释说明每个字段的用途和约束 +- [x] 2.6 编译验证 Model 定义正确,无语法错误 + +## 3. 代理钱包系统 - Store 层实现 + +- [x] 3.1 创建 `internal/store/postgres/agent_wallet_store.go`,定义 AgentWalletStore 结构体,包含 db 和 redis 字段 +- [x] 3.2 实现 `NewAgentWalletStore` 构造函数 +- [x] 3.3 实现 `GetCommissionWallet(ctx, shopID)` 方法,查询店铺的分佣钱包 +- [x] 3.4 实现 `GetMainWallet(ctx, shopID)` 方法,查询店铺的主钱包 +- [x] 3.5 实现 `GetByShopIDAndType(ctx, shopID, walletType)` 方法,根据店铺 ID 和钱包类型查询 +- [x] 3.6 实现 `GetByID(ctx, id)` 方法,根据钱包 ID 查询 +- [x] 3.7 实现 `DeductFrozenBalanceWithTx(ctx, tx, walletID, amount)` 方法,从冻结余额扣款(带事务) +- [x] 3.8 实现 `UnfreezeBalanceWithTx(ctx, tx, walletID, amount)` 方法,解冻余额到可用余额(带事务) +- [x] 3.9 实现 `FreezeBalanceWithTx(ctx, tx, walletID, amount, version)` 方法,冻结余额(带事务,使用乐观锁) +- [x] 3.10 实现 `GetShopCommissionSummaryBatch(ctx, shopIDs)` 方法,批量获取店铺佣金钱包汇总 +- [x] 3.11 创建 `internal/store/postgres/agent_wallet_transaction_store.go`,定义 AgentWalletTransactionStore 结构体 +- [x] 3.12 实现 `CreateWithTx(ctx, tx, transaction)` 方法,创建代理钱包交易记录(带事务) +- [x] 3.13 实现 `ListByShopID(ctx, shopID, offset, limit)` 方法,按店铺查询交易记录(支持分页) +- [x] 3.14 创建 `internal/store/postgres/agent_recharge_store.go`,定义 AgentRechargeStore 结构体 +- [x] 3.15 实现充值记录的 CRUD 方法(Create、GetByRechargeNo、UpdateStatus 等) +- [x] 3.16 编译验证 Store 层代码正确,无语法错误 + +## 4. 卡钱包系统 - Model 层实现 + +- [x] 4.1 创建 `internal/model/card_wallet.go`,定义 CardWallet 结构体,包含 ResourceType、ResourceID、Balance、FrozenBalance、Version、BaseModel 等字段 +- [x] 4.2 在 `card_wallet.go` 中定义 CardWalletTransaction 结构体,包含 CardWalletID、ResourceType、ResourceID、TransactionType、Amount、BalanceBefore、BalanceAfter 等字段 +- [x] 4.3 在 `card_wallet.go` 中定义 CardRechargeRecord 结构体,包含 UserID、CardWalletID、ResourceType、ResourceID、RechargeNo、Amount、PaymentMethod、Status 等字段 +- [x] 4.4 为所有 Model 实现 TableName() 方法,返回对应的表名(tb_card_wallet、tb_card_wallet_transaction、tb_card_recharge_record) +- [x] 4.5 添加中文注释说明每个字段的用途和约束 +- [x] 4.6 编译验证 Model 定义正确,无语法错误 + +## 5. 卡钱包系统 - Store 层实现 + +- [x] 5.1 创建 `internal/store/postgres/card_wallet_store.go`,定义 CardWalletStore 结构体,包含 db 和 redis 字段 +- [x] 5.2 实现 `NewCardWalletStore` 构造函数 +- [x] 5.3 实现 `GetByResourceTypeAndID(ctx, resourceType, resourceID)` 方法,根据资源类型和 ID 查询钱包 +- [x] 5.4 实现 `GetByID(ctx, id)` 方法,根据钱包 ID 查询 +- [x] 5.5 实现 `DeductBalanceWithTx(ctx, tx, walletID, amount, version)` 方法,扣款(带事务,使用乐观锁) +- [x] 5.6 实现 `AddBalanceWithTx(ctx, tx, walletID, amount)` 方法,增加余额(带事务) +- [x] 5.7 创建 `internal/store/postgres/card_wallet_transaction_store.go`,定义 CardWalletTransactionStore 结构体 +- [x] 5.8 实现 `CreateWithTx(ctx, tx, transaction)` 方法,创建卡钱包交易记录(带事务) +- [x] 5.9 实现 `ListByResourceID(ctx, resourceType, resourceID, offset, limit)` 方法,按资源查询交易记录(支持分页) +- [x] 5.10 创建 `internal/store/postgres/card_recharge_store.go`,定义 CardRechargeStore 结构体 +- [x] 5.11 实现充值记录的 CRUD 方法(Create、GetByRechargeNo、UpdateStatus 等) +- [x] 5.12 编译验证 Store 层代码正确,无语法错误 + +## 6. 常量定义更新 + +- [x] 6.1 更新 `pkg/constants/wallet.go`,按钱包类型重新组织常量定义 +- [x] 6.2 添加代理钱包专用常量(AgentRechargeOrderPrefix、AgentRechargeMinAmount、AgentRechargeMaxAmount) +- [x] 6.3 添加卡钱包专用常量(CardWalletResourceTypeIotCard、CardWalletResourceTypeDevice、CardRechargeOrderPrefix、CardRechargeMinAmount、CardRechargeMaxAmount) +- [x] 6.4 定义 Redis Key 生成函数:`RedisAgentWalletBalanceKey(shopID, walletType)` +- [x] 6.5 定义 Redis Key 生成函数:`RedisAgentWalletLockKey(shopID, walletType)` +- [x] 6.6 定义 Redis Key 生成函数:`RedisCardWalletBalanceKey(resourceType, resourceID)` +- [x] 6.7 定义 Redis Key 生成函数:`RedisCardWalletLockKey(resourceType, resourceID)` +- [x] 6.8 为所有常量和函数添加中文注释 +- [x] 6.9 编译验证常量定义正确,无语法错误 + +## 7. Bootstrap 层 - 注册新 Store + +- [x] 7.1 在 `internal/bootstrap/stores.go` 中添加 AgentWalletStore 字段到 Stores 结构体 +- [x] 7.2 在 `internal/bootstrap/stores.go` 中添加 AgentWalletTransactionStore 字段到 Stores 结构体 +- [x] 7.3 在 `internal/bootstrap/stores.go` 中添加 AgentRechargeStore 字段到 Stores 结构体 +- [x] 7.4 在 `internal/bootstrap/stores.go` 中添加 CardWalletStore 字段到 Stores 结构体 +- [x] 7.5 在 `internal/bootstrap/stores.go` 中添加 CardWalletTransactionStore 字段到 Stores 结构体 +- [x] 7.6 在 `internal/bootstrap/stores.go` 中添加 CardRechargeStore 字段到 Stores 结构体 +- [x] 7.7 在 `NewStores()` 函数中初始化所有新 Store 实例(调用 NewXxxStore 构造函数) +- [x] 7.8 编译验证 Bootstrap 层代码正确,无语法错误 + +## 8. Service 层重构 - 佣金相关服务 + +- [x] 8.1 更新 `internal/service/commission_calculation/service.go`,将 `WalletStore` 依赖改为 `AgentWalletStore` +- [x] 8.2 更新 `internal/service/commission_calculation/service.go` 中所有调用钱包的方法,使用新的 AgentWalletStore API +- [x] 8.3 更新 `internal/service/commission_withdrawal/service.go`,将 `WalletStore` 依赖改为 `AgentWalletStore` +- [x] 8.4 更新 `internal/service/commission_withdrawal/service.go` 中所有调用钱包的方法,使用新的 AgentWalletStore API +- [x] 8.5 更新 `internal/service/shop_commission/service.go`,将 `WalletStore` 依赖改为 `AgentWalletStore` +- [x] 8.6 更新 `internal/service/shop_commission/service.go` 中所有调用钱包的方法,使用新的 AgentWalletStore API +- [x] 8.7 更新 `internal/service/my_commission/service.go`,将 `WalletStore` 依赖改为 `AgentWalletStore` +- [x] 8.8 更新 `internal/service/my_commission/service.go` 中所有调用钱包的方法,使用新的 AgentWalletStore API +- [x] 8.9 编译验证所有佣金相关服务重构正确,无语法错误 + +## 9. Service 层重构 - 订单服务 + +- [x] 9.1 更新 `internal/service/order/service.go`,将 `WalletStore` 依赖改为 `AgentWalletStore` 和 `CardWalletStore` +- [x] 9.2 更新 `internal/service/order/service.go` 中的 `WalletPay()` 方法,使用新的 AgentWalletStore 和 CardWalletStore API(根据买家类型分别处理) +- [x] 9.3 更新 `internal/service/order/service.go` 中的 `HandlePaymentCallback()` 方法,使用新的钱包 API +- [x] 9.4 更新 `internal/service/order/service.go` 中所有其他调用钱包的方法,使用新的钱包 Store API +- [x] 9.5 编译验证订单服务重构正确,无语法错误 + +## 10. Service 层重构 - 充值服务(注:实际采用原地重构方案,未拆分为两个独立服务) + +- [x] 10.1 更新 `internal/service/recharge/service.go`,将依赖从 WalletStore 改为 CardWalletStore、CardWalletTransactionStore、CardRechargeStore +- [x] 10.2 更新 Service 构造函数 New(),注入新的 CardWallet 相关 Store +- [x] 10.3 更新 `Create()` 方法,使用 CardRechargeStore 创建充值订单,使用 CardWalletStore 查询钱包 +- [x] 10.4 更新 `HandlePaymentCallback()` 方法,使用 CardRechargeStore 和 CardWalletStore 处理支付回调 +- [x] 10.5 更新 `buildRechargeResponse()` 方法,适配 CardRechargeRecord 模型 +- [x] 10.6 更新 `List()` 方法,使用 CardRechargeStore.List() 查询充值记录 +- [x] 10.7 更新 `GetByID()` 方法,使用 CardRechargeStore.GetByID() 查询充值订单 +- [x] 10.8 更新所有佣金触发逻辑,使用 AgentWalletStore 处理佣金入账 +- [x] 10.9 在 CardRechargeStore 中添加 List()、UpdatePaymentInfo()、UpdateStatusWithOptimisticLock() 方法 +- [x] 10.10 更新 bootstrap/services.go,注入 CardRecharge、CardWallet、CardWalletTransaction Store +- [x] 10.11 编译验证充值服务重构正确,无语法错误 + +## 11. Bootstrap 层 - 更新 Service 依赖注入 + +- [x] 11.1 在 `internal/bootstrap/services.go` 中更新 CommissionCalculationService,注入 AgentWalletStore 和 AgentWalletTransactionStore +- [x] 11.2 在 `internal/bootstrap/services.go` 中更新 CommissionWithdrawalService,注入 AgentWalletStore 和 AgentWalletTransactionStore +- [x] 11.3 在 `internal/bootstrap/services.go` 中更新 ShopCommissionService,注入 AgentWalletStore +- [x] 11.4 在 `internal/bootstrap/services.go` 中更新 MyCommissionService,注入 AgentWalletStore 和 AgentWalletTransactionStore +- [x] 11.5 在 `internal/bootstrap/services.go` 中更新 OrderService,注入 AgentWalletStore 和 CardWalletStore +- [x] 11.6 在 `internal/bootstrap/services.go` 中更新 RechargeService,注入 CardRechargeStore、CardWalletStore、CardWalletTransactionStore +- [x] 11.7 在 `internal/bootstrap/worker_services.go` 中更新 CommissionCalculationService,注入 AgentWalletStore 和 AgentWalletTransactionStore +- [x] 11.8 在 `pkg/queue/types.go` 中更新 WorkerStores,添加 AgentWallet 和 AgentWalletTransaction 字段 +- [x] 11.9 编译验证 Service 依赖注入更新正确,无语法错误 + +## 12. 清理旧代码 - 删除旧 Model 和 Store + +- [x] 12.1 删除 `internal/model/wallet.go` 文件(包含 Wallet、WalletTransaction、RechargeRecord、WalletMetadata) +- [x] 12.2 删除 `internal/store/postgres/wallet_store.go` 文件 +- [x] 12.3 删除 `internal/store/postgres/wallet_transaction_store.go` 文件 +- [x] 12.4 从 `internal/bootstrap/stores.go` 中移除 WalletStore 和 WalletTransactionStore 字段 +- [x] 12.5 从 `internal/bootstrap/stores.go` 的 `NewStores()` 函数中移除 WalletStore 和 WalletTransactionStore 初始化 +- [x] 12.6 编译检查,确保无旧代码引用残留 + +## 13. 数据库迁移 - 删除旧表 + +- [x] 13.1 创建删除旧表的迁移文件(DROP TABLE tb_wallet、tb_wallet_transaction、tb_recharge_record) +- [x] 13.2 执行数据库迁移,验证旧表删除成功 +- [x] 13.3 检查数据库中只剩下新的 6 张表 + +## 14. 手动测试 - 代理钱包核心流程 + +- [ ] 14.1 测试代理钱包创建:为店铺 ID 10 创建主钱包和分佣钱包 +- [ ] 14.2 测试代理钱包充值:主钱包充值 100000 分(1000 元),验证余额正确 +- [ ] 14.3 测试代理钱包扣款:主钱包扣款 30000 分(300 元),验证余额正确,创建交易记录 +- [ ] 14.4 测试余额不足场景:尝试扣款超过可用余额,验证返回"余额不足"错误 +- [ ] 14.5 测试冻结余额:分佣钱包冻结 50000 分用于提现,验证 frozen_balance 增加,可用余额减少 +- [ ] 14.6 测试解冻余额:取消提现,验证冻结余额减少,可用余额恢复 +- [ ] 14.7 测试并发扣款:模拟两个并发请求同时扣款,验证乐观锁生效(一个成功,一个失败重试) +- [ ] 14.8 测试交易记录查询:按店铺 ID 查询交易历史,验证分页和排序正确 + +## 15. 手动测试 - 卡钱包核心流程 + +- [ ] 15.1 测试卡钱包创建:为物联网卡(resource_type=iot_card, resource_id=100)创建钱包 +- [ ] 15.2 测试卡钱包充值:卡钱包充值 10000 分(100 元),验证余额正确 +- [ ] 15.3 测试卡钱包扣款:购买套餐扣款 3000 分(30 元),验证余额正确,创建交易记录 +- [ ] 15.4 测试订单退款:退款 3000 分,验证余额恢复,创建退款交易记录 +- [ ] 15.5 测试余额不足场景:尝试扣款超过可用余额,验证返回"余额不足"错误 +- [ ] 15.6 测试设备钱包:为设备(resource_type=device, resource_id=200)创建钱包,验证设备的多张卡共享钱包 +- [ ] 15.7 测试并发扣款:模拟两个并发请求同时扣款,验证乐观锁生效 +- [ ] 15.8 测试交易记录查询:按资源 ID 查询交易历史,验证分页和排序正确 + +## 16. 手动测试 - 充值流程 + +- [ ] 16.1 测试代理充值最小金额限制:尝试充值 50 元,验证返回"充值金额不能低于 100 元"错误 +- [ ] 16.2 测试代理充值订单创建:创建充值订单,验证生成唯一的 recharge_no(ARCH 前缀),状态为"待支付" +- [ ] 16.3 测试代理充值支付完成:模拟支付回调,验证状态从"待支付"变为"已支付" +- [ ] 16.4 测试代理充值到账:处理充值到账,验证钱包余额增加,状态变为"已完成",创建交易记录 +- [ ] 16.5 测试卡充值最小金额限制:尝试充值 0.5 元,验证返回"充值金额不能低于 1 元"错误 +- [ ] 16.6 测试卡充值订单创建:创建充值订单,验证生成唯一的 recharge_no(CRCH 前缀),状态为"待支付" +- [ ] 16.7 测试卡充值支付完成:模拟支付回调,验证状态从"待支付"变为"已支付" +- [ ] 16.8 测试卡充值到账:处理充值到账,验证钱包余额增加,状态变为"已完成",创建交易记录 + +## 17. 数据一致性验证 + +- [ ] 17.1 验证代理钱包交易记录的 balance_before 和 balance_after 准确性:查询所有交易记录,计算余额变动,与实际余额对比 +- [ ] 17.2 验证卡钱包交易记录的 balance_before 和 balance_after 准确性:查询所有交易记录,计算余额变动,与实际余额对比 +- [ ] 17.3 验证乐观锁 version 字段在并发场景下的有效性:模拟高并发扣款,验证 version 正确递增,无丢失更新 +- [ ] 17.4 验证 Redis 缓存一致性:余额变动后,检查缓存是否被正确删除,下次查询是否重新加载 + +## 18. 最终验证和清理 + +- [x] 18.1 运行 `go build ./...` 编译整个项目,确保无编译错误 +- [x] 18.2 运行 `go mod tidy` 清理未使用的依赖 +- [x] 18.3 检查所有文件的中文注释是否完整 +- [x] 18.4 检查所有常量是否定义在 `pkg/constants/` 中,无硬编码 +- [x] 18.5 检查所有错误返回是否使用 `errors.New()` 或 `errors.Wrap()`,无 `fmt.Errorf()` +- [x] 18.6 使用 `gofmt -w .` 格式化所有代码 +- [x] 18.7 检查 git status,确认所有变更符合预期 +- [x] 18.8 准备提交代码,编写 Git Commit 信息(中文) + +--- + +**注意事项**: + +1. **任务顺序不可颠倒**:必须先完成数据库迁移和 Model 层,再实现 Store 层,最后重构 Service 层 +2. **逐项标记完成**:每完成一个任务,将 `[ ]` 改为 `[x]` +3. **遇到问题时停止**:如果某个任务无法完成或发现设计问题,立即停止并与团队讨论 +4. **编译验证**:每个阶段完成后必须编译验证,确保无语法错误 +5. **手动测试不可跳过**:所有核心流程必须手动测试验证,确保功能正确 diff --git a/openspec/specs/agent-wallet/spec.md b/openspec/specs/agent-wallet/spec.md new file mode 100644 index 0000000..0a3c416 --- /dev/null +++ b/openspec/specs/agent-wallet/spec.md @@ -0,0 +1,318 @@ +# agent-wallet Specification + +## Purpose +代理钱包系统,提供店铺级别的主钱包和分佣钱包管理,支持充值、扣款、冻结、提现等操作。与卡钱包完全隔离,独立的数据表和代码实现。 + +## ADDED Requirements + +### Requirement: 代理钱包实体定义 + +系统 SHALL 定义代理钱包(AgentWallet)实体,管理店铺级别的钱包,支持主钱包和分佣钱包两种类型。 + +**核心概念**: +- **主钱包(main)**:店铺的主要资金账户,用于预充值和购买套餐 +- **分佣钱包(commission)**:店铺的佣金账户,用于接收分佣和提现 + +**实体字段**: +- `id`:钱包 ID(主键,BIGINT,自增) +- `shop_id`:店铺 ID(BIGINT,关联 tb_shop.id,唯一约束之一) +- `wallet_type`:钱包类型(VARCHAR(20),枚举值:"main"-主钱包 | "commission"-分佣钱包,唯一约束之一) +- `balance`:余额(BIGINT,单位:分,默认 0,≥ 0) +- `frozen_balance`:冻结余额(BIGINT,单位:分,默认 0,≥ 0) +- `currency`:币种(VARCHAR(10),默认 "CNY") +- `status`:钱包状态(INT,1-正常 2-冻结 3-关闭,默认 1) +- `version`:版本号(INT,默认 0,乐观锁字段,用于防止并发扣款) +- `shop_id_tag`:店铺 ID 标签(BIGINT,多租户过滤用,与 shop_id 相同) +- `enterprise_id_tag`:企业 ID 标签(BIGINT,多租户过滤用,可空) +- `created_at`:创建时间(TIMESTAMP,自动填充) +- `updated_at`:更新时间(TIMESTAMP,自动填充) +- `deleted_at`:删除时间(TIMESTAMP,可空,软删除) + +**唯一约束**:`(shop_id, wallet_type)` 在 `deleted_at IS NULL` 条件下唯一 + +**可用余额计算**:可用余额 = balance - frozen_balance + +**表名**:`tb_agent_wallet` + +#### Scenario: 创建店铺主钱包 + +- **WHEN** 店铺(ID 为 10)首次充值 +- **THEN** 系统创建代理钱包记录,`shop_id` 为 10,`wallet_type` 为 "main",`balance` 为 0,`status` 为 1(正常),`shop_id_tag` 为 10 + +#### Scenario: 创建店铺分佣钱包 + +- **WHEN** 店铺(ID 为 10)首次获得佣金 +- **THEN** 系统创建代理钱包记录,`shop_id` 为 10,`wallet_type` 为 "commission",`balance` 为 0,`status` 为 1(正常) + +#### Scenario: 计算可用余额 + +- **WHEN** 代理钱包余额为 100000 分(1000 元),冻结余额为 30000 分(300 元) +- **THEN** 系统计算可用余额为 70000 分(700 元) + +#### Scenario: 防止同一店铺创建重复钱包类型 + +- **WHEN** 店铺(ID 为 10)已有 wallet_type 为 "main" 的钱包,尝试再次创建 wallet_type 为 "main" 的钱包 +- **THEN** 系统拒绝创建,返回错误信息"该店铺已存在主钱包" + +--- + +### Requirement: 代理钱包交易记录 + +系统 SHALL 记录所有代理钱包余额变动,包括充值、扣款、退款、分佣、提现等操作,确保完整的审计追踪。 + +**实体字段**: +- `id`:交易记录 ID(主键,BIGINT,自增) +- `agent_wallet_id`:代理钱包 ID(BIGINT,关联 tb_agent_wallet.id) +- `shop_id`:店铺 ID(BIGINT,冗余字段,便于按店铺查询) +- `user_id`:操作人用户 ID(BIGINT,关联 tb_account.id) +- `transaction_type`:交易类型(VARCHAR(20),枚举值:"recharge"-充值 | "deduct"-扣款 | "refund"-退款 | "commission"-分佣 | "withdrawal"-提现) +- `amount`:变动金额(BIGINT,单位:分,正数为增加,负数为减少) +- `balance_before`:变动前余额(BIGINT,单位:分) +- `balance_after`:变动后余额(BIGINT,单位:分) +- `status`:交易状态(INT,1-成功 2-失败 3-处理中,默认 1) +- `reference_type`:关联业务类型(VARCHAR(50),如 "order" | "commission" | "withdrawal" | "topup",可空) +- `reference_id`:关联业务 ID(BIGINT,可空) +- `remark`:备注(TEXT,可空) +- `metadata`:扩展信息(JSONB,如手续费、支付方式等,可空) +- `creator`:创建人 ID(BIGINT) +- `shop_id_tag`:店铺 ID 标签(BIGINT,多租户过滤用) +- `enterprise_id_tag`:企业 ID 标签(BIGINT,多租户过滤用,可空) +- `created_at`:创建时间(TIMESTAMP,自动填充) +- `updated_at`:更新时间(TIMESTAMP,自动填充) +- `deleted_at`:删除时间(TIMESTAMP,可空,软删除) + +**表名**:`tb_agent_wallet_transaction` + +**索引**: +- `idx_agent_tx_wallet (agent_wallet_id, created_at)`:按钱包查询交易历史 +- `idx_agent_tx_shop (shop_id, created_at)`:按店铺汇总交易 +- `idx_agent_tx_ref (reference_type, reference_id)`:按关联业务查询 +- `idx_agent_tx_type (transaction_type, created_at)`:按交易类型统计 + +#### Scenario: 充值创建交易记录 + +- **WHEN** 店铺(ID 为 10)主钱包充值 100000 分(1000 元) +- **THEN** 系统创建代理钱包交易记录,`transaction_type` 为 "recharge",`amount` 为 100000,`balance_before` 为 0,`balance_after` 为 100000,`status` 为 1(成功),`shop_id` 为 10 + +#### Scenario: 分佣发放创建交易记录 + +- **WHEN** 店铺(ID 为 10)的分佣钱包收到佣金 50000 分(500 元) +- **THEN** 系统创建代理钱包交易记录,`transaction_type` 为 "commission",`amount` 为 50000,`balance_before` 为 200000,`balance_after` 为 250000,`reference_type` 为 "commission",`reference_id` 为分佣记录 ID + +#### Scenario: 提现创建交易记录 + +- **WHEN** 店铺(ID 为 10)从分佣钱包提现 30000 分(300 元) +- **THEN** 系统创建代理钱包交易记录,`transaction_type` 为 "withdrawal",`amount` 为 -30000,`balance_before` 为 250000,`balance_after` 为 220000,`reference_type` 为 "withdrawal",`reference_id` 为提现申请 ID + +#### Scenario: 按店铺查询交易历史 + +- **WHEN** 管理员查询店铺(ID 为 10)的所有钱包交易记录,按时间倒序 +- **THEN** 系统使用索引 `idx_agent_tx_shop` 查询,返回该店铺的主钱包和分佣钱包的所有交易记录,按 `created_at` 降序排序 + +--- + +### Requirement: 代理充值记录管理 + +系统 SHALL 记录所有代理充值操作,包括充值订单号、金额、支付方式、支付状态等信息。 + +**实体字段**: +- `id`:充值记录 ID(主键,BIGINT,自增) +- `user_id`:操作人用户 ID(BIGINT,关联 tb_account.id) +- `agent_wallet_id`:代理钱包 ID(BIGINT,关联 tb_agent_wallet.id) +- `shop_id`:店铺 ID(BIGINT,冗余字段,便于查询) +- `recharge_no`:充值订单号(VARCHAR(50),唯一,格式:ARCH+时间戳+随机数) +- `amount`:充值金额(BIGINT,单位:分,≥ 10000) +- `payment_method`:支付方式(VARCHAR(20),枚举值:"alipay"-支付宝 | "wechat"-微信 | "bank"-银行转账 | "offline"-线下) +- `payment_channel`:支付渠道(VARCHAR(50),可空) +- `payment_transaction_id`:第三方支付交易号(VARCHAR(100),可空) +- `status`:充值状态(INT,1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款,默认 1) +- `paid_at`:支付时间(TIMESTAMP,可空) +- `completed_at`:完成时间(TIMESTAMP,可空) +- `shop_id_tag`:店铺 ID 标签(BIGINT,多租户过滤用) +- `enterprise_id_tag`:企业 ID 标签(BIGINT,多租户过滤用,可空) +- `created_at`:创建时间(TIMESTAMP,自动填充) +- `updated_at`:更新时间(TIMESTAMP,自动填充) +- `deleted_at`:删除时间(TIMESTAMP,可空,软删除) + +**表名**:`tb_agent_recharge_record` + +**充值金额限制**: +- 最小充值金额:10000 分(100 元) +- 最大充值金额:100000000 分(1000000 元) + +**索引**: +- `idx_agent_recharge_user (user_id, created_at)`:按用户查询充值记录 +- `idx_agent_recharge_shop (shop_id, created_at)`:按店铺查询充值记录 +- `idx_agent_recharge_status (status, created_at)`:按状态过滤充值记录 +- `idx_agent_recharge_no (recharge_no)`:按订单号查询 + +#### Scenario: 创建代理充值订单 + +- **WHEN** 店铺(ID 为 10)的管理员发起充值 100000 分(1000 元),选择支付宝支付 +- **THEN** 系统创建代理充值记录,生成唯一的 `recharge_no`(如 "ARCH20260224123456789012"),`amount` 为 100000,`payment_method` 为 "alipay",`status` 为 1(待支付),`shop_id` 为 10 + +#### Scenario: 充值金额低于最小限制 + +- **WHEN** 店铺管理员尝试充值 5000 分(50 元) +- **THEN** 系统拒绝创建充值订单,返回错误信息"充值金额不能低于 100 元" + +#### Scenario: 充值支付完成 + +- **WHEN** 店铺管理员完成支付宝支付 +- **THEN** 系统将充值记录状态从 1(待支付)变更为 2(已支付),记录 `paid_at` 时间和 `payment_transaction_id` + +#### Scenario: 充值到账 + +- **WHEN** 充值记录状态为 2(已支付),系统处理充值到账 +- **THEN** 系统将代理钱包余额增加 100000 分,创建代理钱包交易记录,将充值记录状态变更为 3(已完成),记录 `completed_at` 时间 + +--- + +### Requirement: 代理钱包余额操作 + +系统 SHALL 支持代理钱包余额的充值、扣款、退款、冻结、解冻等操作,使用乐观锁防止并发问题。 + +**操作类型**: +- **充值**:增加钱包余额 +- **扣款**:减少钱包余额(如购买套餐) +- **退款**:增加钱包余额(如订单退款) +- **冻结**:将部分余额转为冻结状态(如提现申请中) +- **解冻**:将冻结余额转回可用余额(如提现取消) + +**并发控制**: +- 使用 `version` 字段实现乐观锁 +- 每次更新余额时,检查 `version` 是否匹配 +- 如果 `version` 不匹配,说明有并发更新,操作失败并重试 + +**操作约束**: +- 扣款时,检查可用余额(balance - frozen_balance)是否充足 +- 冻结时,检查可用余额是否充足 +- 所有余额变动必须创建交易记录 + +#### Scenario: 代理钱包充值 + +- **WHEN** 店铺主钱包当前余额为 100000 分,充值 50000 分 +- **THEN** 系统将钱包余额更新为 150000 分,`version` 从 1 变更为 2,创建交易记录(`transaction_type` 为 "recharge",`amount` 为 50000) + +#### Scenario: 代理钱包扣款 + +- **WHEN** 店铺主钱包当前余额为 150000 分,购买套餐扣款 30000 分 +- **THEN** 系统检查可用余额(150000 - 0 = 150000)≥ 30000,将钱包余额更新为 120000 分,`version` 从 2 变更为 3,创建交易记录(`transaction_type` 为 "deduct",`amount` 为 -30000) + +#### Scenario: 余额不足扣款失败 + +- **WHEN** 店铺主钱包当前余额为 20000 分,购买套餐需要扣款 30000 分 +- **THEN** 系统检查可用余额(20000 - 0 = 20000)< 30000,拒绝扣款,返回错误信息"余额不足" + +#### Scenario: 并发扣款乐观锁生效 + +- **WHEN** 店铺主钱包当前余额为 100000 分,version 为 1,两个并发请求同时扣款 30000 分和 50000 分 +- **THEN** 第一个请求成功,余额变为 70000 分,version 变为 2;第二个请求因 version 不匹配失败,需重新读取最新余额(70000 分)和 version(2)后重试 + +#### Scenario: 冻结余额用于提现 + +- **WHEN** 店铺分佣钱包余额为 100000 分,申请提现 30000 分 +- **THEN** 系统将钱包的 `frozen_balance` 增加 30000 分,可用余额减少 30000 分,`version` 增加 1 + +#### Scenario: 解冻余额(提现取消) + +- **WHEN** 店铺分佣钱包冻结余额为 30000 分,用户取消提现申请 +- **THEN** 系统将钱包的 `frozen_balance` 减少 30000 分,可用余额增加 30000 分,`version` 增加 1 + +--- + +### Requirement: 代理钱包数据校验 + +系统 SHALL 对代理钱包数据进行校验,确保数据完整性和一致性。 + +**校验规则**: +- `shop_id`:必填,≥ 1,必须是有效的店铺 ID +- `wallet_type`:必填,枚举值 "main" | "commission" +- `balance`:必填,≥ 0 +- `frozen_balance`:必填,≥ 0,≤ balance +- `currency`:必填,长度 1-10 字符,默认 "CNY" +- `status`:必填,枚举值 1-3 +- `version`:必填,≥ 0 + +#### Scenario: 创建钱包时 shop_id 无效 + +- **WHEN** 创建代理钱包,`shop_id` 为 0 +- **THEN** 系统拒绝创建,返回错误信息"店铺 ID 无效,必须 ≥ 1" + +#### Scenario: 创建钱包时 wallet_type 无效 + +- **WHEN** 创建代理钱包,`wallet_type` 为 "invalid" +- **THEN** 系统拒绝创建,返回错误信息"钱包类型无效,必须是 main 或 commission" + +#### Scenario: 冻结余额超过总余额 + +- **WHEN** 代理钱包余额为 100000 分,尝试冻结 150000 分 +- **THEN** 系统拒绝操作,返回错误信息"冻结余额不能超过总余额" + +#### Scenario: 余额为负数 + +- **WHEN** 尝试将代理钱包余额设置为 -10000 分 +- **THEN** 系统拒绝操作,返回错误信息"余额不能为负数" + +--- + +### Requirement: 代理钱包归属店铺规则 + +系统 SHALL 确保代理钱包归属店铺,不支持转手,店铺的多个员工账号共享钱包。 + +**归属规则**: +- 代理钱包归属店铺(shop_id),不归属个人用户 +- 同一店铺的所有员工账号共享该店铺的主钱包和分佣钱包 +- 店铺钱包不支持转手,归属关系固定 + +#### Scenario: 店铺的多个员工账号共享钱包 + +- **WHEN** 店铺(ID 为 10)有 3 个员工账号(账号 ID 为 201、202、203),店铺主钱包余额为 500000 分 +- **THEN** 3 个员工账号登录后查询店铺主钱包,余额都是 500000 分,可以共享使用 + +#### Scenario: 员工账号只能访问自己店铺的钱包 + +- **WHEN** 员工账号(ID 为 201,归属店铺 10)尝试访问店铺 20 的钱包 +- **THEN** 系统拒绝访问,返回错误信息"无权限访问该店铺的钱包" + +--- + +### Requirement: 代理钱包 Redis 缓存策略 + +系统 SHALL 使用 Redis 缓存代理钱包余额,提升查询性能,并使用 Redis 分布式锁防止并发操作冲突。 + +**缓存 Key 定义**: +- 余额缓存:`agent_wallet:balance:{shop_id}:{wallet_type}` +- 分布式锁:`agent_wallet:lock:{shop_id}:{wallet_type}` + +**缓存 TTL**: +- 余额缓存:300 秒(5 分钟) +- 分布式锁:10 秒 + +**缓存更新策略**: +- 余额变动时,删除缓存(Cache-Aside 模式) +- 下次查询时重新加载到缓存 + +**常量定义位置**:`pkg/constants/wallet.go` + +```go +func RedisAgentWalletBalanceKey(shopID uint, walletType string) string +func RedisAgentWalletLockKey(shopID uint, walletType string) string +``` + +#### Scenario: 查询余额时使用缓存 + +- **WHEN** 查询店铺(ID 为 10)主钱包余额,缓存中存在该余额 +- **THEN** 系统直接从 Redis 返回余额,不查询数据库 + +#### Scenario: 余额变动后删除缓存 + +- **WHEN** 店铺(ID 为 10)主钱包余额增加 50000 分 +- **THEN** 系统删除 Redis 缓存 Key `agent_wallet:balance:10:main`,下次查询时重新加载 + +#### Scenario: 使用分布式锁防止并发冻结 + +- **WHEN** 两个并发请求同时尝试冻结店铺(ID 为 10)主钱包的余额 +- **THEN** 系统使用 Redis 分布式锁 `agent_wallet:lock:10:main`,第一个请求获得锁,第二个请求等待或失败 + +--- diff --git a/openspec/specs/card-wallet/spec.md b/openspec/specs/card-wallet/spec.md new file mode 100644 index 0000000..8a9542b --- /dev/null +++ b/openspec/specs/card-wallet/spec.md @@ -0,0 +1,333 @@ +# card-wallet Specification + +## Purpose +卡钱包系统,提供物联网卡和设备级别的钱包管理,支持充值、套餐扣费、余额查询等操作。与代理钱包完全隔离,独立的数据表和代码实现。 + +## ADDED Requirements + +### Requirement: 卡钱包实体定义 + +系统 SHALL 定义卡钱包(CardWallet)实体,管理物联网卡和设备级别的钱包,支持资源转手场景。 + +**核心概念**: +- **物联网卡钱包**:归属单张物联网卡,卡转手时钱包跟着卡走 +- **设备钱包**:归属设备(含1-4张卡),设备的多张卡共享钱包,设备转手时钱包跟着设备走 + +**实体字段**: +- `id`:钱包 ID(主键,BIGINT,自增) +- `resource_type`:资源类型(VARCHAR(20),枚举值:"iot_card"-物联网卡 | "device"-设备,唯一约束之一) +- `resource_id`:资源 ID(BIGINT,关联 tb_iot_card.id 或 tb_device.id,唯一约束之一) +- `balance`:余额(BIGINT,单位:分,默认 0,≥ 0) +- `frozen_balance`:冻结余额(BIGINT,单位:分,默认 0,≥ 0) +- `currency`:币种(VARCHAR(10),默认 "CNY") +- `status`:钱包状态(INT,1-正常 2-冻结 3-关闭,默认 1) +- `version`:版本号(INT,默认 0,乐观锁字段,用于防止并发扣款) +- `shop_id_tag`:店铺 ID 标签(BIGINT,多租户过滤用) +- `enterprise_id_tag`:企业 ID 标签(BIGINT,多租户过滤用,可空) +- `created_at`:创建时间(TIMESTAMP,自动填充) +- `updated_at`:更新时间(TIMESTAMP,自动填充) +- `deleted_at`:删除时间(TIMESTAMP,可空,软删除) + +**唯一约束**:`(resource_type, resource_id)` 在 `deleted_at IS NULL` 条件下唯一 + +**可用余额计算**:可用余额 = balance - frozen_balance + +**表名**:`tb_card_wallet` + +#### Scenario: 创建物联网卡钱包 + +- **WHEN** 个人客户通过 ICCID "8986001234567890" 登录(首次登录),为该卡充值 +- **THEN** 系统创建卡钱包记录,`resource_type` 为 "iot_card",`resource_id` 为卡 ID,`balance` 为 0,`status` 为 1(正常) + +#### Scenario: 创建设备钱包 + +- **WHEN** 个人客户通过设备号 "DEV-001" 登录(首次登录),该设备绑定 3 张卡,为设备充值 +- **THEN** 系统创建卡钱包记录,`resource_type` 为 "device",`resource_id` 为设备 ID,设备的 3 张卡共享该钱包 + +#### Scenario: 计算可用余额 + +- **WHEN** 卡钱包余额为 10000 分(100 元),冻结余额为 3000 分(30 元) +- **THEN** 系统计算可用余额为 7000 分(70 元) + +#### Scenario: 防止同一资源创建重复钱包 + +- **WHEN** 物联网卡(ID 为 100)已有钱包,尝试再次创建钱包 +- **THEN** 系统拒绝创建,返回错误信息"该资源已存在钱包" + +--- + +### Requirement: 卡钱包交易记录 + +系统 SHALL 记录所有卡钱包余额变动,包括充值、套餐扣费、退款等操作,确保完整的审计追踪。 + +**实体字段**: +- `id`:交易记录 ID(主键,BIGINT,自增) +- `card_wallet_id`:卡钱包 ID(BIGINT,关联 tb_card_wallet.id) +- `resource_type`:资源类型(VARCHAR(20),冗余字段,便于查询) +- `resource_id`:资源 ID(BIGINT,冗余字段,便于查询) +- `user_id`:操作人用户 ID(BIGINT,关联 tb_account.id) +- `transaction_type`:交易类型(VARCHAR(20),枚举值:"recharge"-充值 | "deduct"-扣款 | "refund"-退款) +- `amount`:变动金额(BIGINT,单位:分,正数为增加,负数为减少) +- `balance_before`:变动前余额(BIGINT,单位:分) +- `balance_after`:变动后余额(BIGINT,单位:分) +- `status`:交易状态(INT,1-成功 2-失败 3-处理中,默认 1) +- `reference_type`:关联业务类型(VARCHAR(50),如 "order" | "topup",可空) +- `reference_id`:关联业务 ID(BIGINT,可空) +- `remark`:备注(TEXT,可空) +- `metadata`:扩展信息(JSONB,如套餐信息、支付方式等,可空) +- `creator`:创建人 ID(BIGINT) +- `shop_id_tag`:店铺 ID 标签(BIGINT,多租户过滤用) +- `enterprise_id_tag`:企业 ID 标签(BIGINT,多租户过滤用,可空) +- `created_at`:创建时间(TIMESTAMP,自动填充) +- `updated_at`:更新时间(TIMESTAMP,自动填充) +- `deleted_at`:删除时间(TIMESTAMP,可空,软删除) + +**表名**:`tb_card_wallet_transaction` + +**索引**: +- `idx_card_tx_wallet (card_wallet_id, created_at)`:按钱包查询交易历史 +- `idx_card_tx_resource (resource_type, resource_id, created_at)`:按资源查询交易 +- `idx_card_tx_ref (reference_type, reference_id)`:按关联业务查询 +- `idx_card_tx_type (transaction_type, created_at)`:按交易类型统计 + +#### Scenario: 充值创建交易记录 + +- **WHEN** 物联网卡(ICCID "8986001234567890")充值 10000 分(100 元) +- **THEN** 系统创建卡钱包交易记录,`transaction_type` 为 "recharge",`amount` 为 10000,`balance_before` 为 0,`balance_after` 为 10000,`status` 为 1(成功) + +#### Scenario: 套餐扣费创建交易记录 + +- **WHEN** 物联网卡(ICCID "8986001234567890")购买套餐,钱包支付扣款 3000 分(30 元) +- **THEN** 系统创建卡钱包交易记录,`transaction_type` 为 "deduct",`amount` 为 -3000,`balance_before` 为 10000,`balance_after` 为 7000,`reference_type` 为 "order",`reference_id` 为订单 ID + +#### Scenario: 订单退款创建交易记录 + +- **WHEN** 物联网卡订单(ID 为 1001)退款 3000 分(30 元) +- **THEN** 系统创建卡钱包交易记录,`transaction_type` 为 "refund",`amount` 为 3000,`balance_before` 为 7000,`balance_after` 为 10000,`reference_type` 为 "order",`reference_id` 为 1001 + +#### Scenario: 按资源查询交易历史 + +- **WHEN** 个人客户查询物联网卡(ICCID "8986001234567890")的交易历史 +- **THEN** 系统使用索引 `idx_card_tx_resource` 查询,返回该卡的所有钱包交易记录,按 `created_at` 降序排序 + +--- + +### Requirement: 卡充值记录管理 + +系统 SHALL 记录所有卡充值操作,包括充值订单号、金额、支付方式、支付状态等信息。 + +**实体字段**: +- `id`:充值记录 ID(主键,BIGINT,自增) +- `user_id`:操作人用户 ID(BIGINT,关联 tb_account.id) +- `card_wallet_id`:卡钱包 ID(BIGINT,关联 tb_card_wallet.id) +- `resource_type`:资源类型(VARCHAR(20),冗余字段) +- `resource_id`:资源 ID(BIGINT,冗余字段) +- `recharge_no`:充值订单号(VARCHAR(50),唯一,格式:CRCH+时间戳+随机数) +- `amount`:充值金额(BIGINT,单位:分,≥ 100) +- `payment_method`:支付方式(VARCHAR(20),枚举值:"alipay"-支付宝 | "wechat"-微信) +- `payment_channel`:支付渠道(VARCHAR(50),可空) +- `payment_transaction_id`:第三方支付交易号(VARCHAR(100),可空) +- `status`:充值状态(INT,1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款,默认 1) +- `paid_at`:支付时间(TIMESTAMP,可空) +- `completed_at`:完成时间(TIMESTAMP,可空) +- `shop_id_tag`:店铺 ID 标签(BIGINT,多租户过滤用) +- `enterprise_id_tag`:企业 ID 标签(BIGINT,多租户过滤用,可空) +- `created_at`:创建时间(TIMESTAMP,自动填充) +- `updated_at`:更新时间(TIMESTAMP,自动填充) +- `deleted_at`:删除时间(TIMESTAMP,可空,软删除) + +**表名**:`tb_card_recharge_record` + +**充值金额限制**: +- 最小充值金额:100 分(1 元) +- 最大充值金额:10000000 分(100000 元) + +**索引**: +- `idx_card_recharge_user (user_id, created_at)`:按用户查询充值记录 +- `idx_card_recharge_resource (resource_type, resource_id, created_at)`:按资源查询充值记录 +- `idx_card_recharge_status (status, created_at)`:按状态过滤充值记录 +- `idx_card_recharge_no (recharge_no)`:按订单号查询 + +#### Scenario: 创建卡充值订单 + +- **WHEN** 个人客户为物联网卡(ICCID "8986001234567890")发起充值 10000 分(100 元),选择微信支付 +- **THEN** 系统创建卡充值记录,生成唯一的 `recharge_no`(如 "CRCH20260224123456789012"),`amount` 为 10000,`payment_method` 为 "wechat",`status` 为 1(待支付),`resource_type` 为 "iot_card" + +#### Scenario: 充值金额低于最小限制 + +- **WHEN** 个人客户尝试充值 50 分(0.5 元) +- **THEN** 系统拒绝创建充值订单,返回错误信息"充值金额不能低于 1 元" + +#### Scenario: 充值支付完成 + +- **WHEN** 个人客户完成微信支付 +- **THEN** 系统将充值记录状态从 1(待支付)变更为 2(已支付),记录 `paid_at` 时间和 `payment_transaction_id` + +#### Scenario: 充值到账 + +- **WHEN** 充值记录状态为 2(已支付),系统处理充值到账 +- **THEN** 系统将卡钱包余额增加 10000 分,创建卡钱包交易记录,将充值记录状态变更为 3(已完成),记录 `completed_at` 时间 + +--- + +### Requirement: 卡钱包余额操作 + +系统 SHALL 支持卡钱包余额的充值、扣款、退款等操作,使用乐观锁防止并发问题。 + +**操作类型**: +- **充值**:增加钱包余额 +- **扣款**:减少钱包余额(如购买套餐) +- **退款**:增加钱包余额(如订单退款) + +**并发控制**: +- 使用 `version` 字段实现乐观锁 +- 每次更新余额时,检查 `version` 是否匹配 +- 如果 `version` 不匹配,说明有并发更新,操作失败并重试 + +**操作约束**: +- 扣款时,检查可用余额(balance - frozen_balance)是否充足 +- 所有余额变动必须创建交易记录 + +#### Scenario: 卡钱包充值 + +- **WHEN** 卡钱包当前余额为 10000 分,充值 5000 分 +- **THEN** 系统将钱包余额更新为 15000 分,`version` 从 1 变更为 2,创建交易记录(`transaction_type` 为 "recharge",`amount` 为 5000) + +#### Scenario: 卡钱包扣款 + +- **WHEN** 卡钱包当前余额为 15000 分,购买套餐扣款 3000 分 +- **THEN** 系统检查可用余额(15000 - 0 = 15000)≥ 3000,将钱包余额更新为 12000 分,`version` 从 2 变更为 3,创建交易记录(`transaction_type` 为 "deduct",`amount` 为 -3000) + +#### Scenario: 余额不足扣款失败 + +- **WHEN** 卡钱包当前余额为 2000 分,购买套餐需要扣款 3000 分 +- **THEN** 系统检查可用余额(2000 - 0 = 2000)< 3000,拒绝扣款,返回错误信息"余额不足" + +#### Scenario: 并发扣款乐观锁生效 + +- **WHEN** 卡钱包当前余额为 10000 分,version 为 1,两个并发请求同时扣款 3000 分和 5000 分 +- **THEN** 第一个请求成功,余额变为 7000 分,version 变为 2;第二个请求因 version 不匹配失败,需重新读取最新余额(7000 分)和 version(2)后重试 + +#### Scenario: 订单退款 + +- **WHEN** 卡钱包当前余额为 7000 分,订单退款 3000 分 +- **THEN** 系统将钱包余额更新为 10000 分,`version` 增加 1,创建交易记录(`transaction_type` 为 "refund",`amount` 为 3000) + +--- + +### Requirement: 卡钱包数据校验 + +系统 SHALL 对卡钱包数据进行校验,确保数据完整性和一致性。 + +**校验规则**: +- `resource_type`:必填,枚举值 "iot_card" | "device" +- `resource_id`:必填,≥ 1,必须是有效的资源 ID +- `balance`:必填,≥ 0 +- `frozen_balance`:必填,≥ 0,≤ balance +- `currency`:必填,长度 1-10 字符,默认 "CNY" +- `status`:必填,枚举值 1-3 +- `version`:必填,≥ 0 + +#### Scenario: 创建钱包时 resource_type 无效 + +- **WHEN** 创建卡钱包,`resource_type` 为 "invalid" +- **THEN** 系统拒绝创建,返回错误信息"资源类型无效,必须是 iot_card 或 device" + +#### Scenario: 创建钱包时 resource_id 无效 + +- **WHEN** 创建卡钱包,`resource_type` 为 "iot_card",`resource_id` 为 0 +- **THEN** 系统拒绝创建,返回错误信息"资源 ID 无效,必须 ≥ 1" + +#### Scenario: 冻结余额超过总余额 + +- **WHEN** 卡钱包余额为 10000 分,尝试冻结 15000 分 +- **THEN** 系统拒绝操作,返回错误信息"冻结余额不能超过总余额" + +#### Scenario: 余额为负数 + +- **WHEN** 尝试将卡钱包余额设置为 -10000 分 +- **THEN** 系统拒绝操作,返回错误信息"余额不能为负数" + +--- + +### Requirement: 卡钱包归属资源转手规则 + +系统 SHALL 支持卡钱包随资源(物联网卡、设备)转手,新用户登录后可以看到钱包余额。 + +**归属规则**: + +| 资源类型 | ResourceType | 适用场景 | 转手规则 | +|---------|-------------|---------|---------| +| 物联网卡 | iot_card | 个人客户购买单卡 | 钱包归属卡,卡转手时钱包跟着卡走 | +| 设备 | device | 个人客户购买设备(含1-4张卡) | 钱包归属设备,设备的多张卡共享钱包,设备转手时钱包跟着设备走 | + +**资源转手场景**: +- 物联网卡转手:新用户通过 ICCID 登录后可以看到卡的钱包余额 +- 设备转手:新用户通过设备号登录后可以看到设备的钱包余额(包含绑定的所有卡) + +#### Scenario: 个人客户购买单卡并充值 + +- **WHEN** 个人客户通过 ICCID "8986001234567890" 登录(首次登录),为该卡充值 10000 分 +- **THEN** 系统创建卡钱包记录,`resource_type` 为 "iot_card",`resource_id` 为卡 ID,`balance` 为 10000 + +#### Scenario: 个人客户购买设备并充值 + +- **WHEN** 个人客户通过设备号 "DEV-001" 登录(首次登录),该设备绑定 3 张卡,为设备充值 20000 分 +- **THEN** 系统创建卡钱包记录,`resource_type` 为 "device",`resource_id` 为设备 ID,设备的 3 张卡共享该钱包,`balance` 为 20000 + +#### Scenario: 卡转手后新用户查询余额 + +- **WHEN** 个人客户 A(微信 OpenID 为 "wx_a")的卡(ICCID 为 "8986001234567890")转手给个人客户 B(微信 OpenID 为 "wx_b"),卡钱包余额为 5000 分 +- **THEN** 个人客户 B 通过 ICCID "8986001234567890" 登录后查询钱包,余额为 5000 分,可以继续使用 + +#### Scenario: 设备转手后新用户查询余额 + +- **WHEN** 个人客户 A 的设备(设备号 "DEV-001",绑定 3 张卡)转手给个人客户 B,设备钱包余额为 15000 分 +- **THEN** 个人客户 B 通过设备号 "DEV-001" 登录后查询钱包,余额为 15000 分,3 张卡共享该余额 + +#### Scenario: 设备的多张卡共享钱包 + +- **WHEN** 设备(设备号 "DEV-001")绑定 3 张卡(ICCID 为 "111"、"222"、"333"),设备钱包余额为 20000 分 +- **THEN** 用户通过任意一张卡的 ICCID 登录,查询钱包余额都是 20000 分(设备级别钱包) + +--- + +### Requirement: 卡钱包 Redis 缓存策略 + +系统 SHALL 使用 Redis 缓存卡钱包余额,提升查询性能,并使用 Redis 分布式锁防止并发操作冲突。 + +**缓存 Key 定义**: +- 余额缓存:`card_wallet:balance:{resource_type}:{resource_id}` +- 分布式锁:`card_wallet:lock:{resource_type}:{resource_id}` + +**缓存 TTL**: +- 余额缓存:180 秒(3 分钟) +- 分布式锁:10 秒 + +**缓存更新策略**: +- 余额变动时,删除缓存(Cache-Aside 模式) +- 下次查询时重新加载到缓存 + +**常量定义位置**:`pkg/constants/wallet.go` + +```go +func RedisCardWalletBalanceKey(resourceType string, resourceID uint) string +func RedisCardWalletLockKey(resourceType string, resourceID uint) string +``` + +#### Scenario: 查询余额时使用缓存 + +- **WHEN** 查询物联网卡(ICCID "8986001234567890")钱包余额,缓存中存在该余额 +- **THEN** 系统直接从 Redis 返回余额,不查询数据库 + +#### Scenario: 余额变动后删除缓存 + +- **WHEN** 物联网卡(ID 为 100)钱包余额增加 5000 分 +- **THEN** 系统删除 Redis 缓存 Key `card_wallet:balance:iot_card:100`,下次查询时重新加载 + +#### Scenario: 使用分布式锁防止并发扣款 + +- **WHEN** 两个并发请求同时尝试从物联网卡(ID 为 100)钱包扣款 +- **THEN** 系统使用 Redis 分布式锁 `card_wallet:lock:iot_card:100`,第一个请求获得锁,第二个请求等待或失败 + +--- diff --git a/openspec/specs/wallet/spec.md b/openspec/specs/wallet/spec.md index c591bb3..c4059d7 100644 --- a/openspec/specs/wallet/spec.md +++ b/openspec/specs/wallet/spec.md @@ -1,254 +1,88 @@ -# wallet Specification +# wallet Specification (DEPRECATED) ## Purpose -TBD - created by archiving change add-wallet-transfer-tag-models. Update Purpose after archive. -## Requirements +**⚠️ 此规范已废弃** + +钱包系统已重构,废弃统一钱包设计,拆分为 `agent-wallet`(代理钱包)和 `card-wallet`(卡钱包)两个完全独立的系统,实现数据层和代码层的完全隔离。 + +**请参阅新规范:** +- 代理钱包系统:[agent-wallet/spec.md](../agent-wallet/spec.md) +- 卡钱包系统:[card-wallet/spec.md](../card-wallet/spec.md) + +--- + +## REMOVED Requirements + ### Requirement: 钱包实体定义 -系统 SHALL 定义钱包(Wallet)实体,统一管理用户钱包和代理钱包,支持余额管理、充值、扣款等操作。 +**⚠️ 已废弃** - 废弃统一钱包设计,拆分为代理钱包(AgentWallet)和卡钱包(CardWallet)两个独立实体,使用独立的数据表。 -**核心概念**: -- **用户钱包**:普通用户和企业用户的钱包,用于购买套餐 -- **代理钱包**:代理商的钱包,支持预充值,可用成本价购买套餐 - -**实体字段**: -- `id`:钱包 ID(主键,BIGINT) -- `user_id`:用户 ID(BIGINT,关联 tb_account.id) -- `wallet_type`:钱包类型(VARCHAR(20),枚举值:"user"-用户钱包 | "agent"-代理钱包) -- `balance`:余额(BIGINT,单位:分,默认 0) -- `frozen_balance`:冻结余额(BIGINT,单位:分,默认 0,用于订单待支付、提现申请中等场景) -- `currency`:币种(VARCHAR(10),默认 "CNY") -- `status`:钱包状态(INT,1-正常 2-冻结 3-关闭) -- `version`:版本号(INT,默认 0,乐观锁字段,用于防止并发扣款) -- `creator`:创建人 ID(BIGINT) -- `updater`:更新人 ID(BIGINT) -- `created_at`:创建时间(TIMESTAMP,自动填充) -- `updated_at`:更新时间(TIMESTAMP,自动填充) -- `deleted_at`:删除时间(TIMESTAMP,可空,软删除) - -**唯一约束**:`(user_id, wallet_type, currency)` 在 `deleted_at IS NULL` 条件下唯一 - -**可用余额计算**:可用余额 = balance - frozen_balance - -#### Scenario: 创建用户钱包 - -- **WHEN** 用户(ID 为 2001)首次充值 -- **THEN** 系统创建钱包记录,`user_id` 为 2001,`wallet_type` 为 "user",`balance` 为 0,`status` 为 1(正常) - -#### Scenario: 创建代理钱包 - -- **WHEN** 代理商(ID 为 123)首次充值 -- **THEN** 系统创建钱包记录,`user_id` 为 123,`wallet_type` 为 "agent",`balance` 为 0,`status` 为 1(正常) - -#### Scenario: 计算可用余额 - -- **WHEN** 用户钱包余额为 10000 分(100 元),冻结余额为 3000 分(30 元) -- **THEN** 系统计算可用余额为 7000 分(70 元) +**迁移指南**: +- 代理钱包(shop 类型)→ `tb_agent_wallet` 表,参见 [agent-wallet spec](../agent-wallet/spec.md) +- 卡钱包(iot_card 和 device 类型)→ `tb_card_wallet` 表,参见 [card-wallet spec](../card-wallet/spec.md) +- 代码层使用新的 Model:`model.AgentWallet` 和 `model.CardWallet` +- 代码层使用新的 Store:`AgentWalletStore` 和 `CardWalletStore` --- ### Requirement: 钱包明细记录 -系统 SHALL 记录所有钱包余额变动,包括充值、扣款、退款、分佣、提现等操作,确保完整的审计追踪。 +**⚠️ 已废弃** - 废弃统一交易记录表,拆分为代理钱包交易记录(tb_agent_wallet_transaction)和卡钱包交易记录(tb_card_wallet_transaction)两个独立表。 -**实体字段**: -- `id`:明细 ID(主键,BIGINT) -- `wallet_id`:钱包 ID(BIGINT,关联 tb_wallet.id) -- `user_id`:用户 ID(BIGINT,关联 tb_account.id) -- `transaction_type`:交易类型(VARCHAR(20),枚举值:"recharge"-充值 | "deduct"-扣款 | "refund"-退款 | "commission"-分佣 | "withdrawal"-提现) -- `amount`:变动金额(BIGINT,单位:分,正数为增加,负数为减少) -- `balance_before`:变动前余额(BIGINT,单位:分) -- `balance_after`:变动后余额(BIGINT,单位:分) -- `status`:交易状态(INT,1-成功 2-失败 3-处理中) -- `reference_type`:关联业务类型(VARCHAR(50),如 "order" | "commission" | "withdrawal" | "topup") -- `reference_id`:关联业务 ID(BIGINT) -- `remark`:备注(TEXT) -- `metadata`:扩展信息(JSONB,如手续费、支付方式等) -- `creator`:创建人 ID(BIGINT) -- `created_at`:创建时间(TIMESTAMP,自动填充) -- `updated_at`:更新时间(TIMESTAMP,自动填充) -- `deleted_at`:删除时间(TIMESTAMP,可空,软删除) - -#### Scenario: 充值创建明细记录 - -- **WHEN** 用户(ID 为 2001)充值 10000 分(100 元) -- **THEN** 系统创建钱包明细记录,`transaction_type` 为 "recharge",`amount` 为 10000,`balance_before` 为 0,`balance_after` 为 10000,`status` 为 1(成功) - -#### Scenario: 购买套餐扣款创建明细记录 - -- **WHEN** 用户(ID 为 2001)使用钱包支付购买套餐,金额 3000 分(30 元) -- **THEN** 系统创建钱包明细记录,`transaction_type` 为 "deduct",`amount` 为 -3000,`balance_before` 为 10000,`balance_after` 为 7000,`reference_type` 为 "order",`reference_id` 为订单 ID - -#### Scenario: 分佣发放创建明细记录 - -- **WHEN** 代理(ID 为 123)的分佣 5000 分(50 元)审批通过并发放 -- **THEN** 系统创建钱包明细记录,`transaction_type` 为 "commission",`amount` 为 5000,`balance_before` 为 20000,`balance_after` 为 25000,`reference_type` 为 "commission",`reference_id` 为分佣记录 ID +**迁移指南**: +- 代理钱包交易记录 → `tb_agent_wallet_transaction` 表,参见 [agent-wallet spec](../agent-wallet/spec.md) +- 卡钱包交易记录 → `tb_card_wallet_transaction` 表,参见 [card-wallet spec](../card-wallet/spec.md) +- 代码层使用新的 Model:`model.AgentWalletTransaction` 和 `model.CardWalletTransaction` --- ### Requirement: 充值记录管理 -系统 SHALL 记录所有充值操作,包括充值订单号、金额、支付方式、支付状态等信息。 +**⚠️ 已废弃** - 废弃统一充值记录表,拆分为代理充值记录(tb_agent_recharge_record)和卡充值记录(tb_card_recharge_record)两个独立表。 -**实体字段**: -- `id`:充值记录 ID(主键,BIGINT) -- `user_id`:用户 ID(BIGINT,关联 tb_account.id) -- `wallet_id`:钱包 ID(BIGINT,关联 tb_wallet.id) -- `recharge_no`:充值订单号(VARCHAR(50),唯一) -- `amount`:充值金额(BIGINT,单位:分) -- `payment_method`:支付方式(VARCHAR(20),枚举值:"alipay"-支付宝 | "wechat"-微信 | "bank"-银行转账 | "offline"-线下) -- `payment_channel`:支付渠道(VARCHAR(50)) -- `payment_transaction_id`:第三方支付交易号(VARCHAR(100)) -- `status`:充值状态(INT,1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款) -- `paid_at`:支付时间(TIMESTAMP,可空) -- `completed_at`:完成时间(TIMESTAMP,可空) -- `creator`:创建人 ID(BIGINT) -- `updater`:更新人 ID(BIGINT) -- `created_at`:创建时间(TIMESTAMP,自动填充) -- `updated_at`:更新时间(TIMESTAMP,自动填充) -- `deleted_at`:删除时间(TIMESTAMP,可空,软删除) - -#### Scenario: 创建充值订单 - -- **WHEN** 用户(ID 为 2001)发起充值 10000 分(100 元),选择支付宝支付 -- **THEN** 系统创建充值记录,生成唯一的 `recharge_no`,`amount` 为 10000,`payment_method` 为 "alipay",`status` 为 1(待支付) - -#### Scenario: 充值支付完成 - -- **WHEN** 用户完成支付宝支付 -- **THEN** 系统将充值记录状态从 1(待支付)变更为 2(已支付),记录 `paid_at` 时间和 `payment_transaction_id` - -#### Scenario: 充值到账 - -- **WHEN** 充值记录状态为 2(已支付),系统处理充值到账 -- **THEN** 系统将钱包余额增加 10000 分,创建钱包明细记录,将充值记录状态变更为 3(已完成),记录 `completed_at` 时间 +**迁移指南**: +- 代理充值记录 → `tb_agent_recharge_record` 表,参见 [agent-wallet spec](../agent-wallet/spec.md) +- 卡充值记录 → `tb_card_recharge_record` 表,参见 [card-wallet spec](../card-wallet/spec.md) +- 代码层使用新的 Model:`model.AgentRechargeRecord` 和 `model.CardRechargeRecord` +- 充值服务拆分为独立的代理充值和卡充值逻辑 --- ### Requirement: 钱包余额操作 -系统 SHALL 支持钱包余额的充值、扣款、退款、冻结、解冻等操作,使用乐观锁防止并发问题。 +**⚠️ 已废弃** - 余额操作逻辑拆分到代理钱包和卡钱包两个独立系统,使用各自的 Store 实现。 -**操作类型**: -- **充值**:增加钱包余额 -- **扣款**:减少钱包余额(如购买套餐) -- **退款**:增加钱包余额(如订单退款) -- **冻结**:将部分余额转为冻结状态(如订单待支付) -- **解冻**:将冻结余额转回可用余额(如订单取消) - -**并发控制**: -- 使用 `version` 字段实现乐观锁 -- 每次更新余额时,检查 `version` 是否匹配 -- 如果 `version` 不匹配,说明有并发更新,操作失败并重试 - -#### Scenario: 钱包充值 - -- **WHEN** 用户钱包当前余额为 10000 分,充值 5000 分 -- **THEN** 系统将钱包余额更新为 15000 分,`version` 从 1 变更为 2,创建钱包明细记录 - -#### Scenario: 钱包扣款 - -- **WHEN** 用户钱包当前余额为 15000 分,购买套餐扣款 3000 分 -- **THEN** 系统检查可用余额(15000 - 0 = 15000)≥ 3000,将钱包余额更新为 12000 分,`version` 从 2 变更为 3,创建钱包明细记录 - -#### Scenario: 余额不足扣款失败 - -- **WHEN** 用户钱包当前余额为 2000 分,购买套餐需要扣款 3000 分 -- **THEN** 系统检查可用余额(2000 - 0 = 2000)< 3000,拒绝扣款,返回错误信息"余额不足" - -#### Scenario: 并发扣款乐观锁生效 - -- **WHEN** 用户钱包当前余额为 10000 分,version 为 1,两个并发请求同时扣款 3000 分和 5000 分 -- **THEN** 第一个请求成功,余额变为 7000 分,version 变为 2;第二个请求因 version 不匹配失败,需重新读取最新余额(7000 分)后重试 - -#### Scenario: 冻结余额 - -- **WHEN** 用户创建订单 10001,订单金额 3000 分,选择钱包支付 -- **THEN** 系统将钱包的 `frozen_balance` 增加 3000 分,可用余额减少 3000 分 - -#### Scenario: 解冻余额 - -- **WHEN** 用户取消订单 10001,订单金额 3000 分 -- **THEN** 系统将钱包的 `frozen_balance` 减少 3000 分,可用余额增加 3000 分 +**迁移指南**: +- 代理钱包余额操作 → 使用 `AgentWalletStore`,参见 [agent-wallet spec](../agent-wallet/spec.md) +- 卡钱包余额操作 → 使用 `CardWalletStore`,参见 [card-wallet spec](../card-wallet/spec.md) +- 并发控制(乐观锁)机制保持不变,继续使用 `version` 字段 --- ### Requirement: 钱包数据校验 -系统 SHALL 对钱包数据进行校验,确保数据完整性和一致性。 +**⚠️ 已废弃** - 数据校验规则拆分到代理钱包和卡钱包两个独立系统,针对各自的字段设计优化。 -**校验规则变更**: -- ~~`user_id`~~:~~必填,≥ 1~~(**已删除**) -- `resource_type`:必填,枚举值 "iot_card" | "device" | "shop"(**新增**) -- `resource_id`:必填,≥ 1,必须是有效的资源 ID(**新增**) -- `wallet_type`:必填,枚举值 "main" | "commission" -- `balance`:必填,≥ 0 -- `frozen_balance`:必填,≥ 0,≤ balance -- `currency`:必填,长度 1-10 字符 -- `status`:必填,枚举值 1-3 -- `version`:必填,≥ 0 - -#### Scenario: 创建钱包时 resource_type 无效 - -- **WHEN** 创建钱包,`resource_type` 为 "invalid" -- **THEN** 系统拒绝创建,返回错误信息"资源类型无效,必须是 iot_card、device 或 shop" - -#### Scenario: 创建钱包时 resource_id 无效 - -- **WHEN** 创建钱包,`resource_type` 为 "iot_card",`resource_id` 为 0 -- **THEN** 系统拒绝创建,返回错误信息"资源 ID 无效,必须 ≥ 1" - -#### Scenario: 冻结余额超过总余额 - -- **WHEN** 钱包余额为 10000 分,尝试冻结 15000 分 -- **THEN** 系统拒绝操作,返回错误信息"冻结余额不能超过总余额" - -### Requirement: 钱包归属资源规则 - -系统 SHALL 根据资源类型管理钱包归属,支持个人客户卡/设备转手和代理商店铺级别管理。 - -**归属规则**: - -| 资源类型 | ResourceType | 适用场景 | 说明 | -|---------|-------------|---------|------| -| 物联网卡 | iot_card | 个人客户购买单卡 | 钱包归属卡,卡转手时钱包跟着卡走 | -| 设备 | device | 个人客户购买设备(含1-4张卡) | 钱包归属设备,设备的多张卡共享钱包 | -| 店铺 | shop | 代理商预存款 | 钱包归属店铺,店铺的多个员工账号共享钱包 | - -**资源转手规则**: -- 物联网卡转手:新用户登录后可以看到卡的钱包余额 -- 设备转手:新用户登录后可以看到设备的钱包余额(包含绑定的所有卡) -- 店铺钱包:不支持转手,归属店铺不变 - -#### Scenario: 个人客户购买单卡并充值 - -- **WHEN** 个人客户通过 ICCID "8986001234567890" 登录(首次登录),为该卡充值 10000 分 -- **THEN** 系统创建钱包记录,`resource_type` 为 "iot_card",`resource_id` 为卡 ID,`balance` 为 10000 - -#### Scenario: 个人客户购买设备并充值 - -- **WHEN** 个人客户通过设备号 "DEV-001" 登录(首次登录),该设备绑定 3 张卡,为设备充值 20000 分 -- **THEN** 系统创建钱包记录,`resource_type` 为 "device",`resource_id` 为设备 ID,设备的 3 张卡共享该钱包 - -#### Scenario: 卡转手后新用户查询余额 - -- **WHEN** 个人客户 A(微信 OpenID 为 "wx_a")的卡(ICCID 为 "8986001234567890")转手给个人客户 B(微信 OpenID 为 "wx_b"),卡钱包余额为 5000 分 -- **THEN** 个人客户 B 通过 ICCID "8986001234567890" 登录后查询钱包,余额为 5000 分,可以继续使用 - -#### Scenario: 设备转手后新用户查询余额 - -- **WHEN** 个人客户 A 的设备(设备号 "DEV-001",绑定 3 张卡)转手给个人客户 B,设备钱包余额为 15000 分 -- **THEN** 个人客户 B 通过设备号 "DEV-001" 登录后查询钱包,余额为 15000 分,3 张卡共享该余额 - -#### Scenario: 代理商店铺钱包充值 - -- **WHEN** 代理商(店铺 ID 为 10)充值 50000 分 -- **THEN** 系统创建或更新钱包记录,`resource_type` 为 "shop",`resource_id` 为 10,`balance` 增加 50000 分 - -#### Scenario: 代理商店铺的多个员工账号共享钱包 - -- **WHEN** 代理商店铺(店铺 ID 为 10)有 3 个员工账号(账号 ID 为 201、202、203),店铺钱包余额为 50000 分 -- **THEN** 3 个员工账号登录后查询店铺钱包,余额都是 50000 分,可以共享使用 +**迁移指南**: +- 代理钱包数据校验:使用 `shop_id` + `wallet_type`,参见 [agent-wallet spec](../agent-wallet/spec.md) +- 卡钱包数据校验:使用 `resource_type` + `resource_id`,参见 [card-wallet spec](../card-wallet/spec.md) --- +### Requirement: 钱包归属资源规则 + +**⚠️ 已废弃** - 归属规则拆分到代理钱包和卡钱包两个独立系统,业务语义更清晰。 + +**迁移指南**: +- 代理钱包归属店铺(shop_id),不支持转手,参见 [agent-wallet spec](../agent-wallet/spec.md) +- 卡钱包归属资源(iot_card / device),支持转手,参见 [card-wallet spec](../card-wallet/spec.md) + +--- + +## 变更历史 + +- **2026-02-25**: 钱包系统重构,废弃统一钱包设计,拆分为 agent-wallet 和 card-wallet 两个独立系统 +- 旧的 3 张表(tb_wallet、tb_wallet_transaction、tb_recharge_record)已删除 +- 新的 6 张表已创建并投入使用 + +--- diff --git a/pkg/constants/wallet.go b/pkg/constants/wallet.go index 8bd9ae3..4c6f88b 100644 --- a/pkg/constants/wallet.go +++ b/pkg/constants/wallet.go @@ -1,54 +1,89 @@ package constants +import "fmt" + // ======================================== // 钱包系统常量定义 // ======================================== -// 钱包资源类型 +// ========== 代理钱包常量 ========== + +// 代理钱包类型 const ( - WalletResourceTypeIotCard = "iot_card" // 物联网卡钱包(个人客户) - WalletResourceTypeDevice = "device" // 设备钱包(个人客户,多卡共享) - WalletResourceTypeShop = "shop" // 店铺钱包(代理商) + AgentWalletTypeMain = "main" // 主钱包 + AgentWalletTypeCommission = "commission" // 分佣钱包 ) -// 钱包类型 +// 代理钱包状态 const ( - WalletTypeMain = "main" // 主钱包 - WalletTypeCommission = "commission" // 分佣钱包 + AgentWalletStatusNormal = 1 // 正常 + AgentWalletStatusFrozen = 2 // 冻结 + AgentWalletStatusClosed = 3 // 关闭 ) -// 钱包状态 +// 代理钱包交易类型 const ( - WalletStatusNormal = 1 // 正常 - WalletStatusFrozen = 2 // 冻结 - WalletStatusClosed = 3 // 关闭 + AgentTransactionTypeRecharge = "recharge" // 充值 + AgentTransactionTypeDeduct = "deduct" // 扣款 + AgentTransactionTypeRefund = "refund" // 退款 + AgentTransactionTypeCommission = "commission" // 分佣 + AgentTransactionTypeWithdrawal = "withdrawal" // 提现 ) -// 交易类型 +// 代理充值订单号前缀 const ( - TransactionTypeRecharge = "recharge" // 充值 - TransactionTypeDeduct = "deduct" // 扣款 - TransactionTypeRefund = "refund" // 退款 - TransactionTypeCommission = "commission" // 分佣 - TransactionTypeWithdrawal = "withdrawal" // 提现 + AgentRechargeOrderPrefix = "ARCH" // 代理充值订单号前缀 ) -// 交易状态 +// 代理充值金额限制(单位:分) +const ( + AgentRechargeMinAmount = 10000 // 最小充值金额(100元) + AgentRechargeMaxAmount = 100000000 // 最大充值金额(1000000元) +) + +// ========== 卡钱包常量 ========== + +// 卡钱包资源类型 +const ( + CardWalletResourceTypeIotCard = "iot_card" // 物联网卡钱包 + CardWalletResourceTypeDevice = "device" // 设备钱包(多卡共享) +) + +// 卡钱包状态 +const ( + CardWalletStatusNormal = 1 // 正常 + CardWalletStatusFrozen = 2 // 冻结 + CardWalletStatusClosed = 3 // 关闭 +) + +// 卡钱包交易类型 +const ( + CardTransactionTypeRecharge = "recharge" // 充值 + CardTransactionTypeDeduct = "deduct" // 扣款 + CardTransactionTypeRefund = "refund" // 退款 +) + +// 卡充值订单号前缀 +const ( + CardRechargeOrderPrefix = "CRCH" // 卡充值订单号前缀 +) + +// 卡充值金额限制(单位:分) +const ( + CardRechargeMinAmount = 100 // 最小充值金额(1元) + CardRechargeMaxAmount = 10000000 // 最大充值金额(100000元) +) + +// ========== 通用常量 ========== + +// 交易状态(代理钱包和卡钱包通用) const ( TransactionStatusSuccess = 1 // 成功 TransactionStatusFailed = 2 // 失败 TransactionStatusProcessing = 3 // 处理中 ) -// 关联业务类型 -const ( - ReferenceTypeOrder = "order" // 订单 - ReferenceTypeCommission = "commission" // 分佣 - ReferenceTypeWithdrawal = "withdrawal" // 提现 - ReferenceTypeTopup = "topup" // 充值 -) - -// 充值状态 +// 充值状态(代理钱包和卡钱包通用) const ( RechargeStatusPending = 1 // 待支付 RechargeStatusPaid = 2 // 已支付 @@ -61,17 +96,96 @@ const ( const ( RechargeMethodAlipay = "alipay" // 支付宝 RechargeMethodWechat = "wechat" // 微信 - RechargeMethodBank = "bank" // 银行转账 - RechargeMethodOffline = "offline" // 线下 + RechargeMethodBank = "bank" // 银行转账(仅代理钱包支持) + RechargeMethodOffline = "offline" // 线下(仅代理钱包支持) ) -// 充值订单号前缀 +// 关联业务类型 const ( - RechargeOrderPrefix = "RCH" // 充值订单号前缀 + ReferenceTypeOrder = "order" // 订单 + ReferenceTypeCommission = "commission" // 分佣 + ReferenceTypeWithdrawal = "withdrawal" // 提现 + ReferenceTypeTopup = "topup" // 充值 ) -// 充值金额限制(单位:分) -const ( - RechargeMinAmount = 100 // 最小充值金额(1元) - RechargeMaxAmount = 10000000 // 最大充值金额(100000元) -) +// ========== Redis Key 生成函数 ========== + +// RedisAgentWalletBalanceKey 代理钱包余额缓存 Key +// 格式:agent_wallet:balance:{shop_id}:{wallet_type} +// TTL:300 秒(5 分钟) +func RedisAgentWalletBalanceKey(shopID uint, walletType string) string { + return fmt.Sprintf("agent_wallet:balance:%d:%s", shopID, walletType) +} + +// RedisAgentWalletLockKey 代理钱包分布式锁 Key +// 格式:agent_wallet:lock:{shop_id}:{wallet_type} +// TTL:10 秒 +func RedisAgentWalletLockKey(shopID uint, walletType string) string { + return fmt.Sprintf("agent_wallet:lock:%d:%s", shopID, walletType) +} + +// RedisCardWalletBalanceKey 卡钱包余额缓存 Key +// 格式:card_wallet:balance:{resource_type}:{resource_id} +// TTL:180 秒(3 分钟) +func RedisCardWalletBalanceKey(resourceType string, resourceID uint) string { + return fmt.Sprintf("card_wallet:balance:%s:%d", resourceType, resourceID) +} + +// RedisCardWalletLockKey 卡钱包分布式锁 Key +// 格式:card_wallet:lock:{resource_type}:{resource_id} +// TTL:10 秒 +func RedisCardWalletLockKey(resourceType string, resourceID uint) string { + return fmt.Sprintf("card_wallet:lock:%s:%d", resourceType, resourceID) +} + +// ========== 兼容性别名(待清理)========== + +// 以下常量保留用于向后兼容,待旧代码清理后删除 + +// WalletTypeMain 主钱包(已废弃,使用 AgentWalletTypeMain) +const WalletTypeMain = AgentWalletTypeMain + +// WalletTypeCommission 分佣钱包(已废弃,使用 AgentWalletTypeCommission) +const WalletTypeCommission = AgentWalletTypeCommission + +// WalletResourceTypeIotCard 物联网卡钱包(已废弃,使用 CardWalletResourceTypeIotCard) +const WalletResourceTypeIotCard = CardWalletResourceTypeIotCard + +// WalletResourceTypeDevice 设备钱包(已废弃,使用 CardWalletResourceTypeDevice) +const WalletResourceTypeDevice = CardWalletResourceTypeDevice + +// WalletResourceTypeShop 店铺钱包(已废弃,代理钱包不再使用 resource_type) +const WalletResourceTypeShop = "shop" + +// WalletStatusNormal 钱包状态-正常(已废弃,使用 AgentWalletStatusNormal 或 CardWalletStatusNormal) +const WalletStatusNormal = AgentWalletStatusNormal + +// WalletStatusFrozen 钱包状态-冻结(已废弃,使用 AgentWalletStatusFrozen 或 CardWalletStatusFrozen) +const WalletStatusFrozen = AgentWalletStatusFrozen + +// WalletStatusClosed 钱包状态-关闭(已废弃,使用 AgentWalletStatusClosed 或 CardWalletStatusClosed) +const WalletStatusClosed = AgentWalletStatusClosed + +// TransactionTypeRecharge 交易类型-充值(已废弃,使用 AgentTransactionTypeRecharge 或 CardTransactionTypeRecharge) +const TransactionTypeRecharge = AgentTransactionTypeRecharge + +// TransactionTypeDeduct 交易类型-扣款(已废弃,使用 AgentTransactionTypeDeduct 或 CardTransactionTypeDeduct) +const TransactionTypeDeduct = AgentTransactionTypeDeduct + +// TransactionTypeRefund 交易类型-退款(已废弃,使用 AgentTransactionTypeRefund 或 CardTransactionTypeRefund) +const TransactionTypeRefund = AgentTransactionTypeRefund + +// TransactionTypeCommission 交易类型-分佣(已废弃,使用 AgentTransactionTypeCommission) +const TransactionTypeCommission = AgentTransactionTypeCommission + +// TransactionTypeWithdrawal 交易类型-提现(已废弃,使用 AgentTransactionTypeWithdrawal) +const TransactionTypeWithdrawal = AgentTransactionTypeWithdrawal + +// RechargeOrderPrefix 充值订单号前缀(已废弃,使用 AgentRechargeOrderPrefix 或 CardRechargeOrderPrefix) +const RechargeOrderPrefix = "RCH" + +// RechargeMinAmount 最小充值金额(已废弃,使用 AgentRechargeMinAmount 或 CardRechargeMinAmount) +const RechargeMinAmount = CardRechargeMinAmount + +// RechargeMaxAmount 最大充值金额(已废弃,使用 AgentRechargeMaxAmount 或 CardRechargeMaxAmount) +const RechargeMaxAmount = CardRechargeMaxAmount diff --git a/pkg/errors/codes.go b/pkg/errors/codes.go index b848d7e..16e5e08 100644 --- a/pkg/errors/codes.go +++ b/pkg/errors/codes.go @@ -117,20 +117,20 @@ const ( CodeForceRechargeAmountMismatch = 1141 // 强充金额不匹配 // 轮询系统相关错误 (1150-1169) - CodePollingConfigNotFound = 1150 // 轮询配置不存在 - CodePollingConfigNameExists = 1151 // 轮询配置名称已存在 - CodePollingQueueFull = 1152 // 轮询队列已满 - CodePollingConcurrencyLimit = 1153 // 并发数已达上限 - CodePollingAlertRuleNotFound = 1154 // 告警规则不存在 + CodePollingConfigNotFound = 1150 // 轮询配置不存在 + CodePollingConfigNameExists = 1151 // 轮询配置名称已存在 + CodePollingQueueFull = 1152 // 轮询队列已满 + CodePollingConcurrencyLimit = 1153 // 并发数已达上限 + CodePollingAlertRuleNotFound = 1154 // 告警规则不存在 CodePollingCleanupConfigNotFound = 1155 // 数据清理配置不存在 - CodePollingManualTriggerLimit = 1156 // 手动触发次数已达上限 + CodePollingManualTriggerLimit = 1156 // 手动触发次数已达上限 // 套餐相关错误 (1160-1179) - CodeNoAvailablePackage = 1160 // 没有可用套餐 - CodePackageActivationConflict = 1161 // 套餐正在激活中 - CodeNoMainPackage = 1162 // 必须有主套餐才能购买加油包 - CodeRealnameRequired = 1163 // 设备/卡必须先完成实名认证才能购买套餐 - CodeMixedOrderForbidden = 1164 // 同订单不能同时购买正式套餐和加油包 + CodeNoAvailablePackage = 1160 // 没有可用套餐 + CodePackageActivationConflict = 1161 // 套餐正在激活中 + CodeNoMainPackage = 1162 // 必须有主套餐才能购买加油包 + CodeRealnameRequired = 1163 // 设备/卡必须先完成实名认证才能购买套餐 + CodeMixedOrderForbidden = 1164 // 同订单不能同时购买正式套餐和加油包 // 服务端错误 (2000-2999) -> 5xx HTTP 状态码 CodeInternalError = 2001 // 内部服务器错误 diff --git a/pkg/queue/types.go b/pkg/queue/types.go index ecc558f..e874e90 100644 --- a/pkg/queue/types.go +++ b/pkg/queue/types.go @@ -21,8 +21,6 @@ type WorkerStores struct { Shop *postgres.ShopStore ShopSeriesAllocation *postgres.ShopSeriesAllocationStore PackageSeries *postgres.PackageSeriesStore - Wallet *postgres.WalletStore - WalletTransaction *postgres.WalletTransactionStore Order *postgres.OrderStore OrderItem *postgres.OrderItemStore Package *postgres.PackageStore @@ -32,6 +30,9 @@ type WorkerStores struct { PollingAlertHistory *postgres.PollingAlertHistoryStore DataCleanupConfig *postgres.DataCleanupConfigStore DataCleanupLog *postgres.DataCleanupLogStore + // 新增代理钱包 Store + AgentWallet *postgres.AgentWalletStore + AgentWalletTransaction *postgres.AgentWalletTransactionStore } // WorkerServices Worker 侧所有 Service 的集合 diff --git a/scripts/benchmark/generate_cards.go b/scripts/benchmark/generate_cards.go index e494f09..c58c9cc 100644 --- a/scripts/benchmark/generate_cards.go +++ b/scripts/benchmark/generate_cards.go @@ -1,3 +1,4 @@ +//go:build ignore // +build ignore package main @@ -20,20 +21,20 @@ import ( // IotCard 简化的卡模型 type IotCard struct { - ID uint `gorm:"primaryKey"` - ICCID string `gorm:"column:iccid;uniqueIndex:idx_iot_card_iccid,where:deleted_at IS NULL"` - CardCategory string `gorm:"column:card_category;default:normal"` - CarrierID uint `gorm:"column:carrier_id"` - Status int `gorm:"column:status;default:1"` - ActivationStatus int `gorm:"column:activation_status;default:0"` - RealNameStatus int `gorm:"column:real_name_status;default:0"` - NetworkStatus int `gorm:"column:network_status;default:0"` - EnablePolling bool `gorm:"column:enable_polling;default:true"` - Creator uint `gorm:"column:creator"` - Updater uint `gorm:"column:updater"` - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt *time.Time `gorm:"index"` + ID uint `gorm:"primaryKey"` + ICCID string `gorm:"column:iccid;uniqueIndex:idx_iot_card_iccid,where:deleted_at IS NULL"` + CardCategory string `gorm:"column:card_category;default:normal"` + CarrierID uint `gorm:"column:carrier_id"` + Status int `gorm:"column:status;default:1"` + ActivationStatus int `gorm:"column:activation_status;default:0"` + RealNameStatus int `gorm:"column:real_name_status;default:0"` + NetworkStatus int `gorm:"column:network_status;default:0"` + EnablePolling bool `gorm:"column:enable_polling;default:true"` + Creator uint `gorm:"column:creator"` + Updater uint `gorm:"column:updater"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index"` } func (IotCard) TableName() string { @@ -41,11 +42,11 @@ func (IotCard) TableName() string { } var ( - totalCards = flag.Int("total", 10000000, "要生成的卡数量") - batchSize = flag.Int("batch", 10000, "每批插入数量") - workers = flag.Int("workers", 10, "并行 worker 数量") - startICCID = flag.String("start", "898600000", "起始 ICCID 前缀(9位,总长度不超过20位)") - clearOld = flag.Bool("clear", false, "是否清空现有测试卡") + totalCards = flag.Int("total", 10000000, "要生成的卡数量") + batchSize = flag.Int("batch", 10000, "每批插入数量") + workers = flag.Int("workers", 10, "并行 worker 数量") + startICCID = flag.String("start", "898600000", "起始 ICCID 前缀(9位,总长度不超过20位)") + clearOld = flag.Bool("clear", false, "是否清空现有测试卡") insertedCount int64 startTime time.Time diff --git a/scripts/benchmark/mock_gateway.go b/scripts/benchmark/mock_gateway.go index 826ffe5..9c68dd0 100644 --- a/scripts/benchmark/mock_gateway.go +++ b/scripts/benchmark/mock_gateway.go @@ -1,3 +1,4 @@ +//go:build ignore // +build ignore package main @@ -250,12 +251,12 @@ func handleStats(w http.ResponseWriter, r *http.Request) { } stats := map[string]interface{}{ - "uptime_seconds": elapsed, - "total_requests": total, - "success_count": success, - "failed_count": failed, - "qps": qps, - "success_rate": fmt.Sprintf("%.2f%%", successRate), + "uptime_seconds": elapsed, + "total_requests": total, + "success_count": success, + "failed_count": failed, + "qps": qps, + "success_rate": fmt.Sprintf("%.2f%%", successRate), } w.Header().Set("Content-Type", "application/json")