Files
hi-server/internal/logic/public/user/queryWithdrawalLogLogic.go
T
shanshanzhong147 be09a115ec 新功能: 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 字段值正确性断言
2026-06-12 19:49:03 -07:00

172 lines
6.1 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 user
import (
"context"
"github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
)
const (
withdrawalLogBizTypeWithdrawal = "withdrawal"
withdrawalLogBizTypeCommissionRefund = "commission_refund"
)
type QueryWithdrawalLogLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// NewQueryWithdrawalLogLogic Query Withdrawal Log
func NewQueryWithdrawalLogLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryWithdrawalLogLogic {
return &QueryWithdrawalLogLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalLogListRequest) (resp *types.QueryWithdrawalLogListResponse, err error) {
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
l.Error("current user is not found in context")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
page, size := normalizePagination(req.Page, req.Size)
switch req.BizType {
case "", withdrawalLogBizTypeWithdrawal:
return l.queryWithdrawalLogs(u.Id, page, size)
case withdrawalLogBizTypeCommissionRefund:
return l.queryCommissionRefundLogs(u.Id, page, size)
default:
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "invalid biz_type: %s", req.BizType)
}
}
func (l *QueryWithdrawalLogLogic) queryWithdrawalLogs(userID int64, page, size int) (*types.QueryWithdrawalLogListResponse, error) {
query := l.svcCtx.DB.WithContext(l.ctx).Model(&user.Withdrawal{}).Where("user_id = ?", userID)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count withdrawal logs failed: %v", err)
}
var rows []user.Withdrawal
if err := query.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query withdrawal logs failed: %v", err)
}
list := make([]types.WithdrawalLog, 0, len(rows))
for _, row := range rows {
list = append(list, types.WithdrawalLog{
Id: row.Id,
BizType: withdrawalLogBizTypeWithdrawal,
UserId: row.UserId,
Amount: row.Amount,
Content: row.Content,
Status: row.Status,
Reason: row.Reason,
Method: row.Method,
Account: row.Account,
QrCodeUrl: row.QrCodeUrl,
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,
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)
if err != nil {
return nil, err
}
list := make([]types.WithdrawalLog, 0, len(rows))
for _, row := range rows {
list = append(list, types.WithdrawalLog{
Id: row.LogID,
BizType: withdrawalLogBizTypeCommissionRefund,
UserId: row.UserID,
Amount: row.Amount,
Content: row.Content,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
})
}
return &types.QueryWithdrawalLogListResponse{
List: list,
Total: total,
}, nil
}