feat: 钱包系统分离 - 代理钱包与卡钱包完全隔离
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m17s

## 变更概述
将统一钱包系统拆分为代理钱包和卡钱包两个独立系统,实现数据表和代码层面的完全隔离。

## 数据库变更
- 新增 6 张表:tb_agent_wallet、tb_agent_wallet_transaction、tb_agent_recharge_record、tb_card_wallet、tb_card_wallet_transaction、tb_card_recharge_record
- 删除 3 张旧表:tb_wallet、tb_wallet_transaction、tb_recharge_record
- 代理钱包:按 (shop_id, wallet_type) 唯一标识,支持主钱包和分佣钱包
- 卡钱包:按 (resource_type, resource_id) 唯一标识,支持物联网卡和设备

## 代码变更
- Model 层:新增 AgentWallet、AgentWalletTransaction、AgentRechargeRecord、CardWallet、CardWalletTransaction、CardRechargeRecord 模型
- Store 层:新增 6 个独立 Store,支持事务、乐观锁、Redis 缓存
- Service 层:重构 commission_calculation、commission_withdrawal、order、recharge 等 8 个服务
- Bootstrap 层:更新 Store 和 Service 依赖注入
- 常量层:按钱包类型重新组织常量和 Redis Key 生成函数

## 技术特性
- 乐观锁:使用 version 字段防止并发冲突
- 多租户:支持 shop_id_tag 和 enterprise_id_tag 过滤
- 事务管理:所有余额变动使用事务保证 ACID
- 缓存策略:Cache-Aside 模式,余额变动后删除缓存

## 业务影响
- 代理钱包和卡钱包业务完全隔离,互不影响
- 为独立监控、优化、扩展打下基础
- 提升代理钱包的稳定性和独立性

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 09:51:00 +08:00
parent f32d32cd36
commit 18daeae65a
66 changed files with 4420 additions and 1090 deletions

6
go.mod
View File

@@ -15,7 +15,6 @@ require (
github.com/jackc/pgx/v5 v5.7.6 github.com/jackc/pgx/v5 v5.7.6
github.com/redis/go-redis/v9 v9.17.3 github.com/redis/go-redis/v9 v9.17.3
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/swaggest/openapi-go v0.2.60 github.com/swaggest/openapi-go v0.2.60
github.com/valyala/fasthttp v1.66.0 github.com/valyala/fasthttp v1.66.0
github.com/xuri/excelize/v2 v2.8.1 github.com/xuri/excelize/v2 v2.8.1
@@ -25,7 +24,6 @@ require (
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/datatypes v1.2.7 gorm.io/datatypes v1.2.7
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )
@@ -40,7 +38,6 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.6 // 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // 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-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // 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/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/pkg/errors v0.9.1 // 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/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/rivo/uniseg v0.2.0 // 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/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // 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/subosito/gotenv v1.6.0 // indirect
github.com/swaggest/jsonschema-go v0.3.74 // indirect github.com/swaggest/jsonschema-go v0.3.74 // indirect
github.com/swaggest/refl v1.3.1 // indirect github.com/swaggest/refl v1.3.1 // indirect

1
go.sum
View File

@@ -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.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.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.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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=

View File

@@ -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), 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), 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), 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), ShopCommission: shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionRecord),
CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.Wallet, s.WalletTransaction, s.CommissionWithdrawalRequest), CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest),
CommissionWithdrawalSetting: commissionWithdrawalSettingSvc.New(deps.DB, s.Account, s.CommissionWithdrawalSetting), CommissionWithdrawalSetting: commissionWithdrawalSettingSvc.New(deps.DB, s.Account, s.CommissionWithdrawalSetting),
CommissionCalculation: commissionCalculationSvc.New( CommissionCalculation: commissionCalculationSvc.New(
deps.DB, deps.DB,
@@ -110,8 +110,8 @@ func initServices(s *stores, deps *Dependencies) *services {
s.PackageSeries, s.PackageSeries,
s.IotCard, s.IotCard,
s.Device, s.Device,
s.Wallet, s.AgentWallet,
s.WalletTransaction, s.AgentWalletTransaction,
s.Order, s.Order,
s.OrderItem, s.OrderItem,
s.Package, s.Package,
@@ -123,7 +123,7 @@ func initServices(s *stores, deps *Dependencies) *services {
EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization), 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), 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), 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, IotCard: iotCard,
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient), 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), 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), ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop),
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats), CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
PurchaseValidation: purchaseValidation, 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), 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.Recharge, s.Wallet, s.WalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, 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), PollingConfig: pollingSvc.NewConfigService(s.PollingConfig),
PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis), PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis),
PollingMonitoring: pollingSvc.NewMonitoringService(deps.Redis), PollingMonitoring: pollingSvc.NewMonitoringService(deps.Redis),

View File

@@ -15,10 +15,8 @@ type stores struct {
RolePermission *postgres.RolePermissionStore RolePermission *postgres.RolePermissionStore
PersonalCustomer *postgres.PersonalCustomerStore PersonalCustomer *postgres.PersonalCustomerStore
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
Wallet *postgres.WalletStore
CommissionWithdrawalRequest *postgres.CommissionWithdrawalRequestStore CommissionWithdrawalRequest *postgres.CommissionWithdrawalRequestStore
CommissionRecord *postgres.CommissionRecordStore CommissionRecord *postgres.CommissionRecordStore
WalletTransaction *postgres.WalletTransactionStore
CommissionWithdrawalSetting *postgres.CommissionWithdrawalSettingStore CommissionWithdrawalSetting *postgres.CommissionWithdrawalSettingStore
Enterprise *postgres.EnterpriseStore Enterprise *postgres.EnterpriseStore
EnterpriseCardAuthorization *postgres.EnterpriseCardAuthorizationStore EnterpriseCardAuthorization *postgres.EnterpriseCardAuthorizationStore
@@ -40,7 +38,6 @@ type stores struct {
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
Order *postgres.OrderStore Order *postgres.OrderStore
OrderItem *postgres.OrderItemStore OrderItem *postgres.OrderItemStore
Recharge *postgres.RechargeStore
PollingConfig *postgres.PollingConfigStore PollingConfig *postgres.PollingConfigStore
PollingConcurrencyConfig *postgres.PollingConcurrencyConfigStore PollingConcurrencyConfig *postgres.PollingConcurrencyConfigStore
PollingAlertRule *postgres.PollingAlertRuleStore PollingAlertRule *postgres.PollingAlertRuleStore
@@ -48,6 +45,14 @@ type stores struct {
DataCleanupConfig *postgres.DataCleanupConfigStore DataCleanupConfig *postgres.DataCleanupConfigStore
DataCleanupLog *postgres.DataCleanupLogStore DataCleanupLog *postgres.DataCleanupLogStore
PollingManualTriggerLog *postgres.PollingManualTriggerLogStore 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 { func initStores(deps *Dependencies) *stores {
@@ -62,10 +67,8 @@ func initStores(deps *Dependencies) *stores {
RolePermission: postgres.NewRolePermissionStore(deps.DB, deps.Redis), RolePermission: postgres.NewRolePermissionStore(deps.DB, deps.Redis),
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis), PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB), PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
Wallet: postgres.NewWalletStore(deps.DB, deps.Redis),
CommissionWithdrawalRequest: postgres.NewCommissionWithdrawalRequestStore(deps.DB, deps.Redis), CommissionWithdrawalRequest: postgres.NewCommissionWithdrawalRequestStore(deps.DB, deps.Redis),
CommissionRecord: postgres.NewCommissionRecordStore(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), CommissionWithdrawalSetting: postgres.NewCommissionWithdrawalSettingStore(deps.DB, deps.Redis),
Enterprise: postgres.NewEnterpriseStore(deps.DB, deps.Redis), Enterprise: postgres.NewEnterpriseStore(deps.DB, deps.Redis),
EnterpriseCardAuthorization: postgres.NewEnterpriseCardAuthorizationStore(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), ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
Order: postgres.NewOrderStore(deps.DB, deps.Redis), Order: postgres.NewOrderStore(deps.DB, deps.Redis),
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis), OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
Recharge: postgres.NewRechargeStore(deps.DB, deps.Redis),
PollingConfig: postgres.NewPollingConfigStore(deps.DB), PollingConfig: postgres.NewPollingConfigStore(deps.DB),
PollingConcurrencyConfig: postgres.NewPollingConcurrencyConfigStore(deps.DB), PollingConcurrencyConfig: postgres.NewPollingConcurrencyConfigStore(deps.DB),
PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB), PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB),
@@ -95,5 +97,13 @@ func initStores(deps *Dependencies) *stores {
DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB), DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB),
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB), DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
PollingManualTriggerLog: postgres.NewPollingManualTriggerLogStore(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),
} }
} }

View File

@@ -30,8 +30,8 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stores.PackageSeries, stores.PackageSeries,
stores.IotCard, stores.IotCard,
stores.Device, stores.Device,
stores.Wallet, stores.AgentWallet,
stores.WalletTransaction, stores.AgentWalletTransaction,
stores.Order, stores.Order,
stores.OrderItem, stores.OrderItem,
stores.Package, stores.Package,

View File

@@ -17,8 +17,6 @@ type workerStores struct {
Shop *postgres.ShopStore Shop *postgres.ShopStore
ShopSeriesAllocation *postgres.ShopSeriesAllocationStore ShopSeriesAllocation *postgres.ShopSeriesAllocationStore
PackageSeries *postgres.PackageSeriesStore PackageSeries *postgres.PackageSeriesStore
Wallet *postgres.WalletStore
WalletTransaction *postgres.WalletTransactionStore
Order *postgres.OrderStore Order *postgres.OrderStore
OrderItem *postgres.OrderItemStore OrderItem *postgres.OrderItemStore
Package *postgres.PackageStore Package *postgres.PackageStore
@@ -28,6 +26,8 @@ type workerStores struct {
PollingAlertHistory *postgres.PollingAlertHistoryStore PollingAlertHistory *postgres.PollingAlertHistoryStore
DataCleanupConfig *postgres.DataCleanupConfigStore DataCleanupConfig *postgres.DataCleanupConfigStore
DataCleanupLog *postgres.DataCleanupLogStore DataCleanupLog *postgres.DataCleanupLogStore
AgentWallet *postgres.AgentWalletStore
AgentWalletTransaction *postgres.AgentWalletTransactionStore
} }
func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores { func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
@@ -43,8 +43,6 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
Shop: postgres.NewShopStore(deps.DB, deps.Redis), Shop: postgres.NewShopStore(deps.DB, deps.Redis),
ShopSeriesAllocation: postgres.NewShopSeriesAllocationStore(deps.DB), ShopSeriesAllocation: postgres.NewShopSeriesAllocationStore(deps.DB),
PackageSeries: postgres.NewPackageSeriesStore(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), Order: postgres.NewOrderStore(deps.DB, deps.Redis),
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis), OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
Package: postgres.NewPackageStore(deps.DB), Package: postgres.NewPackageStore(deps.DB),
@@ -54,6 +52,8 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
PollingAlertHistory: postgres.NewPollingAlertHistoryStore(deps.DB), PollingAlertHistory: postgres.NewPollingAlertHistoryStore(deps.DB),
DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB), DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB),
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB), DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
} }
return &queue.WorkerStores{ return &queue.WorkerStores{
@@ -68,8 +68,6 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
Shop: stores.Shop, Shop: stores.Shop,
ShopSeriesAllocation: stores.ShopSeriesAllocation, ShopSeriesAllocation: stores.ShopSeriesAllocation,
PackageSeries: stores.PackageSeries, PackageSeries: stores.PackageSeries,
Wallet: stores.Wallet,
WalletTransaction: stores.WalletTransaction,
Order: stores.Order, Order: stores.Order,
OrderItem: stores.OrderItem, OrderItem: stores.OrderItem,
Package: stores.Package, Package: stores.Package,
@@ -79,5 +77,7 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
PollingAlertHistory: stores.PollingAlertHistory, PollingAlertHistory: stores.PollingAlertHistory,
DataCleanupConfig: stores.DataCleanupConfig, DataCleanupConfig: stores.DataCleanupConfig,
DataCleanupLog: stores.DataCleanupLog, DataCleanupLog: stores.DataCleanupLog,
AgentWallet: stores.AgentWallet,
AgentWalletTransaction: stores.AgentWalletTransaction,
} }
} }

View File

