feat: 客户端接口数据模型基础准备

- 新增资产状态、订单来源、操作人类型、实名链接类型常量
- 8个模型新增字段(asset_status/generation/source/retail_price等)
- 数据库迁移000082:7张表15+字段,含存量retail_price回填
- BUG-1修复:代理零售价渠道隔离,cost_price分配锁定
- BUG-2修复:一次性佣金仅客户端订单触发
- BUG-4修复:充值回调Store操作纳入事务
- 新增资产手动停用接口(PATCH /iot-cards/:id/deactivate、/devices/:id/deactivate)
- Carrier管理新增实名链接配置
- 后台订单generation写时快照
- BatchUpdatePricing支持retail_price调价目标
- 清理全部H5旧接口和个人客户旧登录方法
This commit is contained in:
2026-03-19 10:56:50 +08:00
parent 817d0d6e04
commit ec86dbf463
70 changed files with 1438 additions and 1188 deletions

View File

@@ -0,0 +1,88 @@
package asset
import (
"context"
stderrors "errors"
"github.com/break/junhong_cmp_fiber/internal/model"
"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"
"gorm.io/gorm"
)
var deactivatableAssetStatuses = []int{constants.AssetStatusInStock, constants.AssetStatusSold}
// LifecycleService 资产生命周期服务
type LifecycleService struct {
db *gorm.DB
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
}
// NewLifecycleService 创建资产生命周期服务
func NewLifecycleService(db *gorm.DB, iotCardStore *postgres.IotCardStore, deviceStore *postgres.DeviceStore) *LifecycleService {
return &LifecycleService{
db: db,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
}
}
// DeactivateIotCard 手动停用 IoT 卡
func (s *LifecycleService) DeactivateIotCard(ctx context.Context, id uint) error {
card, err := s.iotCardStore.GetByID(ctx, id)
if err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(errors.CodeIotCardNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
if !canDeactivateAsset(card.AssetStatus) {
return errors.New(errors.CodeForbidden, "当前状态不允许停用")
}
result := s.db.WithContext(ctx).Model(&model.IotCard{}).
Where("id = ? AND asset_status IN ?", id, deactivatableAssetStatuses).
Update("asset_status", constants.AssetStatusDeactivated)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "停用IoT卡失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeConflict, "状态已变更,请刷新后重试")
}
return nil
}
// DeactivateDevice 手动停用设备
func (s *LifecycleService) DeactivateDevice(ctx context.Context, id uint) error {
device, err := s.deviceStore.GetByID(ctx, id)
if err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(errors.CodeNotFound, "设备不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
if !canDeactivateAsset(device.AssetStatus) {
return errors.New(errors.CodeForbidden, "当前状态不允许停用")
}
result := s.db.WithContext(ctx).Model(&model.Device{}).
Where("id = ? AND asset_status IN ?", id, deactivatableAssetStatuses).
Update("asset_status", constants.AssetStatusDeactivated)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "停用设备失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeConflict, "状态已变更,请刷新后重试")
}
return nil
}
func canDeactivateAsset(assetStatus int) bool {
return assetStatus == constants.AssetStatusInStock || assetStatus == constants.AssetStatusSold
}

View File

