This commit is contained in:
@@ -54,6 +54,20 @@ func (h *ShopSeriesGrantHandler) List(c *fiber.Ctx) error {
|
||||
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
|
||||
}
|
||||
|
||||
// ListPackageOptions 查询授权页面的套餐候选项。
|
||||
// GET /api/admin/shop-series-grants/package-options
|
||||
func (h *ShopSeriesGrantHandler) ListPackageOptions(c *fiber.Ctx) error {
|
||||
var req dto.ShopSeriesGrantPackageOptionRequest
|
||||
if err := c.QueryParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
result, err := h.service.ListPackageOptions(c.UserContext(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// Get 查询系列授权详情
|
||||
// GET /api/admin/shop-series-grants/:id
|
||||
func (h *ShopSeriesGrantHandler) Get(c *fiber.Ctx) error {
|
||||
|
||||
@@ -2,12 +2,10 @@ package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
@@ -16,20 +14,18 @@ import (
|
||||
)
|
||||
|
||||
// DebitEventConsumer 校验已投递的代理主钱包扣款事件具有对应权威资金流水。
|
||||
// 后续余额预警等消费者在此稳定接缝上扩展,不需要回读或改写订单扣款事务。
|
||||
type DebitEventConsumer struct {
|
||||
db *gorm.DB
|
||||
outbox *outbox.Repository
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewDebitEventConsumer 创建代理主钱包扣款事件消费者。
|
||||
func NewDebitEventConsumer(db *gorm.DB) *DebitEventConsumer {
|
||||
return &DebitEventConsumer{db: db, outbox: outbox.NewRepository()}
|
||||
return &DebitEventConsumer{db: db}
|
||||
}
|
||||
|
||||
// Consume 校验事件载荷及不可变流水,保证未知或损坏事件不会被静默确认。
|
||||
func (c *DebitEventConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
if c == nil || c.db == nil || c.outbox == nil {
|
||||
if c == nil || c.db == nil {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包扣款事件消费者未配置")
|
||||
}
|
||||
if envelope.EventType != constants.OutboxEventTypeAgentMainWalletDebited ||
|
||||
@@ -51,7 +47,7 @@ func (c *DebitEventConsumer) Consume(ctx context.Context, envelope outbox.Delive
|
||||
})
|
||||
}
|
||||
|
||||
// consumeInTx 复核权威扣款流水,并在余额首次跨过固定阈值时追加业务员通知事件。
|
||||
// consumeInTx 复核权威扣款流水。
|
||||
func (c *DebitEventConsumer) consumeInTx(ctx context.Context, tx *gorm.DB, event walletapp.DebitedEvent) error {
|
||||
var transaction model.AgentWalletTransaction
|
||||
err := tx.WithContext(ctx).Unscoped().
|
||||
@@ -68,57 +64,5 @@ func (c *DebitEventConsumer) consumeInTx(ctx context.Context, tx *gorm.DB, event
|
||||
transaction.BalanceAfter != event.BalanceAfter {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包扣款事件与权威资金流水不一致")
|
||||
}
|
||||
if event.BalanceBefore < constants.AgentMainWalletLowBalanceThreshold ||
|
||||
event.BalanceAfter >= constants.AgentMainWalletLowBalanceThreshold {
|
||||
return nil
|
||||
}
|
||||
|
||||
recipientID, err := resolveBusinessOwnerAccountID(ctx, tx, event.ShopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if recipientID == 0 {
|
||||
return nil
|
||||
}
|
||||
shopID := strconv.FormatUint(uint64(event.ShopID), 10)
|
||||
notificationEventID := "wallet-low:order:" + strconv.FormatUint(uint64(event.ReferenceID), 10)
|
||||
_, err = c.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
|
||||
EventID: notificationEventID, EventType: constants.OutboxEventTypeAdminDirectNotification,
|
||||
PayloadVersion: constants.NotificationPayloadVersionV1,
|
||||
AggregateType: "agent_wallet", AggregateID: strconv.FormatUint(uint64(event.WalletID), 10),
|
||||
ResourceType: constants.NotificationRefTypeShopFund, ResourceID: shopID,
|
||||
BusinessKey: notificationEventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
|
||||
Payload: notificationapp.AdminDirectPayload{
|
||||
RecipientID: recipientID, NotificationType: constants.NotificationTypeAgentMainWalletLowBalance,
|
||||
RefType: constants.NotificationRefTypeShopFund, RefID: shopID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入主钱包低余额通知事件失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveBusinessOwnerAccountID 返回店铺当前启用的平台业务员账号;无有效归属时返回零值。
|
||||
func resolveBusinessOwnerAccountID(ctx context.Context, tx *gorm.DB, shopID uint) (uint, error) {
|
||||
var shop model.Shop
|
||||
err := tx.WithContext(ctx).Select("business_owner_account_id").Where("id = ?", shopID).Take(&shop).Error
|
||||
if err == gorm.ErrRecordNotFound || shop.BusinessOwnerAccountID == nil {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺业务员归属失败")
|
||||
}
|
||||
|
||||
var account model.Account
|
||||
err = tx.WithContext(ctx).Select("id").
|
||||
Where("id = ? AND status = ? AND user_type = ?", *shop.BusinessOwnerAccountID, constants.StatusEnabled, constants.UserTypePlatform).
|
||||
Take(&account).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "校验店铺业务员账号失败")
|
||||
}
|
||||
return account.ID, nil
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
@@ -39,5 +41,54 @@ func (w *DebitEventWriter) Append(ctx context.Context, tx *gorm.DB, event wallet
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return w.audit.WriteAgentWalletDebit(ctx, tx, event)
|
||||
if err := w.audit.WriteAgentWalletDebit(ctx, tx, event); err != nil {
|
||||
return err
|
||||
}
|
||||
if event.BalanceBefore < constants.AgentMainWalletLowBalanceThreshold || event.BalanceAfter >= constants.AgentMainWalletLowBalanceThreshold {
|
||||
return nil
|
||||
}
|
||||
recipientID, err := resolveBusinessOwnerAccountID(ctx, tx, event.ShopID)
|
||||
if err != nil || recipientID == 0 {
|
||||
return err
|
||||
}
|
||||
shopID := strconv.FormatUint(uint64(event.ShopID), 10)
|
||||
notificationEventID := "wallet-low:order:" + strconv.FormatUint(uint64(event.ReferenceID), 10)
|
||||
_, err = w.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
|
||||
EventID: notificationEventID, EventType: constants.OutboxEventTypeAdminDirectNotification,
|
||||
PayloadVersion: constants.NotificationPayloadVersionV1,
|
||||
AggregateType: "agent_wallet", AggregateID: strconv.FormatUint(uint64(event.WalletID), 10),
|
||||
ResourceType: constants.NotificationRefTypeShopFund, ResourceID: shopID,
|
||||
BusinessKey: notificationEventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
|
||||
Payload: notificationapp.AdminDirectPayload{
|
||||
RecipientID: recipientID, NotificationType: constants.NotificationTypeAgentMainWalletLowBalance,
|
||||
RefType: constants.NotificationRefTypeShopFund, RefID: shopID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入主钱包低余额通知事件失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveBusinessOwnerAccountID 返回店铺当前启用的平台业务员账号;无有效归属时返回零值。
|
||||
func resolveBusinessOwnerAccountID(ctx context.Context, tx *gorm.DB, shopID uint) (uint, error) {
|
||||
var shop model.Shop
|
||||
err := tx.WithContext(ctx).Select("business_owner_account_id").Where("id = ?", shopID).Take(&shop).Error
|
||||
if err == gorm.ErrRecordNotFound || shop.BusinessOwnerAccountID == nil {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺业务员归属失败")
|
||||
}
|
||||
var account model.Account
|
||||
err = tx.WithContext(ctx).Select("id").
|
||||
Where("id = ? AND status = ? AND user_type = ?", *shop.BusinessOwnerAccountID, constants.StatusEnabled, constants.UserTypePlatform).
|
||||
Take(&account).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "校验店铺业务员账号失败")
|
||||
}
|
||||
return account.ID, nil
|
||||
}
|
||||
|
||||
@@ -93,6 +93,23 @@ type ShopSeriesGrantListRequest struct {
|
||||
Status *int `json:"status" query:"status" validate:"omitempty" description:"过滤状态 0=禁用 1=启用"`
|
||||
}
|
||||
|
||||
// ShopSeriesGrantPackageOptionRequest 是授权页面的套餐候选查询参数。
|
||||
type ShopSeriesGrantPackageOptionRequest struct {
|
||||
ShopID uint `json:"shop_id" query:"shop_id" validate:"required,min=1" required:"true" minimum:"1" description:"被授权代理店铺ID"`
|
||||
SeriesID uint `json:"series_id" query:"series_id" validate:"required,min=1" required:"true" minimum:"1" description:"套餐系列ID"`
|
||||
}
|
||||
|
||||
// ShopSeriesGrantPackageOption 是套餐列表字段及目标店铺的当前授权状态。
|
||||
type ShopSeriesGrantPackageOption struct {
|
||||
PackageResponse
|
||||
Authorized bool `json:"authorized" description:"目标店铺是否已启用授权"`
|
||||
}
|
||||
|
||||
// ShopSeriesGrantPackageOptionResult 是授权页面候选套餐列表。
|
||||
type ShopSeriesGrantPackageOptionResult struct {
|
||||
Items []ShopSeriesGrantPackageOption `json:"items" description:"套餐候选项"`
|
||||
}
|
||||
|
||||
// ShopSeriesGrantListItem 系列授权列表项
|
||||
type ShopSeriesGrantListItem struct {
|
||||
ID uint `json:"id" description:"授权记录ID"`
|
||||
|
||||
@@ -20,6 +20,15 @@ func registerShopSeriesGrantRoutes(router fiber.Router, handler *admin.ShopSerie
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(grants, doc, groupPath, "GET", "/package-options", handler.ListPackageOptions, RouteSpec{
|
||||
Summary: "查询代理系列授权套餐候选项",
|
||||
Description: "返回指定店铺和套餐系列下当前操作者可分配的非赠送套餐,以及目标店铺的已授权状态。",
|
||||
Tags: []string{"代理系列授权"},
|
||||
Input: new(dto.ShopSeriesGrantPackageOptionRequest),
|
||||
Output: new(dto.ShopSeriesGrantPackageOptionResult),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(grants, doc, groupPath, "POST", "", handler.Create, RouteSpec{
|
||||
Summary: "创建代理系列授权",
|
||||
Tags: []string{"代理系列授权"},
|
||||
|
||||
@@ -741,6 +741,22 @@ func (s *Service) batchGetAllocationsForShop(ctx context.Context, shopID uint, p
|
||||
}
|
||||
|
||||
func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package, allocationMap map[uint]*model.ShopPackageAllocation, seriesAllocationMap map[uint]*model.ShopSeriesAllocation, seriesConfigMap map[uint]*model.OneTimeCommissionConfig) *dto.PackageResponse {
|
||||
var allocation *model.ShopPackageAllocation
|
||||
if allocationMap != nil {
|
||||
allocation = allocationMap[pkg.ID]
|
||||
}
|
||||
resp := BuildResponseForAllocation(pkg, allocation)
|
||||
|
||||
// 填充返佣信息(仅代理用户可见)
|
||||
if pkg.SeriesID > 0 && seriesAllocationMap != nil && seriesConfigMap != nil {
|
||||
s.fillCommissionInfo(resp, pkg.SeriesID, seriesAllocationMap, seriesConfigMap)
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// BuildResponseForAllocation 构建套餐列表字段,并按店铺授权覆盖价格和上架状态。
|
||||
func BuildResponseForAllocation(pkg *model.Package, allocation *model.ShopPackageAllocation) *dto.PackageResponse {
|
||||
var seriesID *uint
|
||||
if pkg.SeriesID > 0 {
|
||||
seriesID = &pkg.SeriesID
|
||||
@@ -778,29 +794,21 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
|
||||
}
|
||||
initPackageExpiryBaseFields(resp, pkg)
|
||||
|
||||
if allocationMap != nil {
|
||||
if allocation, ok := allocationMap[pkg.ID]; ok {
|
||||
resp.CostPrice = allocation.CostPrice
|
||||
resp.RetailPrice = packageprice.AllocationRawRetailPrice(allocation)
|
||||
retailPriceConfigStatus := allocation.RetailPriceConfigStatus
|
||||
resp.RetailPriceConfigStatus = &retailPriceConfigStatus
|
||||
effectiveRetailPrice := packageprice.AllocationEffectiveRetailPrice(allocation)
|
||||
resp.EffectiveRetailPrice = &effectiveRetailPrice
|
||||
profitMargin := effectiveRetailPrice - allocation.CostPrice
|
||||
resp.ProfitMargin = &profitMargin
|
||||
applyAllocationExpiryBase(resp, pkg, allocation)
|
||||
resp.ShelfStatus = allocation.ShelfStatus
|
||||
}
|
||||
if allocation != nil {
|
||||
resp.CostPrice = allocation.CostPrice
|
||||
resp.RetailPrice = packageprice.AllocationRawRetailPrice(allocation)
|
||||
retailPriceConfigStatus := allocation.RetailPriceConfigStatus
|
||||
resp.RetailPriceConfigStatus = &retailPriceConfigStatus
|
||||
effectiveRetailPrice := packageprice.AllocationEffectiveRetailPrice(allocation)
|
||||
resp.EffectiveRetailPrice = &effectiveRetailPrice
|
||||
profitMargin := effectiveRetailPrice - allocation.CostPrice
|
||||
resp.ProfitMargin = &profitMargin
|
||||
applyAllocationExpiryBase(resp, pkg, allocation)
|
||||
resp.ShelfStatus = allocation.ShelfStatus
|
||||
} else {
|
||||
effectiveRetailPrice := packageprice.PackageEffectiveRetailPrice(pkg)
|
||||
resp.EffectiveRetailPrice = &effectiveRetailPrice
|
||||
}
|
||||
|
||||
// 填充返佣信息(仅代理用户可见)
|
||||
if pkg.SeriesID > 0 && seriesAllocationMap != nil && seriesConfigMap != nil {
|
||||
s.fillCommissionInfo(resp, pkg.SeriesID, seriesAllocationMap, seriesConfigMap)
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
|
||||
@@ -652,6 +652,83 @@ func (s *Service) List(ctx context.Context, req *dto.ShopSeriesGrantListRequest)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListPackageOptions 返回授权页面可选择套餐与目标店铺已有授权状态。
|
||||
func (s *Service) ListPackageOptions(ctx context.Context, req *dto.ShopSeriesGrantPackageOptionRequest) (*dto.ShopSeriesGrantPackageOptionResult, error) {
|
||||
if req == nil || req.ShopID == 0 || req.SeriesID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "店铺ID和套餐系列ID不能为空")
|
||||
}
|
||||
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.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
series, err := s.packageSeriesStore.GetByID(ctx, req.SeriesID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "套餐系列不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐系列失败")
|
||||
}
|
||||
|
||||
operatorType := middleware.GetUserTypeFromContext(ctx)
|
||||
operatorShopID := middleware.GetShopIDFromContext(ctx)
|
||||
query := s.db.WithContext(ctx).Model(&model.Package{}).
|
||||
Where("tb_package.series_id = ? AND tb_package.is_gift = ?", req.SeriesID, false)
|
||||
if operatorType == constants.UserTypeAgent {
|
||||
if operatorShopID == 0 || targetShop.ParentID == nil || *targetShop.ParentID != operatorShopID {
|
||||
return nil, errors.New(errors.CodeForbidden, "只能授权直属下级店铺")
|
||||
}
|
||||
var parentSeriesAllocation model.ShopSeriesAllocation
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("shop_id = ? AND series_id = ? AND status = ?", operatorShopID, req.SeriesID, constants.StatusEnabled).
|
||||
First(&parentSeriesAllocation).Error; err != nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "当前账号无此系列授权,无法向下分配")
|
||||
}
|
||||
query = query.Joins("INNER JOIN tb_shop_package_allocation parent_allocation ON parent_allocation.package_id = tb_package.id AND parent_allocation.deleted_at IS NULL").
|
||||
Where("parent_allocation.shop_id = ? AND parent_allocation.status = ?", operatorShopID, constants.StatusEnabled)
|
||||
} else if operatorType != constants.UserTypePlatform && operatorType != constants.UserTypeSuperAdmin {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限查询授权套餐")
|
||||
}
|
||||
|
||||
var packages []model.Package
|
||||
if err := query.Select("tb_package.*").Order("tb_package.id ASC").Find(&packages).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询授权套餐候选项失败")
|
||||
}
|
||||
packageIDs := make([]uint, 0, len(packages))
|
||||
for _, pkg := range packages {
|
||||
packageIDs = append(packageIDs, pkg.ID)
|
||||
}
|
||||
authorized := make(map[uint]bool, len(packageIDs))
|
||||
if len(packageIDs) > 0 {
|
||||
var allocations []model.ShopPackageAllocation
|
||||
if err := s.db.WithContext(ctx).Where("shop_id = ? AND package_id IN ? AND status = ?", req.ShopID, packageIDs, constants.StatusEnabled).Find(&allocations).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询目标店铺套餐授权失败")
|
||||
}
|
||||
for _, allocation := range allocations {
|
||||
authorized[allocation.PackageID] = true
|
||||
}
|
||||
}
|
||||
parentAllocations := make(map[uint]*model.ShopPackageAllocation)
|
||||
if operatorType == constants.UserTypeAgent && len(packageIDs) > 0 {
|
||||
allocations, err := s.shopPackageAllocationStore.GetByShopAndPackages(ctx, operatorShopID, packageIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级套餐授权失败")
|
||||
}
|
||||
for _, allocation := range allocations {
|
||||
parentAllocations[allocation.PackageID] = allocation
|
||||
}
|
||||
}
|
||||
items := make([]dto.ShopSeriesGrantPackageOption, 0, len(packages))
|
||||
for i := range packages {
|
||||
resp := packagepkg.BuildResponseForAllocation(&packages[i], parentAllocations[packages[i].ID])
|
||||
resp.SeriesName = &series.SeriesName
|
||||
items = append(items, dto.ShopSeriesGrantPackageOption{PackageResponse: *resp, Authorized: authorized[packages[i].ID]})
|
||||
}
|
||||
return &dto.ShopSeriesGrantPackageOptionResult{Items: items}, nil
|
||||
}
|
||||
|
||||
// Update 更新系列授权
|
||||
// PUT /api/admin/shop-series-grants/:id
|
||||
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeriesGrantRequest) (_ *dto.ShopSeriesGrantResponse, retErr error) {
|
||||
|
||||
Reference in New Issue
Block a user