diff --git a/backend/internal/modules/listing/admin_filter_sql.go b/backend/internal/modules/listing/admin_filter_sql.go new file mode 100644 index 0000000..e2225d0 --- /dev/null +++ b/backend/internal/modules/listing/admin_filter_sql.go @@ -0,0 +1,73 @@ +package listing + +import ( + "strings" + + "gorm.io/gorm" +) + +// applyAdminAssetSQLFilters 把资产与皮肤条件下推到 MySQL JSON 字段,避免先加载全表再在内存过滤。 +func applyAdminAssetSQLFilters(db *gorm.DB, query AdminListQuery) *gorm.DB { + if len(query.Insurance) > 0 { + db = db.Where( + "JSON_UNQUOTE(JSON_EXTRACT(a.asset_summary, '$.season_insurance')) IN ?", + query.Insurance, + ) + } + if len(query.Stamina) > 0 { + db = db.Where( + "JSON_UNQUOTE(JSON_EXTRACT(a.asset_summary, '$.stamina_level')) IN ?", + query.Stamina, + ) + } + if len(query.Load) > 0 { + db = db.Where( + "JSON_UNQUOTE(JSON_EXTRACT(a.asset_summary, '$.load_level')) IN ?", + query.Load, + ) + } + + // 皮肤名称按 AND 语义过滤;同时选择多款皮肤时必须全部命中。 + if len(query.SkinName) > 0 { + for _, skin := range query.SkinName { + skin = strings.TrimSpace(skin) + if skin == "" { + continue + } + db = db.Where( + "JSON_SEARCH(a.asset_summary, 'one', ?, '!', '$.skin_groups.*') IS NOT NULL", + escapeJSONSearchPattern(skin), + ) + } + return db + } + + if len(query.SkinGroup) > 0 { + clauses := make([]string, 0, len(query.SkinGroup)) + args := make([]any, 0, len(query.SkinGroup)) + for _, group := range query.SkinGroup { + group = strings.TrimSpace(group) + if group == "" { + continue + } + clauses = append(clauses, "JSON_LENGTH(JSON_EXTRACT(a.asset_summary, ?)) > 0") + args = append(args, jsonMemberPath("$.skin_groups", group)) + } + if len(clauses) == 0 { + return db.Where("1 = 0") + } + db = db.Where("("+strings.Join(clauses, " OR ")+")", args...) + } + return db +} + +func jsonMemberPath(prefix string, key string) string { + escaped := strings.ReplaceAll(key, `\`, `\\`) + escaped = strings.ReplaceAll(escaped, `"`, `\"`) + return prefix + `."` + escaped + `"` +} + +func escapeJSONSearchPattern(value string) string { + replacer := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_") + return replacer.Replace(value) +} diff --git a/backend/internal/modules/listing/admin_filter_test.go b/backend/internal/modules/listing/admin_filter_test.go index 8fe65cd..b775505 100644 --- a/backend/internal/modules/listing/admin_filter_test.go +++ b/backend/internal/modules/listing/admin_filter_test.go @@ -129,6 +129,65 @@ func TestFilterAdminListingsByMultipleSkinsRequiresAll(t *testing.T) { } } +func TestApplyAdminListFiltersPushesAssetFiltersToSQL(t *testing.T) { + db := database.NewDryRunDB() + repo := &Repository{} + + query := AdminListQuery{ + Insurance: []string{"3*3"}, + Stamina: []string{"7级"}, + Load: []string{"7级"}, + } + sql := repo.applyAdminListFilters(db.Table("rental_listings AS l"), query). + Find(&[]struct{}{}).Statement.SQL.String() + + for _, fragment := range []string{ + "JSON_UNQUOTE(JSON_EXTRACT(a.asset_summary, '$.season_insurance')) IN (?)", + "JSON_UNQUOTE(JSON_EXTRACT(a.asset_summary, '$.stamina_level')) IN (?)", + "JSON_UNQUOTE(JSON_EXTRACT(a.asset_summary, '$.load_level')) IN (?)", + } { + if !strings.Contains(sql, fragment) { + t.Fatalf("期望资产筛选下推 SQL 包含 %q, 实际生成: %s", fragment, sql) + } + } +} + +func TestApplyAdminListFiltersPushesSkinNameAndGroupFiltersToSQL(t *testing.T) { + db := database.NewDryRunDB() + repo := &Repository{} + + skinNameSQL := repo.applyAdminListFilters( + db.Table("rental_listings AS l"), + AdminListQuery{SkinName: []string{"凌霄成卫", "M7棱镜攻势S2"}}, + ).Find(&[]struct{}{}).Statement.SQL.String() + if strings.Count(skinNameSQL, "JSON_SEARCH(a.asset_summary") != 2 { + t.Fatalf("多皮肤筛选应生成两个 AND 条件, 实际: %s", skinNameSQL) + } + if !strings.Contains(skinNameSQL, "'$.skin_groups.*'") { + t.Fatalf("皮肤名称筛选应遍历全部分组, 实际: %s", skinNameSQL) + } + + groupSQL := repo.applyAdminListFilters( + db.Table("rental_listings AS l"), + AdminListQuery{SkinGroup: []string{"operatorRed", "weapon"}}, + ).Find(&[]struct{}{}).Statement.SQL.String() + if !strings.Contains(groupSQL, "JSON_LENGTH(JSON_EXTRACT(a.asset_summary, ?)) > 0") { + t.Fatalf("皮肤分组筛选应下推 JSON_LENGTH, 实际: %s", groupSQL) + } + if !strings.Contains(groupSQL, " OR ") { + t.Fatalf("多皮肤分组应使用 OR 语义, 实际: %s", groupSQL) + } +} + +func TestAdminAssetFilterHelpers(t *testing.T) { + if got := jsonMemberPath("$.skin_groups", `operator"Red`); got != `$.skin_groups."operator\"Red"` { + t.Fatalf("jsonMemberPath = %q", got) + } + if got := escapeJSONSearchPattern(`50%_暗星!`); got != `50!%!_暗星!!` { + t.Fatalf("escapeJSONSearchPattern = %q", got) + } +} + func TestApplyAdminListFiltersSmartRouting(t *testing.T) { db := database.NewDryRunDB() repo := &Repository{} diff --git a/backend/internal/modules/listing/query.go b/backend/internal/modules/listing/query.go index 797062d..547d717 100644 --- a/backend/internal/modules/listing/query.go +++ b/backend/internal/modules/listing/query.go @@ -35,37 +35,6 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*Admi pageSize = 100 } - if hasAdminAssetFilters(query) { - var rows []listingRow - err := r.applyAdminListFilters(r.baseQuery(ctx), query). - Order("l.id DESC"). - Scan(&rows).Error - if err != nil { - return nil, err - } - items := filterAdminListings(rowsToDTO(rows), query) - total := int64(len(items)) - start := (page - 1) * pageSize - if start < 0 { - start = 0 - } - if start >= len(items) { - items = []ListingDTO{} - } else { - end := start + pageSize - if end > len(items) { - end = len(items) - } - items = items[start:end] - } - return &AdminListResult{ - Items: items, - Total: total, - Page: page, - PageSize: pageSize, - }, nil - } - db := r.applyAdminListFilters(r.baseQuery(ctx), query) offset := (page - 1) * pageSize if offset < 0 { @@ -82,7 +51,12 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*Admi if page == 1 && len(rows) < pageSize { total = int64(len(rows)) } else { - countDB := r.applyAdminListFilters(r.db.WithContext(ctx).Table("rental_listings AS l"), query) + countDB := r.applyAdminListFilters( + r.db.WithContext(ctx). + Table("rental_listings AS l"). + Joins("JOIN game_accounts AS a ON a.id = l.account_id"), + query, + ) if err := countDB.Count(&total).Error; err != nil { return nil, err } @@ -149,6 +123,7 @@ func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *g if query.MaxDeposit != nil { db = db.Where("l.deposit_amount_cent <= ?", yuanToAdminCent(*query.MaxDeposit)) } + db = applyAdminAssetSQLFilters(db, query) return db } diff --git a/frontend/src/features/admin/AdminDisputeDetailDrawer.tsx b/frontend/src/features/admin/AdminDisputeDetailDrawer.tsx index f3559be..490b95b 100644 --- a/frontend/src/features/admin/AdminDisputeDetailDrawer.tsx +++ b/frontend/src/features/admin/AdminDisputeDetailDrawer.tsx @@ -1,11 +1,18 @@ import { useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { App, Button, Image, Input, InputNumber, Modal, Select, Space, Tag, Typography } from 'antd' import { CheckCircleOutlined } from '@ant-design/icons' import { arbitrateAdminDispute } from '@/features/admin/api' +import { invalidateAdminDashboard } from '@/features/admin/cache' import { DetailDrawer, type DetailSection } from '@/features/admin/components/DetailDrawer' import { StatusTag } from '@/features/admin/components/StatusTag' -import { handoffStatusMeta, orderStatusMap, readStatusMeta, settlementStatusMap } from '@/features/admin/labels' +import { + handoffStatusMeta, + orderStatusMap, + readStatusMeta, + settlementStatusMap, +} from '@/features/admin/labels' import { allowsAmount, arbitrationResultLabel, @@ -211,6 +218,7 @@ export function AdminDisputeDetailDrawer({ onUpdated, }: AdminDisputeDetailDrawerProps) { const { message } = App.useApp() + const queryClient = useQueryClient() const permissions = useAdminSessionStore(state => state.admin?.permissions) const canArbitrate = Boolean( permissions?.includes('*') || permissions?.includes('dispute:arbitrate') @@ -234,7 +242,8 @@ export function AdminDisputeDetailDrawer({ ) setCoinM(dispute.checkout_coin_consumed_m ?? null) setConsumableYuan( - (dispute.checkout_consumable_amount_cent ?? dispute.extra_item_original_amount_cent ?? 0) / 100 + (dispute.checkout_consumable_amount_cent ?? dispute.extra_item_original_amount_cent ?? 0) / + 100 ) setModalOpen(true) } @@ -277,7 +286,7 @@ export function AdminDisputeDetailDrawer({ remark: text, amount_cent: allowsAmount(result) && amountYuan != null ? Math.round(amountYuan * 100) : undefined, - actual_coin_consumed_m: usesActualCheckout(result) ? coinM ?? undefined : undefined, + actual_coin_consumed_m: usesActualCheckout(result) ? (coinM ?? undefined) : undefined, actual_consumable_amount_cent: usesActualCheckout(result) ? Math.round((consumableYuan ?? 0) * 100) : undefined, @@ -285,6 +294,7 @@ export function AdminDisputeDetailDrawer({ message.success('仲裁结果已保存') setModalOpen(false) onUpdated(updated) + void invalidateAdminDashboard(queryClient) } catch (error) { const text = error && typeof error === 'object' && 'response' in error @@ -299,7 +309,8 @@ export function AdminDisputeDetailDrawer({ } } - const canHandle = dispute != null && ['open', 'processing'].includes(dispute.status) && canArbitrate + const canHandle = + dispute != null && ['open', 'processing'].includes(dispute.status) && canArbitrate const showAmount = allowsAmount(result) const showActual = usesActualCheckout(result) @@ -312,8 +323,7 @@ export function AdminDisputeDetailDrawer({ subtitle={ dispute ? ( - {dispute.order_no} · {disputeTypeLabel(dispute.type)} ·{' '} - {initiatorLabel(dispute)} + {dispute.order_no} · {disputeTypeLabel(dispute.type)} · {initiatorLabel(dispute)} ) : null } @@ -331,11 +341,7 @@ export function AdminDisputeDetailDrawer({ dispute ? ( {canHandle ? ( - ) : null} @@ -360,7 +366,8 @@ export function AdminDisputeDetailDrawer({ > {dispute ? ( - 订单 {dispute.order_no},申诉 #{dispute.id}。裁决会立即影响订单、钱包与商品状态,请核对后提交。 + 订单 {dispute.order_no},申诉 #{dispute.id} + 。裁决会立即影响订单、钱包与商品状态,请核对后提交。 ) : null} @@ -395,9 +402,7 @@ export function AdminDisputeDetailDrawer({ precision={2} value={consumableYuan} addonBefore="¥" - onChange={value => - setConsumableYuan(typeof value === 'number' ? value : null) - } + onChange={value => setConsumableYuan(typeof value === 'number' ? value : null)} /> diff --git a/frontend/src/features/admin/AdminListingDetailDrawer.tsx b/frontend/src/features/admin/AdminListingDetailDrawer.tsx index d267941..943b8e5 100644 --- a/frontend/src/features/admin/AdminListingDetailDrawer.tsx +++ b/frontend/src/features/admin/AdminListingDetailDrawer.tsx @@ -1,16 +1,7 @@ import { useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import axios from 'axios' -import { - App, - Button, - Image, - Input, - Modal, - Space, - Tag, - Typography, -} from 'antd' +import { App, Button, Image, Input, Modal, Space, Tag, Typography } from 'antd' import { CopyOutlined, DownOutlined, StopOutlined, WarningOutlined } from '@ant-design/icons' import { @@ -20,6 +11,7 @@ import { offlineAdminListing, rejectAdminListing, } from '@/features/admin/api' +import { invalidateAdminDashboard } from '@/features/admin/cache' import { DetailDrawer, type DetailSection } from '@/features/admin/components/DetailDrawer' import { StatusTag } from '@/features/admin/components/StatusTag' import { handoffModeMap, readStatusMeta, settlementModeMap } from '@/features/admin/labels' @@ -78,8 +70,16 @@ function buildSections(listing: AdminListing): DetailSection[] { key: 'overview', title: '商品概览', fields: [ - { key: 'listing_no', label: '商品编号', value: {listing.listing_no} }, - { key: 'status', label: '商品状态', value: }, + { + key: 'listing_no', + label: '商品编号', + value: {listing.listing_no}, + }, + { + key: 'status', + label: '商品状态', + value: , + }, { key: 'review_status', label: '审核状态', @@ -185,9 +185,7 @@ function buildSections(listing: AdminListing): DetailSection[] { { key: 'reason', label: '审核意见', - value: ( -
{listing.review_reason || '暂无审核意见'}
- ), + value:
{listing.review_reason || '暂无审核意见'}
, span: 2, }, { @@ -212,7 +210,8 @@ function buildSections(listing: AdminListing): DetailSection[] { } if (listing.screenshot_urls.length > 0 || listing.cover_url) { - const images = listing.screenshot_urls.length > 0 ? listing.screenshot_urls : [listing.cover_url] + const images = + listing.screenshot_urls.length > 0 ? listing.screenshot_urls : [listing.cover_url] sections.push({ key: 'screenshots', title: '商品截图', @@ -273,6 +272,7 @@ export function AdminListingDetailDrawer({ await query.refetch() await queryClient.invalidateQueries({ queryKey: ['admin-listings'] }) await queryClient.invalidateQueries({ queryKey: ['admin-pending-listings'] }) + await invalidateAdminDashboard(queryClient) onChanged?.() } @@ -345,7 +345,9 @@ export function AdminListingDetailDrawer({ !['offline', 'completed', 'sealed'].includes(listing.status) && can('listing:offline') const canMarkAbnormal = - listing != null && !['abnormal', 'completed', 'sealed'].includes(listing.status) && can('listing:offline') + listing != null && + !['abnormal', 'completed', 'sealed'].includes(listing.status) && + can('listing:offline') const actionCopyItem = reasonAction ? actionCopy[reasonAction] : null @@ -398,7 +400,11 @@ export function AdminListingDetailDrawer({ 标记异常 ) : null} - diff --git a/frontend/src/features/admin/AdminRefundReviewPage.tsx b/frontend/src/features/admin/AdminRefundReviewPage.tsx index 4fd9ec5..bb72f77 100644 --- a/frontend/src/features/admin/AdminRefundReviewPage.tsx +++ b/frontend/src/features/admin/AdminRefundReviewPage.tsx @@ -1,18 +1,29 @@ import { useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { Alert, App, Button, Modal, Radio, Space, Tag, Typography, type TableColumnsType } from 'antd' +import { + Alert, + App, + Button, + Modal, + Radio, + Space, + Tag, + Typography, + type TableColumnsType, +} from 'antd' import { EyeOutlined, ReloadOutlined } from '@ant-design/icons' -import { approveAdminRefund, fetchPendingRefundOrders, rejectAdminRefund } from '@/features/admin/api' +import { + approveAdminRefund, + fetchPendingRefundOrders, + rejectAdminRefund, +} from '@/features/admin/api' import { AdminOrderDetailDrawer } from '@/features/admin/AdminOrderDetailDrawer' +import { invalidateAdminDashboard } from '@/features/admin/cache' import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' import { DataTable } from '@/features/admin/components/DataTable' import { StatusTag } from '@/features/admin/components/StatusTag' -import { - handoffStatusMeta, - orderStatusMap, - readStatusMeta, -} from '@/features/admin/labels' +import { handoffStatusMeta, orderStatusMap, readStatusMeta } from '@/features/admin/labels' import type { AdminOrderDetail } from '@/features/admin/types' import { useAdminSessionStore } from '@/stores/adminSession' import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format' @@ -137,7 +148,7 @@ export function AdminRefundReviewPage() { const refreshAll = async () => { await query.refetch() await queryClient.invalidateQueries({ queryKey: ['admin-orders'] }) - await queryClient.invalidateQueries({ queryKey: ['admin-dashboard'] }) + await invalidateAdminDashboard(queryClient) } const openDetail = (row: AdminOrderDetail) => { @@ -174,7 +185,9 @@ export function AdminRefundReviewPage() { setSubmitting(true) try { await rejectAdminRefund(rejectOrder.id, rejectMode) - message.success(rejectMode === 'restore' ? '退款已驳回,订单恢复待交接' : '退款已驳回,订单已关闭') + message.success( + rejectMode === 'restore' ? '退款已驳回,订单恢复待交接' : '退款已驳回,订单已关闭' + ) setRejectOrder(null) await refreshAll() } catch (error) { @@ -185,7 +198,8 @@ export function AdminRefundReviewPage() { } const columns = buildColumns(openDetail, approve, openReject, canClose, canView) - const errorText = query.error instanceof Error ? query.error.message : '请稍后重试或重新登录后台。' + const errorText = + query.error instanceof Error ? query.error.message : '请稍后重试或重新登录后台。' return (
@@ -200,7 +214,11 @@ export function AdminRefundReviewPage() { } extra={ - } diff --git a/frontend/src/features/admin/AdminWithdrawalDetailDrawer.tsx b/frontend/src/features/admin/AdminWithdrawalDetailDrawer.tsx index 07e709b..174d721 100644 --- a/frontend/src/features/admin/AdminWithdrawalDetailDrawer.tsx +++ b/frontend/src/features/admin/AdminWithdrawalDetailDrawer.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { App, Button, Image, Input, Modal, Space, Tag, Typography } from 'antd' import { CheckOutlined, CloseOutlined, WalletOutlined } from '@ant-design/icons' @@ -8,6 +8,7 @@ import { fetchAdminWithdrawal, reviewAdminWithdrawal, } from '@/features/admin/api' +import { invalidateAdminDashboard } from '@/features/admin/cache' import { DetailDrawer, type DetailSection } from '@/features/admin/components/DetailDrawer' import { StatusTag } from '@/features/admin/components/StatusTag' import { readStatusMeta, withdrawalStatusMap } from '@/features/admin/labels' @@ -51,7 +52,11 @@ function buildSections(item: AdminWithdrawal): DetailSection[] { label: '提现状态', value: , }, - { key: 'user', label: '用户', value: `${item.user_nickname || '未设置昵称'} · ${item.user_phone}` }, + { + key: 'user', + label: '用户', + value: `${item.user_nickname || '未设置昵称'} · ${item.user_phone}`, + }, { key: 'user_id', label: '用户 ID', value: `UID ${item.user_id}` }, { key: 'site', @@ -103,7 +108,12 @@ function buildSections(item: AdminWithdrawal): DetailSection[] { { key: 'review_remark', label: '审核备注', value: formatText(item.review_remark), span: 2 }, { key: 'paid_by', label: '打款人', value: formatText(item.paid_by_name) }, { key: 'paid_at', label: '打款时间', value: formatDateTime(item.paid_at) }, - { key: 'payment_remark', label: '打款备注', value: formatText(item.payment_remark), span: 2 }, + { + key: 'payment_remark', + label: '打款备注', + value: formatText(item.payment_remark), + span: 2, + }, { key: 'proof', label: '打款凭证', @@ -167,6 +177,7 @@ export function AdminWithdrawalDetailDrawer({ onChanged, }: AdminWithdrawalDetailDrawerProps) { const { message } = App.useApp() + const queryClient = useQueryClient() const permissions = useAdminSessionStore(state => state.admin?.permissions) const can = (code: string) => Boolean(permissions?.includes('*') || permissions?.includes(code)) const [action, setAction] = useState(null) @@ -216,6 +227,7 @@ export function AdminWithdrawalDetailDrawer({ } setAction(null) await query.refetch() + await invalidateAdminDashboard(queryClient) onChanged?.() } catch (error) { const text = @@ -278,11 +290,7 @@ export function AdminWithdrawalDetailDrawer({ ) : null} {canPay ? ( - ) : null} @@ -308,8 +316,8 @@ export function AdminWithdrawalDetailDrawer({ {item ? ( 提现单 {item.withdraw_no},申请 {formatMoney(item.amount_cent)},到账{' '} - {formatMoney(item.actual_amount_cent)},收款人 {item.account_name}( - {item.account_no})。 + {formatMoney(item.actual_amount_cent)},收款人 {item.account_name}({item.account_no} + )。 ) : null}