test #1

Merged
allen.yan merged 2 commits from test into main 2025-08-22 09:29:50 +08:00
18 changed files with 801 additions and 36 deletions
Showing only changes of commit 00c4225987 - Show all commits

View File

@ -0,0 +1,35 @@
<?php
namespace App\Enums;
use App\Enums\Traits\HasLabels;
/**
* @OA\Schema(
* schema="OrderedSongStatus",
* type="string",
* enum={"NotPlayed", "Playing", "Played", "NoFile", "Skipped", "InsertPlayback"},
* example="NotPlayed"
* )
*/
enum OrderedSongStatus: string
{
case NotPlayed = 'NotPlayed';
case Playing = 'Playing';
case Played = 'Played';
case NoFile = 'NoFile';
case Skipped = 'Skipped';
case InsertPlayback = 'InsertPlayback';
public function labels(): string
{
return match($this) {
self::NotPlayed => __('enums.NotPlayed'),
self::Playing => __('enums.Playing'),
self::Played => __('enums.Played'),
self::NoFile => __('enums.NoFile'),
self::Skipped => __('enums.Skipped'),
self::InsertPlayback => __('enums.InsertPlayback'),
};
}
}

View File

@ -0,0 +1,301 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\OrderSongRequest;
use App\Http\Requests\RoomSongRequest;
use Illuminate\Http\Request;
use App\Models\Room;
use App\Models\Song;
use App\Models\OrderedSong;
use App\Models\RoomSession;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Carbon;
use App\Http\Responses\ApiResponse;
class RoomSongController extends Controller
{
/**
* @OA\Post(
* path="/api/room/order-song",
* summary="點歌",
* description="在指定包廂點一首歌曲",
* tags={"Room Control Song"},
* security={{"Authorization":{}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/OrderSongRequest")
* ),
* @OA\Response(
* response=200,
* description="成功",
* @OA\JsonContent(
* type="object",
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(
* property="ordered_song",
* ref="#/components/schemas/OrderedSong"
* ),
* @OA\Property(property="next_song_name", type="string", example="XXXSSSS")
* )
* ),
* @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 orderSong(OrderSongRequest $request)
{
// 取得對應的 RoomSession透過 api_token
$roomSession = $this->getRoomSession($request->api_token) ;
// 取得歌曲名稱
$song = Song::findOrFail($request->song_id);
// 建立 OrderedSong
$orderedSong = OrderedSong::create([
'room_session_id' => $roomSession->id,
'from_by' => $request->from_by,
'status' => $request->status,
'song_id' => $request->song_id,
'song_name' => $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');
}
return ApiResponse::success([
'ordered_song' => $orderedSong,
'next_song_name' => $this->nextSongName($roomSession),
]);
}
/**
* @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(),
]);
}
// 撈出候播清單(下首 + 下下首)
$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(),
]);
}
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();
}
}

View File

@ -265,6 +265,8 @@ class RoomControlController extends Controller
return ApiResponse::error('房間未設定 IP 或 Port'); return ApiResponse::error('房間未設定 IP 或 Port');
} }
$room->status=$validated['command']; $room->status=$validated['command'];
$room->log_source='api';
$room->log_message='sendSwitch';
$room->started_at=$validated['started_at']; $room->started_at=$validated['started_at'];
$room->ended_at=$validated['ended_at']; $room->ended_at=$validated['ended_at'];
$room->save(); $room->save();
@ -279,7 +281,6 @@ class RoomControlController extends Controller
}; };
$data = $suffix . "," . $signal; $data = $suffix . "," . $signal;
//dd($data);
$client = new TcpSocketClient($room->internal_ip, $room->port); $client = new TcpSocketClient($room->internal_ip, $room->port);
try { try {
$response = $client->send($data); $response = $client->send($data);

View File

@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests;
use Illuminate\Validation\Rule;
/**
* @OA\Schema(
* schema="OrderSongRequest",
* required={"song_id","status","api_token"},
* @OA\Property(property="song_id", type="integer", example=1),
* @OA\Property(property="status", type="string", enum={"NotPlayed", "InsertPlayback", "Skipped"},example="NotPlayed"),
* @OA\Property(property="from_by", type="string", example="介面ERP之類的說明"),
* @OA\Property(property="api_token", type="string", example="da9cdb88a60a377bba9fc53f09dd07838eb6f4f355d63f4927c711e7aaae3104"),
* )
*/
class OrderSongRequest extends ApiRequest
{
public function rules(): array
{
return [
'api_token' => [
'required',
Rule::exists('room_sessions', 'api_token')
->where(fn ($q) => $q->whereIn('status', ['active', 'maintain']))
],
'song_id' => 'required|exists:songs,id',
'status' => 'required|in:NotPlayed,InsertPlayback,Skipped',
'from_by' => 'nullable',
];
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests;
use Illuminate\Validation\Rule;
/**
* @OA\Parameter(
* parameter="ApiTokenQuery",
* name="api_token",
* in="query",
* required=true,
* description="Room session 的 API token",
* @OA\Schema(
* type="string",
* example="da9cdb88a60a377bba9fc53f09dd07838eb6f4f355d63f4927c711e7aaae3104"
* )
* )
* @OA\Schema(
* schema="RoomSongRequest",
* type="object",
* required={"api_token"},
* @OA\Property(
* property="api_token",
* type="string",
* description="Room session 的 API token",
* example="da9cdb88a60a377bba9fc53f09dd07838eb6f4f355d63f4927c711e7aaae3104"
* )
* )
*/
class RoomSongRequest extends ApiRequest
{
public function rules(): array
{
return [
'api_token' => [
'required',
Rule::exists('room_sessions', 'api_token')
->where(fn ($q) => $q->whereIn('status', ['active', 'maintain']))
]
];
}
}

View File

@ -4,7 +4,7 @@ namespace App\Livewire\Modals;
use App\Models\Room; use App\Models\Room;
use App\Models\Branch; use App\Models\Branch;
use Illuminate\Support\Carbon;
use Livewire\Component; use Livewire\Component;
use App\Services\ApiClient; use App\Services\ApiClient;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -36,20 +36,20 @@ class RoomDetailModal extends Component
} }
public function startNotify() public function startNotify()
{ {
$data = $this->buildNotifyData('maintain', null, null); $data = $this->buildNotifyData('maintain', Carbon::now(), null);
$this->send($data); $this->send($data);
} }
public function stopNotify() public function stopNotify()
{ {
$data = $this->buildNotifyData('closed', null, null); $data = $this->buildNotifyData('closed', null, Carbon::now());
$chk =$this->send($data); $chk =$this->send($data);
} }
public function fireNotify() public function fireNotify()
{ {
$data = $this->buildNotifyData('fire', null, null); $data = $this->buildNotifyData('fire', null, Carbon::now());
$this->send($data); $this->send($data);
} }

View File

@ -2,7 +2,9 @@
namespace App\Livewire\Tables; namespace App\Livewire\Tables;
use App\Models\Branch;
use App\Models\RoomStatusLog; use App\Models\RoomStatusLog;
use App\Enums\RoomType;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use PowerComponents\LivewirePowerGrid\Button; use PowerComponents\LivewirePowerGrid\Button;
@ -37,28 +39,47 @@ final class RoomStatusLogTable extends PowerGridComponent
public function datasource(): Builder public function datasource(): Builder
{ {
return RoomStatusLog::query()->latest();; return RoomStatusLog::with(['room', 'branch'])->latest();
} }
public function relationSearch(): array public function relationSearch(): array
{ {
return []; return [
'branch' => ['name'],
'room' => ['name'],
'user' => ['name'],
];
} }
public function fields(): PowerGridFields public function fields(): PowerGridFields
{ {
return PowerGrid::fields() return PowerGrid::fields()
->add('id') ->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) { ->add('room_name', function (RoomStatusLog $model) {
return $model->room?->type->labelPowergridFilter().$model->room?->name; return $model->room?->name;
}) })
->add('user_name', function (RoomStatusLog $model){ ->add('user_name', function (RoomStatusLog $model){
return $model->user?->name; 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){ ->add('status_str',function (RoomStatusLog $model){
return $model->status->labelPowergridFilter(); return $model->status->labelPowergridFilter();
}) })
->add('started_at')
->add('ended_at')
->add('message') ->add('message')
->add('source')
->add('created_at'); ->add('created_at');
} }
@ -66,17 +87,48 @@ final class RoomStatusLogTable extends PowerGridComponent
{ {
$column=[]; $column=[];
$column[]=Column::make(__('room-status-log.id'), 'id'); $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.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.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.message'), 'message');
$column[]=Column::make(__('room-status-log.source'), 'source');
$column[]=Column::make(__('room-status-log.created_at'), 'created_at'); $column[]=Column::make(__('room-status-log.created_at'), 'created_at');
return $column; return $column;
} }
public function rowTemplates(): array
{
return [
'check-circle' => '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 {{ text-color }}">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>',
'x-circle' => '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 {{ text-color }}">
<path stroke-linecap="round" stroke-linejoin="round" d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>',
];
}
public function filters(): array public function filters(): array
{ {
$branches = Branch::query()->orderBy('name')->get()->map(fn($branch) => (object)[
'value' => $branch->id,
'label' => $branch->name,
]);
//dd($branches);
return [ 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')
]; ];
} }
} }

