重构: 店铺套餐分配系统从加价模式改为返佣模式
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m18s

主要变更:
- 重构分配模型:从加价模式(pricing_mode/pricing_value)改为返佣模式(base_commission + tier_commission)
- 删除独立的 my_package 接口,统一到 /api/admin/packages(通过数据权限自动过滤)
- 新增批量分配和批量调价功能,支持事务和性能优化
- 新增配置版本管理,订单创建时锁定返佣配置
- 新增成本价历史记录,支持审计和纠纷处理
- 新增统计缓存系统(Redis + 异步任务),优化梯度返佣计算性能
- 删除冗余的梯度佣金独立 CRUD 接口(合并到分配配置中)
- 归档 3 个已完成的 OpenSpec changes 并同步 8 个新 capabilities 到 main specs

技术细节:
- 数据库迁移:000026_refactor_shop_package_allocation
- 新增 Store:AllocationConfigStore, PriceHistoryStore, CommissionStatsStore
- 新增 Service:BatchAllocationService, BatchPricingService, CommissionStatsService
- 新增异步任务:统计更新、定时同步、周期归档
- 测试覆盖:批量操作集成测试、梯度佣金 CRUD 清理验证

影响:
- API 变更:删除 4 个梯度 CRUD 接口(POST/GET/PUT/DELETE /:id/tiers)
- API 新增:批量分配、批量调价接口
- 数据模型:重构 shop_series_allocation 表结构
- 性能优化:批量操作使用 CreateInBatches,统计使用 Redis 缓存

相关文档:
- openspec/changes/archive/2026-01-28-refactor-shop-package-allocation/
- openspec/specs/agent-available-packages/
- openspec/specs/allocation-config-versioning/
- 等 8 个新 capability specs
This commit is contained in:
2026-01-28 17:11:55 +08:00
parent 23eb0307bb
commit 1da680a790
97 changed files with 6810 additions and 3622 deletions

View File

@@ -37,6 +37,18 @@ func (s *PackageSeriesStore) GetByCode(ctx context.Context, code string) (*model
return &series, nil
}
// GetByIDs 批量查询套餐系列
func (s *PackageSeriesStore) GetByIDs(ctx context.Context, ids []uint) ([]*model.PackageSeries, error) {
if len(ids) == 0 {
return []*model.PackageSeries{}, nil
}
var seriesList []*model.PackageSeries
if err := s.db.WithContext(ctx).Where("id IN ?", ids).Find(&seriesList).Error; err != nil {
return nil, err
}
return seriesList, nil
}
func (s *PackageSeriesStore) Update(ctx context.Context, series *model.PackageSeries) error {
return s.db.WithContext(ctx).Save(series).Error
}

View File

