暂存一下,防止丢失
This commit is contained in:
132
internal/domain/approval/instance.go
Normal file
132
internal/domain/approval/instance.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Package approval 提供渠道无关的通用审批领域事实。
|
||||
package approval
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// Instance 是只保存渠道无关事实的通用审批实例。
|
||||
type Instance struct {
|
||||
ID uint
|
||||
BusinessType string
|
||||
BusinessID uint
|
||||
SubmitterAccountID uint
|
||||
SubmitterSnapshot []byte
|
||||
Provider string
|
||||
ExternalRef string
|
||||
Status int
|
||||
RequestSnapshot []byte
|
||||
DecisionSnapshot []byte
|
||||
CorrelationID string
|
||||
Version int
|
||||
StatusChangedAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NewInstanceParams 是创建通用审批实例所需的稳定业务事实。
|
||||
type NewInstanceParams struct {
|
||||
BusinessType string
|
||||
BusinessID uint
|
||||
SubmitterAccountID uint
|
||||
SubmitterSnapshot []byte
|
||||
Provider string
|
||||
RequestSnapshot []byte
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// NewInstance 创建处于提交中的渠道无关审批实例。
|
||||
func NewInstance(params NewInstanceParams, now time.Time) (*Instance, error) {
|
||||
businessType := strings.TrimSpace(params.BusinessType)
|
||||
provider := strings.TrimSpace(params.Provider)
|
||||
correlationID := strings.TrimSpace(params.CorrelationID)
|
||||
if businessType == "" || params.BusinessID == 0 || params.SubmitterAccountID == 0 || provider == "" || correlationID == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "通用审批实例的业务引用、提交人、渠道和关联 ID 不能为空")
|
||||
}
|
||||
if !isJSONObject(params.SubmitterSnapshot) || !isJSONObject(params.RequestSnapshot) {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "通用审批实例快照必须是有效 JSON 对象")
|
||||
}
|
||||
if now.IsZero() {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "通用审批实例创建时间不能为空")
|
||||
}
|
||||
now = now.UTC()
|
||||
return &Instance{
|
||||
BusinessType: businessType, BusinessID: params.BusinessID,
|
||||
SubmitterAccountID: params.SubmitterAccountID, SubmitterSnapshot: cloneBytes(params.SubmitterSnapshot),
|
||||
Provider: provider, Status: constants.ApprovalStatusSubmitting,
|
||||
RequestSnapshot: cloneBytes(params.RequestSnapshot), CorrelationID: correlationID,
|
||||
Version: constants.ApprovalInitialVersion, StatusChangedAt: now, CreatedAt: now, UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StatusForDecision 将渠道 Adapter 输出的标准决策映射为通用审批终态。
|
||||
func StatusForDecision(decision string) (int, error) {
|
||||
switch decision {
|
||||
case constants.ApprovalDecisionApproved:
|
||||
return constants.ApprovalStatusApproved, nil
|
||||
case constants.ApprovalDecisionRejected:
|
||||
return constants.ApprovalStatusRejected, nil
|
||||
case constants.ApprovalDecisionCancelled:
|
||||
return constants.ApprovalStatusCancelled, nil
|
||||
case constants.ApprovalDecisionDeleted:
|
||||
return constants.ApprovalStatusDeleted, nil
|
||||
case constants.ApprovalDecisionRevokedAfterApproved:
|
||||
return constants.ApprovalStatusRevokedAfterApproved, nil
|
||||
default:
|
||||
return 0, errors.New(errors.CodeInvalidParam, "审批渠道返回了不受支持的标准决策")
|
||||
}
|
||||
}
|
||||
|
||||
// IsTerminalStatus 判断状态是否为可分发给业务消费者的标准终态。
|
||||
func IsTerminalStatus(status int) bool {
|
||||
return status == constants.ApprovalStatusApproved ||
|
||||
status == constants.ApprovalStatusRejected ||
|
||||
status == constants.ApprovalStatusCancelled ||
|
||||
status == constants.ApprovalStatusDeleted ||
|
||||
status == constants.ApprovalStatusRevokedAfterApproved
|
||||
}
|
||||
|
||||
// ApplyDecision 校验标准决策状态迁移并冻结首次到达该终态的决策快照。
|
||||
func (i *Instance) ApplyDecision(decision string, snapshot []byte, now time.Time) (bool, error) {
|
||||
if i == nil || now.IsZero() || !isJSONObject(snapshot) {
|
||||
return false, errors.New(errors.CodeInvalidParam, "审批决策实例、快照和决策时间不能为空")
|
||||
}
|
||||
targetStatus, err := StatusForDecision(decision)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if i.Status == targetStatus {
|
||||
return false, nil
|
||||
}
|
||||
if targetStatus == constants.ApprovalStatusRevokedAfterApproved {
|
||||
if i.Status != constants.ApprovalStatusApproved {
|
||||
return false, errors.New(errors.CodeInvalidStatus, "只有已通过审批可以进入通过后撤销状态")
|
||||
}
|
||||
} else if IsTerminalStatus(i.Status) {
|
||||
return false, errors.New(errors.CodeInvalidStatus, "审批已进入其他标准终态")
|
||||
}
|
||||
now = now.UTC()
|
||||
i.Status = targetStatus
|
||||
i.DecisionSnapshot = cloneBytes(snapshot)
|
||||
i.StatusChangedAt = now
|
||||
i.UpdatedAt = now
|
||||
i.Version++
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func isJSONObject(value []byte) bool {
|
||||
var object map[string]any
|
||||
return len(value) > 0 && sonic.Unmarshal(value, &object) == nil && object != nil
|
||||
}
|
||||
|
||||
func cloneBytes(value []byte) []byte {
|
||||
cloned := make([]byte, len(value))
|
||||
copy(cloned, value)
|
||||
return cloned
|
||||
}
|
||||
12
internal/domain/approval/repository.go
Normal file
12
internal/domain/approval/repository.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Repository 定义通用审批实例的写侧持久化接缝。
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, instance *Instance) error
|
||||
GetForUpdate(ctx context.Context, instanceID uint) (*Instance, error)
|
||||
SaveDecision(ctx context.Context, instance *Instance, expectedStatus int, expectedVersion int) (bool, error)
|
||||
}
|
||||
2
internal/domain/cardobservation/doc.go
Normal file
2
internal/domain/cardobservation/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package cardobservation 提供卡状态观测的纯领域规则。
|
||||
package cardobservation
|
||||
90
internal/domain/cardobservation/network.go
Normal file
90
internal/domain/cardobservation/network.go
Normal 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)
|
||||
}
|
||||
133
internal/domain/cardobservation/realname.go
Normal file
133
internal/domain/cardobservation/realname.go
Normal 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, "实名观测来源无效")
|
||||
}
|
||||
}
|
||||
92
internal/domain/cardobservation/traffic.go
Normal file
92
internal/domain/cardobservation/traffic.go
Normal 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()
|
||||
}
|
||||
2
internal/domain/wallet/doc.go
Normal file
2
internal/domain/wallet/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package wallet 定义代理主钱包的资金边界与信用额度不变量。
|
||||
package wallet
|
||||
250
internal/domain/wallet/wallet.go
Normal file
250
internal/domain/wallet/wallet.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
appErrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// AgentWallet 表示代理钱包聚合的资金状态。
|
||||
//
|
||||
// 当前聚合统一维护扣款、冻结、完成扣除、正向入账、退款回充与调额不变量。
|
||||
type AgentWallet struct {
|
||||
ID uint
|
||||
ShopID uint
|
||||
WalletType string
|
||||
Balance int64
|
||||
FrozenBalance int64
|
||||
CreditEnabled bool
|
||||
CreditLimit int64
|
||||
Status int
|
||||
Version int
|
||||
}
|
||||
|
||||
// Debit 从代理主钱包扣减指定金额,并保证扣款后总可用金额不为负数。
|
||||
func (w *AgentWallet) Debit(amount int64) error {
|
||||
if err := w.validateMainWalletMutation(amount); err != nil {
|
||||
return err
|
||||
}
|
||||
candidate := *w
|
||||
balance, ok := safeSub(candidate.Balance, amount)
|
||||
if !ok {
|
||||
return appErrors.New(appErrors.CodeInvalidParam, "扣减钱包余额时发生整数溢出")
|
||||
}
|
||||
candidate.Balance = balance
|
||||
if err := candidate.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
w.Balance = balance
|
||||
return nil
|
||||
}
|
||||
|
||||
// Credit 向代理主钱包增加账面余额,负余额会自然表现为欠款减少。
|
||||
func (w *AgentWallet) Credit(amount int64) error {
|
||||
if err := w.validateMainWalletMutation(amount); err != nil {
|
||||
return err
|
||||
}
|
||||
balance, ok := safeAdd(w.Balance, amount)
|
||||
if !ok {
|
||||
return appErrors.New(appErrors.CodeInvalidParam, "增加钱包余额时发生整数溢出")
|
||||
}
|
||||
candidate := *w
|
||||
candidate.Balance = balance
|
||||
if err := candidate.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
w.Balance = balance
|
||||
return nil
|
||||
}
|
||||
|
||||
// Freeze 预占代理主钱包资金,冻结金额会占用现金和信用但不直接形成欠款。
|
||||
func (w *AgentWallet) Freeze(amount int64) error {
|
||||
if err := w.validateMainWalletMutation(amount); err != nil {
|
||||
return err
|
||||
}
|
||||
candidate := *w
|
||||
frozen, ok := safeAdd(candidate.FrozenBalance, amount)
|
||||
if !ok {
|
||||
return appErrors.New(appErrors.CodeInvalidParam, "增加钱包冻结金额时发生整数溢出")
|
||||
}
|
||||
candidate.FrozenBalance = frozen
|
||||
if err := candidate.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
w.FrozenBalance = frozen
|
||||
return nil
|
||||
}
|
||||
|
||||
// Release 释放已经预占的代理主钱包资金。
|
||||
func (w *AgentWallet) Release(amount int64) error {
|
||||
if err := w.validateMainWalletMutation(amount); err != nil {
|
||||
return err
|
||||
}
|
||||
if w.FrozenBalance < amount {
|
||||
return appErrors.New(appErrors.CodeInsufficientBalance, "钱包冻结金额不足")
|
||||
}
|
||||
w.FrozenBalance -= amount
|
||||
return w.Validate()
|
||||
}
|
||||
|
||||
// CompleteReserved 完成冻结资金扣除,同时减少账面余额和冻结金额。
|
||||
func (w *AgentWallet) CompleteReserved(amount int64) error {
|
||||
if err := w.validateMainWalletMutation(amount); err != nil {
|
||||
return err
|
||||
}
|
||||
if w.FrozenBalance < amount {
|
||||
return appErrors.New(appErrors.CodeInsufficientBalance, "钱包冻结金额不足")
|
||||
}
|
||||
balance, ok := safeSub(w.Balance, amount)
|
||||
if !ok {
|
||||
return appErrors.New(appErrors.CodeInvalidParam, "完成冻结资金扣除时发生整数溢出")
|
||||
}
|
||||
candidate := *w
|
||||
candidate.Balance = balance
|
||||
candidate.FrozenBalance -= amount
|
||||
if err := candidate.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
w.Balance = candidate.Balance
|
||||
w.FrozenBalance = candidate.FrozenBalance
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w AgentWallet) validateMainWalletMutation(amount int64) error {
|
||||
if amount <= 0 {
|
||||
return appErrors.New(appErrors.CodeInvalidParam, "钱包变更金额必须大于零")
|
||||
}
|
||||
if w.WalletType != constants.AgentWalletTypeMain {
|
||||
return appErrors.New(appErrors.CodeInvalidParam, "仅代理主钱包支持该资金操作")
|
||||
}
|
||||
if w.Status != constants.AgentWalletStatusNormal {
|
||||
return appErrors.New(appErrors.CodeInvalidStatus, "当前钱包状态不允许资金操作")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangeCredit 调整主钱包实际信用额度并重新校验完整资金边界。
|
||||
func (w *AgentWallet) ChangeCredit(enabled bool, limit int64) error {
|
||||
candidate := *w
|
||||
candidate.CreditEnabled = enabled
|
||||
candidate.CreditLimit = limit
|
||||
if err := candidate.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
w.CreditEnabled = enabled
|
||||
w.CreditLimit = limit
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate 校验钱包类型、信用配置、版本与总可用金额不变量。
|
||||
func (w AgentWallet) Validate() error {
|
||||
if w.WalletType != constants.AgentWalletTypeMain && w.WalletType != constants.AgentWalletTypeCommission {
|
||||
return appErrors.New(appErrors.CodeInvalidParam, "代理钱包类型无效")
|
||||
}
|
||||
if w.Version < 0 {
|
||||
return appErrors.New(appErrors.CodeInvalidParam, "钱包版本不能为负数")
|
||||
}
|
||||
if w.FrozenBalance < 0 {
|
||||
return appErrors.New(appErrors.CodeInvalidParam, "冻结金额不能为负数")
|
||||
}
|
||||
if err := validateCredit(w.WalletType, w.CreditEnabled, w.CreditLimit); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.CashAvailableBalance(); err != nil {
|
||||
return err
|
||||
}
|
||||
available, err := w.AvailableBalance()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if available < 0 {
|
||||
return appErrors.New(appErrors.CodeInsufficientBalance, "钱包总可用金额不能为负数")
|
||||
}
|
||||
if _, err := w.DebtAmount(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EffectiveCredit 返回当前实际生效的信用额度。
|
||||
func (w AgentWallet) EffectiveCredit() int64 {
|
||||
if !w.CreditEnabled {
|
||||
return 0
|
||||
}
|
||||
return w.CreditLimit
|
||||
}
|
||||
|
||||
// CashAvailableBalance 返回现金可用金额,即账面余额减冻结金额。
|
||||
func (w AgentWallet) CashAvailableBalance() (int64, error) {
|
||||
available, ok := safeSub(w.Balance, w.FrozenBalance)
|
||||
if !ok {
|
||||
return 0, appErrors.New(appErrors.CodeInvalidParam, "计算现金可用金额时发生整数溢出")
|
||||
}
|
||||
return available, nil
|
||||
}
|
||||
|
||||
// AvailableBalance 返回总可用金额,即现金可用金额加有效信用额度。
|
||||
func (w AgentWallet) AvailableBalance() (int64, error) {
|
||||
cashAvailable, err := w.CashAvailableBalance()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
available, ok := safeAdd(cashAvailable, w.EffectiveCredit())
|
||||
if !ok {
|
||||
return 0, appErrors.New(appErrors.CodeInvalidParam, "计算钱包总可用金额时发生整数溢出")
|
||||
}
|
||||
return available, nil
|
||||
}
|
||||
|
||||
// IsInDebt 返回账面余额是否已经形成欠款。
|
||||
func (w AgentWallet) IsInDebt() bool {
|
||||
return w.Balance < 0
|
||||
}
|
||||
|
||||
// DebtAmount 返回欠款金额;冻结金额不直接计入欠款。
|
||||
func (w AgentWallet) DebtAmount() (int64, error) {
|
||||
if !w.IsInDebt() {
|
||||
return 0, nil
|
||||
}
|
||||
if w.Balance == math.MinInt64 {
|
||||
return 0, appErrors.New(appErrors.CodeInvalidParam, "计算钱包欠款金额时发生整数溢出")
|
||||
}
|
||||
return -w.Balance, nil
|
||||
}
|
||||
|
||||
func validateCredit(walletType string, enabled bool, limit int64) error {
|
||||
if limit < 0 {
|
||||
return appErrors.New(appErrors.CodeInvalidParam, "信用额度不能为负数")
|
||||
}
|
||||
if !enabled && limit != 0 {
|
||||
return appErrors.New(appErrors.CodeInvalidParam, "关闭信用时信用额度必须为零")
|
||||
}
|
||||
if enabled && limit == 0 {
|
||||
return appErrors.New(appErrors.CodeInvalidParam, "启用信用时信用额度必须大于零")
|
||||
}
|
||||
if walletType != constants.AgentWalletTypeMain && (enabled || limit != 0) {
|
||||
return appErrors.New(appErrors.CodeInvalidParam, "只有代理主钱包可以启用信用额度")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeAdd(left, right int64) (int64, bool) {
|
||||
if right > 0 && left > math.MaxInt64-right {
|
||||
return 0, false
|
||||
}
|
||||
if right < 0 && left < math.MinInt64-right {
|
||||
return 0, false
|
||||
}
|
||||
return left + right, true
|
||||
}
|
||||
|
||||
func safeSub(left, right int64) (int64, bool) {
|
||||
if right > 0 && left < math.MinInt64+right {
|
||||
return 0, false
|
||||
}
|
||||
if right < 0 && left > math.MaxInt64+right {
|
||||
return 0, false
|
||||
}
|
||||
return left - right, true
|
||||
}
|
||||
Reference in New Issue
Block a user