修复钓鱼通知与游戏配置保存问题
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 · 可拖动`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给停止自动钓鱼按钮绑定拖拽和点击停止事件。
|
||||
*
|
||||
|
||||
@@ -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']} 参数未写入数据库。",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user