Files
junhong_cmp_fiber/internal/handler/app/client_device.go
break 5c4d17e9fc
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
收口七月卡状态回调与系列授权兼容契约
完成运营商实名回调、业务事件观测序列与受控配置装配,同时恢复 UR43 已交付的 packages[].remove 字段及旧响应兼容,统一更新 OpenSpec、OpenAPI 和交付文档。

Constraint: 七月测试环境里程碑不新增或运行自动化测试

Rejected: 以必填 operation_type 替换 packages[].remove | 会破坏已交付前端契约

Confidence: high

Scope-risk: broad

Directive: 后续修改系列套餐管理接口必须保持 packages[].remove 和 ShopSeriesGrantResponse 兼容

Tested: go run ./cmd/gendocs;go build -buildvcs=false ./...;openspec validate complete-july-iteration-test-release --strict;git diff --check

Not-tested: 按本 Change 约定未运行 go test,真实运营商与 Gateway 联调延期
2026-07-24 19:59:24 +08:00

329 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
)
var clientDeviceValidator = validator.New()
// deviceAssetInfo validateDeviceAsset 解析后的设备资产信息
type deviceAssetInfo struct {
DeviceID uint // 设备数据库 ID
IMEI string // 设备 IMEI用于 Gateway API 调用)
VirtualNo string // 设备虚拟号(用于所有权校验)
}
// ClientDeviceHandler C 端设备能力处理器
// 提供设备卡列表、重启、恢复出厂、WiFi 配置、切卡等操作
type ClientDeviceHandler struct {
assetService *assetSvc.Service
customerBinding *customerBinding.Service
deviceStore *postgres.DeviceStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
iotCardStore *postgres.IotCardStore
gatewayClient *gateway.Client
deviceService *deviceSvc.Service
logger *zap.Logger
}
// SetDeviceService 注入统一设备控制服务,确保各入口共享成功后的观测触发。
func (h *ClientDeviceHandler) SetDeviceService(service *deviceSvc.Service) {
h.deviceService = service
}
// NewClientDeviceHandler 创建 C 端设备能力处理器
func NewClientDeviceHandler(
assetService *assetSvc.Service,
binding *customerBinding.Service,
deviceStore *postgres.DeviceStore,
deviceSimBindingStore *postgres.DeviceSimBindingStore,
iotCardStore *postgres.IotCardStore,
gatewayClient *gateway.Client,
logger *zap.Logger,
) *ClientDeviceHandler {
return &ClientDeviceHandler{
assetService: assetService,
customerBinding: binding,
deviceStore: deviceStore,
deviceSimBindingStore: deviceSimBindingStore,
iotCardStore: iotCardStore,
gatewayClient: gatewayClient,
logger: logger,
}
}
// validateDeviceAsset 校验设备资产的所有权和有效性
// 流程:认证 → 资产解析 → 类型校验(仅设备)→ 所有权校验 → IMEI 校验
func (h *ClientDeviceHandler) validateDeviceAsset(c *fiber.Ctx, identifier string) (*deviceAssetInfo, error) {
// 获取当前登录的个人客户 ID
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
ctx := c.UserContext()
// 通过标识符解析资产
asset, err := h.assetService.Resolve(ctx, identifier, false)
if err != nil {
return nil, err
}
// 仅设备资产支持设备能力操作
if asset.AssetType != "device" {
return nil, errors.New(errors.CodeInvalidParam, "仅设备资产支持该操作")
}
// 校验个人客户对该设备的所有权
owns, err := h.customerBinding.OwnsAsset(ctx, customerID, asset.AssetType, asset.AssetID)
if err != nil {
h.logger.Error("校验设备所有权失败",
zap.Uint("customer_id", customerID),
zap.Uint("asset_id", asset.AssetID),
zap.Error(err))
return nil, errors.New(errors.CodeInternalError)
}
if !owns {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
// 校验设备 IMEI 是否存在Gateway API 调用必需)
if asset.IMEI == "" {
return nil, errors.New(errors.CodeInvalidParam, "设备IMEI缺失")
}
return &deviceAssetInfo{
DeviceID: asset.AssetID,
IMEI: asset.IMEI,
VirtualNo: asset.VirtualNo,
}, nil
}
// GetDeviceCards F1 获取设备卡列表
// GET /api/c/v1/device/cards
func (h *ClientDeviceHandler) GetDeviceCards(c *fiber.Ctx) error {
var req dto.DeviceCardListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientDeviceValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("设备卡列表参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
info, err := h.validateDeviceAsset(c, req.Identifier)
if err != nil {
return err
}
ctx := c.UserContext()
// 查询设备绑定的所有 SIM 卡
bindings, err := h.deviceSimBindingStore.ListByDeviceID(ctx, info.DeviceID)
if err != nil {
h.logger.Error("查询设备SIM绑定失败",
zap.Uint("device_id", info.DeviceID),
zap.Error(err))
return errors.New(errors.CodeInternalError)
}
// 无绑定卡时返回空列表
if len(bindings) == 0 {
return response.Success(c, &dto.DeviceCardListResponse{Cards: []dto.DeviceCardItem{}})
}
// 收集卡 ID并记录插槽位置与当前在用标识
cardIDs := make([]uint, 0, len(bindings))
slotMap := make(map[uint]int, len(bindings))
isCurrentMap := make(map[uint]bool, len(bindings))
for _, b := range bindings {
cardIDs = append(cardIDs, b.IotCardID)
slotMap[b.IotCardID] = b.SlotPosition
isCurrentMap[b.IotCardID] = b.IsCurrent
}
// 批量查询卡详情
cards, err := h.iotCardStore.GetByIDs(ctx, cardIDs)
if err != nil {
h.logger.Error("批量查询IoT卡失败",
zap.Uints("card_ids", cardIDs),
zap.Error(err))
return errors.New(errors.CodeInternalError)
}
// 组装响应,当前激活卡以绑定表 is_current 为准
items := make([]dto.DeviceCardItem, 0, len(cards))
for _, card := range cards {
slot := slotMap[card.ID]
items = append(items, dto.DeviceCardItem{
CardID: card.ID,
ICCID: card.ICCID,
MSISDN: card.MSISDN,
CarrierType: card.CarrierType,
CarrierName: card.CarrierName,
NetworkStatus: networkStatusText(card.NetworkStatus),
GatewayExtend: card.GatewayExtend,
RealNameStatus: card.RealNameStatus,
SlotPosition: slot,
IsActive: isCurrentMap[card.ID],
RealnamePolicy: card.RealnamePolicy,
})
}
return response.Success(c, &dto.DeviceCardListResponse{Cards: items})
}
// RebootDevice F2 设备重启
// POST /api/c/v1/device/reboot
func (h *ClientDeviceHandler) RebootDevice(c *fiber.Ctx) error {
var req dto.DeviceRebootRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientDeviceValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("设备重启参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
info, err := h.validateDeviceAsset(c, req.Identifier)
if err != nil {
return err
}
if h.deviceService == nil {
return errors.New(errors.CodeInternalError, "设备控制服务未配置")
}
if err := h.deviceService.GatewayRebootDevice(c.UserContext(), info.IMEI); err != nil {
h.logger.Error("Gateway重启设备失败",
zap.String("imei", info.IMEI),
zap.Error(err))
return errors.Wrap(errors.CodeGatewayError, err, "设备重启失败")
}
return response.Success(c, &dto.DeviceOperationResponse{Accepted: true})
}
// FactoryResetDevice F3 恢复出厂设置
// POST /api/c/v1/device/factory-reset
func (h *ClientDeviceHandler) FactoryResetDevice(c *fiber.Ctx) error {
var req dto.DeviceFactoryResetRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientDeviceValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("恢复出厂设置参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
info, err := h.validateDeviceAsset(c, req.Identifier)
if err != nil {
return err
}
if h.deviceService == nil {
return errors.New(errors.CodeInternalError, "设备控制服务未配置")
}
if err := h.deviceService.GatewayResetDevice(c.UserContext(), info.IMEI); err != nil {
h.logger.Error("Gateway恢复出厂设置失败",
zap.String("imei", info.IMEI),
zap.Error(err))
return errors.Wrap(errors.CodeGatewayError, err, "恢复出厂设置失败")
}
return response.Success(c, &dto.DeviceOperationResponse{Accepted: true})
}
// SetWiFi F4 设备WiFi配置
// POST /api/c/v1/device/wifi
// 注意WiFiReq.CardNo 字段名具有误导性,实际传入的是设备 IMEI而非卡号
func (h *ClientDeviceHandler) SetWiFi(c *fiber.Ctx) error {
var req dto.DeviceWifiRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientDeviceValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("WiFi配置参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
info, err := h.validateDeviceAsset(c, req.Identifier)
if err != nil {
return err
}
if h.deviceService == nil {
return errors.New(errors.CodeInternalError, "设备控制服务未配置")
}
if err := h.deviceService.GatewaySetWiFi(c.UserContext(), info.IMEI, &dto.SetWiFiRequest{
SSID: req.SSID, Password: req.Password, Enabled: req.Enabled,
}); err != nil {
h.logger.Error("Gateway配置WiFi失败",
zap.String("imei", info.IMEI),
zap.String("ssid", req.SSID),
zap.Error(err))
return errors.Wrap(errors.CodeGatewayError, err, "WiFi配置失败")
}
return response.Success(c, &dto.DeviceOperationResponse{Accepted: true})
}
// SwitchCard F5 设备切卡
// POST /api/c/v1/device/switch-card
func (h *ClientDeviceHandler) SwitchCard(c *fiber.Ctx) error {
var req dto.DeviceSwitchCardRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientDeviceValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("设备切卡参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
info, err := h.validateDeviceAsset(c, req.Identifier)
if err != nil {
return err
}
if h.deviceService == nil {
return errors.New(errors.CodeInternalError, "设备控制服务未配置")
}
if err := h.deviceService.GatewaySwitchCard(c.UserContext(), info.IMEI, &dto.SwitchCardRequest{
TargetICCID: req.TargetICCID,
}); err != nil {
h.logger.Error("Gateway切卡失败",
zap.String("imei", info.IMEI),
zap.String("target_iccid", req.TargetICCID),
zap.Error(err))
return errors.Wrap(errors.CodeGatewayError, err, "设备切卡失败")
}
return response.Success(c, &dto.DeviceSwitchCardResponse{
Accepted: true,
TargetICCID: req.TargetICCID,
})
}
// networkStatusText 将网络状态码转为文本描述
func networkStatusText(status int) string {
switch status {
case 0:
return "停机"
case 1:
return "开机"
default:
return "未知"
}
}