feat: add admin risk actions and arbitration settlement
This commit is contained in:
@@ -36,13 +36,14 @@ npm run dev
|
|||||||
- 钱包账务当前为开发态模拟流水,可通过 `GET /api/wallet/balance` 和 `GET /api/wallet/ledger` 查看。
|
- 钱包账务当前为开发态模拟流水,可通过 `GET /api/wallet/balance` 和 `GET /api/wallet/ledger` 查看。
|
||||||
- 后台资金流水已接入,页面为 `http://localhost:5173/admin/wallet-ledger`,支持按用户、订单和业务类型查询。
|
- 后台资金流水已接入,页面为 `http://localhost:5173/admin/wallet-ledger`,支持按用户、订单和业务类型查询。
|
||||||
- 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。
|
- 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。
|
||||||
- 申诉仲裁已支持订单双方发起申诉、开发态后台处理,后台页面为 `http://localhost:5173/admin/disputes`。
|
- 申诉仲裁已支持订单双方发起申诉、开发态后台落账处理,后台页面为 `http://localhost:5173/admin/disputes`。
|
||||||
- 系统配置已支持默认配置初始化和后台编辑,页面为 `http://localhost:5173/admin/system-configs`,更新会写入审计日志。
|
- 系统配置已支持默认配置初始化和后台编辑,页面为 `http://localhost:5173/admin/system-configs`,更新会写入审计日志。
|
||||||
- 后台已使用独立登录、图形验证码和独立管理 UI,页面为 `http://localhost:5173/admin/login`;开发态默认管理员为 `admin / admin123456`。
|
- 后台已使用独立登录、图形验证码和独立管理 UI,页面为 `http://localhost:5173/admin/login`;开发态默认管理员为 `admin / admin123456`。
|
||||||
- 后台仪表盘已接入真实统计数据,页面为 `http://localhost:5173/admin/dashboard`。
|
- 后台仪表盘已接入真实统计数据,页面为 `http://localhost:5173/admin/dashboard`。
|
||||||
- 用户管理后台已接入,页面为 `http://localhost:5173/admin/users`,支持冻结和解冻用户。
|
- 用户管理后台已接入,页面为 `http://localhost:5173/admin/users`,支持冻结和解冻用户。
|
||||||
- 订单管理后台已接入,页面为 `http://localhost:5173/admin/orders`,支持查看全量订单和交接记录。
|
- 订单管理后台已接入,页面为 `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/listings/review`。
|
||||||
- 审计日志后台已接入,页面为 `http://localhost:5173/admin/audit-logs`,支持查看高风险操作明细。
|
- 审计日志后台已接入,页面为 `http://localhost:5173/admin/audit-logs`,支持查看高风险操作明细。
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ type CreateRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ArbitrateRequest struct {
|
type ArbitrateRequest struct {
|
||||||
Result string `json:"result" binding:"required"`
|
Result string `json:"result" binding:"required"`
|
||||||
Remark string `json:"remark" binding:"required"`
|
Remark string `json:"remark" binding:"required"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditMeta struct {
|
||||||
|
IP string
|
||||||
|
UserAgent string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,10 @@ func (h *Handler) AdminArbitrate(c *gin.Context) {
|
|||||||
response.BadRequest(c, "仲裁结果和备注不能为空")
|
response.BadRequest(c, "仲裁结果和备注不能为空")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeDisputeError(c, err)
|
writeDisputeError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
"hfb_sys/backend/internal/modules/notification"
|
"hfb_sys/backend/internal/modules/notification"
|
||||||
|
"hfb_sys/backend/internal/modules/wallet"
|
||||||
|
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -130,7 +131,7 @@ func (r *Repository) ListAdmin() ([]DisputeDTO, error) {
|
|||||||
return toDTOs(rows), nil
|
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 {
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
var row model.Dispute
|
var row model.Dispute
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
|
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 {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil {
|
||||||
return err
|
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()
|
now := time.Now()
|
||||||
row.Status = "resolved"
|
row.Status = "resolved"
|
||||||
row.ArbitrationResult = req.Result
|
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" {
|
if order.HandoffStatus != "cancelled" && order.HandoffStatus != "returned" {
|
||||||
order.HandoffStatus = "arbitrated"
|
order.HandoffStatus = "arbitrated"
|
||||||
}
|
}
|
||||||
listing.Status = "published"
|
if req.Result == "order_close" {
|
||||||
account.Status = "published"
|
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 {
|
if err := tx.Save(&row).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -179,6 +197,29 @@ func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
disputeID := row.ID
|
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,
|
if err := notification.Append(tx,
|
||||||
notification.Entry{
|
notification.Entry{
|
||||||
UserID: order.RenterID,
|
UserID: order.RenterID,
|
||||||
@@ -212,6 +253,94 @@ func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest)
|
|||||||
return &dto, nil
|
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 {
|
func (r *Repository) baseQuery() *gorm.DB {
|
||||||
return r.db.Table("disputes AS d").
|
return r.db.Table("disputes AS d").
|
||||||
Select("d.*, o.order_no, a.title").
|
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 {
|
func IsNotFound(err error) bool {
|
||||||
return errors.Is(err, gorm.ErrRecordNotFound)
|
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,12 +49,12 @@ func (s *Service) ListAdmin() ([]DisputeDTO, error) {
|
|||||||
return s.repo.ListAdmin()
|
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 {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
if id == 0 || req.Result == "" || req.Remark == "" {
|
if id == 0 || req.Result == "" || req.Remark == "" {
|
||||||
return nil, ErrInvalidDispute
|
return nil, ErrInvalidDispute
|
||||||
}
|
}
|
||||||
return s.repo.Arbitrate(adminID, id, req)
|
return s.repo.Arbitrate(adminID, id, req, meta)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,3 +56,12 @@ type AdminListQuery struct {
|
|||||||
ReviewStatus string
|
ReviewStatus string
|
||||||
Limit int
|
Limit int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AdminActionRequest struct {
|
||||||
|
Reason string `json:"reason" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditMeta struct {
|
||||||
|
IP string
|
||||||
|
UserAgent string
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,6 +101,50 @@ func (h *Handler) ListAdmin(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"items": items})
|
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) {
|
func (h *Handler) Approve(c *gin.Context) {
|
||||||
id, ok := parseID(c)
|
id, ok := parseID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -212,6 +256,15 @@ func currentUserID(c *gin.Context) (uint64, bool) {
|
|||||||
return userID, ok
|
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) {
|
func parseID(c *gin.Context) (uint64, bool) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package listing
|
package listing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
"hfb_sys/backend/internal/modules/notification"
|
"hfb_sys/backend/internal/modules/notification"
|
||||||
|
|
||||||
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
@@ -162,6 +164,73 @@ func (r *Repository) ListAdmin(query AdminListQuery) ([]ListingDTO, error) {
|
|||||||
return rowsToDTO(rows), nil
|
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) {
|
func (r *Repository) Approve(listingID uint64) (*ListingDTO, error) {
|
||||||
var dto *ListingDTO
|
var dto *ListingDTO
|
||||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
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 {
|
func IsNotFound(err error) bool {
|
||||||
return errors.Is(err, gorm.ErrRecordNotFound)
|
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,33 @@ func (s *Service) ListAdmin(query AdminListQuery) ([]ListingDTO, error) {
|
|||||||
return s.repo.ListAdmin(query)
|
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) {
|
func (s *Service) Approve(id uint64) (*ListingDTO, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
|
|||||||
@@ -45,6 +45,15 @@ type SubmitReturnRequest struct {
|
|||||||
Content string `json:"content" binding:"required"`
|
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 {
|
type HandoffRecordDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
OrderID uint64 `json:"order_id"`
|
OrderID uint64 `json:"order_id"`
|
||||||
|
|||||||
@@ -87,6 +87,36 @@ func (h *Handler) AdminHandoffRecords(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"items": items})
|
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) {
|
func (h *Handler) Detail(c *gin.Context) {
|
||||||
userID, ok := currentUserID(c)
|
userID, ok := currentUserID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -229,6 +259,15 @@ func currentUserID(c *gin.Context) (uint64, bool) {
|
|||||||
return userID, ok
|
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) {
|
func parseID(c *gin.Context) (uint64, bool) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
|
|||||||
@@ -503,6 +503,152 @@ func (r *Repository) HandoffRecordsAdmin(orderID uint64) ([]HandoffRecordDTO, er
|
|||||||
return items, nil
|
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) {
|
func (r *Repository) FindForUser(userID uint64, orderID uint64) (*OrderDTO, error) {
|
||||||
var row orderRow
|
var row orderRow
|
||||||
if err := r.baseQuery().
|
if err := r.baseQuery().
|
||||||
@@ -514,6 +660,26 @@ func (r *Repository) FindForUser(userID uint64, orderID uint64) (*OrderDTO, erro
|
|||||||
return &dto, nil
|
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) {
|
func (r *Repository) findHandoffRecord(id uint64) (*HandoffRecordDTO, error) {
|
||||||
var record model.HandoffRecord
|
var record model.HandoffRecord
|
||||||
if err := r.db.First(&record, id).Error; err != nil {
|
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
|
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 {
|
func IsNotFound(err error) bool {
|
||||||
return errors.Is(err, gorm.ErrRecordNotFound)
|
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,26 @@ func (s *Service) HandoffRecordsAdmin(orderID uint64) ([]HandoffRecordDTO, error
|
|||||||
return s.repo.HandoffRecordsAdmin(orderID)
|
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) {
|
func (s *Service) FindForUser(userID uint64, orderID uint64) (*OrderDTO, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
|
|||||||
@@ -197,10 +197,15 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
adminRoutes.GET("/orders", orderHandler.AdminList)
|
adminRoutes.GET("/orders", orderHandler.AdminList)
|
||||||
adminRoutes.GET("/orders/:id", orderHandler.AdminDetail)
|
adminRoutes.GET("/orders/:id", orderHandler.AdminDetail)
|
||||||
adminRoutes.GET("/orders/:id/handoff-records", orderHandler.AdminHandoffRecords)
|
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", listingHandler.ListAdmin)
|
||||||
adminRoutes.GET("/listings/pending", listingHandler.ListPendingReview)
|
adminRoutes.GET("/listings/pending", listingHandler.ListPendingReview)
|
||||||
|
adminRoutes.GET("/listings/:id", listingHandler.FindAdmin)
|
||||||
adminRoutes.POST("/listings/:id/approve", listingHandler.Approve)
|
adminRoutes.POST("/listings/:id/approve", listingHandler.Approve)
|
||||||
adminRoutes.POST("/listings/:id/reject", listingHandler.Reject)
|
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.GET("/disputes", disputeHandler.AdminList)
|
||||||
adminRoutes.POST("/disputes/:id/arbitrate", disputeHandler.AdminArbitrate)
|
adminRoutes.POST("/disputes/:id/arbitrate", disputeHandler.AdminArbitrate)
|
||||||
adminRoutes.GET("/wallet/ledger", walletHandler.AdminLedger)
|
adminRoutes.GET("/wallet/ledger", walletHandler.AdminLedger)
|
||||||
|
|||||||
@@ -59,8 +59,13 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。
|
|||||||
- `GET /api/admin/orders/{id}/handoff-records`
|
- `GET /api/admin/orders/{id}/handoff-records`
|
||||||
- `GET /api/admin/listings`
|
- `GET /api/admin/listings`
|
||||||
- `GET /api/admin/listings/pending`
|
- `GET /api/admin/listings/pending`
|
||||||
|
- `GET /api/admin/listings/{id}`
|
||||||
- `POST /api/admin/listings/{id}/approve`
|
- `POST /api/admin/listings/{id}/approve`
|
||||||
- `POST /api/admin/listings/{id}/reject`
|
- `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`
|
- `GET /api/admin/disputes`
|
||||||
- `POST /api/admin/disputes/{id}/arbitrate`
|
- `POST /api/admin/disputes/{id}/arbitrate`
|
||||||
- `GET /api/admin/wallet/ledger`
|
- `GET /api/admin/wallet/ledger`
|
||||||
|
|||||||
+24
-5
@@ -86,9 +86,14 @@
|
|||||||
- 同一个订单同一时间只允许存在一个 `open` 或 `processing` 申诉。
|
- 同一个订单同一时间只允许存在一个 `open` 或 `processing` 申诉。
|
||||||
- 发起申诉后订单状态变为 `disputing`,账号和发布保持锁定。
|
- 发起申诉后订单状态变为 `disputing`,账号和发布保持锁定。
|
||||||
- 开发态后台仲裁接口为 `/api/admin/disputes`,当前只要求登录,后续接后台管理员和 RBAC。
|
- 开发态后台仲裁接口为 `/api/admin/disputes`,当前只要求登录,后续接后台管理员和 RBAC。
|
||||||
- 仲裁结果先只落状态和通知:全额退款、部分退款、关闭订单会将订单置为 `closed`;扣押金、释放押金、赔付号主会将订单置为 `completed`。
|
- 仲裁会释放开发态模拟冻结金额,并根据结果生成钱包流水。
|
||||||
- 仲裁完成后账号和发布恢复为 `published`。
|
- 全额退款:释放冻结金额后,将租金加押金作为可用余额退给租客,订单置为 `closed`。
|
||||||
- 当前仲裁不做真实扣款、退款、赔付落账,后续接真实支付后再补资金流水和审计日志。
|
- 部分退款:需要填写退给租客的金额,剩余冻结金额结算给号主,订单置为 `closed`。
|
||||||
|
- 释放押金:租金结算给号主,押金退给租客,订单置为 `completed`。
|
||||||
|
- 扣押金/赔付号主:可填写从押金中赔付给号主的金额;不填默认处理全额押金,订单置为 `completed`。
|
||||||
|
- 关闭订单:只释放冻结金额,不产生可用余额结算,订单置为 `closed`,关联商品和账号下架。
|
||||||
|
- 仲裁完成会写入 `audit_logs`,记录裁决结果、金额、订单状态和资金处理。
|
||||||
|
- 当前仍是开发态模拟账务,不代表真实退款、扣款或提现;接入真实支付后需要替换为支付渠道退款、分账和对账逻辑。
|
||||||
|
|
||||||
## 开发态系统配置
|
## 开发态系统配置
|
||||||
|
|
||||||
@@ -127,14 +132,28 @@
|
|||||||
- 订单管理接口为 `/api/admin/orders`,前端页面为 `/admin/orders`。
|
- 订单管理接口为 `/api/admin/orders`,前端页面为 `/admin/orders`。
|
||||||
- 后台可查看全量订单、租客、号主、订单状态、交接状态、结算状态、租金和押金。
|
- 后台可查看全量订单、租客、号主、订单状态、交接状态、结算状态、租金和押金。
|
||||||
- 订单详情页 `/admin/orders/:id` 展示订单状态、双方用户、租期、交接记录和账号资产快照。
|
- 订单详情页 `/admin/orders/:id` 展示订单状态、双方用户、租期、交接记录和账号资产快照。
|
||||||
- 当前后台订单管理先做只读能力,后续再补后台关闭订单、标记异常和客服介入操作。
|
- 高风险客服操作集中放在订单详情页处理,必须填写原因并写入审计日志。
|
||||||
|
|
||||||
## 开发态商品管理
|
## 开发态商品管理
|
||||||
|
|
||||||
- 商品管理接口为 `/api/admin/listings`,前端页面为 `/admin/listings`。
|
- 商品管理接口为 `/api/admin/listings`,前端页面为 `/admin/listings`。
|
||||||
- 后台可查看全量商品、号主、账号 ID、区服、平台、段位、哈夫币、时租、押金、商品状态和审核状态。
|
- 后台可查看全量商品、号主、账号 ID、区服、平台、段位、哈夫币、时租、押金、商品状态和审核状态。
|
||||||
- 当前支持按号主 ID、商品状态、审核状态和查询条数筛选。
|
- 当前支持按号主 ID、商品状态、审核状态和查询条数筛选。
|
||||||
- 商品管理先做只读能力,强制下架、标记异常和后台改价等高风险操作后续接 RBAC 和审计后再做。
|
- 商品详情页为 `/admin/listings/:id`,支持查看完整账号、价格、租期和审核信息。
|
||||||
|
- 后台可对非租赁中的商品执行强制下架和标记异常。
|
||||||
|
- 强制下架会将商品和账号状态改为 `offline`,标记异常会将商品和账号状态改为 `abnormal`。
|
||||||
|
- 强制下架和标记异常必须填写原因,写入 `audit_logs`,并通知号主。
|
||||||
|
- 租赁中的商品暂不允许直接下架或标记异常,需要先处理关联订单。
|
||||||
|
|
||||||
|
## 开发态订单客服操作
|
||||||
|
|
||||||
|
- 订单详情页 `/admin/orders/:id` 支持客服关闭订单和标记异常。
|
||||||
|
- 已完成、已取消、已关闭订单为终态,不允许再次客服关闭或标记异常。
|
||||||
|
- 客服关闭订单会将订单状态改为 `closed`,交接状态改为 `admin_closed`,结算状态改为 `closed`。
|
||||||
|
- 客服关闭订单会释放开发态模拟冻结金额,并将关联商品和账号下架为 `offline`。
|
||||||
|
- 标记异常会将订单状态改为 `abnormal`,交接状态改为 `admin_abnormal`,关联商品和账号改为 `abnormal`。
|
||||||
|
- 客服关闭和标记异常必须填写原因,写入 `audit_logs`,并通知租客和号主。
|
||||||
|
- 当前不处理真实退款、扣款或赔付,真实支付接入后需要补充资金裁决逻辑。
|
||||||
|
|
||||||
## 开发态审计日志
|
## 开发态审计日志
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export async function fetchAdminDisputes() {
|
|||||||
return data.data.items
|
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<ApiResponse<Dispute>>(`/admin/disputes/${id}/arbitrate`, payload)
|
const { data } = await apiClient.post<ApiResponse<Dispute>>(`/admin/disputes/${id}/arbitrate`, payload)
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,21 @@ export async function fetchAdminListings(query: AdminListingQuery = {}) {
|
|||||||
return data.data.items
|
return data.data.items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminListing(id: string | number) {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<Listing>>(`/admin/listings/${id}`)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminOfflineListing(id: number, reason: string) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/offline`, { reason })
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminMarkListingAbnormal(id: number, reason: string) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/mark-abnormal`, { reason })
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function approveListing(id: number) {
|
export async function approveListing(id: number) {
|
||||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/approve`)
|
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/approve`)
|
||||||
return data.data
|
return data.data
|
||||||
|
|||||||
@@ -92,6 +92,16 @@ export async function fetchAdminHandoffRecords(id: string | number) {
|
|||||||
return data.data.items
|
return data.data.items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function adminCloseOrder(id: number, reason: string) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<{ closed: boolean }>>(`/admin/orders/${id}/close`, { reason })
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminMarkOrderAbnormal(id: number, reason: string) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<{ abnormal: boolean }>>(`/admin/orders/${id}/mark-abnormal`, { reason })
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function confirmReceive(id: number) {
|
export async function confirmReceive(id: number) {
|
||||||
const { data } = await apiClient.post<ApiResponse<{ received: boolean }>>(`/orders/${id}/confirm-receive`)
|
const { data } = await apiClient.post<ApiResponse<{ received: boolean }>>(`/orders/${id}/confirm-receive`)
|
||||||
return data.data
|
return data.data
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/admin/AdminListingsView.vue'),
|
component: () => import('@/views/admin/AdminListingsView.vue'),
|
||||||
meta: { layout: 'admin', requiresAdmin: true },
|
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',
|
path: '/admin/listings/review',
|
||||||
name: 'admin-listing-review',
|
name: 'admin-listing-review',
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ function actionType(action: string) {
|
|||||||
<el-select v-model="filters.action" clearable filterable placeholder="全部动作" class="full-control">
|
<el-select v-model="filters.action" clearable filterable placeholder="全部动作" class="full-control">
|
||||||
<el-option label="冻结用户" value="admin_user.freeze" />
|
<el-option label="冻结用户" value="admin_user.freeze" />
|
||||||
<el-option label="解冻用户" value="admin_user.unfreeze" />
|
<el-option label="解冻用户" value="admin_user.unfreeze" />
|
||||||
|
<el-option label="后台下架商品" value="listing.admin_offline" />
|
||||||
|
<el-option label="商品标记异常" value="listing.mark_abnormal" />
|
||||||
|
<el-option label="客服关闭订单" value="order.admin_close" />
|
||||||
|
<el-option label="订单标记异常" value="order.mark_abnormal" />
|
||||||
|
<el-option label="申诉仲裁" value="dispute.arbitrate" />
|
||||||
<el-option label="更新系统配置" value="system_config.update" />
|
<el-option label="更新系统配置" value="system_config.update" />
|
||||||
<el-option label="创建系统配置" value="system_config.create" />
|
<el-option label="创建系统配置" value="system_config.create" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -96,6 +101,9 @@ function actionType(action: string) {
|
|||||||
<el-form-item label="业务类型">
|
<el-form-item label="业务类型">
|
||||||
<el-select v-model="filters.biz_type" clearable placeholder="全部业务" class="full-control">
|
<el-select v-model="filters.biz_type" clearable placeholder="全部业务" class="full-control">
|
||||||
<el-option label="用户" value="user" />
|
<el-option label="用户" value="user" />
|
||||||
|
<el-option label="商品" value="listing" />
|
||||||
|
<el-option label="订单" value="order" />
|
||||||
|
<el-option label="申诉" value="dispute" />
|
||||||
<el-option label="系统配置" value="system_config" />
|
<el-option label="系统配置" value="system_config" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const disputes = ref<Dispute[]>([])
|
|||||||
const activeDispute = ref<Dispute | null>(null)
|
const activeDispute = 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>()
|
||||||
|
|
||||||
onMounted(loadDisputes)
|
onMounted(loadDisputes)
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ function openArbitration(row: Dispute) {
|
|||||||
activeDispute.value = row
|
activeDispute.value = row
|
||||||
result.value = row.arbitration_result || 'release_deposit'
|
result.value = row.arbitration_result || 'release_deposit'
|
||||||
remark.value = row.arbitration_remark || ''
|
remark.value = row.arbitration_remark || ''
|
||||||
|
amount.value = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleArbitrate() {
|
async function handleArbitrate() {
|
||||||
@@ -35,6 +37,7 @@ async function handleArbitrate() {
|
|||||||
await arbitrateDispute(activeDispute.value.id, {
|
await arbitrateDispute(activeDispute.value.id, {
|
||||||
result: result.value,
|
result: result.value,
|
||||||
remark: remark.value,
|
remark: remark.value,
|
||||||
|
amount: amount.value,
|
||||||
})
|
})
|
||||||
ElMessage.success('仲裁结果已保存,双方已收到通知')
|
ElMessage.success('仲裁结果已保存,双方已收到通知')
|
||||||
activeDispute.value = null
|
activeDispute.value = null
|
||||||
@@ -90,6 +93,17 @@ function readError(error: unknown, fallback: string) {
|
|||||||
<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-select>
|
</el-select>
|
||||||
|
<el-input-number
|
||||||
|
v-if="['partial_refund', 'deduct_deposit', 'compensate_owner'].includes(result)"
|
||||||
|
v-model="amount"
|
||||||
|
class="full-control panel-action"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="10"
|
||||||
|
placeholder="裁决金额"
|
||||||
|
/>
|
||||||
|
<p v-if="result === 'partial_refund'">部分退款金额表示退给租客的金额,剩余冻结金额结算给号主。</p>
|
||||||
|
<p v-if="['deduct_deposit', 'compensate_owner'].includes(result)">金额表示从押金中赔付给号主的部分;不填则默认处理全额押金。</p>
|
||||||
<el-input v-model="remark" class="panel-action" type="textarea" :rows="4" placeholder="填写客服裁决说明" />
|
<el-input v-model="remark" class="panel-action" type="textarea" :rows="4" placeholder="填写客服裁决说明" />
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
import { adminMarkListingAbnormal, adminOfflineListing, fetchAdminListing, type Listing } from '@/api/listings'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const listing = ref<Listing | null>(null)
|
||||||
|
const actionType = ref<'offline' | 'abnormal' | ''>('')
|
||||||
|
const reason = ref('')
|
||||||
|
|
||||||
|
const actionTitle = computed(() => (actionType.value === 'offline' ? '强制下架商品' : '标记商品异常'))
|
||||||
|
const canOperate = computed(() => !!listing.value && listing.value.status !== 'rented' && !['offline', 'abnormal'].includes(listing.value.status))
|
||||||
|
|
||||||
|
onMounted(loadListing)
|
||||||
|
|
||||||
|
async function loadListing() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
listing.value = await fetchAdminListing(String(route.params.id))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAction(type: 'offline' | 'abnormal') {
|
||||||
|
actionType.value = type
|
||||||
|
reason.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAction() {
|
||||||
|
if (!listing.value || !actionType.value) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (actionType.value === 'offline') {
|
||||||
|
listing.value = await adminOfflineListing(listing.value.id, reason.value)
|
||||||
|
ElMessage.success('商品已强制下架')
|
||||||
|
} else {
|
||||||
|
listing.value = await adminMarkListingAbnormal(listing.value.id, reason.value)
|
||||||
|
ElMessage.success('商品已标记异常')
|
||||||
|
}
|
||||||
|
actionType.value = ''
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '操作失败'))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(value: number) {
|
||||||
|
return `¥${Number(value || 0).toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function readError(error: unknown, fallback: string) {
|
||||||
|
if (typeof error === 'object' && error && 'response' in error) {
|
||||||
|
const response = (error as { response?: { data?: { message?: string } } }).response
|
||||||
|
return response?.data?.message || fallback
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page" v-loading="loading">
|
||||||
|
<div v-if="listing" class="page-header-row">
|
||||||
|
<div class="page-header">
|
||||||
|
<p class="eyebrow">Listing #{{ listing.id }}</p>
|
||||||
|
<h1>商品详情</h1>
|
||||||
|
<p>{{ listing.title }} · {{ listing.server_region }} / {{ listing.login_platform }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<RouterLink to="/admin/listings">
|
||||||
|
<el-button>返回列表</el-button>
|
||||||
|
</RouterLink>
|
||||||
|
<el-button type="warning" :disabled="!canOperate" @click="openAction('offline')">强制下架</el-button>
|
||||||
|
<el-button type="danger" :disabled="!canOperate" @click="openAction('abnormal')">标记异常</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="listing" class="metric-grid dashboard-metrics">
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>商品状态</span>
|
||||||
|
<strong>{{ listing.status }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>审核状态</span>
|
||||||
|
<strong>{{ listing.review_status }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>时租</span>
|
||||||
|
<strong>{{ money(listing.price_hourly) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>押金</span>
|
||||||
|
<strong>{{ money(listing.deposit_amount) }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="listing" class="dashboard-panels">
|
||||||
|
<div class="order-panel dashboard-panel">
|
||||||
|
<h2>账号信息</h2>
|
||||||
|
<p>账号 ID:{{ listing.account_id }}</p>
|
||||||
|
<p>游戏:{{ listing.game_name }}</p>
|
||||||
|
<p>区服:{{ listing.server_region }}</p>
|
||||||
|
<p>平台:{{ listing.login_platform }}</p>
|
||||||
|
<p>段位:{{ listing.rank_level || '-' }}</p>
|
||||||
|
<p>哈夫币:{{ listing.haf_coin_amount }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="order-panel dashboard-panel">
|
||||||
|
<h2>号主与租期</h2>
|
||||||
|
<p>号主:{{ listing.owner_phone || listing.owner_nickname || listing.owner_id }}</p>
|
||||||
|
<p>号主 ID:{{ listing.owner_id }}</p>
|
||||||
|
<p>最短租期:{{ listing.min_rent_hours }} 小时</p>
|
||||||
|
<p>最长租期:{{ listing.max_rent_hours }} 小时</p>
|
||||||
|
<p>日租:{{ money(listing.price_daily) }}</p>
|
||||||
|
<p>周租:{{ money(listing.price_weekly) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="listing" class="order-panel dashboard-panel">
|
||||||
|
<h2>说明与审核原因</h2>
|
||||||
|
<p>{{ listing.description || '暂无商品说明' }}</p>
|
||||||
|
<p>审核/后台原因:{{ listing.review_reason || '-' }}</p>
|
||||||
|
<p>上架时间:{{ listing.published_at || '-' }}</p>
|
||||||
|
<p>更新时间:{{ listing.updated_at }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog :model-value="!!actionType" :title="actionTitle" width="560px" @update:model-value="actionType = ''">
|
||||||
|
<div v-if="listing" class="dialog-body">
|
||||||
|
<p><strong>{{ listing.title }}</strong></p>
|
||||||
|
<el-input v-model="reason" type="textarea" :rows="4" placeholder="填写后台操作原因,会写入审计日志并通知号主" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="actionType = ''">取消</el-button>
|
||||||
|
<el-button type="danger" :loading="submitting" @click="submitAction">确认操作</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -102,6 +102,7 @@ function reviewType(status: string) {
|
|||||||
<el-option label="已上架" value="published" />
|
<el-option label="已上架" value="published" />
|
||||||
<el-option label="租赁中" value="rented" />
|
<el-option label="租赁中" value="rented" />
|
||||||
<el-option label="已下架" value="offline" />
|
<el-option label="已下架" value="offline" />
|
||||||
|
<el-option label="异常" value="abnormal" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="审核状态">
|
<el-form-item label="审核状态">
|
||||||
@@ -155,10 +156,9 @@ function reviewType(status: string) {
|
|||||||
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
||||||
<el-table-column label="操作" width="110">
|
<el-table-column label="操作" width="110">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<RouterLink v-if="row.status === 'published' && row.review_status === 'approved'" :to="`/listings/${row.id}`">
|
<RouterLink :to="`/admin/listings/${row.id}`">
|
||||||
<el-button size="small">前台详情</el-button>
|
<el-button size="small">详情</el-button>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<span v-else>-</span>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import { fetchAdminHandoffRecords, fetchAdminOrder, type HandoffRecord, type Order } from '@/api/orders'
|
import { adminCloseOrder, adminMarkOrderAbnormal, fetchAdminHandoffRecords, fetchAdminOrder, type HandoffRecord, type Order } from '@/api/orders'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
const order = ref<Order | null>(null)
|
const order = ref<Order | null>(null)
|
||||||
const handoffRecords = ref<HandoffRecord[]>([])
|
const handoffRecords = ref<HandoffRecord[]>([])
|
||||||
|
const actionType = ref<'close' | 'abnormal' | ''>('')
|
||||||
|
const reason = ref('')
|
||||||
|
|
||||||
const snapshotText = computed(() => JSON.stringify(order.value?.account_snapshot || {}, null, 2))
|
const snapshotText = computed(() => JSON.stringify(order.value?.account_snapshot || {}, null, 2))
|
||||||
|
const actionTitle = computed(() => (actionType.value === 'close' ? '客服关闭订单' : '标记订单异常'))
|
||||||
|
const canOperate = computed(() => !!order.value && !['completed', 'cancelled', 'closed'].includes(order.value.status))
|
||||||
|
|
||||||
onMounted(loadOrder)
|
onMounted(loadOrder)
|
||||||
|
|
||||||
@@ -22,6 +28,39 @@ async function loadOrder() {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openAction(type: 'close' | 'abnormal') {
|
||||||
|
actionType.value = type
|
||||||
|
reason.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAction() {
|
||||||
|
if (!order.value || !actionType.value) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (actionType.value === 'close') {
|
||||||
|
await adminCloseOrder(order.value.id, reason.value)
|
||||||
|
ElMessage.success('订单已关闭')
|
||||||
|
} else {
|
||||||
|
await adminMarkOrderAbnormal(order.value.id, reason.value)
|
||||||
|
ElMessage.success('订单已标记异常')
|
||||||
|
}
|
||||||
|
actionType.value = ''
|
||||||
|
await loadOrder()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '操作失败'))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readError(error: unknown, fallback: string) {
|
||||||
|
if (typeof error === 'object' && error && 'response' in error) {
|
||||||
|
const response = (error as { response?: { data?: { message?: string } } }).response
|
||||||
|
return response?.data?.message || fallback
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -32,9 +71,13 @@ async function loadOrder() {
|
|||||||
<h1>订单详情</h1>
|
<h1>订单详情</h1>
|
||||||
<p>{{ order.title }} · {{ order.server_region }} / {{ order.login_platform }}</p>
|
<p>{{ order.title }} · {{ order.server_region }} / {{ order.login_platform }}</p>
|
||||||
</div>
|
</div>
|
||||||
<RouterLink to="/admin/orders">
|
<div class="toolbar-actions">
|
||||||
<el-button>返回列表</el-button>
|
<RouterLink to="/admin/orders">
|
||||||
</RouterLink>
|
<el-button>返回列表</el-button>
|
||||||
|
</RouterLink>
|
||||||
|
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')">标记异常</el-button>
|
||||||
|
<el-button type="danger" :disabled="!canOperate" @click="openAction('close')">客服关闭</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="order" class="metric-grid dashboard-metrics">
|
<div v-if="order" class="metric-grid dashboard-metrics">
|
||||||
@@ -81,5 +124,16 @@ async function loadOrder() {
|
|||||||
<h2>账号快照</h2>
|
<h2>账号快照</h2>
|
||||||
<pre>{{ snapshotText }}</pre>
|
<pre>{{ snapshotText }}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<el-dialog :model-value="!!actionType" :title="actionTitle" width="560px" @update:model-value="actionType = ''">
|
||||||
|
<div v-if="order" class="dialog-body">
|
||||||
|
<p><strong>{{ order.order_no }}</strong> · {{ order.title }}</p>
|
||||||
|
<el-input v-model="reason" type="textarea" :rows="4" placeholder="填写客服操作原因,会写入审计日志并通知双方" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="actionType = ''">取消</el-button>
|
||||||
|
<el-button type="danger" :loading="submitting" @click="submitAction">确认操作</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ async function loadOrders() {
|
|||||||
<el-option label="租赁中" value="renting" />
|
<el-option label="租赁中" value="renting" />
|
||||||
<el-option label="待归还确认" value="pending_return_confirm" />
|
<el-option label="待归还确认" value="pending_return_confirm" />
|
||||||
<el-option label="申诉中" value="disputing" />
|
<el-option label="申诉中" value="disputing" />
|
||||||
|
<el-option label="异常" value="abnormal" />
|
||||||
<el-option label="已完成" value="completed" />
|
<el-option label="已完成" value="completed" />
|
||||||
<el-option label="已关闭" value="closed" />
|
<el-option label="已关闭" value="closed" />
|
||||||
<el-option label="已取消" value="cancelled" />
|
<el-option label="已取消" value="cancelled" />
|
||||||
|
|||||||
Reference in New Issue
Block a user