where('session_key', $sessionKey) ->lock(true) ->find(); $now = time(); if ($session && (int) $session['user_id'] !== $userId) { Db::rollback(); return self::fail('游戏局标识已被使用'); } if (!$session) { $sessionId = Db::name('tcm_game_session')->insertGetId([ 'session_key' => $sessionKey, 'user_id' => $userId, 'week_start' => $weekStart, 'learned_count' => 0, 'last_score' => 0, 'ended' => 0, 'create_time' => $now, 'update_time' => $now, ]); $session = [ 'id' => $sessionId, 'week_start' => $weekStart, 'learned_count' => 0, 'last_score' => 0, 'ended' => 0, ]; } $sessionWeek = (string) $session['week_start']; if ($sessionWeek !== $weekStart) { $weeklyScore = self::ensureWeeklyScore($userId, $sessionWeek); } $confirmedLearned = (int) $session['learned_count']; $nextLearned = max($confirmedLearned, $learned); $delta = $nextLearned - $confirmedLearned; $wasEnded = (int) $session['ended'] === 1; $markEnded = $wasEnded || $ended === 1; Db::name('tcm_game_session')->where('id', (int) $session['id'])->update([ 'learned_count' => $nextLearned, 'last_score' => max((int) $session['last_score'], $scoreValue), 'ended' => $markEnded ? 1 : 0, 'update_time' => $now, ]); $weeklyLearned = (int) $weeklyScore['learned_count'] + $delta; $weeklyBest = max((int) $weeklyScore['best_score'], $scoreValue); $gamesPlayed = (int) $weeklyScore['games_played'] + (!$wasEnded && $ended === 1 ? 1 : 0); Db::name('tcm_game_weekly_score')->where('id', (int) $weeklyScore['id'])->update([ 'learned_count' => $weeklyLearned, 'best_score' => $weeklyBest, 'games_played' => $gamesPlayed, 'update_time' => $now, ]); $inviteCode = self::ensureShareInvite($userId, $sessionWeek); Db::commit(); $result = self::buildLeaderboard((int) $weeklyScore['group_id'], $userId, $inviteCode, $sessionWeek); $result['confirmed_session_learned'] = $nextLearned; return $result; } catch (\Throwable $e) { Db::rollback(); self::logException('submitProgress', $e); return self::fail('成绩保存失败,请稍后再试'); } } /** 记录用户发起一次微信分享,并返回当前分享码。 */ public static function recordShare(int $userId): array|false { self::$error = ''; if ($userId <= 0) { return self::fail('请先登录'); } try { Db::startTrans(); $score = self::ensureWeeklyScore($userId, self::weekStart()); Db::name('tcm_game_weekly_score')->where('id', (int) $score['id'])->inc('share_count')->update([ 'update_time' => time(), ]); $inviteCode = self::ensureShareInvite($userId, self::weekStart()); Db::commit(); return ['invite_code' => $inviteCode]; } catch (\Throwable $e) { Db::rollback(); self::logException('recordShare', $e); return self::fail('分享记录失败'); } } /** * 记录从分享卡片进入。仅记录一次轻量同行关系,不做家庭/好友强绑定。 */ public static function acceptShare(int $userId, string $inviteCode): array|false { self::$error = ''; $inviteCode = strtoupper(trim($inviteCode)); if ($userId <= 0) { return self::fail('请先登录'); } if (!preg_match('/^[A-F0-9]{12}$/', $inviteCode)) { return self::fail('分享码无效'); } try { $invite = Db::name('tcm_game_share_invite')->where('invite_code', $inviteCode)->find(); if (!$invite) { return self::fail('分享已失效'); } $inviterUserId = (int) $invite['user_id']; if ($inviterUserId === $userId) { return ['accepted' => false, 'message' => '这是您自己的分享']; } Db::startTrans(); $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(), ]); } Db::commit(); $inviter = Db::name('user')->where('id', $inviterUserId)->field('nickname')->find(); return [ 'accepted' => $accepted, 'message' => '已加入控糖消消乐', 'inviter' => self::displayName((string) ($inviter['nickname'] ?? ''), $inviterUserId), ]; } catch (\Throwable $e) { Db::rollback(); self::logException('acceptShare', $e); return self::fail('分享关系记录失败'); } } 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) ->find(); 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) { Db::name('tcm_game_weekly_score')->where('id', (int) $existing['id'])->update([ 'nickname' => $profile['nickname'], 'avatar' => $profile['avatar'], 'update_time'=> $now, ]); $existing['nickname'] = $profile['nickname']; $existing['avatar'] = $profile['avatar']; } 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']) ->where('member_count', '<', self::GROUP_SIZE) ->order('group_no', 'asc') ->lock(true) ->find(); if (!$group) { $maxGroupNo = (int) Db::name('tcm_game_weekly_group') ->where('week_start', $weekStart) ->where('sex', $profile['sex']) ->max('group_no'); $groupNo = $maxGroupNo + 1; // 首批用户并发进入时可能同时算出相同 group_no。利用唯一键 upsert, // 让请求汇合到同一组,再锁定该组继续分配,避免偶发 1062/死锁。 Db::name('tcm_game_weekly_group')->duplicate([ 'update_time', ])->insert([ 'week_start' => $weekStart, 'sex' => $profile['sex'], 'group_no' => $groupNo, 'member_count'=> 0, 'create_time' => $now, 'update_time' => $now, ]); $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('同行分组创建失败'); } } $scoreRow = [ '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'], 'create_time' => $now, 'update_time' => $now, ]; $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( int $groupId, int $userId, string $inviteCode, ?string $weekStart = null ): array { $weekStart = $weekStart ?: self::weekStart(); $rows = Db::name('tcm_game_weekly_score') ->where('group_id', $groupId) ->where('week_start', $weekStart) ->order('learned_count', 'desc') ->order('best_score', 'desc') ->order('create_time', 'asc') ->limit(self::GROUP_SIZE) ->select() ->toArray(); $players = []; $myIndex = 0; foreach ($rows as $index => $row) { $isMe = (int) $row['user_id'] === $userId; if ($isMe) { $myIndex = $index; } $players[] = [ // 前端只需要稳定列表键,不暴露平台内部 user_id。 'id' => (int) $row['id'], 'name' => self::displayName((string) $row['nickname'], (int) $row['user_id']), 'avatar' => self::avatarUrl((string) $row['avatar']), 'count' => (int) $row['learned_count'], 'best_score' => (int) $row['best_score'], 'rank' => $index + 1, 'is_me' => $isMe, ]; } $me = $players[$myIndex] ?? [ 'count' => 0, 'rank' => 1, 'best_score' => 0, ]; $distance = $myIndex > 0 ? max(1, (int) $players[$myIndex - 1]['count'] - (int) $me['count'] + 1) : 0; $group = Db::name('tcm_game_weekly_group')->where('id', $groupId)->find(); $sex = (int) ($group['sex'] ?? 0); return [ 'week_start' => $weekStart, 'week_end' => date('Y-m-d', strtotime($weekStart . ' +6 days')), 'sex' => $sex, 'sex_label' => $sex === 1 ? '男士同行' : ($sex === 2 ? '女士同行' : '同行'), 'group_size' => self::GROUP_SIZE, 'member_count'=> count($players), 'players' => $players, 'me' => [ 'count' => (int) ($me['count'] ?? 0), 'rank' => (int) ($me['rank'] ?? 1), 'best_score' => (int) ($me['best_score'] ?? 0), 'distance' => $distance, 'is_first' => $myIndex === 0, ], 'invite_code' => $inviteCode, ]; } /** @return array{nickname:string,avatar:string,sex:int} */ private static function userProfile(int $userId): array { $user = Db::name('user') ->where('id', $userId) ->whereNull('delete_time') ->field('id,sn,nickname,avatar,sex') ->find(); if (!$user) { throw new \RuntimeException('用户不存在'); } $sex = (int) ($user['sex'] ?? 0); if ($sex !== 1 && $sex !== 2) { $gender = Db::name('diagnosis_view_records')->alias('v') ->join('tcm_diagnosis d', 'd.id = v.diagnosis_id') ->where('v.user_id', $userId) ->whereNull('v.delete_time') ->whereNull('d.delete_time') ->order('v.id', 'desc') ->value('d.gender'); if ($gender !== null && $gender !== '') { $sex = (int) $gender === 1 ? 1 : 2; } } return [ 'nickname' => self::displayName((string) ($user['nickname'] ?? ''), $userId), 'avatar' => (string) ($user['avatar'] ?? ''), 'sex' => in_array($sex, [1, 2], true) ? $sex : 0, ]; } private static function displayName(string $nickname, int $userId): string { $nickname = trim(strip_tags($nickname)); if ($nickname === '') { return '控糖好友' . substr((string) $userId, -2); } return mb_substr($nickname, 0, 12); } private static function avatarUrl(string $avatar): string { return $avatar === '' ? '' : FileService::getFileUrl($avatar); } private static function ensureShareInvite(int $userId, string $weekStart): string { $existing = Db::name('tcm_game_share_invite') ->where('week_start', $weekStart) ->where('user_id', $userId) ->find(); if ($existing) { return (string) $existing['invite_code']; } for ($attempt = 0; $attempt < 5; $attempt++) { $code = strtoupper(bin2hex(random_bytes(6))); $now = time(); // 同一用户并发打开榜单时,以 week_start + user_id 唯一键汇合; // 极小概率随机码撞车时,查询不到本人的记录就继续生成新码。 Db::name('tcm_game_share_invite')->duplicate([ 'update_time', ])->insert([ 'invite_code' => $code, 'user_id' => $userId, 'week_start' => $weekStart, 'open_count' => 0, 'create_time' => $now, 'update_time' => $now, ]); $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('分享码生成失败'); } private static function weekStart(?int $timestamp = null): string { $timestamp = $timestamp ?: time(); $day = (int) date('N', $timestamp); return date('Y-m-d', strtotime('-' . ($day - 1) . ' days', $timestamp)); } private static function logException(string $action, \Throwable $e): void { Log::error(sprintf( 'tcm endless game %s failed: %s at %s:%d', $action, $e->getMessage(), $e->getFile(), $e->getLine() )); } }