From 181cc6a0b006a7f9a6314b020329d74fb0c95d3a Mon Sep 17 00:00:00 2001 From: pllx Date: Thu, 30 Apr 2026 09:55:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B9=E4=B8=BA=E7=8B=AC=E7=AB=8B=E5=BA=A7?= =?UTF-8?q?=E9=A9=BE=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Admin/RideController.php | 82 ++++++ app/Http/Controllers/RideController.php | 6 +- app/Http/Controllers/ShopController.php | 1 - app/Http/Requests/BuyRideRequest.php | 6 +- app/Http/Requests/StoreRideRequest.php | 63 ++++ app/Http/Requests/StoreShopItemRequest.php | 4 +- app/Http/Requests/UpdateRideRequest.php | 65 +++++ app/Http/Requests/UpdateShopItemRequest.php | 4 +- app/Models/Ride.php | 59 ++++ app/Models/ShopItem.php | 24 +- app/Models/UserRidePurchase.php | 60 ++++ app/Services/RideService.php | 61 ++-- ...51_add_ride_fields_to_shop_items_table.php | 88 +----- .../2026_04_30_101500_create_rides_tables.php | 122 ++++++++ ...ove_legacy_ride_fields_from_shop_items.php | 52 ++++ .../skills/chatroom-ride-development/SKILL.md | 18 +- resources/views/admin/layouts/app.blade.php | 4 + resources/views/admin/rides/index.blade.php | 270 ++++++++++++++++++ resources/views/admin/shop/index.blade.php | 14 - routes/web.php | 5 + tests/Feature/ChatControllerTest.php | 20 +- tests/Feature/RideControllerTest.php | 74 +++-- 22 files changed, 886 insertions(+), 216 deletions(-) create mode 100644 app/Http/Controllers/Admin/RideController.php create mode 100644 app/Http/Requests/StoreRideRequest.php create mode 100644 app/Http/Requests/UpdateRideRequest.php create mode 100644 app/Models/Ride.php create mode 100644 app/Models/UserRidePurchase.php create mode 100644 database/migrations/2026_04_30_101500_create_rides_tables.php create mode 100644 database/migrations/2026_04_30_102000_remove_legacy_ride_fields_from_shop_items.php create mode 100644 resources/views/admin/rides/index.blade.php diff --git a/app/Http/Controllers/Admin/RideController.php b/app/Http/Controllers/Admin/RideController.php new file mode 100644 index 0000000..042a3c6 --- /dev/null +++ b/app/Http/Controllers/Admin/RideController.php @@ -0,0 +1,82 @@ +orderBy('sort_order') + ->orderBy('id') + ->get(); + + return view('admin.rides.index', compact('rides')); + } + + /** + * 新增座驾(仅 id=1 超级站长)。 + */ + public function store(StoreRideRequest $request): RedirectResponse + { + $data = $request->validated(); + Ride::create($data); + + return redirect()->route('admin.rides.index')->with('success', '座驾「'.$data['name'].'」创建成功!'); + } + + /** + * 更新座驾信息。 + */ + public function update(UpdateRideRequest $request, Ride $ride): RedirectResponse + { + $ride->update($request->validated()); + + return redirect()->route('admin.rides.index')->with('success', '座驾「'.$ride->name.'」更新成功!'); + } + + /** + * 切换座驾上下架状态。 + */ + public function toggle(Ride $ride): RedirectResponse + { + $ride->update(['is_active' => ! $ride->is_active]); + $status = $ride->is_active ? '上架' : '下架'; + + return redirect()->route('admin.rides.index')->with('success', "「{$ride->name}」已{$status}。"); + } + + /** + * 删除座驾(仅 id=1 超级站长)。 + */ + public function destroy(Ride $ride): RedirectResponse + { + abort_unless(Auth::id() === 1, 403); + + $name = $ride->name; + $ride->delete(); + + return redirect()->route('admin.rides.index')->with('success', "「{$name}」已删除。"); + } +} diff --git a/app/Http/Controllers/RideController.php b/app/Http/Controllers/RideController.php index 8ea7553..340f50c 100644 --- a/app/Http/Controllers/RideController.php +++ b/app/Http/Controllers/RideController.php @@ -9,8 +9,8 @@ namespace App\Http\Controllers; use App\Http\Requests\BuyRideRequest; +use App\Models\Ride; use App\Models\Room; -use App\Models\ShopItem; use App\Services\ChatStateService; use App\Services\RideService; use Illuminate\Http\JsonResponse; @@ -39,7 +39,7 @@ class RideController extends Controller return response()->json([ 'items' => $this->rideService->activeItems() - ->map(fn (ShopItem $item) => $this->rideService->formatItem($item)) + ->map(fn (Ride $item) => $this->rideService->formatItem($item)) ->values(), 'current_ride' => $this->rideService->formatCurrentRide($user), 'purchases' => $this->rideService->purchaseRecords($user), @@ -60,7 +60,7 @@ class RideController extends Controller return response()->json(['status' => 'error', 'message' => '请先进入当前房间后再购买座驾。'], 403); } - $item = ShopItem::query()->findOrFail((int) $request->integer('item_id')); + $item = Ride::query()->findOrFail((int) $request->integer('item_id')); $result = $this->rideService->buy($user, $item); if (! $result['ok']) { diff --git a/app/Http/Controllers/ShopController.php b/app/Http/Controllers/ShopController.php index 88056d8..8f3c300 100644 --- a/app/Http/Controllers/ShopController.php +++ b/app/Http/Controllers/ShopController.php @@ -42,7 +42,6 @@ class ShopController extends Controller $user = Auth::user(); $items = ShopItem::query() ->where('is_active', true) - ->where('type', '!=', ShopItem::TYPE_RIDE) ->orderBy('sort_order') ->get() ->map(fn ($item) => [ diff --git a/app/Http/Requests/BuyRideRequest.php b/app/Http/Requests/BuyRideRequest.php index 20055c9..5f4ed4e 100644 --- a/app/Http/Requests/BuyRideRequest.php +++ b/app/Http/Requests/BuyRideRequest.php @@ -3,7 +3,7 @@ /** * 文件功能:前台座驾购买请求验证。 * - * 校验用户购买座驾时传入的商品与房间上下文,避免未进房直接购买聊天室座驾。 + * 校验用户购买座驾时传入的座驾与房间上下文,避免未进房直接购买聊天室座驾。 */ namespace App\Http\Requests; @@ -13,7 +13,7 @@ use Illuminate\Foundation\Http\FormRequest; /** * 座驾购买请求 - * 负责校验座驾商品 ID 与当前房间 ID。 + * 负责校验座驾 ID 与当前房间 ID。 */ class BuyRideRequest extends FormRequest { @@ -33,7 +33,7 @@ class BuyRideRequest extends FormRequest public function rules(): array { return [ - 'item_id' => ['required', 'integer', 'exists:shop_items,id'], + 'item_id' => ['required', 'integer', 'exists:rides,id'], 'room_id' => ['required', 'integer', 'exists:rooms,id'], ]; } diff --git a/app/Http/Requests/StoreRideRequest.php b/app/Http/Requests/StoreRideRequest.php new file mode 100644 index 0000000..daf030f --- /dev/null +++ b/app/Http/Requests/StoreRideRequest.php @@ -0,0 +1,63 @@ +user()?->id === 1; + } + + /** + * 获取新增座驾验证规则。 + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:100'], + 'slug' => ['required', 'string', 'max:100', 'regex:/^ride_[a-z0-9_]+$/', Rule::unique('rides', 'slug')], + 'effect_key' => ['required', 'string', 'max:50', 'regex:/^[a-z0-9_]+$/', Rule::unique('rides', 'effect_key')], + 'icon' => ['required', 'string', 'max:20'], + 'description' => ['nullable', 'string', 'max:500'], + 'price' => ['required', 'integer', 'min:0'], + 'duration_days' => ['required', 'integer', 'min:1'], + 'welcome_message' => ['nullable', 'string', 'max:255'], + 'sort_order' => ['required', 'integer', 'min:0'], + 'is_active' => ['boolean'], + ]; + } + + /** + * 获取新增座驾中文错误提示。 + * + * @return array + */ + public function messages(): array + { + return [ + 'slug.regex' => '座驾标识必须使用 ride_ 开头,例如 ride_j35。', + 'effect_key.regex' => '特效 key 只能包含小写字母、数字和下划线。', + 'duration_days.min' => '使用天数至少为 1 天。', + ]; + } +} diff --git a/app/Http/Requests/StoreShopItemRequest.php b/app/Http/Requests/StoreShopItemRequest.php index 9395e25..515f9b3 100644 --- a/app/Http/Requests/StoreShopItemRequest.php +++ b/app/Http/Requests/StoreShopItemRequest.php @@ -3,7 +3,7 @@ /** * 文件功能:后台新增商店商品请求验证。 * - * 统一校验商店商品字段,包含座驾欢迎语字段。 + * 统一校验商店商品字段。 */ namespace App\Http\Requests; @@ -45,7 +45,6 @@ class StoreShopItemRequest extends FormRequest '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'], ]; @@ -69,7 +68,6 @@ class StoreShopItemRequest extends FormRequest 'msg_name_color', 'msg_text_color', 'avatar_frame', - ShopItem::TYPE_RIDE, ]; } } diff --git a/app/Http/Requests/UpdateRideRequest.php b/app/Http/Requests/UpdateRideRequest.php new file mode 100644 index 0000000..44feb4a --- /dev/null +++ b/app/Http/Requests/UpdateRideRequest.php @@ -0,0 +1,65 @@ +user() !== null; + } + + /** + * 获取更新座驾验证规则。 + * + * @return array|string> + */ + public function rules(): array + { + $ride = $this->route('ride'); + + return [ + 'name' => ['required', 'string', 'max:100'], + 'slug' => ['required', 'string', 'max:100', 'regex:/^ride_[a-z0-9_]+$/', Rule::unique('rides', 'slug')->ignore($ride?->id)], + 'effect_key' => ['required', 'string', 'max:50', 'regex:/^[a-z0-9_]+$/', Rule::unique('rides', 'effect_key')->ignore($ride?->id)], + 'icon' => ['required', 'string', 'max:20'], + 'description' => ['nullable', 'string', 'max:500'], + 'price' => ['required', 'integer', 'min:0'], + 'duration_days' => ['required', 'integer', 'min:1'], + 'welcome_message' => ['nullable', 'string', 'max:255'], + 'sort_order' => ['required', 'integer', 'min:0'], + 'is_active' => ['boolean'], + ]; + } + + /** + * 获取更新座驾中文错误提示。 + * + * @return array + */ + public function messages(): array + { + return [ + 'slug.regex' => '座驾标识必须使用 ride_ 开头,例如 ride_j35。', + 'effect_key.regex' => '特效 key 只能包含小写字母、数字和下划线。', + 'duration_days.min' => '使用天数至少为 1 天。', + ]; + } +} diff --git a/app/Http/Requests/UpdateShopItemRequest.php b/app/Http/Requests/UpdateShopItemRequest.php index 67c45a3..33c64b1 100644 --- a/app/Http/Requests/UpdateShopItemRequest.php +++ b/app/Http/Requests/UpdateShopItemRequest.php @@ -3,7 +3,7 @@ /** * 文件功能:后台更新商店商品请求验证。 * - * 统一校验商店商品编辑字段,包含座驾欢迎语字段。 + * 统一校验商店商品编辑字段。 */ namespace App\Http\Requests; @@ -47,7 +47,6 @@ class UpdateShopItemRequest extends FormRequest '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'], ]; @@ -71,7 +70,6 @@ class UpdateShopItemRequest extends FormRequest 'msg_name_color', 'msg_text_color', 'avatar_frame', - ShopItem::TYPE_RIDE, ]; } } diff --git a/app/Models/Ride.php b/app/Models/Ride.php new file mode 100644 index 0000000..5b48941 --- /dev/null +++ b/app/Models/Ride.php @@ -0,0 +1,59 @@ + 'boolean', + ]; + + /** + * 获取座驾对应的所有购买记录。 + */ + public function purchases(): HasMany + { + return $this->hasMany(UserRidePurchase::class); + } + + /** + * 获取座驾全屏特效 key。 + */ + public function rideKey(): string + { + return $this->effect_key; + } + + /** + * 获取所有上架座驾。 + * + * @return Collection + */ + public static function active(): Collection + { + return static::query() + ->where('is_active', true) + ->orderBy('sort_order') + ->orderBy('id') + ->get(); + } +} diff --git a/app/Models/ShopItem.php b/app/Models/ShopItem.php index d9a0a67..36cf092 100644 --- a/app/Models/ShopItem.php +++ b/app/Models/ShopItem.php @@ -15,8 +15,6 @@ 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'; @@ -24,7 +22,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', 'welcome_message', + 'intimacy_bonus', 'charm_bonus', ]; protected $casts = [ @@ -63,14 +61,6 @@ 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_ 开头) */ @@ -110,18 +100,6 @@ 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; - } - /** * 获取所有上架商品(按排序) */ diff --git a/app/Models/UserRidePurchase.php b/app/Models/UserRidePurchase.php new file mode 100644 index 0000000..930f6cd --- /dev/null +++ b/app/Models/UserRidePurchase.php @@ -0,0 +1,60 @@ + 'datetime', + 'used_at' => 'datetime', + ]; + + /** + * 获取购买记录所属用户。 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * 获取购买记录对应座驾。 + */ + public function ride(): BelongsTo + { + return $this->belongsTo(Ride::class); + } + + /** + * 判断座驾购买记录是否仍然有效。 + */ + public function isAlive(): bool + { + if ($this->status !== 'active') { + return false; + } + + if ($this->expires_at && $this->expires_at->isPast()) { + return false; + } + + return true; + } +} diff --git a/app/Services/RideService.php b/app/Services/RideService.php index 72ec79b..16773db 100644 --- a/app/Services/RideService.php +++ b/app/Services/RideService.php @@ -8,9 +8,9 @@ namespace App\Services; -use App\Models\ShopItem; +use App\Models\Ride; use App\Models\User; -use App\Models\UserPurchase; +use App\Models\UserRidePurchase; use App\Support\ChatContentSanitizer; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Carbon; @@ -18,23 +18,18 @@ use Illuminate\Support\Facades\DB; /** * 聊天室座驾服务 - * 负责复用商店商品和用户购买记录完成座驾购买、续期、替换与进房展示。 + * 负责通过 rides 与 user_ride_purchases 完成座驾购买、续期、替换与进房展示。 */ class RideService { /** * 获取全部上架座驾商品。 * - * @return Collection + * @return Collection */ public function activeItems(): Collection { - return ShopItem::query() - ->where('type', ShopItem::TYPE_RIDE) - ->where('is_active', true) - ->orderBy('sort_order') - ->orderBy('id') - ->get(); + return Ride::active(); } /** @@ -42,7 +37,7 @@ class RideService * * @return array */ - public function formatItem(ShopItem $item): array + public function formatItem(Ride $item): array { return [ 'id' => $item->id, @@ -60,13 +55,12 @@ class RideService /** * 获取用户当前有效座驾,若已过期则自动标记为 expired。 */ - public function currentRide(User $user): ?UserPurchase + public function currentRide(User $user): ?UserRidePurchase { - $purchase = UserPurchase::query() - ->with('shopItem') + $purchase = UserRidePurchase::query() + ->with('ride') ->where('user_id', $user->id) ->where('status', 'active') - ->whereHas('shopItem', fn ($query) => $query->where('type', ShopItem::TYPE_RIDE)) ->orderByDesc('expires_at') ->first(); @@ -92,7 +86,7 @@ class RideService public function formatCurrentRide(User $user): ?array { $purchase = $this->currentRide($user); - if (! $purchase || ! $purchase->shopItem) { + if (! $purchase || ! $purchase->ride) { return null; } @@ -106,14 +100,13 @@ class RideService */ public function purchaseRecords(User $user, int $limit = 20): array { - return UserPurchase::query() - ->with('shopItem') + return UserRidePurchase::query() + ->with('ride') ->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)) + ->map(fn (UserRidePurchase $purchase) => $this->formatPurchase($purchase)) ->values() ->all(); } @@ -123,9 +116,9 @@ class RideService * * @return array{ok:bool, message:string, current_ride?:array} */ - public function buy(User $user, ShopItem $item): array + public function buy(User $user, Ride $item): array { - if (! $item->isRide() || ! $item->is_active) { + if (! $item->is_active) { return ['ok' => false, 'message' => '该座驾暂未上架。']; } @@ -142,35 +135,33 @@ class RideService $now = Carbon::now(); // 先清理已过期的 active 座驾,避免旧状态影响替换判断。 - UserPurchase::query() + UserRidePurchase::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') + $activeRide = UserRidePurchase::query() + ->with('ride') ->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) { + if ($activeRide && (int) $activeRide->ride_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([ + UserRidePurchase::create([ 'user_id' => $user->id, - 'shop_item_id' => $item->id, + 'ride_id' => $item->id, 'status' => 'active', 'price_paid' => $item->price, 'expires_at' => $baseTime->copy()->addDays($days), @@ -184,9 +175,9 @@ class RideService $activeRide->update(['status' => 'cancelled']); } - UserPurchase::create([ + UserRidePurchase::create([ 'user_id' => $user->id, - 'shop_item_id' => $item->id, + 'ride_id' => $item->id, 'status' => 'active', 'price_paid' => $item->price, 'expires_at' => $now->copy()->addDays($days), @@ -208,7 +199,7 @@ class RideService public function buildPresencePayload(User $user): ?array { $purchase = $this->currentRide($user); - $item = $purchase?->shopItem; + $item = $purchase?->ride; $rideKey = $item?->rideKey(); if (! $purchase || ! $item || ! $rideKey) { @@ -234,9 +225,9 @@ class RideService * * @return array */ - private function formatPurchase(UserPurchase $purchase): array + private function formatPurchase(UserRidePurchase $purchase): array { - $item = $purchase->shopItem; + $item = $purchase->ride; return [ 'id' => $purchase->id, diff --git a/database/migrations/2026_04_30_092651_add_ride_fields_to_shop_items_table.php b/database/migrations/2026_04_30_092651_add_ride_fields_to_shop_items_table.php index 15be403..fb4f8ba 100644 --- a/database/migrations/2026_04_30_092651_add_ride_fields_to_shop_items_table.php +++ b/database/migrations/2026_04_30_092651_add_ride_fields_to_shop_items_table.php @@ -1,104 +1,28 @@ string('welcome_message', 255)->nullable()->after('charm_bonus')->comment('座驾入场欢迎语模板'); - }); - - if (DB::getDriverName() === 'mysql') { - DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair','msg_bubble','msg_name_color','avatar_frame','msg_text_color','ride') NOT NULL COMMENT '道具类型'"); - } - - $rides = [ - [ - 'name' => '歼-35隐身战机', - 'slug' => 'ride_j35', - 'description' => '驾驶歼-35划破长空入场,附带全屏战机掠过特效。', - 'icon' => '🛩️', - 'price' => 18888, - 'duration_days' => 7, - 'sort_order' => 80, - 'welcome_message' => '【{name}】驾驶【{ride}】划破长空,震撼降临聊天室!', - ], - [ - 'name' => '99A主战坦克', - 'slug' => 'ride_99a', - 'description' => '驾驶 99A 主战坦克重装入场,附带履带尘土与炮击冲击特效。', - 'icon' => '🛡️', - 'price' => 18888, - 'duration_days' => 7, - 'sort_order' => 81, - 'welcome_message' => '【{name}】驾驶【{ride}】重装入场,地面都为之一震!', - ], - [ - 'name' => '东风-5C战略导弹', - 'slug' => 'ride_df5c', - 'description' => '乘东风-5C 发射升空入场,附带尾焰、烟尘和雷达 HUD 特效。', - 'icon' => '🚀', - 'price' => 28888, - 'duration_days' => 7, - 'sort_order' => 82, - 'welcome_message' => '【{name}】乘【{ride}】点火升空,战略级排面拉满!', - ], - [ - 'name' => '福建舰航母', - 'slug' => 'ride_fujian', - 'description' => '乘福建舰破浪入场,附带海浪、舰载机和甲板 HUD 特效。', - 'icon' => '⚓', - 'price' => 28888, - 'duration_days' => 7, - 'sort_order' => 83, - 'welcome_message' => '【{name}】乘【{ride}】破浪而来,全场列队欢迎!', - ], - ]; - - foreach ($rides as $ride) { - DB::table('shop_items')->updateOrInsert( - ['slug' => $ride['slug']], - $ride + [ - 'type' => 'ride', - 'duration_minutes' => 0, - 'intimacy_bonus' => 0, - 'charm_bonus' => 0, - 'is_active' => true, - 'created_at' => now(), - 'updated_at' => now(), - ] - ); - } + // } /** - * 方法功能:移除座驾默认商品并回滚字段与类型。 + * 方法功能:空迁移无需回滚结构。 */ public function down(): void { - DB::table('shop_items')->whereIn('slug', ['ride_j35', 'ride_99a', 'ride_df5c', 'ride_fujian'])->delete(); - - if (DB::getDriverName() === 'mysql') { - DB::statement("UPDATE `shop_items` SET `type` = 'one_time' WHERE `type` = 'ride'"); - DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair','msg_bubble','msg_name_color','avatar_frame','msg_text_color') NOT NULL COMMENT '道具类型'"); - } - - Schema::table('shop_items', function (Blueprint $table) { - $table->dropColumn('welcome_message'); - }); + // } }; diff --git a/database/migrations/2026_04_30_101500_create_rides_tables.php b/database/migrations/2026_04_30_101500_create_rides_tables.php new file mode 100644 index 0000000..0bc5f7e --- /dev/null +++ b/database/migrations/2026_04_30_101500_create_rides_tables.php @@ -0,0 +1,122 @@ +id(); + $table->string('name', 100)->comment('座驾名称'); + $table->string('slug', 100)->unique()->comment('座驾唯一标识,格式 ride_key'); + $table->string('effect_key', 50)->unique()->comment('全屏特效 key'); + $table->string('icon', 20)->default('🚘')->comment('座驾图标'); + $table->text('description')->nullable()->comment('座驾说明'); + $table->unsignedInteger('price')->default(0)->comment('购买价格'); + $table->unsignedInteger('duration_days')->default(7)->comment('使用天数'); + $table->string('welcome_message', 255)->nullable()->comment('入场欢迎语模板'); + $table->unsignedInteger('sort_order')->default(0)->comment('排序权重'); + $table->boolean('is_active')->default(true)->comment('是否上架'); + $table->timestamps(); + }); + + Schema::create('user_ride_purchases', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('ride_id')->constrained('rides')->cascadeOnDelete(); + $table->enum('status', ['active', 'expired', 'cancelled'])->default('active')->comment('座驾状态'); + $table->unsignedInteger('price_paid')->default(0)->comment('实际支付金币'); + $table->timestamp('expires_at')->nullable()->comment('到期时间'); + $table->timestamp('used_at')->nullable()->comment('首次使用时间'); + $table->timestamps(); + + $table->index(['user_id', 'status', 'expires_at']); + }); + + $this->seedDefaultRides(); + } + + /** + * 方法功能:删除座驾独立数据表。 + */ + public function down(): void + { + Schema::dropIfExists('user_ride_purchases'); + Schema::dropIfExists('rides'); + } + + /** + * 方法功能:写入当前默认四个全屏座驾。 + */ + private function seedDefaultRides(): void + { + $now = now(); + $rides = [ + [ + 'name' => '歼-35隐身战机', + 'slug' => 'ride_j35', + 'effect_key' => 'j35', + 'description' => '驾驶歼-35划破长空入场,附带全屏战机掠过特效。', + 'icon' => '🛩️', + 'price' => 18888, + 'duration_days' => 7, + 'sort_order' => 80, + 'welcome_message' => '【{name}】驾驶【{ride}】划破长空,震撼降临聊天室!', + ], + [ + 'name' => '99A主战坦克', + 'slug' => 'ride_99a', + 'effect_key' => '99a', + 'description' => '驾驶 99A 主战坦克重装入场,附带履带尘土与炮击冲击特效。', + 'icon' => '🛡️', + 'price' => 18888, + 'duration_days' => 7, + 'sort_order' => 81, + 'welcome_message' => '【{name}】驾驶【{ride}】重装入场,地面都为之一震!', + ], + [ + 'name' => '东风-5C战略导弹', + 'slug' => 'ride_df5c', + 'effect_key' => 'df5c', + 'description' => '乘东风-5C 发射升空入场,附带尾焰、烟尘和雷达 HUD 特效。', + 'icon' => '🚀', + 'price' => 28888, + 'duration_days' => 7, + 'sort_order' => 82, + 'welcome_message' => '【{name}】乘【{ride}】点火升空,战略级排面拉满!', + ], + [ + 'name' => '福建舰航母', + 'slug' => 'ride_fujian', + 'effect_key' => 'fujian', + 'description' => '乘福建舰破浪入场,附带海浪、舰载机和甲板 HUD 特效。', + 'icon' => '⚓', + 'price' => 28888, + 'duration_days' => 7, + 'sort_order' => 83, + 'welcome_message' => '【{name}】乘【{ride}】破浪而来,全场列队欢迎!', + ], + ]; + + foreach ($rides as $ride) { + DB::table('rides')->insert($ride + [ + 'is_active' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } +}; diff --git a/database/migrations/2026_04_30_102000_remove_legacy_ride_fields_from_shop_items.php b/database/migrations/2026_04_30_102000_remove_legacy_ride_fields_from_shop_items.php new file mode 100644 index 0000000..634951b --- /dev/null +++ b/database/migrations/2026_04_30_102000_remove_legacy_ride_fields_from_shop_items.php @@ -0,0 +1,52 @@ +where('slug', 'like', 'ride_%')->delete(); + + if (DB::getDriverName() === 'mysql' && $this->shopItemTypeContainsRide()) { + DB::statement("UPDATE `shop_items` SET `type` = 'one_time' WHERE `type` = 'ride'"); + DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair','msg_bubble','msg_name_color','avatar_frame','msg_text_color') NOT NULL COMMENT '道具类型'"); + } + + if (Schema::hasColumn('shop_items', 'welcome_message')) { + Schema::table('shop_items', function (Blueprint $table) { + $table->dropColumn('welcome_message'); + }); + } + } + + /** + * 方法功能:清理迁移不恢复旧商店座驾结构。 + */ + public function down(): void + { + // + } + + /** + * 方法功能:判断当前 shop_items.type 枚举是否包含旧座驾类型。 + */ + private function shopItemTypeContainsRide(): bool + { + $column = DB::selectOne("SHOW COLUMNS FROM `shop_items` LIKE 'type'"); + + return $column && str_contains((string) $column->Type, "'ride'"); + } +}; diff --git a/plugins/chatroom-ride-development/skills/chatroom-ride-development/SKILL.md b/plugins/chatroom-ride-development/skills/chatroom-ride-development/SKILL.md index 6293d7a..a8d5874 100644 --- a/plugins/chatroom-ride-development/skills/chatroom-ride-development/SKILL.md +++ b/plugins/chatroom-ride-development/skills/chatroom-ride-development/SKILL.md @@ -1,31 +1,33 @@ --- name: chatroom-ride-development -description: "开发 /Users/pllx/Web/Herd/chatroom 的聊天室座驾。适用于新增 ride_ 座驾商品、全屏 Canvas 特效、座驾音效、后台配置、前台座驾页面和进房欢迎语。" +description: "开发 /Users/pllx/Web/Herd/chatroom 的聊天室座驾。适用于新增 ride_ 独立座驾、全屏 Canvas 特效、座驾音效、后台配置、前台座驾页面和进房欢迎语。" --- # Chatroom Ride Development ## 适用场景 -- 新增或修改聊天室座驾商品。 +- 新增或修改聊天室独立座驾。 - 新增 `resources/js/effects/.js` 全屏座驾特效。 - 调整座驾购买、续期、替换、入场欢迎语或后台价格/天数配置。 - 排查座驾进房后特效不播放、欢迎语不显示、购买记录不正确的问题。 ## 必须遵守 -- 座驾商品类型固定为 `ride`,商品 slug 固定为 `ride_`。 +- 座驾必须使用独立 `rides` 和 `user_ride_purchases`,不要写入 `shop_items` 或 `user_purchases`。 +- 座驾 slug 固定为 `ride_`。 - `` 必须同时出现在: - - `shop_items.slug` - - `ShopItem::rideKey()` 可解析结果 + - `rides.slug` + - `rides.effect_key` + - `Ride::rideKey()` 可解析结果 - `EffectBroadcast::TYPES` - `resources/js/effects/effect-manager.js` - `resources/js/effects/effect-sounds.js` - `resources/js/effects/.js` -- 新增座驾默认通过迁移或 Seeder 写入 `shop_items`,字段至少包含名称、slug、图标、价格、`duration_days`、排序和 `welcome_message`。 +- 新增座驾默认通过迁移或 Seeder 写入 `rides`,字段至少包含名称、slug、`effect_key`、图标、价格、`duration_days`、排序和 `welcome_message`。 - `welcome_message` 支持 `{name}` 和 `{ride}`,输出前必须转义,不能直接信任后台输入。 - 当前版本只允许用户同时拥有一个 active 座驾;同款续购叠加有效期,不同款替换旧座驾并把旧记录置为 `cancelled`。 -- 不要另建座驾购买表;购买记录继续使用 `user_purchases`。 +- 用户购买记录必须写入 `user_ride_purchases`,不要复用商店购买记录。 ## 新增座驾步骤 @@ -33,7 +35,7 @@ description: "开发 /Users/pllx/Web/Herd/chatroom 的聊天室座驾。适用 2. 在 `effect-manager.js` 注册模块加载和启动分支。 3. 在 `effect-sounds.js` 注册音效启动分支。 4. 在 `EffectBroadcast::TYPES` 加入 ``。 -5. 在迁移或 Seeder 中新增 `shop_items` 记录,slug 使用 `ride_`。 +5. 在迁移或 Seeder 中新增 `rides` 记录,slug 使用 `ride_`,`effect_key` 使用 ``。 6. 若后台预览需要,把按钮加入管理菜单预览区。 7. 更新座驾相关 PHPUnit 测试,至少覆盖列表、购买、续期、替换和进房触发。 diff --git a/resources/views/admin/layouts/app.blade.php b/resources/views/admin/layouts/app.blade.php index da7d6c4..a364494 100644 --- a/resources/views/admin/layouts/app.blade.php +++ b/resources/views/admin/layouts/app.blade.php @@ -84,6 +84,10 @@ class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.shop.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}"> {!! '🛒 商店管理' !!} + + 🚘 座驾管理 + {!! '💒 婚姻管理' !!} diff --git a/resources/views/admin/rides/index.blade.php b/resources/views/admin/rides/index.blade.php new file mode 100644 index 0000000..5b88a2e --- /dev/null +++ b/resources/views/admin/rides/index.blade.php @@ -0,0 +1,270 @@ +{{-- + 文件功能:后台座驾独立管理页面 + 支持查看、新增、编辑、上下架切换、删除座驾,以及配置价格、使用天数和欢迎语。 +--}} + +@extends('admin.layouts.app') + +@section('title', '🚘 座驾管理') + +@section('content') + @php require resource_path('views/admin/partials/list-theme.php'); @endphp + + @php + $isSuperAdmin = Auth::id() === 1; + @endphp + +
+
+
+
+

聊天室座驾列表

+

单独管理座驾价格、使用天数、入场欢迎语和全屏特效 key。

+
+ @if ($isSuperAdmin) + + @endif +
+
+ +
+ + + + + + + + + + + + + + @forelse ($rides as $ride) + + + + + + + + + + @empty + + + + @endforelse + +
座驾特效 Key价格使用天数排序状态操作
+
+ {{ $ride->icon }} +
+

{{ $ride->name }}

+

{{ $ride->slug }}

+ @if ($ride->description) +

+ {{ $ride->description }} +

+ @endif +
+
+
{{ $ride->effect_key }} + {{ number_format($ride->price) }} 金 + {{ $ride->duration_days }} 天{{ $ride->sort_order }} +
+ @csrf @method('PATCH') + +
+
+
+ + @if ($isSuperAdmin) +
+ @csrf @method('DELETE') + +
+ @endif +
+
暂无座驾数据
+
+ +
+
+
+

+ +
+ +
+ @csrf + + +
+
+ + +
+
+ + +

格式:ride_j35、ride_df5c。

+
+
+ + +

对应 resources/js/effects/<key>.js。

+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + + +
+ +
+ + +
+
+
+
+
+@endsection diff --git a/resources/views/admin/shop/index.blade.php b/resources/views/admin/shop/index.blade.php index d789926..8b74dd3 100644 --- a/resources/views/admin/shop/index.blade.php +++ b/resources/views/admin/shop/index.blade.php @@ -26,7 +26,6 @@ 'msg_name_color' => ['label' => '昵称颜色', 'color' => 'bg-pink-100 text-pink-700'], 'msg_text_color' => ['label' => '文字颜色', 'color' => 'bg-cyan-100 text-cyan-700'], 'avatar_frame' => ['label' => '头像框', 'color' => 'bg-amber-100 text-amber-700'], - 'ride' => ['label' => '聊天室座驾', 'color' => 'bg-slate-900 text-white'], ]; $isSuperAdmin = Auth::id() === 1; @endphp @@ -45,7 +44,6 @@ duration_minutes: 0, intimacy_bonus: 0, charm_bonus: 0, - welcome_message: '', sort_order: 0, is_active: true, }, @@ -63,7 +61,6 @@ duration_minutes: 0, intimacy_bonus: 0, charm_bonus: 0, - welcome_message: '', sort_order: 0, is_active: true, }; @@ -84,7 +81,6 @@ duration_minutes: item.duration_minutes || 0, intimacy_bonus: item.intimacy_bonus || 0, charm_bonus: item.charm_bonus || 0, - welcome_message: item.welcome_message || '', sort_order: item.sort_order, is_active: item.is_active, }; @@ -196,7 +192,6 @@ 'duration_minutes' => $item->duration_minutes, 'intimacy_bonus' => $item->intimacy_bonus, 'charm_bonus' => $item->charm_bonus, - 'welcome_message' => $item->welcome_message, 'sort_order' => $item->sort_order, 'is_active' => (bool) $item->is_active, ]) }})" @@ -300,19 +295,10 @@ - -
- - -

