收口七月卡状态回调与系列授权兼容契约
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

@@ -0,0 +1,41 @@
package cardobservation
import (
"context"
"time"
"gorm.io/gorm"
)
type suppressSeriesTriggerKey struct{}
// SuppressSeriesTriggerContext 标记观测结果驱动的业务评估,避免形成反向触发环。
func SuppressSeriesTriggerContext(ctx context.Context) context.Context {
return context.WithValue(ctx, suppressSeriesTriggerKey{}, true)
}
// IsSeriesTriggerSuppressed 判断当前业务调用是否来自观测结果消费。
func IsSeriesTriggerSuppressed(ctx context.Context) bool {
value, _ := ctx.Value(suppressSeriesTriggerKey{}).(bool)
return value
}
// SeriesRequestedEvent 是业务成功边界可靠请求观测序列的事实。
type SeriesRequestedEvent struct {
EventID string `json:"event_id"`
Scene string `json:"scene"`
ResourceType string `json:"resource_type"`
ResourceID uint `json:"resource_id"`
ResourceIDs []uint `json:"resource_ids,omitempty"`
SyncTypes []string `json:"sync_types"`
ExpectedValue string `json:"expected_value,omitempty"`
Source string `json:"source"`
OccurredAt time.Time `json:"occurred_at"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// SeriesEventWriter 在原业务事务中追加观测序列请求事件。
type SeriesEventWriter interface {
AppendSeriesRequested(ctx context.Context, tx *gorm.DB, event SeriesRequestedEvent) error
}

View File

@@ -23,6 +23,29 @@ type SeriesRequest struct {
CorrelationID string `json:"correlation_id,omitempty"`
}
// DeviceCardsSeriesRequest 描述需要在后台展开设备有效绑定卡的观测请求。
type DeviceCardsSeriesRequest struct {
DeviceID uint
Request SeriesRequest
}
// DeviceControlSeriesRequest 描述设备控制成功后需要在后台解析的差异化观测资源。
type DeviceControlSeriesRequest struct {
DeviceID uint
TargetICCID string
SourceCardID uint
TargetCardID uint
BoundCardIDs []uint
IncludeTargetTraffic bool
Request SeriesRequest
}
// RealnameCapabilitySeriesRequest 描述需要在后台判断运营商实名能力的观测请求。
type RealnameCapabilitySeriesRequest struct {
CarrierID uint
Request SeriesRequest
}
// SeriesTaskPayload 是固定三次 Asynq 任务的结构化载荷。
type SeriesTaskPayload struct {
SeriesID string `json:"series_id"`
@@ -46,7 +69,10 @@ type RunResult struct {
// SeriesCoordinator 管理活跃序列、尝试幂等和实际请求互斥。
type SeriesCoordinator interface {
Reserve(ctx context.Context, request SeriesRequest, candidateSeriesID string, candidateBaseTime time.Time) (seriesID string, baseTime time.Time, merged bool, err error)
Reserve(ctx context.Context, request SeriesRequest, candidateSeriesID string, candidateBaseTime time.Time) (seriesID string, baseTime time.Time, originalRequest SeriesRequest, merged bool, err error)
IsScheduled(ctx context.Context, seriesID string) (bool, error)
ClaimSchedule(ctx context.Context, seriesID string, attempt int) (bool, error)
ReleaseSchedule(ctx context.Context, seriesID string, attempt int)
IsCompleted(ctx context.Context, seriesID string) (bool, error)
ClaimAttempt(ctx context.Context, seriesID string, attempt int) (bool, error)
AcquireRequest(ctx context.Context, payload SeriesTaskPayload, provider string) (release func(), wait time.Duration, acquired bool, err error)
@@ -60,6 +86,14 @@ type SeriesScheduler interface {
Enqueue(ctx context.Context, payload SeriesTaskPayload) error
}
// BestEffortSeriesDispatcher 为读取入口提供不返回业务错误的轻量触发端口。
type BestEffortSeriesDispatcher interface {
Dispatch(ctx context.Context, request SeriesRequest)
DispatchDeviceCards(ctx context.Context, request DeviceCardsSeriesRequest)
DispatchDeviceControl(ctx context.Context, request DeviceControlSeriesRequest)
DispatchRealnameWithCapability(ctx context.Context, request RealnameCapabilitySeriesRequest)
}
// SeriesAttemptLogger 记录未访问 Gateway 的合并、互斥、限频和提前完成结果。
type SeriesAttemptLogger interface {
Record(ctx context.Context, payload SeriesTaskPayload, result, reason string) error
@@ -88,27 +122,58 @@ func NewSeriesTrigger(coordinator SeriesCoordinator, scheduler SeriesScheduler,
// Trigger 创建新序列;同场景未结束序列只留合并记录,不延长原序列。
func (s *SeriesTrigger) Trigger(ctx context.Context, request SeriesRequest) (string, bool, error) {
return s.trigger(ctx, request, uuid.NewString(), false)
}
// TriggerEvent 使用稳定业务事件键触发序列,至少一次重投不会再次创建任务。
func (s *SeriesTrigger) TriggerEvent(ctx context.Context, eventKey string, request SeriesRequest) (string, bool, error) {
seriesID := uuid.NewSHA1(uuid.NameSpaceOID, []byte(eventKey)).String()
return s.trigger(ctx, request, seriesID, true)
}
func (s *SeriesTrigger) trigger(ctx context.Context, request SeriesRequest, candidateSeriesID string, stable bool) (string, bool, error) {
if s == nil || s.coordinator == nil || s.scheduler == nil || s.logger == nil {
return "", false, errors.New(errors.CodeInternalError, "卡观测序列触发能力未完整配置")
}
request = normalizeSeriesTrace(request)
if err := validateSeriesRequest(request); err != nil {
return "", false, err
}
if stable {
scheduled, err := s.coordinator.IsScheduled(ctx, candidateSeriesID)
if err != nil {
return "", false, err
}
if scheduled {
if logErr := s.logger.RecordMerged(ctx, request, candidateSeriesID); logErr != nil {
return candidateSeriesID, true, logErr
}
return candidateSeriesID, true, nil
}
}
candidateBaseTime := s.now().UTC()
seriesID, baseTime, merged, err := s.coordinator.Reserve(ctx, request, uuid.NewString(), candidateBaseTime)
seriesID, baseTime, originalRequest, merged, err := s.coordinator.Reserve(ctx, request, candidateSeriesID, candidateBaseTime)
if err != nil {
return "", false, err
}
// 重复触发仍以稳定任务 ID 补齐首次入队的局部失败;已存在任务由 Asynq 去重,不会延长原序列。
for attempt := 1; attempt <= constants.CardObservationSeriesAttemptCount; attempt++ {
claimed, claimErr := s.coordinator.ClaimSchedule(ctx, seriesID, attempt)
if claimErr != nil {
return seriesID, merged, claimErr
}
if !claimed {
continue
}
scheduledAt := baseTime.Add(constants.CardObservationAttemptDelay(attempt))
payload := SeriesTaskPayload{
SeriesID: seriesID, Attempt: attempt, ScheduledAt: scheduledAt,
Scene: request.Scene, ResourceType: request.ResourceType, ResourceID: request.ResourceID,
SyncType: request.SyncType, ExpectedValue: request.ExpectedValue, Source: request.Source,
RequestID: request.RequestID, CorrelationID: request.CorrelationID,
Scene: originalRequest.Scene, ResourceType: originalRequest.ResourceType, ResourceID: originalRequest.ResourceID,
SyncType: originalRequest.SyncType, ExpectedValue: originalRequest.ExpectedValue, Source: originalRequest.Source,
RequestID: originalRequest.RequestID, CorrelationID: originalRequest.CorrelationID,
}
if err := s.scheduler.Enqueue(ctx, payload); err != nil {
s.coordinator.ReleaseSchedule(ctx, seriesID, attempt)
return seriesID, false, err
}
}
@@ -148,25 +213,25 @@ func (s *SeriesAttemptService) Execute(ctx context.Context, payload SeriesTaskPa
completed, err := s.coordinator.IsCompleted(ctx, payload.SeriesID)
if err != nil {
return err
return s.recordPreGatewayFailure(ctx, payload, "读取序列完成状态失败", err)
}
if completed {
return s.completeRemainingAttempts(ctx, payload, "序列已提前完成")
}
met, err := s.runner.ExpectationMet(ctx, payload)
if err != nil {
return err
return s.recordPreGatewayFailure(ctx, payload, "读取本地预期快照失败", err)
}
if met {
return s.completeRemainingAttempts(ctx, payload, "本地快照已达到预期")
}
provider, err := s.runner.Provider(ctx, payload)
if err != nil {
return err
return s.recordPreGatewayFailure(ctx, payload, "解析运营商接入失败", err)
}
release, wait, acquired, err := s.coordinator.AcquireRequest(ctx, payload, provider)
if err != nil {
return err
return s.recordPreGatewayFailure(ctx, payload, "获取实际请求互斥失败", err)
}
if !acquired {
return s.logger.Record(ctx, payload, constants.IntegrationResultIgnored, "实际 Gateway 请求正在执行")
@@ -194,6 +259,13 @@ func (s *SeriesAttemptService) Execute(ctx context.Context, payload SeriesTaskPa
return nil
}
func (s *SeriesAttemptService) recordPreGatewayFailure(ctx context.Context, payload SeriesTaskPayload, reason string, original error) error {
if logErr := s.logger.Record(ctx, payload, constants.IntegrationResultFailed, reason); logErr != nil {
return errors.Wrap(errors.CodeInternalError, original, reason+",且 Integration Log 写入失败")
}
return original
}
func (s *SeriesAttemptService) completeRemainingAttempts(ctx context.Context, payload SeriesTaskPayload, reason string) error {
baseTime := payload.ScheduledAt.Add(-constants.CardObservationAttemptDelay(payload.Attempt))
for attempt := payload.Attempt; attempt <= constants.CardObservationSeriesAttemptCount; attempt++ {
@@ -217,7 +289,8 @@ func (s *SeriesAttemptService) CompleteResourceSeries(ctx context.Context, resou
func validateSeriesRequest(request SeriesRequest) error {
if strings.TrimSpace(request.Scene) == "" || strings.TrimSpace(request.ResourceType) == "" ||
strings.TrimSpace(request.ResourceID) == "" || !validSyncType(request.SyncType) {
strings.TrimSpace(request.ResourceID) == "" || !validSyncType(request.SyncType) || !validObservationSource(request.Source) ||
strings.TrimSpace(request.RequestID) == "" || strings.TrimSpace(request.CorrelationID) == "" {
return errors.New(errors.CodeInvalidParam, "卡观测序列参数不完整")
}
return nil
@@ -227,7 +300,36 @@ func validateSeriesPayload(payload SeriesTaskPayload) error {
if payload.SeriesID == "" || payload.Attempt < 1 || payload.Attempt > constants.CardObservationSeriesAttemptCount {
return errors.New(errors.CodeInvalidParam, "卡观测序列任务载荷无效")
}
return validateSeriesRequest(SeriesRequest{Scene: payload.Scene, ResourceType: payload.ResourceType, ResourceID: payload.ResourceID, SyncType: payload.SyncType})
return validateSeriesRequest(SeriesRequest{
Scene: payload.Scene, ResourceType: payload.ResourceType, ResourceID: payload.ResourceID,
SyncType: payload.SyncType, Source: payload.Source, RequestID: payload.RequestID, CorrelationID: payload.CorrelationID,
})
}
func normalizeSeriesTrace(request SeriesRequest) SeriesRequest {
request.RequestID = strings.TrimSpace(request.RequestID)
request.CorrelationID = strings.TrimSpace(request.CorrelationID)
if request.RequestID == "" && request.CorrelationID == "" {
traceID := uuid.NewString()
request.RequestID = traceID
request.CorrelationID = traceID
} else if request.RequestID == "" {
request.RequestID = request.CorrelationID
} else if request.CorrelationID == "" {
request.CorrelationID = request.RequestID
}
return request
}
func validObservationSource(source string) bool {
switch source {
case constants.CardObservationSourcePolling, constants.CardObservationSourceManualSync,
constants.CardObservationSourceManualOverride, constants.CardObservationSourceCarrierCallback,
constants.CardObservationSourceBusinessEvent:
return true
default:
return false
}
}
func validSyncType(syncType string) bool {

View File

@@ -5,7 +5,6 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
stderrors "errors"
"time"
"gorm.io/gorm"
@@ -31,19 +30,11 @@ type ChangeAudit struct {
CorrelationID string
}
// AuditWriter 由 tech-global-audit 提供事务内实现
// AuditWriter 可选接收系统配置事务内审计事实
type AuditWriter interface {
WriteConfigChange(ctx context.Context, tx *gorm.DB, audit ChangeAudit) error
}
// UnavailableAuditWriter 是统一审计尚未注入时的失败关闭策略。
type UnavailableAuditWriter struct{}
// WriteConfigChange 拒绝在缺少统一审计时修改敏感配置。
func (UnavailableAuditWriter) WriteConfigChange(_ context.Context, _ *gorm.DB, _ ChangeAudit) error {
return stderrors.New("统一审计接缝尚未配置")
}
// UpdateService 执行单 Key 校验、事务更新、审计和提交后缓存失效。
type UpdateService struct {
db *gorm.DB
@@ -63,9 +54,6 @@ func NewUpdateService(
alerts configinfra.AlertSink,
now func() time.Time,
) *UpdateService {
if audit == nil {
audit = UnavailableAuditWriter{}
}
if now == nil {
now = time.Now
}
@@ -133,14 +121,16 @@ func (s *UpdateService) Execute(ctx context.Context, key string, request dto.Upd
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
if err := s.audit.WriteConfigChange(ctx, tx, ChangeAudit{
OperatorID: operatorID, OperationType: "system_config_update", Description: "更新受控系统配置",
ConfigKey: key,
BeforeData: map[string]any{"config_key": key, "value": auditValue(definition, beforeValue)},
AfterData: map[string]any{"config_key": key, "value": auditValue(definition, request.Value)},
RequestID: requestID, CorrelationID: requestID,
}); err != nil {
return err
if s.audit != nil {
if err := s.audit.WriteConfigChange(ctx, tx, ChangeAudit{
OperatorID: operatorID, OperationType: "system_config_update", Description: "更新受控系统配置",
ConfigKey: key,
BeforeData: map[string]any{"config_key": key, "value": auditValue(definition, beforeValue)},
AfterData: map[string]any{"config_key": key, "value": auditValue(definition, request.Value)},
RequestID: requestID, CorrelationID: requestID,
}); err != nil {
return err
}
}
saved = existing
return nil

View File

@@ -0,0 +1,37 @@
package bootstrap
import (
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
func registerCarrierCallbackConfigDefinitions(registry *systemconfig.Registry, logger *zap.Logger) {
if registry == nil {
return
}
definitions := []systemconfig.Definition{
{Key: constants.SystemConfigCarrierCallbackCTCCRealnameEnabled, Module: constants.SystemConfigModuleCarrierCallback, ValueType: constants.SystemConfigTypeBool, DefaultValue: "false", Description: "是否处理中国电信实名回调", Control: "switch"},
{Key: constants.SystemConfigCarrierCallbackCMCCRealnameEnabled, Module: constants.SystemConfigModuleCarrierCallback, ValueType: constants.SystemConfigTypeBool, DefaultValue: "false", Description: "是否处理中国移动实名回调", Control: "switch"},
{Key: constants.SystemConfigCarrierCallbackCUCCRealnameEnabled, Module: constants.SystemConfigModuleCarrierCallback, ValueType: constants.SystemConfigTypeBool, DefaultValue: "false", Description: "是否处理中国联通实名成功回调", Control: "switch"},
{Key: constants.SystemConfigCarrierCallbackCUCCRealnameRemovalEnabled, Module: constants.SystemConfigModuleCarrierCallback, ValueType: constants.SystemConfigTypeBool, DefaultValue: "false", Description: "是否处理中国联通解除实名回调", Control: "switch"},
}
for _, definition := range definitions {
if existing, exists := registry.Get(definition.Key); exists {
if existing.ValueType != definition.ValueType || existing.Module != definition.Module {
logCarrierCallbackConfigRegistrationError(logger, definition.Key, "配置 Key 已被其他类型或模块注册")
}
continue
}
if err := registry.Register(definition); err != nil {
logCarrierCallbackConfigRegistrationError(logger, definition.Key, err.Error())
}
}
}
func logCarrierCallbackConfigRegistrationError(logger *zap.Logger, key, reason string) {
if logger != nil {
logger.Error("注册运营商回调系统配置失败,相关回调将按关闭处理", zap.String("config_key", key), zap.String("reason", reason))
}
}

View File

@@ -28,5 +28,5 @@ type Dependencies struct {
GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil
WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选)
SystemConfigRegistry *systemConfigInfra.Registry // 业务模块共享的受控配置注册表(可选)
SystemConfigAudit systemConfigApp.AuditWriter // 统一配置变更审计 Port可选缺失时更新失败关闭
SystemConfigAudit systemConfigApp.AuditWriter // 配置变更审计 Port可选装配后与配置同事务写入
}

View File

@@ -11,6 +11,8 @@ import (
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
openapiHandler "github.com/break/junhong_cmp_fiber/internal/handler/openapi"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
@@ -89,6 +91,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
if systemConfigRegistry == nil {
systemConfigRegistry = systemConfigInfra.NewRegistry()
}
registerCarrierCallbackConfigDefinitions(systemConfigRegistry, deps.Logger)
systemConfigAlerts := systemConfigInfra.NewLogAlertSink(deps.Logger)
var systemConfigCache systemConfigInfra.Cache
if deps.Redis != nil {
@@ -108,15 +111,27 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
handler.SetDefaultCreditService(roleApp.NewDefaultCreditService(deps.DB, svc.Permission))
return handler
}(),
Permission: admin.NewPermissionHandler(svc.Permission),
PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger),
ClientAuth: app.NewClientAuthHandler(svc.ClientAuth, deps.Logger),
ClientAsset: app.NewClientAssetHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger),
ClientWallet: app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore),
ClientOrder: app.NewClientOrderHandler(clientOrderService, deps.Logger),
ClientExchange: app.NewClientExchangeHandler(svc.Exchange),
ClientRealname: app.NewClientRealnameHandler(svc.Asset, svc.CustomerBinding, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger, svc.PollingManualTrigger),
ClientDevice: app.NewClientDeviceHandler(svc.Asset, svc.CustomerBinding, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger),
Permission: admin.NewPermissionHandler(svc.Permission),
PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger),
ClientAuth: app.NewClientAuthHandler(svc.ClientAuth, deps.Logger),
ClientAsset: func() *app.ClientAssetHandler {
handler := app.NewClientAssetHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger)
handler.SetObservationSeriesDispatcher(svc.ObservationSeries)
return handler
}(),
ClientWallet: app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore),
ClientOrder: app.NewClientOrderHandler(clientOrderService, deps.Logger),
ClientExchange: app.NewClientExchangeHandler(svc.Exchange),
ClientRealname: func() *app.ClientRealnameHandler {
handler := app.NewClientRealnameHandler(svc.Asset, svc.CustomerBinding, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger, svc.PollingManualTrigger)
handler.SetObservationSeriesDispatcher(svc.ObservationSeries)
return handler
}(),
ClientDevice: func() *app.ClientDeviceHandler {
handler := app.NewClientDeviceHandler(svc.Asset, svc.CustomerBinding, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger)
handler.SetDeviceService(svc.Device)
return handler
}(),
ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger),
ClientNotification: app.NewClientNotificationHandler(notificationQuery.NewQuery(deps.DB), notificationApp.NewReadService(deps.DB), validate),
Shop: func() *admin.ShopHandler {
@@ -158,12 +173,28 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
AdminExchange: admin.NewExchangeHandler(svc.Exchange, exchangeQuery.NewListQuery(deps.DB), validate),
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, rechargeOrderService, svc.AgentRecharge, deps.WechatPayment, svc.WechatConfig, paymentStore, deps.Logger),
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring),
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
CTCCRealnameCallback: callback.NewCTCCRealnameHandler(
carriercallback.NewCTCCRealnameTranslator(), carriercallback.NewCTCCCardResolver(deps.DB),
integrationlog.NewRepository(deps.DB), svc.CardObservation, svc.CardObservationSeries, systemConfigReader, deps.Logger,
),
CMCCRealnameCallback: callback.NewCMCCRealnameHandler(
carriercallback.NewCMCCRealnameTranslator(), carriercallback.NewCMCCCardResolver(deps.DB),
integrationlog.NewRepository(deps.DB), svc.CardObservation, svc.CardObservationSeries, systemConfigReader, deps.Logger,
),
CUCCRealnameCallback: callback.NewCUCCRealnameHandler(
carriercallback.NewCUCCRealnameTranslator(), carriercallback.NewCUCCCardResolver(deps.DB),
integrationlog.NewRepository(deps.DB), svc.CardObservation, svc.CardObservationSeries, systemConfigReader, deps.Logger,
),
CUCCRealnameRemovalCallback: callback.NewCUCCRealnameRemovalHandler(
carriercallback.NewCUCCRealnameRemovalTranslator(), carriercallback.NewCUCCCardResolver(deps.DB),
integrationlog.NewRepository(deps.DB), systemConfigReader, deps.Logger,
),
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring),
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
Asset: func() *admin.AssetHandler {
pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger)
assetPollingSvc := pollingSvcPkg.NewAssetPollingService(
@@ -176,6 +207,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
)
h := admin.NewAssetHandler(svc.Asset, svc.AssetAudit, svc.Device, svc.IotCard, svc.StopResumeService, assetPollingSvc, assetQuery.NewExchangeTraceQuery(deps.DB, deps.Logger))
h.SetLifecycleService(svc.AssetLifecycle)
h.SetObservationSeriesDispatcher(svc.ObservationSeries)
return h
}(),
AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle),

View File

@@ -8,6 +8,7 @@ import (
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
"github.com/break/junhong_cmp_fiber/internal/polling"
@@ -27,6 +28,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/payment"
"github.com/break/junhong_cmp_fiber/pkg/queue"
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
assetWalletSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_wallet"
@@ -118,6 +120,9 @@ type services struct {
AgentOpenAPI *agentOpenAPISvc.Service
CustomerBinding *customerBindingSvc.Service
OrderPackageInvalidate *orderPackageInvalidateSvc.Service
ObservationSeries cardObservationApp.BestEffortSeriesDispatcher
CardObservation *cardObservationApp.Service
CardObservationSeries *cardObservationApp.SeriesAttemptService
}
func initServices(s *stores, deps *Dependencies) *services {
@@ -143,11 +148,27 @@ func initServices(s *stores, deps *Dependencies) *services {
assetAudit,
)
cardObservationOutbox := outbox.NewRepository()
iotCard.SetCardObservationService(cardObservationApp.NewService(
observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox)
cardObservationService := cardObservationApp.NewService(
deps.DB,
cardObservationInfra.NewEventWriter(cardObservationOutbox),
cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger),
))
)
iotCard.SetCardObservationService(cardObservationService)
seriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
seriesIntegration := integrationlog.NewRepository(deps.DB)
seriesTrigger := cardObservationApp.NewSeriesTrigger(
seriesCoordinator,
queue.NewCardObservationSeriesScheduler(deps.QueueClient),
cardObservationInfra.NewSeriesAttemptLogger(seriesIntegration),
)
cardObservationSeries := cardObservationApp.NewSeriesAttemptService(
seriesCoordinator,
cardObservationInfra.NewSeriesRunner(deps.DB, deps.GatewayClient, cardObservationService, seriesIntegration),
cardObservationInfra.NewSeriesAttemptLogger(seriesIntegration),
)
observationSeries := cardObservationInfra.NewBestEffortSeriesDispatcher(seriesTrigger, deps.Logger, s.DeviceSimBinding, s.Carrier)
iotCard.SetObservationSeriesDispatcher(observationSeries)
// 使用 PollingLifecycleService 替代 APICallback通过分片队列准确操作修复 api_callback.go 遗漏 protect 队列的 Bug3
pollingConfigStore := postgres.NewPollingConfigStore(deps.DB)
pollingConfigMgr := polling.NewPollingConfigManager(pollingConfigStore, deps.Redis, deps.Logger)
@@ -172,6 +193,7 @@ func initServices(s *stores, deps *Dependencies) *services {
s.PackageUsageDailyRecord,
deps.Logger,
)
packageActivation.SetObservationSeriesEventWriter(observationSeriesEvents)
stopResumeService := iotCardSvc.NewStopResumeService(
deps.Redis,
@@ -183,6 +205,7 @@ func initServices(s *stores, deps *Dependencies) *services {
assetAudit,
)
stopResumeService.SetPollingCallback(pollingLifecycleSvc)
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
iotCard.SetRealnameActivator(packageActivation)
iotCard.SetStopResumeService(stopResumeService)
iotCard.SetDeviceSimBindingStore(s.DeviceSimBinding)
@@ -206,10 +229,13 @@ func initServices(s *stores, deps *Dependencies) *services {
s.EnterpriseDeviceAuthorization,
s.Enterprise,
)
device.SetObservationSeriesEventWriter(observationSeriesEvents)
device.SetObservationSeriesDispatcher(observationSeries)
operationPassword := operationPasswordSvc.New(deps.Redis)
shopCommission := shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction, deps.DB, deps.Logger)
packageService := packageSvc.New(s.Package, s.PackageSeries, s.ShopPackageAllocation, s.ShopSeriesAllocation)
orderService := orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.AssetWallet, s.Payment, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, wechatConfig, deps.WechatPayment, paymentLoader, deps.QueueClient, deps.Logger, s.AssetIdentifier, s.PersonalCustomer, s.PersonalCustomerPhone)
orderService.SetObservationSeriesEventWriter(observationSeriesEvents)
walletOutbox := outbox.NewRepository()
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox)
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
@@ -244,6 +270,7 @@ func initServices(s *stores, deps *Dependencies) *services {
refundService.SetAgentWalletRefundService(walletapp.NewRefundService(walletinfra.NewRefundEventWriter(walletOutbox), nil))
assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder, assetAudit)
agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device)
agentOpenAPI.SetObservationSeriesDispatcher(observationSeries)
return &services{
Account: account,
@@ -331,5 +358,8 @@ func initServices(s *stores, deps *Dependencies) *services {
Refund: refundService,
CustomerBinding: customerBinding,
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient),
ObservationSeries: observationSeries,
CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeries,
}
}

View File

@@ -53,6 +53,10 @@ type Handlers struct {
AdminOrder *admin.OrderHandler
AdminExchange *admin.ExchangeHandler
PaymentCallback *callback.PaymentHandler
CTCCRealnameCallback *callback.CTCCRealnameHandler
CMCCRealnameCallback *callback.CMCCRealnameHandler
CUCCRealnameCallback *callback.CUCCRealnameHandler
CUCCRealnameRemovalCallback *callback.CUCCRealnameRemovalHandler
PollingConfig *admin.PollingConfigHandler
PollingConcurrency *admin.PollingConcurrencyHandler
PollingMonitoring *admin.PollingMonitoringHandler

View File

@@ -88,6 +88,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
deps.Logger,
)
cardObservationOutbox := outbox.NewRepository()
observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox)
cardObservationService := cardObservationApp.NewService(
deps.DB,
cardObservationInfra.NewEventWriter(cardObservationOutbox),
@@ -141,22 +142,25 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
deps.Logger,
assetAudit,
)
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
activationService.SetObservationSeriesEventWriter(observationSeriesEvents)
usageService.SetStopResumeCallback(stopResumeService)
activationService.SetResumeCallback(stopResumeService)
orderService.SetResumeCallback(stopResumeService)
resetService.SetResumeCallback(stopResumeService)
return &queue.WorkerServices{
CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeriesService,
CommissionCalculation: commissionCalculationService,
CommissionStats: commissionStatsService,
UsageService: usageService,
ActivationService: activationService,
ResetService: resetService,
AlertService: alertService,
CleanupService: cleanupService,
StopResumeService: stopResumeService,
OrderExpirer: orderService,
CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeriesService,
ObservationSeriesEvents: observationSeriesEvents,
CommissionCalculation: commissionCalculationService,
CommissionStats: commissionStatsService,
UsageService: usageService,
ActivationService: activationService,
ResetService: resetService,
AlertService: alertService,
CleanupService: cleanupService,
StopResumeService: stopResumeService,
OrderExpirer: orderService,
}
}

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

View File

@@ -0,0 +1,221 @@
package cardobservation
import (
"context"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// BestEffortSeriesDispatcher 将读入口触发失败降级为中文安全日志。
type BestEffortSeriesDispatcher struct {
trigger *cardapp.SeriesTrigger
logger *zap.Logger
jobs chan cardapp.SeriesRequest
deviceJobs chan cardapp.DeviceCardsSeriesRequest
deviceControlJobs chan cardapp.DeviceControlSeriesRequest
realnameJobs chan cardapp.RealnameCapabilitySeriesRequest
deviceSimBindingStore *postgres.DeviceSimBindingStore
carrierStore *postgres.CarrierStore
}
const bestEffortSeriesQueueSize = 1024
// NewBestEffortSeriesDispatcher 创建读入口观测序列分发器。
func NewBestEffortSeriesDispatcher(trigger *cardapp.SeriesTrigger, logger *zap.Logger, deviceSimBindingStore *postgres.DeviceSimBindingStore, carrierStore *postgres.CarrierStore) *BestEffortSeriesDispatcher {
dispatcher := &BestEffortSeriesDispatcher{
trigger: trigger, logger: logger,
jobs: make(chan cardapp.SeriesRequest, bestEffortSeriesQueueSize),
deviceJobs: make(chan cardapp.DeviceCardsSeriesRequest, bestEffortSeriesQueueSize),
deviceControlJobs: make(chan cardapp.DeviceControlSeriesRequest, bestEffortSeriesQueueSize),
realnameJobs: make(chan cardapp.RealnameCapabilitySeriesRequest, bestEffortSeriesQueueSize),
deviceSimBindingStore: deviceSimBindingStore, carrierStore: carrierStore,
}
go dispatcher.run()
return dispatcher
}
// DispatchDeviceControl 在后台解析设备控制前后的相关卡,避免增加原操作响应耗时。
func (d *BestEffortSeriesDispatcher) DispatchDeviceControl(_ context.Context, request cardapp.DeviceControlSeriesRequest) {
if d == nil || d.trigger == nil || request.DeviceID == 0 {
return
}
request.Request = normalizeRequest(request.Request)
select {
case d.deviceControlJobs <- request:
default:
d.logFailure(request.Request, "", false, nil, "后台设备控制观测队列已满")
}
}
// Dispatch 尝试创建或合并序列,失败不向原查询调用方返回。
func (d *BestEffortSeriesDispatcher) Dispatch(_ context.Context, request cardapp.SeriesRequest) {
if d == nil || d.trigger == nil {
return
}
request = normalizeRequest(request)
select {
case d.jobs <- request:
default:
d.logFailure(request, "", false, nil, "后台观测队列已满")
}
}
// DispatchDeviceCards 在后台展开设备当前有效绑定卡,避免请求协程逐卡查询数据库。
func (d *BestEffortSeriesDispatcher) DispatchDeviceCards(_ context.Context, request cardapp.DeviceCardsSeriesRequest) {
if d == nil || d.trigger == nil || request.DeviceID == 0 || d.deviceSimBindingStore == nil {
return
}
request.Request = normalizeRequest(request.Request)
select {
case d.deviceJobs <- request:
default:
d.logFailure(request.Request, "", false, nil, "后台设备观测队列已满")
}
}
// DispatchRealnameWithCapability 在后台读取运营商实名能力none 能力不触发观测。
func (d *BestEffortSeriesDispatcher) DispatchRealnameWithCapability(_ context.Context, request cardapp.RealnameCapabilitySeriesRequest) {
if d == nil || d.trigger == nil || request.CarrierID == 0 || d.carrierStore == nil {
return
}
request.Request = normalizeRequest(request.Request)
select {
case d.realnameJobs <- request:
default:
d.logFailure(request.Request, "", false, nil, "后台实名观测队列已满")
}
}
func (d *BestEffortSeriesDispatcher) run() {
for {
select {
case request := <-d.jobs:
d.triggerRequest(request)
case request := <-d.deviceJobs:
d.expandDeviceRequest(request)
case request := <-d.deviceControlJobs:
d.expandDeviceControlRequest(request)
case request := <-d.realnameJobs:
d.expandRealnameRequest(request)
}
}
}
func (d *BestEffortSeriesDispatcher) expandDeviceControlRequest(request cardapp.DeviceControlSeriesRequest) {
deviceRequest := request.Request
deviceRequest.ResourceType = constants.CardObservationResourceTypeDevice
deviceRequest.ResourceID = strconv.FormatUint(uint64(request.DeviceID), 10)
deviceRequest.SyncType = constants.CardObservationSyncTypeDeviceInfo
deviceRequest.ExpectedValue = strings.TrimSpace(request.TargetICCID)
d.triggerRequest(deviceRequest)
if strings.TrimSpace(request.TargetICCID) == "" {
for _, cardID := range request.BoundCardIDs {
d.triggerCardControlRequest(request.Request, cardID, constants.CardObservationSyncTypeNetwork)
}
return
}
d.triggerCardControlRequest(request.Request, request.SourceCardID, constants.CardObservationSyncTypeNetwork)
if request.TargetCardID != request.SourceCardID {
d.triggerCardControlRequest(request.Request, request.TargetCardID, constants.CardObservationSyncTypeNetwork)
}
if request.IncludeTargetTraffic {
d.triggerCardControlRequest(request.Request, request.TargetCardID, constants.CardObservationSyncTypeTraffic)
}
}
func (d *BestEffortSeriesDispatcher) triggerCardControlRequest(base cardapp.SeriesRequest, cardID uint, syncType string) {
if cardID == 0 {
return
}
base.ResourceType = constants.CardObservationResourceTypeCard
base.ResourceID = strconv.FormatUint(uint64(cardID), 10)
base.SyncType = syncType
base.ExpectedValue = ""
d.triggerRequest(base)
}
func (d *BestEffortSeriesDispatcher) triggerRequest(request cardapp.SeriesRequest) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
seriesID, merged, err := d.trigger.Trigger(ctx, request)
cancel()
if err != nil {
d.logFailure(request, seriesID, merged, err, "后台观测序列触发失败")
}
}
func (d *BestEffortSeriesDispatcher) expandDeviceRequest(request cardapp.DeviceCardsSeriesRequest) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
bindings, err := d.deviceSimBindingStore.ListByDeviceID(ctx, request.DeviceID)
cancel()
if err != nil {
d.logFailure(request.Request, "", false, err, "查询设备有效绑定卡失败")
return
}
for _, binding := range bindings {
if binding == nil || binding.IotCardID == 0 {
continue
}
cardRequest := request.Request
cardRequest.ResourceType = constants.CardObservationResourceTypeCard
cardRequest.ResourceID = strconv.FormatUint(uint64(binding.IotCardID), 10)
d.triggerRequest(cardRequest)
}
}
func (d *BestEffortSeriesDispatcher) expandRealnameRequest(request cardapp.RealnameCapabilitySeriesRequest) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
carrier, err := d.carrierStore.GetByID(ctx, request.CarrierID)
cancel()
if err != nil {
d.logFailure(request.Request, "", false, err, "查询运营商实名能力失败")
return
}
if carrier.RealnameLinkType == constants.RealnameLinkTypeNone {
return
}
d.triggerRequest(request.Request)
}
func normalizeRequest(request cardapp.SeriesRequest) cardapp.SeriesRequest {
request.RequestID = strings.TrimSpace(request.RequestID)
request.CorrelationID = strings.TrimSpace(request.CorrelationID)
if request.RequestID == "" && request.CorrelationID == "" {
request.RequestID = uuid.NewString()
request.CorrelationID = request.RequestID
} else if request.RequestID == "" {
request.RequestID = request.CorrelationID
} else if request.CorrelationID == "" {
request.CorrelationID = request.RequestID
}
return request
}
func (d *BestEffortSeriesDispatcher) logFailure(request cardapp.SeriesRequest, seriesID string, merged bool, err error, message string) {
if d.logger == nil {
return
}
fields := []zap.Field{
zap.String("scene", request.Scene),
zap.String("resource_type", request.ResourceType),
zap.String("resource_id", request.ResourceID),
zap.String("sync_type", request.SyncType),
zap.String("series_id", seriesID),
zap.Bool("merged", merged),
zap.String("request_id", request.RequestID),
zap.String("correlation_id", request.CorrelationID),
}
if err != nil {
fields = append(fields, zap.Error(err))
}
d.logger.Warn(message+",已保持原查询结果", fields...)
}
var _ cardapp.BestEffortSeriesDispatcher = (*BestEffortSeriesDispatcher)(nil)

View File

@@ -146,6 +146,7 @@ func (c *NetworkChangedConsumer) Consume(ctx context.Context, envelope outbox.De
if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "卡网络事件消费者未配置")
}
ctx = cardapp.SuppressSeriesTriggerContext(ctx)
if envelope.EventType != constants.OutboxEventTypeCardNetworkChanged || envelope.PayloadVersion != constants.CardNetworkChangedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡网络事件类型或版本不受支持")
}
@@ -179,6 +180,7 @@ func (c *RealnameChangedConsumer) Consume(ctx context.Context, envelope outbox.D
if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "卡实名事件消费者未配置")
}
ctx = cardapp.SuppressSeriesTriggerContext(ctx)
if envelope.EventType != constants.OutboxEventTypeCardRealnameChanged || envelope.PayloadVersion != constants.CardRealnameChangedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡实名事件类型或版本不受支持")
}
@@ -235,6 +237,7 @@ func (c *TrafficIncrementedConsumer) Consume(ctx context.Context, envelope outbo
if c == nil || c.db == nil || c.redis == nil || c.deductor == nil {
return errors.New(errors.CodeInternalError, "卡流量事件消费者未配置")
}
ctx = cardapp.SuppressSeriesTriggerContext(ctx)
if envelope.EventType != constants.OutboxEventTypeCardTrafficIncremented || envelope.PayloadVersion != constants.CardTrafficIncrementedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡流量事件类型或版本不受支持")
}

View File

@@ -5,49 +5,87 @@ import (
"strconv"
"time"
"github.com/bytedance/sonic"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/cardtrafficlock"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SeriesCoordinator 使用 Redis 原子协调活跃序列、幂等尝试和实际请求互斥。
type SeriesCoordinator struct {
redis *redis.Client
now func() time.Time
redis *redis.Client
trafficLock *cardtrafficlock.Lock
now func() time.Time
}
// ClaimSchedule 保证每个固定阶梯任务只成功入队一次。
func (c *SeriesCoordinator) ClaimSchedule(ctx context.Context, seriesID string, attempt int) (bool, error) {
claimed, err := c.redis.SetNX(ctx, constants.RedisCardObservationScheduleKey(seriesID, attempt), "scheduled", constants.CardObservationSeriesResultTTL).Result()
if err != nil {
return false, errors.Wrap(errors.CodeInternalError, err, "领取卡观测序列入队槽位失败")
}
return claimed, nil
}
// IsScheduled 判断稳定业务事件是否已经创建过第一阶梯任务。
func (c *SeriesCoordinator) IsScheduled(ctx context.Context, seriesID string) (bool, error) {
keys := make([]string, 0, constants.CardObservationSeriesAttemptCount)
for attempt := 1; attempt <= constants.CardObservationSeriesAttemptCount; attempt++ {
keys = append(keys, constants.RedisCardObservationScheduleKey(seriesID, attempt))
}
exists, err := c.redis.Exists(ctx, keys...).Result()
if err != nil {
return false, errors.Wrap(errors.CodeInternalError, err, "读取卡观测序列业务幂等状态失败")
}
return exists == int64(constants.CardObservationSeriesAttemptCount), nil
}
// ReleaseSchedule 在入队失败时释放槽位,允许后续同场景触发补齐原序列。
func (c *SeriesCoordinator) ReleaseSchedule(ctx context.Context, seriesID string, attempt int) {
_ = c.redis.Del(ctx, constants.RedisCardObservationScheduleKey(seriesID, attempt)).Err()
}
// NewSeriesCoordinator 创建观测序列 Redis 协调器。
func NewSeriesCoordinator(redisClient *redis.Client) *SeriesCoordinator {
return &SeriesCoordinator{redis: redisClient, now: time.Now}
return &SeriesCoordinator{redis: redisClient, trafficLock: cardtrafficlock.New(redisClient), now: time.Now}
}
// Reserve 原子保留同场景活跃序列;重复触发不会刷新 TTL。
func (c *SeriesCoordinator) Reserve(ctx context.Context, request cardapp.SeriesRequest, candidateSeriesID string, candidateBaseTime time.Time) (string, time.Time, bool, error) {
func (c *SeriesCoordinator) Reserve(ctx context.Context, request cardapp.SeriesRequest, candidateSeriesID string, candidateBaseTime time.Time) (string, time.Time, cardapp.SeriesRequest, bool, error) {
if c == nil || c.redis == nil {
return "", time.Time{}, false, errors.New(errors.CodeInternalError, "卡观测序列 Redis 未配置")
return "", time.Time{}, cardapp.SeriesRequest{}, false, errors.New(errors.CodeInternalError, "卡观测序列 Redis 未配置")
}
requestJSON, err := sonic.Marshal(request)
if err != nil {
return "", time.Time{}, cardapp.SeriesRequest{}, false, errors.Wrap(errors.CodeInvalidParam, err, "序列原始请求无法保存")
}
seriesKey := constants.RedisCardObservationSeriesKey(request.Scene, request.ResourceType, request.ResourceID, request.SyncType)
indexKey := constants.RedisCardObservationSeriesIndexKey(request.ResourceType, request.ResourceID, request.SyncType)
result, err := c.redis.Eval(ctx, `
local current = redis.call('HMGET', KEYS[1], 'series_id', 'base_time_ms')
if current[1] then return {current[1], current[2], '1'} end
redis.call('HSET', KEYS[1], 'series_id', ARGV[1], 'base_time_ms', ARGV[2])
redis.call('EXPIRE', KEYS[1], ARGV[3])
local current = redis.call('HMGET', KEYS[1], 'series_id', 'base_time_ms', 'request_json')
if current[1] then return {current[1], current[2], current[3], '1'} end
redis.call('HSET', KEYS[1], 'series_id', ARGV[1], 'base_time_ms', ARGV[2], 'request_json', ARGV[3])
redis.call('EXPIRE', KEYS[1], ARGV[4])
redis.call('SADD', KEYS[2], KEYS[1])
redis.call('EXPIRE', KEYS[2], ARGV[3])
return {ARGV[1], ARGV[2], '0'}
`, []string{seriesKey, indexKey}, candidateSeriesID, candidateBaseTime.UTC().UnixMilli(), int(constants.CardObservationSeriesTTL.Seconds())).StringSlice()
if err != nil || len(result) != 3 {
return "", time.Time{}, false, errors.Wrap(errors.CodeInternalError, err, "保留卡观测序列失败")
redis.call('EXPIRE', KEYS[2], ARGV[4])
return {ARGV[1], ARGV[2], ARGV[3], '0'}
`, []string{seriesKey, indexKey}, candidateSeriesID, candidateBaseTime.UTC().UnixMilli(), string(requestJSON), int(constants.CardObservationSeriesTTL.Seconds())).StringSlice()
if err != nil || len(result) != 4 {
return "", time.Time{}, cardapp.SeriesRequest{}, false, errors.Wrap(errors.CodeInternalError, err, "保留卡观测序列失败")
}
baseTimeMS, parseErr := strconv.ParseInt(result[1], 10, 64)
if parseErr != nil {
return "", time.Time{}, false, errors.Wrap(errors.CodeInternalError, parseErr, "解析卡观测序列基准时间失败")
return "", time.Time{}, cardapp.SeriesRequest{}, false, errors.Wrap(errors.CodeInternalError, parseErr, "解析卡观测序列基准时间失败")
}
return result[0], time.UnixMilli(baseTimeMS).UTC(), result[2] == "1", nil
var originalRequest cardapp.SeriesRequest
if unmarshalErr := sonic.Unmarshal([]byte(result[2]), &originalRequest); unmarshalErr != nil {
return "", time.Time{}, cardapp.SeriesRequest{}, false, errors.Wrap(errors.CodeInternalError, unmarshalErr, "解析序列原始请求失败")
}
return result[0], time.UnixMilli(baseTimeMS).UTC(), originalRequest, result[3] == "1", nil
}
// IsCompleted 判断序列是否已由预期状态或回调提前完成。
@@ -70,14 +108,14 @@ func (c *SeriesCoordinator) ClaimAttempt(ctx context.Context, seriesID string, a
// AcquireRequest 获取仅覆盖实际 Gateway 请求的互斥,并返回最小间隔剩余等待时间。
func (c *SeriesCoordinator) AcquireRequest(ctx context.Context, payload cardapp.SeriesTaskPayload, provider string) (func(), time.Duration, bool, error) {
token := uuid.NewString()
lockKey := constants.RedisCardObservationInflightKey(provider, payload.SyncType, payload.ResourceID)
// 流量观测复用现有卡级同步锁,避免事件、轮询和手动刷新并发读取同一上游读数。
if payload.SyncType == constants.CardObservationSyncTypeTraffic {
if cardID, parseErr := strconv.ParseUint(payload.ResourceID, 10, 64); parseErr == nil && cardID > 0 {
lockKey = constants.RedisCardTrafficSyncLockKey(uint(cardID))
return c.acquireTrafficRequest(ctx, payload, provider, uint(cardID))
}
}
token := uuid.NewString()
lockKey := constants.RedisCardObservationInflightKey(provider, payload.SyncType, payload.ResourceID)
lastKey := constants.RedisCardObservationLastRequestKey(provider, payload.SyncType, payload.ResourceID)
nowMS := c.now().UTC().UnixMilli()
result, err := c.redis.Eval(ctx, `
@@ -105,6 +143,34 @@ return 0
return release, time.Duration(result[0]) * time.Millisecond, true, nil
}
func (c *SeriesCoordinator) acquireTrafficRequest(ctx context.Context, payload cardapp.SeriesTaskPayload, provider string, cardID uint) (func(), time.Duration, bool, error) {
token, acquired, err := c.trafficLock.Acquire(ctx, cardID)
if err != nil {
return nil, 0, false, errors.Wrap(errors.CodeInternalError, err, "获取卡流量 Gateway 请求互斥失败")
}
if !acquired {
return func() {}, 0, false, nil
}
release := func() {
_ = c.trafficLock.Release(context.Background(), cardID, token)
}
lastKey := constants.RedisCardObservationLastRequestKey(provider, payload.SyncType, payload.ResourceID)
nowMS := c.now().UTC().UnixMilli()
wait, err := c.redis.Eval(ctx, `
local now = tonumber(ARGV[1])
local minimum = tonumber(ARGV[2])
local last = tonumber(redis.call('GET', KEYS[1]) or '0')
local wait = math.max(0, last + minimum - now)
redis.call('PSETEX', KEYS[1], minimum * 3, now + wait)
return wait
`, []string{lastKey}, nowMS, constants.CardObservationGatewayMinInterval.Milliseconds()).Int64()
if err != nil {
release()
return nil, 0, false, errors.Wrap(errors.CodeInternalError, err, "获取卡流量 Gateway 最小请求间隔失败")
}
return release, time.Duration(wait) * time.Millisecond, true, nil
}
// FinishAttempt 保存尝试终态,并在最后一次尝试后释放同场景活跃序列。
func (c *SeriesCoordinator) FinishAttempt(ctx context.Context, payload cardapp.SeriesTaskPayload) error {
if err := c.redis.Set(ctx, constants.RedisCardObservationAttemptKey(payload.SeriesID, payload.Attempt), "completed", constants.CardObservationSeriesResultTTL).Err(); err != nil {

View File

@@ -0,0 +1,119 @@
package cardobservation
import (
"context"
"strconv"
"strings"
"github.com/bytedance/sonic"
"gorm.io/gorm"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SeriesEventWriter 将业务成功观测请求写入公共 Outbox。
type SeriesEventWriter struct {
outbox *outbox.Repository
}
// NewSeriesEventWriter 创建业务观测事件 Writer。
func NewSeriesEventWriter(repository *outbox.Repository) *SeriesEventWriter {
return &SeriesEventWriter{outbox: repository}
}
// AppendSeriesRequested 在原业务事务中追加稳定观测请求事件。
func (w *SeriesEventWriter) AppendSeriesRequested(ctx context.Context, tx *gorm.DB, event cardapp.SeriesRequestedEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "业务观测 Outbox Writer 未配置")
}
if cardapp.IsSeriesTriggerSuppressed(ctx) {
return nil
}
if strings.TrimSpace(event.EventID) == "" || event.ResourceID == 0 || len(event.SyncTypes) == 0 {
return errors.New(errors.CodeInvalidParam, "业务观测事件载荷不完整")
}
if tx == nil {
return errors.New(errors.CodeInternalError, "业务观测事件缺少原业务事务")
}
if event.ResourceType == constants.CardObservationResourceTypeDevice {
event.ResourceIDs = nil
if err := tx.WithContext(ctx).
Model(&model.DeviceSimBinding{}).
Where("device_id = ? AND bind_status = ?", event.ResourceID, constants.BindStatusBound).
Order("slot_position ASC").
Pluck("iot_card_id", &event.ResourceIDs).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "冻结业务观测设备绑定卡快照失败")
}
}
_, err := w.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeCardSeriesRequested,
PayloadVersion: constants.CardSeriesRequestedPayloadVersionV1,
AggregateType: event.ResourceType, AggregateID: strconv.FormatUint(uint64(event.ResourceID), 10),
ResourceType: event.ResourceType, ResourceID: strconv.FormatUint(uint64(event.ResourceID), 10),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
Payload: event,
})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入业务观测 Outbox 事件失败")
}
return nil
}
// SeriesRequestedConsumer 消费业务成功事实并触发卡观测序列。
type SeriesRequestedConsumer struct {
trigger *cardapp.SeriesTrigger
}
// NewSeriesRequestedConsumer 创建业务观测序列消费者。
func NewSeriesRequestedConsumer(trigger *cardapp.SeriesTrigger) *SeriesRequestedConsumer {
return &SeriesRequestedConsumer{trigger: trigger}
}
// Consume 将一个业务事件展开为卡观测序列;重复投递由稳定序列键幂等。
func (c *SeriesRequestedConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.trigger == nil {
return errors.New(errors.CodeInternalError, "业务观测序列消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeCardSeriesRequested || envelope.PayloadVersion != constants.CardSeriesRequestedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "业务观测事件类型或版本不受支持")
}
var event cardapp.SeriesRequestedEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "业务观测事件载荷无法解析")
}
if event.EventID != envelope.EventID || event.ResourceID == 0 || len(event.SyncTypes) == 0 {
return errors.New(errors.CodeInvalidParam, "业务观测事件载荷不完整")
}
if event.ResourceType != constants.CardObservationResourceTypeCard && event.ResourceType != constants.CardObservationResourceTypeDevice {
return errors.New(errors.CodeInvalidParam, "业务观测事件资源类型不受支持")
}
cardIDs := []uint{event.ResourceID}
if event.ResourceType == constants.CardObservationResourceTypeDevice {
cardIDs = event.ResourceIDs
}
if len(cardIDs) == 0 {
return nil
}
for _, cardID := range cardIDs {
for _, syncType := range event.SyncTypes {
request := cardapp.SeriesRequest{
Scene: event.Scene, ResourceType: constants.CardObservationResourceTypeCard,
ResourceID: strconv.FormatUint(uint64(cardID), 10), SyncType: syncType,
ExpectedValue: event.ExpectedValue, Source: event.Source,
RequestID: event.RequestID, CorrelationID: event.CorrelationID,
}
key := event.EventID + ":" + strconv.FormatUint(uint64(cardID), 10) + ":" + syncType
if _, _, err := c.trigger.TriggerEvent(ctx, key, request); err != nil {
return err
}
}
}
return nil
}
var _ cardapp.SeriesEventWriter = (*SeriesEventWriter)(nil)
var _ outbox.EventConsumer = (*SeriesRequestedConsumer)(nil)

View File

@@ -37,14 +37,20 @@ func (l *SeriesAttemptLogger) Record(ctx context.Context, payload cardapp.Series
resourceID := payload.ResourceID
source, scene, seriesID := payload.Source, payload.Scene, payload.SeriesID
requestID, correlationID := optionalText(payload.RequestID), optionalText(payload.CorrelationID)
_, err := l.repository.Start(ctx, integrationlog.Attempt{
attempt, err := l.repository.Start(ctx, integrationlog.Attempt{
IntegrationID: unsentIntegrationID(payload), Provider: constants.IntegrationProviderGateway,
Direction: constants.IntegrationDirectionOutbound, Operation: operationForSyncType(payload.SyncType),
ResourceType: payload.ResourceType, ResourceID: &resourceID, TriggerSource: &source,
TriggerScene: &scene, TriggerSeries: &seriesID, ScheduledAt: &payload.ScheduledAt,
Attempt: payload.Attempt, InitialResult: result, RequestID: requestID, CorrelationID: correlationID,
Attempt: payload.Attempt, InitialResult: initialResult(result), RequestID: requestID, CorrelationID: correlationID,
Metadata: map[string]any{"reason": reason, "sync_type": payload.SyncType},
})
if err != nil || result != constants.IntegrationResultFailed {
return err
}
_, err = l.repository.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, ResponseSummary: map[string]any{"reason": reason},
})
return err
}
@@ -80,6 +86,12 @@ func NewSeriesRunner(db *gorm.DB, gatewayClient *gateway.Client, observation *ca
// Provider 返回用于请求互斥的运营商接入标识。
func (r *SeriesRunner) Provider(ctx context.Context, payload cardapp.SeriesTaskPayload) (string, error) {
if payload.ResourceType == constants.CardObservationResourceTypeDevice {
if _, err := r.loadDevice(ctx, payload); err != nil {
return "", err
}
return constants.CardObservationResourceTypeDevice, nil
}
card, err := r.loadCard(ctx, payload)
if err != nil {
return "", err
@@ -97,6 +109,18 @@ func (r *SeriesRunner) ExpectationMet(ctx context.Context, payload cardapp.Serie
if expected == "" {
return false, nil
}
if payload.ResourceType == constants.CardObservationResourceTypeDevice && payload.SyncType == constants.CardObservationSyncTypeDeviceInfo {
currentICCIDs, err := r.loadCurrentICCIDs(ctx, payload)
if err != nil {
return false, err
}
for _, currentICCID := range currentICCIDs {
if strings.EqualFold(expected, strings.TrimSpace(currentICCID)) {
return true, nil
}
}
return false, nil
}
card, err := r.loadCard(ctx, payload)
if err != nil {
return false, err
@@ -120,6 +144,9 @@ func (r *SeriesRunner) Run(ctx context.Context, payload cardapp.SeriesTaskPayloa
if r == nil || r.db == nil || r.gateway == nil || r.observation == nil || r.integration == nil {
return cardapp.RunResult{}, apperrors.New(apperrors.CodeInternalError, "卡观测序列 Gateway 能力未完整配置")
}
if payload.ResourceType == constants.CardObservationResourceTypeDevice && payload.SyncType == constants.CardObservationSyncTypeDeviceInfo {
return r.runDeviceInfo(ctx, payload)
}
card, err := r.loadCard(ctx, payload)
if err != nil {
return cardapp.RunResult{}, err
@@ -148,6 +175,178 @@ func (r *SeriesRunner) Run(ctx context.Context, payload cardapp.SeriesTaskPayloa
return result, runErr
}
func (r *SeriesRunner) runDeviceInfo(ctx context.Context, payload cardapp.SeriesTaskPayload) (cardapp.RunResult, error) {
device, err := r.loadDevice(ctx, payload)
if err != nil {
return cardapp.RunResult{}, err
}
attempt, err := r.startDeviceAttempt(ctx, payload, device)
if err != nil {
return cardapp.RunResult{}, err
}
startedAt := time.Now()
response, runErr := r.gateway.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{CardNo: deviceGatewayIdentifier(device)})
result := cardapp.RunResult{}
if runErr == nil {
result.StateChanged, runErr = r.applyDeviceInfo(ctx, device, response)
}
completionResult := constants.IntegrationResultSuccess
if runErr != nil {
completionResult = constants.IntegrationResultFailed
if isRateLimited(runErr) {
completionResult = constants.IntegrationResultRateLimited
result.RateLimited = true
}
}
_, completeErr := r.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: completionResult, DurationMS: time.Since(startedAt).Milliseconds(), StateChanged: result.StateChanged,
ResponseSummary: map[string]any{"sync_type": payload.SyncType, "applied": runErr == nil},
})
if completeErr != nil {
return result, completeErr
}
return result, runErr
}
func (r *SeriesRunner) startDeviceAttempt(ctx context.Context, payload cardapp.SeriesTaskPayload, device *model.Device) (*model.IntegrationLog, error) {
resourceID, source, scene, seriesID := payload.ResourceID, payload.Source, payload.Scene, payload.SeriesID
resourceKey := "device:" + strconv.FormatUint(uint64(device.ID), 10)
return r.integration.Start(ctx, integrationlog.Attempt{
IntegrationID: gatewayIntegrationID(payload), Provider: constants.IntegrationProviderGateway,
Direction: constants.IntegrationDirectionOutbound, Operation: operationForSyncType(payload.SyncType),
ResourceType: payload.ResourceType, ResourceID: &resourceID, ResourceKey: &resourceKey,
TriggerSource: &source, TriggerScene: &scene, TriggerSeries: &seriesID,
ScheduledAt: &payload.ScheduledAt, Attempt: payload.Attempt,
RequestSummary: map[string]any{"device_id": device.ID, "sync_type": payload.SyncType},
RequestID: optionalText(payload.RequestID), CorrelationID: optionalText(payload.CorrelationID),
})
}
func (r *SeriesRunner) applyDeviceInfo(ctx context.Context, device *model.Device, response *gateway.SyncDeviceInfoResp) (bool, error) {
if response == nil {
return false, apperrors.New(apperrors.CodeGatewayError, "Gateway 设备信息响应为空")
}
now := time.Now()
updates := map[string]any{
"online_status": int(response.OnlineStatus),
"software_version": string(response.SoftwareVersion),
"switch_mode": string(response.SwitchMode),
"last_gateway_sync_at": now,
}
if lastOnlineTime := parseGatewayTime(response.LastOnlineTime); lastOnlineTime != nil {
updates["last_online_time"] = lastOnlineTime
}
var currentBinding struct {
SlotPosition int
}
currentSlotErr := r.db.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Select("slot_position").
Where("device_id = ? AND bind_status = ? AND is_current = ?", device.ID, constants.BindStatusBound, true).
Take(&currentBinding).Error
if currentSlotErr != nil && currentSlotErr != gorm.ErrRecordNotFound {
return false, apperrors.Wrap(apperrors.CodeDatabaseError, currentSlotErr, "读取设备当前槽位失败")
}
changed := device.OnlineStatus != int(response.OnlineStatus) ||
device.SoftwareVersion != string(response.SoftwareVersion) ||
device.SwitchMode != string(response.SwitchMode) ||
currentBinding.SlotPosition != int(response.CurrentSlotNo)
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(updates).Error; err != nil {
return err
}
if err := tx.Model(&model.DeviceSimBinding{}).
Where("device_id = ? AND bind_status = ?", device.ID, constants.BindStatusBound).
Update("is_current", false).Error; err != nil {
return err
}
if int(response.CurrentSlotNo) <= 0 {
return nil
}
return tx.Model(&model.DeviceSimBinding{}).
Where("device_id = ? AND slot_position = ? AND bind_status = ?", device.ID, int(response.CurrentSlotNo), constants.BindStatusBound).
Update("is_current", true).Error
})
if err != nil {
return false, apperrors.Wrap(apperrors.CodeDatabaseError, err, "回写设备 Gateway 信息失败")
}
return changed, nil
}
func (r *SeriesRunner) loadDevice(ctx context.Context, payload cardapp.SeriesTaskPayload) (*model.Device, error) {
if payload.ResourceType != constants.CardObservationResourceTypeDevice {
return nil, apperrors.New(apperrors.CodeInvalidParam, "设备信息观测资源类型无效")
}
deviceID, err := strconv.ParseUint(payload.ResourceID, 10, 64)
if err != nil || deviceID == 0 {
return nil, apperrors.New(apperrors.CodeInvalidParam, "设备观测资源 ID 无效")
}
var device model.Device
if err := r.db.WithContext(ctx).Where("id = ?", uint(deviceID)).First(&device).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, apperrors.New(apperrors.CodeNotFound, "设备不存在")
}
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询设备观测本地快照失败")
}
if deviceGatewayIdentifier(&device) == "" {
return nil, apperrors.New(apperrors.CodeInvalidParam, "设备缺少 Gateway 标识")
}
return &device, nil
}
func (r *SeriesRunner) loadCurrentICCIDs(ctx context.Context, payload cardapp.SeriesTaskPayload) ([]string, error) {
device, err := r.loadDevice(ctx, payload)
if err != nil {
return nil, err
}
var current struct {
ICCID string
ICCID19 string
ICCID20 *string
}
err = r.db.WithContext(ctx).Table("tb_device_sim_binding AS binding").
Select("card.iccid, card.iccid_19, card.iccid_20").
Joins("JOIN tb_iot_card AS card ON card.id = binding.iot_card_id AND card.deleted_at IS NULL").
Where("binding.device_id = ? AND binding.bind_status = ? AND binding.is_current = ? AND binding.deleted_at IS NULL", device.ID, constants.BindStatusBound, true).
Take(&current).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询设备当前卡失败")
}
iccids := []string{current.ICCID, current.ICCID19}
if current.ICCID20 != nil {
iccids = append(iccids, *current.ICCID20)
}
return iccids, nil
}
func deviceGatewayIdentifier(device *model.Device) string {
if strings.TrimSpace(device.IMEI) != "" {
return strings.TrimSpace(device.IMEI)
}
return strings.TrimSpace(device.SN)
}
func parseGatewayTime(raw gateway.FlexString) *time.Time {
value := strings.TrimSpace(string(raw))
if value == "" {
return nil
}
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return &parsed
}
timestamp, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return nil
}
if timestamp > 1_000_000_000_000 {
timestamp /= 1000
}
parsed := time.Unix(timestamp, 0)
return &parsed
}
func (r *SeriesRunner) startAttempt(ctx context.Context, payload cardapp.SeriesTaskPayload, card *model.IotCard) (*model.IntegrationLog, error) {
resourceID, source, scene, seriesID := payload.ResourceID, payload.Source, payload.Scene, payload.SeriesID
resourceKey := "card:" + formatCardID(card.ID)
@@ -235,7 +434,7 @@ func operationForSyncType(syncType string) string {
case constants.CardObservationSyncTypeNetwork:
return constants.IntegrationOperationGatewayNetwork
default:
return "query_device_info"
return constants.IntegrationOperationGatewayDeviceInfo
}
}
@@ -255,5 +454,12 @@ func optionalText(value string) *string {
return &value
}
func initialResult(result string) string {
if result == constants.IntegrationResultFailed {
return constants.IntegrationResultPending
}
return result
}
var _ cardapp.SeriesAttemptLogger = (*SeriesAttemptLogger)(nil)
var _ cardapp.SeriesRunner = (*SeriesRunner)(nil)

View File

@@ -0,0 +1,49 @@
// Package cardtrafficlock 提供卡流量上游请求的统一 Redis 互斥。
package cardtrafficlock
import (
"context"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
var releaseScript = redis.NewScript(`
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
`)
// Lock 统一卡流量上游请求互斥,防止过期持有者误删新锁。
type Lock struct {
redis *redis.Client
}
// New 创建卡流量请求锁。
func New(redisClient *redis.Client) *Lock {
return &Lock{redis: redisClient}
}
// Acquire 使用随机令牌获取至少覆盖完整 Gateway 重试窗口的卡级锁。
func (l *Lock) Acquire(ctx context.Context, cardID uint) (string, bool, error) {
if l == nil || l.redis == nil {
return "", true, nil
}
token := uuid.NewString()
acquired, err := l.redis.SetNX(ctx, constants.RedisCardTrafficSyncLockKey(cardID), token, constants.CardObservationGatewayLockTTL).Result()
if err != nil || !acquired {
return "", acquired, err
}
return token, true, nil
}
// Release 仅由仍持有相同随机令牌的调用方释放卡级锁。
func (l *Lock) Release(ctx context.Context, cardID uint, token string) error {
if l == nil || l.redis == nil || token == "" {
return nil
}
return releaseScript.Run(ctx, l.redis, []string{constants.RedisCardTrafficSyncLockKey(cardID)}, token).Err()
}

View File

@@ -0,0 +1,67 @@
package carriercallback
import (
"bytes"
"strings"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/errors"
pkgvalidator "github.com/break/junhong_cmp_fiber/pkg/validator"
)
// CMCCRealnameTranslation 是移动实名 JSON 回调的受控语义。
type CMCCRealnameTranslation struct {
ICCID string
BusiSeq string
}
// CMCCRealnameTranslator 解析移动实名成功回调的固定 JSON 结构。
type CMCCRealnameTranslator struct{}
// CMCCCardResolver 复用系统级 ICCID 精确查询约束。
type CMCCCardResolver = CTCCCardResolver
// NewCMCCCardResolver 创建移动回调卡定位器。
func NewCMCCCardResolver(db *gorm.DB) *CMCCCardResolver {
return NewCTCCCardResolver(db)
}
// NewCMCCRealnameTranslator 创建移动实名回调 Translator。
func NewCMCCRealnameTranslator() *CMCCRealnameTranslator {
return &CMCCRealnameTranslator{}
}
type cmccRealnamePayload struct {
Status string `json:"status"`
Message string `json:"message"`
Result []struct {
RegStatus string `json:"regStatus"`
BusiSeq string `json:"busiSeq"`
ICCID string `json:"iccid"`
} `json:"result"`
}
// Translate 只接受 status=0、message=正确、首条结果实名成功且 ICCID 合法。
func (t *CMCCRealnameTranslator) Translate(body []byte) (CMCCRealnameTranslation, error) {
var result CMCCRealnameTranslation
if len(bytes.TrimSpace(body)) == 0 {
return result, errors.New(errors.CodeInvalidParam, "移动实名回调报文为空")
}
var payload cmccRealnamePayload
if err := sonic.Unmarshal(body, &payload); err != nil {
return result, errors.Wrap(errors.CodeInvalidParam, err, "移动实名回调 JSON 解析失败")
}
if len(payload.Result) > 0 {
result.BusiSeq = strings.TrimSpace(payload.Result[0].BusiSeq)
result.ICCID = strings.TrimSpace(payload.Result[0].ICCID)
}
if payload.Status != "0" || payload.Message != "正确" || len(payload.Result) == 0 || payload.Result[0].RegStatus != "00000" {
return result, errors.New(errors.CodeInvalidParam, "移动实名回调业务结果无效")
}
if validation := pkgvalidator.ValidateICCIDWithoutCarrier(result.ICCID); !validation.Valid {
return result, errors.New(errors.CodeInvalidParam, "移动实名回调 ICCID 无效")
}
return result, nil
}

View File

@@ -0,0 +1,107 @@
// Package carriercallback 提供运营商回调协议到内部观测模型的适配能力。
package carriercallback
import (
"bytes"
"context"
"encoding/xml"
"strings"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
pkgvalidator "github.com/break/junhong_cmp_fiber/pkg/validator"
)
const (
// CTCCRealnameActionVerified 表示电信实名补录已完成。
CTCCRealnameActionVerified = "verified"
// CTCCRealnameActionIgnored 表示报文合法但无需改变实名事实。
CTCCRealnameActionIgnored = "ignored"
)
// CTCCRealnameTranslation 是电信 XML 回调的受控内部语义。
type CTCCRealnameTranslation struct {
ICCID string
AcceptMessage string
ResultMessage string
GroupTransactionID string
Action string
}
type ctccContractRoot struct {
XMLName xml.Name `xml:"ContractRoot"`
ICCID string `xml:"ICCID"`
AcceptMessage string `xml:"ACCEPTMSG"`
ResultMessage string `xml:"RESULTMSG"`
GroupTransactionID string `xml:"GROUP_TRANSACTIONID"`
}
// CTCCRealnameTranslator 解析已知电信 XML 协议字段。
type CTCCRealnameTranslator struct{}
// NewCTCCRealnameTranslator 创建电信实名回调 Translator。
func NewCTCCRealnameTranslator() *CTCCRealnameTranslator {
return &CTCCRealnameTranslator{}
}
// Translate 将 XML 转为受控语义;未知业务结果保留为 ignored。
func (t *CTCCRealnameTranslator) Translate(body []byte) (CTCCRealnameTranslation, error) {
var result CTCCRealnameTranslation
if len(bytes.TrimSpace(body)) == 0 {
return result, errors.New(errors.CodeInvalidParam, "电信实名回调报文为空")
}
var payload ctccContractRoot
if err := xml.Unmarshal(body, &payload); err != nil {
return result, errors.Wrap(errors.CodeInvalidParam, err, "电信实名回调 XML 解析失败")
}
result.ICCID = strings.TrimSpace(payload.ICCID)
result.AcceptMessage = strings.TrimSpace(payload.AcceptMessage)
result.ResultMessage = strings.TrimSpace(payload.ResultMessage)
result.GroupTransactionID = strings.TrimSpace(payload.GroupTransactionID)
if err := t.validateICCID(result.ICCID); err != nil {
return result, err
}
result.Action = CTCCRealnameActionIgnored
if result.ResultMessage == "成功" && strings.Contains(result.AcceptMessage, "已完成实名信息补录") {
result.Action = CTCCRealnameActionVerified
}
return result, nil
}
func (t *CTCCRealnameTranslator) validateICCID(iccid string) error {
if result := pkgvalidator.ValidateICCIDWithoutCarrier(iccid); !result.Valid {
return errors.New(errors.CodeInvalidParam, "电信实名回调 ICCID 无效")
}
return nil
}
// CTCCCardResolver 使用系统级精确列查询定位电信回调卡。
type CTCCCardResolver struct {
db *gorm.DB
}
// NewCTCCCardResolver 创建电信回调卡定位器。
func NewCTCCCardResolver(db *gorm.DB) *CTCCCardResolver {
return &CTCCCardResolver{db: db}
}
// Resolve 按 ICCID 长度选择唯一列,禁止跨列降级。
func (r *CTCCCardResolver) Resolve(ctx context.Context, iccid string) ([]*model.IotCard, error) {
if r == nil || r.db == nil {
return nil, errors.New(errors.CodeInternalError, "电信实名回调卡查询能力未配置")
}
column := "iccid_19"
if len(iccid) == 20 {
column = "iccid_20"
}
var cards []*model.IotCard
if err := r.db.WithContext(ctx).
Where(column+" = ? AND deleted_at IS NULL", iccid).
Limit(2).
Find(&cards).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询电信实名回调卡失败")
}
return cards, nil
}

View File

@@ -0,0 +1,22 @@
package carriercallback
// CUCCRealnameTranslation 是联通实名成功回调的受控语义。
type CUCCRealnameTranslation = CUCCRealnameRemovalTranslation
// CUCCRealnameTranslator 解析联通外层 data 字符串及其内层实名 JSON。
type CUCCRealnameTranslator struct {
parser *CUCCRealnameRemovalTranslator
}
// NewCUCCRealnameTranslator 创建联通实名成功回调 Translator。
func NewCUCCRealnameTranslator() *CUCCRealnameTranslator {
return &CUCCRealnameTranslator{parser: NewCUCCRealnameRemovalTranslator()}
}
// Translate 解析联通实名成功报文;协议结构与解除实名报文一致。
func (t *CUCCRealnameTranslator) Translate(body []byte) (CUCCRealnameTranslation, error) {
if t == nil || t.parser == nil {
return NewCUCCRealnameRemovalTranslator().Translate(body)
}
return t.parser.Translate(body)
}

View File

@@ -0,0 +1,67 @@
package carriercallback
import (
"bytes"
"strings"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/errors"
pkgvalidator "github.com/break/junhong_cmp_fiber/pkg/validator"
)
// CUCCRealnameRemovalTranslation 是联通解除实名回调的受控语义。
type CUCCRealnameRemovalTranslation struct {
ICCID string
DateChanged string
}
// CUCCRealnameRemovalTranslator 解析外层 data 字符串及其内层 JSON。
type CUCCRealnameRemovalTranslator struct{}
// NewCUCCRealnameRemovalTranslator 创建联通解除实名 Translator。
func NewCUCCRealnameRemovalTranslator() *CUCCRealnameRemovalTranslator {
return &CUCCRealnameRemovalTranslator{}
}
// Translate 解析并校验联通解除实名报文,不执行任何业务写入。
func (t *CUCCRealnameRemovalTranslator) Translate(body []byte) (CUCCRealnameRemovalTranslation, error) {
var result CUCCRealnameRemovalTranslation
if len(bytes.TrimSpace(body)) == 0 {
return result, errors.New(errors.CodeInvalidParam, "联通解除实名回调报文为空")
}
var outer struct {
Data string `json:"data"`
}
if err := sonic.Unmarshal(body, &outer); err != nil {
return result, errors.Wrap(errors.CodeInvalidParam, err, "联通解除实名外层 JSON 解析失败")
}
if strings.TrimSpace(outer.Data) == "" {
return result, errors.New(errors.CodeInvalidParam, "联通解除实名回调 data 为空")
}
var inner struct {
ICCID string `json:"iccid"`
DateChanged string `json:"dateChanged"`
}
if err := sonic.Unmarshal([]byte(outer.Data), &inner); err != nil {
return result, errors.Wrap(errors.CodeInvalidParam, err, "联通解除实名内层 JSON 解析失败")
}
result.ICCID = strings.TrimSpace(inner.ICCID)
result.DateChanged = strings.TrimSpace(inner.DateChanged)
if validation := pkgvalidator.ValidateICCIDWithoutCarrier(result.ICCID); !validation.Valid {
return result, errors.New(errors.CodeInvalidParam, "联通解除实名回调 ICCID 无效")
}
if result.DateChanged == "" {
return result, errors.New(errors.CodeInvalidParam, "联通解除实名回调变更时间为空")
}
return result, nil
}
// CUCCCardResolver 复用系统级 ICCID 精确查询约束。
type CUCCCardResolver = CTCCCardResolver
// NewCUCCCardResolver 创建联通回调卡定位器。
func NewCUCCCardResolver(db *gorm.DB) *CUCCCardResolver {
return NewCTCCCardResolver(db)
}

View File

@@ -187,7 +187,7 @@ func (r *Repository) RecordInbound(ctx context.Context, input InboundAttempt) (*
if r == nil || r.db == nil {
return nil, false, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Integration Log 数据库未配置")
}
if input.Provider == "" || input.Operation == "" || input.IdempotencyKey == "" || len(input.RawPayload) == 0 {
if input.Provider == "" || input.Operation == "" || input.IdempotencyKey == "" {
return nil, false, pkgerrors.New(pkgerrors.CodeInvalidParam, "入站 Integration Log 参数无效")
}
if input.IntegrationID == "" {
@@ -233,6 +233,29 @@ func (r *Repository) RecordInbound(ctx context.Context, input InboundAttempt) (*
return &existing, false, nil
}
// ClaimExpiredInboundPending 原子认领已超过处理租约的入站 pending 记录。
func (r *Repository) ClaimExpiredInboundPending(ctx context.Context, integrationID string, lease time.Duration) (bool, error) {
if r == nil || r.db == nil {
return false, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Integration Log 数据库未配置")
}
if strings.TrimSpace(integrationID) == "" || lease <= 0 {
return false, pkgerrors.New(pkgerrors.CodeInvalidParam, "入站 Integration Log 恢复参数无效")
}
now := r.now().UTC()
result := r.db.WithContext(ctx).Model(&model.IntegrationLog{}).
Where("integration_id = ? AND direction = ? AND result = ? AND (started_at IS NULL OR started_at <= ?)",
integrationID, constants.IntegrationDirectionInbound, constants.IntegrationResultPending, now.Add(-lease)).
Updates(map[string]any{
"started_at": now,
"attempt": gorm.Expr("attempt + 1"),
"updated_at": now,
})
if result.Error != nil {
return false, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, result.Error, "认领待恢复入站 Integration Log 失败")
}
return result.RowsAffected == 1, nil
}
func validateAttempt(input Attempt) error {
if input.Provider == "" || input.Operation == "" {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 提供方和操作不能为空")
@@ -250,6 +273,7 @@ func isTerminalResult(result string) bool {
switch result {
case constants.IntegrationResultSuccess, constants.IntegrationResultFailed, constants.IntegrationResultUnknown,
constants.IntegrationResultNotFound, constants.IntegrationResultInvalidPayload, constants.IntegrationResultIgnored,
constants.IntegrationResultConflict,
constants.IntegrationResultMerged, constants.IntegrationResultRateLimited, constants.IntegrationResultCompleted,
constants.IntegrationResultCancelled:
return true

View File

@@ -11,6 +11,7 @@ import (
"github.com/google/uuid"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/asynctask"
@@ -42,6 +43,15 @@ func NewRepository() *Repository {
// Append 在业务事务中持久化稳定事件身份和必要快照。
func (r *Repository) Append(ctx context.Context, tx *gorm.DB, envelope Envelope) (*model.OutboxEvent, error) {
return r.append(ctx, tx, envelope, false)
}
// AppendIdempotent 在稳定 EventID 重投时保持原事件,不重复创建事实。
func (r *Repository) AppendIdempotent(ctx context.Context, tx *gorm.DB, envelope Envelope) (*model.OutboxEvent, error) {
return r.append(ctx, tx, envelope, true)
}
func (r *Repository) append(ctx context.Context, tx *gorm.DB, envelope Envelope, idempotent bool) (*model.OutboxEvent, error) {
if tx == nil {
return nil, stderrors.New("Outbox 追加必须传入 GORM 事务句柄")
}
@@ -71,7 +81,11 @@ func (r *Repository) Append(ctx context.Context, tx *gorm.DB, envelope Envelope)
RequestID: envelope.RequestID, CorrelationID: envelope.CorrelationID, Payload: datatypes.JSON(payload),
Status: constants.OutboxStatusPending, MaxRetries: constants.OutboxDefaultMaxRetries, NextAttemptAt: now,
}
if err := tx.WithContext(ctx).Create(event).Error; err != nil {
create := tx.WithContext(ctx)
if idempotent {
create = create.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "event_id"}}, DoNothing: true})
}
if err := create.Create(event).Error; err != nil {
return nil, err
}
return event, nil

View File

@@ -2,9 +2,9 @@ package dto
// GrantPackageItem 授权套餐操作项(用于创建/管理套餐列表)
type GrantPackageItem struct {
PackageID uint `json:"package_id" validate:"required" description:"套餐ID"`
CostPrice int64 `json:"cost_price" validate:"required,min=0" description:"成本价(分)"`
Remove *bool `json:"remove,omitempty" description:"是否删除该套餐授权true=删除)"`
PackageID uint `json:"package_id" validate:"required" required:"true" minimum:"1" description:"套餐ID"`
CostPrice *int64 `json:"cost_price,omitempty" validate:"omitempty,min=0" minimum:"0" description:"成本价(分),新增或调价时必填"`
Remove *bool `json:"remove,omitempty" description:"是否删除该套餐授权true=删除)"`
}
// ShopSeriesGrantPackageItem 授权套餐详情(响应中的套餐信息)
@@ -56,13 +56,13 @@ type ShopSeriesGrantResponse struct {
// CreateShopSeriesGrantRequest 创建系列授权请求
type CreateShopSeriesGrantRequest struct {
ShopID uint `json:"shop_id" validate:"required" description:"被授权代理店铺ID"`
SeriesID uint `json:"series_id" validate:"required" description:"套餐系列ID"`
ShopID uint `json:"shop_id" validate:"required" required:"true" minimum:"1" description:"被授权代理店铺ID"`
SeriesID uint `json:"series_id" validate:"required" required:"true" minimum:"1" description:"套餐系列ID"`
OneTimeCommissionAmount *int64 `json:"one_time_commission_amount,omitempty" description:"固定模式佣金金额(分),固定模式必填"`
CommissionTiers []GrantCommissionTierItem `json:"commission_tiers,omitempty" description:"梯度模式阶梯配置,梯度模式必填"`
EnableForceRecharge *bool `json:"enable_force_recharge,omitempty" description:"是否启用代理强充"`
ForceRechargeAmount *int64 `json:"force_recharge_amount,omitempty" description:"代理强充金额(分)"`
Packages []GrantPackageItem `json:"packages,omitempty" description:"初始授权套餐列表"`
Packages []GrantPackageItem `json:"packages" validate:"required,min=1,max=100,dive" required:"true" minItems:"1" maxItems:"100" description:"初始授权套餐列表1100项套餐ID不可重复"`
ExpiryBaseOverride *string `json:"expiry_base_override" nullable:"true" description:"分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)"`
ExpiryBaseOverrideSet bool `json:"-"`
}
@@ -77,7 +77,7 @@ type UpdateShopSeriesGrantRequest struct {
// ManageGrantPackagesRequest 管理授权套餐请求
type ManageGrantPackagesRequest struct {
Packages []GrantPackageItem `json:"packages" validate:"required,min=1" description:"套餐操作列表"`
Packages []GrantPackageItem `json:"packages" validate:"required,min=1,max=100,dive" required:"true" minItems:"1" maxItems:"100" description:"套餐操作列表1100项套餐ID不可重复"`
ExpiryBaseOverride *string `json:"expiry_base_override" nullable:"true" description:"新增分配的生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)"`
ExpiryBaseOverrideSet bool `json:"-"`
}

View File

@@ -52,6 +52,42 @@ func registerAdminOrderRoutes(router fiber.Router, handler *admin.OrderHandler,
})
}
// registerCarrierCallbackRoutes 注册运营商业务回调路由。
func registerCarrierCallbackRoutes(router fiber.Router, handler *callback.CTCCRealnameHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "POST", "/carriers/ctcc/realname", handler.Realname, RouteSpec{
Summary: "电信实名结果回调",
Description: "接收电信 XML 实名补录结果,固定返回运营商成功应答。",
Tags: []string{"运营商回调"},
Input: nil,
Output: nil,
Auth: false,
})
}
// registerCMCCCallbackRoutes 注册中国移动实名回调路由。
func registerCMCCCallbackRoutes(router fiber.Router, handler *callback.CMCCRealnameHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "POST", "/carriers/cmcc/realname", handler.Realname, RouteSpec{
Summary: "移动实名结果回调", Description: "接收中国移动 JSON 实名成功结果,固定返回运营商成功应答。",
Tags: []string{"运营商回调"}, Input: nil, Output: nil, Auth: false,
})
}
// registerCUCCRealnameCallbackRoutes 注册中国联通实名成功回调路由。
func registerCUCCRealnameCallbackRoutes(router fiber.Router, handler *callback.CUCCRealnameHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "POST", "/carriers/cucc/realname", handler.Realname, RouteSpec{
Summary: "联通实名结果回调", Description: "接收中国联通嵌套 JSON 实名成功结果,固定返回运营商成功应答。",
Tags: []string{"运营商回调"}, Input: nil, Output: nil, Auth: false,
})
}
// registerCUCCCallbackRoutes 注册中国联通解除实名回调路由。
func registerCUCCCallbackRoutes(router fiber.Router, handler *callback.CUCCRealnameRemovalHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "POST", "/carriers/cucc/realname/remove", handler.Remove, RouteSpec{
Summary: "联通解除实名回调", Description: "识别中国联通解除实名 JSON仅留痕且不修改本地实名事实。",
Tags: []string{"运营商回调"}, Input: nil, Output: nil, Auth: false,
})
}
// registerPaymentCallbackRoutes 注册支付回调路由
func registerPaymentCallbackRoutes(router fiber.Router, handler *callback.PaymentHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "POST", "/wechat-pay", handler.WechatPayCallback, RouteSpec{

View File

@@ -46,4 +46,20 @@ func RegisterRoutesWithDoc(app *fiber.App, handlers *bootstrap.Handlers, middlew
callbackGroup := app.Group("/api/callback")
registerPaymentCallbackRoutes(callbackGroup, handlers.PaymentCallback, doc, "/api/callback")
}
if handlers.CTCCRealnameCallback != nil {
callbackGroup := app.Group("/api/callback")
registerCarrierCallbackRoutes(callbackGroup, handlers.CTCCRealnameCallback, doc, "/api/callback")
}
if handlers.CMCCRealnameCallback != nil {
callbackGroup := app.Group("/api/callback")
registerCMCCCallbackRoutes(callbackGroup, handlers.CMCCRealnameCallback, doc, "/api/callback")
}
if handlers.CUCCRealnameCallback != nil {
callbackGroup := app.Group("/api/callback")
registerCUCCRealnameCallbackRoutes(callbackGroup, handlers.CUCCRealnameCallback, doc, "/api/callback")
}
if handlers.CUCCRealnameRemovalCallback != nil {
callbackGroup := app.Group("/api/callback")
registerCUCCCallbackRoutes(callbackGroup, handlers.CUCCRealnameRemovalCallback, doc, "/api/callback")
}
}

View File

@@ -4,9 +4,11 @@ import (
"context"
"fmt"
"math/rand"
"strconv"
"strings"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
@@ -38,6 +40,12 @@ type Service struct {
agentWalletStore *postgres.AgentWalletStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
deviceStore *postgres.DeviceStore
observationSeries cardObservationApp.BestEffortSeriesDispatcher
}
// SetObservationSeriesDispatcher 注入 OpenAPI 读取后的后台观测序列端口。
func (s *Service) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) {
s.observationSeries = dispatcher
}
// New 创建代理开放接口业务编排服务
@@ -83,7 +91,11 @@ func (s *Service) GetCardTraffic(ctx context.Context, req *dto.AgentOpenAPICardQ
if cardErr == nil {
// 独立卡:直接查卡维度流量
if card.IsStandalone {
return s.buildCardTrafficResponse(ctx, req.CardNo, "iot_card", card.ID, "")
resp, err := s.buildCardTrafficResponse(ctx, req.CardNo, "iot_card", card.ID, "")
if err == nil {
s.dispatchCardObservation(ctx, constants.CardObservationSceneOpenCardTraffic, card.ID, constants.CardObservationSyncTypeTraffic)
}
return resp, err
}
// 已绑定设备的卡:反查设备后查设备维度流量
binding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
@@ -94,7 +106,11 @@ func (s *Service) GetCardTraffic(ctx context.Context, req *dto.AgentOpenAPICardQ
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询绑定设备信息失败")
}
return s.buildCardTrafficResponse(ctx, req.CardNo, "device", device.ID, device.VirtualNo)
resp, buildErr := s.buildCardTrafficResponse(ctx, req.CardNo, "device", device.ID, device.VirtualNo)
if buildErr == nil {
s.dispatchCardObservation(ctx, constants.CardObservationSceneOpenCardTraffic, card.ID, constants.CardObservationSyncTypeTraffic)
}
return resp, buildErr
}
// 兜底:尝试解析为设备标识(支持 IMEI/虚拟号)
@@ -103,7 +119,11 @@ func (s *Service) GetCardTraffic(ctx context.Context, req *dto.AgentOpenAPICardQ
// 两种解析都失败,返回原始卡解析错误(语义更贴近入参)
return nil, cardErr
}
return s.buildCardTrafficResponse(ctx, req.CardNo, "device", device.ID, device.VirtualNo)
resp, buildErr := s.buildCardTrafficResponse(ctx, req.CardNo, "device", device.ID, device.VirtualNo)
if buildErr == nil {
s.dispatchDeviceCardObservations(ctx, constants.CardObservationSceneOpenCardTraffic, device.ID, constants.CardObservationSyncTypeTraffic)
}
return resp, buildErr
}
// buildCardTrafficResponse 统一构造卡流量响应,支持卡和设备两种载体维度
@@ -167,14 +187,16 @@ func (s *Service) GetCardStatus(ctx context.Context, req *dto.AgentOpenAPICardQu
stopReason = ""
}
return &dto.AgentOpenAPICardStatusResponse{
resp := &dto.AgentOpenAPICardStatusResponse{
CardNo: req.CardNo,
CardStatus: cardStatus,
CardStatusName: constants.AgentOpenAPICardStatusName(cardStatus),
GatewayExtend: card.GatewayExtend,
StopReason: stopReason,
StopReasonName: constants.AgentOpenAPIStopReasonName(stopReason),
}, nil
}
s.dispatchCardObservation(ctx, constants.CardObservationSceneOpenCardNetwork, card.ID, constants.CardObservationSyncTypeNetwork)
return resp, nil
}
// GetRealnameStatus 查询开放接口单卡实名状态
@@ -184,10 +206,12 @@ func (s *Service) GetRealnameStatus(ctx context.Context, req *dto.AgentOpenAPICa
return nil, err
}
return &dto.AgentOpenAPIRealnameStatusResponse{
resp := &dto.AgentOpenAPIRealnameStatusResponse{
CardNo: req.CardNo,
IsRealnamed: card.RealNameStatus == constants.RealNameStatusVerified,
}, nil
}
s.dispatchCardObservation(ctx, constants.CardObservationSceneOpenCardRealname, card.ID, constants.CardObservationSyncTypeRealname)
return resp, nil
}
// ResumeCard 调用上游复机机卡分离停机卡
@@ -396,6 +420,41 @@ func (s *Service) CreateWalletPackageOrders(ctx context.Context, req *dto.AgentO
return resp, nil
}
func (s *Service) dispatchDeviceCardObservations(ctx context.Context, scene string, deviceID uint, syncType string) {
if s.observationSeries == nil || deviceID == 0 {
return
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
s.observationSeries.DispatchDeviceCards(ctx, cardObservationApp.DeviceCardsSeriesRequest{
DeviceID: deviceID,
Request: cardObservationApp.SeriesRequest{
Scene: scene, ResourceType: constants.CardObservationResourceTypeDevice,
ResourceID: strconv.FormatUint(uint64(deviceID), 10), SyncType: syncType,
Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
},
})
}
func (s *Service) dispatchCardObservation(ctx context.Context, scene string, cardID uint, syncType string) {
if s.observationSeries == nil || cardID == 0 {
return
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
s.observationSeries.Dispatch(ctx, cardObservationApp.SeriesRequest{
Scene: scene, ResourceType: constants.CardObservationResourceTypeCard,
ResourceID: strconv.FormatUint(uint64(cardID), 10), SyncType: syncType,
Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
})
}
// resolveOpenAPIDevice 将开放接口设备标识解析为当前代理可见的设备
// 设备不存在或不属于代理管辖店铺时统一返回 CodeForbidden避免信息泄露
func (s *Service) resolveOpenAPIDevice(ctx context.Context, deviceNo string) (*model.Device, error) {
@@ -473,6 +532,7 @@ func (s *Service) GetDeviceTraffic(ctx context.Context, req *dto.AgentOpenAPIDev
resp.PendingPackages = append(resp.PendingPackages, s.buildTrafficItem(usage, packageMap, seriesMap, false))
}
s.dispatchDeviceCardObservations(ctx, constants.CardObservationSceneOpenDeviceTraffic, device.ID, constants.CardObservationSyncTypeTraffic)
return resp, nil
}

View File

@@ -129,6 +129,7 @@ func (s *Service) GatewaySetWiFi(ctx context.Context, identifier string, req *dt
)
return err
}
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, "")
if err = s.gatewayClient.SetWiFi(ctx, &gateway.WiFiReq{
CardNo: imei,
Params: gateway.WiFiParams{
@@ -175,6 +176,7 @@ func (s *Service) GatewaySetWiFi(ctx context.Context, identifier string, req *dt
0,
nil,
)
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceSetWiFi, observation, false)
return nil
}
@@ -200,6 +202,7 @@ func (s *Service) GatewaySwitchCard(ctx context.Context, identifier string, req
)
return err
}
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, req.TargetICCID)
if err = s.gatewayClient.SwitchCard(ctx, &gateway.SwitchCardReq{
CardNo: imei,
ICCID: req.TargetICCID,
@@ -233,6 +236,7 @@ func (s *Service) GatewaySwitchCard(ctx context.Context, identifier string, req
0,
nil,
)
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceSwitchCard, observation, true)
return nil
}
@@ -255,6 +259,7 @@ func (s *Service) GatewayRebootDevice(ctx context.Context, identifier string) er
)
return err
}
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, "")
if err = s.gatewayClient.RebootDevice(ctx, &gateway.DeviceOperationReq{
DeviceID: imei,
}); err != nil {
@@ -287,6 +292,7 @@ func (s *Service) GatewayRebootDevice(ctx context.Context, identifier string) er
0,
nil,
)
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceReboot, observation, false)
return nil
}
@@ -309,6 +315,7 @@ func (s *Service) GatewayResetDevice(ctx context.Context, identifier string) err
)
return err
}
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, "")
if err = s.gatewayClient.ResetDevice(ctx, &gateway.DeviceOperationReq{
DeviceID: imei,
}); err != nil {
@@ -341,6 +348,7 @@ func (s *Service) GatewayResetDevice(ctx context.Context, identifier string) err
0,
nil,
)
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceReset, observation, false)
return nil
}
@@ -556,6 +564,7 @@ func (s *Service) GatewaySwitchMode(ctx context.Context, identifier string, req
)
return appErr
}
observation := s.captureDeviceControlObservation(ctx, device.ID, targetCard.ID, targetCard.ICCID)
if err = s.gatewayClient.SwitchMode(ctx, &gateway.SwitchModeReq{
CardNo: imei,
SwitchMode: strconv.Itoa(switchMode),
@@ -590,5 +599,6 @@ func (s *Service) GatewaySwitchMode(ctx context.Context, identifier string, req
0,
nil,
)
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceSwitchMode, observation, true)
return nil
}

View File

@@ -2,8 +2,11 @@ package device
import (
"context"
"strings"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
@@ -37,6 +40,93 @@ type Service struct {
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore
enterpriseStore *postgres.EnterpriseStore
packageExpiryQuery *packageexpiry.Query
observationSeriesEvents cardObservationApp.SeriesEventWriter
observationSeries cardObservationApp.BestEffortSeriesDispatcher
}
// SetObservationSeriesEventWriter 注入设备停复机成功观测序列 Outbox Writer。
func (s *Service) SetObservationSeriesEventWriter(writer cardObservationApp.SeriesEventWriter) {
s.observationSeriesEvents = writer
}
// SetObservationSeriesDispatcher 注入设备控制成功后的后台观测分发器。
func (s *Service) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) {
s.observationSeries = dispatcher
}
type deviceControlObservationSnapshot struct {
SourceCardID uint
TargetCardID uint
BoundCardIDs []uint
TargetICCID string
}
func (s *Service) captureDeviceControlObservation(ctx context.Context, deviceID, knownTargetCardID uint, targetICCID string) deviceControlObservationSnapshot {
snapshot := deviceControlObservationSnapshot{TargetICCID: targetICCID}
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
if err != nil {
logger.GetAppLogger().Warn("冻结设备控制观测绑定快照失败,继续执行原操作", zap.Uint("device_id", deviceID), zap.Error(err))
return snapshot
}
bound := make(map[uint]struct{}, len(bindings))
for _, binding := range bindings {
if binding == nil || binding.IotCardID == 0 {
continue
}
snapshot.BoundCardIDs = append(snapshot.BoundCardIDs, binding.IotCardID)
bound[binding.IotCardID] = struct{}{}
if binding.IsCurrent {
snapshot.SourceCardID = binding.IotCardID
}
}
if knownTargetCardID != 0 {
if _, ok := bound[knownTargetCardID]; ok {
snapshot.TargetCardID = knownTargetCardID
}
return snapshot
}
if strings.TrimSpace(targetICCID) == "" || len(snapshot.BoundCardIDs) == 0 {
return snapshot
}
cards, err := s.iotCardStore.GetByIDs(ctx, snapshot.BoundCardIDs)
if err != nil {
logger.GetAppLogger().Warn("冻结设备控制观测目标卡快照失败,继续执行原操作", zap.Uint("device_id", deviceID), zap.String("target_iccid", targetICCID), zap.Error(err))
return snapshot
}
for _, card := range cards {
if card != nil && cardMatchesICCID(card, targetICCID) {
snapshot.TargetCardID = card.ID
break
}
}
return snapshot
}
func cardMatchesICCID(card *model.IotCard, expected string) bool {
expected = strings.TrimSpace(expected)
return strings.EqualFold(strings.TrimSpace(card.ICCID), expected) ||
strings.EqualFold(strings.TrimSpace(card.ICCID19), expected) ||
(card.ICCID20 != nil && strings.EqualFold(strings.TrimSpace(*card.ICCID20), expected))
}
func (s *Service) dispatchDeviceControlObservation(ctx context.Context, deviceID uint, scene string, snapshot deviceControlObservationSnapshot, includeTargetTraffic bool) {
if s.observationSeries == nil || deviceID == 0 {
return
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
request := cardObservationApp.SeriesRequest{
Scene: scene, Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
}
s.observationSeries.DispatchDeviceControl(ctx, cardObservationApp.DeviceControlSeriesRequest{
DeviceID: deviceID, TargetICCID: snapshot.TargetICCID,
SourceCardID: snapshot.SourceCardID, TargetCardID: snapshot.TargetCardID,
BoundCardIDs: snapshot.BoundCardIDs,
IncludeTargetTraffic: includeTargetTraffic, Request: request,
})
}
// SetPackageExpiryQuery 注入套餐最终到期查询,供列表使用批量投影。
@@ -1678,11 +1768,11 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus
}
now := time.Now()
if dbErr := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{
if dbErr := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{
"network_status": constants.NetworkStatusOffline,
"stopped_at": now,
"stop_reason": constants.StopReasonManual,
}); dbErr != nil {
}, constants.CardObservationSceneBusinessStop, "offline", s.gatewayClient != nil); dbErr != nil {
log.Error("设备停机-更新卡状态失败",
zap.Uint("card_id", card.ID),
zap.Error(dbErr))
@@ -1699,7 +1789,7 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus
// 成功停机至少一张卡后设置保护期
if successCount > 0 && s.redis != nil {
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "stop"), 1, constants.DeviceProtectPeriodDuration)
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "stop"), uuid.NewString(), constants.DeviceProtectPeriodDuration)
s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "start"))
}
@@ -1907,11 +1997,11 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error {
}
now := time.Now()
if dbErr := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{
if dbErr := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": now,
"stop_reason": "",
}); dbErr != nil {
}, constants.CardObservationSceneBusinessResume, "online", s.gatewayClient != nil); dbErr != nil {
log.Error("设备复机-更新卡状态失败",
zap.Uint("card_id", card.ID),
zap.Error(dbErr))
@@ -1926,7 +2016,7 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error {
// 成功复机至少一张卡后设置保护期
if successCount > 0 && s.redis != nil {
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "start"), 1, constants.DeviceProtectPeriodDuration)
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "start"), uuid.NewString(), constants.DeviceProtectPeriodDuration)
s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "stop"))
}
@@ -2089,3 +2179,29 @@ func (s *Service) invalidatePollingCardCache(cardID uint) {
}
_ = s.redis.Del(context.Background(), constants.RedisPollingCardInfoKey(cardID)).Err()
}
func (s *Service) updateCardAndAppendNetworkSeries(ctx context.Context, cardID uint, fields map[string]any, scene, expected string, upstreamCalled bool) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).Updates(fields).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新设备绑定卡停复机状态失败")
}
if !upstreamCalled || cardObservationApp.IsSeriesTriggerSuppressed(ctx) {
return nil
}
if s.observationSeriesEvents == nil {
return errors.New(errors.CodeInternalError, "设备停复机观测 Outbox Writer 未配置")
}
operationID := uuid.NewString()
requestID := operationID
if value := middleware.GetRequestIDFromContext(ctx); value != nil && *value != "" {
requestID = *value
}
return s.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: "card-observation:network-command:" + operationID,
Scene: scene, ResourceType: constants.CardObservationResourceTypeCard, ResourceID: cardID,
SyncTypes: []string{constants.CardObservationSyncTypeNetwork}, ExpectedValue: expected,
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: time.Now().UTC(),
RequestID: requestID, CorrelationID: requestID,
})
})
}

View File

@@ -2,8 +2,12 @@ package iot_card
import (
"context"
"strconv"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"go.uber.org/zap"
)
@@ -50,11 +54,35 @@ func (s *Service) GatewayQueryRealnameStatus(ctx context.Context, iccid string)
// GatewayGetRealnameLink 获取实名认证跳转链接
func (s *Service) GatewayGetRealnameLink(ctx context.Context, iccid string) (*gateway.RealnameLinkResp, error) {
if err := s.validateCardAccess(ctx, iccid); err != nil {
card, err := s.iotCardStore.GetByICCID(ctx, iccid)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "卡不存在或无权限访问")
}
response, err := s.gatewayClient.GetRealnameLink(ctx, &gateway.CardStatusReq{
CardNo: iccid,
})
if err != nil {
return nil, err
}
return s.gatewayClient.GetRealnameLink(ctx, &gateway.CardStatusReq{
CardNo: iccid,
s.dispatchAdminRealnameObservation(ctx, card)
return response, nil
}
func (s *Service) dispatchAdminRealnameObservation(ctx context.Context, card *model.IotCard) {
if s.observationSeries == nil || card == nil {
return
}
requestID := requestIDFromContext(ctx)
s.observationSeries.DispatchRealnameWithCapability(ctx, cardapp.RealnameCapabilitySeriesRequest{
CarrierID: card.CarrierID,
Request: cardapp.SeriesRequest{
Scene: constants.CardObservationSceneAdminRealnameLink,
ResourceType: constants.CardObservationResourceTypeCard,
ResourceID: strconv.FormatUint(uint64(card.ID), 10),
SyncType: constants.CardObservationSyncTypeRealname,
ExpectedValue: "verified", Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
},
})
}

View File

@@ -9,6 +9,7 @@ import (
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/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/cardtrafficlock"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
@@ -24,8 +25,6 @@ import (
"gorm.io/gorm"
)
const cardTrafficSyncLockTTL = 10 * time.Minute
// PollingCallback 轮询回调接口
// 用于在卡生命周期事件发生时通知轮询调度器
type PollingCallback interface {
@@ -71,6 +70,13 @@ type Service struct {
enterpriseStore *postgres.EnterpriseStore
packageExpiryQuery *packageexpiry.Query
cardObservation *cardapp.Service
observationSeries cardapp.BestEffortSeriesDispatcher
trafficLock *cardtrafficlock.Lock
}
// SetObservationSeriesDispatcher 注入获取实名链接后的后台观测端口。
func (s *Service) SetObservationSeriesDispatcher(dispatcher cardapp.BestEffortSeriesDispatcher) {
s.observationSeries = dispatcher
}
// SetCardObservationService 注入统一卡实名观测写入用例。
@@ -152,22 +158,17 @@ func (s *Service) SetDeviceSimBindingStore(deviceSimBindingStore *postgres.Devic
// 用于清理实名逆转计数器,避免历史计数干扰手动实名后的判定
func (s *Service) SetRedisClient(redisClient *redis.Client) {
s.redis = redisClient
s.trafficLock = cardtrafficlock.New(redisClient)
}
// acquireCardTrafficSyncLock 获取卡流量同步锁,避免主动刷新与轮询重复统计同一上游读数。
func (s *Service) acquireCardTrafficSyncLock(ctx context.Context, cardID uint) (bool, error) {
if s.redis == nil {
return true, nil
}
return s.redis.SetNX(ctx, constants.RedisCardTrafficSyncLockKey(cardID), "1", cardTrafficSyncLockTTL).Result()
func (s *Service) acquireCardTrafficSyncLock(ctx context.Context, cardID uint) (string, bool, error) {
return s.trafficLock.Acquire(ctx, cardID)
}
// releaseCardTrafficSyncLock 释放卡流量同步锁。
func (s *Service) releaseCardTrafficSyncLock(ctx context.Context, cardID uint) {
if s.redis == nil {
return
}
if err := s.redis.Del(ctx, constants.RedisCardTrafficSyncLockKey(cardID)).Err(); err != nil {
func (s *Service) releaseCardTrafficSyncLock(ctx context.Context, cardID uint, token string) {
if err := s.trafficLock.Release(ctx, cardID, token); err != nil {
s.logger.Warn("释放卡流量同步锁失败", zap.Uint("card_id", cardID), zap.Error(err))
}
}
@@ -1531,13 +1532,13 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string)
return err
}
locked, lockErr := s.acquireCardTrafficSyncLock(ctx, card.ID)
lockToken, locked, lockErr := s.acquireCardTrafficSyncLock(ctx, card.ID)
if lockErr != nil {
return errors.Wrap(errors.CodeInternalError, lockErr, "获取卡流量同步锁失败")
} else if !locked {
return errors.New(errors.CodeTooManyRequests, "卡流量正在同步,请稍后重试")
} else {
defer s.releaseCardTrafficSyncLock(ctx, card.ID)
defer s.releaseCardTrafficSyncLock(ctx, card.ID, lockToken)
latestCard, loadErr := s.iotCardStore.GetByID(ctx, card.ID)
if loadErr != nil {
return errors.Wrap(errors.CodeInternalError, loadErr, "刷新卡数据失败")

View File

@@ -6,6 +6,8 @@ import (
"strings"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
@@ -31,19 +33,27 @@ var _ StopResumeServiceInterface = (*StopResumeService)(nil)
// StopResumeService 停复机服务
// 处理 IoT 卡的自动停机、复机和手动停复机逻辑
type StopResumeService struct {
redis *redis.Client
iotCardStore *postgres.IotCardStore
packageUsageStore *postgres.PackageUsageStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
gatewayClient *gateway.Client
logger *zap.Logger
assetAuditService AssetAuditService
pollingCallback PollingCallback
db *gorm.DB
redis *redis.Client
iotCardStore *postgres.IotCardStore
packageUsageStore *postgres.PackageUsageStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
gatewayClient *gateway.Client
logger *zap.Logger
assetAuditService AssetAuditService
pollingCallback PollingCallback
observationSeriesEvents cardObservationApp.SeriesEventWriter
maxRetries int
retryInterval time.Duration
}
// SetObservationSeriesEventWriter 注入停复机成功观测序列 Outbox Writer。
func (s *StopResumeService) SetObservationSeriesEventWriter(db *gorm.DB, writer cardObservationApp.SeriesEventWriter) {
s.db = db
s.observationSeriesEvents = writer
}
// NewStopResumeService 创建停复机服务
func NewStopResumeService(
redis *redis.Client,
@@ -96,8 +106,7 @@ func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotC
zap.Uint("card_id", card.ID), zap.Error(lookupErr))
// 降级:查不到绑定时按单卡处理
} else if bound {
s.stopDeviceCards(ctx, deviceID, primaryReason)
return nil
return s.stopDeviceCards(ctx, deviceID, primaryReason)
}
}
return s.stopCardWithRetry(ctx, card, primaryReason)
@@ -121,8 +130,7 @@ func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotC
s.logger.Error("查询设备绑定关系失败,降级为单卡复机",
zap.Uint("card_id", card.ID), zap.Error(lookupErr))
} else if bound {
s.resumeDeviceCards(ctx, deviceID)
return nil
return s.resumeDeviceCards(ctx, deviceID)
}
}
return s.resumeSingleCard(ctx, card.ID)
@@ -291,14 +299,17 @@ func (s *StopResumeService) shouldResume(ctx context.Context, card *model.IotCar
// stopDeviceCards 停机设备下所有在线卡(含设备维度幂等锁)
// 防止设备下多张卡并发触发 EvaluateAndAct 导致重复 Gateway 停机调用
func (s *StopResumeService) stopDeviceCards(ctx context.Context, deviceID uint, stopReason string) {
func (s *StopResumeService) stopDeviceCards(ctx context.Context, deviceID uint, stopReason string) error {
// 设备维度幂等锁:防止多协程同时对同一设备停机
lockKey := constants.RedisPollingDeviceOpLockKey(deviceID)
locked, _ := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
locked, lockErr := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
if lockErr != nil {
return errors.Wrap(errors.CodeRedisError, lockErr, "获取设备停机锁失败")
}
if !locked {
s.logger.Debug("设备停机操作已在进行中,跳过重复调用",
zap.Uint("device_id", deviceID))
return
return nil
}
defer s.redis.Del(ctx, lockKey)
@@ -307,29 +318,38 @@ func (s *StopResumeService) stopDeviceCards(ctx context.Context, deviceID uint,
if err != nil {
s.logger.Error("查询设备在线卡失败",
zap.Uint("device_id", deviceID), zap.Error(err))
return
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备在线卡失败")
}
var cardErrors []error
for _, card := range cards {
if stopErr := s.stopCardWithRetry(ctx, card, stopReason); stopErr != nil {
cardErrors = append(cardErrors, stopErr)
s.logger.Warn("设备卡停机失败,继续处理其他卡",
zap.Uint("device_id", deviceID),
zap.Uint("card_id", card.ID),
zap.Error(stopErr))
}
}
if len(cardErrors) > 0 {
return errors.Wrap(errors.CodeInternalError, stderrors.Join(cardErrors...), "设备停机存在未成功处理的卡")
}
return nil
}
// resumeDeviceCards 复机设备下满足条件的停机卡(含设备维度幂等锁)
// 遍历时对每张卡检查实名状态:未实名普通卡更新 stop_reason='not_realname' 后跳过
func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint) {
func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint) error {
// 设备维度幂等锁:与 stopDeviceCards 共用,防止停/复机并发
lockKey := constants.RedisPollingDeviceOpLockKey(deviceID)
locked, _ := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
locked, lockErr := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
if lockErr != nil {
return errors.Wrap(errors.CodeRedisError, lockErr, "获取设备复机锁失败")
}
if !locked {
s.logger.Debug("设备复机操作已在进行中,跳过重复调用",
zap.Uint("device_id", deviceID))
return
return nil
}
defer s.redis.Del(ctx, lockKey)
@@ -338,24 +358,31 @@ func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint
if err != nil {
s.logger.Error("查询设备停机卡失败",
zap.Uint("device_id", deviceID), zap.Error(err))
return
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备停机卡失败")
}
var cardErrors []error
for _, card := range cards {
if !s.isRealnameOK(card) {
if updateErr := s.iotCardStore.UpdateStopReason(ctx, card.ID, constants.StopReasonNotRealname); updateErr != nil {
cardErrors = append(cardErrors, updateErr)
s.logger.Warn("更新未实名卡停机原因失败",
zap.Uint("card_id", card.ID), zap.Error(updateErr))
}
continue
}
if resumeErr := s.resumeSingleCard(ctx, card.ID); resumeErr != nil {
cardErrors = append(cardErrors, resumeErr)
s.logger.Warn("设备卡复机失败,继续处理其他卡",
zap.Uint("device_id", deviceID),
zap.Uint("card_id", card.ID),
zap.Error(resumeErr))
}
}
if len(cardErrors) > 0 {
return errors.Wrap(errors.CodeInternalError, stderrors.Join(cardErrors...), "设备复机存在未成功处理的卡")
}
return nil
}
// CheckAndStopCard 检查流量耗尽并停机(旧入口,保留兼容性)
@@ -375,8 +402,7 @@ func (s *StopResumeService) ResumeCardIfStopped(ctx context.Context, carrierType
case "iot_card":
return s.resumeSingleCard(ctx, carrierID)
case "device":
s.resumeDeviceCards(ctx, carrierID)
return nil
return s.resumeDeviceCards(ctx, carrierID)
default:
return nil
}
@@ -460,11 +486,11 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
}
now := time.Now()
if err := s.iotCardStore.UpdateFields(ctx, cardID, map[string]any{
if err := s.updateCardAndAppendNetworkSeries(ctx, cardID, map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": now,
"stop_reason": "",
}); err != nil {
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString()); err != nil {
s.logger.Error("复机 Gateway 成功但 DB 更新失败",
zap.Uint("card_id", cardID),
zap.String("iccid", card.ICCID),
@@ -488,7 +514,7 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
"stop_reason": "",
},
})
return nil
return err
}
s.reschedulePolling(ctx, card.ID)
@@ -568,11 +594,11 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
zap.String("iccid", card.ICCID))
now := time.Now()
if updateErr := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{
if updateErr := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{
"network_status": constants.NetworkStatusOffline,
"stopped_at": now,
"stop_reason": stopReason,
}); updateErr != nil {
}, constants.CardObservationSceneBusinessStop, "offline", uuid.NewString()); updateErr != nil {
s.logger.Error("停机 Gateway 成功但 DB 更新失败",
zap.Uint("card_id", card.ID),
zap.String("iccid", card.ICCID),
@@ -596,6 +622,7 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
"stop_reason": stopReason,
},
})
return updateErr
}
s.reschedulePolling(ctx, card.ID)
@@ -763,12 +790,12 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card
}
now := time.Now()
if err := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{
if err := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": now,
"stop_reason": "",
"gateway_extend": "",
}); err != nil {
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString()); err != nil {
wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
@@ -1008,11 +1035,11 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
}
now := time.Now()
if err := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{
if err := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": now,
"stop_reason": "",
}); err != nil {
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString()); err != nil {
wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
@@ -1056,6 +1083,31 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
return nil
}
func (s *StopResumeService) updateCardAndAppendNetworkSeries(ctx context.Context, cardID uint, fields map[string]any, scene, expected, operationID string) error {
if s.db == nil || s.observationSeriesEvents == nil {
return errors.New(errors.CodeInternalError, "停复机观测 Outbox 能力未配置")
}
requestID := requestIDFromContext(ctx)
if requestID == "" {
requestID = operationID
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).Updates(fields).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡停复机状态失败")
}
if cardObservationApp.IsSeriesTriggerSuppressed(ctx) {
return nil
}
return s.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: "card-observation:network-command:" + operationID,
Scene: scene, ResourceType: constants.CardObservationResourceTypeCard, ResourceID: cardID,
SyncTypes: []string{constants.CardObservationSyncTypeNetwork}, ExpectedValue: expected,
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: time.Now().UTC(),
RequestID: requestID, CorrelationID: requestID,
})
})
}
// invalidatePollingCache 删除轮询卡缓存,下次轮询自动从 DB 重建
func (s *StopResumeService) invalidatePollingCache(ctx context.Context, cardID uint) {
if s.redis == nil {

View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/model"
@@ -66,6 +67,12 @@ type Service struct {
personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore
agentWalletDebit *walletapp.DebitService
agentWalletReservation *walletapp.ReservationService
observationSeriesEvents cardObservationApp.SeriesEventWriter
}
// SetObservationSeriesEventWriter 注入购包成功观测序列 Outbox Writer。
func (s *Service) SetObservationSeriesEventWriter(writer cardObservationApp.SeriesEventWriter) {
s.observationSeriesEvents = writer
}
// SetAgentWalletDebitService 注入代理主钱包统一扣款用例。
@@ -1940,8 +1947,9 @@ func (s *Service) tryResumeAfterPayment(ctx context.Context, order *model.Order)
return
}
resumeCtx := context.WithoutCancel(ctx)
go func(ct string, cid uint) {
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
if err := s.resumeCallback.ResumeCardIfStopped(resumeCtx, ct, cid); err != nil {
s.logger.Error("支付后自动复机失败",
zap.String("carrier_type", ct),
zap.Uint("carrier_id", cid),
@@ -1975,6 +1983,7 @@ func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model
}
now := time.Now()
createdUsage := false
for _, item := range items {
// 检查是否已存在使用记录
var existingUsage model.PackageUsage
@@ -2002,17 +2011,44 @@ func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model
if err := s.activateMainPackage(ctx, tx, order, &pkg, carrierType, carrierID, now); err != nil {
return err
}
createdUsage = true
} else if pkg.PackageType == "addon" {
// 加油包处理逻辑(任务 8.5-8.7
if err := s.activateAddonPackage(ctx, tx, order, &pkg, carrierType, carrierID, now); err != nil {
return err
}
createdUsage = true
}
}
if createdUsage {
if s.observationSeriesEvents == nil {
return errors.New(errors.CodeInternalError, "购包观测 Outbox Writer 未配置")
}
eventID := "card-observation:order:" + strconv.FormatUint(uint64(order.ID), 10)
requestID := requestIDFromContext(ctx)
if requestID == "" {
requestID = eventID
}
if err := s.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: eventID,
Scene: constants.CardObservationScenePackageChanged, ResourceType: carrierType, ResourceID: carrierID,
SyncTypes: []string{constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic, constants.CardObservationSyncTypeNetwork},
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: now,
RequestID: requestID, CorrelationID: requestID,
}); err != nil {
return err
}
}
return nil
}
func requestIDFromContext(ctx context.Context) string {
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
return *value
}
return ""
}
func (s *Service) lockPackageCarrier(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) error {
switch carrierType {
case constants.AssetWalletResourceTypeIotCard, "card":

View File

@@ -2,8 +2,10 @@ package packagepkg
import (
"context"
"strconv"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -27,6 +29,12 @@ type ActivationService struct {
packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
logger *zap.Logger
resumeCallback ResumeCallback // 复机回调,可选
observationSeriesEvents cardObservationApp.SeriesEventWriter
}
// SetObservationSeriesEventWriter 注入套餐激活成功观测序列 Outbox Writer。
func (s *ActivationService) SetObservationSeriesEventWriter(writer cardObservationApp.SeriesEventWriter) {
s.observationSeriesEvents = writer
}
func NewActivationService(
@@ -81,8 +89,9 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
now := time.Now()
activated := false
// 在事务中激活套餐
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, usage := range pendingUsages {
// 查询套餐信息
var pkg model.Package
@@ -144,6 +153,9 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
if err := tx.Model(usage).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "激活套餐失败")
}
if err := s.appendActivationObservation(ctx, tx, usage, carrierType, carrierID, now); err != nil {
return err
}
s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID)
@@ -153,20 +165,18 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
zap.Time("activated_at", activatedAt),
zap.Time("expires_at", expiresAt))
if s.resumeCallback != nil {
go func(ct string, cid uint) {
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
s.logger.Error("实名激活后自动复机失败",
zap.String("carrier_type", ct),
zap.Uint("carrier_id", cid),
zap.Error(err))
}
}(carrierType, carrierID)
}
activated = true
}
return nil
})
if err != nil {
return err
}
if activated {
s.resumeAfterActivation(ctx, carrierType, carrierID, "实名激活后自动复机失败")
}
return nil
}
// ActivateQueuedPackage 任务 9.4-9.7: 排队主套餐激活
@@ -187,7 +197,8 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy
}
defer s.redis.Del(ctx, lockKey)
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
activated := false
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 任务 9.5: 检测并标记过期的主套餐
now := time.Now()
var expiredMainUsages []*model.PackageUsage
@@ -221,13 +232,22 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy
}
// 任务 9.6: 激活下一个待生效主套餐
if err := s.activateNextMainPackage(ctx, tx, carrierType, carrierID, now); err != nil {
currentActivated, err := s.activateNextMainPackage(ctx, tx, carrierType, carrierID, now)
if err != nil {
return err
}
activated = activated || currentActivated
}
return nil
})
if err != nil {
return err
}
if activated {
s.resumeAfterActivation(ctx, carrierType, carrierID, "排队激活后自动复机失败")
}
return nil
}
// ActivateSpecificPackage 任务 4: 激活指定的套餐使用记录
@@ -285,7 +305,8 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package
}
// 事务内激活
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
activated := false
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 再次幂等检查(防止锁释放期间被其他操作激活)
var currentUsage model.PackageUsage
if err := tx.First(&currentUsage, packageUsageID).Error; err != nil {
@@ -323,21 +344,16 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package
if err := s.activatePendingUsage(ctx, tx, &currentUsage, &pkg, carrierType, carrierID, time.Now(), "指定套餐已激活"); err != nil {
return err
}
// 异步复机
if s.resumeCallback != nil {
go func(ct string, cid uint) {
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
s.logger.Error("激活后自动复机失败",
zap.String("carrier_type", ct),
zap.Uint("carrier_id", cid),
zap.Error(err))
}
}(carrierType, carrierID)
}
activated = true
return nil
})
if err != nil {
return err
}
if activated {
s.resumeAfterActivation(ctx, carrierType, carrierID, "激活后自动复机失败")
}
return nil
}
// ActivateNextPendingMainPackage 按购买顺序激活载体的下一个待生效主套餐。
@@ -409,6 +425,9 @@ func (s *ActivationService) ActivateNextPendingMainPackage(ctx context.Context,
if err != nil {
return false, err
}
if activated {
s.resumeAfterActivation(ctx, carrierType, carrierID, "队首激活后自动复机失败")
}
return activated, nil
}
@@ -451,22 +470,22 @@ func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, m
}
// activateNextMainPackage 任务 9.6: 激活下一个待生效主套餐
func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint, now time.Time) error {
func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint, now time.Time) (bool, error) {
// 查询下一个待生效主套餐
nextMain, err := s.getNextPendingMainPackage(ctx, tx, carrierType, carrierID)
if err == gorm.ErrRecordNotFound {
s.logger.Info("没有待生效的主套餐",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
return nil
return false, nil
}
if err != nil {
return err
return false, err
}
canActivate, err := s.canActivatePendingUsage(ctx, tx, nextMain, carrierType, carrierID)
if err != nil {
return err
return false, err
}
if !canActivate {
s.logger.Info("下一个待生效主套餐暂不满足激活条件",
@@ -474,16 +493,19 @@ func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gor
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.Bool("pending_realname_activation", nextMain.PendingRealnameActivation))
return nil
return false, nil
}
// 查询套餐信息
var pkg model.Package
if err := tx.First(&pkg, nextMain.PackageID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
}
return s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, now, "排队主套餐已激活")
if err := s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, now, "排队主套餐已激活"); err != nil {
return false, err
}
return true, nil
}
func (s *ActivationService) getNextPendingMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (*model.PackageUsage, error) {
@@ -589,6 +611,9 @@ func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.D
if err := tx.Model(usage).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "激活排队主套餐失败")
}
if err := s.appendActivationObservation(ctx, tx, usage, carrierType, carrierID, now); err != nil {
return err
}
s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID)
@@ -598,20 +623,45 @@ func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.D
zap.Time("activated_at", activatedAt),
zap.Time("expires_at", expiresAt))
if s.resumeCallback != nil {
go func(ct string, cid uint) {
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
s.logger.Error("排队激活后自动复机失败",
zap.String("carrier_type", ct),
zap.Uint("carrier_id", cid),
zap.Error(err))
}
}(carrierType, carrierID)
}
return nil
}
// resumeAfterActivation 在激活事务提交后异步尝试复机,避免外部副作用先于业务事实提交。
func (s *ActivationService) resumeAfterActivation(ctx context.Context, carrierType string, carrierID uint, errorMessage string) {
if s.resumeCallback == nil {
return
}
resumeCtx := context.WithoutCancel(ctx)
go func() {
if err := s.resumeCallback.ResumeCardIfStopped(resumeCtx, carrierType, carrierID); err != nil {
s.logger.Error(errorMessage,
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.Error(err))
}
}()
}
func (s *ActivationService) appendActivationObservation(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, carrierType string, carrierID uint, occurredAt time.Time) error {
if cardObservationApp.IsSeriesTriggerSuppressed(ctx) {
return nil
}
if s.observationSeriesEvents == nil {
return errors.New(errors.CodeInternalError, "套餐激活观测 Outbox Writer 未配置")
}
if usage == nil || usage.ID == 0 || carrierID == 0 {
return errors.New(errors.CodeInvalidParam, "套餐激活观测事件缺少载体")
}
eventID := "card-observation:package-usage:" + strconv.FormatUint(uint64(usage.ID), 10) + ":activated"
return s.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: eventID, Scene: constants.CardObservationScenePackageChanged,
ResourceType: carrierType, ResourceID: carrierID,
SyncTypes: []string{constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic, constants.CardObservationSyncTypeNetwork},
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: occurredAt,
RequestID: eventID, CorrelationID: eventID,
})
}
func (s *ActivationService) syncCarrierStatusActivated(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, carrierType string, carrierID uint) {
if usage == nil {
return

View File

@@ -247,6 +247,7 @@ func (s *ResetService) triggerResumeForPackages(ctx context.Context, packages []
return
}
resumeCtx := context.WithoutCancel(ctx)
for _, pkg := range packages {
var carrierType string
var carrierID uint
@@ -262,7 +263,7 @@ func (s *ResetService) triggerResumeForPackages(ctx context.Context, packages []
}
go func(ct string, cid uint) {
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
if err := s.resumeCallback.ResumeCardIfStopped(resumeCtx, ct, cid); err != nil {
s.logger.Warn("流量重置后自动复机失败",
zap.String("carrier_type", ct),
zap.Uint("carrier_id", cid),

View File

@@ -186,7 +186,7 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
}
if shouldSuspend {
s.triggerSuspensionAfterCommit(suspendCarrierType, suspendCarrierID)
s.triggerSuspensionAfterCommit(ctx, suspendCarrierType, suspendCarrierID)
}
return nil
@@ -405,7 +405,7 @@ func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.D
}
// triggerSuspensionAfterCommit 在事务提交后触发停机检查,避免回调读到未提交状态。
func (s *UsageService) triggerSuspensionAfterCommit(carrierType string, carrierID uint) {
func (s *UsageService) triggerSuspensionAfterCommit(ctx context.Context, carrierType string, carrierID uint) {
if s.stopResumeCallback == nil {
if carrierType == constants.AssetTypeDevice {
s.logger.Warn("停复机回调未注入,跳过设备绑定卡停机",
@@ -414,9 +414,9 @@ func (s *UsageService) triggerSuspensionAfterCommit(carrierType string, carrierI
return
}
stopCtx := context.WithoutCancel(ctx)
if carrierType == constants.AssetTypeIotCard {
go func() {
stopCtx := context.Background()
if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, carrierID); err != nil {
s.logger.Error("调用停机服务失败",
zap.Uint("card_id", carrierID),
@@ -430,7 +430,7 @@ func (s *UsageService) triggerSuspensionAfterCommit(carrierType string, carrierI
return
}
bindings, err := s.deviceSimBindingStore.ListByDeviceID(context.Background(), carrierID)
bindings, err := s.deviceSimBindingStore.ListByDeviceID(stopCtx, carrierID)
if err != nil {
s.logger.Error("查询设备绑定卡失败",
zap.Uint("device_id", carrierID),
@@ -446,7 +446,6 @@ func (s *UsageService) triggerSuspensionAfterCommit(carrierType string, carrierI
for _, b := range bindings {
cardID := b.IotCardID
go func(cID uint) {
stopCtx := context.Background()
if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, cID); err != nil {
s.logger.Error("调用停机服务失败",
zap.Uint("card_id", cID),

View File

@@ -16,6 +16,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Service 代理系列授权业务服务
@@ -30,6 +31,12 @@ type Service struct {
logger *zap.Logger
}
// initialGrantPackage 保存通过创建前校验的套餐及其请求价格。
type initialGrantPackage struct {
item dto.GrantPackageItem
packageModel *model.Package
}
// New 创建代理系列授权服务实例
func New(
db *gorm.DB,
@@ -53,6 +60,89 @@ func New(
}
}
// validateInitialGrantPackageItems 校验首次授权套餐的数量、价格和批内唯一性。
func validateInitialGrantPackageItems(items []dto.GrantPackageItem) ([]uint, error) {
if len(items) == 0 || len(items) > constants.ShopSeriesGrantMaxPackages {
return nil, errors.New(errors.CodeInvalidParam, "初始授权套餐数量必须为1到100项")
}
packageIDs := make([]uint, 0, len(items))
seen := make(map[uint]struct{}, len(items))
for _, item := range items {
if item.PackageID == 0 || item.CostPrice == nil || *item.CostPrice < 0 {
return nil, errors.New(errors.CodeInvalidParam, "套餐ID和成本价参数无效")
}
if _, exists := seen[item.PackageID]; exists {
return nil, errors.New(errors.CodeInvalidParam, "初始授权套餐ID不能重复")
}
seen[item.PackageID] = struct{}{}
packageIDs = append(packageIDs, item.PackageID)
}
return packageIDs, nil
}
// validateInitialGrantPackages 批量校验首次授权的套餐归属、授权链和成本价边界。
func (s *Service) validateInitialGrantPackages(ctx context.Context, tx *gorm.DB, req *dto.CreateShopSeriesGrantRequest, allocatorShopID uint) ([]initialGrantPackage, error) {
packageIDs, err := validateInitialGrantPackageItems(req.Packages)
if err != nil {
return nil, err
}
var packages []*model.Package
if err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id IN ?", packageIDs).
Find(&packages).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询初始授权套餐失败")
}
if len(packages) != len(packageIDs) {
return nil, errors.New(errors.CodeInvalidParam, "初始授权套餐不存在或已删除")
}
packageMap := make(map[uint]*model.Package, len(packages))
for _, pkg := range packages {
packageMap[pkg.ID] = pkg
}
parentCostMap := make(map[uint]int64, len(packageIDs))
if allocatorShopID > 0 {
var parentAllocations []*model.ShopPackageAllocation
if err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND package_id IN ? AND status = ?", allocatorShopID, packageIDs, constants.StatusEnabled).
Find(&parentAllocations).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级套餐授权失败")
}
if len(parentAllocations) != len(packageIDs) {
return nil, errors.New(errors.CodeForbidden, "无权限分配套餐")
}
for _, allocation := range parentAllocations {
parentCostMap[allocation.PackageID] = allocation.CostPrice
}
}
validated := make([]initialGrantPackage, 0, len(req.Packages))
for _, item := range req.Packages {
pkg := packageMap[item.PackageID]
if pkg.SeriesID != req.SeriesID {
return nil, errors.New(errors.CodeInvalidParam, "套餐不属于该系列,无法添加到此授权")
}
if pkg.IsGift {
return nil, errors.New(errors.CodeForbidden, "赠送套餐不能分配给代理")
}
minimumCost := pkg.CostPrice
if allocatorShopID > 0 {
minimumCost = parentCostMap[item.PackageID]
}
if *item.CostPrice < minimumCost {
return nil, errors.New(errors.CodeInvalidParam, "授权成本价不能低于当前上级成本价")
}
validated = append(validated, initialGrantPackage{item: item, packageModel: pkg})
}
return validated, nil
}
// getParentCeilingFixed 查询固定模式佣金天花板
// allocatorShopID=0 表示平台分配,天花板为 PackageSeries.commission_amount
// allocatorShopID>0 表示代理分配,天花板为分配者自身的 ShopSeriesAllocation.one_time_commission_amount
@@ -225,10 +315,13 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
}
config, _ := series.GetOneTimeCommissionConfig()
// 1.5 校验目标店铺是否存在
_, err = s.shopStore.GetByID(ctx, req.ShopID)
// 1.5 校验管理范围,避免通过错误差异探测其他店铺。
if err := middleware.CanManageShop(ctx, req.ShopID); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
targetShop, err := s.shopStore.GetByID(ctx, req.ShopID)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "目标店铺不存在")
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
// 2. 检查重复授权
@@ -244,12 +337,20 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
var allocatorShopID uint
if operatorType == constants.UserTypeAgent {
allocatorShopID = operatorShopID
_, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, allocatorShopID, req.SeriesID)
if err != nil {
if allocatorShopID == 0 || targetShop.ParentID == nil || *targetShop.ParentID != allocatorShopID {
return nil, errors.New(errors.CodeForbidden, "只能授权直属下级店铺")
}
var parentSeriesAllocation model.ShopSeriesAllocation
if err := s.db.WithContext(ctx).
Where("shop_id = ? AND series_id = ? AND status = ?", allocatorShopID, req.SeriesID, constants.StatusEnabled).
First(&parentSeriesAllocation).Error; err != nil {
return nil, errors.New(errors.CodeForbidden, "当前账号无此系列授权,无法向下分配")
}
}
// 平台/超管 allocatorShopID = 0
if operatorType != constants.UserTypeAgent && operatorType != constants.UserTypePlatform && operatorType != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden, "无权限创建系列授权")
}
// 4. 参数验证:仅启用一次性佣金的系列才需要配置佣金金额
allocation := &model.ShopSeriesAllocation{
@@ -322,59 +423,67 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
// 6. 事务中创建 ShopSeriesAllocation + N 条 ShopPackageAllocation
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var lockedTargetShop model.Shop
if lockErr := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&lockedTargetShop, req.ShopID).Error; lockErr != nil {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if allocatorShopID > 0 {
if lockedTargetShop.ParentID == nil || *lockedTargetShop.ParentID != allocatorShopID {
return errors.New(errors.CodeForbidden, "只能授权直属下级店铺")
}
var parentSeriesAllocation model.ShopSeriesAllocation
if lockErr := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND series_id = ? AND status = ?", allocatorShopID, req.SeriesID, constants.StatusEnabled).
First(&parentSeriesAllocation).Error; lockErr != nil {
return errors.New(errors.CodeForbidden, "当前账号无有效系列授权,无法向下分配")
}
}
validatedPackages, validateErr := s.validateInitialGrantPackages(ctx, tx, req, allocatorShopID)
if validateErr != nil {
return validateErr
}
txSeriesStore := postgres.NewShopSeriesAllocationStore(tx)
if createErr := txSeriesStore.Create(ctx, allocation); createErr != nil {
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建系列授权失败")
}
// 创建套餐分配
if len(req.Packages) > 0 {
txPkgStore := postgres.NewShopPackageAllocationStore(tx)
txHistoryStore := postgres.NewShopPackageAllocationPriceHistoryStore(tx)
for _, item := range req.Packages {
if item.Remove != nil && *item.Remove {
continue
}
// W1: 校验套餐归属于该系列,防止跨系列套餐混入
pkg, pkgErr := s.packageStore.GetByID(ctx, item.PackageID)
if pkgErr != nil || pkg.SeriesID != req.SeriesID {
return errors.New(errors.CodeInvalidParam, "套餐不属于该系列,无法添加到此授权")
}
if pkg.IsGift {
return errors.New(errors.CodeForbidden, "赠送套餐不能分配给代理")
}
// W2: 代理操作时,校验分配者已拥有此套餐授权,防止越权分配
if allocatorShopID > 0 {
_, authErr := s.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, allocatorShopID, item.PackageID)
if authErr != nil {
return errors.New(errors.CodeForbidden, "无权限分配该套餐")
}
}
pkgAlloc := &model.ShopPackageAllocation{
ShopID: req.ShopID,
PackageID: item.PackageID,
AllocatorShopID: allocatorShopID,
CostPrice: item.CostPrice,
RetailPrice: pkg.SuggestedRetailPrice,
RetailPriceConfigStatus: pkg.PriceConfigStatus,
SeriesAllocationID: &allocation.ID,
ExpiryBaseOverride: expiryBaseOverride,
Status: constants.StatusEnabled,
ShelfStatus: constants.ShelfStatusOn,
}
pkgAlloc.Creator = operatorID
pkgAlloc.Updater = operatorID
if err := txPkgStore.Create(ctx, pkgAlloc); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐分配失败")
}
_ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
AllocationID: pkgAlloc.ID,
OldCostPrice: 0,
NewCostPrice: item.CostPrice,
ChangeReason: "初始授权",
ChangedBy: operatorID,
EffectiveFrom: time.Now(),
})
// 所有套餐已在当前事务内完成批量校验,此处只写入同一业务单元。
txPkgStore := postgres.NewShopPackageAllocationStore(tx)
txHistoryStore := postgres.NewShopPackageAllocationPriceHistoryStore(tx)
for _, validated := range validatedPackages {
item := validated.item
pkg := validated.packageModel
pkgAlloc := &model.ShopPackageAllocation{
ShopID: req.ShopID,
PackageID: item.PackageID,
AllocatorShopID: allocatorShopID,
CostPrice: *item.CostPrice,
RetailPrice: pkg.SuggestedRetailPrice,
RetailPriceConfigStatus: pkg.PriceConfigStatus,
SeriesAllocationID: &allocation.ID,
ExpiryBaseOverride: expiryBaseOverride,
Status: constants.StatusEnabled,
ShelfStatus: constants.ShelfStatusOn,
}
pkgAlloc.Creator = operatorID
pkgAlloc.Updater = operatorID
if err := txPkgStore.Create(ctx, pkgAlloc); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐分配失败")
}
if err := txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
AllocationID: pkgAlloc.ID,
OldCostPrice: 0,
NewCostPrice: *item.CostPrice,
ChangeReason: "初始授权",
ChangedBy: operatorID,
EffectiveFrom: time.Now(),
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐价格历史失败")
}
}
@@ -639,45 +748,56 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
for _, item := range req.Packages {
if item.Remove != nil && *item.Remove {
// 软删除已有的 active 分配
// 软删除已有的有效分配
existing, findErr := txPkgStore.GetByShopAndPackageForSystem(ctx, allocation.ShopID, item.PackageID)
if findErr != nil {
// 找不到则静默忽略
// 已不存在时按幂等成功处理
continue
}
_ = txPkgStore.Delete(ctx, existing.ID)
if deleteErr := txPkgStore.Delete(ctx, existing.ID); deleteErr != nil {
return errors.Wrap(errors.CodeDatabaseError, deleteErr, "删除套餐分配失败")
}
continue
}
if item.CostPrice == nil {
return errors.New(errors.CodeInvalidParam, "新增或修改套餐授权时必须提交成本价")
}
costPrice := *item.CostPrice
// 新增或更新套餐分配
existing, findErr := txPkgStore.GetByShopAndPackageForSystem(ctx, allocation.ShopID, item.PackageID)
if findErr == nil {
// 已有记录:更新成本价并写历史
oldPrice := existing.CostPrice
if oldPrice != item.CostPrice {
if oldPrice != costPrice {
// cost_price 锁定检查:存在下级分配记录时禁止修改
var subCount int64
tx.Model(&model.ShopPackageAllocation{}).
if countErr := tx.Model(&model.ShopPackageAllocation{}).
Where("allocator_shop_id = ? AND package_id = ? AND deleted_at IS NULL", allocation.ShopID, item.PackageID).
Count(&subCount)
Count(&subCount).Error; countErr != nil {
return errors.Wrap(errors.CodeDatabaseError, countErr, "查询下级套餐分配失败")
}
if subCount > 0 {
return errors.New(errors.CodeForbidden, "存在下级分配记录,请先回收后再修改成本价")
}
}
existing.CostPrice = item.CostPrice
existing.CostPrice = costPrice
existing.Updater = operatorID
if updateErr := txPkgStore.Update(ctx, existing); updateErr != nil {
return errors.Wrap(errors.CodeDatabaseError, updateErr, "更新套餐分配失败")
}
if oldPrice != item.CostPrice {
_ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
if oldPrice != costPrice {
if historyErr := txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
AllocationID: existing.ID,
OldCostPrice: oldPrice,
NewCostPrice: item.CostPrice,
NewCostPrice: costPrice,
ChangeReason: "手动调价",
ChangedBy: operatorID,
EffectiveFrom: time.Now(),
})
}); historyErr != nil {
return errors.Wrap(errors.CodeDatabaseError, historyErr, "创建套餐价格历史失败")
}
}
} else {
pkg, pkgErr := s.packageStore.GetByID(ctx, item.PackageID)
@@ -697,7 +817,7 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
ShopID: allocation.ShopID,
PackageID: item.PackageID,
AllocatorShopID: allocation.AllocatorShopID,
CostPrice: item.CostPrice,
CostPrice: costPrice,
RetailPrice: pkg.SuggestedRetailPrice,
RetailPriceConfigStatus: pkg.PriceConfigStatus,
SeriesAllocationID: &allocation.ID,
@@ -710,14 +830,16 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
if createErr := txPkgStore.Create(ctx, pkgAlloc); createErr != nil {
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建套餐分配失败")
}
_ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
if historyErr := txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
AllocationID: pkgAlloc.ID,
OldCostPrice: 0,
NewCostPrice: item.CostPrice,
NewCostPrice: costPrice,
ChangeReason: "新增授权",
ChangedBy: operatorID,
EffectiveFrom: time.Now(),
})
}); historyErr != nil {
return errors.Wrap(errors.CodeDatabaseError, historyErr, "创建套餐价格历史失败")
}
}
}
return nil

View File

@@ -3,6 +3,7 @@ package task
import (
"context"
"errors"
"strconv"
"time"
"github.com/bytedance/sonic"
@@ -12,6 +13,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/model"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
@@ -41,6 +43,7 @@ type AutoPurchaseHandler struct {
redis *redis.Client
asynqClient *asynq.Client // 用于事务提交成功后触发佣金计算任务
logger *zap.Logger
observationSeriesEvents cardObservationApp.SeriesEventWriter
}
// NewAutoPurchaseHandler 创建充值后自动购包处理器
@@ -55,6 +58,7 @@ func NewAutoPurchaseHandler(
redisClient *redis.Client,
asynqClient *asynq.Client,
logger *zap.Logger,
observationSeriesEvents cardObservationApp.SeriesEventWriter,
) *AutoPurchaseHandler {
if orderStore == nil {
orderStore = postgres.NewOrderStore(db, redisClient)
@@ -89,6 +93,7 @@ func NewAutoPurchaseHandler(
redis: redisClient,
asynqClient: asynqClient,
logger: logger,
observationSeriesEvents: observationSeriesEvents,
}
}
@@ -248,6 +253,23 @@ func (h *AutoPurchaseHandler) ProcessTask(ctx context.Context, task *asynq.Task)
if err = h.activatePackages(ctx, tx, order, packages, now); err != nil {
return err
}
if h.observationSeriesEvents == nil {
return pkgerrors.New(pkgerrors.CodeInternalError, "自动购包观测 Outbox Writer 未配置")
}
resourceType, resourceID := orderObservationResource(order)
if resourceID == 0 {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "自动购包观测事件缺少载体")
}
requestID := "card-observation:auto-purchase:" + strconv.FormatUint(uint64(order.ID), 10)
if err = h.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: requestID, Scene: constants.CardObservationScenePackageChanged,
ResourceType: resourceType, ResourceID: resourceID,
SyncTypes: []string{constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic, constants.CardObservationSyncTypeNetwork},
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: now,
RequestID: requestID, CorrelationID: requestID,
}); err != nil {
return err
}
if err = tx.Model(&model.RechargeOrder{}).
Where("id = ?", rechargeOrder.ID).
@@ -289,6 +311,16 @@ func (h *AutoPurchaseHandler) ProcessTask(ctx context.Context, task *asynq.Task)
return nil
}
func orderObservationResource(order *model.Order) (string, uint) {
if order != nil && order.DeviceID != nil {
return constants.CardObservationResourceTypeDevice, *order.DeviceID
}
if order != nil && order.IotCardID != nil {
return constants.CardObservationResourceTypeCard, *order.IotCardID
}
return "", 0
}
// NewAutoPurchaseTask 创建充值后自动购包任务
func NewAutoPurchaseTask(rechargeOrderID uint) (*asynq.Task, error) {
payloadBytes, err := sonic.Marshal(AutoPurchasePayload{RechargeOrderID: rechargeOrderID})

View File

@@ -7,6 +7,7 @@ import (
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/cardtrafficlock"
"github.com/break/junhong_cmp_fiber/internal/polling"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -28,7 +29,6 @@ var acquireConcurrencyScript = redis.NewScript(`
return current
`)
const cardTrafficSyncLockTTL = 10 * time.Minute
const pollingFallbackOperationTimeout = 5 * time.Second
// pollingFallbackContext 创建轮询兜底操作使用的独立短超时上下文。
@@ -47,6 +47,7 @@ type PollingBase struct {
iotCardStore *postgres.IotCardStore
logger *zap.Logger
verboseLog bool
trafficLock *cardtrafficlock.Lock
}
// NewPollingBase 创建轮询共享基类
@@ -65,6 +66,7 @@ func NewPollingBase(
iotCardStore: iotCardStore,
logger: logger,
verboseLog: verboseLog,
trafficLock: cardtrafficlock.New(redisClient),
}
}
@@ -105,26 +107,16 @@ func (b *PollingBase) releaseConcurrency(_ context.Context, taskType string) {
}
// acquireCardTrafficSyncLock 获取卡流量同步锁,避免轮询与手动刷新重复统计同一上游读数。
func (b *PollingBase) acquireCardTrafficSyncLock(ctx context.Context, cardID uint) (bool, error) {
if b.redis == nil {
return true, nil
}
locked, err := b.redis.SetNX(ctx, constants.RedisCardTrafficSyncLockKey(cardID), "1", cardTrafficSyncLockTTL).Result()
if err != nil {
return false, err
}
return locked, nil
func (b *PollingBase) acquireCardTrafficSyncLock(ctx context.Context, cardID uint) (string, bool, error) {
return b.trafficLock.Acquire(ctx, cardID)
}
// releaseCardTrafficSyncLock 释放卡流量同步锁。
func (b *PollingBase) releaseCardTrafficSyncLock(_ context.Context, cardID uint) {
if b.redis == nil {
return
}
func (b *PollingBase) releaseCardTrafficSyncLock(_ context.Context, cardID uint, token string) {
ctx, cancel := pollingFallbackContext()
defer cancel()
if err := b.redis.Del(ctx, constants.RedisCardTrafficSyncLockKey(cardID)).Err(); err != nil {
if err := b.trafficLock.Release(ctx, cardID, token); err != nil {
b.logger.Warn("释放卡流量同步锁失败", zap.Uint("card_id", cardID), zap.Error(err))
}
}

View File

@@ -42,12 +42,12 @@ func (h *PollingCarddataHandler) Handle(ctx context.Context, task *asynq.Task) e
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata)
}
defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingCarddata)
locked, err := h.base.acquireCardTrafficSyncLock(ctx, cardID)
lockToken, locked, err := h.base.acquireCardTrafficSyncLock(ctx, cardID)
if err != nil || !locked {
h.base.logger.Warn("卡流量同步锁不可用,延迟重入队", zap.Uint("card_id", cardID), zap.Error(err))
return h.base.queueMgr.Requeue(ctx, cardID, constants.TaskTypePollingCarddata, time.Now().Add(5*time.Second))
}
defer h.base.releaseCardTrafficSyncLock(ctx, cardID)
defer h.base.releaseCardTrafficSyncLock(ctx, cardID, lockToken)
h.base.invalidateCardCache(ctx, cardID)
card, err := h.base.iotCardStore.GetByID(ctx, cardID)

View File

@@ -2,15 +2,20 @@ package task
import (
"context"
"strconv"
"time"
"github.com/hibiken/asynq"
"go.uber.org/zap"
"gorm.io/gorm"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
"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"
)
// PollingProtectHandler 保护期一致性检查任务处理器
@@ -18,15 +23,19 @@ import (
// 保护期结束:调 EvaluateAndAct 重新评估正常停复机条件
// 两种路径不可混淆:保护期内=强制修正;保护期结束=重新评估
type PollingProtectHandler struct {
base *PollingBase
gateway *gateway.Client
iotCardStore *postgres.IotCardStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
stopResumeSvc iot_card_svc.StopResumeServiceInterface
db *gorm.DB
observationSeriesEvents cardObservationApp.SeriesEventWriter
base *PollingBase
gateway *gateway.Client
iotCardStore *postgres.IotCardStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
stopResumeSvc iot_card_svc.StopResumeServiceInterface
}
// NewPollingProtectHandler 创建保护期一致性检查任务处理器
func NewPollingProtectHandler(
db *gorm.DB,
observationSeriesEvents cardObservationApp.SeriesEventWriter,
base *PollingBase,
gw *gateway.Client,
iotCardStore *postgres.IotCardStore,
@@ -34,11 +43,13 @@ func NewPollingProtectHandler(
stopResumeSvc iot_card_svc.StopResumeServiceInterface,
) *PollingProtectHandler {
return &PollingProtectHandler{
base: base,
gateway: gw,
iotCardStore: iotCardStore,
deviceSimBindingStore: deviceSimBindingStore,
stopResumeSvc: stopResumeSvc,
db: db,
observationSeriesEvents: observationSeriesEvents,
base: base,
gateway: gw,
iotCardStore: iotCardStore,
deviceSimBindingStore: deviceSimBindingStore,
stopResumeSvc: stopResumeSvc,
}
}
@@ -82,8 +93,10 @@ func (h *PollingProtectHandler) Handle(ctx context.Context, t *asynq.Task) error
}
deviceID := binding.DeviceID
stopProtect := h.base.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Val() > 0
startProtect := h.base.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Val() > 0
stopProtectGeneration := h.base.redis.Get(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Val()
startProtectGeneration := h.base.redis.Get(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Val()
stopProtect := stopProtectGeneration != ""
startProtect := startProtectGeneration != ""
actionTaken := "no_action"
@@ -101,15 +114,18 @@ func (h *PollingProtectHandler) Handle(ctx context.Context, t *asynq.Task) error
h.base.updateStats(ctx, constants.TaskTypePollingProtect, false, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
}
if updateErr := h.iotCardStore.UpdateFields(ctx, cardID, map[string]any{
if updateErr := h.updateCardAndAppendNetworkSeries(ctx, cardID, map[string]any{
"network_status": constants.NetworkStatusOffline,
"stopped_at": time.Now(),
"stop_reason": constants.StopReasonProtectPeriod,
}); updateErr != nil {
}, constants.CardObservationSceneBusinessStop, "offline", "stop", stopProtectGeneration); updateErr != nil {
h.base.logger.Warn("保护期停机 DB 更新失败", zap.Uint("card_id", cardID), zap.Error(updateErr))
h.base.invalidateCardCache(ctx, cardID)
h.base.updateStats(ctx, constants.TaskTypePollingProtect, false, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
if requeueErr := h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect); requeueErr != nil {
return errors.Wrap(errors.CodeInternalError, requeueErr, "保护期停机事务失败且重入队失败")
}
return updateErr
}
h.base.updateCardCache(ctx, cardID, map[string]any{"network_status": constants.NetworkStatusOffline})
actionTaken = "forced_stop"
@@ -127,15 +143,18 @@ func (h *PollingProtectHandler) Handle(ctx context.Context, t *asynq.Task) error
h.base.updateStats(ctx, constants.TaskTypePollingProtect, false, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
}
if updateErr := h.iotCardStore.UpdateFields(ctx, cardID, map[string]any{
if updateErr := h.updateCardAndAppendNetworkSeries(ctx, cardID, map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": time.Now(),
"stop_reason": "",
}); updateErr != nil {
}, constants.CardObservationSceneBusinessResume, "online", "start", startProtectGeneration); updateErr != nil {
h.base.logger.Warn("保护期复机 DB 更新失败", zap.Uint("card_id", cardID), zap.Error(updateErr))
h.base.invalidateCardCache(ctx, cardID)
h.base.updateStats(ctx, constants.TaskTypePollingProtect, false, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
if requeueErr := h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect); requeueErr != nil {
return errors.Wrap(errors.CodeInternalError, requeueErr, "保护期复机事务失败且重入队失败")
}
return updateErr
}
h.base.updateCardCache(ctx, cardID, map[string]any{"network_status": constants.NetworkStatusOnline})
actionTaken = "forced_resume"
@@ -170,3 +189,35 @@ func (h *PollingProtectHandler) Handle(ctx context.Context, t *asynq.Task) error
h.base.updateStats(ctx, constants.TaskTypePollingProtect, true, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
}
// updateCardAndAppendNetworkSeries 在同一事务中更新卡状态并写入网络观测请求。
func (h *PollingProtectHandler) updateCardAndAppendNetworkSeries(
ctx context.Context,
cardID uint,
fields map[string]any,
scene string,
expected string,
operation string,
protectGeneration string,
) error {
if h.db == nil || h.observationSeriesEvents == nil {
return errors.New(errors.CodeInternalError, "保护期停复机观测 Outbox 能力未配置")
}
if protectGeneration == "" {
return errors.New(errors.CodeInvalidParam, "保护期停复机事件缺少保护期标识")
}
requestID := "card-observation:polling-protect:" + operation + ":" +
strconv.FormatUint(uint64(cardID), 10) + ":" + protectGeneration
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).Updates(fields).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新保护期停复机卡状态失败")
}
return h.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: requestID, Scene: scene,
ResourceType: constants.CardObservationResourceTypeCard, ResourceID: cardID,
SyncTypes: []string{constants.CardObservationSyncTypeNetwork}, ExpectedValue: expected,
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: time.Now().UTC(),
RequestID: requestID, CorrelationID: requestID,
})
})
}