perf(query): 全面治理后台跨表模糊查询慢SQL并升级SQL门禁守护

- 解决 MySQL 8.4 无法跨表 Index Merge 导致的索引失效与全表扫描问题
- 订单/提号/纠纷/用户/商品/会话列表实现智能意图路由分流直达单表唯一索引
- 剥离 COUNT(*) 阶段无关多表关联及标量子查询,实现首页条数快速短路
- check-sql-guard.sh 增加 forbid_pattern 拦截器与核心路由强校验防线
This commit is contained in:
yml2213
2026-09-05 10:37:04 +08:00
parent ba5aa48c8f
commit a9125b1939
10 changed files with 535 additions and 89 deletions
+55 -20
View File
@@ -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
}
@@ -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)
}
})
}
}