Files
hfb_sys/backend/internal/modules/order/repository.go
T
2026-06-07 20:05:56 +08:00

1661 lines
50 KiB
Go

package order
import (
"crypto/rand"
"encoding/json"
"errors"
"log"
"math"
"strconv"
"time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/chat"
"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"
)
// RefundFunc 由 payment 模块注入,避免 order 与 payment 形成循环依赖。
type RefundFunc func(orderID uint64, refundAmountCent int64, bizType string, remark string) (status string, err error)
type refundAction struct {
OrderID uint64
RefundAmountCent int64
BizType string
Remark string
}
type Repository struct {
db *gorm.DB
chatRepo *chat.Repository
refundFunc RefundFunc
}
const defaultPendingPaymentTimeoutMinutes = 15
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) SetChatRepo(cr *chat.Repository) {
r.chatRepo = cr
}
func (r *Repository) SetRefundFunc(fn RefundFunc) {
r.refundFunc = fn
}
type orderPricing struct {
RentAmount float64
OwnerRentAmount float64
PlatformFee float64
}
type checkoutSettlement struct {
OwnerRentIncome float64
DepositCompensation float64
OwnerIncome float64
RentRefund float64
DepositRefund float64
RenterRefund float64
PlatformFee float64
ActualRentAmount float64
}
func orderDurationHours(order model.RentalOrder) int {
if order.EstimatedDurationHours > 0 {
return order.EstimatedDurationHours
}
return internalOrderHours
}
func buildOrderPricing(listing model.RentalListing, account model.GameAccount) orderPricing {
rentAmount := roundMoney(listing.Price)
ownerRentAmount := readSnapshotPrice(account.AssetSummary, "seller_total_price")
if ownerRentAmount <= 0 || ownerRentAmount > rentAmount {
ownerRentAmount = rentAmount
}
platformFee := readSnapshotPrice(account.AssetSummary, "platform_markup_amount")
if platformFee <= 0 || roundMoney(ownerRentAmount+platformFee) != rentAmount {
platformFee = roundMoney(rentAmount - ownerRentAmount)
}
if platformFee < 0 {
platformFee = 0
}
return orderPricing{
RentAmount: rentAmount,
OwnerRentAmount: roundMoney(ownerRentAmount),
PlatformFee: roundMoney(platformFee),
}
}
func readSnapshotPrice(raw datatypes.JSON, key string) float64 {
return readOrderSnapshotPrice(raw, key)
}
func readOrderSnapshotPrice(raw datatypes.JSON, key string) float64 {
if len(raw) == 0 {
return 0
}
var snapshot map[string]any
if err := json.Unmarshal(raw, &snapshot); err != nil {
return 0
}
if assetSummary, ok := snapshot["asset_summary"].(map[string]any); ok {
snapshot = assetSummary
}
breakdown, ok := snapshot["price_breakdown"].(map[string]any)
if !ok {
return 0
}
return readJSONNumber(breakdown[key])
}
func readJSONNumber(value any) float64 {
switch typed := value.(type) {
case float64:
return typed
case float32:
return float64(typed)
case int:
return float64(typed)
case int64:
return float64(typed)
case json.Number:
number, err := typed.Float64()
if err != nil {
return 0
}
return number
case string:
number, err := strconv.ParseFloat(typed, 64)
if err != nil {
return 0
}
return number
default:
return 0
}
}
func (r *Repository) Create(renterID uint64, req CreateRequest) (*OrderDTO, error) {
var createdID uint64
err := r.db.Transaction(func(tx *gorm.DB) error {
var listing model.RentalListing
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, req.ListingID).Error; err != nil {
return err
}
if listing.Status != "published" || listing.ReviewStatus != "approved" || listing.InTransaction {
return ErrListingUnavailable
}
if listing.OwnerID == renterID {
return ErrCannotRentOwnListing
}
var account model.GameAccount
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, listing.AccountID).Error; err != nil {
return err
}
snapshot, err := makeAccountSnapshot(account)
if err != nil {
return err
}
orderNo, err := newOrderNo()
if err != nil {
return err
}
rentHours := internalOrderHours
pricing := buildOrderPricing(listing, account)
order := model.RentalOrder{
OrderNo: orderNo,
ListingID: listing.ID,
AccountID: listing.AccountID,
OwnerID: listing.OwnerID,
RenterID: renterID,
EstimatedDurationHours: rentHours,
RentAmount: pricing.RentAmount,
OwnerRentAmount: pricing.OwnerRentAmount,
DepositAmount: listing.DepositAmount,
PlatformFee: pricing.PlatformFee,
AccountSnapshot: snapshot,
Status: "pending_payment",
HandoffStatus: "none",
SettlementStatus: "unsettled",
}
if err := tx.Create(&order).Error; err != nil {
return err
}
orderID := order.ID
if err := notification.Append(tx,
notification.Entry{
UserID: order.RenterID,
Type: "order",
Title: "订单已创建",
Content: "订单已创建,请在有效时间内完成支付。",
BizType: "order",
BizID: &orderID,
},
); err != nil {
return err
}
listing.InTransaction = true
if err := tx.Save(&listing).Error; err != nil {
return err
}
createdID = order.ID
return nil
})
if err != nil {
return nil, err
}
return r.FindForUser(renterID, createdID)
}
// Pay 保留旧接口兼容,但真实付款必须走 payment 模块的渠道支付入口。
func (r *Repository) Pay(userID uint64, orderID uint64) error {
return ErrChannelPaymentRequired
}
// ConfirmPaidFromChannel 在乐刷确认支付后推进订单状态;租客资金不进入站内钱包。
func (r *Repository) ConfirmPaidFromChannel(orderID uint64, providerBizNo string) error {
var newConvID 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.Status == "pending_handoff" || order.Status == "renting" {
return nil
}
if order.Status != "pending_payment" {
return ErrOrderCannotPay
}
var listing model.RentalListing
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil {
return err
}
if listing.Status != "published" || listing.ReviewStatus != "approved" || !listing.InTransaction {
return ErrListingUnavailable
}
var account model.GameAccount
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil {
return err
}
// 租客已通过外部渠道付款,这里不写租客钱包流水。
orderID := order.ID
order.Status = "pending_handoff"
order.HandoffStatus = "pending_owner"
listing.Status = "rented"
account.Status = "rented"
conv, err := chat.EnsureOrderConversation(tx, order)
if err != nil {
return err
}
newConvID = conv.ID
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
}
if err := tx.Save(&order).Error; err != nil {
return err
}
if err := tx.Save(&listing).Error; err != nil {
return err
}
return tx.Save(&account).Error
})
if err != nil {
return err
}
if newConvID > 0 && r.chatRepo != nil {
r.chatRepo.NotifyNewConversation(newConvID)
}
return nil
}
func (r *Repository) Cancel(userID uint64, orderID uint64) error {
var refund *refundAction
err := r.db.Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND renter_id = ?", orderID, userID).
First(&order).Error; err != nil {
return err
}
if order.Status != "pending_payment" && order.Status != "pending_handoff" {
return ErrOrderCannotCancel
}
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
}
beforeStatus := order.Status
order.Status = "cancelled"
order.HandoffStatus = "cancelled"
orderID := order.ID
if beforeStatus == "pending_handoff" {
totalCent := int64(math.Round((order.RentAmount + order.DepositAmount) * 100))
action, err := r.prepareRefund(&order, totalCent, "cancel_refund", "取消订单原路退款")
if err != nil {
return err
}
refund = action
}
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"
listing.InTransaction = false
account.Status = "published"
if err := tx.Save(&order).Error; err != nil {
return err
}
if err := tx.Save(&listing).Error; err != nil {
return err
}
return tx.Save(&account).Error
})
if err != nil {
return err
}
r.startRefundBestEffort(refund)
return nil
}
func (r *Repository) SubmitHandoff(userID uint64, orderID uint64, req SubmitHandoffRequest) (*HandoffRecordDTO, error) {
var recordID 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.OwnerID != userID {
return ErrPermissionDenied
}
if order.Status != "pending_handoff" || order.HandoffStatus != "pending_owner" {
return ErrOrderCannotHandoff
}
record := model.HandoffRecord{
OrderID: order.ID,
FromUserID: order.OwnerID,
ToUserID: order.RenterID,
Type: "owner_handoff",
Content: req.Content,
}
if err := tx.Create(&record).Error; err != nil {
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
}
recordID = record.ID
return nil
})
if err != nil {
return nil, err
}
return r.findHandoffRecord(recordID)
}
func (r *Repository) ConfirmReceive(userID uint64, orderID uint64) error {
return 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 {
return ErrPermissionDenied
}
if order.Status != "pending_handoff" || order.HandoffStatus != "pending_renter_confirm" {
return ErrOrderCannotReceive
}
now := time.Now()
if err := tx.Model(&model.HandoffRecord{}).
Where("order_id = ? AND type = ?", order.ID, "owner_handoff").
Update("confirmed_by_renter_at", now).Error; err != nil {
return err
}
order.Status = "renting"
order.HandoffStatus = "received"
durationHours := orderDurationHours(order)
order.EstimatedDurationHours = durationHours
order.RentedAt = &now
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
})
}
func (r *Repository) HandoffRecords(userID uint64, orderID uint64) ([]HandoffRecordDTO, error) {
var order model.RentalOrder
if err := r.db.Where("id = ? AND (renter_id = ? OR owner_id = ?)", orderID, userID, userID).First(&order).Error; err != nil {
return nil, err
}
var records []model.HandoffRecord
if err := r.db.Where("order_id = ?", orderID).Order("id ASC").Find(&records).Error; err != nil {
return nil, err
}
items := make([]HandoffRecordDTO, 0, len(records))
for _, record := range records {
items = append(items, toHandoffDTO(record))
}
return items, nil
}
func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitReturnRequest) (*HandoffRecordDTO, error) {
return r.SubmitCheckout(userID, orderID, SubmitCheckoutRequest{Content: req.Content})
}
func (r *Repository) SubmitCheckout(userID uint64, orderID uint64, req SubmitCheckoutRequest) (*HandoffRecordDTO, error) {
var recordID 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 {
return ErrPermissionDenied
}
if (order.Status != "renting" && order.Status != "overdue") || (order.HandoffStatus != "received" && order.HandoffStatus != "return_overdue") {
return ErrCheckoutCannotSubmit
}
hasOpen, err := hasOpenCheckout(tx, order.ID)
if err != nil {
return err
}
if hasOpen {
return ErrCheckoutCannotSubmit
}
record := model.HandoffRecord{
OrderID: order.ID,
FromUserID: order.RenterID,
ToUserID: order.OwnerID,
Type: "renter_checkout",
Content: req.Content,
}
if err := tx.Create(&record).Error; err != nil {
return err
}
checkout, err := buildCheckout(order, order.RenterID, "submitted", req.Content, req.EvidenceURLS, req.ConsumableAmount, req.CoinConsumedM, req.OtherAmount, 0, false)
if err != nil {
return err
}
if err := tx.Create(&checkout).Error; err != nil {
return err
}
order.Status = "pending_checkout_confirm"
order.HandoffStatus = "pending_owner_checkout"
order.SettlementStatus = "pending"
orderID := order.ID
if err := notification.Append(tx, notification.Entry{
UserID: order.OwnerID,
Type: "checkout",
Title: "租客已发起结账",
Content: "请检查账号状态和消耗明细,确认无误后完成结算。",
BizType: "order",
BizID: &orderID,
}); err != nil {
return err
}
if err := tx.Save(&order).Error; err != nil {
return err
}
recordID = record.ID
return nil
})
if err != nil {
return nil, err
}
return r.findHandoffRecord(recordID)
}
func (r *Repository) ConfirmReturn(userID uint64, orderID uint64) error {
return r.ConfirmCheckout(userID, orderID)
}
func (r *Repository) ConfirmCheckout(userID uint64, orderID uint64) error {
var refund *refundAction
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.OwnerID != userID {
return ErrPermissionDenied
}
if order.Status != "pending_checkout_confirm" || order.HandoffStatus != "pending_owner_checkout" {
return ErrCheckoutCannotConfirm
}
var checkout model.OrderCheckout
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("order_id = ? AND status = ?", order.ID, "submitted").
Order("id DESC").
First(&checkout).Error; err != nil {
return err
}
now := time.Now()
if err := tx.Model(&model.HandoffRecord{}).
Where("order_id = ? AND type = ?", order.ID, "renter_checkout").
Update("confirmed_by_owner_at", now).Error; err != nil {
return err
}
checkout.Status = "accepted"
checkout.OwnerAdjustedAt = &now
action, err := r.finalizeCheckout(tx, &order, &checkout, "号主已确认结账,订单完成。")
refund = action
return err
})
if err != nil {
return err
}
r.startRefundBestEffort(refund)
return nil
}
func (r *Repository) CounterCheckout(userID uint64, orderID uint64, req CounterCheckoutRequest) (*CheckoutDTO, error) {
var checkoutID 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.OwnerID != userID {
return ErrPermissionDenied
}
if order.Status != "pending_checkout_confirm" || order.HandoffStatus != "pending_owner_checkout" {
return ErrCheckoutCannotCounter
}
var checkout model.OrderCheckout
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("order_id = ? AND status = ?", order.ID, "submitted").
Order("id DESC").
First(&checkout).Error; err != nil {
return err
}
next, err := buildCheckout(order, checkout.InitiatedBy, "countered", checkout.Content, req.EvidenceURLS, req.ConsumableAmount, req.CoinConsumedM, req.OtherAmount, req.DepositDeductAmount, true)
if err != nil {
return err
}
now := time.Now()
checkout.Status = "countered"
checkout.ConsumableAmount = next.ConsumableAmount
checkout.CoinConsumedM = next.CoinConsumedM
checkout.OtherAmount = next.OtherAmount
checkout.DepositDeductAmount = next.DepositDeductAmount
checkout.RenterRefundAmount = next.RenterRefundAmount
checkout.OwnerIncomeAmount = next.OwnerIncomeAmount
checkout.OwnerAdjustmentReason = req.Reason
checkout.OwnerAdjustedAt = &now
checkout.EvidenceURLS = next.EvidenceURLS
order.Status = "pending_checkout_accept"
order.HandoffStatus = "pending_renter_checkout"
order.SettlementStatus = "pending"
orderID := order.ID
if err := notification.Append(tx, notification.Entry{
UserID: order.RenterID,
Type: "checkout",
Title: "号主已修改结账金额",
Content: "请核对号主修正的消耗和结算金额。同意后订单完成;不同意可发起争议。",
BizType: "order",
BizID: &orderID,
}); err != nil {
return err
}
if err := tx.Save(&order).Error; err != nil {
return err
}
if err := tx.Save(&checkout).Error; err != nil {
return err
}
checkoutID = checkout.ID
return nil
})
if err != nil {
return nil, err
}
checkout, err := r.findCheckout(checkoutID)
if err != nil {
return nil, err
}
dto := toCheckoutDTOForUser(*checkout, userID, model.RentalOrder{
OwnerID: userID,
RentAmount: checkout.RentAmount,
OwnerRentAmount: checkout.OwnerRentAmount,
DepositAmount: checkout.DepositAmount,
})
return &dto, nil
}
func (r *Repository) AcceptCheckout(userID uint64, orderID uint64) error {
var refund *refundAction
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 {
return ErrPermissionDenied
}
if order.Status != "pending_checkout_accept" || order.HandoffStatus != "pending_renter_checkout" {
return ErrCheckoutCannotConfirm
}
var checkout model.OrderCheckout
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("order_id = ? AND status = ?", order.ID, "countered").
Order("id DESC").
First(&checkout).Error; err != nil {
return err
}
now := time.Now()
checkout.Status = "accepted"
checkout.RenterConfirmedAt = &now
action, err := r.finalizeCheckout(tx, &order, &checkout, "租客已确认修正结账,订单完成。")
refund = action
return err
})
if err != nil {
return err
}
r.startRefundBestEffort(refund)
return nil
}
func (r *Repository) ListForUser(userID uint64) ([]OrderDTO, error) {
var rows []orderRow
err := r.baseQuery().
Where("o.renter_id = ? OR o.owner_id = ?", userID, userID).
Order("o.id DESC").
Scan(&rows).Error
if err != nil {
return nil, err
}
items := make([]OrderDTO, 0, len(rows))
paymentTimeoutMinutes := pendingPaymentTimeoutMinutes(r.db)
for _, row := range rows {
dto := row.toDTOForUser(userID)
applyPaymentDeadline(&dto, row.RentalOrder, paymentTimeoutMinutes)
if shouldAttachCheckout(row.Status) {
dto.Checkout = r.latestCheckoutDTOForUser(row.ID, userID, row.RentalOrder)
}
items = append(items, dto)
}
return items, nil
}
func (r *Repository) ListAdmin(page, pageSize int) (*PaginatedResult, error) {
var total int64
if err := r.adminQuery().Count(&total).Error; err != nil {
return nil, err
}
offset := (page - 1) * pageSize
var rows []orderRow
err := r.adminQuery().
Order("o.id DESC").
Limit(pageSize).
Offset(offset).
Scan(&rows).Error
if err != nil {
return nil, err
}
items := make([]OrderDTO, 0, len(rows))
paymentTimeoutMinutes := pendingPaymentTimeoutMinutes(r.db)
for _, row := range rows {
dto := row.toAdminDTO()
applyPaymentDeadline(&dto, row.RentalOrder, paymentTimeoutMinutes)
items = append(items, dto)
}
return &PaginatedResult{
Items: items,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
func (r *Repository) FindAdmin(orderID uint64) (*OrderDTO, error) {
var row orderRow
if err := r.adminQuery().Where("o.id = ?", orderID).First(&row).Error; err != nil {
return nil, err
}
dto := row.toAdminDTO()
applyPaymentDeadline(&dto, row.RentalOrder, pendingPaymentTimeoutMinutes(r.db))
dto.Checkout = r.latestCheckoutAdminDTO(orderID)
return &dto, nil
}
func (r *Repository) HandoffRecordsAdmin(orderID uint64) ([]HandoffRecordDTO, error) {
var order model.RentalOrder
if err := r.db.First(&order, orderID).Error; err != nil {
return nil, err
}
var records []model.HandoffRecord
if err := r.db.Where("order_id = ?", orderID).Order("id ASC").Find(&records).Error; err != nil {
return nil, err
}
items := make([]HandoffRecordDTO, 0, len(records))
for _, record := range records {
items = append(items, toHandoffDTO(record))
}
return items, nil
}
func (r *Repository) AdminClose(adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
var refund *refundAction
err := r.db.Transaction(func(tx *gorm.DB) error {
order, listing, account, err := r.findOrderAssetsForAdminUpdate(tx, orderID)
if err != nil {
return err
}
if isTerminalStatus(order.Status) {
return ErrOrderCannotComplete
}
beforeOrderStatus := order.Status
beforeHandoffStatus := order.HandoffStatus
beforeSettlementStatus := order.SettlementStatus
beforeListingStatus := listing.Status
beforeAccountStatus := account.Status
now := time.Now()
order.Status = "closed"
order.HandoffStatus = "admin_closed"
order.SettlementStatus = "closed"
order.SettledAt = &now
listing.Status = "offline"
listing.InTransaction = false
account.Status = "offline"
if beforeOrderStatus != "pending_payment" {
totalCent := int64(math.Round((order.RentAmount + order.DepositAmount) * 100))
action, err := r.prepareRefund(order, totalCent, "admin_close_refund", "客服关闭订单原路退款")
if err != nil {
return err
}
refund = action
}
if err := notification.Append(tx,
notification.Entry{
UserID: order.RenterID,
Type: "order_admin",
Title: "订单已由客服关闭",
Content: "客服已关闭订单,退款将原路退回您的支付账户。原因:" + req.Reason,
BizType: "order",
BizID: &order.ID,
},
notification.Entry{
UserID: order.OwnerID,
Type: "order_admin",
Title: "订单已由客服关闭",
Content: "客服已关闭订单,关联商品已下架。原因:" + req.Reason,
BizType: "order",
BizID: &order.ID,
},
); err != nil {
return err
}
if err := appendAuditLog(tx, adminID, "order.admin_close", "order", order.ID, meta, map[string]any{
"order_id": order.ID,
"order_no": order.OrderNo,
"listing_id": order.ListingID,
"account_id": order.AccountID,
"reason": req.Reason,
"before_order_status": beforeOrderStatus,
"after_order_status": order.Status,
"before_handoff_status": beforeHandoffStatus,
"after_handoff_status": order.HandoffStatus,
"before_settlement_status": beforeSettlementStatus,
"after_settlement_status": order.SettlementStatus,
"before_listing_status": beforeListingStatus,
"after_listing_status": listing.Status,
"before_account_status": beforeAccountStatus,
"after_account_status": account.Status,
}); err != nil {
return err
}
if err := tx.Save(order).Error; err != nil {
return err
}
if err := tx.Save(listing).Error; err != nil {
return err
}
return tx.Save(account).Error
})
if err != nil {
return err
}
r.startRefundBestEffort(refund)
return nil
}
func (r *Repository) AdminMarkAbnormal(adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
return r.db.Transaction(func(tx *gorm.DB) error {
order, listing, account, err := r.findOrderAssetsForAdminUpdate(tx, orderID)
if err != nil {
return err
}
if isTerminalStatus(order.Status) {
return ErrOrderCannotComplete
}
beforeOrderStatus := order.Status
beforeHandoffStatus := order.HandoffStatus
beforeListingStatus := listing.Status
beforeAccountStatus := account.Status
order.Status = "abnormal"
order.HandoffStatus = "admin_abnormal"
listing.Status = "abnormal"
listing.InTransaction = false
account.Status = "abnormal"
if err := notification.Append(tx,
notification.Entry{
UserID: order.RenterID,
Type: "order_admin",
Title: "订单已被标记异常",
Content: "客服已将订单标记为异常,请等待进一步处理。原因:" + req.Reason,
BizType: "order",
BizID: &order.ID,
},
notification.Entry{
UserID: order.OwnerID,
Type: "order_admin",
Title: "订单已被标记异常",
Content: "客服已将订单标记为异常,关联商品暂不可出租。原因:" + req.Reason,
BizType: "order",
BizID: &order.ID,
},
); err != nil {
return err
}
if err := appendAuditLog(tx, adminID, "order.mark_abnormal", "order", order.ID, meta, map[string]any{
"order_id": order.ID,
"order_no": order.OrderNo,
"listing_id": order.ListingID,
"account_id": order.AccountID,
"reason": req.Reason,
"before_order_status": beforeOrderStatus,
"after_order_status": order.Status,
"before_handoff_status": beforeHandoffStatus,
"after_handoff_status": order.HandoffStatus,
"before_listing_status": beforeListingStatus,
"after_listing_status": listing.Status,
"before_account_status": beforeAccountStatus,
"after_account_status": account.Status,
}); err != nil {
return err
}
if err := tx.Save(order).Error; err != nil {
return err
}
if err := tx.Save(listing).Error; err != nil {
return err
}
return tx.Save(account).Error
})
}
// AdminRefund 触发后台人工退款,退款由 payment 模块走渠道原路退回。
func (r *Repository) AdminRefund(orderID uint64) (*RefundStatusDTO, error) {
var order model.RentalOrder
if err := r.db.First(&order, orderID).Error; err != nil {
return nil, err
}
if order.RefundStatus == "refunded" {
return r.buildRefundStatusDTO(&order), nil
}
if r.refundFunc == nil {
return nil, ErrDependencyUnavailable
}
totalCent := int64(math.Round((order.RentAmount + order.DepositAmount) * 100))
if totalCent <= 0 {
return nil, ErrInvalidCheckoutAmount
}
status, err := r.refundFunc(orderID, totalCent, "admin_refund", "后台人工退款")
if err != nil {
return nil, err
}
// 重新读取订单,拿到 payment 模块更新后的退款字段。
if err := r.db.First(&order, orderID).Error; err != nil {
return nil, err
}
dto := r.buildRefundStatusDTO(&order)
if status != "" {
dto.RefundStatus = status
}
return dto, nil
}
// AdminRefundStatus 查询订单退款状态。
func (r *Repository) AdminRefundStatus(orderID uint64) (*RefundStatusDTO, error) {
var order model.RentalOrder
if err := r.db.First(&order, orderID).Error; err != nil {
return nil, err
}
return r.buildRefundStatusDTO(&order), nil
}
func (r *Repository) buildRefundStatusDTO(order *model.RentalOrder) *RefundStatusDTO {
return &RefundStatusDTO{
OrderID: order.ID,
OrderNo: order.OrderNo,
RefundStatus: order.RefundStatus,
RefundAmountCent: order.RefundAmountCent,
RefundedAt: order.RefundedAt,
TotalAmount: order.RentAmount + order.DepositAmount,
}
}
func (r *Repository) FindForUser(userID uint64, orderID uint64) (*OrderDTO, error) {
var row orderRow
if err := r.baseQuery().
Where("o.id = ? AND (o.renter_id = ? OR o.owner_id = ?)", orderID, userID, userID).
First(&row).Error; err != nil {
return nil, err
}
dto := row.toDTOForUser(userID)
applyPaymentDeadline(&dto, row.RentalOrder, pendingPaymentTimeoutMinutes(r.db))
dto.Checkout = r.latestCheckoutDTOForUser(orderID, userID, row.RentalOrder)
return &dto, nil
}
func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, checkout *model.OrderCheckout, renterContent string) (*refundAction, error) {
var listing model.RentalListing
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil {
return nil, err
}
var account model.GameAccount
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil {
return nil, err
}
now := time.Now()
order.Status = "completed"
order.HandoffStatus = "returned"
order.SettlementStatus = "settled"
order.SettledAt = &now
order.OwnerSettledAt = &now
archiveListingAfterCheckout(&listing, &account)
orderID := order.ID
settlement := buildCheckoutSettlement(*order, checkout)
// 卖家收入进入站内钱包;租客资金不进入站内钱包。
var ownerEntries []wallet.Entry
if settlement.OwnerRentIncome > 0 {
ownerEntries = append(ownerEntries, wallet.Entry{
UserID: order.OwnerID,
OrderID: &orderID,
Direction: "in",
Amount: settlement.OwnerRentIncome,
BalanceType: "available",
BizType: "owner_income",
BizNo: order.OrderNo,
Remark: "订单结账租金收入",
})
}
if settlement.DepositCompensation > 0 {
ownerEntries = append(ownerEntries, wallet.Entry{
UserID: order.OwnerID,
OrderID: &orderID,
Direction: "in",
Amount: settlement.DepositCompensation,
BalanceType: "available",
BizType: "deposit_compensation",
BizNo: order.OrderNo,
Remark: "订单结账押金赔付",
})
}
if len(ownerEntries) > 0 {
if err := wallet.AppendEntries(tx, ownerEntries...); err != nil {
return nil, err
}
}
var refund *refundAction
renterRefundTotal := settlement.RentRefund + settlement.DepositRefund
if renterRefundTotal > 0 {
refundCent := int64(math.Round(renterRefundTotal * 100))
action, err := r.prepareRefund(order, refundCent, "checkout_refund", "结账退款原路退还")
if err != nil {
return nil, err
}
refund = action
}
checkout.RentAmount = settlement.ActualRentAmount
checkout.OwnerRentAmount = settlement.OwnerRentIncome
checkout.PlatformFee = settlement.PlatformFee
checkout.RenterRefundAmount = settlement.RenterRefund
checkout.OwnerIncomeAmount = settlement.OwnerIncome
if err := notification.Append(tx,
notification.Entry{
UserID: order.RenterID,
Type: "settlement",
Title: "订单已完成",
Content: renterContent,
BizType: "order",
BizID: &orderID,
},
notification.Entry{
UserID: order.OwnerID,
Type: "settlement",
Title: "订单已完成",
Content: "订单已完成,结账金额已入账。",
BizType: "order",
BizID: &orderID,
},
); err != nil {
return nil, err
}
if err := tx.Save(order).Error; err != nil {
return nil, err
}
if err := tx.Save(checkout).Error; err != nil {
return nil, err
}
if err := tx.Save(&listing).Error; err != nil {
return nil, err
}
if err := tx.Save(&account).Error; err != nil {
return nil, err
}
return refund, nil
}
// 完成后的账号先下架,避免已完成订单对应的账号重新出现在公开首页。
func archiveListingAfterCheckout(listing *model.RentalListing, account *model.GameAccount) {
listing.Status = "offline"
listing.InTransaction = false
listing.PublishedAt = nil
account.Status = "offline"
}
func (r *Repository) prepareRefund(order *model.RentalOrder, amountCent int64, bizType string, remark string) (*refundAction, error) {
if amountCent <= 0 {
return nil, nil
}
if r.refundFunc == nil {
return nil, ErrDependencyUnavailable
}
order.RefundStatus = "pending"
order.RefundAmountCent = amountCent
order.RefundedAt = nil
return &refundAction{
OrderID: order.ID,
RefundAmountCent: amountCent,
BizType: bizType,
Remark: remark,
}, nil
}
func (r *Repository) startRefundBestEffort(action *refundAction) {
if action == nil || r.refundFunc == nil {
return
}
if _, err := r.refundFunc(action.OrderID, action.RefundAmountCent, action.BizType, action.Remark); err != nil {
log.Printf("[order] start refund failed order_id=%d biz_type=%s amount_cent=%d err=%v", action.OrderID, action.BizType, action.RefundAmountCent, err)
}
}
func (r *Repository) findOrderAssetsForAdminUpdate(tx *gorm.DB, orderID uint64) (*model.RentalOrder, *model.RentalListing, *model.GameAccount, error) {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
return nil, nil, nil, err
}
var listing model.RentalListing
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil {
return nil, nil, nil, err
}
var account model.GameAccount
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil {
return nil, nil, nil, err
}
return &order, &listing, &account, nil
}
func isTerminalStatus(status string) bool {
return status == "completed" || status == "cancelled" || status == "closed"
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func (r *Repository) findHandoffRecord(id uint64) (*HandoffRecordDTO, error) {
var record model.HandoffRecord
if err := r.db.First(&record, id).Error; err != nil {
return nil, err
}
dto := toHandoffDTO(record)
return &dto, nil
}
func pendingPaymentTimeoutMinutes(tx *gorm.DB) int {
var row model.SystemConfig
err := tx.Where("`key` = ?", "order.pending_payment_timeout_minutes").First(&row).Error
if err != nil {
return defaultPendingPaymentTimeoutMinutes
}
value, err := strconv.Atoi(row.Value)
if err != nil || value < 0 {
return defaultPendingPaymentTimeoutMinutes
}
return value
}
func applyPaymentDeadline(dto *OrderDTO, order model.RentalOrder, timeoutMinutes int) {
if dto == nil || order.Status != "pending_payment" || timeoutMinutes <= 0 {
return
}
deadline := order.CreatedAt.Add(time.Duration(timeoutMinutes) * time.Minute)
dto.PaymentDeadlineAt = &deadline
}
func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, content string, evidenceURLS []string, consumableAmount float64, coinConsumedM float64, otherAmount float64, explicitDeduct float64, useExplicitDeduct bool) (model.OrderCheckout, error) {
if consumableAmount < 0 || coinConsumedM < 0 || otherAmount < 0 || explicitDeduct < 0 {
return model.OrderCheckout{}, ErrInvalidCheckoutAmount
}
consumableAmount = roundMoney(consumableAmount)
otherAmount = roundMoney(otherAmount)
explicitDeduct = roundMoney(explicitDeduct)
deductAmount := otherAmount
if useExplicitDeduct {
deductAmount = explicitDeduct
}
if deductAmount > order.DepositAmount {
return model.OrderCheckout{}, ErrInvalidCheckoutAmount
}
settlement := calculateCheckoutSettlement(order, consumableAmount, roundQuantity(coinConsumedM), deductAmount)
evidence, err := marshalStringList(evidenceURLS)
if err != nil {
return model.OrderCheckout{}, err
}
return model.OrderCheckout{
OrderID: order.ID,
InitiatedBy: initiatedBy,
Status: status,
RentAmount: settlement.ActualRentAmount,
OwnerRentAmount: settlement.OwnerRentIncome,
PlatformFee: settlement.PlatformFee,
DepositAmount: order.DepositAmount,
ConsumableAmount: consumableAmount,
CoinConsumedM: roundQuantity(coinConsumedM),
OtherAmount: otherAmount,
DepositDeductAmount: roundMoney(deductAmount),
RenterRefundAmount: settlement.RenterRefund,
OwnerIncomeAmount: settlement.OwnerIncome,
Content: content,
EvidenceURLS: evidence,
}, nil
}
func buildCheckoutSettlement(order model.RentalOrder, checkout *model.OrderCheckout) checkoutSettlement {
return calculateCheckoutSettlement(order, checkout.ConsumableAmount, checkout.CoinConsumedM, checkout.DepositDeductAmount)
}
func calculateCheckoutSettlement(order model.RentalOrder, consumableAmount float64, coinConsumedM float64, depositDeductAmount float64) checkoutSettlement {
buyerCoinBasePrice := readOrderSnapshotPrice(order.AccountSnapshot, "buyer_coin_base_price")
if buyerCoinBasePrice <= 0 || buyerCoinBasePrice > order.RentAmount {
buyerCoinBasePrice = order.RentAmount
}
sellerCoinBasePrice := readOrderSnapshotPrice(order.AccountSnapshot, "seller_coin_base_price")
if sellerCoinBasePrice <= 0 || sellerCoinBasePrice > order.OwnerRentAmount {
sellerCoinBasePrice = order.OwnerRentAmount
}
prepaidConsumablePrice := readOrderSnapshotPrice(order.AccountSnapshot, "consumable_price")
if prepaidConsumablePrice <= 0 || prepaidConsumablePrice > order.RentAmount-buyerCoinBasePrice {
prepaidConsumablePrice = maxMoney(order.RentAmount-buyerCoinBasePrice, 0)
}
prepaidOwnerConsumablePrice := maxMoney(order.OwnerRentAmount-sellerCoinBasePrice, 0)
totalCoinM := readOrderSnapshotCoinM(order.AccountSnapshot)
coinUseRatio := 1.0
if totalCoinM > 0 {
coinUseRatio = minRatio(maxRatio(roundQuantity(coinConsumedM)/totalCoinM, 0), 1)
}
usedBuyerCoinPrice := roundMoney(buyerCoinBasePrice * coinUseRatio)
usedOwnerCoinPrice := roundMoney(sellerCoinBasePrice * coinUseRatio)
usedBuyerConsumablePrice := minMoney(roundMoney(consumableAmount), prepaidConsumablePrice)
consumableUseRatio := 1.0
if prepaidConsumablePrice > 0 {
consumableUseRatio = minRatio(maxRatio(usedBuyerConsumablePrice/prepaidConsumablePrice, 0), 1)
}
usedOwnerConsumablePrice := roundMoney(prepaidOwnerConsumablePrice * consumableUseRatio)
actualRentAmount := minMoney(roundMoney(usedBuyerCoinPrice+usedBuyerConsumablePrice), order.RentAmount)
ownerRentIncome := minMoney(roundMoney(usedOwnerCoinPrice+usedOwnerConsumablePrice), order.OwnerRentAmount)
depositCompensation := minMoney(roundMoney(depositDeductAmount), order.DepositAmount)
rentRefund := maxMoney(order.RentAmount-actualRentAmount, 0)
depositRefund := maxMoney(order.DepositAmount-depositCompensation, 0)
return checkoutSettlement{
OwnerRentIncome: ownerRentIncome,
DepositCompensation: depositCompensation,
OwnerIncome: roundMoney(ownerRentIncome + depositCompensation),
RentRefund: rentRefund,
DepositRefund: depositRefund,
RenterRefund: roundMoney(rentRefund + depositRefund),
PlatformFee: maxMoney(actualRentAmount-ownerRentIncome, 0),
ActualRentAmount: actualRentAmount,
}
}
func readOrderSnapshotCoinM(raw datatypes.JSON) float64 {
if len(raw) == 0 {
return 0
}
var snapshot map[string]any
if err := json.Unmarshal(raw, &snapshot); err != nil {
return 0
}
return roundQuantity(readJSONNumber(snapshot["haf_coin_amount"]) / 1000000)
}
func marshalStringList(items []string) (datatypes.JSON, error) {
if items == nil {
items = []string{}
}
raw, err := json.Marshal(items)
return datatypes.JSON(raw), err
}
func decodeStringList(raw datatypes.JSON) []string {
if len(raw) == 0 {
return []string{}
}
var items []string
if err := json.Unmarshal(raw, &items); err != nil {
return []string{}
}
return items
}
// roundMoney 使用统一的角精度(0.1元)
func roundMoney(value float64) float64 {
return money.Round(value)
}
func roundQuantity(value float64) float64 {
return math.Round(value*100) / 100
}
// minMoney 返回较小金额(角精度)
func minMoney(a float64, b float64) float64 {
return money.Min(a, b)
}
// maxMoney 返回较大金额(角精度)
func maxMoney(a float64, b float64) float64 {
return money.Max(a, b)
}
func minRatio(a float64, b float64) float64 {
if a < b {
return a
}
return b
}
func maxRatio(a float64, b float64) float64 {
if a > b {
return a
}
return b
}
func hasOpenCheckout(tx *gorm.DB, orderID uint64) (bool, error) {
var count int64
err := tx.Model(&model.OrderCheckout{}).
Where("order_id = ? AND status IN ?", orderID, []string{"submitted", "countered", "accepted", "disputed"}).
Count(&count).Error
return count > 0, err
}
func (r *Repository) latestCheckoutAdminDTO(orderID uint64) *CheckoutDTO {
var checkout model.OrderCheckout
if err := r.db.Where("order_id = ?", orderID).Order("id DESC").First(&checkout).Error; err != nil {
return nil
}
dto := toCheckoutAdminDTO(checkout)
return &dto
}
func shouldAttachCheckout(status string) bool {
switch status {
case "pending_checkout_confirm", "pending_checkout_accept", "checkout_disputing", "completed":
return true
default:
return false
}
}
func (r *Repository) latestCheckoutDTOForUser(orderID uint64, userID uint64, order model.RentalOrder) *CheckoutDTO {
var checkout model.OrderCheckout
if err := r.db.Where("order_id = ?", orderID).Order("id DESC").First(&checkout).Error; err != nil {
return nil
}
dto := toCheckoutDTOForUser(checkout, userID, order)
return &dto
}
func (r *Repository) findCheckout(id uint64) (*model.OrderCheckout, error) {
var checkout model.OrderCheckout
if err := r.db.First(&checkout, id).Error; err != nil {
return nil, err
}
return &checkout, nil
}
func (r *Repository) baseQuery() *gorm.DB {
return r.db.Table("rental_orders AS o").
Select("o.*, a.title, a.server_region, a.login_platform").
Joins("JOIN game_accounts AS a ON a.id = o.account_id")
}
func (r *Repository) adminQuery() *gorm.DB {
return r.db.Table("rental_orders AS o").
Select("o.*, a.title, a.server_region, a.login_platform, owner.phone AS owner_phone, renter.phone AS renter_phone").
Joins("JOIN game_accounts AS a ON a.id = o.account_id").
Joins("JOIN users AS owner ON owner.id = o.owner_id").
Joins("JOIN users AS renter ON renter.id = o.renter_id")
}
type orderRow struct {
model.RentalOrder
Title string
ServerRegion string
LoginPlatform string
OwnerPhone string
RenterPhone string
}
func (row orderRow) toAdminDTO() OrderDTO {
rentedAt := row.RentedAt
durationHours := orderDurationHours(row.RentalOrder)
rentAmount := row.RentAmount
ownerRentAmount := row.OwnerRentAmount
platformFee := row.PlatformFee
return OrderDTO{
ID: row.ID,
OrderNo: row.OrderNo,
ListingID: row.ListingID,
AccountID: row.AccountID,
OwnerID: row.OwnerID,
RenterID: row.RenterID,
OwnerPhone: row.OwnerPhone,
RenterPhone: row.RenterPhone,
Title: row.Title,
ServerRegion: row.ServerRegion,
LoginPlatform: row.LoginPlatform,
RentedAt: rentedAt,
EstimatedDurationHours: durationHours,
PriceRole: "admin",
DisplayAmount: row.RentAmount,
RentAmount: &rentAmount,
OwnerRentAmount: &ownerRentAmount,
DepositAmount: row.DepositAmount,
PlatformFee: &platformFee,
AccountSnapshot: row.AccountSnapshot,
Status: row.Status,
HandoffStatus: row.HandoffStatus,
SettlementStatus: row.SettlementStatus,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
func (row orderRow) toDTOForUser(userID uint64) OrderDTO {
dto := row.toAdminDTO()
applyOrderPriceView(&dto, row.RentalOrder, userID)
return dto
}
func toHandoffDTO(record model.HandoffRecord) HandoffRecordDTO {
return HandoffRecordDTO{
ID: record.ID,
OrderID: record.OrderID,
FromUserID: record.FromUserID,
ToUserID: record.ToUserID,
Type: record.Type,
Content: record.Content,
ConfirmedByRenterAt: record.ConfirmedByRenterAt,
ConfirmedByOwnerAt: record.ConfirmedByOwnerAt,
CreatedAt: record.CreatedAt,
}
}
func toCheckoutAdminDTO(checkout model.OrderCheckout) CheckoutDTO {
rentAmount := checkout.RentAmount
ownerRentAmount := checkout.OwnerRentAmount
platformFee := checkout.PlatformFee
renterRefundAmount := checkout.RenterRefundAmount
ownerIncomeAmount := checkout.OwnerIncomeAmount
return CheckoutDTO{
ID: checkout.ID,
OrderID: checkout.OrderID,
InitiatedBy: checkout.InitiatedBy,
Status: checkout.Status,
PriceRole: "admin",
DisplayAmount: checkout.RentAmount,
RentAmount: &rentAmount,
OwnerRentAmount: &ownerRentAmount,
PlatformFee: &platformFee,
DepositAmount: checkout.DepositAmount,
ConsumableAmount: checkout.ConsumableAmount,
CoinConsumedM: checkout.CoinConsumedM,
OtherAmount: checkout.OtherAmount,
DepositDeductAmount: checkout.DepositDeductAmount,
RenterRefundAmount: &renterRefundAmount,
OwnerIncomeAmount: &ownerIncomeAmount,
Content: checkout.Content,
EvidenceURLS: decodeStringList(checkout.EvidenceURLS),
OwnerAdjustmentReason: checkout.OwnerAdjustmentReason,
OwnerAdjustedAt: checkout.OwnerAdjustedAt,
RenterConfirmedAt: checkout.RenterConfirmedAt,
RenterRejectedAt: checkout.RenterRejectedAt,
CreatedAt: checkout.CreatedAt,
UpdatedAt: checkout.UpdatedAt,
}
}
func toCheckoutDTOForUser(checkout model.OrderCheckout, userID uint64, order model.RentalOrder) CheckoutDTO {
dto := toCheckoutAdminDTO(checkout)
applyCheckoutPriceView(&dto, order, userID)
return dto
}
func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64) {
if dto == nil {
return
}
dto.PlatformFee = nil
switch {
case userID == order.OwnerID:
ownerAmount := order.OwnerRentAmount
if ownerAmount <= 0 {
ownerAmount = order.RentAmount
}
dto.PriceRole = "owner"
dto.DisplayAmount = ownerAmount
dto.RentAmount = nil
dto.OwnerRentAmount = &ownerAmount
sanitizeOrderSnapshot(&dto.AccountSnapshot, "owner")
case userID == order.RenterID:
rentAmount := order.RentAmount
dto.PriceRole = "renter"
dto.DisplayAmount = rentAmount
dto.RentAmount = &rentAmount
dto.OwnerRentAmount = nil
sanitizeOrderSnapshot(&dto.AccountSnapshot, "renter")
default:
dto.PriceRole = ""
dto.DisplayAmount = 0
dto.RentAmount = nil
dto.OwnerRentAmount = nil
sanitizeOrderSnapshot(&dto.AccountSnapshot, "")
}
}
func applyCheckoutPriceView(dto *CheckoutDTO, order model.RentalOrder, userID uint64) {
if dto == nil {
return
}
ownerAmount := 0.0
if dto.OwnerRentAmount != nil {
ownerAmount = *dto.OwnerRentAmount
}
ownerIncomeAmount := 0.0
if dto.OwnerIncomeAmount != nil {
ownerIncomeAmount = *dto.OwnerIncomeAmount
}
rentAmount := 0.0
if dto.RentAmount != nil {
rentAmount = *dto.RentAmount
}
renterRefundAmount := 0.0
if dto.RenterRefundAmount != nil {
renterRefundAmount = *dto.RenterRefundAmount
}
dto.PlatformFee = nil
dto.RenterRefundAmount = nil
dto.OwnerIncomeAmount = nil
switch {
case userID == order.OwnerID:
dto.PriceRole = "owner"
dto.DisplayAmount = ownerAmount
dto.RentAmount = nil
dto.OwnerRentAmount = &ownerAmount
dto.OwnerIncomeAmount = &ownerIncomeAmount
case userID == order.RenterID:
dto.PriceRole = "renter"
dto.DisplayAmount = rentAmount
dto.RentAmount = &rentAmount
dto.OwnerRentAmount = nil
dto.RenterRefundAmount = &renterRefundAmount
default:
dto.PriceRole = ""
dto.DisplayAmount = 0
dto.RentAmount = nil
dto.OwnerRentAmount = nil
}
}
func sanitizeOrderSnapshot(snapshot *datatypes.JSON, role string) {
if snapshot == nil || len(*snapshot) == 0 {
return
}
var payload map[string]any
if err := json.Unmarshal(*snapshot, &payload); err != nil {
return
}
rawSummary, ok := payload["asset_summary"]
if !ok {
return
}
var summary map[string]any
switch typed := rawSummary.(type) {
case map[string]any:
summary = typed
case string:
if err := json.Unmarshal([]byte(typed), &summary); err != nil {
return
}
default:
raw, err := json.Marshal(typed)
if err != nil || json.Unmarshal(raw, &summary) != nil {
return
}
}
breakdown, _ := summary["price_breakdown"].(map[string]any)
if role == "owner" && breakdown != nil {
if sellerRatio := readJSONNumber(breakdown["seller_ratio"]); sellerRatio > 0 {
summary["publish_ratio"] = sellerRatio
}
}
delete(summary, "price_breakdown")
payload["asset_summary"] = summary
raw, err := json.Marshal(payload)
if err != nil {
return
}
*snapshot = datatypes.JSON(raw)
}
func makeAccountSnapshot(account model.GameAccount) (datatypes.JSON, error) {
payload := map[string]any{
"account_id": account.ID,
"title": account.Title,
"game_name": account.GameName,
"server_region": account.ServerRegion,
"login_platform": account.LoginPlatform,
"rank_level": account.RankLevel,
"haf_coin_amount": account.HafCoinAmount,
"asset_summary": account.AssetSummary,
"season_tags": account.SeasonTags,
"screenshot_urls": account.ScreenshotURLS,
"snapshot_version": 1,
}
raw, err := json.Marshal(payload)
return datatypes.JSON(raw), err
}
func newOrderNo() (string, error) {
// 生成格式:RO + YYYYMMDDHHMMSS + 3位随机数
// 例如:RO20260603123456789
now := time.Now()
// 时间部分:年月日时分秒 (14位)
timeStr := now.Format("20060102150405")
// 随机部分:3位数字 (000-999)
buf := make([]byte, 2)
if _, err := rand.Read(buf); err != nil {
return "", err
}
// 将2字节转为0-999的数字
randomNum := (int(buf[0])<<8 | int(buf[1])) % 1000
return "RO" + timeStr + strconv.Itoa(1000 + randomNum)[1:], nil
}
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)
}