From fd7e6071a884a47495a05ba90f0198e33cf32476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E5=93=A5=E5=A4=A7=E5=93=A5=E7=9A=84=E5=A4=A7?= =?UTF-8?q?=E5=93=A5=E5=93=A5?= Date: Fri, 17 Jul 2026 21:21:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(tongji):=20=E4=BF=AE=E5=A4=8D=E5=B0=8F?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E5=B9=B3=E5=8F=B0=E5=90=8C=E6=AD=A5=E4=B8=8E?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../endless-game/PLATFORM_INTEGRATION.md | 6 +- TUICallKit-Vue3/tongji/endless-game/README.md | 8 +- .../endless-game/composables/useGameAuth.js | 71 +++++++++ .../composables/useGamePlatform.js | 9 +- TUICallKit-Vue3/tongji/endless-game/index.vue | 10 +- .../app/api/logic/tcm/GamePlatformLogic.php | 142 ++++++++++++------ .../add_tcm_endless_game_platform.sql | 11 +- ...ade_tcm_endless_game_platform_20260717.sql | 55 +++++++ 8 files changed, 262 insertions(+), 50 deletions(-) create mode 100644 TUICallKit-Vue3/tongji/endless-game/composables/useGameAuth.js create mode 100644 server/sql/1.9.20260717/upgrade_tcm_endless_game_platform_20260717.sql diff --git a/TUICallKit-Vue3/tongji/endless-game/PLATFORM_INTEGRATION.md b/TUICallKit-Vue3/tongji/endless-game/PLATFORM_INTEGRATION.md index 8d0ab463..51ecb259 100644 --- a/TUICallKit-Vue3/tongji/endless-game/PLATFORM_INTEGRATION.md +++ b/TUICallKit-Vue3/tongji/endless-game/PLATFORM_INTEGRATION.md @@ -4,11 +4,12 @@ - 复用主小程序 `token` 与微信小程序登录,不创建第二套游戏账号。 - 按周一日期、性别自动分配最多 7 人的同行组。 +- 首次入组使用数据库分配锁,多个用户同时进入也不会重复分组或超过 7 人。 - 以真实平台昵称、头像、认糖数和本周最高分排序。 - 每局用 `session_key` 上报绝对进度,断网重试不会重复加分。 - 待同步成绩最多本地保留 8 局,重新联网后自动补传。 - 微信好友与朋友圈分享使用随机分享码,不在链接中暴露用户 ID。 -- 受邀进入只记录一次轻量分享访问,不自动建立家庭或好友绑定。 +- 每张周分享卡对同一受邀人只记录一次轻量访问,不自动建立家庭或好友绑定。 ## 接口 @@ -25,6 +26,9 @@ 2. 发布 `server/app/api/logic/tcm/GamePlatformLogic.php` 和 `TcmController.php`。 3. 重新构建并上传小程序前端。 +如果数据库已经执行过本功能的旧版建表脚本,再执行一次 +`server/sql/1.9.20260717/upgrade_tcm_endless_game_platform_20260717.sql`,用于补充分组锁并把分享去重范围修正为“每张周分享卡”。 + 如果后端或数据表尚未发布,游戏仍可离线游玩,榜单会显示“离线记录中”;联网且接口可用后自动补传。 ## 上线前检查 diff --git a/TUICallKit-Vue3/tongji/endless-game/README.md b/TUICallKit-Vue3/tongji/endless-game/README.md index 2afa8a5a..1709868b 100644 --- a/TUICallKit-Vue3/tongji/endless-game/README.md +++ b/TUICallKit-Vue3/tongji/endless-game/README.md @@ -7,6 +7,8 @@ - `index.vue`:页面、棋盘算法、关卡主题、任务、道具、三/四/五连奖励和适老化交互。 - `game-endless.scss`:完整页面与动画样式。 - `components/TongjiIcon.vue`:本功能包使用的图标组件。 +- `composables/useGameAuth.js`:复用主小程序账号并处理 token 过期重登。 +- `composables/useGamePlatform.js`:周榜、成绩补传与微信分享的平台连接层。 - `composables/useGameSfx.js`:滑动、掉落、消除、连击和大奖音效。 - `utils/svgDataUrl.js`:图标编码工具。 - `assets/food/`:本游戏使用的全部食品与驼乳粉图片。 @@ -37,7 +39,9 @@ build: { "navigationStyle": "custom", "navigationBarTitleText": "控糖消消乐", "backgroundColor": "#eefbf4", - "disableScroll": false + "disableScroll": false, + "enableShareAppMessage": true, + "enableShareTimeline": true } } ``` @@ -51,6 +55,6 @@ build: { - 当前项目原来的“糖分突袭”源码保存在 `tongji/legacy-game/game.vue`,没有注册为页面,不参与打包。 - 连消小目标按“连消×2”累计 2 次;连续 6 次普通消除没有连消时,下一次掉落会提供连消机会。“连消×3”保留为额外积分、驼乳粉与撒花惊喜,不阻挡主线任务。 - 横向 3 个与竖向 3 个相交形成 L/T 形五消时,会在交点生成带高对比 L 标记的范围爆破棋子;该棋子再次被消除时清除周围九格。 -- 进入游戏时展示“本周 7 人同行榜”界面原型,包含前后名次、差距提示和可切换的同行/亲友鼓励;顶部“本周同行”可再次打开。当前榜单明确标注为预览数据,正式用户关系和周榜成绩需要后端接口接入。 +- 进入游戏时展示“本周 7 人同行榜”,包含真实平台昵称、前后名次、差距提示和可切换的同行/亲友鼓励;顶部“本周同行”可再次打开。后端部署方式见 `PLATFORM_INTEGRATION.md`。 - 本功能包依赖 uni-app Vue 3 和 `@dcloudio/uni-app`,不依赖当前项目其他业务组件。 - 食物风险等级和科普文案集中在 `index.vue` 的 `FOODS` 配置中,正式上线前仍需由医院医生审核。 diff --git a/TUICallKit-Vue3/tongji/endless-game/composables/useGameAuth.js b/TUICallKit-Vue3/tongji/endless-game/composables/useGameAuth.js new file mode 100644 index 00000000..2ae3d209 --- /dev/null +++ b/TUICallKit-Vue3/tongji/endless-game/composables/useGameAuth.js @@ -0,0 +1,71 @@ +/** + * 控糖消消乐独立登录适配层。复用主小程序账号,但不依赖 tongji 其他页面代码。 + */ +export function useGameAuth(proxy) { + let loginPromise = null + + function hasToken() { + return !!String(uni.getStorageSync('token') || '').trim() + } + + function clearToken() { + uni.removeStorageSync('token') + } + + function wxLoginCode() { + return new Promise((resolve, reject) => { + uni.login({ + provider: 'weixin', + success: (res) => res?.code ? resolve(res.code) : reject(new Error('微信登录未返回 code')), + fail: reject + }) + }) + } + + async function loginWithCode(code) { + const res = await proxy.apiUrl({ + url: '/api/login/mnpLogin', + method: 'POST', + data: { code } + }, false) + if (res?.code !== 1 || !res.data?.token) { + clearToken() + throw new Error(res?.msg || '登录失败') + } + uni.setStorageSync('token', res.data.token) + uni.setStorageSync('userData', res.data) + return true + } + + async function verifyOrLogin() { + if (hasToken()) { + try { + const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false) + if (res?.code === 1 && res.data) { + uni.setStorageSync('userData', res.data) + return true + } + // 只有明确的登录失效才重新换取 token;其他业务错误先保留原登录。 + if (res?.code !== -1) return true + } catch (_) { + // 断网不清除仍可能有效的 token,成绩由离线队列稍后补传。 + return true + } + clearToken() + } + const code = await wxLoginCode() + return loginWithCode(code) + } + + async function ensureLoggedIn() { + if (loginPromise) return loginPromise + loginPromise = verifyOrLogin() + .then(ok => !!ok) + .catch(() => false) + // 只合并同时发生的登录;成功结果不能永久缓存,否则 token 过期后无法恢复。 + .finally(() => { loginPromise = null }) + return loginPromise + } + + return { ensureLoggedIn } +} diff --git a/TUICallKit-Vue3/tongji/endless-game/composables/useGamePlatform.js b/TUICallKit-Vue3/tongji/endless-game/composables/useGamePlatform.js index 10b37b98..7d30bd5c 100644 --- a/TUICallKit-Vue3/tongji/endless-game/composables/useGamePlatform.js +++ b/TUICallKit-Vue3/tongji/endless-game/composables/useGamePlatform.js @@ -182,7 +182,14 @@ export function useGamePlatform(proxy, ensureLoggedIn) { throw new Error(res?.msg || '成绩保存失败') } applyLeaderboard(res.data, payload.session_key) - queuedPayloads = queuedPayloads.filter(item => item.session_key !== payload.session_key) + // 请求发出后玩家可能又完成了消除。只移除已经被本次请求覆盖的进度, + // 不能按 session_key 整局删除,否则会丢失请求进行期间产生的新进度。 + queuedPayloads = queuedPayloads.filter(item => ( + item.session_key !== payload.session_key + || Number(item.learned_count) > Number(payload.learned_count) + || Number(item.score) > Number(payload.score) + || Number(item.ended) > Number(payload.ended) + )) persistPendingPayloads() } catch (_) { connected.value = false diff --git a/TUICallKit-Vue3/tongji/endless-game/index.vue b/TUICallKit-Vue3/tongji/endless-game/index.vue index 4275ca15..4607b3a1 100644 --- a/TUICallKit-Vue3/tongji/endless-game/index.vue +++ b/TUICallKit-Vue3/tongji/endless-game/index.vue @@ -302,7 +302,7 @@ import { onLoad, onShareAppMessage, onShareTimeline, onShow, onUnload } from '@d import TongjiIcon from './components/TongjiIcon.vue' import { useGameSfx } from './composables/useGameSfx.js' import { useGamePlatform } from './composables/useGamePlatform.js' -import { useTongjiAuth } from '../composables/useTongjiAuth.js' +import { useGameAuth } from './composables/useGameAuth.js' const FOOD_IMAGE_MODULES = import.meta.glob('./assets/food/*.jpg', { eager: true, @@ -398,7 +398,7 @@ const { } = useGameSfx() const { proxy } = getCurrentInstance() -const { ensureLoggedIn } = useTongjiAuth(proxy) +const { ensureLoggedIn } = useGameAuth(proxy) const { connected: platformConnected, loading: platformLoading, @@ -953,7 +953,11 @@ async function resolveMatches(initialGroups, movedIndex) { const camelMatchCount = Math.max(bentCamelCount, camelGroup?.indices.length || 0) if (camelMatchCount >= 3) applyCamelJackpot(camelMatchCount, remove) const removedTiles = [...remove].map(i => board.value[i]).filter(Boolean) - const removedNormal = removedTiles.filter(tile => !foodOf(tile.key).high) + // 排名统计的是正确认识并消除的普通食物;驼乳是道具,不计入认糖数。 + const removedNormal = removedTiles.filter(tile => { + const food = foodOf(tile.key) + return !food.high && !food.product + }) const fireRemovedHigh = fireClearingIndices.value.some(index => foodOf(board.value[index]?.key).high) const largestMatch = Math.max( ...groups.map(group => group.indices.length), diff --git a/server/app/api/logic/tcm/GamePlatformLogic.php b/server/app/api/logic/tcm/GamePlatformLogic.php index c2eddb92..a675be7a 100644 --- a/server/app/api/logic/tcm/GamePlatformLogic.php +++ b/server/app/api/logic/tcm/GamePlatformLogic.php @@ -195,18 +195,16 @@ class GamePlatformLogic } Db::startTrans(); - $exists = Db::name('tcm_game_share_visit') - ->where('inviter_user_id', $inviterUserId) - ->where('visitor_user_id', $userId) - ->lock(true) - ->find(); - if (!$exists) { - Db::name('tcm_game_share_visit')->insert([ - 'invite_code' => $inviteCode, - 'inviter_user_id' => $inviterUserId, - 'visitor_user_id' => $userId, - 'create_time' => time(), - ]); + $inserted = Db::name('tcm_game_share_visit')->duplicate([ + 'invite_code', + ])->insert([ + 'invite_code' => $inviteCode, + 'inviter_user_id' => $inviterUserId, + 'visitor_user_id' => $userId, + 'create_time' => time(), + ]); + $accepted = $inserted === 1; + if ($accepted) { Db::name('tcm_game_share_invite')->where('id', (int) $invite['id'])->inc('open_count')->update([ 'update_time' => time(), ]); @@ -215,7 +213,7 @@ class GamePlatformLogic $inviter = Db::name('user')->where('id', $inviterUserId)->field('nickname')->find(); return [ - 'accepted' => !$exists, + 'accepted' => $accepted, 'message' => '已加入控糖消消乐', 'inviter' => self::displayName((string) ($inviter['nickname'] ?? ''), $inviterUserId), ]; @@ -228,15 +226,18 @@ class GamePlatformLogic private static function ensureWeeklyScore(int $userId, string $weekStart): array { + $profile = self::userProfile($userId); + $now = time(); $existing = Db::name('tcm_game_weekly_score') ->where('week_start', $weekStart) ->where('user_id', $userId) - ->lock(true) ->find(); - $profile = self::userProfile($userId); - $now = time(); if ($existing) { + $existing = Db::name('tcm_game_weekly_score') + ->where('id', (int) $existing['id']) + ->lock(true) + ->find(); $profileChanged = (string) $existing['nickname'] !== $profile['nickname'] || (string) $existing['avatar'] !== $profile['avatar']; if ($profileChanged) { @@ -251,6 +252,35 @@ class GamePlatformLogic return $existing; } + // 每周、每个性别使用一行分配锁串行化首次入组。直接 upsert 锁行, + // 避免空分组上的间隙锁导致首批并发请求互相等待或偶发死锁。 + Db::name('tcm_game_weekly_allocator')->duplicate([ + 'update_time', + ])->insert([ + 'week_start' => $weekStart, + 'sex' => $profile['sex'], + 'create_time'=> $now, + 'update_time'=> $now, + ]); + $allocator = Db::name('tcm_game_weekly_allocator') + ->where('week_start', $weekStart) + ->where('sex', $profile['sex']) + ->lock(true) + ->find(); + if (!$allocator) { + throw new \RuntimeException('同行分配锁创建失败'); + } + + // 等待分配锁期间,同一用户的另一个请求可能已经完成分配。 + $existing = Db::name('tcm_game_weekly_score') + ->where('week_start', $weekStart) + ->where('user_id', $userId) + ->lock(true) + ->find(); + if ($existing) { + return $existing; + } + $group = Db::name('tcm_game_weekly_group') ->where('week_start', $weekStart) ->where('sex', $profile['sex']) @@ -265,7 +295,11 @@ class GamePlatformLogic ->where('sex', $profile['sex']) ->max('group_no'); $groupNo = $maxGroupNo + 1; - $groupId = Db::name('tcm_game_weekly_group')->insertGetId([ + // 首批用户并发进入时可能同时算出相同 group_no。利用唯一键 upsert, + // 让请求汇合到同一组,再锁定该组继续分配,避免偶发 1062/死锁。 + Db::name('tcm_game_weekly_group')->duplicate([ + 'update_time', + ])->insert([ 'week_start' => $weekStart, 'sex' => $profile['sex'], 'group_no' => $groupNo, @@ -273,10 +307,18 @@ class GamePlatformLogic 'create_time' => $now, 'update_time' => $now, ]); - $group = ['id' => $groupId, 'member_count' => 0]; + $group = Db::name('tcm_game_weekly_group') + ->where('week_start', $weekStart) + ->where('sex', $profile['sex']) + ->where('group_no', $groupNo) + ->lock(true) + ->find(); + if (!$group) { + throw new \RuntimeException('同行分组创建失败'); + } } - $scoreId = Db::name('tcm_game_weekly_score')->insertGetId([ + $scoreRow = [ 'group_id' => (int) $group['id'], 'week_start' => $weekStart, 'user_id' => $userId, @@ -289,25 +331,34 @@ class GamePlatformLogic 'sex' => $profile['sex'], 'create_time' => $now, 'update_time' => $now, - ]); - Db::name('tcm_game_weekly_group')->where('id', (int) $group['id'])->update([ - 'member_count' => min(self::GROUP_SIZE, (int) $group['member_count'] + 1), - 'update_time' => $now, - ]); - - return [ - 'id' => $scoreId, - 'group_id' => (int) $group['id'], - 'week_start' => $weekStart, - 'user_id' => $userId, - 'learned_count' => 0, - 'best_score' => 0, - 'games_played' => 0, - 'share_count' => 0, - 'nickname' => $profile['nickname'], - 'avatar' => $profile['avatar'], - 'sex' => $profile['sex'], ]; + $inserted = Db::name('tcm_game_weekly_score')->duplicate([ + 'nickname', + 'avatar', + 'sex', + 'update_time', + ])->insert($scoreRow); + $score = Db::name('tcm_game_weekly_score') + ->where('week_start', $weekStart) + ->where('user_id', $userId) + ->lock(true) + ->find(); + if (!$score) { + throw new \RuntimeException('同行成绩创建失败'); + } + + // 仅新插入时刷新人数;使用实际成绩行数纠正历史并发造成的计数漂移。 + if ($inserted === 1) { + $memberCount = (int) Db::name('tcm_game_weekly_score') + ->where('group_id', (int) $group['id']) + ->count(); + Db::name('tcm_game_weekly_group')->where('id', (int) $group['id'])->update([ + 'member_count' => min(self::GROUP_SIZE, $memberCount), + 'update_time' => $now, + ]); + } + + return $score; } private static function buildLeaderboard( @@ -435,11 +486,12 @@ class GamePlatformLogic for ($attempt = 0; $attempt < 5; $attempt++) { $code = strtoupper(bin2hex(random_bytes(6))); - if (Db::name('tcm_game_share_invite')->where('invite_code', $code)->find()) { - continue; - } $now = time(); - Db::name('tcm_game_share_invite')->insert([ + // 同一用户并发打开榜单时,以 week_start + user_id 唯一键汇合; + // 极小概率随机码撞车时,查询不到本人的记录就继续生成新码。 + Db::name('tcm_game_share_invite')->duplicate([ + 'update_time', + ])->insert([ 'invite_code' => $code, 'user_id' => $userId, 'week_start' => $weekStart, @@ -447,7 +499,13 @@ class GamePlatformLogic 'create_time' => $now, 'update_time' => $now, ]); - return $code; + $invite = Db::name('tcm_game_share_invite') + ->where('week_start', $weekStart) + ->where('user_id', $userId) + ->find(); + if ($invite) { + return (string) $invite['invite_code']; + } } throw new \RuntimeException('分享码生成失败'); } diff --git a/server/sql/1.9.20260717/add_tcm_endless_game_platform.sql b/server/sql/1.9.20260717/add_tcm_endless_game_platform.sql index 6c08d063..1f04dc6e 100644 --- a/server/sql/1.9.20260717/add_tcm_endless_game_platform.sql +++ b/server/sql/1.9.20260717/add_tcm_endless_game_platform.sql @@ -1,5 +1,13 @@ -- 控糖消消乐:真实用户周榜、幂等局记录与轻量分享访问关系 +CREATE TABLE IF NOT EXISTS `zyt_tcm_game_weekly_allocator` ( + `week_start` date NOT NULL COMMENT '周一日期', + `sex` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '同行组性别:0未知 1男 2女', + `create_time` int unsigned NOT NULL DEFAULT 0, + `update_time` int unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (`week_start`, `sex`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='控糖消消乐每周同行分组并发锁'; + CREATE TABLE IF NOT EXISTS `zyt_tcm_game_weekly_group` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `week_start` date NOT NULL COMMENT '周一日期', @@ -68,6 +76,7 @@ CREATE TABLE IF NOT EXISTS `zyt_tcm_game_share_visit` ( `visitor_user_id` int unsigned NOT NULL COMMENT '受邀打开用户ID', `create_time` int unsigned NOT NULL DEFAULT 0, PRIMARY KEY (`id`), - UNIQUE KEY `uk_inviter_visitor` (`inviter_user_id`, `visitor_user_id`), + UNIQUE KEY `uk_invite_visitor` (`invite_code`, `visitor_user_id`), + KEY `idx_inviter` (`inviter_user_id`), KEY `idx_visitor` (`visitor_user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='控糖消消乐分享打开记录(不做强绑定)'; diff --git a/server/sql/1.9.20260717/upgrade_tcm_endless_game_platform_20260717.sql b/server/sql/1.9.20260717/upgrade_tcm_endless_game_platform_20260717.sql new file mode 100644 index 00000000..aa6ed381 --- /dev/null +++ b/server/sql/1.9.20260717/upgrade_tcm_endless_game_platform_20260717.sql @@ -0,0 +1,55 @@ +-- 仅用于已经执行过旧版小游戏建表脚本的数据库。 +-- 新安装直接执行 add_tcm_endless_game_platform.sql 即可。 + +CREATE TABLE IF NOT EXISTS `zyt_tcm_game_weekly_allocator` ( + `week_start` date NOT NULL COMMENT '周一日期', + `sex` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '同行组性别:0未知 1男 2女', + `create_time` int unsigned NOT NULL DEFAULT 0, + `update_time` int unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (`week_start`, `sex`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='控糖消消乐每周同行分组并发锁'; + +SET @has_old_visit_index = ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'zyt_tcm_game_share_visit' + AND index_name = 'uk_inviter_visitor' +); +SET @drop_old_visit_index_sql = IF( + @has_old_visit_index > 0, + 'ALTER TABLE `zyt_tcm_game_share_visit` DROP INDEX `uk_inviter_visitor`', + 'SELECT 1' +); +PREPARE drop_old_visit_index_stmt FROM @drop_old_visit_index_sql; +EXECUTE drop_old_visit_index_stmt; +DEALLOCATE PREPARE drop_old_visit_index_stmt; + +SET @has_invite_visit_index = ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'zyt_tcm_game_share_visit' + AND index_name = 'uk_invite_visitor' +); +SET @add_invite_visit_index_sql = IF( + @has_invite_visit_index = 0, + 'ALTER TABLE `zyt_tcm_game_share_visit` ADD UNIQUE INDEX `uk_invite_visitor` (`invite_code`, `visitor_user_id`)', + 'SELECT 1' +); +PREPARE add_invite_visit_index_stmt FROM @add_invite_visit_index_sql; +EXECUTE add_invite_visit_index_stmt; +DEALLOCATE PREPARE add_invite_visit_index_stmt; + +SET @has_inviter_index = ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'zyt_tcm_game_share_visit' + AND index_name = 'idx_inviter' +); +SET @add_inviter_index_sql = IF( + @has_inviter_index = 0, + 'ALTER TABLE `zyt_tcm_game_share_visit` ADD INDEX `idx_inviter` (`inviter_user_id`)', + 'SELECT 1' +); +PREPARE add_inviter_index_stmt FROM @add_inviter_index_sql; +EXECUTE add_inviter_index_stmt; +DEALLOCATE PREPARE add_inviter_index_stmt;