diff --git a/apis/public/user.api b/apis/public/user.api index 350d7cf..e7968c4 100644 --- a/apis/public/user.api +++ b/apis/public/user.api @@ -137,9 +137,18 @@ type ( Size int `form:"size"` 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 { - List []WithdrawalLog `json:"list"` - Total int64 `json:"total"` + List []WithdrawalLog `json:"list"` + Total int64 `json:"total"` + Summary *WithdrawalLogSummary `json:"summary,omitempty"` } QueryCommissionReturnLogRequest { Page int `form:"page"` diff --git a/internal/logic/admin/user/updateUserBasicInfoLogic.go b/internal/logic/admin/user/updateUserBasicInfoLogic.go index 3d5e504..4fc2a5a 100644 --- a/internal/logic/admin/user/updateUserBasicInfoLogic.go +++ b/internal/logic/admin/user/updateUserBasicInfoLogic.go @@ -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") } 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 { return err } diff --git a/internal/logic/public/user/queryCommissionReturnLogLogic.go b/internal/logic/public/user/queryCommissionReturnLogLogic.go index 55250a6..66f627c 100644 --- a/internal/logic/public/user/queryCommissionReturnLogLogic.go +++ b/internal/logic/public/user/queryCommissionReturnLogLogic.go @@ -130,9 +130,12 @@ func (l *QueryCommissionReturnLogLogic) queryCommissionReturnLogRecordsByEventTy continue } - timestamp := content.Timestamp - if timestamp == 0 { - timestamp = row.CreatedAt.UnixMilli() + // content.Timestamp 历史数据为毫秒,按项目约定(统一秒级)转换;为 0 时回退到 row.CreatedAt 秒值 + var timestamp int64 + if content.Timestamp > 0 { + timestamp = content.Timestamp / 1000 + } else { + timestamp = row.CreatedAt.Unix() } list = append(list, commissionReturnLogRecord{ LogID: row.Id, diff --git a/internal/logic/public/user/queryWithdrawalLogLogic.go b/internal/logic/public/user/queryWithdrawalLogLogic.go index b8e74a0..61d949b 100644 --- a/internal/logic/public/user/queryWithdrawalLogLogic.go +++ b/internal/logic/public/user/queryWithdrawalLogLogic.go @@ -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) diff --git a/internal/logic/public/user/queryWithdrawalLogLogic_test.go b/internal/logic/public/user/queryWithdrawalLogLogic_test.go index 8e1f264..81a1f20 100644 --- a/internal/logic/public/user/queryWithdrawalLogLogic_test.go +++ b/internal/logic/public/user/queryWithdrawalLogLogic_test.go @@ -40,6 +40,23 @@ func TestQueryWithdrawalLog_WithWithdrawalBizType(t *testing.T) { }).AddRow( 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) resp, err := logic.QueryWithdrawalLog(&types.QueryWithdrawalLogListRequest{ @@ -57,9 +74,16 @@ func TestQueryWithdrawalLog_WithWithdrawalBizType(t *testing.T) { if 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) } + 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) } diff --git a/internal/types/types.go b/internal/types/types.go index ea0dad4..b9a18a0 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -2565,9 +2565,19 @@ type QueryWithdrawalLogListRequest struct { 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 { - List []WithdrawalLog `json:"list"` - Total int64 `json:"total"` + List []WithdrawalLog `json:"list"` + Total int64 `json:"total"` + Summary *WithdrawalLogSummary `json:"summary,omitempty"` } type QuotaTask struct {