新增聊天室座驾系统
This commit is contained in:
@@ -19,6 +19,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 类功能:广播聊天室全屏特效播放指令,并携带操作者与定向接收者信息。
|
||||
*/
|
||||
class EffectBroadcast implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
@@ -26,13 +29,13 @@ class EffectBroadcast implements ShouldBroadcastNow
|
||||
/**
|
||||
* 支持的特效类型列表(用于校验)
|
||||
*/
|
||||
public const TYPES = ['fireworks', 'rain', 'lightning', 'snow', 'sakura', 'meteors', 'gold-rain', 'hearts', 'confetti', 'fireflies'];
|
||||
public const TYPES = ['fireworks', 'rain', 'lightning', 'snow', 'sakura', 'meteors', 'gold-rain', 'hearts', 'confetti', 'fireflies', 'j35', '99a', 'df5c', 'fujian'];
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
*
|
||||
* @param int $roomId 房间 ID
|
||||
* @param string $type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies
|
||||
* @param string $type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies / j35 / 99a / df5c / fujian
|
||||
* @param string $operator 触发特效的用户名(购买者)
|
||||
* @param string|null $targetUsername 接收者用户名(null = 全员)
|
||||
* @param string|null $giftMessage 附带赠言
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreShopItemRequest;
|
||||
use App\Http\Requests\UpdateShopItemRequest;
|
||||
use App\Models\ShopItem;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
@@ -35,11 +36,9 @@ class ShopItemController extends Controller
|
||||
/**
|
||||
* 新增商品(仅 id=1 超级站长)
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
public function store(StoreShopItemRequest $request): RedirectResponse
|
||||
{
|
||||
abort_unless(Auth::id() === 1, 403);
|
||||
|
||||
$data = $this->validateItem($request);
|
||||
$data = $request->validated();
|
||||
ShopItem::create($data);
|
||||
|
||||
return redirect()->route('admin.shop.index')->with('success', '商品「'.$data['name'].'」创建成功!');
|
||||
@@ -50,9 +49,9 @@ class ShopItemController extends Controller
|
||||
*
|
||||
* @param ShopItem $shopItem 路由模型自动注入
|
||||
*/
|
||||
public function update(Request $request, ShopItem $shopItem): RedirectResponse
|
||||
public function update(UpdateShopItemRequest $request, ShopItem $shopItem): RedirectResponse
|
||||
{
|
||||
$data = $this->validateItem($request, $shopItem);
|
||||
$data = $request->validated();
|
||||
$shopItem->update($data);
|
||||
|
||||
return redirect()->route('admin.shop.index')->with('success', '商品「'.$shopItem->name.'」更新成功!');
|
||||
@@ -85,29 +84,4 @@ class ShopItemController extends Controller
|
||||
|
||||
return redirect()->route('admin.shop.index')->with('success', "「{$name}」已删除。");
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一验证商品表单(新增/编辑共用)
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function validateItem(Request $request, ?ShopItem $item = null): array
|
||||
{
|
||||
return $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'slug' => ['required', 'string', 'max:100',
|
||||
\Illuminate\Validation\Rule::unique('shop_items', 'slug')->ignore($item?->id),
|
||||
],
|
||||
'icon' => 'required|string|max:20',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'price' => 'required|integer|min:0',
|
||||
'type' => 'required|in:instant,duration,one_time,ring,auto_fishing,sign_repair,msg_bubble,msg_name_color,msg_text_color,avatar_frame',
|
||||
'duration_days' => 'nullable|integer|min:0',
|
||||
'duration_minutes' => 'nullable|integer|min:0',
|
||||
'intimacy_bonus' => 'nullable|integer|min:0',
|
||||
'charm_bonus' => 'nullable|integer|min:0',
|
||||
'sort_order' => 'required|integer|min:0',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ use App\Services\ChatStateService;
|
||||
use App\Services\ChatUserPresenceService;
|
||||
use App\Services\MessageFilterService;
|
||||
use App\Services\PositionPermissionService;
|
||||
use App\Services\RideService;
|
||||
use App\Services\RoomBroadcastService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use App\Services\VipService;
|
||||
@@ -65,6 +66,7 @@ class ChatController extends Controller
|
||||
private readonly AppointmentService $appointmentService,
|
||||
private readonly RoomBroadcastService $broadcast,
|
||||
private readonly PositionPermissionService $positionPermissionService,
|
||||
private readonly RideService $rideService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -116,6 +118,7 @@ class ChatController extends Controller
|
||||
|
||||
// 3. 广播和初始化欢迎(仅限初次进入)
|
||||
$newbieEffect = null;
|
||||
$initialRideEffect = null;
|
||||
$initialPresenceTheme = null;
|
||||
$initialWelcomeMessage = null;
|
||||
$initialWelcomeMessages = [];
|
||||
@@ -227,6 +230,33 @@ class ChatController extends Controller
|
||||
if (! empty($vipPresencePayload['presence_effect'])) {
|
||||
broadcast(new \App\Events\EffectBroadcast($id, $vipPresencePayload['presence_effect'], $user->username))->toOthers();
|
||||
}
|
||||
|
||||
$ridePresencePayload = $this->rideService->buildPresencePayload($user);
|
||||
if ($ridePresencePayload) {
|
||||
$rideWelcomeMsg = [
|
||||
'id' => $this->chatState->nextMessageId($id),
|
||||
'room_id' => $id,
|
||||
'from_user' => '座驾播报',
|
||||
'to_user' => '大家',
|
||||
'content' => "<span style=\"color:#0f766e;font-weight:bold;\">{$ridePresencePayload['ride_icon']} {$ridePresencePayload['welcome_text']}</span>",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#0f766e',
|
||||
'action' => 'ride_presence',
|
||||
'welcome_user' => $user->username,
|
||||
'welcome_kind' => 'ride_presence',
|
||||
'ride_key' => $ridePresencePayload['ride_key'],
|
||||
'ride_name' => $ridePresencePayload['ride_name'],
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
// 座驾进场独立追加一条播报,并广播全屏特效给其他在线用户。
|
||||
$this->chatState->pushMessage($id, $rideWelcomeMsg);
|
||||
broadcast(new MessageSent($id, $rideWelcomeMsg));
|
||||
broadcast(new \App\Events\EffectBroadcast($id, $ridePresencePayload['ride_key'], $user->username))->toOthers();
|
||||
|
||||
$initialRideEffect = $ridePresencePayload['ride_key'];
|
||||
$initialWelcomeMessages[] = $rideWelcomeMsg;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 获取历史消息并过滤,只保留和当前用户相关的消息用于初次渲染
|
||||
@@ -314,6 +344,7 @@ class ChatController extends Controller
|
||||
'user' => $user,
|
||||
'weekEffect' => $this->shopService->getActiveWeekEffect($user),
|
||||
'newbieEffect' => $newbieEffect,
|
||||
'initialRideEffect' => $initialRideEffect,
|
||||
'initialPresenceTheme' => $initialPresenceTheme,
|
||||
'initialWelcomeMessage' => $initialWelcomeMessage,
|
||||
'initialWelcomeMessages' => $initialWelcomeMessages,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:聊天室座驾前台接口控制器。
|
||||
*
|
||||
* 提供座驾列表、用户当前座驾、购买记录与购买座驾接口。
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\BuyRideRequest;
|
||||
use App\Models\Room;
|
||||
use App\Models\ShopItem;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\RideService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* 聊天室座驾控制器
|
||||
* 负责前台座驾页面的数据读取与购买操作。
|
||||
*/
|
||||
class RideController extends Controller
|
||||
{
|
||||
/**
|
||||
* 构造座驾控制器依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly RideService $rideService,
|
||||
private readonly ChatStateService $chatState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取座驾页面需要的商品、当前座驾和购买记录。
|
||||
*/
|
||||
public function items(): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
return response()->json([
|
||||
'items' => $this->rideService->activeItems()
|
||||
->map(fn (ShopItem $item) => $this->rideService->formatItem($item))
|
||||
->values(),
|
||||
'current_ride' => $this->rideService->formatCurrentRide($user),
|
||||
'purchases' => $this->rideService->purchaseRecords($user),
|
||||
'user_jjb' => $user->jjb ?? 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 购买座驾并返回最新金币和当前座驾状态。
|
||||
*/
|
||||
public function buy(BuyRideRequest $request): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
$roomId = (int) $request->integer('room_id');
|
||||
$room = Room::query()->findOrFail($roomId);
|
||||
|
||||
if (! $room->canUserEnter($user) || ! $this->chatState->isUserInRoom($roomId, $user->username)) {
|
||||
return response()->json(['status' => 'error', 'message' => '请先进入当前房间后再购买座驾。'], 403);
|
||||
}
|
||||
|
||||
$item = ShopItem::query()->findOrFail((int) $request->integer('item_id'));
|
||||
$result = $this->rideService->buy($user, $item);
|
||||
|
||||
if (! $result['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $result['message']], 400);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => $result['message'],
|
||||
'current_ride' => $result['current_ride'] ?? null,
|
||||
'purchases' => $this->rideService->purchaseRecords($user->fresh()),
|
||||
'jjb' => $user->fresh()->jjb,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -40,19 +40,24 @@ class ShopController extends Controller
|
||||
public function items(): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
$items = ShopItem::active()->map(fn ($item) => [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'slug' => $item->slug,
|
||||
'description' => $item->description,
|
||||
'icon' => $item->icon,
|
||||
'price' => $item->price,
|
||||
'type' => $item->type,
|
||||
'duration_days' => $item->duration_days,
|
||||
'duration_minutes' => $item->duration_minutes,
|
||||
'intimacy_bonus' => $item->intimacy_bonus,
|
||||
'charm_bonus' => $item->charm_bonus,
|
||||
]);
|
||||
$items = ShopItem::query()
|
||||
->where('is_active', true)
|
||||
->where('type', '!=', ShopItem::TYPE_RIDE)
|
||||
->orderBy('sort_order')
|
||||
->get()
|
||||
->map(fn ($item) => [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'slug' => $item->slug,
|
||||
'description' => $item->description,
|
||||
'icon' => $item->icon,
|
||||
'price' => $item->price,
|
||||
'type' => $item->type,
|
||||
'duration_days' => $item->duration_days,
|
||||
'duration_minutes' => $item->duration_minutes,
|
||||
'intimacy_bonus' => $item->intimacy_bonus,
|
||||
'charm_bonus' => $item->charm_bonus,
|
||||
]);
|
||||
|
||||
$signRepairCard = $items->firstWhere('type', ShopItem::TYPE_SIGN_REPAIR);
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:前台座驾购买请求验证。
|
||||
*
|
||||
* 校验用户购买座驾时传入的商品与房间上下文,避免未进房直接购买聊天室座驾。
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 座驾购买请求
|
||||
* 负责校验座驾商品 ID 与当前房间 ID。
|
||||
*/
|
||||
class BuyRideRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 判断当前用户是否允许购买座驾。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取座驾购买请求验证规则。
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'item_id' => ['required', 'integer', 'exists:shop_items,id'],
|
||||
'room_id' => ['required', 'integer', 'exists:rooms,id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取座驾购买请求中文错误提示。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'item_id.required' => '请选择要购买的座驾。',
|
||||
'item_id.exists' => '座驾不存在或已被删除。',
|
||||
'room_id.required' => '请先进入聊天室后再购买座驾。',
|
||||
'room_id.exists' => '当前房间不存在。',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台新增商店商品请求验证。
|
||||
*
|
||||
* 统一校验商店商品字段,包含座驾欢迎语字段。
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\ShopItem;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* 后台新增商店商品请求
|
||||
* 负责新增商品时的权限与字段校验。
|
||||
*/
|
||||
class StoreShopItemRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 判断当前用户是否允许新增商品。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->id === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取新增商品验证规则。
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'slug' => ['required', 'string', 'max:100', Rule::unique('shop_items', 'slug')],
|
||||
'icon' => ['required', 'string', 'max:20'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'price' => ['required', 'integer', 'min:0'],
|
||||
'type' => ['required', Rule::in($this->allowedTypes())],
|
||||
'duration_days' => ['nullable', 'integer', 'min:0'],
|
||||
'duration_minutes' => ['nullable', 'integer', 'min:0'],
|
||||
'intimacy_bonus' => ['nullable', 'integer', 'min:0'],
|
||||
'charm_bonus' => ['nullable', 'integer', 'min:0'],
|
||||
'welcome_message' => ['nullable', 'string', 'max:255'],
|
||||
'sort_order' => ['required', 'integer', 'min:0'],
|
||||
'is_active' => ['boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取允许后台配置的商品类型。
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function allowedTypes(): array
|
||||
{
|
||||
return [
|
||||
'instant',
|
||||
'duration',
|
||||
'one_time',
|
||||
'ring',
|
||||
'auto_fishing',
|
||||
ShopItem::TYPE_SIGN_REPAIR,
|
||||
'msg_bubble',
|
||||
'msg_name_color',
|
||||
'msg_text_color',
|
||||
'avatar_frame',
|
||||
ShopItem::TYPE_RIDE,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台更新商店商品请求验证。
|
||||
*
|
||||
* 统一校验商店商品编辑字段,包含座驾欢迎语字段。
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\ShopItem;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* 后台更新商店商品请求
|
||||
* 负责编辑商品时的权限与字段校验。
|
||||
*/
|
||||
class UpdateShopItemRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 判断当前用户是否允许编辑商品。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取更新商品验证规则。
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$shopItem = $this->route('shopItem');
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'slug' => ['required', 'string', 'max:100', Rule::unique('shop_items', 'slug')->ignore($shopItem?->id)],
|
||||
'icon' => ['required', 'string', 'max:20'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'price' => ['required', 'integer', 'min:0'],
|
||||
'type' => ['required', Rule::in($this->allowedTypes())],
|
||||
'duration_days' => ['nullable', 'integer', 'min:0'],
|
||||
'duration_minutes' => ['nullable', 'integer', 'min:0'],
|
||||
'intimacy_bonus' => ['nullable', 'integer', 'min:0'],
|
||||
'charm_bonus' => ['nullable', 'integer', 'min:0'],
|
||||
'welcome_message' => ['nullable', 'string', 'max:255'],
|
||||
'sort_order' => ['required', 'integer', 'min:0'],
|
||||
'is_active' => ['boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取允许后台配置的商品类型。
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function allowedTypes(): array
|
||||
{
|
||||
return [
|
||||
'instant',
|
||||
'duration',
|
||||
'one_time',
|
||||
'ring',
|
||||
'auto_fishing',
|
||||
ShopItem::TYPE_SIGN_REPAIR,
|
||||
'msg_bubble',
|
||||
'msg_name_color',
|
||||
'msg_text_color',
|
||||
'avatar_frame',
|
||||
ShopItem::TYPE_RIDE,
|
||||
];
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -14,6 +14,9 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
class ShopItem extends Model
|
||||
{
|
||||
public const TYPE_SIGN_REPAIR = 'sign_repair';
|
||||
|
||||
public const TYPE_RIDE = 'ride';
|
||||
|
||||
public const DECORATION_TYPES = ['msg_bubble', 'msg_name_color', 'msg_text_color', 'avatar_frame'];
|
||||
|
||||
protected $table = 'shop_items';
|
||||
@@ -21,7 +24,7 @@ class ShopItem extends Model
|
||||
protected $fillable = [
|
||||
'name', 'slug', 'description', 'icon', 'price',
|
||||
'type', 'duration_days', 'duration_minutes', 'sort_order', 'is_active',
|
||||
'intimacy_bonus', 'charm_bonus',
|
||||
'intimacy_bonus', 'charm_bonus', 'welcome_message',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -60,6 +63,14 @@ class ShopItem extends Model
|
||||
return in_array($this->type, self::DECORATION_TYPES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为聊天室座驾商品。
|
||||
*/
|
||||
public function isRide(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_RIDE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为特效类商品(instant 或 duration,slug 以 once_ 或 week_ 开头)
|
||||
*/
|
||||
@@ -99,6 +110,18 @@ class ShopItem extends Model
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取座驾全屏特效 key(去掉 ride_ 前缀)。
|
||||
*/
|
||||
public function rideKey(): ?string
|
||||
{
|
||||
if (str_starts_with($this->slug, 'ride_')) {
|
||||
return substr($this->slug, 5);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有上架商品(按排序)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:聊天室座驾业务服务。
|
||||
*
|
||||
* 统一管理座驾商品列表、购买续期、当前激活座驾、购买记录和入场欢迎语载荷。
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ShopItem;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPurchase;
|
||||
use App\Support\ChatContentSanitizer;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 聊天室座驾服务
|
||||
* 负责复用商店商品和用户购买记录完成座驾购买、续期、替换与进房展示。
|
||||
*/
|
||||
class RideService
|
||||
{
|
||||
/**
|
||||
* 获取全部上架座驾商品。
|
||||
*
|
||||
* @return Collection<int, ShopItem>
|
||||
*/
|
||||
public function activeItems(): Collection
|
||||
{
|
||||
return ShopItem::query()
|
||||
->where('type', ShopItem::TYPE_RIDE)
|
||||
->where('is_active', true)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化座驾商品,供前端页面直接渲染。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function formatItem(ShopItem $item): array
|
||||
{
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'slug' => $item->slug,
|
||||
'ride_key' => $item->rideKey(),
|
||||
'description' => $item->description,
|
||||
'icon' => $item->icon,
|
||||
'price' => $item->price,
|
||||
'duration_days' => (int) ($item->duration_days ?? 0),
|
||||
'welcome_message' => $item->welcome_message,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户当前有效座驾,若已过期则自动标记为 expired。
|
||||
*/
|
||||
public function currentRide(User $user): ?UserPurchase
|
||||
{
|
||||
$purchase = UserPurchase::query()
|
||||
->with('shopItem')
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->whereHas('shopItem', fn ($query) => $query->where('type', ShopItem::TYPE_RIDE))
|
||||
->orderByDesc('expires_at')
|
||||
->first();
|
||||
|
||||
if (! $purchase) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($purchase->expires_at && $purchase->expires_at->isPast()) {
|
||||
// 过期座驾必须及时落库,避免后续进房继续播放旧特效。
|
||||
$purchase->update(['status' => 'expired']);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $purchase;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化用户当前座驾。
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function formatCurrentRide(User $user): ?array
|
||||
{
|
||||
$purchase = $this->currentRide($user);
|
||||
if (! $purchase || ! $purchase->shopItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->formatPurchase($purchase);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户最近座驾购买记录。
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function purchaseRecords(User $user, int $limit = 20): array
|
||||
{
|
||||
return UserPurchase::query()
|
||||
->with('shopItem')
|
||||
->where('user_id', $user->id)
|
||||
->whereHas('shopItem', fn ($query) => $query->where('type', ShopItem::TYPE_RIDE))
|
||||
->latest()
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (UserPurchase $purchase) => $this->formatPurchase($purchase))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 购买座驾:同款续期,不同款替换旧座驾且不退款。
|
||||
*
|
||||
* @return array{ok:bool, message:string, current_ride?:array<string, mixed>}
|
||||
*/
|
||||
public function buy(User $user, ShopItem $item): array
|
||||
{
|
||||
if (! $item->isRide() || ! $item->is_active) {
|
||||
return ['ok' => false, 'message' => '该座驾暂未上架。'];
|
||||
}
|
||||
|
||||
$days = (int) ($item->duration_days ?? 0);
|
||||
if ($days <= 0) {
|
||||
return ['ok' => false, 'message' => '该座驾使用天数配置异常,请联系管理员。'];
|
||||
}
|
||||
|
||||
if ($user->jjb < $item->price) {
|
||||
return ['ok' => false, 'message' => "金币不足,购买【{$item->name}】需要 {$item->price} 金币,当前仅有 {$user->jjb} 金币。"];
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $item, $days): void {
|
||||
$now = Carbon::now();
|
||||
|
||||
// 先清理已过期的 active 座驾,避免旧状态影响替换判断。
|
||||
UserPurchase::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', $now)
|
||||
->whereHas('shopItem', fn ($query) => $query->where('type', ShopItem::TYPE_RIDE))
|
||||
->update(['status' => 'expired']);
|
||||
|
||||
$activeRide = UserPurchase::query()
|
||||
->with('shopItem')
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->whereHas('shopItem', fn ($query) => $query->where('type', ShopItem::TYPE_RIDE))
|
||||
->orderByDesc('expires_at')
|
||||
->first();
|
||||
|
||||
// 座驾购买必须先扣金币,后续续期或替换都在同一个事务内完成。
|
||||
$user->decrement('jjb', $item->price);
|
||||
|
||||
if ($activeRide && (int) $activeRide->shop_item_id === (int) $item->id) {
|
||||
$baseTime = $activeRide->expires_at && $activeRide->expires_at->greaterThan($now)
|
||||
? $activeRide->expires_at
|
||||
: $now;
|
||||
|
||||
// 同款续购先取消旧 active,再创建新 active,既保留购买记录,又保持当前座驾唯一。
|
||||
$activeRide->update(['status' => 'cancelled']);
|
||||
UserPurchase::create([
|
||||
'user_id' => $user->id,
|
||||
'shop_item_id' => $item->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => $item->price,
|
||||
'expires_at' => $baseTime->copy()->addDays($days),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($activeRide) {
|
||||
// 不同座驾替换旧座驾,旧记录保留为 cancelled 供后台追溯。
|
||||
$activeRide->update(['status' => 'cancelled']);
|
||||
}
|
||||
|
||||
UserPurchase::create([
|
||||
'user_id' => $user->id,
|
||||
'shop_item_id' => $item->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => $item->price,
|
||||
'expires_at' => $now->copy()->addDays($days),
|
||||
]);
|
||||
});
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => "购买成功!{$item->icon} {$item->name} 已激活({$days}天有效)。",
|
||||
'current_ride' => $this->formatCurrentRide($user->fresh()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建进房座驾欢迎语与特效载荷。
|
||||
*
|
||||
* @return array<string, string>|null
|
||||
*/
|
||||
public function buildPresencePayload(User $user): ?array
|
||||
{
|
||||
$purchase = $this->currentRide($user);
|
||||
$item = $purchase?->shopItem;
|
||||
$rideKey = $item?->rideKey();
|
||||
|
||||
if (! $purchase || ! $item || ! $rideKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$template = trim((string) ($item->welcome_message ?: '【{name}】驾驶【{ride}】震撼入场,全场请注意!'));
|
||||
$rendered = strtr($template, [
|
||||
'{name}' => $user->username,
|
||||
'{ride}' => $item->name,
|
||||
]);
|
||||
|
||||
return [
|
||||
'ride_key' => $rideKey,
|
||||
'ride_name' => $item->name,
|
||||
'ride_icon' => (string) ($item->icon ?? '🚘'),
|
||||
'welcome_text' => ChatContentSanitizer::htmlText($rendered),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化单条座驾购买记录。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatPurchase(UserPurchase $purchase): array
|
||||
{
|
||||
$item = $purchase->shopItem;
|
||||
|
||||
return [
|
||||
'id' => $purchase->id,
|
||||
'status' => $purchase->status,
|
||||
'price_paid' => (int) $purchase->price_paid,
|
||||
'expires_at' => $purchase->expires_at?->toDateTimeString(),
|
||||
'used_at' => $purchase->used_at?->toDateTimeString(),
|
||||
'created_at' => $purchase->created_at?->toDateTimeString(),
|
||||
'item' => $item ? $this->formatItem($item) : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user