暂存一下,防止丢失

This commit is contained in:
2026-07-24 16:07:18 +08:00
parent 5d6e23f1a5
commit a18ed8bc8d
180 changed files with 13597 additions and 1986 deletions

View File

@@ -0,0 +1,2 @@
// Package cardobservation 提供卡状态观测的纯领域规则。
package cardobservation

View File

@@ -0,0 +1,90 @@
package cardobservation
import (
"strings"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// NetworkObservation 是领域层接收的标准 Gateway 网络观测。
type NetworkObservation struct {
CardID uint
GatewayStatus string
GatewayExtend string
GatewayIMEI string
Metadata ObservationMetadata
}
// CardNetworkSnapshot 是网络规则需要的最小卡快照。
type CardNetworkSnapshot struct {
CardID uint
NetworkStatus int
StopReason string
IsStandalone bool
EnablePolling bool
}
// NetworkDecision 描述一次网络观测可安全持久化的最终值。
type NetworkDecision struct {
StatusKnown bool
StatusChanged bool
BeforeStatus int
AfterStatus int
GatewayExtend string
GatewayIMEI string
UpdateIMEI bool
StopReason string
StopReasonChanged bool
StopPolling bool
}
// ApplyNetwork 应用 Gateway 状态映射、运营商停机原因和独立风险卡规则。
func ApplyNetwork(snapshot CardNetworkSnapshot, observation NetworkObservation) (NetworkDecision, error) {
if err := validateObservationMetadata(observation.CardID, observation.Metadata); err != nil {
return NetworkDecision{}, err
}
if snapshot.NetworkStatus != constants.NetworkStatusOffline && snapshot.NetworkStatus != constants.NetworkStatusOnline {
return NetworkDecision{}, errors.New(errors.CodeInvalidStatus, "卡网络状态无效")
}
status, known := MapGatewayNetworkStatus(observation.GatewayStatus, observation.GatewayExtend)
extend := strings.TrimSpace(observation.GatewayExtend)
imei := strings.TrimSpace(observation.GatewayIMEI)
decision := NetworkDecision{
StatusKnown: known, BeforeStatus: snapshot.NetworkStatus, AfterStatus: snapshot.NetworkStatus,
GatewayExtend: extend, GatewayIMEI: imei, UpdateIMEI: imei != "", StopReason: snapshot.StopReason,
StopPolling: snapshot.EnablePolling && ShouldStopPollingForRisk(snapshot.IsStandalone, extend),
}
if !known {
return decision, nil
}
decision.AfterStatus = status
decision.StatusChanged = status != snapshot.NetworkStatus
if decision.StatusChanged && status == constants.NetworkStatusOffline && snapshot.StopReason == "" &&
strings.TrimSpace(observation.GatewayStatus) == constants.GatewayCardStatusStopped {
decision.StopReason = constants.StopReasonCarrierStopped
decision.StopReasonChanged = true
}
return decision, nil
}
// MapGatewayNetworkStatus 将 Gateway 状态稳定映射为本地网络状态。
func MapGatewayNetworkStatus(cardStatus, extend string) (int, bool) {
status := strings.TrimSpace(cardStatus)
ext := strings.TrimSpace(extend)
switch status {
case constants.GatewayCardStatusNormal:
return constants.NetworkStatusOnline, true
case constants.GatewayCardStatusStopped, constants.GatewayCardStatusReady:
return constants.NetworkStatusOffline, true
}
if ext == constants.GatewayCardExtendPendingActivation {
return constants.NetworkStatusOffline, true
}
return constants.NetworkStatusOffline, false
}
// ShouldStopPollingForRisk 判断独立卡是否命中运营商风险终止状态。
func ShouldStopPollingForRisk(isStandalone bool, extend string) bool {
return isStandalone && (extend == constants.GatewayCardExtendRiskStop || extend == constants.GatewayCardExtendCancelled)
}

View File

@@ -0,0 +1,133 @@
package cardobservation
import (
"strings"
"time"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
const (
realnameReversalThreshold = 3
realnameReversalWindow = 10 * time.Minute
)
// ObservationMetadata 描述一次上游实名观测的可追踪元数据。
type ObservationMetadata struct {
ObservationID string
Source string
Scene string
ObservedAt time.Time
RequestID string
CorrelationID string
UpstreamSummary string
}
// RealnameObservation 是领域层接收的标准实名观测。
type RealnameObservation struct {
CardID uint
Verified bool
Metadata ObservationMetadata
}
// CardRealnameSnapshot 是应用层从持久化模型映射出的最小卡实名快照。
type CardRealnameSnapshot struct {
CardID uint
Status int
FirstRealnameAt *time.Time
ReversalCount int
ReversalStartedAt *time.Time
}
// RealnameDecision 描述应用本次应持久化的实名事实。
type RealnameDecision struct {
StatusChanged bool
FirstVerified bool
ReversalPending bool
ReversalCount int
ReversalStartedAt *time.Time
ReversalReset bool
BeforeStatus int
AfterStatus int
}
// ApplyRealname 根据观测来源应用首次实名和周期逆转规则。
func ApplyRealname(snapshot CardRealnameSnapshot, observation RealnameObservation) (RealnameDecision, error) {
if err := validateObservation(observation); err != nil {
return RealnameDecision{}, err
}
decision := RealnameDecision{
BeforeStatus: snapshot.Status, AfterStatus: snapshot.Status,
ReversalCount: snapshot.ReversalCount, ReversalStartedAt: snapshot.ReversalStartedAt,
}
if snapshot.Status != constants.RealNameStatusNotVerified && snapshot.Status != constants.RealNameStatusVerified {
return RealnameDecision{}, errors.New(errors.CodeInvalidStatus, "卡实名状态无效")
}
if observation.Verified {
decision.AfterStatus = constants.RealNameStatusVerified
decision.StatusChanged = snapshot.Status != decision.AfterStatus
decision.FirstVerified = decision.StatusChanged && snapshot.FirstRealnameAt == nil
decision.ReversalReset = snapshot.ReversalCount != 0 || snapshot.ReversalStartedAt != nil
decision.ReversalCount = 0
decision.ReversalStartedAt = nil
return decision, nil
}
if snapshot.Status == constants.RealNameStatusNotVerified {
decision.ReversalReset = snapshot.ReversalCount != 0 || snapshot.ReversalStartedAt != nil
decision.ReversalCount = 0
decision.ReversalStartedAt = nil
return decision, nil
}
if observation.Metadata.Source == constants.CardObservationSourceCarrierCallback {
// 解除实名回调只留痕,不把外部单次结果变成本地逆转事实。
return decision, nil
}
if observation.Metadata.Source == constants.CardObservationSourceManualOverride {
decision.AfterStatus = constants.RealNameStatusNotVerified
decision.StatusChanged = true
decision.ReversalReset = true
decision.ReversalCount = 0
decision.ReversalStartedAt = nil
return decision, nil
}
count := snapshot.ReversalCount
startedAt := snapshot.ReversalStartedAt
now := observation.Metadata.ObservedAt
if startedAt == nil || now.Before(*startedAt) || now.Sub(*startedAt) > realnameReversalWindow {
count = 0
startedAt = &now
}
count++
decision.ReversalCount = count
decision.ReversalStartedAt = startedAt
if count < realnameReversalThreshold {
decision.ReversalPending = true
return decision, nil
}
decision.AfterStatus = constants.RealNameStatusNotVerified
decision.StatusChanged = true
decision.ReversalReset = true
decision.ReversalCount = 0
decision.ReversalStartedAt = nil
return decision, nil
}
func validateObservation(observation RealnameObservation) error {
if observation.CardID == 0 || strings.TrimSpace(observation.Metadata.ObservationID) == "" ||
strings.TrimSpace(observation.Metadata.Source) == "" || strings.TrimSpace(observation.Metadata.Scene) == "" ||
observation.Metadata.ObservedAt.IsZero() {
return errors.New(errors.CodeInvalidParam, "实名观测关键字段缺失")
}
switch observation.Metadata.Source {
case constants.CardObservationSourcePolling, constants.CardObservationSourceManualSync,
constants.CardObservationSourceManualOverride, constants.CardObservationSourceCarrierCallback,
constants.CardObservationSourceBusinessEvent:
return nil
default:
return errors.New(errors.CodeInvalidParam, "实名观测来源无效")
}
}

View File

@@ -0,0 +1,92 @@
package cardobservation
import (
"math"
"time"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// TrafficObservation 是领域层接收的标准运营商流量读数。
type TrafficObservation struct {
CardID uint
GatewayReadingMB float64
ResetDay int
Metadata ObservationMetadata
}
// CardTrafficSnapshot 是流量规则需要的最小卡快照。
type CardTrafficSnapshot struct {
CardID uint
DataUsageMB int64
CurrentMonthUsageMB float64
CurrentMonthStartDate *time.Time
LastMonthTotalMB float64
LastGatewayReadingMB float64
}
// TrafficDecision 描述一次流量观测应持久化的最终值。
type TrafficDecision struct {
IncrementMB float64
ReadingAccepted bool
CrossMonth bool
DataUsageMB int64
CurrentMonthUsageMB float64
CurrentMonthStartDate time.Time
LastMonthTotalMB float64
LastGatewayReadingMB float64
}
// ApplyTraffic 应用运营商重置、跨月和异常下降保护规则。
func ApplyTraffic(snapshot CardTrafficSnapshot, observation TrafficObservation) (TrafficDecision, error) {
if err := validateObservationMetadata(observation.CardID, observation.Metadata); err != nil {
return TrafficDecision{}, err
}
if math.IsNaN(observation.GatewayReadingMB) || math.IsInf(observation.GatewayReadingMB, 0) || observation.GatewayReadingMB < 0 {
return TrafficDecision{}, errors.New(errors.CodeInvalidParam, "流量观测读数无效")
}
if observation.ResetDay < 1 || observation.ResetDay > 31 {
return TrafficDecision{}, errors.New(errors.CodeInvalidParam, "运营商流量重置日无效")
}
now := observation.Metadata.ObservedAt
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
decision := TrafficDecision{
ReadingAccepted: true, DataUsageMB: snapshot.DataUsageMB,
CurrentMonthUsageMB: snapshot.CurrentMonthUsageMB, CurrentMonthStartDate: monthStart,
LastMonthTotalMB: snapshot.LastMonthTotalMB, LastGatewayReadingMB: observation.GatewayReadingMB,
}
increment := observation.GatewayReadingMB - snapshot.LastGatewayReadingMB
if increment < 0 {
if isTrafficResetWindow(now, observation.ResetDay) {
increment = observation.GatewayReadingMB
} else {
increment = 0
decision.ReadingAccepted = false
decision.LastGatewayReadingMB = snapshot.LastGatewayReadingMB
}
}
decision.IncrementMB = increment
decision.CrossMonth = snapshot.CurrentMonthStartDate == nil || snapshot.CurrentMonthStartDate.Before(monthStart)
if decision.CrossMonth {
decision.LastMonthTotalMB = snapshot.CurrentMonthUsageMB
decision.CurrentMonthUsageMB = increment
} else if increment > 0 {
decision.CurrentMonthUsageMB += increment
}
if increment > 0 {
decision.DataUsageMB += int64(increment)
}
return decision, nil
}
func validateObservationMetadata(cardID uint, metadata ObservationMetadata) error {
return validateObservation(RealnameObservation{CardID: cardID, Verified: true, Metadata: metadata})
}
func isTrafficResetWindow(now time.Time, resetDay int) bool {
if now.Day() == resetDay {
return true
}
resetDate := time.Date(now.Year(), now.Month(), resetDay, 0, 0, 0, 0, now.Location())
return now.Day() == resetDate.AddDate(0, 0, -1).Day()
}