Files
chatroom/app/Http/Controllers/FishingController.php
lkddi 03ec3a9fbb 功能:钓鱼游戏后台管理系统
一、钓鱼全局开关
- 钓鱼纳入 GameConfig(game_key=fishing),游戏管理页可一键开关
- cast() 接口加开关校验,关闭时返回 403 友好提示
- GameConfigSeeder 新增 fishing 配置(含4个参数)

二、钓鱼事件数据库化
- 新建 fishing_events 表(emoji/name/message/exp/jjb/weight/is_active/sort)
- FishingEvent 模型含 rollOne() 加权随机方法
- FishingEventSeeder 填充7条初始事件(经验降低、金币提升)
- FishingController::randomFishResult() 改为读数据库事件

三、钓鱼参数迁移至 GameConfig
- fishing_cost/wait_min/wait_max/cooldown 改为 GameConfig::param() 读取
- 保留 Sysparam fallback 兼容旧数据

四、后台管理页面
- 新建 FishingEventController(CRUD + AJAX toggle)
- 新建 admin/fishing/index.blade.php(事件列表+概率显示+编辑弹窗)
- 侧边栏「游戏管理」下方新增「🎣 钓鱼事件」入口
- 游戏管理视图 gameParamLabels 新增钓鱼参数标签
2026-03-03 16:46:36 +08:00

