perf(query): 全面治理后台跨表模糊查询慢SQL并升级SQL门禁守护
- 解决 MySQL 8.4 无法跨表 Index Merge 导致的索引失效与全表扫描问题 - 订单/提号/纠纷/用户/商品/会话列表实现智能意图路由分流直达单表唯一索引 - 剥离 COUNT(*) 阶段无关多表关联及标量子查询,实现首页条数快速短路 - check-sql-guard.sh 增加 forbid_pattern 拦截器与核心路由强校验防线
This commit is contained in:
@@ -44,11 +44,6 @@ func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQue
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
offset := (page - 1) * pageSize
|
||||||
var rows []userRow
|
var rows []userRow
|
||||||
listTx := r.db.WithContext(ctx).Table("users AS u").
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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))
|
items := make([]UserDTO, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
items = append(items, row.toDTO(growthConfig))
|
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 != "" {
|
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||||||
like := "%" + keyword + "%"
|
if len(keyword) == 11 && keyword[0] == '1' && isAllDigits(keyword) {
|
||||||
// 关键词为纯数字时一并按用户 ID 精确匹配
|
// 11 位手机号:命中 users(phone) 唯一索引
|
||||||
if id, err := strconv.ParseUint(keyword, 10, 64); err == nil {
|
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)
|
tx = tx.Where("u.id = ? OR u.phone LIKE ? OR u.nickname LIKE ?", id, like, like)
|
||||||
} else {
|
} else {
|
||||||
|
like := "%" + keyword + "%"
|
||||||
tx = tx.Where("u.phone LIKE ? OR u.nickname LIKE ?", like, like)
|
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 {
|
func IsNotFound(err error) bool {
|
||||||
return errors.Is(err, gorm.ErrRecordNotFound)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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_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)")
|
Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(explicit_lo.listing_id, c.listing_id)")
|
||||||
if keyword != "" {
|
if keyword != "" {
|
||||||
db = db.Joins("LEFT JOIN users AS renter ON renter.id = explicit_lo.renter_id").
|
upper := strings.ToUpper(keyword)
|
||||||
Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(explicit_lo.owner_id, l.owner_id)")
|
if len(keyword) == 11 && keyword[0] == '1' && isAllDigits(keyword) {
|
||||||
like := "%" + keyword + "%"
|
db = db.Joins("LEFT JOIN users AS renter ON renter.id = explicit_lo.renter_id").
|
||||||
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 ?
|
Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(explicit_lo.owner_id, l.owner_id)").
|
||||||
OR renter.phone LIKE ? OR owner.phone LIKE ?
|
Where("(renter.phone = ? OR owner.phone = ?)", keyword, keyword)
|
||||||
OR EXISTS (
|
} else if strings.HasPrefix(upper, "RO") || strings.HasPrefix(upper, "ORD") || (len(keyword) >= 12 && isAllDigits(keyword)) {
|
||||||
SELECT 1 FROM chat_participants AS cp_kw
|
db = db.Where("(explicit_lo.order_no = ? OR c.latest_order_no = ?)", keyword, keyword)
|
||||||
WHERE cp_kw.conversation_id = c.id AND cp_kw.remark LIKE ?
|
} else if strings.HasPrefix(upper, "L") {
|
||||||
)`, like, like, like, like, like, like, like)
|
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
|
return db
|
||||||
}
|
}
|
||||||
@@ -469,3 +480,16 @@ func ArchiveListingConversation(tx *gorm.DB, listingID uint64, reason string) er
|
|||||||
}
|
}
|
||||||
return sendSystemMessage(tx, conversation.ID, content)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
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
|
page := query.Page
|
||||||
pageSize := query.PageSize
|
pageSize := query.PageSize
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
@@ -51,6 +46,7 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*Pagi
|
|||||||
pageSize = 20
|
pageSize = 20
|
||||||
}
|
}
|
||||||
offset := (page - 1) * pageSize
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
var rows []disputeRow
|
var rows []disputeRow
|
||||||
err := applyAdminListFilters(r.baseQuery(ctx), query).
|
err := applyAdminListFilters(r.baseQuery(ctx), query).
|
||||||
Order("d.id DESC").
|
Order("d.id DESC").
|
||||||
@@ -60,6 +56,17 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*Pagi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
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")
|
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 {
|
func applyAdminListFilters(db *gorm.DB, query AdminListQuery) *gorm.DB {
|
||||||
if query.Status != "" {
|
if query.Status != "" {
|
||||||
db = db.Where("d.status = ?", 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)
|
db = db.Where("d.type = ?", query.Type)
|
||||||
}
|
}
|
||||||
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||||||
like := "%" + keyword + "%"
|
upper := strings.ToUpper(keyword)
|
||||||
if id, err := strconv.ParseUint(keyword, 10, 64); err == nil {
|
if len(keyword) == 11 && keyword[0] == '1' && isAllDigits(keyword) {
|
||||||
db = db.Where(
|
// 11 位手机号:命中 users(phone) 唯一索引
|
||||||
`(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 = ?)`,
|
db = db.Where("(owner.phone = ? OR renter.phone = ?)", keyword, keyword)
|
||||||
like,
|
} else if strings.HasPrefix(upper, "RO") || strings.HasPrefix(upper, "ORD") || (len(keyword) >= 12 && isAllDigits(keyword)) {
|
||||||
like,
|
// 订单号:命中 rental_orders(order_no) 唯一索引
|
||||||
like,
|
db = db.Where("o.order_no = ?", keyword)
|
||||||
like,
|
} else if strings.HasPrefix(upper, "L") {
|
||||||
like,
|
// 商品编号:命中 rental_listings(listing_no) 唯一索引
|
||||||
id,
|
db = db.Where("l.listing_no = ?", keyword)
|
||||||
id,
|
} else if id, err := strconv.ParseUint(keyword, 10, 64); err == nil && id > 0 && keyword[0] != '0' && id < 100000000 {
|
||||||
id,
|
// 短纯数字 ID(非零开头):命中纠纷 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)
|
||||||
id,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
|
like := "%" + keyword + "%"
|
||||||
db = db.Where(
|
db = db.Where(
|
||||||
`(o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ?)`,
|
`(o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ?)`,
|
||||||
like,
|
like,
|
||||||
@@ -144,3 +178,16 @@ func applyAdminListFilters(db *gorm.DB, query AdminListQuery) *gorm.DB {
|
|||||||
}
|
}
|
||||||
return 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"
|
"hfb_sys/backend/internal/model"
|
||||||
|
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -702,3 +703,58 @@ func TestPartialRefundOwnerIncomeCentUnit(t *testing.T) {
|
|||||||
t.Fatalf("full refund: got %d, want 0", got)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,12 +66,6 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*Admi
|
|||||||
}, nil
|
}, 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)
|
db := r.applyAdminListFilters(r.baseQuery(ctx), query)
|
||||||
offset := (page - 1) * pageSize
|
offset := (page - 1) * pageSize
|
||||||
if offset < 0 {
|
if offset < 0 {
|
||||||
@@ -82,6 +76,17 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*Admi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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{
|
return &AdminListResult{
|
||||||
Items: rowsToDTO(rows),
|
Items: rowsToDTO(rows),
|
||||||
Total: total,
|
Total: total,
|
||||||
@@ -95,8 +100,14 @@ func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *g
|
|||||||
db = db.Where("l.owner_id = ?", query.OwnerID)
|
db = db.Where("l.owner_id = ?", query.OwnerID)
|
||||||
}
|
}
|
||||||
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||||||
like := "%" + keyword + "%"
|
upper := strings.ToUpper(keyword)
|
||||||
if id, err := strconv.ParseUint(keyword, 10, 64); err == nil {
|
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(
|
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 = ?)`,
|
`(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,
|
keyword,
|
||||||
@@ -106,6 +117,7 @@ func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *g
|
|||||||
id,
|
id,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
like := "%" + keyword + "%"
|
||||||
db = db.Where(
|
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 ?))`,
|
`(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,
|
like,
|
||||||
|
|||||||
@@ -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) {
|
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
|
page := query.Page
|
||||||
pageSize := query.PageSize
|
pageSize := query.PageSize
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
@@ -51,6 +44,7 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Pag
|
|||||||
pageSize = 20
|
pageSize = 20
|
||||||
}
|
}
|
||||||
offset := (page - 1) * pageSize
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
var rows []orderRow
|
var rows []orderRow
|
||||||
err := applyAdminOrderFilters(r.adminQuery(ctx), query).
|
err := applyAdminOrderFilters(r.adminQuery(ctx), query).
|
||||||
Order("o.id DESC").
|
Order("o.id DESC").
|
||||||
@@ -61,6 +55,18 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Pag
|
|||||||
return nil, err
|
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)
|
checkouts, err := r.latestAdminCheckouts(ctx, rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -86,6 +92,25 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Pag
|
|||||||
}, nil
|
}, 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
|
// latestAdminCheckouts loads one latest checkout per order so the admin order
|
||||||
// list can display finalized income without issuing one query per row.
|
// 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) {
|
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)
|
db = db.Where("COALESCE(NULLIF(o.offline_settlement_status, ''), 'none') = ?", query.OfflineSettlementStatus)
|
||||||
}
|
}
|
||||||
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||||||
like := "%" + keyword + "%"
|
upper := strings.ToUpper(keyword)
|
||||||
if id, err := strconv.ParseUint(keyword, 10, 64); err == nil {
|
if len(keyword) == 11 && keyword[0] == '1' && isAllDigits(keyword) {
|
||||||
db = db.Where(
|
// 11 位手机号:命中 users(phone) 唯一索引
|
||||||
`(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 = ?)`,
|
db = db.Where("(owner.phone = ? OR renter.phone = ?)", keyword, keyword)
|
||||||
keyword,
|
} else if strings.HasPrefix(upper, "RO") || strings.HasPrefix(upper, "ORD") || (len(keyword) >= 12 && isAllDigits(keyword)) {
|
||||||
keyword,
|
// 订单号:命中 rental_orders(order_no) 唯一索引
|
||||||
keyword,
|
db = db.Where("o.order_no = ?", keyword)
|
||||||
keyword,
|
} else if strings.HasPrefix(upper, "L") {
|
||||||
id,
|
// 商品编号:命中 rental_listings(listing_no) 唯一索引
|
||||||
id,
|
db = db.Where("l.listing_no = ?", keyword)
|
||||||
id,
|
} else if id, err := strconv.ParseUint(keyword, 10, 64); err == nil && id > 0 && keyword[0] != '0' && id < 100000000 {
|
||||||
id,
|
// 短纯数字 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 {
|
} else {
|
||||||
|
like := "%" + keyword + "%"
|
||||||
db = db.Where(
|
db = db.Where(
|
||||||
`(o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ?)`,
|
`(o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ?)`,
|
||||||
like,
|
like,
|
||||||
@@ -326,3 +352,12 @@ type orderRow struct {
|
|||||||
OwnerPhone string
|
OwnerPhone string
|
||||||
RenterPhone 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
|
package order
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -9,6 +10,8 @@ import (
|
|||||||
"hfb_sys/backend/internal/timeutil"
|
"hfb_sys/backend/internal/timeutil"
|
||||||
|
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func pureCoinDiscountOrder() model.RentalOrder {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -722,11 +722,7 @@ func (r *Repository) FindByID(ctx context.Context, id uint64) (*PickupDTO, error
|
|||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ListAdmin(ctx context.Context, query AdminPickupQuery) (*PaginatedResult, error) {
|
func applyAdminPickupFilters(db *gorm.DB, query AdminPickupQuery) *gorm.DB {
|
||||||
if r == nil || r.db == nil {
|
|
||||||
return nil, ErrDependencyUnavailable
|
|
||||||
}
|
|
||||||
db := r.baseQuery(ctx)
|
|
||||||
if status := strings.TrimSpace(query.Status); status != "" {
|
if status := strings.TrimSpace(query.Status); status != "" {
|
||||||
db = db.Where("p.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)
|
db = db.Where("p.offline_settlement_status = ?", offlineSettlementStatus)
|
||||||
}
|
}
|
||||||
if kw := strings.TrimSpace(query.Keyword); kw != "" {
|
if kw := strings.TrimSpace(query.Keyword); kw != "" {
|
||||||
like := "%" + kw + "%"
|
upper := strings.ToUpper(kw)
|
||||||
db = db.Where(
|
if len(kw) == 11 && kw[0] == '1' && isAllDigits(kw) {
|
||||||
"p.pickup_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ?",
|
// 11 位手机号:命中 users(phone) 唯一索引
|
||||||
like,
|
db = db.Where("owner.phone = ?", kw)
|
||||||
like,
|
} else if strings.HasPrefix(upper, "PK") || strings.HasPrefix(upper, "P") {
|
||||||
like,
|
// 提号编号:命中 admin_pickups(pickup_no) 唯一索引
|
||||||
like,
|
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 返回历史提号中已使用的店铺名称,供后台选择和筛选。
|
// 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) {
|
func (r *Repository) paginatePickups(db *gorm.DB, page, pageSize int, sellerView bool) (*PaginatedResult, error) {
|
||||||
page, pageSize = normalizePaging(page, pageSize)
|
page, pageSize = normalizePaging(page, pageSize)
|
||||||
var total int64
|
|
||||||
if err := db.Count(&total).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var rows []pickupRow
|
var rows []pickupRow
|
||||||
if err := db.Order("p.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil {
|
if err := db.Order("p.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil {
|
||||||
return nil, err
|
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))
|
items := make([]PickupDTO, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
item := row.toDTO()
|
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").
|
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)
|
Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false)
|
||||||
if kw := strings.TrimSpace(query.Keyword); kw != "" {
|
if kw := strings.TrimSpace(query.Keyword); kw != "" {
|
||||||
like := "%" + kw + "%"
|
upper := strings.ToUpper(kw)
|
||||||
db = db.Where("l.listing_no LIKE ? OR a.title LIKE ?", like, like)
|
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) {
|
switch strings.TrimSpace(query.SourceType) {
|
||||||
case ListingSourceExternal:
|
case ListingSourceExternal:
|
||||||
@@ -839,15 +923,20 @@ func (r *Repository) ListAvailableListings(ctx context.Context, query AvailableL
|
|||||||
db = db.Where("lu.id IS NULL")
|
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)
|
page, pageSize := normalizePaging(query.Page, query.PageSize)
|
||||||
var rows []availableListingRow
|
var rows []availableListingRow
|
||||||
if err := db.Order("l.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil {
|
if err := db.Order("l.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil {
|
||||||
return nil, err
|
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))
|
items := make([]AvailableListingDTO, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
items = append(items, row.toDTO())
|
items = append(items, row.toDTO())
|
||||||
@@ -1249,3 +1338,16 @@ func newPickupNo() (string, error) {
|
|||||||
randomNum := (int(buf[0])<<8 | int(buf[1])) % 1000
|
randomNum := (int(buf[0])<<8 | int(buf[1])) % 1000
|
||||||
return "PK" + timeStr + strconv.Itoa(1000 + randomNum)[1:], nil
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package pickup
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/auditlog"
|
"hfb_sys/backend/internal/auditlog"
|
||||||
@@ -702,3 +703,58 @@ func seedPickup(t *testing.T, db *gorm.DB, status string) model.AdminPickup {
|
|||||||
}
|
}
|
||||||
return pickup
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ require_pattern() {
|
|||||||
fi
|
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_SLOW_QUERY_THRESHOLD_MS" "慢查询阈值配置"
|
||||||
require_pattern "backend/internal/config/config.go" "DATABASE_MAX_OPEN_CONNS" "连接池上限配置"
|
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/listing/public_query.go" "if pageSize > 50" "公开商品分页上限"
|
||||||
require_pattern "backend/internal/modules/pickup/repository.go" "if pageSize > 100" "后台接单分页上限"
|
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
|
if changed_migrations="$(git -C "${ROOT_DIR}" diff --name-status HEAD -- backend/migrations)"; then
|
||||||
while read -r status file _; do
|
while read -r status file _; do
|
||||||
|
|||||||
Reference in New Issue
Block a user