第 5 阶段:纠纷、通知与后台-1
This commit is contained in:
@@ -34,6 +34,9 @@ npm run dev
|
||||
- 账号交接已支持号主提交说明、租客确认收号,确认后订单进入租赁中。
|
||||
- 归还流程已支持租客提交归还、号主确认归还,完成后账号重新上架。
|
||||
- 钱包账务当前为开发态模拟流水,可通过 `GET /api/wallet/balance` 和 `GET /api/wallet/ledger` 查看。
|
||||
- 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。
|
||||
- 申诉仲裁已支持订单双方发起申诉、开发态后台处理,后台页面为 `http://localhost:5173/admin/disputes`。
|
||||
- 系统配置已支持默认配置初始化和后台编辑,页面为 `http://localhost:5173/admin/system-configs`,更新会写入审计日志。
|
||||
|
||||
## 文档
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
type AuditLog struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
ActorType string `gorm:"size:32;not null" json:"actor_type"`
|
||||
ActorID uint64 `gorm:"not null;index" json:"actor_id"`
|
||||
Action string `gorm:"size:64;not null" json:"action"`
|
||||
BizType string `gorm:"size:32;not null" json:"biz_type"`
|
||||
BizID *uint64 `json:"biz_id"`
|
||||
IP string `gorm:"size:64;not null;default:''" json:"ip"`
|
||||
UserAgent string `gorm:"size:512;not null;default:''" json:"user_agent"`
|
||||
Detail datatypes.JSON `json:"detail"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (AuditLog) TableName() string {
|
||||
return "audit_logs"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
type Dispute struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
OrderID uint64 `gorm:"not null;index" json:"order_id"`
|
||||
InitiatorID uint64 `gorm:"not null" json:"initiator_id"`
|
||||
TargetUserID uint64 `gorm:"not null" json:"target_user_id"`
|
||||
Type string `gorm:"size:32;not null" json:"type"`
|
||||
Status string `gorm:"size:32;not null;default:'open'" json:"status"`
|
||||
Description string `json:"description"`
|
||||
EvidenceURLS datatypes.JSON `json:"evidence_urls"`
|
||||
ArbitrationResult string `gorm:"size:32;not null;default:''" json:"arbitration_result"`
|
||||
ArbitrationRemark string `json:"arbitration_remark"`
|
||||
HandledBy *uint64 `json:"handled_by"`
|
||||
HandledAt *time.Time `json:"handled_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Dispute) TableName() string {
|
||||
return "disputes"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Notification struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
UserID uint64 `gorm:"not null;index" json:"user_id"`
|
||||
Type string `gorm:"size:32;not null" json:"type"`
|
||||
Title string `gorm:"size:128;not null" json:"title"`
|
||||
Content string `json:"content"`
|
||||
BizType string `gorm:"size:32;not null;default:''" json:"biz_type"`
|
||||
BizID *uint64 `json:"biz_id"`
|
||||
ReadAt *time.Time `json:"read_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (Notification) TableName() string {
|
||||
return "notifications"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type SystemConfig struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
Key string `gorm:"column:key;size:128;not null;uniqueIndex" json:"key"`
|
||||
Value string `gorm:"column:value;type:text;not null" json:"value"`
|
||||
Description string `gorm:"size:255;not null;default:''" json:"description"`
|
||||
UpdatedBy *uint64 `json:"updated_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (SystemConfig) TableName() string {
|
||||
return "system_configs"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package dispute
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
type DisputeDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
OrderID uint64 `json:"order_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
Title string `json:"title"`
|
||||
InitiatorID uint64 `json:"initiator_id"`
|
||||
TargetUserID uint64 `json:"target_user_id"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Description string `json:"description"`
|
||||
EvidenceURLS datatypes.JSON `json:"evidence_urls"`
|
||||
ArbitrationResult string `json:"arbitration_result"`
|
||||
ArbitrationRemark string `json:"arbitration_remark"`
|
||||
HandledBy *uint64 `json:"handled_by"`
|
||||
HandledAt *time.Time `json:"handled_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
Type string `json:"type" binding:"required"`
|
||||
Description string `json:"description" binding:"required"`
|
||||
EvidenceURLS []string `json:"evidence_urls"`
|
||||
}
|
||||
|
||||
type ArbitrateRequest struct {
|
||||
Result string `json:"result" binding:"required"`
|
||||
Remark string `json:"remark" binding:"required"`
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package dispute
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"hfb_sys/backend/internal/middleware"
|
||||
"hfb_sys/backend/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
orderID, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req CreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "申诉信息不完整")
|
||||
return
|
||||
}
|
||||
item, err := h.service.Create(userID, orderID, req)
|
||||
if err != nil {
|
||||
writeDisputeError(c, err)
|
||||
return
|
||||
}
|
||||
response.Created(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListForUser(userID)
|
||||
if err != nil {
|
||||
writeDisputeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *Handler) Detail(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.FindForUser(userID, id)
|
||||
if err != nil {
|
||||
writeDisputeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) AdminList(c *gin.Context) {
|
||||
items, err := h.service.ListAdmin()
|
||||
if err != nil {
|
||||
writeDisputeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *Handler) AdminArbitrate(c *gin.Context) {
|
||||
adminID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req ArbitrateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "仲裁结果和备注不能为空")
|
||||
return
|
||||
}
|
||||
item, err := h.service.Arbitrate(adminID, id, req)
|
||||
if err != nil {
|
||||
writeDisputeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func currentUserID(c *gin.Context) (uint64, bool) {
|
||||
value, ok := c.Get(middleware.ContextUserID)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
userID, ok := value.(uint64)
|
||||
return userID, ok
|
||||
}
|
||||
|
||||
func parseID(c *gin.Context) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
response.BadRequest(c, "ID 不正确")
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func writeDisputeError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrDependencyUnavailable):
|
||||
response.ServiceUnavailable(c, "数据库未连接")
|
||||
case errors.Is(err, ErrInvalidDispute):
|
||||
response.BadRequest(c, "申诉信息不符合规则")
|
||||
case errors.Is(err, ErrDisputeExists):
|
||||
response.Error(c, http.StatusConflict, "dispute_exists", "该订单已有处理中申诉")
|
||||
case errors.Is(err, ErrDisputeCannotHandle):
|
||||
response.Error(c, http.StatusConflict, "dispute_cannot_handle", "当前申诉不能仲裁")
|
||||
case errors.Is(err, ErrPermissionDenied):
|
||||
response.Error(c, http.StatusForbidden, "permission_denied", "无权操作该申诉")
|
||||
case IsNotFound(err):
|
||||
response.Error(c, http.StatusNotFound, "not_found", "申诉不存在")
|
||||
default:
|
||||
response.Error(c, http.StatusInternalServerError, "internal_error", "申诉服务暂时不可用")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package dispute
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (*DisputeDTO, error) {
|
||||
var createdID uint64
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
var order model.RentalOrder
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if order.RenterID != userID && order.OwnerID != userID {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
if order.Status == "completed" || order.Status == "cancelled" || order.Status == "closed" {
|
||||
return ErrInvalidDispute
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.Dispute{}).
|
||||
Where("order_id = ? AND status IN ?", order.ID, []string{"open", "processing"}).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return ErrDisputeExists
|
||||
}
|
||||
|
||||
targetID := order.OwnerID
|
||||
if userID == order.OwnerID {
|
||||
targetID = order.RenterID
|
||||
}
|
||||
evidence, err := marshalEvidence(req.EvidenceURLS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row := model.Dispute{
|
||||
OrderID: order.ID,
|
||||
InitiatorID: userID,
|
||||
TargetUserID: targetID,
|
||||
Type: req.Type,
|
||||
Status: "open",
|
||||
Description: req.Description,
|
||||
EvidenceURLS: evidence,
|
||||
}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
order.Status = "disputing"
|
||||
if err := tx.Save(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
disputeID := row.ID
|
||||
if err := notification.Append(tx,
|
||||
notification.Entry{
|
||||
UserID: targetID,
|
||||
Type: "dispute",
|
||||
Title: "订单进入申诉",
|
||||
Content: "对方已发起申诉,请等待客服仲裁或补充沟通记录。",
|
||||
BizType: "dispute",
|
||||
BizID: &disputeID,
|
||||
},
|
||||
notification.Entry{
|
||||
UserID: userID,
|
||||
Type: "dispute",
|
||||
Title: "申诉已提交",
|
||||
Content: "申诉已进入待处理状态,客服仲裁后会通知双方。",
|
||||
BizType: "dispute",
|
||||
BizID: &disputeID,
|
||||
},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
createdID = row.ID
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.FindForUser(userID, createdID)
|
||||
}
|
||||
|
||||
func (r *Repository) ListForUser(userID uint64) ([]DisputeDTO, error) {
|
||||
var rows []disputeRow
|
||||
err := r.baseQuery().
|
||||
Where("d.initiator_id = ? OR d.target_user_id = ?", userID, userID).
|
||||
Order("d.id DESC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toDTOs(rows), nil
|
||||
}
|
||||
|
||||
func (r *Repository) FindForUser(userID uint64, id uint64) (*DisputeDTO, error) {
|
||||
var row disputeRow
|
||||
if err := r.baseQuery().
|
||||
Where("d.id = ? AND (d.initiator_id = ? OR d.target_user_id = ?)", id, userID, userID).
|
||||
First(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := row.toDTO()
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListAdmin() ([]DisputeDTO, error) {
|
||||
var rows []disputeRow
|
||||
err := r.baseQuery().Order("d.id DESC").Limit(200).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toDTOs(rows), nil
|
||||
}
|
||||
|
||||
func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest) (*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 {
|
||||
return err
|
||||
}
|
||||
if row.Status != "open" && row.Status != "processing" {
|
||||
return ErrDisputeCannotHandle
|
||||
}
|
||||
var order model.RentalOrder
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, row.OrderID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var listing model.RentalListing
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var account model.GameAccount
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
row.Status = "resolved"
|
||||
row.ArbitrationResult = req.Result
|
||||
row.ArbitrationRemark = req.Remark
|
||||
row.HandledBy = &adminID
|
||||
row.HandledAt = &now
|
||||
order.Status = arbitrateOrderStatus(req.Result)
|
||||
order.SettlementStatus = "arbitrated"
|
||||
order.SettledAt = &now
|
||||
order.OwnerSettledAt = &now
|
||||
if order.HandoffStatus != "cancelled" && order.HandoffStatus != "returned" {
|
||||
order.HandoffStatus = "arbitrated"
|
||||
}
|
||||
listing.Status = "published"
|
||||
account.Status = "published"
|
||||
if err := tx.Save(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Save(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Save(&listing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Save(&account).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
disputeID := row.ID
|
||||
if err := notification.Append(tx,
|
||||
notification.Entry{
|
||||
UserID: order.RenterID,
|
||||
Type: "arbitration",
|
||||
Title: "申诉仲裁已完成",
|
||||
Content: "客服已给出仲裁结果,请在订单和申诉记录中查看处理说明。",
|
||||
BizType: "dispute",
|
||||
BizID: &disputeID,
|
||||
},
|
||||
notification.Entry{
|
||||
UserID: order.OwnerID,
|
||||
Type: "arbitration",
|
||||
Title: "申诉仲裁已完成",
|
||||
Content: "客服已给出仲裁结果,请在订单和申诉记录中查看处理说明。",
|
||||
BizType: "dispute",
|
||||
BizID: &disputeID,
|
||||
},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var row disputeRow
|
||||
if err := r.baseQuery().Where("d.id = ?", id).First(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := row.toDTO()
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (r *Repository) baseQuery() *gorm.DB {
|
||||
return r.db.Table("disputes AS d").
|
||||
Select("d.*, o.order_no, a.title").
|
||||
Joins("JOIN rental_orders AS o ON o.id = d.order_id").
|
||||
Joins("JOIN game_accounts AS a ON a.id = o.account_id")
|
||||
}
|
||||
|
||||
type disputeRow struct {
|
||||
model.Dispute
|
||||
OrderNo string
|
||||
Title string
|
||||
}
|
||||
|
||||
func (row disputeRow) toDTO() DisputeDTO {
|
||||
return DisputeDTO{
|
||||
ID: row.ID,
|
||||
OrderID: row.OrderID,
|
||||
OrderNo: row.OrderNo,
|
||||
Title: row.Title,
|
||||
InitiatorID: row.InitiatorID,
|
||||
TargetUserID: row.TargetUserID,
|
||||
Type: row.Type,
|
||||
Status: row.Status,
|
||||
Description: row.Description,
|
||||
EvidenceURLS: row.EvidenceURLS,
|
||||
ArbitrationResult: row.ArbitrationResult,
|
||||
ArbitrationRemark: row.ArbitrationRemark,
|
||||
HandledBy: row.HandledBy,
|
||||
HandledAt: row.HandledAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func toDTOs(rows []disputeRow) []DisputeDTO {
|
||||
items := make([]DisputeDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, row.toDTO())
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func marshalEvidence(urls []string) (datatypes.JSON, error) {
|
||||
if len(urls) == 0 {
|
||||
return datatypes.JSON([]byte("[]")), nil
|
||||
}
|
||||
raw, err := json.Marshal(urls)
|
||||
return datatypes.JSON(raw), err
|
||||
}
|
||||
|
||||
func arbitrateOrderStatus(result string) string {
|
||||
switch result {
|
||||
case "full_refund", "partial_refund", "order_close":
|
||||
return "closed"
|
||||
default:
|
||||
return "completed"
|
||||
}
|
||||
}
|
||||
|
||||
func IsNotFound(err error) bool {
|
||||
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dispute
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||
ErrPermissionDenied = errors.New("permission denied")
|
||||
ErrInvalidDispute = errors.New("invalid dispute")
|
||||
ErrDisputeExists = errors.New("dispute already exists")
|
||||
ErrDisputeCannotHandle = errors.New("dispute cannot handle")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
}
|
||||
|
||||
func NewService(repo *Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) Create(userID uint64, orderID uint64, req CreateRequest) (*DisputeDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
if orderID == 0 || req.Type == "" || req.Description == "" {
|
||||
return nil, ErrInvalidDispute
|
||||
}
|
||||
return s.repo.Create(userID, orderID, req)
|
||||
}
|
||||
|
||||
func (s *Service) ListForUser(userID uint64) ([]DisputeDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.ListForUser(userID)
|
||||
}
|
||||
|
||||
func (s *Service) FindForUser(userID uint64, id uint64) (*DisputeDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.FindForUser(userID, id)
|
||||
}
|
||||
|
||||
func (s *Service) ListAdmin() ([]DisputeDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.ListAdmin()
|
||||
}
|
||||
|
||||
func (s *Service) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest) (*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)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Notification Module
|
||||
|
||||
站内信列表、已读状态和订单流程消息。
|
||||
@@ -0,0 +1,15 @@
|
||||
package notification
|
||||
|
||||
import "time"
|
||||
|
||||
type NotificationDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
BizType string `json:"biz_type"`
|
||||
BizID *uint64 `json:"biz_id"`
|
||||
ReadAt *time.Time `json:"read_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"hfb_sys/backend/internal/middleware"
|
||||
"hfb_sys/backend/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
items, err := h.service.List(userID)
|
||||
if err != nil {
|
||||
writeNotificationError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *Handler) MarkRead(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
response.BadRequest(c, "ID 不正确")
|
||||
return
|
||||
}
|
||||
if err := h.service.MarkRead(userID, id); err != nil {
|
||||
writeNotificationError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"read": true})
|
||||
}
|
||||
|
||||
func currentUserID(c *gin.Context) (uint64, bool) {
|
||||
value, ok := c.Get(middleware.ContextUserID)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
userID, ok := value.(uint64)
|
||||
return userID, ok
|
||||
}
|
||||
|
||||
func writeNotificationError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrDependencyUnavailable):
|
||||
response.ServiceUnavailable(c, "数据库未连接")
|
||||
default:
|
||||
response.ServiceUnavailable(c, "通知服务暂时不可用")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
UserID uint64
|
||||
Type string
|
||||
Title string
|
||||
Content string
|
||||
BizType string
|
||||
BizID *uint64
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
func (r *Repository) List(userID uint64) ([]NotificationDTO, error) {
|
||||
var rows []model.Notification
|
||||
if err := r.db.Where("user_id = ?", userID).Order("id DESC").Limit(100).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]NotificationDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, toDTO(row))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) MarkRead(userID uint64, id uint64) error {
|
||||
now := time.Now()
|
||||
return r.db.Model(&model.Notification{}).
|
||||
Where("id = ? AND user_id = ?", id, userID).
|
||||
Update("read_at", now).Error
|
||||
}
|
||||
|
||||
func Append(tx *gorm.DB, entries ...Entry) error {
|
||||
for _, entry := range entries {
|
||||
if entry.UserID == 0 || entry.Title == "" {
|
||||
continue
|
||||
}
|
||||
row := model.Notification{
|
||||
UserID: entry.UserID,
|
||||
Type: entry.Type,
|
||||
Title: entry.Title,
|
||||
Content: entry.Content,
|
||||
BizType: entry.BizType,
|
||||
BizID: entry.BizID,
|
||||
}
|
||||
if row.Type == "" {
|
||||
row.Type = "system"
|
||||
}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toDTO(row model.Notification) NotificationDTO {
|
||||
return NotificationDTO{
|
||||
ID: row.ID,
|
||||
UserID: row.UserID,
|
||||
Type: row.Type,
|
||||
Title: row.Title,
|
||||
Content: row.Content,
|
||||
BizType: row.BizType,
|
||||
BizID: row.BizID,
|
||||
ReadAt: row.ReadAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package notification
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
}
|
||||
|
||||
func NewService(repo *Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) List(userID uint64) ([]NotificationDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.List(userID)
|
||||
}
|
||||
|
||||
func (s *Service) MarkRead(userID uint64, id uint64) error {
|
||||
if s.repo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.MarkRead(userID, id)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/notification"
|
||||
"hfb_sys/backend/internal/modules/wallet"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
@@ -90,6 +91,26 @@ func (r *Repository) Create(renterID uint64, req CreateRequest) (*OrderDTO, erro
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := notification.Append(tx,
|
||||
notification.Entry{
|
||||
UserID: order.OwnerID,
|
||||
Type: "order",
|
||||
Title: "收到新的租号订单",
|
||||
Content: "租客已下单,请尽快提交交接说明。",
|
||||
BizType: "order",
|
||||
BizID: &orderID,
|
||||
},
|
||||
notification.Entry{
|
||||
UserID: order.RenterID,
|
||||
Type: "order",
|
||||
Title: "订单已创建",
|
||||
Content: "账号已锁定,等待号主提交交接说明。",
|
||||
BizType: "order",
|
||||
BizID: &orderID,
|
||||
},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
listing.Status = "rented"
|
||||
account.Status = "rented"
|
||||
if err := tx.Save(&listing).Error; err != nil {
|
||||
@@ -144,6 +165,26 @@ func (r *Repository) Cancel(userID uint64, orderID uint64) error {
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := notification.Append(tx,
|
||||
notification.Entry{
|
||||
UserID: order.OwnerID,
|
||||
Type: "order",
|
||||
Title: "订单已取消",
|
||||
Content: "租客已取消待交接订单,账号已重新释放。",
|
||||
BizType: "order",
|
||||
BizID: &orderID,
|
||||
},
|
||||
notification.Entry{
|
||||
UserID: order.RenterID,
|
||||
Type: "order",
|
||||
Title: "订单取消成功",
|
||||
Content: "待交接订单已取消,模拟冻结金额已释放。",
|
||||
BizType: "order",
|
||||
BizID: &orderID,
|
||||
},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
listing.Status = "published"
|
||||
account.Status = "published"
|
||||
if err := tx.Save(&order).Error; err != nil {
|
||||
@@ -180,6 +221,17 @@ func (r *Repository) SubmitHandoff(userID uint64, orderID uint64, req SubmitHand
|
||||
return err
|
||||
}
|
||||
order.HandoffStatus = "pending_renter_confirm"
|
||||
orderID := order.ID
|
||||
if err := notification.Append(tx, notification.Entry{
|
||||
UserID: order.RenterID,
|
||||
Type: "handoff",
|
||||
Title: "号主已提交交接说明",
|
||||
Content: "请查看交接记录,确认账号可正常登录后点击确认收号。",
|
||||
BizType: "order",
|
||||
BizID: &orderID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Save(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -215,6 +267,17 @@ func (r *Repository) ConfirmReceive(userID uint64, orderID uint64) error {
|
||||
order.RentStartAt = &now
|
||||
rentEnd := now.Add(time.Duration(order.RentHours) * time.Hour)
|
||||
order.RentEndAt = &rentEnd
|
||||
orderID := order.ID
|
||||
if err := notification.Append(tx, notification.Entry{
|
||||
UserID: order.OwnerID,
|
||||
Type: "handoff",
|
||||
Title: "租客已确认收号",
|
||||
Content: "订单已进入租赁中。",
|
||||
BizType: "order",
|
||||
BizID: &orderID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Save(&order).Error
|
||||
})
|
||||
}
|
||||
@@ -262,6 +325,17 @@ func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitRetur
|
||||
order.Status = "pending_return_confirm"
|
||||
order.HandoffStatus = "pending_owner_return_confirm"
|
||||
order.RentEndAt = &now
|
||||
orderID := order.ID
|
||||
if err := notification.Append(tx, notification.Entry{
|
||||
UserID: order.OwnerID,
|
||||
Type: "return",
|
||||
Title: "租客已提交归还",
|
||||
Content: "请检查账号状态,确认无误后完成订单。",
|
||||
BizType: "order",
|
||||
BizID: &orderID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Save(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -340,6 +414,26 @@ func (r *Repository) ConfirmReturn(userID uint64, orderID uint64) error {
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := notification.Append(tx,
|
||||
notification.Entry{
|
||||
UserID: order.RenterID,
|
||||
Type: "settlement",
|
||||
Title: "订单已完成",
|
||||
Content: "号主已确认归还,模拟押金已退回。",
|
||||
BizType: "order",
|
||||
BizID: &orderID,
|
||||
},
|
||||
notification.Entry{
|
||||
UserID: order.OwnerID,
|
||||
Type: "settlement",
|
||||
Title: "订单已完成",
|
||||
Content: "订单已完成,模拟租金已入账。",
|
||||
BizType: "order",
|
||||
BizID: &orderID,
|
||||
},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
listing.Status = "published"
|
||||
account.Status = "published"
|
||||
if err := tx.Save(&order).Error; err != nil {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package systemconfig
|
||||
|
||||
import "time"
|
||||
|
||||
type ConfigDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Description string `json:"description"`
|
||||
UpdatedBy *uint64 `json:"updated_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type UpdateRequest struct {
|
||||
Value string `json:"value" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package systemconfig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"hfb_sys/backend/internal/middleware"
|
||||
"hfb_sys/backend/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
items, err := h.service.List()
|
||||
if err != nil {
|
||||
writeConfigError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
key := c.Param("key")
|
||||
var req UpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "配置值不能为空")
|
||||
return
|
||||
}
|
||||
item, err := h.service.Update(userID, key, req, AuditMeta{
|
||||
IP: c.ClientIP(),
|
||||
UserAgent: c.GetHeader("User-Agent"),
|
||||
})
|
||||
if err != nil {
|
||||
writeConfigError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func currentUserID(c *gin.Context) (uint64, bool) {
|
||||
value, ok := c.Get(middleware.ContextUserID)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
userID, ok := value.(uint64)
|
||||
return userID, ok
|
||||
}
|
||||
|
||||
func writeConfigError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrDependencyUnavailable):
|
||||
response.ServiceUnavailable(c, "数据库未连接")
|
||||
case errors.Is(err, ErrInvalidConfig):
|
||||
response.BadRequest(c, "配置不符合规则")
|
||||
default:
|
||||
response.Error(c, http.StatusInternalServerError, "internal_error", "系统配置服务暂时不可用")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package systemconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type AuditMeta struct {
|
||||
IP string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
type defaultConfig struct {
|
||||
Key string
|
||||
Value string
|
||||
Description string
|
||||
}
|
||||
|
||||
var defaultConfigs = []defaultConfig{
|
||||
{Key: "handoff.owner_submit_timeout_minutes", Value: "30", Description: "号主待交接超时分钟数"},
|
||||
{Key: "handoff.renter_confirm_timeout_minutes", Value: "30", Description: "租客待确认收号超时分钟数"},
|
||||
{Key: "handoff.owner_return_confirm_timeout_minutes", Value: "120", Description: "号主待确认归还超时分钟数"},
|
||||
{Key: "order.return_overdue_grace_minutes", Value: "10", Description: "租期到期后归还宽限分钟数"},
|
||||
{Key: "deposit.min_amount", Value: "50", Description: "发布租号最低押金"},
|
||||
{Key: "risk.sms_limit_per_phone_hour", Value: "5", Description: "单手机号每小时短信验证码次数"},
|
||||
{Key: "risk.sms_limit_per_ip_hour", Value: "20", Description: "单 IP 每小时短信验证码次数"},
|
||||
{Key: "realname.required_for_order", Value: "false", Description: "下单是否必须完成实名认证"},
|
||||
{Key: "settlement.platform_fee_rate", Value: "0.00", Description: "平台抽成比例"},
|
||||
{Key: "settlement.owner_cycle_days", Value: "0", Description: "号主结算周期天数"},
|
||||
{Key: "withdraw.min_amount", Value: "100", Description: "提现最低金额预留"},
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
func (r *Repository) List() ([]ConfigDTO, error) {
|
||||
if err := r.ensureDefaults(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []model.SystemConfig
|
||||
if err := r.db.Order("`key` ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]ConfigDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, toDTO(row))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) Update(actorID uint64, key string, req UpdateRequest, meta AuditMeta) (*ConfigDTO, error) {
|
||||
var row model.SystemConfig
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("`key` = ?", key).First(&row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
row = model.SystemConfig{
|
||||
Key: key,
|
||||
Value: req.Value,
|
||||
Description: req.Description,
|
||||
UpdatedBy: &actorID,
|
||||
}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
before := row.Value
|
||||
row.Value = req.Value
|
||||
if req.Description != "" {
|
||||
row.Description = req.Description
|
||||
}
|
||||
row.UpdatedBy = &actorID
|
||||
if err := tx.Save(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appendAuditLog(tx, actorID, "system_config.update", row.ID, meta, map[string]any{
|
||||
"key": row.Key,
|
||||
"before": before,
|
||||
"after": row.Value,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return appendAuditLog(tx, actorID, "system_config.create", row.ID, meta, map[string]any{
|
||||
"key": row.Key,
|
||||
"value": row.Value,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := toDTO(row)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ensureDefaults() error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, item := range defaultConfigs {
|
||||
row := model.SystemConfig{
|
||||
Key: item.Key,
|
||||
Value: item.Value,
|
||||
Description: item.Description,
|
||||
}
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizID uint64, meta AuditMeta, detail map[string]any) error {
|
||||
raw, err := json.Marshal(detail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row := model.AuditLog{
|
||||
ActorType: "admin",
|
||||
ActorID: actorID,
|
||||
Action: action,
|
||||
BizType: "system_config",
|
||||
BizID: &bizID,
|
||||
IP: meta.IP,
|
||||
UserAgent: meta.UserAgent,
|
||||
Detail: datatypes.JSON(raw),
|
||||
}
|
||||
return tx.Create(&row).Error
|
||||
}
|
||||
|
||||
func toDTO(row model.SystemConfig) ConfigDTO {
|
||||
return ConfigDTO{
|
||||
ID: row.ID,
|
||||
Key: row.Key,
|
||||
Value: row.Value,
|
||||
Description: row.Description,
|
||||
UpdatedBy: row.UpdatedBy,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package systemconfig
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||
ErrInvalidConfig = errors.New("invalid config")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
}
|
||||
|
||||
func NewService(repo *Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) List() ([]ConfigDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.List()
|
||||
}
|
||||
|
||||
func (s *Service) Update(actorID uint64, key string, req UpdateRequest, meta AuditMeta) (*ConfigDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
if key == "" || req.Value == "" {
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
return s.repo.Update(actorID, key, req, meta)
|
||||
}
|
||||
@@ -5,9 +5,12 @@ import (
|
||||
"hfb_sys/backend/internal/handler"
|
||||
"hfb_sys/backend/internal/middleware"
|
||||
"hfb_sys/backend/internal/modules/auth"
|
||||
"hfb_sys/backend/internal/modules/dispute"
|
||||
"hfb_sys/backend/internal/modules/listing"
|
||||
"hfb_sys/backend/internal/modules/notification"
|
||||
"hfb_sys/backend/internal/modules/order"
|
||||
"hfb_sys/backend/internal/modules/realname"
|
||||
"hfb_sys/backend/internal/modules/systemconfig"
|
||||
"hfb_sys/backend/internal/modules/user"
|
||||
"hfb_sys/backend/internal/modules/wallet"
|
||||
|
||||
@@ -59,6 +62,24 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
}
|
||||
walletService := wallet.NewService(walletRepo)
|
||||
walletHandler := wallet.NewHandler(walletService)
|
||||
var notificationRepo *notification.Repository
|
||||
if deps.DB != nil {
|
||||
notificationRepo = notification.NewRepository(deps.DB)
|
||||
}
|
||||
notificationService := notification.NewService(notificationRepo)
|
||||
notificationHandler := notification.NewHandler(notificationService)
|
||||
var disputeRepo *dispute.Repository
|
||||
if deps.DB != nil {
|
||||
disputeRepo = dispute.NewRepository(deps.DB)
|
||||
}
|
||||
disputeService := dispute.NewService(disputeRepo)
|
||||
disputeHandler := dispute.NewHandler(disputeService)
|
||||
var systemConfigRepo *systemconfig.Repository
|
||||
if deps.DB != nil {
|
||||
systemConfigRepo = systemconfig.NewRepository(deps.DB)
|
||||
}
|
||||
systemConfigService := systemconfig.NewService(systemConfigRepo)
|
||||
systemConfigHandler := systemconfig.NewHandler(systemConfigService)
|
||||
requireAuth := middleware.Auth(jwtManager)
|
||||
requireRealname := middleware.RequireRealname(userRepo)
|
||||
|
||||
@@ -103,6 +124,13 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
orderRoutes.POST("/:id/confirm-receive", orderHandler.ConfirmReceive)
|
||||
orderRoutes.POST("/:id/return", orderHandler.SubmitReturn)
|
||||
orderRoutes.POST("/:id/confirm-return", orderHandler.ConfirmReturn)
|
||||
orderRoutes.POST("/:id/dispute", disputeHandler.Create)
|
||||
}
|
||||
|
||||
disputeRoutes := api.Group("/disputes", requireAuth)
|
||||
{
|
||||
disputeRoutes.GET("", disputeHandler.List)
|
||||
disputeRoutes.GET("/:id", disputeHandler.Detail)
|
||||
}
|
||||
|
||||
walletRoutes := api.Group("/wallet", requireAuth)
|
||||
@@ -111,11 +139,25 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
walletRoutes.GET("/ledger", walletHandler.Ledger)
|
||||
}
|
||||
|
||||
notificationRoutes := api.Group("/notifications", requireAuth)
|
||||
{
|
||||
notificationRoutes.GET("", notificationHandler.List)
|
||||
notificationRoutes.POST("/:id/read", notificationHandler.MarkRead)
|
||||
}
|
||||
|
||||
realnameRoutes := api.Group("/realname", requireAuth)
|
||||
{
|
||||
realnameRoutes.POST("/start", realnameHandler.Start)
|
||||
realnameRoutes.GET("/status", realnameHandler.Status)
|
||||
}
|
||||
|
||||
adminRoutes := api.Group("/admin", requireAuth)
|
||||
{
|
||||
adminRoutes.GET("/disputes", disputeHandler.AdminList)
|
||||
adminRoutes.POST("/disputes/:id/arbitrate", disputeHandler.AdminArbitrate)
|
||||
adminRoutes.GET("/system-configs", systemConfigHandler.List)
|
||||
adminRoutes.PUT("/system-configs/:key", systemConfigHandler.Update)
|
||||
}
|
||||
}
|
||||
|
||||
return engine
|
||||
|
||||
+11
@@ -39,5 +39,16 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。
|
||||
- `POST /api/orders/{id}/confirm-receive`
|
||||
- `POST /api/orders/{id}/return`
|
||||
- `POST /api/orders/{id}/confirm-return`
|
||||
- `POST /api/orders/{id}/dispute`
|
||||
- `GET /api/disputes`
|
||||
- `GET /api/disputes/{id}`
|
||||
- `GET /api/wallet/balance`
|
||||
- `GET /api/wallet/ledger`
|
||||
- `GET /api/notifications`
|
||||
- `POST /api/notifications/{id}/read`
|
||||
- `GET /api/admin/disputes`
|
||||
- `POST /api/admin/disputes/{id}/arbitrate`
|
||||
- `GET /api/admin/system-configs`
|
||||
- `PUT /api/admin/system-configs/{key}`
|
||||
|
||||
说明:`/api/admin/*` 当前是开发态 mock 后台接口,只要求登录,后续接入后台管理员账号、RBAC 和 Casbin 权限后再收紧访问控制。
|
||||
|
||||
+30
-1
@@ -53,7 +53,7 @@
|
||||
- 只有号主可以确认归还。
|
||||
- 号主确认归还后,订单状态变为 `completed`,交接状态变为 `returned`,结算状态先标记为 `settled`。
|
||||
- 订单完成后,账号和发布状态恢复为 `published`。
|
||||
- 当前只做状态闭环,不生成真实钱包流水,资金流水后续接入。
|
||||
- 当前会生成开发态模拟钱包流水,不代表真实支付或提现。
|
||||
|
||||
## 开发态钱包账务
|
||||
|
||||
@@ -63,3 +63,32 @@
|
||||
- 订单完成时释放租客冻结金额,给号主生成租金入账流水,并给租客生成押金退回流水。
|
||||
- 每笔流水记录 `balance_after`,用于后续对账。
|
||||
- 后续接入真实支付后,需要把模拟冻结替换为支付成功后的真实冻结。
|
||||
|
||||
## 开发态站内信
|
||||
|
||||
- 订单创建后通知号主和租客。
|
||||
- 租客取消订单后通知号主和租客。
|
||||
- 号主提交交接说明后通知租客。
|
||||
- 租客确认收号后通知号主。
|
||||
- 租客提交归还后通知号主。
|
||||
- 号主确认归还后通知号主和租客。
|
||||
- 发起申诉后通知对方和发起人。
|
||||
- 仲裁完成后通知号主和租客。
|
||||
- 站内信支持列表查询和标记已读。
|
||||
|
||||
## 开发态申诉仲裁
|
||||
|
||||
- 订单双方都可以在非终态订单发起申诉。
|
||||
- 同一个订单同一时间只允许存在一个 `open` 或 `processing` 申诉。
|
||||
- 发起申诉后订单状态变为 `disputing`,账号和发布保持锁定。
|
||||
- 开发态后台仲裁接口为 `/api/admin/disputes`,当前只要求登录,后续接后台管理员和 RBAC。
|
||||
- 仲裁结果先只落状态和通知:全额退款、部分退款、关闭订单会将订单置为 `closed`;扣押金、释放押金、赔付号主会将订单置为 `completed`。
|
||||
- 仲裁完成后账号和发布恢复为 `published`。
|
||||
- 当前仲裁不做真实扣款、退款、赔付落账,后续接真实支付后再补资金流水和审计日志。
|
||||
|
||||
## 开发态系统配置
|
||||
|
||||
- 系统配置接口为 `/api/admin/system-configs`。
|
||||
- 首次查询会自动补齐一组默认配置,包括交接超时、归还确认超时、短信限流、最低押金、实名下单开关、平台抽成和提现门槛。
|
||||
- 更新配置会写入 `audit_logs`,记录操作人、配置项、修改前值和修改后值。
|
||||
- 当前后台接口仍是开发态 mock 权限,只要求登录;后续接入后台管理员、角色和权限后再限制可操作配置项。
|
||||
|
||||
@@ -621,12 +621,12 @@
|
||||
- `GET /admin/listings/pending`:待审核商品。
|
||||
- `POST /admin/listings/{id}/approve`:审核通过。
|
||||
- `POST /admin/listings/{id}/reject`:审核拒绝。
|
||||
- `GET /admin/orders`:订单列表。
|
||||
- `GET /admin/disputes`:纠纷列表。
|
||||
- `POST /admin/orders/{id}/arbitrate`:订单仲裁。
|
||||
- `GET /admin/wallet/ledger`:资金流水。
|
||||
- `GET /admin/system-configs`:系统配置列表。
|
||||
- `PUT /admin/system-configs/{key}`:更新系统配置。
|
||||
- `GET /api/admin/orders`:订单列表。
|
||||
- `GET /api/admin/disputes`:纠纷列表。
|
||||
- `POST /api/admin/disputes/{id}/arbitrate`:纠纷仲裁。
|
||||
- `GET /api/admin/wallet/ledger`:资金流水。
|
||||
- `GET /api/admin/system-configs`:系统配置列表。
|
||||
- `PUT /api/admin/system-configs/{key}`:更新系统配置。
|
||||
- `GET /admin/audit-logs`:审计日志。
|
||||
|
||||
## 11. 安全与风控
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
export interface Dispute {
|
||||
id: number
|
||||
order_id: number
|
||||
order_no: string
|
||||
title: string
|
||||
initiator_id: number
|
||||
target_user_id: number
|
||||
type: string
|
||||
status: string
|
||||
description: string
|
||||
evidence_urls?: string[]
|
||||
arbitration_result: string
|
||||
arbitration_remark: string
|
||||
handled_by?: number
|
||||
handled_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
code: string
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export async function createDispute(orderId: number, payload: { type: string; description: string; evidence_urls?: string[] }) {
|
||||
const { data } = await apiClient.post<ApiResponse<Dispute>>(`/orders/${orderId}/dispute`, payload)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchDisputes() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: Dispute[] }>>('/disputes')
|
||||
return data.data.items
|
||||
}
|
||||
|
||||
export async function fetchAdminDisputes() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: Dispute[] }>>('/admin/disputes')
|
||||
return data.data.items
|
||||
}
|
||||
|
||||
export async function arbitrateDispute(id: number, payload: { result: string; remark: string }) {
|
||||
const { data } = await apiClient.post<ApiResponse<Dispute>>(`/admin/disputes/${id}/arbitrate`, payload)
|
||||
return data.data
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
export interface NotificationItem {
|
||||
id: number
|
||||
user_id: number
|
||||
type: string
|
||||
title: string
|
||||
content: string
|
||||
biz_type: string
|
||||
biz_id?: number
|
||||
read_at?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
code: string
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export async function fetchNotifications() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: NotificationItem[] }>>('/notifications')
|
||||
return data.data.items
|
||||
}
|
||||
|
||||
export async function markNotificationRead(id: number) {
|
||||
const { data } = await apiClient.post<ApiResponse<{ read: boolean }>>(`/notifications/${id}/read`)
|
||||
return data.data
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
export interface SystemConfig {
|
||||
id: number
|
||||
key: string
|
||||
value: string
|
||||
description: string
|
||||
updated_by?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
code: string
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export async function fetchSystemConfigs() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: SystemConfig[] }>>('/admin/system-configs')
|
||||
return data.data.items
|
||||
}
|
||||
|
||||
export async function updateSystemConfig(key: string, payload: { value: string; description?: string }) {
|
||||
const { data } = await apiClient.put<ApiResponse<SystemConfig>>(`/admin/system-configs/${key}`, payload)
|
||||
return data.data
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Bell, House, Phone, Shop, Tickets, UserFilled, Wallet } from '@element-plus/icons-vue'
|
||||
import { Bell, House, Operation, Phone, ScaleToOriginal, Shop, Tickets, UserFilled, Wallet } from '@element-plus/icons-vue'
|
||||
|
||||
const navItems = [
|
||||
{ label: '首页', to: '/', icon: House },
|
||||
@@ -8,6 +8,8 @@ const navItems = [
|
||||
{ label: '钱包', to: '/wallet', icon: Wallet },
|
||||
{ label: '通知', to: '/notifications', icon: Bell },
|
||||
{ label: '实名', to: '/realname', icon: UserFilled },
|
||||
{ label: '仲裁', to: '/admin/disputes', icon: ScaleToOriginal },
|
||||
{ label: '配置', to: '/admin/system-configs', icon: Operation },
|
||||
{ label: '登录', to: '/login', icon: Phone },
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -307,6 +307,69 @@ h1 {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.full-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dialog-body p {
|
||||
margin: 0;
|
||||
color: #52616f;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
border-color: #0f766e;
|
||||
}
|
||||
|
||||
.notification-item span {
|
||||
color: #0f766e;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.notification-item h2 {
|
||||
margin: 6px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.notification-item p {
|
||||
margin: 0 0 8px;
|
||||
color: #52616f;
|
||||
}
|
||||
|
||||
.notification-item small {
|
||||
color: #6b7785;
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -1,3 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { fetchNotifications, markNotificationRead, type NotificationItem } from '@/api/notifications'
|
||||
|
||||
const loading = ref(false)
|
||||
const notifications = ref<NotificationItem[]>([])
|
||||
|
||||
onMounted(loadNotifications)
|
||||
|
||||
async function loadNotifications() {
|
||||
loading.value = true
|
||||
try {
|
||||
notifications.value = await fetchNotifications()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead(id: number) {
|
||||
await markNotificationRead(id)
|
||||
ElMessage.success('已标记为已读')
|
||||
await loadNotifications()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<div class="page-header">
|
||||
@@ -5,5 +32,23 @@
|
||||
<h1>站内信</h1>
|
||||
<p>接收审核、交接、归还、申诉和仲裁结果通知。</p>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!loading && notifications.length === 0" description="暂无站内信" />
|
||||
<div v-else v-loading="loading" class="notification-list">
|
||||
<div v-for="item in notifications" :key="item.id" class="notification-item" :class="{ unread: !item.read_at }">
|
||||
<div>
|
||||
<span>{{ item.type }}</span>
|
||||
<h2>{{ item.title }}</h2>
|
||||
<p>{{ item.content }}</p>
|
||||
<small>{{ item.created_at }}</small>
|
||||
</div>
|
||||
<div class="notification-actions">
|
||||
<RouterLink v-if="item.biz_type === 'order' && item.biz_id" :to="`/orders/${item.biz_id}`">
|
||||
<el-button size="small">查看订单</el-button>
|
||||
</RouterLink>
|
||||
<el-button v-if="!item.read_at" size="small" type="primary" @click="markRead(item.id)">已读</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ElMessage } from 'element-plus'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { createDispute } from '@/api/disputes'
|
||||
import {
|
||||
cancelOrder,
|
||||
confirmReceive,
|
||||
@@ -25,13 +26,21 @@ const handoffing = ref(false)
|
||||
const confirming = ref(false)
|
||||
const returning = ref(false)
|
||||
const completing = ref(false)
|
||||
const disputing = ref(false)
|
||||
const order = ref<Order | null>(null)
|
||||
const handoffRecords = ref<HandoffRecord[]>([])
|
||||
const handoffContent = ref('')
|
||||
const returnContent = ref('')
|
||||
const disputeType = ref('cannot_login')
|
||||
const disputeDescription = ref('')
|
||||
const disputeEvidenceText = ref('')
|
||||
|
||||
const isOwner = computed(() => order.value?.owner_id === session.userId)
|
||||
const isRenter = computed(() => order.value?.renter_id === session.userId)
|
||||
const canOpenDispute = computed(() => {
|
||||
if (!order.value || (!isOwner.value && !isRenter.value)) return false
|
||||
return !['completed', 'cancelled', 'closed', 'disputing'].includes(order.value.status)
|
||||
})
|
||||
|
||||
onMounted(loadOrder)
|
||||
|
||||
@@ -117,6 +126,30 @@ async function handleConfirmReturn() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateDispute() {
|
||||
if (!order.value) return
|
||||
disputing.value = true
|
||||
try {
|
||||
const evidence_urls = disputeEvidenceText.value
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
await createDispute(order.value.id, {
|
||||
type: disputeType.value,
|
||||
description: disputeDescription.value,
|
||||
evidence_urls,
|
||||
})
|
||||
disputeDescription.value = ''
|
||||
disputeEvidenceText.value = ''
|
||||
ElMessage.success('申诉已提交,订单进入仲裁处理')
|
||||
await loadOrder()
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '提交申诉失败'))
|
||||
} finally {
|
||||
disputing.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
|
||||
@@ -198,5 +231,33 @@ function readError(error: unknown, fallback: string) {
|
||||
<p>确认账号状态无误后,订单会完成,账号重新上架。</p>
|
||||
<el-button type="primary" :loading="completing" @click="handleConfirmReturn">确认归还并完成订单</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="order && canOpenDispute" class="order-panel">
|
||||
<h2>发起申诉</h2>
|
||||
<el-select v-model="disputeType" class="full-control" placeholder="选择申诉类型">
|
||||
<el-option label="无法登录" value="cannot_login" />
|
||||
<el-option label="虚假描述" value="false_description" />
|
||||
<el-option label="账号被封" value="account_banned" />
|
||||
<el-option label="资产损失" value="asset_loss" />
|
||||
<el-option label="哈夫币争议" value="haf_coin_dispute" />
|
||||
<el-option label="超时未交接" value="handoff_timeout" />
|
||||
<el-option label="超时未归还" value="return_timeout" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="disputeDescription"
|
||||
class="panel-action"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="说明争议经过、时间点和希望客服核查的证据"
|
||||
/>
|
||||
<el-input
|
||||
v-model="disputeEvidenceText"
|
||||
class="panel-action"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="证据链接,一行一个。开发阶段可先填截图地址或备注链接"
|
||||
/>
|
||||
<el-button class="panel-action" type="warning" :loading="disputing" @click="handleCreateDispute">提交申诉</el-button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,3 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { arbitrateDispute, fetchAdminDisputes, type Dispute } from '@/api/disputes'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const disputes = ref<Dispute[]>([])
|
||||
const activeDispute = ref<Dispute | null>(null)
|
||||
const result = ref('release_deposit')
|
||||
const remark = ref('')
|
||||
|
||||
onMounted(loadDisputes)
|
||||
|
||||
async function loadDisputes() {
|
||||
loading.value = true
|
||||
try {
|
||||
disputes.value = await fetchAdminDisputes()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openArbitration(row: Dispute) {
|
||||
activeDispute.value = row
|
||||
result.value = row.arbitration_result || 'release_deposit'
|
||||
remark.value = row.arbitration_remark || ''
|
||||
}
|
||||
|
||||
async function handleArbitrate() {
|
||||
if (!activeDispute.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await arbitrateDispute(activeDispute.value.id, {
|
||||
result: result.value,
|
||||
remark: remark.value,
|
||||
})
|
||||
ElMessage.success('仲裁结果已保存,双方已收到通知')
|
||||
activeDispute.value = null
|
||||
remark.value = ''
|
||||
await loadDisputes()
|
||||
} 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>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<div class="page-header">
|
||||
@@ -5,5 +63,39 @@
|
||||
<h1>仲裁中心</h1>
|
||||
<p>处理无法登录、资产损失、哈夫币争议和超时归还。</p>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" class="table-panel" :data="disputes">
|
||||
<el-table-column prop="order_no" label="订单号" min-width="210" />
|
||||
<el-table-column prop="title" label="账号" min-width="160" />
|
||||
<el-table-column prop="type" label="类型" width="150" />
|
||||
<el-table-column prop="status" label="状态" width="110" />
|
||||
<el-table-column prop="description" label="说明" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="arbitration_result" label="结果" width="150" />
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" :disabled="row.status === 'resolved'" @click="openArbitration(row)">仲裁</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog :model-value="!!activeDispute" title="申诉仲裁" width="560px" @update:model-value="activeDispute = null">
|
||||
<div v-if="activeDispute" class="dialog-body">
|
||||
<p><strong>{{ activeDispute.order_no }}</strong> · {{ activeDispute.title }}</p>
|
||||
<p>{{ activeDispute.description }}</p>
|
||||
<el-select v-model="result" class="full-control" placeholder="选择裁决结果">
|
||||
<el-option label="全额退款" value="full_refund" />
|
||||
<el-option label="部分退款" value="partial_refund" />
|
||||
<el-option label="扣押金" value="deduct_deposit" />
|
||||
<el-option label="释放押金" value="release_deposit" />
|
||||
<el-option label="赔付号主" value="compensate_owner" />
|
||||
<el-option label="关闭订单" value="order_close" />
|
||||
</el-select>
|
||||
<el-input v-model="remark" class="panel-action" type="textarea" :rows="4" placeholder="填写客服裁决说明" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="activeDispute = null">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleArbitrate">保存裁决</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,3 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { fetchSystemConfigs, updateSystemConfig, type SystemConfig } from '@/api/systemConfigs'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const configs = ref<SystemConfig[]>([])
|
||||
const activeConfig = ref<SystemConfig | null>(null)
|
||||
const value = ref('')
|
||||
const description = ref('')
|
||||
|
||||
onMounted(loadConfigs)
|
||||
|
||||
async function loadConfigs() {
|
||||
loading.value = true
|
||||
try {
|
||||
configs.value = await fetchSystemConfigs()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(row: SystemConfig) {
|
||||
activeConfig.value = row
|
||||
value.value = row.value
|
||||
description.value = row.description
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!activeConfig.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await updateSystemConfig(activeConfig.value.key, {
|
||||
value: value.value,
|
||||
description: description.value,
|
||||
})
|
||||
ElMessage.success('配置已更新')
|
||||
activeConfig.value = null
|
||||
await loadConfigs()
|
||||
} 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>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<div class="page-header">
|
||||
@@ -5,5 +62,30 @@
|
||||
<h1>系统配置</h1>
|
||||
<p>管理交接超时、归还超时、短信限流、最低押金和抽成比例。</p>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" class="table-panel" :data="configs">
|
||||
<el-table-column prop="key" label="配置项" min-width="260" />
|
||||
<el-table-column prop="value" label="当前值" width="150" />
|
||||
<el-table-column prop="description" label="说明" min-width="260" show-overflow-tooltip />
|
||||
<el-table-column prop="updated_by" label="更新人" width="100" />
|
||||
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog :model-value="!!activeConfig" title="编辑系统配置" width="560px" @update:model-value="activeConfig = null">
|
||||
<div v-if="activeConfig" class="dialog-body">
|
||||
<p><strong>{{ activeConfig.key }}</strong></p>
|
||||
<el-input v-model="value" placeholder="配置值" />
|
||||
<el-input v-model="description" type="textarea" :rows="3" placeholder="配置说明" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="activeConfig = null">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user