Files
junhong_cmp_fiber/internal/service/package/service.go
huang 1da680a790
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
2026-01-28 17:11:55 +08:00

424 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package packagepkg
import (
"context"
"fmt"
"time"
"gorm.io/gorm"
"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"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
type Service struct {
packageStore *postgres.PackageStore
packageSeriesStore *postgres.PackageSeriesStore
packageAllocationStore *postgres.ShopPackageAllocationStore
seriesAllocationStore *postgres.ShopSeriesAllocationStore
commissionTierStore *postgres.ShopSeriesCommissionTierStore
}
func New(
packageStore *postgres.PackageStore,
packageSeriesStore *postgres.PackageSeriesStore,
packageAllocationStore *postgres.ShopPackageAllocationStore,
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
commissionTierStore *postgres.ShopSeriesCommissionTierStore,
) *Service {
return &Service{
packageStore: packageStore,
packageSeriesStore: packageSeriesStore,
packageAllocationStore: packageAllocationStore,
seriesAllocationStore: seriesAllocationStore,
commissionTierStore: commissionTierStore,
}
}
func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*dto.PackageResponse, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
existing, _ := s.packageStore.GetByCode(ctx, req.PackageCode)
if existing != nil {
return nil, errors.New(errors.CodeConflict, "套餐编码已存在")
}
var seriesName *string
if req.SeriesID != nil && *req.SeriesID > 0 {
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{
PackageCode: req.PackageCode,
PackageName: req.PackageName,
PackageType: req.PackageType,
DurationMonths: req.DurationMonths,
Price: req.Price,
Status: constants.StatusEnabled,
ShelfStatus: 2,
}
if req.SeriesID != nil {
pkg.SeriesID = *req.SeriesID
}
if req.DataType != nil {
pkg.DataType = *req.DataType
}
if req.RealDataMB != nil {
pkg.RealDataMB = *req.RealDataMB
}
if req.VirtualDataMB != nil {
pkg.VirtualDataMB = *req.VirtualDataMB
}
if req.DataAmountMB != nil {
pkg.DataAmountMB = *req.DataAmountMB
}
if req.SuggestedCostPrice != nil {
pkg.SuggestedCostPrice = *req.SuggestedCostPrice
}
if req.SuggestedRetailPrice != nil {
pkg.SuggestedRetailPrice = *req.SuggestedRetailPrice
}
pkg.Creator = currentUserID
if err := s.packageStore.Create(ctx, pkg); err != nil {
return nil, fmt.Errorf("创建套餐失败: %w", err)
}
resp := s.toResponse(ctx, pkg)
resp.SeriesName = seriesName
return resp, nil
}
func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageResponse, error) {
pkg, err := s.packageStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "套餐不存在")
}
return nil, fmt.Errorf("获取套餐失败: %w", err)
}
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) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
pkg, err := s.packageStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "套餐不存在")
}
return nil, fmt.Errorf("获取套餐失败: %w", err)
}
var seriesName *string
if req.SeriesID != nil && *req.SeriesID > 0 {
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)
}
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 {
pkg.PackageName = *req.PackageName
}
if req.PackageType != nil {
pkg.PackageType = *req.PackageType
}
if req.DurationMonths != nil {
pkg.DurationMonths = *req.DurationMonths
}
if req.DataType != nil {
pkg.DataType = *req.DataType
}
if req.RealDataMB != nil {
pkg.RealDataMB = *req.RealDataMB
}
if req.VirtualDataMB != nil {
pkg.VirtualDataMB = *req.VirtualDataMB
}
if req.DataAmountMB != nil {
pkg.DataAmountMB = *req.DataAmountMB
}
if req.Price != nil {
pkg.Price = *req.Price
}
if req.SuggestedCostPrice != nil {
pkg.SuggestedCostPrice = *req.SuggestedCostPrice
}
if req.SuggestedRetailPrice != nil {
pkg.SuggestedRetailPrice = *req.SuggestedRetailPrice
}
pkg.Updater = currentUserID
if err := s.packageStore.Update(ctx, pkg); err != nil {
return nil, fmt.Errorf("更新套餐失败: %w", err)
}
resp := s.toResponse(ctx, pkg)
resp.SeriesName = seriesName
return resp, nil
}
func (s *Service) Delete(ctx context.Context, id uint) error {
_, err := s.packageStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "套餐不存在")
}
return fmt.Errorf("获取套餐失败: %w", err)
}
if err := s.packageStore.Delete(ctx, id); err != nil {
return fmt.Errorf("删除套餐失败: %w", err)
}
return nil
}
func (s *Service) List(ctx context.Context, req *dto.PackageListRequest) ([]*dto.PackageResponse, int64, error) {
opts := &store.QueryOptions{
Page: req.Page,
PageSize: req.PageSize,
OrderBy: "id DESC",
}
if opts.Page == 0 {
opts.Page = 1
}
if opts.PageSize == 0 {
opts.PageSize = constants.DefaultPageSize
}
filters := make(map[string]interface{})
if req.PackageName != nil {
filters["package_name"] = *req.PackageName
}
if req.SeriesID != nil {
filters["series_id"] = *req.SeriesID
}
if req.Status != nil {
filters["status"] = *req.Status
}
if req.ShelfStatus != nil {
filters["shelf_status"] = *req.ShelfStatus
}
if req.PackageType != nil {
filters["package_type"] = *req.PackageType
}
packages, total, err := s.packageStore.List(ctx, opts, filters)
if err != nil {
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 {
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
}
func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
pkg, err := s.packageStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "套餐不存在")
}
return fmt.Errorf("获取套餐失败: %w", err)
}
pkg.Status = status
pkg.Updater = currentUserID
if status == constants.StatusDisabled {
pkg.ShelfStatus = 2
}
if err := s.packageStore.Update(ctx, pkg); err != nil {
return fmt.Errorf("更新套餐状态失败: %w", err)
}
return nil
}
func (s *Service) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus int) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
pkg, err := s.packageStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "套餐不存在")
}
return fmt.Errorf("获取套餐失败: %w", err)
}
if shelfStatus == 1 && pkg.Status == constants.StatusDisabled {
return errors.New(errors.CodeInvalidStatus, "禁用的套餐不能上架,请先启用")
}
pkg.ShelfStatus = shelfStatus
pkg.Updater = currentUserID
if err := s.packageStore.Update(ctx, pkg); err != nil {
return fmt.Errorf("更新套餐上架状态失败: %w", err)
}
return nil
}
func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.PackageResponse {
var seriesID *uint
if pkg.SeriesID > 0 {
seriesID = &pkg.SeriesID
}
resp := &dto.PackageResponse{
ID: pkg.ID,
PackageCode: pkg.PackageCode,
PackageName: pkg.PackageName,
SeriesID: seriesID,
PackageType: pkg.PackageType,
DurationMonths: pkg.DurationMonths,
DataType: pkg.DataType,
RealDataMB: pkg.RealDataMB,
VirtualDataMB: pkg.VirtualDataMB,
DataAmountMB: pkg.DataAmountMB,
Price: pkg.Price,
SuggestedCostPrice: pkg.SuggestedCostPrice,
SuggestedRetailPrice: pkg.SuggestedRetailPrice,
Status: pkg.Status,
ShelfStatus: pkg.ShelfStatus,
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
}