Files
star-cloud/app/Http/Controllers/Api/V1/App/MachineController.php
sky121113 8f008ffb61
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 52s
[FEAT] 實作 B014 機台參數下載 API 與 B000 登入認證強化
1. 強化 B000 登入接口:驗證成功後回傳 Sanctum Token 供後續初始化使用。
2. 實作 B014 (getSettings) API:整合機台、金流與發票設定,並映射至 Android App 預期欄位。
3. 強化安全性:B014 API 掛載 auth:sanctum 並執行 RBAC 權限檢查。
4. 更新 API 說明文件 (iot-spec.md, api-docs.php) 及技術規範 (SKILL.md)。
2026-04-13 17:04:52 +08:00

542 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Http\Controllers\Api\V1\App;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Machine\Machine;
use App\Models\System\User;
use App\Jobs\Machine\ProcessHeartbeat;
use App\Jobs\Machine\ProcessTimerStatus;
use App\Jobs\Machine\ProcessCoinInventory;
use App\Jobs\Machine\ProcessMachineError;
use App\Jobs\Machine\ProcessStateLog;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Cache;
class MachineController extends Controller
{
/**
* B010: Machine Heartbeat & Status Update (Asynchronous)
*/
public function heartbeat(Request $request)
{
$machine = $request->get('machine');
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入的 Model 物件與認證 key
// === 狀態異動觸發 (Redis 快取免查 DB) ===
$cacheKey = "machine:{$machine->serial_no}:state";
$oldState = Cache::get($cacheKey);
$currentPage = $data['current_page'] ?? null;
$doorStatus = $data['door_status'] ?? null;
$firmwareVersion = $data['firmware_version'] ?? null;
$model = $data['model'] ?? null;
if ($currentPage !== null || $doorStatus !== null || $firmwareVersion !== null || $model !== null) {
// 更新目前狀態到 Redis (保存 1 天)
$newState = $oldState ?? [];
if ($currentPage !== null) $newState['current_page'] = $currentPage;
if ($doorStatus !== null) $newState['door_status'] = $doorStatus;
if ($firmwareVersion !== null) $newState['firmware_version'] = $firmwareVersion;
if ($model !== null) $newState['model'] = $model;
Cache::put($cacheKey, $newState, 86400);
// 若有歷史紀錄才進行比對 (避開 Cache Miss 造成的雪崩)
if ($oldState !== null) {
// 1. 判斷頁面是否變更
if ($currentPage !== null && (string)$currentPage !== (string)($oldState['current_page'] ?? '')) {
// 只記錄「絕對狀態」,配合 lang 中 "Page X" 的翻譯
ProcessStateLog::dispatch($machine->id, $machine->company_id, "Page {$currentPage}", 'info');
}
// 2. 判斷門禁是否變更 (0: 關閉, 1: 開啟)
if ($doorStatus !== null && (string)$doorStatus !== (string)($oldState['door_status'] ?? '')) {
$doorMessage = $doorStatus == 1 ? "Door Opened" : "Door Closed";
$doorLevel = 'info'; // 不論開關門皆為 info避免觸發異常狀態
ProcessStateLog::dispatch($machine->id, $machine->company_id, $doorMessage, $doorLevel);
}
// 3. 判斷韌體版本是否變更
if ($firmwareVersion !== null && (string)$firmwareVersion !== (string)($oldState['firmware_version'] ?? '')) {
$oldVersion = $oldState['firmware_version'] ?? 'Unknown';
// 直接在 Controller 進行翻譯並填值,確保儲存到 DB 的是最終正確字串
$versionMessage = __("Firmware updated to :version", ['version' => $firmwareVersion]);
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
$versionMessage,
'info',
['old' => $oldVersion, 'new' => $firmwareVersion]
);
}
// 4. 判斷型號是否變更
if ($model !== null && (string)$model !== (string)($oldState['model'] ?? '')) {
$oldModel = $oldState['model'] ?? 'Unknown';
$modelMessage = __("Model changed to :model", ['model' => $model]);
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
$modelMessage,
'info',
['old' => $oldModel, 'new' => $model]
);
}
}
}
// 異步處理狀態更新
ProcessHeartbeat::dispatch($machine->serial_no, $data);
// 取出待處理指令
$command = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
->pending()
->first();
$status = '49'; // 預設 49 (OK / No Command)
$message = 'OK';
if ($command) {
switch ($command->command_type) {
case 'reboot':
$status = '51';
$message = 'reboot';
break;
case 'reboot_card':
$status = '60';
$message = 'reboot card machine';
break;
case 'checkout':
$status = '61';
$message = 'checkout';
break;
case 'lock':
$status = '71';
$message = 'lock';
break;
case 'unlock':
$status = '70';
$message = 'unlock';
break;
case 'change':
$status = '82';
$message = $command->payload['amount'] ?? '0';
break;
case 'dispense':
$status = '85';
$message = $command->payload['slot_no'] ?? '';
break;
case 'reload_stock':
$status = '49';
$message = 'reload B017';
break;
}
// 標記為已發送 (sent)
$command->update(['status' => 'sent', 'executed_at' => now()]);
}
return response()->json([
'success' => true,
'code' => 200,
'message' => $message,
'status' => $status
], 202); // 202 Accepted
}
/**
* B018: Record Machine Restock/Setup Report (Asynchronous)
*/
public function recordRestock(Request $request)
{
$machine = $request->get('machine');
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
$data['serial_no'] = $machine->serial_no;
\App\Jobs\Machine\ProcessRestockReport::dispatch($data);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'Restock report accepted',
'status' => '49'
], 202);
}
/**
* B017: Get Slot Info & Stock (Synchronous)
*/
public function getSlots(Request $request)
{
$machine = $request->get('machine');
$slots = $machine->slots()->with('product')->get();
// 自動轉 Success: 若機台來撈 B017代表之前的 reload_stock 指令已成功被機台響應
\App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'reload_stock')
->where('status', 'sent')
->update(['status' => 'success', 'executed_at' => now()]);
return response()->json([
'success' => true,
'code' => 200,
'data' => $slots->map(function ($slot) {
return [
'slot_no' => $slot->slot_no,
'product_id' => $slot->product_id,
'stock' => $slot->stock,
'capacity' => $slot->capacity,
'price' => $slot->price,
'status' => $slot->status,
'expiry_date' => $slot->expiry_date,
'batch_no' => $slot->batch_no,
];
})
]);
}
/**
* B710: Sync Timer status (Asynchronous)
*/
public function syncTimer(Request $request)
{
$machine = $request->get('machine');
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
ProcessTimerStatus::dispatch($machine->serial_no, $data);
return response()->json(['success' => true], 202);
}
/**
* B220: Sync Coin Inventory (Asynchronous)
*/
public function syncCoinInventory(Request $request)
{
$machine = $request->get('machine');
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
ProcessCoinInventory::dispatch($machine->serial_no, $data);
return response()->json(['success' => true], 202);
}
/**
* B650: Verify Member Code/Barcode (Synchronous)
*/
public function verifyMember(Request $request)
{
$validator = Validator::make($request->all(), [
'code' => 'required|string',
]);
if ($validator->fails()) {
return response()->json(['success' => false, 'message' => 'Invalid code'], 400);
}
$code = $request->input('code');
// 搜尋會員 (barcode 或特定驗證碼)
$member = \App\Models\Member\Member::where('barcode', $code)
->orWhere('id', $code) // 暫時支援 ID
->first();
if (!$member) {
return response()->json([
'success' => false,
'code' => 404,
'message' => 'Member not found'
], 404);
}
return response()->json([
'success' => true,
'code' => 200,
'data' => [
'member_id' => $member->id,
'name' => $member->name,
'points' => $member->points,
'wallet_balance' => $member->wallet_balance ?? 0,
]
]);
}
/**
* B005: Download Machine Advertisements (Synchronous)
*/
public function getAdvertisements(Request $request)
{
$machine = $request->get('machine');
$advertisements = \App\Models\Machine\MachineAdvertisement::where('machine_id', $machine->id)
->with([
'advertisement' => function ($query) {
$query->playing();
}
])
->get()
->filter(fn($ma) => $ma->advertisement !== null)
->map(function ($ma) {
// 定義顯示順序權重 (待機 > 購物 > 成功禮)
$posWeight = [
'standby' => 1,
'vending' => 2,
'visit_gift' => 3
];
// 為了相容現有機台 App 邏輯:
// App 讀取 t070v03 作為位置標籤 (flag)
// 1. HomeActivity (待機) 讀取 "3"
// 2. FontendActivity (販賣頁) 讀取 "1"
$posIdMap = [
'standby' => '3',
'vending' => '1',
'visit_gift' => '2'
];
return [
't070v01' => $ma->advertisement->name,
't070v02' => (string) ($ma->advertisement->duration ?? 15), // 秒數改放這裡
't070v03' => (string) ($posIdMap[$ma->position] ?? '1'), // 位置數字改放這裡 (App 會讀這欄當 Flag)
't070v04' => $ma->advertisement->url ? (str_starts_with($ma->advertisement->url, 'http') ? $ma->advertisement->url : asset($ma->advertisement->url)) : '',
't070v05' => (string) $ma->sort_order,
'raw_pos_weight' => $posWeight[$ma->position] ?? 99,
'raw_sort' => (int) $ma->sort_order
];
})
->sortBy([
['raw_pos_weight', 'asc'],
['raw_sort', 'asc']
])
->values()
->map(function ($item) {
unset($item['raw_pos_weight'], $item['raw_sort']);
return $item;
});
return response()->json([
'success' => true,
'code' => 200,
'data' => $advertisements->values()
]);
}
/**
* B009: Report Machine Slot List / Supplementary (Synchronous)
*/
public function reportSlotList(Request $request, \App\Services\Machine\MachineService $machineService)
{
$machine = $request->get('machine');
$payload = $request->all();
$account = $payload['account'] ?? null;
// 1. 驗證帳號是否存在 (驗證執行補貨的人員身分)
$user = User::where('username', $account)
->orWhere('email', $account)
->first();
if (!$user) {
return response()->json([
'success' => false,
'code' => 403,
'message' => 'Unauthorized: Account not found',
'status' => ''
], 403);
}
// 2. 階層式權限驗證 (遵循 RBAC 多租戶規範)
$isAuthorized = false;
if ($user->isSystemAdmin()) {
// [系統層]:系統管理員可異動所有機台
$isAuthorized = true;
} elseif ($user->is_admin) {
// [公司層]:公司管理員需驗證此機台是否隸屬於該公司
$isAuthorized = ($machine->company_id === $user->company_id);
} else {
// [人員層]:一般人員需檢查 machine_user 授權表
$isAuthorized = $user->machines()->where('machine_id', $machine->id)->exists();
}
if (!$isAuthorized) {
return response()->json([
'success' => false,
'code' => 403,
'message' => 'Unauthorized: Account not authorized for this machine',
'status' => ''
], 403);
}
// 3. 映射舊版機台回傳格式 (Map legacy machine format)
// 支持單個物件 data: {} 或 陣列 data: [{}] (Handle both single object and array)
$legacyData = $payload['data'] ?? [];
if (Arr::isAssoc($legacyData)) {
$legacyData = [$legacyData];
}
$mappedSlots = array_map(function ($item) {
return [
'slot_no' => $item['tid'] ?? null,
'product_id' => $item['t060v00'] ?? null,
'stock' => $item['num'] ?? 0,
'type' => $item['type'] ?? null,
];
}, $legacyData);
// 過濾無效資料 (Filter invalid entries)
$mappedSlots = array_filter($mappedSlots, fn($s) => $s['slot_no'] !== null);
// 同步處理更新庫存 (直接更新不進隊列)
$machineService->syncSlots($machine, $mappedSlots);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'Slot report synchronized success',
'status' => '49'
]);
}
/**
* B012_new: Download Product Catalog (Synchronous)
*/
public function getProducts(Request $request)
{
$machine = $request->get('machine');
$products = \App\Models\Product\Product::where('company_id', $machine->company_id)
->with(['translations'])
->active()
->get()
->map(function ($product) {
// 提取多語系名稱 (Extract translations)
$nameEn = $product->translations->firstWhere('locale', 'en')?->value ?? '';
$nameJp = $product->translations->firstWhere('locale', 'ja')?->value ?? '';
return [
't060v00' => (string) $product->id, // ID 仍建議維持字串,增加未來編號彈性
't060v01' => $product->name,
't060v01_en' => $nameEn,
't060v01_jp' => $nameJp,
't060v03' => $product->spec ?? '',
't060v06' => $product->image_url ? (str_starts_with($product->image_url, 'http') ? $product->image_url : asset($product->image_url)) : '',
't060v09' => (float) $product->price,
't060v11' => (int) ($product->track_limit ?? 10),
't060v30' => (float) ($product->member_price ?? $product->price),
't060v40' => $product->metadata['marketing_plan'] ?? '', // 行銷計畫
't060v41' => $product->metadata['material_code'] ?? $product->barcode ?? '', // 物料編碼 (優先從 metadata 找,回退至條碼)
'spring_limit' => (int) ($product->spring_limit ?? 10),
'track_limit' => (int) ($product->track_limit ?? 10),
't063v03' => (float) $product->price,
];
});
return response()->json([
'success' => true,
'code' => 200,
'data' => $products
]);
}
/**
* B013: Report Machine Hardware Error/Status (Asynchronous)
*/
public function reportError(Request $request)
{
$machine = $request->get('machine');
$data = $request->only(['tid', 'error_code']);
// 異步分派處理 (Dispatch to queue)
ProcessMachineError::dispatch($machine->serial_no, $data);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'Error report accepted',
], 202); // 202 Accepted
}
/**
* B014: Download Machine Settings & Config (Synchronous, Requires User Auth)
* 用於機台引導階段,同步金流、發票與機台專屬 API Token。
*/
public function getSettings(Request $request)
{
$serialNo = $request->input('machine');
$user = $request->user();
// 1. 查找機台 (忽略全局範圍以進行認領)
$machine = Machine::withoutGlobalScopes()
->with(['paymentConfig', 'company'])
->where('serial_no', $serialNo)
->first();
if (!$machine) {
return response()->json([
'success' => false,
'code' => 404,
'message' => 'Machine not found'
], 404);
}
// 2. 權限加強驗證 (RBAC)
$isAuthorized = false;
if ($user->isSystemAdmin()) {
$isAuthorized = true;
} elseif ($machine->company_id === $user->company_id) {
// 公司管理員或已授權員工才能存取
if ($user->is_admin || $user->machines()->where('machine_id', $machine->id)->exists()) {
$isAuthorized = true;
}
}
if (!$isAuthorized) {
return response()->json([
'success' => false,
'code' => 403,
'message' => 'Forbidden: You do not have permission to configure this machine'
], 403);
}
// 3. 獲取關聯設定
$paymentSettings = $machine->paymentConfig->settings ?? [];
$companySettings = $machine->company->settings ?? [];
// 4. 映射 App 預期欄位 (嚴格遵守 HttpAPI.java 結構)
$data = [
't050v01' => $machine->serial_no,
'api_token' => $machine->api_token, // 向 App 核發正式通訊 Token
// 玉山支付
't050v41' => $paymentSettings['esun_store_id'] ?? '',
't050v42' => $paymentSettings['esun_term_id'] ?? '',
't050v43' => $paymentSettings['esun_hash'] ?? '',
// 電子發票 (綠界)
't050v34' => $companySettings['invoice_merchant_id'] ?? '',
't050v35' => $companySettings['invoice_hash_key'] ?? '',
't050v36' => $companySettings['invoice_hash_iv'] ?? '',
't050v38' => $companySettings['invoice_email'] ?? '',
// 趨勢支付 (TrendPay/Greenpay)
'TP_APP_ID' => $paymentSettings['tp_app_id'] ?? '',
'TP_APP_KEY' => $paymentSettings['tp_app_key'] ?? '',
'TP_PARTNER_KEY' => $paymentSettings['tp_partner_key'] ?? '',
// 各類行動支付特店 ID
'TP_LINE_MERCHANT_ID' => $paymentSettings['tp_line_merchant_id'] ?? '',
'TP_PS_MERCHANT_ID' => $paymentSettings['tp_ps_merchant_id'] ?? '',
'TP_EASY_MERCHANT_ID' => $paymentSettings['tp_easy_merchant_id'] ?? '',
'TP_PI_MERCHANT_ID' => $paymentSettings['tp_pi_merchant_id'] ?? '',
'TP_JKO_MERCHANT_ID' => $paymentSettings['tp_jko_merchant_id'] ?? '',
];
return response()->json([
'success' => true,
'code' => 200,
'data' => [$data] // App 預期的是包含單一物件的陣列
]);
}
}