第 5 阶段:纠纷、通知与后台-1

This commit is contained in:
yml
2026-05-22 16:34:32 +08:00
parent 9ebdfab078
commit 99f9df7bcd
32 changed files with 1711 additions and 8 deletions
+37
View File
@@ -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"`
}
+144
View File
@@ -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)
}