新增聊天室发送图片功能

This commit is contained in:
2026-04-12 14:04:18 +08:00
parent d2f08eb2dd
commit 00b9396dea
10 changed files with 547 additions and 42 deletions
+69 -1
View File
@@ -17,7 +17,12 @@ use App\Models\Message;
use App\Models\Sysparam;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
/**
* 定期清理聊天记录命令
* 负责删除过期文本消息,并额外回收聊天图片文件。
*/
class PurgeOldMessages extends Command
{
/**
@@ -27,6 +32,7 @@ class PurgeOldMessages extends Command
*/
protected $signature = 'messages:purge
{--days= : 覆盖默认保留天数}
{--image-days=3 : 聊天图片单独保留天数}
{--dry-run : 仅预览不实际删除}';
/**
@@ -34,7 +40,7 @@ class PurgeOldMessages extends Command
*
* @var string
*/
protected $description = '清理超过指定天数的聊天记录(保留天数由 sysparam message_retention_days 配置,默认 30';
protected $description = '清理过期聊天记录,并额外清理 3 天前的聊天图片文件';
/**
* 执行命令
@@ -46,10 +52,13 @@ class PurgeOldMessages extends Command
// 保留天数:命令行参数 > sysparam 配置 > 默认 30 天
$days = (int) ($this->option('days')
?: Sysparam::getValue('message_retention_days', '30'));
$imageDays = max(0, (int) $this->option('image-days'));
$cutoff = Carbon::now()->subDays($days);
$isDryRun = $this->option('dry-run');
$this->cleanupExpiredImages($imageDays, $isDryRun);
// 统计待清理数量
$totalCount = Message::where('sent_at', '<', $cutoff)->count();
@@ -88,4 +97,63 @@ class PurgeOldMessages extends Command
return self::SUCCESS;
}
/**
* 清理超过图片保留天数的聊天图片文件,并把消息改成过期占位。
*/
private function cleanupExpiredImages(int $imageDays, bool $isDryRun): void
{
$imageCutoff = Carbon::now()->subDays($imageDays);
$query = Message::query()
->where('message_type', 'image')
->where('sent_at', '<', $imageCutoff)
->where(function ($builder) {
$builder->whereNotNull('image_path')->orWhereNotNull('image_thumb_path');
});
$totalCount = (clone $query)->count();
if ($totalCount === 0) {
$this->line("🖼️ 没有超过 {$imageDays} 天的聊天图片需要清理。");
return;
}
if ($isDryRun) {
$this->warn("🔍 [预览模式] 将清理 {$totalCount} 条超过 {$imageDays} 天的聊天图片(截止 {$imageCutoff->toDateTimeString()}");
return;
}
$processed = 0;
$query->orderBy('id')->chunkById(200, function ($messages) use (&$processed) {
foreach ($messages as $message) {
$paths = array_values(array_filter([
$message->image_path,
$message->image_thumb_path,
]));
// 先删物理文件,再把数据库消息降级成“图片已过期”占位,避免出现坏图。
if ($paths !== []) {
Storage::disk('public')->delete($paths);
}
$placeholder = trim((string) $message->content);
$placeholder = $placeholder !== '' ? $placeholder.' [图片已过期]' : '[图片已过期]';
$message->forceFill([
'content' => $placeholder,
'message_type' => 'expired_image',
'image_path' => null,
'image_thumb_path' => null,
'image_original_name' => null,
])->save();
$processed++;
}
});
$this->info("🖼️ 已清理 {$processed} 条超过 {$imageDays} 天的聊天图片。");
}
}
+53 -3
View File
@@ -31,14 +31,20 @@ use App\Services\UserCurrencyService;
use App\Services\VipService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
/**
* 聊天室核心控制器
* 负责进房、发言、退房、公告与聊天室内各种实时交互。
*/
class ChatController extends Controller
{
/**
@@ -333,6 +339,7 @@ class ChatController extends Controller
{
$data = $request->validated();
$user = Auth::user();
$imagePayload = null;
// 0. 检查用户是否被禁言(Redis TTL 自动过期)
$muteKey = "mute:{$id}:{$user->username}";
@@ -360,12 +367,18 @@ class ChatController extends Controller
}
}
// 1. 过滤净化消息体
$pureContent = $this->filter->filter($data['content'] ?? '');
if ($pureContent === '') {
// 1. 过滤净化消息体;若本次只发图片,则允许文本内容为空。
$rawContent = (string) ($data['content'] ?? '');
$pureContent = $rawContent !== '' ? $this->filter->filter($rawContent) : '';
if ($pureContent === '' && ! $request->hasFile('image')) {
return response()->json(['status' => 'error', 'message' => '消息内容不能为空或不合法。'], 422);
}
// 2. 若带图片,则生成原图与缩略图并按日期目录保存。
if ($request->hasFile('image')) {
$imagePayload = $this->storeChatImage($request->file('image'), $user->id);
}
// 2. 封装消息对象
$messageData = [
'id' => $this->chatState->nextMessageId($id), // 分布式安全自增序号
@@ -376,8 +389,12 @@ class ChatController extends Controller
'is_secret' => $data['is_secret'] ?? false,
'font_color' => $data['font_color'] ?? '',
'action' => $data['action'] ?? '',
'message_type' => $imagePayload ? 'image' : 'text',
'sent_at' => now()->toDateTimeString(),
];
if ($imagePayload !== null) {
$messageData = array_merge($messageData, $imagePayload);
}
// 3. 压入 Redis 缓存列表 (防炸内存,只保留最近 N 条)
$this->chatState->pushMessage($id, $messageData);
@@ -405,6 +422,39 @@ class ChatController extends Controller
return response()->json(['status' => 'success']);
}
/**
* 保存聊天图片并生成原图、缩略图两份资源。
*
* @return array<string, string>
*/
private function storeChatImage(UploadedFile $image, int $userId): array
{
$manager = new ImageManager(new Driver);
$extension = strtolower($image->extension() ?: 'jpg');
$datePath = now()->format('Y-m-d');
$basename = 'chat_'.$userId.'_'.Str::uuid();
$originalPath = "chat-images/{$datePath}/{$basename}_original.{$extension}";
$thumbPath = "chat-images/{$datePath}/{$basename}_thumb.{$extension}";
// 原图仅做缩边处理,避免超大文件直接灌进磁盘。
$originalImage = $manager->read($image);
$originalImage->scaleDown(width: 1600, height: 1600);
Storage::disk('public')->put($originalPath, (string) $originalImage->encode());
// 缩略图限制在 220px 范围内,聊天室里只展示轻量小图。
$thumbImage = $manager->read($image);
$thumbImage->scaleDown(width: 220, height: 220);
Storage::disk('public')->put($thumbPath, (string) $thumbImage->encode());
return [
'image_path' => $originalPath,
'image_thumb_path' => $thumbPath,
'image_original_name' => $image->getClientOriginalName(),
'image_url' => Storage::url($originalPath),
'image_thumb_url' => Storage::url($thumbPath),
];
}
/**
* 自动挂机存点心跳与经验升级 (新增)
* 替代原版定时 iframe 刷新的 save.asp。
+18 -6
View File
@@ -14,10 +14,14 @@ use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
/**
* 聊天室发言请求验证器
* 负责统一校验文本消息与图片消息的发送参数。
*/
class SendMessageRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
* 判断当前请求是否允许继续。
*/
public function authorize(): bool
{
@@ -25,14 +29,15 @@ class SendMessageRequest extends FormRequest
}
/**
* Get the validation rules that apply to the request.
* 返回发言请求的校验规则。
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<int, mixed>|string>
*/
public function rules(): array
{
return [
'content' => ['required', 'string', 'max:500'], // 防止超长文本炸服
'content' => ['nullable', 'required_without:image', 'string', 'max:500'], // 文本与图片至少二选一
'image' => ['nullable', 'required_without:content', 'file', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:6144'],
'to_user' => ['nullable', 'string', 'max:50'],
'is_secret' => ['nullable', 'boolean'],
'font_color' => ['nullable', 'string', 'max:10'], // html color hex
@@ -40,18 +45,25 @@ class SendMessageRequest extends FormRequest
];
}
/**
* 返回校验失败时的中文提示。
*/
public function messages(): array
{
return [
'content.required' => '不能发送空消息。',
'content.required_without' => '文字内容和图片至少要发送一项。',
'content.max' => '发言内容不能超过 500 个字符。',
'image.required_without' => '文字内容和图片至少要发送一项。',
'image.image' => '上传的文件必须是图片。',
'image.mimes' => '仅支持 jpg、jpeg、png、gif、webp 图片格式。',
'image.max' => '图片大小不能超过 6MB。',
];
}
/**
* 重写验证失败的处理,无论如何(就算未按 ajax 标准提交)都必须抛出 JSON,不可以触发网页重定向去走 GET 请求而引发 302 方法错误
*/
protected function failedValidation(Validator $validator)
protected function failedValidation(Validator $validator): void
{
throw new HttpResponseException(response()->json([
'status' => 'error',
+11 -4
View File
@@ -16,22 +16,25 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Carbon;
/**
* 异步聊天消息持久化任务
* 负责把 Redis 中的聊天消息安全写入数据库归档。
*/
class SaveMessageJob implements ShouldQueue
{
use Queueable;
/**
* Create a new job instance.
* 创建聊天消息持久化任务。
*
* @param array $messageData 包装好的消息数组
* @param array<string, mixed> $messageData 包装好的消息数组
*/
public function __construct(
public readonly array $messageData
) {}
/**
* Execute the job.
* 将缓存在 Redis 刚广播出去的消息,真实映射写入到 `messages` 数据表。
* 执行队列任务,将已广播的聊天消息写入数据库。
*/
public function handle(): void
{
@@ -43,6 +46,10 @@ class SaveMessageJob implements ShouldQueue
'is_secret' => $this->messageData['is_secret'] ?? false,
'font_color' => $this->messageData['font_color'] ?? '',
'action' => $this->messageData['action'] ?? '',
'message_type' => $this->messageData['message_type'] ?? 'text',
'image_path' => $this->messageData['image_path'] ?? null,
'image_thumb_path' => $this->messageData['image_thumb_path'] ?? null,
'image_original_name' => $this->messageData['image_original_name'] ?? null,
// 恢复 Carbon 时间对象
'sent_at' => Carbon::parse($this->messageData['sent_at']),
]);
+9 -1
View File
@@ -14,6 +14,10 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* 聊天消息模型
* 负责承载聊天室文本消息、图片消息与过期图片占位消息。
*/
class Message extends Model
{
/**
@@ -29,11 +33,15 @@ class Message extends Model
'is_secret',
'font_color',
'action',
'message_type',
'image_path',
'image_thumb_path',
'image_original_name',
'sent_at',
];
/**
* Get the attributes that should be cast.
* 返回模型字段的类型转换配置。
*
* @return array<string, string>
*/
@@ -0,0 +1,44 @@
<?php
/**
* 文件功能:为聊天消息表补充图片消息存储字段
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 聊天消息图片字段迁移
* 为消息表增加图片类型、原图、缩略图与原始文件名字段。
*/
return new class extends Migration
{
/**
* 执行迁移,补充图片消息类型与图片路径字段。
*/
public function up(): void
{
Schema::table('messages', function (Blueprint $table) {
$table->string('message_type', 20)->default('text')->after('action')->comment('消息类型:text/image/expired_image');
$table->string('image_path', 255)->nullable()->after('message_type')->comment('聊天图片原图相对路径');
$table->string('image_thumb_path', 255)->nullable()->after('image_path')->comment('聊天图片缩略图相对路径');
$table->string('image_original_name', 255)->nullable()->after('image_thumb_path')->comment('聊天图片原始文件名');
});
}
/**
* 回滚迁移,移除聊天图片相关字段。
*/
public function down(): void
{
Schema::table('messages', function (Blueprint $table) {
$table->dropColumn([
'message_type',
'image_path',
'image_thumb_path',
'image_original_name',
]);
});
}
};
+19 -1
View File
@@ -115,7 +115,8 @@
claimEnvelopeUrl: (id, ceremonyId) => `/wedding/${id}/claim`,
envelopeStatusUrl: (id) => `/wedding/${id}/envelope-status`,
},
earnRewardUrl: "{{ route('earn.video_reward') }}"
earnRewardUrl: "{{ route('earn.video_reward') }}",
chatImageRetentionDays: 3
};
</script>
@vite(['resources/css/app.css', 'resources/js/app.js', 'resources/js/chat.js'])
@@ -174,6 +175,23 @@
@include('chat.partials.global-dialog')
{{-- Toast 轻提示 --}}
@include('chat.partials.toast-notification')
{{-- 聊天图片大图预览层 --}}
<div id="chat-image-lightbox"
style="display:none; position:fixed; inset:0; z-index:10020; background:rgba(15,23,42,.86); backdrop-filter:blur(4px);"
onclick="closeChatImageLightbox(event)">
<div
style="position:absolute; inset:0; display:flex; align-items:center; justify-content:center; padding:32px;"
onclick="closeChatImageLightbox(event)">
<img id="chat-image-lightbox-img" src="" alt="聊天图片预览"
onclick="event.stopPropagation()"
style="max-width:92vw; max-height:86vh; border-radius:12px; box-shadow:0 18px 50px rgba(0,0,0,.45);">
</div>
<button type="button" onclick="closeChatImageLightbox(event)"
style="position:absolute; top:20px; right:24px; z-index:10021; border:none; background:transparent; color:#fff; font-size:34px; cursor:pointer;">&times;</button>
<div id="chat-image-lightbox-name"
style="position:absolute; left:50%; bottom:24px; transform:translateX(-50%); z-index:10021; max-width:88vw; color:#e2e8f0; font-size:12px; text-align:center; word-break:break-all;">
</div>
</div>
{{-- 大卡片通知(任命公告、好友通知、礼包选择等) --}}
@include('chat.partials.chat-banner')
@@ -8,7 +8,7 @@
--}}
<div class="input-bar">
<form id="chat-form" onsubmit="sendMessage(event)">
<form id="chat-form" onsubmit="sendMessage(event)" enctype="multipart/form-data">
{{-- 第一行:工具选项 --}}
<div class="input-row">
<label>
@@ -81,18 +81,18 @@
</button>
<div id="welcome-menu" class="welcome-menu" style="display:none;">
@php
$welcomeMessages = [
'欢迎【{name}】来到我们的聊天室,请遵守规则,文明聊天!',
'【{name}】,你好!欢迎来访,有什么问题随时告诉我们!',
'热烈欢迎【{name}】加入,愿您在这里度过愉快的时光!',
'欢迎新朋友【{name}】!请先阅读公告,了解聊天室规则哦~',
'【{name}】来了!欢迎欢迎,希望你在这里玩得开心!',
'亲爱的【{name}】,欢迎光临本聊天室,请保持文明礼貌!',
'欢迎【{name}】入驻!有问题请联系管理员,我们随时为您服务!',
'【{name}】,初来乍到,欢迎多多关照,我们是一家人!',
'大家欢迎新成员【{name}】!请遵守群规,共建和谐聊天环境!',
'欢迎【{name}】莅临指导!希望你常来,让我们一起聊天!',
];
$welcomeMessages = [
'欢迎【{name}】来到我们的聊天室,请遵守规则,文明聊天!',
'【{name}】,你好!欢迎来访,有什么问题随时告诉我们!',
'热烈欢迎【{name}】加入,愿您在这里度过愉快的时光!',
'欢迎新朋友【{name}】!请先阅读公告,了解聊天室规则哦~',
'【{name}】来了!欢迎欢迎,希望你在这里玩得开心!',
'亲爱的【{name}】,欢迎光临本聊天室,请保持文明礼貌!',
'欢迎【{name}】入驻!有问题请联系管理员,我们随时为您服务!',
'【{name}】,初来乍到,欢迎多多关照,我们是一家人!',
'大家欢迎新成员【{name}】!请遵守群规,共建和谐聊天环境!',
'欢迎【{name}】莅临指导!希望你常来,让我们一起聊天!',
];
@endphp
@foreach ($welcomeMessages as $msg)
<div class="welcome-menu-item" onclick="sendWelcomeTpl({{ json_encode($msg) }})">
@@ -102,8 +102,9 @@
</div>
@if (
$user->user_level >= (int) \App\Models\Sysparam::getValue('level_announcement', '10') ||
$room->master == $user->username)
$user->user_level >= (int) \App\Models\Sysparam::getValue('level_announcement', '10') ||
$room->master == $user->username
)
<button type="button" onclick="promptAnnouncement()"
style="font-size: 11px; padding: 1px 6px; background: #4a9; color: #fff; border: none; border-radius: 2px; cursor: pointer;">设公告</button>
@endif
@@ -112,6 +113,13 @@
style="font-size: 11px; padding: 1px 6px; background: #2563eb; color: #fff; border: none; border-radius: 2px; cursor: pointer;">🎣
钓鱼</button>
<input type="file" id="chat_image" name="image" accept="image/jpeg,image/png,image/gif,image/webp" style="display:none;"
onchange="handleChatImageSelected(this)">
<button type="button" onclick="document.getElementById('chat_image')?.click()"
style="font-size: 11px; padding: 3px 8px; background: #0f766e; color: #fff; border: none; border-radius: 3px; cursor: pointer;">
📷 图片
</button>
@if ($user->user_level >= (int) \App\Models\Sysparam::getValue('superlevel', '100'))
<button type="button" onclick="promptAnnounceMessage()"
style="font-size: 11px; padding: 1px 6px; background: #7c3aed; color: #fff; border: none; border-radius: 2px; cursor: pointer;">📢
@@ -147,8 +155,10 @@
{{-- 第二行:输入框 + 发送 --}}
<div class="input-row">
<input type="text" id="content" name="content" class="say-input"
placeholder="在这里输入聊天内容,按 Enter 发送..." autocomplete="off">
placeholder="在这里输入聊天内容或发送图片,按 Enter 发送..." autocomplete="off">
<button type="submit" id="send-btn" class="send-btn">发送</button>
</div>
</form>
+178 -7
View File
@@ -151,6 +151,80 @@
window.showVipPresenceBanner = showVipPresenceBanner;
/**
* 判断图片消息是否已经超过前端允许展示的保留期。
*/
function isExpiredChatImageMessage(msg) {
if (!msg) {
return false;
}
if (msg.message_type === 'expired_image') {
return true;
}
if (msg.message_type !== 'image') {
return false;
}
if (!msg.image_url || !msg.image_thumb_url) {
return true;
}
const retentionDays = parseInt(window.chatContext?.chatImageRetentionDays || 3, 10);
const sentAtText = String(msg.sent_at || '').replace(' ', 'T');
const sentAt = sentAtText ? new Date(sentAtText) : null;
if (!sentAt || Number.isNaN(sentAt.getTime())) {
return false;
}
return Date.now() >= sentAt.getTime() + retentionDays * 24 * 60 * 60 * 1000;
}
/**
* 构建普通聊天消息的正文区域,支持缩略图与过期占位渲染。
*/
function buildChatMessageContent(msg, fontColor) {
const rawContent = msg.content || '';
if (msg.message_type === 'image' && !isExpiredChatImageMessage(msg)) {
const fullUrl = escapeHtml(msg.image_url || '');
const thumbUrl = escapeHtml(msg.image_thumb_url || '');
const imageName = escapeHtml(msg.image_original_name || '聊天图片');
const captionHtml = rawContent ?
`<span style="display:inline-block; max-width:220px; color:${fontColor}; line-height:1.55;">${rawContent}</span>` :
'';
return `
<span style="display:inline-flex; align-items:flex-start; gap:6px; vertical-align:middle;">
<a href="${fullUrl}" data-full="${fullUrl}" data-alt="${imageName}"
onclick="openChatImageLightbox(this.dataset.full, this.dataset.alt); return false;"
style="display:inline-block; border:1px solid rgba(15,23,42,.14); border-radius:10px; overflow:hidden; background:#f8fafc; box-shadow:0 2px 10px rgba(15,23,42,.10);">
<img src="${thumbUrl}" alt="${imageName}"
style="display:block; max-width:96px; max-height:96px; object-fit:cover; cursor:zoom-in;">
</a>
${captionHtml}
</span>
`;
}
if (msg.message_type === 'expired_image' || isExpiredChatImageMessage(msg)) {
const captionHtml = rawContent ?
`<span style="display:inline-block; color:${fontColor}; line-height:1.55;">${rawContent}</span>` :
'';
return `
<span style="display:inline-flex; align-items:center; gap:6px; vertical-align:middle;">
<span style="display:inline-flex; align-items:center; padding:4px 8px; border:1px dashed #94a3b8; border-radius:999px; background:#f8fafc; color:#64748b; font-size:12px;">🖼️ 图片已过期</span>
${captionHtml}
</span>
`;
}
return rawContent;
}
// ── Tab 切换 ──────────────────────────────────────
let _roomsRefreshTimer = null;
@@ -606,6 +680,7 @@
}
const headImg =
`<img src="${headImgSrc}" style="display:inline;width:16px;height:16px;vertical-align:middle;margin-right:2px;mix-blend-mode: multiply;" onerror="this.src='/images/headface/1.gif'">`;
const messageBodyHtml = buildChatMessageContent(msg, fontColor);
let html = '';
@@ -726,7 +801,7 @@
buildActionStr(msg.action, fromHtml, toHtml, '悄悄说') :
`${fromHtml}对${toHtml}悄悄说:`;
html =
`${headImg}<span class="msg-secret">${verbStr}</span><span class="msg-content" style="color: ${fontColor}; font-style: italic;">${msg.content}</span>`;
`${headImg}<span class="msg-secret">${verbStr}</span><span class="msg-content" style="color: ${fontColor}; font-style: italic;">${messageBodyHtml}</span>`;
}
} else if (msg.to_user && msg.to_user !== '大家') {
// 对特定对象说话
@@ -735,14 +810,14 @@
const verbStr = msg.action ?
buildActionStr(msg.action, fromHtml, toHtml) :
`${fromHtml}对${toHtml}说:`;
html = `${headImg}${verbStr}<span class="msg-content" style="color: ${fontColor}">${msg.content}</span>`;
html = `${headImg}${verbStr}<span class="msg-content" style="color: ${fontColor}">${messageBodyHtml}</span>`;
} else {
// 对大家说话
const fromHtml = clickableUser(msg.from_user, '#000099');
const verbStr = msg.action ?
buildActionStr(msg.action, fromHtml, '大家') :
`${fromHtml}对大家说:`;
html = `${headImg}${verbStr}<span class="msg-content" style="color: ${fontColor}">${msg.content}</span>`;
html = `${headImg}${verbStr}<span class="msg-content" style="color: ${fontColor}">${messageBodyHtml}</span>`;
}
if (!timeStrOverride) {
@@ -1251,6 +1326,99 @@
let _imeComposing = false;
const _contentInput = document.getElementById('content');
/**
* 更新底部图片选择状态提示。
*/
function updateChatImageSelectionLabel(filename = '') {
const nameEl = document.getElementById('chat-image-name');
if (!nameEl) {
return;
}
nameEl.textContent = filename || '未选择图片';
nameEl.style.color = filename ? '#0f766e' : '#64748b';
}
/**
* 处理聊天图片选择后的前端状态展示。
*/
function handleChatImageSelected(input) {
const file = input?.files?.[0] ?? null;
if (!file) {
updateChatImageSelectionLabel('');
return;
}
updateChatImageSelectionLabel(file.name);
// 用户选择图片后,立即触发自动发送
sendMessage(null);
}
/**
* 清理当前选中的聊天图片。
*/
function clearSelectedChatImage(resetInput = false) {
const imageInput = document.getElementById('chat_image');
if (resetInput && imageInput) {
imageInput.value = '';
}
updateChatImageSelectionLabel('');
}
/**
* 打开聊天图片大图预览层。
*/
function openChatImageLightbox(imageUrl, imageName = '聊天图片') {
const lightbox = document.getElementById('chat-image-lightbox');
const imageEl = document.getElementById('chat-image-lightbox-img');
const nameEl = document.getElementById('chat-image-lightbox-name');
if (!lightbox || !imageEl || !imageUrl) {
return;
}
imageEl.src = imageUrl;
imageEl.alt = imageName;
if (nameEl) {
nameEl.textContent = imageName;
}
lightbox.style.display = 'block';
document.body.style.overflow = 'hidden';
}
/**
* 关闭聊天图片大图预览层。
*/
function closeChatImageLightbox(event = null) {
// 如果是点击事件,且点击的目标不是背景或关闭按钮(比如点击了图片本身且没有阻止冒泡),则不关闭
// 已经在 HTML 中对 img 做了 stopPropagation,此处 event.target !== event.currentTarget 仍是安全的
if (event && event.target !== event.currentTarget) {
return;
}
const lightbox = document.getElementById('chat-image-lightbox');
if (!lightbox) return;
lightbox.style.display = 'none';
const imageEl = document.getElementById('chat-image-lightbox-img');
if (imageEl) {
imageEl.src = '';
}
document.body.style.overflow = '';
}
window.handleChatImageSelected = handleChatImageSelected;
window.openChatImageLightbox = openChatImageLightbox;
window.closeChatImageLightbox = closeChatImageLightbox;
updateChatImageSelectionLabel();
// 中文/日文等 IME 组词开始
_contentInput.addEventListener('compositionstart', () => {
_imeComposing = true;
@@ -1302,9 +1470,11 @@
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 content = formData.get('content').trim();
if (!content) {
const content = String(formData.get('content') || '').trim();
if (!content && !selectedImage) {
contentInput.focus();
_isSending = false;
return;
@@ -1312,7 +1482,7 @@
// 如果发言对象是 AI 小助手,也发送一份给专用机器人 API,不打断正常的发消息流程
const toUser = formData.get('to_user');
if (toUser === 'AI小班长') {
if (toUser === 'AI小班长' && content) {
sendToChatBot(content); // 异步调用,不阻塞全局发送
}
@@ -1320,7 +1490,7 @@
// 当用户输入内容符合暗号格式(4-8位大写字母/数字)时,主动尝试领取
// 不依赖轮询标志,服务端负责校验是否真有活跃箱子及暗号是否匹配
const passcodePattern = /^[A-Z0-9]{4,8}$/;
if (passcodePattern.test(content.trim())) {
if (!selectedImage && passcodePattern.test(content.trim())) {
_isSending = false;
try {
@@ -1385,6 +1555,7 @@
const data = await response.json();
if (response.ok && data.status === 'success') {
contentInput.value = '';
clearSelectedChatImage(true);
contentInput.focus();
} else {
window.chatDialog.alert('发送失败: ' + (data.message || JSON.stringify(data.errors)), '操作失败',
+120 -3
View File
@@ -5,20 +5,32 @@ namespace Tests\Feature;
use App\Models\Room;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
/**
* 聊天室控制器功能测试
* 覆盖进房、发言、图片消息与退房等关键聊天流程。
*/
class ChatControllerTest extends TestCase
{
use RefreshDatabase;
/**
* 每个测试前清空 Redis,避免跨用例污染在线状态与消息缓存。
*/
protected function setUp(): void
{
parent::setUp();
Redis::flushall();
}
/**
* 测试用户可以正常进入聊天室页面。
*/
public function test_can_view_room()
{
$room = Room::create(['room_name' => 'testroom']);
@@ -33,6 +45,9 @@ class ChatControllerTest extends TestCase
$this->assertEquals(1, Redis::hexists("room:{$room->id}:users", $user->username));
}
/**
* 测试用户可以发送普通文本消息。
*/
public function test_can_send_message()
{
$room = Room::create(['room_name' => 'test_send']);
@@ -67,9 +82,12 @@ class ChatControllerTest extends TestCase
$this->assertTrue($found, 'Message not found in Redis');
}
/**
* 测试文本内容为字符串 0 时仍可正常发送。
*/
public function test_can_send_zero_message_content(): void
{
$room = Room::create(['room_name' => 'test_send_zero']);
$room = Room::create(['room_name' => 'send0']);
$user = User::factory()->create();
$this->actingAs($user)->get(route('chat.room', $room->id));
@@ -100,6 +118,90 @@ class ChatControllerTest extends TestCase
$this->assertTrue($found, 'Zero message not found in Redis');
}
/**
* 测试用户可以发送带缩略图的图片消息。
*/
public function test_can_send_image_message(): void
{
Storage::fake('public');
$room = Room::create(['room_name' => 'imgsend']);
$user = User::factory()->create();
$this->actingAs($user)->get(route('chat.room', $room->id));
$response = $this->actingAs($user)->post(route('chat.send', $room->id), [
'to_user' => '大家',
'content' => '图片说明',
'font_color' => '#000000',
'action' => '',
'image' => UploadedFile::fake()->image('chat-picture.png', 1280, 960),
], [
'Accept' => 'application/json',
]);
$response->assertOk();
$response->assertJson(['status' => 'success']);
$messages = Redis::lrange("room:{$room->id}:messages", 0, -1);
$payload = collect($messages)
->map(fn (string $item) => json_decode($item, true))
->first(fn (array $item) => ($item['from_user'] ?? null) === $user->username && ($item['message_type'] ?? null) === 'image');
$this->assertNotNull($payload);
$this->assertSame('image', $payload['message_type'] ?? null);
$this->assertNotEmpty($payload['image_path'] ?? null);
$this->assertNotEmpty($payload['image_thumb_path'] ?? null);
$this->assertNotEmpty($payload['image_url'] ?? null);
$this->assertNotEmpty($payload['image_thumb_url'] ?? null);
Storage::disk('public')->assertExists($payload['image_path']);
Storage::disk('public')->assertExists($payload['image_thumb_path']);
}
/**
* 测试超过保留期的聊天图片会被命令清理并改成过期占位消息。
*/
public function test_purge_command_cleans_expired_chat_images(): void
{
Storage::fake('public');
Storage::disk('public')->put('chat-images/2026-04-08/sample_original.png', 'original');
Storage::disk('public')->put('chat-images/2026-04-08/sample_thumb.png', 'thumb');
$message = \App\Models\Message::create([
'room_id' => 1,
'from_user' => 'tester',
'to_user' => '大家',
'content' => '历史图片',
'is_secret' => false,
'font_color' => '#000000',
'action' => '',
'message_type' => 'image',
'image_path' => 'chat-images/2026-04-08/sample_original.png',
'image_thumb_path' => 'chat-images/2026-04-08/sample_thumb.png',
'image_original_name' => 'sample.png',
'sent_at' => now()->subDays(4),
]);
$this->artisan('messages:purge', [
'--days' => 30,
'--image-days' => 3,
])->assertExitCode(0);
$message->refresh();
$this->assertSame('expired_image', $message->message_type);
$this->assertNull($message->image_path);
$this->assertNull($message->image_thumb_path);
$this->assertNull($message->image_original_name);
$this->assertStringContainsString('图片已过期', $message->content);
Storage::disk('public')->assertMissing('chat-images/2026-04-08/sample_original.png');
Storage::disk('public')->assertMissing('chat-images/2026-04-08/sample_thumb.png');
}
/**
* 测试心跳接口可以正常返回成功响应。
*/
public function test_can_trigger_heartbeat()
{
$room = Room::create(['room_name' => 'test_hb']);
@@ -114,6 +216,9 @@ class ChatControllerTest extends TestCase
$this->assertGreaterThanOrEqual(0, $user->exp_num); // Might be 1 depending on sysparam
}
/**
* 测试显式退房会清理 Redis 在线状态。
*/
public function test_can_leave_room()
{
$room = Room::create(['room_name' => 'test_leave']);
@@ -132,9 +237,12 @@ class ChatControllerTest extends TestCase
$this->assertEquals(0, Redis::hexists("room:{$room->id}:users", $user->username));
}
/**
* 测试签名退房链接同样可以正常清理在线状态。
*/
public function test_can_leave_room_through_signed_expired_route(): void
{
$room = Room::create(['room_name' => 'expired_leave_room']);
$room = Room::create(['room_name' => 'leave2']);
$user = User::factory()->create();
$this->actingAs($user)->get(route('chat.room', $room->id));
@@ -156,7 +264,7 @@ class ChatControllerTest extends TestCase
*/
public function test_vip_user_join_message_uses_presence_theme_payload(): void
{
$room = Room::create(['room_name' => 'vip_theme_room']);
$room = Room::create(['room_name' => 'viproom']);
$vipLevel = \App\Models\VipLevel::factory()->create([
'join_effect' => 'lightning',
'join_banner_style' => 'storm',
@@ -182,6 +290,9 @@ class ChatControllerTest extends TestCase
$this->assertStringContainsString($user->username, $presenceMessage['presence_text']);
}
/**
* 测试可以获取所有房间的在线人数状态。
*/
public function test_can_get_rooms_online_status()
{
$user = User::factory()->create();
@@ -205,6 +316,9 @@ class ChatControllerTest extends TestCase
]);
}
/**
* 测试管理员可以设置房间公告。
*/
public function test_can_set_announcement()
{
$user = User::factory()->create(['user_level' => 100]); // superadmin
@@ -220,6 +334,9 @@ class ChatControllerTest extends TestCase
$this->assertStringContainsString('This is a new test announcement', $room->announcement);
}
/**
* 测试无权限用户不能设置房间公告。
*/
public function test_cannot_set_announcement_without_permission()
{
$user = User::factory()->create(['user_level' => 0]);