perf(query): 全面治理后台跨表模糊查询慢SQL并升级SQL门禁守护
- 解决 MySQL 8.4 无法跨表 Index Merge 导致的索引失效与全表扫描问题 - 订单/提号/纠纷/用户/商品/会话列表实现智能意图路由分流直达单表唯一索引 - 剥离 COUNT(*) 阶段无关多表关联及标量子查询,实现首页条数快速短路 - check-sql-guard.sh 增加 forbid_pattern 拦截器与核心路由强校验防线
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user