package app import ( "context" "strings" "time" "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" 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/config" "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 personalDeviceStore *postgres.PersonalCustomerDeviceStore iotCardStore *postgres.IotCardStore deviceSimBindingStore *postgres.DeviceSimBindingStore carrierStore *postgres.CarrierStore gatewayClient *gateway.Client logger *zap.Logger manualTriggerSvc *pollingSvc.ManualTriggerService // 手动触发服务(可为nil,nil时跳过自动触发) } // NewClientRealnameHandler 创建 C 端实名认证处理器 func NewClientRealnameHandler( assetSvc *assetService.Service, personalDeviceStore *postgres.PersonalCustomerDeviceStore, iotCardStore *postgres.IotCardStore, deviceSimBindingStore *postgres.DeviceSimBindingStore, carrierStore *postgres.CarrierStore, gatewayClient *gateway.Client, logger *zap.Logger, manualTriggerSvc *pollingSvc.ManualTriggerService, // 可为nil ) *ClientRealnameHandler { return &ClientRealnameHandler{ assetService: assetSvc, personalDeviceStore: personalDeviceStore, iotCardStore: iotCardStore, deviceSimBindingStore: deviceSimBindingStore, carrierStore: carrierStore, gatewayClient: gatewayClient, logger: logger, manualTriggerSvc: manualTriggerSvc, } } // 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) if err != nil { return err } // 4. 验证资产归属(个人客户必须绑定过该资产) owned, err := h.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, asset.VirtualNo) if err != nil { logger.GetAppLogger().Error("查询资产归属失败", zap.Uint("customer_id", customerID), zap.String("virtual_no", asset.VirtualNo), 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 } // 异步触发实名检查,提升检测优先级;失败不影响主流程 if h.manualTriggerSvc != nil && config.Get().PollingAutoTrigger.EnableAutoTrigger { systemUserID := uint(config.Get().PollingAutoTrigger.AutoTriggerSystemUserID) go h.triggerRealnameCheck(targetCard.ID, customerID, targetCard.ICCID, systemUserID) } return response.Success(c, resp) } // 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 } // triggerRealnameCheck 异步触发单卡实名检查 // 在独立 goroutine 中调用,使用独立 context 避免 Fiber 请求 context 失效问题 // 参数全部为值类型,不捕获请求相关指针 func (h *ClientRealnameHandler) triggerRealnameCheck(cardID, customerID uint, iccid string, systemUserID uint) { // 必须使用独立 context,禁止复用 Fiber 请求 context(请求返回后即失效) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() // 使用平台用户身份构建 context,绕过卡归属权限检查 // 注意:使用 UserTypePlatform 而非 UserTypeSuperAdmin,SuperAdmin 不受日限制约束 sysCtx := pkgMiddleware.SetUserContext(ctx, &pkgMiddleware.UserContextInfo{ UserID: systemUserID, UserType: constants.UserTypePlatform, }) err := h.manualTriggerSvc.TriggerSingle(sysCtx, cardID, constants.TaskTypePollingRealname, systemUserID) if err != nil { h.logger.Warn("自动触发实名检查失败", zap.Uint("customer_id", customerID), zap.String("iccid", iccid), zap.Uint("card_id", cardID), zap.Error(err)) return } h.logger.Info("自动触发实名检查成功", zap.Uint("customer_id", customerID), zap.String("iccid", iccid), zap.Uint("card_id", cardID)) }