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

@@ -0,0 +1,51 @@
package postgres
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"gorm.io/gorm"
)
// AssetIdentifierStore 全局资产标识符注册表存储层
type AssetIdentifierStore struct {
db *gorm.DB
}
// NewAssetIdentifierStore 创建资产标识符注册表存储实例
func NewAssetIdentifierStore(db *gorm.DB) *AssetIdentifierStore {
return &AssetIdentifierStore{db: db}
}
// Register 注册资产标识符
// 在同一事务内调用,冲突时事务回滚
func (s *AssetIdentifierStore) Register(ctx context.Context, identifier, assetType string, assetID uint) error {
record := &model.AssetIdentifier{
Identifier: identifier,
AssetType: assetType,
AssetID: assetID,
}
return s.db.WithContext(ctx).Create(record).Error
}
// FindByIdentifier 通过标识符精确查找资产注册记录
// 未命中时返回 nil, nil非错误状态调用方按需 fallback
func (s *AssetIdentifierStore) FindByIdentifier(ctx context.Context, identifier string) (*model.AssetIdentifier, error) {
var record model.AssetIdentifier
err := s.db.WithContext(ctx).Where("identifier = ?", identifier).First(&record).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &record, nil
}
// DeleteByAsset 删除资产时清理注册表(软删除时调用)
// 删除该资产对应的所有注册记录,允许标识符被后续资产复用
func (s *AssetIdentifierStore) DeleteByAsset(ctx context.Context, assetType string, assetID uint) error {
return s.db.WithContext(ctx).
Where("asset_type = ? AND asset_id = ?", assetType, assetID).
Delete(&model.AssetIdentifier{}).Error
}

View File

@@ -93,6 +93,24 @@ func (s *ExchangeOrderStore) UpdateStatus(ctx context.Context, id uint, fromStat
return nil
}
// FindByNewAssetID 通过新资产类型和ID查询换货来源记录
// 用于换货链追溯:找到将当前资产作为换货结果的换货单,从而得到前代资产信息
// 若未找到则返回 nil, nil 表示该资产无前代
func (s *ExchangeOrderStore) FindByNewAssetID(ctx context.Context, assetType string, assetID uint) (*model.ExchangeOrder, error) {
var order model.ExchangeOrder
err := s.db.WithContext(ctx).
Where("new_asset_id = ? AND new_asset_type = ?", assetID, assetType).
Order("id DESC").
First(&order).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &order, nil
}
func (s *ExchangeOrderStore) FindActiveByOldAsset(ctx context.Context, assetType string, assetID uint) (*model.ExchangeOrder, error) {
var order model.ExchangeOrder
query := s.db.WithContext(ctx).

View File

@@ -133,6 +133,9 @@ func (s *OrderStore) List(ctx context.Context, opts *store.QueryOptions, filters
if v, ok := filters["end_time"]; ok {
query = query.Where("created_at <= ?", v)
}
if v, ok := filters["identifier"]; ok {
query = query.Where("asset_identifier = ?", v)
}
if v, ok := filters["is_expired"]; ok {
isExpired, _ := v.(bool)
if isExpired {
@@ -186,6 +189,28 @@ func (s *OrderStore) GenerateOrderNo() string {
return fmt.Sprintf("ORD%s%06d", now.Format("20060102150405"), randomNum)
}
// ListByAssetIdentifier 按资产标识符分页查询订单(倒序)
// 用于资产历史订单查询,直接走 asset_identifier 快照字段精确匹配
// 数据权限已在调用方asset service的 Resolve 环节检查,此处不重复过滤
func (s *OrderStore) ListByAssetIdentifier(ctx context.Context, identifier string, page, pageSize int) ([]*model.Order, int64, error) {
var orders []*model.Order
var total int64
query := s.db.WithContext(ctx).Model(&model.Order{}).
Where("asset_identifier = ?", identifier)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
if err := query.Order("id DESC").Offset(offset).Limit(pageSize).Find(&orders).Error; err != nil {
return nil, 0, err
}
return orders, total, nil
}
// FindExpiredOrders 查询已超时的待支付订单
// 查询条件expires_at <= NOW() AND payment_status = 1待支付
// limit 参数限制每次批量处理的数量,避免一次性加载太多数据