暂存一下,防止丢失

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,127 @@
package approval
import (
"context"
"strconv"
"strings"
"time"
"gorm.io/gorm"
approvaldomain "github.com/break/junhong_cmp_fiber/internal/domain/approval"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// CreationService 实现业务侧 Approval Port并保持渠道前置检查与业务事务分离。
type CreationService struct {
providers ProviderPort
repositories RepositoryProvider
eventWriter SubmissionEventWriter
now func() time.Time
}
// NewCreationService 创建通用审批申请创建用例。
func NewCreationService(
providers ProviderPort,
repositories RepositoryProvider,
eventWriter SubmissionEventWriter,
now func() time.Time,
) *CreationService {
if now == nil {
now = time.Now
}
return &CreationService{providers: providers, repositories: repositories, eventWriter: eventWriter, now: now}
}
// Prepare 在任何业务事实写入前确认 Adapter、场景和真实发起身份可用。
func (s *CreationService) Prepare(ctx context.Context, request PrepareRequest) (Preparation, error) {
if s == nil || s.providers == nil || s.repositories == nil || s.eventWriter == nil {
return Preparation{}, errors.New(errors.CodeServiceUnavailable, "审批能力尚未配置")
}
request.BusinessType = strings.TrimSpace(request.BusinessType)
request.CorrelationID = strings.TrimSpace(request.CorrelationID)
if request.BusinessType == "" || request.SubmitterAccountID == 0 || request.CorrelationID == "" {
return Preparation{}, errors.New(errors.CodeInvalidParam)
}
providerContext, err := s.providers.Prepare(ctx, request)
if err != nil {
return Preparation{}, err
}
providerContext.Provider = strings.TrimSpace(providerContext.Provider)
if providerContext.Provider == "" {
return Preparation{}, errors.New(errors.CodeServiceUnavailable, "审批渠道未返回有效 provider")
}
return Preparation{
provider: providerContext.Provider, businessType: request.BusinessType,
submitterAccountID: request.SubmitterAccountID, correlationID: request.CorrelationID,
expiresAt: s.now().UTC().Add(constants.ApprovalPreparationTTL), issuer: s,
providerContext: providerContext,
}, nil
}
// CreateInTx 使用调用方业务事务原子创建通用实例、渠道上下文和提交 Outbox。
func (s *CreationService) CreateInTx(ctx context.Context, tx *gorm.DB, request CreateRequest) (Reference, error) {
if s == nil || tx == nil || s.repositories == nil || s.providers == nil || s.eventWriter == nil {
return Reference{}, errors.New(errors.CodeInternalError, "通用审批创建用例未完整配置")
}
now := s.now().UTC()
if err := s.validatePreparation(request, now); err != nil {
return Reference{}, err
}
instance, err := approvaldomain.NewInstance(approvaldomain.NewInstanceParams{
BusinessType: request.BusinessType, BusinessID: request.BusinessID,
SubmitterAccountID: request.SubmitterAccountID, SubmitterSnapshot: request.SubmitterSnapshot,
Provider: request.Preparation.provider, RequestSnapshot: request.RequestSnapshot,
CorrelationID: request.CorrelationID,
}, now)
if err != nil {
return Reference{}, err
}
repository := s.repositories.ForDB(tx)
if repository == nil {
return Reference{}, errors.New(errors.CodeInternalError, "通用审批 Repository 未配置")
}
if err := repository.Create(ctx, instance); err != nil {
return Reference{}, err
}
if err := s.providers.CreateContextInTx(ctx, tx, request.Preparation.providerContext, instance.ID); err != nil {
return Reference{}, err
}
event := SubmissionRequestedEvent{
EventID: "approval:" + strconv.FormatUint(uint64(instance.ID), 10) + ":submission",
InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
SubmitterAccountID: instance.SubmitterAccountID, Provider: instance.Provider,
CorrelationID: instance.CorrelationID, OccurredAt: instance.CreatedAt,
}
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
return Reference{}, err
}
return Reference{InstanceID: instance.ID, Status: instance.Status}, nil
}
func (s *CreationService) validatePreparation(request CreateRequest, now time.Time) error {
preparation := request.Preparation
if preparation.issuer != s || preparation.expiresAt.IsZero() || !preparation.expiresAt.After(now) {
return errors.New(errors.CodeServiceUnavailable, "审批可用性检查已失效,请重新提交")
}
if request.BusinessType != preparation.businessType ||
request.SubmitterAccountID != preparation.submitterAccountID ||
request.CorrelationID != preparation.correlationID {
return errors.New(errors.CodeInvalidParam, "审批准备结果与业务申请不匹配")
}
return nil
}
// UnavailableProviderPort 在当前环境未装配有效审批 Adapter 时失败关闭。
type UnavailableProviderPort struct{}
// Prepare 拒绝在缺少有效 Adapter、场景或发起身份时创建业务审批。
func (UnavailableProviderPort) Prepare(_ context.Context, _ PrepareRequest) (ProviderPreparation, error) {
return ProviderPreparation{}, errors.New(errors.CodeServiceUnavailable, "当前环境没有可用审批渠道")
}
// CreateContextInTx 防止未配置渠道上下文时误写审批事实。
func (UnavailableProviderPort) CreateContextInTx(_ context.Context, _ *gorm.DB, _ ProviderPreparation, _ uint) error {
return errors.New(errors.CodeServiceUnavailable, "当前环境没有可用审批渠道")
}

View File

@@ -0,0 +1,85 @@
package approval
import (
"context"
"time"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// DecisionProcessingStore 管理标准决策交给业务消费者时的处理租约。
type DecisionProcessingStore interface {
Claim(ctx context.Context, eventID string, owner string, now time.Time, duration time.Duration) (bool, error)
MarkSucceeded(ctx context.Context, eventID string, owner string, now time.Time) (bool, error)
MarkFailed(ctx context.Context, eventID string, owner string, now time.Time, errorSummary string) (bool, error)
}
// BusinessDecisionHandler 消费渠道无关标准决策。
// 实现必须以审批实例 ID 和决策作为业务幂等键,并且不得依赖任何渠道 SDK、DTO 或状态码。
type BusinessDecisionHandler interface {
Handle(ctx context.Context, event TerminalDecisionEvent) error
}
// DecisionDispatcher 使用处理租约把标准决策交给对应业务消费者。
type DecisionDispatcher struct {
store DecisionProcessingStore
handlers map[string]BusinessDecisionHandler
owner string
logger *zap.Logger
now func() time.Time
}
// NewDecisionDispatcher 创建通用审批标准决策分发器。
func NewDecisionDispatcher(
store DecisionProcessingStore,
handlers map[string]BusinessDecisionHandler,
owner string,
logger *zap.Logger,
now func() time.Time,
) *DecisionDispatcher {
if logger == nil {
logger = zap.NewNop()
}
if now == nil {
now = time.Now
}
return &DecisionDispatcher{store: store, handlers: handlers, owner: owner, logger: logger, now: now}
}
// Consume 幂等消费一条标准决策;重复投递或其他有效租约正在处理时正常结束。
func (d *DecisionDispatcher) Consume(ctx context.Context, event TerminalDecisionEvent) error {
if d == nil || d.store == nil || d.owner == "" || event.EventID == "" || event.InstanceID == 0 || event.BusinessType == "" {
return errors.New(errors.CodeInternalError, "通用审批决策分发器未完整配置")
}
handler := d.handlers[event.BusinessType]
if handler == nil {
return errors.New(errors.CodeServiceUnavailable, "审批业务消费者尚未注册")
}
now := d.now().UTC()
claimed, err := d.store.Claim(ctx, event.EventID, d.owner, now, constants.ApprovalDecisionDeliveryLeaseDuration)
if err != nil {
return err
}
if !claimed {
return nil
}
if err := handler.Handle(ctx, event); err != nil {
if _, markErr := d.store.MarkFailed(ctx, event.EventID, d.owner, d.now().UTC(), "业务消费者处理失败"); markErr != nil {
d.logger.Error("审批标准决策失败状态保存失败",
zap.String("event_id", event.EventID), zap.Uint("approval_instance_id", event.InstanceID),
zap.String("business_type", event.BusinessType), zap.Error(markErr))
}
return err
}
marked, err := d.store.MarkSucceeded(ctx, event.EventID, d.owner, d.now().UTC())
if err != nil {
return err
}
if !marked {
return errors.New(errors.CodeConflict, "审批标准决策处理租约已失效")
}
return nil
}

View File

@@ -0,0 +1,81 @@
// Package approval 定义业务用例依赖的渠道无关审批接缝。
package approval
import (
"context"
"time"
"gorm.io/gorm"
)
// PrepareRequest 是业务写入前执行审批渠道可用性检查的请求。
type PrepareRequest struct {
BusinessType string
SubmitterAccountID uint
CorrelationID string
}
// Preparation 是渠道可用性检查返回的短期、不透明准备凭据。
type Preparation struct {
provider string
businessType string
submitterAccountID uint
correlationID string
expiresAt time.Time
issuer *CreationService
providerContext ProviderPreparation
}
// CreateRequest 是业务事务内创建通用审批实例的请求。
type CreateRequest struct {
Preparation Preparation
BusinessType string
BusinessID uint
SubmitterAccountID uint
SubmitterSnapshot []byte
RequestSnapshot []byte
CorrelationID string
}
// Reference 是业务表保存的通用审批实例引用。
type Reference struct {
InstanceID uint
Status int
}
// Port 是退款、线下充值等业务用例唯一依赖的审批创建接缝。
// Prepare 必须在业务事务前确认 Adapter、场景和发起身份可用CreateInTx 必须复核准备凭据并使用调用方事务写入审批事实。
type Port interface {
Prepare(ctx context.Context, request PrepareRequest) (Preparation, error)
CreateInTx(ctx context.Context, tx *gorm.DB, request CreateRequest) (Reference, error)
}
// ProviderPreparation 是渠道 Adapter 在事务前完成场景和发起身份检查后返回的内部准备结果。
// ChannelContext 只能包含后续写入渠道专属表所需的安全快照,不得包含密钥或访问令牌。
type ProviderPreparation struct {
Provider string
ChannelContext []byte
}
// ProviderPort 定义具体审批渠道对通用创建用例提供的防腐接缝。
type ProviderPort interface {
Prepare(ctx context.Context, request PrepareRequest) (ProviderPreparation, error)
CreateContextInTx(ctx context.Context, tx *gorm.DB, preparation ProviderPreparation, instanceID uint) error
}
// SubmissionRequestedEvent 是渠道提交 Worker 接收的通用申请事件。
type SubmissionRequestedEvent struct {
EventID string `json:"event_id"`
InstanceID uint `json:"instance_id"`
BusinessType string `json:"business_type"`
BusinessID uint `json:"business_id"`
SubmitterAccountID uint `json:"submitter_account_id"`
Provider string `json:"provider"`
CorrelationID string `json:"correlation_id"`
OccurredAt time.Time `json:"occurred_at"`
}
// SubmissionEventWriter 在调用方业务事务中追加渠道提交 Outbox。
type SubmissionEventWriter interface {
Append(ctx context.Context, tx *gorm.DB, event SubmissionRequestedEvent) error
}

View File

@@ -0,0 +1,145 @@
package approval
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
approvaldomain "github.com/break/junhong_cmp_fiber/internal/domain/approval"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RepositoryProvider 为当前 GORM 事务提供纯领域 Repository。
type RepositoryProvider interface {
ForDB(db *gorm.DB) approvaldomain.Repository
}
// TerminalDecisionEvent 是业务消费者接收的渠道无关标准决策事件。
type TerminalDecisionEvent struct {
EventID string `json:"event_id"`
InstanceID uint `json:"instance_id"`
BusinessType string `json:"business_type"`
BusinessID uint `json:"business_id"`
SubmitterAccountID uint `json:"submitter_account_id"`
Decision string `json:"decision"`
Source string `json:"source"`
CorrelationID string `json:"correlation_id"`
OccurredAt time.Time `json:"occurred_at"`
}
// TerminalEventWriter 在审批状态事务中追加标准决策 Outbox。
type TerminalEventWriter interface {
Append(ctx context.Context, tx *gorm.DB, event TerminalDecisionEvent) error
}
// DecisionDeliveryWriter 在审批状态事务中创建业务消费租约事实。
type DecisionDeliveryWriter interface {
Create(ctx context.Context, tx *gorm.DB, event TerminalDecisionEvent) error
}
// SyncDecisionCommand 是回调、兜底轮询和受控人工同步共用的标准决策命令。
type SyncDecisionCommand struct {
InstanceID uint
Decision string
DecisionSnapshot []byte
Source string
}
// SyncDecisionResult 返回本次是否首次记录该标准终态。
type SyncDecisionResult struct {
Status int
FirstTerminal bool
}
// SyncDecisionService 统一处理各审批渠道回传的标准决策。
type SyncDecisionService struct {
db *gorm.DB
repositories RepositoryProvider
eventWriter TerminalEventWriter
deliveryWriter DecisionDeliveryWriter
now func() time.Time
}
// NewSyncDecisionService 创建标准决策同步用例。
func NewSyncDecisionService(
db *gorm.DB,
repositories RepositoryProvider,
eventWriter TerminalEventWriter,
deliveryWriter DecisionDeliveryWriter,
now func() time.Time,
) *SyncDecisionService {
if now == nil {
now = time.Now
}
return &SyncDecisionService{
db: db, repositories: repositories, eventWriter: eventWriter, deliveryWriter: deliveryWriter, now: now,
}
}
// Execute 将回调或轮询取得的权威渠道状态原子转换为通用审批终态和可靠业务事件。
func (s *SyncDecisionService) Execute(ctx context.Context, command SyncDecisionCommand) (*SyncDecisionResult, error) {
if s == nil || s.db == nil || s.repositories == nil || s.eventWriter == nil || s.deliveryWriter == nil {
return nil, errors.New(errors.CodeInternalError, "通用审批决策同步用例未完整配置")
}
if command.InstanceID == 0 || !isSupportedSyncSource(command.Source) {
return nil, errors.New(errors.CodeInvalidParam)
}
result := &SyncDecisionResult{}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
repository := s.repositories.ForDB(tx)
if repository == nil {
return errors.New(errors.CodeInternalError, "通用审批 Repository 未配置")
}
instance, err := repository.GetForUpdate(ctx, command.InstanceID)
if err != nil {
return err
}
expectedStatus, expectedVersion := instance.Status, instance.Version
changed, err := instance.ApplyDecision(command.Decision, command.DecisionSnapshot, s.now().UTC())
if err != nil {
return err
}
result.Status = instance.Status
if !changed {
return nil
}
saved, err := repository.SaveDecision(ctx, instance, expectedStatus, expectedVersion)
if err != nil {
return err
}
if !saved {
return errors.New(errors.CodeConflict, "审批状态已被其他同步任务更新")
}
event := TerminalDecisionEvent{
EventID: terminalDecisionEventID(instance.ID, command.Decision),
InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
SubmitterAccountID: instance.SubmitterAccountID, Decision: command.Decision, Source: command.Source,
CorrelationID: instance.CorrelationID, OccurredAt: instance.StatusChangedAt,
}
if err := s.deliveryWriter.Create(ctx, tx, event); err != nil {
return err
}
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
return err
}
result.FirstTerminal = true
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func terminalDecisionEventID(instanceID uint, decision string) string {
return "approval:" + strconv.FormatUint(uint64(instanceID), 10) + ":" + decision
}
func isSupportedSyncSource(source string) bool {
return source == constants.ApprovalSyncSourceCallback ||
source == constants.ApprovalSyncSourcePolling ||
source == constants.ApprovalSyncSourceManual
}

View File

@@ -0,0 +1,120 @@
// Package cardobservation 提供卡实名观测的复杂写用例。
package cardobservation
import (
"context"
"strconv"
"time"
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// RealnameChangedEvent 是卡实名状态变化的可靠领域事实。
type RealnameChangedEvent struct {
EventID string `json:"event_id"`
CardID uint `json:"card_id"`
BeforeStatus int `json:"before_status"`
AfterStatus int `json:"after_status"`
FirstVerified bool `json:"first_verified"`
ObservedAt time.Time `json:"observed_at"`
Source string `json:"source"`
Scene string `json:"scene"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// EventWriter 在卡状态事务中追加领域 Outbox 事件。
type EventWriter interface {
AppendRealname(ctx context.Context, tx *gorm.DB, event RealnameChangedEvent) error
AppendTraffic(ctx context.Context, tx *gorm.DB, event TrafficIncrementedEvent) error
AppendNetwork(ctx context.Context, tx *gorm.DB, event NetworkChangedEvent) error
}
// CacheInvalidator 在业务事务提交后失效卡缓存。
type CacheInvalidator interface {
Invalidate(ctx context.Context, cardID uint)
}
// Service 负责卡实名观测的锁定、规则应用和可靠事件写入。
type Service struct {
db *gorm.DB
eventWriter EventWriter
cache CacheInvalidator
}
// NewService 创建卡实名观测应用服务。
func NewService(db *gorm.DB, eventWriter EventWriter, cache CacheInvalidator) *Service {
return &Service{db: db, eventWriter: eventWriter, cache: cache}
}
// ApplyCardObservation 在同一事务中应用实名状态、逆转窗口和状态变更事件。
func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.RealnameObservation) (domain.RealnameDecision, error) {
if s == nil || s.db == nil || s.eventWriter == nil {
return domain.RealnameDecision{}, errors.New(errors.CodeInternalError, "卡实名观测能力未完整配置")
}
var decision domain.RealnameDecision
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "IoT卡不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡失败")
}
nextDecision, decisionErr := domain.ApplyRealname(domain.CardRealnameSnapshot{
CardID: card.ID, Status: card.RealNameStatus, FirstRealnameAt: card.FirstRealnameAt,
ReversalCount: card.RealnameReversalCount, ReversalStartedAt: card.RealnameReversalStartedAt,
}, observation)
if decisionErr != nil {
return decisionErr
}
decision = nextDecision
updates := map[string]any{
"last_real_name_check_at": observation.Metadata.ObservedAt,
"realname_reversal_count": decision.ReversalCount,
"realname_reversal_started_at": decision.ReversalStartedAt,
}
if observation.Metadata.Source != constants.CardObservationSourceManualOverride {
updates["last_sync_time"] = observation.Metadata.ObservedAt
}
if decision.StatusChanged {
updates["real_name_status"] = decision.AfterStatus
updates["activation_status"] = gorm.Expr(`CASE WHEN network_status = ? AND (card_category = ? OR ? = ?) THEN 1 ELSE 0 END`, constants.NetworkStatusOnline, constants.CardCategoryIndustry, decision.AfterStatus, constants.RealNameStatusVerified)
updates["activated_at"] = gorm.Expr(`CASE WHEN activated_at IS NULL AND (network_status = ? AND (card_category = ? OR ? = ?)) THEN ? ELSE activated_at END`, constants.NetworkStatusOnline, constants.CardCategoryIndustry, decision.AfterStatus, constants.RealNameStatusVerified, observation.Metadata.ObservedAt)
}
if decision.FirstVerified {
updates["first_realname_at"] = observation.Metadata.ObservedAt
}
result := tx.Model(&model.IotCard{}).Where("id = ? AND real_name_status = ?", card.ID, card.RealNameStatus).Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡实名事实失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "卡实名状态已被其他请求更新")
}
if decision.StatusChanged {
eventID := "card-realname:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":changed"
if err := s.eventWriter.AppendRealname(ctx, tx, RealnameChangedEvent{
EventID: eventID, CardID: card.ID, BeforeStatus: card.RealNameStatus, AfterStatus: decision.AfterStatus,
FirstVerified: decision.FirstVerified, ObservedAt: observation.Metadata.ObservedAt,
Source: observation.Metadata.Source, Scene: observation.Metadata.Scene,
RequestID: observation.Metadata.RequestID, CorrelationID: observation.Metadata.CorrelationID,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入卡实名 Outbox 事件失败")
}
}
return nil
})
if err != nil {
return domain.RealnameDecision{}, err
}
if s.cache != nil {
s.cache.Invalidate(ctx, observation.CardID)
}
return decision, nil
}

View File

@@ -0,0 +1,98 @@
package cardobservation
import (
"context"
"strconv"
"time"
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// NetworkChangedEvent 是卡网络状态变化的可靠领域事实。
type NetworkChangedEvent struct {
EventID string `json:"event_id"`
CardID uint `json:"card_id"`
BeforeStatus int `json:"before_status"`
AfterStatus int `json:"after_status"`
GatewayExtend string `json:"gateway_extend"`
ObservedAt time.Time `json:"observed_at"`
Source string `json:"source"`
Scene string `json:"scene"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// ApplyNetworkObservation 串行应用 Gateway 网络状态、扩展原因、IMEI 和风险轮询规则。
func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domain.NetworkObservation) (domain.NetworkDecision, error) {
if s == nil || s.db == nil || s.eventWriter == nil {
return domain.NetworkDecision{}, errors.New(errors.CodeInternalError, "卡网络观测能力未完整配置")
}
var decision domain.NetworkDecision
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "IoT卡不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡网络事实失败")
}
nextDecision, decisionErr := domain.ApplyNetwork(domain.CardNetworkSnapshot{
CardID: card.ID, NetworkStatus: card.NetworkStatus, StopReason: card.StopReason,
IsStandalone: card.IsStandalone, EnablePolling: card.EnablePolling,
}, observation)
if decisionErr != nil {
return decisionErr
}
decision = nextDecision
updates := map[string]any{
"last_card_status_check_at": observation.Metadata.ObservedAt,
"last_sync_time": observation.Metadata.ObservedAt,
"gateway_extend": decision.GatewayExtend,
}
if decision.UpdateIMEI {
updates["gateway_card_imei"] = decision.GatewayIMEI
}
if decision.StatusChanged {
updates["network_status"] = decision.AfterStatus
}
if decision.StopReasonChanged {
updates["stop_reason"] = decision.StopReason
}
if decision.StopPolling {
updates["enable_polling"] = false
}
result := tx.Model(&model.IotCard{}).
Where("id = ? AND network_status = ? AND enable_polling = ?", card.ID, card.NetworkStatus, card.EnablePolling).
Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡网络事实失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "卡网络事实已被其他请求更新")
}
if !decision.StatusChanged {
return nil
}
eventID := "card-network:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":changed"
if err := s.eventWriter.AppendNetwork(ctx, tx, NetworkChangedEvent{
EventID: eventID, CardID: card.ID, BeforeStatus: card.NetworkStatus, AfterStatus: decision.AfterStatus,
GatewayExtend: decision.GatewayExtend, ObservedAt: observation.Metadata.ObservedAt,
Source: observation.Metadata.Source, Scene: observation.Metadata.Scene,
RequestID: observation.Metadata.RequestID, CorrelationID: observation.Metadata.CorrelationID,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入卡网络 Outbox 事件失败")
}
return nil
})
if err != nil {
return domain.NetworkDecision{}, err
}
if s.cache != nil {
s.cache.Invalidate(ctx, observation.CardID)
}
return decision, nil
}

View File

@@ -0,0 +1,241 @@
package cardobservation
import (
"context"
"strings"
"time"
"github.com/google/uuid"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SeriesRequest 描述一次业务成功边界产生的观测序列请求。
type SeriesRequest struct {
Scene string `json:"scene"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
SyncType string `json:"sync_type"`
ExpectedValue string `json:"expected_value,omitempty"`
Source string `json:"source"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// SeriesTaskPayload 是固定三次 Asynq 任务的结构化载荷。
type SeriesTaskPayload struct {
SeriesID string `json:"series_id"`
Attempt int `json:"attempt"`
ScheduledAt time.Time `json:"scheduled_at"`
Scene string `json:"scene"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
SyncType string `json:"sync_type"`
ExpectedValue string `json:"expected_value,omitempty"`
Source string `json:"source"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// RunResult 描述一次实际 Gateway 请求及公共观测应用结果。
type RunResult struct {
StateChanged bool
RateLimited bool
}
// SeriesCoordinator 管理活跃序列、尝试幂等和实际请求互斥。
type SeriesCoordinator interface {
Reserve(ctx context.Context, request SeriesRequest, candidateSeriesID string, candidateBaseTime time.Time) (seriesID string, baseTime time.Time, merged bool, err error)
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)
FinishAttempt(ctx context.Context, payload SeriesTaskPayload) error
CompleteSeries(ctx context.Context, payload SeriesTaskPayload) error
CompleteResourceSeries(ctx context.Context, resourceType, resourceID, syncType string) error
}
// SeriesScheduler 提交固定的零重试观测任务。
type SeriesScheduler interface {
Enqueue(ctx context.Context, payload SeriesTaskPayload) error
}
// SeriesAttemptLogger 记录未访问 Gateway 的合并、互斥、限频和提前完成结果。
type SeriesAttemptLogger interface {
Record(ctx context.Context, payload SeriesTaskPayload, result, reason string) error
RecordMerged(ctx context.Context, request SeriesRequest, seriesID string) error
}
// SeriesRunner 查询本地快照并执行一次 Gateway 公共观测。
type SeriesRunner interface {
Provider(ctx context.Context, payload SeriesTaskPayload) (string, error)
ExpectationMet(ctx context.Context, payload SeriesTaskPayload) (bool, error)
Run(ctx context.Context, payload SeriesTaskPayload) (RunResult, error)
}
// SeriesTrigger 创建或合并固定的立即、3 分钟、5 分钟任务序列。
type SeriesTrigger struct {
coordinator SeriesCoordinator
scheduler SeriesScheduler
logger SeriesAttemptLogger
now func() time.Time
}
// NewSeriesTrigger 创建观测序列触发器。
func NewSeriesTrigger(coordinator SeriesCoordinator, scheduler SeriesScheduler, logger SeriesAttemptLogger) *SeriesTrigger {
return &SeriesTrigger{coordinator: coordinator, scheduler: scheduler, logger: logger, now: time.Now}
}
// Trigger 创建新序列;同场景未结束序列只留合并记录,不延长原序列。
func (s *SeriesTrigger) Trigger(ctx context.Context, request SeriesRequest) (string, bool, error) {
if s == nil || s.coordinator == nil || s.scheduler == nil || s.logger == nil {
return "", false, errors.New(errors.CodeInternalError, "卡观测序列触发能力未完整配置")
}
if err := validateSeriesRequest(request); err != nil {
return "", false, err
}
candidateBaseTime := s.now().UTC()
seriesID, baseTime, merged, err := s.coordinator.Reserve(ctx, request, uuid.NewString(), candidateBaseTime)
if err != nil {
return "", false, err
}
// 重复触发仍以稳定任务 ID 补齐首次入队的局部失败;已存在任务由 Asynq 去重,不会延长原序列。
for attempt := 1; attempt <= constants.CardObservationSeriesAttemptCount; attempt++ {
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,
}
if err := s.scheduler.Enqueue(ctx, payload); err != nil {
return seriesID, false, err
}
}
if merged {
if err := s.logger.RecordMerged(ctx, request, seriesID); err != nil {
return "", true, err
}
}
return seriesID, merged, nil
}
// SeriesAttemptService 执行单次事件观测,不改变后续阶梯任务。
type SeriesAttemptService struct {
coordinator SeriesCoordinator
runner SeriesRunner
logger SeriesAttemptLogger
}
// NewSeriesAttemptService 创建序列尝试服务。
func NewSeriesAttemptService(coordinator SeriesCoordinator, runner SeriesRunner, logger SeriesAttemptLogger) *SeriesAttemptService {
return &SeriesAttemptService{coordinator: coordinator, runner: runner, logger: logger}
}
// Execute 执行一次幂等尝试;失败只结束当前任务。
func (s *SeriesAttemptService) Execute(ctx context.Context, payload SeriesTaskPayload) error {
if s == nil || s.coordinator == nil || s.runner == nil || s.logger == nil {
return errors.New(errors.CodeInternalError, "卡观测序列执行能力未完整配置")
}
if err := validateSeriesPayload(payload); err != nil {
return err
}
claimed, err := s.coordinator.ClaimAttempt(ctx, payload.SeriesID, payload.Attempt)
if err != nil || !claimed {
return err
}
defer func() { _ = s.coordinator.FinishAttempt(context.Background(), payload) }()
completed, err := s.coordinator.IsCompleted(ctx, payload.SeriesID)
if err != nil {
return err
}
if completed {
return s.completeRemainingAttempts(ctx, payload, "序列已提前完成")
}
met, err := s.runner.ExpectationMet(ctx, payload)
if err != nil {
return err
}
if met {
return s.completeRemainingAttempts(ctx, payload, "本地快照已达到预期")
}
provider, err := s.runner.Provider(ctx, payload)
if err != nil {
return err
}
release, wait, acquired, err := s.coordinator.AcquireRequest(ctx, payload, provider)
if err != nil {
return err
}
if !acquired {
return s.logger.Record(ctx, payload, constants.IntegrationResultIgnored, "实际 Gateway 请求正在执行")
}
defer release()
if wait > 0 {
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case <-ctx.Done():
return s.logger.Record(ctx, payload, constants.IntegrationResultRateLimited, "最小请求间隔等待被取消")
case <-timer.C:
}
}
result, err := s.runner.Run(ctx, payload)
if result.RateLimited {
return nil
}
if err != nil {
return err
}
if payload.Attempt == constants.CardObservationSeriesAttemptCount {
return s.coordinator.CompleteSeries(ctx, payload)
}
return nil
}
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++ {
remaining := payload
remaining.Attempt = attempt
remaining.ScheduledAt = baseTime.Add(constants.CardObservationAttemptDelay(attempt))
if err := s.logger.Record(ctx, remaining, constants.IntegrationResultCompleted, reason); err != nil {
return err
}
}
return s.coordinator.CompleteSeries(ctx, payload)
}
// CompleteResourceSeries 供可信回调在公共观测成功后提前完成同资源序列。
func (s *SeriesAttemptService) CompleteResourceSeries(ctx context.Context, resourceType, resourceID, syncType string) error {
if s == nil || s.coordinator == nil {
return errors.New(errors.CodeInternalError, "卡观测序列协调器未配置")
}
return s.coordinator.CompleteResourceSeries(ctx, resourceType, resourceID, syncType)
}
func validateSeriesRequest(request SeriesRequest) error {
if strings.TrimSpace(request.Scene) == "" || strings.TrimSpace(request.ResourceType) == "" ||
strings.TrimSpace(request.ResourceID) == "" || !validSyncType(request.SyncType) {
return errors.New(errors.CodeInvalidParam, "卡观测序列参数不完整")
}
return nil
}
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})
}
func validSyncType(syncType string) bool {
switch syncType {
case constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic,
constants.CardObservationSyncTypeNetwork, constants.CardObservationSyncTypeDeviceInfo:
return true
default:
return false
}
}

View File

@@ -0,0 +1,86 @@
package cardobservation
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// TrafficIncrementedEvent 是卡流量正增量的可靠领域事实。
type TrafficIncrementedEvent struct {
EventID string `json:"event_id"`
CardID uint `json:"card_id"`
IncrementMB float64 `json:"increment_mb"`
ObservedAt time.Time `json:"observed_at"`
Source string `json:"source"`
Scene string `json:"scene"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// ApplyTrafficObservation 串行应用流量读数,并在正增量时同事务写 Outbox。
func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domain.TrafficObservation) (domain.TrafficDecision, error) {
if s == nil || s.db == nil || s.eventWriter == nil {
return domain.TrafficDecision{}, errors.New(errors.CodeInternalError, "卡流量观测能力未完整配置")
}
var decision domain.TrafficDecision
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "IoT卡不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡流量事实失败")
}
nextDecision, decisionErr := domain.ApplyTraffic(domain.CardTrafficSnapshot{
CardID: card.ID, DataUsageMB: card.DataUsageMB, CurrentMonthUsageMB: card.CurrentMonthUsageMB,
CurrentMonthStartDate: card.CurrentMonthStartDate, LastMonthTotalMB: card.LastMonthTotalMB,
LastGatewayReadingMB: card.LastGatewayReadingMB,
}, observation)
if decisionErr != nil {
return decisionErr
}
decision = nextDecision
updates := map[string]any{
"last_data_check_at": observation.Metadata.ObservedAt, "last_sync_time": observation.Metadata.ObservedAt,
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
"current_month_usage_mb": decision.CurrentMonthUsageMB, "data_usage_mb": decision.DataUsageMB,
}
if decision.ReadingAccepted {
updates["last_gateway_reading_mb"] = decision.LastGatewayReadingMB
}
result := tx.Model(&model.IotCard{}).
Where("id = ? AND last_gateway_reading_mb = ?", card.ID, card.LastGatewayReadingMB).
Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡流量事实失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "卡流量基线已被其他请求更新")
}
if decision.IncrementMB <= 0 {
return nil
}
eventID := "card-traffic:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":incremented"
return s.eventWriter.AppendTraffic(ctx, tx, TrafficIncrementedEvent{
EventID: eventID, CardID: card.ID, IncrementMB: decision.IncrementMB,
ObservedAt: observation.Metadata.ObservedAt, Source: observation.Metadata.Source,
Scene: observation.Metadata.Scene, RequestID: observation.Metadata.RequestID,
CorrelationID: observation.Metadata.CorrelationID,
})
})
if err != nil {
return domain.TrafficDecision{}, err
}
if s.cache != nil {
s.cache.Invalidate(ctx, observation.CardID)
}
return decision, nil
}

View File

@@ -0,0 +1,252 @@
// Package notification 提供站内通知简单写用例与 Outbox 消费边界。
package notification
import (
"context"
"strings"
"time"
"github.com/bytedance/sonic"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
notificationinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// AdminDirectPayload 是明确后台账号通知的结构化 Outbox 载荷。
type AdminDirectPayload struct {
RecipientID uint `json:"recipient_id"`
NotificationType string `json:"notification_type"`
TemplateData map[string]string `json:"template_data"`
RefType string `json:"ref_type,omitempty"`
RefID string `json:"ref_id,omitempty"`
RefKey string `json:"ref_key,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// PersonalCustomerDirectPayload 是明确个人客户通知的结构化 Outbox 载荷。
type PersonalCustomerDirectPayload = AdminDirectPayload
// AdminDynamicPayload 是按账号、平台角色或店铺动态解析后台接收人的结构化 Outbox 载荷。
type AdminDynamicPayload struct {
TargetKind string `json:"target_kind"`
TargetID uint `json:"target_id"`
NotificationType string `json:"notification_type"`
TemplateData map[string]string `json:"template_data"`
RefType string `json:"ref_type,omitempty"`
RefID string `json:"ref_id,omitempty"`
RefKey string `json:"ref_key,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
type deliveryRequest struct {
notificationType string
templateData map[string]string
refType string
refID string
refKey string
expiresAt *time.Time
}
// DeliveryService 校验接收人并幂等生成站内通知。
type DeliveryService struct {
repository *notificationinfra.Repository
registry *notificationinfra.Registry
resolver DynamicRecipientResolver
logger *zap.Logger
now func() time.Time
}
// NewDeliveryService 创建站内通知投递用例。
func NewDeliveryService(repository *notificationinfra.Repository, registry *notificationinfra.Registry, resolver DynamicRecipientResolver, logger *zap.Logger) *DeliveryService {
if logger == nil {
logger = zap.NewNop()
}
return &DeliveryService{repository: repository, registry: registry, resolver: resolver, logger: logger, now: time.Now}
}
// Consume 消费明确或动态接收人通知事件;所有可恢复错误交给 Asynq 重试策略处理。
func (s *DeliveryService) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if envelope.PayloadVersion != constants.NotificationPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "通知事件类型或载荷版本不受支持")
}
if envelope.EventType == constants.OutboxEventTypeAdminDynamicNotification {
return s.consumeDynamic(ctx, envelope)
}
return s.consumeDirect(ctx, envelope)
}
func (s *DeliveryService) consumeDirect(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
recipientKind, err := recipientKindForDirectEvent(envelope.EventType)
if err != nil {
return err
}
var payload AdminDirectPayload
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "通知事件载荷格式错误")
}
if payload.RecipientID == 0 || payload.NotificationType == "" {
return errors.New(errors.CodeInvalidParam, "通知事件载荷不完整")
}
request := deliveryRequest{
notificationType: payload.NotificationType, templateData: payload.TemplateData,
refType: payload.RefType, refID: payload.RefID, refKey: payload.RefKey, expiresAt: payload.ExpiresAt,
}
if err := validateDeliveryRequest(request); err != nil {
return err
}
return s.deliver(ctx, envelope.EventID, recipientKind, []uint{payload.RecipientID}, request)
}
func (s *DeliveryService) consumeDynamic(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
var payload AdminDynamicPayload
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "通知事件载荷格式错误")
}
if payload.TargetKind == "" || payload.TargetID == 0 || payload.NotificationType == "" {
return errors.New(errors.CodeInvalidParam, "通知事件载荷不完整")
}
if s.resolver == nil {
return errors.New(errors.CodeInternalError, "通知动态接收人解析器未配置")
}
request := deliveryRequest{
notificationType: payload.NotificationType, templateData: payload.TemplateData,
refType: payload.RefType, refID: payload.RefID, refKey: payload.RefKey, expiresAt: payload.ExpiresAt,
}
if err := validateDeliveryRequest(request); err != nil {
return err
}
recipientIDs, err := s.resolver.Resolve(ctx, payload.TargetKind, payload.TargetID)
if err != nil {
s.logger.Error("站内通知接收人解析失败",
zap.String("event_id", envelope.EventID), zap.String("notification_type", payload.NotificationType),
zap.String("target_kind", payload.TargetKind), zap.Uint("target_id", payload.TargetID),
zap.String("failure_category", "recipient_resolution"))
return err
}
if len(recipientIDs) == 0 {
s.logger.Info("站内通知暂无可用接收人,已正常结束",
zap.String("event_id", envelope.EventID), zap.String("target_kind", payload.TargetKind), zap.Uint("target_id", payload.TargetID),
zap.String("resolution", "no_recipient"))
return nil
}
return s.deliver(ctx, envelope.EventID, constants.NotificationRecipientKindAccount, recipientIDs, request)
}
func validateDeliveryRequest(request deliveryRequest) error {
if strings.Contains(request.refID, "://") || strings.Contains(request.refKey, "://") {
return errors.New(errors.CodeInvalidParam, "通知资源引用禁止包含任意 URL")
}
if request.refType == "" && (request.refID != "" || request.refKey != "") {
return errors.New(errors.CodeInvalidParam, "通知资源引用缺少受控类型")
}
if request.refType != "" && request.refID == "" && request.refKey == "" {
return errors.New(errors.CodeInvalidParam, "通知资源引用缺少定位值")
}
return nil
}
func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind string, recipientIDs []uint, request deliveryRequest) error {
rendered, err := s.registry.Render(request.notificationType, request.templateData, request.refType, recipientKind)
if err != nil {
s.logger.Error("站内通知模板校验失败",
zap.String("event_id", eventID), zap.String("notification_type", request.notificationType),
zap.String("failure_category", "template"))
return errors.Wrap(errors.CodeInvalidParam, err, "站内通知模板校验失败")
}
now := s.now().UTC()
expiresAt, err := notificationDisplayExpiry(rendered.Category, request.expiresAt, now)
if err != nil {
s.logger.Error("站内通知展示期限校验失败",
zap.String("event_id", eventID), zap.String("notification_type", request.notificationType),
zap.String("failure_category", "display_policy"))
return err
}
for _, recipientID := range recipientIDs {
active, err := s.isActiveRecipient(ctx, recipientKind, recipientID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验通知接收人失败")
}
if !active {
s.logger.Info("站内通知接收人不可用,已跳过",
zap.String("event_id", eventID), zap.String("recipient_kind", recipientKind), zap.Uint("recipient_id", recipientID))
continue
}
notification := &model.Notification{
EventID: eventID, RecipientKind: recipientKind,
RecipientID: recipientID, Category: rendered.Category, Type: rendered.Type,
Severity: rendered.Severity, Title: rendered.Title, Body: rendered.Body,
RefType: request.refType, RefID: request.refID, RefKey: request.refKey,
ExpiresAt: expiresAt, CreatedAt: now,
}
created, err := s.repository.CreateIdempotent(ctx, notification)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入站内通知失败")
}
if !created {
s.logger.Info("站内通知重复事件已幂等忽略",
zap.String("event_id", eventID), zap.String("recipient_kind", recipientKind), zap.Uint("recipient_id", recipientID))
}
}
return nil
}
func notificationDisplayExpiry(category string, requested *time.Time, now time.Time) (*time.Time, error) {
switch category {
case constants.NotificationCategoryApproval:
return nil, nil
case constants.NotificationCategoryExpiry:
if requested == nil {
return nil, errors.New(errors.CodeInvalidParam, "临期通知缺少业务到期时间")
}
expiresAt := requested.UTC()
return &expiresAt, nil
case constants.NotificationCategorySync:
return cappedNotificationExpiry(requested, now, constants.NotificationSyncDisplayDays), nil
case constants.NotificationCategorySystem:
return cappedNotificationExpiry(requested, now, constants.NotificationSystemMaxDisplayDays), nil
default:
return nil, errors.New(errors.CodeInvalidParam, "通知类别不支持展示期限策略")
}
}
func cappedNotificationExpiry(requested *time.Time, now time.Time, maxDays int) *time.Time {
maximum := now.AddDate(0, 0, maxDays)
if requested == nil {
if maxDays == constants.NotificationSystemMaxDisplayDays {
defaultExpiry := now.AddDate(0, 0, constants.NotificationSystemDefaultDisplayDays)
return &defaultExpiry
}
return &maximum
}
expiresAt := requested.UTC()
if expiresAt.After(maximum) {
expiresAt = maximum
}
return &expiresAt
}
func recipientKindForDirectEvent(eventType string) (string, error) {
switch eventType {
case constants.OutboxEventTypeAdminDirectNotification:
return constants.NotificationRecipientKindAccount, nil
case constants.OutboxEventTypePersonalCustomerDirectNotification:
return constants.NotificationRecipientKindPersonalCustomer, nil
default:
return "", errors.New(errors.CodeInvalidParam, "通知事件类型或载荷版本不受支持")
}
}
func (s *DeliveryService) isActiveRecipient(ctx context.Context, recipientKind string, recipientID uint) (bool, error) {
switch recipientKind {
case constants.NotificationRecipientKindAccount:
return s.repository.IsActiveAccount(ctx, recipientID)
case constants.NotificationRecipientKindPersonalCustomer:
return s.repository.IsActivePersonalCustomer(ctx, recipientID)
default:
return false, nil
}
}

View File

@@ -0,0 +1,110 @@
package notification
import (
"context"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ReadService 执行后台账号与个人客户的幂等已读事务脚本。
type ReadService struct {
db *gorm.DB
now func() time.Time
}
// NewReadService 创建单条已读用例。
func NewReadService(db *gorm.DB) *ReadService {
return &ReadService{db: db, now: time.Now}
}
// MarkRead 仅首次更新当前接收人的未过期未读通知。
func (s *ReadService) MarkRead(ctx context.Context, recipientID, notificationID uint) error {
if recipientID == 0 || notificationID == 0 {
return errors.New(errors.CodeInvalidParam)
}
now := s.now().UTC()
result := s.db.WithContext(ctx).Model(&model.Notification{}).
Where("id = ? AND recipient_kind = ? AND recipient_id = ? AND is_read = ? AND (expires_at IS NULL OR expires_at > ?)",
notificationID, constants.NotificationRecipientKindAccount, recipientID, false, now).
Updates(map[string]any{"is_read": true, "read_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新通知已读状态失败")
}
return nil
}
// MarkAllRead 将当前后台账号全部或指定类别的未过期通知幂等标记为已读。
func (s *ReadService) MarkAllRead(ctx context.Context, recipientID uint, request dto.NotificationReadAllRequest) (*dto.NotificationReadAllResponse, error) {
if recipientID == 0 || !isReadAllCategory(request.Category) {
return nil, errors.New(errors.CodeInvalidParam)
}
now := s.now().UTC()
db := s.db.WithContext(ctx).Model(&model.Notification{}).
Where("recipient_kind = ? AND recipient_id = ? AND is_read = ? AND (expires_at IS NULL OR expires_at > ?)",
constants.NotificationRecipientKindAccount, recipientID, false, now)
if request.Category != "" {
db = db.Where("category = ?", request.Category)
}
result := db.Updates(map[string]any{"is_read": true, "read_at": now})
if result.Error != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新通知已读状态失败")
}
return &dto.NotificationReadAllResponse{UpdatedCount: result.RowsAffected}, nil
}
func isReadAllCategory(category string) bool {
switch category {
case "", constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry,
constants.NotificationCategorySync, constants.NotificationCategorySystem:
return true
default:
return false
}
}
// MarkPersonalRead 仅首次更新当前个人客户可见的未过期未读通知。
func (s *ReadService) MarkPersonalRead(ctx context.Context, customerID, notificationID uint) error {
if customerID == 0 || notificationID == 0 {
return errors.New(errors.CodeInvalidParam)
}
now := s.now().UTC()
result := personalReadScope(s.db.WithContext(ctx).Model(&model.Notification{}), customerID, now).
Where("id = ? AND is_read = ?", notificationID, false).
Updates(map[string]any{"is_read": true, "read_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新个人客户通知已读状态失败")
}
return nil
}
// MarkAllPersonalRead 将当前个人客户可见的全部未过期通知幂等标记为已读。
func (s *ReadService) MarkAllPersonalRead(ctx context.Context, customerID uint) (*dto.NotificationReadAllResponse, error) {
if customerID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
now := s.now().UTC()
result := personalReadScope(s.db.WithContext(ctx).Model(&model.Notification{}), customerID, now).
Where("is_read = ?", false).
Updates(map[string]any{"is_read": true, "read_at": now})
if result.Error != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新个人客户通知已读状态失败")
}
return &dto.NotificationReadAllResponse{UpdatedCount: result.RowsAffected}, nil
}
func personalReadScope(db *gorm.DB, customerID uint, now time.Time) *gorm.DB {
return db.Where(`recipient_kind = ? AND recipient_id = ?
AND category IN ? AND type IN ? AND (expires_at IS NULL OR expires_at > ?)`,
constants.NotificationRecipientKindPersonalCustomer,
customerID,
[]string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry},
[]string{constants.NotificationTypePackageExpiring},
now,
)
}

View File

@@ -0,0 +1,8 @@
package notification
import "context"
// DynamicRecipientResolver 定义后台通知动态接收人解析 Port。
type DynamicRecipientResolver interface {
Resolve(ctx context.Context, targetKind string, targetID uint) ([]uint, error)
}

View File

@@ -0,0 +1,108 @@
// Package role 提供角色默认信用模板的应用用例。
package role
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"gorm.io/gorm"
)
// PermissionChecker 检查平台账号是否拥有独立信用模板权限。
type PermissionChecker interface {
CheckPermission(ctx context.Context, userID uint, permCode string, platform string) (bool, error)
}
// DefaultCreditService 更新客户角色的新建代理默认信用模板。
type DefaultCreditService struct {
db *gorm.DB
permissionChecker PermissionChecker
}
// NewDefaultCreditService 创建角色默认信用模板服务。
func NewDefaultCreditService(db *gorm.DB, permissionChecker PermissionChecker) *DefaultCreditService {
return &DefaultCreditService{db: db, permissionChecker: permissionChecker}
}
// Update 更新模板;该操作不扫描或修改任何既有钱包。
func (s *DefaultCreditService) Update(ctx context.Context, roleID uint, enabled bool, limit int64) (*model.Role, error) {
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
if err := s.authorize(ctx, operatorID); err != nil {
return nil, err
}
if err := validateDefaultCredit(enabled, limit); err != nil {
return nil, err
}
var role model.Role
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses().First(&role, roleID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeRoleNotFound)
}
return errors.Wrap(errors.CodeInternalError, err, "读取角色失败")
}
if role.RoleType != constants.RoleTypeCustomer {
return errors.New(errors.CodeInvalidParam, "只有客户角色可以配置新建代理默认信用")
}
result := tx.Model(&model.Role{}).
Where("id = ? AND role_type = ?", roleID, constants.RoleTypeCustomer).
Updates(map[string]any{
"default_credit_enabled": enabled,
"default_credit_limit": limit,
"updater": operatorID,
})
if result.Error != nil {
return errors.Wrap(errors.CodeInternalError, result.Error, "更新角色默认信用失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "角色默认信用已发生变化,请刷新后重试")
}
role.DefaultCreditEnabled = enabled
role.DefaultCreditLimit = limit
role.Updater = operatorID
return nil
})
if err != nil {
return nil, err
}
return &role, nil
}
func (s *DefaultCreditService) authorize(ctx context.Context, operatorID uint) error {
userType := middleware.GetUserTypeFromContext(ctx)
if userType == constants.UserTypeSuperAdmin {
return nil
}
if userType != constants.UserTypePlatform || s.permissionChecker == nil {
return errors.New(errors.CodeForbidden, "无权限配置角色默认信用")
}
hasPermission, err := s.permissionChecker.CheckPermission(ctx, operatorID, constants.PermissionRoleDefaultCreditManage, constants.PlatformWeb)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "检查角色默认信用权限失败")
}
if !hasPermission {
return errors.New(errors.CodeForbidden, "无权限配置角色默认信用")
}
return nil
}
func validateDefaultCredit(enabled bool, limit int64) error {
if limit < 0 {
return errors.New(errors.CodeInvalidParam, "默认信用额度不能为负数")
}
if enabled && limit == 0 {
return errors.New(errors.CodeInvalidParam, "启用默认信用时额度必须大于零")
}
if !enabled && limit != 0 {
return errors.New(errors.CodeInvalidParam, "关闭默认信用时额度必须为零")
}
return nil
}

View File

@@ -0,0 +1,251 @@
// Package shop 提供店铺创建与业务员归属的简单写事务脚本。
package shop
import (
"context"
"strings"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// CreateService 收口平台与代理创建店铺的完整事务。
type CreateService struct {
db *gorm.DB
}
// NewCreateService 创建店铺创建事务脚本。
func NewCreateService(db *gorm.DB) *CreateService {
return &CreateService{db: db}
}
// Create 按操作者类型执行平台显式归属或代理安全继承。
func (s *CreateService) Create(ctx context.Context, request *dto.CreateShopRequest) (*dto.ShopResponse, error) {
userType := middleware.GetUserTypeFromContext(ctx)
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
resolver := resolvePlatformBusinessOwner
switch userType {
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
case constants.UserTypeAgent:
if request.BusinessOwnerAccountIDSet {
return nil, errors.New(errors.CodeForbidden, "无权限设置店铺业务员")
}
if request.ParentID == nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if err := middleware.CanManageShop(ctx, *request.ParentID); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
resolver = resolveInheritedBusinessOwner
default:
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(request.InitPassword), bcrypt.DefaultCost)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败")
}
var response *dto.ShopResponse
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
created, createErr := createShop(tx, request, operatorID, string(hashedPassword), resolver)
if createErr != nil {
return createErr
}
response = created
return nil
})
if err != nil {
return nil, err
}
return response, nil
}
type businessOwnerResolver func(*gorm.DB, *dto.CreateShopRequest, *model.Shop) (*uint, error)
func createShop(tx *gorm.DB, request *dto.CreateShopRequest, operatorID uint, hashedPassword string, resolveOwner businessOwnerResolver) (*dto.ShopResponse, error) {
if exists, err := recordExists(tx, &model.Shop{}, "shop_code = ?", request.ShopCode); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验店铺编号失败")
} else if exists {
return nil, errors.New(errors.CodeShopCodeExists, "店铺编号已存在")
}
if exists, err := recordExists(tx, &model.Account{}, "username = ?", request.InitUsername); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验初始账号用户名失败")
} else if exists {
return nil, errors.New(errors.CodeUsernameExists, "初始账号用户名已存在")
}
if exists, err := recordExists(tx, &model.Account{}, "phone = ?", request.InitPhone); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验初始账号手机号失败")
} else if exists {
return nil, errors.New(errors.CodePhoneExists, "初始账号手机号已存在")
}
parent, level, err := resolveParent(tx, request.ParentID)
if err != nil {
return nil, err
}
ownerID, err := resolveOwner(tx, request, parent)
if err != nil {
return nil, err
}
var role model.Role
if err := tx.Where("id = ? AND role_type = ? AND status = ?", request.DefaultRoleID, constants.RoleTypeCustomer, constants.StatusEnabled).First(&role).Error; err != nil {
return nil, errors.New(errors.CodeInvalidParam, "请选择启用的客户角色")
}
shop := &model.Shop{
ShopName: request.ShopName, ShopCode: request.ShopCode, ParentID: request.ParentID,
BusinessOwnerAccountID: ownerID, Level: level, ContactName: request.ContactName,
ContactPhone: request.ContactPhone, Province: request.Province, City: request.City,
District: request.District, Address: request.Address, Status: constants.ShopStatusEnabled,
}
shop.Creator = operatorID
shop.Updater = operatorID
if err := tx.Create(shop).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建店铺失败")
}
account := &model.Account{
Username: request.InitUsername, Phone: request.InitPhone, Password: hashedPassword,
UserType: constants.UserTypeAgent, ShopID: &shop.ID, Status: constants.StatusEnabled, IsPrimary: true,
}
account.Creator = operatorID
account.Updater = operatorID
if err := tx.Create(account).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建初始账号失败")
}
if err := tx.Create(&model.AccountRole{
AccountID: account.ID, RoleID: request.DefaultRoleID, Status: constants.StatusEnabled,
Creator: operatorID, Updater: operatorID,
}).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "为初始账号分配角色失败")
}
if err := tx.Create(&model.ShopRole{
ShopID: shop.ID, RoleID: request.DefaultRoleID, Status: constants.StatusEnabled,
Creator: operatorID, Updater: operatorID,
}).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "设置店铺默认角色失败")
}
if err := tx.Create([]*model.AgentWallet{
{
ShopID: shop.ID, WalletType: constants.AgentWalletTypeMain,
CreditEnabled: role.DefaultCreditEnabled, CreditLimit: role.DefaultCreditLimit,
Currency: "CNY", Status: constants.AgentWalletStatusNormal, ShopIDTag: shop.ID,
},
{
ShopID: shop.ID, WalletType: constants.AgentWalletTypeCommission,
CreditEnabled: false, CreditLimit: 0,
Currency: "CNY", Status: constants.AgentWalletStatusNormal, ShopIDTag: shop.ID,
},
}).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "初始化店铺钱包失败")
}
parentName := ""
if parent != nil {
parentName = parent.ShopName
}
response := newShopResponse(shop, parentName)
if err := fillBusinessOwnerResponse(tx, shop, response); err != nil {
return nil, err
}
return response, nil
}
func resolveParent(tx *gorm.DB, parentID *uint) (*model.Shop, int, error) {
if parentID == nil {
return nil, 1, nil
}
var parent model.Shop
if err := tx.First(&parent, *parentID).Error; err != nil {
return nil, 0, errors.New(errors.CodeInvalidParentID, "上级店铺不存在或无效")
}
level := parent.Level + 1
if level > constants.ShopMaxLevel {
return nil, 0, errors.New(errors.CodeShopLevelExceeded, "店铺层级不能超过 7 级")
}
return &parent, level, nil
}
func resolvePlatformBusinessOwner(tx *gorm.DB, request *dto.CreateShopRequest, parent *model.Shop) (*uint, error) {
if !request.BusinessOwnerAccountIDSet {
if parent == nil || parent.BusinessOwnerAccountID == nil {
return nil, nil
}
ownerID := *parent.BusinessOwnerAccountID
return &ownerID, nil
}
if request.BusinessOwnerAccountID == nil {
return nil, nil
}
if *request.BusinessOwnerAccountID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "业务员账号无效")
}
var account model.Account
if err := tx.Where("id = ? AND user_type = ? AND status = ?", *request.BusinessOwnerAccountID, constants.UserTypePlatform, constants.StatusEnabled).
First(&account).Error; err != nil {
return nil, errors.New(errors.CodeInvalidParam, "业务员账号无效或不可用")
}
ownerID := account.ID
return &ownerID, nil
}
func resolveInheritedBusinessOwner(_ *gorm.DB, _ *dto.CreateShopRequest, parent *model.Shop) (*uint, error) {
if parent == nil || parent.BusinessOwnerAccountID == nil {
return nil, nil
}
ownerID := *parent.BusinessOwnerAccountID
return &ownerID, nil
}
func recordExists(tx *gorm.DB, target any, query string, value any) (bool, error) {
var count int64
err := tx.Model(target).Where(query, value).Count(&count).Error
return count > 0, err
}
func newShopResponse(shop *model.Shop, parentName string) *dto.ShopResponse {
return &dto.ShopResponse{
ID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode, ParentID: shop.ParentID,
BusinessOwnerAccountID: shop.BusinessOwnerAccountID,
ParentShopName: parentName, Level: shop.Level, ContactName: shop.ContactName,
ContactPhone: shop.ContactPhone, Province: shop.Province, City: shop.City,
District: shop.District, Address: shop.Address, Status: shop.Status,
StatusName: constants.GetStatusName(shop.Status), CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"),
}
}
func fillBusinessOwnerResponse(tx *gorm.DB, shop *model.Shop, response *dto.ShopResponse) error {
if shop.BusinessOwnerAccountID == nil {
return nil
}
var account model.Account
err := tx.Unscoped().Where("id = ?", *shop.BusinessOwnerAccountID).First(&account).Error
if err == gorm.ErrRecordNotFound {
return nil
}
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询业务员摘要失败")
}
response.BusinessOwnerUsername = account.Username
response.BusinessOwnerPhoneSummary = maskBusinessOwnerPhone(account.Phone)
response.BusinessOwnerAvailable = account.UserType == constants.UserTypePlatform && account.Status == constants.StatusEnabled && !account.DeletedAt.Valid
return nil
}
func maskBusinessOwnerPhone(phone string) string {
phone = strings.TrimSpace(phone)
if len(phone) < 7 {
return ""
}
return phone[:3] + "****" + phone[len(phone)-4:]
}

View File

@@ -0,0 +1,10 @@
package shop
import "context"
// NotificationRecipientResolver 定义按店铺解析当前可用后台通知接收人的 Port。
//
// 实现只返回稳定账号 ID无可用接收人是正常结果不应触发无限重试。
type NotificationRecipientResolver interface {
ResolveNotificationRecipients(ctx context.Context, shopID uint) ([]uint, error)
}

View File

@@ -0,0 +1,108 @@
package shop
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// UpdateService 收口店铺资料与业务员归属的简单写事务脚本。
type UpdateService struct {
db *gorm.DB
}
// NewUpdateService 创建店铺更新事务脚本。
func NewUpdateService(db *gorm.DB) *UpdateService {
return &UpdateService{db: db}
}
// Update 更新单个店铺;业务员归属变化不会传播到其他店铺。
func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.UpdateShopRequest) (*dto.ShopResponse, error) {
if shopID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
userType := middleware.GetUserTypeFromContext(ctx)
if userType == constants.UserTypeEnterprise {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if err := middleware.CanManageShop(ctx, shopID); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if userType == constants.UserTypeAgent && request.BusinessOwnerAccountIDSet {
return nil, errors.New(errors.CodeForbidden, "无权限设置店铺业务员")
}
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
var response *dto.ShopResponse
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var shop model.Shop
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&shop, shopID).Error; err != nil {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if request.BusinessOwnerAccountIDSet {
ownerID, err := validateUpdatedBusinessOwner(tx, request.BusinessOwnerAccountID)
if err != nil {
return err
}
shop.BusinessOwnerAccountID = ownerID
}
shop.ShopName = request.ShopName
shop.ContactName = request.ContactName
shop.ContactPhone = request.ContactPhone
shop.Province = request.Province
shop.City = request.City
shop.District = request.District
shop.Address = request.Address
shop.Status = request.Status
shop.Updater = operatorID
if err := tx.Save(&shop).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新店铺失败")
}
parentName := ""
if shop.ParentID != nil {
var parent model.Shop
if err := tx.Select("shop_name").First(&parent, *shop.ParentID).Error; err == nil {
parentName = parent.ShopName
}
}
response = newShopResponse(&shop, parentName)
if err := fillBusinessOwnerResponse(tx, &shop, response); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return response, nil
}
func validateUpdatedBusinessOwner(tx *gorm.DB, requestedID *uint) (*uint, error) {
if requestedID == nil {
return nil, nil
}
if *requestedID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "业务员账号无效")
}
var account model.Account
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).
Where("id = ? AND user_type = ? AND status = ?", *requestedID, constants.UserTypePlatform, constants.StatusEnabled).
First(&account).Error; err != nil {
return nil, errors.New(errors.CodeInvalidParam, "业务员账号无效或不可用")
}
ownerID := account.ID
return &ownerID, nil
}

View File

@@ -0,0 +1,58 @@
// Package wallet 提供代理主钱包复杂写用例。
package wallet
import (
"context"
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"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
)
// ChangeCreditService 调整既有店铺主钱包实际信用额度。
type ChangeCreditService struct {
db *gorm.DB
}
// NewChangeCreditService 创建实际信用额度调整服务。
func NewChangeCreditService(db *gorm.DB) *ChangeCreditService {
return &ChangeCreditService{db: db}
}
// Execute 按主钱包类型和版本条件更新,不修改余额、冻结金额或钱包流水。
func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled bool, limit int64, version int) (*dto.ShopCreditLimitResponse, error) {
var result *dto.ShopCreditLimitResponse
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var stored model.AgentWallet
if err := tx.Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).First(&stored).Error; err != nil {
return errors.New(errors.CodeWalletNotFound, "店铺主钱包不存在")
}
aggregate := domainwallet.AgentWallet{ID: stored.ID, ShopID: stored.ShopID, WalletType: stored.WalletType, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: stored.CreditEnabled, CreditLimit: stored.CreditLimit, Status: stored.Status, Version: stored.Version}
if err := aggregate.ChangeCredit(enabled, limit); err != nil {
return err
}
update := tx.Model(&model.AgentWallet{}).
Where("id = ? AND wallet_type = ? AND version = ? AND balance::numeric - frozen_balance::numeric + ?::numeric >= 0", stored.ID, constants.AgentWalletTypeMain, version, aggregate.EffectiveCredit()).
Updates(map[string]any{"credit_enabled": enabled, "credit_limit": limit, "version": gorm.Expr("version + 1")})
if update.Error != nil {
return errors.Wrap(errors.CodeInternalError, update.Error, "更新店铺信用额度失败")
}
if update.RowsAffected != 1 {
var current model.AgentWallet
if err := tx.Where("id = ? AND wallet_type = ?", stored.ID, constants.AgentWalletTypeMain).First(&current).Error; err != nil {
return errors.New(errors.CodeWalletNotFound, "店铺主钱包不存在")
}
if current.Version != version {
return errors.New(errors.CodeConflict, "钱包版本已变化,请刷新后重试")
}
return errors.New(errors.CodeInsufficientQuota, "当前资金占用无法降低或关闭信用额度")
}
available, _ := aggregate.AvailableBalance()
result = &dto.ShopCreditLimitResponse{ShopID: shopID, WalletID: stored.ID, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: enabled, CreditLimit: limit, AvailableBalance: available, Version: version + 1}
return nil
})
return result, err
}

View File

@@ -0,0 +1,202 @@
// Package wallet 提供代理主钱包复杂写用例。
package wallet
import (
"context"
"strconv"
"strings"
"time"
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// DebitCommand 描述一次具有稳定业务引用的代理主钱包扣款。
type DebitCommand struct {
ShopID uint
Amount int64
ReferenceType string
ReferenceID uint
UserID uint
Creator uint
TransactionSubtype string
RelatedShopID *uint
AssetType string
AssetID uint
AssetIdentifier string
Remark string
RequestID string
CorrelationID string
}
// DebitResult 返回统一扣款后的资金快照。
type DebitResult struct {
WalletID uint
BalanceBefore int64
BalanceAfter int64
Version int
AlreadyApplied bool
}
// DebitedEvent 是代理主钱包扣款成功后的可靠领域事实。
type DebitedEvent struct {
EventID string `json:"event_id"`
WalletID uint `json:"wallet_id"`
ShopID uint `json:"shop_id"`
Amount int64 `json:"amount"`
BalanceBefore int64 `json:"balance_before"`
BalanceAfter int64 `json:"balance_after"`
Version int `json:"version"`
ReferenceType string `json:"reference_type"`
ReferenceID uint `json:"reference_id"`
TransactionType string `json:"transaction_type"`
OccurredAt time.Time `json:"occurred_at"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// DebitEventWriter 在调用方事务内追加扣款成功事件。
type DebitEventWriter interface {
Append(ctx context.Context, tx *gorm.DB, event DebitedEvent) error
}
// DebitService 统一代理主钱包扣款、流水与可靠事件。
type DebitService struct {
eventWriter DebitEventWriter
now func() time.Time
}
// NewDebitService 创建统一代理主钱包扣款服务。
func NewDebitService(eventWriter DebitEventWriter, now func() time.Time) *DebitService {
if now == nil {
now = time.Now
}
return &DebitService{eventWriter: eventWriter, now: now}
}
// DebitInTx 在调用方事务内完成锁定、扣款、唯一流水和 Outbox 事件。
func (s *DebitService) DebitInTx(ctx context.Context, tx *gorm.DB, command DebitCommand) (DebitResult, error) {
if s == nil || s.eventWriter == nil || tx == nil {
return DebitResult{}, errors.New(errors.CodeInternalError, "代理主钱包扣款能力未完整配置")
}
if err := validateDebitCommand(command); err != nil {
return DebitResult{}, err
}
var stored model.AgentWallet
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND wallet_type = ?", command.ShopID, constants.AgentWalletTypeMain).
First(&stored).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return DebitResult{}, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在")
}
return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败")
}
existing, err := findExistingDebit(ctx, tx, command.ReferenceType, command.ReferenceID)
if err != nil {
return DebitResult{}, err
}
if existing != nil {
if existing.AgentWalletID != stored.ID || existing.Amount != -command.Amount {
return DebitResult{}, errors.New(errors.CodeConflict, "业务单已存在不一致的钱包扣款流水")
}
return DebitResult{
WalletID: stored.ID, BalanceBefore: existing.BalanceBefore, BalanceAfter: existing.BalanceAfter,
Version: stored.Version, AlreadyApplied: true,
}, nil
}
aggregate := domainwallet.AgentWallet{
ID: stored.ID, ShopID: stored.ShopID, WalletType: stored.WalletType,
Balance: stored.Balance, FrozenBalance: stored.FrozenBalance,
CreditEnabled: stored.CreditEnabled, CreditLimit: stored.CreditLimit,
Status: stored.Status, Version: stored.Version,
}
if err := aggregate.Debit(command.Amount); err != nil {
return DebitResult{}, err
}
updatedAt := s.now().UTC()
update := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where(`id = ? AND wallet_type = ? AND status = ? AND version = ?
AND balance::numeric - frozen_balance::numeric
+ CASE WHEN credit_enabled THEN credit_limit::numeric ELSE 0 END >= ?::numeric`,
stored.ID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal, stored.Version, command.Amount).
Updates(map[string]any{
"balance": aggregate.Balance, "version": gorm.Expr("version + 1"), "updated_at": updatedAt,
})
if update.Error != nil {
return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, update.Error, "扣减代理主钱包失败")
}
if update.RowsAffected != 1 {
return DebitResult{}, errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
}
transaction := buildDebitTransaction(stored, aggregate.Balance, command)
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包扣款流水失败")
}
event := DebitedEvent{
EventID: "agent-wallet:order:" + strconv.FormatUint(uint64(command.ReferenceID), 10) + ":debited",
WalletID: stored.ID, ShopID: stored.ShopID, Amount: command.Amount,
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1,
ReferenceType: command.ReferenceType, ReferenceID: command.ReferenceID,
TransactionType: constants.AgentTransactionTypeDeduct, OccurredAt: updatedAt,
RequestID: command.RequestID, CorrelationID: command.CorrelationID,
}
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包扣款事件失败")
}
return DebitResult{
WalletID: stored.ID, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance,
Version: stored.Version + 1,
}, nil
}
func validateDebitCommand(command DebitCommand) error {
if command.ShopID == 0 || command.Amount <= 0 || command.ReferenceID == 0 {
return errors.New(errors.CodeInvalidParam, "代理主钱包扣款参数无效")
}
if strings.TrimSpace(command.ReferenceType) != constants.ReferenceTypeOrder {
return errors.New(errors.CodeInvalidParam, "当前统一扣款仅支持订单业务")
}
return nil
}
func findExistingDebit(ctx context.Context, tx *gorm.DB, referenceType string, referenceID uint) (*model.AgentWalletTransaction, error) {
var transaction model.AgentWalletTransaction
err := tx.WithContext(ctx).Unscoped().
Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
referenceType, referenceID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess).
First(&transaction).Error
if err == nil {
return &transaction, nil
}
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包扣款流水失败")
}
func buildDebitTransaction(stored model.AgentWallet, balanceAfter int64, command DebitCommand) *model.AgentWalletTransaction {
referenceType := strings.TrimSpace(command.ReferenceType)
remark := strings.TrimSpace(command.Remark)
var subtype *string
if value := strings.TrimSpace(command.TransactionSubtype); value != "" {
subtype = &value
}
return &model.AgentWalletTransaction{
AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID,
TransactionType: constants.AgentTransactionTypeDeduct, TransactionSubtype: subtype,
Amount: -command.Amount, BalanceBefore: stored.Balance, BalanceAfter: balanceAfter,
Status: constants.TransactionStatusSuccess, ReferenceType: &referenceType, ReferenceID: &command.ReferenceID,
RelatedShopID: command.RelatedShopID, AssetType: command.AssetType, AssetID: command.AssetID,
AssetIdentifier: command.AssetIdentifier, Remark: &remark, Creator: command.Creator,
ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag,
}
}

View File

@@ -0,0 +1,182 @@
package wallet
import (
"context"
"strconv"
"strings"
"time"
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// PostingCommand 描述一次具有稳定业务引用的代理主钱包正向入账。
type PostingCommand struct {
ShopID uint
WalletID uint
Amount int64
ReferenceType string
ReferenceID uint
TransactionType string
UserID uint
Creator uint
Remark string
Metadata *string
RequestID string
CorrelationID string
}
// PostingResult 返回统一入账后的资金快照。
type PostingResult struct {
WalletID uint
BalanceBefore int64
BalanceAfter int64
Version int
AlreadyApplied bool
}
// CreditedEvent 是代理主钱包正向入账成功后的可靠领域事实。
type CreditedEvent struct {
EventID string `json:"event_id"`
WalletID uint `json:"wallet_id"`
ShopID uint `json:"shop_id"`
Amount int64 `json:"amount"`
BalanceBefore int64 `json:"balance_before"`
BalanceAfter int64 `json:"balance_after"`
Version int `json:"version"`
ReferenceType string `json:"reference_type"`
ReferenceID uint `json:"reference_id"`
TransactionType string `json:"transaction_type"`
OccurredAt time.Time `json:"occurred_at"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// CreditEventWriter 在调用方事务内追加正向入账事件。
type CreditEventWriter interface {
Append(ctx context.Context, tx *gorm.DB, event CreditedEvent) error
}
// PostingService 统一代理主钱包充值与人工调整入账。
type PostingService struct {
eventWriter CreditEventWriter
now func() time.Time
}
// NewPostingService 创建统一代理主钱包入账服务。
func NewPostingService(eventWriter CreditEventWriter, now func() time.Time) *PostingService {
if now == nil {
now = time.Now
}
return &PostingService{eventWriter: eventWriter, now: now}
}
// PostInTx 在调用方事务内完成锁定、入账、唯一流水和 Outbox 事件。
func (s *PostingService) PostInTx(ctx context.Context, tx *gorm.DB, command PostingCommand) (PostingResult, error) {
if s == nil || s.eventWriter == nil || tx == nil {
return PostingResult{}, errors.New(errors.CodeInternalError, "代理主钱包入账能力未完整配置")
}
if err := validatePostingCommand(command); err != nil {
return PostingResult{}, err
}
var stored model.AgentWallet
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND wallet_type = ?", command.ShopID, constants.AgentWalletTypeMain).
First(&stored).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return PostingResult{}, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在")
}
return PostingResult{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败")
}
if command.WalletID > 0 && command.WalletID != stored.ID {
return PostingResult{}, errors.New(errors.CodeConflict, "入账业务单与代理主钱包归属不一致")
}
existing, err := findExistingPosting(ctx, tx, command.ReferenceType, command.ReferenceID)
if err != nil {
return PostingResult{}, err
}
if existing != nil {
if existing.AgentWalletID != stored.ID || existing.Amount != command.Amount || existing.TransactionType != command.TransactionType {
return PostingResult{}, errors.New(errors.CodeConflict, "业务单已存在不一致的钱包入账流水")
}
return PostingResult{WalletID: stored.ID, BalanceBefore: existing.BalanceBefore, BalanceAfter: existing.BalanceAfter, Version: stored.Version, AlreadyApplied: true}, nil
}
aggregate := domainwallet.AgentWallet{
ID: stored.ID, ShopID: stored.ShopID, WalletType: stored.WalletType,
Balance: stored.Balance, FrozenBalance: stored.FrozenBalance,
CreditEnabled: stored.CreditEnabled, CreditLimit: stored.CreditLimit,
Status: stored.Status, Version: stored.Version,
}
if err := aggregate.Credit(command.Amount); err != nil {
return PostingResult{}, err
}
now := s.now().UTC()
update := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where("id = ? AND wallet_type = ? AND status = ? AND version = ?", stored.ID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal, stored.Version).
Updates(map[string]any{"balance": aggregate.Balance, "version": gorm.Expr("version + 1"), "updated_at": now})
if update.Error != nil {
return PostingResult{}, errors.Wrap(errors.CodeDatabaseError, update.Error, "增加代理主钱包余额失败")
}
if update.RowsAffected != 1 {
return PostingResult{}, errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
}
referenceType := strings.TrimSpace(command.ReferenceType)
remark := strings.TrimSpace(command.Remark)
transaction := &model.AgentWalletTransaction{
AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID,
TransactionType: command.TransactionType, Amount: command.Amount,
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Status: constants.TransactionStatusSuccess,
ReferenceType: &referenceType, ReferenceID: &command.ReferenceID, Remark: &remark, Metadata: command.Metadata,
Creator: command.Creator, ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag,
}
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
return PostingResult{}, errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包入账流水失败")
}
event := CreditedEvent{
EventID: "agent-wallet:" + referenceType + ":" + strconv.FormatUint(uint64(command.ReferenceID), 10) + ":credited",
WalletID: stored.ID, ShopID: stored.ShopID, Amount: command.Amount,
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1,
ReferenceType: referenceType, ReferenceID: command.ReferenceID, TransactionType: command.TransactionType,
OccurredAt: now, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
}
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
return PostingResult{}, errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包入账事件失败")
}
return PostingResult{WalletID: stored.ID, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1}, nil
}
func validatePostingCommand(command PostingCommand) error {
if command.ShopID == 0 || command.Amount <= 0 || command.ReferenceID == 0 {
return errors.New(errors.CodeInvalidParam, "代理主钱包入账参数无效")
}
referenceType := strings.TrimSpace(command.ReferenceType)
validRecharge := referenceType == constants.ReferenceTypeTopup && command.TransactionType == constants.AgentTransactionTypeRecharge
validAdjustment := referenceType == constants.ReferenceTypeManualAdjustment && command.TransactionType == constants.AgentTransactionTypeAdjustment
if !validRecharge && !validAdjustment {
return errors.New(errors.CodeInvalidParam, "代理主钱包入账业务类型无效")
}
return nil
}
func findExistingPosting(ctx context.Context, tx *gorm.DB, referenceType string, referenceID uint) (*model.AgentWalletTransaction, error) {
var transaction model.AgentWalletTransaction
err := tx.WithContext(ctx).Unscoped().
Where("reference_type = ? AND reference_id = ? AND transaction_type IN ? AND status = ?",
strings.TrimSpace(referenceType), referenceID, []string{constants.AgentTransactionTypeRecharge, constants.AgentTransactionTypeAdjustment}, constants.TransactionStatusSuccess).
First(&transaction).Error
if err == nil {
return &transaction, nil
}
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包入账流水失败")
}

View File

@@ -0,0 +1,289 @@
package wallet
import (
"context"
"math"
"strconv"
"strings"
"time"
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// RefundCommand 描述一次沿订单原扣款回溯的代理主钱包退款。
type RefundCommand struct {
OrderID uint
RefundID uint
Amount int64
LegacyPayerShopID uint
LegacyDeductAmount int64
LegacyRelatedShopID *uint
AssetType string
AssetID uint
AssetIdentifier string
UserID uint
Creator uint
Remark string
RequestID string
CorrelationID string
}
// RefundResult 返回代理主钱包退款后的资金快照。
type RefundResult struct {
WalletID uint
BalanceBefore int64
BalanceAfter int64
Version int
AlreadyApplied bool
}
// RefundedEvent 是代理主钱包订单退款成功后的可靠资金事实。
type RefundedEvent struct {
EventID string `json:"event_id"`
WalletID uint `json:"wallet_id"`
ShopID uint `json:"shop_id"`
OrderID uint `json:"order_id"`
RefundID uint `json:"refund_id"`
Amount int64 `json:"amount"`
BalanceBefore int64 `json:"balance_before"`
BalanceAfter int64 `json:"balance_after"`
Version int `json:"version"`
OriginalDebitTransactionID uint `json:"original_debit_transaction_id,omitempty"`
OriginalDeductAmount int64 `json:"original_deduct_amount"`
RelatedShopID *uint `json:"related_shop_id,omitempty"`
TransactionSubtype *string `json:"transaction_subtype,omitempty"`
AssetType string `json:"asset_type,omitempty"`
AssetID uint `json:"asset_id,omitempty"`
AssetIdentifier string `json:"asset_identifier,omitempty"`
Legacy bool `json:"legacy"`
OccurredAt time.Time `json:"occurred_at"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// RefundEventWriter 在调用方事务内追加代理主钱包退款事件。
type RefundEventWriter interface {
Append(ctx context.Context, tx *gorm.DB, event RefundedEvent) error
}
// RefundService 统一代理订单退款回充、真实流水和可靠事件。
type RefundService struct {
eventWriter RefundEventWriter
now func() time.Time
}
// NewRefundService 创建统一代理主钱包退款服务。
func NewRefundService(eventWriter RefundEventWriter, now func() time.Time) *RefundService {
if now == nil {
now = time.Now
}
return &RefundService{eventWriter: eventWriter, now: now}
}
// RefundInTx 在调用方事务内按原扣款事实完成退款回充、版本递增、唯一流水和 Outbox。
func (s *RefundService) RefundInTx(ctx context.Context, tx *gorm.DB, command RefundCommand) (RefundResult, error) {
if s == nil || s.eventWriter == nil || tx == nil {
return RefundResult{}, errors.New(errors.CodeInternalError, "代理主钱包退款能力未完整配置")
}
if err := validateRefundCommand(command); err != nil {
return RefundResult{}, err
}
origin, err := findOriginalOrderDebit(ctx, tx, command.OrderID)
if err != nil {
return RefundResult{}, err
}
resolution, err := resolveRefundTarget(command, origin)
if err != nil {
return RefundResult{}, err
}
stored, err := lockRefundWallet(ctx, tx, resolution)
if err != nil {
return RefundResult{}, err
}
existing, err := findExistingRefund(ctx, tx, command.RefundID)
if err != nil {
return RefundResult{}, err
}
if existing != nil {
if existing.AgentWalletID != stored.ID || existing.Amount != command.Amount ||
!sameOptionalUint(existing.RelatedShopID, resolution.relatedShopID) ||
!sameOptionalString(existing.TransactionSubtype, resolution.subtype) ||
existing.AssetType != resolution.assetType || existing.AssetID != resolution.assetID ||
existing.AssetIdentifier != resolution.assetIdentifier {
return RefundResult{}, errors.New(errors.CodeConflict, "退款单已存在不一致的钱包回充流水")
}
return RefundResult{
WalletID: stored.ID, BalanceBefore: existing.BalanceBefore, BalanceAfter: existing.BalanceAfter,
Version: stored.Version, AlreadyApplied: true,
}, nil
}
aggregate := domainwallet.AgentWallet{
ID: stored.ID, ShopID: stored.ShopID, WalletType: stored.WalletType,
Balance: stored.Balance, FrozenBalance: stored.FrozenBalance,
CreditEnabled: stored.CreditEnabled, CreditLimit: stored.CreditLimit,
Status: stored.Status, Version: stored.Version,
}
if err := aggregate.Credit(command.Amount); err != nil {
return RefundResult{}, err
}
now := s.now().UTC()
update := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where("id = ? AND wallet_type = ? AND status = ? AND version = ?",
stored.ID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal, stored.Version).
Updates(map[string]any{"balance": aggregate.Balance, "version": gorm.Expr("version + 1"), "updated_at": now})
if update.Error != nil {
return RefundResult{}, errors.Wrap(errors.CodeDatabaseError, update.Error, "退回代理主钱包余额失败")
}
if update.RowsAffected != 1 {
return RefundResult{}, errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
}
transaction := buildRefundTransaction(stored, aggregate.Balance, command, resolution)
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
return RefundResult{}, errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包退款流水失败")
}
event := RefundedEvent{
EventID: "agent-wallet:refund:" + strconv.FormatUint(uint64(command.RefundID), 10) + ":refunded",
WalletID: stored.ID, ShopID: stored.ShopID, OrderID: command.OrderID, RefundID: command.RefundID,
Amount: command.Amount, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance,
Version: stored.Version + 1, OriginalDebitTransactionID: resolution.originalDebitID,
OriginalDeductAmount: resolution.deductAmount,
RelatedShopID: resolution.relatedShopID, TransactionSubtype: resolution.subtype, Legacy: resolution.legacy,
AssetType: resolution.assetType, AssetID: resolution.assetID, AssetIdentifier: resolution.assetIdentifier,
OccurredAt: now, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
}
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
return RefundResult{}, errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包退款事件失败")
}
return RefundResult{
WalletID: stored.ID, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance,
Version: stored.Version + 1,
}, nil
}
type refundResolution struct {
walletID uint
shopID uint
originalDebitID uint
deductAmount int64
relatedShopID *uint
subtype *string
assetType string
assetID uint
assetIdentifier string
legacy bool
}
func validateRefundCommand(command RefundCommand) error {
if command.OrderID == 0 || command.RefundID == 0 || command.Amount <= 0 {
return errors.New(errors.CodeInvalidParam, "代理主钱包退款参数无效")
}
return nil
}
func findOriginalOrderDebit(ctx context.Context, tx *gorm.DB, orderID uint) (*model.AgentWalletTransaction, error) {
var transaction model.AgentWalletTransaction
err := tx.WithContext(ctx).Unscoped().
Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
constants.ReferenceTypeOrder, orderID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess).
Order("id ASC").First(&transaction).Error
if err == nil {
return &transaction, nil
}
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询原代理主钱包扣款流水失败")
}
func resolveRefundTarget(command RefundCommand, origin *model.AgentWalletTransaction) (refundResolution, error) {
if origin != nil {
if origin.Amount >= 0 || origin.Amount == math.MinInt64 {
return refundResolution{}, errors.New(errors.CodeInternalError, "原代理主钱包扣款流水金额非法")
}
maxAmount := -origin.Amount
if command.Amount > maxAmount {
return refundResolution{}, errors.New(errors.CodeInvalidParam, "退款金额不能大于原钱包扣款金额")
}
return refundResolution{
walletID: origin.AgentWalletID, originalDebitID: origin.ID, deductAmount: maxAmount,
relatedShopID: origin.RelatedShopID, subtype: origin.TransactionSubtype,
assetType: origin.AssetType, assetID: origin.AssetID, assetIdentifier: origin.AssetIdentifier,
}, nil
}
if command.LegacyPayerShopID == 0 || command.LegacyDeductAmount <= 0 {
return refundResolution{}, errors.New(errors.CodeInternalError, "历史订单缺少原扣款流水和兼容付款快照")
}
if command.Amount > command.LegacyDeductAmount {
return refundResolution{}, errors.New(errors.CodeInvalidParam, "退款金额不能大于历史订单实付金额")
}
return refundResolution{
shopID: command.LegacyPayerShopID, relatedShopID: command.LegacyRelatedShopID,
deductAmount: command.LegacyDeductAmount,
assetType: command.AssetType, assetID: command.AssetID, assetIdentifier: command.AssetIdentifier,
legacy: true,
}, nil
}
func lockRefundWallet(ctx context.Context, tx *gorm.DB, resolution refundResolution) (model.AgentWallet, error) {
var stored model.AgentWallet
query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("wallet_type = ?", constants.AgentWalletTypeMain)
if resolution.walletID > 0 {
query = query.Where("id = ?", resolution.walletID)
} else {
query = query.Where("shop_id = ?", resolution.shopID)
}
if err := query.First(&stored).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return model.AgentWallet{}, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在")
}
return model.AgentWallet{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败")
}
return stored, nil
}
func findExistingRefund(ctx context.Context, tx *gorm.DB, refundID uint) (*model.AgentWalletTransaction, error) {
var transaction model.AgentWalletTransaction
err := tx.WithContext(ctx).Unscoped().
Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
constants.ReferenceTypeRefund, refundID, constants.AgentTransactionTypeRefund, constants.TransactionStatusSuccess).
First(&transaction).Error
if err == nil {
return &transaction, nil
}
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包退款流水失败")
}
func buildRefundTransaction(stored model.AgentWallet, balanceAfter int64, command RefundCommand, resolution refundResolution) *model.AgentWalletTransaction {
referenceType := constants.ReferenceTypeRefund
remark := strings.TrimSpace(command.Remark)
return &model.AgentWalletTransaction{
AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID,
TransactionType: constants.AgentTransactionTypeRefund, TransactionSubtype: resolution.subtype,
Amount: command.Amount, BalanceBefore: stored.Balance, BalanceAfter: balanceAfter,
Status: constants.TransactionStatusSuccess, ReferenceType: &referenceType, ReferenceID: &command.RefundID,
RelatedShopID: resolution.relatedShopID, AssetType: resolution.assetType, AssetID: resolution.assetID,
AssetIdentifier: resolution.assetIdentifier, Remark: &remark, Creator: command.Creator,
ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag,
}
}
func sameOptionalUint(left, right *uint) bool {
return (left == nil && right == nil) || (left != nil && right != nil && *left == *right)
}
func sameOptionalString(left, right *string) bool {
return (left == nil && right == nil) || (left != nil && right != nil && *left == *right)
}

View File

@@ -0,0 +1,297 @@
package wallet
import (
"context"
"strconv"
"time"
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ReservationCommand 描述一次具有稳定业务引用的代理主钱包预占操作。
type ReservationCommand struct {
ShopID uint
Amount int64
ReferenceType string
ReferenceID uint
UserID uint
Creator uint
Subtype string
RelatedShopID *uint
AssetType string
AssetID uint
AssetIdentifier string
Remark string
RequestID string
CorrelationID string
}
// ReservationEvent 是代理主钱包预占状态变化事件。
type ReservationEvent struct {
EventID string `json:"event_id"`
ReservationID uint `json:"reservation_id"`
WalletID uint `json:"wallet_id"`
ShopID uint `json:"shop_id"`
Amount int64 `json:"amount"`
Status int `json:"status"`
ReferenceType string `json:"reference_type"`
ReferenceID uint `json:"reference_id"`
OccurredAt time.Time `json:"occurred_at"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// ReservationEventWriter 在业务事务内追加预占状态事件。
type ReservationEventWriter interface {
Append(ctx context.Context, tx *gorm.DB, event ReservationEvent) error
}
// ReservationService 统一代理主钱包冻结、释放与完成扣除。
type ReservationService struct {
reservationEvents ReservationEventWriter
debitEvents DebitEventWriter
now func() time.Time
}
// NewReservationService 创建代理主钱包预占服务。
func NewReservationService(reservationEvents ReservationEventWriter, debitEvents DebitEventWriter, now func() time.Time) *ReservationService {
if now == nil {
now = time.Now
}
return &ReservationService{reservationEvents: reservationEvents, debitEvents: debitEvents, now: now}
}
// FreezeInTx 在调用方事务内冻结资金并创建唯一预占事实。
func (s *ReservationService) FreezeInTx(ctx context.Context, tx *gorm.DB, command ReservationCommand) error {
if err := s.validateFreeze(tx, command); err != nil {
return err
}
wallet, aggregate, err := lockMainWallet(ctx, tx, command.ShopID)
if err != nil {
return err
}
existing, err := findReservation(ctx, tx, command.ReferenceType, command.ReferenceID, false)
if err != nil {
return err
}
if existing != nil {
if existing.AgentWalletID == wallet.ID && existing.Amount == command.Amount && existing.Status == constants.AgentWalletReservationStatusFrozen {
return nil
}
return errors.New(errors.CodeConflict, "业务引用已存在不一致的钱包预占")
}
if err := aggregate.Freeze(command.Amount); err != nil {
return err
}
now := s.now().UTC()
if err := updateWalletFunds(ctx, tx, wallet, aggregate, now); err != nil {
return err
}
reservation := &model.AgentWalletReservation{
AgentWalletID: wallet.ID, ShopID: wallet.ShopID, Amount: command.Amount,
Status: constants.AgentWalletReservationStatusFrozen,
ReferenceType: command.ReferenceType, ReferenceID: command.ReferenceID, Creator: command.Creator,
ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := tx.WithContext(ctx).Create(reservation).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包预占事实失败")
}
return s.appendReservationEvent(ctx, tx, reservation, now, command)
}
// ReleaseInTx 幂等释放处于冻结状态的资金预占。
func (s *ReservationService) ReleaseInTx(ctx context.Context, tx *gorm.DB, command ReservationCommand) error {
return s.finish(ctx, tx, command, constants.AgentWalletReservationStatusReleased)
}
// CompleteInTx 完成冻结资金扣除并创建真实扣款流水与扣款事件。
func (s *ReservationService) CompleteInTx(ctx context.Context, tx *gorm.DB, command ReservationCommand) error {
return s.finish(ctx, tx, command, constants.AgentWalletReservationStatusCompleted)
}
func (s *ReservationService) finish(ctx context.Context, tx *gorm.DB, command ReservationCommand, targetStatus int) error {
if err := s.validateFinish(tx, command); err != nil {
return err
}
reservation, err := findReservation(ctx, tx, command.ReferenceType, command.ReferenceID, true)
if err != nil {
return err
}
if reservation == nil {
return errors.New(errors.CodeNotFound, "代理主钱包预占事实不存在")
}
if (command.ShopID > 0 && reservation.ShopID != command.ShopID) || (command.Amount > 0 && reservation.Amount != command.Amount) {
return errors.New(errors.CodeConflict, "钱包预占事实与请求不一致")
}
command.ShopID = reservation.ShopID
command.Amount = reservation.Amount
if reservation.Status == targetStatus {
return nil
}
if reservation.Status != constants.AgentWalletReservationStatusFrozen {
return errors.New(errors.CodeInvalidStatus, "钱包预占已经进入其他终态")
}
wallet, aggregate, err := lockMainWallet(ctx, tx, command.ShopID)
if err != nil {
return err
}
if wallet.ID != reservation.AgentWalletID {
return errors.New(errors.CodeConflict, "钱包预占归属不一致")
}
if targetStatus == constants.AgentWalletReservationStatusReleased {
err = aggregate.Release(command.Amount)
} else {
err = aggregate.CompleteReserved(command.Amount)
}
if err != nil {
return err
}
now := s.now().UTC()
if err := updateWalletFunds(ctx, tx, wallet, aggregate, now); err != nil {
return err
}
result := tx.WithContext(ctx).Model(&model.AgentWalletReservation{}).
Where("id = ? AND status = ?", reservation.ID, constants.AgentWalletReservationStatusFrozen).
Updates(map[string]any{"status": targetStatus, "completed_at": now, "updated_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新代理主钱包预占状态失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "钱包预占状态已变化")
}
reservation.Status = targetStatus
reservation.CompletedAt = &now
if targetStatus == constants.AgentWalletReservationStatusCompleted {
if err := s.createCompletedDebit(ctx, tx, wallet, aggregate, command, now); err != nil {
return err
}
}
return s.appendReservationEvent(ctx, tx, reservation, now, command)
}
func (s *ReservationService) validateConfigured(tx *gorm.DB) error {
if s == nil || s.reservationEvents == nil || s.debitEvents == nil || tx == nil {
return errors.New(errors.CodeInternalError, "代理主钱包预占能力未完整配置")
}
return nil
}
func (s *ReservationService) validateFreeze(tx *gorm.DB, command ReservationCommand) error {
if err := s.validateConfigured(tx); err != nil {
return err
}
if command.ShopID == 0 || command.ReferenceType != constants.ReferenceTypeOrder || command.ReferenceID == 0 || command.Amount <= 0 {
return errors.New(errors.CodeInvalidParam, "代理主钱包预占参数无效")
}
return nil
}
func (s *ReservationService) validateFinish(tx *gorm.DB, command ReservationCommand) error {
if err := s.validateConfigured(tx); err != nil {
return err
}
if command.ReferenceType != constants.ReferenceTypeOrder || command.ReferenceID == 0 || command.Amount < 0 {
return errors.New(errors.CodeInvalidParam, "代理主钱包预占参数无效")
}
return nil
}
func lockMainWallet(ctx context.Context, tx *gorm.DB, shopID uint) (model.AgentWallet, *domainwallet.AgentWallet, error) {
var wallet model.AgentWallet
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).First(&wallet).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return wallet, nil, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在")
}
return wallet, nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败")
}
aggregate := &domainwallet.AgentWallet{
ID: wallet.ID, ShopID: wallet.ShopID, WalletType: wallet.WalletType,
Balance: wallet.Balance, FrozenBalance: wallet.FrozenBalance,
CreditEnabled: wallet.CreditEnabled, CreditLimit: wallet.CreditLimit,
Status: wallet.Status, Version: wallet.Version,
}
return wallet, aggregate, nil
}
func findReservation(ctx context.Context, tx *gorm.DB, referenceType string, referenceID uint, lock bool) (*model.AgentWalletReservation, error) {
var reservation model.AgentWalletReservation
query := tx.WithContext(ctx)
if lock {
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
}
err := query.Where("reference_type = ? AND reference_id = ?", referenceType, referenceID).First(&reservation).Error
if err == nil {
return &reservation, nil
}
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包预占事实失败")
}
func updateWalletFunds(ctx context.Context, tx *gorm.DB, stored model.AgentWallet, aggregate *domainwallet.AgentWallet, now time.Time) error {
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where("id = ? AND wallet_type = ? AND status = ? AND version = ?", stored.ID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal, stored.Version).
Updates(map[string]any{
"balance": aggregate.Balance, "frozen_balance": aggregate.FrozenBalance,
"version": gorm.Expr("version + 1"), "updated_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新代理主钱包预占资金失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
}
return nil
}
func (s *ReservationService) createCompletedDebit(ctx context.Context, tx *gorm.DB, stored model.AgentWallet, aggregate *domainwallet.AgentWallet, command ReservationCommand, now time.Time) error {
referenceType := command.ReferenceType
transaction := &model.AgentWalletTransaction{
AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID,
TransactionType: constants.AgentTransactionTypeDeduct, Amount: -command.Amount,
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance,
Status: constants.TransactionStatusSuccess, ReferenceType: &referenceType, ReferenceID: &command.ReferenceID,
RelatedShopID: command.RelatedShopID, AssetType: command.AssetType, AssetID: command.AssetID,
AssetIdentifier: command.AssetIdentifier, Remark: &command.Remark, Creator: command.Creator,
ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag,
}
if command.Subtype != "" {
transaction.TransactionSubtype = &command.Subtype
}
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建冻结资金扣款流水失败")
}
event := DebitedEvent{
EventID: "agent-wallet:order:" + strconv.FormatUint(uint64(command.ReferenceID), 10) + ":debited",
WalletID: stored.ID, ShopID: stored.ShopID, Amount: command.Amount,
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1,
ReferenceType: command.ReferenceType, ReferenceID: command.ReferenceID,
TransactionType: constants.AgentTransactionTypeDeduct, OccurredAt: now,
RequestID: command.RequestID, CorrelationID: command.CorrelationID,
}
if err := s.debitEvents.Append(ctx, tx, event); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入冻结资金扣款事件失败")
}
return nil
}
func (s *ReservationService) appendReservationEvent(ctx context.Context, tx *gorm.DB, reservation *model.AgentWalletReservation, now time.Time, command ReservationCommand) error {
event := ReservationEvent{
EventID: "agent-wallet-reservation:" + strconv.FormatUint(uint64(reservation.ID), 10) + ":" + strconv.Itoa(reservation.Status),
ReservationID: reservation.ID, WalletID: reservation.AgentWalletID, ShopID: reservation.ShopID,
Amount: reservation.Amount, Status: reservation.Status,
ReferenceType: reservation.ReferenceType, ReferenceID: reservation.ReferenceID,
OccurredAt: now, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
}
if err := s.reservationEvents.Append(ctx, tx, event); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包预占事件失败")
}
return nil
}

View File

@@ -1,7 +1,11 @@
package bootstrap
import (
notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification"
roleApp "github.com/break/junhong_cmp_fiber/internal/application/role"
shopApp "github.com/break/junhong_cmp_fiber/internal/application/shop"
systemConfigApp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
walletApp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/handler/app"
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
@@ -11,7 +15,9 @@ import (
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop"
systemConfigQuery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig"
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling"
@@ -95,23 +101,39 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
)
return &Handlers{
Auth: authHandler.NewHandler(svc.Auth, validate),
Account: admin.NewAccountHandler(svc.Account),
Role: admin.NewRoleHandler(svc.Role, validate),
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),
ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger),
Shop: admin.NewShopHandler(svc.Shop, validate),
ShopRole: admin.NewShopRoleHandler(svc.Shop),
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
ShopCommission: admin.NewShopCommissionHandler(svc.ShopCommission),
Auth: authHandler.NewHandler(svc.Auth, validate),
Account: admin.NewAccountHandler(svc.Account),
Role: func() *admin.RoleHandler {
handler := admin.NewRoleHandler(svc.Role, validate)
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),
ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger),
ClientNotification: app.NewClientNotificationHandler(notificationQuery.NewQuery(deps.DB), notificationApp.NewReadService(deps.DB), validate),
Shop: func() *admin.ShopHandler {
handler := admin.NewShopHandler(svc.Shop, validate)
handler.SetCreateService(shopApp.NewCreateService(deps.DB))
handler.SetUpdateService(shopApp.NewUpdateService(deps.DB))
handler.SetBusinessOwnerQuery(shopQuery.NewBusinessOwnerQuery(deps.DB))
handler.SetChangeCreditService(walletApp.NewChangeCreditService(deps.DB))
return handler
}(),
ShopRole: admin.NewShopRoleHandler(svc.Shop),
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
ShopCommission: func() *admin.ShopCommissionHandler {
handler := admin.NewShopCommissionHandler(svc.ShopCommission)
handler.SetFundSummaryQuery(shopQuery.NewFundSummaryQuery(deps.DB))
return handler
}(),
CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(svc.CommissionWithdrawal, validate),
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(svc.CommissionWithdrawalSetting),
Enterprise: admin.NewEnterpriseHandler(svc.Enterprise),
@@ -121,6 +143,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
IotCard: admin.NewIotCardHandler(svc.IotCard),
IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport),
ExportTask: admin.NewExportTaskHandler(svc.ExportTask),
Notification: admin.NewNotificationHandler(notificationQuery.NewQuery(deps.DB), notificationApp.NewReadService(deps.DB), validate),
Device: admin.NewDeviceHandler(svc.Device),
DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport),
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),

View File

@@ -5,6 +5,11 @@ import (
"go.uber.org/zap"
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/messaging/outbox"
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
"github.com/break/junhong_cmp_fiber/internal/polling"
accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account"
accountAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/account_audit"
@@ -137,6 +142,12 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.Logger,
assetAudit,
)
cardObservationOutbox := outbox.NewRepository()
iotCard.SetCardObservationService(cardObservationApp.NewService(
deps.DB,
cardObservationInfra.NewEventWriter(cardObservationOutbox),
cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger),
))
// 使用 PollingLifecycleService 替代 APICallback通过分片队列准确操作修复 api_callback.go 遗漏 protect 队列的 Bug3
pollingConfigStore := postgres.NewPollingConfigStore(deps.DB)
pollingConfigMgr := polling.NewPollingConfigManager(pollingConfigStore, deps.Redis, deps.Logger)
@@ -147,10 +158,6 @@ func initServices(s *stores, deps *Dependencies) *services {
pollingQueueMgr := polling.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger)
pollingLifecycleSvc := polling.NewPollingLifecycleService(pollingQueueMgr, pollingConfigMgr, s.IotCard, s.DeviceSimBinding, s.Device, deps.Logger)
iotCard.SetPollingCallback(pollingLifecycleSvc)
// 注入流量扣减回调,使手动刷新资产时能触发套餐流量扣减
usageService := packageSvc.NewUsageService(deps.DB, deps.Redis, s.PackageUsage, s.PackageUsageDailyRecord, s.DeviceSimBinding, deps.Logger)
iotCard.SetDataDeductor(usageService)
// 创建支付配置服务Order 和 Recharge 依赖)
wechatConfig := wechatConfigSvc.New(s.WechatConfig, s.Order, s.RechargeOrder, s.AgentRecharge, s.Payment, accountAudit, deps.Redis, deps.Logger)
@@ -203,6 +210,38 @@ func initServices(s *stores, deps *Dependencies) *services {
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)
walletOutbox := outbox.NewRepository()
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox)
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox), walletDebitEvents, nil))
agentRechargeService := agentRechargeSvc.New(
deps.DB,
s.AgentRecharge,
s.AgentWallet,
s.Shop,
wechatConfig,
accountAudit,
operationPassword,
deps.Redis,
deps.Logger,
)
agentRechargeService.SetAgentWalletPostingService(walletapp.NewPostingService(walletinfra.NewCreditEventWriter(walletOutbox), nil))
refundService := refundSvc.New(
deps.DB,
s.RefundRequest,
s.Order,
s.CommissionRecord,
s.AgentWallet,
s.AgentWalletTransaction,
stopResumeService,
device,
packageActivation,
s.IotCard,
s.Device,
s.AssetWallet,
deps.Logger,
)
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)
@@ -227,7 +266,7 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.Logger,
customerBinding,
),
Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role, s.AccountRole, s.AgentWallet),
Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role),
Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger),
ShopCommission: shopCommission,
CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest),
@@ -284,38 +323,13 @@ func initServices(s *stores, deps *Dependencies) *services {
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),
StopResumeService: stopResumeService,
WechatConfig: wechatConfig,
AgentRecharge: agentRechargeSvc.New(
deps.DB,
s.AgentRecharge,
s.AgentWallet,
s.AgentWalletTransaction,
s.Shop,
wechatConfig,
accountAudit,
operationPassword,
deps.Redis,
deps.Logger,
),
PackageActivation: packageActivation,
TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage),
OperationPassword: operationPassword,
AgentOpenAPI: agentOpenAPI,
Refund: refundSvc.New(
deps.DB,
s.RefundRequest,
s.Order,
s.CommissionRecord,
s.AgentWallet,
s.AgentWalletTransaction,
stopResumeService,
device,
packageActivation,
s.IotCard,
s.Device,
s.AssetWallet,
deps.Logger,
),
CustomerBinding: customerBinding,
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient),
AgentRecharge: agentRechargeService,
PackageActivation: packageActivation,
TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage),
OperationPassword: operationPassword,
AgentOpenAPI: agentOpenAPI,
Refund: refundService,
CustomerBinding: customerBinding,
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient),
}
}

View File

@@ -24,6 +24,7 @@ type Handlers struct {
ClientRealname *app.ClientRealnameHandler
ClientDevice *app.ClientDeviceHandler
ClientRechargeOrder *app.ClientRechargeOrderHandler
ClientNotification *app.ClientNotificationHandler
Shop *admin.ShopHandler
ShopRole *admin.ShopRoleHandler
AdminAuth *admin.AuthHandler
@@ -37,6 +38,7 @@ type Handlers struct {
IotCard *admin.IotCardHandler
IotCardImport *admin.IotCardImportHandler
ExportTask *admin.ExportTaskHandler
Notification *admin.NotificationHandler
Device *admin.DeviceHandler
DeviceImport *admin.DeviceImportHandler
AssetAllocationRecord *admin.AssetAllocationRecordHandler

View File

@@ -1,6 +1,12 @@
package bootstrap
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"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
"github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
@@ -81,6 +87,19 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stores.DataCleanupLog,
deps.Logger,
)
cardObservationOutbox := outbox.NewRepository()
cardObservationService := cardObservationApp.NewService(
deps.DB,
cardObservationInfra.NewEventWriter(cardObservationOutbox),
cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger),
)
cardObservationIntegration := integrationlog.NewRepository(deps.DB)
cardObservationSeriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
cardObservationSeriesService := cardObservationApp.NewSeriesAttemptService(
cardObservationSeriesCoordinator,
cardObservationInfra.NewSeriesRunner(deps.DB, deps.GatewayClient, cardObservationService, cardObservationIntegration),
cardObservationInfra.NewSeriesAttemptLogger(cardObservationIntegration),
)
// 初始化订单服务(仅用于超时自动取消,不需要微信支付和队列客户端)
orderService := orderSvc.New(
@@ -108,6 +127,9 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stores.PersonalCustomer,
stores.PersonalCustomerPhone,
)
walletOutbox := outbox.NewRepository()
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox)
orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox), walletDebitEvents, nil))
// 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机
stopResumeService := iotCardSvc.NewStopResumeService(
@@ -125,6 +147,8 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
resetService.SetResumeCallback(stopResumeService)
return &queue.WorkerServices{
CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeriesService,
CommissionCalculation: commissionCalculationService,
CommissionStats: commissionStatsService,
UsageService: usageService,

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

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

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

View File

@@ -0,0 +1,2 @@
// Package wallet 定义代理主钱包的资金边界与信用额度不变量。
package wallet

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

View File

@@ -3,27 +3,14 @@ package gateway
import (
"strings"
cardobservation "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// ParseCardNetworkStatus 将 Gateway 卡状态转换为系统网络状态。
// 只有网关明确返回“正常”才视为开机;“准备”或“待激活”不能按正常卡展示。
func ParseCardNetworkStatus(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
return cardobservation.MapGatewayNetworkStatus(cardStatus, extend)
}
// IsGatewayCardStopped 判断 Gateway 是否明确返回停机状态。

View File

@@ -0,0 +1,146 @@
package admin
import (
"math"
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
notificationquery "github.com/break/junhong_cmp_fiber/internal/query/notification"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"go.uber.org/zap"
)
// NotificationHandler 提供当前后台账号的站内通知接口。
type NotificationHandler struct {
query *notificationquery.Query
readService *notificationapp.ReadService
validate *validator.Validate
}
// NewNotificationHandler 创建后台站内通知 Handler。
func NewNotificationHandler(query *notificationquery.Query, readService *notificationapp.ReadService, validate *validator.Validate) *NotificationHandler {
return &NotificationHandler{query: query, readService: readService, validate: validate}
}
// UnreadCount 查询当前后台账号的通知未读数。
// GET /api/admin/notifications/unread-count
func (h *NotificationHandler) UnreadCount(c *fiber.Ctx) error {
recipientID := middleware.GetUserIDFromContext(c.UserContext())
result, err := h.query.UnreadCount(c.UserContext(), recipientID)
if err != nil {
return err
}
return response.Success(c, result)
}
// UnreadSummary 查询当前后台账号的固定分类未读汇总。
// GET /api/admin/notifications/unread-summary
func (h *NotificationHandler) UnreadSummary(c *fiber.Ctx) error {
recipientID := middleware.GetUserIDFromContext(c.UserContext())
result, err := h.query.UnreadSummary(c.UserContext(), recipientID)
if err != nil {
return err
}
return response.Success(c, result)
}
// List 查询当前后台账号的未过期通知列表。
// GET /api/admin/notifications
func (h *NotificationHandler) List(c *fiber.Ctx) error {
var request dto.NotificationListRequest
if err := c.QueryParser(&request); err != nil {
logNotificationListValidationFailure(c, request, err)
return errors.New(errors.CodeInvalidParam)
}
if h.validate != nil {
if err := h.validate.Struct(request); err != nil {
logNotificationListValidationFailure(c, request, err)
return errors.New(errors.CodeInvalidParam)
}
}
recipientID := middleware.GetUserIDFromContext(c.UserContext())
result, err := h.query.List(c.UserContext(), recipientID, request)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
func logNotificationListValidationFailure(c *fiber.Ctx, request dto.NotificationListRequest, err error) {
logger.GetAppLogger().Warn("站内通知列表参数验证失败",
zap.String("method", c.Method()),
zap.String("path", c.Path()),
zap.Int("page", request.Page),
zap.Int("page_size", request.PageSize),
zap.Error(err),
)
}
// MarkRead 将当前后台账号的一条通知幂等标记为已读。
// PUT /api/admin/notifications/:id/read
func (h *NotificationHandler) MarkRead(c *fiber.Ctx) error {
notificationID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || notificationID == 0 || notificationID > math.MaxInt64 {
return errors.New(errors.CodeInvalidParam)
}
recipientID := middleware.GetUserIDFromContext(c.UserContext())
if err := h.readService.MarkRead(c.UserContext(), recipientID, uint(notificationID)); err != nil {
return err
}
return response.Success(c, dto.NotificationReadResponse{Success: true})
}
// Target 解析当前后台账号通知的受控结构化目标。
// GET /api/admin/notifications/:id/target
func (h *NotificationHandler) Target(c *fiber.Ctx) error {
notificationID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || notificationID == 0 || notificationID > math.MaxInt64 {
return errors.New(errors.CodeInvalidParam)
}
recipientID := middleware.GetUserIDFromContext(c.UserContext())
result, err := h.query.Target(c.UserContext(), recipientID, uint(notificationID))
if err != nil {
return err
}
return response.Success(c, result)
}
// MarkAllRead 将当前后台账号全部或指定类别通知幂等标记为已读。
// PUT /api/admin/notifications/read-all
func (h *NotificationHandler) MarkAllRead(c *fiber.Ctx) error {
var request dto.NotificationReadAllRequest
if len(c.Body()) > 0 {
if err := c.BodyParser(&request); err != nil {
logNotificationReadAllValidationFailure(c, request, err)
return errors.New(errors.CodeInvalidParam)
}
}
if h.validate != nil {
if err := h.validate.Struct(request); err != nil {
logNotificationReadAllValidationFailure(c, request, err)
return errors.New(errors.CodeInvalidParam)
}
}
recipientID := middleware.GetUserIDFromContext(c.UserContext())
result, err := h.readService.MarkAllRead(c.UserContext(), recipientID, request)
if err != nil {
return err
}
return response.Success(c, result)
}
func logNotificationReadAllValidationFailure(c *fiber.Ctx, request dto.NotificationReadAllRequest, err error) {
logger.GetAppLogger().Warn("站内通知批量已读参数验证失败",
zap.String("method", c.Method()),
zap.String("path", c.Path()),
zap.Bool("category_present", request.Category != ""),
zap.Error(err),
)
}

View File

@@ -11,14 +11,21 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/response"
roleApp "github.com/break/junhong_cmp_fiber/internal/application/role"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
roleService "github.com/break/junhong_cmp_fiber/internal/service/role"
)
// RoleHandler 角色 Handler
type RoleHandler struct {
service *roleService.Service
validator *validator.Validate
service *roleService.Service
defaultCreditService *roleApp.DefaultCreditService
validator *validator.Validate
}
// SetDefaultCreditService 设置角色默认信用模板应用服务。
func (h *RoleHandler) SetDefaultCreditService(service *roleApp.DefaultCreditService) {
h.defaultCreditService = service
}
// NewRoleHandler 创建角色 Handler
@@ -254,3 +261,36 @@ func (h *RoleHandler) UpdateStatus(c *fiber.Ctx) error {
return response.Success(c, nil)
}
// UpdateDefaultCredit 更新客户角色的新建代理默认信用模板。
// PUT /api/admin/roles/:id/default-credit
func (h *RoleHandler) UpdateDefaultCredit(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "无效的角色 ID")
}
var req dto.UpdateRoleDefaultCreditRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.validator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("角色默认信用参数验证失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
if h.defaultCreditService == nil {
return errors.New(errors.CodeInternalError, "角色默认信用服务未配置")
}
role, err := h.defaultCreditService.Update(c.UserContext(), uint(id), *req.CreditEnabled, *req.CreditLimit)
if err != nil {
return err
}
return response.Success(c, dto.RoleDefaultCreditResponse{
RoleID: role.ID,
CreditEnabled: role.DefaultCreditEnabled,
CreditLimit: role.DefaultCreditLimit,
Scope: "new_shops_only",
AffectsExistingWallets: false,
})
}

View File

@@ -1,13 +1,17 @@
package admin
import (
"math"
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
shopapp "github.com/break/junhong_cmp_fiber/internal/application/shop"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
shopquery "github.com/break/junhong_cmp_fiber/internal/query/shop"
shopService "github.com/break/junhong_cmp_fiber/internal/service/shop"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
@@ -17,8 +21,32 @@ import (
// ShopHandler 店铺管理处理器。
type ShopHandler struct {
service *shopService.Service
validator *validator.Validate
service *shopService.Service
createService *shopapp.CreateService
updateService *shopapp.UpdateService
ownerQuery *shopquery.BusinessOwnerQuery
changeCreditService *walletapp.ChangeCreditService
validator *validator.Validate
}
// SetChangeCreditService 注入既有店铺实际信用额度调整用例。
func (h *ShopHandler) SetChangeCreditService(service *walletapp.ChangeCreditService) {
h.changeCreditService = service
}
// SetCreateService 注入店铺创建 Application 事务脚本。
func (h *ShopHandler) SetCreateService(service *shopapp.CreateService) {
h.createService = service
}
// SetUpdateService 注入店铺更新 Application 事务脚本。
func (h *ShopHandler) SetUpdateService(service *shopapp.UpdateService) {
h.updateService = service
}
// SetBusinessOwnerQuery 注入店铺业务员归属 Query。
func (h *ShopHandler) SetBusinessOwnerQuery(query *shopquery.BusinessOwnerQuery) {
h.ownerQuery = query
}
// NewShopHandler 创建店铺管理处理器。
@@ -49,7 +77,10 @@ func (h *ShopHandler) List(c *fiber.Ctx) error {
normalizeShopListPagination(&req)
shops, total, err := h.service.ListShopResponses(c.UserContext(), &req)
if h.ownerQuery == nil {
return errors.New(errors.CodeInternalError, "店铺业务员查询服务未配置")
}
shops, total, err := h.ownerQuery.List(c.UserContext(), req)
if err != nil {
return err
}
@@ -66,7 +97,83 @@ func (h *ShopHandler) logListValidationFailure(c *fiber.Ctx, err error) {
logger.GetAppLogger().Warn("店铺列表参数验证失败",
zap.String("method", c.Method()),
zap.String("path", c.Path()),
zap.String("query", c.Context().QueryArgs().String()),
zap.Bool("page_present", c.Context().QueryArgs().Has("page")),
zap.Bool("page_size_present", c.Context().QueryArgs().Has("page_size")),
zap.Error(err),
)
}
// Detail 查询店铺详情。
// GET /api/admin/shops/:id
func (h *ShopHandler) Detail(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 || id > math.MaxInt64 {
return errors.New(errors.CodeInvalidParam)
}
if h.ownerQuery == nil {
return errors.New(errors.CodeInternalError, "店铺业务员查询服务未配置")
}
result, err := h.ownerQuery.Detail(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdateCreditLimit 调整既有店铺代理主钱包实际信用额度。
// PUT /api/admin/shops/:id/credit-limit
func (h *ShopHandler) UpdateCreditLimit(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 || id > math.MaxInt64 {
return errors.New(errors.CodeInvalidParam)
}
var request dto.UpdateShopCreditLimitRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if h.validator == nil || h.validator.Struct(&request) != nil {
return errors.New(errors.CodeInvalidParam)
}
if h.changeCreditService == nil {
return errors.New(errors.CodeInternalError, "店铺信用额度服务未配置")
}
result, err := h.changeCreditService.Execute(c.UserContext(), uint(id), *request.CreditEnabled, *request.CreditLimit, *request.Version)
if err != nil {
return err
}
return response.Success(c, result)
}
// BusinessOwnerCandidates 查询当前可人工绑定的平台业务员候选。
// GET /api/admin/shops/business-owner-candidates
func (h *ShopHandler) BusinessOwnerCandidates(c *fiber.Ctx) error {
var request dto.ShopBusinessOwnerCandidateRequest
if err := c.QueryParser(&request); err != nil {
h.logBusinessOwnerCandidateValidationFailure(c, err)
return errors.New(errors.CodeInvalidParam)
}
if h.validator == nil {
return errors.New(errors.CodeInternalError, "业务员候选校验器未配置")
}
if err := h.validator.Struct(request); err != nil {
h.logBusinessOwnerCandidateValidationFailure(c, err)
return errors.New(errors.CodeInvalidParam)
}
if h.ownerQuery == nil {
return errors.New(errors.CodeInternalError, "店铺业务员查询服务未配置")
}
items, total, page, pageSize, err := h.ownerQuery.Candidates(c.UserContext(), request)
if err != nil {
return err
}
return response.SuccessWithPagination(c, items, total, page, pageSize)
}
func (h *ShopHandler) logBusinessOwnerCandidateValidationFailure(c *fiber.Ctx, err error) {
logger.GetAppLogger().Warn("店铺业务员候选参数验证失败",
zap.String("method", c.Method()),
zap.String("path", c.Path()),
zap.Bool("keyword_present", c.Context().QueryArgs().Has("keyword")),
zap.Error(err),
)
}
@@ -87,8 +194,20 @@ func (h *ShopHandler) Create(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if h.validator == nil {
return errors.New(errors.CodeInternalError, "店铺创建校验器未配置")
}
if err := h.validator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("店铺创建参数验证失败",
zap.String("method", c.Method()), zap.String("path", c.Path()), zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
shop, err := h.service.Create(c.UserContext(), &req)
createService := h.createService
if createService == nil {
return errors.New(errors.CodeInternalError, "店铺创建服务未配置")
}
shop, err := createService.Create(c.UserContext(), &req)
if err != nil {
return err
}
@@ -108,8 +227,19 @@ func (h *ShopHandler) Update(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if h.validator == nil {
return errors.New(errors.CodeInternalError, "店铺更新校验器未配置")
}
if err := h.validator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("店铺更新参数验证失败",
zap.String("method", c.Method()), zap.String("path", c.Path()), zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
shop, err := h.service.Update(c.UserContext(), uint(id), &req)
if h.updateService == nil {
return errors.New(errors.CodeInternalError, "店铺更新服务未配置")
}
shop, err := h.updateService.Update(c.UserContext(), uint(id), &req)
if err != nil {
return err
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop"
shopCommissionService "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
@@ -13,7 +14,8 @@ import (
// ShopCommissionHandler 代理商资金管理 Handler
type ShopCommissionHandler struct {
service *shopCommissionService.Service
service *shopCommissionService.Service
fundSummaryQuery *shopQuery.FundSummaryQuery
}
// NewShopCommissionHandler 创建代理商资金管理 Handler
@@ -21,6 +23,11 @@ func NewShopCommissionHandler(service *shopCommissionService.Service) *ShopCommi
return &ShopCommissionHandler{service: service}
}
// SetFundSummaryQuery 注入代理商资金概况 Query。
func (h *ShopCommissionHandler) SetFundSummaryQuery(query *shopQuery.FundSummaryQuery) {
h.fundSummaryQuery = query
}
// ListFundSummary 代理商资金概况列表
// GET /api/admin/shops/fund-summary
func (h *ShopCommissionHandler) ListFundSummary(c *fiber.Ctx) error {
@@ -29,7 +36,10 @@ func (h *ShopCommissionHandler) ListFundSummary(c *fiber.Ctx) error {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.ListShopFundSummary(c.UserContext(), &req)
if h.fundSummaryQuery == nil {
return errors.New(errors.CodeInternalError, "代理商资金概况查询能力未配置")
}
result, err := h.fundSummaryQuery.List(c.UserContext(), req)
if err != nil {
return err
}

View File

@@ -0,0 +1,110 @@
package app
import (
"math"
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
notificationquery "github.com/break/junhong_cmp_fiber/internal/query/notification"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// ClientNotificationHandler 提供当前个人客户的简化站内通知接口。
type ClientNotificationHandler struct {
query *notificationquery.Query
readService *notificationapp.ReadService
validate *validator.Validate
}
// NewClientNotificationHandler 创建个人客户站内通知 Handler。
func NewClientNotificationHandler(query *notificationquery.Query, readService *notificationapp.ReadService, validate *validator.Validate) *ClientNotificationHandler {
return &ClientNotificationHandler{query: query, readService: readService, validate: validate}
}
// UnreadCount 查询当前个人客户的业务通知未读数。
// GET /api/c/v1/notifications/unread-count
func (h *ClientNotificationHandler) UnreadCount(c *fiber.Ctx) error {
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
result, err := h.query.PersonalUnreadCount(c.UserContext(), customerID)
if err != nil {
return err
}
return response.Success(c, result)
}
// List 查询当前个人客户的未过期业务通知列表。
// GET /api/c/v1/notifications
func (h *ClientNotificationHandler) List(c *fiber.Ctx) error {
var request dto.PersonalNotificationListRequest
if err := c.QueryParser(&request); err != nil {
logPersonalNotificationListValidationFailure(c, request, err)
return errors.New(errors.CodeInvalidParam)
}
if h.validate != nil {
if err := h.validate.Struct(request); err != nil {
logPersonalNotificationListValidationFailure(c, request, err)
return errors.New(errors.CodeInvalidParam)
}
}
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
result, err := h.query.PersonalList(c.UserContext(), customerID, request)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
// MarkAllRead 将当前个人客户可见的全部通知幂等标记为已读。
// PUT /api/c/v1/notifications/read-all
func (h *ClientNotificationHandler) MarkAllRead(c *fiber.Ctx) error {
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
result, err := h.readService.MarkAllPersonalRead(c.UserContext(), customerID)
if err != nil {
return err
}
return response.Success(c, result)
}
// MarkRead 将当前个人客户的一条通知幂等标记为已读。
// PUT /api/c/v1/notifications/:id/read
func (h *ClientNotificationHandler) MarkRead(c *fiber.Ctx) error {
notificationID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || notificationID == 0 || notificationID > math.MaxInt64 {
return errors.New(errors.CodeInvalidParam)
}
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
if err := h.readService.MarkPersonalRead(c.UserContext(), customerID, uint(notificationID)); err != nil {
return err
}
return response.Success(c, dto.NotificationReadResponse{Success: true})
}
func logPersonalNotificationListValidationFailure(c *fiber.Ctx, request dto.PersonalNotificationListRequest, err error) {
logger.GetAppLogger().Warn("个人客户站内通知列表参数验证失败",
zap.String("method", c.Method()),
zap.String("path", c.Path()),
zap.Int("page", request.Page),
zap.Int("page_size", request.PageSize),
zap.Error(err),
)
}

View File

@@ -27,7 +27,7 @@ import (
// AgentRechargeServiceInterface 代理充值服务接口
type AgentRechargeServiceInterface interface {
HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string) error
HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string, paidAmount int64) error
}
// WechatConfigServiceInterface 支付配置服务接口
@@ -170,7 +170,7 @@ func (h *PaymentHandler) dispatchWechatCallback(ctx context.Context, outTradeNo,
return fmt.Errorf("充值订单服务未配置,无法处理订单: %s", outTradeNo)
case strings.HasPrefix(outTradeNo, constants.AgentRechargeOrderPrefix):
if h.agentRechargeService != nil {
return h.agentRechargeService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat, transactionID)
return h.agentRechargeService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat, transactionID, paidAmount)
}
return fmt.Errorf("代理充值服务未配置,无法处理订单: %s", outTradeNo)
default:
@@ -473,7 +473,7 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
return c.Send(fuiou.BuildNotifyFailResponse("充值订单服务未配置"))
case strings.HasPrefix(orderNo, constants.AgentRechargeOrderPrefix):
if h.agentRechargeService != nil {
if err := h.agentRechargeService.HandlePaymentCallback(ctx, orderNo, "fuiou", notify.TransactionId); err != nil {
if err := h.agentRechargeService.HandlePaymentCallback(ctx, orderNo, model.ProviderTypeFuiou, notify.TransactionId, orderAmt); err != nil {
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
}
}

View File

@@ -0,0 +1,104 @@
package approval
import (
"context"
"time"
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// DecisionDeliveryStore 持久化标准决策消费事实并管理处理租约。
type DecisionDeliveryStore struct {
db *gorm.DB
}
// NewDecisionDeliveryStore 创建标准决策投递 Store。
func NewDecisionDeliveryStore(db *gorm.DB) *DecisionDeliveryStore {
return &DecisionDeliveryStore{db: db}
}
// Create 在审批状态事务中创建唯一标准决策投递事实。
func (s *DecisionDeliveryStore) Create(ctx context.Context, tx *gorm.DB, event approvalapp.TerminalDecisionEvent) error {
if tx == nil {
return errors.New(errors.CodeInternalError, "审批决策投递必须使用审批状态事务")
}
record := model.ApprovalDecisionDelivery{
InstanceID: event.InstanceID, Decision: event.Decision, EventID: event.EventID,
Status: constants.ApprovalDecisionDeliveryPending, CreatedAt: event.OccurredAt, UpdatedAt: event.OccurredAt,
}
if err := tx.WithContext(ctx).Create(&record).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建审批决策投递事实失败")
}
return nil
}
// Claim 领取待处理、失败或租约已过期的标准决策。
func (s *DecisionDeliveryStore) Claim(
ctx context.Context,
eventID string,
owner string,
now time.Time,
duration time.Duration,
) (bool, error) {
if s == nil || s.db == nil || eventID == "" || owner == "" || duration <= 0 {
return false, errors.New(errors.CodeInternalError, "审批决策处理租约 Store 未完整配置")
}
result := s.db.WithContext(ctx).Model(&model.ApprovalDecisionDelivery{}).
Where("event_id = ? AND (status IN ? OR (status = ? AND lease_expires_at <= ?))",
eventID,
[]int{constants.ApprovalDecisionDeliveryPending, constants.ApprovalDecisionDeliveryFailed},
constants.ApprovalDecisionDeliveryProcessing, now).
Updates(map[string]any{
"status": constants.ApprovalDecisionDeliveryProcessing,
"lease_owner": owner, "lease_expires_at": now.Add(duration), "updated_at": now,
})
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "领取审批决策处理租约失败")
}
return result.RowsAffected == 1, nil
}
// MarkSucceeded 标记当前租约的业务消费者已幂等处理成功。
func (s *DecisionDeliveryStore) MarkSucceeded(ctx context.Context, eventID string, owner string, now time.Time) (bool, error) {
if s == nil || s.db == nil {
return false, errors.New(errors.CodeInternalError, "审批决策处理租约 Store 未配置")
}
result := s.db.WithContext(ctx).Model(&model.ApprovalDecisionDelivery{}).
Where("event_id = ? AND status = ? AND lease_owner = ?", eventID, constants.ApprovalDecisionDeliveryProcessing, owner).
Updates(map[string]any{
"status": constants.ApprovalDecisionDeliverySucceeded, "processed_at": now,
"lease_owner": nil, "lease_expires_at": nil, "last_error": "", "updated_at": now,
})
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "完成审批决策业务处理失败")
}
return result.RowsAffected == 1, nil
}
// MarkFailed 记录当前租约的安全失败摘要并释放租约等待重试。
func (s *DecisionDeliveryStore) MarkFailed(
ctx context.Context,
eventID string,
owner string,
now time.Time,
errorSummary string,
) (bool, error) {
if s == nil || s.db == nil {
return false, errors.New(errors.CodeInternalError, "审批决策处理租约 Store 未配置")
}
result := s.db.WithContext(ctx).Model(&model.ApprovalDecisionDelivery{}).
Where("event_id = ? AND status = ? AND lease_owner = ?", eventID, constants.ApprovalDecisionDeliveryProcessing, owner).
Updates(map[string]any{
"status": constants.ApprovalDecisionDeliveryFailed, "retry_count": gorm.Expr("retry_count + 1"),
"lease_owner": nil, "lease_expires_at": nil, "last_error": errorSummary, "updated_at": now,
})
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "记录审批决策业务处理失败")
}
return result.RowsAffected == 1, nil
}

View File

@@ -0,0 +1,112 @@
// Package approval 实现通用审批实例的 PostgreSQL 持久化 Adapter。
package approval
import (
"context"
stderrors "errors"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvaldomain "github.com/break/junhong_cmp_fiber/internal/domain/approval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// Repository 实现通用审批实例写侧仓储。
type Repository struct {
db *gorm.DB
}
// RepositoryProvider 为 Application 用例创建事务作用域 Repository。
type RepositoryProvider struct{}
// NewRepositoryProvider 创建通用审批 Repository Provider。
func NewRepositoryProvider() *RepositoryProvider {
return &RepositoryProvider{}
}
// ForDB 使用指定数据库会话创建领域 Repository。
func (p *RepositoryProvider) ForDB(db *gorm.DB) approvaldomain.Repository {
return NewRepository(db)
}
// GetForUpdate 在当前事务中锁定并读取通用审批实例。
func (r *Repository) GetForUpdate(ctx context.Context, instanceID uint) (*approvaldomain.Instance, error) {
if r == nil || r.db == nil {
return nil, errors.New(errors.CodeInternalError, "通用审批实例 Repository 未配置数据库会话")
}
var record model.ApprovalInstance
err := r.db.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", instanceID).First(&record).Error
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取通用审批实例失败")
}
return instanceFromModel(record), nil
}
// SaveDecision 以旧状态和旧版本为条件保存标准决策,防止并发重复终态。
func (r *Repository) SaveDecision(
ctx context.Context,
instance *approvaldomain.Instance,
expectedStatus int,
expectedVersion int,
) (bool, error) {
if r == nil || r.db == nil || instance == nil {
return false, errors.New(errors.CodeInternalError, "通用审批实例 Repository 未完整配置")
}
result := r.db.WithContext(ctx).Model(&model.ApprovalInstance{}).
Where("id = ? AND status = ? AND version = ?", instance.ID, expectedStatus, expectedVersion).
Updates(map[string]any{
"status": instance.Status, "decision_snapshot": datatypes.JSON(instance.DecisionSnapshot),
"status_changed_at": instance.StatusChangedAt, "version": instance.Version, "updated_at": instance.UpdatedAt,
})
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "保存通用审批决策失败")
}
return result.RowsAffected == 1, nil
}
func instanceFromModel(record model.ApprovalInstance) *approvaldomain.Instance {
return &approvaldomain.Instance{
ID: record.ID, BusinessType: record.BusinessType, BusinessID: record.BusinessID,
SubmitterAccountID: record.SubmitterAccountID, SubmitterSnapshot: append([]byte(nil), record.SubmitterSnapshot...),
Provider: record.Provider, ExternalRef: record.ExternalRef, Status: record.Status,
RequestSnapshot: append([]byte(nil), record.RequestSnapshot...),
DecisionSnapshot: append([]byte(nil), record.DecisionSnapshot...), CorrelationID: record.CorrelationID,
Version: record.Version, StatusChangedAt: record.StatusChangedAt,
CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt,
}
}
// NewRepository 创建通用审批实例 PostgreSQL Repository。
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
// Create 使用 Repository 持有的数据库会话创建一条唯一通用审批实例。
// 需要原子写入业务事实时,调用方必须以当前 GORM 事务创建 Repository。
func (r *Repository) Create(ctx context.Context, instance *approvaldomain.Instance) error {
if r == nil || r.db == nil {
return stderrors.New("通用审批实例 Repository 未配置数据库会话")
}
if instance == nil {
return stderrors.New("通用审批实例不能为空")
}
record := model.ApprovalInstance{
BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: datatypes.JSON(instance.SubmitterSnapshot),
Provider: instance.Provider, ExternalRef: instance.ExternalRef, Status: instance.Status,
RequestSnapshot: datatypes.JSON(instance.RequestSnapshot), DecisionSnapshot: datatypes.JSON(instance.DecisionSnapshot),
CorrelationID: instance.CorrelationID, Version: instance.Version, StatusChangedAt: instance.StatusChangedAt,
CreatedAt: instance.CreatedAt, UpdatedAt: instance.UpdatedAt,
}
if err := r.db.WithContext(ctx).Create(&record).Error; err != nil {
return err
}
instance.ID = record.ID
return nil
}

View File

@@ -0,0 +1,38 @@
package approval
import (
"context"
"strconv"
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SubmissionEventWriter 将通用审批申请写入公共 Outbox。
type SubmissionEventWriter struct {
outbox *outbox.Repository
}
// NewSubmissionEventWriter 创建通用审批申请 Outbox Writer。
func NewSubmissionEventWriter(repository *outbox.Repository) *SubmissionEventWriter {
return &SubmissionEventWriter{outbox: repository}
}
// Append 在调用方业务事务中追加渠道提交事件。
func (w *SubmissionEventWriter) Append(ctx context.Context, tx *gorm.DB, event approvalapp.SubmissionRequestedEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "审批申请 Outbox Writer 未配置")
}
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeApprovalSubmissionRequested,
PayloadVersion: constants.ApprovalSubmissionPayloadVersionV1,
AggregateType: "approval", AggregateID: strconv.FormatUint(uint64(event.InstanceID), 10),
ResourceType: event.BusinessType, ResourceID: strconv.FormatUint(uint64(event.BusinessID), 10),
BusinessKey: event.EventID, CorrelationID: event.CorrelationID, Payload: event,
})
return err
}

View File

@@ -0,0 +1,73 @@
package approval
import (
"context"
"strconv"
"github.com/bytedance/sonic"
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
approvaldomain "github.com/break/junhong_cmp_fiber/internal/domain/approval"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// TerminalEventWriter 将通用审批标准决策写入公共 Outbox。
type TerminalEventWriter struct {
outbox *outbox.Repository
}
// NewTerminalEventWriter 创建通用审批标准决策 Outbox Writer。
func NewTerminalEventWriter(repository *outbox.Repository) *TerminalEventWriter {
return &TerminalEventWriter{outbox: repository}
}
// Append 在审批状态事务中追加结构化标准决策事件。
func (w *TerminalEventWriter) Append(ctx context.Context, tx *gorm.DB, event approvalapp.TerminalDecisionEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "审批标准决策 Outbox Writer 未配置")
}
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeApprovalTerminalDecision,
PayloadVersion: constants.ApprovalTerminalDecisionPayloadVersionV1,
AggregateType: "approval", AggregateID: strconv.FormatUint(uint64(event.InstanceID), 10),
ResourceType: event.BusinessType, ResourceID: strconv.FormatUint(uint64(event.BusinessID), 10),
BusinessKey: event.EventID, CorrelationID: event.CorrelationID, Payload: event,
})
return err
}
// TerminalDecisionConsumer 将公共 Outbox 信封转换为渠道无关业务决策。
type TerminalDecisionConsumer struct {
dispatcher *approvalapp.DecisionDispatcher
}
// NewTerminalDecisionConsumer 创建审批标准决策消费者 Adapter。
func NewTerminalDecisionConsumer(dispatcher *approvalapp.DecisionDispatcher) *TerminalDecisionConsumer {
return &TerminalDecisionConsumer{dispatcher: dispatcher}
}
// Consume 校验事件类型、版本和稳定身份后交给业务分发器。
func (c *TerminalDecisionConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.dispatcher == nil {
return errors.New(errors.CodeInternalError, "审批标准决策消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeApprovalTerminalDecision ||
envelope.PayloadVersion != constants.ApprovalTerminalDecisionPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "审批标准决策事件类型或版本不受支持")
}
var event approvalapp.TerminalDecisionEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "审批标准决策事件载荷格式错误")
}
if event.EventID == "" || event.EventID != envelope.EventID || event.InstanceID == 0 ||
event.BusinessType == "" || event.BusinessID == 0 || event.CorrelationID == "" {
return errors.New(errors.CodeInvalidParam, "审批标准决策事件载荷不完整")
}
if _, err := approvaldomain.StatusForDecision(event.Decision); err != nil {
return err
}
return c.dispatcher.Consume(ctx, event)
}

View File

@@ -0,0 +1,336 @@
// Package cardobservation 提供卡观测的 Outbox、缓存和消费者适配器。
package cardobservation
import (
"context"
"strconv"
"time"
"github.com/bytedance/sonic"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
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"
)
func uintString(value uint) string {
return strconv.FormatUint(uint64(value), 10)
}
// EventWriter 将卡实名状态变化写入公共 Outbox。
type EventWriter struct {
outbox *outbox.Repository
}
// NewEventWriter 创建卡实名事件 Writer。
func NewEventWriter(repository *outbox.Repository) *EventWriter {
return &EventWriter{outbox: repository}
}
// Append 在调用方事务中追加卡实名状态变化事件。
func (w *EventWriter) AppendRealname(ctx context.Context, tx *gorm.DB, event cardapp.RealnameChangedEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "卡实名 Outbox Writer 未配置")
}
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeCardRealnameChanged,
PayloadVersion: constants.CardRealnameChangedPayloadVersionV1,
AggregateType: "iot_card", AggregateID: uintString(event.CardID),
ResourceType: constants.AssetTypeIotCard, ResourceID: uintString(event.CardID),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
Payload: event,
})
return err
}
// AppendTraffic 在调用方事务中追加卡流量正增量事件。
func (w *EventWriter) AppendTraffic(ctx context.Context, tx *gorm.DB, event cardapp.TrafficIncrementedEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "卡流量 Outbox Writer 未配置")
}
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeCardTrafficIncremented,
PayloadVersion: constants.CardTrafficIncrementedPayloadVersionV1,
AggregateType: "iot_card", AggregateID: uintString(event.CardID),
ResourceType: constants.AssetTypeIotCard, ResourceID: uintString(event.CardID),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
Payload: event,
})
return err
}
// AppendNetwork 在调用方事务中追加卡网络状态变化事件。
func (w *EventWriter) AppendNetwork(ctx context.Context, tx *gorm.DB, event cardapp.NetworkChangedEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "卡网络 Outbox Writer 未配置")
}
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeCardNetworkChanged,
PayloadVersion: constants.CardNetworkChangedPayloadVersionV1,
AggregateType: "iot_card", AggregateID: uintString(event.CardID),
ResourceType: constants.AssetTypeIotCard, ResourceID: uintString(event.CardID),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
Payload: event,
})
return err
}
// CacheInvalidator 在提交后删除轮询卡缓存和旧 Redis 逆转计数。
type CacheInvalidator struct {
redis *redis.Client
logger *zap.Logger
}
// NewCacheInvalidator 创建卡观测缓存失效适配器。
func NewCacheInvalidator(redisClient *redis.Client, logger *zap.Logger) *CacheInvalidator {
return &CacheInvalidator{redis: redisClient, logger: logger}
}
// Invalidate 删除可由数据库重建的缓存;失败只告警,不伪造事务回滚。
func (i *CacheInvalidator) Invalidate(ctx context.Context, cardID uint) {
if i == nil || i.redis == nil {
return
}
if err := i.redis.Del(ctx, constants.RedisPollingCardInfoKey(cardID), constants.RedisPollingRealnameReversalCountKey(cardID)).Err(); err != nil && i.logger != nil {
i.logger.Warn("卡实名事实已提交但缓存失效失败", zap.Uint("card_id", cardID), zap.Error(err))
}
}
// RealnameActivator 激活卡或设备待实名套餐。
type RealnameActivator interface {
ActivateByRealname(ctx context.Context, carrierType string, carrierID uint) error
}
// TrafficDeductor 按卡流量正增量扣减套餐流量。
type TrafficDeductor interface {
DeductDataUsage(ctx context.Context, carrierType string, carrierID uint, usageMB float64) error
}
// StopResumeEvaluator 根据卡的当前事实执行停复机评估。
type StopResumeEvaluator interface {
EvaluateAndAct(ctx context.Context, card *model.IotCard) error
}
// DeviceBindingReader 查询卡当前绑定的设备。
type DeviceBindingReader interface {
GetActiveBindingByCardID(ctx context.Context, cardID uint) (*model.DeviceSimBinding, error)
}
// RealnameChangedConsumer 消费实名变化事件并串联现有幂等业务副作用。
type RealnameChangedConsumer struct {
db *gorm.DB
activator RealnameActivator
binding DeviceBindingReader
evaluator StopResumeEvaluator
}
// NetworkChangedConsumer 消费网络状态变化并按当前权威事实执行停复机评估。
type NetworkChangedConsumer struct {
db *gorm.DB
evaluator StopResumeEvaluator
}
// NewNetworkChangedConsumer 创建卡网络状态变化事件消费者。
func NewNetworkChangedConsumer(db *gorm.DB, evaluator StopResumeEvaluator) *NetworkChangedConsumer {
return &NetworkChangedConsumer{db: db, evaluator: evaluator}
}
// Consume 校验网络事件并按当前卡事实执行评估。
func (c *NetworkChangedConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "卡网络事件消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeCardNetworkChanged || envelope.PayloadVersion != constants.CardNetworkChangedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡网络事件类型或版本不受支持")
}
var event cardapp.NetworkChangedEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "卡网络事件载荷无法解析")
}
if event.EventID != envelope.EventID || event.CardID == 0 || event.BeforeStatus == event.AfterStatus {
return errors.New(errors.CodeInvalidParam, "卡网络事件载荷不完整")
}
if c.evaluator == nil {
return nil
}
var card model.IotCard
if err := c.db.WithContext(ctx).Where("id = ?", event.CardID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询网络变化后的卡事实失败")
}
return c.evaluator.EvaluateAndAct(ctx, &card)
}
// NewRealnameChangedConsumer 创建卡实名变化事件消费者。
func NewRealnameChangedConsumer(db *gorm.DB, activator RealnameActivator, binding DeviceBindingReader, evaluator StopResumeEvaluator) *RealnameChangedConsumer {
return &RealnameChangedConsumer{db: db, activator: activator, binding: binding, evaluator: evaluator}
}
// Consume 校验载荷,并以当前权威卡事实执行可重入副作用。
func (c *RealnameChangedConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "卡实名事件消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeCardRealnameChanged || envelope.PayloadVersion != constants.CardRealnameChangedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡实名事件类型或版本不受支持")
}
var event cardapp.RealnameChangedEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "卡实名事件载荷无法解析")
}
if event.EventID != envelope.EventID || event.CardID == 0 || event.BeforeStatus == event.AfterStatus {
return errors.New(errors.CodeInvalidParam, "卡实名事件载荷不完整")
}
if event.FirstVerified && c.activator != nil {
if err := c.activator.ActivateByRealname(ctx, constants.AssetTypeIotCard, event.CardID); err != nil {
return err
}
if c.binding != nil {
binding, err := c.binding.GetActiveBindingByCardID(ctx, event.CardID)
if err == nil && binding != nil {
if err := c.activator.ActivateByRealname(ctx, constants.AssetTypeDevice, binding.DeviceID); err != nil {
return err
}
} else if err != nil && err != gorm.ErrRecordNotFound {
return errors.Wrap(errors.CodeDatabaseError, err, "查询实名卡绑定设备失败")
}
}
}
if c.evaluator == nil {
return nil
}
var card model.IotCard
if err := c.db.WithContext(ctx).Where("id = ?", event.CardID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询实名变化后的卡事实失败")
}
return c.evaluator.EvaluateAndAct(ctx, &card)
}
// TrafficIncrementedConsumer 消费卡流量正增量并推进幂等副作用进度。
type TrafficIncrementedConsumer struct {
db *gorm.DB
redis *redis.Client
deductor TrafficDeductor
evaluator StopResumeEvaluator
}
// NewTrafficIncrementedConsumer 创建卡流量正增量事件消费者。
func NewTrafficIncrementedConsumer(db *gorm.DB, redisClient *redis.Client, deductor TrafficDeductor, evaluator StopResumeEvaluator) *TrafficIncrementedConsumer {
return &TrafficIncrementedConsumer{db: db, redis: redisClient, deductor: deductor, evaluator: evaluator}
}
// Consume 按事件处理进度避免至少一次投递重复扣减套餐流量。
func (c *TrafficIncrementedConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.db == nil || c.redis == nil || c.deductor == nil {
return errors.New(errors.CodeInternalError, "卡流量事件消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeCardTrafficIncremented || envelope.PayloadVersion != constants.CardTrafficIncrementedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡流量事件类型或版本不受支持")
}
var event cardapp.TrafficIncrementedEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "卡流量事件载荷无法解析")
}
if event.EventID != envelope.EventID || event.CardID == 0 || event.IncrementMB <= 0 {
return errors.New(errors.CodeInvalidParam, "卡流量事件载荷不完整")
}
receipt, err := c.acquireTrafficEffect(ctx, event.EventID)
if err != nil {
return err
}
if receipt.Status == constants.CardObservationEffectStatusCompleted {
return nil
}
if receipt.Status == constants.CardObservationEffectStatusProcessing {
dailyKey := constants.RedisCardDailyTrafficKey(event.CardID, event.ObservedAt.Format("2006-01-02"))
pipe := c.redis.TxPipeline()
pipe.IncrByFloat(ctx, dailyKey, event.IncrementMB)
pipe.Expire(ctx, dailyKey, 48*time.Hour)
if _, err := pipe.Exec(ctx); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "记录卡日流量缓冲失败,处理结果未知")
}
if err := c.updateTrafficEffectStatus(ctx, receipt.ID, constants.CardObservationEffectStatusProcessing, constants.CardObservationEffectStatusDailySaved); err != nil {
return err
}
receipt.Status = constants.CardObservationEffectStatusDailySaved
}
if receipt.Status == constants.CardObservationEffectStatusDailySaved {
if err := c.updateTrafficEffectStatus(ctx, receipt.ID, constants.CardObservationEffectStatusDailySaved, constants.CardObservationEffectStatusProcessing); err != nil {
return err
}
if err := c.deductor.DeductDataUsage(ctx, constants.AssetTypeIotCard, event.CardID, event.IncrementMB); err != nil {
return err
}
if err := c.updateTrafficEffectStatus(ctx, receipt.ID, constants.CardObservationEffectStatusProcessing, constants.CardObservationEffectStatusDeducted); err != nil {
return err
}
receipt.Status = constants.CardObservationEffectStatusDeducted
}
if c.evaluator != nil {
var card model.IotCard
if err := c.db.WithContext(ctx).Where("id = ?", event.CardID).First(&card).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询流量变化后的卡事实失败")
}
if err := c.evaluator.EvaluateAndAct(ctx, &card); err != nil {
return err
}
}
if err := c.updateTrafficEffectStatus(ctx, receipt.ID, constants.CardObservationEffectStatusDeducted, constants.CardObservationEffectStatusCompleted); err != nil {
return err
}
return nil
}
func (c *TrafficIncrementedConsumer) acquireTrafficEffect(ctx context.Context, eventID string) (*model.CardObservationEffect, error) {
var receipt model.CardObservationEffect
err := c.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("event_id = ?", eventID).First(&receipt).Error
if err == gorm.ErrRecordNotFound {
receipt = model.CardObservationEffect{
EventID: eventID, EffectType: constants.CardObservationEffectTypeTrafficIncrement,
Status: constants.CardObservationEffectStatusProcessing,
}
return tx.Create(&receipt).Error
}
if err != nil {
return err
}
switch receipt.Status {
case constants.CardObservationEffectStatusCompleted, constants.CardObservationEffectStatusDailySaved, constants.CardObservationEffectStatusDeducted:
return nil
case constants.CardObservationEffectStatusProcessing:
return errors.New(errors.CodeConflict, "卡流量副作用仍在处理或结果未知")
default:
return tx.Model(&receipt).Where("status = ?", constants.CardObservationEffectStatusPending).
Update("status", constants.CardObservationEffectStatusProcessing).Error
}
})
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "获取卡流量副作用处理权失败")
}
return &receipt, nil
}
func (c *TrafficIncrementedConsumer) updateTrafficEffectStatus(ctx context.Context, id uint, beforeStatus, afterStatus int) error {
result := c.db.WithContext(ctx).Model(&model.CardObservationEffect{}).
Where("id = ? AND status = ?", id, beforeStatus).
Update("status", afterStatus)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡流量副作用进度失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "卡流量副作用进度已被其他消费者更新")
}
return nil
}

View File

@@ -0,0 +1,185 @@
package cardobservation
import (
"context"
"strconv"
"time"
"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/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SeriesCoordinator 使用 Redis 原子协调活跃序列、幂等尝试和实际请求互斥。
type SeriesCoordinator struct {
redis *redis.Client
now func() time.Time
}
// NewSeriesCoordinator 创建观测序列 Redis 协调器。
func NewSeriesCoordinator(redisClient *redis.Client) *SeriesCoordinator {
return &SeriesCoordinator{redis: 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) {
if c == nil || c.redis == nil {
return "", time.Time{}, false, errors.New(errors.CodeInternalError, "卡观测序列 Redis 未配置")
}
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])
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, "保留卡观测序列失败")
}
baseTimeMS, parseErr := strconv.ParseInt(result[1], 10, 64)
if parseErr != nil {
return "", time.Time{}, false, errors.Wrap(errors.CodeInternalError, parseErr, "解析卡观测序列基准时间失败")
}
return result[0], time.UnixMilli(baseTimeMS).UTC(), result[2] == "1", nil
}
// IsCompleted 判断序列是否已由预期状态或回调提前完成。
func (c *SeriesCoordinator) IsCompleted(ctx context.Context, seriesID string) (bool, error) {
exists, err := c.redis.Exists(ctx, constants.RedisCardObservationSeriesCompletedKey(seriesID)).Result()
if err != nil {
return false, errors.Wrap(errors.CodeInternalError, err, "读取卡观测序列完成状态失败")
}
return exists > 0, nil
}
// ClaimAttempt 以 series_id + attempt 原子保证任务只执行一次。
func (c *SeriesCoordinator) ClaimAttempt(ctx context.Context, seriesID string, attempt int) (bool, error) {
claimed, err := c.redis.SetNX(ctx, constants.RedisCardObservationAttemptKey(seriesID, attempt), "processing", constants.CardObservationSeriesResultTTL).Result()
if err != nil {
return false, errors.Wrap(errors.CodeInternalError, err, "领取卡观测序列尝试失败")
}
return claimed, nil
}
// 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))
}
}
lastKey := constants.RedisCardObservationLastRequestKey(provider, payload.SyncType, payload.ResourceID)
nowMS := c.now().UTC().UnixMilli()
result, err := c.redis.Eval(ctx, `
if redis.call('EXISTS', KEYS[1]) == 1 then return {-1} end
local now = tonumber(ARGV[2])
local minimum = tonumber(ARGV[3])
local last = tonumber(redis.call('GET', KEYS[2]) or '0')
local wait = math.max(0, last + minimum - now)
redis.call('PSETEX', KEYS[1], ARGV[4], ARGV[1])
redis.call('PSETEX', KEYS[2], minimum * 3, now + wait)
return {wait}
`, []string{lockKey, lastKey}, token, nowMS, constants.CardObservationGatewayMinInterval.Milliseconds(), constants.CardObservationGatewayLockTTL.Milliseconds()).Int64Slice()
if err != nil || len(result) != 1 {
return nil, 0, false, errors.Wrap(errors.CodeInternalError, err, "获取卡观测 Gateway 请求互斥失败")
}
if result[0] < 0 {
return func() {}, 0, false, nil
}
release := func() {
_, _ = c.redis.Eval(context.Background(), `
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end
return 0
`, []string{lockKey}, token).Result()
}
return release, time.Duration(result[0]) * 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 {
return errors.Wrap(errors.CodeInternalError, err, "完成卡观测序列尝试失败")
}
if payload.Attempt < constants.CardObservationSeriesAttemptCount {
return nil
}
return c.releaseActiveSeries(ctx, payload, false)
}
// CompleteSeries 提前完成当前及剩余任务,并释放同场景合并键。
func (c *SeriesCoordinator) CompleteSeries(ctx context.Context, payload cardapp.SeriesTaskPayload) error {
if err := c.releaseActiveSeries(ctx, payload, true); err != nil {
return err
}
pipe := c.redis.TxPipeline()
for attempt := payload.Attempt; attempt <= constants.CardObservationSeriesAttemptCount; attempt++ {
pipe.Set(ctx, constants.RedisCardObservationAttemptKey(payload.SeriesID, attempt), "completed", constants.CardObservationSeriesResultTTL)
}
if _, err := pipe.Exec(ctx); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "标记卡观测剩余尝试完成失败")
}
return nil
}
func (c *SeriesCoordinator) releaseActiveSeries(ctx context.Context, payload cardapp.SeriesTaskPayload, completed bool) error {
seriesKey := constants.RedisCardObservationSeriesKey(payload.Scene, payload.ResourceType, payload.ResourceID, payload.SyncType)
indexKey := constants.RedisCardObservationSeriesIndexKey(payload.ResourceType, payload.ResourceID, payload.SyncType)
completedValue := "0"
if completed {
completedValue = "1"
}
if _, err := c.redis.Eval(ctx, `
if ARGV[2] == '1' then redis.call('SET', KEYS[3], '1', 'EX', ARGV[3]) end
if redis.call('HGET', KEYS[1], 'series_id') == ARGV[1] then
redis.call('DEL', KEYS[1])
redis.call('SREM', KEYS[2], KEYS[1])
end
return 1
`, []string{seriesKey, indexKey, constants.RedisCardObservationSeriesCompletedKey(payload.SeriesID)}, payload.SeriesID, completedValue, int(constants.CardObservationSeriesResultTTL.Seconds())).Result(); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "释放卡观测活跃序列失败")
}
return nil
}
// CompleteResourceSeries 提前完成同资源、同步类型下所有场景的活跃序列。
func (c *SeriesCoordinator) CompleteResourceSeries(ctx context.Context, resourceType, resourceID, syncType string) error {
indexKey := constants.RedisCardObservationSeriesIndexKey(resourceType, resourceID, syncType)
seriesKeys, err := c.redis.SMembers(ctx, indexKey).Result()
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "读取卡观测活跃序列索引失败")
}
for _, seriesKey := range seriesKeys {
seriesID, getErr := c.redis.HGet(ctx, seriesKey, "series_id").Result()
if getErr == redis.Nil {
_ = c.redis.SRem(ctx, indexKey, seriesKey).Err()
continue
}
if getErr != nil {
return errors.Wrap(errors.CodeInternalError, getErr, "读取卡观测活跃序列失败")
}
pipe := c.redis.TxPipeline()
pipe.Set(ctx, constants.RedisCardObservationSeriesCompletedKey(seriesID), "1", constants.CardObservationSeriesResultTTL)
pipe.Del(ctx, seriesKey)
pipe.SRem(ctx, indexKey, seriesKey)
if _, pipeErr := pipe.Exec(ctx); pipeErr != nil {
return errors.Wrap(errors.CodeInternalError, pipeErr, "提前完成卡观测资源序列失败")
}
}
return nil
}
var _ cardapp.SeriesCoordinator = (*SeriesCoordinator)(nil)
func formatCardID(cardID uint) string {
return strconv.FormatUint(uint64(cardID), 10)
}

View File

@@ -0,0 +1,259 @@
package cardobservation
import (
"context"
stderrors "errors"
"strconv"
"strings"
"time"
"gorm.io/gorm"
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/integrationlog"
"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"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SeriesAttemptLogger 将未发送的序列结果写入统一 Integration Log。
type SeriesAttemptLogger struct {
repository *integrationlog.Repository
}
// NewSeriesAttemptLogger 创建未发送尝试日志适配器。
func NewSeriesAttemptLogger(repository *integrationlog.Repository) *SeriesAttemptLogger {
return &SeriesAttemptLogger{repository: repository}
}
// Record 写入互斥、限频或提前完成结果。
func (l *SeriesAttemptLogger) Record(ctx context.Context, payload cardapp.SeriesTaskPayload, result, reason string) error {
if l == nil || l.repository == nil {
return apperrors.New(apperrors.CodeInternalError, "卡观测 Integration Log 未配置")
}
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{
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,
Metadata: map[string]any{"reason": reason, "sync_type": payload.SyncType},
})
return err
}
// RecordMerged 记录同场景重复触发被合并,不创建第二组三任务。
func (l *SeriesAttemptLogger) RecordMerged(ctx context.Context, request cardapp.SeriesRequest, seriesID string) error {
resourceID := request.ResourceID
source, scene := request.Source, request.Scene
now := time.Now().UTC()
_, err := l.repository.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderGateway, Direction: constants.IntegrationDirectionOutbound,
Operation: operationForSyncType(request.SyncType), ResourceType: request.ResourceType,
ResourceID: &resourceID, TriggerSource: &source, TriggerScene: &scene, TriggerSeries: &seriesID,
ScheduledAt: &now, Attempt: 1, InitialResult: constants.IntegrationResultMerged,
RequestID: optionalText(request.RequestID), CorrelationID: optionalText(request.CorrelationID),
Metadata: map[string]any{"reason": "同场景未结束序列已存在", "sync_type": request.SyncType},
})
return err
}
// SeriesRunner 负责卡本地预期判断、Gateway 查询和公共观测应用。
type SeriesRunner struct {
db *gorm.DB
gateway *gateway.Client
observation *cardapp.Service
integration *integrationlog.Repository
carrier *postgres.CarrierStore
}
// NewSeriesRunner 创建卡观测序列 Gateway 执行器。
func NewSeriesRunner(db *gorm.DB, gatewayClient *gateway.Client, observation *cardapp.Service, integration *integrationlog.Repository) *SeriesRunner {
return &SeriesRunner{db: db, gateway: gatewayClient, observation: observation, integration: integration, carrier: postgres.NewCarrierStore(db)}
}
// Provider 返回用于请求互斥的运营商接入标识。
func (r *SeriesRunner) Provider(ctx context.Context, payload cardapp.SeriesTaskPayload) (string, error) {
card, err := r.loadCard(ctx, payload)
if err != nil {
return "", err
}
provider := strings.ToLower(strings.TrimSpace(card.CarrierType))
if provider == "" {
provider = "carrier-" + strconv.FormatUint(uint64(card.CarrierID), 10)
}
return provider, nil
}
// ExpectationMet 在访问 Gateway 前读取本地权威快照。
func (r *SeriesRunner) ExpectationMet(ctx context.Context, payload cardapp.SeriesTaskPayload) (bool, error) {
expected := strings.ToLower(strings.TrimSpace(payload.ExpectedValue))
if expected == "" {
return false, nil
}
card, err := r.loadCard(ctx, payload)
if err != nil {
return false, err
}
switch payload.SyncType {
case constants.CardObservationSyncTypeRealname:
return (expected == "verified" || expected == "1") && card.RealNameStatus == constants.RealNameStatusVerified, nil
case constants.CardObservationSyncTypeNetwork:
if expected == "online" || expected == "1" {
return card.NetworkStatus == constants.NetworkStatusOnline, nil
}
if expected == "offline" || expected == "stopped" || expected == "0" {
return card.NetworkStatus == constants.NetworkStatusOffline, nil
}
}
return false, nil
}
// Run 执行一次 Gateway 查询,并将结果交给公共卡观测应用服务。
func (r *SeriesRunner) Run(ctx context.Context, payload cardapp.SeriesTaskPayload) (cardapp.RunResult, error) {
if r == nil || r.db == nil || r.gateway == nil || r.observation == nil || r.integration == nil {
return cardapp.RunResult{}, apperrors.New(apperrors.CodeInternalError, "卡观测序列 Gateway 能力未完整配置")
}
card, err := r.loadCard(ctx, payload)
if err != nil {
return cardapp.RunResult{}, err
}
attempt, err := r.startAttempt(ctx, payload, card)
if err != nil {
return cardapp.RunResult{}, err
}
startedAt := time.Now()
result, runErr := r.queryAndApply(ctx, payload, card, attempt.IntegrationID)
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) 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)
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{"card_id": card.ID, "sync_type": payload.SyncType},
RequestID: optionalText(payload.RequestID), CorrelationID: optionalText(payload.CorrelationID),
})
}
func (r *SeriesRunner) queryAndApply(ctx context.Context, payload cardapp.SeriesTaskPayload, card *model.IotCard, observationID string) (cardapp.RunResult, error) {
metadata := carddomain.ObservationMetadata{
ObservationID: observationID, Source: constants.CardObservationSourceBusinessEvent,
Scene: payload.Scene, ObservedAt: time.Now().UTC(), RequestID: payload.RequestID,
CorrelationID: payload.CorrelationID,
}
switch payload.SyncType {
case constants.CardObservationSyncTypeRealname:
response, err := r.gateway.QueryRealnameStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
if err != nil {
return cardapp.RunResult{}, err
}
decision, err := r.observation.ApplyCardObservation(ctx, carddomain.RealnameObservation{CardID: card.ID, Verified: response.RealStatus, Metadata: metadata})
return cardapp.RunResult{StateChanged: decision.StatusChanged}, err
case constants.CardObservationSyncTypeTraffic:
response, err := r.gateway.QueryFlow(ctx, &gateway.FlowQueryReq{CardNo: card.ICCID})
if err != nil {
return cardapp.RunResult{}, err
}
decision, err := r.observation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{
CardID: card.ID, GatewayReadingMB: float64(response.Used), ResetDay: r.carrier.GetDataResetDay(ctx, card.CarrierID), Metadata: metadata,
})
return cardapp.RunResult{StateChanged: decision.IncrementMB > 0 || decision.CrossMonth}, err
case constants.CardObservationSyncTypeNetwork:
response, err := r.gateway.QueryCardStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
if err != nil {
return cardapp.RunResult{}, err
}
decision, err := r.observation.ApplyNetworkObservation(ctx, carddomain.NetworkObservation{
CardID: card.ID, GatewayStatus: response.CardStatus, GatewayExtend: response.Extend,
GatewayIMEI: response.IMEI, Metadata: metadata,
})
return cardapp.RunResult{StateChanged: decision.StatusChanged}, err
default:
return cardapp.RunResult{}, apperrors.New(apperrors.CodeInvalidParam, "设备信息观测执行器尚未注册")
}
}
func (r *SeriesRunner) loadCard(ctx context.Context, payload cardapp.SeriesTaskPayload) (*model.IotCard, error) {
if payload.ResourceType != constants.CardObservationResourceTypeCard {
return nil, apperrors.New(apperrors.CodeInvalidParam, "当前观测执行器仅支持 IoT 卡资源")
}
cardID, err := strconv.ParseUint(payload.ResourceID, 10, 64)
if err != nil || cardID == 0 {
return nil, apperrors.New(apperrors.CodeInvalidParam, "卡观测资源 ID 无效")
}
var card model.IotCard
if err := r.db.WithContext(ctx).Where("id = ?", uint(cardID)).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, apperrors.New(apperrors.CodeNotFound, "IoT卡不存在")
}
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询卡观测本地快照失败")
}
return &card, nil
}
func isRateLimited(err error) bool {
var appErr *apperrors.AppError
if stderrors.As(err, &appErr) && appErr.Code == apperrors.CodeTooManyRequests {
return true
}
return strings.Contains(err.Error(), "429") || strings.Contains(strings.ToLower(err.Error()), "rate limit")
}
func operationForSyncType(syncType string) string {
switch syncType {
case constants.CardObservationSyncTypeRealname:
return constants.IntegrationOperationGatewayRealname
case constants.CardObservationSyncTypeTraffic:
return constants.IntegrationOperationGatewayTraffic
case constants.CardObservationSyncTypeNetwork:
return constants.IntegrationOperationGatewayNetwork
default:
return "query_device_info"
}
}
func gatewayIntegrationID(payload cardapp.SeriesTaskPayload) string {
return "card-series:" + payload.SeriesID + ":" + strconv.Itoa(payload.Attempt)
}
func unsentIntegrationID(payload cardapp.SeriesTaskPayload) string {
return gatewayIntegrationID(payload) + ":unsent"
}
func optionalText(value string) *string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return &value
}
var _ cardapp.SeriesAttemptLogger = (*SeriesAttemptLogger)(nil)
var _ cardapp.SeriesRunner = (*SeriesRunner)(nil)

View File

@@ -0,0 +1,72 @@
package notification
import (
"context"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// CleanupService 按通知类别的保留期限分批删除过期通知事实。
type CleanupService struct {
db *gorm.DB
logger *zap.Logger
now func() time.Time
}
// NewCleanupService 创建通知保留清理服务。
func NewCleanupService(db *gorm.DB, logger *zap.Logger) *CleanupService {
if logger == nil {
logger = zap.NewNop()
}
return &CleanupService{db: db, logger: logger, now: time.Now}
}
// Run 按类别、创建时间和稳定主键执行有界分批清理。
func (s *CleanupService) Run(ctx context.Context) error {
policies := []struct {
category string
days int
}{
{constants.NotificationCategoryApproval, constants.NotificationApprovalRetentionDays},
{constants.NotificationCategoryExpiry, constants.NotificationExpiryRetentionDays},
{constants.NotificationCategorySync, constants.NotificationSyncRetentionDays},
{constants.NotificationCategorySystem, constants.NotificationSystemRetentionDays},
}
for _, policy := range policies {
deleted, err := s.cleanupCategory(ctx, policy.category, s.now().UTC().AddDate(0, 0, -policy.days))
if err != nil {
return err
}
s.logger.Info("站内通知保留清理完成",
zap.String("category", policy.category), zap.Int64("deleted_count", deleted))
}
return nil
}
func (s *CleanupService) cleanupCategory(ctx context.Context, category string, cutoff time.Time) (int64, error) {
var total int64
for batch := 0; batch < constants.NotificationCleanupMaxBatches; batch++ {
result := s.db.WithContext(ctx).Exec(`WITH candidates AS (
SELECT id FROM tb_notification
WHERE category = ? AND created_at < ?
ORDER BY created_at ASC, id ASC
LIMIT ?
)
DELETE FROM tb_notification AS notification
USING candidates
WHERE notification.id = candidates.id`, category, cutoff, constants.NotificationCleanupBatchSize)
if result.Error != nil {
return total, errors.Wrap(errors.CodeDatabaseError, result.Error, "清理站内通知失败")
}
total += result.RowsAffected
if result.RowsAffected < constants.NotificationCleanupBatchSize {
break
}
}
return total, nil
}

View File

@@ -0,0 +1,100 @@
package notification
import (
"context"
"sort"
"gorm.io/gorm"
shopapp "github.com/break/junhong_cmp_fiber/internal/application/shop"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// DynamicRecipientResolver 按稳定账号、平台角色或店铺解析当前可用后台账号。
type DynamicRecipientResolver struct {
db *gorm.DB
shopResolver shopapp.NotificationRecipientResolver
}
// NewDynamicRecipientResolver 创建后台通知动态接收人解析 Adapter。
func NewDynamicRecipientResolver(db *gorm.DB, shopResolver shopapp.NotificationRecipientResolver) *DynamicRecipientResolver {
return &DynamicRecipientResolver{db: db, shopResolver: shopResolver}
}
// Resolve 根据受控目标类型返回去重且按账号 ID 排序的当前可用接收人。
func (r *DynamicRecipientResolver) Resolve(ctx context.Context, targetKind string, targetID uint) ([]uint, error) {
if targetID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
var ids []uint
var err error
switch targetKind {
case constants.NotificationTargetKindAccount:
ids, err = r.resolveAccount(ctx, targetID)
case constants.NotificationTargetKindPlatformRole:
ids, err = r.resolvePlatformRole(ctx, targetID)
case constants.NotificationTargetKindShop:
if r.shopResolver == nil {
return nil, errors.New(errors.CodeInternalError, "店铺通知接收人解析器未配置")
}
ids, err = r.shopResolver.ResolveNotificationRecipients(ctx, targetID)
default:
return nil, errors.New(errors.CodeInvalidParam, "通知接收目标类型不受支持")
}
if err != nil {
return nil, err
}
return uniqueSortedIDs(ids), nil
}
func (r *DynamicRecipientResolver) resolveAccount(ctx context.Context, accountID uint) ([]uint, error) {
var account model.Account
err := r.db.WithContext(ctx).Select("id").
Where("id = ? AND status = ?", accountID, constants.StatusEnabled).Take(&account).Error
if err == gorm.ErrRecordNotFound {
return []uint{}, nil
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通知申请人失败")
}
return []uint{account.ID}, nil
}
func (r *DynamicRecipientResolver) resolvePlatformRole(ctx context.Context, roleID uint) ([]uint, error) {
var accounts []model.Account
err := r.db.WithContext(ctx).Model(&model.Account{}).
Select("tb_account.id").
Joins("JOIN tb_account_role ar ON ar.account_id = tb_account.id AND ar.deleted_at IS NULL AND ar.status = ?", constants.StatusEnabled).
Joins("JOIN tb_role r ON r.id = ar.role_id AND r.deleted_at IS NULL AND r.status = ? AND r.role_type = ?",
constants.StatusEnabled, constants.RoleTypePlatform).
Where("ar.role_id = ? AND tb_account.status = ? AND tb_account.user_type IN ?",
roleID, constants.StatusEnabled, []int{constants.UserTypeSuperAdmin, constants.UserTypePlatform}).
Find(&accounts).Error
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询平台角色通知接收人失败")
}
ids := make([]uint, 0, len(accounts))
for _, account := range accounts {
ids = append(ids, account.ID)
}
return ids, nil
}
func uniqueSortedIDs(ids []uint) []uint {
seen := make(map[uint]struct{}, len(ids))
result := make([]uint, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
return result
}

View File

@@ -0,0 +1,154 @@
// Package notification 提供站内通知模板注册、渲染和 PostgreSQL 持久化 Adapter。
package notification
import (
"bytes"
"errors"
"regexp"
"strings"
"text/template"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
var (
htmlTagPattern = regexp.MustCompile(`(?i)<\s*/?\s*[a-z][^>]*>`)
sensitiveTextPattern = regexp.MustCompile(`(?i)(password|operation_password|token|secret|credential|media_id|密码|令牌|密钥)\s*[:=]\s*\S+`)
longURLPattern = regexp.MustCompile(`(?i)https?://\S+`)
)
// Definition 定义一个受控通知类型的类别、级别、模板和允许资源引用。
type Definition struct {
Type string
Category string
Severity string
TitleTemplate string
BodyTemplate string
TemplateFields map[string]struct{}
RecipientKinds map[string]struct{}
AllowedRefTypes map[string]struct{}
}
// Rendered 是模板渲染后的纯文本快照。
type Rendered struct {
Category string
Type string
Severity string
Title string
Body string
}
// Registry 保存代码内受控通知类型注册关系。
type Registry struct {
definitions map[string]Definition
}
// NewRegistry 创建内置受控通知类型注册表。
func NewRegistry() *Registry {
return &Registry{definitions: map[string]Definition{
constants.NotificationTypeSystemNotice: {
Type: constants.NotificationTypeSystemNotice, Category: constants.NotificationCategorySystem,
Severity: constants.NotificationSeverityInfo,
TitleTemplate: "系统通知",
BodyTemplate: "有一项系统事项需要处理,请进入对应页面查看。",
TemplateFields: map[string]struct{}{},
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindAccount: {}},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypeSystemConfig: {},
constants.NotificationRefTypeIntegrationLog: {},
},
},
constants.NotificationTypePackageExpiring: {
Type: constants.NotificationTypePackageExpiring, Category: constants.NotificationCategoryExpiry,
Severity: constants.NotificationSeverityWarning,
TitleTemplate: "套餐即将到期",
BodyTemplate: "您的套餐即将到期,请及时查看并处理。",
TemplateFields: map[string]struct{}{},
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindPersonalCustomer: {}},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypePackage: {},
constants.NotificationRefTypeAsset: {},
},
},
}}
}
// Render 校验注册类型、模板字段、资源引用与敏感内容后生成纯文本快照。
func (r *Registry) Render(notificationType string, data map[string]string, refType, recipientKind string) (Rendered, error) {
definition, exists := r.definitions[notificationType]
if !exists {
return Rendered{}, errors.New("通知类型未注册")
}
if _, allowed := definition.RecipientKinds[recipientKind]; !allowed {
return Rendered{}, errors.New("通知类型未向当前接收人开放")
}
if err := validateTemplateData(data, definition.TemplateFields); err != nil {
return Rendered{}, err
}
if refType != "" {
if _, allowed := definition.AllowedRefTypes[refType]; !allowed {
return Rendered{}, errors.New("通知资源类型未注册")
}
}
title, err := executeTemplate("通知标题", definition.TitleTemplate, data)
if err != nil {
return Rendered{}, err
}
body, err := executeTemplate("通知正文", definition.BodyTemplate, data)
if err != nil {
return Rendered{}, err
}
if title == "" || body == "" {
return Rendered{}, errors.New("通知模板字段为空")
}
if len([]rune(title)) > constants.NotificationMaxTitleLength {
return Rendered{}, errors.New("通知标题超过长度限制")
}
if len([]rune(body)) > constants.NotificationMaxBodyLength {
return Rendered{}, errors.New("通知正文超过长度限制")
}
if htmlTagPattern.MatchString(title) || htmlTagPattern.MatchString(body) {
return Rendered{}, errors.New("通知正文禁止包含 HTML")
}
if sensitiveTextPattern.MatchString(title) || sensitiveTextPattern.MatchString(body) || longURLPattern.MatchString(title) || longURLPattern.MatchString(body) {
return Rendered{}, errors.New("通知正文包含禁止的敏感内容")
}
return Rendered{
Category: definition.Category, Type: definition.Type, Severity: definition.Severity,
Title: title, Body: body,
}, nil
}
func executeTemplate(name, source string, data map[string]string) (string, error) {
tmpl, err := template.New(name).Option("missingkey=error").Parse(source)
if err != nil {
return "", err
}
var buffer bytes.Buffer
if err := tmpl.Execute(&buffer, data); err != nil {
return "", errors.New("通知模板字段缺失")
}
return strings.TrimSpace(buffer.String()), nil
}
func validateTemplateData(data map[string]string, fields map[string]struct{}) error {
if data == nil && len(fields) > 0 {
return errors.New("通知模板数据不能为空")
}
for key := range data {
normalized := strings.ToLower(strings.TrimSpace(key))
switch normalized {
case "password", "operation_password", "token", "secret", "credential", "id_card", "callback", "media_id", "url":
return errors.New("通知模板数据包含禁止字段")
}
if _, allowed := fields[normalized]; !allowed {
return errors.New("通知模板数据包含未注册字段")
}
}
for field := range fields {
if strings.TrimSpace(data[field]) == "" {
return errors.New("通知模板必填字段缺失")
}
}
return nil
}

View File

@@ -0,0 +1,48 @@
package notification
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// Repository 提供站内通知幂等写入能力。
type Repository struct {
db *gorm.DB
}
// NewRepository 创建站内通知持久化 Adapter。
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
// CreateIdempotent 以事件、接收人类型和接收人 ID 唯一键幂等写入通知。
func (r *Repository) CreateIdempotent(ctx context.Context, notification *model.Notification) (bool, error) {
result := r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "event_id"}, {Name: "recipient_kind"}, {Name: "recipient_id"}},
DoNothing: true,
}).Create(notification)
return result.RowsAffected == 1, result.Error
}
// IsActiveAccount 判断明确后台账号是否仍启用且未软删除。
func (r *Repository) IsActiveAccount(ctx context.Context, accountID uint) (bool, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.Account{}).
Where("id = ? AND status = ?", accountID, constants.StatusEnabled).
Count(&count).Error
return count == 1, err
}
// IsActivePersonalCustomer 判断明确个人客户是否仍启用且未软删除。
func (r *Repository) IsActivePersonalCustomer(ctx context.Context, customerID uint) (bool, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.PersonalCustomer{}).
Where("id = ? AND status = ?", customerID, constants.StatusEnabled).
Count(&count).Error
return count == 1, err
}

View File

@@ -0,0 +1,71 @@
// Package shop 提供店铺通知接收人的 PostgreSQL Adapter。
package shop
import (
"context"
"sort"
"gorm.io/gorm"
shopapp "github.com/break/junhong_cmp_fiber/internal/application/shop"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RecipientResolver 按店铺当前独立归属解析主账号和可用平台业务员。
type RecipientResolver struct {
db *gorm.DB
}
var _ shopapp.NotificationRecipientResolver = (*RecipientResolver)(nil)
// NewRecipientResolver 创建店铺通知接收人解析 Adapter。
func NewRecipientResolver(db *gorm.DB) *RecipientResolver {
return &RecipientResolver{db: db}
}
// ResolveNotificationRecipients 返回启用的店铺主账号和当前可用业务员账号 ID。
//
// 解析只读取目标店铺当前保存的业务员 ID不读取父店铺、祖先店铺或创建人。
func (r *RecipientResolver) ResolveNotificationRecipients(ctx context.Context, shopID uint) ([]uint, error) {
if shopID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
var target struct {
BusinessOwnerAccountID *uint
}
err := r.db.WithContext(ctx).Model(&model.Shop{}).
Select("business_owner_account_id").Where("id = ?", shopID).Take(&target).Error
if err == gorm.ErrRecordNotFound {
return []uint{}, nil
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺通知归属失败")
}
query := r.db.WithContext(ctx).Model(&model.Account{}).
Select("id").
Where("status = ? AND shop_id = ? AND is_primary = ? AND user_type = ?",
constants.StatusEnabled, shopID, true, constants.UserTypeAgent)
if target.BusinessOwnerAccountID != nil {
query = query.Or("id = ? AND status = ? AND user_type = ?",
*target.BusinessOwnerAccountID, constants.StatusEnabled, constants.UserTypePlatform)
}
var accounts []model.Account
if err := query.Find(&accounts).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺通知接收人失败")
}
ids := make([]uint, 0, len(accounts))
seen := make(map[uint]struct{}, len(accounts))
for _, account := range accounts {
if _, exists := seen[account.ID]; exists {
continue
}
seen[account.ID] = struct{}{}
ids = append(ids, account.ID)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids, nil
}

View File

@@ -0,0 +1,59 @@
package wallet
import (
"context"
"github.com/bytedance/sonic"
"gorm.io/gorm"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"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"
)
// CreditEventConsumer 校验已投递的代理主钱包入账事件具有对应权威资金流水。
type CreditEventConsumer struct {
db *gorm.DB
}
// NewCreditEventConsumer 创建代理主钱包入账事件消费者。
func NewCreditEventConsumer(db *gorm.DB) *CreditEventConsumer {
return &CreditEventConsumer{db: db}
}
// Consume 校验事件载荷及不可变流水,拒绝确认未知或损坏事件。
func (c *CreditEventConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "代理主钱包入账事件消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeAgentMainWalletCredited || envelope.PayloadVersion != constants.AgentMainWalletCreditedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "代理主钱包入账事件类型或版本不受支持")
}
var event walletapp.CreditedEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "代理主钱包入账事件载荷无法解析")
}
validRecharge := event.ReferenceType == constants.ReferenceTypeTopup && event.TransactionType == constants.AgentTransactionTypeRecharge
validAdjustment := event.ReferenceType == constants.ReferenceTypeManualAdjustment && event.TransactionType == constants.AgentTransactionTypeAdjustment
if event.EventID != envelope.EventID || event.WalletID == 0 || event.ReferenceID == 0 || event.Amount <= 0 || (!validRecharge && !validAdjustment) {
return errors.New(errors.CodeInvalidParam, "代理主钱包入账事件载荷不完整")
}
var transaction model.AgentWalletTransaction
err := c.db.WithContext(ctx).Unscoped().
Where("agent_wallet_id = ? AND reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
event.WalletID, event.ReferenceType, event.ReferenceID, event.TransactionType, constants.TransactionStatusSuccess).
First(&transaction).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeInternalError, "代理主钱包入账事件缺少权威资金流水")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包入账流水失败")
}
if transaction.Amount != event.Amount || transaction.BalanceBefore != event.BalanceBefore || transaction.BalanceAfter != event.BalanceAfter {
return errors.New(errors.CodeInternalError, "代理主钱包入账事件与权威资金流水不一致")
}
return nil
}

View File

@@ -0,0 +1,37 @@
package wallet
import (
"context"
"strconv"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
)
// CreditEventWriter 将代理主钱包正向入账事实写入公共 Outbox。
type CreditEventWriter struct {
outbox *outbox.Repository
}
// NewCreditEventWriter 创建代理主钱包入账 Outbox Writer。
func NewCreditEventWriter(repository *outbox.Repository) *CreditEventWriter {
return &CreditEventWriter{outbox: repository}
}
// Append 在调用方业务事务中追加代理主钱包入账事件。
func (w *CreditEventWriter) Append(ctx context.Context, tx *gorm.DB, event walletapp.CreditedEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "代理主钱包入账 Outbox Writer 未配置")
}
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeAgentMainWalletCredited,
PayloadVersion: constants.AgentMainWalletCreditedPayloadVersionV1,
AggregateType: "agent_wallet", AggregateID: strconv.FormatUint(uint64(event.WalletID), 10),
ResourceType: event.ReferenceType, ResourceID: strconv.FormatUint(uint64(event.ReferenceID), 10),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
})
return err
}

View File

@@ -0,0 +1,62 @@
package wallet
import (
"context"
"github.com/bytedance/sonic"
"gorm.io/gorm"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"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"
)
// DebitEventConsumer 校验已投递的代理主钱包扣款事件具有对应权威资金流水。
// 后续余额预警等消费者在此稳定接缝上扩展,不需要回读或改写订单扣款事务。
type DebitEventConsumer struct {
db *gorm.DB
}
// NewDebitEventConsumer 创建代理主钱包扣款事件消费者。
func NewDebitEventConsumer(db *gorm.DB) *DebitEventConsumer {
return &DebitEventConsumer{db: db}
}
// Consume 校验事件载荷及不可变流水,保证未知或损坏事件不会被静默确认。
func (c *DebitEventConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "代理主钱包扣款事件消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeAgentMainWalletDebited ||
envelope.PayloadVersion != constants.AgentMainWalletDebitedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "代理主钱包扣款事件类型或版本不受支持")
}
var event walletapp.DebitedEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "代理主钱包扣款事件载荷无法解析")
}
if event.EventID != envelope.EventID || event.WalletID == 0 || event.ReferenceID == 0 ||
event.ReferenceType != constants.ReferenceTypeOrder || event.TransactionType != constants.AgentTransactionTypeDeduct ||
event.Amount <= 0 {
return errors.New(errors.CodeInvalidParam, "代理主钱包扣款事件载荷不完整")
}
var transaction model.AgentWalletTransaction
err := c.db.WithContext(ctx).Unscoped().
Where("agent_wallet_id = ? AND reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
event.WalletID, event.ReferenceType, event.ReferenceID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess).
First(&transaction).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeInternalError, "代理主钱包扣款事件缺少权威资金流水")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包扣款流水失败")
}
if transaction.Amount != -event.Amount || transaction.BalanceBefore != event.BalanceBefore ||
transaction.BalanceAfter != event.BalanceAfter {
return errors.New(errors.CodeInternalError, "代理主钱包扣款事件与权威资金流水不一致")
}
return nil
}

View File

@@ -0,0 +1,38 @@
// Package wallet 提供代理主钱包持久化与可靠事件 Adapter。
package wallet
import (
"context"
"strconv"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
)
// DebitEventWriter 将代理主钱包扣款事实写入公共 Outbox。
type DebitEventWriter struct {
outbox *outbox.Repository
}
// NewDebitEventWriter 创建代理主钱包扣款 Outbox Writer。
func NewDebitEventWriter(repository *outbox.Repository) *DebitEventWriter {
return &DebitEventWriter{outbox: repository}
}
// Append 在调用方业务事务中追加代理主钱包扣款事件。
func (w *DebitEventWriter) Append(ctx context.Context, tx *gorm.DB, event walletapp.DebitedEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "代理主钱包扣款 Outbox Writer 未配置")
}
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeAgentMainWalletDebited,
PayloadVersion: constants.AgentMainWalletDebitedPayloadVersionV1,
AggregateType: "agent_wallet", AggregateID: strconv.FormatUint(uint64(event.WalletID), 10),
ResourceType: event.ReferenceType, ResourceID: strconv.FormatUint(uint64(event.ReferenceID), 10),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
})
return err
}

View File

@@ -0,0 +1,98 @@
package wallet
import (
"context"
"math"
"github.com/bytedance/sonic"
"gorm.io/gorm"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"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"
)
// RefundEventConsumer 校验已投递的代理主钱包退款事件具有对应权威资金流水。
type RefundEventConsumer struct {
db *gorm.DB
}
// NewRefundEventConsumer 创建代理主钱包退款事件消费者。
func NewRefundEventConsumer(db *gorm.DB) *RefundEventConsumer {
return &RefundEventConsumer{db: db}
}
// Consume 复核退款流水及正常订单的原扣款流水,拒绝确认未知或损坏事件。
func (c *RefundEventConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "代理主钱包退款事件消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeAgentMainWalletRefunded ||
envelope.PayloadVersion != constants.AgentMainWalletRefundedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "代理主钱包退款事件类型或版本不受支持")
}
var event walletapp.RefundedEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "代理主钱包退款事件载荷无法解析")
}
if event.EventID != envelope.EventID || event.WalletID == 0 || event.ShopID == 0 ||
event.OrderID == 0 || event.RefundID == 0 || event.Amount <= 0 || event.Version <= 0 ||
event.OriginalDeductAmount <= 0 || event.Amount > event.OriginalDeductAmount {
return errors.New(errors.CodeInvalidParam, "代理主钱包退款事件载荷不完整")
}
if event.Legacy == (event.OriginalDebitTransactionID > 0) {
return errors.New(errors.CodeInvalidParam, "代理主钱包退款事件原扣款标识不一致")
}
var refund model.AgentWalletTransaction
err := c.db.WithContext(ctx).Unscoped().
Where("agent_wallet_id = ? AND reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
event.WalletID, constants.ReferenceTypeRefund, event.RefundID, constants.AgentTransactionTypeRefund, constants.TransactionStatusSuccess).
First(&refund).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeInternalError, "代理主钱包退款事件缺少权威资金流水")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包退款流水失败")
}
if refund.ShopID != event.ShopID || refund.Amount != event.Amount ||
refund.BalanceBefore != event.BalanceBefore || refund.BalanceAfter != event.BalanceAfter ||
!sameRefundUint(refund.RelatedShopID, event.RelatedShopID) ||
!sameRefundString(refund.TransactionSubtype, event.TransactionSubtype) ||
refund.AssetType != event.AssetType || refund.AssetID != event.AssetID || refund.AssetIdentifier != event.AssetIdentifier {
return errors.New(errors.CodeInternalError, "代理主钱包退款事件与权威退款流水不一致")
}
if event.Legacy {
return nil
}
var debit model.AgentWalletTransaction
err = c.db.WithContext(ctx).Unscoped().
Where("id = ? AND agent_wallet_id = ? AND reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
event.OriginalDebitTransactionID, event.WalletID, constants.ReferenceTypeOrder, event.OrderID,
constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess).
First(&debit).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeInternalError, "代理主钱包退款事件缺少原扣款流水")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包原扣款流水失败")
}
if debit.Amount >= 0 || debit.Amount == math.MinInt64 || event.OriginalDeductAmount != -debit.Amount ||
!sameRefundUint(debit.RelatedShopID, event.RelatedShopID) ||
!sameRefundString(debit.TransactionSubtype, event.TransactionSubtype) ||
debit.AssetType != event.AssetType || debit.AssetID != event.AssetID || debit.AssetIdentifier != event.AssetIdentifier {
return errors.New(errors.CodeInternalError, "代理主钱包退款事件与原扣款流水不一致")
}
return nil
}
func sameRefundUint(left, right *uint) bool {
return (left == nil && right == nil) || (left != nil && right != nil && *left == *right)
}
func sameRefundString(left, right *string) bool {
return (left == nil && right == nil) || (left != nil && right != nil && *left == *right)
}

View File

@@ -0,0 +1,37 @@
package wallet
import (
"context"
"strconv"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
)
// RefundEventWriter 将代理主钱包退款事实写入公共 Outbox。
type RefundEventWriter struct {
outbox *outbox.Repository
}
// NewRefundEventWriter 创建代理主钱包退款 Outbox Writer。
func NewRefundEventWriter(repository *outbox.Repository) *RefundEventWriter {
return &RefundEventWriter{outbox: repository}
}
// Append 在调用方业务事务中追加代理主钱包退款事件。
func (w *RefundEventWriter) Append(ctx context.Context, tx *gorm.DB, event walletapp.RefundedEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "代理主钱包退款 Outbox Writer 未配置")
}
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeAgentMainWalletRefunded,
PayloadVersion: constants.AgentMainWalletRefundedPayloadVersionV1,
AggregateType: "agent_wallet", AggregateID: strconv.FormatUint(uint64(event.WalletID), 10),
ResourceType: constants.ReferenceTypeRefund, ResourceID: strconv.FormatUint(uint64(event.RefundID), 10),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
})
return err
}

View File

@@ -0,0 +1,64 @@
package wallet
import (
"context"
"github.com/bytedance/sonic"
"gorm.io/gorm"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"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"
)
// ReservationEventConsumer 校验预占事件与本地权威事实一致。
type ReservationEventConsumer struct {
db *gorm.DB
}
// NewReservationEventConsumer 创建代理主钱包预占事件消费者。
func NewReservationEventConsumer(db *gorm.DB) *ReservationEventConsumer {
return &ReservationEventConsumer{db: db}
}
// Consume 只确认具有匹配权威预占事实的事件。
func (c *ReservationEventConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "代理主钱包预占事件消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeAgentMainWalletReservationChanged ||
envelope.PayloadVersion != constants.AgentMainWalletReservationPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "代理主钱包预占事件类型或版本不受支持")
}
var event walletapp.ReservationEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "代理主钱包预占事件载荷无法解析")
}
if event.EventID != envelope.EventID || event.ReservationID == 0 || event.WalletID == 0 ||
event.Amount <= 0 || event.ReferenceType == "" || event.ReferenceID == 0 {
return errors.New(errors.CodeInvalidParam, "代理主钱包预占事件载荷不完整")
}
var reservation model.AgentWalletReservation
if err := c.db.WithContext(ctx).Where("id = ?", event.ReservationID).First(&reservation).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeInternalError, "代理主钱包预占事件缺少权威事实")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包预占事实失败")
}
if reservation.AgentWalletID != event.WalletID || reservation.Amount != event.Amount ||
reservation.ReferenceType != event.ReferenceType || reservation.ReferenceID != event.ReferenceID ||
!reservationStatusContainsEvent(reservation.Status, event.Status) {
return errors.New(errors.CodeInternalError, "代理主钱包预占事件与权威事实不一致")
}
return nil
}
func reservationStatusContainsEvent(current, event int) bool {
if current == event {
return true
}
return event == constants.AgentWalletReservationStatusFrozen &&
(current == constants.AgentWalletReservationStatusReleased || current == constants.AgentWalletReservationStatusCompleted)
}

View File

@@ -0,0 +1,37 @@
package wallet
import (
"context"
"strconv"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
)
// ReservationEventWriter 将代理主钱包预占状态写入公共 Outbox。
type ReservationEventWriter struct {
outbox *outbox.Repository
}
// NewReservationEventWriter 创建代理主钱包预占 Outbox Writer。
func NewReservationEventWriter(repository *outbox.Repository) *ReservationEventWriter {
return &ReservationEventWriter{outbox: repository}
}
// Append 在调用方事务中追加预占状态事件。
func (w *ReservationEventWriter) Append(ctx context.Context, tx *gorm.DB, event walletapp.ReservationEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "代理主钱包预占 Outbox Writer 未配置")
}
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeAgentMainWalletReservationChanged,
PayloadVersion: constants.AgentMainWalletReservationPayloadVersionV1,
AggregateType: "agent_wallet_reservation", AggregateID: strconv.FormatUint(uint64(event.ReservationID), 10),
ResourceType: event.ReferenceType, ResourceID: strconv.FormatUint(uint64(event.ReferenceID), 10),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
})
return err
}

View File

@@ -14,6 +14,8 @@ type AgentWallet struct {
WalletType string `gorm:"column:wallet_type;type:varchar(20);not null;comment:钱包类型(main-主钱包 | commission-分佣钱包)" json:"wallet_type"`
Balance int64 `gorm:"column:balance;type:bigint;not null;default:0;comment:余额(单位:分)" json:"balance"`
FrozenBalance int64 `gorm:"column:frozen_balance;type:bigint;not null;default:0;comment:冻结余额(单位:分)" json:"frozen_balance"`
CreditEnabled bool `gorm:"column:credit_enabled;type:boolean;not null;default:false;comment:是否启用主钱包信用额度" json:"credit_enabled"`
CreditLimit int64 `gorm:"column:credit_limit;type:bigint;not null;default:0;comment:主钱包信用额度(单位:分)" json:"credit_limit"`
Currency string `gorm:"column:currency;type:varchar(10);not null;default:'CNY';comment:币种" json:"currency"`
Status int `gorm:"column:status;type:int;not null;default:1;comment:钱包状态(1-正常 2-冻结 3-关闭)" json:"status"`
Version int `gorm:"column:version;type:int;not null;default:0;comment:版本号(乐观锁)" json:"version"`
@@ -29,7 +31,8 @@ func (AgentWallet) TableName() string {
return "tb_agent_wallet"
}
// GetAvailableBalance 获取可用余额 = balance - frozen_balance
// GetAvailableBalance 获取旧写入口使用的现金可用金额。
// Deprecated: 信用钱包完整用例应使用 Wallet Domain 的 AvailableBalance。
func (w *AgentWallet) GetAvailableBalance() int64 {
return w.Balance - w.FrozenBalance
}
@@ -41,13 +44,13 @@ type AgentWalletTransaction struct {
AgentWalletID uint `gorm:"column:agent_wallet_id;not null;index;comment:代理钱包ID" json:"agent_wallet_id"`
ShopID uint `gorm:"column:shop_id;not null;index;comment:店铺ID(冗余字段,便于查询)" json:"shop_id"`
UserID uint `gorm:"column:user_id;not null;comment:操作人用户ID" json:"user_id"`
TransactionType string `gorm:"column:transaction_type;type:varchar(20);not null;comment:交易类型(recharge-充值 | deduct-扣款 | refund-退款 | commission-分佣 | withdrawal-提现)" json:"transaction_type"`
TransactionType string `gorm:"column:transaction_type;type:varchar(20);not null;comment:交易类型(recharge-充值 | adjustment-人工调整 | deduct-扣款 | refund-退款 | commission-分佣 | withdrawal-提现)" json:"transaction_type"`
TransactionSubtype *string `gorm:"column:transaction_subtype;type:varchar(50);comment:交易子类型(细分 order_payment 场景)" json:"transaction_subtype,omitempty"`
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:变动金额(单位:分,正数为增加,负数为减少)" json:"amount"`
BalanceBefore int64 `gorm:"column:balance_before;type:bigint;not null;comment:变动前余额(单位:分)" json:"balance_before"`
BalanceAfter int64 `gorm:"column:balance_after;type:bigint;not null;comment:变动后余额(单位:分)" json:"balance_after"`
Status int `gorm:"column:status;type:int;not null;default:1;comment:交易状态(1-成功 2-失败 3-处理中)" json:"status"`
ReferenceType *string `gorm:"column:reference_type;type:varchar(50);comment:关联业务类型(order | commission | withdrawal | topup)" json:"reference_type,omitempty"`
ReferenceType *string `gorm:"column:reference_type;type:varchar(50);comment:关联业务类型(order | commission | withdrawal | topup | refund | exchange | manual_adjustment)" json:"reference_type,omitempty"`
ReferenceID *uint `gorm:"column:reference_id;comment:关联业务ID" json:"reference_id,omitempty"`
RelatedShopID *uint `gorm:"column:related_shop_id;comment:关联店铺ID代购时记录下级店铺" json:"related_shop_id,omitempty"`
AssetType string `gorm:"column:asset_type;type:varchar(20);not null;default:'';comment:资产类型(iot_card-物联网卡 | device-设备)" json:"asset_type,omitempty"`
@@ -71,27 +74,27 @@ func (AgentWalletTransaction) TableName() string {
// AgentRechargeRecord 代理充值记录模型
// 记录所有代理充值操作
type AgentRechargeRecord struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
UserID uint `gorm:"column:user_id;not null;index;comment:操作人用户ID" json:"user_id"`
AgentWalletID uint `gorm:"column:agent_wallet_id;not null;comment:代理钱包ID" json:"agent_wallet_id"`
ShopID uint `gorm:"column:shop_id;not null;index;comment:店铺ID(冗余字段,便于查询)" json:"shop_id"`
RechargeNo string `gorm:"column:recharge_no;type:varchar(50);not null;uniqueIndex;comment:充值订单号(格式:ARCH+时间戳+随机数)" json:"recharge_no"`
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:充值金额(单位:分,最小1分)" json:"amount"`
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null;comment:支付方式(alipay-支付宝 | wechat-微信 | bank-银行转账 | offline-线下)" json:"payment_method"`
PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"`
PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"`
PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"`
Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)" json:"status"`
ID uint `gorm:"column:id;primaryKey" json:"id"`
UserID uint `gorm:"column:user_id;not null;index;comment:操作人用户ID" json:"user_id"`
AgentWalletID uint `gorm:"column:agent_wallet_id;not null;comment:代理钱包ID" json:"agent_wallet_id"`
ShopID uint `gorm:"column:shop_id;not null;index;comment:店铺ID(冗余字段,便于查询)" json:"shop_id"`
RechargeNo string `gorm:"column:recharge_no;type:varchar(50);not null;uniqueIndex;comment:充值订单号(格式:ARCH+时间戳+随机数)" json:"recharge_no"`
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:充值金额(单位:分,最小1分)" json:"amount"`
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null;comment:支付方式(alipay-支付宝 | wechat-微信 | bank-银行转账 | offline-线下)" json:"payment_method"`
PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"`
PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"`
PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"`
Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)" json:"status"`
PaymentVoucherKey StringJSONBArray `gorm:"column:payment_voucher_key;type:jsonb;comment:支付凭证对象存储Key列表线下支付时必填最多5个微信支付时为空" json:"payment_voucher_key"`
Remark string `gorm:"column:remark;type:text;comment:运营备注(创建时填写,不可修改)" json:"remark,omitempty"`
RejectionReason *string `gorm:"column:rejection_reason;type:varchar(500);comment:驳回原因,仅驳回时写入" json:"rejection_reason,omitempty"`
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"`
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"`
ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"`
EnterpriseIDTag *uint `gorm:"column:enterprise_id_tag;index;comment:企业ID标签(多租户过滤)" json:"enterprise_id_tag,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"`
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"`
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"`
ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"`
EnterpriseIDTag *uint `gorm:"column:enterprise_id_tag;index;comment:企业ID标签(多租户过滤)" json:"enterprise_id_tag,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"`
}
// TableName 指定表名

View File

@@ -0,0 +1,25 @@
package model
import "time"
// AgentWalletReservation 记录代理主钱包资金预占及其唯一终态。
type AgentWalletReservation struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
AgentWalletID uint `gorm:"column:agent_wallet_id;not null;index:idx_agent_wallet_reservation_wallet" json:"agent_wallet_id"`
ShopID uint `gorm:"column:shop_id;not null;index:idx_agent_wallet_reservation_shop" json:"shop_id"`
Amount int64 `gorm:"column:amount;type:bigint;not null" json:"amount"`
Status int `gorm:"column:status;type:int;not null;default:1;index:idx_agent_wallet_reservation_status" json:"status"`
ReferenceType string `gorm:"column:reference_type;type:varchar(50);not null;uniqueIndex:uq_agent_wallet_reservation_reference" json:"reference_type"`
ReferenceID uint `gorm:"column:reference_id;not null;uniqueIndex:uq_agent_wallet_reservation_reference" json:"reference_id"`
Creator uint `gorm:"column:creator;not null;default:0" json:"creator"`
CompletedAt *time.Time `gorm:"column:completed_at" json:"completed_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;not null;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;autoUpdateTime" json:"updated_at"`
ShopIDTag uint `gorm:"column:shop_id_tag;not null;index" json:"shop_id_tag"`
EnterpriseIDTag *uint `gorm:"column:enterprise_id_tag;index" json:"enterprise_id_tag,omitempty"`
}
// TableName 返回代理主钱包预占事实表名。
func (AgentWalletReservation) TableName() string {
return "tb_agent_wallet_reservation"
}

View File

@@ -0,0 +1,24 @@
package model
import "time"
// ApprovalDecisionDelivery 是标准审批决策交给业务消费者的幂等处理事实。
type ApprovalDecisionDelivery struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
InstanceID uint `gorm:"column:instance_id;not null;uniqueIndex:uq_approval_decision_delivery,priority:1" json:"instance_id"`
Decision string `gorm:"column:decision;type:varchar(32);not null;uniqueIndex:uq_approval_decision_delivery,priority:2" json:"decision"`
EventID string `gorm:"column:event_id;type:varchar(64);not null;uniqueIndex" json:"event_id"`
Status int `gorm:"column:status;type:int;not null;default:0;index:idx_approval_decision_delivery_claim,priority:1" json:"status"`
RetryCount int `gorm:"column:retry_count;type:int;not null;default:0" json:"retry_count"`
LeaseOwner *string `gorm:"column:lease_owner;type:varchar(100)" json:"lease_owner,omitempty"`
LeaseExpiresAt *time.Time `gorm:"column:lease_expires_at;type:timestamptz;index:idx_approval_decision_delivery_claim,priority:2" json:"lease_expires_at,omitempty"`
LastError string `gorm:"column:last_error;type:varchar(500);not null;default:''" json:"last_error,omitempty"`
ProcessedAt *time.Time `gorm:"column:processed_at;type:timestamptz" json:"processed_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
}
// TableName 返回审批决策投递表名。
func (ApprovalDecisionDelivery) TableName() string {
return "tb_approval_decision_delivery"
}

View File

@@ -0,0 +1,31 @@
package model
import (
"time"
"gorm.io/datatypes"
)
// ApprovalInstance 是渠道无关的通用审批实例持久化模型。
type ApprovalInstance struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
BusinessType string `gorm:"column:business_type;type:varchar(64);not null;uniqueIndex:uq_approval_instance_business,priority:1" json:"business_type"`
BusinessID uint `gorm:"column:business_id;not null;uniqueIndex:uq_approval_instance_business,priority:2" json:"business_id"`
SubmitterAccountID uint `gorm:"column:submitter_account_id;not null;index" json:"submitter_account_id"`
SubmitterSnapshot datatypes.JSON `gorm:"column:submitter_snapshot;type:jsonb;not null" json:"submitter_snapshot"`
Provider string `gorm:"column:provider;type:varchar(32);not null" json:"provider"`
ExternalRef string `gorm:"column:external_ref;type:varchar(128);not null;default:''" json:"external_ref"`
Status int `gorm:"column:status;type:int;not null;default:0;index:idx_approval_instance_status_changed,priority:1" json:"status"`
RequestSnapshot datatypes.JSON `gorm:"column:request_snapshot;type:jsonb;not null" json:"request_snapshot"`
DecisionSnapshot datatypes.JSON `gorm:"column:decision_snapshot;type:jsonb" json:"decision_snapshot,omitempty"`
CorrelationID string `gorm:"column:correlation_id;type:varchar(100);not null;index" json:"correlation_id"`
Version int `gorm:"column:version;type:int;not null;default:1" json:"version"`
StatusChangedAt time.Time `gorm:"column:status_changed_at;type:timestamptz;not null;index:idx_approval_instance_status_changed,priority:2" json:"status_changed_at"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
}
// TableName 返回通用审批实例表名。
func (ApprovalInstance) TableName() string {
return "tb_approval_instance"
}

View File

@@ -0,0 +1,18 @@
package model
import "gorm.io/gorm"
// CardObservationEffect 记录卡观测事件副作用的幂等处理进度。
// 处理中的记录代表结果未知,自动重试不得重复执行已提交的套餐扣减。
type CardObservationEffect struct {
gorm.Model
BaseModel `gorm:"embedded"`
EventID string `gorm:"column:event_id;type:varchar(180);not null;uniqueIndex:uk_card_observation_effect_event;comment:Outbox事件ID" json:"event_id"`
EffectType string `gorm:"column:effect_type;type:varchar(50);not null;comment:副作用类型" json:"effect_type"`
Status int `gorm:"column:status;type:int;not null;default:0;comment:处理状态 0-待处理 1-处理中或结果未知 2-日流量已记录 3-已扣减 4-已完成" json:"status"`
}
// TableName 指定表名。
func (CardObservationEffect) TableName() string {
return "tb_card_observation_effect"
}

View File

@@ -89,10 +89,16 @@ type AgentOpenAPIPackageItem struct {
// AgentOpenAPIWalletBalanceResponse 开放接口预充值钱包余额响应
type AgentOpenAPIWalletBalanceResponse struct {
Balance int64 `json:"balance" description:"钱包余额(分)"`
FrozenBalance int64 `json:"frozen_balance" description:"冻结余额(分)"`
AvailableBalance int64 `json:"available_balance" description:"可用额(分)"`
Currency string `json:"currency" description:"币种"`
Balance int64 `json:"balance" description:"钱包账面余额(分)"`
FrozenBalance int64 `json:"frozen_balance" description:"冻结余额(分)"`
CashAvailableBalance int64 `json:"cash_available_balance" description:"现金可用额(分),等于账面余额减冻结余额"`
CreditEnabled bool `json:"credit_enabled" description:"是否启用主钱包信用额度"`
CreditLimit int64 `json:"credit_limit" description:"主钱包信用额度(分)"`
AvailableBalance int64 `json:"available_balance" description:"总可用金额(分),等于现金可用金额加生效信用额度"`
IsInDebt bool `json:"is_in_debt" description:"账面余额是否已形成欠款"`
DebtAmount int64 `json:"debt_amount" description:"欠款金额(分),仅取负账面余额绝对值"`
Version int `json:"version" description:"钱包乐观锁版本"`
Currency string `json:"currency" description:"币种"`
}
// AgentOpenAPIWalletTransactionListRequest 开放接口预充值钱包流水请求

View File

@@ -0,0 +1,94 @@
package dto
import "time"
// NotificationUnreadCountResponse 是后台账号未读数投影。
type NotificationUnreadCountResponse struct {
Count int64 `json:"count" description:"未读通知数量"`
DisplayCount string `json:"display_count" description:"徽标显示文本,超过 99 时为 99+"`
}
// NotificationListRequest 是后台通知基础分页参数。
type NotificationListRequest struct {
Category string `json:"category" query:"category" validate:"omitempty,oneof=approval expiry sync system" enums:"approval,expiry,sync,system" description:"通知类别 (approval:审批, expiry:临期, sync:同步, system:系统)"`
Type string `json:"type" query:"type" validate:"omitempty,max=100" maxlength:"100" description:"稳定通知类型"`
Severity string `json:"severity" query:"severity" validate:"omitempty,oneof=info warning error critical" enums:"info,warning,error,critical" description:"通知级别 (info:提示, warning:警告, error:错误, critical:严重)"`
IsRead *bool `json:"is_read" query:"is_read" description:"已读状态;不传时查询全部"`
Page int `json:"page" query:"page" validate:"omitempty,min=1,max=10000" minimum:"1" maximum:"10000" description:"页码,默认 1最大 10000"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=50" minimum:"1" maximum:"50" description:"每页数量,默认 20最大 50"`
}
// NotificationItem 是后台账号可见的站内通知投影。
type NotificationItem struct {
ID uint `json:"id" description:"通知ID"`
Category string `json:"category" description:"通知类别 (approval:审批, expiry:临期, sync:同步, system:系统)"`
Type string `json:"type" description:"稳定通知类型"`
Severity string `json:"severity" description:"通知级别 (info:提示, warning:警告, error:错误, critical:严重)"`
Title string `json:"title" description:"纯文本标题"`
Body string `json:"body" description:"纯文本正文"`
RefType string `json:"ref_type" description:"受控资源类型"`
RefID string `json:"ref_id" description:"受控资源ID"`
RefKey string `json:"ref_key" description:"受控资源Key"`
IsRead bool `json:"is_read" description:"是否已读"`
ReadAt *time.Time `json:"read_at,omitempty" description:"首次已读时间ISO 8601"`
CreatedAt time.Time `json:"created_at" description:"创建时间ISO 8601"`
}
// NotificationListResponse 是后台通知基础分页结果。
type NotificationListResponse struct {
Items []NotificationItem `json:"items" description:"通知列表"`
Total int64 `json:"total" description:"总数量"`
Page int `json:"page" description:"页码"`
Size int `json:"size" description:"每页数量"`
}
// NotificationIDParams 是单条通知路径参数。
type NotificationIDParams struct {
ID uint `json:"id" path:"id" required:"true" description:"通知ID"`
}
// NotificationReadResponse 是单条通知幂等已读结果。
type NotificationReadResponse struct {
Success bool `json:"success" description:"请求是否成功;通知不存在、属于别人或已经已读也返回 true"`
}
// NotificationTargetResponse 是通知受控目标解析结果,不包含任意 URL。
type NotificationTargetResponse struct {
TargetType string `json:"target_type" description:"前端白名单目标类型;空表示不支持跳转"`
TargetID *uint `json:"target_id,omitempty" description:"受控数值目标ID"`
TargetKey string `json:"target_key,omitempty" description:"受控稳定目标Key"`
Available bool `json:"available" description:"当前账号是否仍可访问目标"`
}
// NotificationUnreadSummaryResponse 是后台账号未读通知的固定分类汇总。
type NotificationUnreadSummaryResponse struct {
Total int64 `json:"total" description:"未读通知总数"`
Approval int64 `json:"approval" description:"审批类未读数量"`
Expiry int64 `json:"expiry" description:"临期类未读数量"`
Sync int64 `json:"sync" description:"同步类未读数量"`
System int64 `json:"system" description:"系统类未读数量"`
}
// NotificationReadAllRequest 是后台批量已读请求。
type NotificationReadAllRequest struct {
Category string `json:"category" validate:"omitempty,oneof=approval expiry sync system" enums:"approval,expiry,sync,system" description:"可选通知类别 (approval:审批, expiry:临期, sync:同步, system:系统)"`
}
// NotificationReadAllResponse 是后台批量已读结果。
type NotificationReadAllResponse struct {
UpdatedCount int64 `json:"updated_count" description:"本次实际更新的通知数量"`
}
// PersonalNotificationListRequest 是个人客户通知的简化分页参数。
type PersonalNotificationListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1,max=10000" minimum:"1" maximum:"10000" description:"页码,默认 1最大 10000"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=50" minimum:"1" maximum:"50" description:"每页数量,默认 20最大 50"`
}
// PersonalNotificationListResponse 是个人客户通知的简化分页结果。
type PersonalNotificationListResponse struct {
Items []NotificationItem `json:"items" description:"当前个人客户可见的业务通知列表"`
Total int64 `json:"total" description:"总数量"`
Page int `json:"page" description:"页码"`
Size int `json:"size" description:"每页数量"`
}

View File

@@ -31,15 +31,39 @@ type RoleListRequest struct {
// RoleResponse 角色响应
type RoleResponse struct {
ID uint `json:"id" description:"角色ID"`
RoleName string `json:"role_name" description:"角色名称"`
RoleDesc string `json:"role_desc" description:"角色描述"`
RoleType int `json:"role_type" description:"角色类型 (1:平台角色, 2:客户角色)"`
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
Creator uint `json:"creator" description:"创建人ID"`
Updater uint `json:"updater" description:"更新人ID"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
ID uint `json:"id" description:"角色ID"`
RoleName string `json:"role_name" description:"角色名称"`
RoleDesc string `json:"role_desc" description:"角色描述"`
RoleType int `json:"role_type" description:"角色类型 (1:平台角色, 2:客户角色)"`
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
DefaultCreditEnabled bool `json:"default_credit_enabled" description:"是否启用新建代理默认信用;仅客户角色有效"`
DefaultCreditLimit int64 `json:"default_credit_limit" description:"新建代理默认信用额度(分);仅影响未来新建店铺"`
DefaultCreditScope string `json:"default_credit_scope" description:"模板生效范围,固定为 new_shops_only"`
Creator uint `json:"creator" description:"创建人ID"`
Updater uint `json:"updater" description:"更新人ID"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
}
// UpdateRoleDefaultCreditRequest 更新角色默认信用模板请求。
type UpdateRoleDefaultCreditRequest struct {
CreditEnabled *bool `json:"credit_enabled" validate:"required" required:"true" description:"是否启用新建代理默认信用"`
CreditLimit *int64 `json:"credit_limit" validate:"required,min=0" required:"true" minimum:"0" description:"新建代理默认信用额度关闭时必须为0开启时必须大于0"`
}
// UpdateRoleDefaultCreditParams 更新角色默认信用模板参数。
type UpdateRoleDefaultCreditParams struct {
IDReq
UpdateRoleDefaultCreditRequest
}
// RoleDefaultCreditResponse 角色默认信用模板响应。
type RoleDefaultCreditResponse struct {
RoleID uint `json:"role_id" description:"客户角色ID"`
CreditEnabled bool `json:"credit_enabled" description:"是否启用新建代理默认信用"`
CreditLimit int64 `json:"credit_limit" description:"新建代理默认信用额度(分)"`
Scope string `json:"scope" description:"模板生效范围,固定为 new_shops_only"`
AffectsExistingWallets bool `json:"affects_existing_wallets" description:"是否影响既有钱包,固定为 false"`
}
// RolePageResult 角色分页响应

View File

@@ -20,7 +20,14 @@ type ShopFundSummaryItem struct {
Username string `json:"username" description:"主账号用户名"`
Phone string `json:"phone" description:"主账号手机号"`
MainBalance int64 `json:"main_balance" description:"预充值钱包余额(分)"`
MainFrozenBalance int64 `json:"main_frozen_balance" description:"预充值钱包冻结余额(分,预留字段"`
MainFrozenBalance int64 `json:"main_frozen_balance" description:"预充值钱包冻结余额(分)"`
CashAvailableBalance int64 `json:"cash_available_balance" description:"现金可用金额(分),等于主钱包余额减冻结金额"`
CreditEnabled bool `json:"credit_enabled" description:"是否启用主钱包信用额度"`
CreditLimit int64 `json:"credit_limit" description:"主钱包信用额度关闭信用时为0"`
AvailableBalance int64 `json:"available_balance" description:"总可用金额(分),等于现金可用金额加生效信用额度"`
IsInDebt bool `json:"is_in_debt" description:"主钱包账面余额是否为负数"`
DebtAmount int64 `json:"debt_amount" description:"主钱包欠款金额(分),仅取负账面余额绝对值"`
Version int `json:"version" description:"主钱包当前版本号,并发写冲突后应重新查询"`
TotalCommission int64 `json:"total_commission" description:"累计佣金总额(分)"`
WithdrawnCommission int64 `json:"withdrawn_commission" description:"已提现佣金(分)"`
UnwithdrawCommission int64 `json:"unwithdraw_commission" description:"未提现佣金(分)"`

View File

@@ -1,64 +1,112 @@
package dto
import "github.com/bytedance/sonic"
type ShopListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
ShopName string `json:"shop_name" query:"shop_name" validate:"omitempty,max=100" maxLength:"100" description:"店铺名称模糊查询"`
ShopCode string `json:"shop_code" query:"shop_code" validate:"omitempty,max=50" maxLength:"50" description:"店铺编号精确查询"`
ContactPhone string `json:"contact_phone" query:"contact_phone" validate:"omitempty,len=11,numeric,ascii" minLength:"11" maxLength:"11" pattern:"^[0-9]{11}$" description:"联系电话精确查询11位 ASCII 数字;空值不启用筛选;与其他条件按 AND 组合)"`
ParentID *uint `json:"parent_id" query:"parent_id" validate:"omitempty,min=1" minimum:"1" description:"上级店铺ID"`
Level *int `json:"level" query:"level" validate:"omitempty,min=1,max=7" minimum:"1" maximum:"7" description:"店铺层级 (1-7级)"`
Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:禁用, 1:启用)"`
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
ShopName string `json:"shop_name" query:"shop_name" validate:"omitempty,max=100" maxLength:"100" description:"店铺名称模糊查询"`
ShopCode string `json:"shop_code" query:"shop_code" validate:"omitempty,max=50" maxLength:"50" description:"店铺编号精确查询"`
ContactPhone string `json:"contact_phone" query:"contact_phone" validate:"omitempty,len=11,numeric,ascii" minLength:"11" maxLength:"11" pattern:"^[0-9]{11}$" description:"联系电话精确查询11位 ASCII 数字;空值不启用筛选;与其他条件按 AND 组合)"`
BusinessOwnerAccountID *uint `json:"business_owner_account_id" query:"business_owner_account_id" validate:"omitempty,min=1" minimum:"1" description:"平台业务员账号ID精确筛选"`
ParentID *uint `json:"parent_id" query:"parent_id" validate:"omitempty,min=1" minimum:"1" description:"上级店铺ID"`
Level *int `json:"level" query:"level" validate:"omitempty,min=1,max=7" minimum:"1" maximum:"7" description:"店铺层级 (1-7级)"`
Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:禁用, 1:启用)"`
}
type CreateShopRequest struct {
ShopName string `json:"shop_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"店铺名称"`
ShopCode string `json:"shop_code" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"店铺编号"`
ParentID *uint `json:"parent_id" validate:"omitempty,min=1" minimum:"1" description:"上级店铺ID一级店铺可不填"`
ContactName string `json:"contact_name" validate:"omitempty,max=50" maxLength:"50" description:"联系人姓名"`
ContactPhone string `json:"contact_phone" validate:"omitempty,len=11" minLength:"11" maxLength:"11" description:"联系人电话"`
Province string `json:"province" validate:"omitempty,max=50" maxLength:"50" description:"省份"`
City string `json:"city" validate:"omitempty,max=50" maxLength:"50" description:"城市"`
District string `json:"district" validate:"omitempty,max=50" maxLength:"50" description:"区县"`
Address string `json:"address" validate:"omitempty,max=255" maxLength:"255" description:"详细地址"`
DefaultRoleID uint `json:"default_role_id" validate:"required,min=1" required:"true" minimum:"1" description:"店铺默认角色ID必须是客户角色"`
InitPassword string `json:"init_password" validate:"required,min=8,max=32" required:"true" minLength:"8" maxLength:"32" description:"初始账号密码"`
InitUsername string `json:"init_username" validate:"required,min=3,max=50" required:"true" minLength:"3" maxLength:"50" description:"初始账号用户名"`
InitPhone string `json:"init_phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"初始账号手机号"`
ShopName string `json:"shop_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"店铺名称"`
ShopCode string `json:"shop_code" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"店铺编号"`
ParentID *uint `json:"parent_id" validate:"omitempty,min=1" minimum:"1" description:"上级店铺ID一级店铺可不填"`
BusinessOwnerAccountID *uint `json:"business_owner_account_id" nullable:"true" description:"平台业务员账号ID缺失时继承上级null 表示空归属"`
BusinessOwnerAccountIDSet bool `json:"-"`
ContactName string `json:"contact_name" validate:"omitempty,max=50" maxLength:"50" description:"联系人姓名"`
ContactPhone string `json:"contact_phone" validate:"omitempty,len=11" minLength:"11" maxLength:"11" description:"联系人电话"`
Province string `json:"province" validate:"omitempty,max=50" maxLength:"50" description:"省份"`
City string `json:"city" validate:"omitempty,max=50" maxLength:"50" description:"城市"`
District string `json:"district" validate:"omitempty,max=50" maxLength:"50" description:"区县"`
Address string `json:"address" validate:"omitempty,max=255" maxLength:"255" description:"详细地址"`
DefaultRoleID uint `json:"default_role_id" validate:"required,min=1" required:"true" minimum:"1" description:"店铺默认角色ID必须是客户角色"`
InitPassword string `json:"init_password" validate:"required,min=8,max=32" required:"true" minLength:"8" maxLength:"32" description:"初始账号密码"`
InitUsername string `json:"init_username" validate:"required,min=3,max=50" required:"true" minLength:"3" maxLength:"50" description:"初始账号用户名"`
InitPhone string `json:"init_phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"初始账号手机号"`
}
// UnmarshalJSON 解析创建店铺请求并保留业务员字段“缺失”和“显式 null”的差异。
func (r *CreateShopRequest) UnmarshalJSON(data []byte) error {
type plain CreateShopRequest
var decoded plain
if err := sonic.Unmarshal(data, &decoded); err != nil {
return err
}
var fields map[string]any
if err := sonic.Unmarshal(data, &fields); err != nil {
return err
}
decoded.BusinessOwnerAccountIDSet = false
if _, exists := fields["business_owner_account_id"]; exists {
decoded.BusinessOwnerAccountIDSet = true
}
*r = CreateShopRequest(decoded)
return nil
}
type UpdateShopRequest struct {
ShopName string `json:"shop_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"店铺名称"`
ContactName string `json:"contact_name" validate:"omitempty,max=50" maxLength:"50" description:"联系人姓名"`
ContactPhone string `json:"contact_phone" validate:"omitempty,len=11" minLength:"11" maxLength:"11" description:"联系人电话"`
Province string `json:"province" validate:"omitempty,max=50" maxLength:"50" description:"省份"`
City string `json:"city" validate:"omitempty,max=50" maxLength:"50" description:"城市"`
District string `json:"district" validate:"omitempty,max=50" maxLength:"50" description:"区县"`
Address string `json:"address" validate:"omitempty,max=255" maxLength:"255" description:"详细地址"`
Status int `json:"status" validate:"required,oneof=0 1" required:"true" description:"状态 (0:禁用, 1:启用)"`
ShopName string `json:"shop_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"店铺名称"`
BusinessOwnerAccountID *uint `json:"business_owner_account_id" nullable:"true" description:"平台业务员账号ID缺失时保持不变null 表示清空"`
BusinessOwnerAccountIDSet bool `json:"-"`
ContactName string `json:"contact_name" validate:"omitempty,max=50" maxLength:"50" description:"联系人姓名"`
ContactPhone string `json:"contact_phone" validate:"omitempty,len=11" minLength:"11" maxLength:"11" description:"联系人电话"`
Province string `json:"province" validate:"omitempty,max=50" maxLength:"50" description:"省份"`
City string `json:"city" validate:"omitempty,max=50" maxLength:"50" description:"城市"`
District string `json:"district" validate:"omitempty,max=50" maxLength:"50" description:"区县"`
Address string `json:"address" validate:"omitempty,max=255" maxLength:"255" description:"详细地址"`
Status int `json:"status" validate:"oneof=0 1" required:"true" description:"状态 (0:禁用, 1:启用)"`
}
// UnmarshalJSON 解析更新店铺请求并保留业务员字段“缺失”和“显式 null”的差异。
func (r *UpdateShopRequest) UnmarshalJSON(data []byte) error {
type plain UpdateShopRequest
var decoded plain
if err := sonic.Unmarshal(data, &decoded); err != nil {
return err
}
var fields map[string]any
if err := sonic.Unmarshal(data, &fields); err != nil {
return err
}
decoded.BusinessOwnerAccountIDSet = false
if _, exists := fields["business_owner_account_id"]; exists {
decoded.BusinessOwnerAccountIDSet = true
}
*r = UpdateShopRequest(decoded)
return nil
}
// ShopResponse 店铺响应
type ShopResponse struct {
ID uint `json:"id" description:"店铺ID"`
ShopName string `json:"shop_name" description:"店铺名称"`
ShopCode string `json:"shop_code" description:"店铺编号"`
ParentID *uint `json:"parent_id,omitempty" description:"上级店铺ID"`
ParentShopName string `json:"parent_shop_name,omitempty" description:"上级店铺名称"`
Level int `json:"level" description:"店铺层级 (1-7级)"`
ContactName string `json:"contact_name" description:"联系人姓名"`
ContactPhone string `json:"contact_phone" description:"联系人电话"`
Province string `json:"province" description:"省份"`
City string `json:"city" description:"城市"`
District string `json:"district" description:"区县"`
Address string `json:"address" description:"详细地址"`
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
StatusName string `json:"status_name" description:"状态名称(中文)"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
ID uint `json:"id" description:"店铺ID"`
ShopName string `json:"shop_name" description:"店铺名称"`
ShopCode string `json:"shop_code" description:"店铺编号"`
ParentID *uint `json:"parent_id,omitempty" description:"上级店铺ID"`
ParentShopName string `json:"parent_shop_name,omitempty" description:"上级店铺名称"`
BusinessOwnerAccountID *uint `json:"business_owner_account_id" description:"平台业务员账号IDnull 表示未归属"`
BusinessOwnerUsername string `json:"business_owner_username" description:"平台业务员账号名"`
BusinessOwnerPhoneSummary string `json:"business_owner_phone_summary" description:"平台业务员手机号摘要(前三后四)"`
BusinessOwnerAvailable bool `json:"business_owner_available" description:"平台业务员当前是否可用于通知接收"`
Level int `json:"level" description:"店铺层级 (1-7级)"`
ContactName string `json:"contact_name" description:"联系人姓名"`
ContactPhone string `json:"contact_phone" description:"联系人电话"`
Province string `json:"province" description:"省份"`
City string `json:"city" description:"城市"`
District string `json:"district" description:"区县"`
Address string `json:"address" description:"详细地址"`
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
StatusName string `json:"status_name" description:"状态名称(中文)"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
}
// ShopPageResult 店铺分页响应
// ShopPageResult 店铺分页响应
type ShopPageResult struct {
Items []ShopResponse `json:"items" description:"店铺列表"`
@@ -86,3 +134,50 @@ type ShopCascadeItem struct {
ShopName string `json:"shop_name" description:"店铺名称"`
HasChildren bool `json:"has_children" description:"是否有下级店铺"`
}
// ShopBusinessOwnerCandidateRequest 是平台业务员候选分页查询。
type ShopBusinessOwnerCandidateRequest struct {
Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=50" maxLength:"50" description:"用户名或手机号关键词"`
Page int `json:"page" query:"page" validate:"omitempty,min=1,max=10000" minimum:"1" maximum:"10000" description:"页码,默认 1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量,默认 20最大 100"`
}
// ShopBusinessOwnerCandidate 是平台业务员候选最小投影。
type ShopBusinessOwnerCandidate struct {
ID uint `json:"id" description:"平台业务员账号ID"`
Username string `json:"username" description:"平台业务员账号名"`
PhoneSummary string `json:"phone_summary" description:"手机号摘要(前三后四)"`
}
// ShopBusinessOwnerCandidatePageResult 是平台业务员候选分页结果。
type ShopBusinessOwnerCandidatePageResult struct {
Items []ShopBusinessOwnerCandidate `json:"items" description:"候选账号列表"`
Total int64 `json:"total" description:"总数量"`
Page int `json:"page" description:"当前页码"`
Size int `json:"size" description:"每页数量"`
}
// UpdateShopCreditLimitRequest 调整既有店铺实际信用额度请求。
type UpdateShopCreditLimitRequest struct {
CreditEnabled *bool `json:"credit_enabled" validate:"required" required:"true" description:"是否启用实际信用额度"`
CreditLimit *int64 `json:"credit_limit" validate:"required,min=0" required:"true" minimum:"0" description:"实际信用额度关闭时必须为0"`
Version *int `json:"version" validate:"required,min=0" required:"true" minimum:"0" description:"主钱包乐观锁版本"`
}
// UpdateShopCreditLimitParams 调整既有店铺实际信用额度参数。
type UpdateShopCreditLimitParams struct {
IDReq
UpdateShopCreditLimitRequest
}
// ShopCreditLimitResponse 店铺实际信用额度调整响应。
type ShopCreditLimitResponse struct {
ShopID uint `json:"shop_id" description:"店铺ID"`
WalletID uint `json:"wallet_id" description:"代理主钱包ID"`
Balance int64 `json:"balance" description:"账面余额(分)"`
FrozenBalance int64 `json:"frozen_balance" description:"冻结金额(分)"`
CreditEnabled bool `json:"credit_enabled" description:"是否启用实际信用额度"`
CreditLimit int64 `json:"credit_limit" description:"实际信用额度(分)"`
AvailableBalance int64 `json:"available_balance" description:"总可用金额(分)"`
Version int `json:"version" description:"更新后的主钱包版本"`
}

View File

@@ -36,6 +36,8 @@ type IotCard struct {
EnablePolling bool `gorm:"column:enable_polling;type:boolean;default:true;comment:是否参与轮询 true-参与 false-不参与" json:"enable_polling"`
LastDataCheckAt *time.Time `gorm:"column:last_data_check_at;comment:最后一次流量检查时间" json:"last_data_check_at"`
LastRealNameCheckAt *time.Time `gorm:"column:last_real_name_check_at;comment:最后一次实名检查时间" json:"last_real_name_check_at"`
RealnameReversalCount int `gorm:"column:realname_reversal_count;type:int;default:0;not null;comment:实名逆转连续观测次数" json:"realname_reversal_count"`
RealnameReversalStartedAt *time.Time `gorm:"column:realname_reversal_started_at;comment:实名逆转当前确认窗口开始时间" json:"realname_reversal_started_at,omitempty"`
LastProtectCheckAt *time.Time `gorm:"column:last_protect_check_at;comment:上次保护期一致性检查时间" json:"last_protect_check_at"`
LastCardStatusCheckAt *time.Time `gorm:"column:last_card_status_check_at;comment:最后一次卡状态检查时间" json:"last_card_status_check_at"`
LastSyncTime *time.Time `gorm:"column:last_sync_time;comment:最后一次与Gateway同步时间" json:"last_sync_time"`
@@ -56,8 +58,8 @@ type IotCard struct {
LastGatewayReadingMB float64 `gorm:"column:last_gateway_reading_mb;type:float;not null;default:0;comment:运营商当前周期累计流量读数(MB来自网关)" json:"last_gateway_reading_mb"`
GatewayExtend string `gorm:"column:gateway_extend;type:text;not null;default:'';comment:Gateway 卡状态扩展字段,原样保存上游 extend用于展示运营商侧实际停机原因" json:"gateway_extend"`
// GatewayCardIMEI 为插拔卡业务中网关上报的设备 IMEI非设备自身 IMEI仅用于数据同步落库
GatewayCardIMEI string `gorm:"column:gateway_card_imei;type:varchar(50);not null;default:'';comment:插拔卡业务 IMEI网关卡状态接口同步非设备 IMEI" json:"gateway_card_imei"`
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)" json:"realname_policy"`
GatewayCardIMEI string `gorm:"column:gateway_card_imei;type:varchar(50);not null;default:'';comment:插拔卡业务 IMEI网关卡状态接口同步非设备 IMEI" json:"gateway_card_imei"`
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)" json:"realname_policy"`
// ICCID 双列存储,用于支持 19/20 位 ICCID 精确路由查询
// 所有卡必须有 ICCID19仅 20 位运营商卡有 ICCID2019 位卡为 nil → 数据库 NULL

View File

@@ -0,0 +1,28 @@
package model
import "time"
// Notification 是站内通知的 PostgreSQL 持久化事实。
type Notification struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
EventID string `gorm:"column:event_id;type:varchar(64);not null;uniqueIndex:uq_notification_event_recipient,priority:1" json:"event_id"`
RecipientKind string `gorm:"column:recipient_kind;type:varchar(32);not null;uniqueIndex:uq_notification_event_recipient,priority:2" json:"recipient_kind"`
RecipientID uint `gorm:"column:recipient_id;not null;uniqueIndex:uq_notification_event_recipient,priority:3" json:"recipient_id"`
Category string `gorm:"column:category;type:varchar(32);not null" json:"category"`
Type string `gorm:"column:type;type:varchar(100);not null" json:"type"`
Severity string `gorm:"column:severity;type:varchar(16);not null" json:"severity"`
Title string `gorm:"column:title;type:varchar(200);not null" json:"title"`
Body string `gorm:"column:body;type:text;not null" json:"body"`
RefType string `gorm:"column:ref_type;type:varchar(64);not null;default:''" json:"ref_type"`
RefID string `gorm:"column:ref_id;type:varchar(128);not null;default:''" json:"ref_id"`
RefKey string `gorm:"column:ref_key;type:varchar(128);not null;default:''" json:"ref_key"`
IsRead bool `gorm:"column:is_read;not null;default:false" json:"is_read"`
ReadAt *time.Time `gorm:"column:read_at;type:timestamptz" json:"read_at,omitempty"`
ExpiresAt *time.Time `gorm:"column:expires_at;type:timestamptz" json:"expires_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
}
// TableName 返回站内通知表名。
func (Notification) TableName() string {
return "tb_notification"
}

View File

@@ -16,6 +16,8 @@ type Order struct {
// 订单基础信息
OrderNo string `gorm:"column:order_no;type:varchar(30);uniqueIndex:idx_order_no,where:deleted_at IS NULL;not null;comment:订单号(ORD+时间戳+6位随机数)" json:"order_no"`
OrderType string `gorm:"column:order_type;type:varchar(20);not null;comment:订单类型 single_card-单卡购买 device-设备购买" json:"order_type"`
// IdempotencyKey 仅用于订单创建事务防重,不向客户端暴露。
IdempotencyKey string `gorm:"column:idempotency_key;type:varchar(64);not null;default:'';index:idx_order_idempotency_key;comment:订单创建幂等指纹(SHA-256)" json:"-"`
// 买家信息
BuyerType string `gorm:"column:buyer_type;type:varchar(20);not null;comment:买家类型 personal-个人客户 agent-代理商" json:"buyer_type"`

View File

@@ -9,10 +9,12 @@ type Role struct {
gorm.Model
BaseModel `gorm:"embedded"`
RoleName string `gorm:"column:role_name;not null;size:50;comment:角色名称" json:"role_name"`
RoleDesc string `gorm:"column:role_desc;size:255;comment:角色描述" json:"role_desc"`
RoleType int `gorm:"column:role_type;not null;index;comment:角色类型 1=平台角色 2=客户角色" json:"role_type"`
Status int `gorm:"column:status;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
RoleName string `gorm:"column:role_name;not null;size:50;comment:角色名称" json:"role_name"`
RoleDesc string `gorm:"column:role_desc;size:255;comment:角色描述" json:"role_desc"`
RoleType int `gorm:"column:role_type;not null;index;comment:角色类型 1=平台角色 2=客户角色" json:"role_type"`
Status int `gorm:"column:status;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
DefaultCreditEnabled bool `gorm:"column:default_credit_enabled;type:boolean;not null;default:false;comment:是否启用新建代理默认信用" json:"default_credit_enabled"`
DefaultCreditLimit int64 `gorm:"column:default_credit_limit;type:bigint;not null;default:0;comment:新建代理默认信用额度(单位:分)" json:"default_credit_limit"`
}
// TableName 指定表名

View File

@@ -7,18 +7,19 @@ import (
// Shop 店铺模型
type Shop struct {
gorm.Model
BaseModel `gorm:"embedded"`
ShopName string `gorm:"column:shop_name;type:varchar(100);not null;comment:店铺名称" json:"shop_name"`
ShopCode string `gorm:"column:shop_code;type:varchar(50);uniqueIndex:idx_shop_code,where:deleted_at IS NULL;comment:店铺编号" json:"shop_code"`
ParentID *uint `gorm:"column:parent_id;index;comment:上级店铺IDNULL表示一级代理" json:"parent_id,omitempty"`
Level int `gorm:"column:level;type:int;not null;default:1;comment:层级1-7" json:"level"`
ContactName string `gorm:"column:contact_name;type:varchar(50);comment:联系人姓名" json:"contact_name"`
ContactPhone string `gorm:"column:contact_phone;type:varchar(20);comment:联系人电话" json:"contact_phone"`
Province string `gorm:"column:province;type:varchar(50);comment:省份" json:"province"`
City string `gorm:"column:city;type:varchar(50);comment:城市" json:"city"`
District string `gorm:"column:district;type:varchar(50);comment:区县" json:"district"`
Address string `gorm:"column:address;type:varchar(255);comment:详细地址" json:"address"`
Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
BaseModel `gorm:"embedded"`
ShopName string `gorm:"column:shop_name;type:varchar(100);not null;comment:店铺名称" json:"shop_name"`
ShopCode string `gorm:"column:shop_code;type:varchar(50);uniqueIndex:idx_shop_code,where:deleted_at IS NULL;comment:店铺编号" json:"shop_code"`
ParentID *uint `gorm:"column:parent_id;index;comment:上级店铺IDNULL表示一级代理" json:"parent_id,omitempty"`
BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id;index:idx_shop_business_owner_account_id;comment:平台业务员账号ID" json:"business_owner_account_id,omitempty"`
Level int `gorm:"column:level;type:int;not null;default:1;comment:层级1-7" json:"level"`
ContactName string `gorm:"column:contact_name;type:varchar(50);comment:联系人姓名" json:"contact_name"`
ContactPhone string `gorm:"column:contact_phone;type:varchar(20);comment:联系人电话" json:"contact_phone"`
Province string `gorm:"column:province;type:varchar(50);comment:省份" json:"province"`
City string `gorm:"column:city;type:varchar(50);comment:城市" json:"city"`
District string `gorm:"column:district;type:varchar(50);comment:区县" json:"district"`
Address string `gorm:"column:address;type:varchar(255);comment:详细地址" json:"address"`
Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
}
// TableName 指定表名

View File

@@ -0,0 +1,218 @@
// Package approval 提供渠道无关审批实例的读取模型和权限投影。
package approval
import (
"context"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// Viewer 是业务权限 Adapter 判定当前读取主体所需的最小身份。
type Viewer struct {
AccountID uint
UserType int
}
// BusinessReference 是通用审批实例指向的稳定业务引用。
type BusinessReference struct {
InstanceID uint
BusinessType string
BusinessID uint
}
// BusinessProjection 是业务权限 Adapter 返回的安全摘要和处理状态。
type BusinessProjection struct {
Allowed bool
Summary string
ProcessingStatus int
ProcessingStatusName string
ProcessingSummary string
}
// BusinessProjectionResolver 批量复核业务资源当前权限并投影处理摘要。
type BusinessProjectionResolver interface {
Resolve(ctx context.Context, viewer Viewer, references []BusinessReference) (map[uint]BusinessProjection, error)
}
// ExtensionReference 是渠道 Adapter 读取本地扩展快照所需的最小引用。
type ExtensionReference struct {
InstanceID uint
Provider string
ExternalRef string
}
// ChannelExtensionResolver 可为平台主体批量补充渠道专属本地快照。
// 实现不得在 Query 请求中实时调用外部审批平台。
type ChannelExtensionResolver interface {
Resolve(ctx context.Context, viewer Viewer, references []ExtensionReference) (map[uint]any, error)
}
// Projection 是通用审批 Query 对业务列表和详情输出的稳定读取模型。
type Projection struct {
ID uint `json:"id"`
BusinessType string `json:"business_type"`
BusinessID uint `json:"business_id"`
BusinessSummary string `json:"business_summary"`
SubmitterAccountID uint `json:"submitter_account_id"`
SubmitterName string `json:"submitter_name"`
Provider string `json:"provider"`
Status int `json:"status"`
StatusName string `json:"status_name"`
StatusChangedAt time.Time `json:"status_changed_at"`
ProcessingStatus int `json:"processing_status"`
ProcessingStatusName string `json:"processing_status_name"`
ProcessingSummary string `json:"processing_summary"`
ChannelExtension any `json:"channel_extension,omitempty"`
}
// Query 批量读取通用审批事实,并通过业务 Adapter 复核当前权限。
type Query struct {
db *gorm.DB
businessResolver BusinessProjectionResolver
extensionResolver ChannelExtensionResolver
}
// NewQuery 创建通用审批 Query。
func NewQuery(db *gorm.DB, businessResolver BusinessProjectionResolver, extensionResolver ChannelExtensionResolver) *Query {
return &Query{db: db, businessResolver: businessResolver, extensionResolver: extensionResolver}
}
// GetByID 读取单条通用审批投影;资源不存在、引用失效或无权限统一返回禁止访问。
func (q *Query) GetByID(ctx context.Context, instanceID uint) (*Projection, error) {
items, err := q.BatchByIDs(ctx, []uint{instanceID})
if err != nil {
return nil, err
}
if len(items) != 1 {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return &items[0], nil
}
// BatchByIDs 按调用方当前页实例 ID 批量返回有权读取的审批投影,并保持输入顺序。
func (q *Query) BatchByIDs(ctx context.Context, instanceIDs []uint) ([]Projection, error) {
ids, err := normalizeInstanceIDs(instanceIDs)
if err != nil {
return nil, err
}
if len(ids) == 0 {
return []Projection{}, nil
}
if q == nil || q.db == nil || q.businessResolver == nil {
return nil, errors.New(errors.CodeInternalError, "通用审批 Query 未完整配置")
}
var records []model.ApprovalInstance
if err := q.db.WithContext(ctx).Where("id IN ?", ids).Find(&records).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通用审批实例失败")
}
recordByID := make(map[uint]model.ApprovalInstance, len(records))
references := make([]BusinessReference, 0, len(records))
for _, record := range records {
recordByID[record.ID] = record
references = append(references, BusinessReference{
InstanceID: record.ID, BusinessType: record.BusinessType, BusinessID: record.BusinessID,
})
}
viewer := Viewer{AccountID: middleware.GetUserIDFromContext(ctx), UserType: middleware.GetUserTypeFromContext(ctx)}
if viewer.AccountID == 0 || viewer.UserType == 0 {
return nil, errors.New(errors.CodeForbidden)
}
businessByID, err := q.businessResolver.Resolve(ctx, viewer, references)
if err != nil {
return nil, err
}
extensionByID, err := q.resolveExtensions(ctx, viewer, records, businessByID)
if err != nil {
return nil, err
}
items := make([]Projection, 0, len(records))
for _, id := range ids {
record, exists := recordByID[id]
business, allowed := businessByID[id]
if !exists || !allowed || !business.Allowed {
continue
}
items = append(items, project(record, business, extensionByID[id]))
}
return items, nil
}
func (q *Query) resolveExtensions(
ctx context.Context,
viewer Viewer,
records []model.ApprovalInstance,
businessByID map[uint]BusinessProjection,
) (map[uint]any, error) {
if q.extensionResolver == nil || viewer.UserType == constants.UserTypeAgent || viewer.UserType == constants.UserTypeEnterprise {
return map[uint]any{}, nil
}
if viewer.UserType != constants.UserTypeSuperAdmin && viewer.UserType != constants.UserTypePlatform {
return map[uint]any{}, nil
}
references := make([]ExtensionReference, 0, len(records))
for _, record := range records {
business, exists := businessByID[record.ID]
if !exists || !business.Allowed {
continue
}
references = append(references, ExtensionReference{
InstanceID: record.ID, Provider: record.Provider, ExternalRef: record.ExternalRef,
})
}
if len(references) == 0 {
return map[uint]any{}, nil
}
return q.extensionResolver.Resolve(ctx, viewer, references)
}
func normalizeInstanceIDs(instanceIDs []uint) ([]uint, error) {
if len(instanceIDs) > constants.ApprovalQueryMaxBatchSize {
return nil, errors.New(errors.CodeInvalidParam, "单次最多查询 100 条审批摘要")
}
seen := make(map[uint]struct{}, len(instanceIDs))
ids := make([]uint, 0, len(instanceIDs))
for _, id := range instanceIDs {
if id == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids, nil
}
func project(record model.ApprovalInstance, business BusinessProjection, extension any) Projection {
return Projection{
ID: record.ID, BusinessType: record.BusinessType, BusinessID: record.BusinessID,
BusinessSummary: business.Summary,
SubmitterAccountID: record.SubmitterAccountID, SubmitterName: submitterName(record.SubmitterSnapshot),
Provider: record.Provider, Status: record.Status, StatusName: constants.GetApprovalStatusName(record.Status),
StatusChangedAt: record.StatusChangedAt,
ProcessingStatus: business.ProcessingStatus, ProcessingStatusName: business.ProcessingStatusName,
ProcessingSummary: business.ProcessingSummary, ChannelExtension: extension,
}
}
func submitterName(snapshot []byte) string {
var value struct {
AccountName string `json:"account_name"`
}
if sonic.Unmarshal(snapshot, &value) != nil || strings.TrimSpace(value.AccountName) == "" {
return constants.ApprovalUnknownSubmitterName
}
return strings.TrimSpace(value.AccountName)
}

View File

@@ -0,0 +1,221 @@
// Package notification 提供当前接收人的 PostgreSQL 站内通知读取投影。
package notification
import (
"context"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// Query 提供后台账号未读数和基础列表查询。
type Query struct {
db *gorm.DB
now func() time.Time
}
// NewQuery 创建后台通知查询。
func NewQuery(db *gorm.DB) *Query {
return &Query{db: db, now: time.Now}
}
// UnreadCount 从 PostgreSQL 查询当前后台账号的准确未读数。
func (q *Query) UnreadCount(ctx context.Context, recipientID uint) (*dto.NotificationUnreadCountResponse, error) {
if recipientID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
var count int64
now := q.now().UTC()
err := q.db.WithContext(ctx).Model(&model.Notification{}).
Where("recipient_kind = ? AND recipient_id = ? AND is_read = ? AND (expires_at IS NULL OR expires_at > ?)",
constants.NotificationRecipientKindAccount, recipientID, false, now).
Count(&count).Error
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通知未读数失败")
}
return newUnreadCountResponse(count), nil
}
// List 按创建时间和 ID 倒序查询当前后台账号的未过期通知。
func (q *Query) List(ctx context.Context, recipientID uint, request dto.NotificationListRequest) (*dto.NotificationListResponse, error) {
if recipientID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
page, pageSize, offset, err := normalizeNotificationPagination(request.Page, request.PageSize)
if err != nil {
return nil, err
}
if !isNotificationCategory(request.Category) || !isNotificationSeverity(request.Severity) || len(request.Type) > 100 || strings.TrimSpace(request.Type) != request.Type {
return nil, errors.New(errors.CodeInvalidParam)
}
now := q.now().UTC()
base := q.db.WithContext(ctx).Model(&model.Notification{}).
Where("recipient_kind = ? AND recipient_id = ? AND (expires_at IS NULL OR expires_at > ?)",
constants.NotificationRecipientKindAccount, recipientID, now)
if request.Category != "" {
base = base.Where("category = ?", request.Category)
}
if request.Type != "" {
base = base.Where("type = ?", request.Type)
}
if request.Severity != "" {
base = base.Where("severity = ?", request.Severity)
}
if request.IsRead != nil {
base = base.Where("is_read = ?", *request.IsRead)
}
var total int64
if err := base.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通知总数失败")
}
var records []model.Notification
if err := base.Order("created_at DESC, id DESC").Offset(offset).Limit(pageSize).Find(&records).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通知列表失败")
}
items := make([]dto.NotificationItem, 0, len(records))
for _, record := range records {
items = append(items, dto.NotificationItem{
ID: record.ID, Category: record.Category, Type: record.Type, Severity: record.Severity,
Title: record.Title, Body: record.Body, RefType: record.RefType, RefID: record.RefID,
RefKey: record.RefKey, IsRead: record.IsRead, ReadAt: record.ReadAt, CreatedAt: record.CreatedAt,
})
}
return &dto.NotificationListResponse{Items: items, Total: total, Page: page, Size: pageSize}, nil
}
// PersonalUnreadCount 查询当前个人客户可见业务通知的准确未读数。
func (q *Query) PersonalUnreadCount(ctx context.Context, customerID uint) (*dto.NotificationUnreadCountResponse, error) {
if customerID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
var count int64
err := personalNotificationScope(q.db.WithContext(ctx).Model(&model.Notification{}), customerID, q.now().UTC()).
Where("is_read = ?", false).
Count(&count).Error
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询个人客户通知未读数失败")
}
return newUnreadCountResponse(count), nil
}
// PersonalList 查询当前个人客户可见的未过期业务通知简化列表。
func (q *Query) PersonalList(ctx context.Context, customerID uint, request dto.PersonalNotificationListRequest) (*dto.PersonalNotificationListResponse, error) {
if customerID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
page, pageSize, offset, err := normalizeNotificationPagination(request.Page, request.PageSize)
if err != nil {
return nil, err
}
base := personalNotificationScope(q.db.WithContext(ctx).Model(&model.Notification{}), customerID, q.now().UTC())
var total int64
if err := base.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询个人客户通知总数失败")
}
var records []model.Notification
if err := base.Order("created_at DESC, id DESC").Offset(offset).Limit(pageSize).Find(&records).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询个人客户通知列表失败")
}
return &dto.PersonalNotificationListResponse{
Items: notificationItems(records), Total: total, Page: page, Size: pageSize,
}, nil
}
// UnreadSummary 使用单条 PostgreSQL 查询返回当前后台账号的固定分类汇总。
func (q *Query) UnreadSummary(ctx context.Context, recipientID uint) (*dto.NotificationUnreadSummaryResponse, error) {
if recipientID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
var summary dto.NotificationUnreadSummaryResponse
err := q.db.WithContext(ctx).Model(&model.Notification{}).
Select(`COUNT(*) AS total,
COUNT(*) FILTER (WHERE category = ?) AS approval,
COUNT(*) FILTER (WHERE category = ?) AS expiry,
COUNT(*) FILTER (WHERE category = ?) AS sync,
COUNT(*) FILTER (WHERE category = ?) AS system`,
constants.NotificationCategoryApproval,
constants.NotificationCategoryExpiry,
constants.NotificationCategorySync,
constants.NotificationCategorySystem).
Where("recipient_kind = ? AND recipient_id = ? AND is_read = ? AND (expires_at IS NULL OR expires_at > ?)",
constants.NotificationRecipientKindAccount, recipientID, false, q.now().UTC()).
Scan(&summary).Error
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通知未读汇总失败")
}
return &summary, nil
}
func isNotificationCategory(category string) bool {
switch category {
case "", constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry,
constants.NotificationCategorySync, constants.NotificationCategorySystem:
return true
default:
return false
}
}
func isNotificationSeverity(severity string) bool {
switch severity {
case "", constants.NotificationSeverityInfo, constants.NotificationSeverityWarning,
constants.NotificationSeverityError, constants.NotificationSeverityCritical:
return true
default:
return false
}
}
func normalizeNotificationPagination(page, pageSize int) (int, int, int, error) {
if page <= 0 {
page = 1
}
if page > constants.NotificationMaxPage {
return 0, 0, 0, errors.New(errors.CodeInvalidParam)
}
if pageSize <= 0 {
pageSize = constants.NotificationDefaultPageSize
}
if pageSize > constants.NotificationMaxPageSize {
return 0, 0, 0, errors.New(errors.CodeInvalidParam)
}
return page, pageSize, (page - 1) * pageSize, nil
}
func personalNotificationScope(db *gorm.DB, customerID uint, now time.Time) *gorm.DB {
return db.Where(`recipient_kind = ? AND recipient_id = ?
AND category IN ? AND type IN ? AND (expires_at IS NULL OR expires_at > ?)`,
constants.NotificationRecipientKindPersonalCustomer,
customerID,
[]string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry},
[]string{constants.NotificationTypePackageExpiring},
now,
)
}
func newUnreadCountResponse(count int64) *dto.NotificationUnreadCountResponse {
displayCount := strconv.FormatInt(count, 10)
if count > 99 {
displayCount = "99+"
}
return &dto.NotificationUnreadCountResponse{Count: count, DisplayCount: displayCount}
}
func notificationItems(records []model.Notification) []dto.NotificationItem {
items := make([]dto.NotificationItem, 0, len(records))
for _, record := range records {
items = append(items, dto.NotificationItem{
ID: record.ID, Category: record.Category, Type: record.Type, Severity: record.Severity,
Title: record.Title, Body: record.Body, RefType: record.RefType, RefID: record.RefID,
RefKey: record.RefKey, IsRead: record.IsRead, ReadAt: record.ReadAt, CreatedAt: record.CreatedAt,
})
}
return items
}

View File

@@ -0,0 +1,156 @@
package notification
import (
"context"
"math"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// Target 先校验通知属于当前账号,再解析白名单结构化目标并复核当前权限。
func (q *Query) Target(ctx context.Context, recipientID, notificationID uint) (*dto.NotificationTargetResponse, error) {
result := &dto.NotificationTargetResponse{}
if recipientID == 0 || notificationID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
var record model.Notification
err := q.db.WithContext(ctx).Select("ref_type", "ref_id", "ref_key").
Where("id = ? AND recipient_kind = ? AND recipient_id = ? AND (expires_at IS NULL OR expires_at > ?)",
notificationID, constants.NotificationRecipientKindAccount, recipientID, q.now().UTC()).Take(&record).Error
if err == gorm.ErrRecordNotFound {
return result, nil
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通知目标失败")
}
return q.resolveTarget(ctx, record)
}
func (q *Query) resolveTarget(ctx context.Context, record model.Notification) (*dto.NotificationTargetResponse, error) {
definition, exists := notificationTargetDefinitions()[record.RefType]
if !exists {
return &dto.NotificationTargetResponse{}, nil
}
result := &dto.NotificationTargetResponse{TargetType: definition.targetType}
if definition.keyTarget {
result.TargetKey = record.RefKey
}
if definition.idTarget {
id, valid := parseNotificationTargetID(record.RefID)
if !valid {
return result, nil
}
result.TargetID = &id
}
available, err := definition.available(q, ctx, record, result.TargetID)
if err != nil {
return nil, err
}
result.Available = available
return result, nil
}
type targetDefinition struct {
targetType string
idTarget bool
keyTarget bool
available func(*Query, context.Context, model.Notification, *uint) (bool, error)
}
func notificationTargetDefinitions() map[string]targetDefinition {
return map[string]targetDefinition{
constants.NotificationRefTypeRefund: {targetType: constants.NotificationTargetTypeRefundDetail, idTarget: true, available: refundTargetAvailable},
constants.NotificationRefTypeAgentRecharge: {targetType: constants.NotificationTargetTypeAgentRechargeDetail, idTarget: true, available: unavailableFutureTarget},
constants.NotificationRefTypeWeComApproval: {targetType: constants.NotificationTargetTypeWeComApprovalDetail, idTarget: true, available: unavailableFutureTarget},
constants.NotificationRefTypeIotCard: {targetType: constants.NotificationTargetTypeIotCardDetail, idTarget: true, available: iotCardTargetAvailable},
constants.NotificationRefTypeDevice: {targetType: constants.NotificationTargetTypeDeviceDetail, idTarget: true, available: deviceTargetAvailable},
constants.NotificationRefTypeExpiringAsset: {targetType: constants.NotificationTargetTypeExpiringAssetList, idTarget: true, available: shopTargetAvailable},
constants.NotificationRefTypeShopFund: {targetType: constants.NotificationTargetTypeShopFundSummary, idTarget: true, available: shopTargetAvailable},
constants.NotificationRefTypeIntegrationLog: {targetType: constants.NotificationTargetTypeIntegrationLog, keyTarget: true, available: integrationTargetAvailable},
constants.NotificationRefTypeCardSync: {targetType: constants.NotificationTargetTypeIntegrationLog, keyTarget: true, available: integrationTargetAvailable},
constants.NotificationRefTypeSystemConfig: {targetType: constants.NotificationTargetTypeSystemConfig, keyTarget: true, available: systemConfigTargetAvailable},
}
}
func parseNotificationTargetID(value string) (uint, bool) {
parsed, err := strconv.ParseUint(value, 10, 64)
if err != nil || parsed == 0 || parsed > math.MaxInt64 {
return 0, false
}
return uint(parsed), true
}
func refundTargetAvailable(q *Query, ctx context.Context, _ model.Notification, id *uint) (bool, error) {
query := q.db.WithContext(ctx).Model(&model.RefundRequest{}).Where("id = ?", *id)
switch middleware.GetUserTypeFromContext(ctx) {
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
case constants.UserTypeAgent:
query = query.Where("creator = ?", middleware.GetUserIDFromContext(ctx))
default:
return false, nil
}
return targetExists(query, "查询退款通知目标失败")
}
func iotCardTargetAvailable(q *Query, ctx context.Context, _ model.Notification, id *uint) (bool, error) {
if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeEnterprise {
return false, nil
}
query := middleware.ApplyShopFilter(ctx, q.db.WithContext(ctx).Model(&model.IotCard{})).Where("id = ?", *id)
return targetExists(query, "查询物联网卡通知目标失败")
}
func deviceTargetAvailable(q *Query, ctx context.Context, _ model.Notification, id *uint) (bool, error) {
if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeEnterprise {
return false, nil
}
query := middleware.ApplyShopFilter(ctx, q.db.WithContext(ctx).Model(&model.Device{})).Where("id = ?", *id)
return targetExists(query, "查询设备通知目标失败")
}
func shopTargetAvailable(q *Query, ctx context.Context, _ model.Notification, id *uint) (bool, error) {
if err := middleware.CanManageShop(ctx, *id); err != nil {
return false, nil
}
query := middleware.ApplyShopIDFilter(ctx, q.db.WithContext(ctx).Model(&model.Shop{})).Where("id = ?", *id)
return targetExists(query, "查询店铺通知目标失败")
}
func integrationTargetAvailable(q *Query, ctx context.Context, record model.Notification, _ *uint) (bool, error) {
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return false, nil
}
if record.RefKey == "" {
return false, nil
}
query := q.db.WithContext(ctx).Model(&model.IntegrationLog{}).Where("integration_id = ?", record.RefKey)
return targetExists(query, "查询外部集成通知目标失败")
}
func systemConfigTargetAvailable(q *Query, ctx context.Context, record model.Notification, _ *uint) (bool, error) {
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin || record.RefKey == "" {
return false, nil
}
query := q.db.WithContext(ctx).Model(&model.SystemConfig{}).Where("config_key = ?", record.RefKey)
return targetExists(query, "查询系统配置通知目标失败")
}
func unavailableFutureTarget(_ *Query, _ context.Context, _ model.Notification, _ *uint) (bool, error) {
return false, nil
}
func targetExists(query *gorm.DB, summary string) (bool, error) {
var count int64
if err := query.Count(&count).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, summary)
}
return count > 0, nil
}

View File

@@ -0,0 +1,219 @@
// Package shop 提供店铺业务员归属与资金概况读取投影。
package shop
import (
"context"
"strings"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// BusinessOwnerQuery 提供店铺业务员归属读取能力。
type BusinessOwnerQuery struct {
db *gorm.DB
}
// NewBusinessOwnerQuery 创建店铺业务员归属 Query。
func NewBusinessOwnerQuery(db *gorm.DB) *BusinessOwnerQuery {
return &BusinessOwnerQuery{db: db}
}
// List 查询调用者数据范围内的店铺,并批量投影上级和业务员摘要。
func (q *BusinessOwnerQuery) List(ctx context.Context, request dto.ShopListRequest) ([]*dto.ShopResponse, int64, error) {
base := middleware.ApplyShopIDFilter(ctx, q.db.WithContext(ctx).Model(&model.Shop{}))
base = applyShopFilters(base, request)
var total int64
if err := base.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺总数失败")
}
var shops []*model.Shop
offset := (request.Page - 1) * request.PageSize
if err := base.Order("created_at DESC, id DESC").Offset(offset).Limit(request.PageSize).Find(&shops).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺列表失败")
}
responses, err := q.project(ctx, shops)
if err != nil {
return nil, 0, err
}
return responses, total, nil
}
// Detail 查询调用者数据范围内的一家店铺详情。
func (q *BusinessOwnerQuery) Detail(ctx context.Context, shopID uint) (*dto.ShopResponse, error) {
if shopID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
var shop model.Shop
db := middleware.ApplyShopIDFilter(ctx, q.db.WithContext(ctx).Model(&model.Shop{}))
if err := db.Where("id = ?", shopID).First(&shop).Error; err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
responses, err := q.project(ctx, []*model.Shop{&shop})
if err != nil {
return nil, err
}
return responses[0], nil
}
// Candidates 查询当前可人工绑定的平台业务员最小投影。
func (q *BusinessOwnerQuery) Candidates(ctx context.Context, request dto.ShopBusinessOwnerCandidateRequest) ([]dto.ShopBusinessOwnerCandidate, int64, int, int, error) {
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return nil, 0, 0, 0, errors.New(errors.CodeForbidden, "无权限查询业务员候选")
}
page, pageSize := request.Page, request.PageSize
if page == 0 {
page = constants.DefaultPage
}
if pageSize == 0 {
pageSize = constants.DefaultPageSize
}
base := q.db.WithContext(ctx).Model(&model.Account{}).
Where("user_type = ? AND status = ?", constants.UserTypePlatform, constants.StatusEnabled)
if request.Keyword != "" {
keyword := "%" + request.Keyword + "%"
base = base.Where("username ILIKE ? OR phone ILIKE ?", keyword, keyword)
}
var total int64
if err := base.Count(&total).Error; err != nil {
return nil, 0, 0, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员候选总数失败")
}
var accounts []model.Account
if err := base.Order("id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&accounts).Error; err != nil {
return nil, 0, 0, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员候选失败")
}
items := make([]dto.ShopBusinessOwnerCandidate, 0, len(accounts))
for _, account := range accounts {
items = append(items, dto.ShopBusinessOwnerCandidate{
ID: account.ID, Username: account.Username, PhoneSummary: maskPhone(account.Phone),
})
}
return items, total, page, pageSize, nil
}
func applyShopFilters(db *gorm.DB, request dto.ShopListRequest) *gorm.DB {
if request.ShopName != "" {
db = db.Where("shop_name LIKE ?", "%"+request.ShopName+"%")
}
if request.ShopCode != "" {
db = db.Where("shop_code = ?", request.ShopCode)
}
if request.ContactPhone != "" {
db = db.Where("contact_phone = ?", request.ContactPhone)
}
if request.BusinessOwnerAccountID != nil {
db = db.Where("business_owner_account_id = ?", *request.BusinessOwnerAccountID)
}
if request.ParentID != nil {
db = db.Where("parent_id = ?", *request.ParentID)
}
if request.Level != nil {
db = db.Where("level = ?", *request.Level)
}
if request.Status != nil {
db = db.Where("status = ?", *request.Status)
}
return db
}
func (q *BusinessOwnerQuery) project(ctx context.Context, shops []*model.Shop) ([]*dto.ShopResponse, error) {
parentIDs, ownerIDs := collectProjectionIDs(shops)
parentNames, err := q.loadParentNames(ctx, parentIDs)
if err != nil {
return nil, err
}
owners, err := q.loadBusinessOwners(ctx, ownerIDs)
if err != nil {
return nil, err
}
responses := make([]*dto.ShopResponse, 0, len(shops))
for _, shop := range shops {
response := &dto.ShopResponse{
ID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode, ParentID: shop.ParentID,
BusinessOwnerAccountID: shop.BusinessOwnerAccountID, Level: shop.Level,
ContactName: shop.ContactName, ContactPhone: shop.ContactPhone, Province: shop.Province,
City: shop.City, District: shop.District, Address: shop.Address, Status: shop.Status,
StatusName: constants.GetStatusName(shop.Status), CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"),
}
if shop.ParentID != nil {
response.ParentShopName = parentNames[*shop.ParentID]
}
if shop.BusinessOwnerAccountID != nil {
if owner, exists := owners[*shop.BusinessOwnerAccountID]; exists {
response.BusinessOwnerUsername = owner.Username
response.BusinessOwnerPhoneSummary = maskPhone(owner.Phone)
response.BusinessOwnerAvailable = owner.UserType == constants.UserTypePlatform && owner.Status == constants.StatusEnabled && !owner.DeletedAt.Valid
}
}
responses = append(responses, response)
}
return responses, nil
}
func collectProjectionIDs(shops []*model.Shop) ([]uint, []uint) {
parents := make(map[uint]struct{})
owners := make(map[uint]struct{})
for _, shop := range shops {
if shop.ParentID != nil {
parents[*shop.ParentID] = struct{}{}
}
if shop.BusinessOwnerAccountID != nil {
owners[*shop.BusinessOwnerAccountID] = struct{}{}
}
}
return mapKeys(parents), mapKeys(owners)
}
func (q *BusinessOwnerQuery) loadParentNames(ctx context.Context, ids []uint) (map[uint]string, error) {
result := make(map[uint]string, len(ids))
if len(ids) == 0 {
return result, nil
}
var shops []model.Shop
db := middleware.ApplyShopIDFilter(ctx, q.db.WithContext(ctx).Model(&model.Shop{}))
if err := db.Select("id", "shop_name").Where("id IN ?", ids).Find(&shops).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级店铺摘要失败")
}
for _, shop := range shops {
result[shop.ID] = shop.ShopName
}
return result, nil
}
func (q *BusinessOwnerQuery) loadBusinessOwners(ctx context.Context, ids []uint) (map[uint]model.Account, error) {
result := make(map[uint]model.Account, len(ids))
if len(ids) == 0 {
return result, nil
}
var accounts []model.Account
if err := q.db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Find(&accounts).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员摘要失败")
}
for _, account := range accounts {
result[account.ID] = account
}
return result, nil
}
func mapKeys(values map[uint]struct{}) []uint {
keys := make([]uint, 0, len(values))
for key := range values {
keys = append(keys, key)
}
return keys
}
func maskPhone(phone string) string {
phone = strings.TrimSpace(phone)
if len(phone) < 7 {
return ""
}
return phone[:3] + "****" + phone[len(phone)-4:]
}

View File

@@ -0,0 +1,294 @@
package shop
import (
"context"
"math"
"strings"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// FundSummaryQuery 提供数据权限范围内的店铺资金概况读取投影。
type FundSummaryQuery struct {
db *gorm.DB
}
// NewFundSummaryQuery 创建店铺资金概况 Query。
func NewFundSummaryQuery(db *gorm.DB) *FundSummaryQuery {
return &FundSummaryQuery{db: db}
}
// List 分页查询店铺,并以固定批次数投影主钱包、佣金、提现和主账号信息。
func (q *FundSummaryQuery) List(ctx context.Context, req dto.ShopFundSummaryListReq) (*dto.ShopFundSummaryPageResult, error) {
page, pageSize := normalizeFundSummaryPage(req.Page, req.PageSize)
base := middleware.ApplyShopIDFilter(ctx, q.db.WithContext(ctx).Model(&model.Shop{}))
base = applyFundSummaryFilters(base, req)
var total int64
if err := base.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺资金概况总数失败")
}
var shops []model.Shop
if err := base.Order("created_at DESC, id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&shops).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺资金概况列表失败")
}
if len(shops) == 0 {
return &dto.ShopFundSummaryPageResult{Items: []dto.ShopFundSummaryItem{}, Total: total, Page: page, Size: pageSize}, nil
}
shopIDs := make([]uint, 0, len(shops))
for index := range shops {
shopIDs = append(shopIDs, shops[index].ID)
}
wallets, err := q.loadWallets(ctx, shopIDs)
if err != nil {
return nil, err
}
withdrawals, err := q.loadWithdrawals(ctx, shopIDs)
if err != nil {
return nil, err
}
accounts, err := q.loadPrimaryAccounts(ctx, shopIDs)
if err != nil {
return nil, err
}
items := make([]dto.ShopFundSummaryItem, 0, len(shops))
for index := range shops {
item, err := projectFundSummary(shops[index], wallets[shops[index].ID], withdrawals[shops[index].ID], accounts[shops[index].ID])
if err != nil {
return nil, err
}
items = append(items, item)
}
return &dto.ShopFundSummaryPageResult{Items: items, Total: total, Page: page, Size: pageSize}, nil
}
type fundWallets struct {
main *model.AgentWallet
commission *model.AgentWallet
}
type withdrawalAmounts struct {
approved int64
pending int64
}
type withdrawalAggregate struct {
ShopID uint
Status int
Amount int64
}
func normalizeFundSummaryPage(page, pageSize int) (int, int) {
if page <= 0 {
page = constants.DefaultPage
}
if pageSize <= 0 {
pageSize = constants.DefaultPageSize
}
if pageSize > constants.MaxPageSize {
pageSize = constants.MaxPageSize
}
return page, pageSize
}
func applyFundSummaryFilters(db *gorm.DB, req dto.ShopFundSummaryListReq) *gorm.DB {
if shopName := strings.TrimSpace(req.ShopName); shopName != "" {
db = db.Where("shop_name ILIKE ?", "%"+shopName+"%")
}
if username := strings.TrimSpace(req.Username); username != "" {
db = db.Where(`EXISTS (
SELECT 1 FROM tb_account AS account_filter
WHERE account_filter.shop_id = tb_shop.id
AND account_filter.is_primary = TRUE
AND account_filter.deleted_at IS NULL
AND account_filter.username ILIKE ?
)`, "%"+username+"%")
}
return db
}
func (q *FundSummaryQuery) loadWallets(ctx context.Context, shopIDs []uint) (map[uint]fundWallets, error) {
var records []model.AgentWallet
if err := q.db.WithContext(ctx).
Where("shop_id IN ? AND wallet_type IN ?", shopIDs, []string{constants.AgentWalletTypeMain, constants.AgentWalletTypeCommission}).
Order("id ASC").Find(&records).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询店铺钱包失败")
}
result := make(map[uint]fundWallets, len(shopIDs))
for index := range records {
wallet := &records[index]
pair := result[wallet.ShopID]
switch wallet.WalletType {
case constants.AgentWalletTypeMain:
if pair.main == nil {
pair.main = wallet
}
case constants.AgentWalletTypeCommission:
if pair.commission == nil {
pair.commission = wallet
}
}
result[wallet.ShopID] = pair
}
return result, nil
}
func (q *FundSummaryQuery) loadWithdrawals(ctx context.Context, shopIDs []uint) (map[uint]withdrawalAmounts, error) {
var records []withdrawalAggregate
if err := q.db.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
Select("shop_id, status, COALESCE(SUM(amount), 0) AS amount").
Where("shop_id IN ? AND status IN ?", shopIDs, []int{constants.WithdrawalStatusApproved, constants.WithdrawalStatusPending}).
Group("shop_id, status").Scan(&records).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询店铺提现汇总失败")
}
result := make(map[uint]withdrawalAmounts, len(shopIDs))
for _, record := range records {
amounts := result[record.ShopID]
if record.Status == constants.WithdrawalStatusApproved {
amounts.approved = record.Amount
} else if record.Status == constants.WithdrawalStatusPending {
amounts.pending = record.Amount
}
result[record.ShopID] = amounts
}
return result, nil
}
func (q *FundSummaryQuery) loadPrimaryAccounts(ctx context.Context, shopIDs []uint) (map[uint]*model.Account, error) {
var records []model.Account
if err := q.db.WithContext(ctx).
Where("shop_id IN ? AND is_primary = ? AND deleted_at IS NULL", shopIDs, true).
Order("id ASC").Find(&records).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询店铺主账号失败")
}
result := make(map[uint]*model.Account, len(shopIDs))
for index := range records {
account := &records[index]
if account.ShopID != nil && result[*account.ShopID] == nil {
result[*account.ShopID] = account
}
}
return result, nil
}
func projectFundSummary(shop model.Shop, wallets fundWallets, withdrawals withdrawalAmounts, account *model.Account) (dto.ShopFundSummaryItem, error) {
mainProjection, err := projectMainWallet(wallets.main)
if err != nil {
return dto.ShopFundSummaryItem{}, err
}
commissionProjection, err := projectCommissionWallet(wallets.commission, withdrawals)
if err != nil {
return dto.ShopFundSummaryItem{}, err
}
item := dto.ShopFundSummaryItem{
ShopID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode,
MainBalance: mainProjection.balance, MainFrozenBalance: mainProjection.frozen,
CashAvailableBalance: mainProjection.cashAvailable, CreditEnabled: mainProjection.creditEnabled,
CreditLimit: mainProjection.creditLimit, AvailableBalance: mainProjection.available,
IsInDebt: mainProjection.isInDebt, DebtAmount: mainProjection.debtAmount, Version: mainProjection.version,
TotalCommission: commissionProjection.total, WithdrawnCommission: withdrawals.approved,
UnwithdrawCommission: commissionProjection.unwithdrawn, FrozenCommission: commissionProjection.frozen,
WithdrawingCommission: withdrawals.pending, AvailableCommission: commissionProjection.available,
CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
}
if account != nil {
item.Username = account.Username
item.Phone = account.Phone
}
return item, nil
}
type mainWalletProjection struct {
balance int64
frozen int64
cashAvailable int64
creditEnabled bool
creditLimit int64
available int64
isInDebt bool
debtAmount int64
version int
}
func projectMainWallet(wallet *model.AgentWallet) (mainWalletProjection, error) {
if wallet == nil {
return mainWalletProjection{}, nil
}
cashAvailable, ok := safeFundSub(wallet.Balance, wallet.FrozenBalance)
if !ok {
return mainWalletProjection{}, errors.New(errors.CodeInternalError, "计算主钱包现金可用金额时发生整数溢出")
}
effectiveCredit := int64(0)
if wallet.CreditEnabled {
effectiveCredit = wallet.CreditLimit
}
available, ok := safeFundAdd(cashAvailable, effectiveCredit)
if !ok {
return mainWalletProjection{}, errors.New(errors.CodeInternalError, "计算主钱包总可用金额时发生整数溢出")
}
debtAmount := int64(0)
if wallet.Balance < 0 {
if wallet.Balance == math.MinInt64 {
return mainWalletProjection{}, errors.New(errors.CodeInternalError, "计算主钱包欠款金额时发生整数溢出")
}
debtAmount = -wallet.Balance
}
return mainWalletProjection{
balance: wallet.Balance, frozen: wallet.FrozenBalance, cashAvailable: cashAvailable,
creditEnabled: wallet.CreditEnabled, creditLimit: wallet.CreditLimit, available: available,
isInDebt: wallet.Balance < 0, debtAmount: debtAmount, version: wallet.Version,
}, nil
}
type commissionProjection struct {
total int64
unwithdrawn int64
frozen int64
available int64
}
func projectCommissionWallet(wallet *model.AgentWallet, withdrawals withdrawalAmounts) (commissionProjection, error) {
balance, frozen := int64(0), int64(0)
if wallet != nil {
balance = wallet.Balance
frozen = wallet.FrozenBalance
}
unwithdrawn, ok := safeFundAdd(balance, frozen)
if !ok {
return commissionProjection{}, errors.New(errors.CodeInternalError, "计算未提现佣金时发生整数溢出")
}
total, ok := safeFundAdd(unwithdrawn, withdrawals.approved)
if !ok {
return commissionProjection{}, errors.New(errors.CodeInternalError, "计算累计佣金时发生整数溢出")
}
available, ok := safeFundSub(balance, withdrawals.pending)
if !ok {
return commissionProjection{}, errors.New(errors.CodeInternalError, "计算可提现佣金时发生整数溢出")
}
if available < 0 {
available = 0
}
return commissionProjection{total: total, unwithdrawn: unwithdrawn, frozen: frozen, available: available}, nil
}
func safeFundAdd(left, right int64) (int64, bool) {
if (right > 0 && left > math.MaxInt64-right) || (right < 0 && left < math.MinInt64-right) {
return 0, false
}
return left + right, true
}
func safeFundSub(left, right int64) (int64, bool) {
if (right > 0 && left < math.MinInt64+right) || (right < 0 && left > math.MaxInt64+right) {
return 0, false
}
return left - right, true
}

View File

@@ -31,6 +31,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.ShopCommission != nil {
registerShopCommissionRoutes(authGroup, handlers.ShopCommission, doc, basePath)
}
if handlers.Shop != nil {
registerShopDetailRoute(authGroup, handlers.Shop, doc, basePath)
}
if handlers.CommissionWithdrawal != nil {
registerCommissionWithdrawalRoutes(authGroup, handlers.CommissionWithdrawal, doc, basePath)
}
@@ -56,6 +59,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.ExportTask != nil {
registerExportTaskRoutes(authGroup, handlers.ExportTask, doc, basePath)
}
if handlers.Notification != nil {
registerNotificationRoutes(authGroup, handlers.Notification, doc, basePath)
}
if handlers.Device != nil {
registerDeviceRoutes(authGroup, handlers.Device, handlers.DeviceImport, doc, basePath)
}

View File

@@ -0,0 +1,68 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerNotificationRoutes 注册当前后台账号的站内通知路由。
func registerNotificationRoutes(router fiber.Router, handler *admin.NotificationHandler, doc *openapi.Generator, basePath string) {
notifications := router.Group("/notifications")
groupPath := basePath + "/notifications"
// 静态路径必须先于通知 ID 动态路径,避免被动态参数吞掉。
Register(notifications, doc, groupPath, "GET", "/unread-count", handler.UnreadCount, RouteSpec{
Summary: "查询通知未读数",
Description: "仅查询当前认证后台账号的未过期未读通知,超过 99 条时 display_count 返回 99+。",
Tags: []string{"站内通知"},
Output: new(dto.NotificationUnreadCountResponse),
Auth: true,
})
Register(notifications, doc, groupPath, "GET", "/unread-summary", handler.UnreadSummary, RouteSpec{
Summary: "查询通知未读分类汇总",
Description: "返回当前认证后台账号未过期通知的总未读数及审批、临期、同步、系统四个固定类别计数。",
Tags: []string{"站内通知"},
Output: new(dto.NotificationUnreadSummaryResponse),
Auth: true,
})
Register(notifications, doc, groupPath, "GET", "", handler.List, RouteSpec{
Summary: "查询通知列表",
Description: "仅返回当前认证后台账号的未过期通知,固定按创建时间和通知 ID 倒序。",
Tags: []string{"站内通知"},
Input: new(dto.NotificationListRequest),
Output: new(dto.NotificationListResponse),
Auth: true,
})
Register(notifications, doc, groupPath, "PUT", "/read-all", handler.MarkAllRead, RouteSpec{
Summary: "批量标记通知已读",
Description: "类别为空时更新当前认证后台账号全部未过期未读通知;指定有效类别时只更新该类别,重复调用幂等成功。",
Tags: []string{"站内通知"},
Input: new(dto.NotificationReadAllRequest),
Output: new(dto.NotificationReadAllResponse),
Auth: true,
})
Register(notifications, doc, groupPath, "GET", "/:id/target", handler.Target, RouteSpec{
Summary: "解析通知受控目标",
Description: "先校验通知属于当前认证后台账号,再复核目标资源当前权限;只返回白名单结构化目标,不返回任意 URL。",
Tags: []string{"站内通知"},
Input: new(dto.NotificationIDParams),
Output: new(dto.NotificationTargetResponse),
Auth: true,
})
Register(notifications, doc, groupPath, "PUT", "/:id/read", handler.MarkRead, RouteSpec{
Summary: "标记单条通知已读",
Description: "仅更新当前认证后台账号的通知;通知不存在、属于别人或已经已读均幂等成功。",
Tags: []string{"站内通知"},
Input: new(dto.NotificationIDParams),
Output: new(dto.NotificationReadResponse),
Auth: true,
})
}

View File

@@ -115,6 +115,9 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator,
// 需要认证的路由
authGroup := router.Group("")
authGroup.Use(personalAuthMiddleware.Authenticate())
if handlers.ClientNotification != nil {
registerPersonalNotificationRoutes(authGroup, handlers.ClientNotification, doc, basePath)
}
// 获取个人资料
Register(authGroup, doc, basePath, "GET", "/profile", handlers.PersonalCustomer.GetProfile, RouteSpec{

View File

@@ -0,0 +1,50 @@
package routes
import (
"github.com/gofiber/fiber/v2"
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerPersonalNotificationRoutes 注册当前个人客户的简化站内通知路由。
func registerPersonalNotificationRoutes(router fiber.Router, handler *apphandler.ClientNotificationHandler, doc *openapi.Generator, basePath string) {
notifications := router.Group("/notifications")
groupPath := basePath + "/notifications"
Register(notifications, doc, groupPath, "GET", "/unread-count", handler.UnreadCount, RouteSpec{
Summary: "查询个人客户通知未读数",
Description: "仅查询当前认证个人客户的未过期业务通知,平台同步和系统运维通知不会进入结果。",
Tags: []string{"个人客户 - 站内通知"},
Output: new(dto.NotificationUnreadCountResponse),
Auth: true,
})
Register(notifications, doc, groupPath, "GET", "", handler.List, RouteSpec{
Summary: "查询个人客户通知列表",
Description: "仅返回当前认证个人客户的未过期业务通知,固定按创建时间和通知 ID 倒序。",
Tags: []string{"个人客户 - 站内通知"},
Input: new(dto.PersonalNotificationListRequest),
Output: new(dto.PersonalNotificationListResponse),
Auth: true,
})
// 静态路径必须先于通知 ID 动态路径,避免被动态参数吞掉。
Register(notifications, doc, groupPath, "PUT", "/read-all", handler.MarkAllRead, RouteSpec{
Summary: "全部标记个人客户通知已读",
Description: "只更新当前认证个人客户可见的未过期未读业务通知,重复调用幂等成功。",
Tags: []string{"个人客户 - 站内通知"},
Output: new(dto.NotificationReadAllResponse),
Auth: true,
})
Register(notifications, doc, groupPath, "PUT", "/:id/read", handler.MarkRead, RouteSpec{
Summary: "标记个人客户单条通知已读",
Description: "仅更新当前认证个人客户可见的通知;通知不存在、属于别人或已经已读均幂等成功。",
Tags: []string{"个人客户 - 站内通知"},
Input: new(dto.NotificationIDParams),
Output: new(dto.NotificationReadResponse),
Auth: true,
})
}

View File

@@ -55,6 +55,14 @@ func registerRoleRoutes(api fiber.Router, h *admin.RoleHandler, doc *openapi.Gen
Auth: true,
})
Register(roles, doc, groupPath, "PUT", "/:id/default-credit", h.UpdateDefaultCredit, RouteSpec{
Summary: "更新客户角色的新建代理默认信用模板",
Tags: []string{"角色"},
Input: new(dto.UpdateRoleDefaultCreditParams),
Output: new(dto.RoleDefaultCreditResponse),
Auth: true,
})
Register(roles, doc, groupPath, "DELETE", "/:id", h.Delete, RouteSpec{
Summary: "删除角色",
Tags: []string{"角色"},

View File

@@ -33,6 +33,15 @@ func registerShopRoutes(router fiber.Router, handler *admin.ShopHandler, doc *op
Auth: true,
})
Register(shops, doc, groupPath, "GET", "/business-owner-candidates", coreShopManagement(handler.BusinessOwnerCandidates), RouteSpec{
Summary: "查询店铺业务员候选",
Description: "仅超级管理员和平台账号可查询;只返回当前启用、未删除的平台账号最小摘要。",
Tags: []string{"店铺管理"},
Input: new(dto.ShopBusinessOwnerCandidateRequest),
Output: new(dto.ShopBusinessOwnerCandidatePageResult),
Auth: true,
})
Register(shops, doc, groupPath, "PUT", "/:id", coreShopManagement(handler.Update), RouteSpec{
Summary: "更新店铺",
Description: constants.ShopManagementAccessDescription,
@@ -42,6 +51,15 @@ func registerShopRoutes(router fiber.Router, handler *admin.ShopHandler, doc *op
Auth: true,
})
Register(shops, doc, groupPath, "PUT", "/:id/credit-limit", handler.UpdateCreditLimit, RouteSpec{
Summary: "调整既有店铺实际信用额度",
Description: "后端不校验按钮权限shop:credit-limit:manage 仅供前端控制按钮显示。",
Tags: []string{"代理商资金管理"},
Input: new(dto.UpdateShopCreditLimitParams),
Output: new(dto.ShopCreditLimitResponse),
Auth: true,
})
Register(shops, doc, groupPath, "DELETE", "/:id", coreShopManagement(handler.Delete), RouteSpec{
Summary: "删除店铺",
Description: constants.ShopManagementAccessDescription,
@@ -61,6 +79,19 @@ func registerShopRoutes(router fiber.Router, handler *admin.ShopHandler, doc *op
})
}
func registerShopDetailRoute(router fiber.Router, handler *admin.ShopHandler, doc *openapi.Generator, basePath string) {
shops := router.Group("/shops")
groupPath := basePath + "/shops"
Register(shops, doc, groupPath, "GET", "/:id", coreShopManagement(handler.Detail), RouteSpec{
Summary: "查询店铺详情",
Description: constants.ShopManagementAccessDescription + " 返回与店铺列表一致的业务员归属摘要。",
Tags: []string{"店铺管理"},
Input: new(dto.IDReq),
Output: new(dto.ShopResponse),
Auth: true,
})
}
func coreShopManagement(handler fiber.Handler) fiber.Handler {
return func(c *fiber.Ctx) error {
if middleware.GetUserTypeFromContext(c.UserContext()) == constants.UserTypeEnterprise {
@@ -103,12 +134,13 @@ func registerShopCommissionRoutes(router fiber.Router, handler *admin.ShopCommis
shops := router.Group("/shops")
groupPath := basePath + "/shops"
Register(shops, doc, groupPath, "GET", "/fund-summary", handler.ListFundSummary, RouteSpec{
Summary: "代理商资金概况",
Tags: []string{"代理商资金管理"},
Input: new(dto.ShopFundSummaryListReq),
Output: new(dto.ShopFundSummaryPageResult),
Auth: true,
Register(shops, doc, groupPath, "GET", "/fund-summary", agentFundManagement(handler.ListFundSummary), RouteSpec{
Summary: "代理商资金概况",
Description: "按当前店铺数据范围分页返回主钱包、佣金及信用资金事实;现金可用、总可用和欠款均由服务端计算,读取不代表具有调额权限。",
Tags: []string{"代理商资金管理"},
Input: new(dto.ShopFundSummaryListReq),
Output: new(dto.ShopFundSummaryPageResult),
Auth: true,
})
Register(shops, doc, groupPath, "GET", "/:shop_id/withdrawal-requests", handler.ListWithdrawalRequests, RouteSpec{
@@ -169,3 +201,12 @@ func registerShopCommissionRoutes(router fiber.Router, handler *admin.ShopCommis
Auth: true,
})
}
func agentFundManagement(handler fiber.Handler) fiber.Handler {
return func(c *fiber.Ctx) error {
if middleware.GetUserTypeFromContext(c.UserContext()) == constants.UserTypeEnterprise {
return errors.New(errors.CodeForbidden, constants.AgentFundManagementForbiddenMessage)
}
return handler(c)
}
}

View File

@@ -7,6 +7,7 @@ import (
"strings"
"time"
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"
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
@@ -267,12 +268,39 @@ func (s *Service) GetWalletBalance(ctx context.Context) (*dto.AgentOpenAPIWallet
}
return nil, errors.Wrap(errors.CodeInternalError, err, "查询主钱包余额失败")
}
aggregate := domainwallet.AgentWallet{
ID: wallet.ID, ShopID: wallet.ShopID, WalletType: wallet.WalletType,
Balance: wallet.Balance, FrozenBalance: wallet.FrozenBalance,
CreditEnabled: wallet.CreditEnabled, CreditLimit: wallet.CreditLimit,
Status: wallet.Status, Version: wallet.Version,
}
if err := aggregate.Validate(); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "代理主钱包资金状态异常")
}
cashAvailable, err := aggregate.CashAvailableBalance()
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "计算主钱包现金可用金额失败")
}
available, err := aggregate.AvailableBalance()
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "计算主钱包总可用金额失败")
}
debtAmount, err := aggregate.DebtAmount()
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "计算主钱包欠款金额失败")
}
return &dto.AgentOpenAPIWalletBalanceResponse{
Balance: wallet.Balance,
FrozenBalance: wallet.FrozenBalance,
AvailableBalance: wallet.GetAvailableBalance(),
Currency: walletCurrency(wallet.Currency),
Balance: wallet.Balance,
FrozenBalance: wallet.FrozenBalance,
CashAvailableBalance: cashAvailable,
CreditEnabled: wallet.CreditEnabled,
CreditLimit: wallet.CreditLimit,
AvailableBalance: available,
IsInDebt: aggregate.IsInDebt(),
DebtAmount: debtAmount,
Version: wallet.Version,
Currency: walletCurrency(wallet.Currency),
}, nil
}

View File

@@ -6,12 +6,14 @@ import (
"context"
"fmt"
"math/rand"
"strings"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
@@ -41,7 +43,7 @@ type Service struct {
db *gorm.DB
agentRechargeStore *postgres.AgentRechargeStore
agentWalletStore *postgres.AgentWalletStore
agentWalletTxStore *postgres.AgentWalletTransactionStore
agentWalletPosting *walletapp.PostingService
shopStore *postgres.ShopStore
wechatConfigService WechatConfigServiceInterface
auditService AuditServiceInterface
@@ -55,7 +57,6 @@ func New(
db *gorm.DB,
agentRechargeStore *postgres.AgentRechargeStore,
agentWalletStore *postgres.AgentWalletStore,
agentWalletTxStore *postgres.AgentWalletTransactionStore,
shopStore *postgres.ShopStore,
wechatConfigService WechatConfigServiceInterface,
auditService AuditServiceInterface,
@@ -67,7 +68,6 @@ func New(
db: db,
agentRechargeStore: agentRechargeStore,
agentWalletStore: agentWalletStore,
agentWalletTxStore: agentWalletTxStore,
shopStore: shopStore,
wechatConfigService: wechatConfigService,
auditService: auditService,
@@ -77,6 +77,11 @@ func New(
}
}
// SetAgentWalletPostingService 注入代理主钱包统一入账用例。
func (s *Service) SetAgentWalletPostingService(service *walletapp.PostingService) {
s.agentWalletPosting = service
}
// Create 创建代理充值订单
// POST /api/admin/agent-recharges
func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeRequest) (*dto.AgentRechargeResponse, error) {
@@ -199,12 +204,6 @@ func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOffline
return nil, errors.New(errors.CodeInvalidParam, "该订单状态不允许确认支付")
}
// 查询钱包(事务内需要用到 version
wallet, err := s.agentWalletStore.GetByID(ctx, record.AgentWalletID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理钱包失败")
}
now := time.Now()
err = s.db.Transaction(func(tx *gorm.DB) error {
// 条件更新充值记录状态
@@ -222,41 +221,20 @@ func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOffline
return errors.New(errors.CodeInvalidParam, "充值记录状态已变更")
}
// 增加钱包余额(乐观锁)
balanceResult := tx.Model(&model.AgentWallet{}).
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
Updates(map[string]interface{}{
"balance": gorm.Expr("balance + ?", record.Amount),
"version": gorm.Expr("version + 1"),
})
if balanceResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, balanceResult.Error, "更新钱包余额失败")
if s.agentWalletPosting == nil {
return errors.New(errors.CodeInternalError, "代理主钱包入账能力未配置")
}
if balanceResult.RowsAffected == 0 {
return errors.New(errors.CodeInternalError, "钱包余额更新冲突,请重试")
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
// 创建钱包交易记录
remark := "线下充值确认"
refType := "topup"
txRecord := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID,
ShopID: record.ShopID,
UserID: userID,
TransactionType: constants.AgentTransactionTypeRecharge,
Amount: record.Amount,
BalanceBefore: wallet.Balance,
BalanceAfter: wallet.Balance + record.Amount,
Status: constants.TransactionStatusSuccess,
ReferenceType: &refType,
ReferenceID: &record.ID,
Remark: &remark,
Creator: userID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := s.agentWalletTxStore.CreateWithTx(ctx, tx, txRecord); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败")
if _, err := s.agentWalletPosting.PostInTx(ctx, tx, walletapp.PostingCommand{
ShopID: record.ShopID, WalletID: record.AgentWalletID, Amount: record.Amount,
ReferenceType: constants.ReferenceTypeTopup, ReferenceID: record.ID,
TransactionType: constants.AgentTransactionTypeRecharge, UserID: userID, Creator: userID,
Remark: "线下充值确认", RequestID: requestID, CorrelationID: record.RechargeNo,
}); err != nil {
return err
}
return nil
@@ -293,7 +271,8 @@ func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOffline
// HandlePaymentCallback 处理支付回调
// 幂等处理status != 待支付则直接返回成功
func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string) error {
func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string, paidAmount int64) error {
paymentTransactionID = strings.TrimSpace(paymentTransactionID)
record, err := s.agentRechargeStore.GetByRechargeNo(ctx, rechargeNo)
if err != nil {
if err == gorm.ErrRecordNotFound {
@@ -302,25 +281,23 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string,
return errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单失败")
}
// 幂等检查
if err := validateAgentRechargePayment(record, paymentMethod, paymentTransactionID, paidAmount); err != nil {
return err
}
// 已完成订单仅在第三方交易号一致时按同一回调幂等成功。
if record.Status != constants.RechargeStatusPending {
s.logger.Info("代理充值订单已处理,跳过",
zap.String("recharge_no", rechargeNo),
zap.Int("status", record.Status),
)
return nil
if record.Status == constants.RechargeStatusCompleted && record.PaymentTransactionID != nil && *record.PaymentTransactionID == strings.TrimSpace(paymentTransactionID) {
s.logger.Info("代理充值支付回调幂等命中", zap.String("recharge_no", rechargeNo), zap.Int("status", record.Status))
return nil
}
return errors.New(errors.CodeInvalidStatus, "充值订单状态不允许确认支付")
}
wallet, err := s.agentWalletStore.GetByID(ctx, record.AgentWalletID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理钱包失败")
}
now := time.Now()
err = s.db.Transaction(func(tx *gorm.DB) error {
// 条件更新WHERE status = 1
result := tx.Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status = ?", record.ID, constants.RechargeStatusPending).
Where("id = ? AND status = ? AND payment_method <> ? AND amount = ?", record.ID, constants.RechargeStatusPending, constants.RechargeMethodOffline, paidAmount).
Updates(map[string]interface{}{
"status": constants.RechargeStatusCompleted,
"payment_transaction_id": paymentTransactionID,
@@ -331,44 +308,31 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string,
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新充值记录状态失败")
}
if result.RowsAffected == 0 {
return nil
var current model.AgentRechargeRecord
if err := tx.WithContext(ctx).Unscoped().
Where("id = ?", record.ID).First(&current).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "复核充值记录终态失败")
}
if current.Status == constants.RechargeStatusCompleted && current.PaymentTransactionID != nil && *current.PaymentTransactionID == paymentTransactionID {
return nil
}
return errors.New(errors.CodeConflict, "充值订单已被其他请求处理")
}
// 增加钱包余额(乐观锁)
balanceResult := tx.Model(&model.AgentWallet{}).
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
Updates(map[string]interface{}{
"balance": gorm.Expr("balance + ?", record.Amount),
"version": gorm.Expr("version + 1"),
})
if balanceResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, balanceResult.Error, "更新钱包余额失败")
if s.agentWalletPosting == nil {
return errors.New(errors.CodeInternalError, "代理主钱包入账能力未配置")
}
if balanceResult.RowsAffected == 0 {
return errors.New(errors.CodeInternalError, "钱包余额更新冲突,请重试")
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
// 创建交易记录
remark := "在线支付充值"
refType := "topup"
txRecord := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID,
ShopID: record.ShopID,
UserID: record.UserID,
TransactionType: constants.AgentTransactionTypeRecharge,
Amount: record.Amount,
BalanceBefore: wallet.Balance,
BalanceAfter: wallet.Balance + record.Amount,
Status: constants.TransactionStatusSuccess,
ReferenceType: &refType,
ReferenceID: &record.ID,
Remark: &remark,
Creator: record.UserID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := s.agentWalletTxStore.CreateWithTx(ctx, tx, txRecord); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败")
if _, err := s.agentWalletPosting.PostInTx(ctx, tx, walletapp.PostingCommand{
ShopID: record.ShopID, WalletID: record.AgentWalletID, Amount: record.Amount,
ReferenceType: constants.ReferenceTypeTopup, ReferenceID: record.ID,
TransactionType: constants.AgentTransactionTypeRecharge, UserID: record.UserID, Creator: record.UserID,
Remark: "在线支付充值", RequestID: requestID, CorrelationID: record.RechargeNo,
}); err != nil {
return err
}
return nil
@@ -387,6 +351,29 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string,
return nil
}
func validateAgentRechargePayment(record *model.AgentRechargeRecord, paymentMethod, paymentTransactionID string, paidAmount int64) error {
if record == nil || strings.TrimSpace(paymentTransactionID) == "" || paidAmount <= 0 || paidAmount != record.Amount {
return errors.New(errors.CodeInvalidParam, "代理充值支付回调金额或交易号不匹配")
}
if record.PaymentMethod == constants.RechargeMethodOffline || record.PaymentConfigID == nil || record.PaymentChannel == nil {
return errors.New(errors.CodeInvalidStatus, "线下充值订单不能通过支付回调确认")
}
channel := strings.TrimSpace(*record.PaymentChannel)
switch strings.TrimSpace(paymentMethod) {
case model.PaymentMethodWechat:
if record.PaymentMethod != constants.RechargeMethodWechat || (channel != model.ProviderTypeWechat && channel != model.ProviderTypeWechatV2) {
return errors.New(errors.CodeInvalidParam, "代理充值支付渠道不匹配")
}
case model.ProviderTypeFuiou:
if record.PaymentMethod != constants.RechargeMethodWechat || channel != model.ProviderTypeFuiou {
return errors.New(errors.CodeInvalidParam, "代理充值支付渠道不匹配")
}
default:
return errors.New(errors.CodeInvalidParam, "代理充值支付渠道不受支持")
}
return nil
}
// Reject 驳回代理充值订单
// 仅 status=1待支付的订单可驳回驳回后状态变为 6已驳回为终态
func (s *Service) Reject(ctx context.Context, id uint, rejectionReason string) error {

View File

@@ -6,6 +6,8 @@ import (
"strings"
"time"
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/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
@@ -16,6 +18,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
@@ -40,13 +43,6 @@ type PollingCallback interface {
OnCardDisabled(ctx context.Context, cardID uint)
}
// DataDeductor 流量扣减回调接口
// 用于在手动刷新流量后触发套餐扣减,避免循环依赖
type DataDeductor interface {
// DeductDataUsage 按优先级扣减套餐流量
DeductDataUsage(ctx context.Context, carrierType string, carrierID uint, usageMB float64) error
}
// RealnameActivator 实名激活回调接口
// 用于在手动实名后触发待实名套餐激活,避免循环依赖
type RealnameActivator interface {
@@ -65,7 +61,6 @@ type Service struct {
gatewayClient *gateway.Client
logger *zap.Logger
pollingCallback PollingCallback
dataDeductor DataDeductor
realnameActivator RealnameActivator
stopResumeService StopResumeServiceInterface
deviceSimBindingStore *postgres.DeviceSimBindingStore
@@ -75,6 +70,12 @@ type Service struct {
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore
enterpriseStore *postgres.EnterpriseStore
packageExpiryQuery *packageexpiry.Query
cardObservation *cardapp.Service
}
// SetCardObservationService 注入统一卡实名观测写入用例。
func (s *Service) SetCardObservationService(service *cardapp.Service) {
s.cardObservation = service
}
// SetPackageExpiryQuery 注入套餐最终到期查询,供列表使用批量投影。
@@ -129,12 +130,6 @@ func (s *Service) SetEnterpriseStore(store *postgres.EnterpriseStore) {
s.enterpriseStore = store
}
// SetDataDeductor 设置流量扣减回调
// 在应用启动时由 bootstrap 调用,注入套餐扣减服务
func (s *Service) SetDataDeductor(deductor DataDeductor) {
s.dataDeductor = deductor
}
// SetRealnameActivator 设置实名激活回调
// 在应用启动时由 bootstrap 注入套餐激活服务
func (s *Service) SetRealnameActivator(activator RealnameActivator) {
@@ -1563,17 +1558,29 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string)
})
if err != nil {
s.logger.Warn("刷新卡数据:查询网络状态失败", zap.String("iccid", iccid), zap.Error(err))
} else if strings.TrimSpace(statusResp.ICCID) == "" {
s.logger.Warn("刷新卡数据:网络状态响应缺少 ICCID跳过本次网络观测", zap.Uint("card_id", card.ID))
} else if s.cardObservation == nil {
return errors.New(errors.CodeInternalError, "卡网络观测能力未配置")
} else {
gatewayExtend := strings.TrimSpace(statusResp.Extend)
updates["gateway_extend"] = gatewayExtend
networkStatus, ok := gateway.ParseCardNetworkStatus(statusResp.CardStatus, gatewayExtend)
if !ok {
requestID := requestIDFromContext(ctx)
decision, applyErr := s.cardObservation.ApplyNetworkObservation(ctx, carddomain.NetworkObservation{
CardID: card.ID, GatewayStatus: statusResp.CardStatus,
GatewayExtend: statusResp.Extend, GatewayIMEI: statusResp.IMEI,
Metadata: carddomain.ObservationMetadata{
ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualSync,
Scene: constants.CardObservationSceneManualRefresh, ObservedAt: syncTime,
RequestID: requestID, CorrelationID: requestID,
},
})
if applyErr != nil {
return applyErr
}
if !decision.StatusKnown {
s.logger.Warn("刷新卡数据:未知 Gateway 卡状态",
zap.String("iccid", iccid),
zap.String("card_status", statusResp.CardStatus),
zap.String("extend", gatewayExtend))
} else {
updates["network_status"] = networkStatus
zap.String("extend", strings.TrimSpace(statusResp.Extend)))
}
}
@@ -1583,12 +1590,23 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string)
})
if err != nil {
s.logger.Warn("刷新卡数据:查询实名状态失败", zap.String("iccid", iccid), zap.Error(err))
} else if strings.TrimSpace(realnameResp.ICCID) == "" {
s.logger.Warn("刷新卡数据:实名响应缺少 ICCID跳过本次实名观测", zap.Uint("card_id", card.ID))
} else if s.cardObservation == nil {
return errors.New(errors.CodeInternalError, "卡实名观测能力未配置")
} else {
realNameStatus := parseGatewayRealnameStatus(realnameResp.RealStatus)
updates["real_name_status"] = realNameStatus
// 检测 0→1 变化:旧状态非已实名且新状态为已实名,补写 first_realname_at
if card.RealNameStatus != constants.RealNameStatusVerified && realNameStatus == constants.RealNameStatusVerified {
updates["first_realname_at"] = syncTime
requestID := requestIDFromContext(ctx)
_, applyErr := s.cardObservation.ApplyCardObservation(ctx, carddomain.RealnameObservation{
CardID: card.ID,
Verified: realnameResp.RealStatus,
Metadata: carddomain.ObservationMetadata{
ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualSync,
Scene: constants.CardObservationSceneManualRefresh, ObservedAt: syncTime,
RequestID: requestID, CorrelationID: requestID,
},
})
if applyErr != nil {
return applyErr
}
}
@@ -1598,8 +1616,23 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string)
})
if err != nil {
s.logger.Warn("刷新卡数据:查询流量失败", zap.String("iccid", iccid), zap.Error(err))
} else if s.cardObservation == nil {
return errors.New(errors.CodeInternalError, "卡流量观测能力未配置")
} else {
flowIncrementMB = s.calculateRefreshFlowUpdates(card, float64(flowResp.Used), syncTime, updates)
requestID := requestIDFromContext(ctx)
resetDay := postgres.NewCarrierStore(s.db).GetDataResetDay(ctx, card.CarrierID)
decision, applyErr := s.cardObservation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{
CardID: card.ID, GatewayReadingMB: float64(flowResp.Used), ResetDay: resetDay,
Metadata: carddomain.ObservationMetadata{
ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualSync,
Scene: constants.CardObservationSceneManualRefresh, ObservedAt: syncTime,
RequestID: requestID, CorrelationID: requestID,
},
})
if applyErr != nil {
return applyErr
}
flowIncrementMB = decision.IncrementMB
}
}
@@ -1607,16 +1640,6 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string)
return errors.Wrap(errors.CodeInternalError, err, "更新卡数据失败")
}
// 有增量时触发套餐流量扣减
if flowIncrementMB > 0 && s.dataDeductor != nil {
if err := s.dataDeductor.DeductDataUsage(ctx, constants.AssetTypeIotCard, card.ID, flowIncrementMB); err != nil {
s.logger.Warn("手动刷新:套餐流量扣减失败",
zap.Uint("card_id", card.ID),
zap.Float64("increment_mb", flowIncrementMB),
zap.Error(err))
}
}
// 失效轮询缓存,确保轮询系统读到最新的 network_status/real_name_status/流量
if s.pollingCallback != nil {
s.pollingCallback.OnCardStatusChanged(ctx, card.ID)
@@ -1629,97 +1652,6 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string)
return nil
}
// calculateRefreshFlowUpdates 计算手动刷新时的流量增量并填充 updates
// 算法与轮询 calculateFlowUpdates 一致:增量 = 当前网关读数 - 上次网关读数
// 返回本次增量值MB用于触发套餐扣减
func (s *Service) calculateRefreshFlowUpdates(card *model.IotCard, gatewayFlowMB float64, now time.Time, updates map[string]any) float64 {
increment := gatewayFlowMB - card.LastGatewayReadingMB
shouldUpdateGatewayReading := true
if increment < 0 {
// 当前值比上次小,检查是否为上游运营商正常重置
resetDay := s.getCarrierResetDay(card.CarrierID)
if isRefreshResetWindow(now, resetDay) {
// 在重置日窗口内,本次原始值即为增量
increment = gatewayFlowMB
s.logger.Info("手动刷新:检测到上游运营商重置",
zap.Uint("card_id", card.ID),
zap.Float64("gateway_flow", gatewayFlowMB),
zap.Float64("last_reading", card.LastGatewayReadingMB),
zap.Int("reset_day", resetDay))
} else {
// 非重置日出现值下降,异常,不计入
s.logger.Warn("手动刷新:流量异常,非重置日出现值下降",
zap.Uint("card_id", card.ID),
zap.Float64("gateway_flow", gatewayFlowMB),
zap.Float64("last_reading", card.LastGatewayReadingMB))
increment = 0
shouldUpdateGatewayReading = false
}
}
if shouldUpdateGatewayReading {
updates["last_gateway_reading_mb"] = gatewayFlowMB
}
// 检测跨自然月
currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
isCrossMonth := card.CurrentMonthStartDate == nil ||
card.CurrentMonthStartDate.Before(currentMonthStart)
if isCrossMonth {
s.logger.Info("手动刷新:检测到跨月,重置流量计数",
zap.Uint("card_id", card.ID),
zap.Float64("last_month_total", card.CurrentMonthUsageMB))
updates["last_month_total_mb"] = card.CurrentMonthUsageMB
updates["current_month_start_date"] = currentMonthStart
if increment > 0 {
updates["current_month_usage_mb"] = increment
} else {
updates["current_month_usage_mb"] = float64(0)
}
} else if increment > 0 {
// 同月内,增量累加(使用 gorm.Expr 保证原子性)
updates["current_month_usage_mb"] = gorm.Expr("current_month_usage_mb + ?", increment)
}
// 更新卡生命周期总用量
if increment > 0 {
updates["data_usage_mb"] = gorm.Expr("data_usage_mb + ?", int64(increment))
}
// 首次流量查询初始化
if card.CurrentMonthStartDate == nil {
updates["current_month_start_date"] = currentMonthStart
}
return increment
}
// getCarrierResetDay 读取运营商的上游流量重置日
func (s *Service) getCarrierResetDay(carrierID uint) int {
var carrier model.Carrier
if err := s.db.Select("data_reset_day").First(&carrier, carrierID).Error; err != nil {
return 1
}
if carrier.DataResetDay == 0 {
return 1
}
return carrier.DataResetDay
}
// isRefreshResetWindow 判断今天是否在运营商重置日窗口内
// 窗口 = 重置日当天 + 前一天(容错网关数据延迟)
func isRefreshResetWindow(now time.Time, resetDay int) bool {
today := now.Day()
if today == resetDay {
return true
}
resetDate := time.Date(now.Year(), now.Month(), resetDay, 0, 0, 0, 0, now.Location())
prevDay := resetDate.AddDate(0, 0, -1).Day()
return today == prevDay
}
// parseGatewayRealnameStatus 将网关返回的实名状态布尔值转换为 real_name_status 数值
// true=已实名(1)false=未实名(0)
func parseGatewayRealnameStatus(realStatus bool) int {
@@ -2190,22 +2122,20 @@ func (s *Service) ManualUpdateRealnameStatus(ctx context.Context, cardID uint, r
}
oldStatus := card.RealNameStatus
statusChanged := oldStatus != realNameStatus
now := time.Now()
fields := map[string]any{
"last_real_name_check_at": now,
if s.cardObservation == nil {
return nil, errors.New(errors.CodeInternalError, "卡实名观测能力未配置")
}
if statusChanged {
fields["real_name_status"] = realNameStatus
}
if statusChanged &&
oldStatus != constants.RealNameStatusVerified &&
realNameStatus == constants.RealNameStatusVerified {
fields["first_realname_at"] = now
}
if err := s.iotCardStore.UpdateFields(ctx, cardID, fields); err != nil {
requestID := requestIDFromContext(ctx)
if _, err := s.cardObservation.ApplyCardObservation(ctx, carddomain.RealnameObservation{
CardID: cardID,
Verified: realNameStatus == constants.RealNameStatusVerified,
Metadata: carddomain.ObservationMetadata{
ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualOverride,
Scene: constants.CardObservationSceneManualRefresh, ObservedAt: now,
RequestID: requestID, CorrelationID: requestID,
},
}); err != nil {
wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡实名状态失败")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
@@ -2248,10 +2178,6 @@ func (s *Service) ManualUpdateRealnameStatus(ctx context.Context, cardID uint, r
return nil, wrapErr
}
if statusChanged {
s.handleManualRealnameStatusChanged(ctx, oldStatus, freshCard)
}
if s.pollingCallback != nil {
s.pollingCallback.OnCardStatusChanged(ctx, cardID)
}
@@ -2281,6 +2207,14 @@ func (s *Service) ManualUpdateRealnameStatus(ctx context.Context, cardID uint, r
return freshCard, nil
}
func requestIDFromContext(ctx context.Context) string {
requestID := middleware.GetRequestIDFromContext(ctx)
if requestID == nil {
return ""
}
return *requestID
}
// handleManualRealnameStatusChanged 处理手动实名状态变更后的联动逻辑
func (s *Service) handleManualRealnameStatusChanged(ctx context.Context, oldStatus int, card *model.IotCard) {
if card == nil {

View File

@@ -2,12 +2,15 @@ package order
import (
"context"
"crypto/sha256"
"encoding/binary"
"fmt"
"sort"
"strconv"
"strings"
"time"
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"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
@@ -23,6 +26,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/payment"
"github.com/break/junhong_cmp_fiber/pkg/queue"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
@@ -60,6 +64,18 @@ type Service struct {
assetIdentifierStore *postgres.AssetIdentifierStore
personalCustomerStore *postgres.PersonalCustomerStore
personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore
agentWalletDebit *walletapp.DebitService
agentWalletReservation *walletapp.ReservationService
}
// SetAgentWalletDebitService 注入代理主钱包统一扣款用例。
func (s *Service) SetAgentWalletDebitService(service *walletapp.DebitService) {
s.agentWalletDebit = service
}
// SetAgentWalletReservationService 注入代理主钱包统一预占用例。
func (s *Service) SetAgentWalletReservationService(service *walletapp.ReservationService) {
s.agentWalletReservation = service
}
func New(
@@ -188,16 +204,17 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
}
carrierType, carrierID := resolveAdminCarrierInfoFromVars(orderType, iotCardID, deviceID)
existingOrderID, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, orderType, carrierType, carrierID, req.PackageIDs)
existingOrderID, lockToken, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, orderType, carrierType, carrierID, req.PackageIDs)
lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID)
if lockToken != "" {
defer s.releaseOrderCreateLock(ctx, lockKey, lockToken)
}
if err != nil {
return nil, err
}
if existingOrderID > 0 {
return s.Get(ctx, existingOrderID)
}
// 获取到分布式锁后,确保无论成功还是失败都释放
lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID)
defer s.redis.Del(ctx, lockKey)
// offline 订单不检查强充:平台直接操作,不涉及消费者支付门槛,不产生一次性佣金触发条件
if req.PaymentMethod != model.PaymentMethodOffline {
@@ -486,10 +503,16 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
operatorShopID = *operatorID
}
buyerShopID := orderBuyerID
order.IdempotencyKey = orderIdempotencyDigest(idempotencyKey)
if err := s.createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID); err != nil {
existingOrderID, err := s.createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID)
if err != nil {
return nil, err
}
if existingOrderID > 0 {
s.markOrderCreated(ctx, idempotencyKey, existingOrderID)
return s.Get(ctx, existingOrderID)
}
s.markOrderCreated(ctx, idempotencyKey, order.ID)
return s.buildOrderResponse(ctx, order, items), nil
@@ -548,16 +571,17 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest
// 幂等性检查:防止同一买家对同一载体短时间内重复下单
carrierType, carrierID := resolveCarrierInfo(req)
existingOrderID, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, req.OrderType, carrierType, carrierID, req.PackageIDs)
existingOrderID, lockToken, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, req.OrderType, carrierType, carrierID, req.PackageIDs)
lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID)
if lockToken != "" {
defer s.releaseOrderCreateLock(ctx, lockKey, lockToken)
}
if err != nil {
return nil, err
}
if existingOrderID > 0 {
return s.Get(ctx, existingOrderID)
}
// 获取到分布式锁后,确保无论成功还是失败都释放
lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID)
defer s.redis.Del(ctx, lockKey)
forceRechargeCheck := s.checkForceRechargeRequirement(ctx, validationResult)
if forceRechargeCheck.NeedForceRecharge && validationResult.TotalPrice < forceRechargeCheck.ForceRechargeAmount {
@@ -767,10 +791,16 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest
}
operatorShopID := *operatorID
buyerShopID := orderBuyerID
order.IdempotencyKey = orderIdempotencyDigest(idempotencyKey)
if err := s.createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID); err != nil {
existingOrderID, err := s.createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID)
if err != nil {
return nil, err
}
if existingOrderID > 0 {
s.markOrderCreated(ctx, idempotencyKey, existingOrderID)
return s.Get(ctx, existingOrderID)
}
s.markOrderCreated(ctx, idempotencyKey, order.ID)
return s.buildOrderResponse(ctx, order, items), nil
@@ -989,32 +1019,23 @@ func (s *Service) getCostPrice(ctx context.Context, shopID uint, packageID uint)
return allocation.CostPrice, nil
}
// createWalletTransaction 创建钱包流水记录
// ctx: 上下文
// tx: 事务对象
// wallet: 扣款前的钱包快照
// order: 订单快照
// amount: 扣款金额(正数)
// purchaseRole: 订单角色
// relatedShopID: 关联店铺ID代购场景填充下级店铺ID
func (s *Service) createWalletTransaction(ctx context.Context, tx *gorm.DB, wallet *model.AgentWallet, order *model.Order, amount int64, purchaseRole string, relatedShopID *uint) error {
var subtype *string
// debitAgentMainWalletInTx 通过统一 Wallet Application 接缝扣款并写入流水与 Outbox。
func (s *Service) debitAgentMainWalletInTx(ctx context.Context, tx *gorm.DB, order *model.Order, shopID uint, amount int64, relatedShopID *uint) error {
if s.agentWalletDebit == nil {
return errors.New(errors.CodeInternalError, "代理主钱包扣款能力未配置")
}
subtype := ""
remark := "购买套餐"
purchaseRole := order.PurchaseRole
if purchaseRole == "" {
purchaseRole = model.PurchaseRoleSelfPurchase
}
// 根据订单角色确定交易子类型和备注
switch purchaseRole {
case model.PurchaseRoleSelfPurchase:
subtypeVal := constants.WalletTransactionSubtypeSelfPurchase
subtype = &subtypeVal
subtype = constants.WalletTransactionSubtypeSelfPurchase
case model.PurchaseRolePurchaseForSubordinate:
subtypeVal := constants.WalletTransactionSubtypePurchaseForSubordinate
subtype = &subtypeVal
// 查询下级店铺名称,填充到备注
subtype = constants.WalletTransactionSubtypePurchaseForSubordinate
if relatedShopID != nil {
var shop model.Shop
if err := tx.Where("id = ?", *relatedShopID).First(&shop).Error; err == nil {
@@ -1027,34 +1048,19 @@ func (s *Service) createWalletTransaction(ctx context.Context, tx *gorm.DB, wall
userID := middleware.GetUserIDFromContext(ctx)
assetType, assetID, assetIdentifier := buildAgentWalletTransactionAssetSnapshot(order)
// 创建钱包流水记录
transaction := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID,
ShopID: wallet.ShopID,
UserID: userID,
TransactionType: constants.AgentTransactionTypeDeduct,
TransactionSubtype: subtype,
Amount: -amount, // 扣款为负数
BalanceBefore: wallet.Balance,
BalanceAfter: wallet.Balance - amount,
Status: constants.TransactionStatusSuccess,
ReferenceType: strPtr(constants.ReferenceTypeOrder),
ReferenceID: &order.ID,
RelatedShopID: relatedShopID,
AssetType: assetType,
AssetID: assetID,
AssetIdentifier: assetIdentifier,
Remark: &remark,
Creator: userID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
if err := tx.Create(transaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包流水失败")
_, err := s.agentWalletDebit.DebitInTx(ctx, tx, walletapp.DebitCommand{
ShopID: shopID, Amount: amount, ReferenceType: constants.ReferenceTypeOrder, ReferenceID: order.ID,
UserID: userID, Creator: userID, TransactionSubtype: subtype, RelatedShopID: relatedShopID,
AssetType: assetType, AssetID: assetID, AssetIdentifier: assetIdentifier, Remark: remark,
RequestID: requestID, CorrelationID: order.OrderNo,
})
if err != nil {
return err
}
return nil
}
@@ -1108,44 +1114,33 @@ func strPtr(s string) *string {
return &s
}
// createOrderWithWalletPayment 使用钱包支付创建订单并完成支付
// 包含余额检查、扣款、创建流水、激活套餐等操作,在事务中执行
// createOrderWithWalletPayment 使用钱包支付创建订单并完成支付
// 订单、扣款流水、Payment、套餐处理与 Outbox 在同一事务提交。
// ctx: 上下文
// order: 订单对象
// items: 订单明细列表
// operatorShopID: 操作者店铺ID扣款的店铺
// buyerShopID: 买家店铺ID代购场景下级店铺ID
func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model.Order, items []*model.OrderItem, operatorShopID uint, buyerShopID uint) error {
func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model.Order, items []*model.OrderItem, operatorShopID uint, buyerShopID uint) (uint, error) {
if order.ActualPaidAmount == nil {
return errors.New(errors.CodeInternalError, "实际支付金额不能为空")
return 0, errors.New(errors.CodeInternalError, "实际支付金额不能为空")
}
actualAmount := *order.ActualPaidAmount
var existingOrderID uint
// 事务内:加锁读取钱包 + 余额检查 + 创建订单 + 扣款 + 创建流水 + 激活套餐
// 使用 FOR UPDATE 悲观锁替代乐观锁,避免并发请求因 version 过期导致误报余额不足
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1. 加锁读取钱包,并发请求排队等待,不会冲突
var wallet model.AgentWallet
if err := tx.Set("gorm:query_option", "FOR UPDATE").
Where("shop_id = ? AND wallet_type = ?", operatorShopID, constants.AgentWalletTypeMain).
First(&wallet).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeWalletNotFound, "钱包不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
matchedOrderID, err := lockAndFindRecentWalletOrder(ctx, tx, order.IdempotencyKey)
if err != nil {
return err
}
// 2. 余额检查(加锁后读到的是最新余额,结果准确)
if wallet.Balance < actualAmount {
return errors.New(errors.CodeInsufficientBalance, "余额不足")
if matchedOrderID > 0 {
existingOrderID = matchedOrderID
return nil
}
// 3. 创建订单
if err := tx.Create(order).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败")
}
// 4. 创建订单明细
for _, item := range items {
item.OrderID = order.ID
}
@@ -1153,24 +1148,16 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败")
}
// 5. 扣减钱包余额FOR UPDATE 已保证串行,无需 version 条件)
if err := tx.Model(&wallet).Updates(map[string]any{
"balance": gorm.Expr("balance - ?", actualAmount),
"version": gorm.Expr("version + 1"),
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "扣减钱包余额失败")
}
// 6. 创建钱包流水
var relatedShopID *uint
if order.PurchaseRole == model.PurchaseRolePurchaseForSubordinate {
relatedShopID = &buyerShopID
}
if err := s.createWalletTransaction(ctx, tx, &wallet, order, actualAmount, order.PurchaseRole, relatedShopID); err != nil {
if err := s.debitAgentMainWalletInTx(ctx, tx, order, operatorShopID, actualAmount, relatedShopID); err != nil {
return err
}
if err := s.createWalletPaymentRecord(tx, order, model.PaymentByWallet, actualAmount); err != nil {
return err
}
// 7. 激活套餐
if err := s.activatePackage(ctx, tx, order); err != nil {
return err
}
@@ -1179,7 +1166,10 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model
})
if err != nil {
return err
return 0, err
}
if existingOrderID > 0 {
return existingOrderID, nil
}
// 3. 事务外:所有已支付且适用差价佣金的订单都进入佣金计算
@@ -1187,7 +1177,7 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model
s.enqueueCommissionCalculation(ctx, order.ID)
}
return nil
return 0, nil
}
func (s *Service) createOrderWithActivation(ctx context.Context, order *model.Order, items []*model.OrderItem) error {
@@ -1480,16 +1470,22 @@ func (s *Service) cancelOrder(ctx context.Context, order *model.Order) error {
})
}
// unfreezeWalletForCancel 取消订单时解冻钱包余额
// 根据买家类型和订单金额确定解冻金额和目标钱包
// unfreezeWalletForCancel 取消订单时解冻钱包余额
// 代理主钱包以预占事实中的钱包和金额为权威,避免代购订单误用买方店铺。
func (s *Service) unfreezeWalletForCancel(ctx context.Context, tx *gorm.DB, order *model.Order) error {
if order.BuyerType == model.BuyerTypeAgent {
// 代理商钱包(店铺钱包)
wallet, err := s.agentWalletStore.GetMainWallet(ctx, order.BuyerID)
if err != nil {
return errors.Wrap(errors.CodeWalletNotFound, err, "查询代理钱包失败")
if s.agentWalletReservation == nil {
return errors.New(errors.CodeInternalError, "代理主钱包预占能力未配置")
}
return s.agentWalletStore.UnfreezeBalanceWithTx(ctx, tx, wallet.ID, order.TotalAmount)
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
return s.agentWalletReservation.ReleaseInTx(ctx, tx, walletapp.ReservationCommand{
ReferenceType: constants.ReferenceTypeOrder, ReferenceID: order.ID,
UserID: middleware.GetUserIDFromContext(ctx), Creator: middleware.GetUserIDFromContext(ctx),
RequestID: requestID, CorrelationID: order.OrderNo, Remark: "取消订单释放预占资金",
})
} else if order.BuyerType == model.BuyerTypePersonal {
// 个人客户钱包(卡/设备钱包)
var resourceType string
@@ -1524,16 +1520,25 @@ func (s *Service) unfreezeWalletForCancel(ctx context.Context, tx *gorm.DB, orde
return nil
}
func (s *Service) createWalletPaymentRecord(tx *gorm.DB, order *model.Order, paymentMethod string) error {
func (s *Service) createWalletPaymentRecord(tx *gorm.DB, order *model.Order, paymentMethod string, amount int64) error {
paidAt := order.PaidAt
if paidAt == nil {
now := time.Now()
paidAt = &now
}
payment := &model.Payment{
PaymentNo: order.OrderNo,
OrderID: order.ID,
OrderType: model.PaymentOrderTypePackage,
PaymentMethod: paymentMethod,
Amount: order.TotalAmount,
Amount: amount,
Status: model.PaymentRecordStatusPaid,
PaidAt: paidAt,
}
return tx.Create(payment).Error
if err := tx.Create(payment).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包支付记录失败")
}
return nil
}
func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) error {
@@ -1553,7 +1558,10 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
return errors.New(errors.CodeInvalidStatus, "代购订单无需支付")
}
existingPayment, _ := s.paymentStore.GetByOrderIDAndStatus(ctx, orderID, model.PaymentRecordStatusPaid)
existingPayment, err := s.paymentStore.GetByOrderIDAndStatus(ctx, orderID, model.PaymentRecordStatusPaid)
if err != nil && err != gorm.ErrRecordNotFound {
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单支付记录失败")
}
if existingPayment != nil {
return nil
}
@@ -1584,19 +1592,6 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
shouldEnqueueCommission := false
if resourceType == "shop" {
// 代理钱包系统(店铺)
wallet, err := s.agentWalletStore.GetMainWallet(ctx, resourceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeWalletNotFound, "钱包不存在")
}
return err
}
if wallet.Balance < order.TotalAmount {
return errors.New(errors.CodeInsufficientBalance, "余额不足")
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.Order{}).
Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending).
@@ -1631,26 +1626,14 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
actualPaidAmountSnapshot := actualPaidAmount
order.ActualPaidAmount = &actualPaidAmountSnapshot
order.PaidAt = &now
shouldEnqueueCommission = true
walletResult := tx.Model(&model.AgentWallet{}).
Where("id = ? AND balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version).
Updates(map[string]any{
"balance": gorm.Expr("balance - ?", order.TotalAmount),
"version": gorm.Expr("version + 1"),
})
if walletResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, walletResult.Error, "扣减钱包余额失败")
}
if walletResult.RowsAffected == 0 {
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
}
if err := s.createWalletTransaction(ctx, tx, wallet, order, order.TotalAmount, order.PurchaseRole, nil); err != nil {
if err := s.debitAgentMainWalletInTx(ctx, tx, order, resourceID, order.TotalAmount, nil); err != nil {
return err
}
if err := s.createWalletPaymentRecord(tx, order, model.PaymentByWallet); err != nil {
if err := s.createWalletPaymentRecord(tx, order, model.PaymentByWallet, order.TotalAmount); err != nil {
return err
}
@@ -1742,7 +1725,7 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
return errors.Wrap(errors.CodeDatabaseError, err, "创建扣款流水失败")
}
if err := s.createWalletPaymentRecord(tx, order, model.PaymentByWallet); err != nil {
if err := s.createWalletPaymentRecord(tx, order, model.PaymentByWallet, order.TotalAmount); err != nil {
return err
}
@@ -2160,14 +2143,24 @@ func buildOrderIdempotencyKey(buyerType string, buyerID uint, orderType string,
buyerType, buyerID, orderType, carrierType, carrierID, strings.Join(idStrs, ","))
}
func orderIdempotencyDigest(key string) string {
digest := sha256.Sum256([]byte(key))
return fmt.Sprintf("%x", digest)
}
func orderIdempotencyAdvisoryKey(digest string) int64 {
value := sha256.Sum256([]byte(digest))
return int64(binary.BigEndian.Uint64(value[:8]))
}
const (
orderIdempotencyTTL = 3 * time.Minute
orderCreateLockTTL = 10 * time.Second
orderCreateLockTTL = orderIdempotencyTTL
)
// checkOrderIdempotency 检查订单是否已创建Redis SETNX + 分布式锁)
// 返回已存在的 orderID>0 表示重复请求),或 0 表示可以创续创建
func (s *Service) checkOrderIdempotency(ctx context.Context, buyerType string, buyerID uint, orderType string, carrierType string, carrierID uint, packageIDs []uint) (uint, error) {
// 返回已存在的 orderID、当前请求持有的锁令牌以及错误。
func (s *Service) checkOrderIdempotency(ctx context.Context, buyerType string, buyerID uint, orderType string, carrierType string, carrierID uint, packageIDs []uint) (uint, string, error) {
idempotencyKey := buildOrderIdempotencyKey(buyerType, buyerID, orderType, carrierType, carrierID, packageIDs)
redisKey := constants.RedisOrderIdempotencyKey(idempotencyKey)
@@ -2179,21 +2172,22 @@ func (s *Service) checkOrderIdempotency(ctx context.Context, buyerType string, b
s.logger.Info("订单幂等性命中,返回已有订单",
zap.Uint("order_id", uint(orderID)),
zap.String("idempotency_key", idempotencyKey))
return uint(orderID), nil
return uint(orderID), "", nil
}
}
if err != nil && err != redis.Nil {
return 0, "", errors.Wrap(errors.CodeServiceUnavailable, err, "订单幂等缓存不可用")
}
// 第 2 层:分布式锁,防止并发创建
lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID)
locked, err := s.redis.SetNX(ctx, lockKey, time.Now().String(), orderCreateLockTTL).Result()
lockToken := uuid.NewString()
locked, err := s.redis.SetNX(ctx, lockKey, lockToken, orderCreateLockTTL).Result()
if err != nil {
s.logger.Warn("获取订单创建分布式锁失败,继续执行",
zap.Error(err),
zap.String("lock_key", lockKey))
return 0, nil
return 0, "", errors.Wrap(errors.CodeServiceUnavailable, err, "获取订单创建锁失败")
}
if !locked {
return 0, errors.New(errors.CodeTooManyRequests, "订单正在创建中,请勿重复提交")
return 0, "", errors.New(errors.CodeTooManyRequests, "订单正在创建中,请勿重复提交")
}
// 第 3 层:加锁后二次检测,防止锁等待期间已被处理
@@ -2204,14 +2198,50 @@ func (s *Service) checkOrderIdempotency(ctx context.Context, buyerType string, b
s.logger.Info("订单幂等性二次检测命中",
zap.Uint("order_id", uint(orderID)),
zap.String("idempotency_key", idempotencyKey))
return uint(orderID), nil
return uint(orderID), lockToken, nil
}
}
if err != nil && err != redis.Nil {
return 0, lockToken, errors.Wrap(errors.CodeServiceUnavailable, err, "复核订单幂等标记失败")
}
return 0, nil
return 0, lockToken, nil
}
// markOrderCreated 订单创建成功后标记 Redis 并释放分布式锁
// releaseOrderCreateLock 仅释放当前请求持有的订单创建锁。
func (s *Service) releaseOrderCreateLock(ctx context.Context, lockKey, lockToken string) {
const compareAndDelete = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0`
if err := s.redis.Eval(ctx, compareAndDelete, []string{lockKey}, lockToken).Err(); err != nil {
s.logger.Warn("释放订单创建锁失败", zap.String("lock_key", lockKey), zap.Error(err))
}
}
func lockAndFindRecentWalletOrder(ctx context.Context, tx *gorm.DB, idempotencyKey string) (uint, error) {
if idempotencyKey == "" {
return 0, errors.New(errors.CodeInternalError, "钱包订单缺少持久化幂等指纹")
}
if err := tx.WithContext(ctx).Exec("SELECT pg_advisory_xact_lock(?)", orderIdempotencyAdvisoryKey(idempotencyKey)).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "锁定钱包订单幂等指纹失败")
}
var existing model.Order
err := tx.WithContext(ctx).
Where("idempotency_key = ? AND created_at >= CURRENT_TIMESTAMP - (? * INTERVAL '1 second')",
idempotencyKey, int64(orderIdempotencyTTL/time.Second)).
Order("id DESC").First(&existing).Error
if err == nil {
return existing.ID, nil
}
if err == gorm.ErrRecordNotFound {
return 0, nil
}
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询近期重复钱包订单失败")
}
// markOrderCreated 在订单事务提交后写入 Redis 快速幂等标记。
func (s *Service) markOrderCreated(ctx context.Context, idempotencyKey string, orderID uint) {
redisKey := constants.RedisOrderIdempotencyKey(idempotencyKey)
if err := s.redis.Set(ctx, redisKey, strconv.FormatUint(uint64(orderID), 10), orderIdempotencyTTL).Err(); err != nil {

View File

@@ -13,6 +13,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store"
@@ -41,6 +42,7 @@ type Service struct {
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
assetWalletStore *postgres.AssetWalletStore
agentWalletRefundService *walletapp.RefundService
logger *zap.Logger
}
@@ -77,6 +79,11 @@ func New(
}
}
// SetAgentWalletRefundService 注入代理主钱包统一退款回充用例。
func (s *Service) SetAgentWalletRefundService(service *walletapp.RefundService) {
s.agentWalletRefundService = service
}
// Create 创建退款申请
// 校验订单存在且已支付,检查是否存在活跃退款申请,生成退款单号并创建记录
func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dto.RefundResponse, error) {
@@ -299,75 +306,33 @@ func (s *Service) refundWalletPayment(ctx context.Context, tx *gorm.DB, refund *
// refundAgentWalletPayment 将代理钱包支付的订单退款退回原扣款主钱包。
func (s *Service) refundAgentWalletPayment(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, amount int64, operatorID uint) error {
wallet, relatedShopID, subtype, err := s.resolveAgentRefundWallet(tx, order)
if err != nil {
return err
if s.agentWalletRefundService == nil {
return errors.New(errors.CodeInternalError, "代理主钱包退款能力未配置")
}
balanceBefore := wallet.Balance
if err := s.agentWalletStore.AddBalanceWithTx(ctx, tx, wallet.ID, amount); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "退回代理预充值钱包失败")
legacyPayerShopID, legacyRelatedShopID, _ := resolveAgentWalletRefundShopID(order)
legacyDeductAmount := order.TotalAmount
if order.ActualPaidAmount != nil && *order.ActualPaidAmount > 0 {
legacyDeductAmount = *order.ActualPaidAmount
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
correlationID := refund.RefundNo
if correlationID == "" {
correlationID = order.OrderNo
}
refType := constants.ReferenceTypeRefund
refID := refund.ID
remark := fmt.Sprintf("订单%s退款退回预充值钱包", order.OrderNo)
assetType, assetID, assetIdentifier := buildRefundWalletTransactionAssetSnapshot(order)
transaction := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID,
ShopID: wallet.ShopID,
UserID: operatorID,
TransactionType: constants.AgentTransactionTypeRefund,
TransactionSubtype: subtype,
Amount: amount,
BalanceBefore: balanceBefore,
BalanceAfter: balanceBefore + amount,
Status: constants.TransactionStatusSuccess,
ReferenceType: &refType,
ReferenceID: &refID,
RelatedShopID: relatedShopID,
AssetType: assetType,
AssetID: assetID,
AssetIdentifier: assetIdentifier,
Remark: &remark,
Creator: operatorID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := s.agentWalletTransactionStore.CreateWithTx(ctx, tx, transaction); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建代理钱包退款流水失败")
}
return nil
}
// resolveAgentRefundWallet 优先按原扣款流水定位回款钱包,兼容历史缺失扣款流水的订单。
func (s *Service) resolveAgentRefundWallet(tx *gorm.DB, order *model.Order) (*model.AgentWallet, *uint, *string, error) {
var deductTx model.AgentWalletTransaction
err := tx.Where("reference_type = ? AND reference_id = ? AND transaction_type = ?",
constants.ReferenceTypeOrder, order.ID, constants.AgentTransactionTypeDeduct).
Order("id ASC").
First(&deductTx).Error
if err == nil {
wallet, err := lockAgentMainWalletByID(tx, deductTx.AgentWalletID)
if err != nil {
return nil, nil, nil, err
}
return wallet, deductTx.RelatedShopID, deductTx.TransactionSubtype, nil
}
if err != gorm.ErrRecordNotFound {
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询原代理钱包扣款流水失败")
}
payerShopID, relatedShopID, err := resolveAgentWalletRefundShopID(order)
if err != nil {
return nil, nil, nil, err
}
wallet, err := lockAgentMainWalletByShopID(tx, payerShopID)
if err != nil {
return nil, nil, nil, err
}
return wallet, relatedShopID, nil, nil
_, err := s.agentWalletRefundService.RefundInTx(ctx, tx, walletapp.RefundCommand{
OrderID: order.ID, RefundID: refund.ID, Amount: amount,
LegacyPayerShopID: legacyPayerShopID, LegacyDeductAmount: legacyDeductAmount,
LegacyRelatedShopID: legacyRelatedShopID, UserID: operatorID, Creator: operatorID,
AssetType: assetType, AssetID: assetID, AssetIdentifier: assetIdentifier,
Remark: remark, RequestID: requestID, CorrelationID: correlationID,
})
return err
}
// resolveAgentWalletRefundShopID 兼容旧订单:没有扣款流水时按订单角色推导原扣款店铺。
@@ -389,34 +354,6 @@ func resolveAgentWalletRefundShopID(order *model.Order) (uint, *uint, error) {
return order.BuyerID, nil, nil
}
// lockAgentMainWalletByID 锁定代理主钱包,保证余额快照与后续入账一致。
func lockAgentMainWalletByID(tx *gorm.DB, walletID uint) (*model.AgentWallet, error) {
var wallet model.AgentWallet
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND wallet_type = ?", walletID, constants.AgentWalletTypeMain).
First(&wallet).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeWalletNotFound, "代理预充值钱包不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理预充值钱包失败")
}
return &wallet, nil
}
// lockAgentMainWalletByShopID 按店铺锁定代理主钱包。
func lockAgentMainWalletByShopID(tx *gorm.DB, shopID uint) (*model.AgentWallet, error) {
var wallet model.AgentWallet
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).
First(&wallet).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeWalletNotFound, "代理预充值钱包不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理预充值钱包失败")
}
return &wallet, nil
}
// refundAssetWalletPayment 将个人资产钱包支付的订单退款退回原资产钱包。
func (s *Service) refundAssetWalletPayment(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, amount int64, operatorID uint) error {
wallet, err := s.resolveAssetRefundWallet(tx, order)

View File

@@ -160,7 +160,7 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
}
// List 查询角色列表
func (s *Service) List(ctx context.Context, req *dto.RoleListRequest) ([]*model.Role, int64, error) {
func (s *Service) List(ctx context.Context, req *dto.RoleListRequest) ([]*dto.RoleResponse, int64, error) {
opts := &store.QueryOptions{
Page: req.Page,
PageSize: req.PageSize,
@@ -184,7 +184,15 @@ func (s *Service) List(ctx context.Context, req *dto.RoleListRequest) ([]*model.
filters["status"] = *req.Status
}
return s.roleStore.List(ctx, opts, filters)
roles, total, err := s.roleStore.List(ctx, opts, filters)
if err != nil {
return nil, 0, errors.Wrap(errors.CodeInternalError, err, "查询角色列表失败")
}
result := make([]*dto.RoleResponse, 0, len(roles))
for _, role := range roles {
result = append(result, toResponse(role))
}
return result, total, nil
}
// AssignPermissions 为角色分配权限
@@ -358,15 +366,18 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
// toResponse 将 model.Role 转换为 dto.RoleResponse
func toResponse(role *model.Role) *dto.RoleResponse {
return &dto.RoleResponse{
ID: role.ID,
RoleName: role.RoleName,
RoleDesc: role.RoleDesc,
RoleType: role.RoleType,
Status: role.Status,
Creator: role.Creator,
Updater: role.Updater,
CreatedAt: role.CreatedAt.Format(time.RFC3339),
UpdatedAt: role.UpdatedAt.Format(time.RFC3339),
ID: role.ID,
RoleName: role.RoleName,
RoleDesc: role.RoleDesc,
RoleType: role.RoleType,
Status: role.Status,
DefaultCreditEnabled: role.DefaultCreditEnabled,
DefaultCreditLimit: role.DefaultCreditLimit,
DefaultCreditScope: "new_shops_only",
Creator: role.Creator,
Updater: role.Updater,
CreatedAt: role.CreatedAt.Format(time.RFC3339),
UpdatedAt: role.UpdatedAt.Format(time.RFC3339),
}
}

View File

@@ -10,17 +10,14 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type Service struct {
shopStore *postgres.ShopStore
accountStore *postgres.AccountStore
shopRoleStore *postgres.ShopRoleStore
roleStore *postgres.RoleStore
accountRoleStore *postgres.AccountRoleStore
agentWalletStore *postgres.AgentWalletStore
shopStore *postgres.ShopStore
accountStore *postgres.AccountStore
shopRoleStore *postgres.ShopRoleStore
roleStore *postgres.RoleStore
}
func New(
@@ -28,177 +25,15 @@ func New(
accountStore *postgres.AccountStore,
shopRoleStore *postgres.ShopRoleStore,
roleStore *postgres.RoleStore,
accountRoleStore *postgres.AccountRoleStore,
agentWalletStore *postgres.AgentWalletStore,
) *Service {
return &Service{
shopStore: shopStore,
accountStore: accountStore,
shopRoleStore: shopRoleStore,
roleStore: roleStore,
accountRoleStore: accountRoleStore,
agentWalletStore: agentWalletStore,
shopStore: shopStore,
accountStore: accountStore,
shopRoleStore: shopRoleStore,
roleStore: roleStore,
}
}
func (s *Service) Create(ctx context.Context, req *dto.CreateShopRequest) (*dto.ShopResponse, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
existing, err := s.shopStore.GetByCode(ctx, req.ShopCode)
if err == nil && existing != nil {
return nil, errors.New(errors.CodeShopCodeExists, "店铺编号已存在")
}
level := 1
parentShopName := ""
if req.ParentID != nil {
parent, err := s.shopStore.GetByID(ctx, *req.ParentID)
if err != nil {
return nil, errors.New(errors.CodeInvalidParentID, "上级店铺不存在或无效")
}
parentShopName = parent.ShopName
level = parent.Level + 1
if level > constants.ShopMaxLevel {
return nil, errors.New(errors.CodeShopLevelExceeded, "店铺层级不能超过 7 级")
}
}
existingAccount, err := s.accountStore.GetByUsername(ctx, req.InitUsername)
if err == nil && existingAccount != nil {
return nil, errors.New(errors.CodeUsernameExists, "初始账号用户名已存在")
}
existingAccount, err = s.accountStore.GetByPhone(ctx, req.InitPhone)
if err == nil && existingAccount != nil {
return nil, errors.New(errors.CodePhoneExists, "初始账号手机号已存在")
}
// 验证默认角色:必须存在、是客户角色且已启用
defaultRole, err := s.roleStore.GetByID(ctx, req.DefaultRoleID)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "请选择默认角色")
}
if defaultRole.RoleType != constants.RoleTypeCustomer {
return nil, errors.New(errors.CodeInvalidParam, "店铺默认角色必须是客户角色")
}
if defaultRole.Status != constants.StatusEnabled {
return nil, errors.New(errors.CodeInvalidParam, "默认角色已禁用")
}
shop := &model.Shop{
ShopName: req.ShopName,
ShopCode: req.ShopCode,
ParentID: req.ParentID,
Level: level,
ContactName: req.ContactName,
ContactPhone: req.ContactPhone,
Province: req.Province,
City: req.City,
District: req.District,
Address: req.Address,
Status: constants.ShopStatusEnabled,
}
shop.Creator = currentUserID
shop.Updater = currentUserID
if err := s.shopStore.Create(ctx, shop); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建店铺失败")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.InitPassword), bcrypt.DefaultCost)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败")
}
account := &model.Account{
Username: req.InitUsername,
Phone: req.InitPhone,
Password: string(hashedPassword),
UserType: constants.UserTypeAgent,
ShopID: &shop.ID,
Status: constants.StatusEnabled,
IsPrimary: true,
}
account.Creator = currentUserID
account.Updater = currentUserID
if err := s.accountStore.Create(ctx, account); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建初始账号失败")
}
// 为初始账号分配默认角色
accountRole := &model.AccountRole{
AccountID: account.ID,
RoleID: req.DefaultRoleID,
Status: constants.StatusEnabled,
Creator: currentUserID,
Updater: currentUserID,
}
if err := s.accountRoleStore.Create(ctx, accountRole); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "为初始账号分配角色失败")
}
// 设置店铺默认角色
shopRole := &model.ShopRole{
ShopID: shop.ID,
RoleID: req.DefaultRoleID,
Status: constants.StatusEnabled,
Creator: currentUserID,
Updater: currentUserID,
}
if err := s.shopRoleStore.BatchCreate(ctx, []*model.ShopRole{shopRole}); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "设置店铺默认角色失败")
}
// 初始化店铺代理钱包:主钱包 + 分佣钱包
// 新店铺必须有两个钱包才能参与充值和分佣体系
wallets := []*model.AgentWallet{
{
ShopID: shop.ID,
WalletType: constants.AgentWalletTypeMain,
Balance: 0,
Currency: "CNY",
Status: constants.AgentWalletStatusNormal,
ShopIDTag: shop.ID,
},
{
ShopID: shop.ID,
WalletType: constants.AgentWalletTypeCommission,
Balance: 0,
Currency: "CNY",
Status: constants.AgentWalletStatusNormal,
ShopIDTag: shop.ID,
},
}
for _, wallet := range wallets {
if err := s.agentWalletStore.Create(ctx, wallet); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "初始化店铺钱包失败")
}
}
return &dto.ShopResponse{
ID: shop.ID,
ShopName: shop.ShopName,
ShopCode: shop.ShopCode,
ParentID: shop.ParentID,
ParentShopName: parentShopName,
Level: shop.Level,
ContactName: shop.ContactName,
ContactPhone: shop.ContactPhone,
Province: shop.Province,
City: shop.City,
District: shop.District,
Address: shop.Address,
Status: shop.Status,
StatusName: constants.GetStatusName(shop.Status),
CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"),
}, nil
}
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopRequest) (*dto.ShopResponse, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {

View File

@@ -56,147 +56,6 @@ func New(
}
}
// ListShopFundSummary 代理商资金概况列表(含预充值余额和佣金钱包)
// GET /shops/fund-summary
func (s *Service) ListShopFundSummary(ctx context.Context, req *dto.ShopFundSummaryListReq) (*dto.ShopFundSummaryPageResult, error) {
opts := &store.QueryOptions{
Page: req.Page,
PageSize: req.PageSize,
OrderBy: "created_at DESC",
}
if opts.Page == 0 {
opts.Page = 1
}
if opts.PageSize == 0 {
opts.PageSize = constants.DefaultPageSize
}
filters := make(map[string]interface{})
if req.ShopName != "" {
filters["shop_name"] = req.ShopName
}
shops, total, err := s.shopStore.List(ctx, opts, filters)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询店铺列表失败")
}
if len(shops) == 0 {
return &dto.ShopFundSummaryPageResult{
Items: []dto.ShopFundSummaryItem{},
Total: 0,
Page: opts.Page,
Size: opts.PageSize,
}, nil
}
shopIDs := make([]uint, 0, len(shops))
for _, shop := range shops {
shopIDs = append(shopIDs, shop.ID)
}
// 批量获取主钱包(预充值钱包)余额
mainWallets, err := s.agentWalletStore.GetShopMainWalletBatch(ctx, shopIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询主钱包余额失败")
}
walletSummaries, err := s.agentWalletStore.GetShopCommissionSummaryBatch(ctx, shopIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询店铺钱包汇总失败")
}
withdrawnAmounts, err := s.commissionWithdrawalReqStore.SumAmountByShopIDsAndStatus(ctx, shopIDs, constants.WithdrawalStatusApproved)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询已提现金额失败")
}
withdrawingAmounts, err := s.commissionWithdrawalReqStore.SumAmountByShopIDsAndStatus(ctx, shopIDs, constants.WithdrawalStatusPending)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询提现中金额失败")
}
primaryAccounts, err := s.accountStore.GetPrimaryAccountsByShopIDs(ctx, shopIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询主账号失败")
}
accountMap := make(map[uint]*model.Account)
for _, acc := range primaryAccounts {
if acc.ShopID != nil {
accountMap[*acc.ShopID] = acc
}
}
items := make([]dto.ShopFundSummaryItem, 0, len(shops))
for _, shop := range shops {
if req.Username != "" {
acc := accountMap[shop.ID]
if acc == nil || !containsSubstring(acc.Username, req.Username) {
total--
continue
}
}
item := s.buildFundSummaryItem(shop, mainWallets[shop.ID], walletSummaries[shop.ID], withdrawnAmounts[shop.ID], withdrawingAmounts[shop.ID], accountMap[shop.ID])
items = append(items, item)
}
return &dto.ShopFundSummaryPageResult{
Items: items,
Total: total,
Page: opts.Page,
Size: opts.PageSize,
}, nil
}
// buildFundSummaryItem 构造资金概况条目
func (s *Service) buildFundSummaryItem(shop *model.Shop, mainWallet, commissionWallet *model.AgentWallet, withdrawnAmount, withdrawingAmount int64, account *model.Account) dto.ShopFundSummaryItem {
// 主钱包余额若未充值则为0
var mainBalance, mainFrozenBalance int64
if mainWallet != nil {
mainBalance = mainWallet.Balance
mainFrozenBalance = mainWallet.FrozenBalance
}
// 佣金钱包
var balance, frozenBalance int64
if commissionWallet != nil {
balance = commissionWallet.Balance
frozenBalance = commissionWallet.FrozenBalance
}
totalCommission := balance + frozenBalance + withdrawnAmount
unwithdrawCommission := totalCommission - withdrawnAmount
availableCommission := balance - withdrawingAmount
if availableCommission < 0 {
availableCommission = 0
}
var username, phone string
if account != nil {
username = account.Username
phone = account.Phone
}
return dto.ShopFundSummaryItem{
ShopID: shop.ID,
ShopName: shop.ShopName,
ShopCode: shop.ShopCode,
Username: username,
Phone: phone,
MainBalance: mainBalance,
MainFrozenBalance: mainFrozenBalance,
TotalCommission: totalCommission,
WithdrawnCommission: withdrawnAmount,
UnwithdrawCommission: unwithdrawCommission,
FrozenCommission: frozenBalance,
WithdrawingCommission: withdrawingAmount,
AvailableCommission: availableCommission,
CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
}
}
// ListShopWithdrawalRequests 查询代理商提现记录
// GET /shops/:id/withdrawal-requests
func (s *Service) ListShopWithdrawalRequests(ctx context.Context, shopID uint, req *dto.ShopWithdrawalRequestListReq) (*dto.ShopWithdrawalRequestPageResult, error) {
@@ -832,16 +691,3 @@ func generateWithdrawalNo() string {
now := time.Now()
return fmt.Sprintf("W%s%04d", now.Format("20060102150405"), rand.Intn(10000))
}
func containsSubstring(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 || (len(s) > 0 && len(substr) > 0 && contains(s, substr)))
}
func contains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@@ -263,7 +263,7 @@ func (s *AccountStore) GetPrimaryAccountsByShopIDs(ctx context.Context, shopIDs
Where("shop_id IN ? AND is_primary = ?", shopIDs, true)
// 注意:此处不再应用数据权限过滤
// 因为调用方Service 层)已经保证了 shopIDs 的合法性
// 在 ListShopFundSummary 场景中,店铺列表已由 Service 层过滤
// 在资金概况等批量投影场景中,调用方已经保证 shopIDs 位于当前数据范围内
if err := query.Find(&accounts).Error; err != nil {
return nil, err
}

View File

@@ -80,12 +80,12 @@ func (s *AgentWalletStore) CreateWithTx(ctx context.Context, tx *gorm.DB, wallet
return tx.WithContext(ctx).Create(wallet).Error
}
// DeductFrozenBalanceWithTx 从冻结余额扣款(带事务)
// 用于提现完成后,从冻结余额中扣除金额
// DeductFrozenBalanceWithTx 从分佣钱包冻结余额扣款(带事务)
// 代理主钱包必须使用 Wallet Application禁止通过旧 Store 写接缝变更。
func (s *AgentWalletStore) DeductFrozenBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64) error {
// 扣除冻结余额和总余额
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where("id = ? AND frozen_balance >= ?", walletID, amount).
Where("id = ? AND wallet_type = ? AND frozen_balance >= ?", walletID, constants.AgentWalletTypeCommission, amount).
Updates(map[string]interface{}{
"balance": gorm.Expr("balance - ?", amount),
"frozen_balance": gorm.Expr("frozen_balance - ?", amount),
@@ -106,12 +106,12 @@ func (s *AgentWalletStore) DeductFrozenBalanceWithTx(ctx context.Context, tx *go
return nil
}
// UnfreezeBalanceWithTx 解冻余额到可用余额(带事务)
// 用于提现取消,将冻结余额转回可用余额
// UnfreezeBalanceWithTx 解冻分佣钱包余额(带事务)
// 代理主钱包必须使用 Wallet Application禁止通过旧 Store 写接缝变更。
func (s *AgentWalletStore) UnfreezeBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64) error {
// 减少冻结余额(总余额不变)
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where("id = ? AND frozen_balance >= ?", walletID, amount).
Where("id = ? AND wallet_type = ? AND frozen_balance >= ?", walletID, constants.AgentWalletTypeCommission, amount).
Updates(map[string]interface{}{
"frozen_balance": gorm.Expr("frozen_balance - ?", amount),
"updated_at": time.Now(),
@@ -131,37 +131,11 @@ func (s *AgentWalletStore) UnfreezeBalanceWithTx(ctx context.Context, tx *gorm.D
return nil
}
// FreezeBalanceWithTx 冻结余额(带事务,使用乐观锁)
// 用于提现申请,将可用余额转为冻结状态
func (s *AgentWalletStore) FreezeBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64, version int) error {
// 增加冻结余额(总余额不变),使用乐观锁
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where("id = ? AND balance - frozen_balance >= ? AND version = ?", walletID, amount, version).
Updates(map[string]interface{}{
"frozen_balance": gorm.Expr("frozen_balance + ?", amount),
"version": gorm.Expr("version + 1"),
"updated_at": time.Now(),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound // 可用余额不足或版本冲突
}
// 删除缓存
s.clearWalletCache(ctx, walletID)
return nil
}
// AddBalanceWithTx 增加余额(带事务)
// 用于充值、退款等增加余额的操作
// AddBalanceWithTx 增加分佣钱包余额(带事务)。
// 代理主钱包充值、退款和人工调整必须使用 Wallet Application。
func (s *AgentWalletStore) AddBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64) error {
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where("id = ?", walletID).
Where("id = ? AND wallet_type = ?", walletID, constants.AgentWalletTypeCommission).
Updates(map[string]interface{}{
"balance": gorm.Expr("balance + ?", amount),
"version": gorm.Expr("version + 1"),
@@ -182,32 +156,6 @@ func (s *AgentWalletStore) AddBalanceWithTx(ctx context.Context, tx *gorm.DB, wa
return nil
}
// DeductBalanceWithTx 扣除余额(带事务,使用乐观锁)
// 用于扣款操作,检查可用余额是否充足
func (s *AgentWalletStore) DeductBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64, version int) error {
// 使用乐观锁,检查可用余额是否充足
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where("id = ? AND balance - frozen_balance >= ? AND version = ?", walletID, amount, version).
Updates(map[string]interface{}{
"balance": gorm.Expr("balance - ?", amount),
"version": gorm.Expr("version + 1"),
"updated_at": time.Now(),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound // 余额不足或版本冲突
}
// 删除缓存
s.clearWalletCache(ctx, walletID)
return nil
}
// GetShopCommissionSummaryBatch 批量获取店铺佣金钱包汇总
// 返回 map[shopID]*AgentWallet
func (s *AgentWalletStore) GetShopCommissionSummaryBatch(ctx context.Context, shopIDs []uint) (map[uint]*model.AgentWallet, error) {

View File

@@ -0,0 +1,41 @@
package task
import (
"context"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
"go.uber.org/zap"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// CardObservationSeriesHandler 处理固定零重试的卡观测序列尝试。
type CardObservationSeriesHandler struct {
service *cardapp.SeriesAttemptService
logger *zap.Logger
}
// NewCardObservationSeriesHandler 创建卡观测序列任务处理器。
func NewCardObservationSeriesHandler(service *cardapp.SeriesAttemptService, logger *zap.Logger) *CardObservationSeriesHandler {
return &CardObservationSeriesHandler{service: service, logger: logger}
}
// Handle 解析结构化载荷并执行单次观测。
func (h *CardObservationSeriesHandler) Handle(ctx context.Context, task *asynq.Task) error {
if h == nil || h.service == nil {
return errors.New(errors.CodeInternalError, "卡观测序列任务服务未配置")
}
var payload cardapp.SeriesTaskPayload
if err := sonic.Unmarshal(task.Payload(), &payload); err != nil {
h.logger.Error("解析卡观测序列任务载荷失败", zap.Error(err))
return errors.Wrap(errors.CodeInvalidParam, err, "卡观测序列任务载荷无法解析")
}
if err := h.service.Execute(ctx, payload); err != nil {
h.logger.Warn("卡观测序列当前尝试失败",
zap.String("series_id", payload.SeriesID), zap.Int("attempt", payload.Attempt), zap.Error(err))
return err
}
return nil
}

View File

@@ -0,0 +1,31 @@
package task
import (
"context"
"github.com/hibiken/asynq"
"go.uber.org/zap"
notificationinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
)
// NotificationCleanupHandler 处理低峰通知保留清理任务。
type NotificationCleanupHandler struct {
service *notificationinfra.CleanupService
logger *zap.Logger
}
// NewNotificationCleanupHandler 创建通知保留清理任务处理器。
func NewNotificationCleanupHandler(service *notificationinfra.CleanupService, logger *zap.Logger) *NotificationCleanupHandler {
return &NotificationCleanupHandler{service: service, logger: logger}
}
// Handle 执行有界、可重入的通知分批清理。
func (h *NotificationCleanupHandler) Handle(ctx context.Context, _ *asynq.Task) error {
h.logger.Info("开始执行站内通知保留清理")
if err := h.service.Run(ctx); err != nil {
h.logger.Error("站内通知保留清理失败", zap.String("failure_category", "database"), zap.Error(err))
return err
}
return nil
}

View File

@@ -2,211 +2,110 @@ package task
import (
"context"
"strings"
"time"
"github.com/hibiken/asynq"
"go.uber.org/zap"
"gorm.io/gorm"
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/model"
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// PollingCarddataHandler 流量检查任务处理器
// 职责:调 Gateway 查流量 → 写 DB → 扣减套餐流量 → 调 EvaluateAndAct 判断停复机
// 不做:直接停复机决策,委托给 StopResumeService.EvaluateAndAct
// PollingCarddataHandler 负责流量轮询编排,查询结果统一交给卡观测应用服务。
type PollingCarddataHandler struct {
base *PollingBase
gateway *gateway.Client
iotCardStore *postgres.IotCardStore
carrierStore *postgres.CarrierStore
usageService *packagepkg.UsageService
stopResumeSvc iot_card_svc.StopResumeServiceInterface
base *PollingBase
gateway *gateway.Client
carrier *postgres.CarrierStore
observation *cardapp.Service
integration *integrationlog.Repository
}
// NewPollingCarddataHandler 创建流量检查任务处理器
func NewPollingCarddataHandler(
base *PollingBase,
gw *gateway.Client,
iotCardStore *postgres.IotCardStore,
carrierStore *postgres.CarrierStore,
usageService *packagepkg.UsageService,
stopResumeSvc iot_card_svc.StopResumeServiceInterface,
) *PollingCarddataHandler {
return &PollingCarddataHandler{
base: base,
gateway: gw,
iotCardStore: iotCardStore,
carrierStore: carrierStore,
usageService: usageService,
stopResumeSvc: stopResumeSvc,
}
// NewPollingCarddataHandler 创建流量检查任务处理器
func NewPollingCarddataHandler(base *PollingBase, gw *gateway.Client, carrier *postgres.CarrierStore, observation *cardapp.Service, integration *integrationlog.Repository) *PollingCarddataHandler {
return &PollingCarddataHandler{base: base, gateway: gw, carrier: carrier, observation: observation, integration: integration}
}
// Handle 处理流量检查任务
func (h *PollingCarddataHandler) Handle(ctx context.Context, t *asynq.Task) error {
startTime := time.Now()
cardID, ok := parseTaskPayload(t.Payload(), h.base.logger)
// Handle 处理流量检查任务
func (h *PollingCarddataHandler) Handle(ctx context.Context, task *asynq.Task) error {
startedAt := time.Now()
cardID, ok := parseTaskPayload(task.Payload(), h.base.logger)
if !ok {
return nil
}
if !h.base.acquireConcurrency(ctx, constants.TaskTypePollingCarddata) {
h.base.logger.Debug("并发已满,重新入队", zap.Uint("card_id", cardID))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata)
}
defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingCarddata)
locked, lockErr := h.base.acquireCardTrafficSyncLock(ctx, cardID)
if lockErr != nil {
h.base.logger.Warn("获取卡流量同步锁失败,延迟重入队", zap.Uint("card_id", cardID), zap.Error(lockErr))
return h.base.queueMgr.Requeue(ctx, cardID, constants.TaskTypePollingCarddata, time.Now().Add(5*time.Second))
}
if !locked {
h.base.logger.Info("卡流量同步正在执行,延迟重入队", zap.Uint("card_id", cardID))
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)
h.base.invalidateCardCache(ctx, cardID)
card, err := h.iotCardStore.GetByID(ctx, cardID)
card, err := h.base.iotCardStore.GetByID(ctx, cardID)
if err != nil {
if isNotFound(err) {
return nil
}
h.base.logger.Error("获取卡信息失败", zap.Uint("card_id", cardID), zap.Error(err))
h.base.updateStats(ctx, constants.TaskTypePollingCarddata, false, time.Since(startTime))
return h.failAndRequeue(ctx, cardID, startedAt, "获取卡信息失败", err)
}
if h.gateway == nil {
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata)
}
var gatewayFlowMB float64
if h.gateway != nil {
result, gwErr := h.gateway.QueryFlow(ctx, &gateway.FlowQueryReq{CardNo: card.ICCID})
if gwErr != nil {
h.base.logger.Warn("查询流量失败",
zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID), zap.Error(gwErr))
h.base.updateStats(ctx, constants.TaskTypePollingCarddata, false, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata)
}
gatewayFlowMB = float64(result.Used)
} else {
gatewayFlowMB = card.CurrentMonthUsageMB
attemptStartedAt := time.Now()
attempt, err := startGatewayAttempt(ctx, h.integration, cardID, constants.IntegrationOperationGatewayTraffic, constants.CardObservationSceneTrafficPolling)
if err != nil {
return h.failAndRequeue(ctx, cardID, startedAt, "建立 Gateway 流量 Integration Log 失败", err)
}
result, err := h.gateway.QueryFlow(ctx, &gateway.FlowQueryReq{CardNo: card.ICCID})
if err != nil {
_ = completeGatewayAttempt(ctx, h.integration, attempt, false, attemptStartedAt)
return h.failAndRequeue(ctx, cardID, startedAt, "查询流量失败", err)
}
if strings.TrimSpace(result.ICCID) == "" {
_ = completeGatewayAttempt(ctx, h.integration, attempt, false, attemptStartedAt)
return h.failAndRequeue(ctx, cardID, startedAt, "流量查询响应缺少 ICCID", nil)
}
if logErr := completeGatewayAttempt(ctx, h.integration, attempt, true, attemptStartedAt); logErr != nil {
return h.failAndRequeue(ctx, cardID, startedAt, "完成 Gateway 流量 Integration Log 失败", logErr)
}
if h.observation == nil || h.carrier == nil {
return h.failAndRequeue(ctx, cardID, startedAt, "卡流量观测能力未配置", nil)
}
now := time.Now()
resetDay := h.carrierStore.GetDataResetDay(ctx, card.CarrierID)
updates, flowIncrementMB, isCrossMonth := h.calculateFlowUpdates(card, gatewayFlowMB, now, resetDay)
updates["last_data_check_at"] = now
if h.gateway != nil {
updates["last_sync_time"] = now
decision, err := h.observation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{
CardID: cardID, GatewayReadingMB: float64(result.Used), ResetDay: h.carrier.GetDataResetDay(ctx, card.CarrierID),
Metadata: carddomain.ObservationMetadata{
ObservationID: attempt.IntegrationID, Source: constants.CardObservationSourcePolling,
Scene: constants.CardObservationSceneTrafficPolling, ObservedAt: now, CorrelationID: attempt.IntegrationID,
},
})
if err != nil {
return h.failAndRequeue(ctx, cardID, startedAt, "应用流量观测失败", err)
}
if h.base.verboseLog && h.gateway != nil {
h.base.logger.Info("流量轮询详情",
zap.Uint("card_id", cardID),
zap.String("iccid", card.ICCID),
zap.Float64("gateway_flow_mb", gatewayFlowMB),
zap.Float64("increment_mb", flowIncrementMB),
zap.Bool("is_cross_month", isCrossMonth))
if h.base.verboseLog {
h.base.logger.Info("流量轮询详情", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID),
zap.Float64("gateway_flow_mb", float64(result.Used)), zap.Float64("increment_mb", decision.IncrementMB),
zap.Bool("is_cross_month", decision.CrossMonth), zap.Bool("reading_accepted", decision.ReadingAccepted))
}
if updateErr := h.iotCardStore.UpdateFields(ctx, cardID, updates); updateErr != nil {
h.base.logger.Error("更新卡流量信息失败", zap.Uint("card_id", cardID), zap.Error(updateErr))
h.base.updateStats(ctx, constants.TaskTypePollingCarddata, false, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata)
}
go h.base.insertDataUsageRecord(context.Background(), cardID, flowIncrementMB, now)
if refreshedCard, loadErr := h.iotCardStore.GetByID(ctx, cardID); loadErr == nil {
writeCardToCache(h.base, constants.RedisPollingCardInfoKey(cardID), refreshedCard)
} else {
h.base.invalidateCardCache(ctx, cardID)
h.base.logger.Warn("刷新卡缓存失败", zap.Uint("card_id", cardID), zap.Error(loadErr))
}
if flowIncrementMB > 0 && h.usageService != nil {
if deductErr := h.usageService.DeductDataUsage(ctx, constants.AssetTypeIotCard, cardID, flowIncrementMB); deductErr != nil {
h.base.logger.Warn("套餐流量扣减失败",
zap.Uint("card_id", cardID), zap.Float64("increment_mb", flowIncrementMB), zap.Error(deductErr))
}
}
if h.stopResumeSvc != nil {
freshCard, loadErr := h.iotCardStore.GetByID(ctx, cardID)
if loadErr == nil {
if evalErr := h.stopResumeSvc.EvaluateAndAct(ctx, freshCard); evalErr != nil {
h.base.logger.Warn("流量检查后停复机评估失败",
zap.Uint("card_id", cardID), zap.Error(evalErr))
}
}
}
h.base.updateStats(ctx, constants.TaskTypePollingCarddata, true, time.Since(startTime))
h.base.updateStats(ctx, constants.TaskTypePollingCarddata, true, time.Since(startedAt))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata)
}
// calculateFlowUpdates 计算流量更新字段、增量和是否跨月
// 决策13完整迁移跨月流量边界检测逻辑月份切换检测、上月总量保存、当月计数器重置
// 第三个返回值 isCrossMonth 供调用方同步更新 Redis 缓存,避免下次误判跨月
func (h *PollingCarddataHandler) calculateFlowUpdates(card *model.IotCard, gatewayFlowMB float64, now time.Time, resetDay int) (map[string]any, float64, bool) {
updates := make(map[string]any)
increment := gatewayFlowMB - card.LastGatewayReadingMB
shouldUpdateGatewayReading := true
if increment < 0 {
if isResetWindow(now, resetDay) {
increment = gatewayFlowMB
h.base.logger.Info("检测到上游运营商重置",
zap.Uint("card_id", card.ID),
zap.Float64("gateway_flow", gatewayFlowMB),
zap.Float64("last_reading", card.LastGatewayReadingMB),
zap.Int("reset_day", resetDay))
} else {
h.base.logger.Warn("流量异常:非重置日出现值下降",
zap.Uint("card_id", card.ID),
zap.Float64("gateway_flow", gatewayFlowMB),
zap.Float64("last_reading", card.LastGatewayReadingMB))
increment = 0
shouldUpdateGatewayReading = false
}
func (h *PollingCarddataHandler) failAndRequeue(ctx context.Context, cardID uint, startedAt time.Time, message string, err error) error {
fields := []zap.Field{zap.Uint("card_id", cardID)}
if err != nil {
fields = append(fields, zap.Error(err))
}
if shouldUpdateGatewayReading {
updates["last_gateway_reading_mb"] = gatewayFlowMB
}
currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
isCrossMonth := card.CurrentMonthStartDate == nil ||
card.CurrentMonthStartDate.Before(currentMonthStart)
if isCrossMonth {
h.base.logger.Info("检测到跨月,重置流量计数",
zap.Uint("card_id", card.ID),
zap.Float64("last_month_total", card.CurrentMonthUsageMB))
updates["last_month_total_mb"] = card.CurrentMonthUsageMB
updates["current_month_start_date"] = currentMonthStart
if increment > 0 {
updates["current_month_usage_mb"] = increment
} else {
updates["current_month_usage_mb"] = float64(0)
}
} else if increment > 0 {
updates["current_month_usage_mb"] = gorm.Expr("current_month_usage_mb + ?", increment)
}
if increment > 0 {
updates["data_usage_mb"] = gorm.Expr("data_usage_mb + ?", int64(increment))
}
if card.CurrentMonthStartDate == nil {
updates["current_month_start_date"] = currentMonthStart
}
return updates, increment, isCrossMonth
h.base.logger.Warn(message, fields...)
h.base.updateStats(ctx, constants.TaskTypePollingCarddata, false, time.Since(startedAt))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata)
}

View File

@@ -8,181 +8,108 @@ import (
"github.com/hibiken/asynq"
"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/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"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"
)
// shouldStopPollingForRisk 判断轮询到风险 gateway_extend 时是否应停止该卡的轮询
// 仅独立卡is_standalone=true命中风险状态时才停止绑定设备的卡不受此逻辑约束
func shouldStopPollingForRisk(card *model.IotCard, newGatewayExtend string) bool {
if !card.IsStandalone {
// PollingCardStatusHandler 负责网络状态轮询编排,查询结果统一交给卡观测应用服务。
type PollingCardStatusHandler struct {
base *PollingBase
gateway *gateway.Client
observation *cardapp.Service
integration *integrationlog.Repository
}
// shouldStopPollingForRisk 保留旧测试与调用方的纯判断兼容入口,规则权威在卡观测领域层。
func shouldStopPollingForRisk(card *model.IotCard, extend string) bool {
if card == nil {
return false
}
extend := strings.TrimSpace(newGatewayExtend)
return extend == constants.GatewayCardExtendRiskStop ||
extend == constants.GatewayCardExtendCancelled
return carddomain.ShouldStopPollingForRisk(card.IsStandalone, strings.TrimSpace(extend))
}
// PollingCardStatusHandler 卡开停机状态轮询任务处理器
// 职责:调 Gateway 查卡状态(正常/停机/准备)→ 映射为 network_status → 写 DB → 触发停复机评估
// 不做:直接停复机决策,委托给 StopResumeService.EvaluateAndAct
type PollingCardStatusHandler struct {
base *PollingBase
gateway *gateway.Client
iotCardStore *postgres.IotCardStore
stopResumeSvc iot_card_svc.StopResumeServiceInterface
// NewPollingCardStatusHandler 创建卡状态轮询任务处理器
func NewPollingCardStatusHandler(base *PollingBase, gw *gateway.Client, observation *cardapp.Service, integration *integrationlog.Repository) *PollingCardStatusHandler {
return &PollingCardStatusHandler{base: base, gateway: gw, observation: observation, integration: integration}
}
// NewPollingCardStatusHandler 创建卡状态轮询任务处理器
func NewPollingCardStatusHandler(
base *PollingBase,
gw *gateway.Client,
iotCardStore *postgres.IotCardStore,
stopResumeSvc iot_card_svc.StopResumeServiceInterface,
) *PollingCardStatusHandler {
return &PollingCardStatusHandler{
base: base,
gateway: gw,
iotCardStore: iotCardStore,
stopResumeSvc: stopResumeSvc,
}
}
// Handle 处理卡状态轮询任务
func (h *PollingCardStatusHandler) Handle(ctx context.Context, t *asynq.Task) error {
startTime := time.Now()
cardID, ok := parseTaskPayload(t.Payload(), h.base.logger)
// Handle 处理卡状态轮询任务
func (h *PollingCardStatusHandler) Handle(ctx context.Context, task *asynq.Task) error {
startedAt := time.Now()
cardID, ok := parseTaskPayload(task.Payload(), h.base.logger)
if !ok {
return nil
}
if !h.base.acquireConcurrency(ctx, constants.TaskTypePollingCardStatus) {
h.base.logger.Debug("并发已满,重新入队", zap.Uint("card_id", cardID))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus)
}
defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingCardStatus)
card, err := h.base.getCardWithCache(ctx, cardID)
if err != nil {
if isNotFound(err) {
return nil
}
h.base.logger.Error("获取卡信息失败", zap.Uint("card_id", cardID), zap.Error(err))
h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, false, time.Since(startTime))
return h.failAndRequeue(ctx, cardID, startedAt, "获取卡信息失败", err)
}
if h.gateway == nil {
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus)
}
newNetworkStatus := card.NetworkStatus
gatewayCardStatus := ""
gatewayExtend := card.GatewayExtend
gatewayCardIMEI := ""
statusQueried := false
if h.gateway != nil {
result, gwErr := h.gateway.QueryCardStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
if gwErr != nil {
h.base.logger.Warn("查询卡状态失败",
zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID), zap.Error(gwErr))
h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, false, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus)
}
gatewayCardStatus = result.CardStatus
gatewayExtend = strings.TrimSpace(result.Extend)
gatewayCardIMEI = strings.TrimSpace(result.IMEI)
statusQueried = true
parsedStatus, ok := gateway.ParseCardNetworkStatus(result.CardStatus, gatewayExtend)
if !ok {
h.base.logger.Warn("卡状态轮询:未知 Gateway 卡状态",
zap.Uint("card_id", cardID),
zap.String("iccid", card.ICCID),
zap.String("card_status", result.CardStatus),
zap.String("extend", gatewayExtend))
} else {
newNetworkStatus = parsedStatus
}
if h.base.verboseLog {
h.base.logger.Info("卡状态轮询详情",
zap.Uint("card_id", cardID),
zap.String("iccid", card.ICCID),
zap.String("card_status", result.CardStatus),
zap.String("extend", gatewayExtend),
zap.Int("new_network_status", newNetworkStatus),
zap.Bool("changed", newNetworkStatus != card.NetworkStatus))
}
attemptStartedAt := time.Now()
attempt, err := startGatewayAttempt(ctx, h.integration, cardID, constants.IntegrationOperationGatewayNetwork, constants.CardObservationSceneNetworkPolling)
if err != nil {
return h.failAndRequeue(ctx, cardID, startedAt, "建立 Gateway 网络 Integration Log 失败", err)
}
result, err := h.gateway.QueryCardStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
if err != nil {
_ = completeGatewayAttempt(ctx, h.integration, attempt, false, attemptStartedAt)
return h.failAndRequeue(ctx, cardID, startedAt, "查询卡状态失败", err)
}
if strings.TrimSpace(result.ICCID) == "" {
_ = completeGatewayAttempt(ctx, h.integration, attempt, false, attemptStartedAt)
return h.failAndRequeue(ctx, cardID, startedAt, "卡状态查询响应缺少 ICCID", nil)
}
if logErr := completeGatewayAttempt(ctx, h.integration, attempt, true, attemptStartedAt); logErr != nil {
return h.failAndRequeue(ctx, cardID, startedAt, "完成 Gateway 网络 Integration Log 失败", logErr)
}
if h.observation == nil {
return h.failAndRequeue(ctx, cardID, startedAt, "卡网络观测能力未配置", nil)
}
statusChanged := newNetworkStatus != card.NetworkStatus
now := time.Now()
// 无论状态是否变化,都更新最后一次检查时间
fields := map[string]any{"last_card_status_check_at": now}
if statusQueried {
fields["gateway_extend"] = gatewayExtend
fields["last_sync_time"] = now
if gatewayCardIMEI != "" {
fields["gateway_card_imei"] = gatewayCardIMEI
}
decision, err := h.observation.ApplyNetworkObservation(ctx, carddomain.NetworkObservation{
CardID: cardID, GatewayStatus: result.CardStatus, GatewayExtend: result.Extend, GatewayIMEI: result.IMEI,
Metadata: carddomain.ObservationMetadata{
ObservationID: attempt.IntegrationID, Source: constants.CardObservationSourcePolling,
Scene: constants.CardObservationSceneNetworkPolling, ObservedAt: now, CorrelationID: attempt.IntegrationID,
},
})
if err != nil {
return h.failAndRequeue(ctx, cardID, startedAt, "应用网络状态观测失败", err)
}
if statusChanged {
fields["network_status"] = newNetworkStatus
// 网关侧停机事件:补写 stop_reason便于状态追踪和区分手动停机
if newNetworkStatus == constants.NetworkStatusOffline &&
card.StopReason == "" &&
gateway.IsGatewayCardStopped(gatewayCardStatus) {
fields["stop_reason"] = constants.StopReasonCarrierStopped
}
if h.base.verboseLog || !decision.StatusKnown {
h.base.logger.Info("卡状态轮询详情", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID),
zap.String("card_status", result.CardStatus), zap.String("extend", strings.TrimSpace(result.Extend)),
zap.Int("new_network_status", decision.AfterStatus), zap.Bool("status_known", decision.StatusKnown),
zap.Bool("changed", decision.StatusChanged), zap.Bool("stop_polling", decision.StopPolling))
}
if updateErr := h.iotCardStore.UpdateFields(ctx, cardID, fields); updateErr != nil {
h.base.logger.Warn("更新卡状态信息失败", zap.Uint("card_id", cardID), zap.Error(updateErr))
h.base.invalidateCardCache(ctx, cardID)
h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, false, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus)
h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, true, time.Since(startedAt))
if decision.StopPolling {
h.base.logger.Info("独立卡命中风险状态,已关闭轮询", zap.Uint("card_id", cardID), zap.String("gateway_extend", decision.GatewayExtend))
return nil
}
if statusQueried || statusChanged {
cacheUpdates := map[string]any{}
if statusQueried {
cacheUpdates["gateway_extend"] = gatewayExtend
}
if statusChanged {
cacheUpdates["network_status"] = newNetworkStatus
}
h.base.updateCardCache(ctx, cardID, cacheUpdates)
}
// 独立卡写入风险 gateway_extend 后:关闭轮询,终止循环
if statusQueried && shouldStopPollingForRisk(card, gatewayExtend) {
if updateErr := h.iotCardStore.UpdatePollingStatus(ctx, cardID, false); updateErr != nil {
h.base.logger.Warn("关闭风险卡轮询状态失败",
zap.Uint("card_id", cardID), zap.String("gateway_extend", gatewayExtend), zap.Error(updateErr))
} else {
h.base.logger.Info("独立卡命中风险状态,已关闭轮询",
zap.Uint("card_id", cardID), zap.String("gateway_extend", gatewayExtend))
}
h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, true, time.Since(startTime))
return nil // 不再入队,终止轮询循环
}
if statusChanged {
h.base.logger.Info("卡状态已变化",
zap.Uint("card_id", cardID),
zap.Int("old_status", card.NetworkStatus),
zap.Int("new_status", newNetworkStatus))
if h.stopResumeSvc != nil {
freshCard, loadErr := h.iotCardStore.GetByID(ctx, cardID)
if loadErr == nil {
if evalErr := h.stopResumeSvc.EvaluateAndAct(ctx, freshCard); evalErr != nil {
h.base.logger.Warn("卡状态变化后停复机评估失败",
zap.Uint("card_id", cardID), zap.Error(evalErr))
}
}
}
}
h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, true, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus)
}
func (h *PollingCardStatusHandler) failAndRequeue(ctx context.Context, cardID uint, startedAt time.Time, message string, err error) error {
fields := []zap.Field{zap.Uint("card_id", cardID)}
if err != nil {
fields = append(fields, zap.Error(err))
}
h.base.logger.Warn(message, fields...)
h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, false, time.Since(startedAt))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus)
}

View File

@@ -0,0 +1,45 @@
package task
import (
"context"
"strconv"
"time"
"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"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// startGatewayAttempt 在实际调用 Gateway 前建立 Integration Log 尝试事实。
func startGatewayAttempt(ctx context.Context, repository *integrationlog.Repository, cardID uint, operation, scene string) (*model.IntegrationLog, error) {
if repository == nil {
return nil, errors.New(errors.CodeInternalError, "Gateway Integration Log 未配置")
}
resourceID := strconv.FormatUint(uint64(cardID), 10)
triggerSource := constants.CardObservationSourcePolling
triggerScene := scene
return repository.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderGateway, Direction: constants.IntegrationDirectionOutbound,
Operation: operation, ResourceType: constants.AssetTypeIotCard, ResourceID: &resourceID,
TriggerSource: &triggerSource, TriggerScene: &triggerScene,
RequestSummary: map[string]any{"card_id": cardID}, Metadata: map[string]any{"scene": scene},
InitialResult: constants.IntegrationResultPending,
})
}
// completeGatewayAttempt 记录 Gateway 查询成功或明确失败,不把响应正文写入日志。
func completeGatewayAttempt(ctx context.Context, repository *integrationlog.Repository, attempt *model.IntegrationLog, success bool, startedAt time.Time) error {
if repository == nil || attempt == nil {
return errors.New(errors.CodeInternalError, "Gateway Integration Log 尝试不存在")
}
result := constants.IntegrationResultFailed
if success {
result = constants.IntegrationResultSuccess
}
_, err := repository.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: result, DurationMS: time.Since(startedAt).Milliseconds(), StateChanged: false,
ResponseSummary: map[string]any{"success": success},
})
return err
}

Some files were not shown because too many files have changed in this diff Show More