2026-02-28 12:49:26 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 文件功能:用户积分统一变更服务
|
|
|
|
|
|
* 所有修改 exp_num(经验)、jjb(金币)、meili(魅力) 的操作必须经由此服务,
|
|
|
|
|
|
* 禁止在 Controller 中直接操作 User 属性并 save()。
|
|
|
|
|
|
* 本服务负责:原子性更新用户属性、写入流水记录、提供统计与排行数据。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @author ChatRoom Laravel
|
2026-03-12 15:26:54 +08:00
|
|
|
|
*
|
2026-02-28 12:49:26 +08:00
|
|
|
|
* @version 1.0.0
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
namespace App\Services;
|
|
|
|
|
|
|
|
|
|
|
|
use App\Enums\CurrencySource;
|
|
|
|
|
|
use App\Models\User;
|
|
|
|
|
|
use App\Models\UserCurrencyLog;
|
|
|
|
|
|
use Illuminate\Support\Collection;
|
|
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
|
|
|
|
|
|
|
|
class UserCurrencyService
|
|
|
|
|
|
{
|
|
|
|
|
|
/**
|
|
|
|
|
|
* currency 标识与 users 表字段名的映射关系。
|
|
|
|
|
|
* 以后新增货币类型,在此加一行即可。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private const FIELD_MAP = [
|
2026-03-12 15:26:54 +08:00
|
|
|
|
'exp' => 'exp_num',
|
|
|
|
|
|
'gold' => 'jjb',
|
2026-02-28 12:49:26 +08:00
|
|
|
|
'charm' => 'meili',
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 统一变更用户货币属性并写入流水记录。
|
|
|
|
|
|
* 使用数据库事务保证原子性:用户属性更新 + 流水写入同时成功或同时回滚。
|
|
|
|
|
|
*
|
2026-03-12 15:26:54 +08:00
|
|
|
|
* @param User $user 目标用户
|
|
|
|
|
|
* @param string $currency 货币类型('exp' / 'gold' / 'charm')
|
|
|
|
|
|
* @param int $amount 变更量,正数增加,负数扣除
|
|
|
|
|
|
* @param CurrencySource $source 来源活动枚举
|
|
|
|
|
|
* @param string $remark 备注说明
|
|
|
|
|
|
* @param int|null $roomId 所在房间 ID(可选)
|
2026-02-28 12:49:26 +08:00
|
|
|
|
*/
|
|
|
|
|
|
public function change(
|
2026-03-12 15:26:54 +08:00
|
|
|
|
User $user,
|
|
|
|
|
|
string $currency,
|
|
|
|
|
|
int $amount,
|
2026-02-28 12:49:26 +08:00
|
|
|
|
CurrencySource $source,
|
2026-03-12 15:26:54 +08:00
|
|
|
|
string $remark = '',
|
|
|
|
|
|
?int $roomId = null,
|
2026-02-28 12:49:26 +08:00
|
|
|
|
): void {
|
|
|
|
|
|
if ($amount === 0) {
|
|
|
|
|
|
return; // 变更量为 0 不写记录
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$field = self::FIELD_MAP[$currency] ?? null;
|
|
|
|
|
|
if (! $field) {
|
|
|
|
|
|
return; // 未知货币类型,静默忽略(不抛异常,避免影响主流程)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
DB::transaction(function () use ($user, $currency, $amount, $source, $remark, $roomId, $field) {
|
|
|
|
|
|
// 原子性更新用户属性(用 increment/decrement 防并发竞态)
|
|
|
|
|
|
if ($amount > 0) {
|
|
|
|
|
|
$user->increment($field, $amount);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 扣除时不让余额低于 0
|
|
|
|
|
|
$user->decrement($field, min(abs($amount), $user->$field ?? 0));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 重新读取最新余额(避免缓存脏数据)
|
|
|
|
|
|
$balanceAfter = (int) $user->fresh()->$field;
|
|
|
|
|
|
|
|
|
|
|
|
// 写入流水记录(快照当前用户名,排行 JOIN 时再取最新名)
|
|
|
|
|
|
UserCurrencyLog::create([
|
2026-03-12 15:26:54 +08:00
|
|
|
|
'user_id' => $user->id,
|
|
|
|
|
|
'username' => $user->username,
|
|
|
|
|
|
'currency' => $currency,
|
|
|
|
|
|
'amount' => $amount,
|
|
|
|
|
|
'balance_after' => $balanceAfter,
|
|
|
|
|
|
'source' => $source->value,
|
|
|
|
|
|
'remark' => $remark,
|
|
|
|
|
|
'room_id' => $roomId,
|
2026-02-28 12:49:26 +08:00
|
|
|
|
]);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 批量变更多个用户的货币属性(适用于自动存点:一次操作多人)。
|
|
|
|
|
|
* 每位用户仍独立走事务,单人失败不影响其他人。
|
|
|
|
|
|
*
|
2026-03-12 15:26:54 +08:00
|
|
|
|
* @param array $items [['user' => User, 'changes' => ['exp'=>1,'gold'=>2]], ...]
|
2026-02-28 12:49:26 +08:00
|
|
|
|
*/
|
|
|
|
|
|
public function batchChange(array $items, CurrencySource $source, ?int $roomId = null): void
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach ($items as $item) {
|
2026-03-12 15:26:54 +08:00
|
|
|
|
$user = $item['user'];
|
2026-02-28 12:49:26 +08:00
|
|
|
|
$changes = $item['changes'] ?? [];
|
|
|
|
|
|
foreach ($changes as $currency => $amount) {
|
|
|
|
|
|
$this->change($user, $currency, (int) $amount, $source, '', $roomId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 查询某日各来源活动的产出统计(后台统计页面使用)。
|
|
|
|
|
|
* 返回格式:Collection of stdClass { source, currency, total_amount, participant_count }
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param string|null $date 日期字符串如 '2026-02-28',默认今日
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function activityStats(?string $date = null): Collection
|
|
|
|
|
|
{
|
|
|
|
|
|
$date = $date ?? today()->toDateString();
|
|
|
|
|
|
|
|
|
|
|
|
return UserCurrencyLog::query()
|
|
|
|
|
|
->whereDate('created_at', $date)
|
|
|
|
|
|
->selectRaw('source, currency, SUM(amount) as total_amount, COUNT(DISTINCT user_id) as participant_count')
|
|
|
|
|
|
->groupBy('source', 'currency')
|
|
|
|
|
|
->orderBy('currency')
|
|
|
|
|
|
->orderByRaw('ABS(SUM(amount)) DESC')
|
|
|
|
|
|
->get();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 今日排行榜(按 user_id 聚合,展示最新用户名)。
|
|
|
|
|
|
* 只统计正向变更(amount > 0),不因消耗而扣分。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param string $currency 'exp' | 'gold' | 'charm'
|
2026-03-12 15:26:54 +08:00
|
|
|
|
* @param int $limit 返回条数
|
|
|
|
|
|
* @param string|null $date 日期,默认今日
|
2026-02-28 12:49:26 +08:00
|
|
|
|
*/
|
|
|
|
|
|
public function todayLeaderboard(string $currency, int $limit = 20, ?string $date = null): Collection
|
|
|
|
|
|
{
|
|
|
|
|
|
$date = $date ?? today()->toDateString();
|
|
|
|
|
|
|
|
|
|
|
|
return UserCurrencyLog::query()
|
|
|
|
|
|
->whereDate('created_at', $date)
|
|
|
|
|
|
->where('currency', $currency)
|
2026-03-28 17:38:59 +08:00
|
|
|
|
// 计算净收益,包含正向与负向消耗
|
2026-02-28 12:49:26 +08:00
|
|
|
|
->selectRaw('user_id, SUM(amount) as total')
|
|
|
|
|
|
->groupBy('user_id')
|
2026-03-28 17:38:59 +08:00
|
|
|
|
->havingRaw('SUM(amount) > 0') // 只有今日净收益为正数才能上榜
|
2026-02-28 12:49:26 +08:00
|
|
|
|
->orderByRaw('SUM(amount) DESC')
|
|
|
|
|
|
->limit($limit)
|
|
|
|
|
|
->get()
|
|
|
|
|
|
->map(function ($row) {
|
|
|
|
|
|
// JOIN 取最新用户名(避免改名后显示旧名)
|
|
|
|
|
|
$user = User::select('id', 'username', 'user_level', 'sex', 'headface')
|
|
|
|
|
|
->find($row->user_id);
|
|
|
|
|
|
|
|
|
|
|
|
return (object) [
|
2026-03-12 15:26:54 +08:00
|
|
|
|
'user_id' => $row->user_id,
|
2026-02-28 12:49:26 +08:00
|
|
|
|
'username' => $user?->username ?? '未知用户',
|
2026-03-12 15:26:54 +08:00
|
|
|
|
'level' => $user?->user_level ?? 0,
|
|
|
|
|
|
'sex' => $user?->sex ?? 1,
|
2026-02-28 12:49:26 +08:00
|
|
|
|
'headface' => $user?->headface ?? '1.gif',
|
2026-03-12 15:26:54 +08:00
|
|
|
|
'total' => $row->total,
|
2026-02-28 12:49:26 +08:00
|
|
|
|
];
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 用户个人流水明细(用户查询自己的日志)。
|
|
|
|
|
|
*
|
2026-03-12 15:26:54 +08:00
|
|
|
|
* @param int $userId 用户 ID
|
2026-02-28 12:49:26 +08:00
|
|
|
|
* @param string|null $currency 为 null 时返回所有货币类型
|
2026-03-12 15:26:54 +08:00
|
|
|
|
* @param int $days 查询最近多少天
|
2026-02-28 12:49:26 +08:00
|
|
|
|
*/
|
|
|
|
|
|
public function userLogs(int $userId, ?string $currency = null, int $days = 7): Collection
|
|
|
|
|
|
{
|
|
|
|
|
|
return UserCurrencyLog::query()
|
|
|
|
|
|
->where('user_id', $userId)
|
|
|
|
|
|
->when($currency, fn ($q) => $q->where('currency', $currency))
|
|
|
|
|
|
->where('created_at', '>=', now()->subDays($days))
|
|
|
|
|
|
->orderByDesc('created_at')
|
|
|
|
|
|
->limit(200)
|
|
|
|
|
|
->get();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 货币类型中文名映射(用于视图展示)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function currencyLabel(string $currency): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return match ($currency) {
|
2026-03-12 15:26:54 +08:00
|
|
|
|
'exp' => '经验',
|
|
|
|
|
|
'gold' => '金币',
|
2026-02-28 12:49:26 +08:00
|
|
|
|
'charm' => '魅力',
|
|
|
|
|
|
default => $currency,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|