收口七月卡状态回调与系列授权兼容契约
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:
2026-07-24 19:59:24 +08:00
parent a18ed8bc8d
commit 5c4d17e9fc
79 changed files with 4341 additions and 478 deletions

View File

@@ -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