Files
junhong_cmp_fiber/internal/handler/app/client_realname.go
break ba435dd6a6
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
关于绑定的问题
2026-06-22 12:08:41 +09:00

308 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 (
"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"
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/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
customerBinding *customerBinding.Service
iotCardStore *postgres.IotCardStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
carrierStore *postgres.CarrierStore
gatewayClient *gateway.Client
logger *zap.Logger
manualTriggerSvc *pollingSvc.ManualTriggerService // 手动触发服务可为nilnil时跳过自动触发
}
// 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,
manualTriggerSvc *pollingSvc.ManualTriggerService, // 可为nil
) *ClientRealnameHandler {
return &ClientRealnameHandler{
assetService: assetSvc,
customerBinding: binding,
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.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
}
// 异步触发实名检查,提升检测优先级;失败不影响主流程
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 而非 UserTypeSuperAdminSuperAdmin 不受日限制约束
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))
}