1237 lines
37 KiB
Go
1237 lines
37 KiB
Go
package order
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"math"
|
|
"strconv"
|
|
"time"
|
|
|
|
"hfb_sys/backend/internal/model"
|
|
"hfb_sys/backend/internal/modules/notification"
|
|
"hfb_sys/backend/internal/modules/wallet"
|
|
|
|
"gorm.io/datatypes"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
type Repository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
const defaultPendingPaymentTimeoutMinutes = 15
|
|
|
|
func NewRepository(db *gorm.DB) *Repository {
|
|
return &Repository{db: db}
|
|
}
|
|
|
|
type orderPricing struct {
|
|
RentAmount float64
|
|
OwnerRentAmount float64
|
|
PlatformFee 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 {
|
|
if len(raw) == 0 {
|
|
return 0
|
|
}
|
|
var summary map[string]any
|
|
if err := json.Unmarshal(raw, &summary); err != nil {
|
|
return 0
|
|
}
|
|
breakdown, ok := summary["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)
|
|
}
|
|
|
|
func (r *Repository) Pay(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"}).
|
|
Where("id = ? AND renter_id = ?", orderID, userID).
|
|
First(&order).Error; err != nil {
|
|
return err
|
|
}
|
|
if order.Status != "pending_payment" {
|
|
return ErrOrderCannotPay
|
|
}
|
|
timeoutMinutes := pendingPaymentTimeoutMinutes(tx)
|
|
if order.CreatedAt.Before(time.Now().Add(-time.Duration(timeoutMinutes) * time.Minute)) {
|
|
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
|
|
}
|
|
|
|
total := order.RentAmount + order.DepositAmount
|
|
if err := wallet.AppendEntries(tx,
|
|
wallet.Entry{
|
|
UserID: order.RenterID,
|
|
OrderID: &order.ID,
|
|
Direction: "out",
|
|
Amount: total,
|
|
BalanceType: "available",
|
|
BizType: "order_pay",
|
|
BizNo: order.OrderNo,
|
|
Remark: "订单支付扣减可用余额",
|
|
},
|
|
wallet.Entry{
|
|
UserID: order.RenterID,
|
|
OrderID: &order.ID,
|
|
Direction: "in",
|
|
Amount: total,
|
|
BalanceType: "frozen",
|
|
BizType: "order_lock",
|
|
BizNo: order.OrderNo,
|
|
Remark: "订单支付冻结租金和押金",
|
|
},
|
|
); err != nil {
|
|
if errors.Is(err, wallet.ErrInsufficientBalance) {
|
|
return ErrInsufficientBalance
|
|
}
|
|
return err
|
|
}
|
|
|
|
order.Status = "pending_handoff"
|
|
order.HandoffStatus = "pending_owner"
|
|
listing.Status = "rented"
|
|
account.Status = "rented"
|
|
orderID := order.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
|
|
})
|
|
}
|
|
|
|
func (r *Repository) Cancel(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"}).
|
|
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" {
|
|
total := order.RentAmount + order.DepositAmount
|
|
if err := wallet.AppendEntries(tx,
|
|
wallet.Entry{
|
|
UserID: order.RenterID,
|
|
OrderID: &orderID,
|
|
Direction: "out",
|
|
Amount: total,
|
|
BalanceType: "frozen",
|
|
BizType: "order_cancel",
|
|
BizNo: order.OrderNo,
|
|
Remark: "取消订单释放冻结金额",
|
|
},
|
|
wallet.Entry{
|
|
UserID: order.RenterID,
|
|
OrderID: &orderID,
|
|
Direction: "in",
|
|
Amount: total,
|
|
BalanceType: "available",
|
|
BizType: "order_cancel_refund",
|
|
BizNo: order.OrderNo,
|
|
Remark: "取消订单退回可用余额",
|
|
},
|
|
); 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"
|
|
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
|
|
})
|
|
}
|
|
|
|
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 {
|
|
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.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
|
|
return r.finalizeCheckout(tx, &order, &checkout, "号主已确认结账,订单完成。")
|
|
})
|
|
}
|
|
|
|
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 := toCheckoutDTO(*checkout)
|
|
return &dto, nil
|
|
}
|
|
|
|
func (r *Repository) AcceptCheckout(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_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
|
|
return r.finalizeCheckout(tx, &order, &checkout, "租客已确认修正结账,订单完成。")
|
|
})
|
|
}
|
|
|
|
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))
|
|
for _, row := range rows {
|
|
items = append(items, row.toDTO())
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (r *Repository) ListAdmin() ([]OrderDTO, error) {
|
|
var rows []orderRow
|
|
err := r.adminQuery().
|
|
Order("o.id DESC").
|
|
Limit(200).
|
|
Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]OrderDTO, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, row.toDTO())
|
|
}
|
|
return items, 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.toDTO()
|
|
dto.Checkout = r.latestCheckoutDTO(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 {
|
|
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
|
|
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" {
|
|
total := order.RentAmount + order.DepositAmount
|
|
if err := wallet.AppendEntries(tx,
|
|
wallet.Entry{
|
|
UserID: order.RenterID,
|
|
OrderID: &order.ID,
|
|
Direction: "out",
|
|
Amount: total,
|
|
BalanceType: "frozen",
|
|
BizType: "admin_order_close",
|
|
BizNo: order.OrderNo,
|
|
Remark: "后台关闭订单释放冻结金额",
|
|
},
|
|
wallet.Entry{
|
|
UserID: order.RenterID,
|
|
OrderID: &order.ID,
|
|
Direction: "in",
|
|
Amount: total,
|
|
BalanceType: "available",
|
|
BizType: "admin_order_close_refund",
|
|
BizNo: order.OrderNo,
|
|
Remark: "后台关闭订单退回可用余额",
|
|
},
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
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
|
|
})
|
|
}
|
|
|
|
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
|
|
})
|
|
}
|
|
|
|
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.toDTO()
|
|
dto.Checkout = r.latestCheckoutDTO(orderID)
|
|
return &dto, nil
|
|
}
|
|
|
|
func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, checkout *model.OrderCheckout, renterContent string) error {
|
|
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()
|
|
order.Status = "completed"
|
|
order.HandoffStatus = "returned"
|
|
order.SettlementStatus = "settled"
|
|
order.SettledAt = &now
|
|
order.OwnerSettledAt = &now
|
|
listing.Status = "published"
|
|
listing.InTransaction = false
|
|
account.Status = "published"
|
|
orderID := order.ID
|
|
if err := wallet.AppendEntries(tx,
|
|
wallet.Entry{
|
|
UserID: order.RenterID,
|
|
OrderID: &orderID,
|
|
Direction: "out",
|
|
Amount: order.RentAmount + order.DepositAmount,
|
|
BalanceType: "frozen",
|
|
BizType: "order_settle",
|
|
BizNo: order.OrderNo,
|
|
Remark: "订单结账释放冻结金额",
|
|
},
|
|
wallet.Entry{
|
|
UserID: order.OwnerID,
|
|
OrderID: &orderID,
|
|
Direction: "in",
|
|
Amount: checkout.OwnerIncomeAmount,
|
|
BalanceType: "available",
|
|
BizType: "owner_income",
|
|
BizNo: order.OrderNo,
|
|
Remark: "订单结账收入",
|
|
},
|
|
wallet.Entry{
|
|
UserID: order.RenterID,
|
|
OrderID: &orderID,
|
|
Direction: "in",
|
|
Amount: checkout.RenterRefundAmount,
|
|
BalanceType: "available",
|
|
BizType: "deposit_release",
|
|
BizNo: order.OrderNo,
|
|
Remark: "订单结账退回押金",
|
|
},
|
|
); err != nil {
|
|
return err
|
|
}
|
|
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 err
|
|
}
|
|
if err := tx.Save(order).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Save(checkout).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Save(&listing).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Save(&account).Error
|
|
}
|
|
|
|
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 (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 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
|
|
}
|
|
deductAmount := consumableAmount + otherAmount
|
|
if useExplicitDeduct {
|
|
deductAmount = explicitDeduct
|
|
}
|
|
if deductAmount > order.DepositAmount {
|
|
return model.OrderCheckout{}, ErrInvalidCheckoutAmount
|
|
}
|
|
renterRefund := order.DepositAmount - deductAmount
|
|
evidence, err := marshalStringList(evidenceURLS)
|
|
if err != nil {
|
|
return model.OrderCheckout{}, err
|
|
}
|
|
return model.OrderCheckout{
|
|
OrderID: order.ID,
|
|
InitiatedBy: initiatedBy,
|
|
Status: status,
|
|
RentAmount: order.RentAmount,
|
|
OwnerRentAmount: order.OwnerRentAmount,
|
|
PlatformFee: order.PlatformFee,
|
|
DepositAmount: order.DepositAmount,
|
|
ConsumableAmount: roundMoney(consumableAmount),
|
|
CoinConsumedM: roundMoney(coinConsumedM),
|
|
OtherAmount: roundMoney(otherAmount),
|
|
DepositDeductAmount: roundMoney(deductAmount),
|
|
RenterRefundAmount: roundMoney(renterRefund),
|
|
OwnerIncomeAmount: roundMoney(order.OwnerRentAmount + deductAmount),
|
|
Content: content,
|
|
EvidenceURLS: evidence,
|
|
}, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func roundMoney(value float64) float64 {
|
|
return math.Round(value*100) / 100
|
|
}
|
|
|
|
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) latestCheckoutDTO(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 := toCheckoutDTO(checkout)
|
|
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) toDTO() OrderDTO {
|
|
rentedAt := row.RentedAt
|
|
durationHours := orderDurationHours(row.RentalOrder)
|
|
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,
|
|
RentAmount: row.RentAmount,
|
|
OwnerRentAmount: row.OwnerRentAmount,
|
|
DepositAmount: row.DepositAmount,
|
|
PlatformFee: row.PlatformFee,
|
|
AccountSnapshot: row.AccountSnapshot,
|
|
Status: row.Status,
|
|
HandoffStatus: row.HandoffStatus,
|
|
SettlementStatus: row.SettlementStatus,
|
|
CreatedAt: row.CreatedAt,
|
|
UpdatedAt: row.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
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 toCheckoutDTO(checkout model.OrderCheckout) CheckoutDTO {
|
|
return CheckoutDTO{
|
|
ID: checkout.ID,
|
|
OrderID: checkout.OrderID,
|
|
InitiatedBy: checkout.InitiatedBy,
|
|
Status: checkout.Status,
|
|
RentAmount: checkout.RentAmount,
|
|
OwnerRentAmount: checkout.OwnerRentAmount,
|
|
PlatformFee: checkout.PlatformFee,
|
|
DepositAmount: checkout.DepositAmount,
|
|
ConsumableAmount: checkout.ConsumableAmount,
|
|
CoinConsumedM: checkout.CoinConsumedM,
|
|
OtherAmount: checkout.OtherAmount,
|
|
DepositDeductAmount: checkout.DepositDeductAmount,
|
|
RenterRefundAmount: checkout.RenterRefundAmount,
|
|
OwnerIncomeAmount: checkout.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 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) {
|
|
buf := make([]byte, 4)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return "RO" + strconv.FormatInt(time.Now().UnixNano(), 10) + hex.EncodeToString(buf), nil
|
|
}
|
|
|
|
func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizType 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: bizType,
|
|
BizID: &bizID,
|
|
IP: meta.IP,
|
|
UserAgent: meta.UserAgent,
|
|
Detail: datatypes.JSON(raw),
|
|
}
|
|
return tx.Create(&row).Error
|
|
}
|
|
|
|
func IsNotFound(err error) bool {
|
|
return errors.Is(err, gorm.ErrRecordNotFound)
|
|
}
|