修复钓鱼通知与游戏配置保存问题

This commit is contained in:
pllx
2026-04-29 15:23:32 +08:00
parent c640a31302
commit 4fe4155ec0
5 changed files with 275 additions and 26 deletions
@@ -67,7 +67,10 @@ class GameConfigController extends Controller
{
// 合并参数,保留已有键,只更新传入的键
$current = $gameConfig->params ?? [];
$validatedParams = $request->validated('params');
// 这里不能只读取 validated('params')。
// 当前请求类只对公共房间字段做了显式规则约束,像 fishing_cooldown 这类普通游戏参数
// 在 validated 数据中会被裁掉,导致后台提示成功但实际没有写入数据库。
$validatedParams = (array) $request->input('params', []);
$updated = array_merge($current, $validatedParams);
$scopeConfig = $roomScopeService->getScopeConfigForParams($validatedParams);
+23 -20
View File
@@ -11,6 +11,7 @@
* (会员加成:+经验X+金币Y
*
* @author ChatRoom Laravel
*
* @version 1.2.0
*/
@@ -24,20 +25,19 @@ use App\Models\User;
class FishingService
{
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
private readonly ShopService $shopService,
)
{
}
private readonly ShopService $shopService,
) {}
/**
* 处理收竿逻辑:计算结果、发放积分并全服广播。
*
* @param User $user 收竿的用户实体
* @param int $roomId 所在房间 ID
* @param bool $isAi 是否为 AI 调用(用于影响文案或标签)
* @param User $user 收竿的用户实体
* @param int $roomId 所在房间 ID
* @param bool $isAi 是否为 AI 调用(用于影响文案或标签)
* @return array{emoji:string,message:string,exp:int,jjb:int,base_exp:int,base_jjb:int,bonus_exp:int,bonus_jjb:int}
*/
public function processCatch(User $user, int $roomId, bool $isAi = false): array
{
@@ -54,11 +54,11 @@ class FishingService
if ($result['exp'] !== 0) {
// 当经验为 正数 则可使用会员翻倍,负数则不
$finalExp = $result['exp'] > 0 ? (int)round($result['exp'] * $expMul) : $result['exp'];
$finalExp = $result['exp'] > 0 ? (int) round($result['exp'] * $expMul) : $result['exp'];
}
if ($result['jjb'] !== 0) {
$finalJjb = $result['jjb'] > 0 ? (int)round($result['jjb'] * $jjbMul) : $result['jjb'];
$finalJjb = $result['jjb'] > 0 ? (int) round($result['jjb'] * $jjbMul) : $result['jjb'];
}
// 4. 计算会员额外加成部分
@@ -92,16 +92,18 @@ class FishingService
// 8. 广播钓鱼结果到聊天室
$promoTag = '';
if (!$isAi) {
if (! $isAi) {
$autoFishingMinutesLeft = $this->shopService->getActiveAutoFishingMinutesLeft($user);
$promoTag = $autoFishingMinutesLeft > 0
? ' <span onclick="window.openShopModal&&window.openShopModal()" '
. 'style="display:inline-block;margin-left:6px;padding:1px 7px;background:#e9e4f5;'
. 'color:#6d4fa8;border-radius:10px;font-size:10px;cursor:pointer;font-weight:bold;vertical-align:middle;'
. 'border:1px solid #d0c4ec;" title="点击购买自动钓鱼卡">🎣 自动钓鱼卡</span>'
.'style="display:inline-block;margin-left:6px;padding:1px 7px;background:#e9e4f5;'
.'color:#6d4fa8;border-radius:10px;font-size:10px;cursor:pointer;font-weight:bold;vertical-align:middle;'
.'border:1px solid #d0c4ec;" title="点击购买自动钓鱼卡">🎣 自动钓鱼卡</span>'
: '';
}
// 广播结果时额外带上统一动作标记和钓鱼者用户名,
// 方便前端把“钓鱼者本人”的公屏结果折叠到包厢窗口,避免重复显示。
$sysMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
@@ -110,7 +112,8 @@ class FishingService
'content' => "{$result['emoji']}{$user->username}{$finalMessage}{$promoTag}",
'is_secret' => false,
'font_color' => ($result['exp'] < 0 || $result['jjb'] < 0) ? '#dc2626' : '#16a34a',
'action' => '',
'action' => 'fishing_result',
'fishing_username' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
@@ -154,7 +157,7 @@ class FishingService
return $baseMessage;
}
return $baseMessage . '' . $user->vipName() . '追加:' . implode('', $bonusParts) . '';
return $baseMessage.''.$user->vipName().'追加:'.implode('', $bonusParts).'';
}
/**
@@ -168,7 +171,7 @@ class FishingService
{
$event = FishingEvent::rollOne();
if (!$event) {
if (! $event) {
return [
'emoji' => '🐟',
'message' => '钓到一条小鱼,获得金币10',
@@ -180,8 +183,8 @@ class FishingService
return [
'emoji' => $event->emoji,
'message' => $event->message,
'exp' => (int)$event->exp,
'jjb' => (int)$event->jjb,
'exp' => (int) $event->exp,
'jjb' => (int) $event->jjb,
];
}
}
+17
View File
@@ -241,6 +241,7 @@ function startAutoFishingCooldown(cooldown) {
autoFishCooldownCountdown = window.setInterval(() => {
const remaining = Math.max(0, Math.ceil((endTime - Date.now()) / 1000));
setFishingButton(`⏳ 冷却 ${remaining}s`, true);
updateAutoFishStopButtonCountdown(remaining);
if (remaining <= 0) {
window.clearInterval(autoFishCooldownCountdown);
@@ -299,6 +300,22 @@ function showAutoFishStopButton(cooldown) {
document.body.appendChild(button);
}
/**
* 同步更新停止自动钓鱼浮层上的冷却秒数,避免与主按钮倒计时不一致。
*
* @param {number} cooldown
* @returns {void}
*/
function updateAutoFishStopButtonCountdown(cooldown) {
const hint = document.querySelector("#auto-fish-stop-btn .drag-hint");
if (!(hint instanceof HTMLElement)) {
return;
}
hint.textContent = `冷却 ${Number(cooldown) || 0}s · 可拖动`;
}
/**
* 给停止自动钓鱼按钮绑定拖拽和点击停止事件。
*
+48 -5
View File
@@ -81,7 +81,26 @@ function isRedPacketAnnouncementMessage(msg) {
function buildRedPacketAnnouncementHtml(msg, timeStr) {
const rawContent = String(msg?.content || "");
const isExpPacket = rawContent.includes("经验的礼包");
const accentColor = isExpPacket ? "#7c3aed" : "#dc2626";
const colorPalette = isExpPacket
? {
accent: "#16a34a",
text: "#166534",
softBackground: "linear-gradient(135deg,#f0fdf4,#f7fee7)",
softBorder: "rgba(22,163,74,.18)",
chipBackground: "#dcfce7",
chipBorder: "#86efac",
chipText: "#15803d",
}
: {
accent: "#dc2626",
text: "#b91c1c",
softBackground: "linear-gradient(135deg,#fef2f2,#fff7ed)",
softBorder: "rgba(220,38,38,.18)",
chipBackground: "#fee2e2",
chipBorder: "#fca5a5",
chipText: "#dc2626",
};
const accentColor = colorPalette.accent;
const typeLabel = isExpPacket ? "经验礼包" : "金币礼包";
const icon = isExpPacket ? "✨" : "🧧";
const buttonMatch = rawContent.match(/<button\b([^>]*)>([\s\S]*?)<\/button>/iu);
@@ -99,12 +118,12 @@ function buildRedPacketAnnouncementHtml(msg, timeStr) {
const actionButtonHtml = `<button type="button"${buttonOnclick ? ` onclick="${escapeHtml(buttonOnclick)}"` : ""} style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${accentColor};color:#fff;font-size:11px;font-weight:700;line-height:1;border:1px solid ${accentColor};cursor:pointer;box-shadow:none;vertical-align:middle;">${escapeHtml(buttonLabel)}</button>`;
return `
<div style="display:flex;align-items:center;gap:7px;padding:5px 9px;border-radius:11px;background:linear-gradient(135deg,${isExpPacket ? "#f5f3ff,#faf5ff" : "#fef2f2,#fff7ed"});border:1px solid ${isExpPacket ? "rgba(124,58,237,.16)" : "rgba(220,38,38,.16)"};box-shadow:0 4px 12px rgba(15,23,42,.045);overflow:hidden;">
<div style="width:23px;height:23px;border-radius:7px;background:${accentColor};display:flex;align-items:center;justify-content:center;color:#fff;font-size:13px;box-shadow:0 2px 6px ${isExpPacket ? "rgba(124,58,237,.16)" : "rgba(220,38,38,.16)"};flex-shrink:0;">${icon}</div>
<div style="min-width:0;flex:1;display:flex;align-items:center;gap:7px;flex-wrap:wrap;color:${isExpPacket ? "#5b21b6" : "#b91c1c"};">
<div style="display:flex;align-items:center;gap:7px;padding:5px 9px;border-radius:11px;background:${colorPalette.softBackground};border:1px solid ${colorPalette.softBorder};box-shadow:0 4px 12px rgba(15,23,42,.045);overflow:hidden;">
<div style="width:23px;height:23px;border-radius:7px;background:${accentColor};display:flex;align-items:center;justify-content:center;color:#fff;font-size:13px;box-shadow:0 2px 6px ${colorPalette.softBorder};flex-shrink:0;">${icon}</div>
<div style="min-width:0;flex:1;display:flex;align-items:center;gap:7px;flex-wrap:wrap;color:${colorPalette.text};">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex-shrink:0;">
${buildGameLabelChipHtml("礼包红包", accentColor)}
<span style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${accentColor}1A;color:${accentColor};font-size:11px;font-weight:700;line-height:1;border:1px solid ${accentColor}33;">${escapeHtml(typeLabel)}</span>
<span style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${colorPalette.chipBackground};color:${colorPalette.chipText};font-size:11px;font-weight:700;line-height:1;border:1px solid ${colorPalette.chipBorder};">${escapeHtml(typeLabel)}</span>
</div>
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;font-size:12px;line-height:1.25;font-weight:700;min-width:200px;flex:1;">
<span>${summary}</span>
@@ -130,6 +149,26 @@ function buildQuizBadgeHtml(msg, accentColor = "#7c3aed") {
`;
}
/**
* 判断当前公屏消息是否属于“我自己”的钓鱼结果广播。
*
* 说明:
* - 收竿后,钓鱼者本人已经会在包厢窗口收到本地结果提示;
* - 这里需要把同一条公屏广播对本人隐藏,避免自己同时看到两条。
*/
function isOwnFishingResultBroadcast(msg) {
const currentUsername = String(window.chatContext?.username || "").trim();
const fishingUsername = String(msg?.fishing_username || "").trim();
if (!currentUsername) {
return false;
}
return String(msg?.from_user || "") === "钓鱼播报"
&& String(msg?.action || "") === "fishing_result"
&& fishingUsername === currentUsername;
}
/**
* 判断当前消息是否应该使用统一的游戏通知卡片。
*/
@@ -549,6 +588,10 @@ export function appendMessage(msg, renderBatch = null) {
state.trackMaxMsgId(msg.id || 0);
if (isOwnFishingResultBroadcast(msg)) {
return null;
}
const quizMeta = normalizeQuizRoundPayload(msg);
const idiomRoundId = quizMeta.roundId;
const isIdiomStartMessage = isQuizStartMessage(msg)
@@ -0,0 +1,183 @@
<?php
/**
* 文件功能:后台游戏参数保存测试
*
* 覆盖游戏管理页保存参数时的关键写库行为,
* 防止普通数值字段因为请求校验裁剪而出现“提示成功但未落库”的回归。
*/
namespace Tests\Feature\Feature;
use App\Models\GameConfig;
use App\Models\Room;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 类功能:验证后台游戏配置保存链路。
*/
class AdminGameConfigControllerTest extends TestCase
{
use RefreshDatabase;
/**
* 方法功能:验证保存钓鱼冷却时间时会真正写入配置参数。
*/
public function test_admin_can_update_fishing_cooldown_param(): void
{
$siteOwner = User::factory()->create([
'id' => 1,
'username' => 'site-owner',
'user_level' => 100,
]);
Room::query()->create([
'id' => 1,
'room_name' => 'test-room',
'room_owner' => $siteOwner->username,
'room_des' => '用于后台游戏配置测试',
'room_time' => now(),
'build_time' => now(),
]);
$gameConfig = GameConfig::query()->create([
'game_key' => 'fishing',
'name' => '钓鱼',
'icon' => 'fish',
'description' => 'Fishing Game',
'enabled' => true,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'fishing_cost' => 5,
'fishing_wait_min' => 8,
'fishing_wait_max' => 15,
'fishing_cooldown' => 300,
],
]);
$response = $this->actingAs($siteOwner)->post(route('admin.game-configs.params', $gameConfig), [
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'fishing_cost' => 5,
'fishing_wait_min' => 8,
'fishing_wait_max' => 15,
'fishing_cooldown' => 120,
],
]);
$response->assertRedirect();
$response->assertSessionHas('success');
$this->assertSame(120, (int) ($gameConfig->fresh()->params['fishing_cooldown'] ?? 0));
}
/**
* 方法功能:验证其他游戏的普通参数也会通过统一保存入口真正落库。
*/
public function test_admin_can_update_multiple_game_params_via_shared_config_endpoint(): void
{
$siteOwner = User::factory()->create([
'id' => 1,
'username' => 'site-owner',
'user_level' => 100,
]);
$cases = [
[
'game_key' => 'baccarat',
'name' => '百家乐',
'icon' => 'dice',
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'interval_minutes' => 2,
'bet_window_seconds' => 60,
],
'updated_key' => 'bet_window_seconds',
'updated_value' => 90,
],
[
'game_key' => 'lottery',
'name' => '双色球彩票',
'icon' => 'lottery',
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'ticket_price' => 100,
'draw_hour' => 20,
],
'updated_key' => 'ticket_price',
'updated_value' => 188,
],
[
'game_key' => 'mystery_box',
'name' => '神秘箱子',
'icon' => 'box',
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'claim_window_seconds' => 60,
'normal_reward_min' => 500,
],
'updated_key' => 'claim_window_seconds',
'updated_value' => 75,
],
[
'game_key' => 'idiom',
'name' => '猜谜活动',
'icon' => 'puzzle',
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'reward_gold' => 50,
'reward_exp' => 30,
],
'updated_key' => 'reward_gold',
'updated_value' => 88,
],
];
foreach ($cases as $index => $case) {
$room = Room::query()->create([
'room_name' => 'room-'.($index + 1),
'room_owner' => $siteOwner->username,
'room_des' => '用于后台游戏配置测试',
'room_time' => now(),
'build_time' => now(),
]);
$roomId = (int) $room->id;
$gameConfig = GameConfig::query()->create([
'game_key' => $case['game_key'],
'name' => $case['name'],
'icon' => $case['icon'],
'description' => 'shared config endpoint test',
'enabled' => true,
'params' => array_merge($case['params'], [
'room_ids' => [$roomId],
]),
]);
$requestParams = $case['params'];
$requestParams['room_ids'] = [$roomId];
$requestParams[$case['updated_key']] = $case['updated_value'];
$response = $this->actingAs($siteOwner)->post(route('admin.game-configs.params', $gameConfig), [
'params' => $requestParams,
]);
$response->assertRedirect();
$response->assertSessionHas('success');
$this->assertSame(
$case['updated_value'],
(int) ($gameConfig->fresh()->params[$case['updated_key']] ?? 0),
"{$case['game_key']} 参数未写入数据库。",
);
}
}
}