收口七月卡状态回调与系列授权兼容契约
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
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:
41
internal/application/cardobservation/business_event.go
Normal file
41
internal/application/cardobservation/business_event.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
37
internal/bootstrap/carrier_callback_config.go
Normal file
37
internal/bootstrap/carrier_callback_config.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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(可选,装配后与配置同事务写入)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 // 手动触发服务(可为nil,nil时跳过自动触发)
|
||||
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 而非 UserTypeSuperAdmin,SuperAdmin 不受日限制约束
|
||||
sysCtx := pkgMiddleware.SetUserContext(ctx, &pkgMiddleware.UserContextInfo{
|
||||
UserID: systemUserID,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
err := h.manualTriggerSvc.TriggerSingle(sysCtx, cardID, constants.TaskTypePollingRealname, systemUserID)
|
||||
if err != nil {
|
||||
h.logger.Warn("自动触发实名检查失败",
|
||||
zap.Uint("customer_id", customerID),
|
||||
zap.String("iccid", iccid),
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
h.logger.Info("自动触发实名检查成功",
|
||||
zap.Uint("customer_id", customerID),
|
||||
zap.String("iccid", iccid),
|
||||
zap.Uint("card_id", cardID))
|
||||
}
|
||||
|
||||
79
internal/handler/callback/carrier_switch.go
Normal file
79
internal/handler/callback/carrier_switch.go
Normal 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
|
||||
}
|
||||
171
internal/handler/callback/cmcc_realname.go
Normal file
171
internal/handler/callback/cmcc_realname.go
Normal 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)
|
||||
}
|
||||
243
internal/handler/callback/ctcc_realname.go
Normal file
243
internal/handler/callback/ctcc_realname.go
Normal 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)
|
||||
}
|
||||
168
internal/handler/callback/cucc_realname.go
Normal file
168
internal/handler/callback/cucc_realname.go
Normal 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)
|
||||
}
|
||||
159
internal/handler/callback/cucc_realname_removal.go
Normal file
159
internal/handler/callback/cucc_realname_removal.go
Normal 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)
|
||||
}
|
||||
221
internal/infrastructure/cardobservation/best_effort.go
Normal file
221
internal/infrastructure/cardobservation/best_effort.go
Normal 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)
|
||||
@@ -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, "卡流量事件类型或版本不受支持")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
119
internal/infrastructure/cardobservation/series_event.go
Normal file
119
internal/infrastructure/cardobservation/series_event.go
Normal 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)
|
||||
@@ -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(¤tBinding).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(¤t).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)
|
||||
|
||||
49
internal/infrastructure/cardtrafficlock/lock.go
Normal file
49
internal/infrastructure/cardtrafficlock/lock.go
Normal 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()
|
||||
}
|
||||
67
internal/infrastructure/carriercallback/cmcc_realname.go
Normal file
67
internal/infrastructure/carriercallback/cmcc_realname.go
Normal 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
|
||||
}
|
||||
107
internal/infrastructure/carriercallback/ctcc_realname.go
Normal file
107
internal/infrastructure/carriercallback/ctcc_realname.go
Normal 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
|
||||
}
|
||||
22
internal/infrastructure/carriercallback/cucc_realname.go
Normal file
22
internal/infrastructure/carriercallback/cucc_realname.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:"初始授权套餐列表(1~100项,套餐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:"套餐操作列表(1~100项,套餐ID不可重复)"`
|
||||
ExpiryBaseOverride *string `json:"expiry_base_override" nullable:"true" description:"新增分配的生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)"`
|
||||
ExpiryBaseOverrideSet bool `json:"-"`
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "刷新卡数据失败")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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(¤tUsage, packageUsageID).Error; err != nil {
|
||||
@@ -323,21 +344,16 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package
|
||||
if err := s.activatePendingUsage(ctx, tx, ¤tUsage, &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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user