Добавлен REST API для мобильного приложения (этап 3, без Telegram)

Токен-аутентификация Sanctum (login/logout по устройствам),
эндпоинты /api/v1 для зон/устройств/правил автоматизации с тем же
RBAC (owner/viewer), что и в веб-версии — контроллеры переиспользуют
существующие Policy и FormRequest. Устройства отдают живой статус
из Device Shadow (Redis) и историю телеметрии из ClickHouse, плюс
управление (turn-on/turn-off/set-level) через device-control-service.
This commit is contained in:
2026-08-12 00:21:38 +05:00
parent 81040eec62
commit e739ef5d38
15 changed files with 872 additions and 4 deletions
@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
/**
* Issue a Sanctum personal access token for a mobile client. Each
* client names its own token (device_name) so a user can see/revoke
* per-device sessions later without logging everyone out at once.
*/
public function login(Request $request)
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
'device_name' => ['required', 'string', 'max:255'],
]);
$user = User::where('email', $credentials['email'])->first();
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
throw ValidationException::withMessages([
'email' => ['Неверный email или пароль.'],
]);
}
return response()->json([
'token' => $user->createToken($credentials['device_name'])->plainTextToken,
'user' => new UserResource($user),
]);
}
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->noContent();
}
public function me(Request $request)
{
return new UserResource($request->user());
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\AutomationRuleRequest;
use App\Http\Resources\AutomationRuleResource;
use App\Models\AutomationRule;
class AutomationRuleController extends Controller
{
public function index()
{
$this->authorize('viewAny', AutomationRule::class);
$rules = AutomationRule::with(['zone', 'targetDevice', 'conditionSourceDevice'])
->orderByDesc('id')
->get();
return AutomationRuleResource::collection($rules);
}
public function store(AutomationRuleRequest $request)
{
$rule = new AutomationRule($this->mapActionParams($request->validated()));
$rule->user()->associate($request->user());
$rule->save();
return (new AutomationRuleResource($rule))->response()->setStatusCode(201);
}
public function update(AutomationRuleRequest $request, AutomationRule $automation_rule)
{
$automation_rule->update($this->mapActionParams($request->validated()));
return new AutomationRuleResource($automation_rule);
}
public function destroy(AutomationRule $automation_rule)
{
$this->authorize('delete', $automation_rule);
$automation_rule->delete();
return response()->noContent();
}
/**
* Mirrors AutomationRuleController@mapActionParams on the web side
* "level" is a friendlier stand-in for action_params over the wire too,
* so mobile clients don't need to know the {"level": ...} shape.
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function mapActionParams(array $data): array
{
$data['action_params'] = $data['action_type'] === 'set_level'
? ['level' => $data['level']]
: [];
unset($data['level']);
return $data;
}
}
@@ -0,0 +1,126 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\DeviceRequest;
use App\Http\Resources\DeviceResource;
use App\Models\Device;
use App\Services\ClickHouseClient;
use App\Services\DeviceControlClient;
use App\Services\DeviceShadow;
use Illuminate\Http\Request;
class DeviceController extends Controller
{
public function index(DeviceShadow $shadow)
{
$this->authorize('viewAny', Device::class);
$devices = Device::with(['zone', 'deviceType'])->orderBy('name')->get();
$statuses = $shadow->statuses($devices->pluck('external_id')->all());
$devices->each(fn (Device $d) => $d->live_status = $statuses[$d->external_id] ?? 'unknown');
return DeviceResource::collection($devices);
}
public function show(Device $device, DeviceShadow $shadow, ClickHouseClient $clickHouse)
{
$this->authorize('view', $device);
$device->load(['zone', 'deviceType']);
$snapshot = $shadow->snapshot($device->external_id);
$device->live_status = $snapshot['status'];
$telemetry = [];
if ($device->deviceType->category->value === 'sensor') {
$telemetry = $clickHouse->query(
'SELECT sensor_type, value, recorded_at FROM telemetry
WHERE device_id = {device_id:String}
ORDER BY recorded_at DESC LIMIT 50',
['device_id' => $device->external_id],
);
}
return (new DeviceResource($device))->additional([
'shadow' => [
'last_seen' => $snapshot['last_seen'],
'desired_state' => $snapshot['desired_state'],
'reported_state' => $snapshot['reported_state'],
],
'telemetry' => $telemetry,
]);
}
public function store(DeviceRequest $request)
{
$device = new Device($request->validated());
$device->user()->associate($request->user());
$device->save();
return (new DeviceResource($device))->response()->setStatusCode(201);
}
public function update(DeviceRequest $request, Device $device)
{
$device->update($request->validated());
return new DeviceResource($device);
}
public function destroy(Device $device)
{
$this->authorize('delete', $device);
$device->delete();
return response()->noContent();
}
public function turnOn(Device $device, DeviceControlClient $client)
{
$this->authorize('update', $device);
$this->ensureCapability($device, 'turn_on');
return $this->respondToCommand($client->turnOn($device->external_id));
}
public function turnOff(Device $device, DeviceControlClient $client)
{
$this->authorize('update', $device);
$this->ensureCapability($device, 'turn_off');
return $this->respondToCommand($client->turnOff($device->external_id));
}
public function setLevel(Request $request, Device $device, DeviceControlClient $client)
{
$this->authorize('update', $device);
$this->ensureCapability($device, 'set_level');
$validated = $request->validate(['level' => ['required', 'numeric']]);
return $this->respondToCommand($client->setLevel($device->external_id, (float) $validated['level']));
}
private function ensureCapability(Device $device, string $capability): void
{
abort_unless(
in_array($capability, $device->deviceType->capabilities ?? [], true),
422,
"Устройство не поддерживает действие «{$capability}».",
);
}
/**
* @param array{success: bool, error: ?string} $result
*/
private function respondToCommand(array $result)
{
if (! $result['success']) {
return response()->json(['message' => $result['error'] ?? 'Команда не выполнена.'], 422);
}
return response()->json(['message' => 'Команда отправлена.']);
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\DeviceTypeResource;
use App\Models\DeviceType;
class DeviceTypeController extends Controller
{
public function index()
{
return DeviceTypeResource::collection(DeviceType::orderBy('code')->get());
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\ZoneRequest;
use App\Http\Resources\ZoneResource;
use App\Models\Zone;
class ZoneController extends Controller
{
public function index()
{
$this->authorize('viewAny', Zone::class);
return ZoneResource::collection(Zone::withCount('devices')->orderBy('name')->get());
}
public function store(ZoneRequest $request)
{
$zone = $request->user()->zones()->create($request->validated());
return (new ZoneResource($zone))->response()->setStatusCode(201);
}
public function update(ZoneRequest $request, Zone $zone)
{
$zone->update($request->validated());
return new ZoneResource($zone);
}
public function destroy(Zone $zone)
{
$this->authorize('delete', $zone);
$zone->delete();
return response()->noContent();
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Resources;
use App\Models\AutomationRule;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin AutomationRule */
class AutomationRuleResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'zone' => new ZoneResource($this->whenLoaded('zone')),
'target_device' => new DeviceResource($this->whenLoaded('targetDevice')),
'condition_source_device' => new DeviceResource($this->whenLoaded('conditionSourceDevice')),
'condition_sensor_type' => $this->condition_sensor_type,
'condition_operator' => $this->condition_operator->value,
'condition_value' => $this->condition_value,
'action_type' => $this->action_type,
'action_params' => $this->action_params,
'is_active' => $this->is_active,
'created_at' => $this->created_at,
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Resources;
use App\Models\Device;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Device
*
* `devices.status` in Postgres is only a stale snapshot (see migration
* comment) Redis (Device Shadow) is authoritative. The controller stamps
* the live value onto the model as `live_status` before wrapping it here;
* `status` in the JSON always prefers that when present.
*/
class DeviceResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'external_id' => $this->external_id,
'protocol' => $this->protocol,
'status' => $this->live_status ?? $this->status,
'zone' => new ZoneResource($this->whenLoaded('zone')),
'device_type' => new DeviceTypeResource($this->whenLoaded('deviceType')),
'created_at' => $this->created_at,
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Resources;
use App\Models\DeviceType;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin DeviceType */
class DeviceTypeResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'code' => $this->code,
'category' => $this->category->value,
'capabilities' => $this->capabilities,
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Resources;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin User */
class UserResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'role' => $this->role->value,
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources;
use App\Models\Zone;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Zone */
class ZoneResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'devices_count' => $this->whenCounted('devices'),
'created_at' => $this->created_at,
];
}
}