202509041808

加入RoomApiToken 驗証
加入通知顯示
This commit is contained in:
allen.yan 2025-09-04 18:12:15 +08:00
parent 355dde4481
commit d70da24a75
19 changed files with 249 additions and 79 deletions

View File

@ -0,0 +1,33 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use App\Models\RoomSession;
class RoomApiTokenMiddleware
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
// 優先從 query 拿,沒就 session
$roomCode = $request->query('room_code', session('room_code'));
if ($roomCode) {
$roomSession = RoomSession::validToken($roomCode)->first();
if (!$roomSession) {
session()->forget('room_code');
return redirect()->route('welcome')->with('error', '房間不存在或狀態不可用');
}else{
session(['room_code' => $roomCode]);
$request->merge(['roomSession' => $roomSession]); // 可選:直接注入 request
}
}
return $next($request);
}
}

View File

@ -2,22 +2,33 @@
namespace App\Livewire\Pages; namespace App\Livewire\Pages;
use App\Models\RoomSession;
use Livewire\Component; use Livewire\Component;
class Home extends Component class Home extends Component
{ {
public $roomCode; public array $menus = [];
public function mount() public function mount()
{ {
// 先從 URL 取得 room_code再存進 session $this->menus = [
//session()->forget('room_code'); ['route' => 'new-songs','image' => '手機點歌/首頁-新歌快報.png'],
$this->roomCode = request()->query('room_code', session('room_code', null)); ['route' => 'top-ranking','image' => '手機點歌/首頁-熱門排行.png'],
if ($this->roomCode) { ['route' => 'search-song','image' => '手機點歌/首頁-歌名查詢.png'],
session(['room_code' => $this->roomCode]); ];
$roomMenus = [
['route' => 'clicked-song','image' => '手機點歌/首頁-已點歌曲.png'],
['route' => 'sound-control','image' => '手機點歌/首頁-聲音控制.png',],
['route' => 'love-message','image' => '手機點歌/首頁-真情告白.png',],
];
$roomCode = request()->query('room_code', session('room_code', null));
if ($roomCode) {
$roomSession = RoomSession::validToken($roomCode)->first();
if ($roomSession) {
session(['room_code' => $roomCode]);
$this->menus = array_merge($this->menus, $roomMenus);
}
} }
} }
public function render() public function render()
{ {
return view('livewire.pages.home'); return view('livewire.pages.home');

View File

@ -3,12 +3,21 @@
namespace App\Livewire\Pages; namespace App\Livewire\Pages;
use Livewire\Component; use Livewire\Component;
use App\Models\RoomSession;
use WireUi\Traits\WireUiActions;
use App\Services\TcpSocketClient;
class LoveMessage extends Component class LoveMessage extends Component
{ {
use WireUiActions;
protected $listeners = ['stickerSelected' => 'handleSticker']; protected $listeners = ['stickerSelected' => 'handleSticker'];
public $roomSession;
public $message = ''; public $message = '';
public $selectedSticker=null; public $selectedSticker=null;
public function mount()
{
$this->roomSession = request()->roomSession;
}
public function handleSticker($sticker) public function handleSticker($sticker)
{ {

View File

@ -9,7 +9,7 @@ use App\Models\OrderedSong;
class OrderedSongList extends Component class OrderedSongList extends Component
{ {
public ?string $roomSessionId = null; public $roomSession;
public EloquentCollection $playing ; public EloquentCollection $playing ;
public EloquentCollection $nextSong ; public EloquentCollection $nextSong ;
@ -21,32 +21,22 @@ class OrderedSongList extends Component
$this->playing = new EloquentCollection(); $this->playing = new EloquentCollection();
$this->nextSong = new EloquentCollection(); $this->nextSong = new EloquentCollection();
$this->finished = new EloquentCollection(); $this->finished = new EloquentCollection();
$roomSession = $this->getRoomSession(session('room_code'))?->id ; $this->roomSession = request()->roomSession;
if ($roomSession) { $this->refreshSongs();
$this->roomSessionId = $roomSession->id;
$this->loadSongs($this->roomSessionId);
}
}
private function getRoomSession($apiToken): ?RoomSession
{
if (!$apiToken) return null;
return RoomSession::where('api_token', $apiToken)
->whereIn('status', ['active', 'maintain'])
->first();
} }
public function refreshSongs() public function refreshSongs()
{ {
if ($this->roomSessionId) { $dbSession = $this->roomSession->refreshValid();
$this->loadSongs($this->roomSessionId); if (!$dbSession) {
session()->forget('room_code');
return $this->redirect(route('welcome'), navigate: true);
} }
$this->playing = OrderedSong::forSession($this->roomSession->id)->playing()->get();
$this->nextSong = OrderedSong::forSession($this->roomSession->id)->nextSong()->get();
$this->finished = OrderedSong::forSession($this->roomSession->id)->finished()->get();
} }
protected function loadSongs(string $roomSessionId)
{
$this->playing = OrderedSong::forSession($roomSessionId)->playing()->get();
$this->nextSong = OrderedSong::forSession($roomSessionId)->nextSong()->get();
$this->finished = OrderedSong::forSession($roomSessionId)->finished()->get();
}
public function render() public function render()
{ {
return view('livewire.pages.ordered-song-list', [ return view('livewire.pages.ordered-song-list', [

View File

@ -3,11 +3,15 @@
namespace App\Livewire\Pages; namespace App\Livewire\Pages;
use Livewire\Component; use Livewire\Component;
use App\Models\RoomSession;
use App\Models\Artist; use App\Models\Artist;
use App\Models\SongLibraryCache; use App\Models\SongLibraryCache;
use WireUi\Traits\WireUiActions;
class SearchSong extends Component class SearchSong extends Component
{ {
use WireUiActions;
public $roomSession;
public string $search = ''; public string $search = '';
public $searchCategory = ''; public $searchCategory = '';
public $selectedLanguage = '國語'; // 預設語言 public $selectedLanguage = '國語'; // 預設語言
@ -21,6 +25,13 @@ class SearchSong extends Component
public function mount(string $searchCategory = '') public function mount(string $searchCategory = '')
{ {
$this->searchCategory = $searchCategory; $this->searchCategory = $searchCategory;
$roomCode = request()->query('room_code', session('room_code', null));
if ($roomCode) {
$this->roomSession = RoomSession::validToken($roomCode)->first();
if ($this->roomSession) {
session(['room_code' => $roomCode]);
}
}
} }
/** /**
@ -68,8 +79,10 @@ class SearchSong extends Component
// TODO: 加入已點歌曲邏輯,例如: // TODO: 加入已點歌曲邏輯,例如:
// auth()->user()->room->addSong($songId); // auth()->user()->room->addSong($songId);
$this->dispatchBrowserEvent('notify', [ $this->notification()->send([
'message' => '已加入已點歌曲' 'icon' => 'success',
'title' => $songId,
'description' => '已加入已點歌曲',
]); ]);
} }

View File

@ -2,37 +2,76 @@
namespace App\Livewire\Pages; namespace App\Livewire\Pages;
use App\Models\RoomSession;
use Livewire\Component; use Livewire\Component;
use App\Services\ApiClient;
use WireUi\Traits\WireUiActions;
use App\Services\TcpSocketClient;
class SoundControl extends Component class SoundControl extends Component
{ {
use WireUiActions;
public $roomSession;
public $buttons = [ public $buttons = [
['img'=>'音控-01.jpg', 'action'=>'pause'], 'pause' =>['img'=>'音控-01.jpg','label' =>'暫停'],
['img'=>'音控-02.jpg', 'action'=>'volume_up'], 'volume_up' =>['img'=>'音控-02.jpg','label' =>'音量 ↑'],
['img'=>'音控-04.jpg', 'action'=>'mic_up'], 'volume_down' =>['img'=>'音控-04.jpg','label' =>'音量 ↓'],
['img'=>'音控-06.jpg', 'action'=>'mute'], 'mic_up' =>['img'=>'音控-06.jpg','label' =>'麥克風 ↑'],
['img'=>'音控-03.jpg', 'action'=>'volume_down'], 'mic_down' =>['img'=>'音控-03.jpg','label' =>'麥克風 ↓'],
['img'=>'音控-05.jpg', 'action'=>'mic_down'], 'mute' =>['img'=>'音控-05.jpg','label' =>'靜音'],
['img'=>'音控-07.jpg', 'action'=>'original_song'], 'original_song' =>['img'=>'音控-07.jpg','label' =>'原唱'],
['img'=>'音控-08.jpg', 'action'=>'service'], 'service' =>['img'=>'音控-08.jpg','label' =>'服務鈴'],
['img'=>'音控-09.jpg', 'action'=>'replay'], 'replay' =>['img'=>'音控-09.jpg','label' =>'重播'],
['img'=>'音控-11.jpg', 'action'=>'male_key'], 'male_key' =>['img'=>'音控-11.jpg','label' =>'男調'],
['img'=>'音控-12.jpg', 'action'=>'female_key'], 'female_key' =>['img'=>'音控-12.jpg','label' =>'女調'],
['img'=>'音控-10.jpg', 'action'=>'cut'], 'cut' =>['img'=>'音控-10.jpg','label' =>'切歌'],
['img'=>'音控-15.jpg', 'action'=>'lower_key'], 'lower_key' =>['img'=>'音控-15.jpg','label' =>'降調'],
['img'=>'音控-14.jpg', 'action'=>'standard_key'], 'standard_key' =>['img'=>'音控-14.jpg','label' =>'原調'],
['img'=>'音控-13.jpg', 'action'=>'raise_key'], 'raise_key' =>['img'=>'音控-13.jpg','label' =>'升調'],
]; ];
public function mount()
public function sendVolumeControl(string $action)
{ {
// 這裡可以加你的 API 或邏輯 $this->roomSession = request()->roomSession;
// 範例:發送到後台控制音量 }
info("Sound control action: ".$action); public function sendControl(string $action)
{
$this->dispatchBrowserEvent('notify', [ $dbSession = $this->roomSession->refreshValid();
'message' => "已執行操作: {$action}" if (!$dbSession) {
session()->forget('room_code');
$this->notification()->send([
'icon' => 'error',
'title' => '驗證失敗',
'description' => 'Token 已失效,請重新登入',
]); ]);
return $this->redirect(route('welcome'), navigate: true);
}else{
$room=$this->roomSession->room;
}
$client = new TcpSocketClient($room->internal_ip, $room->port);
try {
$response = $client->send($room->name . "," . $action);
} catch (\Throwable $e) {
logger()->error('❌ TCP 傳送失敗: ' . $e->getMessage(), [
'room_id' => $room->id,
'ip' => $room->internal_ip,
'port' => $room->port,
]);
}
$title = $this->buttons[$action]['label'] ?? $action;
$this->notification()->send(
(isset($response) && trim($response) === "OK")
?[
'icon' => 'success',
'title' => $title,
'description' => '已執行操作',
]:[
'icon' => 'error',
'title' => $title,
'description' => $data['message'] ?? '操作失敗',
]
);
} }
public function render() public function render()

View File

@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use App\Enums\OrderedSongStatus;
/** /**
* @OA\Schema( * @OA\Schema(
@ -85,4 +86,33 @@ class OrderedSong extends Model
} }
]); ]);
} }
public function scopeForSession($query, $roomSessionId)
{
return $query->where('room_session_id', $roomSessionId);
}
public function scopePlaying($query)
{
return $query->where('status', OrderedSongStatus::Playing);
}
public function scopeNextSong($query)
{
return $query->whereIn('status', [OrderedSongStatus::InsertPlayback, OrderedSongStatus::NotPlayed])
->orderByRaw("FIELD(status, ?, ?)", [
OrderedSongStatus::InsertPlayback->value,
OrderedSongStatus::NotPlayed->value,
])
->orderByRaw("CASE WHEN status=? THEN ordered_at END DESC", [OrderedSongStatus::InsertPlayback->value])
->orderByRaw("CASE WHEN status=? THEN ordered_at END ASC", [OrderedSongStatus::NotPlayed->value]);
}
public function scopeFinished($query)
{
return $query->whereIn('status', [
OrderedSongStatus::Played,
OrderedSongStatus::Skipped,
OrderedSongStatus::NoFile,
])->orderByDesc('finished_at');
}
} }

View File

@ -35,8 +35,22 @@ class RoomSession extends Model
public const MODE_VIP = 'vip'; public const MODE_VIP = 'vip';
public const MODE_TEST = 'test'; public const MODE_TEST = 'test';
public function scopeValidToken($query, ?string $token)
{
return $query->with('room')
->where('api_token', $token)
->whereIn('status', ['active', 'maintain']);
}
public function refreshValid(): ?self
{
return self::validToken($this->api_token)
->where('id', $this->id)
->first();
}
public function room() public function room()
{ {
return $this->belongsTo(Room::class); return $this->belongsTo(Room::class);
} }
} }

View File

@ -14,6 +14,7 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {
$middleware->alias([ $middleware->alias([
'api_token' => \App\Http\Middleware\ApiTokenMiddleware::class, 'api_token' => \App\Http\Middleware\ApiTokenMiddleware::class,
'room_api_token' => \App\Http\Middleware\RoomApiTokenMiddleware::class,
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class 'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class

View File

@ -5,6 +5,6 @@
<img src="{{ asset('手機點歌/BANNER-12.png') }}" alt="超級巨星 Banner"> <img src="{{ asset('手機點歌/BANNER-12.png') }}" alt="超級巨星 Banner">
</div> </div>
</x-slot> </x-slot>
<x-notifications/>
<livewire:pages.love-message /> <livewire:pages.love-message />
</x-app-layout> </x-app-layout>

View File

@ -5,6 +5,7 @@
<img src="{{ asset('手機點歌/BANNER-01.png') }}" alt="新歌快報"> <img src="{{ asset('手機點歌/BANNER-01.png') }}" alt="新歌快報">
</div> </div>
</x-slot> </x-slot>
<x-notifications/>
<livewire:pages.search-song :searchCategory="'New'" /> <livewire:pages.search-song :searchCategory="'New'" />
<div class="image-container"> <div class="image-container">
<img src="{{ asset('手機點歌/LOGO_800x400px.png') }}" alt="Wolf Fox Logo"> <img src="{{ asset('手機點歌/LOGO_800x400px.png') }}" alt="Wolf Fox Logo">

View File

@ -5,5 +5,6 @@
<img src="{{ asset('手機點歌/BANNER-06.png') }}" alt="歌名查詢"> <img src="{{ asset('手機點歌/BANNER-06.png') }}" alt="歌名查詢">
</div> </div>
</x-slot> </x-slot>
<x-notifications/>
<livewire:pages.search-song /> <livewire:pages.search-song />
</x-app-layout> </x-app-layout>

View File

@ -5,6 +5,6 @@
<img src="{{ asset('手機點歌/BANNER-09.png') }}" alt="超級巨星 Banner"> <img src="{{ asset('手機點歌/BANNER-09.png') }}" alt="超級巨星 Banner">
</div> </div>
</x-slot> </x-slot>
<x-notifications/>
<livewire:pages.sound-control /> <livewire:pages.sound-control />
</x-app-layout> </x-app-layout>

View File

@ -5,6 +5,7 @@
<img src="{{ asset('手機點歌/BANNER-02.png') }}" alt="熱門排行"> <img src="{{ asset('手機點歌/BANNER-02.png') }}" alt="熱門排行">
</div> </div>
</x-slot> </x-slot>
<x-notifications/>
<livewire:pages.search-song :searchCategory="'Hot'" /> <livewire:pages.search-song :searchCategory="'Hot'" />
<div class="image-container"> <div class="image-container">
<img src="{{ asset('手機點歌/LOGO_800x400px.png') }}" alt="Wolf Fox Logo"> <img src="{{ asset('手機點歌/LOGO_800x400px.png') }}" alt="Wolf Fox Logo">

View File

@ -1,15 +1,7 @@
<div class="py-12 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="py-12 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"> <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
@foreach($menus as $menu)
<x-button.flat-card image="{{ asset('手機點歌/首頁-新歌快報.png') }}" href="{{ route('new-songs') }}" /> <x-button.flat-card image="{{ asset($menu['image']) }}" href="{{ route($menu['route']) }}" />
<x-button.flat-card image="{{ asset('手機點歌/首頁-熱門排行.png') }}" href="{{ route('top-ranking') }}" /> @endforeach
<x-button.flat-card image="{{ asset('手機點歌/首頁-歌名查詢.png') }}" href="{{ route('search-song') }}" />
@if($roomCode)
<x-button.flat-card image="{{ asset('手機點歌/首頁-已點歌曲.png') }}" onclick="orderSongAndNavigate()" />
<x-button.flat-card image="{{ asset('手機點歌/首頁-聲音控制.png') }}" href="{{ route('sound-control') }}" />
<x-button.flat-card image="{{ asset('手機點歌/首頁-社群媒體.png') }}" href="{{ route('social-media') }}" />
<x-button.flat-card image="{{ asset('手機點歌/首頁-真情告白.png') }}" href="{{ route('love-message') }}" />
<x-button.flat-card image="{{ asset('手機點歌/首頁-心情貼圖.png') }}" href="{{ route('mood-stickers') }}" />
@endif
</div> </div>
</div> </div>

View File

@ -62,12 +62,14 @@
<tr> <tr>
<th class="px-4 py-2 w-16 text-center">編號</th> <th class="px-4 py-2 w-16 text-center">編號</th>
<th class="px-4 py-2">歌曲</th> <th class="px-4 py-2">歌曲</th>
@if($roomSession)
<th class="px-4 py-2 w-24 text-center">操作</th> <th class="px-4 py-2 w-24 text-center">操作</th>
@endif
</tr> </tr>
</x-slot> </x-slot>
@forelse($songs as $song) @forelse($songs as $song)
<tr wire:click="orderSong({{ $song->id }})" class="hover:bg-gray-50 cursor-pointer"> <tr @if($roomSession) wire:click="orderSong({{ $song->song_id }})" @endif class="hover:bg-gray-50 cursor-pointer">
<td class="px-4 py-2 text-center">{{ $song->song_id }}</td> <td class="px-4 py-2 text-center">{{ $song->song_id }}</td>
<td class="px-4 py-2"> <td class="px-4 py-2">
<div class="flex flex-col"> <div class="flex flex-col">
@ -75,7 +77,9 @@
<span class="text-xs text-gray-500 self-end">{{ $song->str_artists_plus() }}</span> <span class="text-xs text-gray-500 self-end">{{ $song->str_artists_plus() }}</span>
</div> </div>
</td> </td>
@if($roomSession)
<td class="px-4 py-2 text-blue-500 text-center">點歌</td> <td class="px-4 py-2 text-blue-500 text-center">點歌</td>
@endif
</tr> </tr>
@empty @empty
<tr> <tr>

View File

@ -1,9 +1,10 @@
<div class="py-12 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="py-12 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-3 gap-6"> <div class="grid grid-cols-3 gap-6">
@foreach($buttons as $btn) @foreach($buttons as $key => $btn)
<x-button.flat-card <x-button.flat-card
image="{{ asset('手機點歌/'.$btn['img']) }}" image="{{ asset('手機點歌/'.$btn['img']) }}"
wire:click="sendVolumeControl('{{ $btn['action'] }}')" wire:click="sendControl('{{ $key }}')"
label="{{ $btn['label'] ?? '' }}"
/> />
@endforeach @endforeach
</div> </div>

View File

@ -11,9 +11,11 @@ Route::view('/welcome', 'livewire.app.welcome')->name('welcome');
Route::view('/new-songs', 'livewire.app.new-songs')->name('new-songs'); Route::view('/new-songs', 'livewire.app.new-songs')->name('new-songs');
Route::view('/top-ranking', 'livewire.app.top-ranking')->name('top-ranking'); Route::view('/top-ranking', 'livewire.app.top-ranking')->name('top-ranking');
Route::view('/search-song', 'livewire.app.search-song')->name('search-song'); Route::view('/search-song', 'livewire.app.search-song')->name('search-song');
Route::view('/clicked-song', 'livewire.app.clicked-song')->name('clicked-song'); Route::middleware('room_api_token')->group(function () {
Route::view('/sound-control', 'livewire.app.sound-control')->name('sound-control'); Route::view('/clicked-song', 'livewire.app.clicked-song')->name('clicked-song');
Route::view('/love-message', 'livewire.app.love-message')->name('love-message'); Route::view('/sound-control', 'livewire.app.sound-control')->name('sound-control');
Route::view('/love-message', 'livewire.app.love-message')->name('love-message');
});
Route::middleware(['auth'])->group(function () { Route::middleware(['auth'])->group(function () {
// Profile // Profile

View File

@ -90,3 +90,31 @@ php artisan migrate
php artisan transfer:sqlite sqlite/tempUser.sqlite --sync php artisan transfer:sqlite sqlite/tempUser.sqlite --sync
php artisan queue:work --daemon --timeout=3600 --tries=1 --queue=default php artisan queue:work --daemon --timeout=3600 --tries=1 --queue=default
npm install && npm run build
https://ktvremote.test/?room_code=abc123
http://192.168.12.14:9090/wnknwl1f3yy/windows.html
php artisan storage:link
# 執行全部測試
php artisan test
# 執行特定檔案
php artisan test tests/Feature/InternalHtmlTest.php
# 執行特定測試方法
php artisan test --filter test_internal_html_page
# 執行全部測試
vendor/bin/phpunit
# 執行特定檔案
vendor/bin/phpunit tests/Feature/InternalHtmlTest.php
# 執行特定測試方法
vendor/bin/phpunit --filter test_internal_html_page
02 2171 1168 89843