收口七月卡状态回调与系列授权兼容契约
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
完成运营商实名回调、业务事件观测序列与受控配置装配,同时恢复 UR43 已交付的 packages[].remove 字段及旧响应兼容,统一更新 OpenSpec、OpenAPI 和交付文档。 Constraint: 七月测试环境里程碑不新增或运行自动化测试 Rejected: 以必填 operation_type 替换 packages[].remove | 会破坏已交付前端契约 Confidence: high Scope-risk: broad Directive: 后续修改系列套餐管理接口必须保持 packages[].remove 和 ShopSeriesGrantResponse 兼容 Tested: go run ./cmd/gendocs;go build -buildvcs=false ./...;openspec validate complete-july-iteration-test-release --strict;git diff --check Not-tested: 按本 Change 约定未运行 go test,真实运营商与 Gateway 联调延期
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// Service 代理系列授权业务服务
|
||||
@@ -30,6 +31,12 @@ type Service struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// initialGrantPackage 保存通过创建前校验的套餐及其请求价格。
|
||||
type initialGrantPackage struct {
|
||||
item dto.GrantPackageItem
|
||||
packageModel *model.Package
|
||||
}
|
||||
|
||||
// New 创建代理系列授权服务实例
|
||||
func New(
|
||||
db *gorm.DB,
|
||||
@@ -53,6 +60,89 @@ func New(
|
||||
}
|
||||
}
|
||||
|
||||
// validateInitialGrantPackageItems 校验首次授权套餐的数量、价格和批内唯一性。
|
||||
func validateInitialGrantPackageItems(items []dto.GrantPackageItem) ([]uint, error) {
|
||||
if len(items) == 0 || len(items) > constants.ShopSeriesGrantMaxPackages {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "初始授权套餐数量必须为1到100项")
|
||||
}
|
||||
|
||||
packageIDs := make([]uint, 0, len(items))
|
||||
seen := make(map[uint]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
if item.PackageID == 0 || item.CostPrice == nil || *item.CostPrice < 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "套餐ID和成本价参数无效")
|
||||
}
|
||||
if _, exists := seen[item.PackageID]; exists {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "初始授权套餐ID不能重复")
|
||||
}
|
||||
seen[item.PackageID] = struct{}{}
|
||||
packageIDs = append(packageIDs, item.PackageID)
|
||||
}
|
||||
return packageIDs, nil
|
||||
}
|
||||
|
||||
// validateInitialGrantPackages 批量校验首次授权的套餐归属、授权链和成本价边界。
|
||||
func (s *Service) validateInitialGrantPackages(ctx context.Context, tx *gorm.DB, req *dto.CreateShopSeriesGrantRequest, allocatorShopID uint) ([]initialGrantPackage, error) {
|
||||
packageIDs, err := validateInitialGrantPackageItems(req.Packages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var packages []*model.Package
|
||||
if err := tx.WithContext(ctx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id IN ?", packageIDs).
|
||||
Find(&packages).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询初始授权套餐失败")
|
||||
}
|
||||
if len(packages) != len(packageIDs) {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "初始授权套餐不存在或已删除")
|
||||
}
|
||||
|
||||
packageMap := make(map[uint]*model.Package, len(packages))
|
||||
for _, pkg := range packages {
|
||||
packageMap[pkg.ID] = pkg
|
||||
}
|
||||
|
||||
parentCostMap := make(map[uint]int64, len(packageIDs))
|
||||
if allocatorShopID > 0 {
|
||||
var parentAllocations []*model.ShopPackageAllocation
|
||||
if err := tx.WithContext(ctx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("shop_id = ? AND package_id IN ? AND status = ?", allocatorShopID, packageIDs, constants.StatusEnabled).
|
||||
Find(&parentAllocations).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级套餐授权失败")
|
||||
}
|
||||
if len(parentAllocations) != len(packageIDs) {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限分配套餐")
|
||||
}
|
||||
for _, allocation := range parentAllocations {
|
||||
parentCostMap[allocation.PackageID] = allocation.CostPrice
|
||||
}
|
||||
}
|
||||
|
||||
validated := make([]initialGrantPackage, 0, len(req.Packages))
|
||||
for _, item := range req.Packages {
|
||||
pkg := packageMap[item.PackageID]
|
||||
if pkg.SeriesID != req.SeriesID {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "套餐不属于该系列,无法添加到此授权")
|
||||
}
|
||||
if pkg.IsGift {
|
||||
return nil, errors.New(errors.CodeForbidden, "赠送套餐不能分配给代理")
|
||||
}
|
||||
|
||||
minimumCost := pkg.CostPrice
|
||||
if allocatorShopID > 0 {
|
||||
minimumCost = parentCostMap[item.PackageID]
|
||||
}
|
||||
if *item.CostPrice < minimumCost {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "授权成本价不能低于当前上级成本价")
|
||||
}
|
||||
validated = append(validated, initialGrantPackage{item: item, packageModel: pkg})
|
||||
}
|
||||
return validated, nil
|
||||
}
|
||||
|
||||
// getParentCeilingFixed 查询固定模式佣金天花板
|
||||
// allocatorShopID=0 表示平台分配,天花板为 PackageSeries.commission_amount
|
||||
// allocatorShopID>0 表示代理分配,天花板为分配者自身的 ShopSeriesAllocation.one_time_commission_amount
|
||||
@@ -225,10 +315,13 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
|
||||
}
|
||||
config, _ := series.GetOneTimeCommissionConfig()
|
||||
|
||||
// 1.5 校验目标店铺是否存在
|
||||
_, err = s.shopStore.GetByID(ctx, req.ShopID)
|
||||
// 1.5 先校验管理范围,避免通过错误差异探测其他店铺。
|
||||
if err := middleware.CanManageShop(ctx, req.ShopID); err != nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
targetShop, err := s.shopStore.GetByID(ctx, req.ShopID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeNotFound, "目标店铺不存在")
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
|
||||
// 2. 检查重复授权
|
||||
@@ -244,12 +337,20 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
|
||||
var allocatorShopID uint
|
||||
if operatorType == constants.UserTypeAgent {
|
||||
allocatorShopID = operatorShopID
|
||||
_, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, allocatorShopID, req.SeriesID)
|
||||
if err != nil {
|
||||
if allocatorShopID == 0 || targetShop.ParentID == nil || *targetShop.ParentID != allocatorShopID {
|
||||
return nil, errors.New(errors.CodeForbidden, "只能授权直属下级店铺")
|
||||
}
|
||||
var parentSeriesAllocation model.ShopSeriesAllocation
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("shop_id = ? AND series_id = ? AND status = ?", allocatorShopID, req.SeriesID, constants.StatusEnabled).
|
||||
First(&parentSeriesAllocation).Error; err != nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "当前账号无此系列授权,无法向下分配")
|
||||
}
|
||||
}
|
||||
// 平台/超管 allocatorShopID = 0
|
||||
if operatorType != constants.UserTypeAgent && operatorType != constants.UserTypePlatform && operatorType != constants.UserTypeSuperAdmin {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限创建系列授权")
|
||||
}
|
||||
|
||||
// 4. 参数验证:仅启用一次性佣金的系列才需要配置佣金金额
|
||||
allocation := &model.ShopSeriesAllocation{
|
||||
@@ -322,59 +423,67 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
|
||||
|
||||
// 6. 事务中创建 ShopSeriesAllocation + N 条 ShopPackageAllocation
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var lockedTargetShop model.Shop
|
||||
if lockErr := tx.WithContext(ctx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&lockedTargetShop, req.ShopID).Error; lockErr != nil {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
if allocatorShopID > 0 {
|
||||
if lockedTargetShop.ParentID == nil || *lockedTargetShop.ParentID != allocatorShopID {
|
||||
return errors.New(errors.CodeForbidden, "只能授权直属下级店铺")
|
||||
}
|
||||
var parentSeriesAllocation model.ShopSeriesAllocation
|
||||
if lockErr := tx.WithContext(ctx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("shop_id = ? AND series_id = ? AND status = ?", allocatorShopID, req.SeriesID, constants.StatusEnabled).
|
||||
First(&parentSeriesAllocation).Error; lockErr != nil {
|
||||
return errors.New(errors.CodeForbidden, "当前账号无有效系列授权,无法向下分配")
|
||||
}
|
||||
}
|
||||
|
||||
validatedPackages, validateErr := s.validateInitialGrantPackages(ctx, tx, req, allocatorShopID)
|
||||
if validateErr != nil {
|
||||
return validateErr
|
||||
}
|
||||
|
||||
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, "套餐不属于该系列,无法添加到此授权")
|
||||
}
|
||||
if pkg.IsGift {
|
||||
return errors.New(errors.CodeForbidden, "赠送套餐不能分配给代理")
|
||||
}
|
||||
// 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,
|
||||
RetailPrice: pkg.SuggestedRetailPrice,
|
||||
RetailPriceConfigStatus: pkg.PriceConfigStatus,
|
||||
SeriesAllocationID: &allocation.ID,
|
||||
ExpiryBaseOverride: expiryBaseOverride,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: constants.ShelfStatusOn,
|
||||
}
|
||||
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(),
|
||||
})
|
||||
// 所有套餐已在当前事务内完成批量校验,此处只写入同一业务单元。
|
||||
txPkgStore := postgres.NewShopPackageAllocationStore(tx)
|
||||
txHistoryStore := postgres.NewShopPackageAllocationPriceHistoryStore(tx)
|
||||
for _, validated := range validatedPackages {
|
||||
item := validated.item
|
||||
pkg := validated.packageModel
|
||||
pkgAlloc := &model.ShopPackageAllocation{
|
||||
ShopID: req.ShopID,
|
||||
PackageID: item.PackageID,
|
||||
AllocatorShopID: allocatorShopID,
|
||||
CostPrice: *item.CostPrice,
|
||||
RetailPrice: pkg.SuggestedRetailPrice,
|
||||
RetailPriceConfigStatus: pkg.PriceConfigStatus,
|
||||
SeriesAllocationID: &allocation.ID,
|
||||
ExpiryBaseOverride: expiryBaseOverride,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: constants.ShelfStatusOn,
|
||||
}
|
||||
pkgAlloc.Creator = operatorID
|
||||
pkgAlloc.Updater = operatorID
|
||||
if err := txPkgStore.Create(ctx, pkgAlloc); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐分配失败")
|
||||
}
|
||||
if err := txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
|
||||
AllocationID: pkgAlloc.ID,
|
||||
OldCostPrice: 0,
|
||||
NewCostPrice: *item.CostPrice,
|
||||
ChangeReason: "初始授权",
|
||||
ChangedBy: operatorID,
|
||||
EffectiveFrom: time.Now(),
|
||||
}); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐价格历史失败")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,45 +748,56 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
|
||||
|
||||
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)
|
||||
if deleteErr := txPkgStore.Delete(ctx, existing.ID); deleteErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, deleteErr, "删除套餐分配失败")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if item.CostPrice == nil {
|
||||
return errors.New(errors.CodeInvalidParam, "新增或修改套餐授权时必须提交成本价")
|
||||
}
|
||||
costPrice := *item.CostPrice
|
||||
|
||||
// 新增或更新套餐分配
|
||||
existing, findErr := txPkgStore.GetByShopAndPackageForSystem(ctx, allocation.ShopID, item.PackageID)
|
||||
if findErr == nil {
|
||||
// 已有记录:更新成本价并写历史
|
||||
oldPrice := existing.CostPrice
|
||||
if oldPrice != item.CostPrice {
|
||||
if oldPrice != costPrice {
|
||||
// cost_price 锁定检查:存在下级分配记录时禁止修改
|
||||
var subCount int64
|
||||
tx.Model(&model.ShopPackageAllocation{}).
|
||||
if countErr := tx.Model(&model.ShopPackageAllocation{}).
|
||||
Where("allocator_shop_id = ? AND package_id = ? AND deleted_at IS NULL", allocation.ShopID, item.PackageID).
|
||||
Count(&subCount)
|
||||
Count(&subCount).Error; countErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, countErr, "查询下级套餐分配失败")
|
||||
}
|
||||
if subCount > 0 {
|
||||
return errors.New(errors.CodeForbidden, "存在下级分配记录,请先回收后再修改成本价")
|
||||
}
|
||||
}
|
||||
existing.CostPrice = item.CostPrice
|
||||
existing.CostPrice = 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{
|
||||
if oldPrice != costPrice {
|
||||
if historyErr := txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
|
||||
AllocationID: existing.ID,
|
||||
OldCostPrice: oldPrice,
|
||||
NewCostPrice: item.CostPrice,
|
||||
NewCostPrice: costPrice,
|
||||
ChangeReason: "手动调价",
|
||||
ChangedBy: operatorID,
|
||||
EffectiveFrom: time.Now(),
|
||||
})
|
||||
}); historyErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, historyErr, "创建套餐价格历史失败")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pkg, pkgErr := s.packageStore.GetByID(ctx, item.PackageID)
|
||||
@@ -697,7 +817,7 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
|
||||
ShopID: allocation.ShopID,
|
||||
PackageID: item.PackageID,
|
||||
AllocatorShopID: allocation.AllocatorShopID,
|
||||
CostPrice: item.CostPrice,
|
||||
CostPrice: costPrice,
|
||||
RetailPrice: pkg.SuggestedRetailPrice,
|
||||
RetailPriceConfigStatus: pkg.PriceConfigStatus,
|
||||
SeriesAllocationID: &allocation.ID,
|
||||
@@ -710,14 +830,16 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
|
||||
if createErr := txPkgStore.Create(ctx, pkgAlloc); createErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建套餐分配失败")
|
||||
}
|
||||
_ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
|
||||
if historyErr := txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
|
||||
AllocationID: pkgAlloc.ID,
|
||||
OldCostPrice: 0,
|
||||
NewCostPrice: item.CostPrice,
|
||||
NewCostPrice: costPrice,
|
||||
ChangeReason: "新增授权",
|
||||
ChangedBy: operatorID,
|
||||
EffectiveFrom: time.Now(),
|
||||
})
|
||||
}); historyErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, historyErr, "创建套餐价格历史失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user