diff --git a/.gitignore b/.gitignore index 1fb60f1..bd655bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .snow .vscode .idea +.openidea/ .claude *.log backend/logs/ @@ -46,4 +47,4 @@ artifacts/ frontend/coverage/ .zcode/ -.workbuddy/ \ No newline at end of file +.workbuddy/ diff --git a/backend/internal/modules/adminfinance/disbursement_list.go b/backend/internal/modules/adminfinance/disbursement_list.go index eccf568..2567a20 100644 --- a/backend/internal/modules/adminfinance/disbursement_list.go +++ b/backend/internal/modules/adminfinance/disbursement_list.go @@ -11,7 +11,7 @@ import ( func (r *Repository) Disbursements(ctx context.Context, query DisbursementQuery) (*DisbursementListDTO, error) { var summary DisbursementListSummaryDTO - summaryDB := r.applyDisbursementFilters(r.disbursementListBaseQuery(ctx), query) + summaryDB := r.applyDisbursementFilters(r.disbursementListBaseQuery(ctx, query.Keyword), query) if err := summaryDB.Select(`COUNT(*) AS record_count, COALESCE(SUM(CASE WHEN status = 'paid' THEN actual_amount_cent ELSE 0 END), 0) AS paid_amount_cent, COALESCE(SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END), 0) AS paid_count, @@ -30,7 +30,7 @@ func (r *Repository) Disbursements(ctx context.Context, query DisbursementQuery) orderColumn = "paid_at" } offset := (query.Page - 1) * query.PageSize - rowsDB := r.applyDisbursementFilters(r.disbursementListBaseQuery(ctx), query) + rowsDB := r.applyDisbursementFilters(r.disbursementListBaseQuery(ctx, query.Keyword), query) if err := rowsDB.Order(orderColumn + " DESC"). Order("source_id DESC"). Offset(offset). @@ -52,7 +52,7 @@ func (r *Repository) Disbursements(ctx context.Context, query DisbursementQuery) }, nil } -func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB { +func (r *Repository) disbursementListBaseQuery(ctx context.Context, phoneKeyword string) *gorm.DB { db := r.db.WithContext(ctx) platformManaged := db.Table("rental_orders AS ro"). Select(`'platform_managed' AS source_type, @@ -213,6 +213,11 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB { Joins("LEFT JOIN users AS u ON u.id = wr.user_id"). Joins("LEFT JOIN admin_users AS operator ON operator.id = COALESCE(wr.paid_by, wr.reviewed_by)") + if phoneKeyword = strings.TrimSpace(phoneKeyword); isPhoneKeyword(phoneKeyword) { + withdrawal = withdrawal.Where("wr.user_id = (SELECT id FROM users WHERE phone = ?)", phoneKeyword) + return db.Table("(?) AS d", withdrawal) + } + manualOffline := db.Table("manual_disbursements AS md"). Select(`'manual_offline' AS source_type, md.id AS source_id, @@ -267,9 +272,15 @@ func (r *Repository) applyDisbursementFilters(db *gorm.DB, query DisbursementQue db = db.Where("d.user_id = ?", query.UserID) } if query.Keyword != "" { - like := "%" + query.Keyword + "%" - db = db.Where(`(d.business_no LIKE ? OR d.payee_name LIKE ? OR d.payee_phone LIKE ? - OR d.uploader_name LIKE ? OR d.account_name LIKE ? OR d.account_no LIKE ?)`, like, like, like, like, like, like) + keyword := strings.TrimSpace(query.Keyword) + if isPhoneKeyword(keyword) { + // 完整手机号只可能命中提现收款人,先经 users(phone) 唯一索引解析用户。 + db = db.Where("d.source_type = ? AND d.user_id = (SELECT id FROM users WHERE phone = ?)", "withdrawal", keyword) + } else { + like := "%" + keyword + "%" + db = db.Where(`(d.business_no LIKE ? OR d.payee_name LIKE ? OR d.payee_phone LIKE ? + OR d.uploader_name LIKE ? OR d.account_name LIKE ? OR d.account_no LIKE ?)`, like, like, like, like, like, like) + } } if query.Operator != "" { like := "%" + query.Operator + "%" @@ -288,6 +299,18 @@ func (r *Repository) applyDisbursementFilters(db *gorm.DB, query DisbursementQue return db } +func isPhoneKeyword(s string) bool { + if len(s) != 11 || s[0] != '1' { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} + type disbursementItemRow struct { SourceType string SourceID uint64 diff --git a/backend/internal/modules/adminfinance/disbursement_list_test.go b/backend/internal/modules/adminfinance/disbursement_list_test.go index 0611700..fed27a6 100644 --- a/backend/internal/modules/adminfinance/disbursement_list_test.go +++ b/backend/internal/modules/adminfinance/disbursement_list_test.go @@ -95,6 +95,22 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T) if result.Summary.ReviewPendingAmountCent != 5000 || result.Summary.ReviewPendingCount != 1 { t.Fatalf("待审核 = %d/%d, want 5000/1", result.Summary.ReviewPendingAmountCent, result.Summary.ReviewPendingCount) } + phoneFiltered, err := repo.Disbursements(t.Context(), DisbursementQuery{ + Keyword: "13900000000", + Page: 1, + PageSize: 20, + }) + if err != nil { + t.Fatalf("按完整手机号查询出款失败: %v", err) + } + if phoneFiltered.Total != 3 { + t.Fatalf("按完整手机号查询记录数 = %d, want 3", phoneFiltered.Total) + } + for _, item := range phoneFiltered.Items { + if item.SourceType != "withdrawal" || item.PayeePhone != "13900000000" { + t.Fatalf("完整手机号筛选返回了不匹配记录: %+v", item) + } + } var platformItem *DisbursementItemDTO for index := range result.Items { diff --git a/backend/internal/modules/chat/admin_list_test.go b/backend/internal/modules/chat/admin_list_test.go index 422f18a..c16f045 100644 --- a/backend/internal/modules/chat/admin_list_test.go +++ b/backend/internal/modules/chat/admin_list_test.go @@ -105,6 +105,13 @@ func TestListAdminConversationsUsesPersonalStateAndBatchData(t *testing.T) { if counts.Ownership[adminChatFilterMine] != 1 || counts.Stages[adminChatStageAll] != 1 { t.Fatalf("聚合统计异常: ownership=%v stages=%v", counts.Ownership, counts.Stages) } + result, err = repo.ListConversationsWithFilter(t.Context(), Principal{Type: "admin", ID: admin.ID}, 1, 20, adminChatFilterMine, adminChatStageAll, user.Phone) + if err != nil { + t.Fatalf("按手机号查询订单群失败: %v", err) + } + if result.Total != 1 { + t.Fatalf("按手机号匹配订单群数量 = %d, want 1", result.Total) + } if err := db.Model(&conversation).Update("type", ConversationTypeGeneralSupport).Error; err != nil { t.Fatalf("切换咨询会话类型失败: %v", err) } @@ -183,6 +190,13 @@ func TestListAdminConversationsUsesListingOrderSnapshot(t *testing.T) { if items[0].LatestOrderID == nil || *items[0].LatestOrderID != order.ID || items[0].LatestOrderStatus != "pending_handoff" { t.Fatalf("最新订单快照未生效: %+v", items[0]) } + result, err = repo.ListConversationsWithFilter(t.Context(), Principal{Type: "admin", ID: admin.ID}, 1, 20, adminChatFilterMine, adminChatStageAll, owner.Phone) + if err != nil { + t.Fatalf("按号主手机号查询发布群失败: %v", err) + } + if result.Total != 1 { + t.Fatalf("按号主手机号匹配发布群数量 = %d, want 1", result.Total) + } order.Status = "renting" if err := db.Save(&order).Error; err != nil { diff --git a/backend/internal/modules/chat/support.go b/backend/internal/modules/chat/support.go index 7e1a761..d8a7903 100644 --- a/backend/internal/modules/chat/support.go +++ b/backend/internal/modules/chat/support.go @@ -307,10 +307,22 @@ func (r *Repository) adminConversationBase(ctx context.Context, principal Princi Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(explicit_lo.listing_id, c.listing_id)") if keyword != "" { upper := strings.ToUpper(keyword) - if len(keyword) == 11 && keyword[0] == '1' && isAllDigits(keyword) { - db = db.Joins("LEFT JOIN users AS renter ON renter.id = explicit_lo.renter_id"). - Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(explicit_lo.owner_id, l.owner_id)"). - Where("(renter.phone = ? OR owner.phone = ?)", keyword, keyword) + if isPhoneKeyword(keyword) { + // 先由 users(phone) 唯一索引解析用户,再分别从订单群和发布群定位会话。 + // 保持原有语义:订单群匹配订单号主/租客,发布群仅匹配商品号主。 + db = db.Joins(`JOIN ( + SELECT c2.id AS conversation_id + FROM chat_conversations AS c2 + JOIN rental_orders AS o2 ON o2.id = c2.order_id + WHERE o2.owner_id = (SELECT id FROM users WHERE phone = ?) + OR o2.renter_id = (SELECT id FROM users WHERE phone = ?) + UNION ALL + SELECT c2.id AS conversation_id + FROM chat_conversations AS c2 + JOIN rental_listings AS l2 ON l2.id = c2.listing_id + WHERE c2.order_id IS NULL + AND l2.owner_id = (SELECT id FROM users WHERE phone = ?) + ) AS matched_phone ON matched_phone.conversation_id = c.id`, keyword, keyword, keyword) } else if strings.HasPrefix(upper, "RO") || strings.HasPrefix(upper, "ORD") { db = db.Where("(explicit_lo.order_no = ? OR c.latest_order_no = ?)", keyword, keyword) } else if strings.HasPrefix(upper, "L") || (len(keyword) >= 12 && isAllDigits(keyword)) { @@ -492,3 +504,7 @@ func isAllDigits(s string) bool { } return true } + +func isPhoneKeyword(s string) bool { + return len(s) == 11 && s[0] == '1' && isAllDigits(s) +} diff --git a/backend/internal/modules/dispute/query.go b/backend/internal/modules/dispute/query.go index 1e16222..72f2489 100644 --- a/backend/internal/modules/dispute/query.go +++ b/backend/internal/modules/dispute/query.go @@ -134,11 +134,9 @@ func (r *Repository) adminDisputeCountQuery(ctx context.Context, query AdminList Joins("JOIN rental_orders AS o ON o.id = d.order_id"). Joins("JOIN rental_listings AS l ON l.id = o.listing_id") } - if len(kw) == 11 && kw[0] == '1' && isAllDigits(kw) { + if isPhoneKeyword(kw) { return r.db.WithContext(ctx).Table("disputes AS d"). - Joins("JOIN rental_orders AS o ON o.id = d.order_id"). - Joins("JOIN users AS owner ON owner.id = o.owner_id"). - Joins("JOIN users AS renter ON renter.id = o.renter_id") + Joins("JOIN rental_orders AS o ON o.id = d.order_id") } return r.adminFilterQuery(ctx) } @@ -152,9 +150,13 @@ func applyAdminListFilters(db *gorm.DB, query AdminListQuery) *gorm.DB { } 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) + if isPhoneKeyword(keyword) { + // 先由 users(phone) 唯一索引解析用户,再走订单号主/租客索引。 + 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) @@ -190,3 +192,7 @@ func isAllDigits(s string) bool { } return true } + +func isPhoneKeyword(s string) bool { + return len(s) == 11 && s[0] == '1' && isAllDigits(s) +} diff --git a/backend/internal/modules/dispute/repository_test.go b/backend/internal/modules/dispute/repository_test.go index f5c5bc0..cd18487 100644 --- a/backend/internal/modules/dispute/repository_test.go +++ b/backend/internal/modules/dispute/repository_test.go @@ -770,9 +770,9 @@ func TestApplyAdminListFiltersSmartRouting(t *testing.T) { wantSQLFrag string }{ { - name: "11位手机号路由至users(phone)", + name: "11位手机号先解析用户再走订单角色索引", query: AdminListQuery{Keyword: "13800138000"}, - wantSQLFrag: "owner.phone = ? OR renter.phone = ?", + wantSQLFrag: "o.owner_id = (SELECT id FROM users WHERE phone = ?)", }, { name: "RO前缀订单号路由至rental_orders(order_no)", diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index c6d2376..089ef34 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -1138,8 +1138,13 @@ func (r *Repository) ListOrders(ctx context.Context, query DistributionOrderList base = base.Where("f.settlement_status = ?", financialStatus) } if strings.TrimSpace(query.Keyword) != "" { - kw := "%" + strings.TrimSpace(query.Keyword) + "%" - base = base.Where("(ro.order_no LIKE ? OR rl.listing_no LIKE ? OR ga.title LIKE ? OR u.phone LIKE ?)", kw, kw, kw, kw) + keyword := strings.TrimSpace(query.Keyword) + if isPhoneKeyword(keyword) { + base = base.Where("ro.renter_id = (SELECT id FROM users WHERE phone = ?)", keyword) + } else { + kw := "%" + keyword + "%" + base = base.Where("(ro.order_no LIKE ? OR rl.listing_no LIKE ? OR ga.title LIKE ? OR u.phone LIKE ?)", kw, kw, kw, kw) + } } switch strings.TrimSpace(query.Kind) { case "sold": @@ -3034,8 +3039,13 @@ func (r *Repository) ListReceivedListings(ctx context.Context, adminID, siteID u base = base.Where("ga.game_name = ?", q.GameName) } if q.Keyword != "" { - kw := "%" + strings.TrimSpace(q.Keyword) + "%" - base = base.Where("(rl.listing_no LIKE ? OR ga.title LIKE ? OR u.phone LIKE ? OR u.nickname LIKE ?)", kw, kw, kw, kw) + keyword := strings.TrimSpace(q.Keyword) + if isPhoneKeyword(keyword) { + base = base.Where("rl.owner_id = (SELECT id FROM users WHERE phone = ?)", keyword) + } else { + kw := "%" + keyword + "%" + base = base.Where("(rl.listing_no LIKE ? OR ga.title LIKE ? OR u.phone LIKE ? OR u.nickname LIKE ?)", kw, kw, kw, kw) + } } var total int64 @@ -3609,7 +3619,11 @@ func (r *Repository) ListDistributionUsers(ctx context.Context, adminID, siteID } if kw := strings.TrimSpace(query.Keyword); kw != "" { - baseQuery = baseQuery.Where("u.phone LIKE ? OR u.nickname LIKE ?", "%"+kw+"%", "%"+kw+"%") + if isPhoneKeyword(kw) { + baseQuery = baseQuery.Where("u.phone = ?", kw) + } else { + baseQuery = baseQuery.Where("u.phone LIKE ? OR u.nickname LIKE ?", "%"+kw+"%", "%"+kw+"%") + } } var totalFiltered int64 @@ -3675,3 +3689,15 @@ func (r *Repository) ListDistributionUsers(ctx context.Context, adminID, siteID OrderUsers: orderUsersCount, }, nil } + +func isPhoneKeyword(s string) bool { + if len(s) != 11 || s[0] != '1' { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go index 0a0f444..f173d4b 100644 --- a/backend/internal/modules/distribution/repository_test.go +++ b/backend/internal/modules/distribution/repository_test.go @@ -911,6 +911,17 @@ func TestDistributionReceivedListingsAndWallet(t *testing.T) { if recPage.Total != 1 || len(recPage.Items) != 1 { t.Fatalf("expected 1 received listing, got %d", recPage.Total) } + phonePage, err := repo.ListReceivedListings(t.Context(), 0, site.ID, ReceivedListingQuery{ + Page: 1, + PageSize: 10, + Keyword: user.Phone, + }) + if err != nil { + t.Fatalf("ListReceivedListings by phone failed: %v", err) + } + if phonePage.Total != 1 || phonePage.Items[0].ListingID != listing.ID { + t.Fatalf("expected listing %d by phone, got total=%d items=%+v", listing.ID, phonePage.Total, phonePage.Items) + } item := recPage.Items[0] if item.ListingNo != "RL-99001" || item.GameName != "delta_force" || item.OwnerNickname != "seller01" { t.Fatalf("unexpected received listing: %+v", item) @@ -1804,6 +1815,18 @@ func TestListOrdersPerformanceAndScoping(t *testing.T) { if len(allPage.Items) != 2 { t.Fatalf("expected 2 items, got %d", len(allPage.Items)) } + phonePage, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{ + SiteID: 0, + Page: 1, + Size: 10, + Keyword: u.Phone, + }) + if err != nil { + t.Fatalf("ListOrders by phone failed: %v", err) + } + if phonePage.Total != 2 { + t.Fatalf("expected 2 distribution orders by renter phone, got %d", phonePage.Total) + } // B. 分销商1查询其订单 (SiteID = site1.ID) // 预期:只查出 distOrder1 diff --git a/backend/internal/modules/invitation/admin_filters_test.go b/backend/internal/modules/invitation/admin_filters_test.go index 9d8c3b3..d37d8fb 100644 --- a/backend/internal/modules/invitation/admin_filters_test.go +++ b/backend/internal/modules/invitation/admin_filters_test.go @@ -55,9 +55,13 @@ func TestListAdminInvitationsFiltersPhoneAndInviteCode(t *testing.T) { query AdminInvitationQuery want int64 }{ + {name: "通用完整手机号精确匹配邀请人", query: AdminInvitationQuery{Phone: "13812345678"}, want: 1}, + {name: "通用完整手机号精确匹配被邀请人", query: AdminInvitationQuery{Phone: "13911112222"}, want: 1}, {name: "通用手机号匹配邀请人", query: AdminInvitationQuery{Phone: "1234"}, want: 1}, {name: "通用手机号匹配被邀请人", query: AdminInvitationQuery{Phone: "139111"}, want: 1}, {name: "邀请码模糊匹配", query: AdminInvitationQuery{InviteCode: "beta"}, want: 1}, + {name: "邀请人完整手机号精确匹配", query: AdminInvitationQuery{InviterPhone: "13812345678"}, want: 1}, + {name: "被邀请人完整手机号精确匹配", query: AdminInvitationQuery{InviteePhone: "13911112222"}, want: 1}, {name: "邀请人手机号模糊匹配", query: AdminInvitationQuery{InviterPhone: "137"}, want: 1}, {name: "被邀请人手机号模糊匹配", query: AdminInvitationQuery{InviteePhone: "1362"}, want: 1}, } @@ -476,6 +480,8 @@ func TestListAdminRebateLedgersFiltersPartialPhoneAndOrderNo(t *testing.T) { want int64 }{ {name: "订单号模糊匹配", query: AdminRebateLedgerQuery{OrderNo: "ALPHA"}, want: 1}, + {name: "邀请人完整手机号精确匹配", query: AdminRebateLedgerQuery{InviterPhone: "13812345678"}, want: 1}, + {name: "被邀请人完整手机号精确匹配", query: AdminRebateLedgerQuery{InviteePhone: "13911112222"}, want: 1}, {name: "邀请人手机号模糊匹配", query: AdminRebateLedgerQuery{InviterPhone: "1381"}, want: 1}, {name: "被邀请人手机号模糊匹配", query: AdminRebateLedgerQuery{InviteePhone: "1362"}, want: 1}, } diff --git a/backend/internal/modules/invitation/repository.go b/backend/internal/modules/invitation/repository.go index d1bb9c7..437f704 100644 --- a/backend/internal/modules/invitation/repository.go +++ b/backend/internal/modules/invitation/repository.go @@ -877,14 +877,30 @@ func (r *Repository) ListAdminInvitations(ctx context.Context, query AdminInvita Joins("LEFT JOIN users invitee ON invitee.id = ui.invitee_id") if p := strings.TrimSpace(query.Phone); p != "" { - like := "%" + p + "%" - db = db.Where("(inviter.phone LIKE ? OR invitee.phone LIKE ?)", like, like) + if isPhoneKeyword(p) { + db = db.Where( + "(ui.inviter_id = (SELECT id FROM users WHERE phone = ?) OR ui.invitee_id = (SELECT id FROM users WHERE phone = ?))", + p, + p, + ) + } else { + like := "%" + p + "%" + db = db.Where("(inviter.phone LIKE ? OR invitee.phone LIKE ?)", like, like) + } } if p := strings.TrimSpace(query.InviterPhone); p != "" { - db = db.Where("inviter.phone LIKE ?", "%"+p+"%") + if isPhoneKeyword(p) { + db = db.Where("ui.inviter_id = (SELECT id FROM users WHERE phone = ?)", p) + } else { + db = db.Where("inviter.phone LIKE ?", "%"+p+"%") + } } if p := strings.TrimSpace(query.InviteePhone); p != "" { - db = db.Where("invitee.phone LIKE ?", "%"+p+"%") + if isPhoneKeyword(p) { + db = db.Where("ui.invitee_id = (SELECT id FROM users WHERE phone = ?)", p) + } else { + db = db.Where("invitee.phone LIKE ?", "%"+p+"%") + } } if code := strings.TrimSpace(query.InviteCode); code != "" { db = db.Where("ui.invite_code LIKE ?", "%"+code+"%") @@ -1008,10 +1024,18 @@ func (r *Repository) adminRebateLedgerBase(ctx context.Context, query AdminRebat db = db.Where("irl.order_no LIKE ?", "%"+o+"%") } if p := strings.TrimSpace(query.InviterPhone); p != "" { - db = db.Where("inviter.phone LIKE ?", "%"+p+"%") + if isPhoneKeyword(p) { + db = db.Where("irl.inviter_id = (SELECT id FROM users WHERE phone = ?)", p) + } else { + db = db.Where("inviter.phone LIKE ?", "%"+p+"%") + } } if p := strings.TrimSpace(query.InviteePhone); p != "" { - db = db.Where("invitee.phone LIKE ?", "%"+p+"%") + if isPhoneKeyword(p) { + db = db.Where("irl.invitee_id = (SELECT id FROM users WHERE phone = ?)", p) + } else { + db = db.Where("invitee.phone LIKE ?", "%"+p+"%") + } } if t := strings.TrimSpace(query.TradeRole); t != "" { db = db.Where("irl.trade_role = ?", t) @@ -1050,6 +1074,18 @@ func parseAdminRebateDate(value string) (time.Time, bool) { return time.Time{}, false } +func isPhoneKeyword(s string) bool { + if len(s) != 11 || s[0] != '1' { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} + // ListAdminRebateLedgers 管理后台查询返利流水 func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminRebateLedgerQuery) ([]map[string]any, int64, error) { if r == nil || r.db == nil { diff --git a/backend/internal/modules/order/queries.go b/backend/internal/modules/order/queries.go index e6ba8a9..6e3fb73 100644 --- a/backend/internal/modules/order/queries.go +++ b/backend/internal/modules/order/queries.go @@ -241,7 +241,7 @@ func (r *Repository) adminOrderCountQuery(ctx context.Context, query AdminOrderQ upper := strings.ToUpper(kw) needsJoin := false if kw != "" { - if strings.HasPrefix(upper, "RO") || strings.HasPrefix(upper, "ORD") { + 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 @@ -334,9 +334,14 @@ func applyAdminOrderFilters(db *gorm.DB, query AdminOrderQuery) *gorm.DB { } 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) + 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) @@ -532,3 +537,7 @@ func isAllDigits(s string) bool { } return len(s) > 0 } + +func isPhoneKeyword(s string) bool { + return len(s) == 11 && s[0] == '1' && isAllDigits(s) +} diff --git a/backend/internal/modules/order/repository_test.go b/backend/internal/modules/order/repository_test.go index 980ee38..64bed4c 100644 --- a/backend/internal/modules/order/repository_test.go +++ b/backend/internal/modules/order/repository_test.go @@ -511,9 +511,9 @@ func TestApplyAdminOrderFiltersSmartRouting(t *testing.T) { wantSQLFrag string }{ { - name: "11位手机号路由至users(phone)", + name: "11位手机号先解析用户再走订单角色索引", query: AdminOrderQuery{Keyword: "13800138000"}, - wantSQLFrag: "owner.phone = ? OR renter.phone = ?", + wantSQLFrag: "o.owner_id = (SELECT id FROM users WHERE phone = ?)", }, { name: "RO前缀订单号路由至rental_orders(order_no)", diff --git a/backend/internal/modules/pickup/repository.go b/backend/internal/modules/pickup/repository.go index 7df4bbd..a93c022 100644 --- a/backend/internal/modules/pickup/repository.go +++ b/backend/internal/modules/pickup/repository.go @@ -178,17 +178,17 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest, adminID uint BizID: &bid, Meta: meta, Detail: map[string]any{ - "pickup_no": pickupNo, - "listing_id": listing.ID, - "platform": pickup.Platform, - "third_party_order_no": pickup.ThirdPartyOrderNo, - "shop_name": pickup.ShopName, - "operator": pickup.Operator, - "account_source": pickup.AccountSource, - "source_channel": pickup.SourceChannel, - "order_amount_cent": pickup.OrderAmountCent, - "deposit_amount_cent": pickup.DepositAmountCent, - "profit_cent": pickup.ProfitAmountCent, + "pickup_no": pickupNo, + "listing_id": listing.ID, + "platform": pickup.Platform, + "third_party_order_no": pickup.ThirdPartyOrderNo, + "shop_name": pickup.ShopName, + "operator": pickup.Operator, + "account_source": pickup.AccountSource, + "source_channel": pickup.SourceChannel, + "order_amount_cent": pickup.OrderAmountCent, + "deposit_amount_cent": pickup.DepositAmountCent, + "profit_cent": pickup.ProfitAmountCent, }, }); err != nil { return err @@ -534,9 +534,9 @@ func (r *Repository) UpdateThirdPartyOrderNo(ctx context.Context, pickupID uint6 BizID: &bid, Meta: meta, Detail: map[string]any{ - "pickup_no": pickup.PickupNo, - "old_third_party_order_no": oldThirdPartyOrderNo, - "new_third_party_order_no": pickup.ThirdPartyOrderNo, + "pickup_no": pickup.PickupNo, + "old_third_party_order_no": oldThirdPartyOrderNo, + "new_third_party_order_no": pickup.ThirdPartyOrderNo, }, }) }) @@ -854,9 +854,9 @@ func applyAdminPickupFilters(db *gorm.DB, query AdminPickupQuery) *gorm.DB { } if kw := strings.TrimSpace(query.Keyword); kw != "" { upper := strings.ToUpper(kw) - if len(kw) == 11 && kw[0] == '1' && isAllDigits(kw) { - // 11 位手机号:命中 users(phone) 唯一索引 - db = db.Where("owner.phone = ?", kw) + if isPhoneKeyword(kw) { + // 先由 users(phone) 唯一索引解析用户,再让 admin_pickups(owner_id) 索引驱动。 + db = db.Where("p.owner_id = (SELECT id FROM users WHERE phone = ?)", kw) } else if strings.HasPrefix(upper, "PK") || strings.HasPrefix(upper, "P") { // 提号编号:命中 admin_pickups(pickup_no) 唯一索引 db = db.Where("p.pickup_no = ?", kw) @@ -894,9 +894,8 @@ func (r *Repository) adminPickupCountQuery(ctx context.Context, query AdminPicku if id, err := strconv.ParseUint(kw, 10, 64); err == nil && id > 0 && kw[0] != '0' && id < 100000000 { return r.db.WithContext(ctx).Table("admin_pickups AS p") } - if len(kw) == 11 && kw[0] == '1' && isAllDigits(kw) { - return r.db.WithContext(ctx).Table("admin_pickups AS p"). - Joins("JOIN users AS owner ON owner.id = p.owner_id") + if isPhoneKeyword(kw) { + return r.db.WithContext(ctx).Table("admin_pickups AS p") } if strings.HasPrefix(upper, "L") || (len(kw) >= 12 && isAllDigits(kw)) { return r.db.WithContext(ctx).Table("admin_pickups AS p"). @@ -1974,3 +1973,7 @@ func isAllDigits(s string) bool { } return true } + +func isPhoneKeyword(s string) bool { + return len(s) == 11 && s[0] == '1' && isAllDigits(s) +} diff --git a/backend/internal/modules/pickup/repository_test.go b/backend/internal/modules/pickup/repository_test.go index c5a1a1d..007f413 100644 --- a/backend/internal/modules/pickup/repository_test.go +++ b/backend/internal/modules/pickup/repository_test.go @@ -829,9 +829,9 @@ func TestApplyAdminPickupFiltersSmartRouting(t *testing.T) { wantSQLFrag string }{ { - name: "11位手机号路由至users(phone)", + name: "11位手机号先解析用户再走提号号主索引", query: AdminPickupQuery{Keyword: "13800138000"}, - wantSQLFrag: "owner.phone = ?", + wantSQLFrag: "p.owner_id = (SELECT id FROM users WHERE phone = ?)", }, { name: "PK前缀提号编号路由至admin_pickups(pickup_no)", diff --git a/backend/migrations/000090_invitation_rebate_invitee_index.sql b/backend/migrations/000090_invitation_rebate_invitee_index.sql new file mode 100644 index 0000000..49c5921 --- /dev/null +++ b/backend/migrations/000090_invitation_rebate_invitee_index.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- 后台返利流水按完整被邀请人手机号搜索时,先经 users(phone) 解析 ID, +-- 再由该索引定位流水,避免回退为用户表与流水表的跨表全扫描。 +ALTER TABLE invitation_rebate_ledgers + ADD KEY idx_invitation_rebate_invitee (invitee_id, settled_at); + +-- +goose Down +ALTER TABLE invitation_rebate_ledgers + DROP KEY idx_invitation_rebate_invitee; diff --git a/scripts/check-sql-guard.sh b/scripts/check-sql-guard.sh index e74d169..86c5ab7 100755 --- a/scripts/check-sql-guard.sh +++ b/scripts/check-sql-guard.sh @@ -41,14 +41,15 @@ require_pattern "backend/internal/modules/pickup/repository.go" "if pageSize > 1 # 智能意图路由与单表唯一索引直达防线(防止多表跨表 OR/LIKE 导致 MySQL 放弃索引而退化为全表扫描) require_pattern "backend/internal/modules/order/queries.go" "o.order_no = ?" "订单列表订单号唯一索引直达" require_pattern "backend/internal/modules/order/queries.go" "l.listing_no = ?" "订单列表商品号唯一索引直达" -require_pattern "backend/internal/modules/order/queries.go" "owner.phone = ? OR renter.phone = ?" "订单列表手机号索引直达" +require_pattern "backend/internal/modules/order/queries.go" "o.owner_id = (SELECT id FROM users WHERE phone = ?)" "订单列表手机号解析用户索引直达" require_pattern "backend/internal/modules/order/queries.go" "page == 1 && len(rows) < pageSize" "订单列表首页面单扫描短路" require_pattern "backend/internal/modules/pickup/repository.go" "p.pickup_no = ?" "提号列表提号单号唯一索引直达" -require_pattern "backend/internal/modules/pickup/repository.go" "owner.phone = ?" "提号列表号主手机号索引直达" +require_pattern "backend/internal/modules/pickup/repository.go" "p.owner_id = (SELECT id FROM users WHERE phone = ?)" "提号列表号主手机号解析用户索引直达" require_pattern "backend/internal/modules/pickup/repository.go" "page == 1 && len(rows) < pageSize" "提号列表首页面单扫描短路" require_pattern "backend/internal/modules/dispute/query.go" "o.order_no = ?" "纠纷列表订单号唯一索引直达" +require_pattern "backend/internal/modules/dispute/query.go" "o.owner_id = (SELECT id FROM users WHERE phone = ?)" "纠纷列表手机号解析用户索引直达" require_pattern "backend/internal/modules/dispute/query.go" "page == 1 && len(rows) < pageSize" "纠纷列表首页面单扫描短路" require_pattern "backend/internal/modules/adminuser/repository.go" "u.phone = ?" "用户管理手机号唯一索引直达" @@ -58,7 +59,10 @@ require_pattern "backend/internal/modules/listing/query.go" "l.listing_no = ?" " require_pattern "backend/internal/modules/listing/query.go" "page == 1 && len(rows) < pageSize" "商品列表首页面单扫描短路" require_pattern "backend/internal/modules/chat/support.go" "l.listing_no = ?" "客服会话商品号唯一索引直达" -require_pattern "backend/internal/modules/chat/support.go" "renter.phone = ? OR owner.phone = ?" "客服会话手机号唯一索引直达" +require_pattern "backend/internal/modules/chat/support.go" "matched_phone.conversation_id = c.id" "客服会话手机号经订单角色索引直达" + +require_pattern "backend/internal/modules/adminfinance/disbursement_list.go" "d.source_type = ? AND d.user_id = (SELECT id FROM users WHERE phone = ?)" "出款列表手机号解析用户索引直达" +require_pattern "backend/internal/modules/adminfinance/disbursement_list.go" "wr.user_id = (SELECT id FROM users WHERE phone = ?)" "出款列表手机号在联合前裁剪来源" # 禁止核心列表重新引入跨表 6~10 路全扫描反模式 forbid_pattern "backend/internal/modules/dispute/query.go" "o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ? OR d.id = ?" "纠纷列表禁止6表无意图全表扫描"