仅座驾类型生效;不填写时使用系统默认欢迎语。

-
-
diff --git a/routes/web.php b/routes/web.php index c467b3d..1f58bc6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -519,6 +519,11 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad Route::put('/shop/{shopItem}', [\App\Http\Controllers\Admin\ShopItemController::class, 'update'])->name('shop.update'); Route::patch('/shop/{shopItem}/toggle', [\App\Http\Controllers\Admin\ShopItemController::class, 'toggle'])->name('shop.toggle'); Route::delete('/shop/{shopItem}', [\App\Http\Controllers\Admin\ShopItemController::class, 'destroy'])->name('shop.destroy'); + Route::get('/rides', [\App\Http\Controllers\Admin\RideController::class, 'index'])->name('rides.index'); + Route::post('/rides', [\App\Http\Controllers\Admin\RideController::class, 'store'])->name('rides.store'); + Route::put('/rides/{ride}', [\App\Http\Controllers\Admin\RideController::class, 'update'])->name('rides.update'); + Route::patch('/rides/{ride}/toggle', [\App\Http\Controllers\Admin\RideController::class, 'toggle'])->name('rides.toggle'); + Route::delete('/rides/{ride}', [\App\Http\Controllers\Admin\RideController::class, 'destroy'])->name('rides.destroy'); // 💒 婚姻管理(superlevel 及以上) Route::prefix('marriages')->name('marriages.')->group(function () { diff --git a/tests/Feature/ChatControllerTest.php b/tests/Feature/ChatControllerTest.php index 871f996..8798791 100644 --- a/tests/Feature/ChatControllerTest.php +++ b/tests/Feature/ChatControllerTest.php @@ -12,12 +12,12 @@ use App\Events\MessageSent; use App\Models\Department; use App\Models\Gift; use App\Models\Position; +use App\Models\Ride; use App\Models\Room; -use App\Models\ShopItem; use App\Models\Sysparam; use App\Models\User; use App\Models\UserPosition; -use App\Models\UserPurchase; +use App\Models\UserRidePurchase; use App\Models\VipLevel; use App\Support\PositionPermissionRegistry; use Illuminate\Broadcasting\PresenceChannel; @@ -1104,20 +1104,20 @@ class ChatControllerTest extends TestCase { $room = Room::create(['room_name' => 'rideroom']); $user = User::factory()->create(['has_received_new_gift' => true]); - $ride = ShopItem::query()->updateOrCreate(['slug' => 'ride_j35'], [ + $ride = Ride::query()->updateOrCreate(['slug' => 'ride_j35'], [ 'name' => '歼-35测试座驾', + 'effect_key' => 'j35', 'description' => '测试座驾', 'icon' => '🛩️', 'price' => 18888, - 'type' => 'ride', 'duration_days' => 7, 'sort_order' => 80, 'is_active' => true, 'welcome_message' => '【{name}】驾驶【{ride}】震撼入场', ]); - UserPurchase::create([ + UserRidePurchase::create([ 'user_id' => $user->id, - 'shop_item_id' => $ride->id, + 'ride_id' => $ride->id, 'status' => 'active', 'price_paid' => 18888, 'expires_at' => now()->addDays(3), @@ -1143,20 +1143,20 @@ class ChatControllerTest extends TestCase { $room = Room::create(['room_name' => '过期房']); $user = User::factory()->create(['has_received_new_gift' => true]); - $ride = ShopItem::query()->updateOrCreate(['slug' => 'ride_df5c'], [ + $ride = Ride::query()->updateOrCreate(['slug' => 'ride_df5c'], [ 'name' => '过期座驾', + 'effect_key' => 'df5c', 'description' => '测试座驾', 'icon' => '🚀', 'price' => 18888, - 'type' => 'ride', 'duration_days' => 7, 'sort_order' => 80, 'is_active' => true, 'welcome_message' => '【{name}】驾驶【{ride}】震撼入场', ]); - $purchase = UserPurchase::create([ + $purchase = UserRidePurchase::create([ 'user_id' => $user->id, - 'shop_item_id' => $ride->id, + 'ride_id' => $ride->id, 'status' => 'active', 'price_paid' => 18888, 'expires_at' => now()->subMinute(), diff --git a/tests/Feature/RideControllerTest.php b/tests/Feature/RideControllerTest.php index 5c8afb1..33ab14a 100644 --- a/tests/Feature/RideControllerTest.php +++ b/tests/Feature/RideControllerTest.php @@ -8,10 +8,10 @@ namespace Tests\Feature; +use App\Models\Ride; use App\Models\Room; -use App\Models\ShopItem; use App\Models\User; -use App\Models\UserPurchase; +use App\Models\UserRidePurchase; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Redis; use Tests\TestCase; @@ -41,9 +41,9 @@ class RideControllerTest extends TestCase $user = User::factory()->create(); $ride = $this->createRide(['name' => '歼-35测试座驾', 'slug' => 'ride_j35']); - UserPurchase::create([ + UserRidePurchase::create([ 'user_id' => $user->id, - 'shop_item_id' => $ride->id, + 'ride_id' => $ride->id, 'status' => 'active', 'price_paid' => 18888, 'expires_at' => now()->addDays(3), @@ -76,9 +76,9 @@ class RideControllerTest extends TestCase ->assertJsonPath('status', 'error'); $this->assertSame(100, (int) $user->fresh()->jjb); - $this->assertDatabaseMissing('user_purchases', [ + $this->assertDatabaseMissing('user_ride_purchases', [ 'user_id' => $user->id, - 'shop_item_id' => $ride->id, + 'ride_id' => $ride->id, ]); } @@ -102,9 +102,9 @@ class RideControllerTest extends TestCase ->assertJsonPath('current_ride.item.slug', 'ride_99a') ->assertJsonPath('jjb', 11112); - $this->assertDatabaseHas('user_purchases', [ + $this->assertDatabaseHas('user_ride_purchases', [ 'user_id' => $user->id, - 'shop_item_id' => $ride->id, + 'ride_id' => $ride->id, 'status' => 'active', 'price_paid' => 18888, ]); @@ -120,9 +120,9 @@ class RideControllerTest extends TestCase $this->joinRoom($user, $room); $ride = $this->createRide(['price' => 1000, 'duration_days' => 7]); - $purchase = UserPurchase::create([ + $purchase = UserRidePurchase::create([ 'user_id' => $user->id, - 'shop_item_id' => $ride->id, + 'ride_id' => $ride->id, 'status' => 'active', 'price_paid' => 1000, 'expires_at' => now()->addDays(2), @@ -136,14 +136,14 @@ class RideControllerTest extends TestCase $response->assertOk(); $this->assertSame('cancelled', $purchase->fresh()->status); - $activePurchase = UserPurchase::query() + $activePurchase = UserRidePurchase::query() ->where('user_id', $user->id) - ->where('shop_item_id', $ride->id) + ->where('ride_id', $ride->id) ->where('status', 'active') ->firstOrFail(); $this->assertSame(1000, (int) $activePurchase->price_paid); $this->assertTrue($activePurchase->expires_at->greaterThan(now()->addDays(8))); - $this->assertSame(1, UserPurchase::query()->where('user_id', $user->id)->where('status', 'active')->count()); + $this->assertSame(1, UserRidePurchase::query()->where('user_id', $user->id)->where('status', 'active')->count()); } /** @@ -157,9 +157,9 @@ class RideControllerTest extends TestCase $oldRide = $this->createRide(['slug' => 'ride_j35', 'price' => 1000]); $newRide = $this->createRide(['slug' => 'ride_df5c', 'price' => 2000]); - $oldPurchase = UserPurchase::create([ + $oldPurchase = UserRidePurchase::create([ 'user_id' => $user->id, - 'shop_item_id' => $oldRide->id, + 'ride_id' => $oldRide->id, 'status' => 'active', 'price_paid' => 1000, 'expires_at' => now()->addDays(3), @@ -174,66 +174,78 @@ class RideControllerTest extends TestCase ->assertJsonPath('current_ride.item.slug', 'ride_df5c'); $this->assertSame('cancelled', $oldPurchase->fresh()->status); - $this->assertDatabaseHas('user_purchases', [ + $this->assertDatabaseHas('user_ride_purchases', [ 'user_id' => $user->id, - 'shop_item_id' => $newRide->id, + 'ride_id' => $newRide->id, 'status' => 'active', ]); } /** - * 测试后台商店可以保存座驾类型和欢迎语字段。 + * 测试后台座驾独立模块可以保存欢迎语字段。 */ public function test_admin_can_store_ride_with_welcome_message(): void { $admin = User::factory()->create(['id' => 1, 'user_level' => 100]); - $response = $this->actingAs($admin)->post(route('admin.shop.store'), [ + $response = $this->actingAs($admin)->post(route('admin.rides.store'), [ 'name' => '测试座驾', 'slug' => 'ride_test', + 'effect_key' => 'test', 'icon' => '🚘', 'description' => '测试座驾说明', 'price' => 12345, - 'type' => 'ride', 'duration_days' => 7, - 'duration_minutes' => 0, - 'intimacy_bonus' => 0, - 'charm_bonus' => 0, 'welcome_message' => '【{name}】驾驶【{ride}】入场', 'sort_order' => 99, 'is_active' => 1, ]); - $response->assertRedirect(route('admin.shop.index')); - $this->assertDatabaseHas('shop_items', [ + $response->assertRedirect(route('admin.rides.index')); + $this->assertDatabaseHas('rides', [ 'slug' => 'ride_test', - 'type' => 'ride', + 'effect_key' => 'test', 'welcome_message' => '【{name}】驾驶【{ride}】入场', ]); } /** - * 创建测试用座驾商品。 + * 测试后台座驾管理页面可以显示独立座驾数据。 + */ + public function test_admin_can_view_ride_management_page(): void + { + $admin = User::factory()->create(['id' => 1, 'user_level' => 100]); + $ride = $this->createRide(['name' => '后台可见座驾', 'slug' => 'ride_admin_visible']); + + $response = $this->actingAs($admin)->get(route('admin.rides.index')); + + $response->assertOk() + ->assertSee('座驾管理') + ->assertSee($ride->name) + ->assertSee('ride_admin_visible'); + } + + /** + * 创建测试用独立座驾。 * * @param array $attributes 覆盖字段 */ - private function createRide(array $attributes = []): ShopItem + private function createRide(array $attributes = []): Ride { $data = array_merge([ 'name' => '测试座驾', 'slug' => 'ride_test_'.str()->random(8), + 'effect_key' => 'test_'.str()->random(8), 'description' => '测试座驾说明', 'icon' => '🚘', 'price' => 1000, - 'type' => 'ride', 'duration_days' => 7, - 'duration_minutes' => 0, 'sort_order' => 80, 'is_active' => true, 'welcome_message' => '【{name}】驾驶【{ride}】入场', ], $attributes); - return ShopItem::query()->updateOrCreate( + return Ride::query()->updateOrCreate( ['slug' => $data['slug']], $data, );