完善猜成语过期与答题记录逻辑

This commit is contained in:
pllx
2026-04-29 10:32:12 +08:00
parent 2f9b2eed64
commit 5962d6d2b3
11 changed files with 685 additions and 115 deletions
+14 -8
View File
@@ -12,16 +12,20 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\GameConfig;
use App\Models\Idiom;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* 类功能:负责猜成语题库与后台参数管理。
*/
class IdiomController extends Controller
{
/**
* 显示所有成语题目列表
* 方法功能:显示所有成语题目列表
*/
public function index(): View
{
@@ -31,7 +35,7 @@ class IdiomController extends Controller
}
/**
* 创建新题目
* 方法功能:创建新的成语题目
*/
public function store(Request $request): RedirectResponse
{
@@ -49,7 +53,7 @@ class IdiomController extends Controller
}
/**
* 更新题目
* 方法功能:更新已有成语题目
*/
public function update(Request $request, Idiom $idiom): RedirectResponse
{
@@ -67,7 +71,7 @@ class IdiomController extends Controller
}
/**
* 切换启用/禁用(AJAX
* 方法功能:通过 AJAX 切换题目的启用状态。
*/
public function toggle(Idiom $idiom): JsonResponse
{
@@ -81,7 +85,7 @@ class IdiomController extends Controller
}
/**
* 删除题目
* 方法功能:删除指定成语题目
*/
public function destroy(Idiom $idiom): RedirectResponse
{
@@ -92,7 +96,7 @@ class IdiomController extends Controller
}
/**
* 保存猜成语游戏参数(仅更新 GameConfig params,不影响其他字段
* 方法功能:保存猜成语游戏参数而不覆盖其他游戏配置字段
*/
public function saveSettings(Request $request): RedirectResponse
{
@@ -100,19 +104,21 @@ class IdiomController extends Controller
'reward_gold' => 'required|integer|min:0',
'reward_exp' => 'required|integer|min:0',
'auto_start_interval' => 'required|integer|min:0',
'expire_minutes' => 'required|integer|min:0',
]);
$config = \App\Models\GameConfig::firstOrCreate(
$config = GameConfig::firstOrCreate(
['game_key' => 'idiom'],
['name' => '猜成语', 'icon' => '🧩', 'enabled' => false],
);
// 合并现有 params,只覆盖提交的字段,不影响其他已有参数
// 合并现有 params,只覆盖本次后台提交的字段,避免误删其他游戏参数
$existingParams = $config->params ?? [];
$config->params = array_merge($existingParams, [
'reward_gold' => (int) $data['reward_gold'],
'reward_exp' => (int) $data['reward_exp'],
'auto_start_interval' => (int) $data['auto_start_interval'],
'expire_minutes' => (int) $data['expire_minutes'],
]);
$config->save();
$config->clearCache();
+43 -19
View File
@@ -18,15 +18,23 @@ use App\Models\GameConfig;
use App\Models\Idiom;
use App\Models\IdiomGameRound;
use App\Services\ChatStateService;
use App\Services\IdiomGameService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* 类功能:处理猜成语出题、答题和当前回合查询。
*/
class IdiomQuizController extends Controller
{
/**
* 方法功能:注入猜成语所需的状态、结算与奖励服务。
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly IdiomGameService $idiomGameService,
private readonly UserCurrencyService $currencyService,
) {}
@@ -37,7 +45,7 @@ class IdiomQuizController extends Controller
{
$user = Auth::user();
// 权限校验:仅站长或 superlevel
// 权限校验:仅站长或具备后台身份的管理用户可手动出题。
if (! $user || ($user->id !== 1 && ! $request->user()?->activePosition)) {
return response()->json(['status' => 'error', 'message' => '无权限'], 403);
}
@@ -47,7 +55,10 @@ class IdiomQuizController extends Controller
return response()->json(['status' => 'error', 'message' => '缺少房间 ID'], 422);
}
// 检查是否有进行中的回合
// 先清理该房间已超时但未结算的旧回合,避免它们长期卡住新题。
$this->idiomGameService->expireActiveRoundsForRoom($roomId);
// 清理后再检查是否还有真正进行中的回合。
$activeRound = IdiomGameRound::where('room_id', $roomId)
->whereIn('status', ['pending', 'active'])
->first();
@@ -64,13 +75,13 @@ class IdiomQuizController extends Controller
return response()->json(['status' => 'error', 'message' => '题库中没有可用的题目,请先在后台添加。'], 400);
}
// 读取游戏配置
// 读取游戏配置,保证手动出题和自动出题使用同一组奖励参数。
$config = GameConfig::forGame('idiom');
$params = $config?->params ?? [];
$rewardGold = (int) ($params['reward_gold'] ?? 50);
$rewardExp = (int) ($params['reward_exp'] ?? 30);
// 创建新回合
// 创建新回合,并以 started_at 作为后续过期计时的起点。
$round = IdiomGameRound::create([
'room_id' => $roomId,
'idiom_id' => $idiom->id,
@@ -80,7 +91,7 @@ class IdiomQuizController extends Controller
'started_at' => now(),
]);
// 广播到聊天室
// 广播到聊天室,让前端即时展示题目提示与答题按钮。
broadcast(new IdiomGameStarted(
roomId: $roomId,
hint: $idiom->hint,
@@ -89,7 +100,7 @@ class IdiomQuizController extends Controller
rewardExp: $rewardExp,
));
// 同时推一条 MessageSent 消息(显示在聊天窗口)
// 同时推一条公屏消息,兼容现有聊天窗口的消息渲染链路。
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
@@ -119,10 +130,7 @@ class IdiomQuizController extends Controller
}
/**
* 提交答案(POST
*
* @param Request $request
* @return JsonResponse
* 方法功能:提交当前猜成语回合的答案。
*/
public function answer(Request $request): JsonResponse
{
@@ -145,6 +153,11 @@ class IdiomQuizController extends Controller
return response()->json(['status' => 'error', 'message' => '回合不存在'], 404);
}
// 判题前先处理超时,避免用户在无效回合上继续抢答。
if ($this->idiomGameService->expireRound($round)) {
return response()->json(['status' => 'error', 'message' => '该回合已超时结束'], 400);
}
if ($round->status !== 'active') {
if ($round->status === 'answered') {
return response()->json([
@@ -152,10 +165,11 @@ class IdiomQuizController extends Controller
'message' => "这道题已被「{$round->winner_username}」抢先答对了!",
], 400);
}
return response()->json(['status' => 'error', 'message' => '该回合已结束'], 400);
}
// 校验答案忽略空格和全半角)
// 校验答案忽略空格与大小写差异,降低正常输入误判率。
$normalizedAnswer = str_replace(' ', '', $userAnswer);
$normalizedCorrect = str_replace(' ', '', $round->idiom->answer);
if (mb_strtolower($normalizedAnswer) !== mb_strtolower($normalizedCorrect)) {
@@ -165,7 +179,7 @@ class IdiomQuizController extends Controller
], 200);
}
// 答对了!加锁防并发(Redis
// 答对后立即加 Redis 锁,防止多人并发提交造成重复领奖。
$lockKey = "idiom:answer_lock:{$roundId}";
if (! \Illuminate\Support\Facades\Redis::setnx($lockKey, 1)) {
return response()->json([
@@ -175,7 +189,7 @@ class IdiomQuizController extends Controller
}
\Illuminate\Support\Facades\Redis::expire($lockKey, 10);
// 更新回合状态
// 抢答成功后立刻封盘,确保后续请求统一看到 answered 状态
$round->update([
'status' => 'answered',
'winner_id' => $user->id,
@@ -183,7 +197,7 @@ class IdiomQuizController extends Controller
'ended_at' => now(),
]);
// 发放奖励
// 奖励仍沿用现有金币与经验发放逻辑,避免行为回归。
if ($round->reward_gold > 0) {
$this->currencyService->change(
$user, 'gold', $round->reward_gold,
@@ -197,7 +211,7 @@ class IdiomQuizController extends Controller
$user->save();
}
// 广播结果(前端通过 IdiomGameAnswered 事件做分屏显示)
// 广播结果,让房间内用户立即看到答题成功公告。
broadcast(new IdiomGameAnswered(
roomId: $roomId,
roundId: $round->id,
@@ -207,16 +221,21 @@ class IdiomQuizController extends Controller
rewardExp: $round->reward_exp,
));
// 存聊天记录(不广播,避免重复显示)
// 存聊天记录但不再次广播,避免和上面的实时事件重复刷屏。
$resultMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '星海小博士',
'to_user' => '大家',
'content' => "🎉 恭喜 {$user->username} 率先答对成语「{$round->idiom->answer}」,获得 {$round->reward_gold} 金币、{$round->reward_exp} 经验!",
'content' => "🎉 {$user->username}率先答对成语「{$round->idiom->answer}」,获得 {$round->reward_gold} 金币、{$round->reward_exp} 经验!",
'is_secret' => false,
'font_color' => '#16a34a',
'action' => '',
'action' => 'idiom_result',
'winner_username' => $user->username,
'idiom_answer' => $round->idiom->answer,
'idiom_result_reward_gold' => $round->reward_gold,
'idiom_result_reward_exp' => $round->reward_exp,
'idiom_game_round_ended_id' => $round->id,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $resultMsg);
@@ -235,7 +254,7 @@ class IdiomQuizController extends Controller
}
/**
* 查询当前进行中回合
* 方法功能:查询当前房间的进行中回合
*/
public function current(Request $request): JsonResponse
{
@@ -253,6 +272,11 @@ class IdiomQuizController extends Controller
return response()->json(['status' => 'success', 'data' => null]);
}
// 当前接口不再暴露已过期回合,避免前端继续显示无效答题入口。
if ($this->idiomGameService->expireRound($round)) {
return response()->json(['status' => 'success', 'data' => null]);
}
return response()->json([
'status' => 'success',
'data' => [
+16
View File
@@ -15,8 +15,16 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 类功能:记录猜成语每一轮的题目、奖励与结算状态。
*/
class IdiomGameRound extends Model
{
/**
* 方法功能:声明可批量赋值的回合字段。
*
* @var array<int, string>
*/
protected $fillable = [
'room_id',
'idiom_id',
@@ -29,6 +37,11 @@ class IdiomGameRound extends Model
'ended_at',
];
/**
* 方法功能:定义回合字段的类型转换规则。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
@@ -39,6 +52,9 @@ class IdiomGameRound extends Model
];
}
/**
* 方法功能:关联本回合对应的成语题目。
*/
public function idiom(): BelongsTo
{
return $this->belongsTo(Idiom::class);
+124
View File
@@ -0,0 +1,124 @@
<?php
/**
* 文件功能:猜成语游戏回合服务
*
* 统一处理猜成语回合的过期判定、超时结算和系统消息广播,
* 避免控制器与定时任务各自维护一套回合状态逻辑。
*/
namespace App\Services;
use App\Events\MessageSent;
use App\Models\GameConfig;
use App\Models\IdiomGameRound;
/**
* 类功能:提供猜成语回合过期与结算能力。
*/
class IdiomGameService
{
/**
* 方法功能:注入聊天室状态服务,复用现有公屏消息推送链路。
*/
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 方法功能:读取猜成语题目的有效时长配置,单位分钟。
*/
public function getExpireMinutes(): int
{
return max(0, (int) GameConfig::param('idiom', 'expire_minutes', 5));
}
/**
* 方法功能:判断指定回合是否已经超过有效时长。
*/
public function isRoundExpired(IdiomGameRound $round): bool
{
$expireMinutes = $this->getExpireMinutes();
if ($expireMinutes <= 0) {
return false;
}
if (! in_array($round->status, ['pending', 'active'], true)) {
return false;
}
if (! $round->started_at) {
return false;
}
return $round->started_at->copy()->addMinutes($expireMinutes)->lte(now());
}
/**
* 方法功能:结算并结束已过期的回合,必要时发送超时公告。
*/
public function expireRound(IdiomGameRound $round, bool $announce = true): bool
{
if (! $this->isRoundExpired($round)) {
return false;
}
$round->loadMissing('idiom');
// 已过期的回合统一落为 ended,防止继续答题或阻塞新开题。
$round->update([
'status' => 'ended',
'ended_at' => $round->ended_at ?? now(),
]);
if ($announce) {
$this->pushExpiredRoundMessage($round);
}
return true;
}
/**
* 方法功能:批量清理指定房间内已超时但仍处于进行中的回合。
*/
public function expireActiveRoundsForRoom(int $roomId, bool $announce = true): int
{
$expiredCount = 0;
IdiomGameRound::with('idiom')
->where('room_id', $roomId)
->whereIn('status', ['pending', 'active'])
->orderBy('id')
->get()
->each(function (IdiomGameRound $round) use ($announce, &$expiredCount): void {
if ($this->expireRound($round, $announce)) {
$expiredCount++;
}
});
return $expiredCount;
}
/**
* 方法功能:向公屏推送猜成语超时公告。
*/
public function pushExpiredRoundMessage(IdiomGameRound $round): void
{
$answer = $round->idiom?->answer ?? '未知答案';
$message = [
'id' => $this->chatState->nextMessageId($round->room_id),
'room_id' => $round->room_id,
'from_user' => '星海小博士',
'to_user' => '大家',
'content' => "⌛ 本轮猜成语已超时结束,正确答案是「{$answer}」。",
'is_secret' => false,
'font_color' => '#f59e0b',
'action' => '',
'idiom_game_round_ended_id' => $round->id,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($round->room_id, $message);
broadcast(new MessageSent($round->room_id, $message));
}
}