diff --git a/app/Console/Commands/PurgeOldMessages.php b/app/Console/Commands/PurgeOldMessages.php index 525f535..dc53cf3 100644 --- a/app/Console/Commands/PurgeOldMessages.php +++ b/app/Console/Commands/PurgeOldMessages.php @@ -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} 天的聊天图片。"); + } } diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index 8e58efb..5e011b0 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -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 + */ + 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。 diff --git a/app/Http/Requests/SendMessageRequest.php b/app/Http/Requests/SendMessageRequest.php index fe48c4c..72e2a8e 100644 --- a/app/Http/Requests/SendMessageRequest.php +++ b/app/Http/Requests/SendMessageRequest.php @@ -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> + * @return array|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', diff --git a/app/Jobs/SaveMessageJob.php b/app/Jobs/SaveMessageJob.php index fcbc661..66bc543 100644 --- a/app/Jobs/SaveMessageJob.php +++ b/app/Jobs/SaveMessageJob.php @@ -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 $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']), ]); diff --git a/app/Models/Message.php b/app/Models/Message.php index 7129eff..c16adfb 100644 --- a/app/Models/Message.php +++ b/app/Models/Message.php @@ -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 */ diff --git a/database/migrations/2026_04_12_134311_add_image_fields_to_messages_table.php b/database/migrations/2026_04_12_134311_add_image_fields_to_messages_table.php new file mode 100644 index 0000000..4c95fce --- /dev/null +++ b/database/migrations/2026_04_12_134311_add_image_fields_to_messages_table.php @@ -0,0 +1,44 @@ +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', + ]); + }); + } +}; diff --git a/resources/views/chat/frame.blade.php b/resources/views/chat/frame.blade.php index 97770f9..b74450c 100644 --- a/resources/views/chat/frame.blade.php +++ b/resources/views/chat/frame.blade.php @@ -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 }; @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') + {{-- 聊天图片大图预览层 --}} + {{-- 大卡片通知(任命公告、好友通知、礼包选择等) --}} @include('chat.partials.chat-banner') diff --git a/resources/views/chat/partials/layout/input-bar.blade.php b/resources/views/chat/partials/layout/input-bar.blade.php index ca4f78b..7c6858b 100644 --- a/resources/views/chat/partials/layout/input-bar.blade.php +++ b/resources/views/chat/partials/layout/input-bar.blade.php @@ -8,7 +8,7 @@ --}}
-
+ {{-- 第一行:工具选项 --}}