Files
junhong_cmp_fiber/internal/query/audit/finance.go
break ba0855d9eb feat(退款): AUG26-006 退款方式选择与原路退款
按 PRD 2.3/2.4/2.5 落地套餐退款的方式矩阵与原路渠道退款:

- 退款申请派生并冻结权威实收金额(线上取原成功支付记录,钱包/线下取订单实际收款),
  提交人不可填写或修改;按来源支付方式生成可选方式矩阵并在创建、提交、执行前重复校验。
- 审批切换为「每次提交一条不可变审批尝试记录 + 独立企业微信审批实例」,业务标识取尝试
  记录主键;终态消费按尝试记录优先、退款申请兜底双读,兼容存量无实例与已关联实例申请。
  新增活动退款部分唯一索引 (order_id) WHERE status IN (1,5,6)。
- 本地人工终审保持既有开关,补齐通过入口的 approval_instance_id IS NULL 守卫,使三个
  入口一致拒绝已关联审批实例的申请;重提按尝试模式重写(仅已拒绝/已退回/原路失败且无异常)。
- 权益时点:企微通过事务写退款终态、按方式确定的订单态、钱包回款、员工账单冲销与可靠
  失效事实;套餐失效/接续/停机仍由既有可靠机制最终一致执行,不把外部调用放入资金事务。
  订单支付状态按方式置位:凭证退款与退回原钱包在企微通过时置已退款,原路须渠道明确成功。
- 按官方契约实现微信直连 v3、微信 v2(双向证书)、富友(/commonRefund 与 /refundQuery)、
  支付宝四类原路退款;能力只由服务商类型与退款必需凭证完整性决定,无人工开关。
  渠道请求号在提交时冻结到尝试记录,并以 channel_submitted_at 条件认领保证资金动作至多
  提交一次(重复投递只查询不二次提交);不向任何渠道传递退款结果通知地址。
- 新增 refund:channel:recovery 恢复任务只查询回填;本地查询窗口超期(富友 72 小时、
  微信 v2 7 天)转原路退款失败、渠道状态已失败、分类超时未知并置异常转人工,不放行自动
  重提以避免重复退款。
- 同步退款 DTO/导出/审计资源与审计查询关联、商户凭证文档,并修正 fuiou 集成契约文档。

迁移 000218(退款尝试与渠道退款事实)、000219(微信 v2 客户端证书凭证)成对提供,
未修改既有迁移;测试库 junhong_cmp_test 完成 up/down/up 与行为核对,未调用真实渠道。
2026-09-14 11:55:16 +08:00

1560 lines
73 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package audit
import (
"context"
"sort"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
"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, refundApprovalBusinessCondition()), append(args, constants.ApprovalBusinessTypeRefund, uintKeys(refs.refunds), 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:
refundID, err := refundapproval.ResolveRefundIDInTx(ctx, q.db, row.BusinessID, row.ID)
if err != nil {
return err
}
addUint(refs.refunds, refundID)
case constants.ApprovalBusinessTypeOfflineRecharge:
addUint(refs.agentRecharges, row.BusinessID)
}
}
return nil
}
// refundApprovalBusinessCondition 返回退款审批实例的过滤条件,覆盖尝试模式与存量模式两种业务标识语义。
// 三个占位符依次为业务类型、尝试记录主键、退款单主键。
func refundApprovalBusinessCondition() string {
return "business_type = ? AND (business_id IN ? OR business_id IN (SELECT refund_attempt.id FROM tb_refund_request_attempt AS refund_attempt WHERE refund_attempt.refund_id IN ?))"
}
// 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)
appendRefundApprovalBusinessCondition(&conditions, &args, refs.refunds)
appendApprovalBusinessCondition(&conditions, &args, constants.ApprovalBusinessTypeOfflineRecharge, refs.agentRecharges)
if filter.ShopID != 0 {
// 退款审批的店铺归属要同时覆盖尝试模式business_id 指向尝试记录与存量模式business_id 指向退款单)。
conditions = append(conditions, `(business_type = ? AND (EXISTS (SELECT 1 FROM tb_refund_request_attempt a JOIN tb_refund_request r ON r.id = a.refund_id AND r.deleted_at IS NULL WHERE a.id = tb_approval_instance.business_id AND r.shop_id = ?) OR 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, 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)
businessRefs, err := approvalBusinessRefs(ctx, q.db, row)
if err != nil {
return nil, 0, err
}
refsView.ResourceRefs = append(refsView.ResourceRefs, businessRefs...)
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))
}
// appendRefundApprovalBusinessCondition 关联按退款单过滤的退款审批实例。
// 业务标识在尝试模式下指向审批尝试记录、存量模式下指向退款申请,因此两种语义都要命中。
func appendRefundApprovalBusinessCondition(conditions *[]string, args *[]any, values map[uint]struct{}) {
if len(values) == 0 {
return
}
refundIDs := uintKeys(values)
*conditions = append(*conditions, refundApprovalBusinessCondition())
*args = append(*args, constants.ApprovalBusinessTypeRefund, refundIDs, refundIDs)
}
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 按审批实例声明的业务类型生成稳定跳转。
//
// 退款审批的业务标识在尝试模式下指向审批尝试记录,直接用 business_id 跳转会指向错误资源,
// 因此这里经共享解析器还原真实退款单存量模式business_id 即退款单主键)由解析器兜底命中。
func approvalBusinessRefs(ctx context.Context, db *gorm.DB, row model.ApprovalInstance) ([]InvestigationResourceRef, error) {
resourceType := ""
resourceID := row.BusinessID
switch row.BusinessType {
case constants.ApprovalBusinessTypeRefund:
resourceType = constants.AuditResourceRefund
refundID, err := refundapproval.ResolveRefundIDInTx(ctx, db, row.BusinessID, row.ID)
if err != nil {
return nil, err
}
resourceID = refundID
case constants.ApprovalBusinessTypeOfflineRecharge:
resourceType = constants.AuditResourceAgentRecharge
}
if resourceType == "" {
return nil, nil
}
return []InvestigationResourceRef{financeResourceRef(resourceType, resourceID, "")}, nil
}
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
}