2026-02-27 12:39:23 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 文件功能:定时自动存点指令
|
|
|
|
|
|
*
|
|
|
|
|
|
* 每5分钟由 Laravel Scheduler 调用,为所有在线用户:
|
|
|
|
|
|
* 1. 发放经验和金币奖励
|
|
|
|
|
|
* 2. 自动计算并更新等级
|
|
|
|
|
|
* 3. 在聊天室内推送"系统为你自动存点"提示
|
|
|
|
|
|
* 4. 若用户等级提升,向全频道广播恭喜消息
|
|
|
|
|
|
*
|
|
|
|
|
|
* @author ChatRoom Laravel
|
2026-03-01 00:04:59 +08:00
|
|
|
|
*
|
2026-02-27 12:39:23 +08:00
|
|
|
|
* @version 1.0.0
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
|
|
|
2026-02-28 12:49:26 +08:00
|
|
|
|
use App\Enums\CurrencySource;
|
2026-03-01 00:04:59 +08:00
|
|
|
|
use App\Events\MessageSent;
|
2026-02-27 12:39:23 +08:00
|
|
|
|
use App\Jobs\SaveMessageJob;
|
2026-03-01 00:04:59 +08:00
|
|
|
|
use App\Models\PositionDutyLog;
|
2026-02-27 12:39:23 +08:00
|
|
|
|
use App\Models\Sysparam;
|
|
|
|
|
|
use App\Models\User;
|
|
|
|
|
|
use App\Services\ChatStateService;
|
2026-02-28 12:49:26 +08:00
|
|
|
|
use App\Services\UserCurrencyService;
|
2026-02-27 12:39:23 +08:00
|
|
|
|
use App\Services\VipService;
|
|
|
|
|
|
use Illuminate\Console\Command;
|
2026-03-01 00:04:59 +08:00
|
|
|
|
use Illuminate\Support\Facades\DB;
|
2026-02-27 12:39:23 +08:00
|
|
|
|
use Illuminate\Support\Facades\Redis;
|
|
|
|
|
|
|
|
|
|
|
|
class AutoSaveExp extends Command
|
|
|
|
|
|
{
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Artisan 指令名称
|
|
|
|
|
|
*/
|
|
|
|
|
|
protected $signature = 'chatroom:auto-save-exp';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 指令描述(在 artisan list 中显示)
|
|
|
|
|
|
*/
|
|
|
|
|
|
protected $description = '为所有聊天室在线用户执行自动存点(经验/金币/等级),每5分钟运行一次';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 注入依赖服务
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function __construct(
|
2026-03-01 00:04:59 +08:00
|
|
|
|
private readonly ChatStateService $chatState,
|
|
|
|
|
|
private readonly VipService $vipService,
|
2026-02-28 12:49:26 +08:00
|
|
|
|
private readonly UserCurrencyService $currencyService,
|
2026-02-27 12:39:23 +08:00
|
|
|
|
) {
|
|
|
|
|
|
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');
|
2026-03-01 00:04:59 +08:00
|
|
|
|
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
2026-02-27 12:39:23 +08:00
|
|
|
|
|
|
|
|
|
|
// 从 Redis 扫描所有在线房间
|
|
|
|
|
|
$roomMap = $this->scanOnlineRooms();
|
|
|
|
|
|
|
|
|
|
|
|
if (empty($roomMap)) {
|
|
|
|
|
|
$this->info('当前没有在线用户,跳过存点。');
|
2026-03-01 00:04:59 +08:00
|
|
|
|
|
2026-02-27 12:39:23 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-27 12:48:23 +08:00
|
|
|
|
* 查询所有活跃房间及其在线用户列表。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 改为从数据库获取所有房间,再用 Redis::hkeys() 查询在线用户。
|
|
|
|
|
|
* 这样可以避免 Redis SCAN + 前缀匹配不一致的问题,
|
|
|
|
|
|
* 且 Redis::hkeys() 会自动正确地加上前缀,与 ChatStateService::userJoin() 一致。
|
2026-02-27 12:39:23 +08:00
|
|
|
|
*
|
|
|
|
|
|
* @return array<int, array<string>> 格式:[房间ID => [用户名, ...]]
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function scanOnlineRooms(): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$roomMap = [];
|
2026-02-27 12:48:23 +08:00
|
|
|
|
|
|
|
|
|
|
// 从数据库取出所有房间 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;
|
2026-02-27 12:39:23 +08:00
|
|
|
|
}
|
2026-02-27 12:48:23 +08:00
|
|
|
|
}
|
2026-02-27 12:39:23 +08:00
|
|
|
|
|
|
|
|
|
|
return $roomMap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 为单个在线用户执行存点逻辑,并在其所在房间推送存点通知。
|
|
|
|
|
|
*
|
2026-03-01 00:04:59 +08:00
|
|
|
|
* @param string $username 用户名
|
|
|
|
|
|
* @param int $roomId 所在房间ID
|
2026-02-27 12:39:23 +08:00
|
|
|
|
* @param string $expGainRaw 经验奖励原始配置(支持 "1" 或 "1-10" 范围)
|
|
|
|
|
|
* @param string $jjbGainRaw 金币奖励原始配置
|
2026-03-01 00:04:59 +08:00
|
|
|
|
* @param int $superLevel 管理员等级阈值
|
2026-02-27 12:39:23 +08:00
|
|
|
|
*/
|
|
|
|
|
|
private function processUser(
|
|
|
|
|
|
string $username,
|
|
|
|
|
|
int $roomId,
|
|
|
|
|
|
string $expGainRaw,
|
|
|
|
|
|
string $jjbGainRaw,
|
|
|
|
|
|
int $superLevel
|
|
|
|
|
|
): void {
|
|
|
|
|
|
$user = User::where('username', $username)->first();
|
|
|
|
|
|
if (! $user) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-28 12:49:26 +08:00
|
|
|
|
// 1. 计算奖励量(经验/金币 均支持 VIP 倍率)
|
2026-03-01 00:04:59 +08:00
|
|
|
|
$expGain = $this->parseRewardValue($expGainRaw);
|
2026-02-27 12:39:23 +08:00
|
|
|
|
$expMultiplier = $this->vipService->getExpMultiplier($user);
|
|
|
|
|
|
$actualExpGain = (int) round($expGain * $expMultiplier);
|
|
|
|
|
|
|
2026-03-01 00:04:59 +08:00
|
|
|
|
$jjbGain = $this->parseRewardValue($jjbGainRaw);
|
2026-02-27 12:39:23 +08:00
|
|
|
|
$actualJjbGain = 0;
|
|
|
|
|
|
if ($jjbGain > 0) {
|
|
|
|
|
|
$jjbMultiplier = $this->vipService->getJjbMultiplier($user);
|
|
|
|
|
|
$actualJjbGain = (int) round($jjbGain * $jjbMultiplier);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-28 12:49:26 +08:00
|
|
|
|
// 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 已原子更新)
|
|
|
|
|
|
|
2026-02-27 12:39:23 +08:00
|
|
|
|
// 3. 自动升降级(管理员不参与)
|
2026-03-01 00:04:59 +08:00
|
|
|
|
$oldLevel = $user->user_level;
|
2026-02-27 12:39:23 +08:00
|
|
|
|
$leveledUp = false;
|
|
|
|
|
|
|
|
|
|
|
|
if ($oldLevel < $superLevel) {
|
|
|
|
|
|
$newLevel = Sysparam::calculateLevel($user->exp_num);
|
|
|
|
|
|
if ($newLevel !== $oldLevel && $newLevel < $superLevel) {
|
|
|
|
|
|
$user->user_level = $newLevel;
|
2026-03-01 00:04:59 +08:00
|
|
|
|
$leveledUp = ($newLevel > $oldLevel);
|
2026-02-27 12:39:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$user->save();
|
|
|
|
|
|
|
|
|
|
|
|
// 4. 若升级,向全频道广播升级消息
|
|
|
|
|
|
if ($leveledUp) {
|
|
|
|
|
|
$sysMsg = [
|
2026-03-01 00:04:59 +08:00
|
|
|
|
'id' => $this->chatState->nextMessageId($roomId),
|
|
|
|
|
|
'room_id' => $roomId,
|
|
|
|
|
|
'from_user' => '系统传音',
|
|
|
|
|
|
'to_user' => '大家',
|
|
|
|
|
|
'content' => "天道酬勤!恭喜侠客【{$user->username}】挂机苦修,境界突破至 LV.{$user->user_level}!",
|
|
|
|
|
|
'is_secret' => false,
|
2026-02-27 12:39:23 +08:00
|
|
|
|
'font_color' => '#d97706',
|
2026-03-01 00:04:59 +08:00
|
|
|
|
'action' => '大声宣告',
|
|
|
|
|
|
'sent_at' => now()->toDateTimeString(),
|
2026-02-27 12:39:23 +08:00
|
|
|
|
];
|
|
|
|
|
|
$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;
|
2026-03-01 00:04:59 +08:00
|
|
|
|
$gainStr = ! empty($gainParts) ? ' 本次获得:'.implode(',', $gainParts) : '';
|
2026-02-27 12:39:23 +08:00
|
|
|
|
|
2026-02-27 12:51:29 +08:00
|
|
|
|
// 格式:⏰ 自动存点 · LV.100 · 经验 10,468 · 金币 8,345 · 已满级 · 本次获得:经验+1,金币+3
|
2026-03-01 00:04:59 +08:00
|
|
|
|
$statusTag = $user->user_level >= $superLevel ? ' · 已满级 ✓' : '';
|
|
|
|
|
|
$content = "⏰ 自动存点 · LV.{$user->user_level} · 经验 {$user->exp_num} · 金币 {$jjbDisplay}{$statusTag}{$gainStr}";
|
2026-02-27 12:39:23 +08:00
|
|
|
|
|
|
|
|
|
|
$noticeMsg = [
|
2026-03-01 00:04:59 +08:00
|
|
|
|
'id' => $this->chatState->nextMessageId($roomId),
|
|
|
|
|
|
'room_id' => $roomId,
|
|
|
|
|
|
'from_user' => '系统',
|
|
|
|
|
|
'to_user' => $username, // 定向推送给本人
|
|
|
|
|
|
'content' => $content,
|
|
|
|
|
|
'is_secret' => true, // 私信模式:前端过滤,只有收件人才能看到
|
2026-02-27 12:51:29 +08:00
|
|
|
|
'font_color' => '#16a34a', // 草绿色
|
2026-03-01 00:04:59 +08:00
|
|
|
|
'action' => '',
|
|
|
|
|
|
'sent_at' => now()->toDateTimeString(),
|
2026-02-27 12:39:23 +08:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
$this->chatState->pushMessage($roomId, $noticeMsg);
|
|
|
|
|
|
broadcast(new MessageSent($roomId, $noticeMsg));
|
2026-03-01 00:04:59 +08:00
|
|
|
|
|
|
|
|
|
|
// 6. 同步更新在职用户的勤务时长
|
|
|
|
|
|
$this->tickDutyLog($user, $roomId);
|
2026-02-27 12:39:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 解析奖励值配置字符串,支持固定值和随机范围两种格式。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 格式说明:
|
|
|
|
|
|
* - 固定值:直接写数字,如 "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);
|
2026-03-01 00:04:59 +08:00
|
|
|
|
|
2026-02-27 12:39:23 +08:00
|
|
|
|
return rand((int) $min, (int) $max);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (int) $raw;
|
|
|
|
|
|
}
|
2026-03-01 00:04:59 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 自动存点时同步更新或创建在职用户的勤务日志。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 逻辑同 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;
|
|
|
|
|
|
if (! $activeUP) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$openLog = PositionDutyLog::query()
|
|
|
|
|
|
->where('user_id', $user->id)
|
|
|
|
|
|
->whereNull('logout_at')
|
|
|
|
|
|
->whereDate('login_at', today())
|
|
|
|
|
|
->first();
|
|
|
|
|
|
|
|
|
|
|
|
if ($openLog) {
|
|
|
|
|
|
// 绕过模型 cast(integer),使用 DB::table 直接执行 SQL 表达式
|
|
|
|
|
|
DB::table('position_duty_logs')
|
|
|
|
|
|
->where('id', $openLog->id)
|
|
|
|
|
|
->update(['duration_seconds' => DB::raw('GREATEST(0, TIMESTAMPDIFF(SECOND, login_at, NOW()))')]);
|
|
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 今日无日志,新建:取进房时间为 login_at(防止时长丢失)
|
|
|
|
|
|
$loginAt = $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', // 定时任务无 HTTP 请求,占位符
|
|
|
|
|
|
'room_id' => $roomId,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
2026-02-27 12:39:23 +08:00
|
|
|
|
}
|