feat: 资产标识符标准化、资产历史订单查询及导入虚拟号强制验证
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m21s

主要变更:
- 新增 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>
This commit is contained in:
2026-04-07 17:39:36 +08:00
parent 7e489a19fb
commit 80c6f6c756
69 changed files with 3039 additions and 851 deletions

View File

@@ -51,6 +51,7 @@ type Service struct {
queueClient *queue.Client
logger *zap.Logger
resumeCallback packagepkg.ResumeCallback
assetIdentifierStore *postgres.AssetIdentifierStore
}
func New(
@@ -72,6 +73,7 @@ func New(
wechatPayment wechat.PaymentServiceInterface,
queueClient *queue.Client,
logger *zap.Logger,
assetIdentifierStore *postgres.AssetIdentifierStore,
) *Service {
return &Service{
db: db,
@@ -92,6 +94,7 @@ func New(
wechatPayment: wechatPayment,
queueClient: queueClient,
logger: logger,
assetIdentifierStore: assetIdentifierStore,
}
}
@@ -348,49 +351,44 @@ func (s *Service) CreateLegacy(ctx context.Context, req *dto.CreateOrderRequest,
// 与 CreateH5Order 的核心区别后台订单不创建待支付状态wallet 立即扣款offline 立即激活
// POST /api/admin/orders
func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) {
resolvedCard, resolvedDevice, resolveErr := s.resolveAssetByIdentifier(ctx, req.Identifier)
if resolveErr != nil {
return nil, resolveErr
}
var orderType string
var iotCardID *uint
var deviceID *uint
if resolvedCard != nil {
orderType = model.OrderTypeSingleCard
iotCardID = &resolvedCard.ID
} else {
orderType = model.OrderTypeDevice
deviceID = &resolvedDevice.ID
}
var validationResult *purchase_validation.PurchaseValidationResult
var err error
if req.PaymentMethod == model.PaymentMethodOffline {
// offline 订单:绕过代理 Allocation 上架检查,仅验证套餐全局状态
if req.OrderType == model.OrderTypeSingleCard {
if req.IotCardID == nil {
return nil, errors.New(errors.CodeInvalidParam, "单卡购买必须指定IoT卡ID")
}
validationResult, err = s.purchaseValidationService.ValidateAdminOfflineCardPurchase(ctx, *req.IotCardID, req.PackageIDs)
} else if req.OrderType == model.OrderTypeDevice {
if req.DeviceID == nil {
return nil, errors.New(errors.CodeInvalidParam, "设备购买必须指定设备ID")
}
validationResult, err = s.purchaseValidationService.ValidateAdminOfflineDevicePurchase(ctx, *req.DeviceID, req.PackageIDs)
if resolvedCard != nil {
validationResult, err = s.purchaseValidationService.ValidateAdminOfflineCardPurchase(ctx, resolvedCard.ID, req.PackageIDs)
} else {
return nil, errors.New(errors.CodeInvalidParam, "无效的订单类型")
validationResult, err = s.purchaseValidationService.ValidateAdminOfflineDevicePurchase(ctx, resolvedDevice.ID, req.PackageIDs)
}
} else {
if req.OrderType == model.OrderTypeSingleCard {
if req.IotCardID == nil {
return nil, errors.New(errors.CodeInvalidParam, "单卡购买必须指定IoT卡ID")
}
// 平台账号代表平台直接下单,不受卡所属代理的套餐分配限制;
// 代理账号下单时,卡所属代理必须已将套餐上架分配
if resolvedCard != nil {
if buyerType == model.BuyerTypeAgent {
validationResult, err = s.purchaseValidationService.ValidateCardPurchase(ctx, *req.IotCardID, req.PackageIDs)
validationResult, err = s.purchaseValidationService.ValidateCardPurchase(ctx, resolvedCard.ID, req.PackageIDs)
} else {
validationResult, err = s.purchaseValidationService.ValidateAdminOfflineCardPurchase(ctx, *req.IotCardID, req.PackageIDs)
}
} else if req.OrderType == model.OrderTypeDevice {
if req.DeviceID == nil {
return nil, errors.New(errors.CodeInvalidParam, "设备购买必须指定设备ID")
}
// 平台账号代表平台直接下单,不受设备所属代理的套餐分配限制;
// 代理账号下单时,设备所属代理必须已将套餐上架分配
if buyerType == model.BuyerTypeAgent {
validationResult, err = s.purchaseValidationService.ValidateDevicePurchase(ctx, *req.DeviceID, req.PackageIDs)
} else {
validationResult, err = s.purchaseValidationService.ValidateAdminOfflineDevicePurchase(ctx, *req.DeviceID, req.PackageIDs)
validationResult, err = s.purchaseValidationService.ValidateAdminOfflineCardPurchase(ctx, resolvedCard.ID, req.PackageIDs)
}
} else {
return nil, errors.New(errors.CodeInvalidParam, "无效的订单类型")
if buyerType == model.BuyerTypeAgent {
validationResult, err = s.purchaseValidationService.ValidateDevicePurchase(ctx, resolvedDevice.ID, req.PackageIDs)
} else {
validationResult, err = s.purchaseValidationService.ValidateAdminOfflineDevicePurchase(ctx, resolvedDevice.ID, req.PackageIDs)
}
}
}
@@ -398,14 +396,12 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
return nil, err
}
// 下单阶段校验混买限制:禁止同一订单同时包含正式套餐和加油包
if err := validatePackageTypeMixFromPackages(validationResult.Packages); err != nil {
return nil, err
}
// 幂等性检查:防止同一买家对同一载体短时间内重复下单
carrierType, carrierID := resolveAdminCarrierInfo(req)
existingOrderID, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, req.OrderType, carrierType, carrierID, req.PackageIDs)
carrierType, carrierID := resolveAdminCarrierInfoFromVars(orderType, iotCardID, deviceID)
existingOrderID, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, orderType, carrierType, carrierID, req.PackageIDs)
if err != nil {
return nil, err
}
@@ -598,13 +594,14 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
Updater: userID,
},
OrderNo: s.orderStore.GenerateOrderNo(),
OrderType: req.OrderType,
OrderType: orderType,
Source: constants.OrderSourceAdmin,
Generation: assetGeneration,
BuyerType: orderBuyerType,
BuyerID: orderBuyerID,
IotCardID: req.IotCardID,
DeviceID: req.DeviceID,
IotCardID: iotCardID,
DeviceID: deviceID,
AssetIdentifier: req.Identifier,
TotalAmount: totalAmount,
PaymentMethod: paymentMethod,
PaymentStatus: paymentStatus,
@@ -624,7 +621,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
items := s.buildOrderItems(userID, validationResult.Packages)
idempotencyKey := buildOrderIdempotencyKey(buyerType, buyerID, req.OrderType, carrierType, carrierID, req.PackageIDs)
idempotencyKey := buildOrderIdempotencyKey(buyerType, buyerID, orderType, carrierType, carrierID, req.PackageIDs)
// 根据支付方式选择创建订单的方式
if req.PaymentMethod == model.PaymentMethodOffline {
@@ -1212,6 +1209,9 @@ func (s *Service) List(ctx context.Context, req *dto.OrderListRequest, buyerType
if req.EndTime != nil {
filters["end_time"] = req.EndTime
}
if req.Identifier != "" {
filters["identifier"] = req.Identifier
}
orders, total, err := s.orderStore.List(ctx, opts, filters)
if err != nil {
@@ -1791,17 +1791,40 @@ func resolveCarrierInfo(req *dto.CreateOrderRequest) (carrierType string, carrie
return "", 0
}
// resolveAdminCarrierInfo 从后台订单请求中提取载体类型和ID
func resolveAdminCarrierInfo(req *dto.CreateAdminOrderRequest) (carrierType string, carrierID uint) {
if req.OrderType == model.OrderTypeSingleCard && req.IotCardID != nil {
return "iot_card", *req.IotCardID
// resolveAdminCarrierInfoFromVars 从已解析的订单类型和资产ID中提取载体类型和ID
func resolveAdminCarrierInfoFromVars(orderType string, iotCardID *uint, deviceID *uint) (carrierType string, carrierID uint) {
if orderType == model.OrderTypeSingleCard && iotCardID != nil {
return "iot_card", *iotCardID
}
if req.OrderType == model.OrderTypeDevice && req.DeviceID != nil {
return "device", *req.DeviceID
if orderType == model.OrderTypeDevice && deviceID != nil {
return "device", *deviceID
}
return "", 0
}
// resolveAssetByIdentifier 通过标识符ICCID 或 VirtualNo解析资产
// 优先查注册表,注册表命中则直接返回对应卡或设备
func (s *Service) resolveAssetByIdentifier(ctx context.Context, identifier string) (*model.IotCard, *model.Device, error) {
if s.assetIdentifierStore != nil {
regRecord, regErr := s.assetIdentifierStore.FindByIdentifier(ctx, identifier)
if regErr == nil && regRecord != nil {
switch regRecord.AssetType {
case model.AssetTypeIotCard:
card, cardErr := s.iotCardStore.GetByID(ctx, regRecord.AssetID)
if cardErr == nil && card != nil {
return card, nil, nil
}
case model.AssetTypeDevice:
device, devErr := s.deviceStore.GetByID(ctx, regRecord.AssetID)
if devErr == nil && device != nil {
return nil, device, nil
}
}
}
}
return nil, nil, errors.New(errors.CodeNotFound, "未找到对应资产,请使用 ICCID 或虚拟号")
}
// buildOrderIdempotencyKey 生成订单创建的幂等性业务键
// 格式: {buyer_type}:{buyer_id}:{order_type}:{carrier_type}:{carrier_id}:{sorted_package_ids}
func buildOrderIdempotencyKey(buyerType string, buyerID uint, orderType string, carrierType string, carrierID uint, packageIDs []uint) string {
@@ -2134,6 +2157,13 @@ func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderIte
}
}
assetType := ""
if order.OrderType == model.OrderTypeSingleCard {
assetType = "card"
} else if order.OrderType == model.OrderTypeDevice {
assetType = "device"
}
return &dto.OrderResponse{
ID: order.ID,
OrderNo: order.OrderNo,
@@ -2151,13 +2181,11 @@ func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderIte
CommissionStatus: order.CommissionStatus,
CommissionConfigVersion: order.CommissionConfigVersion,
// 操作者信息
OperatorID: order.OperatorID,
OperatorType: order.OperatorType,
OperatorName: operatorName,
ActualPaidAmount: order.ActualPaidAmount,
// 订单角色
PurchaseRole: order.PurchaseRole,
IsPurchasedByParent: isPurchasedByParent,
PurchaseRemark: purchaseRemark,
@@ -2166,9 +2194,11 @@ func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderIte
CreatedAt: order.CreatedAt,
UpdatedAt: order.UpdatedAt,
// 订单超时信息
ExpiresAt: order.ExpiresAt,
IsExpired: order.ExpiresAt != nil && order.PaymentStatus == model.PaymentStatusPending && time.Now().After(*order.ExpiresAt),
AssetIdentifier: order.AssetIdentifier,
AssetType: assetType,
}
}