Swagger 都改到ApiResponse 輸出

Swagger Room加入TcpSocketClient
Swagger room 加入 設備註冊,設備開關
Swagger room 有異動需要寫記錄
20250519
This commit is contained in:
allen.yan 2025-05-19 16:08:35 +08:00
parent 3e9d02451e
commit ae5ed4aa1f
21 changed files with 737 additions and 65 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -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'),
};

View File

@ -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());
}
}

View File

@ -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)
* )
* }
* )
*/
}

View File

@ -0,0 +1,157 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\SendRoomSwitchCommandRequest;
use App\Http\Requests\ReceiveRoomRegisterRequest;
use App\Services\TcpSocketClient;
use Illuminate\Http\JsonResponse;
use App\Models\Room;
use App\Enums\RoomStatus;
/**
* @OA\Tag(
* name="Auth",
* description="包廂控制"
* )
*/
class RoomControlController extends Controller
{
/**
* @OA\Post(
* path="/api/room/receiveRegister",
* summary="包廂註冊控制指令",
* description="依據傳入的 branch_id 與 room_name知道過來的設備來之於那個IP設備。",
* operationId="registerRoomCommand",
* tags={"Room Control"},
* security={{"Authorization":{}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/ReceiveRoomRegisterRequest")
* ),
* @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 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);
}
/**
* @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());
}
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use App\Http\Requests\Traits\FailedValidationJsonResponse;
class ApiRequest extends FormRequest
{
use FailedValidationJsonResponse;
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules(): array{
return [];
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
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"),
* )
*/
class ReceiveRoomRegisterRequest 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 [
'branch_id' => 'required|integer|exists:branches,id',
'room_name' => 'required|string',
'room_ip' => 'nullable|ip',
];
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* @OA\Schema(
* schema="SendRoomSwitchCommandRequest",
* required={"room_id", "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"),
* @OA\Property(property="started_at", type="string", nullable=true, example="2025-05-19 09:31:00"),
* @OA\Property(property="ended_at", type="string", nullable=true, example="2025-05-19 09:31:00")
* )
*/
class SendRoomSwitchCommandRequest 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 [
'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',
];
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Traits;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\Response;
trait FailedValidationJsonResponse
{
protected function failedValidation(Validator $validator)
{
throw new HttpResponseException(response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
'code' => 'ERROR',
'token' => ''
], Response::HTTP_UNPROCESSABLE_ENTITY));
}
}

View File

@ -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',

View File

@ -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() {

View File

@ -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,
]);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Services;
class TcpSocketClient
{
protected $ip;
protected $port;
protected $timeout;
public function __construct(string $ip, int $port, int $timeout = 5)
{
$this->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;
}
}

View File

@ -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();

View File

@ -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();
});

View File

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

View File

@ -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']);
});

View File

@ -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"
}
]
}

View File

@ -0,0 +1,43 @@
<?php
// tests/Feature/TcpSocketClientTest.php
namespace Tests\Feature;
use Tests\TestCase;
use App\Services\TcpSocketClient;
class TcpSocketClientTest extends TestCase
{
public function testCanSendAndReceiveTcpMessage()
{
$host = '127.0.0.1';
$port = 12345;
// 建立一個假的 TCP server (background)
$pid = pcntl_fork();
if ($pid == -1) {
$this->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); // 等子程序結束
}
}
}

View File

@ -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"
php artisan vendor:publish --provider "L5Swagger\L5SwaggerServiceProvider"
php -d memory_limit=512M artisan l5-swagger:generate
4|qdXjrZTvWzOE5kNhbmOXFO6d7DrFzGh1RqhVATLZf8475a6b