收口七月卡状态回调与系列授权兼容契约
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 联调延期
This commit is contained in:
2026-07-24 19:59:24 +08:00
parent a18ed8bc8d
commit 5c4d17e9fc
79 changed files with 4341 additions and 478 deletions

View File

@@ -6,6 +6,7 @@ import (
"strings"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/gofiber/fiber/v2"
dto "github.com/break/junhong_cmp_fiber/internal/model/dto"
@@ -31,6 +32,12 @@ type AssetHandler struct {
assetPolling *pollingSvc.AssetPollingService
assetLifecycleService AssetLifecycleService
exchangeTraceQuery AssetExchangeTraceResolver
observationSeries cardObservationApp.BestEffortSeriesDispatcher
}
// SetObservationSeriesDispatcher 注入后台实时状态的观测序列端口。
func (h *AssetHandler) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) {
h.observationSeries = dispatcher
}
// AssetExchangeTraceResolver 定义资产详情换货链路读取用例。
@@ -110,10 +117,40 @@ func (h *AssetHandler) RealtimeStatus(c *fiber.Ctx) error {
if err != nil {
return err
}
h.dispatchRealtimeObservations(c.UserContext(), result)
return response.Success(c, result)
}
func (h *AssetHandler) dispatchRealtimeObservations(ctx context.Context, result *dto.AssetRealtimeStatusResponse) {
if h.observationSeries == nil || result == nil {
return
}
cardIDs := make([]uint, 0, len(result.Cards)+1)
if result.AssetType == "card" {
cardIDs = append(cardIDs, result.AssetID)
} else {
for _, card := range result.Cards {
cardIDs = append(cardIDs, card.CardID)
}
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
for _, cardID := range cardIDs {
resourceID := strconv.FormatUint(uint64(cardID), 10)
for _, syncType := range []string{constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic, constants.CardObservationSyncTypeNetwork} {
h.observationSeries.Dispatch(ctx, cardObservationApp.SeriesRequest{
Scene: constants.CardObservationSceneAdminAssetRead,
ResourceType: constants.CardObservationResourceTypeCard, ResourceID: resourceID,
SyncType: syncType, Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
})
}
}
}
// Refresh 刷新资产状态(调网关同步)
// POST /api/admin/assets/:identifier/refresh
func (h *AssetHandler) Refresh(c *fiber.Ctx) error {

View File

@@ -7,6 +7,7 @@ import (
"strings"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"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"
@@ -18,6 +19,7 @@ import (
"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"
pkgMiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
@@ -36,6 +38,12 @@ type ClientAssetHandler struct {
deviceStore *postgres.DeviceStore
db *gorm.DB
logger *zap.Logger
observationSeries cardObservationApp.BestEffortSeriesDispatcher
}
// SetObservationSeriesDispatcher 注入读取型后台观测序列端口。
func (h *ClientAssetHandler) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) {
h.observationSeries = dispatcher
}
// NewClientAssetHandler 创建 C 端资产信息处理器
@@ -213,10 +221,47 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
resp.DeviceRealtime = mapDeviceGatewayInfoToClientInfo(realtimeResp.DeviceRealtime)
}
}
h.dispatchAssetReadObservations(c.UserContext(), resolved.Asset)
return response.Success(c, resp)
}
func (h *ClientAssetHandler) dispatchAssetReadObservations(ctx context.Context, asset *dto.AssetResolveResponse) {
if h.observationSeries == nil || asset == nil {
return
}
cardIDs := make([]uint, 0, len(asset.Cards)+1)
if asset.AssetType == "card" {
cardIDs = append(cardIDs, asset.AssetID)
} else {
for _, card := range asset.Cards {
cardIDs = append(cardIDs, card.CardID)
}
}
dispatchCardReadSeries(ctx, h.observationSeries, constants.CardObservationSceneClientAssetRead, cardIDs)
}
func dispatchCardReadSeries(ctx context.Context, dispatcher cardObservationApp.BestEffortSeriesDispatcher, scene string, cardIDs []uint) {
requestID := ""
if value := pkgMiddleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
for _, cardID := range cardIDs {
resourceID := strconv.FormatUint(uint64(cardID), 10)
for _, syncType := range []string{
constants.CardObservationSyncTypeRealname,
constants.CardObservationSyncTypeTraffic,
constants.CardObservationSyncTypeNetwork,
} {
dispatcher.Dispatch(ctx, cardObservationApp.SeriesRequest{
Scene: scene, ResourceType: constants.CardObservationResourceTypeCard,
ResourceID: resourceID, SyncType: syncType, Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
})
}
}
}
// GetAvailablePackages B2 资产可购套餐列表
// GET /api/c/v1/asset/packages
func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error {

View File

@@ -6,6 +6,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model/dto"
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
@@ -33,9 +34,15 @@ type ClientDeviceHandler struct {
deviceSimBindingStore *postgres.DeviceSimBindingStore
iotCardStore *postgres.IotCardStore
gatewayClient *gateway.Client
deviceService *deviceSvc.Service
logger *zap.Logger
}
// SetDeviceService 注入统一设备控制服务,确保各入口共享成功后的观测触发。
func (h *ClientDeviceHandler) SetDeviceService(service *deviceSvc.Service) {
h.deviceService = service
}
// NewClientDeviceHandler 创建 C 端设备能力处理器
func NewClientDeviceHandler(
assetService *assetSvc.Service,
@@ -195,10 +202,10 @@ func (h *ClientDeviceHandler) RebootDevice(c *fiber.Ctx) error {
return err
}
// 调用 Gateway 重启设备
if err := h.gatewayClient.RebootDevice(c.UserContext(), &gateway.DeviceOperationReq{
DeviceID: info.IMEI,
}); err != nil {
if h.deviceService == nil {
return errors.New(errors.CodeInternalError, "设备控制服务未配置")
}
if err := h.deviceService.GatewayRebootDevice(c.UserContext(), info.IMEI); err != nil {
h.logger.Error("Gateway重启设备失败",
zap.String("imei", info.IMEI),
zap.Error(err))
@@ -225,10 +232,10 @@ func (h *ClientDeviceHandler) FactoryResetDevice(c *fiber.Ctx) error {
return err
}
// 调用 Gateway 恢复出厂设置
if err := h.gatewayClient.ResetDevice(c.UserContext(), &gateway.DeviceOperationReq{
DeviceID: info.IMEI,
}); err != nil {
if h.deviceService == nil {
return errors.New(errors.CodeInternalError, "设备控制服务未配置")
}
if err := h.deviceService.GatewayResetDevice(c.UserContext(), info.IMEI); err != nil {
h.logger.Error("Gateway恢复出厂设置失败",
zap.String("imei", info.IMEI),
zap.Error(err))
@@ -256,14 +263,11 @@ func (h *ClientDeviceHandler) SetWiFi(c *fiber.Ctx) error {
return err
}
// 调用 Gateway 配置 WiFi
// CardNo 字段虽名为"卡号",但 Gateway 实际要求传入设备 IMEI
if err := h.gatewayClient.SetWiFi(c.UserContext(), &gateway.WiFiReq{
CardNo: info.IMEI,
Params: gateway.WiFiParams{
SSIDName: req.SSID,
SSIDPassword: req.Password,
},
if h.deviceService == nil {
return errors.New(errors.CodeInternalError, "设备控制服务未配置")
}
if err := h.deviceService.GatewaySetWiFi(c.UserContext(), info.IMEI, &dto.SetWiFiRequest{
SSID: req.SSID, Password: req.Password, Enabled: req.Enabled,
}); err != nil {
h.logger.Error("Gateway配置WiFi失败",
zap.String("imei", info.IMEI),
@@ -292,10 +296,11 @@ func (h *ClientDeviceHandler) SwitchCard(c *fiber.Ctx) error {
return err
}
// 调用 Gateway 切卡CardNo 传设备 IMEI
if err := h.gatewayClient.SwitchCard(c.UserContext(), &gateway.SwitchCardReq{
CardNo: info.IMEI,
ICCID: req.TargetICCID,
if h.deviceService == nil {
return errors.New(errors.CodeInternalError, "设备控制服务未配置")
}
if err := h.deviceService.GatewaySwitchCard(c.UserContext(), info.IMEI, &dto.SwitchCardRequest{
TargetICCID: req.TargetICCID,
}); err != nil {
h.logger.Error("Gateway切卡失败",
zap.String("imei", info.IMEI),

View File

@@ -2,9 +2,10 @@ package app
import (
"context"
"strconv"
"strings"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
@@ -16,7 +17,6 @@ import (
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"
@@ -36,7 +36,7 @@ type ClientRealnameHandler struct {
carrierStore *postgres.CarrierStore
gatewayClient *gateway.Client
logger *zap.Logger
manualTriggerSvc *pollingSvc.ManualTriggerService // 手动触发服务可为nilnil时跳过自动触发
observationSeries cardObservationApp.BestEffortSeriesDispatcher
}
// NewClientRealnameHandler 创建 C 端实名认证处理器
@@ -48,7 +48,7 @@ func NewClientRealnameHandler(
carrierStore *postgres.CarrierStore,
gatewayClient *gateway.Client,
logger *zap.Logger,
manualTriggerSvc *pollingSvc.ManualTriggerService, // 可为nil
_ *pollingSvc.ManualTriggerService, // 兼容旧构造签名;获取链接改由 0/3/5 观测序列收敛
) *ClientRealnameHandler {
return &ClientRealnameHandler{
assetService: assetSvc,
@@ -58,10 +58,14 @@ func NewClientRealnameHandler(
carrierStore: carrierStore,
gatewayClient: gatewayClient,
logger: logger,
manualTriggerSvc: manualTriggerSvc,
}
}
// 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 {
@@ -138,15 +142,29 @@ func (h *ClientRealnameHandler) GetRealnameLink(c *fiber.Ctx) error {
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)
}
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) {
@@ -275,33 +293,3 @@ func (h *ClientRealnameHandler) findFirstBoundCard(c *fiber.Ctx, deviceID uint)
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))
}

View File

@@ -0,0 +1,79 @@
package callback
import (
"context"
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// SystemConfigReader 提供运营商回调运行时开关读取能力。
type SystemConfigReader interface {
Get(ctx context.Context, key string) (string, error)
}
func carrierCallbackEnabled(ctx context.Context, reader SystemConfigReader, key string) (bool, error) {
if reader == nil {
return false, apperrors.New(apperrors.CodeInternalError, "运营商回调启停配置未装配")
}
value, err := reader.Get(ctx, key)
if err != nil {
return false, apperrors.Wrap(apperrors.CodeInternalError, err, "读取运营商回调启停配置失败")
}
enabled, err := strconv.ParseBool(value)
if err != nil {
return false, apperrors.Wrap(apperrors.CodeInternalError, err, "运营商回调启停配置值无效")
}
return enabled, nil
}
func recordDisabledCarrierCallback(
ctx context.Context,
repository *integrationlog.Repository,
provider string,
operation string,
integrationPrefix string,
body []byte,
contentType string,
) error {
if repository == nil {
return apperrors.New(apperrors.CodeInternalError, "运营商回调留痕能力未配置")
}
payloadHash := shortHashBytes(body)
key := integrationPrefix + "-disabled-body:" + payloadHash
requestID := middleware.GetRequestIDFromContext(ctx)
log, created, err := repository.RecordInbound(ctx, integrationlog.InboundAttempt{
IntegrationID: integrationPrefix + "-disabled:" + shortHash(key),
IdempotencyKey: key,
Provider: provider,
Operation: operation,
ResourceType: constants.CardObservationResourceTypeCard,
RawPayload: body,
ContentType: contentType,
RequestID: requestID,
CorrelationID: requestID,
})
if err != nil {
return err
}
if !created {
if log == nil || log.Result != constants.IntegrationResultPending {
return nil
}
claimed, claimErr := repository.ClaimExpiredInboundPending(ctx, log.IntegrationID, constants.IntegrationInboundProcessingLease)
if claimErr != nil || !claimed {
return claimErr
}
}
_, err = repository.Complete(ctx, log.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultIgnored,
HTTPStatus: fiber.StatusOK,
ResponseSummary: map[string]any{"reason": "后台配置已关闭该运营商回调"},
})
return err
}

View File

@@ -0,0 +1,171 @@
package callback
import (
"context"
"time"
"github.com/bytedance/sonic"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// CMCCRealnameHandler 处理中国移动实名成功 JSON 回调。
type CMCCRealnameHandler struct {
translator *carriercallback.CMCCRealnameTranslator
resolver *carriercallback.CMCCCardResolver
integration *integrationlog.Repository
observation *cardapp.Service
series ResourceSeriesCompleter
config SystemConfigReader
logger *zap.Logger
}
// NewCMCCRealnameHandler 创建中国移动实名回调 Handler。
func NewCMCCRealnameHandler(translator *carriercallback.CMCCRealnameTranslator, resolver *carriercallback.CMCCCardResolver, integration *integrationlog.Repository, observation *cardapp.Service, series ResourceSeriesCompleter, config SystemConfigReader, logger *zap.Logger) *CMCCRealnameHandler {
return &CMCCRealnameHandler{translator: translator, resolver: resolver, integration: integration, observation: observation, series: series, config: config, logger: logger}
}
// Realname 接收中国移动实名结果并返回固定成功应答。
// POST /api/callback/carriers/cmcc/realname
func (h *CMCCRealnameHandler) Realname(c *fiber.Ctx) error {
startedAt := time.Now()
enabled, err := carrierCallbackEnabled(c.UserContext(), h.config, constants.SystemConfigCarrierCallbackCMCCRealnameEnabled)
if err != nil {
if h.logger != nil {
h.logger.Error("读取移动实名回调启停配置失败,已按关闭处理并返回成功", zap.Error(err))
}
return sendCMCCSuccess(c, startedAt)
}
if !enabled {
if err := recordDisabledCarrierCallback(c.UserContext(), h.integration, constants.IntegrationProviderCMCC, constants.IntegrationOperationCMCCRealnameCallback, "cmcc-realname", c.Body(), c.Get("Content-Type")); err != nil && h.logger != nil {
h.logger.Error("移动实名回调已关闭,但留痕失败", zap.Error(err))
}
return sendCMCCSuccess(c, startedAt)
}
if err := h.process(c.UserContext(), c.Body(), c.Get("Content-Type")); err != nil && h.logger != nil {
h.logger.Error("处理移动实名回调失败,已按运营商约定返回成功", zap.Error(err))
}
return sendCMCCSuccess(c, startedAt)
}
func (h *CMCCRealnameHandler) process(ctx context.Context, body []byte, contentType string) error {
if h == nil || h.translator == nil || h.resolver == nil || h.integration == nil || h.observation == nil {
return apperrors.New(apperrors.CodeInternalError, "移动实名回调能力未完整配置")
}
translated, translateErr := h.translator.Translate(body)
idempotencyKey := cmccIdempotencyKey(body, translated)
integrationID := "cmcc-realname:" + shortHash(idempotencyKey)
requestID := middleware.GetRequestIDFromContext(ctx)
log, created, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IntegrationID: integrationID, IdempotencyKey: idempotencyKey,
Provider: constants.IntegrationProviderCMCC, Operation: constants.IntegrationOperationCMCCRealnameCallback,
ResourceType: constants.CardObservationResourceTypeCard, ResourceKey: optionalHashedResourceKey(translated.ICCID),
ExternalID: translated.BusiSeq, RawPayload: body, ContentType: contentType, RequestID: requestID, CorrelationID: requestID,
})
if err != nil {
if isAppConflict(err) {
return h.recordConflict(ctx, body, contentType, idempotencyKey, translated, requestID)
}
return err
}
if !created {
if log.Result != constants.IntegrationResultPending {
return nil
}
claimed, claimErr := h.integration.ClaimExpiredInboundPending(ctx, log.IntegrationID, constants.IntegrationInboundProcessingLease)
if claimErr != nil || !claimed {
return claimErr
}
}
if translateErr != nil {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultInvalidPayload, false, "报文或业务结果无效")
}
cards, err := h.resolver.Resolve(ctx, translated.ICCID)
if err != nil {
return h.fail(ctx, log.IntegrationID, err)
}
if len(cards) == 0 {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultNotFound, false, "精确列未找到卡")
}
if len(cards) > 1 {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultConflict, false, "精确列匹配多张卡")
}
card := cards[0]
decision, err := h.observation.ApplyCardObservation(ctx, carddomain.RealnameObservation{
CardID: card.ID, Verified: true,
Metadata: carddomain.ObservationMetadata{
ObservationID: log.IntegrationID, Source: constants.CardObservationSourceCarrierCallback,
Scene: constants.CardObservationSceneCarrierCallback, ObservedAt: time.Now().UTC(),
RequestID: optionalStringValue(requestID), CorrelationID: optionalStringValue(requestID), UpstreamSummary: "移动实名成功",
},
})
if err != nil {
return h.fail(ctx, log.IntegrationID, err)
}
if h.series != nil {
if err := h.series.CompleteResourceSeries(ctx, constants.CardObservationResourceTypeCard, uintString(card.ID), constants.CardObservationSyncTypeRealname); err != nil && h.logger != nil {
h.logger.Warn("移动实名事实已应用但提前完成观测序列失败", zap.Uint("card_id", card.ID), zap.Error(err))
}
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用")
}
func (h *CMCCRealnameHandler) recordConflict(ctx context.Context, body []byte, contentType, baseKey string, translated carriercallback.CMCCRealnameTranslation, requestID *string) error {
key := baseKey + ":conflict:" + shortHashBytes(body)
log, created, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{IntegrationID: "cmcc-realname-conflict:" + shortHash(key), IdempotencyKey: key, Provider: constants.IntegrationProviderCMCC, Operation: constants.IntegrationOperationCMCCRealnameCallback, ResourceType: constants.CardObservationResourceTypeCard, ResourceKey: optionalHashedResourceKey(translated.ICCID), ExternalID: translated.BusiSeq, RawPayload: body, ContentType: contentType, RequestID: requestID, CorrelationID: requestID})
if err != nil {
return err
}
if !created {
if log.Result != constants.IntegrationResultPending {
return nil
}
claimed, claimErr := h.integration.ClaimExpiredInboundPending(ctx, log.IntegrationID, constants.IntegrationInboundProcessingLease)
if claimErr != nil || !claimed {
return claimErr
}
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultConflict, false, "同一幂等语义对应不同载荷")
}
func (h *CMCCRealnameHandler) complete(ctx context.Context, integrationID, result string, changed bool, reason string) error {
_, err := h.integration.Complete(ctx, integrationID, integrationlog.Completion{Result: result, HTTPStatus: fiber.StatusOK, StateChanged: changed, ResponseSummary: map[string]any{"reason": reason}})
return err
}
func (h *CMCCRealnameHandler) fail(ctx context.Context, integrationID string, original error) error {
if err := h.complete(ctx, integrationID, constants.IntegrationResultFailed, false, "内部处理失败"); err != nil && h.logger != nil {
h.logger.Error("移动实名回调失败终态写入失败", zap.String("integration_id", integrationID), zap.Error(err))
}
return original
}
func cmccIdempotencyKey(body []byte, translated carriercallback.CMCCRealnameTranslation) string {
if translated.BusiSeq != "" {
return "cmcc-external:" + shortHash(translated.BusiSeq)
}
return "cmcc-body:" + shortHashBytes(body)
}
type cmccSuccessResponse struct {
Code int `json:"code"`
Message string `json:"msg"`
Timestamp string `json:"timestamp"`
}
func sendCMCCSuccess(c *fiber.Ctx, startedAt time.Time) error {
body, err := sonic.Marshal(cmccSuccessResponse{Code: 200, Message: "success", Timestamp: startedAt.Format("2006-01-02 15:04:05")})
if err != nil {
return apperrors.Wrap(apperrors.CodeInternalError, err, "生成移动回调应答失败")
}
c.Type("json", "utf-8")
return c.Status(fiber.StatusOK).Send(body)
}

View File

@@ -0,0 +1,243 @@
package callback
import (
"context"
"crypto/sha256"
"encoding/hex"
stderrors "errors"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// ResourceSeriesCompleter 提前完成同资源未执行的观测序列。
type ResourceSeriesCompleter interface {
CompleteResourceSeries(ctx context.Context, resourceType, resourceID, syncType string) error
}
// CTCCRealnameHandler 处理电信实名 XML 回调。
type CTCCRealnameHandler struct {
translator *carriercallback.CTCCRealnameTranslator
resolver *carriercallback.CTCCCardResolver
integration *integrationlog.Repository
observation *cardapp.Service
series ResourceSeriesCompleter
config SystemConfigReader
logger *zap.Logger
}
// NewCTCCRealnameHandler 创建电信实名回调 Handler。
func NewCTCCRealnameHandler(
translator *carriercallback.CTCCRealnameTranslator,
resolver *carriercallback.CTCCCardResolver,
integration *integrationlog.Repository,
observation *cardapp.Service,
series ResourceSeriesCompleter,
config SystemConfigReader,
logger *zap.Logger,
) *CTCCRealnameHandler {
return &CTCCRealnameHandler{
translator: translator, resolver: resolver, integration: integration,
observation: observation, series: series, config: config, logger: logger,
}
}
// Realname 接收电信实名结果并返回固定成功应答。
// POST /api/callback/carriers/ctcc/realname
func (h *CTCCRealnameHandler) Realname(c *fiber.Ctx) error {
startedAt := time.Now()
enabled, err := carrierCallbackEnabled(c.UserContext(), h.config, constants.SystemConfigCarrierCallbackCTCCRealnameEnabled)
if err != nil {
if h.logger != nil {
h.logger.Error("读取电信实名回调启停配置失败,已按关闭处理并返回成功", zap.Error(err))
}
return sendCTCCSuccess(c, startedAt)
}
if !enabled {
if err := recordDisabledCarrierCallback(c.UserContext(), h.integration, constants.IntegrationProviderCTCC, constants.IntegrationOperationCTCCRealnameCallback, "ctcc-realname", c.Body(), c.Get("Content-Type")); err != nil && h.logger != nil {
h.logger.Error("电信实名回调已关闭,但留痕失败", zap.Error(err))
}
return sendCTCCSuccess(c, startedAt)
}
if err := h.process(c.UserContext(), c.Body(), c.Get("Content-Type")); err != nil && h.logger != nil {
h.logger.Error("处理电信实名回调失败,已按运营商约定返回成功", zap.Error(err))
}
return sendCTCCSuccess(c, startedAt)
}
func (h *CTCCRealnameHandler) process(ctx context.Context, body []byte, contentType string) error {
if h == nil || h.translator == nil || h.resolver == nil || h.integration == nil || h.observation == nil {
return apperrors.New(apperrors.CodeInternalError, "电信实名回调能力未完整配置")
}
translated, translateErr := h.translator.Translate(body)
idempotencyKey := ctccIdempotencyKey(body, translated)
integrationID := "ctcc-realname:" + shortHash(idempotencyKey)
resourceKey := optionalHashedResourceKey(translated.ICCID)
requestID := middleware.GetRequestIDFromContext(ctx)
log, created, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IntegrationID: integrationID, IdempotencyKey: idempotencyKey,
Provider: constants.IntegrationProviderCTCC, Operation: constants.IntegrationOperationCTCCRealnameCallback,
ExternalID: translated.GroupTransactionID, ResourceType: constants.CardObservationResourceTypeCard,
ResourceKey: resourceKey, RawPayload: body, ContentType: contentType,
RequestID: requestID, CorrelationID: requestID,
})
if err != nil {
if isAppConflict(err) {
return h.recordPayloadConflict(ctx, body, contentType, idempotencyKey, translated, requestID)
}
return err
}
if !created {
claimed, claimErr := h.claimExistingPending(ctx, log)
if claimErr != nil || !claimed {
return claimErr
}
}
if translateErr != nil {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultInvalidPayload, false, "报文解析或 ICCID 校验失败")
}
if translated.Action != carriercallback.CTCCRealnameActionVerified {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultIgnored, false, "合法业务结果无需更新实名事实")
}
cards, err := h.resolver.Resolve(ctx, translated.ICCID)
if err != nil {
return h.failPending(ctx, log.IntegrationID, err)
}
if len(cards) == 0 {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultNotFound, false, "精确列未找到卡")
}
if len(cards) > 1 {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultConflict, false, "精确列匹配多张卡")
}
card := cards[0]
now := time.Now().UTC()
decision, err := h.observation.ApplyCardObservation(ctx, carddomain.RealnameObservation{
CardID: card.ID, Verified: true,
Metadata: carddomain.ObservationMetadata{
ObservationID: log.IntegrationID, Source: constants.CardObservationSourceCarrierCallback,
Scene: constants.CardObservationSceneCarrierCallback, ObservedAt: now,
RequestID: optionalStringValue(requestID), CorrelationID: optionalStringValue(requestID),
UpstreamSummary: "电信实名补录成功",
},
})
if err != nil {
return h.failPending(ctx, log.IntegrationID, err)
}
if h.series != nil {
if err := h.series.CompleteResourceSeries(ctx, constants.CardObservationResourceTypeCard, uintString(card.ID), constants.CardObservationSyncTypeRealname); err != nil && h.logger != nil {
h.logger.Warn("电信实名事实已应用但提前完成观测序列失败", zap.Uint("card_id", card.ID), zap.Error(err))
}
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用")
}
func (h *CTCCRealnameHandler) failPending(ctx context.Context, integrationID string, original error) error {
if err := h.complete(ctx, integrationID, constants.IntegrationResultFailed, false, "内部处理失败"); err != nil && h.logger != nil {
h.logger.Error("电信实名回调失败终态写入失败", zap.String("integration_id", integrationID), zap.Error(err))
}
return original
}
func (h *CTCCRealnameHandler) recordPayloadConflict(ctx context.Context, body []byte, contentType, baseKey string, translated carriercallback.CTCCRealnameTranslation, requestID *string) error {
conflictKey := baseKey + ":conflict:" + shortHashBytes(body)
log, created, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IntegrationID: "ctcc-realname-conflict:" + shortHash(conflictKey), IdempotencyKey: conflictKey,
Provider: constants.IntegrationProviderCTCC, Operation: constants.IntegrationOperationCTCCRealnameCallback,
ExternalID: translated.GroupTransactionID, ResourceType: constants.CardObservationResourceTypeCard,
ResourceKey: optionalHashedResourceKey(translated.ICCID), RawPayload: body, ContentType: contentType,
RequestID: requestID, CorrelationID: requestID,
})
if err != nil {
return err
}
if !created {
claimed, claimErr := h.claimExistingPending(ctx, log)
if claimErr != nil || !claimed {
return claimErr
}
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultConflict, false, "同一幂等语义对应不同载荷")
}
func (h *CTCCRealnameHandler) claimExistingPending(ctx context.Context, log *model.IntegrationLog) (bool, error) {
if log == nil || log.Result != constants.IntegrationResultPending {
return false, nil
}
return h.integration.ClaimExpiredInboundPending(ctx, log.IntegrationID, constants.IntegrationInboundProcessingLease)
}
func (h *CTCCRealnameHandler) complete(ctx context.Context, integrationID, result string, stateChanged bool, reason string) error {
_, err := h.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: result, HTTPStatus: fiber.StatusOK, StateChanged: stateChanged,
ResponseSummary: map[string]any{"reason": reason},
})
return err
}
func ctccIdempotencyKey(body []byte, translated carriercallback.CTCCRealnameTranslation) string {
if strings.TrimSpace(translated.GroupTransactionID) != "" {
return "ctcc-external:" + shortHash(strings.TrimSpace(translated.GroupTransactionID))
}
return "ctcc-body:" + shortHashBytes(body)
}
func optionalHashedResourceKey(iccid string) *string {
if strings.TrimSpace(iccid) == "" {
return nil
}
value := "iccid-sha256:" + shortHash(strings.TrimSpace(iccid))
return &value
}
func shortHash(value string) string {
return shortHashBytes([]byte(value))
}
func shortHashBytes(value []byte) string {
sum := sha256.Sum256(value)
return hex.EncodeToString(sum[:])[:32]
}
func optionalStringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func isAppConflict(err error) bool {
var appErr *apperrors.AppError
return stderrors.As(err, &appErr) && appErr.Code == apperrors.CodeConflict
}
func uintString(value uint) string {
return strconv.FormatUint(uint64(value), 10)
}
type ctccSuccessResponse struct {
Code int `json:"code"`
Message string `json:"msg"`
Timestamp string `json:"timestamp"`
}
func sendCTCCSuccess(c *fiber.Ctx, startedAt time.Time) error {
body, err := sonic.Marshal(ctccSuccessResponse{Code: 200, Message: "success", Timestamp: startedAt.Format("2006-01-02 15:04:05")})
if err != nil {
return apperrors.Wrap(apperrors.CodeInternalError, err, "生成电信回调应答失败")
}
c.Type("json", "utf-8")
return c.Status(fiber.StatusOK).Send(body)
}

View File

@@ -0,0 +1,168 @@
package callback
import (
"context"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// CUCCRealnameHandler 处理中国联通实名成功回调。
type CUCCRealnameHandler struct {
translator *carriercallback.CUCCRealnameTranslator
resolver *carriercallback.CUCCCardResolver
integration *integrationlog.Repository
observation *cardapp.Service
series ResourceSeriesCompleter
config SystemConfigReader
logger *zap.Logger
}
// NewCUCCRealnameHandler 创建中国联通实名成功回调 Handler。
func NewCUCCRealnameHandler(translator *carriercallback.CUCCRealnameTranslator, resolver *carriercallback.CUCCCardResolver, integration *integrationlog.Repository, observation *cardapp.Service, series ResourceSeriesCompleter, config SystemConfigReader, logger *zap.Logger) *CUCCRealnameHandler {
return &CUCCRealnameHandler{translator: translator, resolver: resolver, integration: integration, observation: observation, series: series, config: config, logger: logger}
}
// Realname 接收中国联通实名成功结果并返回固定成功应答。
// POST /api/callback/carriers/cucc/realname
func (h *CUCCRealnameHandler) Realname(c *fiber.Ctx) error {
startedAt := time.Now()
enabled, err := carrierCallbackEnabled(c.UserContext(), h.config, constants.SystemConfigCarrierCallbackCUCCRealnameEnabled)
if err != nil {
if h.logger != nil {
h.logger.Error("读取联通实名回调启停配置失败,已按关闭处理并返回成功", zap.Error(err))
}
return sendCUCCSuccess(c, startedAt)
}
if !enabled {
if err := recordDisabledCarrierCallback(c.UserContext(), h.integration, constants.IntegrationProviderCUCC, constants.IntegrationOperationCUCCRealnameCallback, "cucc-realname", c.Body(), c.Get("Content-Type")); err != nil && h.logger != nil {
h.logger.Error("联通实名回调已关闭,但留痕失败", zap.Error(err))
}
return sendCUCCSuccess(c, startedAt)
}
if err := h.process(c.UserContext(), c.Body(), c.Get("Content-Type")); err != nil && h.logger != nil {
h.logger.Error("处理联通实名回调失败,已按运营商约定返回成功", zap.Error(err))
}
return sendCUCCSuccess(c, startedAt)
}
func (h *CUCCRealnameHandler) process(ctx context.Context, body []byte, contentType string) error {
if h == nil || h.translator == nil || h.resolver == nil || h.integration == nil || h.observation == nil {
return apperrors.New(apperrors.CodeInternalError, "联通实名回调能力未完整配置")
}
translated, translateErr := h.translator.Translate(body)
key := cuccRealnameIdempotencyKey(body, translated, translateErr == nil)
integrationID := "cucc-realname:" + shortHash(key)
requestID := middleware.GetRequestIDFromContext(ctx)
log, created, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IntegrationID: integrationID, IdempotencyKey: key,
Provider: constants.IntegrationProviderCUCC, Operation: constants.IntegrationOperationCUCCRealnameCallback,
ResourceType: constants.CardObservationResourceTypeCard, ResourceKey: optionalHashedResourceKey(translated.ICCID),
RawPayload: body, ContentType: contentType, RequestID: requestID, CorrelationID: requestID,
})
if err != nil {
if isAppConflict(err) {
return h.recordConflict(ctx, body, contentType, key, translated, requestID)
}
return err
}
if !created {
claimed, claimErr := h.claimPending(ctx, log)
if claimErr != nil || !claimed {
return claimErr
}
}
if translateErr != nil {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultInvalidPayload, false, "报文、嵌套 data 或字段校验失败")
}
cards, err := h.resolver.Resolve(ctx, translated.ICCID)
if err != nil {
return h.fail(ctx, log.IntegrationID, err)
}
if len(cards) == 0 {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultNotFound, false, "精确列未找到卡")
}
if len(cards) > 1 {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultConflict, false, "精确列匹配多张卡")
}
card := cards[0]
decision, err := h.observation.ApplyCardObservation(ctx, carddomain.RealnameObservation{
CardID: card.ID, Verified: true,
Metadata: carddomain.ObservationMetadata{
ObservationID: log.IntegrationID, Source: constants.CardObservationSourceCarrierCallback,
Scene: constants.CardObservationSceneCarrierCallback, ObservedAt: time.Now().UTC(),
RequestID: optionalStringValue(requestID), CorrelationID: optionalStringValue(requestID),
UpstreamSummary: "联通实名成功,变更时间已留痕",
},
})
if err != nil {
return h.fail(ctx, log.IntegrationID, err)
}
if h.series != nil {
if err := h.series.CompleteResourceSeries(ctx, constants.CardObservationResourceTypeCard, uintString(card.ID), constants.CardObservationSyncTypeRealname); err != nil && h.logger != nil {
h.logger.Warn("联通实名事实已应用但提前完成观测序列失败", zap.Uint("card_id", card.ID), zap.Error(err))
}
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用")
}
func (h *CUCCRealnameHandler) recordConflict(ctx context.Context, body []byte, contentType, baseKey string, translated carriercallback.CUCCRealnameTranslation, requestID *string) error {
key := baseKey + ":conflict:" + shortHashBytes(body)
log, created, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IntegrationID: "cucc-realname-conflict:" + shortHash(key), IdempotencyKey: key,
Provider: constants.IntegrationProviderCUCC, Operation: constants.IntegrationOperationCUCCRealnameCallback,
ResourceType: constants.CardObservationResourceTypeCard, ResourceKey: optionalHashedResourceKey(translated.ICCID),
RawPayload: body, ContentType: contentType, RequestID: requestID, CorrelationID: requestID,
})
if err != nil {
return err
}
if !created {
claimed, claimErr := h.claimPending(ctx, log)
if claimErr != nil || !claimed {
return claimErr
}
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultConflict, false, "同一实名语义对应不同载荷")
}
func (h *CUCCRealnameHandler) claimPending(ctx context.Context, log *model.IntegrationLog) (bool, error) {
if log == nil || log.Result != constants.IntegrationResultPending {
return false, nil
}
return h.integration.ClaimExpiredInboundPending(ctx, log.IntegrationID, constants.IntegrationInboundProcessingLease)
}
func (h *CUCCRealnameHandler) complete(ctx context.Context, integrationID, result string, changed bool, reason string) error {
_, err := h.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: result, HTTPStatus: fiber.StatusOK, StateChanged: changed,
ResponseSummary: map[string]any{"reason": reason},
})
return err
}
func (h *CUCCRealnameHandler) fail(ctx context.Context, integrationID string, original error) error {
if err := h.complete(ctx, integrationID, constants.IntegrationResultFailed, false, "内部处理失败"); err != nil && h.logger != nil {
h.logger.Error("联通实名回调失败终态写入失败", zap.String("integration_id", integrationID), zap.Error(err))
}
return original
}
func cuccRealnameIdempotencyKey(body []byte, translated carriercallback.CUCCRealnameTranslation, valid bool) string {
if valid {
semantic := strings.TrimSpace(translated.ICCID) + "\x00" + strings.TrimSpace(translated.DateChanged)
return "cucc-realname-semantic:" + shortHash(semantic)
}
return "cucc-realname-body:" + shortHashBytes(body)
}

