package app import ( "context" "strconv" "strings" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" "github.com/gofiber/fiber/v2" "go.uber.org/zap" "github.com/break/junhong_cmp_fiber/internal/gateway" "github.com/break/junhong_cmp_fiber/internal/middleware" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" assetService "github.com/break/junhong_cmp_fiber/internal/service/asset" customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling" "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" pkgMiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/break/junhong_cmp_fiber/pkg/response" "github.com/go-playground/validator/v10" ) var clientRealnameValidator = validator.New() // ClientRealnameHandler C 端实名认证处理器 type ClientRealnameHandler struct { assetService *assetService.Service customerBinding *customerBinding.Service iotCardStore *postgres.IotCardStore deviceSimBindingStore *postgres.DeviceSimBindingStore carrierStore *postgres.CarrierStore gatewayClient *gateway.Client logger *zap.Logger observationSeries cardObservationApp.BestEffortSeriesDispatcher } // NewClientRealnameHandler 创建 C 端实名认证处理器 func NewClientRealnameHandler( assetSvc *assetService.Service, binding *customerBinding.Service, iotCardStore *postgres.IotCardStore, deviceSimBindingStore *postgres.DeviceSimBindingStore, carrierStore *postgres.CarrierStore, gatewayClient *gateway.Client, logger *zap.Logger, _ *pollingSvc.ManualTriggerService, // 兼容旧构造签名;获取链接改由 0/3/5 观测序列收敛 ) *ClientRealnameHandler { return &ClientRealnameHandler{ assetService: assetSvc, customerBinding: binding, iotCardStore: iotCardStore, deviceSimBindingStore: deviceSimBindingStore, carrierStore: carrierStore, gatewayClient: gatewayClient, logger: logger, } } // SetObservationSeriesDispatcher 注入获取实名链接后的观测序列端口。 func (h *ClientRealnameHandler) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) { h.observationSeries = dispatcher } // GetRealnameLink E1 获取实名认证链接 // GET /api/c/v1/realname/link func (h *ClientRealnameHandler) GetRealnameLink(c *fiber.Ctx) error { // 1. 获取当前登录客户 customerID, ok := middleware.GetCustomerID(c) if !ok || customerID == 0 { return errors.New(errors.CodeUnauthorized) } // 2. 解析请求参数 var req dto.RealnimeLinkRequest if err := c.QueryParser(&req); err != nil { return errors.New(errors.CodeInvalidParam) } if err := clientRealnameValidator.Struct(&req); err != nil { logger.GetAppLogger().Warn("实名链接参数校验失败", zap.Error(err)) return errors.New(errors.CodeInvalidParam) } ctx := c.UserContext() // 3. 通过标识符解析资产 asset, err := h.assetService.Resolve(ctx, req.Identifier, false) if err != nil { return err } // 4. 验证资产归属(个人客户必须绑定过该资产) owned, err := h.customerBinding.OwnsAsset(ctx, customerID, asset.AssetType, asset.AssetID) if err != nil { logger.GetAppLogger().Error("查询资产归属失败", zap.Uint("customer_id", customerID), zap.Uint("asset_id", asset.AssetID), zap.Error(err)) return errors.New(errors.CodeInternalError, "查询资产归属失败") } if !owned { return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } // 5. 定位目标卡(3 条路径) targetCard, err := h.resolveTargetCard(c, asset, req.ICCID) if err != nil { return err } // after_order 策略:必须先有充值或订单才能实名 if asset.RealnamePolicy == constants.RealnamePolicyAfterOrder { hasValid, checkErr := h.assetService.HasValidRechargeOrPaidOrder(ctx, asset.AssetType, asset.AssetID) if checkErr != nil { logger.GetAppLogger().Warn("检查资产有效充值或订单失败", zap.Error(checkErr)) } if !hasValid { return errors.New(errors.CodeRealnameNotAvailable) } } // 6. 检查实名状态 if targetCard.RealNameStatus == constants.RealNameStatusVerified { return errors.New(errors.CodeInvalidStatus, "该卡已完成实名") } // 7. 获取运营商信息,根据实名链接类型生成 URL carrier, err := h.carrierStore.GetByID(ctx, targetCard.CarrierID) if err != nil { logger.GetAppLogger().Error("查询运营商失败", zap.Uint("carrier_id", targetCard.CarrierID), zap.Error(err)) return errors.New(errors.CodeCarrierNotFound, "运营商信息查询失败") } resp, err := h.buildRealnameResponse(ctx, targetCard, carrier) if err != nil { return err } h.dispatchRealnameObservation(ctx, targetCard.ID) return response.Success(c, resp) } func (h *ClientRealnameHandler) dispatchRealnameObservation(ctx context.Context, cardID uint) { if h.observationSeries == nil { return } requestID := "" if value := pkgMiddleware.GetRequestIDFromContext(ctx); value != nil { requestID = *value } h.observationSeries.Dispatch(ctx, cardObservationApp.SeriesRequest{ Scene: constants.CardObservationSceneClientRealnameLink, ResourceType: constants.CardObservationResourceTypeCard, ResourceID: strconv.FormatUint(uint64(cardID), 10), SyncType: constants.CardObservationSyncTypeRealname, ExpectedValue: "verified", Source: constants.CardObservationSourceBusinessEvent, RequestID: requestID, CorrelationID: requestID, }) } // resolveTargetCard 根据资产类型和ICCID定位目标卡 // 支持三条路径:直接卡资产、设备+指定ICCID、设备取第一张绑定卡 func (h *ClientRealnameHandler) resolveTargetCard(c *fiber.Ctx, asset *dto.AssetResolveResponse, iccid string) (*model.IotCard, error) { switch { case asset.AssetType == "card": // 路径 1:资产本身就是卡,直接使用 card, cardErr := h.iotCardStore.GetByID(c.UserContext(), asset.AssetID) if cardErr != nil { return nil, errors.New(errors.CodeIotCardNotFound, "卡信息查询失败") } return card, nil case asset.AssetType == "device" && iccid != "": // 路径 2:资产是设备,指定了 ICCID,从设备绑定中查找该卡 return h.findCardInDeviceBindings(c, asset.AssetID, iccid) case asset.AssetType == "device": // 路径 3:资产是设备,未指定 ICCID,取第一张绑定卡(按插槽位置排序) return h.findFirstBoundCard(c, asset.AssetID) default: return nil, errors.New(errors.CodeInvalidParam, "不支持的资产类型") } } // buildRealnameResponse 根据运营商实名链接类型构建响应 func (h *ClientRealnameHandler) buildRealnameResponse(ctx context.Context, card *model.IotCard, carrier *model.Carrier) (*dto.RealnimeLinkResponse, error) { resp := &dto.RealnimeLinkResponse{ CardInfo: dto.CardInfoBrief{ ICCID: card.ICCID, MSISDN: card.MSISDN, VirtualNo: card.VirtualNo, }, } switch carrier.RealnameLinkType { case constants.RealnameLinkTypeNone: // 该运营商不支持在线实名 return nil, errors.New(errors.CodeInvalidStatus, "该运营商暂不支持在线实名") case constants.RealnameLinkTypeTemplate: // 模板模式:替换占位符生成实名链接 url := carrier.RealnameLinkTemplate url = strings.ReplaceAll(url, "{iccid}", card.ICCID) url = strings.ReplaceAll(url, "{msisdn}", card.MSISDN) url = strings.ReplaceAll(url, "{virtual_no}", card.VirtualNo) resp.RealnameMode = constants.RealnameLinkTypeTemplate resp.RealnameURL = url case constants.RealnameLinkTypeGateway: // 网关模式:调用 Gateway 接口获取实名链接 linkResp, gwErr := h.gatewayClient.GetRealnameLink(ctx, &gateway.CardStatusReq{ CardNo: card.ICCID, }) if gwErr != nil { logger.GetAppLogger().Error("Gateway 获取实名链接失败", zap.String("iccid", card.ICCID), zap.Error(gwErr)) return nil, errors.Wrap(errors.CodeGatewayError, gwErr, "获取实名链接失败") } resp.RealnameMode = constants.RealnameLinkTypeGateway resp.RealnameURL = linkResp.URL default: logger.GetAppLogger().Warn("未知的实名链接类型", zap.Uint("carrier_id", carrier.ID), zap.String("realname_link_type", carrier.RealnameLinkType)) return nil, errors.New(errors.CodeInvalidStatus, "该运营商暂不支持在线实名") } return resp, nil } // findCardInDeviceBindings 在设备绑定中查找指定 ICCID 的卡 func (h *ClientRealnameHandler) findCardInDeviceBindings(c *fiber.Ctx, deviceID uint, iccid string) (*model.IotCard, error) { ctx := c.UserContext() // 查询设备的所有有效绑定 bindings, err := h.deviceSimBindingStore.ListByDeviceID(ctx, deviceID) if err != nil { logger.GetAppLogger().Error("查询设备绑定失败", zap.Uint("device_id", deviceID), zap.Error(err)) return nil, errors.New(errors.CodeInternalError, "查询设备绑定失败") } // 收集所有绑定卡的 ID cardIDs := make([]uint, 0, len(bindings)) for _, b := range bindings { cardIDs = append(cardIDs, b.IotCardID) } if len(cardIDs) == 0 { return nil, errors.New(errors.CodeIotCardNotFound, "该设备未绑定任何卡") } // 批量查询卡,匹配指定的 ICCID cards, err := h.iotCardStore.GetByIDs(ctx, cardIDs) if err != nil { return nil, errors.New(errors.CodeInternalError, "查询卡信息失败") } for _, card := range cards { if card.ICCID == iccid { return card, nil } } return nil, errors.New(errors.CodeIotCardNotFound, "该设备未绑定指定的 ICCID") } // findFirstBoundCard 获取设备第一张绑定卡(按插槽位置排序,取第一张) func (h *ClientRealnameHandler) findFirstBoundCard(c *fiber.Ctx, deviceID uint) (*model.IotCard, error) { ctx := c.UserContext() // ListByDeviceID 返回 bind_status=1 的绑定,按 slot_position ASC 排序 bindings, err := h.deviceSimBindingStore.ListByDeviceID(ctx, deviceID) if err != nil { logger.GetAppLogger().Error("查询设备绑定失败", zap.Uint("device_id", deviceID), zap.Error(err)) return nil, errors.New(errors.CodeInternalError, "查询设备绑定失败") } if len(bindings) == 0 { return nil, errors.New(errors.CodeIotCardNotFound, "该设备未绑定任何卡") } // 取第一张绑定卡(插槽位置最小的) card, err := h.iotCardStore.GetByID(ctx, bindings[0].IotCardID) if err != nil { return nil, errors.New(errors.CodeIotCardNotFound, "卡信息查询失败") } return card, nil }