重构猜谜活动并统一聊天室答题通知
This commit is contained in:
@@ -0,0 +1,493 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜谜活动回合服务
|
||||
*
|
||||
* 统一处理题型兼容、房间范围、自动出题、超时结算与公屏公告,
|
||||
* 避免控制器与定时任务各自维护一套猜谜活动逻辑。
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Events\MessageSent;
|
||||
use App\Events\RiddleGameStarted;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\Riddle;
|
||||
use App\Models\RiddleGameRound;
|
||||
use App\Models\Room;
|
||||
|
||||
/**
|
||||
* 类功能:提供猜谜活动的配置读取、出题、过期结算与公告能力。
|
||||
*/
|
||||
class RiddleGameService
|
||||
{
|
||||
/**
|
||||
* 方法功能:注入聊天室状态服务,复用现有公屏消息推送链路。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 方法功能:读取指定题型的完整配置,并兼容旧版平铺参数。
|
||||
*
|
||||
* @return array{reward_gold:int,reward_exp:int,expire_minutes:int,auto_start_interval:int,room_mode:string,room_ids:array<int, int>}
|
||||
*/
|
||||
public function getTypeConfig(?string $quizType = null): array
|
||||
{
|
||||
$normalizedQuizType = $this->normalizeQuizType($quizType);
|
||||
$config = GameConfig::forGame(Riddle::TYPE_IDIOM) ?? GameConfig::forGame($normalizedQuizType);
|
||||
$params = $config?->params ?? [];
|
||||
$typeConfig = (array) (($params['type_configs'] ?? [])[$normalizedQuizType] ?? []);
|
||||
$sharedRoomIds = $this->normalizeRoomIds(
|
||||
$params['room_ids']
|
||||
?? (($params['room_id'] ?? null) !== null ? [$params['room_id']] : [])
|
||||
);
|
||||
$roomMode = (string) ($params['room_scope_mode'] ?? ($typeConfig['room_mode'] ?? 'single'));
|
||||
|
||||
if (! in_array($roomMode, ['all', 'single', 'multiple'], true)) {
|
||||
$roomMode = 'single';
|
||||
}
|
||||
|
||||
$roomIds = $sharedRoomIds !== []
|
||||
? $sharedRoomIds
|
||||
: $this->normalizeRoomIds($typeConfig['room_ids'] ?? [1]);
|
||||
|
||||
return [
|
||||
'reward_gold' => max(0, (int) ($params['reward_gold'] ?? ($typeConfig['reward_gold'] ?? 50))),
|
||||
'reward_exp' => max(0, (int) ($params['reward_exp'] ?? ($typeConfig['reward_exp'] ?? 30))),
|
||||
'expire_minutes' => max(0, (int) ($params['expire_minutes'] ?? ($typeConfig['expire_minutes'] ?? 5))),
|
||||
'auto_start_interval' => max(0, (int) ($params['auto_start_interval'] ?? ($typeConfig['auto_start_interval'] ?? 0))),
|
||||
'room_mode' => $roomMode,
|
||||
'room_ids' => $roomIds,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:读取题目有效时长配置,单位分钟。
|
||||
*/
|
||||
public function getExpireMinutes(?string $quizType = null): int
|
||||
{
|
||||
return $this->getTypeConfig($quizType)['expire_minutes'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:读取自动出题间隔配置,单位分钟。
|
||||
*/
|
||||
public function getAutoStartInterval(?string $quizType = null): int
|
||||
{
|
||||
return $this->getTypeConfig($quizType)['auto_start_interval'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:读取答题奖励配置。
|
||||
*
|
||||
* @return array{reward_gold:int,reward_exp:int}
|
||||
*/
|
||||
public function getRewardConfig(?string $quizType = null): array
|
||||
{
|
||||
$typeConfig = $this->getTypeConfig($quizType);
|
||||
|
||||
return [
|
||||
'reward_gold' => $typeConfig['reward_gold'],
|
||||
'reward_exp' => $typeConfig['reward_exp'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:将外部传入的题型归一化为系统支持值。
|
||||
*/
|
||||
public function normalizeQuizType(?string $quizType): string
|
||||
{
|
||||
$normalizedType = trim((string) $quizType);
|
||||
|
||||
return Riddle::isSupportedType($normalizedType)
|
||||
? $normalizedType
|
||||
: Riddle::TYPE_IDIOM;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回题型对应的中文名称。
|
||||
*/
|
||||
public function getQuizTypeLabel(string $quizType): string
|
||||
{
|
||||
return Riddle::labelForType($this->normalizeQuizType($quizType));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:读取自动出题的房间范围模式。
|
||||
*/
|
||||
public function getRoomScopeMode(?string $quizType = null): string
|
||||
{
|
||||
return $this->getTypeConfig($quizType)['room_mode'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:读取自动出题允许覆盖的房间列表。
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function getScopedRoomIds(?string $quizType = null): array
|
||||
{
|
||||
$typeConfig = $this->getTypeConfig($quizType);
|
||||
$mode = $typeConfig['room_mode'];
|
||||
$configuredRoomIds = $typeConfig['room_ids'];
|
||||
|
||||
if ($mode === 'all') {
|
||||
return Room::query()->orderBy('id')->pluck('id')->map(fn (mixed $id): int => (int) $id)->all();
|
||||
}
|
||||
|
||||
if ($mode === 'single') {
|
||||
return array_slice($configuredRoomIds !== [] ? $configuredRoomIds : [1], 0, 1);
|
||||
}
|
||||
|
||||
return $configuredRoomIds !== [] ? $configuredRoomIds : [1];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:判断指定回合是否已经超过有效时长。
|
||||
*/
|
||||
public function isRoundExpired(RiddleGameRound $round): bool
|
||||
{
|
||||
$expireMinutes = $this->getExpireMinutes($round->quiz_type);
|
||||
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(RiddleGameRound $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, ?string $quizType = null): int
|
||||
{
|
||||
$expiredCount = 0;
|
||||
$normalizedQuizType = $quizType !== null ? $this->normalizeQuizType($quizType) : null;
|
||||
|
||||
RiddleGameRound::with('idiom')
|
||||
->where('room_id', $roomId)
|
||||
->when(
|
||||
$normalizedQuizType !== null,
|
||||
fn ($query) => $query->where('quiz_type', $normalizedQuizType),
|
||||
)
|
||||
->whereIn('status', ['pending', 'active'])
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->each(function (RiddleGameRound $round) use ($announce, &$expiredCount): void {
|
||||
if ($this->expireRound($round, $announce)) {
|
||||
$expiredCount++;
|
||||
}
|
||||
});
|
||||
|
||||
return $expiredCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:手动结束指定房间指定题型的所有进行中回合。
|
||||
*/
|
||||
public function endActiveRoundsForRoom(int $roomId, ?string $quizType = null): int
|
||||
{
|
||||
$endedCount = 0;
|
||||
$normalizedQuizType = $quizType !== null ? $this->normalizeQuizType($quizType) : null;
|
||||
|
||||
RiddleGameRound::query()
|
||||
->where('room_id', $roomId)
|
||||
->when(
|
||||
$normalizedQuizType !== null,
|
||||
fn ($query) => $query->where('quiz_type', $normalizedQuizType),
|
||||
)
|
||||
->whereIn('status', ['pending', 'active'])
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->each(function (RiddleGameRound $round) use (&$endedCount): void {
|
||||
// 手动出题覆盖旧题时,直接结束旧回合,不再额外发超时公告。
|
||||
$round->update([
|
||||
'status' => 'ended',
|
||||
'ended_at' => $round->ended_at ?? now(),
|
||||
]);
|
||||
$endedCount++;
|
||||
});
|
||||
|
||||
return $endedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:为指定房间和题型创建一轮新题。
|
||||
*/
|
||||
public function startRound(int $roomId, ?string $quizType = null): ?RiddleGameRound
|
||||
{
|
||||
$normalizedQuizType = $this->normalizeQuizType($quizType);
|
||||
|
||||
if (! $this->isGameEnabled($normalizedQuizType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 先清理同房间同题型的过期回合,避免旧记录卡住新题。
|
||||
$this->expireActiveRoundsForRoom($roomId, true, $normalizedQuizType);
|
||||
|
||||
if ($this->findActiveRound($roomId, $normalizedQuizType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$idiom = $this->pickRandomQuestion($normalizedQuizType);
|
||||
if (! $idiom) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rewardConfig = $this->getRewardConfig($normalizedQuizType);
|
||||
|
||||
// 新回合显式记录 quiz_type,保证房间与题型维度都能独立判定。
|
||||
$round = RiddleGameRound::create([
|
||||
'room_id' => $roomId,
|
||||
'idiom_id' => $idiom->id,
|
||||
'quiz_type' => $normalizedQuizType,
|
||||
'status' => 'active',
|
||||
'reward_gold' => $rewardConfig['reward_gold'],
|
||||
'reward_exp' => $rewardConfig['reward_exp'],
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
$round->setRelation('idiom', $idiom);
|
||||
$this->broadcastStartedRound($round);
|
||||
|
||||
return $round;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:按配置范围自动为各房间各题型尝试开题。
|
||||
*/
|
||||
public function autoStartEligibleRounds(): int
|
||||
{
|
||||
$startedCount = 0;
|
||||
|
||||
foreach (Riddle::supportedTypes() as $quizType) {
|
||||
$interval = $this->getAutoStartInterval($quizType);
|
||||
if ($interval <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($this->getScopedRoomIds($quizType) as $roomId) {
|
||||
// 房间与题型维度独立结算过期回合,互不干扰。
|
||||
$this->expireActiveRoundsForRoom($roomId, true, $quizType);
|
||||
|
||||
if ($this->findActiveRound($roomId, $quizType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $this->hasReachedAutoStartInterval($roomId, $quizType, $interval)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $this->pickRandomQuestion($quizType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->startRound($roomId, $quizType)) {
|
||||
$startedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $startedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:查询指定房间指定题型的进行中回合。
|
||||
*/
|
||||
public function findActiveRound(int $roomId, ?string $quizType = null): ?RiddleGameRound
|
||||
{
|
||||
return RiddleGameRound::query()
|
||||
->with('idiom')
|
||||
->where('room_id', $roomId)
|
||||
->where('quiz_type', $this->normalizeQuizType($quizType))
|
||||
->whereIn('status', ['pending', 'active'])
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:随机抽取一条启用中的题目。
|
||||
*/
|
||||
public function pickRandomQuestion(?string $quizType = null): ?Riddle
|
||||
{
|
||||
return Riddle::query()
|
||||
->where('type', $this->normalizeQuizType($quizType))
|
||||
->where('is_active', true)
|
||||
->inRandomOrder()
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:生成答题奖励日志文案。
|
||||
*/
|
||||
public function buildRewardDescription(RiddleGameRound $round): string
|
||||
{
|
||||
$quizTypeLabel = $this->getQuizTypeLabel($round->quiz_type);
|
||||
|
||||
return "猜谜活动{$quizTypeLabel}答对「{$round->idiom?->answer}」奖励";
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:向公屏推送回合超时公告。
|
||||
*/
|
||||
public function pushExpiredRoundMessage(RiddleGameRound $round): void
|
||||
{
|
||||
$answer = $round->idiom?->answer ?? '未知答案';
|
||||
$quizTitle = Riddle::activityLabelForType($round->quiz_type);
|
||||
$message = [
|
||||
'id' => $this->chatState->nextMessageId($round->room_id),
|
||||
'room_id' => $round->room_id,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => "⏳ 【{$quizTitle}】第 #{$round->id} 题已超时结束!正确答案:{$answer}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#d97706',
|
||||
'action' => '',
|
||||
'quiz_type' => $this->normalizeQuizType($round->quiz_type),
|
||||
'quiz_type_label' => $this->getQuizTypeLabel($round->quiz_type),
|
||||
'quiz_round_id' => $round->id,
|
||||
'quiz_round_ended_id' => $round->id,
|
||||
'quiz_answer' => $answer,
|
||||
'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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:广播新回合开始事件并同步写入公屏消息。
|
||||
*/
|
||||
public function broadcastStartedRound(RiddleGameRound $round): void
|
||||
{
|
||||
$round->loadMissing('idiom');
|
||||
|
||||
broadcast(new RiddleGameStarted(
|
||||
roomId: $round->room_id,
|
||||
quizType: $round->quiz_type,
|
||||
hint: $round->idiom?->hint ?? '',
|
||||
roundId: $round->id,
|
||||
rewardGold: $round->reward_gold,
|
||||
rewardExp: $round->reward_exp,
|
||||
));
|
||||
|
||||
$message = [
|
||||
'id' => $this->chatState->nextMessageId($round->room_id),
|
||||
'room_id' => $round->room_id,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => $this->buildStartMessage($round->quiz_type, $round->id, $round->idiom?->hint ?? ''),
|
||||
'is_secret' => false,
|
||||
'font_color' => '#b91c1c',
|
||||
'action' => '',
|
||||
'quiz_type' => $this->normalizeQuizType($round->quiz_type),
|
||||
'quiz_type_label' => $this->getQuizTypeLabel($round->quiz_type),
|
||||
'quiz_round_id' => $round->id,
|
||||
'quiz_hint' => $round->idiom?->hint ?? '',
|
||||
'quiz_reward_gold' => $round->reward_gold,
|
||||
'quiz_reward_exp' => $round->reward_exp,
|
||||
'idiom_game_round_id' => $round->id,
|
||||
'idiom_reward_gold' => $round->reward_gold,
|
||||
'idiom_reward_exp' => $round->reward_exp,
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage($round->room_id, $message);
|
||||
broadcast(new MessageSent($round->room_id, $message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:判断指定房间指定题型是否已到自动开题间隔。
|
||||
*/
|
||||
private function hasReachedAutoStartInterval(int $roomId, string $quizType, int $interval): bool
|
||||
{
|
||||
$lastRound = RiddleGameRound::query()
|
||||
->where('room_id', $roomId)
|
||||
->where('quiz_type', $this->normalizeQuizType($quizType))
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if (! $lastRound) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$lastTime = $lastRound->ended_at ?? $lastRound->started_at ?? $lastRound->created_at;
|
||||
|
||||
return ! $lastTime || $lastTime->diffInMinutes(now()) >= $interval;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:把 room_ids 配置归一化为整型数组。
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function normalizeRoomIds(mixed $roomIds): array
|
||||
{
|
||||
$items = is_array($roomIds)
|
||||
? $roomIds
|
||||
: preg_split('/[\s,,]+/u', (string) $roomIds, -1, PREG_SPLIT_NO_EMPTY);
|
||||
|
||||
return collect($items)
|
||||
->map(fn (mixed $roomId): int => (int) $roomId)
|
||||
->filter(fn (int $roomId): bool => $roomId > 0)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回题型对应的活动标题。
|
||||
*/
|
||||
private function buildStartMessage(string $quizType, int $roundId, string $hint): string
|
||||
{
|
||||
$normalizedQuizType = $this->normalizeQuizType($quizType);
|
||||
$quizLabel = $this->getQuizTypeLabel($normalizedQuizType);
|
||||
$icon = $normalizedQuizType === Riddle::TYPE_BRAIN_TEASER ? '🧠' : '🧩';
|
||||
|
||||
return "{$icon} 【猜谜活动·{$quizLabel}】第 #{$roundId} 题开始!题面:{$hint}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:判断猜谜活动总开关是否处于启用状态。
|
||||
*/
|
||||
private function isGameEnabled(?string $quizType = null): bool
|
||||
{
|
||||
$normalizedQuizType = $this->normalizeQuizType($quizType);
|
||||
$config = GameConfig::forGame($normalizedQuizType) ?? GameConfig::forGame(Riddle::TYPE_IDIOM);
|
||||
|
||||
return (bool) $config?->enabled;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user