diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index 9592c35..9e53a61 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -320,12 +320,12 @@ components: description: 设备ID minimum: 0 type: integer - device_no: - description: 设备号 - type: string reason: description: 失败原因 type: string + virtual_no: + description: 设备虚拟号 + type: string type: object DtoAllocationFailedItem: properties: @@ -517,6 +517,248 @@ components: description: 目标所有者类型 type: string type: object + DtoAssetPackageResponse: + properties: + activated_at: + description: 激活时间 + format: date-time + type: string + created_at: + description: 创建时间 + format: date-time + type: string + data_limit_mb: + description: 套餐真流量总量(MB) + type: integer + data_usage_mb: + description: 已用真流量(MB) + type: integer + expires_at: + description: 到期时间 + format: date-time + type: string + master_usage_id: + description: 主套餐ID(加油包时有值) + minimum: 0 + nullable: true + type: integer + package_id: + description: 套餐ID + minimum: 0 + type: integer + package_name: + description: 套餐名称 + type: string + package_type: + description: 套餐类型:formal/addon + type: string + package_usage_id: + description: 套餐使用记录ID + minimum: 0 + type: integer + priority: + description: 优先级 + type: integer + status: + description: 状态:0待生效 1生效中 2已用完 3已过期 4已失效 + type: integer + status_name: + description: 状态名称 + type: string + usage_type: + description: 使用类型:single_card/device + type: string + virtual_limit_mb: + description: 套餐虚流量总量(MB),按virtual_ratio换算 + type: integer + virtual_ratio: + description: 虚流量比例(real/virtual) + type: number + virtual_remain_mb: + description: 剩余虚流量(MB),按virtual_ratio换算 + type: number + virtual_used_mb: + description: 已用虚流量(MB),按virtual_ratio换算 + type: number + type: object + DtoAssetRealtimeStatusResponse: + properties: + asset_id: + description: 资产ID + minimum: 0 + type: integer + asset_type: + description: 资产类型:card 或 device + type: string + cards: + description: 绑定卡状态列表(asset_type=device时有效) + items: + $ref: '#/components/schemas/DtoBoundCardInfo' + type: array + current_month_usage_mb: + description: 本月已用流量MB(asset_type=card时有效) + type: number + device_protect_status: + description: 保护期状态(asset_type=device时有效):none/stop/start + type: string + last_sync_time: + description: 最后同步时间(asset_type=card时有效) + format: date-time + nullable: true + type: string + network_status: + description: 网络状态(asset_type=card时有效):0停机 1开机 + type: integer + real_name_status: + description: 实名状态(asset_type=card时有效) + type: integer + type: object + DtoAssetResolveResponse: + properties: + accumulated_recharge: + description: 累计充值金额(分) + type: integer + activated_at: + description: 激活时间 + format: date-time + nullable: true + type: string + activation_status: + description: 激活状态 + type: integer + asset_id: + description: 资产数据库ID + minimum: 0 + type: integer + asset_type: + description: 资产类型:card 或 device + type: string + batch_no: + description: 批次号 + type: string + bound_card_count: + description: 绑定的卡数量(asset_type=device时有效) + type: integer + bound_device_id: + description: 绑定的设备ID(asset_type=card时有效) + minimum: 0 + nullable: true + type: integer + bound_device_name: + description: 绑定的设备名称(asset_type=card时有效) + type: string + bound_device_no: + description: 绑定的设备虚拟号(asset_type=card时有效) + type: string + card_category: + description: 卡业务类型 + type: string + cards: + description: 绑定的卡列表(asset_type=device时有效) + items: + $ref: '#/components/schemas/DtoBoundCardInfo' + type: array + carrier_id: + description: 运营商ID + minimum: 0 + type: integer + carrier_name: + description: 运营商名称 + type: string + carrier_type: + description: 运营商类型 + type: string + created_at: + description: 创建时间 + format: date-time + type: string + current_package: + description: 当前套餐名称(无套餐时为空) + type: string + device_model: + description: 设备型号 + type: string + device_name: + description: 设备名称 + type: string + device_protect_status: + description: 设备保护期状态:none/stop/start(仅asset_type=device时有效) + type: string + device_type: + description: 设备类型 + type: string + enable_polling: + description: 是否参与轮询 + type: boolean + first_commission_paid: + description: 一次性佣金是否已发放 + type: boolean + iccid: + description: 卡ICCID(asset_type=card时有效) + type: string + imei: + description: 设备IMEI + type: string + imsi: + description: IMSI + type: string + manufacturer: + description: 制造商 + type: string + max_sim_slots: + description: 最大插槽数 + type: integer + msisdn: + description: 手机号 + type: string + network_status: + description: 网络状态:0停机 1开机(asset_type=card时有效) + type: integer + package_remain_mb: + description: 当前套餐剩余虚流量(MB),已按virtual_ratio换算 + type: number + package_total_mb: + description: 当前套餐总虚流量(MB),已按virtual_ratio换算 + type: integer + package_used_mb: + description: 当前已用虚流量(MB),已按virtual_ratio换算 + type: number + real_name_status: + description: 实名状态:0未实名 1实名中 2已实名 + type: integer + series_id: + description: 套餐系列ID + minimum: 0 + nullable: true + type: integer + series_name: + description: 套餐系列名称 + type: string + shop_id: + description: 所属店铺ID + minimum: 0 + nullable: true + type: integer + shop_name: + description: 所属店铺名称 + type: string + sn: + description: 设备序列号 + type: string + status: + description: 资产状态 + type: integer + supplier: + description: 供应商 + type: string + updated_at: + description: 更新时间 + format: date-time + type: string + virtual_no: + description: 虚拟号 + type: string + type: object DtoAssignPermissionsParams: properties: perm_ids: @@ -634,8 +876,8 @@ components: description: 设备ID minimum: 0 type: integer - device_no: - description: 设备号 + virtual_no: + description: 设备虚拟号 type: string type: object DtoBatchAllocatePackagesRequest: @@ -785,6 +1027,28 @@ components: description: 提示信息 type: string type: object + DtoBoundCardInfo: + properties: + card_id: + description: 卡ID + minimum: 0 + type: integer + iccid: + description: ICCID + type: string + msisdn: + description: 手机号 + type: string + network_status: + description: 网络状态:0停机 1开机 + type: integer + real_name_status: + description: 实名状态 + type: integer + slot_position: + description: 插槽位置 + type: integer + type: object DtoCancelTriggerReq: properties: trigger_id: @@ -1127,7 +1391,7 @@ components: nullable: true type: array payment_method: - description: 支付方式 (wallet:钱包支付, offline:线下支付) + description: 支付方式 (wallet:钱包支付, wechat:微信支付, alipay:支付宝支付) type: string required: - order_type @@ -1379,25 +1643,6 @@ components: - role_name - role_type type: object - DtoCreateShopPackageAllocationRequest: - properties: - cost_price: - description: 该代理的成本价(分) - minimum: 0 - type: integer - package_id: - description: 套餐ID - minimum: 0 - type: integer - shop_id: - description: 被分配的店铺ID - minimum: 0 - type: integer - required: - - shop_id - - package_id - - cost_price - type: object DtoCreateShopRequest: properties: address: @@ -1467,49 +1712,38 @@ components: - init_username - init_phone type: object - DtoCreateShopSeriesAllocationRequest: + DtoCreateShopSeriesGrantRequest: properties: + commission_tiers: + description: 梯度模式阶梯配置,梯度模式必填 + items: + $ref: '#/components/schemas/DtoGrantCommissionTierItem' + type: array enable_force_recharge: - description: 是否启用强制充值 - nullable: true - type: boolean - enable_one_time_commission: - description: 是否启用一次性佣金 + description: 是否启用代理强充 nullable: true type: boolean force_recharge_amount: - description: 强制充值金额(分) - minimum: 0 - nullable: true - type: integer - force_recharge_trigger_type: - description: 强充触发类型 (1:单次充值, 2:累计充值) + description: 代理强充金额(分) nullable: true type: integer one_time_commission_amount: - description: 该代理能拿的一次性佣金金额上限(分) - minimum: 0 - type: integer - one_time_commission_threshold: - description: 一次性佣金触发阈值(分) - minimum: 0 + description: 固定模式佣金金额(分),固定模式必填 nullable: true type: integer - one_time_commission_trigger: - description: 一次性佣金触发类型 (first_recharge:首次充值, accumulated_recharge:累计充值) - type: string + packages: + description: 初始授权套餐列表 + items: + $ref: '#/components/schemas/DtoGrantPackageItem' + type: array series_id: description: 套餐系列ID minimum: 0 type: integer shop_id: - description: 被分配的店铺ID + description: 被授权代理店铺ID minimum: 0 type: integer - required: - - shop_id - - series_id - - one_time_commission_amount type: object DtoCreateWithdrawalSettingReq: properties: @@ -1753,32 +1987,17 @@ components: description: 网络状态名称 type: string type: object - DtoDeviceCardOperationReq: - properties: - reason: - description: 操作原因 - type: string - type: object - DtoDeviceCardOperationResp: - properties: - message: - description: 操作结果消息 - type: string - success: - description: 操作是否成功 - type: boolean - type: object DtoDeviceImportResultItemDTO: properties: - device_no: - description: 设备号 - type: string line: description: 行号 type: integer reason: description: 原因 type: string + virtual_no: + description: 设备虚拟号 + type: string type: object DtoDeviceImportTaskDetailResponse: properties: @@ -1932,9 +2151,6 @@ components: device_name: description: 设备名称 type: string - device_no: - description: 设备号 - type: string device_type: description: 设备类型 type: string @@ -1945,6 +2161,9 @@ components: description: 设备ID minimum: 0 type: integer + imei: + description: 设备IMEI + type: string manufacturer: description: 制造商 type: string @@ -1967,6 +2186,9 @@ components: shop_name: description: 店铺名称 type: string + sn: + description: 设备序列号 + type: string status: description: 状态 (1:在库, 2:已分销, 3:已激活, 4:已停用) type: integer @@ -1977,6 +2199,9 @@ components: description: 更新时间 format: date-time type: string + virtual_no: + description: 设备虚拟号/别名 + type: string type: object DtoDeviceSeriesBindngFailedItem: properties: @@ -1984,13 +2209,39 @@ components: description: 设备ID minimum: 0 type: integer - device_no: - description: 设备号 + reason: + description: 失败原因 + type: string + virtual_no: + description: 设备虚拟号 + type: string + type: object + DtoDeviceSuspendFailItem: + properties: + iccid: + description: 卡ICCID type: string reason: description: 失败原因 type: string type: object + DtoDeviceSuspendResponse: + properties: + fail_count: + description: 失败卡数 + type: integer + failed_items: + description: 失败详情 + items: + $ref: '#/components/schemas/DtoDeviceSuspendFailItem' + type: array + skip_count: + description: 跳过卡数(未实名或已停机) + type: integer + success_count: + description: 成功停机卡数 + type: integer + type: object DtoEmptyResponse: properties: message: @@ -2011,9 +2262,6 @@ components: minimum: 0 nullable: true type: integer - device_no: - description: 设备号 - type: string iccid: description: ICCID type: string @@ -2044,6 +2292,9 @@ components: status_name: description: 状态名称 type: string + virtual_no: + description: 设备虚拟号 + type: string type: object DtoEnterpriseCardPageResult: properties: @@ -2090,12 +2341,12 @@ components: device_name: description: 设备名称 type: string - device_no: - description: 设备号 - type: string device_type: description: 设备类型 type: string + virtual_no: + description: 设备虚拟号 + type: string type: object DtoEnterpriseDeviceItem: properties: @@ -2116,8 +2367,8 @@ components: device_name: description: 设备名称 type: string - device_no: - description: 设备号 + virtual_no: + description: 设备虚拟号 type: string type: object DtoEnterpriseDeviceListResp: @@ -2209,12 +2460,12 @@ components: type: object DtoFailedDeviceItem: properties: - device_no: - description: 设备号 - type: string reason: description: 失败原因 type: string + virtual_no: + description: 设备虚拟号 + type: string type: object DtoFailedItem: properties: @@ -2255,6 +2506,38 @@ components: description: 预签名上传 URL,使用 PUT 方法上传文件 type: string type: object + DtoGrantCommissionTierItem: + properties: + amount: + description: 该代理在此档位的佣金金额(分) + type: integer + dimension: + description: 统计维度(sales_count:销售量, sales_amount:销售额),来自 PackageSeries 全局配置,响应中只读 + type: string + operator: + description: 比较运算符(>、>=、<、<=),响应中从 PackageSeries 合并,请求中不传 + type: string + stat_scope: + description: 统计范围(self:仅自己, self_and_sub:自己+下级),来自 PackageSeries 全局配置,响应中只读 + type: string + threshold: + description: 阈值(与 PackageSeries 全局配置对应) + type: integer + type: object + DtoGrantPackageItem: + properties: + cost_price: + description: 成本价(分) + type: integer + package_id: + description: 套餐ID + minimum: 0 + type: integer + remove: + description: 是否删除该套餐授权(true=删除) + nullable: true + type: boolean + type: object DtoImportDeviceRequest: properties: batch_no: @@ -2463,115 +2746,6 @@ components: description: 总数 type: integer type: object - DtoIotCardDetailResponse: - properties: - accumulated_recharge: - description: 累计充值金额(分) - type: integer - activated_at: - description: 激活时间 - format: date-time - nullable: true - type: string - activation_status: - description: 激活状态 (0:未激活, 1:已激活) - type: integer - batch_no: - description: 批次号 - type: string - card_category: - description: 卡业务类型 (normal:普通卡, industry:行业卡) - type: string - carrier_id: - description: 运营商ID - minimum: 0 - type: integer - carrier_name: - description: 运营商名称 - type: string - carrier_type: - description: 运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电) - type: string - created_at: - description: 创建时间 - format: date-time - type: string - current_month_start_date: - description: 本月开始日期 - format: date-time - nullable: true - type: string - current_month_usage_mb: - description: 本月已用流量(MB) - type: number - data_usage_mb: - description: 累计流量使用(MB) - type: integer - enable_polling: - description: 是否参与轮询 - type: boolean - first_commission_paid: - description: 一次性佣金是否已发放 - type: boolean - iccid: - description: ICCID - type: string - id: - description: 卡ID - minimum: 0 - type: integer - imsi: - description: IMSI - type: string - last_data_check_at: - description: 最后流量检查时间 - format: date-time - nullable: true - type: string - last_month_total_mb: - description: 上月流量总量(MB) - type: number - last_real_name_check_at: - description: 最后实名检查时间 - format: date-time - nullable: true - type: string - msisdn: - description: 卡接入号 - type: string - network_status: - description: 网络状态 (0:停机, 1:开机) - type: integer - real_name_status: - description: 实名状态 (0:未实名, 1:已实名) - type: integer - series_id: - description: 套餐系列ID - minimum: 0 - nullable: true - type: integer - series_name: - description: 套餐系列名称 - type: string - shop_id: - description: 店铺ID - minimum: 0 - nullable: true - type: integer - shop_name: - description: 店铺名称 - type: string - status: - description: 状态 (1:在库, 2:已分销, 3:已激活, 4:已停用) - type: integer - supplier: - description: 供应商 - type: string - updated_at: - description: 更新时间 - format: date-time - type: string - type: object DtoListAssetAllocationRecordResponse: properties: list: @@ -2724,6 +2898,15 @@ components: user: $ref: '#/components/schemas/DtoUserInfo' type: object + DtoManageGrantPackagesParams: + properties: + packages: + description: 套餐操作列表 + items: + $ref: '#/components/schemas/DtoGrantPackageItem' + nullable: true + type: array + type: object DtoManualTriggerLogListResp: properties: items: @@ -2920,6 +3103,9 @@ components: dimension: description: 统计维度 (sales_count:销量, sales_amount:销售额) type: string + operator: + description: 阈值比较运算符(>、>=、<、<=),空值时计算引擎默认 >= + type: string stat_scope: description: 统计范围 (self:仅自己, self_and_sub:自己+下级) type: string @@ -3004,6 +3190,11 @@ components: minimum: 0 nullable: true type: integer + expires_at: + description: 订单过期时间 + format: date-time + nullable: true + type: string id: description: 订单ID minimum: 0 @@ -3013,6 +3204,9 @@ components: minimum: 0 nullable: true type: integer + is_expired: + description: 是否已过期 + type: boolean is_purchase_on_behalf: description: 是否为代购订单 type: boolean @@ -3982,15 +4176,15 @@ components: description: 设备ID minimum: 0 type: integer - device_no: - description: 设备号 - type: string iccids: description: 卡ICCID列表 items: type: string nullable: true type: array + virtual_no: + description: 设备虚拟号 + type: string type: object DtoRechargeCheckResponse: properties: @@ -4221,27 +4415,23 @@ components: type: object DtoSetSpeedLimitRequest: properties: - download_speed: - description: 下行速率(KB/s) - minimum: 1 - type: integer - upload_speed: - description: 上行速率(KB/s) + speed_limit: + description: 限速值(KB/s) minimum: 1 type: integer required: - - upload_speed - - download_speed + - speed_limit type: object DtoSetWiFiRequest: properties: + card_no: + description: 流量卡号(ICCID) + type: string enabled: - description: 启用状态(0:禁用, 1:启用) - type: integer + description: 启用状态 + type: boolean password: description: WiFi 密码 - maxLength: 63 - minLength: 8 type: string ssid: description: WiFi 名称 @@ -4249,9 +4439,8 @@ components: minLength: 1 type: string required: + - card_no - ssid - - password - - enabled type: object DtoShopCommissionRecordItem: properties: @@ -4267,9 +4456,6 @@ components: created_at: description: 佣金入账时间 type: string - device_no: - description: 设备号 - type: string iccid: description: ICCID type: string @@ -4293,6 +4479,9 @@ components: status_name: description: 状态名称 type: string + virtual_no: + description: 设备虚拟号 + type: string type: object DtoShopCommissionRecordPageResult: properties: @@ -4370,82 +4559,6 @@ components: description: 总记录数 type: integer type: object - DtoShopPackageAllocationPageResult: - properties: - list: - description: 分配列表 - items: - $ref: '#/components/schemas/DtoShopPackageAllocationResponse' - nullable: true - type: array - page: - description: 当前页 - type: integer - page_size: - description: 每页数量 - type: integer - total: - description: 总数 - type: integer - total_pages: - description: 总页数 - type: integer - type: object - DtoShopPackageAllocationResponse: - properties: - allocator_shop_id: - description: 分配者店铺ID,0表示平台分配 - minimum: 0 - type: integer - allocator_shop_name: - description: 分配者店铺名称 - type: string - cost_price: - description: 该代理的成本价(分) - type: integer - created_at: - description: 创建时间 - type: string - id: - description: 分配ID - minimum: 0 - type: integer - package_code: - description: 套餐编码 - type: string - package_id: - description: 套餐ID - minimum: 0 - type: integer - package_name: - description: 套餐名称 - type: string - series_allocation_id: - description: 关联的系列分配ID - minimum: 0 - nullable: true - type: integer - series_id: - description: 套餐系列ID - minimum: 0 - type: integer - series_name: - description: 套餐系列名称 - type: string - shop_id: - description: 被分配的店铺ID - minimum: 0 - type: integer - shop_name: - description: 被分配的店铺名称 - type: string - status: - description: 状态 (1:启用, 2:禁用) - type: integer - updated_at: - description: 更新时间 - type: string - type: object DtoShopPageResult: properties: items: @@ -4545,12 +4658,86 @@ components: minimum: 0 type: integer type: object - DtoShopSeriesAllocationPageResult: + DtoShopSeriesGrantListItem: + properties: + allocator_shop_id: + description: 分配者店铺ID + minimum: 0 + type: integer + allocator_shop_name: + description: 分配者店铺名称 + type: string + commission_type: + description: 佣金类型 + type: string + created_at: + description: 创建时间 + type: string + force_recharge_amount: + description: 强充金额(分) + type: integer + force_recharge_enabled: + description: 是否启用强充 + type: boolean + force_recharge_locked: + description: 强充是否被套餐系列锁定(true 时代理不可修改) + type: boolean + id: + description: 授权记录ID + minimum: 0 + type: integer + one_time_commission_amount: + description: 固定模式佣金金额(分) + type: integer + package_count: + description: 已授权套餐数量 + type: integer + series_id: + description: 套餐系列ID + minimum: 0 + type: integer + series_name: + description: 套餐系列名称 + type: string + shop_id: + description: 被授权店铺ID + minimum: 0 + type: integer + shop_name: + description: 被授权店铺名称 + type: string + status: + description: 状态 1-启用 2-禁用 + type: integer + type: object + DtoShopSeriesGrantPackageItem: + properties: + cost_price: + description: 成本价(分) + type: integer + package_code: + description: 套餐编码 + type: string + package_id: + description: 套餐ID + minimum: 0 + type: integer + package_name: + description: 套餐名称 + type: string + shelf_status: + description: 上架状态 1-上架 2-下架 + type: integer + status: + description: 分配状态 1-启用 2-禁用 + type: integer + type: object + DtoShopSeriesGrantPageResult: properties: list: - description: 分配列表 + description: 授权列表 items: - $ref: '#/components/schemas/DtoShopSeriesAllocationResponse' + $ref: '#/components/schemas/DtoShopSeriesGrantListItem' nullable: true type: array page: @@ -4566,43 +4753,49 @@ components: description: 总页数 type: integer type: object - DtoShopSeriesAllocationResponse: + DtoShopSeriesGrantResponse: properties: allocator_shop_id: - description: 分配者店铺ID,0表示平台分配 + description: 分配者店铺ID,0 表示平台 minimum: 0 type: integer allocator_shop_name: description: 分配者店铺名称 type: string + commission_tiers: + description: 梯度模式阶梯列表(固定模式为空) + items: + $ref: '#/components/schemas/DtoGrantCommissionTierItem' + nullable: true + type: array + commission_type: + description: 佣金类型 fixed-固定 tiered-梯度 + type: string created_at: description: 创建时间 type: string - enable_force_recharge: - description: 是否启用强制充值 - type: boolean - enable_one_time_commission: - description: 是否启用一次性佣金 - type: boolean force_recharge_amount: - description: 强制充值金额(分) - type: integer - force_recharge_trigger_type: - description: 强充触发类型 (1:单次充值, 2:累计充值) + description: 强充金额(分) type: integer + force_recharge_enabled: + description: 是否启用强充 + type: boolean + force_recharge_locked: + description: 强充是否被平台锁定(true 时代理不可修改) + type: boolean id: - description: 分配ID + description: 授权记录ID minimum: 0 type: integer one_time_commission_amount: - description: 该代理能拿的一次性佣金金额上限(分) + description: 固定模式佣金金额(分),梯度模式返回 0 type: integer - one_time_commission_threshold: - description: 一次性佣金触发阈值(分) - type: integer - one_time_commission_trigger: - description: 一次性佣金触发类型 - type: string + packages: + description: 已授权套餐列表 + items: + $ref: '#/components/schemas/DtoShopSeriesGrantPackageItem' + nullable: true + type: array series_code: description: 套餐系列编码 type: string @@ -4614,14 +4807,14 @@ components: description: 套餐系列名称 type: string shop_id: - description: 被分配的店铺ID + description: 被授权店铺ID minimum: 0 type: integer shop_name: - description: 被分配的店铺名称 + description: 被授权店铺名称 type: string status: - description: 状态 (1:启用, 2:禁用) + description: 状态 1-启用 2-禁用 type: integer updated_at: description: 更新时间 @@ -5343,14 +5536,6 @@ components: nullable: true type: integer type: object - DtoUpdateShopPackageAllocationParams: - properties: - cost_price: - description: 该代理的成本价(分) - minimum: 0 - nullable: true - type: integer - type: object DtoUpdateShopParams: properties: address: @@ -5390,41 +5575,23 @@ components: - shop_name - status type: object - DtoUpdateShopSeriesAllocationParams: + DtoUpdateShopSeriesGrantParams: properties: + commission_tiers: + description: 梯度模式阶梯配置 + items: + $ref: '#/components/schemas/DtoGrantCommissionTierItem' + type: array enable_force_recharge: - description: 是否启用强制充值 - nullable: true - type: boolean - enable_one_time_commission: - description: 是否启用一次性佣金 + description: 是否启用代理强充 nullable: true type: boolean force_recharge_amount: - description: 强制充值金额(分) - minimum: 0 - nullable: true - type: integer - force_recharge_trigger_type: - description: 强充触发类型 (1:单次充值, 2:累计充值) + description: 代理强充金额(分) nullable: true type: integer one_time_commission_amount: - description: 该代理能拿的一次性佣金金额上限(分) - minimum: 0 - nullable: true - type: integer - one_time_commission_threshold: - description: 一次性佣金触发阈值(分) - minimum: 0 - nullable: true - type: integer - one_time_commission_trigger: - description: 一次性佣金触发类型 - nullable: true - type: string - status: - description: 状态 (1:启用, 2:禁用) + description: 固定模式佣金金额(分) nullable: true type: integer type: object @@ -5727,75 +5894,12 @@ components: - msg - timestamp type: object - GatewayCardStatusResp: - properties: - cardStatus: - description: 卡状态(准备、正常、停机) - type: string - extend: - description: 扩展字段(广电国网特殊参数) - type: string - iccid: - description: ICCID - type: string - type: object - GatewayDeviceInfoResp: - properties: - downloadSpeed: - description: 下行速率(KB/s) - type: integer - extend: - description: 扩展字段(广电国网特殊参数) - type: string - imei: - description: 设备 IMEI - type: string - onlineStatus: - description: 在线状态(0:离线, 1:在线) - type: integer - signalLevel: - description: 信号强度(0-31) - type: integer - uploadSpeed: - description: 上行速率(KB/s) - type: integer - wifiEnabled: - description: WiFi 启用状态(0:禁用, 1:启用) - type: integer - wifiSsid: - description: WiFi 名称 - type: string - type: object - GatewayFlowUsageResp: - properties: - extend: - description: 扩展字段(广电国网特殊参数) - type: string - unit: - description: 流量单位(MB) - type: string - usedFlow: - description: 已用流量 - type: integer - type: object GatewayRealnameLinkResp: properties: - extend: - description: 扩展字段(广电国网特殊参数) - type: string - link: + url: description: 实名认证跳转链接(HTTPS URL) type: string type: object - GatewayRealnameStatusResp: - properties: - extend: - description: 扩展字段(广电国网特殊参数) - type: string - status: - description: 实名认证状态 - type: string - type: object GatewaySlotInfo: properties: cardStatus: @@ -6733,6 +6837,566 @@ paths: summary: 分配记录详情 tags: - 资产分配记录 + /api/admin/assets/{asset_type}/{id}/current-package: + get: + parameters: + - description: 资产类型:card 或 device + in: path + name: asset_type + required: true + schema: + description: 资产类型:card 或 device + type: string + - description: 资产ID + in: path + name: id + required: true + schema: + description: 资产ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAssetPackageResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 当前生效套餐 + tags: + - 资产管理 + /api/admin/assets/{asset_type}/{id}/packages: + get: + description: 查询该资产所有套餐记录,含虚流量换算结果。 + parameters: + - description: 资产类型:card 或 device + in: path + name: asset_type + required: true + schema: + description: 资产类型:card 或 device + type: string + - description: 资产ID + in: path + name: id + required: true + schema: + description: 资产ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + items: + $ref: '#/components/schemas/DtoAssetPackageResponse' + type: array + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 资产套餐列表 + tags: + - 资产管理 + /api/admin/assets/{asset_type}/{id}/realtime-status: + get: + description: 读取 DB/Redis 中的持久化状态,不调网关。asset_type 为 card 或 device。 + parameters: + - description: 资产类型:card 或 device + in: path + name: asset_type + required: true + schema: + description: 资产类型:card 或 device + type: string + - description: 资产ID + in: path + name: id + required: true + schema: + description: 资产ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAssetRealtimeStatusResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 资产实时状态 + tags: + - 资产管理 + /api/admin/assets/{asset_type}/{id}/refresh: + post: + description: 主动调网关同步最新状态。设备有30秒冷却期。 + parameters: + - description: 资产类型:card 或 device + in: path + name: asset_type + required: true + schema: + description: 资产类型:card 或 device + type: string + - description: 资产ID + in: path + name: id + required: true + schema: + description: 资产ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAssetRealtimeStatusResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 刷新资产状态 + tags: + - 资产管理 + /api/admin/assets/card/{iccid}/start: + post: + description: 手动复机单张卡(通过ICCID)。受设备保护期约束。 + parameters: + - description: 卡ICCID + in: path + name: iccid + required: true + schema: + description: 卡ICCID + type: string + responses: + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 单卡复机 + tags: + - 资产管理 + /api/admin/assets/card/{iccid}/stop: + post: + description: 手动停机单张卡(通过ICCID)。受设备保护期约束。 + parameters: + - description: 卡ICCID + in: path + name: iccid + required: true + schema: + description: 卡ICCID + type: string + responses: + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 单卡停机 + tags: + - 资产管理 + /api/admin/assets/device/{device_id}/start: + post: + description: 批量复机设备下所有已实名卡。设置1小时复机保护期。 + parameters: + - description: 设备ID + in: path + name: device_id + required: true + schema: + description: 设备ID + minimum: 0 + type: integer + responses: + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 设备复机 + tags: + - 资产管理 + /api/admin/assets/device/{device_id}/stop: + post: + description: 批量停机设备下所有已实名卡。设置1小时停机保护期。 + parameters: + - description: 设备ID + in: path + name: device_id + required: true + schema: + description: 设备ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoDeviceSuspendResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 设备停机 + tags: + - 资产管理 + /api/admin/assets/resolve/{identifier}: + get: + description: 通过虚拟号/ICCID/IMEI/SN/MSISDN 解析设备或卡的完整详情。企业账号禁止调用。 + parameters: + - description: 资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN) + in: path + name: identifier + required: true + schema: + description: 资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN) + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAssetResolveResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 解析资产 + tags: + - 资产管理 /api/admin/authorizations: get: parameters: @@ -8335,11 +8999,11 @@ paths: maximum: 100 minimum: 1 type: integer - - description: 设备号(模糊查询) + - description: 虚拟号(模糊查询) in: query - name: device_no + name: virtual_no schema: - description: 设备号(模糊查询) + description: 虚拟号(模糊查询) maxLength: 100 type: string - description: 设备名称(模糊查询) @@ -8509,72 +9173,6 @@ paths: summary: 删除设备 tags: - 设备管理 - get: - parameters: - - description: 设备ID - in: path - name: id - required: true - schema: - description: 设备ID - minimum: 0 - type: integer - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoDeviceResponse' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 设备详情 - tags: - - 设备管理 /api/admin/devices/{id}/cards: get: parameters: @@ -8854,147 +9452,16 @@ paths: summary: 批量分配设备 tags: - 设备管理 - /api/admin/devices/by-imei/{imei}: + /api/admin/devices/by-identifier/{identifier}/gateway-slots: get: + description: 通过虚拟号/IMEI/SN 查询设备卡槽信息。设备必须已配置 IMEI。 parameters: - - description: 设备号(IMEI) + - description: 设备标识符(支持虚拟号/IMEI/SN) in: path - name: imei + name: identifier required: true schema: - description: 设备号(IMEI) - type: string - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoDeviceResponse' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 通过设备号查询设备详情 - tags: - - 设备管理 - /api/admin/devices/by-imei/{imei}/gateway-info: - get: - parameters: - - description: 设备号(IMEI) - in: path - name: imei - required: true - schema: - description: 设备号(IMEI) - type: string - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/GatewayDeviceInfoResp' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 查询设备信息 - tags: - - 设备管理 - /api/admin/devices/by-imei/{imei}/gateway-slots: - get: - parameters: - - description: 设备号(IMEI) - in: path - name: imei - required: true - schema: - description: 设备号(IMEI) + description: 设备标识符(支持虚拟号/IMEI/SN) type: string responses: "200": @@ -9052,15 +9519,16 @@ paths: summary: 查询卡槽信息 tags: - 设备管理 - /api/admin/devices/by-imei/{imei}/reboot: + /api/admin/devices/by-identifier/{identifier}/reboot: post: + description: 通过虚拟号/IMEI/SN 重启设备。设备必须已配置 IMEI。 parameters: - - description: 设备号(IMEI) + - description: 设备标识符(支持虚拟号/IMEI/SN) in: path - name: imei + name: identifier required: true schema: - description: 设备号(IMEI) + description: 设备标识符(支持虚拟号/IMEI/SN) type: string responses: "200": @@ -9118,15 +9586,16 @@ paths: summary: 重启设备 tags: - 设备管理 - /api/admin/devices/by-imei/{imei}/reset: + /api/admin/devices/by-identifier/{identifier}/reset: post: + description: 通过虚拟号/IMEI/SN 恢复设备出厂设置。设备必须已配置 IMEI。 parameters: - - description: 设备号(IMEI) + - description: 设备标识符(支持虚拟号/IMEI/SN) in: path - name: imei + name: identifier required: true schema: - description: 设备号(IMEI) + description: 设备标识符(支持虚拟号/IMEI/SN) type: string responses: "200": @@ -9184,15 +9653,16 @@ paths: summary: 恢复出厂 tags: - 设备管理 - /api/admin/devices/by-imei/{imei}/speed-limit: + /api/admin/devices/by-identifier/{identifier}/speed-limit: put: + description: 通过虚拟号/IMEI/SN 设置设备限速。设备必须已配置 IMEI。 parameters: - - description: 设备号(IMEI) + - description: 设备标识符(支持虚拟号/IMEI/SN) in: path - name: imei + name: identifier required: true schema: - description: 设备号(IMEI) + description: 设备标识符(支持虚拟号/IMEI/SN) type: string requestBody: content: @@ -9255,15 +9725,16 @@ paths: summary: 设置限速 tags: - 设备管理 - /api/admin/devices/by-imei/{imei}/switch-card: + /api/admin/devices/by-identifier/{identifier}/switch-card: post: + description: 通过虚拟号/IMEI/SN 切换设备当前使用的卡。设备必须已配置 IMEI。 parameters: - - description: 设备号(IMEI) + - description: 设备标识符(支持虚拟号/IMEI/SN) in: path - name: imei + name: identifier required: true schema: - description: 设备号(IMEI) + description: 设备标识符(支持虚拟号/IMEI/SN) type: string requestBody: content: @@ -9326,15 +9797,16 @@ paths: summary: 切卡 tags: - 设备管理 - /api/admin/devices/by-imei/{imei}/wifi: + /api/admin/devices/by-identifier/{identifier}/wifi: put: + description: 通过虚拟号/IMEI/SN 设置设备 WiFi。设备必须已配置 IMEI。 parameters: - - description: 设备号(IMEI) + - description: 设备标识符(支持虚拟号/IMEI/SN) in: path - name: imei + name: identifier required: true schema: - description: 设备号(IMEI) + description: 设备标识符(支持虚拟号/IMEI/SN) type: string requestBody: content: @@ -10208,11 +10680,11 @@ paths: schema: description: ICCID(模糊查询) type: string - - description: 设备号(模糊查询) + - description: 虚拟号(模糊查询) in: query - name: device_no + name: virtual_no schema: - description: 设备号(模糊查询) + description: 虚拟号(模糊查询) type: string - description: 企业ID in: path @@ -10278,104 +10750,6 @@ paths: summary: 企业卡列表 tags: - 企业卡授权 - /api/admin/enterprises/{id}/cards/{card_id}/resume: - post: - parameters: - - description: 企业ID - in: path - name: id - required: true - schema: - description: 企业ID - minimum: 0 - type: integer - - description: 卡ID - in: path - name: card_id - required: true - schema: - description: 卡ID - minimum: 0 - type: integer - responses: - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 复机卡 - tags: - - 企业卡授权 - /api/admin/enterprises/{id}/cards/{card_id}/suspend: - post: - parameters: - - description: 企业ID - in: path - name: id - required: true - schema: - description: 企业ID - minimum: 0 - type: integer - - description: 卡ID - in: path - name: card_id - required: true - schema: - description: 卡ID - minimum: 0 - type: integer - responses: - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 停机卡 - tags: - - 企业卡授权 /api/admin/enterprises/{id}/devices: get: parameters: @@ -10391,11 +10765,11 @@ paths: schema: description: 每页数量 type: integer - - description: 设备号(模糊搜索) + - description: 虚拟号(模糊搜索) in: query - name: device_no + name: virtual_no schema: - description: 设备号(模糊搜索) + description: 虚拟号(模糊搜索) type: string - description: 企业ID in: path @@ -10697,204 +11071,6 @@ paths: summary: 启用/禁用企业 tags: - 企业客户管理 - /api/admin/iot-cards/{iccid}/gateway-flow: - get: - parameters: - - description: ICCID - in: path - name: iccid - required: true - schema: - description: ICCID - type: string - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/GatewayFlowUsageResp' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 查询流量使用 - tags: - - IoT卡管理 - /api/admin/iot-cards/{iccid}/gateway-realname: - get: - parameters: - - description: ICCID - in: path - name: iccid - required: true - schema: - description: ICCID - type: string - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/GatewayRealnameStatusResp' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 查询实名认证状态 - tags: - - IoT卡管理 - /api/admin/iot-cards/{iccid}/gateway-status: - get: - parameters: - - description: ICCID - in: path - name: iccid - required: true - schema: - description: ICCID - type: string - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/GatewayCardStatusResp' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 查询卡实时状态 - tags: - - IoT卡管理 /api/admin/iot-cards/{iccid}/realname-link: get: parameters: @@ -10961,152 +11137,6 @@ paths: summary: 获取实名认证链接 tags: - IoT卡管理 - /api/admin/iot-cards/{iccid}/start: - post: - parameters: - - description: ICCID - in: path - name: iccid - required: true - schema: - description: ICCID - type: string - responses: - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 复机 - tags: - - IoT卡管理 - /api/admin/iot-cards/{iccid}/stop: - post: - parameters: - - description: ICCID - in: path - name: iccid - required: true - schema: - description: ICCID - type: string - responses: - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 停机 - tags: - - IoT卡管理 - /api/admin/iot-cards/by-iccid/{iccid}: - get: - parameters: - - description: ICCID - in: path - name: iccid - required: true - schema: - description: ICCID - type: string - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoIotCardDetailResponse' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 通过ICCID查询单卡详情 - tags: - - IoT卡管理 /api/admin/iot-cards/import: post: description: |- @@ -11860,11 +11890,11 @@ paths: schema: description: ICCID(模糊查询) type: string - - description: 设备号(模糊查询) + - description: 设备虚拟号(模糊查询) in: query - name: device_no + name: virtual_no schema: - description: 设备号(模糊查询) + description: 设备虚拟号(模糊查询) type: string - description: 订单号(模糊查询) in: query @@ -12284,6 +12314,13 @@ paths: format: date-time nullable: true type: string + - description: 是否已过期 (true:已过期, false:未过期) + in: query + name: is_expired + schema: + description: 是否已过期 (true:已过期, false:未过期) + nullable: true + type: boolean responses: "200": content: @@ -16041,472 +16078,6 @@ paths: summary: 移除权限 tags: - 角色 - /api/admin/shop-package-allocations: - get: - parameters: - - description: 页码 - in: query - name: page - schema: - description: 页码 - minimum: 1 - type: integer - - description: 每页数量 - in: query - name: page_size - schema: - description: 每页数量 - maximum: 100 - minimum: 1 - type: integer - - description: 被分配的店铺ID - in: query - name: shop_id - schema: - description: 被分配的店铺ID - minimum: 0 - nullable: true - type: integer - - description: 套餐ID - in: query - name: package_id - schema: - description: 套餐ID - minimum: 0 - nullable: true - type: integer - - description: 系列分配ID - in: query - name: series_allocation_id - schema: - description: 系列分配ID - minimum: 0 - nullable: true - type: integer - - description: 分配者店铺ID - in: query - name: allocator_shop_id - schema: - description: 分配者店铺ID - minimum: 0 - nullable: true - type: integer - - description: 状态 (1:启用, 2:禁用) - in: query - name: status - schema: - description: 状态 (1:启用, 2:禁用) - nullable: true - type: integer - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoShopPackageAllocationPageResult' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 单套餐分配列表 - tags: - - 套餐分配 - post: - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoCreateShopPackageAllocationRequest' - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoShopPackageAllocationResponse' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 创建单套餐分配 - tags: - - 套餐分配 - /api/admin/shop-package-allocations/{id}: - delete: - parameters: - - description: ID - in: path - name: id - required: true - schema: - description: ID - minimum: 0 - type: integer - responses: - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 删除单套餐分配 - tags: - - 套餐分配 - get: - parameters: - - description: ID - in: path - name: id - required: true - schema: - description: ID - minimum: 0 - type: integer - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoShopPackageAllocationResponse' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 获取单套餐分配详情 - tags: - - 套餐分配 - put: - parameters: - - description: ID - in: path - name: id - required: true - schema: - description: ID - minimum: 0 - type: integer - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoUpdateShopPackageAllocationParams' - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoShopPackageAllocationResponse' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 更新单套餐分配 - tags: - - 套餐分配 - /api/admin/shop-package-allocations/{id}/cost-price: - put: - parameters: - - description: ID - in: path - name: id - required: true - schema: - description: ID - minimum: 0 - type: integer - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoShopPackageAllocationResponse' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 更新单套餐分配成本价 - tags: - - 套餐分配 - /api/admin/shop-package-allocations/{id}/status: - put: - parameters: - - description: ID - in: path - name: id - required: true - schema: - description: ID - minimum: 0 - type: integer - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoUpdateStatusParams' - responses: - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 更新单套餐分配状态 - tags: - - 套餐分配 /api/admin/shop-package-batch-allocations: post: requestBody: @@ -16607,7 +16178,7 @@ paths: summary: 批量调价 tags: - 批量套餐调价 - /api/admin/shop-series-allocations: + /api/admin/shop-series-grants: get: parameters: - description: 页码 @@ -16625,35 +16196,35 @@ paths: maximum: 100 minimum: 1 type: integer - - description: 被分配的店铺ID + - description: 过滤被授权店铺ID in: query name: shop_id schema: - description: 被分配的店铺ID + description: 过滤被授权店铺ID minimum: 0 nullable: true type: integer - - description: 套餐系列ID + - description: 过滤套餐系列ID in: query name: series_id schema: - description: 套餐系列ID + description: 过滤套餐系列ID minimum: 0 nullable: true type: integer - - description: 分配者店铺ID + - description: 过滤分配者店铺ID in: query name: allocator_shop_id schema: - description: 分配者店铺ID + description: 过滤分配者店铺ID minimum: 0 nullable: true type: integer - - description: 状态 (1:启用, 2:禁用) + - description: 过滤状态 1-启用 2-禁用 in: query name: status schema: - description: 状态 (1:启用, 2:禁用) + description: 过滤状态 1-启用 2-禁用 nullable: true type: integer responses: @@ -16667,7 +16238,7 @@ paths: example: 0 type: integer data: - $ref: '#/components/schemas/DtoShopSeriesAllocationPageResult' + $ref: '#/components/schemas/DtoShopSeriesGrantPageResult' msg: description: 响应消息 example: success @@ -16709,15 +16280,15 @@ paths: description: 服务器内部错误 security: - BearerAuth: [] - summary: 系列分配列表 + summary: 查询代理系列授权列表 tags: - - 套餐分配 + - 代理系列授权 post: requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoCreateShopSeriesAllocationRequest' + $ref: '#/components/schemas/DtoCreateShopSeriesGrantRequest' responses: "200": content: @@ -16729,7 +16300,7 @@ paths: example: 0 type: integer data: - $ref: '#/components/schemas/DtoShopSeriesAllocationResponse' + $ref: '#/components/schemas/DtoShopSeriesGrantResponse' msg: description: 响应消息 example: success @@ -16771,10 +16342,10 @@ paths: description: 服务器内部错误 security: - BearerAuth: [] - summary: 创建系列分配 + summary: 创建代理系列授权 tags: - - 套餐分配 - /api/admin/shop-series-allocations/{id}: + - 代理系列授权 + /api/admin/shop-series-grants/{id}: delete: parameters: - description: ID @@ -16812,9 +16383,9 @@ paths: description: 服务器内部错误 security: - BearerAuth: [] - summary: 删除系列分配 + summary: 删除代理系列授权 tags: - - 套餐分配 + - 代理系列授权 get: parameters: - description: ID @@ -16836,7 +16407,7 @@ paths: example: 0 type: integer data: - $ref: '#/components/schemas/DtoShopSeriesAllocationResponse' + $ref: '#/components/schemas/DtoShopSeriesGrantResponse' msg: description: 响应消息 example: success @@ -16878,9 +16449,9 @@ paths: description: 服务器内部错误 security: - BearerAuth: [] - summary: 获取系列分配详情 + summary: 查询代理系列授权详情 tags: - - 套餐分配 + - 代理系列授权 put: parameters: - description: ID @@ -16895,7 +16466,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/DtoUpdateShopSeriesAllocationParams' + $ref: '#/components/schemas/DtoUpdateShopSeriesGrantParams' responses: "200": content: @@ -16907,7 +16478,7 @@ paths: example: 0 type: integer data: - $ref: '#/components/schemas/DtoShopSeriesAllocationResponse' + $ref: '#/components/schemas/DtoShopSeriesGrantResponse' msg: description: 响应消息 example: success @@ -16949,9 +16520,81 @@ paths: description: 服务器内部错误 security: - BearerAuth: [] - summary: 更新系列分配 + summary: 更新代理系列授权 tags: - - 套餐分配 + - 代理系列授权 + /api/admin/shop-series-grants/{id}/packages: + put: + parameters: + - description: ID + in: path + name: id + required: true + schema: + description: ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoManageGrantPackagesParams' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoShopSeriesGrantResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 管理授权套餐(新增/更新/删除) + tags: + - 代理系列授权 /api/admin/shops: get: parameters: @@ -17269,11 +16912,11 @@ paths: description: ICCID(模糊查询) maxLength: 50 type: string - - description: 设备号(模糊查询) + - description: 设备虚拟号(模糊查询) in: query - name: device_no + name: virtual_no schema: - description: 设备号(模糊查询) + description: 设备虚拟号(模糊查询) maxLength: 50 type: string - description: 订单号(模糊查询) @@ -18370,11 +18013,11 @@ paths: schema: description: 每页数量 type: integer - - description: 设备号(模糊搜索) + - description: 虚拟号(模糊搜索) in: query - name: device_no + name: virtual_no schema: - description: 设备号(模糊搜索) + description: 虚拟号(模糊搜索) type: string responses: "200": @@ -18499,166 +18142,6 @@ paths: summary: 获取设备详情(H5) tags: - H5-企业设备 - /api/h5/devices/{device_id}/cards/{card_id}/resume: - post: - parameters: - - description: 设备ID - in: path - name: device_id - required: true - schema: - description: 设备ID - minimum: 0 - type: integer - - description: 卡ID - in: path - name: card_id - required: true - schema: - description: 卡ID - minimum: 0 - type: integer - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoDeviceCardOperationReq' - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoDeviceCardOperationResp' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 复机卡(H5) - tags: - - H5-企业设备 - /api/h5/devices/{device_id}/cards/{card_id}/suspend: - post: - parameters: - - description: 设备ID - in: path - name: device_id - required: true - schema: - description: 设备ID - minimum: 0 - type: integer - - description: 卡ID - in: path - name: card_id - required: true - schema: - description: 卡ID - minimum: 0 - type: integer - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoDeviceCardOperationReq' - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoDeviceCardOperationResp' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 停机卡(H5) - tags: - - H5-企业设备 /api/h5/orders: get: parameters: @@ -18721,6 +18204,13 @@ paths: format: date-time nullable: true type: string + - description: 是否已过期 (true:已过期, false:未过期) + in: query + name: is_expired + schema: + description: 是否已过期 (true:已过期, false:未过期) + nullable: true + type: boolean responses: "200": content: diff --git a/docs/asset-detail-refactor-api-changes.md b/docs/asset-detail-refactor-api-changes.md new file mode 100644 index 0000000..04c848d --- /dev/null +++ b/docs/asset-detail-refactor-api-changes.md @@ -0,0 +1,253 @@ +# 资产详情重构 API 变更说明 + +> 适用版本:asset-detail-refactor 提案上线后 +> 文档更新:2026-03-14 + +--- + +## 一、现有接口字段变更 + +### 1. `device_no` 重命名为 `virtual_no` + +所有涉及设备标识符的接口,响应中的 `device_no` 字段已统一改名为 `virtual_no`,**JSON key 同步变更**,前端需全局替换。 + +受影响接口: + +| 接口 | 变更字段 | +|------|---------| +| `GET /api/admin/devices`(列表/详情响应) | `device_no` → `virtual_no` | +| `GET /api/admin/devices/import/tasks/:id` | `failed_items[].device_no` → `virtual_no` | +| `GET /api/admin/enterprises/:id/devices`(企业设备列表) | `device_no` → `virtual_no` | +| `GET /api/admin/shop-commission/records` | `device_no` → `virtual_no` | +| `GET /api/admin/my-commission/records` | `device_no` → `virtual_no` | +| 企业卡授权相关响应中的设备字段 | `device_no` → `virtual_no` | + +--- + +### 2. 套餐接口新增 `virtual_ratio` 字段 + +`GET /api/admin/packages` 及套餐详情响应新增: + +| 新增字段 | 类型 | 说明 | +|---------|------|------| +| `virtual_ratio` | float64 | 虚流量比例(real_data_mb / virtual_data_mb)。启用虚流量时计算,否则为 1.0 | + +--- + +### 3. IoT 卡接口新增 `virtual_no` 字段 + +卡列表/详情响应新增: + +| 新增字段 | 类型 | 说明 | +|---------|------|------| +| `virtual_no` | string | 虚拟号(可空) | + +--- + +## 二、新增接口 + +### 基础说明 + +- 路径参数 `asset_type` 取值:`card`(卡)或 `device`(设备) +- 企业账号调用 `resolve` 接口会返回 403 + +--- + +### `GET /api/admin/assets/resolve/:identifier` + +通过任意标识符查询设备或卡的完整详情。支持虚拟号、ICCID、IMEI、SN、MSISDN。 + +**响应字段:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `asset_type` | string | `card` 或 `device` | +| `asset_id` | uint | 数据库 ID | +| `virtual_no` | string | 虚拟号 | +| `status` | int | 资产状态 | +| `batch_no` | string | 批次号 | +| `shop_id` | uint | 所属店铺 ID | +| `shop_name` | string | 所属店铺名称 | +| `series_id` | uint | 套餐系列 ID | +| `series_name` | string | 套餐系列名称 | +| `real_name_status` | int | 实名状态:0 未实名 / 1 实名中 / 2 已实名 | +| `network_status` | int | 网络状态:0 停机 / 1 开机(仅 card) | +| `current_package` | string | 当前套餐名称(无则空) | +| `package_total_mb` | int64 | 当前套餐总虚流量 MB | +| `package_used_mb` | float64 | 已用虚流量 MB | +| `package_remain_mb` | float64 | 剩余虚流量 MB | +| `device_protect_status` | string | 保护期状态:`none` / `stop` / `start`(仅 device) | +| `activated_at` | time | 激活时间 | +| `created_at` | time | 创建时间 | +| `updated_at` | time | 更新时间 | +| **绑定关系(card 时)** | | | +| `iccid` | string | 卡 ICCID | +| `bound_device_id` | uint | 绑定设备 ID | +| `bound_device_no` | string | 绑定设备虚拟号 | +| `bound_device_name` | string | 绑定设备名称 | +| **绑定关系(device 时)** | | | +| `bound_card_count` | int | 绑定卡数量 | +| `cards[]` | array | 绑定卡列表,每项含:`card_id` / `iccid` / `msisdn` / `network_status` / `real_name_status` / `slot_position` | +| **设备专属字段(card 时为空)** | | | +| `device_name` | string | 设备名称 | +| `imei` | string | IMEI | +| `sn` | string | 序列号 | +| `device_model` | string | 设备型号 | +| `device_type` | string | 设备类型 | +| `max_sim_slots` | int | 最大插槽数 | +| `manufacturer` | string | 制造商 | +| **卡专属字段(device 时为空)** | | | +| `carrier_type` | string | 运营商类型 | +| `carrier_name` | string | 运营商名称 | +| `msisdn` | string | 手机号 | +| `imsi` | string | IMSI | +| `card_category` | string | 卡业务类型 | +| `supplier` | string | 供应商 | +| `activation_status` | int | 激活状态 | +| `enable_polling` | bool | 是否参与轮询 | + +--- + +### `GET /api/admin/assets/:asset_type/:id/realtime-status` + +读取资产实时状态(直接读 DB/Redis,不调网关)。 + +**响应字段:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `asset_type` | string | `card` 或 `device` | +| `asset_id` | uint | 资产 ID | +| `network_status` | int | 网络状态(仅 card) | +| `real_name_status` | int | 实名状态(仅 card) | +| `current_month_usage_mb` | float64 | 本月已用流量 MB(仅 card) | +| `last_sync_time` | time | 最后同步时间(仅 card) | +| `device_protect_status` | string | 保护期:`none` / `stop` / `start`(仅 device) | +| `cards[]` | array | 所有绑定卡的状态(仅 device),同 resolve 的 cards 结构 | + +--- + +### `POST /api/admin/assets/:asset_type/:id/refresh` + +主动调网关拉取最新数据后返回,响应结构与 `realtime-status` 完全相同。 + +> 设备有 **30 秒冷却期**,冷却中调用返回 429。 + +--- + +### `GET /api/admin/assets/:asset_type/:id/packages` + +查询该资产所有套餐记录,含虚流量换算字段。 + +**响应为数组,每项字段:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `package_usage_id` | uint | 套餐使用记录 ID | +| `package_id` | uint | 套餐 ID | +| `package_name` | string | 套餐名称 | +| `package_type` | string | `formal`(正式套餐)/ `addon`(加油包) | +| `status` | int | 0 待生效 / 1 生效中 / 2 已用完 / 3 已过期 / 4 已失效 | +| `status_name` | string | 状态中文名 | +| `data_limit_mb` | int64 | 真流量总量 MB | +| `virtual_limit_mb` | int64 | 虚流量总量 MB(已按 virtual_ratio 换算) | +| `data_usage_mb` | int64 | 已用真流量 MB | +| `virtual_used_mb` | float64 | 已用虚流量 MB | +| `virtual_remain_mb` | float64 | 剩余虚流量 MB | +| `virtual_ratio` | float64 | 虚流量比例 | +| `activated_at` | time | 激活时间 | +| `expires_at` | time | 到期时间 | +| `master_usage_id` | uint | 主套餐 ID(加油包时有值) | +| `priority` | int | 优先级 | +| `created_at` | time | 创建时间 | + +--- + +### `GET /api/admin/assets/:asset_type/:id/current-package` + +查询当前生效中的主套餐,响应结构同 `packages` 数组的单项。无生效套餐时返回 404。 + +--- + +### `POST /api/admin/assets/device/:device_id/stop` + +批量停机设备下所有已实名卡,停机成功后设置 **1 小时停机保护期**(保护期内禁止复机)。 + +**响应字段:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `message` | string | 操作结果描述 | +| `success_count` | int | 成功停机的卡数量 | +| `failed_cards[]` | array | 停机失败列表,每项含 `iccid` 和 `reason` | + +--- + +### `POST /api/admin/assets/device/:device_id/start` + +批量复机设备下所有已实名卡,复机成功后设置 **1 小时复机保护期**(保护期内禁止停机)。 + +无响应 body,HTTP 200 即成功。 + +--- + +### `POST /api/admin/assets/card/:iccid/stop` + +手动停机单张卡(通过 ICCID)。若卡绑定的设备在**复机保护期**内,返回 403。 + +无响应 body,HTTP 200 即成功。 + +--- + +### `POST /api/admin/assets/card/:iccid/start` + +手动复机单张卡(通过 ICCID)。若卡绑定的设备在**停机保护期**内,返回 403。 + +无响应 body,HTTP 200 即成功。 + +--- + +## 三、删除的接口 + +### IoT 卡 + +| 删除的接口 | 替代接口 | +|-----------|---------| +| `GET /api/admin/iot-cards/by-iccid/:iccid` | `GET /api/admin/assets/resolve/:iccid` | +| `GET /api/admin/iot-cards/:iccid/gateway-status` | `GET /api/admin/assets/card/:id/realtime-status` | +| `GET /api/admin/iot-cards/:iccid/gateway-flow` | `GET /api/admin/assets/card/:id/realtime-status` | +| `GET /api/admin/iot-cards/:iccid/gateway-realname` | `GET /api/admin/assets/card/:id/realtime-status` | +| `POST /api/admin/iot-cards/:iccid/stop` | `POST /api/admin/assets/card/:iccid/stop` | +| `POST /api/admin/iot-cards/:iccid/start` | `POST /api/admin/assets/card/:iccid/start` | + +### 设备 + +| 删除的接口 | 替代接口 | +|-----------|---------| +| `GET /api/admin/devices/:id` | `GET /api/admin/assets/resolve/:virtual_no` | +| `GET /api/admin/devices/by-identifier/:identifier` | `GET /api/admin/assets/resolve/:identifier` | +| `GET /api/admin/devices/by-identifier/:identifier/gateway-info` | `GET /api/admin/assets/device/:id/realtime-status` | + +### 企业卡(Admin) + +| 删除的接口 | 替代接口 | +|-----------|---------| +| `POST /api/admin/enterprises/:id/cards/:card_id/suspend` | `POST /api/admin/assets/card/:iccid/stop` | +| `POST /api/admin/enterprises/:id/cards/:card_id/resume` | `POST /api/admin/assets/card/:iccid/start` | + +### 企业设备(H5) + +| 删除的接口 | 替代接口 | +|-----------|---------| +| `POST /api/h5/enterprise/devices/:device_id/suspend-card` | `POST /api/admin/assets/device/:device_id/stop` | +| `POST /api/h5/enterprise/devices/:device_id/resume-card` | `POST /api/admin/assets/device/:device_id/start` | + +--- + +## 四、新增错误码说明 + +| HTTP 状态码 | 触发场景 | +|------------|---------| +| 403 | 设备在保护期内(停机 1h 内禁止复机,反之亦然);企业账号调用 resolve 接口 | +| 404 | 标识符未匹配到任何资产;当前无生效套餐 | +| 429 | 设备刷新冷却中(30 秒内只能主动刷新一次) | diff --git a/docs/discussion/资产详情重构讨论纪要.md b/docs/discussion/资产详情重构讨论纪要.md new file mode 100644 index 0000000..49502cb --- /dev/null +++ b/docs/discussion/资产详情重构讨论纪要.md @@ -0,0 +1,821 @@ +# 君鸿卡管系统资产详情体系重构 - 讨论纪要 + +> 创建时间:2026-03-12 +> 最后更新:2026-03-14 +> 当前阶段:设计讨论(尚未进入 openspec 提案) +> 目的:保留完整上下文,供未来继续 + +--- + +## 一、背景与需求来源 + +### 1.1 项目背景 + +君鸿卡管系统(junhong_cmp_fiber)是一个面向代理/企业的物联网卡管理平台,核心资产有两类: +- **IoT 卡(IotCard)**:纯卡资源,含 ICCID、MSISDN、流量套餐 +- **设备(Device)**:带卡的硬件设备,一个设备可绑定多张卡,设备级套餐 + +### 1.2 需求触发点 + +核心痛点: +1. **接口分散且重复** - 卡和设备的查询散落在多处,H5/Admin/Personal 三端各有一套 +2. **详情信息严重缺失** - 现有的详情接口返回数据太少,前端无法据此渲染完整页面 +3. **网关裸数据透传** - 封装程度不够,没有业务层的聚合和处理 +4. **虚拟号只存在于设备** - 卡的查询只能靠 ICCID/MSISDN,不方便 + +### 1.3 已确认的核心决策 + +- ✅ **多接口组合** - 不做单一聚合大接口,前端按需调用 +- ✅ **统一入口** - 一个接口告诉前端查的是"卡"还是"设备" +- ✅ **设备优先查找** - 统一入口先查设备表,再查卡表 +- ✅ **卡加虚拟号** - 虚拟号概念延伸到卡,与设备的 virtual_no 对等 +- ✅ **全部一步到位** - 改造不分期,一次性完成 +- ✅ **resolve 返回中等版本** - 包含资产类型、ID、虚拟号、状态、实名状态、套餐概况、流量使用、所属设备(如果绑定)等关键信息 +- ✅ **资产类型只有卡和设备两种** - 未来路由器也归属设备,无需预留更多类型 +- ✅ **虚拟号客服和客户都要用** - 不是只有内部人员用 +- ✅ **H5 端接口暂时不需要提供** - 后续做到时再删除旧接口 +- ✅ **套餐查询看历史记录** - 通过套餐记录/订单记录页面查看历史,同时提供当前套餐接口 +- ✅ **手动刷新接口复用 SyncCardStatusFromGateway** - 无需重新实现,设备时批量刷新所有绑定卡 +- ✅ **权限不足返回 403** - 明确告知无权限,不假装资产不存在 +- ✅ **虚拟号人工填写/批量导入** - 无格式规范,允许修改,重复时全批失败并告知原因 +- ✅ **device_no 字段全量改名为 virtual_no** - 数据库+代码全部更新,不保留旧字段 +- ✅ **设备停复机有保护期机制** - 保护状态一致性,时长 1 小时,存储在 Redis +- ✅ **realtime-status 只查持久化数据** - 不调用网关,刷新用 refresh 接口 +- ✅ **未实名的卡不参与停复机** - 未实名卡永远是停机状态,保护期逻辑跳过 +- ✅ **企业账号 resolve 接口** - 企业账号暂不支持 resolve,未来单独开新接口 +- ✅ **resolve 响应含卡 ICCID** - card 类型时在响应中返回 ICCID,供前端调用停复机接口 +- ✅ **批量停机部分失败仍设保护期** - 部分卡停机失败时也设置 Redis 保护期,已停机的卡不回滚,失败的卡记录日志 +- ✅ **流量汇总逻辑统一** - 整个系统使用统一的流量汇总逻辑;设备级套餐从 PackageUsage 汇总多卡用量 +- ✅ **套餐历史列表规则** - 按创建时间倒序,不分页,包含所有状态(含已失效) +- ✅ **current-package 返回主套餐** - 多套餐同时生效时只返回主套餐(master_usage_id IS NULL) +- ✅ **轮询系统新增第四种任务** - 保护期一致性检查封装为独立轮询任务类型,不修改现有三种任务 +- ✅ **卡虚拟号导入只补空白** - 只允许为现有空白虚拟号的卡填入,不支持覆盖更新;与数据库现存数据重复则全批失败 +- ✅ **设备批量刷新需限频** - Redis 限频保护,同一设备冷却期内(建议 30 秒)不允许重复触发 +- ✅ **PersonalCustomerDevice 统一改名** - tb_personal_customer_device 表的 device_no 字段一并改为 virtual_no +- ✅ **realtime-status 与 resolve 分工明确** - resolve 用于初始加载(含查找),realtime-status 用于已知 ID 的轻量状态轮询(不含套餐流量计算) + +--- + +## 二、现有系统审计结果 + +### 2.1 接口现状(三端盘点) + +| 端 | 卡接口数 | 设备接口数 | 重复停复机 | 套餐接口 | +|---|---------|-----------|-----------|---------| +| Admin | 9 | 14 | 3处 | 仅流量详单 | +| H5 | 4 | 7 | 1处 | 有套餐聚合 | +| Personal | 2 | 0 | 无 | 无 | + +**重复停复机的三处实现:** +1. Admin 卡端:`POST /iot-cards/:iccid/suspend|resume`(按 ICCID) +2. Admin 企业卡端:`POST /enterprises/:id/cards/:card_id/suspend|resume`(按 card_id) +3. H5 企业设备端:`POST /h5/devices/:device_id/cards/:card_id/suspend|resume`(按 card_id) + +### 2.2 DTO 缺失分析 + +#### 卡详情(IotCardDetailResponse) + +```go +// 当前实现 (iot_card_dto.go:134-136) +type IotCardDetailResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *StandaloneIotCardResponse `json:"data"` // 只是空壳嵌套! +} +``` + +**问题**:详情响应只是列表响应的空包装,完全没有额外信息。无套餐、无所属设备、无聚合流量。 + +#### 设备详情(DeviceResponse) + +```go +// 当前实现 (device_dto.go:20) +type DeviceResponse struct { + // ... 基本字段 + BoundCardCount int `json:"bound_card_count"` // 只有一个数字! +} +``` + +**问题**:只返回绑定卡数量,看不到每张卡的实名状态、卡状态、流量使用。 + +#### H5 端已有参考实现 + +`EnterpriseDeviceDetailResp`(enterprise_device_authorization_dto.go)是目前唯一有"设备+绑定卡列表"聚合的 DTO,可作为 admin 端改造的参考。 + +### 2.3 网关接口问题 + +**6 个网关查询接口全部是纯透传**: +- `gateway.GetCardStatus` +- `gateway.GetFlowUsage` +- `gateway.GetRealNameStatus` +- `gateway.GetDeviceInfo` +- `gateway.GetSlotInfo` +- `gateway.GetDeviceFlowUsage` + +**问题**:只读不写,不更新 DB 缓存,无业务封装。 + +### 2.4 数据模型现状 + +| 模型 | 虚拟号 | 缓存字段 | 套餐载体 | +|-----|-------|---------|---------| +| IotCard | ❌ 无(需新增) | CurrentMonthUsageMB, NetworkStatus, RealNameStatus, LastDataCheckAt | IotCardID | +| Device | ✅ device_no(需改名为 virtual_no) | 无 | DeviceID | + +**关键发现**: +- `PackageUsage` 模型已支持两种载体:`IotCardID`(单卡)和 `DeviceID`(设备级) +- `IotCard.IsStandalone` 字段由触发器维护,标识卡是否绑定到设备 +- `DeviceStore.GetByIdentifier` 已实现多字段匹配:`WHERE device_no = ? OR imei = ? OR sn = ?`(改造后改为 virtual_no) + +--- + +## 三、设计方向(已确认) + +### 3.1 统一资产入口(resolve) + +**接口**:`GET /api/admin/assets/resolve/:identifier` + +**查找逻辑**: +``` +1. 先查 device 表(virtual_no / imei / sn) +2. 未命中则查 iot_card 表(virtual_no / iccid / msisdn) +3. 应用数据权限过滤:代理只能看自己及下级店铺的资产,平台账号看所有 +4. 有权限 → 返回资产数据(中等版本) +5. 无权限 → 返回 HTTP 403 +6. 未找到 → 返回 HTTP 404 +``` + +**响应结构(已确认)**: + +```go +// AssetResolveResponse 资产解析响应 +type AssetResolveResponse struct { + // 基础信息 + AssetType string `json:"asset_type"` // "device" 或 "card" + AssetID uint `json:"asset_id"` // 对应表的主键 + VirtualNo string `json:"virtual_no"` // 统一虚拟号字段(设备/卡均用此字段) + ICCID string `json:"iccid,omitempty"` // 仅 card 类型时有值,供前端调用停复机接口使用 + + // 状态信息 + Status int `json:"status"` // 资产状态 + RealNameStatus int `json:"real_name_status"` // 实名状态 + + // 套餐和流量信息(无套餐时返回空字符串/0) + CurrentPackage string `json:"current_package"` // 当前套餐名称 + PackageTotalMB float64 `json:"package_total_mb"` // 真总流量(套餐标称,RealDataMB) + PackageVirtualMB float64 `json:"package_virtual_mb"` // 虚总流量(停机阈值,VirtualDataMB) + PackageUsedMB float64 `json:"package_used_mb"` // 客户端展示已使用流量(经虚流量换算) + PackageRemainMB float64 `json:"package_remain_mb"` // 客户端展示剩余流量 + + // 保护期状态(设备类型,以及绑定该设备的卡均返回) + DeviceProtectStatus string `json:"device_protect_status"` // "none" / "stop" / "start" + + // 绑定信息(仅 card 类型,且卡绑定了设备时才有值) + BoundDeviceID *uint `json:"bound_device_id,omitempty"` + BoundDeviceNo string `json:"bound_device_no,omitempty"` + BoundDeviceName string `json:"bound_device_name,omitempty"` + + // 设备类型特有:绑定卡信息 + BoundCardCount int `json:"bound_card_count"` + Cards []DeviceCardInfo `json:"cards,omitempty"` // 包含所有状态的卡(含未实名) +} + +// DeviceCardInfo 设备下绑定卡信息 +type DeviceCardInfo struct { + IotCardID uint `json:"iot_card_id"` + ICCID string `json:"iccid"` + VirtualNo string `json:"virtual_no"` + RealNameStatus int `json:"real_name_status"` + NetworkStatus int `json:"network_status"` + CurrentMonthUsageMB float64 `json:"current_month_usage_mb"` + LastSyncAt *time.Time `json:"last_sync_at"` // 最后与 Gateway 同步时间 +} +``` + +**说明**: +- 卡绑定的设备被软删除时,该卡视为独立卡,不填充绑定信息 +- 设备下的 `cards` 列表包含所有绑定卡(含未实名、已停用) + +### 3.2 套餐查询接口 + +**接口一**:`GET /api/admin/assets/:asset_type/:id/packages` +- 返回所有套餐记录(含历史和当前生效套餐) +- 按 asset_type 区分查 PackageUsage.IotCardID 还是 PackageUsage.DeviceID +- 每条记录包含:套餐名称、真总流量、虚总流量、展示已使用、展示剩余、有效期、状态 +- **排序**:按创建时间倒序(最新套餐在前) +- **分页**:不分页,全量返回 +- **范围**:包含所有状态(含 status=4 已失效的历史套餐) + +**接口二**:`GET /api/admin/assets/:asset_type/:id/current-package` +- 返回当前生效的**主套餐**(status=1 且 master_usage_id IS NULL)的详细信息 +- 当同时有主套餐 + 加油包生效时,只返回主套餐;需要查看加油包通过接口一的列表查看 +- 包含完整流量明细:真总量、虚总量、展示已使用、展示剩余 + +### 3.3 实时状态查询接口 + +**接口**:`GET /api/admin/assets/:asset_type/:id/realtime-status` + +**与 resolve 的定位分工**: + +> **resolve**:初始加载使用,包含查找逻辑 + 全量聚合数据(套餐/流量/绑定信息),数据较重。 +> **realtime-status**:已知资产 ID 后的轻量状态轮询,**不含套餐流量计算**,专注于网络/实名/保护期状态的快速刷新。 + +**说明**: +- **只查询持久化数据(DB/Redis),不调用网关** +- 返回最近一次轮询/刷新同步到系统的状态 +- "实时性"依赖轮询系统保持数据新鲜(实名 5 分钟,流量/套餐 10 分钟) +- 需要最新数据时,先调用 refresh 接口手动刷新,再查此接口 +- 设备类型返回:保护期状态 + 每张绑定卡的状态(网络/实名/流量/最后同步时间) +- 卡类型返回:网络状态 + 实名状态 + 流量使用 + 最后同步时间 + +### 3.4 手动刷新接口 + +**接口**:`POST /api/admin/assets/:asset_type/:id/refresh` + +**说明**: +- 调用网关获取最新数据,写回 DB 更新缓存字段,返回刷新后的最新状态 +- 卡类型:调用已有的 `SyncCardStatusFromGateway(iccid)` 方法 +- 设备类型:批量刷新所有绑定卡(遍历调用 `SyncCardStatusFromGateway`) +- **设备类型需要频率限制**:通过 Redis 记录最后刷新时间,同一设备冷却期内(建议 30 秒)不允许重复触发,防止前端多次快速点击打爆网关 + +### 3.5 设备停复机保护期机制 + +**背景**: +设备本身没有停机/复机概念,对设备停机 = 批量停用其下所有已实名卡。保护期机制确保操作期间所有卡的状态一致性,防止单卡被误操作破坏整体状态。 + +**接口**: +- `POST /api/admin/assets/device/:device_id/stop` +- `POST /api/admin/assets/device/:device_id/start` + +**保护期规则**: + +| 规则 | 说明 | +|------|------| +| 保护期时长 | **1 小时**(硬编码在代码常量中) | +| 存储方式 | Redis Key `protect:device:{device_id}:stop` 或 `protect:device:{device_id}:start`,TTL=1小时 | +| 未实名的卡 | **不参与停复机操作**,未实名卡永远是停机状态,跳过不处理 | +| 重叠操作 | 设备在保护期内不允许再次发起相同或相反的停复机操作,返回 HTTP 403 | +| 批量停机部分失败 | 部分卡调网关失败时,**仍设置 Redis 保护期**;已成功停机的卡不回滚;失败的卡记录错误日志 | + +**stop 保护期(设备停机后 1 小时内)**: +- 对某张已实名卡手动发起复机 → **不允许**(HTTP 403,设备处于停机保护期) +- 对某张已实名卡手动发起停机 → 允许(本已是停机,无冲突) +- 轮询系统发现某张已实名卡处于开机状态 → **强制调网关停机**,保持一致 + +**start 保护期(设备复机后 1 小时内)**: +- 对某张已实名卡手动发起停机 → **允许**(用户可主动停单张卡) +- 对某张已实名卡手动发起复机 → 允许(本已是复机,无冲突) +- 轮询系统发现某张已实名卡处于停机状态 → **强制调网关复机**,保持一致 + +**保护期状态对外暴露**: +- resolve 接口的 `device_protect_status` 字段返回当前保护期状态 +- 卡绑定的设备有保护期时,该卡的 resolve 结果也返回 `device_protect_status` + +### 3.6 接口去重(废弃清单) + +**废弃接口**(直接删除,不保留向后兼容): + +| 废弃接口 | 替代接口 | +|---------|---------| +| `POST /enterprises/:id/cards/:card_id/suspend` | `POST /api/admin/assets/card/:iccid/stop` | +| `POST /enterprises/:id/cards/:card_id/resume` | `POST /api/admin/assets/card/:iccid/start` | +| `POST /h5/devices/:device_id/cards/:card_id/suspend` | `POST /api/admin/assets/device/:device_id/stop` | +| `POST /h5/devices/:device_id/cards/:card_id/resume` | `POST /api/admin/assets/device/:device_id/start` | +| 旧 Admin 卡停复机接口(按 ICCID) | `POST /api/admin/assets/card/:iccid/stop|start` | +| `GET /devices/:id` | `GET /api/admin/assets/device/:id` | + +### 3.7 数据层变更 + +**变更一:设备表字段改名(全量重构)** +```sql +ALTER TABLE tb_device RENAME COLUMN device_no TO virtual_no; +ALTER TABLE tb_personal_customer_device RENAME COLUMN device_no TO virtual_no; +``` +涉及改动范围:Model 定义、DTO 响应、Store 查询、所有引用 `device_no` 的代码,以及 `tb_personal_customer_device` 表的 `device_no` 字段(一并改名为 `virtual_no`),确保系统中不再有 `device_no` 的身影。 + +**变更二:卡表新增 virtual_no 字段** +```sql +ALTER TABLE tb_iot_card ADD COLUMN virtual_no VARCHAR(50); +CREATE UNIQUE INDEX idx_iot_card_virtual_no + ON tb_iot_card (virtual_no) WHERE deleted_at IS NULL; +``` +- 允许为空(老数据无虚拟号) +- 允许手动修改 +- 全局唯一(导入时检测重复,重复则全批失败并告知具体冲突数据) + +**变更三:套餐表新增 virtual_ratio 字段** +```sql +ALTER TABLE tb_package ADD COLUMN virtual_ratio DECIMAL(10,6) DEFAULT 1.0; +``` +- 创建套餐时计算并存储:`virtual_ratio = real_data_mb / virtual_data_mb` +- 用于客户端展示的流量换算(见第六节) +- 未启用虚流量时(`enable_virtual_data=false`),virtual_ratio = 1.0 + +--- + +## 四、完整接口清单 + +| # | 方法 | 路径 | 说明 | +|---|------|------|------| +| 1 | GET | `/api/admin/assets/resolve/:identifier` | 资产解析(通过任意标识符) | +| 1 | GET | `/api/admin/assets/resolve/:identifier` | 资产解析(通过任意标识符) | +| 2 | GET | `/api/admin/assets/:asset_type/:id/packages` | 套餐记录(历史+当前) | +| 3 | GET | `/api/admin/assets/:asset_type/:id/current-package` | 当前生效主套餐详情 | +| 4 | GET | `/api/admin/assets/:asset_type/:id/realtime-status` | 当前持久化状态查询(轻量) | +| 5 | POST | `/api/admin/assets/:asset_type/:id/refresh` | 手动刷新(调网关写回 DB) | +| 6 | POST | `/api/admin/assets/device/:device_id/stop` | 设备停机(批量停所有已实名卡) | +| 7 | POST | `/api/admin/assets/device/:device_id/start` | 设备复机(批量开所有已实名卡) | +| 8 | POST | `/api/admin/assets/card/:iccid/stop` | 卡停机 | +| 9 | POST | `/api/admin/assets/card/:iccid/start` | 卡复机 | + +> `:asset_type` 取值:`device` 或 `card` + +--- + +## 五、流程图 + +### 5.1 资产查找(resolve)流程 + +```mermaid +flowchart TD + A["GET /api/admin/assets/resolve/:identifier"] --> B{"查询设备表\nvirtual_no / imei / sn"} + B -->|找到| C{"应用数据权限过滤\n代理:仅自己及下级店铺\n平台:所有资产"} + B -->|未找到| D{"查询卡表\nvirtual_no / iccid / msisdn"} + D -->|找到| C + D -->|未找到| E["返回 HTTP 404\n资产不存在"] + C -->|有权限| F["聚合资产数据\n基础信息 + 状态 + 套餐流量 + 保护期 + 绑定信息"] + C -->|无权限| G["返回 HTTP 403\n无权限查看该资产"] + F --> H["返回 AssetResolveResponse"] +``` + +### 5.2 设备停机/复机流程 + +```mermaid +flowchart TD + subgraph 设备停机 + A1["POST /assets/device/:id/stop"] --> B1{"设备是否存在?"} + B1 -->|否| C1["HTTP 404"] + B1 -->|是| D1{"设备是否在保护期?"} + D1 -->|是| E1["HTTP 403\n设备处于保护期,不允许操作"] + D1 -->|否| F1["获取所有已实名下属卡"] + F1 --> G1["批量调网关停机"] + G1 --> H1["更新各卡 NetworkStatus=停机\n(部分失败时已成功的卡不回滚)"] + H1 --> I1["Redis SET protect:device:id:stop\nTTL = 1 小时(部分失败时仍设置)"] + I1 --> J1["返回成功(附带失败卡日志)"] + end + + subgraph 设备复机 + A2["POST /assets/device/:id/start"] --> B2{"设备是否存在?"} + B2 -->|否| C2["HTTP 404"] + B2 -->|是| D2{"设备是否在保护期?"} + D2 -->|是| E2["HTTP 403\n设备处于保护期,不允许操作"] + D2 -->|否| F2["获取所有已实名下属卡"] + F2 --> G2["批量调网关复机"] + G2 --> H2["更新各卡 NetworkStatus=开机"] + H2 --> I2["Redis SET protect:device:id:start\nTTL = 1 小时"] + I2 --> J2["返回成功"] + end +``` + +### 5.3 手动操作单卡 + 保护期检查 + +```mermaid +flowchart TD + subgraph 手动停机单卡 + A1["POST /assets/card/:iccid/stop"] --> B1{"卡是否存在?"} + B1 -->|否| C1["HTTP 404"] + B1 -->|是| D1{"卡是否已实名?"} + D1 -->|未实名| E1["HTTP 403\n未实名卡不允许停复机"] + D1 -->|已实名| F1{"卡是否绑定设备?"} + F1 -->|未绑定| G1["正常执行停机"] + F1 -->|已绑定| H1{"设备有 start 保护期?"} + H1 -->|是| I1["允许停机\n与 start 保护期方向一致"] + H1 -->|否| G1 + end + + subgraph 手动复机单卡 + A2["POST /assets/card/:iccid/start"] --> B2{"卡是否存在?"} + B2 -->|否| C2["HTTP 404"] + B2 -->|是| D2{"卡是否已实名?"} + D2 -->|未实名| E2["HTTP 403\n未实名卡不允许停复机"] + D2 -->|已实名| F2{"卡是否绑定设备?"} + F2 -->|未绑定| G2["正常执行复机"] + F2 -->|已绑定| H2{"设备有 stop 保护期?"} + H2 -->|是| I2["HTTP 403\n设备处于停机保护期\n不允许手动复机"] + H2 -->|否| G2 + end +``` + +### 5.4 轮询系统与保护期交互 + +```mermaid +flowchart TD + A["轮询任务触发:检查卡状态"] --> B{"卡是否已实名?"} + B -->|未实名| C["跳过,未实名卡不参与停复机逻辑"] + B -->|已实名| D{"卡是否绑定设备?"} + D -->|未绑定| E["按卡自身逻辑正常处理"] + D -->|已绑定| F{"设备是否有保护期?"} + F -->|无保护期| E + F -->|"stop 保护期"| G{"卡当前网络状态?"} + G -->|开机| H["强制调网关停机\n保持与设备保护期一致"] + G -->|停机| I["已一致,跳过"] + F -->|"start 保护期"| J{"卡当前网络状态?"} + J -->|停机| K["强制调网关复机\n保持与设备保护期一致"] + J -->|开机| L["已一致,跳过"] +``` + +### 5.5 手动刷新(refresh)流程 + +```mermaid +flowchart TD + A["POST /api/admin/assets/:type/:id/refresh"] --> B{"资产类型"} + B -->|card| C["调用 SyncCardStatusFromGateway(iccid)"] + C --> D["更新 iot_card 表\nNetworkStatus / RealNameStatus\nCurrentMonthUsageMB / LastSyncTime"] + D --> H["返回刷新后的最新状态"] + B -->|device| E["检查 Redis 限频(冷却期 30 秒)"] + E -->|冷却中| Z["HTTP 429 请勿频繁刷新"] + E -->|可刷新| F["查询所有绑定卡列表"] + F --> G["遍历每张卡\n调用 SyncCardStatusFromGateway"] + G --> H +``` + +### 5.6 实时状态查询(realtime-status)流程 + +```mermaid +flowchart TD + A["GET /api/admin/assets/:type/:id/realtime-status"] --> B{"资产类型"} + B -->|card| C["从 DB/Redis 读取持久化的卡状态"] + C --> D["返回卡状态\n网络状态 / 实名状态 / 本月已用流量\n最后同步时间"] + B -->|device| E["从 DB/Redis 读取持久化的设备数据"] + E --> F["读取所有绑定卡的持久化状态"] + F --> G["返回设备状态\n保护期状态 + 各绑定卡当前状态 + 最后同步时间"] +``` + +> **注意**:此接口**不调用网关**,展示的是最近一次轮询/刷新写入的持久化数据。 +> 如需获取最新数据,请先调用 `POST /refresh` 接口,再查询此接口。 + +### 5.7 虚流量计算规则 + +```mermaid +flowchart TD + subgraph 创建["套餐创建时 - 存储比例"] + A1["RealDataMB = 10G 真总流量"] --> C1 + A2["VirtualDataMB = 9G 虚总流量/停机阈值"] --> C1 + C1["virtual_ratio = RealDataMB / VirtualDataMB\n= 10 / 9 ≈ 1.111\n存储到 tb_package.virtual_ratio"] + end + + subgraph 停机["系统内部 - 停机判断"] + D1["真已使用\nCurrentMonthUsageMB"] --> E1{"真已使用 >= VirtualDataMB?"} + D2["VirtualDataMB = 9G"] --> E1 + E1 -->|是| F1["触发停机"] + E1 -->|否| F2["正常运行"] + end + + subgraph 展示["客户端展示 - 流量换算"] + G1["真已使用 = 9G"] --> H1 + H1["展示已使用 = 真已使用 x virtual_ratio\n= 9G x 1.111 = 10G"] + G2["展示总量 = RealDataMB = 10G"] + H1 --> I1["客户看到 已用10G/共10G = 100% 已停机"] + end +``` + +--- + +## 六、虚流量计算规则详解 + +### 6.1 概念说明 + +| 字段 | 含义 | 来源 | +|------|------|------| +| 真总流量(RealDataMB) | 套餐标称总流量,用户购买的名义流量 | `Package.real_data_mb` | +| 虚总流量(VirtualDataMB) | 停机阈值,始终小于真总流量 | `Package.virtual_data_mb` | +| virtual_ratio | 换算比例 = RealDataMB / VirtualDataMB | `Package.virtual_ratio`(套餐创建时存储) | +| 真已使用 | 网关报告的实际用量 | `IotCard.current_month_usage_mb` | +| 展示已使用 | 客户看到的用量 = 真已使用 × virtual_ratio | 计算得出 | +| 展示剩余 | 客户看到的剩余 = 真总流量 − 展示已使用 | 计算得出 | + +### 6.2 设计意图 + +虚总流量(VirtualDataMB)是系统内部的停机保护阈值。由于网关数据同步存在延迟,若以真总流量作为停机阈值,客户可能在用完 10G 后继续用到 10.5G 才被停机,产生超用。因此系统设置一个比真总流量略小的虚总流量(如 9G)作为实际停机阈值,保证不超用。 + +客户端展示时,系统将真实用量按比例换算回真总流量的尺度,使客户的体感与购买的套餐一致: +- 当真用量达到 9G(VirtualDataMB)时,卡被停机 +- 此时展示用量 = 9G × (10G/9G) = 10G,客户看到"已用 10G / 共 10G = 100%" + +### 6.3 计算示例 + +| 场景 | 真总 | 虚总(停机阈值) | 真已使用 | 展示已使用 | 展示剩余 | 是否停机 | +|------|------|----------------|---------|-----------|---------|---------| +| 刚开始 | 10G | 9G | 0G | 0G | 10G | 否 | +| 用了一半 | 10G | 9G | 4.5G | 5G | 5G | 否 | +| 接近阈值 | 10G | 9G | 8G | ≈8.89G | ≈1.11G | 否 | +| 触发停机 | 10G | 9G | 9G | 10G | 0G | **是** | + +### 6.4 未启用虚流量时 + +当 `Package.enable_virtual_data = false` 时: +- `virtual_ratio = 1.0` +- 停机阈值 = 真总流量(RealDataMB) +- 展示已使用 = 真已使用(无换算) + +--- + +## 七、用户的思考与担忧(已全部解决) + +### 7.1 关于接口粒度 + +**已确认**:resolve 返回中等版本,多接口组合,前端按需调用。 + +### 7.2 关于网关封装程度 + +**已确认**: +- realtime-status:只查持久化数据,不调用网关 +- refresh:调用网关并写回 DB,更新缓存字段 + +### 7.3 关于停复机去重 + +**已确认**:所有停复机统一迁移到 assets 路径,旧接口直接删除。 + +### 7.4 关于虚拟号 + +**已确认**: +- 卡的虚拟号给客服和客户用 +- 人工填写/批量导入,无格式规范,允许修改 +- 设备 device_no 全量重命名为 virtual_no +- 导入重复时全批失败,告知具体冲突数据 + +### 7.5 关于套餐查询 + +**已确认**:套餐查询分两个接口,历史套餐接口包含当前套餐,同时单独提供当前套餐接口。 + +### 7.6 关于停复机保护期 + +**已确认**:保护期 1 小时,Redis 存储,未实名卡不参与,stop 保护期内禁止手动复机,start 保护期内允许手动停机。 + +--- + +## 八、设计决策确认清单 + +| 序号 | 问题 | 确认结果 | +|-----|------|---------| +| 1 | resolve 返回数据范围 | 中等版本,含状态/套餐/流量/绑定信息/保护期 | +| 2 | realtime-status 和 refresh 区别 | realtime-status=查持久化数据(轻量),refresh=调网关写回DB | +| 3 | 实时状态封装 | 持久化数据展示,不调网关 | +| 4 | 手动刷新复用 SyncCardStatusFromGateway | 是,设备时批量刷新所有绑定卡 | +| 5 | 停复机统一 | 统一迁移到 /assets 路径,旧接口直接删除 | +| 6 | 卡虚拟号生成方式 | 人工填写/批量导入,无格式规范 | +| 7 | 废弃接口处理 | 直接删除 | +| 8 | 套餐查询接口 | 两个接口:历史套餐列表 + 当前套餐详情 | +| 9 | 权限不足的返回 | HTTP 403,明确告知无权限 | +| 10 | 保护期时长 | 1 小时,硬编码常量 | +| 11 | 虚流量计算 | virtual_ratio=RealDataMB/VirtualDataMB,套餐创建时存储 | +| 12 | device_no 改名 | 全量改为 virtual_no,数据库+代码全部更新 | +| 13 | 设备下卡列表 | 包含所有状态的卡(含未实名、已停用) | +| 14 | 卡绑定设备被软删除时 | 视为独立卡,不填充绑定信息 | +| 15 | 未实名卡参与停复机 | 不参与,永远是停机状态,保护期跳过 | +| 16 | 数据权限规则 | 代理:仅自己及下级店铺,平台账号:所有资产 | +| 17 | 查找失败 404 还是 403 | 资产不存在=404,有资产但无权限=403 | +| 18 | 设备卡列表排序 | 无要求 | +| 19 | resolve 中 current_package 无套餐时 | 返回空字符串/0 | +| 20 | 虚拟号唯一索引 | 需要,允许为空,允许手动修改 | +| 21 | 企业账号能否用 resolve | 暂不支持;企业账号未来开新接口 | +| 22 | 接口 #2(按主键查详情)的设计 | 已确认删除,与 resolve 功能重叠,无独立价值 | +| 23 | resolve 响应是否含 ICCID | 是,card 类型时返回 ICCID,供停复机接口使用 | +| 24 | 设备批量停机部分失败策略 | 仍设置 Redis 保护期;已成功停机的卡不回滚;失败的卡记录日志 | +| 25 | 流量数据汇总逻辑 | 统一用专门汇总逻辑,从 PackageUsage 读取;设备级套餐汇总所有绑定卡 | +| 26 | 套餐历史列表排序和范围 | 按创建时间倒序,不分页,包含所有状态(含 status=4 已失效) | +| 27 | current-package 多套餐时返回哪个 | 返回主套餐(master_usage_id IS NULL) | +| 28 | 轮询系统保护期检查实现方式 | 新增独立的第四种轮询任务类型,不修改现有三种任务 | +| 29 | 卡虚拟号导入规则 | 只允许为空白虚拟号的卡填入;与现存数据重复则全批失败 | +| 30 | 设备批量刷新频率限制 | 需要;Redis 限频,同一设备冷却期(建议 30 秒)内不允许重复触发 | +| 31 | PersonalCustomerDevice.device_no 改名 | 是,统一改为 virtual_no,与 tb_device 保持语义一致 | +| 32 | DeviceCardInfo 需要 last_sync_time | 是,添加 last_sync_at 字段 | + +--- + +## 九、轮询系统补充说明 + +### 9.1 整体架构 + +轮询系统是君鸿卡管系统维护卡数据实时性的核心机制: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Worker 服务(后台) │ +├─────────────────────────────────────────────────────────────────────┤ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Scheduler │────▶│ Asynq 队列 │────▶│ Handler │ │ +│ │ (调度器) │ │ (任务队列) │ │ (处理器) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ │ │ +│ │ 定时循环 (每秒) │ │ +│ ▼ ▼ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ Redis Sorted Set 轮询队列 │ │ +│ │ - polling:queue:realname (实名检查) │ │ +│ │ - polling:queue:carddata (流量检查) │ │ +│ │ - polling:queue:package (套餐检查) │ │ +│ │ - polling:queue:protect (保护期一致性检查) │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ + │ 调用网关 API + ▼ + ┌──────────────────────┐ + │ Gateway 网关 │ + │ (第三方运营商) │ + └──────────────────────┘ +``` + +### 9.2 四种轮询任务 + +| 任务类型 | 触发频率 | 作用 | 更新字段 | +|---------|---------|------|---------| +| **实名检查** | 默认 5 分钟 | 调用网关查实名状态 | real_name_status | +| **流量检查** | 默认 10 分钟 | 调用网关查流量,更新套餐 | current_month_usage_mb | +| **套餐检查** | 默认 10 分钟 | 检查是否超额,触发停机 | network_status | +| **保护期检查** | 同流量检查频率 | 检查绑定设备保护期,强制同步卡的网络状态 | network_status | + +> **第四种任务设计说明**:保护期一致性检查封装为独立任务类型,不嵌入现有三种任务内部。只检查"已绑定设备且设备当前有保护期"的卡,范围小,可与流量检查同频触发。 + +### 9.3 关键特点 + +1. **启动时渐进式初始化**:系统启动时把卡分批加载到 Redis 队列(每批 10 万张) +2. **按时间排序**:Redis Sorted Set 的 score 是下次检查的时间戳,到期自动被调度器取出 +3. **并发控制**:通过 Redis 信号量限制并发数(默认 50),防止打爆网关 +4. **失败重试**:任务失败后重新入队 +5. **缓存优化**:优先从 Redis 读取卡信息,避免频繁查 DB + +### 9.4 与手动刷新接口的关系 + +- **轮询是后台自动跑**:所有卡都会按配置的时间间隔被检查,保证日常数据更新 +- **手动刷新是前台客服主动用**:只更新这一张卡(或设备的所有绑定卡),满足客户急用场景 +- **两者是互补关系**:轮询保证数据不会太旧,手动刷新满足实时性要求高的场景 + +### 9.5 与设备保护期的交互 + +轮询系统在处理设备的绑定卡时,需要检查设备是否有保护期(见 5.4 流程图): +- 发现设备有 stop 保护期,且卡为开机状态 → 强制调网关停机 +- 发现设备有 start 保护期,且卡为停机状态 → 强制调网关复机 +- 未实名的卡跳过,不参与保护期逻辑 + +关键代码位置: +- `internal/task/polling_handler.go` - 轮询任务处理器(需新增独立的第四种任务:保护期一致性检查处理函数) +- `pkg/constants/redis.go` - 需新增 `RedisDeviceProtectKey()` 函数 + +### 9.6 涉及的关键代码 + +- `internal/polling/scheduler.go` - 轮询调度器(把卡加入队列) +- `internal/task/polling_handler.go` - 任务处理器(实际调网关更新数据) +- `internal/service/iot_card/service.go:799` - SyncCardStatusFromGateway 方法 + +--- + +## 十、下一步行动 + +### 10.1 当前阶段 + +**设计讨论** - 已完成,所有关键决策已确认,可进入 openspec 提案阶段 + +### 10.2 进入 openspec 提案后的任务拆分建议 + +**数据层(优先)**: +1. 数据库迁移:设备表 `device_no` → `virtual_no`(同步更新 `tb_personal_customer_device.device_no` → `virtual_no`) +2. 数据库迁移:卡表新增 `virtual_no` 字段(唯一索引,允许空) +3. 数据库迁移:套餐表新增 `virtual_ratio` 字段 +4. 更新 Device Model 和所有引用 `device_no` 的代码(全量替换,含 PersonalCustomerDevice) +5. 更新 Package Service,创建/更新套餐时自动计算并存储 `virtual_ratio` + +**接口层(依次实现)**: +6. 实现资产入口 `GET /assets/resolve/:identifier` +7. 实现当前状态查询 `GET /assets/:type/:id/realtime-status` +8. 实现手动刷新 `POST /assets/:type/:id/refresh`(含设备批量刷新 + Redis 限频) +9. 实现套餐记录查询 `GET /assets/:type/:id/packages` +10. 实现当前套餐查询 `GET /assets/:type/:id/current-package` +11. 实现设备停机 `POST /assets/device/:id/stop`(含保护期逻辑 + 部分失败策略) +12. 实现设备复机 `POST /assets/device/:id/start`(含保护期逻辑) +13. 实现卡停机 `POST /assets/card/:iccid/stop`(含保护期检查) +14. 实现卡复机 `POST /assets/card/:iccid/start`(含保护期检查) + +**轮询系统**: +15. 新增第四种轮询任务:保护期一致性检查(独立任务类型,不修改现有三种任务内部逻辑) + +**清理**: +16. 删除废弃的停复机接口(见 3.6 废弃清单) +17. 丰富现有卡/设备 DTO(IotCardDetailResponse、DeviceResponse) +18. 更新 API 文档生成器(docs.go 和 gendocs/main.go) + +### 10.3 涉及的关键代码文件 + +**Handler 层**: +- `internal/handler/admin/iot_card.go` +- `internal/handler/admin/device.go` +- `internal/handler/h5/enterprise_device.go`(待删除的废弃接口) + +**Service 层**: +- `internal/service/iot_card/service.go`(含 SyncCardStatusFromGateway:799) +- `internal/service/iot_card/stop_resume_service.go`(停复机逻辑,需扩展) +- `internal/service/device/service.go`(含 GetByIdentifier:177) +- `internal/service/package/customer_view_service.go`(套餐聚合,需复用) +- `internal/service/package/service.go`(创建套餐时存储 virtual_ratio) + +**Store 层**: +- `internal/store/postgres/device_store.go`(GetByIdentifier:62,改用 virtual_no) +- `internal/store/postgres/iot_card_store.go` +- `internal/store/postgres/personal_customer_device_store.go`(device_no → virtual_no) + +**Model 层**: +- `internal/model/iot_card.go`(新增 virtual_no 字段) +- `internal/model/device.go`(device_no → virtual_no) +- `internal/model/package.go`(新增 virtual_ratio 字段) +- `internal/model/personal_customer_device.go`(device_no → virtual_no) + +**DTO 层**: +- `internal/model/dto/iot_card_dto.go`(需重构) +- `internal/model/dto/device_dto.go`(需丰富) + +**常量层**: +- `pkg/constants/redis.go`(新增 `RedisDeviceProtectKey()` 函数) + +**轮询层**: +- `internal/task/polling_handler.go`(新增保护期一致性检查独立任务处理函数) + +--- + +## 十一、附录:关键代码片段 + +### 11.1 现有空壳详情 DTO + +```go +// internal/model/dto/iot_card_dto.go:134-136 +type IotCardDetailResponse struct { + StandaloneIotCardResponse // 只是列表响应的空包装 +} +``` + +### 11.2 设备详情 DTO + +```go +// internal/model/dto/device_dto.go:20 +type DeviceResponse struct { + ID uint `json:"id"` + DeviceNo string `json:"device_no"` // 改名为 virtual_no + // ... + BoundCardCount int `json:"bound_card_count"` // 只有数字,需丰富 +} +``` + +### 11.3 设备多字段查找 Store + +```go +// internal/store/postgres/device_store.go:62 +// 改造后:device_no → virtual_no +func (s *Store) GetByIdentifier(db *gorm.DB, identifier string) (*model.Device, error) { + var device model.Device + err := db.Where("virtual_no = ? OR imei = ? OR sn = ?", identifier, identifier, identifier). + First(&device).Error + return &device, err +} +``` + +### 11.4 手动刷新方法(待暴露为接口) + +```go +// internal/service/iot_card/service.go:799 +func (s *Service) SyncCardStatusFromGateway(ctx context.Context, iccid string) error { + // 已有实现,需作为接口暴露,并支持设备批量刷新 +} +``` + +### 11.5 新增 Redis Key 常量 + +```go +// pkg/constants/redis.go +// RedisDeviceProtectKey 设备停复机保护期 Key +// action: "stop" 或 "start",TTL = 1 小时 +func RedisDeviceProtectKey(deviceID uint, action string) string { + return fmt.Sprintf("protect:device:%d:%s", deviceID, action) +} + +// RedisDeviceRefreshCooldownKey 设备手动刷新冷却期 Key,TTL = 冷却时长(建议 30 秒) +func RedisDeviceRefreshCooldownKey(deviceID uint) string { + return fmt.Sprintf("refresh:cooldown:device:%d", deviceID) +} +``` + +### 11.6 virtual_ratio 计算位置 + +```go +// internal/service/package/service.go +// 创建/更新套餐时计算并存储 virtual_ratio +if pkg.EnableVirtualData && pkg.VirtualDataMB > 0 { + pkg.VirtualRatio = float64(pkg.RealDataMB) / float64(pkg.VirtualDataMB) +} else { + pkg.VirtualRatio = 1.0 +} +``` + +--- + +> **文档结束** +> +> 所有设计决策已确认,可进入 openspec 提案阶段。 diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 994ae18..eaf5951 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -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), } } diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 779aa87..2b88b83 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -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), } } diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index 0ed63e5..d50d61f 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -53,6 +53,7 @@ type Handlers struct { PollingAlert *admin.PollingAlertHandler PollingCleanup *admin.PollingCleanupHandler PollingManualTrigger *admin.PollingManualTriggerHandler + Asset *admin.AssetHandler } // Middlewares 封装所有中间件 diff --git a/internal/handler/admin/asset.go b/internal/handler/admin/asset.go new file mode 100644 index 0000000..739b974 --- /dev/null +++ b/internal/handler/admin/asset.go @@ -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) +} diff --git a/internal/handler/admin/device.go b/internal/handler/admin/device.go index 73fd122..3f681ea 100644 --- a/internal/handler/admin/device.go +++ b/internal/handler/admin/device.go @@ -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 { diff --git a/internal/handler/admin/enterprise_card.go b/internal/handler/admin/enterprise_card.go index 09f4712..9bb7b69 100644 --- a/internal/handler/admin/enterprise_card.go +++ b/internal/handler/admin/enterprise_card.go @@ -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) -} diff --git a/internal/handler/admin/iot_card.go b/internal/handler/admin/iot_card.go index 8f30760..3446f0e 100644 --- a/internal/handler/admin/iot_card.go +++ b/internal/handler/admin/iot_card.go @@ -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) -} diff --git a/internal/handler/h5/enterprise_device.go b/internal/handler/h5/enterprise_device.go index ba1c8a5..6c31106 100644 --- a/internal/handler/h5/enterprise_device.go +++ b/internal/handler/h5/enterprise_device.go @@ -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) -} diff --git a/internal/model/device.go b/internal/model/device.go index 93e7292..3e0eaa5 100644 --- a/internal/model/device.go +++ b/internal/model/device.go @@ -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"` diff --git a/internal/model/device_import_task.go b/internal/model/device_import_task.go index ed3e44a..a7ffa63 100644 --- a/internal/model/device_import_task.go +++ b/internal/model/device_import_task.go @@ -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"` } diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go new file mode 100644 index 0000000..30d3aad --- /dev/null +++ b/internal/model/dto/asset_dto.go @@ -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"` +} diff --git a/internal/model/dto/commission.go b/internal/model/dto/commission.go index 731f3e6..1cf4f20 100644 --- a/internal/model/dto/commission.go +++ b/internal/model/dto/commission.go @@ -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:"入账后钱包余额(分)"` diff --git a/internal/model/dto/device_dto.go b/internal/model/dto/device_dto.go index 3b9cba1..631edf9 100644 --- a/internal/model/dto/device_dto.go +++ b/internal/model/dto/device_dto.go @@ -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:"失败原因"` +} diff --git a/internal/model/dto/device_import_dto.go b/internal/model/dto/device_import_dto.go index 5c8fe31..4bcd050 100644 --- a/internal/model/dto/device_import_dto.go +++ b/internal/model/dto/device_import_dto.go @@ -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 { diff --git a/internal/model/dto/enterprise_card_authorization_dto.go b/internal/model/dto/enterprise_card_authorization_dto.go index ed64a60..f98ccd9 100644 --- a/internal/model/dto/enterprise_card_authorization_dto.go +++ b/internal/model/dto/enterprise_card_authorization_dto.go @@ -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"` diff --git a/internal/model/dto/enterprise_device_authorization_dto.go b/internal/model/dto/enterprise_device_authorization_dto.go index 0bfd745..ad390fc 100644 --- a/internal/model/dto/enterprise_device_authorization_dto.go +++ b/internal/model/dto/enterprise_device_authorization_dto.go @@ -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:"设备类型"` diff --git a/internal/model/dto/my_commission_dto.go b/internal/model/dto/my_commission_dto.go index b55e999..2df7a90 100644 --- a/internal/model/dto/my_commission_dto.go +++ b/internal/model/dto/my_commission_dto.go @@ -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:"订单号(模糊查询)"` } diff --git a/internal/model/dto/shop_commission_dto.go b/internal/model/dto/shop_commission_dto.go index a2a4c7b..f153445 100644 --- a/internal/model/dto/shop_commission_dto.go +++ b/internal/model/dto/shop_commission_dto.go @@ -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:"佣金入账时间"` diff --git a/internal/model/iot_card.go b/internal/model/iot_card.go index 8018e0b..6981787 100644 --- a/internal/model/iot_card.go +++ b/internal/model/iot_card.go @@ -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 指定表名 diff --git a/internal/model/iot_card_import_task.go b/internal/model/iot_card_import_task.go index f097516..4ac9cba 100644 --- a/internal/model/iot_card_import_task.go +++ b/internal/model/iot_card_import_task.go @@ -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 diff --git a/internal/model/package.go b/internal/model/package.go index 51c23ae..caba834 100644 --- a/internal/model/package.go +++ b/internal/model/package.go @@ -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"` diff --git a/internal/model/personal_customer_device.go b/internal/model/personal_customer_device.go index 55e3952..53b7a1f 100644 --- a/internal/model/personal_customer_device.go +++ b/internal/model/personal_customer_device.go @@ -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"` diff --git a/internal/routes/admin.go b/internal/routes/admin.go index f832bb8..56204b7 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -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) + } } diff --git a/internal/routes/asset.go b/internal/routes/asset.go new file mode 100644 index 0000000..38f1cb5 --- /dev/null +++ b/internal/routes/asset.go @@ -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, + }) +} diff --git a/internal/routes/device.go b/internal/routes/device.go index 560764b..f1ad3e2 100644 --- a/internal/routes/device.go +++ b/internal/routes/device.go @@ -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。", diff --git a/internal/routes/enterprise_card.go b/internal/routes/enterprise_card.go index 51d5691..83639ef 100644 --- a/internal/routes/enterprise_card.go +++ b/internal/routes/enterprise_card.go @@ -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, - }) } diff --git a/internal/routes/h5_enterprise_device.go b/internal/routes/h5_enterprise_device.go index b084a29..145827c 100644 --- a/internal/routes/h5_enterprise_device.go +++ b/internal/routes/h5_enterprise_device.go @@ -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, - }) } diff --git a/internal/routes/iot_card.go b/internal/routes/iot_card.go index fcc0dd7..116c07c 100644 --- a/internal/routes/iot_card.go +++ b/internal/routes/iot_card.go @@ -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, - }) } diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go new file mode 100644 index 0000000..e0073da --- /dev/null +++ b/internal/service/asset/service.go @@ -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 "未知" + } +} diff --git a/internal/service/device/service.go b/internal/service/device/service.go index 3c1b925..4184aeb 100644 --- a/internal/service/device/service.go +++ b/internal/service/device/service.go @@ -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 +} diff --git a/internal/service/device_import/service.go b/internal/service/device_import/service.go index b4afd30..0b0922a 100644 --- a/internal/service/device_import/service.go +++ b/internal/service/device_import/service.go @@ -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, }) } diff --git a/internal/service/enterprise_device/service.go b/internal/service/enterprise_device/service.go index 4060016..6539894 100644 --- a/internal/service/enterprise_device/service.go +++ b/internal/service/enterprise_device/service.go @@ -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, diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index 5c24168..c93c9fa 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -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 { diff --git a/internal/service/iot_card/stop_resume_service.go b/internal/service/iot_card/stop_resume_service.go index d090d8c..f297180 100644 --- a/internal/service/iot_card/stop_resume_service.go +++ b/internal/service/iot_card/stop_resume_service.go @@ -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 +} diff --git a/internal/service/my_commission/service.go b/internal/service/my_commission/service.go index 7a6ac7b..8a0dd9d 100644 --- a/internal/service/my_commission/service.go +++ b/internal/service/my_commission/service.go @@ -214,7 +214,6 @@ func (s *Service) CreateWithdrawalRequest(ctx context.Context, req *dto.CreateMy return nil }) - if err != nil { return nil, err } diff --git a/internal/service/package/service.go b/internal/service/package/service.go index 89dbff3..9dae6a9 100644 --- a/internal/service/package/service.go +++ b/internal/service/package/service.go @@ -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 +} diff --git a/internal/service/shop_commission/service.go b/internal/service/shop_commission/service.go index c4a4846..77a0d29 100644 --- a/internal/service/shop_commission/service.go +++ b/internal/service/shop_commission/service.go @@ -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"), diff --git a/internal/store/postgres/device_store.go b/internal/store/postgres/device_store.go index 7df591b..43ae632 100644 --- a/internal/store/postgres/device_store.go +++ b/internal/store/postgres/device_store.go @@ -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 { diff --git a/internal/store/postgres/iot_card_store.go b/internal/store/postgres/iot_card_store.go index e665ec1..22c72e0 100644 --- a/internal/store/postgres/iot_card_store.go +++ b/internal/store/postgres/iot_card_store.go @@ -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) { diff --git a/internal/store/postgres/personal_customer_device_store.go b/internal/store/postgres/personal_customer_device_store.go index 35a04a0..2e5b592 100644 --- a/internal/store/postgres/personal_customer_device_store.go +++ b/internal/store/postgres/personal_customer_device_store.go @@ -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) diff --git a/internal/task/device_import.go b/internal/task/device_import.go index 4f340c7..23d103d 100644 --- a/internal/task/device_import.go +++ b/internal/task/device_import.go @@ -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 列") diff --git a/internal/task/iot_card_import.go b/internal/task/iot_card_import.go index 92c3017..c4573fd 100644 --- a/internal/task/iot_card_import.go +++ b/internal/task/iot_card_import.go @@ -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) } diff --git a/internal/task/polling_handler.go b/internal/task/polling_handler.go index 20aeb4d..7853db5 100644 --- a/internal/task/polling_handler.go +++ b/internal/task/polling_handler.go @@ -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: 查询该卡是否有待激活套餐 diff --git a/migrations/000073_rename_device_no_to_virtual_no.down.sql b/migrations/000073_rename_device_no_to_virtual_no.down.sql new file mode 100644 index 0000000..89183c4 --- /dev/null +++ b/migrations/000073_rename_device_no_to_virtual_no.down.sql @@ -0,0 +1,7 @@ +-- 回滚:将 virtual_no 改回 device_no + +ALTER TABLE tb_device RENAME COLUMN virtual_no TO device_no; + +ALTER INDEX IF EXISTS idx_device_virtual_no RENAME TO idx_device_no; + +ALTER TABLE tb_personal_customer_device RENAME COLUMN virtual_no TO device_no; diff --git a/migrations/000073_rename_device_no_to_virtual_no.up.sql b/migrations/000073_rename_device_no_to_virtual_no.up.sql new file mode 100644 index 0000000..72d8e36 --- /dev/null +++ b/migrations/000073_rename_device_no_to_virtual_no.up.sql @@ -0,0 +1,11 @@ +-- 将 tb_device 和 tb_personal_customer_device 中的 device_no 字段改名为 virtual_no +-- 原因:统一"虚拟号"的字段命名,与 tb_iot_card.virtual_no 保持一致 + +-- 重命名 tb_device.device_no → virtual_no +ALTER TABLE tb_device RENAME COLUMN device_no TO virtual_no; + +-- 同步重命名唯一索引(可选,保持命名一致性) +ALTER INDEX IF EXISTS idx_device_no RENAME TO idx_device_virtual_no; + +-- 重命名 tb_personal_customer_device.device_no → virtual_no +ALTER TABLE tb_personal_customer_device RENAME COLUMN device_no TO virtual_no; diff --git a/migrations/000074_add_virtual_no_to_iot_card.down.sql b/migrations/000074_add_virtual_no_to_iot_card.down.sql new file mode 100644 index 0000000..8b802af --- /dev/null +++ b/migrations/000074_add_virtual_no_to_iot_card.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_iot_card_virtual_no; +ALTER TABLE tb_iot_card DROP COLUMN IF EXISTS virtual_no; diff --git a/migrations/000074_add_virtual_no_to_iot_card.up.sql b/migrations/000074_add_virtual_no_to_iot_card.up.sql new file mode 100644 index 0000000..ec93917 --- /dev/null +++ b/migrations/000074_add_virtual_no_to_iot_card.up.sql @@ -0,0 +1,7 @@ +ALTER TABLE tb_iot_card ADD COLUMN IF NOT EXISTS virtual_no VARCHAR(50); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_iot_card_virtual_no + ON tb_iot_card(virtual_no) + WHERE deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> ''; + +COMMENT ON COLUMN tb_iot_card.virtual_no IS '虚拟号(可空,全局唯一)'; diff --git a/migrations/000075_add_virtual_ratio_to_package.down.sql b/migrations/000075_add_virtual_ratio_to_package.down.sql new file mode 100644 index 0000000..3fe507d --- /dev/null +++ b/migrations/000075_add_virtual_ratio_to_package.down.sql @@ -0,0 +1 @@ +ALTER TABLE tb_package DROP COLUMN IF EXISTS virtual_ratio; diff --git a/migrations/000075_add_virtual_ratio_to_package.up.sql b/migrations/000075_add_virtual_ratio_to_package.up.sql new file mode 100644 index 0000000..129d251 --- /dev/null +++ b/migrations/000075_add_virtual_ratio_to_package.up.sql @@ -0,0 +1,11 @@ +ALTER TABLE tb_package ADD COLUMN IF NOT EXISTS virtual_ratio DECIMAL(18,6) NOT NULL DEFAULT 1.0; + +COMMENT ON COLUMN tb_package.virtual_ratio IS '虚流量比例(real_data_mb/virtual_data_mb),创建套餐时计算存储,默认1.0'; + +UPDATE tb_package +SET virtual_ratio = CASE + WHEN enable_virtual_data = true AND virtual_data_mb > 0 + THEN (real_data_mb::DECIMAL / virtual_data_mb::DECIMAL) + ELSE 1.0 +END +WHERE deleted_at IS NULL; diff --git a/openspec/changes/archive/2026-03-14-asset-detail-refactor/.openspec.yaml b/openspec/changes/archive/2026-03-14-asset-detail-refactor/.openspec.yaml new file mode 100644 index 0000000..49ccc67 --- /dev/null +++ b/openspec/changes/archive/2026-03-14-asset-detail-refactor/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-14 diff --git a/openspec/changes/archive/2026-03-14-asset-detail-refactor/design.md b/openspec/changes/archive/2026-03-14-asset-detail-refactor/design.md new file mode 100644 index 0000000..ccb5041 --- /dev/null +++ b/openspec/changes/archive/2026-03-14-asset-detail-refactor/design.md @@ -0,0 +1,121 @@ +## Context + +当前系统中 IoT 卡和设备的详情体系存在三大问题: + +1. **接口分散**:Admin/H5 两端各自实现了停复机接口,逻辑重复且行为不一致 +2. **数据贫血**:`IotCardDetailResponse` 是 `StandaloneIotCardResponse` 的空包装,`DeviceResponse` 仅返回 `BoundCardCount` 一个数字,无法支撑前端渲染完整详情页 +3. **网关裸透传**:`SyncCardStatusFromGateway`(改名后为 `RefreshCardDataFromGateway`)仅为示例方法,轮询系统的同步逻辑分散在 polling_handler.go 中,无统一入口 + +本次重构在 Admin 端建立统一的资产详情体系,含资产解析入口、状态查询、手动刷新、套餐查询、停复机接口,并完成数据模型的字段补全和改名。 + +## Goals / Non-Goals + +**Goals:** +- 建立 `GET /api/admin/assets/resolve/:identifier` 作为唯一的资产查找入口 +- 卡和设备的停复机接口统一归入 `/api/admin/assets/` 路径,删除旧重复接口 +- 设备停复机引入 1 小时保护期机制,通过 Redis 存储保护期状态 +- IotCard 模型新增 `virtual_no` 字段,Device 模型 `device_no` 全量改名为 `virtual_no` +- Package 模型新增 `virtual_ratio` 字段,套餐创建时自动计算 +- 轮询系统新增保护期一致性检查作为第四种独立任务类型 + +**Non-Goals:** +- H5 端接口不在本次改造范围,旧 H5 接口保留(待后续单独处理) +- 企业账号不支持 resolve 接口(未来单独开新接口) +- 不引入新的外部依赖,不改变现有轮询系统的前三种任务类型内部逻辑 + +## Decisions + +### 决策 1:统一资产入口而非按类型分开 + +**选项 A(采用)**:单一入口 `GET /assets/resolve/:identifier`,返回响应中含 `asset_type` 字段区分 +**选项 B**:`GET /assets/card/:identifier` 和 `GET /assets/device/:identifier` 分开 + +选 A 的原因:虚拟号 identifier 在全局唯一时,前端无需预先知道是卡还是设备,一次请求拿到类型和 ID,后续调用才按类型路由。这符合"统一入口"的核心设计目标。 + +**查找顺序**:先查 Device(virtual_no / imei / sn),未命中再查 IotCard(virtual_no / iccid / msisdn)。设备优先因为设备标识符更具体。 + +### 决策 2:resolve 返回中等聚合版本,而非最小或最大版本 + +resolve 包含:基础信息 + 状态 + 当前套餐流量概况 + 保护期状态 + 绑定信息(卡←→设备)。 + +不做最小版本(仅返回 asset_type + id):前端页面需要立即展示套餐流量,避免二次请求。 +不做最大版本(返回全量历史套餐):套餐历史通过独立接口按需加载,避免 resolve 过重。 + +### 决策 3:realtime-status 不调网关 + +realtime-status 只读 DB/Redis 中已持久化的数据,"实时性"由轮询系统(5-10 分钟刷新一次)保证。需要最新数据时,先调 refresh 接口,再调此接口。 + +这个分工避免 realtime-status 因网关延迟而超时,保证轻量轮询性能。 + +### 决策 4:设备保护期存储在 Redis,而非数据库 + +保护期是临时状态,1 小时 TTL 后自然过期,无需持久化。Redis Key 格式: +``` +protect:device:{device_id}:stop // 停机保护期 +protect:device:{device_id}:start // 复机保护期 +``` +这两个 key 互斥(发起 stop 时删除 start key,反之亦然)。 + +### 决策 5:新建 AssetService 而非扩展现有 Service + +resolve 接口需要跨越 Device 和 IotCard 两张表,流量聚合逻辑来自 PackageUsage,保护期来自 Redis。 +如果分散在现有 Service 中会造成跨模块依赖混乱。新建 `internal/service/asset/service.go` 依赖注入 DeviceStore、IotCardStore、PackageUsageStore 和 Redis。 + +### 决策 6:RefreshCardDataFromGateway 增强现有方法而非新建 + +原 `SyncCardStatusFromGateway` 是示例实现,改名并增强为完整同步(NetworkStatus、RealNameStatus、CurrentMonthUsageMB、LastSyncTime)。轮询系统的 polling_handler.go 已有完整的同步逻辑,增强时参考该实现。 + +### 决策 7:卡 ICCID 导入新增可选 virtual_no 列 + +在现有导入模板中增加第 N+1 列 `virtual_no`(可选),导入时按规则处理: +- 该行 virtual_no 不为空 + 数据库当前值为空 → 填入 +- 该行 virtual_no 不为空 + 数据库当前值不为空 → 跳过(不覆盖) +- 批次中有任意 virtual_no 与数据库现存值重复(其他卡) → 整批失败,返回冲突列表 + +### 决策 8:虚流量比例 virtual_ratio 在套餐创建时计算并存储 + +不在查询时实时计算,原因:套餐参数确定后比例不变,存储避免每次查询重复除法,且支持未来通过 SQL 直接用于统计。 +``` +enable_virtual_data = true → virtual_ratio = real_data_mb / virtual_data_mb +enable_virtual_data = false → virtual_ratio = 1.0 +``` + +### 决策 9:保护期一致性检查作为独立第四种轮询任务 + +不嵌入现有三种任务(实名/流量/套餐)中,原因:保护期检查的触发条件(设备有保护期)和处理逻辑完全不同,嵌入会破坏现有任务的单一职责。独立任务类型,Redis 队列 key:`polling:queue:protect`,与流量检查同频(10 分钟)。 + +### 决策 10:设备批量停机部分失败策略 + +部分卡调网关失败时: +- 已成功停机的卡**不回滚**(回滚代价高且可能再次失败) +- **仍设置** Redis 保护期(保护期从"发起操作"那一刻算起,而非"所有卡成功"后) +- 失败卡记录 Error 日志,响应中携带失败列表 + +## Risks / Trade-offs + +**[风险 1] device_no 全量改名影响范围广** → 缓解:先做数据库迁移,再用 `lsp_rename` 全量替换代码引用,最后运行编译检查确认无遗漏 + +**[风险 2] resolve 接口的套餐流量计算可能超过 50ms 目标** → 缓解:PackageUsage 查询已有 `iot_card_id` 和 `device_id` 索引,只查当前生效套餐(status=1);设备类型时只查 DeviceID,不逐卡汇总 + +**[风险 3] 保护期与轮询系统的竞争条件** → 缓解:轮询任务在处理卡状态前检查 Redis 保护期,保护期内强制按保护方向同步,不受卡自身状态影响 + +**[风险 4] 设备批量刷新打爆网关** → 缓解:Redis 限频(同一设备 30 秒冷却),Handler 层返回 HTTP 429 + +**[Trade-off] resolve 不支持企业账号** → 接受:企业账号的资产查询路径不同,未来单独开接口更合适,本次不过度设计 + +## Migration Plan + +1. **数据库迁移(先行)**: + - Migration 1:`tb_device.device_no` → `virtual_no`,`tb_personal_customer_device.device_no` → `virtual_no` + - Migration 2:`tb_iot_card` 新增 `virtual_no` 字段 + 唯一部分索引 + - Migration 3:`tb_package` 新增 `virtual_ratio` 字段,为现有数据回填(按 enable_virtual_data 计算) + +2. **代码变更**:Model/Store/Service/Handler 按分层顺序依次实现 + +3. **废弃接口删除**:在新接口实现并验证后,删除旧停复机接口 + +4. **回滚**:数据库变更均可逆(改名可再改回,新增字段可删除);代码回滚通过 git revert + +## Open Questions + +(无——所有关键决策已在讨论纪要中确认) diff --git a/openspec/changes/archive/2026-03-14-asset-detail-refactor/proposal.md b/openspec/changes/archive/2026-03-14-asset-detail-refactor/proposal.md new file mode 100644 index 0000000..bf8f099 --- /dev/null +++ b/openspec/changes/archive/2026-03-14-asset-detail-refactor/proposal.md @@ -0,0 +1,72 @@ +## Why + +卡和设备的详情体系严重割裂:查询入口散落三端(Admin/H5/Personal)、停复机实现重复三处、详情接口仅返回骨架数据、网关数据纯透传无业务聚合。前端无法依赖现有接口渲染完整的资产详情页,客服也因缺少虚拟号统一入口而无法快速定位资产。本次重构一次性完成资产详情体系的建设,引入统一资产入口、合并停复机接口、建立设备保护期机制。 + +## What Changes + +- **新增** 统一资产解析入口 `GET /api/admin/assets/resolve/:identifier`,支持通过虚拟号/ICCID/MSISDN/IMEI/SN 查找卡或设备 +- **新增** 资产轻量状态查询 `GET /api/admin/assets/:asset_type/:id/realtime-status`(只查持久化数据,不调网关) +- **新增** 手动刷新接口 `POST /api/admin/assets/:asset_type/:id/refresh`(调网关写回 DB,设备类型含 Redis 限频) +- **新增** 套餐历史列表 `GET /api/admin/assets/:asset_type/:id/packages` +- **新增** 当前套餐查询 `GET /api/admin/assets/:asset_type/:id/current-package` +- **新增** 设备停机/复机 `POST /api/admin/assets/device/:device_id/stop|start`(含 1 小时保护期机制) +- **新增** 卡停机/复机 `POST /api/admin/assets/card/:iccid/stop|start`(含保护期感知) +- **新增** 轮询系统第四种任务:保护期一致性检查(独立任务类型,与流量检查同频触发) +- **数据库变更** `tb_device.device_no` → `virtual_no`(**BREAKING** 字段改名,全量更新代码) +- **数据库变更** `tb_personal_customer_device.device_no` → `virtual_no`(同上) +- **数据库变更** `tb_iot_card` 新增 `virtual_no` 字段(可空,全局唯一索引) +- **数据库变更** `tb_package` 新增 `virtual_ratio` 字段(套餐创建时计算并存储) +- **重命名** `SyncCardStatusFromGateway` → `RefreshCardDataFromGateway`,增强为完整同步 NetworkStatus/RealNameStatus/CurrentMonthUsageMB/LastSyncTime +- **修改** 现有卡 ICCID 导入:模板新增可选 `virtual_no` 列(只补空白,重复则全批失败) +- **修改** 现有设备导入:`device_no` 列头随字段改名同步更新为 `virtual_no` +- **删除** 废弃停复机接口(Admin 企业卡端 suspend/resume、H5 企业设备端 suspend/resume、旧 Admin 按 ICCID 停复机) +- 企业账号暂不支持 resolve 接口 + +## Capabilities + +### New Capabilities + +- `asset-resolve`:统一资产解析入口,通过任意标识符查找卡或设备,返回资产类型、ID、虚拟号、状态、套餐流量概况、保护期状态、绑定信息等中等聚合数据 +- `asset-queries`:资产状态查询族,包含轻量实时状态查询(realtime-status)、手动刷新(refresh)、套餐历史列表(packages)、当前主套餐详情(current-package) +- `asset-suspend-resume`:卡和设备的停复机统一接口,含设备 1 小时保护期机制(Redis 存储)、未实名卡跳过逻辑、批量停机部分失败策略 +- `polling-protect-consistency`:轮询系统新增的第四种任务,检查绑定设备保护期并强制同步卡的网络状态 + +### Modified Capabilities + +- `iot-card`:新增 `virtual_no` 字段(可空,全局唯一索引);IotCard 导入模板新增可选 virtual_no 列 +- `device`:`device_no` 字段全量改名为 `virtual_no`(含 tb_personal_customer_device);设备导入模板同步更新 +- `iot-package`:`Package` 模型新增 `virtual_ratio` 字段,创建/更新套餐时自动计算存储 + +## Impact + +**Handler 层**: +- 新增 `internal/handler/admin/asset.go`(9 个新接口) +- 删除 `internal/handler/admin/enterprise_card.go` 中的废弃停复机 Handler +- 删除 `internal/handler/h5/enterprise_device.go` 中的废弃停复机 Handler + +**Service 层**: +- 新增 `internal/service/asset/service.go`(资产解析、状态聚合逻辑) +- 修改 `internal/service/iot_card/service.go`:`SyncCardStatusFromGateway` → `RefreshCardDataFromGateway`,增强为完整同步 +- 修改 `internal/service/iot_card/stop_resume_service.go`:扩展手动停复机逻辑 +- 修改 `internal/service/device/service.go`:新增设备停复机 + 保护期逻辑 +- 修改 `internal/service/package/service.go`:创建/更新套餐时自动计算 virtual_ratio + +**Store 层**: +- 修改 `internal/store/postgres/device_store.go`:`GetByIdentifier` 改用 `virtual_no` +- 修改 `internal/store/postgres/personal_customer_device_store.go`:`device_no` → `virtual_no` + +**Model 层**: +- `internal/model/iot_card.go`:新增 `VirtualNo` 字段 +- `internal/model/device.go`:`DeviceNo` → `VirtualNo` +- `internal/model/package.go`:新增 `VirtualRatio` 字段 +- `internal/model/personal_customer_device.go`:`DeviceNo` → `VirtualNo` + +**常量层**: +- `pkg/constants/redis.go`:新增 `RedisDeviceProtectKey`、`RedisDeviceRefreshCooldownKey` + +**轮询层**: +- `internal/task/polling_handler.go`:新增保护期一致性检查任务处理函数 + +**数据库迁移**:3 张表的结构变更(device、iot_card、package) + +**API 文档**:`cmd/api/docs.go` 和 `cmd/gendocs/main.go` 需同步更新 diff --git a/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/asset-queries/spec.md b/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/asset-queries/spec.md new file mode 100644 index 0000000..52d4e0f --- /dev/null +++ b/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/asset-queries/spec.md @@ -0,0 +1,166 @@ +## ADDED Requirements + +### Requirement: 轻量实时状态查询 + +系统 SHALL 提供基于持久化数据的轻量状态查询接口,供前端在已知资产 ID 后进行快速轮询。 + +**API 端点**: `GET /api/admin/assets/:asset_type/:id/realtime-status` + +**约束**: +- `:asset_type` 取值为 `device` 或 `card` +- 此接口**不调用网关**,仅读取 DB/Redis 中持久化的最新数据 +- 不包含套餐流量计算(与 resolve 的区别) +- "实时性"依赖轮询系统定期刷新(实名状态约 5 分钟,流量约 10 分钟) + +**card 类型响应字段**: +- `network_status`: 网络状态(0-停机 1-开机) +- `real_name_status`: 实名状态(0-未实名 1-已实名) +- `current_month_usage_mb`: 本月已用流量(持久化缓存值) +- `last_sync_at`: 最后与 Gateway 同步时间 + +**device 类型响应字段**: +- `device_protect_status`: 保护期状态(`"none"` / `"stop"` / `"start"`) +- `cards`: 所有绑定卡的状态列表(同 DeviceCardInfo 结构) + +#### Scenario: 查询单卡实时状态 + +- **WHEN** 管理员调用 `GET /api/admin/assets/card/123/realtime-status` +- **THEN** 系统返回该卡的 network_status、real_name_status、current_month_usage_mb、last_sync_at + +#### Scenario: 查询设备实时状态 + +- **WHEN** 管理员调用 `GET /api/admin/assets/device/456/realtime-status` +- **THEN** 系统返回设备的保护期状态及所有绑定卡的当前状态列表 + +#### Scenario: asset_type 参数非法 + +- **WHEN** 管理员调用 `GET /api/admin/assets/unknown-type/123/realtime-status` +- **THEN** 系统返回 HTTP 400 参数错误 + +--- + +### Requirement: 手动刷新接口 + +系统 SHALL 提供手动触发网关同步的接口,用于客服主动刷新资产最新状态。 + +**API 端点**: `POST /api/admin/assets/:asset_type/:id/refresh` + +**行为规则**: +- card 类型:直接调用 `RefreshCardDataFromGateway(iccid)` 同步网络状态、实名状态、本月流量、最后同步时间 +- device 类型:对该设备所有绑定卡遍历调用 `RefreshCardDataFromGateway` + +**设备类型频率限制**: +- 使用 Redis Key `RedisDeviceRefreshCooldownKey(deviceID)` 限频 +- 同一设备 30 秒冷却期内不允许重复触发 +- 冷却期内调用返回 HTTP 429 + +**响应**: +- 刷新完成后返回刷新后的最新状态(与 realtime-status 响应结构相同) + +#### Scenario: 刷新单卡状态 + +- **WHEN** 客服调用 `POST /api/admin/assets/card/123/refresh` +- **THEN** 系统调用 RefreshCardDataFromGateway,更新 DB 中的卡状态字段,返回刷新后的最新状态 + +#### Scenario: 刷新设备状态(首次) + +- **WHEN** 管理员调用 `POST /api/admin/assets/device/456/refresh`,该设备有 3 张绑定卡 +- **THEN** 系统依次刷新 3 张卡,设置 30 秒冷却期,返回最新状态 + +#### Scenario: 设备刷新冷却期内重复触发 + +- **WHEN** 管理员在 30 秒冷却期内第二次调用 `POST /api/admin/assets/device/456/refresh` +- **THEN** 系统返回 HTTP 429,提示"刷新过于频繁,请稍后再试" + +--- + +### Requirement: 套餐历史列表查询 + +系统 SHALL 提供资产的全量套餐记录查询接口,包含历史和当前生效套餐。 + +**API 端点**: `GET /api/admin/assets/:asset_type/:id/packages` + +**排序**: 按 `created_at` 倒序(最新套餐在前) + +**分页**: 不分页,全量返回 + +**范围**: 包含所有状态(含 status=4 已失效的历史套餐) + +**按 asset_type 区分查询**: +- card:查询 `PackageUsage.iot_card_id = :id` +- device:查询 `PackageUsage.device_id = :id` + +**每条记录响应字段**: +- `package_usage_id`: 套餐使用记录 ID +- `package_name`: 套餐名称 +- `package_type`: 套餐类型(formal/addon) +- `master_usage_id`: 主套餐 ID(加油包时有值,主套餐时为 null) +- `real_data_mb`: 真总流量(MB) +- `virtual_data_mb`: 虚总流量/停机阈值(MB) +- `package_used_mb`: 展示已使用流量(经虚流量换算) +- `package_remain_mb`: 展示剩余流量 +- `activated_at`: 生效时间 +- `expires_at`: 过期时间 +- `status`: 套餐状态(0-待生效 1-生效中 2-已用完 3-已过期 4-已失效) + +#### Scenario: 查询卡的套餐历史 + +- **WHEN** 管理员调用 `GET /api/admin/assets/card/123/packages`,该卡有 3 条套餐记录(含 1 条已失效) +- **THEN** 系统返回全部 3 条记录,按创建时间倒序排列 + +#### Scenario: 查询设备的套餐历史 + +- **WHEN** 管理员调用 `GET /api/admin/assets/device/456/packages` +- **THEN** 系统返回该设备 device_id 下的所有套餐记录 + +#### Scenario: 资产无套餐记录 + +- **WHEN** 管理员查询一张从未购买过套餐的卡 +- **THEN** 系统返回空数组,不报错 + +--- + +### Requirement: 当前主套餐详情查询 + +系统 SHALL 提供查询资产当前生效主套餐的接口,用于展示套餐详细信息。 + +**API 端点**: `GET /api/admin/assets/:asset_type/:id/current-package` + +**查询条件**: `status = 1(生效中)AND master_usage_id IS NULL` + +**多套餐同时生效时**:只返回主套餐(master_usage_id IS NULL),不返回加油包 + +**响应字段**: +- 完整套餐信息(同套餐历史列表中的单条记录字段) +- 当无生效主套餐时,返回 HTTP 404 + +#### Scenario: 返回当前主套餐 + +- **WHEN** 管理员调用 `GET /api/admin/assets/card/123/current-package`,该卡有 1 个生效主套餐和 1 个加油包 +- **THEN** 系统只返回主套餐信息,不包含加油包 + +#### Scenario: 无当前生效主套餐 + +- **WHEN** 管理员查询没有生效中主套餐的资产 +- **THEN** 系统返回 HTTP 404 + +--- + +### Requirement: RefreshCardDataFromGateway 完整同步 + +系统 SHALL 提供从 Gateway 完整同步卡数据的方法,替代原 `SyncCardStatusFromGateway`(仅为示例实现)。 + +**方法签名**: `RefreshCardDataFromGateway(ctx context.Context, iccid string) error` + +**同步字段**: +- `network_status`: 网络状态(从网关卡状态映射) +- `real_name_status`: 实名状态(从网关实名接口获取) +- `current_month_usage_mb`: 本月已用流量(从网关流量接口获取) +- `last_sync_time`: 更新为当前时间 + +**错误处理**: 网关调用失败时记录 Error 日志并返回错误,不更新 DB + +#### Scenario: 完整同步卡数据 + +- **WHEN** 调用 `RefreshCardDataFromGateway(ctx, "89860123456789012345")` +- **THEN** 系统调用网关接口,将 network_status、real_name_status、current_month_usage_mb、last_sync_time 写回 DB diff --git a/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/asset-resolve/spec.md b/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/asset-resolve/spec.md new file mode 100644 index 0000000..ab2a71f --- /dev/null +++ b/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/asset-resolve/spec.md @@ -0,0 +1,132 @@ +## ADDED Requirements + +### Requirement: 统一资产解析入口 + +系统 SHALL 提供统一的资产查找接口,通过任意标识符定位卡或设备,并返回该资产的中等聚合信息。 + +**API 端点**: `GET /api/admin/assets/resolve/:identifier` + +**查找顺序**: +1. 先在 `tb_device` 表查找(匹配 `virtual_no = ? OR imei = ? OR sn = ?`) +2. 未命中则在 `tb_iot_card` 表查找(匹配 `virtual_no = ? OR iccid = ? OR msisdn = ?`) +3. 两表均未命中 → 返回 HTTP 404 +4. 找到后应用数据权限过滤,无权限 → 返回 HTTP 403 + +**数据权限规则**: +- 代理用户:只能查看 `shop_id` 在自己及下级店铺范围内的资产 +- 平台用户(SuperAdmin/Platform):可查看所有资产 +- 企业账号:暂不支持此接口,调用时返回 HTTP 403 + +**响应结构(AssetResolveResponse)**: + +*通用字段(device 和 card 均有)*: +- `asset_type`: 资产类型(`"device"` 或 `"card"`) +- `asset_id`: 资产主键 ID +- `virtual_no`: 虚拟号(设备/卡均使用此字段) +- `status`: 资产状态(整型) +- `batch_no`: 批次号 +- `shop_id`: 所属店铺 ID(平台库存时为空) +- `shop_name`: 所属店铺名称 +- `series_id`: 套餐系列 ID(未绑定时为空) +- `series_name`: 套餐系列名称 +- `first_commission_paid`: 一次性佣金是否已发放 +- `accumulated_recharge`: 累计充值金额(分) +- `activated_at`: 激活时间(未激活时为空) +- `created_at`: 创建时间 +- `updated_at`: 更新时间 + +*状态与套餐字段(device 和 card 均有)*: +- `real_name_status`: 实名状态(整型) +- `current_package`: 当前套餐名称(无套餐时返回空字符串) +- `package_total_mb`: 真总流量,即 RealDataMB(无套餐时返回 0) +- `package_virtual_mb`: 虚总流量/停机阈值,即 VirtualDataMB(无套餐时返回 0) +- `package_used_mb`: 客户端展示已使用流量(经虚流量换算,见流量计算规则) +- `package_remain_mb`: 客户端展示剩余流量 +- `device_protect_status`: 保护期状态(`"none"` / `"stop"` / `"start"`);card 类型时若绑定的设备有保护期也返回该设备的保护期状态 + +*绑定关系字段*: +- `iccid`: 仅 card 类型时有值,供前端调用停复机接口使用 +- `bound_device_id`: 仅 card 类型且卡绑定了设备时有值 +- `bound_device_no`: 绑定设备的虚拟号 +- `bound_device_name`: 绑定设备的名称 +- `bound_card_count`: 仅 device 类型时有值,绑定卡的总数量 +- `cards`: 仅 device 类型时有值,所有绑定卡列表(含未实名、已停用) + +*设备专属档案字段(asset_type=device 时有值,card 类型时为空/零值)*: +- `device_name`: 设备名称 +- `imei`: IMEI +- `sn`: 序列号 +- `device_model`: 设备型号 +- `device_type`: 设备类型 +- `max_sim_slots`: 最大插槽数 +- `manufacturer`: 制造商 + +*卡专属档案字段(asset_type=card 时有值,device 类型时为空/零值)*: +- `carrier_id`: 运营商 ID +- `carrier_type`: 运营商类型(CMCC/CUCC/CTCC/CBN) +- `carrier_name`: 运营商名称 +- `msisdn`: 卡接入号 +- `imsi`: IMSI +- `card_category`: 卡业务类型(normal/industry) +- `supplier`: 供应商 +- `activation_status`: 激活状态(0-未激活 1-已激活) +- `enable_polling`: 是否参与轮询 + +**DeviceCardInfo 结构**: +- `iot_card_id`: 卡 ID +- `iccid`: ICCID +- `virtual_no`: 卡的虚拟号 +- `real_name_status`: 实名状态 +- `network_status`: 网络状态 +- `current_month_usage_mb`: 本月已用流量(来自持久化缓存字段) +- `last_sync_at`: 最后与 Gateway 同步时间 + +**流量展示计算规则**: +- `package_used_mb = current_month_usage_mb × virtual_ratio` +- `package_remain_mb = package_total_mb - package_used_mb` +- 当 `enable_virtual_data = false` 时,`virtual_ratio = 1.0`(无换算) +- 设备级套餐:`current_month_usage_mb` 为所有绑定卡本月用量之和 + +**特殊情况处理**: +- 卡绑定的设备已被软删除:视为独立卡,不填充绑定信息 +- `cards` 列表包含所有状态的绑定卡,不过滤未实名或已停用的卡 + +#### Scenario: 通过 ICCID 找到卡 + +- **WHEN** 管理员调用 `GET /api/admin/assets/resolve/89860123456789012345`,ICCID 匹配到一张独立卡 +- **THEN** 系统返回 `asset_type="card"`,包含该卡的虚拟号、状态、套餐流量信息,`bound_device_id` 为空 + +#### Scenario: 通过虚拟号找到设备 + +- **WHEN** 管理员调用 `GET /api/admin/assets/resolve/GPS-001`,设备表中 `virtual_no = "GPS-001"` 存在 +- **THEN** 系统返回 `asset_type="device"`,包含该设备的绑定卡列表(DeviceCardInfo 数组),`bound_card_count` 为绑定卡总数 + +#### Scenario: 标识符同时命中设备和卡(设备优先) + +- **WHEN** `GPS-001` 在 device 表和 iot_card 表均有匹配(virtual_no 相同) +- **THEN** 系统返回设备信息(device 优先),不返回卡信息 + +#### Scenario: 标识符未命中任何资产 + +- **WHEN** 管理员查询不存在的标识符 `UNKNOWN-999` +- **THEN** 系统返回 HTTP 404 + +#### Scenario: 代理用户查询无权限的资产 + +- **WHEN** 代理用户(shop_id=10)查询属于 shop_id=99(非下级)的设备 +- **THEN** 系统返回 HTTP 403,明确提示无权限 + +#### Scenario: 企业账号调用 resolve + +- **WHEN** 企业账号调用 `GET /api/admin/assets/resolve/:identifier` +- **THEN** 系统返回 HTTP 403,提示企业账号暂不支持此接口 + +#### Scenario: 卡绑定了有停机保护期的设备 + +- **WHEN** 管理员通过 ICCID 查询某张卡,该卡绑定的设备当前有 stop 保护期 +- **THEN** 响应中 `device_protect_status = "stop"`,反映所属设备的保护期状态 + +#### Scenario: 设备无当前生效套餐 + +- **WHEN** 管理员查询一台没有购买任何套餐的设备 +- **THEN** `current_package = ""`,`package_total_mb = 0`,`package_used_mb = 0`,`package_remain_mb = 0` diff --git a/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/asset-suspend-resume/spec.md b/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/asset-suspend-resume/spec.md new file mode 100644 index 0000000..4eee83c --- /dev/null +++ b/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/asset-suspend-resume/spec.md @@ -0,0 +1,168 @@ +## ADDED Requirements + +### Requirement: 设备停机接口 + +系统 SHALL 提供设备停机接口,批量停用设备下所有已实名卡,并建立停机保护期。 + +**API 端点**: `POST /api/admin/assets/device/:device_id/stop` + +**执行流程**: +1. 验证设备存在(不存在返回 HTTP 404) +2. 检查设备是否在保护期(`RedisDeviceProtectKey(deviceID, "stop")` 或 `"start"` 存在则返回 HTTP 403) +3. 获取该设备所有已实名(`real_name_status = 1`)的绑定卡 +4. 遍历调用网关停机接口(未实名卡跳过,永远是停机状态) +5. 更新成功停机的卡的 `network_status = 0`,`stopped_at = now()`,`stop_reason = "manual"` +6. 在 Redis 中设置停机保护期:`RedisDeviceProtectKey(deviceID, "stop")`,TTL = 1 小时 +7. 响应:返回成功,附带失败卡列表(如有) + +**保护期说明**: +- 保护期时长:1 小时(常量 `DeviceProtectPeriodDuration = 1 * time.Hour`,定义在 `pkg/constants/`) +- 停机保护期 key:`protect:device:{device_id}:stop` +- 复机保护期 key:`protect:device:{device_id}:start` +- 两个 key 互斥:设置 stop 保护期时删除 start 保护期,反之亦然 + +**批量部分失败策略**: +- 部分卡调网关失败:**仍设置** Redis 保护期(保护期从发起操作时算起) +- 已成功停机的卡**不回滚** +- 失败的卡记录 Error 日志,响应体中携带失败列表 + +#### Scenario: 成功执行设备停机 + +- **WHEN** 管理员调用 `POST /api/admin/assets/device/456/stop`,该设备有 3 张已实名卡 +- **THEN** 系统批量调网关停机,更新 3 张卡 network_status=0,设置 1 小时 stop 保护期,返回成功 + +#### Scenario: 设备存在保护期 + +- **WHEN** 管理员在设备已有 stop 保护期时再次调用停机接口 +- **THEN** 系统返回 HTTP 403,提示"设备处于保护期,不允许操作" + +#### Scenario: 设备下无已实名卡 + +- **WHEN** 管理员对只有未实名卡的设备执行停机 +- **THEN** 系统返回成功(0 张卡操作),设置 stop 保护期 + +#### Scenario: 设备不存在 + +- **WHEN** 管理员调用不存在的设备 ID +- **THEN** 系统返回 HTTP 404 + +#### Scenario: 部分卡停机失败 + +- **WHEN** 设备有 3 张卡,1 张网关调用失败 +- **THEN** 2 张成功停机,1 张失败记录日志,**仍设置** stop 保护期,响应中包含失败卡信息 + +--- + +### Requirement: 设备复机接口 + +系统 SHALL 提供设备复机接口,批量恢复设备下所有已实名卡,并建立复机保护期。 + +**API 端点**: `POST /api/admin/assets/device/:device_id/start` + +**执行流程**: +1. 验证设备存在(不存在返回 HTTP 404) +2. 检查设备是否在保护期(stop 或 start 保护期均存在时返回 HTTP 403) +3. 获取该设备所有已实名(`real_name_status = 1`)的绑定卡 +4. 遍历调用网关复机接口 +5. 更新成功复机的卡的 `network_status = 1`,`resumed_at = now()` +6. 设置复机保护期:`RedisDeviceProtectKey(deviceID, "start")`,TTL = 1 小时 +7. 响应:返回成功 + +#### Scenario: 成功执行设备复机 + +- **WHEN** 管理员调用 `POST /api/admin/assets/device/456/start`,该设备有 2 张已实名卡 +- **THEN** 系统批量复机,更新卡状态,设置 1 小时 start 保护期,返回成功 + +#### Scenario: 设备在 start 保护期内再次复机 + +- **WHEN** 设备已有 start 保护期时再次调用复机接口 +- **THEN** 系统返回 HTTP 403,提示"设备处于保护期,不允许操作" + +--- + +### Requirement: 卡停机接口 + +系统 SHALL 提供单卡停机接口,含保护期感知逻辑。 + +**API 端点**: `POST /api/admin/assets/card/:iccid/stop` + +**执行流程**: +1. 通过 ICCID 查找卡(不存在返回 HTTP 404) +2. 检查卡是否已实名(`real_name_status = 0` 时返回 HTTP 403:未实名卡不允许停复机) +3. 若卡绑定了设备,检查该设备的保护期: + - 设备有 **stop 保护期**:允许停机(本已是停机方向,无冲突) + - 设备有 **start 保护期**:允许停机(用户可主动停单张卡) + - 设备无保护期:正常执行 +4. 调用网关停机接口 +5. 更新卡 `network_status = 0`,`stopped_at = now()`,`stop_reason = "manual"` + +#### Scenario: 独立卡(未绑定设备)停机 + +- **WHEN** 管理员对一张未绑定设备的已实名卡执行停机 +- **THEN** 系统正常调网关停机,更新卡状态 + +#### Scenario: 绑定设备且设备在 start 保护期内停机 + +- **WHEN** 管理员对绑定了设备且设备有 start 保护期的卡执行停机 +- **THEN** 系统允许执行(用户主动停单张卡不违反 start 保护期),正常停机 + +#### Scenario: 对未实名卡执行停机 + +- **WHEN** 管理员对 real_name_status=0(未实名)的卡执行停机 +- **THEN** 系统返回 HTTP 403,提示"未实名卡不允许停复机操作" + +--- + +### Requirement: 卡复机接口 + +系统 SHALL 提供单卡复机接口,含保护期感知逻辑。 + +**API 端点**: `POST /api/admin/assets/card/:iccid/start` + +**执行流程**: +1. 通过 ICCID 查找卡(不存在返回 HTTP 404) +2. 检查卡是否已实名(`real_name_status = 0` 时返回 HTTP 403) +3. 若卡绑定了设备,检查该设备的保护期: + - 设备有 **stop 保护期**:**不允许**手动复机,返回 HTTP 403(设备处于停机保护期) + - 设备有 **start 保护期**:允许复机(本已是复机方向,无冲突) + - 设备无保护期:正常执行 +4. 调用网关复机接口 +5. 更新卡 `network_status = 1`,`resumed_at = now()`,清空 `stop_reason` + +#### Scenario: 独立卡(未绑定设备)复机 + +- **WHEN** 管理员对一张未绑定设备的已实名停机卡执行复机 +- **THEN** 系统正常调网关复机,更新卡状态 + +#### Scenario: 设备处于 stop 保护期时尝试复机 + +- **WHEN** 管理员对绑定了设备且设备有 stop 保护期的卡执行复机 +- **THEN** 系统返回 HTTP 403,提示"设备处于停机保护期,不允许手动复机" + +#### Scenario: 设备在 start 保护期内复机 + +- **WHEN** 管理员对绑定了设备且设备有 start 保护期的卡执行复机 +- **THEN** 系统允许执行(本已是复机方向),正常复机 + +#### Scenario: 对未实名卡执行复机 + +- **WHEN** 管理员对 real_name_status=0 的卡执行复机 +- **THEN** 系统返回 HTTP 403,提示"未实名卡不允许停复机操作" + +--- + +### Requirement: 废弃旧停复机接口 + +系统 SHALL 删除以下重复的停复机接口,统一使用新的 `/api/admin/assets/` 路径。 + +**待删除接口**: +- `POST /api/admin/enterprises/:id/cards/:card_id/suspend` +- `POST /api/admin/enterprises/:id/cards/:card_id/resume` +- `POST /h5/devices/:device_id/cards/:card_id/suspend` +- `POST /h5/devices/:device_id/cards/:card_id/resume` +- 旧 Admin 卡停复机接口(`POST /iot-cards/:iccid/suspend|resume`) + +#### Scenario: 调用已删除的旧接口 + +- **WHEN** 前端调用 `POST /api/admin/enterprises/:id/cards/:card_id/suspend` +- **THEN** 系统返回 HTTP 404(路由已不存在) diff --git a/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/device/spec.md b/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/device/spec.md new file mode 100644 index 0000000..444a77c --- /dev/null +++ b/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/device/spec.md @@ -0,0 +1,133 @@ +## MODIFIED Requirements + +### Requirement: 设备列表查询 + +系统 SHALL 提供设备列表查询功能,支持多维度筛选和分页。 + +**查询条件**: +- `virtual_no`(可选): 设备虚拟号,支持模糊匹配(原 `device_no` 字段,已全量改名) +- `device_name`(可选): 设备名称,支持模糊匹配 +- `status`(可选): 设备状态,枚举值 1-在库 | 2-已分销 | 3-已激活 | 4-已停用 +- `shop_id`(可选): 店铺 ID,NULL 表示平台库存 +- `batch_no`(可选): 批次号,精确匹配 +- `device_type`(可选): 设备类型 +- `manufacturer`(可选): 制造商,支持模糊匹配 +- `created_at_start`(可选): 创建时间起始 +- `created_at_end`(可选): 创建时间结束 + +**分页**: +- 默认每页 20 条,最大每页 100 条 +- 返回总记录数和总页数 + +**数据权限**: +- 平台用户可查看所有设备 +- 代理用户只能查看自己店铺及下级店铺的设备 + +**API 端点**: `GET /api/admin/devices` + +**响应字段**: +- `id`: 设备 ID +- `virtual_no`: 设备虚拟号(原 `device_no`,已改名) +- `device_name`: 设备名称 +- `device_model`: 设备型号 +- `device_type`: 设备类型 +- `max_sim_slots`: 最大插槽数 +- `manufacturer`: 制造商 +- `batch_no`: 批次号 +- `shop_id`: 店铺 ID +- `shop_name`: 店铺名称 +- `status`: 状态 +- `status_name`: 状态名称 +- `bound_card_count`: 已绑定卡数量 +- `activated_at`: 激活时间 +- `created_at`: 创建时间 +- `updated_at`: 更新时间 + +#### Scenario: 平台查询所有设备 + +- **WHEN** 平台管理员查询设备列表,不带任何筛选条件 +- **THEN** 系统返回所有设备,按创建时间倒序排列 + +#### Scenario: 按虚拟号模糊查询 + +- **WHEN** 管理员输入 virtual_no = "GPS" +- **THEN** 系统返回虚拟号包含 "GPS" 的所有设备 + +#### Scenario: 按状态筛选设备 + +- **WHEN** 管理员查询状态为 1(在库)的设备 +- **THEN** 系统只返回在库状态的设备 + +#### Scenario: 代理查询自己店铺的设备 + +- **WHEN** 代理用户(店铺 ID=10)查询设备列表 +- **THEN** 系统只返回 shop_id 为 10 及其下级店铺的设备 + +#### Scenario: 查询平台库存设备 + +- **WHEN** 平台管理员查询 shop_id 为空的设备 +- **THEN** 系统返回所有平台库存设备(shop_id = NULL) + +--- + +### Requirement: 设备详情查询 + +系统 SHALL 提供设备详情查询功能,返回设备的基本信息。 + +**API 端点**: `GET /api/admin/devices/:id` + +**响应字段**: +- 包含设备的所有基本字段(含 `virtual_no`,不再有 `device_no`) +- `shop_name`: 店铺名称(如果有) + +**数据权限**: +- 平台用户可查看所有设备 +- 代理用户只能查看自己店铺及下级店铺的设备 + +#### Scenario: 查询设备详情成功 + +- **WHEN** 管理员查询设备详情(ID=1) +- **THEN** 系统返回该设备的完整基本信息,响应中含 `virtual_no` 字段,不含 `device_no` + +#### Scenario: 查询不存在的设备 + +- **WHEN** 管理员查询不存在的设备(ID=999) +- **THEN** 系统返回 404 错误,提示"设备不存在" + +#### Scenario: 代理查询无权限的设备 + +- **WHEN** 代理用户(店铺 ID=10)查询其他店铺的设备(shop_id=20,非下级) +- **THEN** 系统返回 404 错误,提示"设备不存在" + +## ADDED Requirements + +### Requirement: device_no 全量改名为 virtual_no + +系统 SHALL 将 `tb_device` 表和 `tb_personal_customer_device` 表中的 `device_no` 字段全量改名为 `virtual_no`,确保系统中不再有 `device_no` 的存在。 + +**数据库变更**: +```sql +ALTER TABLE tb_device RENAME COLUMN device_no TO virtual_no; +ALTER TABLE tb_personal_customer_device RENAME COLUMN device_no TO virtual_no; +``` + +**代码影响范围**: +- `internal/model/device.go`:`DeviceNo` → `VirtualNo`,column tag 更新 +- `internal/model/personal_customer_device.go`:`DeviceNo` → `VirtualNo`,column tag 更新 +- `internal/model/dto/device_dto.go`:`DeviceResponse.DeviceNo` → `VirtualNo`,JSON tag 更新为 `"virtual_no"` +- `internal/store/postgres/device_store.go`:`GetByIdentifier` 查询条件中 `device_no` → `virtual_no` +- `internal/store/postgres/personal_customer_device_store.go`:所有 `device_no` 引用更新 +- 所有 Handler、Service 中引用 `DeviceNo` 字段的代码全量替换 + +**设备导入模板**: +- 导入 Excel 模板中的列头从 `device_no` 更新为 `virtual_no` + +#### Scenario: 改名后查询设备 + +- **WHEN** 改名迁移完成后,调用 `GetByIdentifier("GPS-001")` +- **THEN** 系统在 `WHERE virtual_no = ? OR imei = ? OR sn = ?` 中正确匹配,与改名前行为一致 + +#### Scenario: 响应中字段名已更新 + +- **WHEN** 前端调用设备列表或详情接口 +- **THEN** 响应 JSON 中 key 为 `virtual_no`,不再有 `device_no` diff --git a/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/iot-card-import-task/spec.md b/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/iot-card-import-task/spec.md new file mode 100644 index 0000000..7c91218 --- /dev/null +++ b/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/iot-card-import-task/spec.md @@ -0,0 +1,81 @@ +## MODIFIED Requirements + +### Requirement: Excel 文件格式规范 + +系统 SHALL 要求 Excel 文件必须包含 ICCID 和 MSISDN 两列,并支持可选的 `virtual_no` 列。 + +**文件格式要求**: +- **文件格式**: 仅支持 `.xlsx` (Excel 2007+) +- **Sheet**: 读取第一个sheet,或优先读取名为"导入数据"的sheet +- **表头行**: 第1行(可选,但建议包含) +- **表头识别关键字**: + - ICCID 列: iccid/ICCID/卡号/号码 + - MSISDN 列: msisdn/MSISDN/接入号/手机号/电话/号码 + - virtual_no 列(新增,可选): virtual_no/VirtualNo/虚拟号/设备号 +- **列数要求**: 至少 2 列(ICCID 和 MSISDN),virtual_no 为可选第三列 +- **列格式**: 应设置为文本格式(避免长数字被转为科学记数法) + +**解析规则**: +- 自动检测表头(第1行包含识别关键字则跳过) +- 自动去除单元格首尾空格 +- 跳过空行 +- ICCID 为空的行记录为失败 +- MSISDN 为空的行记录为失败 +- virtual_no 为空的行:跳过该列(不填入,保留原值) + +**virtual_no 导入规则(只补空白)**: +- 该行 virtual_no 不为空 + 数据库当前值为 NULL:填入新值 +- 该行 virtual_no 不为空 + 数据库当前值已有值:跳过(不覆盖) +- **批次级唯一性检查**:在执行导入前,先检查整批中所有非空 virtual_no 是否与数据库现存值重复;有任意冲突则**整批失败**,响应中返回冲突的 virtual_no 及行号列表 + +**示例 Excel 内容**: +``` +| ICCID | MSISDN | 虚拟号 | +|----------------------|-------------|-----------| +| 89860012345678901234 | 13800000001 | CARD-001 | +| 89860012345678901235 | 13800000002 | | +``` + +#### Scenario: 解析标准双列 Excel 文件(无 virtual_no 列) + +- **WHEN** Excel 文件只含 ICCID 和 MSISDN 两列,无虚拟号列 +- **THEN** 解析结果包含 2 条有效记录,virtual_no 字段为空,不影响导入逻辑 + +#### Scenario: 解析含 virtual_no 列的三列 Excel + +- **WHEN** Excel 文件含 ICCID、MSISDN、虚拟号三列,某行 virtual_no = "CARD-001",对应卡当前 virtual_no 为 NULL +- **THEN** 解析后该卡的 virtual_no 填入 "CARD-001" + +#### Scenario: virtual_no 已有值时不覆盖 + +- **WHEN** Excel 中某行 virtual_no = "CARD-NEW",但该卡数据库中已有 virtual_no = "CARD-OLD" +- **THEN** 该卡的 virtual_no 保持 "CARD-OLD" 不变,该行跳过(不报错,不计入失败) + +#### Scenario: 批次中有 virtual_no 与现存数据重复 + +- **WHEN** Excel 中某行 virtual_no = "CARD-001",但数据库中另一张卡已有 virtual_no = "CARD-001" +- **THEN** 系统拒绝整批导入,响应返回冲突的 virtual_no 值和行号,提示"虚拟号重复,整批导入已终止" + +#### Scenario: 支持中文表头 + +- **GIVEN** Excel 文件表头为 `卡号 | 接入号 | 虚拟号` +- **WHEN** 系统解析该 Excel 文件 +- **THEN** 系统正确识别三列,按规则处理 virtual_no + +#### Scenario: 拒绝非Excel格式文件 + +- **GIVEN** 上传文件扩展名为 .csv +- **WHEN** 系统尝试解析该文件 +- **THEN** 系统返回错误"不支持的文件格式 .csv,请上传Excel文件(.xlsx)" + +#### Scenario: MSISDN 为空的行记录失败 + +- **GIVEN** Excel 文件第二行 MSISDN 为空 +- **WHEN** 系统解析该 Excel 文件 +- **THEN** 第一条记录解析成功,第二条记录标记为失败,原因为"MSISDN 不能为空" + +#### Scenario: 长数字无损解析 + +- **GIVEN** Excel 文件中 ICCID 列设置为文本格式,包含 20 位数字 "89860012345678901234" +- **WHEN** 系统解析该 Excel 文件 +- **THEN** ICCID 完整保留为 "89860012345678901234",无精度损失,无科学记数法 diff --git a/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/iot-card/spec.md b/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/iot-card/spec.md new file mode 100644 index 0000000..ddb7bc6 --- /dev/null +++ b/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/iot-card/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: IoT 卡虚拟号字段 + +系统 SHALL 在 `tb_iot_card` 表新增 `virtual_no` 字段,与设备的虚拟号概念对等,供客服和客户通过统一虚拟号查找资产。 + +**字段定义**: +- 字段名:`virtual_no`(VARCHAR(50),可空) +- 全局唯一索引:`CREATE UNIQUE INDEX idx_iot_card_virtual_no ON tb_iot_card (virtual_no) WHERE deleted_at IS NULL` +- 老数据:`virtual_no` 为 NULL(已有卡不强制要求有虚拟号) +- 允许手动修改 + +**唯一性规则**: +- 在所有未软删除的卡中唯一(部分索引,deleted_at IS NULL) +- 导入时与数据库现存数据重复则整批失败,响应中包含冲突的具体 virtual_no 列表 + +**虚拟号的使用场景**: +- resolve 接口:支持通过 virtual_no 查找卡 +- 客服工单:客服将虚拟号告知客户,客户通过虚拟号自助查询 + +#### Scenario: 为卡设置唯一虚拟号 + +- **WHEN** 管理员为 ICCID 为 "898601234..." 的卡设置 virtual_no = "CARD-001" +- **THEN** 系统保存成功,`idx_iot_card_virtual_no` 确保全局唯一 + +#### Scenario: 导入批次中有重复虚拟号 + +- **WHEN** ICCID 导入批次中,有 1 条记录的 virtual_no 与数据库现存卡的 virtual_no 重复 +- **THEN** 系统拒绝整批导入,响应中返回冲突的 virtual_no 及所属行号 + +#### Scenario: virtual_no 为空的老卡 + +- **WHEN** 系统中有历史导入的卡,没有 virtual_no +- **THEN** 这些卡的 virtual_no = NULL,不影响唯一索引(部分索引跳过 NULL 值) diff --git a/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/iot-package/spec.md b/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/iot-package/spec.md new file mode 100644 index 0000000..0b77c42 --- /dev/null +++ b/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/iot-package/spec.md @@ -0,0 +1,63 @@ +## MODIFIED Requirements + +### Requirement: 套餐实体定义 + +系统 SHALL 定义套餐(Package)实体,包含套餐的基本属性、定价、流量配置,以及用于客户端展示流量换算的 `virtual_ratio` 字段。 + +**核心概念**: 套餐只适用于 IoT 卡(ICCID),用户可以为单张 IoT 卡购买套餐,也可以为设备购买套餐(套餐分配到设备绑定的所有 IoT 卡,流量设备级共享)。 + +**实体字段**: +- `id`: 套餐 ID(主键,BIGINT) +- `package_code`: 套餐编码(VARCHAR(50),唯一) +- `package_name`: 套餐名称(VARCHAR(255)) +- `series_id`: 套餐系列 ID(BIGINT,关联 package_series 表,用于组织套餐分组和配置一次性分佣) +- `package_type`: 套餐类型(VARCHAR(20),"formal"-正式套餐 | "addon"-加油包) +- `duration_months`: 套餐时长(INT,月数,1-月套餐 12-年套餐,加油包为 0) +- `real_data_mb`: 真流量额度(BIGINT,MB 为单位,套餐标称总流量) +- `virtual_data_mb`: 虚流量额度(BIGINT,MB 为单位,停机阈值,始终小于或等于真流量) +- `data_amount_mb`: 总流量额度(BIGINT,MB 为单位,real_data_mb + virtual_data_mb) +- `virtual_ratio`: 虚流量换算比例(DECIMAL(10,6),套餐创建时计算并存储,用于客户端展示) +- `enable_virtual_data`: 是否启用虚流量(BOOLEAN,false 时 virtual_ratio=1.0) +- `price`: 套餐价格(DECIMAL(10,2),元) +- `status`: 套餐状态(INT,1-上架 2-下架) +- `created_at`: 创建时间(TIMESTAMP,自动填充) +- `updated_at`: 更新时间(TIMESTAMP,自动填充) + +**virtual_ratio 计算规则**: +- `enable_virtual_data = true` 且 `virtual_data_mb > 0`:`virtual_ratio = real_data_mb / virtual_data_mb` +- 其他情况(未启用虚流量):`virtual_ratio = 1.0` +- 套餐创建或更新时由 Service 层自动计算并存储,不由调用方传入 + +**virtual_ratio 使用场景**(展示换算): +- `展示已使用 = 真已使用 × virtual_ratio` +- `展示剩余 = real_data_mb - 展示已使用` +- 目的:当真用量达到停机阈值(virtual_data_mb)时,客户看到的展示用量恰好等于 real_data_mb(100% 已使用) + +**套餐类型说明**: +- **正式套餐(formal)**: 每张 IoT 卡只能有一个有效的正式套餐,购买新的正式套餐会替换旧的 +- **加油包(addon)**: 每张 IoT 卡可以购买多个加油包,与正式套餐共存 + +#### Scenario: 创建月套餐(未启用虚流量) + +- **WHEN** 平台创建月套餐,套餐编码为 "PKG-M-001",`enable_virtual_data = false`,`real_data_mb = 10240` +- **THEN** 系统创建套餐记录,`virtual_ratio = 1.0`(未启用虚流量时无换算) + +#### Scenario: 创建启用虚流量的套餐 + +- **WHEN** 平台创建套餐,`enable_virtual_data = true`,`real_data_mb = 10240`(10G),`virtual_data_mb = 9216`(9G) +- **THEN** 系统自动计算并存储 `virtual_ratio = 10240 / 9216 ≈ 1.111111` + +#### Scenario: 展示流量换算正确 + +- **WHEN** 客户的卡真已使用 = 9216 MB(已达停机阈值),`real_data_mb = 10240`,`virtual_ratio = 1.111111` +- **THEN** 展示已使用 = 9216 × 1.111111 ≈ 10240 MB,展示剩余 = 0 MB,客户看到"已用 10G / 共 10G" + +#### Scenario: 创建年套餐 + +- **WHEN** 平台创建年套餐,套餐编码为 "PKG-Y-001",套餐名称为 "年套餐 120GB",套餐系列 ID 为 1,类型为正式套餐,时长为 12 个月,真流量为 122880 MB,虚流量为 0,价格为 300.00 元 +- **THEN** 系统创建套餐记录,`package_code` 为 "PKG-Y-001",`series_id` 为 1,`package_type` 为 "formal",`duration_months` 为 12,`real_data_mb` 为 122880,`virtual_data_mb` 为 0,`data_amount_mb` 为 122880,`price` 为 300.00,`virtual_ratio` 为 1.0 + +#### Scenario: 创建流量加油包 + +- **WHEN** 平台创建加油包,套餐编码为 "PKG-ADD-001",套餐名称为 "流量包 5GB",套餐系列 ID 为 2,类型为加油包,时长为 0,真流量为 5120 MB,虚流量为 0,价格为 10.00 元 +- **THEN** 系统创建套餐记录,`package_code` 为 "PKG-ADD-001",`series_id` 为 2,`package_type` 为 "addon",`duration_months` 为 0,`real_data_mb` 为 5120,`virtual_data_mb` 为 0,`data_amount_mb` 为 5120,`price` 为 10.00,`virtual_ratio` 为 1.0 diff --git a/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/polling-protect-consistency/spec.md b/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/polling-protect-consistency/spec.md new file mode 100644 index 0000000..6f9a3ac --- /dev/null +++ b/openspec/changes/archive/2026-03-14-asset-detail-refactor/specs/polling-protect-consistency/spec.md @@ -0,0 +1,46 @@ +## ADDED Requirements + +### Requirement: 保护期一致性检查轮询任务 + +系统 SHALL 新增第四种轮询任务类型(保护期一致性检查),作为独立任务处理器,不修改现有三种任务(实名检查/流量检查/套餐检查)的内部逻辑。 + +**任务类型标识**: `protect`(与现有 `realname`、`carddata`、`package` 并列) + +**Redis 队列 Key**: `RedisPollingQueueProtectKey()` → `"polling:queue:protect"` + +**触发频率**: 与流量检查任务同频(默认 10 分钟) + +**任务范围**: 仅检查"已绑定设备且设备当前有保护期"的卡,范围小,不会对未绑定设备的卡产生影响 + +**处理逻辑**: +1. 检查卡是否已实名(`real_name_status = 0` 则跳过,未实名卡不参与保护期逻辑) +2. 检查卡是否绑定设备(`is_standalone = true` 则跳过) +3. 读取设备保护期 Redis Key +4. 若设备有 **stop 保护期**,且卡当前网络状态为**开机**:强制调网关停机,更新卡 `network_status = 0` +5. 若设备有 **start 保护期**,且卡当前网络状态为**停机**:强制调网关复机,更新卡 `network_status = 1` +6. 状态已一致(开机 + stop 保护期已停 / 停机 + start 保护期已开):跳过 + +#### Scenario: stop 保护期内卡状态异常(开机) + +- **WHEN** 轮询任务检查一张已实名卡,发现绑定设备有 stop 保护期,但卡当前 network_status=1(开机) +- **THEN** 任务强制调网关停机,更新卡 network_status=0,记录 Info 日志 + +#### Scenario: start 保护期内卡状态异常(停机) + +- **WHEN** 轮询任务检查一张已实名卡,发现绑定设备有 start 保护期,但卡当前 network_status=0(停机) +- **THEN** 任务强制调网关复机,更新卡 network_status=1,记录 Info 日志 + +#### Scenario: 状态已一致,跳过 + +- **WHEN** 轮询任务检查一张卡,设备有 stop 保护期,卡已是停机状态 +- **THEN** 任务跳过,不调网关,不更新 DB + +#### Scenario: 未实名卡跳过保护期逻辑 + +- **WHEN** 轮询任务遇到 real_name_status=0 的卡 +- **THEN** 任务直接跳过,不检查保护期,不调网关 + +#### Scenario: 独立卡(未绑定设备)跳过 + +- **WHEN** 轮询任务遇到 is_standalone=true 的卡 +- **THEN** 任务直接跳过,不查询设备保护期 diff --git a/openspec/changes/archive/2026-03-14-asset-detail-refactor/tasks.md b/openspec/changes/archive/2026-03-14-asset-detail-refactor/tasks.md new file mode 100644 index 0000000..a8f2f07 --- /dev/null +++ b/openspec/changes/archive/2026-03-14-asset-detail-refactor/tasks.md @@ -0,0 +1,107 @@ +## 1. 数据库迁移(先行) + +- [x] 1.1 创建迁移文件:`tb_device.device_no` → `virtual_no`,`tb_personal_customer_device.device_no` → `virtual_no`(两张表在同一个迁移文件中完成) +- [x] 1.2 创建迁移文件:`tb_iot_card` 新增 `virtual_no VARCHAR(50)` 字段,创建部分唯一索引 `idx_iot_card_virtual_no`(`WHERE deleted_at IS NULL`) +- [x] 1.3 创建迁移文件:`tb_package` 新增 `virtual_ratio DECIMAL(10,6) DEFAULT 1.0` 字段,回填现有数据(根据 `enable_virtual_data` 和 `real_data_mb / virtual_data_mb` 计算) +- [x] 1.4 执行全部迁移,验证三张表结构变更成功(PostgreSQL MCP 确认) + +## 2. 数据模型更新(device_no 全量改名) + +- [x] 2.1 更新 `internal/model/device.go`:`DeviceNo` → `VirtualNo`,GORM column tag 从 `device_no` 改为 `virtual_no`,更新注释 +- [x] 2.2 更新 `internal/model/personal_customer_device.go`:`DeviceNo` → `VirtualNo`,column tag 同步更新 +- [x] 2.3 更新 `internal/model/dto/device_dto.go`:`DeviceResponse.DeviceNo` → `VirtualNo`,JSON tag 从 `"device_no"` 改为 `"virtual_no"`;`ListDeviceRequest.DeviceNo` → `VirtualNo`,query tag 同步更新;`AllocationDeviceFailedItem.DeviceNo` → `VirtualNo`;`DeviceSeriesBindngFailedItem.DeviceNo` → `VirtualNo` +- [x] 2.4 全量搜索代码中所有 `.DeviceNo` 引用(Store/Service/Handler 层),逐一替换为 `.VirtualNo`(使用 lsp_rename 保证全量覆盖) +- [x] 2.5 运行 `go build ./...` 确认无编译错误,运行 `lsp_diagnostics` 确认无类型错误 + +## 3. IotCard 模型更新(新增 virtual_no 字段) + +- [x] 3.1 更新 `internal/model/iot_card.go`:新增 `VirtualNo string` 字段,GORM tag 包含 `column:virtual_no; type:varchar(50); uniqueIndex:idx_iot_card_virtual_no,where:deleted_at IS NULL` 注释 +- [x] 3.2 运行 `lsp_diagnostics` 确认模型字段无错误 + +## 4. Package 模型更新(新增 virtual_ratio 字段) + +- [x] 4.1 更新 `internal/model/package.go`:`Package` 结构体新增 `VirtualRatio float64` 字段,GORM tag `column:virtual_ratio; type:decimal(10,6); default:1.0` +- [x] 4.2 更新 `internal/service/package/service.go`:在套餐创建和更新逻辑中自动计算并存储 `virtual_ratio`(`enable_virtual_data=true` 且 `virtual_data_mb>0` 时 = `real_data_mb/virtual_data_mb`,否则 = 1.0) +- [x] 4.3 运行 `lsp_diagnostics` 确认无错误 + +## 5. Redis 常量新增 + +- [x] 5.1 在 `pkg/constants/redis.go` 新增 `RedisDeviceProtectKey(deviceID uint, action string) string`(格式:`protect:device:{id}:{action}`,TTL 注释:1 小时) +- [x] 5.2 在 `pkg/constants/redis.go` 新增 `RedisDeviceRefreshCooldownKey(deviceID uint) string`(格式:`refresh:cooldown:device:{id}`,TTL 注释:冷却时长,建议 30 秒) +- [x] 5.3 在 `pkg/constants/redis.go` 新增 `RedisPollingQueueProtectKey() string`(格式:`polling:queue:protect`) +- [x] 5.4 在 `pkg/constants/` 新增设备保护期时长常量 `DeviceProtectPeriodDuration = 1 * time.Hour`,设备刷新冷却时长常量 `DeviceRefreshCooldownDuration = 30 * time.Second` + +## 6. RefreshCardDataFromGateway 方法增强 + +- [x] 6.1 在 `internal/service/iot_card/service.go` 中将 `SyncCardStatusFromGateway` 改名为 `RefreshCardDataFromGateway` +- [x] 6.2 增强方法实现:调用网关查询卡状态(网络状态)、实名状态、本月流量用量,将结果写回 DB:更新 `network_status`、`real_name_status`、`current_month_usage_mb`、`last_sync_time`(参考 polling_handler.go 中的完整同步逻辑) +- [x] 6.3 更新所有调用 `SyncCardStatusFromGateway` 的地方改为 `RefreshCardDataFromGateway`(全局搜索替换) +- [x] 6.4 运行 `go build ./...` 确认无编译错误 + +## 7. 新建 AssetService + +- [x] 7.1 创建 `internal/service/asset/service.go`,定义 `Service` 结构体,依赖注入:DeviceStore、IotCardStore、PackageUsageStore、PackageStore、Redis(通过现有 bootstrap 体系接入) +- [x] 7.2 实现 `Resolve(ctx, identifier string) (*dto.AssetResolveResponse, error)`:先查设备(virtual_no/imei/sn),再查卡(virtual_no/iccid/msisdn),应用数据权限过滤,聚合套餐流量(含 virtual_ratio 换算)、保护期状态、绑定信息 +- [x] 7.3 实现 `GetRealtimeStatus(ctx, assetType string, id uint) (*dto.AssetRealtimeStatusResponse, error)`:仅读 DB/Redis 持久化数据,不调网关;card 返回网络状态/实名/流量/最后同步;device 返回保护期状态+所有绑定卡状态 +- [x] 7.4 实现 `Refresh(ctx, assetType string, id uint) (*dto.AssetRealtimeStatusResponse, error)`:card 调 `RefreshCardDataFromGateway`;device 检查 Redis 冷却期(429),可刷新则遍历绑定卡调 `RefreshCardDataFromGateway`,设置冷却 Key +- [x] 7.5 实现 `GetPackages(ctx, assetType string, id uint) ([]*dto.AssetPackageResponse, error)`:按 asset_type 查 PackageUsage(card→iot_card_id,device→device_id),全量返回按创建时间倒序,含 virtual_ratio 展示换算 +- [x] 7.6 实现 `GetCurrentPackage(ctx, assetType string, id uint) (*dto.AssetPackageResponse, error)`:查 status=1 且 master_usage_id IS NULL 的主套餐,无则返回 ErrNotFound + +## 8. 设备停复机 Service + +- [x] 8.1 在 `internal/service/device/service.go` 实现 `StopDevice(ctx, deviceID uint) (*dto.DeviceSuspendResponse, error)`:验证设备存在、检查保护期、获取已实名绑定卡、批量调网关停机、更新卡状态、设置 Redis stop 保护期(部分失败时仍设置) +- [x] 8.2 实现 `StartDevice(ctx, deviceID uint) error`:验证设备存在、检查保护期、获取已实名绑定卡、批量调网关复机、更新卡状态、设置 Redis start 保护期 + +## 9. 卡停复机 Service 扩展 + +- [x] 9.1 在 `internal/service/iot_card/stop_resume_service.go` 实现 `ManualStopCard(ctx, iccid string) error`:通过 ICCID 查卡、验证已实名、检查绑定设备的保护期(stop 保护期允许、start 保护期允许)、调网关停机、更新卡状态 +- [x] 9.2 实现 `ManualStartCard(ctx, iccid string) error`:通过 ICCID 查卡、验证已实名、检查绑定设备的保护期(stop 保护期→拒绝 403、start 保护期→允许)、调网关复机、更新卡状态 + +## 10. DTO 新增 + +- [x] 10.1 在 `internal/model/dto/` 新增 `asset_dto.go`,定义以下 DTO(含所有字段及 description tag): + - `AssetResolveResponse`、`BoundCardInfo`、`AssetRealtimeStatusResponse`、`AssetPackageResponse` +- [x] 10.2 `DeviceSuspendResponse`(成功信息 + 失败卡列表,已在 device_dto.go 中) + +## 11. 新建 AssetHandler 和路由注册 + +- [x] 11.1 创建 `internal/handler/admin/asset.go`,定义 `AssetHandler` 结构体和 9 个 Handler 方法(Resolve、RealtimeStatus、Refresh、Packages、CurrentPackage、StopDevice、StartDevice、StopCard、StartCard) +- [x] 11.2 创建 `internal/routes/asset.go` 注册 `/api/admin/assets/*` 路由(9 个端点);企业账号访问 resolve 时在 Handler 层检查 user_type 返回 403 +- [x] 11.3 更新 `internal/bootstrap/types.go`、`services.go`、`handlers.go`,将 Asset、StopResumeService 加入 bootstrap 体系;更新 docs 文件 + +## 12. 轮询系统新增保护期一致性检查任务 + +- [x] 12.1 `RedisPollingQueueProtectKey()` 已存在(Task 5.3) +- [x] 12.2 在 `internal/task/polling_handler.go` 新增 `HandleProtectConsistencyCheck`:检查 is_standalone、real_name_status、设备保护期 Redis Key,按规则强制同步网络状态 +- [x] 12.3 在 `pkg/constants/constants.go` 添加 `TaskTypePollingProtect`,在 `pkg/queue/handler.go` 注册处理器 + +## 13. 卡 ICCID 导入支持 virtual_no 列 + +- [x] 13.1 `pkg/utils/excel.go` 新增 virtual_no 列识别(关键字:virtual_no/VirtualNo/虚拟号/设备号) +- [x] 13.2 `internal/task/iot_card_import.go` 实现 virtual_no 唯一性校验和写入逻辑;`IotCardStore` 新增 `ExistsByVirtualNoBatch` + +## 14. 删除废弃接口 + +### 14a. 废弃停复机接口 + +- [x] 14.1 删除 `internal/handler/admin/enterprise_card.go` 中的 `SuspendCard` 和 `ResumeCard` Handler +- [x] 14.2 删除 `internal/handler/h5/enterprise_device.go` 中的 `SuspendCard` 和 `ResumeCard` Handler +- [x] 14.3 删除 `internal/handler/admin/iot_card.go` 中的 `StopCard` 和 `StartCard` Handler +- [x] 14.4 清理对应路由注册,`go build ./...` 通过 + +### 14b. 废弃详情查询和网关直查接口 + +- [x] 14.5 删除 `internal/handler/admin/iot_card.go` 中的 `GetByICCID` Handler +- [x] 14.6 删除 `internal/handler/admin/iot_card.go` 中的 `GetGatewayStatus`、`GetGatewayFlow`、`GetGatewayRealname` 三个 Handler +- [x] 14.7 删除 `internal/handler/admin/device.go` 中的 `GetByID` Handler +- [x] 14.8 删除 `internal/handler/admin/device.go` 中的 `GetByIdentifier` Handler +- [x] 14.9 删除 `internal/handler/admin/device.go` 中的 `GetGatewayInfo` Handler +- [x] 14.10 清理对应路由注册,`go build ./...` 通过 + +## 15. 文档和最终验收 + +- [x] 15.1 更新 API 文档生成器,运行 `go run cmd/gendocs/main.go` 确认 9 个新接口出现在文档中 +- [x] 15.2 使用 PostgreSQL MCP 验证三张表结构变更正确(tb_device.virtual_no 唯一索引、tb_iot_card.virtual_no 条件唯一索引、tb_package.virtual_ratio 字段 NOT NULL DEFAULT 1.0) +- [x] 15.3 使用 PostgreSQL MCP 验证 Package 数据回填正确(enable_virtual_data=true 的套餐 virtual_ratio=930.9,非 1.0) +- [x] 15.4 运行 `go build ./...` 全量检查无编译错误 +- [x] 15.5 tasks.md 全部任务标记完成 diff --git a/openspec/specs/asset-queries/spec.md b/openspec/specs/asset-queries/spec.md new file mode 100644 index 0000000..688fbc9 --- /dev/null +++ b/openspec/specs/asset-queries/spec.md @@ -0,0 +1,172 @@ +# asset-queries Specification + +## Purpose + +提供基于已知资产 ID 的轻量查询接口,包括实时状态查询、手动刷新、套餐历史列表和当前主套餐详情。供前端在 resolve 之后进行快速轮询和详情展示。 + +## Requirements + +### Requirement: 轻量实时状态查询 + +系统 SHALL 提供基于持久化数据的轻量状态查询接口,供前端在已知资产 ID 后进行快速轮询。 + +**API 端点**: `GET /api/admin/assets/:asset_type/:id/realtime-status` + +**约束**: +- `:asset_type` 取值为 `device` 或 `card` +- 此接口**不调用网关**,仅读取 DB/Redis 中持久化的最新数据 +- 不包含套餐流量计算(与 resolve 的区别) +- "实时性"依赖轮询系统定期刷新(实名状态约 5 分钟,流量约 10 分钟) + +**card 类型响应字段**: +- `network_status`: 网络状态(0-停机 1-开机) +- `real_name_status`: 实名状态(0-未实名 1-已实名) +- `current_month_usage_mb`: 本月已用流量(持久化缓存值) +- `last_sync_at`: 最后与 Gateway 同步时间 + +**device 类型响应字段**: +- `device_protect_status`: 保护期状态(`"none"` / `"stop"` / `"start"`) +- `cards`: 所有绑定卡的状态列表(同 DeviceCardInfo 结构) + +#### Scenario: 查询单卡实时状态 + +- **WHEN** 管理员调用 `GET /api/admin/assets/card/123/realtime-status` +- **THEN** 系统返回该卡的 network_status、real_name_status、current_month_usage_mb、last_sync_at + +#### Scenario: 查询设备实时状态 + +- **WHEN** 管理员调用 `GET /api/admin/assets/device/456/realtime-status` +- **THEN** 系统返回设备的保护期状态及所有绑定卡的当前状态列表 + +#### Scenario: asset_type 参数非法 + +- **WHEN** 管理员调用 `GET /api/admin/assets/unknown-type/123/realtime-status` +- **THEN** 系统返回 HTTP 400 参数错误 + +--- + +### Requirement: 手动刷新接口 + +系统 SHALL 提供手动触发网关同步的接口,用于客服主动刷新资产最新状态。 + +**API 端点**: `POST /api/admin/assets/:asset_type/:id/refresh` + +**行为规则**: +- card 类型:直接调用 `RefreshCardDataFromGateway(iccid)` 同步网络状态、实名状态、本月流量、最后同步时间 +- device 类型:对该设备所有绑定卡遍历调用 `RefreshCardDataFromGateway` + +**设备类型频率限制**: +- 使用 Redis Key `RedisDeviceRefreshCooldownKey(deviceID)` 限频 +- 同一设备 30 秒冷却期内不允许重复触发 +- 冷却期内调用返回 HTTP 429 + +**响应**: +- 刷新完成后返回刷新后的最新状态(与 realtime-status 响应结构相同) + +#### Scenario: 刷新单卡状态 + +- **WHEN** 客服调用 `POST /api/admin/assets/card/123/refresh` +- **THEN** 系统调用 RefreshCardDataFromGateway,更新 DB 中的卡状态字段,返回刷新后的最新状态 + +#### Scenario: 刷新设备状态(首次) + +- **WHEN** 管理员调用 `POST /api/admin/assets/device/456/refresh`,该设备有 3 张绑定卡 +- **THEN** 系统依次刷新 3 张卡,设置 30 秒冷却期,返回最新状态 + +#### Scenario: 设备刷新冷却期内重复触发 + +- **WHEN** 管理员在 30 秒冷却期内第二次调用 `POST /api/admin/assets/device/456/refresh` +- **THEN** 系统返回 HTTP 429,提示"刷新过于频繁,请稍后再试" + +--- + +### Requirement: 套餐历史列表查询 + +系统 SHALL 提供资产的全量套餐记录查询接口,包含历史和当前生效套餐。 + +**API 端点**: `GET /api/admin/assets/:asset_type/:id/packages` + +**排序**: 按 `created_at` 倒序(最新套餐在前) + +**分页**: 不分页,全量返回 + +**范围**: 包含所有状态(含 status=4 已失效的历史套餐) + +**按 asset_type 区分查询**: +- card:查询 `PackageUsage.iot_card_id = :id` +- device:查询 `PackageUsage.device_id = :id` + +**每条记录响应字段**: +- `package_usage_id`: 套餐使用记录 ID +- `package_name`: 套餐名称 +- `package_type`: 套餐类型(formal/addon) +- `master_usage_id`: 主套餐 ID(加油包时有值,主套餐时为 null) +- `real_data_mb`: 真总流量(MB) +- `virtual_data_mb`: 虚总流量/停机阈值(MB) +- `package_used_mb`: 展示已使用流量(经虚流量换算) +- `package_remain_mb`: 展示剩余流量 +- `activated_at`: 生效时间 +- `expires_at`: 过期时间 +- `status`: 套餐状态(0-待生效 1-生效中 2-已用完 3-已过期 4-已失效) + +#### Scenario: 查询卡的套餐历史 + +- **WHEN** 管理员调用 `GET /api/admin/assets/card/123/packages`,该卡有 3 条套餐记录(含 1 条已失效) +- **THEN** 系统返回全部 3 条记录,按创建时间倒序排列 + +#### Scenario: 查询设备的套餐历史 + +- **WHEN** 管理员调用 `GET /api/admin/assets/device/456/packages` +- **THEN** 系统返回该设备 device_id 下的所有套餐记录 + +#### Scenario: 资产无套餐记录 + +- **WHEN** 管理员查询一张从未购买过套餐的卡 +- **THEN** 系统返回空数组,不报错 + +--- + +### Requirement: 当前主套餐详情查询 + +系统 SHALL 提供查询资产当前生效主套餐的接口,用于展示套餐详细信息。 + +**API 端点**: `GET /api/admin/assets/:asset_type/:id/current-package` + +**查询条件**: `status = 1(生效中)AND master_usage_id IS NULL` + +**多套餐同时生效时**:只返回主套餐(master_usage_id IS NULL),不返回加油包 + +**响应字段**: +- 完整套餐信息(同套餐历史列表中的单条记录字段) +- 当无生效主套餐时,返回 HTTP 404 + +#### Scenario: 返回当前主套餐 + +- **WHEN** 管理员调用 `GET /api/admin/assets/card/123/current-package`,该卡有 1 个生效主套餐和 1 个加油包 +- **THEN** 系统只返回主套餐信息,不包含加油包 + +#### Scenario: 无当前生效主套餐 + +- **WHEN** 管理员查询没有生效中主套餐的资产 +- **THEN** 系统返回 HTTP 404 + +--- + +### Requirement: RefreshCardDataFromGateway 完整同步 + +系统 SHALL 提供从 Gateway 完整同步卡数据的方法,替代原 `SyncCardStatusFromGateway`(仅为示例实现)。 + +**方法签名**: `RefreshCardDataFromGateway(ctx context.Context, iccid string) error` + +**同步字段**: +- `network_status`: 网络状态(从网关卡状态映射) +- `real_name_status`: 实名状态(从网关实名接口获取) +- `current_month_usage_mb`: 本月已用流量(从网关流量接口获取) +- `last_sync_time`: 更新为当前时间 + +**错误处理**: 网关调用失败时记录 Error 日志并返回错误,不更新 DB + +#### Scenario: 完整同步卡数据 + +- **WHEN** 调用 `RefreshCardDataFromGateway(ctx, "89860123456789012345")` +- **THEN** 系统调用网关接口,将 network_status、real_name_status、current_month_usage_mb、last_sync_time 写回 DB diff --git a/openspec/specs/asset-resolve/spec.md b/openspec/specs/asset-resolve/spec.md new file mode 100644 index 0000000..f08c481 --- /dev/null +++ b/openspec/specs/asset-resolve/spec.md @@ -0,0 +1,138 @@ +# asset-resolve Specification + +## Purpose + +提供统一的资产解析入口,通过任意标识符(虚拟号/ICCID/IMEI/SN/MSISDN)定位卡或设备,并返回该资产的中等聚合信息,包含套餐流量、保护期状态和绑定关系。 + +## Requirements + +### Requirement: 统一资产解析入口 + +系统 SHALL 提供统一的资产查找接口,通过任意标识符定位卡或设备,并返回该资产的中等聚合信息。 + +**API 端点**: `GET /api/admin/assets/resolve/:identifier` + +**查找顺序**: +1. 先在 `tb_device` 表查找(匹配 `virtual_no = ? OR imei = ? OR sn = ?`) +2. 未命中则在 `tb_iot_card` 表查找(匹配 `virtual_no = ? OR iccid = ? OR msisdn = ?`) +3. 两表均未命中 → 返回 HTTP 404 +4. 找到后应用数据权限过滤,无权限 → 返回 HTTP 403 + +**数据权限规则**: +- 代理用户:只能查看 `shop_id` 在自己及下级店铺范围内的资产 +- 平台用户(SuperAdmin/Platform):可查看所有资产 +- 企业账号:暂不支持此接口,调用时返回 HTTP 403 + +**响应结构(AssetResolveResponse)**: + +*通用字段(device 和 card 均有)*: +- `asset_type`: 资产类型(`"device"` 或 `"card"`) +- `asset_id`: 资产主键 ID +- `virtual_no`: 虚拟号(设备/卡均使用此字段) +- `status`: 资产状态(整型) +- `batch_no`: 批次号 +- `shop_id`: 所属店铺 ID(平台库存时为空) +- `shop_name`: 所属店铺名称 +- `series_id`: 套餐系列 ID(未绑定时为空) +- `series_name`: 套餐系列名称 +- `first_commission_paid`: 一次性佣金是否已发放 +- `accumulated_recharge`: 累计充值金额(分) +- `activated_at`: 激活时间(未激活时为空) +- `created_at`: 创建时间 +- `updated_at`: 更新时间 + +*状态与套餐字段(device 和 card 均有)*: +- `real_name_status`: 实名状态(整型) +- `current_package`: 当前套餐名称(无套餐时返回空字符串) +- `package_total_mb`: 真总流量,即 RealDataMB(无套餐时返回 0) +- `package_virtual_mb`: 虚总流量/停机阈值,即 VirtualDataMB(无套餐时返回 0) +- `package_used_mb`: 客户端展示已使用流量(经虚流量换算,见流量计算规则) +- `package_remain_mb`: 客户端展示剩余流量 +- `device_protect_status`: 保护期状态(`"none"` / `"stop"` / `"start"`);card 类型时若绑定的设备有保护期也返回该设备的保护期状态 + +*绑定关系字段*: +- `iccid`: 仅 card 类型时有值,供前端调用停复机接口使用 +- `bound_device_id`: 仅 card 类型且卡绑定了设备时有值 +- `bound_device_no`: 绑定设备的虚拟号 +- `bound_device_name`: 绑定设备的名称 +- `bound_card_count`: 仅 device 类型时有值,绑定卡的总数量 +- `cards`: 仅 device 类型时有值,所有绑定卡列表(含未实名、已停用) + +*设备专属档案字段(asset_type=device 时有值,card 类型时为空/零值)*: +- `device_name`: 设备名称 +- `imei`: IMEI +- `sn`: 序列号 +- `device_model`: 设备型号 +- `device_type`: 设备类型 +- `max_sim_slots`: 最大插槽数 +- `manufacturer`: 制造商 + +*卡专属档案字段(asset_type=card 时有值,device 类型时为空/零值)*: +- `carrier_id`: 运营商 ID +- `carrier_type`: 运营商类型(CMCC/CUCC/CTCC/CBN) +- `carrier_name`: 运营商名称 +- `msisdn`: 卡接入号 +- `imsi`: IMSI +- `card_category`: 卡业务类型(normal/industry) +- `supplier`: 供应商 +- `activation_status`: 激活状态(0-未激活 1-已激活) +- `enable_polling`: 是否参与轮询 + +**DeviceCardInfo 结构**: +- `iot_card_id`: 卡 ID +- `iccid`: ICCID +- `virtual_no`: 卡的虚拟号 +- `real_name_status`: 实名状态 +- `network_status`: 网络状态 +- `current_month_usage_mb`: 本月已用流量(来自持久化缓存字段) +- `last_sync_at`: 最后与 Gateway 同步时间 + +**流量展示计算规则**: +- `package_used_mb = current_month_usage_mb × virtual_ratio` +- `package_remain_mb = package_total_mb - package_used_mb` +- 当 `enable_virtual_data = false` 时,`virtual_ratio = 1.0`(无换算) +- 设备级套餐:`current_month_usage_mb` 为所有绑定卡本月用量之和 + +**特殊情况处理**: +- 卡绑定的设备已被软删除:视为独立卡,不填充绑定信息 +- `cards` 列表包含所有状态的绑定卡,不过滤未实名或已停用的卡 + +#### Scenario: 通过 ICCID 找到卡 + +- **WHEN** 管理员调用 `GET /api/admin/assets/resolve/89860123456789012345`,ICCID 匹配到一张独立卡 +- **THEN** 系统返回 `asset_type="card"`,包含该卡的虚拟号、状态、套餐流量信息,`bound_device_id` 为空 + +#### Scenario: 通过虚拟号找到设备 + +- **WHEN** 管理员调用 `GET /api/admin/assets/resolve/GPS-001`,设备表中 `virtual_no = "GPS-001"` 存在 +- **THEN** 系统返回 `asset_type="device"`,包含该设备的绑定卡列表(DeviceCardInfo 数组),`bound_card_count` 为绑定卡总数 + +#### Scenario: 标识符同时命中设备和卡(设备优先) + +- **WHEN** `GPS-001` 在 device 表和 iot_card 表均有匹配(virtual_no 相同) +- **THEN** 系统返回设备信息(device 优先),不返回卡信息 + +#### Scenario: 标识符未命中任何资产 + +- **WHEN** 管理员查询不存在的标识符 `UNKNOWN-999` +- **THEN** 系统返回 HTTP 404 + +#### Scenario: 代理用户查询无权限的资产 + +- **WHEN** 代理用户(shop_id=10)查询属于 shop_id=99(非下级)的设备 +- **THEN** 系统返回 HTTP 403,明确提示无权限 + +#### Scenario: 企业账号调用 resolve + +- **WHEN** 企业账号调用 `GET /api/admin/assets/resolve/:identifier` +- **THEN** 系统返回 HTTP 403,提示企业账号暂不支持此接口 + +#### Scenario: 卡绑定了有停机保护期的设备 + +- **WHEN** 管理员通过 ICCID 查询某张卡,该卡绑定的设备当前有 stop 保护期 +- **THEN** 响应中 `device_protect_status = "stop"`,反映所属设备的保护期状态 + +#### Scenario: 设备无当前生效套餐 + +- **WHEN** 管理员查询一台没有购买任何套餐的设备 +- **THEN** `current_package = ""`,`package_total_mb = 0`,`package_used_mb = 0`,`package_remain_mb = 0` diff --git a/openspec/specs/asset-suspend-resume/spec.md b/openspec/specs/asset-suspend-resume/spec.md new file mode 100644 index 0000000..a231c15 --- /dev/null +++ b/openspec/specs/asset-suspend-resume/spec.md @@ -0,0 +1,174 @@ +# asset-suspend-resume Specification + +## Purpose + +提供统一的资产停复机接口,包括设备级批量停复机和单卡停复机,含保护期感知逻辑。废弃原分散在各模块的旧停复机接口,统一使用 `/api/admin/assets/` 路径。 + +## Requirements + +### Requirement: 设备停机接口 + +系统 SHALL 提供设备停机接口,批量停用设备下所有已实名卡,并建立停机保护期。 + +**API 端点**: `POST /api/admin/assets/device/:device_id/stop` + +**执行流程**: +1. 验证设备存在(不存在返回 HTTP 404) +2. 检查设备是否在保护期(`RedisDeviceProtectKey(deviceID, "stop")` 或 `"start"` 存在则返回 HTTP 403) +3. 获取该设备所有已实名(`real_name_status = 1`)的绑定卡 +4. 遍历调用网关停机接口(未实名卡跳过,永远是停机状态) +5. 更新成功停机的卡的 `network_status = 0`,`stopped_at = now()`,`stop_reason = "manual"` +6. 在 Redis 中设置停机保护期:`RedisDeviceProtectKey(deviceID, "stop")`,TTL = 1 小时 +7. 响应:返回成功,附带失败卡列表(如有) + +**保护期说明**: +- 保护期时长:1 小时(常量 `DeviceProtectPeriodDuration = 1 * time.Hour`,定义在 `pkg/constants/`) +- 停机保护期 key:`protect:device:{device_id}:stop` +- 复机保护期 key:`protect:device:{device_id}:start` +- 两个 key 互斥:设置 stop 保护期时删除 start 保护期,反之亦然 + +**批量部分失败策略**: +- 部分卡调网关失败:**仍设置** Redis 保护期(保护期从发起操作时算起) +- 已成功停机的卡**不回滚** +- 失败的卡记录 Error 日志,响应体中携带失败列表 + +#### Scenario: 成功执行设备停机 + +- **WHEN** 管理员调用 `POST /api/admin/assets/device/456/stop`,该设备有 3 张已实名卡 +- **THEN** 系统批量调网关停机,更新 3 张卡 network_status=0,设置 1 小时 stop 保护期,返回成功 + +#### Scenario: 设备存在保护期 + +- **WHEN** 管理员在设备已有 stop 保护期时再次调用停机接口 +- **THEN** 系统返回 HTTP 403,提示"设备处于保护期,不允许操作" + +#### Scenario: 设备下无已实名卡 + +- **WHEN** 管理员对只有未实名卡的设备执行停机 +- **THEN** 系统返回成功(0 张卡操作),设置 stop 保护期 + +#### Scenario: 设备不存在 + +- **WHEN** 管理员调用不存在的设备 ID +- **THEN** 系统返回 HTTP 404 + +#### Scenario: 部分卡停机失败 + +- **WHEN** 设备有 3 张卡,1 张网关调用失败 +- **THEN** 2 张成功停机,1 张失败记录日志,**仍设置** stop 保护期,响应中包含失败卡信息 + +--- + +### Requirement: 设备复机接口 + +系统 SHALL 提供设备复机接口,批量恢复设备下所有已实名卡,并建立复机保护期。 + +**API 端点**: `POST /api/admin/assets/device/:device_id/start` + +**执行流程**: +1. 验证设备存在(不存在返回 HTTP 404) +2. 检查设备是否在保护期(stop 或 start 保护期均存在时返回 HTTP 403) +3. 获取该设备所有已实名(`real_name_status = 1`)的绑定卡 +4. 遍历调用网关复机接口 +5. 更新成功复机的卡的 `network_status = 1`,`resumed_at = now()` +6. 设置复机保护期:`RedisDeviceProtectKey(deviceID, "start")`,TTL = 1 小时 +7. 响应:返回成功 + +#### Scenario: 成功执行设备复机 + +- **WHEN** 管理员调用 `POST /api/admin/assets/device/456/start`,该设备有 2 张已实名卡 +- **THEN** 系统批量复机,更新卡状态,设置 1 小时 start 保护期,返回成功 + +#### Scenario: 设备在 start 保护期内再次复机 + +- **WHEN** 设备已有 start 保护期时再次调用复机接口 +- **THEN** 系统返回 HTTP 403,提示"设备处于保护期,不允许操作" + +--- + +### Requirement: 卡停机接口 + +系统 SHALL 提供单卡停机接口,含保护期感知逻辑。 + +**API 端点**: `POST /api/admin/assets/card/:iccid/stop` + +**执行流程**: +1. 通过 ICCID 查找卡(不存在返回 HTTP 404) +2. 检查卡是否已实名(`real_name_status = 0` 时返回 HTTP 403:未实名卡不允许停复机) +3. 若卡绑定了设备,检查该设备的保护期: + - 设备有 **stop 保护期**:允许停机(本已是停机方向,无冲突) + - 设备有 **start 保护期**:允许停机(用户可主动停单张卡) + - 设备无保护期:正常执行 +4. 调用网关停机接口 +5. 更新卡 `network_status = 0`,`stopped_at = now()`,`stop_reason = "manual"` + +#### Scenario: 独立卡(未绑定设备)停机 + +- **WHEN** 管理员对一张未绑定设备的已实名卡执行停机 +- **THEN** 系统正常调网关停机,更新卡状态 + +#### Scenario: 绑定设备且设备在 start 保护期内停机 + +- **WHEN** 管理员对绑定了设备且设备有 start 保护期的卡执行停机 +- **THEN** 系统允许执行(用户主动停单张卡不违反 start 保护期),正常停机 + +#### Scenario: 对未实名卡执行停机 + +- **WHEN** 管理员对 real_name_status=0(未实名)的卡执行停机 +- **THEN** 系统返回 HTTP 403,提示"未实名卡不允许停复机操作" + +--- + +### Requirement: 卡复机接口 + +系统 SHALL 提供单卡复机接口,含保护期感知逻辑。 + +**API 端点**: `POST /api/admin/assets/card/:iccid/start` + +**执行流程**: +1. 通过 ICCID 查找卡(不存在返回 HTTP 404) +2. 检查卡是否已实名(`real_name_status = 0` 时返回 HTTP 403) +3. 若卡绑定了设备,检查该设备的保护期: + - 设备有 **stop 保护期**:**不允许**手动复机,返回 HTTP 403(设备处于停机保护期) + - 设备有 **start 保护期**:允许复机(本已是复机方向,无冲突) + - 设备无保护期:正常执行 +4. 调用网关复机接口 +5. 更新卡 `network_status = 1`,`resumed_at = now()`,清空 `stop_reason` + +#### Scenario: 独立卡(未绑定设备)复机 + +- **WHEN** 管理员对一张未绑定设备的已实名停机卡执行复机 +- **THEN** 系统正常调网关复机,更新卡状态 + +#### Scenario: 设备处于 stop 保护期时尝试复机 + +- **WHEN** 管理员对绑定了设备且设备有 stop 保护期的卡执行复机 +- **THEN** 系统返回 HTTP 403,提示"设备处于停机保护期,不允许手动复机" + +#### Scenario: 设备在 start 保护期内复机 + +- **WHEN** 管理员对绑定了设备且设备有 start 保护期的卡执行复机 +- **THEN** 系统允许执行(本已是复机方向),正常复机 + +#### Scenario: 对未实名卡执行复机 + +- **WHEN** 管理员对 real_name_status=0 的卡执行复机 +- **THEN** 系统返回 HTTP 403,提示"未实名卡不允许停复机操作" + +--- + +### Requirement: 废弃旧停复机接口 + +系统 SHALL 删除以下重复的停复机接口,统一使用新的 `/api/admin/assets/` 路径。 + +**待删除接口**: +- `POST /api/admin/enterprises/:id/cards/:card_id/suspend` +- `POST /api/admin/enterprises/:id/cards/:card_id/resume` +- `POST /h5/devices/:device_id/cards/:card_id/suspend` +- `POST /h5/devices/:device_id/cards/:card_id/resume` +- 旧 Admin 卡停复机接口(`POST /iot-cards/:iccid/suspend|resume`) + +#### Scenario: 调用已删除的旧接口 + +- **WHEN** 前端调用 `POST /api/admin/enterprises/:id/cards/:card_id/suspend` +- **THEN** 系统返回 HTTP 404(路由已不存在) diff --git a/openspec/specs/device/spec.md b/openspec/specs/device/spec.md index 122587a..4b0c98c 100644 --- a/openspec/specs/device/spec.md +++ b/openspec/specs/device/spec.md @@ -8,7 +8,7 @@ TBD - created by archiving change add-device-management. Update Purpose after ar 系统 SHALL 提供设备列表查询功能,支持多维度筛选和分页。 **查询条件**: -- `device_no`(可选): 设备号,支持模糊匹配 +- `virtual_no`(可选): 设备虚拟号,支持模糊匹配(原 `device_no` 字段,已全量改名) - `device_name`(可选): 设备名称,支持模糊匹配 - `status`(可选): 设备状态,枚举值 1-在库 | 2-已分销 | 3-已激活 | 4-已停用 - `shop_id`(可选): 店铺 ID,NULL 表示平台库存 @@ -30,7 +30,7 @@ TBD - created by archiving change add-device-management. Update Purpose after ar **响应字段**: - `id`: 设备 ID -- `device_no`: 设备号 +- `virtual_no`: 设备虚拟号(原 `device_no`,已改名) - `device_name`: 设备名称 - `device_model`: 设备型号 - `device_type`: 设备类型 @@ -51,10 +51,10 @@ TBD - created by archiving change add-device-management. Update Purpose after ar - **WHEN** 平台管理员查询设备列表,不带任何筛选条件 - **THEN** 系统返回所有设备,按创建时间倒序排列 -#### Scenario: 按设备号模糊查询 +#### Scenario: 按虚拟号模糊查询 -- **WHEN** 管理员输入 device_no = "GPS" -- **THEN** 系统返回设备号包含 "GPS" 的所有设备 +- **WHEN** 管理员输入 virtual_no = "GPS" +- **THEN** 系统返回虚拟号包含 "GPS" 的所有设备 #### Scenario: 按状态筛选设备 @@ -80,7 +80,7 @@ TBD - created by archiving change add-device-management. Update Purpose after ar **API 端点**: `GET /api/admin/devices/:id` **响应字段**: -- 包含设备的所有基本字段 +- 包含设备的所有基本字段(含 `virtual_no`,不再有 `device_no`) - `shop_name`: 店铺名称(如果有) **数据权限**: @@ -90,7 +90,7 @@ TBD - created by archiving change add-device-management. Update Purpose after ar #### Scenario: 查询设备详情成功 - **WHEN** 管理员查询设备详情(ID=1) -- **THEN** 系统返回该设备的完整基本信息 +- **THEN** 系统返回该设备的完整基本信息,响应中含 `virtual_no` 字段,不含 `device_no` #### Scenario: 查询不存在的设备 @@ -323,3 +323,36 @@ TBD - created by archiving change add-device-management. Update Purpose after ar - **WHEN** 代理(店铺 ID=10)尝试回收非直属下级的设备 - **THEN** 系统返回错误,提示"只能回收直属下级店铺的设备" +--- + +### Requirement: device_no 全量改名为 virtual_no + +系统 SHALL 将 `tb_device` 表和 `tb_personal_customer_device` 表中的 `device_no` 字段全量改名为 `virtual_no`,确保系统中不再有 `device_no` 的存在。 + +**数据库变更**: +```sql +ALTER TABLE tb_device RENAME COLUMN device_no TO virtual_no; +ALTER TABLE tb_personal_customer_device RENAME COLUMN device_no TO virtual_no; +``` + +**代码影响范围**: +- `internal/model/device.go`:`DeviceNo` → `VirtualNo`,column tag 更新 +- `internal/model/personal_customer_device.go`:`DeviceNo` → `VirtualNo`,column tag 更新 +- `internal/model/dto/device_dto.go`:`DeviceResponse.DeviceNo` → `VirtualNo`,JSON tag 更新为 `"virtual_no"` +- `internal/store/postgres/device_store.go`:`GetByIdentifier` 查询条件中 `device_no` → `virtual_no` +- `internal/store/postgres/personal_customer_device_store.go`:所有 `device_no` 引用更新 +- 所有 Handler、Service 中引用 `DeviceNo` 字段的代码全量替换 + +**设备导入模板**: +- 导入 Excel 模板中的列头从 `device_no` 更新为 `virtual_no` + +#### Scenario: 改名后查询设备 + +- **WHEN** 改名迁移完成后,调用 `GetByIdentifier("GPS-001")` +- **THEN** 系统在 `WHERE virtual_no = ? OR imei = ? OR sn = ?` 中正确匹配,与改名前行为一致 + +#### Scenario: 响应中字段名已更新 + +- **WHEN** 前端调用设备列表或详情接口 +- **THEN** 响应 JSON 中 key 为 `virtual_no`,不再有 `device_no` + diff --git a/openspec/specs/iot-card-import-task/spec.md b/openspec/specs/iot-card-import-task/spec.md index b56395f..4b6ae77 100644 --- a/openspec/specs/iot-card-import-task/spec.md +++ b/openspec/specs/iot-card-import-task/spec.md @@ -192,93 +192,83 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose ### Requirement: Excel 文件格式规范 -系统 SHALL 要求 Excel 文件必须包含 ICCID 和 MSISDN 两列。 +系统 SHALL 要求 Excel 文件必须包含 ICCID 和 MSISDN 两列,并支持可选的 `virtual_no` 列。 **文件格式要求**: - **文件格式**: 仅支持 `.xlsx` (Excel 2007+) -- **Sheet**: 读取第一个sheet,或优先读取名为"导入数据"的sheet -- **表头行**: 第1行(可选,但建议包含) -- **表头识别关键字**: - - ICCID列: iccid/ICCID/卡号/号码 - - MSISDN列: msisdn/MSISDN/接入号/手机号/电话/号码 -- **列数要求**: 至少2列(ICCID和MSISDN) -- **列格式**: 应设置为文本格式(避免长数字被转为科学记数法) +- **Sheet**: 读取第一个sheet,或优先读取名为"导入数据"的sheet +- **表头行**: 第1行(可选,但建议包含) +- **表头识别关键字**: + - ICCID 列: iccid/ICCID/卡号/号码 + - MSISDN 列: msisdn/MSISDN/接入号/手机号/电话/号码 + - virtual_no 列(新增,可选): virtual_no/VirtualNo/虚拟号/设备号 +- **列数要求**: 至少 2 列(ICCID 和 MSISDN),virtual_no 为可选第三列 +- **列格式**: 应设置为文本格式(避免长数字被转为科学记数法) **解析规则**: -- 自动检测表头(第1行包含识别关键字则跳过) +- 自动检测表头(第1行包含识别关键字则跳过) - 自动去除单元格首尾空格 - 跳过空行 - ICCID 为空的行记录为失败 - MSISDN 为空的行记录为失败 +- virtual_no 为空的行:跳过该列(不填入,保留原值) -**示例Excel内容**: +**virtual_no 导入规则(只补空白)**: +- 该行 virtual_no 不为空 + 数据库当前值为 NULL:填入新值 +- 该行 virtual_no 不为空 + 数据库当前值已有值:跳过(不覆盖) +- **批次级唯一性检查**:在执行导入前,先检查整批中所有非空 virtual_no 是否与数据库现存值重复;有任意冲突则**整批失败**,响应中返回冲突的 virtual_no 及行号列表 + +**示例 Excel 内容**: ``` -| ICCID | MSISDN | -|----------------------|-------------| -| 89860012345678901234 | 13800000001 | -| 89860012345678901235 | 13800000002 | +| ICCID | MSISDN | 虚拟号 | +|----------------------|-------------|-----------| +| 89860012345678901234 | 13800000001 | CARD-001 | +| 89860012345678901235 | 13800000002 | | ``` -#### Scenario: 解析标准双列 Excel 文件 +#### Scenario: 解析标准双列 Excel 文件(无 virtual_no 列) -- **GIVEN** Excel 文件内容为: - ``` - | ICCID | MSISDN | - | 89860012345678901234 | 13800000001 | - | 89860012345678901235 | 13800000002 | - ``` -- **WHEN** 系统解析该 Excel 文件 -- **THEN** 解析结果包含 2 条有效记录,每条包含 ICCID 和 MSISDN +- **WHEN** Excel 文件只含 ICCID 和 MSISDN 两列,无虚拟号列 +- **THEN** 解析结果包含 2 条有效记录,virtual_no 字段为空,不影响导入逻辑 + +#### Scenario: 解析含 virtual_no 列的三列 Excel + +- **WHEN** Excel 文件含 ICCID、MSISDN、虚拟号三列,某行 virtual_no = "CARD-001",对应卡当前 virtual_no 为 NULL +- **THEN** 解析后该卡的 virtual_no 填入 "CARD-001" + +#### Scenario: virtual_no 已有值时不覆盖 + +- **WHEN** Excel 中某行 virtual_no = "CARD-NEW",但该卡数据库中已有 virtual_no = "CARD-OLD" +- **THEN** 该卡的 virtual_no 保持 "CARD-OLD" 不变,该行跳过(不报错,不计入失败) + +#### Scenario: 批次中有 virtual_no 与现存数据重复 + +- **WHEN** Excel 中某行 virtual_no = "CARD-001",但数据库中另一张卡已有 virtual_no = "CARD-001" +- **THEN** 系统拒绝整批导入,响应返回冲突的 virtual_no 值和行号,提示"虚拟号重复,整批导入已终止" #### Scenario: 支持中文表头 -- **GIVEN** Excel 文件内容为: - ``` - | 卡号 | 接入号 | - | 89860012345678901234 | 13800000001 | - ``` +- **GIVEN** Excel 文件表头为 `卡号 | 接入号 | 虚拟号` - **WHEN** 系统解析该 Excel 文件 -- **THEN** 系统正确识别列,解析结果包含 1 条有效记录 +- **THEN** 系统正确识别三列,按规则处理 virtual_no #### Scenario: 拒绝非Excel格式文件 - **GIVEN** 上传文件扩展名为 .csv - **WHEN** 系统尝试解析该文件 -- **THEN** 系统返回错误 "不支持的文件格式 .csv,请上传Excel文件(.xlsx)" - -#### Scenario: Excel文件无工作表 - -- **GIVEN** Excel 文件不包含任何工作表 -- **WHEN** 系统尝试解析该 Excel 文件 -- **THEN** 系统返回错误 "Excel文件无工作表" +- **THEN** 系统返回错误"不支持的文件格式 .csv,请上传Excel文件(.xlsx)" #### Scenario: MSISDN 为空的行记录失败 -- **GIVEN** Excel 文件内容为: - ``` - | ICCID | MSISDN | - | 89860012345678901234 | 13800000001 | - | 89860012345678901235 | | - ``` +- **GIVEN** Excel 文件第二行 MSISDN 为空 - **WHEN** 系统解析该 Excel 文件 -- **THEN** 第一条记录解析成功,第二条记录标记为失败,原因为 "MSISDN 不能为空" - -#### Scenario: ICCID 为空的行记录失败 - -- **GIVEN** Excel 文件内容为: - ``` - | ICCID | MSISDN | - | 89860012345678901234 | 13800000001 | - | | 13800000002 | - ``` -- **WHEN** 系统解析该 Excel 文件 -- **THEN** 第一条记录解析成功,第二条记录标记为失败,原因为 "ICCID 不能为空" +- **THEN** 第一条记录解析成功,第二条记录标记为失败,原因为"MSISDN 不能为空" #### Scenario: 长数字无损解析 -- **GIVEN** Excel 文件中ICCID列设置为文本格式,包含20位数字 "89860012345678901234" +- **GIVEN** Excel 文件中 ICCID 列设置为文本格式,包含 20 位数字 "89860012345678901234" - **WHEN** 系统解析该 Excel 文件 -- **THEN** ICCID 完整保留为 "89860012345678901234",无精度损失,无科学记数法 +- **THEN** ICCID 完整保留为 "89860012345678901234",无精度损失,无科学记数法 --- diff --git a/openspec/specs/iot-card/spec.md b/openspec/specs/iot-card/spec.md index 8d53eaf..3bf09d0 100644 --- a/openspec/specs/iot-card/spec.md +++ b/openspec/specs/iot-card/spec.md @@ -714,3 +714,38 @@ IotCard Service SHALL 提供 Gateway API 的代理方法,封装权限检查和 - **WHEN** 调用任意 Gateway 代理方法且 ICCID 对应的卡不存在或用户无权限 - **THEN** 返回 `CodeNotFound` 错误 - **AND** 错误信息为 "卡不存在或无权限访问" + +--- + +### Requirement: IoT 卡虚拟号字段 + +系统 SHALL 在 `tb_iot_card` 表新增 `virtual_no` 字段,与设备的虚拟号概念对等,供客服和客户通过统一虚拟号查找资产。 + +**字段定义**: +- 字段名:`virtual_no`(VARCHAR(50),可空) +- 全局唯一索引:`CREATE UNIQUE INDEX idx_iot_card_virtual_no ON tb_iot_card (virtual_no) WHERE deleted_at IS NULL` +- 老数据:`virtual_no` 为 NULL(已有卡不强制要求有虚拟号) +- 允许手动修改 + +**唯一性规则**: +- 在所有未软删除的卡中唯一(部分索引,deleted_at IS NULL) +- 导入时与数据库现存数据重复则整批失败,响应中包含冲突的具体 virtual_no 列表 + +**虚拟号的使用场景**: +- resolve 接口:支持通过 virtual_no 查找卡 +- 客服工单:客服将虚拟号告知客户,客户通过虚拟号自助查询 + +#### Scenario: 为卡设置唯一虚拟号 + +- **WHEN** 管理员为 ICCID 为 "898601234..." 的卡设置 virtual_no = "CARD-001" +- **THEN** 系统保存成功,`idx_iot_card_virtual_no` 确保全局唯一 + +#### Scenario: 导入批次中有重复虚拟号 + +- **WHEN** ICCID 导入批次中,有 1 条记录的 virtual_no 与数据库现存卡的 virtual_no 重复 +- **THEN** 系统拒绝整批导入,响应中返回冲突的 virtual_no 及所属行号 + +#### Scenario: virtual_no 为空的老卡 + +- **WHEN** 系统中有历史导入的卡,没有 virtual_no +- **THEN** 这些卡的 virtual_no = NULL,不影响唯一索引(部分索引跳过 NULL 值) diff --git a/openspec/specs/iot-package/spec.md b/openspec/specs/iot-package/spec.md index 1a33439..8d6844d 100644 --- a/openspec/specs/iot-package/spec.md +++ b/openspec/specs/iot-package/spec.md @@ -16,7 +16,7 @@ This capability supports: ### Requirement: 套餐实体定义 -系统 SHALL 定义套餐(Package)实体,包含套餐的基本属性、定价、流量配置。 +系统 SHALL 定义套餐(Package)实体,包含套餐的基本属性、定价、流量配置,以及用于客户端展示流量换算的 `virtual_ratio` 字段。 **核心概念**: 套餐只适用于 IoT 卡(ICCID),用户可以为单张 IoT 卡购买套餐,也可以为设备购买套餐(套餐分配到设备绑定的所有 IoT 卡,流量设备级共享)。 @@ -27,32 +27,54 @@ This capability supports: - `series_id`: 套餐系列 ID(BIGINT,关联 package_series 表,用于组织套餐分组和配置一次性分佣) - `package_type`: 套餐类型(VARCHAR(20),"formal"-正式套餐 | "addon"-加油包) - `duration_months`: 套餐时长(INT,月数,1-月套餐 12-年套餐,加油包为 0) -- `real_data_mb`: 真流量额度(BIGINT,MB 为单位,可选) -- `virtual_data_mb`: 虚流量额度(BIGINT,MB 为单位,用于停机判断,可选) +- `real_data_mb`: 真流量额度(BIGINT,MB 为单位,套餐标称总流量) +- `virtual_data_mb`: 虚流量额度(BIGINT,MB 为单位,停机阈值,始终小于或等于真流量) - `data_amount_mb`: 总流量额度(BIGINT,MB 为单位,real_data_mb + virtual_data_mb) +- `virtual_ratio`: 虚流量换算比例(DECIMAL(10,6),套餐创建时计算并存储,用于客户端展示) +- `enable_virtual_data`: 是否启用虚流量(BOOLEAN,false 时 virtual_ratio=1.0) - `price`: 套餐价格(DECIMAL(10,2),元) - `status`: 套餐状态(INT,1-上架 2-下架) - `created_at`: 创建时间(TIMESTAMP,自动填充) - `updated_at`: 更新时间(TIMESTAMP,自动填充) +**virtual_ratio 计算规则**: +- `enable_virtual_data = true` 且 `virtual_data_mb > 0`:`virtual_ratio = real_data_mb / virtual_data_mb` +- 其他情况(未启用虚流量):`virtual_ratio = 1.0` +- 套餐创建或更新时由 Service 层自动计算并存储,不由调用方传入 + +**virtual_ratio 使用场景**(展示换算): +- `展示已使用 = 真已使用 × virtual_ratio` +- `展示剩余 = real_data_mb - 展示已使用` +- 目的:当真用量达到停机阈值(virtual_data_mb)时,客户看到的展示用量恰好等于 real_data_mb(100% 已使用) + **套餐类型说明**: - **正式套餐(formal)**: 每张 IoT 卡只能有一个有效的正式套餐,购买新的正式套餐会替换旧的 - **加油包(addon)**: 每张 IoT 卡可以购买多个加油包,与正式套餐共存 -#### Scenario: 创建月套餐 +#### Scenario: 创建月套餐(未启用虚流量) -- **WHEN** 平台创建月套餐,套餐编码为 "PKG-M-001",套餐名称为 "月套餐 10GB",套餐系列 ID 为 1,类型为正式套餐,时长为 1 个月,真流量为 10240 MB,虚流量为 0,价格为 30.00 元 -- **THEN** 系统创建套餐记录,`package_code` 为 "PKG-M-001",`series_id` 为 1,`package_type` 为 "formal",`duration_months` 为 1,`real_data_mb` 为 10240,`virtual_data_mb` 为 0,`data_amount_mb` 为 10240,`price` 为 30.00 +- **WHEN** 平台创建月套餐,套餐编码为 "PKG-M-001",`enable_virtual_data = false`,`real_data_mb = 10240` +- **THEN** 系统创建套餐记录,`virtual_ratio = 1.0`(未启用虚流量时无换算) + +#### Scenario: 创建启用虚流量的套餐 + +- **WHEN** 平台创建套餐,`enable_virtual_data = true`,`real_data_mb = 10240`(10G),`virtual_data_mb = 9216`(9G) +- **THEN** 系统自动计算并存储 `virtual_ratio = 10240 / 9216 ≈ 1.111111` + +#### Scenario: 展示流量换算正确 + +- **WHEN** 客户的卡真已使用 = 9216 MB(已达停机阈值),`real_data_mb = 10240`,`virtual_ratio = 1.111111` +- **THEN** 展示已使用 = 9216 × 1.111111 ≈ 10240 MB,展示剩余 = 0 MB,客户看到"已用 10G / 共 10G" #### Scenario: 创建年套餐 - **WHEN** 平台创建年套餐,套餐编码为 "PKG-Y-001",套餐名称为 "年套餐 120GB",套餐系列 ID 为 1,类型为正式套餐,时长为 12 个月,真流量为 122880 MB,虚流量为 0,价格为 300.00 元 -- **THEN** 系统创建套餐记录,`package_code` 为 "PKG-Y-001",`series_id` 为 1,`package_type` 为 "formal",`duration_months` 为 12,`real_data_mb` 为 122880,`virtual_data_mb` 为 0,`data_amount_mb` 为 122880,`price` 为 300.00 +- **THEN** 系统创建套餐记录,`package_code` 为 "PKG-Y-001",`series_id` 为 1,`package_type` 为 "formal",`duration_months` 为 12,`real_data_mb` 为 122880,`virtual_data_mb` 为 0,`data_amount_mb` 为 122880,`price` 为 300.00,`virtual_ratio` 为 1.0 #### Scenario: 创建流量加油包 - **WHEN** 平台创建加油包,套餐编码为 "PKG-ADD-001",套餐名称为 "流量包 5GB",套餐系列 ID 为 2,类型为加油包,时长为 0,真流量为 5120 MB,虚流量为 0,价格为 10.00 元 -- **THEN** 系统创建套餐记录,`package_code` 为 "PKG-ADD-001",`series_id` 为 2,`package_type` 为 "addon",`duration_months` 为 0,`real_data_mb` 为 5120,`virtual_data_mb` 为 0,`data_amount_mb` 为 5120,`price` 为 10.00 +- **THEN** 系统创建套餐记录,`package_code` 为 "PKG-ADD-001",`series_id` 为 2,`package_type` 为 "addon",`duration_months` 为 0,`real_data_mb` 为 5120,`virtual_data_mb` 为 0,`data_amount_mb` 为 5120,`price` 为 10.00,`virtual_ratio` 为 1.0 --- diff --git a/openspec/specs/polling-protect-consistency/spec.md b/openspec/specs/polling-protect-consistency/spec.md new file mode 100644 index 0000000..61be1ad --- /dev/null +++ b/openspec/specs/polling-protect-consistency/spec.md @@ -0,0 +1,52 @@ +# polling-protect-consistency Specification + +## Purpose + +新增第四种轮询任务类型(保护期一致性检查),用于确保设备保护期内绑定卡的网络状态与保护期方向保持一致,防止状态漂移。 + +## Requirements + +### Requirement: 保护期一致性检查轮询任务 + +系统 SHALL 新增第四种轮询任务类型(保护期一致性检查),作为独立任务处理器,不修改现有三种任务(实名检查/流量检查/套餐检查)的内部逻辑。 + +**任务类型标识**: `protect`(与现有 `realname`、`carddata`、`package` 并列) + +**Redis 队列 Key**: `RedisPollingQueueProtectKey()` → `"polling:queue:protect"` + +**触发频率**: 与流量检查任务同频(默认 10 分钟) + +**任务范围**: 仅检查"已绑定设备且设备当前有保护期"的卡,范围小,不会对未绑定设备的卡产生影响 + +**处理逻辑**: +1. 检查卡是否已实名(`real_name_status = 0` 则跳过,未实名卡不参与保护期逻辑) +2. 检查卡是否绑定设备(`is_standalone = true` 则跳过) +3. 读取设备保护期 Redis Key +4. 若设备有 **stop 保护期**,且卡当前网络状态为**开机**:强制调网关停机,更新卡 `network_status = 0` +5. 若设备有 **start 保护期**,且卡当前网络状态为**停机**:强制调网关复机,更新卡 `network_status = 1` +6. 状态已一致(开机 + stop 保护期已停 / 停机 + start 保护期已开):跳过 + +#### Scenario: stop 保护期内卡状态异常(开机) + +- **WHEN** 轮询任务检查一张已实名卡,发现绑定设备有 stop 保护期,但卡当前 network_status=1(开机) +- **THEN** 任务强制调网关停机,更新卡 network_status=0,记录 Info 日志 + +#### Scenario: start 保护期内卡状态异常(停机) + +- **WHEN** 轮询任务检查一张已实名卡,发现绑定设备有 start 保护期,但卡当前 network_status=0(停机) +- **THEN** 任务强制调网关复机,更新卡 network_status=1,记录 Info 日志 + +#### Scenario: 状态已一致,跳过 + +- **WHEN** 轮询任务检查一张卡,设备有 stop 保护期,卡已是停机状态 +- **THEN** 任务跳过,不调网关,不更新 DB + +#### Scenario: 未实名卡跳过保护期逻辑 + +- **WHEN** 轮询任务遇到 real_name_status=0 的卡 +- **THEN** 任务直接跳过,不检查保护期,不调网关 + +#### Scenario: 独立卡(未绑定设备)跳过 + +- **WHEN** 轮询任务遇到 is_standalone=true 的卡 +- **THEN** 任务直接跳过,不查询设备保护期 diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index fe5b3b1..cf80f3f 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -56,6 +56,7 @@ const ( TaskTypePollingRealname = "polling:realname" // 实名状态检查 TaskTypePollingCarddata = "polling:carddata" // 卡流量检查 TaskTypePollingPackage = "polling:package" // 套餐流量检查 + TaskTypePollingProtect = "polling:protect" // 保护期一致性检查 // 套餐激活任务类型 TaskTypePackageFirstActivation = "package:first:activation" // 首次实名激活 @@ -203,3 +204,9 @@ const ( AuthorizerTypePlatform = UserTypePlatform // 平台用户授权(2) AuthorizerTypeAgent = UserTypeAgent // 代理账号授权(3) ) + +// 设备保护期相关时长常量 +const ( + DeviceProtectPeriodDuration = 1 * time.Hour // 设备停/复机保护期时长(1小时) + DeviceRefreshCooldownDuration = 30 * time.Second // 设备网关刷新冷却时长(30秒) +) diff --git a/pkg/constants/redis.go b/pkg/constants/redis.go index f6d6f5d..8789af4 100644 --- a/pkg/constants/redis.go +++ b/pkg/constants/redis.go @@ -285,3 +285,31 @@ func RedisOrderIdempotencyKey(businessKey string) string { func RedisOrderCreateLockKey(carrierType string, carrierID uint) string { return fmt.Sprintf("order:create:lock:%s:%d", carrierType, carrierID) } + +// ======================================== +// 设备保护期相关 Redis Key +// ======================================== + +// RedisDeviceProtectKey 生成设备保护期 Redis 键 +// action: "stop"(停机保护期)或 "start"(复机保护期),两者互斥 +// 过期时间:DeviceProtectPeriodDuration(1 小时) +func RedisDeviceProtectKey(deviceID uint, action string) string { + return fmt.Sprintf("protect:device:%d:%s", deviceID, action) +} + +// RedisDeviceRefreshCooldownKey 生成设备刷新冷却期 Redis 键 +// 用途:防止同一设备短时间内多次调网关刷新(限频) +// 过期时间:DeviceRefreshCooldownDuration(30 秒) +func RedisDeviceRefreshCooldownKey(deviceID uint) string { + return fmt.Sprintf("refresh:cooldown:device:%d", deviceID) +} + +// ======================================== +// 轮询保护期队列 Redis Key +// ======================================== + +// RedisPollingQueueProtectKey 保护期一致性检查轮询队列键 +// 用途:存储需要进行保护期一致性检查的卡 ID(ZSet,按时间排序) +func RedisPollingQueueProtectKey() string { + return "polling:queue:protect" +} diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index 354f09e..78909c7 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -52,5 +52,6 @@ func BuildDocHandlers() *bootstrap.Handlers { PollingAlert: admin.NewPollingAlertHandler(nil), PollingCleanup: admin.NewPollingCleanupHandler(nil), PollingManualTrigger: admin.NewPollingManualTriggerHandler(nil), + Asset: admin.NewAssetHandler(nil, nil, nil), } } diff --git a/pkg/queue/handler.go b/pkg/queue/handler.go index 0cff589..c051787 100644 --- a/pkg/queue/handler.go +++ b/pkg/queue/handler.go @@ -164,6 +164,9 @@ func (h *Handler) registerPollingHandlers() { h.mux.HandleFunc(constants.TaskTypePollingPackage, pollingHandler.HandlePackageCheck) h.logger.Info("注册套餐检查任务处理器", zap.String("task_type", constants.TaskTypePollingPackage)) + + h.mux.HandleFunc(constants.TaskTypePollingProtect, pollingHandler.HandleProtectConsistencyCheck) + h.logger.Info("注册保护期一致性检查任务处理器", zap.String("task_type", constants.TaskTypePollingProtect)) } func (h *Handler) registerPackageActivationHandlers() { diff --git a/pkg/utils/excel.go b/pkg/utils/excel.go index 5cab161..ed9c433 100644 --- a/pkg/utils/excel.go +++ b/pkg/utils/excel.go @@ -9,10 +9,11 @@ import ( "github.com/xuri/excelize/v2" ) -// CardInfo 卡信息(ICCID + MSISDN) +// CardInfo 卡信息(ICCID + MSISDN + VirtualNo) type CardInfo struct { - ICCID string - MSISDN string + ICCID string + MSISDN string + VirtualNo string } // CSVParseResult Excel/CSV 解析结果 @@ -33,7 +34,7 @@ type CSVParseError struct { // DeviceRow 设备导入数据行 type DeviceRow struct { Line int - DeviceNo string + VirtualNo string DeviceName string DeviceModel string DeviceType string @@ -128,8 +129,8 @@ func ParseDeviceExcel(filePath string) ([]DeviceRow, int, error) { row := DeviceRow{Line: lineNum} // 提取各字段 - if idx := colIndex["device_no"]; idx >= 0 && idx < len(record) { - row.DeviceNo = strings.TrimSpace(record[idx]) + if idx := colIndex["virtual_no"]; idx >= 0 && idx < len(record) { + row.VirtualNo = strings.TrimSpace(record[idx]) } if idx := colIndex["device_name"]; idx >= 0 && idx < len(record) { row.DeviceName = strings.TrimSpace(record[idx]) @@ -161,8 +162,8 @@ func ParseDeviceExcel(filePath string) ([]DeviceRow, int, error) { } } - // 跳过设备号为空的行 - if row.DeviceNo == "" { + // 跳过虚拟号为空的行 + if row.VirtualNo == "" { continue } @@ -204,47 +205,44 @@ func parseCardRows(rows [][]string) (*CSVParseResult, error) { ParseErrors: make([]CSVParseError, 0), } - // 检测表头 (第1行) headerSkipped := false - iccidCol, msisdnCol := -1, -1 + iccidCol, msisdnCol, virtualNoCol := -1, -1, -1 if len(rows) > 0 { - iccidCol, msisdnCol = findCardColumns(rows[0]) + iccidCol, msisdnCol, virtualNoCol = findCardColumns(rows[0]) if iccidCol >= 0 && msisdnCol >= 0 { headerSkipped = true } } - // 确定数据开始行 startLine := 0 if headerSkipped { startLine = 1 } - // 解析数据行 for i := startLine; i < len(rows); i++ { row := rows[i] - lineNum := i + 1 // Excel行号从1开始 + lineNum := i + 1 - // 跳过空行 if len(row) == 0 { continue } - // 提取字段 iccid := "" msisdn := "" + virtualNo := "" if iccidCol >= 0 { - // 有表头,使用列索引 if iccidCol < len(row) { iccid = strings.TrimSpace(row[iccidCol]) } if msisdnCol < len(row) { msisdn = strings.TrimSpace(row[msisdnCol]) } + if virtualNoCol >= 0 && virtualNoCol < len(row) { + virtualNo = strings.TrimSpace(row[virtualNoCol]) + } } else { - // 无表头,假设第一列ICCID,第二列MSISDN if len(row) >= 1 { iccid = strings.TrimSpace(row[0]) } @@ -253,11 +251,9 @@ func parseCardRows(rows [][]string) (*CSVParseResult, error) { } } - // 验证 result.TotalCount++ if iccid == "" && msisdn == "" { - // 空行,跳过 continue } @@ -280,45 +276,50 @@ func parseCardRows(rows [][]string) (*CSVParseResult, error) { } result.Cards = append(result.Cards, CardInfo{ - ICCID: iccid, - MSISDN: msisdn, + ICCID: iccid, + MSISDN: msisdn, + VirtualNo: virtualNo, }) } return result, nil } -// findCardColumns 查找ICCID和MSISDN列索引 +// findCardColumns 查找ICCID、MSISDN和VirtualNo列索引 // 支持中英文列名识别 -func findCardColumns(header []string) (iccidCol, msisdnCol int) { - iccidCol, msisdnCol = -1, -1 +func findCardColumns(header []string) (iccidCol, msisdnCol, virtualNoCol int) { + iccidCol, msisdnCol, virtualNoCol = -1, -1, -1 for i, col := range header { colLower := strings.ToLower(strings.TrimSpace(col)) - // 识别ICCID列 if colLower == "iccid" || colLower == "卡号" || colLower == "号码" { - if iccidCol == -1 { // 只取第一个匹配 + if iccidCol == -1 { iccidCol = i } } - // 识别MSISDN列 if colLower == "msisdn" || colLower == "接入号" || colLower == "手机号" || colLower == "电话" { - if msisdnCol == -1 { // 只取第一个匹配 + if msisdnCol == -1 { msisdnCol = i } } + + if colLower == "virtual_no" || colLower == "virtualno" || colLower == "虚拟号" || colLower == "设备号" { + if virtualNoCol == -1 { + virtualNoCol = i + } + } } - return iccidCol, msisdnCol + return iccidCol, msisdnCol, virtualNoCol } // buildDeviceColumnIndex 构建设备导入列索引 // 识别表头中的列名,返回列名到列索引的映射 func buildDeviceColumnIndex(header []string) map[string]int { index := map[string]int{ - "device_no": -1, + "virtual_no": -1, "device_name": -1, "device_model": -1, "device_type": -1,