View File

@ -0,0 +1,87 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* @OA\Schema(
* schema="OrderedSong",
* type="object",
* @OA\Property(property="id", type="integer", example=123),
* @OA\Property(property="room_session_id", type="integer", example=1),
* @OA\Property(property="from_by", type="string", example="remote"),
* @OA\Property(property="song_id", type="integer", example=5),
* @OA\Property(property="song_name", type="string", example="歌名"),
* @OA\Property(property="artist_name", type="string", example="歌手名稱"),
* @OA\Property(property="status", ref="#/components/schemas/OrderedSongStatus"),
* @OA\Property(property="ordered_at", type="string", format="date-time", example="2025-08-18T14:00:00Z"),
* @OA\Property(property="started_at", type="string", format="date-time", nullable=true, example="2025-08-18T14:05:00Z"),
* @OA\Property(property="finished_at", type="string", format="date-time", nullable=true, example="2025-08-18T14:10:00Z"),
* )
* @OA\Schema(
* schema="OrderedSongWithPartialSong",
* allOf={
* @OA\Schema(ref="#/components/schemas/OrderedSong"),
* @OA\Schema(
* @OA\Property(
* property="song",
* type="object",
* nullable=true,
* @OA\Property(property="id", type="integer", example=5),
* @OA\Property(property="name", type="string", example="歌名"),
* @OA\Property(property="filename", type="string", example="song123.mp4"),
* @OA\Property(property="db_change", type="integer", example=-2),
* @OA\Property(property="vocal", type="boolean", example=true),
* @OA\Property(property="situation", type="string", example="party")
* )
* )
* }
* )
*/
class OrderedSong extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'room_session_id',
'from_by',
'song_id',
'song_name',
'artist_name',
'status',
'ordered_at',
'started_at',
'finished_at',
];
protected $casts = [
'ordered_at' => '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(Song::class);
}
public function scopeWithPartialSong($query)
{
return $query->with([
'song' => function ($q) {
$q->select('id', 'name','filename','db_change','vocal','situation'); // 精簡版
}
]);
}
}

