From a9125b19398817baaba212cdfa70d4543ea000e1 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 5 Sep 2026 10:37:04 +0800 Subject: [PATCH] =?UTF-8?q?perf(query):=20=E5=85=A8=E9=9D=A2=E6=B2=BB?= =?UTF-8?q?=E7=90=86=E5=90=8E=E5=8F=B0=E8=B7=A8=E8=A1=A8=E6=A8=A1=E7=B3=8A?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E6=85=A2SQL=E5=B9=B6=E5=8D=87=E7=BA=A7SQL?= =?UTF-8?q?=E9=97=A8=E7=A6=81=E5=AE=88=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 解决 MySQL 8.4 无法跨表 Index Merge 导致的索引失效与全表扫描问题 - 订单/提号/纠纷/用户/商品/会话列表实现智能意图路由分流直达单表唯一索引 - 剥离 COUNT(*) 阶段无关多表关联及标量子查询,实现首页条数快速短路 - check-sql-guard.sh 增加 forbid_pattern 拦截器与核心路由强校验防线 --- .../internal/modules/adminuser/repository.go | 40 ++++- backend/internal/modules/chat/support.go | 42 +++-- backend/internal/modules/dispute/query.go | 87 +++++++--- .../modules/dispute/repository_test.go | 56 +++++++ backend/internal/modules/listing/query.go | 28 +++- backend/internal/modules/order/queries.go | 75 ++++++--- .../internal/modules/order/repository_test.go | 58 +++++++ backend/internal/modules/pickup/repository.go | 150 +++++++++++++++--- .../modules/pickup/repository_test.go | 56 +++++++ scripts/check-sql-guard.sh | 32 ++++ 10 files changed, 535 insertions(+), 89 deletions(-) diff --git a/backend/internal/modules/adminuser/repository.go b/backend/internal/modules/adminuser/repository.go index 51970bd..6c413cf 100644 --- a/backend/internal/modules/adminuser/repository.go +++ b/backend/internal/modules/adminuser/repository.go @@ -44,11 +44,6 @@ func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQue if err != nil { return nil, err } - var total int64 - countTx := applyUserFilter(r.db.WithContext(ctx).Table("users AS u"), query, growthConfig) - if err := countTx.Count(&total).Error; err != nil { - return nil, err - } offset := (page - 1) * pageSize var rows []userRow listTx := r.db.WithContext(ctx).Table("users AS u"). @@ -72,6 +67,18 @@ func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQue if err != nil { return nil, err } + + var total int64 + // 首页查询且返回条数小于 pageSize 时,总数等于当前返回条数,彻底免除第二次 COUNT(*) 扫描 + if page == 1 && len(rows) < pageSize { + total = int64(len(rows)) + } else { + countTx := applyUserFilter(r.db.WithContext(ctx).Table("users AS u"), query, growthConfig) + if err := countTx.Count(&total).Error; err != nil { + return nil, err + } + } + items := make([]UserDTO, 0, len(rows)) for _, row := range rows { items = append(items, row.toDTO(growthConfig)) @@ -92,11 +99,15 @@ func applyUserFilter(tx *gorm.DB, query ListQuery, growthConfig rentergrowth.Con } } if keyword := strings.TrimSpace(query.Keyword); keyword != "" { - like := "%" + keyword + "%" - // 关键词为纯数字时一并按用户 ID 精确匹配 - if id, err := strconv.ParseUint(keyword, 10, 64); err == nil { + if len(keyword) == 11 && keyword[0] == '1' && isAllDigits(keyword) { + // 11 位手机号:命中 users(phone) 唯一索引 + tx = tx.Where("u.phone = ?", keyword) + } else if id, err := strconv.ParseUint(keyword, 10, 64); err == nil && id > 0 && keyword[0] != '0' && id < 100000000 { + // 纯数字 ID:一并按用户 ID 精确匹配 + like := "%" + keyword + "%" tx = tx.Where("u.id = ? OR u.phone LIKE ? OR u.nickname LIKE ?", id, like, like) } else { + like := "%" + keyword + "%" tx = tx.Where("u.phone LIKE ? OR u.nickname LIKE ?", like, like) } } @@ -568,3 +579,16 @@ func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizID uint64, me func IsNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) } + +func isAllDigits(s string) bool { + if s == "" { + 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/chat/support.go b/backend/internal/modules/chat/support.go index dc9597c..725e711 100644 --- a/backend/internal/modules/chat/support.go +++ b/backend/internal/modules/chat/support.go @@ -306,15 +306,26 @@ func (r *Repository) adminConversationBase(ctx context.Context, principal Princi Joins("LEFT JOIN rental_orders AS explicit_lo ON explicit_lo.id = c.order_id"). Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(explicit_lo.listing_id, c.listing_id)") if 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)") - like := "%" + keyword + "%" - db = db.Where(`c.title LIKE ? OR c.last_message_preview LIKE ? OR COALESCE(explicit_lo.order_no, c.latest_order_no) LIKE ? OR l.listing_no LIKE ? - OR renter.phone LIKE ? OR owner.phone LIKE ? - OR EXISTS ( - SELECT 1 FROM chat_participants AS cp_kw - WHERE cp_kw.conversation_id = c.id AND cp_kw.remark LIKE ? - )`, like, like, like, like, like, like, like) + 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) + } else if strings.HasPrefix(upper, "RO") || strings.HasPrefix(upper, "ORD") || (len(keyword) >= 12 && isAllDigits(keyword)) { + db = db.Where("(explicit_lo.order_no = ? OR c.latest_order_no = ?)", keyword, keyword) + } else if strings.HasPrefix(upper, "L") { + db = db.Where("l.listing_no = ?", keyword) + } else { + 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)") + like := "%" + keyword + "%" + db = db.Where(`c.title LIKE ? OR c.last_message_preview LIKE ? OR COALESCE(explicit_lo.order_no, c.latest_order_no) LIKE ? OR l.listing_no LIKE ? + OR renter.phone LIKE ? OR owner.phone LIKE ? + OR EXISTS ( + SELECT 1 FROM chat_participants AS cp_kw + WHERE cp_kw.conversation_id = c.id AND cp_kw.remark LIKE ? + )`, like, like, like, like, like, like, like) + } } return db } @@ -469,3 +480,16 @@ func ArchiveListingConversation(tx *gorm.DB, listingID uint64, reason string) er } return sendSystemMessage(tx, conversation.ID, content) } + +func isAllDigits(s string) bool { + if s == "" { + 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/dispute/query.go b/backend/internal/modules/dispute/query.go index f18a2ac..9e1c368 100644 --- a/backend/internal/modules/dispute/query.go +++ b/backend/internal/modules/dispute/query.go @@ -37,11 +37,6 @@ func (r *Repository) FindForUser(ctx context.Context, userID uint64, id uint64) } func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*PaginatedResult, error) { - var total int64 - countDB := applyAdminListFilters(r.adminFilterQuery(ctx), query) - if err := countDB.Count(&total).Error; err != nil { - return nil, err - } page := query.Page pageSize := query.PageSize if page < 1 { @@ -51,6 +46,7 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*Pagi pageSize = 20 } offset := (page - 1) * pageSize + var rows []disputeRow err := applyAdminListFilters(r.baseQuery(ctx), query). Order("d.id DESC"). @@ -60,6 +56,17 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*Pagi if err != nil { return nil, err } + + var total int64 + // 首页查询且返回条数小于 pageSize 时,总数等于当前返回条数,彻底免除第二次 COUNT(*) 扫描 + if page == 1 && len(rows) < pageSize { + total = int64(len(rows)) + } else { + countDB := applyAdminListFilters(r.adminDisputeCountQuery(ctx, query), query) + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + } return &PaginatedResult{Items: toDTOs(rows), Total: total, Page: page, PageSize: pageSize}, nil } @@ -108,6 +115,34 @@ func (r *Repository) adminFilterQuery(ctx context.Context) *gorm.DB { Joins("LEFT JOIN order_checkouts AS c ON c.id = d.checkout_id") } +func (r *Repository) adminDisputeCountQuery(ctx context.Context, query AdminListQuery) *gorm.DB { + kw := strings.TrimSpace(query.Keyword) + if kw == "" { + return r.db.WithContext(ctx).Table("disputes AS d") + } + upper := strings.ToUpper(kw) + if id, err := strconv.ParseUint(kw, 10, 64); err == nil && id > 0 && kw[0] != '0' && id < 100000000 { + return r.db.WithContext(ctx).Table("disputes AS d"). + Joins("JOIN rental_orders AS o ON o.id = d.order_id") + } + if strings.HasPrefix(upper, "RO") || strings.HasPrefix(upper, "ORD") || (len(kw) >= 12 && isAllDigits(kw)) { + return r.db.WithContext(ctx).Table("disputes AS d"). + Joins("JOIN rental_orders AS o ON o.id = d.order_id") + } + if strings.HasPrefix(upper, "L") { + return r.db.WithContext(ctx).Table("disputes AS d"). + 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) { + 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") + } + return r.adminFilterQuery(ctx) +} + func applyAdminListFilters(db *gorm.DB, query AdminListQuery) *gorm.DB { if query.Status != "" { db = db.Where("d.status = ?", query.Status) @@ -116,22 +151,21 @@ func applyAdminListFilters(db *gorm.DB, query AdminListQuery) *gorm.DB { db = db.Where("d.type = ?", query.Type) } if keyword := strings.TrimSpace(query.Keyword); keyword != "" { - like := "%" + keyword + "%" - if id, err := strconv.ParseUint(keyword, 10, 64); err == nil { - db = db.Where( - `(o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ? OR d.id = ? OR d.order_id = ? OR o.listing_id = ? OR o.owner_id = ? OR o.renter_id = ?)`, - like, - like, - like, - like, - like, - id, - id, - id, - id, - id, - ) + 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") || (len(keyword) >= 12 && isAllDigits(keyword)) { + // 订单号:命中 rental_orders(order_no) 唯一索引 + db = db.Where("o.order_no = ?", keyword) + } else if strings.HasPrefix(upper, "L") { + // 商品编号:命中 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(非零开头):命中纠纷 ID、订单 ID 或关联 ID + db = db.Where("(d.id = ? OR d.order_id = ? OR o.listing_id = ? OR o.owner_id = ? OR o.renter_id = ?)", 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, @@ -144,3 +178,16 @@ func applyAdminListFilters(db *gorm.DB, query AdminListQuery) *gorm.DB { } return db } + +func isAllDigits(s string) bool { + if s == "" { + 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/dispute/repository_test.go b/backend/internal/modules/dispute/repository_test.go index a6772e2..22c2f88 100644 --- a/backend/internal/modules/dispute/repository_test.go +++ b/backend/internal/modules/dispute/repository_test.go @@ -11,6 +11,7 @@ import ( "hfb_sys/backend/internal/model" "gorm.io/datatypes" + "gorm.io/driver/sqlite" "gorm.io/gorm" ) @@ -702,3 +703,58 @@ func TestPartialRefundOwnerIncomeCentUnit(t *testing.T) { t.Fatalf("full refund: got %d, want 0", got) } } + +func TestApplyAdminListFiltersSmartRouting(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DryRun: true}) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + + tests := []struct { + name string + query AdminListQuery + wantSQLFrag string + }{ + { + name: "11位手机号路由至users(phone)", + query: AdminListQuery{Keyword: "13800138000"}, + wantSQLFrag: "owner.phone = ? OR renter.phone = ?", + }, + { + name: "RO前缀订单号路由至rental_orders(order_no)", + query: AdminListQuery{Keyword: "RO202609051000"}, + wantSQLFrag: "o.order_no = ?", + }, + { + name: "L前缀商品编号路由至rental_listings(listing_no)", + query: AdminListQuery{Keyword: "L20260905100"}, + wantSQLFrag: "l.listing_no = ?", + }, + { + name: "短数字ID路由至主键及关联ID", + query: AdminListQuery{Keyword: "12345"}, + wantSQLFrag: "d.id = ? OR d.order_id = ? OR o.listing_id = ? OR o.owner_id = ? OR o.renter_id = ?", + }, + { + name: "长纯数字路由至订单号", + query: AdminListQuery{Keyword: "2026090510001234"}, + wantSQLFrag: "o.order_no = ?", + }, + { + name: "普通文字退回多列LIKE模糊匹配", + query: AdminListQuery{Keyword: "测试账号"}, + wantSQLFrag: "o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ?", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt := applyAdminListFilters(db.Table("disputes AS d"), tt.query).Find(&[]struct{}{}).Statement + sql := stmt.SQL.String() + if !strings.Contains(sql, tt.wantSQLFrag) { + t.Fatalf("期望 SQL 包含 %q, 实际生成: %s", tt.wantSQLFrag, sql) + } + }) + } +} + diff --git a/backend/internal/modules/listing/query.go b/backend/internal/modules/listing/query.go index 61aa97e..45c32cb 100644 --- a/backend/internal/modules/listing/query.go +++ b/backend/internal/modules/listing/query.go @@ -66,12 +66,6 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*Admi }, nil } - countDB := r.applyAdminListFilters(r.db.WithContext(ctx).Table("rental_listings AS l"), query) - var total int64 - if err := countDB.Count(&total).Error; err != nil { - return nil, err - } - db := r.applyAdminListFilters(r.baseQuery(ctx), query) offset := (page - 1) * pageSize if offset < 0 { @@ -82,6 +76,17 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*Admi if err != nil { return nil, err } + + var total int64 + // 首页查询且返回条数小于 pageSize 时,总数等于当前返回条数,彻底免除第二次 COUNT(*) 扫描 + if page == 1 && len(rows) < pageSize { + total = int64(len(rows)) + } else { + countDB := r.applyAdminListFilters(r.db.WithContext(ctx).Table("rental_listings AS l"), query) + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + } return &AdminListResult{ Items: rowsToDTO(rows), Total: total, @@ -95,8 +100,14 @@ func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *g db = db.Where("l.owner_id = ?", query.OwnerID) } if keyword := strings.TrimSpace(query.Keyword); keyword != "" { - like := "%" + keyword + "%" - if id, err := strconv.ParseUint(keyword, 10, 64); err == nil { + upper := strings.ToUpper(keyword) + if strings.HasPrefix(upper, "RO") || strings.HasPrefix(upper, "ORD") { + // 订单号:命中 rental_orders(order_no) 唯一索引 + db = db.Where("EXISTS (SELECT 1 FROM rental_orders AS o WHERE o.listing_id = l.id AND o.order_no = ?)", keyword) + } else if strings.HasPrefix(upper, "L") { + // 商品编号:命中 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 { db = db.Where( `(l.listing_no = ? OR EXISTS (SELECT 1 FROM rental_orders AS o WHERE o.listing_id = l.id AND o.order_no = ?) OR l.id = ? OR l.account_id = ? OR l.owner_id = ?)`, keyword, @@ -106,6 +117,7 @@ func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *g id, ) } else { + like := "%" + keyword + "%" db = db.Where( `(l.listing_no LIKE ? OR EXISTS (SELECT 1 FROM rental_orders AS o WHERE o.listing_id = l.id AND o.order_no LIKE ?))`, like, diff --git a/backend/internal/modules/order/queries.go b/backend/internal/modules/order/queries.go index c6ffd28..ca0a87e 100644 --- a/backend/internal/modules/order/queries.go +++ b/backend/internal/modules/order/queries.go @@ -35,13 +35,6 @@ func (r *Repository) ListForUser(ctx context.Context, userID uint64) ([]OrderDTO } func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*PaginatedResult, error) { - var total int64 - db := r.db.WithContext(ctx) - countDB := applyAdminOrderFilters(r.adminOrderJoinQuery(ctx), query) - if err := countDB.Count(&total).Error; err != nil { - return nil, err - } - page := query.Page pageSize := query.PageSize if page < 1 { @@ -51,6 +44,7 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Pag pageSize = 20 } offset := (page - 1) * pageSize + var rows []orderRow err := applyAdminOrderFilters(r.adminQuery(ctx), query). Order("o.id DESC"). @@ -61,6 +55,18 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Pag 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 + } + } + + db := r.db.WithContext(ctx) checkouts, err := r.latestAdminCheckouts(ctx, rows) if err != nil { return nil, err @@ -86,6 +92,25 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Pag }, nil } +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") || (len(kw) >= 12 && isAllDigits(kw)) { + 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) { @@ -141,20 +166,21 @@ func applyAdminOrderFilters(db *gorm.DB, query AdminOrderQuery) *gorm.DB { db = db.Where("COALESCE(NULLIF(o.offline_settlement_status, ''), 'none') = ?", query.OfflineSettlementStatus) } if keyword := strings.TrimSpace(query.Keyword); keyword != "" { - like := "%" + keyword + "%" - if id, err := strconv.ParseUint(keyword, 10, 64); err == nil { - db = db.Where( - `(o.order_no = ? OR l.listing_no = ? OR owner.phone = ? OR renter.phone = ? OR o.id = ? OR o.listing_id = ? OR o.owner_id = ? OR o.renter_id = ?)`, - keyword, - keyword, - keyword, - keyword, - id, - id, - id, - id, - ) + 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") || (len(keyword) >= 12 && isAllDigits(keyword)) { + // 订单号:命中 rental_orders(order_no) 唯一索引 + db = db.Where("o.order_no = ?", keyword) + } else if strings.HasPrefix(upper, "L") { + // 商品编号:命中 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, @@ -326,3 +352,12 @@ type orderRow struct { 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 +} diff --git a/backend/internal/modules/order/repository_test.go b/backend/internal/modules/order/repository_test.go index 4d9749f..56b7151 100644 --- a/backend/internal/modules/order/repository_test.go +++ b/backend/internal/modules/order/repository_test.go @@ -1,6 +1,7 @@ package order import ( + "strings" "testing" "time" @@ -9,6 +10,8 @@ import ( "hfb_sys/backend/internal/timeutil" "gorm.io/datatypes" + "gorm.io/driver/sqlite" + "gorm.io/gorm" ) func pureCoinDiscountOrder() model.RentalOrder { @@ -470,3 +473,58 @@ func TestNewOrderNoUsesShanghaiTimeWhenLocalIsUTC(t *testing.T) { t.Fatalf("order no time = %s, want between %s and %s", parsed, before, after) } } + +func TestApplyAdminOrderFiltersSmartRouting(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DryRun: true}) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + + tests := []struct { + name string + query AdminOrderQuery + wantSQLFrag string + }{ + { + name: "11位手机号路由至users(phone)", + query: AdminOrderQuery{Keyword: "13800138000"}, + wantSQLFrag: "owner.phone = ? OR renter.phone = ?", + }, + { + name: "RO前缀订单号路由至rental_orders(order_no)", + query: AdminOrderQuery{Keyword: "RO202609051000"}, + wantSQLFrag: "o.order_no = ?", + }, + { + name: "长纯数字订单号路由至rental_orders(order_no)", + query: AdminOrderQuery{Keyword: "2026090510001234"}, + wantSQLFrag: "o.order_no = ?", + }, + { + name: "L前缀商品编号路由至rental_listings(listing_no)", + query: AdminOrderQuery{Keyword: "L20260905100"}, + wantSQLFrag: "l.listing_no = ?", + }, + { + name: "短数字ID路由至主键及关联ID", + query: AdminOrderQuery{Keyword: "12345"}, + wantSQLFrag: "o.id = ? OR o.listing_id = ? OR o.owner_id = ? OR o.renter_id = ?", + }, + { + name: "普通文字退回多列LIKE模糊匹配", + query: AdminOrderQuery{Keyword: "测试账号"}, + wantSQLFrag: "o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ?", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt := applyAdminOrderFilters(db.Table("rental_orders AS o"), tt.query).Find(&[]struct{}{}).Statement + sql := stmt.SQL.String() + if !strings.Contains(sql, tt.wantSQLFrag) { + t.Fatalf("期望 SQL 包含 %q, 实际生成: %s", tt.wantSQLFrag, sql) + } + }) + } +} + diff --git a/backend/internal/modules/pickup/repository.go b/backend/internal/modules/pickup/repository.go index e2f1937..f518898 100644 --- a/backend/internal/modules/pickup/repository.go +++ b/backend/internal/modules/pickup/repository.go @@ -722,11 +722,7 @@ func (r *Repository) FindByID(ctx context.Context, id uint64) (*PickupDTO, error return &dto, nil } -func (r *Repository) ListAdmin(ctx context.Context, query AdminPickupQuery) (*PaginatedResult, error) { - if r == nil || r.db == nil { - return nil, ErrDependencyUnavailable - } - db := r.baseQuery(ctx) +func applyAdminPickupFilters(db *gorm.DB, query AdminPickupQuery) *gorm.DB { if status := strings.TrimSpace(query.Status); status != "" { db = db.Where("p.status = ?", status) } @@ -740,16 +736,93 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminPickupQuery) (*Pa db = db.Where("p.offline_settlement_status = ?", offlineSettlementStatus) } if kw := strings.TrimSpace(query.Keyword); kw != "" { - like := "%" + kw + "%" - db = db.Where( - "p.pickup_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ?", - like, - like, - like, - like, - ) + upper := strings.ToUpper(kw) + if len(kw) == 11 && kw[0] == '1' && isAllDigits(kw) { + // 11 位手机号:命中 users(phone) 唯一索引 + db = db.Where("owner.phone = ?", kw) + } else if strings.HasPrefix(upper, "PK") || strings.HasPrefix(upper, "P") { + // 提号编号:命中 admin_pickups(pickup_no) 唯一索引 + db = db.Where("p.pickup_no = ?", kw) + } else if strings.HasPrefix(upper, "L") { + // 商品编号:命中 rental_listings(listing_no) 唯一索引 + db = db.Where("l.listing_no = ?", kw) + } else if id, err := strconv.ParseUint(kw, 10, 64); err == nil && id > 0 && kw[0] != '0' && id < 100000000 { + // 短纯数字 ID(非零开头):命中 admin_pickups 主键及关联 ID + db = db.Where("(p.id = ? OR p.listing_id = ? OR p.account_id = ? OR p.owner_id = ?)", id, id, id, id) + } else if len(kw) >= 12 && isAllDigits(kw) { + // 12位以上纯数字可能为商品编号或提号编号 + db = db.Where("(p.pickup_no = ? OR l.listing_no = ?)", kw, kw) + } else { + like := "%" + kw + "%" + db = db.Where( + "p.pickup_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ?", + like, + like, + like, + like, + ) + } } - return r.paginatePickups(db, query.Page, query.PageSize, false) + return db +} + +func (r *Repository) adminPickupCountQuery(ctx context.Context, query AdminPickupQuery) *gorm.DB { + kw := strings.TrimSpace(query.Keyword) + if kw == "" { + return r.db.WithContext(ctx).Table("admin_pickups AS p") + } + upper := strings.ToUpper(kw) + if strings.HasPrefix(upper, "PK") || strings.HasPrefix(upper, "P") { + return r.db.WithContext(ctx).Table("admin_pickups AS p") + } + 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 strings.HasPrefix(upper, "L") { + return r.db.WithContext(ctx).Table("admin_pickups AS p"). + Joins("JOIN rental_listings AS l ON l.id = p.listing_id") + } + return r.db.WithContext(ctx).Table("admin_pickups AS p"). + Joins("JOIN rental_listings AS l ON l.id = p.listing_id"). + Joins("JOIN game_accounts AS a ON a.id = p.account_id"). + Joins("JOIN users AS owner ON owner.id = p.owner_id") +} + +func (r *Repository) ListAdmin(ctx context.Context, query AdminPickupQuery) (*PaginatedResult, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + page, pageSize := normalizePaging(query.Page, query.PageSize) + offset := (page - 1) * pageSize + + var rows []pickupRow + db := applyAdminPickupFilters(r.baseQuery(ctx), query) + if err := db.Order("p.id DESC").Offset(offset).Limit(pageSize).Scan(&rows).Error; err != nil { + return nil, err + } + + var total int64 + // 首页查询且返回条数小于 pageSize 时,总数等于当前返回条数,彻底免除第二次 COUNT(*) 扫描 + if page == 1 && len(rows) < pageSize { + total = int64(len(rows)) + } else { + countDB := applyAdminPickupFilters(r.adminPickupCountQuery(ctx, query), query) + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + } + + items := make([]PickupDTO, 0, len(rows)) + for _, row := range rows { + item := row.toDTO() + item.AccountSnapshot = nil + items = append(items, item) + } + return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } // ListShopNames 返回历史提号中已使用的店铺名称,供后台选择和筛选。 @@ -782,14 +855,20 @@ func (r *Repository) ListForSeller(ctx context.Context, ownerID uint64, query Se func (r *Repository) paginatePickups(db *gorm.DB, page, pageSize int, sellerView bool) (*PaginatedResult, error) { page, pageSize = normalizePaging(page, pageSize) - var total int64 - if err := db.Count(&total).Error; err != nil { - return nil, err - } var rows []pickupRow if err := db.Order("p.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil { return nil, err } + var total int64 + if page == 1 && len(rows) < pageSize { + total = int64(len(rows)) + } else { + // 使用独立干净的 Count 查询,避免触发 baseQuery 中的关联子查询 + countDB := db.Session(&gorm.Session{}).Select("p.id") + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + } items := make([]PickupDTO, 0, len(rows)) for _, row := range rows { item := row.toDTO() @@ -829,8 +908,13 @@ func (r *Repository) ListAvailableListings(ctx context.Context, query AvailableL Joins("LEFT JOIN listing_uploads AS lu ON lu.id = latest_upload.upload_id"). Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false) if kw := strings.TrimSpace(query.Keyword); kw != "" { - like := "%" + kw + "%" - db = db.Where("l.listing_no LIKE ? OR a.title LIKE ?", like, like) + upper := strings.ToUpper(kw) + if strings.HasPrefix(upper, "L") || (len(kw) >= 12 && isAllDigits(kw)) { + db = db.Where("l.listing_no = ?", kw) + } else { + like := "%" + kw + "%" + db = db.Where("l.listing_no LIKE ? OR a.title LIKE ?", like, like) + } } switch strings.TrimSpace(query.SourceType) { case ListingSourceExternal: @@ -839,15 +923,20 @@ func (r *Repository) ListAvailableListings(ctx context.Context, query AvailableL db = db.Where("lu.id IS NULL") } - var total int64 - if err := db.Count(&total).Error; err != nil { - return nil, err - } page, pageSize := normalizePaging(query.Page, query.PageSize) var rows []availableListingRow if err := db.Order("l.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil { return nil, err } + var total int64 + if page == 1 && len(rows) < pageSize { + total = int64(len(rows)) + } else { + countDB := db.Session(&gorm.Session{}).Select("l.id") + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + } items := make([]AvailableListingDTO, 0, len(rows)) for _, row := range rows { items = append(items, row.toDTO()) @@ -1249,3 +1338,16 @@ func newPickupNo() (string, error) { randomNum := (int(buf[0])<<8 | int(buf[1])) % 1000 return "PK" + timeStr + strconv.Itoa(1000 + randomNum)[1:], nil } + +func isAllDigits(s string) bool { + if s == "" { + 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/pickup/repository_test.go b/backend/internal/modules/pickup/repository_test.go index 4d065e4..e2f6269 100644 --- a/backend/internal/modules/pickup/repository_test.go +++ b/backend/internal/modules/pickup/repository_test.go @@ -3,6 +3,7 @@ package pickup import ( "encoding/json" "errors" + "strings" "testing" "hfb_sys/backend/internal/auditlog" @@ -702,3 +703,58 @@ func seedPickup(t *testing.T, db *gorm.DB, status string) model.AdminPickup { } return pickup } + +func TestApplyAdminPickupFiltersSmartRouting(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DryRun: true}) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + + tests := []struct { + name string + query AdminPickupQuery + wantSQLFrag string + }{ + { + name: "11位手机号路由至users(phone)", + query: AdminPickupQuery{Keyword: "13800138000"}, + wantSQLFrag: "owner.phone = ?", + }, + { + name: "PK前缀提号编号路由至admin_pickups(pickup_no)", + query: AdminPickupQuery{Keyword: "PK202609051000"}, + wantSQLFrag: "p.pickup_no = ?", + }, + { + name: "L前缀商品编号路由至rental_listings(listing_no)", + query: AdminPickupQuery{Keyword: "L20260905100"}, + wantSQLFrag: "l.listing_no = ?", + }, + { + name: "短数字ID路由至主键及关联ID", + query: AdminPickupQuery{Keyword: "12345"}, + wantSQLFrag: "p.id = ? OR p.listing_id = ? OR p.account_id = ? OR p.owner_id = ?", + }, + { + name: "长纯数字路由至提号编号或商品编号", + query: AdminPickupQuery{Keyword: "2026090510001234"}, + wantSQLFrag: "p.pickup_no = ? OR l.listing_no = ?", + }, + { + name: "普通文字退回多列LIKE模糊匹配", + query: AdminPickupQuery{Keyword: "测试账号"}, + wantSQLFrag: "p.pickup_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ?", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt := applyAdminPickupFilters(db.Table("admin_pickups AS p"), tt.query).Find(&[]struct{}{}).Statement + sql := stmt.SQL.String() + if !strings.Contains(sql, tt.wantSQLFrag) { + t.Fatalf("期望 SQL 包含 %q, 实际生成: %s", tt.wantSQLFrag, sql) + } + }) + } +} + diff --git a/scripts/check-sql-guard.sh b/scripts/check-sql-guard.sh index f9ebf11..629019e 100755 --- a/scripts/check-sql-guard.sh +++ b/scripts/check-sql-guard.sh @@ -15,6 +15,13 @@ require_pattern() { fi } +forbid_pattern() { + local file="$1" pattern="$2" name="$3" + if [[ -f "${ROOT_DIR}/${file}" ]] && grep -Fq -- "${pattern}" "${ROOT_DIR}/${file}"; then + failures+=("${name}: 存在被禁止的跨表全扫描反模式 ${pattern} (${file})") + fi +} + # 数据库运行时配置和参数脱敏不能被重构删除。 require_pattern "backend/internal/config/config.go" "DATABASE_SLOW_QUERY_THRESHOLD_MS" "慢查询阈值配置" require_pattern "backend/internal/config/config.go" "DATABASE_MAX_OPEN_CONNS" "连接池上限配置" @@ -31,6 +38,31 @@ require_pattern "backend/migrations/000060_finance_dashboard_indexes.sql" "idx_a require_pattern "backend/internal/modules/listing/public_query.go" "if pageSize > 50" "公开商品分页上限" require_pattern "backend/internal/modules/pickup/repository.go" "if pageSize > 100" "后台接单分页上限" +# 智能意图路由与单表唯一索引直达防线(防止多表跨表 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" "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" "page == 1 && len(rows) < pageSize" "提号列表首页面单扫描短路" + +require_pattern "backend/internal/modules/dispute/query.go" "o.order_no = ?" "纠纷列表订单号唯一索引直达" +require_pattern "backend/internal/modules/dispute/query.go" "page == 1 && len(rows) < pageSize" "纠纷列表首页面单扫描短路" + +require_pattern "backend/internal/modules/adminuser/repository.go" "u.phone = ?" "用户管理手机号唯一索引直达" +require_pattern "backend/internal/modules/adminuser/repository.go" "page == 1 && len(rows) < pageSize" "用户管理首页面单扫描短路" + +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 = ?" "客服会话手机号唯一索引直达" + +# 禁止核心列表重新引入跨表 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表无意图全表扫描" + # 已发布迁移只允许新增,不允许修改或删除历史文件。 if changed_migrations="$(git -C "${ROOT_DIR}" diff --name-status HEAD -- backend/migrations)"; then while read -r status file _; do