feat: 新增数据库迁移,重命名 device_no 为 virtual_no,新增 iot_card.virtual_no 和 package.virtual_ratio 字段
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m3s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m3s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -55,5 +55,6 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
|
||||
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
|
||||
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
|
||||
Asset: admin.NewAssetHandler(svc.Asset, svc.Device, svc.StopResumeService),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
|
||||
commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting"
|
||||
|
||||
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
|
||||
deviceImportSvc "github.com/break/junhong_cmp_fiber/internal/service/device_import"
|
||||
enterpriseSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise"
|
||||
@@ -77,6 +78,8 @@ type services struct {
|
||||
PollingAlert *pollingSvc.AlertService
|
||||
PollingCleanup *pollingSvc.CleanupService
|
||||
PollingManualTrigger *pollingSvc.ManualTriggerService
|
||||
Asset *assetSvc.Service
|
||||
StopResumeService *iotCardSvc.StopResumeService
|
||||
}
|
||||
|
||||
func initServices(s *stores, deps *Dependencies) *services {
|
||||
@@ -124,7 +127,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
MyCommission: myCommissionSvc.New(deps.DB, s.Shop, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction),
|
||||
IotCard: iotCard,
|
||||
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient),
|
||||
Device: deviceSvc.New(deps.DB, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient),
|
||||
Device: deviceSvc.New(deps.DB, deps.Redis, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient),
|
||||
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient),
|
||||
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
|
||||
Carrier: carrierSvc.New(s.Carrier),
|
||||
@@ -145,5 +148,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
PollingAlert: pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger),
|
||||
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
|
||||
PollingManualTrigger: pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger),
|
||||
Asset: assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard),
|
||||
StopResumeService: iotCardSvc.NewStopResumeService(deps.DB, deps.Redis, s.IotCard, s.DeviceSimBinding, deps.GatewayClient, deps.Logger),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ type Handlers struct {
|
||||
PollingAlert *admin.PollingAlertHandler
|
||||
PollingCleanup *admin.PollingCleanupHandler
|
||||
PollingManualTrigger *admin.PollingManualTriggerHandler
|
||||
Asset *admin.AssetHandler
|
||||
}
|
||||
|
||||
// Middlewares 封装所有中间件
|
||||
|
||||
186
internal/handler/admin/asset.go
Normal file
186
internal/handler/admin/asset.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||
deviceService "github.com/break/junhong_cmp_fiber/internal/service/device"
|
||||
iotCardService "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
)
|
||||
|
||||
// AssetHandler 资产管理处理器
|
||||
// 提供统一的资产解析、实时状态、套餐查询、停复机等接口
|
||||
type AssetHandler struct {
|
||||
assetService *assetService.Service
|
||||
deviceService *deviceService.Service
|
||||
iotCardStopResume *iotCardService.StopResumeService
|
||||
}
|
||||
|
||||
// NewAssetHandler 创建资产管理处理器
|
||||
func NewAssetHandler(
|
||||
assetSvc *assetService.Service,
|
||||
deviceSvc *deviceService.Service,
|
||||
iotCardStopResume *iotCardService.StopResumeService,
|
||||
) *AssetHandler {
|
||||
return &AssetHandler{
|
||||
assetService: assetSvc,
|
||||
deviceService: deviceSvc,
|
||||
iotCardStopResume: iotCardStopResume,
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve 通过任意标识符解析资产(设备或卡)
|
||||
// GET /api/admin/assets/resolve/:identifier
|
||||
func (h *AssetHandler) Resolve(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType == constants.UserTypeEnterprise {
|
||||
return errors.New(errors.CodeForbidden, "企业账号暂不支持此接口")
|
||||
}
|
||||
|
||||
identifier := c.Params("identifier")
|
||||
if identifier == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "标识符不能为空")
|
||||
}
|
||||
|
||||
result, err := h.assetService.Resolve(c.UserContext(), identifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// RealtimeStatus 获取资产实时状态
|
||||
// GET /api/admin/assets/:asset_type/:id/realtime-status
|
||||
func (h *AssetHandler) RealtimeStatus(c *fiber.Ctx) error {
|
||||
assetType := c.Params("asset_type")
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
|
||||
}
|
||||
|
||||
result, err := h.assetService.GetRealtimeStatus(c.UserContext(), assetType, uint(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// Refresh 刷新资产状态(调网关同步)
|
||||
// POST /api/admin/assets/:asset_type/:id/refresh
|
||||
func (h *AssetHandler) Refresh(c *fiber.Ctx) error {
|
||||
assetType := c.Params("asset_type")
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
|
||||
}
|
||||
|
||||
result, err := h.assetService.Refresh(c.UserContext(), assetType, uint(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// Packages 获取资产所有套餐列表
|
||||
// GET /api/admin/assets/:asset_type/:id/packages
|
||||
func (h *AssetHandler) Packages(c *fiber.Ctx) error {
|
||||
assetType := c.Params("asset_type")
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
|
||||
}
|
||||
|
||||
result, err := h.assetService.GetPackages(c.UserContext(), assetType, uint(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// CurrentPackage 获取资产当前生效套餐
|
||||
// GET /api/admin/assets/:asset_type/:id/current-package
|
||||
func (h *AssetHandler) CurrentPackage(c *fiber.Ctx) error {
|
||||
assetType := c.Params("asset_type")
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
|
||||
}
|
||||
|
||||
result, err := h.assetService.GetCurrentPackage(c.UserContext(), assetType, uint(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
|
||||
}
|
||||
|
||||
result, err := h.deviceService.StopDevice(c.UserContext(), uint(deviceID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -37,37 +37,6 @@ func (h *DeviceHandler) List(c *fiber.Ctx) error {
|
||||
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) GetByID(c *fiber.Ctx) error {
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
|
||||
}
|
||||
|
||||
result, err := h.service.Get(c.UserContext(), uint(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// GetByIdentifier 通过标识符查询设备详情
|
||||
// GET /api/admin/devices/by-identifier/:identifier
|
||||
func (h *DeviceHandler) GetByIdentifier(c *fiber.Ctx) error {
|
||||
identifier := c.Params("identifier")
|
||||
if identifier == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "设备标识符不能为空")
|
||||
}
|
||||
|
||||
result, err := h.service.GetByIdentifier(c.UserContext(), identifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) Delete(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
@@ -223,22 +192,6 @@ func (h *DeviceHandler) BatchSetSeriesBinding(c *fiber.Ctx) error {
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// GetGatewayInfo 查询设备信息
|
||||
// GET /api/admin/devices/by-identifier/:identifier/gateway-info
|
||||
func (h *DeviceHandler) GetGatewayInfo(c *fiber.Ctx) error {
|
||||
identifier := c.Params("identifier")
|
||||
if identifier == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "设备标识符不能为空")
|
||||
}
|
||||
|
||||
resp, err := h.service.GatewayGetDeviceInfo(c.UserContext(), identifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
|
||||
// GetGatewaySlots 查询设备卡槽信息
|
||||
// GET /api/admin/devices/by-identifier/:identifier/gateway-slots
|
||||
func (h *DeviceHandler) GetGatewaySlots(c *fiber.Ctx) error {
|
||||
|
||||
@@ -78,43 +78,3 @@ func (h *EnterpriseCardHandler) ListCards(c *fiber.Ctx) error {
|
||||
|
||||
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
|
||||
}
|
||||
|
||||
func (h *EnterpriseCardHandler) SuspendCard(c *fiber.Ctx) error {
|
||||
enterpriseIDStr := c.Params("id")
|
||||
enterpriseID, err := strconv.ParseUint(enterpriseIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的企业ID")
|
||||
}
|
||||
|
||||
cardIDStr := c.Params("card_id")
|
||||
cardID, err := strconv.ParseUint(cardIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的卡ID")
|
||||
}
|
||||
|
||||
if err := h.service.SuspendCard(c.UserContext(), uint(enterpriseID), uint(cardID)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, nil)
|
||||
}
|
||||
|
||||
func (h *EnterpriseCardHandler) ResumeCard(c *fiber.Ctx) error {
|
||||
enterpriseIDStr := c.Params("id")
|
||||
enterpriseID, err := strconv.ParseUint(enterpriseIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的企业ID")
|
||||
}
|
||||
|
||||
cardIDStr := c.Params("card_id")
|
||||
cardID, err := strconv.ParseUint(cardIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的卡ID")
|
||||
}
|
||||
|
||||
if err := h.service.ResumeCard(c.UserContext(), uint(enterpriseID), uint(cardID)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, nil)
|
||||
}
|
||||
|
||||
@@ -35,20 +35,6 @@ func (h *IotCardHandler) ListStandalone(c *fiber.Ctx) error {
|
||||
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
|
||||
}
|
||||
|
||||
func (h *IotCardHandler) GetByICCID(c *fiber.Ctx) error {
|
||||
iccid := c.Params("iccid")
|
||||
if iccid == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
|
||||
}
|
||||
|
||||
result, err := h.service.GetByICCID(c.UserContext(), iccid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *IotCardHandler) AllocateCards(c *fiber.Ctx) error {
|
||||
var req dto.AllocateStandaloneCardsRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
@@ -126,51 +112,6 @@ func (h *IotCardHandler) BatchSetSeriesBinding(c *fiber.Ctx) error {
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// GetGatewayStatus 查询卡实时状态
|
||||
func (h *IotCardHandler) GetGatewayStatus(c *fiber.Ctx) error {
|
||||
iccid := c.Params("iccid")
|
||||
if iccid == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
|
||||
}
|
||||
|
||||
resp, err := h.service.GatewayQueryCardStatus(c.UserContext(), iccid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
|
||||
// GetGatewayFlow 查询流量使用情况
|
||||
func (h *IotCardHandler) GetGatewayFlow(c *fiber.Ctx) error {
|
||||
iccid := c.Params("iccid")
|
||||
if iccid == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
|
||||
}
|
||||
|
||||
resp, err := h.service.GatewayQueryFlow(c.UserContext(), iccid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
|
||||
// GetGatewayRealname 查询实名认证状态
|
||||
func (h *IotCardHandler) GetGatewayRealname(c *fiber.Ctx) error {
|
||||
iccid := c.Params("iccid")
|
||||
if iccid == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
|
||||
}
|
||||
|
||||
resp, err := h.service.GatewayQueryRealnameStatus(c.UserContext(), iccid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
|
||||
// GetRealnameLink 获取实名认证链接
|
||||
func (h *IotCardHandler) GetRealnameLink(c *fiber.Ctx) error {
|
||||
iccid := c.Params("iccid")
|
||||
@@ -185,31 +126,3 @@ func (h *IotCardHandler) GetRealnameLink(c *fiber.Ctx) error {
|
||||
|
||||
return response.Success(c, link)
|
||||
}
|
||||
|
||||
// StopCard 停止卡服务
|
||||
func (h *IotCardHandler) StopCard(c *fiber.Ctx) error {
|
||||
iccid := c.Params("iccid")
|
||||
if iccid == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
|
||||
}
|
||||
|
||||
if err := h.service.GatewayStopCard(c.UserContext(), iccid); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, nil)
|
||||
}
|
||||
|
||||
// StartCard 恢复卡服务
|
||||
func (h *IotCardHandler) StartCard(c *fiber.Ctx) error {
|
||||
iccid := c.Params("iccid")
|
||||
if iccid == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
|
||||
}
|
||||
|
||||
if err := h.service.GatewayStartCard(c.UserContext(), iccid); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, nil)
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ func (h *EnterpriseDeviceHandler) ListDevices(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
serviceReq := &dto.EnterpriseDeviceListReq{
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
DeviceNo: req.DeviceNo,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
VirtualNo: req.VirtualNo,
|
||||
}
|
||||
|
||||
result, err := h.service.ListDevicesForEnterprise(c.UserContext(), serviceReq)
|
||||
@@ -53,55 +53,3 @@ func (h *EnterpriseDeviceHandler) GetDeviceDetail(c *fiber.Ctx) error {
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *EnterpriseDeviceHandler) SuspendCard(c *fiber.Ctx) error {
|
||||
deviceIDStr := c.Params("device_id")
|
||||
deviceID, err := strconv.ParseUint(deviceIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "设备ID格式错误")
|
||||
}
|
||||
|
||||
cardIDStr := c.Params("card_id")
|
||||
cardID, err := strconv.ParseUint(cardIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "卡ID格式错误")
|
||||
}
|
||||
|
||||
var req dto.DeviceCardOperationReq
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
result, err := h.service.SuspendCard(c.UserContext(), uint(deviceID), uint(cardID), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *EnterpriseDeviceHandler) ResumeCard(c *fiber.Ctx) error {
|
||||
deviceIDStr := c.Params("device_id")
|
||||
deviceID, err := strconv.ParseUint(deviceIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "设备ID格式错误")
|
||||
}
|
||||
|
||||
cardIDStr := c.Params("card_id")
|
||||
cardID, err := strconv.ParseUint(cardIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "卡ID格式错误")
|
||||
}
|
||||
|
||||
var req dto.DeviceCardOperationReq
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
result, err := h.service.ResumeCard(c.UserContext(), uint(deviceID), uint(cardID), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@ import (
|
||||
// Device 设备模型
|
||||
// 物联网设备(如 GPS 追踪器、智能传感器)
|
||||
// 通过 shop_id 区分所有权:NULL=平台库存,有值=店铺所有
|
||||
// 标识符说明:device_no 为虚拟号/别名,imei/sn 为设备真实标识
|
||||
// 标识符说明:virtual_no 为虚拟号/别名,imei/sn 为设备真实标识
|
||||
type Device struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
DeviceNo string `gorm:"column:device_no;type:varchar(100);uniqueIndex:idx_device_no,where:deleted_at IS NULL;not null;comment:设备虚拟号/别名(用户友好的短标识)" json:"device_no"`
|
||||
VirtualNo string `gorm:"column:virtual_no;type:varchar(100);uniqueIndex:idx_device_virtual_no,where:deleted_at IS NULL;not null;comment:设备虚拟号/别名(用户友好的短标识)" json:"virtual_no"`
|
||||
IMEI string `gorm:"column:imei;type:varchar(20);comment:设备IMEI(有蜂窝网络的设备标识,用于Gateway API调用)" json:"imei"`
|
||||
SN string `gorm:"column:sn;type:varchar(100);comment:设备序列号(厂商唯一标识,预留字段)" json:"sn"`
|
||||
DeviceName string `gorm:"column:device_name;type:varchar(255);comment:设备名称" json:"device_name"`
|
||||
|
||||
@@ -37,7 +37,7 @@ func (DeviceImportTask) TableName() string {
|
||||
|
||||
// DeviceImportResultItem 设备导入结果项
|
||||
type DeviceImportResultItem struct {
|
||||
Line int `json:"line"`
|
||||
DeviceNo string `json:"device_no"`
|
||||
Reason string `json:"reason"`
|
||||
Line int `json:"line"`
|
||||
VirtualNo string `json:"virtual_no"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
119
internal/model/dto/asset_dto.go
Normal file
119
internal/model/dto/asset_dto.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
// AssetResolveResponse 统一资产解析响应
|
||||
type AssetResolveResponse struct {
|
||||
AssetType string `json:"asset_type" description:"资产类型:card 或 device"`
|
||||
AssetID uint `json:"asset_id" description:"资产数据库ID"`
|
||||
VirtualNo string `json:"virtual_no" description:"虚拟号"`
|
||||
Status int `json:"status" description:"资产状态"`
|
||||
BatchNo string `json:"batch_no" description:"批次号"`
|
||||
ShopID *uint `json:"shop_id,omitempty" description:"所属店铺ID"`
|
||||
ShopName string `json:"shop_name,omitempty" description:"所属店铺名称"`
|
||||
SeriesID *uint `json:"series_id,omitempty" description:"套餐系列ID"`
|
||||
SeriesName string `json:"series_name,omitempty" description:"套餐系列名称"`
|
||||
FirstCommissionPaid bool `json:"first_commission_paid" description:"一次性佣金是否已发放"`
|
||||
AccumulatedRecharge int64 `json:"accumulated_recharge" description:"累计充值金额(分)"`
|
||||
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
|
||||
// 状态聚合字段
|
||||
RealNameStatus int `json:"real_name_status" description:"实名状态:0未实名 1实名中 2已实名"`
|
||||
CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"`
|
||||
PackageTotalMB int64 `json:"package_total_mb" description:"当前套餐总虚流量(MB),已按virtual_ratio换算"`
|
||||
PackageUsedMB float64 `json:"package_used_mb" description:"当前已用虚流量(MB),已按virtual_ratio换算"`
|
||||
PackageRemainMB float64 `json:"package_remain_mb" description:"当前套餐剩余虚流量(MB),已按virtual_ratio换算"`
|
||||
DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"设备保护期状态:none/stop/start(仅asset_type=device时有效)"`
|
||||
// 绑定关系字段
|
||||
ICCID string `json:"iccid,omitempty" description:"卡ICCID(asset_type=card时有效)"`
|
||||
BoundDeviceID *uint `json:"bound_device_id,omitempty" description:"绑定的设备ID(asset_type=card时有效)"`
|
||||
BoundDeviceNo string `json:"bound_device_no,omitempty" description:"绑定的设备虚拟号(asset_type=card时有效)"`
|
||||
BoundDeviceName string `json:"bound_device_name,omitempty" description:"绑定的设备名称(asset_type=card时有效)"`
|
||||
BoundCardCount int `json:"bound_card_count,omitempty" description:"绑定的卡数量(asset_type=device时有效)"`
|
||||
Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定的卡列表(asset_type=device时有效)"`
|
||||
// 设备专属字段(card类型时为零值)
|
||||
DeviceName string `json:"device_name,omitempty" description:"设备名称"`
|
||||
IMEI string `json:"imei,omitempty" description:"设备IMEI"`
|
||||
SN string `json:"sn,omitempty" description:"设备序列号"`
|
||||
DeviceModel string `json:"device_model,omitempty" description:"设备型号"`
|
||||
DeviceType string `json:"device_type,omitempty" description:"设备类型"`
|
||||
MaxSimSlots int `json:"max_sim_slots,omitempty" description:"最大插槽数"`
|
||||
Manufacturer string `json:"manufacturer,omitempty" description:"制造商"`
|
||||
// 卡专属字段(device类型时为零值)
|
||||
CarrierID uint `json:"carrier_id,omitempty" description:"运营商ID"`
|
||||
CarrierType string `json:"carrier_type,omitempty" description:"运营商类型"`
|
||||
CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"`
|
||||
MSISDN string `json:"msisdn,omitempty" description:"手机号"`
|
||||
IMSI string `json:"imsi,omitempty" description:"IMSI"`
|
||||
CardCategory string `json:"card_category,omitempty" description:"卡业务类型"`
|
||||
Supplier string `json:"supplier,omitempty" description:"供应商"`
|
||||
ActivationStatus int `json:"activation_status,omitempty" description:"激活状态"`
|
||||
EnablePolling bool `json:"enable_polling,omitempty" description:"是否参与轮询"`
|
||||
NetworkStatus int `json:"network_status,omitempty" description:"网络状态:0停机 1开机(asset_type=card时有效)"`
|
||||
}
|
||||
|
||||
// BoundCardInfo 设备绑定的卡信息
|
||||
type BoundCardInfo struct {
|
||||
CardID uint `json:"card_id" description:"卡ID"`
|
||||
ICCID string `json:"iccid" description:"ICCID"`
|
||||
MSISDN string `json:"msisdn,omitempty" description:"手机号"`
|
||||
NetworkStatus int `json:"network_status" description:"网络状态:0停机 1开机"`
|
||||
RealNameStatus int `json:"real_name_status" description:"实名状态"`
|
||||
SlotPosition int `json:"slot_position,omitempty" description:"插槽位置"`
|
||||
}
|
||||
|
||||
// AssetRealtimeStatusResponse 资产实时状态(只读DB/Redis)
|
||||
type AssetRealtimeStatusResponse struct {
|
||||
AssetType string `json:"asset_type" description:"资产类型:card 或 device"`
|
||||
AssetID uint `json:"asset_id" description:"资产ID"`
|
||||
NetworkStatus int `json:"network_status,omitempty" description:"网络状态(asset_type=card时有效):0停机 1开机"`
|
||||
RealNameStatus int `json:"real_name_status,omitempty" description:"实名状态(asset_type=card时有效)"`
|
||||
CurrentMonthUsageMB float64 `json:"current_month_usage_mb,omitempty" description:"本月已用流量MB(asset_type=card时有效)"`
|
||||
LastSyncTime *time.Time `json:"last_sync_time,omitempty" description:"最后同步时间(asset_type=card时有效)"`
|
||||
DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"保护期状态(asset_type=device时有效):none/stop/start"`
|
||||
Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定卡状态列表(asset_type=device时有效)"`
|
||||
}
|
||||
|
||||
// AssetPackageResponse 资产套餐信息
|
||||
type AssetPackageResponse struct {
|
||||
PackageUsageID uint `json:"package_usage_id" description:"套餐使用记录ID"`
|
||||
PackageID uint `json:"package_id" description:"套餐ID"`
|
||||
PackageName string `json:"package_name" description:"套餐名称"`
|
||||
PackageType string `json:"package_type" description:"套餐类型:formal/addon"`
|
||||
UsageType string `json:"usage_type" description:"使用类型:single_card/device"`
|
||||
Status int `json:"status" description:"状态:0待生效 1生效中 2已用完 3已过期 4已失效"`
|
||||
StatusName string `json:"status_name" description:"状态名称"`
|
||||
DataLimitMB int64 `json:"data_limit_mb" description:"套餐真流量总量(MB)"`
|
||||
VirtualLimitMB int64 `json:"virtual_limit_mb" description:"套餐虚流量总量(MB),按virtual_ratio换算"`
|
||||
DataUsageMB int64 `json:"data_usage_mb" description:"已用真流量(MB)"`
|
||||
VirtualUsedMB float64 `json:"virtual_used_mb" description:"已用虚流量(MB),按virtual_ratio换算"`
|
||||
VirtualRemainMB float64 `json:"virtual_remain_mb" description:"剩余虚流量(MB),按virtual_ratio换算"`
|
||||
VirtualRatio float64 `json:"virtual_ratio" description:"虚流量比例(real/virtual)"`
|
||||
ActivatedAt time.Time `json:"activated_at" description:"激活时间"`
|
||||
ExpiresAt time.Time `json:"expires_at" description:"到期时间"`
|
||||
MasterUsageID *uint `json:"master_usage_id,omitempty" description:"主套餐ID(加油包时有值)"`
|
||||
Priority int `json:"priority" description:"优先级"`
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
}
|
||||
|
||||
// AssetResolveRequest 资产解析请求(路径参数)
|
||||
type AssetResolveRequest struct {
|
||||
Identifier string `path:"identifier" description:"资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN)" 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"`
|
||||
}
|
||||
|
||||
// DeviceIDRequest 设备ID请求(路径参数)
|
||||
type DeviceIDRequest struct {
|
||||
DeviceID uint `path:"device_id" description:"设备ID" required:"true"`
|
||||
}
|
||||
|
||||
// CardICCIDRequest 卡ICCID请求(路径参数)
|
||||
type CardICCIDRequest struct {
|
||||
ICCID string `path:"iccid" description:"卡ICCID" required:"true"`
|
||||
}
|
||||
@@ -10,7 +10,7 @@ type CommissionRecordResponse struct {
|
||||
IotCardID *uint `json:"iot_card_id" description:"关联卡ID"`
|
||||
IotCardICCID string `json:"iot_card_iccid" description:"卡ICCID"`
|
||||
DeviceID *uint `json:"device_id" description:"关联设备ID"`
|
||||
DeviceNo string `json:"device_no" description:"设备号"`
|
||||
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
|
||||
CommissionSource string `json:"commission_source" description:"佣金来源 (cost_diff:成本价差, one_time:一次性佣金)"`
|
||||
Amount int64 `json:"amount" description:"佣金金额(分)"`
|
||||
BalanceAfter int64 `json:"balance_after" description:"入账后钱包余额(分)"`
|
||||
|
||||
@@ -5,7 +5,7 @@ import "time"
|
||||
type ListDeviceRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
DeviceNo string `json:"device_no" query:"device_no" validate:"omitempty,max=100" maxLength:"100" description:"设备号(模糊查询)"`
|
||||
VirtualNo string `json:"virtual_no" query:"virtual_no" validate:"omitempty,max=100" maxLength:"100" description:"虚拟号(模糊查询)"`
|
||||
DeviceName string `json:"device_name" query:"device_name" validate:"omitempty,max=255" maxLength:"255" description:"设备名称(模糊查询)"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"`
|
||||
ShopID *uint `json:"shop_id" query:"shop_id" description:"店铺ID (NULL表示平台库存)"`
|
||||
@@ -19,7 +19,7 @@ type ListDeviceRequest struct {
|
||||
|
||||
type DeviceResponse struct {
|
||||
ID uint `json:"id" description:"设备ID"`
|
||||
DeviceNo string `json:"device_no" description:"设备虚拟号/别名"`
|
||||
VirtualNo string `json:"virtual_no" description:"设备虚拟号/别名"`
|
||||
IMEI string `json:"imei" description:"设备IMEI"`
|
||||
SN string `json:"sn" description:"设备序列号"`
|
||||
DeviceName string `json:"device_name" description:"设备名称"`
|
||||
@@ -109,9 +109,9 @@ type AllocateDevicesRequest struct {
|
||||
}
|
||||
|
||||
type AllocationDeviceFailedItem struct {
|
||||
DeviceID uint `json:"device_id" description:"设备ID"`
|
||||
DeviceNo string `json:"device_no" description:"设备号"`
|
||||
Reason string `json:"reason" description:"失败原因"`
|
||||
DeviceID uint `json:"device_id" description:"设备ID"`
|
||||
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
|
||||
Reason string `json:"reason" description:"失败原因"`
|
||||
}
|
||||
|
||||
type AllocateDevicesResponse struct {
|
||||
@@ -139,9 +139,9 @@ type BatchSetDeviceSeriesBindngRequest struct {
|
||||
|
||||
// DeviceSeriesBindngFailedItem 设备系列绑定失败项
|
||||
type DeviceSeriesBindngFailedItem struct {
|
||||
DeviceID uint `json:"device_id" description:"设备ID"`
|
||||
DeviceNo string `json:"device_no" description:"设备号"`
|
||||
Reason string `json:"reason" description:"失败原因"`
|
||||
DeviceID uint `json:"device_id" description:"设备ID"`
|
||||
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
|
||||
Reason string `json:"reason" description:"失败原因"`
|
||||
}
|
||||
|
||||
// BatchSetDeviceSeriesBindngResponse 批量设置设备的套餐系列绑定响应
|
||||
@@ -175,3 +175,17 @@ type SwitchCardRequest struct {
|
||||
type EmptyResponse struct {
|
||||
Message string `json:"message,omitempty" description:"提示信息"`
|
||||
}
|
||||
|
||||
// DeviceSuspendResponse 设备停机响应
|
||||
type DeviceSuspendResponse struct {
|
||||
SuccessCount int `json:"success_count" description:"成功停机卡数"`
|
||||
FailCount int `json:"fail_count" description:"失败卡数"`
|
||||
SkipCount int `json:"skip_count" description:"跳过卡数(未实名或已停机)"`
|
||||
FailedItems []DeviceSuspendFailItem `json:"failed_items,omitempty" description:"失败详情"`
|
||||
}
|
||||
|
||||
// DeviceSuspendFailItem 设备停机失败项
|
||||
type DeviceSuspendFailItem struct {
|
||||
ICCID string `json:"iccid" description:"卡ICCID"`
|
||||
Reason string `json:"reason" description:"失败原因"`
|
||||
}
|
||||
|
||||
@@ -49,9 +49,9 @@ type ListDeviceImportTaskResponse struct {
|
||||
}
|
||||
|
||||
type DeviceImportResultItemDTO struct {
|
||||
Line int `json:"line" description:"行号"`
|
||||
DeviceNo string `json:"device_no" description:"设备号"`
|
||||
Reason string `json:"reason" description:"原因"`
|
||||
Line int `json:"line" description:"行号"`
|
||||
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
|
||||
Reason string `json:"reason" description:"原因"`
|
||||
}
|
||||
|
||||
type GetDeviceImportTaskRequest struct {
|
||||
|
||||
@@ -16,7 +16,7 @@ type StandaloneCard struct {
|
||||
// Deprecated: 已废弃,不再支持通过单卡授权接口授权设备卡,请使用设备授权接口
|
||||
type DeviceBundle struct {
|
||||
DeviceID uint `json:"device_id" description:"设备ID"`
|
||||
DeviceNo string `json:"device_no" description:"设备号"`
|
||||
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
|
||||
TriggerCard DeviceBundleCard `json:"trigger_card" description:"触发卡(用户选择的卡)"`
|
||||
BundleCards []DeviceBundleCard `json:"bundle_cards" description:"连带卡(同设备的其他卡)"`
|
||||
}
|
||||
@@ -31,7 +31,7 @@ type DeviceBundleCard struct {
|
||||
// Deprecated: 已废弃,不再支持通过单卡授权接口授权设备卡,请使用设备授权接口
|
||||
type AllocatedDevice struct {
|
||||
DeviceID uint `json:"device_id" description:"设备ID"`
|
||||
DeviceNo string `json:"device_no" description:"设备号"`
|
||||
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
|
||||
CardCount int `json:"card_count" description:"卡数量"`
|
||||
ICCIDs []string `json:"iccids" description:"卡ICCID列表"`
|
||||
}
|
||||
@@ -74,7 +74,7 @@ type RecallCardsReq struct {
|
||||
|
||||
type RecalledDevice struct {
|
||||
DeviceID uint `json:"device_id" description:"设备ID"`
|
||||
DeviceNo string `json:"device_no" description:"设备号"`
|
||||
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
|
||||
CardCount int `json:"card_count" description:"卡数量"`
|
||||
ICCIDs []string `json:"iccids" description:"卡ICCID列表"`
|
||||
}
|
||||
@@ -93,7 +93,7 @@ type EnterpriseCardListReq struct {
|
||||
Status *int `json:"status" query:"status" description:"卡状态"`
|
||||
CarrierID *uint `json:"carrier_id" query:"carrier_id" description:"运营商ID"`
|
||||
ICCID string `json:"iccid" query:"iccid" description:"ICCID(模糊查询)"`
|
||||
DeviceNo string `json:"device_no" query:"device_no" description:"设备号(模糊查询)"`
|
||||
VirtualNo string `json:"virtual_no" query:"virtual_no" description:"虚拟号(模糊查询)"`
|
||||
}
|
||||
|
||||
type EnterpriseCardItem struct {
|
||||
@@ -101,7 +101,7 @@ type EnterpriseCardItem struct {
|
||||
ICCID string `json:"iccid" description:"ICCID"`
|
||||
MSISDN string `json:"msisdn" description:"手机号"`
|
||||
DeviceID *uint `json:"device_id,omitempty" description:"设备ID"`
|
||||
DeviceNo string `json:"device_no" description:"设备号"`
|
||||
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
|
||||
CarrierID uint `json:"carrier_id" description:"运营商ID"`
|
||||
CarrierName string `json:"carrier_name" description:"运营商名称"`
|
||||
PackageID *uint `json:"package_id,omitempty" description:"套餐ID"`
|
||||
|
||||
@@ -16,13 +16,13 @@ type AllocateDevicesResp struct {
|
||||
}
|
||||
|
||||
type FailedDeviceItem struct {
|
||||
DeviceNo string `json:"device_no" description:"设备号"`
|
||||
Reason string `json:"reason" description:"失败原因"`
|
||||
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
|
||||
Reason string `json:"reason" description:"失败原因"`
|
||||
}
|
||||
|
||||
type AuthorizedDeviceItem struct {
|
||||
DeviceID uint `json:"device_id" description:"设备ID"`
|
||||
DeviceNo string `json:"device_no" description:"设备号"`
|
||||
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
|
||||
CardCount int `json:"card_count" description:"绑定卡数量"`
|
||||
}
|
||||
|
||||
@@ -38,16 +38,16 @@ type RecallDevicesResp struct {
|
||||
}
|
||||
|
||||
type EnterpriseDeviceListReq struct {
|
||||
ID uint `json:"-" params:"id" path:"id" validate:"required" required:"true" description:"企业ID"`
|
||||
Page int `json:"page" query:"page" validate:"required,min=1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" description:"每页数量"`
|
||||
DeviceNo string `json:"device_no" query:"device_no" description:"设备号(模糊搜索)"`
|
||||
ID uint `json:"-" params:"id" path:"id" validate:"required" required:"true" description:"企业ID"`
|
||||
Page int `json:"page" query:"page" validate:"required,min=1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" description:"每页数量"`
|
||||
VirtualNo string `json:"virtual_no" query:"virtual_no" description:"虚拟号(模糊搜索)"`
|
||||
}
|
||||
|
||||
type H5EnterpriseDeviceListReq struct {
|
||||
Page int `json:"page" query:"page" validate:"required,min=1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" description:"每页数量"`
|
||||
DeviceNo string `json:"device_no" query:"device_no" description:"设备号(模糊搜索)"`
|
||||
Page int `json:"page" query:"page" validate:"required,min=1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" description:"每页数量"`
|
||||
VirtualNo string `json:"virtual_no" query:"virtual_no" description:"虚拟号(模糊搜索)"`
|
||||
}
|
||||
|
||||
type EnterpriseDeviceListResp struct {
|
||||
@@ -57,7 +57,7 @@ type EnterpriseDeviceListResp struct {
|
||||
|
||||
type EnterpriseDeviceItem struct {
|
||||
DeviceID uint `json:"device_id" description:"设备ID"`
|
||||
DeviceNo string `json:"device_no" description:"设备号"`
|
||||
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
|
||||
DeviceName string `json:"device_name" description:"设备名称"`
|
||||
DeviceModel string `json:"device_model" description:"设备型号"`
|
||||
CardCount int `json:"card_count" description:"绑定卡数量"`
|
||||
@@ -71,7 +71,7 @@ type EnterpriseDeviceDetailResp struct {
|
||||
|
||||
type EnterpriseDeviceInfo struct {
|
||||
DeviceID uint `json:"device_id" description:"设备ID"`
|
||||
DeviceNo string `json:"device_no" description:"设备号"`
|
||||
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
|
||||
DeviceName string `json:"device_name" description:"设备名称"`
|
||||
DeviceModel string `json:"device_model" description:"设备型号"`
|
||||
DeviceType string `json:"device_type" description:"设备类型"`
|
||||
|
||||
@@ -43,7 +43,7 @@ type MyCommissionRecordListReq struct {
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
CommissionSource *string `json:"commission_source" query:"commission_source" validate:"omitempty,oneof=cost_diff one_time tier_bonus" description:"佣金来源 (cost_diff:成本价差, one_time:一次性佣金, tier_bonus(已废弃):梯度奖励)"`
|
||||
ICCID string `json:"iccid" query:"iccid" description:"ICCID(模糊查询)"`
|
||||
DeviceNo string `json:"device_no" query:"device_no" description:"设备号(模糊查询)"`
|
||||
VirtualNo string `json:"virtual_no" query:"virtual_no" description:"设备虚拟号(模糊查询)"`
|
||||
OrderNo string `json:"order_no" query:"order_no" description:"订单号(模糊查询)"`
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ type ShopCommissionRecordListReq struct {
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量(默认20,最大100)"`
|
||||
CommissionSource string `json:"commission_source" query:"commission_source" validate:"omitempty,oneof=cost_diff one_time tier_bonus" description:"佣金来源 (cost_diff:成本价差, one_time:一次性佣金, tier_bonus(已废弃):梯度奖励)"`
|
||||
ICCID string `json:"iccid" query:"iccid" validate:"omitempty,max=50" maxLength:"50" description:"ICCID(模糊查询)"`
|
||||
DeviceNo string `json:"device_no" query:"device_no" validate:"omitempty,max=50" maxLength:"50" description:"设备号(模糊查询)"`
|
||||
VirtualNo string `json:"virtual_no" query:"virtual_no" validate:"omitempty,max=50" maxLength:"50" description:"设备虚拟号(模糊查询)"`
|
||||
OrderNo string `json:"order_no" query:"order_no" validate:"omitempty,max=50" maxLength:"50" description:"订单号(模糊查询)"`
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ type ShopCommissionRecordItem struct {
|
||||
StatusName string `json:"status_name" description:"状态名称"`
|
||||
OrderID uint `json:"order_id" description:"订单ID"`
|
||||
OrderNo string `json:"order_no" description:"订单号"`
|
||||
DeviceNo string `json:"device_no,omitempty" description:"设备号"`
|
||||
VirtualNo string `json:"virtual_no,omitempty" description:"设备虚拟号"`
|
||||
ICCID string `json:"iccid,omitempty" description:"ICCID"`
|
||||
OrderCreatedAt string `json:"order_created_at" description:"订单创建时间"`
|
||||
CreatedAt string `json:"created_at" description:"佣金入账时间"`
|
||||
|
||||
@@ -49,6 +49,7 @@ type IotCard struct {
|
||||
ResumedAt *time.Time `gorm:"column:resumed_at;comment:最近复机时间" json:"resumed_at,omitempty"`
|
||||
StopReason string `gorm:"column:stop_reason;type:varchar(50);comment:停机原因(traffic_exhausted=流量耗尽,manual=手动停机,arrears=欠费)" json:"stop_reason,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
@@ -33,10 +33,11 @@ type IotCardImportTask struct {
|
||||
StorageKey string `gorm:"column:storage_key;type:varchar(500);comment:对象存储文件路径" json:"storage_key,omitempty"`
|
||||
}
|
||||
|
||||
// CardItem 卡信息(ICCID + MSISDN)
|
||||
// CardItem 卡信息(ICCID + MSISDN + VirtualNo)
|
||||
type CardItem struct {
|
||||
ICCID string `json:"iccid"`
|
||||
MSISDN string `json:"msisdn"`
|
||||
ICCID string `json:"iccid"`
|
||||
MSISDN string `json:"msisdn"`
|
||||
VirtualNo string `json:"virtual_no,omitempty"`
|
||||
}
|
||||
|
||||
type CardListJSON []CardItem
|
||||
|
||||
@@ -30,22 +30,23 @@ func (PackageSeries) TableName() string {
|
||||
type Package struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
PackageCode string `gorm:"column:package_code;type:varchar(100);uniqueIndex:idx_package_code,where:deleted_at IS NULL;not null;comment:套餐编码" json:"package_code"`
|
||||
PackageName string `gorm:"column:package_name;type:varchar(255);not null;comment:套餐名称" json:"package_name"`
|
||||
SeriesID uint `gorm:"column:series_id;index;comment:套餐系列ID" json:"series_id"`
|
||||
PackageType string `gorm:"column:package_type;type:varchar(50);not null;comment:套餐类型 formal-正式套餐 addon-附加套餐" json:"package_type"`
|
||||
DurationMonths int `gorm:"column:duration_months;type:int;not null;comment:套餐时长(月数) 1-月套餐 12-年套餐" json:"duration_months"`
|
||||
RealDataMB int64 `gorm:"column:real_data_mb;type:bigint;default:0;comment:真流量额度(MB)" json:"real_data_mb"`
|
||||
VirtualDataMB int64 `gorm:"column:virtual_data_mb;type:bigint;default:0;comment:虚流量额度(MB,用于停机判断)" json:"virtual_data_mb"`
|
||||
EnableVirtualData bool `gorm:"column:enable_virtual_data;type:boolean;default:false;not null;comment:是否启用虚流量" json:"enable_virtual_data"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
||||
CostPrice int64 `gorm:"column:cost_price;type:bigint;default:0;comment:成本价(分为单位)" json:"cost_price"`
|
||||
SuggestedRetailPrice int64 `gorm:"column:suggested_retail_price;type:bigint;default:0;comment:建议售价(分为单位)" json:"suggested_retail_price"`
|
||||
ShelfStatus int `gorm:"column:shelf_status;type:int;default:2;not null;comment:上架状态 1-上架 2-下架" json:"shelf_status"`
|
||||
CalendarType string `gorm:"column:calendar_type;type:varchar(20);default:'by_day';comment:套餐周期类型 natural_month-自然月 by_day-按天" json:"calendar_type"`
|
||||
DurationDays int `gorm:"column:duration_days;type:int;comment:套餐天数(calendar_type=by_day时必填)" json:"duration_days"`
|
||||
DataResetCycle string `gorm:"column:data_reset_cycle;type:varchar(20);default:'monthly';comment:流量重置周期 daily-每日 monthly-每月 yearly-每年 none-不重置" json:"data_reset_cycle"`
|
||||
EnableRealnameActivation bool `gorm:"column:enable_realname_activation;type:boolean;default:true;comment:是否启用实名激活 true-需实名后激活 false-立即激活" json:"enable_realname_activation"`
|
||||
PackageCode string `gorm:"column:package_code;type:varchar(100);uniqueIndex:idx_package_code,where:deleted_at IS NULL;not null;comment:套餐编码" json:"package_code"`
|
||||
PackageName string `gorm:"column:package_name;type:varchar(255);not null;comment:套餐名称" json:"package_name"`
|
||||
SeriesID uint `gorm:"column:series_id;index;comment:套餐系列ID" json:"series_id"`
|
||||
PackageType string `gorm:"column:package_type;type:varchar(50);not null;comment:套餐类型 formal-正式套餐 addon-附加套餐" json:"package_type"`
|
||||
DurationMonths int `gorm:"column:duration_months;type:int;not null;comment:套餐时长(月数) 1-月套餐 12-年套餐" json:"duration_months"`
|
||||
RealDataMB int64 `gorm:"column:real_data_mb;type:bigint;default:0;comment:真流量额度(MB)" json:"real_data_mb"`
|
||||
VirtualDataMB int64 `gorm:"column:virtual_data_mb;type:bigint;default:0;comment:虚流量额度(MB,用于停机判断)" json:"virtual_data_mb"`
|
||||
EnableVirtualData bool `gorm:"column:enable_virtual_data;type:boolean;default:false;not null;comment:是否启用虚流量" json:"enable_virtual_data"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
||||
CostPrice int64 `gorm:"column:cost_price;type:bigint;default:0;comment:成本价(分为单位)" json:"cost_price"`
|
||||
SuggestedRetailPrice int64 `gorm:"column:suggested_retail_price;type:bigint;default:0;comment:建议售价(分为单位)" json:"suggested_retail_price"`
|
||||
ShelfStatus int `gorm:"column:shelf_status;type:int;default:2;not null;comment:上架状态 1-上架 2-下架" json:"shelf_status"`
|
||||
CalendarType string `gorm:"column:calendar_type;type:varchar(20);default:'by_day';comment:套餐周期类型 natural_month-自然月 by_day-按天" json:"calendar_type"`
|
||||
DurationDays int `gorm:"column:duration_days;type:int;comment:套餐天数(calendar_type=by_day时必填)" json:"duration_days"`
|
||||
DataResetCycle string `gorm:"column:data_reset_cycle;type:varchar(20);default:'monthly';comment:流量重置周期 daily-每日 monthly-每月 yearly-每年 none-不重置" json:"data_reset_cycle"`
|
||||
EnableRealnameActivation bool `gorm:"column:enable_realname_activation;type:boolean;default:true;comment:是否启用实名激活 true-需实名后激活 false-立即激活" json:"enable_realname_activation"`
|
||||
VirtualRatio float64 `gorm:"column:virtual_ratio;type:decimal(18,6);default:1.0;comment:虚流量比例(real_data_mb/virtual_data_mb),创建套餐时计算存储" json:"virtual_ratio"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
@@ -119,7 +120,7 @@ type OneTimeCommissionConfig struct {
|
||||
|
||||
// OneTimeCommissionTier 一次性佣金梯度配置
|
||||
type OneTimeCommissionTier struct {
|
||||
Operator string `json:"operator"` // 阈值比较运算符:>、>=、<、<=,空值默认 >=
|
||||
Operator string `json:"operator"` // 阈值比较运算符:>、>=、<、<=,空值默认 >=
|
||||
Dimension string `json:"dimension"`
|
||||
StatScope string `json:"stat_scope"`
|
||||
Threshold int64 `json:"threshold"`
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
type PersonalCustomerDevice struct {
|
||||
gorm.Model
|
||||
CustomerID uint `gorm:"column:customer_id;type:bigint;not null;comment:关联个人客户ID" json:"customer_id"`
|
||||
DeviceNo string `gorm:"column:device_no;type:varchar(50);not null;comment:设备号/IMEI" json:"device_no"`
|
||||
VirtualNo string `gorm:"column:virtual_no;type:varchar(50);not null;comment:设备虚拟号/IMEI" json:"virtual_no"`
|
||||
BindAt time.Time `gorm:"column:bind_at;type:timestamp;not null;comment:绑定时间" json:"bind_at"`
|
||||
LastUsedAt time.Time `gorm:"column:last_used_at;type:timestamp;comment:最后使用时间" json:"last_used_at"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
|
||||
|
||||
@@ -107,4 +107,7 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
|
||||
if handlers.PollingManualTrigger != nil {
|
||||
registerPollingManualTriggerRoutes(authGroup, handlers.PollingManualTrigger, doc, basePath)
|
||||
}
|
||||
if handlers.Asset != nil {
|
||||
registerAssetRoutes(authGroup, handlers.Asset, doc, basePath)
|
||||
}
|
||||
}
|
||||
|
||||
94
internal/routes/asset.go
Normal file
94
internal/routes/asset.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package routes
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, doc *openapi.Generator, basePath string) {
|
||||
assets := router.Group("/assets")
|
||||
groupPath := basePath + "/assets"
|
||||
|
||||
Register(assets, doc, groupPath, "GET", "/resolve/:identifier", handler.Resolve, RouteSpec{
|
||||
Summary: "解析资产",
|
||||
Description: "通过虚拟号/ICCID/IMEI/SN/MSISDN 解析设备或卡的完整详情。企业账号禁止调用。",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.AssetResolveRequest),
|
||||
Output: new(dto.AssetResolveResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "GET", "/:asset_type/:id/realtime-status", handler.RealtimeStatus, RouteSpec{
|
||||
Summary: "资产实时状态",
|
||||
Description: "读取 DB/Redis 中的持久化状态,不调网关。asset_type 为 card 或 device。",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.AssetTypeIDRequest),
|
||||
Output: new(dto.AssetRealtimeStatusResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "POST", "/:asset_type/:id/refresh", handler.Refresh, RouteSpec{
|
||||
Summary: "刷新资产状态",
|
||||
Description: "主动调网关同步最新状态。设备有30秒冷却期。",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.AssetTypeIDRequest),
|
||||
Output: new(dto.AssetRealtimeStatusResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "GET", "/:asset_type/:id/packages", handler.Packages, RouteSpec{
|
||||
Summary: "资产套餐列表",
|
||||
Description: "查询该资产所有套餐记录,含虚流量换算结果。",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.AssetTypeIDRequest),
|
||||
Output: new([]dto.AssetPackageResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "GET", "/:asset_type/:id/current-package", handler.CurrentPackage, RouteSpec{
|
||||
Summary: "当前生效套餐",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.AssetTypeIDRequest),
|
||||
Output: new(dto.AssetPackageResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "POST", "/device/:device_id/stop", handler.StopDevice, RouteSpec{
|
||||
Summary: "设备停机",
|
||||
Description: "批量停机设备下所有已实名卡。设置1小时停机保护期。",
|
||||
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),
|
||||
Output: nil,
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "POST", "/card/:iccid/stop", handler.StopCard, RouteSpec{
|
||||
Summary: "单卡停机",
|
||||
Description: "手动停机单张卡(通过ICCID)。受设备保护期约束。",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.CardICCIDRequest),
|
||||
Output: nil,
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "POST", "/card/:iccid/start", handler.StartCard, RouteSpec{
|
||||
Summary: "单卡复机",
|
||||
Description: "手动复机单张卡(通过ICCID)。受设备保护期约束。",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.CardICCIDRequest),
|
||||
Output: nil,
|
||||
Auth: true,
|
||||
})
|
||||
}
|
||||
@@ -21,23 +21,6 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(devices, doc, groupPath, "GET", "/:id", handler.GetByID, RouteSpec{
|
||||
Summary: "设备详情",
|
||||
Tags: []string{"设备管理"},
|
||||
Input: new(dto.GetDeviceRequest),
|
||||
Output: new(dto.DeviceResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(devices, doc, groupPath, "GET", "/by-identifier/:identifier", handler.GetByIdentifier, RouteSpec{
|
||||
Summary: "通过标识符查询设备详情",
|
||||
Description: "支持通过虚拟号(device_no)、IMEI、SN 任意一个标识符查询设备。",
|
||||
Tags: []string{"设备管理"},
|
||||
Input: new(dto.GetDeviceByIdentifierRequest),
|
||||
Output: new(dto.DeviceResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(devices, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{
|
||||
Summary: "删除设备",
|
||||
Description: "仅平台用户可操作。删除设备时自动解绑所有卡(卡不会被删除)。",
|
||||
@@ -146,15 +129,6 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(devices, doc, groupPath, "GET", "/by-identifier/:identifier/gateway-info", handler.GetGatewayInfo, RouteSpec{
|
||||
Summary: "查询设备信息",
|
||||
Description: "通过虚拟号/IMEI/SN 查询设备网关信息。设备必须已配置 IMEI。",
|
||||
Tags: []string{"设备管理"},
|
||||
Input: new(dto.GetDeviceByIdentifierRequest),
|
||||
Output: new(gateway.DeviceInfoResp),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(devices, doc, groupPath, "GET", "/by-identifier/:identifier/gateway-slots", handler.GetGatewaySlots, RouteSpec{
|
||||
Summary: "查询卡槽信息",
|
||||
Description: "通过虚拟号/IMEI/SN 查询设备卡槽信息。设备必须已配置 IMEI。",
|
||||
|
||||
@@ -36,19 +36,4 @@ func registerEnterpriseCardRoutes(router fiber.Router, handler *admin.Enterprise
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(enterprises, doc, groupPath, "POST", "/:id/cards/:card_id/suspend", handler.SuspendCard, RouteSpec{
|
||||
Summary: "停机卡",
|
||||
Tags: []string{"企业卡授权"},
|
||||
Input: new(dto.SuspendCardReq),
|
||||
Output: nil,
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(enterprises, doc, groupPath, "POST", "/:id/cards/:card_id/resume", handler.ResumeCard, RouteSpec{
|
||||
Summary: "复机卡",
|
||||
Tags: []string{"企业卡授权"},
|
||||
Input: new(dto.ResumeCardReq),
|
||||
Output: nil,
|
||||
Auth: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,19 +28,4 @@ func registerH5EnterpriseDeviceRoutes(router fiber.Router, handler *h5.Enterpris
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(devices, doc, groupPath, "POST", "/:device_id/cards/:card_id/suspend", handler.SuspendCard, RouteSpec{
|
||||
Summary: "停机卡(H5)",
|
||||
Tags: []string{"H5-企业设备"},
|
||||
Input: new(dto.DeviceCardOperationReq),
|
||||
Output: new(dto.DeviceCardOperationResp),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(devices, doc, groupPath, "POST", "/:device_id/cards/:card_id/resume", handler.ResumeCard, RouteSpec{
|
||||
Summary: "复机卡(H5)",
|
||||
Tags: []string{"H5-企业设备"},
|
||||
Input: new(dto.DeviceCardOperationReq),
|
||||
Output: new(dto.DeviceCardOperationResp),
|
||||
Auth: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,14 +21,6 @@ func registerIotCardRoutes(router fiber.Router, handler *admin.IotCardHandler, i
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(iotCards, doc, groupPath, "GET", "/by-iccid/:iccid", handler.GetByICCID, RouteSpec{
|
||||
Summary: "通过ICCID查询单卡详情",
|
||||
Tags: []string{"IoT卡管理"},
|
||||
Input: new(dto.GetIotCardByICCIDRequest),
|
||||
Output: new(dto.IotCardDetailResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(iotCards, doc, groupPath, "POST", "/import", importHandler.Import, RouteSpec{
|
||||
Summary: "批量导入IoT卡(ICCID+MSISDN)",
|
||||
Description: `仅平台用户可操作。
|
||||
@@ -109,30 +101,6 @@ func registerIotCardRoutes(router fiber.Router, handler *admin.IotCardHandler, i
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(iotCards, doc, groupPath, "GET", "/:iccid/gateway-status", handler.GetGatewayStatus, RouteSpec{
|
||||
Summary: "查询卡实时状态",
|
||||
Tags: []string{"IoT卡管理"},
|
||||
Input: new(dto.GetIotCardByICCIDRequest),
|
||||
Output: new(gateway.CardStatusResp),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(iotCards, doc, groupPath, "GET", "/:iccid/gateway-flow", handler.GetGatewayFlow, RouteSpec{
|
||||
Summary: "查询流量使用",
|
||||
Tags: []string{"IoT卡管理"},
|
||||
Input: new(dto.GetIotCardByICCIDRequest),
|
||||
Output: new(gateway.FlowUsageResp),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(iotCards, doc, groupPath, "GET", "/:iccid/gateway-realname", handler.GetGatewayRealname, RouteSpec{
|
||||
Summary: "查询实名认证状态",
|
||||
Tags: []string{"IoT卡管理"},
|
||||
Input: new(dto.GetIotCardByICCIDRequest),
|
||||
Output: new(gateway.RealnameStatusResp),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(iotCards, doc, groupPath, "GET", "/:iccid/realname-link", handler.GetRealnameLink, RouteSpec{
|
||||
Summary: "获取实名认证链接",
|
||||
Tags: []string{"IoT卡管理"},
|
||||
@@ -141,19 +109,4 @@ func registerIotCardRoutes(router fiber.Router, handler *admin.IotCardHandler, i
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(iotCards, doc, groupPath, "POST", "/:iccid/stop", handler.StopCard, RouteSpec{
|
||||
Summary: "停机",
|
||||
Tags: []string{"IoT卡管理"},
|
||||
Input: new(dto.GetIotCardByICCIDRequest),
|
||||
Output: nil,
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(iotCards, doc, groupPath, "POST", "/:iccid/start", handler.StartCard, RouteSpec{
|
||||
Summary: "复机",
|
||||
Tags: []string{"IoT卡管理"},
|
||||
Input: new(dto.GetIotCardByICCIDRequest),
|
||||
Output: nil,
|
||||
Auth: true,
|
||||
})
|
||||
}
|
||||
|
||||
477
internal/service/asset/service.go
Normal file
477
internal/service/asset/service.go
Normal file
@@ -0,0 +1,477 @@
|
||||
// Package asset 提供统一的资产查询与操作服务
|
||||
// 资产包含两种类型:IoT卡(card)和设备(device)
|
||||
// 支持资产解析、实时状态查询、网关刷新、套餐查询等功能
|
||||
package asset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// IotCardRefresher 用于调用 RefreshCardDataFromGateway,避免循环依赖
|
||||
type IotCardRefresher interface {
|
||||
RefreshCardDataFromGateway(ctx context.Context, iccid string) error
|
||||
}
|
||||
|
||||
// Service 资产查询与操作服务
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
deviceStore *postgres.DeviceStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
packageUsageStore *postgres.PackageUsageStore
|
||||
packageStore *postgres.PackageStore
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore
|
||||
shopStore *postgres.ShopStore
|
||||
redis *redis.Client
|
||||
iotCardService IotCardRefresher
|
||||
}
|
||||
|
||||
// New 创建资产服务实例
|
||||
func New(
|
||||
db *gorm.DB,
|
||||
deviceStore *postgres.DeviceStore,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
packageUsageStore *postgres.PackageUsageStore,
|
||||
packageStore *postgres.PackageStore,
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore,
|
||||
shopStore *postgres.ShopStore,
|
||||
redisClient *redis.Client,
|
||||
iotCardService IotCardRefresher,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
deviceStore: deviceStore,
|
||||
iotCardStore: iotCardStore,
|
||||
packageUsageStore: packageUsageStore,
|
||||
packageStore: packageStore,
|
||||
deviceSimBindingStore: deviceSimBindingStore,
|
||||
shopStore: shopStore,
|
||||
redis: redisClient,
|
||||
iotCardService: iotCardService,
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve 通过任意标识符解析资产
|
||||
// 优先匹配设备(virtual_no/imei/sn),未命中则匹配卡(virtual_no/iccid/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)
|
||||
}
|
||||
|
||||
// 未找到设备,查 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)
|
||||
}
|
||||
|
||||
return nil, errors.New(errors.CodeNotFound, "未找到匹配的资产")
|
||||
}
|
||||
|
||||
// buildDeviceResolveResponse 构建设备类型的资产解析响应
|
||||
func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model.Device) (*dto.AssetResolveResponse, error) {
|
||||
resp := &dto.AssetResolveResponse{
|
||||
AssetType: "device",
|
||||
AssetID: device.ID,
|
||||
VirtualNo: device.VirtualNo,
|
||||
Status: device.Status,
|
||||
BatchNo: device.BatchNo,
|
||||
ShopID: device.ShopID,
|
||||
SeriesID: device.SeriesID,
|
||||
FirstCommissionPaid: device.FirstCommissionPaid,
|
||||
AccumulatedRecharge: device.AccumulatedRecharge,
|
||||
ActivatedAt: device.ActivatedAt,
|
||||
CreatedAt: device.CreatedAt,
|
||||
UpdatedAt: device.UpdatedAt,
|
||||
DeviceName: device.DeviceName,
|
||||
IMEI: device.IMEI,
|
||||
SN: device.SN,
|
||||
DeviceModel: device.DeviceModel,
|
||||
DeviceType: device.DeviceType,
|
||||
MaxSimSlots: device.MaxSimSlots,
|
||||
Manufacturer: device.Manufacturer,
|
||||
}
|
||||
|
||||
// 查绑定卡
|
||||
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, device.ID)
|
||||
if err == nil && len(bindings) > 0 {
|
||||
resp.BoundCardCount = len(bindings)
|
||||
cardIDs := make([]uint, 0, len(bindings))
|
||||
slotMap := make(map[uint]int, len(bindings))
|
||||
for _, b := range bindings {
|
||||
cardIDs = append(cardIDs, b.IotCardID)
|
||||
slotMap[b.IotCardID] = b.SlotPosition
|
||||
}
|
||||
cards, _ := s.iotCardStore.GetByIDs(ctx, cardIDs)
|
||||
for _, c := range cards {
|
||||
resp.Cards = append(resp.Cards, dto.BoundCardInfo{
|
||||
CardID: c.ID,
|
||||
ICCID: c.ICCID,
|
||||
MSISDN: c.MSISDN,
|
||||
NetworkStatus: c.NetworkStatus,
|
||||
RealNameStatus: c.RealNameStatus,
|
||||
SlotPosition: slotMap[c.ID],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 查当前主套餐
|
||||
s.fillPackageInfo(ctx, resp, "device", device.ID)
|
||||
|
||||
// 查 shop 名称
|
||||
s.fillShopName(ctx, resp)
|
||||
|
||||
// 查 Redis 保护期
|
||||
resp.DeviceProtectStatus = s.getDeviceProtectStatus(ctx, device.ID)
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// buildCardResolveResponse 构建卡类型的资产解析响应
|
||||
func (s *Service) buildCardResolveResponse(ctx context.Context, card *model.IotCard) (*dto.AssetResolveResponse, error) {
|
||||
resp := &dto.AssetResolveResponse{
|
||||
AssetType: "card",
|
||||
AssetID: card.ID,
|
||||
VirtualNo: card.VirtualNo,
|
||||
Status: card.Status,
|
||||
BatchNo: card.BatchNo,
|
||||
ShopID: card.ShopID,
|
||||
SeriesID: card.SeriesID,
|
||||
FirstCommissionPaid: card.FirstCommissionPaid,
|
||||
AccumulatedRecharge: card.AccumulatedRecharge,
|
||||
ActivatedAt: card.ActivatedAt,
|
||||
CreatedAt: card.CreatedAt,
|
||||
UpdatedAt: card.UpdatedAt,
|
||||
RealNameStatus: card.RealNameStatus,
|
||||
NetworkStatus: card.NetworkStatus,
|
||||
ICCID: card.ICCID,
|
||||
CarrierID: card.CarrierID,
|
||||
CarrierType: card.CarrierType,
|
||||
CarrierName: card.CarrierName,
|
||||
MSISDN: card.MSISDN,
|
||||
IMSI: card.IMSI,
|
||||
CardCategory: card.CardCategory,
|
||||
Supplier: card.Supplier,
|
||||
ActivationStatus: card.ActivationStatus,
|
||||
EnablePolling: card.EnablePolling,
|
||||
}
|
||||
|
||||
// 查绑定设备
|
||||
binding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
|
||||
if err == nil && binding != nil {
|
||||
resp.BoundDeviceID = &binding.DeviceID
|
||||
device, devErr := s.deviceStore.GetByID(ctx, binding.DeviceID)
|
||||
if devErr == nil && device != nil {
|
||||
resp.BoundDeviceNo = device.VirtualNo
|
||||
resp.BoundDeviceName = device.DeviceName
|
||||
}
|
||||
}
|
||||
|
||||
// 查当前主套餐
|
||||
s.fillPackageInfo(ctx, resp, "iot_card", card.ID)
|
||||
|
||||
// 查 shop 名称
|
||||
s.fillShopName(ctx, resp)
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// fillPackageInfo 填充当前主套餐信息到响应中
|
||||
func (s *Service) fillPackageInfo(ctx context.Context, resp *dto.AssetResolveResponse, carrierType string, carrierID uint) {
|
||||
usage, err := s.packageUsageStore.GetActiveMainPackage(ctx, carrierType, carrierID)
|
||||
if err != nil || usage == nil {
|
||||
return
|
||||
}
|
||||
|
||||
pkg, err := s.packageStore.GetByID(ctx, usage.PackageID)
|
||||
if err != nil || pkg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
resp.CurrentPackage = pkg.PackageName
|
||||
ratio := safeVirtualRatio(pkg.VirtualRatio)
|
||||
resp.PackageTotalMB = int64(float64(usage.DataLimitMB) / ratio)
|
||||
resp.PackageUsedMB = float64(usage.DataUsageMB) / ratio
|
||||
resp.PackageRemainMB = float64(usage.DataLimitMB-usage.DataUsageMB) / ratio
|
||||
}
|
||||
|
||||
// fillShopName 填充店铺名称
|
||||
func (s *Service) fillShopName(ctx context.Context, resp *dto.AssetResolveResponse) {
|
||||
if resp.ShopID == nil || *resp.ShopID == 0 {
|
||||
return
|
||||
}
|
||||
shop, err := s.shopStore.GetByID(ctx, *resp.ShopID)
|
||||
if err == nil && shop != nil {
|
||||
resp.ShopName = shop.ShopName
|
||||
}
|
||||
}
|
||||
|
||||
// GetRealtimeStatus 获取资产实时状态(只读DB/Redis)
|
||||
func (s *Service) GetRealtimeStatus(ctx context.Context, assetType string, id uint) (*dto.AssetRealtimeStatusResponse, error) {
|
||||
resp := &dto.AssetRealtimeStatusResponse{
|
||||
AssetType: assetType,
|
||||
AssetID: id,
|
||||
}
|
||||
|
||||
switch assetType {
|
||||
case "card":
|
||||
card, err := s.iotCardStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeNotFound, err, "卡不存在")
|
||||
}
|
||||
resp.NetworkStatus = card.NetworkStatus
|
||||
resp.RealNameStatus = card.RealNameStatus
|
||||
resp.CurrentMonthUsageMB = card.CurrentMonthUsageMB
|
||||
resp.LastSyncTime = card.LastSyncTime
|
||||
|
||||
case "device":
|
||||
// 查绑定卡状态列表
|
||||
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, id)
|
||||
if err == nil && len(bindings) > 0 {
|
||||
cardIDs := make([]uint, 0, len(bindings))
|
||||
slotMap := make(map[uint]int, len(bindings))
|
||||
for _, b := range bindings {
|
||||
cardIDs = append(cardIDs, b.IotCardID)
|
||||
slotMap[b.IotCardID] = b.SlotPosition
|
||||
}
|
||||
cards, _ := s.iotCardStore.GetByIDs(ctx, cardIDs)
|
||||
for _, c := range cards {
|
||||
resp.Cards = append(resp.Cards, dto.BoundCardInfo{
|
||||
CardID: c.ID,
|
||||
ICCID: c.ICCID,
|
||||
MSISDN: c.MSISDN,
|
||||
NetworkStatus: c.NetworkStatus,
|
||||
RealNameStatus: c.RealNameStatus,
|
||||
SlotPosition: slotMap[c.ID],
|
||||
})
|
||||
}
|
||||
}
|
||||
resp.DeviceProtectStatus = s.getDeviceProtectStatus(ctx, id)
|
||||
|
||||
default:
|
||||
return nil, errors.New(errors.CodeInvalidParam, "不支持的资产类型,仅支持 card 或 device")
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Refresh 刷新资产数据(调网关同步)
|
||||
func (s *Service) Refresh(ctx context.Context, assetType string, id uint) (*dto.AssetRealtimeStatusResponse, error) {
|
||||
switch assetType {
|
||||
case "card":
|
||||
card, err := s.iotCardStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeNotFound, err, "卡不存在")
|
||||
}
|
||||
if err := s.iotCardService.RefreshCardDataFromGateway(ctx, card.ICCID); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "刷新卡数据失败")
|
||||
}
|
||||
return s.GetRealtimeStatus(ctx, "card", id)
|
||||
|
||||
case "device":
|
||||
// 检查冷却期
|
||||
cooldownKey := constants.RedisDeviceRefreshCooldownKey(id)
|
||||
if s.redis.Exists(ctx, cooldownKey).Val() > 0 {
|
||||
return nil, errors.New(errors.CodeTooManyRequests, "刷新过于频繁,请30秒后再试")
|
||||
}
|
||||
|
||||
// 查所有绑定卡,逐一刷新
|
||||
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询绑定卡失败")
|
||||
}
|
||||
for _, b := range bindings {
|
||||
card, cardErr := s.iotCardStore.GetByID(ctx, b.IotCardID)
|
||||
if cardErr != nil {
|
||||
logger.GetAppLogger().Warn("刷新设备绑定卡失败:查卡失败",
|
||||
zap.Uint("device_id", id),
|
||||
zap.Uint("card_id", b.IotCardID),
|
||||
zap.Error(cardErr))
|
||||
continue
|
||||
}
|
||||
if refreshErr := s.iotCardService.RefreshCardDataFromGateway(ctx, card.ICCID); refreshErr != nil {
|
||||
logger.GetAppLogger().Warn("刷新设备绑定卡失败:网关调用失败",
|
||||
zap.Uint("device_id", id),
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.Error(refreshErr))
|
||||
}
|
||||
}
|
||||
|
||||
// 设置冷却 Key
|
||||
s.redis.Set(ctx, cooldownKey, 1, constants.DeviceRefreshCooldownDuration)
|
||||
|
||||
return s.GetRealtimeStatus(ctx, "device", id)
|
||||
|
||||
default:
|
||||
return nil, errors.New(errors.CodeInvalidParam, "不支持的资产类型,仅支持 card 或 device")
|
||||
}
|
||||
}
|
||||
|
||||
// GetPackages 获取资产的所有套餐列表
|
||||
func (s *Service) GetPackages(ctx context.Context, assetType string, id uint) ([]*dto.AssetPackageResponse, error) {
|
||||
// assetType 对应 Store 中的 carrierType:card→iot_card, device→device
|
||||
carrierType := assetType
|
||||
if assetType == "card" {
|
||||
carrierType = "iot_card"
|
||||
}
|
||||
|
||||
usages, err := s.packageUsageStore.ListByCarrier(ctx, carrierType, id)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐使用记录失败")
|
||||
}
|
||||
|
||||
// 收集所有 PackageID 并批量查询
|
||||
pkgIDSet := make(map[uint]struct{}, len(usages))
|
||||
for _, u := range usages {
|
||||
pkgIDSet[u.PackageID] = struct{}{}
|
||||
}
|
||||
pkgIDs := make([]uint, 0, len(pkgIDSet))
|
||||
for id := range pkgIDSet {
|
||||
pkgIDs = append(pkgIDs, id)
|
||||
}
|
||||
packages, _ := s.packageStore.GetByIDsUnscoped(ctx, pkgIDs)
|
||||
pkgMap := make(map[uint]*model.Package, len(packages))
|
||||
for _, p := range packages {
|
||||
pkgMap[p.ID] = p
|
||||
}
|
||||
|
||||
result := make([]*dto.AssetPackageResponse, 0, len(usages))
|
||||
for _, u := range usages {
|
||||
pkg := pkgMap[u.PackageID]
|
||||
ratio := 1.0
|
||||
pkgName := ""
|
||||
pkgType := ""
|
||||
if pkg != nil {
|
||||
ratio = safeVirtualRatio(pkg.VirtualRatio)
|
||||
pkgName = pkg.PackageName
|
||||
pkgType = pkg.PackageType
|
||||
}
|
||||
|
||||
item := &dto.AssetPackageResponse{
|
||||
PackageUsageID: u.ID,
|
||||
PackageID: u.PackageID,
|
||||
PackageName: pkgName,
|
||||
PackageType: pkgType,
|
||||
UsageType: u.UsageType,
|
||||
Status: u.Status,
|
||||
StatusName: packageStatusName(u.Status),
|
||||
DataLimitMB: u.DataLimitMB,
|
||||
VirtualLimitMB: int64(float64(u.DataLimitMB) / ratio),
|
||||
DataUsageMB: u.DataUsageMB,
|
||||
VirtualUsedMB: float64(u.DataUsageMB) / ratio,
|
||||
VirtualRemainMB: float64(u.DataLimitMB-u.DataUsageMB) / ratio,
|
||||
VirtualRatio: ratio,
|
||||
ActivatedAt: u.ActivatedAt,
|
||||
ExpiresAt: u.ExpiresAt,
|
||||
MasterUsageID: u.MasterUsageID,
|
||||
Priority: u.Priority,
|
||||
CreatedAt: u.CreatedAt,
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
|
||||
// 按 created_at DESC 排序
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].CreatedAt.After(result[j].CreatedAt)
|
||||
})
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetCurrentPackage 获取资产当前生效的主套餐
|
||||
func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id uint) (*dto.AssetPackageResponse, error) {
|
||||
carrierType := assetType
|
||||
if assetType == "card" {
|
||||
carrierType = "iot_card"
|
||||
}
|
||||
|
||||
usage, err := s.packageUsageStore.GetActiveMainPackage(ctx, carrierType, id)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeNotFound, "当前无生效套餐")
|
||||
}
|
||||
|
||||
pkg, pkgErr := s.packageStore.GetByID(ctx, usage.PackageID)
|
||||
ratio := 1.0
|
||||
pkgName := ""
|
||||
pkgType := ""
|
||||
if pkgErr == nil && pkg != nil {
|
||||
ratio = safeVirtualRatio(pkg.VirtualRatio)
|
||||
pkgName = pkg.PackageName
|
||||
pkgType = pkg.PackageType
|
||||
}
|
||||
|
||||
return &dto.AssetPackageResponse{
|
||||
PackageUsageID: usage.ID,
|
||||
PackageID: usage.PackageID,
|
||||
PackageName: pkgName,
|
||||
PackageType: pkgType,
|
||||
UsageType: usage.UsageType,
|
||||
Status: usage.Status,
|
||||
StatusName: packageStatusName(usage.Status),
|
||||
DataLimitMB: usage.DataLimitMB,
|
||||
VirtualLimitMB: int64(float64(usage.DataLimitMB) / ratio),
|
||||
DataUsageMB: usage.DataUsageMB,
|
||||
VirtualUsedMB: float64(usage.DataUsageMB) / ratio,
|
||||
VirtualRemainMB: float64(usage.DataLimitMB-usage.DataUsageMB) / ratio,
|
||||
VirtualRatio: ratio,
|
||||
ActivatedAt: usage.ActivatedAt,
|
||||
ExpiresAt: usage.ExpiresAt,
|
||||
MasterUsageID: usage.MasterUsageID,
|
||||
Priority: usage.Priority,
|
||||
CreatedAt: usage.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getDeviceProtectStatus 查询设备保护期状态
|
||||
func (s *Service) getDeviceProtectStatus(ctx context.Context, deviceID uint) string {
|
||||
stopKey := constants.RedisDeviceProtectKey(deviceID, "stop")
|
||||
startKey := constants.RedisDeviceProtectKey(deviceID, "start")
|
||||
if s.redis.Exists(ctx, stopKey).Val() > 0 {
|
||||
return "stop"
|
||||
}
|
||||
if s.redis.Exists(ctx, startKey).Val() > 0 {
|
||||
return "start"
|
||||
}
|
||||
return "none"
|
||||
}
|
||||
|
||||
// safeVirtualRatio 安全获取虚流量比例,避免除零
|
||||
func safeVirtualRatio(ratio float64) float64 {
|
||||
if ratio <= 0 {
|
||||
return 1.0
|
||||
}
|
||||
return ratio
|
||||
}
|
||||
|
||||
// packageStatusName 套餐状态码转中文名称
|
||||
func packageStatusName(status int) string {
|
||||
switch status {
|
||||
case 0:
|
||||
return "待生效"
|
||||
case 1:
|
||||
return "生效中"
|
||||
case 2:
|
||||
return "已用完"
|
||||
case 3:
|
||||
return "已过期"
|
||||
case 4:
|
||||
return "已失效"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,11 @@ package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
@@ -10,11 +15,13 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
deviceStore *postgres.DeviceStore
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
@@ -28,6 +35,7 @@ type Service struct {
|
||||
|
||||
func New(
|
||||
db *gorm.DB,
|
||||
rds *redis.Client,
|
||||
deviceStore *postgres.DeviceStore,
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
@@ -40,6 +48,7 @@ func New(
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
redis: rds,
|
||||
deviceStore: deviceStore,
|
||||
deviceSimBindingStore: deviceSimBindingStore,
|
||||
iotCardStore: iotCardStore,
|
||||
@@ -69,8 +78,8 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li
|
||||
}
|
||||
|
||||
filters := make(map[string]interface{})
|
||||
if req.DeviceNo != "" {
|
||||
filters["device_no"] = req.DeviceNo
|
||||
if req.VirtualNo != "" {
|
||||
filters["virtual_no"] = req.VirtualNo
|
||||
}
|
||||
if req.DeviceName != "" {
|
||||
filters["device_name"] = req.DeviceName
|
||||
@@ -251,9 +260,9 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR
|
||||
// 平台只能分配 shop_id=NULL 的设备
|
||||
if isPlatform && device.ShopID != nil {
|
||||
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
|
||||
DeviceID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
Reason: "平台只能分配库存设备",
|
||||
DeviceID: device.ID,
|
||||
VirtualNo: device.VirtualNo,
|
||||
Reason: "平台只能分配库存设备",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -261,9 +270,9 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR
|
||||
// 代理只能分配自己店铺的设备
|
||||
if !isPlatform && (device.ShopID == nil || *device.ShopID != *operatorShopID) {
|
||||
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
|
||||
DeviceID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
Reason: "设备不属于当前店铺",
|
||||
DeviceID: device.ID,
|
||||
VirtualNo: device.VirtualNo,
|
||||
Reason: "设备不属于当前店铺",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -342,9 +351,9 @@ func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesReque
|
||||
// 验证设备所属店铺是否为直属下级
|
||||
if device.ShopID == nil {
|
||||
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
|
||||
DeviceID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
Reason: "设备已在平台库存中",
|
||||
DeviceID: device.ID,
|
||||
VirtualNo: device.VirtualNo,
|
||||
Reason: "设备已在平台库存中",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -353,9 +362,9 @@ func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesReque
|
||||
if !isPlatform {
|
||||
if err := s.validateDirectSubordinate(ctx, operatorShopID, *device.ShopID); err != nil {
|
||||
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
|
||||
DeviceID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
Reason: "只能回收直属下级店铺的设备",
|
||||
DeviceID: device.ID,
|
||||
VirtualNo: device.VirtualNo,
|
||||
Reason: "只能回收直属下级店铺的设备",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -528,7 +537,7 @@ func (s *Service) extractDeviceIDs(devices []*model.Device) []uint {
|
||||
func (s *Service) toDeviceResponse(device *model.Device, shopMap map[uint]string, seriesMap map[uint]string, bindingCounts map[uint]int64) *dto.DeviceResponse {
|
||||
resp := &dto.DeviceResponse{
|
||||
ID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
VirtualNo: device.VirtualNo,
|
||||
IMEI: device.IMEI,
|
||||
SN: device.SN,
|
||||
DeviceName: device.DeviceName,
|
||||
@@ -592,7 +601,7 @@ func (s *Service) buildAllocationRecords(devices []*model.Device, successDeviceI
|
||||
AllocationType: constants.AssetAllocationTypeAllocate,
|
||||
AssetType: constants.AssetTypeDevice,
|
||||
AssetID: device.ID,
|
||||
AssetIdentifier: device.DeviceNo,
|
||||
AssetIdentifier: device.VirtualNo,
|
||||
ToOwnerType: constants.OwnerTypeShop,
|
||||
ToOwnerID: toShopID,
|
||||
OperatorID: operatorID,
|
||||
@@ -630,7 +639,7 @@ func (s *Service) buildRecallRecords(devices []*model.Device, successDeviceIDs [
|
||||
AllocationType: constants.AssetAllocationTypeRecall,
|
||||
AssetType: constants.AssetTypeDevice,
|
||||
AssetID: device.ID,
|
||||
AssetIdentifier: device.DeviceNo,
|
||||
AssetIdentifier: device.VirtualNo,
|
||||
OperatorID: operatorID,
|
||||
Remark: remark,
|
||||
}
|
||||
@@ -699,9 +708,9 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe
|
||||
device, exists := deviceMap[deviceID]
|
||||
if !exists {
|
||||
failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{
|
||||
DeviceID: deviceID,
|
||||
DeviceNo: "",
|
||||
Reason: "设备不存在",
|
||||
DeviceID: deviceID,
|
||||
VirtualNo: "",
|
||||
Reason: "设备不存在",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -721,9 +730,9 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe
|
||||
}
|
||||
if !hasSeriesAllocation {
|
||||
failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{
|
||||
DeviceID: deviceID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
Reason: "您没有权限分配该套餐系列",
|
||||
DeviceID: deviceID,
|
||||
VirtualNo: device.VirtualNo,
|
||||
Reason: "您没有权限分配该套餐系列",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -733,9 +742,9 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe
|
||||
if operatorShopID != nil {
|
||||
if device.ShopID == nil || *device.ShopID != *operatorShopID {
|
||||
failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{
|
||||
DeviceID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
Reason: "无权操作此设备",
|
||||
DeviceID: device.ID,
|
||||
VirtualNo: device.VirtualNo,
|
||||
Reason: "无权操作此设备",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -765,10 +774,207 @@ func (s *Service) buildDeviceNotFoundFailedItems(deviceIDs []uint) []dto.DeviceS
|
||||
items := make([]dto.DeviceSeriesBindngFailedItem, len(deviceIDs))
|
||||
for i, id := range deviceIDs {
|
||||
items[i] = dto.DeviceSeriesBindngFailedItem{
|
||||
DeviceID: id,
|
||||
DeviceNo: "",
|
||||
Reason: "设备不存在",
|
||||
DeviceID: id,
|
||||
VirtualNo: "",
|
||||
Reason: "设备不存在",
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// StopDevice 设备停机
|
||||
// POST /api/admin/assets/device/:device_id/stop
|
||||
// 查找设备绑定的所有已实名且已开机的卡,逐一调网关停机
|
||||
func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSuspendResponse, error) {
|
||||
log := logger.GetAppLogger()
|
||||
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
if userID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
device, err := s.deviceStore.GetByID(ctx, deviceID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeNotFound, "设备不存在")
|
||||
}
|
||||
_ = device
|
||||
|
||||
// 复机保护期内禁止停机
|
||||
if s.redis != nil {
|
||||
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Result()
|
||||
if exists > 0 {
|
||||
return nil, errors.New(errors.CodeForbidden, "设备复机保护期内,禁止停机")
|
||||
}
|
||||
}
|
||||
|
||||
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败")
|
||||
}
|
||||
|
||||
if len(bindings) == 0 {
|
||||
return &dto.DeviceSuspendResponse{}, nil
|
||||
}
|
||||
|
||||
cardIDs := make([]uint, 0, len(bindings))
|
||||
for _, b := range bindings {
|
||||
cardIDs = append(cardIDs, b.IotCardID)
|
||||
}
|
||||
|
||||
cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败")
|
||||
}
|
||||
|
||||
var successCount, skipCount int
|
||||
var failedItems []dto.DeviceSuspendFailItem
|
||||
|
||||
for _, card := range cards {
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified || card.NetworkStatus != constants.NetworkStatusOnline {
|
||||
skipCount++
|
||||
continue
|
||||
}
|
||||
|
||||
if s.gatewayClient != nil {
|
||||
if gwErr := s.gatewayClient.StopCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); gwErr != nil {
|
||||
log.Error("设备停机-调网关停机失败",
|
||||
zap.Uint("device_id", deviceID),
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.Error(gwErr))
|
||||
failedItems = append(failedItems, dto.DeviceSuspendFailItem{
|
||||
ICCID: card.ICCID,
|
||||
Reason: "网关停机失败",
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if dbErr := s.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("id = ?", card.ID).
|
||||
Updates(map[string]any{
|
||||
"network_status": constants.NetworkStatusOffline,
|
||||
"stopped_at": now,
|
||||
"stop_reason": constants.StopReasonManual,
|
||||
}).Error; dbErr != nil {
|
||||
log.Error("设备停机-更新卡状态失败",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.Error(dbErr))
|
||||
failedItems = append(failedItems, dto.DeviceSuspendFailItem{
|
||||
ICCID: card.ICCID,
|
||||
Reason: "更新卡状态失败",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
successCount++
|
||||
}
|
||||
|
||||
// 成功停机至少一张卡后设置保护期
|
||||
if successCount > 0 && s.redis != nil {
|
||||
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "stop"), 1, constants.DeviceProtectPeriodDuration)
|
||||
s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "start"))
|
||||
}
|
||||
|
||||
return &dto.DeviceSuspendResponse{
|
||||
SuccessCount: successCount,
|
||||
FailCount: len(failedItems),
|
||||
SkipCount: skipCount,
|
||||
FailedItems: failedItems,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StartDevice 设备复机
|
||||
// POST /api/admin/assets/device/:device_id/start
|
||||
// 查找设备绑定的所有已实名且已停机的卡,逐一调网关复机
|
||||
func (s *Service) StartDevice(ctx context.Context, deviceID uint) error {
|
||||
log := logger.GetAppLogger()
|
||||
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
if userID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
device, err := s.deviceStore.GetByID(ctx, deviceID)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeNotFound, "设备不存在")
|
||||
}
|
||||
_ = device
|
||||
|
||||
// 停机保护期内禁止复机
|
||||
if s.redis != nil {
|
||||
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Result()
|
||||
if exists > 0 {
|
||||
return errors.New(errors.CodeForbidden, "设备停机保护期内,禁止复机")
|
||||
}
|
||||
}
|
||||
|
||||
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败")
|
||||
}
|
||||
|
||||
if len(bindings) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cardIDs := make([]uint, 0, len(bindings))
|
||||
for _, b := range bindings {
|
||||
cardIDs = append(cardIDs, b.IotCardID)
|
||||
}
|
||||
|
||||
cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败")
|
||||
}
|
||||
|
||||
var successCount int
|
||||
var lastErr error
|
||||
|
||||
for _, card := range cards {
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified || card.NetworkStatus != constants.NetworkStatusOffline {
|
||||
continue
|
||||
}
|
||||
|
||||
if s.gatewayClient != nil {
|
||||
if gwErr := s.gatewayClient.StartCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); gwErr != nil {
|
||||
log.Error("设备复机-调网关复机失败",
|
||||
zap.Uint("device_id", deviceID),
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.Error(gwErr))
|
||||
lastErr = gwErr
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if dbErr := s.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("id = ?", card.ID).
|
||||
Updates(map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"resumed_at": now,
|
||||
"stop_reason": "",
|
||||
}).Error; dbErr != nil {
|
||||
log.Error("设备复机-更新卡状态失败",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.Error(dbErr))
|
||||
lastErr = dbErr
|
||||
continue
|
||||
}
|
||||
|
||||
successCount++
|
||||
}
|
||||
|
||||
// 成功复机至少一张卡后设置保护期
|
||||
if successCount > 0 && s.redis != nil {
|
||||
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "start"), 1, constants.DeviceProtectPeriodDuration)
|
||||
s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "stop"))
|
||||
}
|
||||
|
||||
// 全部失败时返回 error
|
||||
if successCount == 0 && lastErr != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, lastErr, "设备复机失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -139,25 +139,25 @@ func (s *Service) GetByID(ctx context.Context, id uint) (*dto.DeviceImportTaskDe
|
||||
|
||||
for _, item := range task.SkippedItems {
|
||||
resp.SkippedItems = append(resp.SkippedItems, &dto.DeviceImportResultItemDTO{
|
||||
Line: item.Line,
|
||||
DeviceNo: item.ICCID,
|
||||
Reason: item.Reason,
|
||||
Line: item.Line,
|
||||
VirtualNo: item.ICCID,
|
||||
Reason: item.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
for _, item := range task.FailedItems {
|
||||
resp.FailedItems = append(resp.FailedItems, &dto.DeviceImportResultItemDTO{
|
||||
Line: item.Line,
|
||||
DeviceNo: item.ICCID,
|
||||
Reason: item.Reason,
|
||||
Line: item.Line,
|
||||
VirtualNo: item.ICCID,
|
||||
Reason: item.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
for _, item := range task.WarningItems {
|
||||
resp.WarningItems = append(resp.WarningItems, &dto.DeviceImportResultItemDTO{
|
||||
Line: item.Line,
|
||||
DeviceNo: item.ICCID,
|
||||
Reason: item.Reason,
|
||||
Line: item.Line,
|
||||
VirtualNo: item.ICCID,
|
||||
Reason: item.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -59,14 +59,14 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
|
||||
|
||||
// 查询所有设备
|
||||
var devices []model.Device
|
||||
if err := s.db.WithContext(ctx).Where("device_no IN ?", req.DeviceNos).Find(&devices).Error; err != nil {
|
||||
if err := s.db.WithContext(ctx).Where("virtual_no IN ?", req.DeviceNos).Find(&devices).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
|
||||
}
|
||||
|
||||
deviceMap := make(map[string]*model.Device)
|
||||
deviceIDs := make([]uint, 0, len(devices))
|
||||
for i := range devices {
|
||||
deviceMap[devices[i].DeviceNo] = &devices[i]
|
||||
deviceMap[devices[i].VirtualNo] = &devices[i]
|
||||
deviceIDs = append(deviceIDs, devices[i].ID)
|
||||
}
|
||||
|
||||
@@ -90,8 +90,8 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
|
||||
device, exists := deviceMap[deviceNo]
|
||||
if !exists {
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||||
DeviceNo: deviceNo,
|
||||
Reason: "设备不存在",
|
||||
VirtualNo: deviceNo,
|
||||
Reason: "设备不存在",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -99,8 +99,8 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
|
||||
// 验证设备状态(必须是"已分销"状态)
|
||||
if device.Status != 2 {
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||||
DeviceNo: deviceNo,
|
||||
Reason: "设备状态不正确,必须是已分销状态",
|
||||
VirtualNo: deviceNo,
|
||||
Reason: "设备状态不正确,必须是已分销状态",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -109,8 +109,8 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
|
||||
if userType == constants.UserTypeAgent {
|
||||
if device.ShopID == nil || *device.ShopID != currentShopID {
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||||
DeviceNo: deviceNo,
|
||||
Reason: "无权操作此设备",
|
||||
VirtualNo: deviceNo,
|
||||
Reason: "无权操作此设备",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -119,8 +119,8 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
|
||||
// 检查是否已授权
|
||||
if existingAuths[device.ID] {
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||||
DeviceNo: deviceNo,
|
||||
Reason: "设备已授权给此企业",
|
||||
VirtualNo: deviceNo,
|
||||
Reason: "设备已授权给此企业",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -199,7 +199,7 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
|
||||
for _, device := range devicesToAllocate {
|
||||
resp.AuthorizedDevices = append(resp.AuthorizedDevices, dto.AuthorizedDeviceItem{
|
||||
DeviceID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
VirtualNo: device.VirtualNo,
|
||||
CardCount: deviceCardCount[device.ID],
|
||||
})
|
||||
}
|
||||
@@ -232,14 +232,14 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
|
||||
|
||||
// 查询设备
|
||||
var devices []model.Device
|
||||
if err := s.db.WithContext(ctx).Where("device_no IN ?", req.DeviceNos).Find(&devices).Error; err != nil {
|
||||
if err := s.db.WithContext(ctx).Where("virtual_no IN ?", req.DeviceNos).Find(&devices).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
|
||||
}
|
||||
|
||||
deviceMap := make(map[string]*model.Device)
|
||||
deviceIDs := make([]uint, 0, len(devices))
|
||||
for i := range devices {
|
||||
deviceMap[devices[i].DeviceNo] = &devices[i]
|
||||
deviceMap[devices[i].VirtualNo] = &devices[i]
|
||||
deviceIDs = append(deviceIDs, devices[i].ID)
|
||||
}
|
||||
|
||||
@@ -258,16 +258,16 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
|
||||
device, exists := deviceMap[deviceNo]
|
||||
if !exists {
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||||
DeviceNo: deviceNo,
|
||||
Reason: "设备不存在",
|
||||
VirtualNo: deviceNo,
|
||||
Reason: "设备不存在",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if !existingAuths[device.ID] {
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||||
DeviceNo: deviceNo,
|
||||
Reason: "设备未授权给此企业",
|
||||
VirtualNo: deviceNo,
|
||||
Reason: "设备未授权给此企业",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -276,8 +276,8 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
|
||||
auth, err := s.enterpriseDeviceAuthStore.GetByDeviceID(ctx, device.ID)
|
||||
if err != nil || auth.EnterpriseID != enterpriseID {
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||||
DeviceNo: deviceNo,
|
||||
Reason: "授权记录不存在",
|
||||
VirtualNo: deviceNo,
|
||||
Reason: "授权记录不存在",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -352,8 +352,8 @@ func (s *Service) ListDevices(ctx context.Context, enterpriseID uint, req *dto.E
|
||||
// 查询设备信息
|
||||
var devices []model.Device
|
||||
query := s.db.WithContext(ctx).Where("id IN ?", deviceIDs)
|
||||
if req.DeviceNo != "" {
|
||||
query = query.Where("device_no LIKE ?", "%"+req.DeviceNo+"%")
|
||||
if req.VirtualNo != "" {
|
||||
query = query.Where("virtual_no LIKE ?", "%"+req.VirtualNo+"%")
|
||||
}
|
||||
if err := query.Find(&devices).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
|
||||
@@ -378,7 +378,7 @@ func (s *Service) ListDevices(ctx context.Context, enterpriseID uint, req *dto.E
|
||||
auth := authMap[device.ID]
|
||||
items = append(items, dto.EnterpriseDeviceItem{
|
||||
DeviceID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
VirtualNo: device.VirtualNo,
|
||||
DeviceName: device.DeviceName,
|
||||
DeviceModel: device.DeviceModel,
|
||||
CardCount: cardCountMap[device.ID],
|
||||
@@ -427,8 +427,8 @@ func (s *Service) ListDevicesForEnterprise(ctx context.Context, req *dto.Enterpr
|
||||
|
||||
var devices []model.Device
|
||||
query := s.db.WithContext(ctx).Where("id IN ?", deviceIDs)
|
||||
if req.DeviceNo != "" {
|
||||
query = query.Where("device_no LIKE ?", "%"+req.DeviceNo+"%")
|
||||
if req.VirtualNo != "" {
|
||||
query = query.Where("virtual_no LIKE ?", "%"+req.VirtualNo+"%")
|
||||
}
|
||||
if err := query.Find(&devices).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
|
||||
@@ -451,7 +451,7 @@ func (s *Service) ListDevicesForEnterprise(ctx context.Context, req *dto.Enterpr
|
||||
auth := authMap[device.ID]
|
||||
items = append(items, dto.EnterpriseDeviceItem{
|
||||
DeviceID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
VirtualNo: device.VirtualNo,
|
||||
DeviceName: device.DeviceName,
|
||||
DeviceModel: device.DeviceModel,
|
||||
CardCount: cardCountMap[device.ID],
|
||||
@@ -531,7 +531,7 @@ func (s *Service) GetDeviceDetail(ctx context.Context, deviceID uint) (*dto.Ente
|
||||
return &dto.EnterpriseDeviceDetailResp{
|
||||
Device: dto.EnterpriseDeviceInfo{
|
||||
DeviceID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
VirtualNo: device.VirtualNo,
|
||||
DeviceName: device.DeviceName,
|
||||
DeviceModel: device.DeviceModel,
|
||||
DeviceType: device.DeviceType,
|
||||
|
||||
@@ -2,6 +2,7 @@ package iot_card
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
@@ -795,20 +796,9 @@ func (s *Service) buildCardNotFoundFailedItems(iccids []string) []dto.CardSeries
|
||||
return items
|
||||
}
|
||||
|
||||
// SyncCardStatusFromGateway 从 Gateway 同步卡状态(示例方法)
|
||||
func (s *Service) SyncCardStatusFromGateway(ctx context.Context, iccid string) error {
|
||||
if s.gatewayClient == nil {
|
||||
return errors.New(errors.CodeGatewayError, "Gateway 客户端未配置")
|
||||
}
|
||||
|
||||
resp, err := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{
|
||||
CardNo: iccid,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("查询卡状态失败", zap.String("iccid", iccid), zap.Error(err))
|
||||
return errors.Wrap(errors.CodeGatewayError, err, "查询卡状态失败")
|
||||
}
|
||||
|
||||
// RefreshCardDataFromGateway 从 Gateway 完整同步卡数据
|
||||
// 调用网关查询网络状态、实名状态、本月流量,并写回数据库
|
||||
func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string) error {
|
||||
card, err := s.iotCardStore.GetByICCID(ctx, iccid)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
@@ -817,40 +807,73 @@ func (s *Service) SyncCardStatusFromGateway(ctx context.Context, iccid string) e
|
||||
return err
|
||||
}
|
||||
|
||||
var newStatus int
|
||||
switch resp.CardStatus {
|
||||
case "准备":
|
||||
newStatus = constants.IotCardStatusInStock
|
||||
case "正常":
|
||||
newStatus = constants.IotCardStatusDistributed
|
||||
case "停机":
|
||||
newStatus = constants.IotCardStatusSuspended
|
||||
default:
|
||||
s.logger.Warn("未知的卡状态", zap.String("cardStatus", resp.CardStatus))
|
||||
return nil
|
||||
syncTime := time.Now()
|
||||
updates := map[string]any{
|
||||
"last_sync_time": syncTime,
|
||||
}
|
||||
|
||||
if card.Status != newStatus {
|
||||
oldStatus := card.Status
|
||||
card.Status = newStatus
|
||||
if err := s.iotCardStore.Update(ctx, card); err != nil {
|
||||
return err
|
||||
if s.gatewayClient != nil {
|
||||
// 1. 查询网络状态(卡的开/停机状态)
|
||||
statusResp, err := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{
|
||||
CardNo: iccid,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("刷新卡数据:查询网络状态失败", zap.String("iccid", iccid), zap.Error(err))
|
||||
} else {
|
||||
networkStatus := parseNetworkStatus(statusResp.CardStatus)
|
||||
updates["network_status"] = networkStatus
|
||||
}
|
||||
s.logger.Info("同步卡状态成功",
|
||||
zap.String("iccid", iccid),
|
||||
zap.Int("oldStatus", oldStatus),
|
||||
zap.Int("newStatus", newStatus),
|
||||
)
|
||||
|
||||
// 通知轮询调度器状态变化
|
||||
if s.pollingCallback != nil {
|
||||
s.pollingCallback.OnCardStatusChanged(ctx, card.ID)
|
||||
// 2. 查询实名状态
|
||||
realnameResp, err := s.gatewayClient.QueryRealnameStatus(ctx, &gateway.CardStatusReq{
|
||||
CardNo: iccid,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("刷新卡数据:查询实名状态失败", zap.String("iccid", iccid), zap.Error(err))
|
||||
} else {
|
||||
realNameStatus := parseGatewayRealnameStatus(realnameResp.RealStatus)
|
||||
updates["real_name_status"] = realNameStatus
|
||||
}
|
||||
|
||||
// 3. 查询本月流量用量
|
||||
flowResp, err := s.gatewayClient.QueryFlow(ctx, &gateway.FlowQueryReq{
|
||||
CardNo: iccid,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("刷新卡数据:查询流量失败", zap.String("iccid", iccid), zap.Error(err))
|
||||
} else {
|
||||
updates["current_month_usage_mb"] = flowResp.Used
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("id = ?", card.ID).
|
||||
Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新卡数据失败")
|
||||
}
|
||||
|
||||
s.logger.Info("刷新卡数据成功", zap.String("iccid", iccid), zap.Uint("card_id", card.ID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseNetworkStatus 将网关返回的卡状态字符串转换为 network_status 数值
|
||||
// 停机→0,其他(准备/正常)→1
|
||||
func parseNetworkStatus(cardStatus string) int {
|
||||
if cardStatus == "停机" {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// parseGatewayRealnameStatus 将网关返回的实名状态布尔值转换为 real_name_status 数值
|
||||
// true=已实名(2),false=未实名(0)
|
||||
func parseGatewayRealnameStatus(realStatus bool) int {
|
||||
if realStatus {
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// UpdatePollingStatus 更新卡的轮询状态
|
||||
// 启用或禁用卡的轮询功能
|
||||
func (s *Service) UpdatePollingStatus(ctx context.Context, cardID uint, enablePolling bool) error {
|
||||
|
||||
@@ -8,22 +8,25 @@ import (
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
stderrors "errors"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// StopResumeService 停复机服务
|
||||
// 任务 24.2: 处理 IoT 卡的自动停机和复机逻辑
|
||||
// 处理 IoT 卡的自动停机、复机和手动停复机逻辑
|
||||
type StopResumeService struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
iotCardStore *postgres.IotCardStore
|
||||
gatewayClient *gateway.Client
|
||||
logger *zap.Logger
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
iotCardStore *postgres.IotCardStore
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore
|
||||
gatewayClient *gateway.Client
|
||||
logger *zap.Logger
|
||||
|
||||
// 重试配置
|
||||
maxRetries int
|
||||
retryInterval time.Duration
|
||||
}
|
||||
@@ -33,17 +36,19 @@ func NewStopResumeService(
|
||||
db *gorm.DB,
|
||||
redis *redis.Client,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore,
|
||||
gatewayClient *gateway.Client,
|
||||
logger *zap.Logger,
|
||||
) *StopResumeService {
|
||||
return &StopResumeService{
|
||||
db: db,
|
||||
redis: redis,
|
||||
iotCardStore: iotCardStore,
|
||||
gatewayClient: gatewayClient,
|
||||
logger: logger,
|
||||
maxRetries: 3, // 默认最多重试 3 次
|
||||
retryInterval: 2 * time.Second, // 默认重试间隔 2 秒
|
||||
db: db,
|
||||
redis: redis,
|
||||
iotCardStore: iotCardStore,
|
||||
deviceSimBindingStore: deviceSimBindingStore,
|
||||
gatewayClient: gatewayClient,
|
||||
logger: logger,
|
||||
maxRetries: 3,
|
||||
retryInterval: 2 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,3 +238,75 @@ func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// ManualStopCard 手动停机单张卡(通过ICCID)
|
||||
func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) error {
|
||||
card, err := s.iotCardStore.GetByICCID(ctx, iccid)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeNotFound, "卡不存在")
|
||||
}
|
||||
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified {
|
||||
return errors.New(errors.CodeForbidden, "卡未实名,无法操作")
|
||||
}
|
||||
|
||||
// 检查绑定设备是否在复机保护期
|
||||
if s.deviceSimBindingStore != nil && s.redis != nil {
|
||||
binding, bindErr := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
|
||||
if bindErr == nil && binding != nil {
|
||||
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(binding.DeviceID, "start")).Result()
|
||||
if exists > 0 {
|
||||
return errors.New(errors.CodeForbidden, "设备复机保护期内,禁止停机")
|
||||
}
|
||||
} else if bindErr != nil && !stderrors.Is(bindErr, gorm.ErrRecordNotFound) {
|
||||
return errors.Wrap(errors.CodeInternalError, bindErr, "查询卡绑定关系失败")
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.stopCardWithRetry(ctx, card); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "调网关停机失败")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
return s.db.WithContext(ctx).Model(card).Updates(map[string]any{
|
||||
"network_status": constants.NetworkStatusOffline,
|
||||
"stopped_at": now,
|
||||
"stop_reason": constants.StopReasonManual,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// ManualStartCard 手动复机单张卡(通过ICCID)
|
||||
func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) error {
|
||||
card, err := s.iotCardStore.GetByICCID(ctx, iccid)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeNotFound, "卡不存在")
|
||||
}
|
||||
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified {
|
||||
return errors.New(errors.CodeForbidden, "卡未实名,无法操作")
|
||||
}
|
||||
|
||||
// 检查绑定设备是否在停机保护期
|
||||
if s.deviceSimBindingStore != nil && s.redis != nil {
|
||||
binding, bindErr := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
|
||||
if bindErr == nil && binding != nil {
|
||||
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(binding.DeviceID, "stop")).Result()
|
||||
if exists > 0 {
|
||||
return errors.New(errors.CodeForbidden, "设备停机保护期内,禁止复机")
|
||||
}
|
||||
} else if bindErr != nil && !stderrors.Is(bindErr, gorm.ErrRecordNotFound) {
|
||||
return errors.Wrap(errors.CodeInternalError, bindErr, "查询卡绑定关系失败")
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.resumeCardWithRetry(ctx, card); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "调网关复机失败")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
return s.db.WithContext(ctx).Model(card).Updates(map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"resumed_at": now,
|
||||
"stop_reason": "",
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -214,7 +214,6 @@ func (s *Service) CreateWithdrawalRequest(ctx context.Context, req *dto.CreateMy
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -126,9 +126,9 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
|
||||
if req.EnableRealnameActivation != nil {
|
||||
pkg.EnableRealnameActivation = *req.EnableRealnameActivation
|
||||
} else {
|
||||
// 默认启用实名激活
|
||||
pkg.EnableRealnameActivation = true
|
||||
}
|
||||
pkg.VirtualRatio = calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB)
|
||||
pkg.Creator = currentUserID
|
||||
|
||||
if err := s.packageStore.Create(ctx, pkg); err != nil {
|
||||
@@ -250,6 +250,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq
|
||||
}
|
||||
}
|
||||
|
||||
pkg.VirtualRatio = calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB)
|
||||
pkg.Updater = currentUserID
|
||||
|
||||
if err := s.packageStore.Update(ctx, pkg); err != nil {
|
||||
@@ -673,3 +674,12 @@ func formatAmount(amountFen int64) string {
|
||||
}
|
||||
return fmt.Sprintf("%.2f元/张", yuan)
|
||||
}
|
||||
|
||||
// calculateVirtualRatio 计算虚流量比例
|
||||
// enable_virtual_data=true 且 virtual_data_mb>0 时 = real_data_mb/virtual_data_mb,否则 = 1.0
|
||||
func calculateVirtualRatio(enableVirtualData bool, realDataMB, virtualDataMB int64) float64 {
|
||||
if enableVirtualData && virtualDataMB > 0 {
|
||||
return float64(realDataMB) / float64(virtualDataMB)
|
||||
}
|
||||
return 1.0
|
||||
}
|
||||
|
||||
@@ -347,7 +347,7 @@ func (s *Service) ListShopCommissionRecords(ctx context.Context, shopID uint, re
|
||||
ShopID: shopID,
|
||||
CommissionSource: req.CommissionSource,
|
||||
ICCID: req.ICCID,
|
||||
DeviceNo: req.DeviceNo,
|
||||
DeviceNo: req.VirtualNo,
|
||||
OrderNo: req.OrderNo,
|
||||
}
|
||||
|
||||
@@ -367,7 +367,7 @@ func (s *Service) ListShopCommissionRecords(ctx context.Context, shopID uint, re
|
||||
StatusName: getCommissionStatusName(r.Status),
|
||||
OrderID: r.OrderID,
|
||||
OrderNo: "",
|
||||
DeviceNo: "",
|
||||
VirtualNo: "",
|
||||
ICCID: "",
|
||||
OrderCreatedAt: "",
|
||||
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
|
||||
@@ -48,7 +48,7 @@ func (s *DeviceStore) GetByID(ctx context.Context, id uint) (*model.Device, erro
|
||||
|
||||
func (s *DeviceStore) GetByDeviceNo(ctx context.Context, deviceNo string) (*model.Device, error) {
|
||||
var device model.Device
|
||||
query := s.db.WithContext(ctx).Where("device_no = ?", deviceNo)
|
||||
query := s.db.WithContext(ctx).Where("virtual_no = ?", deviceNo)
|
||||
// 应用数据权限过滤(NULL shop_id 对代理用户不可见)
|
||||
query = middleware.ApplyShopFilter(ctx, query)
|
||||
if err := query.First(&device).Error; err != nil {
|
||||
@@ -58,10 +58,10 @@ func (s *DeviceStore) GetByDeviceNo(ctx context.Context, deviceNo string) (*mode
|
||||
}
|
||||
|
||||
// GetByIdentifier 通过任意标识符查找设备
|
||||
// 支持 device_no(虚拟号)、imei、sn 三个字段的自动匹配
|
||||
// 支持 virtual_no(虚拟号)、imei、sn 三个字段的自动匹配
|
||||
func (s *DeviceStore) GetByIdentifier(ctx context.Context, identifier string) (*model.Device, error) {
|
||||
var device model.Device
|
||||
query := s.db.WithContext(ctx).Where("device_no = ? OR imei = ? OR sn = ?", identifier, identifier, identifier)
|
||||
query := s.db.WithContext(ctx).Where("virtual_no = ? OR imei = ? OR sn = ?", identifier, identifier, identifier)
|
||||
// 应用数据权限过滤(NULL shop_id 对代理用户不可见)
|
||||
query = middleware.ApplyShopFilter(ctx, query)
|
||||
if err := query.First(&device).Error; err != nil {
|
||||
@@ -100,8 +100,8 @@ func (s *DeviceStore) List(ctx context.Context, opts *store.QueryOptions, filter
|
||||
// 应用数据权限过滤(NULL shop_id 对代理用户不可见)
|
||||
query = middleware.ApplyShopFilter(ctx, query)
|
||||
|
||||
if deviceNo, ok := filters["device_no"].(string); ok && deviceNo != "" {
|
||||
query = query.Where("device_no LIKE ?", "%"+deviceNo+"%")
|
||||
if virtualNo, ok := filters["virtual_no"].(string); ok && virtualNo != "" {
|
||||
query = query.Where("virtual_no LIKE ?", "%"+virtualNo+"%")
|
||||
}
|
||||
if deviceName, ok := filters["device_name"].(string); ok && deviceName != "" {
|
||||
query = query.Where("device_name LIKE ?", "%"+deviceName+"%")
|
||||
@@ -184,17 +184,17 @@ func (s *DeviceStore) ExistsByDeviceNoBatch(ctx context.Context, deviceNos []str
|
||||
}
|
||||
|
||||
var existingDevices []struct {
|
||||
DeviceNo string
|
||||
VirtualNo string
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&model.Device{}).
|
||||
Select("device_no").
|
||||
Where("device_no IN ?", deviceNos).
|
||||
Select("virtual_no").
|
||||
Where("virtual_no IN ?", deviceNos).
|
||||
Find(&existingDevices).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, d := range existingDevices {
|
||||
result[d.DeviceNo] = true
|
||||
result[d.VirtualNo] = true
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -204,7 +204,7 @@ func (s *DeviceStore) GetByDeviceNos(ctx context.Context, deviceNos []string) ([
|
||||
if len(deviceNos) == 0 {
|
||||
return devices, nil
|
||||
}
|
||||
query := s.db.WithContext(ctx).Where("device_no IN ?", deviceNos)
|
||||
query := s.db.WithContext(ctx).Where("virtual_no IN ?", deviceNos)
|
||||
// 应用数据权限过滤(NULL shop_id 对代理用户不可见)
|
||||
query = middleware.ApplyShopFilter(ctx, query)
|
||||
if err := query.Find(&devices).Error; err != nil {
|
||||
|
||||
@@ -903,6 +903,26 @@ func (s *IotCardStore) BatchDelete(ctx context.Context, cardIDs []uint) error {
|
||||
Delete(&model.IotCard{}).Error
|
||||
}
|
||||
|
||||
// ExistsByVirtualNoBatch 批量检查 virtual_no 是否已存在
|
||||
func (s *IotCardStore) ExistsByVirtualNoBatch(ctx context.Context, virtualNos []string) (map[string]bool, error) {
|
||||
result := make(map[string]bool)
|
||||
if len(virtualNos) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var existingNos []string
|
||||
if err := s.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("virtual_no IN ? AND virtual_no <> ''", virtualNos).
|
||||
Pluck("virtual_no", &existingNos).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, no := range existingNos {
|
||||
result[no] = true
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ==================== 列表计数缓存 ====================
|
||||
|
||||
func (s *IotCardStore) getCachedCount(ctx context.Context, table string, filters map[string]any) (int64, bool) {
|
||||
|
||||
@@ -104,7 +104,7 @@ func (s *PersonalCustomerDeviceStore) CreateOrUpdateLastUsed(ctx context.Context
|
||||
// 不存在,创建新记录
|
||||
newRecord := &model.PersonalCustomerDevice{
|
||||
CustomerID: customerID,
|
||||
DeviceNo: deviceNo,
|
||||
VirtualNo: deviceNo,
|
||||
Status: 1, // 启用
|
||||
}
|
||||
return s.Create(ctx, newRecord)
|
||||
|
||||
@@ -180,7 +180,7 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
|
||||
allICCIDs := make([]string, 0)
|
||||
|
||||
for _, row := range batch {
|
||||
deviceNos = append(deviceNos, row.DeviceNo)
|
||||
deviceNos = append(deviceNos, row.VirtualNo)
|
||||
allICCIDs = append(allICCIDs, row.ICCIDs...)
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
|
||||
for _, row := range batch {
|
||||
result.failedItems = append(result.failedItems, model.ImportResultItem{
|
||||
Line: row.Line,
|
||||
ICCID: row.DeviceNo,
|
||||
ICCID: row.VirtualNo,
|
||||
Reason: "数据库查询失败",
|
||||
})
|
||||
result.failCount++
|
||||
@@ -218,10 +218,10 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
|
||||
}
|
||||
|
||||
for _, row := range batch {
|
||||
if existingDevices[row.DeviceNo] {
|
||||
if existingDevices[row.VirtualNo] {
|
||||
result.skippedItems = append(result.skippedItems, model.ImportResultItem{
|
||||
Line: row.Line,
|
||||
ICCID: row.DeviceNo,
|
||||
ICCID: row.VirtualNo,
|
||||
Reason: "设备号已存在",
|
||||
})
|
||||
result.skipCount++
|
||||
@@ -251,7 +251,7 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
|
||||
if len(row.ICCIDs) > 0 && len(cardIssues) > 0 {
|
||||
result.failedItems = append(result.failedItems, model.ImportResultItem{
|
||||
Line: row.Line,
|
||||
ICCID: row.DeviceNo,
|
||||
ICCID: row.VirtualNo,
|
||||
Reason: "卡验证失败: " + strings.Join(cardIssues, ", "),
|
||||
})
|
||||
result.failCount++
|
||||
@@ -263,7 +263,7 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
|
||||
txBindingStore := postgres.NewDeviceSimBindingStore(tx, nil)
|
||||
|
||||
device := &model.Device{
|
||||
DeviceNo: row.DeviceNo,
|
||||
VirtualNo: row.VirtualNo,
|
||||
DeviceName: row.DeviceName,
|
||||
DeviceModel: row.DeviceModel,
|
||||
DeviceType: row.DeviceType,
|
||||
@@ -298,12 +298,12 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
|
||||
|
||||
if err != nil {
|
||||
h.logger.Error("创建设备失败",
|
||||
zap.String("device_no", row.DeviceNo),
|
||||
zap.String("virtual_no", row.VirtualNo),
|
||||
zap.Error(err),
|
||||
)
|
||||
result.failedItems = append(result.failedItems, model.ImportResultItem{
|
||||
Line: row.Line,
|
||||
ICCID: row.DeviceNo,
|
||||
ICCID: row.VirtualNo,
|
||||
Reason: "数据库写入失败: " + err.Error(),
|
||||
})
|
||||
result.failCount++
|
||||
@@ -320,4 +320,4 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
|
||||
}
|
||||
}
|
||||
|
||||
var ErrMissingDeviceNoColumn = stderrors.New("CSV 缺少 device_no 列")
|
||||
var ErrMissingDeviceNoColumn = stderrors.New("CSV 缺少 virtual_no 列")
|
||||
|
||||
@@ -167,8 +167,9 @@ func (h *IotCardImportHandler) downloadAndParse(ctx context.Context, task *model
|
||||
cards := make(model.CardListJSON, 0, len(parseResult.Cards))
|
||||
for _, card := range parseResult.Cards {
|
||||
cards = append(cards, model.CardItem{
|
||||
ICCID: card.ICCID,
|
||||
MSISDN: card.MSISDN,
|
||||
ICCID: card.ICCID,
|
||||
MSISDN: card.MSISDN,
|
||||
VirtualNo: card.VirtualNo,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -210,15 +211,16 @@ func (h *IotCardImportHandler) getCardsFromTask(task *model.IotCardImportTask) [
|
||||
|
||||
func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.IotCardImportTask, batch []model.CardItem, startLine int, result *importResult) {
|
||||
type cardMeta struct {
|
||||
line int
|
||||
msisdn string
|
||||
line int
|
||||
msisdn string
|
||||
virtualNo string
|
||||
}
|
||||
validCards := make([]model.CardItem, 0)
|
||||
cardMetaMap := make(map[string]cardMeta)
|
||||
|
||||
for i, card := range batch {
|
||||
line := startLine + i
|
||||
cardMetaMap[card.ICCID] = cardMeta{line: line, msisdn: card.MSISDN}
|
||||
cardMetaMap[card.ICCID] = cardMeta{line: line, msisdn: card.MSISDN, virtualNo: card.VirtualNo}
|
||||
|
||||
validationResult := validator.ValidateICCID(card.ICCID, task.CarrierType)
|
||||
if !validationResult.Valid {
|
||||
@@ -282,12 +284,56 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot
|
||||
return
|
||||
}
|
||||
|
||||
iotCards := make([]*model.IotCard, 0, len(newCards))
|
||||
now := time.Now()
|
||||
// 批量检查 virtual_no 唯一性
|
||||
virtualNos := make([]string, 0)
|
||||
for _, card := range newCards {
|
||||
if 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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
// 批内去重:记录本批次已分配的 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
|
||||
}
|
||||
finalCards = append(finalCards, card)
|
||||
}
|
||||
|
||||
if len(finalCards) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
iotCards := make([]*model.IotCard, 0, len(finalCards))
|
||||
now := time.Now()
|
||||
for _, card := range finalCards {
|
||||
iotCard := &model.IotCard{
|
||||
ICCID: card.ICCID,
|
||||
MSISDN: card.MSISDN,
|
||||
VirtualNo: card.VirtualNo,
|
||||
CarrierID: task.CarrierID,
|
||||
BatchNo: task.BatchNo,
|
||||
Status: constants.IotCardStatusInStock,
|
||||
@@ -308,7 +354,7 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot
|
||||
zap.Error(err),
|
||||
zap.Int("batch_size", len(iotCards)),
|
||||
)
|
||||
for _, card := range newCards {
|
||||
for _, card := range finalCards {
|
||||
meta := cardMetaMap[card.ICCID]
|
||||
result.failedItems = append(result.failedItems, model.ImportResultItem{
|
||||
Line: meta.line,
|
||||
@@ -321,9 +367,8 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot
|
||||
return
|
||||
}
|
||||
|
||||
result.successCount += len(newCards)
|
||||
result.successCount += len(finalCards)
|
||||
|
||||
// 通知轮询系统:批量卡已创建
|
||||
if h.pollingCallback != nil {
|
||||
h.pollingCallback.OnBatchCardsCreated(ctx, iotCards)
|
||||
}
|
||||
|
||||
@@ -757,6 +757,8 @@ func (h *PollingHandler) requeueCard(ctx context.Context, cardID uint, taskType
|
||||
intervalSeconds = 600 // 默认 10 分钟
|
||||
case constants.TaskTypePollingPackage:
|
||||
intervalSeconds = 600 // 默认 10 分钟
|
||||
case constants.TaskTypePollingProtect:
|
||||
intervalSeconds = 300 // 默认 5 分钟
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -773,6 +775,8 @@ func (h *PollingHandler) requeueCard(ctx context.Context, cardID uint, taskType
|
||||
queueKey = constants.RedisPollingQueueCarddataKey()
|
||||
case constants.TaskTypePollingPackage:
|
||||
queueKey = constants.RedisPollingQueuePackageKey()
|
||||
case constants.TaskTypePollingProtect:
|
||||
queueKey = constants.RedisPollingQueueProtectKey()
|
||||
}
|
||||
|
||||
// 添加到队列
|
||||
@@ -943,6 +947,103 @@ func (h *PollingHandler) getCardWithCache(ctx context.Context, cardID uint) (*mo
|
||||
return card, nil
|
||||
}
|
||||
|
||||
// HandleProtectConsistencyCheck 保护期一致性检查
|
||||
// 检查绑定设备(is_standalone=false)且已实名(real_name_status=2)的卡
|
||||
// stop 保护期 + 开机 → 调网关停机;start 保护期 + 停机 → 调网关复机
|
||||
func (h *PollingHandler) HandleProtectConsistencyCheck(ctx context.Context, t *asynq.Task) error {
|
||||
var payload PollingTaskPayload
|
||||
if err := sonic.Unmarshal(t.Payload(), &payload); err != nil {
|
||||
h.logger.Error("解析保护期检查任务载荷失败", zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
cardID, err := strconv.ParseUint(payload.CardID, 10, 64)
|
||||
if err != nil {
|
||||
h.logger.Error("解析卡ID失败", zap.String("card_id", payload.CardID), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 查询卡信息
|
||||
var card model.IotCard
|
||||
if err := h.db.WithContext(ctx).Where("id = ?", cardID).First(&card).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil
|
||||
}
|
||||
h.logger.Error("查询卡信息失败", zap.Uint64("card_id", cardID), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 未绑设备(独立卡),跳过
|
||||
if card.IsStandalone {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 未实名,跳过
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 查绑定设备
|
||||
binding, err := h.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
|
||||
if err != nil || binding == nil {
|
||||
return nil
|
||||
}
|
||||
deviceID := binding.DeviceID
|
||||
|
||||
// 检查 stop 保护期:设备处于停机保护期,但卡是开机状态 → 需要停机
|
||||
stopProtect := h.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Val() > 0
|
||||
if stopProtect && card.NetworkStatus == constants.NetworkStatusOnline {
|
||||
h.logger.Info("保护期一致性检查:停机保护期内发现开机卡,执行停机",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.Uint("device_id", deviceID))
|
||||
|
||||
if h.gatewayClient != nil {
|
||||
if err := h.gatewayClient.StopCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); err != nil {
|
||||
h.logger.Error("保护期一致性停机失败",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
h.db.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{
|
||||
"network_status": constants.NetworkStatusOffline,
|
||||
"stopped_at": time.Now(),
|
||||
"stop_reason": "保护期一致性检查自动停机",
|
||||
})
|
||||
h.updateCardCache(ctx, card.ID, map[string]any{"network_status": constants.NetworkStatusOffline})
|
||||
return nil
|
||||
}
|
||||
|
||||
// 检查 start 保护期:设备处于复机保护期,但卡是停机状态 → 需要复机
|
||||
startProtect := h.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Val() > 0
|
||||
if startProtect && card.NetworkStatus == constants.NetworkStatusOffline {
|
||||
h.logger.Info("保护期一致性检查:复机保护期内发现停机卡,执行复机",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.Uint("device_id", deviceID))
|
||||
|
||||
if h.gatewayClient != nil {
|
||||
if err := h.gatewayClient.StartCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); err != nil {
|
||||
h.logger.Error("保护期一致性复机失败",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
h.db.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"resumed_at": time.Now(),
|
||||
"stop_reason": "",
|
||||
})
|
||||
h.updateCardCache(ctx, card.ID, map[string]any{"network_status": constants.NetworkStatusOnline})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// triggerFirstRealnameActivation 任务 21.3-21.4: 首次实名后触发套餐激活
|
||||
func (h *PollingHandler) triggerFirstRealnameActivation(ctx context.Context, cardID uint) {
|
||||
// 任务 21.3: 查询该卡是否有待激活套餐
|
||||
|
||||
Reference in New Issue
Block a user