diff --git a/.DS_Store b/.DS_Store index 92ed48d..cf959a3 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/app/Enums/RoomStatus.php b/app/Enums/RoomStatus.php index b9cd372..74b27ec 100644 --- a/app/Enums/RoomStatus.php +++ b/app/Enums/RoomStatus.php @@ -4,11 +4,20 @@ namespace App\Enums; use App\Enums\Traits\HasLabels; +/** + * @OA\Schema( + * schema="RoomStatus", + * type="string", + * enum={"active", "closed", "fire", "error", "maintenance"}, + * example="error" + * ) + */ enum RoomStatus: string { use HasLabels; case Active = 'active'; case Closed = 'closed'; + case Fire ='fire'; case Error = 'error'; case Maintenance = 'maintenance'; @@ -18,6 +27,7 @@ enum RoomStatus: string { return match($this) { self::Active => __('enums.room.status.Active'), self::Closed => __('enums.room.status.Closed'), + self::Fire => __('enums.room.status.Fire'), self::Error => __('enums.room.status.Error'), self::Maintenance => __('enums.room.status.Maintenance'), }; diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index ea9faf8..5be3027 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -11,53 +11,53 @@ use OpenApi\Annotations as OA; /** * @OA\Tag( * name="Auth", - * description="用戶註冊、登入、登出與個人資料" + * description="用戶個人資料" * ) */ class AuthController extends Controller { /** - * @OA\Get( - * path="/api/profile", - * summary="Get current user profile", - * tags={"Auth"}, - * security={{"Authorization":{}}}, - * @OA\Response( - * response=200, - * description="User profile", - * @OA\JsonContent( - * allOf={ - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema( - * @OA\Property(property="data", ref="#/components/schemas/User") - * ) - * } - * ) - * ), - * @OA\Response( - * response=401, - * description="Unauthorized", - * @OA\JsonContent( - * allOf={ - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema( - * @OA\Property(property="code", type="string", example="UNAUTHORIZED"), - * @OA\Property(property="message", type="string", example="Unauthorized"), - * @OA\Property(property="data", type="null") - * ) - * } - * ) - * ), - * @OA\Parameter( - * name="Accept", - * in="header", - * required=true, - * @OA\Schema(type="string", default="application/json") - * ) - * ) - */ + * @OA\Get( + * path="/api/profile", + * summary="Get current user profile", + * tags={"Auth"}, + * security={{"Authorization":{}}}, + * @OA\Response( + * response=200, + * description="User profile", + * @OA\JsonContent( + * allOf={ + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema( + * @OA\Property(property="data", ref="#/components/schemas/User") + * ) + * } + * ) + * ), + * @OA\Response( + * response=401, + * description="Unauthorized", + * @OA\JsonContent( + * allOf={ + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema( + * @OA\Property(property="code", type="string", example="UNAUTHORIZED"), + * @OA\Property(property="message", type="string", example="Unauthorized"), + * @OA\Property(property="data", type="null") + * ) + * } + * ) + * ), + * @OA\Parameter( + * name="Accept", + * in="header", + * required=true, + * @OA\Schema(type="string", default="application/json") + * ) + * ) + */ public function profile(Request $request) { - return response()->json($request->user()); + return \App\Http\Responses\ApiResponse::success($request->user()); } } diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 784ab4d..ad6fabf 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -29,18 +29,4 @@ use Illuminate\Routing\Controller as BaseController; class Controller extends BaseController { use AuthorizesRequests, DispatchesJobs, ValidatesRequests; - - /** - * @OA\Schema( - * schema="UnauthorizedResponse", - * allOf={ - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema( - * @OA\Property(property="code", type="string", example="UNAUTHORIZED"), - * @OA\Property(property="message", type="string", example="Unauthorized"), - * @OA\Property(property="data", type="null", nullable=true) - * ) - * } - * ) - */ } \ No newline at end of file diff --git a/app/Http/Controllers/RoomControlController.php b/app/Http/Controllers/RoomControlController.php new file mode 100644 index 0000000..9387c47 --- /dev/null +++ b/app/Http/Controllers/RoomControlController.php @@ -0,0 +1,157 @@ +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); + + } + /** + * @OA\Post( + * path="/api/room/sendSwitch", + * summary="送出包廂控制指令", + * description="依據傳入的 room_id 與 command,透過 TCP 傳送對應指令給包廂電腦。", + * operationId="sendRoomSwitchCommand", + * tags={"Room Control"}, + * security={{"Authorization":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent(ref="#/components/schemas/SendRoomSwitchCommandRequest") + * ), + * @OA\Response( + * response=200, + * description="成功傳送指令並回傳 TCP 回應", + * @OA\JsonContent( + * allOf={ + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema( + * @OA\Property(property="data", ref="#/components/schemas/Room") + * ) + * } + * ) + * ), + * @OA\Response( + * response=401, + * description="Unauthorized", + * @OA\JsonContent( + * allOf={ + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema( + * @OA\Property(property="code", type="string", example="UNAUTHORIZED"), + * @OA\Property(property="message", type="string", example="Unauthorized"), + * @OA\Property(property="data", type="null") + * ) + * } + * ) + * ), + * @OA\Parameter( + * name="Accept", + * in="header", + * required=true, + * @OA\Schema(type="string", default="application/json") + * ) + * ) + */ + 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'); + + //dd($data); + $client = new TcpSocketClient($ip, $port); + try { + $response = $client->send($data); + + $room->status=$validated['command']; + $room->started_at=$validated['started_at']; + $room->ended_at=$validated['ended_at']; + $room->save(); + + return \App\Http\Responses\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()); + } + } +} diff --git a/app/Http/Requests/ApiRequest.php b/app/Http/Requests/ApiRequest.php new file mode 100644 index 0000000..a05875b --- /dev/null +++ b/app/Http/Requests/ApiRequest.php @@ -0,0 +1,31 @@ +|string> + */ + public function rules(): array + { + return [ + 'branch_id' => 'required|integer|exists:branches,id', + 'room_name' => 'required|string', + 'room_ip' => 'nullable|ip', + ]; + } +} diff --git a/app/Http/Requests/SendRoomSwitchCommandRequest.php b/app/Http/Requests/SendRoomSwitchCommandRequest.php new file mode 100644 index 0000000..e002345 --- /dev/null +++ b/app/Http/Requests/SendRoomSwitchCommandRequest.php @@ -0,0 +1,35 @@ +|string> + */ + public function rules(): array + { + return [ + 'branch_id' => 'required|integer', + 'room_name' => 'required|string', + 'command' => 'required|string', + 'started_at' => 'nullable|date_format:Y-m-d H:i:s', + 'ended_at' => 'nullable|date_format:Y-m-d H:i:s', + ]; + } +} diff --git a/app/Http/Requests/Traits/FailedValidationJsonResponse.php b/app/Http/Requests/Traits/FailedValidationJsonResponse.php new file mode 100644 index 0000000..bd6c0ac --- /dev/null +++ b/app/Http/Requests/Traits/FailedValidationJsonResponse.php @@ -0,0 +1,20 @@ +json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + 'code' => 'ERROR', + 'token' => '' + ], Response::HTTP_UNPROCESSABLE_ENTITY)); + } +} \ No newline at end of file diff --git a/app/Models/Room.php b/app/Models/Room.php index 42ca611..2a07725 100644 --- a/app/Models/Room.php +++ b/app/Models/Room.php @@ -6,6 +6,19 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use App\Traits\LogsModelActivity; +/** + * @OA\Schema( + * schema="Room", + * type="object", + * @OA\Property(property="id", type="integer", example=16), + * @OA\Property(property="name", type="string", example="pc102"), + * @OA\Property(property="internal_ip", type="string", example="192.168.11.7"), + * @OA\Property(property="port", type="int", example="9000"), + * @OA\Property(property="status", ref="#/components/schemas/RoomStatus"), + * @OA\Property(property="started_at", type="string", format="date-time", example="2025-05-11T16:00:00.000000Z"), + * @OA\Property(property="ended_at", type="string", format="date-time", example=null), + * ) + */ class Room extends Model { /** @use HasFactory<\Database\Factories\ArtistFactory> */ @@ -20,6 +33,11 @@ class Room extends Model 'ended_at', ]; + protected $hidden = [ + 'internal_ip', + 'port', + ]; + protected $casts = [ 'name' => 'string', 'internal_ip' =>'string', diff --git a/app/Models/RoomStatusLog.php b/app/Models/RoomStatusLog.php index d36c84a..7080beb 100644 --- a/app/Models/RoomStatusLog.php +++ b/app/Models/RoomStatusLog.php @@ -10,7 +10,7 @@ class RoomStatusLog extends Model /** @use HasFactory<\Database\Factories\ArtistFactory> */ use HasFactory; - public $timestamps = false; + public $timestamps = true; protected $fillable = [ @@ -18,11 +18,10 @@ class RoomStatusLog extends Model 'user_id', 'status', 'message', - 'logged_at' ]; protected $casts = [ - 'logged_at' => 'datetime', + 'status' => \App\Enums\RoomStatus::class, ]; public function room() { diff --git a/app/Observers/RoomObserver.php b/app/Observers/RoomObserver.php index 01a9775..d8a3649 100644 --- a/app/Observers/RoomObserver.php +++ b/app/Observers/RoomObserver.php @@ -2,7 +2,10 @@ namespace App\Observers; +use Illuminate\Support\Facades\Auth; + use App\Models\Room; +use App\Models\RoomStatusLog; class RoomObserver { @@ -25,8 +28,7 @@ class RoomObserver 'room_id' => $room->id, 'user_id' => Auth::id(), // 若是 console 或系統自動操作可能為 null 'status' => $room->status, - 'message' => '狀態自動變更紀錄', - 'logged_at' => now(), + 'message' => 'started_at:'.$room->started_at.',ended_at:'.$room->ended_at, ]); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..4828850 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,9 @@ namespace App\Providers; use Illuminate\Support\ServiceProvider; +use App\Models\Room; +use App\Observers\RoomObserver; + class AppServiceProvider extends ServiceProvider { /** @@ -19,6 +22,6 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + Room::observe(RoomObserver::class); } } diff --git a/app/Services/TcpSocketClient.php b/app/Services/TcpSocketClient.php new file mode 100644 index 0000000..45530b5 --- /dev/null +++ b/app/Services/TcpSocketClient.php @@ -0,0 +1,43 @@ +ip = $ip; + $this->port = $port; + $this->timeout = $timeout; + } + + public function send(string $data): string + { + $socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + if ($socket === false) { + throw new \Exception("Socket create failed: " . socket_strerror(socket_last_error())); + } + + $result = socket_connect($socket, $this->ip, $this->port); + if ($result === false) { + throw new \Exception("Socket connect failed: " . socket_strerror(socket_last_error($socket))); + } + + socket_write($socket, $data, strlen($data)); + + $response = ''; + while ($out = socket_read($socket, 2048)) { + $response .= $out; + // 根據協議判斷是否結束接收,可以自行調整 + if (strpos($response, "\n") !== false) { + break; + } + } + + socket_close($socket); + return $response; + } +} \ No newline at end of file diff --git a/database/migrations/2025_05_06_055307_create_rooms_table.php b/database/migrations/2025_05_06_055307_create_rooms_table.php index 5cbc491..81466a9 100644 --- a/database/migrations/2025_05_06_055307_create_rooms_table.php +++ b/database/migrations/2025_05_06_055307_create_rooms_table.php @@ -17,7 +17,7 @@ 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', 'error', 'maintenance'])->default('error')->comment('狀態'); // :啟用中 / 已結束 + $table->enum('status', ['active', 'closed','fire', 'error', 'maintenance'])->default('error')->comment('狀態'); // :啟用中 / 已結束 $table->dateTime('started_at')->nullable()->comment('開始時間'); // $table->dateTime('ended_at')->nullable()->comment('結束時間'); // $table->timestamps(); diff --git a/database/migrations/2025_05_06_055312_create_room_status_logs_table.php b/database/migrations/2025_05_06_055312_create_room_status_logs_table.php index 8c27e98..33c9743 100644 --- a/database/migrations/2025_05_06_055312_create_room_status_logs_table.php +++ b/database/migrations/2025_05_06_055312_create_room_status_logs_table.php @@ -15,7 +15,7 @@ return new class extends Migration $table->id(); $table->foreignId('room_id')->constrained()->onDelete('cascade'); $table->foreignId('user_id')->nullable()->constrained()->onDelete('set null'); // 操作者,可為 null(系統) - $table->enum('status', ['active', 'closed', 'error', 'maintenance']); + $table->enum('status', ['active', 'closed','fire', 'error', 'maintenance']); $table->text('message')->nullable(); // 可填異常原因或操作說明 $table->timestamps(); }); diff --git a/resources/lang/zh-tw/enums.php b/resources/lang/zh-tw/enums.php index 46d6eab..a8f1c8b 100644 --- a/resources/lang/zh-tw/enums.php +++ b/resources/lang/zh-tw/enums.php @@ -26,6 +26,7 @@ return [ 'room.status.Active' => '已占用', 'room.status.Closed' => '可用', + 'room.status.Fire' => '火災', 'room.status.Error' => '異常', 'room.status.Maintenance' => '維修', ]; \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index 081634a..4e5ad37 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,10 +3,13 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\ArtistController; use App\Http\Controllers\AuthController; +use App\Http\Controllers\RoomControlController; Route::get('/artists/search', [App\Http\Controllers\ArtistController::class, 'search'])->name('api.artists.search'); 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']); }); \ No newline at end of file diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json index 1643ba9..e43a1dd 100644 --- a/storage/api-docs/api-docs.json +++ b/storage/api-docs/api-docs.json @@ -127,10 +127,201 @@ } ] } + }, + "/api/room/receiveRegister": { + "post": { + "tags": [ + "Room Control" + ], + "summary": "包廂註冊控制指令", + "description": "依據傳入的 branch_id 與 room_name,知道過來的設備來之於那個IP設備。", + "operationId": "registerRoomCommand", + "parameters": [ + { + "name": "Accept", + "in": "header", + "required": true, + "schema": { + "type": "string", + "default": "application/json" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReceiveRoomRegisterRequest" + } + } + } + }, + "responses": { + "200": { + "description": "成功傳送指令並回傳 TCP 回應", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/Room" + } + }, + "type": "object" + } + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResponse" + }, + { + "properties": { + "code": { + "type": "string", + "example": "UNAUTHORIZED" + }, + "message": { + "type": "string", + "example": "Unauthorized" + }, + "data": { + "type": "null" + } + }, + "type": "object" + } + ] + } + } + } + } + }, + "security": [ + { + "Authorization": [] + } + ] + } + }, + "/api/room/sendSwitch": { + "post": { + "tags": [ + "Room Control" + ], + "summary": "送出包廂控制指令", + "description": "依據傳入的 room_id 與 command,透過 TCP 傳送對應指令給包廂電腦。", + "operationId": "sendRoomSwitchCommand", + "parameters": [ + { + "name": "Accept", + "in": "header", + "required": true, + "schema": { + "type": "string", + "default": "application/json" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendRoomSwitchCommandRequest" + } + } + } + }, + "responses": { + "200": { + "description": "成功傳送指令並回傳 TCP 回應", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/Room" + } + }, + "type": "object" + } + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResponse" + }, + { + "properties": { + "code": { + "type": "string", + "example": "UNAUTHORIZED" + }, + "message": { + "type": "string", + "example": "Unauthorized" + }, + "data": { + "type": "null" + } + }, + "type": "object" + } + ] + } + } + } + } + }, + "security": [ + { + "Authorization": [] + } + ] + } } }, "components": { "schemas": { + "RoomStatus": { + "type": "string", + "enum": [ + "active", + "closed", + "fire", + "error", + "maintenance" + ], + "example": "error" + }, "UserGender": { "type": "string", "enum": [ @@ -151,6 +342,64 @@ ], "example": "0" }, + "ReceiveRoomRegisterRequest": { + "required": [ + "branch_id", + "room_id", + "ip" + ], + "properties": { + "branch_id": { + "type": "integer", + "example": "5" + }, + "room_name": { + "type": "string", + "example": "pc102" + }, + "ip": { + "type": "string", + "example": "192.168.x.x" + } + }, + "type": "object" + }, + "SendRoomSwitchCommandRequest": { + "required": [ + "room_id", + "command" + ], + "properties": { + "branch_id": { + "type": "integer", + "example": "5" + }, + "room_name": { + "type": "string", + "example": "pc102" + }, + "command": { + "type": "string", + "enum": [ + "active", + "closed", + "maintenance" + ], + "example": "active" + }, + "started_at": { + "type": "string", + "example": "2025-05-19 09:31:00", + "nullable": true + }, + "ended_at": { + "type": "string", + "example": "2025-05-19 09:31:00", + "nullable": true + } + }, + "type": "object" + }, "ApiResponse": { "properties": { "code": { @@ -168,6 +417,40 @@ }, "type": "object" }, + "Room": { + "properties": { + "id": { + "type": "integer", + "example": 16 + }, + "name": { + "type": "string", + "example": "pc102" + }, + "internal_ip": { + "type": "string", + "example": "192.168.11.7" + }, + "port": { + "type": "integer", + "example": "9000" + }, + "status": { + "$ref": "#/components/schemas/RoomStatus" + }, + "started_at": { + "type": "string", + "format": "date-time", + "example": "2025-05-11T16:00:00.000000Z" + }, + "ended_at": { + "type": "string", + "format": "date-time", + "example": null + } + }, + "type": "object" + }, "User": { "properties": { "id": { @@ -216,7 +499,11 @@ }, { "name": "Auth", - "description": "用戶註冊、登入、登出與個人資料" + "description": "包廂控制" + }, + { + "name": "Room Control", + "description": "Room Control" } ] } \ No newline at end of file diff --git a/tests/Feature/TcpSocketClientTest.php b/tests/Feature/TcpSocketClientTest.php new file mode 100644 index 0000000..bd3be91 --- /dev/null +++ b/tests/Feature/TcpSocketClientTest.php @@ -0,0 +1,43 @@ +fail("Unable to fork process for mock TCP server."); + } elseif ($pid == 0) { + // 子處理序:啟動簡單 TCP server + $socket = stream_socket_server("tcp://$host:$port", $errno, $errstr); + if (!$socket) { + exit(1); + } + $conn = stream_socket_accept($socket); + $data = fread($conn, 1024); // 收資料 + fwrite($conn, "Echo: " . $data); // 回傳資料 + fclose($conn); + fclose($socket); + exit(0); + } else { + // 父處理序:給 server 一點時間啟動 + usleep(100_000); // 0.1 秒 + + $client = new TcpSocketClient($host, $port); + $response = $client->send("hello test\n"); + + $this->assertEquals("Echo: hello test\n", $response); + + pcntl_wait($status); // 等子程序結束 + } + } +} \ No newline at end of file diff --git a/開發手冊.ini b/開發手冊.ini index dcc2f6d..d4bb37c 100644 --- a/開發手冊.ini +++ b/開發手冊.ini @@ -126,4 +126,7 @@ php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider" php artisan migrate composer require "darkaonline/l5-swagger" -php artisan vendor:publish --provider "L5Swagger\L5SwaggerServiceProvider" \ No newline at end of file +php artisan vendor:publish --provider "L5Swagger\L5SwaggerServiceProvider" +php -d memory_limit=512M artisan l5-swagger:generate + +4|qdXjrZTvWzOE5kNhbmOXFO6d7DrFzGh1RqhVATLZf8475a6b \ No newline at end of file