Files
junhong_cmp_fiber/internal/handler/callback/ctcc_realname.go
break 5c4d17e9fc
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 联调延期
2026-07-24 19:59:24 +08:00

244 lines
9.7 KiB
Go

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)
}