Files
hfb_sys/backend/internal/modules/order/queries.go
T

538 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package order
import (
"context"
"strconv"
"strings"
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/distribution"
"gorm.io/gorm"
)
func (r *Repository) ListForUser(ctx context.Context, userID uint64, page, pageSize int, siteCode ...string) (*UserPaginatedResult, error) {
page, pageSize = normalizePagination(page, pageSize)
ownerVisible := "NOT (o.owner_id = ? AND o.owner_hidden_at IS NOT NULL)"
base := r.baseQuery(ctx).Where("o.renter_id = ? OR o.owner_id = ?", userID, userID).Where(ownerVisible, userID)
countBase := r.db.WithContext(ctx).Table("rental_orders AS o").Where("o.renter_id = ? OR o.owner_id = ?", userID, userID).Where(ownerVisible, userID)
var code string
if len(siteCode) > 0 {
code = strings.TrimSpace(siteCode[0])
}
if code != "" {
base = base.Joins("INNER JOIN distribution_sites AS ds ON ds.id = o.distribution_site_id AND ds.site_code = ?", code).
Where("o.owner_id = ? OR o.distribution_mode != ?", userID, distribution.ModeTwoPartyDistributorReceive)
countBase = countBase.Joins("INNER JOIN distribution_sites AS ds ON ds.id = o.distribution_site_id AND ds.site_code = ?", code).
Where("o.owner_id = ? OR o.distribution_mode != ?", userID, distribution.ModeTwoPartyDistributorReceive)
} else {
mainSiteCond := "o.owner_id = ? OR o.distribution_site_id IS NULL OR o.distribution_site_id = 0 OR o.distribution_mode = ?"
base = base.Where(mainSiteCond, userID, distribution.ModeTwoPartyDistributorReceive)
countBase = countBase.Where(mainSiteCond, userID, distribution.ModeTwoPartyDistributorReceive)
}
var total int64
if err := countBase.Count(&total).Error; err != nil {
return nil, err
}
var rows []orderRow
db := r.db.WithContext(ctx)
err := base.
Order("o.id DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Scan(&rows).Error
if err != nil {
return nil, err
}
ownerOrderIDs := make([]uint64, 0, len(rows))
for _, row := range rows {
if row.OwnerID == userID {
ownerOrderIDs = append(ownerOrderIDs, row.ID)
}
}
signals, err := loadOwnerHideSignals(db, ownerOrderIDs)
if err != nil {
return nil, err
}
items := make([]UserOrderListItemDTO, 0, len(rows))
paymentTimeoutMinutes := pendingPaymentTimeoutMinutes(db)
for _, row := range rows {
var deadline *time.Time
if row.Status == orderStatusPendingPayment && paymentTimeoutMinutes > 0 {
value := row.CreatedAt.Add(time.Duration(paymentTimeoutMinutes) * time.Minute)
deadline = &value
}
item := row.toUserListItem(userID, deadline)
if item.Role == "owner" {
_, disputed := signals.disputed[row.ID]
item.CanHide = ownerMayHideOrder(row.RentalOrder, disputed, signals.dist[row.ID])
}
items = append(items, item)
}
return &UserPaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
func (r *Repository) ListSellerHandoffs(ctx context.Context, userID uint64, query SellerHandoffQuery) (*SellerHandoffPaginatedResult, error) {
page, pageSize := normalizePagination(query.Page, query.PageSize)
base := r.baseQuery(ctx).
Where("o.owner_id = ?", userID).
Where("o.owner_hidden_at IS NULL").
Where("o.status IN ?", sellerHandoffStatuses())
if query.Status != "" {
base = base.Where("o.status = ?", query.Status)
}
if query.SiteCode != "" {
base = base.Joins("INNER JOIN distribution_sites AS ds ON ds.id = o.distribution_site_id AND ds.site_code = ?", query.SiteCode)
}
var total int64
if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil {
return nil, err
}
var rows []orderRow
if err := base.Order("o.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil {
return nil, err
}
items := make([]UserOrderListItemDTO, 0, len(rows))
for _, row := range rows {
items = append(items, row.toUserListItem(userID, nil))
}
metrics, err := r.sellerHandoffMetrics(ctx, userID, query.SiteCode)
if err != nil {
return nil, err
}
return &SellerHandoffPaginatedResult{
Items: items,
Total: total,
Page: page,
PageSize: pageSize,
Metrics: metrics,
}, nil
}
func (r *Repository) sellerHandoffMetrics(ctx context.Context, userID uint64, siteCode ...string) (SellerHandoffMetrics, error) {
base := r.db.WithContext(ctx).Table("rental_orders AS o").Where("o.owner_id = ? AND o.owner_hidden_at IS NULL", userID)
if len(siteCode) > 0 && siteCode[0] != "" {
base = base.Joins("INNER JOIN distribution_sites AS ds ON ds.id = o.distribution_site_id AND ds.site_code = ?", siteCode[0])
}
var metrics SellerHandoffMetrics
if err := base.Session(&gorm.Session{}).Where("o.status = ? AND o.handoff_status = ?", orderStatusPendingHandoff, handoffStatusPendingOwner).Count(&metrics.PendingHandoff).Error; err != nil {
return SellerHandoffMetrics{}, err
}
if err := base.Session(&gorm.Session{}).Where("o.status = ?", orderStatusPendingCheckoutConfirm).Count(&metrics.PendingCheckout).Error; err != nil {
return SellerHandoffMetrics{}, err
}
if err := base.Session(&gorm.Session{}).Where("o.status IN ?", sellerHandoffAbnormalStatuses()).Count(&metrics.Abnormal).Error; err != nil {
return SellerHandoffMetrics{}, err
}
return metrics, nil
}
func sellerHandoffStatuses() []string {
return []string{
orderStatusPendingHandoff,
orderStatusRenting,
orderStatusOverdue,
orderStatusPendingCheckoutConfirm,
orderStatusPendingCheckoutAccept,
orderStatusCheckoutDisputing,
"disputing",
orderStatusAbnormal,
}
}
func sellerHandoffAbnormalStatuses() []string {
return []string{orderStatusOverdue, orderStatusCheckoutDisputing, "disputing", orderStatusAbnormal}
}
func isSellerHandoffStatus(status string) bool {
for _, candidate := range sellerHandoffStatuses() {
if status == candidate {
return true
}
}
return false
}
func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*AdminOrderListPaginatedResult, error) {
page := query.Page
pageSize := query.PageSize
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
offset := (page - 1) * pageSize
var rows []orderRow
err := applyAdminOrderFilters(r.adminQuery(ctx), query).
Order("o.id DESC").
Limit(pageSize).
Offset(offset).
Scan(&rows).Error
if err != nil {
return nil, err
}
var total int64
// 首页查询且返回条数小于 pageSize 时,总数等于当前返回条数,免除第二次 COUNT(*) 扫描
if page == 1 && len(rows) < pageSize {
total = int64(len(rows))
} else {
countDB := applyAdminOrderFilters(r.adminOrderCountQuery(ctx, query), query)
if err := countDB.Count(&total).Error; err != nil {
return nil, err
}
}
// 号主实际收入:按需收集本页行号,一次性批量查询(单条 SQL),避免逐行查询的 N+1。
ownerIncome := make(map[uint64]int64)
incomeOrderIDs := make([]uint64, 0, len(rows))
for _, row := range rows {
if adminListItemNeedsOwnerIncome(row) {
incomeOrderIDs = append(incomeOrderIDs, row.ID)
}
}
if len(incomeOrderIDs) > 0 {
ownerIncome, err = r.ownerActualIncomeByOrder(ctx, incomeOrderIDs)
if err != nil {
return nil, err
}
}
items := make([]AdminOrderListItemDTO, 0, len(rows))
for _, row := range rows {
item := row.toAdminListItem()
if amount, ok := ownerIncome[row.ID]; ok {
value := amount
item.OwnerIncomeAmountCent = &value
}
items = append(items, item)
}
return &AdminOrderListPaginatedResult{
Items: items,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
func normalizePagination(page, pageSize int) (int, int) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
return page, pageSize
}
func (r *Repository) adminOrderCountQuery(ctx context.Context, query AdminOrderQuery) *gorm.DB {
kw := strings.TrimSpace(query.Keyword)
upper := strings.ToUpper(kw)
needsJoin := false
if kw != "" {
if isPhoneKeyword(kw) || strings.HasPrefix(upper, "RO") || strings.HasPrefix(upper, "ORD") {
needsJoin = false
} else if id, err := strconv.ParseUint(kw, 10, 64); err == nil && id > 0 && kw[0] != '0' && len(kw) < 9 {
needsJoin = false
} else {
needsJoin = true
}
}
if !needsJoin {
return r.db.WithContext(ctx).Table("rental_orders AS o")
}
return r.adminOrderJoinQuery(ctx)
}
// ownerActualIncomeByOrder 批量读取每单号主最终实际结算金额(分)。
//
// 结账行只能代表协商结果,不能代表仲裁后的最终落账:
// - 平台代管订单:最终金额写入 rental_orders.offline_settlement_amount_cent;
// - 普通钱包订单:最终金额以 wallet_ledger 中号主的实际入账合计为准。
//
// 仲裁可能不更新历史结账行,甚至 dispute.checkout_id 为空,因此不能继续取
// order_checkouts 的 owner_income_amount_cent,否则列表会显示仲裁前金额。
func (r *Repository) ownerActualIncomeByOrder(ctx context.Context, orderIDs []uint64) (map[uint64]int64, error) {
result := make(map[uint64]int64, len(orderIDs))
if len(orderIDs) == 0 {
return result, nil
}
var rows []struct {
ID uint64
OwnerIncomeAmountCent int64
}
if err := r.db.WithContext(ctx).
Table("rental_orders AS o").
Select(`o.id,
CASE WHEN o.settlement_mode = 'platform_managed'
THEN COALESCE(o.offline_settlement_amount_cent, 0)
ELSE COALESCE(SUM(wl.amount_cent), 0)
END AS owner_income_amount_cent`).
Joins(`LEFT JOIN wallet_ledger AS wl
ON wl.order_id = o.id
AND wl.user_id = o.owner_id
AND wl.direction = 'in'
AND wl.biz_type IN ?`, []string{walletBizOwnerIncome, walletBizDepositCompensation, "arbitration_owner_income"}).
Where("o.id IN ?", orderIDs).
Group("o.id, o.settlement_mode, o.offline_settlement_amount_cent").
Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
result[row.ID] = row.OwnerIncomeAmountCent
}
return result, nil
}
// adminListItemNeedsOwnerIncome 判断列表行是否需要附带号主实际收入。
// 仅已结账/已仲裁时库中金额为最终值;协商中的金额会随后续还价变化,
// 不作为列表结论展示(协商中的实时金额走详情接口)。
func adminListItemNeedsOwnerIncome(row orderRow) bool {
return row.SettlementStatus == settlementStatusSettled ||
row.SettlementStatus == settlementStatusArbitrated
}
func applyAdminOrderFilters(db *gorm.DB, query AdminOrderQuery) *gorm.DB {
if query.UserID > 0 {
// 精确命中该用户作为号主或租客参与的订单,避免与 keyword 的数字 ID 模糊命中混淆。
db = db.Where("(o.owner_id = ? OR o.renter_id = ?)", query.UserID, query.UserID)
}
if query.Status != "" {
db = db.Where("o.status = ?", query.Status)
}
if query.HandoffStatus != "" {
db = db.Where("o.handoff_status = ?", query.HandoffStatus)
}
switch query.HandoffMode {
case handoffModePlatform:
db = db.Where("(o.handoff_mode = ? OR o.settlement_mode = ?)", handoffModePlatform, settlementModePlatformManaged)
case handoffModeOwner, "normal":
db = db.Where("COALESCE(NULLIF(o.handoff_mode, ''), ?) <> ? AND COALESCE(NULLIF(o.settlement_mode, ''), ?) <> ?", handoffModeOwner, handoffModePlatform, settlementModeOwnerWallet, settlementModePlatformManaged)
}
if query.SettlementStatus != "" {
db = db.Where("o.settlement_status = ?", query.SettlementStatus)
}
if query.OfflineSettlementStatus != "" {
db = db.Where("COALESCE(NULLIF(o.offline_settlement_status, ''), 'none') = ?", query.OfflineSettlementStatus)
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
upper := strings.ToUpper(keyword)
if isPhoneKeyword(keyword) {
// 先由 users(phone) 唯一索引解析用户,再让订单表 owner_id/renter_id 索引驱动。
// 不能直接写 owner.phone/renter.phone:跨表别名 OR 会让 MySQL 倒序扫描全部订单。
db = db.Where(
"(o.owner_id = (SELECT id FROM users WHERE phone = ?) OR o.renter_id = (SELECT id FROM users WHERE phone = ?))",
keyword,
keyword,
)
} else if strings.HasPrefix(upper, "RO") || strings.HasPrefix(upper, "ORD") {
// 订单号:命中 rental_orders(order_no) 唯一索引
db = db.Where("o.order_no = ?", keyword)
} else if strings.HasPrefix(upper, "L") || (len(keyword) >= 12 && isAllDigits(keyword)) {
// 商品编号(如 L前缀或时间计算生成的 12位纯数字如 202609040184):命中 rental_listings(listing_no) 唯一索引
db = db.Where("l.listing_no = ?", keyword)
} else if id, err := strconv.ParseUint(keyword, 10, 64); err == nil && id > 0 && keyword[0] != '0' && id < 100000000 {
// 短纯数字 ID(非零开头):命中 rental_orders(id) 主键及关联 ID
db = db.Where("(o.id = ? OR o.listing_id = ? OR o.owner_id = ? OR o.renter_id = ?)", id, id, id, id)
} else {
like := "%" + keyword + "%"
db = db.Where(
`(o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ?)`,
like,
like,
like,
like,
like,
)
}
}
return db
}
func (r *Repository) FindAdmin(ctx context.Context, orderID uint64) (*OrderDTO, error) {
var row orderRow
db := r.db.WithContext(ctx)
if err := r.adminQuery(ctx).Where("o.id = ?", orderID).First(&row).Error; err != nil {
return nil, err
}
dto := row.toAdminDTO()
applyPaymentDeadline(&dto, row.RentalOrder, pendingPaymentTimeoutMinutes(db))
dto.ActiveDispute = r.activeDisputeDTO(ctx, orderID)
dto.Checkout = r.latestCheckoutAdminDTO(ctx, orderID)
return &dto, nil
}
func (r *Repository) FindLatestAdminByListing(ctx context.Context, listingID uint64) (*OrderDTO, error) {
var row orderRow
db := r.db.WithContext(ctx)
if err := r.adminQuery(ctx).
Where("o.listing_id = ?", listingID).
Order("o.id DESC").
First(&row).Error; err != nil {
return nil, err
}
dto := row.toAdminDTO()
applyPaymentDeadline(&dto, row.RentalOrder, pendingPaymentTimeoutMinutes(db))
dto.ActiveDispute = r.activeDisputeDTO(ctx, row.ID)
dto.Checkout = r.latestCheckoutAdminDTO(ctx, row.ID)
return &dto, nil
}
func (r *Repository) FindForUser(ctx context.Context, userID uint64, orderID uint64, siteCode ...string) (*OrderDTO, error) {
var row orderRow
db := r.db.WithContext(ctx)
query := r.baseQuery(ctx).
Where("o.id = ? AND (o.renter_id = ? OR o.owner_id = ?)", orderID, userID, userID).
Where("NOT (o.owner_id = ? AND o.owner_hidden_at IS NOT NULL)", userID)
var code string
if len(siteCode) > 0 {
code = strings.TrimSpace(siteCode[0])
}
if code != "" {
query = query.Joins("LEFT JOIN distribution_sites AS ds ON ds.id = o.distribution_site_id").
Where("o.owner_id = ? OR (ds.site_code = ? AND o.distribution_mode != ?)", userID, code, distribution.ModeTwoPartyDistributorReceive)
} else {
query = query.Where("o.owner_id = ? OR o.distribution_site_id IS NULL OR o.distribution_site_id = 0 OR o.distribution_mode = ?", userID, distribution.ModeTwoPartyDistributorReceive)
}
if err := query.First(&row).Error; err != nil {
return nil, err
}
dto := row.toDTOForUser(userID)
applyPaymentDeadline(&dto, row.RentalOrder, pendingPaymentTimeoutMinutes(db))
dto.ActiveDispute = r.activeDisputeDTO(ctx, orderID)
dto.Checkout = r.latestCheckoutDTOForUser(ctx, 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 != orderStatusPendingPayment || timeoutMinutes <= 0 {
return
}
deadline := order.CreatedAt.Add(time.Duration(timeoutMinutes) * time.Minute)
dto.PaymentDeadlineAt = &deadline
}
func (r *Repository) latestCheckoutAdminDTO(ctx context.Context, orderID uint64) *CheckoutDTO {
var checkout model.OrderCheckout
if err := r.db.WithContext(ctx).Where("order_id = ?", orderID).Order("id DESC").First(&checkout).Error; err != nil {
return nil
}
dto := toCheckoutAdminDTO(checkout)
var order model.RentalOrder
if err := r.db.WithContext(ctx).First(&order, orderID).Error; err == nil {
refreshCheckoutSettlementDTO(&dto, order, checkout)
}
return &dto
}
func (r *Repository) activeDisputeDTO(ctx context.Context, orderID uint64) *ActiveDisputeDTO {
var row model.Dispute
if err := r.db.WithContext(ctx).
Where("order_id = ? AND status IN ?", orderID, []string{"open", "processing"}).
Order("id DESC").
First(&row).Error; err != nil {
return nil
}
return &ActiveDisputeDTO{
ID: row.ID,
InitiatorID: row.InitiatorID,
InitiatorType: effectiveDisputeInitiatorType(row),
InitiatorAdminID: row.InitiatorAdminID,
Type: row.Type,
Status: row.Status,
}
}
func effectiveDisputeInitiatorType(row model.Dispute) string {
if row.InitiatorType == "admin" {
return "admin"
}
return "user"
}
func (r *Repository) latestCheckoutDTOForUser(ctx context.Context, orderID uint64, userID uint64, order model.RentalOrder) *CheckoutDTO {
var checkout model.OrderCheckout
if err := r.db.WithContext(ctx).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(ctx context.Context, id uint64) (*model.OrderCheckout, error) {
var checkout model.OrderCheckout
if err := r.db.WithContext(ctx).First(&checkout, id).Error; err != nil {
return nil, err
}
return &checkout, nil
}
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
return r.db.WithContext(ctx).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(ctx context.Context) *gorm.DB {
return r.adminOrderJoinQuery(ctx).
Select("o.*, l.listing_no, a.title, a.server_region, a.login_platform, owner.phone AS owner_phone, renter.phone AS renter_phone")
}
func (r *Repository) adminOrderJoinQuery(ctx context.Context) *gorm.DB {
return r.db.WithContext(ctx).Table("rental_orders AS o").
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 isAllDigits(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return len(s) > 0
}
func isPhoneKeyword(s string) bool {
return len(s) == 11 && s[0] == '1' && isAllDigits(s)
}