feat: 实现账号与佣金管理模块
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 4m35s

新增功能:
- 店铺佣金查询:店铺佣金统计、店铺佣金记录列表、店铺提现记录
- 佣金提现审批:提现申请列表、审批通过、审批拒绝
- 提现配置管理:配置列表、新增配置、获取当前生效配置
- 企业管理:企业列表、创建、更新、删除、获取详情
- 企业卡授权:授权列表、批量授权、批量取消授权、统计
- 客户账号管理:账号列表、创建、更新状态、重置密码
- 我的佣金:佣金统计、佣金记录、提现申请、提现记录

数据库变更:
- 扩展 tb_commission_withdrawal_request 新增提现单号等字段
- 扩展 tb_account 新增 is_primary 字段
- 扩展 tb_commission_record 新增 shop_id、balance_after
- 扩展 tb_commission_withdrawal_setting 新增每日提现次数限制
- 扩展 tb_iot_card、tb_device 新增 shop_id 冗余字段
- 新建 tb_enterprise_card_authorization 企业卡授权表
- 新建 tb_asset_allocation_record 资产分配记录表
- 数据迁移:owner_type 枚举值 agent 统一为 shop

测试:
- 新增 7 个单元测试文件覆盖各服务
- 修复集成测试 Redis 依赖问题
This commit is contained in:
2026-01-21 18:20:44 +08:00
parent 1489abe668
commit 91c9bbfeb8
89 changed files with 11958 additions and 159 deletions

View File

