重构猜谜活动并统一聊天室答题通知
This commit is contained in:
@@ -1,128 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜成语题库后台管理控制器
|
||||
* 提供成语题目的列表展示、创建、编辑、删除、启用/禁用功能
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
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
|
||||
{
|
||||
$idioms = Idiom::orderBy('sort')->orderBy('id')->get();
|
||||
|
||||
return view('admin.idioms.index', compact('idioms'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:创建新的成语题目。
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'answer' => 'required|string|max:50',
|
||||
'hint' => 'required|string|max:255',
|
||||
'sort' => 'required|integer|min:0',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$data['is_active'] = $request->boolean('is_active', true);
|
||||
Idiom::create($data);
|
||||
|
||||
return redirect()->route('admin.idioms.index')->with('success', '成语题目已添加!');
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:更新已有成语题目。
|
||||
*/
|
||||
public function update(Request $request, Idiom $idiom): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'answer' => 'required|string|max:50',
|
||||
'hint' => 'required|string|max:255',
|
||||
'sort' => 'required|integer|min:0',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
$idiom->update($data);
|
||||
|
||||
return redirect()->route('admin.idioms.index')->with('success', "题目「{$idiom->answer}」已更新!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:通过 AJAX 切换题目的启用状态。
|
||||
*/
|
||||
public function toggle(Idiom $idiom): JsonResponse
|
||||
{
|
||||
$idiom->update(['is_active' => ! $idiom->is_active]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'is_active' => $idiom->is_active,
|
||||
'message' => $idiom->is_active ? "「{$idiom->answer}」已启用" : "「{$idiom->answer}」已禁用",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:删除指定成语题目。
|
||||
*/
|
||||
public function destroy(Idiom $idiom): RedirectResponse
|
||||
{
|
||||
$answer = $idiom->answer;
|
||||
$idiom->delete();
|
||||
|
||||
return redirect()->route('admin.idioms.index')->with('success', "题目「{$answer}」已删除!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:保存猜成语游戏参数而不覆盖其他游戏配置字段。
|
||||
*/
|
||||
public function saveSettings(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'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 = GameConfig::firstOrCreate(
|
||||
['game_key' => 'idiom'],
|
||||
['name' => '猜成语', 'icon' => '🧩', 'enabled' => false],
|
||||
);
|
||||
|
||||
// 合并现有 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();
|
||||
|
||||
return redirect()->route('admin.idioms.index')->with('success', '游戏参数已保存!');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜谜活动题库后台管理控制器
|
||||
*
|
||||
* 负责后台题库的列表筛选、题目增删改和启用状态切换。
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Riddle;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:统一处理猜谜活动题库的后台管理动作。
|
||||
*/
|
||||
class RiddleController extends Controller
|
||||
{
|
||||
/**
|
||||
* 方法功能:显示题库列表,并支持按题型和关键词筛选。
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$typeOptions = Riddle::typeOptions();
|
||||
$selectedType = trim((string) $request->query('type', ''));
|
||||
$keyword = trim((string) $request->query('keyword', ''));
|
||||
|
||||
$idiomQuery = Riddle::query();
|
||||
|
||||
if ($selectedType !== '' && isset($typeOptions[$selectedType])) {
|
||||
// 题型筛选只接受系统支持值,避免非法参数污染查询。
|
||||
$idiomQuery->ofType($selectedType);
|
||||
}
|
||||
|
||||
if ($keyword !== '') {
|
||||
// 关键词同时匹配答案与提示,方便后台快速定位题目。
|
||||
$idiomQuery->where(function ($query) use ($keyword): void {
|
||||
$query->where('answer', 'like', '%'.$keyword.'%')
|
||||
->orWhere('hint', 'like', '%'.$keyword.'%');
|
||||
});
|
||||
}
|
||||
|
||||
$idioms = $idiomQuery
|
||||
->orderBy('type')
|
||||
->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$typeStats = Riddle::query()
|
||||
->selectRaw('type, COUNT(*) as total')
|
||||
->groupBy('type')
|
||||
->pluck('total', 'type')
|
||||
->all();
|
||||
|
||||
return view('admin.riddles.index', [
|
||||
'idioms' => $idioms,
|
||||
'typeOptions' => $typeOptions,
|
||||
'selectedType' => $selectedType,
|
||||
'keyword' => $keyword,
|
||||
'typeStats' => $typeStats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:创建新的猜谜活动题目。
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $this->validateRiddlePayload($request);
|
||||
|
||||
// 新增时默认启用,便于后台批量补题后立即可用。
|
||||
$data['is_active'] = $request->boolean('is_active', true);
|
||||
Riddle::create($data);
|
||||
|
||||
$typeLabel = Riddle::labelForType($data['type']);
|
||||
|
||||
return redirect()
|
||||
->route('admin.riddles.index', $this->buildIndexFilters($request))
|
||||
->with('success', "{$typeLabel}题目已添加!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:更新已有题目内容与题型。
|
||||
*/
|
||||
public function update(Request $request, Riddle $idiom): RedirectResponse
|
||||
{
|
||||
$data = $this->validateRiddlePayload($request);
|
||||
|
||||
// 编辑时显式按复选框结果落库,避免旧状态残留。
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
$idiom->update($data);
|
||||
|
||||
return redirect()
|
||||
->route('admin.riddles.index', $this->buildIndexFilters($request))
|
||||
->with('success', "题目「{$idiom->answer}」已更新!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:通过 AJAX 切换题目的启用状态。
|
||||
*/
|
||||
public function toggle(Riddle $idiom): JsonResponse
|
||||
{
|
||||
// 开关按钮只变更启用状态,不改动其他题库字段。
|
||||
$idiom->update(['is_active' => ! $idiom->is_active]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'is_active' => $idiom->is_active,
|
||||
'message' => $idiom->is_active ? "「{$idiom->answer}」已启用" : "「{$idiom->answer}」已禁用",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:删除指定题目。
|
||||
*/
|
||||
public function destroy(Request $request, Riddle $idiom): RedirectResponse
|
||||
{
|
||||
$answer = $idiom->answer;
|
||||
$idiom->delete();
|
||||
|
||||
return redirect()
|
||||
->route('admin.riddles.index', $this->buildIndexFilters($request))
|
||||
->with('success', "题目「{$answer}」已删除!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:校验后台题库保存载荷。
|
||||
*
|
||||
* @return array{type:string,answer:string,hint:string,sort:int}
|
||||
*/
|
||||
private function validateRiddlePayload(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'type' => ['required', 'string', Rule::in(Riddle::supportedTypes())],
|
||||
'answer' => ['required', 'string', 'max:120'],
|
||||
'hint' => ['required', 'string', 'max:255'],
|
||||
'sort' => ['required', 'integer', 'min:0'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:保留列表筛选参数,方便后台操作后返回原筛选结果。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildIndexFilters(Request $request): array
|
||||
{
|
||||
$filters = [];
|
||||
$type = trim((string) $request->input('redirect_type', $request->query('type', '')));
|
||||
$keyword = trim((string) $request->input('redirect_keyword', $request->query('keyword', '')));
|
||||
|
||||
if ($type !== '') {
|
||||
$filters['type'] = $type;
|
||||
}
|
||||
|
||||
if ($keyword !== '') {
|
||||
$filters['keyword'] = $keyword;
|
||||
}
|
||||
|
||||
return $filters;
|
||||
}
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜成语游戏控制器
|
||||
*
|
||||
* 负责出题、答题、查询当前回合状态等游戏核心逻辑。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\IdiomGameAnswered;
|
||||
use App\Events\IdiomGameStarted;
|
||||
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,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 管理员手动出题(POST)
|
||||
*/
|
||||
public function start(Request $request): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// 权限校验:仅站长或具备后台身份的管理用户可手动出题。
|
||||
if (! $user || ($user->id !== 1 && ! $request->user()?->activePosition)) {
|
||||
return response()->json(['status' => 'error', 'message' => '无权限'], 403);
|
||||
}
|
||||
|
||||
$roomId = (int) $request->input('room_id', 0);
|
||||
if ($roomId <= 0) {
|
||||
return response()->json(['status' => 'error', 'message' => '缺少房间 ID'], 422);
|
||||
}
|
||||
|
||||
// 先清理该房间已超时但未结算的旧回合,避免它们长期卡住新题。
|
||||
$this->idiomGameService->expireActiveRoundsForRoom($roomId);
|
||||
|
||||
// 清理后再检查是否还有真正进行中的回合。
|
||||
$activeRound = IdiomGameRound::where('room_id', $roomId)
|
||||
->whereIn('status', ['pending', 'active'])
|
||||
->first();
|
||||
if ($activeRound) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '当前房间已有进行中的猜成语题目,请先结束当前回合。',
|
||||
], 400);
|
||||
}
|
||||
|
||||
// 随机选一道启用的题目
|
||||
$idiom = Idiom::where('is_active', true)->inRandomOrder()->first();
|
||||
if (! $idiom) {
|
||||
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,
|
||||
'status' => 'active',
|
||||
'reward_gold' => $rewardGold,
|
||||
'reward_exp' => $rewardExp,
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
// 广播到聊天室,让前端即时展示题目提示与答题按钮。
|
||||
broadcast(new IdiomGameStarted(
|
||||
roomId: $roomId,
|
||||
hint: $idiom->hint,
|
||||
roundId: $round->id,
|
||||
rewardGold: $rewardGold,
|
||||
rewardExp: $rewardExp,
|
||||
));
|
||||
|
||||
// 同时推一条公屏消息,兼容现有聊天窗口的消息渲染链路。
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '星海小博士',
|
||||
'to_user' => '大家',
|
||||
'content' => "🧩 猜成语时间!{$idiom->hint}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#7c3aed',
|
||||
'action' => '',
|
||||
'idiom_game_round_id' => $round->id,
|
||||
'idiom_reward_gold' => $rewardGold,
|
||||
'idiom_reward_exp' => $rewardExp,
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$this->chatState->pushMessage($roomId, $msg);
|
||||
broadcast(new \App\Events\MessageSent($roomId, $msg));
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'round_id' => $round->id,
|
||||
'hint' => $idiom->hint,
|
||||
'reward_gold' => $rewardGold,
|
||||
'reward_exp' => $rewardExp,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:提交当前猜成语回合的答案。
|
||||
*/
|
||||
public function answer(Request $request): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (! $user) {
|
||||
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
|
||||
}
|
||||
|
||||
$roundId = (int) $request->input('round_id');
|
||||
$userAnswer = trim((string) $request->input('answer', ''));
|
||||
$roomId = (int) $request->input('room_id');
|
||||
|
||||
if ($roundId <= 0 || $userAnswer === '' || $roomId <= 0) {
|
||||
return response()->json(['status' => 'error', 'message' => '参数不完整'], 422);
|
||||
}
|
||||
|
||||
// 查找回合
|
||||
$round = IdiomGameRound::with('idiom')->find($roundId);
|
||||
if (! $round || $round->room_id !== $roomId) {
|
||||
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([
|
||||
'status' => 'error',
|
||||
'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)) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '答案不正确,再想想!',
|
||||
], 200);
|
||||
}
|
||||
|
||||
// 答对后立即加 Redis 锁,防止多人并发提交造成重复领奖。
|
||||
$lockKey = "idiom:answer_lock:{$roundId}";
|
||||
if (! \Illuminate\Support\Facades\Redis::setnx($lockKey, 1)) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => "这道题已被「{$round->winner_username}」抢先答对了!",
|
||||
], 400);
|
||||
}
|
||||
\Illuminate\Support\Facades\Redis::expire($lockKey, 10);
|
||||
|
||||
// 抢答成功后立刻封盘,确保后续请求统一看到 answered 状态。
|
||||
$round->update([
|
||||
'status' => 'answered',
|
||||
'winner_id' => $user->id,
|
||||
'winner_username' => $user->username,
|
||||
'ended_at' => now(),
|
||||
]);
|
||||
|
||||
// 奖励仍沿用现有金币与经验发放逻辑,避免行为回归。
|
||||
if ($round->reward_gold > 0) {
|
||||
$this->currencyService->change(
|
||||
$user, 'gold', $round->reward_gold,
|
||||
\App\Enums\CurrencySource::GAME_REWARD,
|
||||
"猜成语答对「{$round->idiom->answer}」奖励",
|
||||
$roomId,
|
||||
);
|
||||
}
|
||||
if ($round->reward_exp > 0) {
|
||||
$user->exp_num = ($user->exp_num ?? 0) + $round->reward_exp;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
// 广播结果,让房间内用户立即看到答题成功公告。
|
||||
broadcast(new IdiomGameAnswered(
|
||||
roomId: $roomId,
|
||||
roundId: $round->id,
|
||||
answer: $round->idiom->answer,
|
||||
winnerUsername: $user->username,
|
||||
rewardGold: $round->reward_gold,
|
||||
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} 经验!",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#16a34a',
|
||||
'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);
|
||||
|
||||
\Illuminate\Support\Facades\Redis::del($lockKey);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => "🎉 回答正确!获得 {$round->reward_gold} 金币、{$round->reward_exp} 经验!",
|
||||
'data' => [
|
||||
'answer' => $round->idiom->answer,
|
||||
'reward_gold' => $round->reward_gold,
|
||||
'reward_exp' => $round->reward_exp,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:查询当前房间的进行中回合。
|
||||
*/
|
||||
public function current(Request $request): JsonResponse
|
||||
{
|
||||
$roomId = (int) $request->input('room_id', 0);
|
||||
if ($roomId <= 0) {
|
||||
return response()->json(['status' => 'error', 'message' => '缺少房间 ID'], 422);
|
||||
}
|
||||
|
||||
$round = IdiomGameRound::with('idiom')
|
||||
->where('room_id', $roomId)
|
||||
->whereIn('status', ['pending', 'active'])
|
||||
->first();
|
||||
|
||||
if (! $round) {
|
||||
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' => [
|
||||
'round_id' => $round->id,
|
||||
'hint' => $round->idiom?->hint ?? '',
|
||||
'reward_gold' => $round->reward_gold,
|
||||
'reward_exp' => $round->reward_exp,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜谜活动控制器
|
||||
*
|
||||
* 负责兼容现有 idiom-quiz 路由,同时支持猜成语与脑筋急转弯
|
||||
* 两类题型的开题、答题与当前回合查询。
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\RiddleGameAnswered;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\Riddle;
|
||||
use App\Models\RiddleGameRound;
|
||||
use App\Services\RiddleGameService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
/**
|
||||
* 类功能:处理猜谜活动开题、答题和当前回合读取。
|
||||
*/
|
||||
class RiddleQuizController extends Controller
|
||||
{
|
||||
/**
|
||||
* 方法功能:注入猜谜活动所需的服务。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly RiddleGameService $riddleGameService,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 方法功能:管理员手动为指定房间与题型发起一轮猜谜活动。
|
||||
*/
|
||||
public function start(Request $request): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// 仅站长或具备后台职务的管理用户可手动开题。
|
||||
if (! $user || ($user->id !== 1 && ! $request->user()?->activePosition)) {
|
||||
return response()->json(['status' => 'error', 'message' => '无权限'], 403);
|
||||
}
|
||||
|
||||
$roomId = (int) $request->input('room_id', 0);
|
||||
// 兼容后台新字段 quiz_type 与旧字段 type,两边都允许触发手动出题。
|
||||
$quizType = $this->riddleGameService->normalizeQuizType($request->input('quiz_type', $request->input('type', Riddle::TYPE_IDIOM)));
|
||||
if ($roomId <= 0) {
|
||||
return response()->json(['status' => 'error', 'message' => '缺少房间 ID'], 422);
|
||||
}
|
||||
|
||||
// 猜谜活动总开关关闭时,直接返回明确提示,避免误报成“题库为空”。
|
||||
if (! GameConfig::isEnabled(Riddle::TYPE_IDIOM)) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '猜谜活动未开启,请先到游戏管理中开启后再出题。',
|
||||
], 400);
|
||||
}
|
||||
|
||||
// 后台手动出题允许覆盖当前同题型回合,避免管理员还要先人工结束上一题。
|
||||
$this->riddleGameService->endActiveRoundsForRoom($roomId, $quizType);
|
||||
|
||||
$round = $this->riddleGameService->startRound($roomId, $quizType);
|
||||
if (! $round) {
|
||||
if (! $this->riddleGameService->pickRandomQuestion($quizType)) {
|
||||
return response()->json(['status' => 'error', 'message' => '当前题型题库中没有可用题目,请先在后台添加。'], 400);
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'error', 'message' => '当前题型暂时无法出题,请检查游戏配置与参与房间设置。'], 400);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'quiz_type' => $round->quiz_type,
|
||||
'quiz_type_label' => $this->riddleGameService->getQuizTypeLabel($round->quiz_type),
|
||||
'round_id' => $round->id,
|
||||
'quiz_round_id' => $round->id,
|
||||
'hint' => $round->idiom?->hint ?? '',
|
||||
'quiz_hint' => $round->idiom?->hint ?? '',
|
||||
'reward_gold' => $round->reward_gold,
|
||||
'reward_exp' => $round->reward_exp,
|
||||
'quiz_reward_gold' => $round->reward_gold,
|
||||
'quiz_reward_exp' => $round->reward_exp,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:提交当前猜谜活动回合的答案。
|
||||
*/
|
||||
public function answer(Request $request): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (! $user) {
|
||||
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
|
||||
}
|
||||
|
||||
$roundId = (int) $request->input('round_id');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$quizType = $this->riddleGameService->normalizeQuizType($request->input('quiz_type', $request->input('type', Riddle::TYPE_IDIOM)));
|
||||
$userAnswer = trim((string) $request->input('answer', ''));
|
||||
|
||||
if ($roundId <= 0 || $roomId <= 0 || $userAnswer === '') {
|
||||
return response()->json(['status' => 'error', 'message' => '参数不完整'], 422);
|
||||
}
|
||||
|
||||
$round = RiddleGameRound::with('idiom')->find($roundId);
|
||||
if (! $round || $round->room_id !== $roomId || $round->quiz_type !== $quizType) {
|
||||
return response()->json(['status' => 'error', 'message' => '回合不存在'], 404);
|
||||
}
|
||||
|
||||
// 判题前先做超时结算,避免用户继续抢答无效回合。
|
||||
if ($this->riddleGameService->expireRound($round)) {
|
||||
return response()->json(['status' => 'error', 'message' => '该回合已超时结束'], 400);
|
||||
}
|
||||
|
||||
if ($round->status !== 'active') {
|
||||
if ($round->status === 'answered') {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => "这道{$this->riddleGameService->getQuizTypeLabel($round->quiz_type)}已被「{$round->winner_username}」抢先答对了!",
|
||||
], 400);
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'error', 'message' => '该回合已结束'], 400);
|
||||
}
|
||||
|
||||
// 答案对比忽略空格与大小写,减少正常输入误判。
|
||||
$normalizedAnswer = str_replace(' ', '', $userAnswer);
|
||||
$normalizedCorrect = str_replace(' ', '', (string) $round->idiom?->answer);
|
||||
if (mb_strtolower($normalizedAnswer) !== mb_strtolower($normalizedCorrect)) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '答案不正确,再想想!',
|
||||
]);
|
||||
}
|
||||
|
||||
$lockKey = "riddle:answer_lock:{$roundId}";
|
||||
if (! Redis::setnx($lockKey, 1)) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => "这道{$this->riddleGameService->getQuizTypeLabel($round->quiz_type)}已被「{$round->winner_username}」抢先答对了!",
|
||||
], 400);
|
||||
}
|
||||
|
||||
Redis::expire($lockKey, 10);
|
||||
|
||||
// 抢答成功后立即封盘,确保并发请求读到统一状态。
|
||||
$round->update([
|
||||
'status' => 'answered',
|
||||
'winner_id' => $user->id,
|
||||
'winner_username' => $user->username,
|
||||
'ended_at' => now(),
|
||||
]);
|
||||
|
||||
if ($round->reward_gold > 0) {
|
||||
$this->currencyService->change(
|
||||
$user,
|
||||
'gold',
|
||||
$round->reward_gold,
|
||||
\App\Enums\CurrencySource::GAME_REWARD,
|
||||
$this->riddleGameService->buildRewardDescription($round),
|
||||
$roomId,
|
||||
);
|
||||
}
|
||||
|
||||
if ($round->reward_exp > 0) {
|
||||
// 经验奖励仍沿用现有字段,避免引入额外奖励服务改动。
|
||||
$user->exp_num = ($user->exp_num ?? 0) + $round->reward_exp;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
broadcast(new RiddleGameAnswered(
|
||||
roomId: $roomId,
|
||||
roundId: $round->id,
|
||||
quizType: $round->quiz_type,
|
||||
answer: (string) $round->idiom?->answer,
|
||||
winnerUsername: $user->username,
|
||||
rewardGold: $round->reward_gold,
|
||||
rewardExp: $round->reward_exp,
|
||||
));
|
||||
|
||||
$quizTypeLabel = $this->riddleGameService->getQuizTypeLabel($round->quiz_type);
|
||||
$resultMsg = [
|
||||
'id' => app(\App\Services\ChatStateService::class)->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => "🎉 【猜谜活动·{$quizTypeLabel}】{$user->username} 率先答对「{$round->idiom?->answer}」,获得 {$round->reward_gold} 金币、{$round->reward_exp} 经验!",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#16a34a',
|
||||
'action' => 'idiom_result',
|
||||
'winner_username' => $user->username,
|
||||
'quiz_type' => $round->quiz_type,
|
||||
'quiz_type_label' => $quizTypeLabel,
|
||||
'quiz_answer' => (string) $round->idiom?->answer,
|
||||
'quiz_reward_gold' => $round->reward_gold,
|
||||
'quiz_reward_exp' => $round->reward_exp,
|
||||
'quiz_round_id' => $round->id,
|
||||
'quiz_round_ended_id' => $round->id,
|
||||
'idiom_answer' => (string) $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(),
|
||||
];
|
||||
app(\App\Services\ChatStateService::class)->pushMessage($roomId, $resultMsg);
|
||||
|
||||
Redis::del($lockKey);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => "🎉 回答正确!获得 {$round->reward_gold} 金币、{$round->reward_exp} 经验!",
|
||||
'data' => [
|
||||
'quiz_type' => $round->quiz_type,
|
||||
'quiz_type_label' => $quizTypeLabel,
|
||||
'answer' => (string) $round->idiom?->answer,
|
||||
'quiz_answer' => (string) $round->idiom?->answer,
|
||||
'reward_gold' => $round->reward_gold,
|
||||
'reward_exp' => $round->reward_exp,
|
||||
'quiz_reward_gold' => $round->reward_gold,
|
||||
'quiz_reward_exp' => $round->reward_exp,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:查询当前房间指定题型的进行中回合。
|
||||
*/
|
||||
public function current(Request $request): JsonResponse
|
||||
{
|
||||
$roomId = (int) $request->input('room_id', 0);
|
||||
$quizType = $this->riddleGameService->normalizeQuizType($request->input('quiz_type', $request->input('type', Riddle::TYPE_IDIOM)));
|
||||
if ($roomId <= 0) {
|
||||
return response()->json(['status' => 'error', 'message' => '缺少房间 ID'], 422);
|
||||
}
|
||||
|
||||
$round = $this->riddleGameService->findActiveRound($roomId, $quizType);
|
||||
if (! $round) {
|
||||
return response()->json(['status' => 'success', 'data' => null]);
|
||||
}
|
||||
|
||||
if ($this->riddleGameService->expireRound($round)) {
|
||||
return response()->json(['status' => 'success', 'data' => null]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'quiz_type' => $round->quiz_type,
|
||||
'quiz_type_label' => $this->riddleGameService->getQuizTypeLabel($round->quiz_type),
|
||||
'round_id' => $round->id,
|
||||
'quiz_round_id' => $round->id,
|
||||
'hint' => $round->idiom?->hint ?? '',
|
||||
'quiz_hint' => $round->idiom?->hint ?? '',
|
||||
'reward_gold' => $round->reward_gold,
|
||||
'reward_exp' => $round->reward_exp,
|
||||
'quiz_reward_gold' => $round->reward_gold,
|
||||
'quiz_reward_exp' => $round->reward_exp,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -309,10 +309,18 @@ class UserController extends Controller
|
||||
{
|
||||
$user = Auth::user();
|
||||
$data = $request->validated();
|
||||
$blockedSystemSenders = collect($data['blocked_system_senders'] ?? [])
|
||||
->map(function (string $sender): string {
|
||||
// 猜谜活动前端文案允许升级,但持久化键仍复用旧值,避免历史偏好失效。
|
||||
return $sender === '猜谜活动' ? '猜成语' : $sender;
|
||||
})
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$preferences = [
|
||||
// 去重并重建索引,保持存储结构稳定,便于后续继续扩展其它屏蔽项。
|
||||
'blocked_system_senders' => array_values(array_unique($data['blocked_system_senders'] ?? [])),
|
||||
'blocked_system_senders' => $blockedSystemSenders,
|
||||
'sound_muted' => (bool) $data['sound_muted'],
|
||||
];
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class UpdateChatPreferencesRequest extends FormRequest
|
||||
'blocked_system_senders' => ['nullable', 'array'],
|
||||
'blocked_system_senders.*' => [
|
||||
'string',
|
||||
Rule::in(['钓鱼播报', '星海小博士', '百家乐', '跑马','神秘箱子']),
|
||||
Rule::in(['钓鱼播报', '猜成语', '猜谜活动', '星海小博士', '百家乐', '跑马', '神秘箱子']),
|
||||
],
|
||||
'sound_muted' => ['required', 'boolean'],
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user