256 lines
9.3 KiB
PHP
256 lines
9.3 KiB
PHP
<?php
|
||
|
||
/**
|
||
* 文件功能:用户个人装扮统一管理服务
|
||
* 负责装扮的购买、激活、过期清理、以及向前端广播载荷注入装饰信息。
|
||
* 所有装扮变更经由此服务,确保 users.active_decorations JSON 字段一致性。
|
||
*
|
||
* 当前支持的装扮槽位(存储在 active_decorations 的 key):
|
||
* - bubble : 消息气泡边框样式
|
||
* - name_color : 昵称颜色效果
|
||
* - avatar_frame: 头像装饰边框
|
||
* - text_color : 消息文字颜色特效
|
||
*
|
||
* @author ChatRoom Laravel
|
||
*
|
||
* @version 1.0.0
|
||
*/
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Enums\CurrencySource;
|
||
use App\Models\ShopItem;
|
||
use App\Models\User;
|
||
use App\Models\UserPurchase;
|
||
use Carbon\Carbon;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* 类功能:统一管理用户个人装扮的购买、激活、过期清理及广播数据注入。
|
||
*/
|
||
class DecorationService
|
||
{
|
||
/**
|
||
* 商店商品 type → 装扮槽位映射。
|
||
* 以后新增装扮类型,在此加一行即可。
|
||
*/
|
||
private const TYPE_TO_SLOT = [
|
||
'msg_bubble' => 'bubble',
|
||
'msg_name_color' => 'name_color',
|
||
'avatar_frame' => 'avatar_frame',
|
||
'msg_text_color' => 'text_color',
|
||
];
|
||
|
||
/**
|
||
* @param UserCurrencyService $currencyService 统一积分变更服务
|
||
*/
|
||
public function __construct(
|
||
private readonly UserCurrencyService $currencyService,
|
||
) {}
|
||
|
||
/**
|
||
* 购买装扮:扣金币、写购买记录、更新 users.active_decorations。
|
||
*
|
||
* 同槽位的旧装扮会被新购买覆盖(旧装扮不退款),不同槽位可并行持有。
|
||
* 若购买的是已激活的同款样式,则自动叠加天数而非覆盖重置。
|
||
*
|
||
* @param User $user 购买用户
|
||
* @param ShopItem $item 装扮商品
|
||
* @param int $quantity 购买份数
|
||
* @return array{ok:bool, message:string, balance_after?:int, slot?:string, style?:string, expires_at?:string}
|
||
*/
|
||
public function purchase(User $user, ShopItem $item, int $quantity = 1): array
|
||
{
|
||
// 根据商品类型映射到对应槽位
|
||
$slot = self::TYPE_TO_SLOT[$item->type] ?? null;
|
||
if (! $slot) {
|
||
return ['ok' => false, 'message' => '未知装扮类型'];
|
||
}
|
||
|
||
$totalPrice = $item->price * $quantity;
|
||
|
||
// 校验金币余额
|
||
if ($user->jjb < $totalPrice) {
|
||
return ['ok' => false, 'message' => "金币不足,购买 {$quantity} 份 [{$item->name}] 需要 {$totalPrice} 金币,当前仅有 {$user->jjb} 金币。"];
|
||
}
|
||
|
||
// 计算过期时间(至少 1 天)
|
||
$days = max(1, (int) ($item->duration_days ?? 1));
|
||
$totalDays = $days * $quantity;
|
||
|
||
// 检查同一槽位是否已激活相同样式 → 叠加天数
|
||
$decorations = $this->getActiveDecorations($user);
|
||
$isSameActive = ! empty($decorations[$slot])
|
||
&& ($decorations[$slot]['style'] ?? '') === $item->slug;
|
||
|
||
if ($isSameActive) {
|
||
// 在现有到期时间上追加天数
|
||
$existingExpires = Carbon::parse($decorations[$slot]['expires_at']);
|
||
$expiresAt = $existingExpires->copy()->addDays($totalDays);
|
||
} else {
|
||
$expiresAt = Carbon::now()->addDays($totalDays);
|
||
}
|
||
|
||
// 按装扮类型使用不同的流水来源标识,便于后台按类型筛选消费记录
|
||
$source = match ($item->type) {
|
||
'msg_bubble' => CurrencySource::MSG_BUBBLE_BUY,
|
||
'msg_name_color' => CurrencySource::MSG_NAME_COLOR_BUY,
|
||
'msg_text_color' => CurrencySource::MSG_TEXT_COLOR_BUY,
|
||
'avatar_frame' => CurrencySource::AVATAR_FRAME_BUY,
|
||
default => CurrencySource::MSG_DECORATION_BUY,
|
||
};
|
||
|
||
// 事务包裹:扣金币、写购买记录、更新激活状态三步原子操作
|
||
DB::transaction(function () use ($user, $item, $slot, $totalPrice, $totalDays, $expiresAt, $source) {
|
||
// ① 通过统一积分服务扣除金币(含流水记录)
|
||
$this->currencyService->change(
|
||
$user, 'gold', -$totalPrice, $source,
|
||
"购买装扮:{$item->name}({$totalDays}天)"
|
||
);
|
||
|
||
// ② 写入购买记录(用于后台统计与用户回溯)
|
||
UserPurchase::create([
|
||
'user_id' => $user->id,
|
||
'shop_item_id' => $item->id,
|
||
'status' => 'active',
|
||
'price_paid' => $totalPrice,
|
||
'expires_at' => $expiresAt,
|
||
]);
|
||
|
||
// ③ 更新用户 active_decorations JSON 字段(同槽位合并,不同槽位追加)
|
||
$decorations = $this->getActiveDecorations($user);
|
||
$decorations[$slot] = [
|
||
'style' => $item->slug,
|
||
'expires_at' => $expiresAt->toIso8601String(),
|
||
];
|
||
$user->active_decorations = $decorations;
|
||
$user->save();
|
||
});
|
||
|
||
// 重新读取最新余额,避免缓存脏数据
|
||
$balanceAfter = (int) $user->fresh()->jjb;
|
||
|
||
// 计算叠加后的总天数显示(如果是续费,显示累计总天数)
|
||
$displayDays = $isSameActive
|
||
? (int) Carbon::now()->diffInDays(Carbon::parse($expiresAt), false) + 1
|
||
: $totalDays;
|
||
|
||
return [
|
||
'ok' => true,
|
||
'message' => "购买成功!{$item->icon} {$item->name} 已激活({$displayDays}天有效)",
|
||
'balance_after' => $balanceAfter,
|
||
'slot' => $slot,
|
||
'style' => $item->slug,
|
||
'expires_at' => $expiresAt->toIso8601String(),
|
||
'quantity' => $quantity,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 获取用户当前所有激活的装扮,同时自动清理已过期项。
|
||
*
|
||
* 采用懒过期策略,在每次读取时检查 expires_at,不依赖定时任务。
|
||
* 如果清理了过期数据会自动写回 users.active_decorations。
|
||
*
|
||
* @param User $user 目标用户
|
||
* @return array<string, array{style:string, expires_at:string}> 返回干净的装扮列表
|
||
*/
|
||
public function getActiveDecorations(User $user): array
|
||
{
|
||
$decorations = $user->active_decorations ?? [];
|
||
|
||
// 非数组(null 或格式异常)直接返回空
|
||
if (! is_array($decorations)) {
|
||
return [];
|
||
}
|
||
|
||
$now = Carbon::now();
|
||
$changed = false;
|
||
|
||
foreach ($decorations as $slot => $data) {
|
||
// 格式校验:必须包含 expires_at 字段
|
||
if (! is_array($data) || empty($data['expires_at'])) {
|
||
unset($decorations[$slot]);
|
||
$changed = true;
|
||
|
||
continue;
|
||
}
|
||
|
||
// 解析过期时间并与当前时间比较
|
||
try {
|
||
$expiresAt = Carbon::parse($data['expires_at']);
|
||
if ($expiresAt->isPast()) {
|
||
unset($decorations[$slot]);
|
||
$changed = true;
|
||
}
|
||
} catch (\Exception $e) {
|
||
// 时间格式异常也视为无效,清理之
|
||
unset($decorations[$slot]);
|
||
$changed = true;
|
||
}
|
||
}
|
||
|
||
// 有过期项被清理时写回数据库
|
||
if ($changed) {
|
||
$user->active_decorations = $decorations;
|
||
$user->save();
|
||
}
|
||
|
||
return $decorations;
|
||
}
|
||
|
||
/**
|
||
* 获取消息广播 payload 需要携带的装扮字段。
|
||
*
|
||
* 消息广播需要气泡样式(msg_bubble)、昵称颜色(msg_name_color)、文字颜色特效(msg_text_color)
|
||
* 以及头像框(avatar_frame),前端据此渲染发送者头像的装饰边框。
|
||
*
|
||
* @param User $user 消息发送者
|
||
* @return array{msg_bubble?:string, msg_name_color?:string, msg_text_color?:string, avatar_frame?:string}
|
||
*/
|
||
public function getDecorationsForMessage(User $user): array
|
||
{
|
||
$decorations = $this->getActiveDecorations($user);
|
||
$result = [];
|
||
|
||
if (! empty($decorations['bubble']['style'])) {
|
||
$result['msg_bubble'] = $decorations['bubble']['style'];
|
||
}
|
||
if (! empty($decorations['name_color']['style'])) {
|
||
$result['msg_name_color'] = $decorations['name_color']['style'];
|
||
}
|
||
if (! empty($decorations['text_color']['style'])) {
|
||
$result['msg_text_color'] = $decorations['text_color']['style'];
|
||
}
|
||
if (! empty($decorations['avatar_frame']['style'])) {
|
||
$result['avatar_frame'] = $decorations['avatar_frame']['style'];
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* 获取在线用户 Presence 载荷需要携带的装扮字段。
|
||
*
|
||
* 在线列表中主要展示头像框(avatar_frame)和昵称颜色(name_color),
|
||
* 气泡样式仅作用于消息,不需要在用户列表中展示。
|
||
*
|
||
* @param User $user 目标用户
|
||
* @return array{avatar_frame?:string, name_color?:string}
|
||
*/
|
||
public function getDecorationsForPresence(User $user): array
|
||
{
|
||
$decorations = $this->getActiveDecorations($user);
|
||
$result = [];
|
||
|
||
if (! empty($decorations['avatar_frame']['style'])) {
|
||
$result['avatar_frame'] = $decorations['avatar_frame']['style'];
|
||
}
|
||
if (! empty($decorations['name_color']['style'])) {
|
||
$result['name_color'] = $decorations['name_color']['style'];
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
}
|