531 lines
20 KiB
PHP
531 lines
20 KiB
PHP
<?php
|
||
|
||
namespace app\api\logic\tcm;
|
||
|
||
use app\common\service\FileService;
|
||
use think\facade\Db;
|
||
use think\facade\Log;
|
||
|
||
/**
|
||
* 控糖消消乐平台能力:真实用户、每周7人同行榜、幂等成绩上报和微信分享。
|
||
*/
|
||
class GamePlatformLogic
|
||
{
|
||
private const GROUP_SIZE = 7;
|
||
private const MAX_SESSION_LEARNED = 20000;
|
||
private const MAX_SCORE = 100000000;
|
||
|
||
protected static string $error = '';
|
||
|
||
public static function getError(): string
|
||
{
|
||
return self::$error;
|
||
}
|
||
|
||
protected static function fail(string $message): bool
|
||
{
|
||
self::$error = $message;
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 获取当前用户所在的真实周榜;首次进入会分配到当周同性别7人组。
|
||
*/
|
||
public static function leaderboard(int $userId): array|false
|
||
{
|
||
self::$error = '';
|
||
if ($userId <= 0) {
|
||
return self::fail('请先登录');
|
||
}
|
||
|
||
try {
|
||
Db::startTrans();
|
||
$score = self::ensureWeeklyScore($userId, self::weekStart());
|
||
$inviteCode = self::ensureShareInvite($userId, self::weekStart());
|
||
Db::commit();
|
||
return self::buildLeaderboard((int) $score['group_id'], $userId, $inviteCode);
|
||
} catch (\Throwable $e) {
|
||
Db::rollback();
|
||
self::logException('leaderboard', $e);
|
||
return self::fail('同行榜暂时不可用,请稍后再试');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 上报每局绝对进度。session_key + learned_count 共同保证网络重试不会重复计分。
|
||
*
|
||
* @param array{session_key:string,learned_count:int,score:int,ended:int|bool} $params
|
||
*/
|
||
public static function submitProgress(int $userId, array $params): array|false
|
||
{
|
||
self::$error = '';
|
||
if ($userId <= 0) {
|
||
return self::fail('请先登录');
|
||
}
|
||
|
||
$sessionKey = trim((string) ($params['session_key'] ?? ''));
|
||
if (!preg_match('/^[A-Za-z0-9_-]{16,64}$/', $sessionKey)) {
|
||
return self::fail('游戏局标识无效');
|
||
}
|
||
$learned = max(0, min(self::MAX_SESSION_LEARNED, (int) ($params['learned_count'] ?? 0)));
|
||
$scoreValue = max(0, min(self::MAX_SCORE, (int) ($params['score'] ?? 0)));
|
||
$ended = !empty($params['ended']) ? 1 : 0;
|
||
$weekStart = self::weekStart();
|
||
|
||
try {
|
||
Db::startTrans();
|
||
$weeklyScore = self::ensureWeeklyScore($userId, $weekStart);
|
||
$session = Db::name('tcm_game_session')
|
||
->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()
|
||
));
|
||
}
|
||
}
|