View File

@ -2,7 +2,6 @@
namespace App\Models; namespace App\Models;
use Illuminate\Support\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use App\Traits\LogsModelActivity; use App\Traits\LogsModelActivity;
@ -22,11 +21,15 @@ use App\Traits\LogsModelActivity;
* @OA\Property(property="ended_at", type="string", format="date-time", example=null), * @OA\Property(property="ended_at", type="string", format="date-time", example=null),
* ) * )
*/ */
class Room extends Model class Room extends Model
{ {
/** @use HasFactory<\Database\Factories\ArtistFactory> */ /** @use HasFactory<\Database\Factories\ArtistFactory> */
use HasFactory, LogsModelActivity; use HasFactory, LogsModelActivity;
public string $log_message = 'BranchForm-add';
public string $log_source = 'manual';
protected $fillable = [ protected $fillable = [
'branch_id', 'branch_id',
'floor', 'floor',
@ -39,6 +42,12 @@ class Room extends Model
'started_at', 'started_at',
'ended_at', 'ended_at',
]; ];
protected $attributes = [
'type' => \App\Enums\RoomType::Unset,
'floor' => 1,
'is_online' => false,
'status' => \App\Enums\RoomStatus::Error,
];
//protected $hidden = [ //protected $hidden = [
// 'internal_ip', // 'internal_ip',
@ -55,13 +64,12 @@ class Room extends Model
'status' => \App\Enums\RoomStatus::class, 'status' => \App\Enums\RoomStatus::class,
'started_at' => 'datetime', 'started_at' => 'datetime',
'ended_at' => 'datetime', 'ended_at' => 'datetime',
]; ];
public function str_started_at(){ public function str_started_at(){
$str ="Not Set"; $str ="Not Set";
if($this->started_at !=null){ if($this->started_at !=null){
$str = $this->started_at; $str = $this->started_at;
} }
return $str; return $str;
} }
@ -77,8 +85,15 @@ class Room extends Model
public function branch() { public function branch() {
return $this->belongsTo(Branch::class); return $this->belongsTo(Branch::class);
} }
public function full_name(){
return $this->type->labelPowergridFilter().$this->name;
}
public function statusLogs() { public function statusLogs() {
return $this->hasMany(RoomStatusLog::class); return $this->hasMany(RoomStatusLog::class);
} }
public function sessions()
{
return $this->hasMany(RoomSession::class);
}
} }

