Files

451 lines
14 KiB
Go

package order
import (
"context"
"encoding/json"
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/chat"
"hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/modules/rentergrowth"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequest) (*OrderDTO, error) {
var createdID uint64
err := r.db.WithContext(ctx).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 != listingStatusPublished || listing.ReviewStatus != listingReviewStatusApproved || 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
}
active, err := hasActiveOrderForAssets(tx, listing.ID, account.ID, 0)
if err != nil {
return err
}
if active {
return ErrListingUnavailable
}
snapshot, err := makeAccountSnapshot(account, listing)
if err != nil {
return err
}
orderNo, err := newOrderNo()
if err != nil {
return err
}
rentHours := estimateOrderDurationHours(snapshot)
pricing := buildOrderPricing(listing, account)
rentOriginalAmountCent := pricing.RentAmountCent
growthSnapshot, err := rentergrowth.SnapshotForUser(tx, renterID)
if err != nil {
return err
}
rentDiscountAmountCent := rentergrowth.CalculateDiscountCent(pricing.PureCoinAmountCent, pricing.PureCoinPlatformFeeCent, growthSnapshot.DiscountBps)
if rentDiscountAmountCent > 0 {
pricing.PlatformFeeCent -= rentDiscountAmountCent
pricing.RentAmountCent -= rentDiscountAmountCent
}
depositOriginalAmountCent := listing.DepositAmountCent
depositWaiver, err := r.depositAmountsForOrder(tx, renterID, depositOriginalAmountCent, growthSnapshot)
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: depositWaiver.PaidCent,
DepositOriginalAmountCent: depositOriginalAmountCent,
DepositWaivedAmountCent: depositWaiver.WaivedCent,
DepositFreeLevelQuotaCent: growthSnapshot.LevelDepositFreeQuotaCent,
DepositFreeManualQuotaCent: growthSnapshot.ManualDepositFreeQuotaCent,
DepositFreeUsedBeforeCent: depositWaiver.UsedBeforeCent,
PlatformFeeCent: pricing.PlatformFeeCent,
RentOriginalAmountCent: rentOriginalAmountCent,
RentDiscountAmountCent: rentDiscountAmountCent,
PureCoinOriginalAmountCent: pricing.PureCoinAmountCent,
ExtraItemOriginalAmountCent: pricing.ExtraItemAmountCent,
RenterGrowthLevel: growthSnapshot.LevelCode,
RenterGrowthLevelName: growthSnapshot.LevelName,
RenterDiscountBps: growthSnapshot.DiscountBps,
AccountSnapshot: snapshot,
Status: orderStatusPendingPayment,
HandoffStatus: handoffStatusNone,
HandoffMode: listingHandoffMode(listing),
SettlementMode: listingSettlementMode(listing),
ManagedAdminID: listing.ManagedAdminID,
SettlementStatus: settlementStatusUnsettled,
OfflineSettlementStatus: offlineSettlementStatusNone,
}
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
}
reserveListingForOrder(&listing)
if err := tx.Save(&listing).Error; err != nil {
return err
}
createdID = order.ID
return nil
})
if err != nil {
return nil, err
}
return r.FindForUser(ctx, renterID, createdID)
}
func listingHandoffMode(listing model.RentalListing) string {
if listing.HandoffMode != "" {
return listing.HandoffMode
}
return handoffModeOwner
}
func listingSettlementMode(listing model.RentalListing) string {
if listing.SettlementMode != "" {
return listing.SettlementMode
}
return settlementModeOwnerWallet
}
type depositWaiverAmounts struct {
PaidCent int64
WaivedCent int64
UsedBeforeCent int64
}
func (r *Repository) depositAmountsForOrder(tx *gorm.DB, renterID uint64, originalDepositCent int64, growthSnapshot rentergrowth.DiscountSnapshot) (depositWaiverAmounts, error) {
if originalDepositCent <= 0 {
return depositWaiverAmounts{}, nil
}
used, err := activeDepositFreeUsed(tx, renterID)
if err != nil {
return depositWaiverAmounts{}, err
}
paidDeposit, waivedDeposit := calculateDepositWaiver(originalDepositCent, growthSnapshot.EffectiveDepositFreeQuotaCent, used)
return depositWaiverAmounts{
PaidCent: paidDeposit,
WaivedCent: waivedDeposit,
UsedBeforeCent: used,
}, 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{orderStatusCompleted, orderStatusCancelled, orderStatusClosed},
).
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(ctx context.Context, userID uint64, orderID uint64) error {
return ErrChannelPaymentRequired
}
// ConfirmPaidFromChannel 在乐刷确认支付后推进订单状态;租客资金不进入站内钱包。
func (r *Repository) ConfirmPaidFromChannel(ctx context.Context, orderID uint64, providerBizNo string) error {
var newConvID uint64
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
convID, err := r.ConfirmPaidFromChannelTx(tx, orderID)
if err != nil {
return err
}
newConvID = convID
return nil
})
if err != nil {
return err
}
if newConvID > 0 && r.chatNotifier != nil {
r.chatNotifier.NotifyNewConversation(newConvID)
}
return nil
}
// ConfirmPaidFromChannelTx 在外部事务内推进支付成功后的订单状态。
// 调用方负责提交事务,并在提交成功后按返回的会话 ID 触发异步通知。
func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint64, error) {
assets, err := r.lockOrderAssets(tx, orderID)
if err != nil {
return 0, err
}
order := assets.Order
listing := assets.Listing
account := assets.Account
if order.Status == orderStatusPendingHandoff || order.Status == orderStatusRenting {
return 0, nil
}
if order.Status != orderStatusPendingPayment {
return 0, ErrOrderCannotPay
}
if listing.Status != listingStatusPublished || listing.ReviewStatus != listingReviewStatusApproved || !listing.InTransaction {
return 0, ErrListingUnavailable
}
active, err := hasActiveSiblingOrder(tx, order)
if err != nil {
return 0, err
}
if active {
return 0, ErrListingUnavailable
}
// 租客已通过外部渠道付款,这里不写租客钱包流水。
orderID = order.ID
now := time.Now()
order.Status = orderStatusPendingHandoff
order.HandoffStatus = handoffStatusPendingOwner
order.HandoffStartedAt = &now
if err := markAssetsRented(tx, listing, account); err != nil {
return 0, err
}
// 拉租客进发布群(替代原来的建订单群)
conversationID := uint64(0)
if err := chat.AddRenterToListingConversation(tx, listing.ID, order.RenterID, order.OrderNo); err != nil {
return 0, err
}
// 查询发布群ID用于返回
var listingConv model.ChatConversation
if err := tx.Where("listing_id = ?", listing.ID).First(&listingConv).Error; err == nil {
conversationID = listingConv.ID
}
if isPlatformHandoffOrder(*order) {
if err := appendManagedAdminNotification(tx, *order, "order", "代管订单待交接", "租客已完成支付,请尽快在订单详情中提交交接说明。"); err != nil {
return 0, err
}
if err := notification.Append(tx, notification.Entry{
UserID: order.RenterID,
Type: "order",
Title: "订单支付成功",
Content: "支付已完成,等待客服提交交接说明。",
BizType: "order",
BizID: &orderID,
}); err != nil {
return 0, err
}
} else {
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 0, err
}
}
if err := tx.Save(order).Error; err != nil {
return 0, err
}
if err := tx.Save(listing).Error; err != nil {
return 0, err
}
if err := tx.Save(account).Error; err != nil {
return 0, err
}
return conversationID, nil
}
func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64) error {
err := r.db.WithContext(ctx).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 != orderStatusPendingPayment && order.Status != orderStatusPendingHandoff {
return ErrOrderCannotCancel
}
listing, account, err := r.lockListingAccountForOrder(tx, &order)
if err != nil {
return err
}
beforeStatus := order.Status
now := time.Now()
order.Status = orderStatusCancelled
order.SettledAt = &now
orderID := order.ID
if beforeStatus == orderStatusPendingHandoff {
totalCent := order.RentAmountCent + order.DepositAmountCent
order.RefundStatus = refundStatusPendingReview
order.RefundAmountCent = totalCent
order.RefundedAt = nil
} else {
order.HandoffStatus = handoffStatusCancelled
}
if isPlatformHandoffOrder(order) {
if err := appendManagedAdminNotification(tx, order, "order", "代管订单已取消", "租客已取消订单,退款待客服审核。"); err != nil {
return err
}
if err := notification.Append(tx, notification.Entry{
UserID: order.RenterID,
Type: "order",
Title: "订单取消成功",
Content: "订单已取消,退款将由客服审核后原路退回。",
BizType: "order",
BizID: &orderID,
}); err != nil {
return err
}
} else {
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 := releaseAssetsForRentalIfIdle(tx, &order, listing, account); err != nil {
return err
}
if err := closePendingOrderPayments(tx, order.ID, "order_cancel"); 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
}
return nil
}
// closePendingOrderPayments 在订单取消时关闭仍未完成的下单支付流水,避免后台一直显示“支付中”。
func closePendingOrderPayments(tx *gorm.DB, orderID uint64, source string) error {
raw, err := json.Marshal(map[string]string{
"source": source,
"reason": "订单已取消,关闭未完成支付单",
})
if err != nil {
return err
}
return tx.Model(&model.PaymentOrder{}).
Where("order_id = ? AND biz_type = ? AND status IN ?", orderID, "order_pay", []string{"created", "paying"}).
Updates(map[string]any{
"status": "closed",
"raw_response": datatypes.JSON(raw),
}).Error
}
func (r *Repository) ConfirmReceive(ctx context.Context, userID uint64, orderID uint64) error {
return r.db.WithContext(ctx).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 != orderStatusPendingHandoff || order.HandoffStatus != handoffStatusPendingRenterConfirm {
return ErrOrderCannotReceive
}
now := time.Now()
if err := tx.Model(&model.HandoffRecord{}).
Where("order_id = ? AND type IN ?", order.ID, []string{"owner_handoff", "platform_handoff"}).
Update("confirmed_by_renter_at", now).Error; err != nil {
return err
}
order.Status = orderStatusRenting
order.HandoffStatus = handoffStatusReceived
order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot)
order.RentedAt = &now
orderID := order.ID
if isPlatformHandoffOrder(order) {
if err := appendManagedAdminNotification(tx, order, "handoff", "租客已确认收号", "代管订单已进入使用中。"); err != nil {
return err
}
} else {
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
})
}