Files
junhong_cmp_fiber/internal/service/purchase_validation/service.go
huang 80c6f6c756
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m21s
feat: 资产标识符标准化、资产历史订单查询及导入虚拟号强制验证
主要变更:
- 新增 AssetIdentifier 模型及 Store,统一管理资产标识符(ICCID/IMEI/SN 等)
- 新增迁移:asset_identifier 表、order 表新增 asset_identifier 字段、iot_card.virtual_no NOT NULL 约束
- 资产 Handler/Service/Route 全面重构,支持标识符路由查询与解析
- 新增资产历史订单查询接口,支持跨设备/卡/钱包维度的订单聚合
- 设备与物联卡导入任务强制校验虚拟号,缺失时直接拒绝
- Excel 工具函数优化,前端导入指引文档同步更新
- 归档三个 OpenSpec 提案:asset-identifier-standardization、asset-historical-orders、import-mandatory-virtual-no
- 更新 OpenAPI 文档及相关 DTO

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-07 17:39:36 +08:00

250 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package purchase_validation
import (
"context"
"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"
)
type Service struct {
db *gorm.DB
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
packageStore *postgres.PackageStore
packageAllocationStore *postgres.ShopPackageAllocationStore
}
func New(
db *gorm.DB,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
packageStore *postgres.PackageStore,
packageAllocationStore *postgres.ShopPackageAllocationStore,
) *Service {
return &Service{
db: db,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
packageStore: packageStore,
packageAllocationStore: packageAllocationStore,
}
}
type PurchaseValidationResult struct {
Card *model.IotCard
Device *model.Device
Packages []*model.Package
TotalPrice int64
}
func (s *Service) ValidateCardPurchase(ctx context.Context, cardID uint, packageIDs []uint) (*PurchaseValidationResult, error) {
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeIotCardNotFound, "IoT卡不存在")
}
return nil, err
}
if !card.IsStandalone {
return nil, errors.New(errors.CodeInvalidParam, "该卡已绑定设备,请前往设备页面购买套餐")
}
if card.SeriesID == nil || *card.SeriesID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "该卡未关联套餐系列,无法购买套餐")
}
// 确定卖家店铺ID卡所属店铺即为卖家代理渠道平台自营时为0
var sellerShopID uint
if card.ShopID != nil {
sellerShopID = *card.ShopID
}
packages, totalPrice, err := s.validatePackages(ctx, packageIDs, *card.SeriesID, sellerShopID)
if err != nil {
return nil, err
}
return &PurchaseValidationResult{
Card: card,
Packages: packages,
TotalPrice: totalPrice,
}, nil
}
func (s *Service) ValidateDevicePurchase(ctx context.Context, deviceID uint, packageIDs []uint) (*PurchaseValidationResult, error) {
device, err := s.deviceStore.GetByID(ctx, deviceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "设备不存在")
}
return nil, err
}
if device.SeriesID == nil || *device.SeriesID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "该设备未关联套餐系列,无法购买套餐")
}
// 确定卖家店铺ID设备所属店铺即为卖家代理渠道平台自营时为0
var sellerShopID uint
if device.ShopID != nil {
sellerShopID = *device.ShopID
}
packages, totalPrice, err := s.validatePackages(ctx, packageIDs, *device.SeriesID, sellerShopID)
if err != nil {
return nil, err
}
return &PurchaseValidationResult{
Device: device,
Packages: packages,
TotalPrice: totalPrice,
}, nil
}
// validatePackages 验证套餐列表是否可购买
// sellerShopID > 0 表示代理渠道,校验该代理的 allocation.shelf_status
// sellerShopID == 0 表示平台自营渠道,校验 package.shelf_status
func (s *Service) validatePackages(ctx context.Context, packageIDs []uint, seriesID uint, sellerShopID uint) ([]*model.Package, int64, error) {
if len(packageIDs) == 0 {
return nil, 0, errors.New(errors.CodeInvalidParam, "请选择至少一个套餐")
}
var packages []*model.Package
var totalPrice int64
for _, pkgID := range packageIDs {
pkg, err := s.packageStore.GetByID(ctx, pkgID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐不存在")
}
return nil, 0, err
}
if pkg.SeriesID != seriesID {
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐不在可购买范围内")
}
// Package.status 为全局开关,任何渠道都必须检查
if pkg.Status != constants.StatusEnabled {
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐已禁用")
}
if sellerShopID > 0 {
// 代理渠道:检查上架状态并获取分配记录,使用零售价
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 {
if pkg.ShelfStatus != constants.ShelfStatusOn {
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐已下架")
}
totalPrice += pkg.SuggestedRetailPrice
}
packages = append(packages, pkg)
}
return packages, totalPrice, nil
}
// 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 nil, errors.New(errors.CodeInvalidParam, "套餐已下架")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐分配记录失败")
}
if allocation.ShelfStatus != constants.ShelfStatusOn {
return nil, errors.New(errors.CodeInvalidParam, "套餐已下架")
}
return allocation, nil
}
// 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 订单专用卡验证
// 绕过代理 Allocation 上架检查,仅验证套餐全局状态
func (s *Service) ValidateAdminOfflineCardPurchase(ctx context.Context, cardID uint, packageIDs []uint) (*PurchaseValidationResult, error) {
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeIotCardNotFound, "IoT卡不存在")
}
return nil, err
}
if !card.IsStandalone {
return nil, errors.New(errors.CodeInvalidParam, "该卡已绑定设备,请前往设备页面购买套餐")
}
if card.SeriesID == nil || *card.SeriesID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "该卡未关联套餐系列,无法购买套餐")
}
packages, totalPrice, err := s.validatePackages(ctx, packageIDs, *card.SeriesID, 0)
if err != nil {
return nil, err
}
return &PurchaseValidationResult{
Card: card,
Packages: packages,
TotalPrice: totalPrice,
}, nil
}
// ValidateAdminOfflineDevicePurchase 后台 offline 订单专用设备验证
// 绕过代理 Allocation 上架检查,仅验证套餐全局状态
func (s *Service) ValidateAdminOfflineDevicePurchase(ctx context.Context, deviceID uint, packageIDs []uint) (*PurchaseValidationResult, error) {
device, err := s.deviceStore.GetByID(ctx, deviceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "设备不存在")
}
return nil, err
}
if device.SeriesID == nil || *device.SeriesID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "该设备未关联套餐系列,无法购买套餐")
}
packages, totalPrice, err := s.validatePackages(ctx, packageIDs, *device.SeriesID, 0)
if err != nil {
return nil, err
}
return &PurchaseValidationResult{
Device: device,
Packages: packages,
TotalPrice: totalPrice,
}, nil
}