[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:
sky121113 2026-06-11 11:23:35 +08:00
commit 4e5f952f28
6 changed files with 364 additions and 0 deletions

View File

@ -51,6 +51,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| success | Boolean | 請求是否成功 | true | | success | Boolean | 請求是否成功 | true |
| code | Integer | 業務狀態碼 | 200 | | code | Integer | 業務狀態碼 | 200 |
| message | String | 驗證結果 (Success 或 Failed) | Success | | message | String | 驗證結果 (Success 或 Failed) | Success |
| identity | String | **登入者身分**`system` 系統登入者 / `company` 公司帳號 / `staff` 一般人員。僅驗證成功時回傳 | system |
| token | String | **臨時身份認證 Token** (可選,供未來擴充使用) | 1\|abcdefg... | | 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) ### 3.9 B660: 取貨碼驗證 (Pickup Code)
處理機台端的 8 位數代碼取貨流程。 處理機台端的 8 位數代碼取貨流程。

View File

@ -59,16 +59,22 @@ class MachineAuthController extends Controller
} }
// 5. RBAC 權限驗證 (遵循多租戶與機台授權規範) // 5. RBAC 權限驗證 (遵循多租戶與機台授權規範)
// $identity: 登入者身分 (system:系統登入者 / company:公司帳號 / staff:一般人員)
// 僅在授權通過時賦值,確保未授權者不洩漏身分
$isAuthorized = false; $isAuthorized = false;
$identity = null;
if ($user->isSystemAdmin()) { if ($user->isSystemAdmin()) {
$isAuthorized = true; $isAuthorized = true;
$identity = 'system';
} elseif ($user->is_admin) { } elseif ($user->is_admin) {
if ($machine->company_id === $user->company_id) { if ($machine->company_id === $user->company_id) {
$isAuthorized = true; $isAuthorized = true;
$identity = 'company';
} }
} else { } else {
if ($user->machines()->where('machine_id', $machine->id)->exists()) { if ($user->machines()->where('machine_id', $machine->id)->exists()) {
$isAuthorized = true; $isAuthorized = true;
$identity = 'staff';
} }
} }
@ -120,6 +126,7 @@ class MachineAuthController extends Controller
'success' => true, 'success' => true,
'code' => 200, 'code' => 200,
'message' => 'Success', 'message' => 'Success',
'identity' => $identity,
'token' => $user->createToken('technician-setup', ['*'], now()->addHours(8))->plainTextToken 'token' => $user->createToken('technician-setup', ['*'], now()->addHours(8))->plainTextToken
]); ]);
} }

View File

@ -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 * B660: Verify/Consume Pickup Code
* POST: 驗證 8 位數取貨碼 (與原邏輯一致,但加入日誌) * POST: 驗證 8 位數取貨碼 (與原邏輯一致,但加入日誌)

View File

@ -55,6 +55,11 @@ return [
'description' => '回應訊息', 'description' => '回應訊息',
'example' => 'Success' 'example' => 'Success'
], ],
'identity' => [
'type' => 'string',
'description' => '登入者身分system 系統登入者 / company 公司帳號 / staff 一般人員 (僅驗證成功時回傳)',
'example' => 'system'
],
'token' => [ 'token' => [
'type' => 'string', 'type' => 'string',
'description' => '臨時身份認證 Token', 'description' => '臨時身份認證 Token',
@ -70,6 +75,7 @@ return [
'success' => true, 'success' => true,
'code' => 200, 'code' => 200,
'message' => 'Success', 'message' => 'Success',
'identity' => 'system',
'token' => '1|abcdefg...' '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 端需另案實作)。' '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_60true=彈簧 / 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 foundsettings 非物件回 422 Invalid settings payload。寫回時同步更新 machines.settings JSON 與 is_spring_slot_* 實體欄位並記錄 updater_id套用 withoutGlobalScopes 跨租戶定位機台。'
],
[ [
'name' => 'B005: 廣告清單同步 (Ad Sync)', 'name' => 'B005: 廣告清單同步 (Ad Sync)',
'slug' => 'b005-ad-sync', 'slug' => 'b005-ad-sync',

View File

@ -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']); 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 () { Route::prefix('app')->middleware(['iot.auth', 'throttle:100,1'])->group(function () {
// 機台管理員 B000 登入驗證 (需帶機台 API Token) // 機台管理員 B000 登入驗證 (需帶機台 API Token)
Route::post('admin/login/B000', [\App\Http\Controllers\Api\V1\App\MachineAuthController::class, 'loginB000'])->middleware('throttle:30,1'); Route::post('admin/login/B000', [\App\Http\Controllers\Api\V1\App\MachineAuthController::class, 'loginB000'])->middleware('throttle:30,1');

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