diff --git a/internal/service/shop_series_grant/service.go b/internal/service/shop_series_grant/service.go new file mode 100644 index 0000000..a5bacdd --- /dev/null +++ b/internal/service/shop_series_grant/service.go @@ -0,0 +1,722 @@ +// Package shop_series_grant 提供代理系列授权的业务逻辑服务 +// 包含授权创建、查询、更新、套餐管理、删除等功能 +package shop_series_grant + +import ( + "context" + "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" + "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" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// Service 代理系列授权业务服务 +type Service struct { + db *gorm.DB + shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore + shopPackageAllocationStore *postgres.ShopPackageAllocationStore + shopPackageAllocationPriceHistoryStore *postgres.ShopPackageAllocationPriceHistoryStore + shopStore *postgres.ShopStore + packageStore *postgres.PackageStore + packageSeriesStore *postgres.PackageSeriesStore + logger *zap.Logger +} + +// New 创建代理系列授权服务实例 +func New( + db *gorm.DB, + shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore, + shopPackageAllocationStore *postgres.ShopPackageAllocationStore, + shopPackageAllocationPriceHistoryStore *postgres.ShopPackageAllocationPriceHistoryStore, + shopStore *postgres.ShopStore, + packageStore *postgres.PackageStore, + packageSeriesStore *postgres.PackageSeriesStore, + logger *zap.Logger, +) *Service { + return &Service{ + db: db, + shopSeriesAllocationStore: shopSeriesAllocationStore, + shopPackageAllocationStore: shopPackageAllocationStore, + shopPackageAllocationPriceHistoryStore: shopPackageAllocationPriceHistoryStore, + shopStore: shopStore, + packageStore: packageStore, + packageSeriesStore: packageSeriesStore, + logger: logger, + } +} + +// getParentCeilingFixed 查询固定模式佣金天花板 +// allocatorShopID=0 表示平台分配,天花板为 PackageSeries.commission_amount +// allocatorShopID>0 表示代理分配,天花板为分配者自身的 ShopSeriesAllocation.one_time_commission_amount +func (s *Service) getParentCeilingFixed(ctx context.Context, allocatorShopID uint, seriesID uint, config *model.OneTimeCommissionConfig) (int64, error) { + if allocatorShopID == 0 { + // 平台分配:天花板为套餐系列全局佣金金额 + return config.CommissionAmount, nil + } + // 代理分配:天花板为分配者自身拥有的授权金额 + allocatorAllocation, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, allocatorShopID, seriesID) + if err != nil { + return 0, errors.Wrap(errors.CodeNotFound, err, "分配者无此系列授权") + } + return allocatorAllocation.OneTimeCommissionAmount, nil +} + +// getParentCeilingTiered 查询梯度模式各档位天花板(map: threshold→amount) +// allocatorShopID=0 → 读 PackageSeries 全局 Tiers 中各 threshold 的 amount +// allocatorShopID>0 → 读分配者自身 ShopSeriesAllocation.commission_tiers_json +func (s *Service) getParentCeilingTiered(ctx context.Context, allocatorShopID uint, seriesID uint, globalTiers []model.OneTimeCommissionTier) (map[int64]int64, error) { + ceilingMap := make(map[int64]int64) + + if allocatorShopID == 0 { + // 平台分配:天花板来自套餐系列全局配置 + for _, t := range globalTiers { + ceilingMap[t.Threshold] = t.Amount + } + return ceilingMap, nil + } + + // 代理分配:天花板来自分配者的专属阶梯 + allocatorAllocation, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, allocatorShopID, seriesID) + if err != nil { + return nil, errors.Wrap(errors.CodeNotFound, err, "分配者无此系列授权") + } + agentTiers, err := allocatorAllocation.GetCommissionTiers() + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "解析分配者阶梯佣金失败") + } + for _, t := range agentTiers { + ceilingMap[t.Threshold] = t.Amount + } + return ceilingMap, nil +} + +// buildGrantResponse 构建授权详情响应 +func (s *Service) buildGrantResponse(ctx context.Context, allocation *model.ShopSeriesAllocation, series *model.PackageSeries, config *model.OneTimeCommissionConfig) (*dto.ShopSeriesGrantResponse, error) { + resp := &dto.ShopSeriesGrantResponse{ + ID: allocation.ID, + ShopID: allocation.ShopID, + SeriesID: allocation.SeriesID, + SeriesName: series.SeriesName, + SeriesCode: series.SeriesCode, + CommissionType: config.CommissionType, + AllocatorShopID: allocation.AllocatorShopID, + Status: allocation.Status, + CreatedAt: allocation.CreatedAt.Format(time.DateTime), + UpdatedAt: allocation.UpdatedAt.Format(time.DateTime), + } + + // 查询店铺名称 + shop, err := s.shopStore.GetByID(ctx, allocation.ShopID) + if err == nil { + resp.ShopName = shop.ShopName + } + + // 查询分配者名称 + if allocation.AllocatorShopID > 0 { + allocatorShop, err := s.shopStore.GetByID(ctx, allocation.AllocatorShopID) + if err == nil { + resp.AllocatorShopName = allocatorShop.ShopName + } + } else { + resp.AllocatorShopName = "平台" + } + + // 强充状态:first_recharge 或平台已启用 accumulated_recharge 强充时,锁定不可改 + forceRechargeLocked := config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge || config.EnableForceRecharge + resp.ForceRechargeLocked = forceRechargeLocked + resp.ForceRechargeEnabled = allocation.EnableForceRecharge + resp.ForceRechargeAmount = allocation.ForceRechargeAmount + + // 固定模式 + if config.CommissionType == "fixed" { + resp.OneTimeCommissionAmount = allocation.OneTimeCommissionAmount + resp.CommissionTiers = []dto.GrantCommissionTierItem{} + } else { + // 梯度模式:将代理专属金额与全局 operator 合并 + resp.OneTimeCommissionAmount = 0 + agentTiers, err := allocation.GetCommissionTiers() + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "解析代理阶梯佣金失败") + } + // 按 threshold 索引代理金额 + agentAmountMap := make(map[int64]int64) + for _, t := range agentTiers { + agentAmountMap[t.Threshold] = t.Amount + } + // 合并全局 operator 和代理 amount + tiers := make([]dto.GrantCommissionTierItem, 0, len(config.Tiers)) + for _, globalTier := range config.Tiers { + tiers = append(tiers, dto.GrantCommissionTierItem{ + Operator: globalTier.Operator, + Threshold: globalTier.Threshold, + Amount: agentAmountMap[globalTier.Threshold], + }) + } + resp.CommissionTiers = tiers + } + + // 查询已授权套餐列表 + pkgAllocations, err := s.shopPackageAllocationStore.GetBySeriesAllocationID(ctx, allocation.ID) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐分配失败") + } + packages := make([]dto.ShopSeriesGrantPackageItem, 0, len(pkgAllocations)) + for _, pa := range pkgAllocations { + pkg, pkgErr := s.packageStore.GetByID(ctx, pa.PackageID) + if pkgErr != nil { + continue + } + packages = append(packages, dto.ShopSeriesGrantPackageItem{ + PackageID: pa.PackageID, + PackageName: pkg.PackageName, + PackageCode: pkg.PackageCode, + CostPrice: pa.CostPrice, + ShelfStatus: pa.ShelfStatus, + Status: pa.Status, + }) + } + resp.Packages = packages + + return resp, nil +} + +// Create 创建系列授权 +// POST /api/admin/shop-series-grants +func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequest) (*dto.ShopSeriesGrantResponse, error) { + operatorID := middleware.GetUserIDFromContext(ctx) + operatorShopID := middleware.GetShopIDFromContext(ctx) + operatorType := middleware.GetUserTypeFromContext(ctx) + + // 1. 查询套餐系列,确认佣金类型 + series, err := s.packageSeriesStore.GetByID(ctx, req.SeriesID) + if err != nil { + return nil, errors.New(errors.CodeNotFound, "套餐系列不存在") + } + config, err := series.GetOneTimeCommissionConfig() + if err != nil || config == nil || !config.Enable { + return nil, errors.New(errors.CodeInvalidParam, "该系列未启用一次性佣金,无法创建授权") + } + + // 2. 检查重复授权 + exists, err := s.shopSeriesAllocationStore.ExistsByShopAndSeries(ctx, req.ShopID, req.SeriesID) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "检查授权重复失败") + } + if exists { + return nil, errors.New(errors.CodeConflict, "该代理已存在此系列授权") + } + + // 3. 确定 allocatorShopID(代理操作者必须自己有授权才能向下分配) + var allocatorShopID uint + if operatorType == constants.UserTypeAgent { + allocatorShopID = operatorShopID + _, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, allocatorShopID, req.SeriesID) + if err != nil { + return nil, errors.New(errors.CodeForbidden, "当前账号无此系列授权,无法向下分配") + } + } + // 平台/超管 allocatorShopID = 0 + + // 4. 参数验证:固定模式 or 梯度模式 + allocation := &model.ShopSeriesAllocation{ + ShopID: req.ShopID, + SeriesID: req.SeriesID, + AllocatorShopID: allocatorShopID, + Status: constants.StatusEnabled, + CommissionTiersJSON: "[]", + } + allocation.Creator = operatorID + allocation.Updater = operatorID + + if config.CommissionType == "fixed" { + if req.OneTimeCommissionAmount == nil { + return nil, errors.New(errors.CodeInvalidParam, "固定模式必须填写佣金金额") + } + ceiling, ceilErr := s.getParentCeilingFixed(ctx, allocatorShopID, req.SeriesID, config) + if ceilErr != nil { + return nil, ceilErr + } + if *req.OneTimeCommissionAmount > ceiling { + return nil, errors.New(errors.CodeInvalidParam, "佣金金额不能超过上级天花板") + } + allocation.OneTimeCommissionAmount = *req.OneTimeCommissionAmount + } else { + // 梯度模式 + if len(req.CommissionTiers) == 0 { + return nil, errors.New(errors.CodeInvalidParam, "梯度模式必须填写阶梯配置") + } + // 阶梯数量和 threshold 必须与全局完全一致 + if len(req.CommissionTiers) != len(config.Tiers) { + return nil, errors.New(errors.CodeInvalidParam, "梯度阶梯数量与系列配置不一致") + } + ceilingMap, ceilErr := s.getParentCeilingTiered(ctx, allocatorShopID, req.SeriesID, config.Tiers) + if ceilErr != nil { + return nil, ceilErr + } + agentTiers := make([]model.AllocationCommissionTier, 0, len(req.CommissionTiers)) + for i, tier := range req.CommissionTiers { + if tier.Threshold != config.Tiers[i].Threshold { + return nil, errors.New(errors.CodeInvalidParam, "梯度阶梯 threshold 与系列配置不匹配") + } + ceiling, ok := ceilingMap[tier.Threshold] + if !ok { + ceiling = 0 + } + if tier.Amount > ceiling { + return nil, errors.New(errors.CodeInvalidParam, "某档位佣金金额超过上级天花板") + } + agentTiers = append(agentTiers, model.AllocationCommissionTier{ + Threshold: tier.Threshold, + Amount: tier.Amount, + }) + } + if err := allocation.SetCommissionTiers(agentTiers); err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "序列化阶梯佣金失败") + } + } + + // 5. 强充配置:first_recharge 或平台已开强充 → locked,忽略代理传入 + forceRechargeLocked := config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge || config.EnableForceRecharge + if !forceRechargeLocked && req.EnableForceRecharge != nil && *req.EnableForceRecharge { + allocation.EnableForceRecharge = true + if req.ForceRechargeAmount != nil { + allocation.ForceRechargeAmount = *req.ForceRechargeAmount + } + } + + // 6. 事务中创建 ShopSeriesAllocation + N 条 ShopPackageAllocation + var result *dto.ShopSeriesGrantResponse + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + txSeriesStore := postgres.NewShopSeriesAllocationStore(tx) + if createErr := txSeriesStore.Create(ctx, allocation); createErr != nil { + return errors.Wrap(errors.CodeDatabaseError, createErr, "创建系列授权失败") + } + + // 创建套餐分配 + if len(req.Packages) > 0 { + txPkgStore := postgres.NewShopPackageAllocationStore(tx) + txHistoryStore := postgres.NewShopPackageAllocationPriceHistoryStore(tx) + for _, item := range req.Packages { + if item.Remove != nil && *item.Remove { + continue + } + // W1: 校验套餐归属于该系列,防止跨系列套餐混入 + pkg, pkgErr := s.packageStore.GetByID(ctx, item.PackageID) + if pkgErr != nil || pkg.SeriesID != req.SeriesID { + return errors.New(errors.CodeInvalidParam, "套餐不属于该系列,无法添加到此授权") + } + // W2: 代理操作时,校验分配者已拥有此套餐授权,防止越权分配 + if allocatorShopID > 0 { + _, authErr := s.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, allocatorShopID, item.PackageID) + if authErr != nil { + return errors.New(errors.CodeForbidden, "无权限分配该套餐") + } + } + pkgAlloc := &model.ShopPackageAllocation{ + ShopID: req.ShopID, + PackageID: item.PackageID, + AllocatorShopID: allocatorShopID, + CostPrice: item.CostPrice, + SeriesAllocationID: &allocation.ID, + Status: constants.StatusEnabled, + ShelfStatus: constants.StatusEnabled, + } + pkgAlloc.Creator = operatorID + pkgAlloc.Updater = operatorID + if err := txPkgStore.Create(ctx, pkgAlloc); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐分配失败") + } + // 写成本价历史 + _ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{ + AllocationID: pkgAlloc.ID, + OldCostPrice: 0, + NewCostPrice: item.CostPrice, + ChangeReason: "初始授权", + ChangedBy: operatorID, + EffectiveFrom: time.Now(), + }) + } + } + + var buildErr error + result, buildErr = s.buildGrantResponse(ctx, allocation, series, config) + return buildErr + }) + if err != nil { + return nil, err + } + + return result, nil +} + +// Get 查询单条系列授权详情 +// GET /api/admin/shop-series-grants/:id +func (s *Service) Get(ctx context.Context, id uint) (*dto.ShopSeriesGrantResponse, error) { + allocation, err := s.shopSeriesAllocationStore.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "授权记录不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询授权记录失败") + } + + series, err := s.packageSeriesStore.GetByID(ctx, allocation.SeriesID) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐系列失败") + } + config, err := series.GetOneTimeCommissionConfig() + if err != nil || config == nil { + config = &model.OneTimeCommissionConfig{} + } + + return s.buildGrantResponse(ctx, allocation, series, config) +} + +// List 分页查询系列授权列表 +// GET /api/admin/shop-series-grants +func (s *Service) List(ctx context.Context, req *dto.ShopSeriesGrantListRequest) (*dto.ShopSeriesGrantPageResult, error) { + page := req.Page + if page <= 0 { + page = 1 + } + pageSize := req.PageSize + if pageSize <= 0 { + pageSize = constants.DefaultPageSize + } + if pageSize > constants.MaxPageSize { + pageSize = constants.MaxPageSize + } + + opts := &store.QueryOptions{ + Page: page, + PageSize: pageSize, + OrderBy: "id DESC", + } + filters := make(map[string]interface{}) + if req.ShopID != nil { + filters["shop_id"] = *req.ShopID + } + if req.SeriesID != nil { + filters["series_id"] = *req.SeriesID + } + if req.AllocatorShopID != nil { + filters["allocator_shop_id"] = *req.AllocatorShopID + } + if req.Status != nil { + filters["status"] = *req.Status + } + + allocations, total, err := s.shopSeriesAllocationStore.List(ctx, opts, filters) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询授权列表失败") + } + + // 批量查询店铺名称 + shopIDs := make([]uint, 0) + seriesIDs := make([]uint, 0) + for _, a := range allocations { + shopIDs = append(shopIDs, a.ShopID) + if a.AllocatorShopID > 0 { + shopIDs = append(shopIDs, a.AllocatorShopID) + } + seriesIDs = append(seriesIDs, a.SeriesID) + } + shopMap := make(map[uint]string) + if len(shopIDs) > 0 { + shops, _ := s.shopStore.GetByIDs(ctx, shopIDs) + for _, sh := range shops { + shopMap[sh.ID] = sh.ShopName + } + } + seriesMap := make(map[uint]*model.PackageSeries) + if len(seriesIDs) > 0 { + seriesList, _ := s.packageSeriesStore.GetByIDs(ctx, seriesIDs) + for _, sr := range seriesList { + seriesMap[sr.ID] = sr + } + } + + items := make([]*dto.ShopSeriesGrantListItem, 0, len(allocations)) + for _, a := range allocations { + item := &dto.ShopSeriesGrantListItem{ + ID: a.ID, + ShopID: a.ShopID, + ShopName: shopMap[a.ShopID], + SeriesID: a.SeriesID, + AllocatorShopID: a.AllocatorShopID, + OneTimeCommissionAmount: a.OneTimeCommissionAmount, + ForceRechargeEnabled: a.EnableForceRecharge, + Status: a.Status, + CreatedAt: a.CreatedAt.Format(time.DateTime), + } + if a.AllocatorShopID > 0 { + item.AllocatorShopName = shopMap[a.AllocatorShopID] + } else { + item.AllocatorShopName = "平台" + } + if sr, ok := seriesMap[a.SeriesID]; ok { + item.SeriesName = sr.SeriesName + config, _ := sr.GetOneTimeCommissionConfig() + if config != nil { + item.CommissionType = config.CommissionType + } + } + // 统计套餐数量 + pkgCount, _ := s.shopPackageAllocationStore.CountBySeriesAllocationID(ctx, a.ID) + item.PackageCount = int(pkgCount) + items = append(items, item) + } + + totalPages := int(total) / pageSize + if int(total)%pageSize != 0 { + totalPages++ + } + + return &dto.ShopSeriesGrantPageResult{ + List: items, + Total: total, + Page: page, + PageSize: pageSize, + TotalPages: totalPages, + }, nil +} + +// Update 更新系列授权 +// PUT /api/admin/shop-series-grants/:id +func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeriesGrantRequest) (*dto.ShopSeriesGrantResponse, error) { + operatorID := middleware.GetUserIDFromContext(ctx) + operatorShopID := middleware.GetShopIDFromContext(ctx) + + allocation, err := s.shopSeriesAllocationStore.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "授权记录不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询授权记录失败") + } + + // 代理只能修改自己分配出去的授权 + if operatorShopID > 0 && allocation.AllocatorShopID != operatorShopID { + return nil, errors.New(errors.CodeForbidden, "无权限操作该授权记录") + } + + series, err := s.packageSeriesStore.GetByID(ctx, allocation.SeriesID) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐系列失败") + } + config, err := series.GetOneTimeCommissionConfig() + if err != nil || config == nil { + return nil, errors.New(errors.CodeInternalError, "获取系列佣金配置失败") + } + + // 更新固定模式佣金金额 + if req.OneTimeCommissionAmount != nil && config.CommissionType == "fixed" { + ceiling, ceilErr := s.getParentCeilingFixed(ctx, allocation.AllocatorShopID, allocation.SeriesID, config) + if ceilErr != nil { + return nil, ceilErr + } + if *req.OneTimeCommissionAmount > ceiling { + return nil, errors.New(errors.CodeInvalidParam, "佣金金额不能超过上级天花板") + } + allocation.OneTimeCommissionAmount = *req.OneTimeCommissionAmount + } + + // 更新梯度模式阶梯 + if len(req.CommissionTiers) > 0 && config.CommissionType == "tiered" { + if len(req.CommissionTiers) != len(config.Tiers) { + return nil, errors.New(errors.CodeInvalidParam, "梯度阶梯数量与系列配置不一致") + } + ceilingMap, ceilErr := s.getParentCeilingTiered(ctx, allocation.AllocatorShopID, allocation.SeriesID, config.Tiers) + if ceilErr != nil { + return nil, ceilErr + } + agentTiers := make([]model.AllocationCommissionTier, 0, len(req.CommissionTiers)) + for i, tier := range req.CommissionTiers { + if tier.Threshold != config.Tiers[i].Threshold { + return nil, errors.New(errors.CodeInvalidParam, "梯度阶梯 threshold 与系列配置不匹配") + } + if ceiling, ok := ceilingMap[tier.Threshold]; ok && tier.Amount > ceiling { + return nil, errors.New(errors.CodeInvalidParam, "某档位佣金金额超过上级天花板") + } + agentTiers = append(agentTiers, model.AllocationCommissionTier{ + Threshold: tier.Threshold, + Amount: tier.Amount, + }) + } + if err := allocation.SetCommissionTiers(agentTiers); err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "序列化阶梯佣金失败") + } + } + + // 更新强充配置(平台已锁定时忽略) + forceRechargeLocked := config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge || config.EnableForceRecharge + if !forceRechargeLocked && req.EnableForceRecharge != nil { + allocation.EnableForceRecharge = *req.EnableForceRecharge + if req.ForceRechargeAmount != nil { + allocation.ForceRechargeAmount = *req.ForceRechargeAmount + } + } + + allocation.Updater = operatorID + if err := s.shopSeriesAllocationStore.Update(ctx, allocation); err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新授权记录失败") + } + + return s.buildGrantResponse(ctx, allocation, series, config) +} + +// ManagePackages 管理授权套餐(新增/更新/删除) +// PUT /api/admin/shop-series-grants/:id/packages +func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGrantPackagesRequest) (*dto.ShopSeriesGrantResponse, error) { + operatorID := middleware.GetUserIDFromContext(ctx) + operatorShopID := middleware.GetShopIDFromContext(ctx) + + allocation, err := s.shopSeriesAllocationStore.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "授权记录不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询授权记录失败") + } + + // 代理只能操作自己分配的授权 + if operatorShopID > 0 && allocation.AllocatorShopID != operatorShopID { + return nil, errors.New(errors.CodeForbidden, "无权限操作该授权记录") + } + + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + txPkgStore := postgres.NewShopPackageAllocationStore(tx) + txHistoryStore := postgres.NewShopPackageAllocationPriceHistoryStore(tx) + + for _, item := range req.Packages { + if item.Remove != nil && *item.Remove { + // 软删除已有的 active 分配 + existing, findErr := txPkgStore.GetByShopAndPackageForSystem(ctx, allocation.ShopID, item.PackageID) + if findErr != nil { + // 找不到则静默忽略 + continue + } + _ = txPkgStore.Delete(ctx, existing.ID) + continue + } + + // 新增或更新套餐分配 + existing, findErr := txPkgStore.GetByShopAndPackageForSystem(ctx, allocation.ShopID, item.PackageID) + if findErr == nil { + // 已有记录:更新成本价并写历史 + oldPrice := existing.CostPrice + existing.CostPrice = item.CostPrice + existing.Updater = operatorID + if updateErr := txPkgStore.Update(ctx, existing); updateErr != nil { + return errors.Wrap(errors.CodeDatabaseError, updateErr, "更新套餐分配失败") + } + if oldPrice != item.CostPrice { + _ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{ + AllocationID: existing.ID, + OldCostPrice: oldPrice, + NewCostPrice: item.CostPrice, + ChangeReason: "手动调价", + ChangedBy: operatorID, + EffectiveFrom: time.Now(), + }) + } + } else { + // W1: 校验套餐归属于该系列,防止跨系列套餐混入 + pkg, pkgErr := s.packageStore.GetByID(ctx, item.PackageID) + if pkgErr != nil || pkg.SeriesID != allocation.SeriesID { + return errors.New(errors.CodeInvalidParam, "套餐不属于该系列,无法添加到此授权") + } + // W2: 代理操作时,校验分配者已拥有此套餐授权,防止越权分配 + if allocation.AllocatorShopID > 0 { + _, authErr := s.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, allocation.AllocatorShopID, item.PackageID) + if authErr != nil { + return errors.New(errors.CodeForbidden, "无权限分配该套餐") + } + } + // 新建分配 + pkgAlloc := &model.ShopPackageAllocation{ + ShopID: allocation.ShopID, + PackageID: item.PackageID, + AllocatorShopID: allocation.AllocatorShopID, + CostPrice: item.CostPrice, + SeriesAllocationID: &allocation.ID, + Status: constants.StatusEnabled, + ShelfStatus: constants.StatusEnabled, + } + pkgAlloc.Creator = operatorID + pkgAlloc.Updater = operatorID + if createErr := txPkgStore.Create(ctx, pkgAlloc); createErr != nil { + return errors.Wrap(errors.CodeDatabaseError, createErr, "创建套餐分配失败") + } + _ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{ + AllocationID: pkgAlloc.ID, + OldCostPrice: 0, + NewCostPrice: item.CostPrice, + ChangeReason: "新增授权", + ChangedBy: operatorID, + EffectiveFrom: time.Now(), + }) + } + } + return nil + }) + if err != nil { + return nil, err + } + + // 重新查询最新状态 + return s.Get(ctx, id) +} + +// Delete 删除系列授权(软删除) +// DELETE /api/admin/shop-series-grants/:id +func (s *Service) Delete(ctx context.Context, id uint) error { + operatorShopID := middleware.GetShopIDFromContext(ctx) + + allocation, err := s.shopSeriesAllocationStore.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeNotFound, "授权记录不存在") + } + return errors.Wrap(errors.CodeDatabaseError, err, "查询授权记录失败") + } + + // 代理只能删除自己分配的授权 + if operatorShopID > 0 && allocation.AllocatorShopID != operatorShopID { + return errors.New(errors.CodeForbidden, "无权限操作该授权记录") + } + + // 检查是否有子级授权依赖(该店铺是否作为 allocator 向下分配了) + subAllocations, err := s.shopSeriesAllocationStore.GetByAllocatorShopID(ctx, allocation.ShopID) + for _, sub := range subAllocations { + if sub.SeriesID == allocation.SeriesID { + return errors.New(errors.CodeConflict, "该授权存在下级依赖,请先删除下级授权") + } + } + _ = err + + // 事务软删除 ShopSeriesAllocation + 所有关联 ShopPackageAllocation + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + txSeriesStore := postgres.NewShopSeriesAllocationStore(tx) + txPkgStore := postgres.NewShopPackageAllocationStore(tx) + + pkgAllocations, _ := txPkgStore.GetBySeriesAllocationID(ctx, id) + for _, pa := range pkgAllocations { + if delErr := txPkgStore.Delete(ctx, pa.ID); delErr != nil { + return errors.Wrap(errors.CodeDatabaseError, delErr, "删除套餐分配失败") + } + } + + if delErr := txSeriesStore.Delete(ctx, id); delErr != nil { + return errors.Wrap(errors.CodeDatabaseError, delErr, "删除系列授权失败") + } + return nil + }) +}