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)) for _, row := range rows { dto := row.toDTOForUser(userID) 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)) for _, row := range rows { items = append(items, row.toAdminDTO()) } 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() 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) 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 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) }