View File

@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class RoomSession extends Model
{
use HasFactory;
protected $fillable = [
'room_id',
'started_at',
'ended_at',
'status',
'mode',
'close_reason',
'api_token',
];
protected $casts = [
'started_at' => '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);
}
}

View File

@ -13,18 +13,30 @@ class RoomStatusLog extends Model
protected $fillable = protected $fillable =
[ [
'branch_id',
'room_id', 'room_id',
'user_id', 'user_id',
'is_online',
'status', 'status',
'started_at',
'ended_at',
'message', 'message',
'source',
]; ];
protected $casts = [ protected $casts = [
'is_online' => 'boolean',
'status' => \App\Enums\RoomStatus::class, 'status' => \App\Enums\RoomStatus::class,
'started_at' => 'datetime',
'ended_at' => 'datetime',
]; ];
public function user(){ public function user(){
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function branch() {
return $this->belongsTo(Branch::class);
}
public function room() { public function room() {
return $this->belongsTo(Room::class); return $this->belongsTo(Room::class);
} }

View File

@ -3,6 +3,7 @@
namespace App\Observers; namespace App\Observers;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Arr;
use App\Models\Room; use App\Models\Room;
use App\Models\RoomStatusLog; use App\Models\RoomStatusLog;
@ -14,7 +15,8 @@ class RoomObserver
*/ */
public function created(Room $room): void public function created(Room $room): void
{ {
// // 建立初始 log
$this->createStatusLog($room);
} }
/** /**
@ -23,13 +25,33 @@ class RoomObserver
public function updated(Room $room): void public function updated(Room $room): void
{ {
// 檢查是否有變更狀態 // 檢查是否有變更狀態
if ($room->wasChanged('status')) { if ($room->wasChanged()) {
RoomStatusLog::create([ $this->createStatusLog($room);
'room_id' => $room->id, }
'user_id' => Auth::id(), // 若是 console 或系統自動操作可能為 null if ($room->isDirty('status')) {
'status' => $room->status, $now = now();
'message' => 'started_at:'.$room->started_at.',ended_at:'.$room->ended_at,
]); // 找到最後一筆未結束 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,
'api_token' => bin2hex(random_bytes(32)),
]);
}
} }
} }
@ -38,22 +60,38 @@ class RoomObserver
*/ */
public function deleted(Room $room): void 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),
]);
} }
/** private function getSource(Room $room): string
* Handle the Room "restored" event.
*/
public function restored(Room $room): void
{ {
// return app()->runningInConsole() ? 'system' : ($room->log_source ?? 'manual');
}
/**
* Handle the Room "force deleted" event.
*/
public function forceDeleted(Room $room): void
{
//
} }
} }

