完善后台分页与令牌刷新

This commit is contained in:
yml
2026-05-24 17:56:26 +08:00
parent cb636e4e89
commit abc4447401
19 changed files with 392 additions and 91 deletions
@@ -65,10 +65,30 @@ func (h *Handler) Logout(c *gin.Context) {
response.OK(c, gin.H{"logged_out": true}) 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) { func writeAdminAuthError(c *gin.Context, err error) {
switch { switch {
case errors.Is(err, ErrDependencyUnavailable): case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "数据库未连接") response.ServiceUnavailable(c, "数据库未连接")
case errors.Is(err, ErrInvalidRefreshToken):
response.Unauthorized(c, "刷新令牌无效或已过期")
case errors.Is(err, ErrInvalidCredential): case errors.Is(err, ErrInvalidCredential):
response.BadRequest(c, "用户名或密码错误") response.BadRequest(c, "用户名或密码错误")
case errors.Is(err, ErrCaptchaInvalid): case errors.Is(err, ErrCaptchaInvalid):
+24 -3
View File
@@ -1,20 +1,41 @@
package adminauth package adminauth
import "errors" import (
"errors"
"hfb_sys/backend/internal/modules/auth"
)
var ( var (
ErrDependencyUnavailable = errors.New("dependency unavailable") ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrInvalidCredential = errors.New("invalid credential") ErrInvalidCredential = errors.New("invalid credential")
ErrCaptchaInvalid = errors.New("captcha invalid") ErrCaptchaInvalid = errors.New("captcha invalid")
ErrAdminDisabled = errors.New("admin disabled") ErrAdminDisabled = errors.New("admin disabled")
ErrInvalidRefreshToken = errors.New("invalid refresh token")
) )
type Service struct { type Service struct {
repo *Repository repo *Repository
jwt *auth.JWTManager
} }
func NewService(repo *Repository) *Service { func NewService(repo *Repository, jwt *auth.JWTManager) *Service {
return &Service{repo: repo} return &Service{repo: repo, jwt: jwt}
}
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) { func (s *Service) Captcha() (*CaptchaDTO, error) {
+7 -1
View File
@@ -17,7 +17,13 @@ type UserDTO struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
type FreezeRequest struct { type FreezeRequest struct {
Reason string `json:"reason"` Reason string `json:"reason"`
} }
type PaginatedResult struct {
Items interface{} `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
+18 -2
View File
@@ -19,13 +19,29 @@ func NewHandler(service *Service) *Handler {
return &Handler{service: service} 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) { 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 { if err != nil {
writeAdminUserError(c, err) writeAdminUserError(c, err)
return return
} }
response.OK(c, gin.H{"items": items}) response.OK(c, result)
} }
func (h *Handler) Freeze(c *gin.Context) { func (h *Handler) Freeze(c *gin.Context) {
@@ -24,7 +24,12 @@ func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db} 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 var rows []userRow
err := r.db.Table("users AS u"). err := r.db.Table("users AS u").
Select(`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 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"). 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"). Order("u.id DESC").
Limit(200). Offset(offset).Limit(pageSize).
Scan(&rows).Error Scan(&rows).Error
if err != nil { if err != nil {
return nil, err return nil, err
@@ -44,7 +49,7 @@ func (r *Repository) List() ([]UserDTO, error) {
for _, row := range rows { for _, row := range rows {
items = append(items, row.toDTO()) 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) { func (r *Repository) Freeze(adminID uint64, userID uint64, req FreezeRequest, meta AuditMeta) (*UserDTO, error) {
@@ -15,11 +15,11 @@ func NewService(repo *Repository) *Service {
return &Service{repo: repo} return &Service{repo: repo}
} }
func (s *Service) List() ([]UserDTO, error) { func (s *Service) List(page, pageSize int) (*PaginatedResult, error) {
if s.repo == nil { if s.repo == nil {
return nil, ErrDependencyUnavailable 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) { func (s *Service) Freeze(adminID uint64, userID uint64, req FreezeRequest, meta AuditMeta) (*UserDTO, error) {
+7 -1
View File
@@ -36,8 +36,14 @@ type ArbitrateRequest struct {
Remark string `json:"remark" binding:"required"` Remark string `json:"remark" binding:"required"`
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
} }
type AuditMeta struct { type AuditMeta struct {
IP string IP string
UserAgent string UserAgent string
} }
type PaginatedResult struct {
Items []DisputeDTO `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
+21 -4
View File
@@ -48,12 +48,13 @@ func (h *Handler) List(c *gin.Context) {
response.Unauthorized(c, "缺少用户上下文") response.Unauthorized(c, "缺少用户上下文")
return return
} }
items, err := h.service.ListForUser(userID) page, pageSize := parsePagination(c)
result, err := h.service.ListForUser(userID, page, pageSize)
if err != nil { if err != nil {
writeDisputeError(c, err) writeDisputeError(c, err)
return return
} }
response.OK(c, gin.H{"items": items}) response.OK(c, result)
} }
func (h *Handler) Detail(c *gin.Context) { 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) { 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 { if err != nil {
writeDisputeError(c, err) writeDisputeError(c, err)
return return
} }
response.OK(c, gin.H{"items": items}) response.OK(c, result)
} }
func (h *Handler) AdminArbitrate(c *gin.Context) { func (h *Handler) AdminArbitrate(c *gin.Context) {
@@ -136,6 +138,21 @@ func parseID(c *gin.Context) (uint64, bool) {
return id, true 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) { func writeDisputeError(c *gin.Context, err error) {
switch { switch {
case errors.Is(err, ErrDependencyUnavailable): case errors.Is(err, ErrDependencyUnavailable):
+61 -9
View File
@@ -35,6 +35,7 @@ func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (*
if order.Status == "completed" || order.Status == "cancelled" || order.Status == "closed" { if order.Status == "completed" || order.Status == "cancelled" || order.Status == "closed" {
return ErrInvalidDispute return ErrInvalidDispute
} }
isCheckoutDispute := order.Status == "pending_checkout_confirm" || order.Status == "pending_checkout_accept"
var count int64 var count int64
if err := tx.Model(&model.Dispute{}). if err := tx.Model(&model.Dispute{}).
Where("order_id = ? AND status IN ?", order.ID, []string{"open", "processing"}). Where("order_id = ? AND status IN ?", order.ID, []string{"open", "processing"}).
@@ -57,7 +58,7 @@ func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (*
OrderID: order.ID, OrderID: order.ID,
InitiatorID: userID, InitiatorID: userID,
TargetUserID: targetID, TargetUserID: targetID,
Type: req.Type, Type: disputeType(req.Type, isCheckoutDispute),
Status: "open", Status: "open",
Description: req.Description, Description: req.Description,
EvidenceURLS: evidence, EvidenceURLS: evidence,
@@ -65,17 +66,42 @@ func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (*
if err := tx.Create(&row).Error; err != nil { if err := tx.Create(&row).Error; err != nil {
return err return err
} }
order.Status = "disputing" if isCheckoutDispute {
now := time.Now()
order.Status = "checkout_disputing"
order.HandoffStatus = "checkout_disputed"
order.SettlementStatus = "disputed"
updates := map[string]any{
"status": "disputed",
"updated_at": now,
}
if userID == order.RenterID {
updates["renter_rejected_at"] = now
}
if err := tx.Model(&model.OrderCheckout{}).
Where("order_id = ? AND status IN ?", order.ID, []string{"submitted", "countered"}).
Updates(updates).Error; err != nil {
return err
}
} else {
order.Status = "disputing"
}
if err := tx.Save(&order).Error; err != nil { if err := tx.Save(&order).Error; err != nil {
return err return err
} }
disputeID := row.ID disputeID := row.ID
title := "订单进入申诉"
content := "对方已发起申诉,请等待客服仲裁或补充沟通记录。"
if isCheckoutDispute {
title = "订单进入结账争议"
content = "对方已发起结账争议,请等待客服仲裁或补充结账证据。"
}
if err := notification.Append(tx, if err := notification.Append(tx,
notification.Entry{ notification.Entry{
UserID: targetID, UserID: targetID,
Type: "dispute", Type: "dispute",
Title: "订单进入申诉", Title: title,
Content: "对方已发起申诉,请等待客服仲裁或补充沟通记录。", Content: content,
BizType: "dispute", BizType: "dispute",
BizID: &disputeID, BizID: &disputeID,
}, },
@@ -99,16 +125,23 @@ func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (*
return r.FindForUser(userID, createdID) 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 var rows []disputeRow
err := r.baseQuery(). err := r.baseQuery().
Where("d.initiator_id = ? OR d.target_user_id = ?", userID, userID). Where("d.initiator_id = ? OR d.target_user_id = ?", userID, userID).
Order("d.id DESC"). Order("d.id DESC").
Offset(offset).Limit(pageSize).
Scan(&rows).Error Scan(&rows).Error
if err != nil { if err != nil {
return nil, err 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) { func (r *Repository) FindForUser(userID uint64, id uint64) (*DisputeDTO, error) {
@@ -122,13 +155,18 @@ func (r *Repository) FindForUser(userID uint64, id uint64) (*DisputeDTO, error)
return &dto, nil 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 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 { if err != nil {
return nil, err 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) { func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest, meta AuditMeta) (*DisputeDTO, error) {
@@ -177,6 +215,9 @@ func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest,
if req.Result == "order_close" { if req.Result == "order_close" {
listing.Status = "offline" listing.Status = "offline"
account.Status = "offline" account.Status = "offline"
} else if req.Result == "mark_abnormal" {
listing.Status = "abnormal"
account.Status = "abnormal"
} else { } else {
listing.Status = "published" listing.Status = "published"
account.Status = "published" account.Status = "published"
@@ -335,6 +376,8 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest) (
addRenterRefund(order.DepositAmount-deductAmount, "仲裁退回剩余押金给租客") addRenterRefund(order.DepositAmount-deductAmount, "仲裁退回剩余押金给租客")
case "order_close": case "order_close":
// Only release frozen funds. No available-balance settlement happens in development mode. // Only release frozen funds. No available-balance settlement happens in development mode.
case "mark_abnormal":
// 标记异常只释放冻结账务,后续由客服继续线下复核。
default: default:
return settlement, ErrInvalidDispute return settlement, ErrInvalidDispute
} }
@@ -395,11 +438,20 @@ func arbitrateOrderStatus(result string) string {
switch result { switch result {
case "full_refund", "partial_refund", "order_close": case "full_refund", "partial_refund", "order_close":
return "closed" return "closed"
case "mark_abnormal":
return "abnormal"
default: default:
return "completed" return "completed"
} }
} }
func disputeType(input string, isCheckoutDispute bool) string {
if isCheckoutDispute {
return "checkout_dispute"
}
return input
}
func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizType string, bizID uint64, meta AuditMeta, detail map[string]any) error { func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizType string, bizID uint64, meta AuditMeta, detail map[string]any) error {
raw, err := json.Marshal(detail) raw, err := json.Marshal(detail)
if err != nil { if err != nil {
+4 -4
View File
@@ -28,11 +28,11 @@ func (s *Service) Create(userID uint64, orderID uint64, req CreateRequest) (*Dis
return s.repo.Create(userID, orderID, req) 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 { if s.repo == nil {
return nil, ErrDependencyUnavailable 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) { 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) 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 { if s.repo == nil {
return nil, ErrDependencyUnavailable 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) { func (s *Service) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest, meta AuditMeta) (*DisputeDTO, error) {
+2 -1
View File
@@ -46,7 +46,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
if deps.DB != nil { if deps.DB != nil {
adminAuthRepo = adminauth.NewRepository(deps.DB, deps.Redis, jwtManager) adminAuthRepo = adminauth.NewRepository(deps.DB, deps.Redis, jwtManager)
} }
adminAuthService := adminauth.NewService(adminAuthRepo) adminAuthService := adminauth.NewService(adminAuthRepo, jwtManager)
adminAuthHandler := adminauth.NewHandler(adminAuthService) adminAuthHandler := adminauth.NewHandler(adminAuthService)
var adminDashboardRepo *admindashboard.Repository var adminDashboardRepo *admindashboard.Repository
if deps.DB != nil { if deps.DB != nil {
@@ -215,6 +215,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
{ {
adminAuthRoutes.GET("/captcha", adminAuthHandler.Captcha) adminAuthRoutes.GET("/captcha", adminAuthHandler.Captcha)
adminAuthRoutes.POST("/login", adminAuthHandler.Login) adminAuthRoutes.POST("/login", adminAuthHandler.Login)
adminAuthRoutes.POST("/refresh", adminAuthHandler.Refresh)
} }
adminRoutes := api.Group("/admin", requireAdmin) adminRoutes := api.Group("/admin", requireAdmin)
+9
View File
@@ -56,3 +56,12 @@ export async function logoutAdmin() {
const { data } = await apiClient.post<ApiResponse<{ logged_out: boolean }>>('/admin/auth/logout') const { data } = await apiClient.post<ApiResponse<{ logged_out: boolean }>>('/admin/auth/logout')
return data.data 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
}
+7 -3
View File
@@ -1,5 +1,7 @@
import { apiClient } from './client' import { apiClient } from './client'
import { type PaginatedResult } from './orders'
export interface AdminUserItem { export interface AdminUserItem {
id: number id: number
phone: string phone: string
@@ -22,9 +24,11 @@ interface ApiResponse<T> {
data: T data: T
} }
export async function fetchAdminUsers() { export async function fetchAdminUsers(page = 1, pageSize = 20) {
const { data } = await apiClient.get<ApiResponse<{ items: AdminUserItem[] }>>('/admin/users') const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminUserItem>>>('/admin/users', {
return data.data.items params: { page, page_size: pageSize },
})
return data.data
} }
export async function freezeAdminUser(id: number, reason: string) { export async function freezeAdminUser(id: number, reason: string) {
+8
View File
@@ -50,3 +50,11 @@ export async function updateMe(payload: Pick<AuthUser, 'nickname' | 'avatar_url'
const { data } = await apiClient.put<ApiResponse<AuthUser>>('/me', payload) const { data } = await apiClient.put<ApiResponse<AuthUser>>('/me', payload)
return data.data 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<ApiResponse<TokenPair>>('/auth/refresh', {
refresh_token: refreshToken,
})
return data.data
}
+100 -23
View File
@@ -1,10 +1,53 @@
import axios from 'axios' import axios, { type InternalAxiosRequestConfig } from 'axios'
export const apiClient = axios.create({ export const apiClient = axios.create({
baseURL: '/api', baseURL: '/api',
timeout: 10000, 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<string> {
const refreshTokenKey = isAdmin ? 'admin_refresh_token' : 'refresh_token'
const refreshToken = localStorage.getItem(refreshTokenKey)
if (!refreshToken) {
throw new Error('no refresh token')
}
const endpoint = isAdmin ? '/api/admin/auth/refresh' : '/api/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) => { apiClient.interceptors.request.use((config) => {
const url = config.url || '' const url = config.url || ''
const tokenKey = url.startsWith('/admin') ? 'admin_access_token' : 'access_token' const tokenKey = url.startsWith('/admin') ? 'admin_access_token' : 'access_token'
@@ -15,40 +58,74 @@ apiClient.interceptors.request.use((config) => {
return config return config
}) })
// ---- Response interceptor ----
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
(error) => { async (error) => {
if (error?.response?.status !== 401) { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
if (error?.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error) return Promise.reject(error)
} }
const requestUrl = error.config?.url || '' const requestUrl = originalRequest.url || ''
const isAdminRequest = requestUrl.startsWith('/admin') const isAdminRequest = requestUrl.startsWith('/admin')
const currentPath = window.location.pathname + window.location.search
if (isAdminRequest) { // exclude refresh endpoints themselves to avoid dead loop
localStorage.removeItem('admin_access_token') if (requestUrl.endsWith('/auth/refresh') || requestUrl.endsWith('/admin/auth/refresh')) {
localStorage.removeItem('admin_refresh_token') if (isAdminRequest) clearAdminTokens()
if (!window.location.pathname.startsWith('/admin/login')) { else clearUserTokens()
if (isAdminRequest && !window.location.pathname.startsWith('/admin/login')) {
window.location.assign('/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) return Promise.reject(error)
} }
localStorage.removeItem('access_token') // if already refreshing, queue up
localStorage.removeItem('refresh_token') if (isRefreshing) {
localStorage.removeItem('user_id') return new Promise((resolve) => {
localStorage.removeItem('phone') addPendingRequest((newToken: string) => {
localStorage.removeItem('nickname') originalRequest.headers.Authorization = `Bearer ${newToken}`
localStorage.removeItem('avatar_url') resolve(apiClient(originalRequest))
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}`)
} }
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
}
}
) )
+12 -6
View File
@@ -1,5 +1,7 @@
import { apiClient } from './client' import { apiClient } from './client'
import { type PaginatedResult } from './orders'
export interface Dispute { export interface Dispute {
id: number id: number
order_id: number order_id: number
@@ -30,14 +32,18 @@ export async function createDispute(orderId: number, payload: { type: string; de
return data.data return data.data
} }
export async function fetchDisputes() { export async function fetchDisputes(page = 1, pageSize = 20) {
const { data } = await apiClient.get<ApiResponse<{ items: Dispute[] }>>('/disputes') const { data } = await apiClient.get<ApiResponse<PaginatedResult<Dispute>>>('/disputes', {
return data.data.items params: { page, page_size: pageSize },
})
return data.data
} }
export async function fetchAdminDisputes() { export async function fetchAdminDisputes(page = 1, pageSize = 20) {
const { data } = await apiClient.get<ApiResponse<{ items: Dispute[] }>>('/admin/disputes') const { data } = await apiClient.get<ApiResponse<PaginatedResult<Dispute>>>('/admin/disputes', {
return data.data.items params: { page, page_size: pageSize },
})
return data.data
} }
export async function arbitrateDispute(id: number, payload: { result: string; remark: string; amount?: number }) { export async function arbitrateDispute(id: number, payload: { result: string; remark: string; amount?: number }) {
+22 -3
View File
@@ -61,6 +61,12 @@ const router = createRouter({
component: () => import("@/views/mobile/MobileOrdersView.vue"), component: () => import("@/views/mobile/MobileOrdersView.vue"),
meta: { layout: "blank", requiresAuth: true }, meta: { layout: "blank", requiresAuth: true },
}, },
{
path: "/m/orders/:id",
name: "mobile-order-detail",
component: () => import("@/views/account/OrderDetailView.vue"),
meta: { layout: "blank", requiresAuth: true },
},
{ {
path: "/m/seller/listings/create", path: "/m/seller/listings/create",
name: "mobile-seller-listing-create", name: "mobile-seller-listing-create",
@@ -92,51 +98,61 @@ const router = createRouter({
path: "/orders/create", path: "/orders/create",
name: "order-create", name: "order-create",
component: () => import("@/views/account/OrderCreateView.vue"), component: () => import("@/views/account/OrderCreateView.vue"),
meta: { requiresAuth: true },
}, },
{ {
path: "/orders", path: "/orders",
name: "orders", name: "orders",
component: () => import("@/views/account/OrdersView.vue"), component: () => import("@/views/account/OrdersView.vue"),
meta: { requiresAuth: true },
}, },
{ {
path: "/orders/:id", path: "/orders/:id",
name: "order-detail", name: "order-detail",
component: () => import("@/views/account/OrderDetailView.vue"), component: () => import("@/views/account/OrderDetailView.vue"),
meta: { requiresAuth: true },
}, },
{ {
path: "/wallet", path: "/wallet",
name: "wallet", name: "wallet",
component: () => import("@/views/account/WalletView.vue"), component: () => import("@/views/account/WalletView.vue"),
meta: { requiresAuth: true },
}, },
{ {
path: "/notifications", path: "/notifications",
name: "notifications", name: "notifications",
component: () => import("@/views/account/NotificationsView.vue"), component: () => import("@/views/account/NotificationsView.vue"),
meta: { requiresAuth: true },
}, },
{ {
path: "/realname", path: "/realname",
name: "realname", name: "realname",
component: () => import("@/views/account/RealnameView.vue"), component: () => import("@/views/account/RealnameView.vue"),
meta: { requiresAuth: true },
}, },
{ {
path: "/seller/listings", path: "/seller/listings",
name: "seller-listings", name: "seller-listings",
component: () => import("@/views/seller/SellerListingsView.vue"), component: () => import("@/views/seller/SellerListingsView.vue"),
meta: { requiresAuth: true },
}, },
{ {
path: "/seller/listings/create", path: "/seller/listings/create",
name: "seller-listing-create", name: "seller-listing-create",
component: () => import("@/views/seller/SellerListingCreateView.vue"), component: () => import("@/views/seller/SellerListingCreateView.vue"),
meta: { requiresAuth: true },
}, },
{ {
path: "/seller/handoffs", path: "/seller/handoffs",
name: "seller-handoffs", name: "seller-handoffs",
component: () => import("@/views/seller/SellerHandoffsView.vue"), component: () => import("@/views/seller/SellerHandoffsView.vue"),
meta: { requiresAuth: true },
}, },
{ {
path: "/seller/earnings", path: "/seller/earnings",
name: "seller-earnings", name: "seller-earnings",
component: () => import("@/views/seller/SellerEarningsView.vue"), component: () => import("@/views/seller/SellerEarningsView.vue"),
meta: { requiresAuth: true },
}, },
{ {
path: "/admin/login", path: "/admin/login",
@@ -220,14 +236,17 @@ router.beforeEach(async (to) => {
if (to.meta.requiresAuth) { if (to.meta.requiresAuth) {
const session = useSessionStore(); const session = useSessionStore();
if (!session.token) { const hasToken = !!localStorage.getItem("access_token");
return { path: "/m/login", query: { redirect: to.fullPath } }; if (!hasToken) {
const loginPath = to.path.startsWith("/m") ? "/m/login" : "/login";
return { path: loginPath, query: { redirect: to.fullPath } };
} }
if (!session.phone) { if (!session.phone) {
try { try {
await session.loadMe(); await session.loadMe();
} catch { } 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 } };
} }
} }
} }
+31 -9
View File
@@ -5,6 +5,7 @@ import { onMounted, ref } from 'vue'
import { arbitrateDispute, fetchAdminDisputes, type Dispute } from '@/api/disputes' import { arbitrateDispute, fetchAdminDisputes, type Dispute } from '@/api/disputes'
import { fetchAdminFileBlob } from '@/api/files' import { fetchAdminFileBlob } from '@/api/files'
import { disputeStatusLabel } from '@/utils/statusLabels' import { disputeStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const loading = ref(false) const loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
@@ -14,18 +15,28 @@ const evidenceDispute = ref<Dispute | null>(null)
const result = ref('release_deposit') const result = ref('release_deposit')
const remark = ref('') const remark = ref('')
const amount = ref<number | undefined>() const amount = ref<number | undefined>()
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
onMounted(loadDisputes) onMounted(loadDisputes)
async function loadDisputes() { async function loadDisputes() {
loading.value = true loading.value = true
try { try {
disputes.value = await fetchAdminDisputes() const res = await fetchAdminDisputes(currentPage.value, currentPageSize.value)
disputes.value = res.items
total.value = res.total
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function handleSizeChange() {
currentPage.value = 1
loadDisputes()
}
function openArbitration(row: Dispute) { function openArbitration(row: Dispute) {
activeDispute.value = row activeDispute.value = row
result.value = row.arbitration_result || 'release_deposit' result.value = row.arbitration_result || 'release_deposit'
@@ -99,7 +110,7 @@ function readError(error: unknown, fallback: string) {
<div class="page-header"> <div class="page-header">
<p class="eyebrow">Arbitration</p> <p class="eyebrow">Arbitration</p>
<h1>仲裁中心</h1> <h1>仲裁中心</h1>
<p>处理无法登录资产损失哈夫币争议和超时归还</p> <p>处理无法登录资产损失哈夫币争议和结账争议</p>
</div> </div>
<el-table v-loading="loading" class="table-panel" :data="disputes"> <el-table v-loading="loading" class="table-panel" :data="disputes">
@@ -109,20 +120,30 @@ function readError(error: unknown, fallback: string) {
<el-table-column label="状态" width="110"> <el-table-column label="状态" width="110">
<template #default="{ row }">{{ disputeStatusLabel(row.status) }}</template> <template #default="{ row }">{{ disputeStatusLabel(row.status) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="description" label="说明" min-width="220" show-overflow-tooltip /> <el-table-column label="创建时间" min-width="180">
<el-table-column prop="arbitration_result" label="结果" width="150" /> <template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
<el-table-column label="证据" width="100">
<template #default="{ row }">
<el-button size="small" :disabled="evidenceItems(row).length === 0" @click="evidenceDispute = row">查看</el-button>
</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="120"> <el-table-column prop="arbitration_result" label="仲裁结果" width="150" show-overflow-tooltip />
<el-table-column label="操作" width="160">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" :disabled="evidenceItems(row).length === 0" @click="evidenceDispute = row">证据</el-button>
<el-button size="small" :disabled="row.status === 'resolved'" @click="openArbitration(row)">仲裁</el-button> <el-button size="small" :disabled="row.status === 'resolved'" @click="openArbitration(row)">仲裁</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadDisputes"
@size-change="handleSizeChange"
/>
</div>
<el-dialog :model-value="!!activeDispute" title="申诉仲裁" width="560px" @update:model-value="activeDispute = null"> <el-dialog :model-value="!!activeDispute" title="申诉仲裁" width="560px" @update:model-value="activeDispute = null">
<div v-if="activeDispute" class="dialog-body"> <div v-if="activeDispute" class="dialog-body">
<p><strong>{{ activeDispute.order_no }}</strong> · {{ activeDispute.title }}</p> <p><strong>{{ activeDispute.order_no }}</strong> · {{ activeDispute.title }}</p>
@@ -134,6 +155,7 @@ function readError(error: unknown, fallback: string) {
<el-option label="释放押金" value="release_deposit" /> <el-option label="释放押金" value="release_deposit" />
<el-option label="赔付号主" value="compensate_owner" /> <el-option label="赔付号主" value="compensate_owner" />
<el-option label="关闭订单" value="order_close" /> <el-option label="关闭订单" value="order_close" />
<el-option label="标记异常" value="mark_abnormal" />
</el-select> </el-select>
<el-input-number <el-input-number
v-if="['partial_refund', 'deduct_deposit', 'compensate_owner'].includes(result)" v-if="['partial_refund', 'deduct_deposit', 'compensate_owner'].includes(result)"
+29 -17
View File
@@ -3,7 +3,7 @@ import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { fetchAdminUsers, freezeAdminUser, unfreezeAdminUser, type AdminUserItem } from '@/api/adminUsers' 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' import { formatDateTime } from '@/utils/time'
const loading = ref(false) const loading = ref(false)
@@ -11,18 +11,28 @@ const submitting = ref(false)
const users = ref<AdminUserItem[]>([]) const users = ref<AdminUserItem[]>([])
const activeUser = ref<AdminUserItem | null>(null) const activeUser = ref<AdminUserItem | null>(null)
const freezeReason = ref('') const freezeReason = ref('')
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
onMounted(loadUsers) onMounted(loadUsers)
async function loadUsers() { async function loadUsers() {
loading.value = true loading.value = true
try { try {
users.value = await fetchAdminUsers() const result = await fetchAdminUsers(currentPage.value, currentPageSize.value)
users.value = result.items
total.value = result.total
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function handleSizeChange() {
currentPage.value = 1
loadUsers()
}
function openFreeze(row: AdminUserItem) { function openFreeze(row: AdminUserItem) {
activeUser.value = row activeUser.value = row
freezeReason.value = '' freezeReason.value = ''
@@ -71,30 +81,20 @@ function readError(error: unknown, fallback: string) {
<div class="page-header"> <div class="page-header">
<p class="eyebrow">Users</p> <p class="eyebrow">Users</p>
<h1>用户管理</h1> <h1>用户管理</h1>
<p>查看用户实名风险信用和业务数量处理冻结与解冻</p> <p>查看用户信息和状态处理冻结与解冻</p>
</div> </div>
<el-button @click="loadUsers">刷新</el-button> <el-button @click="loadUsers">刷新</el-button>
</div> </div>
<el-table v-loading="loading" class="table-panel" :data="users"> <el-table v-loading="loading" class="table-panel" :data="users">
<el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="id" label="用户ID" width="80" />
<el-table-column prop="phone" label="手机号" min-width="130" />
<el-table-column prop="nickname" label="昵称" min-width="130" /> <el-table-column prop="nickname" label="昵称" min-width="130" />
<el-table-column label="实名" width="110"> <el-table-column prop="phone" label="手机号" min-width="130" />
<template #default="{ row }">{{ realnameStatusLabel(row.realname_status) }}</template>
</el-table-column>
<el-table-column label="风险" width="110">
<template #default="{ row }">{{ riskStatusLabel(row.risk_status) }}</template>
</el-table-column>
<el-table-column prop="credit_score" label="信用分" width="100" />
<el-table-column label="状态" width="110"> <el-table-column label="状态" width="110">
<template #default="{ row }">{{ userStatusLabel(row.status) }}</template> <template #default="{ row }">{{ userStatusLabel(row.status) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="order_count" label="订单" width="90" /> <el-table-column label="注册时间" min-width="180">
<el-table-column prop="listing_count" label="发布" width="90" /> <template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
<el-table-column prop="dispute_count" label="申诉" width="90" />
<el-table-column label="最近登录" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.last_login_at) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="150"> <el-table-column label="操作" width="150">
<template #default="{ row }"> <template #default="{ row }">
@@ -106,6 +106,18 @@ function readError(error: unknown, fallback: string) {
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadUsers"
@size-change="handleSizeChange"
/>
</div>
<el-dialog :model-value="!!activeUser" title="冻结用户" width="560px" @update:model-value="activeUser = null"> <el-dialog :model-value="!!activeUser" title="冻结用户" width="560px" @update:model-value="activeUser = null">
<div v-if="activeUser" class="dialog-body"> <div v-if="activeUser" class="dialog-body">
<p><strong>{{ activeUser.phone }}</strong> · {{ activeUser.nickname }}</p> <p><strong>{{ activeUser.phone }}</strong> · {{ activeUser.nickname }}</p>