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) base := r.baseQuery(ctx).Where("o.renter_id = ? OR o.owner_id = ?", userID, userID) countBase := r.db.WithContext(ctx).Table("rental_orders AS o").Where("o.renter_id = ? OR o.owner_id = ?", userID, 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 } 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 } items = append(items, row.toUserListItem(userID, deadline)) } 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.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 = ?", 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 } } items := make([]AdminOrderListItemDTO, 0, len(rows)) for _, row := range rows { items = append(items, row.toAdminListItem()) } 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 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) } // latestAdminCheckouts loads one latest checkout per order so the admin order // list can display finalized income without issuing one query per row. func (r *Repository) latestAdminCheckouts(ctx context.Context, rows []orderRow) (map[uint64]model.OrderCheckout, error) { result := make(map[uint64]model.OrderCheckout, len(rows)) if len(rows) == 0 { return result, nil } orderIDs := make([]uint64, 0, len(rows)) for _, row := range rows { orderIDs = append(orderIDs, row.ID) } latest := r.db.WithContext(ctx). Table("order_checkouts"). Select("MAX(id) AS id"). Where("order_id IN ?", orderIDs). Group("order_id") var checkouts []model.OrderCheckout if err := r.db.WithContext(ctx). Table("order_checkouts AS oc"). Joins("JOIN (?) AS latest ON latest.id = oc.id", latest). Find(&checkouts).Error; err != nil { return nil, err } for _, checkout := range checkouts { result[checkout.OrderID] = checkout } return result, nil } func shouldAttachAdminCheckout(row orderRow) bool { return shouldAttachCheckout(row.Status) || row.SettlementStatus == settlementStatusSettled || row.SettlementStatus == settlementStatusArbitrated } func applyAdminOrderFilters(db *gorm.DB, query AdminOrderQuery) *gorm.DB { 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 len(keyword) == 11 && keyword[0] == '1' && isAllDigits(keyword) { // 11 位手机号:命中 users(phone) 唯一索引 db = db.Where("(owner.phone = ? OR renter.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) 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 shouldAttachCheckout(status string) bool { switch status { case orderStatusPendingCheckoutConfirm, orderStatusPendingCheckoutAccept, orderStatusCheckoutDisputing, orderStatusCompleted: return true default: return false } } 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 }