Files
chatroom/app/Http/Controllers/FishingController.php
lkddi fdb500c3dd 优化:自动钓鱼卡标签改为柔和灰紫色;工具栏「提议」按钮改为「反馈」
- FishingController: 钓鱼播报内「自动钓鱼卡」标签从高饱和紫色渐变改为低调灰紫底色+深紫字,减少视觉刺激
- toolbar.blade.php: 「提议(待开发)」→「反馈」,链接至 feedback 路由(新标签页打开)
2026-03-03 14:30:09 +08:00

286 lines
11 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\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);
}
// 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) 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) Sysparam::getValue('fishing_wait_min', '8');
$waitMax = (int) 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) 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, // 前端自动钓鱼卡循环等待用
]);
}
/**
* 随机钓鱼结果(复刻原版概率分布)
*
* @return array{emoji: string, message: string, exp: int, jjb: int}
*/
private function randomFishResult(): array
{
$roll = rand(1, 100);
return match (true) {
$roll <= 15 => [
'emoji' => '🦈',
'message' => '钓到一条大鲨鱼增加经验100、金币20',
'exp' => 100,
'jjb' => 20,
],
$roll <= 30 => [
'emoji' => '🐟',
'message' => '钓到一条娃娃鱼到集市卖得30个金币',
'exp' => 0,
'jjb' => 30,
],
$roll <= 50 => [
'emoji' => '🐠',
'message' => '钓到一只大草鱼吃下增加经验50',
'exp' => 50,
'jjb' => 0,
],
$roll <= 70 => [
'emoji' => '🐡',
'message' => '钓到一条小鲤鱼增加经验50、金币10',
'exp' => 50,
'jjb' => 10,
],
$roll <= 85 => [
'emoji' => '💧',
'message' => '鱼没钓到摔到河里经验减少50',
'exp' => -50,
'jjb' => 0,
],
$roll <= 95 => [
'emoji' => '👊',
'message' => '偷钓鱼塘被主人发现一阵殴打经验减少20、金币减少3',
'exp' => -20,
'jjb' => -3,
],
default => [
'emoji' => '🎉',
'message' => '运气爆棚!钓到大鲨鱼、大草鱼、小鲤鱼各一条!经验+150金币+50',
'exp' => 150,
'jjb' => 50,
],
};
}
}