新功能: 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:
+11
-2
@@ -137,9 +137,18 @@ type (
|
|||||||
Size int `form:"size"`
|
Size int `form:"size"`
|
||||||
BizType string `form:"biz_type" validate:"omitempty,oneof=withdrawal commission_refund"`
|
BizType string `form:"biz_type" validate:"omitempty,oneof=withdrawal commission_refund"`
|
||||||
}
|
}
|
||||||
|
WithdrawalLogSummary {
|
||||||
|
CommissionBalance int64 `json:"commission_balance"`
|
||||||
|
LockedByPending int64 `json:"locked_by_pending"`
|
||||||
|
AvailableToWithdraw int64 `json:"available_to_withdraw"`
|
||||||
|
TotalHistoricalAmount int64 `json:"total_historical_amount"`
|
||||||
|
TotalRefundedAmount int64 `json:"total_refunded_amount"`
|
||||||
|
TotalIncomeAmount int64 `json:"total_income_amount"`
|
||||||
|
}
|
||||||
QueryWithdrawalLogListResponse {
|
QueryWithdrawalLogListResponse {
|
||||||
List []WithdrawalLog `json:"list"`
|
List []WithdrawalLog `json:"list"`
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
|
Summary *WithdrawalLogSummary `json:"summary,omitempty"`
|
||||||
}
|
}
|
||||||
QueryCommissionReturnLogRequest {
|
QueryCommissionReturnLogRequest {
|
||||||
Page int `form:"page"`
|
Page int `form:"page"`
|
||||||
|
|||||||
@@ -110,6 +110,16 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
|||||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "commission overwrite is blocked in withdrawal scene")
|
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "commission overwrite is blocked in withdrawal scene")
|
||||||
}
|
}
|
||||||
change := *req.Commission - userInfo.Commission
|
change := *req.Commission - userInfo.Commission
|
||||||
|
if change < 0 {
|
||||||
|
// 禁止直接扣减佣金:扣减必须走 approveWithdrawal 写 type=334 日志,
|
||||||
|
// 否则 user.commission 和 system_logs 会再次失衡,破坏账目闭环。
|
||||||
|
l.Logger.Errorw("blocked direct commission deduction via admin update",
|
||||||
|
logger.Field("user_id", userInfo.Id),
|
||||||
|
logger.Field("change", change),
|
||||||
|
)
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess),
|
||||||
|
"commission deduction must go through withdrawal approval, not direct edit")
|
||||||
|
}
|
||||||
if err = l.svcCtx.UserModel.UpdateCommission(l.ctx, userInfo.Id, change, tx); err != nil {
|
if err = l.svcCtx.UserModel.UpdateCommission(l.ctx, userInfo.Id, change, tx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,9 +130,12 @@ func (l *QueryCommissionReturnLogLogic) queryCommissionReturnLogRecordsByEventTy
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
timestamp := content.Timestamp
|
// content.Timestamp 历史数据为毫秒,按项目约定(统一秒级)转换;为 0 时回退到 row.CreatedAt 秒值
|
||||||
if timestamp == 0 {
|
var timestamp int64
|
||||||
timestamp = row.CreatedAt.UnixMilli()
|
if content.Timestamp > 0 {
|
||||||
|
timestamp = content.Timestamp / 1000
|
||||||
|
} else {
|
||||||
|
timestamp = row.CreatedAt.Unix()
|
||||||
}
|
}
|
||||||
list = append(list, commissionReturnLogRecord{
|
list = append(list, commissionReturnLogRecord{
|
||||||
LogID: row.Id,
|
LogID: row.Id,
|
||||||
|
|||||||
@@ -78,17 +78,72 @@ func (l *QueryWithdrawalLogLogic) queryWithdrawalLogs(userID int64, page, size i
|
|||||||
Method: row.Method,
|
Method: row.Method,
|
||||||
Account: row.Account,
|
Account: row.Account,
|
||||||
QrCodeUrl: row.QrCodeUrl,
|
QrCodeUrl: row.QrCodeUrl,
|
||||||
CreatedAt: row.CreatedAt.UnixMilli(),
|
CreatedAt: row.CreatedAt.Unix(),
|
||||||
UpdatedAt: row.UpdatedAt.UnixMilli(),
|
UpdatedAt: row.UpdatedAt.Unix(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
summary, err := l.buildSummary(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &types.QueryWithdrawalLogListResponse{
|
return &types.QueryWithdrawalLogListResponse{
|
||||||
List: list,
|
List: list,
|
||||||
Total: total,
|
Total: total,
|
||||||
|
Summary: summary,
|
||||||
}, nil
|
}, 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) {
|
func (l *QueryWithdrawalLogLogic) queryCommissionRefundLogs(userID int64, page, size int) (*types.QueryWithdrawalLogListResponse, error) {
|
||||||
queryLogic := NewQueryCommissionReturnLogLogic(l.ctx, l.svcCtx)
|
queryLogic := NewQueryCommissionReturnLogLogic(l.ctx, l.svcCtx)
|
||||||
rows, total, err := queryLogic.queryCommissionReturnLogRecordsByEventTypes(userID, page, size, log.CommissionTypeRefund)
|
rows, total, err := queryLogic.queryCommissionReturnLogRecordsByEventTypes(userID, page, size, log.CommissionTypeRefund)
|
||||||
|
|||||||
@@ -40,6 +40,23 @@ func TestQueryWithdrawalLog_WithWithdrawalBizType(t *testing.T) {
|
|||||||
}).AddRow(
|
}).AddRow(
|
||||||
int64(1001), userID, int64(3000), "bank withdrawal", usermodel.WithdrawalStatusPending, "", uint8(3), "acct-001", "", createdAt, updatedAt,
|
int64(1001), userID, int64(3000), "bank withdrawal", usermodel.WithdrawalStatusPending, "", uint8(3), "acct-001", "", createdAt, updatedAt,
|
||||||
))
|
))
|
||||||
|
// buildSummary 触发的 4 个聚合查询
|
||||||
|
mock.ExpectQuery("SELECT COALESCE(commission, 0) FROM `user`").
|
||||||
|
WithArgs(userID).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"commission"}).AddRow(int64(5000)))
|
||||||
|
mock.ExpectQuery("SELECT COALESCE(SUM(amount), 0) FROM `withdrawals`").
|
||||||
|
WithArgs(userID, usermodel.WithdrawalStatusPending).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"sum"}).AddRow(int64(3000)))
|
||||||
|
mock.ExpectQuery("SELECT COALESCE(SUM(amount), 0) FROM `withdrawals`").
|
||||||
|
WithArgs(userID, usermodel.WithdrawalStatusApproved).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"sum"}).AddRow(int64(0)))
|
||||||
|
mock.ExpectQuery("FROM system_logs").
|
||||||
|
WithArgs(
|
||||||
|
logmodel.CommissionTypePurchase, logmodel.CommissionTypeRenewal,
|
||||||
|
logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel,
|
||||||
|
logmodel.TypeCommission.Uint8(), userID,
|
||||||
|
).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"income", "refund"}).AddRow(int64(8000), int64(0)))
|
||||||
|
|
||||||
logic := newTestQueryWithdrawalLogLogic(t, db, userID)
|
logic := newTestQueryWithdrawalLogLogic(t, db, userID)
|
||||||
resp, err := logic.QueryWithdrawalLog(&types.QueryWithdrawalLogListRequest{
|
resp, err := logic.QueryWithdrawalLog(&types.QueryWithdrawalLogListRequest{
|
||||||
@@ -57,9 +74,16 @@ func TestQueryWithdrawalLog_WithWithdrawalBizType(t *testing.T) {
|
|||||||
if got.BizType != withdrawalLogBizTypeWithdrawal {
|
if got.BizType != withdrawalLogBizTypeWithdrawal {
|
||||||
t.Fatalf("BizType = %q, want %q", got.BizType, withdrawalLogBizTypeWithdrawal)
|
t.Fatalf("BizType = %q, want %q", got.BizType, withdrawalLogBizTypeWithdrawal)
|
||||||
}
|
}
|
||||||
if got.Id != 1001 || got.UserId != userID || got.Amount != 3000 || got.CreatedAt != createdAt.UnixMilli() || got.UpdatedAt != updatedAt.UnixMilli() {
|
if got.Id != 1001 || got.UserId != userID || got.Amount != 3000 || got.CreatedAt != createdAt.Unix() || got.UpdatedAt != updatedAt.Unix() {
|
||||||
t.Fatalf("withdrawal item = %+v", got)
|
t.Fatalf("withdrawal item = %+v", got)
|
||||||
}
|
}
|
||||||
|
if resp.Summary == nil {
|
||||||
|
t.Fatalf("expected summary, got nil")
|
||||||
|
}
|
||||||
|
if resp.Summary.CommissionBalance != 5000 || resp.Summary.LockedByPending != 3000 ||
|
||||||
|
resp.Summary.AvailableToWithdraw != 2000 || resp.Summary.TotalIncomeAmount != 8000 {
|
||||||
|
t.Fatalf("summary = %+v, want balance=5000 locked=3000 avail=2000 income=8000", resp.Summary)
|
||||||
|
}
|
||||||
assertQueryWithdrawalLogExpectations(t, mock)
|
assertQueryWithdrawalLogExpectations(t, mock)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -2565,9 +2565,19 @@ type QueryWithdrawalLogListRequest struct {
|
|||||||
BizType string `form:"biz_type" validate:"omitempty,oneof=withdrawal commission_refund"`
|
BizType string `form:"biz_type" validate:"omitempty,oneof=withdrawal commission_refund"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WithdrawalLogSummary struct {
|
||||||
|
CommissionBalance int64 `json:"commission_balance"`
|
||||||
|
LockedByPending int64 `json:"locked_by_pending"`
|
||||||
|
AvailableToWithdraw int64 `json:"available_to_withdraw"`
|
||||||
|
TotalHistoricalAmount int64 `json:"total_historical_amount"`
|
||||||
|
TotalRefundedAmount int64 `json:"total_refunded_amount"`
|
||||||
|
TotalIncomeAmount int64 `json:"total_income_amount"`
|
||||||
|
}
|
||||||
|
|
||||||
type QueryWithdrawalLogListResponse struct {
|
type QueryWithdrawalLogListResponse struct {
|
||||||
List []WithdrawalLog `json:"list"`
|
List []WithdrawalLog `json:"list"`
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
|
Summary *WithdrawalLogSummary `json:"summary,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type QuotaTask struct {
|
type QuotaTask struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user