## 变更概述 将全项目金额处理从整元四舍五入统一为角精度(0.1元),提高金额计算准确性和显示一致性。 ## 后端改动 - 新增 pkg/money/format.go 统一金额处理包 - Round(): 角精度四舍五入 - Min/Max(): 金额比较 - Format(): 格式化字符串 - 更新业务模块使用统一金额函数 - internal/modules/order: 订单结算改为角精度 - internal/modules/dispute: 纠纷金额处理 - internal/modules/listing: 商品定价和存储 - 更新测试用例期望值为角精度 ## 前端改动 - 新增 shared/utils/money.ts 金额工具函数 - roundMoney(): 角精度四舍五入 - formatMoney(): 格式化为字符串(保留1位小数) - formatMoneyWithSymbol(): 添加¥符号 - 更新金额计算和显示逻辑 - shared/utils/pricing.ts: 定价计算 - shared/utils/listingDisplay.ts: 商品显示 - shared/composables/useMoney.ts: 组合式函数 - 修复视图文件导入声明 - features/listings/views: 商品详情页 - features/seller/views: 卖家管理页 ## 效果 - 金额显示:¥123.0(统一保留1位小数) - 计算精度:12.45 -> 12.5(角精度) - 减少误差:避免整元四舍五入损失 - 显示一致:全项目统一格式 ## 测试 - ✅ 后端单元测试通过 - ✅ TypeScript 类型检查通过 - ✅ 开发环境正常运行 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
487 lines
14 KiB
Go
487 lines
14 KiB
Go
package dispute
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"time"
|
|
|
|
"hfb_sys/backend/internal/auditlog"
|
|
"hfb_sys/backend/internal/model"
|
|
"hfb_sys/backend/internal/modules/notification"
|
|
"hfb_sys/backend/internal/modules/wallet"
|
|
"hfb_sys/backend/pkg/money"
|
|
|
|
"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
|
|
}
|
|
isCheckoutDispute := order.Status == "pending_checkout_confirm" || order.Status == "pending_checkout_accept"
|
|
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: disputeType(req.Type, isCheckoutDispute),
|
|
Status: "open",
|
|
Description: req.Description,
|
|
EvidenceURLS: evidence,
|
|
}
|
|
if err := tx.Create(&row).Error; err != nil {
|
|
return err
|
|
}
|
|
if isCheckoutDispute {
|
|
now := time.Now()
|
|
order.Status = "checkout_disputing"
|
|
order.HandoffStatus = "checkout_disputed"
|
|
order.SettlementStatus = "disputed"
|
|
updates := map[string]any{
|
|
"status": "disputed",
|
|
"updated_at": now,
|
|
}
|
|
if userID == order.RenterID {
|
|
updates["renter_rejected_at"] = now
|
|
}
|
|
if err := tx.Model(&model.OrderCheckout{}).
|
|
Where("order_id = ? AND status IN ?", order.ID, []string{"submitted", "countered"}).
|
|
Updates(updates).Error; err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
order.Status = "disputing"
|
|
}
|
|
if err := tx.Save(&order).Error; err != nil {
|
|
return err
|
|
}
|
|
disputeID := row.ID
|
|
title := "订单进入申诉"
|
|
content := "对方已发起申诉,请等待客服仲裁或补充沟通记录。"
|
|
if isCheckoutDispute {
|
|
title = "订单进入结账争议"
|
|
content = "对方已发起结账争议,请等待客服仲裁或补充结账证据。"
|
|
}
|
|
if err := notification.Append(tx,
|
|
notification.Entry{
|
|
UserID: targetID,
|
|
Type: "dispute",
|
|
Title: title,
|
|
Content: 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, page, pageSize int) (*PaginatedResult, error) {
|
|
conditions := r.db.Model(&model.Dispute{}).Where("initiator_id = ? OR target_user_id = ?", userID, userID)
|
|
var total int64
|
|
if err := conditions.Count(&total).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
offset := (page - 1) * pageSize
|
|
var rows []disputeRow
|
|
err := r.baseQuery().
|
|
Where("d.initiator_id = ? OR d.target_user_id = ?", userID, userID).
|
|
Order("d.id DESC").
|
|
Offset(offset).Limit(pageSize).
|
|
Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &PaginatedResult{Items: toDTOs(rows), Total: total, Page: page, PageSize: pageSize}, 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(page, pageSize int) (*PaginatedResult, error) {
|
|
var total int64
|
|
if err := r.db.Model(&model.Dispute{}).Count(&total).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
offset := (page - 1) * pageSize
|
|
var rows []disputeRow
|
|
err := r.baseQuery().Order("d.id DESC").Offset(offset).Limit(pageSize).Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &PaginatedResult{Items: toDTOs(rows), Total: total, Page: page, PageSize: pageSize}, nil
|
|
}
|
|
|
|
func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest, meta AuditMeta) (*DisputeDTO, error) {
|
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
|
var row model.Dispute
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
|
|
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
|
|
}
|
|
beforeOrderStatus := order.Status
|
|
beforeHandoffStatus := order.HandoffStatus
|
|
beforeSettlementStatus := order.SettlementStatus
|
|
beforeListingStatus := listing.Status
|
|
beforeAccountStatus := account.Status
|
|
settlement, err := buildArbitrationSettlement(order, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := time.Now()
|
|
row.Status = "resolved"
|
|
row.ArbitrationResult = req.Result
|
|
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"
|
|
}
|
|
if req.Result == "order_close" {
|
|
listing.Status = "offline"
|
|
account.Status = "offline"
|
|
} else if req.Result == "mark_abnormal" {
|
|
listing.Status = "abnormal"
|
|
account.Status = "abnormal"
|
|
} else {
|
|
listing.Status = "published"
|
|
account.Status = "published"
|
|
}
|
|
if err := wallet.AppendEntries(tx, settlement.Entries...); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Save(&row).Error; err != nil {
|
|
return err
|
|
}
|
|
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 := appendAuditLog(tx, adminID, "dispute.arbitrate", "dispute", row.ID, meta, map[string]any{
|
|
"dispute_id": row.ID,
|
|
"order_id": order.ID,
|
|
"order_no": order.OrderNo,
|
|
"result": req.Result,
|
|
"remark": req.Remark,
|
|
"input_amount": req.Amount,
|
|
"renter_refund_amount": settlement.RenterRefundAmount,
|
|
"owner_income_amount": settlement.OwnerIncomeAmount,
|
|
"deposit_deduct_amount": settlement.DepositDeductAmount,
|
|
"before_order_status": beforeOrderStatus,
|
|
"after_order_status": order.Status,
|
|
"before_handoff_status": beforeHandoffStatus,
|
|
"after_handoff_status": order.HandoffStatus,
|
|
"before_settlement_status": beforeSettlementStatus,
|
|
"after_settlement_status": order.SettlementStatus,
|
|
"before_listing_status": beforeListingStatus,
|
|
"after_listing_status": listing.Status,
|
|
"before_account_status": beforeAccountStatus,
|
|
"after_account_status": account.Status,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := notification.Append(tx,
|
|
notification.Entry{
|
|
UserID: order.RenterID,
|
|
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
|
|
}
|
|
|
|
type arbitrationSettlement struct {
|
|
Entries []wallet.Entry
|
|
RenterRefundAmount float64
|
|
OwnerIncomeAmount float64
|
|
DepositDeductAmount float64
|
|
}
|
|
|
|
func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest) (arbitrationSettlement, error) {
|
|
total := roundMoney(order.RentAmount + order.DepositAmount)
|
|
ownerRentAmount := roundMoney(order.OwnerRentAmount)
|
|
if ownerRentAmount <= 0 || ownerRentAmount > order.RentAmount {
|
|
ownerRentAmount = roundMoney(order.RentAmount)
|
|
}
|
|
settlement := arbitrationSettlement{}
|
|
orderID := order.ID
|
|
if total > 0 {
|
|
settlement.Entries = append(settlement.Entries, wallet.Entry{
|
|
UserID: order.RenterID,
|
|
OrderID: &orderID,
|
|
Direction: "out",
|
|
Amount: total,
|
|
BalanceType: "frozen",
|
|
BizType: "arbitration_release_frozen",
|
|
BizNo: order.OrderNo,
|
|
Remark: "仲裁释放开发态模拟冻结金额",
|
|
})
|
|
}
|
|
|
|
addRenterRefund := func(amount float64, remark string) {
|
|
if amount <= 0 {
|
|
return
|
|
}
|
|
settlement.RenterRefundAmount += amount
|
|
settlement.Entries = append(settlement.Entries, wallet.Entry{
|
|
UserID: order.RenterID,
|
|
OrderID: &orderID,
|
|
Direction: "in",
|
|
Amount: amount,
|
|
BalanceType: "available",
|
|
BizType: "arbitration_renter_refund",
|
|
BizNo: order.OrderNo,
|
|
Remark: remark,
|
|
})
|
|
}
|
|
addOwnerIncome := func(amount float64, remark string) {
|
|
if amount <= 0 {
|
|
return
|
|
}
|
|
settlement.OwnerIncomeAmount += amount
|
|
settlement.Entries = append(settlement.Entries, wallet.Entry{
|
|
UserID: order.OwnerID,
|
|
OrderID: &orderID,
|
|
Direction: "in",
|
|
Amount: amount,
|
|
BalanceType: "available",
|
|
BizType: "arbitration_owner_income",
|
|
BizNo: order.OrderNo,
|
|
Remark: remark,
|
|
})
|
|
}
|
|
|
|
switch req.Result {
|
|
case "full_refund":
|
|
addRenterRefund(total, "仲裁全额退款")
|
|
case "partial_refund":
|
|
req.Amount = roundMoney(req.Amount)
|
|
if req.Amount <= 0 || req.Amount > total {
|
|
return settlement, ErrInvalidDispute
|
|
}
|
|
addRenterRefund(req.Amount, "仲裁部分退款")
|
|
addOwnerIncome(minMoney(total-req.Amount, ownerRentAmount+order.DepositAmount), "仲裁剩余金额结算给号主")
|
|
case "release_deposit":
|
|
addOwnerIncome(ownerRentAmount, "仲裁确认订单金额结算给号主")
|
|
addRenterRefund(order.DepositAmount, "仲裁释放押金给租客")
|
|
case "deduct_deposit", "compensate_owner":
|
|
deductAmount := roundMoney(req.Amount)
|
|
if deductAmount <= 0 {
|
|
deductAmount = order.DepositAmount
|
|
}
|
|
if deductAmount > order.DepositAmount {
|
|
return settlement, ErrInvalidDispute
|
|
}
|
|
settlement.DepositDeductAmount = deductAmount
|
|
addOwnerIncome(ownerRentAmount+deductAmount, "仲裁订单金额及押金赔付结算给号主")
|
|
addRenterRefund(order.DepositAmount-deductAmount, "仲裁退回剩余押金给租客")
|
|
case "order_close":
|
|
// Only release frozen funds. No available-balance settlement happens in development mode.
|
|
case "mark_abnormal":
|
|
// 标记异常只释放冻结账务,后续由客服继续线下复核。
|
|
default:
|
|
return settlement, ErrInvalidDispute
|
|
}
|
|
return settlement, nil
|
|
}
|
|
|
|
// minMoney 返回较小金额(角精度)
|
|
func minMoney(a float64, b float64) float64 {
|
|
return money.Min(a, b)
|
|
}
|
|
|
|
// roundMoney 使用统一的角精度(0.1元)
|
|
func roundMoney(value float64) float64 {
|
|
return money.Round(value)
|
|
}
|
|
|
|
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"
|
|
case "mark_abnormal":
|
|
return "abnormal"
|
|
default:
|
|
return "completed"
|
|
}
|
|
}
|
|
|
|
func disputeType(input string, isCheckoutDispute bool) string {
|
|
if isCheckoutDispute {
|
|
return "checkout_dispute"
|
|
}
|
|
return input
|
|
}
|
|
|
|
func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizType string, bizID uint64, meta AuditMeta, detail map[string]any) error {
|
|
return auditlog.Append(tx, auditlog.Entry{
|
|
ActorType: "admin",
|
|
ActorID: actorID,
|
|
Action: action,
|
|
BizType: bizType,
|
|
BizID: &bizID,
|
|
Meta: meta,
|
|
Detail: detail,
|
|
})
|
|
}
|
|
|
|
func IsNotFound(err error) bool {
|
|
return errors.Is(err, gorm.ErrRecordNotFound)
|
|
}
|