diff --git a/app/Enums/OrderedSongStatus.php b/app/Enums/OrderedSongStatus.php new file mode 100644 index 0000000..4d05b3e --- /dev/null +++ b/app/Enums/OrderedSongStatus.php @@ -0,0 +1,35 @@ + __('enums.NotPlayed'), + self::Playing => __('enums.Playing'), + self::Played => __('enums.Played'), + self::NoFile => __('enums.NoFile'), + self::Skipped => __('enums.Skipped'), + self::InsertPlayback => __('enums.InsertPlayback'), + }; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/RoomSongController.php b/app/Http/Controllers/Api/RoomSongController.php new file mode 100644 index 0000000..f59831c --- /dev/null +++ b/app/Http/Controllers/Api/RoomSongController.php @@ -0,0 +1,364 @@ +getRoomSession($request->api_token) ; + + // 找這個 session 的最大 order_number,下一首加 1 + $lastOrder = OrderedSong::where('room_session_id', $roomSession->id)->max('order_number'); + $orderNumber = $lastOrder ? $lastOrder + 1 : 1; + + // 取得歌曲名稱 + $song = SongLibraryCache::findOrFail($request->song_id); + + // 建立 OrderedSong + $orderedSong = OrderedSong::create([ + 'room_session_id' => $roomSession->id, + 'from_by' => $request->from_by, + 'order_number' => $orderNumber, + 'status' => $request->status, + 'song_id' => $request->song_id, + 'song_name' => $song->song_name, + 'artist_name' => $song->str_artists_plus(), + 'ordered_at' => now(), + ]); + + // 檢查這首歌在此 session 是否第一次點 + $countInSession = OrderedSong::where('room_session_id', $roomSession->id) + ->where('song_id', $request->song_id) + ->count(); + + if ($countInSession === 1) { // 第一次點才加 + $song->increment('song_counts'); + } + $this->sys($roomSession,$orderedSong); + return ApiResponse::success([ + 'ordered_song' => $orderedSong, + 'next_song_name' => $this->nextSongName($roomSession), + ]); + } + private function sys($roomSession,$orderedSong) + { + $validated = [ + 'api_token' => $roomSession->api_token, + 'order_number' => $orderedSong->order_number, + 'from_by' => $orderedSong->from_by, + 'song_id' => $orderedSong->song_id, + 'song_name' => $orderedSong->song_name, + 'artist_name' => $orderedSong->artist_name, + 'status' => $orderedSong->status, + 'ordered_at' => $orderedSong->ordered_at, + 'started_at' => $orderedSong->started_at, + 'finished_at' => $orderedSong->finished_at, + ]; + $response = (new MachineStatusForwarder( + $roomSession->room->branch->external_ip, + "/api/room/sync-order-song", + $validated + ))->forward(); + } + public function syncOrderSong(SyncOrderSongRequest $request) + { + $roomSession = $this->getRoomSession($request->api_token) ; + // 建立或更新 OrderedSong + $orderedSong = OrderedSong::updateOrCreate( + [ + 'room_session_id' => $roomSession->id, + 'order_number' => $request->order_number, + ], + [ + 'from_by' => $request->from_by, + 'song_id' => $request->song_id, + 'song_name' => $request->song_name, + 'artist_name' => $request->artist_name, + 'status' => $request->status, + 'ordered_at' => $request->ordered_at, + 'started_at' => $request->started_at, + 'finished_at' => $request->finished_at, + ] + ); + + // 檢查這首歌在此 session 是否第一次點 + if ($orderedSong->wasRecentlyCreated) { + $countInSession = OrderedSong::where('room_session_id', $roomSession->id) + ->where('song_id', $request->song_id)->count(); + if ($countInSession === 1) { // 第一次點才加 + $song->increment('song_counts'); + } + } + + return ApiResponse::success([ + 'ordered_song' => $orderedSong + ]); + } + /** + * @OA\Get( + * path="/api/room/ordered-songs", + * summary="取得包廂點歌列表", + * description="取得指定包廂的已播、正在播放、待播/插播歌曲列表", + * tags={"Room Control Song"}, + * security={{"Authorization":{}}}, + * @OA\Parameter(ref="#/components/parameters/ApiTokenQuery"), + * @OA\Response( + * response=200, + * description="成功取得點歌列表", + * @OA\JsonContent( + * type="object", + * @OA\Property( + * property="played", + * type="array", + * description="已播放 (Played, Skipped, NoFile)", + * @OA\Items(ref="#/components/schemas/OrderedSong") + * ), + * @OA\Property( + * property="playing", + * type="array", + * description="正在播放中 (Playing)", + * @OA\Items(ref="#/components/schemas/OrderedSong") + * ), + * @OA\Property( + * property="not_played", + * type="array", + * description="未播放 (NotPlayed, InsertPlayback)", + * @OA\Items(ref="#/components/schemas/OrderedSong") + * ) + * ) + * ), + * @OA\Response( + * response=422, + * description="驗證失敗", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="message", type="string", example="The given data was invalid."), + * @OA\Property(property="errors", type="object") + * ) + * ) + * ) + */ + public function listOrderedSongs(RoomSongRequest $request) + { + $roomSession = $this->getRoomSession($request->api_token) ; + // 已結束 (finished + canceled) + $played = OrderedSong::where('room_session_id', $roomSession->id) + ->whereIn('status', ['Played', 'Skipped', 'NoFile']) + ->orderByDesc('finished_at') + ->get(); + $playing = OrderedSong::where('room_session_id', $roomSession->id) + ->whereIn('status', ['Playing']) + ->get(); + + // 正在播 + 插播 + 待播 + $not_played = OrderedSong::where('room_session_id', $roomSession->id) + ->whereIn('status', ['Playing', 'InsertPlayback', 'NotPlayed']) + ->orderByRaw("FIELD(status, 'Playing', 'InsertPlayback', 'NotPlayed')") // playing > InsertPlayback > NotPlayed + ->orderByRaw("CASE + WHEN status = 'InsertPlayback' THEN ordered_at END DESC") // InsertPlayback 越後排越前 + ->orderByRaw("CASE + WHEN status = 'NotPlayed' THEN ordered_at END ASC") // NotPlayed 越後排越後 + ->get(); + + return ApiResponse::success([ + 'played' => $played, + 'playing' => $playing, + 'not_played' => $not_played, + ]); + } + /** + * @OA\Post( + * path="/api/room/current-song", + * summary="取得目前播放中的歌曲", + * description="回傳當前房間正在播放的歌曲資訊 (包含部分 song 欄位)", + * tags={"Room Control Song"}, + * security={{"Authorization":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent(ref="#/components/schemas/RoomSongRequest") + * ), + * @OA\Response( + * response=200, + * description="成功回傳目前播放的歌曲", + * @OA\JsonContent( + * type="object", + * @OA\Property( + * property="success", + * type="boolean", + * example=true + * ), + * @OA\Property( + * property="data", + * type="object", + * @OA\Property( + * property="current", + * ref="#/components/schemas/OrderedSongWithPartialSong" + * ) + * ) + * ) + * ) + * ) + */ + public function currentSong(RoomSongRequest $request) + { + $roomSession = $this->getRoomSession($request->api_token) ; + $current = OrderedSong::where('room_session_id', $roomSession->id) + ->where('status', 'Playing') + ->withPartialSong() + ->first(); + return ApiResponse::success([ + 'current' => $current , + ]); + } + /** + * @OA\Post( + * path="/api/room/next-song", + * summary="播放下一首歌曲", + * description="將目前播放的歌標記為結束,並將下一首設為播放中,回傳下首與下下首資訊。", + * tags={"Room Control Song"}, + * security={{"Authorization":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent(ref="#/components/schemas/RoomSongRequest") + * ), + * @OA\Response( + * response=200, + * description="成功回傳歌曲資訊", + * @OA\JsonContent( + * type="object", + * @OA\Property( + * property="current", + * type="object", + * nullable=true, + * ref="#/components/schemas/OrderedSongWithPartialSong" + * ), + * @OA\Property( + * property="next", + * type="object", + * nullable=true, + * ref="#/components/schemas/OrderedSongWithPartialSong" + * ) + * ) + * ), + * @OA\Response( + * response=401, + * description="未授權或 Token 錯誤" + * ), + * @OA\Response( + * response=404, + * description="找不到房間或歌曲" + * ) + * ) + */ + public function nextSong(RoomSongRequest $request) + { + $roomSession = $this->getRoomSession($request->api_token) ; + // 找目前正在播 + $current = OrderedSong::where('room_session_id', $roomSession->id) + ->where('status', 'Playing') + ->first(); + if ($current) { + // 把當前設為 finished,並記錄結束時間 + $current->update([ + 'status' => 'Played', + 'finished_at' => now(), + ]); + $this->sys($roomSession,$current); + } + // 撈出候播清單(下首 + 下下首) + $queue = OrderedSong::where('room_session_id', $roomSession->id) + ->whereIn('status', ['InsertPlayback', 'NotPlayed']) + ->withPartialSong() + ->orderByRaw("FIELD(status, 'InsertPlayback', 'NotPlayed')") // InsertPlayback > NotPlayed + ->orderByRaw("CASE WHEN status = 'InsertPlayback' THEN ordered_at END DESC") // InsertPlayback 後插播的先播 + ->orderByRaw("CASE WHEN status = 'NotPlayed' THEN ordered_at END ASC") // NotPlayed 先點的先播 + ->limit(2) + ->get(); + $current =$queue->get(0); + if ($current) { + $current->update([ + 'status' => 'Playing', + 'started_at' => now(), + ]); + $this->sys($roomSession,$current); + } + return ApiResponse::success([ + 'current' => $current,// 下首 + 'next' => $queue->get(1) ?? null,// 下下首 + ]); + } + + private function nextSongName(RoomSession $roomSession) + { + // 找下首 + $next = OrderedSong::where('room_session_id', $roomSession->id) + ->whereIn('status', ['InsertPlayback', 'NotPlayed']) + ->with('song') + ->orderByRaw("FIELD(status, 'InsertPlayback', 'NotPlayed')") + ->orderByRaw("CASE WHEN status = 'InsertPlayback' THEN ordered_at END DESC") + ->orderByRaw("CASE WHEN status = 'NotPlayed' THEN ordered_at END ASC") + ->first(); + return $next?->song?->name; + + } + /** + * 取得對應的 RoomSession + */ + private function getRoomSession($api_token): RoomSession + { + return RoomSession::where('api_token', $api_token) + ->whereIn('status', ['active', 'maintain']) + ->firstOrFail(); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/RoomControlController.php b/app/Http/Controllers/RoomControlController.php index d89c12c..00ae616 100644 --- a/app/Http/Controllers/RoomControlController.php +++ b/app/Http/Controllers/RoomControlController.php @@ -4,7 +4,8 @@ namespace App\Http\Controllers; use App\Http\Requests\SendRoomSwitchCommandRequest; use App\Http\Requests\ReceiveRoomRegisterRequest; -use App\Http\Requests\ReceiveRoomStatusDataRequest; +use App\Http\Requests\HeartBeatRequest; +use App\Http\Requests\SessionRequest; use App\Services\TcpSocketClient; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Auth; @@ -102,6 +103,87 @@ class RoomControlController extends Controller 'other_set' => $otherSet ]); } + /** + * @OA\Post( + * path="/api/room/session", + * summary="取得包廂狀態", + * description="依據 hostname 與 branch_name 取得或建立房間,並回傳房間資料與最新 session", + * tags={"Room Control"}, + * security={{"Authorization":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent(ref="#/components/schemas/SessionRequest") + * ), + * @OA\Response( + * response=200, + * description="成功取得房間狀態", + * @OA\JsonContent( + * type="object", + * @OA\Property( + * property="room", + * type="object", + * ref="#/components/schemas/Room" + * ) + * ) + * ), + * @OA\Response( + * response=422, + * description="驗證失敗", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="message", type="string", example="The given data was invalid."), + * @OA\Property(property="errors", type="object") + * ) + * ) + * ) + */ + public function session(SessionRequest $request): JsonResponse + { + $validated = $request->validated(); + $roomType = null; + $roomName = null; + $floor = null; + // 從 room_name(例如 PC101, SVR01)中擷取 type 與 name + if (preg_match('/^([A-Za-z]+)(\d+)$/', $validated['hostname'], $matches)) { + $roomType = strtolower($matches[1]); // 'PC' → 'pc' + $roomName = $matches[2]; // '101' + if($roomType =='svr'){ + $floor = '0'; + } else{ + $floor = (int) substr($roomName, 0, 1); + } + } + $branch=Branch::where('name',$validated['branch_name'])->first(); + $room = Room::firstOrNew([ + 'branch_id' => $branch->id, + 'floor' => $floor, + 'name' => $roomName, + 'type' => $roomType, + ]); + $room->internal_ip = $validated['ip']; + $room->port = 1000; + $room->is_online=1; + $room->touch(); // 更新 updated_at + $room->save(); + $room->load('latestSession'); + if ($room->latestSession) { + $room->latestSession->api_token = !empty($validated['token']) + ? $validated['token'] + : bin2hex(random_bytes(32)); + $room->latestSession->save(); + $validated['token']=$room->latestSession->api_token; + } + + $response = (new MachineStatusForwarder( + $branch->external_ip, + "/api/room/session", + $validated + ))->forward(); + return ApiResponse::success([ + 'room' => $room->latestSession, + ]); + + } /** * @OA\Post( @@ -113,7 +195,7 @@ class RoomControlController extends Controller * security={{"Authorization":{}}}, * @OA\RequestBody( * required=true, - * @OA\JsonContent(ref="#/components/schemas/ReceiveRoomStatusDataRequest") + * @OA\JsonContent(ref="#/components/schemas/HeartBeatRequest") * ), * @OA\Response( * response=200, @@ -149,7 +231,7 @@ class RoomControlController extends Controller * ) * ) */ - public function StatusReport(ReceiveRoomStatusDataRequest $request) + public function HeartBeat(HeartBeatRequest $request) { $validated = $request->validated(); $roomType = null; @@ -265,6 +347,8 @@ class RoomControlController extends Controller return ApiResponse::error('房間未設定 IP 或 Port'); } $room->status=$validated['command']; + $room->log_source='api'; + $room->log_message='sendSwitch'; $room->started_at=$validated['started_at']; $room->ended_at=$validated['ended_at']; $room->save(); @@ -279,7 +363,6 @@ class RoomControlController extends Controller }; $data = $suffix . "," . $signal; - //dd($data); $client = new TcpSocketClient($room->internal_ip, $room->port); try { $response = $client->send($data); diff --git a/app/Http/Requests/ReceiveRoomStatusDataRequest.php b/app/Http/Requests/HeartBeatRequest.php similarity index 91% rename from app/Http/Requests/ReceiveRoomStatusDataRequest.php rename to app/Http/Requests/HeartBeatRequest.php index 7e62797..caba528 100644 --- a/app/Http/Requests/ReceiveRoomStatusDataRequest.php +++ b/app/Http/Requests/HeartBeatRequest.php @@ -6,7 +6,7 @@ use Illuminate\Foundation\Http\FormRequest; /** * @OA\Schema( - * schema="ReceiveRoomStatusDataRequest", + * schema="HeartBeatRequest", * required={"branch_name","hostname", "ip"}, * @OA\Property(property="branch_name", type="string", example="測試"), * @OA\Property(property="hostname", type="string", example="PC101"), @@ -16,7 +16,7 @@ use Illuminate\Foundation\Http\FormRequest; * @OA\Property(property="disk", type="numeric", example="158266.49"), * ) */ -class ReceiveRoomStatusDataRequest extends ApiRequest +class HeartBeatRequest extends ApiRequest { /** * Get the validation rules that apply to the request. diff --git a/app/Http/Requests/OrderSongRequest.php b/app/Http/Requests/OrderSongRequest.php new file mode 100644 index 0000000..b0b3f07 --- /dev/null +++ b/app/Http/Requests/OrderSongRequest.php @@ -0,0 +1,33 @@ + [ + 'required', + Rule::exists('room_sessions', 'api_token') + ->where(fn ($q) => $q->whereIn('status', ['active', 'maintain'])) + ], + 'song_id' => 'required|exists:song_library_cache,song_id', + 'status' => 'required|in:NotPlayed,InsertPlayback,Skipped', + 'from_by' => 'nullable', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/RoomSongRequest.php b/app/Http/Requests/RoomSongRequest.php new file mode 100644 index 0000000..257afd1 --- /dev/null +++ b/app/Http/Requests/RoomSongRequest.php @@ -0,0 +1,43 @@ + [ + 'required', + Rule::exists('room_sessions', 'api_token') + ->where(fn ($q) => $q->whereIn('status', ['active', 'maintain'])) + ] + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/SessionRequest.php b/app/Http/Requests/SessionRequest.php new file mode 100644 index 0000000..6ce2097 --- /dev/null +++ b/app/Http/Requests/SessionRequest.php @@ -0,0 +1,32 @@ +|string> + */ + public function rules(): array + { + return [ + 'branch_name' =>'required|string|exists:branches,name', + 'hostname' => 'required|string', + 'ip' => 'required|string', + 'token' => 'nullable|string', + ]; + } +} diff --git a/app/Http/Requests/SyncOrderSongRequest.php b/app/Http/Requests/SyncOrderSongRequest.php new file mode 100644 index 0000000..580c573 --- /dev/null +++ b/app/Http/Requests/SyncOrderSongRequest.php @@ -0,0 +1,25 @@ + 'required|exists:room_sessions,api_token', + 'from_by' => 'nullable', + 'order_number' => 'required|integer', + 'song_id' => 'required|exists:song_library_cache,song_id', + 'song_name' => 'nullable', + 'artist_name' => 'nullable', + 'status' => 'required|in:NotPlayed,Playing,Played,NoFile,Skipped,InsertPlayback', + 'ordered_at' => 'required', + 'started_at' => 'nullable', + 'finished_at' => 'nullable', + ]; + } +} \ No newline at end of file diff --git a/app/Livewire/Modals/RoomDetailModal.php b/app/Livewire/Modals/RoomDetailModal.php index cb73f06..95dfb65 100644 --- a/app/Livewire/Modals/RoomDetailModal.php +++ b/app/Livewire/Modals/RoomDetailModal.php @@ -4,7 +4,7 @@ namespace App\Livewire\Modals; use App\Models\Room; use App\Models\Branch; - +use Illuminate\Support\Carbon; use Livewire\Component; use App\Services\ApiClient; use Illuminate\Support\Facades\Auth; @@ -36,20 +36,20 @@ class RoomDetailModal extends Component } public function startNotify() { - $data = $this->buildNotifyData('maintain', null, null); + $data = $this->buildNotifyData('maintain', Carbon::now(), null); $this->send($data); } public function stopNotify() { - $data = $this->buildNotifyData('closed', null, null); + $data = $this->buildNotifyData('closed', null, Carbon::now()); $chk =$this->send($data); } public function fireNotify() { - $data = $this->buildNotifyData('fire', null, null); + $data = $this->buildNotifyData('fire', null, Carbon::now()); $this->send($data); } diff --git a/app/Livewire/Tables/RoomStatusLogTable.php b/app/Livewire/Tables/RoomStatusLogTable.php index 6fb4d13..a4afa4a 100644 --- a/app/Livewire/Tables/RoomStatusLogTable.php +++ b/app/Livewire/Tables/RoomStatusLogTable.php @@ -2,7 +2,9 @@ namespace App\Livewire\Tables; +use App\Models\Branch; use App\Models\RoomStatusLog; +use App\Enums\RoomType; use Illuminate\Support\Carbon; use Illuminate\Database\Eloquent\Builder; use PowerComponents\LivewirePowerGrid\Button; @@ -37,28 +39,47 @@ final class RoomStatusLogTable extends PowerGridComponent public function datasource(): Builder { - return RoomStatusLog::query()->latest();; + return RoomStatusLog::with(['room', 'branch'])->latest(); } public function relationSearch(): array { - return []; + return [ + 'branch' => ['name'], + 'room' => ['name'], + 'user' => ['name'], + ]; } public function fields(): PowerGridFields { return PowerGrid::fields() ->add('id') + ->add('branch_name', function (RoomStatusLog $model) { + return $model->branch?->name; + }) + ->add('room_type', function (RoomStatusLog $model) { + return $model->room?->type->labelPowergridFilter(); + }) ->add('room_name', function (RoomStatusLog $model) { - return $model->room?->type->labelPowergridFilter().$model->room?->name; + return $model->room?->name; }) ->add('user_name', function (RoomStatusLog $model){ return $model->user?->name; }) + ->add('is_online_img', fn ($model) => + [ + $model->is_online ? 'check-circle' : 'x-circle' => [ + 'text-color' => $model->is_online ? 'text-green-600' : 'text-red-600', + ], + ]) ->add('status_str',function (RoomStatusLog $model){ return $model->status->labelPowergridFilter(); }) + ->add('started_at') + ->add('ended_at') ->add('message') + ->add('source') ->add('created_at'); } @@ -66,17 +87,48 @@ final class RoomStatusLogTable extends PowerGridComponent { $column=[]; $column[]=Column::make(__('room-status-log.id'), 'id'); - $column[]=Column::make(__('room-status-log.room'), 'room_name'); + $column[]=Column::make(__('room-status-log.branch'), 'branch_name'); + $column[]=Column::make(__('room-status-log.room_type'), 'room_type'); + $column[]=Column::make(__('room-status-log.room_name'), 'room_name'); $column[]=Column::make(__('room-status-log.user'), 'user_name'); + $column[]=Column::make(__('room-status-log.is_online'), 'is_online_img')->template(); $column[]=Column::make(__('room-status-log.status'), 'status_str'); + $column[]=Column::make(__('room-status-log.started_at'), 'started_at'); + $column[]=Column::make(__('room-status-log.ended_at'), 'ended_at'); $column[]=Column::make(__('room-status-log.message'), 'message'); + $column[]=Column::make(__('room-status-log.source'), 'source'); $column[]=Column::make(__('room-status-log.created_at'), 'created_at'); return $column; } + public function rowTemplates(): array + { + return [ + 'check-circle' => ' + + ', + 'x-circle' => ' + + ', + ]; + } public function filters(): array { + $branches = Branch::query()->orderBy('name')->get()->map(fn($branch) => (object)[ + 'value' => $branch->id, + 'label' => $branch->name, + ]); + //dd($branches); return [ + Filter::enumSelect('branch_name','branch_id') + ->datasource($branches) + ->optionLabel('label'), + Filter::inputText('room_type') + ->placeholder('輸入"pc","svr"') + ->filterRelation('room','type'), + Filter::inputText('room_name') + ->placeholder('輸入包廂名稱') + ->filterRelation('room','name') ]; } } diff --git a/app/Models/OrderedSong.php b/app/Models/OrderedSong.php new file mode 100644 index 0000000..552088c --- /dev/null +++ b/app/Models/OrderedSong.php @@ -0,0 +1,88 @@ + 'datetime', + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + 'status' => \App\Enums\OrderedSongStatus::class, + ]; + + + public function session() + { + return $this->belongsTo(RoomSession::class, 'room_session_id'); + } + + public function song() + { + return $this->belongsTo(SongLibraryCache::class); + } + public function scopeWithPartialSong($query) + { + return $query->with([ + 'song' => function ($q) { + $q->select('id', 'name','filename','db_change','vocal','situation'); // 精簡版 + } + ]); + } +} diff --git a/app/Models/Room.php b/app/Models/Room.php index 4721b71..0c690f3 100644 --- a/app/Models/Room.php +++ b/app/Models/Room.php @@ -2,7 +2,8 @@ namespace App\Models; -use Illuminate\Support\Carbon; +use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use App\Traits\LogsModelActivity; @@ -22,11 +23,15 @@ use App\Traits\LogsModelActivity; * @OA\Property(property="ended_at", type="string", format="date-time", example=null), * ) */ + class Room extends Model { /** @use HasFactory<\Database\Factories\ArtistFactory> */ use HasFactory, LogsModelActivity; + public string $log_message = 'BranchForm-add'; + public string $log_source = 'manual'; + protected $fillable = [ 'branch_id', 'floor', @@ -39,6 +44,12 @@ class Room extends Model 'started_at', 'ended_at', ]; + protected $attributes = [ + 'type' => \App\Enums\RoomType::Unset, + 'floor' => 1, + 'is_online' => false, + 'status' => \App\Enums\RoomStatus::Error, + ]; //protected $hidden = [ // 'internal_ip', @@ -55,13 +66,12 @@ class Room extends Model 'status' => \App\Enums\RoomStatus::class, 'started_at' => 'datetime', 'ended_at' => 'datetime', - ]; public function str_started_at(){ $str ="Not Set"; if($this->started_at !=null){ - $str = $this->started_at; + $str = $this->started_at; } return $str; } @@ -77,8 +87,30 @@ class Room extends Model public function branch() { return $this->belongsTo(Branch::class); } + public function full_name(){ + return $this->type->labelPowergridFilter().$this->name; + } public function statusLogs() { return $this->hasMany(RoomStatusLog::class); } + public function sessions(): HasMany + { + return $this->hasMany(RoomSession::class); + } + /** + * 最新的一筆 session + */ + public function latestSession(): HasOne + { + return $this->hasOne(RoomSession::class)->latestOfMany(); + } + + /** + * 方便取得最新狀態 + */ + public function getLatestStatusAttribute(): string + { + return $this->latestSession?->status ?? 'error'; + } } diff --git a/app/Models/RoomSession.php b/app/Models/RoomSession.php new file mode 100644 index 0000000..7b17096 --- /dev/null +++ b/app/Models/RoomSession.php @@ -0,0 +1,42 @@ + 'datetime', + 'ended_at' => 'datetime', + ]; + + // 狀態常數 + public const STATUS_ACTIVE = 'active'; + public const STATUS_CLOSED = 'closed'; + public const STATUS_FORCE_CLOSED = 'force_closed'; + public const STATUS_FIRE_CLOSED = 'fire_closed'; + + // 模式常數 + public const MODE_NORMAL = 'normal'; + public const MODE_VIP = 'vip'; + public const MODE_TEST = 'test'; + + public function room() + { + return $this->belongsTo(Room::class); + } +} diff --git a/app/Models/RoomStatusLog.php b/app/Models/RoomStatusLog.php index 1c7dca5..7c7f7b2 100644 --- a/app/Models/RoomStatusLog.php +++ b/app/Models/RoomStatusLog.php @@ -13,18 +13,30 @@ class RoomStatusLog extends Model protected $fillable = [ + 'branch_id', 'room_id', 'user_id', + 'is_online', 'status', + 'started_at', + 'ended_at', 'message', + 'source', ]; protected $casts = [ + 'is_online' => 'boolean', 'status' => \App\Enums\RoomStatus::class, + 'started_at' => 'datetime', + 'ended_at' => 'datetime', + ]; public function user(){ return $this->belongsTo(User::class); } + public function branch() { + return $this->belongsTo(Branch::class); + } public function room() { return $this->belongsTo(Room::class); } diff --git a/app/Models/SongLibraryCache.php b/app/Models/SongLibraryCache.php index 16ddd10..4f208c2 100644 --- a/app/Models/SongLibraryCache.php +++ b/app/Models/SongLibraryCache.php @@ -39,4 +39,8 @@ class SongLibraryCache extends Model 'updated_at', ]; + public function str_artists_plus(): string + { + return ($this->artistB!=null) ? $this->artistA ." + ".$this->artistB :$this->artistA; + } } diff --git a/app/Observers/RoomObserver.php b/app/Observers/RoomObserver.php index d8a3649..4ebfefc 100644 --- a/app/Observers/RoomObserver.php +++ b/app/Observers/RoomObserver.php @@ -3,6 +3,7 @@ namespace App\Observers; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Arr; use App\Models\Room; use App\Models\RoomStatusLog; @@ -14,7 +15,8 @@ class RoomObserver */ public function created(Room $room): void { - // + // 建立初始 log + $this->createStatusLog($room); } /** @@ -23,13 +25,32 @@ class RoomObserver public function updated(Room $room): void { // 檢查是否有變更狀態 - if ($room->wasChanged('status')) { - RoomStatusLog::create([ - 'room_id' => $room->id, - 'user_id' => Auth::id(), // 若是 console 或系統自動操作可能為 null - 'status' => $room->status, - 'message' => 'started_at:'.$room->started_at.',ended_at:'.$room->ended_at, - ]); + if ($room->wasChanged()) { + $this->createStatusLog($room); + } + if ($room->isDirty('status')) { + $now = now(); + + // 找到最後一筆未結束 session + $lastSession = $room->sessions()->whereNull('ended_at')->latest('started_at')->first(); + + if ($lastSession) { + // 結束上一筆 session + $lastSession->update([ + 'status' => $room->status->value, + 'ended_at' => $now, + ]); + } + + // 如果狀態是 active 或 maintain,開新 session + if (in_array($room->status->value, ['active', 'maintain'])) { + $mode = $room->status->value === 'active' ? 'normal' : 'test'; + $room->sessions()->create([ + 'mode' => $mode, + 'status' => $room->status->value, + 'started_at' => $now, + ]); + } } } @@ -38,22 +59,38 @@ class RoomObserver */ public function deleted(Room $room): void { - // + $message = sprintf( + "%s:%s%s (%s:%s) 已刪除", + $room->branch->name, + $room->type->value, + $room->name, + $room->internal_ip, + $room->port + ); + $this->createStatusLog($room,$message); + } + + /** + * 建立 RoomStatusLog + */ + private function createStatusLog(Room $room,$log_message =null): void + { + $message=($log_message !=null)?$log_message:$room->log_message ?? ''; + RoomStatusLog::create([ + 'branch_id' => $room->branch->id, + 'room_id' => $room->id, + 'user_id' => Auth::id() ?? 0, + 'is_online' => $room->is_online, + 'status' => $room->status, + 'started_at' => $room->started_at, + 'ended_at' => $room->ended_at, + 'message' => $message, + 'source' => $this->getSource($room), + ]); } - /** - * Handle the Room "restored" event. - */ - public function restored(Room $room): void + private function getSource(Room $room): string { - // - } - - /** - * Handle the Room "force deleted" event. - */ - public function forceDeleted(Room $room): void - { - // + return app()->runningInConsole() ? 'system' : ($room->log_source ?? 'manual'); } } diff --git a/database/migrations/2025_05_23_055312_create_room_status_logs_table.php b/database/migrations/2025_08_18_055312_create_room_status_logs_table.php similarity index 55% rename from database/migrations/2025_05_23_055312_create_room_status_logs_table.php rename to database/migrations/2025_08_18_055312_create_room_status_logs_table.php index a9f91b3..37bb00a 100644 --- a/database/migrations/2025_05_23_055312_create_room_status_logs_table.php +++ b/database/migrations/2025_08_18_055312_create_room_status_logs_table.php @@ -13,10 +13,19 @@ return new class extends Migration { Schema::create('room_status_logs', function (Blueprint $table) { $table->id(); - $table->foreignId('room_id')->nullable(); - $table->foreignId('user_id')->nullable(); // 操作者,可為 null(系統) + $table->unsignedBigInteger('branch_id'); + $table->unsignedBigInteger('room_id'); + $table->unsignedBigInteger('user_id'); + $table->tinyInteger('is_online')->default(0); $table->enum('status', ['active', 'closed','fire', 'error', 'maintain']); + $table->timestamp('started_at')->nullable(); + $table->timestamp('ended_at')->nullable(); $table->text('message')->nullable(); // 可填異常原因或操作說明 + $table->enum('source', ['system', 'manual', 'api'])->default('manual'); + $table->index('branch_id'); + $table->index('room_id'); + $table->index('user_id'); + $table->index('started_at'); $table->timestamps(); }); } diff --git a/database/migrations/2025_08_18_103818_create_room_sessions_table.php b/database/migrations/2025_08_18_103818_create_room_sessions_table.php new file mode 100644 index 0000000..664ca15 --- /dev/null +++ b/database/migrations/2025_08_18_103818_create_room_sessions_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('room_id')->constrained()->cascadeOnDelete(); + $table->enum('status', ['active', 'closed','fire', 'error', 'maintain'])->default('error'); + $table->timestamp('started_at')->nullable(); + $table->timestamp('ended_at')->nullable(); + $table->enum('mode', ['normal', 'test'])->default('normal'); + $table->string('close_reason')->nullable(); + $table->string('api_token', 64)->unique()->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('room_sessions'); + } +}; diff --git a/database/migrations/2025_08_18_104152_create_ordered_songs_table.php b/database/migrations/2025_08_18_104152_create_ordered_songs_table.php new file mode 100644 index 0000000..0fee13f --- /dev/null +++ b/database/migrations/2025_08_18_104152_create_ordered_songs_table.php @@ -0,0 +1,45 @@ +id(); + $table->foreignId('room_session_id')->constrained('room_sessions')->cascadeOnDelete(); + $table->string('from_by')->nullable(); + $table->integer('order_number')->default(0); + + // 歌曲資訊 + $table->unsignedBigInteger('song_id'); + $table->string('song_name')->nullable(); + $table->string('artist_name')->nullable(); + + + // 狀態:未播 / 播放中 / 已播 / 刪除 + $table->enum('status', ['NotPlayed', 'Playing', 'Played', 'NoFile', 'Skipped' , 'InsertPlayback'])->default('NotPlayed'); + + // 播放流程 + $table->timestamp('ordered_at')->useCurrent(); // 點歌時間 + $table->timestamp('started_at')->nullable(); // 開始播放時間 + $table->timestamp('finished_at')->nullable(); // 播放結束時間 + + $table->unique(['room_session_id', 'order_number']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ordered_songs'); + } +}; diff --git a/resources/lang/zh-tw/enums.php b/resources/lang/zh-tw/enums.php index 2e06d66..9266a12 100644 --- a/resources/lang/zh-tw/enums.php +++ b/resources/lang/zh-tw/enums.php @@ -2,6 +2,12 @@ return [ 'Unset' => '未定義', + 'NotPlayed' =>'未播放', + 'Playing' =>'播放中', + 'Played' =>'播畢', + 'NoFile' =>'無文件', + 'Skipped' =>'刪除', + 'InsertPlayback' =>'插播', 'user.gender.Male' =>'男', 'user.gender.Female' =>'女', 'user.gender.Other' =>'其他', diff --git a/resources/lang/zh-tw/room-status-log.php b/resources/lang/zh-tw/room-status-log.php index 40ae803..b875073 100644 --- a/resources/lang/zh-tw/room-status-log.php +++ b/resources/lang/zh-tw/room-status-log.php @@ -4,10 +4,17 @@ return [ 'list' => '包廂狀態紀錄', 'id' => '編號', + 'branch' => '分店', 'room' => '包廂', + 'room_type' => '包廂類別', + 'room_name' => '包廂名稱', 'user' => '操成者', + 'is_online' => '在線?', 'status' => '狀態', + 'started_at' => '開始於', + 'ended_at' => '結束於', 'message' => '紀錄', + 'source' => '來源', 'created_at' => '建立於' ]; \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index daa9010..a5e8d6a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\ArtistController; use App\Http\Controllers\AuthController; use App\Http\Controllers\RoomControlController; +use App\Http\Controllers\Api\RoomSongController; use App\Http\Controllers\SqliteUploadController; Route::post('/room/receiveRegister', [RoomControlController::class, 'receiveRegister']); @@ -11,6 +12,14 @@ Route::post('/room/receiveRegister', [RoomControlController::class, 'receiveRegi Route::middleware('auth:sanctum')->group(function () { Route::get('/profile', [AuthController::class, 'profile']); Route::post('/room/sendSwitch', [RoomControlController::class, 'sendSwitch']); - Route::post('/room/heartbeat', [RoomControlController::class, 'StatusReport']); + Route::post('/room/heartbeat', [RoomControlController::class, 'HeartBeat']); + Route::post('/room/session',[RoomControlController::class, 'session']); + + Route::post('/room/order-song', [RoomSongController::class, 'orderSong']); + Route::get ('/room/ordered-songs', [RoomSongController::class, 'listOrderedSongs']); + Route::post ('/room/current-song', [RoomSongController::class, 'currentSong']); + Route::post ('/room/next-song', [RoomSongController::class, 'nextSong']); + Route::post ('/room/sync-order-song', [RoomSongController::class, 'syncOrderSong']); + Route::post('/upload-sqlite', [SqliteUploadController::class, 'upload']); }); \ No newline at end of file