@@ -3,11 +3,11 @@ package admin
import ( import (
"github.com/gofiber/fiber/v2" "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"
"github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/service/polling" "github.com/break/junhong_cmp_fiber/internal/service/polling"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response" "github.com/break/junhong_cmp_fiber/pkg/response"
) )

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -20,24 +20,24 @@ type ListStandaloneIotCardRequest struct {
} }
type StandaloneIotCardResponse struct { type StandaloneIotCardResponse struct {
ID uint `json:"id" description:"卡ID"` ID uint `json:"id" description:"卡ID"`
ICCID string `json:"iccid" description:"ICCID"` ICCID string `json:"iccid" description:"ICCID"`
CardCategory string `json:"card_category" description:"卡业务类型 (normal:普通卡, industry:行业卡)"` CardCategory string `json:"card_category" description:"卡业务类型 (normal:普通卡, industry:行业卡)"`
CarrierID uint `json:"carrier_id" description:"运营商ID"` CarrierID uint `json:"carrier_id" description:"运营商ID"`
CarrierType string `json:"carrier_type,omitempty" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"` CarrierType string `json:"carrier_type,omitempty" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"` CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"`
IMSI string `json:"imsi,omitempty" description:"IMSI"` IMSI string `json:"imsi,omitempty" description:"IMSI"`
MSISDN string `json:"msisdn,omitempty" description:"卡接入号"` MSISDN string `json:"msisdn,omitempty" description:"卡接入号"`
BatchNo string `json:"batch_no,omitempty" description:"批次号"` BatchNo string `json:"batch_no,omitempty" description:"批次号"`
Supplier string `json:"supplier,omitempty" description:"供应商"` Supplier string `json:"supplier,omitempty" description:"供应商"`
CostPrice int64 `json:"cost_price" description:"成本价(分)"` CostPrice int64 `json:"cost_price" description:"成本价(分)"`
DistributePrice int64 `json:"distribute_price" description:"分销价(分)"` DistributePrice int64 `json:"distribute_price" description:"分销价(分)"`
Status int `json:"status" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"` Status int `json:"status" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"`
ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"` ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"`
ShopName string `json:"shop_name,omitempty" description:"店铺名称"` ShopName string `json:"shop_name,omitempty" description:"店铺名称"`
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"` ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
ActivationStatus int `json:"activation_status" description:"激活状态 (0:未激活, 1:已激活)"` ActivationStatus int `json:"activation_status" description:"激活状态 (0:未激活, 1:已激活)"`
RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"` RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"`
NetworkStatus int `json:"network_status" description:"网络状态 (0:停机, 1:开机)"` NetworkStatus int `json:"network_status" description:"网络状态 (0:停机, 1:开机)"`
DataUsageMB int64 `json:"data_usage_mb" description:"累计流量使用(MB)"` DataUsageMB int64 `json:"data_usage_mb" description:"累计流量使用(MB)"`
CurrentMonthUsageMB float64 `json:"current_month_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:"最后实名检查时间"` LastRealNameCheckAt *time.Time `json:"last_real_name_check_at,omitempty" description:"最后实名检查时间"`
EnablePolling bool `json:"enable_polling" description:"是否参与轮询"` EnablePolling bool `json:"enable_polling" description:"是否参与轮询"`
SeriesID *uint `json:"series_id,omitempty" description:"套餐系列ID"` SeriesID *uint `json:"series_id,omitempty" description:"套餐系列ID"`
SeriesName string `json:"series_name,omitempty" description:"套餐系列名称"` SeriesName string `json:"series_name,omitempty" description:"套餐系列名称"`
FirstCommissionPaid bool `json:"first_commission_paid" description:"一次性佣金是否已发放"` FirstCommissionPaid bool `json:"first_commission_paid" description:"一次性佣金是否已发放"`
AccumulatedRecharge int64 `json:"accumulated_recharge" description:"累计充值金额(分)"` AccumulatedRecharge int64 `json:"accumulated_recharge" description:"累计充值金额(分)"`
CreatedAt time.Time `json:"created_at" description:"创建时间"` CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"` UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
} }
type ListStandaloneIotCardResponse struct { type ListStandaloneIotCardResponse struct {

View File

@@ -26,15 +26,15 @@ type UpdateDataCleanupConfigParams struct {
// DataCleanupConfigResp 数据清理配置响应 // DataCleanupConfigResp 数据清理配置响应
type DataCleanupConfigResp struct { type DataCleanupConfigResp struct {
ID uint `json:"id" description:"配置ID"` ID uint `json:"id" description:"配置ID"`
TargetTable string `json:"table_name" description:"表名"` TargetTable string `json:"table_name" description:"表名"`
RetentionDays int `json:"retention_days" description:"保留天数"` RetentionDays int `json:"retention_days" description:"保留天数"`
BatchSize int `json:"batch_size" description:"每批删除条数"` BatchSize int `json:"batch_size" description:"每批删除条数"`
Enabled int `json:"enabled" description:"是否启用0-禁用1-启用"` Enabled int `json:"enabled" description:"是否启用0-禁用1-启用"`
Description string `json:"description" description:"配置说明"` Description string `json:"description" description:"配置说明"`
CreatedAt time.Time `json:"created_at" description:"创建时间"` CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"` UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
UpdatedBy *uint `json:"updated_by,omitempty" description:"更新人ID"` UpdatedBy *uint `json:"updated_by,omitempty" description:"更新人ID"`
} }
// DataCleanupConfigListResp 数据清理配置列表响应 // DataCleanupConfigListResp 数据清理配置列表响应

View File

@@ -57,27 +57,27 @@ func (Package) TableName() string {
// 跟踪单卡套餐和设备级套餐的流量使用 // 跟踪单卡套餐和设备级套餐的流量使用
type PackageUsage struct { type PackageUsage struct {
gorm.Model gorm.Model
BaseModel `gorm:"embedded"` BaseModel `gorm:"embedded"`
OrderID uint `gorm:"column:order_id;index;not null;comment:订单ID" json:"order_id"` 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"` 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"` 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"` 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"` 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"` 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"` 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"` 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"` 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"` 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"` 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"` 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"` 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"` 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"` 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"` 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"` 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"` 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"` 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"` NextResetAt *time.Time `gorm:"column:next_reset_at;index:idx_package_usage_next_reset_at;comment:下次流量重置时间(用于定时任务查询)" json:"next_reset_at"`
} }
// TableName 指定表名 // TableName 指定表名
@@ -88,13 +88,13 @@ func (PackageUsage) TableName() string {
// PackageUsageDailyRecord 套餐流量日记录模型 // PackageUsageDailyRecord 套餐流量日记录模型
// 记录每个套餐每天的流量使用情况,用于流量详单查询 // 记录每个套餐每天的流量使用情况,用于流量详单查询
type PackageUsageDailyRecord struct { type PackageUsageDailyRecord struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` 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"` 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"` 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"` 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"` 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"` 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"` UpdatedAt time.Time `gorm:"column:updated_at;default:CURRENT_TIMESTAMP" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名

View File

@@ -6,21 +6,21 @@ import (
// PollingConfig 轮询配置表 // PollingConfig 轮询配置表
type PollingConfig struct { type PollingConfig struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
ConfigName string `gorm:"column:config_name;type:varchar(100);not null;comment:配置名称" json:"config_name"` 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"` 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"` 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"` CarrierID *uint `gorm:"column:carrier_id;comment:运营商ID可选精确匹配" json:"carrier_id"`
Priority int `gorm:"column:priority;not null;default:100;comment:优先级(数字越小优先级越高)" json:"priority"` 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"` 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"` 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"` 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"` 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"` 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"` 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"` 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"` CreatedBy *uint `gorm:"column:created_by;comment:创建人ID" json:"created_by"`
UpdatedBy *uint `gorm:"column:updated_by;comment:更新人ID" json:"updated_by"` UpdatedBy *uint `gorm:"column:updated_by;comment:更新人ID" json:"updated_by"`
} }
// TableName 指定表名 // TableName 指定表名

View File

@@ -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"
}

View File

@@ -19,18 +19,18 @@ import (
// PackageActivationHandler 套餐激活检查处理器 // PackageActivationHandler 套餐激活检查处理器
// 任务 19: 处理主套餐过期、加油包级联失效、待生效主套餐激活 // 任务 19: 处理主套餐过期、加油包级联失效、待生效主套餐激活
type PackageActivationHandler struct { type PackageActivationHandler struct {
db *gorm.DB db *gorm.DB
redis *redis.Client redis *redis.Client
queueClient *asynq.Client queueClient *asynq.Client
packageUsageStore *postgres.PackageUsageStore packageUsageStore *postgres.PackageUsageStore
activationService *packagepkg.ActivationService activationService *packagepkg.ActivationService
logger *zap.Logger logger *zap.Logger
} }
// PackageActivationPayload 套餐激活任务载荷 // PackageActivationPayload 套餐激活任务载荷
type PackageActivationPayload struct { type PackageActivationPayload struct {
PackageUsageID uint `json:"package_usage_id"` 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"` CarrierID uint `json:"carrier_id"`
ActivationType string `json:"activation_type"` // "queue" 或 "realname" ActivationType string `json:"activation_type"` // "queue" 或 "realname"
Timestamp int64 `json:"timestamp"` Timestamp int64 `json:"timestamp"`
@@ -102,7 +102,7 @@ func (h *PackageActivationHandler) findExpiredMainPackages(ctx context.Context)
Where("status = ?", constants.PackageUsageStatusActive). Where("status = ?", constants.PackageUsageStatusActive).
Where("expires_at <= ?", now). Where("expires_at <= ?", now).
Where("master_usage_id IS NULL"). // 主套餐没有 master_usage_id Where("master_usage_id IS NULL"). // 主套餐没有 master_usage_id
Limit(1000). // 每次最多处理 1000 个,避免长事务 Limit(1000). // 每次最多处理 1000 个,避免长事务
Find(&packages).Error Find(&packages).Error
return packages, err return packages, err
@@ -353,9 +353,9 @@ func (h *PackageActivationHandler) HandlePackageFirstActivation(ctx context.Cont
// ActivationService 未注入,直接更新状态(备用逻辑) // ActivationService 未注入,直接更新状态(备用逻辑)
now := time.Now() now := time.Now()
if err := h.db.Model(&pkg).Updates(map[string]any{ if err := h.db.Model(&pkg).Updates(map[string]any{
"status": constants.PackageUsageStatusActive, "status": constants.PackageUsageStatusActive,
"activated_at": now, "activated_at": now,
"pending_realname_activation": false, "pending_realname_activation": false,
}).Error; err != nil { }).Error; err != nil {
return err return err
} }

View File

@@ -20,13 +20,13 @@ import (
// Scheduler 轮询调度器 // Scheduler 轮询调度器
// 负责管理 IoT 卡的定期检查任务(实名、流量、套餐) // 负责管理 IoT 卡的定期检查任务(实名、流量、套餐)
type Scheduler struct { type Scheduler struct {
db *gorm.DB db *gorm.DB
redis *redis.Client redis *redis.Client
queueClient *asynq.Client queueClient *asynq.Client
logger *zap.Logger logger *zap.Logger
configStore *postgres.PollingConfigStore configStore *postgres.PollingConfigStore
iotCardStore *postgres.IotCardStore iotCardStore *postgres.IotCardStore
concurrencyStore *postgres.PollingConcurrencyConfigStore concurrencyStore *postgres.PollingConcurrencyConfigStore
// 任务 19: 套餐激活检查处理器 // 任务 19: 套餐激活检查处理器
packageActivationHandler *PackageActivationHandler packageActivationHandler *PackageActivationHandler
@@ -50,12 +50,12 @@ type Scheduler struct {
// InitProgress 初始化进度 // InitProgress 初始化进度
type InitProgress struct { type InitProgress struct {
mu sync.RWMutex mu sync.RWMutex
TotalCards int64 `json:"total_cards"` // 总卡数 TotalCards int64 `json:"total_cards"` // 总卡数
LoadedCards int64 `json:"loaded_cards"` // 已加载卡数 LoadedCards int64 `json:"loaded_cards"` // 已加载卡数
StartTime time.Time `json:"start_time"` // 开始时间 StartTime time.Time `json:"start_time"` // 开始时间
LastBatchTime time.Time `json:"last_batch_time"` // 最后一批处理时间 LastBatchTime time.Time `json:"last_batch_time"` // 最后一批处理时间
Status string `json:"status"` // 状态: pending, running, completed, failed Status string `json:"status"` // 状态: pending, running, completed, failed
ErrorMessage string `json:"error_message"` // 错误信息 ErrorMessage string `json:"error_message"` // 错误信息
} }
// SchedulerConfig 调度器配置 // SchedulerConfig 调度器配置
@@ -74,13 +74,13 @@ type SchedulerConfig struct {
// 单 Worker 设计吞吐50000 张/秒,支持多 Worker 水平扩展 // 单 Worker 设计吞吐50000 张/秒,支持多 Worker 水平扩展
func DefaultSchedulerConfig() *SchedulerConfig { func DefaultSchedulerConfig() *SchedulerConfig {
return &SchedulerConfig{ return &SchedulerConfig{
ScheduleInterval: 1 * time.Second, // 1秒调度一次提高响应速度 ScheduleInterval: 1 * time.Second, // 1秒调度一次提高响应速度
InitBatchSize: 100000, // 10万张/批初始化 InitBatchSize: 100000, // 10万张/批初始化
InitBatchSleepDuration: 500 * time.Millisecond, // 500ms 间隔,加快初始化 InitBatchSleepDuration: 500 * time.Millisecond, // 500ms 间隔,加快初始化
ConfigCacheTTL: 5 * time.Minute, ConfigCacheTTL: 5 * time.Minute,
CardCacheTTL: 7 * 24 * time.Hour, CardCacheTTL: 7 * 24 * time.Hour,
ScheduleBatchSize: 50000, // 每次取 5 万张,每秒可调度 5 万张 ScheduleBatchSize: 50000, // 每次取 5 万张,每秒可调度 5 万张
MaxManualBatchSize: 1000, // 手动触发每次处理 1000 张 MaxManualBatchSize: 1000, // 手动触发每次处理 1000 张
} }
} }
@@ -383,8 +383,8 @@ func (s *Scheduler) enqueueTask(ctx context.Context, taskType, cardID string, is
} }
task := asynq.NewTask(taskType, mustMarshal(payload), task := asynq.NewTask(taskType, mustMarshal(payload),
asynq.MaxRetry(0), // 不重试,失败后重新入队 asynq.MaxRetry(0), // 不重试,失败后重新入队
asynq.Timeout(30*time.Second), // 30秒超时 asynq.Timeout(30*time.Second), // 30秒超时
asynq.Queue(constants.QueueDefault), asynq.Queue(constants.QueueDefault),
) )
@@ -681,13 +681,13 @@ func (s *Scheduler) cacheCardInfo(ctx context.Context, card *model.IotCard, cach
config := DefaultSchedulerConfig() config := DefaultSchedulerConfig()
data := map[string]interface{}{ data := map[string]interface{}{
"id": card.ID, "id": card.ID,
"iccid": card.ICCID, "iccid": card.ICCID,
"card_category": card.CardCategory, "card_category": card.CardCategory,
"real_name_status": card.RealNameStatus, "real_name_status": card.RealNameStatus,
"network_status": card.NetworkStatus, "network_status": card.NetworkStatus,
"carrier_id": card.CarrierID, "carrier_id": card.CarrierID,
"cached_at": cachedAt.Unix(), "cached_at": cachedAt.Unix(),
} }
pipe := s.redis.Pipeline() pipe := s.redis.Pipeline()

View File

@@ -14,22 +14,22 @@ import (
) )
type Service struct { type Service struct {
db *gorm.DB db *gorm.DB
commissionRecordStore *postgres.CommissionRecordStore commissionRecordStore *postgres.CommissionRecordStore
shopStore *postgres.ShopStore shopStore *postgres.ShopStore
shopPackageAllocationStore *postgres.ShopPackageAllocationStore shopPackageAllocationStore *postgres.ShopPackageAllocationStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
packageSeriesStore *postgres.PackageSeriesStore packageSeriesStore *postgres.PackageSeriesStore
iotCardStore *postgres.IotCardStore iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore deviceStore *postgres.DeviceStore
walletStore *postgres.WalletStore agentWalletStore *postgres.AgentWalletStore
walletTransactionStore *postgres.WalletTransactionStore agentWalletTransactionStore *postgres.AgentWalletTransactionStore
orderStore *postgres.OrderStore orderStore *postgres.OrderStore
orderItemStore *postgres.OrderItemStore orderItemStore *postgres.OrderItemStore
packageStore *postgres.PackageStore packageStore *postgres.PackageStore
commissionStatsStore *postgres.ShopSeriesCommissionStatsStore commissionStatsStore *postgres.ShopSeriesCommissionStatsStore
commissionStatsService *commission_stats.Service commissionStatsService *commission_stats.Service
logger *zap.Logger logger *zap.Logger
} }
func New( func New(
@@ -41,8 +41,8 @@ func New(
packageSeriesStore *postgres.PackageSeriesStore, packageSeriesStore *postgres.PackageSeriesStore,
iotCardStore *postgres.IotCardStore, iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore, deviceStore *postgres.DeviceStore,
walletStore *postgres.WalletStore, agentWalletStore *postgres.AgentWalletStore,
walletTransactionStore *postgres.WalletTransactionStore, agentWalletTransactionStore *postgres.AgentWalletTransactionStore,
orderStore *postgres.OrderStore, orderStore *postgres.OrderStore,
orderItemStore *postgres.OrderItemStore, orderItemStore *postgres.OrderItemStore,
packageStore *postgres.PackageStore, packageStore *postgres.PackageStore,
@@ -51,22 +51,22 @@ func New(
logger *zap.Logger, logger *zap.Logger,
) *Service { ) *Service {
return &Service{ return &Service{
db: db, db: db,
commissionRecordStore: commissionRecordStore, commissionRecordStore: commissionRecordStore,
shopStore: shopStore, shopStore: shopStore,
shopPackageAllocationStore: shopPackageAllocationStore, shopPackageAllocationStore: shopPackageAllocationStore,
shopSeriesAllocationStore: shopSeriesAllocationStore, shopSeriesAllocationStore: shopSeriesAllocationStore,
packageSeriesStore: packageSeriesStore, packageSeriesStore: packageSeriesStore,
iotCardStore: iotCardStore, iotCardStore: iotCardStore,
deviceStore: deviceStore, deviceStore: deviceStore,
walletStore: walletStore, agentWalletStore: agentWalletStore,
walletTransactionStore: walletTransactionStore, agentWalletTransactionStore: agentWalletTransactionStore,
orderStore: orderStore, orderStore: orderStore,
orderItemStore: orderItemStore, orderItemStore: orderItemStore,
packageStore: packageStore, packageStore: packageStore,
commissionStatsStore: commissionStatsStore, commissionStatsStore: commissionStatsStore,
commissionStatsService: commissionStatsService, commissionStatsService: commissionStatsService,
logger: logger, 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 { 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 { var wallet model.AgentWallet
return errors.Wrap(errors.CodeDatabaseError, err, "获取店铺钱包失败") 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 balanceBefore := wallet.Balance
result := tx.Model(&model.Wallet{}).
Where("id = ? AND version = ?", wallet.ID, wallet.Version). // 使用 AgentWalletStore 的方法增加余额(带事务)
Updates(map[string]any{ if err := s.agentWalletStore.AddBalanceWithTx(ctx, tx, wallet.ID, record.Amount); err != nil {
"balance": gorm.Expr("balance + ?", record.Amount), return errors.Wrap(errors.CodeInternalError, err, "更新钱包余额失败")
"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, "钱包版本冲突,请重试")
} }
// 更新佣金记录状态
now := time.Now() now := time.Now()
if err := tx.Model(record).Updates(map[string]any{ if err := tx.Model(record).Updates(map[string]any{
"balance_after": balanceBefore + record.Amount, "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, "更新佣金记录失败") return errors.Wrap(errors.CodeDatabaseError, err, "更新佣金记录失败")
} }
// 创建代理钱包交易记录
remark := "佣金入账" remark := "佣金入账"
transaction := &model.WalletTransaction{ transaction := &model.AgentWalletTransaction{
WalletID: wallet.ID, AgentWalletID: wallet.ID,
ShopID: record.ShopID,
UserID: record.Creator, UserID: record.Creator,
TransactionType: "commission", TransactionType: "commission",
Amount: record.Amount, Amount: record.Amount,
@@ -603,8 +600,9 @@ func (s *Service) creditCommissionInTx(ctx context.Context, tx *gorm.DB, record
ReferenceID: &record.ID, ReferenceID: &record.ID,
Remark: &remark, Remark: &remark,
Creator: record.Creator, 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, "创建钱包交易记录失败") return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败")
} }

View File

@@ -19,8 +19,8 @@ type Service struct {
db *gorm.DB db *gorm.DB
shopStore *postgres.ShopStore shopStore *postgres.ShopStore
accountStore *postgres.AccountStore accountStore *postgres.AccountStore
walletStore *postgres.WalletStore agentWalletStore *postgres.AgentWalletStore
walletTransactionStore *postgres.WalletTransactionStore agentWalletTransactionStore *postgres.AgentWalletTransactionStore
commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore
} }
@@ -28,16 +28,16 @@ func New(
db *gorm.DB, db *gorm.DB,
shopStore *postgres.ShopStore, shopStore *postgres.ShopStore,
accountStore *postgres.AccountStore, accountStore *postgres.AccountStore,
walletStore *postgres.WalletStore, agentWalletStore *postgres.AgentWalletStore,
walletTransactionStore *postgres.WalletTransactionStore, agentWalletTransactionStore *postgres.AgentWalletTransactionStore,
commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore, commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore,
) *Service { ) *Service {
return &Service{ return &Service{
db: db, db: db,
shopStore: shopStore, shopStore: shopStore,
accountStore: accountStore, accountStore: accountStore,
walletStore: walletStore, agentWalletStore: agentWalletStore,
walletTransactionStore: walletTransactionStore, agentWalletTransactionStore: agentWalletTransactionStore,
commissionWithdrawalReqStore: commissionWithdrawalReqStore, commissionWithdrawalReqStore: commissionWithdrawalReqStore,
} }
} }
@@ -157,7 +157,8 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveWithdraw
return nil, errors.New(errors.CodeInvalidStatus, "申请状态不允许此操作") 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 { if err != nil {
return nil, errors.New(errors.CodeNotFound, "店铺佣金钱包不存在") 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() now := time.Now()
err = s.db.Transaction(func(tx *gorm.DB) error { 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, "扣除冻结余额失败") return errors.Wrap(errors.CodeInternalError, err, "扣除冻结余额失败")
} }
// 创建代理钱包交易记录
refType := "withdrawal" refType := "withdrawal"
refID := withdrawal.ID refID := withdrawal.ID
transaction := &model.WalletTransaction{ transaction := &model.AgentWalletTransaction{
WalletID: wallet.ID, AgentWalletID: wallet.ID,
ShopID: withdrawal.ShopID,
UserID: currentUserID, UserID: currentUserID,
TransactionType: "withdrawal", TransactionType: "withdrawal",
Amount: -amount, Amount: -amount,
@@ -190,8 +194,9 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveWithdraw
ReferenceType: &refType, ReferenceType: &refType,
ReferenceID: &refID, ReferenceID: &refID,
Creator: currentUserID, 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, "创建交易流水失败") 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, "申请状态不允许此操作") 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 { if err != nil {
return nil, errors.New(errors.CodeNotFound, "店铺佣金钱包不存在") return nil, errors.New(errors.CodeNotFound, "店铺佣金钱包不存在")
} }
now := time.Now() now := time.Now()
err = s.db.Transaction(func(tx *gorm.DB) error { 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, "解冻余额失败") return errors.Wrap(errors.CodeInternalError, err, "解冻余额失败")
} }
refType := "withdrawal" refType := "withdrawal"
refID := withdrawal.ID refID := withdrawal.ID
transaction := &model.WalletTransaction{ transaction := &model.AgentWalletTransaction{
WalletID: wallet.ID, AgentWalletID: wallet.ID,
ShopID: withdrawal.ShopID,
UserID: currentUserID, UserID: currentUserID,
TransactionType: "refund", TransactionType: "refund",
Amount: withdrawal.Amount, Amount: withdrawal.Amount,
@@ -289,8 +295,9 @@ func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectWithdrawal
ReferenceType: &refType, ReferenceType: &refType,
ReferenceID: &refID, ReferenceID: &refID,
Creator: currentUserID, 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, "创建交易流水失败") return errors.Wrap(errors.CodeInternalError, err, "创建交易流水失败")
} }

View File

@@ -42,7 +42,7 @@ func NewStopResumeService(
iotCardStore: iotCardStore, iotCardStore: iotCardStore,
gatewayClient: gatewayClient, gatewayClient: gatewayClient,
logger: logger, logger: logger,
maxRetries: 3, // 默认最多重试 3 次 maxRetries: 3, // 默认最多重试 3 次
retryInterval: 2 * time.Second, // 默认重试间隔 2 秒 retryInterval: 2 * time.Second, // 默认重试间隔 2 秒
} }
} }

View File

@@ -19,30 +19,30 @@ import (
type Service struct { type Service struct {
db *gorm.DB db *gorm.DB
shopStore *postgres.ShopStore shopStore *postgres.ShopStore
walletStore *postgres.WalletStore agentWalletStore *postgres.AgentWalletStore
commissionWithdrawalRequestStore *postgres.CommissionWithdrawalRequestStore commissionWithdrawalRequestStore *postgres.CommissionWithdrawalRequestStore
commissionWithdrawalSettingStore *postgres.CommissionWithdrawalSettingStore commissionWithdrawalSettingStore *postgres.CommissionWithdrawalSettingStore
commissionRecordStore *postgres.CommissionRecordStore commissionRecordStore *postgres.CommissionRecordStore
walletTransactionStore *postgres.WalletTransactionStore agentWalletTransactionStore *postgres.AgentWalletTransactionStore
} }
func New( func New(
db *gorm.DB, db *gorm.DB,
shopStore *postgres.ShopStore, shopStore *postgres.ShopStore,
walletStore *postgres.WalletStore, agentWalletStore *postgres.AgentWalletStore,
commissionWithdrawalRequestStore *postgres.CommissionWithdrawalRequestStore, commissionWithdrawalRequestStore *postgres.CommissionWithdrawalRequestStore,
commissionWithdrawalSettingStore *postgres.CommissionWithdrawalSettingStore, commissionWithdrawalSettingStore *postgres.CommissionWithdrawalSettingStore,
commissionRecordStore *postgres.CommissionRecordStore, commissionRecordStore *postgres.CommissionRecordStore,
walletTransactionStore *postgres.WalletTransactionStore, agentWalletTransactionStore *postgres.AgentWalletTransactionStore,
) *Service { ) *Service {
return &Service{ return &Service{
db: db, db: db,
shopStore: shopStore, shopStore: shopStore,
walletStore: walletStore, agentWalletStore: agentWalletStore,
commissionWithdrawalRequestStore: commissionWithdrawalRequestStore, commissionWithdrawalRequestStore: commissionWithdrawalRequestStore,
commissionWithdrawalSettingStore: commissionWithdrawalSettingStore, commissionWithdrawalSettingStore: commissionWithdrawalSettingStore,
commissionRecordStore: commissionRecordStore, 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, "店铺不存在") return nil, errors.New(errors.CodeShopNotFound, "店铺不存在")
} }
// 使用 GetShopCommissionWallet 获取店铺佣金钱包 // 使用 GetCommissionWallet 获取店铺佣金钱包
wallet, err := s.walletStore.GetShopCommissionWallet(ctx, shopID) wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, shopID)
if err != nil { if err != nil {
// 钱包不存在时返回空数据 // 钱包不存在时返回空数据
return &dto.MyCommissionSummaryResp{ 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 { if err != nil {
return nil, errors.New(errors.CodeInsufficientBalance, "钱包不存在") 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 { 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). Where("id = ? AND balance >= ?", wallet.ID, req.Amount).
Updates(map[string]interface{}{ Updates(map[string]interface{}{
"balance": gorm.Expr("balance - ?", req.Amount), "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) remark := fmt.Sprintf("提现冻结,单号:%s", withdrawalNo)
refType := constants.ReferenceTypeWithdrawal refType := constants.ReferenceTypeWithdrawal
transaction := &model.WalletTransaction{ transaction := &model.AgentWalletTransaction{
WalletID: wallet.ID, AgentWalletID: wallet.ID,
ShopID: shopID,
UserID: currentUserID, UserID: currentUserID,
TransactionType: constants.TransactionTypeWithdrawal, TransactionType: constants.TransactionTypeWithdrawal,
Amount: -req.Amount, // 冻结为负数 Amount: -req.Amount, // 冻结为负数
@@ -204,6 +205,7 @@ func (s *Service) CreateWithdrawalRequest(ctx context.Context, req *dto.CreateMy
ReferenceID: &withdrawalRequest.ID, ReferenceID: &withdrawalRequest.ID,
Remark: &remark, Remark: &remark,
Creator: currentUserID, Creator: currentUserID,
ShopIDTag: shopID,
} }
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil { if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {

View File

@@ -30,7 +30,8 @@ type Service struct {
redis *redis.Client redis *redis.Client
orderStore *postgres.OrderStore orderStore *postgres.OrderStore
orderItemStore *postgres.OrderItemStore orderItemStore *postgres.OrderItemStore
walletStore *postgres.WalletStore agentWalletStore *postgres.AgentWalletStore
cardWalletStore *postgres.CardWalletStore
purchaseValidationService *purchase_validation.Service purchaseValidationService *purchase_validation.Service
shopPackageAllocationStore *postgres.ShopPackageAllocationStore shopPackageAllocationStore *postgres.ShopPackageAllocationStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
@@ -49,7 +50,8 @@ func New(
redisClient *redis.Client, redisClient *redis.Client,
orderStore *postgres.OrderStore, orderStore *postgres.OrderStore,
orderItemStore *postgres.OrderItemStore, orderItemStore *postgres.OrderItemStore,
walletStore *postgres.WalletStore, agentWalletStore *postgres.AgentWalletStore,
cardWalletStore *postgres.CardWalletStore,
purchaseValidationService *purchase_validation.Service, purchaseValidationService *purchase_validation.Service,
shopPackageAllocationStore *postgres.ShopPackageAllocationStore, shopPackageAllocationStore *postgres.ShopPackageAllocationStore,
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore, shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
@@ -67,7 +69,8 @@ func New(
redis: redisClient, redis: redisClient,
orderStore: orderStore, orderStore: orderStore,
orderItemStore: orderItemStore, orderItemStore: orderItemStore,
walletStore: walletStore, agentWalletStore: agentWalletStore,
cardWalletStore: cardWalletStore,
purchaseValidationService: purchaseValidationService, purchaseValidationService: purchaseValidationService,
shopPackageAllocationStore: shopPackageAllocationStore, shopPackageAllocationStore: shopPackageAllocationStore,
shopSeriesAllocationStore: shopSeriesAllocationStore, shopSeriesAllocationStore: shopSeriesAllocationStore,
@@ -430,64 +433,128 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
return errors.New(errors.CodeInvalidParam, "不支持的买家类型") 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() now := time.Now()
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.Order{}). if resourceType == "shop" {
Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending). // 代理钱包系统(店铺)
Updates(map[string]any{ wallet, err := s.agentWalletStore.GetMainWallet(ctx, resourceID)
"payment_status": model.PaymentStatusPaid, if err != nil {
"payment_method": model.PaymentMethodWallet, if err == gorm.ErrRecordNotFound {
"paid_at": now, return errors.New(errors.CodeWalletNotFound, "钱包不存在")
}) }
if result.Error != nil { return err
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单支付状态失败")
} }
if result.RowsAffected == 0 { if wallet.Balance < order.TotalAmount {
var currentOrder model.Order return errors.New(errors.CodeInsufficientBalance, "余额不足")
if err := tx.First(&currentOrder, orderID).Error; err != nil { }
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单失败")
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 { if result.RowsAffected == 0 {
case model.PaymentStatusPaid: var currentOrder model.Order
return nil if err := tx.First(&currentOrder, orderID).Error; err != nil {
case model.PaymentStatusCancelled: return errors.Wrap(errors.CodeDatabaseError, err, "查询订单失败")
return errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付") }
case model.PaymentStatusRefunded:
return errors.New(errors.CodeInvalidStatus, "订单已退款,无法支付") switch currentOrder.PaymentStatus {
default: case model.PaymentStatusPaid:
return errors.New(errors.CodeInvalidStatus, "订单状态异常") 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{}). if wallet.Balance < order.TotalAmount {
Where("id = ? AND balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version). return errors.New(errors.CodeInsufficientBalance, "余额不足")
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) 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(&currentOrder, 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 { if err != nil {
return err return err

View File

@@ -71,10 +71,10 @@ func (s *ResetService) resetDailyUsageWithDB(ctx context.Context, db *gorm.DB) e
// 批量更新 // 批量更新
updates := map[string]interface{}{ updates := map[string]interface{}{
"data_usage_mb": 0, "data_usage_mb": 0,
"last_reset_at": now, "last_reset_at": now,
"next_reset_at": nextReset, "next_reset_at": nextReset,
"status": constants.PackageUsageStatusActive, // 重置后恢复为生效中 "status": constants.PackageUsageStatusActive, // 重置后恢复为生效中
} }
if err := tx.Model(&model.PackageUsage{}). if err := tx.Model(&model.PackageUsage{}).
@@ -221,4 +221,3 @@ func (s *ResetService) resetYearlyUsageWithDB(ctx context.Context, db *gorm.DB)
return nil return nil
}) })
} }

View File

@@ -152,13 +152,13 @@ func (s *CleanupService) Preview(ctx context.Context) ([]*CleanupPreview, error)
// CleanupProgress 清理进度 // CleanupProgress 清理进度
type CleanupProgress struct { type CleanupProgress struct {
IsRunning bool `json:"is_running"` IsRunning bool `json:"is_running"`
CurrentTable string `json:"current_table,omitempty"` CurrentTable string `json:"current_table,omitempty"`
TotalTables int `json:"total_tables"` TotalTables int `json:"total_tables"`
ProcessedTables int `json:"processed_tables"` ProcessedTables int `json:"processed_tables"`
TotalDeleted int64 `json:"total_deleted"` TotalDeleted int64 `json:"total_deleted"`
StartedAt *time.Time `json:"started_at,omitempty"` StartedAt *time.Time `json:"started_at,omitempty"`
LastLog *model.DataCleanupLog `json:"last_log,omitempty"` LastLog *model.DataCleanupLog `json:"last_log,omitempty"`
} }
// GetProgress 获取清理进度 // GetProgress 获取清理进度

View File

@@ -28,11 +28,11 @@ func NewConcurrencyService(store *postgres.PollingConcurrencyConfigStore, redis
// ConcurrencyStatus 并发状态 // ConcurrencyStatus 并发状态
type ConcurrencyStatus struct { type ConcurrencyStatus struct {
TaskType string `json:"task_type"` TaskType string `json:"task_type"`
TaskTypeName string `json:"task_type_name"` TaskTypeName string `json:"task_type_name"`
MaxConcurrency int `json:"max_concurrency"` MaxConcurrency int `json:"max_concurrency"`
Current int64 `json:"current"` Current int64 `json:"current"`
Available int64 `json:"available"` Available int64 `json:"available"`
Utilization float64 `json:"utilization"` Utilization float64 `json:"utilization"`
} }

View File

@@ -207,13 +207,13 @@ func (s *ManualTriggerService) processBatchTrigger(ctx context.Context, logID ui
// ConditionFilter 条件筛选参数 // ConditionFilter 条件筛选参数
type ConditionFilter struct { type ConditionFilter struct {
CardStatus string `json:"card_status,omitempty"` // 卡状态 CardStatus string `json:"card_status,omitempty"` // 卡状态
CarrierCode string `json:"carrier_code,omitempty"` // 运营商代码 CarrierCode string `json:"carrier_code,omitempty"` // 运营商代码
CardType string `json:"card_type,omitempty"` // 卡类型 CardType string `json:"card_type,omitempty"` // 卡类型
ShopID *uint `json:"shop_id,omitempty"` // 店铺ID ShopID *uint `json:"shop_id,omitempty"` // 店铺ID
PackageIDs []uint `json:"package_ids,omitempty"` // 套餐ID列表 PackageIDs []uint `json:"package_ids,omitempty"` // 套餐ID列表
EnablePolling *bool `json:"enable_polling,omitempty"` // 是否启用轮询 EnablePolling *bool `json:"enable_polling,omitempty"` // 是否启用轮询
Limit int `json:"limit,omitempty"` // 限制数量 Limit int `json:"limit,omitempty"` // 限制数量
} }
// TriggerByCondition 条件筛选触发 // TriggerByCondition 条件筛选触发

View File

@@ -29,26 +29,26 @@ type ForceRechargeRequirement struct {
} }
// Service 充值服务 // Service 充值服务
// 负责充值订单创建、预检、支付回调处理等业务逻辑 // 负责卡钱包IoT卡/设备)的充值订单创建、预检、支付回调处理等业务逻辑
type Service struct { type Service struct {
db *gorm.DB db *gorm.DB
rechargeStore *postgres.RechargeStore cardRechargeStore *postgres.CardRechargeStore
walletStore *postgres.WalletStore cardWalletStore *postgres.CardWalletStore
walletTransactionStore *postgres.WalletTransactionStore cardWalletTransactionStore *postgres.CardWalletTransactionStore
iotCardStore *postgres.IotCardStore iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore deviceStore *postgres.DeviceStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
packageSeriesStore *postgres.PackageSeriesStore packageSeriesStore *postgres.PackageSeriesStore
commissionRecordStore *postgres.CommissionRecordStore commissionRecordStore *postgres.CommissionRecordStore
logger *zap.Logger logger *zap.Logger
} }
// New 创建充值服务实例 // New 创建充值服务实例
func New( func New(
db *gorm.DB, db *gorm.DB,
rechargeStore *postgres.RechargeStore, cardRechargeStore *postgres.CardRechargeStore,
walletStore *postgres.WalletStore, cardWalletStore *postgres.CardWalletStore,
walletTransactionStore *postgres.WalletTransactionStore, cardWalletTransactionStore *postgres.CardWalletTransactionStore,
iotCardStore *postgres.IotCardStore, iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore, deviceStore *postgres.DeviceStore,
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore, shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
@@ -57,16 +57,16 @@ func New(
logger *zap.Logger, logger *zap.Logger,
) *Service { ) *Service {
return &Service{ return &Service{
db: db, db: db,
rechargeStore: rechargeStore, cardRechargeStore: cardRechargeStore,
walletStore: walletStore, cardWalletStore: cardWalletStore,
walletTransactionStore: walletTransactionStore, cardWalletTransactionStore: cardWalletTransactionStore,
iotCardStore: iotCardStore, iotCardStore: iotCardStore,
deviceStore: deviceStore, deviceStore: deviceStore,
shopSeriesAllocationStore: shopSeriesAllocationStore, shopSeriesAllocationStore: shopSeriesAllocationStore,
packageSeriesStore: packageSeriesStore, packageSeriesStore: packageSeriesStore,
commissionRecordStore: commissionRecordStore, commissionRecordStore: commissionRecordStore,
logger: logger, logger: logger,
} }
} }
@@ -81,14 +81,14 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRechargeRequest, us
return nil, errors.New(errors.CodeRechargeAmountInvalid, "充值金额不能超过100000元") return nil, errors.New(errors.CodeRechargeAmountInvalid, "充值金额不能超过100000元")
} }
// 2. 获取资源(卡或设备) // 2. 获取资源(卡或设备)钱包
var wallet *model.Wallet var wallet *model.CardWallet
var err error var err error
if req.ResourceType == "iot_card" { 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" { } 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 { } else {
return nil, errors.New(errors.CodeInvalidParam, "无效的资源类型") return nil, errors.New(errors.CodeInvalidParam, "无效的资源类型")
} }
@@ -115,20 +115,20 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRechargeRequest, us
rechargeNo := s.generateRechargeNo() rechargeNo := s.generateRechargeNo()
// 5. 创建充值订单 // 5. 创建充值订单
recharge := &model.RechargeRecord{ recharge := &model.CardRechargeRecord{
BaseModel: model.BaseModel{ UserID: userID,
Creator: userID, CardWalletID: wallet.ID,
Updater: userID, ResourceType: req.ResourceType,
}, ResourceID: req.ResourceID,
UserID: userID, RechargeNo: rechargeNo,
WalletID: wallet.ID, Amount: req.Amount,
RechargeNo: rechargeNo, PaymentMethod: req.PaymentMethod,
Amount: req.Amount, Status: constants.RechargeStatusPending,
PaymentMethod: req.PaymentMethod, ShopIDTag: wallet.ShopIDTag,
Status: constants.RechargeStatusPending, 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, "创建充值订单失败") return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
} }
@@ -173,7 +173,7 @@ func (s *Service) GetRechargeCheck(ctx context.Context, resourceType string, res
// GetByID 根据ID查询充值订单详情 // GetByID 根据ID查询充值订单详情
// 支持数据权限过滤 // 支持数据权限过滤
func (s *Service) GetByID(ctx context.Context, id uint, userID uint) (*dto.RechargeResponse, error) { 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 != nil {
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeRechargeNotFound, "充值订单不存在") return nil, errors.New(errors.CodeRechargeNotFound, "充值订单不存在")
@@ -201,7 +201,7 @@ func (s *Service) List(ctx context.Context, req *dto.RechargeListRequest, userID
pageSize = constants.DefaultPageSize pageSize = constants.DefaultPageSize
} }
params := &postgres.ListRechargeParams{ params := &postgres.ListCardRechargeParams{
Page: page, Page: page,
PageSize: pageSize, PageSize: pageSize,
UserID: &userID, // 数据权限:只能查看自己的 UserID: &userID, // 数据权限:只能查看自己的
@@ -211,7 +211,8 @@ func (s *Service) List(ctx context.Context, req *dto.RechargeListRequest, userID
params.Status = req.Status params.Status = req.Status
} }
if req.WalletID != nil { if req.WalletID != nil {
params.WalletID = req.WalletID walletID := *req.WalletID
params.CardWalletID = &walletID
} }
if req.StartTime != nil { if req.StartTime != nil {
params.StartTime = req.StartTime params.StartTime = req.StartTime
@@ -220,7 +221,7 @@ func (s *Service) List(ctx context.Context, req *dto.RechargeListRequest, userID
params.EndTime = req.EndTime params.EndTime = req.EndTime
} }
recharges, total, err := s.rechargeStore.List(ctx, params) recharges, total, err := s.cardRechargeStore.List(ctx, params)
if err != nil { if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单列表失败") 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 { func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string) error {
// 1. 查询充值订单 // 1. 查询充值订单
recharge, err := s.rechargeStore.GetByRechargeNo(ctx, rechargeNo) recharge, err := s.cardRechargeStore.GetByRechargeNo(ctx, rechargeNo)
if err != nil { if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeRechargeNotFound, "充值订单不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单失败") return errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单失败")
} }
if recharge == nil {
return errors.New(errors.CodeRechargeNotFound, "充值订单不存在")
}
// 2. 幂等性检查:已支付则直接返回成功 // 2. 幂等性检查:已支付则直接返回成功
if recharge.Status == constants.RechargeStatusPaid || recharge.Status == constants.RechargeStatusCompleted { if recharge.Status == constants.RechargeStatusPaid || recharge.Status == constants.RechargeStatusCompleted {
@@ -271,21 +272,21 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string,
} }
// 4. 获取钱包信息 // 4. 获取钱包信息
wallet, err := s.walletStore.GetByID(ctx, recharge.WalletID) wallet, err := s.cardWalletStore.GetByID(ctx, recharge.CardWalletID)
if err != nil { if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败") return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
} }
// 5. 获取钱包对应的资源类型和ID // 5. 获取钱包对应的资源类型和ID(从充值记录中直接获取)
resourceType := wallet.ResourceType resourceType := recharge.ResourceType
resourceID := wallet.ResourceID resourceID := recharge.ResourceID
// 6. 事务处理:更新订单状态、增加余额、更新累计充值、触发佣金 // 6. 事务处理:更新订单状态、增加余额、更新累计充值、触发佣金
now := time.Now() now := time.Now()
err = s.db.Transaction(func(tx *gorm.DB) error { err = s.db.Transaction(func(tx *gorm.DB) error {
// 6.1 更新充值订单状态(带状态检查,实现乐观锁) // 6.1 更新充值订单状态(带状态检查,实现乐观锁)
oldStatus := constants.RechargeStatusPending 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 { if err == gorm.ErrRecordNotFound {
// 状态已变更,幂等处理 // 状态已变更,幂等处理
return nil return nil
@@ -294,13 +295,13 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string,
} }
// 6.2 更新支付信息 // 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, "更新支付信息失败") return errors.Wrap(errors.CodeDatabaseError, err, "更新支付信息失败")
} }
// 6.3 增加钱包余额(使用乐观锁) // 6.3 增加钱包余额(使用乐观锁)
balanceBefore := wallet.Balance balanceBefore := wallet.Balance
result := tx.Model(&model.Wallet{}). result := tx.Model(&model.CardWallet{}).
Where("id = ? AND version = ?", wallet.ID, wallet.Version). Where("id = ? AND version = ?", wallet.ID, wallet.Version).
Updates(map[string]any{ Updates(map[string]any{
"balance": gorm.Expr("balance + ?", recharge.Amount), "balance": gorm.Expr("balance + ?", recharge.Amount),
@@ -316,8 +317,10 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string,
// 6.4 创建钱包交易记录 // 6.4 创建钱包交易记录
remark := "钱包充值" remark := "钱包充值"
refType := "recharge" refType := "recharge"
transaction := &model.WalletTransaction{ transaction := &model.CardWalletTransaction{
WalletID: wallet.ID, CardWalletID: wallet.ID,
ResourceType: resourceType,
ResourceID: resourceID,
UserID: recharge.UserID, UserID: recharge.UserID,
TransactionType: "recharge", TransactionType: "recharge",
Amount: recharge.Amount, Amount: recharge.Amount,
@@ -327,7 +330,8 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string,
ReferenceType: &refType, ReferenceType: &refType,
ReferenceID: &recharge.ID, ReferenceID: &recharge.ID,
Remark: &remark, Remark: &remark,
Creator: recharge.UserID, ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
} }
if err := tx.Create(transaction).Error; err != nil { if err := tx.Create(transaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败") return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败")
@@ -344,7 +348,7 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string,
} }
// 6.7 更新充值订单状态为已完成 // 6.7 更新充值订单状态为已完成
if err := tx.Model(&model.RechargeRecord{}). if err := tx.Model(&model.CardRechargeRecord{}).
Where("id = ?", recharge.ID). Where("id = ?", recharge.ID).
Update("status", constants.RechargeStatusCompleted).Error; err != nil { Update("status", constants.RechargeStatusCompleted).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新充值订单完成状态失败") return errors.Wrap(errors.CodeDatabaseError, err, "更新充值订单完成状态失败")
@@ -590,8 +594,8 @@ func (s *Service) triggerOneTimeCommissionIfNeededInTx(ctx context.Context, tx *
return nil return nil
} }
var commissionWallet model.Wallet var commissionWallet model.AgentWallet
if err := tx.Where("resource_type = ? AND resource_id = ? AND wallet_type = ?", "shop", *shopID, "commission"). if err := tx.Where("shop_id = ? AND wallet_type = ?", *shopID, constants.AgentWalletTypeCommission).
First(&commissionWallet).Error; err != nil { First(&commissionWallet).Error; err != nil {
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
s.logger.Warn("店铺佣金钱包不存在,跳过佣金发放", s.logger.Warn("店铺佣金钱包不存在,跳过佣金发放",
@@ -629,7 +633,7 @@ func (s *Service) triggerOneTimeCommissionIfNeededInTx(ctx context.Context, tx *
// 11. 佣金入账到店铺佣金钱包 // 11. 佣金入账到店铺佣金钱包
balanceBefore := commissionWallet.Balance balanceBefore := commissionWallet.Balance
result := tx.Model(&model.Wallet{}). result := tx.Model(&model.AgentWallet{}).
Where("id = ? AND version = ?", commissionWallet.ID, commissionWallet.Version). Where("id = ? AND version = ?", commissionWallet.ID, commissionWallet.Version).
Updates(map[string]any{ Updates(map[string]any{
"balance": gorm.Expr("balance + ?", commissionAmount), "balance": gorm.Expr("balance + ?", commissionAmount),
@@ -654,8 +658,9 @@ func (s *Service) triggerOneTimeCommissionIfNeededInTx(ctx context.Context, tx *
// 13. 创建佣金钱包交易记录 // 13. 创建佣金钱包交易记录
remark := "一次性佣金入账(充值触发)" remark := "一次性佣金入账(充值触发)"
refType := "commission" refType := "commission"
commissionTransaction := &model.WalletTransaction{ commissionTransaction := &model.AgentWalletTransaction{
WalletID: commissionWallet.ID, AgentWalletID: commissionWallet.ID,
ShopID: *shopID,
UserID: userID, UserID: userID,
TransactionType: "commission", TransactionType: "commission",
Amount: commissionAmount, Amount: commissionAmount,
@@ -665,7 +670,7 @@ func (s *Service) triggerOneTimeCommissionIfNeededInTx(ctx context.Context, tx *
ReferenceType: &refType, ReferenceType: &refType,
ReferenceID: &commissionRecord.ID, ReferenceID: &commissionRecord.ID,
Remark: &remark, Remark: &remark,
Creator: userID, ShopIDTag: *shopID,
} }
if err := tx.Create(commissionTransaction).Error; err != nil { if err := tx.Create(commissionTransaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建佣金钱包交易记录失败") return errors.Wrap(errors.CodeDatabaseError, err, "创建佣金钱包交易记录失败")
@@ -724,7 +729,7 @@ func (s *Service) generateRechargeNo() string {
} }
// buildRechargeResponse 构建充值订单响应 // buildRechargeResponse 构建充值订单响应
func (s *Service) buildRechargeResponse(recharge *model.RechargeRecord) *dto.RechargeResponse { func (s *Service) buildRechargeResponse(recharge *model.CardRechargeRecord) *dto.RechargeResponse {
statusText := "" statusText := ""
switch recharge.Status { switch recharge.Status {
case constants.RechargeStatusPending: case constants.RechargeStatusPending:
@@ -743,7 +748,7 @@ func (s *Service) buildRechargeResponse(recharge *model.RechargeRecord) *dto.Rec
ID: recharge.ID, ID: recharge.ID,
RechargeNo: recharge.RechargeNo, RechargeNo: recharge.RechargeNo,
UserID: recharge.UserID, UserID: recharge.UserID,
WalletID: recharge.WalletID, WalletID: recharge.CardWalletID,
Amount: recharge.Amount, Amount: recharge.Amount,
PaymentMethod: recharge.PaymentMethod, PaymentMethod: recharge.PaymentMethod,
PaymentChannel: recharge.PaymentChannel, PaymentChannel: recharge.PaymentChannel,

View File

@@ -16,7 +16,7 @@ import (
type Service struct { type Service struct {
shopStore *postgres.ShopStore shopStore *postgres.ShopStore
accountStore *postgres.AccountStore accountStore *postgres.AccountStore
walletStore *postgres.WalletStore agentWalletStore *postgres.AgentWalletStore
commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore
commissionRecordStore *postgres.CommissionRecordStore commissionRecordStore *postgres.CommissionRecordStore
} }
@@ -24,14 +24,14 @@ type Service struct {
func New( func New(
shopStore *postgres.ShopStore, shopStore *postgres.ShopStore,
accountStore *postgres.AccountStore, accountStore *postgres.AccountStore,
walletStore *postgres.WalletStore, agentWalletStore *postgres.AgentWalletStore,
commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore, commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore,
commissionRecordStore *postgres.CommissionRecordStore, commissionRecordStore *postgres.CommissionRecordStore,
) *Service { ) *Service {
return &Service{ return &Service{
shopStore: shopStore, shopStore: shopStore,
accountStore: accountStore, accountStore: accountStore,
walletStore: walletStore, agentWalletStore: agentWalletStore,
commissionWithdrawalReqStore: commissionWithdrawalReqStore, commissionWithdrawalReqStore: commissionWithdrawalReqStore,
commissionRecordStore: commissionRecordStore, commissionRecordStore: commissionRecordStore,
} }
@@ -74,7 +74,7 @@ func (s *Service) ListShopCommissionSummary(ctx context.Context, req *dto.ShopCo
shopIDs = append(shopIDs, shop.ID) shopIDs = append(shopIDs, shop.ID)
} }
walletSummaries, err := s.walletStore.GetShopCommissionSummaryBatch(ctx, shopIDs) walletSummaries, err := s.agentWalletStore.GetShopCommissionSummaryBatch(ctx, shopIDs)
if err != nil { if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询店铺钱包汇总失败") return nil, errors.Wrap(errors.CodeInternalError, err, "查询店铺钱包汇总失败")
} }
@@ -123,7 +123,7 @@ func (s *Service) ListShopCommissionSummary(ctx context.Context, req *dto.ShopCo
}, nil }, 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 var balance, frozenBalance int64
if walletSummary != nil { if walletSummary != nil {
balance = walletSummary.Balance balance = walletSummary.Balance

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -28,16 +28,16 @@ type PollingTaskPayload struct {
// PollingHandler 轮询任务处理器 // PollingHandler 轮询任务处理器
type PollingHandler struct { type PollingHandler struct {
db *gorm.DB db *gorm.DB
redis *redis.Client redis *redis.Client
gatewayClient *gateway.Client gatewayClient *gateway.Client
iotCardStore *postgres.IotCardStore iotCardStore *postgres.IotCardStore
concurrencyStore *postgres.PollingConcurrencyConfigStore concurrencyStore *postgres.PollingConcurrencyConfigStore
deviceSimBindingStore *postgres.DeviceSimBindingStore deviceSimBindingStore *postgres.DeviceSimBindingStore
dataUsageRecordStore *postgres.DataUsageRecordStore dataUsageRecordStore *postgres.DataUsageRecordStore
packageUsageStore *postgres.PackageUsageStore packageUsageStore *postgres.PackageUsageStore
usageService *packagepkg.UsageService usageService *packagepkg.UsageService
logger *zap.Logger logger *zap.Logger
} }
// NewPollingHandler 创建轮询任务处理器 // NewPollingHandler 创建轮询任务处理器
@@ -925,14 +925,14 @@ func (h *PollingHandler) getCardWithCache(ctx context.Context, cardID uint) (*mo
go func() { go func() {
cacheCtx := context.Background() cacheCtx := context.Background()
cacheData := map[string]any{ cacheData := map[string]any{
"id": card.ID, "id": card.ID,
"iccid": card.ICCID, "iccid": card.ICCID,
"card_category": card.CardCategory, "card_category": card.CardCategory,
"real_name_status": card.RealNameStatus, "real_name_status": card.RealNameStatus,
"network_status": card.NetworkStatus, "network_status": card.NetworkStatus,
"carrier_id": card.CarrierID, "carrier_id": card.CarrierID,
"current_month_usage_mb": card.CurrentMonthUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB,
"cached_at": time.Now().Unix(), "cached_at": time.Now().Unix(),
} }
if card.SeriesID != nil { if card.SeriesID != nil {
cacheData["series_id"] = *card.SeriesID cacheData["series_id"] = *card.SeriesID

View File

@@ -0,0 +1,2 @@
-- 删除代理钱包主表
DROP TABLE IF EXISTS tb_agent_wallet;

View File

@@ -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 标签(多租户过滤)';

View File

@@ -0,0 +1,2 @@
-- 删除代理钱包交易记录表
DROP TABLE IF EXISTS tb_agent_wallet_transaction;

View File

@@ -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 '扩展信息(如手续费、支付方式等)';

View File

@@ -0,0 +1,2 @@
-- 删除代理充值记录表
DROP TABLE IF EXISTS tb_agent_recharge_record;

View File

@@ -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 '完成时间';

View File

@@ -0,0 +1,2 @@
-- 删除卡钱包主表
DROP TABLE IF EXISTS tb_card_wallet;

View File

@@ -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 标签(多租户过滤)';

View File

@@ -0,0 +1,2 @@
-- 删除卡钱包交易记录表
DROP TABLE IF EXISTS tb_card_wallet_transaction;

View File

@@ -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 '扩展信息(如套餐信息、支付方式等)';

View File

@@ -0,0 +1,2 @@
-- 删除卡充值记录表
DROP TABLE IF EXISTS tb_card_recharge_record;

View File

@@ -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 '完成时间';

View File

@@ -0,0 +1,6 @@
-- 回滚:恢复旧的钱包表结构
-- 注意:此回滚脚本仅用于迁移失败的情况,实际使用中应避免回滚
-- 旧系统已废弃,不建议回滚到旧表结构
-- 此处故意留空,因为钱包系统已完全重构
-- 如需回滚,请手动参考旧版本的迁移文件重建表结构

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-24

View File

@@ -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) -- 多租户过滤
```
---
### 决策 4Model 层类型完全独立
**设计**
```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` 基类 + 接口抽象:过度设计,增加复杂度
---
### 决策 5Store 层完全独立
**设计**
```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 接口保持不变
---
### 决策 7Redis 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`
**阶段 2Model 和 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暂不删除
**阶段 3Service 层重构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 自动过滤多租户数据,这些字段是必需的

View File

@@ -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` - 代理钱包相关 ModelAgentWallet、AgentWalletTransaction、AgentRechargeRecord
- `internal/model/card_wallet.go` - 卡钱包相关 ModelCardWallet、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

View File

@@ -0,0 +1,318 @@
# agent-wallet Specification
## Purpose
代理钱包系统,提供店铺级别的主钱包和分佣钱包管理,支持充值、扣款、冻结、提现等操作。与卡钱包完全隔离,独立的数据表和代码实现。
## ADDED Requirements
### Requirement: 代理钱包实体定义
系统 SHALL 定义代理钱包AgentWallet实体管理店铺级别的钱包支持主钱包和分佣钱包两种类型。
**核心概念**
- **主钱包main**:店铺的主要资金账户,用于预充值和购买套餐
- **分佣钱包commission**:店铺的佣金账户,用于接收分佣和提现
**实体字段**
- `id`:钱包 ID主键BIGINT自增
- `shop_id`:店铺 IDBIGINT关联 tb_shop.id唯一约束之一
- `wallet_type`钱包类型VARCHAR(20),枚举值:"main"-主钱包 | "commission"-分佣钱包,唯一约束之一)
- `balance`余额BIGINT单位默认 0≥ 0
- `frozen_balance`冻结余额BIGINT单位默认 0≥ 0
- `currency`币种VARCHAR(10),默认 "CNY"
- `status`钱包状态INT1-正常 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`:代理钱包 IDBIGINT关联 tb_agent_wallet.id
- `shop_id`:店铺 IDBIGINT冗余字段便于按店铺查询
- `user_id`:操作人用户 IDBIGINT关联 tb_account.id
- `transaction_type`交易类型VARCHAR(20),枚举值:"recharge"-充值 | "deduct"-扣款 | "refund"-退款 | "commission"-分佣 | "withdrawal"-提现)
- `amount`变动金额BIGINT单位正数为增加负数为减少
- `balance_before`变动前余额BIGINT单位
- `balance_after`变动后余额BIGINT单位
- `status`交易状态INT1-成功 2-失败 3-处理中,默认 1
- `reference_type`关联业务类型VARCHAR(50),如 "order" | "commission" | "withdrawal" | "topup",可空)
- `reference_id`:关联业务 IDBIGINT可空
- `remark`备注TEXT可空
- `metadata`扩展信息JSONB如手续费、支付方式等可空
- `creator`:创建人 IDBIGINT
- `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`:操作人用户 IDBIGINT关联 tb_account.id
- `agent_wallet_id`:代理钱包 IDBIGINT关联 tb_agent_wallet.id
- `shop_id`:店铺 IDBIGINT冗余字段便于查询
- `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`充值状态INT1-待支付 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 分)和 version2后重试
#### 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`,第一个请求获得锁,第二个请求等待或失败
---

View File

@@ -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`:资源 IDBIGINT关联 tb_iot_card.id 或 tb_device.id唯一约束之一
- `balance`余额BIGINT单位默认 0≥ 0
- `frozen_balance`冻结余额BIGINT单位默认 0≥ 0
- `currency`币种VARCHAR(10),默认 "CNY"
- `status`钱包状态INT1-正常 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`:卡钱包 IDBIGINT关联 tb_card_wallet.id
- `resource_type`资源类型VARCHAR(20),冗余字段,便于查询)
- `resource_id`:资源 IDBIGINT冗余字段便于查询
- `user_id`:操作人用户 IDBIGINT关联 tb_account.id
- `transaction_type`交易类型VARCHAR(20),枚举值:"recharge"-充值 | "deduct"-扣款 | "refund"-退款)
- `amount`变动金额BIGINT单位正数为增加负数为减少
- `balance_before`变动前余额BIGINT单位
- `balance_after`变动后余额BIGINT单位
- `status`交易状态INT1-成功 2-失败 3-处理中,默认 1
- `reference_type`关联业务类型VARCHAR(50),如 "order" | "topup",可空)
- `reference_id`:关联业务 IDBIGINT可空
- `remark`备注TEXT可空
- `metadata`扩展信息JSONB如套餐信息、支付方式等可空
- `creator`:创建人 IDBIGINT
- `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`:操作人用户 IDBIGINT关联 tb_account.id
- `card_wallet_id`:卡钱包 IDBIGINT关联 tb_card_wallet.id
- `resource_type`资源类型VARCHAR(20),冗余字段)
- `resource_id`:资源 IDBIGINT冗余字段
- `recharge_no`充值订单号VARCHAR(50)唯一格式CRCH+时间戳+随机数)
- `amount`充值金额BIGINT单位≥ 100
- `payment_method`支付方式VARCHAR(20),枚举值:"alipay"-支付宝 | "wechat"-微信)
- `payment_channel`支付渠道VARCHAR(50),可空)
- `payment_transaction_id`第三方支付交易号VARCHAR(100),可空)
- `status`充值状态INT1-待支付 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 分)和 version2后重试
#### 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`,第一个请求获得锁,第二个请求等待或失败
---

View File

@@ -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 均已废弃并在新系统中重新定义。
---

View File

@@ -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_noARCH 前缀),状态为"待支付"
- [ ] 16.3 测试代理充值支付完成:模拟支付回调,验证状态从"待支付"变为"已支付"
- [ ] 16.4 测试代理充值到账:处理充值到账,验证钱包余额增加,状态变为"已完成",创建交易记录
- [ ] 16.5 测试卡充值最小金额限制:尝试充值 0.5 元,验证返回"充值金额不能低于 1 元"错误
- [ ] 16.6 测试卡充值订单创建:创建充值订单,验证生成唯一的 recharge_noCRCH 前缀),状态为"待支付"
- [ ] 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. **手动测试不可跳过**:所有核心流程必须手动测试验证,确保功能正确

View File

@@ -0,0 +1,318 @@
# agent-wallet Specification
## Purpose
代理钱包系统,提供店铺级别的主钱包和分佣钱包管理,支持充值、扣款、冻结、提现等操作。与卡钱包完全隔离,独立的数据表和代码实现。
## ADDED Requirements
### Requirement: 代理钱包实体定义
系统 SHALL 定义代理钱包AgentWallet实体管理店铺级别的钱包支持主钱包和分佣钱包两种类型。
**核心概念**
- **主钱包main**:店铺的主要资金账户,用于预充值和购买套餐
- **分佣钱包commission**:店铺的佣金账户,用于接收分佣和提现
**实体字段**
- `id`:钱包 ID主键BIGINT自增
- `shop_id`:店铺 IDBIGINT关联 tb_shop.id唯一约束之一
- `wallet_type`钱包类型VARCHAR(20),枚举值:"main"-主钱包 | "commission"-分佣钱包,唯一约束之一)
- `balance`余额BIGINT单位默认 0≥ 0
- `frozen_balance`冻结余额BIGINT单位默认 0≥ 0
- `currency`币种VARCHAR(10),默认 "CNY"
- `status`钱包状态INT1-正常 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`:代理钱包 IDBIGINT关联 tb_agent_wallet.id
- `shop_id`:店铺 IDBIGINT冗余字段便于按店铺查询
- `user_id`:操作人用户 IDBIGINT关联 tb_account.id
- `transaction_type`交易类型VARCHAR(20),枚举值:"recharge"-充值 | "deduct"-扣款 | "refund"-退款 | "commission"-分佣 | "withdrawal"-提现)
- `amount`变动金额BIGINT单位正数为增加负数为减少
- `balance_before`变动前余额BIGINT单位
- `balance_after`变动后余额BIGINT单位
- `status`交易状态INT1-成功 2-失败 3-处理中,默认 1
- `reference_type`关联业务类型VARCHAR(50),如 "order" | "commission" | "withdrawal" | "topup",可空)
- `reference_id`:关联业务 IDBIGINT可空
- `remark`备注TEXT可空
- `metadata`扩展信息JSONB如手续费、支付方式等可空
- `creator`:创建人 IDBIGINT
- `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`:操作人用户 IDBIGINT关联 tb_account.id
- `agent_wallet_id`:代理钱包 IDBIGINT关联 tb_agent_wallet.id
- `shop_id`:店铺 IDBIGINT冗余字段便于查询
- `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`充值状态INT1-待支付 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 分)和 version2后重试
#### 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`,第一个请求获得锁,第二个请求等待或失败
---

View File

@@ -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`:资源 IDBIGINT关联 tb_iot_card.id 或 tb_device.id唯一约束之一
- `balance`余额BIGINT单位默认 0≥ 0
- `frozen_balance`冻结余额BIGINT单位默认 0≥ 0
- `currency`币种VARCHAR(10),默认 "CNY"
- `status`钱包状态INT1-正常 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`:卡钱包 IDBIGINT关联 tb_card_wallet.id
- `resource_type`资源类型VARCHAR(20),冗余字段,便于查询)
- `resource_id`:资源 IDBIGINT冗余字段便于查询
- `user_id`:操作人用户 IDBIGINT关联 tb_account.id
- `transaction_type`交易类型VARCHAR(20),枚举值:"recharge"-充值 | "deduct"-扣款 | "refund"-退款)
- `amount`变动金额BIGINT单位正数为增加负数为减少
- `balance_before`变动前余额BIGINT单位
- `balance_after`变动后余额BIGINT单位
- `status`交易状态INT1-成功 2-失败 3-处理中,默认 1
- `reference_type`关联业务类型VARCHAR(50),如 "order" | "topup",可空)
- `reference_id`:关联业务 IDBIGINT可空
- `remark`备注TEXT可空
- `metadata`扩展信息JSONB如套餐信息、支付方式等可空
- `creator`:创建人 IDBIGINT
- `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`:操作人用户 IDBIGINT关联 tb_account.id
- `card_wallet_id`:卡钱包 IDBIGINT关联 tb_card_wallet.id
- `resource_type`资源类型VARCHAR(20),冗余字段)
- `resource_id`:资源 IDBIGINT冗余字段
- `recharge_no`充值订单号VARCHAR(50)唯一格式CRCH+时间戳+随机数)
- `amount`充值金额BIGINT单位≥ 100
- `payment_method`支付方式VARCHAR(20),枚举值:"alipay"-支付宝 | "wechat"-微信)
- `payment_channel`支付渠道VARCHAR(50),可空)
- `payment_transaction_id`第三方支付交易号VARCHAR(100),可空)
- `status`充值状态INT1-待支付 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 分)和 version2后重试
#### 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`,第一个请求获得锁,第二个请求等待或失败
---

View File

@@ -1,254 +1,88 @@
# wallet Specification # wallet Specification (DEPRECATED)
## Purpose ## 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: 钱包实体定义 ### Requirement: 钱包实体定义
系统 SHALL 定义钱包(Wallet)实体,统一管理用户钱包和代理钱包,支持余额管理、充值、扣款等操作 **⚠️ 已废弃** - 废弃统一钱包设计拆分为代理钱包AgentWallet和卡钱包CardWallet两个独立实体使用独立的数据表
**核心概念** **迁移指南**
- **用户钱包**:普通用户和企业用户的钱包,用于购买套餐 - 代理钱包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`
- `id`:钱包 ID主键BIGINT
- `user_id`:用户 IDBIGINT关联 tb_account.id
- `wallet_type`钱包类型VARCHAR(20),枚举值:"user"-用户钱包 | "agent"-代理钱包)
- `balance`余额BIGINT单位默认 0
- `frozen_balance`冻结余额BIGINT单位默认 0用于订单待支付、提现申请中等场景
- `currency`币种VARCHAR(10),默认 "CNY"
- `status`钱包状态INT1-正常 2-冻结 3-关闭)
- `version`版本号INT默认 0乐观锁字段用于防止并发扣款
- `creator`:创建人 IDBIGINT
- `updater`:更新人 IDBIGINT
- `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 元)
--- ---
### Requirement: 钱包明细记录 ### Requirement: 钱包明细记录
系统 SHALL 记录所有钱包余额变动,包括充值、扣款、退款、分佣、提现等操作,确保完整的审计追踪 **⚠️ 已废弃** - 废弃统一交易记录表拆分为代理钱包交易记录tb_agent_wallet_transaction和卡钱包交易记录tb_card_wallet_transaction两个独立表
**实体字段** **迁移指南**
- `id`:明细 ID主键BIGINT - 代理钱包交易记录 → `tb_agent_wallet_transaction` 表,参见 [agent-wallet spec](../agent-wallet/spec.md)
- `wallet_id`:钱包 IDBIGINT关联 tb_wallet.id - 卡钱包交易记录 → `tb_card_wallet_transaction` 表,参见 [card-wallet spec](../card-wallet/spec.md)
- `user_id`:用户 IDBIGINT关联 tb_account.id - 代码层使用新的 Model`model.AgentWalletTransaction``model.CardWalletTransaction`
- `transaction_type`交易类型VARCHAR(20),枚举值:"recharge"-充值 | "deduct"-扣款 | "refund"-退款 | "commission"-分佣 | "withdrawal"-提现)
- `amount`变动金额BIGINT单位正数为增加负数为减少
- `balance_before`变动前余额BIGINT单位
- `balance_after`变动后余额BIGINT单位
- `status`交易状态INT1-成功 2-失败 3-处理中)
- `reference_type`关联业务类型VARCHAR(50),如 "order" | "commission" | "withdrawal" | "topup"
- `reference_id`:关联业务 IDBIGINT
- `remark`备注TEXT
- `metadata`扩展信息JSONB如手续费、支付方式等
- `creator`:创建人 IDBIGINT
- `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
--- ---
### Requirement: 充值记录管理 ### Requirement: 充值记录管理
系统 SHALL 记录所有充值操作,包括充值订单号、金额、支付方式、支付状态等信息 **⚠️ 已废弃** - 废弃统一充值记录表拆分为代理充值记录tb_agent_recharge_record和卡充值记录tb_card_recharge_record两个独立表
**实体字段** **迁移指南**
- `id`:充值记录 ID主键BIGINT - 代理充值记录 → `tb_agent_recharge_record` 表,参见 [agent-wallet spec](../agent-wallet/spec.md)
- `user_id`:用户 IDBIGINT关联 tb_account.id - 卡充值记录 → `tb_card_recharge_record` 表,参见 [card-wallet spec](../card-wallet/spec.md)
- `wallet_id`:钱包 IDBIGINT关联 tb_wallet.id - 代码层使用新的 Model`model.AgentRechargeRecord``model.CardRechargeRecord`
- `recharge_no`充值订单号VARCHAR(50),唯一) - 充值服务拆分为独立的代理充值和卡充值逻辑
- `amount`充值金额BIGINT单位
- `payment_method`支付方式VARCHAR(20),枚举值:"alipay"-支付宝 | "wechat"-微信 | "bank"-银行转账 | "offline"-线下)
- `payment_channel`支付渠道VARCHAR(50)
- `payment_transaction_id`第三方支付交易号VARCHAR(100)
- `status`充值状态INT1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)
- `paid_at`支付时间TIMESTAMP可空
- `completed_at`完成时间TIMESTAMP可空
- `creator`:创建人 IDBIGINT
- `updater`:更新人 IDBIGINT
- `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` 时间
--- ---
### Requirement: 钱包余额操作 ### Requirement: 钱包余额操作
系统 SHALL 支持钱包余额的充值、扣款、退款、冻结、解冻等操作,使用乐观锁防止并发问题 **⚠️ 已废弃** - 余额操作逻辑拆分到代理钱包和卡钱包两个独立系统,使用各自的 Store 实现
**操作类型** **迁移指南**
- **充值**:增加钱包余额 - 代理钱包余额操作 → 使用 `AgentWalletStore`,参见 [agent-wallet spec](../agent-wallet/spec.md)
- **扣款**:减少钱包余额(如购买套餐) - 卡钱包余额操作 → 使用 `CardWalletStore`,参见 [card-wallet spec](../card-wallet/spec.md)
- **退款**:增加钱包余额(如订单退款) - 并发控制(乐观锁)机制保持不变,继续使用 `version` 字段
- **冻结**:将部分余额转为冻结状态(如订单待支付)
- **解冻**:将冻结余额转回可用余额(如订单取消)
**并发控制**
- 使用 `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 分
--- ---
### Requirement: 钱包数据校验 ### Requirement: 钱包数据校验
系统 SHALL 对钱包数据进行校验,确保数据完整性和一致性 **⚠️ 已废弃** - 数据校验规则拆分到代理钱包和卡钱包两个独立系统,针对各自的字段设计优化
**校验规则变更** **迁移指南**
- ~~`user_id`~~~~必填,≥ 1~~**已删除** - 代理钱包数据校验:使用 `shop_id` + `wallet_type`,参见 [agent-wallet spec](../agent-wallet/spec.md)
- `resource_type`:必填,枚举值 "iot_card" | "device" | "shop"**新增** - 卡钱包数据校验:使用 `resource_type` + `resource_id`,参见 [card-wallet spec](../card-wallet/spec.md)
- `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 分,可以共享使用
--- ---
### 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 张表已创建并投入使用
---

View File

@@ -1,54 +1,89 @@
package constants package constants
import "fmt"
// ======================================== // ========================================
// 钱包系统常量定义 // 钱包系统常量定义
// ======================================== // ========================================
// 钱包资源类型 // ========== 代理钱包常量 ==========
// 代理钱包类型
const ( const (
WalletResourceTypeIotCard = "iot_card" // 物联网卡钱包(个人客户) AgentWalletTypeMain = "main" // 主钱包
WalletResourceTypeDevice = "device" // 设备钱包(个人客户,多卡共享) AgentWalletTypeCommission = "commission" // 分佣钱包
WalletResourceTypeShop = "shop" // 店铺钱包(代理商)
) )
// 钱包类型 // 代理钱包状态
const ( const (
WalletTypeMain = "main" // 主钱包 AgentWalletStatusNormal = 1 // 正常
WalletTypeCommission = "commission" // 分佣钱包 AgentWalletStatusFrozen = 2 // 冻结
AgentWalletStatusClosed = 3 // 关闭
) )
// 钱包状态 // 代理钱包交易类型
const ( const (
WalletStatusNormal = 1 // 正常 AgentTransactionTypeRecharge = "recharge" // 充值
WalletStatusFrozen = 2 // 冻结 AgentTransactionTypeDeduct = "deduct" // 扣款
WalletStatusClosed = 3 // 关闭 AgentTransactionTypeRefund = "refund" // 退款
AgentTransactionTypeCommission = "commission" // 分佣
AgentTransactionTypeWithdrawal = "withdrawal" // 提现
) )
// 交易类型 // 代理充值订单号前缀
const ( const (
TransactionTypeRecharge = "recharge" // 充值 AgentRechargeOrderPrefix = "ARCH" // 代理充值订单号前缀
TransactionTypeDeduct = "deduct" // 扣款
TransactionTypeRefund = "refund" // 退款
TransactionTypeCommission = "commission" // 分佣
TransactionTypeWithdrawal = "withdrawal" // 提现
) )
// 交易状态 // 代理充值金额限制(单位:分)
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 ( const (
TransactionStatusSuccess = 1 // 成功 TransactionStatusSuccess = 1 // 成功
TransactionStatusFailed = 2 // 失败 TransactionStatusFailed = 2 // 失败
TransactionStatusProcessing = 3 // 处理中 TransactionStatusProcessing = 3 // 处理中
) )
// 关联业务类型 // 充值状态(代理钱包和卡钱包通用)
const (
ReferenceTypeOrder = "order" // 订单
ReferenceTypeCommission = "commission" // 分佣
ReferenceTypeWithdrawal = "withdrawal" // 提现
ReferenceTypeTopup = "topup" // 充值
)
// 充值状态
const ( const (
RechargeStatusPending = 1 // 待支付 RechargeStatusPending = 1 // 待支付
RechargeStatusPaid = 2 // 已支付 RechargeStatusPaid = 2 // 已支付
@@ -61,17 +96,96 @@ const (
const ( const (
RechargeMethodAlipay = "alipay" // 支付宝 RechargeMethodAlipay = "alipay" // 支付宝
RechargeMethodWechat = "wechat" // 微信 RechargeMethodWechat = "wechat" // 微信
RechargeMethodBank = "bank" // 银行转账 RechargeMethodBank = "bank" // 银行转账(仅代理钱包支持)
RechargeMethodOffline = "offline" // 线下 RechargeMethodOffline = "offline" // 线下(仅代理钱包支持)
) )
// 充值订单号前缀 // 关联业务类型
const ( const (
RechargeOrderPrefix = "RCH" // 充值订单号前缀 ReferenceTypeOrder = "order" // 订单
ReferenceTypeCommission = "commission" // 分佣
ReferenceTypeWithdrawal = "withdrawal" // 提现
ReferenceTypeTopup = "topup" // 充值
) )
// 充值金额限制(单位:分) // ========== Redis Key 生成函数 ==========
const (
RechargeMinAmount = 100 // 最小充值金额1元 // RedisAgentWalletBalanceKey 代理钱包余额缓存 Key
RechargeMaxAmount = 10000000 // 最大充值金额100000元 // 格式agent_wallet:balance:{shop_id}:{wallet_type}
) // TTL300 秒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}
// TTL10 秒
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}
// TTL180 秒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}
// TTL10 秒
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

View File

@@ -117,20 +117,20 @@ const (
CodeForceRechargeAmountMismatch = 1141 // 强充金额不匹配 CodeForceRechargeAmountMismatch = 1141 // 强充金额不匹配
// 轮询系统相关错误 (1150-1169) // 轮询系统相关错误 (1150-1169)
CodePollingConfigNotFound = 1150 // 轮询配置不存在 CodePollingConfigNotFound = 1150 // 轮询配置不存在
CodePollingConfigNameExists = 1151 // 轮询配置名称已存在 CodePollingConfigNameExists = 1151 // 轮询配置名称已存在
CodePollingQueueFull = 1152 // 轮询队列已满 CodePollingQueueFull = 1152 // 轮询队列已满
CodePollingConcurrencyLimit = 1153 // 并发数已达上限 CodePollingConcurrencyLimit = 1153 // 并发数已达上限
CodePollingAlertRuleNotFound = 1154 // 告警规则不存在 CodePollingAlertRuleNotFound = 1154 // 告警规则不存在
CodePollingCleanupConfigNotFound = 1155 // 数据清理配置不存在 CodePollingCleanupConfigNotFound = 1155 // 数据清理配置不存在
CodePollingManualTriggerLimit = 1156 // 手动触发次数已达上限 CodePollingManualTriggerLimit = 1156 // 手动触发次数已达上限
// 套餐相关错误 (1160-1179) // 套餐相关错误 (1160-1179)
CodeNoAvailablePackage = 1160 // 没有可用套餐 CodeNoAvailablePackage = 1160 // 没有可用套餐
CodePackageActivationConflict = 1161 // 套餐正在激活中 CodePackageActivationConflict = 1161 // 套餐正在激活中
CodeNoMainPackage = 1162 // 必须有主套餐才能购买加油包 CodeNoMainPackage = 1162 // 必须有主套餐才能购买加油包
CodeRealnameRequired = 1163 // 设备/卡必须先完成实名认证才能购买套餐 CodeRealnameRequired = 1163 // 设备/卡必须先完成实名认证才能购买套餐
CodeMixedOrderForbidden = 1164 // 同订单不能同时购买正式套餐和加油包 CodeMixedOrderForbidden = 1164 // 同订单不能同时购买正式套餐和加油包
// 服务端错误 (2000-2999) -> 5xx HTTP 状态码 // 服务端错误 (2000-2999) -> 5xx HTTP 状态码
CodeInternalError = 2001 // 内部服务器错误 CodeInternalError = 2001 // 内部服务器错误

View File

@@ -21,8 +21,6 @@ type WorkerStores struct {
Shop *postgres.ShopStore Shop *postgres.ShopStore
ShopSeriesAllocation *postgres.ShopSeriesAllocationStore ShopSeriesAllocation *postgres.ShopSeriesAllocationStore
PackageSeries *postgres.PackageSeriesStore PackageSeries *postgres.PackageSeriesStore
Wallet *postgres.WalletStore
WalletTransaction *postgres.WalletTransactionStore
Order *postgres.OrderStore Order *postgres.OrderStore
OrderItem *postgres.OrderItemStore OrderItem *postgres.OrderItemStore
Package *postgres.PackageStore Package *postgres.PackageStore
@@ -32,6 +30,9 @@ type WorkerStores struct {
PollingAlertHistory *postgres.PollingAlertHistoryStore PollingAlertHistory *postgres.PollingAlertHistoryStore
DataCleanupConfig *postgres.DataCleanupConfigStore DataCleanupConfig *postgres.DataCleanupConfigStore
DataCleanupLog *postgres.DataCleanupLogStore DataCleanupLog *postgres.DataCleanupLogStore
// 新增代理钱包 Store
AgentWallet *postgres.AgentWalletStore
AgentWalletTransaction *postgres.AgentWalletTransactionStore
} }
// WorkerServices Worker 侧所有 Service 的集合 // WorkerServices Worker 侧所有 Service 的集合

View File

@@ -1,3 +1,4 @@
//go:build ignore
// +build ignore // +build ignore
package main package main
@@ -20,20 +21,20 @@ import (
// IotCard 简化的卡模型 // IotCard 简化的卡模型
type IotCard struct { type IotCard struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
ICCID string `gorm:"column:iccid;uniqueIndex:idx_iot_card_iccid,where:deleted_at IS NULL"` ICCID string `gorm:"column:iccid;uniqueIndex:idx_iot_card_iccid,where:deleted_at IS NULL"`
CardCategory string `gorm:"column:card_category;default:normal"` CardCategory string `gorm:"column:card_category;default:normal"`
CarrierID uint `gorm:"column:carrier_id"` CarrierID uint `gorm:"column:carrier_id"`
Status int `gorm:"column:status;default:1"` Status int `gorm:"column:status;default:1"`
ActivationStatus int `gorm:"column:activation_status;default:0"` ActivationStatus int `gorm:"column:activation_status;default:0"`
RealNameStatus int `gorm:"column:real_name_status;default:0"` RealNameStatus int `gorm:"column:real_name_status;default:0"`
NetworkStatus int `gorm:"column:network_status;default:0"` NetworkStatus int `gorm:"column:network_status;default:0"`
EnablePolling bool `gorm:"column:enable_polling;default:true"` EnablePolling bool `gorm:"column:enable_polling;default:true"`
Creator uint `gorm:"column:creator"` Creator uint `gorm:"column:creator"`
Updater uint `gorm:"column:updater"` Updater uint `gorm:"column:updater"`
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
DeletedAt *time.Time `gorm:"index"` DeletedAt *time.Time `gorm:"index"`
} }
func (IotCard) TableName() string { func (IotCard) TableName() string {
@@ -41,11 +42,11 @@ func (IotCard) TableName() string {
} }
var ( var (
totalCards = flag.Int("total", 10000000, "要生成的卡数量") totalCards = flag.Int("total", 10000000, "要生成的卡数量")
batchSize = flag.Int("batch", 10000, "每批插入数量") batchSize = flag.Int("batch", 10000, "每批插入数量")
workers = flag.Int("workers", 10, "并行 worker 数量") workers = flag.Int("workers", 10, "并行 worker 数量")
startICCID = flag.String("start", "898600000", "起始 ICCID 前缀9位总长度不超过20位") startICCID = flag.String("start", "898600000", "起始 ICCID 前缀9位总长度不超过20位")
clearOld = flag.Bool("clear", false, "是否清空现有测试卡") clearOld = flag.Bool("clear", false, "是否清空现有测试卡")
insertedCount int64 insertedCount int64
startTime time.Time startTime time.Time

View File

@@ -1,3 +1,4 @@
//go:build ignore
// +build ignore // +build ignore
package main package main
@@ -250,12 +251,12 @@ func handleStats(w http.ResponseWriter, r *http.Request) {
} }
stats := map[string]interface{}{ stats := map[string]interface{}{
"uptime_seconds": elapsed, "uptime_seconds": elapsed,
"total_requests": total, "total_requests": total,
"success_count": success, "success_count": success,
"failed_count": failed, "failed_count": failed,
"qps": qps, "qps": qps,
"success_rate": fmt.Sprintf("%.2f%%", successRate), "success_rate": fmt.Sprintf("%.2f%%", successRate),
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")