diff --git a/backend/internal/modules/order/admin_actions.go b/backend/internal/modules/order/admin_actions.go new file mode 100644 index 0000000..b1b8940 --- /dev/null +++ b/backend/internal/modules/order/admin_actions.go @@ -0,0 +1,232 @@ +package order + +import ( + "time" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/notification" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +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 := order.RentAmountCent + order.DepositAmountCent + 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 := order.RentAmountCent + order.DepositAmountCent + 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, + TotalAmountCent: order.RentAmountCent + order.DepositAmountCent, + } +} + +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" +} diff --git a/backend/internal/modules/order/checkout.go b/backend/internal/modules/order/checkout.go new file mode 100644 index 0000000..00c32cc --- /dev/null +++ b/backend/internal/modules/order/checkout.go @@ -0,0 +1,347 @@ +package order + +import ( + "time" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/notification" + "hfb_sys/backend/internal/modules/wallet" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +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.ConsumableAmountCent, req.CoinConsumedM, req.OtherAmountCent, 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.ConsumableAmountCent, req.CoinConsumedM, req.OtherAmountCent, req.DepositDeductAmountCent, true) + if err != nil { + return err + } + now := time.Now() + checkout.Status = "countered" + checkout.RentAmountCent = next.RentAmountCent + checkout.OwnerRentAmountCent = next.OwnerRentAmountCent + checkout.PlatformFeeCent = next.PlatformFeeCent + checkout.DepositAmountCent = next.DepositAmountCent + checkout.ConsumableAmountCent = next.ConsumableAmountCent + checkout.CoinConsumedM = next.CoinConsumedM + checkout.OtherAmountCent = next.OtherAmountCent + checkout.DepositDeductAmountCent = next.DepositDeductAmountCent + checkout.RenterRefundAmountCent = next.RenterRefundAmountCent + checkout.OwnerIncomeAmountCent = next.OwnerIncomeAmountCent + 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, + RentAmountCent: checkout.RentAmountCent, + OwnerRentAmountCent: checkout.OwnerRentAmountCent, + DepositAmountCent: checkout.DepositAmountCent, + }) + 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) 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.OwnerRentIncomeCent > 0 { + ownerEntries = append(ownerEntries, wallet.Entry{ + UserID: order.OwnerID, + OrderID: &orderID, + Direction: "in", + AmountCent: settlement.OwnerRentIncomeCent, + BalanceType: "available", + BizType: "owner_income", + BizNo: order.OrderNo, + Remark: "订单结账租金收入", + }) + } + if settlement.DepositCompensationCent > 0 { + ownerEntries = append(ownerEntries, wallet.Entry{ + UserID: order.OwnerID, + OrderID: &orderID, + Direction: "in", + AmountCent: settlement.DepositCompensationCent, + 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 + renterRefundTotalCent := settlement.RentRefundCent + settlement.DepositRefundCent + if renterRefundTotalCent > 0 { + action, err := r.prepareRefund(order, renterRefundTotalCent, "checkout_refund", "结账退款原路退还") + if err != nil { + return nil, err + } + refund = action + } + + checkout.RentAmountCent = settlement.ActualRentAmountCent + checkout.OwnerRentAmountCent = settlement.OwnerRentIncomeCent + checkout.PlatformFeeCent = settlement.PlatformFeeCent + checkout.RenterRefundAmountCent = settlement.RenterRefundCent + checkout.OwnerIncomeAmountCent = settlement.OwnerIncomeCent + 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 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 +} diff --git a/backend/internal/modules/order/handoff.go b/backend/internal/modules/order/handoff.go new file mode 100644 index 0000000..c4f935e --- /dev/null +++ b/backend/internal/modules/order/handoff.go @@ -0,0 +1,115 @@ +package order + +import ( + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/notification" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +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) 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) 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) 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 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, + } +} diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go new file mode 100644 index 0000000..07e81fe --- /dev/null +++ b/backend/internal/modules/order/lifecycle.go @@ -0,0 +1,316 @@ +package order + +import ( + "time" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/chat" + "hfb_sys/backend/internal/modules/notification" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +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, listing) + if err != nil { + return err + } + orderNo, err := newOrderNo() + if err != nil { + return err + } + rentHours := internalOrderHours + pricing := buildOrderPricing(listing, account) + depositOriginalAmountCent := listing.DepositAmountCent + paidDepositAmountCent, waivedDepositAmountCent, err := r.depositAmountsForOrder(tx, renterID, depositOriginalAmountCent) + if err != nil { + return err + } + order := model.RentalOrder{ + OrderNo: orderNo, + ListingID: listing.ID, + AccountID: listing.AccountID, + OwnerID: listing.OwnerID, + RenterID: renterID, + EstimatedDurationHours: rentHours, + RentAmountCent: pricing.RentAmountCent, + OwnerRentAmountCent: pricing.OwnerRentAmountCent, + DepositAmountCent: paidDepositAmountCent, + DepositOriginalAmountCent: depositOriginalAmountCent, + DepositWaivedAmountCent: waivedDepositAmountCent, + PlatformFeeCent: pricing.PlatformFeeCent, + 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) depositAmountsForOrder(tx *gorm.DB, renterID uint64, originalDepositCent int64) (int64, int64, error) { + if originalDepositCent <= 0 { + return 0, 0, nil + } + var user model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, renterID).Error; err != nil { + return 0, 0, err + } + used, err := activeDepositFreeUsed(tx, renterID) + if err != nil { + return 0, 0, err + } + paidDeposit, waivedDeposit := calculateDepositWaiver(originalDepositCent, user.DepositFreeQuotaCent, used) + return paidDeposit, waivedDeposit, nil +} + +func activeDepositFreeUsed(tx *gorm.DB, renterID uint64) (int64, error) { + var used int64 + err := tx.Model(&model.RentalOrder{}). + Where("renter_id = ? AND status NOT IN ?", + renterID, + []string{"completed", "cancelled", "closed"}, + ). + Select("COALESCE(SUM(deposit_waived_amount_cent), 0)"). + Scan(&used).Error + return used, err +} + +func calculateDepositWaiver(originalDepositCent int64, quotaCent int64, usedCent int64) (int64, int64) { + remaining := maxCent(quotaCent-usedCent, 0) + waived := minCent(originalDepositCent, remaining) + paid := maxCent(originalDepositCent-waived, 0) + return paid, waived +} + +// 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 := order.RentAmountCent + order.DepositAmountCent + 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) 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 + }) +} diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go new file mode 100644 index 0000000..cb4efab --- /dev/null +++ b/backend/internal/modules/order/presenter.go @@ -0,0 +1,256 @@ +package order + +import ( + "encoding/json" + + "hfb_sys/backend/internal/model" + + "gorm.io/datatypes" +) + +func (row orderRow) toAdminDTO() OrderDTO { + rentedAt := row.RentedAt + durationHours := orderDurationHours(row.RentalOrder) + rentAmountCent := row.RentAmountCent + ownerRentAmountCent := row.OwnerRentAmountCent + platformFeeCent := row.PlatformFeeCent + return OrderDTO{ + ID: row.ID, + OrderNo: row.OrderNo, + ListingID: row.ListingID, + ListingNo: row.ListingNo, + 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", + DisplayAmountCent: row.RentAmountCent, + RentAmountCent: &rentAmountCent, + OwnerRentAmountCent: &ownerRentAmountCent, + DepositAmountCent: row.DepositAmountCent, + DepositOriginalAmountCent: effectiveDepositOriginalAmountCent(row.RentalOrder), + DepositWaivedAmountCent: row.DepositWaivedAmountCent, + PlatformFeeCent: &platformFeeCent, + 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 effectiveDepositOriginalAmountCent(order model.RentalOrder) int64 { + if order.DepositOriginalAmountCent > 0 { + return order.DepositOriginalAmountCent + } + return order.DepositAmountCent +} + +func toCheckoutAdminDTO(checkout model.OrderCheckout) CheckoutDTO { + rentAmountCent := checkout.RentAmountCent + ownerRentAmountCent := checkout.OwnerRentAmountCent + platformFeeCent := checkout.PlatformFeeCent + renterRefundAmountCent := checkout.RenterRefundAmountCent + ownerIncomeAmountCent := checkout.OwnerIncomeAmountCent + return CheckoutDTO{ + ID: checkout.ID, + OrderID: checkout.OrderID, + InitiatedBy: checkout.InitiatedBy, + Status: checkout.Status, + PriceRole: "admin", + DisplayAmountCent: rentAmountCent, + RentAmountCent: &rentAmountCent, + OwnerRentAmountCent: &ownerRentAmountCent, + PlatformFeeCent: &platformFeeCent, + DepositAmountCent: checkout.DepositAmountCent, + ConsumableAmountCent: checkout.ConsumableAmountCent, + CoinConsumedM: checkout.CoinConsumedM, + OtherAmountCent: checkout.OtherAmountCent, + DepositDeductAmountCent: checkout.DepositDeductAmountCent, + RenterRefundAmountCent: &renterRefundAmountCent, + OwnerIncomeAmountCent: &ownerIncomeAmountCent, + 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.PlatformFeeCent = nil + switch { + case userID == order.OwnerID: + ownerAmountCent := order.OwnerRentAmountCent + if ownerAmountCent <= 0 { + ownerAmountCent = order.RentAmountCent + } + dto.PriceRole = "owner" + dto.DisplayAmountCent = ownerAmountCent + dto.RentAmountCent = nil + dto.OwnerRentAmountCent = &ownerAmountCent + sanitizeOrderSnapshot(&dto.AccountSnapshot, "owner") + case userID == order.RenterID: + rentAmountCent := order.RentAmountCent + dto.PriceRole = "renter" + dto.DisplayAmountCent = rentAmountCent + dto.RentAmountCent = &rentAmountCent + dto.OwnerRentAmountCent = nil + sanitizeOrderSnapshot(&dto.AccountSnapshot, "renter") + default: + dto.PriceRole = "" + dto.DisplayAmountCent = 0 + dto.RentAmountCent = nil + dto.OwnerRentAmountCent = nil + sanitizeOrderSnapshot(&dto.AccountSnapshot, "") + } +} + +func applyCheckoutPriceView(dto *CheckoutDTO, order model.RentalOrder, userID uint64) { + if dto == nil { + return + } + ownerAmountCent := int64(0) + if dto.OwnerRentAmountCent != nil { + ownerAmountCent = *dto.OwnerRentAmountCent + } + ownerIncomeAmountCent := int64(0) + if dto.OwnerIncomeAmountCent != nil { + ownerIncomeAmountCent = *dto.OwnerIncomeAmountCent + } + rentAmountCent := int64(0) + if dto.RentAmountCent != nil { + rentAmountCent = *dto.RentAmountCent + } + renterRefundAmountCent := int64(0) + if dto.RenterRefundAmountCent != nil { + renterRefundAmountCent = *dto.RenterRefundAmountCent + } + dto.PlatformFeeCent = nil + dto.RenterRefundAmountCent = nil + dto.OwnerIncomeAmountCent = nil + switch { + case userID == order.OwnerID: + dto.PriceRole = "owner" + dto.DisplayAmountCent = ownerAmountCent + dto.RentAmountCent = nil + dto.OwnerRentAmountCent = &ownerAmountCent + dto.OwnerIncomeAmountCent = &ownerIncomeAmountCent + case userID == order.RenterID: + dto.PriceRole = "renter" + dto.DisplayAmountCent = rentAmountCent + dto.RentAmountCent = &rentAmountCent + dto.OwnerRentAmountCent = nil + dto.RenterRefundAmountCent = &renterRefundAmountCent + default: + dto.PriceRole = "" + dto.DisplayAmountCent = 0 + dto.RentAmountCent = nil + dto.OwnerRentAmountCent = 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 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 makeAccountSnapshot(account model.GameAccount, listing model.RentalListing) (datatypes.JSON, error) { + payload := map[string]any{ + "listing_id": listing.ID, + "listing_no": listing.ListingNo, + "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 +} diff --git a/backend/internal/modules/order/pricing.go b/backend/internal/modules/order/pricing.go new file mode 100644 index 0000000..f6742f8 --- /dev/null +++ b/backend/internal/modules/order/pricing.go @@ -0,0 +1,251 @@ +package order + +import ( + "encoding/json" + "math" + "strconv" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/pkg/money" + + "gorm.io/datatypes" +) + +type orderPricing struct { + RentAmountCent int64 + OwnerRentAmountCent int64 + PlatformFeeCent int64 +} + +type checkoutSettlement struct { + OwnerRentIncomeCent int64 + DepositCompensationCent int64 + OwnerIncomeCent int64 + RentRefundCent int64 + DepositRefundCent int64 + RenterRefundCent int64 + PlatformFeeCent int64 + ActualRentAmountCent int64 +} + +func orderDurationHours(order model.RentalOrder) int { + if order.EstimatedDurationHours > 0 { + return order.EstimatedDurationHours + } + return internalOrderHours +} + +func buildOrderPricing(listing model.RentalListing, account model.GameAccount) orderPricing { + rentAmountCent := listing.PriceCent + ownerRentAmountCent := int64(math.Round(readSnapshotPrice(account.AssetSummary, "seller_total_price") * 100)) + if ownerRentAmountCent <= 0 || ownerRentAmountCent > rentAmountCent { + ownerRentAmountCent = rentAmountCent + } + platformFeeCent := int64(math.Round(readSnapshotPrice(account.AssetSummary, "platform_markup_amount") * 100)) + if platformFeeCent <= 0 || ownerRentAmountCent+platformFeeCent != rentAmountCent { + platformFeeCent = rentAmountCent - ownerRentAmountCent + } + if platformFeeCent < 0 { + platformFeeCent = 0 + } + return orderPricing{ + RentAmountCent: rentAmountCent, + OwnerRentAmountCent: ownerRentAmountCent, + PlatformFeeCent: platformFeeCent, + } +} + +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 buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, content string, evidenceURLS []string, consumableAmountCent int64, coinConsumedM float64, otherAmountCent int64, explicitDeductCent int64, useExplicitDeduct bool) (model.OrderCheckout, error) { + if consumableAmountCent < 0 || coinConsumedM < 0 || otherAmountCent < 0 || explicitDeductCent < 0 { + return model.OrderCheckout{}, ErrInvalidCheckoutAmount + } + deductAmountCent := otherAmountCent + if useExplicitDeduct { + deductAmountCent = explicitDeductCent + } + if deductAmountCent > order.DepositAmountCent { + return model.OrderCheckout{}, ErrInvalidCheckoutAmount + } + settlement := calculateCheckoutSettlement(order, consumableAmountCent, roundQuantity(coinConsumedM), deductAmountCent) + evidence, err := marshalStringList(evidenceURLS) + if err != nil { + return model.OrderCheckout{}, err + } + return model.OrderCheckout{ + OrderID: order.ID, + InitiatedBy: initiatedBy, + Status: status, + RentAmountCent: settlement.ActualRentAmountCent, + OwnerRentAmountCent: settlement.OwnerRentIncomeCent, + PlatformFeeCent: settlement.PlatformFeeCent, + DepositAmountCent: order.DepositAmountCent, + ConsumableAmountCent: consumableAmountCent, + CoinConsumedM: roundQuantity(coinConsumedM), + OtherAmountCent: otherAmountCent, + DepositDeductAmountCent: settlement.DepositCompensationCent, + RenterRefundAmountCent: settlement.RenterRefundCent, + OwnerIncomeAmountCent: settlement.OwnerIncomeCent, + Content: content, + EvidenceURLS: evidence, + }, nil +} + +func buildCheckoutSettlement(order model.RentalOrder, checkout *model.OrderCheckout) checkoutSettlement { + return calculateCheckoutSettlement(order, checkout.ConsumableAmountCent, checkout.CoinConsumedM, checkout.DepositDeductAmountCent) +} + +func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent int64, coinConsumedM float64, depositDeductAmountCent int64) checkoutSettlement { + orderRentAmountCent := order.RentAmountCent + orderOwnerRentAmountCent := order.OwnerRentAmountCent + orderDepositAmountCent := order.DepositAmountCent + + buyerCoinBasePriceCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "buyer_coin_base_price") * 100)) + if buyerCoinBasePriceCent <= 0 || buyerCoinBasePriceCent > orderRentAmountCent { + buyerCoinBasePriceCent = orderRentAmountCent + } + sellerCoinBasePriceCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "seller_coin_base_price") * 100)) + if sellerCoinBasePriceCent <= 0 || sellerCoinBasePriceCent > orderOwnerRentAmountCent { + sellerCoinBasePriceCent = orderOwnerRentAmountCent + } + prepaidConsumablePriceCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "consumable_price") * 100)) + if prepaidConsumablePriceCent <= 0 || prepaidConsumablePriceCent > orderRentAmountCent-buyerCoinBasePriceCent { + prepaidConsumablePriceCent = maxCent(orderRentAmountCent-buyerCoinBasePriceCent, 0) + } + prepaidOwnerConsumablePriceCent := maxCent(orderOwnerRentAmountCent-sellerCoinBasePriceCent, 0) + totalCoinM := readOrderSnapshotCoinM(order.AccountSnapshot) + coinUseRatio := 1.0 + if totalCoinM > 0 { + coinUseRatio = minRatio(maxRatio(roundQuantity(coinConsumedM)/totalCoinM, 0), 1) + } + usedBuyerCoinPriceCent := int64(math.Round(float64(buyerCoinBasePriceCent) * coinUseRatio)) + usedOwnerCoinPriceCent := int64(math.Round(float64(sellerCoinBasePriceCent) * coinUseRatio)) + usedBuyerConsumablePriceCent := minCent(consumableAmountCent, prepaidConsumablePriceCent) + consumableUseRatio := 1.0 + if prepaidConsumablePriceCent > 0 { + consumableUseRatio = minRatio(maxRatio(float64(usedBuyerConsumablePriceCent)/float64(prepaidConsumablePriceCent), 0), 1) + } + usedOwnerConsumablePriceCent := int64(math.Round(float64(prepaidOwnerConsumablePriceCent) * consumableUseRatio)) + actualRentAmountCent := minCent(usedBuyerCoinPriceCent+usedBuyerConsumablePriceCent, orderRentAmountCent) + ownerRentIncomeCent := minCent(usedOwnerCoinPriceCent+usedOwnerConsumablePriceCent, orderOwnerRentAmountCent) + depositCompensationCent := minCent(depositDeductAmountCent, orderDepositAmountCent) + rentRefundCent := maxCent(orderRentAmountCent-actualRentAmountCent, 0) + depositRefundCent := maxCent(orderDepositAmountCent-depositCompensationCent, 0) + + return checkoutSettlement{ + OwnerRentIncomeCent: ownerRentIncomeCent, + DepositCompensationCent: depositCompensationCent, + OwnerIncomeCent: ownerRentIncomeCent + depositCompensationCent, + RentRefundCent: rentRefundCent, + DepositRefundCent: depositRefundCent, + RenterRefundCent: rentRefundCent + depositRefundCent, + PlatformFeeCent: maxCent(actualRentAmountCent-ownerRentIncomeCent, 0), + ActualRentAmountCent: actualRentAmountCent, + } +} + +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) +} + +// 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 minCent(a int64, b int64) int64 { + if a < b { + return a + } + return b +} + +func maxCent(a int64, b int64) int64 { + if a > b { + return a + } + return 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 +} diff --git a/backend/internal/modules/order/queries.go b/backend/internal/modules/order/queries.go new file mode 100644 index 0000000..fa47d49 --- /dev/null +++ b/backend/internal/modules/order/queries.go @@ -0,0 +1,171 @@ +package order + +import ( + "strconv" + "time" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" +) + +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) 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 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 (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.*, l.listing_no, a.title, a.server_region, a.login_platform"). + Joins("JOIN rental_listings AS l ON l.id = o.listing_id"). + 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.*, l.listing_no, a.title, a.server_region, a.login_platform, owner.phone AS owner_phone, renter.phone AS renter_phone"). + Joins("JOIN rental_listings AS l ON l.id = o.listing_id"). + 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 + ListingNo string + ServerRegion string + LoginPlatform string + OwnerPhone string + RenterPhone string +} diff --git a/backend/internal/modules/order/refund.go b/backend/internal/modules/order/refund.go new file mode 100644 index 0000000..5e4c2e0 --- /dev/null +++ b/backend/internal/modules/order/refund.go @@ -0,0 +1,34 @@ +package order + +import ( + "log" + + "hfb_sys/backend/internal/model" +) + +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) + } +} diff --git a/backend/internal/modules/order/repository.go b/backend/internal/modules/order/repository.go index 1c3fa05..0e69199 100644 --- a/backend/internal/modules/order/repository.go +++ b/backend/internal/modules/order/repository.go @@ -1,30 +1,13 @@ 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/internal/timeutil" - "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 @@ -51,1687 +34,3 @@ func (r *Repository) SetChatRepo(cr *chat.Repository) { func (r *Repository) SetRefundFunc(fn RefundFunc) { r.refundFunc = fn } - -type orderPricing struct { - RentAmountCent int64 - OwnerRentAmountCent int64 - PlatformFeeCent int64 -} - -type checkoutSettlement struct { - OwnerRentIncomeCent int64 - DepositCompensationCent int64 - OwnerIncomeCent int64 - RentRefundCent int64 - DepositRefundCent int64 - RenterRefundCent int64 - PlatformFeeCent int64 - ActualRentAmountCent int64 -} - -func orderDurationHours(order model.RentalOrder) int { - if order.EstimatedDurationHours > 0 { - return order.EstimatedDurationHours - } - return internalOrderHours -} - -func buildOrderPricing(listing model.RentalListing, account model.GameAccount) orderPricing { - rentAmountCent := listing.PriceCent - ownerRentAmountCent := int64(math.Round(readSnapshotPrice(account.AssetSummary, "seller_total_price") * 100)) - if ownerRentAmountCent <= 0 || ownerRentAmountCent > rentAmountCent { - ownerRentAmountCent = rentAmountCent - } - platformFeeCent := int64(math.Round(readSnapshotPrice(account.AssetSummary, "platform_markup_amount") * 100)) - if platformFeeCent <= 0 || ownerRentAmountCent+platformFeeCent != rentAmountCent { - platformFeeCent = rentAmountCent - ownerRentAmountCent - } - if platformFeeCent < 0 { - platformFeeCent = 0 - } - return orderPricing{ - RentAmountCent: rentAmountCent, - OwnerRentAmountCent: ownerRentAmountCent, - PlatformFeeCent: platformFeeCent, - } -} - -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, listing) - if err != nil { - return err - } - orderNo, err := newOrderNo() - if err != nil { - return err - } - rentHours := internalOrderHours - pricing := buildOrderPricing(listing, account) - depositOriginalAmountCent := listing.DepositAmountCent - paidDepositAmountCent, waivedDepositAmountCent, err := r.depositAmountsForOrder(tx, renterID, depositOriginalAmountCent) - if err != nil { - return err - } - order := model.RentalOrder{ - OrderNo: orderNo, - ListingID: listing.ID, - AccountID: listing.AccountID, - OwnerID: listing.OwnerID, - RenterID: renterID, - EstimatedDurationHours: rentHours, - RentAmountCent: pricing.RentAmountCent, - OwnerRentAmountCent: pricing.OwnerRentAmountCent, - DepositAmountCent: paidDepositAmountCent, - DepositOriginalAmountCent: depositOriginalAmountCent, - DepositWaivedAmountCent: waivedDepositAmountCent, - PlatformFeeCent: pricing.PlatformFeeCent, - 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) depositAmountsForOrder(tx *gorm.DB, renterID uint64, originalDepositCent int64) (int64, int64, error) { - if originalDepositCent <= 0 { - return 0, 0, nil - } - var user model.User - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, renterID).Error; err != nil { - return 0, 0, err - } - used, err := activeDepositFreeUsed(tx, renterID) - if err != nil { - return 0, 0, err - } - paidDeposit, waivedDeposit := calculateDepositWaiver(originalDepositCent, user.DepositFreeQuotaCent, used) - return paidDeposit, waivedDeposit, nil -} - -func activeDepositFreeUsed(tx *gorm.DB, renterID uint64) (int64, error) { - var used int64 - err := tx.Model(&model.RentalOrder{}). - Where("renter_id = ? AND status NOT IN ?", - renterID, - []string{"completed", "cancelled", "closed"}, - ). - Select("COALESCE(SUM(deposit_waived_amount_cent), 0)"). - Scan(&used).Error - return used, err -} - -func calculateDepositWaiver(originalDepositCent int64, quotaCent int64, usedCent int64) (int64, int64) { - remaining := maxCent(quotaCent-usedCent, 0) - waived := minCent(originalDepositCent, remaining) - paid := maxCent(originalDepositCent-waived, 0) - return paid, waived -} - -// 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 := order.RentAmountCent + order.DepositAmountCent - 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.ConsumableAmountCent, req.CoinConsumedM, req.OtherAmountCent, 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.ConsumableAmountCent, req.CoinConsumedM, req.OtherAmountCent, req.DepositDeductAmountCent, true) - if err != nil { - return err - } - now := time.Now() - checkout.Status = "countered" - checkout.RentAmountCent = next.RentAmountCent - checkout.OwnerRentAmountCent = next.OwnerRentAmountCent - checkout.PlatformFeeCent = next.PlatformFeeCent - checkout.DepositAmountCent = next.DepositAmountCent - checkout.ConsumableAmountCent = next.ConsumableAmountCent - checkout.CoinConsumedM = next.CoinConsumedM - checkout.OtherAmountCent = next.OtherAmountCent - checkout.DepositDeductAmountCent = next.DepositDeductAmountCent - checkout.RenterRefundAmountCent = next.RenterRefundAmountCent - checkout.OwnerIncomeAmountCent = next.OwnerIncomeAmountCent - 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, - RentAmountCent: checkout.RentAmountCent, - OwnerRentAmountCent: checkout.OwnerRentAmountCent, - DepositAmountCent: checkout.DepositAmountCent, - }) - 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 := order.RentAmountCent + order.DepositAmountCent - 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 := order.RentAmountCent + order.DepositAmountCent - 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, - TotalAmountCent: order.RentAmountCent + order.DepositAmountCent, - } -} - -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.OwnerRentIncomeCent > 0 { - ownerEntries = append(ownerEntries, wallet.Entry{ - UserID: order.OwnerID, - OrderID: &orderID, - Direction: "in", - AmountCent: settlement.OwnerRentIncomeCent, - BalanceType: "available", - BizType: "owner_income", - BizNo: order.OrderNo, - Remark: "订单结账租金收入", - }) - } - if settlement.DepositCompensationCent > 0 { - ownerEntries = append(ownerEntries, wallet.Entry{ - UserID: order.OwnerID, - OrderID: &orderID, - Direction: "in", - AmountCent: settlement.DepositCompensationCent, - 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 - renterRefundTotalCent := settlement.RentRefundCent + settlement.DepositRefundCent - if renterRefundTotalCent > 0 { - action, err := r.prepareRefund(order, renterRefundTotalCent, "checkout_refund", "结账退款原路退还") - if err != nil { - return nil, err - } - refund = action - } - - checkout.RentAmountCent = settlement.ActualRentAmountCent - checkout.OwnerRentAmountCent = settlement.OwnerRentIncomeCent - checkout.PlatformFeeCent = settlement.PlatformFeeCent - checkout.RenterRefundAmountCent = settlement.RenterRefundCent - checkout.OwnerIncomeAmountCent = settlement.OwnerIncomeCent - 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, consumableAmountCent int64, coinConsumedM float64, otherAmountCent int64, explicitDeductCent int64, useExplicitDeduct bool) (model.OrderCheckout, error) { - if consumableAmountCent < 0 || coinConsumedM < 0 || otherAmountCent < 0 || explicitDeductCent < 0 { - return model.OrderCheckout{}, ErrInvalidCheckoutAmount - } - deductAmountCent := otherAmountCent - if useExplicitDeduct { - deductAmountCent = explicitDeductCent - } - if deductAmountCent > order.DepositAmountCent { - return model.OrderCheckout{}, ErrInvalidCheckoutAmount - } - settlement := calculateCheckoutSettlement(order, consumableAmountCent, roundQuantity(coinConsumedM), deductAmountCent) - evidence, err := marshalStringList(evidenceURLS) - if err != nil { - return model.OrderCheckout{}, err - } - return model.OrderCheckout{ - OrderID: order.ID, - InitiatedBy: initiatedBy, - Status: status, - RentAmountCent: settlement.ActualRentAmountCent, - OwnerRentAmountCent: settlement.OwnerRentIncomeCent, - PlatformFeeCent: settlement.PlatformFeeCent, - DepositAmountCent: order.DepositAmountCent, - ConsumableAmountCent: consumableAmountCent, - CoinConsumedM: roundQuantity(coinConsumedM), - OtherAmountCent: otherAmountCent, - DepositDeductAmountCent: settlement.DepositCompensationCent, - RenterRefundAmountCent: settlement.RenterRefundCent, - OwnerIncomeAmountCent: settlement.OwnerIncomeCent, - Content: content, - EvidenceURLS: evidence, - }, nil -} - -func buildCheckoutSettlement(order model.RentalOrder, checkout *model.OrderCheckout) checkoutSettlement { - return calculateCheckoutSettlement(order, checkout.ConsumableAmountCent, checkout.CoinConsumedM, checkout.DepositDeductAmountCent) -} - -func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent int64, coinConsumedM float64, depositDeductAmountCent int64) checkoutSettlement { - orderRentAmountCent := order.RentAmountCent - orderOwnerRentAmountCent := order.OwnerRentAmountCent - orderDepositAmountCent := order.DepositAmountCent - - buyerCoinBasePriceCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "buyer_coin_base_price") * 100)) - if buyerCoinBasePriceCent <= 0 || buyerCoinBasePriceCent > orderRentAmountCent { - buyerCoinBasePriceCent = orderRentAmountCent - } - sellerCoinBasePriceCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "seller_coin_base_price") * 100)) - if sellerCoinBasePriceCent <= 0 || sellerCoinBasePriceCent > orderOwnerRentAmountCent { - sellerCoinBasePriceCent = orderOwnerRentAmountCent - } - prepaidConsumablePriceCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "consumable_price") * 100)) - if prepaidConsumablePriceCent <= 0 || prepaidConsumablePriceCent > orderRentAmountCent-buyerCoinBasePriceCent { - prepaidConsumablePriceCent = maxCent(orderRentAmountCent-buyerCoinBasePriceCent, 0) - } - prepaidOwnerConsumablePriceCent := maxCent(orderOwnerRentAmountCent-sellerCoinBasePriceCent, 0) - totalCoinM := readOrderSnapshotCoinM(order.AccountSnapshot) - coinUseRatio := 1.0 - if totalCoinM > 0 { - coinUseRatio = minRatio(maxRatio(roundQuantity(coinConsumedM)/totalCoinM, 0), 1) - } - usedBuyerCoinPriceCent := int64(math.Round(float64(buyerCoinBasePriceCent) * coinUseRatio)) - usedOwnerCoinPriceCent := int64(math.Round(float64(sellerCoinBasePriceCent) * coinUseRatio)) - usedBuyerConsumablePriceCent := minCent(consumableAmountCent, prepaidConsumablePriceCent) - consumableUseRatio := 1.0 - if prepaidConsumablePriceCent > 0 { - consumableUseRatio = minRatio(maxRatio(float64(usedBuyerConsumablePriceCent)/float64(prepaidConsumablePriceCent), 0), 1) - } - usedOwnerConsumablePriceCent := int64(math.Round(float64(prepaidOwnerConsumablePriceCent) * consumableUseRatio)) - actualRentAmountCent := minCent(usedBuyerCoinPriceCent+usedBuyerConsumablePriceCent, orderRentAmountCent) - ownerRentIncomeCent := minCent(usedOwnerCoinPriceCent+usedOwnerConsumablePriceCent, orderOwnerRentAmountCent) - depositCompensationCent := minCent(depositDeductAmountCent, orderDepositAmountCent) - rentRefundCent := maxCent(orderRentAmountCent-actualRentAmountCent, 0) - depositRefundCent := maxCent(orderDepositAmountCent-depositCompensationCent, 0) - - return checkoutSettlement{ - OwnerRentIncomeCent: ownerRentIncomeCent, - DepositCompensationCent: depositCompensationCent, - OwnerIncomeCent: ownerRentIncomeCent + depositCompensationCent, - RentRefundCent: rentRefundCent, - DepositRefundCent: depositRefundCent, - RenterRefundCent: rentRefundCent + depositRefundCent, - PlatformFeeCent: maxCent(actualRentAmountCent-ownerRentIncomeCent, 0), - ActualRentAmountCent: actualRentAmountCent, - } -} - -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 minCent(a int64, b int64) int64 { - if a < b { - return a - } - return b -} - -func maxCent(a int64, b int64) int64 { - if a > b { - return a - } - return 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.*, l.listing_no, a.title, a.server_region, a.login_platform"). - Joins("JOIN rental_listings AS l ON l.id = o.listing_id"). - 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.*, l.listing_no, a.title, a.server_region, a.login_platform, owner.phone AS owner_phone, renter.phone AS renter_phone"). - Joins("JOIN rental_listings AS l ON l.id = o.listing_id"). - 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 - ListingNo string - ServerRegion string - LoginPlatform string - OwnerPhone string - RenterPhone string -} - -func (row orderRow) toAdminDTO() OrderDTO { - rentedAt := row.RentedAt - durationHours := orderDurationHours(row.RentalOrder) - rentAmountCent := row.RentAmountCent - ownerRentAmountCent := row.OwnerRentAmountCent - platformFeeCent := row.PlatformFeeCent - return OrderDTO{ - ID: row.ID, - OrderNo: row.OrderNo, - ListingID: row.ListingID, - ListingNo: row.ListingNo, - 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", - DisplayAmountCent: row.RentAmountCent, - RentAmountCent: &rentAmountCent, - OwnerRentAmountCent: &ownerRentAmountCent, - DepositAmountCent: row.DepositAmountCent, - DepositOriginalAmountCent: effectiveDepositOriginalAmountCent(row.RentalOrder), - DepositWaivedAmountCent: row.DepositWaivedAmountCent, - PlatformFeeCent: &platformFeeCent, - 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 effectiveDepositOriginalAmountCent(order model.RentalOrder) int64 { - if order.DepositOriginalAmountCent > 0 { - return order.DepositOriginalAmountCent - } - return order.DepositAmountCent -} - -func toCheckoutAdminDTO(checkout model.OrderCheckout) CheckoutDTO { - rentAmountCent := checkout.RentAmountCent - ownerRentAmountCent := checkout.OwnerRentAmountCent - platformFeeCent := checkout.PlatformFeeCent - renterRefundAmountCent := checkout.RenterRefundAmountCent - ownerIncomeAmountCent := checkout.OwnerIncomeAmountCent - return CheckoutDTO{ - ID: checkout.ID, - OrderID: checkout.OrderID, - InitiatedBy: checkout.InitiatedBy, - Status: checkout.Status, - PriceRole: "admin", - DisplayAmountCent: rentAmountCent, - RentAmountCent: &rentAmountCent, - OwnerRentAmountCent: &ownerRentAmountCent, - PlatformFeeCent: &platformFeeCent, - DepositAmountCent: checkout.DepositAmountCent, - ConsumableAmountCent: checkout.ConsumableAmountCent, - CoinConsumedM: checkout.CoinConsumedM, - OtherAmountCent: checkout.OtherAmountCent, - DepositDeductAmountCent: checkout.DepositDeductAmountCent, - RenterRefundAmountCent: &renterRefundAmountCent, - OwnerIncomeAmountCent: &ownerIncomeAmountCent, - 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.PlatformFeeCent = nil - switch { - case userID == order.OwnerID: - ownerAmountCent := order.OwnerRentAmountCent - if ownerAmountCent <= 0 { - ownerAmountCent = order.RentAmountCent - } - dto.PriceRole = "owner" - dto.DisplayAmountCent = ownerAmountCent - dto.RentAmountCent = nil - dto.OwnerRentAmountCent = &ownerAmountCent - sanitizeOrderSnapshot(&dto.AccountSnapshot, "owner") - case userID == order.RenterID: - rentAmountCent := order.RentAmountCent - dto.PriceRole = "renter" - dto.DisplayAmountCent = rentAmountCent - dto.RentAmountCent = &rentAmountCent - dto.OwnerRentAmountCent = nil - sanitizeOrderSnapshot(&dto.AccountSnapshot, "renter") - default: - dto.PriceRole = "" - dto.DisplayAmountCent = 0 - dto.RentAmountCent = nil - dto.OwnerRentAmountCent = nil - sanitizeOrderSnapshot(&dto.AccountSnapshot, "") - } -} - -func applyCheckoutPriceView(dto *CheckoutDTO, order model.RentalOrder, userID uint64) { - if dto == nil { - return - } - ownerAmountCent := int64(0) - if dto.OwnerRentAmountCent != nil { - ownerAmountCent = *dto.OwnerRentAmountCent - } - ownerIncomeAmountCent := int64(0) - if dto.OwnerIncomeAmountCent != nil { - ownerIncomeAmountCent = *dto.OwnerIncomeAmountCent - } - rentAmountCent := int64(0) - if dto.RentAmountCent != nil { - rentAmountCent = *dto.RentAmountCent - } - renterRefundAmountCent := int64(0) - if dto.RenterRefundAmountCent != nil { - renterRefundAmountCent = *dto.RenterRefundAmountCent - } - dto.PlatformFeeCent = nil - dto.RenterRefundAmountCent = nil - dto.OwnerIncomeAmountCent = nil - switch { - case userID == order.OwnerID: - dto.PriceRole = "owner" - dto.DisplayAmountCent = ownerAmountCent - dto.RentAmountCent = nil - dto.OwnerRentAmountCent = &ownerAmountCent - dto.OwnerIncomeAmountCent = &ownerIncomeAmountCent - case userID == order.RenterID: - dto.PriceRole = "renter" - dto.DisplayAmountCent = rentAmountCent - dto.RentAmountCent = &rentAmountCent - dto.OwnerRentAmountCent = nil - dto.RenterRefundAmountCent = &renterRefundAmountCent - default: - dto.PriceRole = "" - dto.DisplayAmountCent = 0 - dto.RentAmountCent = nil - dto.OwnerRentAmountCent = 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, listing model.RentalListing) (datatypes.JSON, error) { - payload := map[string]any{ - "listing_id": listing.ID, - "listing_no": listing.ListingNo, - "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 := timeutil.ShanghaiNow() - - // 时间部分:年月日时分秒 (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) -} diff --git a/backend/internal/modules/order/utils.go b/backend/internal/modules/order/utils.go new file mode 100644 index 0000000..7ba7d2f --- /dev/null +++ b/backend/internal/modules/order/utils.go @@ -0,0 +1,55 @@ +package order + +import ( + "crypto/rand" + "errors" + "strconv" + + "gorm.io/gorm" + "hfb_sys/backend/internal/auditlog" + "hfb_sys/backend/internal/timeutil" +) + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func newOrderNo() (string, error) { + // 生成格式:RO + YYYYMMDDHHMMSS + 3位随机数 + // 例如:RO20260603123456789 + now := timeutil.ShanghaiNow() + + // 时间部分:年月日时分秒 (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) +}