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

主要变更:
- 新增 AssetIdentifier 模型及 Store,统一管理资产标识符(ICCID/IMEI/SN 等)
- 新增迁移:asset_identifier 表、order 表新增 asset_identifier 字段、iot_card.virtual_no NOT NULL 约束
- 资产 Handler/Service/Route 全面重构,支持标识符路由查询与解析
- 新增资产历史订单查询接口,支持跨设备/卡/钱包维度的订单聚合
- 设备与物联卡导入任务强制校验虚拟号,缺失时直接拒绝
- Excel 工具函数优化,前端导入指引文档同步更新
- 归档三个 OpenSpec 提案:asset-identifier-standardization、asset-historical-orders、import-mandatory-virtual-no
- 更新 OpenAPI 文档及相关 DTO

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-04-07 17:39:36 +08:00
parent 7e489a19fb
commit 80c6f6c756
69 changed files with 3039 additions and 851 deletions

View File

@@ -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),
}
}

View File

@@ -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),
}
}

View File

@@ -100,6 +100,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
nil, // wechatPayment: 超时取消不需要
nil, // queueClient: 超时取消不触发分佣
deps.Logger,
stores.AssetIdentifier,
)
// 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机

View File

@@ -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,
}
}

View File

@@ -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,
})
}

View File

@@ -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
}

View File

@@ -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
}

View 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"
)

View File

@@ -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 更新资产轮询状态响应

View File

@@ -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"`

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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"`
}

View File

@@ -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"`

View File

@@ -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),

View File

@@ -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) {
}

View File

@@ -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`" + `: 设备类型

View File

@@ -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),

View File

