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

@@ -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
}
// 未找到设备,查 IotCardvirtual_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 {