改为独立座驾模块

This commit is contained in:
pllx
2026-04-30 09:55:20 +08:00
parent 3c95478097
commit 181cc6a0b0
22 changed files with 886 additions and 216 deletions
@@ -0,0 +1,82 @@
<?php
/**
* 文件功能:后台座驾独立管理控制器。
*
* 提供座驾列表、新增、编辑、上下架切换与删除能力,不依赖商店商品模块。
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreRideRequest;
use App\Http\Requests\UpdateRideRequest;
use App\Models\Ride;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* 后台座驾管理控制器
* 负责独立 rides 表的后台管理流程。
*/
class RideController extends Controller
{
/**
* 显示座驾管理列表页。
*/
public function index(): View
{
$rides = Ride::query()
->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}」已删除。");
}
}
+3 -3
View File
@@ -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']) {
-1
View File
@@ -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) => [
+3 -3
View File
@@ -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'],
];
}
+63
View File
@@ -0,0 +1,63 @@
<?php
/**
* 文件功能:后台新增座驾请求验证。
*
* 校验座驾独立模块的名称、特效 key、价格、使用天数、欢迎语和上下架状态。
*/
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 后台新增座驾请求
* 负责新增座驾时的权限与字段校验。
*/
class StoreRideRequest 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', '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<string, string>
*/
public function messages(): array
{
return [
'slug.regex' => '座驾标识必须使用 ride_ 开头,例如 ride_j35。',
'effect_key.regex' => '特效 key 只能包含小写字母、数字和下划线。',
'duration_days.min' => '使用天数至少为 1 天。',
];
}
}
+1 -3
View File
@@ -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,
];
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* 文件功能:后台更新座驾请求验证。
*
* 校验座驾编辑时的唯一标识、价格、使用天数和欢迎语配置。
*/
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 后台更新座驾请求
* 负责编辑座驾时的权限与字段校验。
*/
class UpdateRideRequest extends FormRequest
{
/**
* 判断当前用户是否允许编辑座驾。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 获取更新座驾验证规则。
*
* @return array<string, ValidationRule|array<mixed>|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<string, string>
*/
public function messages(): array
{
return [
'slug.regex' => '座驾标识必须使用 ride_ 开头,例如 ride_j35。',
'effect_key.regex' => '特效 key 只能包含小写字母、数字和下划线。',
'duration_days.min' => '使用天数至少为 1 天。',
];
}
}
+1 -3
View File
@@ -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,
];
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
/**
* 文件功能:聊天室座驾模型。
*
* 对应 rides 表,保存座驾名称、特效 key、价格、使用天数、欢迎语与上下架状态。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 聊天室座驾模型
* 负责提供座驾定义、全屏特效 key 和购买记录关系。
*/
class Ride extends Model
{
protected $fillable = [
'name', 'slug', 'effect_key', 'icon', 'description', 'price',
'duration_days', 'welcome_message', 'sort_order', 'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
/**
* 获取座驾对应的所有购买记录。
*/
public function purchases(): HasMany
{
return $this->hasMany(UserRidePurchase::class);
}
/**
* 获取座驾全屏特效 key。
*/
public function rideKey(): string
{
return $this->effect_key;
}
/**
* 获取所有上架座驾。
*
* @return Collection<int, self>
*/
public static function active(): Collection
{
return static::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('id')
->get();
}
}
+1 -23
View File
@@ -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 durationslug 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;
}
/**
* 获取所有上架商品(按排序)
*/
+60
View File
@@ -0,0 +1,60 @@
<?php
/**
* 文件功能:用户座驾购买记录模型。
*
* 对应 user_ride_purchases 表,追踪用户座驾购买、续期、替换和过期状态。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 用户座驾购买记录模型
* 负责连接用户与座驾,并判断当前记录是否仍有效。
*/
class UserRidePurchase extends Model
{
protected $fillable = [
'user_id', 'ride_id', 'status', 'price_paid', 'expires_at', 'used_at',
];
protected $casts = [
'expires_at' => '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;
}
}
+26 -35
View File
@@ -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<int, ShopItem>
* @return Collection<int, Ride>
*/
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<string, mixed>
*/
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<string, mixed>}
*/
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<string, mixed>
*/
private function formatPurchase(UserPurchase $purchase): array
private function formatPurchase(UserRidePurchase $purchase): array
{
$item = $purchase->shopItem;
$item = $purchase->ride;
return [
'id' => $purchase->id,