View File

@@ -0,0 +1,159 @@
package callback
import (
"context"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// CUCCRealnameRemovalHandler 处理中国联通解除实名留痕回调。
type CUCCRealnameRemovalHandler struct {
translator *carriercallback.CUCCRealnameRemovalTranslator
resolver *carriercallback.CUCCCardResolver
integration *integrationlog.Repository
config SystemConfigReader
logger *zap.Logger
}
// NewCUCCRealnameRemovalHandler 创建中国联通解除实名回调 Handler。
func NewCUCCRealnameRemovalHandler(translator *carriercallback.CUCCRealnameRemovalTranslator, resolver *carriercallback.CUCCCardResolver, integration *integrationlog.Repository, config SystemConfigReader, logger *zap.Logger) *CUCCRealnameRemovalHandler {
return &CUCCRealnameRemovalHandler{translator: translator, resolver: resolver, integration: integration, config: config, logger: logger}
}
// Remove 接收联通解除实名结果,仅精确识别资源并留痕忽略。
// POST /api/callback/carriers/cucc/realname/remove
func (h *CUCCRealnameRemovalHandler) Remove(c *fiber.Ctx) error {
startedAt := time.Now()
enabled, err := carrierCallbackEnabled(c.UserContext(), h.config, constants.SystemConfigCarrierCallbackCUCCRealnameRemovalEnabled)
if err != nil {
if h.logger != nil {
h.logger.Error("读取联通解除实名回调启停配置失败,已按关闭处理并返回成功", zap.Error(err))
}
return sendCUCCSuccess(c, startedAt)
}
if !enabled {
if err := recordDisabledCarrierCallback(c.UserContext(), h.integration, constants.IntegrationProviderCUCC, constants.IntegrationOperationCUCCRealnameRemovalCallback, "cucc-realname-remove", c.Body(), c.Get("Content-Type")); err != nil && h.logger != nil {
h.logger.Error("联通解除实名回调已关闭,但留痕失败", zap.Error(err))
}
return sendCUCCSuccess(c, startedAt)
}
if err := h.process(c.UserContext(), c.Body(), c.Get("Content-Type")); err != nil && h.logger != nil {
h.logger.Error("处理联通解除实名回调失败,已按运营商约定返回成功", zap.Error(err))
}
return sendCUCCSuccess(c, startedAt)
}
func (h *CUCCRealnameRemovalHandler) process(ctx context.Context, body []byte, contentType string) error {
if h == nil || h.translator == nil || h.resolver == nil || h.integration == nil {
return apperrors.New(apperrors.CodeInternalError, "联通解除实名回调能力未完整配置")
}
translated, translateErr := h.translator.Translate(body)
key := cuccRemovalIdempotencyKey(body, translated, translateErr == nil)
integrationID := "cucc-realname-remove:" + shortHash(key)
requestID := middleware.GetRequestIDFromContext(ctx)
log, created, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IntegrationID: integrationID, IdempotencyKey: key,
Provider: constants.IntegrationProviderCUCC, Operation: constants.IntegrationOperationCUCCRealnameRemovalCallback,
ResourceType: constants.CardObservationResourceTypeCard, ResourceKey: optionalHashedResourceKey(translated.ICCID),
RawPayload: body, ContentType: contentType, RequestID: requestID, CorrelationID: requestID,
})
if err != nil {
if isAppConflict(err) {
return h.recordConflict(ctx, body, contentType, key, translated, requestID)
}
return err
}
if !created {
claimed, claimErr := h.claimPending(ctx, log)
if claimErr != nil || !claimed {
return claimErr
}
}
if translateErr != nil {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultInvalidPayload, "报文、嵌套 data 或字段校验失败")
}
cards, err := h.resolver.Resolve(ctx, translated.ICCID)
if err != nil {
return h.fail(ctx, log.IntegrationID, err)
}
if len(cards) == 0 {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultNotFound, "精确列未找到卡")
}
if len(cards) > 1 {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultConflict, "精确列匹配多张卡")
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultIgnored, "解除实名仅留痕,不修改本地实名事实")
}
func (h *CUCCRealnameRemovalHandler) recordConflict(ctx context.Context, body []byte, contentType, baseKey string, translated carriercallback.CUCCRealnameRemovalTranslation, requestID *string) error {
key := baseKey + ":conflict:" + shortHashBytes(body)
log, created, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IntegrationID: "cucc-realname-remove-conflict:" + shortHash(key), IdempotencyKey: key,
Provider: constants.IntegrationProviderCUCC, Operation: constants.IntegrationOperationCUCCRealnameRemovalCallback,
ResourceType: constants.CardObservationResourceTypeCard, ResourceKey: optionalHashedResourceKey(translated.ICCID),
RawPayload: body, ContentType: contentType, RequestID: requestID, CorrelationID: requestID,
})
if err != nil {
return err
}
if !created {
claimed, claimErr := h.claimPending(ctx, log)
if claimErr != nil || !claimed {
return claimErr
}
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultConflict, "同一解除实名语义对应不同载荷")
}
func (h *CUCCRealnameRemovalHandler) claimPending(ctx context.Context, log *model.IntegrationLog) (bool, error) {
if log == nil || log.Result != constants.IntegrationResultPending {
return false, nil
}
return h.integration.ClaimExpiredInboundPending(ctx, log.IntegrationID, constants.IntegrationInboundProcessingLease)
}
func (h *CUCCRealnameRemovalHandler) complete(ctx context.Context, integrationID, result, reason string) error {
_, err := h.integration.Complete(ctx, integrationID, integrationlog.Completion{Result: result, HTTPStatus: fiber.StatusOK, ResponseSummary: map[string]any{"reason": reason}})
return err
}
func (h *CUCCRealnameRemovalHandler) fail(ctx context.Context, integrationID string, original error) error {
if err := h.complete(ctx, integrationID, constants.IntegrationResultFailed, "内部处理失败"); err != nil && h.logger != nil {
h.logger.Error("联通解除实名回调失败终态写入失败", zap.String("integration_id", integrationID), zap.Error(err))
}
return original
}
func cuccRemovalIdempotencyKey(body []byte, translated carriercallback.CUCCRealnameRemovalTranslation, valid bool) string {
if valid {
semantic := strings.TrimSpace(translated.ICCID) + "\x00" + strings.TrimSpace(translated.DateChanged)
return "cucc-semantic:" + shortHash(semantic)
}
return "cucc-body:" + shortHashBytes(body)
}
type cuccSuccessResponse struct {
Code int `json:"code"`
Message string `json:"msg"`
Timestamp string `json:"timestamp"`
}
func sendCUCCSuccess(c *fiber.Ctx, startedAt time.Time) error {
body, err := sonic.Marshal(cuccSuccessResponse{Code: 200, Message: "success", Timestamp: startedAt.Format("2006-01-02 15:04:05")})
if err != nil {
return apperrors.Wrap(apperrors.CodeInternalError, err, "生成联通回调应答失败")
}
c.Type("json", "utf-8")
return c.Status(fiber.StatusOK).Send(body)
}