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 联调延期
296 lines
11 KiB
Go
296 lines
11 KiB
Go
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
|
||
}
|