feat: 资产标识符标准化、资产历史订单查询及导入虚拟号强制验证
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m21s
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:
@@ -37,9 +37,13 @@ type Service struct {
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore
|
||||
shopStore *postgres.ShopStore
|
||||
orderStore *postgres.OrderStore
|
||||
orderItemStore *postgres.OrderItemStore
|
||||
exchangeOrderStore *postgres.ExchangeOrderStore
|
||||
redis *redis.Client
|
||||
iotCardService IotCardRefresher
|
||||
gatewayClient *gateway.Client // 用于调用 sync-info 同步设备信息(可为 nil,失败时仅记录日志)
|
||||
gatewayClient *gateway.Client
|
||||
assetIdentifierStore *postgres.AssetIdentifierStore
|
||||
}
|
||||
|
||||
// New 创建资产服务实例
|
||||
@@ -55,6 +59,10 @@ func New(
|
||||
redisClient *redis.Client,
|
||||
iotCardService IotCardRefresher,
|
||||
gatewayClient *gateway.Client,
|
||||
assetIdentifierStore *postgres.AssetIdentifierStore,
|
||||
orderStore *postgres.OrderStore,
|
||||
orderItemStore *postgres.OrderItemStore,
|
||||
exchangeOrderStore *postgres.ExchangeOrderStore,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
@@ -65,28 +73,65 @@ func New(
|
||||
packageSeriesStore: packageSeriesStore,
|
||||
deviceSimBindingStore: deviceSimBindingStore,
|
||||
shopStore: shopStore,
|
||||
orderStore: orderStore,
|
||||
orderItemStore: orderItemStore,
|
||||
exchangeOrderStore: exchangeOrderStore,
|
||||
redis: redisClient,
|
||||
iotCardService: iotCardService,
|
||||
gatewayClient: gatewayClient,
|
||||
assetIdentifierStore: assetIdentifierStore,
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve 通过任意标识符解析资产
|
||||
// 优先匹配设备(virtual_no/imei/sn),未命中则匹配卡(virtual_no/iccid/msisdn)
|
||||
// 主路径:查注册表(精确匹配 ICCID 或 VirtualNo)
|
||||
// Fallback:原有跨表 OR 查询(处理 IMEI/SN/MSISDN 等非注册标识符)
|
||||
func (s *Service) Resolve(ctx context.Context, identifier string) (*dto.AssetResolveResponse, error) {
|
||||
// 先查 Device
|
||||
device, err := s.deviceStore.GetByIdentifier(ctx, identifier)
|
||||
if err == nil && device != nil {
|
||||
return s.buildDeviceResolveResponse(ctx, device)
|
||||
if s.assetIdentifierStore != nil {
|
||||
regRecord, regErr := s.assetIdentifierStore.FindByIdentifier(ctx, identifier)
|
||||
if regErr == nil && regRecord != nil {
|
||||
switch regRecord.AssetType {
|
||||
case model.AssetTypeDevice:
|
||||
device, devErr := s.deviceStore.GetByID(ctx, regRecord.AssetID)
|
||||
if devErr == nil && device != nil {
|
||||
resp, buildErr := s.buildDeviceResolveResponse(ctx, device)
|
||||
if buildErr == nil {
|
||||
resp.Identifier = identifier
|
||||
}
|
||||
return resp, buildErr
|
||||
}
|
||||
case model.AssetTypeIotCard:
|
||||
card, cardErr := s.iotCardStore.GetByID(ctx, regRecord.AssetID)
|
||||
if cardErr == nil && card != nil {
|
||||
resp, buildErr := s.buildCardResolveResponse(ctx, card)
|
||||
if buildErr == nil {
|
||||
resp.Identifier = identifier
|
||||
}
|
||||
return resp, buildErr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
device, err := s.deviceStore.GetByIdentifier(ctx, identifier)
|
||||
if err == nil && device != nil {
|
||||
resp, buildErr := s.buildDeviceResolveResponse(ctx, device)
|
||||
if buildErr == nil {
|
||||
resp.Identifier = identifier
|
||||
}
|
||||
return resp, buildErr
|
||||
}
|
||||
|
||||
// 未找到设备,查 IotCard(virtual_no/iccid/msisdn)
|
||||
var card model.IotCard
|
||||
query := s.db.WithContext(ctx).
|
||||
Where("virtual_no = ? OR iccid = ? OR msisdn = ?", identifier, identifier, identifier)
|
||||
query = middleware.ApplyShopFilter(ctx, query)
|
||||
if err := query.First(&card).Error; err == nil {
|
||||
return s.buildCardResolveResponse(ctx, &card)
|
||||
resp, buildErr := s.buildCardResolveResponse(ctx, &card)
|
||||
if buildErr == nil {
|
||||
resp.Identifier = identifier
|
||||
}
|
||||
return resp, buildErr
|
||||
}
|
||||
|
||||
return nil, errors.New(errors.CodeNotFound, "未找到匹配的资产")
|
||||
@@ -725,6 +770,204 @@ func safeVirtualRatio(ratio float64) float64 {
|
||||
return ratio
|
||||
}
|
||||
|
||||
// GetOrders 查询资产历史订单,支持换货链跨代追溯
|
||||
// page/pageSize 仅对本代订单生效;前代订单每代最多返回20条
|
||||
// 最多追溯10代,超出后 truncated=true
|
||||
func (s *Service) GetOrders(ctx context.Context, identifier string, page, pageSize int, includePrevious bool) (*dto.AssetOrdersResponse, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
|
||||
asset, err := s.Resolve(ctx, identifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
currentGeneration := s.resolveAssetGeneration(ctx, asset.AssetType, asset.AssetID)
|
||||
|
||||
orders, total, err := s.orderStore.ListByAssetIdentifier(ctx, identifier, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询本代订单失败")
|
||||
}
|
||||
|
||||
orderItems := s.fetchOrderItemsMap(ctx, orders)
|
||||
currentItems := make([]*dto.AssetOrderItem, 0, len(orders))
|
||||
for _, o := range orders {
|
||||
currentItems = append(currentItems, buildAssetOrderItem(o, orderItems[o.ID]))
|
||||
}
|
||||
|
||||
resp := &dto.AssetOrdersResponse{
|
||||
CurrentGeneration: &dto.GenerationOrders{
|
||||
Generation: currentGeneration,
|
||||
Identifier: identifier,
|
||||
AssetType: asset.AssetType,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Items: currentItems,
|
||||
},
|
||||
PreviousGenerations: nil,
|
||||
Truncated: false,
|
||||
}
|
||||
|
||||
if !includePrevious {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
prevGens, truncated := s.tracePreviousGenerations(ctx, asset.AssetType, asset.AssetID)
|
||||
resp.PreviousGenerations = prevGens
|
||||
resp.Truncated = truncated
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveAssetGeneration(ctx context.Context, assetType string, assetID uint) int {
|
||||
switch assetType {
|
||||
case "card":
|
||||
card, err := s.iotCardStore.GetByID(ctx, assetID)
|
||||
if err == nil && card != nil {
|
||||
return card.Generation
|
||||
}
|
||||
case "device":
|
||||
device, err := s.deviceStore.GetByID(ctx, assetID)
|
||||
if err == nil && device != nil {
|
||||
return device.Generation
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// tracePreviousGenerations 通过换货链逆向追溯前代资产的订单,最多追溯10代
|
||||
func (s *Service) tracePreviousGenerations(ctx context.Context, assetType string, assetID uint) ([]*dto.PreviousGenerationOrders, bool) {
|
||||
const maxDepth = 10
|
||||
const prevPageSize = 20
|
||||
|
||||
prevGens := make([]*dto.PreviousGenerationOrders, 0)
|
||||
currentType := assetType
|
||||
currentID := assetID
|
||||
truncated := false
|
||||
|
||||
for i := 0; i < maxDepth; i++ {
|
||||
// 转换为 ExchangeOrder 中使用的资产类型(card→iot_card)
|
||||
storeType := currentType
|
||||
if storeType == "card" {
|
||||
storeType = "iot_card"
|
||||
}
|
||||
|
||||
exchOrder, err := s.exchangeOrderStore.FindByNewAssetID(ctx, storeType, currentID)
|
||||
if err != nil || exchOrder == nil {
|
||||
break
|
||||
}
|
||||
|
||||
oldOrders, oldTotal, err := s.orderStore.ListByAssetIdentifier(ctx, exchOrder.OldAssetIdentifier, 1, prevPageSize)
|
||||
if err != nil {
|
||||
logger.GetAppLogger().Warn("查询前代订单失败",
|
||||
zap.String("old_identifier", exchOrder.OldAssetIdentifier),
|
||||
zap.Error(err))
|
||||
oldOrders = nil
|
||||
oldTotal = 0
|
||||
}
|
||||
|
||||
oldItems := s.fetchOrderItemsMap(ctx, oldOrders)
|
||||
orderItems := make([]*dto.AssetOrderItem, 0, len(oldOrders))
|
||||
for _, o := range oldOrders {
|
||||
orderItems = append(orderItems, buildAssetOrderItem(o, oldItems[o.ID]))
|
||||
}
|
||||
|
||||
oldAssetType := exchOrder.OldAssetType
|
||||
if oldAssetType == "iot_card" {
|
||||
oldAssetType = "card"
|
||||
}
|
||||
|
||||
oldGeneration := s.resolveAssetGeneration(ctx, oldAssetType, exchOrder.OldAssetID)
|
||||
|
||||
prevGens = append(prevGens, &dto.PreviousGenerationOrders{
|
||||
Generation: oldGeneration,
|
||||
Identifier: exchOrder.OldAssetIdentifier,
|
||||
AssetType: oldAssetType,
|
||||
ExchangeNo: exchOrder.ExchangeNo,
|
||||
ExchangedAt: exchOrder.UpdatedAt,
|
||||
Total: oldTotal,
|
||||
Items: orderItems,
|
||||
})
|
||||
|
||||
currentType = oldAssetType
|
||||
currentID = exchOrder.OldAssetID
|
||||
|
||||
if i == maxDepth-1 {
|
||||
truncated = true
|
||||
}
|
||||
}
|
||||
|
||||
return prevGens, truncated
|
||||
}
|
||||
|
||||
// fetchOrderItemsMap 批量查询订单明细并按 orderID 分组
|
||||
func (s *Service) fetchOrderItemsMap(ctx context.Context, orders []*model.Order) map[uint][]*model.OrderItem {
|
||||
if len(orders) == 0 {
|
||||
return nil
|
||||
}
|
||||
orderIDs := make([]uint, 0, len(orders))
|
||||
for _, o := range orders {
|
||||
orderIDs = append(orderIDs, o.ID)
|
||||
}
|
||||
allItems, err := s.orderItemStore.ListByOrderIDs(ctx, orderIDs)
|
||||
if err != nil {
|
||||
logger.GetAppLogger().Warn("批量查询订单明细失败", zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
result := make(map[uint][]*model.OrderItem, len(orders))
|
||||
for _, item := range allItems {
|
||||
result[item.OrderID] = append(result[item.OrderID], item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func buildAssetOrderItem(o *model.Order, items []*model.OrderItem) *dto.AssetOrderItem {
|
||||
details := make([]*dto.AssetOrderItemDetail, 0, len(items))
|
||||
for _, it := range items {
|
||||
details = append(details, &dto.AssetOrderItemDetail{
|
||||
PackageName: it.PackageName,
|
||||
Quantity: it.Quantity,
|
||||
UnitPrice: it.UnitPrice,
|
||||
Amount: it.Amount,
|
||||
})
|
||||
}
|
||||
return &dto.AssetOrderItem{
|
||||
OrderNo: o.OrderNo,
|
||||
OrderType: o.OrderType,
|
||||
PaymentStatus: o.PaymentStatus,
|
||||
PaymentStatusText: paymentStatusText(o.PaymentStatus),
|
||||
TotalAmount: o.TotalAmount,
|
||||
PaymentMethod: o.PaymentMethod,
|
||||
PaidAt: o.PaidAt,
|
||||
Generation: o.Generation,
|
||||
Items: details,
|
||||
CreatedAt: o.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func paymentStatusText(status int) string {
|
||||
switch status {
|
||||
case 1:
|
||||
return "待支付"
|
||||
case 2:
|
||||
return "已支付"
|
||||
case 3:
|
||||
return "已取消"
|
||||
case 4:
|
||||
return "已退款"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
|
||||
// packageStatusName 套餐状态码转中文名称
|
||||
func packageStatusName(status int) string {
|
||||
switch status {
|
||||
|
||||
@@ -31,6 +31,7 @@ type Service struct {
|
||||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
gatewayClient *gateway.Client
|
||||
assetIdentifierStore *postgres.AssetIdentifierStore
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -45,6 +46,7 @@ func New(
|
||||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||
packageSeriesStore *postgres.PackageSeriesStore,
|
||||
gatewayClient *gateway.Client,
|
||||
assetIdentifierStore *postgres.AssetIdentifierStore,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
@@ -58,6 +60,7 @@ func New(
|
||||
shopSeriesAllocationStore: shopSeriesAllocationStore,
|
||||
packageSeriesStore: packageSeriesStore,
|
||||
gatewayClient: gatewayClient,
|
||||
assetIdentifierStore: assetIdentifierStore,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,7 +225,32 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.deviceStore.Delete(ctx, id)
|
||||
if err := s.deviceStore.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.assetIdentifierStore != nil {
|
||||
_ = s.assetIdentifierStore.DeleteByAsset(ctx, model.AssetTypeDevice, id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByVirtualNo 通过虚拟号获取设备
|
||||
func (s *Service) GetByVirtualNo(ctx context.Context, virtualNo string) (*model.Device, error) {
|
||||
return s.deviceStore.GetByIdentifier(ctx, virtualNo)
|
||||
}
|
||||
|
||||
// GetCardByICCID 通过 ICCID 获取 IoT 卡
|
||||
func (s *Service) GetCardByICCID(ctx context.Context, iccid string) (*model.IotCard, error) {
|
||||
cards, err := s.iotCardStore.GetByICCIDs(ctx, []string{iccid})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡失败")
|
||||
}
|
||||
if len(cards) == 0 {
|
||||
return nil, errors.New(errors.CodeNotFound, "卡不存在")
|
||||
}
|
||||
return cards[0], nil
|
||||
}
|
||||
|
||||
// AllocateDevices 批量分配设备
|
||||
|
||||
@@ -48,8 +48,9 @@ type Service struct {
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
gatewayClient *gateway.Client
|
||||
logger *zap.Logger
|
||||
pollingCallback PollingCallback // 轮询回调,可选
|
||||
dataDeductor DataDeductor // 流量扣减回调,可选
|
||||
pollingCallback PollingCallback
|
||||
dataDeductor DataDeductor
|
||||
assetIdentifierStore *postgres.AssetIdentifierStore
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -77,11 +78,15 @@ func New(
|
||||
}
|
||||
|
||||
// SetPollingCallback 设置轮询回调
|
||||
// 在应用启动时由 bootstrap 调用,注入轮询调度器
|
||||
func (s *Service) SetPollingCallback(callback PollingCallback) {
|
||||
s.pollingCallback = callback
|
||||
}
|
||||
|
||||
// SetAssetIdentifierStore 设置资产标识符注册表存储
|
||||
func (s *Service) SetAssetIdentifierStore(store *postgres.AssetIdentifierStore) {
|
||||
s.assetIdentifierStore = store
|
||||
}
|
||||
|
||||
// SetDataDeductor 设置流量扣减回调
|
||||
// 在应用启动时由 bootstrap 调用,注入套餐扣减服务
|
||||
func (s *Service) SetDataDeductor(deductor DataDeductor) {
|
||||
@@ -1073,14 +1078,16 @@ func (s *Service) DeleteCard(ctx context.Context, cardID uint) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 执行软删除
|
||||
if err := s.iotCardStore.Delete(ctx, cardID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.assetIdentifierStore != nil {
|
||||
_ = s.assetIdentifierStore.DeleteByAsset(ctx, model.AssetTypeIotCard, cardID)
|
||||
}
|
||||
|
||||
s.logger.Info("删除卡", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID))
|
||||
|
||||
// 通知轮询调度器
|
||||
if s.pollingCallback != nil {
|
||||
s.pollingCallback.OnCardDeleted(ctx, cardID)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ func (s *Service) ValidateCardPurchase(ctx context.Context, cardID uint, package
|
||||
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, "该卡未关联套餐系列,无法购买套餐")
|
||||
}
|
||||
@@ -197,6 +201,10 @@ func (s *Service) ValidateAdminOfflineCardPurchase(ctx context.Context, cardID u
|
||||
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, "该卡未关联套餐系列,无法购买套餐")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user