diff --git a/README.md b/README.md index db7e0bb..c5c8df4 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,14 @@ npm run dev - 账号交接已支持号主提交说明、租客确认收号,确认后订单进入租赁中。 - 归还流程已支持租客提交归还、号主确认归还,完成后账号重新上架。 - 钱包账务当前为开发态模拟流水,可通过 `GET /api/wallet/balance` 和 `GET /api/wallet/ledger` 查看。 +- 后台资金流水已接入,页面为 `http://localhost:5173/admin/wallet-ledger`,支持按用户、订单和业务类型查询。 - 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。 - 申诉仲裁已支持订单双方发起申诉、开发态后台处理,后台页面为 `http://localhost:5173/admin/disputes`。 - 系统配置已支持默认配置初始化和后台编辑,页面为 `http://localhost:5173/admin/system-configs`,更新会写入审计日志。 - 后台已使用独立登录、图形验证码和独立管理 UI,页面为 `http://localhost:5173/admin/login`;开发态默认管理员为 `admin / admin123456`。 - 后台仪表盘已接入真实统计数据,页面为 `http://localhost:5173/admin/dashboard`。 +- 用户管理后台已接入,页面为 `http://localhost:5173/admin/users`,支持冻结和解冻用户。 +- 订单管理后台已接入,页面为 `http://localhost:5173/admin/orders`,支持查看全量订单和交接记录。 - 商品审核后台已接入,页面为 `http://localhost:5173/admin/listings/review`。 ## 文档 diff --git a/backend/internal/modules/adminuser/dto.go b/backend/internal/modules/adminuser/dto.go new file mode 100644 index 0000000..7502899 --- /dev/null +++ b/backend/internal/modules/adminuser/dto.go @@ -0,0 +1,23 @@ +package adminuser + +import "time" + +type UserDTO struct { + ID uint64 `json:"id"` + Phone string `json:"phone"` + Nickname string `json:"nickname"` + RealnameStatus string `json:"realname_status"` + RiskStatus string `json:"risk_status"` + CreditScore int `json:"credit_score"` + Status string `json:"status"` + OrderCount int64 `json:"order_count"` + ListingCount int64 `json:"listing_count"` + DisputeCount int64 `json:"dispute_count"` + LastLoginAt *time.Time `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type FreezeRequest struct { + Reason string `json:"reason"` +} diff --git a/backend/internal/modules/adminuser/handler.go b/backend/internal/modules/adminuser/handler.go new file mode 100644 index 0000000..3d0cb97 --- /dev/null +++ b/backend/internal/modules/adminuser/handler.go @@ -0,0 +1,105 @@ +package adminuser + +import ( + "errors" + "net/http" + "strconv" + + "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) List(c *gin.Context) { + items, err := h.service.List() + if err != nil { + writeAdminUserError(c, err) + return + } + response.OK(c, gin.H{"items": items}) +} + +func (h *Handler) Freeze(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + userID, ok := parseID(c) + if !ok { + return + } + var req FreezeRequest + _ = c.ShouldBindJSON(&req) + item, err := h.service.Freeze(adminID, userID, req, auditMeta(c)) + if err != nil { + writeAdminUserError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) Unfreeze(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + userID, ok := parseID(c) + if !ok { + return + } + item, err := h.service.Unfreeze(adminID, userID, auditMeta(c)) + if err != nil { + writeAdminUserError(c, err) + return + } + response.OK(c, item) +} + +func currentAdminID(c *gin.Context) (uint64, bool) { + value, ok := c.Get(middleware.ContextAdminID) + if !ok { + return 0, false + } + adminID, ok := value.(uint64) + return adminID, ok +} + +func parseID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.BadRequest(c, "ID 不正确") + return 0, false + } + return id, true +} + +func auditMeta(c *gin.Context) AuditMeta { + return AuditMeta{ + IP: c.ClientIP(), + UserAgent: c.GetHeader("User-Agent"), + } +} + +func writeAdminUserError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrDependencyUnavailable): + response.ServiceUnavailable(c, "数据库未连接") + case errors.Is(err, ErrInvalidUser): + response.BadRequest(c, "用户不符合规则") + case IsNotFound(err): + response.Error(c, http.StatusNotFound, "not_found", "用户不存在") + default: + response.Error(c, http.StatusInternalServerError, "internal_error", "用户管理服务暂时不可用") + } +} diff --git a/backend/internal/modules/adminuser/repository.go b/backend/internal/modules/adminuser/repository.go new file mode 100644 index 0000000..4cfa59e --- /dev/null +++ b/backend/internal/modules/adminuser/repository.go @@ -0,0 +1,150 @@ +package adminuser + +import ( + "encoding/json" + "errors" + + "hfb_sys/backend/internal/model" + + "gorm.io/datatypes" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type Repository struct { + db *gorm.DB +} + +type AuditMeta struct { + IP string + UserAgent string +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) List() ([]UserDTO, error) { + var rows []userRow + err := r.db.Table("users AS u"). + Select(`u.*, + COALESCE(o.order_count, 0) AS order_count, + COALESCE(l.listing_count, 0) AS listing_count, + COALESCE(d.dispute_count, 0) AS dispute_count`). + Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS order_count FROM (SELECT renter_id AS user_id FROM rental_orders UNION ALL SELECT owner_id AS user_id FROM rental_orders) AS order_users GROUP BY user_id) AS o ON o.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"). + Order("u.id DESC"). + Limit(200). + Scan(&rows).Error + if err != nil { + return nil, err + } + items := make([]UserDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, row.toDTO()) + } + return items, nil +} + +func (r *Repository) Freeze(adminID uint64, userID uint64, req FreezeRequest, meta AuditMeta) (*UserDTO, error) { + return r.updateStatus(adminID, userID, "frozen", "frozen", "admin_user.freeze", req.Reason, meta) +} + +func (r *Repository) Unfreeze(adminID uint64, userID uint64, meta AuditMeta) (*UserDTO, error) { + return r.updateStatus(adminID, userID, "active", "normal", "admin_user.unfreeze", "", meta) +} + +func (r *Repository) updateStatus(adminID uint64, userID uint64, status string, riskStatus string, action string, reason string, meta AuditMeta) (*UserDTO, error) { + err := r.db.Transaction(func(tx *gorm.DB) error { + var user model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil { + return err + } + beforeStatus := user.Status + beforeRisk := user.RiskStatus + user.Status = status + user.RiskStatus = riskStatus + if err := tx.Save(&user).Error; err != nil { + return err + } + return appendAuditLog(tx, adminID, action, user.ID, meta, map[string]any{ + "user_id": user.ID, + "reason": reason, + "before_status": beforeStatus, + "after_status": status, + "before_risk_status": beforeRisk, + "after_risk_status": riskStatus, + }) + }) + if err != nil { + return nil, err + } + return r.Find(userID) +} + +func (r *Repository) Find(userID uint64) (*UserDTO, error) { + var row userRow + err := r.db.Table("users AS u"). + Select(`u.*, + COALESCE(o.order_count, 0) AS order_count, + COALESCE(l.listing_count, 0) AS listing_count, + COALESCE(d.dispute_count, 0) AS dispute_count`). + Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS order_count FROM (SELECT renter_id AS user_id FROM rental_orders UNION ALL SELECT owner_id AS user_id FROM rental_orders) AS order_users GROUP BY user_id) AS o ON o.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"). + Where("u.id = ?", userID). + First(&row).Error + if err != nil { + return nil, err + } + dto := row.toDTO() + return &dto, nil +} + +type userRow struct { + model.User + OrderCount int64 + ListingCount int64 + DisputeCount int64 +} + +func (row userRow) toDTO() UserDTO { + return UserDTO{ + ID: row.ID, + Phone: row.Phone, + Nickname: row.Nickname, + RealnameStatus: row.RealnameStatus, + RiskStatus: row.RiskStatus, + CreditScore: row.CreditScore, + Status: row.Status, + OrderCount: row.OrderCount, + ListingCount: row.ListingCount, + DisputeCount: row.DisputeCount, + LastLoginAt: row.LastLoginAt, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizID uint64, meta AuditMeta, detail map[string]any) error { + raw, err := json.Marshal(detail) + if err != nil { + return err + } + row := model.AuditLog{ + ActorType: "admin", + ActorID: actorID, + Action: action, + BizType: "user", + BizID: &bizID, + IP: meta.IP, + UserAgent: meta.UserAgent, + Detail: datatypes.JSON(raw), + } + return tx.Create(&row).Error +} + +func IsNotFound(err error) bool { + return errors.Is(err, gorm.ErrRecordNotFound) +} diff --git a/backend/internal/modules/adminuser/service.go b/backend/internal/modules/adminuser/service.go new file mode 100644 index 0000000..95bc002 --- /dev/null +++ b/backend/internal/modules/adminuser/service.go @@ -0,0 +1,43 @@ +package adminuser + +import "errors" + +var ( + ErrDependencyUnavailable = errors.New("dependency unavailable") + ErrInvalidUser = errors.New("invalid user") +) + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) List() ([]UserDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.List() +} + +func (s *Service) Freeze(adminID uint64, userID uint64, req FreezeRequest, meta AuditMeta) (*UserDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if userID == 0 { + return nil, ErrInvalidUser + } + return s.repo.Freeze(adminID, userID, req, meta) +} + +func (s *Service) Unfreeze(adminID uint64, userID uint64, meta AuditMeta) (*UserDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if userID == 0 { + return nil, ErrInvalidUser + } + return s.repo.Unfreeze(adminID, userID, meta) +} diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 1baff09..7c0ad1d 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -13,6 +13,8 @@ type OrderDTO struct { AccountID uint64 `json:"account_id"` OwnerID uint64 `json:"owner_id"` RenterID uint64 `json:"renter_id"` + OwnerPhone string `json:"owner_phone,omitempty"` + RenterPhone string `json:"renter_phone,omitempty"` Title string `json:"title"` ServerRegion string `json:"server_region"` LoginPlatform string `json:"login_platform"` diff --git a/backend/internal/modules/order/handler.go b/backend/internal/modules/order/handler.go index f87ed53..833a57d 100644 --- a/backend/internal/modules/order/handler.go +++ b/backend/internal/modules/order/handler.go @@ -52,6 +52,41 @@ func (h *Handler) List(c *gin.Context) { response.OK(c, gin.H{"items": items}) } +func (h *Handler) AdminList(c *gin.Context) { + items, err := h.service.ListAdmin() + if err != nil { + writeOrderError(c, err) + return + } + response.OK(c, gin.H{"items": items}) +} + +func (h *Handler) AdminDetail(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + item, err := h.service.FindAdmin(id) + if err != nil { + writeOrderError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) AdminHandoffRecords(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + items, err := h.service.HandoffRecordsAdmin(id) + if err != nil { + writeOrderError(c, err) + return + } + response.OK(c, gin.H{"items": items}) +} + func (h *Handler) Detail(c *gin.Context) { userID, ok := currentUserID(c) if !ok { diff --git a/backend/internal/modules/order/repository.go b/backend/internal/modules/order/repository.go index 9652084..7808eb5 100644 --- a/backend/internal/modules/order/repository.go +++ b/backend/internal/modules/order/repository.go @@ -462,6 +462,47 @@ func (r *Repository) ListForUser(userID uint64) ([]OrderDTO, error) { return items, nil } +func (r *Repository) ListAdmin() ([]OrderDTO, error) { + var rows []orderRow + err := r.adminQuery(). + Order("o.id DESC"). + Limit(200). + Scan(&rows).Error + if err != nil { + return nil, err + } + items := make([]OrderDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, row.toDTO()) + } + return items, nil +} + +func (r *Repository) FindAdmin(orderID uint64) (*OrderDTO, error) { + var row orderRow + if err := r.adminQuery().Where("o.id = ?", orderID).First(&row).Error; err != nil { + return nil, err + } + dto := row.toDTO() + return &dto, nil +} + +func (r *Repository) HandoffRecordsAdmin(orderID uint64) ([]HandoffRecordDTO, error) { + var order model.RentalOrder + if err := r.db.First(&order, orderID).Error; err != nil { + return nil, err + } + var records []model.HandoffRecord + if err := r.db.Where("order_id = ?", orderID).Order("id ASC").Find(&records).Error; err != nil { + return nil, err + } + items := make([]HandoffRecordDTO, 0, len(records)) + for _, record := range records { + items = append(items, toHandoffDTO(record)) + } + return items, nil +} + func (r *Repository) FindForUser(userID uint64, orderID uint64) (*OrderDTO, error) { var row orderRow if err := r.baseQuery(). @@ -488,11 +529,21 @@ func (r *Repository) baseQuery() *gorm.DB { Joins("JOIN game_accounts AS a ON a.id = o.account_id") } +func (r *Repository) adminQuery() *gorm.DB { + return r.db.Table("rental_orders AS o"). + Select("o.*, a.title, a.server_region, a.login_platform, owner.phone AS owner_phone, renter.phone AS renter_phone"). + 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") +} + type orderRow struct { model.RentalOrder Title string ServerRegion string LoginPlatform string + OwnerPhone string + RenterPhone string } func (row orderRow) toDTO() OrderDTO { @@ -503,6 +554,8 @@ func (row orderRow) toDTO() OrderDTO { AccountID: row.AccountID, OwnerID: row.OwnerID, RenterID: row.RenterID, + OwnerPhone: row.OwnerPhone, + RenterPhone: row.RenterPhone, Title: row.Title, ServerRegion: row.ServerRegion, LoginPlatform: row.LoginPlatform, diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go index 1d640f7..17c7dc5 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -88,6 +88,27 @@ func (s *Service) ListForUser(userID uint64) ([]OrderDTO, error) { return s.repo.ListForUser(userID) } +func (s *Service) ListAdmin() ([]OrderDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ListAdmin() +} + +func (s *Service) FindAdmin(orderID uint64) (*OrderDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.FindAdmin(orderID) +} + +func (s *Service) HandoffRecordsAdmin(orderID uint64) ([]HandoffRecordDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.HandoffRecordsAdmin(orderID) +} + func (s *Service) FindForUser(userID uint64, orderID uint64) (*OrderDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/modules/wallet/dto.go b/backend/internal/modules/wallet/dto.go index 3312941..81fcc4d 100644 --- a/backend/internal/modules/wallet/dto.go +++ b/backend/internal/modules/wallet/dto.go @@ -23,3 +23,28 @@ type LedgerDTO struct { Remark string `json:"remark"` CreatedAt time.Time `json:"created_at"` } + +type AdminLedgerQuery struct { + UserID uint64 + OrderID uint64 + BizType string + Limit int +} + +type AdminLedgerDTO struct { + ID uint64 `json:"id"` + LedgerNo string `json:"ledger_no"` + UserID uint64 `json:"user_id"` + UserPhone string `json:"user_phone"` + UserNickname string `json:"user_nickname"` + OrderID *uint64 `json:"order_id"` + OrderNo string `json:"order_no"` + Direction string `json:"direction"` + Amount float64 `json:"amount"` + BalanceAfter float64 `json:"balance_after"` + BalanceType string `json:"balance_type"` + BizType string `json:"biz_type"` + BizNo string `json:"biz_no"` + Remark string `json:"remark"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/backend/internal/modules/wallet/handler.go b/backend/internal/modules/wallet/handler.go index efcdc7a..d6499ac 100644 --- a/backend/internal/modules/wallet/handler.go +++ b/backend/internal/modules/wallet/handler.go @@ -2,6 +2,7 @@ package wallet import ( "errors" + "strconv" "hfb_sys/backend/internal/middleware" "hfb_sys/backend/pkg/response" @@ -45,6 +46,49 @@ func (h *Handler) Ledger(c *gin.Context) { response.OK(c, gin.H{"items": items}) } +func (h *Handler) AdminLedger(c *gin.Context) { + query, ok := parseAdminLedgerQuery(c) + if !ok { + return + } + items, err := h.service.AdminLedger(query) + if err != nil { + writeWalletError(c, err) + return + } + response.OK(c, gin.H{"items": items}) +} + +func parseAdminLedgerQuery(c *gin.Context) (AdminLedgerQuery, bool) { + var query AdminLedgerQuery + if raw := c.Query("user_id"); raw != "" { + value, err := strconv.ParseUint(raw, 10, 64) + if err != nil || value == 0 { + response.BadRequest(c, "用户 ID 不正确") + return query, false + } + query.UserID = value + } + if raw := c.Query("order_id"); raw != "" { + value, err := strconv.ParseUint(raw, 10, 64) + if err != nil || value == 0 { + response.BadRequest(c, "订单 ID 不正确") + return query, false + } + 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") + return query, true +} + func currentUserID(c *gin.Context) (uint64, bool) { value, ok := c.Get(middleware.ContextUserID) if !ok { diff --git a/backend/internal/modules/wallet/repository.go b/backend/internal/modules/wallet/repository.go index 5216946..55cb182 100644 --- a/backend/internal/modules/wallet/repository.go +++ b/backend/internal/modules/wallet/repository.go @@ -57,6 +57,37 @@ func (r *Repository) Ledger(userID uint64) ([]LedgerDTO, error) { return items, nil } +func (r *Repository) AdminLedger(query AdminLedgerQuery) ([]AdminLedgerDTO, error) { + limit := query.Limit + if limit <= 0 || limit > 500 { + limit = 200 + } + + 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, + wl.direction, wl.amount, wl.balance_after, wl.balance_type, wl.biz_type, wl.biz_no, + wl.remark, wl.created_at`). + 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") + + if query.UserID > 0 { + db = db.Where("wl.user_id = ?", query.UserID) + } + if query.OrderID > 0 { + db = db.Where("wl.order_id = ?", query.OrderID) + } + if query.BizType != "" { + db = db.Where("wl.biz_type = ?", query.BizType) + } + + var items []AdminLedgerDTO + if err := db.Order("wl.id DESC").Limit(limit).Scan(&items).Error; err != nil { + return nil, err + } + return items, nil +} + func AppendEntries(tx *gorm.DB, entries ...Entry) error { for _, entry := range entries { if entry.Amount <= 0 { diff --git a/backend/internal/modules/wallet/service.go b/backend/internal/modules/wallet/service.go index 897aa9d..89445c8 100644 --- a/backend/internal/modules/wallet/service.go +++ b/backend/internal/modules/wallet/service.go @@ -25,3 +25,10 @@ func (s *Service) Ledger(userID uint64) ([]LedgerDTO, error) { } return s.repo.Ledger(userID) } + +func (s *Service) AdminLedger(query AdminLedgerQuery) ([]AdminLedgerDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.AdminLedger(query) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index d7ddc57..cb3c3f9 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -6,6 +6,7 @@ import ( "hfb_sys/backend/internal/middleware" "hfb_sys/backend/internal/modules/adminauth" "hfb_sys/backend/internal/modules/admindashboard" + "hfb_sys/backend/internal/modules/adminuser" "hfb_sys/backend/internal/modules/auth" "hfb_sys/backend/internal/modules/dispute" "hfb_sys/backend/internal/modules/listing" @@ -51,6 +52,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } adminDashboardService := admindashboard.NewService(adminDashboardRepo) adminDashboardHandler := admindashboard.NewHandler(adminDashboardService) + var adminUserRepo *adminuser.Repository + if deps.DB != nil { + adminUserRepo = adminuser.NewRepository(deps.DB) + } + adminUserService := adminuser.NewService(adminUserRepo) + adminUserHandler := adminuser.NewHandler(adminUserService) userHandler := user.NewHandler(userRepo) var realnameRepo *realname.Repository if deps.DB != nil { @@ -177,11 +184,18 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/me", adminAuthHandler.Me) adminRoutes.POST("/auth/logout", adminAuthHandler.Logout) adminRoutes.GET("/dashboard", adminDashboardHandler.Summary) + adminRoutes.GET("/users", adminUserHandler.List) + adminRoutes.POST("/users/:id/freeze", adminUserHandler.Freeze) + adminRoutes.POST("/users/:id/unfreeze", adminUserHandler.Unfreeze) + adminRoutes.GET("/orders", orderHandler.AdminList) + adminRoutes.GET("/orders/:id", orderHandler.AdminDetail) + adminRoutes.GET("/orders/:id/handoff-records", orderHandler.AdminHandoffRecords) adminRoutes.GET("/listings/pending", listingHandler.ListPendingReview) adminRoutes.POST("/listings/:id/approve", listingHandler.Approve) adminRoutes.POST("/listings/:id/reject", listingHandler.Reject) adminRoutes.GET("/disputes", disputeHandler.AdminList) adminRoutes.POST("/disputes/:id/arbitrate", disputeHandler.AdminArbitrate) + adminRoutes.GET("/wallet/ledger", walletHandler.AdminLedger) adminRoutes.GET("/system-configs", systemConfigHandler.List) adminRoutes.PUT("/system-configs/:key", systemConfigHandler.Update) } diff --git a/docs/api.md b/docs/api.md index 852340e..07f6f6e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -51,12 +51,19 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。 - `POST /api/admin/auth/logout` - `GET /api/admin/me` - `GET /api/admin/dashboard` +- `GET /api/admin/users` +- `POST /api/admin/users/{id}/freeze` +- `POST /api/admin/users/{id}/unfreeze` +- `GET /api/admin/orders` +- `GET /api/admin/orders/{id}` +- `GET /api/admin/orders/{id}/handoff-records` - `GET /api/admin/listings/pending` - `POST /api/admin/listings/{id}/approve` - `POST /api/admin/listings/{id}/reject` - `GET /api/admin/disputes` - `POST /api/admin/disputes/{id}/arbitrate` +- `GET /api/admin/wallet/ledger` - `GET /api/admin/system-configs` - `PUT /api/admin/system-configs/{key}` -说明:`/api/admin/*` 当前是开发态 mock 后台接口,只要求登录,后续接入后台管理员账号、RBAC 和 Casbin 权限后再收紧访问控制。 +说明:`GET /api/admin/wallet/ledger` 支持按 `user_id`、`order_id`、`biz_type` 和 `limit` 查询最近资金流水。`/api/admin/*` 当前已使用独立后台登录,后续接入 RBAC 和 Casbin 权限后再按角色收紧访问控制。 diff --git a/docs/business-rules.md b/docs/business-rules.md index 98d3b33..e790f55 100644 --- a/docs/business-rules.md +++ b/docs/business-rules.md @@ -64,6 +64,8 @@ - 取消待交接订单时释放租客冻结金额。 - 订单完成时释放租客冻结金额,给号主生成租金入账流水,并给租客生成押金退回流水。 - 每笔流水记录 `balance_after`,用于后续对账。 +- 后台资金流水接口为 `/api/admin/wallet/ledger`,前端页面为 `/admin/wallet-ledger`。 +- 后台可按用户、订单和业务类型查看最近流水,用于客服核对押金、租金、释放和结算记录。 - 后续接入真实支付后,需要把模拟冻结替换为支付成功后的真实冻结。 ## 开发态站内信 @@ -111,3 +113,18 @@ - 当前统计用户数、实名用户数、商品数、上架数、订单数、租赁中订单、今日订单、今日钱包流水。 - 待处理事项包括待审核商品、待仲裁申诉、待交接订单和待归还确认订单。 - 最近订单和最近申诉用于运营快速定位问题,后续接入后台订单管理和商品审核后再跳转到对应详情页。 + +## 开发态用户管理 + +- 用户管理接口为 `/api/admin/users`,前端页面为 `/admin/users`。 +- 后台可查看用户手机号、实名状态、风险状态、信用分、订单数、发布数和申诉数。 +- 冻结用户会将 `users.status` 改为 `frozen`,`risk_status` 改为 `frozen`,被冻结用户不能继续登录。 +- 解冻用户会将 `users.status` 改为 `active`,`risk_status` 改为 `normal`。 +- 冻结和解冻都会写入 `audit_logs`,记录管理员、用户、前后状态和原因。 + +## 开发态订单管理 + +- 订单管理接口为 `/api/admin/orders`,前端页面为 `/admin/orders`。 +- 后台可查看全量订单、租客、号主、订单状态、交接状态、结算状态、租金和押金。 +- 订单详情页 `/admin/orders/:id` 展示订单状态、双方用户、租期、交接记录和账号资产快照。 +- 当前后台订单管理先做只读能力,后续再补后台关闭订单、标记异常和客服介入操作。 diff --git a/frontend/src/api/adminUsers.ts b/frontend/src/api/adminUsers.ts new file mode 100644 index 0000000..18bec6d --- /dev/null +++ b/frontend/src/api/adminUsers.ts @@ -0,0 +1,38 @@ +import { apiClient } from './client' + +export interface AdminUserItem { + id: number + phone: string + nickname: string + realname_status: string + risk_status: string + credit_score: number + status: string + order_count: number + listing_count: number + dispute_count: number + last_login_at?: string + created_at: string + updated_at: string +} + +interface ApiResponse { + code: string + message: string + data: T +} + +export async function fetchAdminUsers() { + const { data } = await apiClient.get>('/admin/users') + return data.data.items +} + +export async function freezeAdminUser(id: number, reason: string) { + const { data } = await apiClient.post>(`/admin/users/${id}/freeze`, { reason }) + return data.data +} + +export async function unfreezeAdminUser(id: number) { + const { data } = await apiClient.post>(`/admin/users/${id}/unfreeze`) + return data.data +} diff --git a/frontend/src/api/adminWallet.ts b/frontend/src/api/adminWallet.ts new file mode 100644 index 0000000..01635e2 --- /dev/null +++ b/frontend/src/api/adminWallet.ts @@ -0,0 +1,38 @@ +import { apiClient } from './client' + +export interface AdminWalletLedger { + id: number + ledger_no: string + user_id: number + user_phone: string + user_nickname: string + order_id?: number + order_no: string + direction: string + amount: number + balance_after: number + balance_type: string + biz_type: string + biz_no: string + remark: string + created_at: string +} + +export interface AdminWalletLedgerQuery { + user_id?: string + order_id?: string + biz_type?: string + limit?: number +} + +interface ApiResponse { + code: string + message: string + data: T +} + +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 +} diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts index 39fd1d1..d83f3f1 100644 --- a/frontend/src/api/orders.ts +++ b/frontend/src/api/orders.ts @@ -7,6 +7,8 @@ export interface Order { account_id: number owner_id: number renter_id: number + owner_phone?: string + renter_phone?: string title: string server_region: string login_platform: string @@ -55,11 +57,21 @@ export async function fetchOrders() { return data.data.items } +export async function fetchAdminOrders() { + const { data } = await apiClient.get>('/admin/orders') + return data.data.items +} + export async function fetchOrder(id: string | number) { 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 +} + export async function cancelOrder(id: number) { const { data } = await apiClient.post>(`/orders/${id}/cancel`) return data.data @@ -75,6 +87,11 @@ export async function fetchHandoffRecords(id: string | number) { 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 +} + export async function confirmReceive(id: number) { const { data } = await apiClient.post>(`/orders/${id}/confirm-receive`) return data.data diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index aa7d1fa..5b257c1 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -1,5 +1,5 @@ + + diff --git a/frontend/src/views/admin/AdminOrdersView.vue b/frontend/src/views/admin/AdminOrdersView.vue new file mode 100644 index 0000000..b8ce846 --- /dev/null +++ b/frontend/src/views/admin/AdminOrdersView.vue @@ -0,0 +1,69 @@ + + + diff --git a/frontend/src/views/admin/AdminUsersView.vue b/frontend/src/views/admin/AdminUsersView.vue new file mode 100644 index 0000000..f10635d --- /dev/null +++ b/frontend/src/views/admin/AdminUsersView.vue @@ -0,0 +1,110 @@ + + + diff --git a/frontend/src/views/admin/AdminWalletLedgerView.vue b/frontend/src/views/admin/AdminWalletLedgerView.vue new file mode 100644 index 0000000..95c8728 --- /dev/null +++ b/frontend/src/views/admin/AdminWalletLedgerView.vue @@ -0,0 +1,142 @@ + + +