From a5388446d3bb0376a90f20096b7aa6e93844a415 Mon Sep 17 00:00:00 2001 From: huang Date: Tue, 12 May 2026 14:10:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 + internal/model/device.go | 2 +- internal/model/dto/asset_dto.go | 25 ++--- internal/model/dto/client_asset_dto.go | 46 ++++---- internal/model/dto/device_dto.go | 102 +++++++++--------- .../polling/package_activation_handler.go | 7 -- internal/service/asset/service.go | 62 ++++++----- internal/service/device/service.go | 100 +++++++++-------- .../service/package/activation_service.go | 19 +--- internal/store/postgres/device_store.go | 66 ++++++++++++ ...ce_distribution_activation_status.down.sql | 2 + ...vice_distribution_activation_status.up.sql | 10 ++ pkg/constants/iot.go | 15 ++- 13 files changed, 278 insertions(+), 182 deletions(-) create mode 100644 migrations/000144_split_device_distribution_activation_status.down.sql create mode 100644 migrations/000144_split_device_distribution_activation_status.up.sql diff --git a/README.md b/README.md index 0b104b8..edcaae3 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,10 @@ 后台卡/设备套餐系列绑定接口支持按显式列表、号段范围、筛选条件三种方式批量选择资源。运营人员可按完整筛选结果批量设置或清除套餐系列绑定,不受列表分页勾选限制。 +### 设备状态口径 + +设备 `status` 仅表示归属状态(在库/已分销),业务激活状态通过 `activation_status` 返回;设备需同时具备生效中主套餐和任意已实名绑定卡才视为已激活。 + ### 三种客户类型 | 客户类型 | 业务特点 | 典型场景 | 钱包归属 | diff --git a/internal/model/device.go b/internal/model/device.go index fe9167d..427a81b 100644 --- a/internal/model/device.go +++ b/internal/model/device.go @@ -25,7 +25,7 @@ type Device struct { Manufacturer string `gorm:"column:manufacturer;type:varchar(255);comment:制造商" json:"manufacturer"` BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"` ShopID *uint `gorm:"column:shop_id;index;comment:店铺ID(NULL=平台库存,有值=店铺所有)" json:"shop_id,omitempty"` - Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-在库 2-已分销 3-已激活 4-已停用" json:"status"` + Status int `gorm:"column:status;type:int;default:1;not null;comment:归属状态 1-在库 2-已分销" json:"status"` ActivatedAt *time.Time `gorm:"column:activated_at;comment:激活时间" json:"activated_at"` DeviceUsername string `gorm:"column:device_username;type:varchar(100);comment:设备登录用户名" json:"device_username"` DevicePasswordEncrypted string `gorm:"column:device_password_encrypted;type:varchar(255);comment:设备登录密码(加密)" json:"device_password_encrypted"` diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go index 8e88365..c95cfc8 100644 --- a/internal/model/dto/asset_dto.go +++ b/internal/model/dto/asset_dto.go @@ -8,7 +8,7 @@ type AssetResolveResponse struct { AssetID uint `json:"asset_id" description:"资产数据库ID"` Identifier string `json:"identifier,omitempty" description:"解析时使用的标识符"` VirtualNo string `json:"virtual_no" description:"虚拟号"` - Status int `json:"status" description:"资产状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"` + Status int `json:"status" description:"资产归属状态 (1:在库, 2:已分销;卡仍沿用原卡状态枚举)"` StatusName string `json:"status_name" description:"资产状态名称(中文)"` BatchNo string `json:"batch_no" description:"批次号"` ShopID *uint `json:"shop_id,omitempty" description:"所属店铺ID"` @@ -19,6 +19,8 @@ type AssetResolveResponse struct { CreatedAt time.Time `json:"created_at" description:"创建时间"` UpdatedAt time.Time `json:"updated_at" description:"更新时间"` // 状态聚合字段 + ActivationStatus int `json:"activation_status" description:"激活状态:0未激活 1已激活;设备需有生效中主套餐且任意绑定卡已实名"` + ActivationStatusName string `json:"activation_status_name" description:"激活状态名称(中文)"` RealNameStatus int `json:"real_name_status" description:"实名状态:0未实名 1已实名"` RealNameAt *time.Time `json:"real_name_at" description:"最近一次完成实名的时间,未实名时为 null"` RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"` @@ -55,17 +57,16 @@ type AssetResolveResponse struct { SwitchMode string `json:"switch_mode,omitempty" description:"切卡模式:0=自动,1=手动(设备类型时有效)"` LastGatewaySyncAt *time.Time `json:"last_gateway_sync_at,omitempty" description:"最后 sync-info 同步时间(设备类型时有效)"` // 卡专属字段(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" description:"是否参与轮询"` - NetworkStatus int `json:"network_status" description:"网络状态:0停机 1开机(asset_type=card时有效)"` - GatewayExtend string `json:"gateway_extend" description:"Gateway 卡状态扩展字段,原样返回上游 extend,用于展示运营商侧实际停机原因"` + 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:"供应商"` + EnablePolling bool `json:"enable_polling" description:"是否参与轮询"` + NetworkStatus int `json:"network_status" description:"网络状态:0停机 1开机(asset_type=card时有效)"` + GatewayExtend string `json:"gateway_extend" description:"Gateway 卡状态扩展字段,原样返回上游 extend,用于展示运营商侧实际停机原因"` } // BoundCardInfo 设备绑定的卡信息 diff --git a/internal/model/dto/client_asset_dto.go b/internal/model/dto/client_asset_dto.go index 324c61c..0e44ae8 100644 --- a/internal/model/dto/client_asset_dto.go +++ b/internal/model/dto/client_asset_dto.go @@ -18,19 +18,21 @@ type AssetInfoResponse struct { BoundPhone string `json:"bound_phone" description:"当前登录用户绑定的手机号"` // === 基础信息(通用) === - AssetType string `json:"asset_type" description:"资产类型(card:卡, device:设备)"` - AssetID uint `json:"asset_id" description:"资产ID"` - Identifier string `json:"identifier" description:"资产标识符"` - VirtualNo string `json:"virtual_no" description:"虚拟号"` - Status int `json:"status" description:"状态(1:在库, 2:已分销, 3:已激活, 4:已停用)"` - StatusName string `json:"status_name" description:"状态名称(中文)"` - RealNameStatus int `json:"real_name_status" description:"实名状态(0:未实名, 1:已实名)"` - RealNameStatusName string `json:"real_name_status_name" description:"实名状态名称(中文)"` - RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"` - CarrierName string `json:"carrier_name" description:"运营商名称"` - Generation string `json:"generation" description:"世代"` - WalletBalance int64 `json:"wallet_balance" description:"钱包余额(分)"` - ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"` + AssetType string `json:"asset_type" description:"资产类型(card:卡, device:设备)"` + AssetID uint `json:"asset_id" description:"资产ID"` + Identifier string `json:"identifier" description:"资产标识符"` + VirtualNo string `json:"virtual_no" description:"虚拟号"` + Status int `json:"status" description:"资产归属状态(1:在库, 2:已分销;卡仍沿用原卡状态枚举)"` + StatusName string `json:"status_name" description:"状态名称(中文)"` + ActivationStatus int `json:"activation_status" description:"激活状态(0:未激活, 1:已激活;设备需有生效中主套餐且任意绑定卡已实名)"` + ActivationStatusName string `json:"activation_status_name" description:"激活状态名称(中文)"` + RealNameStatus int `json:"real_name_status" description:"实名状态(0:未实名, 1:已实名)"` + RealNameStatusName string `json:"real_name_status_name" description:"实名状态名称(中文)"` + RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"` + CarrierName string `json:"carrier_name" description:"运营商名称"` + Generation string `json:"generation" description:"世代"` + WalletBalance int64 `json:"wallet_balance" description:"钱包余额(分)"` + ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"` // === 套餐信息(通用) === CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"` @@ -57,16 +59,14 @@ type AssetInfoResponse struct { DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"设备保护期状态(none:无, stop:停机保护, start:开机保护)"` // === 卡专属字段(asset_type=card 时有效) === - ICCID string `json:"iccid,omitempty" description:"卡ICCID"` - MSISDN string `json:"msisdn,omitempty" description:"手机号"` - CarrierID uint `json:"carrier_id,omitempty" description:"运营商ID"` - CarrierType string `json:"carrier_type,omitempty" description:"运营商类型(CMCC/CUCC/CTCC/CBN)"` - NetworkStatus int `json:"network_status,omitempty" description:"网络状态(0:停机, 1:开机)"` - NetworkStatusName string `json:"network_status_name,omitempty" description:"网络状态名称(中文)"` - GatewayExtend string `json:"gateway_extend" description:"Gateway 卡状态扩展字段,原样返回上游 extend,用于展示运营商侧实际停机原因"` - ActivationStatus int `json:"activation_status,omitempty" description:"激活状态(0:未激活, 1:已激活)"` - ActivationStatusName string `json:"activation_status_name,omitempty" description:"激活状态名称(中文)"` - CardCategory string `json:"card_category,omitempty" description:"卡业务类型(normal:普通卡, industry:行业卡)"` + ICCID string `json:"iccid,omitempty" description:"卡ICCID"` + MSISDN string `json:"msisdn,omitempty" description:"手机号"` + CarrierID uint `json:"carrier_id,omitempty" description:"运营商ID"` + CarrierType string `json:"carrier_type,omitempty" description:"运营商类型(CMCC/CUCC/CTCC/CBN)"` + NetworkStatus int `json:"network_status,omitempty" description:"网络状态(0:停机, 1:开机)"` + NetworkStatusName string `json:"network_status_name,omitempty" description:"网络状态名称(中文)"` + GatewayExtend string `json:"gateway_extend" description:"Gateway 卡状态扩展字段,原样返回上游 extend,用于展示运营商侧实际停机原因"` + CardCategory string `json:"card_category,omitempty" description:"卡业务类型(normal:普通卡, industry:行业卡)"` // === 卡绑定设备信息(asset_type=card 且绑定了设备时有效) === BoundDeviceID *uint `json:"bound_device_id,omitempty" description:"绑定的设备ID"` diff --git a/internal/model/dto/device_dto.go b/internal/model/dto/device_dto.go index 86ec2f0..1113d35 100644 --- a/internal/model/dto/device_dto.go +++ b/internal/model/dto/device_dto.go @@ -3,43 +3,46 @@ package dto 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:"每页数量"` - 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表示平台库存)"` - ShopIDs []uint `json:"shop_ids" query:"shop_ids" validate:"omitempty,dive,min=1" description:"店铺ID列表(多选)"` - SeriesID *uint `json:"series_id" query:"series_id" description:"套餐系列ID"` - BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"` - DeviceType string `json:"device_type" query:"device_type" validate:"omitempty,max=50" maxLength:"50" description:"设备类型"` - Manufacturer string `json:"manufacturer" query:"manufacturer" validate:"omitempty,max=255" maxLength:"255" description:"制造商(模糊查询)"` - CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"创建时间起始"` - CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"创建时间结束"` + 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:"每页数量"` + 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=2" minimum:"1" maximum:"2" description:"归属状态 (1:在库, 2:已分销)"` + ActivationStatus *int `json:"activation_status" query:"activation_status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"激活状态 (0:未激活, 1:已激活;需有生效中主套餐且任意绑定卡已实名)"` + ShopID *uint `json:"shop_id" query:"shop_id" description:"店铺ID(兼容旧参数,单选;NULL表示平台库存)"` + ShopIDs []uint `json:"shop_ids" query:"shop_ids" validate:"omitempty,dive,min=1" description:"店铺ID列表(多选)"` + SeriesID *uint `json:"series_id" query:"series_id" description:"套餐系列ID"` + BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"` + DeviceType string `json:"device_type" query:"device_type" validate:"omitempty,max=50" maxLength:"50" description:"设备类型"` + Manufacturer string `json:"manufacturer" query:"manufacturer" validate:"omitempty,max=255" maxLength:"255" description:"制造商(模糊查询)"` + CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"创建时间起始"` + CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"创建时间结束"` } type DeviceResponse struct { - ID uint `json:"id" description:"设备ID"` - VirtualNo string `json:"virtual_no" description:"设备虚拟号/别名"` - IMEI string `json:"imei" description:"设备IMEI"` - SN string `json:"sn" description:"设备序列号"` - DeviceName string `json:"device_name" description:"设备名称"` - DeviceModel string `json:"device_model" description:"设备型号"` - DeviceType string `json:"device_type" description:"设备类型"` - MaxSimSlots int `json:"max_sim_slots" description:"最大插槽数"` - Manufacturer string `json:"manufacturer" description:"制造商"` - BatchNo string `json:"batch_no" description:"批次号"` - ShopID *uint `json:"shop_id" description:"店铺ID"` - ShopName string `json:"shop_name" description:"店铺名称"` - Status int `json:"status" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"` - StatusName string `json:"status_name" description:"状态名称"` - BoundCardCount int `json:"bound_card_count" description:"已绑定卡数量"` - SeriesID *uint `json:"series_id" description:"套餐系列ID"` - SeriesName string `json:"series_name" description:"套餐系列名称"` - ActivatedAt *time.Time `json:"activated_at" description:"激活时间"` - RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"` - CreatedAt time.Time `json:"created_at" description:"创建时间"` - UpdatedAt time.Time `json:"updated_at" description:"更新时间"` + ID uint `json:"id" description:"设备ID"` + VirtualNo string `json:"virtual_no" description:"设备虚拟号/别名"` + IMEI string `json:"imei" description:"设备IMEI"` + SN string `json:"sn" description:"设备序列号"` + DeviceName string `json:"device_name" description:"设备名称"` + DeviceModel string `json:"device_model" description:"设备型号"` + DeviceType string `json:"device_type" description:"设备类型"` + MaxSimSlots int `json:"max_sim_slots" description:"最大插槽数"` + Manufacturer string `json:"manufacturer" description:"制造商"` + BatchNo string `json:"batch_no" description:"批次号"` + ShopID *uint `json:"shop_id" description:"店铺ID"` + ShopName string `json:"shop_name" description:"店铺名称"` + Status int `json:"status" description:"归属状态 (1:在库, 2:已分销)"` + StatusName string `json:"status_name" description:"归属状态名称(中文)"` + ActivationStatus int `json:"activation_status" description:"激活状态 (0:未激活, 1:已激活;需有生效中主套餐且任意绑定卡已实名)"` + ActivationStatusName string `json:"activation_status_name" description:"激活状态名称(中文)"` + BoundCardCount int `json:"bound_card_count" description:"已绑定卡数量"` + SeriesID *uint `json:"series_id" description:"套餐系列ID"` + SeriesName string `json:"series_name" description:"套餐系列名称"` + ActivatedAt *time.Time `json:"activated_at" description:"激活时间"` + RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` + UpdatedAt time.Time `json:"updated_at" description:"更新时间"` // 设备实时同步字段(由 sync-info 接口写入) OnlineStatus int `json:"online_status" description:"在线状态:0未知 1在线 2离线"` LastOnlineTime *time.Time `json:"last_online_time" description:"最后在线时间"` @@ -146,21 +149,22 @@ type RecallDevicesResponse struct { // BatchSetDeviceSeriesBindngRequest 批量设置设备的套餐系列绑定请求 type BatchSetDeviceSeriesBindngRequest struct { - SelectionType string `json:"selection_type" validate:"omitempty,oneof=list range filter" enum:"list,range,filter" description:"选设备方式 (list:设备ID列表, range:设备虚拟号范围, filter:筛选条件;不传时兼容旧版设备ID列表)"` - DeviceIDs []uint `json:"device_ids" validate:"omitempty,max=1000,dive,required" maxItems:"1000" description:"设备ID列表(selection_type=list时使用,最多1000个)"` - VirtualNoStart string `json:"virtual_no_start" validate:"omitempty,max=100" maxLength:"100" description:"起始设备虚拟号(selection_type=range时使用)"` - VirtualNoEnd string `json:"virtual_no_end" validate:"omitempty,max=100" maxLength:"100" description:"结束设备虚拟号(selection_type=range时使用)"` - VirtualNo string `json:"virtual_no" validate:"omitempty,max=100" maxLength:"100" description:"设备虚拟号模糊查询(selection_type=filter时可选)"` - DeviceName string `json:"device_name" validate:"omitempty,max=255" maxLength:"255" description:"设备名称模糊查询(selection_type=filter时可选)"` - Status *int `json:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)(selection_type=filter时可选)"` - ShopID *uint `json:"shop_id" description:"店铺ID(selection_type=filter时可选,兼容旧参数,单选)"` - ShopIDs []uint `json:"shop_ids" validate:"omitempty,dive,min=1" description:"店铺ID列表(selection_type=filter时可选,多选)"` - FilterSeriesID *uint `json:"filter_series_id" description:"当前绑定套餐系列ID(selection_type=filter时可选;目标套餐系列仍使用series_id)"` - BatchNo string `json:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号(selection_type=filter时可选)"` - DeviceType string `json:"device_type" validate:"omitempty,max=50" maxLength:"50" description:"设备类型(selection_type=filter时可选)"` - Manufacturer string `json:"manufacturer" validate:"omitempty,max=255" maxLength:"255" description:"制造商模糊查询(selection_type=filter时可选)"` - CreatedAtStart *time.Time `json:"created_at_start" description:"创建时间起始(selection_type=filter时可选)"` - CreatedAtEnd *time.Time `json:"created_at_end" description:"创建时间结束(selection_type=filter时可选)"` + SelectionType string `json:"selection_type" validate:"omitempty,oneof=list range filter" enum:"list,range,filter" description:"选设备方式 (list:设备ID列表, range:设备虚拟号范围, filter:筛选条件;不传时兼容旧版设备ID列表)"` + DeviceIDs []uint `json:"device_ids" validate:"omitempty,max=1000,dive,required" maxItems:"1000" description:"设备ID列表(selection_type=list时使用,最多1000个)"` + VirtualNoStart string `json:"virtual_no_start" validate:"omitempty,max=100" maxLength:"100" description:"起始设备虚拟号(selection_type=range时使用)"` + VirtualNoEnd string `json:"virtual_no_end" validate:"omitempty,max=100" maxLength:"100" description:"结束设备虚拟号(selection_type=range时使用)"` + VirtualNo string `json:"virtual_no" validate:"omitempty,max=100" maxLength:"100" description:"设备虚拟号模糊查询(selection_type=filter时可选)"` + DeviceName string `json:"device_name" validate:"omitempty,max=255" maxLength:"255" description:"设备名称模糊查询(selection_type=filter时可选)"` + Status *int `json:"status" validate:"omitempty,min=1,max=2" minimum:"1" maximum:"2" description:"归属状态 (1:在库, 2:已分销)(selection_type=filter时可选)"` + ActivationStatus *int `json:"activation_status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"激活状态 (0:未激活, 1:已激活;selection_type=filter时可选)"` + ShopID *uint `json:"shop_id" description:"店铺ID(selection_type=filter时可选,兼容旧参数,单选)"` + ShopIDs []uint `json:"shop_ids" validate:"omitempty,dive,min=1" description:"店铺ID列表(selection_type=filter时可选,多选)"` + FilterSeriesID *uint `json:"filter_series_id" description:"当前绑定套餐系列ID(selection_type=filter时可选;目标套餐系列仍使用series_id)"` + BatchNo string `json:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号(selection_type=filter时可选)"` + DeviceType string `json:"device_type" validate:"omitempty,max=50" maxLength:"50" description:"设备类型(selection_type=filter时可选)"` + Manufacturer string `json:"manufacturer" validate:"omitempty,max=255" maxLength:"255" description:"制造商模糊查询(selection_type=filter时可选)"` + CreatedAtStart *time.Time `json:"created_at_start" description:"创建时间起始(selection_type=filter时可选)"` + CreatedAtEnd *time.Time `json:"created_at_end" description:"创建时间结束(selection_type=filter时可选)"` SeriesID uint `json:"series_id" validate:"required,min=0" required:"true" minimum:"0" description:"目标套餐系列ID(0表示清除关联)"` } diff --git a/internal/polling/package_activation_handler.go b/internal/polling/package_activation_handler.go index fb11593..fcc0e92 100644 --- a/internal/polling/package_activation_handler.go +++ b/internal/polling/package_activation_handler.go @@ -367,13 +367,6 @@ func (h *PackageActivationHandler) updateDeviceSuspendedStatus(ctx context.Conte } } - if err := tx.WithContext(ctx). - Model(&model.Device{}). - Where("id = ?", deviceID). - Update("status", constants.DeviceStatusSuspended).Error; err != nil { - return err - } - if len(cardIDs) > 0 { if err := tx.WithContext(ctx). Model(&model.IotCard{}). diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index 075e171..75572b6 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -145,7 +145,7 @@ func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model. AssetID: device.ID, VirtualNo: device.VirtualNo, Status: device.Status, - StatusName: constants.GetIotCardStatusName(device.Status), + StatusName: constants.GetDeviceStatusName(device.Status), BatchNo: device.BatchNo, ShopID: device.ShopID, SeriesID: device.SeriesID, @@ -226,6 +226,13 @@ func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model. // 查当前主套餐 s.fillPackageInfo(ctx, resp, "device", device.ID) + activationStatuses, err := s.deviceStore.GetActivationStatusMap(ctx, []uint{device.ID}) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备激活状态失败") + } + resp.ActivationStatus = activationStatuses[device.ID] + resp.ActivationStatusName = constants.GetActivationStatusName(resp.ActivationStatus) + // 查 shop 名称 s.fillShopName(ctx, resp) @@ -241,32 +248,33 @@ func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model. // 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, - StatusName: constants.GetIotCardStatusName(card.Status), - BatchNo: card.BatchNo, - ShopID: card.ShopID, - SeriesID: card.SeriesID, - ActivatedAt: card.ActivatedAt, - CreatedAt: card.CreatedAt, - UpdatedAt: card.UpdatedAt, - RealNameStatus: card.RealNameStatus, - RealNameAt: card.FirstRealnameAt, - RealnamePolicy: card.RealnamePolicy, - NetworkStatus: card.NetworkStatus, - GatewayExtend: card.GatewayExtend, - 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, + AssetType: "card", + AssetID: card.ID, + VirtualNo: card.VirtualNo, + Status: card.Status, + StatusName: constants.GetIotCardStatusName(card.Status), + BatchNo: card.BatchNo, + ShopID: card.ShopID, + SeriesID: card.SeriesID, + ActivatedAt: card.ActivatedAt, + CreatedAt: card.CreatedAt, + UpdatedAt: card.UpdatedAt, + RealNameStatus: card.RealNameStatus, + RealNameAt: card.FirstRealnameAt, + RealnamePolicy: card.RealnamePolicy, + NetworkStatus: card.NetworkStatus, + GatewayExtend: card.GatewayExtend, + 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, + ActivationStatusName: constants.GetActivationStatusName(card.ActivationStatus), + EnablePolling: card.EnablePolling, } // 查绑定设备 diff --git a/internal/service/device/service.go b/internal/service/device/service.go index 6aedcac..3f2843b 100644 --- a/internal/service/device/service.go +++ b/internal/service/device/service.go @@ -93,6 +93,9 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li if req.Status != nil { filters["status"] = *req.Status } + if req.ActivationStatus != nil { + filters["activation_status"] = *req.ActivationStatus + } shopIDs, hasShopIDs := normalizeShopIDs(req.ShopIDs) if hasShopIDs { filters["shop_ids"] = shopIDs @@ -129,14 +132,19 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li shopMap := s.loadShopData(ctx, devices) seriesMap := s.loadSeriesNames(ctx, devices) - bindingCounts, err := s.getBindingCounts(ctx, s.extractDeviceIDs(devices)) + deviceIDs := s.extractDeviceIDs(devices) + bindingCounts, err := s.getBindingCounts(ctx, deviceIDs) + if err != nil { + return nil, err + } + activationStatuses, err := s.deviceStore.GetActivationStatusMap(ctx, deviceIDs) if err != nil { return nil, err } list := make([]*dto.DeviceResponse, 0, len(devices)) for _, device := range devices { - item := s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts) + item := s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts, activationStatuses) list = append(list, item) } @@ -183,8 +191,12 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.DeviceResponse, error) if err != nil { return nil, err } + activationStatuses, err := s.deviceStore.GetActivationStatusMap(ctx, []uint{device.ID}) + if err != nil { + return nil, err + } - return s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts), nil + return s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts, activationStatuses), nil } // GetByDeviceNo 通过设备号获取设备详情 @@ -203,8 +215,12 @@ func (s *Service) GetByDeviceNo(ctx context.Context, deviceNo string) (*dto.Devi if err != nil { return nil, err } + activationStatuses, err := s.deviceStore.GetActivationStatusMap(ctx, []uint{device.ID}) + if err != nil { + return nil, err + } - return s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts), nil + return s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts, activationStatuses), nil } // GetByIdentifier 通过任意标识符获取设备详情 @@ -224,8 +240,12 @@ func (s *Service) GetByIdentifier(ctx context.Context, identifier string) (*dto. if err != nil { return nil, err } + activationStatuses, err := s.deviceStore.GetActivationStatusMap(ctx, []uint{device.ID}) + if err != nil { + return nil, err + } - return s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts), nil + return s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts, activationStatuses), nil } // GetDeviceByIdentifier 通过任意标识符获取设备模型(内部使用,不转为 DTO) @@ -884,31 +904,34 @@ func (s *Service) extractDeviceIDs(devices []*model.Device) []uint { return ids } -func (s *Service) toDeviceResponse(device *model.Device, shopMap map[uint]string, seriesMap map[uint]string, bindingCounts map[uint]int64) *dto.DeviceResponse { +func (s *Service) toDeviceResponse(device *model.Device, shopMap map[uint]string, seriesMap map[uint]string, bindingCounts map[uint]int64, activationStatuses map[uint]int) *dto.DeviceResponse { + activationStatus := activationStatuses[device.ID] resp := &dto.DeviceResponse{ - ID: device.ID, - VirtualNo: device.VirtualNo, - IMEI: device.IMEI, - SN: device.SN, - DeviceName: device.DeviceName, - DeviceModel: device.DeviceModel, - DeviceType: device.DeviceType, - MaxSimSlots: device.MaxSimSlots, - Manufacturer: device.Manufacturer, - BatchNo: device.BatchNo, - ShopID: device.ShopID, - Status: device.Status, - StatusName: s.getDeviceStatusName(device.Status), - BoundCardCount: int(bindingCounts[device.ID]), - SeriesID: device.SeriesID, - ActivatedAt: device.ActivatedAt, - CreatedAt: device.CreatedAt, - UpdatedAt: device.UpdatedAt, - OnlineStatus: device.OnlineStatus, - LastOnlineTime: device.LastOnlineTime, - SoftwareVersion: device.SoftwareVersion, - SwitchMode: device.SwitchMode, - LastGatewaySyncAt: device.LastGatewaySyncAt, + ID: device.ID, + VirtualNo: device.VirtualNo, + IMEI: device.IMEI, + SN: device.SN, + DeviceName: device.DeviceName, + DeviceModel: device.DeviceModel, + DeviceType: device.DeviceType, + MaxSimSlots: device.MaxSimSlots, + Manufacturer: device.Manufacturer, + BatchNo: device.BatchNo, + ShopID: device.ShopID, + Status: device.Status, + StatusName: constants.GetDeviceStatusName(device.Status), + ActivationStatus: activationStatus, + ActivationStatusName: constants.GetActivationStatusName(activationStatus), + BoundCardCount: int(bindingCounts[device.ID]), + SeriesID: device.SeriesID, + ActivatedAt: device.ActivatedAt, + CreatedAt: device.CreatedAt, + UpdatedAt: device.UpdatedAt, + OnlineStatus: device.OnlineStatus, + LastOnlineTime: device.LastOnlineTime, + SoftwareVersion: device.SoftwareVersion, + SwitchMode: device.SwitchMode, + LastGatewaySyncAt: device.LastGatewaySyncAt, } if device.ShopID != nil && *device.ShopID > 0 { @@ -922,21 +945,6 @@ func (s *Service) toDeviceResponse(device *model.Device, shopMap map[uint]string return resp } -func (s *Service) getDeviceStatusName(status int) string { - switch status { - case constants.DeviceStatusInStock: - return "在库" - case constants.DeviceStatusDistributed: - return "已分销" - case constants.DeviceStatusActivated: - return "已激活" - case constants.DeviceStatusSuspended: - return "已停用" - default: - return "未知" - } -} - func (s *Service) buildAllocationRecords(devices []*model.Device, successDeviceIDs []uint, fromShopID *uint, toShopID uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord { successIDSet := make(map[uint]bool) for _, id := range successDeviceIDs { @@ -1312,6 +1320,7 @@ func hasDeviceSeriesBindingFilters(req *dto.BatchSetDeviceSeriesBindngRequest) b return req.VirtualNo != "" || req.DeviceName != "" || req.Status != nil || + req.ActivationStatus != nil || req.ShopID != nil || len(req.ShopIDs) > 0 || req.FilterSeriesID != nil || @@ -1355,6 +1364,9 @@ func buildDeviceSeriesBindingFilters(req *dto.BatchSetDeviceSeriesBindngRequest) if req.Status != nil { filters["status"] = *req.Status } + if req.ActivationStatus != nil { + filters["activation_status"] = *req.ActivationStatus + } shopIDs, hasShopIDs := normalizeShopIDs(req.ShopIDs) if hasShopIDs { filters["shop_ids"] = shopIDs diff --git a/internal/service/package/activation_service.go b/internal/service/package/activation_service.go index b744c15..841bfaf 100644 --- a/internal/service/package/activation_service.go +++ b/internal/service/package/activation_service.go @@ -604,23 +604,8 @@ func (s *ActivationService) syncCarrierStatusActivated(ctx context.Context, tx * } if usage.UsageType == constants.PackageUsageTypeDevice || usage.DeviceID > 0 || carrierType == constants.AssetTypeDevice { - deviceID := usage.DeviceID - if deviceID == 0 { - deviceID = carrierID - } - if deviceID == 0 { - return - } - - if err := tx.WithContext(ctx). - Model(&model.Device{}). - Where("id = ? AND status < ?", deviceID, constants.DeviceStatusActivated). - Update("status", constants.DeviceStatusActivated).Error; err != nil { - s.logger.Warn("套餐激活后更新设备状态失败", - zap.Uint("device_id", deviceID), - zap.Uint("usage_id", usage.ID), - zap.Error(err)) - } + // 设备激活状态由“生效中主套餐 + 任意绑定卡已实名”动态计算, + // 不再写入设备归属状态字段。 return } diff --git a/internal/store/postgres/device_store.go b/internal/store/postgres/device_store.go index 7188f4a..cfb45af 100644 --- a/internal/store/postgres/device_store.go +++ b/internal/store/postgres/device_store.go @@ -143,6 +143,9 @@ func (s *DeviceStore) applyDeviceFilters(query *gorm.DB, filters map[string]any) if status, ok := filters["status"].(int); ok && status > 0 { query = query.Where("status = ?", status) } + if activationStatus, ok := filters["activation_status"].(int); ok { + query = s.applyActivationStatusFilter(query, activationStatus) + } if shopIDs, ok := filters["shop_ids"].([]uint); ok { if len(shopIDs) == 0 { query = query.Where("1 = 0") @@ -177,6 +180,37 @@ func (s *DeviceStore) applyDeviceFilters(query *gorm.DB, filters map[string]any) return query } +func (s *DeviceStore) applyActivationStatusFilter(query *gorm.DB, activationStatus int) *gorm.DB { + activeCondition := `EXISTS ( + SELECT 1 + FROM tb_package_usage AS pu + JOIN tb_device_sim_binding AS b + ON b.device_id = pu.device_id + AND b.bind_status = ? + AND b.deleted_at IS NULL + JOIN tb_iot_card AS c + ON c.id = b.iot_card_id + AND c.real_name_status = ? + AND c.deleted_at IS NULL + WHERE pu.device_id = tb_device.id + AND pu.status = ? + AND pu.master_usage_id IS NULL + AND pu.deleted_at IS NULL + )` + args := []any{ + constants.BindStatusBound, + constants.RealNameStatusVerified, + constants.PackageUsageStatusActive, + } + if activationStatus == constants.ActivationStatusActive { + return query.Where(activeCondition, args...) + } + if activationStatus == constants.ActivationStatusInactive { + return query.Where("NOT "+activeCondition, args...) + } + return query +} + // GetByFilters 根据筛选条件查询设备 // 用于批量设置套餐系列时选择完整筛选结果,不受列表分页限制。 func (s *DeviceStore) GetByFilters(ctx context.Context, filters map[string]any) ([]*model.Device, error) { @@ -206,6 +240,38 @@ func (s *DeviceStore) BatchUpdateShopIDAndStatus(ctx context.Context, ids []uint return s.db.WithContext(ctx).Model(&model.Device{}).Where("id IN ?", ids).Updates(updates).Error } +// GetActivationStatusMap 批量查询设备激活状态。 +// 设备激活规则:存在生效中主套餐,且任意已绑定卡已实名。 +func (s *DeviceStore) GetActivationStatusMap(ctx context.Context, ids []uint) (map[uint]int, error) { + result := make(map[uint]int, len(ids)) + if len(ids) == 0 { + return result, nil + } + for _, id := range ids { + result[id] = constants.ActivationStatusInactive + } + + var activeDeviceIDs []uint + err := s.db.WithContext(ctx). + Table("tb_package_usage AS pu"). + Distinct("pu.device_id"). + Joins("JOIN tb_device_sim_binding AS b ON b.device_id = pu.device_id AND b.bind_status = ? AND b.deleted_at IS NULL", constants.BindStatusBound). + Joins("JOIN tb_iot_card AS c ON c.id = b.iot_card_id AND c.real_name_status = ? AND c.deleted_at IS NULL", constants.RealNameStatusVerified). + Where("pu.device_id IN ?", ids). + Where("pu.status = ?", constants.PackageUsageStatusActive). + Where("pu.master_usage_id IS NULL"). + Where("pu.deleted_at IS NULL"). + Pluck("pu.device_id", &activeDeviceIDs).Error + if err != nil { + return nil, err + } + + for _, id := range activeDeviceIDs { + result[id] = constants.ActivationStatusActive + } + return result, nil +} + func (s *DeviceStore) ExistsByDeviceNoBatch(ctx context.Context, deviceNos []string) (map[string]bool, error) { result := make(map[string]bool) if len(deviceNos) == 0 { diff --git a/migrations/000144_split_device_distribution_activation_status.down.sql b/migrations/000144_split_device_distribution_activation_status.down.sql new file mode 100644 index 0000000..a7ba5db --- /dev/null +++ b/migrations/000144_split_device_distribution_activation_status.down.sql @@ -0,0 +1,2 @@ +-- 回滚字段注释;历史 status=3/4 数据无法从归属状态无损还原。 +COMMENT ON COLUMN tb_device.status IS '状态 1-在库 2-已分销 3-已激活 4-已停用'; diff --git a/migrations/000144_split_device_distribution_activation_status.up.sql b/migrations/000144_split_device_distribution_activation_status.up.sql new file mode 100644 index 0000000..6c18f69 --- /dev/null +++ b/migrations/000144_split_device_distribution_activation_status.up.sql @@ -0,0 +1,10 @@ +-- 将设备 status 收敛为归属状态:1=在库,2=已分销。 +-- 设备激活状态不落库,由“生效中主套餐 + 任意绑定卡已实名”动态计算。 +UPDATE tb_device +SET status = CASE + WHEN shop_id IS NULL THEN 1 + ELSE 2 +END +WHERE status NOT IN (1, 2); + +COMMENT ON COLUMN tb_device.status IS '归属状态 1-在库 2-已分销'; diff --git a/pkg/constants/iot.go b/pkg/constants/iot.go index 0c47a35..4f59745 100644 --- a/pkg/constants/iot.go +++ b/pkg/constants/iot.go @@ -26,8 +26,6 @@ const ( const ( DeviceStatusInStock = 1 // 在库 DeviceStatusDistributed = 2 // 已分销 - DeviceStatusActivated = 3 // 已激活 - DeviceStatusSuspended = 4 // 已停用 ) // 号卡状态 @@ -293,6 +291,19 @@ func GetIotCardStatusName(status int) string { } } +// GetDeviceStatusName 获取设备归属状态名称 +// 对应 DeviceStatus* 常量:1=在库, 2=已分销 +func GetDeviceStatusName(status int) string { + switch status { + case DeviceStatusInStock: + return "在库" + case DeviceStatusDistributed: + return "已分销" + default: + return "未知" + } +} + // GetActivationStatusName 获取激活状态名称 // 对应 ActivationStatus* 常量:0=未激活, 1=已激活 func GetActivationStatusName(status int) string {