合并七月迭代分支

This commit is contained in:
2026-08-18 14:53:29 +08:00
2237 changed files with 91363 additions and 290515 deletions

View File

@@ -0,0 +1,243 @@
// Package accessaudit 定义账号权限与组织简单写用例的统一审计接缝。
package accessaudit
import (
"context"
stderrors "errors"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ChangeAudit 是账号权限与组织变更交给统一审计 Port 的事实。
type ChangeAudit struct {
ActionCode string
Summary string
Result string
ErrorCode string
ErrorSummary string
OperatorID uint
ActorKind string
ActorName string
Source string
ScopeType string
Account *model.Account
Accounts []AccountChange
Shop *model.Shop
ParentShop *model.Shop
Enterprise *model.Enterprise
Cards []IotCardChange
CardAuthorizations []EnterpriseCardAuthorizationChange
Devices []DeviceChange
DeviceBindings []DeviceSimBindingChange
DeviceAuthorizations []EnterpriseDeviceAuthorizationChange
PersonalCustomer *model.PersonalCustomer
PersonalPhones []PersonalCustomerPhoneChange
PersonalOpenIDs []PersonalCustomerOpenIDChange
PersonalDevices []PersonalCustomerDeviceChange
PersonalICCIDs []PersonalCustomerICCIDChange
Role *model.Role
Roles []RoleChange
Permissions []PermissionChange
BeforeData map[string]any
AfterData map[string]any
SubjectVisibility string
SubjectSummary string
SubjectData map[string]any
}
// PersonalCustomerPhoneChange 保存个人客户手机号资源变化。
type PersonalCustomerPhoneChange struct {
Phone *model.PersonalCustomerPhone
BeforeData map[string]any
AfterData map[string]any
}
// PersonalCustomerOpenIDChange 保存个人客户微信主体资源变化。
type PersonalCustomerOpenIDChange struct {
OpenID *model.PersonalCustomerOpenID
BeforeData map[string]any
AfterData map[string]any
}
// PersonalCustomerDeviceChange 保存个人客户设备号绑定资源变化。
type PersonalCustomerDeviceChange struct {
Binding *model.PersonalCustomerDevice
Relation string
Role string
BeforeData map[string]any
AfterData map[string]any
}
// PersonalCustomerICCIDChange 保存个人客户 ICCID 绑定资源变化。
type PersonalCustomerICCIDChange struct {
Binding *model.PersonalCustomerICCID
Relation string
Role string
BeforeData map[string]any
AfterData map[string]any
}
// DeviceChange 保存组织操作关联设备的资源变化与主体安全投影。
type DeviceChange struct {
Device *model.Device
Relation string
Role string
BeforeData map[string]any
AfterData map[string]any
SubjectVisibility string
SubjectSummary string
SubjectData map[string]any
}
// DeviceSimBindingChange 保存企业设备授权涉及的卡槽绑定快照。
type DeviceSimBindingChange struct {
Binding *model.DeviceSimBinding
Relation string
Role string
BeforeData map[string]any
AfterData map[string]any
}
// EnterpriseDeviceAuthorizationChange 保存企业设备授权记录的直接变化。
type EnterpriseDeviceAuthorizationChange struct {
Authorization *model.EnterpriseDeviceAuthorization
Relation string
Role string
BeforeData map[string]any
AfterData map[string]any
}
// IotCardChange 保存组织操作关联卡的资源变化与主体安全投影。
type IotCardChange struct {
Card *model.IotCard
Relation string
Role string
BeforeData map[string]any
AfterData map[string]any
SubjectVisibility string
SubjectSummary string
SubjectData map[string]any
}
// EnterpriseCardAuthorizationChange 保存企业卡授权记录的直接变化。
type EnterpriseCardAuthorizationChange struct {
Authorization *model.EnterpriseCardAuthorization
BeforeData map[string]any
AfterData map[string]any
}
// AccountChange 保存店铺操作关联账号的资源角色与直接变化。
type AccountChange struct {
Account *model.Account
Relation string
Role string
BeforeData map[string]any
AfterData map[string]any
}
// RoleChange 保存主体授权中单个角色资源的前后变化。
type RoleChange struct {
Role *model.Role
BeforeData map[string]any
AfterData map[string]any
}
// PermissionChange 保存单个权限资源的前后变化。
type PermissionChange struct {
Permission *model.Permission
BeforeData map[string]any
AfterData map[string]any
}
// Writer 接收账号权限与组织事务内审计事实。
type Writer interface {
WriteAccessChange(context.Context, *gorm.DB, ChangeAudit) error
}
// RecordFailure 在业务回滚后使用独立短事务记录失败或拒绝事实。
func RecordFailure(ctx context.Context, db *gorm.DB, writer Writer, change ChangeAudit, originalErr error) {
fillFailure(changeError(originalErr), &change)
if db == nil || writer == nil {
recordSecondaryFailure(ctx, change, apperrors.New(apperrors.CodeInvalidStatus, "统一组织审计接缝未配置"))
return
}
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return writer.WriteAccessChange(ctx, tx, change)
}); err != nil {
recordSecondaryFailure(ctx, change, err)
}
}
func changeError(err error) *apperrors.AppError {
var appErr *apperrors.AppError
if stderrors.As(err, &appErr) {
return appErr
}
return apperrors.New(apperrors.CodeInternalError, "账号权限或组织操作失败")
}
func fillFailure(appErr *apperrors.AppError, change *ChangeAudit) {
if change.Result == "" {
change.Result = constants.AuditResultFailed
}
if change.ErrorCode == "" {
change.ErrorCode = strconv.Itoa(appErr.Code)
}
if change.ErrorSummary == "" {
change.ErrorSummary = appErr.Message
}
}
func recordSecondaryFailure(ctx context.Context, change ChangeAudit, err error) {
value := auditcontext.From(ctx)
auditfailure.RecordSecondaryWriteFailure(
change.ActionCode, resourceKey(change), value.RequestID, value.CorrelationID, change.ErrorCode, err,
)
}
func resourceKey(change ChangeAudit) string {
if change.Account != nil {
if change.Account.ID != 0 {
return strconv.FormatUint(uint64(change.Account.ID), 10)
}
return change.Account.Username
}
if change.Enterprise != nil {
if change.Enterprise.ID != 0 {
return strconv.FormatUint(uint64(change.Enterprise.ID), 10)
}
return change.Enterprise.EnterpriseCode
}
if change.PersonalCustomer != nil {
return strconv.FormatUint(uint64(change.PersonalCustomer.ID), 10)
}
if change.Shop != nil {
if change.Shop.ID != 0 {
return strconv.FormatUint(uint64(change.Shop.ID), 10)
}
return change.Shop.ShopCode
}
if change.Role != nil {
if change.Role.ID != 0 {
return strconv.FormatUint(uint64(change.Role.ID), 10)
}
return change.Role.RoleName
}
for _, permission := range change.Permissions {
if permission.Permission == nil {
continue
}
if permission.Permission.ID != 0 {
return strconv.FormatUint(uint64(permission.Permission.ID), 10)
}
return permission.Permission.PermCode
}
return "unknown"
}

View File

@@ -0,0 +1,46 @@
// Package accountaudit 定义账号生命周期写入统一审计的应用边界。
package accountaudit
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"gorm.io/gorm"
)
// LifecycleAudit 是账号生命周期用例提交给统一 Writer 的业务事实。
type LifecycleAudit struct {
ActionCode string
Summary string
Result string
ErrorCode string
ErrorSummary string
Account *model.Account
Shop *model.Shop
Enterprise *model.Enterprise
Roles []*model.Role
BeforeData map[string]any
AfterData map[string]any
}
// SecurityAudit 是账号安全用例提交给统一 Writer 的无凭据业务事实。
type SecurityAudit struct {
ActionCode string
Summary string
Result string
ErrorCode string
ErrorSummary string
ActorID uint
ActorName string
Account *model.Account
AuthenticationKey string
Authentication map[string]any
BeforeData map[string]any
AfterData map[string]any
}
// Writer 在调用方提供的事务内追加账号生命周期事件。
type Writer interface {
WriteAccountLifecycle(ctx context.Context, tx *gorm.DB, audit LifecycleAudit) error
WriteAccountSecurity(ctx context.Context, tx *gorm.DB, audit SecurityAudit) error
}

View File

@@ -0,0 +1,156 @@
package agentrecharge
import (
"context"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ApprovalDecisionHandler 将渠道无关审批终态应用到员工线下代充值业务。
type ApprovalDecisionHandler struct {
db *gorm.DB
posting *walletapp.PostingService
audit RechargeAuditWriter
}
// NewApprovalDecisionHandler 创建员工线下代充值审批终态消费者。
func NewApprovalDecisionHandler(db *gorm.DB, posting *walletapp.PostingService, audit RechargeAuditWriter) *ApprovalDecisionHandler {
return &ApprovalDecisionHandler{db: db, posting: posting, audit: audit}
}
// Handle 幂等处理标准审批终态;只有 approved 首次入账,其他终态不修改钱包。
func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
if h == nil || h.db == nil || h.posting == nil || h.audit == nil {
return errors.New(errors.CodeInternalError, "员工线下代充值审批终态能力未配置")
}
if event.BusinessType != constants.ApprovalBusinessTypeOfflineRecharge || event.BusinessID == 0 || event.InstanceID == 0 {
return errors.New(errors.CodeInvalidParam, "员工线下代充值审批终态参数无效")
}
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID})
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var record model.AgentRechargeRecord
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND payment_method = ?", event.BusinessID, constants.RechargeMethodOffline).
First(&record).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定员工线下代充值申请失败")
}
if record.ApprovalInstanceID == nil || *record.ApprovalInstanceID != event.InstanceID {
return errors.New(errors.CodeConflict, "线下代充值申请关联的审批实例不一致")
}
switch event.Decision {
case constants.ApprovalDecisionApproved:
return h.applyApproved(ctx, tx, &record, event)
case constants.ApprovalDecisionRejected:
return h.closeOfflineRecharge(ctx, tx, &record, event, constants.RechargeStatusRejected, "企业微信审批已拒绝")
case constants.ApprovalDecisionCancelled:
return h.closeOfflineRecharge(ctx, tx, &record, event, constants.RechargeStatusClosed, "企业微信审批已撤销")
case constants.ApprovalDecisionDeleted:
return h.closeOfflineRecharge(ctx, tx, &record, event, constants.RechargeStatusClosed, "企业微信审批已删除")
case constants.ApprovalDecisionRevokedAfterApproved:
return nil
default:
return errors.New(errors.CodeInvalidParam, "不支持的线下代充值审批终态")
}
})
}
func (h *ApprovalDecisionHandler) applyApproved(
ctx context.Context,
tx *gorm.DB,
record *model.AgentRechargeRecord,
event approvalapp.TerminalDecisionEvent,
) error {
if record.Status != constants.RechargeStatusPending && record.Status != constants.RechargeStatusCompleted {
return errors.New(errors.CodeInvalidStatus, "线下代充值申请状态不允许审批入账")
}
if record.Status == constants.RechargeStatusPending {
result := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status = ?", record.ID, constants.RechargeStatusPending).
Updates(map[string]any{
"status": constants.RechargeStatusCompleted, "paid_at": event.OccurredAt,
"completed_at": event.OccurredAt, "updated_at": event.OccurredAt,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "完成线下代充值审批申请失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "线下代充值申请状态已变化")
}
}
posting, err := h.posting.PostInTx(ctx, tx, walletapp.PostingCommand{
ShopID: record.ShopID, WalletID: record.AgentWalletID, Amount: record.Amount,
ReferenceType: constants.ReferenceTypeTopup, ReferenceID: record.ID,
TransactionType: constants.AgentTransactionTypeRecharge,
UserID: event.SubmitterAccountID, Creator: event.SubmitterAccountID,
Remark: "企业微信审批通过线下充值", CorrelationID: event.CorrelationID,
})
if err != nil {
return err
}
if record.Status == constants.RechargeStatusCompleted && posting.AlreadyApplied {
return nil
}
return h.appendTerminalAudit(ctx, tx, record, event, constants.AuditActionAgentRechargeCredited, "企业微信审批通过,代理充值已入账", constants.RechargeStatusCompleted, true)
}
func (h *ApprovalDecisionHandler) closeOfflineRecharge(ctx context.Context, tx *gorm.DB, record *model.AgentRechargeRecord, event approvalapp.TerminalDecisionEvent, status int, reason string) error {
if record.Status == status {
return nil
}
if record.Status != constants.RechargeStatusPending {
return errors.New(errors.CodeInvalidStatus, "线下代充值申请状态不允许结束审批")
}
reason = strings.TrimSpace(reason)
result := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status = ?", record.ID, constants.RechargeStatusPending).
Updates(map[string]any{"status": status, "rejection_reason": reason})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "结束线下代充值审批申请失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "线下代充值申请状态已变化")
}
return h.appendTerminalAudit(ctx, tx, record, event, constants.AuditActionAgentRechargeClosed, reason, status, false)
}
func (h *ApprovalDecisionHandler) appendTerminalAudit(ctx context.Context, tx *gorm.DB, record *model.AgentRechargeRecord, event approvalapp.TerminalDecisionEvent, actionCode, summary string, status int, withWallet bool) error {
after := *record
after.Status = status
change := RechargeAudit{
ActionCode: actionCode, Summary: summary, Record: &after,
BeforeData: map[string]any{"status": record.Status}, AfterData: map[string]any{"status": status},
}
var approval model.ApprovalInstance
if err := tx.WithContext(ctx).First(&approval, event.InstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询线下代充值审批审计快照失败")
}
change.Approval = &approval
if withWallet {
var wallet model.AgentWallet
if err := tx.WithContext(ctx).First(&wallet, record.AgentWalletID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值钱包审计快照失败")
}
var transaction model.AgentWalletTransaction
if err := tx.WithContext(ctx).Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
constants.ReferenceTypeTopup, record.ID, constants.AgentTransactionTypeRecharge, constants.TransactionStatusSuccess).
First(&transaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值入账流水审计快照失败")
}
change.Wallet, change.Transaction = &wallet, &transaction
}
return h.audit.WriteAgentRecharge(ctx, tx, change)
}
var _ approvalapp.BusinessDecisionHandler = (*ApprovalDecisionHandler)(nil)

View File

@@ -0,0 +1,207 @@
package agentrecharge
import (
"context"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
domain "github.com/break/junhong_cmp_fiber/internal/domain/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ConfirmOnlinePaymentCommand 描述验签或主动查单后得到的第三方收款事实。
type ConfirmOnlinePaymentCommand struct {
PaymentNo string
PaymentMethod string
ConfigID uint
MerchantIdentity string
ThirdPartyTradeNo string
Amount int64
PaidAt time.Time
RequestID string
CorrelationID string
ParentEventID string
}
// PaymentConfirmedEvent 是第三方收款事实提交后的代理充值入账事件。
type PaymentConfirmedEvent struct {
EventID string `json:"event_id"`
RechargeID uint `json:"recharge_id"`
RechargeNo string `json:"recharge_no"`
PaymentID uint `json:"payment_id"`
PaymentNo string `json:"payment_no"`
ShopID uint `json:"shop_id"`
WalletID uint `json:"wallet_id"`
UserID uint `json:"user_id"`
Amount int64 `json:"amount"`
PaymentMethod string `json:"payment_method"`
ThirdPartyTradeNo string `json:"third_party_trade_no"`
PaidAt time.Time `json:"paid_at"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
ParentEventID string `json:"parent_event_id,omitempty"`
}
// PaymentConfirmedEventWriter 在支付确认事务内追加可靠入账事件。
type PaymentConfirmedEventWriter interface {
Append(ctx context.Context, tx *gorm.DB, event PaymentConfirmedEvent) error
}
// ConfirmOnlinePaymentResult 返回支付确认是否属于幂等重放。
type ConfirmOnlinePaymentResult struct {
RechargeID uint
PaymentID uint
AlreadyConfirmed bool
}
// ConfirmOnlinePaymentService 统一处理回调和主动查单得到的代理充值支付事实。
type ConfirmOnlinePaymentService struct {
db *gorm.DB
eventWriter PaymentConfirmedEventWriter
auditWriter PaymentAuditWriter
}
// NewConfirmOnlinePaymentService 创建代理充值支付确认用例。
func NewConfirmOnlinePaymentService(db *gorm.DB, eventWriter PaymentConfirmedEventWriter, auditWriter PaymentAuditWriter) *ConfirmOnlinePaymentService {
return &ConfirmOnlinePaymentService{db: db, eventWriter: eventWriter, auditWriter: auditWriter}
}
// Execute 在一个短事务中校验并固化支付事实和可靠入账事件。
func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command ConfirmOnlinePaymentCommand) (*ConfirmOnlinePaymentResult, error) {
if s == nil || s.db == nil || s.eventWriter == nil || s.auditWriter == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "代理充值支付确认能力未配置")
}
command.PaymentNo = strings.TrimSpace(command.PaymentNo)
command.PaymentMethod = strings.TrimSpace(command.PaymentMethod)
command.MerchantIdentity = strings.TrimSpace(command.MerchantIdentity)
command.ThirdPartyTradeNo = strings.TrimSpace(command.ThirdPartyTradeNo)
if command.PaymentNo == "" || command.ConfigID == 0 || command.PaidAt.IsZero() {
return nil, errors.New(errors.CodeInvalidParam, "代理充值支付确认参数不完整")
}
result := &ConfirmOnlinePaymentResult{}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
payment, recharge, err := lockPaymentConfirmationFacts(ctx, tx, command.PaymentNo)
if err != nil {
return err
}
alreadyConfirmed, err := domain.ValidatePaymentConfirmation(toDomainConfirmationFacts(payment, recharge, command))
if err != nil {
return err
}
result.RechargeID = recharge.ID
result.PaymentID = payment.ID
if alreadyConfirmed {
result.AlreadyConfirmed = true
return nil
}
if err := ensureTradeNoAvailable(ctx, tx, payment, command); err != nil {
return err
}
paidAt := command.PaidAt.UTC()
paymentUpdate := tx.WithContext(ctx).Model(&model.Payment{}).
Where("id = ? AND status IN ?", payment.ID, []int{model.PaymentRecordStatusPending, model.PaymentRecordStatusFailed}).
Updates(map[string]any{"status": model.PaymentRecordStatusPaid, "third_party_trade_no": command.ThirdPartyTradeNo, "paid_at": paidAt})
if paymentUpdate.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, paymentUpdate.Error, "更新代理充值支付单失败")
}
if paymentUpdate.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "代理充值支付单状态已变化")
}
rechargeUpdate := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status IN ?", recharge.ID, []int{constants.RechargeStatusPending, constants.RechargeStatusClosed}).
Updates(map[string]any{"status": constants.RechargeStatusPaid, "payment_transaction_id": command.ThirdPartyTradeNo, "paid_at": paidAt})
if rechargeUpdate.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, rechargeUpdate.Error, "更新代理充值单支付状态失败")
}
if rechargeUpdate.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "代理充值单状态已变化")
}
event := PaymentConfirmedEvent{
EventID: "agent-recharge:" + strconv.FormatUint(uint64(recharge.ID), 10) + ":payment-confirmed",
RechargeID: recharge.ID, RechargeNo: recharge.RechargeNo, PaymentID: payment.ID, PaymentNo: payment.PaymentNo,
ShopID: recharge.ShopID, WalletID: recharge.AgentWalletID, UserID: recharge.UserID, Amount: recharge.Amount,
PaymentMethod: command.PaymentMethod, ThirdPartyTradeNo: command.ThirdPartyTradeNo,
PaidAt: paidAt, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
ParentEventID: command.ParentEventID,
}
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入代理充值支付确认事件失败")
}
afterPayment := *payment
afterPayment.Status = model.PaymentRecordStatusPaid
afterPayment.ThirdPartyTradeNo = command.ThirdPartyTradeNo
afterPayment.PaidAt = &paidAt
return s.auditWriter.WriteAgentRechargePayment(ctx, tx, PaymentAudit{
ActionCode: constants.AuditActionPaymentConfirmed, Summary: "确认代理充值支付成功",
Payment: &afterPayment, Recharge: recharge,
BeforeData: map[string]any{"status": payment.Status, "third_party_trade_no": payment.ThirdPartyTradeNo, "paid_at": payment.PaidAt},
AfterData: map[string]any{"status": afterPayment.Status, "third_party_trade_no": afterPayment.ThirdPartyTradeNo, "paid_at": afterPayment.PaidAt},
RechargeBeforeData: map[string]any{"status": recharge.Status, "payment_transaction_id": recharge.PaymentTransactionID, "paid_at": recharge.PaidAt},
RechargeAfterData: map[string]any{"status": constants.RechargeStatusPaid, "payment_transaction_id": command.ThirdPartyTradeNo, "paid_at": paidAt},
})
})
if err != nil {
return nil, err
}
return result, nil
}
func lockPaymentConfirmationFacts(ctx context.Context, tx *gorm.DB, paymentNo string) (*model.Payment, *model.AgentRechargeRecord, error) {
var payment model.Payment
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("payment_no = ?", paymentNo).First(&payment).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, errors.New(errors.CodeNotFound, "代理充值支付单不存在")
}
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理充值支付单失败")
}
var recharge model.AgentRechargeRecord
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", payment.OrderID).First(&recharge).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, errors.New(errors.CodeConflict, "支付单关联的代理充值单不存在")
}
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理充值单失败")
}
return &payment, &recharge, nil
}
func toDomainConfirmationFacts(payment *model.Payment, recharge *model.AgentRechargeRecord, command ConfirmOnlinePaymentCommand) domain.PaymentConfirmationFacts {
paymentConfigID, rechargeConfigID, rechargeChannel := uint(0), uint(0), ""
if payment.PaymentConfigID != nil {
paymentConfigID = *payment.PaymentConfigID
}
if recharge.PaymentConfigID != nil {
rechargeConfigID = *recharge.PaymentConfigID
}
if recharge.PaymentChannel != nil {
rechargeChannel = *recharge.PaymentChannel
}
return domain.PaymentConfirmationFacts{
OrderType: payment.OrderType, ExpectedOrderType: model.PaymentOrderTypeAgentRecharge,
PaymentMethod: payment.PaymentMethod, RechargePaymentMethod: recharge.PaymentMethod, RechargePaymentChannel: rechargeChannel,
PaymentConfigID: paymentConfigID, RechargePaymentConfigID: rechargeConfigID, ConfirmedConfigID: command.ConfigID,
MerchantIdentity: payment.MerchantIdentity, ConfirmedMerchantIdentity: command.MerchantIdentity,
PaymentAmount: payment.Amount, RechargeAmount: recharge.Amount, ConfirmedAmount: command.Amount,
PaymentOrderID: payment.OrderID, RechargeID: recharge.ID, PaymentState: domain.PaymentState(payment.Status),
RechargeStatus: recharge.Status, StoredTradeNo: payment.ThirdPartyTradeNo, ConfirmedTradeNo: command.ThirdPartyTradeNo,
}
}
func ensureTradeNoAvailable(ctx context.Context, tx *gorm.DB, payment *model.Payment, command ConfirmOnlinePaymentCommand) error {
var count int64
if err := tx.WithContext(ctx).Unscoped().Model(&model.Payment{}).
Where("id <> ? AND payment_method = ? AND third_party_trade_no = ?", payment.ID, command.PaymentMethod, command.ThirdPartyTradeNo).
Count(&count).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验第三方交易号唯一性失败")
}
if count > 0 {
return errors.New(errors.CodeConflict, "第三方交易号已被其他支付单使用")
}
return nil
}

View File

@@ -0,0 +1,196 @@
// Package agentrecharge 收口员工线下代充值申请和审批终态业务用例。
package agentrecharge
import (
"context"
"fmt"
"strings"
"github.com/bytedance/sonic"
"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"
)
// CreateOfflineCommand 描述员工创建线下代充值审批申请的稳定输入。
type CreateOfflineCommand struct {
SubmitterAccountID uint
SubmitterUserType int
ShopID uint
RechargeNo string
Amount int64
PaymentVoucherKeys []string
Remark string
}
// CreateOfflineResult 返回已原子保存的业务申请和初始审批状态。
type CreateOfflineResult struct {
Record *model.AgentRechargeRecord
ShopName string
SubmitterName string
ApprovalStatus int
}
// OfflineCreationService 创建员工线下代充值申请及唯一通用审批实例。
type OfflineCreationService struct {
db *gorm.DB
approval approvalapp.Port
audit RechargeAuditWriter
}
// NewOfflineCreationService 创建员工线下代充值申请用例。
func NewOfflineCreationService(db *gorm.DB, approval approvalapp.Port, audit RechargeAuditWriter) *OfflineCreationService {
return &OfflineCreationService{db: db, approval: approval, audit: audit}
}
// Execute 在业务写入前校验审批渠道,并在同一事务保存充值申请、审批实例和提交 Outbox。
func (s *OfflineCreationService) Execute(ctx context.Context, command CreateOfflineCommand) (*CreateOfflineResult, error) {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "员工线下代充值审批能力未配置")
}
if err := validateCreateOfflineCommand(command); err != nil {
return nil, err
}
account, shop, wallet, err := s.loadCreationFacts(ctx, command)
if err != nil {
return nil, err
}
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeOfflineRecharge, SubmitterAccountID: command.SubmitterAccountID,
CorrelationID: strings.TrimSpace(command.RechargeNo),
})
if err != nil {
return nil, err
}
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName)
if err != nil {
return nil, err
}
paymentChannel := constants.RechargeMethodOffline
record := &model.AgentRechargeRecord{
UserID: command.SubmitterAccountID, AgentWalletID: wallet.ID, ShopID: command.ShopID,
RechargeNo: strings.TrimSpace(command.RechargeNo), Amount: command.Amount,
PaymentMethod: constants.RechargeMethodOffline, PaymentChannel: &paymentChannel,
PaymentVoucherKey: model.StringJSONBArray(command.PaymentVoucherKeys), Remark: strings.TrimSpace(command.Remark),
Status: constants.RechargeStatusPending, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
}
var approvalStatus int
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Create(record).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建员工线下代充值申请失败")
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeOfflineRecharge,
BusinessID: record.ID, SubmitterAccountID: command.SubmitterAccountID,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: record.RechargeNo,
})
if err != nil {
return err
}
result := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
Where("id = ? AND approval_instance_id IS NULL", record.ID).
Update("approval_instance_id", reference.InstanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联员工线下代充值审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "员工线下代充值审批实例关联已变化")
}
record.ApprovalInstanceID = &reference.InstanceID
approvalStatus = reference.Status
var instance model.ApprovalInstance
if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询线下代充值审批审计快照失败")
}
return s.audit.WriteAgentRecharge(ctx, tx, RechargeAudit{
ActionCode: constants.AuditActionAgentRechargeCreated, Summary: "创建员工线下代充值申请",
Record: record, Approval: &instance, Wallet: wallet, AfterData: map[string]any{"status": record.Status},
})
})
if err != nil {
return nil, err
}
return &CreateOfflineResult{
Record: record, ShopName: shop.ShopName, SubmitterName: account.Username, ApprovalStatus: approvalStatus,
}, nil
}
func validateCreateOfflineCommand(command CreateOfflineCommand) error {
if command.SubmitterUserType != constants.UserTypePlatform && command.SubmitterUserType != constants.UserTypeSuperAdmin {
return errors.New(errors.CodeForbidden, "线下充值仅平台管理员可操作")
}
if command.SubmitterAccountID == 0 || command.ShopID == 0 || strings.TrimSpace(command.RechargeNo) == "" {
return errors.New(errors.CodeInvalidParam)
}
if command.Amount < constants.AgentRechargeMinAmount || command.Amount > constants.AgentRechargeMaxAmount {
return errors.New(errors.CodeInvalidParam, "充值金额超出允许范围")
}
if len(command.PaymentVoucherKeys) == 0 || len(command.PaymentVoucherKeys) > 5 {
return errors.New(errors.CodeInvalidParam, "线下充值必须上传 1 至 5 个支付凭证")
}
for _, key := range command.PaymentVoucherKeys {
if strings.TrimSpace(key) == "" {
return errors.New(errors.CodeInvalidParam, "线下充值支付凭证不能为空")
}
}
return nil
}
func (s *OfflineCreationService) loadCreationFacts(
ctx context.Context,
command CreateOfflineCommand,
) (*model.Account, *model.Shop, *model.AgentWallet, error) {
var account model.Account
if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", command.SubmitterAccountID, constants.StatusEnabled).First(&account).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, nil, errors.New(errors.CodeForbidden, "提交人账号不可用")
}
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下代充值提交人失败")
}
var shop model.Shop
if err := s.db.WithContext(ctx).Where("id = ?", command.ShopID).First(&shop).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, nil, errors.New(errors.CodeNotFound, "目标店铺不存在")
}
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询目标店铺失败")
}
var wallet model.AgentWallet
if err := s.db.WithContext(ctx).
Where("shop_id = ? AND wallet_type = ? AND status = ?", command.ShopID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal).
First(&wallet).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, nil, errors.New(errors.CodeWalletNotFound, "目标店铺主钱包不存在或不可用")
}
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询目标店铺主钱包失败")
}
return &account, &shop, &wallet, nil
}
func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopName string) ([]byte, []byte, error) {
submitterSnapshot, err := sonic.Marshal(map[string]any{
"account_id": command.SubmitterAccountID, "account_name": submitterName,
"user_type": command.SubmitterUserType,
})
if err != nil {
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码线下代充值提交人快照失败")
}
requestSnapshot, err := sonic.Marshal(map[string]any{
constants.ApprovalFieldRechargeNo: strings.TrimSpace(command.RechargeNo),
constants.ApprovalFieldShopID: command.ShopID,
constants.ApprovalFieldShopName: shopName,
constants.ApprovalFieldAmount: fmt.Sprintf("%d.%02d", command.Amount/100, command.Amount%100),
constants.ApprovalFieldAmountCent: command.Amount,
constants.ApprovalFieldPaymentVoucherKey: command.PaymentVoucherKeys,
constants.ApprovalFieldRemark: strings.TrimSpace(command.Remark),
constants.ApprovalFieldSubmitterID: command.SubmitterAccountID,
constants.ApprovalFieldSubmitterName: submitterName,
})
if err != nil {
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码线下代充值审批业务快照失败")
}
return submitterSnapshot, requestSnapshot, nil
}

View File

@@ -0,0 +1,344 @@
package agentrecharge
import (
"context"
cryptorand "crypto/rand"
stderrors "errors"
"fmt"
"math/big"
"strings"
"time"
"gorm.io/gorm"
domain "github.com/break/junhong_cmp_fiber/internal/domain/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/idempotency"
)
const onlineCreationOperation = "agent-recharge.create-online"
// CreateOnlineCommand 描述从认证上下文构造的在线充值命令。
type CreateOnlineCommand struct {
AccountID uint
UserType int
CurrentShopID uint
Amount int64
PaymentMethod string
RequestID string
PayerClientIP string
}
// CreateOnlineResult 返回在线充值单、支付单及支付链接。
type CreateOnlineResult struct {
Recharge *model.AgentRechargeRecord
Payment *model.Payment
}
// AvailablePaymentMethodsResult 返回当前可用渠道及在线金额边界。
type AvailablePaymentMethodsResult struct {
Methods []string
MinAmount int64
MaxAmount int64
}
// OnlineCreationService 创建代理在线扫码充值单。
type OnlineCreationService struct {
db *gorm.DB
wechat OnlinePaymentPort
alipay OnlinePaymentPort
audit PaymentAuditWriter
}
// NewOnlineCreationService 创建代理在线充值用例并以结构体字段注入两个渠道 Adapter。
func NewOnlineCreationService(db *gorm.DB, wechat, alipay OnlinePaymentPort, audit PaymentAuditWriter) *OnlineCreationService {
return &OnlineCreationService{db: db, wechat: wechat, alipay: alipay, audit: audit}
}
// Execute 以短事务建单,事务外生成支付链接,再条件保存链接或关闭失败订单。
func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlineCommand) (*CreateOnlineResult, error) {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.audit == nil {
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置")
}
command.PaymentMethod = strings.TrimSpace(command.PaymentMethod)
command.RequestID = strings.TrimSpace(command.RequestID)
if err := domain.ValidateOnlineCreation(command.UserType, command.Amount, command.PaymentMethod); err != nil {
return nil, err
}
if command.AccountID == 0 || command.CurrentShopID == 0 || len(command.RequestID) > 64 ||
!idempotency.ValidateScope(onlineCreationScope(command.AccountID), command.RequestID) {
return nil, apperrors.New(apperrors.CodeInvalidParam)
}
fingerprint, err := idempotency.Fingerprint(struct {
Amount int64 `json:"amount"`
PaymentMethod string `json:"payment_method"`
}{command.Amount, command.PaymentMethod})
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeInternalError, err, "生成在线充值请求指纹失败")
}
if replay, found, err := s.loadReplay(ctx, command, fingerprint); err != nil || found {
return replay, err
}
account, shop, wallet, config, adapter, err := s.loadCreationFacts(ctx, command)
if err != nil {
return nil, err
}
result, err := s.createLocalFacts(ctx, command, fingerprint.Value, account, shop, wallet, config)
if err != nil {
if replay, found, replayErr := s.loadReplay(ctx, command, fingerprint); replayErr != nil || found {
return replay, replayErr
}
return nil, err
}
paymentResult, err := adapter.CreatePaymentURL(ctx, OnlinePaymentRequest{
PaymentID: result.Payment.ID, PaymentNo: result.Payment.PaymentNo, CorrelationID: result.Payment.PaymentNo,
Description: "代理主钱包充值", Amount: command.Amount,
ExpireAt: *result.Payment.ExpireAt, PayerClientIP: command.PayerClientIP, Config: config,
})
if err != nil {
if !isUnknownPaymentResult(err) {
if closeErr := s.closeFailedCreation(ctx, result); closeErr != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, closeErr, "支付链接生成失败且关闭本地订单失败")
}
}
return nil, err
}
if strings.TrimSpace(paymentResult.QRContent) == "" {
if closeErr := s.closeFailedCreation(ctx, result); closeErr != nil {
return nil, closeErr
}
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "支付渠道未返回支付链接")
}
update := s.db.WithContext(ctx).Model(&model.Payment{}).
Where("id = ? AND status = ? AND qr_content = ''", result.Payment.ID, model.PaymentRecordStatusPending).
Update("qr_content", paymentResult.QRContent)
if update.Error != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, update.Error, "保存支付链接失败")
}
if update.RowsAffected != 1 {
return nil, apperrors.New(apperrors.CodeConflict, "在线充值支付链接已变化")
}
result.Payment.QRContent = paymentResult.QRContent
return result, nil
}
// AvailablePaymentMethods 按固定顺序返回配置完整的在线支付方式。
func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, userType int) (AvailablePaymentMethodsResult, error) {
result := AvailablePaymentMethodsResult{
Methods: []string{}, MinAmount: constants.AgentOnlineRechargeMinAmount, MaxAmount: constants.AgentRechargeMaxAmount,
}
if userType != constants.UserTypeAgent {
return result, apperrors.New(apperrors.CodeForbidden, "仅代理账号可以查询在线支付方式")
}
var config model.WechatConfig
if err := s.db.WithContext(ctx).Where("is_active = ?", true).First(&config).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return result, nil
}
return result, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败")
}
if s.wechat.Available(&config) {
result.Methods = append(result.Methods, constants.RechargeMethodWechat)
}
if s.alipay.Available(&config) {
result.Methods = append(result.Methods, constants.RechargeMethodAlipay)
}
return result, nil
}
func (s *OnlineCreationService) loadCreationFacts(
ctx context.Context,
command CreateOnlineCommand,
) (*model.Account, *model.Shop, *model.AgentWallet, *model.WechatConfig, OnlinePaymentPort, error) {
var account model.Account
if err := s.db.WithContext(ctx).Where("id = ? AND user_type = ? AND status = ?", command.AccountID, constants.UserTypeAgent, constants.StatusEnabled).First(&account).Error; err != nil || account.ShopID == nil || *account.ShopID != command.CurrentShopID {
if err != nil && !stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询在线充值账号失败")
}
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "当前代理账号不可为该店铺充值")
}
var shop model.Shop
if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", command.CurrentShopID, constants.StatusEnabled).First(&shop).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺失败")
}
var wallet model.AgentWallet
if err := s.db.WithContext(ctx).Where("shop_id = ? AND wallet_type = ? AND status = ?", command.CurrentShopID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal).First(&wallet).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeWalletNotFound, "当前店铺主钱包不存在或不可用")
}
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺主钱包失败")
}
var config model.WechatConfig
if err := s.db.WithContext(ctx).Where("is_active = ?", true).First(&config).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig)
}
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败")
}
adapter := s.adapter(command.PaymentMethod)
if adapter == nil || !adapter.Available(&config) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig)
}
return &account, &shop, &wallet, &config, adapter, nil
}
func (s *OnlineCreationService) createLocalFacts(
ctx context.Context,
command CreateOnlineCommand,
fingerprint string,
account *model.Account,
shop *model.Shop,
wallet *model.AgentWallet,
config *model.WechatConfig,
) (*CreateOnlineResult, error) {
rechargeNo, err := newBusinessNo(constants.AgentRechargeOrderPrefix, time.Now().Format("20060102150405"))
if err != nil {
return nil, err
}
paymentNo, err := newBusinessNo("PAY", fmt.Sprintf("%d", time.Now().UnixMilli()))
if err != nil {
return nil, err
}
expireMinutes := config.AliPayExpireMinutes
if expireMinutes <= 0 {
expireMinutes = model.DefaultAliPayExpireMinutes
}
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
channel, requestID := command.PaymentMethod, command.RequestID
record := &model.AgentRechargeRecord{
UserID: account.ID, AgentWalletID: wallet.ID, ShopID: shop.ID, RechargeNo: rechargeNo,
Amount: command.Amount, PaymentMethod: command.PaymentMethod, PaymentChannel: &channel,
PaymentConfigID: &config.ID, Status: constants.RechargeStatusPending,
RequestID: &requestID, RequestFingerprint: &fingerprint,
ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
}
payment := &model.Payment{
PaymentNo: paymentNo, OrderType: model.PaymentOrderTypeAgentRecharge,
PaymentMethod: command.PaymentMethod, MerchantIdentity: paymentMerchantIdentity(command.PaymentMethod, config),
Amount: command.Amount, Status: model.PaymentRecordStatusPending,
PaymentConfigID: &config.ID, ExpireAt: &expireAt,
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(record).Error; err != nil {
return err
}
payment.OrderID = record.ID
if err := tx.Create(payment).Error; err != nil {
return err
}
return s.audit.WriteAgentRechargePayment(ctx, tx, PaymentAudit{
ActionCode: constants.AuditActionPaymentCreated, Summary: "创建代理充值支付记录",
Payment: payment, Recharge: record, AfterData: map[string]any{"status": payment.Status},
})
})
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "创建在线充值本地订单失败")
}
return &CreateOnlineResult{Recharge: record, Payment: payment}, nil
}
func paymentMerchantIdentity(paymentMethod string, config *model.WechatConfig) string {
if config == nil {
return ""
}
if paymentMethod == constants.RechargeMethodWechat {
return config.WxMchID
}
if paymentMethod == constants.RechargeMethodAlipay {
return config.AliAppID
}
return ""
}
func (s *OnlineCreationService) loadReplay(
ctx context.Context,
command CreateOnlineCommand,
fingerprint idempotency.FingerprintValue,
) (*CreateOnlineResult, bool, error) {
var record model.AgentRechargeRecord
err := s.db.WithContext(ctx).Where("user_id = ? AND request_id = ?", command.AccountID, command.RequestID).First(&record).Error
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, nil
}
if err != nil {
return nil, false, apperrors.Wrap(apperrors.CodeDatabaseError, err, "读取在线充值幂等记录失败")
}
existingFingerprint := ""
if record.RequestFingerprint != nil {
existingFingerprint = *record.RequestFingerprint
}
if existingFingerprint != fingerprint.Value {
return nil, true, apperrors.New(apperrors.CodeConflict, "同一请求标识对应的充值内容不一致")
}
var payment model.Payment
if err := s.db.WithContext(ctx).Where("order_id = ? AND order_type = ?", record.ID, model.PaymentOrderTypeAgentRecharge).First(&payment).Error; err != nil {
return nil, true, apperrors.Wrap(apperrors.CodeDatabaseError, err, "读取在线充值支付单失败")
}
if payment.QRContent == "" {
if record.Status == constants.RechargeStatusClosed || payment.Status == model.PaymentRecordStatusFailed {
return nil, true, apperrors.New(apperrors.CodeInvalidStatus, "原在线充值请求支付链接生成失败")
}
return nil, true, apperrors.New(apperrors.CodeConflict, "在线充值请求正在处理中,请稍后重试")
}
return &CreateOnlineResult{Recharge: &record, Payment: &payment}, true, nil
}
func (s *OnlineCreationService) closeFailedCreation(ctx context.Context, result *CreateOnlineResult) error {
if result == nil || result.Recharge == nil || result.Payment == nil || !domain.CanCloseAfterPaymentURLFailure(result.Recharge.Status) {
return nil
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
paymentUpdate := tx.Model(&model.Payment{}).
Where("id = ? AND status = ?", result.Payment.ID, model.PaymentRecordStatusPending).
Update("status", model.PaymentRecordStatusFailed)
if paymentUpdate.Error != nil {
return apperrors.Wrap(apperrors.CodeDatabaseError, paymentUpdate.Error, "关闭失败支付单失败")
}
rechargeUpdate := tx.Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status = ?", result.Recharge.ID, constants.RechargeStatusPending).
Update("status", constants.RechargeStatusClosed)
if rechargeUpdate.Error != nil {
return apperrors.Wrap(apperrors.CodeDatabaseError, rechargeUpdate.Error, "关闭失败充值单失败")
}
afterPayment := *result.Payment
afterPayment.Status = model.PaymentRecordStatusFailed
return s.audit.WriteAgentRechargePayment(ctx, tx, PaymentAudit{
ActionCode: constants.AuditActionPaymentFailed, Summary: "支付链接生成失败,关闭支付记录",
Payment: &afterPayment, Recharge: result.Recharge,
BeforeData: map[string]any{"status": result.Payment.Status}, AfterData: map[string]any{"status": afterPayment.Status},
RechargeBeforeData: map[string]any{"status": result.Recharge.Status}, RechargeAfterData: map[string]any{"status": constants.RechargeStatusClosed},
})
})
}
func (s *OnlineCreationService) adapter(paymentMethod string) OnlinePaymentPort {
if paymentMethod == constants.RechargeMethodWechat {
return s.wechat
}
if paymentMethod == constants.RechargeMethodAlipay {
return s.alipay
}
return nil
}
func onlineCreationScope(accountID uint) idempotency.Scope {
return idempotency.Scope{Subject: fmt.Sprintf("account:%d", accountID), Operation: onlineCreationOperation}
}
func newBusinessNo(prefix, timestamp string) (string, error) {
random, err := cryptorand.Int(cryptorand.Reader, big.NewInt(1000000))
if err != nil {
return "", apperrors.Wrap(apperrors.CodeInternalError, err, "生成业务单号失败")
}
return fmt.Sprintf("%s%s%06d", prefix, timestamp, random.Int64()), nil
}
func isUnknownPaymentResult(err error) bool {
var appErr *apperrors.AppError
return stderrors.As(err, &appErr) && appErr.Code == apperrors.CodeTimeout
}

View File

@@ -0,0 +1,69 @@
package agentrecharge
import (
"context"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"gorm.io/gorm"
)
// PaymentAudit 描述代理充值支付记录的实际生命周期变化。
type PaymentAudit struct {
ActionCode string
Summary string
Payment *model.Payment
Recharge *model.AgentRechargeRecord
BeforeData map[string]any
AfterData map[string]any
RechargeBeforeData map[string]any
RechargeAfterData map[string]any
}
// PaymentAuditWriter 在支付业务事务内追加统一 Audit Event。
type PaymentAuditWriter interface {
WriteAgentRechargePayment(ctx context.Context, tx *gorm.DB, change PaymentAudit) error
}
const (
// OnlinePaymentStatePending 表示渠道仍在等待付款。
OnlinePaymentStatePending = "pending"
// OnlinePaymentStatePaid 表示渠道已确认收款。
OnlinePaymentStatePaid = "paid"
// OnlinePaymentStateClosed 表示渠道订单已明确关闭。
OnlinePaymentStateClosed = "closed"
// OnlinePaymentStateUnknown 表示渠道状态暂时无法确定。
OnlinePaymentStateUnknown = "unknown"
)
// OnlinePaymentRequest 描述生成支付链接与主动查单所需的最小事实。
type OnlinePaymentRequest struct {
PaymentID uint
PaymentNo string
CorrelationID string
Description string
Amount int64
ExpireAt time.Time
PayerClientIP string
Config *model.WechatConfig
}
// OnlinePaymentResult 描述渠道返回的支付链接。
type OnlinePaymentResult struct {
QRContent string
}
// OnlinePaymentQueryResult 描述统一后的渠道支付状态。
type OnlinePaymentQueryResult struct {
State string
ThirdPartyTradeNo string
Amount int64
PaidAt *time.Time
}
// OnlinePaymentPort 定义代理在线充值需要的最小渠道能力。
type OnlinePaymentPort interface {
Available(config *model.WechatConfig) bool
CreatePaymentURL(ctx context.Context, request OnlinePaymentRequest) (OnlinePaymentResult, error)
Query(ctx context.Context, request OnlinePaymentRequest) (OnlinePaymentQueryResult, error)
}

View File

@@ -0,0 +1,27 @@
package agentrecharge
import (
"context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// RechargeAudit 描述代理充值申请或资金终态的实际变化。
type RechargeAudit struct {
ActionCode string
Summary string
Record *model.AgentRechargeRecord
Payment *model.Payment
Approval *model.ApprovalInstance
Wallet *model.AgentWallet
Transaction *model.AgentWalletTransaction
BeforeData map[string]any
AfterData map[string]any
}
// RechargeAuditWriter 在代理充值业务事务内追加统一 Audit Event。
type RechargeAuditWriter interface {
WriteAgentRecharge(ctx context.Context, tx *gorm.DB, change RechargeAudit) error
}

View File

@@ -0,0 +1,211 @@
package agentrecharge
import (
"context"
"time"
"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"
)
// RecoverOnlinePaymentService 批量收敛长期缺少支付链接或待支付的代理在线充值。
type RecoverOnlinePaymentService struct {
db *gorm.DB
wechat OnlinePaymentPort
alipay OnlinePaymentPort
confirm *ConfirmOnlinePaymentService
audit PaymentAuditWriter
now func() time.Time
}
// NewRecoverOnlinePaymentService 创建代理在线充值支付恢复用例。
func NewRecoverOnlinePaymentService(db *gorm.DB, wechat, alipay OnlinePaymentPort, confirm *ConfirmOnlinePaymentService, audit PaymentAuditWriter) *RecoverOnlinePaymentService {
return &RecoverOnlinePaymentService{db: db, wechat: wechat, alipay: alipay, confirm: confirm, audit: audit, now: time.Now}
}
// ProcessBatch 按固定批次读取本地待处理事实并调用对应渠道收敛状态。
func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, error) {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.confirm == nil || s.audit == nil {
return 0, errors.New(errors.CodeServiceUnavailable, "代理在线充值支付恢复能力未配置")
}
now := s.now().UTC()
var payments []model.Payment
if err := s.db.WithContext(ctx).
Where("order_type = ? AND status = ? AND created_at <= ?", model.PaymentOrderTypeAgentRecharge, model.PaymentRecordStatusPending, now.Add(-constants.AgentRechargeRecoveryMinimumAge)).
Order("created_at ASC, id ASC").Limit(constants.AgentRechargeRecoveryBatchSize).
Find(&payments).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "扫描待恢复代理充值支付单失败")
}
if len(payments) == 0 {
return 0, nil
}
recharges, configs, err := s.loadRecoveryFacts(ctx, payments)
if err != nil {
return 0, err
}
processed := 0
var firstErr error
for index := range payments {
payment := &payments[index]
recharge := recharges[payment.OrderID]
config := recoveryConfig(payment, configs)
if recharge == nil || config == nil {
if firstErr == nil {
firstErr = errors.New(errors.CodeConflict, "待恢复支付单缺少充值单或创建配置")
}
continue
}
if err := s.recoverOne(ctx, payment, recharge, config, now); err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
processed++
}
return processed, firstErr
}
func (s *RecoverOnlinePaymentService) recoverOne(ctx context.Context, payment *model.Payment, recharge *model.AgentRechargeRecord, config *model.WechatConfig, now time.Time) error {
adapter := s.adapter(payment.PaymentMethod)
if adapter == nil {
return errors.New(errors.CodeNoPaymentConfig, "代理充值创建时支付配置不可用")
}
if payment.QRContent == "" {
if !adapter.Available(config) {
return errors.New(errors.CodeNoPaymentConfig, "代理充值支付配置不可用")
}
// 支付宝 WAP 链接由本地签名生成,可以安全重建;微信 H5 下单结果未知时只允许查单。
if payment.PaymentMethod != constants.RechargeMethodAlipay {
return s.queryPayment(ctx, adapter, payment, recharge, config)
}
expireAt := now.Add(30 * time.Minute)
if payment.ExpireAt != nil && payment.ExpireAt.After(now) {
expireAt = *payment.ExpireAt
}
result, err := adapter.CreatePaymentURL(ctx, OnlinePaymentRequest{
PaymentID: payment.ID, PaymentNo: payment.PaymentNo, CorrelationID: payment.PaymentNo,
Description: "代理主钱包充值", Amount: payment.Amount,
ExpireAt: expireAt, Config: config,
})
if err != nil {
// 恢复阶段不能仅凭链接生成错误推断未收款,保留本地状态等待下次查单。
return nil
}
if result.QRContent == "" {
return nil
}
update := s.db.WithContext(ctx).Model(&model.Payment{}).
Where("id = ? AND status = ? AND qr_content = ''", payment.ID, model.PaymentRecordStatusPending).
Update("qr_content", result.QRContent)
if update.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, update.Error, "恢复代理充值支付链接失败")
}
return nil
}
return s.queryPayment(ctx, adapter, payment, recharge, config)
}
func (s *RecoverOnlinePaymentService) queryPayment(ctx context.Context, adapter OnlinePaymentPort, payment *model.Payment, recharge *model.AgentRechargeRecord, config *model.WechatConfig) error {
queryResult, err := adapter.Query(ctx, OnlinePaymentRequest{
PaymentID: payment.ID, PaymentNo: payment.PaymentNo, CorrelationID: payment.PaymentNo, Config: config,
})
if err != nil {
return nil
}
switch queryResult.State {
case OnlinePaymentStatePaid:
if queryResult.PaidAt == nil {
return errors.New(errors.CodeConflict, "支付渠道成功结果缺少支付时间")
}
_, err = s.confirm.Execute(ctx, ConfirmOnlinePaymentCommand{
PaymentNo: payment.PaymentNo, PaymentMethod: payment.PaymentMethod, ConfigID: config.ID,
MerchantIdentity: paymentMerchantIdentity(payment.PaymentMethod, config),
ThirdPartyTradeNo: queryResult.ThirdPartyTradeNo, Amount: queryResult.Amount, PaidAt: *queryResult.PaidAt,
CorrelationID: payment.PaymentNo,
})
return err
case OnlinePaymentStateClosed:
return s.closePending(ctx, payment, recharge)
default:
return nil
}
}
func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, payments []model.Payment) (map[uint]*model.AgentRechargeRecord, map[uint]*model.WechatConfig, error) {
rechargeIDs := make([]uint, 0, len(payments))
configIDs := make([]uint, 0, len(payments))
for index := range payments {
rechargeIDs = append(rechargeIDs, payments[index].OrderID)
if payments[index].PaymentConfigID != nil {
configIDs = append(configIDs, *payments[index].PaymentConfigID)
}
}
var rechargeRows []model.AgentRechargeRecord
if err := s.db.WithContext(ctx).Where("id IN ?", rechargeIDs).Find(&rechargeRows).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询待恢复代理充值单失败")
}
var configRows []model.WechatConfig
if len(configIDs) > 0 {
if err := s.db.WithContext(ctx).Where("id IN ?", configIDs).Find(&configRows).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询代理充值创建配置失败")
}
}
recharges := make(map[uint]*model.AgentRechargeRecord, len(rechargeRows))
for index := range rechargeRows {
recharges[rechargeRows[index].ID] = &rechargeRows[index]
}
configs := make(map[uint]*model.WechatConfig, len(configRows))
for index := range configRows {
configs[configRows[index].ID] = &configRows[index]
}
return recharges, configs, nil
}
func recoveryConfig(payment *model.Payment, configs map[uint]*model.WechatConfig) *model.WechatConfig {
if payment.PaymentConfigID == nil {
return nil
}
return configs[*payment.PaymentConfigID]
}
func (s *RecoverOnlinePaymentService) closePending(ctx context.Context, payment *model.Payment, recharge *model.AgentRechargeRecord) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
paymentUpdate := tx.Model(&model.Payment{}).
Where("id = ? AND status = ?", payment.ID, model.PaymentRecordStatusPending).
Update("status", model.PaymentRecordStatusFailed)
if paymentUpdate.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, paymentUpdate.Error, "关闭失效代理充值支付单失败")
}
rechargeUpdate := tx.Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status = ?", recharge.ID, constants.RechargeStatusPending).
Update("status", constants.RechargeStatusClosed)
if rechargeUpdate.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, rechargeUpdate.Error, "关闭失效代理充值单失败")
}
if paymentUpdate.RowsAffected == 0 {
return nil
}
afterPayment := *payment
afterPayment.Status = model.PaymentRecordStatusFailed
return s.audit.WriteAgentRechargePayment(ctx, tx, PaymentAudit{
ActionCode: constants.AuditActionPaymentFailed, Summary: "支付渠道确认订单已关闭",
Payment: &afterPayment, Recharge: recharge,
BeforeData: map[string]any{"status": payment.Status}, AfterData: map[string]any{"status": afterPayment.Status},
RechargeBeforeData: map[string]any{"status": recharge.Status}, RechargeAfterData: map[string]any{"status": constants.RechargeStatusClosed},
})
})
}
func (s *RecoverOnlinePaymentService) adapter(paymentMethod string) OnlinePaymentPort {
if paymentMethod == constants.RechargeMethodWechat {
return s.wechat
}
if paymentMethod == constants.RechargeMethodAlipay {
return s.alipay
}
return nil
}

View File

@@ -0,0 +1,40 @@
package approval
import (
"context"
"gorm.io/gorm"
)
// AuditChange 描述通用审批链路一次实际状态变化。
type AuditChange struct {
EventID string
ActionCode string
Summary string
InstanceID uint
BusinessType string
BusinessID uint
SubmitterAccountID uint
SubmitterSnapshot []byte
Provider string
BeforeExternalRef string
AfterExternalRef string
CorrelationID string
ParentEventID string
BeforeStatus *int
AfterStatus *int
ActorKind string
ActorID string
ActorName string
Source string
Result string
ErrorSummary string
Decision string
IntegrationIDs []string
OutboxEventID string
}
// AuditWriter 在审批事实事务中追加统一 Audit Event。
type AuditWriter interface {
WriteApproval(ctx context.Context, tx *gorm.DB, change AuditChange) error
}

View File

@@ -0,0 +1,145 @@
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
audit AuditWriter
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}
}
// SetAuditWriter 注入通用审批统一审计 Writer。
func (s *CreationService) SetAuditWriter(writer AuditWriter) {
s.audit = writer
}
// 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 || s.audit == 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
}
afterStatus := instance.Status
if err := s.audit.WriteApproval(ctx, tx, AuditChange{
EventID: "approval:" + strconv.FormatUint(uint64(instance.ID), 10) + ":audit:requested",
ActionCode: constants.AuditActionApprovalRequested, Summary: "提交通用审批申请",
InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot,
Provider: instance.Provider, CorrelationID: instance.CorrelationID, AfterStatus: &afterStatus,
ActorKind: constants.AuditActorAccount, ActorID: strconv.FormatUint(uint64(instance.SubmitterAccountID), 10),
Source: constants.AuditSourceAdminAPI, Result: constants.AuditResultSuccess, OutboxEventID: event.EventID,
}); 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,179 @@
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
IntegrationIDs []string
}
// SyncDecisionResult 返回本次是否首次记录该标准终态。
type SyncDecisionResult struct {
Status int
FirstTerminal bool
}
// SyncDecisionService 统一处理各审批渠道回传的标准决策。
type SyncDecisionService struct {
db *gorm.DB
repositories RepositoryProvider
eventWriter TerminalEventWriter
deliveryWriter DecisionDeliveryWriter
audit AuditWriter
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,
}
}
// SetAuditWriter 注入通用审批统一审计 Writer。
func (s *SyncDecisionService) SetAuditWriter(writer AuditWriter) {
s.audit = writer
}
// 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 || s.audit == 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
beforeStatus := instance.Status
beforeExternalRef := instance.ExternalRef
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
}
actorKind, actorID, source := approvalSyncAuditOrigin(command.Source)
afterStatus := instance.Status
if err := s.audit.WriteApproval(ctx, tx, AuditChange{
EventID: event.EventID + ":audit", ActionCode: constants.AuditActionApprovalDecisionSynced,
Summary: "同步审批权威终态", InstanceID: instance.ID,
BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot,
Provider: instance.Provider, BeforeExternalRef: beforeExternalRef, AfterExternalRef: instance.ExternalRef,
CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &afterStatus,
ActorKind: actorKind, ActorID: actorID, Source: source, Result: constants.AuditResultSuccess,
Decision: command.Decision, IntegrationIDs: command.IntegrationIDs, OutboxEventID: event.EventID,
}); err != nil {
return err
}
result.FirstTerminal = true
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func approvalSyncAuditOrigin(source string) (string, string, string) {
switch source {
case constants.ApprovalSyncSourceCallback:
return constants.AuditActorExternalSystem, constants.ApprovalAuditActorWeCom, constants.AuditSourceCallback
case constants.ApprovalSyncSourcePolling:
return constants.AuditActorScheduledJob, constants.ApprovalAuditActorRecoveryJob, constants.AuditSourceScheduler
default:
return constants.AuditActorAccount, "", constants.AuditSourceAdminAPI
}
}
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,350 @@
package auditarchive
import (
"compress/gzip"
"context"
"crypto/sha256"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
type integrationArchiveFile struct {
path string
recordCount int64
uncompressedBytes int64
compressedBytes int64
sha256 string
}
type integrationArchiveManifest struct {
SchemaVersion string `json:"schema_version"`
Source string `json:"source"`
ArchiveDate string `json:"archive_date"`
Timezone string `json:"timezone"`
RangeStart time.Time `json:"range_start"`
RangeEnd time.Time `json:"range_end"`
InstanceID string `json:"instance_id"`
RecordCount int64 `json:"record_count"`
UncompressedBytes int64 `json:"uncompressed_bytes"`
CompressedBytes int64 `json:"compressed_bytes"`
ObjectKey string `json:"object_key"`
SHA256 string `json:"sha256"`
Revision int `json:"revision"`
GeneratedAt time.Time `json:"generated_at"`
Status string `json:"status"`
Final bool `json:"final"`
}
// ArchivePreviousIntegrationDay 归档 Asia/Shanghai 前一完整自然日的 Integration Log 创建日快照。
func (s *Service) ArchivePreviousIntegrationDay(ctx context.Context) error {
now := time.Now().In(s.location)
return s.ArchiveIntegrationDate(ctx, now.AddDate(0, 0, -1))
}
// ArchiveIntegrationDate 归档指定 Asia/Shanghai 自然日的 Integration Log 创建日快照。
func (s *Service) ArchiveIntegrationDate(ctx context.Context, archiveDate time.Time) error {
return s.archiveIntegrationDate(ctx, archiveDate, false)
}
// FinalizePreviousIntegrationMonth 复核并终结上一个完整自然月的 Integration Log 归档。
func (s *Service) FinalizePreviousIntegrationMonth(ctx context.Context) error {
now := time.Now().In(s.location)
return s.FinalizeIntegrationMonth(ctx, now.AddDate(0, -1, 0))
}
// FinalizeIntegrationMonth 逐日复核指定完整自然月,并为变化内容创建最终 revision。
func (s *Service) FinalizeIntegrationMonth(ctx context.Context, month time.Time) error {
monthStart := time.Date(month.In(s.location).Year(), month.In(s.location).Month(), 1, 0, 0, 0, 0, s.location)
currentMonth := time.Now().In(s.location)
currentMonthStart := time.Date(currentMonth.Year(), currentMonth.Month(), 1, 0, 0, 0, 0, s.location)
if !monthStart.Before(currentMonthStart) {
return fmt.Errorf("只能终结已经结束的 Integration Log 完整自然月")
}
for date := monthStart; date.Before(monthStart.AddDate(0, 1, 0)); date = date.AddDate(0, 0, 1) {
if err := s.archiveIntegrationDate(ctx, date, true); err != nil {
return fmt.Errorf("终结 %s Integration Log 归档失败: %w", date.Format(time.DateOnly), err)
}
}
return nil
}
func (s *Service) archiveIntegrationDate(ctx context.Context, archiveDate time.Time, final bool) error {
if s.db == nil || s.store == nil {
return fmt.Errorf("Integration Log 归档数据库或对象存储未配置")
}
start := time.Date(archiveDate.In(s.location).Year(), archiveDate.In(s.location).Month(), archiveDate.In(s.location).Day(), 0, 0, 0, 0, s.location)
end := start.AddDate(0, 0, 1)
today := time.Now().In(s.location)
if end.After(time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, s.location)) {
return fmt.Errorf("Integration Log 只能归档已经结束的完整自然日")
}
run, err := s.ensureIntegrationRun(ctx, start, end)
if err != nil {
return err
}
file, err := s.buildIntegrationArchiveFile(ctx, start, end)
if err != nil {
return err
}
defer os.Remove(file.path)
if final {
pending, pendingErr := s.integrationPendingCount(ctx, start, end)
if pendingErr != nil {
return pendingErr
}
if pending > 0 {
_ = s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("id = ?", run.ID).
Updates(map[string]any{"is_final": false, "error_summary": "存在 pending Integration Log无法形成最终归档", "updated_at": time.Now()}).Error
return fmt.Errorf("仍有 %d 条 pending Integration Log无法形成最终归档", pending)
}
}
if run.Status == constants.ArchiveStatusSuccess && run.RecordCount == file.recordCount && run.SHA256 == file.sha256 {
valid, validateErr := s.validateIntegrationRun(ctx, run)
if validateErr == nil && valid && (!final || run.IsFinal) {
return nil
}
}
acquired, err := s.acquireIntegrationRun(ctx, run)
if err != nil {
return err
}
if !acquired {
return fmt.Errorf("Integration Log 归档任务正在执行")
}
if err := s.uploadIntegrationArchive(ctx, run, file, final); err != nil {
s.markFailed(ctx, run.ID, err)
return err
}
return nil
}
func (s *Service) ensureIntegrationRun(ctx context.Context, start, end time.Time) (*model.LogArchiveRun, error) {
run := model.LogArchiveRun{
Source: constants.IntegrationArchiveSource, ArchiveDate: start, InstanceID: s.instanceID,
SchemaVersion: constants.IntegrationArchiveSchemaVersion, Revision: 1,
Status: constants.ArchiveStatusPending, RangeStart: start, RangeEnd: end,
}
result := s.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source"}, {Name: "archive_date"}, {Name: "instance_id"}, {Name: "schema_version"}},
DoNothing: true,
}).Create(&run)
if result.Error != nil {
return nil, fmt.Errorf("创建 Integration Log 归档账本失败: %w", result.Error)
}
if result.RowsAffected == 0 {
if err := s.db.WithContext(ctx).Where(
"source = ? AND archive_date = ? AND instance_id = ? AND schema_version = ?",
constants.IntegrationArchiveSource, start, s.instanceID, constants.IntegrationArchiveSchemaVersion,
).First(&run).Error; err != nil {
return nil, fmt.Errorf("读取 Integration Log 归档账本失败: %w", err)
}
}
return &run, nil
}
func (s *Service) acquireIntegrationRun(ctx context.Context, run *model.LogArchiveRun) (bool, error) {
revision := run.Revision
if run.Status != constants.ArchiveStatusPending {
revision++
}
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("id = ? AND (status <> ? OR updated_at < ?)", run.ID, constants.ArchiveStatusRunning, now.Add(-3*time.Hour)).
Updates(map[string]any{
"status": constants.ArchiveStatusRunning, "revision": revision, "is_final": false,
"attempt_count": gorm.Expr("attempt_count + 1"), "error_summary": "", "completed_at": nil, "updated_at": now,
})
if result.Error != nil {
return false, fmt.Errorf("锁定 Integration Log 归档任务失败: %w", result.Error)
}
if result.RowsAffected == 0 {
return false, nil
}
run.Revision = revision
return true, nil
}
func (s *Service) buildIntegrationArchiveFile(ctx context.Context, start, end time.Time) (*integrationArchiveFile, error) {
temp, err := os.CreateTemp("", "integration-logs-*.jsonl.gz")
if err != nil {
return nil, fmt.Errorf("创建 Integration Log 归档临时文件失败: %w", err)
}
path := temp.Name()
failed := true
defer func() {
_ = temp.Close()
if failed {
_ = os.Remove(path)
}
}()
hasher := sha256.New()
gzipWriter := gzip.NewWriter(io.MultiWriter(temp, hasher))
result := &integrationArchiveFile{path: path}
var lastID uint
for {
var logs []model.IntegrationLog
if err := s.db.WithContext(ctx).Where("created_at >= ? AND created_at < ? AND id > ?", start, end, lastID).
Order("id ASC").Limit(archivePageSize).Find(&logs).Error; err != nil {
return nil, fmt.Errorf("读取 Integration Log 归档记录失败: %w", err)
}
if len(logs) == 0 {
break
}
for i := range logs {
line, marshalErr := sonic.Marshal(logs[i])
if marshalErr != nil {
return nil, fmt.Errorf("序列化 Integration Log 归档记录失败: %w", marshalErr)
}
line = append(line, '\n')
if _, writeErr := gzipWriter.Write(line); writeErr != nil {
return nil, fmt.Errorf("写入 Integration Log 归档压缩流失败: %w", writeErr)
}
result.recordCount++
result.uncompressedBytes += int64(len(line))
}
lastID = logs[len(logs)-1].ID
}
if err := gzipWriter.Close(); err != nil {
return nil, fmt.Errorf("关闭 Integration Log 归档压缩流失败: %w", err)
}
if err := temp.Close(); err != nil {
return nil, fmt.Errorf("关闭 Integration Log 归档临时文件失败: %w", err)
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("读取 Integration Log 归档临时文件信息失败: %w", err)
}
result.compressedBytes = info.Size()
result.sha256 = fmt.Sprintf("%x", hasher.Sum(nil))
failed = false
return result, nil
}
func (s *Service) uploadIntegrationArchive(ctx context.Context, run *model.LogArchiveRun, file *integrationArchiveFile, final bool) error {
count, err := s.integrationRecordCount(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
if count != file.recordCount {
return fmt.Errorf("Integration Log 归档生成期间记录数量发生变化")
}
objectKey, manifestKey := integrationObjectKeys(run.RangeStart, run.Revision)
metadata := integrationArchiveMetadata(file, run, final)
reader, err := os.Open(file.path)
if err != nil {
return fmt.Errorf("打开 Integration Log 归档临时文件失败: %w", err)
}
uploadErr := s.store.UploadWithMetadata(ctx, objectKey, reader, "application/gzip", metadata)
closeErr := reader.Close()
if uploadErr != nil {
return fmt.Errorf("上传 Integration Log 归档对象失败: %w", uploadErr)
}
if closeErr != nil {
return fmt.Errorf("关闭 Integration Log 归档临时文件失败: %w", closeErr)
}
if err := s.verifyObject(ctx, objectKey, file.compressedBytes, metadata); err != nil {
return err
}
generatedAt := time.Now().In(s.location)
manifest := integrationArchiveManifest{
SchemaVersion: constants.IntegrationArchiveSchemaVersion, Source: constants.IntegrationArchiveSource,
ArchiveDate: run.RangeStart.In(s.location).Format(time.DateOnly), Timezone: constants.AuditArchiveTimezone,
RangeStart: run.RangeStart, RangeEnd: run.RangeEnd, InstanceID: s.instanceID,
RecordCount: file.recordCount, UncompressedBytes: file.uncompressedBytes, CompressedBytes: file.compressedBytes,
ObjectKey: objectKey, SHA256: file.sha256, Revision: run.Revision, GeneratedAt: generatedAt,
Status: constants.ArchiveStatusSuccess, Final: final,
}
manifestBytes, err := sonic.Marshal(manifest)
if err != nil {
return fmt.Errorf("序列化 Integration Log 归档清单失败: %w", err)
}
manifestMetadata := map[string]string{
"source": constants.IntegrationArchiveSource, "data-sha256": file.sha256,
"revision": strconv.Itoa(run.Revision), "final": strconv.FormatBool(final),
}
if err := s.store.UploadWithMetadata(ctx, manifestKey, strings.NewReader(string(manifestBytes)), "application/json", manifestMetadata); err != nil {
return fmt.Errorf("上传 Integration Log 归档清单失败: %w", err)
}
if err := s.verifyObject(ctx, manifestKey, int64(len(manifestBytes)), manifestMetadata); err != nil {
return err
}
completedAt := time.Now()
return s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("id = ?", run.ID).Updates(map[string]any{
"status": constants.ArchiveStatusSuccess, "is_final": final,
"object_key": objectKey, "manifest_key": manifestKey, "record_count": file.recordCount,
"uncompressed_bytes": file.uncompressedBytes, "compressed_bytes": file.compressedBytes,
"sha256": file.sha256, "generated_at": generatedAt, "completed_at": completedAt, "updated_at": completedAt,
}).Error
}
func (s *Service) validateIntegrationRun(ctx context.Context, run *model.LogArchiveRun) (bool, error) {
metadata := map[string]string{
"sha256": run.SHA256, "record-count": strconv.FormatInt(run.RecordCount, 10),
"revision": strconv.Itoa(run.Revision), "final": strconv.FormatBool(run.IsFinal),
}
if err := s.verifyObject(ctx, run.ObjectKey, run.CompressedBytes, metadata); err != nil {
return false, nil
}
manifestMetadata := map[string]string{
"source": constants.IntegrationArchiveSource, "data-sha256": run.SHA256,
"revision": strconv.Itoa(run.Revision), "final": strconv.FormatBool(run.IsFinal),
}
if err := s.verifyObject(ctx, run.ManifestKey, -1, manifestMetadata); err != nil {
return false, nil
}
return true, nil
}
func (s *Service) integrationRecordCount(ctx context.Context, start, end time.Time) (int64, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&model.IntegrationLog{}).
Where("created_at >= ? AND created_at < ?", start, end).Count(&count).Error; err != nil {
return 0, fmt.Errorf("统计 Integration Log 归档记录失败: %w", err)
}
return count, nil
}
func (s *Service) integrationPendingCount(ctx context.Context, start, end time.Time) (int64, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&model.IntegrationLog{}).
Where("created_at >= ? AND created_at < ? AND result = ?", start, end, constants.IntegrationResultPending).
Count(&count).Error; err != nil {
return 0, fmt.Errorf("统计 pending Integration Log 失败: %w", err)
}
return count, nil
}
func integrationObjectKeys(date time.Time, revision int) (string, string) {
prefix := fmt.Sprintf("audit-archive/v1/%04d/%02d/%02d", date.Year(), date.Month(), date.Day())
name := fmt.Sprintf("integration-logs-%s-r%d", date.Format(time.DateOnly), revision)
return prefix + "/" + name + ".jsonl.gz", prefix + "/" + name + ".manifest.json"
}
func integrationArchiveMetadata(file *integrationArchiveFile, run *model.LogArchiveRun, final bool) map[string]string {
return map[string]string{
"schema-version": constants.IntegrationArchiveSchemaVersion,
"source": constants.IntegrationArchiveSource,
"archive-date": run.RangeStart.Format(time.DateOnly),
"timezone": constants.AuditArchiveTimezone,
"record-count": strconv.FormatInt(file.recordCount, 10),
"sha256": file.sha256,
"revision": strconv.Itoa(run.Revision),
"final": strconv.FormatBool(final),
}
}

View File

@@ -0,0 +1,580 @@
package auditarchive
import (
"context"
"crypto/sha256"
"fmt"
"io"
"os"
"strconv"
"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"
)
const maxManifestBytes = 1024 * 1024
// RetentionAudit 描述月度物理清理的统一审计事实。
type RetentionAudit struct {
EventID string
Month string
Summary string
Result string
ErrorSummary string
RangeStart time.Time
RangeEnd time.Time
EventCount int64
ResourceCount int64
IntegrationCount int64
ManifestKeys []string
DurationMS int64
}
// RetentionResult 是月度留存清理的结构化执行结果。
type RetentionResult struct {
Month string
EventCount int64
ResourceCount int64
IntegrationCount int64
EstimatedBatches int64
ManifestKeys []string
Duration time.Duration
}
type retentionRuns struct {
audit []*model.LogArchiveRun
integration []*model.LogArchiveRun
}
// CleanupPreviousMonth 校验并物理清理上一个完整自然月的在线审计日志。
func (s *Service) CleanupPreviousMonth(ctx context.Context) (RetentionResult, error) {
now := time.Now().In(s.location)
return s.CleanupMonth(ctx, now.AddDate(0, -1, 0))
}
// ValidatePreviousMonth 只读校验上一个完整自然月的归档与清理门禁。
func (s *Service) ValidatePreviousMonth(ctx context.Context) (RetentionResult, error) {
now := time.Now().In(s.location)
return s.ValidateMonth(ctx, now.AddDate(0, -1, 0))
}
// ValidateMonth 只读校验指定完整自然月,不写清理断点且不删除在线数据。
func (s *Service) ValidateMonth(ctx context.Context, month time.Time) (result RetentionResult, err error) {
if s.db == nil || s.store == nil {
return result, fmt.Errorf("日志留存演练数据库或对象存储未配置")
}
start, end, err := s.retentionMonthRange(month)
if err != nil {
return result, err
}
startedAt := time.Now()
result.Month = start.Format("2006-01")
runs, err := s.loadRetentionRuns(ctx, start, end)
if err != nil {
return result, err
}
if err := s.validateRetentionRuns(ctx, start, end, runs); err != nil {
return result, err
}
summarizeRetentionRuns(runs, &result)
result.EstimatedBatches = estimatedRetentionBatches(result)
result.Duration = time.Since(startedAt)
return result, nil
}
// CleanupMonth 校验归档硬门禁后按固定顺序物理清理指定完整自然月。
func (s *Service) CleanupMonth(ctx context.Context, month time.Time) (result RetentionResult, cleanupErr error) {
if s.db == nil || s.store == nil || s.audit == nil {
return result, fmt.Errorf("日志留存清理数据库、对象存储或审计 Writer 未配置")
}
start, end, err := s.retentionMonthRange(month)
if err != nil {
return result, err
}
startedAt := time.Now()
result.Month = start.Format("2006-01")
cleanupErr = s.executeRetention(ctx, start, end, &result)
result.Duration = time.Since(startedAt)
if auditErr := s.recordRetentionAudit(ctx, start, end, result, cleanupErr); auditErr != nil {
if cleanupErr != nil {
return result, fmt.Errorf("%w记录留存清理失败审计失败: %v", cleanupErr, auditErr)
}
return result, fmt.Errorf("记录留存清理成功审计失败: %w", auditErr)
}
return result, cleanupErr
}
func (s *Service) retentionMonthRange(month time.Time) (time.Time, time.Time, error) {
start := time.Date(month.In(s.location).Year(), month.In(s.location).Month(), 1, 0, 0, 0, 0, s.location)
end := start.AddDate(0, 1, 0)
now := time.Now().In(s.location)
currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, s.location)
if !end.Before(currentMonth) && !end.Equal(currentMonth) {
return time.Time{}, time.Time{}, fmt.Errorf("只能清理已经结束的完整自然月")
}
return start, end, nil
}
func (s *Service) executeRetention(ctx context.Context, start, end time.Time, result *RetentionResult) error {
started, err := s.retentionCleanupStarted(ctx, start, end)
if err != nil {
return err
}
if !started {
lastDay := end.AddDate(0, 0, -1)
if err := s.ArchiveDate(ctx, lastDay); err != nil {
return fmt.Errorf("完成上月最后一天 Audit 归档失败: %w", err)
}
if err := s.ArchiveIntegrationDate(ctx, lastDay); err != nil {
return fmt.Errorf("完成上月最后一天 Integration Log 归档失败: %w", err)
}
if err := s.FinalizeIntegrationMonth(ctx, start); err != nil {
return err
}
}
runs, err := s.loadRetentionRuns(ctx, start, end)
if err != nil {
return err
}
if err := s.validateRetentionRuns(ctx, start, end, runs); err != nil {
return err
}
summarizeRetentionRuns(runs, result)
if err := s.cleanupAuditMonth(ctx, start, end, runs.audit); err != nil {
return err
}
return s.cleanupIntegrationMonth(ctx, start, end, runs.integration)
}
func (s *Service) retentionCleanupStarted(ctx context.Context, start, end time.Time) (bool, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("archive_date >= ? AND archive_date < ? AND instance_id = ? AND cleanup_started_at IS NOT NULL",
start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Count(&count).Error
if err != nil {
return false, fmt.Errorf("读取月度清理断点失败: %w", err)
}
return count > 0, nil
}
func (s *Service) loadRetentionRuns(ctx context.Context, start, end time.Time) (retentionRuns, error) {
var rows []model.LogArchiveRun
err := s.db.WithContext(ctx).Where(
"source IN ? AND archive_date >= ? AND archive_date < ? AND instance_id = ?",
[]string{constants.AuditArchiveSource, constants.IntegrationArchiveSource}, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID,
).Order("archive_date ASC, source ASC").Find(&rows).Error
if err != nil {
return retentionRuns{}, fmt.Errorf("读取月度归档账本失败: %w", err)
}
days := int(end.Sub(start).Hours() / 24)
if len(rows) != days*2 {
return retentionRuns{}, fmt.Errorf("月度归档账本缺日:期望 %d 条,实际 %d 条", days*2, len(rows))
}
runs := retentionRuns{audit: make([]*model.LogArchiveRun, 0, days), integration: make([]*model.LogArchiveRun, 0, days)}
for index := range rows {
run := &rows[index]
switch run.Source {
case constants.AuditArchiveSource:
runs.audit = append(runs.audit, run)
case constants.IntegrationArchiveSource:
runs.integration = append(runs.integration, run)
}
}
if len(runs.audit) != days || len(runs.integration) != days {
return retentionRuns{}, fmt.Errorf("月度 Audit 或 Integration 归档账本不完整")
}
return runs, nil
}
func (s *Service) validateRetentionRuns(ctx context.Context, start, end time.Time, runs retentionRuns) error {
if err := validateCleanupLedgerState(runs.audit); err != nil {
return fmt.Errorf("Audit 清理断点非法: %w", err)
}
if err := validateCleanupLedgerState(runs.integration); err != nil {
return fmt.Errorf("Integration 清理断点非法: %w", err)
}
for index := range runs.audit {
date := start.AddDate(0, 0, index)
if err := s.validateAuditRetentionDay(ctx, date, runs.audit[index]); err != nil {
return fmt.Errorf("%s Audit 清理门禁失败: %w", date.Format(time.DateOnly), err)
}
if err := s.validateIntegrationRetentionDay(ctx, date, runs.integration[index]); err != nil {
return fmt.Errorf("%s Integration 清理门禁失败: %w", date.Format(time.DateOnly), err)
}
}
return nil
}
func validateCleanupLedgerState(runs []*model.LogArchiveRun) error {
started, cleaned := 0, 0
for _, run := range runs {
if run.CleanupStartedAt != nil {
started++
}
if run.CleanedAt != nil {
cleaned++
}
}
if started != 0 && started != len(runs) {
return fmt.Errorf("清理开始断点不是整月原子状态")
}
if cleaned != 0 && cleaned != len(runs) {
return fmt.Errorf("清理完成断点不是整月原子状态")
}
if cleaned > 0 && started == 0 {
return fmt.Errorf("清理完成但缺少开始断点")
}
return nil
}
func (s *Service) validateAuditRetentionDay(ctx context.Context, date time.Time, run *model.LogArchiveRun) error {
if err := validateRunBase(run, date, constants.AuditArchiveSchemaVersion, false); err != nil {
return err
}
if err := s.validateAuditManifest(ctx, run); err != nil {
return err
}
events, resources, err := s.databaseCounts(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
return validateRemainingCounts(run, events, resources)
}
func (s *Service) validateIntegrationRetentionDay(ctx context.Context, date time.Time, run *model.LogArchiveRun) error {
if err := validateRunBase(run, date, constants.IntegrationArchiveSchemaVersion, true); err != nil {
return err
}
if err := s.validateIntegrationManifest(ctx, run); err != nil {
return err
}
count, err := s.integrationRecordCount(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
if run.CleanedAt != nil {
if count != 0 {
return fmt.Errorf("已标记清理完成但数据库仍有 %d 条记录", count)
}
return nil
}
if run.CleanupStartedAt != nil {
if count > run.RecordCount {
return fmt.Errorf("续跑窗口记录数超过最终归档数量")
}
return nil
}
file, err := s.buildIntegrationArchiveFile(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
defer os.Remove(file.path)
if file.recordCount != run.RecordCount || file.sha256 != run.SHA256 {
return fmt.Errorf("数据库当前 Integration 内容与最终 revision 不一致")
}
return nil
}
func validateRunBase(run *model.LogArchiveRun, date time.Time, schema string, final bool) error {
if run.Status != constants.ArchiveStatusSuccess || run.SchemaVersion != schema {
return fmt.Errorf("归档状态或 schema version 不符合清理要求")
}
if run.ArchiveDate.Format(time.DateOnly) != date.Format(time.DateOnly) ||
!run.RangeStart.Equal(date) || !run.RangeEnd.Equal(date.AddDate(0, 0, 1)) {
return fmt.Errorf("归档日期或半开时间范围不一致")
}
if final && !run.IsFinal {
return fmt.Errorf("Integration 最终 revision 尚未形成")
}
if run.ObjectKey == "" || run.ManifestKey == "" || run.SHA256 == "" {
return fmt.Errorf("归档对象、manifest 或 SHA-256 缺失")
}
return nil
}
func validateRemainingCounts(run *model.LogArchiveRun, events, resources int64) error {
if run.CleanedAt != nil {
if events != 0 || resources != 0 {
return fmt.Errorf("已标记清理完成但数据库仍有事件或资源")
}
return nil
}
if run.CleanupStartedAt != nil {
if events > run.EventCount || resources > run.ResourceCount {
return fmt.Errorf("续跑窗口数量超过已归档数量")
}
return nil
}
if events != run.EventCount || resources != run.ResourceCount {
return fmt.Errorf("数据库事件或资源数量与 manifest 不一致")
}
return nil
}
func (s *Service) validateAuditManifest(ctx context.Context, run *model.LogArchiveRun) error {
var manifest Manifest
if err := s.readManifest(ctx, run.ManifestKey, &manifest); err != nil {
return err
}
if manifest.Source != run.Source || manifest.SchemaVersion != run.SchemaVersion || manifest.Status != constants.ArchiveStatusSuccess ||
manifest.ArchiveDate != run.ArchiveDate.Format(time.DateOnly) || manifest.Timezone != constants.AuditArchiveTimezone ||
manifest.InstanceID != run.InstanceID || !manifest.RangeStart.Equal(run.RangeStart) || !manifest.RangeEnd.Equal(run.RangeEnd) ||
manifest.EventCount != run.EventCount || manifest.ResourceCount != run.ResourceCount ||
manifest.CompressedBytes != run.CompressedBytes || manifest.ObjectKey != run.ObjectKey ||
manifest.SHA256 != run.SHA256 || manifest.Revision != run.Revision {
return fmt.Errorf("Audit manifest 与 ledger 不一致")
}
if err := s.verifyObject(ctx, run.ManifestKey, -1, map[string]string{
"source": constants.AuditArchiveSource, "data-sha256": run.SHA256, "revision": strconv.Itoa(run.Revision),
}); err != nil {
return err
}
return s.verifyRetentionObject(ctx, run, false)
}
func (s *Service) validateIntegrationManifest(ctx context.Context, run *model.LogArchiveRun) error {
var manifest integrationArchiveManifest
if err := s.readManifest(ctx, run.ManifestKey, &manifest); err != nil {
return err
}
if manifest.Source != run.Source || manifest.SchemaVersion != run.SchemaVersion || manifest.Status != constants.ArchiveStatusSuccess || !manifest.Final ||
manifest.ArchiveDate != run.ArchiveDate.Format(time.DateOnly) || manifest.Timezone != constants.AuditArchiveTimezone ||
manifest.InstanceID != run.InstanceID || !manifest.RangeStart.Equal(run.RangeStart) || !manifest.RangeEnd.Equal(run.RangeEnd) ||
manifest.RecordCount != run.RecordCount || manifest.CompressedBytes != run.CompressedBytes ||
manifest.ObjectKey != run.ObjectKey || manifest.SHA256 != run.SHA256 || manifest.Revision != run.Revision {
return fmt.Errorf("Integration manifest 与最终 ledger 不一致")
}
if err := s.verifyObject(ctx, run.ManifestKey, -1, map[string]string{
"source": constants.IntegrationArchiveSource, "data-sha256": run.SHA256,
"revision": strconv.Itoa(run.Revision), "final": "true",
}); err != nil {
return err
}
return s.verifyRetentionObject(ctx, run, true)
}
func (s *Service) readManifest(ctx context.Context, key string, target any) error {
object, err := s.store.Stat(ctx, key)
if err != nil {
return fmt.Errorf("读取 manifest metadata 失败: %w", err)
}
reader, err := s.store.Download(ctx, key)
if err != nil {
return fmt.Errorf("下载 manifest 失败: %w", err)
}
data, readErr := io.ReadAll(io.LimitReader(reader, maxManifestBytes+1))
closeErr := reader.Close()
if readErr != nil {
return fmt.Errorf("读取 manifest 失败: %w", readErr)
}
if closeErr != nil {
return fmt.Errorf("关闭 manifest 对象失败: %w", closeErr)
}
if len(data) > maxManifestBytes || int64(len(data)) != object.Size {
return fmt.Errorf("manifest 大小非法或不完整")
}
if err := sonic.Unmarshal(data, target); err != nil {
return fmt.Errorf("解析 manifest 失败: %w", err)
}
return nil
}
func (s *Service) verifyRetentionObject(ctx context.Context, run *model.LogArchiveRun, final bool) error {
metadata := map[string]string{
"schema-version": run.SchemaVersion, "source": run.Source,
"archive-date": run.RangeStart.Format(time.DateOnly), "timezone": constants.AuditArchiveTimezone,
"sha256": run.SHA256, "revision": strconv.Itoa(run.Revision),
}
if run.Source == constants.AuditArchiveSource {
metadata["event-count"] = strconv.FormatInt(run.EventCount, 10)
metadata["resource-count"] = strconv.FormatInt(run.ResourceCount, 10)
} else {
metadata["record-count"] = strconv.FormatInt(run.RecordCount, 10)
metadata["final"] = strconv.FormatBool(final)
}
if err := s.verifyObject(ctx, run.ObjectKey, run.CompressedBytes, metadata); err != nil {
return err
}
reader, err := s.store.Download(ctx, run.ObjectKey)
if err != nil {
return fmt.Errorf("下载归档对象复核 SHA-256 失败: %w", err)
}
hasher := sha256.New()
written, copyErr := io.Copy(hasher, reader)
closeErr := reader.Close()
if copyErr != nil {
return fmt.Errorf("读取归档对象复核 SHA-256 失败: %w", copyErr)
}
if closeErr != nil {
return fmt.Errorf("关闭归档对象失败: %w", closeErr)
}
if written != run.CompressedBytes || fmt.Sprintf("%x", hasher.Sum(nil)) != run.SHA256 {
return fmt.Errorf("归档对象大小或 SHA-256 复核失败")
}
return nil
}
func summarizeRetentionRuns(runs retentionRuns, result *RetentionResult) {
result.ManifestKeys = make([]string, 0, len(runs.audit)+len(runs.integration))
for _, run := range runs.audit {
result.EventCount += run.EventCount
result.ResourceCount += run.ResourceCount
result.ManifestKeys = append(result.ManifestKeys, run.ManifestKey)
}
for _, run := range runs.integration {
result.IntegrationCount += run.RecordCount
result.ManifestKeys = append(result.ManifestKeys, run.ManifestKey)
}
}
func estimatedRetentionBatches(result RetentionResult) int64 {
batchSize := int64(constants.AuditRetentionDeleteBatchSize)
return (result.EventCount+batchSize-1)/batchSize +
(result.ResourceCount+batchSize-1)/batchSize +
(result.IntegrationCount+batchSize-1)/batchSize
}
func (s *Service) cleanupAuditMonth(ctx context.Context, start, end time.Time, runs []*model.LogArchiveRun) error {
if allRunsCleaned(runs) {
return nil
}
if err := s.markCleanupStarted(ctx, constants.AuditArchiveSource, start, end); err != nil {
return err
}
if err := s.deleteAuditResources(ctx, start, end); err != nil {
return err
}
if err := s.deleteAuditEvents(ctx, start, end); err != nil {
return err
}
return s.markCleaned(ctx, constants.AuditArchiveSource, start, end)
}
func (s *Service) cleanupIntegrationMonth(ctx context.Context, start, end time.Time, runs []*model.LogArchiveRun) error {
if allRunsCleaned(runs) {
return nil
}
if err := s.markCleanupStarted(ctx, constants.IntegrationArchiveSource, start, end); err != nil {
return err
}
for {
subquery := s.db.Model(&model.IntegrationLog{}).Select("id").
Where("created_at >= ? AND created_at < ?", start, end).Order("id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
deleted := s.db.WithContext(ctx).Where("id IN (?)", subquery).Delete(&model.IntegrationLog{})
if deleted.Error != nil {
return fmt.Errorf("分批物理删除 Integration Log 失败: %w", deleted.Error)
}
if deleted.RowsAffected == 0 {
break
}
}
return s.markCleaned(ctx, constants.IntegrationArchiveSource, start, end)
}
func (s *Service) deleteAuditResources(ctx context.Context, start, end time.Time) error {
for {
subquery := s.db.Model(&model.AuditEventResource{}).Select("tb_audit_event_resource.id").
Joins("JOIN tb_audit_event ON tb_audit_event.id = tb_audit_event_resource.audit_event_id").
Where("tb_audit_event.created_at >= ? AND tb_audit_event.created_at < ?", start, end).
Order("tb_audit_event_resource.id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
deleted := s.db.WithContext(ctx).Where("id IN (?)", subquery).Delete(&model.AuditEventResource{})
if deleted.Error != nil {
return fmt.Errorf("分批物理删除 Audit Event Resource 失败: %w", deleted.Error)
}
if deleted.RowsAffected == 0 {
return nil
}
}
}
func (s *Service) deleteAuditEvents(ctx context.Context, start, end time.Time) error {
for {
subquery := s.db.Model(&model.AuditEvent{}).Select("id").
Where("created_at >= ? AND created_at < ?", start, end).Order("id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
deleted := s.db.WithContext(ctx).Where("id IN (?)", subquery).Delete(&model.AuditEvent{})
if deleted.Error != nil {
return fmt.Errorf("分批物理删除 Audit Event 失败: %w", deleted.Error)
}
if deleted.RowsAffected == 0 {
return nil
}
}
}
func (s *Service) markCleanupStarted(ctx context.Context, source string, start, end time.Time) error {
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("source = ? AND archive_date >= ? AND archive_date < ? AND instance_id = ? AND cleanup_started_at IS NULL",
source, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Updates(map[string]any{"cleanup_started_at": now, "updated_at": now})
if result.Error != nil {
return fmt.Errorf("记录月度清理开始断点失败: %w", result.Error)
}
return s.validateCleanupMarkerCount(ctx, source, start, end, "cleanup_started_at IS NOT NULL", "开始")
}
func (s *Service) markCleaned(ctx context.Context, source string, start, end time.Time) error {
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("source = ? AND archive_date >= ? AND archive_date < ? AND instance_id = ? AND cleanup_started_at IS NOT NULL",
source, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Updates(map[string]any{"cleaned_at": now, "updated_at": now})
if result.Error != nil {
return fmt.Errorf("记录月度清理完成断点失败: %w", result.Error)
}
return s.validateCleanupMarkerCount(ctx, source, start, end, "cleaned_at IS NOT NULL", "完成")
}
func (s *Service) validateCleanupMarkerCount(ctx context.Context, source string, start, end time.Time, marker, label string) error {
var count int64
err := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("source = ? AND archive_date >= ? AND archive_date < ? AND instance_id = ? AND "+marker,
source, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Count(&count).Error
if err != nil {
return fmt.Errorf("复核月度清理%s断点失败: %w", label, err)
}
expected := int64(end.Sub(start).Hours() / 24)
if count != expected {
return fmt.Errorf("月度清理%s断点不完整期望 %d 条,实际 %d 条", label, expected, count)
}
return nil
}
func allRunsCleaned(runs []*model.LogArchiveRun) bool {
return len(runs) > 0 && runs[0].CleanedAt != nil
}
func (s *Service) recordRetentionAudit(ctx context.Context, start, end time.Time, result RetentionResult, cleanupErr error) error {
audit := RetentionAudit{
Month: result.Month, RangeStart: start, RangeEnd: end,
EventCount: result.EventCount, ResourceCount: result.ResourceCount,
IntegrationCount: result.IntegrationCount, ManifestKeys: result.ManifestKeys,
DurationMS: result.Duration.Milliseconds(), Result: constants.AuditResultSuccess,
Summary: "完成已归档在线日志月度物理清理",
EventID: "evt_retention_" + strings.ReplaceAll(result.Month, "-", "_"),
}
if cleanupErr != nil {
audit.EventID = ""
audit.Result = constants.AuditResultFailed
audit.Summary = "已归档在线日志月度物理清理失败"
audit.ErrorSummary = truncateRetentionError(cleanupErr)
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.audit.WriteRetentionCleanup(ctx, tx, audit)
})
}
func truncateRetentionError(err error) string {
value := []rune(err.Error())
if len(value) > 500 {
value = value[:500]
}
return string(value)
}

View File

@@ -0,0 +1,412 @@
// Package auditarchive 实现统一审计每日冷归档用例。
package auditarchive
import (
"compress/gzip"
"context"
"crypto/sha256"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/storage"
)
const archivePageSize = 1000
// ObjectStore 是每日归档需要的最小对象存储能力。
type ObjectStore interface {
UploadWithMetadata(context.Context, string, io.Reader, string, map[string]string) error
Stat(context.Context, string) (*storage.ObjectMetadata, error)
Download(context.Context, string) (io.ReadCloser, error)
}
// RetentionAuditWriter 记录月度留存清理的系统审计事实。
type RetentionAuditWriter interface {
WriteRetentionCleanup(context.Context, *gorm.DB, RetentionAudit) error
}
// Service 编排审计归档生成、上传、复核和幂等账本更新。
type Service struct {
db *gorm.DB
store ObjectStore
audit RetentionAuditWriter
instanceID string
location *time.Location
}
// Manifest 是归档对象的完整性清单。
type Manifest struct {
SchemaVersion string `json:"schema_version"`
Source string `json:"source"`
ArchiveDate string `json:"archive_date"`
Timezone string `json:"timezone"`
RangeStart time.Time `json:"range_start"`
RangeEnd time.Time `json:"range_end"`
InstanceID string `json:"instance_id"`
EventCount int64 `json:"event_count"`
ResourceCount int64 `json:"resource_count"`
UncompressedBytes int64 `json:"uncompressed_bytes"`
CompressedBytes int64 `json:"compressed_bytes"`
ObjectKey string `json:"object_key"`
SHA256 string `json:"sha256"`
Revision int `json:"revision"`
GeneratedAt time.Time `json:"generated_at"`
Status string `json:"status"`
}
type archiveLine struct {
Event model.AuditEvent `json:"event"`
Resources []model.AuditEventResource `json:"resources"`
}
type archiveFile struct {
path string
eventCount int64
resourceCount int64
uncompressedBytes int64
compressedBytes int64
sha256 string
}
// NewService 创建统一审计每日冷归档服务。
func NewService(db *gorm.DB, store ObjectStore, instanceID string, audit ...RetentionAuditWriter) (*Service, error) {
location, err := time.LoadLocation(constants.AuditArchiveTimezone)
if err != nil {
return nil, fmt.Errorf("加载审计归档时区失败: %w", err)
}
if strings.TrimSpace(instanceID) == "" {
instanceID = "audit-archive"
}
var auditWriter RetentionAuditWriter
if len(audit) > 0 {
auditWriter = audit[0]
}
return &Service{db: db, store: store, audit: auditWriter, instanceID: instanceID, location: location}, nil
}
// ArchivePreviousDay 归档 Asia/Shanghai 前一完整自然日。
func (s *Service) ArchivePreviousDay(ctx context.Context) error {
now := time.Now().In(s.location)
return s.ArchiveDate(ctx, now.AddDate(0, 0, -1))
}
// ArchiveDate 归档指定 Asia/Shanghai 自然日。
func (s *Service) ArchiveDate(ctx context.Context, archiveDate time.Time) error {
if s.db == nil || s.store == nil {
return fmt.Errorf("审计归档数据库或对象存储未配置")
}
start := time.Date(archiveDate.In(s.location).Year(), archiveDate.In(s.location).Month(), archiveDate.In(s.location).Day(), 0, 0, 0, 0, s.location)
end := start.AddDate(0, 0, 1)
today := time.Now().In(s.location)
todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, s.location)
if end.After(todayStart) {
return fmt.Errorf("统一审计只能归档已经结束的完整自然日")
}
run, err := s.ensureRun(ctx, start, end)
if err != nil {
return err
}
if run.Status == constants.ArchiveStatusSuccess {
valid, validateErr := s.validateSuccessfulRun(ctx, run)
if validateErr == nil && valid {
return nil
}
}
acquired, err := s.acquireRun(ctx, run)
if err != nil || !acquired {
return err
}
if err := s.execute(ctx, run); err != nil {
s.markFailed(ctx, run.ID, err)
return err
}
return nil
}
func (s *Service) ensureRun(ctx context.Context, start, end time.Time) (*model.LogArchiveRun, error) {
run := model.LogArchiveRun{
Source: constants.AuditArchiveSource, ArchiveDate: start, InstanceID: s.instanceID,
SchemaVersion: constants.AuditArchiveSchemaVersion, Revision: 1,
Status: constants.ArchiveStatusPending, RangeStart: start, RangeEnd: end,
}
result := s.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source"}, {Name: "archive_date"}, {Name: "instance_id"}, {Name: "schema_version"}},
DoNothing: true,
}).Create(&run)
if result.Error != nil {
return nil, fmt.Errorf("创建审计归档账本失败: %w", result.Error)
}
if result.RowsAffected == 0 {
if err := s.db.WithContext(ctx).Where(
"source = ? AND archive_date = ? AND instance_id = ? AND schema_version = ?",
constants.AuditArchiveSource, start, s.instanceID, constants.AuditArchiveSchemaVersion,
).First(&run).Error; err != nil {
return nil, fmt.Errorf("读取审计归档账本失败: %w", err)
}
}
return &run, nil
}
func (s *Service) acquireRun(ctx context.Context, run *model.LogArchiveRun) (bool, error) {
revision := run.Revision
if run.Status == constants.ArchiveStatusFailed || run.Status == constants.ArchiveStatusSuccess || run.Status == constants.ArchiveStatusRunning {
revision++
}
now := time.Now()
staleBefore := now.Add(-3 * time.Hour)
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("id = ? AND (status <> ? OR updated_at < ?)", run.ID, constants.ArchiveStatusRunning, staleBefore).
Updates(map[string]any{
"status": constants.ArchiveStatusRunning, "revision": revision,
"attempt_count": gorm.Expr("attempt_count + 1"), "error_summary": "",
"completed_at": nil, "updated_at": now,
})
if result.Error != nil {
return false, fmt.Errorf("锁定审计归档任务失败: %w", result.Error)
}
if result.RowsAffected == 0 {
return false, nil
}
run.Revision = revision
run.Status = constants.ArchiveStatusRunning
return true, nil
}
func (s *Service) execute(ctx context.Context, run *model.LogArchiveRun) error {
file, err := s.buildArchiveFile(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
defer os.Remove(file.path)
dbEvents, dbResources, err := s.databaseCounts(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
if dbEvents != file.eventCount || dbResources != file.resourceCount {
return fmt.Errorf("审计归档生成期间数据数量发生变化")
}
objectKey, manifestKey := objectKeys(run.RangeStart, run.Revision)
metadata := archiveMetadata(file, run)
reader, err := os.Open(file.path)
if err != nil {
return fmt.Errorf("打开审计归档临时文件失败: %w", err)
}
uploadErr := s.store.UploadWithMetadata(ctx, objectKey, reader, "application/gzip", metadata)
closeErr := reader.Close()
if uploadErr != nil {
return fmt.Errorf("上传审计归档对象失败: %w", uploadErr)
}
if closeErr != nil {
return fmt.Errorf("关闭审计归档临时文件失败: %w", closeErr)
}
if err := s.verifyObject(ctx, objectKey, file.compressedBytes, metadata); err != nil {
return err
}
generatedAt := time.Now().In(s.location)
manifest := Manifest{
SchemaVersion: constants.AuditArchiveSchemaVersion, Source: constants.AuditArchiveSource,
ArchiveDate: run.RangeStart.In(s.location).Format(time.DateOnly), Timezone: constants.AuditArchiveTimezone,
RangeStart: run.RangeStart, RangeEnd: run.RangeEnd, InstanceID: s.instanceID,
EventCount: file.eventCount, ResourceCount: file.resourceCount,
UncompressedBytes: file.uncompressedBytes, CompressedBytes: file.compressedBytes,
ObjectKey: objectKey, SHA256: file.sha256, Revision: run.Revision,
GeneratedAt: generatedAt, Status: constants.ArchiveStatusSuccess,
}
manifestBytes, err := sonic.Marshal(manifest)
if err != nil {
return fmt.Errorf("序列化审计归档清单失败: %w", err)
}
manifestMetadata := map[string]string{"source": constants.AuditArchiveSource, "data-sha256": file.sha256, "revision": strconv.Itoa(run.Revision)}
if err := s.store.UploadWithMetadata(ctx, manifestKey, strings.NewReader(string(manifestBytes)), "application/json", manifestMetadata); err != nil {
return fmt.Errorf("上传审计归档清单失败: %w", err)
}
if err := s.verifyObject(ctx, manifestKey, int64(len(manifestBytes)), manifestMetadata); err != nil {
return err
}
completedAt := time.Now()
updates := map[string]any{
"status": constants.ArchiveStatusSuccess, "object_key": objectKey, "manifest_key": manifestKey,
"event_count": file.eventCount, "resource_count": file.resourceCount,
"uncompressed_bytes": file.uncompressedBytes, "compressed_bytes": file.compressedBytes,
"sha256": file.sha256, "generated_at": generatedAt, "completed_at": completedAt,
"updated_at": completedAt,
}
if err := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("id = ?", run.ID).Updates(updates).Error; err != nil {
return fmt.Errorf("更新审计归档成功账本失败: %w", err)
}
return nil
}
func (s *Service) buildArchiveFile(ctx context.Context, start, end time.Time) (*archiveFile, error) {
temp, err := os.CreateTemp("", "audit-events-*.jsonl.gz")
if err != nil {
return nil, fmt.Errorf("创建审计归档临时文件失败: %w", err)
}
path := temp.Name()
failed := true
defer func() {
_ = temp.Close()
if failed {
_ = os.Remove(path)
}
}()
hasher := sha256.New()
gzipWriter := gzip.NewWriter(io.MultiWriter(temp, hasher))
result := &archiveFile{path: path}
var lastID uint
for {
var events []model.AuditEvent
if err := s.db.WithContext(ctx).Where("created_at >= ? AND created_at < ? AND id > ?", start, end, lastID).
Order("id ASC").Limit(archivePageSize).Find(&events).Error; err != nil {
return nil, fmt.Errorf("读取审计归档事件失败: %w", err)
}
if len(events) == 0 {
break
}
ids := make([]uint, 0, len(events))
for i := range events {
ids = append(ids, events[i].ID)
}
var resources []model.AuditEventResource
if err := s.db.WithContext(ctx).Where("audit_event_id IN ?", ids).
Order("audit_event_id ASC, sort_order ASC, id ASC").Find(&resources).Error; err != nil {
return nil, fmt.Errorf("读取审计归档资源失败: %w", err)
}
grouped := make(map[uint][]model.AuditEventResource, len(events))
for i := range resources {
resource := resources[i]
grouped[resource.AuditEventID] = append(grouped[resource.AuditEventID], resource)
}
for i := range events {
eventResources := grouped[events[i].ID]
if eventResources == nil {
eventResources = []model.AuditEventResource{}
}
line, marshalErr := sonic.Marshal(archiveLine{Event: events[i], Resources: eventResources})
if marshalErr != nil {
return nil, fmt.Errorf("序列化审计归档事件失败: %w", marshalErr)
}
line = append(line, '\n')
if _, writeErr := gzipWriter.Write(line); writeErr != nil {
return nil, fmt.Errorf("写入审计归档压缩流失败: %w", writeErr)
}
result.eventCount++
result.resourceCount += int64(len(grouped[events[i].ID]))
result.uncompressedBytes += int64(len(line))
}
lastID = events[len(events)-1].ID
}
if err := gzipWriter.Close(); err != nil {
return nil, fmt.Errorf("关闭审计归档压缩流失败: %w", err)
}
if err := temp.Close(); err != nil {
return nil, fmt.Errorf("关闭审计归档临时文件失败: %w", err)
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("读取审计归档临时文件信息失败: %w", err)
}
result.compressedBytes = info.Size()
result.sha256 = fmt.Sprintf("%x", hasher.Sum(nil))
failed = false
return result, nil
}
func (s *Service) databaseCounts(ctx context.Context, start, end time.Time) (int64, int64, error) {
var eventCount int64
if err := s.db.WithContext(ctx).Model(&model.AuditEvent{}).
Where("created_at >= ? AND created_at < ?", start, end).Count(&eventCount).Error; err != nil {
return 0, 0, fmt.Errorf("统计审计归档事件失败: %w", err)
}
var resourceCount int64
subquery := s.db.Model(&model.AuditEvent{}).Select("id").Where("created_at >= ? AND created_at < ?", start, end)
if err := s.db.WithContext(ctx).Model(&model.AuditEventResource{}).
Where("audit_event_id IN (?)", subquery).Count(&resourceCount).Error; err != nil {
return 0, 0, fmt.Errorf("统计审计归档资源失败: %w", err)
}
return eventCount, resourceCount, nil
}
func (s *Service) validateSuccessfulRun(ctx context.Context, run *model.LogArchiveRun) (bool, error) {
events, resources, err := s.databaseCounts(ctx, run.RangeStart, run.RangeEnd)
if err != nil || events != run.EventCount || resources != run.ResourceCount {
return false, err
}
metadata := map[string]string{
"sha256": run.SHA256, "event-count": strconv.FormatInt(run.EventCount, 10),
"resource-count": strconv.FormatInt(run.ResourceCount, 10), "revision": strconv.Itoa(run.Revision),
}
if err := s.verifyObject(ctx, run.ObjectKey, run.CompressedBytes, metadata); err != nil {
return false, nil
}
manifestMetadata := map[string]string{
"source": constants.AuditArchiveSource, "data-sha256": run.SHA256, "revision": strconv.Itoa(run.Revision),
}
if err := s.verifyObject(ctx, run.ManifestKey, -1, manifestMetadata); err != nil {
return false, nil
}
return true, nil
}
func (s *Service) verifyObject(ctx context.Context, key string, expectedSize int64, expectedMetadata map[string]string) error {
object, err := s.store.Stat(ctx, key)
if err != nil {
return fmt.Errorf("复核归档对象 metadata 失败: %w", err)
}
if expectedSize >= 0 && object.Size != expectedSize {
return fmt.Errorf("归档对象大小复核失败")
}
for name, value := range expectedMetadata {
if object.Metadata[strings.ToLower(name)] != value {
return fmt.Errorf("归档对象 metadata 字段 %s 复核失败", name)
}
}
return nil
}
func (s *Service) markFailed(ctx context.Context, runID uint, archiveErr error) {
summary := []rune(archiveErr.Error())
if len(summary) > 500 {
summary = summary[:500]
}
failedCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
_ = s.db.WithContext(failedCtx).Model(&model.LogArchiveRun{}).Where("id = ?", runID).Updates(map[string]any{
"status": constants.ArchiveStatusFailed, "error_summary": string(summary), "updated_at": time.Now(),
}).Error
}
func objectKeys(date time.Time, revision int) (string, string) {
prefix := fmt.Sprintf("audit-archive/v1/%04d/%02d/%02d", date.Year(), date.Month(), date.Day())
name := fmt.Sprintf("audit-events-%s-r%d", date.Format(time.DateOnly), revision)
return prefix + "/" + name + ".jsonl.gz", prefix + "/" + name + ".manifest.json"
}
func archiveMetadata(file *archiveFile, run *model.LogArchiveRun) map[string]string {
return map[string]string{
"schema-version": constants.AuditArchiveSchemaVersion,
"source": constants.AuditArchiveSource,
"archive-date": run.RangeStart.Format(time.DateOnly),
"timezone": constants.AuditArchiveTimezone,
"event-count": strconv.FormatInt(file.eventCount, 10),
"resource-count": strconv.FormatInt(file.resourceCount, 10),
"sha256": file.sha256,
"revision": strconv.Itoa(run.Revision),
}
}

View File

@@ -0,0 +1,260 @@
// 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/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
"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)
}
// StateAudit 描述一次需要与卡事实关联保存的状态操作。
type StateAudit struct {
ActionCode string
Summary string
Card *model.IotCard
IntegrationID string
BeforeData map[string]any
AfterData map[string]any
}
// StateAuditWriter 在卡状态事务中追加统一 Audit Event。
type StateAuditWriter interface {
WriteCardStateAudit(ctx context.Context, tx *gorm.DB, input StateAudit) error
WriteCardStateFailure(ctx context.Context, input StateAudit, businessErr error)
}
// Service 负责卡实名观测的锁定、规则应用和可靠事件写入。
type Service struct {
db *gorm.DB
eventWriter EventWriter
cache CacheInvalidator
auditWriter StateAuditWriter
}
// NewService 创建卡实名观测应用服务。
func NewService(db *gorm.DB, eventWriter EventWriter, cache CacheInvalidator) *Service {
return &Service{db: db, eventWriter: eventWriter, cache: cache}
}
// SetStateAuditWriter 注入卡状态统一审计 Writer。
func (s *Service) SetStateAuditWriter(writer StateAuditWriter) {
s.auditWriter = writer
}
// RecordCarrierCallbackFailure 在已解析卡资源后记录运营商回调处理失败。
func (s *Service) RecordCarrierCallbackFailure(ctx context.Context, card *model.IotCard, integrationID string, businessErr error) {
if s == nil || s.auditWriter == nil || card == nil || card.ID == 0 {
return
}
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardRealnameCallbackSynced,
Summary: "运营商回调同步 IoT 卡实名状态失败", Card: card, IntegrationID: integrationID,
}, businessErr)
}
// 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
var auditedCard *model.IotCard
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卡失败")
}
auditedCard = &card
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 {
verified := decision.AfterStatus == constants.RealNameStatusVerified
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, verified)
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, verified, 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 := realnameChangedEventID(card.ID, observation.Metadata.ObservationID)
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 事件失败")
}
}
if observation.Metadata.Source == constants.CardObservationSourceManualOverride {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
summary := "人工更新 IoT 卡实名状态"
if !decision.StatusChanged {
summary = "人工确认 IoT 卡实名状态无需变化"
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardRealnameStatusUpdated,
Summary: summary,
Card: &card,
BeforeData: map[string]any{"real_name_status": card.RealNameStatus, "first_realname_at": card.FirstRealnameAt},
AfterData: map[string]any{
"real_name_status": decision.AfterStatus, "first_realname_at": firstRealnameAfter(card.FirstRealnameAt, observation.Metadata.ObservedAt, decision.FirstVerified),
"status_changed": decision.StatusChanged,
},
}); err != nil {
return err
}
} else if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && decision.StatusChanged && audited {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: actionCode,
Summary: "人工刷新 IoT 卡实名状态",
Card: &card,
BeforeData: map[string]any{"real_name_status": card.RealNameStatus, "first_realname_at": card.FirstRealnameAt},
AfterData: map[string]any{
"real_name_status": decision.AfterStatus, "first_realname_at": firstRealnameAfter(card.FirstRealnameAt, observation.Metadata.ObservedAt, decision.FirstVerified),
},
}); err != nil {
return err
}
} else if observation.Metadata.Source == constants.CardObservationSourceCarrierCallback && decision.StatusChanged {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardRealnameCallbackSynced,
Summary: "运营商回调同步 IoT 卡实名状态",
Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{"real_name_status": card.RealNameStatus, "first_realname_at": card.FirstRealnameAt},
AfterData: map[string]any{
"real_name_status": decision.AfterStatus, "first_realname_at": firstRealnameAfter(card.FirstRealnameAt, observation.Metadata.ObservedAt, decision.FirstVerified),
},
}); err != nil {
return err
}
} else if workerObservationAudited(ctx) && decision.StatusChanged {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerRealnameSynced,
Summary: "Worker 同步 IoT 卡实名事实", Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{"real_name_status": card.RealNameStatus, "first_realname_at": card.FirstRealnameAt},
AfterData: map[string]any{
"real_name_status": decision.AfterStatus, "first_realname_at": firstRealnameAfter(card.FirstRealnameAt, observation.Metadata.ObservedAt, decision.FirstVerified),
},
}); err != nil {
return err
}
}
return nil
})
if err != nil {
if workerObservationAudited(ctx) && auditedCard != nil && s.auditWriter != nil {
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerRealnameSynced,
Summary: "Worker 同步 IoT 卡实名事实失败", Card: auditedCard,
IntegrationID: observation.Metadata.ObservationID,
}, err)
}
return domain.RealnameDecision{}, err
}
if s.cache != nil {
s.cache.Invalidate(ctx, observation.CardID)
}
return decision, nil
}
func firstRealnameAfter(before *time.Time, observedAt time.Time, firstVerified bool) *time.Time {
if firstVerified {
return &observedAt
}
return before
}
func manualRefreshAuditAction(ctx context.Context) (string, bool) {
switch auditcontext.From(ctx).ActorKind {
case constants.AuditActorAccount:
return constants.AuditActionIotCardManualRefreshed, true
case constants.AuditActorPersonalCustomer:
return constants.AuditActionIotCardPersonalRefreshed, true
default:
return "", false
}
}
func workerObservationAudited(ctx context.Context) bool {
linkage := auditcontext.From(ctx)
return linkage.ActorKind == constants.AuditActorSystemTask && linkage.Source == constants.AuditSourceWorker
}
func realnameChangedEventID(cardID uint, observationID string) string {
prefix := "card-realname:"
return outboxid.Stable(prefix, strconv.FormatUint(uint64(cardID), 10)+":"+observationID+":changed")
}

View File

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

View File

@@ -0,0 +1,169 @@
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"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
"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
var auditedCard *model.IotCard
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卡网络事实失败")
}
auditedCard = &card
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 {
eventID := outboxid.Stable("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 事件失败")
}
}
stateChanged := decision.StatusChanged || decision.StopReasonChanged || decision.StopPolling ||
decision.GatewayExtend != card.GatewayExtend || decision.UpdateIMEI && decision.GatewayIMEI != card.GatewayCardIMEI
if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && stateChanged && audited {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
enablePolling := card.EnablePolling
if decision.StopPolling {
enablePolling = false
}
gatewayIMEI := card.GatewayCardIMEI
if decision.UpdateIMEI {
gatewayIMEI = decision.GatewayIMEI
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: actionCode,
Summary: "人工刷新 IoT 卡网络状态",
Card: &card,
BeforeData: map[string]any{
"network_status": card.NetworkStatus, "stop_reason": card.StopReason,
"gateway_extend": card.GatewayExtend, "gateway_card_imei": card.GatewayCardIMEI,
"enable_polling": card.EnablePolling,
},
AfterData: map[string]any{
"network_status": decision.AfterStatus, "stop_reason": decision.StopReason,
"gateway_extend": decision.GatewayExtend, "gateway_card_imei": gatewayIMEI,
"enable_polling": enablePolling,
},
}); err != nil {
return err
}
} else if workerObservationAudited(ctx) && stateChanged {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
enablePolling := card.EnablePolling
if decision.StopPolling {
enablePolling = false
}
gatewayIMEI := card.GatewayCardIMEI
if decision.UpdateIMEI {
gatewayIMEI = decision.GatewayIMEI
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerNetworkSynced,
Summary: "Worker 同步 IoT 卡网络事实", Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{
"network_status": card.NetworkStatus, "stop_reason": card.StopReason,
"gateway_extend": card.GatewayExtend, "gateway_card_imei": card.GatewayCardIMEI,
"enable_polling": card.EnablePolling,
},
AfterData: map[string]any{
"network_status": decision.AfterStatus, "stop_reason": decision.StopReason,
"gateway_extend": decision.GatewayExtend, "gateway_card_imei": gatewayIMEI,
"enable_polling": enablePolling,
},
}); err != nil {
return err
}
}
return nil
})
if err != nil {
if workerObservationAudited(ctx) && auditedCard != nil && s.auditWriter != nil {
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerNetworkSynced,
Summary: "Worker 同步 IoT 卡网络事实失败", Card: auditedCard,
IntegrationID: observation.Metadata.ObservationID,
}, err)
}
return domain.NetworkDecision{}, err
}
if s.cache != nil {
s.cache.Invalidate(ctx, observation.CardID)
}
return decision, nil
}

View File

@@ -0,0 +1,359 @@
package cardobservation
import (
"context"
"strings"
"time"
"github.com/google/uuid"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"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"`
ParentEventID string `json:"parent_event_id,omitempty"`
}
// DeviceCardsSeriesRequest 描述需要在后台展开设备有效绑定卡的观测请求。
type DeviceCardsSeriesRequest struct {
DeviceID uint
Request SeriesRequest
}
// DeviceControlSeriesRequest 描述设备控制成功后需要在后台解析的差异化观测资源。
type DeviceControlSeriesRequest struct {
DeviceID uint
TargetICCID string
SourceCardID uint
TargetCardID uint
BoundCardIDs []uint
IncludeTargetTraffic bool
Request SeriesRequest
}
// RealnameCapabilitySeriesRequest 描述需要在后台判断运营商实名能力的观测请求。
type RealnameCapabilitySeriesRequest struct {
CarrierID uint
Request SeriesRequest
}
// SeriesTaskPayload 是固定三次 Asynq 任务的结构化载荷。
type SeriesTaskPayload struct {
SeriesID string `json:"series_id"`
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"`
ParentEventID string `json:"parent_event_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, originalRequest SeriesRequest, merged bool, err error)
IsScheduled(ctx context.Context, seriesID string) (bool, error)
ClaimSchedule(ctx context.Context, seriesID string, attempt int) (bool, error)
ReleaseSchedule(ctx context.Context, seriesID string, attempt int)
IsCompleted(ctx context.Context, seriesID string) (bool, error)
ClaimAttempt(ctx context.Context, seriesID string, attempt int) (bool, error)
AcquireRequest(ctx context.Context, payload SeriesTaskPayload, provider string) (release func(), wait time.Duration, acquired bool, err error)
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
}
// BestEffortSeriesDispatcher 为读取入口提供不返回业务错误的轻量触发端口。
type BestEffortSeriesDispatcher interface {
Dispatch(ctx context.Context, request SeriesRequest)
DispatchDeviceCards(ctx context.Context, request DeviceCardsSeriesRequest)
DispatchDeviceControl(ctx context.Context, request DeviceControlSeriesRequest)
DispatchRealnameWithCapability(ctx context.Context, request RealnameCapabilitySeriesRequest)
}
// SeriesAttemptLogger 记录未访问 Gateway 的合并、互斥、限频和提前完成结果。
type SeriesAttemptLogger interface {
Record(ctx context.Context, payload SeriesTaskPayload, result, reason string) error
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) {
return s.trigger(ctx, request, uuid.NewString(), false)
}
// TriggerEvent 使用稳定业务事件键触发序列,至少一次重投不会再次创建任务。
func (s *SeriesTrigger) TriggerEvent(ctx context.Context, eventKey string, request SeriesRequest) (string, bool, error) {
seriesID := uuid.NewSHA1(uuid.NameSpaceOID, []byte(eventKey)).String()
return s.trigger(ctx, request, seriesID, true)
}
func (s *SeriesTrigger) trigger(ctx context.Context, request SeriesRequest, candidateSeriesID string, stable bool) (string, bool, error) {
if s == nil || s.coordinator == nil || s.scheduler == nil || s.logger == nil {
return "", false, errors.New(errors.CodeInternalError, "卡观测序列触发能力未完整配置")
}
request = normalizeSeriesTrace(ctx, request)
if err := validateSeriesRequest(request); err != nil {
return "", false, err
}
if stable {
scheduled, err := s.coordinator.IsScheduled(ctx, candidateSeriesID)
if err != nil {
return "", false, err
}
if scheduled {
if logErr := s.logger.RecordMerged(ctx, request, candidateSeriesID); logErr != nil {
return candidateSeriesID, true, logErr
}
return candidateSeriesID, true, nil
}
}
candidateBaseTime := s.now().UTC()
seriesID, baseTime, originalRequest, merged, err := s.coordinator.Reserve(ctx, request, candidateSeriesID, candidateBaseTime)
if err != nil {
return "", false, err
}
// 重复触发仍以稳定任务 ID 补齐首次入队的局部失败;已存在任务由 Asynq 去重,不会延长原序列。
for attempt := 1; attempt <= constants.CardObservationSeriesAttemptCount; attempt++ {
claimed, claimErr := s.coordinator.ClaimSchedule(ctx, seriesID, attempt)
if claimErr != nil {
return seriesID, merged, claimErr
}
if !claimed {
continue
}
scheduledAt := baseTime.Add(constants.CardObservationAttemptDelay(attempt))
payload := SeriesTaskPayload{
SeriesID: seriesID, Attempt: attempt, ScheduledAt: scheduledAt,
Scene: originalRequest.Scene, ResourceType: originalRequest.ResourceType, ResourceID: originalRequest.ResourceID,
SyncType: originalRequest.SyncType, ExpectedValue: originalRequest.ExpectedValue, Source: originalRequest.Source,
RequestID: originalRequest.RequestID, CorrelationID: originalRequest.CorrelationID,
ParentEventID: originalRequest.ParentEventID,
}
if err := s.scheduler.Enqueue(ctx, payload); err != nil {
s.coordinator.ReleaseSchedule(ctx, seriesID, attempt)
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 s.recordPreGatewayFailure(ctx, payload, "读取序列完成状态失败", err)
}
if completed {
return s.completeRemainingAttempts(ctx, payload, "序列已提前完成")
}
met, err := s.runner.ExpectationMet(ctx, payload)
if err != nil {
return s.recordPreGatewayFailure(ctx, payload, "读取本地预期快照失败", err)
}
if met {
return s.completeRemainingAttempts(ctx, payload, "本地快照已达到预期")
}
provider, err := s.runner.Provider(ctx, payload)
if err != nil {
return s.recordPreGatewayFailure(ctx, payload, "解析运营商接入失败", err)
}
release, wait, acquired, err := s.coordinator.AcquireRequest(ctx, payload, provider)
if err != nil {
return s.recordPreGatewayFailure(ctx, payload, "获取实际请求互斥失败", 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) recordPreGatewayFailure(ctx context.Context, payload SeriesTaskPayload, reason string, original error) error {
if logErr := s.logger.Record(ctx, payload, constants.IntegrationResultFailed, reason); logErr != nil {
return errors.Wrap(errors.CodeInternalError, original, reason+",且 Integration Log 写入失败")
}
return original
}
func (s *SeriesAttemptService) completeRemainingAttempts(ctx context.Context, payload SeriesTaskPayload, reason string) error {
baseTime := payload.ScheduledAt.Add(-constants.CardObservationAttemptDelay(payload.Attempt))
for attempt := payload.Attempt; attempt <= constants.CardObservationSeriesAttemptCount; attempt++ {
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) || !validObservationSource(request.Source) ||
strings.TrimSpace(request.RequestID) == "" || strings.TrimSpace(request.CorrelationID) == "" {
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, Source: payload.Source, RequestID: payload.RequestID, CorrelationID: payload.CorrelationID,
ParentEventID: payload.ParentEventID,
})
}
func normalizeSeriesTrace(ctx context.Context, request SeriesRequest) SeriesRequest {
linkage := auditcontext.From(ctx)
request.RequestID = strings.TrimSpace(request.RequestID)
request.CorrelationID = strings.TrimSpace(request.CorrelationID)
request.ParentEventID = strings.TrimSpace(request.ParentEventID)
if request.RequestID == "" {
request.RequestID = strings.TrimSpace(linkage.RequestID)
}
if request.CorrelationID == "" {
request.CorrelationID = strings.TrimSpace(linkage.CorrelationID)
}
if request.ParentEventID == "" {
request.ParentEventID = strings.TrimSpace(linkage.ParentEventID)
}
if request.RequestID == "" && request.CorrelationID == "" {
traceID := uuid.NewString()
request.RequestID = traceID
request.CorrelationID = traceID
} else if request.RequestID == "" {
request.RequestID = request.CorrelationID
} else if request.CorrelationID == "" {
request.CorrelationID = request.RequestID
}
return request
}
func validObservationSource(source string) bool {
switch source {
case constants.CardObservationSourcePolling, constants.CardObservationSourceManualSync,
constants.CardObservationSourceManualOverride, constants.CardObservationSourceCarrierCallback,
constants.CardObservationSourceBusinessEvent:
return true
default:
return false
}
}
func validSyncType(syncType string) bool {
switch syncType {
case constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic,
constants.CardObservationSyncTypeNetwork, constants.CardObservationSyncTypeDeviceInfo:
return true
default:
return false
}
}

View File

@@ -0,0 +1,143 @@
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/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
)
// 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
var auditedCard *model.IotCard
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卡流量事实失败")
}
auditedCard = &card
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 {
eventID := outboxid.Stable("card-traffic:", strconv.FormatUint(uint64(card.ID), 10)+":"+observation.Metadata.ObservationID+":incremented")
if err := 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,
}); err != nil {
return err
}
}
stateChanged := decision.IncrementMB != 0 || decision.CrossMonth || decision.LastGatewayReadingMB != card.LastGatewayReadingMB
if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && stateChanged && audited {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: actionCode,
Summary: "人工刷新 IoT 卡流量",
Card: &card,
BeforeData: map[string]any{
"data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB,
"current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB,
"last_gateway_reading_mb": card.LastGatewayReadingMB,
},
AfterData: map[string]any{
"data_usage_mb": decision.DataUsageMB, "current_month_usage_mb": decision.CurrentMonthUsageMB,
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
"last_gateway_reading_mb": decision.LastGatewayReadingMB, "increment_mb": decision.IncrementMB,
},
}); err != nil {
return err
}
} else if workerObservationAudited(ctx) && stateChanged {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerTrafficSynced,
Summary: "Worker 同步 IoT 卡流量事实", Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{
"data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB,
"current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB,
"last_gateway_reading_mb": card.LastGatewayReadingMB,
},
AfterData: map[string]any{
"data_usage_mb": decision.DataUsageMB, "current_month_usage_mb": decision.CurrentMonthUsageMB,
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
"last_gateway_reading_mb": decision.LastGatewayReadingMB, "increment_mb": decision.IncrementMB,
},
}); err != nil {
return err
}
}
return nil
})
if err != nil {
if workerObservationAudited(ctx) && auditedCard != nil && s.auditWriter != nil {
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerTrafficSynced,
Summary: "Worker 同步 IoT 卡流量事实失败", Card: auditedCard,
IntegrationID: observation.Metadata.ObservationID,
}, err)
}
return domain.TrafficDecision{}, err
}
if s.cache != nil {
s.cache.Invalidate(ctx, observation.CardID)
}
return decision, nil
}

View File

@@ -0,0 +1,49 @@
// Package exchange 提供换货用例的可靠通知编排。
package exchange
import (
"context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ShippingCreatedEvent 是物流换货单创建后通知个人客户的稳定业务事件。
type ShippingCreatedEvent struct {
ExchangeID uint
ExchangeNo string
CustomerID uint
AssetType string
AssetID uint
AssetIdentifier string
RequestID string
CorrelationID string
}
// ShippingCreatedEventWriter 将物流换货创建通知写入可靠事件基础设施。
type ShippingCreatedEventWriter interface {
Append(ctx context.Context, tx *gorm.DB, event ShippingCreatedEvent) error
}
// ShippingCreatedNotifier 在换货业务事务内编排个人客户通知事件。
type ShippingCreatedNotifier struct {
writer ShippingCreatedEventWriter
}
// NewShippingCreatedNotifier 创建物流换货通知用例。
func NewShippingCreatedNotifier(writer ShippingCreatedEventWriter) *ShippingCreatedNotifier {
return &ShippingCreatedNotifier{writer: writer}
}
// Notify 在换货单事务内追加指定个人客户的可靠通知事件。
func (n *ShippingCreatedNotifier) Notify(ctx context.Context, tx *gorm.DB, event ShippingCreatedEvent) error {
if n == nil || n.writer == nil {
return errors.New(errors.CodeInternalError, "物流换货通知事件 Writer 未配置")
}
if tx == nil || event.ExchangeID == 0 || event.ExchangeNo == "" || event.CustomerID == 0 ||
event.AssetType == "" || event.AssetID == 0 || event.AssetIdentifier == "" {
return errors.New(errors.CodeInvalidParam, "物流换货通知事件参数不完整")
}
return n.writer.Append(ctx, tx, event)
}

View File

@@ -0,0 +1,278 @@
// Package notification 提供站内通知简单写用例与 Outbox 消费边界。
package notification
import (
"context"
"strings"
"time"
"github.com/bytedance/sonic"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"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
auditWriter *audit.Writer
now func() time.Time
}
// NewDeliveryService 创建站内通知投递用例。
func NewDeliveryService(repository *notificationinfra.Repository, registry *notificationinfra.Registry, resolver DynamicRecipientResolver, logger *zap.Logger, auditWriters ...*audit.Writer) *DeliveryService {
if logger == nil {
logger = zap.NewNop()
}
service := &DeliveryService{repository: repository, registry: registry, resolver: resolver, logger: logger, now: time.Now}
if len(auditWriters) > 0 {
service.auditWriter = auditWriters[0]
}
return service
}
// 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 {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
}
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 := false
err = s.repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var createErr error
created, createErr = s.repository.WithTx(tx).CreateIdempotent(ctx, notification)
if createErr != nil || !created {
return createErr
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "delivered"),
ActionCode: constants.AuditActionNotificationDelivered, Summary: "生成站内通知",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
Metadata: map[string]any{"outbox_event_id": eventID},
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
nil, map[string]any{"created": true, "is_read": false})},
})
})
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,220 @@
package notification
import (
"context"
stderrors "errors"
"strconv"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"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
auditWriter *audit.Writer
now func() time.Time
}
// NewReadService 创建单条已读用例。
func NewReadService(db *gorm.DB, auditWriters ...*audit.Writer) *ReadService {
service := &ReadService{db: db, now: time.Now}
if len(auditWriters) > 0 {
service.auditWriter = auditWriters[0]
}
return service
}
// MarkRead 仅首次更新当前接收人的未过期未读通知。
func (s *ReadService) MarkRead(ctx context.Context, recipientID, notificationID uint) error {
return s.markOneRead(ctx, constants.NotificationRecipientKindAccount, recipientID, notificationID,
constants.AuditActorAccount, constants.AuditSourceAdminAPI)
}
// 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)
}
count, err := s.markAllRead(ctx, constants.NotificationRecipientKindAccount, recipientID, request.Category,
constants.AuditActorAccount, constants.AuditSourceAdminAPI)
if err != nil {
return nil, err
}
return &dto.NotificationReadAllResponse{UpdatedCount: count}, 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 {
return s.markOneRead(ctx, constants.NotificationRecipientKindPersonalCustomer, customerID, notificationID,
constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI)
}
// MarkAllPersonalRead 将当前个人客户可见的全部未过期通知幂等标记为已读。
func (s *ReadService) MarkAllPersonalRead(ctx context.Context, customerID uint) (*dto.NotificationReadAllResponse, error) {
if customerID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
count, err := s.markAllRead(ctx, constants.NotificationRecipientKindPersonalCustomer, customerID, "",
constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI)
if err != nil {
return nil, err
}
return &dto.NotificationReadAllResponse{UpdatedCount: count}, nil
}
func (s *ReadService) markOneRead(ctx context.Context, recipientKind string, recipientID, notificationID uint, actorKind, source string) error {
if recipientID == 0 || notificationID == 0 {
return errors.New(errors.CodeInvalidParam)
}
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
}
now := s.now().UTC()
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var notification model.Notification
query := readScope(tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}), recipientKind, recipientID, now).
Where("id = ? AND is_read = ?", notificationID, false).Take(&notification)
if stderrors.Is(query.Error, gorm.ErrRecordNotFound) {
return nil
}
if query.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, query.Error, "查询通知已读状态失败")
}
if err := tx.WithContext(ctx).Model(&model.Notification{}).Where("id = ? AND is_read = ?", notification.ID, false).
Updates(map[string]any{"is_read": true, "read_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新通知已读状态失败")
}
return s.appendReadAudit(ctx, tx, &notification, now, actorKind, source, "")
})
}
func (s *ReadService) markAllRead(ctx context.Context, recipientKind string, recipientID uint, category, actorKind, source string) (int64, error) {
if s.auditWriter == nil {
return 0, errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
}
now := s.now().UTC()
var updated int64
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var notifications []*model.Notification
query := readScope(tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}), recipientKind, recipientID, now).
Where("is_read = ?", false)
if category != "" {
query = query.Where("category = ?", category)
}
if err := query.Order("id ASC").Find(&notifications).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询批量通知已读状态失败")
}
if len(notifications) == 0 {
return nil
}
ids := make([]uint, 0, len(notifications))
for _, notification := range notifications {
ids = append(ids, notification.ID)
}
result := tx.WithContext(ctx).Model(&model.Notification{}).Where("id IN ? AND is_read = ?", ids, false).
Updates(map[string]any{"is_read": true, "read_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新通知已读状态失败")
}
if result.RowsAffected != int64(len(notifications)) {
return errors.New(errors.CodeInvalidStatus, "通知已读状态发生并发变化")
}
updated = result.RowsAffected
return s.appendReadAllAudit(ctx, tx, notifications, now, recipientKind, recipientID, category, actorKind, source)
})
return updated, err
}
func (s *ReadService) appendReadAudit(ctx context.Context, tx *gorm.DB, notification *model.Notification, now time.Time, actorKind, source, parentEventID string) error {
scopeType, scopeID := notificationScope(notification.RecipientKind, notification.RecipientID)
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "read"),
ActionCode: constants.AuditActionNotificationRead, Summary: "标记通知已读",
Actor: audit.ActorInput{Kind: actorKind, ID: strconv.FormatUint(uint64(notification.RecipientID), 10)},
Source: source, ScopeType: scopeType, ScopeID: scopeID,
Result: constants.AuditResultSuccess, ParentEventID: parentEventID,
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
map[string]any{"is_read": false}, map[string]any{"is_read": true, "read_at": now})},
})
}
func (s *ReadService) appendReadAllAudit(ctx context.Context, tx *gorm.DB, notifications []*model.Notification, now time.Time, recipientKind string, recipientID uint, category, actorKind, source string) error {
rootID := "evt_" + uuid.NewString()
actor := audit.ActorInput{Kind: actorKind, ID: strconv.FormatUint(uint64(recipientID), 10)}
scopeType, scopeID := notificationScope(recipientKind, recipientID)
children := make([]audit.AppendInput, 0, len(notifications))
for _, notification := range notifications {
children = append(children, audit.AppendInput{
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "read"),
ActionCode: constants.AuditActionNotificationRead, Summary: "批量标记通知已读",
Actor: actor, Source: source, ScopeType: scopeType, ScopeID: scopeID, Result: constants.AuditResultSuccess,
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
map[string]any{"is_read": false}, map[string]any{"is_read": true, "read_at": now})},
})
}
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
Root: audit.AppendInput{
EventID: rootID, ActionCode: constants.AuditActionNotificationReadAll, Summary: "批量标记通知已读",
Actor: actor, Source: source, ScopeType: scopeType, ScopeID: scopeID, Result: constants.AuditResultSuccess,
BatchTotal: len(notifications), SuccessCount: len(notifications),
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceNotificationReadBatch, Key: rootID, DisplayName: "通知批量已读",
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchTask,
IdentitySnapshot: map[string]any{
"recipient_kind": recipientKind, "recipient_id": recipientID,
"category": category, "updated_count": len(notifications),
}, SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
},
Children: children,
})
}
func notificationScope(recipientKind string, recipientID uint) (string, string) {
if recipientKind == constants.NotificationRecipientKindPersonalCustomer {
return constants.AuditScopePersonalCustomer, strconv.FormatUint(uint64(recipientID), 10)
}
return constants.AuditScopePlatform, ""
}
func readScope(db *gorm.DB, recipientKind string, recipientID uint, now time.Time) *gorm.DB {
if recipientKind == constants.NotificationRecipientKindPersonalCustomer {
return personalReadScope(db, recipientID, now)
}
return db.Model(&model.Notification{}).Where(
"recipient_kind = ? AND recipient_id = ? AND (expires_at IS NULL OR expires_at > ?)",
recipientKind, recipientID, now,
)
}
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, constants.NotificationCategorySystem},
[]string{constants.NotificationTypePackageExpiring, constants.NotificationTypeExchangeShippingCreated},
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,255 @@
// Package outbox 提供公共 Outbox 的受控人工恢复用例。
package outbox
import (
"context"
stderrors "errors"
"strconv"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
// Operator 表示人工恢复操作者的授权快照。
type Operator struct {
ID uint
SuperAdmin bool
RequestID string
CorrelationID string
}
// RecoveryAudit 是交给统一 Audit Port 的安全恢复事实。
type RecoveryAudit struct {
OperatorID uint
OperationType string
Description string
EventIDs []string
Reason string
BatchID string
RequestID string
CorrelationID string
Events []RecoveryEventAudit
Result string
ErrorCode string
ErrorSummary string
}
// RecoveryEventAudit 是一次人工恢复中单个 Outbox 事件的身份和状态变化。
type RecoveryEventAudit struct {
ID uint
EventID string
EventType string
AggregateType string
AggregateID string
ResourceType string
ResourceID string
BusinessKey string
BeforeStatus int
AfterStatus int
BeforeNextAttempt time.Time
AfterNextAttempt time.Time
BeforeLeaseOwner *string
BeforeLeaseExpires *time.Time
AfterLeaseOwner *string
AfterLeaseExpires *time.Time
}
// AuditWriter 是 tech-global-audit 提供实现的统一审计接缝。
type AuditWriter interface {
WriteRecovery(ctx context.Context, tx *gorm.DB, audit RecoveryAudit) error
}
// RecoveryService 执行选择性重放和过期租约释放。
type RecoveryService struct {
db *gorm.DB
audit AuditWriter
now func() time.Time
}
// NewRecoveryService 创建受控恢复用例;审计接缝不可缺失。
func NewRecoveryService(db *gorm.DB, audit AuditWriter, now func() time.Time) (*RecoveryService, error) {
if db == nil || audit == nil {
return nil, stderrors.New("Outbox 恢复必须配置数据库和统一审计接缝")
}
if now == nil {
now = time.Now
}
return &RecoveryService{db: db, audit: audit, now: now}, nil
}
// Replay 只重放明确选择的最终失败或租约过期事件,并保留原始内容和身份。
func (s *RecoveryService) Replay(ctx context.Context, operator Operator, ids []uint, reason string) (string, error) {
if err := validateCommand(operator, ids, reason); err != nil {
return "", err
}
batchID := uuid.NewString()
now := s.now().UTC()
failureAudit := RecoveryAudit{
OperatorID: operator.ID, OperationType: constants.AuditOperationOutboxReplay,
Description: "人工重放 Outbox 事件失败", Reason: reason, BatchID: batchID,
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID,
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
events, err := loadSelectedForUpdate(tx, ids)
if err != nil {
return err
}
failureAudit.EventIDs, failureAudit.Events = unchangedRecoveryAudit(events)
if len(events) != len(ids) {
failureAudit.Events = nil
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的事件不存在或状态不允许重放")
}
eventIDs := make([]string, 0, len(events))
auditEvents := make([]RecoveryEventAudit, 0, len(events))
for _, event := range events {
allowed := event.Status == constants.OutboxStatusFailed ||
(event.Status == constants.OutboxStatusDelivering && event.LeaseExpiresAt != nil && !event.LeaseExpiresAt.After(now))
if !allowed {
failureAudit.Result = constants.AuditResultDenied
failureAudit.ErrorCode = strconv.Itoa(pkgerrors.CodeInvalidStatus)
failureAudit.ErrorSummary = "选择的事件不存在或状态不允许重放"
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的事件不存在或状态不允许重放")
}
eventIDs = append(eventIDs, event.EventID)
auditEvents = append(auditEvents, recoveryEventAudit(event, now))
}
failureAudit.Result = constants.AuditResultFailed
failureAudit.ErrorCode = strconv.Itoa(pkgerrors.CodeDatabaseError)
failureAudit.ErrorSummary = "Outbox 人工重放事务已回滚"
result := tx.Model(&model.OutboxEvent{}).Where("id IN ?", ids).Updates(map[string]any{
"status": constants.OutboxStatusPending, "next_attempt_at": now,
"lease_owner": nil, "lease_expires_at": nil, "updated_at": now,
})
if result.Error != nil {
return result.Error
}
return s.audit.WriteRecovery(ctx, tx, RecoveryAudit{
OperatorID: operator.ID, OperationType: constants.AuditOperationOutboxReplay, Description: "人工重放 Outbox 事件",
EventIDs: eventIDs, Reason: reason, BatchID: batchID,
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID, Events: auditEvents,
})
})
if err != nil {
s.recordFailure(ctx, failureAudit)
}
return batchID, err
}
// ReleaseExpiredLeases 只释放明确选择且已经过期的投递租约。
func (s *RecoveryService) ReleaseExpiredLeases(ctx context.Context, operator Operator, ids []uint, reason string) (string, error) {
if err := validateCommand(operator, ids, reason); err != nil {
return "", err
}
batchID := uuid.NewString()
now := s.now().UTC()
failureAudit := RecoveryAudit{
OperatorID: operator.ID, OperationType: constants.AuditOperationOutboxReleaseExpiredLease,
Description: "人工释放 Outbox 过期租约失败", Reason: reason, BatchID: batchID,
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID,
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
events, err := loadSelectedForUpdate(tx, ids)
if err != nil {
return err
}
failureAudit.EventIDs, failureAudit.Events = unchangedRecoveryAudit(events)
if len(events) != len(ids) {
failureAudit.Events = nil
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的租约不存在或仍然有效")
}
eventIDs := make([]string, 0, len(events))
auditEvents := make([]RecoveryEventAudit, 0, len(events))
for _, event := range events {
if event.Status != constants.OutboxStatusDelivering || event.LeaseExpiresAt == nil || event.LeaseExpiresAt.After(now) {
failureAudit.Result = constants.AuditResultDenied
failureAudit.ErrorCode = strconv.Itoa(pkgerrors.CodeInvalidStatus)
failureAudit.ErrorSummary = "选择的租约不存在或仍然有效"
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的租约不存在或仍然有效")
}
eventIDs = append(eventIDs, event.EventID)
auditEvents = append(auditEvents, recoveryEventAudit(event, now))
}
failureAudit.Result = constants.AuditResultFailed
failureAudit.ErrorCode = strconv.Itoa(pkgerrors.CodeDatabaseError)
failureAudit.ErrorSummary = "Outbox 过期租约释放事务已回滚"
result := tx.Model(&model.OutboxEvent{}).Where("id IN ? AND status = ? AND lease_expires_at <= ?", ids, constants.OutboxStatusDelivering, now).
Updates(map[string]any{
"status": constants.OutboxStatusPending, "next_attempt_at": now,
"lease_owner": nil, "lease_expires_at": nil, "updated_at": now,
})
if result.Error != nil {
return result.Error
}
return s.audit.WriteRecovery(ctx, tx, RecoveryAudit{
OperatorID: operator.ID, OperationType: constants.AuditOperationOutboxReleaseExpiredLease, Description: "人工释放 Outbox 过期租约",
EventIDs: eventIDs, Reason: reason, BatchID: batchID,
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID, Events: auditEvents,
})
})
if err != nil {
s.recordFailure(ctx, failureAudit)
}
return batchID, err
}
func (s *RecoveryService) recordFailure(ctx context.Context, audit RecoveryAudit) {
if len(audit.Events) == 0 || audit.Result == "" {
return
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.audit.WriteRecovery(ctx, tx, audit)
})
if err != nil {
auditfailure.RecordSecondaryWriteFailure(
audit.OperationType, audit.Events[0].EventID, audit.RequestID, audit.CorrelationID, audit.ErrorCode, err,
)
}
}
func validateCommand(operator Operator, ids []uint, reason string) error {
if !operator.SuperAdmin {
return pkgerrors.New(pkgerrors.CodeForbidden)
}
if operator.ID == 0 || len(ids) == 0 || reason == "" {
return pkgerrors.New(pkgerrors.CodeInvalidParam)
}
return nil
}
func loadSelectedForUpdate(tx *gorm.DB, ids []uint) ([]model.OutboxEvent, error) {
var events []model.OutboxEvent
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ?", ids).Order("id ASC").Find(&events).Error
return events, err
}
func recoveryEventAudit(event model.OutboxEvent, nextAttempt time.Time) RecoveryEventAudit {
return RecoveryEventAudit{
ID: event.ID, EventID: event.EventID, EventType: event.EventType,
AggregateType: event.AggregateType, AggregateID: event.AggregateID,
ResourceType: event.ResourceType, ResourceID: event.ResourceID, BusinessKey: event.BusinessKey,
BeforeStatus: event.Status, AfterStatus: constants.OutboxStatusPending,
BeforeNextAttempt: event.NextAttemptAt, AfterNextAttempt: nextAttempt,
BeforeLeaseOwner: event.LeaseOwner, BeforeLeaseExpires: event.LeaseExpiresAt,
}
}
func unchangedRecoveryAudit(events []model.OutboxEvent) ([]string, []RecoveryEventAudit) {
eventIDs := make([]string, 0, len(events))
auditEvents := make([]RecoveryEventAudit, 0, len(events))
for _, event := range events {
eventIDs = append(eventIDs, event.EventID)
auditEvent := recoveryEventAudit(event, event.NextAttemptAt)
auditEvent.AfterStatus = event.Status
auditEvent.AfterLeaseOwner = event.LeaseOwner
auditEvent.AfterLeaseExpires = event.LeaseExpiresAt
auditEvents = append(auditEvents, auditEvent)
}
return eventIDs, auditEvents
}

View File

@@ -0,0 +1,45 @@
// Package packageexpiry 编排每日套餐临期提醒用例。
package packageexpiry
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ReminderScanner 查询当天临期资产。
type ReminderScanner interface {
ReminderCandidates(ctx context.Context) ([]dto.ExpiringAssetItem, error)
}
// ReminderPublisher 批量发布个人客户临期通知。
type ReminderPublisher interface {
Publish(ctx context.Context, candidates []dto.ExpiringAssetItem) error
}
// ReminderService 扫描并发布每日套餐临期提醒。
type ReminderService struct {
scanner ReminderScanner
publisher ReminderPublisher
}
// NewReminderService 创建套餐临期提醒用例。
func NewReminderService(scanner ReminderScanner, publisher ReminderPublisher) *ReminderService {
return &ReminderService{scanner: scanner, publisher: publisher}
}
// Run 执行当天临期扫描并可靠发布通知。
func (s *ReminderService) Run(ctx context.Context) error {
if s == nil || s.scanner == nil || s.publisher == nil {
return errors.New(errors.CodeInternalError, "套餐临期提醒用例未配置")
}
candidates, err := s.scanner.ReminderCandidates(ctx)
if err != nil {
return err
}
if len(candidates) == 0 {
return nil
}
return s.publisher.Publish(ctx, candidates)
}

View File

@@ -0,0 +1,172 @@
// Package refundapproval 收口退款申请与渠道无关审批的事务边界。
package refundapproval
import (
"context"
"fmt"
"strings"
"github.com/bytedance/sonic"
"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"
)
// CreateCommand 描述已通过订单与金额校验的退款审批申请。
type CreateCommand struct {
Refund *model.RefundRequest
Order *model.Order
SubmitterAccountID uint
}
// ApplicationAudit 描述退款申请、审批、订单和提交人的同事务审计事实。
type ApplicationAudit struct {
Refund *model.RefundRequest
Order *model.Order
Approval *model.ApprovalInstance
Submitter *model.Account
}
// AuditWriter 接收退款申请事务内审计事实。
type AuditWriter interface {
WriteRefundApplication(ctx context.Context, tx *gorm.DB, audit ApplicationAudit) error
}
// CreateResult 返回原子保存后的退款申请和初始审批状态。
type CreateResult struct {
Refund *model.RefundRequest
SubmitterName string
ApprovalStatus int
}
// CreationService 原子创建退款申请、通用审批实例、企微上下文和提交 Outbox。
type CreationService struct {
db *gorm.DB
approval approvalapp.Port
audit AuditWriter
}
// NewCreationService 创建退款审批申请用例。
func NewCreationService(db *gorm.DB, approval approvalapp.Port, audit AuditWriter) *CreationService {
return &CreationService{db: db, approval: approval, audit: audit}
}
// Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实和审批事实。
func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*CreateResult, error) {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
if command.Refund == nil || command.Order == nil || command.Refund.OrderID == 0 || command.Order.ID != command.Refund.OrderID || command.SubmitterAccountID == 0 ||
command.Refund.Creator != command.SubmitterAccountID || strings.TrimSpace(command.Refund.RefundNo) == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
account, err := s.loadSubmitter(ctx, command.SubmitterAccountID)
if err != nil {
return nil, err
}
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeRefund, SubmitterAccountID: command.SubmitterAccountID,
CorrelationID: command.Refund.RefundNo,
})
if err != nil {
return nil, err
}
submitterSnapshot, requestSnapshot, err := refundSnapshots(command.Refund, account)
if err != nil {
return nil, err
}
var approvalStatus int
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(command.Refund.OrderID)).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款订单申请边界失败")
}
var activeCount int64
if err := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("order_id = ? AND status IN ?", command.Refund.OrderID, []int{model.RefundStatusPending, model.RefundStatusApproved}).
Count(&activeCount).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "复核订单活跃退款申请失败")
}
if activeCount > 0 {
return errors.New(errors.CodeConflict, "该订单已存在退款申请")
}
if err := tx.WithContext(ctx).Create(command.Refund).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款申请失败")
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
BusinessID: command.Refund.ID, SubmitterAccountID: command.SubmitterAccountID,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: command.Refund.RefundNo,
})
if err != nil {
return err
}
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND approval_instance_id IS NULL", command.Refund.ID).
Update("approval_instance_id", reference.InstanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联退款审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款审批实例关联已变化")
}
command.Refund.ApprovalInstanceID = &reference.InstanceID
approvalStatus = reference.Status
var approval model.ApprovalInstance
if err := tx.WithContext(ctx).First(&approval, reference.InstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
}
return s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
Refund: command.Refund, Order: command.Order, Approval: &approval, Submitter: account,
})
})
if err != nil {
return nil, err
}
return &CreateResult{Refund: command.Refund, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
}
func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*model.Account, error) {
var account model.Account
if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", accountID, constants.StatusEnabled).First(&account).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeForbidden, "退款提交人账号不可用")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款提交人失败")
}
return &account, nil
}
func refundSnapshots(refund *model.RefundRequest, account *model.Account) ([]byte, []byte, error) {
submitterSnapshot, err := sonic.Marshal(map[string]any{
"account_id": account.ID, "account_name": account.Username, "user_type": account.UserType,
})
if err != nil {
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码退款提交人快照失败")
}
requestSnapshot, err := sonic.Marshal(map[string]any{
constants.ApprovalFieldRefundNo: refund.RefundNo,
constants.ApprovalFieldOrderID: refund.OrderID,
constants.ApprovalFieldOrderNo: refund.OrderNo,
constants.ApprovalFieldAssetIdentifier: refund.AssetIdentifier,
constants.ApprovalFieldAssetType: refund.OrderType,
constants.ApprovalFieldActualReceivedAmount: formatCentAmount(refund.ActualReceivedAmount),
constants.ApprovalFieldRequestedRefundAmount: formatCentAmount(refund.RequestedRefundAmount),
constants.ApprovalFieldRefundVoucherKey: []string(refund.RefundVoucherKey),
constants.ApprovalFieldRefundReason: refund.RefundReason,
constants.ApprovalFieldPackageUsageID: refund.PackageUsageID,
constants.ApprovalFieldSubmitterID: account.ID,
constants.ApprovalFieldSubmitterName: account.Username,
})
if err != nil {
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码退款审批业务快照失败")
}
return submitterSnapshot, requestSnapshot, nil
}
func formatCentAmount(amount int64) string {
return fmt.Sprintf("%d.%02d", amount/100, amount%100)
}

View File

@@ -0,0 +1,144 @@
// Package role 提供角色默认信用模板的应用用例。
package role
import (
"context"
stdErrors "errors"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"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
accessAudit accessauditapp.Writer
}
// NewDefaultCreditService 创建角色默认信用模板服务。
func NewDefaultCreditService(db *gorm.DB, permissionChecker PermissionChecker, accessAudit accessauditapp.Writer) *DefaultCreditService {
return &DefaultCreditService{db: db, permissionChecker: permissionChecker, accessAudit: accessAudit}
}
// 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
var beforeData map[string]any
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, "读取角色失败")
}
beforeData = defaultCreditAuditData(&role)
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
if s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "角色默认信用审计接缝未配置")
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionRoleDefaultCreditUpdated, Summary: "更新角色默认信用模板", Result: constants.AuditResultSuccess,
OperatorID: operatorID, Role: &role, BeforeData: beforeData, AfterData: defaultCreditAuditData(&role),
})
})
if err != nil {
if role.ID != 0 {
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionRoleDefaultCreditUpdated, Summary: "更新角色默认信用模板失败",
Result: defaultCreditAuditResult(err), OperatorID: operatorID, Role: &role, BeforeData: beforeData,
}, err)
}
return nil, err
}
return &role, nil
}
func defaultCreditAuditData(role *model.Role) map[string]any {
return map[string]any{
"role_name": role.RoleName, "role_type": role.RoleType,
"default_credit_enabled": role.DefaultCreditEnabled, "default_credit_limit": role.DefaultCreditLimit,
}
}
func defaultCreditAuditResult(err error) string {
var appErr *errors.AppError
if !stdErrors.As(err, &appErr) {
return constants.AuditResultFailed
}
switch appErr.Code {
case errors.CodeInternalError, errors.CodeDatabaseError, errors.CodeRedisError:
return constants.AuditResultFailed
default:
return constants.AuditResultDenied
}
}
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,323 @@
// Package shop 提供店铺创建与业务员归属的简单写事务脚本。
package shop
import (
"context"
stderrors "errors"
"strings"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"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
audit accessauditapp.Writer
}
// NewCreateService 创建店铺创建事务脚本。
func NewCreateService(db *gorm.DB, audit accessauditapp.Writer) *CreateService {
return &CreateService{db: db, audit: audit}
}
// 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 s.fail(ctx, request, errors.New(errors.CodeForbidden, "无权限设置店铺业务员"))
}
if request.ParentID == nil {
return s.fail(ctx, request, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在"))
}
if err := middleware.CanManageShop(ctx, *request.ParentID); err != nil {
return s.fail(ctx, request, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在"))
}
resolver = resolveInheritedBusinessOwner
default:
return s.fail(ctx, request, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在"))
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(request.InitPassword), bcrypt.DefaultCost)
if err != nil {
return s.fail(ctx, request, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败"))
}
var response *dto.ShopResponse
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
created, createErr := createShop(ctx, tx, request, operatorID, string(hashedPassword), resolver, s.audit)
if createErr != nil {
return createErr
}
response = created
return nil
})
if err != nil {
return s.fail(ctx, request, err)
}
return response, nil
}
type businessOwnerResolver func(*gorm.DB, *dto.CreateShopRequest, *model.Shop) (*uint, error)
func createShop(ctx context.Context, tx *gorm.DB, request *dto.CreateShopRequest, operatorID uint, hashedPassword string, resolveOwner businessOwnerResolver, audit accessauditapp.Writer) (*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
}
if audit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "店铺创建审计接缝未配置")
}
if err := audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopCreated, Summary: "创建店铺",
OperatorID: operatorID, Shop: shop, ParentShop: parent,
AfterData: shopCreationData(shop),
}); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "写入店铺创建审计失败")
}
if ownerID != nil {
if err := audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopBusinessOwnerUpdated, Summary: "设置店铺业务员归属",
OperatorID: operatorID, Shop: shop, ParentShop: parent,
Accounts: businessOwnerAuditAccounts(tx, nil, ownerID),
AfterData: map[string]any{"business_owner_account_id": ownerID},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "店铺业务员归属已设置",
}); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "写入店铺业务员审计失败")
}
}
return response, nil
}
func (s *CreateService) fail(ctx context.Context, request *dto.CreateShopRequest, originalErr error) (*dto.ShopResponse, error) {
shop := &model.Shop{ShopName: request.ShopName, ShopCode: request.ShopCode, ParentID: request.ParentID}
var parent *model.Shop
if request.ParentID != nil {
parent = &model.Shop{}
parent.ID = *request.ParentID
}
accessauditapp.RecordFailure(ctx, s.db, s.audit, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopCreated, Summary: "创建店铺失败", Result: shopAuditFailureResult(originalErr),
OperatorID: middleware.GetUserIDFromContext(ctx), Shop: shop, ParentShop: parent,
}, originalErr)
return nil, originalErr
}
func shopCreationData(shop *model.Shop) map[string]any {
data := shopProfileData(shop)
data["shop_code"] = shop.ShopCode
data["parent_id"] = shop.ParentID
data["level"] = shop.Level
return data
}
func shopProfileData(shop *model.Shop) map[string]any {
return map[string]any{
"shop_name": shop.ShopName, "contact_name": shop.ContactName, "contact_phone": shop.ContactPhone,
"province": shop.Province, "city": shop.City, "district": shop.District, "address": shop.Address,
}
}
func shopProfileChanged(before, after *model.Shop) bool {
return before.ShopName != after.ShopName || before.ContactName != after.ContactName ||
before.ContactPhone != after.ContactPhone || before.Province != after.Province || before.City != after.City ||
before.District != after.District || before.Address != after.Address
}
func shopAuditFailureResult(err error) string {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
switch appErr.Code {
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeInvalidParentID,
errors.CodeShopLevelExceeded, errors.CodeShopCodeExists, errors.CodeUsernameExists, errors.CodePhoneExists:
return constants.AuditResultDenied
}
}
return constants.AuditResultFailed
}
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,
ClientLoginDisabled: shop.ClientLoginDisabled,
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,267 @@
package shop
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"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
audit accessauditapp.Writer
}
// NewUpdateService 创建店铺更新事务脚本。
func NewUpdateService(db *gorm.DB, audit accessauditapp.Writer) *UpdateService {
return &UpdateService{db: db, audit: audit}
}
// 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.UserTypeAgent && request.ClientLoginDisabled != nil && middleware.GetShopIDFromContext(ctx) != shopID {
return nil, errors.New(errors.CodeForbidden, "无权限修改其他店铺的C端登录限制")
}
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
var beforeShop *model.Shop
var parentShop *model.Shop
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, "无权限操作该资源或资源不存在")
}
before := shop
beforeShop = &before
parentShop = loadAuditParentShop(tx, shop.ParentID)
if request.BusinessOwnerAccountIDSet {
ownerID, err := validateUpdatedBusinessOwner(tx, request.BusinessOwnerAccountID)
if err != nil {
return err
}
shop.BusinessOwnerAccountID = ownerID
}
if request.ClientLoginDisabled != nil {
shop.ClientLoginDisabled = *request.ClientLoginDisabled
}
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
}
if shopProfileChanged(&before, &shop) {
if s.audit == nil {
return errors.New(errors.CodeInvalidStatus, "店铺更新审计接缝未配置")
}
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopUpdated, Summary: "更新店铺基础资料",
OperatorID: operatorID, Shop: &shop, ParentShop: parentShop,
BeforeData: shopProfileData(&before), AfterData: shopProfileData(&shop),
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入店铺更新审计失败")
}
}
if err := s.writeStateAudits(ctx, tx, &before, &shop, parentShop, operatorID); err != nil {
return err
}
return nil
})
if err != nil {
if beforeShop != nil && requestedShopProfileChanged(beforeShop, request) {
accessauditapp.RecordFailure(ctx, s.db, s.audit, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopUpdated, Summary: "更新店铺基础资料失败", Result: shopAuditFailureResult(err),
OperatorID: operatorID, Shop: beforeShop, ParentShop: parentShop,
}, err)
}
s.recordStateFailures(ctx, beforeShop, parentShop, request, operatorID, err)
return nil, err
}
return response, nil
}
func (s *UpdateService) writeStateAudits(ctx context.Context, tx *gorm.DB, before, after, parent *model.Shop, operatorID uint) error {
if s.audit == nil && (before.Status != after.Status || !sameOptionalUint(before.BusinessOwnerAccountID, after.BusinessOwnerAccountID) || before.ClientLoginDisabled != after.ClientLoginDisabled) {
return errors.New(errors.CodeInvalidStatus, "店铺状态审计接缝未配置")
}
if before.Status != after.Status {
action, summary, subject := constants.AuditActionShopDisabled, "禁用店铺", "店铺已禁用"
if after.Status == constants.StatusEnabled {
action, summary, subject = constants.AuditActionShopEnabled, "启用店铺", "店铺已启用"
}
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: action, Summary: summary, OperatorID: operatorID, Shop: after, ParentShop: parent,
BeforeData: map[string]any{"status": before.Status}, AfterData: map[string]any{"status": after.Status},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: subject,
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入店铺状态审计失败")
}
}
if !sameOptionalUint(before.BusinessOwnerAccountID, after.BusinessOwnerAccountID) {
accounts := businessOwnerAuditAccounts(tx, before.BusinessOwnerAccountID, after.BusinessOwnerAccountID)
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopBusinessOwnerUpdated, Summary: "更新店铺业务员归属",
OperatorID: operatorID, Shop: after, ParentShop: parent, Accounts: accounts,
BeforeData: map[string]any{"business_owner_account_id": before.BusinessOwnerAccountID},
AfterData: map[string]any{"business_owner_account_id": after.BusinessOwnerAccountID},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "店铺业务员归属已更新",
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入店铺业务员审计失败")
}
}
if before.ClientLoginDisabled != after.ClientLoginDisabled {
subject := "店铺 C 端登录限制已解除"
if after.ClientLoginDisabled {
subject = "店铺 C 端登录已限制"
}
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopClientLoginLimitUpdated, Summary: "更新店铺 C 端登录限制",
OperatorID: operatorID, Shop: after, ParentShop: parent,
BeforeData: map[string]any{"client_login_disabled": before.ClientLoginDisabled},
AfterData: map[string]any{"client_login_disabled": after.ClientLoginDisabled},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: subject,
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入店铺登录限制审计失败")
}
}
return nil
}
func (s *UpdateService) recordStateFailures(ctx context.Context, shop, parent *model.Shop, request *dto.UpdateShopRequest, operatorID uint, originalErr error) {
if shop == nil {
return
}
record := func(action, summary string) {
accessauditapp.RecordFailure(ctx, s.db, s.audit, accessauditapp.ChangeAudit{
ActionCode: action, Summary: summary, Result: shopAuditFailureResult(originalErr),
OperatorID: operatorID, Shop: shop, ParentShop: parent, SubjectVisibility: constants.AuditSubjectInternalOnly,
}, originalErr)
}
if shop.Status != request.Status {
action := constants.AuditActionShopDisabled
if request.Status == constants.StatusEnabled {
action = constants.AuditActionShopEnabled
}
record(action, "更新店铺状态失败")
}
if request.BusinessOwnerAccountIDSet && !sameOptionalUint(shop.BusinessOwnerAccountID, request.BusinessOwnerAccountID) {
record(constants.AuditActionShopBusinessOwnerUpdated, "更新店铺业务员归属失败")
}
if request.ClientLoginDisabled != nil && shop.ClientLoginDisabled != *request.ClientLoginDisabled {
record(constants.AuditActionShopClientLoginLimitUpdated, "更新店铺 C 端登录限制失败")
}
}
func businessOwnerAuditAccounts(tx *gorm.DB, beforeID, afterID *uint) []accessauditapp.AccountChange {
changes := make([]accessauditapp.AccountChange, 0, 2)
if account := loadAuditAccount(tx, beforeID); account != nil {
changes = append(changes, accessauditapp.AccountChange{
Account: account, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleShopPreviousBusinessOwner,
BeforeData: map[string]any{"assigned": true}, AfterData: map[string]any{"assigned": false},
})
}
if account := loadAuditAccount(tx, afterID); account != nil {
changes = append(changes, accessauditapp.AccountChange{
Account: account, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleShopBusinessOwner,
BeforeData: map[string]any{"assigned": false}, AfterData: map[string]any{"assigned": true},
})
}
return changes
}
func loadAuditAccount(tx *gorm.DB, accountID *uint) *model.Account {
if accountID == nil {
return nil
}
var account model.Account
if err := tx.Unscoped().First(&account, *accountID).Error; err != nil {
return nil
}
return &account
}
func sameOptionalUint(left, right *uint) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return *left == *right
}
func requestedShopProfileChanged(shop *model.Shop, request *dto.UpdateShopRequest) bool {
return 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
}
func loadAuditParentShop(tx *gorm.DB, parentID *uint) *model.Shop {
if parentID == nil {
return nil
}
var parent model.Shop
if err := tx.Unscoped().First(&parent, *parentID).Error; err != nil {
return nil
}
return &parent
}
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,207 @@
// Package systemconfig 提供受控系统配置的简单写事务脚本。
package systemconfig
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
configinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// ChangeAudit 是系统配置更新交给统一审计 Port 的事实。
type ChangeAudit struct {
OperatorID uint
OperationType string
Description string
ConfigKey string
Module string
ResourceID *string
DisplayName string
Identity map[string]any
BeforeData map[string]any
AfterData map[string]any
RequestID string
CorrelationID string
Result string
ErrorCode string
ErrorSummary string
}
// AuditWriter 接收系统配置事务内审计事实。
type AuditWriter interface {
WriteConfigChange(ctx context.Context, tx *gorm.DB, audit ChangeAudit) error
}
// UpdateService 执行单 Key 校验、事务更新、审计和提交后缓存失效。
type UpdateService struct {
db *gorm.DB
registry *configinfra.Registry
cache configinfra.Cache
audit AuditWriter
alerts configinfra.AlertSink
now func() time.Time
}
// NewUpdateService 创建系统配置更新事务脚本。
func NewUpdateService(
db *gorm.DB,
registry *configinfra.Registry,
cache configinfra.Cache,
audit AuditWriter,
alerts configinfra.AlertSink,
now func() time.Time,
) *UpdateService {
if now == nil {
now = time.Now
}
return &UpdateService{db: db, registry: registry, cache: cache, audit: audit, alerts: alerts, now: now}
}
// Execute 更新一个已注册且非只读的配置 Key。
func (s *UpdateService) Execute(ctx context.Context, key string, request dto.UpdateSystemConfigRequest) (*dto.SystemConfigItem, error) {
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden)
}
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 || key == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
if s.audit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "系统配置审计接缝未配置")
}
definition, registered := s.registry.Get(key)
if !registered {
return nil, errors.New(errors.CodeInvalidParam, "系统配置 Key 未注册")
}
if definition.Readonly {
s.recordFailure(ctx, ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationSystemConfigUpdate,
Description: "拒绝更新只读系统配置", ConfigKey: key, Module: definition.Module,
Result: constants.AuditResultDenied, ErrorCode: strconv.Itoa(errors.CodeInvalidStatus),
ErrorSummary: "系统配置为只读,更新请求已拒绝",
})
return nil, errors.New(errors.CodeInvalidStatus, "系统配置为只读,不能更新")
}
if err := configinfra.ValidateValue(definition, request.Value); err != nil {
s.recordFailure(ctx, ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationSystemConfigUpdate,
Description: "拒绝非法系统配置值", ConfigKey: key, Module: definition.Module,
Result: constants.AuditResultDenied, ErrorCode: strconv.Itoa(errors.CodeInvalidParam),
ErrorSummary: "系统配置值不符合注册规则",
})
return nil, errors.New(errors.CodeInvalidParam, "系统配置值不符合注册规则")
}
now := s.now().UTC()
var saved model.SystemConfig
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 同一 Key 的首次创建和后续更新都由 PostgreSQL 事务级咨询锁串行裁决。
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", key).Error; err != nil {
return err
}
var existing model.SystemConfig
findErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("config_key = ?", key).First(&existing).Error
beforeValue := definition.DefaultValue
if findErr == nil {
beforeValue = existing.ConfigValue
} else if findErr != gorm.ErrRecordNotFound {
return findErr
}
if findErr == gorm.ErrRecordNotFound {
existing = model.SystemConfig{
ConfigKey: key, ConfigValue: request.Value, ValueType: definition.ValueType,
Module: definition.Module, Description: definition.Description,
IsReadonly: definition.Readonly, IsSensitive: definition.Sensitive,
Creator: operatorID, Updater: operatorID, CreatedAt: now, UpdatedAt: now,
}
if err := tx.Create(&existing).Error; err != nil {
return err
}
} else {
if err := tx.Model(&model.SystemConfig{}).Where("id = ?", existing.ID).Updates(map[string]any{
"config_value": request.Value, "value_type": definition.ValueType,
"module": definition.Module, "description": definition.Description,
"is_readonly": definition.Readonly, "is_sensitive": definition.Sensitive,
"updater": operatorID, "updated_at": now,
}).Error; err != nil {
return err
}
existing.ConfigValue = request.Value
existing.Updater = operatorID
existing.UpdatedAt = now
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
if err := s.audit.WriteConfigChange(ctx, tx, ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationSystemConfigUpdate, Description: "更新受控系统配置",
ConfigKey: key, Module: definition.Module,
BeforeData: auditData(definition, beforeValue), AfterData: auditData(definition, request.Value),
RequestID: requestID, CorrelationID: requestID,
}); err != nil {
return err
}
saved = existing
return nil
})
if err != nil {
s.recordFailure(ctx, ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationSystemConfigUpdate,
Description: "系统配置更新失败", ConfigKey: key, Module: definition.Module,
Result: constants.AuditResultFailed, ErrorCode: strconv.Itoa(errors.CodeDatabaseError),
ErrorSummary: "系统配置更新事务已回滚",
})
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新系统配置失败")
}
s.registry.Remember(key, request.Value)
if s.cache != nil {
if err := s.cache.Delete(ctx, constants.RedisSystemConfigKey(key)); err != nil {
if s.alerts != nil {
s.alerts.Warn(ctx, "SYSTEM_CONFIG_CACHE_INVALIDATE_FAILED", "system_config", key, "系统配置缓存失效失败,数据库事实已提交")
}
}
}
value := saved.ConfigValue
if definition.Sensitive && value != "" {
value = "[已配置]"
}
updatedAt := saved.UpdatedAt
return &dto.SystemConfigItem{
ConfigKey: key, Value: value, ValueType: definition.ValueType, Module: definition.Module,
Description: definition.Description, Readonly: definition.Readonly, Sensitive: definition.Sensitive,
Registered: true, Control: definition.Control, EnumValues: definition.EnumValues,
Min: definition.Min, Max: definition.Max, UpdatedAt: &updatedAt,
}, nil
}
func (s *UpdateService) recordFailure(ctx context.Context, audit ChangeAudit) {
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
audit.RequestID = *value
audit.CorrelationID = *value
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.audit.WriteConfigChange(ctx, tx, audit)
})
if err != nil {
auditfailure.RecordSecondaryWriteFailure(
audit.OperationType, audit.ConfigKey, audit.RequestID, audit.CorrelationID, audit.ErrorCode, err,
)
}
}
func auditData(definition configinfra.Definition, value string) map[string]any {
if definition.Sensitive {
return map[string]any{"credentials_configured": value != ""}
}
return map[string]any{"value": value}
}

View File

@@ -0,0 +1,134 @@
// Package wallet 提供代理主钱包复杂写用例。
package wallet
import (
"context"
stderrors "errors"
"strconv"
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/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
)
// CreditChangeAudit 描述一次代理主钱包实际信用额度变化。
type CreditChangeAudit struct {
Wallet *model.AgentWallet
BeforeData map[string]any
AfterData map[string]any
Result string
ErrorCode string
ErrorSummary string
}
// CreditChangeAuditWriter 在信用额度业务事务内追加统一 Audit Event。
type CreditChangeAuditWriter interface {
WriteAgentWalletCreditChange(context.Context, *gorm.DB, CreditChangeAudit) error
}
// ChangeCreditService 调整既有店铺主钱包实际信用额度。
type ChangeCreditService struct {
db *gorm.DB
audit CreditChangeAuditWriter
}
// NewChangeCreditService 创建实际信用额度调整服务。
func NewChangeCreditService(db *gorm.DB, audit CreditChangeAuditWriter) *ChangeCreditService {
return &ChangeCreditService{db: db, audit: audit}
}
// Execute 使用服务端读取的版本条件更新,不修改余额、冻结金额或钱包流水。
func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled bool, limit int64) (*dto.ShopCreditLimitResponse, error) {
var result *dto.ShopCreditLimitResponse
var stored model.AgentWallet
var beforeData map[string]any
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).First(&stored).Error; err != nil {
return errors.New(errors.CodeWalletNotFound, "店铺主钱包不存在")
}
beforeData = creditAuditData(&stored)
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, stored.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 != stored.Version {
return errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
}
return errors.New(errors.CodeInsufficientQuota, "当前资金占用无法降低或关闭信用额度")
}
available, _ := aggregate.AvailableBalance()
after := stored
after.CreditEnabled = enabled
after.CreditLimit = limit
after.Version++
result = &dto.ShopCreditLimitResponse{ShopID: shopID, WalletID: stored.ID, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: enabled, CreditLimit: limit, AvailableBalance: available, Version: after.Version}
if s.audit == nil {
return errors.New(errors.CodeInternalError, "代理主钱包信用额度审计接缝未配置")
}
return s.audit.WriteAgentWalletCreditChange(ctx, tx, CreditChangeAudit{
Wallet: &after, BeforeData: beforeData, AfterData: creditAuditData(&after), Result: constants.AuditResultSuccess,
})
})
if err != nil && stored.ID != 0 {
s.recordCreditChangeFailure(ctx, &stored, beforeData, err)
}
return result, err
}
func creditAuditData(wallet *model.AgentWallet) map[string]any {
return map[string]any{
"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance,
"credit_enabled": wallet.CreditEnabled, "credit_limit": wallet.CreditLimit, "version": wallet.Version,
}
}
func (s *ChangeCreditService) recordCreditChangeFailure(ctx context.Context, wallet *model.AgentWallet, beforeData map[string]any, originalErr error) {
result, code, summary := creditChangeError(originalErr)
if s.audit == nil || s.db == nil {
recordCreditChangeSecondaryFailure(ctx, wallet.ID, code, errors.New(errors.CodeInvalidStatus, "代理主钱包信用额度审计接缝未配置"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.audit.WriteAgentWalletCreditChange(ctx, tx, CreditChangeAudit{
Wallet: wallet, BeforeData: beforeData, Result: result, ErrorCode: code, ErrorSummary: summary,
})
}); err != nil {
recordCreditChangeSecondaryFailure(ctx, wallet.ID, code, err)
}
}
func recordCreditChangeSecondaryFailure(ctx context.Context, walletID uint, errorCode string, err error) {
linkage := auditcontext.From(ctx)
auditfailure.RecordSecondaryWriteFailure(
constants.AuditActionAgentWalletCreditChanged, strconv.FormatUint(uint64(walletID), 10),
linkage.RequestID, linkage.CorrelationID, errorCode, err,
)
}
func creditChangeError(err error) (string, string, string) {
var appErr *errors.AppError
if !stderrors.As(err, &appErr) {
return constants.AuditResultFailed, strconv.Itoa(errors.CodeInternalError), "更新代理主钱包信用额度失败"
}
result := constants.AuditResultDenied
if appErr.Code == errors.CodeDatabaseError || appErr.Code == errors.CodeInternalError {
result = constants.AuditResultFailed
}
return result, strconv.Itoa(appErr.Code), appErr.Message
}

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,187 @@
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"`
Remark string `json:"remark,omitempty"`
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,
Remark: remark,
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, "代理主钱包入账业务类型无效")
}
if validAdjustment && strings.TrimSpace(command.Remark) == "" {
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

@@ -0,0 +1,372 @@
// Package wecom 提供企业微信配置简单写与连接测试用例。
package wecom
import (
"context"
stdErrors "errors"
"fmt"
"time"
"gorm.io/gorm"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// ApplicationRepository 定义企业微信应用配置持久化边界。
type ApplicationRepository interface {
FindByIdentityForUpdate(ctx context.Context, tx *gorm.DB, corpID string, agentID int64) (*model.WeComApplication, error)
Create(ctx context.Context, tx *gorm.DB, application *model.WeComApplication) error
Update(ctx context.Context, tx *gorm.DB, application *model.WeComApplication) error
List(ctx context.Context, page, pageSize int) ([]model.WeComApplication, int64, error)
GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error)
UpdateDefaultCreator(ctx context.Context, tx *gorm.DB, applicationID uint, userID, name string, operatorID uint, updatedAt time.Time) error
}
// DefaultCreatorMemberFinder 定义默认审批发起人的可见成员查询边界。
type DefaultCreatorMemberFinder interface {
GetVisible(ctx context.Context, applicationID uint, userID string) (*model.WeComMember, error)
}
// AccessTokenProvider 定义按应用取得及失效 access_token 的边界。
type AccessTokenProvider interface {
GetAccessToken(ctx context.Context, applicationID uint) (string, error)
Invalidate(ctx context.Context, applicationID uint)
}
// SensitiveReadAuditWriter 定义明文连接凭据读取的失败关闭审计边界。
type SensitiveReadAuditWriter interface {
WriteSensitiveRead(ctx context.Context, tx *gorm.DB, audit SensitiveReadAudit) error
}
// SensitiveReadAudit 是一次企业微信应用明文凭据读取事实。
type SensitiveReadAudit struct {
OperatorID uint
Applications []SensitiveReadResource
FieldClasses []string
RequestID string
CorrelationID string
}
// SensitiveReadResource 是不含任何明文凭据的读取目标快照。
type SensitiveReadResource struct {
ID uint
CorpID string
AgentID int64
Name string
Status int
CredentialsConfigured bool
}
// ConnectionService 保存应用配置并测试企业微信连接。
type ConnectionService struct {
db *gorm.DB
repo ApplicationRepository
tokens AccessTokenProvider
audit systemconfigapp.AuditWriter
readAudit SensitiveReadAuditWriter
members DefaultCreatorMemberFinder
now func() time.Time
}
// SetSensitiveReadAuditWriter 注入明文凭据读取的失败关闭审计 Writer。
func (s *ConnectionService) SetSensitiveReadAuditWriter(writer SensitiveReadAuditWriter) {
s.readAudit = writer
}
// SetDefaultCreatorMemberFinder 注入默认审批发起人的可见成员查询边界。
func (s *ConnectionService) SetDefaultCreatorMemberFinder(finder DefaultCreatorMemberFinder) {
s.members = finder
}
// NewConnectionService 创建企业微信连接用例。
func NewConnectionService(db *gorm.DB, repo ApplicationRepository, tokens AccessTokenProvider, audit systemconfigapp.AuditWriter) *ConnectionService {
return &ConnectionService{db: db, repo: repo, tokens: tokens, audit: audit, now: time.Now}
}
// Save 创建或更新企业微信应用配置。
func (s *ConnectionService) Save(ctx context.Context, request dto.SaveWeComApplicationRequest) (*dto.WeComApplicationResponse, error) {
if s == nil || s.db == nil || s.repo == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden)
}
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
now := s.now().UTC()
var saved *model.WeComApplication
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", fmt.Sprintf("wecom:%s:%d", request.CorpID, request.AgentID)).Error; err != nil {
return err
}
existing, findErr := s.repo.FindByIdentityForUpdate(ctx, tx, request.CorpID, request.AgentID)
if findErr != nil {
return findErr
}
before := map[string]any{"configured": false}
if existing == nil {
existing = &model.WeComApplication{
Model: gorm.Model{CreatedAt: now},
CorpID: request.CorpID,
AgentID: request.AgentID,
CreatedBy: operatorID,
}
} else {
before = applicationAuditSnapshot(existing)
}
existing.Name = request.Name
existing.Secret = request.Secret
existing.CallbackToken = request.CallbackToken
existing.EncodingAESKey = request.EncodingAESKey
existing.Status = request.Status
existing.UpdatedBy = operatorID
existing.UpdatedAt = now
if existing.ID == 0 {
if err := s.repo.Create(ctx, tx, existing); err != nil {
return err
}
} else if err := s.repo.Update(ctx, tx, existing); err != nil {
return err
}
if s.audit != nil {
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
resourceID := fmt.Sprintf("%d", existing.ID)
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationWeComApplicationSave, Description: "保存企业微信应用安全配置",
ConfigKey: fmt.Sprintf("wecom.application.%d", existing.ID), BeforeData: before,
AfterData: applicationAuditSnapshot(existing), RequestID: requestID, CorrelationID: requestID,
ResourceID: &resourceID, DisplayName: existing.Name, Identity: applicationAuditIdentity(existing),
}); err != nil {
return err
}
}
saved = existing
return nil
})
if err != nil {
recordConfigFailure(ctx, s.db, s.audit, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationWeComApplicationSave,
Description: "保存企业微信应用配置失败", ConfigKey: fmt.Sprintf("wecom.application.%s.%d", request.CorpID, request.AgentID),
DisplayName: request.Name, Identity: map[string]any{
"corp_id": request.CorpID, "agent_id": request.AgentID, "name": request.Name, "status": request.Status,
"credentials_configured": request.Secret != "" && request.CallbackToken != "" && request.EncodingAESKey != "",
}, Result: constants.AuditResultFailed, ErrorCode: fmt.Sprintf("%d", errors.CodeDatabaseError), ErrorSummary: "企业微信应用配置事务已回滚",
})
var appErr *errors.AppError
if stdErrors.As(err, &appErr) {
return nil, appErr
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信应用配置失败")
}
if s.tokens != nil {
s.tokens.Invalidate(ctx, saved.ID)
}
response := toApplicationResponse(*saved)
return &response, nil
}
// List 返回企业微信应用列表,并向超级管理员返回可直接编辑的凭据。
func (s *ConnectionService) List(ctx context.Context, request dto.WeComApplicationListRequest) (*dto.WeComApplicationListResponse, error) {
if s == nil || s.repo == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden)
}
if request.Page <= 0 {
request.Page = constants.DefaultPage
}
if request.PageSize <= 0 {
request.PageSize = constants.DefaultPageSize
}
if request.PageSize > constants.MaxPageSize {
return nil, errors.New(errors.CodeInvalidParam)
}
applications, total, err := s.repo.List(ctx, request.Page, request.PageSize)
if err != nil {
return nil, err
}
if len(applications) > 0 {
if s.db == nil || s.readAudit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "敏感读取审计能力未配置")
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
resources := make([]SensitiveReadResource, 0, len(applications))
for _, application := range applications {
resources = append(resources, SensitiveReadResource{
ID: application.ID, CorpID: application.CorpID, AgentID: application.AgentID,
Name: application.Name, Status: application.Status,
CredentialsConfigured: application.Secret != "" && application.CallbackToken != "" && application.EncodingAESKey != "",
})
}
readAudit := SensitiveReadAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), Applications: resources,
FieldClasses: []string{"secret", "callback_token", "encoding_aes_key"},
RequestID: requestID, CorrelationID: requestID,
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.readAudit.WriteSensitiveRead(ctx, tx, readAudit)
}); err != nil {
return nil, err
}
}
result := make([]dto.WeComApplicationResponse, 0, len(applications))
for _, application := range applications {
result = append(result, toApplicationResponse(application))
}
return &dto.WeComApplicationListResponse{
Items: result, Total: total, Page: request.Page, PageSize: request.PageSize,
}, nil
}
// Test 强制失效旧缓存后取得一次 access_token但绝不向调用方返回 token。
func (s *ConnectionService) Test(ctx context.Context, applicationID uint) error {
if s == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return errors.New(errors.CodeForbidden)
}
if s.tokens == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
}
s.tokens.Invalidate(ctx, applicationID)
_, err := s.tokens.GetAccessToken(ctx, applicationID)
return err
}
// SaveDefaultCreator 从应用当前可见成员中保存代理等账号使用的默认审批发起人。
func (s *ConnectionService) SaveDefaultCreator(ctx context.Context, applicationID uint, request dto.SaveWeComDefaultCreatorRequest) (*dto.WeComApplicationResponse, error) {
if s == nil || s.db == nil || s.repo == nil || s.members == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信默认审批发起人服务未配置")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden)
}
operatorID := middleware.GetUserIDFromContext(ctx)
if applicationID == 0 || operatorID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
application, err := s.repo.GetEnabled(ctx, applicationID)
if err != nil {
return nil, err
}
member, err := s.members.GetVisible(ctx, applicationID, request.UserID)
if err != nil {
recordApplicationFailure(ctx, s.db, s.audit, constants.AuditOperationWeComDefaultCreatorSave, "拒绝保存不可用的企业微信默认审批发起人", application, constants.AuditResultDenied, errors.CodeInvalidParam)
return nil, err
}
now := s.now().UTC()
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
before := applicationAuditSnapshot(application)
if err := s.repo.UpdateDefaultCreator(ctx, tx, applicationID, member.UserID, member.Name, operatorID, now); err != nil {
return err
}
application.DefaultCreatorUserID = member.UserID
application.DefaultCreatorName = member.Name
application.UpdatedBy = operatorID
application.UpdatedAt = now
if s.audit != nil {
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
resourceID := fmt.Sprintf("%d", applicationID)
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationWeComDefaultCreatorSave, Description: "保存企业微信默认审批发起人",
ConfigKey: fmt.Sprintf("wecom.application.%d.default_creator", applicationID), BeforeData: before,
AfterData: applicationAuditSnapshot(application), RequestID: requestID, CorrelationID: requestID,
ResourceID: &resourceID, DisplayName: application.Name, Identity: applicationAuditIdentity(application),
})
}
return nil
})
if err != nil {
recordApplicationFailure(ctx, s.db, s.audit, constants.AuditOperationWeComDefaultCreatorSave, "保存企业微信默认审批发起人失败", application, constants.AuditResultFailed, errors.CodeDatabaseError)
var appErr *errors.AppError
if stdErrors.As(err, &appErr) {
return nil, appErr
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信默认审批发起人失败")
}
response := toApplicationResponse(*application)
return &response, nil
}
func applicationAuditSnapshot(application *model.WeComApplication) map[string]any {
return map[string]any{
"id": application.ID, "corp_id": application.CorpID, "agent_id": application.AgentID,
"name": application.Name, "status": application.Status,
"credentials_configured": application.Secret != "" && application.CallbackToken != "" && application.EncodingAESKey != "",
"default_creator_userid": application.DefaultCreatorUserID,
"default_creator_name": application.DefaultCreatorName,
}
}
func applicationAuditIdentity(application *model.WeComApplication) map[string]any {
if application == nil {
return nil
}
return map[string]any{
"id": application.ID, "corp_id": application.CorpID, "agent_id": application.AgentID,
"name": application.Name, "status": application.Status,
"credentials_configured": application.Secret != "" && application.CallbackToken != "" && application.EncodingAESKey != "",
}
}
func recordApplicationFailure(ctx context.Context, db *gorm.DB, audit systemconfigapp.AuditWriter, operation, description string, application *model.WeComApplication, result string, code int) {
if application == nil {
return
}
resourceID := fmt.Sprintf("%d", application.ID)
recordConfigFailure(ctx, db, audit, systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description,
ConfigKey: fmt.Sprintf("wecom.application.%d", application.ID), ResourceID: &resourceID,
DisplayName: application.Name, Identity: applicationAuditIdentity(application), BeforeData: applicationAuditSnapshot(application),
Result: result, ErrorCode: fmt.Sprintf("%d", code), ErrorSummary: description,
})
}
func recordConfigFailure(ctx context.Context, db *gorm.DB, audit systemconfigapp.AuditWriter, change systemconfigapp.ChangeAudit) {
if db == nil || audit == nil || change.OperatorID == 0 || change.ConfigKey == "" {
return
}
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
change.RequestID = *value
change.CorrelationID = *value
}
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return audit.WriteConfigChange(ctx, tx, change)
}); err != nil {
auditfailure.RecordSecondaryWriteFailure(change.OperationType, change.ConfigKey, change.RequestID, change.CorrelationID, change.ErrorCode, err)
}
}
func toApplicationResponse(application model.WeComApplication) dto.WeComApplicationResponse {
statusName := "禁用"
if application.Status == constants.StatusEnabled {
statusName = "启用"
}
return dto.WeComApplicationResponse{
ID: application.ID, CorpID: application.CorpID, AgentID: application.AgentID, Name: application.Name,
Secret: application.Secret, CallbackToken: application.CallbackToken, EncodingAESKey: application.EncodingAESKey,
DefaultCreatorUserID: application.DefaultCreatorUserID, DefaultCreatorName: application.DefaultCreatorName,
Status: application.Status, StatusName: statusName,
CredentialsSet: application.Secret != "" && application.CallbackToken != "" && application.EncodingAESKey != "",
LastConnectedAt: application.LastConnectedAt, CreatedAt: application.CreatedAt, UpdatedAt: application.UpdatedAt,
}
}

View File

@@ -0,0 +1,169 @@
package wecom
import (
"context"
"fmt"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"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"
)
// DirectoryMember 是企微 Adapter 交给 Application 的最小成员快照。
type DirectoryMember struct {
UserID string
Name string
DepartmentIDs []int64
}
// DirectoryProvider 定义拉取应用可见成员的外部端口。
type DirectoryProvider interface {
ListVisibleMembers(ctx context.Context, applicationID uint) ([]DirectoryMember, error)
}
// MemberRepository 定义可见成员快照同步和分页查询边界。
type MemberRepository interface {
ReplaceVisible(ctx context.Context, tx *gorm.DB, applicationID uint, members []model.WeComMember, syncedAt time.Time) error
ListVisible(ctx context.Context, applicationID uint, page, pageSize int, keyword string) ([]model.WeComMember, int64, error)
}
// DirectoryService 同步并分页查询企业微信应用可见成员。
type DirectoryService struct {
db *gorm.DB
applications interface {
GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error)
}
provider DirectoryProvider
members MemberRepository
audit systemconfigapp.AuditWriter
now func() time.Time
}
// NewDirectoryService 创建企业微信通讯录同步用例。
func NewDirectoryService(db *gorm.DB, applications interface {
GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error)
}, provider DirectoryProvider, members MemberRepository, audit systemconfigapp.AuditWriter) *DirectoryService {
return &DirectoryService{db: db, applications: applications, provider: provider, members: members, audit: audit, now: time.Now}
}
// Sync 拉取并替换指定应用当前可见成员快照。
func (s *DirectoryService) Sync(ctx context.Context, applicationID uint) (*dto.WeComMemberSyncResponse, error) {
if s == nil || s.db == nil || s.applications == nil || s.provider == nil || s.members == nil || s.audit == nil || applicationID == 0 {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置")
}
if !canManageWeComDirectory(ctx) {
return nil, errors.New(errors.CodeForbidden)
}
application, err := s.applications.GetEnabled(ctx, applicationID)
if err != nil {
return nil, err
}
remoteMembers, err := s.provider.ListVisibleMembers(ctx, applicationID)
if err != nil {
s.recordFailure(ctx, application, "同步企业微信应用可见成员失败")
return nil, err
}
syncedAt := s.now().UTC()
members := make([]model.WeComMember, 0, len(remoteMembers))
for _, member := range remoteMembers {
departments, err := sonic.Marshal(member.DepartmentIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化企业微信成员部门失败")
}
members = append(members, model.WeComMember{
ApplicationID: applicationID, CorpID: application.CorpID, UserID: member.UserID,
Name: member.Name, DepartmentIDs: departments, Visible: true, SyncedAt: syncedAt,
CreatedAt: syncedAt, UpdatedAt: syncedAt,
})
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.members.ReplaceVisible(ctx, tx, applicationID, members, syncedAt); err != nil {
return err
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
resourceID := fmt.Sprintf("%d", applicationID)
after := applicationAuditSnapshot(application)
after["synced_count"] = len(members)
after["synced_at"] = syncedAt
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: constants.AuditOperationWeComMembersSync,
Description: "同步企业微信应用可见成员", ConfigKey: fmt.Sprintf("wecom.application.%d.members", applicationID),
ResourceID: &resourceID, DisplayName: application.Name, Identity: applicationAuditIdentity(application),
AfterData: after, RequestID: requestID, CorrelationID: requestID,
})
})
if err != nil {
s.recordFailure(ctx, application, "保存企业微信应用可见成员快照失败")
return nil, err
}
return &dto.WeComMemberSyncResponse{ApplicationID: applicationID, SyncedCount: len(members), SyncedAt: syncedAt}, nil
}
func (s *DirectoryService) recordFailure(ctx context.Context, application *model.WeComApplication, description string) {
if application == nil {
return
}
resourceID := fmt.Sprintf("%d", application.ID)
recordConfigFailure(ctx, s.db, s.audit, systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: constants.AuditOperationWeComMembersSync,
Description: description, ConfigKey: fmt.Sprintf("wecom.application.%d.members", application.ID),
ResourceID: &resourceID, DisplayName: application.Name, Identity: applicationAuditIdentity(application),
BeforeData: applicationAuditSnapshot(application), Result: constants.AuditResultFailed,
ErrorCode: fmt.Sprintf("%d", errors.CodeInternalError), ErrorSummary: description,
})
}
// List 分页返回本地最近一次同步的应用可见成员。
func (s *DirectoryService) List(ctx context.Context, applicationID uint, request dto.WeComMemberListRequest) (*dto.WeComMemberListResponse, error) {
if s == nil || s.applications == nil || s.members == nil || applicationID == 0 {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置")
}
if !canManageWeComDirectory(ctx) {
return nil, errors.New(errors.CodeForbidden)
}
if request.Page <= 0 {
request.Page = constants.DefaultPage
}
if request.PageSize <= 0 {
request.PageSize = constants.DefaultPageSize
}
if request.PageSize > constants.MaxPageSize {
return nil, errors.New(errors.CodeInvalidParam)
}
if _, err := s.applications.GetEnabled(ctx, applicationID); err != nil {
return nil, err
}
members, total, err := s.members.ListVisible(ctx, applicationID, request.Page, request.PageSize, request.Keyword)
if err != nil {
return nil, err
}
items := make([]dto.WeComMemberResponse, 0, len(members))
for _, member := range members {
var departmentIDs []int64
if len(member.DepartmentIDs) > 0 {
if err := sonic.Unmarshal(member.DepartmentIDs, &departmentIDs); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "解析企业微信成员部门失败")
}
}
items = append(items, dto.WeComMemberResponse{
ApplicationID: member.ApplicationID, CorpID: member.CorpID, UserID: member.UserID,
Name: member.Name, DepartmentIDs: departmentIDs, SyncedAt: member.SyncedAt,
})
}
return &dto.WeComMemberListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil
}
func canManageWeComDirectory(ctx context.Context) bool {
userType := middleware.GetUserTypeFromContext(ctx)
return userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform
}

View File

@@ -0,0 +1,408 @@
package wecom
import (
"context"
"crypto/sha256"
"encoding/hex"
stdErrors "errors"
"fmt"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"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"
)
// TemplateControl 是模板详情中可供业务映射的最小控件结构。
type TemplateControl struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Required bool `json:"required"`
OptionKeys []string `json:"option_keys"`
}
// TemplateDefinition 是企微 Adapter 返回的模板最小结构。
type TemplateDefinition struct {
TemplateID string `json:"template_id"`
Name string `json:"name"`
Controls []TemplateControl `json:"controls"`
}
// TemplateProvider 定义审批模板详情外部端口。
type TemplateProvider interface {
GetTemplateDetail(ctx context.Context, applicationID uint, templateID string) (TemplateDefinition, error)
}
// SceneRepository 定义审批场景当前配置持久化边界。
type SceneRepository interface {
FindForUpdate(ctx context.Context, tx *gorm.DB, businessType string) (*model.WeComApprovalScene, error)
Create(ctx context.Context, tx *gorm.DB, scene *model.WeComApprovalScene) error
Update(ctx context.Context, tx *gorm.DB, scene *model.WeComApprovalScene) error
List(ctx context.Context, page, pageSize int) ([]model.WeComApprovalScene, int64, error)
}
// SceneService 保存经企微模板详情校验的业务场景当前映射。
type SceneService struct {
db *gorm.DB
provider TemplateProvider
repo SceneRepository
audit systemconfigapp.AuditWriter
now func() time.Time
}
// NewSceneService 创建企业微信审批场景配置用例。
func NewSceneService(db *gorm.DB, provider TemplateProvider, repo SceneRepository, audit systemconfigapp.AuditWriter) *SceneService {
return &SceneService{db: db, provider: provider, repo: repo, audit: audit, now: time.Now}
}
// InspectTemplate 实时读取企微模板详情,供管理员配置控件映射。
func (s *SceneService) InspectTemplate(ctx context.Context, applicationID uint, request dto.InspectWeComTemplateRequest) (*dto.WeComTemplateDetailResponse, error) {
if s == nil || s.provider == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信模板服务未配置")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden)
}
templateID := strings.TrimSpace(request.TemplateID)
if applicationID == 0 || templateID == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
definition, err := s.provider.GetTemplateDetail(ctx, applicationID, templateID)
if err != nil {
return nil, err
}
controls := make([]dto.WeComTemplateControlResponse, 0, len(definition.Controls))
for _, control := range definition.Controls {
controls = append(controls, dto.WeComTemplateControlResponse{
ID: control.ID, Type: control.Type, Title: control.Title,
Required: control.Required, OptionKeys: control.OptionKeys,
})
}
return &dto.WeComTemplateDetailResponse{
TemplateID: definition.TemplateID, Name: definition.Name, Controls: controls,
}, nil
}
// ListBusinessFields 返回指定审批场景允许映射的业务快照字段。
func (s *SceneService) ListBusinessFields(ctx context.Context, businessType string) (*dto.WeComBusinessFieldListResponse, error) {
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return nil, errors.New(errors.CodeForbidden)
}
businessType = strings.TrimSpace(businessType)
items, ok := sceneBusinessFields(businessType)
if !ok {
return nil, errors.New(errors.CodeInvalidParam, "不支持的审批业务类型")
}
return &dto.WeComBusinessFieldListResponse{
BusinessType: businessType, BusinessTypeName: approvalBusinessTypeName(businessType), Items: items,
}, nil
}
// Save 校验模板控件后创建或替换指定稳定业务场景映射。
func (s *SceneService) Save(ctx context.Context, businessType string, request dto.SaveWeComApprovalSceneRequest) (*dto.WeComApprovalSceneResponse, error) {
if s == nil || s.db == nil || s.provider == nil || s.repo == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden)
}
businessType = strings.TrimSpace(businessType)
if !validApprovalBusinessType(businessType) {
return nil, errors.New(errors.CodeInvalidParam, "不支持的审批业务类型")
}
if request.ApplicationID == 0 || strings.TrimSpace(request.TemplateID) == "" || len(request.ControlMapping) == 0 ||
(request.Status != constants.StatusDisabled && request.Status != constants.StatusEnabled) {
return nil, errors.New(errors.CodeInvalidParam)
}
definition, err := s.provider.GetTemplateDetail(ctx, request.ApplicationID, strings.TrimSpace(request.TemplateID))
if err != nil {
s.recordFailure(ctx, businessType, request, constants.AuditResultFailed, errors.CodeInternalError, "校验企业微信审批模板失败")
return nil, err
}
if err := validateSceneMapping(businessType, request.ControlMapping, definition.Controls); err != nil {
s.recordFailure(ctx, businessType, request, constants.AuditResultDenied, errors.CodeInvalidParam, "拒绝保存非法企业微信审批场景映射")
return nil, err
}
request.ControlMapping = normalizeSceneMapping(request.ControlMapping)
mappingJSON, err := sonic.Marshal(request.ControlMapping)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信控件映射失败")
}
snapshotJSON, err := sonic.Marshal(definition)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信模板快照失败")
}
fingerprintBytes := sha256.Sum256(snapshotJSON)
fingerprint := hex.EncodeToString(fingerprintBytes[:])
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
now := s.now().UTC()
var saved *model.WeComApprovalScene
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", "wecom-scene:"+businessType).Error; err != nil {
return err
}
existing, err := s.repo.FindForUpdate(ctx, tx, businessType)
if err != nil {
return err
}
before := map[string]any{"configured": false, "business_type": businessType}
if existing == nil {
existing = &model.WeComApprovalScene{BusinessType: businessType, CreatedBy: operatorID, CreatedAt: now}
} else {
before = sceneAuditSnapshot(existing)
}
existing.ApplicationID = request.ApplicationID
existing.TemplateID = definition.TemplateID
existing.TemplateName = definition.Name
existing.ControlMapping = mappingJSON
existing.TemplateSnapshot = snapshotJSON
existing.TemplateFingerprint = fingerprint
existing.Status = request.Status
existing.LastVerifiedAt = now
existing.UpdatedBy = operatorID
existing.UpdatedAt = now
if existing.ID == 0 {
if err := s.repo.Create(ctx, tx, existing); err != nil {
return err
}
} else if err := s.repo.Update(ctx, tx, existing); err != nil {
return err
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
resourceID := strings.TrimSpace(existing.BusinessType)
if existing.ID != 0 {
resourceID = fmt.Sprintf("%d", existing.ID)
}
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationWeComApprovalSceneSave, Description: "保存企业微信审批模板映射",
ConfigKey: "wecom.approval_scene." + businessType, BeforeData: before,
AfterData: sceneAuditSnapshot(existing), RequestID: requestID, CorrelationID: requestID,
ResourceID: &resourceID, DisplayName: existing.TemplateName, Identity: sceneAuditIdentity(existing),
}); err != nil {
return err
}
saved = existing
return nil
})
if err != nil {
s.recordFailure(ctx, businessType, request, constants.AuditResultFailed, errors.CodeDatabaseError, "保存企业微信审批场景失败")
var appErr *errors.AppError
if stdErrors.As(err, &appErr) {
return nil, appErr
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信审批场景失败")
}
return sceneResponse(*saved)
}
// List 分页查询企业微信审批场景当前配置。
func (s *SceneService) List(ctx context.Context, request dto.WeComApprovalSceneListRequest) (*dto.WeComApprovalSceneListResponse, error) {
if s == nil || s.repo == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置")
}
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return nil, errors.New(errors.CodeForbidden)
}
if request.Page <= 0 {
request.Page = constants.DefaultPage
}
if request.PageSize <= 0 {
request.PageSize = constants.DefaultPageSize
}
if request.PageSize > constants.MaxPageSize {
return nil, errors.New(errors.CodeInvalidParam)
}
scenes, total, err := s.repo.List(ctx, request.Page, request.PageSize)
if err != nil {
return nil, err
}
items := make([]dto.WeComApprovalSceneResponse, 0, len(scenes))
for _, scene := range scenes {
item, err := sceneResponse(scene)
if err != nil {
return nil, err
}
items = append(items, *item)
}
return &dto.WeComApprovalSceneListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil
}
func validateSceneMapping(businessType string, mapping []dto.WeComControlMappingItem, controls []TemplateControl) error {
controlByID := make(map[string]TemplateControl, len(controls))
for _, control := range controls {
controlByID[control.ID] = control
}
mappedControls := make(map[string]struct{}, len(mapping))
businessFields := make(map[string]struct{}, len(mapping))
for _, item := range mapping {
item.BusinessField = strings.TrimSpace(item.BusinessField)
item.ControlID = strings.TrimSpace(item.ControlID)
if item.BusinessField == "" || item.ControlID == "" {
return errors.New(errors.CodeInvalidParam, "企业微信控件映射字段不能为空")
}
if !allowedSceneBusinessField(businessType, item.BusinessField) {
return errors.New(errors.CodeInvalidParam, "企业微信控件映射包含当前业务不支持的字段")
}
if _, exists := businessFields[item.BusinessField]; exists {
return errors.New(errors.CodeInvalidParam, "企业微信业务字段映射重复")
}
if _, exists := mappedControls[item.ControlID]; exists {
return errors.New(errors.CodeInvalidParam, "企业微信模板控件不能重复映射")
}
control, exists := controlByID[item.ControlID]
if !exists || !strings.EqualFold(control.Type, strings.TrimSpace(item.ControlType)) {
return errors.New(errors.CodeInvalidParam, "企业微信模板控件 ID 或类型已失效")
}
validOptions := make(map[string]struct{}, len(control.OptionKeys))
for _, key := range control.OptionKeys {
validOptions[key] = struct{}{}
}
for _, key := range item.OptionMapping {
if _, exists := validOptions[key]; !exists {
return errors.New(errors.CodeInvalidParam, "企业微信模板选择项 key 已失效")
}
}
businessFields[item.BusinessField] = struct{}{}
mappedControls[item.ControlID] = struct{}{}
}
for _, control := range controls {
if control.Required {
if _, exists := mappedControls[control.ID]; !exists {
return errors.New(errors.CodeInvalidParam, "企业微信模板存在未映射的必填控件: "+control.Title)
}
}
}
return nil
}
func allowedSceneBusinessField(businessType, businessField string) bool {
fields, ok := sceneBusinessFields(businessType)
if !ok {
return false
}
for _, field := range fields {
if field.Code == businessField {
return true
}
}
return false
}
func sceneBusinessFields(businessType string) ([]dto.WeComBusinessFieldResponse, bool) {
switch businessType {
case constants.ApprovalBusinessTypeOfflineRecharge:
return []dto.WeComBusinessFieldResponse{
{Code: constants.ApprovalFieldRechargeNo, Name: "充值单号", ValueType: constants.ApprovalFieldValueTypeString, Description: "员工线下代充值单号"},
{Code: constants.ApprovalFieldShopID, Name: "目标店铺 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本次充值目标店铺的系统 ID"},
{Code: constants.ApprovalFieldShopName, Name: "目标店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次充值目标店铺名称快照"},
{Code: constants.ApprovalFieldAmount, Name: "充值金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的充值金额"},
{Code: constants.ApprovalFieldAmountCent, Name: "充值金额(分)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "以分为单位的充值金额整数"},
{Code: constants.ApprovalFieldPaymentVoucherKey, Name: "付款凭证", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "提交时上传到企微文件控件的付款凭证列表"},
{Code: constants.ApprovalFieldRemark, Name: "备注", ValueType: constants.ApprovalFieldValueTypeString, Description: "员工提交线下代充值时填写的备注"},
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
}, true
case constants.ApprovalBusinessTypeRefund:
return []dto.WeComBusinessFieldResponse{
{Code: constants.ApprovalFieldRefundNo, Name: "退款单号", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次退款申请单号"},
{Code: constants.ApprovalFieldOrderID, Name: "订单 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "退款关联订单的系统 ID"},
{Code: constants.ApprovalFieldOrderNo, Name: "订单号", ValueType: constants.ApprovalFieldValueTypeString, Description: "退款关联订单号"},
{Code: constants.ApprovalFieldAssetIdentifier, Name: "资产标识", ValueType: constants.ApprovalFieldValueTypeString, Description: "退款关联卡或设备的业务标识"},
{Code: constants.ApprovalFieldAssetType, Name: "资产类型", ValueType: constants.ApprovalFieldValueTypeString, Description: "退款关联资产类型编码"},
{Code: constants.ApprovalFieldActualReceivedAmount, Name: "订单实收金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的订单实收金额"},
{Code: constants.ApprovalFieldRequestedRefundAmount, Name: "申请退款金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的本次申请退款金额"},
{Code: constants.ApprovalFieldRefundVoucherKey, Name: "退款凭证", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "提交时上传到企微文件控件的退款凭证列表"},
{Code: constants.ApprovalFieldRefundReason, Name: "退款原因", ValueType: constants.ApprovalFieldValueTypeString, Description: "业务提交人填写的退款原因"},
{Code: constants.ApprovalFieldPackageUsageID, Name: "套餐使用记录 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "退款关联套餐使用记录 ID无关联记录时可能为空"},
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
}, true
default:
return nil, false
}
}
func normalizeSceneMapping(mapping []dto.WeComControlMappingItem) []dto.WeComControlMappingItem {
result := make([]dto.WeComControlMappingItem, 0, len(mapping))
for _, item := range mapping {
item.BusinessField = strings.TrimSpace(item.BusinessField)
item.ControlID = strings.TrimSpace(item.ControlID)
item.ControlType = strings.TrimSpace(item.ControlType)
if item.OptionMapping == nil {
item.OptionMapping = map[string]string{}
}
result = append(result, item)
}
return result
}
func validApprovalBusinessType(businessType string) bool {
return businessType == constants.ApprovalBusinessTypeRefund || businessType == constants.ApprovalBusinessTypeOfflineRecharge
}
func approvalBusinessTypeName(businessType string) string {
if businessType == constants.ApprovalBusinessTypeRefund {
return "退款审批"
}
return "员工线下代充值审批"
}
func sceneAuditSnapshot(scene *model.WeComApprovalScene) map[string]any {
return map[string]any{
"business_type": scene.BusinessType, "application_id": scene.ApplicationID,
"template_id": scene.TemplateID, "status": scene.Status, "last_verified_at": scene.LastVerifiedAt,
"template_fingerprint": scene.TemplateFingerprint,
}
}
func sceneAuditIdentity(scene *model.WeComApprovalScene) map[string]any {
if scene == nil {
return nil
}
return map[string]any{
"id": scene.ID, "business_type": scene.BusinessType, "application_id": scene.ApplicationID,
"template_id": scene.TemplateID, "template_name": scene.TemplateName, "status": scene.Status,
}
}
func (s *SceneService) recordFailure(ctx context.Context, businessType string, request dto.SaveWeComApprovalSceneRequest, result string, code int, description string) {
recordConfigFailure(ctx, s.db, s.audit, systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: constants.AuditOperationWeComApprovalSceneSave,
Description: description, ConfigKey: "wecom.approval_scene." + businessType,
DisplayName: businessType, Identity: map[string]any{
"business_type": businessType, "application_id": request.ApplicationID,
"template_id": strings.TrimSpace(request.TemplateID), "status": request.Status,
},
Result: result, ErrorCode: fmt.Sprintf("%d", code), ErrorSummary: description,
})
}
func sceneResponse(scene model.WeComApprovalScene) (*dto.WeComApprovalSceneResponse, error) {
var mapping []dto.WeComControlMappingItem
if err := sonic.Unmarshal(scene.ControlMapping, &mapping); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "解析企业微信控件映射失败")
}
return &dto.WeComApprovalSceneResponse{
ID: scene.ID, BusinessType: scene.BusinessType, BusinessTypeName: approvalBusinessTypeName(scene.BusinessType),
ApplicationID: scene.ApplicationID, TemplateID: scene.TemplateID, TemplateName: scene.TemplateName,
TemplateFingerprint: scene.TemplateFingerprint,
ControlMapping: mapping, Status: scene.Status, StatusName: constants.GetStatusName(scene.Status),
LastVerifiedAt: scene.LastVerifiedAt, UpdatedAt: scene.UpdatedAt,
}, nil
}

View File

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

View File

@@ -1,7 +1,9 @@
package bootstrap
import (
systemConfigApp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/gateway"
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/service/verification"
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/queue"
@@ -15,14 +17,16 @@ import (
// Dependencies 封装所有基础依赖
// 这些是应用启动时初始化的核心组件
type Dependencies struct {
DB *gorm.DB // PostgreSQL 数据库连接
Redis *redis.Client // Redis 客户端
Logger *zap.Logger // 应用日志器
JWTManager *auth.JWTManager // JWT 管理器(个人客户认证)
TokenManager *auth.TokenManager // Token 管理器后台和H5认证
VerificationService *verification.Service // 验证码服务
QueueClient *queue.Client // Asynq 任务队列客户端
StorageService *storage.Service // 对象存储服务(可选,配置缺失时为 nil
GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil
WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选)
DB *gorm.DB // PostgreSQL 数据库连接
Redis *redis.Client // Redis 客户端
Logger *zap.Logger // 应用日志器
JWTManager *auth.JWTManager // JWT 管理器(个人客户认证)
TokenManager *auth.TokenManager // Token 管理器后台和H5认证
VerificationService *verification.Service // 验证码服务
QueueClient *queue.Client // Asynq 任务队列客户端
StorageService *storage.Service // 对象存储服务(可选,配置缺失时为 nil
GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil
WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选)
SystemConfigRegistry *systemConfigInfra.Registry // 业务模块共享的受控配置注册表(可选)
SystemConfigAudit systemConfigApp.AuditWriter // 配置变更审计 Port生产为空时装配统一 Writer
}

View File

@@ -1,22 +1,48 @@
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"
wecomApp "github.com/break/junhong_cmp_fiber/internal/application/wecom"
"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"
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
openapiHandler "github.com/break/junhong_cmp_fiber/internal/handler/openapi"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
agentRechargeQuery "github.com/break/junhong_cmp_fiber/internal/query/agentrecharge"
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
auditQuery "github.com/break/junhong_cmp_fiber/internal/query/audit"
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
integrationQuery "github.com/break/junhong_cmp_fiber/internal/query/integration"
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"
"github.com/break/junhong_cmp_fiber/internal/service/paymentmethod"
pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling"
rechargeOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge_order"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/go-playground/validator/v10"
)
func initHandlers(svc *services, deps *Dependencies) *Handlers {
validate := validator.New()
packageExpiry := packageExpiryQuery.NewQuery(deps.DB)
svc.Asset.SetPackageExpiryQuery(packageExpiry)
svc.IotCard.SetPackageExpiryQuery(packageExpiry)
svc.Device.SetPackageExpiryQuery(packageExpiry)
assetWalletStore := postgres.NewAssetWalletStore(deps.DB, deps.Redis)
packageStore := postgres.NewPackageStore(deps.DB)
shopPackageAllocationStore := postgres.NewShopPackageAllocationStore(deps.DB)
@@ -48,6 +74,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
deps.QueueClient,
deps.Logger,
)
rechargeOrderService.SetPaymentAudit(svc.AccessAudit)
clientOrderService := clientOrderSvc.New(
svc.Asset,
svc.PurchaseValidation,
@@ -69,25 +96,118 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
deps.Redis,
deps.Logger,
)
systemConfigRegistry := deps.SystemConfigRegistry
if systemConfigRegistry == nil {
systemConfigRegistry = systemConfigInfra.NewRegistry()
}
registerCarrierCallbackConfigDefinitions(systemConfigRegistry, deps.Logger)
registerPaymentMethodConfigDefinitions(systemConfigRegistry, deps.Logger)
systemConfigAlerts := systemConfigInfra.NewLogAlertSink(deps.Logger)
var systemConfigCache systemConfigInfra.Cache
if deps.Redis != nil {
systemConfigCache = systemConfigInfra.NewRedisCache(deps.Redis)
}
systemConfigReader := systemConfigInfra.NewReader(deps.DB, systemConfigRegistry, systemConfigCache, systemConfigAlerts)
paymentMethodPolicy := paymentmethod.NewPolicy(systemConfigReader)
clientOrderService.SetPaymentMethodPolicy(paymentMethodPolicy)
clientOrderService.SetPaymentAudit(svc.AccessAudit, integrationlog.NewRepository(deps.DB))
systemConfigList := systemConfigQuery.NewListQuery(systemConfigReader)
systemConfigAudit := deps.SystemConfigAudit
if systemConfigAudit == nil {
systemConfigAudit = auditInfra.NewWriter(auditInfra.NewRegistry(), nil)
}
systemConfigUpdate := systemConfigApp.NewUpdateService(
deps.DB, systemConfigRegistry, systemConfigCache, systemConfigAudit, systemConfigAlerts, nil,
)
wecomRepository := wecomInfra.NewApplicationRepository(deps.DB)
wecomBaseURL := ""
wecomTimeout := constants.WeComDefaultHTTPTimeout
if cfg := config.Get(); cfg != nil {
wecomBaseURL = cfg.WeCom.BaseURL
wecomTimeout = cfg.WeCom.Timeout
}
wecomTokens := wecomInfra.NewTokenProvider(
wecomRepository, deps.Redis, integrationlog.NewRepository(deps.DB),
wecomBaseURL, wecomTimeout, deps.Logger,
)
wecomConnections := wecomApp.NewConnectionService(
deps.DB, wecomRepository, wecomTokens, systemConfigAudit,
)
if sensitiveReadAudit, ok := systemConfigAudit.(wecomApp.SensitiveReadAuditWriter); ok {
wecomConnections.SetSensitiveReadAuditWriter(sensitiveReadAudit)
}
wecomMembers := wecomInfra.NewMemberRepository(deps.DB)
wecomConnections.SetDefaultCreatorMemberFinder(wecomMembers)
wecomDirectory := wecomApp.NewDirectoryService(
deps.DB,
wecomRepository,
wecomInfra.NewDirectoryClient(wecomTokens, integrationlog.NewRepository(deps.DB), wecomBaseURL, wecomTimeout),
wecomMembers, systemConfigAudit,
)
wecomScenes := wecomApp.NewSceneService(
deps.DB,
wecomInfra.NewTemplateClient(wecomTokens, integrationlog.NewRepository(deps.DB), wecomBaseURL, wecomTimeout),
wecomInfra.NewSceneRepository(deps.DB), systemConfigAudit,
)
wecomApprovalCallback := callback.NewWeComApprovalHandler(wecomInfra.NewCallbackService(
wecomRepository, integrationlog.NewRepository(deps.DB), deps.QueueClient, deps.Logger,
))
svc.Account.SetWeComMemberFinder(wecomMembers)
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),
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, svc.AccessAudit))
return handler
}(),
Permission: admin.NewPermissionHandler(svc.Permission),
PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger),
ClientAuth: app.NewClientAuthHandler(svc.ClientAuth, deps.Logger),
ClientAsset: func() *app.ClientAssetHandler {
handler := app.NewClientAssetHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger)
handler.SetObservationSeriesDispatcher(svc.ObservationSeries)
handler.SetPaymentMethodPolicy(paymentMethodPolicy)
handler.SetForceRechargeChecker(svc.Recharge)
return handler
}(),
ClientWallet: func() *app.ClientWalletHandler {
handler := app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore)
handler.SetPaymentMethodPolicy(paymentMethodPolicy)
handler.SetPaymentAudit(svc.AccessAudit, integrationlog.NewRepository(deps.DB))
return handler
}(),
ClientOrder: app.NewClientOrderHandler(clientOrderService, deps.Logger),
ClientExchange: app.NewClientExchangeHandler(svc.Exchange),
ClientRealname: func() *app.ClientRealnameHandler {
handler := app.NewClientRealnameHandler(svc.Asset, svc.CustomerBinding, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger, svc.PollingManualTrigger)
handler.SetObservationSeriesDispatcher(svc.ObservationSeries)
return handler
}(),
ClientDevice: func() *app.ClientDeviceHandler {
handler := app.NewClientDeviceHandler(svc.Asset, svc.CustomerBinding, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger)
handler.SetDeviceService(svc.Device)
return handler
}(),
ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger),
ClientNotification: app.NewClientNotificationHandler(notificationQuery.NewQuery(deps.DB),
notificationApp.NewReadService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)), validate),
Shop: func() *admin.ShopHandler {
handler := admin.NewShopHandler(svc.Shop, validate)
handler.SetCreateService(shopApp.NewCreateService(deps.DB, svc.AccessAudit))
handler.SetUpdateService(shopApp.NewUpdateService(deps.DB, svc.AccessAudit))
handler.SetBusinessOwnerQuery(shopQuery.NewBusinessOwnerQuery(deps.DB))
handler.SetChangeCreditService(walletApp.NewChangeCreditService(deps.DB, svc.AccessAudit))
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),
@@ -97,38 +217,65 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
IotCard: admin.NewIotCardHandler(svc.IotCard),
IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport),
ExportTask: admin.NewExportTaskHandler(svc.ExportTask),
Device: admin.NewDeviceHandler(svc.Device),
DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport),
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),
Storage: admin.NewStorageHandler(deps.StorageService),
Carrier: admin.NewCarrierHandler(svc.Carrier),
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
Package: admin.NewPackageHandler(svc.Package),
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
AdminExchange: admin.NewExchangeHandler(svc.Exchange, validate),
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, rechargeOrderService, svc.AgentRecharge, deps.WechatPayment, svc.WechatConfig, paymentStore, deps.Logger),
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring),
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
Notification: admin.NewNotificationHandler(notificationQuery.NewQuery(deps.DB),
notificationApp.NewReadService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)), validate),
Device: admin.NewDeviceHandler(svc.Device),
DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport),
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),
Storage: admin.NewStorageHandler(deps.StorageService),
Carrier: admin.NewCarrierHandler(svc.Carrier),
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
Package: admin.NewPackageHandler(svc.Package),
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
AdminExchange: admin.NewExchangeHandler(svc.Exchange, exchangeQuery.NewListQuery(deps.DB), validate),
PaymentCallback: callback.NewPaymentHandler(
svc.Order, svc.Recharge, rechargeOrderService, svc.AgentRecharge,
deps.WechatPayment, svc.WechatConfig, paymentStore,
svc.AgentRechargePaymentConfirm, integrationlog.NewRepository(deps.DB), deps.Logger,
),
CTCCRealnameCallback: callback.NewCTCCRealnameHandler(
carriercallback.NewCTCCRealnameTranslator(), carriercallback.NewCTCCCardResolver(deps.DB),
integrationlog.NewRepository(deps.DB), svc.CardObservation, svc.CardObservationSeries, systemConfigReader, deps.Logger,
),
CMCCRealnameCallback: callback.NewCMCCRealnameHandler(
carriercallback.NewCMCCRealnameTranslator(), carriercallback.NewCMCCCardResolver(deps.DB),
integrationlog.NewRepository(deps.DB), svc.CardObservation, svc.CardObservationSeries, systemConfigReader, deps.Logger,
),
CUCCRealnameCallback: callback.NewCUCCRealnameHandler(
carriercallback.NewCUCCRealnameTranslator(), carriercallback.NewCUCCCardResolver(deps.DB),
integrationlog.NewRepository(deps.DB), svc.CardObservation, svc.CardObservationSeries, systemConfigReader, deps.Logger,
),
CUCCRealnameRemovalCallback: callback.NewCUCCRealnameRemovalHandler(
carriercallback.NewCUCCRealnameRemovalTranslator(), carriercallback.NewCUCCCardResolver(deps.DB),
integrationlog.NewRepository(deps.DB), systemConfigReader, deps.Logger,
),
WeComApprovalCallback: wecomApprovalCallback,
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring),
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
Asset: func() *admin.AssetHandler {
pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger)
assetPollingSvc := pollingSvcPkg.NewAssetPollingService(
deps.DB,
deviceStore,
deviceSimBindingStore,
svc.IotCard,
pollingQueueMgr,
deps.Logger,
svc.AssetAudit,
svc.AccessAudit,
)
h := admin.NewAssetHandler(svc.Asset, svc.AssetAudit, svc.Device, svc.IotCard, svc.StopResumeService, assetPollingSvc)
h := admin.NewAssetHandler(svc.Asset, svc.AssetAudit, svc.Device, svc.IotCard, svc.StopResumeService, assetPollingSvc, assetQuery.NewExchangeTraceQuery(deps.DB, deps.Logger))
h.SetLifecycleService(svc.AssetLifecycle)
h.SetObservationSeriesDispatcher(svc.ObservationSeries)
h.SetPackageExpiryQuery(packageExpiry)
h.SetPackageExpiryQueue(deps.QueueClient)
return h
}(),
AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle),
@@ -137,12 +284,26 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
h.SetAssetService(svc.Asset)
return h
}(),
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge, validate),
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
AgentRecharge: func() *admin.AgentRechargeHandler {
handler := admin.NewAgentRechargeHandler(svc.AgentRecharge, validate)
handler.SetOnlineCreationService(svc.AgentRechargeOnline)
handler.SetPaymentStatusQuery(agentRechargeQuery.NewPaymentStatusQuery(deps.DB))
return handler
}(),
Refund: admin.NewRefundHandler(svc.Refund),
OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(svc.OrderPackageInvalidate),
ClientWechat: app.NewClientWechatHandler(svc.WechatConfig, deps.Redis, deps.Logger),
SuperAdmin: admin.NewSuperAdminHandler(svc.OperationPassword),
AgentOpenAPI: openapiHandler.NewHandler(svc.AgentOpenAPI, validate),
AssetPackageBatchOrder: admin.NewAssetPackageBatchOrderHandler(svc.AssetPackageBatchOrder, validate),
ClientWechat: app.NewClientWechatHandler(svc.WechatConfig, deps.Redis, deps.Logger),
SuperAdmin: admin.NewSuperAdminHandler(svc.OperationPassword),
SystemConfig: admin.NewSystemConfigHandler(systemConfigList, systemConfigUpdate),
Audit: admin.NewAuditHandler(auditQuery.New(deps.DB), integrationQuery.New(deps.DB)),
WeCom: func() *admin.WeComHandler {
handler := admin.NewWeComHandler(wecomConnections, validate)
handler.SetDirectoryService(wecomDirectory)
handler.SetSceneService(wecomScenes)
return handler
}(),
AgentOpenAPI: openapiHandler.NewHandler(svc.AgentOpenAPI, validate),
}
}

View File

@@ -0,0 +1,36 @@
package bootstrap
import (
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/service/paymentmethod"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
func registerPaymentMethodConfigDefinitions(registry *systemconfig.Registry, logger *zap.Logger) {
if registry == nil {
return
}
definitions := []systemconfig.Definition{
{Key: constants.SystemConfigPaymentAllowedCard, Module: constants.SystemConfigModulePayment, ValueType: constants.SystemConfigTypeJSON, DefaultValue: `["wallet","wechat","alipay"]`, Description: "卡资产允许的C端支付方式", Control: "payment_methods", Validator: paymentmethod.ValidateConfigValue},
{Key: constants.SystemConfigPaymentAllowedDevice, Module: constants.SystemConfigModulePayment, ValueType: constants.SystemConfigTypeJSON, DefaultValue: `["wallet","wechat","alipay"]`, Description: "设备资产允许的C端支付方式", Control: "payment_methods", Validator: paymentmethod.ValidateConfigValue},
}
for _, definition := range definitions {
if existing, exists := registry.Get(definition.Key); exists {
if existing.ValueType != definition.ValueType || existing.Module != definition.Module {
logPaymentMethodConfigRegistrationError(logger, definition.Key, "配置 Key 已被其他类型或模块注册")
}
continue
}
if err := registry.Register(definition); err != nil {
logPaymentMethodConfigRegistrationError(logger, definition.Key, err.Error())
}
}
}
func logPaymentMethodConfigRegistrationError(logger *zap.Logger, key, reason string) {
if logger != nil {
logger.Error("注册支付方式系统配置失败C端支付将失败关闭", zap.String("config_key", key), zap.String("reason", reason))
}
}

View File

@@ -5,25 +5,43 @@ import (
"go.uber.org/zap"
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
exchangeApp "github.com/break/junhong_cmp_fiber/internal/application/exchange"
refundapprovalApp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
exchangeInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/exchange"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
paymentInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/payment"
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
"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"
agentOpenAPISvc "github.com/break/junhong_cmp_fiber/internal/service/agent_open_api"
assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth"
carrierSvc "github.com/break/junhong_cmp_fiber/internal/service/carrier"
clientAuthSvc "github.com/break/junhong_cmp_fiber/internal/service/client_auth"
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
commissionCalculationSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
commissionStatsSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting"
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/payment"
"github.com/break/junhong_cmp_fiber/pkg/queue"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
assetPackageBatchOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_package_batch_order"
assetWalletSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_wallet"
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
deviceImportSvc "github.com/break/junhong_cmp_fiber/internal/service/device_import"
@@ -58,8 +76,9 @@ import (
)
type services struct {
AccessAudit *auditInfra.Writer
Approval *approvalApp.CreationService
Account *accountSvc.Service
AccountAudit *accountAuditSvc.Service
AssetAudit *assetAuditSvc.Service
Role *roleSvc.Service
Permission *permissionSvc.Service
@@ -106,6 +125,8 @@ type services struct {
StopResumeService *iotCardSvc.StopResumeService
WechatConfig *wechatConfigSvc.Service
AgentRecharge *agentRechargeSvc.Service
AgentRechargeOnline *agentrechargeApp.OnlineCreationService
AgentRechargePaymentConfirm *agentrechargeApp.ConfirmOnlinePaymentService
PackageActivation *packageSvc.ActivationService
Refund *refundSvc.Service
TrafficQuery *trafficSvc.QueryService
@@ -113,6 +134,10 @@ type services struct {
AgentOpenAPI *agentOpenAPISvc.Service
CustomerBinding *customerBindingSvc.Service
OrderPackageInvalidate *orderPackageInvalidateSvc.Service
AssetPackageBatchOrder *assetPackageBatchOrderSvc.Service
ObservationSeries cardObservationApp.BestEffortSeriesDispatcher
CardObservation *cardObservationApp.Service
CardObservationSeries *cardObservationApp.SeriesAttemptService
}
func initServices(s *stores, deps *Dependencies) *services {
@@ -120,9 +145,15 @@ func initServices(s *stores, deps *Dependencies) *services {
customerBinding := customerBindingSvc.New(deps.DB, s.IotCard, s.Device)
purchaseValidation := purchaseValidationSvc.New(deps.DB, s.IotCard, s.Device, s.Package, s.ShopPackageAllocation)
accountAudit := accountAuditSvc.NewService(s.AccountOperationLog)
assetAudit := assetAuditSvc.NewService(s.AssetOperationLog, deps.DB)
account := accountSvc.New(s.Account, s.Role, s.AccountRole, s.ShopRole, s.Shop, s.Enterprise, accountAudit)
auditWriter := auditInfra.NewWriter(auditInfra.NewRegistry(), nil)
customerBinding.SetAccessAudit(auditWriter)
account := accountSvc.New(s.Account, s.Role, s.AccountRole, s.ShopRole, s.Shop, s.Enterprise)
account.SetLifecycleAudit(deps.DB, auditWriter)
account.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
account.SetTokenManager(deps.TokenManager)
authService := authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger)
authService.SetSecurityAudit(deps.DB, auditWriter)
// 创建 IotCard service 并设置回调
iotCard := iotCardSvc.New(
@@ -135,8 +166,32 @@ func initServices(s *stores, deps *Dependencies) *services {
s.PackageSeries,
deps.GatewayClient,
deps.Logger,
assetAudit,
)
iotCard.SetAccessAudit(auditWriter)
cardObservationOutbox := outbox.NewRepository()
observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox)
cardObservationService := cardObservationApp.NewService(
deps.DB,
cardObservationInfra.NewEventWriter(cardObservationOutbox),
cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger),
)
cardObservationService.SetStateAuditWriter(iotCard)
iotCard.SetCardObservationService(cardObservationService)
iotCard.SetSpeedTierIntegrationLog(integrationlog.NewRepository(deps.DB))
seriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
seriesIntegration := integrationlog.NewRepository(deps.DB)
seriesTrigger := cardObservationApp.NewSeriesTrigger(
seriesCoordinator,
queue.NewCardObservationSeriesScheduler(deps.QueueClient),
cardObservationInfra.NewSeriesAttemptLogger(seriesIntegration),
)
cardObservationSeries := cardObservationApp.NewSeriesAttemptService(
seriesCoordinator,
cardObservationInfra.NewSeriesRunner(deps.DB, deps.GatewayClient, cardObservationService, seriesIntegration, auditWriter),
cardObservationInfra.NewSeriesAttemptLogger(seriesIntegration),
)
observationSeries := cardObservationInfra.NewBestEffortSeriesDispatcher(seriesTrigger, deps.Logger, s.DeviceSimBinding, s.Carrier)
iotCard.SetObservationSeriesDispatcher(observationSeries)
// 使用 PollingLifecycleService 替代 APICallback通过分片队列准确操作修复 api_callback.go 遗漏 protect 队列的 Bug3
pollingConfigStore := postgres.NewPollingConfigStore(deps.DB)
pollingConfigMgr := polling.NewPollingConfigManager(pollingConfigStore, deps.Redis, deps.Logger)
@@ -147,12 +202,8 @@ 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)
wechatConfig := wechatConfigSvc.New(s.WechatConfig, s.Order, s.RechargeOrder, s.AgentRecharge, s.Payment, auditWriter, deps.Redis, deps.Logger)
// 创建支付配置动态加载器Order 和 Recharge 依赖)
paymentLoader := payment.NewPaymentConfigLoader(s.WechatConfig, deps.Redis, deps.Logger)
@@ -165,6 +216,8 @@ func initServices(s *stores, deps *Dependencies) *services {
s.PackageUsageDailyRecord,
deps.Logger,
)
packageActivation.SetLifecycleAudit(auditWriter)
packageActivation.SetObservationSeriesEventWriter(observationSeriesEvents)
stopResumeService := iotCardSvc.NewStopResumeService(
deps.Redis,
@@ -173,9 +226,10 @@ func initServices(s *stores, deps *Dependencies) *services {
s.DeviceSimBinding,
deps.GatewayClient,
deps.Logger,
assetAudit,
)
stopResumeService.SetPollingCallback(pollingLifecycleSvc)
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
iotCard.SetRealnameActivator(packageActivation)
iotCard.SetStopResumeService(stopResumeService)
iotCard.SetDeviceSimBindingStore(s.DeviceSimBinding)
@@ -195,24 +249,152 @@ func initServices(s *stores, deps *Dependencies) *services {
s.PackageSeries,
deps.GatewayClient,
s.AssetIdentifier,
assetAudit,
s.EnterpriseDeviceAuthorization,
s.Enterprise,
)
device.SetAccessAudit(auditWriter)
device.SetGatewayIntegrationLog(integrationlog.NewRepository(deps.DB))
device.SetObservationSeriesEventWriter(observationSeriesEvents)
device.SetObservationSeriesDispatcher(observationSeries)
operationPassword := operationPasswordSvc.New(deps.Redis)
shopCommission := shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction, deps.DB, deps.Logger)
shopCommission.SetAuditWriter(auditWriter)
packageService := packageSvc.New(s.Package, s.PackageSeries, s.ShopPackageAllocation, s.ShopSeriesAllocation)
packageService.SetAccessAudit(deps.DB, auditWriter)
packageSeriesService := packageSeriesSvc.New(s.PackageSeries, s.ShopSeriesAllocation, s.Package)
packageSeriesService.SetAccessAudit(deps.DB, auditWriter)
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)
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)
orderService.SetLifecycleAudit(auditWriter)
orderService.SetPaymentIntegrationLog(integrationlog.NewRepository(deps.DB))
orderService.SetObservationSeriesEventWriter(observationSeriesEvents)
walletOutbox := outbox.NewRepository()
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox, auditWriter)
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox, auditWriter), walletDebitEvents, nil))
agentRechargeService := agentRechargeSvc.New(
deps.DB,
s.AgentRecharge,
s.AgentWallet,
s.Shop,
wechatConfig,
operationPassword,
deps.Redis,
deps.Logger,
)
agentWalletPosting := walletapp.NewPostingService(walletinfra.NewCreditEventWriter(walletOutbox, auditWriter), nil)
agentRechargeService.SetAgentWalletPostingService(agentWalletPosting)
paymentIntegration := integrationlog.NewRepository(deps.DB)
agentRechargeOnline := agentrechargeApp.NewOnlineCreationService(
deps.DB,
paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(deps.Redis), paymentIntegration, deps.Logger),
paymentInfra.NewAlipayWapAdapter(paymentIntegration, deps.Logger),
auditWriter,
)
agentRechargePaymentConfirm := agentrechargeApp.NewConfirmOnlinePaymentService(
deps.DB,
paymentInfra.NewAgentRechargePaymentEventWriter(outbox.NewRepository()),
auditWriter,
)
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))
refundService.SetNotificationOutbox(walletOutbox)
refundService.SetLifecycleAudit(auditWriter)
exchangeService := exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger)
exchangeService.SetShippingCreatedNotifier(exchangeApp.NewShippingCreatedNotifier(exchangeInfra.NewShippingNotificationWriter(outbox.NewRepository())))
exchangeService.SetAccessAudit(auditWriter)
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)
assetService.SetAccessAudit(auditWriter)
agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device)
agentOpenAPI.SetObservationSeriesDispatcher(observationSeries)
wecomApplicationRepository := wecomInfra.NewApplicationRepository(deps.DB)
wecomSceneRepository := wecomInfra.NewSceneRepository(deps.DB)
wecomMemberRepository := wecomInfra.NewMemberRepository(deps.DB)
wecomBaseURL := ""
wecomTimeout := constants.WeComDefaultHTTPTimeout
if cfg := config.Get(); cfg != nil {
wecomBaseURL = cfg.WeCom.BaseURL
wecomTimeout = cfg.WeCom.Timeout
}
wecomIntegrationRepository := integrationlog.NewRepository(deps.DB)
wecomTokenProvider := wecomInfra.NewTokenProvider(
wecomApplicationRepository, deps.Redis, wecomIntegrationRepository,
wecomBaseURL, wecomTimeout, deps.Logger,
)
approvalCreationService := approvalApp.NewCreationService(
wecomInfra.NewApprovalProvider(
deps.DB, wecomSceneRepository, wecomApplicationRepository, wecomMemberRepository,
wecomInfra.NewTemplateClient(wecomTokenProvider, wecomIntegrationRepository, wecomBaseURL, wecomTimeout),
),
approvalInfra.NewRepositoryProvider(),
approvalInfra.NewSubmissionEventWriter(outbox.NewRepository()),
nil,
)
approvalCreationService.SetAuditWriter(auditWriter)
agentRechargeService.SetOfflineCreationService(
agentrechargeApp.NewOfflineCreationService(deps.DB, approvalCreationService, auditWriter),
)
agentRechargeService.SetRechargeAudit(auditWriter)
refundService.SetRefundApprovalCreationService(
refundapprovalApp.NewCreationService(deps.DB, approvalCreationService, auditWriter),
)
roleService := roleSvc.New(s.Role, s.Permission, s.RolePermission, s.AccountRole, s.ShopRole)
roleService.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
permissionService := permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis)
permissionService.SetAccessAudit(deps.DB, auditWriter)
shopService := shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role)
shopService.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
commissionWithdrawal := commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest)
commissionWithdrawal.SetAuditWriter(auditWriter)
commissionCalculation := commissionCalculationSvc.New(
deps.DB,
s.CommissionRecord,
s.Shop,
s.ShopPackageAllocation,
s.ShopSeriesAllocation,
s.PackageSeries,
s.IotCard,
s.Device,
s.AgentWallet,
s.AgentWalletTransaction,
s.Order,
s.OrderItem,
s.Package,
s.ShopSeriesCommissionStats,
commissionStatsSvc.New(s.ShopSeriesCommissionStats),
deps.Logger,
)
commissionCalculation.SetAuditWriter(auditWriter)
pollingConfigService := pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger)
pollingConfigService.SetAudit(deps.DB, auditWriter)
pollingConcurrencyService := pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis)
pollingConcurrencyService.SetAudit(deps.DB, auditWriter)
pollingAlertService := pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger)
pollingAlertService.SetAudit(deps.DB, auditWriter)
pollingManualTriggerService := pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger)
pollingManualTriggerService.SetAudit(deps.DB, auditWriter)
return &services{
AccessAudit: auditWriter,
Approval: approvalCreationService,
Account: account,
AccountAudit: accountAudit,
AssetAudit: assetAudit,
Role: roleSvc.New(s.Role, s.Permission, s.RolePermission, s.AccountRole, s.ShopRole),
Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis),
PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.Logger),
Role: roleService,
Permission: permissionService,
PersonalCustomer: personalCustomerSvc.NewService(deps.DB, s.PersonalCustomer, s.PersonalCustomerPhone, deps.Logger, auditWriter),
ClientAuth: clientAuthSvc.New(
deps.DB,
s.PersonalCustomerOpenID,
@@ -226,96 +408,61 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.Redis,
deps.Logger,
customerBinding,
auditWriter,
),
Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role, s.AccountRole, s.AgentWallet),
Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger),
Shop: shopService,
Auth: authService,
ShopCommission: shopCommission,
CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest),
CommissionWithdrawal: commissionWithdrawal,
CommissionWithdrawalSetting: commissionWithdrawalSettingSvc.New(deps.DB, s.Account, s.CommissionWithdrawalSetting),
CommissionCalculation: commissionCalculationSvc.New(
deps.DB,
s.CommissionRecord,
s.Shop,
s.ShopPackageAllocation,
s.ShopSeriesAllocation,
s.PackageSeries,
s.IotCard,
s.Device,
s.AgentWallet,
s.AgentWalletTransaction,
s.Order,
s.OrderItem,
s.Package,
s.ShopSeriesCommissionStats,
commissionStatsSvc.New(s.ShopSeriesCommissionStats),
deps.Logger,
),
Enterprise: enterpriseSvc.New(deps.DB, s.Enterprise, s.Shop, s.Account),
EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization, s.IotCard),
EnterpriseDevice: enterpriseDeviceSvc.New(deps.DB, s.Enterprise, s.Device, s.DeviceSimBinding, s.EnterpriseDeviceAuthorization, s.EnterpriseCardAuthorization, deps.Logger),
Authorization: enterpriseCardSvc.NewAuthorizationService(s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger),
IotCard: iotCard,
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient, assetAudit),
ExportTask: exportTaskSvc.New(deps.DB, s.ExportTask, deps.QueueClient, deps.StorageService),
Device: device,
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient, assetAudit),
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
Carrier: carrierSvc.New(s.Carrier),
PackageSeries: packageSeriesSvc.New(s.PackageSeries, s.ShopSeriesAllocation, s.Package),
Package: packageService,
PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger),
PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, deps.Logger),
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop),
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop),
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger),
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
PurchaseValidation: purchaseValidation,
Order: orderService,
Exchange: exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger),
Recharge: rechargeSvc.New(deps.DB, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, paymentLoader, deps.Logger),
PollingConfig: pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger),
PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis),
PollingMonitoring: pollingSvc.NewMonitoringServiceWithQueueMgr(deps.Redis, pollingQueueMgr, deps.Logger),
PollingAlert: pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger),
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
PollingManualTrigger: pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger),
Asset: assetService,
AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device, assetAudit),
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),
CommissionCalculation: commissionCalculation,
Enterprise: enterpriseSvc.New(deps.DB, s.Enterprise, s.Shop, s.Account, auditWriter),
EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization, s.IotCard, auditWriter),
EnterpriseDevice: enterpriseDeviceSvc.New(deps.DB, s.Enterprise, s.Device, s.DeviceSimBinding, s.EnterpriseDeviceAuthorization, s.EnterpriseCardAuthorization, deps.Logger, auditWriter),
Authorization: enterpriseCardSvc.NewAuthorizationService(deps.DB, s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger, auditWriter),
IotCard: iotCard,
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient, auditWriter),
ExportTask: exportTaskSvc.New(deps.DB, s.ExportTask, deps.QueueClient, deps.StorageService, auditWriter),
Device: device,
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient, auditWriter),
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
Carrier: carrierSvc.New(s.Carrier, auditWriter),
PackageSeries: packageSeriesService,
Package: packageService,
PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger),
PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, deps.Logger),
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop, auditWriter),
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, auditWriter),
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger, auditWriter),
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
PurchaseValidation: purchaseValidation,
Order: orderService,
Exchange: exchangeService,
Recharge: rechargeSvc.New(deps.DB, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, paymentLoader, deps.Logger),
PollingConfig: pollingConfigService,
PollingConcurrency: pollingConcurrencyService,
PollingMonitoring: pollingSvc.NewMonitoringServiceWithQueueMgr(deps.Redis, pollingQueueMgr, deps.Logger),
PollingAlert: pollingAlertService,
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
PollingManualTrigger: pollingManualTriggerService,
Asset: assetService,
AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device, auditWriter),
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),
StopResumeService: stopResumeService,
WechatConfig: wechatConfig,
AgentRecharge: agentRechargeService,
AgentRechargeOnline: agentRechargeOnline,
AgentRechargePaymentConfirm: agentRechargePaymentConfirm,
PackageActivation: packageActivation,
TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage),
OperationPassword: operationPassword,
AgentOpenAPI: agentOpenAPI,
Refund: refundService,
CustomerBinding: customerBinding,
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient, auditWriter),
AssetPackageBatchOrder: assetPackageBatchOrderSvc.New(s.AssetPackageBatchOrderTask, s.Package, deps.QueueClient, auditWriter),
ObservationSeries: observationSeries,
CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeries,
}
}

View File

@@ -6,7 +6,6 @@ import (
type stores struct {
Account *postgres.AccountStore
AccountOperationLog *postgres.AccountOperationLogStore
AssetOperationLog *postgres.AssetOperationLogStore
Shop *postgres.ShopStore
Role *postgres.RoleStore
@@ -68,6 +67,8 @@ type stores struct {
RefundRequest *postgres.RefundStore
// 订单套餐失效任务
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
// 资产套餐批量订购任务
AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore
// 流量系统
CardDailyUsage *postgres.CardDailyUsageStore
// 资产标识符注册表
@@ -77,7 +78,6 @@ type stores struct {
func initStores(deps *Dependencies) *stores {
return &stores{
Account: postgres.NewAccountStore(deps.DB, deps.Redis),
AccountOperationLog: postgres.NewAccountOperationLogStore(deps.DB),
AssetOperationLog: postgres.NewAssetOperationLogStore(deps.DB),
Shop: postgres.NewShopStore(deps.DB, deps.Redis),
Role: postgres.NewRoleStore(deps.DB),
@@ -128,14 +128,15 @@ func initStores(deps *Dependencies) *stores {
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
AgentRecharge: postgres.NewAgentRechargeStore(deps.DB, deps.Redis),
// 资产钱包系统
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
AssetWalletTransaction: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis),
RechargeOrder: postgres.NewRechargeOrderStore(deps.DB, deps.Redis),
Payment: postgres.NewPaymentStore(deps.DB, deps.Redis),
WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis),
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
AssetWalletTransaction: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis),
RechargeOrder: postgres.NewRechargeOrderStore(deps.DB, deps.Redis),
Payment: postgres.NewPaymentStore(deps.DB, deps.Redis),
WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis),
RefundRequest: postgres.NewRefundStore(deps.DB),
CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB),
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB),
}
}

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
@@ -51,6 +53,11 @@ type Handlers struct {
AdminOrder *admin.OrderHandler
AdminExchange *admin.ExchangeHandler
PaymentCallback *callback.PaymentHandler
CTCCRealnameCallback *callback.CTCCRealnameHandler
CMCCRealnameCallback *callback.CMCCRealnameHandler
CUCCRealnameCallback *callback.CUCCRealnameHandler
CUCCRealnameRemovalCallback *callback.CUCCRealnameRemovalHandler
WeComApprovalCallback *callback.WeComApprovalHandler
PollingConfig *admin.PollingConfigHandler
PollingConcurrency *admin.PollingConcurrencyHandler
PollingMonitoring *admin.PollingMonitoringHandler
@@ -64,8 +71,12 @@ type Handlers struct {
AgentRecharge *admin.AgentRechargeHandler
Refund *admin.RefundHandler
OrderPackageInvalidate *admin.OrderPackageInvalidateHandler
AssetPackageBatchOrder *admin.AssetPackageBatchOrderHandler
ClientWechat *app.ClientWechatHandler
SuperAdmin *admin.SuperAdminHandler
SystemConfig *admin.SystemConfigHandler
Audit *admin.AuditHandler
WeCom *admin.WeComHandler
AgentOpenAPI *openapiHandler.Handler
}

View File

@@ -17,6 +17,7 @@ type WorkerDependencies struct {
Redis *redis.Client
Logger *zap.Logger
AsynqClient *asynq.Client // Worker 特有:用于 Scheduler 提交任务
QueueClient *queue.Client // 统一业务任务客户端,用于 Worker 内产生后续任务
StorageService *storage.Service // 对象存储(可选)
GatewayClient *gateway.Client // Gateway 客户端(可选)
}

View File

@@ -1,13 +1,21 @@
package bootstrap
import (
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
"github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
orderSvc "github.com/break/junhong_cmp_fiber/internal/service/order"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
purchaseValidationSvc "github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
"github.com/break/junhong_cmp_fiber/pkg/queue"
)
@@ -22,7 +30,7 @@ type workerServices struct {
}
func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *queue.WorkerServices {
assetAudit := assetAuditSvc.NewService(stores.AssetOperationLog, deps.DB)
auditWriter := auditInfra.NewWriter(auditInfra.NewRegistry(), nil)
commissionStatsService := commission_stats.New(stores.ShopSeriesCommissionStats)
commissionCalculationService := commission_calculation.New(
@@ -43,6 +51,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
commissionStatsService,
deps.Logger,
)
commissionCalculationService.SetAuditWriter(auditWriter)
usageService := packagepkg.NewUsageService(
deps.DB,
@@ -68,6 +77,9 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stores.PackageUsage,
deps.Logger,
)
activationService.SetLifecycleAudit(auditWriter)
usageService.SetLifecycleAudit(auditWriter)
resetService.SetLifecycleAudit(auditWriter)
alertService := pollingSvc.NewAlertService(
stores.PollingAlertRule,
@@ -81,8 +93,30 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stores.DataCleanupLog,
deps.Logger,
)
cardObservationOutbox := outbox.NewRepository()
observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox)
cardObservationService := cardObservationApp.NewService(
deps.DB,
cardObservationInfra.NewEventWriter(cardObservationOutbox),
cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger),
)
iotCardAuditService := iotCardSvc.New(
deps.DB, stores.IotCard, stores.Shop, stores.AssetAllocationRecord,
stores.ShopPackageAllocation, stores.ShopSeriesAllocation, stores.PackageSeries,
deps.GatewayClient, deps.Logger,
)
iotCardAuditService.SetAccessAudit(auditWriter)
cardObservationService.SetStateAuditWriter(iotCardAuditService)
cardObservationIntegration := integrationlog.NewRepository(deps.DB)
cardObservationSeriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
cardObservationSeriesService := cardObservationApp.NewSeriesAttemptService(
cardObservationSeriesCoordinator,
cardObservationInfra.NewSeriesRunner(deps.DB, deps.GatewayClient, cardObservationService, cardObservationIntegration, auditWriter),
cardObservationInfra.NewSeriesAttemptLogger(cardObservationIntegration),
)
// 初始化订单服务(仅用于超时自动取消,不需要微信支付和队列客户端)
// 初始化订单服务,供超时取消和批量订购共同复用现有订单规则。
purchaseValidation := purchaseValidationSvc.New(deps.DB, stores.IotCard, stores.Device, stores.Package, stores.ShopPackageAllocation)
orderService := orderSvc.New(
deps.DB,
deps.Redis,
@@ -91,7 +125,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stores.AgentWallet,
stores.AssetWallet,
nil, // paymentStore: 超时取消不需要
nil, // purchaseValidationService: 超时取消不需要
purchaseValidation,
stores.ShopPackageAllocation,
stores.ShopSeriesAllocation,
stores.IotCard,
@@ -102,12 +136,19 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
nil, // wechatConfigService: 超时取消不需要
nil, // wechatPayment: 超时取消不需要
nil, // paymentLoader: 超时取消不需要
nil, // queueClient: 超时取消不触发分佣
deps.QueueClient,
deps.Logger,
stores.AssetIdentifier,
stores.PersonalCustomer,
stores.PersonalCustomerPhone,
)
orderService.SetLifecycleAudit(auditWriter)
orderService.SetPaymentIntegrationLog(integrationlog.NewRepository(deps.DB))
walletOutbox := outbox.NewRepository()
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox, auditWriter)
orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox, auditWriter), walletDebitEvents, nil))
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
orderService.SetObservationSeriesEventWriter(observationSeriesEvents)
// 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机
stopResumeService := iotCardSvc.NewStopResumeService(
@@ -117,22 +158,36 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stores.DeviceSimBinding,
deps.GatewayClient,
deps.Logger,
assetAudit,
)
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
activationService.SetObservationSeriesEventWriter(observationSeriesEvents)
usageService.SetStopResumeCallback(stopResumeService)
activationService.SetResumeCallback(stopResumeService)
orderService.SetResumeCallback(stopResumeService)
resetService.SetResumeCallback(stopResumeService)
deviceBatchAllocator := deviceSvc.New(
deps.DB, deps.Redis, stores.Device, stores.DeviceSimBinding, stores.IotCard, stores.Shop,
stores.AssetAllocationRecord, stores.ShopPackageAllocation, stores.ShopSeriesAllocation,
stores.PackageSeries, deps.GatewayClient, stores.AssetIdentifier, nil, nil,
)
return &queue.WorkerServices{
CommissionCalculation: commissionCalculationService,
CommissionStats: commissionStatsService,
UsageService: usageService,
ActivationService: activationService,
ResetService: resetService,
AlertService: alertService,
CleanupService: cleanupService,
StopResumeService: stopResumeService,
OrderExpirer: orderService,
PaymentAudit: auditWriter,
RechargeAudit: auditWriter,
CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeriesService,
ObservationSeriesEvents: observationSeriesEvents,
CommissionCalculation: commissionCalculationService,
CommissionStats: commissionStatsService,
UsageService: usageService,
ActivationService: activationService,
ResetService: resetService,
AlertService: alertService,
CleanupService: cleanupService,
StopResumeService: stopResumeService,
OrderExpirer: orderService,
AssetPackageOrderCreator: orderService,
DeviceBatchAllocator: deviceBatchAllocator,
}
}

View File

@@ -6,102 +6,105 @@ import (
)
type workerStores struct {
AssetOperationLog *postgres.AssetOperationLogStore
IotCardImportTask *postgres.IotCardImportTaskStore
IotCard *postgres.IotCardStore
DeviceImportTask *postgres.DeviceImportTaskStore
ExportTask *postgres.ExportTaskStore
ExportShardTask *postgres.ExportShardTaskStore
Device *postgres.DeviceStore
DeviceSimBinding *postgres.DeviceSimBindingStore
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
ShopPackageAllocation *postgres.ShopPackageAllocationStore
CommissionRecord *postgres.CommissionRecordStore
Shop *postgres.ShopStore
ShopSeriesAllocation *postgres.ShopSeriesAllocationStore
PackageSeries *postgres.PackageSeriesStore
Order *postgres.OrderStore
OrderItem *postgres.OrderItemStore
Package *postgres.PackageStore
PackageUsage *postgres.PackageUsageStore
PackageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
PollingAlertRule *postgres.PollingAlertRuleStore
PollingAlertHistory *postgres.PollingAlertHistoryStore
DataCleanupConfig *postgres.DataCleanupConfigStore
DataCleanupLog *postgres.DataCleanupLogStore
AgentWallet *postgres.AgentWalletStore
AgentWalletTransaction *postgres.AgentWalletTransactionStore
AssetWallet *postgres.AssetWalletStore
AssetIdentifier *postgres.AssetIdentifierStore
AssetAllocationRecord *postgres.AssetAllocationRecordStore
IotCardImportTask *postgres.IotCardImportTaskStore
IotCard *postgres.IotCardStore
DeviceImportTask *postgres.DeviceImportTaskStore
ExportTask *postgres.ExportTaskStore
ExportShardTask *postgres.ExportShardTaskStore
Device *postgres.DeviceStore
DeviceSimBinding *postgres.DeviceSimBindingStore
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
ShopPackageAllocation *postgres.ShopPackageAllocationStore
CommissionRecord *postgres.CommissionRecordStore
Shop *postgres.ShopStore
ShopSeriesAllocation *postgres.ShopSeriesAllocationStore
PackageSeries *postgres.PackageSeriesStore
Order *postgres.OrderStore
OrderItem *postgres.OrderItemStore
Package *postgres.PackageStore
PackageUsage *postgres.PackageUsageStore
PackageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
PollingAlertRule *postgres.PollingAlertRuleStore
PollingAlertHistory *postgres.PollingAlertHistoryStore
DataCleanupConfig *postgres.DataCleanupConfigStore
DataCleanupLog *postgres.DataCleanupLogStore
AgentWallet *postgres.AgentWalletStore
AgentWalletTransaction *postgres.AgentWalletTransactionStore
AssetWallet *postgres.AssetWalletStore
AssetIdentifier *postgres.AssetIdentifierStore
PersonalCustomer *postgres.PersonalCustomerStore
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore
}
func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
stores := &workerStores{
AssetOperationLog: postgres.NewAssetOperationLogStore(deps.DB),
IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis),
IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis),
DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis),
ExportTask: postgres.NewExportTaskStore(deps.DB, deps.Redis),
ExportShardTask: postgres.NewExportShardTaskStore(deps.DB, deps.Redis),
Device: postgres.NewDeviceStore(deps.DB, deps.Redis),
DeviceSimBinding: postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis),
ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
ShopPackageAllocation: postgres.NewShopPackageAllocationStore(deps.DB),
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
Shop: postgres.NewShopStore(deps.DB, deps.Redis),
ShopSeriesAllocation: postgres.NewShopSeriesAllocationStore(deps.DB),
PackageSeries: postgres.NewPackageSeriesStore(deps.DB),
Order: postgres.NewOrderStore(deps.DB, deps.Redis),
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
Package: postgres.NewPackageStore(deps.DB),
PackageUsage: postgres.NewPackageUsageStore(deps.DB, deps.Redis),
PackageUsageDailyRecord: postgres.NewPackageUsageDailyRecordStore(deps.DB, deps.Redis),
PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB),
PollingAlertHistory: postgres.NewPollingAlertHistoryStore(deps.DB),
DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB),
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis),
IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis),
IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis),
DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis),
ExportTask: postgres.NewExportTaskStore(deps.DB, deps.Redis),
ExportShardTask: postgres.NewExportShardTaskStore(deps.DB, deps.Redis),
Device: postgres.NewDeviceStore(deps.DB, deps.Redis),
DeviceSimBinding: postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis),
ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
ShopPackageAllocation: postgres.NewShopPackageAllocationStore(deps.DB),
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
Shop: postgres.NewShopStore(deps.DB, deps.Redis),
ShopSeriesAllocation: postgres.NewShopSeriesAllocationStore(deps.DB),
PackageSeries: postgres.NewPackageSeriesStore(deps.DB),
Order: postgres.NewOrderStore(deps.DB, deps.Redis),
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
Package: postgres.NewPackageStore(deps.DB),
PackageUsage: postgres.NewPackageUsageStore(deps.DB, deps.Redis),
PackageUsageDailyRecord: postgres.NewPackageUsageDailyRecordStore(deps.DB, deps.Redis),
PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB),
PollingAlertHistory: postgres.NewPollingAlertHistoryStore(deps.DB),
DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB),
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB),
}
return &queue.WorkerStores{
AssetOperationLog: stores.AssetOperationLog,
IotCardImportTask: stores.IotCardImportTask,
IotCard: stores.IotCard,
DeviceImportTask: stores.DeviceImportTask,
ExportTask: stores.ExportTask,
ExportShardTask: stores.ExportShardTask,
Device: stores.Device,
DeviceSimBinding: stores.DeviceSimBinding,
ShopSeriesCommissionStats: stores.ShopSeriesCommissionStats,
ShopPackageAllocation: stores.ShopPackageAllocation,
CommissionRecord: stores.CommissionRecord,
Shop: stores.Shop,
ShopSeriesAllocation: stores.ShopSeriesAllocation,
PackageSeries: stores.PackageSeries,
Order: stores.Order,
OrderItem: stores.OrderItem,
Package: stores.Package,
PackageUsage: stores.PackageUsage,
PackageUsageDailyRecord: stores.PackageUsageDailyRecord,
PollingAlertRule: stores.PollingAlertRule,
PollingAlertHistory: stores.PollingAlertHistory,
DataCleanupConfig: stores.DataCleanupConfig,
DataCleanupLog: stores.DataCleanupLog,
AgentWallet: stores.AgentWallet,
AgentWalletTransaction: stores.AgentWalletTransaction,
AssetWallet: stores.AssetWallet,
AssetIdentifier: stores.AssetIdentifier,
AssetAllocationRecord: stores.AssetAllocationRecord,
IotCardImportTask: stores.IotCardImportTask,
IotCard: stores.IotCard,
DeviceImportTask: stores.DeviceImportTask,
ExportTask: stores.ExportTask,
ExportShardTask: stores.ExportShardTask,
Device: stores.Device,
DeviceSimBinding: stores.DeviceSimBinding,
ShopSeriesCommissionStats: stores.ShopSeriesCommissionStats,
ShopPackageAllocation: stores.ShopPackageAllocation,
CommissionRecord: stores.CommissionRecord,
Shop: stores.Shop,
ShopSeriesAllocation: stores.ShopSeriesAllocation,
PackageSeries: stores.PackageSeries,
Order: stores.Order,
OrderItem: stores.OrderItem,
Package: stores.Package,
PackageUsage: stores.PackageUsage,
PackageUsageDailyRecord: stores.PackageUsageDailyRecord,
PollingAlertRule: stores.PollingAlertRule,
PollingAlertHistory: stores.PollingAlertHistory,
DataCleanupConfig: stores.DataCleanupConfig,
DataCleanupLog: stores.DataCleanupLog,
AgentWallet: stores.AgentWallet,
AgentWalletTransaction: stores.AgentWalletTransaction,
AssetWallet: stores.AssetWallet,
AssetIdentifier: stores.AssetIdentifier,
PersonalCustomer: stores.PersonalCustomer,
PersonalCustomerPhone: stores.PersonalCustomerPhone,
OrderPackageInvalidateTask: stores.OrderPackageInvalidateTask,
AssetPackageBatchOrderTask: stores.AssetPackageBatchOrderTask,
}
}

View File

@@ -0,0 +1,30 @@
// Package agentrecharge 定义代理在线充值的纯业务规则。
package agentrecharge
import (
"strings"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ValidateOnlineCreation 校验代理在线充值的角色、金额和支付方式不变量。
func ValidateOnlineCreation(userType int, amount int64, paymentMethod string) error {
if userType != constants.UserTypeAgent {
return errors.New(errors.CodeForbidden, "仅代理账号可以创建在线扫码充值")
}
if amount < constants.AgentOnlineRechargeMinAmount || amount > constants.AgentRechargeMaxAmount {
return errors.New(errors.CodeInvalidParam, "在线充值金额必须在100元至100万元之间")
}
switch strings.TrimSpace(paymentMethod) {
case constants.RechargeMethodWechat, constants.RechargeMethodAlipay:
return nil
default:
return errors.New(errors.CodeInvalidParam, "在线充值支付方式无效")
}
}
// CanCloseAfterPaymentURLFailure 判断支付链接生成明确失败后能否关闭充值单。
func CanCloseAfterPaymentURLFailure(status int) bool {
return status == constants.RechargeStatusPending
}

View File

@@ -0,0 +1,82 @@
package agentrecharge
import (
"strings"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// PaymentState 是支付单在确认用例中的领域状态。
type PaymentState int
const (
// PaymentStatePending 表示支付单仍待确认。
PaymentStatePending PaymentState = iota
// PaymentStatePaid 表示支付单已确认收款。
PaymentStatePaid
// PaymentStateFailed 表示支付单曾被明确关闭。
PaymentStateFailed
// PaymentStateRefunded 表示支付单已经退款。
PaymentStateRefunded
)
// PaymentConfirmationFacts 是支付确认所需的渠道与本地权威事实。
type PaymentConfirmationFacts struct {
OrderType string
ExpectedOrderType string
PaymentMethod string
RechargePaymentMethod string
RechargePaymentChannel string
PaymentConfigID uint
RechargePaymentConfigID uint
ConfirmedConfigID uint
MerchantIdentity string
ConfirmedMerchantIdentity string
PaymentAmount int64
RechargeAmount int64
ConfirmedAmount int64
PaymentOrderID uint
RechargeID uint
PaymentState PaymentState
RechargeStatus int
StoredTradeNo string
ConfirmedTradeNo string
}
// ValidatePaymentConfirmation 校验支付确认不变量,并返回是否属于完全一致的重复确认。
func ValidatePaymentConfirmation(facts PaymentConfirmationFacts) (bool, error) {
if facts.ExpectedOrderType == "" || facts.OrderType != facts.ExpectedOrderType ||
facts.RechargeID == 0 || facts.PaymentOrderID != facts.RechargeID {
return false, errors.New(errors.CodeConflict, "支付单与代理充值单关联不一致")
}
method := strings.TrimSpace(facts.PaymentMethod)
if method == "" || method != strings.TrimSpace(facts.RechargePaymentMethod) ||
method != strings.TrimSpace(facts.RechargePaymentChannel) {
return false, errors.New(errors.CodeConflict, "支付渠道与代理充值单不一致")
}
identity := strings.TrimSpace(facts.MerchantIdentity)
if facts.PaymentConfigID == 0 || facts.PaymentConfigID != facts.RechargePaymentConfigID ||
facts.PaymentConfigID != facts.ConfirmedConfigID || identity == "" ||
identity != strings.TrimSpace(facts.ConfirmedMerchantIdentity) {
return false, errors.New(errors.CodeConflict, "支付配置身份与创建记录不一致")
}
tradeNo := strings.TrimSpace(facts.ConfirmedTradeNo)
if tradeNo == "" || facts.ConfirmedAmount <= 0 || facts.PaymentAmount != facts.RechargeAmount ||
facts.PaymentAmount != facts.ConfirmedAmount {
return false, errors.New(errors.CodeConflict, "支付金额或第三方交易号无效")
}
if facts.PaymentState == PaymentStatePaid {
if strings.TrimSpace(facts.StoredTradeNo) == tradeNo &&
(facts.RechargeStatus == constants.RechargeStatusPaid || facts.RechargeStatus == constants.RechargeStatusCompleted) {
return true, nil
}
return false, errors.New(errors.CodeConflict, "支付单已存在不一致的确认事实")
}
validPending := facts.PaymentState == PaymentStatePending && facts.RechargeStatus == constants.RechargeStatusPending
validLateSuccess := facts.PaymentState == PaymentStateFailed && facts.RechargeStatus == constants.RechargeStatusClosed
if !validPending && !validLateSuccess {
return false, errors.New(errors.CodeInvalidStatus, "代理充值单当前状态不可确认支付")
}
return false, nil
}

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,65 @@
// Package package 提供套餐生命周期领域规则。
package packagedomain
import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// TermsSnapshot 套餐购买时不可变计时条款。
type TermsSnapshot struct {
ExpiryBase string
CalendarType string
DurationMonths int
DurationDays int
}
// ResolveTermsSnapshot 解析并校验套餐购买时计时条款。
func ResolveTermsSnapshot(pkg *model.Package, allocation *model.ShopPackageAllocation) (TermsSnapshot, error) {
if pkg == nil {
return TermsSnapshot{}, errors.New(errors.CodeInvalidParam, "套餐计时条款缺失")
}
expiryBase := pkg.ExpiryBase
if allocation != nil && allocation.ExpiryBaseOverride != nil {
expiryBase = *allocation.ExpiryBaseOverride
}
if expiryBase != constants.PackageExpiryBaseFromActivation && expiryBase != constants.PackageExpiryBaseFromPurchase {
return TermsSnapshot{}, errors.New(errors.CodeInvalidParam, "套餐生效条件无效")
}
if pkg.CalendarType == constants.PackageCalendarTypeNaturalMonth {
if pkg.DurationMonths <= 0 {
return TermsSnapshot{}, errors.New(errors.CodeInvalidParam, "套餐自然月时长无效")
}
return TermsSnapshot{ExpiryBase: expiryBase, CalendarType: pkg.CalendarType, DurationMonths: pkg.DurationMonths}, nil
}
if pkg.CalendarType == constants.PackageCalendarTypeByDay && pkg.DurationDays > 0 {
return TermsSnapshot{ExpiryBase: expiryBase, CalendarType: pkg.CalendarType, DurationDays: pkg.DurationDays}, nil
}
return TermsSnapshot{}, errors.New(errors.CodeInvalidParam, "套餐按天时长无效")
}
// Apply 将计时条款写入套餐使用记录。
func (s TermsSnapshot) Apply(usage *model.PackageUsage) {
usage.ExpiryBaseSnapshot = s.ExpiryBase
usage.CalendarTypeSnapshot = s.CalendarType
usage.DurationMonthsSnapshot = s.DurationMonths
usage.DurationDaysSnapshot = s.DurationDays
}
// IsValid 判断快照是否完整有效。
func (s TermsSnapshot) IsValid() bool {
if s.ExpiryBase != constants.PackageExpiryBaseFromActivation && s.ExpiryBase != constants.PackageExpiryBaseFromPurchase {
return false
}
return (s.CalendarType == constants.PackageCalendarTypeNaturalMonth && s.DurationMonths > 0) ||
(s.CalendarType == constants.PackageCalendarTypeByDay && s.DurationDays > 0)
}
// TermsSnapshotFromUsage 从使用记录读取计时条款。
func TermsSnapshotFromUsage(usage *model.PackageUsage) TermsSnapshot {
return TermsSnapshot{
ExpiryBase: usage.ExpiryBaseSnapshot, CalendarType: usage.CalendarTypeSnapshot,
DurationMonths: usage.DurationMonthsSnapshot, DurationDays: usage.DurationDaysSnapshot,
}
}

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

@@ -0,0 +1,190 @@
package exporter
import (
"context"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// AgentRechargeDataSource 代理充值导出数据源。
type AgentRechargeDataSource struct {
db *gorm.DB
}
// NewAgentRechargeDataSource 创建代理充值导出数据源。
func NewAgentRechargeDataSource(db *gorm.DB) *AgentRechargeDataSource {
return &AgentRechargeDataSource{db: db}
}
// Scene 返回导出场景编码。
func (s *AgentRechargeDataSource) Scene() string {
return constants.ExportTaskSceneAgentRecharge
}
// Count 统计代理充值导出行数。
func (s *AgentRechargeDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
var total int64
query := s.applyFilters(s.baseQuery(ctx), params)
if err := query.Count(&total).Error; err != nil {
return 0, err
}
return int(total), nil
}
// Headers 返回代理充值导出表头。
func (s *AgentRechargeDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return []string{
"充值单号", "店铺名称", "充值类型", "充值金额(元)", "实付金额(元)", "充值前余额(元)", "充值后余额(元)",
"状态", "支付方式", "运营备注", "驳回原因", "创建时间", "支付时间", "完成时间", "提交人", "审批来源", "审批状态", "支付凭证",
}, nil
}
// Fetch 按 offset/limit 查询代理充值导出数据。
func (s *AgentRechargeDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
var items []agentRechargeExportRow
query := s.applyFilters(s.baseQuery(ctx), params).
Select(`
r.recharge_no,
r.amount,
r.payment_method,
r.status,
r.remark,
COALESCE(r.rejection_reason, '') AS rejection_reason,
r.created_at,
r.paid_at,
r.completed_at,
COALESCE(sh.shop_name, '') AS shop_name,
COALESCE(ac.username, '') AS submitter_name,
ai.provider AS approval_provider,
ai.status AS approval_status,
wt.amount AS actual_amount,
wt.balance_before,
wt.balance_after,
COALESCE((SELECT string_agg(voucher.value, ',' ORDER BY voucher.ordinality)
FROM jsonb_array_elements_text(COALESCE(r.payment_voucher_key, '[]'::jsonb)) WITH ORDINALITY AS voucher(value, ordinality)), '') AS voucher_keys
`).
Joins("LEFT JOIN tb_shop AS sh ON sh.id = r.shop_id").
Joins("LEFT JOIN tb_account AS ac ON ac.id = r.user_id").
Joins("LEFT JOIN tb_approval_instance AS ai ON ai.id = r.approval_instance_id").
Joins(`LEFT JOIN LATERAL (
SELECT t.amount, t.balance_before, t.balance_after
FROM tb_agent_wallet_transaction AS t
WHERE t.reference_type = ?
AND t.reference_id = r.id
AND t.transaction_type = ?
AND t.status = ?
AND t.deleted_at IS NULL
ORDER BY t.id ASC
LIMIT 1
) AS wt ON TRUE`, constants.ReferenceTypeTopup, constants.AgentTransactionTypeRecharge, constants.TransactionStatusSuccess).
Order("r.id ASC").
Limit(limit).
Offset(offset)
if err := query.Scan(&items).Error; err != nil {
return nil, err
}
rows := make([][]string, 0, len(items))
for _, item := range items {
rows = append(rows, []string{
item.RechargeNo,
item.ShopName,
formatRechargeType(item.PaymentMethod),
formatMoneyYuan(item.Amount),
formatOptionalMoneyYuan(item.ActualAmount),
formatOptionalMoneyYuan(item.BalanceBefore),
formatOptionalMoneyYuan(item.BalanceAfter),
constants.GetRechargeStatusName(item.Status),
constants.GetBusinessPaymentMethodName(item.PaymentMethod),
item.Remark,
item.RejectionReason,
item.CreatedAt.Format(exportTimeLayout),
formatOptionalTime(item.PaidAt),
formatOptionalTime(item.CompletedAt),
item.SubmitterName,
formatApprovalProvider(item.ApprovalProvider),
formatOptionalApprovalStatus(item.ApprovalStatus),
item.VoucherKeys,
})
}
return rows, nil
}
func (s *AgentRechargeDataSource) baseQuery(ctx context.Context) *gorm.DB {
return s.db.WithContext(ctx).Table("tb_agent_recharge_record AS r").Where("r.deleted_at IS NULL")
}
func (s *AgentRechargeDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
query = applyExportShopScope(query, params, "r.shop_id")
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
query = query.Where("r.shop_id = ?", shopID)
}
if status, ok := filterInt(params.Filters, "status"); ok {
query = query.Where("r.status = ?", status)
}
if start, ok := filterTime(params.Filters, "start_date"); ok {
query = query.Where("r.created_at >= ?", start)
}
if end, ok := filterEndDate(params.Filters, "end_date"); ok {
query = query.Where("r.created_at <= ?", end)
}
return query
}
type agentRechargeExportRow struct {
RechargeNo string `gorm:"column:recharge_no"`
ShopName string `gorm:"column:shop_name"`
Amount int64 `gorm:"column:amount"`
ActualAmount *int64 `gorm:"column:actual_amount"`
BalanceBefore *int64 `gorm:"column:balance_before"`
BalanceAfter *int64 `gorm:"column:balance_after"`
PaymentMethod string `gorm:"column:payment_method"`
Status int `gorm:"column:status"`
Remark string `gorm:"column:remark"`
RejectionReason string `gorm:"column:rejection_reason"`
CreatedAt time.Time `gorm:"column:created_at"`
PaidAt *time.Time `gorm:"column:paid_at"`
CompletedAt *time.Time `gorm:"column:completed_at"`
SubmitterName string `gorm:"column:submitter_name"`
ApprovalProvider *string `gorm:"column:approval_provider"`
ApprovalStatus *int `gorm:"column:approval_status"`
VoucherKeys string `gorm:"column:voucher_keys"`
}
func formatRechargeType(paymentMethod string) string {
if paymentMethod == constants.RechargeMethodOffline {
return "员工线下代充值"
}
return "在线充值"
}
func formatOptionalMoneyYuan(value *int64) string {
if value == nil {
return ""
}
return formatMoneyYuan(*value)
}
func formatApprovalProvider(provider *string) string {
if provider == nil || *provider == "" {
return ""
}
if *provider == constants.IntegrationProviderWeCom {
return "企业微信"
}
return *provider
}
func formatOptionalApprovalStatus(status *int) string {
if status == nil {
return ""
}
return constants.GetApprovalStatusName(*status)
}

View File

@@ -0,0 +1,168 @@
package exporter
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// AgentWalletTransactionDataSource 代理主钱包流水导出数据源。
type AgentWalletTransactionDataSource struct {
db *gorm.DB
}
// NewAgentWalletTransactionDataSource 创建代理主钱包流水导出数据源。
func NewAgentWalletTransactionDataSource(db *gorm.DB) *AgentWalletTransactionDataSource {
return &AgentWalletTransactionDataSource{db: db}
}
// Scene 返回导出场景编码。
func (s *AgentWalletTransactionDataSource) Scene() string {
return constants.ExportTaskSceneAgentWalletTransaction
}
// Count 统计代理主钱包流水导出行数。
func (s *AgentWalletTransactionDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
var total int64
query := s.applyFilters(s.baseQuery(ctx), params)
if err := query.Count(&total).Error; err != nil {
return 0, err
}
return int(total), nil
}
// Headers 返回代理主钱包流水导出表头。
func (s *AgentWalletTransactionDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return []string{
"店铺名称", "交易类型", "交易金额(元)", "状态", "资产类型", "资产标识", "交易时间",
"交易前金额(元)", "交易后金额(元)", "购买套餐名称", "操作人", "交易ID", "关联业务订单号", "交易渠道/支付方式",
}, nil
}
// Fetch 按 offset/limit 查询代理主钱包流水导出数据。
func (s *AgentWalletTransactionDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
var items []agentWalletTransactionExportRow
query := s.applyFilters(s.baseQuery(ctx), params).
Select(`
t.id,
t.transaction_type,
t.amount,
t.status,
t.asset_type,
t.asset_identifier,
t.created_at,
t.balance_before,
t.balance_after,
COALESCE(sh.shop_name, '') AS shop_name,
COALESCE(items.package_names, t.metadata ->> 'package_name', '') AS package_name,
COALESCE(ac.username, '') AS operator_name,
COALESCE(
CASE t.reference_type
WHEN ? THEN o.order_no
WHEN ? THEN ar.recharge_no
WHEN ? THEN rr.refund_no
WHEN ? THEN wr.withdrawal_no
WHEN ? THEN ex.exchange_no
END,
''
) AS business_order_no,
COALESCE(t.metadata ->> 'payment_method', '') AS payment_method
`, constants.ReferenceTypeOrder, constants.ReferenceTypeTopup, constants.ReferenceTypeRefund, constants.ReferenceTypeWithdrawal, constants.ReferenceTypeExchange).
Joins("LEFT JOIN tb_shop AS sh ON sh.id = t.shop_id").
Joins("LEFT JOIN tb_account AS ac ON ac.id = COALESCE(NULLIF(t.creator, 0), t.user_id)").
Joins("LEFT JOIN tb_order AS o ON t.reference_type = ? AND o.id = t.reference_id AND o.deleted_at IS NULL", constants.ReferenceTypeOrder).
Joins("LEFT JOIN tb_agent_recharge_record AS ar ON t.reference_type = ? AND ar.id = t.reference_id AND ar.deleted_at IS NULL", constants.ReferenceTypeTopup).
Joins("LEFT JOIN tb_refund_request AS rr ON t.reference_type = ? AND rr.id = t.reference_id AND rr.deleted_at IS NULL", constants.ReferenceTypeRefund).
Joins("LEFT JOIN tb_commission_withdrawal_request AS wr ON t.reference_type = ? AND wr.id = t.reference_id AND wr.deleted_at IS NULL", constants.ReferenceTypeWithdrawal).
Joins("LEFT JOIN tb_exchange_order AS ex ON t.reference_type = ? AND ex.id = t.reference_id AND ex.deleted_at IS NULL", constants.ReferenceTypeExchange).
Joins(`LEFT JOIN LATERAL (
SELECT string_agg(oi.package_name, ',' ORDER BY oi.id) AS package_names
FROM tb_order_item AS oi
WHERE t.reference_type = ?
AND oi.order_id = t.reference_id
AND oi.deleted_at IS NULL
) AS items ON TRUE`, constants.ReferenceTypeOrder).
Order("t.id ASC").
Limit(limit).
Offset(offset)
if err := query.Scan(&items).Error; err != nil {
return nil, err
}
rows := make([][]string, 0, len(items))
for _, item := range items {
paymentMethod := item.PaymentMethod
if paymentMethod == "" && item.TransactionType == constants.AgentTransactionTypeDeduct {
paymentMethod = constants.PaymentMethodWallet
}
rows = append(rows, []string{
item.ShopName,
constants.GetAgentTransactionTypeName(item.TransactionType),
formatMoneyYuan(item.Amount),
constants.GetTransactionStatusName(item.Status),
constants.GetWalletAssetTypeName(item.AssetType),
item.AssetIdentifier,
item.CreatedAt.Format(exportTimeLayout),
formatMoneyYuan(item.BalanceBefore),
formatMoneyYuan(item.BalanceAfter),
item.PackageName,
item.OperatorName,
strconv.FormatUint(uint64(item.ID), 10),
item.BusinessOrderNo,
constants.GetBusinessPaymentMethodName(paymentMethod),
})
}
return rows, nil
}
func (s *AgentWalletTransactionDataSource) baseQuery(ctx context.Context) *gorm.DB {
return s.db.WithContext(ctx).
Table("tb_agent_wallet_transaction AS t").
Joins("INNER JOIN tb_agent_wallet AS w ON w.id = t.agent_wallet_id AND w.wallet_type = ? AND w.deleted_at IS NULL", constants.AgentWalletTypeMain).
Where("t.deleted_at IS NULL")
}
func (s *AgentWalletTransactionDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
query = applyExportShopScope(query, params, "t.shop_id")
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
query = query.Where("t.shop_id = ?", shopID)
}
if transactionType, ok := filterString(params.Filters, "transaction_type"); ok {
query = query.Where("t.transaction_type = ?", transactionType)
}
if start, ok := filterTime(params.Filters, "start_date"); ok {
query = query.Where("t.created_at >= ?", start)
}
if end, ok := filterEndDate(params.Filters, "end_date"); ok {
query = query.Where("t.created_at <= ?", end)
}
if assetIdentifier, ok := filterString(params.Filters, "asset_identifier"); ok {
query = query.Where("t.asset_identifier = ?", assetIdentifier)
}
return query
}
type agentWalletTransactionExportRow struct {
ID uint `gorm:"column:id"`
ShopName string `gorm:"column:shop_name"`
TransactionType string `gorm:"column:transaction_type"`
Amount int64 `gorm:"column:amount"`
Status int `gorm:"column:status"`
AssetType string `gorm:"column:asset_type"`
AssetIdentifier string `gorm:"column:asset_identifier"`
CreatedAt time.Time `gorm:"column:created_at"`
BalanceBefore int64 `gorm:"column:balance_before"`
BalanceAfter int64 `gorm:"column:balance_after"`
PackageName string `gorm:"column:package_name"`
OperatorName string `gorm:"column:operator_name"`
BusinessOrderNo string `gorm:"column:business_order_no"`
PaymentMethod string `gorm:"column:payment_method"`
}

View File

@@ -0,0 +1,176 @@
package exporter
import (
"context"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// ExchangeDataSource 换货记录导出数据源。
type ExchangeDataSource struct {
db *gorm.DB
}
// NewExchangeDataSource 创建换货记录导出数据源。
func NewExchangeDataSource(db *gorm.DB) *ExchangeDataSource {
return &ExchangeDataSource{db: db}
}
// Scene 返回导出场景编码。
func (s *ExchangeDataSource) Scene() string {
return constants.ExportTaskSceneExchange
}
// Count 统计换货记录导出行数。
func (s *ExchangeDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
var total int64
query := s.applyFilters(s.baseQuery(ctx), params)
if err := query.Count(&total).Error; err != nil {
return 0, err
}
return int(total), nil
}
// Headers 返回换货记录导出表头。
func (s *ExchangeDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return []string{
"换货单号", "换货类型", "换货原因", "问题描述/备注", "旧资产类型", "旧资产标识符", "新资产标识符",
"收货人姓名", "收货人电话", "收货地址", "快递公司", "快递单号", "状态", "创建人", "创建时间",
}, nil
}
// Fetch 按 offset/limit 查询换货记录导出数据。
func (s *ExchangeDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
var items []exchangeExportRow
query := s.applyFilters(s.baseQuery(ctx), params).
Select(`
e.exchange_no,
e.flow_type,
e.exchange_reason,
COALESCE(e.remark, '') AS remark,
e.old_asset_type,
e.old_asset_identifier,
e.new_asset_identifier,
e.recipient_name,
e.recipient_phone,
e.recipient_address,
e.express_company,
e.express_no,
e.status,
e.created_at,
COALESCE(ac.username, '') AS creator_name
`).
Joins("LEFT JOIN tb_account AS ac ON ac.id = e.creator").
Order("e.id ASC").
Limit(limit).
Offset(offset)
if err := query.Scan(&items).Error; err != nil {
return nil, err
}
rows := make([][]string, 0, len(items))
for _, item := range items {
rows = append(rows, []string{
item.ExchangeNo,
constants.GetExchangeFlowTypeName(item.FlowType),
item.ExchangeReason,
item.Remark,
formatExchangeAssetType(item.OldAssetType),
item.OldAssetIdentifier,
item.NewAssetIdentifier,
item.RecipientName,
item.RecipientPhone,
item.RecipientAddress,
item.ExpressCompany,
item.ExpressNo,
constants.GetExchangeStatusName(item.Status),
item.CreatorName,
item.CreatedAt.Format(exportTimeLayout),
})
}
return rows, nil
}
func (s *ExchangeDataSource) baseQuery(ctx context.Context) *gorm.DB {
return s.db.WithContext(ctx).Table("tb_exchange_order AS e").Where("e.deleted_at IS NULL")
}
func (s *ExchangeDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
query = applyExportShopScope(query, params, "e.shop_id")
if status, ok := filterInt(params.Filters, "status"); ok {
query = query.Where("e.status = ?", status)
}
if flowType, ok := filterString(params.Filters, "flow_type"); ok {
query = query.Where("COALESCE(NULLIF(e.flow_type, ''), ?) = ?", constants.ExchangeFlowTypeShipping, flowType)
}
query = applyExchangeAssetKeyword(query, "old", filterValue(params.Filters, "old_asset_keyword"))
query = applyExchangeAssetKeyword(query, "new", filterValue(params.Filters, "new_asset_keyword"))
if start, ok := filterTime(params.Filters, "created_at_start"); ok {
query = query.Where("e.created_at >= ?", start)
}
if end, ok := filterTime(params.Filters, "created_at_end"); ok {
query = query.Where("e.created_at <= ?", end)
}
return query
}
func applyExchangeAssetKeyword(query *gorm.DB, side, keyword string) *gorm.DB {
if keyword == "" {
return query
}
like := "%" + keyword + "%"
cardIDs := query.Session(&gorm.Session{NewDB: true}).Table("tb_iot_card").Select("id").
Where("deleted_at IS NULL").
Where("iccid LIKE ? OR msisdn LIKE ? OR virtual_no LIKE ?", like, like, like)
deviceIDs := query.Session(&gorm.Session{NewDB: true}).Table("tb_device").Select("id").
Where("deleted_at IS NULL").
Where("virtual_no LIKE ? OR imei LIKE ? OR sn LIKE ?", like, like, like)
prefix := "e." + side
return query.Where(
"("+prefix+"_asset_type = ? AND "+prefix+"_asset_id IN (?)) OR ("+prefix+"_asset_type = ? AND "+prefix+"_asset_id IN (?))",
constants.ExchangeAssetTypeIotCard, cardIDs, constants.ExchangeAssetTypeDevice, deviceIDs,
)
}
func filterValue(filters map[string]any, key string) string {
value, _ := filterString(filters, key)
return value
}
func formatExchangeAssetType(assetType string) string {
switch assetType {
case constants.ExchangeAssetTypeIotCard:
return "物联网卡"
case constants.ExchangeAssetTypeDevice:
return "设备"
case "":
return ""
default:
return "未知"
}
}
type exchangeExportRow struct {
ExchangeNo string `gorm:"column:exchange_no"`
FlowType string `gorm:"column:flow_type"`
ExchangeReason string `gorm:"column:exchange_reason"`
Remark string `gorm:"column:remark"`
OldAssetType string `gorm:"column:old_asset_type"`
OldAssetIdentifier string `gorm:"column:old_asset_identifier"`
NewAssetIdentifier string `gorm:"column:new_asset_identifier"`
RecipientName string `gorm:"column:recipient_name"`
RecipientPhone string `gorm:"column:recipient_phone"`
RecipientAddress string `gorm:"column:recipient_address"`
ExpressCompany string `gorm:"column:express_company"`
ExpressNo string `gorm:"column:express_no"`
Status int `gorm:"column:status"`
CreatorName string `gorm:"column:creator_name"`
CreatedAt time.Time `gorm:"column:created_at"`
}

View File

@@ -255,6 +255,18 @@ func filterTime(filters map[string]any, key string) (time.Time, bool) {
return time.Time{}, false
}
// filterEndDate 解析截止时间;纯日期输入覆盖到当天结束,带时分秒输入保持原值。
func filterEndDate(filters map[string]any, key string) (time.Time, bool) {
text, ok := filterString(filters, key)
if !ok {
return time.Time{}, false
}
if parsed, err := time.ParseInLocation("2006-01-02", text, time.Local); err == nil {
return parsed.Add(24*time.Hour - time.Nanosecond), true
}
return filterTime(filters, key)
}
func formatOptionalUint(value *uint) string {
if value == nil {
return ""

View File

@@ -2,6 +2,7 @@ package exporter
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
@@ -36,7 +37,7 @@ func (s *IotCardDataSource) Count(ctx context.Context, params ExportParams) (int
// Headers 返回 IoT 卡导出表头。
func (s *IotCardDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) {
return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态"}, nil
return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)"}, nil
}
// Fetch 按 offset/limit 查询 IoT 卡导出数据。
@@ -56,7 +57,10 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs
COALESCE(d.device_name, '') AS device_name,
c.real_name_status,
c.first_realname_at,
c.network_status
c.network_status,
COALESCE(pkg.package_name, '') AS package_name,
COALESCE(pkg.data_usage_mb, 0) AS data_usage_mb,
COALESCE(pkg.data_limit_mb, 0) AS data_limit_mb
`).
Order("c.id ASC").
Limit(limit).
@@ -77,6 +81,9 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs
formatRealNameVerified(item.RealNameStatus),
formatOptionalTime(item.FirstRealnameAt),
constants.GetNetworkStatusName(item.NetworkStatus),
item.PackageName,
strconv.FormatInt(item.DataUsageMB, 10),
strconv.FormatInt(remainingPackageDataMB(item.DataLimitMB, item.DataUsageMB), 10),
})
}
return rows, nil
@@ -100,6 +107,22 @@ func (s *IotCardDataSource) baseQuery(ctx context.Context) *gorm.DB {
LIMIT 1
) AS d ON TRUE
`, constants.BindStatusBound).
Joins(`
LEFT JOIN LATERAL (
SELECT
COALESCE(NULLIF(pu.package_name, ''), p.package_name, '') AS package_name,
pu.data_usage_mb,
pu.data_limit_mb
FROM tb_package_usage AS pu
LEFT JOIN tb_package AS p ON p.id = pu.package_id AND p.deleted_at IS NULL
WHERE pu.iot_card_id = c.id
AND pu.status = ?
AND pu.master_usage_id IS NULL
AND pu.deleted_at IS NULL
ORDER BY pu.priority ASC, pu.activated_at ASC, pu.id ASC
LIMIT 1
) AS pkg ON TRUE
`, constants.PackageUsageStatusActive).
Where("c.deleted_at IS NULL")
}
@@ -206,6 +229,17 @@ type iotCardExportRow struct {
RealNameStatus int `gorm:"column:real_name_status"`
FirstRealnameAt *time.Time `gorm:"column:first_realname_at"`
NetworkStatus int `gorm:"column:network_status"`
PackageName string `gorm:"column:package_name"`
DataUsageMB int64 `gorm:"column:data_usage_mb"`
DataLimitMB int64 `gorm:"column:data_limit_mb"`
}
func remainingPackageDataMB(limit, used int64) int64 {
remaining := limit - used
if remaining < 0 {
return 0
}
return remaining
}
func formatRealNameVerified(status int) string {

View File

@@ -0,0 +1,228 @@
package exporter
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// PackageDataSource 套餐导出数据源。
type PackageDataSource struct {
db *gorm.DB
}
// NewPackageDataSource 创建套餐导出数据源。
func NewPackageDataSource(db *gorm.DB) *PackageDataSource {
return &PackageDataSource{db: db}
}
// Scene 返回导出场景编码。
func (s *PackageDataSource) Scene() string {
return constants.ExportTaskScenePackage
}
// Count 统计套餐导出行数。
func (s *PackageDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
var total int64
query := s.applyFilters(s.baseQuery(ctx, params), params)
if err := query.Count(&total).Error; err != nil {
return 0, err
}
return int(total), nil
}
// Headers 返回套餐导出固定表头。
func (s *PackageDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return []string{
"套餐编码", "套餐名称", "套餐系列名称", "套餐类型", "套餐时长(月)",
"套餐时长说明", "套餐周期类型", "套餐天数", "真流量额度(MB)", "虚流量额度(MB)",
"是否启用虚流量", "虚流量比例", "流量重置周期", "到期时间基准", "成本价(元)",
"建议售价(元)", "价格配置状态", "状态", "上架状态", "是否赠送套餐",
"创建人ID", "更新人ID", "创建时间", "更新时间", "删除时间",
}, nil
}
// Fetch 按 offset/limit 查询套餐导出数据。
func (s *PackageDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
var items []packageExportRow
query := s.applyFilters(s.baseQuery(ctx, params), params).
Joins("LEFT JOIN tb_package_series AS ps ON ps.id = p.series_id AND ps.deleted_at IS NULL").
Select(s.selectColumns(params)).
Order("p.id ASC").
Limit(limit).
Offset(offset)
if err := query.Scan(&items).Error; err != nil {
return nil, err
}
rows := make([][]string, 0, len(items))
for _, item := range items {
rows = append(rows, buildPackageExportRow(item))
}
return rows, nil
}
func (s *PackageDataSource) baseQuery(ctx context.Context, params ExportParams) *gorm.DB {
query := s.db.WithContext(ctx).Table("tb_package AS p").Where("p.deleted_at IS NULL")
switch params.UserType {
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
return query
case constants.UserTypeAgent:
if params.CreatorShopID == nil || *params.CreatorShopID == 0 {
return query.Where("1 = 0")
}
return query.
Joins(`INNER JOIN tb_shop_package_allocation AS a
ON a.package_id = p.id
AND a.shop_id = ?
AND a.status = ?
AND a.deleted_at IS NULL`, *params.CreatorShopID, constants.StatusEnabled).
Where("p.is_gift = ?", false)
default:
return query.Where("1 = 0")
}
}
func (s *PackageDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
if packageName, ok := filterString(params.Filters, "package_name"); ok {
query = query.Where("p.package_name LIKE ?", "%"+packageName+"%")
}
if seriesID, ok := filterUint(params.Filters, "series_id"); ok {
query = query.Where("p.series_id = ?", seriesID)
}
if status, ok := filterInt(params.Filters, "status"); ok {
query = query.Where("p.status = ?", status)
}
if shelfStatus, ok := filterInt(params.Filters, "shelf_status"); ok {
if params.UserType == constants.UserTypeAgent {
query = query.Where("a.shelf_status = ?", shelfStatus)
} else {
query = query.Where("p.shelf_status = ?", shelfStatus)
}
}
if packageType, ok := filterString(params.Filters, "package_type"); ok {
query = query.Where("p.package_type = ?", packageType)
}
return query
}
func (s *PackageDataSource) selectColumns(params ExportParams) string {
costPriceColumn := "p.cost_price"
shelfStatusColumn := "p.shelf_status"
if params.UserType == constants.UserTypeAgent {
costPriceColumn = "a.cost_price"
shelfStatusColumn = "a.shelf_status"
}
return `
p.package_code,
p.package_name,
COALESCE(ps.series_name, '') AS series_name,
p.package_type,
p.duration_months,
p.calendar_type,
p.duration_days,
p.real_data_mb,
p.virtual_data_mb,
p.enable_virtual_data,
p.virtual_ratio,
p.data_reset_cycle,
p.expiry_base,
` + costPriceColumn + ` AS cost_price,
p.suggested_retail_price,
p.price_config_status,
p.status,
` + shelfStatusColumn + ` AS shelf_status,
p.is_gift,
p.creator,
p.updater,
p.created_at,
p.updated_at`
}
type packageExportRow struct {
PackageCode string `gorm:"column:package_code"`
PackageName string `gorm:"column:package_name"`
SeriesName string `gorm:"column:series_name"`
PackageType string `gorm:"column:package_type"`
DurationMonths int `gorm:"column:duration_months"`
CalendarType string `gorm:"column:calendar_type"`
DurationDays int `gorm:"column:duration_days"`
RealDataMB int64 `gorm:"column:real_data_mb"`
VirtualDataMB int64 `gorm:"column:virtual_data_mb"`
EnableVirtualData bool `gorm:"column:enable_virtual_data"`
VirtualRatio float64 `gorm:"column:virtual_ratio"`
DataResetCycle string `gorm:"column:data_reset_cycle"`
ExpiryBase string `gorm:"column:expiry_base"`
CostPrice int64 `gorm:"column:cost_price"`
SuggestedRetailPrice int64 `gorm:"column:suggested_retail_price"`
PriceConfigStatus int `gorm:"column:price_config_status"`
Status int `gorm:"column:status"`
ShelfStatus int `gorm:"column:shelf_status"`
IsGift bool `gorm:"column:is_gift"`
Creator uint `gorm:"column:creator"`
Updater uint `gorm:"column:updater"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func buildPackageExportRow(item packageExportRow) []string {
return []string{
item.PackageCode,
item.PackageName,
item.SeriesName,
constants.GetPackageTypeName(item.PackageType),
strconv.Itoa(item.DurationMonths),
formatPackageDurationDescription(item),
constants.GetPackageCalendarTypeName(item.CalendarType),
strconv.Itoa(item.DurationDays),
strconv.FormatInt(item.RealDataMB, 10),
strconv.FormatInt(item.VirtualDataMB, 10),
formatYesNo(item.EnableVirtualData),
strconv.FormatFloat(item.VirtualRatio, 'f', 6, 64),
constants.GetPackageDataResetCycleName(item.DataResetCycle),
constants.GetPackageExpiryBaseName(item.ExpiryBase),
formatMoneyYuan(item.CostPrice),
formatPackageSuggestedRetailPrice(item),
constants.GetPackagePriceConfigStatusName(item.PriceConfigStatus),
constants.GetStatusName(item.Status),
constants.GetShelfStatusName(item.ShelfStatus),
formatYesNo(item.IsGift),
strconv.FormatUint(uint64(item.Creator), 10),
strconv.FormatUint(uint64(item.Updater), 10),
item.CreatedAt.Format(exportTimeLayout),
item.UpdatedAt.Format(exportTimeLayout),
"",
}
}
func formatPackageDurationDescription(item packageExportRow) string {
if item.CalendarType == constants.PackageCalendarTypeByDay && item.DurationDays > 0 {
return strconv.Itoa(item.DurationDays) + "天"
}
if item.DurationMonths > 0 {
return strconv.Itoa(item.DurationMonths) + "个月"
}
return ""
}
func formatPackageSuggestedRetailPrice(item packageExportRow) string {
if item.PriceConfigStatus == constants.PackagePriceConfigStatusUnconfigured {
return ""
}
return formatMoneyYuan(item.SuggestedRetailPrice)
}
func formatYesNo(value bool) string {
if value {
return "是"
}
return "否"
}

View File

@@ -0,0 +1,206 @@
package exporter
import (
"context"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// RefundDataSource 退款记录导出数据源。
type RefundDataSource struct {
db *gorm.DB
}
// NewRefundDataSource 创建退款记录导出数据源。
func NewRefundDataSource(db *gorm.DB) *RefundDataSource {
return &RefundDataSource{db: db}
}
// Scene 返回导出场景编码。
func (s *RefundDataSource) Scene() string {
return constants.ExportTaskSceneRefund
}
// Count 统计退款记录导出行数。
func (s *RefundDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
var total int64
query := s.applyFilters(s.baseQuery(ctx), params)
if err := query.Count(&total).Error; err != nil {
return 0, err
}
return int(total), nil
}
// Headers 返回退款记录导出表头。
func (s *RefundDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return []string{
"退款单号", "代理店铺名称", "关联的支付订单号", "资产类型", "资产标识", "套餐名称", "原订单金额(元)",
"实收金额(元)", "可退金额(元)", "申请退款金额(元)", "实际退款金额(元)", "状态", "退款原因", "审批备注",
"审批来源", "审批状态", "退款处理状态", "退款申请时间", "退款审批时间", "提交人", "退款凭证",
}, nil
}
// Fetch 按 offset/limit 查询退款记录导出数据。
func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
var items []refundExportRow
query := s.applyFilters(s.baseQuery(ctx), params).
Select(`
r.refund_no,
r.order_no,
r.order_type,
r.asset_identifier,
r.actual_received_amount,
r.requested_refund_amount,
r.approved_refund_amount,
r.status,
r.refund_reason,
r.remark,
r.commission_deducted,
r.asset_reset,
r.created_at,
r.processed_at,
COALESCE(sh.shop_name, '') AS shop_name,
o.total_amount AS original_amount,
o.actual_paid_amount AS refundable_amount,
COALESCE(pu.package_name, items.package_names, '') AS package_name,
COALESCE(ac.username, '') AS submitter_name,
ai.provider AS approval_provider,
ai.status AS approval_status,
COALESCE((SELECT string_agg(voucher.value, ',' ORDER BY voucher.ordinality)
FROM jsonb_array_elements_text(COALESCE(r.refund_voucher_key, '[]'::jsonb)) WITH ORDINALITY AS voucher(value, ordinality)), '') AS voucher_keys
`).
Joins("LEFT JOIN tb_shop AS sh ON sh.id = r.shop_id").
Joins("LEFT JOIN tb_order AS o ON o.id = r.order_id AND o.deleted_at IS NULL").
Joins("LEFT JOIN tb_package_usage AS pu ON pu.id = r.package_usage_id AND pu.deleted_at IS NULL").
Joins("LEFT JOIN tb_account AS ac ON ac.id = r.creator").
Joins("LEFT JOIN tb_approval_instance AS ai ON ai.id = r.approval_instance_id").
Joins(`LEFT JOIN LATERAL (
SELECT string_agg(oi.package_name, ',' ORDER BY oi.id) AS package_names
FROM tb_order_item AS oi
WHERE oi.order_id = r.order_id AND oi.deleted_at IS NULL
) AS items ON TRUE`).
Order("r.id ASC").
Limit(limit).
Offset(offset)
if err := query.Scan(&items).Error; err != nil {
return nil, err
}
rows := make([][]string, 0, len(items))
for _, item := range items {
rows = append(rows, []string{
item.RefundNo,
item.ShopName,
item.OrderNo,
formatRefundAssetType(item.OrderType),
item.AssetIdentifier,
item.PackageName,
formatOptionalMoneyYuan(item.OriginalAmount),
formatMoneyYuan(item.ActualReceivedAmount),
formatOptionalMoneyYuan(item.RefundableAmount),
formatMoneyYuan(item.RequestedRefundAmount),
formatOptionalMoneyYuan(item.ApprovedRefundAmount),
constants.GetRefundStatusName(item.Status),
item.RefundReason,
item.Remark,
formatRefundApprovalSource(item.ApprovalProvider),
formatOptionalApprovalStatus(item.ApprovalStatus),
formatRefundProcessingStatus(item.Status, item.CommissionDeducted, item.AssetReset),
item.CreatedAt.Format(exportTimeLayout),
formatOptionalTime(item.ProcessedAt),
item.SubmitterName,
item.VoucherKeys,
})
}
return rows, nil
}
func (s *RefundDataSource) baseQuery(ctx context.Context) *gorm.DB {
return s.db.WithContext(ctx).Table("tb_refund_request AS r").Where("r.deleted_at IS NULL")
}
func (s *RefundDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
query = applyExportShopScope(query, params, "r.shop_id")
if status, ok := filterInt(params.Filters, "status"); ok {
query = query.Where("r.status = ?", status)
}
if orderID, ok := filterUint(params.Filters, "order_id"); ok {
query = query.Where("r.order_id = ?", orderID)
}
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
query = query.Where("r.shop_id = ?", shopID)
}
if identifier, ok := filterString(params.Filters, "asset_identifier"); ok {
query = query.Where("r.asset_identifier = ?", identifier)
}
return query
}
type refundExportRow struct {
RefundNo string `gorm:"column:refund_no"`
ShopName string `gorm:"column:shop_name"`
OrderNo string `gorm:"column:order_no"`
OrderType string `gorm:"column:order_type"`
AssetIdentifier string `gorm:"column:asset_identifier"`
PackageName string `gorm:"column:package_name"`
OriginalAmount *int64 `gorm:"column:original_amount"`
ActualReceivedAmount int64 `gorm:"column:actual_received_amount"`
RefundableAmount *int64 `gorm:"column:refundable_amount"`
RequestedRefundAmount int64 `gorm:"column:requested_refund_amount"`
ApprovedRefundAmount *int64 `gorm:"column:approved_refund_amount"`
Status int `gorm:"column:status"`
RefundReason string `gorm:"column:refund_reason"`
Remark string `gorm:"column:remark"`
ApprovalProvider *string `gorm:"column:approval_provider"`
ApprovalStatus *int `gorm:"column:approval_status"`
CommissionDeducted bool `gorm:"column:commission_deducted"`
AssetReset bool `gorm:"column:asset_reset"`
CreatedAt time.Time `gorm:"column:created_at"`
ProcessedAt *time.Time `gorm:"column:processed_at"`
SubmitterName string `gorm:"column:submitter_name"`
VoucherKeys string `gorm:"column:voucher_keys"`
}
func formatRefundAssetType(orderType string) string {
switch orderType {
case model.OrderTypeSingleCard:
return "物联网卡"
case model.OrderTypeDevice:
return "设备"
case "":
return ""
default:
return "未知"
}
}
func formatRefundApprovalSource(provider *string) string {
if provider == nil || *provider == "" {
return "历史审批"
}
return formatApprovalProvider(provider)
}
func formatRefundProcessingStatus(status int, commissionDeducted, assetReset bool) string {
switch status {
case model.RefundStatusPending, model.RefundStatusReturned:
return "待审批"
case model.RefundStatusRejected:
return "无需处理"
case model.RefundStatusApproved:
if commissionDeducted && assetReset {
return "已完成"
}
return "处理中"
default:
return "未知"
}
}

View File

@@ -31,6 +31,11 @@ func NewDefaultRegistry(db *gorm.DB) *Registry {
NewDeviceDataSource(db),
NewIotCardDataSource(db),
NewOrderDataSource(db),
NewPackageDataSource(db),
NewAgentWalletTransactionDataSource(db),
NewAgentRechargeDataSource(db),
NewRefundDataSource(db),
NewExchangeDataSource(db),
)
}
@@ -59,7 +64,14 @@ func (r *Registry) Scenes() []string {
// IsSupportedScene 判断是否为受支持的场景。
func IsSupportedScene(scene string) bool {
switch scene {
case constants.ExportTaskSceneDevice, constants.ExportTaskSceneIotCard, constants.ExportTaskSceneOrder:
case constants.ExportTaskSceneDevice,
constants.ExportTaskSceneIotCard,
constants.ExportTaskSceneOrder,
constants.ExportTaskScenePackage,
constants.ExportTaskSceneAgentWalletTransaction,
constants.ExportTaskSceneAgentRecharge,
constants.ExportTaskSceneRefund,
constants.ExportTaskSceneExchange:
return true
default:
return false

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

@@ -38,6 +38,22 @@ type Client struct {
maxRetries int
}
type attemptObserverKey struct{}
// AttemptObserver 记录一次 Gateway HTTP 尝试的开始和结果。
type AttemptObserver interface {
BeforeAttempt(ctx context.Context, attempt int) error
AfterAttempt(ctx context.Context, attempt int, callErr error) error
}
// WithAttemptObserver 为当前 Gateway 调用注入逐次 HTTP 尝试观察器。
func WithAttemptObserver(ctx context.Context, observer AttemptObserver) context.Context {
if observer == nil {
return ctx
}
return context.WithValue(ctx, attemptObserverKey{}, observer)
}
// requestWrapper 用于将请求参数包装为 Gateway 的 {"params": ...} 格式
type requestWrapper struct {
Params interface{} `json:"params"`
@@ -101,6 +117,7 @@ func (c *Client) doRequest(ctx context.Context, path string, params interface{})
// 带重试的 HTTP 请求
var lastErr error
observer, _ := ctx.Value(attemptObserverKey{}).(AttemptObserver)
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
// 检查用户 Context 是否已取消
@@ -120,7 +137,18 @@ func (c *Client) doRequest(ctx context.Context, path string, params interface{})
time.Sleep(delay)
}
attemptNumber := attempt + 1
if observer != nil {
if err := observer.BeforeAttempt(ctx, attemptNumber); err != nil {
return nil, err
}
}
result, retryable, err := c.executeHTTPRequest(ctx, path, encryptedData)
if observer != nil {
if observeErr := observer.AfterAttempt(ctx, attemptNumber, err); observeErr != nil {
return nil, observeErr
}
}
if err != nil {
lastErr = err
// 仅对网络级错误重试

View File

@@ -27,14 +27,6 @@ func (c *Client) GetSlotInfo(ctx context.Context, req *DeviceInfoReq) (*SlotInfo
return doRequestWithResponse[SlotInfoResp](c, ctx, "/device/slot-info", req)
}
// SetSpeedLimit 设置设备限速
// 设置设备的统一限速值(单位 KB/s
// POST /device/speed-limit
func (c *Client) SetSpeedLimit(ctx context.Context, req *SpeedLimitReq) error {
_, err := c.doRequest(ctx, "/device/speed-limit", req)
return err
}
// SetWiFi 设置设备 WiFi
// Gateway 实际要求 cardNo 传设备 IMEI并搭配内层 params 下发 WiFi 名称和密码
// POST /device/wifi-config

View File

@@ -45,6 +45,19 @@ func (c *Client) GetRealnameLink(ctx context.Context, req *CardStatusReq) (*Real
return doRequestWithResponse[RealnameLinkResp](c, ctx, "/flow-card/RealNameVerification", req)
}
// SetCardSpeedTier 设置或恢复流量卡固定限速档位。
// POST /flow-card/speedLimit
func (c *Client) SetCardSpeedTier(ctx context.Context, req *CardSpeedTierReq) error {
if req == nil || req.CardNo == "" || req.Code == "" {
return errors.New(errors.CodeInvalidParam, "流量卡号和限速档位不能为空")
}
// 限速是外部状态写入,网络错误或超时后实际结果不可确定,禁止沿用查询接口的自动重试。
requestClient := *c
requestClient.maxRetries = 0
_, err := requestClient.doRequest(ctx, "/flow-card/speedLimit", req)
return err
}
// BatchQuery 批量查询(预留接口,暂未实现)
func (c *Client) BatchQuery(ctx context.Context, req *BatchQueryReq) (*BatchQueryResp, error) {
return nil, errors.New(errors.CodeGatewayError, "批量查询接口暂未实现")

View File

@@ -187,12 +187,10 @@ type DeviceInfoResp struct {
Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
}
// SpeedLimitReq 是设置设备限速的请求
type SpeedLimitReq struct {
CardNo string `json:"cardNo,omitempty" description:"流量卡号(与 DeviceID 二选一)"`
DeviceID string `json:"deviceId,omitempty" description:"设备 ID/IMEI与 CardNo 二选一)"`
SpeedLimit int `json:"speedLimit" validate:"required,min=1" required:"true" minimum:"1" description:"限速值KB/s"`
Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
// CardSpeedTierReq 是流量卡固定限速档位请求
type CardSpeedTierReq struct {
CardNo string `json:"cardNo" validate:"required" required:"true" description:"流量卡 ICCID"`
Code string `json:"code" validate:"required" required:"true" description:"限速档位编码 (-1:恢复不限速, 0:0kbps, 1:128Kbps, 2:512Kbps, 3:1Mbps, 4:2Mbps, 5:10Mbps, 6:20Mbps, 7:50Mbps, 8:100Mbps)"`
}
// WiFiParams 是设置设备 WiFi 的内层参数

View File

@@ -0,0 +1,576 @@
// Package auditcoverage 提供全系统入口的可复核审计覆盖扫描。
package auditcoverage
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/bytedance/sonic"
)
// Entry 是一个必须经过审计分类的 HTTP、Worker 或定时任务入口。
type Entry struct {
Key string `json:"key"`
Kind string `json:"kind"`
CodeEntry string `json:"code_entry"`
Owner string `json:"owner"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Summary string `json:"summary"`
AuditEvent string `json:"audit_event"`
DomainLedger string `json:"domain_ledger"`
IntegrationLog string `json:"integration_log"`
Outbox string `json:"outbox"`
ActionCode string `json:"action_code,omitempty"`
ActionName string `json:"action_name,omitempty"`
Category string `json:"category,omitempty"`
Risk string `json:"risk,omitempty"`
PrimaryResource string `json:"primary_resource,omitempty"`
AffectedResource string `json:"affected_resource,omitempty"`
ActorSource string `json:"actor_source"`
Visibility string `json:"visibility"`
Transaction string `json:"transaction"`
FailureStrategy string `json:"failure_strategy"`
SensitivePolicy string `json:"sensitive_policy"`
BeforeAfterPolicy string `json:"before_after_policy"`
TestSeam string `json:"test_seam"`
NAReason string `json:"na_reason,omitempty"`
}
// Scan 扫描当前仓库中对外 HTTP、Asynq Worker 和定时任务注册入口。
func Scan(root string) ([]Entry, error) {
var entries []Entry
files := []string{
"internal/routes", "internal/application", "internal/domain", "internal/service",
"internal/handler", "internal/infrastructure", "internal/polling", "pkg/queue", "cmd/worker",
}
for _, directory := range files {
err := filepath.Walk(filepath.Join(root, directory), func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
if info.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
found, err := scanFile(root, path)
if err != nil {
return err
}
entries = append(entries, found...)
return nil
})
if err != nil {
return nil, err
}
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Key < entries[j].Key })
return entries, nil
}
// LoadManifest 读取经评审的显式覆盖快照。
func LoadManifest(path string) ([]Entry, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var entries []Entry
if err := sonic.Unmarshal(data, &entries); err != nil {
return nil, err
}
return entries, nil
}
// MarshalManifest 将扫描结果输出为稳定、便于评审的 JSON。
func MarshalManifest(entries []Entry) ([]byte, error) {
return sonic.ConfigStd.MarshalIndent(entries, "", " ")
}
func scanFile(root, path string) ([]Entry, error) {
set := token.NewFileSet()
file, err := parser.ParseFile(set, path, nil, 0)
if err != nil {
return nil, err
}
relative, err := filepath.Rel(root, path)
if err != nil {
return nil, err
}
var entries []Entry
ast.Inspect(file, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
if !ok {
return true
}
position := set.Position(call.Pos())
if identifier, ok := call.Fun.(*ast.Ident); ok && identifier.Name == "Register" && len(call.Args) >= 7 {
method, methodOK := stringLiteral(call.Args[3])
pathSuffix, pathOK := stringLiteral(call.Args[4])
if !pathOK {
pathSuffix = expression(call.Args[4])
}
if methodOK {
entry := classifyHTTP(relative, position.Line, method, pathSuffix, expression(call.Args[5]), routeSummary(call.Args[6]))
entries = append(entries, entry)
}
return true
}
selector, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
switch selector.Sel.Name {
case "HandleFunc":
if len(call.Args) >= 2 {
entries = append(entries, classifyWorker(relative, position.Line, expression(call.Args[0]), expression(call.Args[1])))
}
case "Register":
if strings.HasPrefix(relative, "cmd/worker/") {
if taskType, schedule, ok := scheduledTask(call); ok {
entries = append(entries, classifySchedule(relative, position.Line, taskType, schedule))
} else if strings.Contains(expression(selector.X), "outboxConsumers") && len(call.Args) >= 2 {
entries = append(entries, classifyOutboxConsumer(relative, position.Line, expression(call.Args[0]), expression(call.Args[1])))
}
}
case "LogOperation":
entries = append(entries, classifyLegacyWriter(relative, position.Line, expression(call.Fun)))
case "Start", "Complete", "RecordInbound", "ClaimExpiredInboundPending":
if selector.Sel.Name == "ClaimExpiredInboundPending" || isIntegrationLogCall(relative, expression(selector.X)) {
entries = append(entries, classifyIntegrationLog(relative, position.Line, expression(call.Fun)))
}
}
return true
})
if strings.HasPrefix(relative, "internal/application/") || strings.HasPrefix(relative, "internal/domain/") ||
strings.HasPrefix(relative, "internal/service/") {
for _, declaration := range file.Decls {
function, ok := declaration.(*ast.FuncDecl)
if !ok || function.Recv == nil || !isBusinessMethod(function) {
continue
}
position := set.Position(function.Pos())
entries = append(entries, classifyBusinessMethod(relative, position.Line, function.Name.Name))
}
}
return entries, nil
}
func classifyHTTP(file string, line int, method, path, handler, summary string) Entry {
owner := strings.TrimSuffix(filepath.Base(file), ".go")
if summary == "" {
summary = handler
}
entry := Entry{
Key: fmt.Sprintf("http:%s:%d:%s:%s", file, line, method, path), Kind: "http",
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, handler), Owner: owner,
Method: method, Path: path, Summary: summary, ActorSource: httpActorSource(file, path, handler),
DomainLedger: ledgerDecision(owner), IntegrationLog: integrationDecision(file, path),
Outbox: "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A",
Visibility: httpVisibility(file, path, handler),
SensitivePolicy: "禁止字段删除手机号、IP、ICCID、金额和第三方单号按权限脱敏单字段 16KB 上限",
BeforeAfterPolicy: "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用",
TestSeam: "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口",
}
if isReadOnlyHTTP(method, path) && !isSensitiveRead(file, path, summary) {
entry.AuditEvent = "N/A"
entry.Transaction = "N/A"
entry.FailureStrategy = "Access Log 记录统一错误;普通读取不创建业务审计"
entry.NAReason = "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值"
return entry
}
entry.AuditEvent = "必须"
entry.ActionCode = actionCode(owner, handler)
entry.ActionName = summary
entry.Category = categoryFor(owner)
entry.Risk = riskFor(owner, path, summary)
entry.PrimaryResource = owner
entry.AffectedResource = "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展"
entry.Transaction = "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入"
entry.FailureStrategy = "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical"
return entry
}
func classifyWorker(file string, line int, taskType, handler string) Entry {
owner := workerOwner(taskType)
entry := Entry{
Key: fmt.Sprintf("worker:%s:%d:%s", file, line, taskType), Kind: "worker",
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, handler), Owner: owner,
Summary: "处理异步任务 " + taskType, AuditEvent: "按状态变化、人工触发、连续失败或高风险异常决定",
DomainLedger: ledgerDecision(owner), IntegrationLog: workerIntegrationDecision(taskType),
Outbox: "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等",
ActionCode: actionCode(owner, handler), ActionName: "处理异步任务(" + taskType + "",
Category: categoryFor(owner), Risk: riskFor(owner, taskType, handler),
PrimaryResource: owner, AffectedResource: "任务载荷定位的直接业务资源",
ActorSource: "system_task/asynq", Transaction: "业务状态变化、领域流水和 Audit Event 按用例原子提交",
Visibility: "内部系统入口;外部主体只读取对应业务安全投影",
FailureStrategy: "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计",
SensitivePolicy: "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL",
BeforeAfterPolicy: "状态变化保存直接前后值;无业务变化时仅保留 Integration Log",
TestSeam: "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费幂等数据核对;覆盖门禁静态比对本入口",
}
return entry
}
func classifySchedule(file string, line int, taskType, schedule string) Entry {
return Entry{
Key: fmt.Sprintf("schedule:%s:%d:%s", file, line, taskType), Kind: "scheduled_job",
CodeEntry: fmt.Sprintf("%s:%d", file, line), Owner: workerOwner(taskType), Summary: "按 " + schedule + " 调度 " + taskType,
AuditEvent: "N/A", DomainLedger: "N/A", IntegrationLog: "N/A", Outbox: "N/A",
ActorSource: "system_task/scheduled_job", Transaction: "N/A",
Visibility: "内部系统入口,不直接对用户展示",
FailureStrategy: "调度注册失败阻止 Worker 启动;执行结果由对应 Worker 入口负责",
SensitivePolicy: "调度日志仅记录任务类型与安全时间信息",
BeforeAfterPolicy: "N/A调度入口不修改业务事实",
TestSeam: "调度注册公开函数 + 覆盖门禁静态比对本入口",
NAReason: "本入口只产生调度信号,不直接读取或修改业务事实;审计责任位于对应 Worker",
}
}
func classifyOutboxConsumer(file string, line int, eventType, consumer string) Entry {
return Entry{
Key: fmt.Sprintf("outbox_consumer:%s:%d:%s", file, line, eventType), Kind: "outbox_consumer",
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, consumer), Owner: workerOwner(eventType),
Summary: "注册 Outbox 消费者 " + eventType,
AuditEvent: "N/A", DomainLedger: "N/A", IntegrationLog: "N/A", Outbox: "必须:消费已提交的可靠事件",
ActorSource: "system_task/outbox_consumer", Transaction: "N/A",
Visibility: "内部系统装配入口,不直接对用户展示",
FailureStrategy: "注册失败阻止 Worker 启动;实际消费失败由 Outbox 重试,业务审计由消费者用例负责",
SensitivePolicy: "注册入口不读取或记录事件载荷与安全凭据",
BeforeAfterPolicy: "N/A注册入口不修改业务事实",
TestSeam: "静态扫描注册点、消费者实现和对应业务动作",
NAReason: "本入口只注册事件类型与消费者;实际业务事实和 Audit Event 由对应 Consumer/Application 完整用例负责",
}
}
func classifyBusinessMethod(file string, line int, method string) Entry {
parts := strings.Split(file, "/")
layer := parts[1]
owner := strings.TrimSuffix(filepath.Base(filepath.Dir(file)), ".go")
if owner == "service" || owner == "application" || owner == "domain" {
owner = strings.TrimSuffix(filepath.Base(file), ".go")
}
entry := Entry{
Key: fmt.Sprintf("%s:%s:%d:%s", layer, file, line, method), Kind: layer,
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, method), Owner: owner,
Summary: "业务方法 " + method, DomainLedger: ledgerDecision(owner),
IntegrationLog: businessIntegrationDecision(file, method),
Outbox: "存在提交后可靠副作用时必须在同一事务追加;否则 N/A",
ActionCode: actionCode(owner, method), Risk: riskFor(owner, file, method),
PrimaryResource: owner, AffectedResource: "完整用例直接修改或引用的资源",
ActorSource: "由调用入口传入操作者与来源快照",
Visibility: "由完整用例决定平台完整视图、主体安全投影或 internal_only",
SensitivePolicy: "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品",
BeforeAfterPolicy: "完整用例保存脱敏后的直接业务变化Domain 方法由 Application 投影",
TestSeam: "Application/Service 公共方法 + PostgreSQL 事实Domain 使用静态检查与数据核对;覆盖门禁静态比对本入口",
}
if layer == "domain" {
entry.AuditEvent = "N/A"
entry.Transaction = "由 Application 组合根负责"
entry.FailureStrategy = "返回领域错误,由 Application 在回滚后裁决 failed/denied 审计"
entry.NAReason = "Domain 只维护业务不变量和领域事实不依赖审计基础设施Audit Event 由 Application 写入"
entry.ActionCode = ""
entry.ActionName = ""
entry.Category = ""
entry.Risk = ""
entry.PrimaryResource = ""
return entry
}
entry.AuditEvent = "必须"
entry.ActionName = "执行业务方法(" + method + ""
entry.Category = categoryFor(owner)
entry.Transaction = "关键成功事件与业务事实同一 GORM 事务"
entry.FailureStrategy = "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical"
return entry
}
func classifyLegacyWriter(file string, line int, call string) Entry {
return Entry{
Key: fmt.Sprintf("legacy_writer:%s:%d:%s", file, line, call), Kind: "legacy_writer",
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, call), Owner: filepath.Base(filepath.Dir(file)),
Summary: "调用旧 Operation Log Writer", AuditEvent: "必须迁移到统一 Audit Event 后停写旧表",
DomainLedger: "既有业务表仍是权威事实,旧 Operation Log 不是 Domain Ledger",
IntegrationLog: "N/A旧 Writer 仅记录内部操作;实际外部交互由 Integration Log 单独记录",
Outbox: "由原完整用例决定,旧 Writer 不得替代 Outbox",
ActionCode: actionCode(filepath.Base(filepath.Dir(file)), call), ActionName: "迁移旧审计写入",
Category: categoryFor(file), Risk: riskFor(file, call, ""), PrimaryResource: filepath.Base(filepath.Dir(file)),
AffectedResource: "按原完整业务用例登记实际资源", ActorSource: "沿用原调用入口真实操作者",
Visibility: "旧表仅保留平台历史入口;新事件按 Registry 生成主体安全投影",
Transaction: "迁移后关键成功与业务事实同事务,旧异步 Writer 停写",
FailureStrategy: "迁移后业务回滚的 failed/denied 使用独立短事务;禁止裸 goroutine",
SensitivePolicy: "迁移时删除密码、Token、Secret、私钥、Cookie、签名 URL 等安全凭据",
BeforeAfterPolicy: "按资源保存本次直接变化,不复制旧单体 JSON",
TestSeam: "静态调用归零扫描 + 对应业务入口与数据库抽样核对",
}
}
func classifyIntegrationLog(file string, line int, call string) Entry {
return Entry{
Key: fmt.Sprintf("integration_log:%s:%d:%s", file, line, call), Kind: "integration_log",
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, call), Owner: filepath.Base(filepath.Dir(file)),
Summary: "记录外部交互尝试或终态", AuditEvent: "N/A",
DomainLedger: "N/AIntegration Log 只记录外部交互事实,不替代内部业务表",
IntegrationLog: "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要",
Outbox: "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox",
ActorSource: "external_system 或发起外呼的真实 Application/Worker/Callback",
Visibility: "仅平台内部调查完整可见;代理/企业不得读取外部交互细节",
Transaction: "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event",
FailureStrategy: "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功",
SensitivePolicy: "请求、响应和 metadata 写入前删除凭据,历史读取再次清理",
BeforeAfterPolicy: "N/A保存外部尝试结构化摘要和本地状态是否变化",
TestSeam: "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对",
NAReason: "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event",
}
}
func routeSummary(expr ast.Expr) string {
composite, ok := expr.(*ast.CompositeLit)
if !ok {
return ""
}
for _, element := range composite.Elts {
pair, ok := element.(*ast.KeyValueExpr)
if !ok || expression(pair.Key) != "Summary" {
continue
}
value, _ := stringLiteral(pair.Value)
return value
}
return ""
}
func scheduledTask(call *ast.CallExpr) (string, string, bool) {
if len(call.Args) < 2 {
return "", "", false
}
schedule, _ := stringLiteral(call.Args[0])
taskCall, ok := call.Args[1].(*ast.CallExpr)
if !ok {
return "", "", false
}
selector, ok := taskCall.Fun.(*ast.SelectorExpr)
if !ok || selector.Sel.Name != "NewTask" || len(taskCall.Args) == 0 {
return "", "", false
}
return expression(taskCall.Args[0]), schedule, true
}
func stringLiteral(expr ast.Expr) (string, bool) {
literal, ok := expr.(*ast.BasicLit)
if !ok || literal.Kind != token.STRING {
return "", false
}
value, err := strconv.Unquote(literal.Value)
return value, err == nil
}
func expression(expr ast.Expr) string {
switch value := expr.(type) {
case *ast.Ident:
return value.Name
case *ast.SelectorExpr:
return expression(value.X) + "." + value.Sel.Name
case *ast.BasicLit:
return value.Value
case *ast.CallExpr:
return expression(value.Fun)
case *ast.BinaryExpr:
return expression(value.X) + value.Op.String() + expression(value.Y)
default:
return fmt.Sprintf("%T", expr)
}
}
func httpActorSource(file, path, handler string) string {
text := strings.ToLower(file + " " + path + " " + handler)
switch {
case strings.Contains(text, "callback") || strings.Contains(path, "/carriers/"):
return "external_system/callback"
case strings.HasSuffix(file, "personal.go"):
return "personal_customer/personal_api"
case strings.HasSuffix(file, "order.go"):
return "按路由分为 admin_user/admin_api 或外部回调入口"
default:
return "登录账号快照/admin_api"
}
}
func httpVisibility(file, path, handler string) string {
text := strings.ToLower(file + " " + path + " " + handler)
switch {
case strings.Contains(text, "callback"):
return "外部回调入口;只记录内部完整事实,不直接向外部主体展示"
case strings.Contains(text, "/audit"):
return "仅超级管理员和平台账号可见"
case strings.Contains(text, "enterprise"):
return "企业认证上下文范围内可见;内部审计字段不可见"
case strings.Contains(text, "personal"):
return "当前个人客户本人范围内可见"
default:
return "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离"
}
}
func isIntegrationLogCall(file, receiver string) bool {
if strings.Contains(file, "/integrationlog/") {
return false
}
receiver = strings.ToLower(receiver)
return strings.Contains(receiver, "integration")
}
func isSensitiveRead(file, path, summary string) bool {
text := strings.ToLower(file + " " + path + " " + summary)
for _, marker := range []string{"download", "realname-link", "realname/link", "实名链接", "敏感", "realtime-status"} {
if strings.Contains(text, marker) {
return true
}
}
return strings.HasSuffix(file, "wecom.go") && path == "/applications" ||
strings.HasSuffix(file, "export_task.go") && path == "/:id"
}
func isReadOnlyHTTP(method, path string) bool {
if method == "GET" {
return true
}
return strings.Contains(path, "purchase-check") || strings.Contains(path, "verify-asset")
}
func integrationDecision(file, path string) string {
text := strings.ToLower(file + " " + path)
if strings.Contains(text, "callback") || strings.HasSuffix(file, "order.go") &&
(strings.Contains(path, "pay") || strings.Contains(path, "alipay")) {
return "必须:业务处理前保存入站安全摘要与幂等标识"
}
return "无外部交互时 N/A用例调用 Gateway、支付、企微或运营商时必须"
}
func workerIntegrationDecision(taskType string) string {
text := strings.ToLower(taskType)
if strings.Contains(text, "polling") {
return "必须:每次实际请求或未发送裁决均记录"
}
return "Worker 调用外部系统时必须;纯本地处理 N/A"
}
func businessIntegrationDecision(file, method string) string {
text := strings.ToLower(file + " " + method)
for _, marker := range []string{"polling", "gateway", "payment", "wechat", "wecom", "carrier", "sms"} {
if strings.Contains(text, marker) {
return "调用外部系统或处理回调时必须;纯本地分支 N/A"
}
}
return "N/A当前方法按代码位置属于本地业务用例后续新增外部调用必须重新分类"
}
func ledgerDecision(owner string) string {
for _, marker := range []string{"order", "recharge", "refund", "commission", "wallet"} {
if strings.Contains(owner, marker) {
return "必须:订单、充值、退款、钱包流水等既有业务表是领域权威"
}
}
if strings.Contains(owner, "import") || strings.Contains(owner, "export") {
return "业务任务及明细表是批量结果权威"
}
return "既有业务表是状态事实Audit Event 不替代业务模型"
}
func riskFor(owner, path, summary string) string {
text := strings.ToLower(owner + " " + path + " " + summary)
for _, marker := range []string{"wallet", "refund", "recharge", "permission", "role", "password", "config", "权限", "资金", "退款", "充值"} {
if strings.Contains(text, marker) {
return "high"
}
}
return "normal"
}
func categoryFor(owner string) string {
text := strings.ToLower(owner)
for _, marker := range []string{"wallet", "refund", "recharge", "commission", "order"} {
if strings.Contains(text, marker) {
return "finance"
}
}
for _, marker := range []string{"account", "role", "permission", "auth"} {
if strings.Contains(text, marker) {
return "security"
}
}
for _, marker := range []string{"card", "device", "asset", "polling"} {
if strings.Contains(text, marker) {
return "asset"
}
}
return "business"
}
func actionCode(owner, handler string) string {
method := handler
if index := strings.LastIndex(method, "."); index >= 0 {
method = method[index+1:]
}
return normalize(owner) + "." + normalize(method)
}
func workerOwner(taskType string) string {
return normalize(strings.TrimPrefix(taskType, "constants.TaskType"))
}
func isBusinessMethod(function *ast.FuncDecl) bool {
name := function.Name.Name
if strings.HasPrefix(name, "Set") && !hasContextParameter(function) {
return false
}
for _, prefix := range []string{
"Create", "Update", "Delete", "Set", "Assign", "Remove", "Cancel", "Reject", "Approve",
"Import", "Allocate", "Recall", "Stop", "Resume", "Bind", "Unbind", "Reset", "Activate",
"Deactivate", "Trigger", "Handle", "Process", "Execute", "Replay", "Release", "Change", "Pay",
"Refund", "Recharge", "Withdraw", "Grant", "Revoke", "Deduct", "Credit", "Debit", "Freeze",
"Unfreeze", "Resolve", "Expire", "Invalidate", "Archive", "Cleanup", "Adjust", "Add", "Batch",
"Enable", "Disable", "Login", "Logout", "Refresh", "Upload", "Download", "Save", "Restore",
"Submit", "Sync", "Migrate", "Send",
} {
if strings.HasPrefix(name, prefix) {
return true
}
}
return false
}
func hasContextParameter(function *ast.FuncDecl) bool {
if function.Type.Params == nil {
return false
}
for _, field := range function.Type.Params.List {
selector, ok := field.Type.(*ast.SelectorExpr)
if ok && expression(selector) == "context.Context" {
return true
}
}
return false
}
func normalize(value string) string {
value = strings.Trim(value, "\"")
var output []rune
for index, current := range []rune(value) {
if current >= 'A' && current <= 'Z' {
if index > 0 {
output = append(output, '_')
}
current += 'a' - 'A'
}
if current == '-' || current == ':' || current == '/' {
current = '_'
}
output = append(output, current)
}
return strings.Trim(strings.ReplaceAll(string(output), "__", "_"), "_")
}

View File

@@ -77,6 +77,24 @@ func (h *AccountHandler) Update(c *fiber.Ctx) error {
return response.Success(c, account)
}
// BindWeCom 绑定账号与企业微信应用可见成员。
// PUT /api/admin/accounts/:id/wecom-binding
func (h *AccountHandler) BindWeCom(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 request dto.BindAccountWeComRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.BindWeCom(c.UserContext(), uint(id), request)
if err != nil {
return err
}
return response.Success(c, result)
}
// Delete 删除账号
// DELETE /api/admin/accounts/:id
func (h *AccountHandler) Delete(c *fiber.Ctx) error {

View File

@@ -1,23 +1,42 @@
package admin
import (
"bytes"
"strconv"
"strings"
"github.com/bytedance/sonic"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
agentrechargeapp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
agentrechargequery "github.com/break/junhong_cmp_fiber/internal/query/agentrecharge"
agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge"
"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/break/junhong_cmp_fiber/pkg/response"
)
// AgentRechargeHandler 代理预充值 Handler
type AgentRechargeHandler struct {
service *agentRechargeSvc.Service
online *agentrechargeapp.OnlineCreationService
status *agentrechargequery.PaymentStatusQuery
validator *validator.Validate
}
// SetOnlineCreationService 注入代理在线扫码充值用例。
func (h *AgentRechargeHandler) SetOnlineCreationService(service *agentrechargeapp.OnlineCreationService) {
h.online = service
}
// SetPaymentStatusQuery 注入代理充值本地支付状态 Query。
func (h *AgentRechargeHandler) SetPaymentStatusQuery(query *agentrechargequery.PaymentStatusQuery) {
h.status = query
}
// NewAgentRechargeHandler 创建代理预充值 Handler
func NewAgentRechargeHandler(service *agentRechargeSvc.Service, validator *validator.Validate) *AgentRechargeHandler {
return &AgentRechargeHandler{service: service, validator: validator}
@@ -27,13 +46,21 @@ func NewAgentRechargeHandler(service *agentRechargeSvc.Service, validator *valid
// POST /api/admin/agent-recharges
func (h *AgentRechargeHandler) Create(c *fiber.Ctx) error {
var req dto.CreateAgentRechargeRequest
if err := c.BodyParser(&req); err != nil {
decoder := sonic.ConfigStd.NewDecoder(bytes.NewReader(c.Body()))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if req.PaymentMethod == constants.RechargeMethodWechat || req.PaymentMethod == constants.RechargeMethodAlipay {
return h.createOnline(c, req)
}
if strings.TrimSpace(req.RequestID) != "" {
return errors.New(errors.CodeInvalidParam, "线下充值不能传入在线请求标识")
}
result, err := h.service.Create(c.UserContext(), &req)
if err != nil {
return err
@@ -42,6 +69,45 @@ func (h *AgentRechargeHandler) Create(c *fiber.Ctx) error {
return response.Success(c, result)
}
func (h *AgentRechargeHandler) createOnline(c *fiber.Ctx, req dto.CreateAgentRechargeRequest) error {
if h.online == nil {
return errors.New(errors.CodeServiceUnavailable, "代理在线充值能力未配置")
}
if req.ShopID != nil || len(req.PaymentVoucherKey) > 0 || strings.TrimSpace(req.Remark) != "" {
return errors.New(errors.CodeInvalidParam, "在线充值不能指定店铺、支付凭证或运营备注")
}
result, err := h.online.Execute(c.UserContext(), agentrechargeapp.CreateOnlineCommand{
AccountID: middleware.GetUserIDFromContext(c.UserContext()), UserType: middleware.GetUserTypeFromContext(c.UserContext()),
CurrentShopID: middleware.GetShopIDFromContext(c.UserContext()), Amount: req.Amount,
PaymentMethod: req.PaymentMethod, RequestID: req.RequestID, PayerClientIP: c.IP(),
})
if err != nil {
return err
}
rechargeSource, rechargeSourceName := constants.GetAgentRechargeSource(result.Payment.PaymentMethod)
return response.Success(c, &dto.AgentRechargeOnlineResponse{
RechargeID: result.Recharge.ID, RechargeNo: result.Recharge.RechargeNo, PaymentNo: result.Payment.PaymentNo,
PaymentMethod: result.Payment.PaymentMethod, Amount: result.Payment.Amount, QRContent: result.Payment.QRContent,
RechargeSource: rechargeSource, RechargeSourceName: rechargeSourceName,
Status: result.Recharge.Status, StatusName: constants.GetRechargeStatusName(result.Recharge.Status),
})
}
// PaymentMethods 查询代理在线充值可用支付方式。
// GET /api/admin/agent-recharges/payment-methods
func (h *AgentRechargeHandler) PaymentMethods(c *fiber.Ctx) error {
if h.online == nil {
return errors.New(errors.CodeServiceUnavailable, "代理在线充值能力未配置")
}
result, err := h.online.AvailablePaymentMethods(c.UserContext(), middleware.GetUserTypeFromContext(c.UserContext()))
if err != nil {
return err
}
return response.Success(c, &dto.AgentRechargePaymentMethodsResponse{
Methods: result.Methods, MinAmount: result.MinAmount, MaxAmount: result.MaxAmount,
})
}
// List 查询代理充值订单列表
// GET /api/admin/agent-recharges
func (h *AgentRechargeHandler) List(c *fiber.Ctx) error {
@@ -49,6 +115,9 @@ func (h *AgentRechargeHandler) List(c *fiber.Ctx) error {
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
list, total, err := h.service.List(c.UserContext(), &req)
if err != nil {
@@ -74,6 +143,23 @@ func (h *AgentRechargeHandler) Get(c *fiber.Ctx) error {
return response.Success(c, result)
}
// PaymentStatus 查询代理充值本地支付与到账状态。
// GET /api/admin/agent-recharges/:id/payment-status
func (h *AgentRechargeHandler) PaymentStatus(c *fiber.Ctx) error {
if h.status == nil {
return errors.New(errors.CodeServiceUnavailable, "代理充值支付状态查询未配置")
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "无效的充值记录ID")
}
result, err := h.status.Get(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// Reject 驳回代理充值订单
// POST /api/admin/agent-recharges/:id/reject
func (h *AgentRechargeHandler) Reject(c *fiber.Ctx) error {

View File

@@ -1,13 +1,17 @@
package admin
import (
"context"
"strconv"
"strings"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/gofiber/fiber/v2"
"github.com/hibiken/asynq"
dto "github.com/break/junhong_cmp_fiber/internal/model/dto"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
deviceService "github.com/break/junhong_cmp_fiber/internal/service/device"
@@ -15,8 +19,11 @@ import (
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"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/queue"
"github.com/break/junhong_cmp_fiber/pkg/response"
"go.uber.org/zap"
)
// AssetHandler 资产管理处理器
@@ -29,6 +36,42 @@ type AssetHandler struct {
iotCardStopResume *iotCardService.StopResumeService
assetPolling *pollingSvc.AssetPollingService
assetLifecycleService AssetLifecycleService
exchangeTraceQuery AssetExchangeTraceResolver
observationSeries cardObservationApp.BestEffortSeriesDispatcher
packageExpiryQuery *packageExpiryQuery.Query
packageExpiryTrigger func(context.Context) error
}
// SetObservationSeriesDispatcher 注入后台实时状态的观测序列端口。
func (h *AssetHandler) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) {
h.observationSeries = dispatcher
}
// SetPackageExpiryQuery 注入临期资产分页查询。
func (h *AssetHandler) SetPackageExpiryQuery(query *packageExpiryQuery.Query) {
h.packageExpiryQuery = query
}
// SetPackageExpiryQueue 注入套餐临期提醒任务队列。
func (h *AssetHandler) SetPackageExpiryQueue(client *queue.Client) {
if client == nil {
h.packageExpiryTrigger = nil
return
}
h.packageExpiryTrigger = func(ctx context.Context) error {
return client.EnqueueTask(
ctx,
constants.TaskTypePackageExpiryReminder,
struct{}{},
asynq.MaxRetry(3),
asynq.Timeout(10*time.Minute),
)
}
}
// AssetExchangeTraceResolver 定义资产详情换货链路读取用例。
type AssetExchangeTraceResolver interface {
Resolve(ctx context.Context, assetType string, assetID uint) (*dto.AssetExchangeTrace, error)
}
// NewAssetHandler 创建资产管理处理器
@@ -39,14 +82,16 @@ func NewAssetHandler(
iotCardSvc *iotCardService.Service,
iotCardStopResume *iotCardService.StopResumeService,
assetPolling *pollingSvc.AssetPollingService,
exchangeTraceQuery AssetExchangeTraceResolver,
) *AssetHandler {
return &AssetHandler{
assetService: assetSvc,
assetAuditService: assetAuditService,
deviceService: deviceSvc,
iotCardService: iotCardSvc,
iotCardStopResume: iotCardStopResume,
assetPolling: assetPolling,
assetService: assetSvc,
assetAuditService: assetAuditService,
deviceService: deviceSvc,
iotCardService: iotCardSvc,
iotCardStopResume: iotCardStopResume,
assetPolling: assetPolling,
exchangeTraceQuery: exchangeTraceQuery,
}
}
@@ -78,10 +123,59 @@ func (h *AssetHandler) Resolve(c *fiber.Ctx) error {
if err != nil {
return err
}
result.ExchangeTrace = &dto.AssetExchangeTrace{}
if h.exchangeTraceQuery != nil {
result.ExchangeTrace, err = h.exchangeTraceQuery.Resolve(c.UserContext(), result.AssetType, result.AssetID)
if err != nil {
return err
}
}
return response.Success(c, result)
}
// ListExpiring 查询当前权限范围内的临期资产列表和数量汇总。
// GET /api/admin/expiring-assets
func (h *AssetHandler) ListExpiring(c *fiber.Ctx) error {
var request dto.ExpiringAssetListRequest
if err := c.QueryParser(&request); err != nil {
logger.GetAppLogger().Warn("临期资产列表参数解析失败",
zap.String("method", c.Method()), zap.String("path", c.Path()), zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
if h.packageExpiryQuery == nil {
return errors.New(errors.CodeInternalError, "套餐临期查询未配置")
}
result, err := h.packageExpiryQuery.List(c.UserContext(), request)
if err != nil {
return err
}
return response.Success(c, dto.ExpiringAssetListResponse{
Items: result.Items, Total: result.Total, Page: result.Page, Size: result.Size, Summary: result.Summary,
})
}
// TriggerPackageExpiryReminder 手动提交每日临期提醒扫描任务。
// POST /api/admin/expiring-assets/reminder-scan
func (h *AssetHandler) TriggerPackageExpiryReminder(c *fiber.Ctx) error {
if middleware.GetUserTypeFromContext(c.UserContext()) != constants.UserTypeSuperAdmin {
return errors.New(errors.CodeForbidden)
}
if h.packageExpiryTrigger == nil {
return errors.New(errors.CodeServiceUnavailable, "每日临期提醒扫描任务队列未配置")
}
if err := h.packageExpiryTrigger(c.UserContext()); err != nil {
logger.GetAppLogger().Error("手动提交每日临期提醒扫描任务失败", zap.Error(err))
return errors.Wrap(errors.CodeTaskQueueError, err, "提交每日临期提醒扫描任务失败")
}
logger.GetAppLogger().Info("已手动提交每日临期提醒扫描任务",
zap.Uint("operator_id", middleware.GetUserIDFromContext(c.UserContext())))
return response.Success(c, dto.TriggerPackageExpiryReminderResponse{
TaskType: constants.TaskTypePackageExpiryReminder,
Message: "每日临期提醒扫描任务已提交",
})
}
// RealtimeStatus 获取资产实时状态
// GET /api/admin/assets/:identifier/realtime-status
func (h *AssetHandler) RealtimeStatus(c *fiber.Ctx) error {
@@ -94,10 +188,40 @@ func (h *AssetHandler) RealtimeStatus(c *fiber.Ctx) error {
if err != nil {
return err
}
h.dispatchRealtimeObservations(c.UserContext(), result)
return response.Success(c, result)
}
func (h *AssetHandler) dispatchRealtimeObservations(ctx context.Context, result *dto.AssetRealtimeStatusResponse) {
if h.observationSeries == nil || result == nil {
return
}
cardIDs := make([]uint, 0, len(result.Cards)+1)
if result.AssetType == "card" {
cardIDs = append(cardIDs, result.AssetID)
} else {
for _, card := range result.Cards {
cardIDs = append(cardIDs, card.CardID)
}
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
for _, cardID := range cardIDs {
resourceID := strconv.FormatUint(uint64(cardID), 10)
for _, syncType := range []string{constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic, constants.CardObservationSyncTypeNetwork} {
h.observationSeries.Dispatch(ctx, cardObservationApp.SeriesRequest{
Scene: constants.CardObservationSceneAdminAssetRead,
ResourceType: constants.CardObservationResourceTypeCard, ResourceID: resourceID,
SyncType: syncType, Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
})
}
}
}
// Refresh 刷新资产状态(调网关同步)
// POST /api/admin/assets/:identifier/refresh
func (h *AssetHandler) Refresh(c *fiber.Ctx) error {
@@ -328,6 +452,10 @@ func (h *AssetHandler) Orders(c *fiber.Ctx) error {
// OperationLogs 查询资产操作审计日志
// GET /api/admin/assets/:identifier/operation-logs
func (h *AssetHandler) OperationLogs(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if h.assetAuditService == nil {
return errors.New(errors.CodeInternalError, "资产审计服务未配置")
}
@@ -410,6 +538,10 @@ func (h *AssetHandler) UpdatePollingStatus(c *fiber.Ctx) error {
// UpdateRealnamePolicy 更新资产实名认证策略
// PATCH /api/admin/assets/:identifier/realname-mode
func (h *AssetHandler) UpdateRealnamePolicy(c *fiber.Ctx) error {
if middleware.GetUserTypeFromContext(c.UserContext()) == constants.UserTypeEnterprise {
return errors.New(errors.CodeForbidden, "企业账号无权修改资产实名认证策略")
}
identifier := c.Params("identifier")
if identifier == "" {
return errors.New(errors.CodeInvalidParam)

View File

@@ -0,0 +1,92 @@
package admin
import (
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
batchOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_package_batch_order"
"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/break/junhong_cmp_fiber/pkg/response"
)
// AssetPackageBatchOrderHandler 资产套餐批量订购 Handler。
type AssetPackageBatchOrderHandler struct {
service *batchOrderSvc.Service
validator *validator.Validate
}
// NewAssetPackageBatchOrderHandler 创建资产套餐批量订购 Handler。
func NewAssetPackageBatchOrderHandler(service *batchOrderSvc.Service, validator *validator.Validate) *AssetPackageBatchOrderHandler {
return &AssetPackageBatchOrderHandler{service: service, validator: validator}
}
// Create 创建资产套餐批量订购任务。
// POST /api/admin/asset-package-batch-orders
func (h *AssetPackageBatchOrderHandler) Create(c *fiber.Ctx) error {
var req dto.CreateAssetPackageBatchOrderRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
userType := middleware.GetUserTypeFromContext(c.UserContext())
if req.PaymentMethod == model.PaymentMethodOffline && userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "只有平台可以使用线下支付")
}
if req.PaymentMethod == model.PaymentMethodWallet && userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent {
return errors.New(errors.CodeForbidden, "无权创建批量订购任务")
}
result, err := h.service.Create(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// List 查询资产套餐批量订购任务列表。
// GET /api/admin/asset-package-batch-orders
func (h *AssetPackageBatchOrderHandler) List(c *fiber.Ctx) error {
if !canAccessAssetPackageBatchOrders(middleware.GetUserTypeFromContext(c.UserContext())) {
return errors.New(errors.CodeForbidden, "无权查询批量订购任务")
}
var req dto.ListAssetPackageBatchOrderRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.List(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// Get 查询资产套餐批量订购任务详情。
// GET /api/admin/asset-package-batch-orders/:id
func (h *AssetPackageBatchOrderHandler) Get(c *fiber.Ctx) error {
if !canAccessAssetPackageBatchOrders(middleware.GetUserTypeFromContext(c.UserContext())) {
return errors.New(errors.CodeForbidden, "无权查询批量订购任务")
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "任务ID无效")
}
result, err := h.service.GetByID(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
func canAccessAssetPackageBatchOrders(userType int) bool {
return userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform || userType == constants.UserTypeAgent
}

View File

@@ -0,0 +1,370 @@
package admin
import (
"time"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
auditquery "github.com/break/junhong_cmp_fiber/internal/query/audit"
integrationquery "github.com/break/junhong_cmp_fiber/internal/query/integration"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// AuditHandler 提供平台基础审计调查只读接口。
type AuditHandler struct {
auditQuery *auditquery.Query
integrationQuery *integrationquery.Query
}
// NewAuditHandler 创建平台基础审计调查 Handler。
func NewAuditHandler(auditQuery *auditquery.Query, integrationQuery *integrationquery.Query) *AuditHandler {
return &AuditHandler{auditQuery: auditQuery, integrationQuery: integrationQuery}
}
// ListEvents 查询平台全局审计事件。
// GET /api/admin/audit/events
func (h *AuditHandler) ListEvents(c *fiber.Ctx) error {
var request dto.AuditEventListRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
if err != nil || invalidAuditPage(request.Page, request.PageSize) {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.auditQuery.List(c.UserContext(), auditquery.EventFilter{
CreatedFrom: from, CreatedTo: to, Action: request.Action, Category: request.Category,
ActorKind: request.ActorKind, ActorID: request.ActorID, Source: request.Source,
Result: request.Result, Risk: request.Risk, ScopeType: request.ScopeType, ScopeID: request.ScopeID,
ResourceType: request.ResourceType, ResourceID: request.ResourceID, ResourceKey: request.ResourceKey,
RequestID: request.RequestID, CorrelationID: request.CorrelationID,
Page: request.Page, PageSize: request.PageSize,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// GetEvent 查询单个稳定审计事件详情。
// GET /api/admin/audit/events/:event_id
func (h *AuditHandler) GetEvent(c *fiber.Ctx) error {
result, err := h.auditQuery.Get(c.UserContext(), c.Params("event_id"))
if err != nil {
return err
}
return response.Success(c, result)
}
// ListActorEvents 查询操作者行为时间线。
// GET /api/admin/audit/actors/:kind/:id/events
func (h *AuditHandler) ListActorEvents(c *fiber.Ctx) error {
var request dto.AuditActorEventsRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
request.Kind, request.ID = c.Params("kind"), c.Params("id")
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
if err != nil || invalidAuditPage(request.Page, request.PageSize) {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.auditQuery.ListActorEvents(c.UserContext(), auditquery.ActorEventFilter{
Kind: request.Kind, ID: request.ID, Action: request.Action, Result: request.Result, Risk: request.Risk,
ResourceType: request.ResourceType, ResourceID: request.ResourceID,
CreatedFrom: from, CreatedTo: to, Page: request.Page, PageSize: request.PageSize,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// SearchResources 按注册业务标识精确搜索资源。
// GET /api/admin/audit/resources/search
func (h *AuditHandler) SearchResources(c *fiber.Ctx) error {
var request dto.AuditResourceSearchRequest
if err := c.QueryParser(&request); err != nil || invalidAuditPage(request.Page, request.PageSize) {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.auditQuery.SearchResources(c.UserContext(), auditquery.ResourceSearchFilter{
ResourceType: request.ResourceType, Keyword: request.Keyword,
Page: request.Page, PageSize: request.PageSize,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// ResourceTimeline 查询资源作为任意关系参与的通用事件时间线。
// GET /api/admin/audit/resources/:resource_type/:resource_id/timeline
func (h *AuditHandler) ResourceTimeline(c *fiber.Ctx) error {
var request dto.AuditResourceTimelineRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
request.ResourceType, request.ResourceID = c.Params("resource_type"), c.Params("resource_id")
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
if err != nil || invalidAuditPage(request.Page, request.PageSize) {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.auditQuery.ResourceTimeline(c.UserContext(), auditquery.ResourceTimelineFilter{
ResourceType: request.ResourceType, ResourceID: request.ResourceID,
CreatedFrom: from, CreatedTo: to, Action: request.Action, Result: request.Result,
Page: request.Page, PageSize: request.PageSize,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// RequestTimeline 查询指定 HTTP 请求关联的跨事实时间线。
// GET /api/admin/audit/requests/:request_id/timeline
func (h *AuditHandler) RequestTimeline(c *fiber.Ctx) error {
requestID := c.Params("request_id")
if requestID == "" {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.auditQuery.RequestTimeline(c.UserContext(), requestID)
if err != nil {
return err
}
return response.Success(c, result)
}
// CorrelationTimeline 查询跨请求业务关联时间线。
// GET /api/admin/audit/correlations/:correlation_id/timeline
func (h *AuditHandler) CorrelationTimeline(c *fiber.Ctx) error {
correlationID := c.Params("correlation_id")
if correlationID == "" {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.auditQuery.CorrelationTimeline(c.UserContext(), correlationID)
if err != nil {
return err
}
return response.Success(c, result)
}
// FinanceTimeline 查询资金审计与业务账本的组合时间线。
// GET /api/admin/audit/finance/timeline
func (h *AuditHandler) FinanceTimeline(c *fiber.Ctx) error {
var request dto.AuditFinanceTimelineRequest
if err := c.QueryParser(&request); err != nil || invalidAuditPage(request.Page, request.PageSize) {
return errors.New(errors.CodeInvalidParam)
}
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
if err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.auditQuery.FinanceTimeline(c.UserContext(), auditquery.FinanceFilter{
ShopID: request.ShopID, WalletID: request.WalletID, OrderID: request.OrderID, OrderNo: request.OrderNo,
PaymentID: request.PaymentID, PaymentNo: request.PaymentNo, RefundID: request.RefundID, RefundNo: request.RefundNo,
RechargeID: request.RechargeID, RechargeNo: request.RechargeNo, ApprovalInstanceID: request.ApprovalInstanceID,
ThirdPartyTradeNo: request.ThirdPartyTradeNo, ActorKind: request.ActorKind, ActorID: request.ActorID,
CorrelationID: request.CorrelationID, CreatedFrom: from, CreatedTo: to, Page: request.Page, PageSize: request.PageSize,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// RiskOverview 查询固定风险信号总览。
// GET /api/admin/audit/risks/overview
func (h *AuditHandler) RiskOverview(c *fiber.Ctx) error {
var request dto.AuditRiskOverviewRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
filter, err := riskFilter(request.AuditRiskFilterRequest, 0, 0)
if err != nil {
return err
}
result, err := h.auditQuery.RiskOverview(c.UserContext(), filter)
if err != nil {
return err
}
return response.Success(c, result)
}
// RiskEvents 查询固定风险集合的事件明细。
// GET /api/admin/audit/risks/events
func (h *AuditHandler) RiskEvents(c *fiber.Ctx) error {
var request dto.AuditRiskEventsRequest
if err := c.QueryParser(&request); err != nil || invalidAuditPage(request.Page, request.PageSize) {
return errors.New(errors.CodeInvalidParam)
}
filter, err := riskFilter(request.AuditRiskFilterRequest, request.Page, request.PageSize)
if err != nil {
return err
}
result, err := h.auditQuery.RiskEvents(c.UserContext(), filter)
if err != nil {
return err
}
return response.Success(c, result)
}
func riskFilter(request dto.AuditRiskFilterRequest, page, pageSize int) (auditquery.RiskFilter, error) {
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
if err != nil {
return auditquery.RiskFilter{}, errors.New(errors.CodeInvalidParam)
}
return auditquery.RiskFilter{
CreatedFrom: from, CreatedTo: to, Risk: request.Risk, Result: request.Result,
Action: request.Action, Source: request.Source, Page: page, PageSize: pageSize,
}, nil
}
// AgentResourceActivities 查询代理范围内的安全资源活动。
// GET /api/admin/agent/resource-activities/:resource_type/:identifier
func (h *AuditHandler) AgentResourceActivities(c *fiber.Ctx) error {
request, from, to, err := subjectActivityRequest(c)
if err != nil {
return err
}
result, err := h.auditQuery.AgentResourceActivities(c.UserContext(), auditquery.SubjectActivityFilter{
ResourceType: request.ResourceType, Identifier: request.Identifier,
CreatedFrom: from, CreatedTo: to,
Page: request.Page, PageSize: request.PageSize,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// EnterpriseResourceActivities 查询企业当前有效授权资产的安全资源活动。
// GET /api/admin/enterprise/resource-activities/:resource_type/:identifier
func (h *AuditHandler) EnterpriseResourceActivities(c *fiber.Ctx) error {
request, from, to, err := subjectActivityRequest(c)
if err != nil {
return err
}
result, err := h.auditQuery.EnterpriseResourceActivities(c.UserContext(), auditquery.SubjectActivityFilter{
ResourceType: request.ResourceType, Identifier: request.Identifier,
CreatedFrom: from, CreatedTo: to,
Page: request.Page, PageSize: request.PageSize,
})
if err != nil {
return err
}
return response.Success(c, result)
}
func subjectActivityRequest(c *fiber.Ctx) (dto.SubjectResourceActivityRequest, *time.Time, *time.Time, error) {
var request dto.SubjectResourceActivityRequest
if err := c.QueryParser(&request); err != nil || invalidAuditPage(request.Page, request.PageSize) {
return request, nil, nil, errors.New(errors.CodeInvalidParam)
}
request.ResourceType = c.Params("resource_type")
request.Identifier = c.Params("identifier")
if request.ResourceType == "" || request.Identifier == "" {
return request, nil, nil, errors.New(errors.CodeInvalidParam)
}
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
if err != nil {
return request, nil, nil, errors.New(errors.CodeInvalidParam)
}
return request, from, to, nil
}
// IntegrationOverview 查询外部集成交互总览。
// GET /api/admin/audit/integrations/overview
func (h *AuditHandler) IntegrationOverview(c *fiber.Ctx) error {
var request dto.IntegrationOverviewRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
filter, err := integrationFilter(request.IntegrationFilterRequest)
if err != nil {
return err
}
result, err := h.integrationQuery.Overview(c.UserContext(), integrationquery.OverviewFilter{
ListFilter: filter,
Bucket: request.Bucket,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// ListIntegrations 查询外部集成交互列表。
// GET /api/admin/audit/integrations
func (h *AuditHandler) ListIntegrations(c *fiber.Ctx) error {
var request dto.IntegrationListRequest
if err := c.QueryParser(&request); err != nil || invalidAuditPage(request.Page, request.PageSize) {
return errors.New(errors.CodeInvalidParam)
}
filter, err := integrationFilter(request.IntegrationFilterRequest)
if err != nil {
return err
}
filter.Page, filter.PageSize = request.Page, request.PageSize
result, err := h.integrationQuery.List(c.UserContext(), filter)
if err != nil {
return err
}
return response.Success(c, result)
}
// GetIntegration 查询稳定外部集成记录详情。
// GET /api/admin/audit/integrations/:integration_id
func (h *AuditHandler) GetIntegration(c *fiber.Ctx) error {
result, err := h.integrationQuery.Get(c.UserContext(), c.Params("integration_id"))
if err != nil {
return err
}
return response.Success(c, result)
}
func integrationFilter(request dto.IntegrationFilterRequest) (integrationquery.ListFilter, error) {
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
if err != nil {
return integrationquery.ListFilter{}, errors.New(errors.CodeInvalidParam)
}
return integrationquery.ListFilter{
CreatedFrom: from, CreatedTo: to, IntegrationID: request.IntegrationID,
Provider: request.Provider, Direction: request.Direction, Operation: request.Operation,
Result: request.Result, ResultCategory: request.ResultCategory, ExternalID: request.ExternalID,
ResourceType: request.ResourceType, ResourceID: request.ResourceID, ResourceKey: request.ResourceKey,
TriggerSource: request.TriggerSource, TriggerScene: request.TriggerScene, TriggerSeries: request.TriggerSeries,
StateChanged: request.StateChanged, HTTPStatus: request.HTTPStatus, ProviderCode: request.ProviderCode,
RequestID: request.RequestID, CorrelationID: request.CorrelationID,
}, nil
}
func auditTimeRange(fromValue, toValue string) (*time.Time, *time.Time, error) {
from, err := optionalAuditTime(fromValue)
if err != nil {
return nil, nil, err
}
to, err := optionalAuditTime(toValue)
if err != nil {
return nil, nil, err
}
if from != nil && to != nil && !from.Before(*to) {
return nil, nil, errors.New(errors.CodeInvalidParam)
}
return from, to, nil
}
func optionalAuditTime(value string) (*time.Time, error) {
if value == "" {
return nil, nil
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return nil, err
}
return &parsed, nil
}
func invalidAuditPage(page, pageSize int) bool {
return page < 0 || pageSize < 0 || pageSize > 100
}

View File

@@ -35,6 +35,20 @@ func (h *DeviceHandler) List(c *fiber.Ctx) error {
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// BatchUpdateRealnamePolicy 批量更新设备实名认证策略。
// POST /api/admin/devices/batch-update-realname-policy
func (h *DeviceHandler) BatchUpdateRealnamePolicy(c *fiber.Ctx) error {
var req dto.BatchUpdateAssetRealnamePolicyRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.BatchUpdateRealnamePolicy(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
func (h *DeviceHandler) Delete(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
@@ -226,26 +240,6 @@ func (h *DeviceHandler) GetGatewaySlots(c *fiber.Ctx) error {
return response.Success(c, resp)
}
// SetSpeedLimit 设置设备限速
// PUT /api/admin/devices/by-identifier/:identifier/speed-limit
func (h *DeviceHandler) SetSpeedLimit(c *fiber.Ctx) error {
identifier := c.Params("identifier")
if identifier == "" {
return errors.New(errors.CodeInvalidParam, "设备标识符不能为空")
}
var req dto.SetSpeedLimitRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.service.GatewaySetSpeedLimit(c.UserContext(), identifier, &req); err != nil {
return err
}
return response.Success(c, nil)
}
// SetWiFi 设置设备 WiFi
// PUT /api/admin/devices/by-identifier/:identifier/wifi
func (h *DeviceHandler) SetWiFi(c *fiber.Ctx) error {

View File

@@ -46,6 +46,20 @@ func (h *DeviceImportHandler) Import(c *fiber.Ctx) error {
return response.Success(c, result)
}
// CreateAllocation 创建单列 CSV 设备批量分配或回收任务。
// POST /api/admin/devices/import/allocations
func (h *DeviceImportHandler) CreateAllocation(c *fiber.Ctx) error {
var req dto.CreateDeviceBatchAllocationRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.CreateBatchAllocationTask(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
func (h *DeviceImportHandler) List(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {

View File

@@ -1,25 +1,38 @@
package admin
import (
"context"
"strconv"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
exchangeService "github.com/break/junhong_cmp_fiber/internal/service/exchange"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
)
// ExchangeLister 定义换货列表读取用例。
type ExchangeLister interface {
List(ctx context.Context, req *dto.ExchangeListRequest) (*dto.ExchangeListResponse, error)
}
// ExchangeHandler 处理后台换货管理接口。
type ExchangeHandler struct {
service *exchangeService.Service
listQuery ExchangeLister
validator *validator.Validate
}
func NewExchangeHandler(service *exchangeService.Service, validator *validator.Validate) *ExchangeHandler {
return &ExchangeHandler{service: service, validator: validator}
// NewExchangeHandler 创建后台换货管理 Handler
func NewExchangeHandler(service *exchangeService.Service, listQuery ExchangeLister, validator *validator.Validate) *ExchangeHandler {
return &ExchangeHandler{service: service, listQuery: listQuery, validator: validator}
}
// Create 创建换货单。
// POST /api/admin/exchanges
func (h *ExchangeHandler) Create(c *fiber.Ctx) error {
var req dto.CreateExchangeRequest
if err := c.BodyParser(&req); err != nil {
@@ -36,22 +49,37 @@ func (h *ExchangeHandler) Create(c *fiber.Ctx) error {
return response.Success(c, data)
}
// List 查询换货单列表。
// GET /api/admin/exchanges
func (h *ExchangeHandler) List(c *fiber.Ctx) error {
var req dto.ExchangeListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
h.logListValidationFailure(c, err)
return errors.New(errors.CodeInvalidParam)
}
if err := h.validator.Struct(&req); err != nil {
h.logListValidationFailure(c, err)
return errors.New(errors.CodeInvalidParam)
}
data, err := h.service.List(c.UserContext(), &req)
data, err := h.listQuery.List(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, data)
}
func (h *ExchangeHandler) 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.Error(err),
)
}
// Get 查询换货单详情。
// GET /api/admin/exchanges/:id
func (h *ExchangeHandler) Get(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
@@ -65,6 +93,8 @@ func (h *ExchangeHandler) Get(c *fiber.Ctx) error {
return response.Success(c, data)
}
// Ship 执行物流换货发货。
// POST /api/admin/exchanges/:id/ship
func (h *ExchangeHandler) Ship(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
@@ -86,6 +116,8 @@ func (h *ExchangeHandler) Ship(c *fiber.Ctx) error {
return response.Success(c, data)
}
// Complete 确认换货完成。
// POST /api/admin/exchanges/:id/complete
func (h *ExchangeHandler) Complete(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
@@ -98,6 +130,8 @@ func (h *ExchangeHandler) Complete(c *fiber.Ctx) error {
return response.Success(c, nil)
}
// Cancel 取消换货单。
// POST /api/admin/exchanges/:id/cancel
func (h *ExchangeHandler) Cancel(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
@@ -118,6 +152,8 @@ func (h *ExchangeHandler) Cancel(c *fiber.Ctx) error {
return response.Success(c, nil)
}
// Renew 将已换出的旧资产转为新资产。
// POST /api/admin/exchanges/:id/renew
func (h *ExchangeHandler) Renew(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {

View File

@@ -35,6 +35,38 @@ func (h *IotCardHandler) ListStandalone(c *fiber.Ctx) error {
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// BatchUpdateRealnamePolicy 批量更新卡实名认证策略。
// POST /api/admin/iot-cards/batch-update-realname-policy
func (h *IotCardHandler) BatchUpdateRealnamePolicy(c *fiber.Ctx) error {
var req dto.BatchUpdateAssetRealnamePolicyRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.BatchUpdateRealnamePolicy(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// SetSpeedTier 设置或恢复 IoT 卡固定限速档位。
// PUT /api/admin/iot-cards/:iccid/speed-tier
func (h *IotCardHandler) SetSpeedTier(c *fiber.Ctx) error {
iccid := c.Params("iccid")
if iccid == "" {
return errors.New(errors.CodeInvalidParam, "ICCID 不能为空")
}
var req dto.SetIotCardSpeedTierRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.SetSpeedTier(c.UserContext(), iccid, req.Code)
if err != nil {
return err
}
return response.Success(c, result)
}
func (h *IotCardHandler) AllocateCards(c *fiber.Ctx) error {
var req dto.AllocateStandaloneCardsRequest
if err := c.BodyParser(&req); err != nil {

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,31 +1,86 @@
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"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// ShopHandler 店铺管理处理器。
type ShopHandler struct {
service *shopService.Service
service *shopService.Service
createService *shopapp.CreateService
updateService *shopapp.UpdateService
ownerQuery *shopquery.BusinessOwnerQuery
changeCreditService *walletapp.ChangeCreditService
validator *validator.Validate
}
func NewShopHandler(service *shopService.Service) *ShopHandler {
return &ShopHandler{service: service}
// 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 创建店铺管理处理器。
func NewShopHandler(service *shopService.Service, validator *validator.Validate) *ShopHandler {
return &ShopHandler{service: service, validator: validator}
}
// List 查询店铺列表。
// GET /api/admin/shops
func (h *ShopHandler) List(c *fiber.Ctx) error {
var req dto.ShopListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
h.logListValidationFailure(c, err)
return errors.New(errors.CodeInvalidParam)
}
if hasExplicitZeroPagination(c, &req) {
err := errors.New(errors.CodeInvalidParam)
h.logListValidationFailure(c, err)
return err
}
if h.validator == nil {
return errors.New(errors.CodeInternalError, "店铺列表校验器未配置")
}
if err := h.validator.Struct(&req); err != nil {
h.logListValidationFailure(c, err)
return errors.New(errors.CodeInvalidParam)
}
shops, total, err := h.service.ListShopResponses(c.UserContext(), &req)
normalizeShopListPagination(&req)
if h.ownerQuery == nil {
return errors.New(errors.CodeInternalError, "店铺业务员查询服务未配置")
}
shops, total, err := h.ownerQuery.List(c.UserContext(), req)
if err != nil {
return err
}
@@ -33,13 +88,126 @@ func (h *ShopHandler) List(c *fiber.Ctx) error {
return response.SuccessWithPagination(c, shops, total, req.Page, req.PageSize)
}
func hasExplicitZeroPagination(c *fiber.Ctx, req *dto.ShopListRequest) bool {
queryArgs := c.Context().QueryArgs()
return queryArgs.Has("page") && req.Page == 0 || queryArgs.Has("page_size") && req.PageSize == 0
}
func (h *ShopHandler) logListValidationFailure(c *fiber.Ctx, err error) {
logger.GetAppLogger().Warn("店铺列表参数验证失败",
zap.String("method", c.Method()),
zap.String("path", c.Path()),
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)
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),
)
}
func normalizeShopListPagination(req *dto.ShopListRequest) {
if req.Page == 0 {
req.Page = constants.DefaultPage
}
if req.PageSize == 0 {
req.PageSize = constants.DefaultPageSize
}
}
// Create 创建店铺。
// POST /api/admin/shops
func (h *ShopHandler) Create(c *fiber.Ctx) error {
var req dto.CreateShopRequest
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
}
@@ -47,6 +215,8 @@ func (h *ShopHandler) Create(c *fiber.Ctx) error {
return response.Success(c, shop)
}
// Update 更新店铺。
// PUT /api/admin/shops/:id
func (h *ShopHandler) Update(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
@@ -57,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
}
@@ -66,6 +247,8 @@ func (h *ShopHandler) Update(c *fiber.Ctx) error {
return response.Success(c, shop)
}
// Delete 删除店铺。
// DELETE /api/admin/shops/:id
func (h *ShopHandler) Delete(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {

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 {
@@ -28,8 +35,14 @@ func (h *ShopCommissionHandler) ListFundSummary(c *fiber.Ctx) error {
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if c.Context().QueryArgs().Has("shop_id") && (req.ShopID == nil || *req.ShopID == 0) {
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

@@ -1,6 +1,9 @@
package admin
import (
"strconv"
"github.com/bytedance/sonic"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
@@ -18,15 +21,46 @@ func NewShopPackageBatchAllocationHandler(service *batchAllocationService.Servic
}
// BatchAllocate 批量分配套餐
// POST /api/admin/shop-package-allocations/batch
func (h *ShopPackageBatchAllocationHandler) BatchAllocate(c *fiber.Ctx) error {
var req dto.BatchAllocatePackagesRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
req.ExpiryBaseOverrideSet = hasJSONField(c.Body(), "expiry_base_override")
if err := h.service.BatchAllocate(c.UserContext(), &req); err != nil {
result, err := h.service.BatchAllocate(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, nil)
return response.Success(c, result)
}
// UpdateExpiryBase 修改套餐分配生效条件覆盖。
// PATCH /api/admin/shop-package-allocations/:id/expiry-base
func (h *ShopPackageBatchAllocationHandler) UpdateExpiryBase(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam)
}
var req dto.UpdateAllocationExpiryBaseRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
req.ExpiryBaseOverrideSet = hasJSONField(c.Body(), "expiry_base_override")
result, err := h.service.UpdateExpiryBase(c.UserContext(), uint(id), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
func hasJSONField(body []byte, field string) bool {
var object map[string]any
if err := sonic.Unmarshal(body, &object); err != nil {
return false
}
_, ok := object[field]
return ok
}

View File

@@ -28,6 +28,7 @@ func (h *ShopSeriesGrantHandler) Create(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
req.ExpiryBaseOverrideSet = hasJSONField(c.Body(), "expiry_base_override")
result, err := h.service.Create(c.UserContext(), &req)
if err != nil {
@@ -53,6 +54,20 @@ func (h *ShopSeriesGrantHandler) List(c *fiber.Ctx) error {
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// ListPackageOptions 查询授权页面的套餐候选项。
// GET /api/admin/shop-series-grants/package-options
func (h *ShopSeriesGrantHandler) ListPackageOptions(c *fiber.Ctx) error {
var req dto.ShopSeriesGrantPackageOptionRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.ListPackageOptions(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// Get 查询系列授权详情
// GET /api/admin/shop-series-grants/:id
func (h *ShopSeriesGrantHandler) Get(c *fiber.Ctx) error {
@@ -83,7 +98,6 @@ func (h *ShopSeriesGrantHandler) Update(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.Update(c.UserContext(), uint(id), &req)
if err != nil {
return err
@@ -105,6 +119,7 @@ func (h *ShopSeriesGrantHandler) ManagePackages(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
req.ExpiryBaseOverrideSet = hasJSONField(c.Body(), "expiry_base_override")
result, err := h.service.ManagePackages(c.UserContext(), uint(id), &req)
if err != nil {

View File

@@ -0,0 +1,54 @@
package admin
import (
"github.com/gofiber/fiber/v2"
configapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
configquery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// SystemConfigHandler 提供受控系统配置查询和单 Key 更新接口。
type SystemConfigHandler struct {
listQuery *configquery.ListQuery
updateService *configapp.UpdateService
}
// NewSystemConfigHandler 创建系统配置 Handler。
func NewSystemConfigHandler(listQuery *configquery.ListQuery, updateService *configapp.UpdateService) *SystemConfigHandler {
return &SystemConfigHandler{listQuery: listQuery, updateService: updateService}
}
// List 查询受控系统配置列表。
// GET /api/admin/system-configs
func (h *SystemConfigHandler) List(c *fiber.Ctx) error {
var request dto.SystemConfigListRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.listQuery.Execute(c.UserContext(), request)
if err != nil {
return err
}
return response.Success(c, result)
}
// Update 更新一个已注册且允许修改的系统配置。
// PUT /api/admin/system-configs/:key
func (h *SystemConfigHandler) Update(c *fiber.Ctx) error {
key := c.Params("key")
if key == "" {
return errors.New(errors.CodeInvalidParam)
}
var request dto.UpdateSystemConfigRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.updateService.Execute(c.UserContext(), key, request)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -0,0 +1,234 @@
package admin
import (
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
wecomapp "github.com/break/junhong_cmp_fiber/internal/application/wecom"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// WeComHandler 提供企业微信自建应用连接配置接口。
type WeComHandler struct {
service *wecomapp.ConnectionService
directory *wecomapp.DirectoryService
scenes *wecomapp.SceneService
validator *validator.Validate
}
// SetSceneService 注入企业微信审批场景配置用例。
func (h *WeComHandler) SetSceneService(service *wecomapp.SceneService) {
h.scenes = service
}
// NewWeComHandler 创建企业微信连接配置 Handler。
func NewWeComHandler(service *wecomapp.ConnectionService, validate *validator.Validate) *WeComHandler {
return &WeComHandler{service: service, validator: validate}
}
// SetDirectoryService 注入企业微信通讯录同步用例。
func (h *WeComHandler) SetDirectoryService(service *wecomapp.DirectoryService) {
h.directory = service
}
// Save 创建或更新企业微信应用连接配置。
// POST /api/admin/wecom/applications
func (h *WeComHandler) Save(c *fiber.Ctx) error {
if h == nil || h.service == nil || h.validator == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
}
var request dto.SaveWeComApplicationRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.validator.Struct(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.Save(c.UserContext(), request)
if err != nil {
return err
}
return response.Success(c, result)
}
// List 查询企业微信应用连接配置。
// GET /api/admin/wecom/applications
func (h *WeComHandler) List(c *fiber.Ctx) error {
if h == nil || h.service == nil || h.validator == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
}
var request dto.WeComApplicationListRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.validator.Struct(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.List(c.UserContext(), request)
if err != nil {
return err
}
return response.Success(c, result)
}
// Test 测试指定企业微信应用能否成功取得 access_token。
// POST /api/admin/wecom/applications/:id/test
func (h *WeComHandler) Test(c *fiber.Ctx) error {
if h == nil || h.service == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效")
}
if err := h.service.Test(c.UserContext(), uint(id)); err != nil {
return err
}
return response.Success(c, dto.WeComConnectionTestResponse{Success: true})
}
// SaveDefaultCreator 保存应用默认审批发起人。
// PUT /api/admin/wecom/applications/:id/default-creator
func (h *WeComHandler) SaveDefaultCreator(c *fiber.Ctx) error {
if h == nil || h.service == nil || h.validator == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效")
}
var request dto.SaveWeComDefaultCreatorRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.validator.Struct(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.SaveDefaultCreator(c.UserContext(), uint(id), request)
if err != nil {
return err
}
return response.Success(c, result)
}
// SyncMembers 同步指定应用当前可见成员。
// POST /api/admin/wecom/applications/:id/members/sync
func (h *WeComHandler) SyncMembers(c *fiber.Ctx) error {
if h == nil || h.directory == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置")
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效")
}
result, err := h.directory.Sync(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// ListMembers 分页查询指定应用最近同步的可见成员。
// GET /api/admin/wecom/applications/:id/members
func (h *WeComHandler) ListMembers(c *fiber.Ctx) error {
if h == nil || h.directory == nil || h.validator == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置")
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效")
}
var request dto.WeComMemberListRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.validator.Struct(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.directory.List(c.UserContext(), uint(id), request)
if err != nil {
return err
}
return response.Success(c, result)
}
// InspectTemplate 实时读取企业微信审批模板控件。
// POST /api/admin/wecom/applications/:id/templates/inspect
func (h *WeComHandler) InspectTemplate(c *fiber.Ctx) error {
if h == nil || h.scenes == nil || h.validator == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信模板服务未配置")
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效")
}
var request dto.InspectWeComTemplateRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.validator.Struct(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.scenes.InspectTemplate(c.UserContext(), uint(id), request)
if err != nil {
return err
}
return response.Success(c, result)
}
// ListSceneFields 查询审批场景允许映射的业务字段。
// GET /api/admin/wecom/scenes/:business_type/fields
func (h *WeComHandler) ListSceneFields(c *fiber.Ctx) error {
if h == nil || h.scenes == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置")
}
result, err := h.scenes.ListBusinessFields(c.UserContext(), c.Params("business_type"))
if err != nil {
return err
}
return response.Success(c, result)
}
// SaveScene 保存并校验稳定业务类型的企微模板控件映射。
// PUT /api/admin/wecom/scenes/:business_type
func (h *WeComHandler) SaveScene(c *fiber.Ctx) error {
if h == nil || h.scenes == nil || h.validator == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置")
}
var request dto.SaveWeComApprovalSceneRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.validator.Struct(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.scenes.Save(c.UserContext(), c.Params("business_type"), request)
if err != nil {
return err
}
return response.Success(c, result)
}
// ListScenes 分页查询企业微信审批场景当前配置。
// GET /api/admin/wecom/scenes
func (h *WeComHandler) ListScenes(c *fiber.Ctx) error {
if h == nil || h.scenes == nil || h.validator == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置")
}
var request dto.WeComApprovalSceneListRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.validator.Struct(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.scenes.List(c.UserContext(), request)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -7,6 +7,7 @@ import (
"strings"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
@@ -14,16 +15,21 @@ import (
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
"github.com/break/junhong_cmp_fiber/internal/service/packageprice"
rechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
pkgMiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
"gorm.io/gorm"
)
var clientAssetValidator = validator.New()
// ClientAssetHandler C 端资产信息处理器
// 提供 B1~B4 资产信息、可购套餐、套餐历史、手动刷新接口
type ClientAssetHandler struct {
@@ -36,6 +42,37 @@ type ClientAssetHandler struct {
deviceStore *postgres.DeviceStore
db *gorm.DB
logger *zap.Logger
observationSeries cardObservationApp.BestEffortSeriesDispatcher
paymentMethodPolicy ClientPaymentMethodPolicy
forceRechargeChecker ClientForceRechargeChecker
}
// ClientPaymentMethodPolicy 提供 C 端支付方式读取和校验能力。
type ClientPaymentMethodPolicy interface {
AllowedMethods(ctx context.Context, assetType string) ([]string, error)
AllowedRechargeMethods(ctx context.Context, assetType string) ([]string, error)
EnsureAllowed(ctx context.Context, assetType, paymentMethod string) error
EnsureRechargeAllowed(ctx context.Context, assetType, paymentMethod string) error
}
// ClientForceRechargeChecker 提供 C 端资产当前强充资格的统一判定。
type ClientForceRechargeChecker interface {
GetRechargeCheck(ctx context.Context, resourceType string, resourceID uint) (*rechargeSvc.ForceRechargeRequirement, error)
}
// SetObservationSeriesDispatcher 注入读取型后台观测序列端口。
func (h *ClientAssetHandler) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) {
h.observationSeries = dispatcher
}
// SetPaymentMethodPolicy 注入 C 端支付方式策略。
func (h *ClientAssetHandler) SetPaymentMethodPolicy(policy ClientPaymentMethodPolicy) {
h.paymentMethodPolicy = policy
}
// SetForceRechargeChecker 注入复用现有一次性佣金状态语义的强充判定服务。
func (h *ClientAssetHandler) SetForceRechargeChecker(checker ClientForceRechargeChecker) {
h.forceRechargeChecker = checker
}
// NewClientAssetHandler 创建 C 端资产信息处理器
@@ -150,10 +187,43 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
if err != nil {
return err
}
currentPackageID, err := h.getCurrentPackageID(resolved.SkipPermissionCtx, resolved.Asset.CurrentPackageUsageID)
if err != nil {
return err
}
renewalPrice, err := h.getRenewalPrice(resolved.SkipPermissionCtx, currentPackageID, resolved.SellerShopID)
if err != nil {
return err
}
if h.paymentMethodPolicy == nil {
return errors.New(errors.CodeNoPaymentConfig)
}
if h.forceRechargeChecker == nil {
return errors.New(errors.CodeNoPaymentConfig)
}
resourceType := resolved.Asset.AssetType
if resourceType == "card" {
resourceType = constants.ResourceTypeIotCard
}
forceRecharge, err := h.forceRechargeChecker.GetRechargeCheck(resolved.SkipPermissionCtx, resourceType, resolved.Asset.AssetID)
if err != nil {
return err
}
allowedPaymentMethods, err := h.paymentMethodPolicy.AllowedMethods(resolved.SkipPermissionCtx, resolved.Asset.AssetType)
if err != nil {
return err
}
if forceRecharge.NeedForceRecharge {
allowedPaymentMethods, err = h.paymentMethodPolicy.AllowedRechargeMethods(resolved.SkipPermissionCtx, resolved.Asset.AssetType)
if err != nil {
return err
}
}
phone, _ := middleware.GetCustomerPhone(c)
resp := &dto.AssetInfoResponse{
PackageExpiryEstimate: resolved.Asset.PackageExpiryEstimate,
BoundPhone: phone,
AssetType: resolved.Asset.AssetType,
AssetID: resolved.Asset.AssetID,
@@ -163,11 +233,17 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
StatusName: resolved.Asset.StatusName,
RealNameStatus: resolved.Asset.RealNameStatus,
RealNameStatusName: constants.GetRealNameStatusName(resolved.Asset.RealNameStatus),
RealnamePolicy: resolved.Asset.RealnamePolicy,
EffectiveRealnamePolicy: resolved.Asset.RealnamePolicy,
RealnameRequired: resolved.Asset.RealnamePolicy != constants.RealnamePolicyNone,
CarrierName: resolved.Asset.CarrierName,
Generation: strconv.Itoa(resolved.Generation),
WalletBalance: resolved.WalletBalance,
AllowedPaymentMethods: allowedPaymentMethods,
ActivatedAt: resolved.Asset.ActivatedAt,
CurrentPackage: resolved.Asset.CurrentPackage,
CurrentPackageID: currentPackageID,
RenewalPrice: renewalPrice,
CurrentPackageUsageID: resolved.Asset.CurrentPackageUsageID,
CurrentPackageActivatedAt: resolved.Asset.CurrentPackageActivatedAt,
CurrentPackageExpiresAt: resolved.Asset.CurrentPackageExpiresAt,
@@ -212,10 +288,89 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
resp.DeviceRealtime = mapDeviceGatewayInfoToClientInfo(realtimeResp.DeviceRealtime)
}
}
h.dispatchAssetReadObservations(c.UserContext(), resolved.Asset)
return response.Success(c, resp)
}
func (h *ClientAssetHandler) getCurrentPackageID(ctx context.Context, usageID *uint) (uint, error) {
if usageID == nil || *usageID == 0 {
return 0, nil
}
var usage model.PackageUsage
if err := h.db.WithContext(ctx).Select("package_id").First(&usage, *usageID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return 0, nil
}
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询当前套餐引用失败")
}
return usage.PackageID, nil
}
func (h *ClientAssetHandler) getRenewalPrice(ctx context.Context, packageID, sellerShopID uint) (*int64, error) {
if packageID == 0 {
return nil, nil
}
pkg, err := h.packageStore.GetByID(ctx, packageID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询续费套餐失败")
}
if sellerShopID == 0 {
price := packageprice.PackageEffectiveRetailPrice(pkg)
return &price, nil
}
allocation, err := h.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, sellerShopID, packageID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询续费套餐价格失败")
}
price := packageprice.AllocationEffectiveRetailPrice(allocation)
return &price, nil
}
func (h *ClientAssetHandler) dispatchAssetReadObservations(ctx context.Context, asset *dto.AssetResolveResponse) {
if h.observationSeries == nil || asset == nil {
return
}
cardIDs := make([]uint, 0, len(asset.Cards)+1)
if asset.AssetType == "card" {
cardIDs = append(cardIDs, asset.AssetID)
} else {
for _, card := range asset.Cards {
cardIDs = append(cardIDs, card.CardID)
}
}
dispatchCardReadSeries(ctx, h.observationSeries, constants.CardObservationSceneClientAssetRead, cardIDs)
}
func dispatchCardReadSeries(ctx context.Context, dispatcher cardObservationApp.BestEffortSeriesDispatcher, scene string, cardIDs []uint) {
requestID := ""
if value := pkgMiddleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
for _, cardID := range cardIDs {
resourceID := strconv.FormatUint(uint64(cardID), 10)
for _, syncType := range []string{
constants.CardObservationSyncTypeRealname,
constants.CardObservationSyncTypeTraffic,
constants.CardObservationSyncTypeNetwork,
} {
dispatcher.Dispatch(ctx, cardObservationApp.SeriesRequest{
Scene: scene, ResourceType: constants.CardObservationResourceTypeCard,
ResourceID: resourceID, SyncType: syncType, Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
})
}
}
}
// GetAvailablePackages B2 资产可购套餐列表
// GET /api/c/v1/asset/packages
func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error {
@@ -223,6 +378,9 @@ func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error {
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientAssetValidator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
if err != nil {
@@ -250,14 +408,20 @@ func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error {
listCtx = context.WithValue(listCtx, constants.ContextKeyShopID, resolved.SellerShopID)
}
filters := map[string]any{
"series_id": *resolved.Asset.SeriesID,
"status": constants.StatusEnabled,
"shelf_status": constants.ShelfStatusOn,
}
if req.PackageType != nil {
filters["package_type"] = *req.PackageType
}
pkgs, _, err := h.packageStore.List(listCtx, &store.QueryOptions{
Page: 1,
PageSize: constants.MaxPageSize,
OrderBy: "id DESC",
}, map[string]any{
"series_id": *resolved.Asset.SeriesID,
"status": constants.StatusEnabled,
})
}, filters)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询可购套餐失败")
}
@@ -423,7 +587,6 @@ func (h *ClientAssetHandler) RefreshAsset(c *fiber.Ctx) error {
return response.Success(c, resp)
}
func (h *ClientAssetHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
switch assetType {
case "card":

View File

@@ -6,6 +6,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model/dto"
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
@@ -33,9 +34,15 @@ type ClientDeviceHandler struct {
deviceSimBindingStore *postgres.DeviceSimBindingStore
iotCardStore *postgres.IotCardStore
gatewayClient *gateway.Client
deviceService *deviceSvc.Service
logger *zap.Logger
}
// SetDeviceService 注入统一设备控制服务,确保各入口共享成功后的观测触发。
func (h *ClientDeviceHandler) SetDeviceService(service *deviceSvc.Service) {
h.deviceService = service
}
// NewClientDeviceHandler 创建 C 端设备能力处理器
func NewClientDeviceHandler(
assetService *assetSvc.Service,
@@ -195,10 +202,10 @@ func (h *ClientDeviceHandler) RebootDevice(c *fiber.Ctx) error {
return err
}
// 调用 Gateway 重启设备
if err := h.gatewayClient.RebootDevice(c.UserContext(), &gateway.DeviceOperationReq{
DeviceID: info.IMEI,
}); err != nil {
if h.deviceService == nil {
return errors.New(errors.CodeInternalError, "设备控制服务未配置")
}
if err := h.deviceService.GatewayRebootDevice(c.UserContext(), info.IMEI); err != nil {
h.logger.Error("Gateway重启设备失败",
zap.String("imei", info.IMEI),
zap.Error(err))
@@ -225,10 +232,10 @@ func (h *ClientDeviceHandler) FactoryResetDevice(c *fiber.Ctx) error {
return err
}
// 调用 Gateway 恢复出厂设置
if err := h.gatewayClient.ResetDevice(c.UserContext(), &gateway.DeviceOperationReq{
DeviceID: info.IMEI,
}); err != nil {
if h.deviceService == nil {
return errors.New(errors.CodeInternalError, "设备控制服务未配置")
}
if err := h.deviceService.GatewayResetDevice(c.UserContext(), info.IMEI); err != nil {
h.logger.Error("Gateway恢复出厂设置失败",
zap.String("imei", info.IMEI),
zap.Error(err))
@@ -256,14 +263,11 @@ func (h *ClientDeviceHandler) SetWiFi(c *fiber.Ctx) error {
return err
}
// 调用 Gateway 配置 WiFi
// CardNo 字段虽名为"卡号",但 Gateway 实际要求传入设备 IMEI
if err := h.gatewayClient.SetWiFi(c.UserContext(), &gateway.WiFiReq{
CardNo: info.IMEI,
Params: gateway.WiFiParams{
SSIDName: req.SSID,
SSIDPassword: req.Password,
},
if h.deviceService == nil {
return errors.New(errors.CodeInternalError, "设备控制服务未配置")
}
if err := h.deviceService.GatewaySetWiFi(c.UserContext(), info.IMEI, &dto.SetWiFiRequest{
SSID: req.SSID, Password: req.Password, Enabled: req.Enabled,
}); err != nil {
h.logger.Error("Gateway配置WiFi失败",
zap.String("imei", info.IMEI),
@@ -292,10 +296,11 @@ func (h *ClientDeviceHandler) SwitchCard(c *fiber.Ctx) error {
return err
}
// 调用 Gateway 切卡CardNo 传设备 IMEI
if err := h.gatewayClient.SwitchCard(c.UserContext(), &gateway.SwitchCardReq{
CardNo: info.IMEI,
ICCID: req.TargetICCID,
if h.deviceService == nil {
return errors.New(errors.CodeInternalError, "设备控制服务未配置")
}
if err := h.deviceService.GatewaySwitchCard(c.UserContext(), info.IMEI, &dto.SwitchCardRequest{
TargetICCID: req.TargetICCID,
}); err != nil {
h.logger.Error("Gateway切卡失败",
zap.String("imei", info.IMEI),

View File

@@ -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

@@ -2,9 +2,10 @@ package app
import (
"context"
"strconv"
"strings"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
@@ -16,7 +17,6 @@ import (
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
@@ -36,7 +36,7 @@ type ClientRealnameHandler struct {
carrierStore *postgres.CarrierStore
gatewayClient *gateway.Client
logger *zap.Logger
manualTriggerSvc *pollingSvc.ManualTriggerService // 手动触发服务可为nilnil时跳过自动触发
observationSeries cardObservationApp.BestEffortSeriesDispatcher
}
// NewClientRealnameHandler 创建 C 端实名认证处理器
@@ -48,7 +48,7 @@ func NewClientRealnameHandler(
carrierStore *postgres.CarrierStore,
gatewayClient *gateway.Client,
logger *zap.Logger,
manualTriggerSvc *pollingSvc.ManualTriggerService, // 可为nil
_ *pollingSvc.ManualTriggerService, // 兼容旧构造签名;获取链接改由 0/3/5 观测序列收敛
) *ClientRealnameHandler {
return &ClientRealnameHandler{
assetService: assetSvc,
@@ -58,10 +58,14 @@ func NewClientRealnameHandler(
carrierStore: carrierStore,
gatewayClient: gatewayClient,
logger: logger,
manualTriggerSvc: manualTriggerSvc,
}
}
// SetObservationSeriesDispatcher 注入获取实名链接后的观测序列端口。
func (h *ClientRealnameHandler) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) {
h.observationSeries = dispatcher
}
// GetRealnameLink E1 获取实名认证链接
// GET /api/c/v1/realname/link
func (h *ClientRealnameHandler) GetRealnameLink(c *fiber.Ctx) error {
@@ -138,15 +142,29 @@ func (h *ClientRealnameHandler) GetRealnameLink(c *fiber.Ctx) error {
return err
}
// 异步触发实名检查,提升检测优先级;失败不影响主流程
if h.manualTriggerSvc != nil && config.Get().PollingAutoTrigger.EnableAutoTrigger {
systemUserID := uint(config.Get().PollingAutoTrigger.AutoTriggerSystemUserID)
go h.triggerRealnameCheck(targetCard.ID, customerID, targetCard.ICCID, systemUserID)
}
h.dispatchRealnameObservation(ctx, targetCard.ID)
return response.Success(c, resp)
}
func (h *ClientRealnameHandler) dispatchRealnameObservation(ctx context.Context, cardID uint) {
if h.observationSeries == nil {
return
}
requestID := ""
if value := pkgMiddleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
h.observationSeries.Dispatch(ctx, cardObservationApp.SeriesRequest{
Scene: constants.CardObservationSceneClientRealnameLink,
ResourceType: constants.CardObservationResourceTypeCard,
ResourceID: strconv.FormatUint(uint64(cardID), 10),
SyncType: constants.CardObservationSyncTypeRealname,
ExpectedValue: "verified", Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
})
}
// resolveTargetCard 根据资产类型和ICCID定位目标卡
// 支持三条路径:直接卡资产、设备+指定ICCID、设备取第一张绑定卡
func (h *ClientRealnameHandler) resolveTargetCard(c *fiber.Ctx, asset *dto.AssetResolveResponse, iccid string) (*model.IotCard, error) {
@@ -275,33 +293,3 @@ func (h *ClientRealnameHandler) findFirstBoundCard(c *fiber.Ctx, deviceID uint)
return card, nil
}
// triggerRealnameCheck 异步触发单卡实名检查
// 在独立 goroutine 中调用,使用独立 context 避免 Fiber 请求 context 失效问题
// 参数全部为值类型,不捕获请求相关指针
func (h *ClientRealnameHandler) triggerRealnameCheck(cardID, customerID uint, iccid string, systemUserID uint) {
// 必须使用独立 context禁止复用 Fiber 请求 context请求返回后即失效
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// 使用平台用户身份构建 context绕过卡归属权限检查
// 注意:使用 UserTypePlatform 而非 UserTypeSuperAdminSuperAdmin 不受日限制约束
sysCtx := pkgMiddleware.SetUserContext(ctx, &pkgMiddleware.UserContextInfo{
UserID: systemUserID,
UserType: constants.UserTypePlatform,
})
err := h.manualTriggerSvc.TriggerSingle(sysCtx, cardID, constants.TaskTypePollingRealname, systemUserID)
if err != nil {
h.logger.Warn("自动触发实名检查失败",
zap.Uint("customer_id", customerID),
zap.String("iccid", iccid),
zap.Uint("card_id", cardID),
zap.Error(err))
return
}
h.logger.Info("自动触发实名检查成功",
zap.Uint("customer_id", customerID),
zap.String("iccid", iccid),
zap.Uint("card_id", cardID))
}

View File

@@ -8,6 +8,8 @@ import (
"strings"
"time"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
@@ -45,6 +47,20 @@ type ClientWalletHandler struct {
db *gorm.DB
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
paymentMethodPolicy ClientPaymentMethodPolicy
auditWriter *audit.Writer
paymentIntegration *integrationlog.Repository
}
// SetPaymentMethodPolicy 注入 C 端支付方式策略。
func (h *ClientWalletHandler) SetPaymentMethodPolicy(policy ClientPaymentMethodPolicy) {
h.paymentMethodPolicy = policy
}
// SetPaymentAudit 注入充值支付审计与外部交互日志接缝。
func (h *ClientWalletHandler) SetPaymentAudit(writer *audit.Writer, integration *integrationlog.Repository) {
h.auditWriter = writer
h.paymentIntegration = integration
}
// NewClientWalletHandler 创建 C 端钱包处理器
@@ -243,14 +259,22 @@ func (h *ClientWalletHandler) GetRechargeCheck(c *fiber.Ctx) error {
if err != nil {
return err
}
if h.paymentMethodPolicy == nil {
return errors.New(errors.CodeNoPaymentConfig)
}
allowedPaymentMethods, err := h.paymentMethodPolicy.AllowedRechargeMethods(resolved.SkipPermissionCtx, resolved.Asset.AssetType)
if err != nil {
return err
}
resp := &dto.ClientRechargeCheckResponse{
NeedForceRecharge: check.NeedForceRecharge,
ForceRechargeAmount: check.ForceRechargeAmount,
TriggerType: check.TriggerType,
MinAmount: check.MinAmount,
MaxAmount: check.MaxAmount,
Message: check.Message,
NeedForceRecharge: check.NeedForceRecharge,
ForceRechargeAmount: check.ForceRechargeAmount,
TriggerType: check.TriggerType,
MinAmount: check.MinAmount,
MaxAmount: check.MaxAmount,
Message: check.Message,
AllowedPaymentMethods: allowedPaymentMethods,
}
return response.Success(c, resp)
@@ -273,6 +297,12 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
if resolved.Asset.RealnamePolicy == constants.RealnamePolicyBeforeOrder && resolved.Asset.RealNameStatus != 1 {
return errors.New(errors.CodeNeedRealname)
}
if h.paymentMethodPolicy == nil {
return errors.New(errors.CodeNoPaymentConfig)
}
if err := h.paymentMethodPolicy.EnsureRechargeAllowed(resolved.SkipPermissionCtx, resolved.Asset.AssetType, req.PaymentMethod); err != nil {
return err
}
wallet, err := h.getOrCreateWallet(resolved)
if err != nil {
@@ -302,9 +332,10 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
switch req.PaymentMethod {
case constants.RechargeMethodAlipay:
return h.createAlipayRecharge(c, resolved, config, wallet, req)
default:
// 微信充值默认app_type 必填
case constants.RechargeMethodWechat:
return h.createWechatRecharge(c, resolved, config, wallet, req)
default:
return errors.New(errors.CodePaymentMethodUnavailable)
}
}
@@ -331,6 +362,10 @@ func (h *ClientWalletHandler) createWechatRecharge(
// 先初始化生效支付通道并创建预支付订单,确认支付通道可用
// 避免先写入充值记录后支付初始化失败,导致产生孤儿记录
attempt, startedAt, err := h.startRechargePaymentAttempt(resolved.SkipPermissionCtx, config, paymentNo, rechargeNo, req.Amount)
if err != nil {
return err
}
payConfig, err := h.createClientRechargePayConfig(
resolved.SkipPermissionCtx,
config,
@@ -342,6 +377,12 @@ func (h *ClientWalletHandler) createWechatRecharge(
int(req.Amount),
)
if err != nil {
if completeErr := h.completeRechargePaymentAttempt(resolved.SkipPermissionCtx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "充值支付预下单结果未知"); completeErr != nil {
return completeErr
}
return err
}
if err := h.completeRechargePaymentAttempt(resolved.SkipPermissionCtx, attempt, startedAt, constants.IntegrationResultSuccess, "SUCCESS", ""); err != nil {
return err
}
@@ -360,10 +401,6 @@ func (h *ClientWalletHandler) createWechatRecharge(
OperatorType: constants.OperatorTypePersonalCustomer,
Generation: resolved.Generation,
}
if err := h.rechargeOrderStore.Create(resolved.SkipPermissionCtx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
payment := &model.Payment{
PaymentNo: paymentNo,
OrderID: rechargeOrder.ID,
@@ -373,8 +410,17 @@ func (h *ClientWalletHandler) createWechatRecharge(
Status: model.PaymentRecordStatusPending,
PaymentConfigID: &config.ID,
}
if err := h.paymentStore.Create(resolved.SkipPermissionCtx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
if err := h.db.WithContext(resolved.SkipPermissionCtx).Transaction(func(tx *gorm.DB) error {
if err := h.rechargeOrderStore.CreateWithTx(resolved.SkipPermissionCtx, tx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
payment.OrderID = rechargeOrder.ID
if err := h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
}
return h.appendRechargePaymentCreatedAudit(resolved.SkipPermissionCtx, tx, payment, rechargeOrder)
}); err != nil {
return err
}
return response.Success(c, &dto.ClientRechargeResponse{
@@ -435,14 +481,17 @@ func (h *ClientWalletHandler) createAlipayRecharge(
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
payment.OrderID = rechargeOrder.ID
return h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment)
if err := h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment); err != nil {
return err
}
return h.appendRechargePaymentCreatedAudit(resolved.SkipPermissionCtx, tx, payment, rechargeOrder)
}); err != nil {
return err
}
wapURL, err := alipay.BuildWapPayURL(resolved.SkipPermissionCtx, config, payment, "资产钱包充值")
if err != nil {
if updateErr := h.paymentStore.UpdateStatus(resolved.SkipPermissionCtx, payment.ID, model.PaymentRecordStatusFailed); updateErr != nil {
if updateErr := h.markRechargePaymentFailed(resolved.SkipPermissionCtx, payment); updateErr != nil {
h.logger.Warn("标记支付宝支付单 failed 失败",
zap.String("payment_no", paymentNo),
zap.Error(updateErr),
@@ -585,7 +634,6 @@ func (h *ClientWalletHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifie
}, nil
}
func (h *ClientWalletHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
switch assetType {
case "card":

View File

@@ -0,0 +1,100 @@
package app
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"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"
)
func (h *ClientWalletHandler) appendRechargePaymentCreatedAudit(ctx context.Context, tx *gorm.DB, payment *model.Payment, recharge *model.RechargeOrder) error {
if h.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "充值支付统一审计接缝未配置")
}
rechargeID := strconv.FormatUint(uint64(recharge.ID), 10)
resources := []audit.ResourceInput{
audit.PaymentResource(payment, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePaymentTarget, nil, map[string]any{"status": payment.Status}),
{
Type: constants.AuditResourceRechargeOrder, ID: &rechargeID, Key: recharge.RechargeOrderNo, DisplayName: recharge.RechargeOrderNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRolePaymentBusinessOrder,
IdentitySnapshot: map[string]any{
"id": recharge.ID, "recharge_order_no": recharge.RechargeOrderNo, "user_id": recharge.UserID,
"asset_wallet_id": recharge.AssetWalletID, "resource_type": recharge.ResourceType,
"resource_id": recharge.ResourceID, "amount": recharge.Amount, "status": recharge.Status,
},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "资产充值支付已创建",
},
}
references, err := audit.AssetRechargeReferences(ctx, tx, recharge)
if err != nil {
return err
}
resources = append(resources, references...)
return h.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionPaymentCreated, Summary: "创建资产充值支付记录",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: payment.PaymentNo,
Resources: resources,
})
}
func (h *ClientWalletHandler) startRechargePaymentAttempt(ctx context.Context, config *model.WechatConfig, paymentNo, rechargeNo string, amount int64) (*model.IntegrationLog, time.Time, error) {
if h.paymentIntegration == nil {
return nil, time.Time{}, errors.New(errors.CodeInvalidStatus, "充值支付 Integration Log 接缝未配置")
}
provider := constants.IntegrationProviderWechatPay
if config.ProviderType == model.ProviderTypeFuiou {
provider = constants.IntegrationProviderFuiou
}
series := "payment:" + paymentNo + ":" + constants.IntegrationOperationPaymentPreCreate
log, err := h.paymentIntegration.Start(ctx, integrationlog.Attempt{
Provider: provider, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationPaymentPreCreate,
ResourceType: constants.IntegrationResourceTypePayment, ResourceKey: &paymentNo, ExternalID: &paymentNo,
TriggerSeries: &series, CorrelationID: &rechargeNo,
RequestSummary: map[string]any{"payment_config_id": config.ID, "amount": amount},
})
return log, time.Now(), err
}
func (h *ClientWalletHandler) completeRechargePaymentAttempt(ctx context.Context, log *model.IntegrationLog, startedAt time.Time, result, providerCode, safeMessage string) error {
completion := integrationlog.Completion{
Result: result, ProviderCode: providerCode, SafeProviderMessage: safeMessage,
ResponseSummary: map[string]any{"success": result == constants.IntegrationResultSuccess},
DurationMS: time.Since(startedAt).Milliseconds(),
}
if result == constants.IntegrationResultUnknown {
completion.RecoveryStrategy = "使用原支付单号向支付渠道查单,确认结果后再推进本地充值状态"
}
_, err := h.paymentIntegration.Complete(ctx, log.IntegrationID, completion)
return err
}
func (h *ClientWalletHandler) markRechargePaymentFailed(ctx context.Context, payment *model.Payment) error {
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.Payment{}).Where("id = ? AND status = ?", payment.ID, model.PaymentRecordStatusPending).
Update("status", model.PaymentRecordStatusFailed)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关闭失败支付记录失败")
}
if result.RowsAffected == 0 {
return nil
}
after := *payment
after.Status = model.PaymentRecordStatusFailed
return h.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionPaymentFailed, Summary: "支付宝支付链接生成失败,关闭支付记录",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: payment.PaymentNo,
Resources: []audit.ResourceInput{audit.PaymentResource(&after, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePaymentTarget,
map[string]any{"status": payment.Status}, map[string]any{"status": after.Status})},
})
})
}

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