[PROMOTE] dev → demo:B016 設定回寫與 B000 identity
1. [FEAT] B016 機台系統設定回寫端點:系統方回寫 machines.settings 與貨道欄位,含路由、SKILL.md §3.8 與 api-docs.php 文件 2. [FEAT] B000 登入回應新增 identity 身分欄位 (system/company/staff),並補 MachineAuthB000Test 共 5 案例 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
4e5f952f28
@ -51,6 +51,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
| success | Boolean | 請求是否成功 | true |
|
||||
| code | Integer | 業務狀態碼 | 200 |
|
||||
| message | String | 驗證結果 (Success 或 Failed) | Success |
|
||||
| identity | String | **登入者身分**:`system` 系統登入者 / `company` 公司帳號 / `staff` 一般人員。僅驗證成功時回傳 | system |
|
||||
| token | String | **臨時身份認證 Token** (可選,供未來擴充使用) | 1\|abcdefg... |
|
||||
|
||||
---
|
||||
@ -203,6 +204,51 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
|
||||
---
|
||||
|
||||
### 3.8 B016: 機台系統設定回寫 (Settings Write-back)
|
||||
機台主控台「系統設定」頁由**系統方**編輯後,將開關狀態回寫雲端。為 B014 下載的反向操作。
|
||||
|
||||
- **URL**: PATCH /api/v1/app/machine/setting/B016
|
||||
- **Authentication**: **User Token (Sanctum)** — 帶入 B000 核發之 `Authorization: Bearer <user_token>`,且**僅系統管理員 (`identity=system`)** 可操作;非系統方一律 403。
|
||||
- **Headers**:
|
||||
- `Content-Type: application/json`
|
||||
- `Authorization: Bearer <user_token>`
|
||||
- **Request Body:**
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| machine | String | 是 | 機台序號 (serial_no) | SN202604130001 |
|
||||
| settings | Object | 是 | 開關鍵值物件,鍵名採**後端原始 snake_case**(非 B014 的 PascalCase `*Set`)。只覆寫有送來的鍵,其餘保留 | {"tax_invoice_enabled": true} |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **命名方向不對稱**:B014 **下載**以 `DevSet`/`CashSet`/... PascalCase 包裝下發;B016 **回寫**直接使用後端鍵名(如 `shopping_cart_enabled`、`cash_bill_1000`、`is_spring_slot_1_10`)。
|
||||
> **白名單限定**:僅接受下列布林開關鍵,未列入者一律忽略;**不接受** `shopping_mode` 及 `OperationSet` 等非開關欄位(避免誤改)。
|
||||
|
||||
- **可回寫鍵(settings 之下,皆為布林):**
|
||||
- 支付旗標:`shopping_cart_enabled`、`tax_invoice_enabled`、`card_terminal_enabled`、`credit_card_enabled`、`mobile_pay_enabled`、`card_pay_enabled`、`scan_pay_enabled`、`scan_pay_esun_enabled`、`scan_pay_tappay_enabled`、`cash_module_enabled`、`scan_pay_linepay_enabled`、`tappay_linepay`、`tappay_jkopay`、`tappay_easywallet`、`tappay_pipay`、`tappay_pluspay`
|
||||
- 現金面額:`cash_bill_1000`、`cash_bill_500`、`cash_bill_100`、`cash_coin_50`、`cash_coin_10`、`cash_coin_5`、`cash_coin_1`
|
||||
- 功能模組:`pickup_module_enabled`、`pickup_code_enabled`、`pass_code_enabled`、`welcome_gift_enabled`、`member_system_enabled`、`ambient_temp_monitoring_enabled`
|
||||
- 貨道類型(machines 實體欄位):`is_spring_slot_1_10`、`is_spring_slot_11_20`、`is_spring_slot_21_30`、`is_spring_slot_31_40`、`is_spring_slot_41_50`、`is_spring_slot_51_60`(true=彈簧 / false=履帶)
|
||||
|
||||
- **Response Body (Success 200):**
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| success | Boolean | 是否成功 | true |
|
||||
| code | Integer | 業務狀態碼 | 200 |
|
||||
| message | String | 回應訊息 | Settings updated successfully. |
|
||||
|
||||
- **錯誤回應:**
|
||||
|
||||
| HTTP | 情境 | message |
|
||||
| :--- | :--- | :--- |
|
||||
| 403 | 非系統管理員 / 未登入 | Forbidden |
|
||||
| 404 | 查無該序號機台 | Machine not found |
|
||||
| 422 | `settings` 非物件/陣列 | Invalid settings payload |
|
||||
|
||||
> 註:寫回時同步更新 `machines.settings` JSON 與 `is_spring_slot_*` 實體欄位,並記錄 `updater_id`。為 system-admin 專用,套用 `withoutGlobalScopes` 跨租戶定位機台。
|
||||
|
||||
---
|
||||
|
||||
### 3.9 B660: 取貨碼驗證 (Pickup Code)
|
||||
處理機台端的 8 位數代碼取貨流程。
|
||||
|
||||
|
||||
@ -59,16 +59,22 @@ class MachineAuthController extends Controller
|
||||
}
|
||||
|
||||
// 5. RBAC 權限驗證 (遵循多租戶與機台授權規範)
|
||||
// $identity: 登入者身分 (system:系統登入者 / company:公司帳號 / staff:一般人員)
|
||||
// 僅在授權通過時賦值,確保未授權者不洩漏身分
|
||||
$isAuthorized = false;
|
||||
$identity = null;
|
||||
if ($user->isSystemAdmin()) {
|
||||
$isAuthorized = true;
|
||||
$identity = 'system';
|
||||
} elseif ($user->is_admin) {
|
||||
if ($machine->company_id === $user->company_id) {
|
||||
$isAuthorized = true;
|
||||
$identity = 'company';
|
||||
}
|
||||
} else {
|
||||
if ($user->machines()->where('machine_id', $machine->id)->exists()) {
|
||||
$isAuthorized = true;
|
||||
$identity = 'staff';
|
||||
}
|
||||
}
|
||||
|
||||
@ -120,6 +126,7 @@ class MachineAuthController extends Controller
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'Success',
|
||||
'identity' => $identity,
|
||||
'token' => $user->createToken('technician-setup', ['*'], now()->addHours(8))->plainTextToken
|
||||
]);
|
||||
}
|
||||
|
||||
@ -534,6 +534,111 @@ class MachineController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* B016: Update Machine System Settings (Write-back from machine console)
|
||||
* 機台主控台「系統設定」由系統方 (identity=system) 編輯後回寫雲端。
|
||||
* 認證:B000 核發的使用者 Token (auth:sanctum);僅系統管理員可操作。
|
||||
* Body: { machine: 序號, settings: { <後端鍵名>: bool, ... } }
|
||||
* 接收的開關鍵名與 B014 getSettings 下發來源一致(machines.settings JSON 與
|
||||
* is_spring_slot_* 實體欄位)。不處理 shopping_mode 等非開關欄位,避免誤改。
|
||||
*/
|
||||
public function updateSettings(Request $request)
|
||||
{
|
||||
// 1. 僅系統方可回寫(雙重把關:App 端隱藏控制 + 此處伺服器驗權)
|
||||
$user = $request->user();
|
||||
if (!$user || !$user->isSystemAdmin()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 403,
|
||||
'message' => __('Forbidden')
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 2. 查找機台
|
||||
$serialNo = $request->input('machine');
|
||||
$machine = Machine::withoutGlobalScopes()
|
||||
->where('serial_no', $serialNo)
|
||||
->first();
|
||||
|
||||
if (!$machine) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 404,
|
||||
'message' => __('Machine not found')
|
||||
], 404);
|
||||
}
|
||||
|
||||
$settings = $request->input('settings', []);
|
||||
if (!is_array($settings)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 422,
|
||||
'message' => __('Invalid settings payload')
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 3. 開關白名單:machines.settings JSON 內的布林鍵(對齊 B014 getSettings 來源)
|
||||
$jsonBoolKeys = [
|
||||
'shopping_cart_enabled', 'tax_invoice_enabled', 'card_terminal_enabled',
|
||||
'credit_card_enabled', 'mobile_pay_enabled', 'card_pay_enabled', 'scan_pay_enabled',
|
||||
'scan_pay_esun_enabled', 'scan_pay_tappay_enabled', 'cash_module_enabled', 'scan_pay_linepay_enabled',
|
||||
'tappay_linepay', 'tappay_jkopay', 'tappay_easywallet', 'tappay_pipay', 'tappay_pluspay',
|
||||
'cash_bill_1000', 'cash_bill_500', 'cash_bill_100',
|
||||
'cash_coin_50', 'cash_coin_10', 'cash_coin_5', 'cash_coin_1',
|
||||
'pickup_module_enabled', 'pickup_code_enabled', 'pass_code_enabled',
|
||||
'welcome_gift_enabled', 'member_system_enabled', 'ambient_temp_monitoring_enabled',
|
||||
];
|
||||
// 直接欄位白名單:machines 實體布林欄位(貨道類型)
|
||||
$columnBoolKeys = [
|
||||
'is_spring_slot_1_10', 'is_spring_slot_11_20', 'is_spring_slot_21_30',
|
||||
'is_spring_slot_31_40', 'is_spring_slot_41_50', 'is_spring_slot_51_60',
|
||||
];
|
||||
// 同時具備實體欄位的 settings 鍵(mutator 同步,需一併寫回避免不一致)
|
||||
$mirrorColumns = [
|
||||
'tax_invoice_enabled', 'card_terminal_enabled', 'scan_pay_esun_enabled',
|
||||
'scan_pay_linepay_enabled', 'shopping_cart_enabled', 'welcome_gift_enabled',
|
||||
'cash_module_enabled', 'member_system_enabled', 'ambient_temp_monitoring_enabled',
|
||||
];
|
||||
|
||||
// 4. 合併 JSON 設定(只覆寫有送來的鍵,其餘保留)
|
||||
$jsonSettings = $machine->settings ?? [];
|
||||
foreach ($jsonBoolKeys as $k) {
|
||||
if (array_key_exists($k, $settings)) {
|
||||
$jsonSettings[$k] = (bool) $settings[$k];
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 整理實體欄位更新(貨道 + 鏡像欄位)
|
||||
$columnData = [];
|
||||
foreach ($columnBoolKeys as $k) {
|
||||
if (array_key_exists($k, $settings)) {
|
||||
$columnData[$k] = (bool) $settings[$k];
|
||||
}
|
||||
}
|
||||
foreach ($mirrorColumns as $k) {
|
||||
if (array_key_exists($k, $jsonSettings)) {
|
||||
$columnData[$k] = (bool) $jsonSettings[$k];
|
||||
}
|
||||
}
|
||||
|
||||
$machine->update(array_merge($columnData, [
|
||||
'settings' => $jsonSettings,
|
||||
'updater_id' => $user->id,
|
||||
]));
|
||||
|
||||
\Log::info('B016 Machine settings updated', [
|
||||
'machine' => $machine->serial_no,
|
||||
'user_id' => $user->id,
|
||||
'keys' => array_keys($settings),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => __('Settings updated successfully.')
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* B660: Verify/Consume Pickup Code
|
||||
* POST: 驗證 8 位數取貨碼 (與原邏輯一致,但加入日誌)
|
||||
|
||||
@ -55,6 +55,11 @@ return [
|
||||
'description' => '回應訊息',
|
||||
'example' => 'Success'
|
||||
],
|
||||
'identity' => [
|
||||
'type' => 'string',
|
||||
'description' => '登入者身分:system 系統登入者 / company 公司帳號 / staff 一般人員 (僅驗證成功時回傳)',
|
||||
'example' => 'system'
|
||||
],
|
||||
'token' => [
|
||||
'type' => 'string',
|
||||
'description' => '臨時身份認證 Token',
|
||||
@ -70,6 +75,7 @@ return [
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'Success',
|
||||
'identity' => 'system',
|
||||
'token' => '1|abcdefg...'
|
||||
],
|
||||
],
|
||||
@ -271,6 +277,59 @@ return [
|
||||
],
|
||||
'notes' => '此 API 為機台初始化引導用,目前不強制驗證 User Token。金流密鑰與發票設定來源為機台關聯的金流配置 (payment_configs);DevSet / CashSet / FunctionSet / ShoppingMode 來源為 machines.settings、OperationSet / HardwareSet 來源為 machines 實體欄位,全部採機台端 *Set + PascalCase 風格(DevSet/CashSet 對齊機台既有結構,其餘為新定義,機台 App 端需另案實作)。'
|
||||
],
|
||||
[
|
||||
'name' => 'B016: 機台系統設定回寫 (Settings Write-back)',
|
||||
'slug' => 'b016-settings-writeback',
|
||||
'method' => 'PATCH',
|
||||
'path' => '/api/v1/app/machine/setting/B016',
|
||||
'description' => '機台主控台「系統設定」頁由系統方編輯後回寫雲端,為 B014 下載的反向操作。需帶 B000 核發之 User Token,且僅系統管理員可操作。鍵名採後端原始 snake_case(非 B014 的 PascalCase *Set),僅白名單布林開關生效,不接受 shopping_mode 等非開關欄位。',
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer <user_token>',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'parameters' => [
|
||||
'machine' => [
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'description' => '機台序號 (serial_no)',
|
||||
'example' => 'SN202604130001'
|
||||
],
|
||||
'settings' => [
|
||||
'type' => 'object',
|
||||
'required' => true,
|
||||
'description' => "開關鍵值物件(皆為布林),鍵名採後端原始 snake_case,只覆寫有送來的鍵、其餘保留。白名單外鍵一律忽略。可回寫鍵:\n• 支付旗標:shopping_cart_enabled / tax_invoice_enabled / card_terminal_enabled / credit_card_enabled / mobile_pay_enabled / card_pay_enabled / scan_pay_enabled / scan_pay_esun_enabled / scan_pay_tappay_enabled / cash_module_enabled / scan_pay_linepay_enabled / tappay_linepay / tappay_jkopay / tappay_easywallet / tappay_pipay / tappay_pluspay\n• 現金面額:cash_bill_1000 / cash_bill_500 / cash_bill_100 / cash_coin_50 / cash_coin_10 / cash_coin_5 / cash_coin_1\n• 功能模組:pickup_module_enabled / pickup_code_enabled / pass_code_enabled / welcome_gift_enabled / member_system_enabled / ambient_temp_monitoring_enabled\n• 貨道類型(machines 實體欄位):is_spring_slot_1_10 / 11_20 / 21_30 / 31_40 / 41_50 / 51_60(true=彈簧 / false=履帶)",
|
||||
'example' => ['tax_invoice_enabled' => true, 'is_spring_slot_1_10' => false]
|
||||
],
|
||||
],
|
||||
'response_parameters' => [
|
||||
'success' => [
|
||||
'type' => 'boolean',
|
||||
'description' => '是否成功',
|
||||
],
|
||||
'code' => [
|
||||
'type' => 'integer',
|
||||
'description' => '業務狀態碼',
|
||||
],
|
||||
'message' => [
|
||||
'type' => 'string',
|
||||
'description' => '回應訊息',
|
||||
],
|
||||
],
|
||||
'request' => [
|
||||
'machine' => 'SN202604130001',
|
||||
'settings' => [
|
||||
'tax_invoice_enabled' => true,
|
||||
'cash_module_enabled' => false,
|
||||
'is_spring_slot_1_10' => true,
|
||||
],
|
||||
],
|
||||
'response' => [
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'Settings updated successfully.',
|
||||
],
|
||||
'notes' => '僅系統管理員 (identity=system) 可操作,非系統方回 403 Forbidden;查無序號回 404 Machine not found;settings 非物件回 422 Invalid settings payload。寫回時同步更新 machines.settings JSON 與 is_spring_slot_* 實體欄位並記錄 updater_id,套用 withoutGlobalScopes 跨租戶定位機台。'
|
||||
],
|
||||
[
|
||||
'name' => 'B005: 廣告清單同步 (Ad Sync)',
|
||||
'slug' => 'b005-ad-sync',
|
||||
|
||||
@ -53,6 +53,11 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
|
||||
Route::get('machine/setting/B014', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getSettings']);
|
||||
});
|
||||
|
||||
// 機台端回寫系統設定 (B016):僅系統方 (identity=system) 可操作,認證使用 B000 核發的使用者 Token
|
||||
Route::prefix('app')->middleware(['auth:sanctum', 'throttle:60,1'])->group(function () {
|
||||
Route::patch('machine/setting/B016', [App\Http\Controllers\Api\V1\App\MachineController::class, 'updateSettings']);
|
||||
});
|
||||
|
||||
Route::prefix('app')->middleware(['iot.auth', 'throttle:100,1'])->group(function () {
|
||||
// 機台管理員 B000 登入驗證 (需帶機台 API Token)
|
||||
Route::post('admin/login/B000', [\App\Http\Controllers\Api\V1\App\MachineAuthController::class, 'loginB000'])->middleware('throttle:30,1');
|
||||
|
||||
142
tests/Feature/Api/V1/MachineAuthB000Test.php
Normal file
142
tests/Feature/Api/V1/MachineAuthB000Test.php
Normal file
@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\V1;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\System\Company;
|
||||
use App\Models\System\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MachineAuthB000Test extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Company $company;
|
||||
private Machine $machine;
|
||||
private string $apiToken;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->company = Company::create([
|
||||
'name' => 'Test Company',
|
||||
]);
|
||||
|
||||
$this->apiToken = 'test-machine-api-token-123456';
|
||||
|
||||
$this->machine = Machine::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'api_token' => $this->apiToken,
|
||||
]);
|
||||
}
|
||||
|
||||
private function loginAs(string $account, string $password = 'password')
|
||||
{
|
||||
return $this->withHeader('Authorization', "Bearer {$this->apiToken}")
|
||||
->postJson('/api/v1/app/admin/login/B000', [
|
||||
'Su_Account' => $account,
|
||||
'Su_Password' => $password,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 系統登入者 (company_id = null) → identity: system
|
||||
*/
|
||||
public function test_system_admin_login_returns_identity_system(): void
|
||||
{
|
||||
User::factory()->create([
|
||||
'company_id' => null,
|
||||
'username' => 'sysadmin',
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
|
||||
$this->loginAs('sysadmin')
|
||||
->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'identity' => 'system',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 公司帳號 (租戶管理員,is_admin = true,同 company_id 機台) → identity: company
|
||||
*/
|
||||
public function test_company_admin_login_returns_identity_company(): void
|
||||
{
|
||||
User::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'username' => 'companyadmin',
|
||||
'is_admin' => true,
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
|
||||
$this->loginAs('companyadmin')
|
||||
->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'identity' => 'company',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一般人員 (is_admin = false,已加入 machine_user 授權) → identity: staff
|
||||
*/
|
||||
public function test_staff_login_returns_identity_staff(): void
|
||||
{
|
||||
$staff = User::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'username' => 'staff',
|
||||
'is_admin' => false,
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
$staff->machines()->attach($this->machine->id);
|
||||
|
||||
$this->loginAs('staff')
|
||||
->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'identity' => 'staff',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 帳密錯誤 → 401,回應不含 identity
|
||||
*/
|
||||
public function test_invalid_credentials_returns_401_without_identity(): void
|
||||
{
|
||||
User::factory()->create([
|
||||
'company_id' => null,
|
||||
'username' => 'sysadmin',
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
|
||||
$response = $this->loginAs('sysadmin', 'wrong-password');
|
||||
|
||||
$response->assertStatus(401)
|
||||
->assertJson(['success' => false, 'code' => 401]);
|
||||
$this->assertArrayNotHasKey('identity', $response->json());
|
||||
}
|
||||
|
||||
/**
|
||||
* 未授權 (一般人員未加入 machine_user) → 403,回應不含 identity
|
||||
*/
|
||||
public function test_unauthorized_staff_returns_403_without_identity(): void
|
||||
{
|
||||
User::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'username' => 'staff',
|
||||
'is_admin' => false,
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
|
||||
$response = $this->loginAs('staff');
|
||||
|
||||
$response->assertStatus(403)
|
||||
->assertJson(['success' => false, 'code' => 403]);
|
||||
$this->assertArrayNotHasKey('identity', $response->json());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user