Room 加入 is_online 來區別再不再線上

調整 FavoriteSongs 資料塞值改用seeder
加入 user_song table
/api/room/receiveRegister 調整邏輯
room介面 開關包帳功能調整
寫入心跳封包
20250522
This commit is contained in:
allen.yan 2025-05-22 10:08:34 +08:00
parent 039a1f3595
commit 3bbb1a5cc0
21 changed files with 380 additions and 53 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -8,7 +8,7 @@ use App\Enums\Traits\HasLabels;
* @OA\Schema(
* schema="RoomStatus",
* type="string",
* enum={"active", "closed", "fire", "error", "maintenance"},
* enum={"active", "closed", "fire", "maintenance"},
* example="error"
* )
*/
@ -19,7 +19,6 @@ enum RoomStatus: string {
case Closed = 'closed';
case Fire ='fire';
case Error = 'error';
case Maintenance = 'maintenance';
// 返回對應的顯示文字
public function labels(): string
@ -29,7 +28,6 @@ enum RoomStatus: string {
self::Closed => __('enums.room.status.Closed'),
self::Fire => __('enums.room.status.Fire'),
self::Error => __('enums.room.status.Error'),
self::Maintenance => __('enums.room.status.Maintenance'),
};
}
}

View File

@ -4,11 +4,13 @@ namespace App\Http\Controllers;
use App\Http\Requests\SendRoomSwitchCommandRequest;
use App\Http\Requests\ReceiveRoomRegisterRequest;
use App\Http\Requests\ReceiveRoomStatusRequest;
use App\Services\TcpSocketClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use App\Models\Room;
use App\Enums\RoomStatus;
use App\Http\Responses\ApiResponse;
/**
* @OA\Tag(
@ -25,7 +27,6 @@ class RoomControlController extends Controller
* description="依據傳入的 branch_id 與 room_name知道過來的設備來之於那個IP設備。",
* operationId="registerRoomCommand",
* tags={"Room Control"},
* security={{"Authorization":{}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/ReceiveRoomRegisterRequest")
@ -66,19 +67,61 @@ class RoomControlController extends Controller
*/
public function receiveRegister(ReceiveRoomRegisterRequest $request): JsonResponse
{
$validated = $request->validated();
$room= Room::where([['branch_id',$validated['branch_id']],['name',$validated['room_name']]])->first();
if (!$room) {
return \App\Http\Responses\ApiResponse::error("'找不到對應包廂'");
}
$room->internal_ip = $validated['room_ip'];
$room->port =1000;
$room->touch();
$room->status=RoomStatus::Closed;
$room->save();
return \App\Http\Responses\ApiResponse::success($room);
// 1. 驗證帳密(登入用)
$credentials = $request->only('email', 'password');
if (!Auth::attempt($credentials)) {
return ApiResponse::unauthorized();
}
// 2. 取得登入使用者
$user = Auth::user();
// 3. 產生或取得 Token
if (empty($user->api_plain_token)) {
$token = $user->createToken('pc-heartbeat')->plainTextToken;
$user->api_plain_token = $token;
$user->save();
} else {
$token = $user->api_plain_token;
}
// 4. 驗證其他註冊欄位
$validated = $request->validated(); // branch_id, room_name, room_ip
// 5. 找出對應包廂
$room = Room::where('branch_id', $validated['branch_id'])
->where('name', $validated['room_name'])
->first();
if (!$room) {
return ApiResponse::error('找不到對應包廂');
}
// 6. 更新包廂資訊
$room->internal_ip = $request->ip();
$room->port = 1000; // 預設值
$room->is_online =1;
$room->status = RoomStatus::Closed;
$room->touch(); // 更新 updated_at
$room->save();
// 7. 回傳 token 與包廂資料
return ApiResponse::success([
'token' => $token,
'room' => $room,
]);
}
public function StatusReport(ReceiveRoomStatusRequest $request)
{
$data = $request->validate([
'hostname' => 'required|string',
'ip' => 'required|string',
'cpu' => 'nullable|numeric',
'memory' => 'nullable|numeric',
'disk' => 'nullable|numeric',
'status' => 'required|string',
]);
}
/**
* @OA\Post(
@ -129,14 +172,36 @@ class RoomControlController extends Controller
public function sendSwitch(SendRoomSwitchCommandRequest $request): JsonResponse
{
$validated = $request->validated();
$room= Room::where([['branch_id',$validated['branch_id']],['name',$validated['room_name']]])->first();
$ip = $room->internal_ip;
$port = $room->port;
$data=(substr($room->name, -3) ?? $room->name).",".($validated['command']=='active' ? 'O':'X');
$room = Room::where([
['branch_id', $validated['branch_id']],
['name', $validated['room_name']],
])->first();
if (!$room) {
return ApiResponse::error('房間不存在');
}
// 檢查必要欄位是否缺失或狀態為錯誤
if (empty($room->internal_ip) || empty($room->port)) {
return ApiResponse::error('房間未設定 IP 或 Port');
}
if ($room->status === RoomStatus::Error) {
return ApiResponse::error('房間目前處於錯誤狀態,無法操作');
}
$suffix = substr($room->name, -3) ?: $room->name;
$signal = match ($validated['command']) {
'active' => 'O',
'closed' => 'X',
'fire' => 'F',
default => 'X', // fallback 保險起見
};
$data = $suffix . "," . $signal;
//dd($data);
$client = new TcpSocketClient($ip, $port);
$client = new TcpSocketClient($room->internal_ip, $room->port);
try {
$response = $client->send($data);
@ -145,13 +210,13 @@ class RoomControlController extends Controller
$room->ended_at=$validated['ended_at'];
$room->save();
return \App\Http\Responses\ApiResponse::success($room);
return ApiResponse::success($room);
} catch (\Throwable $e) {
$room->status=RoomStatus::Error;
$room->started_at=null;
$room->ended_at=null;
$room->save();
return \App\Http\Responses\ApiResponse::error($e->getMessage());
return ApiResponse::error($e->getMessage());
}
}
}

View File

@ -7,10 +7,11 @@ use Illuminate\Foundation\Http\FormRequest;
/**
* @OA\Schema(
* schema="ReceiveRoomRegisterRequest",
* required={"branch_id", "room_id", "ip"},
* @OA\Property(property="branch_id", type="integer", example="5"),
* @OA\Property(property="room_name", type="string", example="pc102"),
* @OA\Property(property="room_ip", type="string", example="192.168.x.x"),
* required={"branch_id", "room_name", "email" ,"password"},
* @OA\Property(property="branch_id", type="integer", example="1"),
* @OA\Property(property="room_name", type="string", example="102"),
* @OA\Property(property="email", type="string", example="XX@gmail.com"),
* @OA\Property(property="password", type="string", example="XXX"),
* )
*/
class ReceiveRoomRegisterRequest extends ApiRequest
@ -25,7 +26,8 @@ class ReceiveRoomRegisterRequest extends ApiRequest
return [
'branch_id' => 'required|integer|exists:branches,id',
'room_name' => 'required|string',
'room_ip' => 'nullable|ip',
'email' => 'required|email',
'password' => 'required',
];
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* @OA\Schema(
* schema="ReceiveRoomStatusRequest",
* required={"hostname", "ip", "status"},
* @OA\Property(property="hostname", type="string", example=""),
* @OA\Property(property="ip", type="string", example=""),
* @OA\Property(property="cpu", type="numeric", example=""),
* @OA\Property(property="memory", type="numeric", example=""),
* @OA\Property(property="disk", type="numeric", example=""),
* @OA\Property(property="status", type="string", example=""),
* )
*/
class ReceiveRoomStatusRequest extends ApiRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'hostname' => 'required|string',
'ip' => 'required|string',
'cpu' => 'nullable|numeric',
'memory' => 'nullable|numeric',
'disk' => 'nullable|numeric',
'status' => 'required|string',
];
}
}

