diff --git a/README.md b/README.md index 3c99605..085f85e 100644 --- a/README.md +++ b/README.md @@ -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`,更新会写入审计日志。 ## 文档 diff --git a/backend/internal/model/audit_log.go b/backend/internal/model/audit_log.go new file mode 100644 index 0000000..c7c43db --- /dev/null +++ b/backend/internal/model/audit_log.go @@ -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" +} diff --git a/backend/internal/model/dispute.go b/backend/internal/model/dispute.go new file mode 100644 index 0000000..aa261f7 --- /dev/null +++ b/backend/internal/model/dispute.go @@ -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" +} diff --git a/backend/internal/model/notification.go b/backend/internal/model/notification.go new file mode 100644 index 0000000..aa5ecdd --- /dev/null +++ b/backend/internal/model/notification.go @@ -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" +} diff --git a/backend/internal/model/system_config.go b/backend/internal/model/system_config.go new file mode 100644 index 0000000..e1a372c --- /dev/null +++ b/backend/internal/model/system_config.go @@ -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" +} diff --git a/backend/internal/modules/dispute/dto.go b/backend/internal/modules/dispute/dto.go new file mode 100644 index 0000000..e0545b9 --- /dev/null +++ b/backend/internal/modules/dispute/dto.go @@ -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"` +} diff --git a/backend/internal/modules/dispute/handler.go b/backend/internal/modules/dispute/handler.go new file mode 100644 index 0000000..f76df7c --- /dev/null +++ b/backend/internal/modules/dispute/handler.go @@ -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", "申诉服务暂时不可用") + } +} diff --git a/backend/internal/modules/dispute/repository.go b/backend/internal/modules/dispute/repository.go new file mode 100644 index 0000000..ba117f9 --- /dev/null +++ b/backend/internal/modules/dispute/repository.go @@ -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) +} diff --git a/backend/internal/modules/dispute/service.go b/backend/internal/modules/dispute/service.go new file mode 100644 index 0000000..744db7e --- /dev/null +++ b/backend/internal/modules/dispute/service.go @@ -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) +} diff --git a/backend/internal/modules/notification/README.md b/backend/internal/modules/notification/README.md new file mode 100644 index 0000000..1358056 --- /dev/null +++ b/backend/internal/modules/notification/README.md @@ -0,0 +1,3 @@ +# Notification Module + +站内信列表、已读状态和订单流程消息。 diff --git a/backend/internal/modules/notification/dto.go b/backend/internal/modules/notification/dto.go new file mode 100644 index 0000000..782e335 --- /dev/null +++ b/backend/internal/modules/notification/dto.go @@ -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"` +} diff --git a/backend/internal/modules/notification/handler.go b/backend/internal/modules/notification/handler.go new file mode 100644 index 0000000..8928321 --- /dev/null +++ b/backend/internal/modules/notification/handler.go @@ -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, "通知服务暂时不可用") + } +} diff --git a/backend/internal/modules/notification/repository.go b/backend/internal/modules/notification/repository.go new file mode 100644 index 0000000..9d50731 --- /dev/null +++ b/backend/internal/modules/notification/repository.go @@ -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, + } +} diff --git a/backend/internal/modules/notification/service.go b/backend/internal/modules/notification/service.go new file mode 100644 index 0000000..c2be3f6 --- /dev/null +++ b/backend/internal/modules/notification/service.go @@ -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) +} diff --git a/backend/internal/modules/order/repository.go b/backend/internal/modules/order/repository.go index a9915c8..9652084 100644 --- a/backend/internal/modules/order/repository.go +++ b/backend/internal/modules/order/repository.go @@ -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 { diff --git a/backend/internal/modules/systemconfig/dto.go b/backend/internal/modules/systemconfig/dto.go new file mode 100644 index 0000000..2cbce04 --- /dev/null +++ b/backend/internal/modules/systemconfig/dto.go @@ -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"` +} diff --git a/backend/internal/modules/systemconfig/handler.go b/backend/internal/modules/systemconfig/handler.go new file mode 100644 index 0000000..3e96634 --- /dev/null +++ b/backend/internal/modules/systemconfig/handler.go @@ -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", "系统配置服务暂时不可用") + } +} diff --git a/backend/internal/modules/systemconfig/repository.go b/backend/internal/modules/systemconfig/repository.go new file mode 100644 index 0000000..195affc --- /dev/null +++ b/backend/internal/modules/systemconfig/repository.go @@ -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, + } +} diff --git a/backend/internal/modules/systemconfig/service.go b/backend/internal/modules/systemconfig/service.go new file mode 100644 index 0000000..dab1ee1 --- /dev/null +++ b/backend/internal/modules/systemconfig/service.go @@ -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) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 20fcd9a..2e781b7 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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 diff --git a/docs/api.md b/docs/api.md index ba7580a..e17e371 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 权限后再收紧访问控制。 diff --git a/docs/business-rules.md b/docs/business-rules.md index 158bc96..82670ab 100644 --- a/docs/business-rules.md +++ b/docs/business-rules.md @@ -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 权限,只要求登录;后续接入后台管理员、角色和权限后再限制可操作配置项。 diff --git a/docs/project-plan.md b/docs/project-plan.md index f58757f..499d2b5 100644 --- a/docs/project-plan.md +++ b/docs/project-plan.md @@ -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. 安全与风控 diff --git a/frontend/src/api/disputes.ts b/frontend/src/api/disputes.ts new file mode 100644 index 0000000..a0b7dc9 --- /dev/null +++ b/frontend/src/api/disputes.ts @@ -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 { + 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>(`/orders/${orderId}/dispute`, payload) + return data.data +} + +export async function fetchDisputes() { + const { data } = await apiClient.get>('/disputes') + return data.data.items +} + +export async function fetchAdminDisputes() { + const { data } = await apiClient.get>('/admin/disputes') + return data.data.items +} + +export async function arbitrateDispute(id: number, payload: { result: string; remark: string }) { + const { data } = await apiClient.post>(`/admin/disputes/${id}/arbitrate`, payload) + return data.data +} diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts new file mode 100644 index 0000000..d39209f --- /dev/null +++ b/frontend/src/api/notifications.ts @@ -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 { + code: string + message: string + data: T +} + +export async function fetchNotifications() { + const { data } = await apiClient.get>('/notifications') + return data.data.items +} + +export async function markNotificationRead(id: number) { + const { data } = await apiClient.post>(`/notifications/${id}/read`) + return data.data +} diff --git a/frontend/src/api/systemConfigs.ts b/frontend/src/api/systemConfigs.ts new file mode 100644 index 0000000..5bebd0f --- /dev/null +++ b/frontend/src/api/systemConfigs.ts @@ -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 { + code: string + message: string + data: T +} + +export async function fetchSystemConfigs() { + const { data } = await apiClient.get>('/admin/system-configs') + return data.data.items +} + +export async function updateSystemConfig(key: string, payload: { value: string; description?: string }) { + const { data } = await apiClient.put>(`/admin/system-configs/${key}`, payload) + return data.data +} diff --git a/frontend/src/layouts/AppLayout.vue b/frontend/src/layouts/AppLayout.vue index d38aa42..aa1bf29 100644 --- a/frontend/src/layouts/AppLayout.vue +++ b/frontend/src/layouts/AppLayout.vue @@ -1,5 +1,5 @@ diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index c08a3b8..e43d4c8 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -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; diff --git a/frontend/src/views/account/NotificationsView.vue b/frontend/src/views/account/NotificationsView.vue index d7436a7..068c0b5 100644 --- a/frontend/src/views/account/NotificationsView.vue +++ b/frontend/src/views/account/NotificationsView.vue @@ -1,3 +1,30 @@ + + diff --git a/frontend/src/views/account/OrderDetailView.vue b/frontend/src/views/account/OrderDetailView.vue index c7e23d7..724076e 100644 --- a/frontend/src/views/account/OrderDetailView.vue +++ b/frontend/src/views/account/OrderDetailView.vue @@ -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(null) const handoffRecords = ref([]) 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) {

确认账号状态无误后,订单会完成,账号重新上架。

确认归还并完成订单 + +
+

发起申诉

+ + + + + + + + + + + + 提交申诉 +
diff --git a/frontend/src/views/admin/AdminDisputesView.vue b/frontend/src/views/admin/AdminDisputesView.vue index 9209c5f..329339b 100644 --- a/frontend/src/views/admin/AdminDisputesView.vue +++ b/frontend/src/views/admin/AdminDisputesView.vue @@ -1,3 +1,61 @@ + + diff --git a/frontend/src/views/admin/AdminSystemConfigsView.vue b/frontend/src/views/admin/AdminSystemConfigsView.vue index d15bb45..f59713c 100644 --- a/frontend/src/views/admin/AdminSystemConfigsView.vue +++ b/frontend/src/views/admin/AdminSystemConfigsView.vue @@ -1,3 +1,60 @@ + +