@@ -41,6 +41,12 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*d
Description: req.Description,
Status: constants.StatusEnabled,
}
if req.RealnameLinkType != nil {
carrier.RealnameLinkType = *req.RealnameLinkType
}
if req.RealnameLinkTemplate != nil {
carrier.RealnameLinkTemplate = *req.RealnameLinkTemplate
}
carrier.Creator = currentUserID
if err := s.carrierStore.Create(ctx, carrier); err != nil {
@@ -81,6 +87,15 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierReq
if req.Description != nil {
carrier.Description = *req.Description
}
if req.RealnameLinkType != nil {
carrier.RealnameLinkType = *req.RealnameLinkType
}
if req.RealnameLinkTemplate != nil {
carrier.RealnameLinkTemplate = *req.RealnameLinkTemplate
}
if carrier.RealnameLinkType == "template" && carrier.RealnameLinkTemplate == "" {
return nil, errors.New(errors.CodeInvalidParam, "模板URL类型必须提供实名链接模板")
}
carrier.Updater = currentUserID
if err := s.carrierStore.Update(ctx, carrier); err != nil {
@@ -169,13 +184,15 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
func (s *Service) toResponse(c *model.Carrier) *dto.CarrierResponse {
return &dto.CarrierResponse{
ID: c.ID,
CarrierCode: c.CarrierCode,
CarrierName: c.CarrierName,
CarrierType: c.CarrierType,
Description: c.Description,
Status: c.Status,
CreatedAt: c.CreatedAt.Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.Format(time.RFC3339),
ID: c.ID,
CarrierCode: c.CarrierCode,
CarrierName: c.CarrierName,
CarrierType: c.CarrierType,
Description: c.Description,
RealnameLinkType: c.RealnameLinkType,
RealnameLinkTemplate: c.RealnameLinkTemplate,
Status: c.Status,
CreatedAt: c.CreatedAt.Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.Format(time.RFC3339),
}
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
"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"
"go.uber.org/zap"
"gorm.io/gorm"
@@ -201,7 +202,7 @@ func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model.
}
func (s *Service) triggerOneTimeCommissionForCardInTx(ctx context.Context, tx *gorm.DB, order *model.Order, cardID uint) error {
if order.IsPurchaseOnBehalf {
if order.IsPurchaseOnBehalf || order.Source != constants.OrderSourceClient {
return nil
}
@@ -285,7 +286,7 @@ func (s *Service) TriggerOneTimeCommissionForCard(ctx context.Context, order *mo
}
func (s *Service) triggerOneTimeCommissionForDeviceInTx(ctx context.Context, tx *gorm.DB, order *model.Order, deviceID uint) error {
if order.IsPurchaseOnBehalf {
if order.IsPurchaseOnBehalf || order.Source != constants.OrderSourceClient {
return nil
}

View File

@@ -564,6 +564,17 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
}
}
// 从资产获取当前 generation用于订单快照
var assetGeneration int
if validationResult.Card != nil {
assetGeneration = validationResult.Card.Generation
} else if validationResult.Device != nil {
assetGeneration = validationResult.Device.Generation
}
if assetGeneration == 0 {
assetGeneration = 1
}
order := &model.Order{
BaseModel: model.BaseModel{
Creator: userID,
@@ -571,6 +582,8 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
},
OrderNo: s.orderStore.GenerateOrderNo(),
OrderType: req.OrderType,
Source: constants.OrderSourceAdmin,
Generation: assetGeneration,
BuyerType: orderBuyerType,
BuyerID: orderBuyerID,
IotCardID: req.IotCardID,

View File

@@ -533,9 +533,9 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa
allocation, err := s.packageAllocationStore.GetByShopAndPackage(ctx, shopID, pkg.ID)
if err == nil && allocation != nil {
resp.CostPrice = allocation.CostPrice
profitMargin := pkg.SuggestedRetailPrice - allocation.CostPrice
resp.RetailPrice = &allocation.RetailPrice
profitMargin := allocation.RetailPrice - allocation.CostPrice
resp.ProfitMargin = &profitMargin
// 代理查询时shelf_status 返回自己分配记录的值,而非平台全局值
resp.ShelfStatus = allocation.ShelfStatus
}
}
@@ -595,9 +595,9 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
if allocationMap != nil {
if allocation, ok := allocationMap[pkg.ID]; ok {
resp.CostPrice = allocation.CostPrice
profitMargin := pkg.SuggestedRetailPrice - allocation.CostPrice
resp.RetailPrice = &allocation.RetailPrice
profitMargin := allocation.RetailPrice - allocation.CostPrice
resp.ProfitMargin = &profitMargin
// 代理查询时shelf_status 返回自己分配记录的值,而非平台全局值
resp.ShelfStatus = allocation.ShelfStatus
}
}

View File

@@ -133,44 +133,57 @@ func (s *Service) validatePackages(ctx context.Context, packageIDs []uint, serie
}
if sellerShopID > 0 {
// 代理渠道:检查卖家代理的 allocation.shelf_status不检查 package.shelf_status
if err := s.validateAgentShelfStatus(ctx, sellerShopID, pkgID); err != nil {
return nil, 0, err
// 代理渠道:检查上架状态并获取分配记录,使用零售价
allocation, allocErr := s.validateAgentAllocation(ctx, sellerShopID, pkgID)
if allocErr != nil {
return nil, 0, allocErr
}
// 零售价低于成本价时视为不可购买,防止亏损售卖
if allocation.RetailPrice < allocation.CostPrice {
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐价格配置异常,暂不可购买")
}
totalPrice += allocation.RetailPrice
} else {
// 平台自营渠道:检查 package.shelf_status
if pkg.ShelfStatus != constants.ShelfStatusOn {
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐已下架")
}
totalPrice += pkg.SuggestedRetailPrice
}
packages = append(packages, pkg)
totalPrice += pkg.SuggestedRetailPrice
}
return packages, totalPrice, nil
}
// validateAgentShelfStatus 校验卖家代理的分配记录上架状态
func (s *Service) validateAgentShelfStatus(ctx context.Context, sellerShopID, packageID uint) error {
// 使用不带数据权限过滤的查询,避免 buyer ctx 的权限限制干扰系统级校验
// validateAgentAllocation 校验卖家代理的分配记录上架状态,并返回分配记录
func (s *Service) validateAgentAllocation(ctx context.Context, sellerShopID, packageID uint) (*model.ShopPackageAllocation, error) {
allocation, err := s.packageAllocationStore.GetByShopAndPackageForSystem(ctx, sellerShopID, packageID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeInvalidParam, "套餐已下架")
return nil, errors.New(errors.CodeInvalidParam, "套餐已下架")
}
return errors.Wrap(errors.CodeInternalError, err, "查询套餐分配记录失败")
return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐分配记录失败")
}
if allocation.ShelfStatus != constants.ShelfStatusOn {
return errors.New(errors.CodeInvalidParam, "套餐已下架")
return nil, errors.New(errors.CodeInvalidParam, "套餐已下架")
}
return nil
return allocation, nil
}
func (s *Service) GetPurchasePrice(ctx context.Context, pkg *model.Package, buyerType string) int64 {
return pkg.SuggestedRetailPrice
// GetPurchasePrice 获取购买价格
// 代理渠道sellerShopID > 0返回 allocation.RetailPrice平台渠道返回 Package.SuggestedRetailPrice
func (s *Service) GetPurchasePrice(ctx context.Context, pkg *model.Package, sellerShopID uint) (int64, error) {
if sellerShopID > 0 {
allocation, err := s.packageAllocationStore.GetByShopAndPackageForSystem(ctx, sellerShopID, pkg.ID)
if err != nil {
return 0, errors.Wrap(errors.CodeInternalError, err, "查询套餐分配记录失败")
}
return allocation.RetailPrice, nil
}
return pkg.SuggestedRetailPrice, nil
}
// ValidateAdminOfflineCardPurchase 后台 offline 订单专用卡验证

View File

@@ -306,18 +306,17 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string,
// 6. 事务处理:更新订单状态、增加余额、更新累计充值、触发佣金
now := time.Now()
err = s.db.Transaction(func(tx *gorm.DB) error {
// 6.1 更新充值订单状态(带状态检查,实现乐观锁
// 6.1 更新充值订单状态(带状态检查,使用事务内 tx 确保原子性
oldStatus := constants.RechargeStatusPending
if err := s.assetRechargeStore.UpdateStatusWithOptimisticLock(ctx, recharge.ID, &oldStatus, constants.RechargeStatusPaid, &now, nil); err != nil {
if err := s.assetRechargeStore.UpdateStatusWithOptimisticLockDB(ctx, tx, recharge.ID, &oldStatus, constants.RechargeStatusPaid, &now, nil); err != nil {
if err == gorm.ErrRecordNotFound {
// 状态已变更,幂等处理
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "更新充值订单状态失败")
}
// 6.2 更新支付信息
if err := s.assetRechargeStore.UpdatePaymentInfo(ctx, recharge.ID, &paymentMethod, &paymentTransactionID); err != nil {
// 6.2 更新支付信息(使用事务内 tx
if err := s.assetRechargeStore.UpdatePaymentInfoWithDB(ctx, tx, recharge.ID, &paymentMethod, &paymentTransactionID); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新支付信息失败")
}

View File

@@ -95,6 +95,7 @@ func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePacka
PackageID: pkg.ID,
AllocatorShopID: allocatorShopID,
CostPrice: costPrice,
RetailPrice: pkg.SuggestedRetailPrice,
SeriesAllocationID: &seriesAllocation.ID,
Status: constants.StatusEnabled,
}

View File

@@ -65,37 +65,86 @@ func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCo
return nil, errors.New(errors.CodeInvalidParam, "没有找到符合条件的分配记录")
}
pricingTarget := req.PricingTarget
if pricingTarget == "" {
pricingTarget = "cost_price"
}
updatedCount := 0
now := time.Now()
affectedIDs := make([]uint, 0)
skipped := make([]dto.BatchPricingSkipped, 0)
err = s.db.Transaction(func(tx *gorm.DB) error {
for _, allocation := range allocations {
oldPrice := allocation.CostPrice
newPrice := s.calculateAdjustedPrice(oldPrice, &req.PriceAdjustment)
if pricingTarget == "retail_price" {
oldRetailPrice := allocation.RetailPrice
newRetailPrice := s.calculateAdjustedPrice(oldRetailPrice, &req.PriceAdjustment)
if newRetailPrice == oldRetailPrice {
continue
}
if newRetailPrice < allocation.CostPrice {
skipped = append(skipped, dto.BatchPricingSkipped{
AllocationID: allocation.ID,
Reason: "零售价不能低于成本价",
})
continue
}
if newPrice == oldPrice {
continue
}
history := &model.ShopPackageAllocationPriceHistory{
AllocationID: allocation.ID,
OldCostPrice: oldRetailPrice,
NewCostPrice: newRetailPrice,
ChangeReason: req.ChangeReason + "(零售价调整)",
ChangedBy: currentUserID,
EffectiveFrom: now,
}
if err := tx.Create(history).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建价格历史失败")
}
history := &model.ShopPackageAllocationPriceHistory{
AllocationID: allocation.ID,
OldCostPrice: oldPrice,
NewCostPrice: newPrice,
ChangeReason: req.ChangeReason,
ChangedBy: currentUserID,
EffectiveFrom: now,
}
allocation.RetailPrice = newRetailPrice
allocation.Updater = currentUserID
if err := tx.Save(allocation).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新零售价失败")
}
} else {
oldPrice := allocation.CostPrice
newPrice := s.calculateAdjustedPrice(oldPrice, &req.PriceAdjustment)
if newPrice == oldPrice {
continue
}
if err := tx.Create(history).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建价格历史失败")
}
// cost_price 锁定检查:存在下级分配记录时跳过
var subCount int64
tx.Model(&model.ShopPackageAllocation{}).
Where("allocator_shop_id = ? AND package_id = ? AND deleted_at IS NULL", allocation.ShopID, allocation.PackageID).
Count(&subCount)
if subCount > 0 {
skipped = append(skipped, dto.BatchPricingSkipped{
AllocationID: allocation.ID,
Reason: "存在下级分配记录,请先回收后再修改成本价",
})
continue
}
allocation.CostPrice = newPrice
allocation.Updater = currentUserID
if err := tx.Save(allocation).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新成本价失败")
history := &model.ShopPackageAllocationPriceHistory{
AllocationID: allocation.ID,
OldCostPrice: oldPrice,
NewCostPrice: newPrice,
ChangeReason: req.ChangeReason,
ChangedBy: currentUserID,
EffectiveFrom: now,
}
if err := tx.Create(history).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建价格历史失败")
}
allocation.CostPrice = newPrice
allocation.Updater = currentUserID
if err := tx.Save(allocation).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新成本价失败")
}
}
affectedIDs = append(affectedIDs, allocation.ID)
@@ -112,6 +161,7 @@ func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCo
return &dto.BatchUpdateCostPriceResponse{
UpdatedCount: updatedCount,
AffectedIDs: affectedIDs,
Skipped: skipped,
}, nil
}

View File

@@ -159,13 +159,13 @@ func (s *Service) buildGrantResponse(ctx context.Context, allocation *model.Shop
// 合并全局 operator 和代理 amount
tiers := make([]dto.GrantCommissionTierItem, 0, len(config.Tiers))
for _, globalTier := range config.Tiers {
tiers = append(tiers, dto.GrantCommissionTierItem{
Operator: globalTier.Operator,
Dimension: globalTier.Dimension,
StatScope: globalTier.StatScope,
Threshold: globalTier.Threshold,
Amount: agentAmountMap[globalTier.Threshold],
})
tiers = append(tiers, dto.GrantCommissionTierItem{
Operator: globalTier.Operator,
Dimension: globalTier.Dimension,
StatScope: globalTier.StatScope,
Threshold: globalTier.Threshold,
Amount: agentAmountMap[globalTier.Threshold],
})
}
resp.CommissionTiers = tiers
}
@@ -218,7 +218,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
return nil, errors.Wrap(errors.CodeDatabaseError, err, "检查授权重复失败")
}
if exists {
return nil, errors.New(errors.CodeConflict, "该代理已存在此系列授权")
return nil, errors.New(errors.CodeConflict, "该代理已存在此系列授权")
}
// 3. 确定 allocatorShopID代理操作者必须自己有授权才能向下分配
@@ -332,6 +332,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
PackageID: item.PackageID,
AllocatorShopID: allocatorShopID,
CostPrice: item.CostPrice,
RetailPrice: pkg.SuggestedRetailPrice,
SeriesAllocationID: &allocation.ID,
Status: constants.StatusEnabled,
ShelfStatus: constants.StatusEnabled,
@@ -341,7 +342,6 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
if err := txPkgStore.Create(ctx, pkgAlloc); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐分配失败")
}
// 写成本价历史
_ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
AllocationID: pkgAlloc.ID,
OldCostPrice: 0,
@@ -632,6 +632,16 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
if findErr == nil {
// 已有记录:更新成本价并写历史
oldPrice := existing.CostPrice
if oldPrice != item.CostPrice {
// cost_price 锁定检查:存在下级分配记录时禁止修改
var subCount int64
tx.Model(&model.ShopPackageAllocation{}).
Where("allocator_shop_id = ? AND package_id = ? AND deleted_at IS NULL", allocation.ShopID, item.PackageID).
Count(&subCount)
if subCount > 0 {
return errors.New(errors.CodeForbidden, "存在下级分配记录,请先回收后再修改成本价")
}
}
existing.CostPrice = item.CostPrice
existing.Updater = operatorID
if updateErr := txPkgStore.Update(ctx, existing); updateErr != nil {
@@ -648,24 +658,22 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
})
}
} 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, "无权限分配该套餐")
pkg, pkgErr := s.packageStore.GetByID(ctx, item.PackageID)
if pkgErr != nil || pkg.SeriesID != allocation.SeriesID {
return errors.New(errors.CodeInvalidParam, "套餐不属于该系列,无法添加到此授权")
}
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,
RetailPrice: pkg.SuggestedRetailPrice,
SeriesAllocationID: &allocation.ID,
Status: constants.StatusEnabled,
ShelfStatus: constants.StatusEnabled,