优化手机输入及钓鱼
This commit is contained in:
@@ -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
|
||||||
|
'''
|
||||||
@@ -167,12 +167,15 @@ class ShopController extends Controller
|
|||||||
$fishDuration = $mins >= 60 ? floor($mins / 60).'小时' : $mins.'分钟';
|
$fishDuration = $mins >= 60 ? floor($mins / 60).'小时' : $mins.'分钟';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自动钓鱼卡购买通知要真正归属到“钓鱼播报”,这样前端屏蔽规则才能直接命中。
|
||||||
|
$broadcastFromUser = $item->type === 'auto_fishing' ? '钓鱼播报' : '系统传音';
|
||||||
|
|
||||||
// 根据商品类型生成不同通知文案
|
// 根据商品类型生成不同通知文案
|
||||||
$sysContent = match ($item->type) {
|
$sysContent = match ($item->type) {
|
||||||
'duration' => "📅 【{$user->username}】购买了全屏特效周卡「{$item->name}」,登录时将自动触发!",
|
'duration' => "📅 【{$user->username}】购买了全屏特效周卡「{$item->name}」,登录时将自动触发!",
|
||||||
'one_time' => "🎫 【{$user->username}】购买了「{$item->name}」道具!",
|
'one_time' => "🎫 【{$user->username}】购买了「{$item->name}」道具!",
|
||||||
'ring' => "💍 【{$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}」。",
|
default => "🛒 【{$user->username}】购买了「{$item->name}」。",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -181,7 +184,7 @@ class ShopController extends Controller
|
|||||||
message: [
|
message: [
|
||||||
'id' => 0,
|
'id' => 0,
|
||||||
'room_id' => $roomId,
|
'room_id' => $roomId,
|
||||||
'from_user' => '系统传音',
|
'from_user' => $broadcastFromUser,
|
||||||
'to_user' => '大家',
|
'to_user' => '大家',
|
||||||
'content' => $sysContent,
|
'content' => $sysContent,
|
||||||
'font_color' => '#7c3aed',
|
'font_color' => '#7c3aed',
|
||||||
|
|||||||
+29
-2
@@ -3,6 +3,9 @@
|
|||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
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__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
@@ -30,10 +33,34 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
$middleware->redirectGuestsTo('/');
|
$middleware->redirectGuestsTo('/');
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->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 提示而非重定向
|
// 聊天室 AJAX 接口:CSRF token 过期(419)时,返回 JSON 提示而非重定向
|
||||||
// 防止浏览器收到 302 后以 GET 方式重请求只允许 POST 的路由,产生 405 错误
|
// 防止浏览器收到 302 后以 GET 方式重请求只允许 POST 的路由,产生 405 错误
|
||||||
$exceptions->render(function (\Illuminate\Session\TokenMismatchException $e, \Illuminate\Http\Request $request) {
|
$exceptions->render(function (TokenMismatchException $e, Request $request) use ($isChatAjaxRequest) {
|
||||||
if ($request->is('room/*/send', 'room/*/heartbeat', 'room/*/leave', 'room/*/announcement', 'gift/*', 'command/*', 'chatbot/*', 'shop/*')) {
|
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([
|
return response()->json([
|
||||||
'status' => 'error',
|
'status' => 'error',
|
||||||
'message' => '页面已过期,请刷新后重试。',
|
'message' => '页面已过期,请刷新后重试。',
|
||||||
|
|||||||
@@ -245,6 +245,11 @@
|
|||||||
return '星海小博士';
|
return '星海小博士';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 兼容旧版自动钓鱼卡购买通知:历史上该消息曾以“系统传音”发送,但正文里带有“钓鱼播报”字样。
|
||||||
|
if ((fromUser === '系统传音' || fromUser === '系统') && (content.includes('钓鱼播报') || content.includes('自动钓鱼模式'))) {
|
||||||
|
return '钓鱼播报';
|
||||||
|
}
|
||||||
|
|
||||||
if ((fromUser === '系统传音' || fromUser === '系统') && content.includes('神秘箱子')) {
|
if ((fromUser === '系统传音' || fromUser === '系统') && content.includes('神秘箱子')) {
|
||||||
return '神秘箱子';
|
return '神秘箱子';
|
||||||
}
|
}
|
||||||
@@ -1739,19 +1744,93 @@
|
|||||||
// ── 发送消息(Enter 发送,防 IME 输入法重复触发)────────
|
// ── 发送消息(Enter 发送,防 IME 输入法重复触发)────────
|
||||||
// 用 isComposing 标记中文输入法的组词状态,组词期间过滤掉 Enter
|
// 用 isComposing 标记中文输入法的组词状态,组词期间过滤掉 Enter
|
||||||
let _imeComposing = false;
|
let _imeComposing = false;
|
||||||
|
let _isSending = false; // 发送中防重入标记
|
||||||
|
let _sendStartedAt = 0; // 记录发送开始时间,用于页面恢复后释放异常锁
|
||||||
const _contentInput = document.getElementById('content');
|
const _contentInput = document.getElementById('content');
|
||||||
|
const CHAT_DRAFT_STORAGE_KEY = `chat_draft_${window.chatContext?.roomId ?? '0'}_${window.chatContext?.userId ?? '0'}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新底部图片选择状态提示。
|
* 将当前输入框内容保存到会话级草稿缓存。
|
||||||
*/
|
*/
|
||||||
function updateChatImageSelectionLabel(filename = '') {
|
function persistChatDraft(value = null) {
|
||||||
const nameEl = document.getElementById('chat-image-name');
|
try {
|
||||||
if (!nameEl) {
|
const draft = value ?? _contentInput?.value ?? '';
|
||||||
return;
|
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 || '未选择图片';
|
if (composerState.selectedImage) {
|
||||||
nameEl.style.color = filename ? '#0f766e' : '#64748b';
|
formData.append('image', composerState.selectedImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return formData;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1760,12 +1839,9 @@
|
|||||||
function handleChatImageSelected(input) {
|
function handleChatImageSelected(input) {
|
||||||
const file = input?.files?.[0] ?? null;
|
const file = input?.files?.[0] ?? null;
|
||||||
if (!file) {
|
if (!file) {
|
||||||
updateChatImageSelectionLabel('');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateChatImageSelectionLabel(file.name);
|
|
||||||
|
|
||||||
// 用户选择图片后,立即触发自动发送
|
// 用户选择图片后,立即触发自动发送
|
||||||
sendMessage(null);
|
sendMessage(null);
|
||||||
}
|
}
|
||||||
@@ -1779,8 +1855,39 @@
|
|||||||
if (resetInput && imageInput) {
|
if (resetInput && imageInput) {
|
||||||
imageInput.value = '';
|
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.handleChatImageSelected = handleChatImageSelected;
|
||||||
window.openChatImageLightbox = openChatImageLightbox;
|
window.openChatImageLightbox = openChatImageLightbox;
|
||||||
window.closeChatImageLightbox = closeChatImageLightbox;
|
window.closeChatImageLightbox = closeChatImageLightbox;
|
||||||
updateChatImageSelectionLabel();
|
syncChatComposerAfterResume();
|
||||||
|
|
||||||
// 中文/日文等 IME 组词开始
|
if (_contentInput) {
|
||||||
_contentInput.addEventListener('compositionstart', () => {
|
_contentInput.addEventListener('input', function() {
|
||||||
_imeComposing = true;
|
persistChatDraft(this.value);
|
||||||
});
|
});
|
||||||
// 组词结束(确认候选词完成),给 10ms 缓冲让 keydown 先被过滤掉
|
|
||||||
_contentInput.addEventListener('compositionend', () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
_imeComposing = false;
|
|
||||||
}, 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
_contentInput.addEventListener('keydown', function(e) {
|
// 中文/日文等 IME 组词开始
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
_contentInput.addEventListener('compositionstart', () => {
|
||||||
e.preventDefault();
|
_imeComposing = true;
|
||||||
// IME 正在组词时(如选候选汉字),不触发发送
|
});
|
||||||
if (_imeComposing) return;
|
// 组词结束(确认候选词完成),给 10ms 缓冲让 keydown 先被过滤掉
|
||||||
sendMessage(e);
|
_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 重复提交)
|
* 发送聊天消息(内带防重入锁,避免快速连按 Enter 重复提交)
|
||||||
*/
|
*/
|
||||||
let _isSending = false; // 发送中防重入标记
|
|
||||||
async function sendMessage(e) {
|
async function sendMessage(e) {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
if (_isSending) return; // 上一次还没结束,忽略
|
if (_isSending) return; // 上一次还没结束,忽略
|
||||||
_isSending = true;
|
_isSending = true;
|
||||||
|
_sendStartedAt = Date.now();
|
||||||
|
|
||||||
// 前端禁言检查
|
// 前端禁言检查
|
||||||
if (isMutedUntil > Date.now()) {
|
if (isMutedUntil > Date.now()) {
|
||||||
@@ -1878,25 +2001,28 @@
|
|||||||
container2.scrollTop = container2.scrollHeight;
|
container2.scrollTop = container2.scrollHeight;
|
||||||
}
|
}
|
||||||
_isSending = false;
|
_isSending = false;
|
||||||
|
_sendStartedAt = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = document.getElementById('chat-form');
|
const composerState = collectChatComposerState();
|
||||||
const formData = new FormData(form);
|
const {
|
||||||
const contentInput = document.getElementById('content');
|
contentInput,
|
||||||
const submitBtn = document.getElementById('send-btn');
|
submitBtn,
|
||||||
const imageInput = document.getElementById('chat_image');
|
content,
|
||||||
const selectedImage = imageInput?.files?.[0] ?? null;
|
contentRaw,
|
||||||
|
selectedImage,
|
||||||
|
toUser,
|
||||||
|
} = composerState;
|
||||||
|
|
||||||
const content = String(formData.get('content') || '').trim();
|
|
||||||
if (!content && !selectedImage) {
|
if (!content && !selectedImage) {
|
||||||
contentInput.focus();
|
contentInput.focus();
|
||||||
_isSending = false;
|
_isSending = false;
|
||||||
|
_sendStartedAt = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果发言对象是 AI 小助手,也发送一份给专用机器人 API,不打断正常的发消息流程
|
// 如果发言对象是 AI 小助手,也发送一份给专用机器人 API,不打断正常的发消息流程
|
||||||
const toUser = formData.get('to_user');
|
|
||||||
if (toUser === 'AI小班长' && content) {
|
if (toUser === 'AI小班长' && content) {
|
||||||
sendToChatBot(content); // 异步调用,不阻塞全局发送
|
sendToChatBot(content); // 异步调用,不阻塞全局发送
|
||||||
}
|
}
|
||||||
@@ -1907,6 +2033,7 @@
|
|||||||
const passcodePattern = /^[A-Z0-9]{4,8}$/;
|
const passcodePattern = /^[A-Z0-9]{4,8}$/;
|
||||||
if (!selectedImage && passcodePattern.test(content.trim())) {
|
if (!selectedImage && passcodePattern.test(content.trim())) {
|
||||||
_isSending = false;
|
_isSending = false;
|
||||||
|
_sendStartedAt = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const claimRes = await fetch('/mystery-box/claim', {
|
const claimRes = await fetch('/mystery-box/claim', {
|
||||||
@@ -1925,6 +2052,7 @@
|
|||||||
if (claimData.ok) {
|
if (claimData.ok) {
|
||||||
// ✅ 领取成功:清空输入框,不发送普通消息
|
// ✅ 领取成功:清空输入框,不发送普通消息
|
||||||
contentInput.value = '';
|
contentInput.value = '';
|
||||||
|
persistChatDraft('');
|
||||||
contentInput.focus();
|
contentInput.focus();
|
||||||
|
|
||||||
// 清除活跃箱子全局标志
|
// 清除活跃箱子全局标志
|
||||||
@@ -1955,6 +2083,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
|
const formData = buildChatMessageFormData({
|
||||||
|
...composerState,
|
||||||
|
contentRaw,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(window.chatContext.sendUrl, {
|
const response = await fetch(window.chatContext.sendUrl, {
|
||||||
@@ -1970,6 +2102,7 @@
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (response.ok && data.status === 'success') {
|
if (response.ok && data.status === 'success') {
|
||||||
contentInput.value = '';
|
contentInput.value = '';
|
||||||
|
persistChatDraft('');
|
||||||
clearSelectedChatImage(true);
|
clearSelectedChatImage(true);
|
||||||
contentInput.focus();
|
contentInput.focus();
|
||||||
} else {
|
} else {
|
||||||
@@ -1982,6 +2115,7 @@
|
|||||||
} finally {
|
} finally {
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
_isSending = false; // 释放发送锁,允许下次发送
|
_isSending = false; // 释放发送锁,允许下次发送
|
||||||
|
_sendStartedAt = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ namespace Tests\Feature;
|
|||||||
use App\Models\Room;
|
use App\Models\Room;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\Redis;
|
use Illuminate\Support\Facades\Redis;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -195,6 +197,24 @@ class ChatControllerTest extends TestCase
|
|||||||
Storage::disk('public')->assertExists($payload['image_thumb_path']);
|
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' => '页面已过期,请刷新后重试。',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 测试超过保留期的聊天图片会被命令清理并改成过期占位消息。
|
* 测试超过保留期的聊天图片会被命令清理并改成过期占位消息。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,10 +7,12 @@
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Events\MessageSent;
|
||||||
use App\Models\ShopItem;
|
use App\Models\ShopItem;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserPurchase;
|
use App\Models\UserPurchase;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -214,4 +216,38 @@ class ShopControllerTest extends TestCase
|
|||||||
'status' => 'used',
|
'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);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user