View File

@ -7,7 +7,7 @@ use Illuminate\Foundation\Http\FormRequest;
/**
* @OA\Schema(
* schema="SendRoomSwitchCommandRequest",
* required={"room_id", "command"},
* required={"branch_id", "room_name", "command"},
* @OA\Property(property="branch_id", type="integer", example="5"),
* @OA\Property(property="room_name", type="string", example="pc102"),
* @OA\Property(property="command", type="string", enum={"active", "closed", "fire", "maintenance"}, example="active"),

View File

@ -5,12 +5,18 @@ namespace App\Livewire\Admin;
use App\Models\Room;
use Livewire\Component;
use App\Services\ApiClient;
use Illuminate\Support\Facades\Auth;
class RoomDetailModal extends Component
{
protected $listeners = ['openModal'];
protected $listeners = [
'openModal', 'closeModal',
'startNotify', 'stopNotify', 'fireNotify',
'openAccountNotify','closeAccountNotify'
];
public $room;
public bool $showModal = false;
@ -20,6 +26,69 @@ class RoomDetailModal extends Component
$this->room = Room::find($roomId);
$this->showModal = true;
}
public function closeModal()
{
$this->showModal = false;
}
public function startNotify()
{
$data = $this->buildNotifyData('active', now(), null);
$this->send($data);
}
public function stopNotify()
{
$data = $this->buildNotifyData('closed', null, null);
$this->send($data);
}
public function fireNotify()
{
$data = $this->buildNotifyData('fire', null, null);
$this->send($data);
}
public function openAccountNotify()
{
$data = $this->buildNotifyData('active', now(), null);
$this->send($data);
}
public function closeAccountNotify()
{
$data = $this->buildNotifyData('closed', now(), null);
$this->send($data);
}
protected function buildNotifyData(string $command, $startedAt = null, $endedAt = null): array
{
return [
'branch_id' => $this->room->branch_id ?? 0,
'room_name' => $this->room->name ?? '',
'command' => $command,
'started_at' => $startedAt ? $startedAt->toDateTimeString() : null,
'ended_at' => $endedAt ? $endedAt->toDateTimeString() : null,
];
}
function send(array $data){
$user = Auth::user();
$token = $user->api_plain_token ?? null;
if (!$token) {
$this->addError('api', 'API token is missing.');
return false;
}
$apiClient = new ApiClient();
$response = $apiClient->setToken($token)->post('/room/sendSwitch', $data);
if ($response->failed()) {
$this->addError('api', 'API request failed: ' . $response->body());
return false;
}
// 可以加入成功提示或事件
return true;
}
public function render()
{

View File

@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class MachineStatus extends Model
{
protected $fillable = [
'hostname',
'ip',
'cpu',
'memory',
'disk',
'status',
];
}

View File

@ -32,6 +32,7 @@ class Room extends Model
'name',
'internal_ip',
'port',
'is_online',
'status',
'started_at',
'ended_at',
@ -48,6 +49,7 @@ class Room extends Model
'name' => 'string',
'internal_ip' =>'string',
'port' => 'int',
'is_online' => 'boolean',
'status' => \App\Enums\RoomStatus::class,
'started_at' => 'datetime',
'ended_at' => 'datetime',

View File

@ -0,0 +1,41 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class ApiClient
{
protected string $baseUrl;
protected string $token;
public function __construct(string $token = null)
{
$this->baseUrl = config('services.room_api.base_url', 'https://ktv.test/api');
$this->token = $token ?? config('services.room_api.token');
}
public function setToken(string $token): self
{
$this->token = $token;
return $this;
}
public function withDefaultHeaders(): \Illuminate\Http\Client\PendingRequest
{
return Http::withHeaders([
'Accept' => 'application/json',
'Authorization' => 'Bearer ' . config('services.room_api.token'),
'Content-Type' => 'application/json',
]);
}
public function post(string $endpoint, array $data = [])
{
return $this->withDefaultHeaders()->post($this->baseUrl . $endpoint, $data);
}
public function get(string $endpoint, array $query = [])
{
return $this->withDefaultHeaders()->get($this->baseUrl . $endpoint, $query);
}
}

View File

@ -3,7 +3,6 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
@ -18,13 +17,6 @@ return new class extends Migration
$table->string('userPhone', 10);
$table->timestamps();
});
// 預塞一筆資料
DB::table('FavoriteSongs')->insert([
'songNumber' => 999996,
'userPhone' => '0912345678',
'created_at' => now(),
'updated_at' => now(),
]);
}
/**

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_song', function (Blueprint $table) {
$table->unsignedBigInteger('song_id');
$table->unsignedBigInteger('user_id');
$table->foreign('song_id')->references('id')->on('songs')->restrictOnDelete()->restrictOnUpdate();
$table->foreign('user_id')->references('id')->on('users')->restrictOnDelete()->restrictOnUpdate();
$table->primary(['song_id', 'user_id']);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_song');
}
};

View File

@ -19,7 +19,8 @@ return new class extends Migration
$table->string('name')->comment('包廂名稱');
$table->string('internal_ip')->nullable()->comment('內部 IP');
$table->unsignedSmallInteger('port')->nullable()->comment('通訊 Port');
$table->enum('status', ['active', 'closed','fire', 'error', 'maintenance'])->default('error')->comment('狀態'); // :啟用中 / 已結束
$table->tinyInteger('is_online')->default(0)->comment('連線狀態');
$table->enum('status', ['active', 'closed','fire', 'error'])->default('error')->comment('狀態'); // :啟用中 / 已結束
$table->dateTime('started_at')->nullable()->comment('開始時間'); //
$table->dateTime('ended_at')->nullable()->comment('結束時間'); //
$table->timestamps();

View File

@ -0,0 +1,33 @@
<?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('machine_statuses', function (Blueprint $table) {
$table->id();
$table->string('hostname');
$table->string('ip')->nullable();
$table->decimal('cpu', 5, 2)->nullable();
$table->decimal('memory', 5, 2)->nullable();
$table->decimal('disk', 5, 2)->nullable();
$table->string('status');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('machine_statuses');
}
};

View File

@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder
$this->call([
PermissionTableSeeder::class,
SongCategorySeeder::class,
FavoriteSongsSeeder::class,
CreateAdminUserSeeder::class,
]);
}

View File

@ -0,0 +1,23 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Carbon;
class FavoriteSongsSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
DB::table('FavoriteSongs')->insert([
'songNumber' => 999996,
'userPhone' => '0912345678',
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
]);
}
}

View File

@ -27,6 +27,5 @@ return [
'room.status.Active' => '已占用',
'room.status.Closed' => '可用',
'room.status.Fire' => '火災',
'room.status.Error' => '異常',
'room.status.Maintenance' => '維修',
'room.status.Error' => '維修',
];

View File

@ -4,12 +4,18 @@
RoomStatus::Active->value => 'green-600',
RoomStatus::Closed->value => 'gray-600',
RoomStatus::Error->value => 'red-600',
RoomStatus::Maintenance->value => 'yellow-600',
];
@endphp
<div class="border p-2 rounded shadow-md h-32 relative cursor-pointer bg-amber-50">
<div class="font-bold">{{ $room->type->labels().".".$room->name }}</div>
{{-- 房間名稱 + 線上狀態圓點 --}}
<div class="font-bold flex items-center gap-1">
<span class="w-2.5 h-2.5 rounded-full inline-block
{{ $room->is_online ? 'bg-green-500' : 'bg-red-500' }}">&nbsp;&nbsp;
</span>
<span>{{ $room->type->labels().".".$room->name }}</span>
</div>
<div class="text-sm text-{{ $statusColors[$room->status->value] ?? 'gray-500' }} text-center">
{{ $room->status->labels() }}
</div>

View File

@ -4,13 +4,19 @@
RoomStatus::Active->value => 'green-600',
RoomStatus::Closed->value => 'gray-600',
RoomStatus::Error->value => 'red-600',
RoomStatus::Maintenance->value => 'yellow-600',
];
@endphp
<div class="border p-2 rounded shadow-md h-32 relative cursor-pointer bg-amber-50"
wire:click="$dispatchTo('admin.room-detail-modal','openModal', { roomId: {{ $room->id }} })">
<div class="font-bold">{{ $room->type->labels().".".$room->name }}</div>
{{-- 房間名稱 + 線上狀態圓點 --}}
<div class="font-bold flex items-center gap-1">
<span class="w-2.5 h-2.5 rounded-full inline-block
{{ $room->is_online ? 'bg-green-500' : 'bg-red-500' }}">&nbsp;&nbsp;
</span>
<span>{{ $room->type->labels().".".$room->name }}</span>
</div>
<div class="text-sm text-{{ $statusColors[$room->status->value] ?? 'gray-500' }} text-center">
{{ $room->status->labels() }}
</div>

View File

@ -2,7 +2,7 @@
<x-wireui:card class="border border-gray-200">
<x-slot name="action">
<button class="cursor-pointer p-1 rounded-full focus:outline-none focus:outline-hidden focus:ring-2 focus:ring-secondary-200 text-secondary-300"
x-on:click="close"
wire:click="closeModal"
tabindex="-1"
>
<x-dynamic-component
@ -16,13 +16,13 @@
{{ $room->name ?? '未選擇' }}包廂設定
</x-slot>
<div class="grid grid-cols-3 gap-4 mb-4">
<x-wireui:button wire:click="startMachine" >開機</x-wireui:button>
<x-wireui:button wire:click="stopMachine" >關機</x-wireui:button>
<x-wireui:button wire:click="closeModal" >取消</x-wireui:button>
<x-wireui:button wire:click="startNotify" >開機</x-wireui:button>
<x-wireui:button wire:click="stopNotify" >關機</x-wireui:button>
<x-wireui:button wire:click="fireNotify" >火災</x-wireui:button>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<x-wireui:button wire:click="openAccount" >包廂開帳</x-wireui:button>
<x-wireui:button wire:click="closeAccount" >包廂關帳</x-wireui:button>
<x-wireui:button wire:click="openAccountNotify" >包廂開帳</x-wireui:button>
<x-wireui:button wire:click="closeAccountNotify" >包廂關帳</x-wireui:button>
</div>
</x-wireui:card>

View File

@ -7,9 +7,10 @@ use App\Http\Controllers\RoomControlController;
Route::get('/artists/search', [App\Http\Controllers\ArtistController::class, 'search'])->name('api.artists.search');
Route::post('/room/receiveRegister', [RoomControlController::class, 'receiveRegister']);
Route::middleware('auth:sanctum')->group(function () {
Route::get('/profile', [AuthController::class, 'profile']);
Route::post('/room/receiveRegister', [RoomControlController::class, 'receiveRegister']);
Route::post('/room/sendSwitch', [RoomControlController::class, 'sendSwitch']);
Route::post('/room/system-status', [RoomControlController::class, 'StatusReport']);
});