View File

@ -13,10 +13,19 @@ return new class extends Migration
{ {
Schema::create('room_status_logs', function (Blueprint $table) { Schema::create('room_status_logs', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('room_id')->nullable(); $table->unsignedBigInteger('branch_id');
$table->foreignId('user_id')->nullable(); // 操作者,可為 null系統 $table->unsignedBigInteger('room_id');
$table->unsignedBigInteger('user_id');
$table->tinyInteger('is_online')->default(0);
$table->enum('status', ['active', 'closed','fire', 'error', 'maintain']); $table->enum('status', ['active', 'closed','fire', 'error', 'maintain']);
$table->timestamp('started_at')->nullable();
$table->timestamp('ended_at')->nullable();
$table->text('message')->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(); $table->timestamps();
}); });
} }

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('room_sessions', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('ordered_songs', function (Blueprint $table) {
$table->id();
$table->foreignId('room_session_id')->constrained('room_sessions')->cascadeOnDelete();
$table->string('from_by')->nullable();
// 歌曲資訊
$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(); // 播放結束時間
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('ordered_songs');
}
};

View File

@ -2,6 +2,12 @@
return [ return [
'Unset' => '未定義', 'Unset' => '未定義',
'NotPlayed' =>'未播放',
'Playing' =>'播放中',
'Played' =>'播畢',
'NoFile' =>'無文件',
'Skipped' =>'刪除',
'InsertPlayback' =>'插播',
'user.gender.Male' =>'男', 'user.gender.Male' =>'男',
'user.gender.Female' =>'女', 'user.gender.Female' =>'女',
'user.gender.Other' =>'其他', 'user.gender.Other' =>'其他',

View File

@ -4,10 +4,17 @@ return [
'list' => '包廂狀態紀錄', 'list' => '包廂狀態紀錄',
'id' => '編號', 'id' => '編號',
'branch' => '分店',
'room' => '包廂', 'room' => '包廂',
'room_type' => '包廂類別',
'room_name' => '包廂名稱',
'user' => '操成者', 'user' => '操成者',
'is_online' => '在線?',
'status' => '狀態', 'status' => '狀態',
'started_at' => '開始於',
'ended_at' => '結束於',
'message' => '紀錄', 'message' => '紀錄',
'source' => '來源',
'created_at' => '建立於' 'created_at' => '建立於'
]; ];

View File

@ -12,5 +12,11 @@ Route::middleware('auth:sanctum')->group(function () {
Route::get('/profile', [AuthController::class, 'profile']); Route::get('/profile', [AuthController::class, 'profile']);
Route::post('/room/sendSwitch', [RoomControlController::class, 'sendSwitch']); Route::post('/room/sendSwitch', [RoomControlController::class, 'sendSwitch']);
Route::post('/room/heartbeat', [RoomControlController::class, 'StatusReport']); Route::post('/room/heartbeat', [RoomControlController::class, 'StatusReport']);
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('/upload-sqlite', [SqliteUploadController::class, 'upload']); Route::post('/upload-sqlite', [SqliteUploadController::class, 'upload']);
}); });