308 lines
11 KiB
Go
308 lines
11 KiB
Go
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 // 手动触发服务(可为nil,nil时跳过自动触发)
|
||
}
|
||
|
||
// 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 而非 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))
|
||
}
|