267 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 文件功能:钓鱼小游戏控制器
*
* 新增随机浮漂点击防挂机机制:
* - 抛竿时生成一次性 token 和随机浮漂坐标(百分比),返回给前端
* - 前端显示漂浮动画,等待下沉后用户点击,将 token 随收竿请求提交
* - 若持有有效「自动钓鱼卡」,前端直接自动点击,无需手动操作
* - 服务端验证 token 有效性,防止脚本直接调用收竿接口
*
* @author ChatRoom Laravel
*
* @version 2.0.0
*/
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Models\FishingEvent;
use App\Models\GameConfig;
use App\Models\Sysparam;
use App\Services\ChatStateService;
use App\Services\ShopService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
class FishingController extends Controller
{
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
private readonly ShopService $shopService,
) {}
/**
* 抛竿 — 检查冷却和金币,扣除金币,生成浮漂 token 和随机坐标。
*
* 返回:
* wait_time — 等待秒数(前端倒数后触发下沉动画)
* bobber_x/y — 浮漂随机位置0-100 百分比)
* token — 本次钓鱼唯一令牌(收竿时必须携带)
* auto_fishing — 是否持有有效自动钓鱼卡(前端据此自动点击)
*
* @param int $id 房间ID
*/
public function cast(Request $request, int $id): JsonResponse
{
$user = Auth::user();
if (! $user) {
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
}
// 检查钓鱼全局开关
if (! GameConfig::isEnabled('fishing')) {
return response()->json(['status' => 'error', 'message' => '钓鱼功能暂未开放。'], 403);
}
// 1. 检查冷却时间Redis TTL
$cooldownKey = "fishing:cd:{$user->id}";
if (Redis::exists($cooldownKey)) {
$ttl = Redis::ttl($cooldownKey);
return response()->json([
'status' => 'error',
'message' => "钓鱼冷却中,还需等待 {$ttl} 秒。",
'cooldown' => $ttl,
], 429);
}
// 2. 检查金币是否足够
$cost = (int) (GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5'));
if (($user->jjb ?? 0) < $cost) {
return response()->json([
'status' => 'error',
'message' => "金币不足!钓鱼需要 {$cost} 金币,您当前只有 {$user->jjb} 金币。",
], 422);
}
// 3. 扣除金币
$this->currencyService->change(
$user, 'gold', -$cost,
CurrencySource::FISHING_COST,
"钓鱼抛竿消耗 {$cost} 金币",
$id,
);
$user->refresh();
// 4. 生成一次性 token存入 RedisTTL = 等待时间 + 收竿窗口 + 缓冲)
$waitMin = (int) (GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8'));
$waitMax = (int) (GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15'));
$waitTime = rand($waitMin, $waitMax);
$token = Str::random(32);
$tokenKey = "fishing:token:{$user->id}";
// token 有效期 = 等待时间 + 8秒点击窗口 + 5秒缓冲
// 同时把 cast 时间戳和 wait_time 一起存入,供 reel 做服务端时间校验
Redis::setex($tokenKey, $waitTime + 13, json_encode([
'token' => $token,
'cast_at' => time(),
'wait_time' => $waitTime,
]));
// 5. 生成随机浮漂坐标(百分比,避开边缘)
$bobberX = rand(15, 85); // 左右 15%~85%
$bobberY = rand(20, 65); // 上下 20%~65%
// 6. 检查是否持有有效自动钓鱼卡
$autoFishingMinutes = $this->shopService->getActiveAutoFishingMinutesLeft($user);
return response()->json([
'status' => 'success',
'message' => "已花费 {$cost} 金币,鱼竿已抛出!等待鱼儿上钩...",
'wait_time' => $waitTime,
'bobber_x' => $bobberX,
'bobber_y' => $bobberY,
'token' => $token,
'auto_fishing' => $autoFishingMinutes > 0,
'auto_fishing_minutes_left' => $autoFishingMinutes,
'cost' => $cost,
'jjb' => $user->jjb,
]);
}
/**
* 收竿 — 验证浮漂 token随机计算钓鱼结果更新经验/金币,广播到聊天室。
*
* 必须携带 token从抛竿接口获取否则判定为非法收竿。
*
* @param int $id 房间ID
*/
public function reel(Request $request, int $id): JsonResponse
{
$user = Auth::user();
if (! $user) {
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
}
// 1. 验证 token + 服务端时间校验(防止前端篡改 wait_time 跳过等待)
$tokenKey = "fishing:token:{$user->id}";
$storedJson = Redis::get($tokenKey);
$clientToken = $request->input('token', '');
if (! $storedJson) {
return response()->json([
'status' => 'error',
'message' => '鱼儿跑了!浮漂已超时,请重新抛竿。',
], 422);
}
$stored = json_decode($storedJson, true);
// 校验 token 一致性
if (($stored['token'] ?? '') !== $clientToken) {
return response()->json([
'status' => 'error',
'message' => '令牌无效,请重新抛竿。',
], 422);
}
// 校验服务端时间:距抛竿必须已过 wait_time 秒(允许 ±1s 误差)
$elapsed = time() - (int) ($stored['cast_at'] ?? 0);
$required = (int) ($stored['wait_time'] ?? 0);
if ($elapsed < $required - 1) {
return response()->json([
'status' => 'error',
'message' => '鱼还没上钩,别急!',
], 422);
}
// 清除 token一次性
Redis::del($tokenKey);
// 2. 设置冷却时间
$cooldown = (int) (GameConfig::param('fishing', 'fishing_cooldown') ?? Sysparam::getValue('fishing_cooldown', '300'));
Redis::setex("fishing:cd:{$user->id}", $cooldown, time());
// 3. 随机决定钓鱼结果
$result = $this->randomFishResult();
// 4. 通过统一积分服务更新经验和金币
$expMul = $this->vipService->getExpMultiplier($user);
$jjbMul = $this->vipService->getJjbMultiplier($user);
if ($result['exp'] !== 0) {
$finalExp = $result['exp'] > 0 ? (int) round($result['exp'] * $expMul) : $result['exp'];
$this->currencyService->change(
$user, 'exp', $finalExp, CurrencySource::FISHING_GAIN,
"钓鱼收竿:{$result['message']}", $id,
);
}
if ($result['jjb'] !== 0) {
$finalJjb = $result['jjb'] > 0 ? (int) round($result['jjb'] * $jjbMul) : $result['jjb'];
$this->currencyService->change(
$user, 'gold', $finalJjb, CurrencySource::FISHING_GAIN,
"钓鱼收竿:{$result['message']}", $id,
);
}
$user->refresh();
// 5. 广播钓鱼结果到聊天室
// 若使用自动钓鱼卡,在消息末尾附加购买推广小标签(其他人点击可打开商店)
$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>'
: '';
$sysMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '钓鱼播报',
'to_user' => '大家',
'content' => "{$result['emoji']}{$user->username}{$result['message']}{$promoTag}",
'is_secret' => false,
'font_color' => $result['exp'] >= 0 ? '#16a34a' : '#dc2626',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $sysMsg);
broadcast(new MessageSent($id, $sysMsg));
return response()->json([
'status' => 'success',
'result' => $result,
'exp_num' => $user->exp_num,
'jjb' => $user->jjb,
'cooldown_seconds' => $cooldown, // 前端自动钓鱼卡循环等待用
]);
}
/**
* 随机钓鱼结果(从数据库 fishing_events 加权随机抽取)
*
* 若数据库中无激活事件,回退到兜底结果。
*
* @return array{emoji: string, message: string, exp: int, jjb: int}
*/
private function randomFishResult(): array
{
$event = FishingEvent::rollOne();
// 数据库无事件时的兜底
if (! $event) {
return [
'emoji' => '🐟',
'message' => '钓到一条小鱼获得金币10',
'exp' => 0,
'jjb' => 10,
];
}
return [
'emoji' => $event->emoji,
'message' => $event->message,
'exp' => $event->exp,
'jjb' => $event->jjb,
];
}
}