重构: 店铺套餐分配系统从加价模式改为返佣模式
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m18s
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:
@@ -17,14 +17,26 @@ import (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
packageStore *postgres.PackageStore
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
packageStore *postgres.PackageStore
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
packageAllocationStore *postgres.ShopPackageAllocationStore
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
commissionTierStore *postgres.ShopSeriesCommissionTierStore
|
||||
}
|
||||
|
||||
func New(packageStore *postgres.PackageStore, packageSeriesStore *postgres.PackageSeriesStore) *Service {
|
||||
func New(
|
||||
packageStore *postgres.PackageStore,
|
||||
packageSeriesStore *postgres.PackageSeriesStore,
|
||||
packageAllocationStore *postgres.ShopPackageAllocationStore,
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||
commissionTierStore *postgres.ShopSeriesCommissionTierStore,
|
||||
) *Service {
|
||||
return &Service{
|
||||
packageStore: packageStore,
|
||||
packageSeriesStore: packageSeriesStore,
|
||||
packageStore: packageStore,
|
||||
packageSeriesStore: packageSeriesStore,
|
||||
packageAllocationStore: packageAllocationStore,
|
||||
seriesAllocationStore: seriesAllocationStore,
|
||||
commissionTierStore: commissionTierStore,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,14 +51,16 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
|
||||
return nil, errors.New(errors.CodeConflict, "套餐编码已存在")
|
||||
}
|
||||
|
||||
var seriesName *string
|
||||
if req.SeriesID != nil && *req.SeriesID > 0 {
|
||||
_, err := s.packageSeriesStore.GetByID(ctx, *req.SeriesID)
|
||||
series, err := s.packageSeriesStore.GetByID(ctx, *req.SeriesID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "套餐系列不存在")
|
||||
}
|
||||
return nil, fmt.Errorf("获取套餐系列失败: %w", err)
|
||||
}
|
||||
seriesName = &series.SeriesName
|
||||
}
|
||||
|
||||
pkg := &model.Package{
|
||||
@@ -85,7 +99,9 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
|
||||
return nil, fmt.Errorf("创建套餐失败: %w", err)
|
||||
}
|
||||
|
||||
return s.toResponse(pkg), nil
|
||||
resp := s.toResponse(ctx, pkg)
|
||||
resp.SeriesName = seriesName
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageResponse, error) {
|
||||
@@ -96,7 +112,16 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageResponse, error
|
||||
}
|
||||
return nil, fmt.Errorf("获取套餐失败: %w", err)
|
||||
}
|
||||
return s.toResponse(pkg), nil
|
||||
|
||||
resp := s.toResponse(ctx, pkg)
|
||||
// 查询系列名称
|
||||
if pkg.SeriesID > 0 {
|
||||
series, err := s.packageSeriesStore.GetByID(ctx, pkg.SeriesID)
|
||||
if err == nil {
|
||||
resp.SeriesName = &series.SeriesName
|
||||
}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageRequest) (*dto.PackageResponse, error) {
|
||||
@@ -113,8 +138,9 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq
|
||||
return nil, fmt.Errorf("获取套餐失败: %w", err)
|
||||
}
|
||||
|
||||
var seriesName *string
|
||||
if req.SeriesID != nil && *req.SeriesID > 0 {
|
||||
_, err := s.packageSeriesStore.GetByID(ctx, *req.SeriesID)
|
||||
series, err := s.packageSeriesStore.GetByID(ctx, *req.SeriesID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "套餐系列不存在")
|
||||
@@ -122,6 +148,13 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq
|
||||
return nil, fmt.Errorf("获取套餐系列失败: %w", err)
|
||||
}
|
||||
pkg.SeriesID = *req.SeriesID
|
||||
seriesName = &series.SeriesName
|
||||
} else if pkg.SeriesID > 0 {
|
||||
// 如果没有更新 SeriesID,但现有套餐有 SeriesID,则查询当前的系列名称
|
||||
series, err := s.packageSeriesStore.GetByID(ctx, pkg.SeriesID)
|
||||
if err == nil {
|
||||
seriesName = &series.SeriesName
|
||||
}
|
||||
}
|
||||
|
||||
if req.PackageName != nil {
|
||||
@@ -160,7 +193,9 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq
|
||||
return nil, fmt.Errorf("更新套餐失败: %w", err)
|
||||
}
|
||||
|
||||
return s.toResponse(pkg), nil
|
||||
resp := s.toResponse(ctx, pkg)
|
||||
resp.SeriesName = seriesName
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
@@ -214,9 +249,40 @@ func (s *Service) List(ctx context.Context, req *dto.PackageListRequest) ([]*dto
|
||||
return nil, 0, fmt.Errorf("查询套餐列表失败: %w", err)
|
||||
}
|
||||
|
||||
// 收集所有唯一的 series_id
|
||||
seriesIDMap := make(map[uint]bool)
|
||||
for _, pkg := range packages {
|
||||
if pkg.SeriesID > 0 {
|
||||
seriesIDMap[pkg.SeriesID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 批量查询套餐系列
|
||||
seriesMap := make(map[uint]string)
|
||||
if len(seriesIDMap) > 0 {
|
||||
seriesIDs := make([]uint, 0, len(seriesIDMap))
|
||||
for id := range seriesIDMap {
|
||||
seriesIDs = append(seriesIDs, id)
|
||||
}
|
||||
seriesList, err := s.packageSeriesStore.GetByIDs(ctx, seriesIDs)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("批量查询套餐系列失败: %w", err)
|
||||
}
|
||||
for _, series := range seriesList {
|
||||
seriesMap[series.ID] = series.SeriesName
|
||||
}
|
||||
}
|
||||
|
||||
// 构建响应,填充系列名称
|
||||
responses := make([]*dto.PackageResponse, len(packages))
|
||||
for i, pkg := range packages {
|
||||
responses[i] = s.toResponse(pkg)
|
||||
resp := s.toResponse(ctx, pkg)
|
||||
if pkg.SeriesID > 0 {
|
||||
if seriesName, ok := seriesMap[pkg.SeriesID]; ok {
|
||||
resp.SeriesName = &seriesName
|
||||
}
|
||||
}
|
||||
responses[i] = resp
|
||||
}
|
||||
|
||||
return responses, total, nil
|
||||
@@ -278,12 +344,13 @@ func (s *Service) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus in
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) toResponse(pkg *model.Package) *dto.PackageResponse {
|
||||
func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.PackageResponse {
|
||||
var seriesID *uint
|
||||
if pkg.SeriesID > 0 {
|
||||
seriesID = &pkg.SeriesID
|
||||
}
|
||||
return &dto.PackageResponse{
|
||||
|
||||
resp := &dto.PackageResponse{
|
||||
ID: pkg.ID,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
@@ -302,4 +369,55 @@ func (s *Service) toResponse(pkg *model.Package) *dto.PackageResponse {
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
shopID := middleware.GetShopIDFromContext(ctx)
|
||||
if userType == constants.UserTypeAgent && shopID > 0 {
|
||||
allocation, err := s.packageAllocationStore.GetByShopAndPackage(ctx, shopID, pkg.ID)
|
||||
if err == nil && allocation != nil {
|
||||
resp.CostPrice = &allocation.CostPrice
|
||||
profitMargin := pkg.SuggestedRetailPrice - allocation.CostPrice
|
||||
resp.ProfitMargin = &profitMargin
|
||||
|
||||
commissionInfo := s.getCommissionInfo(ctx, allocation.AllocationID)
|
||||
if commissionInfo != nil {
|
||||
resp.CurrentCommissionRate = commissionInfo.CurrentRate
|
||||
resp.TierInfo = commissionInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func (s *Service) getCommissionInfo(ctx context.Context, allocationID uint) *dto.CommissionTierInfo {
|
||||
seriesAllocation, err := s.seriesAllocationStore.GetByID(ctx, allocationID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
info := &dto.CommissionTierInfo{}
|
||||
|
||||
if seriesAllocation.BaseCommissionMode == constants.CommissionModeFixed {
|
||||
info.CurrentRate = fmt.Sprintf("%.2f元/单", float64(seriesAllocation.BaseCommissionValue)/100)
|
||||
} else {
|
||||
info.CurrentRate = fmt.Sprintf("%.1f%%", float64(seriesAllocation.BaseCommissionValue)/10)
|
||||
}
|
||||
|
||||
if seriesAllocation.EnableTierCommission {
|
||||
tiers, err := s.commissionTierStore.ListByAllocationID(ctx, allocationID)
|
||||
if err == nil && len(tiers) > 0 {
|
||||
tier := tiers[0]
|
||||
info.NextThreshold = &tier.ThresholdValue
|
||||
if tier.CommissionMode == constants.CommissionModeFixed {
|
||||
nextRate := fmt.Sprintf("%.2f元/单", float64(tier.CommissionValue)/100)
|
||||
info.NextRate = nextRate
|
||||
} else {
|
||||
nextRate := fmt.Sprintf("%.1f%%", float64(tier.CommissionValue)/10)
|
||||
info.NextRate = nextRate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -24,7 +25,7 @@ func TestPackageService_Create(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
@@ -97,7 +98,7 @@ func TestPackageService_UpdateStatus(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
@@ -167,7 +168,7 @@ func TestPackageService_UpdateShelfStatus(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
@@ -254,7 +255,7 @@ func TestPackageService_Get(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
@@ -292,7 +293,7 @@ func TestPackageService_Update(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
@@ -341,7 +342,7 @@ func TestPackageService_Delete(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
@@ -376,7 +377,7 @@ func TestPackageService_List(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
@@ -454,3 +455,135 @@ func TestPackageService_List(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageService_SeriesNameInResponse(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
// 创建套餐系列
|
||||
series := &model.PackageSeries{
|
||||
SeriesCode: fmt.Sprintf("SERIES_%d", time.Now().UnixNano()),
|
||||
SeriesName: "测试套餐系列",
|
||||
Description: "用于测试系列名称字段",
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
series.Creator = 1
|
||||
err := packageSeriesStore.Create(ctx, series)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("创建套餐时返回系列名称", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_SERIES"),
|
||||
PackageName: "带系列的套餐",
|
||||
SeriesID: &series.ID,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
}
|
||||
|
||||
resp, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, resp.SeriesName)
|
||||
assert.Equal(t, series.SeriesName, *resp.SeriesName)
|
||||
})
|
||||
|
||||
t.Run("获取套餐时返回系列名称", func(t *testing.T) {
|
||||
// 先创建一个套餐
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_GET_SERIES"),
|
||||
PackageName: "获取测试套餐",
|
||||
SeriesID: &series.ID,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 获取套餐
|
||||
resp, err := svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, resp.SeriesName)
|
||||
assert.Equal(t, series.SeriesName, *resp.SeriesName)
|
||||
})
|
||||
|
||||
t.Run("更新套餐时返回系列名称", func(t *testing.T) {
|
||||
// 先创建一个套餐
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_UPDATE_SERIES"),
|
||||
PackageName: "更新测试套餐",
|
||||
SeriesID: &series.ID,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 更新套餐
|
||||
newName := "更新后的套餐"
|
||||
updateReq := &dto.UpdatePackageRequest{
|
||||
PackageName: &newName,
|
||||
}
|
||||
resp, err := svc.Update(ctx, created.ID, updateReq)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, resp.SeriesName)
|
||||
assert.Equal(t, series.SeriesName, *resp.SeriesName)
|
||||
})
|
||||
|
||||
t.Run("列表查询时返回系列名称", func(t *testing.T) {
|
||||
// 创建多个带系列的套餐
|
||||
for i := 0; i < 3; i++ {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode(fmt.Sprintf("PKG_LIST_SERIES_%d", i)),
|
||||
PackageName: fmt.Sprintf("列表测试套餐%d", i),
|
||||
SeriesID: &series.ID,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
}
|
||||
_, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 查询列表
|
||||
listReq := &dto.PackageListRequest{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
SeriesID: &series.ID,
|
||||
}
|
||||
resp, _, err := svc.List(ctx, listReq)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, len(resp), 0)
|
||||
|
||||
// 验证所有套餐都有系列名称
|
||||
for _, pkg := range resp {
|
||||
if pkg.SeriesID != nil && *pkg.SeriesID == series.ID {
|
||||
assert.NotNil(t, pkg.SeriesName)
|
||||
assert.Equal(t, series.SeriesName, *pkg.SeriesName)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("没有系列的套餐SeriesName为空", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_NO_SERIES"),
|
||||
PackageName: "无系列套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
}
|
||||
|
||||
resp, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, resp.SeriesID)
|
||||
assert.Nil(t, resp.SeriesName)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user