功能:存点时自动同步在职用户勤务日志

- heartbeat 手动存点:调用 tickDutyLog()
- AutoSaveExp 自动存点:调用 tickDutyLog()
- 逻辑:今日已有开放日志则刷新 duration_seconds,无则新建(login_at 取 in_time 进房时间)
- 修复:TIMESTAMPDIFF 结果用 GREATEST(0, ...) 防 unsigned 溢出
- 修复:database.php MySQL 连接加 timezone=+08:00,与 PHP Asia/Shanghai 时区对齐
This commit is contained in:
2026-03-01 00:04:59 +08:00
parent 5f30220609
commit 76fd17c727
3 changed files with 140 additions and 33 deletions

View File

@@ -9,22 +9,24 @@
* 3. 在聊天室内推送"系统为你自动存点"提示
* 4. 若用户等级提升,向全频道广播恭喜消息
*
* @package App\Console\Commands
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Console\Commands;
use App\Events\MessageSent;
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
@@ -43,8 +45,8 @@ class AutoSaveExp extends Command
* 注入依赖服务
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
) {
parent::__construct();
@@ -64,13 +66,14 @@ class AutoSaveExp extends Command
// 读取奖励配置
$expGainRaw = Sysparam::getValue('exp_per_heartbeat', '1');
$jjbGainRaw = Sysparam::getValue('jjb_per_heartbeat', '0');
$superLevel = (int) Sysparam::getValue('superlevel', '100');
$superLevel = (int) Sysparam::getValue('superlevel', '100');
// 从 Redis 扫描所有在线房间
$roomMap = $this->scanOnlineRooms();
if (empty($roomMap)) {
$this->info('当前没有在线用户,跳过存点。');
return Command::SUCCESS;
}
@@ -119,11 +122,11 @@ class AutoSaveExp extends Command
/**
* 为单个在线用户执行存点逻辑,并在其所在房间推送存点通知。
*
* @param string $username 用户名
* @param int $roomId 所在房间ID
* @param string $username 用户名
* @param int $roomId 所在房间ID
* @param string $expGainRaw 经验奖励原始配置(支持 "1" "1-10" 范围)
* @param string $jjbGainRaw 金币奖励原始配置
* @param int $superLevel 管理员等级阈值
* @param int $superLevel 管理员等级阈值
*/
private function processUser(
string $username,
@@ -138,11 +141,11 @@ class AutoSaveExp extends Command
}
// 1. 计算奖励量(经验/金币 均支持 VIP 倍率)
$expGain = $this->parseRewardValue($expGainRaw);
$expGain = $this->parseRewardValue($expGainRaw);
$expMultiplier = $this->vipService->getExpMultiplier($user);
$actualExpGain = (int) round($expGain * $expMultiplier);
$jjbGain = $this->parseRewardValue($jjbGainRaw);
$jjbGain = $this->parseRewardValue($jjbGainRaw);
$actualJjbGain = 0;
if ($jjbGain > 0) {
$jjbMultiplier = $this->vipService->getJjbMultiplier($user);
@@ -163,14 +166,14 @@ class AutoSaveExp extends Command
$user->refresh(); // 刷新获取最新属性service 已原子更新)
// 3. 自动升降级(管理员不参与)
$oldLevel = $user->user_level;
$oldLevel = $user->user_level;
$leveledUp = false;
if ($oldLevel < $superLevel) {
$newLevel = Sysparam::calculateLevel($user->exp_num);
if ($newLevel !== $oldLevel && $newLevel < $superLevel) {
$user->user_level = $newLevel;
$leveledUp = ($newLevel > $oldLevel);
$leveledUp = ($newLevel > $oldLevel);
}
}
@@ -179,15 +182,15 @@ class AutoSaveExp extends Command
// 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,
'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(),
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $sysMsg);
broadcast(new MessageSent($roomId, $sysMsg));
@@ -203,26 +206,29 @@ class AutoSaveExp extends Command
$gainParts[] = "金币+{$actualJjbGain}";
}
$jjbDisplay = $user->jjb ?? 0;
$gainStr = ! empty($gainParts) ? ' 本次获得:' . implode('', $gainParts) : '';
$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}";
$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, // 私信模式:前端过滤,只有收件人才能看到
'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(),
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $noticeMsg);
broadcast(new MessageSent($roomId, $noticeMsg));
// 6. 同步更新在职用户的勤务时长
$this->tickDutyLog($user, $roomId);
}
/**
@@ -233,16 +239,63 @@ class AutoSaveExp extends Command
* - 随机范围:如 "3-10" 返回 [3, 10] 之间的随机整数
*
* @param string $raw 原始配置字符串
* @return int
*/
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;
if (! $activeUP) {
return;
}
$openLog = PositionDutyLog::query()
->where('user_id', $user->id)
->whereNull('logout_at')
->whereDate('login_at', today())
->first();
if ($openLog) {
// 绕过模型 castinteger使用 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,
]);
}
}

View File

@@ -340,6 +340,9 @@ class ChatController extends Controller
$user->save(); // 存点入库
// 手动心跳存点:同步更新在职用户的勤务时长
$this->tickDutyLog($user, $id);
// 3. 将新的等级反馈给当前用户的在线名单上
// 确保刚刚升级后别人查看到的也是最准确等级
$activePosition = $user->activePosition;
@@ -820,7 +823,57 @@ class ChatController extends Controller
->whereNull('logout_at')
->update([
'logout_at' => now(),
'duration_seconds' => DB::raw('TIMESTAMPDIFF(SECOND, login_at, NOW())'),
'duration_seconds' => DB::raw('GREATEST(0, TIMESTAMPDIFF(SECOND, login_at, NOW()))'),
]);
}
/**
* 存点时同步更新或创建在职用户的勤务日志。
*
* 逻辑:
* 1. 用户无在职职务 跳过
* 2. 今日已有开放日志(无 logout_at 刷新 duration_seconds实时时长
* 3. 今日无任何日志 新建login_at user->in_time进房时间保证时长不丢失
*
* @param \App\Models\User $user 当前用户(必须已 fresh/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) {
// 刷新实时在线时长
// 绕过模型 castinteger使用 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' => request()->ip(),
'room_id' => $roomId,
]);
}
}

View File

@@ -58,6 +58,7 @@ return [
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'timezone' => '+08:00', // 与 PHP Asia/Shanghai 时区对齐NOW() 返回北京时间
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],