@@ -7,6 +7,8 @@ import (
"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/break/junhong_cmp_fiber/pkg/middleware"
)
type PackageStore struct {
@@ -51,20 +53,29 @@ func (s *PackageStore) List(ctx context.Context, opts *store.QueryOptions, filte
query := s.db.WithContext(ctx).Model(&model.Package{})
// 代理用户额外过滤:只能看到已分配的套餐
userType := middleware.GetUserTypeFromContext(ctx)
shopID := middleware.GetShopIDFromContext(ctx)
if userType == constants.UserTypeAgent && shopID > 0 {
query = query.Joins("INNER JOIN tb_shop_package_allocation ON tb_shop_package_allocation.package_id = tb_package.id").
Where("tb_shop_package_allocation.shop_id = ? AND tb_shop_package_allocation.status = ?",
shopID, constants.StatusEnabled)
}
if packageName, ok := filters["package_name"].(string); ok && packageName != "" {
query = query.Where("package_name LIKE ?", "%"+packageName+"%")
query = query.Where("tb_package.package_name LIKE ?", "%"+packageName+"%")
}
if seriesID, ok := filters["series_id"].(uint); ok && seriesID > 0 {
query = query.Where("series_id = ?", seriesID)
query = query.Where("tb_package.series_id = ?", seriesID)
}
if status, ok := filters["status"]; ok {
query = query.Where("status = ?", status)
query = query.Where("tb_package.status = ?", status)
}
if shelfStatus, ok := filters["shelf_status"]; ok {
query = query.Where("shelf_status = ?", shelfStatus)
query = query.Where("tb_package.shelf_status = ?", shelfStatus)
}
if packageType, ok := filters["package_type"].(string); ok && packageType != "" {
query = query.Where("package_type = ?", packageType)
query = query.Where("tb_package.package_type = ?", packageType)
}
if err := query.Count(&total).Error; err != nil {

View File

@@ -0,0 +1,70 @@
package postgres
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store"
"gorm.io/gorm"
)
type ShopPackageAllocationPriceHistoryStore struct {
db *gorm.DB
}
func NewShopPackageAllocationPriceHistoryStore(db *gorm.DB) *ShopPackageAllocationPriceHistoryStore {
return &ShopPackageAllocationPriceHistoryStore{db: db}
}
func (s *ShopPackageAllocationPriceHistoryStore) Create(ctx context.Context, history *model.ShopPackageAllocationPriceHistory) error {
return s.db.WithContext(ctx).Create(history).Error
}
func (s *ShopPackageAllocationPriceHistoryStore) BatchCreate(ctx context.Context, histories []*model.ShopPackageAllocationPriceHistory) error {
return s.db.WithContext(ctx).CreateInBatches(histories, 500).Error
}
func (s *ShopPackageAllocationPriceHistoryStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.ShopPackageAllocationPriceHistory, int64, error) {
var histories []*model.ShopPackageAllocationPriceHistory
var total int64
query := s.db.WithContext(ctx).Model(&model.ShopPackageAllocationPriceHistory{})
if allocationID, ok := filters["allocation_id"].(uint); ok && allocationID > 0 {
query = query.Where("allocation_id = ?", allocationID)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if opts == nil {
opts = store.DefaultQueryOptions()
}
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("effective_from DESC")
}
if err := query.Find(&histories).Error; err != nil {
return nil, 0, err
}
return histories, total, nil
}
func (s *ShopPackageAllocationPriceHistoryStore) ListByAllocation(ctx context.Context, allocationID uint) ([]*model.ShopPackageAllocationPriceHistory, error) {
var histories []*model.ShopPackageAllocationPriceHistory
err := s.db.WithContext(ctx).
Where("allocation_id = ?", allocationID).
Order("effective_from DESC").
Find(&histories).Error
if err != nil {
return nil, err
}
return histories, nil
}

View File

@@ -0,0 +1,65 @@
package postgres
import (
"context"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"gorm.io/gorm"
)
type ShopSeriesAllocationConfigStore struct {
db *gorm.DB
}
func NewShopSeriesAllocationConfigStore(db *gorm.DB) *ShopSeriesAllocationConfigStore {
return &ShopSeriesAllocationConfigStore{db: db}
}
func (s *ShopSeriesAllocationConfigStore) Create(ctx context.Context, config *model.ShopSeriesAllocationConfig) error {
return s.db.WithContext(ctx).Create(config).Error
}
func (s *ShopSeriesAllocationConfigStore) GetEffective(ctx context.Context, allocationID uint, at time.Time) (*model.ShopSeriesAllocationConfig, error) {
var config model.ShopSeriesAllocationConfig
err := s.db.WithContext(ctx).
Where("allocation_id = ?", allocationID).
Where("effective_from <= ?", at).
Where("effective_to IS NULL OR effective_to > ?", at).
First(&config).Error
if err != nil {
return nil, err
}
return &config, nil
}
func (s *ShopSeriesAllocationConfigStore) GetLatestVersion(ctx context.Context, allocationID uint) (*model.ShopSeriesAllocationConfig, error) {
var config model.ShopSeriesAllocationConfig
err := s.db.WithContext(ctx).
Where("allocation_id = ?", allocationID).
Order("version DESC").
First(&config).Error
if err != nil {
return nil, err
}
return &config, nil
}
func (s *ShopSeriesAllocationConfigStore) InvalidateCurrent(ctx context.Context, allocationID uint, effectiveTo time.Time) error {
return s.db.WithContext(ctx).
Model(&model.ShopSeriesAllocationConfig{}).
Where("allocation_id = ? AND effective_to IS NULL", allocationID).
Update("effective_to", effectiveTo).Error
}
func (s *ShopSeriesAllocationConfigStore) List(ctx context.Context, allocationID uint) ([]*model.ShopSeriesAllocationConfig, error) {
var configs []*model.ShopSeriesAllocationConfig
err := s.db.WithContext(ctx).
Where("allocation_id = ?", allocationID).
Order("version DESC").
Find(&configs).Error
if err != nil {
return nil, err
}
return configs, nil
}

View File

@@ -1,281 +0,0 @@
package postgres
import (
"context"
"testing"
"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/break/junhong_cmp_fiber/tests/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShopSeriesAllocationStore_Create(t *testing.T) {
tx := testutils.NewTestTransaction(t)
s := NewShopSeriesAllocationStore(tx)
ctx := context.Background()
allocation := &model.ShopSeriesAllocation{
ShopID: 1,
SeriesID: 1,
AllocatorShopID: 0,
PricingMode: model.PricingModeFixed,
PricingValue: 1000,
Status: constants.StatusEnabled,
}
err := s.Create(ctx, allocation)
require.NoError(t, err)
assert.NotZero(t, allocation.ID)
}
func TestShopSeriesAllocationStore_GetByID(t *testing.T) {
tx := testutils.NewTestTransaction(t)
s := NewShopSeriesAllocationStore(tx)
ctx := context.Background()
allocation := &model.ShopSeriesAllocation{
ShopID: 2,
SeriesID: 2,
AllocatorShopID: 0,
PricingMode: model.PricingModePercent,
PricingValue: 500,
Status: constants.StatusEnabled,
}
require.NoError(t, s.Create(ctx, allocation))
t.Run("查询存在的分配", func(t *testing.T) {
result, err := s.GetByID(ctx, allocation.ID)
require.NoError(t, err)
assert.Equal(t, allocation.ShopID, result.ShopID)
assert.Equal(t, allocation.SeriesID, result.SeriesID)
assert.Equal(t, allocation.PricingMode, result.PricingMode)
})
t.Run("查询不存在的分配", func(t *testing.T) {
_, err := s.GetByID(ctx, 99999)
require.Error(t, err)
})
}
func TestShopSeriesAllocationStore_GetByShopAndSeries(t *testing.T) {
tx := testutils.NewTestTransaction(t)
s := NewShopSeriesAllocationStore(tx)
ctx := context.Background()
allocation := &model.ShopSeriesAllocation{
ShopID: 3,
SeriesID: 3,
AllocatorShopID: 0,
PricingMode: model.PricingModeFixed,
PricingValue: 2000,
Status: constants.StatusEnabled,
}
require.NoError(t, s.Create(ctx, allocation))
t.Run("查询存在的店铺和系列组合", func(t *testing.T) {
result, err := s.GetByShopAndSeries(ctx, 3, 3)
require.NoError(t, err)
assert.Equal(t, allocation.ID, result.ID)
assert.Equal(t, uint(3), result.ShopID)
assert.Equal(t, uint(3), result.SeriesID)
})
t.Run("查询不存在的组合", func(t *testing.T) {
_, err := s.GetByShopAndSeries(ctx, 99, 99)
require.Error(t, err)
})
}
func TestShopSeriesAllocationStore_Update(t *testing.T) {
tx := testutils.NewTestTransaction(t)
s := NewShopSeriesAllocationStore(tx)
ctx := context.Background()
allocation := &model.ShopSeriesAllocation{
ShopID: 4,
SeriesID: 4,
AllocatorShopID: 0,
PricingMode: model.PricingModeFixed,
PricingValue: 1500,
Status: constants.StatusEnabled,
}
require.NoError(t, s.Create(ctx, allocation))
allocation.PricingValue = 2500
allocation.PricingMode = model.PricingModePercent
err := s.Update(ctx, allocation)
require.NoError(t, err)
updated, err := s.GetByID(ctx, allocation.ID)
require.NoError(t, err)
assert.Equal(t, int64(2500), updated.PricingValue)
assert.Equal(t, model.PricingModePercent, updated.PricingMode)
}
func TestShopSeriesAllocationStore_Delete(t *testing.T) {
tx := testutils.NewTestTransaction(t)
s := NewShopSeriesAllocationStore(tx)
ctx := context.Background()
allocation := &model.ShopSeriesAllocation{
ShopID: 5,
SeriesID: 5,
AllocatorShopID: 0,
PricingMode: model.PricingModeFixed,
PricingValue: 1000,
Status: constants.StatusEnabled,
}
require.NoError(t, s.Create(ctx, allocation))
err := s.Delete(ctx, allocation.ID)
require.NoError(t, err)
_, err = s.GetByID(ctx, allocation.ID)
require.Error(t, err)
}
func TestShopSeriesAllocationStore_List(t *testing.T) {
tx := testutils.NewTestTransaction(t)
s := NewShopSeriesAllocationStore(tx)
ctx := context.Background()
allocations := []*model.ShopSeriesAllocation{
{ShopID: 10, SeriesID: 10, AllocatorShopID: 0, PricingMode: model.PricingModeFixed, PricingValue: 1000, Status: constants.StatusEnabled},
{ShopID: 11, SeriesID: 11, AllocatorShopID: 0, PricingMode: model.PricingModePercent, PricingValue: 500, Status: constants.StatusEnabled},
{ShopID: 12, SeriesID: 12, AllocatorShopID: 1, PricingMode: model.PricingModeFixed, PricingValue: 2000, Status: constants.StatusEnabled},
}
for _, a := range allocations {
require.NoError(t, s.Create(ctx, a))
}
// 显式更新第三个分配为禁用状态
allocations[2].Status = constants.StatusDisabled
require.NoError(t, s.Update(ctx, allocations[2]))
t.Run("查询所有分配", func(t *testing.T) {
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, nil)
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(3))
assert.GreaterOrEqual(t, len(result), 3)
})
t.Run("按店铺ID过滤", func(t *testing.T) {
filters := map[string]interface{}{"shop_id": uint(10)}
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(1))
for _, a := range result {
assert.Equal(t, uint(10), a.ShopID)
}
})
t.Run("按系列ID过滤", func(t *testing.T) {
filters := map[string]interface{}{"series_id": uint(11)}
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(1))
for _, a := range result {
assert.Equal(t, uint(11), a.SeriesID)
}
})
t.Run("按分配者店铺ID过滤", func(t *testing.T) {
filters := map[string]interface{}{"allocator_shop_id": uint(1)}
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(1))
for _, a := range result {
assert.Equal(t, uint(1), a.AllocatorShopID)
}
})
t.Run("按状态过滤-启用状态值为1", func(t *testing.T) {
filters := map[string]interface{}{"status": 1}
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(2))
for _, a := range result {
assert.Equal(t, 1, a.Status)
}
})
t.Run("按状态过滤-启用", func(t *testing.T) {
filters := map[string]interface{}{"status": constants.StatusEnabled}
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(2))
for _, a := range result {
assert.Equal(t, constants.StatusEnabled, a.Status)
}
})
t.Run("分页查询", func(t *testing.T) {
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 2}, nil)
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(3))
assert.LessOrEqual(t, len(result), 2)
})
t.Run("默认分页选项", func(t *testing.T) {
result, _, err := s.List(ctx, nil, nil)
require.NoError(t, err)
assert.NotNil(t, result)
})
}
func TestShopSeriesAllocationStore_UpdateStatus(t *testing.T) {
tx := testutils.NewTestTransaction(t)
s := NewShopSeriesAllocationStore(tx)
ctx := context.Background()
allocation := &model.ShopSeriesAllocation{
ShopID: 20,
SeriesID: 20,
AllocatorShopID: 0,
PricingMode: model.PricingModeFixed,
PricingValue: 1000,
Status: constants.StatusEnabled,
}
require.NoError(t, s.Create(ctx, allocation))
err := s.UpdateStatus(ctx, allocation.ID, constants.StatusDisabled, 1)
require.NoError(t, err)
updated, err := s.GetByID(ctx, allocation.ID)
require.NoError(t, err)
assert.Equal(t, constants.StatusDisabled, updated.Status)
assert.Equal(t, uint(1), updated.Updater)
}
func TestShopSeriesAllocationStore_HasDependentAllocations(t *testing.T) {
tx := testutils.NewTestTransaction(t)
s := NewShopSeriesAllocationStore(tx)
ctx := context.Background()
allocation := &model.ShopSeriesAllocation{
ShopID: 30,
SeriesID: 30,
AllocatorShopID: 100,
PricingMode: model.PricingModeFixed,
PricingValue: 1000,
Status: constants.StatusEnabled,
}
require.NoError(t, s.Create(ctx, allocation))
t.Run("检查存在的依赖分配", func(t *testing.T) {
// 注意:这个测试依赖于数据库中存在特定的店铺层级关系
// 由于测试环境可能没有这样的关系,我们只验证函数可以执行
has, err := s.HasDependentAllocations(ctx, 100, 30)
require.NoError(t, err)
// 结果取决于数据库中的实际店铺关系
assert.IsType(t, true, has)
})
t.Run("检查不存在的依赖分配", func(t *testing.T) {
has, err := s.HasDependentAllocations(ctx, 99999, 99999)
require.NoError(t, err)
assert.False(t, has)
})
}

