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 @@ - + @@ -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 @@ - + @@ -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) +})