'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 返回干净的装扮列表 */ 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; } }