功能更新与UI优化:游戏图标移除、用户名片修复、婚礼红包界面重设计

- 移除聊天室右下角浮动游戏图标(占卜、百家乐、赛马、老虎机)
- 用户名片按钮区:修复已婚/已好友时按钮换行问题,统一单行显示
- 婚礼红包弹窗:重设计为喜庆鲜红背景,领取按钮改为圆形米黄样式
- 新增婚礼红包恢复接口(/wedding/pending-envelopes),刷新后自动恢复领取按钮
- 修复 Alpine :style 字符串覆盖静态 style 导致圆形按钮失效的问题
- 撤职后用户等级改为根据经验值重新计算,不再无条件重置为1
- 管理员修改用户经验值后自动重算等级,有职务用户等级锁定
- 娱乐大厅钓鱼游戏按钮直接调用 startFishing() 简化操作流程
- 新增赛马、占卜、百家乐游戏及相关后端逻辑
This commit is contained in:
2026-03-03 23:19:59 +08:00
parent 602dcd7cf1
commit f45483bcba
32 changed files with 3746 additions and 370 deletions
+15 -3
View File
@@ -102,6 +102,15 @@ enum CurrencySource: string
/** 神秘箱子——黑化陷阱(倒扣金币,负数) */
case MYSTERY_BOX_TRAP = 'mystery_box_trap';
/** 赛马竞猜——下注消耗(扣除金币) */
case HORSE_BET = 'horse_bet';
/** 赛马竞猜——中奖赔付(收入金币,含本金返还) */
case HORSE_WIN = 'horse_win';
/** 神秘占卜——额外次数消耗(扣除金币) */
case FORTUNE_COST = 'fortune_cost';
/**
* 返回该来源的中文名称,用于后台统计展示。
*/
@@ -131,10 +140,13 @@ enum CurrencySource: string
self::SLOT_SPIN => '老虎机转动',
self::SLOT_WIN => '老虎机中奖',
self::SLOT_CURSE => '老虎机诅咒',
self::RED_PACKET_RECV => '领取礼包红包(金币)',
self::RED_PACKET_RECV => '领取礼包红包(金币)',
self::RED_PACKET_RECV_EXP => '领取礼包红包(经验)',
self::MYSTERY_BOX => '神秘箱子奖励',
self::MYSTERY_BOX_TRAP => '神秘箱子陷阱',
self::MYSTERY_BOX => '神秘箱子奖励',
self::MYSTERY_BOX_TRAP => '神秘箱子陷阱',
self::HORSE_BET => '赛马下注',
self::HORSE_WIN => '赛马赢钱',
self::FORTUNE_COST => '神秘占卜消耗',
};
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* 文件功能:赛马开赛广播事件
*
* 新场次开始押注时广播给房间所有用户,携带场次 ID、
* 参赛马匹信息和押注截止时间,前端展示倒计时押注面板。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\HorseRace;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class HorseRaceOpened implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param HorseRace $race 本场信息
*/
public function __construct(
public readonly HorseRace $race,
) {}
/**
* 广播至房间公共频道。
*
* @return array<\Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
}
/**
* 广播事件名(前端监听 .horse.opened)。
*/
public function broadcastAs(): string
{
return 'horse.opened';
}
/**
* 广播数据。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'race_id' => $this->race->id,
'horses' => $this->race->horses,
'bet_opens_at' => $this->race->bet_opens_at->toIso8601String(),
'bet_closes_at' => $this->race->bet_closes_at->toIso8601String(),
'bet_seconds' => (int) now()->diffInSeconds($this->race->bet_closes_at),
];
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
/**
* 文件功能:赛马进行中实时进度广播事件
*
* 跑马过程中每隔1秒广播各马匹当前进度(0~100%),
* 前端据此实时更新赛道动画。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class HorseRaceProgress implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param int $raceId 场次 ID
* @param array<int, int> $positions 各马匹进度 [horse_id => progress(0~100)]
* @param bool $finished 是否已到终点
* @param int|null $leaderId 当前领跑马匹 ID
*/
public function __construct(
public readonly int $raceId,
public readonly array $positions,
public readonly bool $finished = false,
public readonly ?int $leaderId = null,
) {}
/**
* 广播至房间公共频道。
*
* @return array<\Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
}
/**
* 广播事件名(前端监听 .horse.progress)。
*/
public function broadcastAs(): string
{
return 'horse.progress';
}
/**
* 广播数据。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'race_id' => $this->raceId,
'positions' => $this->positions,
'finished' => $this->finished,
'leader_id' => $this->leaderId,
];
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
/**
* 文件功能:赛马结算广播事件
*
* 跑马结束後广播赛果(获胜马匹、赔付金额等)给房间所有用户,
* 前端收到后展示结算面板并更新中奖信息。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\HorseRace;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class HorseRaceSettled implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param HorseRace $race 已结算的场次
*/
public function __construct(
public readonly HorseRace $race,
) {}
/**
* 广播至房间公共频道。
*
* @return array<\Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
}
/**
* 广播事件名(前端监听 .horse.settled)。
*/
public function broadcastAs(): string
{
return 'horse.settled';
}
/**
* 广播数据。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
// 找出获胜马匹的名称
$horses = $this->race->horses ?? [];
$winnerName = '未知';
foreach ($horses as $horse) {
if (($horse['id'] ?? 0) === $this->race->winner_horse_id) {
$winnerName = ($horse['emoji'] ?? '').' '.($horse['name'] ?? '');
break;
}
}
return [
'race_id' => $this->race->id,
'winner_horse_id' => $this->race->winner_horse_id,
'winner_name' => $winnerName,
'total_pool' => $this->race->total_pool,
'settled_at' => $this->race->settled_at?->toIso8601String(),
];
}
}
@@ -88,7 +88,7 @@ class UserManagerController extends Controller
*/
public function update(Request $request, User $user): JsonResponse|RedirectResponse
{
$targetUser = $user;
$targetUser = $user;
$currentUser = Auth::user();
// 超级管理员专属:仅 id=1 的账号可编辑用户信息
@@ -129,7 +129,24 @@ class UserManagerController extends Controller
);
$targetUser->refresh();
}
// 调整经验后重新计算等级(有职务用户锁定职务等级,无职务用户按经验重算)
$targetUser->load('activePosition.position');
$superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100');
if ($targetUser->activePosition?->position) {
// 有在职职务:等级锁定为职务级,不受经验影响
$lockedLevel = (int) $targetUser->activePosition->position->level;
if ($lockedLevel > 0 && $targetUser->user_level !== $lockedLevel) {
$targetUser->user_level = $lockedLevel;
}
} elseif ($targetUser->user_level < $superLevel) {
// 无职务普通用户:按经验重算等级(不超过满级阈值)
$newLevel = \App\Models\Sysparam::calculateLevel($targetUser->exp_num ?? 0);
$safeLevel = max(1, min($newLevel, $superLevel - 1));
$targetUser->user_level = $safeLevel;
}
}
if (isset($validated['jjb'])) {
$jjbDiff = $validated['jjb'] - ($targetUser->jjb ?? 0);
if ($jjbDiff !== 0) {
@@ -185,7 +202,7 @@ class UserManagerController extends Controller
*/
public function destroy(Request $request, User $user): RedirectResponse
{
$targetUser = $user;
$targetUser = $user;
$currentUser = Auth::user();
// 超级管理员专属:仅 id=1 的账号可删除用户
@@ -0,0 +1,166 @@
<?php
/**
* 文件功能:神秘占卜前台控制器
*
* 提供用户每日占卜功能:
* - 查询今日占卜状态(已占卜/未占卜/剩余次数)
* - 执行占卜(免费或付费)
* - 查询占卜历史记录
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\FortuneLog;
use App\Models\GameConfig;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class FortuneTellingController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
) {}
/**
* 查询今日占卜状态(用于面板初始化和刷新)。
*/
public function todayStatus(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('fortune_telling')) {
return response()->json(['enabled' => false]);
}
$user = $request->user();
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
$freeCount = (int) ($config['free_count_per_day'] ?? 1);
$extraCost = (int) ($config['extra_cost'] ?? 500);
$todayCount = FortuneLog::todayCount($user->id);
$todayLatest = FortuneLog::todayLatest($user->id);
$freeUsed = FortuneLog::query()
->where('user_id', $user->id)
->where('fortune_date', today())
->where('is_free', true)
->count();
$hasFreeLeft = $freeUsed < $freeCount;
return response()->json([
'enabled' => true,
'today_count' => $todayCount,
'free_count' => $freeCount,
'free_used' => $freeUsed,
'has_free_left' => $hasFreeLeft,
'extra_cost' => $extraCost,
'latest' => $todayLatest ? [
'grade' => $todayLatest->grade,
'grade_label' => $todayLatest->gradeLabel(),
'grade_color' => $todayLatest->gradeColor(),
'text' => $todayLatest->text,
'buff_desc' => $todayLatest->buff_desc,
'created_at' => $todayLatest->created_at->format('H:i'),
] : null,
]);
}
/**
* 执行一次占卜。
*
* 免费次数用完后每次消耗 extra_cost 金币。
*/
public function tell(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('fortune_telling')) {
return response()->json(['ok' => false, 'message' => '神秘占卜当前未开启。']);
}
$user = $request->user();
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
$freeCount = (int) ($config['free_count_per_day'] ?? 1);
$extraCost = (int) ($config['extra_cost'] ?? 500);
// 判断今日免费次数是否已用完
$freeUsed = FortuneLog::query()
->where('user_id', $user->id)
->where('fortune_date', today())
->where('is_free', true)
->count();
$isFree = $freeUsed < $freeCount;
$cost = $isFree ? 0 : $extraCost;
// 检查余额
if (! $isFree && ($user->jjb ?? 0) < $cost) {
return response()->json(['ok' => false, 'message' => "金币不足,额外占卜需要 {$cost} 金币。"]);
}
// 扣费
if (! $isFree && $cost > 0) {
$this->currency->change(
$user,
'gold',
-$cost,
CurrencySource::FORTUNE_COST,
'神秘占卜额外次数消耗',
);
}
// 抽签
$grade = FortuneLog::rollGrade($config);
$fortune = FortuneLog::rollFortune($grade);
// 记录
$log = FortuneLog::create([
'user_id' => $user->id,
'grade' => $grade,
'text' => $fortune['text'],
'buff_desc' => $fortune['buff_desc'] ?? null,
'is_free' => $isFree,
'cost' => $cost,
'fortune_date' => today(),
]);
return response()->json([
'ok' => true,
'grade' => $log->grade,
'grade_label' => $log->gradeLabel(),
'grade_color' => $log->gradeColor(),
'text' => $log->text,
'buff_desc' => $log->buff_desc,
'is_free' => $isFree,
'cost' => $cost,
]);
}
/**
* 查询近20条个人占卜历史记录。
*/
public function history(Request $request): JsonResponse
{
$logs = FortuneLog::query()
->where('user_id', $request->user()->id)
->orderByDesc('id')
->limit(20)
->get(['grade', 'text', 'buff_desc', 'is_free', 'cost', 'fortune_date', 'created_at'])
->map(fn ($log) => [
'grade' => $log->grade,
'grade_label' => $log->gradeLabel(),
'grade_color' => $log->gradeColor(),
'text' => $log->text,
'buff_desc' => $log->buff_desc,
'cost' => $log->cost,
'date' => $log->fortune_date->format('m-d'),
'time' => $log->created_at->format('H:i'),
]);
return response()->json(['history' => $logs]);
}
}
@@ -0,0 +1,224 @@
<?php
/**
* 文件功能:赛马竞猜前台控制器
*
* 提供用户在聊天室内参与赛马的 API 接口:
* - 查询当前场次信息(马匹、注池、赔率)
* - 提交下注(扣除金币 + 写入下注记录)
* - 查询本人下注状态
* - 查询最近历史记录
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\GameConfig;
use App\Models\HorseBet;
use App\Models\HorseRace;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class HorseRaceController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
) {}
/**
* 获取当前进行中的场次信息(前端轮询或事件触发后调用)。
*/
public function currentRace(Request $request): JsonResponse
{
$race = HorseRace::currentRace();
if (! $race) {
return response()->json(['race' => null]);
}
$user = $request->user();
$myBet = HorseBet::query()
->where('race_id', $race->id)
->where('user_id', $user->id)
->first();
// 计算各马匹当前注额
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$houseTake = (int) ($config['house_take_percent'] ?? 5);
$horsePools = HorseBet::query()
->where('race_id', $race->id)
->groupBy('horse_id')
->selectRaw('horse_id, SUM(amount) as pool')
->pluck('pool', 'horse_id')
->toArray();
// 计算实时赔率
$horses = $race->horses ?? [];
$horsesWithBets = array_map(function ($horse) use ($horsePools, $houseTake) {
$horsePool = (int) ($horsePools[$horse['id']] ?? 0);
$totalPool = array_sum(array_values($horsePools));
$netPool = $totalPool * (1 - $houseTake / 100);
$odds = $horsePool > 0 ? round($netPool / $horsePool, 2) : null;
return [
'id' => $horse['id'],
'name' => $horse['name'],
'emoji' => $horse['emoji'],
'pool' => $horsePool,
'odds' => $odds,
];
}, $horses);
return response()->json([
'race' => [
'id' => $race->id,
'status' => $race->status,
'bet_closes_at' => $race->bet_closes_at?->toIso8601String(),
'seconds_left' => $race->status === 'betting'
? max(0, (int) now()->diffInSeconds($race->bet_closes_at, false))
: 0,
'horses' => $horsesWithBets,
'total_pool' => $race->total_pool + array_sum(array_values($horsePools)),
'my_bet' => $myBet ? [
'horse_id' => $myBet->horse_id,
'amount' => $myBet->amount,
] : null,
],
]);
}
/**
* 用户提交下注。
*
* 同一场每人限下一注,下注成功后立即扣除金币。
*/
public function bet(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('horse_racing')) {
return response()->json(['ok' => false, 'message' => '赛马竞猜当前未开启。']);
}
$data = $request->validate([
'race_id' => 'required|integer|exists:horse_races,id',
'horse_id' => 'required|integer|min:1',
'amount' => 'required|integer|min:1',
]);
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$minBet = (int) ($config['min_bet'] ?? 100);
$maxBet = (int) ($config['max_bet'] ?? 100000);
if ($data['amount'] < $minBet || $data['amount'] > $maxBet) {
return response()->json(['ok' => false, 'message' => "押注金额须在 {$minBet}~{$maxBet} 金币之间。"]);
}
$race = HorseRace::find($data['race_id']);
if (! $race || ! $race->isBettingOpen()) {
return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']);
}
// 验证马匹 ID 是否有效
$horses = $race->horses ?? [];
$validIds = array_column($horses, 'id');
if (! in_array($data['horse_id'], $validIds, true)) {
return response()->json(['ok' => false, 'message' => '无效的马匹编号。']);
}
$user = $request->user();
$currency = $this->currency;
// 校验余额
if (($user->jjb ?? 0) < $data['amount']) {
return response()->json(['ok' => false, 'message' => '金币不足,无法下注。']);
}
return DB::transaction(function () use ($user, $race, $data, $currency, $horses): JsonResponse {
// 幂等:同一场只能下一注
$existing = HorseBet::query()
->where('race_id', $race->id)
->where('user_id', $user->id)
->lockForUpdate()
->exists();
if ($existing) {
return response()->json(['ok' => false, 'message' => '本场您已下注,请等待开奖。']);
}
// 找出马匹名称
$horseName = '';
foreach ($horses as $horse) {
if ((int) $horse['id'] === (int) $data['horse_id']) {
$horseName = ($horse['emoji'] ?? '').($horse['name'] ?? '');
break;
}
}
// 扣除金币
$currency->change(
$user,
'gold',
-$data['amount'],
CurrencySource::HORSE_BET,
"赛马 #{$race->id} 押注 {$horseName}",
);
// 写入下注记录
HorseBet::create([
'race_id' => $race->id,
'user_id' => $user->id,
'horse_id' => $data['horse_id'],
'amount' => $data['amount'],
'status' => 'pending',
]);
return response()->json([
'ok' => true,
'message' => "✅ 已押注「{$horseName}{$data['amount']} 金币,等待开跑!",
'amount' => $data['amount'],
'horse_id' => $data['horse_id'],
]);
});
}
/**
* 查询最近10场历史记录(前端展示胜负趋势)。
*/
public function history(): JsonResponse
{
$races = HorseRace::query()
->where('status', 'settled')
->orderByDesc('id')
->limit(10)
->get(['id', 'horses', 'winner_horse_id', 'total_pool', 'total_bets', 'settled_at']);
// 转换获胜马匹名称
$history = $races->map(function ($race) {
$winnerName = '未知';
foreach (($race->horses ?? []) as $horse) {
if (($horse['id'] ?? 0) === (int) $race->winner_horse_id) {
$winnerName = ($horse['emoji'] ?? '').($horse['name'] ?? '');
break;
}
}
return [
'id' => $race->id,
'winner_id' => $race->winner_horse_id,
'winner_name' => $winnerName,
'total_pool' => $race->total_pool,
'total_bets' => $race->total_bets,
'settled_at' => $race->settled_at?->toDateTimeString(),
];
});
return response()->json(['history' => $history]);
}
}
@@ -107,4 +107,36 @@ class WeddingController extends Controller
'expires_at' => $ceremony->expires_at,
]);
}
/**
* 查询当前用户所有未领取且未过期的婚礼红包(页面刷新后恢复领取按钮用)。
*/
public function pendingEnvelopes(Request $request): JsonResponse
{
$userId = $request->user()->id;
// 查询有未领取 claim 的 active 婚礼(未过期)
$claims = \App\Models\WeddingEnvelopeClaim::query()
->where('user_id', $userId)
->where('claimed', false)
->with(['ceremony.marriage.user', 'ceremony.marriage.partner', 'ceremony.tier'])
->get()
->filter(fn ($c) => $c->ceremony
&& in_array($c->ceremony->status, ['active'])
&& (! $c->ceremony->expires_at || $c->ceremony->expires_at->isFuture()))
->values();
return response()->json([
'envelopes' => $claims->map(fn ($c) => [
'ceremony_id' => $c->ceremony_id,
'amount' => $c->amount,
'total_amount' => $c->ceremony->total_amount,
'groom' => $c->ceremony->marriage->user->username ?? '—',
'bride' => $c->ceremony->marriage->partner->username ?? '—',
'tier_name' => $c->ceremony->tier?->name ?? '婚礼',
'tier_icon' => $c->ceremony->tier?->icon ?? '🎊',
'expires_at' => $c->ceremony->expires_at?->toDateTimeString(),
]),
]);
}
}
+171
View File
@@ -0,0 +1,171 @@
<?php
/**
* 文件功能:赛马结算队列任务
*
* 跑马结束后触发,按彩池赔率结算所有下注记录,
* 中奖者获得按注池比例计算的赔付,失败者金币已在下注时扣除。
* 结算完成后广播结果并发公屏公告。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Jobs;
use App\Enums\CurrencySource;
use App\Events\HorseRaceSettled;
use App\Events\MessageSent;
use App\Models\GameConfig;
use App\Models\HorseBet;
use App\Models\HorseRace;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
class CloseHorseRaceJob implements ShouldQueue
{
use Queueable;
/**
* 最大重试次数。
*/
public int $tries = 3;
/**
* @param HorseRace $race 要结算的场次
*/
public function __construct(
public readonly HorseRace $race,
) {}
/**
* 执行结算逻辑。
*/
public function handle(
UserCurrencyService $currency,
ChatStateService $chatState,
): void {
$race = $this->race->fresh();
// 防止重复结算
if (! $race || $race->status !== 'running') {
return;
}
// CAS 改状态为 settled
$updated = HorseRace::query()
->where('id', $race->id)
->where('status', 'running')
->update(['status' => 'settled', 'settled_at' => now()]);
if (! $updated) {
return;
}
$race->refresh();
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$houseTake = (int) ($config['house_take_percent'] ?? 5);
$winnerId = (int) $race->winner_horse_id;
// 按马匹统计各匹下注金额
$horsePools = HorseBet::query()
->where('race_id', $race->id)
->groupBy('horse_id')
->selectRaw('horse_id, SUM(amount) as pool')
->pluck('pool', 'horse_id')
->toArray();
$totalPool = array_sum($horsePools);
$winnerPool = (int) ($horsePools[$winnerId] ?? 0);
$netPool = (int) ($totalPool * (1 - $houseTake / 100));
// 结算:遍历所有下注记录
$bets = HorseBet::query()
->where('race_id', $race->id)
->where('status', 'pending')
->with('user')
->get();
$totalPayout = 0;
DB::transaction(function () use ($bets, $winnerId, $netPool, $winnerPool, $currency, &$totalPayout) {
foreach ($bets as $bet) {
if ((int) $bet->horse_id !== $winnerId) {
// 未中奖(本金已在下注时扣除)
$bet->update(['status' => 'lost', 'payout' => 0]);
continue;
}
// 中奖:按注额比例分配净注池
if ($winnerPool > 0) {
$payout = (int) round($netPool * ($bet->amount / $winnerPool));
} else {
$payout = 0;
}
$bet->update(['status' => 'won', 'payout' => $payout]);
if ($payout > 0 && $bet->user) {
$currency->change(
$bet->user,
'gold',
$payout,
CurrencySource::HORSE_WIN,
"赛马 #{$this->race->id}{$bet->horse_id}号马」中奖",
);
}
$totalPayout += $payout;
}
});
// 公屏公告
$this->pushResultMessage($race, $chatState, $totalPayout);
// 广播结算事件
broadcast(new HorseRaceSettled($race));
}
/**
* 向公屏发送赛果系统消息。
*/
private function pushResultMessage(HorseRace $race, ChatStateService $chatState, int $totalPayout): void
{
// 找出胜利马匹名称
$horses = $race->horses ?? [];
$winnerName = '未知';
foreach ($horses as $horse) {
if (($horse['id'] ?? 0) === (int) $race->winner_horse_id) {
$winnerName = ($horse['emoji'] ?? '').($horse['name'] ?? '');
break;
}
}
$payoutText = $totalPayout > 0
? '共派发 🪙'.number_format($totalPayout).' 金币'
: '本场无人获奖';
$content = "🏆 【赛马】第 #{$race->id} 场结束!冠军:{$winnerName}{$payoutText}";
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#f59e0b',
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
SaveMessageJob::dispatch($msg);
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
/**
* 文件功能:赛马开赛队列任务
*
* 由调度器按配置间隔触发,游戏开启时创建新场次,
* 生成参赛马匹,广播开赛事件并安排跑马任务。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Jobs;
use App\Events\HorseRaceOpened;
use App\Events\MessageSent;
use App\Models\GameConfig;
use App\Models\HorseRace;
use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class OpenHorseRaceJob implements ShouldQueue
{
use Queueable;
/**
* 最大重试次数。
*/
public int $tries = 1;
/**
* 执行开赛逻辑。
*/
public function handle(ChatStateService $chatState): void
{
// 检查游戏是否开启
if (! GameConfig::isEnabled('horse_racing')) {
return;
}
// 防止重复开赛(上一场还在进行中)
if (HorseRace::currentRace()) {
return;
}
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$betSeconds = (int) ($config['bet_window_seconds'] ?? 90);
$horseCount = (int) ($config['horse_count'] ?? 4);
$minBet = (int) ($config['min_bet'] ?? 100);
$maxBet = (int) ($config['max_bet'] ?? 100000);
$now = now();
$closesAt = $now->copy()->addSeconds($betSeconds);
// 生成参赛马匹
$horses = HorseRace::generateHorses($horseCount);
// 创建新场次
$race = HorseRace::create([
'status' => 'betting',
'bet_opens_at' => $now,
'bet_closes_at' => $closesAt,
'horses' => $horses,
]);
// 广播开赛事件
broadcast(new HorseRaceOpened($race));
// 公屏系统公告
$horseList = implode(' ', array_map(
fn ($h) => "{$h['emoji']}{$h['name']}",
$horses
));
$content = "🐎 【赛马】第 #{$race->id} 场开始!押注时间 {$betSeconds} 秒,参赛马匹:{$horseList}。押注范围 ".number_format($minBet).'~'.number_format($maxBet).' 金币!';
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#f59e0b',
'action' => '大声宣告',
'sent_at' => $now->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
SaveMessageJob::dispatch($msg);
// 押注截止后触发跑马 & 结算任务
RunHorseRaceJob::dispatch($race)->delay($closesAt);
}
}
+172
View File
@@ -0,0 +1,172 @@
<?php
/**
* 文件功能:赛马跑马动画广播 + 结算队列任务
*
* 押注截止后触发,模拟跑马进度并实时广播,
* 跑完后确定获胜者并调度结算任务。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Jobs;
use App\Events\HorseRaceProgress;
use App\Events\MessageSent;
use App\Models\GameConfig;
use App\Models\HorseBet;
use App\Models\HorseRace;
use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class RunHorseRaceJob implements ShouldQueue
{
use Queueable;
/**
* 最大重试次数。
*/
public int $tries = 2;
/**
* @param HorseRace $race 要运行的场次
*/
public function __construct(
public readonly HorseRace $race,
) {}
/**
* 执行跑马过程并广播进度,最后触发结算。
*/
public function handle(ChatStateService $chatState): void
{
$race = $this->race->fresh();
// 防止重复执行
if (! $race || $race->status !== 'betting') {
return;
}
// 乐观锁:CAS 改状态为 running
$updated = HorseRace::query()
->where('id', $race->id)
->where('status', 'betting')
->update([
'status' => 'running',
'race_starts_at' => now(),
]);
if (! $updated) {
return;
}
$race->refresh();
// 公屏通知:跑马开始
$horseList = implode(' ', array_map(
fn ($h) => "{$h['emoji']}{$h['name']}",
$race->horses ?? []
));
$startMsg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🏇 【赛马】第 #{$race->id} 场押注截止!马匹已进入跑道,比赛开始!参赛阵容:{$horseList}",
'is_secret' => false,
'font_color' => '#336699',
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $startMsg);
broadcast(new MessageSent(1, $startMsg));
SaveMessageJob::dispatch($startMsg);
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$raceDuration = (int) ($config['race_duration'] ?? 30);
$horses = $race->horses ?? [];
$horseCount = count($horses);
// 初始化各马匹进度(0~100),每步随机增量
$positions = array_fill_keys(array_column($horses, 'id'), 0);
$speeds = [];
foreach ($horses as $horse) {
// 基础速度:随机值,确保比赛有悬念(均值接近 race_duration 步完成)
$speeds[$horse['id']] = random_int(2, 5);
}
// 跑马循环:模拟进度广播(此 job 为同步阻塞广播,每步 sleep 1 秒)
$step = 0;
$maxSteps = $raceDuration;
$winnerId = null;
while ($step < $maxSteps && $winnerId === null) {
$step++;
foreach ($horses as $horse) {
$horseId = $horse['id'];
// 随机冲刺(小概率加速)
$boost = random_int(0, 10) >= 9 ? random_int(5, 15) : 0;
$positions[$horseId] = min(100, $positions[$horseId] + $speeds[$horseId] + $boost);
}
// 检查是否有马到达终点
$finishedHorses = array_filter($positions, fn ($p) => $p >= 100);
$finished = ! empty($finishedHorses);
if ($finished) {
// 取进度最高的马为冠军(若并列取 id 最小的)
arsort($finishedHorses);
$winnerId = (int) array_key_first($finishedHorses);
}
// 广播当前帧进度
broadcast(new HorseRaceProgress($race->id, $positions, $finished, $winnerId ?? $this->leadingHorse($positions)));
if ($finished) {
break;
}
sleep(1);
}
// 如果时间到还没分出胜负,取最高进度的马为赢家
if ($winnerId === null) {
arsort($positions);
$winnerId = (int) array_key_first($positions);
}
// 更新场次记录
$race->update([
'race_ends_at' => now(),
'winner_horse_id' => $winnerId,
]);
// 计算注池统计
$totalBets = HorseBet::query()->where('race_id', $race->id)->count();
$totalPool = HorseBet::query()->where('race_id', $race->id)->sum('amount');
$race->update([
'total_bets' => $totalBets,
'total_pool' => $totalPool,
]);
// 触发结算任务
CloseHorseRaceJob::dispatch($race->fresh());
}
/**
* 获取当前领跑马匹 ID(进度最高)。
*
* @param array<int, int> $positions
*/
private function leadingHorse(array $positions): int
{
arsort($positions);
return (int) array_key_first($positions);
}
}
+224
View File
@@ -0,0 +1,224 @@
<?php
/**
* 文件功能:神秘占卜记录模型
*
* 记录用户每次占卜的签文类型、签文文本和当日加成效果。
* 内嵌 50+ 条签文库,提供当日次数查询和随机抽签方法。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FortuneLog extends Model
{
protected $fillable = [
'user_id',
'grade',
'text',
'buff_desc',
'is_free',
'cost',
'fortune_date',
];
/**
* 属性类型转换。
*/
protected function casts(): array
{
return [
'is_free' => 'boolean',
'cost' => 'integer',
'fortune_date' => 'date',
];
}
/**
* 占卜用户关联。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 查询指定用户今日占卜次数。
*/
public static function todayCount(int $userId): int
{
return static::query()
->where('user_id', $userId)
->where('fortune_date', today())
->count();
}
/**
* 查询指定用户今日最新一条占卜记录。
*/
public static function todayLatest(int $userId): ?static
{
return static::query()
->where('user_id', $userId)
->where('fortune_date', today())
->latest()
->first();
}
/**
* 根据概率配置随机抽取一个签文等级。
*
* @param array $config 游戏配置参数
* @return string 签文等级:jackpot | good | normal | bad | curse
*/
public static function rollGrade(array $config): string
{
$jackpotChance = (int) ($config['jackpot_chance'] ?? 5);
$goodChance = (int) ($config['good_chance'] ?? 20);
$badChance = (int) ($config['bad_chance'] ?? 20);
$curseChance = (int) ($config['curse_chance'] ?? 5);
// normal 占剩余概率
$normalChance = max(0, 100 - $jackpotChance - $goodChance - $badChance - $curseChance);
$rand = random_int(1, 100);
return match (true) {
$rand <= $jackpotChance => 'jackpot',
$rand <= $jackpotChance + $goodChance => 'good',
$rand <= $jackpotChance + $goodChance + $normalChance => 'normal',
$rand <= $jackpotChance + $goodChance + $normalChance + $badChance => 'bad',
default => 'curse',
};
}
/**
* 根据签文等级随机抽取签文内容。
*
* @param string $grade 签文等级
* @return array{text: string, buff_desc: string} 签文文字和加成描述
*/
public static function rollFortune(string $grade): array
{
$library = self::fortuneLibrary();
$pool = $library[$grade] ?? $library['normal'];
$entry = $pool[array_rand($pool)];
return $entry;
}
/**
* 签文库:各等级预设签文(共 55 条)。
*
* @return array<string, array<int, array{text: string, buff_desc: string}>>
*/
private static function fortuneLibrary(): array
{
return [
// ─── 上上签(5条)──────────────────────────────────────
'jackpot' => [
['text' => '龙凤呈祥,万事皆宜。天降鸿运,财源广进!', 'buff_desc' => '✨ 今日金币获取 +30%,经验获取 +20%'],
['text' => '紫气东来,鸿运当头。凡事顺遂,财运亨通!', 'buff_desc' => '✨ 今日金币获取 +30%,魅力增长 +50%'],
['text' => '吉星高照,百事大吉。此乃天赐良机,把握之!', 'buff_desc' => '✨ 今日全属性获取 +25%'],
['text' => '神明庇佑,万难消散。今日出行,无往不利!', 'buff_desc' => '✨ 今日金币获取 +40%'],
['text' => '天时地利人和,三才俱备。大展宏图,正此时也!', 'buff_desc' => '✨ 今日经验获取 +50%,金币 +20%'],
],
// ─── 上签(10条)──────────────────────────────────────
'good' => [
['text' => '春风得意马蹄疾,一日看尽长安花。好运正来,进取可得!', 'buff_desc' => '🌸 今日金币获取 +15%'],
['text' => '风调雨顺,五谷丰登。诸事顺利,喜事临门。', 'buff_desc' => '🌸 今日经验获取 +20%'],
['text' => '柳暗花明又一村,峰回路转现坦途。坚持便是胜利!', 'buff_desc' => '🌸 今日金币及经验各 +10%'],
['text' => '心想事成,万事如意。凡有所求,皆可如愿。', 'buff_desc' => '🌸 今日金币获取 +15%'],
['text' => '鱼跃龙门,一步登天。今日努力,事半功倍!', 'buff_desc' => '🌸 今日经验获取 +25%'],
['text' => '花好月圆,良辰美景。诸事大吉,百福临门。', 'buff_desc' => '🌸 今日魅力增长 +30%'],
['text' => '云开雾散见朝阳,前路光明万里长。好运常伴,前途无量!', 'buff_desc' => '🌸 今日金币获取 +20%'],
['text' => '锦上添花,好事成双。今日诸事皆宜,勇往直前!', 'buff_desc' => '🌸 今日全属性 +10%'],
['text' => '马到成功,旗开得胜。积极行动,收获满满!', 'buff_desc' => '🌸 今日经验获取 +15%'],
['text' => '福星高照,喜气洋洋。和气生财,贵人相助!', 'buff_desc' => '🌸 今日金币 +18%,魅力 +20%'],
],
// ─── 中签(20条)──────────────────────────────────────
'normal' => [
['text' => '守株待兔不如主动出击,时机稍纵即逝,把握当下。', 'buff_desc' => null],
['text' => '平平淡淡才是真,安分守己自太平。此乃中签,宜静不宜动。', 'buff_desc' => null],
['text' => '水至清则无鱼,人至察则无徒。凡事保持平常心。', 'buff_desc' => null],
['text' => '船到桥头自然直,车到山前必有路。莫要忧虑,顺其自然。', 'buff_desc' => null],
['text' => '路漫漫其修远兮,吾将上下而求索。持之以恒,终见曙光。', 'buff_desc' => null],
['text' => '千里之行,始于足下。今日无大凶无大吉,稳中求进。', 'buff_desc' => null],
['text' => '谋事在人,成事在天。尽力而为,其余顺其自然。', 'buff_desc' => null],
['text' => '不以物喜,不以己悲。心态平和,自有一番天地。', 'buff_desc' => null],
['text' => '厚积薄发,积柔成刚。今日蓄力,来日爆发。', 'buff_desc' => null],
['text' => '勤能补拙,笨鸟先飞。平庸亦可,持续努力方为上策。', 'buff_desc' => null],
['text' => '事无大小,做好眼前即是。此签平稳,无风雨亦无彩虹。', 'buff_desc' => null],
['text' => '知足者常乐,贪多者多失。今日知足,便是福气。', 'buff_desc' => null],
['text' => '中庸之道,乃处世良方。不争不抢,随缘自在。', 'buff_desc' => null],
['text' => '晴天要备雨伞,好时要留余地。居安思危,防患未然。', 'buff_desc' => null],
['text' => '静水流深,沉默有力。今日低调行事,暗中蓄势。', 'buff_desc' => null],
['text' => '月有阴晴圆缺,人有悲欢离合。此乃常态,坦然接受。', 'buff_desc' => null],
['text' => '三人行,必有我师。今日宜多学习,少出风头。', 'buff_desc' => null],
['text' => '凡事量力而行,不可强求。今日随缘,顺其自然。', 'buff_desc' => null],
['text' => '忍一时风平浪静,退一步海阔天空。宽容待人,必有回报。', 'buff_desc' => null],
['text' => '心静自然凉,此乃中签之意,平安即是福。', 'buff_desc' => null],
],
// ─── 下签(10条)──────────────────────────────────────
'bad' => [
['text' => '乌云当头,诸事不顺。今日宜静守,切莫轻举妄动。', 'buff_desc' => '😞 今日金币获取 -10%'],
['text' => '时运不济,命途多舛。凡事三思而后行,小心为上。', 'buff_desc' => '😞 今日经验获取 -10%'],
['text' => '逆水行舟,举步维艰。今日诸事宜谨慎,避免重大决策。', 'buff_desc' => '😞 今日金币获取 -10%'],
['text' => '阴云密布,好运暂退。宜韬光养晦,待时机再出。', 'buff_desc' => '😞 今日全属性 -5%'],
['text' => '事与愿违,心力交瘁。今日多加休息,来日再战。', 'buff_desc' => '😞 今日经验获取 -15%'],
['text' => '小人横行,道路坎坷。今日言多必失,祸从口出,慎言!', 'buff_desc' => '😞 今日金币 -10%,魅力 -10%'],
['text' => '财帛散尽,才识运归。今日不宜大额消费,节俭为上。', 'buff_desc' => '😞 今日金币获取 -15%'],
['text' => '阳光总在风雨后,苦尽甘来是常事。今日忍耐,明日可期。', 'buff_desc' => '😞 今日经验获取 -10%'],
['text' => '暗箭难防,处处小心。今日谨慎行事,切勿冒进。', 'buff_desc' => '😞 今日全属性 -8%'],
['text' => '屋漏偏逢连夜雨,行路偏遇顶头风。逆境磨砺,坚持即胜。', 'buff_desc' => '😞 今日金币获取 -12%'],
],
// ─── 大凶签(5条)──────────────────────────────────────
'curse' => [
['text' => '大凶!鬼门大开,诸事皆凶。今日宜闭门避祸,切忌妄动!', 'buff_desc' => '💀 今日金币获取 -25%,经验 -20%'],
['text' => '凶星临头,百事皆忌。今日万事不顺,速速祈神化解!', 'buff_desc' => '💀 今日金币获取 -30%'],
['text' => '阴煞临身,大凶兆也!今日诸事皆休,静待天命。', 'buff_desc' => '💀 今日全属性 -20%'],
['text' => '问此签,凶也大凶。如遇困境,切勿强行克服,顺势而为!', 'buff_desc' => '💀 今日经验获取 -30%,金币 -20%'],
['text' => '天机不可泄露,然此签示警。今日三灾八难,小心谨慎,化凶为吉!', 'buff_desc' => '💀 今日金币获取 -25%,魅力 -15%'],
],
];
}
/**
* 返回签文等级对应的中文名称。
*/
public function gradeLabel(): string
{
return match ($this->grade) {
'jackpot' => '上上签',
'good' => '上签',
'normal' => '中签',
'bad' => '下签',
'curse' => '大凶签',
default => '未知',
};
}
/**
* 返回签文等级对应的颜色(前端展示用)。
*/
public function gradeColor(): string
{
return match ($this->grade) {
'jackpot' => '#f59e0b', // 金色
'good' => '#10b981', // 翠绿
'normal' => '#6b7280', // 灰色
'bad' => '#f97316', // 橙色
'curse' => '#ef4444', // 红色
default => '#6b7280',
};
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
/**
* 文件功能:赛马竞猜下注记录模型
*
* 记录用户在每场赛马中的下注信息、押注马匹、结果和赔付金额。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class HorseBet extends Model
{
protected $fillable = [
'race_id',
'user_id',
'horse_id',
'amount',
'status',
'payout',
];
/**
* 属性类型转换。
*/
protected function casts(): array
{
return [
'horse_id' => 'integer',
'amount' => 'integer',
'payout' => 'integer',
];
}
/**
* 所属场次。
*/
public function race(): BelongsTo
{
return $this->belongsTo(HorseRace::class, 'race_id');
}
/**
* 下注用户。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
/**
* 文件功能:赛马竞猜局次模型
*
* 代表一场赛马比赛,包含参赛马匹信息、场次状态、
* 注池统计以及赛果信息。提供当前场次查询和赔率计算方法。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class HorseRace extends Model
{
protected $fillable = [
'status',
'bet_opens_at',
'bet_closes_at',
'race_starts_at',
'race_ends_at',
'horses',
'winner_horse_id',
'total_bets',
'total_pool',
'settled_at',
];
/**
* 属性类型转换。
*/
protected function casts(): array
{
return [
'bet_opens_at' => 'datetime',
'bet_closes_at' => 'datetime',
'race_starts_at' => 'datetime',
'race_ends_at' => 'datetime',
'settled_at' => 'datetime',
'horses' => 'array',
'winner_horse_id' => 'integer',
'total_bets' => 'integer',
'total_pool' => 'integer',
];
}
/**
* 本场所有下注记录。
*/
public function bets(): HasMany
{
return $this->hasMany(HorseBet::class, 'race_id');
}
/**
* 判断当前是否在押注时间窗口内。
*/
public function isBettingOpen(): bool
{
return $this->status === 'betting'
&& now()->between($this->bet_opens_at, $this->bet_closes_at);
}
/**
* 查询当前正在进行的场次(状态为 betting 且押注未截止)。
*/
public static function currentRace(): ?static
{
return static::query()
->whereIn('status', ['betting', 'running'])
->latest()
->first();
}
/**
* 生成参赛马匹列表(根据马匹数量随机选名)。
*
* @param int $count 马匹数量
* @return array<int, array{id: int, name: string, emoji: string}>
*/
public static function generateHorses(int $count): array
{
// 可用马匹名池(原版竞技风格)
$namePool = [
['name' => '赤兔', 'emoji' => '🐎'],
['name' => '乌骓', 'emoji' => '🐴'],
['name' => '的卢', 'emoji' => '🎠'],
['name' => '绝影', 'emoji' => '🦄'],
['name' => '紫骍', 'emoji' => '🐎'],
['name' => '爪黄', 'emoji' => '🐴'],
['name' => '汗血', 'emoji' => '🎠'],
['name' => '飞电', 'emoji' => '⚡'],
];
// 随机打乱并取前 N 个
shuffle($namePool);
$selected = array_slice($namePool, 0, $count);
$horses = [];
foreach ($selected as $i => $horse) {
$horses[] = [
'id' => $i + 1,
'name' => $horse['name'],
'emoji' => $horse['emoji'],
];
}
return $horses;
}
/**
* 根据注池计算各马匹实时赔率(彩池制,扣除庄家抽水后按比例分配)。
*
* @param int $horseBetAmounts 各马匹的注额数组 [horse_id => amount]
* @param int $housePercent 庄家抽水百分比
* @return array<int, float> horse_id => 赔率(含本金)
*/
public static function calcOdds(array $horseBetAmounts, int $housePercent): array
{
$totalPool = array_sum($horseBetAmounts);
if ($totalPool <= 0) {
// 尚无下注,返回等额赔率
$count = count($horseBetAmounts);
return array_map(fn () => 1.0, $horseBetAmounts);
}
$netPool = $totalPool * (1 - $housePercent / 100);
$odds = [];
foreach ($horseBetAmounts as $horseId => $amount) {
if ($amount <= 0) {
// 无人押注的马,赔率设为理论最大值
$odds[$horseId] = round($netPool, 2);
} else {
// 赔率 = 净注池 / 该马注额(含本金返还)
$odds[$horseId] = round($netPool / $amount, 2);
}
}
return $odds;
}
}
+60 -1
View File
@@ -1,10 +1,69 @@
<?php
/**
* 文件功能:婚礼红包领取记录模型
*
* 对应 wedding_envelope_claims 表。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WeddingEnvelopeClaim extends Model
{
//
/**
* 该模型不使用 updated_at 字段。
*/
const UPDATED_AT = null;
/**
* 允许批量赋值的属性。
*
* @var list<string>
*/
protected $fillable = [
'ceremony_id',
'user_id',
'amount',
'claimed',
'claimed_at',
'created_at',
];
/**
* 获取属性类型转换。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'claimed' => 'boolean',
'claimed_at' => 'datetime',
'created_at' => 'datetime',
'amount' => 'integer',
];
}
/**
* 关联婚礼仪式。
*/
public function ceremony(): BelongsTo
{
return $this->belongsTo(WeddingCeremony::class, 'ceremony_id');
}
/**
* 关联领取用户。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}
+9 -4
View File
@@ -213,7 +213,7 @@ class AppointmentService
->whereNull('logout_at')
->whereDate('login_at', today())
->update([
'logout_at' => now(),
'logout_at' => now(),
'duration_seconds' => DB::raw('TIMESTAMPDIFF(SECOND, login_at, NOW())'),
]);
@@ -222,12 +222,17 @@ class AppointmentService
->whereNull('logout_at')
->whereDate('login_at', '<', today())
->update([
'logout_at' => DB::raw('login_at'),
'logout_at' => DB::raw('login_at'),
'duration_seconds' => 0,
]);
// user_level 归 1(由系统经验值自然升级机制重新成长
$target->update(['user_level' => 1]);
// 撤职后:按当前经验值重新计算等级(不再无条件归 1
// 这样用户撤职后能保留正常的经验升级成果
$recalcLevel = \App\Models\Sysparam::calculateLevel($target->exp_num ?? 0);
$superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100');
// 不超过满级阈值
$safeLevel = max(1, min($recalcLevel, $superLevel - 1));
$target->update(['user_level' => $safeLevel]);
// 写入权限操作日志
$this->logAuthority(