View File

@@ -0,0 +1,70 @@
package postgres
import (
"context"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"gorm.io/gorm"
)
type ShopSeriesCommissionStatsStore struct {
db *gorm.DB
}
func NewShopSeriesCommissionStatsStore(db *gorm.DB) *ShopSeriesCommissionStatsStore {
return &ShopSeriesCommissionStatsStore{db: db}
}
func (s *ShopSeriesCommissionStatsStore) Create(ctx context.Context, stats *model.ShopSeriesCommissionStats) error {
return s.db.WithContext(ctx).Create(stats).Error
}
func (s *ShopSeriesCommissionStatsStore) GetCurrent(ctx context.Context, allocationID uint, periodType string, now time.Time) (*model.ShopSeriesCommissionStats, error) {
var stats model.ShopSeriesCommissionStats
err := s.db.WithContext(ctx).
Where("allocation_id = ?", allocationID).
Where("period_type = ?", periodType).
Where("period_start <= ? AND period_end >= ?", now, now).
Where("status = ?", model.StatsStatusActive).
First(&stats).Error
if err != nil {
return nil, err
}
return &stats, nil
}
func (s *ShopSeriesCommissionStatsStore) Update(ctx context.Context, stats *model.ShopSeriesCommissionStats) error {
return s.db.WithContext(ctx).Save(stats).Error
}
func (s *ShopSeriesCommissionStatsStore) IncrementSales(ctx context.Context, id uint, salesCount int64, salesAmount int64, version int) error {
return s.db.WithContext(ctx).
Model(&model.ShopSeriesCommissionStats{}).
Where("id = ? AND version = ?", id, version).
Updates(map[string]interface{}{
"total_sales_count": gorm.Expr("total_sales_count + ?", salesCount),
"total_sales_amount": gorm.Expr("total_sales_amount + ?", salesAmount),
"last_updated_at": time.Now(),
"version": gorm.Expr("version + 1"),
}).Error
}
func (s *ShopSeriesCommissionStatsStore) CompletePeriod(ctx context.Context, id uint) error {
return s.db.WithContext(ctx).
Model(&model.ShopSeriesCommissionStats{}).
Where("id = ?", id).
Update("status", model.StatsStatusCompleted).Error
}
func (s *ShopSeriesCommissionStatsStore) ListExpired(ctx context.Context, before time.Time) ([]*model.ShopSeriesCommissionStats, error) {
var stats []*model.ShopSeriesCommissionStats
err := s.db.WithContext(ctx).
Where("period_end < ?", before).
Where("status = ?", model.StatsStatusActive).
Find(&stats).Error
if err != nil {
return nil, err
}
return stats, nil
}