新功能(#26): 拆分退款日志查询接口

拆分用户中心退款日志查询:

- 新增 GET /v1/public/user/commission_return_log(333/337/338)
- 旧 GET /v1/public/user/withdrawal_log?biz_type=commission_refund 复用新逻辑做兼容
- 默认 withdrawal_log 行为不变(仍查 withdrawals 表)
- 单测覆盖 333/337/338 happy path、坏 JSON 跳过、object_id 隔离、handler 级 HTTP 响应

父 issue: HIF-25
子 issue: HIF-26
This commit is contained in:
2026-06-11 22:02:22 -07:00
committed by GitHub
parent 3e6318dcdf
commit f11097ab83
9 changed files with 494 additions and 70 deletions
@@ -0,0 +1,143 @@
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"
)
type commissionReturnLogRecord struct {
LogID int64
UserID int64
Amount int64
EventType uint16
Content string
CreatedAt int64
UpdatedAt int64
}
type QueryCommissionReturnLogLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// NewQueryCommissionReturnLogLogic Query Commission Return Log
func NewQueryCommissionReturnLogLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryCommissionReturnLogLogic {
return &QueryCommissionReturnLogLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *QueryCommissionReturnLogLogic) QueryCommissionReturnLog(req *types.QueryCommissionReturnLogRequest) (*types.QueryCommissionReturnLogResponse, 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)
list, total, err := l.queryCommissionReturnLogRecords(u.Id, page, size)
if err != nil {
return nil, err
}
respList := make([]types.CommissionReturnLog, 0, len(list))
for _, item := range list {
respList = append(respList, types.CommissionReturnLog{
Id: item.LogID,
UserId: item.UserID,
Amount: item.Amount,
EventType: item.EventType,
Content: item.Content,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
})
}
return &types.QueryCommissionReturnLogResponse{
List: respList,
Total: total,
}, nil
}
func normalizePagination(page, size int) (int, int) {
if page <= 0 {
page = 1
}
if size <= 0 {
size = 10
}
return page, size
}
func (l *QueryCommissionReturnLogLogic) queryCommissionReturnLogRecords(userID int64, page, size int) ([]commissionReturnLogRecord, int64, error) {
query := l.svcCtx.DB.WithContext(l.ctx).
Model(&log.SystemLog{}).
Where("`type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?)",
log.TypeCommission.Uint8(),
userID,
"%\"type\":333%",
"%\"type\":337%",
"%\"type\":338%",
)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count commission return logs failed: %v", err)
}
var rows []log.SystemLog
if err := query.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
return nil, 0, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query commission return logs failed: %v", err)
}
list := make([]commissionReturnLogRecord, 0, len(rows))
for _, row := range rows {
var content log.Commission
if err := content.Unmarshal([]byte(row.Content)); err != nil {
l.Errorw("unmarshal commission return log content failed",
logger.Field("log_id", row.Id),
logger.Field("error", err.Error()),
)
continue
}
if !isCommissionReturnEventType(content.Type) {
continue
}
timestamp := content.Timestamp
if timestamp == 0 {
timestamp = row.CreatedAt.UnixMilli()
}
list = append(list, commissionReturnLogRecord{
LogID: row.Id,
UserID: row.ObjectID,
Amount: content.Amount,
EventType: content.Type,
Content: row.Content,
CreatedAt: timestamp,
UpdatedAt: timestamp,
})
}
return list, total, nil
}
func isCommissionReturnEventType(eventType uint16) bool {
switch eventType {
case log.CommissionTypeRefund, log.CommissionTypeWithdrawReject, log.CommissionTypeWithdrawCancel:
return true
default:
return false
}
}
@@ -3,7 +3,6 @@ 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"
@@ -40,14 +39,7 @@ func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalL
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
page := req.Page
size := req.Size
if page <= 0 {
page = 1
}
if size <= 0 {
size = 10
}
page, size := normalizePagination(req.Page, req.Size)
switch req.BizType {
case "", withdrawalLogBizTypeWithdrawal:
@@ -97,46 +89,22 @@ func (l *QueryWithdrawalLogLogic) queryWithdrawalLogs(userID int64, page, size i
}
func (l *QueryWithdrawalLogLogic) queryCommissionRefundLogs(userID int64, page, size int) (*types.QueryWithdrawalLogListResponse, error) {
query := l.svcCtx.DB.WithContext(l.ctx).
Model(&log.SystemLog{}).
Where("`type` = ? AND object_id = ? AND `content` LIKE ?", log.TypeCommission.Uint8(), userID, "%\"type\":333%")
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count commission refund logs failed: %v", err)
}
var rows []log.SystemLog
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 commission refund logs failed: %v", err)
queryLogic := NewQueryCommissionReturnLogLogic(l.ctx, l.svcCtx)
rows, total, err := queryLogic.queryCommissionReturnLogRecords(userID, page, size)
if err != nil {
return nil, err
}
list := make([]types.WithdrawalLog, 0, len(rows))
for _, row := range rows {
var content log.Commission
if err := content.Unmarshal([]byte(row.Content)); err != nil {
l.Errorw("unmarshal commission refund log content failed",
logger.Field("log_id", row.Id),
logger.Field("error", err.Error()),
)
continue
}
if content.Type != log.CommissionTypeRefund {
continue
}
timestamp := content.Timestamp
if timestamp == 0 {
timestamp = row.CreatedAt.UnixMilli()
}
list = append(list, types.WithdrawalLog{
Id: row.Id,
Id: row.LogID,
BizType: withdrawalLogBizTypeCommissionRefund,
UserId: row.ObjectID,
Amount: content.Amount,
UserId: row.UserID,
Amount: row.Amount,
Content: row.Content,
CreatedAt: timestamp,
UpdatedAt: timestamp,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
})
}
@@ -1,6 +1,7 @@
package user
import (
"bytes"
"context"
"fmt"
"strings"
@@ -58,26 +59,56 @@ func TestQueryWithdrawalLog_WithWithdrawalBizType(t *testing.T) {
if got.Id != 1001 || got.UserId != userID || got.Amount != 3000 || got.CreatedAt != createdAt.UnixMilli() || got.UpdatedAt != updatedAt.UnixMilli() {
t.Fatalf("withdrawal item = %+v", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
assertQueryWithdrawalLogExpectations(t, mock)
}
func TestQueryWithdrawalLog_WithCommissionRefundBizType(t *testing.T) {
func TestQueryCommissionReturnLog_HappyPathIncludes333337338(t *testing.T) {
const userID = int64(42)
content := `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000000123}`
createdAt := time.Unix(1700000000, 0)
db, mock, cleanup := newQueryWithdrawalLogTestDB(t)
defer cleanup()
mock.ExpectQuery("SELECT count(*) FROM `system_logs` WHERE `type` = ? AND object_id = ? AND `content` LIKE ?").
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND `content` LIKE ? ORDER BY id DESC LIMIT ?").
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", 10).
expectCommissionReturnQueries(mock, userID, 3)
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?").
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10).
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, content, createdAt))
AddRow(int64(2003), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":338,"amount":1500,"order_no":"ORDER-3","timestamp":1700000003123}`, createdAt).
AddRow(int64(2002), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":337,"amount":2000,"order_no":"ORDER-2","timestamp":1700000002123}`, createdAt).
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, createdAt))
logic := NewQueryCommissionReturnLogLogic(newTestQueryCtx(userID), &svc.ServiceContext{DB: db})
resp, err := logic.QueryCommissionReturnLog(&types.QueryCommissionReturnLogRequest{Page: 1, Size: 10})
if err != nil {
t.Fatalf("QueryCommissionReturnLog unexpected error: %v", err)
}
if resp.Total != 3 || len(resp.List) != 3 {
t.Fatalf("QueryCommissionReturnLog response = %+v, want three logs", resp)
}
eventTypes := []uint16{resp.List[0].EventType, resp.List[1].EventType, resp.List[2].EventType}
wantTypes := []uint16{logmodel.CommissionTypeWithdrawCancel, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeRefund}
if fmt.Sprint(eventTypes) != fmt.Sprint(wantTypes) {
t.Fatalf("event types = %v, want %v", eventTypes, wantTypes)
}
assertQueryWithdrawalLogExpectations(t, mock)
}
func TestQueryWithdrawalLog_WithCommissionRefundBizTypeIncludes333337338(t *testing.T) {
const userID = int64(42)
createdAt := time.Unix(1700000000, 0)
db, mock, cleanup := newQueryWithdrawalLogTestDB(t)
defer cleanup()
expectCommissionReturnQueries(mock, userID, 3)
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?").
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10).
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
AddRow(int64(2003), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":338,"amount":1500,"order_no":"ORDER-3","timestamp":1700000003123}`, createdAt).
AddRow(int64(2002), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":337,"amount":2000,"order_no":"ORDER-2","timestamp":1700000002123}`, createdAt).
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, createdAt))
logic := newTestQueryWithdrawalLogLogic(t, db, userID)
resp, err := logic.QueryWithdrawalLog(&types.QueryWithdrawalLogListRequest{
@@ -88,22 +119,72 @@ func TestQueryWithdrawalLog_WithCommissionRefundBizType(t *testing.T) {
if err != nil {
t.Fatalf("QueryWithdrawalLog unexpected error: %v", err)
}
if resp.Total != 1 || len(resp.List) != 1 {
t.Fatalf("QueryWithdrawalLog response = %+v, want one commission refund", resp)
if resp.Total != 3 || len(resp.List) != 3 {
t.Fatalf("QueryWithdrawalLog response = %+v, want three commission returns", resp)
}
got := resp.List[0]
if got.BizType != withdrawalLogBizTypeCommissionRefund {
t.Fatalf("BizType = %q, want %q", got.BizType, withdrawalLogBizTypeCommissionRefund)
for _, item := range resp.List {
if item.BizType != withdrawalLogBizTypeCommissionRefund {
t.Fatalf("BizType = %q, want %q", item.BizType, withdrawalLogBizTypeCommissionRefund)
}
if item.Status != 0 || item.Reason != "" || item.Method != 0 || item.Account != "" || item.QrCodeUrl != "" {
t.Fatalf("withdrawal-only fields should keep zero values, got %+v", item)
}
}
if got.Id != 2001 || got.UserId != userID || got.Amount != 2500 || got.Content != content || got.CreatedAt != 1700000000123 || got.UpdatedAt != 1700000000123 {
t.Fatalf("commission refund item = %+v", got)
assertQueryWithdrawalLogExpectations(t, mock)
}
func TestQueryCommissionReturnLog_SkipsInvalidJSONAndLogsWarn(t *testing.T) {
const userID = int64(42)
createdAt := time.Unix(1700000000, 0)
db, mock, cleanup := newQueryWithdrawalLogTestDB(t)
defer cleanup()
expectCommissionReturnQueries(mock, userID, 2)
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?").
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10).
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
AddRow(int64(2002), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":337,"amount":2000,"order_no":"ORDER-2","timestamp":1700000002123}`, createdAt).
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":333`, createdAt))
var buf bytes.Buffer
restoreLogger := captureTestLogs(&buf)
defer restoreLogger()
logic := NewQueryCommissionReturnLogLogic(newTestQueryCtx(userID), &svc.ServiceContext{DB: db})
resp, err := logic.QueryCommissionReturnLog(&types.QueryCommissionReturnLogRequest{Page: 1, Size: 10})
if err != nil {
t.Fatalf("QueryCommissionReturnLog unexpected error: %v", err)
}
if got.Status != 0 || got.Method != 0 || got.Account != "" || got.QrCodeUrl != "" {
t.Fatalf("commission refund withdrawal-only fields = %+v, want zero values", got)
if resp.Total != 2 || len(resp.List) != 1 {
t.Fatalf("QueryCommissionReturnLog response = %+v, want total=2 and one valid row", resp)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
if !strings.Contains(buf.String(), "unmarshal commission return log content failed") {
t.Fatalf("expected warn log, got %q", buf.String())
}
assertQueryWithdrawalLogExpectations(t, mock)
}
func TestQueryCommissionReturnLog_FiltersOtherUsersByObjectID(t *testing.T) {
const userID = int64(42)
db, mock, cleanup := newQueryWithdrawalLogTestDB(t)
defer cleanup()
expectCommissionReturnQueries(mock, userID, 0)
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?").
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10).
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}))
logic := NewQueryCommissionReturnLogLogic(newTestQueryCtx(userID), &svc.ServiceContext{DB: db})
resp, err := logic.QueryCommissionReturnLog(&types.QueryCommissionReturnLogRequest{Page: 1, Size: 10})
if err != nil {
t.Fatalf("QueryCommissionReturnLog unexpected error: %v", err)
}
if resp.Total != 0 || len(resp.List) != 0 {
t.Fatalf("QueryCommissionReturnLog response = %+v, want no rows for filtered user", resp)
}
assertQueryWithdrawalLogExpectations(t, mock)
}
func TestQueryWithdrawalLog_RejectsInvalidBizType(t *testing.T) {
@@ -119,9 +200,7 @@ func TestQueryWithdrawalLog_RejectsInvalidBizType(t *testing.T) {
if !isQueryWithdrawalLogErrCode(err, xerr.InvalidParams) {
t.Fatalf("QueryWithdrawalLog err = %v, want InvalidParams", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
assertQueryWithdrawalLogExpectations(t, mock)
}
func newQueryWithdrawalLogTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
@@ -150,7 +229,7 @@ func newQueryWithdrawalLogTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func(
func newTestQueryWithdrawalLogLogic(t *testing.T, db *gorm.DB, userID int64) *QueryWithdrawalLogLogic {
t.Helper()
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, &usermodel.User{Id: userID})
ctx := newTestQueryCtx(userID)
return &QueryWithdrawalLogLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
@@ -160,6 +239,37 @@ func newTestQueryWithdrawalLogLogic(t *testing.T, db *gorm.DB, userID int64) *Qu
}
}
func newTestQueryCtx(userID int64) context.Context {
return context.WithValue(context.Background(), constant.CtxKeyUser, &usermodel.User{Id: userID})
}
func expectCommissionReturnQueries(mock sqlmock.Sqlmock, userID int64, total int64) {
mock.ExpectQuery("SELECT count(*) FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?)").
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", "%\"type\":337%", "%\"type\":338%").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(total))
}
func assertQueryWithdrawalLogExpectations(t *testing.T, mock sqlmock.Sqlmock) {
t.Helper()
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
func captureTestLogs(buf *bytes.Buffer) func() {
prevWriter := logger.Reset()
prevLevel := logger.InfoLevel
logger.SetLevel(logger.DebugLevel)
logger.SetWriter(logger.NewWriter(buf))
return func() {
logger.Reset()
logger.SetLevel(prevLevel)
if prevWriter != nil {
logger.SetWriter(prevWriter)
}
}
}
func queryWithdrawalLogErrCodeOf(err error) uint32 {
if err == nil {
return 0