@@ -218,6 +218,30 @@ func (s *AccountStore) BulkUpdateStatus(ctx context.Context, ids []uint, status
}).Error
}
func (s *AccountStore) GetByIDs(ctx context.Context, ids []uint) ([]*model.Account, error) {
if len(ids) == 0 {
return []*model.Account{}, nil
}
var accounts []*model.Account
if err := s.db.WithContext(ctx).Where("id IN ?", ids).Find(&accounts).Error; err != nil {
return nil, err
}
return accounts, nil
}
func (s *AccountStore) GetPrimaryAccountsByShopIDs(ctx context.Context, shopIDs []uint) ([]*model.Account, error) {
if len(shopIDs) == 0 {
return []*model.Account{}, nil
}
var accounts []*model.Account
if err := s.db.WithContext(ctx).
Where("shop_id IN ? AND is_primary = ?", shopIDs, true).
Find(&accounts).Error; err != nil {
return nil, err
}
return accounts, nil
}
// ListByShopID 按店铺ID分页查询账号列表
func (s *AccountStore) ListByShopID(ctx context.Context, shopID uint, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.Account, int64, error) {
var accounts []*model.Account

View File

@@ -0,0 +1,88 @@
package postgres
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type CommissionRecordStore struct {
db *gorm.DB
redis *redis.Client
}
func NewCommissionRecordStore(db *gorm.DB, redis *redis.Client) *CommissionRecordStore {
return &CommissionRecordStore{
db: db,
redis: redis,
}
}
func (s *CommissionRecordStore) Create(ctx context.Context, record *model.CommissionRecord) error {
return s.db.WithContext(ctx).Create(record).Error
}
func (s *CommissionRecordStore) GetByID(ctx context.Context, id uint) (*model.CommissionRecord, error) {
var record model.CommissionRecord
if err := s.db.WithContext(ctx).First(&record, id).Error; err != nil {
return nil, err
}
return &record, nil
}
type CommissionRecordListFilters struct {
ShopID uint
CommissionType string
ICCID string
DeviceNo string
OrderNo string
Status *int
}
func (s *CommissionRecordStore) ListByShopID(ctx context.Context, opts *store.QueryOptions, filters *CommissionRecordListFilters) ([]*model.CommissionRecord, int64, error) {
var records []*model.CommissionRecord
var total int64
query := s.db.WithContext(ctx).Model(&model.CommissionRecord{})
if filters != nil {
if filters.ShopID > 0 {
query = query.Where("shop_id = ?", filters.ShopID)
}
if filters.CommissionType != "" {
query = query.Where("commission_type = ?", filters.CommissionType)
}
if filters.Status != nil {
query = query.Where("status = ?", *filters.Status)
}
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if opts == nil {
opts = &store.QueryOptions{
Page: 1,
PageSize: constants.DefaultPageSize,
}
}
offset := (opts.Page - 1) * opts.PageSize
query = query.Offset(offset).Limit(opts.PageSize)
if opts.OrderBy != "" {
query = query.Order(opts.OrderBy)
} else {
query = query.Order("created_at DESC")
}
if err := query.Find(&records).Error; err != nil {
return nil, 0, err
}
return records, total, nil
}

View File

@@ -0,0 +1,193 @@
package postgres
import (
"context"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type CommissionWithdrawalRequestStore struct {
db *gorm.DB
redis *redis.Client
}
func NewCommissionWithdrawalRequestStore(db *gorm.DB, redis *redis.Client) *CommissionWithdrawalRequestStore {
return &CommissionWithdrawalRequestStore{
db: db,
redis: redis,
}
}
func (s *CommissionWithdrawalRequestStore) Create(ctx context.Context, req *model.CommissionWithdrawalRequest) error {
return s.db.WithContext(ctx).Create(req).Error
}
func (s *CommissionWithdrawalRequestStore) GetByID(ctx context.Context, id uint) (*model.CommissionWithdrawalRequest, error) {
var req model.CommissionWithdrawalRequest
if err := s.db.WithContext(ctx).First(&req, id).Error; err != nil {
return nil, err
}
return &req, nil
}
func (s *CommissionWithdrawalRequestStore) Update(ctx context.Context, req *model.CommissionWithdrawalRequest) error {
return s.db.WithContext(ctx).Save(req).Error
}
type WithdrawalRequestListFilters struct {
ShopID uint
WithdrawalNo string
StartTime *time.Time
EndTime *time.Time
Status *int
}
func (s *CommissionWithdrawalRequestStore) ListByShopID(ctx context.Context, opts *store.QueryOptions, filters *WithdrawalRequestListFilters) ([]*model.CommissionWithdrawalRequest, int64, error) {
var requests []*model.CommissionWithdrawalRequest
var total int64
query := s.db.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{})
if filters != nil {
if filters.ShopID > 0 {
query = query.Where("shop_id = ?", filters.ShopID)
}
if filters.WithdrawalNo != "" {
query = query.Where("withdrawal_no = ?", filters.WithdrawalNo)
}
if filters.StartTime != nil {
query = query.Where("created_at >= ?", filters.StartTime)
}
if filters.EndTime != nil {
query = query.Where("created_at <= ?", filters.EndTime)
}
if filters.Status != nil {
query = query.Where("status = ?", *filters.Status)
}
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if opts == nil {
opts = &store.QueryOptions{
Page: 1,
PageSize: constants.DefaultPageSize,
}
}
offset := (opts.Page - 1) * opts.PageSize
query = query.Offset(offset).Limit(opts.PageSize)
if opts.OrderBy != "" {
query = query.Order(opts.OrderBy)
} else {
query = query.Order("created_at DESC")
}
if err := query.Find(&requests).Error; err != nil {
return nil, 0, err
}
return requests, total, nil
}
func (s *CommissionWithdrawalRequestStore) SumAmountByShopIDAndStatus(ctx context.Context, shopID uint, status int) (int64, error) {
var sum struct {
Total int64
}
err := s.db.WithContext(ctx).
Model(&model.CommissionWithdrawalRequest{}).
Select("COALESCE(SUM(amount), 0) as total").
Where("shop_id = ? AND status = ?", shopID, status).
Scan(&sum).Error
if err != nil {
return 0, err
}
return sum.Total, nil
}
func (s *CommissionWithdrawalRequestStore) SumAmountByShopIDsAndStatus(ctx context.Context, shopIDs []uint, status int) (map[uint]int64, error) {
if len(shopIDs) == 0 {
return make(map[uint]int64), nil
}
type sumResult struct {
ShopID uint
Total int64
}
var results []sumResult
err := s.db.WithContext(ctx).
Model(&model.CommissionWithdrawalRequest{}).
Select("shop_id, COALESCE(SUM(amount), 0) as total").
Where("shop_id IN ? AND status = ?", shopIDs, status).
Group("shop_id").
Scan(&results).Error
if err != nil {
return nil, err
}
result := make(map[uint]int64)
for _, r := range results {
result[r.ShopID] = r.Total
}
return result, nil
}
func (s *CommissionWithdrawalRequestStore) List(ctx context.Context, opts *store.QueryOptions, filters *WithdrawalRequestListFilters) ([]*model.CommissionWithdrawalRequest, int64, error) {
var requests []*model.CommissionWithdrawalRequest
var total int64
query := s.db.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{})
if filters != nil {
if filters.WithdrawalNo != "" {
query = query.Where("withdrawal_no = ?", filters.WithdrawalNo)
}
if filters.StartTime != nil {
query = query.Where("created_at >= ?", filters.StartTime)
}
if filters.EndTime != nil {
query = query.Where("created_at <= ?", filters.EndTime)
}
if filters.Status != nil {
query = query.Where("status = ?", *filters.Status)
}
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if opts == nil {
opts = &store.QueryOptions{
Page: 1,
PageSize: constants.DefaultPageSize,
}
}
offset := (opts.Page - 1) * opts.PageSize
query = query.Offset(offset).Limit(opts.PageSize)
if opts.OrderBy != "" {
query = query.Order(opts.OrderBy)
} else {
query = query.Order("created_at DESC")
}
if err := query.Find(&requests).Error; err != nil {
return nil, 0, err
}
return requests, total, nil
}
func (s *CommissionWithdrawalRequestStore) UpdateStatusWithTx(ctx context.Context, tx *gorm.DB, id uint, updates map[string]interface{}) error {
return tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).Where("id = ? AND status = ?", id, constants.WithdrawalStatusPending).Updates(updates).Error
}

View File

@@ -0,0 +1,79 @@
package postgres
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type CommissionWithdrawalSettingStore struct {
db *gorm.DB
redis *redis.Client
}
func NewCommissionWithdrawalSettingStore(db *gorm.DB, redis *redis.Client) *CommissionWithdrawalSettingStore {
return &CommissionWithdrawalSettingStore{
db: db,
redis: redis,
}
}
func (s *CommissionWithdrawalSettingStore) Create(ctx context.Context, setting *model.CommissionWithdrawalSetting) error {
return s.db.WithContext(ctx).Create(setting).Error
}
func (s *CommissionWithdrawalSettingStore) CreateWithTx(ctx context.Context, tx *gorm.DB, setting *model.CommissionWithdrawalSetting) error {
return tx.WithContext(ctx).Create(setting).Error
}
func (s *CommissionWithdrawalSettingStore) GetByID(ctx context.Context, id uint) (*model.CommissionWithdrawalSetting, error) {
var setting model.CommissionWithdrawalSetting
if err := s.db.WithContext(ctx).First(&setting, id).Error; err != nil {
return nil, err
}
return &setting, nil
}
func (s *CommissionWithdrawalSettingStore) GetCurrent(ctx context.Context) (*model.CommissionWithdrawalSetting, error) {
var setting model.CommissionWithdrawalSetting
if err := s.db.WithContext(ctx).Where("is_active = ?", true).First(&setting).Error; err != nil {
return nil, err
}
return &setting, nil
}
func (s *CommissionWithdrawalSettingStore) List(ctx context.Context, opts *store.QueryOptions) ([]*model.CommissionWithdrawalSetting, int64, error) {
var settings []*model.CommissionWithdrawalSetting
var total int64
query := s.db.WithContext(ctx).Model(&model.CommissionWithdrawalSetting{})
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if opts == nil {
opts = &store.QueryOptions{
Page: 1,
PageSize: constants.DefaultPageSize,
}
}
offset := (opts.Page - 1) * opts.PageSize
query = query.Offset(offset).Limit(opts.PageSize).Order("created_at DESC")
if err := query.Find(&settings).Error; err != nil {
return nil, 0, err
}
return settings, total, nil
}
func (s *CommissionWithdrawalSettingStore) DeactivateCurrentWithTx(ctx context.Context, tx *gorm.DB) error {
return tx.WithContext(ctx).Model(&model.CommissionWithdrawalSetting{}).
Where("is_active = ?", true).
Update("is_active", false).Error
}

View File

@@ -0,0 +1,96 @@
package postgres
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type EnterpriseCardAuthorizationStore struct {
db *gorm.DB
redis *redis.Client
}
func NewEnterpriseCardAuthorizationStore(db *gorm.DB, redis *redis.Client) *EnterpriseCardAuthorizationStore {
return &EnterpriseCardAuthorizationStore{
db: db,
redis: redis,
}
}
func (s *EnterpriseCardAuthorizationStore) Create(ctx context.Context, auth *model.EnterpriseCardAuthorization) error {
return s.db.WithContext(ctx).Create(auth).Error
}
func (s *EnterpriseCardAuthorizationStore) BatchCreate(ctx context.Context, auths []*model.EnterpriseCardAuthorization) error {
if len(auths) == 0 {
return nil
}
return s.db.WithContext(ctx).CreateInBatches(auths, 100).Error
}
func (s *EnterpriseCardAuthorizationStore) UpdateStatus(ctx context.Context, enterpriseID, cardID uint, status int) error {
return s.db.WithContext(ctx).Model(&model.EnterpriseCardAuthorization{}).
Where("enterprise_id = ? AND iot_card_id = ?", enterpriseID, cardID).
Update("status", status).Error
}
func (s *EnterpriseCardAuthorizationStore) BatchUpdateStatus(ctx context.Context, enterpriseID uint, cardIDs []uint, status int) error {
if len(cardIDs) == 0 {
return nil
}
return s.db.WithContext(ctx).Model(&model.EnterpriseCardAuthorization{}).
Where("enterprise_id = ? AND iot_card_id IN ?", enterpriseID, cardIDs).
Update("status", status).Error
}
func (s *EnterpriseCardAuthorizationStore) GetByEnterpriseAndCard(ctx context.Context, enterpriseID, cardID uint) (*model.EnterpriseCardAuthorization, error) {
var auth model.EnterpriseCardAuthorization
err := s.db.WithContext(ctx).
Where("enterprise_id = ? AND iot_card_id = ?", enterpriseID, cardID).
First(&auth).Error
if err != nil {
return nil, err
}
return &auth, nil
}
func (s *EnterpriseCardAuthorizationStore) ListByEnterprise(ctx context.Context, enterpriseID uint, status *int) ([]*model.EnterpriseCardAuthorization, error) {
var auths []*model.EnterpriseCardAuthorization
query := s.db.WithContext(ctx).Where("enterprise_id = ?", enterpriseID)
if status != nil {
query = query.Where("status = ?", *status)
}
if err := query.Find(&auths).Error; err != nil {
return nil, err
}
return auths, nil
}
func (s *EnterpriseCardAuthorizationStore) ListCardIDsByEnterprise(ctx context.Context, enterpriseID uint) ([]uint, error) {
var cardIDs []uint
err := s.db.WithContext(ctx).Model(&model.EnterpriseCardAuthorization{}).
Where("enterprise_id = ? AND status = 1", enterpriseID).
Pluck("iot_card_id", &cardIDs).Error
return cardIDs, err
}
func (s *EnterpriseCardAuthorizationStore) GetActiveAuthsByCardIDs(ctx context.Context, enterpriseID uint, cardIDs []uint) (map[uint]bool, error) {
if len(cardIDs) == 0 {
return make(map[uint]bool), nil
}
var auths []model.EnterpriseCardAuthorization
err := s.db.WithContext(ctx).
Where("enterprise_id = ? AND iot_card_id IN ? AND status = 1", enterpriseID, cardIDs).
Find(&auths).Error
if err != nil {
return nil, err
}
result := make(map[uint]bool)
for _, auth := range auths {
result[auth.IotCardID] = true
}
return result, nil
}

View File

@@ -0,0 +1,108 @@
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

@@ -0,0 +1,37 @@
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
}