From b98ae7f94ed37fac753b2f763f390a2949b1f803 Mon Sep 17 00:00:00 2001 From: lkddi Date: Sun, 19 Apr 2026 12:14:10 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=89=8B=E6=9C=BA=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E5=8F=8A=E9=92=93=E9=B1=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/environments/environment-2.toml | 12 + app/Http/Controllers/ShopController.php | 7 +- bootstrap/app.php | 31 ++- .../views/chat/partials/scripts.blade.php | 208 ++++++++++++++---- tests/Feature/ChatControllerTest.php | 20 ++ tests/Feature/ShopControllerTest.php | 36 +++ 6 files changed, 273 insertions(+), 41 deletions(-) create mode 100644 .codex/environments/environment-2.toml diff --git a/.codex/environments/environment-2.toml b/.codex/environments/environment-2.toml new file mode 100644 index 0000000..af827c1 --- /dev/null +++ b/.codex/environments/environment-2.toml @@ -0,0 +1,12 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "chatroom" + +[setup] +script = "" + +[cleanup] +script = ''' +php artisan reverb:start +php artisan horizon +''' diff --git a/app/Http/Controllers/ShopController.php b/app/Http/Controllers/ShopController.php index 78032ce..2d03e33 100644 --- a/app/Http/Controllers/ShopController.php +++ b/app/Http/Controllers/ShopController.php @@ -167,12 +167,15 @@ class ShopController extends Controller $fishDuration = $mins >= 60 ? floor($mins / 60).'小时' : $mins.'分钟'; } + // 自动钓鱼卡购买通知要真正归属到“钓鱼播报”,这样前端屏蔽规则才能直接命中。 + $broadcastFromUser = $item->type === 'auto_fishing' ? '钓鱼播报' : '系统传音'; + // 根据商品类型生成不同通知文案 $sysContent = match ($item->type) { 'duration' => "📅 【{$user->username}】购买了全屏特效周卡「{$item->name}」,登录时将自动触发!", 'one_time' => "🎫 【{$user->username}】购买了「{$item->name}」道具!", 'ring' => "💍 【{$user->username}】在商店购买了一枚「{$item->name}」,不知道打算送给谁呢?", - 'auto_fishing' => "🎣【钓鱼播报】:【{$user->username}】购买了「{$item->name}」,开启了 {$fishDuration} 的自动钓鱼模式!", + 'auto_fishing' => "🎣 【{$user->username}】购买了「{$item->name}」,开启了 {$fishDuration} 的自动钓鱼模式!", default => "🛒 【{$user->username}】购买了「{$item->name}」。", }; @@ -181,7 +184,7 @@ class ShopController extends Controller message: [ 'id' => 0, 'room_id' => $roomId, - 'from_user' => '系统传音', + 'from_user' => $broadcastFromUser, 'to_user' => '大家', 'content' => $sysContent, 'font_color' => '#7c3aed', diff --git a/bootstrap/app.php b/bootstrap/app.php index d574260..b331bdb 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,9 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Illuminate\Http\Request; +use Illuminate\Session\TokenMismatchException; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -30,10 +33,34 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->redirectGuestsTo('/'); }) ->withExceptions(function (Exceptions $exceptions): void { + $isChatAjaxRequest = static function (Request $request): bool { + return $request->expectsJson() && $request->is( + 'room/*/send', + 'room/*/heartbeat', + 'room/*/leave', + 'room/*/announcement', + 'gift/*', + 'command/*', + 'chatbot/*', + 'shop/*' + ); + }; + // 聊天室 AJAX 接口:CSRF token 过期(419)时,返回 JSON 提示而非重定向 // 防止浏览器收到 302 后以 GET 方式重请求只允许 POST 的路由,产生 405 错误 - $exceptions->render(function (\Illuminate\Session\TokenMismatchException $e, \Illuminate\Http\Request $request) { - if ($request->is('room/*/send', 'room/*/heartbeat', 'room/*/leave', 'room/*/announcement', 'gift/*', 'command/*', 'chatbot/*', 'shop/*')) { + $exceptions->render(function (TokenMismatchException $e, Request $request) use ($isChatAjaxRequest) { + if ($isChatAjaxRequest($request)) { + return response()->json([ + 'status' => 'error', + 'message' => '页面已过期,请刷新后重试。', + ], 419); + } + }); + + // Laravel 在某些环境下会先把 TokenMismatchException 包装成 419 HttpException, + // 这里补一层兜底,确保聊天接口始终返回稳定的 JSON,而不是默认 HTML 错误页。 + $exceptions->render(function (HttpExceptionInterface $e, Request $request) use ($isChatAjaxRequest) { + if ($e->getStatusCode() === 419 && $isChatAjaxRequest($request)) { return response()->json([ 'status' => 'error', 'message' => '页面已过期,请刷新后重试。', diff --git a/resources/views/chat/partials/scripts.blade.php b/resources/views/chat/partials/scripts.blade.php index 1251d90..5eb962b 100644 --- a/resources/views/chat/partials/scripts.blade.php +++ b/resources/views/chat/partials/scripts.blade.php @@ -245,6 +245,11 @@ return '星海小博士'; } + // 兼容旧版自动钓鱼卡购买通知:历史上该消息曾以“系统传音”发送,但正文里带有“钓鱼播报”字样。 + if ((fromUser === '系统传音' || fromUser === '系统') && (content.includes('钓鱼播报') || content.includes('自动钓鱼模式'))) { + return '钓鱼播报'; + } + if ((fromUser === '系统传音' || fromUser === '系统') && content.includes('神秘箱子')) { return '神秘箱子'; } @@ -1739,19 +1744,93 @@ // ── 发送消息(Enter 发送,防 IME 输入法重复触发)──────── // 用 isComposing 标记中文输入法的组词状态,组词期间过滤掉 Enter let _imeComposing = false; + let _isSending = false; // 发送中防重入标记 + let _sendStartedAt = 0; // 记录发送开始时间,用于页面恢复后释放异常锁 const _contentInput = document.getElementById('content'); + const CHAT_DRAFT_STORAGE_KEY = `chat_draft_${window.chatContext?.roomId ?? '0'}_${window.chatContext?.userId ?? '0'}`; /** - * 更新底部图片选择状态提示。 + * 将当前输入框内容保存到会话级草稿缓存。 */ - function updateChatImageSelectionLabel(filename = '') { - const nameEl = document.getElementById('chat-image-name'); - if (!nameEl) { - return; + function persistChatDraft(value = null) { + try { + const draft = value ?? _contentInput?.value ?? ''; + if (draft === '') { + sessionStorage.removeItem(CHAT_DRAFT_STORAGE_KEY); + return; + } + + sessionStorage.setItem(CHAT_DRAFT_STORAGE_KEY, draft); + } catch (_) { + // 会话存储不可用时静默降级,不影响聊天主流程 + } + } + + /** + * 从会话缓存中恢复聊天草稿。 + * + * @returns {string} + */ + function loadChatDraft() { + try { + return sessionStorage.getItem(CHAT_DRAFT_STORAGE_KEY) || ''; + } catch (_) { + return ''; + } + } + + /** + * 将当前输入区状态整理为一份稳定快照,避免直接序列化整张表单。 + * + * @returns {Object} + */ + function collectChatComposerState() { + const contentInput = document.getElementById('content'); + const submitBtn = document.getElementById('send-btn'); + const imageInput = document.getElementById('chat_image'); + const toUserSelect = document.getElementById('to_user'); + const actionSelect = document.getElementById('action'); + const fontColorInput = document.getElementById('font_color'); + const secretCheckbox = document.getElementById('is_secret'); + const contentRaw = contentInput?.value ?? ''; + const selectedImage = imageInput?.files?.[0] ?? null; + + return { + contentInput, + submitBtn, + imageInput, + contentRaw, + content: contentRaw.trim(), + selectedImage, + toUser: toUserSelect?.value || '大家', + action: actionSelect?.value || '', + fontColor: fontColorInput?.value || '', + isSecret: Boolean(secretCheckbox?.checked), + }; + } + + /** + * 基于当前聊天快照构造稳定的 multipart 请求体。 + * + * @param {Object} composerState + * @returns {FormData} + */ + function buildChatMessageFormData(composerState) { + const formData = new FormData(); + formData.append('content', composerState.contentRaw); + formData.append('to_user', composerState.toUser); + formData.append('action', composerState.action); + formData.append('font_color', composerState.fontColor); + + if (composerState.isSecret) { + formData.append('is_secret', '1'); } - nameEl.textContent = filename || '未选择图片'; - nameEl.style.color = filename ? '#0f766e' : '#64748b'; + if (composerState.selectedImage) { + formData.append('image', composerState.selectedImage); + } + + return formData; } /** @@ -1760,12 +1839,9 @@ function handleChatImageSelected(input) { const file = input?.files?.[0] ?? null; if (!file) { - updateChatImageSelectionLabel(''); return; } - updateChatImageSelectionLabel(file.name); - // 用户选择图片后,立即触发自动发送 sendMessage(null); } @@ -1779,8 +1855,39 @@ if (resetInput && imageInput) { imageInput.value = ''; } + } - updateChatImageSelectionLabel(''); + /** + * 页面从后台恢复后,同步草稿、图片提示和发送锁状态。 + */ + function syncChatComposerAfterResume() { + if (!_contentInput) { + return; + } + + const savedDraft = loadChatDraft(); + if (_contentInput.value === '' && savedDraft !== '') { + _contentInput.value = savedDraft; + } else if (_contentInput.value !== '') { + persistChatDraft(_contentInput.value); + } + + const imageInput = document.getElementById('chat_image'); + if (!imageInput?.files?.length) { + clearSelectedChatImage(); + } + + _imeComposing = false; + + if (_isSending && Date.now() - _sendStartedAt > 15000) { + const submitBtn = document.getElementById('send-btn'); + if (submitBtn) { + submitBtn.disabled = false; + } + + _isSending = false; + _sendStartedAt = 0; + } } /** @@ -1832,36 +1939,52 @@ window.handleChatImageSelected = handleChatImageSelected; window.openChatImageLightbox = openChatImageLightbox; window.closeChatImageLightbox = closeChatImageLightbox; - updateChatImageSelectionLabel(); + syncChatComposerAfterResume(); - // 中文/日文等 IME 组词开始 - _contentInput.addEventListener('compositionstart', () => { - _imeComposing = true; - }); - // 组词结束(确认候选词完成),给 10ms 缓冲让 keydown 先被过滤掉 - _contentInput.addEventListener('compositionend', () => { - setTimeout(() => { - _imeComposing = false; - }, 10); - }); + if (_contentInput) { + _contentInput.addEventListener('input', function() { + persistChatDraft(this.value); + }); - _contentInput.addEventListener('keydown', function(e) { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - // IME 正在组词时(如选候选汉字),不触发发送 - if (_imeComposing) return; - sendMessage(e); + // 中文/日文等 IME 组词开始 + _contentInput.addEventListener('compositionstart', () => { + _imeComposing = true; + }); + // 组词结束(确认候选词完成),给 10ms 缓冲让 keydown 先被过滤掉 + _contentInput.addEventListener('compositionend', () => { + setTimeout(() => { + _imeComposing = false; + }, 10); + }); + + _contentInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + // IME 正在组词时(如选候选汉字),不触发发送 + if (_imeComposing) return; + sendMessage(e); + } + }); + } + + window.addEventListener('pageshow', syncChatComposerAfterResume); + document.addEventListener('visibilitychange', function() { + if (document.visibilityState === 'visible') { + syncChatComposerAfterResume(); } }); + window.addEventListener('focus', function() { + setTimeout(syncChatComposerAfterResume, 0); + }); /** * 发送聊天消息(内带防重入锁,避免快速连按 Enter 重复提交) */ - let _isSending = false; // 发送中防重入标记 async function sendMessage(e) { if (e) e.preventDefault(); if (_isSending) return; // 上一次还没结束,忽略 _isSending = true; + _sendStartedAt = Date.now(); // 前端禁言检查 if (isMutedUntil > Date.now()) { @@ -1878,25 +2001,28 @@ container2.scrollTop = container2.scrollHeight; } _isSending = false; + _sendStartedAt = 0; return; } - const form = document.getElementById('chat-form'); - const formData = new FormData(form); - const contentInput = document.getElementById('content'); - const submitBtn = document.getElementById('send-btn'); - const imageInput = document.getElementById('chat_image'); - const selectedImage = imageInput?.files?.[0] ?? null; + const composerState = collectChatComposerState(); + const { + contentInput, + submitBtn, + content, + contentRaw, + selectedImage, + toUser, + } = composerState; - const content = String(formData.get('content') || '').trim(); if (!content && !selectedImage) { contentInput.focus(); _isSending = false; + _sendStartedAt = 0; return; } // 如果发言对象是 AI 小助手,也发送一份给专用机器人 API,不打断正常的发消息流程 - const toUser = formData.get('to_user'); if (toUser === 'AI小班长' && content) { sendToChatBot(content); // 异步调用,不阻塞全局发送 } @@ -1907,6 +2033,7 @@ const passcodePattern = /^[A-Z0-9]{4,8}$/; if (!selectedImage && passcodePattern.test(content.trim())) { _isSending = false; + _sendStartedAt = 0; try { const claimRes = await fetch('/mystery-box/claim', { @@ -1925,6 +2052,7 @@ if (claimData.ok) { // ✅ 领取成功:清空输入框,不发送普通消息 contentInput.value = ''; + persistChatDraft(''); contentInput.focus(); // 清除活跃箱子全局标志 @@ -1955,6 +2083,10 @@ } submitBtn.disabled = true; + const formData = buildChatMessageFormData({ + ...composerState, + contentRaw, + }); try { const response = await fetch(window.chatContext.sendUrl, { @@ -1970,6 +2102,7 @@ const data = await response.json(); if (response.ok && data.status === 'success') { contentInput.value = ''; + persistChatDraft(''); clearSelectedChatImage(true); contentInput.focus(); } else { @@ -1982,6 +2115,7 @@ } finally { submitBtn.disabled = false; _isSending = false; // 释放发送锁,允许下次发送 + _sendStartedAt = 0; } } diff --git a/tests/Feature/ChatControllerTest.php b/tests/Feature/ChatControllerTest.php index 31a6c79..2792946 100644 --- a/tests/Feature/ChatControllerTest.php +++ b/tests/Feature/ChatControllerTest.php @@ -5,10 +5,12 @@ namespace Tests\Feature; use App\Models\Room; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\URL; +use Symfony\Component\HttpKernel\Exception\HttpException; use Tests\TestCase; /** @@ -195,6 +197,24 @@ class ChatControllerTest extends TestCase Storage::disk('public')->assertExists($payload['image_thumb_path']); } + /** + * 测试聊天室发送接口在 419 场景下会返回稳定的 JSON 提示。 + */ + public function test_chat_send_http_419_exception_renders_json_response(): void + { + $request = Request::create('/room/1/send', 'POST', server: [ + 'HTTP_ACCEPT' => 'application/json', + ]); + + $response = $this->app->make(\Illuminate\Contracts\Debug\ExceptionHandler::class) + ->render($request, new HttpException(419, 'Page Expired')); + + \Illuminate\Testing\TestResponse::fromBaseResponse($response)->assertStatus(419)->assertJson([ + 'status' => 'error', + 'message' => '页面已过期,请刷新后重试。', + ]); + } + /** * 测试超过保留期的聊天图片会被命令清理并改成过期占位消息。 */ diff --git a/tests/Feature/ShopControllerTest.php b/tests/Feature/ShopControllerTest.php index ccf21ff..b740071 100644 --- a/tests/Feature/ShopControllerTest.php +++ b/tests/Feature/ShopControllerTest.php @@ -7,10 +7,12 @@ namespace Tests\Feature; +use App\Events\MessageSent; use App\Models\ShopItem; use App\Models\User; use App\Models\UserPurchase; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Event; use Tests\TestCase; /** @@ -214,4 +216,38 @@ class ShopControllerTest extends TestCase 'status' => 'used', ]); } + + /** + * 测试购买自动钓鱼卡时会以钓鱼播报身份广播,便于前端屏蔽规则命中。 + */ + public function test_buy_auto_fishing_card_broadcasts_as_fishing_sender(): void + { + Event::fake([MessageSent::class]); + + $user = User::factory()->create(['jjb' => 500]); + + $item = ShopItem::create([ + 'name' => '自动钓鱼卡(2小时)', + 'slug' => 'auto_fishing_test_2h', + 'type' => 'auto_fishing', + 'price' => 100, + 'duration_minutes' => 120, + 'is_active' => true, + ]); + + $response = $this->actingAs($user)->postJson(route('shop.buy'), [ + 'item_id' => $item->id, + 'room_id' => 1, + ]); + + $response->assertOk(); + $response->assertJson(['status' => 'success']); + + Event::assertDispatched(MessageSent::class, function (MessageSent $event) use ($user, $item): bool { + return $event->roomId === 1 + && ($event->message['from_user'] ?? null) === '钓鱼播报' + && str_contains((string) ($event->message['content'] ?? ''), $user->username) + && str_contains((string) ($event->message['content'] ?? ''), $item->name); + }); + } }