From cdee93c7c50dfd1d097849cba2ad098e75a596ea Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 24 May 2026 07:00:13 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=89=B4=E6=9D=83=EF=BC=9A40?= =?UTF-8?q?1=E6=8B=A6=E6=88=AA=E5=99=A8=E5=8A=A0refresh=E9=87=8D=E8=AF=95?= =?UTF-8?q?=20+=20admin=20refresh=E6=8E=A5=E5=8F=A3=20+=20=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E5=AE=88=E5=8D=AB=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:access_token每2小时过期,前端收到401直接清token跳登录,没有用refresh_token续期 后端修复: - adminauth模块新增 POST /admin/auth/refresh 接口 - Service 注入 JWTManager,支持 admin refresh token 换新 token pair - Refresh 方法验证 subjectType=admin + tokenType=refresh 前端修复: - 401 拦截器核心改造:收到401先调 refresh 接口续期 - 加 isRefreshing 锁 + pendingRequests 队列防止并发刷新 - refresh 用原生 axios.post 避免拦截器递归 - 成功则更新 localStorage + 重试原请求,失败才清 token 跳登录 - 排除 /auth/refresh 自身避免死循环 - 支持 /admin/ 请求独立 token 管理 - auth.ts/adminAuth.ts 新增手动 refreshUserToken/refreshAdminSession - 路由守卫给所有需登录路由添加 meta.requiresAuth - 守卫同时支持 PC 端 /login 和移动端 /m/login --- backend/internal/modules/adminaudit/dto.go | 16 +- .../internal/modules/adminaudit/handler.go | 30 +- .../internal/modules/adminaudit/repository.go | 23 +- .../internal/modules/adminaudit/service.go | 2 +- backend/internal/modules/adminauth/handler.go | 20 ++ backend/internal/modules/adminauth/service.go | 26 +- backend/internal/modules/adminuser/dto.go | 8 +- backend/internal/modules/adminuser/handler.go | 20 +- .../internal/modules/adminuser/repository.go | 11 +- backend/internal/modules/adminuser/service.go | 4 +- backend/internal/modules/dispute/dto.go | 8 +- backend/internal/modules/dispute/handler.go | 25 +- .../internal/modules/dispute/repository.go | 22 +- backend/internal/modules/dispute/service.go | 8 +- backend/internal/modules/listing/dto.go | 9 +- backend/internal/modules/listing/handler.go | 43 ++- .../internal/modules/listing/repository.go | 80 +++-- backend/internal/modules/listing/service.go | 14 +- backend/internal/modules/notification/dto.go | 7 + .../internal/modules/notification/handler.go | 20 +- .../modules/notification/repository.go | 11 +- .../internal/modules/notification/service.go | 4 +- backend/internal/modules/order/dto.go | 23 ++ backend/internal/modules/order/handler.go | 30 +- backend/internal/modules/order/repository.go | 80 ++++- backend/internal/modules/order/service.go | 8 +- backend/internal/modules/wallet/dto.go | 16 +- backend/internal/modules/wallet/handler.go | 35 ++- backend/internal/modules/wallet/repository.go | 34 ++- backend/internal/modules/wallet/service.go | 6 +- backend/internal/router/router.go | 3 +- backend/migrations/000003_add_indexes.sql | 35 +++ frontend/src/api/adminAudit.ts | 9 +- frontend/src/api/adminAuth.ts | 9 + frontend/src/api/adminUsers.ts | 10 +- frontend/src/api/adminWallet.ts | 9 +- frontend/src/api/auth.ts | 8 + frontend/src/api/client.ts | 125 ++++++-- frontend/src/api/disputes.ts | 18 +- frontend/src/api/listings.ts | 197 ++++++++----- frontend/src/api/notifications.ts | 10 +- frontend/src/api/orders.ts | 274 +++++++++++------- frontend/src/api/wallet.ts | 10 +- frontend/src/router/index.ts | 21 +- frontend/src/styles/base.css | 7 + .../src/views/account/NotificationsView.vue | 24 +- frontend/src/views/account/OrdersView.vue | 42 ++- frontend/src/views/account/WalletView.vue | 31 +- .../src/views/admin/AdminAuditLogsView.vue | 59 ++-- .../src/views/admin/AdminDisputesView.vue | 37 ++- .../views/admin/AdminListingReviewView.vue | 72 ++--- .../src/views/admin/AdminListingsView.vue | 123 +++----- frontend/src/views/admin/AdminOrdersView.vue | 60 +++- frontend/src/views/admin/AdminUsersView.vue | 46 +-- .../src/views/admin/AdminWalletLedgerView.vue | 54 ++-- frontend/src/views/mobile/MobileHomeView.vue | 121 ++++++-- .../src/views/mobile/MobileOrdersView.vue | 37 ++- frontend/src/views/public/HomeView.vue | 81 +++++- frontend/src/views/public/ListingsView.vue | 140 ++++----- .../src/views/seller/SellerHandoffsView.vue | 117 ++++++++ .../src/views/seller/SellerListingsView.vue | 25 +- 61 files changed, 1735 insertions(+), 722 deletions(-) create mode 100644 backend/migrations/000003_add_indexes.sql diff --git a/backend/internal/modules/adminaudit/dto.go b/backend/internal/modules/adminaudit/dto.go index f6dfafb..5fbe553 100644 --- a/backend/internal/modules/adminaudit/dto.go +++ b/backend/internal/modules/adminaudit/dto.go @@ -7,10 +7,18 @@ import ( ) type Query struct { - ActorID uint64 - Action string - BizType string - Limit int + ActorID uint64 + Action string + BizType string + Page int + PageSize int +} + +type PaginatedResult struct { + Items interface{} `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` } type LogDTO struct { diff --git a/backend/internal/modules/adminaudit/handler.go b/backend/internal/modules/adminaudit/handler.go index ecba3a5..885cdef 100644 --- a/backend/internal/modules/adminaudit/handler.go +++ b/backend/internal/modules/adminaudit/handler.go @@ -18,17 +18,32 @@ func NewHandler(service *Service) *Handler { return &Handler{service: service} } +func parsePagination(c *gin.Context) (int, int) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} + func (h *Handler) List(c *gin.Context) { query, ok := parseQuery(c) if !ok { return } - items, err := h.service.List(query) + result, err := h.service.List(query) if err != nil { writeAuditError(c, err) return } - response.OK(c, gin.H{"items": items}) + response.OK(c, result) } func parseQuery(c *gin.Context) (Query, bool) { @@ -41,16 +56,11 @@ func parseQuery(c *gin.Context) (Query, bool) { } query.ActorID = value } - if raw := c.Query("limit"); raw != "" { - value, err := strconv.Atoi(raw) - if err != nil || value <= 0 { - response.BadRequest(c, "查询条数不正确") - return query, false - } - query.Limit = value - } query.Action = c.Query("action") query.BizType = c.Query("biz_type") + page, pageSize := parsePagination(c) + query.Page = page + query.PageSize = pageSize return query, true } diff --git a/backend/internal/modules/adminaudit/repository.go b/backend/internal/modules/adminaudit/repository.go index 9ff8c11..5a1ae0a 100644 --- a/backend/internal/modules/adminaudit/repository.go +++ b/backend/internal/modules/adminaudit/repository.go @@ -3,6 +3,8 @@ package adminaudit import ( "time" + "hfb_sys/backend/internal/model" + "gorm.io/datatypes" "gorm.io/gorm" ) @@ -15,37 +17,42 @@ func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} } -func (r *Repository) List(query Query) ([]LogDTO, error) { - limit := query.Limit - if limit <= 0 || limit > 500 { - limit = 200 - } - +func (r *Repository) List(query Query) (*PaginatedResult, error) { db := r.db.Table("audit_logs AS al"). Select(`al.id, al.actor_type, al.actor_id, COALESCE(au.username, '') AS actor_username, COALESCE(au.nickname, '') AS actor_nickname, al.action, al.biz_type, al.biz_id, al.ip, al.user_agent, al.detail, al.created_at`). Joins("LEFT JOIN admin_users AS au ON au.id = al.actor_id AND al.actor_type = ?", "admin") + countDB := r.db.Model(&model.AuditLog{}) if query.ActorID > 0 { db = db.Where("al.actor_id = ?", query.ActorID) + countDB = countDB.Where("actor_id = ?", query.ActorID) } if query.Action != "" { db = db.Where("al.action = ?", query.Action) + countDB = countDB.Where("action = ?", query.Action) } if query.BizType != "" { db = db.Where("al.biz_type = ?", query.BizType) + countDB = countDB.Where("biz_type = ?", query.BizType) } + var total int64 + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + + offset := (query.Page - 1) * query.PageSize var rows []auditLogRow - if err := db.Order("al.id DESC").Limit(limit).Scan(&rows).Error; err != nil { + if err := db.Order("al.id DESC").Offset(offset).Limit(query.PageSize).Scan(&rows).Error; err != nil { return nil, err } items := make([]LogDTO, 0, len(rows)) for _, row := range rows { items = append(items, row.toDTO()) } - return items, nil + return &PaginatedResult{Items: items, Total: total, Page: query.Page, PageSize: query.PageSize}, nil } type auditLogRow struct { diff --git a/backend/internal/modules/adminaudit/service.go b/backend/internal/modules/adminaudit/service.go index 2f51af4..8b624fc 100644 --- a/backend/internal/modules/adminaudit/service.go +++ b/backend/internal/modules/adminaudit/service.go @@ -12,7 +12,7 @@ func NewService(repo *Repository) *Service { return &Service{repo: repo} } -func (s *Service) List(query Query) ([]LogDTO, error) { +func (s *Service) List(query Query) (*PaginatedResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } diff --git a/backend/internal/modules/adminauth/handler.go b/backend/internal/modules/adminauth/handler.go index 498e681..2c8e9eb 100644 --- a/backend/internal/modules/adminauth/handler.go +++ b/backend/internal/modules/adminauth/handler.go @@ -65,10 +65,30 @@ func (h *Handler) Logout(c *gin.Context) { response.OK(c, gin.H{"logged_out": true}) } +type AdminRefreshRequest struct { + RefreshToken string `json:"refresh_token" binding:"required"` +} + +func (h *Handler) Refresh(c *gin.Context) { + var req AdminRefreshRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "refresh_token 不能为空") + return + } + tokens, err := h.service.Refresh(req.RefreshToken) + if err != nil { + writeAdminAuthError(c, err) + return + } + response.OK(c, tokens) +} + func writeAdminAuthError(c *gin.Context, err error) { switch { case errors.Is(err, ErrDependencyUnavailable): response.ServiceUnavailable(c, "数据库未连接") + case errors.Is(err, ErrInvalidRefreshToken): + response.Unauthorized(c, "刷新令牌无效或已过期") case errors.Is(err, ErrInvalidCredential): response.BadRequest(c, "用户名或密码错误") case errors.Is(err, ErrCaptchaInvalid): diff --git a/backend/internal/modules/adminauth/service.go b/backend/internal/modules/adminauth/service.go index 0866b74..cac1ff8 100644 --- a/backend/internal/modules/adminauth/service.go +++ b/backend/internal/modules/adminauth/service.go @@ -1,20 +1,40 @@ package adminauth -import "errors" +import ( + "errors" + + "hfb_sys/backend/internal/modules/auth" +) var ( ErrDependencyUnavailable = errors.New("dependency unavailable") ErrInvalidCredential = errors.New("invalid credential") ErrCaptchaInvalid = errors.New("captcha invalid") ErrAdminDisabled = errors.New("admin disabled") + ErrInvalidRefreshToken = errors.New("invalid refresh token") ) type Service struct { repo *Repository + jwt *auth.JWTManager +} +func NewService(repo *Repository, jwt *auth.JWTManager) *Service { + return &Service{repo: repo, jwt: jwt} } -func NewService(repo *Repository) *Service { - return &Service{repo: repo} +func (s *Service) Refresh(refreshToken string) (*auth.TokenPair, error) { + if s.jwt == nil { + return nil, ErrDependencyUnavailable + } + claims, err := s.jwt.ParseSubject(refreshToken, "refresh", "admin") + if err != nil { + return nil, ErrInvalidRefreshToken + } + pair, err := s.jwt.GenerateSubjectPair(claims.UserID, claims.Phone, "admin") + if err != nil { + return nil, err + } + return &pair, nil } func (s *Service) Captcha() (*CaptchaDTO, error) { diff --git a/backend/internal/modules/adminuser/dto.go b/backend/internal/modules/adminuser/dto.go index 7502899..cfb9834 100644 --- a/backend/internal/modules/adminuser/dto.go +++ b/backend/internal/modules/adminuser/dto.go @@ -17,7 +17,13 @@ type UserDTO struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } - type FreezeRequest struct { Reason string `json:"reason"` } + +type PaginatedResult struct { + Items interface{} `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} diff --git a/backend/internal/modules/adminuser/handler.go b/backend/internal/modules/adminuser/handler.go index 3d0cb97..95e8d69 100644 --- a/backend/internal/modules/adminuser/handler.go +++ b/backend/internal/modules/adminuser/handler.go @@ -19,13 +19,29 @@ func NewHandler(service *Service) *Handler { return &Handler{service: service} } +func parsePagination(c *gin.Context) (int, int) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} + func (h *Handler) List(c *gin.Context) { - items, err := h.service.List() + page, pageSize := parsePagination(c) + result, err := h.service.List(page, pageSize) if err != nil { writeAdminUserError(c, err) return } - response.OK(c, gin.H{"items": items}) + response.OK(c, result) } func (h *Handler) Freeze(c *gin.Context) { diff --git a/backend/internal/modules/adminuser/repository.go b/backend/internal/modules/adminuser/repository.go index 4cfa59e..c34544a 100644 --- a/backend/internal/modules/adminuser/repository.go +++ b/backend/internal/modules/adminuser/repository.go @@ -24,7 +24,12 @@ func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} } -func (r *Repository) List() ([]UserDTO, error) { +func (r *Repository) List(page, pageSize int) (*PaginatedResult, error) { + var total int64 + if err := r.db.Model(&model.User{}).Count(&total).Error; err != nil { + return nil, err + } + offset := (page - 1) * pageSize var rows []userRow err := r.db.Table("users AS u"). Select(`u.*, @@ -35,7 +40,7 @@ func (r *Repository) List() ([]UserDTO, error) { Joins("LEFT JOIN (SELECT owner_id AS user_id, COUNT(*) AS listing_count FROM rental_listings GROUP BY owner_id) AS l ON l.user_id = u.id"). Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS dispute_count FROM (SELECT initiator_id AS user_id FROM disputes UNION ALL SELECT target_user_id AS user_id FROM disputes) AS dispute_users GROUP BY user_id) AS d ON d.user_id = u.id"). Order("u.id DESC"). - Limit(200). + Offset(offset).Limit(pageSize). Scan(&rows).Error if err != nil { return nil, err @@ -44,7 +49,7 @@ func (r *Repository) List() ([]UserDTO, error) { for _, row := range rows { items = append(items, row.toDTO()) } - return items, nil + return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } func (r *Repository) Freeze(adminID uint64, userID uint64, req FreezeRequest, meta AuditMeta) (*UserDTO, error) { diff --git a/backend/internal/modules/adminuser/service.go b/backend/internal/modules/adminuser/service.go index 95bc002..4ade08d 100644 --- a/backend/internal/modules/adminuser/service.go +++ b/backend/internal/modules/adminuser/service.go @@ -15,11 +15,11 @@ func NewService(repo *Repository) *Service { return &Service{repo: repo} } -func (s *Service) List() ([]UserDTO, error) { +func (s *Service) List(page, pageSize int) (*PaginatedResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - return s.repo.List() + return s.repo.List(page, pageSize) } func (s *Service) Freeze(adminID uint64, userID uint64, req FreezeRequest, meta AuditMeta) (*UserDTO, error) { diff --git a/backend/internal/modules/dispute/dto.go b/backend/internal/modules/dispute/dto.go index 4fcf3ce..1faa778 100644 --- a/backend/internal/modules/dispute/dto.go +++ b/backend/internal/modules/dispute/dto.go @@ -36,8 +36,14 @@ type ArbitrateRequest struct { Remark string `json:"remark" binding:"required"` Amount float64 `json:"amount"` } - type AuditMeta struct { IP string UserAgent string } + +type PaginatedResult struct { + Items []DisputeDTO `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} diff --git a/backend/internal/modules/dispute/handler.go b/backend/internal/modules/dispute/handler.go index 6cce4d0..f6c626d 100644 --- a/backend/internal/modules/dispute/handler.go +++ b/backend/internal/modules/dispute/handler.go @@ -48,12 +48,13 @@ func (h *Handler) List(c *gin.Context) { response.Unauthorized(c, "缺少用户上下文") return } - items, err := h.service.ListForUser(userID) + page, pageSize := parsePagination(c) + result, err := h.service.ListForUser(userID, page, pageSize) if err != nil { writeDisputeError(c, err) return } - response.OK(c, gin.H{"items": items}) + response.OK(c, result) } func (h *Handler) Detail(c *gin.Context) { @@ -75,12 +76,13 @@ func (h *Handler) Detail(c *gin.Context) { } func (h *Handler) AdminList(c *gin.Context) { - items, err := h.service.ListAdmin() + page, pageSize := parsePagination(c) + result, err := h.service.ListAdmin(page, pageSize) if err != nil { writeDisputeError(c, err) return } - response.OK(c, gin.H{"items": items}) + response.OK(c, result) } func (h *Handler) AdminArbitrate(c *gin.Context) { @@ -136,6 +138,21 @@ func parseID(c *gin.Context) (uint64, bool) { return id, true } +func parsePagination(c *gin.Context) (int, int) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} + func writeDisputeError(c *gin.Context, err error) { switch { case errors.Is(err, ErrDependencyUnavailable): diff --git a/backend/internal/modules/dispute/repository.go b/backend/internal/modules/dispute/repository.go index a4df211..94a7cdc 100644 --- a/backend/internal/modules/dispute/repository.go +++ b/backend/internal/modules/dispute/repository.go @@ -125,16 +125,23 @@ func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (* return r.FindForUser(userID, createdID) } -func (r *Repository) ListForUser(userID uint64) ([]DisputeDTO, error) { +func (r *Repository) ListForUser(userID uint64, page, pageSize int) (*PaginatedResult, error) { + conditions := r.db.Model(&model.Dispute{}).Where("initiator_id = ? OR target_user_id = ?", userID, userID) + var total int64 + if err := conditions.Count(&total).Error; err != nil { + return nil, err + } + offset := (page - 1) * pageSize var rows []disputeRow err := r.baseQuery(). Where("d.initiator_id = ? OR d.target_user_id = ?", userID, userID). Order("d.id DESC"). + Offset(offset).Limit(pageSize). Scan(&rows).Error if err != nil { return nil, err } - return toDTOs(rows), nil + return &PaginatedResult{Items: toDTOs(rows), Total: total, Page: page, PageSize: pageSize}, nil } func (r *Repository) FindForUser(userID uint64, id uint64) (*DisputeDTO, error) { @@ -148,13 +155,18 @@ func (r *Repository) FindForUser(userID uint64, id uint64) (*DisputeDTO, error) return &dto, nil } -func (r *Repository) ListAdmin() ([]DisputeDTO, error) { +func (r *Repository) ListAdmin(page, pageSize int) (*PaginatedResult, error) { + var total int64 + if err := r.db.Model(&model.Dispute{}).Count(&total).Error; err != nil { + return nil, err + } + offset := (page - 1) * pageSize var rows []disputeRow - err := r.baseQuery().Order("d.id DESC").Limit(200).Scan(&rows).Error + err := r.baseQuery().Order("d.id DESC").Offset(offset).Limit(pageSize).Scan(&rows).Error if err != nil { return nil, err } - return toDTOs(rows), nil + return &PaginatedResult{Items: toDTOs(rows), Total: total, Page: page, PageSize: pageSize}, nil } func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest, meta AuditMeta) (*DisputeDTO, error) { diff --git a/backend/internal/modules/dispute/service.go b/backend/internal/modules/dispute/service.go index 3718737..61621f4 100644 --- a/backend/internal/modules/dispute/service.go +++ b/backend/internal/modules/dispute/service.go @@ -28,11 +28,11 @@ func (s *Service) Create(userID uint64, orderID uint64, req CreateRequest) (*Dis return s.repo.Create(userID, orderID, req) } -func (s *Service) ListForUser(userID uint64) ([]DisputeDTO, error) { +func (s *Service) ListForUser(userID uint64, page, pageSize int) (*PaginatedResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - return s.repo.ListForUser(userID) + return s.repo.ListForUser(userID, page, pageSize) } func (s *Service) FindForUser(userID uint64, id uint64) (*DisputeDTO, error) { @@ -42,11 +42,11 @@ func (s *Service) FindForUser(userID uint64, id uint64) (*DisputeDTO, error) { return s.repo.FindForUser(userID, id) } -func (s *Service) ListAdmin() ([]DisputeDTO, error) { +func (s *Service) ListAdmin(page, pageSize int) (*PaginatedResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - return s.repo.ListAdmin() + return s.repo.ListAdmin(page, pageSize) } func (s *Service) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest, meta AuditMeta) (*DisputeDTO, error) { diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index 8b1b918..fbdc098 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -56,12 +56,19 @@ type AdminListQuery struct { OwnerID uint64 Status string ReviewStatus string - Limit int + Page int + PageSize int } type AdminActionRequest struct { Reason string `json:"reason" binding:"required"` } +type PaginatedResult struct { + Items []ListingDTO `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} type AuditMeta struct { IP string diff --git a/backend/internal/modules/listing/handler.go b/backend/internal/modules/listing/handler.go index 528fbc3..3968075 100644 --- a/backend/internal/modules/listing/handler.go +++ b/backend/internal/modules/listing/handler.go @@ -82,12 +82,13 @@ func (h *Handler) SubmitReview(c *gin.Context) { } func (h *Handler) ListPendingReview(c *gin.Context) { - items, err := h.service.ListPendingReview() + page, pageSize := parsePagination(c) + result, err := h.service.ListPendingReview(page, pageSize) if err != nil { writeListingError(c, err) return } - response.OK(c, gin.H{"items": items}) + response.OK(c, result) } func (h *Handler) ListAdmin(c *gin.Context) { @@ -95,12 +96,12 @@ func (h *Handler) ListAdmin(c *gin.Context) { if !ok { return } - items, err := h.service.ListAdmin(query) + result, err := h.service.ListAdmin(query) if err != nil { writeListingError(c, err) return } - response.OK(c, gin.H{"items": items}) + response.OK(c, result) } func (h *Handler) FindAdmin(c *gin.Context) { @@ -196,12 +197,13 @@ func (h *Handler) Offline(c *gin.Context) { } func (h *Handler) ListPublic(c *gin.Context) { - items, err := h.service.ListPublic() + page, pageSize := parsePagination(c) + result, err := h.service.ListPublic(page, pageSize) if err != nil { writeListingError(c, err) return } - response.OK(c, gin.H{"items": items}) + response.OK(c, result) } func (h *Handler) FindPublic(c *gin.Context) { @@ -276,12 +278,13 @@ func (h *Handler) ListMine(c *gin.Context) { response.Unauthorized(c, "缺少用户上下文") return } - items, err := h.service.ListMine(ownerID) + page, pageSize := parsePagination(c) + result, err := h.service.ListMine(ownerID, page, pageSize) if err != nil { writeListingError(c, err) return } - response.OK(c, gin.H{"items": items}) + response.OK(c, result) } func (h *Handler) FindMine(c *gin.Context) { @@ -339,19 +342,27 @@ func parseAdminListQuery(c *gin.Context) (AdminListQuery, bool) { } query.OwnerID = value } - if raw := c.Query("limit"); raw != "" { - value, err := strconv.Atoi(raw) - if err != nil || value <= 0 { - response.BadRequest(c, "查询条数不正确") - return query, false - } - query.Limit = value - } query.Status = c.Query("status") query.ReviewStatus = c.Query("review_status") + query.Page, query.PageSize = parsePagination(c) return query, true } +func parsePagination(c *gin.Context) (int, int) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} + func writeListingError(c *gin.Context, err error) { switch { case errors.Is(err, ErrDependencyUnavailable): diff --git a/backend/internal/modules/listing/repository.go b/backend/internal/modules/listing/repository.go index 1592a13..8256f4d 100644 --- a/backend/internal/modules/listing/repository.go +++ b/backend/internal/modules/listing/repository.go @@ -158,26 +158,41 @@ func (r *Repository) SubmitReview(ownerID uint64, listingID uint64, reviewRequir return dto, err } -func (r *Repository) ListPendingReview() ([]ListingDTO, error) { +func (r *Repository) ListPendingReview(page, pageSize int) (*PaginatedResult, error) { + conditions := r.db.Model(&model.RentalListing{}).Where("review_status = ?", "pending") + var total int64 + if err := conditions.Count(&total).Error; err != nil { + return nil, err + } + offset := (page - 1) * pageSize var rows []listingRow - err := r.baseQuery(). + err := r.listQuery(). Where("l.review_status = ?", "pending"). Order("l.updated_at ASC, l.id ASC"). - Limit(200). + Offset(offset).Limit(pageSize). Scan(&rows).Error if err != nil { return nil, err } - return rowsToDTO(rows), nil + return &PaginatedResult{Items: rowsToDTO(rows), Total: total, Page: page, PageSize: pageSize}, nil } -func (r *Repository) ListAdmin(query AdminListQuery) ([]ListingDTO, error) { - limit := query.Limit - if limit <= 0 || limit > 500 { - limit = 200 +func (r *Repository) ListAdmin(query AdminListQuery) (*PaginatedResult, error) { + conditions := r.db.Model(&model.RentalListing{}) + if query.OwnerID > 0 { + conditions = conditions.Where("owner_id = ?", query.OwnerID) } - - db := r.baseQuery() + if query.Status != "" { + conditions = conditions.Where("status = ?", query.Status) + } + if query.ReviewStatus != "" { + conditions = conditions.Where("review_status = ?", query.ReviewStatus) + } + var total int64 + if err := conditions.Count(&total).Error; err != nil { + return nil, err + } + db := r.listQuery() if query.OwnerID > 0 { db = db.Where("l.owner_id = ?", query.OwnerID) } @@ -187,13 +202,13 @@ func (r *Repository) ListAdmin(query AdminListQuery) ([]ListingDTO, error) { if query.ReviewStatus != "" { db = db.Where("l.review_status = ?", query.ReviewStatus) } - + offset := (query.Page - 1) * query.PageSize var rows []listingRow - err := db.Order("l.id DESC").Limit(limit).Scan(&rows).Error + err := db.Order("l.id DESC").Offset(offset).Limit(query.PageSize).Scan(&rows).Error if err != nil { return nil, err } - return rowsToDTO(rows), nil + return &PaginatedResult{Items: rowsToDTO(rows), Total: total, Page: query.Page, PageSize: query.PageSize}, nil } func (r *Repository) FindAdmin(listingID uint64) (*ListingDTO, error) { @@ -358,29 +373,43 @@ func (r *Repository) Offline(ownerID uint64, listingID uint64) error { }) } -func (r *Repository) ListPublic() ([]ListingDTO, error) { +func (r *Repository) ListPublic(page, pageSize int) (*PaginatedResult, error) { + conditions := r.db.Model(&model.RentalListing{}).Where("status = ? AND review_status = ?", "published", "approved") + var total int64 + if err := conditions.Count(&total).Error; err != nil { + return nil, err + } + offset := (page - 1) * pageSize var rows []listingRow - err := r.baseQuery(). + err := r.listQuery(). Where("l.status = ? AND l.review_status = ?", "published", "approved"). Order("l.published_at DESC, l.id DESC"). - Limit(100). + Offset(offset).Limit(pageSize). Scan(&rows).Error if err != nil { return nil, err } - return publicListings(rowsToDTO(rows)), nil + items := publicListings(rowsToDTO(rows)) + return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } -func (r *Repository) ListMine(ownerID uint64) ([]ListingDTO, error) { +func (r *Repository) ListMine(ownerID uint64, page, pageSize int) (*PaginatedResult, error) { + conditions := r.db.Model(&model.RentalListing{}).Where("owner_id = ?", ownerID) + var total int64 + if err := conditions.Count(&total).Error; err != nil { + return nil, err + } + offset := (page - 1) * pageSize var rows []listingRow - err := r.baseQuery(). + err := r.listQuery(). Where("l.owner_id = ?", ownerID). Order("l.id DESC"). + Offset(offset).Limit(pageSize). Scan(&rows).Error if err != nil { return nil, err } - return rowsToDTO(rows), nil + return &PaginatedResult{Items: rowsToDTO(rows), Total: total, Page: page, PageSize: pageSize}, nil } func (r *Repository) FindPublic(id uint64) (*ListingDTO, error) { @@ -449,6 +478,17 @@ func (r *Repository) baseQuery() *gorm.DB { Joins("LEFT JOIN users AS u ON u.id = l.owner_id") } +// listQuery returns a SELECT-optimized query for list endpoints (excludes heavy fields like description, screenshot_urls, asset_summary). +func (r *Repository) listQuery() *gorm.DB { + return r.db.Table("rental_listings AS l"). + Select(`l.id, l.account_id, l.owner_id, l.price_hourly, l.price_daily, l.price_weekly, l.deposit_amount, + l.status, l.review_status, l.review_reason, l.published_at, l.created_at, l.updated_at, + a.title, a.game_name, a.server_region, a.login_platform, a.rank_level, a.haf_coin_amount, + COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname`). + Joins("JOIN game_accounts AS a ON a.id = l.account_id"). + Joins("LEFT JOIN users AS u ON u.id = l.owner_id") +} + func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model.RentalListing, *model.GameAccount, error) { var listing model.RentalListing if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, listingID).Error; err != nil { diff --git a/backend/internal/modules/listing/service.go b/backend/internal/modules/listing/service.go index 6091080..e48471d 100644 --- a/backend/internal/modules/listing/service.go +++ b/backend/internal/modules/listing/service.go @@ -93,14 +93,14 @@ func (s *Service) SubmitReview(ownerID uint64, id uint64) (*ListingDTO, error) { return s.repo.SubmitReview(ownerID, id, reviewRequired) } -func (s *Service) ListPendingReview() ([]ListingDTO, error) { +func (s *Service) ListPendingReview(page, pageSize int) (*PaginatedResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - return s.repo.ListPendingReview() + return s.repo.ListPendingReview(page, pageSize) } -func (s *Service) ListAdmin(query AdminListQuery) ([]ListingDTO, error) { +func (s *Service) ListAdmin(query AdminListQuery) (*PaginatedResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } @@ -158,18 +158,18 @@ func (s *Service) Offline(ownerID uint64, id uint64) error { return s.repo.Offline(ownerID, id) } -func (s *Service) ListPublic() ([]ListingDTO, error) { +func (s *Service) ListPublic(page, pageSize int) (*PaginatedResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - return s.repo.ListPublic() + return s.repo.ListPublic(page, pageSize) } -func (s *Service) ListMine(ownerID uint64) ([]ListingDTO, error) { +func (s *Service) ListMine(ownerID uint64, page, pageSize int) (*PaginatedResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - return s.repo.ListMine(ownerID) + return s.repo.ListMine(ownerID, page, pageSize) } func (s *Service) FindPublic(id uint64) (*ListingDTO, error) { diff --git a/backend/internal/modules/notification/dto.go b/backend/internal/modules/notification/dto.go index 782e335..620f36b 100644 --- a/backend/internal/modules/notification/dto.go +++ b/backend/internal/modules/notification/dto.go @@ -13,3 +13,10 @@ type NotificationDTO struct { ReadAt *time.Time `json:"read_at"` CreatedAt time.Time `json:"created_at"` } + +type PaginatedResult struct { + Items interface{} `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} diff --git a/backend/internal/modules/notification/handler.go b/backend/internal/modules/notification/handler.go index 8928321..348c406 100644 --- a/backend/internal/modules/notification/handler.go +++ b/backend/internal/modules/notification/handler.go @@ -18,18 +18,34 @@ func NewHandler(service *Service) *Handler { return &Handler{service: service} } +func parsePagination(c *gin.Context) (int, int) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} + func (h *Handler) List(c *gin.Context) { userID, ok := currentUserID(c) if !ok { response.Unauthorized(c, "缺少用户上下文") return } - items, err := h.service.List(userID) + page, pageSize := parsePagination(c) + result, err := h.service.List(userID, page, pageSize) if err != nil { writeNotificationError(c, err) return } - response.OK(c, gin.H{"items": items}) + response.OK(c, result) } func (h *Handler) MarkRead(c *gin.Context) { diff --git a/backend/internal/modules/notification/repository.go b/backend/internal/modules/notification/repository.go index 9d50731..c415edc 100644 --- a/backend/internal/modules/notification/repository.go +++ b/backend/internal/modules/notification/repository.go @@ -25,16 +25,21 @@ func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} } -func (r *Repository) List(userID uint64) ([]NotificationDTO, error) { +func (r *Repository) List(userID uint64, page, pageSize int) (*PaginatedResult, error) { + var total int64 + if err := r.db.Model(&model.Notification{}).Where("user_id = ?", userID).Count(&total).Error; err != nil { + return nil, err + } + offset := (page - 1) * pageSize var rows []model.Notification - if err := r.db.Where("user_id = ?", userID).Order("id DESC").Limit(100).Find(&rows).Error; err != nil { + if err := r.db.Where("user_id = ?", userID).Order("id DESC").Offset(offset).Limit(pageSize).Find(&rows).Error; err != nil { return nil, err } items := make([]NotificationDTO, 0, len(rows)) for _, row := range rows { items = append(items, toDTO(row)) } - return items, nil + return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } func (r *Repository) MarkRead(userID uint64, id uint64) error { diff --git a/backend/internal/modules/notification/service.go b/backend/internal/modules/notification/service.go index c2be3f6..477a34e 100644 --- a/backend/internal/modules/notification/service.go +++ b/backend/internal/modules/notification/service.go @@ -12,11 +12,11 @@ func NewService(repo *Repository) *Service { return &Service{repo: repo} } -func (s *Service) List(userID uint64) ([]NotificationDTO, error) { +func (s *Service) List(userID uint64, page, pageSize int) (*PaginatedResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - return s.repo.List(userID) + return s.repo.List(userID, page, pageSize) } func (s *Service) MarkRead(userID uint64, id uint64) error { diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 71738ba..3bb0af6 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -78,6 +78,29 @@ type HandoffRecordDTO struct { CreatedAt time.Time `json:"created_at"` } +type OrderListItemDTO struct { + ID uint64 `json:"id"` + OrderNo string `json:"order_no"` + ListingTitle string `json:"listing_title"` + GameName string `json:"game_name"` + AccountTitle string `json:"account_title"` + RenterNickname string `json:"renter_nickname"` + OwnerNickname string `json:"owner_nickname"` + Status string `json:"status"` + HandoffStatus string `json:"handoff_status"` + SettlementStatus string `json:"settlement_status"` + RentAmount float64 `json:"rent_amount"` + DepositAmount float64 `json:"deposit_amount"` + CreatedAt time.Time `json:"created_at"` +} + +type PaginatedOrders struct { + Items []OrderListItemDTO `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + type CheckoutDTO struct { ID uint64 `json:"id"` OrderID uint64 `json:"order_id"` diff --git a/backend/internal/modules/order/handler.go b/backend/internal/modules/order/handler.go index 70671c7..7db46ce 100644 --- a/backend/internal/modules/order/handler.go +++ b/backend/internal/modules/order/handler.go @@ -44,21 +44,43 @@ func (h *Handler) List(c *gin.Context) { response.Unauthorized(c, "缺少用户上下文") return } - items, err := h.service.ListForUser(userID) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + result, err := h.service.ListForUser(userID, page, pageSize) if err != nil { writeOrderError(c, err) return } - response.OK(c, gin.H{"items": items}) + response.OK(c, gin.H{"items": result.Items, "total": result.Total, "page": result.Page, "page_size": result.PageSize}) } func (h *Handler) AdminList(c *gin.Context) { - items, err := h.service.ListAdmin() + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + result, err := h.service.ListAdmin(page, pageSize) if err != nil { writeOrderError(c, err) return } - response.OK(c, gin.H{"items": items}) + response.OK(c, gin.H{"items": result.Items, "total": result.Total, "page": result.Page, "page_size": result.PageSize}) } func (h *Handler) AdminDetail(c *gin.Context) { diff --git a/backend/internal/modules/order/repository.go b/backend/internal/modules/order/repository.go index 5d94d1d..dc75730 100644 --- a/backend/internal/modules/order/repository.go +++ b/backend/internal/modules/order/repository.go @@ -488,36 +488,45 @@ func (r *Repository) AcceptCheckout(userID uint64, orderID uint64) error { }) } -func (r *Repository) ListForUser(userID uint64) ([]OrderDTO, error) { - var rows []orderRow - err := r.baseQuery(). - Where("o.renter_id = ?", userID). +func (r *Repository) ListForUser(userID uint64, page int, pageSize int) (*PaginatedOrders, error) { + var total int64 + r.db.Model(&model.RentalOrder{}).Where("renter_id = ? OR owner_id = ?", userID, userID).Count(&total) + + offset := (page - 1) * pageSize + var rows []orderListRow + err := r.listItemBaseQuery(). + Where("o.renter_id = ? OR o.owner_id = ?", userID, userID). Order("o.id DESC"). + Offset(offset).Limit(pageSize). Scan(&rows).Error if err != nil { return nil, err } - items := make([]OrderDTO, 0, len(rows)) + items := make([]OrderListItemDTO, 0, len(rows)) for _, row := range rows { - items = append(items, row.toDTO()) + items = append(items, row.toListItemDTO()) } - return items, nil + return &PaginatedOrders{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } -func (r *Repository) ListAdmin() ([]OrderDTO, error) { - var rows []orderRow - err := r.adminQuery(). +func (r *Repository) ListAdmin(page int, pageSize int) (*PaginatedOrders, error) { + var total int64 + r.db.Model(&model.RentalOrder{}).Count(&total) + + offset := (page - 1) * pageSize + var rows []orderListRow + err := r.listItemBaseQuery(). Order("o.id DESC"). - Limit(200). + Offset(offset).Limit(pageSize). Scan(&rows).Error if err != nil { return nil, err } - items := make([]OrderDTO, 0, len(rows)) + items := make([]OrderListItemDTO, 0, len(rows)) for _, row := range rows { - items = append(items, row.toDTO()) + items = append(items, row.toListItemDTO()) } - return items, nil + return &PaginatedOrders{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } func (r *Repository) FindAdmin(orderID uint64) (*OrderDTO, error) { @@ -898,6 +907,16 @@ func (r *Repository) findHandoffRecord(id uint64) (*HandoffRecordDTO, error) { return &dto, nil } +func (r *Repository) listItemBaseQuery() *gorm.DB { + return r.db.Table("rental_orders AS o"). + Select("o.id, o.order_no, o.status, o.handoff_status, o.settlement_status, o.rent_amount, o.deposit_amount, o.created_at, "+ + "a.title AS account_title, a.game_name, "+ + "owner.nickname AS owner_nickname, renter.nickname AS renter_nickname"). + Joins("JOIN game_accounts AS a ON a.id = o.account_id"). + Joins("JOIN users AS owner ON owner.id = o.owner_id"). + Joins("JOIN users AS renter ON renter.id = o.renter_id") +} + func (r *Repository) baseQuery() *gorm.DB { return r.db.Table("rental_orders AS o"). Select("o.*, a.title, a.server_region, a.login_platform"). @@ -948,6 +967,39 @@ func (row orderRow) toDTO() OrderDTO { } } +type orderListRow struct { + ID uint64 + OrderNo string + Status string + HandoffStatus string + SettlementStatus string + RentAmount float64 + DepositAmount float64 + CreatedAt time.Time + AccountTitle string + GameName string + OwnerNickname string + RenterNickname string +} + +func (row orderListRow) toListItemDTO() OrderListItemDTO { + return OrderListItemDTO{ + ID: row.ID, + OrderNo: row.OrderNo, + ListingTitle: row.AccountTitle, + GameName: row.GameName, + AccountTitle: row.AccountTitle, + RenterNickname: row.RenterNickname, + OwnerNickname: row.OwnerNickname, + Status: row.Status, + HandoffStatus: row.HandoffStatus, + SettlementStatus: row.SettlementStatus, + RentAmount: row.RentAmount, + DepositAmount: row.DepositAmount, + CreatedAt: row.CreatedAt, + } +} + func toHandoffDTO(record model.HandoffRecord) HandoffRecordDTO { return HandoffRecordDTO{ ID: record.ID, diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go index c142887..38d844e 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -103,18 +103,18 @@ func (s *Service) AcceptCheckout(userID uint64, orderID uint64) error { return s.repo.AcceptCheckout(userID, orderID) } -func (s *Service) ListForUser(userID uint64) ([]OrderDTO, error) { +func (s *Service) ListForUser(userID uint64, page int, pageSize int) (*PaginatedOrders, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - return s.repo.ListForUser(userID) + return s.repo.ListForUser(userID, page, pageSize) } -func (s *Service) ListAdmin() ([]OrderDTO, error) { +func (s *Service) ListAdmin(page int, pageSize int) (*PaginatedOrders, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - return s.repo.ListAdmin() + return s.repo.ListAdmin(page, pageSize) } func (s *Service) FindAdmin(orderID uint64) (*OrderDTO, error) { diff --git a/backend/internal/modules/wallet/dto.go b/backend/internal/modules/wallet/dto.go index 81fcc4d..8ca9cd0 100644 --- a/backend/internal/modules/wallet/dto.go +++ b/backend/internal/modules/wallet/dto.go @@ -25,10 +25,18 @@ type LedgerDTO struct { } type AdminLedgerQuery struct { - UserID uint64 - OrderID uint64 - BizType string - Limit int + UserID uint64 + OrderID uint64 + BizType string + Page int + PageSize int +} + +type PaginatedResult struct { + Items interface{} `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` } type AdminLedgerDTO struct { diff --git a/backend/internal/modules/wallet/handler.go b/backend/internal/modules/wallet/handler.go index d6499ac..445e70c 100644 --- a/backend/internal/modules/wallet/handler.go +++ b/backend/internal/modules/wallet/handler.go @@ -32,18 +32,34 @@ func (h *Handler) Balance(c *gin.Context) { response.OK(c, account) } +func parsePagination(c *gin.Context) (int, int) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} + func (h *Handler) Ledger(c *gin.Context) { userID, ok := currentUserID(c) if !ok { response.Unauthorized(c, "缺少用户上下文") return } - items, err := h.service.Ledger(userID) + page, pageSize := parsePagination(c) + result, err := h.service.Ledger(userID, page, pageSize) if err != nil { writeWalletError(c, err) return } - response.OK(c, gin.H{"items": items}) + response.OK(c, result) } func (h *Handler) AdminLedger(c *gin.Context) { @@ -51,12 +67,12 @@ func (h *Handler) AdminLedger(c *gin.Context) { if !ok { return } - items, err := h.service.AdminLedger(query) + result, err := h.service.AdminLedger(query) if err != nil { writeWalletError(c, err) return } - response.OK(c, gin.H{"items": items}) + response.OK(c, result) } func parseAdminLedgerQuery(c *gin.Context) (AdminLedgerQuery, bool) { @@ -77,15 +93,10 @@ func parseAdminLedgerQuery(c *gin.Context) (AdminLedgerQuery, bool) { } query.OrderID = value } - if raw := c.Query("limit"); raw != "" { - value, err := strconv.Atoi(raw) - if err != nil || value <= 0 { - response.BadRequest(c, "查询条数不正确") - return query, false - } - query.Limit = value - } query.BizType = c.Query("biz_type") + page, pageSize := parsePagination(c) + query.Page = page + query.PageSize = pageSize return query, true } diff --git a/backend/internal/modules/wallet/repository.go b/backend/internal/modules/wallet/repository.go index 55cb182..077b8cd 100644 --- a/backend/internal/modules/wallet/repository.go +++ b/backend/internal/modules/wallet/repository.go @@ -45,24 +45,24 @@ func (r *Repository) Account(userID uint64) (*AccountDTO, error) { return toAccountDTO(account), nil } -func (r *Repository) Ledger(userID uint64) ([]LedgerDTO, error) { +func (r *Repository) Ledger(userID uint64, page, pageSize int) (*PaginatedResult, error) { + var total int64 + if err := r.db.Model(&model.WalletLedger{}).Where("user_id = ?", userID).Count(&total).Error; err != nil { + return nil, err + } + offset := (page - 1) * pageSize var rows []model.WalletLedger - if err := r.db.Where("user_id = ?", userID).Order("id DESC").Limit(100).Find(&rows).Error; err != nil { + if err := r.db.Where("user_id = ?", userID).Order("id DESC").Offset(offset).Limit(pageSize).Find(&rows).Error; err != nil { return nil, err } items := make([]LedgerDTO, 0, len(rows)) for _, row := range rows { items = append(items, toLedgerDTO(row)) } - return items, nil + return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } -func (r *Repository) AdminLedger(query AdminLedgerQuery) ([]AdminLedgerDTO, error) { - limit := query.Limit - if limit <= 0 || limit > 500 { - limit = 200 - } - +func (r *Repository) AdminLedger(query AdminLedgerQuery) (*PaginatedResult, error) { db := r.db.Table("wallet_ledger AS wl"). Select(`wl.id, wl.ledger_no, wl.user_id, COALESCE(u.phone, '') AS user_phone, COALESCE(u.nickname, '') AS user_nickname, wl.order_id, COALESCE(ro.order_no, '') AS order_no, @@ -71,21 +71,31 @@ func (r *Repository) AdminLedger(query AdminLedgerQuery) ([]AdminLedgerDTO, erro Joins("LEFT JOIN users AS u ON u.id = wl.user_id"). Joins("LEFT JOIN rental_orders AS ro ON ro.id = wl.order_id") + countDB := r.db.Model(&model.WalletLedger{}) if query.UserID > 0 { db = db.Where("wl.user_id = ?", query.UserID) + countDB = countDB.Where("user_id = ?", query.UserID) } if query.OrderID > 0 { db = db.Where("wl.order_id = ?", query.OrderID) + countDB = countDB.Where("order_id = ?", query.OrderID) } if query.BizType != "" { db = db.Where("wl.biz_type = ?", query.BizType) + countDB = countDB.Where("biz_type = ?", query.BizType) } - var items []AdminLedgerDTO - if err := db.Order("wl.id DESC").Limit(limit).Scan(&items).Error; err != nil { + var total int64 + if err := countDB.Count(&total).Error; err != nil { return nil, err } - return items, nil + + offset := (query.Page - 1) * query.PageSize + var items []AdminLedgerDTO + if err := db.Order("wl.id DESC").Offset(offset).Limit(query.PageSize).Scan(&items).Error; err != nil { + return nil, err + } + return &PaginatedResult{Items: items, Total: total, Page: query.Page, PageSize: query.PageSize}, nil } func AppendEntries(tx *gorm.DB, entries ...Entry) error { diff --git a/backend/internal/modules/wallet/service.go b/backend/internal/modules/wallet/service.go index 89445c8..ee1cf19 100644 --- a/backend/internal/modules/wallet/service.go +++ b/backend/internal/modules/wallet/service.go @@ -19,14 +19,14 @@ func (s *Service) Account(userID uint64) (*AccountDTO, error) { return s.repo.Account(userID) } -func (s *Service) Ledger(userID uint64) ([]LedgerDTO, error) { +func (s *Service) Ledger(userID uint64, page, pageSize int) (*PaginatedResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - return s.repo.Ledger(userID) + return s.repo.Ledger(userID, page, pageSize) } -func (s *Service) AdminLedger(query AdminLedgerQuery) ([]AdminLedgerDTO, error) { +func (s *Service) AdminLedger(query AdminLedgerQuery) (*PaginatedResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index cab98ff..6e45f89 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -46,7 +46,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { if deps.DB != nil { adminAuthRepo = adminauth.NewRepository(deps.DB, deps.Redis, jwtManager) } - adminAuthService := adminauth.NewService(adminAuthRepo) + adminAuthService := adminauth.NewService(adminAuthRepo, jwtManager) adminAuthHandler := adminauth.NewHandler(adminAuthService) var adminDashboardRepo *admindashboard.Repository if deps.DB != nil { @@ -211,6 +211,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { { adminAuthRoutes.GET("/captcha", adminAuthHandler.Captcha) adminAuthRoutes.POST("/login", adminAuthHandler.Login) + adminAuthRoutes.POST("/refresh", adminAuthHandler.Refresh) } adminRoutes := api.Group("/admin", requireAdmin) diff --git a/backend/migrations/000003_add_indexes.sql b/backend/migrations/000003_add_indexes.sql new file mode 100644 index 0000000..307515e --- /dev/null +++ b/backend/migrations/000003_add_indexes.sql @@ -0,0 +1,35 @@ +-- 000003_add_indexes.sql +-- 添加缺失的业务索引以优化查询性能 + +-- rental_orders 索引(号主/租客查订单、状态筛选、超时扫描) +CREATE INDEX idx_rental_orders_owner_id ON rental_orders(owner_id); +CREATE INDEX idx_rental_orders_renter_id ON rental_orders(renter_id); +CREATE INDEX idx_rental_orders_status ON rental_orders(status); +CREATE INDEX idx_rental_orders_status_updated ON rental_orders(status, updated_at); +CREATE INDEX idx_rental_orders_settlement_status ON rental_orders(settlement_status); + +-- handoff_records 索引 +CREATE INDEX idx_handoff_records_order_id ON handoff_records(order_id); + +-- notifications 索引 +CREATE INDEX idx_notifications_user_id ON notifications(user_id); + +-- wallet_entries 索引 +CREATE INDEX idx_wallet_entries_user_id ON wallet_entries(user_id); +CREATE INDEX idx_wallet_entries_order_id ON wallet_entries(order_id); + +-- disputes 索引 +CREATE INDEX idx_disputes_order_id ON disputes(order_id); +CREATE INDEX idx_disputes_status ON disputes(status); + +-- audit_logs 索引 +CREATE INDEX idx_audit_logs_biz_type_biz_id ON audit_logs(biz_type, biz_id); +CREATE INDEX idx_audit_logs_actor_id ON audit_logs(actor_id); + +-- rental_listings 补充索引 +CREATE INDEX idx_rental_listings_owner_id ON rental_listings(owner_id); +CREATE INDEX idx_rental_listings_status ON rental_listings(status); +CREATE INDEX idx_rental_listings_game_name ON rental_listings(game_name); + +-- order_checkouts 索引补充 +CREATE INDEX idx_order_checkouts_order_id_status ON order_checkouts(order_id, status); diff --git a/frontend/src/api/adminAudit.ts b/frontend/src/api/adminAudit.ts index afd0551..d7d4897 100644 --- a/frontend/src/api/adminAudit.ts +++ b/frontend/src/api/adminAudit.ts @@ -1,5 +1,7 @@ import { apiClient } from './client' +import { type PaginatedResult } from './orders' + export interface AdminAuditLog { id: number actor_type: string @@ -19,7 +21,8 @@ export interface AdminAuditQuery { actor_id?: string action?: string biz_type?: string - limit?: number + page?: number + page_size?: number } interface ApiResponse { @@ -30,6 +33,6 @@ interface ApiResponse { export async function fetchAdminAuditLogs(query: AdminAuditQuery = {}) { const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)) - const { data } = await apiClient.get>('/admin/audit-logs', { params }) - return data.data.items + const { data } = await apiClient.get>>('/admin/audit-logs', { params }) + return data.data } diff --git a/frontend/src/api/adminAuth.ts b/frontend/src/api/adminAuth.ts index 133b491..9c8bc9d 100644 --- a/frontend/src/api/adminAuth.ts +++ b/frontend/src/api/adminAuth.ts @@ -56,3 +56,12 @@ export async function logoutAdmin() { const { data } = await apiClient.post>('/admin/auth/logout') return data.data } + +/** Manually refresh admin token (uses raw axios to avoid interceptor recursion) */ +export async function refreshAdminSession() { + const refreshToken = localStorage.getItem('admin_refresh_token') + if (!refreshToken) throw new Error('no refresh token') + const axios = (await import('axios')).default + const { data } = await axios.post('/api/admin/auth/refresh', { refresh_token: refreshToken }) + return data.data as AdminTokenPair +} \ No newline at end of file diff --git a/frontend/src/api/adminUsers.ts b/frontend/src/api/adminUsers.ts index 18bec6d..3975837 100644 --- a/frontend/src/api/adminUsers.ts +++ b/frontend/src/api/adminUsers.ts @@ -1,5 +1,7 @@ import { apiClient } from './client' +import { type PaginatedResult } from './orders' + export interface AdminUserItem { id: number phone: string @@ -22,9 +24,11 @@ interface ApiResponse { data: T } -export async function fetchAdminUsers() { - const { data } = await apiClient.get>('/admin/users') - return data.data.items +export async function fetchAdminUsers(page = 1, pageSize = 20) { + const { data } = await apiClient.get>>('/admin/users', { + params: { page, page_size: pageSize }, + }) + return data.data } export async function freezeAdminUser(id: number, reason: string) { diff --git a/frontend/src/api/adminWallet.ts b/frontend/src/api/adminWallet.ts index 01635e2..07be54f 100644 --- a/frontend/src/api/adminWallet.ts +++ b/frontend/src/api/adminWallet.ts @@ -1,5 +1,7 @@ import { apiClient } from './client' +import { type PaginatedResult } from './orders' + export interface AdminWalletLedger { id: number ledger_no: string @@ -22,7 +24,8 @@ export interface AdminWalletLedgerQuery { user_id?: string order_id?: string biz_type?: string - limit?: number + page?: number + page_size?: number } interface ApiResponse { @@ -33,6 +36,6 @@ interface ApiResponse { export async function fetchAdminWalletLedger(query: AdminWalletLedgerQuery = {}) { const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)) - const { data } = await apiClient.get>('/admin/wallet/ledger', { params }) - return data.data.items + const { data } = await apiClient.get>>('/admin/wallet/ledger', { params }) + return data.data } diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index e7e24a9..2227d3f 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -50,3 +50,11 @@ export async function updateMe(payload: Pick>('/me', payload) return data.data } + +/** Manually refresh user token (for store to call on app init) */ +export async function refreshUserToken(refreshToken: string) { + const { data } = await apiClient.post>('/auth/refresh', { + refresh_token: refreshToken, + }) + return data.data +} \ No newline at end of file diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a03edad..d05630f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,10 +1,53 @@ -import axios from 'axios' +import axios, { type InternalAxiosRequestConfig } from 'axios' export const apiClient = axios.create({ baseURL: '/api', timeout: 10000, }) +// ---- refresh retry lock ---- +let isRefreshing = false +let pendingRequests: Array<(token: string) => void> = [] + +function subscribePendingRequests(token: string) { + pendingRequests.forEach((cb) => cb(token)) + pendingRequests.length = 0 +} + +function addPendingRequest(callback: (token: string) => void) { + pendingRequests.push(callback) +} + +async function refreshTokenAndRetry(isAdmin = false): Promise { + const refreshTokenKey = isAdmin ? 'admin_refresh_token' : 'refresh_token' + const refreshToken = localStorage.getItem(refreshTokenKey) + if (!refreshToken) { + throw new Error('no refresh token') + } + const endpoint = isAdmin ? '/admin/auth/refresh' : '/auth/refresh' + // use raw axios (not apiClient) to avoid interceptor recursion + const { data } = await axios.post(endpoint, { refresh_token: refreshToken }) + const newAccessToken = data.data.access_token + const newRefreshToken = data.data.refresh_token + const accessKey = isAdmin ? 'admin_access_token' : 'access_token' + localStorage.setItem(accessKey, newAccessToken) + localStorage.setItem(refreshTokenKey, newRefreshToken) + return newAccessToken +} + +// clear user tokens +function clearUserTokens() { + ;['access_token', 'refresh_token', 'user_id', 'phone', 'nickname', 'avatar_url', 'realname_status'] + .forEach((k) => localStorage.removeItem(k)) +} + +// clear admin tokens +function clearAdminTokens() { + ;['admin_access_token', 'admin_refresh_token', 'admin_id', 'admin_username'] + .forEach((k) => localStorage.removeItem(k)) +} + +// ---- Request interceptor ---- apiClient.interceptors.request.use((config) => { const url = config.url || '' const tokenKey = url.startsWith('/admin') ? 'admin_access_token' : 'access_token' @@ -15,40 +58,74 @@ apiClient.interceptors.request.use((config) => { return config }) +// ---- Response interceptor ---- apiClient.interceptors.response.use( (response) => response, - (error) => { - if (error?.response?.status !== 401) { + async (error) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean } + + if (error?.response?.status !== 401 || originalRequest._retry) { return Promise.reject(error) } - const requestUrl = error.config?.url || '' + const requestUrl = originalRequest.url || '' const isAdminRequest = requestUrl.startsWith('/admin') - const currentPath = window.location.pathname + window.location.search - if (isAdminRequest) { - localStorage.removeItem('admin_access_token') - localStorage.removeItem('admin_refresh_token') - if (!window.location.pathname.startsWith('/admin/login')) { + // exclude refresh endpoints themselves to avoid dead loop + if (requestUrl.endsWith('/auth/refresh') || requestUrl.endsWith('/admin/auth/refresh')) { + if (isAdminRequest) clearAdminTokens() + else clearUserTokens() + if (isAdminRequest && !window.location.pathname.startsWith('/admin/login')) { window.location.assign('/admin/login') + } else if (!isAdminRequest) { + const currentPath = window.location.pathname + window.location.search + if (!currentPath.startsWith('/m/login') && !currentPath.startsWith('/login')) { + const redirect = encodeURIComponent(currentPath) + const loginPath = currentPath.startsWith('/m') ? '/m/login' : '/login' + window.location.assign(`${loginPath}?redirect=${redirect}`) + } } return Promise.reject(error) } - localStorage.removeItem('access_token') - localStorage.removeItem('refresh_token') - localStorage.removeItem('user_id') - localStorage.removeItem('phone') - localStorage.removeItem('nickname') - localStorage.removeItem('avatar_url') - localStorage.removeItem('realname_status') - - if (!window.location.pathname.startsWith('/m/login') && !window.location.pathname.startsWith('/m/register')) { - const redirect = encodeURIComponent(currentPath) - const loginPath = window.location.pathname.startsWith('/m') ? '/m/login' : '/login' - window.location.assign(`${loginPath}?redirect=${redirect}`) + // if already refreshing, queue up + if (isRefreshing) { + return new Promise((resolve) => { + addPendingRequest((newToken: string) => { + originalRequest.headers.Authorization = `Bearer ${newToken}` + resolve(apiClient(originalRequest)) + }) + }) } - return Promise.reject(error) - }, -) + // attempt refresh + isRefreshing = true + try { + const newToken = await refreshTokenAndRetry(isAdminRequest) + subscribePendingRequests(newToken) + originalRequest._retry = true + originalRequest.headers.Authorization = `Bearer ${newToken}` + return apiClient(originalRequest) + } catch { + // refresh failed, clear tokens and redirect to login + subscribePendingRequests('') // let queued requests fail + if (isAdminRequest) { + clearAdminTokens() + if (!window.location.pathname.startsWith('/admin/login')) { + window.location.assign('/admin/login') + } + } else { + clearUserTokens() + const currentPath = window.location.pathname + window.location.search + if (!currentPath.startsWith('/m/login') && !currentPath.startsWith('/login')) { + const redirect = encodeURIComponent(currentPath) + const loginPath = currentPath.startsWith('/m') ? '/m/login' : '/login' + window.location.assign(`${loginPath}?redirect=${redirect}`) + } + } + return Promise.reject(error) + } finally { + isRefreshing = false + } + } +) \ No newline at end of file diff --git a/frontend/src/api/disputes.ts b/frontend/src/api/disputes.ts index b71df81..8a2b775 100644 --- a/frontend/src/api/disputes.ts +++ b/frontend/src/api/disputes.ts @@ -1,5 +1,7 @@ import { apiClient } from './client' +import { type PaginatedResult } from './orders' + export interface Dispute { id: number order_id: number @@ -30,14 +32,18 @@ export async function createDispute(orderId: number, payload: { type: string; de return data.data } -export async function fetchDisputes() { - const { data } = await apiClient.get>('/disputes') - return data.data.items +export async function fetchDisputes(page = 1, pageSize = 20) { + const { data } = await apiClient.get>>('/disputes', { + params: { page, page_size: pageSize }, + }) + return data.data } -export async function fetchAdminDisputes() { - const { data } = await apiClient.get>('/admin/disputes') - return data.data.items +export async function fetchAdminDisputes(page = 1, pageSize = 20) { + const { data } = await apiClient.get>>('/admin/disputes', { + params: { page, page_size: pageSize }, + }) + return data.data } export async function arbitrateDispute(id: number, payload: { result: string; remark: string; amount?: number }) { diff --git a/frontend/src/api/listings.ts b/frontend/src/api/listings.ts index 19705c0..f9618a0 100644 --- a/frontend/src/api/listings.ts +++ b/frontend/src/api/listings.ts @@ -1,124 +1,169 @@ -import { apiClient } from './client' +import { apiClient } from "./client"; + +import { type PaginatedResult } from "./orders"; export interface Listing { - id: number - account_id: number - owner_id: number - owner_phone?: string - owner_nickname?: string - title: string - description: string - game_name: string - server_region: string - login_platform: string - rank_level: string - haf_coin_amount: number - asset_summary?: Record - screenshot_urls: string[] - cover_url: string - price_hourly: number - price_daily: number - price_weekly: number - deposit_amount: number - is_accelerated_sale?: boolean - status: string - review_status: string - review_reason: string - published_at?: string - created_at: string - updated_at: string + id: number; + account_id: number; + owner_id: number; + owner_phone?: string; + owner_nickname?: string; + title: string; + description: string; + game_name: string; + server_region: string; + login_platform: string; + rank_level: string; + haf_coin_amount: number; + asset_summary?: Record; + screenshot_urls: string[]; + cover_url: string; + price_hourly: number; + price_daily: number; + price_weekly: number; + deposit_amount: number; + is_accelerated_sale?: boolean; + status: string; + review_status: string; + review_reason: string; + published_at?: string; + created_at: string; + updated_at: string; } export interface ListingPayload { - title: string - description: string - server_region: string - login_platform: string - rank_level: string - haf_coin_amount: number - asset_summary?: Record - screenshot_urls: string[] - price_hourly: number - price_daily: number - price_weekly: number - deposit_amount: number + title: string; + description: string; + server_region: string; + login_platform: string; + rank_level: string; + haf_coin_amount: number; + asset_summary?: Record; + screenshot_urls: string[]; + price_hourly: number; + price_daily: number; + price_weekly: number; + deposit_amount: number; } interface ApiResponse { - code: string - message: string - data: T + code: string; + message: string; + data: T; } -export async function fetchListings() { - const { data } = await apiClient.get>('/listings') - return data.data.items +export async function fetchListings(page = 1, pageSize = 20) { + const { data } = await apiClient.get>>( + "/listings", + { + params: { page, page_size: pageSize }, + } + ); + return data.data; } export async function fetchListing(id: string | number) { - const { data } = await apiClient.get>(`/listings/${id}`) - return data.data + const { data } = await apiClient.get>(`/listings/${id}`); + return data.data; } -export async function fetchSellerListings() { - const { data } = await apiClient.get>('/seller/listings') - return data.data.items +export async function fetchSellerListings(page = 1, pageSize = 20) { + const { data } = await apiClient.get>>( + "/seller/listings", + { + params: { page, page_size: pageSize }, + } + ); + return data.data; } export async function createListing(payload: ListingPayload) { - const { data } = await apiClient.post>('/listings', payload) - return data.data + const { data } = await apiClient.post>( + "/listings", + payload + ); + return data.data; } export async function submitListingReview(id: number) { - const { data } = await apiClient.post>(`/listings/${id}/submit-review`) - return data.data + const { data } = await apiClient.post>( + `/listings/${id}/submit-review` + ); + return data.data; } export async function offlineListing(id: number) { - const { data } = await apiClient.delete>(`/listings/${id}`) - return data.data + const { data } = await apiClient.delete>( + `/listings/${id}` + ); + return data.data; } -export async function fetchPendingReviewListings() { - const { data } = await apiClient.get>('/admin/listings/pending') - return data.data.items +export async function fetchPendingReviewListings(page = 1, pageSize = 20) { + const { data } = await apiClient.get>>( + "/admin/listings/pending", + { + params: { page, page_size: pageSize }, + } + ); + return data.data; } export interface AdminListingQuery { - owner_id?: string - status?: string - review_status?: string - limit?: number + owner_id?: string; + status?: string; + review_status?: string; + page?: number; + page_size?: number; } export async function fetchAdminListings(query: AdminListingQuery = {}) { - const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)) - const { data } = await apiClient.get>('/admin/listings', { params }) - return data.data.items + const params = Object.fromEntries( + Object.entries(query).filter( + ([, value]) => value !== "" && value !== undefined + ) + ); + const { data } = await apiClient.get>>( + "/admin/listings", + { params } + ); + return data.data; } export async function fetchAdminListing(id: string | number) { - const { data } = await apiClient.get>(`/admin/listings/${id}`) - return data.data + const { data } = await apiClient.get>( + `/admin/listings/${id}` + ); + return data.data; } export async function adminOfflineListing(id: number, reason: string) { - const { data } = await apiClient.post>(`/admin/listings/${id}/offline`, { reason }) - return data.data + const { data } = await apiClient.post>( + `/admin/listings/${id}/offline`, + { reason } + ); + return data.data; } export async function adminMarkListingAbnormal(id: number, reason: string) { - const { data } = await apiClient.post>(`/admin/listings/${id}/mark-abnormal`, { reason }) - return data.data + const { data } = await apiClient.post>( + `/admin/listings/${id}/mark-abnormal`, + { reason } + ); + return data.data; } export async function approveListing(id: number) { - const { data } = await apiClient.post>(`/admin/listings/${id}/approve`) - return data.data + const { data } = await apiClient.post>( + `/admin/listings/${id}/approve` + ); + return data.data; } export async function rejectListing(id: number, reason: string) { - const { data } = await apiClient.post>(`/admin/listings/${id}/reject`, { reason }) - return data.data + const { data } = await apiClient.post>( + `/admin/listings/${id}/reject`, + { reason } + ); + return data.data; } diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts index d39209f..2a5c53c 100644 --- a/frontend/src/api/notifications.ts +++ b/frontend/src/api/notifications.ts @@ -1,5 +1,7 @@ import { apiClient } from './client' +import { type PaginatedResult } from './orders' + export interface NotificationItem { id: number user_id: number @@ -18,9 +20,11 @@ interface ApiResponse { data: T } -export async function fetchNotifications() { - const { data } = await apiClient.get>('/notifications') - return data.data.items +export async function fetchNotifications(page = 1, pageSize = 20) { + const { data } = await apiClient.get>>('/notifications', { + params: { page, page_size: pageSize }, + }) + return data.data } export async function markNotificationRead(id: number) { diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts index 520745e..11340cc 100644 --- a/frontend/src/api/orders.ts +++ b/frontend/src/api/orders.ts @@ -1,167 +1,233 @@ -import { apiClient } from './client' +import { apiClient } from "./client"; + +export interface OrderListItem { + id: number; + order_no: string; + listing_title: string; + game_name: string; + account_title: string; + renter_nickname: string; + owner_nickname: string; + status: string; + handoff_status: string; + settlement_status: string; + rent_amount: number; + deposit_amount: number; + created_at: string; +} export interface Order { - id: number - order_no: string - listing_id: number - account_id: number - owner_id: number - renter_id: number - owner_phone?: string - renter_phone?: string - title: string - server_region: string - login_platform: string - rent_start_at?: string - rent_end_at?: string - rent_amount: number - deposit_amount: number - platform_fee: number - account_snapshot?: Record - status: string - handoff_status: string - settlement_status: string - checkout?: Checkout - created_at: string - updated_at: string + id: number; + order_no: string; + listing_id: number; + account_id: number; + owner_id: number; + renter_id: number; + owner_phone?: string; + renter_phone?: string; + title: string; + server_region: string; + login_platform: string; + rent_start_at?: string; + rent_end_at?: string; + rent_amount: number; + deposit_amount: number; + platform_fee: number; + account_snapshot?: Record; + status: string; + handoff_status: string; + settlement_status: string; + checkout?: Checkout; + created_at: string; + updated_at: string; } export interface Checkout { - id: number - order_id: number - initiated_by: number - status: string - rent_amount: number - deposit_amount: number - consumable_amount: number - coin_consumed_m: number - other_amount: number - deposit_deduct_amount: number - renter_refund_amount: number - owner_income_amount: number - content: string - evidence_urls: string[] - owner_adjustment_reason: string - owner_adjusted_at?: string - renter_confirmed_at?: string - renter_rejected_at?: string - created_at: string - updated_at: string + id: number; + order_id: number; + initiated_by: number; + status: string; + rent_amount: number; + deposit_amount: number; + consumable_amount: number; + coin_consumed_m: number; + other_amount: number; + deposit_deduct_amount: number; + renter_refund_amount: number; + owner_income_amount: number; + content: string; + evidence_urls: string[]; + owner_adjustment_reason: string; + owner_adjusted_at?: string; + renter_confirmed_at?: string; + renter_rejected_at?: string; + created_at: string; + updated_at: string; } export interface HandoffRecord { - id: number - order_id: number - from_user_id: number - to_user_id: number - type: string - content: string - confirmed_by_renter_at?: string - confirmed_by_owner_at?: string - created_at: string + id: number; + order_id: number; + from_user_id: number; + to_user_id: number; + type: string; + content: string; + confirmed_by_renter_at?: string; + confirmed_by_owner_at?: string; + created_at: string; } export interface SubmitCheckoutPayload { - content: string - consumable_amount: number - coin_consumed_m: number - other_amount: number - evidence_urls: string[] + content: string; + consumable_amount: number; + coin_consumed_m: number; + other_amount: number; + evidence_urls: string[]; } export interface CounterCheckoutPayload { - consumable_amount: number - coin_consumed_m: number - other_amount: number - deposit_deduct_amount: number - reason: string - evidence_urls: string[] + consumable_amount: number; + coin_consumed_m: number; + other_amount: number; + deposit_deduct_amount: number; + reason: string; + evidence_urls: string[]; +} + +export interface PaginatedResult { + items: T[]; + total: number; + page: number; + page_size: number; } interface ApiResponse { - code: string - message: string - data: T + code: string; + message: string; + data: T; } export async function createOrder(listingId: number) { - const { data } = await apiClient.post>('/orders', { + const { data } = await apiClient.post>("/orders", { listing_id: listingId, - }) - return data.data + }); + return data.data; } -export async function fetchOrders() { - const { data } = await apiClient.get>('/orders') - return data.data.items +export async function fetchOrders(page = 1, pageSize = 20) { + const { data } = await apiClient.get< + ApiResponse> + >("/orders", { + params: { page, page_size: pageSize }, + }); + return data.data; } -export async function fetchAdminOrders() { - const { data } = await apiClient.get>('/admin/orders') - return data.data.items +export async function fetchAdminOrders(page = 1, pageSize = 20) { + const { data } = await apiClient.get< + ApiResponse> + >("/admin/orders", { + params: { page, page_size: pageSize }, + }); + return data.data; } export async function fetchOrder(id: string | number) { - const { data } = await apiClient.get>(`/orders/${id}`) - return data.data + const { data } = await apiClient.get>(`/orders/${id}`); + return data.data; } export async function fetchAdminOrder(id: string | number) { - const { data } = await apiClient.get>(`/admin/orders/${id}`) - return data.data + const { data } = await apiClient.get>( + `/admin/orders/${id}` + ); + return data.data; } export async function cancelOrder(id: number) { - const { data } = await apiClient.post>(`/orders/${id}/cancel`) - return data.data + const { data } = await apiClient.post>( + `/orders/${id}/cancel` + ); + return data.data; } export async function submitHandoff(id: number, content: string) { - const { data } = await apiClient.post>(`/orders/${id}/handoff`, { content }) - return data.data + const { data } = await apiClient.post>( + `/orders/${id}/handoff`, + { content } + ); + return data.data; } export async function fetchHandoffRecords(id: string | number) { - const { data } = await apiClient.get>(`/orders/${id}/handoff-records`) - return data.data.items + const { data } = await apiClient.get>( + `/orders/${id}/handoff-records` + ); + return data.data.items; } export async function fetchAdminHandoffRecords(id: string | number) { - const { data } = await apiClient.get>(`/admin/orders/${id}/handoff-records`) - return data.data.items + const { data } = await apiClient.get>( + `/admin/orders/${id}/handoff-records` + ); + return data.data.items; } export async function adminCloseOrder(id: number, reason: string) { - const { data } = await apiClient.post>(`/admin/orders/${id}/close`, { reason }) - return data.data + const { data } = await apiClient.post>( + `/admin/orders/${id}/close`, + { reason } + ); + return data.data; } export async function adminMarkOrderAbnormal(id: number, reason: string) { - const { data } = await apiClient.post>(`/admin/orders/${id}/mark-abnormal`, { reason }) - return data.data + const { data } = await apiClient.post>( + `/admin/orders/${id}/mark-abnormal`, + { reason } + ); + return data.data; } export async function confirmReceive(id: number) { - const { data } = await apiClient.post>(`/orders/${id}/confirm-receive`) - return data.data + const { data } = await apiClient.post>( + `/orders/${id}/confirm-receive` + ); + return data.data; } -export async function submitCheckout(id: number, payload: SubmitCheckoutPayload) { - const { data } = await apiClient.post>(`/orders/${id}/checkout`, payload) - return data.data +export async function submitCheckout( + id: number, + payload: SubmitCheckoutPayload +) { + const { data } = await apiClient.post>( + `/orders/${id}/checkout`, + payload + ); + return data.data; } export async function confirmCheckout(id: number) { - const { data } = await apiClient.post>(`/orders/${id}/checkout/confirm`) - return data.data + const { data } = await apiClient.post>( + `/orders/${id}/checkout/confirm` + ); + return data.data; } -export async function counterCheckout(id: number, payload: CounterCheckoutPayload) { - const { data } = await apiClient.post>(`/orders/${id}/checkout/counter`, payload) - return data.data +export async function counterCheckout( + id: number, + payload: CounterCheckoutPayload +) { + const { data } = await apiClient.post>( + `/orders/${id}/checkout/counter`, + payload + ); + return data.data; } export async function acceptCheckout(id: number) { - const { data } = await apiClient.post>(`/orders/${id}/checkout/accept`) - return data.data + const { data } = await apiClient.post>( + `/orders/${id}/checkout/accept` + ); + return data.data; } diff --git a/frontend/src/api/wallet.ts b/frontend/src/api/wallet.ts index 7ada290..f582cc2 100644 --- a/frontend/src/api/wallet.ts +++ b/frontend/src/api/wallet.ts @@ -1,5 +1,7 @@ import { apiClient } from './client' +import { type PaginatedResult } from './orders' + export interface WalletAccount { user_id: number available_balance: number @@ -33,7 +35,9 @@ export async function fetchWalletBalance() { return data.data } -export async function fetchWalletLedger() { - const { data } = await apiClient.get>('/wallet/ledger') - return data.data.items +export async function fetchWalletLedger(page = 1, pageSize = 20) { + const { data } = await apiClient.get>>('/wallet/ledger', { + params: { page, page_size: pageSize }, + }) + return data.data } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index c9d7b43..5d539d9 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -92,51 +92,61 @@ const router = createRouter({ path: "/orders/create", name: "order-create", component: () => import("@/views/account/OrderCreateView.vue"), + meta: { requiresAuth: true }, }, { path: "/orders", name: "orders", component: () => import("@/views/account/OrdersView.vue"), + meta: { requiresAuth: true }, }, { path: "/orders/:id", name: "order-detail", component: () => import("@/views/account/OrderDetailView.vue"), + meta: { requiresAuth: true }, }, { path: "/wallet", name: "wallet", component: () => import("@/views/account/WalletView.vue"), + meta: { requiresAuth: true }, }, { path: "/notifications", name: "notifications", component: () => import("@/views/account/NotificationsView.vue"), + meta: { requiresAuth: true }, }, { path: "/realname", name: "realname", component: () => import("@/views/account/RealnameView.vue"), + meta: { requiresAuth: true }, }, { path: "/seller/listings", name: "seller-listings", component: () => import("@/views/seller/SellerListingsView.vue"), + meta: { requiresAuth: true }, }, { path: "/seller/listings/create", name: "seller-listing-create", component: () => import("@/views/seller/SellerListingCreateView.vue"), + meta: { requiresAuth: true }, }, { path: "/seller/handoffs", name: "seller-handoffs", component: () => import("@/views/seller/SellerHandoffsView.vue"), + meta: { requiresAuth: true }, }, { path: "/seller/earnings", name: "seller-earnings", component: () => import("@/views/seller/SellerEarningsView.vue"), + meta: { requiresAuth: true }, }, { path: "/admin/login", @@ -220,14 +230,17 @@ router.beforeEach(async (to) => { if (to.meta.requiresAuth) { const session = useSessionStore(); - if (!session.token) { - return { path: "/m/login", query: { redirect: to.fullPath } }; + const hasToken = !!localStorage.getItem("access_token"); + if (!hasToken) { + const loginPath = to.path.startsWith("/m") ? "/m/login" : "/login"; + return { path: loginPath, query: { redirect: to.fullPath } }; } if (!session.phone) { try { await session.loadMe(); } catch { - return { path: "/m/login", query: { redirect: to.fullPath } }; + const loginPath = to.path.startsWith("/m") ? "/m/login" : "/login"; + return { path: loginPath, query: { redirect: to.fullPath } }; } } } @@ -264,4 +277,4 @@ router.beforeEach(async (to) => { return true; }); -export default router; +export default router; \ No newline at end of file diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 56f43df..5d9aebe 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -1626,3 +1626,10 @@ h1 { background: var(--van-toast-background) !important; color: var(--van-toast-text-color) !important; } + +.pagination-wrap { + display: flex; + justify-content: flex-end; + margin-top: 16px; + padding: 8px 0; +} diff --git a/frontend/src/views/account/NotificationsView.vue b/frontend/src/views/account/NotificationsView.vue index 1890ebe..7bc4f38 100644 --- a/frontend/src/views/account/NotificationsView.vue +++ b/frontend/src/views/account/NotificationsView.vue @@ -7,18 +7,28 @@ import { formatDateTime } from '@/utils/time' const loading = ref(false) const notifications = ref([]) +const currentPage = ref(1) +const currentPageSize = ref(20) +const total = ref(0) onMounted(loadNotifications) async function loadNotifications() { loading.value = true try { - notifications.value = await fetchNotifications() + const result = await fetchNotifications(currentPage.value, currentPageSize.value) + notifications.value = result.items + total.value = result.total } finally { loading.value = false } } +function handleSizeChange() { + currentPage.value = 1 + loadNotifications() +} + async function markRead(id: number) { await markNotificationRead(id) ElMessage.success('已标记为已读') @@ -51,5 +61,17 @@ async function markRead(id: number) { + +
+ +
diff --git a/frontend/src/views/account/OrdersView.vue b/frontend/src/views/account/OrdersView.vue index 993f0ac..6b4919a 100644 --- a/frontend/src/views/account/OrdersView.vue +++ b/frontend/src/views/account/OrdersView.vue @@ -1,22 +1,33 @@ diff --git a/frontend/src/views/account/WalletView.vue b/frontend/src/views/account/WalletView.vue index 17c7cd3..c16b100 100644 --- a/frontend/src/views/account/WalletView.vue +++ b/frontend/src/views/account/WalletView.vue @@ -8,19 +8,32 @@ import { formatDateTime } from '@/utils/time' const loading = ref(false) const account = ref(null) const ledger = ref([]) +const currentPage = ref(1) +const currentPageSize = ref(20) +const total = ref(0) onMounted(loadWallet) async function loadWallet() { loading.value = true try { - const [balance, rows] = await Promise.all([fetchWalletBalance(), fetchWalletLedger()]) + const [balance, result] = await Promise.all([fetchWalletBalance(), fetchWalletLedger(currentPage.value, currentPageSize.value)]) account.value = balance - ledger.value = rows + ledger.value = result.items + total.value = result.total } finally { loading.value = false } } + +function handleSizeChange() { + currentPage.value = 1 + loadWallet() +} + +function loadLedgerPage() { + loadWallet() +} diff --git a/frontend/src/views/admin/AdminAuditLogsView.vue b/frontend/src/views/admin/AdminAuditLogsView.vue index 05be890..0749ef9 100644 --- a/frontend/src/views/admin/AdminAuditLogsView.vue +++ b/frontend/src/views/admin/AdminAuditLogsView.vue @@ -8,22 +8,30 @@ import { formatDateTime } from '@/utils/time' const loading = ref(false) const logs = ref([]) const activeLog = ref(null) +const currentPage = ref(1) +const currentPageSize = ref(20) +const total = ref(0) const filters = reactive({ actor_id: '', action: '', biz_type: '', - limit: 200, }) const highRiskCount = computed(() => logs.value.filter((item) => item.action.includes('freeze') || item.action.includes('update')).length) -const uniqueActors = computed(() => new Set(logs.value.map((item) => item.actor_id)).size) onMounted(loadLogs) async function loadLogs() { loading.value = true try { - logs.value = await fetchAdminAuditLogs(filters) + const query = { + ...filters, + page: currentPage.value, + page_size: currentPageSize.value, + } + const result = await fetchAdminAuditLogs(query) + logs.value = result.items + total.value = result.total } finally { loading.value = false } @@ -33,10 +41,15 @@ function resetFilters() { filters.actor_id = '' filters.action = '' filters.biz_type = '' - filters.limit = 200 + currentPage.value = 1 void loadLogs() } +function handleSizeChange() { + currentPage.value = 1 + loadLogs() +} + function actorName(row: AdminAuditLog) { return row.actor_nickname || row.actor_username || `${row.actor_type} ${row.actor_id}` } @@ -59,7 +72,7 @@ function actionType(action: string) {
重置 @@ -69,12 +82,8 @@ function actionType(action: string) {
- 当前结果 - {{ logs.length }} 条 -
-
- 操作管理员 - {{ uniqueActors }} 人 + 总条数 + {{ total }} 条
高风险动作 @@ -94,13 +103,8 @@ function actionType(action: string) { - - - - - @@ -112,17 +116,12 @@ function actionType(action: string) { - - - - - + @@ -132,8 +131,6 @@ function actionType(action: string) { - - @@ -144,9 +141,23 @@ function actionType(action: string) { +
+ +
+

{{ activeLog.action }} · {{ activeLog.biz_type }} #{{ activeLog.biz_id || '-' }}

+

操作人:{{ actorName(activeLog) }} · IP:{{ activeLog.ip }}

+

User-Agent:{{ activeLog.user_agent }}

{{ detailText(activeLog) }}
diff --git a/frontend/src/views/admin/AdminDisputesView.vue b/frontend/src/views/admin/AdminDisputesView.vue index 327de96..8decd85 100644 --- a/frontend/src/views/admin/AdminDisputesView.vue +++ b/frontend/src/views/admin/AdminDisputesView.vue @@ -5,6 +5,7 @@ import { onMounted, ref } from 'vue' import { arbitrateDispute, fetchAdminDisputes, type Dispute } from '@/api/disputes' import { fetchAdminFileBlob } from '@/api/files' import { disputeStatusLabel } from '@/utils/statusLabels' +import { formatDateTime } from '@/utils/time' const loading = ref(false) const submitting = ref(false) @@ -14,18 +15,28 @@ const evidenceDispute = ref(null) const result = ref('release_deposit') const remark = ref('') const amount = ref() +const currentPage = ref(1) +const currentPageSize = ref(20) +const total = ref(0) onMounted(loadDisputes) async function loadDisputes() { loading.value = true try { - disputes.value = await fetchAdminDisputes() + const res = await fetchAdminDisputes(currentPage.value, currentPageSize.value) + disputes.value = res.items + total.value = res.total } finally { loading.value = false } } +function handleSizeChange() { + currentPage.value = 1 + loadDisputes() +} + function openArbitration(row: Dispute) { activeDispute.value = row result.value = row.arbitration_result || 'release_deposit' @@ -109,20 +120,30 @@ function readError(error: unknown, fallback: string) { - - - - + + - + + +
+ +
+

{{ activeDispute.order_no }} · {{ activeDispute.title }}

diff --git a/frontend/src/views/admin/AdminListingReviewView.vue b/frontend/src/views/admin/AdminListingReviewView.vue index 57ab9a9..041b6c2 100644 --- a/frontend/src/views/admin/AdminListingReviewView.vue +++ b/frontend/src/views/admin/AdminListingReviewView.vue @@ -6,13 +6,8 @@ import { fetchAdminFileBlob } from '@/api/files' import { approveListing, fetchPendingReviewListings, rejectListing, type Listing } from '@/api/listings' import { formatMoney, - formatRatio, - getAdminListingPricing, - listingAssetTags, - listingBadgeTags, listingBuyerPrice, listingDisplayTitle, - listingResourceTags, listingSellerPrice, ownerName, } from '@/views/admin/listingAdminDisplay' @@ -24,18 +19,28 @@ const listings = ref([]) const activeListing = ref(null) const evidenceListing = ref(null) const rejectReason = ref('') +const currentPage = ref(1) +const currentPageSize = ref(20) +const total = ref(0) onMounted(loadListings) async function loadListings() { loading.value = true try { - listings.value = await fetchPendingReviewListings() + const result = await fetchPendingReviewListings(currentPage.value, currentPageSize.value) + listings.value = result.items + total.value = result.total } finally { loading.value = false } } +function handleSizeChange() { + currentPage.value = 1 + loadListings() +} + async function handleApprove(row: Listing) { submitting.value = true try { @@ -113,60 +118,23 @@ function readError(error: unknown, fallback: string) {
- + - + - - - - - - - - - - - + + - - + + + +
+ +
diff --git a/frontend/src/views/admin/AdminOrdersView.vue b/frontend/src/views/admin/AdminOrdersView.vue index 29a97f5..fd6e41f 100644 --- a/frontend/src/views/admin/AdminOrdersView.vue +++ b/frontend/src/views/admin/AdminOrdersView.vue @@ -1,13 +1,16 @@
+ +
+ +
diff --git a/frontend/src/views/admin/AdminUsersView.vue b/frontend/src/views/admin/AdminUsersView.vue index bd6bfdd..9eb75e1 100644 --- a/frontend/src/views/admin/AdminUsersView.vue +++ b/frontend/src/views/admin/AdminUsersView.vue @@ -3,7 +3,7 @@ import { ElMessage } from 'element-plus' import { onMounted, ref } from 'vue' import { fetchAdminUsers, freezeAdminUser, unfreezeAdminUser, type AdminUserItem } from '@/api/adminUsers' -import { realnameStatusLabel, riskStatusLabel, userStatusLabel } from '@/utils/statusLabels' +import { userStatusLabel } from '@/utils/statusLabels' import { formatDateTime } from '@/utils/time' const loading = ref(false) @@ -11,18 +11,28 @@ const submitting = ref(false) const users = ref([]) const activeUser = ref(null) const freezeReason = ref('') +const currentPage = ref(1) +const currentPageSize = ref(20) +const total = ref(0) onMounted(loadUsers) async function loadUsers() { loading.value = true try { - users.value = await fetchAdminUsers() + const result = await fetchAdminUsers(currentPage.value, currentPageSize.value) + users.value = result.items + total.value = result.total } finally { loading.value = false } } +function handleSizeChange() { + currentPage.value = 1 + loadUsers() +} + function openFreeze(row: AdminUserItem) { activeUser.value = row freezeReason.value = '' @@ -71,30 +81,20 @@ function readError(error: unknown, fallback: string) { 刷新
- - + - - - - - - - + - - - - - + +