修复反馈
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m30s

This commit is contained in:
2026-08-10 16:30:38 +08:00
parent 7aa03e91fb
commit f15a64395f
25 changed files with 527 additions and 90 deletions

View File

@@ -1,7 +1,20 @@
[[sources]] [[sources]]
id = "main" id = "main"
description = "当前项目库" description = "当前项目库测试环境以及本地环境"
dsn = "postgresql://erp_pgsql:erp_2025@cxd.whcxd.cn:16159/junhong_cmp_test?sslmode=disable" dsn = "postgresql://erp_pgsql:erp_2025@cxd.whcxd.cn:16159/junhong_cmp_test?sslmode=disable"
lazy = true
[[sources]]
id = "pro_main"
description = "当前项目库正式环境"
dsn = "postgresql://junhong_cmp:CtCom1zBzPQbpVNf3rCNxH@127.0.0.1:5432/junhong_cmp_prod?sslmode=disable"
ssh_host = "116.162.101.91"
ssh_port = 22
ssh_user = "root"
ssh_key = "~/.ssh/id_ed25519"
lazy = true
[[sources]] [[sources]]
id = "legacy_mysql" id = "legacy_mysql"
@@ -22,8 +35,18 @@ source = "main"
[[tools]] [[tools]]
name = "execute_sql" name = "execute_sql"
source = "main" source = "main"
readonly = true # Only allow SELECT, SHOW, DESCRIBE, EXPLAIN readonly = true # Only allow SELECT, SHOW, DESCRIBE, EXPLAIN
max_rows = 1000 # Limit query results max_rows = 1000 # Limit query results
[[tools]]
name = "search_objects"
source = "pro_main"
[[tools]]
name = "execute_sql"
source = "pro_main"
readonly = true
max_rows = 1000
[[tools]] [[tools]]
name = "search_objects" name = "search_objects"
@@ -32,5 +55,5 @@ source = "legacy_mysql"
[[tools]] [[tools]]
name = "execute_sql" name = "execute_sql"
source = "legacy_mysql" source = "legacy_mysql"
readonly = true # Only allow SELECT, SHOW, DESCRIBE, EXPLAIN readonly = true # Only allow SELECT, SHOW, DESCRIBE, EXPLAIN
max_rows = 5000 # Limit query results for migration exploration max_rows = 5000 # Limit query results for migration exploration

View File

