分站公开列表和详情展示当前货架加价;下单写入 base_price / shelf_markup / markup_site_id 快照并计入实付。 支付分账改用快照加价,不再误用 PlatformFeeCent;结账不退加价,事后改价不影响历史订单。
557 lines
18 KiB
Go
557 lines
18 KiB
Go
package order
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"hfb_sys/backend/internal/model"
|
|
"hfb_sys/backend/internal/modules/chat"
|
|
"hfb_sys/backend/internal/modules/distribution"
|
|
"hfb_sys/backend/internal/modules/notification"
|
|
"hfb_sys/backend/internal/modules/rentergrowth"
|
|
"hfb_sys/backend/internal/processlog"
|
|
|
|
"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
|
|
}
|
|
accountSource, sourceChannel, err := orderAccountSourceSnapshot(tx, listing.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
orderNo, err := newOrderNo()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rentHours := estimateOrderDurationHours(snapshot)
|
|
shelfMarkupCent, markupSiteID, err := loadShelfMarkupTx(tx, req.DistributionSiteCode, listing.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pricing := buildOrderPricing(listing, account)
|
|
basePriceCent := listing.PriceCent
|
|
if shelfMarkupCent > 0 {
|
|
pricing.RentAmountCent += shelfMarkupCent
|
|
}
|
|
distributionSite, distributionRule, err := distribution.ResolveOrderSourceTx(tx, req.DistributionSiteCode, listing.ID, account.ID, time.Now())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rentOriginalAmountCent := pricing.RentAmountCent
|
|
var growthSnapshot rentergrowth.DiscountSnapshot
|
|
if distributionSite == nil {
|
|
snap, err := rentergrowth.SnapshotForUser(tx, renterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
growthSnapshot = snap
|
|
} else {
|
|
// 分销站点订单隔离主站成长等级免押与折扣,防止主站免押坏账与折扣侵蚀分销收益
|
|
growthSnapshot = rentergrowth.DiscountSnapshot{
|
|
LevelCode: rentergrowth.DefaultLevelCode,
|
|
LevelName: rentergrowth.DefaultLevelName,
|
|
DiscountBps: rentergrowth.DefaultDiscountBps,
|
|
}
|
|
}
|
|
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,
|
|
BasePriceCent: basePriceCent,
|
|
ShelfMarkupCent: shelfMarkupCent,
|
|
MarkupSiteID: markupSiteID,
|
|
RentOriginalAmountCent: rentOriginalAmountCent,
|
|
RentDiscountAmountCent: rentDiscountAmountCent,
|
|
PureCoinOriginalAmountCent: pricing.PureCoinAmountCent,
|
|
ExtraItemOriginalAmountCent: pricing.ExtraItemAmountCent,
|
|
RenterGrowthLevel: growthSnapshot.LevelCode,
|
|
RenterGrowthLevelName: growthSnapshot.LevelName,
|
|
RenterDiscountBps: growthSnapshot.DiscountBps,
|
|
AccountSnapshot: snapshot,
|
|
AccountSource: accountSource,
|
|
SourceChannel: sourceChannel,
|
|
Status: orderStatusPendingPayment,
|
|
HandoffStatus: handoffStatusNone,
|
|
HandoffMode: listingHandoffMode(listing),
|
|
SettlementMode: listingSettlementMode(listing),
|
|
ManagedAdminID: listing.ManagedAdminID,
|
|
SettlementStatus: settlementStatusUnsettled,
|
|
OfflineSettlementStatus: offlineSettlementStatusNone,
|
|
}
|
|
if distributionSite != nil && distributionRule != nil {
|
|
order.DistributionSiteID = &distributionSite.ID
|
|
order.DistributionMode = distributionRule.Mode
|
|
order.DistributionRuleVersionID = &distributionRule.ID
|
|
}
|
|
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 loadShelfMarkupTx(tx *gorm.DB, siteCode string, listingID uint64) (int64, uint64, error) {
|
|
siteCode = strings.TrimSpace(siteCode)
|
|
if siteCode == "" || listingID == 0 {
|
|
return 0, 0, nil
|
|
}
|
|
var row struct {
|
|
SiteID uint64 `gorm:"column:site_id"`
|
|
Markup int64 `gorm:"column:markup"`
|
|
}
|
|
err := tx.Table("distribution_site_accounts AS dsa").
|
|
Select("dsa.site_id, COALESCE(dsa.markup_amount_cent, 0) AS markup").
|
|
Joins("INNER JOIN distribution_sites AS ds ON ds.id = dsa.site_id AND ds.site_code = ? AND ds.status = 'active'", siteCode).
|
|
Where("dsa.listing_id = ? AND dsa.display_status = 'enabled'", listingID).
|
|
Take(&row).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return 0, 0, nil
|
|
}
|
|
return 0, 0, err
|
|
}
|
|
if row.Markup < 0 {
|
|
row.Markup = 0
|
|
}
|
|
return row.Markup, row.SiteID, nil
|
|
}
|
|
|
|
func orderAccountSourceSnapshot(tx *gorm.DB, listingID uint64) (string, string, error) {
|
|
var upload model.ListingUpload
|
|
err := tx.Where("listing_id = ?", listingID).Order("id DESC").First(&upload).Error
|
|
if err == nil {
|
|
return "external", upload.SourceChannel, nil
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return "", "", err
|
|
}
|
|
|
|
var listing model.RentalListing
|
|
if err := tx.Select("distribution_site_id").First(&listing, listingID).Error; err == nil && listing.DistributionSiteID != nil {
|
|
var site model.DistributionSite
|
|
if err := tx.Select("site_code, site_name").First(&site, *listing.DistributionSiteID).Error; err == nil {
|
|
return "distribution", fmt.Sprintf("分销站 - %s (%s)", site.SiteName, site.SiteCode), nil
|
|
}
|
|
}
|
|
|
|
return "internal", "", nil
|
|
}
|
|
|
|
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 order.DistributionSiteID != nil && *order.DistributionSiteID > 0 {
|
|
orderConv, err := chat.EnsureOrderConversation(tx, *order)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if orderConv != nil {
|
|
conversationID = orderConv.ID
|
|
}
|
|
} else {
|
|
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 beforeStatus == orderStatusPendingPayment {
|
|
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
|
|
}
|
|
before := orderState(order)
|
|
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
|
|
if err := appendOrderEvent(tx, order, "handoff", "renter_received_confirmed", processlog.ActorUser, userID, processlog.ActorUser, &order.OwnerID, "租客确认已收号,订单开始租用。", "", nil, before, nil); err != nil {
|
|
return err
|
|
}
|
|
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
|
|
})
|
|
}
|