新增聊天室发送图片功能
This commit is contained in:
@@ -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} 天的聊天图片。");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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']),
|
||||
]);
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;">×</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>
|
||||
|
||||
@@ -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)), '操作失败',
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user