All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m31s
1520 lines
70 KiB
Go
1520 lines
70 KiB
Go
package audit
|
||
|
||
import (
|
||
"context"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"gorm.io/gorm"
|
||
|
||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||
retentionquery "github.com/break/junhong_cmp_fiber/internal/query/retention"
|
||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||
)
|
||
|
||
// FinanceFilter 定义资金调查时间线的稳定业务筛选。
|
||
type FinanceFilter struct {
|
||
ShopID uint
|
||
WalletID uint
|
||
OrderID uint
|
||
OrderNo string
|
||
PaymentID uint
|
||
PaymentNo string
|
||
RefundID uint
|
||
RefundNo string
|
||
RechargeID uint
|
||
RechargeNo string
|
||
ApprovalInstanceID uint
|
||
ThirdPartyTradeNo string
|
||
ActorKind string
|
||
ActorID string
|
||
CorrelationID string
|
||
CreatedFrom *time.Time
|
||
CreatedTo *time.Time
|
||
Page int
|
||
PageSize int
|
||
}
|
||
|
||
// FinanceTimelinePage 是资金多源投影的稳定分页结果。
|
||
type FinanceTimelinePage struct {
|
||
Total int64 `json:"total" description:"关联资金事实总数"`
|
||
Page int `json:"page" description:"当前页码"`
|
||
PageSize int `json:"page_size" description:"每页数量"`
|
||
Items []FinanceTimelineNode `json:"items" description:"按发生时间稳定倒序的资金事实节点"`
|
||
Retention retentionquery.Info `json:"retention" description:"在线查询留存边界"`
|
||
}
|
||
|
||
// FinanceTimelineNode 是明确事实来源和金额权威的资金时间线节点。
|
||
type FinanceTimelineNode struct {
|
||
RecordSource string `json:"record_source" enum:"audit_event,domain_ledger_ref,agent_wallet_transaction,asset_wallet_transaction,agent_wallet_reservation,order,payment,refund,agent_recharge,recharge_order,commission_record,commission_withdrawal,approval_instance" description:"资金事实来源稳定编码"`
|
||
NodeID string `json:"node_id" description:"该事实来源内的稳定节点ID"`
|
||
OccurredAt time.Time `json:"occurred_at" description:"资金事实发生时间"`
|
||
Code string `json:"code" description:"来源内稳定业务动作或状态编码"`
|
||
Title string `json:"title" description:"code对应的中文展示名称"`
|
||
Result string `json:"result" description:"来源内原始结果或状态编码"`
|
||
ResultName string `json:"result_name" description:"result对应的中文展示名称"`
|
||
Amount *int64 `json:"amount" description:"本节点金额,单位分;为空表示该节点不承载金额"`
|
||
BalanceBefore *int64 `json:"balance_before" description:"变更前余额,单位分"`
|
||
BalanceAfter *int64 `json:"balance_after" description:"变更后余额,单位分"`
|
||
Currency string `json:"currency" description:"币种编码,人民币为CNY"`
|
||
ShopID *uint `json:"shop_id" description:"关联店铺ID"`
|
||
Wallet *FinanceWalletRef `json:"wallet" description:"关联钱包稳定引用"`
|
||
AmountAuthority FinanceAmountAuthority `json:"amount_authority" description:"金额是否权威及权威字段来源"`
|
||
Facts map[string]any `json:"facts" description:"该事实来源的安全结构化业务字段"`
|
||
InvestigationRefs InvestigationRefs `json:"investigation_refs" description:"可继续跳转的稳定调查引用"`
|
||
}
|
||
|
||
// FinanceWalletRef 是资金节点关联的钱包稳定引用。
|
||
type FinanceWalletRef struct {
|
||
ResourceType string `json:"resource_type" enum:"agent_wallet,asset_wallet" description:"钱包资源类型"`
|
||
WalletID uint `json:"wallet_id" description:"钱包内部稳定ID,可作为finance/timeline的wallet_id"`
|
||
}
|
||
|
||
// FinanceAmountAuthority 说明当前金额是否为业务权威及其字段来源。
|
||
type FinanceAmountAuthority struct {
|
||
Authoritative bool `json:"authoritative" description:"当前amount或余额是否来自业务权威表"`
|
||
Table string `json:"table" description:"权威金额所在业务表;非权威节点可为空"`
|
||
Field string `json:"field" description:"权威金额所在字段;非权威节点可为空"`
|
||
ConflictRule string `json:"conflict_rule" description:"多来源冲突时的取值规则说明"`
|
||
}
|
||
|
||
type financeRefs struct {
|
||
seeded bool
|
||
shops map[uint]struct{}
|
||
agentWallets map[uint]struct{}
|
||
assetWallets map[uint]struct{}
|
||
agentTxs map[uint]struct{}
|
||
assetTxs map[uint]struct{}
|
||
reservations map[uint]struct{}
|
||
orders map[uint]struct{}
|
||
orderNos map[string]struct{}
|
||
payments map[uint]struct{}
|
||
paymentNos map[string]struct{}
|
||
refunds map[uint]struct{}
|
||
refundNos map[string]struct{}
|
||
agentRecharges map[uint]struct{}
|
||
rechargeOrders map[uint]struct{}
|
||
rechargeNos map[string]struct{}
|
||
approvals map[uint]struct{}
|
||
commissions map[uint]struct{}
|
||
withdrawals map[uint]struct{}
|
||
tradeNos map[string]struct{}
|
||
}
|
||
|
||
// FinanceTimeline 查询资金审计与业务账本的只读组合时间线。
|
||
func (q *Query) FinanceTimeline(ctx context.Context, filter FinanceFilter) (*FinanceTimelinePage, error) {
|
||
if err := q.authorize(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceAudit)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
filter.CreatedFrom, filter.CreatedTo, err = retentionquery.NormalizeRange(retention, filter.CreatedFrom, filter.CreatedTo)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !validFinanceFilter(filter) {
|
||
return nil, errors.New(errors.CodeInvalidParam)
|
||
}
|
||
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
|
||
refs := newFinanceRefs(filter)
|
||
if err := q.seedFinanceActorAndCorrelation(ctx, filter, refs); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := q.expandFinanceRefs(ctx, refs); err != nil {
|
||
return nil, err
|
||
}
|
||
auditRows, auditTotal, err := q.loadFinanceAuditRows(ctx, filter, refs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := q.addAuditFinanceRefs(ctx, auditRows, refs); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := q.expandFinanceRefs(ctx, refs); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
limit := filter.Page * filter.PageSize
|
||
nodes, total, err := q.loadFinanceNodes(ctx, filter, refs, auditRows, auditTotal, limit)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
sort.Slice(nodes, func(i, j int) bool {
|
||
if nodes[i].OccurredAt.Equal(nodes[j].OccurredAt) {
|
||
if nodes[i].RecordSource == nodes[j].RecordSource {
|
||
return financeNodeIDAfter(nodes[i].NodeID, nodes[j].NodeID)
|
||
}
|
||
return nodes[i].RecordSource > nodes[j].RecordSource
|
||
}
|
||
return nodes[i].OccurredAt.After(nodes[j].OccurredAt)
|
||
})
|
||
start := (filter.Page - 1) * filter.PageSize
|
||
if start > len(nodes) {
|
||
start = len(nodes)
|
||
}
|
||
end := start + filter.PageSize
|
||
if end > len(nodes) {
|
||
end = len(nodes)
|
||
}
|
||
items := nodes[start:end]
|
||
if items == nil {
|
||
items = []FinanceTimelineNode{}
|
||
}
|
||
return &FinanceTimelinePage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items, Retention: retention}, nil
|
||
}
|
||
|
||
// validFinanceFilter 拒绝无条件全表扫描及不完整的操作者筛选。
|
||
func validFinanceFilter(filter FinanceFilter) bool {
|
||
if filter.Page < 0 || filter.PageSize < 0 || filter.PageSize > constants.MaxPageSize {
|
||
return false
|
||
}
|
||
if filter.CreatedFrom != nil && filter.CreatedTo != nil && !filter.CreatedFrom.Before(*filter.CreatedTo) {
|
||
return false
|
||
}
|
||
if filter.ActorKind != "" && !validActorKind(filter.ActorKind) {
|
||
return false
|
||
}
|
||
if (filter.ActorKind == "") != (filter.ActorID == "") {
|
||
return false
|
||
}
|
||
hasStableCondition := filter.ShopID != 0 || filter.WalletID != 0 || filter.OrderID != 0 || filter.OrderNo != "" ||
|
||
filter.PaymentID != 0 || filter.PaymentNo != "" || filter.RefundID != 0 || filter.RefundNo != "" ||
|
||
filter.RechargeID != 0 || filter.RechargeNo != "" || filter.ApprovalInstanceID != 0 ||
|
||
filter.ThirdPartyTradeNo != "" || filter.ActorID != "" || filter.CorrelationID != ""
|
||
return hasStableCondition || (filter.CreatedFrom != nil && filter.CreatedTo != nil)
|
||
}
|
||
|
||
// newFinanceRefs 将调用方提供的稳定条件初始化为关联解析种子。
|
||
func newFinanceRefs(filter FinanceFilter) *financeRefs {
|
||
refs := &financeRefs{
|
||
shops: make(map[uint]struct{}), agentWallets: make(map[uint]struct{}), assetWallets: make(map[uint]struct{}),
|
||
agentTxs: make(map[uint]struct{}), assetTxs: make(map[uint]struct{}), reservations: make(map[uint]struct{}),
|
||
orders: make(map[uint]struct{}), orderNos: make(map[string]struct{}), payments: make(map[uint]struct{}),
|
||
paymentNos: make(map[string]struct{}), refunds: make(map[uint]struct{}), refundNos: make(map[string]struct{}),
|
||
agentRecharges: make(map[uint]struct{}), rechargeOrders: make(map[uint]struct{}), rechargeNos: make(map[string]struct{}),
|
||
approvals: make(map[uint]struct{}), commissions: make(map[uint]struct{}), withdrawals: make(map[uint]struct{}),
|
||
tradeNos: make(map[string]struct{}),
|
||
}
|
||
addUint(refs.shops, filter.ShopID)
|
||
addUint(refs.agentWallets, filter.WalletID)
|
||
addUint(refs.assetWallets, filter.WalletID)
|
||
addUint(refs.orders, filter.OrderID)
|
||
addString(refs.orderNos, filter.OrderNo)
|
||
addUint(refs.payments, filter.PaymentID)
|
||
addString(refs.paymentNos, filter.PaymentNo)
|
||
addUint(refs.refunds, filter.RefundID)
|
||
addString(refs.refundNos, filter.RefundNo)
|
||
addUint(refs.agentRecharges, filter.RechargeID)
|
||
addUint(refs.rechargeOrders, filter.RechargeID)
|
||
addString(refs.rechargeNos, filter.RechargeNo)
|
||
addUint(refs.approvals, filter.ApprovalInstanceID)
|
||
addString(refs.tradeNos, filter.ThirdPartyTradeNo)
|
||
refs.seeded = filter.ShopID != 0 || filter.WalletID != 0 || filter.OrderID != 0 || filter.OrderNo != "" ||
|
||
filter.PaymentID != 0 || filter.PaymentNo != "" || filter.RefundID != 0 || filter.RefundNo != "" ||
|
||
filter.RechargeID != 0 || filter.RechargeNo != "" || filter.ApprovalInstanceID != 0 || filter.ThirdPartyTradeNo != ""
|
||
return refs
|
||
}
|
||
|
||
// seedFinanceActorAndCorrelation 使用明确持久化字段解析操作者和业务链路,不按时间邻近猜测。
|
||
func (q *Query) seedFinanceActorAndCorrelation(ctx context.Context, filter FinanceFilter, refs *financeRefs) error {
|
||
if filter.CorrelationID != "" {
|
||
rows := []model.ApprovalInstance{}
|
||
if err := q.db.WithContext(ctx).Where("correlation_id = ?", filter.CorrelationID).Find(&rows).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "解析资金关联审批链路失败")
|
||
}
|
||
for _, row := range rows {
|
||
addUint(refs.approvals, row.ID)
|
||
}
|
||
}
|
||
if filter.WalletID != 0 {
|
||
if err := q.seedFinanceWalletReferences(ctx, filter, refs); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if filter.ActorKind != constants.AuditActorAccount {
|
||
return nil
|
||
}
|
||
actorID64, err := strconv.ParseUint(filter.ActorID, 10, 64)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
actorID := uint(actorID64)
|
||
orderRows := []model.Order{}
|
||
orderQuery := applyFinanceTime(q.db.WithContext(ctx).Where("operator_account_id = ?", actorID), filter, "updated_at")
|
||
if err := orderQuery.Limit(filter.Page * filter.PageSize).Find(&orderRows).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "解析操作者关联订单失败")
|
||
}
|
||
for _, row := range orderRows {
|
||
addUint(refs.orders, row.ID)
|
||
}
|
||
agentRows := []model.AgentRechargeRecord{}
|
||
agentQuery := applyFinanceTime(q.db.WithContext(ctx).Where("user_id = ?", actorID), filter, "updated_at")
|
||
if err := agentQuery.Limit(filter.Page * filter.PageSize).Find(&agentRows).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "解析操作者关联代理充值失败")
|
||
}
|
||
for _, row := range agentRows {
|
||
addUint(refs.agentRecharges, row.ID)
|
||
}
|
||
rechargeRows := []model.RechargeOrder{}
|
||
rechargeQuery := applyFinanceTime(q.db.WithContext(ctx).Where("user_id = ?", actorID), filter, "updated_at")
|
||
if err := rechargeQuery.Limit(filter.Page * filter.PageSize).Find(&rechargeRows).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "解析操作者关联资产充值失败")
|
||
}
|
||
for _, row := range rechargeRows {
|
||
addUint(refs.rechargeOrders, row.ID)
|
||
}
|
||
approvalRows := []model.ApprovalInstance{}
|
||
approvalQuery := applyFinanceTime(q.db.WithContext(ctx).Where("submitter_account_id = ?", actorID), filter, "status_changed_at")
|
||
if err := approvalQuery.Limit(filter.Page * filter.PageSize).Find(&approvalRows).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "解析操作者关联审批失败")
|
||
}
|
||
for _, row := range approvalRows {
|
||
addUint(refs.approvals, row.ID)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// seedFinanceWalletReferences 从当前分页窗口的唯一流水引用解析业务单据。
|
||
func (q *Query) seedFinanceWalletReferences(ctx context.Context, filter FinanceFilter, refs *financeRefs) error {
|
||
limit := filter.Page * filter.PageSize
|
||
agentRows := []model.AgentWalletTransaction{}
|
||
agentQuery := applyFinanceTime(q.db.WithContext(ctx).Where("agent_wallet_id = ?", filter.WalletID), filter, "created_at")
|
||
if err := agentQuery.Order("created_at DESC, id DESC").Limit(limit).Find(&agentRows).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "解析代理钱包关联业务失败")
|
||
}
|
||
for _, row := range agentRows {
|
||
addUint(refs.agentTxs, row.ID)
|
||
if row.ReferenceType == nil || row.ReferenceID == nil {
|
||
continue
|
||
}
|
||
switch *row.ReferenceType {
|
||
case constants.ReferenceTypeOrder:
|
||
addUint(refs.orders, *row.ReferenceID)
|
||
case constants.ReferenceTypeRefund:
|
||
addUint(refs.refunds, *row.ReferenceID)
|
||
case constants.ReferenceTypeTopup:
|
||
addUint(refs.agentRecharges, *row.ReferenceID)
|
||
case constants.ReferenceTypeCommission:
|
||
addUint(refs.commissions, *row.ReferenceID)
|
||
case constants.ReferenceTypeWithdrawal:
|
||
addUint(refs.withdrawals, *row.ReferenceID)
|
||
}
|
||
}
|
||
assetRows := []model.AssetWalletTransaction{}
|
||
assetQuery := applyFinanceTime(q.db.WithContext(ctx).Where("asset_wallet_id = ?", filter.WalletID), filter, "created_at")
|
||
if err := assetQuery.Order("created_at DESC, id DESC").Limit(limit).Find(&assetRows).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "解析资产钱包关联业务失败")
|
||
}
|
||
for _, row := range assetRows {
|
||
addUint(refs.assetTxs, row.ID)
|
||
if row.ReferenceType == nil || row.ReferenceNo == nil {
|
||
continue
|
||
}
|
||
switch *row.ReferenceType {
|
||
case constants.ReferenceTypeOrder:
|
||
addString(refs.orderNos, *row.ReferenceNo)
|
||
case constants.ReferenceTypeRefund:
|
||
addString(refs.refundNos, *row.ReferenceNo)
|
||
case constants.ReferenceTypeTopup:
|
||
addString(refs.rechargeNos, *row.ReferenceNo)
|
||
case constants.ReferenceTypeRecharge:
|
||
addString(refs.paymentNos, *row.ReferenceNo)
|
||
}
|
||
}
|
||
reservationRows := []model.AgentWalletReservation{}
|
||
reservationQuery := applyFinanceTime(q.db.WithContext(ctx).Where("agent_wallet_id = ?", filter.WalletID), filter, "updated_at")
|
||
if err := reservationQuery.Order("updated_at DESC, id DESC").Limit(limit).Find(&reservationRows).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "解析代理钱包预占关联业务失败")
|
||
}
|
||
for _, row := range reservationRows {
|
||
addUint(refs.reservations, row.ID)
|
||
if row.ReferenceType == constants.ReferenceTypeOrder {
|
||
addUint(refs.orders, row.ReferenceID)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// expandFinanceRefs 以固定轮次展开支付、订单、退款、充值和审批的确定性关系。
|
||
func (q *Query) expandFinanceRefs(ctx context.Context, refs *financeRefs) error {
|
||
for range 2 {
|
||
if err := q.expandFinanceRecords(ctx, refs); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// expandFinanceRecords 批量读取各业务表,避免按时间线节点逐条回查。
|
||
func (q *Query) expandFinanceRecords(ctx context.Context, refs *financeRefs) error {
|
||
if err := q.expandOrders(ctx, refs); err != nil {
|
||
return err
|
||
}
|
||
if err := q.expandPayments(ctx, refs); err != nil {
|
||
return err
|
||
}
|
||
if err := q.expandRefunds(ctx, refs); err != nil {
|
||
return err
|
||
}
|
||
if err := q.expandRecharges(ctx, refs); err != nil {
|
||
return err
|
||
}
|
||
return q.expandApprovals(ctx, refs)
|
||
}
|
||
|
||
// expandOrders 解析订单编号、店铺和后续支付所需的内部 ID。
|
||
func (q *Query) expandOrders(ctx context.Context, refs *financeRefs) error {
|
||
conditions, args := make([]string, 0, 2), make([]any, 0, 2)
|
||
if len(refs.orders) > 0 {
|
||
conditions, args = append(conditions, "id IN ?"), append(args, uintKeys(refs.orders))
|
||
}
|
||
if len(refs.orderNos) > 0 {
|
||
conditions, args = append(conditions, "order_no IN ?"), append(args, stringKeys(refs.orderNos))
|
||
}
|
||
if len(conditions) == 0 {
|
||
return nil
|
||
}
|
||
rows := []model.Order{}
|
||
if err := q.db.WithContext(ctx).Where(strings.Join(conditions, " OR "), args...).Find(&rows).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "解析资金关联订单失败")
|
||
}
|
||
for _, row := range rows {
|
||
addUint(refs.orders, row.ID)
|
||
addString(refs.orderNos, row.OrderNo)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// expandPayments 解析支付单、渠道交易号及其明确业务单类型。
|
||
func (q *Query) expandPayments(ctx context.Context, refs *financeRefs) error {
|
||
conditions, args := make([]string, 0, 5), make([]any, 0, 5)
|
||
if len(refs.payments) > 0 {
|
||
conditions, args = append(conditions, "id IN ?"), append(args, uintKeys(refs.payments))
|
||
}
|
||
if len(refs.paymentNos) > 0 {
|
||
conditions, args = append(conditions, "payment_no IN ?"), append(args, stringKeys(refs.paymentNos))
|
||
}
|
||
if len(refs.tradeNos) > 0 {
|
||
conditions, args = append(conditions, "third_party_trade_no IN ?"), append(args, stringKeys(refs.tradeNos))
|
||
}
|
||
if len(refs.orders) > 0 {
|
||
conditions, args = append(conditions, "order_type = ? AND order_id IN ?"), append(args, model.PaymentOrderTypePackage, uintKeys(refs.orders))
|
||
}
|
||
if len(refs.agentRecharges) > 0 {
|
||
conditions, args = append(conditions, "order_type = ? AND order_id IN ?"), append(args, model.PaymentOrderTypeAgentRecharge, uintKeys(refs.agentRecharges))
|
||
}
|
||
if len(refs.rechargeOrders) > 0 {
|
||
conditions, args = append(conditions, "order_type = ? AND order_id IN ?"), append(args, model.PaymentOrderTypeRecharge, uintKeys(refs.rechargeOrders))
|
||
}
|
||
if len(conditions) == 0 {
|
||
return nil
|
||
}
|
||
rows := []model.Payment{}
|
||
if err := q.db.WithContext(ctx).Where("("+strings.Join(conditions, ") OR (")+")", args...).Find(&rows).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "解析资金关联支付失败")
|
||
}
|
||
for _, row := range rows {
|
||
addUint(refs.payments, row.ID)
|
||
addString(refs.paymentNos, row.PaymentNo)
|
||
addString(refs.tradeNos, row.ThirdPartyTradeNo)
|
||
switch row.OrderType {
|
||
case model.PaymentOrderTypePackage:
|
||
addUint(refs.orders, row.OrderID)
|
||
case model.PaymentOrderTypeAgentRecharge:
|
||
addUint(refs.agentRecharges, row.OrderID)
|
||
case model.PaymentOrderTypeRecharge:
|
||
addUint(refs.rechargeOrders, row.OrderID)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// expandRefunds 解析退款与订单、审批的稳定关系。
|
||
func (q *Query) expandRefunds(ctx context.Context, refs *financeRefs) error {
|
||
conditions, args := make([]string, 0, 4), make([]any, 0, 4)
|
||
if len(refs.refunds) > 0 {
|
||
conditions, args = append(conditions, "id IN ?"), append(args, uintKeys(refs.refunds))
|
||
}
|
||
if len(refs.refundNos) > 0 {
|
||
conditions, args = append(conditions, "refund_no IN ?"), append(args, stringKeys(refs.refundNos))
|
||
}
|
||
if len(refs.orders) > 0 {
|
||
conditions, args = append(conditions, "order_id IN ?"), append(args, uintKeys(refs.orders))
|
||
}
|
||
if len(refs.approvals) > 0 {
|
||
conditions, args = append(conditions, "approval_instance_id IN ?"), append(args, uintKeys(refs.approvals))
|
||
}
|
||
if len(conditions) == 0 {
|
||
return nil
|
||
}
|
||
rows := []model.RefundRequest{}
|
||
if err := q.db.WithContext(ctx).Where(strings.Join(conditions, " OR "), args...).Find(&rows).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "解析资金关联退款失败")
|
||
}
|
||
for _, row := range rows {
|
||
addUint(refs.refunds, row.ID)
|
||
addString(refs.refundNos, row.RefundNo)
|
||
addUint(refs.orders, row.OrderID)
|
||
if row.ApprovalInstanceID != nil {
|
||
addUint(refs.approvals, *row.ApprovalInstanceID)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// expandRecharges 同时解析代理充值和个人资产充值两类既有业务单。
|
||
func (q *Query) expandRecharges(ctx context.Context, refs *financeRefs) error {
|
||
agentConditions, agentArgs := make([]string, 0, 4), make([]any, 0, 4)
|
||
if len(refs.agentRecharges) > 0 {
|
||
agentConditions, agentArgs = append(agentConditions, "id IN ?"), append(agentArgs, uintKeys(refs.agentRecharges))
|
||
}
|
||
if len(refs.rechargeNos) > 0 {
|
||
agentConditions, agentArgs = append(agentConditions, "recharge_no IN ?"), append(agentArgs, stringKeys(refs.rechargeNos))
|
||
}
|
||
if len(refs.tradeNos) > 0 {
|
||
agentConditions, agentArgs = append(agentConditions, "payment_transaction_id IN ?"), append(agentArgs, stringKeys(refs.tradeNos))
|
||
}
|
||
if len(refs.approvals) > 0 {
|
||
agentConditions, agentArgs = append(agentConditions, "approval_instance_id IN ?"), append(agentArgs, uintKeys(refs.approvals))
|
||
}
|
||
if len(agentConditions) > 0 {
|
||
rows := []model.AgentRechargeRecord{}
|
||
if err := q.db.WithContext(ctx).Where(strings.Join(agentConditions, " OR "), agentArgs...).Find(&rows).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "解析资金关联代理充值失败")
|
||
}
|
||
for _, row := range rows {
|
||
addUint(refs.agentRecharges, row.ID)
|
||
addString(refs.rechargeNos, row.RechargeNo)
|
||
if row.ApprovalInstanceID != nil {
|
||
addUint(refs.approvals, *row.ApprovalInstanceID)
|
||
}
|
||
if row.PaymentTransactionID != nil {
|
||
addString(refs.tradeNos, *row.PaymentTransactionID)
|
||
}
|
||
}
|
||
}
|
||
personalConditions, personalArgs := make([]string, 0, 2), make([]any, 0, 2)
|
||
if len(refs.rechargeOrders) > 0 {
|
||
personalConditions, personalArgs = append(personalConditions, "id IN ?"), append(personalArgs, uintKeys(refs.rechargeOrders))
|
||
}
|
||
if len(refs.rechargeNos) > 0 {
|
||
personalConditions, personalArgs = append(personalConditions, "recharge_order_no IN ?"), append(personalArgs, stringKeys(refs.rechargeNos))
|
||
}
|
||
if len(personalConditions) == 0 {
|
||
return nil
|
||
}
|
||
rows := []model.RechargeOrder{}
|
||
if err := q.db.WithContext(ctx).Where(strings.Join(personalConditions, " OR "), personalArgs...).Find(&rows).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "解析资金关联资产充值失败")
|
||
}
|
||
for _, row := range rows {
|
||
addUint(refs.rechargeOrders, row.ID)
|
||
addString(refs.rechargeNos, row.RechargeOrderNo)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// expandApprovals 只按审批业务类型关联退款或线下代理充值。
|
||
func (q *Query) expandApprovals(ctx context.Context, refs *financeRefs) error {
|
||
conditions, args := make([]string, 0, 3), make([]any, 0, 3)
|
||
if len(refs.approvals) > 0 {
|
||
conditions, args = append(conditions, "id IN ?"), append(args, uintKeys(refs.approvals))
|
||
}
|
||
if len(refs.refunds) > 0 {
|
||
conditions, args = append(conditions, "business_type = ? AND business_id IN ?"), append(args, constants.ApprovalBusinessTypeRefund, uintKeys(refs.refunds))
|
||
}
|
||
if len(refs.agentRecharges) > 0 {
|
||
conditions, args = append(conditions, "business_type = ? AND business_id IN ?"), append(args, constants.ApprovalBusinessTypeOfflineRecharge, uintKeys(refs.agentRecharges))
|
||
}
|
||
if len(conditions) == 0 {
|
||
return nil
|
||
}
|
||
rows := []model.ApprovalInstance{}
|
||
if err := q.db.WithContext(ctx).Where("("+strings.Join(conditions, ") OR (")+")", args...).Find(&rows).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "解析资金关联审批失败")
|
||
}
|
||
for _, row := range rows {
|
||
addUint(refs.approvals, row.ID)
|
||
switch row.BusinessType {
|
||
case constants.ApprovalBusinessTypeRefund:
|
||
addUint(refs.refunds, row.BusinessID)
|
||
case constants.ApprovalBusinessTypeOfflineRecharge:
|
||
addUint(refs.agentRecharges, row.BusinessID)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// loadFinanceAuditRows 只读取具有资金资源的审计事件,并保留操作者权威。
|
||
func (q *Query) loadFinanceAuditRows(ctx context.Context, filter FinanceFilter, refs *financeRefs) ([]model.AuditEvent, int64, error) {
|
||
resourceTypes := []string{
|
||
constants.AuditResourceOrder, constants.AuditResourcePayment, constants.AuditResourceRefund,
|
||
constants.AuditResourceAgentRecharge, constants.AuditResourceRechargeOrder,
|
||
constants.AuditResourceAgentWallet, constants.AuditResourceAgentWalletTransaction,
|
||
constants.AuditResourceAgentWalletReservation, constants.AuditResourceAssetWallet,
|
||
constants.AuditResourceAssetWalletTransaction, constants.AuditResourceCommissionRecord,
|
||
constants.AuditResourceCommissionWithdrawal, constants.AuditResourceApprovalInstance,
|
||
}
|
||
query := q.db.WithContext(ctx).Model(&model.AuditEvent{}).
|
||
Where("EXISTS (?)", q.db.Table("tb_audit_event_resource AS finance_resource").Select("1").
|
||
Where("finance_resource.audit_event_id = tb_audit_event.id").
|
||
Where("finance_resource.resource_type IN ?", resourceTypes))
|
||
query = applyFinanceTime(query, filter, "occurred_at")
|
||
if filter.ActorKind != "" {
|
||
query = query.Where("actor_kind = ? AND actor_id = ?", filter.ActorKind, filter.ActorID)
|
||
}
|
||
if filter.CorrelationID != "" {
|
||
query = query.Where("correlation_id = ?", filter.CorrelationID)
|
||
}
|
||
if refs.seeded {
|
||
predicate, args := financeResourcePredicate("matched_resource", refs)
|
||
if predicate == "" {
|
||
return []model.AuditEvent{}, 0, nil
|
||
}
|
||
query = query.Where("EXISTS (?)", q.db.Table("tb_audit_event_resource AS matched_resource").Select("1").
|
||
Where("matched_resource.audit_event_id = tb_audit_event.id").Where(predicate, args...))
|
||
}
|
||
var total int64
|
||
if err := query.Count(&total).Error; err != nil {
|
||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计资金关联审计事件失败")
|
||
}
|
||
rows := []model.AuditEvent{}
|
||
if err := query.Order("occurred_at DESC, event_id DESC").Limit(filter.Page * filter.PageSize).Find(&rows).Error; err != nil {
|
||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资金关联审计事件失败")
|
||
}
|
||
return rows, total, nil
|
||
}
|
||
|
||
// addAuditFinanceRefs 从事件资源读取稳定业务 ID,不解析摘要或中文描述。
|
||
func (q *Query) addAuditFinanceRefs(ctx context.Context, events []model.AuditEvent, refs *financeRefs) error {
|
||
if len(events) == 0 {
|
||
return nil
|
||
}
|
||
ids := make([]uint, 0, len(events))
|
||
for _, event := range events {
|
||
ids = append(ids, event.ID)
|
||
}
|
||
rows := []model.AuditEventResource{}
|
||
if err := q.db.WithContext(ctx).Where("audit_event_id IN ?", ids).Find(&rows).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "解析资金审计资源失败")
|
||
}
|
||
for _, row := range rows {
|
||
id, ok := parseResourceUint(row.ResourceID)
|
||
switch row.ResourceType {
|
||
case constants.AuditResourceShop:
|
||
// 店铺只接受显式筛选,避免单笔业务链路扩散成整个店铺资金历史。
|
||
case constants.AuditResourceOrder:
|
||
if ok {
|
||
addUint(refs.orders, id)
|
||
}
|
||
addString(refs.orderNos, row.ResourceKey)
|
||
case constants.AuditResourcePayment:
|
||
if ok {
|
||
addUint(refs.payments, id)
|
||
}
|
||
addString(refs.paymentNos, row.ResourceKey)
|
||
case constants.AuditResourceRefund:
|
||
if ok {
|
||
addUint(refs.refunds, id)
|
||
}
|
||
addString(refs.refundNos, row.ResourceKey)
|
||
case constants.AuditResourceAgentRecharge:
|
||
if ok {
|
||
addUint(refs.agentRecharges, id)
|
||
}
|
||
addString(refs.rechargeNos, row.ResourceKey)
|
||
case constants.AuditResourceRechargeOrder:
|
||
if ok {
|
||
addUint(refs.rechargeOrders, id)
|
||
}
|
||
addString(refs.rechargeNos, row.ResourceKey)
|
||
case constants.AuditResourceAgentWallet:
|
||
// 钱包只接受显式筛选,关联业务使用唯一流水资源继续解析。
|
||
case constants.AuditResourceAssetWallet:
|
||
// 钱包只接受显式筛选,关联业务使用唯一流水资源继续解析。
|
||
case constants.AuditResourceAgentWalletTransaction:
|
||
if ok {
|
||
addUint(refs.agentTxs, id)
|
||
}
|
||
case constants.AuditResourceAssetWalletTransaction:
|
||
if ok {
|
||
addUint(refs.assetTxs, id)
|
||
}
|
||
case constants.AuditResourceAgentWalletReservation:
|
||
if ok {
|
||
addUint(refs.reservations, id)
|
||
}
|
||
case constants.AuditResourceApprovalInstance:
|
||
if ok {
|
||
addUint(refs.approvals, id)
|
||
}
|
||
case constants.AuditResourceCommissionRecord:
|
||
if ok {
|
||
addUint(refs.commissions, id)
|
||
}
|
||
case constants.AuditResourceCommissionWithdrawal:
|
||
if ok {
|
||
addUint(refs.withdrawals, id)
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// financeResourcePredicate 生成仅包含已注册资金资源的参数化匹配条件。
|
||
func financeResourcePredicate(alias string, refs *financeRefs) (string, []any) {
|
||
type resourceMatch struct {
|
||
resourceType string
|
||
ids map[uint]struct{}
|
||
keys map[string]struct{}
|
||
}
|
||
matches := []resourceMatch{
|
||
{constants.AuditResourceShop, refs.shops, nil},
|
||
{constants.AuditResourceOrder, refs.orders, refs.orderNos},
|
||
{constants.AuditResourcePayment, refs.payments, refs.paymentNos},
|
||
{constants.AuditResourceRefund, refs.refunds, refs.refundNos},
|
||
{constants.AuditResourceAgentRecharge, refs.agentRecharges, refs.rechargeNos},
|
||
{constants.AuditResourceRechargeOrder, refs.rechargeOrders, refs.rechargeNos},
|
||
{constants.AuditResourceAgentWallet, refs.agentWallets, nil},
|
||
{constants.AuditResourceAssetWallet, refs.assetWallets, nil},
|
||
{constants.AuditResourceAgentWalletTransaction, refs.agentTxs, nil},
|
||
{constants.AuditResourceAssetWalletTransaction, refs.assetTxs, nil},
|
||
{constants.AuditResourceAgentWalletReservation, refs.reservations, nil},
|
||
{constants.AuditResourceApprovalInstance, refs.approvals, nil},
|
||
{constants.AuditResourceCommissionRecord, refs.commissions, nil},
|
||
{constants.AuditResourceCommissionWithdrawal, refs.withdrawals, nil},
|
||
}
|
||
conditions, args := make([]string, 0, len(matches)*2), make([]any, 0, len(matches)*2)
|
||
for _, match := range matches {
|
||
if len(match.ids) > 0 {
|
||
conditions = append(conditions, "("+alias+".resource_type = ? AND "+alias+".resource_id IN ?)")
|
||
args = append(args, match.resourceType, stringUintKeys(match.ids))
|
||
}
|
||
if len(match.keys) > 0 {
|
||
conditions = append(conditions, "("+alias+".resource_type = ? AND "+alias+".resource_key IN ?)")
|
||
args = append(args, match.resourceType, stringKeys(match.keys))
|
||
}
|
||
}
|
||
return strings.Join(conditions, " OR "), args
|
||
}
|
||
|
||
// loadFinanceNodes 以固定查询数读取各事实源后统一分页,不产生节点级 N+1。
|
||
func (q *Query) loadFinanceNodes(ctx context.Context, filter FinanceFilter, refs *financeRefs, auditRows []model.AuditEvent, auditTotal int64, limit int) ([]FinanceTimelineNode, int64, error) {
|
||
nodes, err := q.financeAuditNodes(ctx, auditRows)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
total := auditTotal
|
||
loaders := []func() ([]FinanceTimelineNode, int64, error){
|
||
func() ([]FinanceTimelineNode, int64, error) {
|
||
return q.loadAgentWalletFinance(ctx, filter, refs, limit)
|
||
},
|
||
func() ([]FinanceTimelineNode, int64, error) {
|
||
return q.loadAssetWalletFinance(ctx, filter, refs, limit)
|
||
},
|
||
func() ([]FinanceTimelineNode, int64, error) {
|
||
return q.loadReservationFinance(ctx, filter, refs, limit)
|
||
},
|
||
func() ([]FinanceTimelineNode, int64, error) { return q.loadOrderFinance(ctx, filter, refs, limit) },
|
||
func() ([]FinanceTimelineNode, int64, error) { return q.loadPaymentFinance(ctx, filter, refs, limit) },
|
||
func() ([]FinanceTimelineNode, int64, error) { return q.loadRefundFinance(ctx, filter, refs, limit) },
|
||
func() ([]FinanceTimelineNode, int64, error) {
|
||
return q.loadAgentRechargeFinance(ctx, filter, refs, limit)
|
||
},
|
||
func() ([]FinanceTimelineNode, int64, error) {
|
||
return q.loadRechargeOrderFinance(ctx, filter, refs, limit)
|
||
},
|
||
func() ([]FinanceTimelineNode, int64, error) { return q.loadCommissionFinance(ctx, filter, refs, limit) },
|
||
func() ([]FinanceTimelineNode, int64, error) { return q.loadWithdrawalFinance(ctx, filter, refs, limit) },
|
||
func() ([]FinanceTimelineNode, int64, error) { return q.loadApprovalFinance(ctx, filter, refs, limit) },
|
||
}
|
||
for _, load := range loaders {
|
||
items, count, loadErr := load()
|
||
if loadErr != nil {
|
||
return nil, 0, loadErr
|
||
}
|
||
nodes = append(nodes, items...)
|
||
total += count
|
||
}
|
||
return nodes, total, nil
|
||
}
|
||
|
||
// financeAuditNodes 投影操作者事实,明确其金额不是资金权威。
|
||
func (q *Query) financeAuditNodes(ctx context.Context, rows []model.AuditEvent) ([]FinanceTimelineNode, error) {
|
||
events, err := q.project(ctx, rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
integrationRows := []model.IntegrationLog{}
|
||
ids := make([]uint, 0, len(rows))
|
||
for _, row := range rows {
|
||
ids = append(ids, row.ID)
|
||
}
|
||
if len(ids) > 0 {
|
||
if err := q.db.WithContext(ctx).Where("audit_event_id IN ?", ids).Find(&integrationRows).Error; err != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资金审计外部交互引用失败")
|
||
}
|
||
}
|
||
integrationByAudit := integrationRefsByAuditID(integrationRows)
|
||
nodes := make([]FinanceTimelineNode, 0, len(events))
|
||
for index, event := range events {
|
||
refs := event.InvestigationRefs
|
||
refs.IntegrationRefs = uniqueIntegrationRefs(append(refs.IntegrationRefs, integrationByAudit[rows[index].ID]...))
|
||
nodes = append(nodes, FinanceTimelineNode{
|
||
RecordSource: constants.AuditRecordSourceAuditEvent, NodeID: event.EventID, OccurredAt: event.OccurredAt,
|
||
Code: event.ActionCode, Title: event.ActionName, Result: event.Result, ResultName: auditResultName(event.Result),
|
||
AmountAuthority: nonAuthoritativeAuditAmount(),
|
||
Facts: map[string]any{"summary": event.Summary, "metadata": event.Metadata, "risk_level": event.RiskLevel},
|
||
InvestigationRefs: refs,
|
||
})
|
||
}
|
||
return nodes, nil
|
||
}
|
||
|
||
// loadAgentWalletFinance 读取代理钱包金额与余额权威流水。
|
||
func (q *Query) loadAgentWalletFinance(ctx context.Context, filter FinanceFilter, refs *financeRefs, limit int) ([]FinanceTimelineNode, int64, error) {
|
||
query := q.db.WithContext(ctx).Model(&model.AgentWalletTransaction{})
|
||
query = applyFinanceTime(query, filter, "created_at")
|
||
conditions, args := make([]string, 0, 8), make([]any, 0, 8)
|
||
appendUintCondition(&conditions, &args, "id", refs.agentTxs)
|
||
appendUintCondition(&conditions, &args, "agent_wallet_id", refs.agentWallets)
|
||
appendUintCondition(&conditions, &args, "shop_id", refs.shops)
|
||
appendReferenceIDCondition(&conditions, &args, constants.ReferenceTypeOrder, refs.orders)
|
||
appendReferenceIDCondition(&conditions, &args, constants.ReferenceTypeRefund, refs.refunds)
|
||
appendReferenceIDCondition(&conditions, &args, constants.ReferenceTypeTopup, refs.agentRecharges)
|
||
appendReferenceIDCondition(&conditions, &args, constants.ReferenceTypeCommission, refs.commissions)
|
||
appendReferenceIDCondition(&conditions, &args, constants.ReferenceTypeWithdrawal, refs.withdrawals)
|
||
appendAccountActorCondition(&conditions, &args, filter, "user_id", "creator")
|
||
query = applyFinanceRelationship(query, filter, conditions, args)
|
||
rows, total, err := loadFinanceRows[model.AgentWalletTransaction](query, "created_at DESC, id DESC", limit, "代理钱包流水")
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
nodes := make([]FinanceTimelineNode, 0, len(rows))
|
||
for _, row := range rows {
|
||
amount, before, after := row.Amount, row.BalanceBefore, row.BalanceAfter
|
||
resourceID := strconv.FormatUint(uint64(row.ID), 10)
|
||
refsView := ledgerRefs(constants.AuditResourceAgentWalletTransaction, resourceID, resourceID, row.UserID)
|
||
refsView.ResourceRefs = append(refsView.ResourceRefs, financeResourceRef(constants.AuditResourceAgentWallet, row.AgentWalletID, ""))
|
||
refsView.ResourceRefs = append(refsView.ResourceRefs, agentTransactionBusinessRef(row)...)
|
||
nodes = append(nodes, FinanceTimelineNode{
|
||
RecordSource: constants.AuditRecordSourceAgentWalletTransaction, NodeID: resourceID, OccurredAt: row.CreatedAt,
|
||
Code: row.TransactionType, Title: "代理钱包" + constants.GetAgentTransactionTypeName(row.TransactionType),
|
||
Result: strconv.Itoa(row.Status), ResultName: constants.GetTransactionStatusName(row.Status),
|
||
Amount: &amount, BalanceBefore: &before, BalanceAfter: &after, Currency: "CNY", ShopID: &row.ShopID,
|
||
Wallet: &FinanceWalletRef{ResourceType: constants.AuditResourceAgentWallet, WalletID: row.AgentWalletID},
|
||
AmountAuthority: authoritativeAmount("tb_agent_wallet_transaction", "amount,balance_before,balance_after"),
|
||
Facts: map[string]any{"reference_type": row.ReferenceType, "reference_id": row.ReferenceID, "transaction_subtype": row.TransactionSubtype, "asset_type": row.AssetType, "asset_id": row.AssetID, "asset_identifier": row.AssetIdentifier},
|
||
InvestigationRefs: refsView,
|
||
})
|
||
}
|
||
return nodes, total, nil
|
||
}
|
||
|
||
// loadAssetWalletFinance 读取卡或设备钱包金额与余额权威流水。
|
||
func (q *Query) loadAssetWalletFinance(ctx context.Context, filter FinanceFilter, refs *financeRefs, limit int) ([]FinanceTimelineNode, int64, error) {
|
||
query := q.db.WithContext(ctx).Model(&model.AssetWalletTransaction{})
|
||
query = applyFinanceTime(query, filter, "created_at")
|
||
conditions, args := make([]string, 0, 6), make([]any, 0, 6)
|
||
appendUintCondition(&conditions, &args, "id", refs.assetTxs)
|
||
appendUintCondition(&conditions, &args, "asset_wallet_id", refs.assetWallets)
|
||
appendUintCondition(&conditions, &args, "shop_id_tag", refs.shops)
|
||
appendReferenceNoCondition(&conditions, &args, constants.ReferenceTypeOrder, refs.orderNos)
|
||
appendReferenceNoCondition(&conditions, &args, constants.ReferenceTypeRefund, refs.refundNos)
|
||
appendReferenceNoCondition(&conditions, &args, constants.ReferenceTypeTopup, refs.rechargeNos)
|
||
appendReferenceNoCondition(&conditions, &args, constants.ReferenceTypeRecharge, refs.paymentNos)
|
||
appendAccountActorCondition(&conditions, &args, filter, "user_id", "creator")
|
||
query = applyFinanceRelationship(query, filter, conditions, args)
|
||
rows, total, err := loadFinanceRows[model.AssetWalletTransaction](query, "created_at DESC, id DESC", limit, "资产钱包流水")
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
nodes := make([]FinanceTimelineNode, 0, len(rows))
|
||
for _, row := range rows {
|
||
amount, before, after := row.Amount, row.BalanceBefore, row.BalanceAfter
|
||
resourceID := strconv.FormatUint(uint64(row.ID), 10)
|
||
refsView := ledgerRefs(constants.AuditResourceAssetWalletTransaction, resourceID, resourceID, row.UserID)
|
||
refsView.ResourceRefs = append(refsView.ResourceRefs, financeResourceRef(constants.AuditResourceAssetWallet, row.AssetWalletID, ""))
|
||
refsView.ResourceRefs = append(refsView.ResourceRefs, assetTransactionBusinessRef(row)...)
|
||
nodes = append(nodes, FinanceTimelineNode{
|
||
RecordSource: constants.AuditRecordSourceAssetWalletTransaction, NodeID: resourceID, OccurredAt: row.CreatedAt,
|
||
Code: row.TransactionType, Title: "资产钱包" + assetTransactionTypeName(row.TransactionType),
|
||
Result: strconv.Itoa(row.Status), ResultName: constants.GetTransactionStatusName(row.Status),
|
||
Amount: &amount, BalanceBefore: &before, BalanceAfter: &after, Currency: "CNY", ShopID: &row.ShopIDTag,
|
||
Wallet: &FinanceWalletRef{ResourceType: constants.AuditResourceAssetWallet, WalletID: row.AssetWalletID},
|
||
AmountAuthority: authoritativeAmount("tb_asset_wallet_transaction", "amount,balance_before,balance_after"),
|
||
Facts: map[string]any{"reference_type": row.ReferenceType, "reference_no": row.ReferenceNo, "resource_type": row.ResourceType, "resource_id": row.ResourceID},
|
||
InvestigationRefs: refsView,
|
||
})
|
||
}
|
||
return nodes, total, nil
|
||
}
|
||
|
||
// loadReservationFinance 读取代理主钱包预占及唯一终态。
|
||
func (q *Query) loadReservationFinance(ctx context.Context, filter FinanceFilter, refs *financeRefs, limit int) ([]FinanceTimelineNode, int64, error) {
|
||
query := q.db.WithContext(ctx).Model(&model.AgentWalletReservation{})
|
||
query = applyFinanceTime(query, filter, "updated_at")
|
||
conditions, args := make([]string, 0, 4), make([]any, 0, 4)
|
||
appendUintCondition(&conditions, &args, "id", refs.reservations)
|
||
appendUintCondition(&conditions, &args, "agent_wallet_id", refs.agentWallets)
|
||
appendUintCondition(&conditions, &args, "shop_id", refs.shops)
|
||
appendReservationReferenceCondition(&conditions, &args, constants.ReferenceTypeOrder, refs.orders)
|
||
appendAccountActorCondition(&conditions, &args, filter, "creator")
|
||
query = applyFinanceRelationship(query, filter, conditions, args)
|
||
rows, total, err := loadFinanceRows[model.AgentWalletReservation](query, "updated_at DESC, id DESC", limit, "代理钱包预占")
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
nodes := make([]FinanceTimelineNode, 0, len(rows))
|
||
for _, row := range rows {
|
||
amount := row.Amount
|
||
resourceID := strconv.FormatUint(uint64(row.ID), 10)
|
||
refsView := ledgerRefs(constants.AuditResourceAgentWalletReservation, resourceID, row.ReferenceType+":"+strconv.FormatUint(uint64(row.ReferenceID), 10), row.Creator)
|
||
refsView.ResourceRefs = append(refsView.ResourceRefs, financeResourceRef(constants.AuditResourceAgentWallet, row.AgentWalletID, ""))
|
||
nodes = append(nodes, FinanceTimelineNode{
|
||
RecordSource: constants.AuditRecordSourceAgentWalletReservation, NodeID: resourceID, OccurredAt: row.UpdatedAt,
|
||
Code: row.ReferenceType, Title: "代理钱包资金预占", Result: strconv.Itoa(row.Status), ResultName: reservationStatusName(row.Status),
|
||
Amount: &amount, Currency: "CNY", ShopID: &row.ShopID,
|
||
Wallet: &FinanceWalletRef{ResourceType: constants.AuditResourceAgentWallet, WalletID: row.AgentWalletID},
|
||
AmountAuthority: authoritativeAmount("tb_agent_wallet_reservation", "amount"),
|
||
Facts: map[string]any{"reference_type": row.ReferenceType, "reference_id": row.ReferenceID, "completed_at": row.CompletedAt},
|
||
InvestigationRefs: refsView,
|
||
})
|
||
}
|
||
return nodes, total, nil
|
||
}
|
||
|
||
// loadOrderFinance 读取订单金额和当前支付事实。
|
||
func (q *Query) loadOrderFinance(ctx context.Context, filter FinanceFilter, refs *financeRefs, limit int) ([]FinanceTimelineNode, int64, error) {
|
||
query := q.db.WithContext(ctx).Model(&model.Order{})
|
||
query = applyFinanceTime(query, filter, "updated_at")
|
||
conditions, args := make([]string, 0, 5), make([]any, 0, 5)
|
||
appendUintCondition(&conditions, &args, "id", refs.orders)
|
||
appendStringCondition(&conditions, &args, "order_no", refs.orderNos)
|
||
if len(refs.shops) > 0 {
|
||
conditions = append(conditions, "(buyer_type = ? AND buyer_id IN ?) OR seller_shop_id IN ?")
|
||
args = append(args, model.BuyerTypeAgent, uintKeys(refs.shops), uintKeys(refs.shops))
|
||
}
|
||
appendAccountActorCondition(&conditions, &args, filter, "operator_account_id")
|
||
query = applyFinanceRelationship(query, filter, conditions, args)
|
||
rows, total, err := loadFinanceRows[model.Order](query, "updated_at DESC, id DESC", limit, "订单资金事实")
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
nodes := make([]FinanceTimelineNode, 0, len(rows))
|
||
for _, row := range rows {
|
||
amount := row.TotalAmount
|
||
shopID := orderShopID(row)
|
||
refsView := ledgerRefs(constants.AuditResourceOrder, strconv.FormatUint(uint64(row.ID), 10), row.OrderNo, pointerUintValue(row.OperatorAccountID))
|
||
nodes = append(nodes, FinanceTimelineNode{
|
||
RecordSource: constants.AuditRecordSourceOrder, NodeID: strconv.FormatUint(uint64(row.ID), 10), OccurredAt: row.UpdatedAt,
|
||
Code: row.PaymentMethod, Title: "订单 " + row.OrderNo, Result: strconv.Itoa(row.PaymentStatus), ResultName: constants.GetOrderPaymentStatusName(row.PaymentStatus),
|
||
Amount: &amount, Currency: "CNY", ShopID: shopID,
|
||
AmountAuthority: authoritativeAmount("tb_order", "total_amount"),
|
||
Facts: map[string]any{"order_no": row.OrderNo, "actual_paid_amount": row.ActualPaidAmount, "payment_method": row.PaymentMethod, "buyer_type": row.BuyerType, "buyer_id": row.BuyerID, "commission_status": row.CommissionStatus, "commission_result": row.CommissionResult},
|
||
InvestigationRefs: refsView,
|
||
})
|
||
}
|
||
return nodes, total, nil
|
||
}
|
||
|
||
// loadPaymentFinance 读取支付金额、渠道交易号和当前支付状态。
|
||
func (q *Query) loadPaymentFinance(ctx context.Context, filter FinanceFilter, refs *financeRefs, limit int) ([]FinanceTimelineNode, int64, error) {
|
||
query := q.db.WithContext(ctx).Model(&model.Payment{})
|
||
query = applyFinanceTime(query, filter, "updated_at")
|
||
conditions, args := make([]string, 0, 6), make([]any, 0, 6)
|
||
appendUintCondition(&conditions, &args, "id", refs.payments)
|
||
appendStringCondition(&conditions, &args, "payment_no", refs.paymentNos)
|
||
appendStringCondition(&conditions, &args, "third_party_trade_no", refs.tradeNos)
|
||
appendTypedOrderCondition(&conditions, &args, model.PaymentOrderTypePackage, refs.orders)
|
||
appendTypedOrderCondition(&conditions, &args, model.PaymentOrderTypeAgentRecharge, refs.agentRecharges)
|
||
appendTypedOrderCondition(&conditions, &args, model.PaymentOrderTypeRecharge, refs.rechargeOrders)
|
||
if filter.ShopID != 0 {
|
||
conditions = append(conditions, `(order_type = ? AND EXISTS (SELECT 1 FROM tb_order o WHERE o.id = tb_payment.order_id AND o.deleted_at IS NULL AND ((o.buyer_type = ? AND o.buyer_id = ?) OR o.seller_shop_id = ?))) OR (order_type = ? AND EXISTS (SELECT 1 FROM tb_agent_recharge_record ar WHERE ar.id = tb_payment.order_id AND ar.deleted_at IS NULL AND ar.shop_id = ?)) OR (order_type = ? AND EXISTS (SELECT 1 FROM tb_recharge_order ro WHERE ro.id = tb_payment.order_id AND ro.deleted_at IS NULL AND ro.shop_id_tag = ?))`)
|
||
args = append(args, model.PaymentOrderTypePackage, model.BuyerTypeAgent, filter.ShopID, filter.ShopID, model.PaymentOrderTypeAgentRecharge, filter.ShopID, model.PaymentOrderTypeRecharge, filter.ShopID)
|
||
}
|
||
query = applyFinanceRelationship(query, filter, conditions, args)
|
||
rows, total, err := loadFinanceRows[model.Payment](query, "updated_at DESC, id DESC", limit, "支付资金事实")
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
nodes := make([]FinanceTimelineNode, 0, len(rows))
|
||
for _, row := range rows {
|
||
amount := row.Amount
|
||
refsView := ledgerRefs(constants.AuditResourcePayment, strconv.FormatUint(uint64(row.ID), 10), row.PaymentNo, 0)
|
||
refsView.ResourceRefs = append(refsView.ResourceRefs, paymentBusinessRefs(row)...)
|
||
nodes = append(nodes, FinanceTimelineNode{
|
||
RecordSource: constants.AuditRecordSourcePayment, NodeID: strconv.FormatUint(uint64(row.ID), 10), OccurredAt: row.UpdatedAt,
|
||
Code: row.PaymentMethod, Title: "支付单 " + row.PaymentNo, Result: strconv.Itoa(row.Status), ResultName: constants.GetPaymentRecordStatusName(row.Status),
|
||
Amount: &amount, Currency: "CNY", AmountAuthority: authoritativeAmount("tb_payment", "amount"),
|
||
Facts: map[string]any{"payment_no": row.PaymentNo, "order_type": row.OrderType, "order_id": row.OrderID, "third_party_trade_no": row.ThirdPartyTradeNo, "paid_at": row.PaidAt},
|
||
InvestigationRefs: refsView,
|
||
})
|
||
}
|
||
return nodes, total, nil
|
||
}
|
||
|
||
// loadRefundFinance 优先展示已批准金额,否则展示申请金额并声明实际字段。
|
||
func (q *Query) loadRefundFinance(ctx context.Context, filter FinanceFilter, refs *financeRefs, limit int) ([]FinanceTimelineNode, int64, error) {
|
||
query := q.db.WithContext(ctx).Model(&model.RefundRequest{})
|
||
query = applyFinanceTime(query, filter, "updated_at")
|
||
conditions, args := make([]string, 0, 6), make([]any, 0, 6)
|
||
appendUintCondition(&conditions, &args, "id", refs.refunds)
|
||
appendStringCondition(&conditions, &args, "refund_no", refs.refundNos)
|
||
appendUintCondition(&conditions, &args, "order_id", refs.orders)
|
||
appendUintCondition(&conditions, &args, "approval_instance_id", refs.approvals)
|
||
appendUintCondition(&conditions, &args, "shop_id", refs.shops)
|
||
appendAccountActorCondition(&conditions, &args, filter, "processor_id")
|
||
query = applyFinanceRelationship(query, filter, conditions, args)
|
||
rows, total, err := loadFinanceRows[model.RefundRequest](query, "updated_at DESC, id DESC", limit, "退款资金事实")
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
nodes := make([]FinanceTimelineNode, 0, len(rows))
|
||
for _, row := range rows {
|
||
amount, field := row.RequestedRefundAmount, "requested_refund_amount"
|
||
if row.ApprovedRefundAmount != nil {
|
||
amount, field = *row.ApprovedRefundAmount, "approved_refund_amount"
|
||
}
|
||
refsView := ledgerRefs(constants.AuditResourceRefund, strconv.FormatUint(uint64(row.ID), 10), row.RefundNo, pointerUintValue(row.ProcessorID))
|
||
refsView.ResourceRefs = append(refsView.ResourceRefs, financeResourceRef(constants.AuditResourceOrder, row.OrderID, row.OrderNo))
|
||
if row.ApprovalInstanceID != nil {
|
||
refsView.ResourceRefs = append(refsView.ResourceRefs, financeResourceRef(constants.AuditResourceApprovalInstance, *row.ApprovalInstanceID, ""))
|
||
}
|
||
nodes = append(nodes, FinanceTimelineNode{
|
||
RecordSource: constants.AuditRecordSourceRefund, NodeID: strconv.FormatUint(uint64(row.ID), 10), OccurredAt: row.UpdatedAt,
|
||
Code: "refund", Title: "退款单 " + row.RefundNo, Result: strconv.Itoa(row.Status), ResultName: constants.GetRefundStatusName(row.Status),
|
||
Amount: &amount, Currency: "CNY", ShopID: row.ShopID, AmountAuthority: authoritativeAmount("tb_refund_request", field),
|
||
Facts: map[string]any{"refund_no": row.RefundNo, "order_id": row.OrderID, "order_no": row.OrderNo, "actual_received_amount": row.ActualReceivedAmount, "requested_refund_amount": row.RequestedRefundAmount, "approved_refund_amount": row.ApprovedRefundAmount, "approval_instance_id": row.ApprovalInstanceID},
|
||
InvestigationRefs: refsView,
|
||
})
|
||
}
|
||
return nodes, total, nil
|
||
}
|
||
|
||
// loadAgentRechargeFinance 读取代理充值金额、支付和审批事实。
|
||
func (q *Query) loadAgentRechargeFinance(ctx context.Context, filter FinanceFilter, refs *financeRefs, limit int) ([]FinanceTimelineNode, int64, error) {
|
||
query := q.db.WithContext(ctx).Model(&model.AgentRechargeRecord{})
|
||
query = applyFinanceTime(query, filter, "updated_at")
|
||
conditions, args := make([]string, 0, 7), make([]any, 0, 7)
|
||
appendUintCondition(&conditions, &args, "id", refs.agentRecharges)
|
||
appendStringCondition(&conditions, &args, "recharge_no", refs.rechargeNos)
|
||
appendStringCondition(&conditions, &args, "payment_transaction_id", refs.tradeNos)
|
||
appendUintCondition(&conditions, &args, "agent_wallet_id", refs.agentWallets)
|
||
appendUintCondition(&conditions, &args, "approval_instance_id", refs.approvals)
|
||
appendUintCondition(&conditions, &args, "shop_id", refs.shops)
|
||
appendAccountActorCondition(&conditions, &args, filter, "user_id")
|
||
query = applyFinanceRelationship(query, filter, conditions, args)
|
||
rows, total, err := loadFinanceRows[model.AgentRechargeRecord](query, "updated_at DESC, id DESC", limit, "代理充值事实")
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
nodes := make([]FinanceTimelineNode, 0, len(rows))
|
||
for _, row := range rows {
|
||
amount := row.Amount
|
||
refsView := ledgerRefs(constants.AuditResourceAgentRecharge, strconv.FormatUint(uint64(row.ID), 10), row.RechargeNo, row.UserID)
|
||
refsView.ResourceRefs = append(refsView.ResourceRefs, financeResourceRef(constants.AuditResourceAgentWallet, row.AgentWalletID, ""))
|
||
if row.ApprovalInstanceID != nil {
|
||
refsView.ResourceRefs = append(refsView.ResourceRefs, financeResourceRef(constants.AuditResourceApprovalInstance, *row.ApprovalInstanceID, ""))
|
||
}
|
||
nodes = append(nodes, FinanceTimelineNode{
|
||
RecordSource: constants.AuditRecordSourceAgentRecharge, NodeID: strconv.FormatUint(uint64(row.ID), 10), OccurredAt: row.UpdatedAt,
|
||
Code: row.PaymentMethod, Title: "代理充值单 " + row.RechargeNo, Result: strconv.Itoa(row.Status), ResultName: constants.GetRechargeStatusName(row.Status),
|
||
Amount: &amount, Currency: "CNY", ShopID: &row.ShopID,
|
||
Wallet: &FinanceWalletRef{ResourceType: constants.AuditResourceAgentWallet, WalletID: row.AgentWalletID},
|
||
AmountAuthority: authoritativeAmount("tb_agent_recharge_record", "amount"),
|
||
Facts: map[string]any{"recharge_no": row.RechargeNo, "payment_method": row.PaymentMethod, "payment_transaction_id": row.PaymentTransactionID, "approval_instance_id": row.ApprovalInstanceID, "paid_at": row.PaidAt, "completed_at": row.CompletedAt},
|
||
InvestigationRefs: refsView,
|
||
})
|
||
}
|
||
return nodes, total, nil
|
||
}
|
||
|
||
// loadRechargeOrderFinance 读取个人资产充值金额及自动购包状态。
|
||
func (q *Query) loadRechargeOrderFinance(ctx context.Context, filter FinanceFilter, refs *financeRefs, limit int) ([]FinanceTimelineNode, int64, error) {
|
||
query := q.db.WithContext(ctx).Model(&model.RechargeOrder{})
|
||
query = applyFinanceTime(query, filter, "updated_at")
|
||
conditions, args := make([]string, 0, 5), make([]any, 0, 5)
|
||
appendUintCondition(&conditions, &args, "id", refs.rechargeOrders)
|
||
appendStringCondition(&conditions, &args, "recharge_order_no", refs.rechargeNos)
|
||
appendUintCondition(&conditions, &args, "asset_wallet_id", refs.assetWallets)
|
||
appendUintCondition(&conditions, &args, "shop_id_tag", refs.shops)
|
||
appendAccountActorCondition(&conditions, &args, filter, "user_id")
|
||
query = applyFinanceRelationship(query, filter, conditions, args)
|
||
rows, total, err := loadFinanceRows[model.RechargeOrder](query, "updated_at DESC, id DESC", limit, "资产充值事实")
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
nodes := make([]FinanceTimelineNode, 0, len(rows))
|
||
for _, row := range rows {
|
||
amount := row.Amount
|
||
refsView := ledgerRefs(constants.AuditResourceRechargeOrder, strconv.FormatUint(uint64(row.ID), 10), row.RechargeOrderNo, row.UserID)
|
||
refsView.ResourceRefs = append(refsView.ResourceRefs, financeResourceRef(constants.AuditResourceAssetWallet, row.AssetWalletID, ""))
|
||
nodes = append(nodes, FinanceTimelineNode{
|
||
RecordSource: constants.AuditRecordSourceRechargeOrder, NodeID: strconv.FormatUint(uint64(row.ID), 10), OccurredAt: row.UpdatedAt,
|
||
Code: row.OperatorType, Title: "资产充值单 " + row.RechargeOrderNo, Result: strconv.Itoa(row.Status), ResultName: rechargeOrderStatusName(row.Status),
|
||
Amount: &amount, Currency: "CNY", ShopID: &row.ShopIDTag,
|
||
Wallet: &FinanceWalletRef{ResourceType: constants.AuditResourceAssetWallet, WalletID: row.AssetWalletID},
|
||
AmountAuthority: authoritativeAmount("tb_recharge_order", "amount"),
|
||
Facts: map[string]any{"recharge_order_no": row.RechargeOrderNo, "resource_type": row.ResourceType, "resource_id": row.ResourceID, "paid_at": row.PaidAt, "auto_purchase_status": row.AutoPurchaseStatus},
|
||
InvestigationRefs: refsView,
|
||
})
|
||
}
|
||
return nodes, total, nil
|
||
}
|
||
|
||
// loadCommissionFinance 读取佣金金额、状态和入账后余额。
|
||
func (q *Query) loadCommissionFinance(ctx context.Context, filter FinanceFilter, refs *financeRefs, limit int) ([]FinanceTimelineNode, int64, error) {
|
||
query := q.db.WithContext(ctx).Model(&model.CommissionRecord{})
|
||
query = applyFinanceTime(query, filter, "updated_at")
|
||
conditions, args := make([]string, 0, 4), make([]any, 0, 4)
|
||
appendUintCondition(&conditions, &args, "id", refs.commissions)
|
||
appendUintCondition(&conditions, &args, "order_id", refs.orders)
|
||
appendUintCondition(&conditions, &args, "shop_id", refs.shops)
|
||
query = applyFinanceRelationship(query, filter, conditions, args)
|
||
rows, total, err := loadFinanceRows[model.CommissionRecord](query, "updated_at DESC, id DESC", limit, "佣金事实")
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
nodes := make([]FinanceTimelineNode, 0, len(rows))
|
||
for _, row := range rows {
|
||
amount := row.Amount
|
||
refsView := ledgerRefs(constants.AuditResourceCommissionRecord, strconv.FormatUint(uint64(row.ID), 10), strconv.FormatUint(uint64(row.ID), 10), 0)
|
||
refsView.ResourceRefs = append(refsView.ResourceRefs, financeResourceRef(constants.AuditResourceOrder, row.OrderID, ""))
|
||
nodes = append(nodes, FinanceTimelineNode{
|
||
RecordSource: constants.AuditRecordSourceCommissionRecord, NodeID: strconv.FormatUint(uint64(row.ID), 10), OccurredAt: row.UpdatedAt,
|
||
Code: row.CommissionSource, Title: "佣金记录", Result: strconv.Itoa(row.Status), ResultName: constants.GetCommissionRecordStatusName(row.Status),
|
||
Amount: &amount, BalanceAfter: int64Pointer(row.BalanceAfter), Currency: "CNY", ShopID: &row.ShopID,
|
||
AmountAuthority: authoritativeAmount("tb_commission_record", "amount,balance_after"),
|
||
Facts: map[string]any{"order_id": row.OrderID, "commission_source": row.CommissionSource, "released_at": row.ReleasedAt},
|
||
InvestigationRefs: refsView,
|
||
})
|
||
}
|
||
return nodes, total, nil
|
||
}
|
||
|
||
// loadWithdrawalFinance 读取提现申请金额、手续费和实际到账金额。
|
||
func (q *Query) loadWithdrawalFinance(ctx context.Context, filter FinanceFilter, refs *financeRefs, limit int) ([]FinanceTimelineNode, int64, error) {
|
||
query := q.db.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{})
|
||
query = applyFinanceTime(query, filter, "updated_at")
|
||
conditions, args := make([]string, 0, 4), make([]any, 0, 4)
|
||
appendUintCondition(&conditions, &args, "id", refs.withdrawals)
|
||
appendUintCondition(&conditions, &args, "shop_id", refs.shops)
|
||
appendAccountActorCondition(&conditions, &args, filter, "applicant_id", "processor_id")
|
||
query = applyFinanceRelationship(query, filter, conditions, args)
|
||
rows, total, err := loadFinanceRows[model.CommissionWithdrawalRequest](query, "updated_at DESC, id DESC", limit, "佣金提现事实")
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
nodes := make([]FinanceTimelineNode, 0, len(rows))
|
||
for _, row := range rows {
|
||
amount := row.Amount
|
||
refsView := ledgerRefs(constants.AuditResourceCommissionWithdrawal, strconv.FormatUint(uint64(row.ID), 10), row.WithdrawalNo, row.ApplicantID)
|
||
nodes = append(nodes, FinanceTimelineNode{
|
||
RecordSource: constants.AuditRecordSourceCommissionWithdrawal, NodeID: strconv.FormatUint(uint64(row.ID), 10), OccurredAt: row.UpdatedAt,
|
||
Code: row.WithdrawalMethod, Title: "佣金提现单 " + row.WithdrawalNo, Result: strconv.Itoa(row.Status), ResultName: constants.GetWithdrawalStatusName(row.Status),
|
||
Amount: &amount, Currency: "CNY", ShopID: &row.ShopID,
|
||
AmountAuthority: authoritativeAmount("tb_commission_withdrawal_request", "amount"),
|
||
Facts: map[string]any{"fee": row.Fee, "actual_amount": row.ActualAmount, "payment_type": row.PaymentType, "processed_at": row.ProcessedAt, "paid_at": row.PaidAt},
|
||
InvestigationRefs: refsView,
|
||
})
|
||
}
|
||
return nodes, total, nil
|
||
}
|
||
|
||
// loadApprovalFinance 读取审批状态,明确审批表不提供金额权威。
|
||
func (q *Query) loadApprovalFinance(ctx context.Context, filter FinanceFilter, refs *financeRefs, limit int) ([]FinanceTimelineNode, int64, error) {
|
||
query := q.db.WithContext(ctx).Model(&model.ApprovalInstance{})
|
||
query = applyFinanceTime(query, filter, "status_changed_at")
|
||
conditions, args := make([]string, 0, 5), make([]any, 0, 5)
|
||
appendUintCondition(&conditions, &args, "id", refs.approvals)
|
||
appendApprovalBusinessCondition(&conditions, &args, constants.ApprovalBusinessTypeRefund, refs.refunds)
|
||
appendApprovalBusinessCondition(&conditions, &args, constants.ApprovalBusinessTypeOfflineRecharge, refs.agentRecharges)
|
||
if filter.ShopID != 0 {
|
||
conditions = append(conditions, `(business_type = ? AND EXISTS (SELECT 1 FROM tb_refund_request r WHERE r.id = tb_approval_instance.business_id AND r.deleted_at IS NULL AND r.shop_id = ?)) OR (business_type = ? AND EXISTS (SELECT 1 FROM tb_agent_recharge_record ar WHERE ar.id = tb_approval_instance.business_id AND ar.deleted_at IS NULL AND ar.shop_id = ?))`)
|
||
args = append(args, constants.ApprovalBusinessTypeRefund, filter.ShopID, constants.ApprovalBusinessTypeOfflineRecharge, filter.ShopID)
|
||
}
|
||
appendAccountActorCondition(&conditions, &args, filter, "submitter_account_id")
|
||
if filter.CorrelationID != "" {
|
||
conditions, args = append(conditions, "correlation_id = ?"), append(args, filter.CorrelationID)
|
||
}
|
||
query = applyFinanceRelationship(query, filter, conditions, args)
|
||
rows, total, err := loadFinanceRows[model.ApprovalInstance](query, "status_changed_at DESC, id DESC", limit, "审批事实")
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
nodes := make([]FinanceTimelineNode, 0, len(rows))
|
||
for _, row := range rows {
|
||
refsView := ledgerRefs(constants.AuditResourceApprovalInstance, strconv.FormatUint(uint64(row.ID), 10), strconv.FormatUint(uint64(row.ID), 10), row.SubmitterAccountID)
|
||
refsView.CorrelationID = stringPointer(row.CorrelationID)
|
||
refsView.ResourceRefs = append(refsView.ResourceRefs, approvalBusinessRefs(row)...)
|
||
nodes = append(nodes, FinanceTimelineNode{
|
||
RecordSource: constants.AuditRecordSourceApprovalInstance, NodeID: strconv.FormatUint(uint64(row.ID), 10), OccurredAt: row.StatusChangedAt,
|
||
Code: row.BusinessType, Title: "审批实例", Result: strconv.Itoa(row.Status), ResultName: constants.GetApprovalStatusName(row.Status),
|
||
AmountAuthority: FinanceAmountAuthority{Authoritative: false, Table: "tb_approval_instance", ConflictRule: "审批表只对审批状态负责,不提供金额权威"},
|
||
Facts: map[string]any{"business_type": row.BusinessType, "business_id": row.BusinessID, "provider": row.Provider, "external_ref": row.ExternalRef, "correlation_id": row.CorrelationID},
|
||
InvestigationRefs: refsView,
|
||
})
|
||
}
|
||
return nodes, total, nil
|
||
}
|
||
|
||
func loadFinanceRows[T any](query *gorm.DB, order string, limit int, sourceName string) ([]T, int64, error) {
|
||
var total int64
|
||
if err := query.Count(&total).Error; err != nil {
|
||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计"+sourceName+"失败")
|
||
}
|
||
rows := make([]T, 0, limit)
|
||
if err := query.Order(order).Limit(limit).Find(&rows).Error; err != nil {
|
||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询"+sourceName+"失败")
|
||
}
|
||
return rows, total, nil
|
||
}
|
||
|
||
func applyFinanceTime(query *gorm.DB, filter FinanceFilter, column string) *gorm.DB {
|
||
if filter.CreatedFrom != nil {
|
||
query = query.Where(column+" >= ?", filter.CreatedFrom.UTC())
|
||
}
|
||
if filter.CreatedTo != nil {
|
||
query = query.Where(column+" < ?", filter.CreatedTo.UTC())
|
||
}
|
||
return query
|
||
}
|
||
|
||
func applyFinanceRelationship(query *gorm.DB, filter FinanceFilter, conditions []string, args []any) *gorm.DB {
|
||
if !financeRelationshipRequired(filter) {
|
||
return query
|
||
}
|
||
if len(conditions) == 0 {
|
||
return query.Where("1 = 0")
|
||
}
|
||
return query.Where("("+strings.Join(conditions, ") OR (")+")", args...)
|
||
}
|
||
|
||
func financeRelationshipRequired(filter FinanceFilter) bool {
|
||
return filter.ShopID != 0 || filter.WalletID != 0 || filter.OrderID != 0 || filter.OrderNo != "" ||
|
||
filter.PaymentID != 0 || filter.PaymentNo != "" || filter.RefundID != 0 || filter.RefundNo != "" ||
|
||
filter.RechargeID != 0 || filter.RechargeNo != "" || filter.ApprovalInstanceID != 0 ||
|
||
filter.ThirdPartyTradeNo != "" || filter.ActorKind != "" || filter.CorrelationID != ""
|
||
}
|
||
|
||
func appendUintCondition(conditions *[]string, args *[]any, column string, values map[uint]struct{}) {
|
||
if len(values) == 0 {
|
||
return
|
||
}
|
||
*conditions = append(*conditions, column+" IN ?")
|
||
*args = append(*args, uintKeys(values))
|
||
}
|
||
|
||
func appendStringCondition(conditions *[]string, args *[]any, column string, values map[string]struct{}) {
|
||
if len(values) == 0 {
|
||
return
|
||
}
|
||
*conditions = append(*conditions, column+" IN ?")
|
||
*args = append(*args, stringKeys(values))
|
||
}
|
||
|
||
func appendReferenceIDCondition(conditions *[]string, args *[]any, referenceType string, values map[uint]struct{}) {
|
||
if len(values) == 0 {
|
||
return
|
||
}
|
||
*conditions = append(*conditions, "reference_type = ? AND reference_id IN ?")
|
||
*args = append(*args, referenceType, uintKeys(values))
|
||
}
|
||
|
||
func appendReferenceNoCondition(conditions *[]string, args *[]any, referenceType string, values map[string]struct{}) {
|
||
if len(values) == 0 {
|
||
return
|
||
}
|
||
*conditions = append(*conditions, "reference_type = ? AND reference_no IN ?")
|
||
*args = append(*args, referenceType, stringKeys(values))
|
||
}
|
||
|
||
func appendReservationReferenceCondition(conditions *[]string, args *[]any, referenceType string, values map[uint]struct{}) {
|
||
if len(values) == 0 {
|
||
return
|
||
}
|
||
*conditions = append(*conditions, "reference_type = ? AND reference_id IN ?")
|
||
*args = append(*args, referenceType, uintKeys(values))
|
||
}
|
||
|
||
func appendTypedOrderCondition(conditions *[]string, args *[]any, orderType string, values map[uint]struct{}) {
|
||
if len(values) == 0 {
|
||
return
|
||
}
|
||
*conditions = append(*conditions, "order_type = ? AND order_id IN ?")
|
||
*args = append(*args, orderType, uintKeys(values))
|
||
}
|
||
|
||
func appendApprovalBusinessCondition(conditions *[]string, args *[]any, businessType string, values map[uint]struct{}) {
|
||
if len(values) == 0 {
|
||
return
|
||
}
|
||
*conditions = append(*conditions, "business_type = ? AND business_id IN ?")
|
||
*args = append(*args, businessType, uintKeys(values))
|
||
}
|
||
|
||
func appendAccountActorCondition(conditions *[]string, args *[]any, filter FinanceFilter, columns ...string) {
|
||
if filter.ActorKind != constants.AuditActorAccount || filter.ActorID == "" {
|
||
return
|
||
}
|
||
actorID, err := strconv.ParseUint(filter.ActorID, 10, 64)
|
||
if err != nil {
|
||
return
|
||
}
|
||
for _, column := range columns {
|
||
*conditions = append(*conditions, column+" = ?")
|
||
*args = append(*args, uint(actorID))
|
||
}
|
||
}
|
||
|
||
func ledgerRefs(resourceType, resourceID, resourceKey string, actorID uint) InvestigationRefs {
|
||
refs := InvestigationRefs{
|
||
ResourceRefs: []InvestigationResourceRef{{ResourceType: resourceType, ResourceID: stringPointer(resourceID), ResourceKey: resourceKey, DisplayName: resourceKey}},
|
||
IntegrationRefs: []IntegrationRef{},
|
||
}
|
||
if actorID != 0 {
|
||
refs.ActorRef = &ActorRef{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(actorID), 10)}
|
||
}
|
||
return refs
|
||
}
|
||
|
||
func financeResourceRef(resourceType string, resourceID uint, resourceKey string) InvestigationResourceRef {
|
||
id := strconv.FormatUint(uint64(resourceID), 10)
|
||
if resourceKey == "" {
|
||
resourceKey = id
|
||
}
|
||
return InvestigationResourceRef{ResourceType: resourceType, ResourceID: &id, ResourceKey: resourceKey, DisplayName: resourceKey}
|
||
}
|
||
|
||
// agentTransactionBusinessRef 将显式 reference_type 转为业务资源引用。
|
||
func agentTransactionBusinessRef(row model.AgentWalletTransaction) []InvestigationResourceRef {
|
||
if row.ReferenceType == nil || row.ReferenceID == nil {
|
||
return nil
|
||
}
|
||
resourceType := ""
|
||
switch *row.ReferenceType {
|
||
case constants.ReferenceTypeOrder:
|
||
resourceType = constants.AuditResourceOrder
|
||
case constants.ReferenceTypeRefund:
|
||
resourceType = constants.AuditResourceRefund
|
||
case constants.ReferenceTypeTopup:
|
||
resourceType = constants.AuditResourceAgentRecharge
|
||
case constants.ReferenceTypeCommission:
|
||
resourceType = constants.AuditResourceCommissionRecord
|
||
case constants.ReferenceTypeWithdrawal:
|
||
resourceType = constants.AuditResourceCommissionWithdrawal
|
||
}
|
||
if resourceType == "" {
|
||
return nil
|
||
}
|
||
return []InvestigationResourceRef{financeResourceRef(resourceType, *row.ReferenceID, "")}
|
||
}
|
||
|
||
// assetTransactionBusinessRef 保留只有业务编号而没有内部 ID 的历史引用。
|
||
func assetTransactionBusinessRef(row model.AssetWalletTransaction) []InvestigationResourceRef {
|
||
if row.ReferenceType == nil || row.ReferenceNo == nil || *row.ReferenceNo == "" {
|
||
return nil
|
||
}
|
||
resourceType := ""
|
||
switch *row.ReferenceType {
|
||
case constants.ReferenceTypeOrder:
|
||
resourceType = constants.AuditResourceOrder
|
||
case constants.ReferenceTypeRefund:
|
||
resourceType = constants.AuditResourceRefund
|
||
case constants.ReferenceTypeRecharge:
|
||
resourceType = constants.AuditResourcePayment
|
||
}
|
||
if resourceType == "" {
|
||
return nil
|
||
}
|
||
return []InvestigationResourceRef{{ResourceType: resourceType, ResourceKey: *row.ReferenceNo, DisplayName: *row.ReferenceNo}}
|
||
}
|
||
|
||
// paymentBusinessRefs 按支付单声明的 order_type 定位业务单类型。
|
||
func paymentBusinessRefs(row model.Payment) []InvestigationResourceRef {
|
||
resourceType := ""
|
||
switch row.OrderType {
|
||
case model.PaymentOrderTypePackage:
|
||
resourceType = constants.AuditResourceOrder
|
||
case model.PaymentOrderTypeAgentRecharge:
|
||
resourceType = constants.AuditResourceAgentRecharge
|
||
case model.PaymentOrderTypeRecharge:
|
||
resourceType = constants.AuditResourceRechargeOrder
|
||
}
|
||
if resourceType == "" {
|
||
return nil
|
||
}
|
||
return []InvestigationResourceRef{financeResourceRef(resourceType, row.OrderID, "")}
|
||
}
|
||
|
||
// approvalBusinessRefs 按审批实例声明的业务类型生成稳定跳转。
|
||
func approvalBusinessRefs(row model.ApprovalInstance) []InvestigationResourceRef {
|
||
resourceType := ""
|
||
switch row.BusinessType {
|
||
case constants.ApprovalBusinessTypeRefund:
|
||
resourceType = constants.AuditResourceRefund
|
||
case constants.ApprovalBusinessTypeOfflineRecharge:
|
||
resourceType = constants.AuditResourceAgentRecharge
|
||
}
|
||
if resourceType == "" {
|
||
return nil
|
||
}
|
||
return []InvestigationResourceRef{financeResourceRef(resourceType, row.BusinessID, "")}
|
||
}
|
||
|
||
func authoritativeAmount(table, field string) FinanceAmountAuthority {
|
||
return FinanceAmountAuthority{
|
||
Authoritative: true, Table: table, Field: field,
|
||
ConflictRule: "金额冲突时以该业务表字段为准,不修改历史 Audit Event",
|
||
}
|
||
}
|
||
|
||
func nonAuthoritativeAuditAmount() FinanceAmountAuthority {
|
||
return FinanceAmountAuthority{
|
||
Authoritative: false, Table: "tb_audit_event",
|
||
ConflictRule: "Audit Event 只解释操作者与动作,金额以钱包流水及对应业务表为准",
|
||
}
|
||
}
|
||
|
||
func auditResultName(result string) string {
|
||
switch result {
|
||
case constants.AuditResultSuccess:
|
||
return "成功"
|
||
case constants.AuditResultFailed:
|
||
return "失败"
|
||
case constants.AuditResultDenied:
|
||
return "拒绝"
|
||
case constants.AuditResultPartial:
|
||
return "部分成功"
|
||
case constants.AuditResultUnknown:
|
||
return "结果未知"
|
||
default:
|
||
return "未知"
|
||
}
|
||
}
|
||
|
||
func reservationStatusName(status int) string {
|
||
switch status {
|
||
case constants.AgentWalletReservationStatusFrozen:
|
||
return "已冻结"
|
||
case constants.AgentWalletReservationStatusReleased:
|
||
return "已释放"
|
||
case constants.AgentWalletReservationStatusCompleted:
|
||
return "已完成扣除"
|
||
default:
|
||
return "未知"
|
||
}
|
||
}
|
||
|
||
func assetTransactionTypeName(transactionType string) string {
|
||
switch transactionType {
|
||
case constants.AssetTransactionTypeRecharge:
|
||
return "充值"
|
||
case constants.AssetTransactionTypeDeduct:
|
||
return "扣款"
|
||
case constants.AssetTransactionTypeRefund:
|
||
return "退款"
|
||
case constants.AssetTransactionTypeExchange:
|
||
return "换货迁移"
|
||
default:
|
||
return "未知变动"
|
||
}
|
||
}
|
||
|
||
func rechargeOrderStatusName(status int) string {
|
||
switch status {
|
||
case model.RechargeOrderStatusPending:
|
||
return "待支付"
|
||
case model.RechargeOrderStatusPaid:
|
||
return "已支付"
|
||
case model.RechargeOrderStatusClosed:
|
||
return "已关闭"
|
||
case model.RechargeOrderStatusRefunded:
|
||
return "已退款"
|
||
default:
|
||
return "未知"
|
||
}
|
||
}
|
||
|
||
func orderShopID(row model.Order) *uint {
|
||
if row.BuyerType == model.BuyerTypeAgent {
|
||
id := row.BuyerID
|
||
return &id
|
||
}
|
||
return row.SellerShopID
|
||
}
|
||
|
||
func pointerUintValue(value *uint) uint {
|
||
if value == nil {
|
||
return 0
|
||
}
|
||
return *value
|
||
}
|
||
|
||
func int64Pointer(value int64) *int64 {
|
||
return &value
|
||
}
|
||
|
||
func parseResourceUint(value *string) (uint, bool) {
|
||
if value == nil || *value == "" {
|
||
return 0, false
|
||
}
|
||
parsed, err := strconv.ParseUint(*value, 10, 64)
|
||
if err != nil {
|
||
return 0, false
|
||
}
|
||
return uint(parsed), true
|
||
}
|
||
|
||
func addUint(values map[uint]struct{}, value uint) {
|
||
if value != 0 {
|
||
values[value] = struct{}{}
|
||
}
|
||
}
|
||
|
||
func addString(values map[string]struct{}, value string) {
|
||
value = strings.TrimSpace(value)
|
||
if value != "" {
|
||
values[value] = struct{}{}
|
||
}
|
||
}
|
||
|
||
func uintKeys(values map[uint]struct{}) []uint {
|
||
keys := make([]uint, 0, len(values))
|
||
for value := range values {
|
||
keys = append(keys, value)
|
||
}
|
||
return keys
|
||
}
|
||
|
||
func stringUintKeys(values map[uint]struct{}) []string {
|
||
keys := make([]string, 0, len(values))
|
||
for value := range values {
|
||
keys = append(keys, strconv.FormatUint(uint64(value), 10))
|
||
}
|
||
return keys
|
||
}
|
||
|
||
func stringKeys(values map[string]struct{}) []string {
|
||
keys := make([]string, 0, len(values))
|
||
for value := range values {
|
||
keys = append(keys, value)
|
||
}
|
||
return keys
|
||
}
|
||
|
||
func financeNodeIDAfter(left, right string) bool {
|
||
leftID, leftErr := strconv.ParseUint(left, 10, 64)
|
||
rightID, rightErr := strconv.ParseUint(right, 10, 64)
|
||
if leftErr == nil && rightErr == nil {
|
||
return leftID > rightID
|
||
}
|
||
return left > right
|
||
}
|