package employeecollection import ( "context" "github.com/bytedance/sonic" "gorm.io/gorm" "gorm.io/gorm/clause" employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // BillAudit 描述员工代收款账单事实的实际变化。 type BillAudit struct { // EventID 是审计事件稳定标识,同一业务事实重复重放时保持相同值。 EventID string // ActionCode 是已注册的账单审计动作码。 ActionCode string // Summary 是给人工阅读的中文摘要。 Summary string // Bill 是本次动作后的账单事实。 Bill *model.EmployeeCollectionBill // BeforeData 与 AfterData 是脱敏前后的字段快照。 BeforeData map[string]any AfterData map[string]any // CorrelationID 是来源业务链路标识。 CorrelationID string } // BillAuditWriter 在员工代收款业务事务内追加统一 Audit Event。 type BillAuditWriter interface { WriteEmployeeCollectionBill(ctx context.Context, tx *gorm.DB, change BillAudit) error } // BillCreationService 在来源成功事务内按来源唯一键幂等创建员工代收款账单。 // 建账只由来源成功事务携带的来源主键触发,不存在扫描历史订单或充值补建的路径。 type BillCreationService struct { audit BillAuditWriter } // NewBillCreationService 创建员工代收款建账用例。 func NewBillCreationService(audit BillAuditWriter) *BillCreationService { return &BillCreationService{audit: audit} } // CreateFromOrderInTx 在后台线下套餐订单激活事务内建账。 // 判据见 employeecollectiondomain.ShouldCreateBillForOrder;不满足判据时返回 (nil, nil)。 // 重复订单事务、重放或重试都命中 source_key 唯一约束并返回既有账单,不使订单事务失败。 func (s *BillCreationService) CreateFromOrderInTx( ctx context.Context, tx *gorm.DB, order *model.Order, hasGiftPackage bool, ) (*model.EmployeeCollectionBill, error) { if s == nil || tx == nil || s.audit == nil { return nil, errors.New(errors.CodeInternalError, "员工代收款建账用例未完整配置") } if order == nil || order.ID == 0 { return nil, errors.New(errors.CodeInvalidParam, "员工代收款建账缺少来源订单") } if !employeecollectiondomain.ShouldCreateBillForOrder( employeecollectiondomain.OrderBillSubjectFromOrder(order, hasGiftPackage)) { return nil, nil } if order.OperatorAccountID == nil || *order.OperatorAccountID == 0 { return nil, errors.New(errors.CodeInternalError, "线下套餐订单缺少欠款人账号") } debtorAccountID := *order.OperatorAccountID debtorSnapshot, err := marshalSnapshot(map[string]any{ "account_id": debtorAccountID, "account_name": order.OperatorAccountName, "account_type": order.OperatorAccountType, }) if err != nil { return nil, err } // shop_id 与 seller_shop_id 同时写入:店铺筛选统一读 shop_id,seller_shop_id 保留兼容口径。 customerSnapshot, err := marshalSnapshot(map[string]any{ "buyer_type": order.BuyerType, "buyer_id": order.BuyerID, "buyer_nickname": order.BuyerNickname, "shop_id": order.SellerShopID, "seller_shop_id": order.SellerShopID, "asset_identifier": order.AssetIdentifier, }) if err != nil { return nil, err } sourceKey := employeecollectiondomain.OrderSourceKey(order.ID) bill := &model.EmployeeCollectionBill{ SourceType: constants.EmployeeCollectionSourceTypeOrder, SourceID: order.ID, SourceKey: sourceKey, SourceNo: order.OrderNo, DebtorAccountID: debtorAccountID, DebtorSnapshot: debtorSnapshot, CustomerSnapshot: customerSnapshot, ReceivableAmount: *order.ActualPaidAmount, Status: constants.EmployeeCollectionBillStatusPending, Creator: debtorAccountID, Updater: debtorAccountID, } return s.persistInTx(ctx, tx, bill, []string{"employee_collection", "bill", "order", uintText(order.ID), "create"}, "后台线下套餐订单创建员工代收款账单", order.OrderNo) } // CreateFromRechargeInTx 在代理线下充值入账事务内建账。 // 判据见 employeecollectiondomain.ShouldCreateBillForRecharge;欠款人为发起充值的后台账号。 // 覆盖企业微信终审通过入账与后台人工确认入账两条入口,重复入账不重复建账。 func (s *BillCreationService) CreateFromRechargeInTx( ctx context.Context, tx *gorm.DB, record *model.AgentRechargeRecord, ) (*model.EmployeeCollectionBill, error) { if s == nil || tx == nil || s.audit == nil { return nil, errors.New(errors.CodeInternalError, "员工代收款建账用例未完整配置") } if record == nil || record.ID == 0 { return nil, errors.New(errors.CodeInvalidParam, "员工代收款建账缺少来源充值记录") } if !employeecollectiondomain.ShouldCreateBillForRecharge(employeecollectiondomain.RechargeBillSubject{ PaymentMethod: record.PaymentMethod, Amount: record.Amount, }) { return nil, nil } if record.UserID == 0 { return nil, errors.New(errors.CodeInternalError, "线下充值记录缺少发起账号") } debtorName, err := rechargeAccountName(ctx, tx, record.UserID) if err != nil { return nil, err } debtorSnapshot, err := marshalSnapshot(map[string]any{ "account_id": record.UserID, "account_name": debtorName, "account_type": model.OperatorAccountTypePlatform, }) if err != nil { return nil, err } customerSnapshot, err := marshalSnapshot(map[string]any{ "shop_id": record.ShopID, "agent_wallet_id": record.AgentWalletID, "payment_method": record.PaymentMethod, "recharge_no": record.RechargeNo, }) if err != nil { return nil, err } sourceKey := employeecollectiondomain.RechargeSourceKey(record.ID) bill := &model.EmployeeCollectionBill{ SourceType: constants.EmployeeCollectionSourceTypeRecharge, SourceID: record.ID, SourceKey: sourceKey, SourceNo: record.RechargeNo, DebtorAccountID: record.UserID, DebtorSnapshot: debtorSnapshot, CustomerSnapshot: customerSnapshot, ReceivableAmount: record.Amount, Status: constants.EmployeeCollectionBillStatusPending, Creator: record.UserID, Updater: record.UserID, } return s.persistInTx(ctx, tx, bill, []string{"employee_collection", "bill", "recharge", uintText(record.ID), "create"}, "代理线下充值入账创建员工代收款账单", record.RechargeNo) } // persistInTx 以来源唯一键幂等写入账单:已存在同一来源账单时返回既有事实且不重复审计。 func (s *BillCreationService) persistInTx( ctx context.Context, tx *gorm.DB, bill *model.EmployeeCollectionBill, eventIDParts []string, summary string, correlationID string, ) (*model.EmployeeCollectionBill, error) { eventID, err := composeAuditEventID(eventIDParts...) if err != nil { return nil, err } result := tx.WithContext(ctx).Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "source_key"}}, DoNothing: true, }).Create(bill) if result.Error != nil { return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "创建员工代收款账单失败") } if result.RowsAffected == 0 { var existing model.EmployeeCollectionBill if err := tx.WithContext(ctx).Where("source_key = ?", bill.SourceKey).First(&existing).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取既有员工代收款账单失败") } return &existing, nil } if err := s.audit.WriteEmployeeCollectionBill(ctx, tx, BillAudit{ EventID: eventID, ActionCode: constants.AuditActionEmployeeCollectionBillCreated, Summary: summary, Bill: bill, AfterData: billAuditSnapshot(bill), CorrelationID: correlationID, }); err != nil { return nil, err } return bill, nil } // marshalSnapshot 将只读业务快照序列化为 jsonb,快照不得包含付款凭证或外部交易敏感内容。 func marshalSnapshot(snapshot map[string]any) ([]byte, error) { payload, err := sonic.Marshal(snapshot) if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "序列化员工代收款账单快照失败") } return payload, nil } // rechargeAccountName 读取充值发起账号名称用于欠款人快照;账号已被删除时留空名称。 func rechargeAccountName(ctx context.Context, tx *gorm.DB, accountID uint) (string, error) { var account model.Account if err := tx.WithContext(ctx).Unscoped().First(&account, accountID).Error; err != nil { if err == gorm.ErrRecordNotFound { return "", nil } return "", errors.Wrap(errors.CodeDatabaseError, err, "查询线下充值发起账号失败") } return account.Username, nil } // billAuditSnapshot 生成账单审计快照,只包含 ID、来源、金额与状态,不含凭证内容。 func billAuditSnapshot(bill *model.EmployeeCollectionBill) map[string]any { return map[string]any{ "id": bill.ID, "source_type": bill.SourceType, "source_id": bill.SourceID, "source_key": bill.SourceKey, "source_no": bill.SourceNo, "debtor_account_id": bill.DebtorAccountID, "receivable_amount": bill.ReceivableAmount, "received_amount": bill.ReceivedAmount, "reserved_amount": bill.ReservedAmount, "status": bill.Status, } }