@@ -54,6 +54,20 @@ func (h *ShopSeriesGrantHandler) List(c *fiber.Ctx) error {
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize) 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 查询系列授权详情
// GET /api/admin/shop-series-grants/:id // GET /api/admin/shop-series-grants/:id
func (h *ShopSeriesGrantHandler) Get(c *fiber.Ctx) error { func (h *ShopSeriesGrantHandler) Get(c *fiber.Ctx) error {

View File

@@ -2,12 +2,10 @@ package wallet
import ( import (
"context" "context"
"strconv"
"github.com/bytedance/sonic" "github.com/bytedance/sonic"
"gorm.io/gorm" "gorm.io/gorm"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" 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/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
@@ -16,20 +14,18 @@ import (
) )
// DebitEventConsumer 校验已投递的代理主钱包扣款事件具有对应权威资金流水。 // DebitEventConsumer 校验已投递的代理主钱包扣款事件具有对应权威资金流水。
// 后续余额预警等消费者在此稳定接缝上扩展,不需要回读或改写订单扣款事务。
type DebitEventConsumer struct { type DebitEventConsumer struct {
db *gorm.DB db *gorm.DB
outbox *outbox.Repository
} }
// NewDebitEventConsumer 创建代理主钱包扣款事件消费者。 // NewDebitEventConsumer 创建代理主钱包扣款事件消费者。
func NewDebitEventConsumer(db *gorm.DB) *DebitEventConsumer { func NewDebitEventConsumer(db *gorm.DB) *DebitEventConsumer {
return &DebitEventConsumer{db: db, outbox: outbox.NewRepository()} return &DebitEventConsumer{db: db}
} }
// Consume 校验事件载荷及不可变流水,保证未知或损坏事件不会被静默确认。 // Consume 校验事件载荷及不可变流水,保证未知或损坏事件不会被静默确认。
func (c *DebitEventConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error { 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, "代理主钱包扣款事件消费者未配置") return errors.New(errors.CodeInternalError, "代理主钱包扣款事件消费者未配置")
} }
if envelope.EventType != constants.OutboxEventTypeAgentMainWalletDebited || 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 { func (c *DebitEventConsumer) consumeInTx(ctx context.Context, tx *gorm.DB, event walletapp.DebitedEvent) error {
var transaction model.AgentWalletTransaction var transaction model.AgentWalletTransaction
err := tx.WithContext(ctx).Unscoped(). err := tx.WithContext(ctx).Unscoped().
@@ -68,57 +64,5 @@ func (c *DebitEventConsumer) consumeInTx(ctx context.Context, tx *gorm.DB, event
transaction.BalanceAfter != event.BalanceAfter { transaction.BalanceAfter != event.BalanceAfter {
return errors.New(errors.CodeInternalError, "代理主钱包扣款事件与权威资金流水不一致") 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 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
}

View File

@@ -5,9 +5,11 @@ import (
"context" "context"
"strconv" "strconv"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" 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/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" "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/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm" "gorm.io/gorm"
@@ -39,5 +41,54 @@ func (w *DebitEventWriter) Append(ctx context.Context, tx *gorm.DB, event wallet
if err != nil { if err != nil {
return err 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
} }

View File

@@ -93,6 +93,23 @@ type ShopSeriesGrantListRequest struct {
Status *int `json:"status" query:"status" validate:"omitempty" description:"过滤状态 0=禁用 1=启用"` 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 系列授权列表项 // ShopSeriesGrantListItem 系列授权列表项
type ShopSeriesGrantListItem struct { type ShopSeriesGrantListItem struct {
ID uint `json:"id" description:"授权记录ID"` ID uint `json:"id" description:"授权记录ID"`

View File

@@ -20,6 +20,15 @@ func registerShopSeriesGrantRoutes(router fiber.Router, handler *admin.ShopSerie
Auth: true, 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{ Register(grants, doc, groupPath, "POST", "", handler.Create, RouteSpec{
Summary: "创建代理系列授权", Summary: "创建代理系列授权",
Tags: []string{"代理系列授权"}, Tags: []string{"代理系列授权"},

View File

@@ -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 { 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 var seriesID *uint
if pkg.SeriesID > 0 { if pkg.SeriesID > 0 {
seriesID = &pkg.SeriesID seriesID = &pkg.SeriesID
@@ -778,29 +794,21 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
} }
initPackageExpiryBaseFields(resp, pkg) initPackageExpiryBaseFields(resp, pkg)
if allocationMap != nil { if allocation != nil {
if allocation, ok := allocationMap[pkg.ID]; ok { resp.CostPrice = allocation.CostPrice
resp.CostPrice = allocation.CostPrice resp.RetailPrice = packageprice.AllocationRawRetailPrice(allocation)
resp.RetailPrice = packageprice.AllocationRawRetailPrice(allocation) retailPriceConfigStatus := allocation.RetailPriceConfigStatus
retailPriceConfigStatus := allocation.RetailPriceConfigStatus resp.RetailPriceConfigStatus = &retailPriceConfigStatus
resp.RetailPriceConfigStatus = &retailPriceConfigStatus effectiveRetailPrice := packageprice.AllocationEffectiveRetailPrice(allocation)
effectiveRetailPrice := packageprice.AllocationEffectiveRetailPrice(allocation) resp.EffectiveRetailPrice = &effectiveRetailPrice
resp.EffectiveRetailPrice = &effectiveRetailPrice profitMargin := effectiveRetailPrice - allocation.CostPrice
profitMargin := effectiveRetailPrice - allocation.CostPrice resp.ProfitMargin = &profitMargin
resp.ProfitMargin = &profitMargin applyAllocationExpiryBase(resp, pkg, allocation)
applyAllocationExpiryBase(resp, pkg, allocation) resp.ShelfStatus = allocation.ShelfStatus
resp.ShelfStatus = allocation.ShelfStatus
}
} else { } else {
effectiveRetailPrice := packageprice.PackageEffectiveRetailPrice(pkg) effectiveRetailPrice := packageprice.PackageEffectiveRetailPrice(pkg)
resp.EffectiveRetailPrice = &effectiveRetailPrice resp.EffectiveRetailPrice = &effectiveRetailPrice
} }
// 填充返佣信息(仅代理用户可见)
if pkg.SeriesID > 0 && seriesAllocationMap != nil && seriesConfigMap != nil {
s.fillCommissionInfo(resp, pkg.SeriesID, seriesAllocationMap, seriesConfigMap)
}
return resp return resp
} }

View File

@@ -652,6 +652,83 @@ func (s *Service) List(ctx context.Context, req *dto.ShopSeriesGrantListRequest)
}, nil }, 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 更新系列授权 // Update 更新系列授权
// PUT /api/admin/shop-series-grants/:id // PUT /api/admin/shop-series-grants/:id
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeriesGrantRequest) (_ *dto.ShopSeriesGrantResponse, retErr error) { func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeriesGrantRequest) (_ *dto.ShopSeriesGrantResponse, retErr error) {

View File

@@ -27,6 +27,7 @@ BEGIN
SELECT iccid_19 SELECT iccid_19
FROM tb_iot_card FROM tb_iot_card
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND LENGTH(iccid) = 19
AND iccid_19 IS NOT NULL AND iccid_19 IS NOT NULL
GROUP BY iccid_19 GROUP BY iccid_19
HAVING COUNT(*) > 1 HAVING COUNT(*) > 1
@@ -38,6 +39,7 @@ BEGIN
SELECT iccid_20 SELECT iccid_20
FROM tb_iot_card FROM tb_iot_card
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND LENGTH(iccid) = 20
AND iccid_20 IS NOT NULL AND iccid_20 IS NOT NULL
GROUP BY iccid_20 GROUP BY iccid_20
HAVING COUNT(*) > 1 HAVING COUNT(*) > 1
@@ -56,13 +58,13 @@ DROP INDEX IF EXISTS idx_iot_card_iccid_20;
CREATE UNIQUE INDEX idx_iot_card_iccid_19 CREATE UNIQUE INDEX idx_iot_card_iccid_19
ON tb_iot_card (iccid_19) ON tb_iot_card (iccid_19)
WHERE deleted_at IS NULL; WHERE deleted_at IS NULL AND LENGTH(iccid) = 19;
CREATE UNIQUE INDEX idx_iot_card_iccid_20 CREATE UNIQUE INDEX idx_iot_card_iccid_20
ON tb_iot_card (iccid_20) ON tb_iot_card (iccid_20)
WHERE deleted_at IS NULL AND iccid_20 IS NOT NULL; WHERE deleted_at IS NULL AND LENGTH(iccid) = 20 AND iccid_20 IS NOT NULL;
COMMENT ON INDEX idx_iot_card_iccid_19 IS '未删除卡的非空19位ICCID精确唯一索引'; COMMENT ON INDEX idx_iot_card_iccid_19 IS '原始ICCID为19位的未删除卡精确唯一索引';
COMMENT ON INDEX idx_iot_card_iccid_20 IS '未删除卡的非空20位ICCID精确唯一索引'; COMMENT ON INDEX idx_iot_card_iccid_20 IS '原始ICCID为20位的未删除卡精确唯一索引';
COMMIT; COMMIT;

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-10

View File

@@ -0,0 +1,45 @@
## Context
现有套餐管理列表服务于套餐管理页面,不能承载授权页面的店铺授权状态。现有低余额提醒在扣款 Outbox 消费者中派生;测试库证明一次有效跨阈值扣款已完成消费但未产生通知事件。
## Goals / Non-Goals
**Goals:**
- 以独立只读接口提供授权页面所需候选套餐和授权状态。
- 将跨阈值通知事件与钱包扣款事实原子写入。
**Non-Goals:**
- 不改套餐管理列表接口。
- 不新增或替换首次创建、套餐管理的提交路由。
- 不新增阈值配置或修改数据库 Schema。
## Decisions
### 独立授权候选列表
新增授权域路由,输入 `shop_id``series_id`。查询同时执行操作者可管理店铺、上级授权链和套餐系列校验,并按目标店铺现有授权填充状态。
候选项复用套餐列表的 `PackageResponse` 字段集,并追加 `authorized`;代理操作者的价格与上架状态沿用其上级授权记录。
复用 `GET /packages` 会将授权上下文混入套餐管理接口,影响其既有消费者;只返回未授权项则无法支持前端明确置灰,均不采用。
### 扣款事务内创建通知事件
由统一主钱包扣款事件 Writer 根据扣款前后余额判断跨阈值,并在相同事务写入低余额通知 Outbox。该 Writer已被直接扣款和预占完成扣款共同调用。
保留 Worker 对通知 Outbox 的投递;移除扣款消费者中低余额通知派生,只保留扣款事实校验。这样 API 与 Worker 重启之间不会遗失派生通知。
### 幂等与接收人
通知事件 ID 使用订单扣款引用构成稳定值,继续使用 Outbox 幂等写入。接收人于扣款事务中解析为当前有效平台业务员;无有效业务员时不创建通知事件。
## Risks / Trade-offs
- [业务员在扣款后变更] → 通知接收人固定为扣款时有效的业务员,符合触发时的业务事实。
- [候选查询与提交并发] → 现有部分唯一索引继续作为最终重复授权防线。
## Migration Plan
1. 发布 API 与 Worker 代码。
2. 使用 10000 分→9900 分、低余额连续扣款、回升后再次跌破三组数据验证事件与通知。
3. 回滚时恢复前一版本;不涉及迁移或数据回滚。

View File

@@ -0,0 +1,26 @@
## Why
代理系列授权页面无法获知套餐是否已授权,导致重复选择;主钱包低余额提醒依赖扣款事件的异步消费者再次判断,测试库已出现有效跌破阈值却没有生成通知事件的记录。
## What Changes
- 为代理系列授权提供独立的套餐候选列表,返回指定店铺、系列下每个可授权套餐的已授权状态,供前端置灰已授权项。
- 保持现有首次创建和授权套餐管理提交接口不变,并由现有唯一约束作为并发重复授权的最终防线。
- 将主钱包首次跌破 100 元的通知事件与扣款事实写入同一事务;余额恢复至 100 元及以上后再次跌破时再次创建通知事件。
## Capabilities
### New Capabilities
- `agent-series-package-options`: 查询代理系列授权页面的套餐候选项及授权状态。
### Modified Capabilities
- `package-lifecycle`: 代理系列授权页面可查询并区分已授权与未授权套餐。
- `notification-delivery`: 主钱包余额跨过低余额阈值时可靠创建并投递业务员通知。
## Impact
- 涉及代理系列授权路由、Handler、DTO、查询服务和套餐分配查询。
- 涉及主钱包扣款事件 Writer低余额判断从其异步消费者迁入扣款事务。
- 不增加依赖,不修改现有提交路由或数据库 Schema。

View File

@@ -0,0 +1,16 @@
## Purpose
为代理系列授权页面提供与套餐管理列表隔离的候选套餐视图,使前端能够在首次授权和后续加套餐时识别不可重复授权的套餐。
## ADDED Requirements
### Requirement: 查询系列授权套餐候选项
系统 SHALL 提供独立接口,按被授权店铺和套餐系列返回当前操作者可分配的非赠送套餐;每个候选项 MUST 包含与套餐列表一致的套餐字段,以及该店铺是否已授权该套餐的状态。
#### Scenario: 返回已授权与未授权候选项
- **WHEN** 有权限的操作者查询指定店铺和套餐系列的候选套餐
- **THEN** 系统返回该系列可分配套餐的完整套餐列表字段,且已存在有效授权记录的套餐标记为已授权
#### Scenario: 查询无权分配的套餐
- **WHEN** 代理操作者查询其不具备上级授权的套餐系列
- **THEN** 系统拒绝查询且不返回套餐候选项

View File

@@ -0,0 +1,16 @@
## ADDED Requirements
### Requirement: 主钱包低余额跨阈值提醒
系统 SHALL 在店铺主钱包余额由不低于 100 元变为低于 100 元时,与扣款事实在同一事务创建面向当时有效业务员的低余额通知事件;余额持续低于 100 元时不得重复创建,余额恢复至不低于 100 元后再次跌破时 MUST 再次创建。
#### Scenario: 首次跌破阈值
- **WHEN** 店铺主钱包扣款后余额从不低于 100 元变为低于 100 元,且存在有效业务员
- **THEN** 系统在提交扣款事实时创建一条低余额通知事件,并由通知投递流程生成站内通知
#### Scenario: 持续低余额
- **WHEN** 店铺主钱包余额已经低于 100 元且再次发生扣款
- **THEN** 系统不创建新的低余额通知事件
#### Scenario: 回升后再次跌破
- **WHEN** 店铺主钱包余额已恢复至不低于 100 元,随后扣款使其低于 100 元
- **THEN** 系统创建新的低余额通知事件

View File

@@ -0,0 +1,12 @@
## ADDED Requirements
### Requirement: 授权页面禁止重复选择套餐
系统 SHALL 使代理系列授权页面能够区分目标店铺已授权和未授权套餐;已授权套餐 MUST 以不可新增的状态返回,首次创建系列授权和既有系列新增套餐均适用。
#### Scenario: 首次创建前查询候选套餐
- **WHEN** 操作者选择目标店铺和套餐系列以创建系列授权
- **THEN** 系统返回可用于选择的候选套餐及其授权状态,前端可阻止选择已授权套餐
#### Scenario: 既有授权新增套餐前查询候选套餐
- **WHEN** 操作者为已有系列授权添加套餐
- **THEN** 系统返回同一店铺和系列的候选套餐及其授权状态,且不改变现有套餐管理提交接口的调价和删除语义

View File

@@ -0,0 +1,16 @@
## 1. 授权套餐候选列表
- [x] 1.1 定义独立候选列表路由、请求与响应 DTO并生成接口文档。
- [x] 1.2 实现指定店铺和系列的可分配套餐查询及 `authorized` 状态,复用现有权限与上级授权链校验。
- [x] 1.3 运行格式化、API/Worker 构建和文档生成,确认套餐管理列表接口未变。
- [x] 1.4 候选套餐响应复用套餐列表字段集并追加 `authorized` 状态。
## 2. 主钱包低余额通知
- [x] 2.1 将跨 100 元阈值和有效业务员解析收口至主钱包扣款事件 Writer并在扣款事务中幂等写入通知 Outbox。
- [x] 2.2 删除扣款 Outbox 消费者中的低余额派生逻辑,保留扣款事实一致性校验。
- [x] 2.3 以测试库的 10000→9900、持续低余额、回升后再次跌破数据验证通知事件行为。
## 3. 变更验证
- [x] 3.1 运行 `gofmt``go build ./cmd/api ./cmd/worker``go run cmd/gendocs/main.go``openspec validate --all``./scripts/context-health.sh`

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-10

View File

@@ -0,0 +1,40 @@
## Context
见 proposal.md。000178 在版本 140 之后尚未应用于生产库;其迁移前校验和唯一索引均把所有 `iccid_19` 纳入唯一范围与双列模型中“20 位卡以完整 `iccid_20` 识别”的语义冲突。
## Goals / Non-Goals
**Goals:**
- 在测试环境验证版本 140 生产库所需的 ICCID 索引语义。
- 为两种 ICCID 长度分别建立正确的唯一性约束。
- 保持查询索引名称与现有代码兼容。
**Non-Goals:**
- 不清理、合并或修改现有卡数据。
- 不改变 ICCID 查询接口或导入逻辑。
## Decisions
### 将 000178 的唯一范围限定为对应原始长度
`iccid_19` 冲突扫描和唯一索引增加 `length(iccid) = 19``iccid_20` 冲突扫描和唯一索引增加 `length(iccid) = 20`
直接修正 000178而不是新增后续迁移生产库仍位于版本 140必须能连续执行历史链路新增迁移无法绕过 000178 的失败。该文件尚未在生产应用,变更需在上线前完成仓库内所有消费方同步。
### 保留双列格式校验
保留对 19/20 位原始值与派生列对应关系的校验,避免仅放宽唯一索引而掩盖错误回填。
## Risks / Trade-offs
- [测试库已处于更高迁移版本] → 直接按修正后的 000178 谓词定向重建两个索引并核对结果。
- [其他环境已经执行旧版 000178] → 迁移内容不会自动重放;上线前以索引定义核对并按同一谓词重建。
- [错误编辑已发布迁移] → 仅限生产首次执行前的本次兼容修正,发布说明记录文件校验值。
## Migration Plan
1. 在测试环境停止卡写入后,按修正后的 000178 谓词定向重建两个 ICCID 唯一索引。
2. 验证两个索引均为唯一索引且谓词按原始 ICCID 长度限定,并执行 19/20 位重复组检查。
3. 测试完成后保留修正后的索引;不对生产库执行迁移或验证操作。

View File

@@ -0,0 +1,24 @@
## Why
线上活跃的 20 位 ICCID 可以共享前 19 位,现有 000178 迁移却将全部活跃卡的 `iccid_19` 设为唯一,导致从版本 140 升级时必然中止。
## What Changes
- 修正 ICCID 唯一性迁移:仅对原始 ICCID 长度为 19 的活跃卡要求 `iccid_19` 唯一。
- 保持原始 ICCID 长度为 20 的活跃卡以 `iccid_20` 为唯一标识,允许其共享 `iccid_19` 前缀。
- 同步调整迁移前置校验、索引谓词与索引注释。
## Capabilities
### New Capabilities
无。
### Modified Capabilities
- `asset-device`: 明确 19 位与 20 位 ICCID 的有效唯一性边界。
## Impact
- `migrations/000178_make_iot_card_iccid_exact_unique.up.sql`
- 测试环境中的定向索引修复验证;不改变卡数据或 HTTP API也不对生产库执行迁移。

View File

@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: ICCID 按原始长度精确唯一
系统 SHALL 将未删除卡的 ICCID 唯一性按原始 ICCID 长度分别约束:原始长度为 19 的卡以 `iccid_19` 唯一;原始长度为 20 的卡以 `iccid_20` 唯一。20 位 ICCID 卡共享相同的前 19 位 SHALL 被视为有效数据。
#### Scenario: 20 位 ICCID 共享前缀
- **WHEN** 两张未删除卡具有不同的 20 位完整 ICCID但其前 19 位相同
- **THEN** 数据库接受两张卡,并通过各自的 `iccid_20` 保证完整 ICCID 唯一
#### Scenario: 19 位 ICCID 重复
- **WHEN** 两张未删除卡具有相同的 19 位完整 ICCID
- **THEN** 数据库拒绝第二张卡的重复值

View File

@@ -0,0 +1,10 @@
## 1. 迁移修正
- [x] 1.1 修正 000178 的 19 位冲突扫描和唯一索引谓词,仅覆盖原始长度为 19 的活跃卡。
- [x] 1.2 修正 000178 的 20 位冲突扫描、唯一索引谓词和迁移注释,使其仅覆盖原始长度为 20 的活跃卡。
## 2. 验证
- [x] 2.1 在测试环境按修正后的 000178 谓词定向重建两个 ICCID 唯一索引。
- [x] 2.2 校验两个索引均为唯一索引、谓词按原始 ICCID 长度限定,并核对 19/20 位重复组。
- [x] 2.3 运行 OpenSpec 校验并记录测试环境定向修复结果。

View File

@@ -0,0 +1,26 @@
# 代理系列授权套餐候选项当前行为
## Purpose
为代理系列授权页面提供与套餐管理列表隔离的候选套餐和授权状态,使前端能阻止重复授权。
## Requirements
### Requirement: 查询系列授权套餐候选项
系统 SHALL 提供独立接口,按被授权店铺和套餐系列返回当前操作者可分配的非赠送套餐;每个候选项 MUST 包含与套餐列表一致的套餐字段,以及该店铺是否已授权该套餐的状态。
#### Scenario: 返回已授权与未授权候选项
- **WHEN** 有权限的操作者查询指定店铺和套餐系列的候选套餐
- **THEN** 系统返回该系列可分配套餐的完整套餐列表字段,且已存在有效授权记录的套餐标记为已授权
#### Scenario: 查询无权分配的套餐
- **WHEN** 代理操作者查询其不具备上级授权的套餐系列
- **THEN** 系统拒绝查询且不返回套餐候选项
## 可达操作索引
本节只用于入口导航,不是行为 Requirement业务义务以上述 Requirements 为准。
### 代理系列授权套餐候选项
`GET /api/admin/shop-series-grants/package-options`(查询指定店铺和系列的候选套餐及授权状态)。

View File

@@ -26,6 +26,20 @@
- **WHEN** 执行绑定或解绑 - **WHEN** 执行绑定或解绑
- **THEN** 后续设备卡列表反映该关系变化 - **THEN** 后续设备卡列表反映该关系变化
### Requirement: ICCID 按原始长度精确唯一
系统 SHALL 将未删除卡的 ICCID 唯一性按原始 ICCID 长度分别约束:原始长度为 19 的卡以 `iccid_19` 唯一;原始长度为 20 的卡以 `iccid_20` 唯一。20 位 ICCID 卡共享相同的前 19 位 SHALL 被视为有效数据。
#### Scenario: 20 位 ICCID 共享前缀
- **WHEN** 两张未删除卡具有不同的 20 位完整 ICCID但其前 19 位相同
- **THEN** 数据库接受两张卡,并通过各自的 `iccid_20` 保证完整 ICCID 唯一
#### Scenario: 19 位 ICCID 重复
- **WHEN** 两张未删除卡具有相同的 19 位完整 ICCID
- **THEN** 数据库拒绝第二张卡的重复值
## 可达操作索引 ## 可达操作索引
本节只用于入口导航,不是行为 Requirement业务义务以上述 Requirements 为准。 本节只用于入口导航,不是行为 Requirement业务义务以上述 Requirements 为准。

View File

@@ -26,6 +26,22 @@
- **WHEN** 再次执行单条或批量已读 - **WHEN** 再次执行单条或批量已读
- **THEN** 通知保持原读取事实且不影响其他接收人的通知 - **THEN** 通知保持原读取事实且不影响其他接收人的通知
### Requirement: 主钱包低余额跨阈值提醒
系统 SHALL 在店铺主钱包余额由不低于 100 元变为低于 100 元时,与扣款事实在同一事务创建面向当时有效业务员的低余额通知事件;余额持续低于 100 元时不得重复创建,余额恢复至不低于 100 元后再次跌破时 MUST 再次创建。
#### Scenario: 首次跌破阈值
- **WHEN** 店铺主钱包扣款后余额从不低于 100 元变为低于 100 元,且存在有效业务员
- **THEN** 系统在提交扣款事实时创建一条低余额通知事件,并由通知投递流程生成站内通知
#### Scenario: 持续低余额
- **WHEN** 店铺主钱包余额已经低于 100 元且再次发生扣款
- **THEN** 系统不创建新的低余额通知事件
#### Scenario: 回升后再次跌破
- **WHEN** 店铺主钱包余额已恢复至不低于 100 元,随后扣款使其低于 100 元
- **THEN** 系统创建新的低余额通知事件
## 可达操作索引 ## 可达操作索引
本节只用于入口导航,不是行为 Requirement业务义务以上述 Requirements 为准。 本节只用于入口导航,不是行为 Requirement业务义务以上述 Requirements 为准。

View File

@@ -26,6 +26,18 @@
- **WHEN** 创建批量操作 - **WHEN** 创建批量操作
- **THEN** 同步操作直接返回结果;异步订购返回任务标识且可查询处理状态 - **THEN** 同步操作直接返回结果;异步订购返回任务标识且可查询处理状态
### Requirement: 授权页面禁止重复选择套餐
系统 SHALL 使代理系列授权页面能够区分目标店铺已授权和未授权套餐;已授权套餐 MUST 以不可新增的状态返回,首次创建系列授权和既有系列新增套餐均适用。
#### Scenario: 首次创建前查询候选套餐
- **WHEN** 操作者选择目标店铺和套餐系列以创建系列授权
- **THEN** 系统返回可用于选择的候选套餐及其授权状态,前端可阻止选择已授权套餐
#### Scenario: 既有授权新增套餐前查询候选套餐
- **WHEN** 操作者为已有系列授权添加套餐
- **THEN** 系统返回同一店铺和系列的候选套餐及其授权状态,且不改变现有套餐管理提交接口的调价和删除语义
## 可达操作索引 ## 可达操作索引
本节只用于入口导航,不是行为 Requirement业务义务以上述 Requirements 为准。 本节只用于入口导航,不是行为 Requirement业务义务以上述 Requirements 为准。
@@ -44,7 +56,7 @@
### 代理系列授权 ### 代理系列授权
`GET /api/admin/shop-series-grants`(查询代理系列授权列表);`POST /api/admin/shop-series-grants`(创建代理系列授权);`DELETE /api/admin/shop-series-grants/{id}`(删除代理系列授权);`GET /api/admin/shop-series-grants/{id}`(查询代理系列授权详情);`PUT /api/admin/shop-series-grants/{id}`(更新代理系列授权);`PUT /api/admin/shop-series-grants/{id}/packages`(管理授权套餐新增/更新/删除) `GET /api/admin/shop-series-grants`(查询代理系列授权列表);`GET /api/admin/shop-series-grants/package-options`(查询代理系列授权套餐候选项);`POST /api/admin/shop-series-grants`(创建代理系列授权);`DELETE /api/admin/shop-series-grants/{id}`(删除代理系列授权);`GET /api/admin/shop-series-grants/{id}`(查询代理系列授权详情);`PUT /api/admin/shop-series-grants/{id}`(更新代理系列授权);`PUT /api/admin/shop-series-grants/{id}/packages`(管理授权套餐,支持新增更新删除)。
### 批量套餐分配 ### 批量套餐分配