443 lines
16 KiB
PHP
443 lines
16 KiB
PHP
<?php
|
|
|
|
/**
|
|
* 文件功能:用户成就扫描与授予服务。
|
|
*
|
|
* 基于聊天室已有日志表聚合用户进度,并写入固定成就目录的解锁状态。
|
|
*/
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Enums\CurrencySource;
|
|
use App\Events\MessageSent;
|
|
use App\Jobs\SaveMessageJob;
|
|
use App\Models\BaccaratBet;
|
|
use App\Models\DailySignIn;
|
|
use App\Models\GomokuGame;
|
|
use App\Models\HorseBet;
|
|
use App\Models\LotteryTicket;
|
|
use App\Models\Marriage;
|
|
use App\Models\Message;
|
|
use App\Models\PositionAuthorityLog;
|
|
use App\Models\PositionDutyLog;
|
|
use App\Models\RedPacketClaim;
|
|
use App\Models\RedPacketEnvelope;
|
|
use App\Models\SlotMachineLog;
|
|
use App\Models\User;
|
|
use App\Models\UserAchievement;
|
|
use App\Models\UserAchievementProgress;
|
|
use App\Models\UserCurrencyLog;
|
|
use App\Models\UserPosition;
|
|
use App\Support\AchievementCatalog;
|
|
use Illuminate\Support\Collection;
|
|
|
|
/**
|
|
* 类功能:计算成就进度、创建解锁记录并推送本人通知。
|
|
*/
|
|
class AchievementService
|
|
{
|
|
/**
|
|
* 创建成就服务依赖。
|
|
*/
|
|
public function __construct(
|
|
private readonly ChatStateService $chatState,
|
|
) {}
|
|
|
|
/**
|
|
* 扫描单个用户的所有固定成就。
|
|
*
|
|
* @return array{checked: int, unlocked: int, updated: int, dry_run: bool}
|
|
*/
|
|
public function scanUser(User $user, bool $notify = false, bool $dryRun = false): array
|
|
{
|
|
$progress = $this->progressForUser($user);
|
|
$checked = 0;
|
|
$unlocked = 0;
|
|
$updated = 0;
|
|
|
|
foreach (AchievementCatalog::definitions() as $definition) {
|
|
$checked++;
|
|
$value = (int) ($progress[$definition['metric']] ?? 0);
|
|
$achievement = UserAchievement::query()
|
|
->where('user_id', $user->id)
|
|
->where('achievement_key', $definition['key'])
|
|
->first();
|
|
|
|
if ($dryRun) {
|
|
if ($value >= $definition['threshold'] && ! $achievement?->achieved_at) {
|
|
$unlocked++;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
$this->storeProgress($user, $definition, $value);
|
|
|
|
if (! $achievement) {
|
|
$achievement = UserAchievement::query()->create([
|
|
'user_id' => $user->id,
|
|
'achievement_key' => $definition['key'],
|
|
'progress_value' => $value,
|
|
'metadata' => ['threshold' => $definition['threshold']],
|
|
]);
|
|
$updated++;
|
|
} elseif ($achievement->progress_value !== $value) {
|
|
$achievement->forceFill(['progress_value' => $value])->save();
|
|
$updated++;
|
|
}
|
|
|
|
if ($value < $definition['threshold'] || $achievement->achieved_at) {
|
|
continue;
|
|
}
|
|
|
|
$achievement->forceFill([
|
|
'progress_value' => $value,
|
|
'achieved_at' => now(),
|
|
'metadata' => ['threshold' => $definition['threshold']],
|
|
])->save();
|
|
$unlocked++;
|
|
|
|
if ($notify) {
|
|
$this->notifyUnlocked($user, $achievement, $definition);
|
|
}
|
|
}
|
|
|
|
return [
|
|
'checked' => $checked,
|
|
'unlocked' => $unlocked,
|
|
'updated' => $updated,
|
|
'dry_run' => $dryRun,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 批量扫描用户成就。
|
|
*
|
|
* @return array{users: int, checked: int, unlocked: int, updated: int, dry_run: bool}
|
|
*/
|
|
public function scanUsers(iterable $users, bool $notify = false, bool $dryRun = false): array
|
|
{
|
|
$summary = ['users' => 0, 'checked' => 0, 'unlocked' => 0, 'updated' => 0, 'dry_run' => $dryRun];
|
|
|
|
foreach ($users as $user) {
|
|
$result = $this->scanUser($user, $notify, $dryRun);
|
|
$summary['users']++;
|
|
$summary['checked'] += $result['checked'];
|
|
$summary['unlocked'] += $result['unlocked'];
|
|
$summary['updated'] += $result['updated'];
|
|
}
|
|
|
|
return $summary;
|
|
}
|
|
|
|
/**
|
|
* 组装用户成就展示数据。
|
|
*
|
|
* @return array{categories: array<string, string>, achievements: Collection<int, array<string, mixed>>, unlocked_count: int, total_count: int}
|
|
*/
|
|
public function displayForUser(User $user): array
|
|
{
|
|
$progress = $this->progressForUser($user);
|
|
$records = UserAchievement::query()
|
|
->where('user_id', $user->id)
|
|
->get()
|
|
->keyBy('achievement_key');
|
|
|
|
$achievements = collect(AchievementCatalog::definitions())
|
|
->sortBy('sort')
|
|
->map(function (array $definition) use ($progress, $records): array {
|
|
$record = $records->get($definition['key']);
|
|
$value = max((int) ($record?->progress_value ?? 0), (int) ($progress[$definition['metric']] ?? 0));
|
|
$threshold = (int) $definition['threshold'];
|
|
|
|
return [
|
|
...$definition,
|
|
'progress_value' => $value,
|
|
'progress_percent' => $threshold > 0 ? min(100, (int) floor($value / $threshold * 100)) : 100,
|
|
'achieved_at' => $record?->achieved_at,
|
|
'unlocked' => (bool) $record?->achieved_at,
|
|
];
|
|
})
|
|
->values();
|
|
|
|
return [
|
|
'categories' => AchievementCatalog::categories(),
|
|
'achievements' => $achievements,
|
|
'unlocked_count' => $achievements->where('unlocked', true)->count(),
|
|
'total_count' => $achievements->count(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 读取用户最近解锁成就。
|
|
*
|
|
* @return Collection<int, array<string, mixed>>
|
|
*/
|
|
public function recentUnlockedForUser(User $user, int $limit = 5): Collection
|
|
{
|
|
return UserAchievement::query()
|
|
->where('user_id', $user->id)
|
|
->whereNotNull('achieved_at')
|
|
->latest('achieved_at')
|
|
->limit($limit)
|
|
->get()
|
|
->map(function (UserAchievement $achievement): array {
|
|
$definition = AchievementCatalog::find($achievement->achievement_key);
|
|
|
|
return [
|
|
'key' => $achievement->achievement_key,
|
|
'name' => $definition['name'] ?? $achievement->achievement_key,
|
|
'icon' => $definition['icon'] ?? '🏅',
|
|
'description' => $definition['description'] ?? '',
|
|
'achieved_at' => $achievement->achieved_at?->toDateTimeString(),
|
|
];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 读取用户资料卡使用的成就摘要。
|
|
*
|
|
* @return array{unlocked_count: int, total_count: int, recent: array<int, array<string, mixed>>}
|
|
*/
|
|
public function profileSummaryForUser(User $user): array
|
|
{
|
|
return [
|
|
'unlocked_count' => (int) UserAchievement::query()
|
|
->where('user_id', $user->id)
|
|
->whereNotNull('achieved_at')
|
|
->count(),
|
|
'total_count' => count(AchievementCatalog::definitions()),
|
|
'recent' => $this->recentUnlockedForUser($user, 5)->values()->all(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 聚合单个用户所有成就进度。
|
|
*
|
|
* @return array<string, int>
|
|
*/
|
|
public function progressForUser(User $user): array
|
|
{
|
|
$username = (string) $user->username;
|
|
|
|
return [
|
|
'chat_messages' => $this->chatMessageCount($username),
|
|
'welcome_messages' => $this->welcomeMessageCount($username),
|
|
'total_sign_ins' => (int) DailySignIn::query()->where('user_id', $user->id)->count(),
|
|
'sign_in_streak' => (int) DailySignIn::query()->where('user_id', $user->id)->max('streak_days'),
|
|
'makeup_sign_ins' => (int) DailySignIn::query()->where('user_id', $user->id)->where('is_makeup', true)->count(),
|
|
'exp_gain' => $this->currencyGain($user->id, 'exp'),
|
|
'gold_gain' => $this->currencyGain($user->id, 'gold'),
|
|
'charm_gain' => $this->currencyGain($user->id, 'charm'),
|
|
'gold_assets' => max(0, (int) $user->jjb + (int) $user->bank_jjb),
|
|
'bank_balance' => max(0, (int) $user->bank_jjb),
|
|
'game_gold_won' => $this->gameGoldWon($user->id),
|
|
'game_gold_lost' => $this->gameGoldLost($user->id),
|
|
'baccarat_bets' => (int) BaccaratBet::query()->where('user_id', $user->id)->count(),
|
|
'horse_bets' => (int) HorseBet::query()->where('user_id', $user->id)->count(),
|
|
'lottery_tickets' => (int) LotteryTicket::query()->where('user_id', $user->id)->count(),
|
|
'slot_spins' => (int) SlotMachineLog::query()->where('user_id', $user->id)->count(),
|
|
'gomoku_wins' => $this->gomokuWinCount($user->id),
|
|
'fishing_times' => $this->currencySourceCount($user->id, CurrencySource::FISHING_COST->value),
|
|
'riddle_wins' => $this->currencySourceCount($user->id, CurrencySource::GAME_REWARD->value),
|
|
'red_packets_sent' => (int) RedPacketEnvelope::query()->where('sender_id', $user->id)->count(),
|
|
'red_packets_claimed' => (int) RedPacketClaim::query()->where('user_id', $user->id)->count(),
|
|
'marriages' => (int) Marriage::query()->where('status', 'married')->where(fn ($query) => $query->where('user_id', $user->id)->orWhere('partner_id', $user->id))->count(),
|
|
'marriage_intimacy' => (int) Marriage::query()->where(fn ($query) => $query->where('user_id', $user->id)->orWhere('partner_id', $user->id))->max('intimacy'),
|
|
'gifts_sent' => $this->currencySourceCount($user->id, CurrencySource::SEND_GIFT->value),
|
|
'gifts_received' => $this->currencySourceCount($user->id, CurrencySource::RECV_GIFT->value),
|
|
'positions' => (int) UserPosition::query()->where('user_id', $user->id)->count(),
|
|
'duty_minutes' => (int) floor((int) PositionDutyLog::query()->where('user_id', $user->id)->sum('duration_seconds') / 60),
|
|
'authority_actions' => (int) PositionAuthorityLog::query()->where('user_id', $user->id)->count(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 统计普通用户聊天消息数量。
|
|
*/
|
|
private function chatMessageCount(string $username): int
|
|
{
|
|
return (int) Message::query()
|
|
->where('from_user', $username)
|
|
->whereIn('message_type', ['text', 'image', 'expired_image'])
|
|
->where(function ($query) {
|
|
$query->where('retention_type', Message::RETENTION_USER_CHAT)
|
|
->orWhereNull('retention_type');
|
|
})
|
|
->count();
|
|
}
|
|
|
|
/**
|
|
* 统计用户发出的欢迎动作次数。
|
|
*/
|
|
private function welcomeMessageCount(string $username): int
|
|
{
|
|
return (int) Message::query()
|
|
->where('from_user', $username)
|
|
->where('action', '欢迎')
|
|
->count();
|
|
}
|
|
|
|
/**
|
|
* 统计指定货币的累计正向获得量。
|
|
*/
|
|
private function currencyGain(int $userId, string $currency): int
|
|
{
|
|
return (int) UserCurrencyLog::query()
|
|
->where('user_id', $userId)
|
|
->where('currency', $currency)
|
|
->where('amount', '>', 0)
|
|
->sum('amount');
|
|
}
|
|
|
|
/**
|
|
* 统计指定流水来源次数。
|
|
*/
|
|
private function currencySourceCount(int $userId, string $source): int
|
|
{
|
|
return (int) UserCurrencyLog::query()
|
|
->where('user_id', $userId)
|
|
->where('source', $source)
|
|
->count();
|
|
}
|
|
|
|
/**
|
|
* 统计用户通过游戏相关流水累计赢取的金币。
|
|
*/
|
|
private function gameGoldWon(int $userId): int
|
|
{
|
|
return (int) UserCurrencyLog::query()
|
|
->where('user_id', $userId)
|
|
->where('currency', 'gold')
|
|
->where('amount', '>', 0)
|
|
->whereIn('source', $this->gameWinSources())
|
|
->sum('amount');
|
|
}
|
|
|
|
/**
|
|
* 统计用户在游戏相关流水中累计输掉或消耗的金币。
|
|
*/
|
|
private function gameGoldLost(int $userId): int
|
|
{
|
|
return abs((int) UserCurrencyLog::query()
|
|
->where('user_id', $userId)
|
|
->where('currency', 'gold')
|
|
->where('amount', '<', 0)
|
|
->whereIn('source', $this->gameLossSources())
|
|
->sum('amount'));
|
|
}
|
|
|
|
/**
|
|
* 返回游戏赢钱来源,用于游戏赢取类成就聚合。
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
private function gameWinSources(): array
|
|
{
|
|
return [
|
|
CurrencySource::BACCARAT_WIN->value,
|
|
CurrencySource::BACCARAT_LOSS_COVER_CLAIM->value,
|
|
CurrencySource::HORSE_WIN->value,
|
|
CurrencySource::LOTTERY_WIN->value,
|
|
CurrencySource::SLOT_WIN->value,
|
|
CurrencySource::FISHING_GAIN->value,
|
|
CurrencySource::MYSTERY_BOX->value,
|
|
CurrencySource::GOMOKU_WIN->value,
|
|
CurrencySource::GAME_REWARD->value,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 返回游戏输钱来源,用于游戏输钱类成就聚合。
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
private function gameLossSources(): array
|
|
{
|
|
return [
|
|
CurrencySource::BACCARAT_BET->value,
|
|
CurrencySource::HORSE_BET->value,
|
|
CurrencySource::LOTTERY_BUY->value,
|
|
CurrencySource::SLOT_SPIN->value,
|
|
CurrencySource::SLOT_CURSE->value,
|
|
CurrencySource::FISHING_COST->value,
|
|
CurrencySource::FORTUNE_COST->value,
|
|
CurrencySource::GOMOKU_ENTRY_FEE->value,
|
|
CurrencySource::MYSTERY_BOX_TRAP->value,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 统计五子棋胜利次数。
|
|
*/
|
|
private function gomokuWinCount(int $userId): int
|
|
{
|
|
return (int) GomokuGame::query()
|
|
->where('status', 'finished')
|
|
->where(function ($query) use ($userId) {
|
|
$query->where(fn ($inner) => $inner->where('player_black_id', $userId)->where('winner', 1))
|
|
->orWhere(fn ($inner) => $inner->where('player_white_id', $userId)->where('winner', 2));
|
|
})
|
|
->count();
|
|
}
|
|
|
|
/**
|
|
* 写入用户成就进度快照。
|
|
*
|
|
* @param array<string, mixed> $definition 成就定义
|
|
*/
|
|
private function storeProgress(User $user, array $definition, int $value): void
|
|
{
|
|
UserAchievementProgress::query()->updateOrCreate(
|
|
[
|
|
'user_id' => $user->id,
|
|
'achievement_key' => $definition['key'],
|
|
],
|
|
[
|
|
'progress_value' => $value,
|
|
'threshold_value' => (int) $definition['threshold'],
|
|
'last_scanned_at' => now(),
|
|
],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 给用户推送成就解锁通知。
|
|
*
|
|
* @param array<string, mixed> $definition 成就定义
|
|
*/
|
|
private function notifyUnlocked(User $user, UserAchievement $achievement, array $definition): void
|
|
{
|
|
if ($achievement->notified_at) {
|
|
return;
|
|
}
|
|
|
|
$roomId = (int) ($user->room_id ?: 1);
|
|
$message = [
|
|
'id' => $this->chatState->nextMessageId($roomId),
|
|
'room_id' => $roomId,
|
|
'from_user' => '系统公告',
|
|
'to_user' => $user->username,
|
|
'content' => "🏅 恭喜解锁成就:{$definition['icon']} {$definition['name']} <span style=\"color:#64748b;\">{$definition['description']}</span>",
|
|
'is_secret' => true,
|
|
'font_color' => '#ca8a04',
|
|
'action' => 'achievement_unlocked',
|
|
'retention_type' => Message::RETENTION_SYSTEM_NOTICE,
|
|
'toast_notification' => [
|
|
'title' => '🏅 成就解锁',
|
|
'message' => "{$definition['icon']} {$definition['name']}",
|
|
'icon' => '🏅',
|
|
'color' => '#ca8a04',
|
|
'duration' => 3000,
|
|
],
|
|
'sent_at' => now()->toDateTimeString(),
|
|
];
|
|
|
|
$this->chatState->pushMessage($roomId, $message);
|
|
broadcast(new MessageSent($roomId, $message));
|
|
SaveMessageJob::dispatch($message);
|
|
|
|
$achievement->forceFill(['notified_at' => now()])->save();
|
|
}
|
|
}
|