diff --git a/README.md b/README.md index b3d156a..289c4ac 100644 --- a/README.md +++ b/README.md @@ -36,13 +36,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/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`,支持查看全量商品和状态筛选。 +- 商品管理后台已接入,页面为 `http://localhost:5173/admin/listings`,支持查看全量商品、商品详情、强制下架和标记异常。 +- 订单详情后台已支持客服关闭和标记异常,相关操作会写入审计日志并通知双方。 - 商品审核后台已接入,页面为 `http://localhost:5173/admin/listings/review`。 - 审计日志后台已接入,页面为 `http://localhost:5173/admin/audit-logs`,支持查看高风险操作明细。 diff --git a/backend/internal/modules/dispute/dto.go b/backend/internal/modules/dispute/dto.go index e0545b9..4fcf3ce 100644 --- a/backend/internal/modules/dispute/dto.go +++ b/backend/internal/modules/dispute/dto.go @@ -32,6 +32,12 @@ type CreateRequest struct { } type ArbitrateRequest struct { - Result string `json:"result" binding:"required"` - Remark string `json:"remark" binding:"required"` + Result string `json:"result" binding:"required"` + Remark string `json:"remark" binding:"required"` + Amount float64 `json:"amount"` +} + +type AuditMeta struct { + IP string + UserAgent string } diff --git a/backend/internal/modules/dispute/handler.go b/backend/internal/modules/dispute/handler.go index f6b2564..6cce4d0 100644 --- a/backend/internal/modules/dispute/handler.go +++ b/backend/internal/modules/dispute/handler.go @@ -98,7 +98,10 @@ func (h *Handler) AdminArbitrate(c *gin.Context) { response.BadRequest(c, "仲裁结果和备注不能为空") return } - item, err := h.service.Arbitrate(adminID, id, req) + item, err := h.service.Arbitrate(adminID, id, req, AuditMeta{ + IP: c.ClientIP(), + UserAgent: c.GetHeader("User-Agent"), + }) if err != nil { writeDisputeError(c, err) return diff --git a/backend/internal/modules/dispute/repository.go b/backend/internal/modules/dispute/repository.go index ba117f9..494cccc 100644 --- a/backend/internal/modules/dispute/repository.go +++ b/backend/internal/modules/dispute/repository.go @@ -7,6 +7,7 @@ import ( "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/notification" + "hfb_sys/backend/internal/modules/wallet" "gorm.io/datatypes" "gorm.io/gorm" @@ -130,7 +131,7 @@ func (r *Repository) ListAdmin() ([]DisputeDTO, error) { return toDTOs(rows), nil } -func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest) (*DisputeDTO, error) { +func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest, meta AuditMeta) (*DisputeDTO, error) { err := r.db.Transaction(func(tx *gorm.DB) error { var row model.Dispute if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil { @@ -151,6 +152,15 @@ func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest) if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil { return err } + beforeOrderStatus := order.Status + beforeHandoffStatus := order.HandoffStatus + beforeSettlementStatus := order.SettlementStatus + beforeListingStatus := listing.Status + beforeAccountStatus := account.Status + settlement, err := buildArbitrationSettlement(order, req) + if err != nil { + return err + } now := time.Now() row.Status = "resolved" row.ArbitrationResult = req.Result @@ -164,8 +174,16 @@ func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest) if order.HandoffStatus != "cancelled" && order.HandoffStatus != "returned" { order.HandoffStatus = "arbitrated" } - listing.Status = "published" - account.Status = "published" + if req.Result == "order_close" { + listing.Status = "offline" + account.Status = "offline" + } else { + listing.Status = "published" + account.Status = "published" + } + if err := wallet.AppendEntries(tx, settlement.Entries...); err != nil { + return err + } if err := tx.Save(&row).Error; err != nil { return err } @@ -179,6 +197,29 @@ func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest) return err } disputeID := row.ID + if err := appendAuditLog(tx, adminID, "dispute.arbitrate", "dispute", row.ID, meta, map[string]any{ + "dispute_id": row.ID, + "order_id": order.ID, + "order_no": order.OrderNo, + "result": req.Result, + "remark": req.Remark, + "input_amount": req.Amount, + "renter_refund_amount": settlement.RenterRefundAmount, + "owner_income_amount": settlement.OwnerIncomeAmount, + "deposit_deduct_amount": settlement.DepositDeductAmount, + "before_order_status": beforeOrderStatus, + "after_order_status": order.Status, + "before_handoff_status": beforeHandoffStatus, + "after_handoff_status": order.HandoffStatus, + "before_settlement_status": beforeSettlementStatus, + "after_settlement_status": order.SettlementStatus, + "before_listing_status": beforeListingStatus, + "after_listing_status": listing.Status, + "before_account_status": beforeAccountStatus, + "after_account_status": account.Status, + }); err != nil { + return err + } if err := notification.Append(tx, notification.Entry{ UserID: order.RenterID, @@ -212,6 +253,94 @@ func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest) return &dto, nil } +type arbitrationSettlement struct { + Entries []wallet.Entry + RenterRefundAmount float64 + OwnerIncomeAmount float64 + DepositDeductAmount float64 +} + +func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest) (arbitrationSettlement, error) { + total := order.RentAmount + order.DepositAmount + settlement := arbitrationSettlement{} + orderID := order.ID + if total > 0 { + settlement.Entries = append(settlement.Entries, wallet.Entry{ + UserID: order.RenterID, + OrderID: &orderID, + Direction: "out", + Amount: total, + BalanceType: "frozen", + BizType: "arbitration_release_frozen", + BizNo: order.OrderNo, + Remark: "仲裁释放开发态模拟冻结金额", + }) + } + + addRenterRefund := func(amount float64, remark string) { + if amount <= 0 { + return + } + settlement.RenterRefundAmount += amount + settlement.Entries = append(settlement.Entries, wallet.Entry{ + UserID: order.RenterID, + OrderID: &orderID, + Direction: "in", + Amount: amount, + BalanceType: "available", + BizType: "arbitration_renter_refund", + BizNo: order.OrderNo, + Remark: remark, + }) + } + addOwnerIncome := func(amount float64, remark string) { + if amount <= 0 { + return + } + settlement.OwnerIncomeAmount += amount + settlement.Entries = append(settlement.Entries, wallet.Entry{ + UserID: order.OwnerID, + OrderID: &orderID, + Direction: "in", + Amount: amount, + BalanceType: "available", + BizType: "arbitration_owner_income", + BizNo: order.OrderNo, + Remark: remark, + }) + } + + switch req.Result { + case "full_refund": + addRenterRefund(total, "仲裁全额退款") + case "partial_refund": + if req.Amount <= 0 || req.Amount > total { + return settlement, ErrInvalidDispute + } + addRenterRefund(req.Amount, "仲裁部分退款") + addOwnerIncome(total-req.Amount, "仲裁剩余金额结算给号主") + case "release_deposit": + addOwnerIncome(order.RentAmount, "仲裁确认租金结算给号主") + addRenterRefund(order.DepositAmount, "仲裁释放押金给租客") + case "deduct_deposit", "compensate_owner": + deductAmount := req.Amount + if deductAmount <= 0 { + deductAmount = order.DepositAmount + } + if deductAmount > order.DepositAmount { + return settlement, ErrInvalidDispute + } + settlement.DepositDeductAmount = deductAmount + addOwnerIncome(order.RentAmount+deductAmount, "仲裁租金及押金赔付结算给号主") + addRenterRefund(order.DepositAmount-deductAmount, "仲裁退回剩余押金给租客") + case "order_close": + // Only release frozen funds. No available-balance settlement happens in development mode. + default: + return settlement, ErrInvalidDispute + } + return settlement, nil +} + func (r *Repository) baseQuery() *gorm.DB { return r.db.Table("disputes AS d"). Select("d.*, o.order_no, a.title"). @@ -271,6 +400,24 @@ func arbitrateOrderStatus(result string) string { } } +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) + if err != nil { + return err + } + row := model.AuditLog{ + ActorType: "admin", + ActorID: actorID, + Action: action, + BizType: bizType, + 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/dispute/service.go b/backend/internal/modules/dispute/service.go index 744db7e..3718737 100644 --- a/backend/internal/modules/dispute/service.go +++ b/backend/internal/modules/dispute/service.go @@ -49,12 +49,12 @@ func (s *Service) ListAdmin() ([]DisputeDTO, error) { return s.repo.ListAdmin() } -func (s *Service) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest) (*DisputeDTO, error) { +func (s *Service) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest, meta AuditMeta) (*DisputeDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } if id == 0 || req.Result == "" || req.Remark == "" { return nil, ErrInvalidDispute } - return s.repo.Arbitrate(adminID, id, req) + return s.repo.Arbitrate(adminID, id, req, meta) } diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index 7c6312a..f9f5959 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -56,3 +56,12 @@ type AdminListQuery struct { ReviewStatus string Limit int } + +type AdminActionRequest struct { + Reason string `json:"reason" binding:"required"` +} + +type AuditMeta struct { + IP string + UserAgent string +} diff --git a/backend/internal/modules/listing/handler.go b/backend/internal/modules/listing/handler.go index 8a4c228..b73ff92 100644 --- a/backend/internal/modules/listing/handler.go +++ b/backend/internal/modules/listing/handler.go @@ -101,6 +101,50 @@ func (h *Handler) ListAdmin(c *gin.Context) { response.OK(c, gin.H{"items": items}) } +func (h *Handler) FindAdmin(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + item, err := h.service.FindAdmin(id) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) AdminOffline(c *gin.Context) { + h.adminAction(c, h.service.AdminOffline) +} + +func (h *Handler) AdminMarkAbnormal(c *gin.Context) { + h.adminAction(c, h.service.AdminMarkAbnormal) +} + +func (h *Handler) adminAction(c *gin.Context, fn func(uint64, uint64, AdminActionRequest, AuditMeta) (*ListingDTO, error)) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + var req AdminActionRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "操作原因不能为空") + return + } + item, err := fn(adminID, id, req, AuditMeta{IP: c.ClientIP(), UserAgent: c.GetHeader("User-Agent")}) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, item) +} + func (h *Handler) Approve(c *gin.Context) { id, ok := parseID(c) if !ok { @@ -212,6 +256,15 @@ func currentUserID(c *gin.Context) (uint64, bool) { return userID, ok } +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 { diff --git a/backend/internal/modules/listing/repository.go b/backend/internal/modules/listing/repository.go index 1c4d70a..930a387 100644 --- a/backend/internal/modules/listing/repository.go +++ b/backend/internal/modules/listing/repository.go @@ -1,12 +1,14 @@ package listing import ( + "encoding/json" "errors" "time" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/notification" + "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -162,6 +164,73 @@ func (r *Repository) ListAdmin(query AdminListQuery) ([]ListingDTO, error) { return rowsToDTO(rows), nil } +func (r *Repository) FindAdmin(listingID uint64) (*ListingDTO, error) { + return r.findDTO("l.id = ?", listingID) +} + +func (r *Repository) AdminOffline(adminID uint64, listingID uint64, req AdminActionRequest, meta AuditMeta) (*ListingDTO, error) { + return r.adminUpdateStatus(adminID, listingID, req, meta, "offline", "offline", "listing.admin_offline", "商品已被后台下架", "你的租号商品已被后台下架,请查看原因后处理。") +} + +func (r *Repository) AdminMarkAbnormal(adminID uint64, listingID uint64, req AdminActionRequest, meta AuditMeta) (*ListingDTO, error) { + return r.adminUpdateStatus(adminID, listingID, req, meta, "abnormal", "abnormal", "listing.mark_abnormal", "商品已被标记异常", "你的租号商品已被后台标记异常,请联系客服处理。") +} + +func (r *Repository) adminUpdateStatus(adminID uint64, listingID uint64, req AdminActionRequest, meta AuditMeta, listingStatus string, accountStatus string, action string, title string, content string) (*ListingDTO, error) { + var dto *ListingDTO + err := r.db.Transaction(func(tx *gorm.DB) error { + listing, account, err := r.findForReviewUpdate(tx, listingID) + if err != nil { + return err + } + if listing.Status == "rented" { + return ErrListingLocked + } + beforeListingStatus := listing.Status + beforeAccountStatus := account.Status + beforeReviewReason := listing.ReviewReason + listing.Status = listingStatus + listing.ReviewReason = req.Reason + if listingStatus != "published" { + listing.PublishedAt = nil + } + account.Status = accountStatus + if err := tx.Save(account).Error; err != nil { + return err + } + if err := tx.Save(listing).Error; err != nil { + return err + } + if err := notification.Append(tx, notification.Entry{ + UserID: listing.OwnerID, + Type: "listing_admin", + Title: title, + Content: content, + BizType: "listing", + BizID: &listingID, + }); err != nil { + return err + } + if err := appendAuditLog(tx, adminID, action, "listing", listing.ID, meta, map[string]any{ + "listing_id": listing.ID, + "account_id": account.ID, + "owner_id": listing.OwnerID, + "reason": req.Reason, + "before_listing_status": beforeListingStatus, + "after_listing_status": listing.Status, + "before_account_status": beforeAccountStatus, + "after_account_status": account.Status, + "before_review_reason": beforeReviewReason, + "after_review_reason": listing.ReviewReason, + }); err != nil { + return err + } + dto = toDTO(*account, *listing) + return nil + }) + return dto, err +} + func (r *Repository) Approve(listingID uint64) (*ListingDTO, error) { var dto *ListingDTO err := r.db.Transaction(func(tx *gorm.DB) error { @@ -411,6 +480,24 @@ func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO { } } +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) + if err != nil { + return err + } + row := model.AuditLog{ + ActorType: "admin", + ActorID: actorID, + Action: action, + BizType: bizType, + 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/listing/service.go b/backend/internal/modules/listing/service.go index 5012d31..e7fb1eb 100644 --- a/backend/internal/modules/listing/service.go +++ b/backend/internal/modules/listing/service.go @@ -59,6 +59,33 @@ func (s *Service) ListAdmin(query AdminListQuery) ([]ListingDTO, error) { return s.repo.ListAdmin(query) } +func (s *Service) FindAdmin(id uint64) (*ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.FindAdmin(id) +} + +func (s *Service) AdminOffline(adminID uint64, id uint64, req AdminActionRequest, meta AuditMeta) (*ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if req.Reason == "" { + return nil, ErrInvalidInput + } + return s.repo.AdminOffline(adminID, id, req, meta) +} + +func (s *Service) AdminMarkAbnormal(adminID uint64, id uint64, req AdminActionRequest, meta AuditMeta) (*ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if req.Reason == "" { + return nil, ErrInvalidInput + } + return s.repo.AdminMarkAbnormal(adminID, id, req, meta) +} + func (s *Service) Approve(id uint64) (*ListingDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 7c0ad1d..9cfd5db 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -45,6 +45,15 @@ type SubmitReturnRequest struct { Content string `json:"content" binding:"required"` } +type AdminActionRequest struct { + Reason string `json:"reason" binding:"required"` +} + +type AuditMeta struct { + IP string + UserAgent string +} + type HandoffRecordDTO 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 833a57d..c6bce2a 100644 --- a/backend/internal/modules/order/handler.go +++ b/backend/internal/modules/order/handler.go @@ -87,6 +87,36 @@ func (h *Handler) AdminHandoffRecords(c *gin.Context) { response.OK(c, gin.H{"items": items}) } +func (h *Handler) AdminClose(c *gin.Context) { + h.adminAction(c, h.service.AdminClose, gin.H{"closed": true}) +} + +func (h *Handler) AdminMarkAbnormal(c *gin.Context) { + h.adminAction(c, h.service.AdminMarkAbnormal, gin.H{"abnormal": true}) +} + +func (h *Handler) adminAction(c *gin.Context, fn func(uint64, uint64, AdminActionRequest, AuditMeta) error, okData gin.H) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + var req AdminActionRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "操作原因不能为空") + return + } + if err := fn(adminID, id, req, AuditMeta{IP: c.ClientIP(), UserAgent: c.GetHeader("User-Agent")}); err != nil { + writeOrderError(c, err) + return + } + response.OK(c, okData) +} + func (h *Handler) Detail(c *gin.Context) { userID, ok := currentUserID(c) if !ok { @@ -229,6 +259,15 @@ func currentUserID(c *gin.Context) (uint64, bool) { return userID, ok } +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 { diff --git a/backend/internal/modules/order/repository.go b/backend/internal/modules/order/repository.go index 7808eb5..9a635d0 100644 --- a/backend/internal/modules/order/repository.go +++ b/backend/internal/modules/order/repository.go @@ -503,6 +503,152 @@ func (r *Repository) HandoffRecordsAdmin(orderID uint64) ([]HandoffRecordDTO, er return items, nil } +func (r *Repository) AdminClose(adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error { + return r.db.Transaction(func(tx *gorm.DB) error { + order, listing, account, err := r.findOrderAssetsForAdminUpdate(tx, orderID) + if err != nil { + return err + } + if isTerminalStatus(order.Status) { + return ErrOrderCannotComplete + } + beforeOrderStatus := order.Status + beforeHandoffStatus := order.HandoffStatus + beforeSettlementStatus := order.SettlementStatus + beforeListingStatus := listing.Status + beforeAccountStatus := account.Status + now := time.Now() + order.Status = "closed" + order.HandoffStatus = "admin_closed" + order.SettlementStatus = "closed" + order.SettledAt = &now + listing.Status = "offline" + account.Status = "offline" + if err := wallet.AppendEntries(tx, wallet.Entry{ + UserID: order.RenterID, + OrderID: &order.ID, + Direction: "out", + Amount: order.RentAmount + order.DepositAmount, + BalanceType: "frozen", + BizType: "admin_order_close", + BizNo: order.OrderNo, + Remark: "后台关闭订单释放模拟冻结金额", + }); err != nil { + return err + } + if err := notification.Append(tx, + notification.Entry{ + UserID: order.RenterID, + Type: "order_admin", + Title: "订单已由客服关闭", + Content: "客服已关闭订单,模拟冻结金额已释放。原因:" + req.Reason, + BizType: "order", + BizID: &order.ID, + }, + notification.Entry{ + UserID: order.OwnerID, + Type: "order_admin", + Title: "订单已由客服关闭", + Content: "客服已关闭订单,关联商品已下架。原因:" + req.Reason, + BizType: "order", + BizID: &order.ID, + }, + ); err != nil { + return err + } + if err := appendAuditLog(tx, adminID, "order.admin_close", "order", order.ID, meta, map[string]any{ + "order_id": order.ID, + "order_no": order.OrderNo, + "listing_id": order.ListingID, + "account_id": order.AccountID, + "reason": req.Reason, + "before_order_status": beforeOrderStatus, + "after_order_status": order.Status, + "before_handoff_status": beforeHandoffStatus, + "after_handoff_status": order.HandoffStatus, + "before_settlement_status": beforeSettlementStatus, + "after_settlement_status": order.SettlementStatus, + "before_listing_status": beforeListingStatus, + "after_listing_status": listing.Status, + "before_account_status": beforeAccountStatus, + "after_account_status": account.Status, + }); err != nil { + return err + } + if err := tx.Save(order).Error; err != nil { + return err + } + if err := tx.Save(listing).Error; err != nil { + return err + } + return tx.Save(account).Error + }) +} + +func (r *Repository) AdminMarkAbnormal(adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error { + return r.db.Transaction(func(tx *gorm.DB) error { + order, listing, account, err := r.findOrderAssetsForAdminUpdate(tx, orderID) + if err != nil { + return err + } + if isTerminalStatus(order.Status) { + return ErrOrderCannotComplete + } + beforeOrderStatus := order.Status + beforeHandoffStatus := order.HandoffStatus + beforeListingStatus := listing.Status + beforeAccountStatus := account.Status + order.Status = "abnormal" + order.HandoffStatus = "admin_abnormal" + listing.Status = "abnormal" + account.Status = "abnormal" + if err := notification.Append(tx, + notification.Entry{ + UserID: order.RenterID, + Type: "order_admin", + Title: "订单已被标记异常", + Content: "客服已将订单标记为异常,请等待进一步处理。原因:" + req.Reason, + BizType: "order", + BizID: &order.ID, + }, + notification.Entry{ + UserID: order.OwnerID, + Type: "order_admin", + Title: "订单已被标记异常", + Content: "客服已将订单标记为异常,关联商品暂不可出租。原因:" + req.Reason, + BizType: "order", + BizID: &order.ID, + }, + ); err != nil { + return err + } + if err := appendAuditLog(tx, adminID, "order.mark_abnormal", "order", order.ID, meta, map[string]any{ + "order_id": order.ID, + "order_no": order.OrderNo, + "listing_id": order.ListingID, + "account_id": order.AccountID, + "reason": req.Reason, + "before_order_status": beforeOrderStatus, + "after_order_status": order.Status, + "before_handoff_status": beforeHandoffStatus, + "after_handoff_status": order.HandoffStatus, + "before_listing_status": beforeListingStatus, + "after_listing_status": listing.Status, + "before_account_status": beforeAccountStatus, + "after_account_status": account.Status, + }); err != nil { + return err + } + if err := tx.Save(order).Error; err != nil { + return err + } + if err := tx.Save(listing).Error; err != nil { + return err + } + return tx.Save(account).Error + }) +} + func (r *Repository) FindForUser(userID uint64, orderID uint64) (*OrderDTO, error) { var row orderRow if err := r.baseQuery(). @@ -514,6 +660,26 @@ func (r *Repository) FindForUser(userID uint64, orderID uint64) (*OrderDTO, erro return &dto, nil } +func (r *Repository) findOrderAssetsForAdminUpdate(tx *gorm.DB, orderID uint64) (*model.RentalOrder, *model.RentalListing, *model.GameAccount, error) { + var order model.RentalOrder + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil { + return nil, nil, nil, err + } + var listing model.RentalListing + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil { + return nil, nil, nil, err + } + var account model.GameAccount + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil { + return nil, nil, nil, err + } + return &order, &listing, &account, nil +} + +func isTerminalStatus(status string) bool { + return status == "completed" || status == "cancelled" || status == "closed" +} + func (r *Repository) findHandoffRecord(id uint64) (*HandoffRecordDTO, error) { var record model.HandoffRecord if err := r.db.First(&record, id).Error; err != nil { @@ -614,6 +780,24 @@ func newOrderNo() (string, error) { return "RO" + strconv.FormatInt(time.Now().UnixNano(), 10) + hex.EncodeToString(buf), nil } +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) + if err != nil { + return err + } + row := model.AuditLog{ + ActorType: "admin", + ActorID: actorID, + Action: action, + BizType: bizType, + 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/order/service.go b/backend/internal/modules/order/service.go index 17c7dc5..cb29554 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -109,6 +109,26 @@ func (s *Service) HandoffRecordsAdmin(orderID uint64) ([]HandoffRecordDTO, error return s.repo.HandoffRecordsAdmin(orderID) } +func (s *Service) AdminClose(adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + if orderID == 0 || req.Reason == "" { + return ErrOrderCannotComplete + } + return s.repo.AdminClose(adminID, orderID, req, meta) +} + +func (s *Service) AdminMarkAbnormal(adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + if orderID == 0 || req.Reason == "" { + return ErrOrderCannotComplete + } + return s.repo.AdminMarkAbnormal(adminID, orderID, req, meta) +} + func (s *Service) FindForUser(userID uint64, orderID uint64) (*OrderDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index d1ed720..86fa827 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -197,10 +197,15 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/orders", orderHandler.AdminList) adminRoutes.GET("/orders/:id", orderHandler.AdminDetail) adminRoutes.GET("/orders/:id/handoff-records", orderHandler.AdminHandoffRecords) + adminRoutes.POST("/orders/:id/close", orderHandler.AdminClose) + adminRoutes.POST("/orders/:id/mark-abnormal", orderHandler.AdminMarkAbnormal) adminRoutes.GET("/listings", listingHandler.ListAdmin) adminRoutes.GET("/listings/pending", listingHandler.ListPendingReview) + adminRoutes.GET("/listings/:id", listingHandler.FindAdmin) adminRoutes.POST("/listings/:id/approve", listingHandler.Approve) adminRoutes.POST("/listings/:id/reject", listingHandler.Reject) + adminRoutes.POST("/listings/:id/offline", listingHandler.AdminOffline) + adminRoutes.POST("/listings/:id/mark-abnormal", listingHandler.AdminMarkAbnormal) adminRoutes.GET("/disputes", disputeHandler.AdminList) adminRoutes.POST("/disputes/:id/arbitrate", disputeHandler.AdminArbitrate) adminRoutes.GET("/wallet/ledger", walletHandler.AdminLedger) diff --git a/docs/api.md b/docs/api.md index 7908cc8..d260099 100644 --- a/docs/api.md +++ b/docs/api.md @@ -59,8 +59,13 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。 - `GET /api/admin/orders/{id}/handoff-records` - `GET /api/admin/listings` - `GET /api/admin/listings/pending` +- `GET /api/admin/listings/{id}` - `POST /api/admin/listings/{id}/approve` - `POST /api/admin/listings/{id}/reject` +- `POST /api/admin/listings/{id}/offline` +- `POST /api/admin/listings/{id}/mark-abnormal` +- `POST /api/admin/orders/{id}/close` +- `POST /api/admin/orders/{id}/mark-abnormal` - `GET /api/admin/disputes` - `POST /api/admin/disputes/{id}/arbitrate` - `GET /api/admin/wallet/ledger` diff --git a/docs/business-rules.md b/docs/business-rules.md index c8b1e4a..16507f3 100644 --- a/docs/business-rules.md +++ b/docs/business-rules.md @@ -86,9 +86,14 @@ - 同一个订单同一时间只允许存在一个 `open` 或 `processing` 申诉。 - 发起申诉后订单状态变为 `disputing`,账号和发布保持锁定。 - 开发态后台仲裁接口为 `/api/admin/disputes`,当前只要求登录,后续接后台管理员和 RBAC。 -- 仲裁结果先只落状态和通知:全额退款、部分退款、关闭订单会将订单置为 `closed`;扣押金、释放押金、赔付号主会将订单置为 `completed`。 -- 仲裁完成后账号和发布恢复为 `published`。 -- 当前仲裁不做真实扣款、退款、赔付落账,后续接真实支付后再补资金流水和审计日志。 +- 仲裁会释放开发态模拟冻结金额,并根据结果生成钱包流水。 +- 全额退款:释放冻结金额后,将租金加押金作为可用余额退给租客,订单置为 `closed`。 +- 部分退款:需要填写退给租客的金额,剩余冻结金额结算给号主,订单置为 `closed`。 +- 释放押金:租金结算给号主,押金退给租客,订单置为 `completed`。 +- 扣押金/赔付号主:可填写从押金中赔付给号主的金额;不填默认处理全额押金,订单置为 `completed`。 +- 关闭订单:只释放冻结金额,不产生可用余额结算,订单置为 `closed`,关联商品和账号下架。 +- 仲裁完成会写入 `audit_logs`,记录裁决结果、金额、订单状态和资金处理。 +- 当前仍是开发态模拟账务,不代表真实退款、扣款或提现;接入真实支付后需要替换为支付渠道退款、分账和对账逻辑。 ## 开发态系统配置 @@ -127,14 +132,28 @@ - 订单管理接口为 `/api/admin/orders`,前端页面为 `/admin/orders`。 - 后台可查看全量订单、租客、号主、订单状态、交接状态、结算状态、租金和押金。 - 订单详情页 `/admin/orders/:id` 展示订单状态、双方用户、租期、交接记录和账号资产快照。 -- 当前后台订单管理先做只读能力,后续再补后台关闭订单、标记异常和客服介入操作。 +- 高风险客服操作集中放在订单详情页处理,必须填写原因并写入审计日志。 ## 开发态商品管理 - 商品管理接口为 `/api/admin/listings`,前端页面为 `/admin/listings`。 - 后台可查看全量商品、号主、账号 ID、区服、平台、段位、哈夫币、时租、押金、商品状态和审核状态。 - 当前支持按号主 ID、商品状态、审核状态和查询条数筛选。 -- 商品管理先做只读能力,强制下架、标记异常和后台改价等高风险操作后续接 RBAC 和审计后再做。 +- 商品详情页为 `/admin/listings/:id`,支持查看完整账号、价格、租期和审核信息。 +- 后台可对非租赁中的商品执行强制下架和标记异常。 +- 强制下架会将商品和账号状态改为 `offline`,标记异常会将商品和账号状态改为 `abnormal`。 +- 强制下架和标记异常必须填写原因,写入 `audit_logs`,并通知号主。 +- 租赁中的商品暂不允许直接下架或标记异常,需要先处理关联订单。 + +## 开发态订单客服操作 + +- 订单详情页 `/admin/orders/:id` 支持客服关闭订单和标记异常。 +- 已完成、已取消、已关闭订单为终态,不允许再次客服关闭或标记异常。 +- 客服关闭订单会将订单状态改为 `closed`,交接状态改为 `admin_closed`,结算状态改为 `closed`。 +- 客服关闭订单会释放开发态模拟冻结金额,并将关联商品和账号下架为 `offline`。 +- 标记异常会将订单状态改为 `abnormal`,交接状态改为 `admin_abnormal`,关联商品和账号改为 `abnormal`。 +- 客服关闭和标记异常必须填写原因,写入 `audit_logs`,并通知租客和号主。 +- 当前不处理真实退款、扣款或赔付,真实支付接入后需要补充资金裁决逻辑。 ## 开发态审计日志 diff --git a/frontend/src/api/disputes.ts b/frontend/src/api/disputes.ts index a0b7dc9..b71df81 100644 --- a/frontend/src/api/disputes.ts +++ b/frontend/src/api/disputes.ts @@ -40,7 +40,7 @@ export async function fetchAdminDisputes() { return data.data.items } -export async function arbitrateDispute(id: number, payload: { result: string; remark: string }) { +export async function arbitrateDispute(id: number, payload: { result: string; remark: string; amount?: number }) { const { data } = await apiClient.post>(`/admin/disputes/${id}/arbitrate`, payload) return data.data } diff --git a/frontend/src/api/listings.ts b/frontend/src/api/listings.ts index de3f3c2..f3c995b 100644 --- a/frontend/src/api/listings.ts +++ b/frontend/src/api/listings.ts @@ -96,6 +96,21 @@ export async function fetchAdminListings(query: AdminListingQuery = {}) { return data.data.items } +export async function fetchAdminListing(id: string | number) { + 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 +} + +export async function adminMarkListingAbnormal(id: number, reason: string) { + 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 diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts index d83f3f1..4102fde 100644 --- a/frontend/src/api/orders.ts +++ b/frontend/src/api/orders.ts @@ -92,6 +92,16 @@ export async function fetchAdminHandoffRecords(id: string | number) { 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 +} + +export async function adminMarkOrderAbnormal(id: number, reason: string) { + 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 diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index aa71bc3..985ab31 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -49,6 +49,12 @@ const router = createRouter({ component: () => import('@/views/admin/AdminListingsView.vue'), meta: { layout: 'admin', requiresAdmin: true }, }, + { + path: '/admin/listings/:id', + name: 'admin-listing-detail', + component: () => import('@/views/admin/AdminListingDetailView.vue'), + meta: { layout: 'admin', requiresAdmin: true }, + }, { path: '/admin/listings/review', name: 'admin-listing-review', diff --git a/frontend/src/views/admin/AdminAuditLogsView.vue b/frontend/src/views/admin/AdminAuditLogsView.vue index 9a7e41e..d6c9058 100644 --- a/frontend/src/views/admin/AdminAuditLogsView.vue +++ b/frontend/src/views/admin/AdminAuditLogsView.vue @@ -89,6 +89,11 @@ function actionType(action: string) { + + + + + @@ -96,6 +101,9 @@ function actionType(action: string) { + + + diff --git a/frontend/src/views/admin/AdminDisputesView.vue b/frontend/src/views/admin/AdminDisputesView.vue index 329339b..0bdd582 100644 --- a/frontend/src/views/admin/AdminDisputesView.vue +++ b/frontend/src/views/admin/AdminDisputesView.vue @@ -10,6 +10,7 @@ const disputes = ref([]) const activeDispute = ref(null) const result = ref('release_deposit') const remark = ref('') +const amount = ref() onMounted(loadDisputes) @@ -26,6 +27,7 @@ function openArbitration(row: Dispute) { activeDispute.value = row result.value = row.arbitration_result || 'release_deposit' remark.value = row.arbitration_remark || '' + amount.value = undefined } async function handleArbitrate() { @@ -35,6 +37,7 @@ async function handleArbitrate() { await arbitrateDispute(activeDispute.value.id, { result: result.value, remark: remark.value, + amount: amount.value, }) ElMessage.success('仲裁结果已保存,双方已收到通知') activeDispute.value = null @@ -90,6 +93,17 @@ function readError(error: unknown, fallback: string) { + +

部分退款金额表示退给租客的金额,剩余冻结金额结算给号主。

+

金额表示从押金中赔付给号主的部分;不填则默认处理全额押金。