新功能: withdrawal_log 接口加 summary 字段 + admin 禁直接扣减 commission

接口侧:
- /v1/public/user/withdrawal_log 响应新增 summary 字段, 包含:
  - commission_balance (当前余额)
  - locked_by_pending (待审批占用)
  - available_to_withdraw (可提现)
  - total_historical_amount (已通过提现总额)
  - total_refunded_amount (退款回扣总额)
  - total_income_amount (收入总额)
- 前端可据此自洽展示账目对账, 用户能在一个接口里看清整笔账

代码守护:
- updateUserBasicInfoLogic 拒绝任何 change<0 的 commission 修改
- 扣减必须走 approveWithdrawal 写 type=334 日志
- 防止未来 admin 误操作再次造成 user.commission 与 system_logs 失衡

时间戳修复:
- queryWithdrawalLogLogic 和 queryCommissionReturnLogLogic 的时间戳
  从 UnixMilli 改回 Unix (秒级), 符合项目"后端统一秒级"约定

测试:
- 补 buildSummary 的 4 个 mock 查询期望
- 加 summary 字段值正确性断言
This commit is contained in:
2026-06-12 19:49:03 -07:00
parent 077dba3d98
commit be09a115ec
6 changed files with 123 additions and 12 deletions
@@ -78,17 +78,72 @@ func (l *QueryWithdrawalLogLogic) queryWithdrawalLogs(userID int64, page, size i
Method: row.Method,
Account: row.Account,
QrCodeUrl: row.QrCodeUrl,
CreatedAt: row.CreatedAt.UnixMilli(),
UpdatedAt: row.UpdatedAt.UnixMilli(),
CreatedAt: row.CreatedAt.Unix(),
UpdatedAt: row.UpdatedAt.Unix(),
})
}
summary, err := l.buildSummary(userID)
if err != nil {
return nil, err
}
return &types.QueryWithdrawalLogListResponse{
List: list,
Total: total,
List: list,
Total: total,
Summary: summary,
}, nil
}
// buildSummary 聚合用户佣金账目快照,供前端展示闭环对账
//
// commission_balance = user.commission(当前余额,由 system_logs 累计而来)
// locked_by_pending = 待审批提现占用
// available_to_withdraw = balance - locked
// total_historical_amount = 已通过提现累计(status=1
// total_refunded_amount = 佣金回扣累计(type=333/337/338
// total_income_amount = 佣金收入累计(type=331/332
func (l *QueryWithdrawalLogLogic) buildSummary(userID int64) (*types.WithdrawalLogSummary, error) {
db := l.svcCtx.DB.WithContext(l.ctx)
var summary types.WithdrawalLogSummary
if err := db.Model(&user.User{}).Where("id = ?", userID).
Select("COALESCE(commission, 0)").Scan(&summary.CommissionBalance).Error; err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "load commission balance failed: %v", err)
}
if err := db.Model(&user.Withdrawal{}).
Where("user_id = ? AND status = ?", userID, user.WithdrawalStatusPending).
Select("COALESCE(SUM(amount), 0)").Scan(&summary.LockedByPending).Error; err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "sum pending withdrawals failed: %v", err)
}
if err := db.Model(&user.Withdrawal{}).
Where("user_id = ? AND status = ?", userID, user.WithdrawalStatusApproved).
Select("COALESCE(SUM(amount), 0)").Scan(&summary.TotalHistoricalAmount).Error; err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "sum approved withdrawals failed: %v", err)
}
row := db.Raw(`
SELECT
COALESCE(SUM(CASE WHEN CAST(JSON_EXTRACT(content,'$.type') AS UNSIGNED) IN (?,?)
THEN CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED) ELSE 0 END), 0) AS income,
COALESCE(SUM(CASE WHEN CAST(JSON_EXTRACT(content,'$.type') AS UNSIGNED) IN (?,?,?)
THEN CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED) ELSE 0 END), 0) AS refund
FROM system_logs
WHERE type = ? AND object_id = ?`,
log.CommissionTypePurchase, log.CommissionTypeRenewal,
log.CommissionTypeRefund, log.CommissionTypeWithdrawReject, log.CommissionTypeWithdrawCancel,
log.TypeCommission.Uint8(), userID,
).Row()
if err := row.Scan(&summary.TotalIncomeAmount, &summary.TotalRefundedAmount); err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "aggregate commission logs failed: %v", err)
}
summary.AvailableToWithdraw = summary.CommissionBalance - summary.LockedByPending
return &summary, nil
}
func (l *QueryWithdrawalLogLogic) queryCommissionRefundLogs(userID int64, page, size int) (*types.QueryWithdrawalLogListResponse, error) {
queryLogic := NewQueryCommissionReturnLogLogic(l.ctx, l.svcCtx)
rows, total, err := queryLogic.queryCommissionReturnLogRecordsByEventTypes(userID, page, size, log.CommissionTypeRefund)