Files
chatroom/app/Console/Commands/AutoSaveExp.php

323 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 文件功能:定时自动存点指令
*
* 每5分钟由 Laravel Scheduler 调用,为所有在线用户:
* 1. 发放经验和金币奖励
* 2. 自动计算并更新等级
* 3. 在聊天室内推送"系统为你自动存点"提示
* 4. 若用户等级提升,向全频道广播恭喜消息
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Console\Commands;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\PositionDutyLog;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
class AutoSaveExp extends Command
{
/**
* Artisan 指令名称
*/
protected $signature = 'chatroom:auto-save-exp';
/**
* 指令描述(在 artisan list 中显示)
*/
protected $description = '为所有聊天室在线用户执行自动存点(经验/金币/等级每5分钟运行一次';
/**
* 注入依赖服务
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
) {
parent::__construct();
}
/**
* 指令入口:扫描所有房间,为在线用户发放经验和金币奖励。
*
* 处理流程:
* 1. 从 Redis 扫描所有 room:*:users 的哈希表
* 2. 对每个在线用户应用经验/金币奖励
* 3. 重新计算等级,若升级则推送全频道广播
* 4. 向用户所在房间推送"系统为你自动存点"私信
*/
public function handle(): int
{
// 读取奖励配置
$expGainRaw = Sysparam::getValue('exp_per_heartbeat', '1');
$jjbGainRaw = Sysparam::getValue('jjb_per_heartbeat', '0');
$superLevel = (int) Sysparam::getValue('superlevel', '100');
// 从 Redis 扫描所有在线房间
$roomMap = $this->scanOnlineRooms();
if (empty($roomMap)) {
$this->info('当前没有在线用户,跳过存点。');
return Command::SUCCESS;
}
// 统计本次处理总人次(一个用户在多个房间会被计算多次)
$totalProcessed = 0;
foreach ($roomMap as $roomId => $usernames) {
foreach ($usernames as $username) {
$this->processUser($username, $roomId, $expGainRaw, $jjbGainRaw, $superLevel);
$totalProcessed++;
}
}
$this->info("自动存点完成,共处理 {$totalProcessed} 个在线用户。");
return Command::SUCCESS;
}
/**
* 查询所有活跃房间及其在线用户列表。
*
* 改为从数据库获取所有房间,再用 Redis::hkeys() 查询在线用户。
* 这样可以避免 Redis SCAN + 前缀匹配不一致的问题,
* 且 Redis::hkeys() 会自动正确地加上前缀,与 ChatStateService::userJoin() 一致。
*
* @return array<int, array<string>> 格式:[房间ID => [用户名, ...]]
*/
private function scanOnlineRooms(): array
{
$roomMap = [];
// 从数据库取出所有房间 ID
$roomIds = \App\Models\Room::pluck('id');
foreach ($roomIds as $roomId) {
// Laravel 的 Redis facade 会自动加配置的前缀,与 ChatStateService 存入时完全一致
$usernames = Redis::hkeys("room:{$roomId}:users");
if (! empty($usernames)) {
$roomMap[(int) $roomId] = $usernames;
}
}
return $roomMap;
}
/**
* 为单个在线用户执行存点逻辑,并在其所在房间推送存点通知。
*
* @param string $username 用户名
* @param int $roomId 所在房间ID
* @param string $expGainRaw 经验奖励原始配置(支持 "1" 或 "1-10" 范围)
* @param string $jjbGainRaw 金币奖励原始配置
* @param int $superLevel 管理员等级阈值
*/
private function processUser(
string $username,
int $roomId,
string $expGainRaw,
string $jjbGainRaw,
int $superLevel
): void {
$user = User::where('username', $username)->first();
if (! $user) {
return;
}
// 1. 计算奖励量(经验/金币 均支持 VIP 倍率)
$expGain = $this->parseRewardValue($expGainRaw);
$expMultiplier = $this->vipService->getExpMultiplier($user);
$actualExpGain = (int) round($expGain * $expMultiplier);
$jjbGain = $this->parseRewardValue($jjbGainRaw);
$actualJjbGain = 0;
if ($jjbGain > 0) {
$jjbMultiplier = $this->vipService->getJjbMultiplier($user);
$actualJjbGain = (int) round($jjbGain * $jjbMultiplier);
}
// 2. 通过统一积分服务发放奖励(原子写入 + 流水记录)
if ($actualExpGain > 0) {
$this->currencyService->change(
$user, 'exp', $actualExpGain, CurrencySource::AUTO_SAVE, '自动存点', $roomId,
);
}
if ($actualJjbGain > 0) {
$this->currencyService->change(
$user, 'gold', $actualJjbGain, CurrencySource::AUTO_SAVE, '自动存点', $roomId,
);
}
$user->refresh(); // 刷新获取最新属性service 已原子更新)
$user->load('activePosition.position'); // 确保职务及职位关联已加载
// 3. 自动升降级逻辑
// - 有在职职务的用户:等级固定为职务对应等级,不随经验变化
// - 管理员(>= superLevel不变动
// - 普通用户:按经验计算等级,支持升降级
$oldLevel = $user->user_level;
$leveledUp = false;
$activeUP = $user->activePosition; // 已在 refresh 后加载
if ($activeUP?->position) {
// 有在职职务:等级锁定为职务设定值,确保不被经验系统覆盖
$requiredLevel = (int) $activeUP->position->level;
if ($requiredLevel > 0 && $user->user_level !== $requiredLevel) {
$user->user_level = $requiredLevel;
}
} elseif ($oldLevel < $superLevel) {
// 普通用户:按经验计算并更新等级
$newLevel = Sysparam::calculateLevel($user->exp_num);
if ($newLevel !== $oldLevel && $newLevel < $superLevel) {
$user->user_level = $newLevel;
$leveledUp = ($newLevel > $oldLevel);
}
}
$user->save();
// 4. 若升级,向全频道广播升级消息
if ($leveledUp) {
$sysMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "天道酬勤!恭喜侠客【{$user->username}】挂机苦修,境界突破至 LV.{$user->user_level}",
'is_secret' => false,
'font_color' => '#d97706',
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $sysMsg);
broadcast(new MessageSent($roomId, $sysMsg));
SaveMessageJob::dispatch($sysMsg);
}
// 5. 向用户私人推送"系统为你自动存点"信息,在其聊天框显示
$gainParts = [];
if ($actualExpGain > 0) {
$gainParts[] = "经验+{$actualExpGain}";
}
if ($actualJjbGain > 0) {
$gainParts[] = "金币+{$actualJjbGain}";
}
$jjbDisplay = $user->jjb ?? 0;
$gainStr = ! empty($gainParts) ? ' 本次获得:'.implode('', $gainParts) : '';
// 格式:⏰ 自动存点 · LV.100 · 经验 10,468 · 金币 8,345 · 已满级 · 本次获得:经验+1金币+3
$statusTag = $user->user_level >= $superLevel ? ' · 已满级 ✓' : '';
$content = "⏰ 自动存点 · LV.{$user->user_level} · 经验 {$user->exp_num} · 金币 {$jjbDisplay}{$statusTag}{$gainStr}";
$noticeMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统',
'to_user' => $username, // 定向推送给本人
'content' => $content,
'is_secret' => true, // 私信模式:前端过滤,只有收件人才能看到
'font_color' => '#16a34a', // 草绿色
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $noticeMsg);
broadcast(new MessageSent($roomId, $noticeMsg));
// 6. 同步更新在职用户的勤务时长
$this->tickDutyLog($user, $roomId);
}
/**
* 解析奖励值配置字符串,支持固定值和随机范围两种格式。
*
* 格式说明:
* - 固定值:直接写数字,如 "5" → 返回 5
* - 随机范围:如 "3-10" → 返回 [3, 10] 之间的随机整数
*
* @param string $raw 原始配置字符串
*/
private function parseRewardValue(string $raw): int
{
$raw = trim($raw);
if (str_contains($raw, '-')) {
[$min, $max] = explode('-', $raw, 2);
return rand((int) $min, (int) $max);
}
return (int) $raw;
}
/**
* 自动存点时同步更新或创建在职用户的勤务日志。
*
* 逻辑同 ChatController::tickDutyLog
* 1. 无在职职务 → 跳过
* 2. 今日已有开放日志 → 刷新 duration_seconds
* 3. 今日无日志 → 新建login_at 取 user->in_time进房时间
*
* @param \App\Models\User $user 已 refresh 的用户实例
* @param int $roomId 所在房间 ID
*/
private function tickDutyLog(User $user, int $roomId): void
{
// 无论有无职务,均记录在线流水
$activeUP = $user->activePosition;
// ① 今日未关闭的开放日志 → 刷新时长
$openLog = PositionDutyLog::query()
->where('user_id', $user->id)
->whereNull('logout_at')
->whereDate('login_at', today())
->first();
if ($openLog) {
DB::table('position_duty_logs')
->where('id', $openLog->id)
->update([
'duration_seconds' => DB::raw('GREATEST(0, TIMESTAMPDIFF(SECOND, login_at, NOW()))'),
'updated_at' => now(),
]);
return;
}
// ② 今日无开放日志 → 新建
// 若今日已有已关闭的日志(是重建场景),必须用 now(),防止重用旧 in_time 累积膨胀
$hasClosedToday = PositionDutyLog::query()
->where('user_id', $user->id)
->whereDate('login_at', today())
->whereNotNull('logout_at')
->exists();
$loginAt = (! $hasClosedToday && $user->in_time && $user->in_time->isToday())
? $user->in_time
: now();
PositionDutyLog::create([
'user_id' => $user->id,
'user_position_id' => $activeUP?->id,
'login_at' => $loginAt,
'ip_address' => '0.0.0.0',
'room_id' => $roomId,
]);
}
}