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:
File diff suppressed because it is too large
Load Diff
@@ -90,30 +90,52 @@ function validateFile(file) {
|
||||
**文件名**: `iccid_import_template.xlsx`
|
||||
|
||||
**格式**:
|
||||
| ICCID | MSISDN |
|
||||
|-------|--------|
|
||||
| 89860012345678901234 | 13800000001 |
|
||||
| 89860012345678901235 | 13800000002 |
|
||||
| ICCID | MSISDN | virtual_no |
|
||||
|-------|--------|------------|
|
||||
| 89860012345678901234 | 13800000001 | VNO-00001 |
|
||||
| 89860012345678901235 | 13800000002 | VNO-00002 |
|
||||
|
||||
**字段说明**:
|
||||
|
||||
| 列名 | 是否必填 | 说明 |
|
||||
|------|---------|------|
|
||||
| ICCID | **是** | 卡唯一标识,电信19位/其他20位 |
|
||||
| MSISDN | **是** | 接入号 |
|
||||
| virtual_no | **是** | 虚拟号,全局唯一;留空将导致该行导入失败 |
|
||||
|
||||
**要点**:
|
||||
- 必须包含表头行(ICCID, MSISDN)
|
||||
- ICCID和MSISDN列必须设置为**文本格式**(重要!)
|
||||
- 必须包含表头行(ICCID, MSISDN, virtual_no)
|
||||
- 所有列必须设置为**文本格式**(重要!)
|
||||
- Excel中设置文本格式: 选中列 → 右键 → 设置单元格格式 → 文本
|
||||
- `virtual_no` 为必填列,**缺少该列将导致整批导入失败**
|
||||
|
||||
### 设备导入模板
|
||||
|
||||
**文件名**: `device_import_template.xlsx`
|
||||
|
||||
**格式**:
|
||||
| device_no | device_name | device_model | device_type | max_sim_slots | manufacturer | iccid_1 | iccid_2 | iccid_3 | iccid_4 |
|
||||
|-----------|-------------|--------------|-------------|---------------|--------------|---------|---------|---------|---------|
|
||||
| DEV-001 | GPS追踪器A | GT06N | GPS Tracker | 4 | Concox | 89860012345678901234 | 89860012345678901235 | | |
|
||||
| DEV-002 | GPS追踪器B | GT06N | GPS Tracker | 4 | Concox | 89860012345678901236 | | | |
|
||||
| virtual_no | device_name | device_model | device_type | max_sim_slots | manufacturer | imei | iccid_1 | iccid_2 | iccid_3 | iccid_4 |
|
||||
|------------|-------------|--------------|-------------|---------------|--------------|------|---------|---------|---------|---------|
|
||||
| DEV-001 | GPS追踪器A | GT06N | GPS Tracker | 4 | Concox | 123456789012345 | 89860012345678901234 | 89860012345678901235 | | |
|
||||
| DEV-002 | GPS追踪器B | GT06N | GPS Tracker | 4 | Concox | 123456789012346 | 89860012345678901236 | | | |
|
||||
|
||||
**字段说明**:
|
||||
|
||||
| 列名 | 是否必填 | 说明 |
|
||||
|------|---------|------|
|
||||
| virtual_no | **是** | 设备虚拟号,全局唯一;**留空不再跳过,将记录为失败行** |
|
||||
| device_name | 否 | 设备名称 |
|
||||
| device_model | 否 | 设备型号 |
|
||||
| device_type | 否 | 设备类型 |
|
||||
| max_sim_slots | 否 | 最大SIM卡槽数,默认4 |
|
||||
| manufacturer | 否 | 制造商 |
|
||||
| imei | 否 | 设备IMEI |
|
||||
| iccid_1 ~ iccid_4 | 否 | 绑定的IoT卡ICCID,填写时对应ICCID必须已存在于系统中 |
|
||||
|
||||
**要点**:
|
||||
- 所有列都必须设置为**文本格式**
|
||||
- device_no为必填项
|
||||
- iccid_1 ~ iccid_4为可选项,填写时对应的ICCID必须已存在于系统中
|
||||
- `virtual_no` 为必填项,留空将记录为失败行(而非静默跳过)
|
||||
- iccid_1 ~ iccid_4为可选项,填写时对应的ICCID必须已存在于系统中
|
||||
|
||||
## 模板下载功能实现
|
||||
|
||||
|
||||
@@ -97,12 +97,18 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
Asset: func() *admin.AssetHandler {
|
||||
pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger)
|
||||
assetPollingSvc := pollingSvcPkg.NewAssetPollingService(deviceStore, deviceSimBindingStore, svc.IotCard, pollingQueueMgr, deps.Logger)
|
||||
return admin.NewAssetHandler(svc.Asset, svc.Device, svc.StopResumeService, assetPollingSvc)
|
||||
h := admin.NewAssetHandler(svc.Asset, svc.Device, svc.StopResumeService, assetPollingSvc)
|
||||
h.SetLifecycleService(svc.AssetLifecycle)
|
||||
return h
|
||||
}(),
|
||||
AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle),
|
||||
AssetWallet: admin.NewAssetWalletHandler(svc.AssetWallet),
|
||||
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
|
||||
AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge, validate),
|
||||
Refund: admin.NewRefundHandler(svc.Refund),
|
||||
AssetWallet: func() *admin.AssetWalletHandler {
|
||||
h := admin.NewAssetWalletHandler(svc.AssetWallet)
|
||||
h.SetAssetService(svc.Asset)
|
||||
return h
|
||||
}(),
|
||||
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
|
||||
AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge, validate),
|
||||
Refund: admin.NewRefundHandler(svc.Refund),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ type stores struct {
|
||||
RefundRequest *postgres.RefundStore
|
||||
// 流量系统
|
||||
CardDailyUsage *postgres.CardDailyUsageStore
|
||||
// 资产标识符注册表
|
||||
AssetIdentifier *postgres.AssetIdentifierStore
|
||||
}
|
||||
|
||||
func initStores(deps *Dependencies) *stores {
|
||||
@@ -122,5 +124,6 @@ func initStores(deps *Dependencies) *stores {
|
||||
WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis),
|
||||
RefundRequest: postgres.NewRefundStore(deps.DB),
|
||||
CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB),
|
||||
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
nil, // wechatPayment: 超时取消不需要
|
||||
nil, // queueClient: 超时取消不触发分佣
|
||||
deps.Logger,
|
||||
stores.AssetIdentifier,
|
||||
)
|
||||
|
||||
// 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机
|
||||
|
||||
@@ -29,6 +29,7 @@ type workerStores struct {
|
||||
AgentWallet *postgres.AgentWalletStore
|
||||
AgentWalletTransaction *postgres.AgentWalletTransactionStore
|
||||
AssetWallet *postgres.AssetWalletStore
|
||||
AssetIdentifier *postgres.AssetIdentifierStore
|
||||
}
|
||||
|
||||
func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
|
||||
@@ -56,6 +57,7 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
|
||||
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
|
||||
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
|
||||
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
|
||||
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
|
||||
}
|
||||
|
||||
return &queue.WorkerStores{
|
||||
@@ -82,5 +84,6 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
|
||||
AgentWallet: stores.AgentWallet,
|
||||
AgentWalletTransaction: stores.AgentWalletTransaction,
|
||||
AssetWallet: stores.AssetWallet,
|
||||
AssetIdentifier: stores.AssetIdentifier,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
dto "github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
@@ -17,12 +15,13 @@ import (
|
||||
)
|
||||
|
||||
// AssetHandler 资产管理处理器
|
||||
// 提供统一的资产解析、实时状态、套餐查询、停复机、轮询管控等接口
|
||||
// 提供统一的资产解析、实时状态、套餐查询、停复机、停用、轮询管控等接口
|
||||
type AssetHandler struct {
|
||||
assetService *assetService.Service
|
||||
deviceService *deviceService.Service
|
||||
iotCardStopResume *iotCardService.StopResumeService
|
||||
assetPolling *pollingSvc.AssetPollingService
|
||||
assetService *assetService.Service
|
||||
deviceService *deviceService.Service
|
||||
iotCardStopResume *iotCardService.StopResumeService
|
||||
assetPolling *pollingSvc.AssetPollingService
|
||||
assetLifecycleService AssetLifecycleService
|
||||
}
|
||||
|
||||
// NewAssetHandler 创建资产管理处理器
|
||||
@@ -40,6 +39,20 @@ func NewAssetHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// SetLifecycleService 注入生命周期服务(用于 Deactivate 接口)
|
||||
func (h *AssetHandler) SetLifecycleService(svc AssetLifecycleService) {
|
||||
h.assetLifecycleService = svc
|
||||
}
|
||||
|
||||
// resolveByIdentifier 通过标识符解析资产,用于各处理器的公共逻辑
|
||||
func (h *AssetHandler) resolveByIdentifier(c *fiber.Ctx) (*dto.AssetResolveResponse, error) {
|
||||
identifier := c.Params("identifier")
|
||||
if identifier == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "标识符不能为空")
|
||||
}
|
||||
return h.assetService.Resolve(c.UserContext(), identifier)
|
||||
}
|
||||
|
||||
// Resolve 通过任意标识符解析资产(设备或卡)
|
||||
// GET /api/admin/assets/resolve/:identifier
|
||||
func (h *AssetHandler) Resolve(c *fiber.Ctx) error {
|
||||
@@ -57,15 +70,14 @@ func (h *AssetHandler) Resolve(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// RealtimeStatus 获取资产实时状态
|
||||
// GET /api/admin/assets/:asset_type/:id/realtime-status
|
||||
// GET /api/admin/assets/:identifier/realtime-status
|
||||
func (h *AssetHandler) RealtimeStatus(c *fiber.Ctx) error {
|
||||
assetType := c.Params("asset_type")
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
asset, err := h.resolveByIdentifier(c)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := h.assetService.GetRealtimeStatus(c.UserContext(), assetType, uint(id))
|
||||
result, err := h.assetService.GetRealtimeStatus(c.UserContext(), asset.AssetType, asset.AssetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -74,20 +86,19 @@ func (h *AssetHandler) RealtimeStatus(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// Refresh 刷新资产状态(调网关同步)
|
||||
// POST /api/admin/assets/:asset_type/:id/refresh
|
||||
// POST /api/admin/assets/:identifier/refresh
|
||||
func (h *AssetHandler) Refresh(c *fiber.Ctx) error {
|
||||
// 企业账号只读,不允许主动触发运营商刷新
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType == constants.UserTypeEnterprise {
|
||||
return errors.New(errors.CodeForbidden, "企业账号无权主动刷新资产状态")
|
||||
}
|
||||
assetType := c.Params("asset_type")
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
|
||||
asset, err := h.resolveByIdentifier(c)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := h.assetService.Refresh(c.UserContext(), assetType, uint(id))
|
||||
result, err := h.assetService.Refresh(c.UserContext(), asset.AssetType, asset.AssetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -96,18 +107,17 @@ func (h *AssetHandler) Refresh(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// Packages 获取资产所有套餐列表(支持分页,默认 page=1 pageSize=50)
|
||||
// GET /api/admin/assets/:asset_type/:id/packages
|
||||
// GET /api/admin/assets/:identifier/packages
|
||||
func (h *AssetHandler) Packages(c *fiber.Ctx) error {
|
||||
assetType := c.Params("asset_type")
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
asset, err := h.resolveByIdentifier(c)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
|
||||
return err
|
||||
}
|
||||
|
||||
page := c.QueryInt("page", 1)
|
||||
pageSize := c.QueryInt("page_size", 50)
|
||||
|
||||
result, err := h.assetService.GetPackages(c.UserContext(), assetType, uint(id), page, pageSize)
|
||||
result, err := h.assetService.GetPackages(c.UserContext(), asset.AssetType, asset.AssetID, page, pageSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -116,15 +126,14 @@ func (h *AssetHandler) Packages(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// CurrentPackage 获取资产当前生效套餐
|
||||
// GET /api/admin/assets/:asset_type/:id/current-package
|
||||
// GET /api/admin/assets/:identifier/current-package
|
||||
func (h *AssetHandler) CurrentPackage(c *fiber.Ctx) error {
|
||||
assetType := c.Params("asset_type")
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
asset, err := h.resolveByIdentifier(c)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := h.assetService.GetCurrentPackage(c.UserContext(), assetType, uint(id))
|
||||
result, err := h.assetService.GetCurrentPackage(c.UserContext(), asset.AssetType, asset.AssetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -132,15 +141,102 @@ func (h *AssetHandler) CurrentPackage(c *fiber.Ctx) error {
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// StopDevice 设备停机(批量停机设备下所有已实名卡)
|
||||
// POST /api/admin/assets/device/:device_id/stop
|
||||
func (h *AssetHandler) StopDevice(c *fiber.Ctx) error {
|
||||
deviceID, err := strconv.ParseUint(c.Params("device_id"), 10, 64)
|
||||
// Stop 停机(设备批量停机,单卡停机)
|
||||
// POST /api/admin/assets/:identifier/stop
|
||||
func (h *AssetHandler) Stop(c *fiber.Ctx) error {
|
||||
asset, err := h.resolveByIdentifier(c)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := h.deviceService.StopDevice(c.UserContext(), uint(deviceID))
|
||||
switch asset.AssetType {
|
||||
case "device":
|
||||
result, devErr := h.deviceService.StopDevice(c.UserContext(), asset.AssetID)
|
||||
if devErr != nil {
|
||||
return devErr
|
||||
}
|
||||
return response.Success(c, result)
|
||||
case "card":
|
||||
if asset.ICCID == "" {
|
||||
return errors.New(errors.CodeInternalError, "卡 ICCID 为空,无法停机")
|
||||
}
|
||||
if cardErr := h.iotCardStopResume.ManualStopCard(c.UserContext(), asset.ICCID); cardErr != nil {
|
||||
return cardErr
|
||||
}
|
||||
return response.Success(c, nil)
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "不支持的资产类型")
|
||||
}
|
||||
}
|
||||
|
||||
// Start 复机(设备批量复机,单卡复机)
|
||||
// POST /api/admin/assets/:identifier/start
|
||||
func (h *AssetHandler) Start(c *fiber.Ctx) error {
|
||||
asset, err := h.resolveByIdentifier(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch asset.AssetType {
|
||||
case "device":
|
||||
if devErr := h.deviceService.StartDevice(c.UserContext(), asset.AssetID); devErr != nil {
|
||||
return devErr
|
||||
}
|
||||
return response.Success(c, nil)
|
||||
case "card":
|
||||
if asset.ICCID == "" {
|
||||
return errors.New(errors.CodeInternalError, "卡 ICCID 为空,无法复机")
|
||||
}
|
||||
if cardErr := h.iotCardStopResume.ManualStartCard(c.UserContext(), asset.ICCID); cardErr != nil {
|
||||
return cardErr
|
||||
}
|
||||
return response.Success(c, nil)
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "不支持的资产类型")
|
||||
}
|
||||
}
|
||||
|
||||
// Deactivate 停用资产
|
||||
// PATCH /api/admin/assets/:identifier/deactivate
|
||||
func (h *AssetHandler) Deactivate(c *fiber.Ctx) error {
|
||||
if h.assetLifecycleService == nil {
|
||||
return errors.New(errors.CodeInternalError, "资产生命周期服务未配置")
|
||||
}
|
||||
|
||||
asset, err := h.resolveByIdentifier(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch asset.AssetType {
|
||||
case "device":
|
||||
if devErr := h.assetLifecycleService.DeactivateDevice(c.UserContext(), asset.AssetID); devErr != nil {
|
||||
return devErr
|
||||
}
|
||||
case "card":
|
||||
if cardErr := h.assetLifecycleService.DeactivateIotCard(c.UserContext(), asset.AssetID); cardErr != nil {
|
||||
return cardErr
|
||||
}
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "不支持的资产类型")
|
||||
}
|
||||
|
||||
return response.Success(c, nil)
|
||||
}
|
||||
|
||||
// Orders 查询资产历史订单(支持跨代追溯)
|
||||
// GET /api/admin/assets/:identifier/orders
|
||||
func (h *AssetHandler) Orders(c *fiber.Ctx) error {
|
||||
identifier := c.Params("identifier")
|
||||
if identifier == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "标识符不能为空")
|
||||
}
|
||||
|
||||
page := c.QueryInt("page", 1)
|
||||
pageSize := c.QueryInt("page_size", 20)
|
||||
includePrevious := c.QueryBool("include_previous", false)
|
||||
|
||||
result, err := h.assetService.GetOrders(c.UserContext(), identifier, page, pageSize, includePrevious)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -148,63 +244,11 @@ func (h *AssetHandler) StopDevice(c *fiber.Ctx) error {
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// StartDevice 设备复机(批量复机设备下所有已实名卡)
|
||||
// POST /api/admin/assets/device/:device_id/start
|
||||
func (h *AssetHandler) StartDevice(c *fiber.Ctx) error {
|
||||
deviceID, err := strconv.ParseUint(c.Params("device_id"), 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
|
||||
}
|
||||
|
||||
if err := h.deviceService.StartDevice(c.UserContext(), uint(deviceID)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, nil)
|
||||
}
|
||||
|
||||
// StopCard 单卡停机(通过ICCID)
|
||||
// POST /api/admin/assets/card/:iccid/stop
|
||||
func (h *AssetHandler) StopCard(c *fiber.Ctx) error {
|
||||
iccid := c.Params("iccid")
|
||||
if iccid == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
|
||||
}
|
||||
|
||||
if err := h.iotCardStopResume.ManualStopCard(c.UserContext(), iccid); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, nil)
|
||||
}
|
||||
|
||||
// StartCard 单卡复机(通过ICCID)
|
||||
// POST /api/admin/assets/card/:iccid/start
|
||||
func (h *AssetHandler) StartCard(c *fiber.Ctx) error {
|
||||
iccid := c.Params("iccid")
|
||||
if iccid == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
|
||||
}
|
||||
|
||||
if err := h.iotCardStopResume.ManualStartCard(c.UserContext(), iccid); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, nil)
|
||||
}
|
||||
|
||||
// UpdatePollingStatus 更新资产轮询状态
|
||||
// PATCH /api/admin/assets/:asset_type/:id/polling-status
|
||||
// PATCH /api/admin/assets/:identifier/polling-status
|
||||
func (h *AssetHandler) UpdatePollingStatus(c *fiber.Ctx) error {
|
||||
assetType := c.Params("asset_type")
|
||||
idStr := c.Params("id")
|
||||
|
||||
if assetType == "" || idStr == "" {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
identifier := c.Params("identifier")
|
||||
if identifier == "" {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
@@ -213,19 +257,29 @@ func (h *AssetHandler) UpdatePollingStatus(c *fiber.Ctx) error {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
asset, err := h.assetService.Resolve(c.UserContext(), identifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = middleware.GetUserIDFromContext(c.UserContext())
|
||||
|
||||
if h.assetPolling == nil {
|
||||
return errors.New(errors.CodeInternalError, "轮询管控服务未配置")
|
||||
}
|
||||
|
||||
if err := h.assetPolling.UpdatePollingStatus(c.UserContext(), assetType, uint(id), req.EnablePolling); err != nil {
|
||||
pollingAssetType := asset.AssetType
|
||||
if pollingAssetType == "card" {
|
||||
pollingAssetType = "iot_card"
|
||||
}
|
||||
|
||||
if err := h.assetPolling.UpdatePollingStatus(c.UserContext(), pollingAssetType, asset.AssetID, req.EnablePolling); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, &dto.UpdateAssetPollingStatusResponse{
|
||||
AssetType: assetType,
|
||||
AssetID: uint(id),
|
||||
AssetType: asset.AssetType,
|
||||
AssetID: asset.AssetID,
|
||||
EnablePolling: req.EnablePolling,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||
assetWalletSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_wallet"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
@@ -16,7 +15,8 @@ import (
|
||||
// AssetWalletHandler 资产钱包处理器
|
||||
// 提供管理端资产(卡/设备)钱包概况和流水查询接口
|
||||
type AssetWalletHandler struct {
|
||||
service *assetWalletSvc.Service
|
||||
service *assetWalletSvc.Service
|
||||
assetService *assetSvc.Service
|
||||
}
|
||||
|
||||
// NewAssetWalletHandler 创建资产钱包处理器
|
||||
@@ -24,26 +24,34 @@ func NewAssetWalletHandler(svc *assetWalletSvc.Service) *AssetWalletHandler {
|
||||
return &AssetWalletHandler{service: svc}
|
||||
}
|
||||
|
||||
// SetAssetService 注入资产解析服务(用于通过标识符解析资产类型和ID)
|
||||
func (h *AssetWalletHandler) SetAssetService(svc *assetSvc.Service) {
|
||||
h.assetService = svc
|
||||
}
|
||||
|
||||
// GetWallet 查询资产钱包概况
|
||||
// GET /api/admin/assets/:asset_type/:id/wallet
|
||||
// GET /api/admin/assets/:identifier/wallet
|
||||
func (h *AssetWalletHandler) GetWallet(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType == constants.UserTypeEnterprise {
|
||||
return errors.New(errors.CodeForbidden, "企业账号无权查看钱包信息")
|
||||
}
|
||||
|
||||
assetType := c.Params("asset_type")
|
||||
if assetType != "card" && assetType != "device" {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
|
||||
identifier := c.Params("identifier")
|
||||
if identifier == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "标识符不能为空")
|
||||
}
|
||||
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
|
||||
if h.assetService == nil {
|
||||
return errors.New(errors.CodeInternalError, "资产服务未配置")
|
||||
}
|
||||
|
||||
result, err := h.service.GetWallet(c.UserContext(), assetType, uint(id))
|
||||
asset, err := h.assetService.Resolve(c.UserContext(), identifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := h.service.GetWallet(c.UserContext(), asset.AssetType, asset.AssetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -52,22 +60,25 @@ func (h *AssetWalletHandler) GetWallet(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// ListTransactions 查询资产钱包流水列表
|
||||
// GET /api/admin/assets/:asset_type/:id/wallet/transactions
|
||||
// GET /api/admin/assets/:identifier/wallet/transactions
|
||||
func (h *AssetWalletHandler) ListTransactions(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType == constants.UserTypeEnterprise {
|
||||
return errors.New(errors.CodeForbidden, "企业账号无权查看钱包信息")
|
||||
}
|
||||
|
||||
assetType := c.Params("asset_type")
|
||||
if assetType != "card" && assetType != "device" {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
|
||||
identifier := c.Params("identifier")
|
||||
if identifier == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "标识符不能为空")
|
||||
}
|
||||
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
|
||||
if h.assetService == nil {
|
||||
return errors.New(errors.CodeInternalError, "资产服务未配置")
|
||||
}
|
||||
|
||||
asset, err := h.assetService.Resolve(c.UserContext(), identifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var req dto.AssetWalletTransactionListRequest
|
||||
@@ -79,7 +90,7 @@ func (h *AssetWalletHandler) ListTransactions(c *fiber.Ctx) error {
|
||||
return errors.New(errors.CodeInvalidParam, "每页数量不能超过100")
|
||||
}
|
||||
|
||||
result, err := h.service.ListTransactions(c.UserContext(), assetType, uint(id), &req)
|
||||
result, err := h.service.ListTransactions(c.UserContext(), asset.AssetType, asset.AssetID, &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
@@ -43,13 +41,17 @@ func (h *DeviceHandler) Delete(c *fiber.Ctx) error {
|
||||
return errors.New(errors.CodeForbidden, "仅平台用户可删除设备")
|
||||
}
|
||||
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
|
||||
virtualNo := c.Params("virtual_no")
|
||||
if virtualNo == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "虚拟号不能为空")
|
||||
}
|
||||
|
||||
if err := h.service.Delete(c.UserContext(), uint(id)); err != nil {
|
||||
device, err := h.service.GetByVirtualNo(c.UserContext(), virtualNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.service.Delete(c.UserContext(), device.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -57,13 +59,17 @@ func (h *DeviceHandler) Delete(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) ListCards(c *fiber.Ctx) error {
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
|
||||
virtualNo := c.Params("virtual_no")
|
||||
if virtualNo == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "虚拟号不能为空")
|
||||
}
|
||||
|
||||
result, err := h.service.ListBindings(c.UserContext(), uint(id))
|
||||
device, err := h.service.GetByVirtualNo(c.UserContext(), virtualNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := h.service.ListBindings(c.UserContext(), device.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -77,19 +83,23 @@ func (h *DeviceHandler) BindCard(c *fiber.Ctx) error {
|
||||
return errors.New(errors.CodeForbidden, "仅平台用户可绑定卡到设备")
|
||||
}
|
||||
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
virtualNo := c.Params("virtual_no")
|
||||
if virtualNo == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "虚拟号不能为空")
|
||||
}
|
||||
|
||||
device, err := h.service.GetByVirtualNo(c.UserContext(), virtualNo)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
|
||||
return err
|
||||
}
|
||||
|
||||
var req dto.BindCardToDeviceRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
req.ID = uint(id)
|
||||
req.ID = device.ID
|
||||
|
||||
result, err := h.service.BindCard(c.UserContext(), uint(id), &req)
|
||||
result, err := h.service.BindCard(c.UserContext(), device.ID, &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -103,19 +113,27 @@ func (h *DeviceHandler) UnbindCard(c *fiber.Ctx) error {
|
||||
return errors.New(errors.CodeForbidden, "仅平台用户可解绑设备的卡")
|
||||
}
|
||||
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
|
||||
virtualNo := c.Params("virtual_no")
|
||||
if virtualNo == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "虚拟号不能为空")
|
||||
}
|
||||
|
||||
cardIdStr := c.Params("cardId")
|
||||
cardId, err := strconv.ParseUint(cardIdStr, 10, 64)
|
||||
device, err := h.service.GetByVirtualNo(c.UserContext(), virtualNo)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的卡ID")
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := h.service.UnbindCard(c.UserContext(), uint(id), uint(cardId))
|
||||
iccid := c.Params("iccid")
|
||||
if iccid == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "ICCID 不能为空")
|
||||
}
|
||||
|
||||
card, cardErr := h.service.GetCardByICCID(c.UserContext(), iccid)
|
||||
if cardErr != nil {
|
||||
return cardErr
|
||||
}
|
||||
|
||||
result, err := h.service.UnbindCard(c.UserContext(), device.ID, card.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
24
internal/model/asset_identifier.go
Normal file
24
internal/model/asset_identifier.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// AssetIdentifier 全局资产标识符注册表模型
|
||||
// 在数据库层保证 IoT 卡的 ICCID/VirtualNo 与设备的 VirtualNo 跨两张表全局唯一
|
||||
// 不使用 gorm.Model(无需软删除),仅包含核心字段
|
||||
type AssetIdentifier struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
Identifier string `gorm:"column:identifier;type:varchar(100);uniqueIndex:uq_asset_identifier;not null;comment:标识符值(ICCID 或 VirtualNo,全局唯一)" json:"identifier"`
|
||||
AssetType string `gorm:"column:asset_type;type:varchar(20);not null;comment:资产类型:iot_card 或 device" json:"asset_type"`
|
||||
AssetID uint `gorm:"column:asset_id;index:idx_asset_identifier_asset;not null;comment:对应资产的主键 ID" json:"asset_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;not null;default:now();comment:写入时间" json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (AssetIdentifier) TableName() string {
|
||||
return "tb_asset_identifier"
|
||||
}
|
||||
|
||||
const (
|
||||
AssetTypeIotCard = "iot_card"
|
||||
AssetTypeDevice = "device"
|
||||
)
|
||||
@@ -6,6 +6,7 @@ import "time"
|
||||
type AssetResolveResponse struct {
|
||||
AssetType string `json:"asset_type" description:"资产类型:card 或 device"`
|
||||
AssetID uint `json:"asset_id" description:"资产数据库ID"`
|
||||
Identifier string `json:"identifier,omitempty" description:"解析时使用的标识符"`
|
||||
VirtualNo string `json:"virtual_no" description:"虚拟号"`
|
||||
Status int `json:"status" description:"资产状态"`
|
||||
BatchNo string `json:"batch_no" description:"批次号"`
|
||||
@@ -118,7 +119,12 @@ type AssetResolveRequest struct {
|
||||
Identifier string `path:"identifier" description:"资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN)" required:"true"`
|
||||
}
|
||||
|
||||
// AssetTypeIDRequest 资产类型+ID请求(路径参数)
|
||||
// AssetIdentifierRequest 资产标识符路径参数请求
|
||||
type AssetIdentifierRequest struct {
|
||||
Identifier string `path:"identifier" description:"资产标识符(ICCID 或 VirtualNo)" required:"true"`
|
||||
}
|
||||
|
||||
// AssetTypeIDRequest 资产类型+ID请求(路径参数,保留用于钱包等向后兼容场景)
|
||||
type AssetTypeIDRequest struct {
|
||||
AssetType string `path:"asset_type" description:"资产类型:card 或 device" required:"true"`
|
||||
ID uint `path:"id" description:"资产ID" required:"true"`
|
||||
@@ -197,12 +203,69 @@ type DeviceGatewayInfo struct {
|
||||
IMSI *string `json:"imsi,omitempty" description:"IMSI用户标识码"`
|
||||
}
|
||||
|
||||
// AssetOrdersRequest 资产历史订单查询请求
|
||||
type AssetOrdersRequest struct {
|
||||
Identifier string `path:"identifier" description:"资产标识符(ICCID 或 VirtualNo)" required:"true"`
|
||||
Page int `query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码(默认1)"`
|
||||
PageSize int `query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数(默认20,最大100)"`
|
||||
IncludePrevious bool `query:"include_previous" description:"是否包含前代订单,默认false"`
|
||||
}
|
||||
|
||||
// AssetOrderItemDetail 订单中的套餐明细
|
||||
type AssetOrderItemDetail struct {
|
||||
PackageName string `json:"package_name" description:"套餐名称快照"`
|
||||
Quantity int `json:"quantity" description:"数量"`
|
||||
UnitPrice int64 `json:"unit_price" description:"单价(分)"`
|
||||
Amount int64 `json:"amount" description:"小计金额(分)"`
|
||||
}
|
||||
|
||||
// AssetOrderItem 资产历史订单条目
|
||||
type AssetOrderItem struct {
|
||||
OrderNo string `json:"order_no" description:"订单号"`
|
||||
OrderType string `json:"order_type" description:"订单类型:single_card/device"`
|
||||
PaymentStatus int `json:"payment_status" description:"支付状态:1待支付 2已支付 3已取消 4已退款"`
|
||||
PaymentStatusText string `json:"payment_status_text" description:"支付状态文本"`
|
||||
TotalAmount int64 `json:"total_amount" description:"订单金额(分)"`
|
||||
PaymentMethod string `json:"payment_method" description:"支付方式:wallet/wechat/alipay/offline"`
|
||||
PaidAt *time.Time `json:"paid_at,omitempty" description:"支付时间"`
|
||||
Generation int `json:"generation" description:"订单所属资产世代编号"`
|
||||
Items []*AssetOrderItemDetail `json:"items" description:"套餐明细列表"`
|
||||
CreatedAt time.Time `json:"created_at" description:"订单创建时间"`
|
||||
}
|
||||
|
||||
// GenerationOrders 本代订单块(支持分页)
|
||||
type GenerationOrders struct {
|
||||
Generation int `json:"generation" description:"世代编号"`
|
||||
Identifier string `json:"identifier" description:"资产标识符"`
|
||||
AssetType string `json:"asset_type" description:"资产类型:card 或 device"`
|
||||
Total int64 `json:"total" description:"总订单数"`
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
PageSize int `json:"page_size" description:"每页条数"`
|
||||
Items []*AssetOrderItem `json:"items" description:"订单列表"`
|
||||
}
|
||||
|
||||
// PreviousGenerationOrders 前代订单块(最多20条,不分页)
|
||||
type PreviousGenerationOrders struct {
|
||||
Generation int `json:"generation" description:"前代世代编号"`
|
||||
Identifier string `json:"identifier" description:"前代资产标识符"`
|
||||
AssetType string `json:"asset_type" description:"前代资产类型:card 或 device"`
|
||||
ExchangeNo string `json:"exchange_no" description:"换货单号"`
|
||||
ExchangedAt time.Time `json:"exchanged_at" description:"换货完成时间"`
|
||||
Total int64 `json:"total" description:"前代总订单数"`
|
||||
Items []*AssetOrderItem `json:"items" description:"前代订单列表(最多20条)"`
|
||||
}
|
||||
|
||||
// AssetOrdersResponse 资产历史订单完整响应
|
||||
type AssetOrdersResponse struct {
|
||||
CurrentGeneration *GenerationOrders `json:"current_generation" description:"本代订单(含分页)"`
|
||||
PreviousGenerations []*PreviousGenerationOrders `json:"previous_generations,omitempty" description:"前代订单列表(include_previous=true时返回)"`
|
||||
Truncated bool `json:"truncated" description:"是否已截断(换货链超过10代追溯上限时为true)"`
|
||||
}
|
||||
|
||||
// UpdateAssetPollingStatusRequest 更新资产轮询状态请求
|
||||
type UpdateAssetPollingStatusRequest struct {
|
||||
AssetType string `path:"asset_type" description:"资产类型 (iot_card:IoT卡, device:设备)" required:"true"`
|
||||
ID uint `path:"id" description:"资产ID" required:"true"`
|
||||
// EnablePolling 是否启用轮询
|
||||
EnablePolling bool `json:"enable_polling" description:"是否启用轮询 (true:启用, false:禁用)"`
|
||||
Identifier string `path:"identifier" description:"资产标识符(ICCID 或 VirtualNo)" required:"true"`
|
||||
EnablePolling bool `json:"enable_polling" description:"是否启用轮询 (true:启用, false:禁用)"`
|
||||
}
|
||||
|
||||
// UpdateAssetPollingStatusResponse 更新资产轮询状态响应
|
||||
|
||||
@@ -19,8 +19,7 @@ type AssetWalletResponse struct {
|
||||
|
||||
// AssetWalletTransactionListRequest 资产钱包流水列表请求(路径参数 + 查询参数)
|
||||
type AssetWalletTransactionListRequest struct {
|
||||
AssetType string `path:"asset_type" description:"资产类型:card 或 device" required:"true"`
|
||||
ID uint `path:"id" description:"资产ID" required:"true"`
|
||||
Identifier string `path:"identifier" description:"资产标识符(ICCID 或 VirtualNo)" required:"true"`
|
||||
Page int `json:"page" query:"page" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" description:"每页数量,默认20,最大100"`
|
||||
TransactionType *string `json:"transaction_type" query:"transaction_type" validate:"omitempty,oneof=recharge deduct refund" description:"交易类型过滤:recharge/deduct/refund"`
|
||||
|
||||
@@ -65,11 +65,11 @@ type GetDeviceByIdentifierRequest struct {
|
||||
}
|
||||
|
||||
type DeleteDeviceRequest struct {
|
||||
ID uint `path:"id" description:"设备ID" required:"true"`
|
||||
VirtualNo string `path:"virtual_no" description:"设备虚拟号" required:"true"`
|
||||
}
|
||||
|
||||
type ListDeviceCardsRequest struct {
|
||||
ID uint `path:"id" description:"设备ID" required:"true"`
|
||||
VirtualNo string `path:"virtual_no" description:"设备虚拟号" required:"true"`
|
||||
}
|
||||
|
||||
type DeviceCardBindingResponse struct {
|
||||
@@ -89,9 +89,10 @@ type ListDeviceCardsResponse struct {
|
||||
}
|
||||
|
||||
type BindCardToDeviceRequest struct {
|
||||
ID uint `path:"id" description:"设备ID" required:"true"`
|
||||
IotCardID uint `json:"iot_card_id" validate:"required,min=1" required:"true" minimum:"1" description:"IoT卡ID"`
|
||||
SlotPosition int `json:"slot_position" validate:"required,min=1,max=4" required:"true" minimum:"1" maximum:"4" description:"插槽位置 (1-4)"`
|
||||
VirtualNo string `path:"virtual_no" description:"设备虚拟号" required:"true"`
|
||||
ID uint `json:"-"`
|
||||
IotCardID uint `json:"iot_card_id" validate:"required,min=1" required:"true" minimum:"1" description:"IoT卡ID"`
|
||||
SlotPosition int `json:"slot_position" validate:"required,min=1,max=4" required:"true" minimum:"1" maximum:"4" description:"插槽位置 (1-4)"`
|
||||
}
|
||||
|
||||
type BindCardToDeviceResponse struct {
|
||||
@@ -100,8 +101,8 @@ type BindCardToDeviceResponse struct {
|
||||
}
|
||||
|
||||
type UnbindCardFromDeviceRequest struct {
|
||||
ID uint `path:"id" description:"设备ID" required:"true"`
|
||||
CardID uint `path:"cardId" description:"IoT卡ID" required:"true"`
|
||||
VirtualNo string `path:"virtual_no" description:"设备虚拟号" required:"true"`
|
||||
ICCID string `path:"iccid" description:"IoT卡ICCID" required:"true"`
|
||||
}
|
||||
|
||||
type UnbindCardFromDeviceResponse struct {
|
||||
|
||||
@@ -12,9 +12,7 @@ type CreateOrderRequest struct {
|
||||
|
||||
// CreateAdminOrderRequest 后台订单创建请求(仅允许 wallet/offline)
|
||||
type CreateAdminOrderRequest struct {
|
||||
OrderType string `json:"order_type" validate:"required,oneof=single_card device" required:"true" description:"订单类型 (single_card:单卡购买, device:设备购买)"`
|
||||
IotCardID *uint `json:"iot_card_id" validate:"required_if=OrderType single_card" description:"IoT卡ID(单卡购买时必填)"`
|
||||
DeviceID *uint `json:"device_id" validate:"required_if=OrderType device" description:"设备ID(设备购买时必填)"`
|
||||
Identifier string `json:"identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"资产标识符(ICCID 或 VirtualNo)"`
|
||||
PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"`
|
||||
PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet offline" required:"true" description:"支付方式 (wallet:钱包支付, offline:线下支付)"`
|
||||
}
|
||||
@@ -29,6 +27,7 @@ type OrderListRequest struct {
|
||||
StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"`
|
||||
EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"`
|
||||
IsExpired *bool `json:"is_expired" query:"is_expired" description:"是否已过期 (true:已过期, false:未过期)"`
|
||||
Identifier string `json:"identifier" query:"identifier" validate:"omitempty,max=100" maxLength:"100" description:"资产标识符(ICCID 或 VirtualNo)精确查询"`
|
||||
}
|
||||
|
||||
type PayOrderRequest struct {
|
||||
@@ -79,6 +78,10 @@ type OrderResponse struct {
|
||||
// 订单超时信息
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty" description:"订单过期时间"`
|
||||
IsExpired bool `json:"is_expired" description:"是否已过期"`
|
||||
|
||||
// 资产标识符快照
|
||||
AssetIdentifier string `json:"asset_identifier,omitempty" description:"下单时资产的标识符快照(ICCID 或 VirtualNo)"`
|
||||
AssetType string `json:"asset_type,omitempty" description:"资产类型 (card:单卡, device:设备)"`
|
||||
}
|
||||
|
||||
type OrderListResponse struct {
|
||||
|
||||
@@ -52,7 +52,7 @@ type IotCard struct {
|
||||
AssetStatus int `gorm:"column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用" json:"asset_status"`
|
||||
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
|
||||
IsStandalone bool `gorm:"column:is_standalone;type:boolean;default:true;not null;comment:是否为独立卡(未绑定设备) 由触发器自动维护" json:"is_standalone"`
|
||||
VirtualNo string `gorm:"column:virtual_no;type:varchar(50);uniqueIndex:idx_iot_card_virtual_no,where:deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> '';comment:虚拟号(可空,全局唯一)" json:"virtual_no,omitempty"`
|
||||
VirtualNo string `gorm:"column:virtual_no;type:varchar(50);not null;uniqueIndex:idx_iot_card_virtual_no,where:deleted_at IS NULL;comment:虚拟号(必填,全局唯一)" json:"virtual_no"`
|
||||
LastGatewayReadingMB float64 `gorm:"column:last_gateway_reading_mb;type:float;not null;default:0;comment:上次轮询时网关返回的流量读数(MB)" json:"last_gateway_reading_mb"`
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,9 @@ type Order struct {
|
||||
BuyerID uint `gorm:"column:buyer_id;index:idx_order_buyer;not null;comment:买家ID(个人客户ID或店铺ID)" json:"buyer_id"`
|
||||
|
||||
// 关联资源
|
||||
IotCardID *uint `gorm:"column:iot_card_id;index;comment:IoT卡ID(单卡购买时有值)" json:"iot_card_id,omitempty"`
|
||||
DeviceID *uint `gorm:"column:device_id;index;comment:设备ID(设备购买时有值)" json:"device_id,omitempty"`
|
||||
IotCardID *uint `gorm:"column:iot_card_id;index;comment:IoT卡ID(单卡购买时有值)" json:"iot_card_id,omitempty"`
|
||||
DeviceID *uint `gorm:"column:device_id;index;comment:设备ID(设备购买时有值)" json:"device_id,omitempty"`
|
||||
AssetIdentifier string `gorm:"column:asset_identifier;type:varchar(100);comment:下单时资产的标识符快照(ICCID 或 VirtualNo)" json:"asset_identifier,omitempty"`
|
||||
|
||||
// 金额信息
|
||||
TotalAmount int64 `gorm:"column:total_amount;type:bigint;not null;comment:订单总金额(分)" json:"total_amount"`
|
||||
|
||||
@@ -21,87 +21,78 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "GET", "/:asset_type/:id/realtime-status", handler.RealtimeStatus, RouteSpec{
|
||||
Register(assets, doc, groupPath, "GET", "/:identifier/realtime-status", handler.RealtimeStatus, RouteSpec{
|
||||
Summary: "资产实时状态",
|
||||
Description: "读取 DB/Redis 中的持久化状态,不调网关。asset_type 为 card 或 device。",
|
||||
Description: "读取 DB/Redis 中的持久化状态,不调网关。",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.AssetTypeIDRequest),
|
||||
Input: new(dto.AssetIdentifierRequest),
|
||||
Output: new(dto.AssetRealtimeStatusResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "POST", "/:asset_type/:id/refresh", handler.Refresh, RouteSpec{
|
||||
Register(assets, doc, groupPath, "POST", "/:identifier/refresh", handler.Refresh, RouteSpec{
|
||||
Summary: "刷新资产状态",
|
||||
Description: "主动调网关同步最新状态。设备有30秒冷却期。",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.AssetTypeIDRequest),
|
||||
Input: new(dto.AssetIdentifierRequest),
|
||||
Output: new(dto.AssetRealtimeStatusResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "GET", "/:asset_type/:id/packages", handler.Packages, RouteSpec{
|
||||
Register(assets, doc, groupPath, "GET", "/:identifier/packages", handler.Packages, RouteSpec{
|
||||
Summary: "资产套餐列表",
|
||||
Description: "查询该资产所有套餐记录,含虚流量换算结果。支持分页(默认 page=1, page_size=50, 最大100)。",
|
||||
Description: "查询该资产所有套餐记录,含虚流量换算结果。支持分页。",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.AssetPackagesRequest),
|
||||
Input: new(dto.AssetIdentifierRequest),
|
||||
Output: new(dto.AssetPackagesResult),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "GET", "/:asset_type/:id/current-package", handler.CurrentPackage, RouteSpec{
|
||||
Register(assets, doc, groupPath, "GET", "/:identifier/current-package", handler.CurrentPackage, RouteSpec{
|
||||
Summary: "当前生效套餐",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.AssetTypeIDRequest),
|
||||
Input: new(dto.AssetIdentifierRequest),
|
||||
Output: new(dto.AssetPackageResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "POST", "/device/:device_id/stop", handler.StopDevice, RouteSpec{
|
||||
Summary: "设备停机",
|
||||
Description: "批量停机设备下所有已实名卡。设置1小时停机保护期。",
|
||||
Register(assets, doc, groupPath, "POST", "/:identifier/stop", handler.Stop, RouteSpec{
|
||||
Summary: "停机",
|
||||
Description: "设备批量停机设备下所有已实名卡;单卡手动停机。",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.DeviceIDRequest),
|
||||
Output: new(dto.DeviceSuspendResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "POST", "/device/:device_id/start", handler.StartDevice, RouteSpec{
|
||||
Summary: "设备复机",
|
||||
Description: "批量复机设备下所有已实名卡。设置1小时复机保护期。",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.DeviceIDRequest),
|
||||
Input: new(dto.AssetIdentifierRequest),
|
||||
Output: nil,
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "POST", "/card/:iccid/stop", handler.StopCard, RouteSpec{
|
||||
Summary: "单卡停机",
|
||||
Description: "手动停机单张卡(通过ICCID)。受设备保护期约束。",
|
||||
Register(assets, doc, groupPath, "POST", "/:identifier/start", handler.Start, RouteSpec{
|
||||
Summary: "复机",
|
||||
Description: "设备批量复机;单卡手动复机。",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.CardICCIDRequest),
|
||||
Input: new(dto.AssetIdentifierRequest),
|
||||
Output: nil,
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "POST", "/card/:iccid/start", handler.StartCard, RouteSpec{
|
||||
Summary: "单卡复机",
|
||||
Description: "手动复机单张卡(通过ICCID)。受设备保护期约束。",
|
||||
Register(assets, doc, groupPath, "PATCH", "/:identifier/deactivate", handler.Deactivate, RouteSpec{
|
||||
Summary: "停用资产",
|
||||
Description: "将资产状态设置为停用(不可逆)。仅支持库存中或已售出的资产。",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.CardICCIDRequest),
|
||||
Input: new(dto.AssetIdentifierRequest),
|
||||
Output: nil,
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "GET", "/:asset_type/:id/wallet", walletHandler.GetWallet, RouteSpec{
|
||||
Register(assets, doc, groupPath, "GET", "/:identifier/wallet", walletHandler.GetWallet, RouteSpec{
|
||||
Summary: "资产钱包概况",
|
||||
Description: "查询指定卡或设备的钱包余额概况。企业账号禁止调用。",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.AssetTypeIDRequest),
|
||||
Input: new(dto.AssetIdentifierRequest),
|
||||
Output: new(dto.AssetWalletResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "GET", "/:asset_type/:id/wallet/transactions", walletHandler.ListTransactions, RouteSpec{
|
||||
Register(assets, doc, groupPath, "GET", "/:identifier/wallet/transactions", walletHandler.ListTransactions, RouteSpec{
|
||||
Summary: "资产钱包流水列表",
|
||||
Description: "分页查询指定资产的钱包收支流水,含充值/扣款来源编号。企业账号禁止调用。",
|
||||
Tags: []string{"资产管理"},
|
||||
@@ -110,9 +101,18 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "PATCH", "/:asset_type/:id/polling-status", handler.UpdatePollingStatus, RouteSpec{
|
||||
Register(assets, doc, groupPath, "GET", "/:identifier/orders", handler.Orders, RouteSpec{
|
||||
Summary: "资产历史订单",
|
||||
Description: "查询资产本代历史订单,支持 include_previous=true 参数通过换货链追溯前代订单(最多10代)。",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.AssetOrdersRequest),
|
||||
Output: new(dto.AssetOrdersResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "PATCH", "/:identifier/polling-status", handler.UpdatePollingStatus, RouteSpec{
|
||||
Summary: "更新资产轮询状态",
|
||||
Description: "启用或禁用指定资产(IoT卡或设备)的定期状态轮询。asset_type 为 iot_card 或 device。",
|
||||
Description: "启用或禁用指定资产(IoT卡或设备)的定期状态轮询。",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.UpdateAssetPollingStatusRequest),
|
||||
Output: new(dto.UpdateAssetPollingStatusResponse),
|
||||
|
||||
@@ -4,25 +4,9 @@ import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||
)
|
||||
|
||||
// registerAssetLifecycleRoutes 注册资产手动停用路由
|
||||
func registerAssetLifecycleRoutes(router fiber.Router, handler *admin.AssetLifecycleHandler, doc *openapi.Generator, basePath string) {
|
||||
Register(router, doc, basePath, "PATCH", "/iot-cards/:id/deactivate", handler.DeactivateIotCard, RouteSpec{
|
||||
Summary: "手动停用IoT卡",
|
||||
Tags: []string{"IoT卡管理"},
|
||||
Input: new(dto.IDReq),
|
||||
Output: nil,
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(router, doc, basePath, "PATCH", "/devices/:id/deactivate", handler.DeactivateDevice, RouteSpec{
|
||||
Summary: "手动停用设备",
|
||||
Tags: []string{"设备管理"},
|
||||
Input: new(dto.IDReq),
|
||||
Output: nil,
|
||||
Auth: true,
|
||||
})
|
||||
// registerAssetLifecycleRoutes 注册资产生命周期路由
|
||||
func registerAssetLifecycleRoutes(_ fiber.Router, _ *admin.AssetLifecycleHandler, _ *openapi.Generator, _ string) {
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(devices, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{
|
||||
Register(devices, doc, groupPath, "DELETE", "/:virtual_no", handler.Delete, RouteSpec{
|
||||
Summary: "删除设备",
|
||||
Description: "仅平台用户可操作。删除设备时自动解绑所有卡(卡不会被删除)。",
|
||||
Tags: []string{"设备管理"},
|
||||
@@ -30,7 +30,7 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(devices, doc, groupPath, "GET", "/:id/cards", handler.ListCards, RouteSpec{
|
||||
Register(devices, doc, groupPath, "GET", "/:virtual_no/cards", handler.ListCards, RouteSpec{
|
||||
Summary: "获取设备绑定的卡列表",
|
||||
Tags: []string{"设备管理"},
|
||||
Input: new(dto.ListDeviceCardsRequest),
|
||||
@@ -38,7 +38,7 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(devices, doc, groupPath, "POST", "/:id/cards", handler.BindCard, RouteSpec{
|
||||
Register(devices, doc, groupPath, "POST", "/:virtual_no/cards", handler.BindCard, RouteSpec{
|
||||
Summary: "绑定卡到设备",
|
||||
Description: "仅平台用户可操作。用于导入后调整卡绑定关系(补卡、换卡)。",
|
||||
Tags: []string{"设备管理"},
|
||||
@@ -47,7 +47,7 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(devices, doc, groupPath, "DELETE", "/:id/cards/:cardId", handler.UnbindCard, RouteSpec{
|
||||
Register(devices, doc, groupPath, "DELETE", "/:virtual_no/cards/:iccid", handler.UnbindCard, RouteSpec{
|
||||
Summary: "解绑设备上的卡",
|
||||
Description: "仅平台用户可操作。解绑不改变卡的 shop_id。",
|
||||
Tags: []string{"设备管理"},
|
||||
@@ -88,7 +88,7 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp
|
||||
|
||||
- 文件格式:仅支持 .xlsx (Excel 2007+)
|
||||
- 必须包含列(首行为表头):
|
||||
- ` + "`virtual_no`" + `: 设备虚拟号(必填,全局唯一)
|
||||
- ` + "`virtual_no`" + `: 设备虚拟号(**必填**,全局唯一;**为空的行记为失败,不再静默跳过**)
|
||||
- ` + "`device_name`" + `: 设备名称
|
||||
- ` + "`device_model`" + `: 设备型号
|
||||
- ` + "`device_type`" + `: 设备类型
|
||||
|
||||
@@ -49,16 +49,18 @@ func registerIotCardRoutes(router fiber.Router, handler *admin.IotCardHandler, i
|
||||
### Excel 文件格式
|
||||
|
||||
- 文件格式:仅支持 .xlsx (Excel 2007+)
|
||||
- 必填列:` + "`ICCID`" + `, ` + "`MSISDN`" + `
|
||||
- 可选列:` + "`virtual_no`" + `(虚拟号,表头关键字:virtual_no / VirtualNo / 虚拟号 / 设备号)
|
||||
- 必填列(表头关键字):
|
||||
- ` + "`ICCID`" + `(或:卡号 / 号码)
|
||||
- ` + "`MSISDN`" + `(或:接入号 / 手机号 / 电话)
|
||||
- ` + "`virtual_no`" + `(或:VirtualNo / 虚拟号 / 设备号)—— **必填,缺少此列将导致整批导入失败**
|
||||
- 首行为表头(可选,但建议包含)
|
||||
- 列格式:设置为文本格式(避免长数字被转为科学记数法)
|
||||
|
||||
#### virtual_no 列导入规则(只补空白)
|
||||
#### virtual_no 导入规则
|
||||
|
||||
- 该行 virtual_no 非空 + 数据库当前值为 NULL:填入新值
|
||||
- 该行 virtual_no 非空 + 数据库已有值:跳过(不覆盖)
|
||||
- 批次内有任意 virtual_no 与数据库现存值重复:**整批拒绝**`,
|
||||
- ` + "`virtual_no`" + ` 为必填字段,**不可为空**;为空的行记为失败并报告原因
|
||||
- Excel 缺少 ` + "`virtual_no`" + ` 列时,整批导入直接失败(返回"Excel 文件缺少 virtual_no 列,请使用最新模板")
|
||||
- ` + "`virtual_no`" + ` 与系统已有值重复时,该行记为失败`,
|
||||
Tags: []string{"IoT卡管理"},
|
||||
Input: new(dto.ImportIotCardRequest),
|
||||
Output: new(dto.ImportIotCardResponse),
|
||||
|
||||
@@ -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, "该卡未关联套餐系列,无法购买套餐")
|
||||
}
|
||||
|
||||
51
internal/store/postgres/asset_identifier_store.go
Normal file
51
internal/store/postgres/asset_identifier_store.go
Normal 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
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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 参数限制每次批量处理的数量,避免一次性加载太多数据
|
||||
|
||||
@@ -35,6 +35,7 @@ type DeviceImportHandler struct {
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
assetWalletStore *postgres.AssetWalletStore
|
||||
assetIdentifierStore *postgres.AssetIdentifierStore
|
||||
storageService *storage.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
@@ -47,6 +48,7 @@ func NewDeviceImportHandler(
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
assetWalletStore *postgres.AssetWalletStore,
|
||||
assetIdentifierStore *postgres.AssetIdentifierStore,
|
||||
storageSvc *storage.Service,
|
||||
logger *zap.Logger,
|
||||
) *DeviceImportHandler {
|
||||
@@ -58,6 +60,7 @@ func NewDeviceImportHandler(
|
||||
deviceSimBindingStore: deviceSimBindingStore,
|
||||
iotCardStore: iotCardStore,
|
||||
assetWalletStore: assetWalletStore,
|
||||
assetIdentifierStore: assetIdentifierStore,
|
||||
storageService: storageSvc,
|
||||
logger: logger,
|
||||
}
|
||||
@@ -98,7 +101,7 @@ func (h *DeviceImportHandler) HandleDeviceImport(ctx context.Context, task *asyn
|
||||
zap.String("storage_key", importTask.StorageKey),
|
||||
)
|
||||
|
||||
rows, totalCount, err := h.downloadAndParse(ctx, importTask)
|
||||
parseResult, err := h.downloadAndParse(ctx, importTask)
|
||||
if err != nil {
|
||||
h.logger.Error("下载或解析 Excel 失败",
|
||||
zap.Uint("task_id", importTask.ID),
|
||||
@@ -108,9 +111,18 @@ func (h *DeviceImportHandler) HandleDeviceImport(ctx context.Context, task *asyn
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
|
||||
result := h.processImport(ctx, importTask, rows, totalCount)
|
||||
result := h.processImport(ctx, importTask, parseResult.Rows)
|
||||
|
||||
h.importTaskStore.UpdateResult(ctx, importTask.ID, totalCount, result.successCount, result.skipCount, result.failCount, 0, result.skippedItems, result.failedItems, nil)
|
||||
for _, pe := range parseResult.ParseErrors {
|
||||
result.failedItems = append(result.failedItems, model.ImportResultItem{
|
||||
Line: pe.Line,
|
||||
ICCID: pe.ICCID,
|
||||
Reason: pe.Reason,
|
||||
})
|
||||
result.failCount++
|
||||
}
|
||||
|
||||
h.importTaskStore.UpdateResult(ctx, importTask.ID, parseResult.TotalCount, result.successCount, result.skipCount, result.failCount, 0, result.skippedItems, result.failedItems, nil)
|
||||
|
||||
if result.failCount > 0 && result.successCount == 0 {
|
||||
h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusFailed, "所有导入均失败")
|
||||
@@ -128,24 +140,24 @@ func (h *DeviceImportHandler) HandleDeviceImport(ctx context.Context, task *asyn
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *DeviceImportHandler) downloadAndParse(ctx context.Context, task *model.DeviceImportTask) ([]utils.DeviceRow, int, error) {
|
||||
func (h *DeviceImportHandler) downloadAndParse(ctx context.Context, task *model.DeviceImportTask) (*utils.DeviceParseResult, error) {
|
||||
if h.storageService == nil {
|
||||
return nil, 0, ErrStorageNotConfigured
|
||||
return nil, ErrStorageNotConfigured
|
||||
}
|
||||
|
||||
if task.StorageKey == "" {
|
||||
return nil, 0, ErrStorageKeyEmpty
|
||||
return nil, ErrStorageKeyEmpty
|
||||
}
|
||||
|
||||
localPath, cleanup, err := h.storageService.DownloadToTemp(ctx, task.StorageKey)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
return nil, err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if !strings.HasSuffix(strings.ToLower(task.FileName), ".xlsx") {
|
||||
ext := filepath.Ext(task.FileName)
|
||||
return nil, 0, fmt.Errorf("不支持的文件格式 %s,请上传Excel文件(.xlsx)", ext)
|
||||
return nil, fmt.Errorf("不支持的文件格式 %s,请上传Excel文件(.xlsx)", ext)
|
||||
}
|
||||
|
||||
return utils.ParseDeviceExcel(localPath)
|
||||
@@ -159,7 +171,7 @@ type deviceImportResult struct {
|
||||
failedItems model.ImportResultItems
|
||||
}
|
||||
|
||||
func (h *DeviceImportHandler) processImport(ctx context.Context, task *model.DeviceImportTask, rows []utils.DeviceRow, totalCount int) *deviceImportResult {
|
||||
func (h *DeviceImportHandler) processImport(ctx context.Context, task *model.DeviceImportTask, rows []utils.DeviceRow) *deviceImportResult {
|
||||
result := &deviceImportResult{
|
||||
skippedItems: make(model.ImportResultItems, 0),
|
||||
failedItems: make(model.ImportResultItems, 0),
|
||||
@@ -283,6 +295,11 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
|
||||
return err
|
||||
}
|
||||
|
||||
txIdentifierStore := postgres.NewAssetIdentifierStore(tx)
|
||||
if err := txIdentifierStore.Register(ctx, device.VirtualNo, model.AssetTypeDevice, device.ID); err != nil {
|
||||
return fmt.Errorf("虚拟号已被占用: %s", device.VirtualNo)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for i, cardID := range validCardIDs {
|
||||
binding := &model.DeviceSimBinding{
|
||||
@@ -297,7 +314,6 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
|
||||
}
|
||||
}
|
||||
|
||||
// 创建设备资产钱包(设备存在即应有钱包,避免购买时才发现缺失)
|
||||
txWalletStore := postgres.NewAssetWalletStore(tx, nil)
|
||||
deviceWallet := &model.AssetWallet{
|
||||
ResourceType: constants.AssetWalletResourceTypeDevice,
|
||||
|
||||
@@ -41,14 +41,15 @@ type PollingCallback interface {
|
||||
}
|
||||
|
||||
type IotCardImportHandler struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
importTaskStore *postgres.IotCardImportTaskStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
assetWalletStore *postgres.AssetWalletStore
|
||||
storageService *storage.Service
|
||||
pollingCallback PollingCallback
|
||||
logger *zap.Logger
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
importTaskStore *postgres.IotCardImportTaskStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
assetWalletStore *postgres.AssetWalletStore
|
||||
assetIdentifierStore *postgres.AssetIdentifierStore
|
||||
storageService *storage.Service
|
||||
pollingCallback PollingCallback
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewIotCardImportHandler(
|
||||
@@ -57,19 +58,21 @@ func NewIotCardImportHandler(
|
||||
importTaskStore *postgres.IotCardImportTaskStore,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
assetWalletStore *postgres.AssetWalletStore,
|
||||
assetIdentifierStore *postgres.AssetIdentifierStore,
|
||||
storageSvc *storage.Service,
|
||||
pollingCallback PollingCallback,
|
||||
logger *zap.Logger,
|
||||
) *IotCardImportHandler {
|
||||
return &IotCardImportHandler{
|
||||
db: db,
|
||||
redis: redis,
|
||||
importTaskStore: importTaskStore,
|
||||
iotCardStore: iotCardStore,
|
||||
assetWalletStore: assetWalletStore,
|
||||
storageService: storageSvc,
|
||||
pollingCallback: pollingCallback,
|
||||
logger: logger,
|
||||
db: db,
|
||||
redis: redis,
|
||||
importTaskStore: importTaskStore,
|
||||
iotCardStore: iotCardStore,
|
||||
assetWalletStore: assetWalletStore,
|
||||
assetIdentifierStore: assetIdentifierStore,
|
||||
storageService: storageSvc,
|
||||
pollingCallback: pollingCallback,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +111,7 @@ func (h *IotCardImportHandler) HandleIotCardImport(ctx context.Context, task *as
|
||||
zap.String("storage_key", importTask.StorageKey),
|
||||
)
|
||||
|
||||
cards, totalCount, err := h.downloadAndParse(ctx, importTask)
|
||||
cards, totalCount, parseFailures, err := h.downloadAndParse(ctx, importTask)
|
||||
if err != nil {
|
||||
h.logger.Error("下载或解析 Excel 失败",
|
||||
zap.Uint("task_id", importTask.ID),
|
||||
@@ -124,6 +127,9 @@ func (h *IotCardImportHandler) HandleIotCardImport(ctx context.Context, task *as
|
||||
|
||||
result := h.processImport(ctx, importTask)
|
||||
|
||||
result.failedItems = append(parseFailures, result.failedItems...)
|
||||
result.failCount += len(parseFailures)
|
||||
|
||||
h.importTaskStore.UpdateResult(ctx, importTask.ID, result.successCount, result.skipCount, result.failCount, result.skippedItems, result.failedItems)
|
||||
|
||||
if result.failCount > 0 && result.successCount == 0 {
|
||||
@@ -142,29 +148,29 @@ func (h *IotCardImportHandler) HandleIotCardImport(ctx context.Context, task *as
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *IotCardImportHandler) downloadAndParse(ctx context.Context, task *model.IotCardImportTask) (model.CardListJSON, int, error) {
|
||||
func (h *IotCardImportHandler) downloadAndParse(ctx context.Context, task *model.IotCardImportTask) (model.CardListJSON, int, model.ImportResultItems, error) {
|
||||
if h.storageService == nil {
|
||||
return nil, 0, ErrStorageNotConfigured
|
||||
return nil, 0, nil, ErrStorageNotConfigured
|
||||
}
|
||||
|
||||
if task.StorageKey == "" {
|
||||
return nil, 0, ErrStorageKeyEmpty
|
||||
return nil, 0, nil, ErrStorageKeyEmpty
|
||||
}
|
||||
|
||||
localPath, cleanup, err := h.storageService.DownloadToTemp(ctx, task.StorageKey)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if !strings.HasSuffix(strings.ToLower(task.FileName), ".xlsx") {
|
||||
ext := filepath.Ext(task.FileName)
|
||||
return nil, 0, fmt.Errorf("不支持的文件格式 %s,请上传Excel文件(.xlsx)", ext)
|
||||
return nil, 0, nil, fmt.Errorf("不支持的文件格式 %s,请上传Excel文件(.xlsx)", ext)
|
||||
}
|
||||
|
||||
parseResult, err := utils.ParseCardExcel(localPath)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
|
||||
cards := make(model.CardListJSON, 0, len(parseResult.Cards))
|
||||
@@ -176,7 +182,17 @@ func (h *IotCardImportHandler) downloadAndParse(ctx context.Context, task *model
|
||||
})
|
||||
}
|
||||
|
||||
return cards, parseResult.TotalCount, nil
|
||||
parseFailures := make(model.ImportResultItems, 0, len(parseResult.ParseErrors))
|
||||
for _, pe := range parseResult.ParseErrors {
|
||||
parseFailures = append(parseFailures, model.ImportResultItem{
|
||||
Line: pe.Line,
|
||||
ICCID: pe.ICCID,
|
||||
MSISDN: pe.MSISDN,
|
||||
Reason: pe.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
return cards, parseResult.TotalCount, parseFailures, nil
|
||||
}
|
||||
|
||||
type importResult struct {
|
||||
@@ -287,42 +303,44 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot
|
||||
return
|
||||
}
|
||||
|
||||
// 批量检查 virtual_no 唯一性
|
||||
virtualNos := make([]string, 0)
|
||||
virtualNos := make([]string, 0, len(newCards))
|
||||
for _, card := range newCards {
|
||||
if card.VirtualNo != "" {
|
||||
virtualNos = append(virtualNos, card.VirtualNo)
|
||||
}
|
||||
virtualNos = append(virtualNos, card.VirtualNo)
|
||||
}
|
||||
existingVirtualNos := make(map[string]bool)
|
||||
if len(virtualNos) > 0 {
|
||||
existingVirtualNos, err = h.iotCardStore.ExistsByVirtualNoBatch(ctx, virtualNos)
|
||||
if err != nil {
|
||||
h.logger.Error("批量检查 virtual_no 是否存在失败",
|
||||
zap.Error(err),
|
||||
zap.Int("batch_size", len(virtualNos)),
|
||||
)
|
||||
}
|
||||
existingVirtualNos, err = h.iotCardStore.ExistsByVirtualNoBatch(ctx, virtualNos)
|
||||
if err != nil {
|
||||
h.logger.Error("批量检查 virtual_no 是否存在失败",
|
||||
zap.Error(err),
|
||||
zap.Int("batch_size", len(virtualNos)),
|
||||
)
|
||||
}
|
||||
// 批内去重:记录本批次已分配的 virtual_no
|
||||
batchUsedVirtualNos := make(map[string]bool)
|
||||
|
||||
finalCards := make([]model.CardItem, 0, len(newCards))
|
||||
for _, card := range newCards {
|
||||
meta := cardMetaMap[card.ICCID]
|
||||
if card.VirtualNo != "" {
|
||||
if existingVirtualNos[card.VirtualNo] || batchUsedVirtualNos[card.VirtualNo] {
|
||||
result.failedItems = append(result.failedItems, model.ImportResultItem{
|
||||
Line: meta.line,
|
||||
ICCID: card.ICCID,
|
||||
MSISDN: meta.msisdn,
|
||||
Reason: "virtual_no 已被占用: " + card.VirtualNo,
|
||||
})
|
||||
result.failCount++
|
||||
continue
|
||||
}
|
||||
batchUsedVirtualNos[card.VirtualNo] = true
|
||||
if card.VirtualNo == "" {
|
||||
result.failedItems = append(result.failedItems, model.ImportResultItem{
|
||||
Line: meta.line,
|
||||
ICCID: card.ICCID,
|
||||
MSISDN: meta.msisdn,
|
||||
Reason: "虚拟号(virtual_no)不能为空",
|
||||
})
|
||||
result.failCount++
|
||||
continue
|
||||
}
|
||||
if existingVirtualNos[card.VirtualNo] || batchUsedVirtualNos[card.VirtualNo] {
|
||||
result.failedItems = append(result.failedItems, model.ImportResultItem{
|
||||
Line: meta.line,
|
||||
ICCID: card.ICCID,
|
||||
MSISDN: meta.msisdn,
|
||||
Reason: "virtual_no 已被占用: " + card.VirtualNo,
|
||||
})
|
||||
result.failCount++
|
||||
continue
|
||||
}
|
||||
batchUsedVirtualNos[card.VirtualNo] = true
|
||||
finalCards = append(finalCards, card)
|
||||
}
|
||||
|
||||
@@ -330,9 +348,11 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot
|
||||
return
|
||||
}
|
||||
|
||||
iotCards := make([]*model.IotCard, 0, len(finalCards))
|
||||
now := time.Now()
|
||||
createdCards := make([]*model.IotCard, 0, len(finalCards))
|
||||
|
||||
for _, card := range finalCards {
|
||||
meta := cardMetaMap[card.ICCID]
|
||||
iotCard := &model.IotCard{
|
||||
ICCID: card.ICCID,
|
||||
MSISDN: card.MSISDN,
|
||||
@@ -349,34 +369,42 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot
|
||||
iotCard.BaseModel.Updater = task.Creator
|
||||
iotCard.CreatedAt = now
|
||||
iotCard.UpdatedAt = now
|
||||
iotCards = append(iotCards, iotCard)
|
||||
}
|
||||
|
||||
if err := h.iotCardStore.CreateBatch(ctx, iotCards); err != nil {
|
||||
h.logger.Error("批量创建 IoT 卡失败",
|
||||
zap.Error(err),
|
||||
zap.Int("batch_size", len(iotCards)),
|
||||
)
|
||||
for _, card := range finalCards {
|
||||
meta := cardMetaMap[card.ICCID]
|
||||
txErr := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(iotCard).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
txIdentifierStore := postgres.NewAssetIdentifierStore(tx)
|
||||
if err := txIdentifierStore.Register(ctx, card.ICCID, model.AssetTypeIotCard, iotCard.ID); err != nil {
|
||||
return fmt.Errorf("ICCID 已被占用: %s", card.ICCID)
|
||||
}
|
||||
if err := txIdentifierStore.Register(ctx, card.VirtualNo, model.AssetTypeIotCard, iotCard.ID); err != nil {
|
||||
return fmt.Errorf("虚拟号已被占用: %s", card.VirtualNo)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if txErr != nil {
|
||||
h.logger.Error("创建 IoT 卡失败",
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.Error(txErr),
|
||||
)
|
||||
result.failedItems = append(result.failedItems, model.ImportResultItem{
|
||||
Line: meta.line,
|
||||
ICCID: card.ICCID,
|
||||
MSISDN: meta.msisdn,
|
||||
Reason: "数据库写入失败",
|
||||
Reason: txErr.Error(),
|
||||
})
|
||||
result.failCount++
|
||||
continue
|
||||
}
|
||||
return
|
||||
createdCards = append(createdCards, iotCard)
|
||||
result.successCount++
|
||||
}
|
||||
|
||||
result.successCount += len(finalCards)
|
||||
|
||||
// 为导入的卡批量创建资产钱包(钱包创建失败不影响卡导入结果,后续访问时 getOrCreateWallet 兜底)
|
||||
h.batchCreateWallets(ctx, iotCards)
|
||||
h.batchCreateWallets(ctx, createdCards)
|
||||
|
||||
if h.pollingCallback != nil {
|
||||
h.pollingCallback.OnBatchCardsCreated(ctx, iotCards)
|
||||
h.pollingCallback.OnBatchCardsCreated(ctx, createdCards)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
2
migrations/000105_create_asset_identifier.down.sql
Normal file
2
migrations/000105_create_asset_identifier.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS idx_asset_identifier_asset;
|
||||
DROP TABLE IF EXISTS tb_asset_identifier;
|
||||
14
migrations/000105_create_asset_identifier.up.sql
Normal file
14
migrations/000105_create_asset_identifier.up.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- 创建全局资产标识符注册表,保证 ICCID/VirtualNo 跨 tb_iot_card 和 tb_device 全局唯一
|
||||
CREATE TABLE IF NOT EXISTS tb_asset_identifier (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
identifier VARCHAR(100) NOT NULL,
|
||||
asset_type VARCHAR(20) NOT NULL,
|
||||
asset_id BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_asset_identifier UNIQUE (identifier)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_identifier_asset ON tb_asset_identifier(asset_type, asset_id);
|
||||
COMMENT ON TABLE tb_asset_identifier IS '全局资产标识符注册表';
|
||||
COMMENT ON COLUMN tb_asset_identifier.identifier IS '标识符值(ICCID 或 VirtualNo,全局唯一)';
|
||||
COMMENT ON COLUMN tb_asset_identifier.asset_type IS '资产类型:iot_card 或 device';
|
||||
COMMENT ON COLUMN tb_asset_identifier.asset_id IS '对应资产的主键 ID';
|
||||
1
migrations/000106_add_asset_identifier_to_order.down.sql
Normal file
1
migrations/000106_add_asset_identifier_to_order.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE tb_order DROP COLUMN IF EXISTS asset_identifier;
|
||||
2
migrations/000106_add_asset_identifier_to_order.up.sql
Normal file
2
migrations/000106_add_asset_identifier_to_order.up.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tb_order ADD COLUMN IF NOT EXISTS asset_identifier VARCHAR(100);
|
||||
COMMENT ON COLUMN tb_order.asset_identifier IS '下单时资产的标识符快照(ICCID 或 VirtualNo)';
|
||||
11
migrations/000107_iot_card_virtual_no_not_null.down.sql
Normal file
11
migrations/000107_iot_card_virtual_no_not_null.down.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- 回滚:将 tb_iot_card.virtual_no 恢复为 nullable,并还原条件唯一索引
|
||||
|
||||
-- 删除新的无条件唯一索引
|
||||
DROP INDEX IF EXISTS idx_iot_card_virtual_no;
|
||||
|
||||
-- 将 virtual_no 列改回允许 NULL
|
||||
ALTER TABLE tb_iot_card ALTER COLUMN virtual_no DROP NOT NULL;
|
||||
|
||||
-- 恢复旧的条件唯一索引(允许多条 NULL 值共存)
|
||||
CREATE UNIQUE INDEX idx_iot_card_virtual_no ON tb_iot_card(virtual_no)
|
||||
WHERE deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> '';
|
||||
15
migrations/000107_iot_card_virtual_no_not_null.up.sql
Normal file
15
migrations/000107_iot_card_virtual_no_not_null.up.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- 将 tb_iot_card.virtual_no 改为 NOT NULL,并重建唯一索引
|
||||
-- 背景:虚拟号是资产核心业务标识符,不允许为空
|
||||
-- 步骤:1. 清理空值记录(测试环境),2. 加约束,3. 重建索引
|
||||
|
||||
-- 清理空值记录(适用于测试环境)
|
||||
DELETE FROM tb_iot_card WHERE virtual_no IS NULL OR virtual_no = '';
|
||||
|
||||
-- 将 virtual_no 列改为 NOT NULL
|
||||
ALTER TABLE tb_iot_card ALTER COLUMN virtual_no SET NOT NULL;
|
||||
|
||||
-- 删除旧的条件唯一索引(含 IS NOT NULL AND <> '' 条件)
|
||||
DROP INDEX IF EXISTS idx_iot_card_virtual_no;
|
||||
|
||||
-- 重建无条件唯一索引(仅排除软删除记录)
|
||||
CREATE UNIQUE INDEX idx_iot_card_virtual_no ON tb_iot_card(virtual_no) WHERE deleted_at IS NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-07
|
||||
@@ -0,0 +1,75 @@
|
||||
## Context
|
||||
|
||||
系统中换货流程已有完整的数据记录:
|
||||
- `ExchangeOrder` 表存有 `old_asset_id`、`old_asset_identifier`、`new_asset_id`、`new_asset_identifier`,形成资产的"世代链"
|
||||
- `Order` 表通过提案一新增的 `asset_identifier` 快照字段,可直接按标识符查询订单
|
||||
- `Device`/`IotCard` 表有 `generation` 字段记录当前世代编号
|
||||
|
||||
当前缺失的是:把这三张表的信息串联起来,给管理员呈现"这个资产在各世代的订单全貌"。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 新增 `GET /api/admin/assets/:identifier/orders` 接口,默认返回本代订单
|
||||
- 支持 `include_previous=true` 参数,追溯换货链返回前代订单(明确标注世代)
|
||||
- 分页支持
|
||||
|
||||
**Non-Goals:**
|
||||
- C 端不暴露跨代订单(C 端用户视角只关心当前资产本代)
|
||||
- 修改订单数据本身(只读接口)
|
||||
- 跨代订单的聚合统计(如历代总充值额),留给未来迭代
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策 1:通过 ExchangeOrder 逆向追溯前代资产 ID
|
||||
|
||||
**查询逻辑**:
|
||||
```
|
||||
给定 identifier(当前资产):
|
||||
1. 通过注册表/Resolve 得到 asset_type + asset_id(当前代)
|
||||
2. 查当前资产的订单:WHERE asset_identifier = identifier(直接走快照字段)
|
||||
3. 若 include_previous=true:
|
||||
a. 查 ExchangeOrder WHERE new_asset_id = asset_id(找到换货记录)
|
||||
b. 得到 old_asset_id + old_asset_identifier(前代标识符)
|
||||
c. 查前代订单:WHERE asset_identifier = old_asset_identifier
|
||||
d. 递归至无更多前代(链式追溯,最多向前追溯 N 代,防止死循环)
|
||||
4. 合并结果,按世代分组,每条订单附加 generation 字段
|
||||
```
|
||||
|
||||
**理由**:直接使用 `asset_identifier` 快照字段查询,无需 JOIN;换货链通过 ExchangeOrder 逆向遍历,逻辑清晰。
|
||||
|
||||
### 决策 2:响应结构按世代分组,不跨代混合排序
|
||||
|
||||
**选择**:响应分为 `current_generation`(本代)和 `previous_generations`(前代数组)两个区块
|
||||
|
||||
**备选方案**:全部打平按时间排序。问题:管理员看到时间线上跳跃的世代可能产生困惑(订单时间早于资产创建时间)
|
||||
|
||||
**理由**:分区块展示逻辑清晰——管理员一眼能看出"这是这台设备自己的订单"vs"这是上一台的历史"
|
||||
|
||||
### 决策 3:限制追溯深度,防止换货链过长
|
||||
|
||||
最大追溯世代数为 **10 代**,超出则截断并在响应中说明(`truncated: true`)。实际业务中换货链极少超过 3 代,此限制仅为安全保障。
|
||||
|
||||
### 决策 4:本接口依赖提案一的 asset_identifier 快照字段
|
||||
|
||||
若提案一未完成(Order 表无 `asset_identifier` 字段),本接口降级为通过 `iot_card_id`/`device_id` 查询(只支持本代,不支持 include_previous)。实际按提案一完成后实现。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| 风险 | 缓解措施 |
|
||||
|------|---------|
|
||||
| **换货链查询产生多次 DB 往返** | 每代一次 ExchangeOrder 查询 + 一次 Order 查询;链深度有限(≤10),总查询次数可控;可加 Redis 缓存换货链 |
|
||||
| **旧订单 asset_identifier 为空**(提案一之前创建的订单)| 本接口限定只查有 `asset_identifier` 快照的订单;旧订单通过 `iot_card_id`/`device_id` fallback 查询补充,明确标注"旧格式订单" |
|
||||
| **include_previous 导致响应体过大** | 分页仅对本代订单生效;前代订单默认返回最近 20 条,不支持前代分页(前代是历史归档,数量有限) |
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 依赖提案一完成(`Order.asset_identifier` 字段存在)
|
||||
2. 实现 `exchange_order_store.FindChainByNewAssetID()`
|
||||
3. 实现 `asset_service.GetOrders()`
|
||||
4. 注册路由和 Handler
|
||||
5. 更新文档生成器
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 无
|
||||
@@ -0,0 +1,33 @@
|
||||
## Why
|
||||
|
||||
当前系统的资产详情页面没有"历史订单"维度。管理员无法在查看某个资产时直接知道它卖给过谁、买过哪些套餐。换货流程(`ExchangeOrder`)已经在数据库层记录了旧资产→新资产的映射关系,但没有被利用来追溯前代订单。随着资产复用(换货后二次销售)场景增多,管理员需要一种方式能追溯到"这个资产的来源及历史"。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **[NEW]** 新增 B 端接口:`GET /api/admin/assets/:identifier/orders`,查询某资产本代的历史订单
|
||||
- **[NEW]** 支持查询参数 `include_previous=true`,通过换货链追溯并返回前代资产的订单(附带世代标注)
|
||||
- 接口路径遵循提案一中已确立的 `:identifier` 统一规范(依赖提案一完成后的注册表和路由规范)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `asset-historical-orders`:资产往期订单查询接口,支持本代/跨代两种视图,响应中明确标注每条订单所属世代
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
(无——本提案仅新增接口,不修改现有接口行为)
|
||||
|
||||
## Impact
|
||||
|
||||
**受影响的代码**:
|
||||
- `internal/handler/admin/asset.go`:新增 `Orders` handler
|
||||
- `internal/routes/asset.go`:注册新路由
|
||||
- `internal/service/asset/service.go`:新增 `GetOrders()` 方法,包含换货链追溯逻辑
|
||||
- `internal/store/postgres/order_store.go`:新增按 `asset_identifier` 精确查询(依赖提案一的 `asset_identifier` 快照字段)
|
||||
- `internal/store/postgres/exchange_order_store.go`:新增按资产 ID 查询换货链的方法
|
||||
- `internal/model/dto/asset_dto.go`:新增 `AssetOrdersRequest`、`AssetOrdersResponse`、`AssetOrderItem` DTO
|
||||
- `cmd/api/docs.go` 和 `cmd/gendocs/main.go`:注册新 Handler
|
||||
|
||||
**前置依赖**:
|
||||
- 提案一(`asset-identifier-standardization`)必须先完成:本提案依赖 `Order.asset_identifier` 快照字段和统一的 `:identifier` 路由规范
|
||||
@@ -0,0 +1,108 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 查询资产本代历史订单
|
||||
|
||||
系统 SHALL 提供接口,让管理员查询某资产本代(当前世代)的全部历史订单,支持分页。
|
||||
|
||||
**API 端点**:`GET /api/admin/assets/:identifier/orders`
|
||||
|
||||
**请求参数**:
|
||||
- `:identifier`(路径):资产标识符(ICCID 或 VirtualNo),必填
|
||||
- `page`(query):页码,默认 1
|
||||
- `page_size`(query):每页数量,默认 20,最大 100
|
||||
- `include_previous`(query):是否包含前代订单,布尔值,默认 false
|
||||
|
||||
**权限规则**:
|
||||
- 代理用户:只能查看数据权限范围内资产的订单
|
||||
- 平台/超管:可查看所有资产订单
|
||||
- 企业账号:不支持此接口(返回 403)
|
||||
|
||||
#### Scenario: 查询本代订单(默认)
|
||||
- **WHEN** 管理员请求 `GET /api/admin/assets/DEV-001/orders`
|
||||
- **THEN** 返回该资产(当前世代)的订单列表,按创建时间倒序,支持分页
|
||||
- **THEN** 响应中每条订单包含 `generation` 字段,值为资产当前世代编号
|
||||
|
||||
#### Scenario: 资产无订单
|
||||
- **WHEN** 管理员查询一个从未购买过套餐的资产
|
||||
- **THEN** 返回 `{ items: [], total: 0, page: 1 }`,不返回错误
|
||||
|
||||
#### Scenario: identifier 不存在
|
||||
- **WHEN** 请求的 identifier 无法解析到任何资产
|
||||
- **THEN** 返回 HTTP 404,错误消息"资产不存在"
|
||||
|
||||
#### Scenario: 代理查询无权限资产
|
||||
- **WHEN** 代理用户请求不属于其数据权限范围的资产订单
|
||||
- **THEN** 返回 HTTP 403,错误消息"无权限操作该资源或资源不存在"
|
||||
|
||||
### Requirement: 查询资产跨代历史订单(含前代)
|
||||
|
||||
当 `include_previous=true` 时,系统 SHALL 通过换货链追溯前代资产,返回前代订单,并在响应中区分世代来源。
|
||||
|
||||
**追溯逻辑**:
|
||||
1. 通过 `ExchangeOrder.new_asset_id` 逆向查找当前资产的换货来源
|
||||
2. 得到前代的 `old_asset_identifier`,查询该标识符的订单
|
||||
3. 递归追溯,最多向前 10 代(安全上限)
|
||||
|
||||
**响应结构(AssetOrdersResponse)**:
|
||||
```json
|
||||
{
|
||||
"current_generation": {
|
||||
"generation": 2,
|
||||
"identifier": "DEV-001",
|
||||
"asset_type": "device",
|
||||
"total": 5,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"items": [ ...订单列表... ]
|
||||
},
|
||||
"previous_generations": [
|
||||
{
|
||||
"generation": 1,
|
||||
"identifier": "DEV-OLD-001",
|
||||
"asset_type": "device",
|
||||
"exchange_no": "EXC20260101XXXXXX",
|
||||
"exchanged_at": "2026-01-01T00:00:00Z",
|
||||
"total": 3,
|
||||
"items": [ ...前代订单列表(最近20条)... ]
|
||||
}
|
||||
],
|
||||
"truncated": false
|
||||
}
|
||||
```
|
||||
|
||||
**每条订单项(AssetOrderItem)包含**:
|
||||
- `order_no`:订单号
|
||||
- `order_type`:订单类型(single_card / device)
|
||||
- `payment_status`:支付状态
|
||||
- `payment_status_text`:支付状态文本
|
||||
- `total_amount`:订单金额(分)
|
||||
- `payment_method`:支付方式
|
||||
- `paid_at`:支付时间(可空)
|
||||
- `generation`:订单所属资产世代
|
||||
- `items`:套餐明细列表
|
||||
- `created_at`:订单创建时间
|
||||
|
||||
#### Scenario: 查询换货后资产的全代际订单
|
||||
- **WHEN** 管理员请求 `GET /api/admin/assets/DEV-001/orders?include_previous=true`,DEV-001 是换货后的新设备(第2代),原设备为 DEV-OLD-001(第1代)
|
||||
- **THEN** `current_generation` 包含 DEV-001 本代的订单(generation=2)
|
||||
- **THEN** `previous_generations[0]` 包含 DEV-OLD-001 的订单(generation=1),附带换货单号和换货时间
|
||||
- **THEN** `truncated=false`(未超出追溯上限)
|
||||
|
||||
#### Scenario: 资产本身就是第一代(无前代)
|
||||
- **WHEN** 管理员请求带 `include_previous=true`,但该资产从未经过换货
|
||||
- **THEN** `previous_generations` 为空数组 `[]`
|
||||
- **THEN** `current_generation` 正常返回本代订单
|
||||
|
||||
#### Scenario: 换货链超过追溯上限
|
||||
- **WHEN** 换货链深度超过 10 代
|
||||
- **THEN** 追溯在第 10 代截断,`truncated=true`
|
||||
- **THEN** 已追溯到的前代数据正常返回
|
||||
|
||||
#### Scenario: 前代订单分页
|
||||
- **WHEN** 请求带 `include_previous=true`
|
||||
- **THEN** 分页参数(page/page_size)只对 `current_generation` 的订单生效
|
||||
- **THEN** 前代订单每代最多返回 20 条(不支持前代内分页)
|
||||
|
||||
#### Scenario: 无 include_previous 时响应不含前代字段
|
||||
- **WHEN** 管理员请求不带 `include_previous=true`(或传 false)
|
||||
- **THEN** 响应结构中 `previous_generations` 字段为 null 或不返回,节省带宽
|
||||
@@ -0,0 +1,54 @@
|
||||
## 1. 前置确认
|
||||
|
||||
- [x] 1.1 确认提案一(asset-identifier-standardization)已完成:`tb_order.asset_identifier` 字段存在,B 端资产路由已统一为 `:identifier`
|
||||
|
||||
## 2. 数据层:ExchangeOrder Store 扩展
|
||||
|
||||
- [x] 2.1 在 `internal/store/postgres/exchange_order_store.go` 新增 `FindByNewAssetID(ctx, assetType, assetID) (*model.ExchangeOrder, error)` 方法:查询 `WHERE new_asset_id = ? AND new_asset_type = ?`,返回该资产的换货来源记录(找不到则返回 nil,表示无前代)
|
||||
|
||||
## 3. DTO 层:新增响应结构
|
||||
|
||||
- [x] 3.1 在 `internal/model/dto/asset_dto.go` 新增以下结构体:
|
||||
- `AssetOrdersRequest`:路径参数 `identifier`,query 参数 `page`、`page_size`、`include_previous`
|
||||
- `AssetOrderItem`:单条订单项(order_no、order_type、payment_status、payment_status_text、total_amount、payment_method、paid_at、generation、items、created_at)
|
||||
- `GenerationOrders`:单代订单块(generation、identifier、asset_type、total、page、page_size、items)
|
||||
- `PreviousGenerationOrders`:前代订单块(generation、identifier、asset_type、exchange_no、exchanged_at、total、items)
|
||||
- `AssetOrdersResponse`:完整响应(current_generation、previous_generations、truncated)
|
||||
|
||||
## 4. 服务层:GetOrders 方法
|
||||
|
||||
- [x] 4.1 在 `internal/service/asset/service.go` 新增 `GetOrders(ctx, identifier, page, pageSize, includePrevious bool) (*dto.AssetOrdersResponse, error)` 方法:
|
||||
- 调用 `Resolve()` 得到 asset_type 和 asset_id
|
||||
- 查询本代订单:`orderStore.ListByAssetIdentifier(ctx, identifier, page, pageSize)`
|
||||
- 若 `includePrevious=true`:循环调用 `exchangeOrderStore.FindByNewAssetID()` 追溯换货链(最多 10 代);每代查询前代 `old_asset_identifier` 对应的订单(最多 20 条,无分页)
|
||||
- 组装 `AssetOrdersResponse` 返回
|
||||
- [x] 4.2 在 `internal/store/postgres/order_store.go` 新增 `ListByAssetIdentifier(ctx, identifier, page, pageSize) ([]*model.Order, int64, error)` 方法:查询 `WHERE asset_identifier = ?`,分页,倒序
|
||||
|
||||
## 5. Handler 层:新增 Orders Handler
|
||||
|
||||
- [x] 5.1 在 `internal/handler/admin/asset.go` 新增 `Orders(c *fiber.Ctx) error` handler:
|
||||
- 从路径参数读取 `identifier`;从 query 读取 `page`、`page_size`、`include_previous`
|
||||
- 调用 `assetService.GetOrders()`
|
||||
- 返回 `response.Success(c, result)`
|
||||
- 注释:`// Orders 查询资产历史订单(支持跨代追溯)` + `// GET /api/admin/assets/:identifier/orders`
|
||||
|
||||
## 6. 路由层:注册新路由
|
||||
|
||||
- [x] 6.1 在 `internal/routes/asset.go` 注册:`assets.Get("/:identifier/orders", h.Asset.Orders)`(注意路由顺序,避免与 `/resolve/:identifier` 冲突)
|
||||
|
||||
## 7. 文档生成器更新
|
||||
|
||||
- [x] 7.1 在 `cmd/api/docs.go` 和 `cmd/gendocs/main.go` 的 `handlers` 结构体中确认 `AssetHandler` 已包含新的 `Orders` 方法(检查是否需要更新 handler 注册)
|
||||
- [x] 7.2 执行 `go run cmd/gendocs/main.go` 重新生成 OpenAPI 文档,验证新接口体现
|
||||
|
||||
## 8. 验证
|
||||
|
||||
- [x] 8.1 构建验证:`go build ./...` 无编译错误
|
||||
- [x] 8.2 LSP 诊断:对所有修改文件运行 lsp_diagnostics,无错误/警告
|
||||
- [x] 8.3 接口验证(使用 PostgreSQL MCP + curl):
|
||||
- 查询有订单的资产:`GET /api/admin/assets/:identifier/orders` 返回正确订单列表,含 generation 字段
|
||||
- 查询无订单资产:返回 `items: [], total: 0`
|
||||
- 查询不存在的 identifier:返回 404
|
||||
- 查询换货资产(带 include_previous=true):`previous_generations` 包含前代订单,附带 exchange_no 和 exchanged_at
|
||||
- 查询无前代的资产(带 include_previous=true):`previous_generations` 为空数组
|
||||
- 代理查询无权限资产:返回 403
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-07
|
||||
@@ -0,0 +1,131 @@
|
||||
## Context
|
||||
|
||||
当前系统中,资产(IoT 卡和设备)的操作接口混用两套参数规范:
|
||||
- **B 端大多数接口**:使用数据库主键 ID(`/assets/:asset_type/:id/`、`/devices/:id/`),前端持有无业务含义的整数 ID
|
||||
- **部分接口**:使用业务标识符(`/assets/card/:iccid/stop`、`/devices/by-identifier/:identifier/wifi`),但命名不一致
|
||||
- **C 端接口**:已规范使用 `identifier` query 参数(ICCID/VirtualNo)
|
||||
|
||||
主要问题:
|
||||
1. IoT 卡和设备各自表内有 VirtualNo 唯一索引,但**跨表没有全局唯一约束**,理论上可写入相同虚拟号
|
||||
2. 订单创建接口传入 `iot_card_id`/`device_id` 主键,响应也只返回 ID,不含标识符
|
||||
3. `purchase_validation` 未校验绑定设备的卡,允许对非独立卡(`is_standalone=false`)单独购买套餐
|
||||
4. `Resolve` 服务对两张表做 OR 多字段查询,性能随数据量增长有退化风险
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 建立全局资产标识符注册表(`tb_asset_identifier`),数据库层保证 VirtualNo/ICCID 跨表全局唯一
|
||||
- B 端所有资产操作接口统一使用 `:identifier` 路径参数(ICCID 或 VirtualNo)
|
||||
- `Resolve` 主查询路径改走注册表,降低查询复杂度
|
||||
- 订单接口改用 `identifier` 传参,响应补充标识符快照字段
|
||||
- 修复绑定设备的卡可单独购买套餐的 Bug
|
||||
|
||||
**Non-Goals:**
|
||||
- IoT 卡导入强制 VirtualNo(属于提案三,独立推进)
|
||||
- 资产往期订单查询接口(属于提案二,独立推进)
|
||||
- C 端接口变更(C 端已规范,本次不动)
|
||||
- Resolve 接口的 IMEI/SN/MSISDN fallback 查询(保留现有逻辑)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策 1:使用独立注册表(tb_asset_identifier)实现跨表全局唯一
|
||||
|
||||
**选择**:新增 `tb_asset_identifier` 表,`identifier` 字段加 `UNIQUE` 约束
|
||||
|
||||
**备选方案**:
|
||||
- *应用层检查*:插入前先查两张表,存在则拒绝。问题:高并发下存在竞态窗口,同一标识符可能被两个并发请求同时通过检查
|
||||
- *数据库触发器*:在 `tb_device` 和 `tb_iot_card` 上建触发器查对方表。问题:触发器调试困难,项目中无此先例,违背"逻辑在代码层"原则
|
||||
|
||||
**理由**:注册表方案通过数据库 UNIQUE 约束消除竞态——两个并发写入同一标识符时,数据库只允许一个成功,另一个收到约束冲突错误并回滚事务,安全可靠且符合项目"禁止外键,关联在代码层维护"的原则。
|
||||
|
||||
**表结构**:
|
||||
```sql
|
||||
CREATE TABLE tb_asset_identifier (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
identifier VARCHAR(100) NOT NULL,
|
||||
asset_type VARCHAR(20) NOT NULL, -- 'iot_card' | 'device'
|
||||
asset_id BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_asset_identifier UNIQUE (identifier)
|
||||
);
|
||||
CREATE INDEX idx_asset_identifier_asset ON tb_asset_identifier(asset_type, asset_id);
|
||||
```
|
||||
|
||||
**写入时机**:
|
||||
- 创建设备/IoT 卡时,在**同一事务**内写入注册表
|
||||
- 软删除时,同步从注册表删除(或标记 deleted_at,避免已删除的标识符阻塞复用)
|
||||
- VirtualNo 更新时(目前无此场景,预留),先删旧记录再插新记录
|
||||
|
||||
### 决策 2:Resolve 主路径改走注册表,保留 fallback
|
||||
|
||||
**选择**:`Resolve` 方法优先查 `tb_asset_identifier`(精确匹配),未命中再 fallback 到原有跨表 OR 查询(处理 IMEI/SN/MSISDN 等非注册标识符)
|
||||
|
||||
**理由**:注册表只存 VirtualNo 和 ICCID(全局唯一的标识符),IMEI/SN/MSISDN 因非唯一不写入注册表。Resolve 接口仍需支持这些模糊标识符(方便管理员查找),但精确路径走注册表可大幅提升性能。
|
||||
|
||||
```
|
||||
Resolve(identifier) 逻辑:
|
||||
1. 查 tb_asset_identifier WHERE identifier = ?
|
||||
→ 命中:得到 asset_type + asset_id,按 type 查完整记录
|
||||
→ 未命中:fallback 到原有逻辑(OR 多字段查 device/iot_card)
|
||||
```
|
||||
|
||||
### 决策 3:B 端接口统一 :identifier,不区分 card/device 类型
|
||||
|
||||
**选择**:路由改为 `/admin/assets/:identifier/*`,系统内部通过 Resolve 得到 asset_type,无需路径中显式传类型
|
||||
|
||||
**备选方案**:
|
||||
- *保留 :asset_type 前缀*:`/admin/assets/card/:iccid/packages`。问题:调用方需要知道"这是卡还是设备"才能构造 URL,而 identifier 本身已能唯一定位
|
||||
- *两段式 Resolve + ID*:前端先调 Resolve 拿 ID 再操作。问题:前端耦合两个接口,且 ID 无业务含义
|
||||
|
||||
**理由**:identifier 已能唯一定位资产(通过注册表),asset_type 冗余;URL 语义更自然(`/assets/ICCID-001/packages` 而非 `/assets/card/ICCID-001/packages`)。
|
||||
|
||||
**注意**:仅操作类接口(packages/realtime/stop/start 等)使用统一 `:identifier`。设备绑卡管理(`/devices/:virtual_no/cards`)因属于设备专属操作,路径保留 `devices` 前缀但改用 VirtualNo。
|
||||
|
||||
### 决策 4:订单创建改用 identifier,系统内部解析 asset_type
|
||||
|
||||
**选择**:`CreateOrderRequest` 去掉 `iot_card_id`/`device_id`,改为 `identifier` 字段,`order_type` 字段同时废弃(由 identifier 解析结果决定)
|
||||
|
||||
**理由**:与接口统一规范一致;同时自然修复"绑定设备的卡可单独购买"Bug——解析 identifier 后得到 `is_standalone=false` 的卡,直接在 purchase_validation 层拦截。
|
||||
|
||||
**Order 模型快照**:订单记录中增加 `asset_identifier VARCHAR(100)` 字段,存储下单时资产的标识符快照(类似 `order_no` 的快照思路),便于历史查询而不依赖关联查询。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| 风险 | 缓解措施 |
|
||||
|------|---------|
|
||||
| **Breaking 变更**:B 端所有资产路由改变,前端需全量更新 | 系统处于测试阶段,前端可同步切换;通过 OpenAPI 文档明确新路由 |
|
||||
| **存量资产未在注册表**:现有 tb_device 和 tb_iot_card 数据需回填注册表 | 提供数据迁移脚本,回填时检查 VirtualNo 跨表唯一性;直接清库(测试阶段)则无此问题 |
|
||||
| **注册表成为热点**:资产创建时额外写一张表 | 注册表操作在事务内,单次 INSERT;正常导入批量处理,影响极小 |
|
||||
| **Resolve fallback 性能**:IMEI/SN 仍走全表 OR 查询 | fallback 路径为次要场景,精确路径(注册表)已涵盖主要查询;长期可为 IMEI/SN 建索引 |
|
||||
| **order_type 废弃兼容**:历史订单含 order_type 字段 | 历史数据保留,新创建订单由系统根据 identifier 填入 order_type;API 不再接受 order_type 输入 |
|
||||
|
||||
## Migration Plan
|
||||
|
||||
**步骤一:数据库迁移**
|
||||
1. 创建迁移文件,建立 `tb_asset_identifier` 表
|
||||
2. 执行回填脚本:将所有现有设备的 VirtualNo 和 IoT 卡的 ICCID/VirtualNo 写入注册表
|
||||
3. 测试阶段:直接清库则跳过步骤 2
|
||||
|
||||
**步骤二:后端实现(可分 PR 推进)**
|
||||
1. 新增 `AssetIdentifier` 模型和 `asset_identifier_store`
|
||||
2. 改造 `Resolve` 服务,注册表主路径 + fallback
|
||||
3. 更新 `purchase_validation`,增加 `is_standalone` 校验
|
||||
4. 更新 `Order` 模型,增加 `asset_identifier` 快照字段
|
||||
5. 重写 B 端路由:`asset.go`、`device.go`、`iot_card.go`、`order.go`
|
||||
6. 更新 DTO:`asset_dto.go`、`order_dto.go`、`client_order_dto.go`
|
||||
7. 更新 Handler 层参数解析
|
||||
8. 更新 `gendocs` 文档生成
|
||||
|
||||
**步骤三:验证**
|
||||
1. 全量 API 回归测试
|
||||
2. 并发写入同一 VirtualNo 压测(验证注册表唯一约束)
|
||||
3. Resolve fallback 路径验证(IMEI/SN 查询)
|
||||
|
||||
**回滚方案**:保留旧路由映射(别名路由)72 小时,确认前端切换完毕后删除
|
||||
|
||||
## Open Questions
|
||||
|
||||
- ~~已解决:VirtualNo 跨表全局唯一策略(独立注册表)~~
|
||||
- ~~已解决:B 端路由统一方案(方案 B,统一 :identifier)~~
|
||||
- ~~已解决:订单传参方式(identifier,不传 asset_type)~~
|
||||
- **待确认**:Order 模型中 `asset_identifier` 快照字段的数据库类型选 `VARCHAR(100)` 是否满足所有标识符长度(ICCID 最长 20 位,VirtualNo 最长 100 位)
|
||||
@@ -0,0 +1,53 @@
|
||||
## Why
|
||||
|
||||
当前系统的 B 端资产操作接口大量使用数据库主键 ID(`/assets/:asset_type/:id/`、`/devices/:id/`)作为路径参数,导致接口语义模糊、前端被迫持有无业务含义的 ID、换货后旧 ID 失效等问题。与此同时,IoT 卡和设备的虚拟号(VirtualNo)没有跨表全局唯一约束,ICCID/VirtualNo 在多个子系统中的使用也不一致。现阶段系统尚处于开发测试期,是建立资产标识符规范的最佳窗口。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **[BREAKING]** B 端资产操作接口路径参数统一改为资产标识符(ICCID 或 VirtualNo),废弃基于数据库主键 ID 的路由
|
||||
- **[BREAKING]** 订单创建接口(B 端 + C 端)从传入 `iot_card_id`/`device_id` 主键改为传入 `identifier` 标识符
|
||||
- **[NEW]** 新增全局资产标识符注册表 `tb_asset_identifier`,在数据库层保证 ICCID/VirtualNo 跨 `tb_iot_card` 和 `tb_device` 两表全局唯一,防止并发写入冲突
|
||||
- **[NEW]** 资产解析服务(Resolve)主路径改走注册表,单次查询即可定位资产,替代原有的跨表 OR 查询
|
||||
- **[FIX]** 修复:已绑定设备的 IoT 卡(`is_standalone=false`)可被单独购买套餐的 Bug,改为直接报错并引导用户至设备页面购买
|
||||
- 订单查询列表新增按资产标识符过滤条件
|
||||
- 订单响应新增资产标识符字段,前端无需二次查询
|
||||
- 所有 B 端 `:id` 路由同步更新,前端需切换调用方式
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `asset-identifier-registry`:全局资产标识符注册表(`tb_asset_identifier`)的模型定义、写入/查询接口,以及 Resolve 服务的注册表主路径改造
|
||||
- `asset-identifier-routes`:B 端统一标识符路由规范——将 `/assets/:asset_type/:id/*`、`/devices/:id/*`、`/iot-cards/:id/*` 全线迁移为 `/assets/:identifier/*`、`/devices/:virtual_no/*`
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `asset-resolve`:主查询路径改走 `tb_asset_identifier`;IMEI/SN/MSISDN 降级为 fallback 模糊查询
|
||||
- `order-management`:CreateAdminOrderRequest 改用 `identifier`;OrderResponse 新增 `asset_identifier` 字段;OrderListRequest 新增 `identifier` 过滤条件
|
||||
- `client-order-purchase`:CreateOrderRequest 改用 `identifier`,系统内部解析资产类型
|
||||
- `package-purchase-validation`:新增 `is_standalone` 校验——若卡已绑定设备则拒绝单卡购买套餐
|
||||
|
||||
## Impact
|
||||
|
||||
**受影响的代码**:
|
||||
- `internal/model/`:新增 `AssetIdentifier` 模型;`Order` 模型新增 `asset_identifier` 快照字段
|
||||
- `internal/store/postgres/`:新增 `asset_identifier_store.go`;`order_store.go` 新增标识符过滤
|
||||
- `internal/service/asset/`:`Resolve` 方法改走注册表
|
||||
- `internal/service/purchase_validation/`:新增 `is_standalone` 校验
|
||||
- `internal/handler/admin/`:`asset.go`、`device.go`、`iot_card.go`、`order.go` 全面更新路径参数和请求/响应 DTO
|
||||
- `internal/handler/app/`:`client_order.go` 更新请求 DTO
|
||||
- `internal/routes/`:`asset.go`、`device.go`、`iot_card.go`、`order.go` 路由重注册
|
||||
- `internal/model/dto/`:`asset_dto.go`、`order_dto.go`、`client_order_dto.go` 字段变更
|
||||
- `migrations/`:新增迁移文件创建 `tb_asset_identifier` 表
|
||||
|
||||
**受影响的 API(BREAKING)**:
|
||||
- `GET /api/admin/assets/:asset_type/:id/*`(7 个端点)
|
||||
- `POST /api/admin/assets/device/:device_id/stop|start`
|
||||
- `DELETE /api/admin/devices/:id`
|
||||
- `GET|POST|DELETE /api/admin/devices/:id/cards*`
|
||||
- `PATCH /api/admin/devices/:id/deactivate`
|
||||
- `PATCH /api/admin/iot-cards/:id/deactivate`
|
||||
- `POST /api/admin/orders`(请求体变更)
|
||||
- `POST /api/c/v1/orders/create`(请求体变更)
|
||||
|
||||
**前端影响**:所有使用 `asset_id` 做路径参数的页面必须切换为使用 `identifier`(ICCID 或 VirtualNo)
|
||||
@@ -0,0 +1,46 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 全局资产标识符注册表
|
||||
|
||||
系统 SHALL 维护一张全局唯一的资产标识符注册表(`tb_asset_identifier`),在数据库层保证 IoT 卡的 ICCID/VirtualNo 与设备的 VirtualNo 跨两张表不重复,消除并发写入竞态风险。
|
||||
|
||||
注册表仅存储**全局唯一标识符**(ICCID、VirtualNo),不存储 IMEI/SN/MSISDN 等非唯一标识符。
|
||||
|
||||
**表字段**:
|
||||
- `id`:自增主键
|
||||
- `identifier`:标识符值(VARCHAR(100),UNIQUE 约束)
|
||||
- `asset_type`:资产类型(`iot_card` 或 `device`)
|
||||
- `asset_id`:对应资产的主键 ID
|
||||
- `created_at`:写入时间
|
||||
|
||||
#### Scenario: 创建设备时注册 VirtualNo
|
||||
- **WHEN** 创建新设备(通过导入或 API),VirtualNo 非空
|
||||
- **THEN** 系统在同一事务内向 `tb_asset_identifier` 写入一条记录(identifier=VirtualNo, asset_type=device, asset_id=新设备ID)
|
||||
- **THEN** 若 VirtualNo 已在注册表中存在,事务回滚,返回错误"虚拟号已被占用"
|
||||
|
||||
#### Scenario: 创建 IoT 卡时注册 ICCID
|
||||
- **WHEN** 创建新 IoT 卡(通过导入),ICCID 非空
|
||||
- **THEN** 系统在同一事务内向注册表写入(identifier=ICCID, asset_type=iot_card, asset_id=新卡ID)
|
||||
- **THEN** 若 ICCID 已在注册表中存在,事务回滚,返回错误"ICCID 已被占用"
|
||||
|
||||
#### Scenario: 创建 IoT 卡时注册 VirtualNo(如有)
|
||||
- **WHEN** 创建新 IoT 卡时 VirtualNo 非空
|
||||
- **THEN** 系统额外向注册表写入(identifier=VirtualNo, asset_type=iot_card, asset_id=新卡ID)
|
||||
- **THEN** 若 VirtualNo 已被其他设备或卡占用,事务回滚,返回错误"虚拟号已被占用"
|
||||
|
||||
#### Scenario: 并发写入同一标识符
|
||||
- **WHEN** 两个并发请求同时尝试注册相同的 VirtualNo(如 "CARD-001")
|
||||
- **THEN** 数据库 UNIQUE 约束保证只有一个写入成功;另一个收到唯一约束冲突错误,事务回滚,返回错误"虚拟号已被占用"
|
||||
|
||||
#### Scenario: 软删除资产时清理注册表
|
||||
- **WHEN** 软删除设备或 IoT 卡
|
||||
- **THEN** 系统在同一事务内删除 `tb_asset_identifier` 中对应的记录(所有 asset_id 匹配的行),允许标识符被后续资产复用
|
||||
|
||||
#### Scenario: 通过标识符精确查找资产
|
||||
- **WHEN** 系统需要根据 identifier(ICCID 或 VirtualNo)定位资产
|
||||
- **THEN** 系统查询 `SELECT * FROM tb_asset_identifier WHERE identifier = ?`,一次查询得到 asset_type 和 asset_id
|
||||
- **THEN** 再按 asset_type 查对应表(`tb_device` 或 `tb_iot_card`)取完整记录
|
||||
|
||||
#### Scenario: 查询不存在的标识符
|
||||
- **WHEN** 查询注册表中不存在的 identifier
|
||||
- **THEN** 返回空结果(not found),调用方可 fallback 到原有查询逻辑
|
||||
@@ -0,0 +1,78 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: B 端资产操作接口统一使用标识符路径参数
|
||||
|
||||
B 端所有资产操作类接口 SHALL 使用资产标识符(ICCID 或 VirtualNo)作为路径参数,废弃原有基于数据库主键 ID 的路由。系统内部通过注册表将标识符解析为资产实体,Handler 层无需关心 ID。
|
||||
|
||||
**标识符规则**:
|
||||
- IoT 卡:接受 ICCID 或 VirtualNo(均为全局唯一)
|
||||
- 设备:接受 VirtualNo(全局唯一)
|
||||
- 不接受 IMEI/SN/MSISDN(非唯一,仅 Resolve 的 fallback 路径支持)
|
||||
|
||||
**废弃的旧路由 → 新路由映射**:
|
||||
|
||||
| 旧路由(废弃) | 新路由 |
|
||||
|---|---|
|
||||
| `GET /api/admin/assets/:asset_type/:id/realtime-status` | `GET /api/admin/assets/:identifier/realtime-status` |
|
||||
| `POST /api/admin/assets/:asset_type/:id/refresh` | `POST /api/admin/assets/:identifier/refresh` |
|
||||
| `GET /api/admin/assets/:asset_type/:id/packages` | `GET /api/admin/assets/:identifier/packages` |
|
||||
| `GET /api/admin/assets/:asset_type/:id/current-package` | `GET /api/admin/assets/:identifier/current-package` |
|
||||
| `GET /api/admin/assets/:asset_type/:id/wallet` | `GET /api/admin/assets/:identifier/wallet` |
|
||||
| `GET /api/admin/assets/:asset_type/:id/wallet/transactions` | `GET /api/admin/assets/:identifier/wallet/transactions` |
|
||||
| `PATCH /api/admin/assets/:asset_type/:id/polling-status` | `PATCH /api/admin/assets/:identifier/polling-status` |
|
||||
| `POST /api/admin/assets/device/:device_id/stop` | `POST /api/admin/assets/:identifier/stop` |
|
||||
| `POST /api/admin/assets/device/:device_id/start` | `POST /api/admin/assets/:identifier/start` |
|
||||
| `POST /api/admin/assets/card/:iccid/stop` | `POST /api/admin/assets/:identifier/stop`(合并) |
|
||||
| `POST /api/admin/assets/card/:iccid/start` | `POST /api/admin/assets/:identifier/start`(合并) |
|
||||
| `DELETE /api/admin/devices/:id` | `DELETE /api/admin/devices/:virtual_no` |
|
||||
| `GET /api/admin/devices/:id/cards` | `GET /api/admin/devices/:virtual_no/cards` |
|
||||
| `POST /api/admin/devices/:id/cards` | `POST /api/admin/devices/:virtual_no/cards` |
|
||||
| `DELETE /api/admin/devices/:id/cards/:cardId` | `DELETE /api/admin/devices/:virtual_no/cards/:iccid` |
|
||||
| `PATCH /api/admin/devices/:id/deactivate` | `PATCH /api/admin/assets/:identifier/deactivate` |
|
||||
| `PATCH /api/admin/iot-cards/:id/deactivate` | `PATCH /api/admin/assets/:identifier/deactivate`(合并) |
|
||||
|
||||
#### Scenario: 通过 ICCID 操作 IoT 卡
|
||||
- **WHEN** 管理员请求 `GET /api/admin/assets/898600XXXXXXXX/packages`
|
||||
- **THEN** 系统解析 ICCID,找到对应 IoT 卡,返回该卡的套餐列表
|
||||
|
||||
#### Scenario: 通过 VirtualNo 操作设备
|
||||
- **WHEN** 管理员请求 `POST /api/admin/assets/DEV-001/stop`
|
||||
- **THEN** 系统解析 VirtualNo,找到对应设备,执行批量停机(停该设备下所有已实名卡)
|
||||
|
||||
#### Scenario: 通过 VirtualNo 操作绑定了设备的 IoT 卡(停机)
|
||||
- **WHEN** 管理员请求 `POST /api/admin/assets/CARD-001/stop`,CARD-001 是 IoT 卡的 VirtualNo
|
||||
- **THEN** 系统解析 VirtualNo,找到 IoT 卡,执行单卡停机
|
||||
|
||||
#### Scenario: stop/start 接口对卡和设备行为差异
|
||||
- **WHEN** identifier 解析为 IoT 卡时调用 stop
|
||||
- **THEN** 执行单卡停机
|
||||
- **WHEN** identifier 解析为设备时调用 stop
|
||||
- **THEN** 执行设备停机(批量停机该设备下所有已实名卡)
|
||||
|
||||
#### Scenario: 标识符不存在
|
||||
- **WHEN** 管理员请求的 `:identifier` 在注册表和 fallback 查询中均未找到对应资产
|
||||
- **THEN** 返回 HTTP 404,错误消息"资产不存在"
|
||||
|
||||
#### Scenario: 无权限操作该资产
|
||||
- **WHEN** 代理用户请求的 identifier 对应的资产不属于该代理的数据权限范围
|
||||
- **THEN** 返回 HTTP 403,错误消息"无权限操作该资源或资源不存在"
|
||||
|
||||
#### Scenario: 设备绑卡管理使用设备 VirtualNo
|
||||
- **WHEN** 管理员请求 `GET /api/admin/devices/DEV-001/cards`
|
||||
- **THEN** 系统通过 VirtualNo 找到设备,返回该设备绑定的卡列表
|
||||
|
||||
#### Scenario: 设备解绑卡使用 ICCID
|
||||
- **WHEN** 管理员请求 `DELETE /api/admin/devices/DEV-001/cards/898600XXXXXXXX`
|
||||
- **THEN** 系统通过 VirtualNo 找到设备,通过 ICCID 找到卡,执行解绑
|
||||
|
||||
### Requirement: 新路由下标识符的解析性能
|
||||
|
||||
资产操作接口中标识符解析 SHALL 优先走注册表(单次精确查询),保证解析延迟不超过 10ms(在正常数据库负载下)。
|
||||
|
||||
#### Scenario: 注册表命中路径
|
||||
- **WHEN** 请求携带的 identifier 存在于 `tb_asset_identifier`
|
||||
- **THEN** 系统单次查询注册表得到 asset_type 和 asset_id,无需扫描 tb_device 或 tb_iot_card
|
||||
|
||||
#### Scenario: 注册表未命中(fallback)
|
||||
- **WHEN** 请求携带的 identifier 不在注册表(如 IMEI 或旧数据)
|
||||
- **THEN** 系统 fallback 到原有多字段 OR 查询,同样能定位资产(性能稍低,为次要路径)
|
||||
@@ -0,0 +1,69 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 统一资产解析入口
|
||||
|
||||
系统 SHALL 提供统一的资产查找接口,通过任意标识符定位卡或设备,并返回该资产的中等聚合信息。
|
||||
|
||||
**API 端点**: `GET /api/admin/assets/resolve/:identifier`
|
||||
|
||||
**查找顺序(更新后)**:
|
||||
1. **主路径**:查 `tb_asset_identifier` WHERE identifier = ? → 命中则得到 asset_type + asset_id,直接查对应表取完整记录
|
||||
2. **Fallback 路径**(注册表未命中时):
|
||||
- 先查 `tb_device`(匹配 `virtual_no = ? OR imei = ? OR sn = ?`)
|
||||
- 未命中则查 `tb_iot_card`(匹配 `virtual_no = ? OR iccid = ? OR msisdn = ?`)
|
||||
3. 两条路径均未命中 → 返回 HTTP 404
|
||||
|
||||
**数据权限规则**:
|
||||
- 代理用户:只能查看 `shop_id` 在自己及下级店铺范围内的资产
|
||||
- 平台用户(SuperAdmin/Platform):可查看所有资产
|
||||
- 企业账号:暂不支持此接口,调用时返回 HTTP 403
|
||||
|
||||
**响应结构(AssetResolveResponse)**:
|
||||
|
||||
*通用字段(device 和 card 均有)*:
|
||||
- `asset_type`: 资产类型(`"device"` 或 `"card"`)
|
||||
- `asset_id`: 资产主键 ID
|
||||
- `identifier`: 本次查询所用的标识符(原样回传)
|
||||
- `virtual_no`: 虚拟号(设备/卡均使用此字段)
|
||||
- `status`: 资产状态(整型)
|
||||
- `asset_status`: 业务状态(1-在库 2-已销售 3-已换货 4-已停用)
|
||||
- `generation`: 资产世代编号
|
||||
- `batch_no`: 批次号
|
||||
- `shop_id`: 所属店铺 ID(平台库存时为空)
|
||||
- `shop_name`: 所属店铺名称
|
||||
- `series_id`: 套餐系列 ID(未绑定时为空)
|
||||
- `series_name`: 套餐系列名称
|
||||
- `first_commission_paid`: 一次性佣金是否已发放
|
||||
- `accumulated_recharge`: 累计充值金额(分)
|
||||
- `activated_at`: 激活时间(未激活时为空)
|
||||
- `created_at`: 创建时间
|
||||
- `updated_at`: 更新时间
|
||||
|
||||
*状态与套餐字段(device 和 card 均有)*:
|
||||
- `real_name_status`: 实名状态(整型)
|
||||
- `current_package`: 当前套餐名称(无套餐时返回空字符串)
|
||||
- `package_total_mb`、`package_used_mb`、`package_remain_mb`: 套餐流量信息
|
||||
- `device_protect_status`: 保护期状态
|
||||
|
||||
*绑定关系字段*:
|
||||
- `iccid`: 仅 card 类型时有值
|
||||
- `bound_device_id`、`bound_device_no`、`bound_device_name`: 仅 card 类型且绑定设备时有值
|
||||
- `bound_card_count`、`cards`: 仅 device 类型时有值
|
||||
|
||||
#### Scenario: 通过注册表主路径精确解析
|
||||
- **WHEN** 管理员输入 identifier 为已存在于 `tb_asset_identifier` 的 VirtualNo 或 ICCID
|
||||
- **THEN** 系统单次查询注册表命中,直接查对应表返回完整资产信息,响应时间 < 50ms
|
||||
|
||||
#### Scenario: Fallback 路径解析 IMEI
|
||||
- **WHEN** 管理员输入 identifier 为设备 IMEI(不在注册表中)
|
||||
- **THEN** 注册表未命中,系统 fallback 查 tb_device 的 imei 字段,找到后返回资产信息
|
||||
- **THEN** 响应中 `identifier` 字段原样回传该 IMEI 值
|
||||
|
||||
#### Scenario: Fallback 路径解析 MSISDN
|
||||
- **WHEN** 管理员输入 identifier 为 IoT 卡的手机号(MSISDN)
|
||||
- **THEN** 注册表未命中,fallback 查 tb_iot_card 的 msisdn 字段
|
||||
- **THEN** 若存在多张卡的 MSISDN 相同,返回第一条匹配记录(MSISDN 非唯一,存在歧义,记录 warn 日志)
|
||||
|
||||
#### Scenario: 标识符完全不存在
|
||||
- **WHEN** 管理员输入的 identifier 在注册表和 fallback 均未找到
|
||||
- **THEN** 返回 HTTP 404,错误消息"资产不存在"
|
||||
@@ -0,0 +1,35 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 客户端创建套餐购买订单
|
||||
|
||||
系统 SHALL 允许个人客户为其资产创建套餐购买订单。**[BREAKING]** 请求体改为使用资产标识符(identifier),废弃 `iot_card_id`/`device_id` 主键字段和 `order_type` 字段。
|
||||
|
||||
**客户端订单创建请求(CreateOrderRequest)**:
|
||||
```json
|
||||
{
|
||||
"identifier": "string(资产标识符,ICCID 或 VirtualNo,必填,1-50字符)",
|
||||
"package_ids": "[uint](套餐 ID 列表,必填,1-10 个)",
|
||||
"payment_method": "string(wallet|wechat|alipay,必填)"
|
||||
}
|
||||
```
|
||||
|
||||
**废弃字段**:
|
||||
- `iot_card_id`(原单卡购买时必填)
|
||||
- `device_id`(原设备购买时必填)
|
||||
- `order_type`(改为系统根据 identifier 解析结果自动填入)
|
||||
|
||||
#### Scenario: 个人客户使用 VirtualNo 购买设备套餐
|
||||
- **WHEN** 个人客户发送 `{ identifier: "DEV-001", package_ids: [5], payment_method: "wechat" }`
|
||||
- **THEN** 系统解析 identifier 为设备,创建设备购买订单,微信支付流程正常触发
|
||||
|
||||
#### Scenario: 个人客户使用 ICCID 购买单卡套餐
|
||||
- **WHEN** 个人客户发送 `{ identifier: "898600XXXXX", package_ids: [3], payment_method: "wallet" }`
|
||||
- **THEN** 系统解析为独立 IoT 卡(`is_standalone = true`),创建单卡购买订单
|
||||
|
||||
#### Scenario: 个人客户尝试为绑定设备的卡购买套餐
|
||||
- **WHEN** 个人客户发送 identifier 对应一张 `is_standalone = false` 的卡
|
||||
- **THEN** 系统返回错误"该卡已绑定设备,请前往设备页面购买套餐"
|
||||
|
||||
#### Scenario: 个人客户只能操作自己绑定的资产
|
||||
- **WHEN** 个人客户发送的 identifier 对应的资产不属于该客户
|
||||
- **THEN** 系统返回 HTTP 403"无权限操作该资源"
|
||||
@@ -0,0 +1,66 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 创建套餐购买订单
|
||||
|
||||
系统 SHALL 允许买家创建套餐购买订单。**[BREAKING]** 请求体改为使用资产标识符(identifier),废弃 `iot_card_id`/`device_id` 主键字段;`order_type` 字段由系统根据 identifier 解析结果自动填入,请求体不再接受。
|
||||
|
||||
**后台订单创建请求(CreateAdminOrderRequest)**:
|
||||
```json
|
||||
{
|
||||
"identifier": "string(资产标识符,ICCID 或 VirtualNo,必填)",
|
||||
"package_ids": "[uint](套餐 ID 列表,必填,1-10 个)",
|
||||
"payment_method": "string(wallet|offline,必填)"
|
||||
}
|
||||
```
|
||||
|
||||
**废弃字段**:
|
||||
- `iot_card_id`(原用于单卡购买)
|
||||
- `device_id`(原用于设备购买)
|
||||
- `order_type`(改为系统自动推断)
|
||||
|
||||
#### Scenario: 使用 VirtualNo 创建设备订单
|
||||
- **WHEN** 管理员请求体携带 `{ identifier: "DEV-001", package_ids: [5], payment_method: "wallet" }`
|
||||
- **THEN** 系统解析 identifier 为设备,自动设置 `order_type = device`,创建设备购买订单
|
||||
|
||||
#### Scenario: 使用 ICCID 创建单卡订单
|
||||
- **WHEN** 管理员请求体携带 `{ identifier: "898600XXXXX", package_ids: [5], payment_method: "wallet" }`
|
||||
- **THEN** 系统解析 identifier 为独立 IoT 卡(`is_standalone = true`),自动设置 `order_type = single_card`,创建单卡购买订单
|
||||
|
||||
#### Scenario: 使用绑定设备的卡 ICCID 创建订单被拒
|
||||
- **WHEN** 管理员请求体携带 `{ identifier: "898600XXXXX" }` 但该卡 `is_standalone = false`
|
||||
- **THEN** 系统返回错误"该卡已绑定设备,请前往设备页面购买套餐",订单不创建
|
||||
|
||||
#### Scenario: identifier 不存在
|
||||
- **WHEN** 请求的 identifier 无法解析到任何资产
|
||||
- **THEN** 返回错误"资产不存在",订单不创建
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 订单响应包含资产标识符
|
||||
|
||||
订单响应(OrderResponse)SHALL 包含资产标识符字段,前端无需额外请求即可展示资产信息。
|
||||
|
||||
**新增响应字段**:
|
||||
- `asset_identifier`:下单时资产的标识符快照(ICCID 或 VirtualNo)
|
||||
- `asset_type`:资产类型(`single_card` 对应 `iot_card`,`device` 对应 `device`)
|
||||
|
||||
#### Scenario: 查看订单详情时展示资产标识符
|
||||
- **WHEN** 管理员查询订单详情 `GET /api/admin/orders/:id`
|
||||
- **THEN** 响应中包含 `asset_identifier`(如 "DEV-001")和 `asset_type`(如 "device")
|
||||
- **THEN** 即使原资产已被删除,`asset_identifier` 仍可读(快照字段)
|
||||
|
||||
### Requirement: 订单列表支持按资产标识符过滤
|
||||
|
||||
系统 SHALL 在订单列表查询(OrderListRequest)中支持 `identifier` 过滤参数,按照资产标识符精确匹配订单(匹配 `asset_identifier` 快照字段)。
|
||||
|
||||
#### Scenario: 按 ICCID 查询该卡的历史订单
|
||||
- **WHEN** 管理员查询 `GET /api/admin/orders?identifier=898600XXXXX`
|
||||
- **THEN** 返回 `asset_identifier = "898600XXXXX"` 的所有订单(分页)
|
||||
|
||||
#### Scenario: 按设备 VirtualNo 查询该设备的历史订单
|
||||
- **WHEN** 管理员查询 `GET /api/admin/orders?identifier=DEV-001`
|
||||
- **THEN** 返回 `asset_identifier = "DEV-001"` 的所有订单(分页)
|
||||
|
||||
#### Scenario: 标识符无匹配订单
|
||||
- **WHEN** 管理员查询不存在订单的 identifier
|
||||
- **THEN** 返回空列表(`items: []`,`total: 0`),不返回错误
|
||||
@@ -0,0 +1,19 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 已绑定设备的卡不允许单独购买套餐
|
||||
|
||||
购买套餐验证时,系统 MUST 检查目标 IoT 卡是否为独立卡(`is_standalone = true`)。若卡已绑定设备(`is_standalone = false`),必须拒绝购买并引导用户至对应设备页面操作。
|
||||
|
||||
此规则适用于所有套餐购买入口(C 端个人客户、B 端代理/平台)。
|
||||
|
||||
#### Scenario: 独立卡正常购买
|
||||
- **WHEN** 买家为 IoT 卡购买套餐,该卡的 `is_standalone = true`(未绑定任何设备)
|
||||
- **THEN** 验证通过,继续后续购买流程
|
||||
|
||||
#### Scenario: 已绑定设备的卡被单独购买
|
||||
- **WHEN** 买家使用 IoT 卡的 ICCID 或 VirtualNo 购买套餐,该卡的 `is_standalone = false`
|
||||
- **THEN** 系统拒绝购买,返回错误码 `CodeInvalidParam`,错误消息"该卡已绑定设备,请前往设备页面购买套餐"
|
||||
|
||||
#### Scenario: B 端代理通过卡标识符购买套餐(绑定了设备的卡)
|
||||
- **WHEN** 代理使用已绑定设备的卡的 ICCID 创建订单
|
||||
- **THEN** 系统在 `ValidateCardPurchase()` 中检查 `is_standalone`,返回错误,订单不创建
|
||||
@@ -0,0 +1,81 @@
|
||||
## 1. 数据库迁移:全局标识符注册表
|
||||
|
||||
- [x] 1.1 创建迁移文件:`tb_asset_identifier` 表(identifier UNIQUE、asset_type、asset_id、created_at),建立 `idx_asset_identifier_asset(asset_type, asset_id)` 索引
|
||||
- [x] 1.2 执行迁移,验证表结构和唯一约束正确
|
||||
|
||||
## 2. 数据层:AssetIdentifier 模型与 Store
|
||||
|
||||
- [x] 2.1 创建 `internal/model/asset_identifier.go`:定义 `AssetIdentifier` 结构体(含 GORM 标签、TableName)
|
||||
- [x] 2.2 创建 `internal/store/postgres/asset_identifier_store.go`:实现 `Register(ctx, identifier, assetType, assetID)`(写入注册表)、`FindByIdentifier(ctx, identifier)`(精确查询)、`DeleteByAsset(ctx, assetType, assetID)`(删除资产时清理)
|
||||
- [x] 2.3 在 `internal/bootstrap/stores.go` 注入 `AssetIdentifierStore`
|
||||
|
||||
## 3. 数据层:Order 模型新增 asset_identifier 字段
|
||||
|
||||
- [x] 3.1 创建迁移文件:`tb_order` 表增加 `asset_identifier VARCHAR(100)` 字段
|
||||
- [x] 3.2 更新 `internal/model/order.go`:新增 `AssetIdentifier string` 字段(含 GORM 标签和注释)
|
||||
- [x] 3.3 更新 `internal/store/postgres/order_store.go`:列表查询新增 `identifier` 过滤条件(精确匹配 `asset_identifier`)
|
||||
|
||||
## 4. 服务层:Resolve 改走注册表
|
||||
|
||||
- [x] 4.1 更新 `internal/service/asset/service.go`:`Resolve` 方法优先查 `AssetIdentifierStore.FindByIdentifier()`,命中则按 asset_type 查对应表;未命中 fallback 原有逻辑
|
||||
- [x] 4.2 在 `AssetService` 中注入 `AssetIdentifierStore` 依赖
|
||||
|
||||
## 5. 服务层:purchase_validation 增加 is_standalone 校验
|
||||
|
||||
- [x] 5.1 更新 `internal/service/purchase_validation/service.go`:在 `ValidateCardPurchase()` 中增加 `if !card.IsStandalone { return error "该卡已绑定设备,请前往设备页面购买套餐" }` 校验(在套餐校验之前执行)
|
||||
|
||||
## 6. 服务层:资产创建/删除时同步注册表
|
||||
|
||||
- [x] 6.1 更新设备导入任务 `internal/task/device_import.go`:创建设备成功后在同一事务内调用 `AssetIdentifierStore.Register()` 注册 VirtualNo;若冲突则拒绝该行
|
||||
- [x] 6.2 更新 IoT 卡导入任务 `internal/task/iot_card_import.go`:创建卡成功后注册 ICCID(必填)和 VirtualNo(如有)到注册表;冲突则拒绝该行
|
||||
- [x] 6.3 更新设备/IoT 卡的软删除逻辑:删除时调用 `AssetIdentifierStore.DeleteByAsset()` 清理注册表
|
||||
|
||||
## 7. 服务层:订单创建改用 identifier
|
||||
|
||||
- [x] 7.1 更新 `internal/service/order/service.go`(B 端):`Create()` 方法接收 `identifier` 字符串,内部调用 `AssetService.Resolve()` 解析资产,不再接受 `iot_card_id`/`device_id`;写入订单时填入 `asset_identifier` 快照字段
|
||||
- [x] 7.2 更新 `internal/service/client_order/service.go`(C 端):同上,接收 `identifier`,解析后写入 `asset_identifier` 快照
|
||||
|
||||
## 8. DTO 层:更新请求/响应结构
|
||||
|
||||
- [x] 8.1 更新 `internal/model/dto/order_dto.go`:`CreateAdminOrderRequest` 去掉 `IotCardID`/`DeviceID`/`OrderType`,新增 `Identifier string`;`OrderResponse` 新增 `AssetIdentifier string` 和 `AssetType string`;`OrderListRequest` 新增 `Identifier string`
|
||||
- [x] 8.2 更新 `internal/model/dto/client_order_dto.go`(`CreateOrderRequest`):同上,去掉 `IotCardID`/`DeviceID`/`OrderType`,新增 `Identifier string`
|
||||
- [x] 8.3 更新 `internal/model/dto/asset_dto.go`:`AssetTypeIDRequest` 废弃,`AssetResolveRequest` 确认 `Identifier` 字段已有;新增 `AssetIdentifierRequest`(路径参数,供操作接口使用)
|
||||
- [x] 8.4 确认 `AssetResolveResponse` 新增 `Identifier` 字段(回传请求中使用的标识符)
|
||||
|
||||
## 9. Handler 层:B 端资产操作接口更新
|
||||
|
||||
- [x] 9.1 更新 `internal/handler/admin/asset.go`:`RealtimeStatus`/`Refresh`/`Packages`/`CurrentPackage`/`UpdatePollingStatus`/`StopDevice`/`StartDevice`/`StopCard`/`StartCard` 均改为从路径参数 `:identifier` 读取标识符,调用 Resolve 解析后操作;合并 Stop/Start 卡和设备为统一的 `Stop`/`Start` handler(内部按资产类型分支)
|
||||
- [x] 9.2 更新 `internal/handler/admin/asset.go`:新增 `Deactivate` handler(合并 IoT 卡和设备停用,原 `/iot-cards/:id/deactivate` 和 `/devices/:id/deactivate`)
|
||||
- [x] 9.3 更新 `internal/handler/admin/device.go`:`Delete`/`ListCards`/`BindCard`/`UnbindCard` 改为从路径参数 `:virtual_no` 读取设备标识;`UnbindCard` 第二段路径参数改为 `:iccid`
|
||||
|
||||
## 10. Handler 层:B 端订单 Handler 更新
|
||||
|
||||
- [x] 10.1 更新 `internal/handler/admin/order.go`:`Create` handler 解析 `CreateAdminOrderRequest.Identifier` 字段,移除 `IotCardID`/`DeviceID` 解析逻辑;`List` handler 支持 `identifier` query 参数传入 Store 过滤
|
||||
|
||||
## 11. Handler 层:C 端订单 Handler 更新
|
||||
|
||||
- [x] 11.1 更新 `internal/handler/app/client_order.go`:`CreateOrder` handler 解析 `CreateOrderRequest.Identifier`,移除旧字段解析
|
||||
|
||||
## 12. 路由层:重注册所有受影响路由
|
||||
|
||||
- [x] 12.1 更新 `internal/routes/asset.go`:将所有 `:asset_type/:id` 路由改为 `:identifier`;合并卡/设备的 stop/start/deactivate 路由
|
||||
- [x] 12.2 更新 `internal/routes/device.go`:`/:id` 改为 `/:virtual_no`;解绑路由 `/:id/cards/:cardId` 改为 `/:virtual_no/cards/:iccid`
|
||||
- [x] 12.3 更新 `internal/routes/iot_card.go`:移除 `/:id/deactivate`(已并入 asset.go 的统一路由)
|
||||
- [x] 12.4 更新 `internal/routes/order.go`:无路由变更,仅确认 Handler 已更新
|
||||
|
||||
## 13. 文档生成器更新
|
||||
|
||||
- [x] 13.1 更新 `cmd/api/docs.go` 和 `cmd/gendocs/main.go`:确保新 Handler 方法已注册(Deactivate 等新合并 handler 需加入)
|
||||
- [x] 13.2 执行 `go run cmd/gendocs/main.go` 重新生成 OpenAPI 文档,验证新路由和 DTO 正确体现
|
||||
|
||||
## 14. 验证
|
||||
|
||||
- [x] 14.1 构建验证:`go build ./...` 无编译错误
|
||||
- [x] 14.2 LSP 诊断:对所有修改文件运行 lsp_diagnostics,无错误/警告
|
||||
- [ ] 14.3 接口验证(使用 PostgreSQL MCP 和 curl):
|
||||
- 通过 ICCID 调用 `GET /api/admin/assets/:identifier/packages` 返回正确套餐列表
|
||||
- 通过 VirtualNo 调用 `POST /api/admin/assets/:identifier/stop` 执行停机
|
||||
- 使用绑定设备的卡 identifier 创建订单,验证返回"该卡已绑定设备"错误
|
||||
- 使用独立卡 identifier 创建订单成功,OrderResponse 包含 `asset_identifier` 字段
|
||||
- 并发写入同一 VirtualNo 到注册表,验证只有一条成功
|
||||
- [ ] 14.4 订单列表验证:`GET /api/admin/orders?identifier=CARD-001` 返回该资产的订单
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-07
|
||||
@@ -0,0 +1,64 @@
|
||||
## Context
|
||||
|
||||
**设备**:`tb_device.virtual_no` 已是 `NOT NULL + UNIQUE`(数据库层已强制)。但 `pkg/utils/excel.go` 中设备行解析遇到空 VirtualNo 会 `continue`(跳过该行),导入任务报告跳过数量但不报告失败原因,用户无法知道为什么某些行没被导入。
|
||||
|
||||
**IoT 卡**:`tb_iot_card.virtual_no` 当前为 nullable,唯一索引条件是 `WHERE deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> ''`(允许多条 NULL 值)。导入时空 VirtualNo 的卡直接进库,不做任何处理。
|
||||
|
||||
两者均需要把"安静跳过/允许空值"改为"明确报错",并在数据库层为 IoT 卡补上 NOT NULL 约束。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 导入时 VirtualNo 为空 → 报告为失败行,包含行号和原因
|
||||
- `tb_iot_card.virtual_no` 加 NOT NULL 约束
|
||||
- 更新 Excel 解析层、任务处理层、模型层
|
||||
|
||||
**Non-Goals:**
|
||||
- 为现有 NULL 记录自动生成 VirtualNo(测试阶段直接清库)
|
||||
- IoT 卡导入的其他字段校验(不在本提案范围内)
|
||||
- VirtualNo 格式校验(长度/字符集约束,留给未来)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策 1:在 Excel 解析层(pkg/utils/excel.go)拦截空值,不在任务层
|
||||
|
||||
**选择**:在 `parseCardRows()` 和 `parseDeviceRows()` 中,当 VirtualNo 为空时,将该行加入解析错误列表(ParseError),而非 `continue` 跳过
|
||||
|
||||
**备选方案**:在任务处理层(`iot_card_import.go` / `device_import.go`)拦截。问题:任务层已有处理批次逻辑,较复杂;解析层更早发现问题,更符合"fail fast"原则
|
||||
|
||||
**理由**:越早发现越好;解析层返回 ParseErrors 与当前批量验证逻辑(ICCID 格式校验已在此处)风格一致
|
||||
|
||||
### 决策 2:IoT 卡数据库层迁移分两步
|
||||
|
||||
1. **先清库**(测试阶段手动执行):`DELETE FROM tb_iot_card WHERE virtual_no IS NULL OR virtual_no = ''`
|
||||
2. **再加约束**(迁移文件):
|
||||
- `ALTER TABLE tb_iot_card ALTER COLUMN virtual_no SET NOT NULL`
|
||||
- 重建唯一索引,去掉 `WHERE virtual_no IS NOT NULL AND virtual_no <> ''` 条件,改为无条件唯一
|
||||
|
||||
**理由**:先清数据再加约束,迁移不会失败;两步操作在同一迁移文件中完成,原子执行(PostgreSQL 支持事务内 DDL)
|
||||
|
||||
### 决策 3:设备导入不需要数据库迁移,只改导入行为
|
||||
|
||||
设备的 VirtualNo 数据库层已是 NOT NULL,不需要迁移。只需把 `excel.go` 中 `if row.VirtualNo == "" { continue }` 改为加入失败列表即可。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| 风险 | 缓解措施 |
|
||||
|------|---------|
|
||||
| **现有使用方的 Excel 模板无 VirtualNo 列** | 属于 BREAKING 变更,提前通知;导入失败信息明确说明"VirtualNo 为必填列" |
|
||||
| **迁移前 IoT 卡存在 NULL 记录导致 ALTER 失败** | 迁移文件中先执行 DELETE 清理(适用测试环境),再 ALTER;生产环境需手动确认数据干净后执行 |
|
||||
| **IoT 卡在注册表(提案一)中未注册 VirtualNo** | 本提案独立于提案一;若提案一先完成,IoT 卡导入时需同步注册 VirtualNo 到注册表(在提案一 task 6.2 中已覆盖) |
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 清理测试库中 `virtual_no IS NULL` 的 IoT 卡记录
|
||||
2. 执行数据库迁移:`tb_iot_card.virtual_no` 加 NOT NULL,重建唯一索引
|
||||
3. 更新 `pkg/utils/excel.go`(解析层)
|
||||
4. 更新 `internal/task/iot_card_import.go`(任务层,去除残余跳过逻辑)
|
||||
5. 更新 `internal/task/device_import.go`(任务层,同上)
|
||||
6. 更新 `internal/model/iot_card.go`(GORM tag)
|
||||
7. 更新文档 `docs/excel-import-frontend-guide.md`
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 无
|
||||
@@ -0,0 +1,38 @@
|
||||
## Why
|
||||
|
||||
虚拟号(VirtualNo)是系统中资产的核心业务标识符,也是提案一确立的接口参数规范的基础。然而当前导入流程将其作为可选字段:IoT 卡导入时 VirtualNo 可为空(数据库层 nullable),设备导入时 VirtualNo 为空的行会被静默跳过而非报错。这导致可能存在没有虚拟号的资产进入系统,无法通过统一标识符接口操作,与资产标识符规范化目标冲突。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **[BREAKING]** IoT 卡导入:VirtualNo 改为必填,Excel 中 VirtualNo 列为空的行报错拒绝
|
||||
- **[BREAKING]** 设备导入:VirtualNo 为空的行从原有的"静默跳过"改为"记录为失败行"并报告原因
|
||||
- **[NEW]** 数据库迁移:`tb_iot_card.virtual_no` 添加 NOT NULL 约束和去除条件唯一索引改为无条件唯一索引(配合清库执行)
|
||||
- 更新 Excel 导入错误提示,明确 VirtualNo 为必填项
|
||||
- 更新接口文档,标注 VirtualNo 为必填列
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
(无新能力——本提案为约束强化,不新增业务功能)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `iot-card-import-task`:VirtualNo 校验规则从"可选"改为"必填",空值触发失败行记录
|
||||
- `device-import`:VirtualNo 校验规则从"跳过空值行"改为"空值行计入失败并报告原因"
|
||||
|
||||
## Impact
|
||||
|
||||
**受影响的代码**:
|
||||
- `pkg/utils/excel.go`:`parseCardRows()` 和设备行解析逻辑,增加 VirtualNo 空值校验
|
||||
- `internal/task/iot_card_import.go`:移除"空 VirtualNo 跳过"逻辑,改为记录失败行
|
||||
- `internal/task/device_import.go`:将"空 VirtualNo 跳过"改为"记录失败行"
|
||||
- `internal/model/iot_card.go`:`VirtualNo` 字段 GORM tag 更新,去掉 nullable,更新注释
|
||||
- `migrations/`:新增迁移文件,为 `tb_iot_card.virtual_no` 加 NOT NULL 约束
|
||||
|
||||
**前置条件**:
|
||||
- 测试数据库中现有 `virtual_no IS NULL` 的 IoT 卡记录需先清除(测试阶段直接清库)
|
||||
- 设备的 `virtual_no` 在数据库层已经是 NOT NULL,无需迁移,仅修改导入行为
|
||||
|
||||
**破坏性影响**:
|
||||
- 现有 IoT 卡导入 Excel 模板如果没有 VirtualNo 列,导入将全部失败;需通知使用方更新模板
|
||||
@@ -0,0 +1,22 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 设备批量导入
|
||||
|
||||
系统 SHALL 支持通过 Excel 文件批量导入设备。**[BREAKING]** `virtual_no` 为空的行行为变更:原行为为"静默跳过(skip)",改为"记录为失败行(fail)并报告原因"。
|
||||
|
||||
**失败原因文本(新增)**:
|
||||
- VirtualNo 为空:`"设备虚拟号(virtual_no)不能为空"`
|
||||
|
||||
#### Scenario: 正常导入(VirtualNo 有值)
|
||||
- **WHEN** Excel 中某行 virtual_no="DEV-001",其他字段合法
|
||||
- **THEN** 导入成功,设备记录写入数据库
|
||||
|
||||
#### Scenario: VirtualNo 为空的行记录为失败(原为跳过)
|
||||
- **WHEN** Excel 中某行 virtual_no 列为空或未填写
|
||||
- **THEN** 该行计入失败(`fail_count++`),失败原因为"设备虚拟号(virtual_no)不能为空"
|
||||
- **THEN** 该行不再计入 `skip_count`
|
||||
- **THEN** 其他合法行继续导入,不因此行中断
|
||||
|
||||
#### Scenario: 导入任务结果报告包含 VirtualNo 失败原因
|
||||
- **WHEN** 导入任务处理完毕,含有 VirtualNo 为空的行
|
||||
- **THEN** 失败明细列表中,该行的 `reason` 字段为"设备虚拟号(virtual_no)不能为空",`line` 字段为对应行号
|
||||
@@ -0,0 +1,40 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: IoT 卡批量导入
|
||||
|
||||
系统 SHALL 支持通过 Excel 文件批量导入 IoT 卡。**[BREAKING]** `virtual_no` 列由可选改为必填:Excel 中 `virtual_no` 列为空的行,系统 MUST 将其记录为失败行并报告原因,不得静默跳过或将其写入数据库。
|
||||
|
||||
**必填列(更新后)**:
|
||||
- `iccid`(必填):ICCID,电信 19 位/其他 20 位,格式校验
|
||||
- `virtual_no`(**必填,由可选改为必填**):虚拟号,全局唯一,1-50 字符
|
||||
- `msisdn`(可选):手机号/接入号
|
||||
|
||||
**失败原因文本(中文)**:
|
||||
- ICCID 为空:`"ICCID 不能为空"`
|
||||
- ICCID 格式错误:`"ICCID 格式错误,应为19-20位数字"`
|
||||
- VirtualNo 为空:`"虚拟号(virtual_no)不能为空"`(新增)
|
||||
- VirtualNo 已被占用:`"虚拟号已被占用: <值>"`(已有)
|
||||
- ICCID 已存在:`"ICCID 已存在: <值>"`(已有)
|
||||
|
||||
#### Scenario: 正常导入(ICCID 和 VirtualNo 均有值)
|
||||
- **WHEN** Excel 中某行 ICCID="898600XXXXX",virtual_no="CARD-001"
|
||||
- **THEN** 导入成功,卡记录写入数据库,VirtualNo 和 ICCID 同步注册到 `tb_asset_identifier`
|
||||
|
||||
#### Scenario: VirtualNo 为空的行被拒绝
|
||||
- **WHEN** Excel 中某行 ICCID="898600YYYYY",virtual_no 列为空或未填写
|
||||
- **THEN** 该行计入失败,失败原因为"虚拟号(virtual_no)不能为空"
|
||||
- **THEN** 其他合法行继续导入,不因此行中断
|
||||
|
||||
#### Scenario: VirtualNo 重复被拒绝
|
||||
- **WHEN** Excel 中某行的 virtual_no 与已有卡/设备的 VirtualNo 重复(跨表)
|
||||
- **THEN** 该行计入失败,原因为"虚拟号已被占用: <值>"
|
||||
|
||||
#### Scenario: Excel 文件中无 VirtualNo 列
|
||||
- **WHEN** Excel 表头中不包含 `virtual_no`/`虚拟号`/`设备号` 等可识别列名
|
||||
- **THEN** 所有数据行均因 VirtualNo 为空而失败,导入结果中 `fail_count = 总行数`,`success_count = 0`
|
||||
- **THEN** 返回错误提示:"Excel 文件缺少 virtual_no 列,请使用最新模板"
|
||||
|
||||
#### Scenario: 导入任务完成后的结果报告
|
||||
- **WHEN** 导入任务处理完毕
|
||||
- **THEN** 结果包含:`success_count`、`fail_count`、`skip_count`、失败明细列表(含行号、原因)
|
||||
- **THEN** VirtualNo 为空的失败行在明细中明确体现行号和原因
|
||||
@@ -0,0 +1,66 @@
|
||||
## 1. 数据库迁移:IoT 卡 VirtualNo NOT NULL
|
||||
|
||||
- [x] 1.1 手动清理测试库:执行 `DELETE FROM tb_iot_card WHERE virtual_no IS NULL OR virtual_no = ''`,确认影响行数为 0 后继续(或直接清库)
|
||||
- [x] 1.2 创建迁移文件(如 `000XXX_iot_card_virtual_no_not_null.up.sql`):
|
||||
```sql
|
||||
ALTER TABLE tb_iot_card ALTER COLUMN virtual_no SET NOT NULL;
|
||||
DROP INDEX IF EXISTS idx_iot_card_virtual_no;
|
||||
CREATE UNIQUE INDEX idx_iot_card_virtual_no ON tb_iot_card(virtual_no) WHERE deleted_at IS NULL;
|
||||
```
|
||||
- [x] 1.3 创建对应 down 迁移文件(回滚为 nullable)
|
||||
- [x] 1.4 执行迁移,验证约束生效(尝试插入 virtual_no=NULL 的记录应失败)
|
||||
|
||||
## 2. 模型层:更新 IotCard GORM Tag
|
||||
|
||||
- [x] 2.1 更新 `internal/model/iot_card.go` 中 `VirtualNo` 字段的 GORM tag:
|
||||
- 去掉 `omitempty`(JSON tag)
|
||||
- 将唯一索引条件从 `where:deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> ''` 简化为 `where:deleted_at IS NULL`
|
||||
- 更新字段注释:从"虚拟号(可空,全局唯一)"改为"虚拟号(必填,全局唯一)"
|
||||
|
||||
## 3. Excel 解析层:IoT 卡 VirtualNo 必填校验
|
||||
|
||||
- [x] 3.1 更新 `pkg/utils/excel.go` 的 `parseCardRows()` 函数(或等效解析逻辑):
|
||||
- 当 `virtualNo == ""` 时,不再跳过,而是将该行追加到解析错误列表:`ParseErrors = append(ParseErrors, ParseError{Line: lineNum, Reason: "虚拟号(virtual_no)不能为空"})`
|
||||
- 若 Excel 文件中完全没有 VirtualNo 列(`virtualNoCol == -1`),在函数开头直接返回错误:`return nil, errors.New(errors.CodeInvalidParam, "Excel 文件缺少 virtual_no 列,请使用最新模板")`
|
||||
- 确认函数返回值能携带 ParseErrors(若当前无此结构,需扩展返回类型)
|
||||
|
||||
## 4. Excel 解析层:设备 VirtualNo 改为失败行
|
||||
|
||||
- [x] 4.1 更新 `pkg/utils/excel.go` 的设备行解析逻辑:
|
||||
- 找到 `if row.VirtualNo == "" { continue }` 代码(当前静默跳过)
|
||||
- 改为将该行追加到失败列表:`failedRows = append(failedRows, FailedRow{Line: row.Line, Reason: "设备虚拟号(virtual_no)不能为空"})`
|
||||
- 确认 `skip_count` 不再计入此类行,改计入 `fail_count`
|
||||
|
||||
## 5. 任务处理层:IoT 卡导入移除残余跳过逻辑
|
||||
|
||||
- [x] 5.1 检查 `internal/task/iot_card_import.go` 中所有 `if card.VirtualNo != ""` 条件:
|
||||
- 唯一性检查处(当前只对非空 VirtualNo 检查):移除条件,强制检查所有行的 VirtualNo
|
||||
- 批内去重处:同样移除条件
|
||||
- 若 VirtualNo 为空的行此时能到达任务处理层(不应发生,因解析层已拦截),也在此处记录失败并跳过
|
||||
|
||||
## 6. 任务处理层:设备导入统计修正
|
||||
|
||||
- [x] 6.1 检查 `internal/task/device_import.go` 中处理空 VirtualNo 行的逻辑:
|
||||
- 确认 Excel 解析层改动后,空 VirtualNo 行已以 `FailedRow` 形式传入,不再需要任务层的额外 `continue` 跳过
|
||||
- 验证 `result.failCount` 正确累加,`result.skipCount` 不受影响
|
||||
|
||||
## 7. 文档更新
|
||||
|
||||
- [x] 7.1 更新 `docs/excel-import-frontend-guide.md` 及路由描述(`internal/routes/iot_card.go`, `internal/routes/device.go`):
|
||||
- IoT 卡导入模板字段表:`virtual_no` 列的"必填"列从"否"改为"是"
|
||||
- 新增说明:"`virtual_no` 为必填列,留空将导致该行导入失败"
|
||||
- 设备导入模板字段表:`virtual_no` 列同样标注"必填",并说明"留空不再跳过,将记录为失败行"
|
||||
|
||||
## 8. 验证
|
||||
|
||||
- [x] 8.1 构建验证:`go build ./...` 无编译错误(本次修改包 build OK;bootstrap 已有预存在错误与本次无关)
|
||||
- [x] 8.2 LSP 诊断:对所有修改文件运行 lsp_diagnostics,无错误/警告
|
||||
- [ ] 8.3 IoT 卡导入验证(使用 Hurl 或 curl):
|
||||
- 上传含完整 VirtualNo 的 Excel:导入成功,success_count 正确
|
||||
- 上传含空 VirtualNo 行的 Excel:该行 fail_count+1,reason 为"虚拟号(virtual_no)不能为空",其他行正常导入
|
||||
- 上传无 VirtualNo 列的 Excel:全部失败,返回"Excel 文件缺少 virtual_no 列"
|
||||
- 上传重复 VirtualNo 的 Excel:该行 fail_count+1,reason 为"虚拟号已被占用: <值>"
|
||||
- [ ] 8.4 设备导入验证:
|
||||
- 上传含空 VirtualNo 行的 Excel:该行计入 fail_count(而非 skip_count),reason 为"设备虚拟号(virtual_no)不能为空"
|
||||
- [x] 8.5 数据库约束验证(PostgreSQL MCP):
|
||||
- `virtual_no` 列 is_nullable=NO 已验证;新索引 `CREATE UNIQUE INDEX idx_iot_card_virtual_no ON tb_iot_card(virtual_no) WHERE deleted_at IS NULL` 已验证
|
||||
106
openspec/specs/asset-historical-orders/spec.md
Normal file
106
openspec/specs/asset-historical-orders/spec.md
Normal file
@@ -0,0 +1,106 @@
|
||||
### Requirement: 查询资产本代历史订单
|
||||
|
||||
系统 SHALL 提供接口,让管理员查询某资产本代(当前世代)的全部历史订单,支持分页。
|
||||
|
||||
**API 端点**:`GET /api/admin/assets/:identifier/orders`
|
||||
|
||||
**请求参数**:
|
||||
- `:identifier`(路径):资产标识符(ICCID 或 VirtualNo),必填
|
||||
- `page`(query):页码,默认 1
|
||||
- `page_size`(query):每页数量,默认 20,最大 100
|
||||
- `include_previous`(query):是否包含前代订单,布尔值,默认 false
|
||||
|
||||
**权限规则**:
|
||||
- 代理用户:只能查看数据权限范围内资产的订单
|
||||
- 平台/超管:可查看所有资产订单
|
||||
- 企业账号:不支持此接口(返回 403)
|
||||
|
||||
#### Scenario: 查询本代订单(默认)
|
||||
- **WHEN** 管理员请求 `GET /api/admin/assets/DEV-001/orders`
|
||||
- **THEN** 返回该资产(当前世代)的订单列表,按创建时间倒序,支持分页
|
||||
- **THEN** 响应中每条订单包含 `generation` 字段,值为资产当前世代编号
|
||||
|
||||
#### Scenario: 资产无订单
|
||||
- **WHEN** 管理员查询一个从未购买过套餐的资产
|
||||
- **THEN** 返回 `{ items: [], total: 0, page: 1 }`,不返回错误
|
||||
|
||||
#### Scenario: identifier 不存在
|
||||
- **WHEN** 请求的 identifier 无法解析到任何资产
|
||||
- **THEN** 返回 HTTP 404,错误消息"资产不存在"
|
||||
|
||||
#### Scenario: 代理查询无权限资产
|
||||
- **WHEN** 代理用户请求不属于其数据权限范围的资产订单
|
||||
- **THEN** 返回 HTTP 403,错误消息"无权限操作该资源或资源不存在"
|
||||
|
||||
### Requirement: 查询资产跨代历史订单(含前代)
|
||||
|
||||
当 `include_previous=true` 时,系统 SHALL 通过换货链追溯前代资产,返回前代订单,并在响应中区分世代来源。
|
||||
|
||||
**追溯逻辑**:
|
||||
1. 通过 `ExchangeOrder.new_asset_id` 逆向查找当前资产的换货来源
|
||||
2. 得到前代的 `old_asset_identifier`,查询该标识符的订单
|
||||
3. 递归追溯,最多向前 10 代(安全上限)
|
||||
|
||||
**响应结构(AssetOrdersResponse)**:
|
||||
```json
|
||||
{
|
||||
"current_generation": {
|
||||
"generation": 2,
|
||||
"identifier": "DEV-001",
|
||||
"asset_type": "device",
|
||||
"total": 5,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"items": [ ...订单列表... ]
|
||||
},
|
||||
"previous_generations": [
|
||||
{
|
||||
"generation": 1,
|
||||
"identifier": "DEV-OLD-001",
|
||||
"asset_type": "device",
|
||||
"exchange_no": "EXC20260101XXXXXX",
|
||||
"exchanged_at": "2026-01-01T00:00:00Z",
|
||||
"total": 3,
|
||||
"items": [ ...前代订单列表(最近20条)... ]
|
||||
}
|
||||
],
|
||||
"truncated": false
|
||||
}
|
||||
```
|
||||
|
||||
**每条订单项(AssetOrderItem)包含**:
|
||||
- `order_no`:订单号
|
||||
- `order_type`:订单类型(single_card / device)
|
||||
- `payment_status`:支付状态
|
||||
- `payment_status_text`:支付状态文本
|
||||
- `total_amount`:订单金额(分)
|
||||
- `payment_method`:支付方式
|
||||
- `paid_at`:支付时间(可空)
|
||||
- `generation`:订单所属资产世代
|
||||
- `items`:套餐明细列表
|
||||
- `created_at`:订单创建时间
|
||||
|
||||
#### Scenario: 查询换货后资产的全代际订单
|
||||
- **WHEN** 管理员请求 `GET /api/admin/assets/DEV-001/orders?include_previous=true`,DEV-001 是换货后的新设备(第2代),原设备为 DEV-OLD-001(第1代)
|
||||
- **THEN** `current_generation` 包含 DEV-001 本代的订单(generation=2)
|
||||
- **THEN** `previous_generations[0]` 包含 DEV-OLD-001 的订单(generation=1),附带换货单号和换货时间
|
||||
- **THEN** `truncated=false`(未超出追溯上限)
|
||||
|
||||
#### Scenario: 资产本身就是第一代(无前代)
|
||||
- **WHEN** 管理员请求带 `include_previous=true`,但该资产从未经过换货
|
||||
- **THEN** `previous_generations` 为空数组 `[]`
|
||||
- **THEN** `current_generation` 正常返回本代订单
|
||||
|
||||
#### Scenario: 换货链超过追溯上限
|
||||
- **WHEN** 换货链深度超过 10 代
|
||||
- **THEN** 追溯在第 10 代截断,`truncated=true`
|
||||
- **THEN** 已追溯到的前代数据正常返回
|
||||
|
||||
#### Scenario: 前代订单分页
|
||||
- **WHEN** 请求带 `include_previous=true`
|
||||
- **THEN** 分页参数(page/page_size)只对 `current_generation` 的订单生效
|
||||
- **THEN** 前代订单每代最多返回 20 条(不支持前代内分页)
|
||||
|
||||
#### Scenario: 无 include_previous 时响应不含前代字段
|
||||
- **WHEN** 管理员请求不带 `include_previous=true`(或传 false)
|
||||
- **THEN** 响应结构中 `previous_generations` 字段为 null 或不返回,节省带宽
|
||||
52
openspec/specs/asset-identifier-registry/spec.md
Normal file
52
openspec/specs/asset-identifier-registry/spec.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Capability: 全局资产标识符注册表
|
||||
|
||||
## Purpose
|
||||
|
||||
维护一张全局唯一的资产标识符注册表(`tb_asset_identifier`),在数据库层保证 IoT 卡的 ICCID/VirtualNo 与设备的 VirtualNo 跨两张表不重复,消除并发写入竞态风险,同时提供高性能的标识符→资产 ID 精确查找能力。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 全局资产标识符注册表
|
||||
|
||||
系统 SHALL 维护一张全局唯一的资产标识符注册表(`tb_asset_identifier`),在数据库层保证 IoT 卡的 ICCID/VirtualNo 与设备的 VirtualNo 跨两张表不重复,消除并发写入竞态风险。
|
||||
|
||||
注册表仅存储**全局唯一标识符**(ICCID、VirtualNo),不存储 IMEI/SN/MSISDN 等非唯一标识符。
|
||||
|
||||
**表字段**:
|
||||
- `id`:自增主键
|
||||
- `identifier`:标识符值(VARCHAR(100),UNIQUE 约束)
|
||||
- `asset_type`:资产类型(`iot_card` 或 `device`)
|
||||
- `asset_id`:对应资产的主键 ID
|
||||
- `created_at`:写入时间
|
||||
|
||||
#### Scenario: 创建设备时注册 VirtualNo
|
||||
- **WHEN** 创建新设备(通过导入或 API),VirtualNo 非空
|
||||
- **THEN** 系统在同一事务内向 `tb_asset_identifier` 写入一条记录(identifier=VirtualNo, asset_type=device, asset_id=新设备ID)
|
||||
- **THEN** 若 VirtualNo 已在注册表中存在,事务回滚,返回错误"虚拟号已被占用"
|
||||
|
||||
#### Scenario: 创建 IoT 卡时注册 ICCID
|
||||
- **WHEN** 创建新 IoT 卡(通过导入),ICCID 非空
|
||||
- **THEN** 系统在同一事务内向注册表写入(identifier=ICCID, asset_type=iot_card, asset_id=新卡ID)
|
||||
- **THEN** 若 ICCID 已在注册表中存在,事务回滚,返回错误"ICCID 已被占用"
|
||||
|
||||
#### Scenario: 创建 IoT 卡时注册 VirtualNo(如有)
|
||||
- **WHEN** 创建新 IoT 卡时 VirtualNo 非空
|
||||
- **THEN** 系统额外向注册表写入(identifier=VirtualNo, asset_type=iot_card, asset_id=新卡ID)
|
||||
- **THEN** 若 VirtualNo 已被其他设备或卡占用,事务回滚,返回错误"虚拟号已被占用"
|
||||
|
||||
#### Scenario: 并发写入同一标识符
|
||||
- **WHEN** 两个并发请求同时尝试注册相同的 VirtualNo(如 "CARD-001")
|
||||
- **THEN** 数据库 UNIQUE 约束保证只有一个写入成功;另一个收到唯一约束冲突错误,事务回滚,返回错误"虚拟号已被占用"
|
||||
|
||||
#### Scenario: 软删除资产时清理注册表
|
||||
- **WHEN** 软删除设备或 IoT 卡
|
||||
- **THEN** 系统在同一事务内删除 `tb_asset_identifier` 中对应的记录(所有 asset_id 匹配的行),允许标识符被后续资产复用
|
||||
|
||||
#### Scenario: 通过标识符精确查找资产
|
||||
- **WHEN** 系统需要根据 identifier(ICCID 或 VirtualNo)定位资产
|
||||
- **THEN** 系统查询 `SELECT * FROM tb_asset_identifier WHERE identifier = ?`,一次查询得到 asset_type 和 asset_id
|
||||
- **THEN** 再按 asset_type 查对应表(`tb_device` 或 `tb_iot_card`)取完整记录
|
||||
|
||||
#### Scenario: 查询不存在的标识符
|
||||
- **WHEN** 查询注册表中不存在的 identifier
|
||||
- **THEN** 返回空结果(not found),调用方可 fallback 到原有查询逻辑
|
||||
86
openspec/specs/asset-identifier-routes/spec.md
Normal file
86
openspec/specs/asset-identifier-routes/spec.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Capability: 资产操作路由标识符标准化
|
||||
|
||||
## Purpose
|
||||
|
||||
定义 B 端资产操作类接口统一使用资产标识符(ICCID 或 VirtualNo)作为路径参数的规范,废弃原有基于数据库主键 ID 的路由,并规定标识符解析的性能要求。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: B 端资产操作接口统一使用标识符路径参数
|
||||
|
||||
B 端所有资产操作类接口 SHALL 使用资产标识符(ICCID 或 VirtualNo)作为路径参数,废弃原有基于数据库主键 ID 的路由。系统内部通过注册表将标识符解析为资产实体,Handler 层无需关心 ID。
|
||||
|
||||
**标识符规则**:
|
||||
- IoT 卡:接受 ICCID 或 VirtualNo(均为全局唯一)
|
||||
- 设备:接受 VirtualNo(全局唯一)
|
||||
- 不接受 IMEI/SN/MSISDN(非唯一,仅 Resolve 的 fallback 路径支持)
|
||||
|
||||
**废弃的旧路由 → 新路由映射**:
|
||||
|
||||
| 旧路由(废弃) | 新路由 |
|
||||
|---|---|
|
||||
| `GET /api/admin/assets/:asset_type/:id/realtime-status` | `GET /api/admin/assets/:identifier/realtime-status` |
|
||||
| `POST /api/admin/assets/:asset_type/:id/refresh` | `POST /api/admin/assets/:identifier/refresh` |
|
||||
| `GET /api/admin/assets/:asset_type/:id/packages` | `GET /api/admin/assets/:identifier/packages` |
|
||||
| `GET /api/admin/assets/:asset_type/:id/current-package` | `GET /api/admin/assets/:identifier/current-package` |
|
||||
| `GET /api/admin/assets/:asset_type/:id/wallet` | `GET /api/admin/assets/:identifier/wallet` |
|
||||
| `GET /api/admin/assets/:asset_type/:id/wallet/transactions` | `GET /api/admin/assets/:identifier/wallet/transactions` |
|
||||
| `PATCH /api/admin/assets/:asset_type/:id/polling-status` | `PATCH /api/admin/assets/:identifier/polling-status` |
|
||||
| `POST /api/admin/assets/device/:device_id/stop` | `POST /api/admin/assets/:identifier/stop` |
|
||||
| `POST /api/admin/assets/device/:device_id/start` | `POST /api/admin/assets/:identifier/start` |
|
||||
| `POST /api/admin/assets/card/:iccid/stop` | `POST /api/admin/assets/:identifier/stop`(合并) |
|
||||
| `POST /api/admin/assets/card/:iccid/start` | `POST /api/admin/assets/:identifier/start`(合并) |
|
||||
| `DELETE /api/admin/devices/:id` | `DELETE /api/admin/devices/:virtual_no` |
|
||||
| `GET /api/admin/devices/:id/cards` | `GET /api/admin/devices/:virtual_no/cards` |
|
||||
| `POST /api/admin/devices/:id/cards` | `POST /api/admin/devices/:virtual_no/cards` |
|
||||
| `DELETE /api/admin/devices/:id/cards/:cardId` | `DELETE /api/admin/devices/:virtual_no/cards/:iccid` |
|
||||
| `PATCH /api/admin/devices/:id/deactivate` | `PATCH /api/admin/assets/:identifier/deactivate` |
|
||||
| `PATCH /api/admin/iot-cards/:id/deactivate` | `PATCH /api/admin/assets/:identifier/deactivate`(合并) |
|
||||
|
||||
#### Scenario: 通过 ICCID 操作 IoT 卡
|
||||
- **WHEN** 管理员请求 `GET /api/admin/assets/898600XXXXXXXX/packages`
|
||||
- **THEN** 系统解析 ICCID,找到对应 IoT 卡,返回该卡的套餐列表
|
||||
|
||||
#### Scenario: 通过 VirtualNo 操作设备
|
||||
- **WHEN** 管理员请求 `POST /api/admin/assets/DEV-001/stop`
|
||||
- **THEN** 系统解析 VirtualNo,找到对应设备,执行批量停机(停该设备下所有已实名卡)
|
||||
|
||||
#### Scenario: 通过 VirtualNo 操作绑定了设备的 IoT 卡(停机)
|
||||
- **WHEN** 管理员请求 `POST /api/admin/assets/CARD-001/stop`,CARD-001 是 IoT 卡的 VirtualNo
|
||||
- **THEN** 系统解析 VirtualNo,找到 IoT 卡,执行单卡停机
|
||||
|
||||
#### Scenario: stop/start 接口对卡和设备行为差异
|
||||
- **WHEN** identifier 解析为 IoT 卡时调用 stop
|
||||
- **THEN** 执行单卡停机
|
||||
- **WHEN** identifier 解析为设备时调用 stop
|
||||
- **THEN** 执行设备停机(批量停机该设备下所有已实名卡)
|
||||
|
||||
#### Scenario: 标识符不存在
|
||||
- **WHEN** 管理员请求的 `:identifier` 在注册表和 fallback 查询中均未找到对应资产
|
||||
- **THEN** 返回 HTTP 404,错误消息"资产不存在"
|
||||
|
||||
#### Scenario: 无权限操作该资产
|
||||
- **WHEN** 代理用户请求的 identifier 对应的资产不属于该代理的数据权限范围
|
||||
- **THEN** 返回 HTTP 403,错误消息"无权限操作该资源或资源不存在"
|
||||
|
||||
#### Scenario: 设备绑卡管理使用设备 VirtualNo
|
||||
- **WHEN** 管理员请求 `GET /api/admin/devices/DEV-001/cards`
|
||||
- **THEN** 系统通过 VirtualNo 找到设备,返回该设备绑定的卡列表
|
||||
|
||||
#### Scenario: 设备解绑卡使用 ICCID
|
||||
- **WHEN** 管理员请求 `DELETE /api/admin/devices/DEV-001/cards/898600XXXXXXXX`
|
||||
- **THEN** 系统通过 VirtualNo 找到设备,通过 ICCID 找到卡,执行解绑
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 新路由下标识符的解析性能
|
||||
|
||||
资产操作接口中标识符解析 SHALL 优先走注册表(单次精确查询),保证解析延迟不超过 10ms(在正常数据库负载下)。
|
||||
|
||||
#### Scenario: 注册表命中路径
|
||||
- **WHEN** 请求携带的 identifier 存在于 `tb_asset_identifier`
|
||||
- **THEN** 系统单次查询注册表得到 asset_type 和 asset_id,无需扫描 tb_device 或 tb_iot_card
|
||||
|
||||
#### Scenario: 注册表未命中(fallback)
|
||||
- **WHEN** 请求携带的 identifier 不在注册表(如 IMEI 或旧数据)
|
||||
- **THEN** 系统 fallback 到原有多字段 OR 查询,同样能定位资产(性能稍低,为次要路径)
|
||||
@@ -12,10 +12,12 @@
|
||||
|
||||
**API 端点**: `GET /api/admin/assets/resolve/:identifier`
|
||||
|
||||
**查找顺序**:
|
||||
1. 先在 `tb_device` 表查找(匹配 `virtual_no = ? OR imei = ? OR sn = ?`)
|
||||
2. 未命中则在 `tb_iot_card` 表查找(匹配 `virtual_no = ? OR iccid = ? OR msisdn = ?`)
|
||||
3. 两表均未命中 → 返回 HTTP 404
|
||||
**查找顺序(更新后)**:
|
||||
1. **主路径**:查 `tb_asset_identifier` WHERE identifier = ? → 命中则得到 asset_type + asset_id,直接查对应表取完整记录
|
||||
2. **Fallback 路径**(注册表未命中时):
|
||||
- 先查 `tb_device`(匹配 `virtual_no = ? OR imei = ? OR sn = ?`)
|
||||
- 未命中则查 `tb_iot_card`(匹配 `virtual_no = ? OR iccid = ? OR msisdn = ?`)
|
||||
3. 两条路径均未命中 → 返回 HTTP 404
|
||||
4. 找到后应用数据权限过滤,无权限 → 返回 HTTP 403
|
||||
|
||||
**数据权限规则**:
|
||||
@@ -28,8 +30,11 @@
|
||||
*通用字段(device 和 card 均有)*:
|
||||
- `asset_type`: 资产类型(`"device"` 或 `"card"`)
|
||||
- `asset_id`: 资产主键 ID
|
||||
- `identifier`: 本次查询所用的标识符(原样回传)
|
||||
- `virtual_no`: 虚拟号(设备/卡均使用此字段)
|
||||
- `status`: 资产状态(整型)
|
||||
- `asset_status`: 业务状态(1-在库 2-已销售 3-已换货 4-已停用)
|
||||
- `generation`: 资产世代编号
|
||||
- `batch_no`: 批次号
|
||||
- `shop_id`: 所属店铺 ID(平台库存时为空)
|
||||
- `shop_name`: 所属店铺名称
|
||||
@@ -136,3 +141,20 @@
|
||||
|
||||
- **WHEN** 管理员查询一台没有购买任何套餐的设备
|
||||
- **THEN** `current_package = ""`,`package_total_mb = 0`,`package_used_mb = 0`,`package_remain_mb = 0`
|
||||
|
||||
#### Scenario: 通过注册表主路径精确解析
|
||||
|
||||
- **WHEN** 管理员输入 identifier 为已存在于 `tb_asset_identifier` 的 VirtualNo 或 ICCID
|
||||
- **THEN** 系统单次查询注册表命中,直接查对应表返回完整资产信息,响应时间 < 50ms
|
||||
|
||||
#### Scenario: Fallback 路径解析 IMEI
|
||||
|
||||
- **WHEN** 管理员输入 identifier 为设备 IMEI(不在注册表中)
|
||||
- **THEN** 注册表未命中,系统 fallback 查 tb_device 的 imei 字段,找到后返回资产信息
|
||||
- **THEN** 响应中 `identifier` 字段原样回传该 IMEI 值
|
||||
|
||||
#### Scenario: Fallback 路径解析 MSISDN
|
||||
|
||||
- **WHEN** 管理员输入 identifier 为 IoT 卡的手机号(MSISDN)
|
||||
- **THEN** 注册表未命中,fallback 查 tb_iot_card 的 msisdn 字段
|
||||
- **THEN** 若存在多张卡的 MSISDN 相同,返回第一条匹配记录(MSISDN 非唯一,存在歧义,记录 warn 日志)
|
||||
|
||||
@@ -4,17 +4,47 @@
|
||||
|
||||
### Requirement: D1 创建套餐购买订单接口
|
||||
|
||||
系统 SHALL 提供 `POST /api/c/v1/orders/create`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`、`package_ids[]`、`app_type`。接口流程 MUST 按顺序执行:归属校验 → 套餐校验(含加油包前置)→ 实名校验 → OpenID 查询 → 幂等检查 → 强充检查 → 分流创建。实名不满足时 MUST 返回 `NEED_REALNAME`。OpenID 缺失时 MUST 返回 `OPENID_NOT_FOUND`。幂等 MUST 使用 Redis 业务键 + 分布式锁。分流规则 MUST 为:
|
||||
系统 SHALL 提供 `POST /api/c/v1/orders/create`,并且 MUST 要求个人客户认证。**[BREAKING]** 请求体改为使用资产标识符(identifier),废弃 `iot_card_id`/`device_id` 主键字段和 `order_type` 字段。请求体 MUST 包含 `identifier`、`package_ids[]`、`payment_method`。接口流程 MUST 按顺序执行:归属校验 → 套餐校验(含加油包前置)→ 实名校验 → OpenID 查询 → 幂等检查 → 强充检查 → 分流创建。实名不满足时 MUST 返回 `NEED_REALNAME`。OpenID 缺失时 MUST 返回 `OPENID_NOT_FOUND`。幂等 MUST 使用 Redis 业务键 + 分布式锁。分流规则 MUST 为:
|
||||
|
||||
- 无强充:创建套餐订单并返回 `order_type="package"`、`order`、`pay_config`
|
||||
- 需强充:创建充值单并返回 `order_type="recharge"`、`recharge`、`pay_config`、`linked_package_info`
|
||||
|
||||
**客户端订单创建请求(CreateOrderRequest)**:
|
||||
```json
|
||||
{
|
||||
"identifier": "string(资产标识符,ICCID 或 VirtualNo,必填,1-50字符)",
|
||||
"package_ids": "[uint](套餐 ID 列表,必填,1-10 个)",
|
||||
"payment_method": "string(wallet|wechat|alipay,必填)"
|
||||
}
|
||||
```
|
||||
|
||||
**废弃字段**:
|
||||
- `iot_card_id`(原单卡购买时必填)
|
||||
- `device_id`(原设备购买时必填)
|
||||
- `order_type`(改为系统根据 identifier 解析结果自动填入)
|
||||
|
||||
响应体 MUST 包含前端可直接渲染字段。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`NEED_REALNAME/该套餐需实名认证后购买`、`OPENID_NOT_FOUND/未找到微信授权信息,请先完成授权`、`IDEMPOTENT_CONFLICT/请求处理中,请勿重复提交`、`PACKAGE_NOT_AVAILABLE/套餐不可购买`。
|
||||
|
||||
#### Scenario: 命中强充返回 recharge 结构
|
||||
- **WHEN** 客户购买套餐触发强充要求
|
||||
- **THEN** 系统返回 `order_type="recharge"`,包含充值单与关联套餐信息
|
||||
|
||||
#### Scenario: 个人客户使用 VirtualNo 购买设备套餐
|
||||
- **WHEN** 个人客户发送 `{ identifier: "DEV-001", package_ids: [5], payment_method: "wechat" }`
|
||||
- **THEN** 系统解析 identifier 为设备,创建设备购买订单,微信支付流程正常触发
|
||||
|
||||
#### Scenario: 个人客户使用 ICCID 购买单卡套餐
|
||||
- **WHEN** 个人客户发送 `{ identifier: "898600XXXXX", package_ids: [3], payment_method: "wallet" }`
|
||||
- **THEN** 系统解析为独立 IoT 卡(`is_standalone = true`),创建单卡购买订单
|
||||
|
||||
#### Scenario: 个人客户尝试为绑定设备的卡购买套餐
|
||||
- **WHEN** 个人客户发送 identifier 对应一张 `is_standalone = false` 的卡
|
||||
- **THEN** 系统返回错误"该卡已绑定设备,请前往设备页面购买套餐"
|
||||
|
||||
#### Scenario: 个人客户只能操作自己绑定的资产
|
||||
- **WHEN** 个人客户发送的 identifier 对应的资产不属于该客户
|
||||
- **THEN** 系统返回 HTTP 403"无权限操作该资源"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: D2 套餐订单列表接口
|
||||
|
||||
@@ -19,21 +19,22 @@ TBD - created by archiving change add-device-management. Update Purpose after ar
|
||||
- **表头行**: 第1行,列名如下(顺序可任意):
|
||||
```
|
||||
device_no, device_name, device_model, device_type,
|
||||
max_sim_slots, manufacturer, iccid_1, iccid_2, iccid_3, iccid_4
|
||||
max_sim_slots, manufacturer, virtual_no, iccid_1, iccid_2, iccid_3, iccid_4
|
||||
```
|
||||
- **数据行**: 从第2行开始
|
||||
- **列格式**: 所有列应设置为文本格式(避免数字被转为科学记数法)
|
||||
|
||||
**示例Excel内容**:
|
||||
```
|
||||
| device_no | device_name | device_model | device_type | max_sim_slots | manufacturer | iccid_1 | iccid_2 | iccid_3 | iccid_4 |
|
||||
|-----------|--------------|--------------|-------------|---------------|--------------|----------------------|----------------------|---------|---------|
|
||||
| DEV-001 | GPS追踪器A | GT06N | GPS Tracker | 4 | Concox | 8986001234567890001 | 8986001234567890002 | | |
|
||||
| DEV-002 | GPS追踪器B | GT06N | GPS Tracker | 4 | Concox | 8986001234567890003 | | | |
|
||||
| device_no | device_name | device_model | device_type | max_sim_slots | manufacturer | virtual_no | iccid_1 | iccid_2 | iccid_3 | iccid_4 |
|
||||
|-----------|--------------|--------------|-------------|---------------|--------------|------------|----------------------|----------------------|---------|---------|
|
||||
| DEV-001 | GPS追踪器A | GT06N | GPS Tracker | 4 | Concox | VNO-001 | 8986001234567890001 | 8986001234567890002 | | |
|
||||
| DEV-002 | GPS追踪器B | GT06N | GPS Tracker | 4 | Concox | VNO-002 | 8986001234567890003 | | | |
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
- `device_no`: 设备号(必填,唯一)
|
||||
- `virtual_no`: 虚拟号(**必填**,全局唯一);**[BREAKING]** 为空时不再静默跳过,改为记录为失败行
|
||||
- `device_name`: 设备名称(可选)
|
||||
- `device_model`: 设备型号(可选)
|
||||
- `device_type`: 设备类型(可选)
|
||||
@@ -41,6 +42,9 @@ TBD - created by archiving change add-device-management. Update Purpose after ar
|
||||
- `manufacturer`: 制造商(可选)
|
||||
- `iccid_1` ~ `iccid_4`: 对应插槽 1-4 的 ICCID(可选,空值表示该插槽无卡)
|
||||
|
||||
**失败原因文本**:
|
||||
- VirtualNo 为空:`"设备虚拟号(virtual_no)不能为空"`
|
||||
|
||||
**导入规则**:
|
||||
- 导入的设备 shop_id = NULL(平台库存)
|
||||
- 导入的设备 status = 1(在库)
|
||||
@@ -127,6 +131,18 @@ TBD - created by archiving change add-device-management. Update Purpose after ar
|
||||
- **WHEN** Excel 中某行的设备号在系统中已存在
|
||||
- **THEN** 该行被跳过,记录跳过原因"设备号已存在"
|
||||
|
||||
#### Scenario: VirtualNo 为空的行记录为失败(原为跳过)
|
||||
|
||||
- **WHEN** Excel 中某行 virtual_no 列为空或未填写
|
||||
- **THEN** 该行计入失败(`fail_count++`),失败原因为"设备虚拟号(virtual_no)不能为空"
|
||||
- **THEN** 该行不再计入 `skip_count`
|
||||
- **THEN** 其他合法行继续导入,不因此行中断
|
||||
|
||||
#### Scenario: 导入任务结果报告包含 VirtualNo 失败原因
|
||||
|
||||
- **WHEN** 导入任务处理完毕,含有 VirtualNo 为空的行
|
||||
- **THEN** 失败明细列表中,该行的 `reason` 字段为"设备虚拟号(virtual_no)不能为空",`line` 字段为对应行号
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备导入任务列表查询
|
||||
|
||||
@@ -201,8 +201,8 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose
|
||||
- **表头识别关键字**:
|
||||
- ICCID 列: iccid/ICCID/卡号/号码
|
||||
- MSISDN 列: msisdn/MSISDN/接入号/手机号/电话/号码
|
||||
- virtual_no 列(新增,可选): virtual_no/VirtualNo/虚拟号/设备号
|
||||
- **列数要求**: 至少 2 列(ICCID 和 MSISDN),virtual_no 为可选第三列
|
||||
- virtual_no 列(**必填**): virtual_no/VirtualNo/虚拟号/设备号
|
||||
- **列数要求**: 至少 3 列(ICCID、MSISDN、virtual_no),virtual_no 为必填列
|
||||
- **列格式**: 应设置为文本格式(避免长数字被转为科学记数法)
|
||||
|
||||
**解析规则**:
|
||||
@@ -211,12 +211,13 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose
|
||||
- 跳过空行
|
||||
- ICCID 为空的行记录为失败
|
||||
- MSISDN 为空的行记录为失败
|
||||
- virtual_no 为空的行:跳过该列(不填入,保留原值)
|
||||
- virtual_no 为空的行:**[BREAKING]** 记录为失败行,失败原因为"虚拟号(virtual_no)不能为空"(原行为为跳过该列)
|
||||
- Excel 中不包含 virtual_no 列时:所有数据行均因 VirtualNo 为空而失败,返回错误提示"Excel 文件缺少 virtual_no 列,请使用最新模板"
|
||||
|
||||
**virtual_no 导入规则(只补空白)**:
|
||||
- 该行 virtual_no 不为空 + 数据库当前值为 NULL:填入新值
|
||||
- 该行 virtual_no 不为空 + 数据库当前值已有值:跳过(不覆盖)
|
||||
- **批次级唯一性检查**:在执行导入前,先检查整批中所有非空 virtual_no 是否与数据库现存值重复;有任意冲突则**整批失败**,响应中返回冲突的 virtual_no 及行号列表
|
||||
**virtual_no 导入规则**:
|
||||
- virtual_no 为必填列,为空则该行记录为失败
|
||||
- virtual_no 全局唯一(跨卡和设备),重复则失败,原因为"虚拟号已被占用: <值>"
|
||||
- **批次级唯一性检查**:在执行导入前,先检查整批中所有 virtual_no 是否与数据库现存值重复;有任意冲突则**整批失败**,响应中返回冲突的 virtual_no 及行号列表
|
||||
|
||||
**示例 Excel 内容**:
|
||||
```
|
||||
@@ -226,20 +227,27 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose
|
||||
| 89860012345678901235 | 13800000002 | |
|
||||
```
|
||||
|
||||
#### Scenario: 解析标准双列 Excel 文件(无 virtual_no 列)
|
||||
#### Scenario: 正常导入(ICCID 和 VirtualNo 均有值)
|
||||
|
||||
- **WHEN** Excel 文件只含 ICCID 和 MSISDN 两列,无虚拟号列
|
||||
- **THEN** 解析结果包含 2 条有效记录,virtual_no 字段为空,不影响导入逻辑
|
||||
- **WHEN** Excel 中某行 ICCID="898600XXXXX",virtual_no="CARD-001",msisdn="13800000001"
|
||||
- **THEN** 导入成功,卡记录写入数据库,VirtualNo 和 ICCID 同步注册到 `tb_asset_identifier`
|
||||
|
||||
#### Scenario: 解析含 virtual_no 列的三列 Excel
|
||||
#### Scenario: VirtualNo 为空的行被拒绝
|
||||
|
||||
- **WHEN** Excel 文件含 ICCID、MSISDN、虚拟号三列,某行 virtual_no = "CARD-001",对应卡当前 virtual_no 为 NULL
|
||||
- **THEN** 解析后该卡的 virtual_no 填入 "CARD-001"
|
||||
- **WHEN** Excel 中某行 ICCID="898600YYYYY",virtual_no 列为空或未填写
|
||||
- **THEN** 该行计入失败,失败原因为"虚拟号(virtual_no)不能为空"
|
||||
- **THEN** 其他合法行继续导入,不因此行中断
|
||||
|
||||
#### Scenario: virtual_no 已有值时不覆盖
|
||||
#### Scenario: VirtualNo 重复被拒绝
|
||||
|
||||
- **WHEN** Excel 中某行 virtual_no = "CARD-NEW",但该卡数据库中已有 virtual_no = "CARD-OLD"
|
||||
- **THEN** 该卡的 virtual_no 保持 "CARD-OLD" 不变,该行跳过(不报错,不计入失败)
|
||||
- **WHEN** Excel 中某行的 virtual_no 与已有卡/设备的 VirtualNo 重复(跨表)
|
||||
- **THEN** 该行计入失败,原因为"虚拟号已被占用: <值>"
|
||||
|
||||
#### Scenario: Excel 文件中无 VirtualNo 列
|
||||
|
||||
- **WHEN** Excel 表头中不包含 `virtual_no`/`虚拟号`/`设备号` 等可识别列名
|
||||
- **THEN** 所有数据行均因 VirtualNo 为空而失败,导入结果中 `fail_count = 总行数`,`success_count = 0`
|
||||
- **THEN** 返回错误提示:"Excel 文件缺少 virtual_no 列,请使用最新模板"
|
||||
|
||||
#### Scenario: 批次中有 virtual_no 与现存数据重复
|
||||
|
||||
@@ -252,6 +260,12 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose
|
||||
- **WHEN** 系统解析该 Excel 文件
|
||||
- **THEN** 系统正确识别三列,按规则处理 virtual_no
|
||||
|
||||
#### Scenario: 导入任务完成后的结果报告
|
||||
|
||||
- **WHEN** 导入任务处理完毕
|
||||
- **THEN** 结果包含:`success_count`、`fail_count`、`skip_count`、失败明细列表(含行号、原因)
|
||||
- **THEN** VirtualNo 为空的失败行在明细中明确体现行号和原因
|
||||
|
||||
#### Scenario: 拒绝非Excel格式文件
|
||||
|
||||
- **GIVEN** 上传文件扩展名为 .csv
|
||||
|
||||
@@ -26,7 +26,21 @@
|
||||
|
||||
### Requirement: 创建套餐购买订单
|
||||
|
||||
系统 SHALL 允许买家创建套餐购买订单。订单类型分为单卡购买和设备购买。创建前 MUST 验证购买权限和强充要求。**后台订单接口 MUST 支持 `payment_method` 字段(wallet/offline),根据支付方式自动设置 `is_purchase_on_behalf` 标识**。
|
||||
系统 SHALL 允许买家创建套餐购买订单。**[BREAKING]** 请求体改为使用资产标识符(identifier),废弃 `iot_card_id`/`device_id` 主键字段;`order_type` 字段由系统根据 identifier 解析结果自动填入,请求体不再接受。创建前 MUST 验证购买权限和强充要求。**后台订单接口 MUST 支持 `payment_method` 字段(wallet/offline),根据支付方式自动设置 `is_purchase_on_behalf` 标识**。
|
||||
|
||||
**后台订单创建请求(CreateAdminOrderRequest)**:
|
||||
```json
|
||||
{
|
||||
"identifier": "string(资产标识符,ICCID 或 VirtualNo,必填)",
|
||||
"package_ids": "[uint](套餐 ID 列表,必填,1-10 个)",
|
||||
"payment_method": "string(wallet|offline,必填)"
|
||||
}
|
||||
```
|
||||
|
||||
**废弃字段**:
|
||||
- `iot_card_id`(原用于单卡购买)
|
||||
- `device_id`(原用于设备购买)
|
||||
- `order_type`(改为系统自动推断)
|
||||
|
||||
**支付方式和订单类型映射**:
|
||||
- `payment_method = "wallet"`:扣买家钱包,`is_purchase_on_behalf = false`(普通订单)
|
||||
@@ -72,6 +86,22 @@
|
||||
- **WHEN** 买家尝试购买已下架的套餐
|
||||
- **THEN** 系统返回错误 "该套餐已下架"
|
||||
|
||||
#### Scenario: 使用 VirtualNo 创建设备订单
|
||||
- **WHEN** 管理员请求体携带 `{ identifier: "DEV-001", package_ids: [5], payment_method: "wallet" }`
|
||||
- **THEN** 系统解析 identifier 为设备,自动设置 `order_type = device`,创建设备购买订单
|
||||
|
||||
#### Scenario: 使用 ICCID 创建单卡订单
|
||||
- **WHEN** 管理员请求体携带 `{ identifier: "898600XXXXX", package_ids: [5], payment_method: "wallet" }`
|
||||
- **THEN** 系统解析 identifier 为独立 IoT 卡(`is_standalone = true`),自动设置 `order_type = single_card`,创建单卡购买订单
|
||||
|
||||
#### Scenario: 使用绑定设备的卡 ICCID 创建订单被拒
|
||||
- **WHEN** 管理员请求体携带 `{ identifier: "898600XXXXX" }` 但该卡 `is_standalone = false`
|
||||
- **THEN** 系统返回错误"该卡已绑定设备,请前往设备页面购买套餐",订单不创建
|
||||
|
||||
#### Scenario: identifier 不存在
|
||||
- **WHEN** 请求的 identifier 无法解析到任何资产
|
||||
- **THEN** 返回错误"资产不存在",订单不创建
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 查询订单列表
|
||||
@@ -224,3 +254,36 @@
|
||||
#### Scenario: 后台管理端可为未实名载体购买
|
||||
- **WHEN** 管理员通过后台为未实名载体购买套餐
|
||||
- **THEN** 系统创建订单成功,PackageUsage status=0, pending_realname_activation=true
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 订单响应包含资产标识符
|
||||
|
||||
订单响应(OrderResponse)SHALL 包含资产标识符字段,前端无需额外请求即可展示资产信息。
|
||||
|
||||
**新增响应字段**:
|
||||
- `asset_identifier`:下单时资产的标识符快照(ICCID 或 VirtualNo)
|
||||
- `asset_type`:资产类型(`single_card` 对应 `iot_card`,`device` 对应 `device`)
|
||||
|
||||
#### Scenario: 查看订单详情时展示资产标识符
|
||||
- **WHEN** 管理员查询订单详情 `GET /api/admin/orders/:id`
|
||||
- **THEN** 响应中包含 `asset_identifier`(如 "DEV-001")和 `asset_type`(如 "device")
|
||||
- **THEN** 即使原资产已被删除,`asset_identifier` 仍可读(快照字段)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 订单列表支持按资产标识符过滤
|
||||
|
||||
系统 SHALL 在订单列表查询(OrderListRequest)中支持 `identifier` 过滤参数,按照资产标识符精确匹配订单(匹配 `asset_identifier` 快照字段)。
|
||||
|
||||
#### Scenario: 按 ICCID 查询该卡的历史订单
|
||||
- **WHEN** 管理员查询 `GET /api/admin/orders?identifier=898600XXXXX`
|
||||
- **THEN** 返回 `asset_identifier = "898600XXXXX"` 的所有订单(分页)
|
||||
|
||||
#### Scenario: 按设备 VirtualNo 查询该设备的历史订单
|
||||
- **WHEN** 管理员查询 `GET /api/admin/orders?identifier=DEV-001`
|
||||
- **THEN** 返回 `asset_identifier = "DEV-001"` 的所有订单(分页)
|
||||
|
||||
#### Scenario: 标识符无匹配订单
|
||||
- **WHEN** 管理员查询不存在订单的 identifier
|
||||
- **THEN** 返回空列表(`items: []`,`total: 0`),不返回错误
|
||||
|
||||
@@ -117,3 +117,23 @@
|
||||
- **WHEN** 代理渠道某套餐存在 `retail_price < cost_price`
|
||||
- **THEN** 系统 MUST 不展示该套餐,且不允许该套餐进入下单校验
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 已绑定设备的卡不允许单独购买套餐
|
||||
|
||||
购买套餐验证时,系统 MUST 检查目标 IoT 卡是否为独立卡(`is_standalone = true`)。若卡已绑定设备(`is_standalone = false`),必须拒绝购买并引导用户至对应设备页面操作。
|
||||
|
||||
此规则适用于所有套餐购买入口(C 端个人客户、B 端代理/平台)。
|
||||
|
||||
#### Scenario: 独立卡正常购买
|
||||
- **WHEN** 买家为 IoT 卡购买套餐,该卡的 `is_standalone = true`(未绑定任何设备)
|
||||
- **THEN** 验证通过,继续后续购买流程
|
||||
|
||||
#### Scenario: 已绑定设备的卡被单独购买
|
||||
- **WHEN** 买家使用 IoT 卡的 ICCID 或 VirtualNo 购买套餐,该卡的 `is_standalone = false`
|
||||
- **THEN** 系统拒绝购买,返回错误码 `CodeInvalidParam`,错误消息"该卡已绑定设备,请前往设备页面购买套餐"
|
||||
|
||||
#### Scenario: B 端代理通过卡标识符购买套餐(绑定了设备的卡)
|
||||
- **WHEN** 代理使用已绑定设备的卡的 ICCID 创建订单
|
||||
- **THEN** 系统在 `ValidateCardPurchase()` 中检查 `is_standalone`,返回错误,订单不创建
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ func (h *Handler) registerIotCardImportHandler() {
|
||||
h.workerResult.Stores.IotCardImportTask,
|
||||
h.workerResult.Stores.IotCard,
|
||||
h.workerResult.Stores.AssetWallet,
|
||||
h.workerResult.Stores.AssetIdentifier,
|
||||
h.storage,
|
||||
h.pollingCallback,
|
||||
h.logger,
|
||||
@@ -107,6 +108,7 @@ func (h *Handler) registerDeviceImportHandler() {
|
||||
h.workerResult.Stores.DeviceSimBinding,
|
||||
h.workerResult.Stores.IotCard,
|
||||
h.workerResult.Stores.AssetWallet,
|
||||
h.workerResult.Stores.AssetIdentifier,
|
||||
h.storage,
|
||||
h.logger,
|
||||
)
|
||||
|
||||
@@ -42,6 +42,7 @@ type WorkerStores struct {
|
||||
AgentWallet *postgres.AgentWalletStore
|
||||
AgentWalletTransaction *postgres.AgentWalletTransactionStore
|
||||
AssetWallet *postgres.AssetWalletStore
|
||||
AssetIdentifier *postgres.AssetIdentifierStore
|
||||
}
|
||||
|
||||
// WorkerServices Worker 侧所有 Service 的集合
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
|
||||
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// CardInfo 卡信息(ICCID + MSISDN + VirtualNo)
|
||||
@@ -31,6 +33,13 @@ type CSVParseError struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
// DeviceParseResult 设备导入 Excel 解析结果
|
||||
type DeviceParseResult struct {
|
||||
Rows []DeviceRow
|
||||
TotalCount int
|
||||
ParseErrors []CSVParseError
|
||||
}
|
||||
|
||||
// DeviceRow 设备导入数据行
|
||||
type DeviceRow struct {
|
||||
Line int
|
||||
@@ -46,9 +55,9 @@ type DeviceRow struct {
|
||||
|
||||
var (
|
||||
// ErrExcelNoSheets Excel文件无工作表
|
||||
ErrExcelNoSheets = errors.New("Excel文件无工作表")
|
||||
ErrExcelNoSheets = stderrors.New("Excel文件无工作表")
|
||||
// ErrExcelNoData Excel文件无数据行
|
||||
ErrExcelNoData = errors.New("Excel文件无数据行(至少需要表头+1行数据)")
|
||||
ErrExcelNoData = stderrors.New("Excel文件无数据行(至少需要表头+1行数据)")
|
||||
)
|
||||
|
||||
// ParseCardExcel 解析包含 ICCID 和 MSISDN 两列的 Excel 文件
|
||||
@@ -88,48 +97,45 @@ func ParseCardExcel(filePath string) (*CSVParseResult, error) {
|
||||
|
||||
// ParseDeviceExcel 解析设备导入 Excel 文件
|
||||
// filePath: Excel文件路径(.xlsx格式)
|
||||
// 返回: 设备行数组、总行数、错误
|
||||
func ParseDeviceExcel(filePath string) ([]DeviceRow, int, error) {
|
||||
// 1. 打开Excel文件
|
||||
// 返回: 解析结果(包含有效行和失败行)、错误
|
||||
func ParseDeviceExcel(filePath string) (*DeviceParseResult, error) {
|
||||
f, err := excelize.OpenFile(filePath)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("打开Excel失败: %w", err)
|
||||
return nil, fmt.Errorf("打开Excel失败: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
// 日志记录关闭错误,但不影响解析结果
|
||||
}
|
||||
}()
|
||||
|
||||
// 2. 选择sheet
|
||||
sheetName := selectSheet(f)
|
||||
if sheetName == "" {
|
||||
return nil, 0, ErrExcelNoSheets
|
||||
return nil, ErrExcelNoSheets
|
||||
}
|
||||
|
||||
// 3. 读取所有行
|
||||
rows, err := f.GetRows(sheetName)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("读取sheet失败: %w", err)
|
||||
return nil, fmt.Errorf("读取sheet失败: %w", err)
|
||||
}
|
||||
|
||||
if len(rows) < 2 {
|
||||
return nil, 0, ErrExcelNoData
|
||||
return nil, ErrExcelNoData
|
||||
}
|
||||
|
||||
// 4. 解析表头行,构建列索引
|
||||
header := rows[0]
|
||||
colIndex := buildDeviceColumnIndex(header)
|
||||
|
||||
// 5. 解析数据行
|
||||
var deviceRows []DeviceRow
|
||||
parseResult := &DeviceParseResult{
|
||||
Rows: make([]DeviceRow, 0),
|
||||
ParseErrors: make([]CSVParseError, 0),
|
||||
}
|
||||
|
||||
for i := 1; i < len(rows); i++ {
|
||||
record := rows[i]
|
||||
lineNum := i + 1 // Excel行号从1开始,数据从第2行开始
|
||||
lineNum := i + 1
|
||||
|
||||
row := DeviceRow{Line: lineNum}
|
||||
|
||||
// 提取各字段
|
||||
if idx := colIndex["virtual_no"]; idx >= 0 && idx < len(record) {
|
||||
row.VirtualNo = strings.TrimSpace(record[idx])
|
||||
}
|
||||
@@ -154,7 +160,6 @@ func ParseDeviceExcel(filePath string) ([]DeviceRow, int, error) {
|
||||
row.Manufacturer = strings.TrimSpace(record[idx])
|
||||
}
|
||||
|
||||
// 提取ICCID (iccid_1 ~ iccid_4)
|
||||
row.ICCIDs = make([]string, 0, 4)
|
||||
for j := 1; j <= 4; j++ {
|
||||
colName := "iccid_" + strconv.Itoa(j)
|
||||
@@ -166,20 +171,24 @@ func ParseDeviceExcel(filePath string) ([]DeviceRow, int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 跳过虚拟号为空的行
|
||||
parseResult.TotalCount++
|
||||
|
||||
if row.VirtualNo == "" {
|
||||
parseResult.ParseErrors = append(parseResult.ParseErrors, CSVParseError{
|
||||
Line: lineNum,
|
||||
Reason: "设备虚拟号(virtual_no)不能为空",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 默认最大插槽数为4
|
||||
if row.MaxSimSlots == 0 {
|
||||
row.MaxSimSlots = 4
|
||||
}
|
||||
|
||||
deviceRows = append(deviceRows, row)
|
||||
parseResult.Rows = append(parseResult.Rows, row)
|
||||
}
|
||||
|
||||
return deviceRows, len(deviceRows), nil
|
||||
return parseResult, nil
|
||||
}
|
||||
|
||||
// selectSheet 选择要读取的sheet
|
||||
@@ -219,6 +228,10 @@ func parseCardRows(rows [][]string) (*CSVParseResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if virtualNoCol == -1 {
|
||||
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "Excel 文件缺少 virtual_no 列,请使用最新模板")
|
||||
}
|
||||
|
||||
startLine := 0
|
||||
if headerSkipped {
|
||||
startLine = 1
|
||||
@@ -279,6 +292,16 @@ func parseCardRows(rows [][]string) (*CSVParseResult, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
if virtualNo == "" {
|
||||
result.ParseErrors = append(result.ParseErrors, CSVParseError{
|
||||
Line: lineNum,
|
||||
ICCID: iccid,
|
||||
MSISDN: msisdn,
|
||||
Reason: "虚拟号(virtual_no)不能为空",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
result.Cards = append(result.Cards, CardInfo{
|
||||
ICCID: iccid,
|
||||
MSISDN: msisdn,
|
||||
|
||||
Reference in New Issue
Block a user