From c2e8b729be897b955f7d4d669c4a9db8cc4c22ab Mon Sep 17 00:00:00 2001
From: Yvan <8574526@qq,com>
Date: Wed, 4 Nov 2026 18:28:49 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20=E6=B6=88=E9=99=A4=E6=B8=B8=E6=88=8F?=
=?UTF-8?q?=E4=B8=8B=E6=8B=89=E9=87=8D=E5=A4=8D=E8=AF=B7=E6=B1=82=E5=B9=B6?=
=?UTF-8?q?=E4=BC=98=E5=8C=96=E7=94=B3=E8=AF=B7=E8=8C=83=E5=9B=B4=E6=9F=A5?=
=?UTF-8?q?=E8=AF=A2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
internal/data/business/approved_games.go | 12 ++++-
internal/data/business/migrations.go | 2 +-
internal/data/business/query_indexes_test.go | 37 +++++++++++++++
web/src/view/sw/components/LedgerDetails.vue | 6 ++-
.../view/sw/components/ReportAnalytics.vue | 6 ++-
web/src/view/sw/game-options-loader.js | 21 +++++++++
web/src/view/sw/game-options-loader.test.js | 46 +++++++++++++++++++
7 files changed, 123 insertions(+), 7 deletions(-)
create mode 100644 web/src/view/sw/game-options-loader.js
create mode 100644 web/src/view/sw/game-options-loader.test.js
diff --git a/internal/data/business/approved_games.go b/internal/data/business/approved_games.go
index 7f0a0a0..78efe92 100644
--- a/internal/data/business/approved_games.go
+++ b/internal/data/business/approved_games.go
@@ -19,10 +19,18 @@ func approvedGamesAvailable(db *gorm.DB) (bool, error) {
// 用平台和 ID 配对,避免同号游戏或其他商务的申请扩大可选范围。
func approvedGamesQuery(db *gorm.DB, viewer business.UserScope, sources []string) *gorm.DB {
promotes := ledgerBasePromotes(db, business.LedgerScope{Viewer: viewer, IncludeSub: true, Unrestricted: viewer.All}, business.LedgerFilter{Sources: sources}, "approved_p").Select("approved_p.source_db,approved_p.id")
- applications := db.Table("tab_promote_apply a").Select("a.source_db,a.game_id").Where("a.status=1").Where("(a.source_db,a.promote_id) IN (?)", promotes)
- q := db.Table("tab_game g").Where("(g.source_db,g.id) IN (?)", applications)
+ // 逐个游戏检查有效申请,命中即停止;非相关 IN 在 MySQL 5.7 会先
+ // 物化整张百万级申请表,即使下拉只需要前 50 项也要扫描全部申请。
+ applications := db.Table("tab_promote_apply a").Select("1").Where("a.source_db=g.source_db AND a.game_id=g.id AND a.status=1").Where("(a.source_db,a.promote_id) IN (?)", promotes)
+ q := db.Table("tab_game g").Where("EXISTS (?)", applications)
if len(sources) > 0 {
q = q.Where("g.source_db IN ?", sources)
}
return q
}
+
+func migrateApprovedGameIndexes(db *gorm.DB) error {
+ return migrateQueryIndexes(db, []businessQueryIndex{
+ {"tab_promote_apply", "idx_sw_apply_game_scope", []string{"game_id", "source_db", "status", "promote_id"}},
+ })
+}
diff --git a/internal/data/business/migrations.go b/internal/data/business/migrations.go
index fe123cb..c35becd 100644
--- a/internal/data/business/migrations.go
+++ b/internal/data/business/migrations.go
@@ -6,7 +6,7 @@ import (
)
func Migrations() []migration.Step {
- return []migration.Step{{ID: "202611040014_business_report_build_indexes", Migrate: migrateReportBuildQueryIndexes}, {ID: "202611040013_business_query_indexes", Migrate: migrateBusinessQueryIndexes}, {ID: "202611040012_business_server_report_index", Migrate: migrateServerReportIndex}, {ID: "202611040011_business_registration_index", Migrate: migrateRegistrationIndex}, {ID: "202611040010_business_alert_trigger_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &alertTriggerLockPO{}) }}, {ID: "202611040009_business_menu_icons", Migrate: migrateBusinessMenuIcons}, {ID: "202611040008_business_report_build_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &reportBuildLockPO{}) }}, {ID: "202611030007_business_alert_attachments", Migrate: migrateAlertAttachments}, {ID: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error {
+ return []migration.Step{{ID: "202611040015_business_approved_game_index", Migrate: migrateApprovedGameIndexes}, {ID: "202611040014_business_report_build_indexes", Migrate: migrateReportBuildQueryIndexes}, {ID: "202611040013_business_query_indexes", Migrate: migrateBusinessQueryIndexes}, {ID: "202611040012_business_server_report_index", Migrate: migrateServerReportIndex}, {ID: "202611040011_business_registration_index", Migrate: migrateRegistrationIndex}, {ID: "202611040010_business_alert_trigger_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &alertTriggerLockPO{}) }}, {ID: "202611040009_business_menu_icons", Migrate: migrateBusinessMenuIcons}, {ID: "202611040008_business_report_build_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &reportBuildLockPO{}) }}, {ID: "202611030007_business_alert_attachments", Migrate: migrateAlertAttachments}, {ID: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &resetCodePO{}, &resetTokenPO{})
}}, {ID: "202611030004_business_binding_locks", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &bindingLockPO{})
diff --git a/internal/data/business/query_indexes_test.go b/internal/data/business/query_indexes_test.go
index 2c28ed6..e67b8d2 100644
--- a/internal/data/business/query_indexes_test.go
+++ b/internal/data/business/query_indexes_test.go
@@ -1,12 +1,49 @@
package business
import (
+ "context"
"strings"
"testing"
"kra/internal/biz/business"
)
+func TestMySQLApprovedGameIndexSupportsExistenceLookup(t *testing.T) {
+ db := openRehearsalMySQL(t)
+ for _, sql := range []string{
+ "CREATE TABLE tab_game(id INT,source_db VARCHAR(10),game_name VARCHAR(64),PRIMARY KEY(id,source_db),KEY(game_name)) ENGINE=InnoDB",
+ "CREATE TABLE tab_promote(id INT,source_db VARCHAR(10),PRIMARY KEY(id,source_db)) ENGINE=InnoDB",
+ "CREATE TABLE tab_promote_apply(id INT PRIMARY KEY,source_db VARCHAR(10),game_id INT,promote_id INT,status INT,payload VARCHAR(500)) ENGINE=InnoDB",
+ "INSERT INTO tab_game VALUES(1,'lx','游戏甲'),(1,'hw','无申请同号游戏')",
+ "INSERT INTO tab_promote VALUES(100,'lx')",
+ "INSERT INTO tab_promote_apply VALUES(1,'lx',1,100,1,'keep'),(2,'lx',1,100,0,'pending')",
+ } {
+ if err := db.Exec(sql).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ for attempt := 0; attempt < 2; attempt++ {
+ if err := migrateApprovedGameIndexes(db); err != nil {
+ t.Fatal(err)
+ }
+ }
+ var columns []string
+ if err := db.Raw("SELECT column_name FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='tab_promote_apply' AND index_name='idx_sw_apply_game_scope' ORDER BY seq_in_index").Scan(&columns).Error; err != nil {
+ t.Fatal(err)
+ }
+ if strings.Join(columns, ",") != "game_id,source_db,status,promote_id" {
+ t.Fatal("申请查询需要覆盖完整游戏、平台、状态和渠道条件")
+ }
+ rows, err := NewLedgerRepo(testData{db}).Games(context.Background(), business.UserScope{All: true}, nil, "", 50)
+ if err != nil || len(rows) != 1 || rows[0].SourceDB != "lx" {
+ t.Fatalf("索引不能改变游戏可见范围: %+v %v", rows, err)
+ }
+ var payload string
+ if err := db.Raw("SELECT payload FROM tab_promote_apply WHERE id=1").Scan(&payload).Error; err != nil || payload != "keep" {
+ t.Fatal("索引迁移不能修改申请数据")
+ }
+}
+
func TestMySQLBusinessQueryIndexesAreAdditiveAndIdempotent(t *testing.T) {
db := openRehearsalMySQL(t)
definitions := map[string]string{
diff --git a/web/src/view/sw/components/LedgerDetails.vue b/web/src/view/sw/components/LedgerDetails.vue
index d0fbf73..4b5ba7b 100644
--- a/web/src/view/sw/components/LedgerDetails.vue
+++ b/web/src/view/sw/components/LedgerDetails.vue
@@ -8,7 +8,7 @@
- open && loadGames('')">
+
@@ -50,7 +50,9 @@ import { useUserStore } from '@/pinia/modules/user'
import { useBtnAuth } from '@/utils/btnAuth'
import { listLedger, exportLedger, listLedgerGames } from '@/api/ledger'
import { channelMoney } from '../channel-display'
+import { createGameOptionsLoader } from '../game-options-loader'
import { compareAmounts } from '../table-display'
+const fetchGameOptions = createGameOptionsLoader(listLedgerGames)
const props = defineProps({ kind: { type: String, required: true } })
const spend = computed(() => props.kind === 'spend')
const user = useUserStore(), buttons = useBtnAuth()
@@ -100,7 +102,7 @@ const loadGames = async (keyword = '') => {
const version = ++gameVersion; if (!sources.value.length) return
gamesLoading.value = true
try {
- const res = await listLedgerGames({ sourceDb: sources.value.join(','), keyword, limit: 50 })
+ const res = await fetchGameOptions({ sourceDb: sources.value.join(','), keyword, limit: 50 })
if (version !== gameVersion) return
if (res.code === 0) gameOptions.value = [...new Set([...gameNames.value, ...res.data.map(row => row.gameName).filter(Boolean)])]
else { gameOptions.value = []; ElMessage.error(res.msg || '游戏选项读取失败') }
diff --git a/web/src/view/sw/components/ReportAnalytics.vue b/web/src/view/sw/components/ReportAnalytics.vue
index 18b1f34..7d3431b 100644
--- a/web/src/view/sw/components/ReportAnalytics.vue
+++ b/web/src/view/sw/components/ReportAnalytics.vue
@@ -7,7 +7,7 @@
- open && loadGames('')">
+
@@ -39,8 +39,10 @@ import { ElMessage } from 'element-plus'
import { listAnalytics } from '@/api/analytics'
import { listLedgerGames } from '@/api/ledger'
import { channelCount, channelMoney } from '../channel-display'
+import { createGameOptionsLoader } from '../game-options-loader'
import { metricTips } from '../table-display'
import { analysisGameSelection } from '../analysis-game-selection'
+const fetchGameOptions = createGameOptionsLoader(listLedgerGames)
const props = defineProps({ kind: { type: String, required: true } })
const isChannel = computed(() => props.kind === 'channel')
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
@@ -87,7 +89,7 @@ const loadGames = async (keyword = '') => {
const current = ++gameVersion; if (!sources.value.length) return
gameLoading.value = true
try {
- const res = await listLedgerGames({ sourceDb: sources.value.join(','), keyword, limit: isChannel.value ? 50 : 10000 })
+ const res = await fetchGameOptions({ sourceDb: sources.value.join(','), keyword, limit: isChannel.value ? 50 : 10000 })
if (current !== gameVersion) return
if (res.code === 0) gameOptions.value = [...new Set(res.data.map(row => row.gameName).filter(Boolean))]
else { gameOptions.value = []; ElMessage.error(res.msg || '游戏选项读取失败') }
diff --git a/web/src/view/sw/game-options-loader.js b/web/src/view/sw/game-options-loader.js
new file mode 100644
index 0000000..407e19f
--- /dev/null
+++ b/web/src/view/sw/game-options-loader.js
@@ -0,0 +1,21 @@
+// One loader per page: share identical requests and briefly reuse successful
+// options when reopening the selector. Errors must remain retryable.
+export const createGameOptionsLoader = (fetchOptions, now = Date.now) => {
+ const pending = new Map(), cache = new Map()
+ return params => {
+ const key = JSON.stringify([params.sourceDb, params.keyword, params.limit])
+ const saved = cache.get(key)
+ if (saved && now() - saved.time < 30000) return Promise.resolve(saved.result)
+ if (pending.has(key)) return pending.get(key)
+ const request = Promise.resolve().then(() => fetchOptions(params)).then(result => {
+ if (result.code === 0) {
+ cache.delete(key)
+ cache.set(key, { result, time: now() })
+ if (cache.size > 20) cache.delete(cache.keys().next().value)
+ }
+ return result
+ }).finally(() => pending.delete(key))
+ pending.set(key, request)
+ return request
+ }
+}
diff --git a/web/src/view/sw/game-options-loader.test.js b/web/src/view/sw/game-options-loader.test.js
new file mode 100644
index 0000000..779726c
--- /dev/null
+++ b/web/src/view/sw/game-options-loader.test.js
@@ -0,0 +1,46 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { createGameOptionsLoader } from './game-options-loader.js'
+
+const params = { sourceDb: 'lx,hw,yq', keyword: '', limit: 50 }
+
+test('concurrent opening events share one request and reopening reuses the result briefly', async () => {
+ let finish, calls = 0, time = 0
+ const load = createGameOptionsLoader(() => { calls++; return new Promise(resolve => { finish = resolve }) }, () => time)
+ const requests = [load(params), load(params), load(params)]
+ await Promise.resolve()
+ assert.equal(calls, 1)
+ const result = { code: 0, data: [{ gameName: '游戏甲' }] }
+ finish(result)
+ assert.deepEqual(await Promise.all(requests), [result, result, result])
+ assert.equal(await load(params), result)
+ assert.equal(calls, 1)
+ time = 30001
+ const refreshed = load(params)
+ await Promise.resolve()
+ assert.equal(calls, 2)
+ finish(result)
+ await refreshed
+})
+
+test('platform, search text and limit use separate results', async () => {
+ let calls = 0
+ const load = createGameOptionsLoader(async query => { calls++; return { code: 0, data: [query] } })
+ await Promise.all([load(params), load({ ...params, sourceDb: 'lx' }), load({ ...params, keyword: '传奇' }), load({ ...params, limit: 10000 })])
+ assert.equal(calls, 4)
+ assert.deepEqual((await load(params)).data, [params])
+ assert.equal(calls, 4)
+})
+
+test('network and API failures can be retried immediately', async () => {
+ let calls = 0
+ const load = createGameOptionsLoader(async () => {
+ calls++
+ if (calls === 1) throw new Error('network unavailable')
+ return { code: calls === 2 ? 7 : 0, data: [] }
+ })
+ await assert.rejects(load(params), /network unavailable/)
+ assert.equal((await load(params)).code, 7)
+ assert.equal((await load(params)).code, 0)
+ assert.equal(calls, 3)
+})