@@ -37,9 +37,13 @@ type Service struct {
packageSeriesStore *postgres.PackageSeriesStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
shopStore *postgres.ShopStore
orderStore *postgres.OrderStore
orderItemStore *postgres.OrderItemStore
exchangeOrderStore *postgres.ExchangeOrderStore
redis *redis.Client
iotCardService IotCardRefresher
gatewayClient *gateway.Client // 用于调用 sync-info 同步设备信息(可为 nil失败时仅记录日志
gatewayClient *gateway.Client
assetIdentifierStore *postgres.AssetIdentifierStore
}
// New 创建资产服务实例
@@ -55,6 +59,10 @@ func New(
redisClient *redis.Client,
iotCardService IotCardRefresher,
gatewayClient *gateway.Client,
assetIdentifierStore *postgres.AssetIdentifierStore,
orderStore *postgres.OrderStore,
orderItemStore *postgres.OrderItemStore,
exchangeOrderStore *postgres.ExchangeOrderStore,
) *Service {
return &Service{
db: db,
@@ -65,28 +73,65 @@ func New(
packageSeriesStore: packageSeriesStore,
deviceSimBindingStore: deviceSimBindingStore,
shopStore: shopStore,
orderStore: orderStore,
orderItemStore: orderItemStore,
exchangeOrderStore: exchangeOrderStore,
redis: redisClient,
iotCardService: iotCardService,
gatewayClient: gatewayClient,
assetIdentifierStore: assetIdentifierStore,
}
}
// Resolve 通过任意标识符解析资产
// 优先匹配设备virtual_no/imei/sn未命中则匹配卡virtual_no/iccid/msisdn
// 主路径:查注册表(精确匹配 ICCID 或 VirtualNo
// Fallback原有跨表 OR 查询(处理 IMEI/SN/MSISDN 等非注册标识符)
func (s *Service) Resolve(ctx context.Context, identifier string) (*dto.AssetResolveResponse, error) {
// 先查 Device
device, err := s.deviceStore.GetByIdentifier(ctx, identifier)
if err == nil && device != nil {
return s.buildDeviceResolveResponse(ctx, device)
if s.assetIdentifierStore != nil {
regRecord, regErr := s.assetIdentifierStore.FindByIdentifier(ctx, identifier)
if regErr == nil && regRecord != nil {
switch regRecord.AssetType {
case model.AssetTypeDevice:
device, devErr := s.deviceStore.GetByID(ctx, regRecord.AssetID)
if devErr == nil && device != nil {
resp, buildErr := s.buildDeviceResolveResponse(ctx, device)
if buildErr == nil {
resp.Identifier = identifier
}
return resp, buildErr
}
case model.AssetTypeIotCard:
card, cardErr := s.iotCardStore.GetByID(ctx, regRecord.AssetID)
if cardErr == nil && card != nil {
resp, buildErr := s.buildCardResolveResponse(ctx, card)
if buildErr == nil {
resp.Identifier = identifier
}
return resp, buildErr
}
}
}
}
device, err := s.deviceStore.GetByIdentifier(ctx, identifier)
if err == nil && device != nil {
resp, buildErr := s.buildDeviceResolveResponse(ctx, device)
if buildErr == nil {
resp.Identifier = identifier
}
return resp, buildErr
}
// 未找到设备,查 IotCardvirtual_no/iccid/msisdn
var card model.IotCard
query := s.db.WithContext(ctx).
Where("virtual_no = ? OR iccid = ? OR msisdn = ?", identifier, identifier, identifier)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.First(&card).Error; err == nil {
return s.buildCardResolveResponse(ctx, &card)
resp, buildErr := s.buildCardResolveResponse(ctx, &card)
if buildErr == nil {
resp.Identifier = identifier
}
return resp, buildErr
}
return nil, errors.New(errors.CodeNotFound, "未找到匹配的资产")
@@ -725,6 +770,204 @@ func safeVirtualRatio(ratio float64) float64 {
return ratio
}
// GetOrders 查询资产历史订单,支持换货链跨代追溯
// page/pageSize 仅对本代订单生效前代订单每代最多返回20条
// 最多追溯10代超出后 truncated=true
func (s *Service) GetOrders(ctx context.Context, identifier string, page, pageSize int, includePrevious bool) (*dto.AssetOrdersResponse, error) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
asset, err := s.Resolve(ctx, identifier)
if err != nil {
return nil, err
}
currentGeneration := s.resolveAssetGeneration(ctx, asset.AssetType, asset.AssetID)
orders, total, err := s.orderStore.ListByAssetIdentifier(ctx, identifier, page, pageSize)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询本代订单失败")
}
orderItems := s.fetchOrderItemsMap(ctx, orders)
currentItems := make([]*dto.AssetOrderItem, 0, len(orders))
for _, o := range orders {
currentItems = append(currentItems, buildAssetOrderItem(o, orderItems[o.ID]))
}
resp := &dto.AssetOrdersResponse{
CurrentGeneration: &dto.GenerationOrders{
Generation: currentGeneration,
Identifier: identifier,
AssetType: asset.AssetType,
Total: total,
Page: page,
PageSize: pageSize,
Items: currentItems,
},
PreviousGenerations: nil,
Truncated: false,
}
if !includePrevious {
return resp, nil
}
prevGens, truncated := s.tracePreviousGenerations(ctx, asset.AssetType, asset.AssetID)
resp.PreviousGenerations = prevGens
resp.Truncated = truncated
return resp, nil
}
func (s *Service) resolveAssetGeneration(ctx context.Context, assetType string, assetID uint) int {
switch assetType {
case "card":
card, err := s.iotCardStore.GetByID(ctx, assetID)
if err == nil && card != nil {
return card.Generation
}
case "device":
device, err := s.deviceStore.GetByID(ctx, assetID)
if err == nil && device != nil {
return device.Generation
}
}
return 1
}
// tracePreviousGenerations 通过换货链逆向追溯前代资产的订单最多追溯10代
func (s *Service) tracePreviousGenerations(ctx context.Context, assetType string, assetID uint) ([]*dto.PreviousGenerationOrders, bool) {
const maxDepth = 10
const prevPageSize = 20
prevGens := make([]*dto.PreviousGenerationOrders, 0)
currentType := assetType
currentID := assetID
truncated := false
for i := 0; i < maxDepth; i++ {
// 转换为 ExchangeOrder 中使用的资产类型card→iot_card
storeType := currentType
if storeType == "card" {
storeType = "iot_card"
}
exchOrder, err := s.exchangeOrderStore.FindByNewAssetID(ctx, storeType, currentID)
if err != nil || exchOrder == nil {
break
}
oldOrders, oldTotal, err := s.orderStore.ListByAssetIdentifier(ctx, exchOrder.OldAssetIdentifier, 1, prevPageSize)
if err != nil {
logger.GetAppLogger().Warn("查询前代订单失败",
zap.String("old_identifier", exchOrder.OldAssetIdentifier),
zap.Error(err))
oldOrders = nil
oldTotal = 0
}
oldItems := s.fetchOrderItemsMap(ctx, oldOrders)
orderItems := make([]*dto.AssetOrderItem, 0, len(oldOrders))
for _, o := range oldOrders {
orderItems = append(orderItems, buildAssetOrderItem(o, oldItems[o.ID]))
}
oldAssetType := exchOrder.OldAssetType
if oldAssetType == "iot_card" {
oldAssetType = "card"
}
oldGeneration := s.resolveAssetGeneration(ctx, oldAssetType, exchOrder.OldAssetID)
prevGens = append(prevGens, &dto.PreviousGenerationOrders{
Generation: oldGeneration,
Identifier: exchOrder.OldAssetIdentifier,
AssetType: oldAssetType,
ExchangeNo: exchOrder.ExchangeNo,
ExchangedAt: exchOrder.UpdatedAt,
Total: oldTotal,
Items: orderItems,
})
currentType = oldAssetType
currentID = exchOrder.OldAssetID
if i == maxDepth-1 {
truncated = true
}
}
return prevGens, truncated
}
// fetchOrderItemsMap 批量查询订单明细并按 orderID 分组
func (s *Service) fetchOrderItemsMap(ctx context.Context, orders []*model.Order) map[uint][]*model.OrderItem {
if len(orders) == 0 {
return nil
}
orderIDs := make([]uint, 0, len(orders))
for _, o := range orders {
orderIDs = append(orderIDs, o.ID)
}
allItems, err := s.orderItemStore.ListByOrderIDs(ctx, orderIDs)
if err != nil {
logger.GetAppLogger().Warn("批量查询订单明细失败", zap.Error(err))
return nil
}
result := make(map[uint][]*model.OrderItem, len(orders))
for _, item := range allItems {
result[item.OrderID] = append(result[item.OrderID], item)
}
return result
}
func buildAssetOrderItem(o *model.Order, items []*model.OrderItem) *dto.AssetOrderItem {
details := make([]*dto.AssetOrderItemDetail, 0, len(items))
for _, it := range items {
details = append(details, &dto.AssetOrderItemDetail{
PackageName: it.PackageName,
Quantity: it.Quantity,
UnitPrice: it.UnitPrice,
Amount: it.Amount,
})
}
return &dto.AssetOrderItem{
OrderNo: o.OrderNo,
OrderType: o.OrderType,
PaymentStatus: o.PaymentStatus,
PaymentStatusText: paymentStatusText(o.PaymentStatus),
TotalAmount: o.TotalAmount,
PaymentMethod: o.PaymentMethod,
PaidAt: o.PaidAt,
Generation: o.Generation,
Items: details,
CreatedAt: o.CreatedAt,
}
}
func paymentStatusText(status int) string {
switch status {
case 1:
return "待支付"
case 2:
return "已支付"
case 3:
return "已取消"
case 4:
return "已退款"
default:
return "未知"
}
}
// packageStatusName 套餐状态码转中文名称
func packageStatusName(status int) string {
switch status {

View File

@@ -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 批量分配设备

View File

@@ -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)
}

View File

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

View File

@@ -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, "该卡未关联套餐系列,无法购买套餐")
}

View File

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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)
}
}