优化商店个性装扮体验
This commit is contained in:
@@ -143,6 +143,12 @@ enum CurrencySource: string
|
||||
/** 查看别人隐藏信息扣费 */
|
||||
case USER_INFO_REVEAL = 'user_info_reveal';
|
||||
|
||||
/** 购买消息装扮消耗(气泡/昵称颜色,扣除金币) */
|
||||
case MSG_DECORATION_BUY = 'msg_decoration_buy';
|
||||
|
||||
/** 购买头像框消耗(扣除金币) */
|
||||
case AVATAR_FRAME_BUY = 'avatar_frame_buy';
|
||||
|
||||
/**
|
||||
* 返回该来源的中文名称,用于后台统计展示。
|
||||
*/
|
||||
@@ -190,6 +196,8 @@ enum CurrencySource: string
|
||||
self::GOMOKU_REFUND => '五子棋入场费返还',
|
||||
self::VIDEO_REWARD => '看视频奖励',
|
||||
self::USER_INFO_REVEAL => '信息查看付费',
|
||||
self::MSG_DECORATION_BUY => '消息装扮购买',
|
||||
self::AVATAR_FRAME_BUY => '头像框购买',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +413,10 @@ class ChatController extends Controller
|
||||
$messageData = array_merge($messageData, $imagePayload);
|
||||
}
|
||||
|
||||
// 6.5 将用户当前激活的消息装扮注入广播 payload(气泡样式 + 昵称颜色),前端据此渲染消息外观
|
||||
$decorations = app(\App\Services\DecorationService::class)->getDecorationsForMessage($user);
|
||||
$messageData = array_merge($messageData, $decorations);
|
||||
|
||||
// 3. 压入 Redis 缓存列表 (防炸内存,只保留最近 N 条)
|
||||
$this->chatState->pushMessage($id, $messageData);
|
||||
|
||||
|
||||
@@ -9,10 +9,12 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\EffectBroadcast;
|
||||
use App\Events\MessageSent;
|
||||
use App\Events\UserStatusUpdated;
|
||||
use App\Models\Room;
|
||||
use App\Models\ShopItem;
|
||||
use App\Models\UserPurchase;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\DecorationService;
|
||||
use App\Services\ShopService;
|
||||
use App\Support\ChatContentSanitizer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -73,6 +75,8 @@ class ShopController extends Controller
|
||||
'auto_fishing_minutes_left' => $this->shopService->getActiveAutoFishingMinutesLeft($user),
|
||||
'sign_repair_card_count' => $this->shopService->getSignRepairCardCount($user),
|
||||
'sign_repair_card_item' => $signRepairCard,
|
||||
// 返回用户当前激活的装扮状态,前端用于渲染装扮卡片上的"已激活/剩余X天"标签
|
||||
'active_decorations' => app(DecorationService::class)->getActiveDecorations($user),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -121,6 +125,22 @@ class ShopController extends Controller
|
||||
'total_price' => $result['total_price'] ?? $item->price,
|
||||
];
|
||||
|
||||
// ── 装扮购买:向前端透传槽位与样式信息,用于即时更新装扮状态 ──
|
||||
if (! empty($result['slot'])) {
|
||||
$response['slot'] = $result['slot'];
|
||||
$response['style'] = $result['style'];
|
||||
$response['expires_at'] = $result['expires_at'];
|
||||
|
||||
// 购买后立即同步所有房间的在线名单,让昵称颜色/头像框立即生效
|
||||
$freshUser = $user->fresh();
|
||||
$presenceData = app(\App\Services\ChatUserPresenceService::class)->build($freshUser);
|
||||
foreach ($this->chatState->getUserRooms($user->username) as $rid) {
|
||||
$this->chatState->userJoin((int) $rid, $user->username, $presenceData);
|
||||
// 广播 UserStatusUpdated 到 Presence Channel,触发前端 chat:user-status-updated 刷新用户列表
|
||||
broadcast(new UserStatusUpdated((int) $rid, $user->username, $presenceData));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 单次特效卡:广播给指定用户或全员 ────────────────────────
|
||||
if (isset($result['play_effect'])) {
|
||||
$recipient = trim($request->input('recipient', '')); // 空字符串 = 全员
|
||||
@@ -205,6 +225,7 @@ class ShopController extends Controller
|
||||
'ring' => "💍 【{$safeBuyer}】在商店购买了一枚「{$safeItemName}」,不知道打算送给谁呢?",
|
||||
'auto_fishing' => "🎣 【{$safeBuyer}】购买了「{$safeItemName}」,开启了 {$fishDuration} 的自动钓鱼模式!",
|
||||
ShopItem::TYPE_SIGN_REPAIR => "🗓️ 【{$safeBuyer}】购买了 {$quantity} 张「{$safeItemName}」,准备把漏掉的签到补回来!",
|
||||
'msg_bubble', 'msg_name_color', 'avatar_frame' => "✨ 【{$safeBuyer}】购买了个人装扮「{$safeItemName}」,颜值 +1!",
|
||||
default => "🛒 【{$safeBuyer}】购买了「{$safeItemName}」。",
|
||||
};
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ class User extends Authenticatable
|
||||
'q3_time' => 'datetime',
|
||||
'has_received_new_gift' => 'boolean',
|
||||
'chat_preferences' => 'array',
|
||||
'active_decorations' => 'array',
|
||||
'daily_status_expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -64,6 +64,15 @@ class ChatUserPresenceService
|
||||
$payload['sign_identity_streak_days'] = (int) data_get($signIdentity->metadata, 'streak_days', 0);
|
||||
}
|
||||
|
||||
// 将用户当前激活的头像框和昵称颜色注入在线用户载荷,前端据此渲染用户列表中的装饰效果
|
||||
$decorations = app(\App\Services\DecorationService::class)->getDecorationsForPresence($user);
|
||||
if (! empty($decorations['avatar_frame'])) {
|
||||
$payload['avatar_frame'] = $decorations['avatar_frame'];
|
||||
}
|
||||
if (! empty($decorations['name_color'])) {
|
||||
$payload['name_color'] = $decorations['name_color'];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:用户个人装扮统一管理服务
|
||||
* 负责装扮的购买、激活、过期清理、以及向前端广播载荷注入装饰信息。
|
||||
* 所有装扮变更经由此服务,确保 users.active_decorations JSON 字段一致性。
|
||||
*
|
||||
* 当前支持的装扮槽位(存储在 active_decorations 的 key):
|
||||
* - bubble : 消息气泡边框样式
|
||||
* - name_color : 昵称颜色效果
|
||||
* - avatar_frame: 头像装饰边框
|
||||
*
|
||||
* @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',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param UserCurrencyService $currencyService 统一积分变更服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 购买装扮:扣金币、写购买记录、更新 users.active_decorations。
|
||||
*
|
||||
* 同槽位的旧装扮会被新购买覆盖(旧装扮不退款),不同槽位可并行持有。
|
||||
*
|
||||
* @param User $user 购买用户
|
||||
* @param ShopItem $item 装扮商品
|
||||
* @return array{ok:bool, message:string, balance_after?:int, slot?:string, style?:string, expires_at?:string}
|
||||
*/
|
||||
public function purchase(User $user, ShopItem $item): array
|
||||
{
|
||||
// 根据商品类型映射到对应槽位
|
||||
$slot = self::TYPE_TO_SLOT[$item->type] ?? null;
|
||||
if (! $slot) {
|
||||
return ['ok' => false, 'message' => '未知装扮类型'];
|
||||
}
|
||||
|
||||
// 校验金币余额
|
||||
if ($user->jjb < $item->price) {
|
||||
return ['ok' => false, 'message' => "金币不足,购买 [{$item->name}] 需要 {$item->price} 金币,当前仅有 {$user->jjb} 金币。"];
|
||||
}
|
||||
|
||||
// 计算过期时间(至少 1 天)
|
||||
$days = max(1, (int) ($item->duration_days ?? 1));
|
||||
$expiresAt = Carbon::now()->addDays($days);
|
||||
|
||||
// 头像框与其他装扮使用不同的流水来源标识
|
||||
$source = $item->type === 'avatar_frame'
|
||||
? CurrencySource::AVATAR_FRAME_BUY
|
||||
: CurrencySource::MSG_DECORATION_BUY;
|
||||
|
||||
// 事务包裹:扣金币、写购买记录、更新激活状态三步原子操作
|
||||
DB::transaction(function () use ($user, $item, $slot, $days, $expiresAt, $source) {
|
||||
// ① 通过统一积分服务扣除金币(含流水记录)
|
||||
$this->currencyService->change(
|
||||
$user, 'gold', -$item->price, $source,
|
||||
"购买装扮:{$item->name}({$days}天)"
|
||||
);
|
||||
|
||||
// ② 写入购买记录(用于后台统计与用户回溯)
|
||||
UserPurchase::create([
|
||||
'user_id' => $user->id,
|
||||
'shop_item_id' => $item->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => $item->price,
|
||||
'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;
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => "购买成功!{$item->icon} {$item->name} 已激活({$days}天有效)",
|
||||
'balance_after' => $balanceAfter,
|
||||
'slot' => $slot,
|
||||
'style' => $item->slug,
|
||||
'expires_at' => $expiresAt->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户当前所有激活的装扮,同时自动清理已过期项。
|
||||
*
|
||||
* 采用懒过期策略,在每次读取时检查 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),
|
||||
* 头像框不需要在消息中展示。
|
||||
*
|
||||
* @param User $user 消息发送者
|
||||
* @return array{msg_bubble?:string, msg_name_color?: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'];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,13 @@ use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ShopService
|
||||
{
|
||||
/**
|
||||
* @param DecorationService $decorationService 装扮服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly DecorationService $decorationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 购买商品入口:扣金币、按类型分发处理
|
||||
*
|
||||
@@ -41,6 +48,10 @@ class ShopService
|
||||
'ring' => $this->buyRing($user, $item),
|
||||
'auto_fishing' => $this->buyAutoFishingCard($user, $item),
|
||||
ShopItem::TYPE_SIGN_REPAIR => $this->buySignRepairCard($user, $item, $quantity),
|
||||
// ── 个人装扮购买(委托给 DecorationService)───────────────
|
||||
'msg_bubble' => $this->decorationService->purchase($user, $item),
|
||||
'msg_name_color' => $this->decorationService->purchase($user, $item),
|
||||
'avatar_frame' => $this->decorationService->purchase($user, $item),
|
||||
default => ['ok' => false, 'message' => '未知商品类型'],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user