Compare commits
52 Commits
main
...
09e1d0dc48
| Author | SHA1 | Date | |
|---|---|---|---|
| 09e1d0dc48 | |||
| 42f96d54c3 | |||
| 56daf8940b | |||
| 39d25ed1d4 | |||
| 78597f1c68 | |||
| 588704642b | |||
| e5516193b0 | |||
| 7f9f76111c | |||
| 3fbb7bc286 | |||
| bb5d212569 | |||
| 6fab048461 | |||
| ea460cf6d9 | |||
| 773396fc90 | |||
| 8ee14eaa29 | |||
| 5708c4f12a | |||
| d679c0df17 | |||
| 03dfcc9c7e | |||
| 75a70a256d | |||
| 682a9e7ac3 | |||
| 02918ce0e1 | |||
| a38387f2ad | |||
| 80436caee7 | |||
| c1b40185eb | |||
| f15038fcbd | |||
| 1d035d2766 | |||
| 1c9edf8e46 | |||
| fba4f26575 | |||
| 4b64fede2e | |||
| d905501a77 | |||
| 56c9a55944 | |||
| c30c3a399d | |||
| 21e064ff91 | |||
| adea7feb7b | |||
| 2a6170b4ce | |||
| 4a1fa2ad1b | |||
| acc81b2156 | |||
| 74b6c71c95 | |||
| 88c3678a4d | |||
| 649cbaab02 | |||
| 9c2ef60463 | |||
| f67a1dc11e | |||
| a578c7f261 | |||
| 84ef0c24e2 | |||
| 55ba08c88f | |||
| 11491e07aa | |||
| d3684385b2 | |||
| 7db3ee3a05 | |||
| a0d107ca79 | |||
| 96b22cd577 | |||
| 3ed8b00cab | |||
| 2aff99fc76 | |||
| 2ed0ee272e |
@@ -1,222 +0,0 @@
|
|||||||
---
|
|
||||||
trigger: always_on
|
|
||||||
---
|
|
||||||
|
|
||||||
# Backend API Specification (backend.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 目標範圍
|
|
||||||
|
|
||||||
* 使用 Laravel (RESTful API)
|
|
||||||
* 資料庫:MySQL(migration + seeder)
|
|
||||||
* 提供給現有 Android 團隊的完整 API 規格(JSON 格式)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 認證與安全
|
|
||||||
|
|
||||||
* 採用 JWT 或 Laravel Sanctum
|
|
||||||
* 所有需要授權的 API 回傳 401/403 規範
|
|
||||||
* Error 格式:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"code": 401,
|
|
||||||
"message": "Unauthorized",
|
|
||||||
"errors": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一般回應格式
|
|
||||||
|
|
||||||
成功:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"code": 200,
|
|
||||||
"message": "OK",
|
|
||||||
"data": { }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
錯誤:同上 Error 範例
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API 清單(建議先開發順序)
|
|
||||||
|
|
||||||
1. Auth (登入/登出/註冊/權杖)
|
|
||||||
2. User / Profile
|
|
||||||
3. Device / Machine (機台管理、狀態回傳、日誌)
|
|
||||||
4. Order / ShoppingCart(若需)
|
|
||||||
5. Notification / Push 訊息
|
|
||||||
6. Activity / Campaign
|
|
||||||
7. Report / Analytics
|
|
||||||
8. Admin CRUD for resources
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 主要 Endpoints 範例
|
|
||||||
|
|
||||||
### 1) Auth
|
|
||||||
|
|
||||||
* POST /api/v1/auth/login
|
|
||||||
|
|
||||||
* request:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"email":"user@example.com","password":"pa55"}
|
|
||||||
```
|
|
||||||
|
|
||||||
* response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"success":true,"code":200,"data":{"token":"...","user":{"id":1,"name":"..."}}}
|
|
||||||
```
|
|
||||||
|
|
||||||
* POST /api/v1/auth/logout
|
|
||||||
|
|
||||||
* header: Authorization: Bearer <token>
|
|
||||||
* response: 200
|
|
||||||
|
|
||||||
* POST /api/v1/auth/refresh
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2) User / Profile
|
|
||||||
|
|
||||||
* GET /api/v1/users/{id}
|
|
||||||
* PUT /api/v1/users/{id}
|
|
||||||
* GET /api/v1/users (admin)
|
|
||||||
|
|
||||||
Request / Response 均採 JSON,個資欄位請遵守最小授權原則。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3) Machine (機台)
|
|
||||||
|
|
||||||
* GET /api/v1/machines
|
|
||||||
|
|
||||||
* Params: page, per_page, status
|
|
||||||
|
|
||||||
* GET /api/v1/machines/{id}
|
|
||||||
|
|
||||||
* POST /api/v1/machines
|
|
||||||
|
|
||||||
* PUT /api/v1/machines/{id}
|
|
||||||
|
|
||||||
* DELETE /api/v1/machines/{id}
|
|
||||||
|
|
||||||
* POST /api/v1/machines/{id}/status
|
|
||||||
|
|
||||||
* 用於下位機或 APP 回傳機台狀態
|
|
||||||
* request example:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"temperature": 23.4,
|
|
||||||
"status_code": "OK",
|
|
||||||
"firmware_version": "1.2.3",
|
|
||||||
"timestamp": "2025-11-20T15:00:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
* GET /api/v1/machines/{id}/logs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4) Orders / ShoppingCart
|
|
||||||
|
|
||||||
* POST /api/v1/cart/add
|
|
||||||
* GET /api/v1/cart
|
|
||||||
* POST /api/v1/orders
|
|
||||||
* GET /api/v1/orders/{id}
|
|
||||||
|
|
||||||
支付與第三方串接請另行設計 callback endpoint。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5) Notification
|
|
||||||
|
|
||||||
* POST /api/v1/notifications/send (admin)
|
|
||||||
* GET /api/v1/notifications (user)
|
|
||||||
|
|
||||||
Payload for push could include: title, body, target_user_ids, data
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6) Activity / Campaign
|
|
||||||
|
|
||||||
* GET /api/v1/campaigns
|
|
||||||
* POST /api/v1/campaigns
|
|
||||||
* PUT /api/v1/campaigns/{id}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7) Report / Analytics
|
|
||||||
|
|
||||||
* GET /api/v1/reports/machines/summary?from=YYYY-MM-DD&to=YYYY-MM-DD
|
|
||||||
* GET /api/v1/reports/sales/summary
|
|
||||||
|
|
||||||
Response should include aggregated numbers and paginated lists when needed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Database 基本表(初步)
|
|
||||||
|
|
||||||
* users
|
|
||||||
* roles
|
|
||||||
* machines
|
|
||||||
* machine_logs
|
|
||||||
* orders
|
|
||||||
* order_items
|
|
||||||
* carts
|
|
||||||
* notifications
|
|
||||||
* campaigns
|
|
||||||
* activity_logs
|
|
||||||
* translations (i18n)
|
|
||||||
|
|
||||||
(每張表建議 migration 欄位、index、外鍵,請於開發前定稿)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API 規格輸出
|
|
||||||
|
|
||||||
* 建議產出 Swagger (OpenAPI 3.0) 與 Postman Collection
|
|
||||||
* 每個 endpoint 必要欄位、範例、error code、rate limit
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 測試建議
|
|
||||||
|
|
||||||
* Unit tests:Laravel Feature tests for API
|
|
||||||
* Contract tests:與 Android 團隊一同建立 contract tests(或使用 Postman tests)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 部署與運營
|
|
||||||
|
|
||||||
* 建議使用 queue 與 cache(Redis)處理非同步任務
|
|
||||||
* Logging: Sentry or similar
|
|
||||||
* 定期備份 MySQL
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 交付項目清單
|
|
||||||
|
|
||||||
1. 完整 Laravel 專案(註冊、登入、ACL)
|
|
||||||
2. MySQL migration + seeders
|
|
||||||
3. Swagger/OpenAPI 文件
|
|
||||||
4. Postman Collection
|
|
||||||
5. 後台管理系統(Star Cloud)
|
|
||||||
6. 測試報告
|
|
||||||
7. 部署腳本與上線說明
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*註:本檔為初版 backend.md,若要我把 Excel 的每一列功能自動轉成對應的 API endpoint(含 request/response 範例),我可以直接在此檔案中展開更詳細的 endpoint 清單。*
|
|
||||||
@@ -1,528 +0,0 @@
|
|||||||
---
|
|
||||||
trigger: always_on
|
|
||||||
---
|
|
||||||
|
|
||||||
STAR CLOUD 後台管理系統 - 智能販賣機管理平台
|
|
||||||
|
|
||||||
一、儀錶板模組 (Dashboard)
|
|
||||||
1.1 主頁面
|
|
||||||
|
|
||||||
功能描述: 系統總覽與關鍵數據展示
|
|
||||||
主要內容:
|
|
||||||
|
|
||||||
即時銷售數據
|
|
||||||
機台運行狀態
|
|
||||||
庫存警示
|
|
||||||
營收統計圖表
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
二、應用程式管理 (Application Management)
|
|
||||||
2.1 個人檔案
|
|
||||||
|
|
||||||
功能描述: 使用者個人資訊管理
|
|
||||||
包含功能:
|
|
||||||
|
|
||||||
基本資料編輯
|
|
||||||
密碼修改
|
|
||||||
通知設定
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
三、機台管理模組 (Machine Management)
|
|
||||||
3.1 機台日誌
|
|
||||||
|
|
||||||
功能描述: 機台操作歷史紀錄回溯
|
|
||||||
資料內容:
|
|
||||||
|
|
||||||
操作時間戳記
|
|
||||||
事件類型
|
|
||||||
操作人員
|
|
||||||
詳細描述
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
3.2 機台列表
|
|
||||||
|
|
||||||
功能描述: 所有機台資訊總覽
|
|
||||||
顯示資訊:
|
|
||||||
|
|
||||||
溫度監控
|
|
||||||
下位機狀態
|
|
||||||
刷卡機連線
|
|
||||||
掃碼機狀態
|
|
||||||
機台回傳訊息
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
3.3 機台權限
|
|
||||||
|
|
||||||
功能描述: 機台存取權限控管
|
|
||||||
設定項目:
|
|
||||||
|
|
||||||
人員權限分配
|
|
||||||
操作級別設定
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
3.4 機台稼動率
|
|
||||||
|
|
||||||
功能描述: 機台運行效率分析
|
|
||||||
統計數據:
|
|
||||||
|
|
||||||
運行時間
|
|
||||||
停機時間
|
|
||||||
稼動率百分比
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
3.5 效期管理
|
|
||||||
|
|
||||||
功能描述: 商品效期與貨道出貨控制
|
|
||||||
管理項目:
|
|
||||||
|
|
||||||
設定貨道是否可出貨
|
|
||||||
效期到期提醒
|
|
||||||
商品下架設定
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
3.6 維修管理單
|
|
||||||
|
|
||||||
功能描述: 機台維修工單系統
|
|
||||||
包含功能:
|
|
||||||
|
|
||||||
報修單建立
|
|
||||||
維修進度追蹤
|
|
||||||
維修歷史紀錄
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
3.7 機台管理擴充欄位
|
|
||||||
|
|
||||||
新增欄位:
|
|
||||||
|
|
||||||
保固區間
|
|
||||||
交機日期
|
|
||||||
租賃區間
|
|
||||||
保內/保外狀態顯示
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
3.8 機台設定參數
|
|
||||||
|
|
||||||
刷卡機秒數: 刷卡逾時設定
|
|
||||||
卡機結帳時間: 結帳流程時間限制
|
|
||||||
卡機結帳時間2: 備用結帳時間設定
|
|
||||||
金流緩衝時間: 金流處理緩衝時間
|
|
||||||
刷卡機編號: 刷卡機裝置識別
|
|
||||||
發票狀態碼: 發票開立狀態管理
|
|
||||||
|
|
||||||
3.9 APP到期提醒
|
|
||||||
|
|
||||||
功能描述: APP授權到期通知系統
|
|
||||||
|
|
||||||
|
|
||||||
四、APP管理模組 (APP Management)
|
|
||||||
4.1 UI元素設定
|
|
||||||
|
|
||||||
功能描述: APP版面配置設定
|
|
||||||
注意事項: 與新版差異較大,需特別處理
|
|
||||||
|
|
||||||
4.2 小幫手設定
|
|
||||||
|
|
||||||
功能描述: APP內建輔助功能設定
|
|
||||||
|
|
||||||
4.3 問卷設定
|
|
||||||
|
|
||||||
功能描述: 互動問卷建立與管理
|
|
||||||
|
|
||||||
4.4 互動遊戲設定
|
|
||||||
|
|
||||||
功能描述: APP互動遊戲配置
|
|
||||||
|
|
||||||
4.5 計時器
|
|
||||||
|
|
||||||
功能描述: 時間相關功能設定
|
|
||||||
|
|
||||||
|
|
||||||
五、倉庫管理模組 (Warehouse Management)
|
|
||||||
5.1 倉庫列表
|
|
||||||
|
|
||||||
5.1.1 倉庫列表(全部): 顯示所有倉庫
|
|
||||||
5.1.2 倉庫列表(個人): 顯示個人負責倉庫
|
|
||||||
|
|
||||||
5.2 庫存管理單
|
|
||||||
|
|
||||||
功能描述: 倉庫庫存異動管理
|
|
||||||
|
|
||||||
5.3 調撥單
|
|
||||||
|
|
||||||
功能描述: 倉庫間商品調撥作業
|
|
||||||
|
|
||||||
5.4 採購單
|
|
||||||
|
|
||||||
功能描述: 商品採購申請與管理
|
|
||||||
|
|
||||||
5.5 機台補貨管理
|
|
||||||
|
|
||||||
5.5.1 機台補貨單: 補貨工單建立
|
|
||||||
5.5.2 機台補貨紀錄: 個別補貨歷史
|
|
||||||
5.5.3 機台補貨紀錄(總): 所有補貨總覽
|
|
||||||
|
|
||||||
5.6 庫存查詢
|
|
||||||
|
|
||||||
5.6.1 機台庫存: 各機台即時庫存
|
|
||||||
5.6.2 人員庫存: 人員持有庫存
|
|
||||||
|
|
||||||
5.7 回庫單
|
|
||||||
|
|
||||||
功能描述: 商品退回倉庫管理
|
|
||||||
|
|
||||||
|
|
||||||
六、銷售管理模組 (Sales Management)
|
|
||||||
6.1 銷售&金流紀錄
|
|
||||||
|
|
||||||
功能描述: 銷售交易與金流明細
|
|
||||||
包含項目:
|
|
||||||
|
|
||||||
現金出貨 API
|
|
||||||
發票系統整合
|
|
||||||
各種出貨方式整理
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
6.2 取貨碼設定
|
|
||||||
|
|
||||||
功能描述: 取貨驗證碼管理
|
|
||||||
|
|
||||||
6.3 購買單
|
|
||||||
|
|
||||||
功能描述: 購買訂單管理
|
|
||||||
|
|
||||||
6.4 促銷時段設定
|
|
||||||
|
|
||||||
功能描述: 促銷活動時間設定
|
|
||||||
重要功能: (W) 重啟掃描商品 API
|
|
||||||
|
|
||||||
6.5 通行碼設定
|
|
||||||
|
|
||||||
功能描述: 特殊通行碼權限管理
|
|
||||||
|
|
||||||
6.6 來店禮設定
|
|
||||||
|
|
||||||
功能描述: 來店優惠活動設定
|
|
||||||
包含: 來店禮開關控制
|
|
||||||
|
|
||||||
|
|
||||||
七、分析管理模組 (Analysis Management)
|
|
||||||
7.1 零錢庫存分析
|
|
||||||
|
|
||||||
功能描述: 機台零錢數量監測與分析
|
|
||||||
|
|
||||||
7.2 機台報表分析
|
|
||||||
|
|
||||||
功能描述: 機台運營數據分析報表
|
|
||||||
|
|
||||||
7.3 商品報表分析
|
|
||||||
|
|
||||||
功能描述: 商品銷售數據分析
|
|
||||||
|
|
||||||
7.4 互動問卷分析
|
|
||||||
|
|
||||||
功能描述: 問卷結果統計與分析
|
|
||||||
|
|
||||||
|
|
||||||
八、稽核管理模組 (Audit Management)
|
|
||||||
8.1 採購單稽核
|
|
||||||
|
|
||||||
功能描述: 採購單審核流程
|
|
||||||
|
|
||||||
8.2 調撥單稽核
|
|
||||||
|
|
||||||
功能描述: 調撥單審核流程
|
|
||||||
|
|
||||||
8.3 補貨單稽核
|
|
||||||
|
|
||||||
功能描述: 補貨單審核流程
|
|
||||||
|
|
||||||
|
|
||||||
九、資料設定模組 (Data Configuration)
|
|
||||||
9.1 機台管理
|
|
||||||
|
|
||||||
功能描述: 機台基本資料設定
|
|
||||||
|
|
||||||
9.2 商品管理
|
|
||||||
|
|
||||||
功能描述: 商品資料維護
|
|
||||||
|
|
||||||
9.3 廣告管理
|
|
||||||
|
|
||||||
功能描述: 機台廣告影片管理
|
|
||||||
用途: 機台可讀取後台廣告影片
|
|
||||||
|
|
||||||
9.4 管理者可賣商品
|
|
||||||
|
|
||||||
功能描述: 管理者商品銷售權限
|
|
||||||
|
|
||||||
9.5 帳號管理
|
|
||||||
|
|
||||||
功能描述: 主帳號管理
|
|
||||||
|
|
||||||
9.6 子帳號管理
|
|
||||||
|
|
||||||
功能描述: 子帳號建立與管理
|
|
||||||
|
|
||||||
9.7 子帳號角色管理
|
|
||||||
|
|
||||||
功能描述: 子帳號權限角色設定
|
|
||||||
|
|
||||||
9.8 點數設定
|
|
||||||
|
|
||||||
功能描述: 客戶點數系統設定
|
|
||||||
特殊功能: 支援客戶自行新增點數
|
|
||||||
|
|
||||||
9.9 識別證管理
|
|
||||||
|
|
||||||
功能描述: 識別證資料管理
|
|
||||||
用途: 安霸系統使用
|
|
||||||
|
|
||||||
|
|
||||||
十、遠端管理模組 (Remote Management)
|
|
||||||
10.1 機台庫存
|
|
||||||
|
|
||||||
功能描述: 遠端修改機台庫存
|
|
||||||
|
|
||||||
10.2 機台重啟
|
|
||||||
|
|
||||||
功能描述: 遠端重啟機台系統
|
|
||||||
|
|
||||||
10.3 卡機重啟
|
|
||||||
|
|
||||||
功能描述: 遠端重啟刷卡機
|
|
||||||
|
|
||||||
10.4 遠端結帳
|
|
||||||
|
|
||||||
功能描述: 遠端執行結帳流程
|
|
||||||
|
|
||||||
10.5 遠端鎖定頁
|
|
||||||
|
|
||||||
功能描述: 遠端鎖定機台頁面
|
|
||||||
|
|
||||||
10.6 遠端找零
|
|
||||||
|
|
||||||
功能描述: 遠端執行找零功能
|
|
||||||
|
|
||||||
10.7 遠端出貨
|
|
||||||
|
|
||||||
功能描述: 遠端控制商品出貨
|
|
||||||
|
|
||||||
|
|
||||||
十一、Line管理模組 (Line Integration)
|
|
||||||
11.1 Line會員管理
|
|
||||||
|
|
||||||
功能描述: Line會員資料管理
|
|
||||||
|
|
||||||
11.2 Line機台管理
|
|
||||||
|
|
||||||
功能描述: Line綁定機台管理
|
|
||||||
|
|
||||||
11.3 Line商品管理
|
|
||||||
|
|
||||||
功能描述: Line商城商品設定
|
|
||||||
|
|
||||||
11.4 Line生活圈
|
|
||||||
|
|
||||||
功能描述: Line官方帳號整合
|
|
||||||
|
|
||||||
11.5 Line商城訂單
|
|
||||||
|
|
||||||
功能描述: Line商城訂單管理
|
|
||||||
|
|
||||||
11.6 Line優惠券
|
|
||||||
|
|
||||||
功能描述: Line優惠券發放與管理
|
|
||||||
|
|
||||||
|
|
||||||
十二、預約系統模組 (Reservation System)
|
|
||||||
12.1 Line會員管理
|
|
||||||
|
|
||||||
功能描述: 預約系統會員管理
|
|
||||||
|
|
||||||
12.2 Line店家管理
|
|
||||||
|
|
||||||
功能描述: 店家資訊設定
|
|
||||||
|
|
||||||
12.3 Line時段組合
|
|
||||||
|
|
||||||
功能描述: 預約時段設定
|
|
||||||
|
|
||||||
12.4 Line場地管理
|
|
||||||
|
|
||||||
功能描述: 場地資源管理
|
|
||||||
|
|
||||||
12.5 Line優惠券管理
|
|
||||||
|
|
||||||
功能描述: 預約優惠券管理
|
|
||||||
|
|
||||||
12.6 Line預約管理
|
|
||||||
|
|
||||||
功能描述: 預約單管理
|
|
||||||
|
|
||||||
12.7 Line訂單管理
|
|
||||||
|
|
||||||
功能描述: 預約訂單處理
|
|
||||||
|
|
||||||
|
|
||||||
十三、特殊權限管理模組 (Special Permissions)
|
|
||||||
13.1 庫存清空
|
|
||||||
|
|
||||||
功能描述: 特殊權限庫存清空功能
|
|
||||||
|
|
||||||
13.2 APK版本管理
|
|
||||||
|
|
||||||
功能描述: APP版本控制與更新
|
|
||||||
|
|
||||||
13.3 Discord通知設定
|
|
||||||
|
|
||||||
功能描述: Discord通知整合設定
|
|
||||||
|
|
||||||
|
|
||||||
十四、權限設定模組 (Permission Management)
|
|
||||||
14.1 功能權限設定
|
|
||||||
|
|
||||||
14.1.1 APP功能管理: APP功能權限
|
|
||||||
14.1.2 資料設定: 資料設定權限
|
|
||||||
14.1.3 銷售管理: 銷售管理權限
|
|
||||||
14.1.4 機台管理: 機台管理權限
|
|
||||||
14.1.5 倉庫管理: 倉庫管理權限
|
|
||||||
14.1.6 分析管理: 分析管理權限
|
|
||||||
14.1.7 稽核管理: 稽核管理權限
|
|
||||||
14.1.8 遠端管理: 遠端管理權限
|
|
||||||
14.1.9 Line管理: Line管理權限
|
|
||||||
|
|
||||||
14.2 權限角色設定
|
|
||||||
|
|
||||||
功能描述: 角色權限組合設定
|
|
||||||
|
|
||||||
14.3 其他功能管理
|
|
||||||
|
|
||||||
功能描述: 其他特殊功能權限
|
|
||||||
|
|
||||||
14.4 AI智能預測
|
|
||||||
|
|
||||||
功能描述: AI功能權限設定
|
|
||||||
|
|
||||||
|
|
||||||
資料庫設計建議
|
|
||||||
主要資料表規劃
|
|
||||||
machines # 機台資料表
|
|
||||||
├── warehouses # 倉庫資料表
|
|
||||||
├── products # 商品資料表
|
|
||||||
├── machine_stocks # 機台庫存表
|
|
||||||
├── warehouse_stocks # 倉庫庫存表
|
|
||||||
├── sales_records # 銷售紀錄表
|
|
||||||
├── purchase_orders # 採購單表
|
|
||||||
├── transfer_orders # 調撥單表
|
|
||||||
├── replenishment_orders # 補貨單表
|
|
||||||
├── maintenance_orders # 維修單表
|
|
||||||
├── machine_logs # 機台日誌表
|
|
||||||
├── users # 使用者表
|
|
||||||
├── roles # 角色表
|
|
||||||
├── permissions # 權限表
|
|
||||||
├── line_members # Line會員表
|
|
||||||
├── reservations # 預約表
|
|
||||||
└── advertisements # 廣告表
|
|
||||||
|
|
||||||
API 端點規劃
|
|
||||||
機台管理 API
|
|
||||||
|
|
||||||
GET /api/machines - 取得機台列表
|
|
||||||
POST /api/machines - 新增機台
|
|
||||||
PUT /api/machines/{id} - 更新機台
|
|
||||||
DELETE /api/machines/{id} - 刪除機台
|
|
||||||
GET /api/machines/{id}/logs - 取得機台日誌
|
|
||||||
POST /api/machines/{id}/restart - 遠端重啟
|
|
||||||
|
|
||||||
倉庫管理 API
|
|
||||||
|
|
||||||
GET /api/warehouses - 取得倉庫列表
|
|
||||||
GET /api/warehouses/{id}/stocks - 取得倉庫庫存
|
|
||||||
POST /api/transfer-orders - 建立調撥單
|
|
||||||
POST /api/purchase-orders - 建立採購單
|
|
||||||
POST /api/replenishment-orders - 建立補貨單
|
|
||||||
|
|
||||||
銷售管理 API
|
|
||||||
|
|
||||||
GET /api/sales - 取得銷售紀錄
|
|
||||||
POST /api/sales/cash - 現金出貨
|
|
||||||
GET /api/sales/invoice - 發票查詢
|
|
||||||
POST /api/pickup-codes - 建立取貨碼
|
|
||||||
|
|
||||||
遠端控制 API
|
|
||||||
|
|
||||||
POST /api/remote/checkout - 遠端結帳
|
|
||||||
POST /api/remote/dispense - 遠端出貨
|
|
||||||
POST /api/remote/change - 遠端找零
|
|
||||||
POST /api/remote/lock - 遠端鎖定
|
|
||||||
|
|
||||||
|
|
||||||
技術架構建議
|
|
||||||
後端技術
|
|
||||||
|
|
||||||
框架: Laravel 10+
|
|
||||||
資料庫: MySQL 8.0+
|
|
||||||
快取: Redis
|
|
||||||
佇列: Laravel Queue (Redis Driver)
|
|
||||||
API文件: Swagger/OpenAPI
|
|
||||||
|
|
||||||
前端技術
|
|
||||||
|
|
||||||
模板引擎: Blade
|
|
||||||
CSS框架: Tailwind CSS
|
|
||||||
JavaScript: Alpine.js / Vue.js
|
|
||||||
圖表庫: Chart.js / ApexCharts
|
|
||||||
|
|
||||||
第三方整合
|
|
||||||
|
|
||||||
Line API: Line Messaging API
|
|
||||||
Discord: Discord Webhook
|
|
||||||
金流: 藍新、綠界等
|
|
||||||
發票: 電子發票整合
|
|
||||||
|
|
||||||
|
|
||||||
開發優先順序建議
|
|
||||||
Phase 1 - 核心功能 (1-2個月)
|
|
||||||
|
|
||||||
使用者認證與權限系統
|
|
||||||
機台管理基本功能
|
|
||||||
倉庫管理基本功能
|
|
||||||
銷售紀錄查詢
|
|
||||||
|
|
||||||
Phase 2 - 進階功能 (2-3個月)
|
|
||||||
|
|
||||||
遠端控制功能
|
|
||||||
報表分析功能
|
|
||||||
稽核流程
|
|
||||||
APP管理功能
|
|
||||||
|
|
||||||
Phase 3 - 整合功能 (1-2個月)
|
|
||||||
|
|
||||||
Line整合
|
|
||||||
預約系統
|
|
||||||
AI智能預測
|
|
||||||
Discord通知
|
|
||||||
|
|
||||||
|
|
||||||
注意事項
|
|
||||||
|
|
||||||
安全性: 所有遠端控制功能需要雙重驗證
|
|
||||||
權限控管: 嚴格的角色權限分離
|
|
||||||
日誌記錄: 所有重要操作需記錄日誌
|
|
||||||
API限流: 防止API濫用
|
|
||||||
資料備份: 定期自動備份機制
|
|
||||||
錯誤處理: 完善的異常處理機制
|
|
||||||
測試: 重要功能需撰寫測試案例
|
|
||||||
RetryClaude can make mistakes. Please double-check responses.
|
|
||||||
83
.agents/rules/api-rules.md
Normal file
83
.agents/rules/api-rules.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
trigger: always_on
|
||||||
|
---
|
||||||
|
|
||||||
|
# Backend API Specification (api-rules.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 1. 目標範圍與分類
|
||||||
|
|
||||||
|
本系統 API 分為兩大類,遵循不同的設計慣例:
|
||||||
|
|
||||||
|
* **Admin/Web API (`/api/v1/...`)**: 供後台管理介面、APP UI 使用。遵循標準 RESTful 與 JSON 結構。
|
||||||
|
* **Machine IoT API (`/api/app/...`)**: 供販賣機、計時器等硬體端點使用。需相容既有 PDF 規格(如 B010, B600),欄位命名多為 `req1`, `req2` 或特定縮寫。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 2. 認證與安全性
|
||||||
|
|
||||||
|
* **Admin API**: 採用 **Laravel Sanctum (Session/Token)** 認證。
|
||||||
|
* **Machine IoT API**:
|
||||||
|
* **核心機制**: 必須在 Header 帶入 `Authorization: Bearer <api_token>` 進行身份驗證。
|
||||||
|
* **Phase 1 (兼容模式)**: 若為相容既有機動硬體,可暫時接受 Request 包含 `key` 欄位,但後端應過渡至 Bearer 驗證。
|
||||||
|
* **安全性強化**: 改用每台機台專屬的 `api_token` (透過 B010 初始化或派發),並配合 `serial_no` (即 `workid`) 進行資料歸屬權驗證。
|
||||||
|
* **傳輸安全**: 必須強制使用 **HTTPS**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 3. 回應格式規範
|
||||||
|
|
||||||
|
### 3.1 標準回應 (Admin/Web API)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"code": 200,
|
||||||
|
"message": "OK",
|
||||||
|
"data": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 IoT 指令回應 (B010/B055 等)
|
||||||
|
機台端通常透過 response 的 `status` 欄位或特定的 `message` 字串來執行動作:
|
||||||
|
* **成功但有指令**: `{"status": "49", "message": "reload B017"}`
|
||||||
|
* **純資料回傳**: 直接返回對象陣列或 PDF 定義的欄位。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 4. 主要 Endpoints 與命名慣例
|
||||||
|
|
||||||
|
### 4.1 管理類 (Admin UI)
|
||||||
|
* `GET /api/v1/users`: 管理員清單
|
||||||
|
* `GET /api/v1/machines`: 機台清單
|
||||||
|
* `PUT /api/v1/machines/{id}`: 更新機台參數
|
||||||
|
* 遵循 **kebab-case** 路由與 **snake_case** JSON 欄位。
|
||||||
|
|
||||||
|
### 4.2 終端類 (IoT / Machine) — 須嚴格遵守 PDF 規格
|
||||||
|
* **API 識別碼 (workid)**: URL 中的 `{workid}` 參數固定為該 API 的功能代碼 (如 `B010`, `B017`, `B600`),不隨機台改變。
|
||||||
|
* **機台識別方式**:
|
||||||
|
1. **Header**: 透過 `Authorization: Bearer <api_token>` 識別。
|
||||||
|
2. **Request Body**: 透過 `machine` 或 `serial_no` 等欄位識別具體機台。
|
||||||
|
* **主要 Endpoint 範例**:
|
||||||
|
* **心跳上報 (B010)**: `POST /api/app/machine/status/B010`
|
||||||
|
* **交易回傳 (B600)**: `POST /api/app/B600` (Body 欄位 `req2` 為機台編號)
|
||||||
|
* **貨道庫存 (B017)**: `POST /api/app/machine/reload_msg/B017`
|
||||||
|
* **遠端出貨 (B055)**: `POST /api/app/machine/dispense/B055`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ 5. 高併發處理與隊列
|
||||||
|
|
||||||
|
為了系統穩定性,以下 API **嚴禁直寫資料庫**,必須進入 **Redis Queue** 異步處理:
|
||||||
|
1. **B010**: 心跳上傳(每 5-10 秒一次)。
|
||||||
|
2. **B600 / B602**: 交易與出貨紀錄。
|
||||||
|
3. **B220**: 零錢機庫存變動。
|
||||||
|
4. **B710**: 計時器狀態同步。
|
||||||
|
|
||||||
|
後端應立即回傳 `202 Accepted` 或業務定義的成功碼,由 Job 背景完成數據持久化。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 6. 交付與文件
|
||||||
|
* **OpenAPI**: 應區分 `admin.yaml` 與 `iot.yaml`。
|
||||||
|
* **Postman**: 提供帶有環境變數(機台金鑰、Base URL)的 Collection。
|
||||||
95
.agents/rules/framework.md
Normal file
95
.agents/rules/framework.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
trigger: always_on
|
||||||
|
---
|
||||||
|
|
||||||
|
# 開發框架規範說明書:Cloud 後台管理系統 (star-cloud)
|
||||||
|
|
||||||
|
## 1. 專案概述
|
||||||
|
* **目標**:打造一個強大且穩定的智能販賣機後台管理系統(Cloud 平台),負責管理機台、商品、銷售數據以及提供給端點機台串接的 API。
|
||||||
|
* **核心架構**:採用 **傳統單體式架構 (Monolithic Architecture)** 配 Laravel Blade 模板引擎進行伺服器端渲染 (SSR)。
|
||||||
|
* **工作流程**:後端處理業務邏輯與資料庫存取,並透過 Blade 引擎渲染包含 Tailwind CSS 類別的 HTML。前端互動行為由輕量級 Alpine.js 負責,UI 元件以 Preline UI 為主體。
|
||||||
|
|
||||||
|
## 2. 技術棧 (Tech Stack)
|
||||||
|
* **後端框架**:PHP 8.5 / Laravel 12
|
||||||
|
* **核心組件**:Redis (用於高併發 IoT 隊列與快取,為系統穩定之必要條件)
|
||||||
|
* **前端視圖 (View)**:Laravel Blade
|
||||||
|
* **前端互動 (JS)**:Alpine.js (專注於行為,不負責渲染)
|
||||||
|
* **介面與樣式 (CSS)**:Tailwind CSS + Preline UI (直接寫作於 Blade 模板中)
|
||||||
|
* **前端建置工具**:Vite
|
||||||
|
* **資料庫**:MySQL 8.0
|
||||||
|
* **開發環境**:Laravel Sail (Docker / WSL2)
|
||||||
|
|
||||||
|
## 3. 目錄結構與慣例
|
||||||
|
|
||||||
|
### 3.1 後端 (Laravel)
|
||||||
|
與標準 Laravel 結構保持一致,無過度拆分的模組化(與 ERP 的 Modular Monolith 區別):
|
||||||
|
* **Controllers**:`app/Http/Controllers/`,負責接收請求並回傳 `view()` 或 JSON。
|
||||||
|
* **Models**:`app/Models/{Domain}/`,按領域分群 (例如 `Machine`, `Member`, `System`)。
|
||||||
|
* **Routes**:`routes/web.php` 用於後台管理介面;`routes/api.php` 提供外部或機台調用介面 (需 V1 版本化)。
|
||||||
|
* **Services** (建議):`app/Services/{Domain}/`,將商業邏輯與資料異動封裝於 Service 中。
|
||||||
|
* **Traits**:`app/Traits/ApiResponse.php` 用於統一 API JSON 回傳格式。
|
||||||
|
* **Jobs**:`app/Jobs/{Domain}/`,**高併發 IoT 場景之必要實作**。所有日誌、心跳上報必須進入 Redis Queue 進行背景異步處理,嚴禁在 API 直連 DB 寫入日誌。
|
||||||
|
|
||||||
|
### 3.2 前端 (Blade / Tailwind / Alpine)
|
||||||
|
* **Views (頁面)**:位於 `resources/views/`。通常依功能建立資料夾(如 `resources/views/admin/machines/index.blade.php`)。
|
||||||
|
* **Layouts (版面)**:位於 `resources/views/layouts/`。定義全站的共用版面結構(如 header, sidebar, footer)。
|
||||||
|
* **Components (組件)**:位於 `resources/views/components/`。封裝可重用的 Blade 元件(如 Button, Modal, Table),支援透過 `<x-button>` 語法呼叫。
|
||||||
|
|
||||||
|
## 4. 開發標準 (Coding Standards)
|
||||||
|
* **命名規範**:
|
||||||
|
* Controllers: `PascalCaseController.php` (例如 `MachineController.php`)
|
||||||
|
* Models: `PascalCase.php` (例如 `Machine.php`)
|
||||||
|
* Blade Views: `kebab-case.blade.php` 或按資源名稱 (例如 `index.blade.php`, `create.blade.php`)
|
||||||
|
* Routes uri: `kebab-case` (例如 `/machine-logs`)
|
||||||
|
* **回傳格式**:
|
||||||
|
* Web 路由:回傳 `view()`,表單驗證失敗時直接使用 Laravel 內建的 redirect with errors。
|
||||||
|
* API 路由:回傳標準 JSON 格式的 `JsonResponse`。
|
||||||
|
|
||||||
|
## 5. UI 與前端開發指南
|
||||||
|
* **樣式撰寫**:全面使用 Tailwind CSS utility classes,**避免撰寫自訂 CSS**(除非少數特定動畫或覆寫)。
|
||||||
|
* **UI 元件庫**:遵循 **Preline UI** 的類別與 HTML 結構進行開發。
|
||||||
|
* **前端腳本**:
|
||||||
|
* 優先使用 **Alpine.js** (`x-data`, `x-show`, `@click` 等) 在 HTML 標籤內完成簡單的 DOM 狀態切換與互動邏輯。
|
||||||
|
* 避免在 Blade 內撰寫冗長的 `<script>` Vanilla JS;若邏輯過於複雜,可將 Alpine state 獨立成 js 檔案再於 Vite 引入,但原則上保持輕量。
|
||||||
|
|
||||||
|
## 6. 多語系 I18n 規範 (Multi-language Standards)
|
||||||
|
* **視圖開發**:所有使用者可見的文字、按鈕、提示訊息,必須使用 Laravel 的 `@lang('key')` 或 `__('key')` 函式包裹。
|
||||||
|
* **語系 Key 命名**:語系 Key 必須採用 **英文原始詞彙 (English phrases)** 作為 Key 名稱為原則,以提高代碼可讀性並作為預設回退(除非該字串過長,才建議使用點號分隔的 key)。
|
||||||
|
* 範例:使用 `__('Account Settings')`。
|
||||||
|
* **翻譯檔維護**:
|
||||||
|
* 主語系檔案位於 `lang/` 目錄。
|
||||||
|
* 開發新功能時,必須同步更新以下三個 JSON 翻譯檔:`zh_TW.json` (主要)、`en.json` (預設)、`ja.json` (日文)。
|
||||||
|
|
||||||
|
## 7. AI 協作規則 (給 Antigravity AI)
|
||||||
|
* **角色設定**:你是一位專業的全端開發工程師助手。
|
||||||
|
* **代碼生成指令**:
|
||||||
|
* 所有的解釋說明請使用 **繁體中文**。
|
||||||
|
* **【警告】** 此專案前端禁用 React / Vue / Inertia.js。所有的前端頁面生成必須使用 **Blade 模板** 結合 **Tailwind CSS** 與 **Alpine.js**。
|
||||||
|
* **【多語系強制要求】** 任何新增的 Blade UI 區塊,禁止硬編碼 (Hard-coded) 中文或英文。必須使用 `__('...')` 並同步在 `lang/*.json` 補上翻譯。
|
||||||
|
* 生成 UI 區塊時,必須優先參考與產生 **Preline UI** 風格與結構的標記語法。
|
||||||
|
* 開發新功能時,請建立標準的 Controller 搭配對應的 `resources/views/.../` 目錄。
|
||||||
|
|
||||||
|
## 8. 運行機制 (Docker / Sail)
|
||||||
|
由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令:
|
||||||
|
|
||||||
|
* **啟動環境**:`./vendor/bin/sail up -d`
|
||||||
|
* **執行 PHP 指令**:`./vendor/bin/sail php -v`
|
||||||
|
* **執行 Artisan 指令**:`./vendor/bin/sail artisan route:list`
|
||||||
|
* **執行 Composer**:`./vendor/bin/sail composer install`
|
||||||
|
* **執行 Node/NPM**:`./vendor/bin/sail npm run dev`
|
||||||
|
|
||||||
|
## 8. 部署與查修環境 (CI/CD)
|
||||||
|
* **自動化部署**:專案具備基於 Gitea Actions 的 CI/CD 自動化部署流程 (`.gitea/workflows/`)。
|
||||||
|
* **Demo 環境 (對應 `demo` 分支)**:
|
||||||
|
* 透過 `deploy-demo.yaml`,合併或推送到 `demo` 分支會自動部署至 `demo-cloud.taiwan-star.com.tw`。
|
||||||
|
* 登入伺服器查修:`ssh gitea_work`,路徑為 `/var/www/star-cloud-demo`。
|
||||||
|
|
||||||
|
## 9. 瀏覽器測試規範 (Browser Testing)
|
||||||
|
當需要進行瀏覽器自動化測試或手動驗證時,必須遵守以下連線資訊:
|
||||||
|
|
||||||
|
* **本地測試網址**:`http://localhost:8090/` (注意:非 8000 或 8080)
|
||||||
|
* **預設管理員帳號**:`admin`
|
||||||
|
* **預設管理員密碼**:`password`
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> 在執行 `open_browser_url` 或進行 E2E 測試時,請務必優先確認 Port 是否為 `8090`,以避免連線至錯誤的服務環境。
|
||||||
41
.agents/rules/skill-trigger.md
Normal file
41
.agents/rules/skill-trigger.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
trigger: always_on
|
||||||
|
---
|
||||||
|
|
||||||
|
# 技能觸發規範 (Skill Trigger Rules)
|
||||||
|
|
||||||
|
本文件確保 AI 助手在對話中能**主動辨識**需要參照技能 (Skill) 的時機。
|
||||||
|
Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
|
||||||
|
**若對話內容命中以下任一觸發條件,必須先使用 `view_file` 讀取對應的 `SKILL.md` 後標記為 active 再進行作業。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 觸發對照表
|
||||||
|
|
||||||
|
| 觸發詞 / 情境 | 對應 Skill | 路徑 |
|
||||||
|
|---|---|---|
|
||||||
|
| 機台通訊, IoT, 日誌上報, Log Ingestion, 異步隊列, Queue, Heartbeat, 心跳發報 | **IoT 通訊與高併發處理規範** | `.agents/skills/iot-communication/SKILL.md` |
|
||||||
|
| 介面, UI, 佈局, CSS, Tailwind, 奢華, 深色模式, Light Mode, Dark Mode, Blade, 樣式, 間距, 陰影, 動畫 | **極簡奢華風 UI 實作規範** | `.agents/skills/ui-minimal-luxury/SKILL.md` |
|
||||||
|
| 查詢、撈資料、Query、Controller、下拉選單、Eloquent、N+1、`->get()`、select、交易、Transaction、Bulk、分頁、索引 | **資料庫與 ORM 最佳實踐規範** | `/home/mama/.gemini/antigravity/global_skills/database-best-practices/SKILL.md` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 強制觸發場景
|
||||||
|
|
||||||
|
以下場景**無論對話中是否出現觸發詞**,都必須主動載入對應 Skill:
|
||||||
|
|
||||||
|
### 🔴 新增或修改頁面 (Views/Blade) 時
|
||||||
|
必須讀取:
|
||||||
|
1. **ui-minimal-luxury** — 確保符合極簡奢華風視覺與互動規範
|
||||||
|
|
||||||
|
### 🔴 新增機台通訊 API 端點時
|
||||||
|
必須讀取:
|
||||||
|
1. **iot-communication** — 決定是否使用異步隊列流程
|
||||||
|
|
||||||
|
### 🔴 修改 Job 或 Service 邏輯時
|
||||||
|
必須讀取:
|
||||||
|
1. **iot-communication** — 確保符合高併發處理架構
|
||||||
|
|
||||||
|
### 🔴 新增或修改 API 與 Controller 撈取資料庫邏輯時
|
||||||
|
必須讀取:
|
||||||
|
1. **database-best-practices** — 確認查詢優化、交易安全、批量寫入與索引規範
|
||||||
63
.agents/skills/iot-communication/SKILL.md
Normal file
63
.agents/skills/iot-communication/SKILL.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
name: IoT 通訊與高併發處理規範
|
||||||
|
description: 規範智能販賣機與 Cloud 平台間的高頻通訊處理流程,包含 API 接收、異步隊列、業務邏輯拆分與日誌記錄。
|
||||||
|
---
|
||||||
|
|
||||||
|
# IoT 通訊與高併發處理規範 (IoT Communication Skill)
|
||||||
|
|
||||||
|
本規範確保 Star-Cloud 系統在處理成千上萬台機台的高頻發報時,能維持伺服器響應速度與資料一致性。
|
||||||
|
|
||||||
|
## 1. 處理管線 (Processing Pipeline)
|
||||||
|
|
||||||
|
所有來自機台的非即時性資料(日誌、心跳、狀態上報)必須遵循以下 pipeline:
|
||||||
|
|
||||||
|
1. **API Controller (接收層)**:驗證 Request 合法性,隨即分派 (Dispatch) 任務至 Queue,並回傳 `202 Accepted`。
|
||||||
|
2. **Job (異步層)**:由背景 Worker 讀取隊列任務,呼叫對應 Service 處理。
|
||||||
|
3. **Service (邏輯層)**:封裝商業邏輯,更新資料庫。
|
||||||
|
4. **Model (儲存層)**:執行資料存取。
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **嚴禁**在 API Controller 直接進行資料庫寫入操作(針對機台發訊端點)。
|
||||||
|
|
||||||
|
## 2. 異步任務實作範例
|
||||||
|
|
||||||
|
### 2.1 API Endpoint
|
||||||
|
```php
|
||||||
|
public function storeLog(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
// 1. 驗證
|
||||||
|
$data = $request->validate([...]);
|
||||||
|
|
||||||
|
// 2. 異步分派
|
||||||
|
ProcessMachineLog::dispatch($id, $data);
|
||||||
|
|
||||||
|
// 3. 快速回應
|
||||||
|
return $this->successResponse([], 'Accepted', 202);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Job 處理邏輯
|
||||||
|
Job 應保持單純,僅作為 Service 的觸發點:
|
||||||
|
```php
|
||||||
|
public function handle(MachineService $service): void
|
||||||
|
{
|
||||||
|
$service->recordLog($this->machineId, $this->logData);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 隊列配置規範
|
||||||
|
|
||||||
|
- **連接驅動 (Driver)**:預設使用 `Redis` 以確保高併發吞吐量。
|
||||||
|
- **重試機制 (Retry)**:IoT 任務應設定合理的重試次數,避免因網路短暫波動遺失日誌。
|
||||||
|
- **失敗處理 (Failed Jobs)**:關鍵業務(如訂單同步)必須監控 `failed_jobs`。
|
||||||
|
|
||||||
|
## 4. 速率限制 (Rate Limiting)
|
||||||
|
|
||||||
|
- 所有的 IoT API 必須在 `routes/api.php` 中使用 `throttle:api` 或自定義 Middleware。
|
||||||
|
- 針對單一機台 ID 應限制其每一分鐘的最高連線數,防止遭受攻擊或機台 Bug 導致的連線暴衝。
|
||||||
|
|
||||||
|
## 5. 檢核項目 (Checklist)
|
||||||
|
- [ ] 是否使用了 `ApiResponse` Trait?
|
||||||
|
- [ ] 業務邏輯是否已封裝至 `App\Services`?
|
||||||
|
- [ ] 是否使用了 Redis Queue 進行非同步處理?
|
||||||
|
- [ ] 是否在 API 層級進行了基礎的參數驗證?
|
||||||
189
.agents/skills/ui-minimal-luxury/SKILL.md
Normal file
189
.agents/skills/ui-minimal-luxury/SKILL.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
---
|
||||||
|
name: 極簡奢華風 UI 實作規範 (Minimal Luxury UI)
|
||||||
|
description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範,包含 CSS Tokens、常用組件樣式、動畫效果與互動模式,確保全站 15+ 模組的視覺一致性。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 極簡奢華風 UI 實作規範 (Minimal Luxury UI)
|
||||||
|
|
||||||
|
本文件定義了 Star Cloud 專案的核心視覺語言。所有新頁面與組件開發必須嚴格遵守此規範。
|
||||||
|
|
||||||
|
## 1. 核心設計令牌 (Design Tokens)
|
||||||
|
|
||||||
|
### 色彩系統 (CSS Variables)
|
||||||
|
位於 `resources/css/app.css`:
|
||||||
|
- `--color-luxury-deep`: `#0f172a` (深色背景)
|
||||||
|
- `--color-luxury-card`: `#1e293b` (卡片背景)
|
||||||
|
- `--color-accent`: `#06b6d4` (青色點綴,適用於按鈕與標示)
|
||||||
|
|
||||||
|
### 字體 (Typography)
|
||||||
|
- **內文字體**: `Plus Jakarta Sans`
|
||||||
|
- **標題/顯示字體**: `Outfit`
|
||||||
|
- **特性**: 標題需帶有 `letter-spacing: -0.02em` 以增強精密感。
|
||||||
|
|
||||||
|
## 2. 核心組件樣式
|
||||||
|
|
||||||
|
### 豪華卡片 (Luxury Card)
|
||||||
|
```html
|
||||||
|
<div class="luxury-card p-6 rounded-2xl animate-luxury-in">
|
||||||
|
<!-- 內容 -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
- **特效**: 懸停時帶有 Y 軸平移與深度投影。
|
||||||
|
|
||||||
|
### 側邊導覽項 (Luxury Nav Item)
|
||||||
|
```html
|
||||||
|
<a href="#" class="luxury-nav-item active">
|
||||||
|
<i class="lucide-icon"></i>
|
||||||
|
<span>節點名稱</span>
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
- **啟用狀態**: 左側帶有圓角直條指示器,並輔以青色發光陰影。
|
||||||
|
|
||||||
|
### 按鈕組件 (Buttons)
|
||||||
|
- **Primary**: `.btn-luxury-primary` (青色漸層,適用於建立、儲存)
|
||||||
|
- **Secondary**: `.btn-luxury-secondary` (白色/深色背景,帶邊框,適用於編輯、篩選)
|
||||||
|
- **Ghost**: `.btn-luxury-ghost` (無背景,適用於取消、查看更多)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Primary -->
|
||||||
|
<button class="btn-luxury-primary">
|
||||||
|
<i class="lucide-plus size-4"></i>
|
||||||
|
<span>建立新機台</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Ghost -->
|
||||||
|
<button class="btn-luxury-ghost">取消</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 動畫與互動
|
||||||
|
|
||||||
|
### 進場動畫
|
||||||
|
- **`.animate-luxury-in`**: 所有的主內容區域或卡片在頁面載入時,應具備由下而上的淡入效果。
|
||||||
|
|
||||||
|
### Alpine.js 互動模式 (以時間選擇器為例)
|
||||||
|
- **互動原則**: 點擊觸發下拉選單時,必須使用 `x-transition` 且帶有 `scale` 偏移。
|
||||||
|
- **樣式要求**: 選單背景需使用玻璃擬態 (Glassmorphism) 或帶透明度的深色背景。
|
||||||
|
|
||||||
|
## 4. UI 檢查清單 (AI 助手執行前必讀)
|
||||||
|
- [ ] 是否使用了正確的 `rounded-2xl` (或更圓) 的導角?
|
||||||
|
- [ ] 所有的圖示是否一致使用 `lucide-react` 風格?
|
||||||
|
- [ ] 卡片是否有適當的間距 (通常為 `p-6`)?
|
||||||
|
- [ ] 文字色階是否符合:
|
||||||
|
- **標題**: `text-slate-900` / `dark:text-white`
|
||||||
|
- **副標/標籤**: 最小應為 `text-slate-500` / `dark:text-slate-400`(避免使用 `slate-400` 以下等級,以確保對比度足以閱讀)。
|
||||||
|
- [ ] **字體大小**: 確保所有文字至少為 `text-xs`,重要的標籤建議為 `text-sm`。
|
||||||
|
|
||||||
|
## 5. 開發注意事項 (Important Notes)
|
||||||
|
|
||||||
|
### 技術限制備忘
|
||||||
|
- **CSS 編譯**: 複雜的 `box-shadow` 或漸層應直接寫原生 CSS 屬性,避免在 `@apply` 中使用帶空格的數值導致編譯失敗(詳見 KI: `tailwind-luxury-ui-patterns`)。
|
||||||
|
- **深色模式**: 互動式按鈕在深色模式下必須強化文字亮度(`dark:text-white`),並輔以青色發光效果。
|
||||||
|
|
||||||
|
### 即時動態呈現規範
|
||||||
|
- **格式**: `#機台編號 動作內容` (例如 `#V-001 執行出貨`)。
|
||||||
|
- **脈絡**: 必須呈現相對時間與機台位置。
|
||||||
|
|
||||||
|
## 6. 頁面佈局規範 (Page Layout)
|
||||||
|
|
||||||
|
### 標準寬版佈局 (Wide Layout)
|
||||||
|
|
||||||
|
```html
|
||||||
|
@section('content')
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header: 標題與操作按鈕 -->
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Title') }}</h1>
|
||||||
|
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button class="btn-luxury-primary">...</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Container: 卡片與表格 -->
|
||||||
|
<div class="luxury-card rounded-3xl p-8 animate-luxury-in">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-left border-collapse">
|
||||||
|
<!-- Table Content -->
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
```
|
||||||
|
|
||||||
|
### 佈局核心原則:
|
||||||
|
1. **移除重複內距**: 根容器 `div` 應**禁止**使用 `p-6` 或 `p-10`,因為佈局基底已提供基礎間距。僅使用 `space-y-6` (或 `space-y-8`) 控制區塊間隙。
|
||||||
|
2. **主容器樣式**: 強制對齊為 `luxury-card rounded-3xl p-8`。
|
||||||
|
3. **標題排版**:
|
||||||
|
- 主標題需應用 `font-display` (Outfit)。
|
||||||
|
- 描述文字需應用 `uppercase tracking-widest font-bold` 以呈現高級設計感。
|
||||||
|
|
||||||
|
## 7. 表單元件規範 (Form Elements)
|
||||||
|
|
||||||
|
針對輸入框與下拉選單,強制使用以下類別以確保深色模式質感。
|
||||||
|
|
||||||
|
### 輸入框與選單
|
||||||
|
- **類別**: `.luxury-input`, `.luxury-select`
|
||||||
|
- **特性**:
|
||||||
|
- 深色模式下具備半透明背景與背景模糊效果。
|
||||||
|
- 統一的 `rounded-xl` 圓角與 `font-bold` 字體。
|
||||||
|
- 聚焦時帶有青色 (`Cyan`) 發光邊框。
|
||||||
|
|
||||||
|
```html
|
||||||
|
<input type="text" class="luxury-input" placeholder="請輸入內容">
|
||||||
|
|
||||||
|
<select class="luxury-select">
|
||||||
|
<option value="1">啟用</option>
|
||||||
|
<option value="0">禁用</option>
|
||||||
|
</select>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 資料表格規範 (Data Tables)
|
||||||
|
|
||||||
|
為了確保管理後台資料的可讀性與精密感,表格內的所有文字級別必須對齊以下規範:
|
||||||
|
|
||||||
|
### 文字大小與權重 (Typography Hierarchy)
|
||||||
|
- **表頭 (Table Header)**:
|
||||||
|
- 類別: `text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest`
|
||||||
|
- 作用: 提供清晰的欄位定義而不奪取資料視覺焦點。
|
||||||
|
- **主標題 (Primary Item)**:
|
||||||
|
- 類別: `text-base font-extrabold text-slate-800 dark:text-slate-100`
|
||||||
|
- 範例: 公司名稱、機台標題。
|
||||||
|
- **次要資訊 (Secondary Info)**:
|
||||||
|
- 類別: `text-[11px] font-bold text-slate-400 dark:text-slate-500 tracking-[0.15em]`
|
||||||
|
- 範例: 機台序號 (SN)、公司代碼。
|
||||||
|
- **狀態標籤 (Status Badge)**:
|
||||||
|
- 類別: `text-[11px] font-black tracking-widest`
|
||||||
|
- 樣式: 在線 (`emerald`)、離線 (`rose`)。
|
||||||
|
- **時間訊號 (Signals/Time)**:
|
||||||
|
- 類別: `text-[13px] font-bold font-display tracking-widest`
|
||||||
|
- 作用: 解決數字黏滯感,提升判讀舒適度。
|
||||||
|
|
||||||
|
- **內距 (Padding)**: 單元格統一使用 `px-6 py-6` 以維持呼吸感。
|
||||||
|
- **懸停 (Hover)**: 表格行需具備 `hover:bg-slate-50/80` (深色: `dark:hover:bg-slate-800/40`) 動態反饋。
|
||||||
|
|
||||||
|
### 分頁與列表控制項 (Pagination & Controls)
|
||||||
|
為了維持操作一致性,所有列表的分頁與切換組件必須遵循以下「Luxury Jump」模式:
|
||||||
|
- **統一高度**: 所有控制項(按鈕、下拉選單)固定為 `h-9` (36px)。
|
||||||
|
- **筆數切換 (Limit Selector)**:
|
||||||
|
- 樣式: 使用 `bg-slate-50` (深色: `dark:bg-slate-800`) 配合 `text-[11px] font-black`。
|
||||||
|
- 位置: 位於表格右上方。
|
||||||
|
- **分頁導航 (Luxury Jump)**:
|
||||||
|
- 模式: 捨棄傳統頁碼按鈕,全端統一使用「跳轉選單」。
|
||||||
|
- 寬度: 下拉選單內部 Padding 為 `pl-4 pr-10`。
|
||||||
|
- 字體: 使用 `text-xs font-black tracking-widest`。
|
||||||
|
- **指示文字**:
|
||||||
|
- 行動端隱藏多餘詞彙,僅保留「1 - 10 / 50」格式。
|
||||||
|
- 數字顏色對齊 `text-slate-600` (深色: `text-slate-300`)。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **開發新功能前,必須確認 `app.css` 中的 `.btn-luxury-*` 系列組件是否滿足需求。**
|
||||||
|
> 嚴禁在 Blade 中寫入大量重複的 `bg-indigo-600` 等舊式類別。
|
||||||
|
|
||||||
|
---
|
||||||
|
> [!TIP]
|
||||||
|
> 當遇到未定義的 UI 區塊時,優先參考 `admin.dashboard.blade.php` 的卡片與即時動態實作方式進行衍生。
|
||||||
13
.env.example
13
.env.example
@@ -1,12 +1,13 @@
|
|||||||
APP_NAME=startCloud
|
APP_NAME=starCloud
|
||||||
COMPOSE_PROJECT_NAME=start-cloud
|
COMPOSE_PROJECT_NAME=star-cloud
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost:8090
|
APP_URL=http://localhost:8090
|
||||||
APP_PORT=8090
|
APP_PORT=8090
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=zh_TW
|
||||||
|
APP_TIMEZONE=Asia/Taipei
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
APP_FAKER_LOCALE=en_US
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ LOG_LEVEL=debug
|
|||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=mysql
|
DB_HOST=mysql
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_DATABASE=start-cloud
|
DB_DATABASE=star_cloud
|
||||||
DB_USERNAME=sail
|
DB_USERNAME=sail
|
||||||
DB_PASSWORD=password
|
DB_PASSWORD=password
|
||||||
# FORWARD_DB_PORT=3308
|
# FORWARD_DB_PORT=3308
|
||||||
@@ -38,7 +39,7 @@ SESSION_DOMAIN=null
|
|||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=redis
|
||||||
|
|
||||||
CACHE_STORE=database
|
CACHE_STORE=database
|
||||||
# CACHE_PREFIX=
|
# CACHE_PREFIX=
|
||||||
@@ -49,7 +50,7 @@ REDIS_CLIENT=phpredis
|
|||||||
REDIS_HOST=redis
|
REDIS_HOST=redis
|
||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=null
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
# FORWARD_REDIS_PORT=6380
|
FORWARD_REDIS_PORT=6380
|
||||||
|
|
||||||
MAIL_MAILER=smtp
|
MAIL_MAILER=smtp
|
||||||
MAIL_SCHEME=null
|
MAIL_SCHEME=null
|
||||||
|
|||||||
115
.gitea/workflows/deploy-demo.yaml
Normal file
115
.gitea/workflows/deploy-demo.yaml
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
name: star-cloud-deploy-demo
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- demo
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy-demo:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
github-server-url: https://gitea.taiwan-star.com.tw
|
||||||
|
repository: ${{ github.repository }}
|
||||||
|
|
||||||
|
- name: Step 1 - Push Code to Demo
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y rsync openssh-client
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.DEMO_SSH_KEY }}" > ~/.ssh/id_rsa_demo
|
||||||
|
chmod 600 ~/.ssh/id_rsa_demo
|
||||||
|
rsync -avz --delete \
|
||||||
|
--exclude='/.git' \
|
||||||
|
--exclude='/node_modules' \
|
||||||
|
--exclude='/vendor' \
|
||||||
|
--exclude='/storage' \
|
||||||
|
--exclude='/.env' \
|
||||||
|
--exclude='/public/build' \
|
||||||
|
-e "ssh -p 2227 -i ~/.ssh/id_rsa_demo -o StrictHostKeyChecking=no" \
|
||||||
|
./ root@220.132.7.82:/var/www/star-cloud-demo/
|
||||||
|
rm ~/.ssh/id_rsa_demo
|
||||||
|
|
||||||
|
- name: Step 2 - Check if Rebuild Needed
|
||||||
|
id: check_rebuild
|
||||||
|
uses: appleboy/ssh-action@master
|
||||||
|
with:
|
||||||
|
host: 220.132.7.82
|
||||||
|
port: 2227
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
cd /var/www/star-cloud-demo
|
||||||
|
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|compose\.demo\.yaml|docker-compose\.yaml)'; then
|
||||||
|
echo "REBUILD_NEEDED=true"
|
||||||
|
else
|
||||||
|
echo "REBUILD_NEEDED=false"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Step 3 - Container Up & Health Check
|
||||||
|
uses: appleboy/ssh-action@master
|
||||||
|
with:
|
||||||
|
host: 220.132.7.82
|
||||||
|
port: 2227
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
cd /var/www/star-cloud-demo
|
||||||
|
chown -R 1000:1000 .
|
||||||
|
|
||||||
|
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|compose\.demo\.yaml|docker-compose\.yaml)'; then
|
||||||
|
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
|
||||||
|
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.demo.yaml up -d --build --wait
|
||||||
|
else
|
||||||
|
echo "⚡ 無 Docker 檔案變更,僅重載服務..."
|
||||||
|
if ! docker ps --format '{{.Names}}' | grep -q 'star-cloud-demo-laravel'; then
|
||||||
|
echo "容器未運行,正在啟動..."
|
||||||
|
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.demo.yaml up -d --wait
|
||||||
|
else
|
||||||
|
echo "容器已運行,跳過 docker compose,直接進行程式碼部署..."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "容器狀態:" && docker ps --filter "name=star-cloud-demo"
|
||||||
|
|
||||||
|
- name: Step 4 - Composer & NPM Build
|
||||||
|
uses: appleboy/ssh-action@master
|
||||||
|
with:
|
||||||
|
host: 220.132.7.82
|
||||||
|
port: 2227
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
docker exec -u 1000:1000 -w /var/www/html star-cloud-demo-laravel sh -c "
|
||||||
|
# 1. 後端依賴
|
||||||
|
composer install --no-dev --optimize-autoloader --no-interaction &&
|
||||||
|
|
||||||
|
# 2. 前端編譯
|
||||||
|
npm install &&
|
||||||
|
npm run build &&
|
||||||
|
|
||||||
|
# 3. Laravel 初始化與優化
|
||||||
|
php artisan migrate --force &&
|
||||||
|
php artisan optimize:clear &&
|
||||||
|
php artisan optimize &&
|
||||||
|
php artisan view:cache &&
|
||||||
|
php artisan db:seed --class=RoleSeeder --force &&
|
||||||
|
php artisan db:seed --class=AdminUserSeeder --force
|
||||||
|
"
|
||||||
|
docker exec star-cloud-demo-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
|
||||||
|
|
||||||
|
- name: Step 5 - Auto Sync workflows to main
|
||||||
|
run: |
|
||||||
|
git config --global user.email "bot@taiwan-star.com.tw"
|
||||||
|
git config --global user.name "CICD Bot"
|
||||||
|
git fetch origin main
|
||||||
|
git checkout main
|
||||||
|
git checkout ${{ github.ref_name }} -- .gitea/workflows/
|
||||||
|
if ! git diff --cached --quiet; then
|
||||||
|
git commit -m "[AUTO] Sync workflows from ${{ github.ref_name }} to main"
|
||||||
|
GIT_SSH_COMMAND="ssh -p 3222 -o StrictHostKeyChecking=no" git push origin main
|
||||||
|
fi
|
||||||
|
git checkout ${{ github.ref_name }}
|
||||||
|
|
||||||
21
.gitea/workflows/deploy-prod.yaml
Normal file
21
.gitea/workflows/deploy-prod.yaml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
name: star-cloud-deploy-production
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy-production:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
github-server-url: https://gitea.taiwan-star.com.tw
|
||||||
|
repository: ${{ github.repository }}
|
||||||
|
|
||||||
|
- name: Step 1 - Push Code to Production
|
||||||
|
run: |
|
||||||
|
echo "Production deployment is currently in preparation..."
|
||||||
|
# 待正式環境資料確定後,再補上 rsync 與 SSH 邏輯
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -17,3 +17,7 @@ yarn-error.log
|
|||||||
/.fleet
|
/.fleet
|
||||||
/.idea
|
/.idea
|
||||||
/.vscode
|
/.vscode
|
||||||
|
/docs/API
|
||||||
|
/docs/*.xlsx
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
216
README.md
216
README.md
@@ -1,112 +1,116 @@
|
|||||||
# Star Cloud 智能販賣機管理平台
|
# Star Cloud 智能販賣機管理平台
|
||||||
|
|
||||||
## 專案簡介 (Project Description)
|
> 基於 Docker 的全方位智能販賣機後台管理系統 (Cloud 平台)
|
||||||
Star Cloud 是一個專為智能販賣機設計的後台管理系統,旨在提供全方位的機台監控、庫存管理、銷售分析與會員管理功能。透過此平台,管理者可以即時掌握機台運營狀態,優化補貨流程,並透過數據分析提升營運效益。
|
|
||||||
|
|
||||||
## 技術棧 (Technology Stack)
|
Star Cloud 是一個專為智能販賣機設計的後台管理系統,負責管理機台、商品、銷售數據,並為硬體端點提供專用的 API。
|
||||||
|
|
||||||
### 後端 (Backend)
|
|
||||||
- **Framework**: Laravel 10.x
|
|
||||||
- **Language**: PHP 8.1+
|
|
||||||
- **Database**: MySQL 8.0+
|
|
||||||
- **Authentication**: Laravel Sanctum (API Token Authentication)
|
|
||||||
- **Tools**: Composer
|
|
||||||
|
|
||||||
### 前端 (Frontend)
|
|
||||||
- **Framework**: Blade Templates (Laravel 預設樣板引擎)
|
|
||||||
- **CSS Framework**: Tailwind CSS 3.x
|
|
||||||
- **JavaScript**: Alpine.js 3.x
|
|
||||||
- **Build Tool**: Vite 5.x
|
|
||||||
- **HTTP Client**: Axios
|
|
||||||
|
|
||||||
## 安裝與使用說明 (Installation & Usage)
|
|
||||||
|
|
||||||
請依照以下步驟將專案 Clone 至本地端並開始運行:
|
|
||||||
|
|
||||||
### 0. 前置需求 (Prerequisites)
|
|
||||||
確保您的系統已安裝以下軟體:
|
|
||||||
- PHP 8.1+
|
|
||||||
- Composer
|
|
||||||
- Node.js & npm
|
|
||||||
- MySQL 8.0+
|
|
||||||
|
|
||||||
若您尚未安裝 MySQL,Windows 使用者可至 [MySQL 官網](https://dev.mysql.com/downloads/installer/) 下載 Installer,或使用 XAMPP / Laragon 等整合環境。
|
|
||||||
|
|
||||||
### 1. 下載專案 (Clone Repository)
|
|
||||||
```bash
|
|
||||||
git clone <repository_url>
|
|
||||||
cd star-cloud
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 安裝依賴套件 (Install Dependencies)
|
|
||||||
|
|
||||||
安裝後端 PHP 套件:
|
|
||||||
```bash
|
|
||||||
composer install
|
|
||||||
```
|
|
||||||
|
|
||||||
安裝前端 Node.js 套件:
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 環境變數設定 (Environment Setup)
|
|
||||||
複製範例環境設定檔:
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
請開啟 `.env` 檔案,並依照您的本地環境設定資料庫連線資訊:
|
|
||||||
```dotenv
|
|
||||||
DB_CONNECTION=mysql
|
|
||||||
DB_HOST=127.0.0.1
|
|
||||||
DB_PORT=3306
|
|
||||||
DB_DATABASE=star_cloud
|
|
||||||
DB_USERNAME=root
|
|
||||||
DB_PASSWORD=your_password
|
|
||||||
```
|
|
||||||
|
|
||||||
產生應用程式金鑰 (Application Key):
|
|
||||||
```bash
|
|
||||||
php artisan key:generate
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 資料庫遷移 (Database Migration)
|
|
||||||
執行 Migration 以建立資料庫結構:
|
|
||||||
```bash
|
|
||||||
php artisan migrate
|
|
||||||
```
|
|
||||||
php artisan migrate --seed
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.1 預設管理員帳號 (Default Admin Account)
|
|
||||||
執行上述指令後,系統會建立一組預設管理員帳號:
|
|
||||||
- **Email**: `admin@star-cloud.com`
|
|
||||||
- **Password**: `password`
|
|
||||||
|
|
||||||
### 5. 編譯前端資源 (Build Frontend Assets)
|
|
||||||
啟動開發模式 (Hot Module Replacement):
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
或編譯生產環境檔案:
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. 啟動伺服器 (Start Server)
|
|
||||||
啟動 Laravel 開發伺服器:
|
|
||||||
```bash
|
|
||||||
php artisan serve --port=8001
|
|
||||||
```
|
|
||||||
預設網址為:http://localhost:8001
|
|
||||||
|
|
||||||
## 主要功能模組
|
|
||||||
- **儀錶板 (Dashboard)**: 銷售數據概覽、機台狀態監控
|
|
||||||
- **機台管理 (Machine Management)**: 機台列表、遠端控制、日誌查詢
|
|
||||||
- **商品與庫存 (Inventory)**: 商品管理、進銷存、補貨單
|
|
||||||
- **銷售管理 (Sales)**: 交易紀錄、營收報表
|
|
||||||
- **權限設定 (Permissions)**: 角色與權限分配
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🚀 技術架構
|
||||||
|
|
||||||
|
### 核心架構
|
||||||
|
本專案採用 **傳統單體式架構 (Monolithic Architecture)**,結合 Laravel Blade 引擎進行伺服器端渲染 (SSR)。
|
||||||
|
|
||||||
|
| 服務 | 容器名稱 | 技術 | 用途 | 本地 Port |
|
||||||
|
|------|---------|------|------|--------|
|
||||||
|
| **應用程式** | `star-cloud-laravel` | Laravel 12 + PHP 8.5 | 核心業務與渲染 | 8090 |
|
||||||
|
| **資料庫** | `star-cloud-mysql` | MySQL 8.0 | 數據持久化 | 3306 |
|
||||||
|
| **快取/隊列** | `star-cloud-redis` | Redis Alpine | IoT 高併發隊列 | 6380 |
|
||||||
|
|
||||||
|
### 後端技術棧
|
||||||
|
- **Framework**: Laravel 12.x
|
||||||
|
- **Language**: PHP 8.5
|
||||||
|
- **Redis**: 用於 IoT 高併發隊列 (B010, B600 等)
|
||||||
|
- **Database**: MySQL 8.0
|
||||||
|
|
||||||
|
### 前端技術棧
|
||||||
|
- **View**: Laravel Blade
|
||||||
|
- **CSS**: Tailwind CSS + **Preline UI**
|
||||||
|
- **JS**: Alpine.js (行為控制)
|
||||||
|
- **Build**: Vite
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 開發環境 (Laravel Sail)
|
||||||
|
|
||||||
|
本專案建議使用 **Laravel Sail** 進行開發,避免直接在宿主機執行指令。
|
||||||
|
|
||||||
|
### 前置需求
|
||||||
|
- Docker Desktop (Windows/Mac) 或 Docker Engine (Linux)
|
||||||
|
- Git
|
||||||
|
|
||||||
|
### 快速啟動
|
||||||
|
1. `clone` 專案並進入目錄
|
||||||
|
2. `cp .env.example .env`
|
||||||
|
3. `./vendor/bin/sail up -d`
|
||||||
|
4. `./vendor/bin/sail composer install`
|
||||||
|
5. `./vendor/bin/sail artisan key:generate`
|
||||||
|
6. `./vendor/bin/sail artisan migrate --seed`
|
||||||
|
7. `./vendor/bin/sail npm install`
|
||||||
|
8. `./vendor/bin/sail npm run dev`
|
||||||
|
|
||||||
|
### 常用開發指令
|
||||||
|
| 功能 | 指令 |
|
||||||
|
|------|------|
|
||||||
|
| 啟動環境 | `./vendor/bin/sail up -d` |
|
||||||
|
| 停止環境 | `./vendor/bin/sail down` |
|
||||||
|
| Artisan 指令 | `./vendor/bin/sail artisan <cmd>` |
|
||||||
|
| Composer 指令 | `./vendor/bin/sail composer <cmd>` |
|
||||||
|
| NPM 指令 | `./vendor/bin/sail npm <cmd>` |
|
||||||
|
| 執行測試 | `./vendor/bin/sail test` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 API 規範
|
||||||
|
|
||||||
|
系統 API 分為兩大類,遵循不同的設計慣例:
|
||||||
|
|
||||||
|
### 1. Admin/Web API (`/api/v1/...`)
|
||||||
|
- **對象**: 後台管理介面、APP UI。
|
||||||
|
- **認證**: Laravel Sanctum (Session/Token)。
|
||||||
|
- **格式**: 標準 RESTful JSON。
|
||||||
|
|
||||||
|
### 2. Machine IoT API (`/api/app/...`)
|
||||||
|
- **對象**: 智能販賣機、計時器等硬體。
|
||||||
|
- **認證**: Header `Authorization: Bearer <api_token>`。
|
||||||
|
- **高併發處理**: 核心日誌 (B010 心跳、B600 交易) **嚴禁直寫 DB**,必須進入 **Redis Queue** 背景異步處理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 多語系支援 (I18n)
|
||||||
|
|
||||||
|
所有 UI 顯示文字必須支援多語系,禁止 Hard-coded。
|
||||||
|
- **語系檔案**: `lang/zh_TW.json`, `lang/en.json`, `lang/ja.json`。
|
||||||
|
- **呼叫方式**: 使用 `__('Phrases in English')` 或 `@lang('...')`。
|
||||||
|
- **命名規範**: 優先使用「英文原始詞彙」作為 Key 名稱。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 目錄結構
|
||||||
|
|
||||||
|
- `app/Http/Controllers/`: 控制器
|
||||||
|
- `app/Models/{Domain}/`: 分領域的模型 (如 `Machine`, `Member`)
|
||||||
|
- `app/Services/{Domain}/`: 封裝商業邏輯與資料異動
|
||||||
|
- `app/Jobs/{Domain}/`: IoT 異步隊列處理任務 (重要)
|
||||||
|
- `resources/views/`: Blade 模板 (按功能分資料夾)
|
||||||
|
- `resources/views/components/`: 可重用的 UI 組件
|
||||||
|
- `routes/`: 路由定義 (`web.php` 與 `api.php`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚢 CI/CD 與部署
|
||||||
|
|
||||||
|
- **自動化工具**: Gitea Actions (`.gitea/workflows/`)。
|
||||||
|
- **Demo 環境**: 推送到 `demo` 分支會自動部署至 `demo-cloud.taiwan-star.com.tw`。
|
||||||
|
- **伺服器路徑**: `/var/www/star-cloud-demo` (透過 `ssh gitea_work` 登入)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 授權與版權
|
||||||
© Star Cloud. All Rights Reserved.
|
© Star Cloud. All Rights Reserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技術支援
|
||||||
|
|
||||||
|
如有問題或建議,請聯繫開發團隊或提交 Issue。
|
||||||
64
app/Console/Commands/Machine/SimulateMachineLogs.php
Normal file
64
app/Console/Commands/Machine/SimulateMachineLogs.php
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Machine;
|
||||||
|
|
||||||
|
use App\Models\Machine\Machine;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class SimulateMachineLogs extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'simulate:machine-logs {--count=10 : 發送日誌的次數}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = '模擬機台發送 API 日誌請求到後端 (用於壓測與驗證 Queue)';
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$count = (int) $this->option('count');
|
||||||
|
$machines = Machine::all();
|
||||||
|
|
||||||
|
if ($machines->isEmpty()) {
|
||||||
|
$this->error('No machines found. Please run MachineSeeder first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Starting simulation of {$count} logs...");
|
||||||
|
|
||||||
|
$bar = $this->output->createProgressBar($count);
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
// 由於是在同一個開發環境,且在 Sail 容器內部執行,
|
||||||
|
// 外部 8090 埠對應容器內部 8080 埠。
|
||||||
|
$baseUrl = 'http://localhost:8080/api/v1/machines/';
|
||||||
|
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
$machine = $machines->random();
|
||||||
|
$level = collect(['info', 'warning', 'error'])->random();
|
||||||
|
|
||||||
|
try {
|
||||||
|
Http::post($baseUrl . $machine->id . '/logs', [
|
||||||
|
'level' => $level,
|
||||||
|
'message' => "Simulated message #{$i} for machine {$machine->name}",
|
||||||
|
'context' => [
|
||||||
|
'simulated' => true,
|
||||||
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->error("\nFailed to send log: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Simulation completed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/Http/Controllers/Admin/AdminController.php
Normal file
10
app/Http/Controllers/Admin/AdminController.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
abstract class AdminController extends Controller
|
||||||
|
{
|
||||||
|
// Admin 相關的共用邏輯可寫於此
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\AppConfig;
|
use App\Models\System\AppConfig;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class AppConfigController extends Controller
|
class AppConfigController extends Controller
|
||||||
|
|||||||
125
app/Http/Controllers/Admin/CompanyController.php
Normal file
125
app/Http/Controllers/Admin/CompanyController.php
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\System\Company;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class CompanyController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of the resource.
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$query = Company::query()->withCount(['users', 'machines']);
|
||||||
|
|
||||||
|
// 搜尋
|
||||||
|
if ($search = $request->input('search')) {
|
||||||
|
$query->where(function($q) use ($search) {
|
||||||
|
$q->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('code', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 狀態篩選
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$query->where('status', $request->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = $request->input('limit', 10);
|
||||||
|
$companies = $query->latest()->paginate($limit)->withQueryString();
|
||||||
|
|
||||||
|
return view('admin.companies.index', compact('companies'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created resource in storage.
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'code' => 'required|string|max:50|unique:companies,code',
|
||||||
|
'tax_id' => 'nullable|string|max:50',
|
||||||
|
'contact_name' => 'nullable|string|max:255',
|
||||||
|
'contact_phone' => 'nullable|string|max:50',
|
||||||
|
'contact_email' => 'nullable|email|max:255',
|
||||||
|
'valid_until' => 'nullable|date',
|
||||||
|
'status' => 'required|boolean',
|
||||||
|
'note' => 'nullable|string',
|
||||||
|
// 帳號相關欄位 (可選)
|
||||||
|
'admin_username' => 'nullable|string|max:255|unique:users,username',
|
||||||
|
'admin_password' => 'nullable|string|min:8',
|
||||||
|
'admin_name' => 'nullable|string|max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($validated) {
|
||||||
|
$company = Company::create([
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'code' => $validated['code'],
|
||||||
|
'tax_id' => $validated['tax_id'] ?? null,
|
||||||
|
'contact_name' => $validated['contact_name'] ?? null,
|
||||||
|
'contact_phone' => $validated['contact_phone'] ?? null,
|
||||||
|
'contact_email' => $validated['contact_email'] ?? null,
|
||||||
|
'valid_until' => $validated['valid_until'] ?? null,
|
||||||
|
'status' => $validated['status'],
|
||||||
|
'note' => $validated['note'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 如果有填寫帳號資訊,則建立管理員帳號
|
||||||
|
if (!empty($validated['admin_username']) && !empty($validated['admin_password'])) {
|
||||||
|
$user = \App\Models\System\User::create([
|
||||||
|
'company_id' => $company->id,
|
||||||
|
'username' => $validated['admin_username'],
|
||||||
|
'password' => \Illuminate\Support\Facades\Hash::make($validated['admin_password']),
|
||||||
|
'name' => $validated['admin_name'] ?: ($validated['contact_name'] ?: $validated['name']),
|
||||||
|
'status' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 綁定客戶管理員角色
|
||||||
|
$user->assignRole('tenant-admin');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', __('Customer created successfully.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified resource in storage.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, Company $company)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'code' => 'required|string|max:50|unique:companies,code,' . $company->id,
|
||||||
|
'tax_id' => 'nullable|string|max:50',
|
||||||
|
'contact_name' => 'required|string|max:255',
|
||||||
|
'contact_phone' => 'nullable|string|max:50',
|
||||||
|
'contact_email' => 'nullable|email|max:255',
|
||||||
|
'valid_until' => 'nullable|date',
|
||||||
|
'status' => 'required|boolean',
|
||||||
|
'note' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$company->update($validated);
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', __('Customer updated successfully.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified resource from storage.
|
||||||
|
*/
|
||||||
|
public function destroy(Company $company)
|
||||||
|
{
|
||||||
|
if ($company->users()->count() > 0) {
|
||||||
|
return redirect()->back()->with('error', __('Cannot delete company with active accounts.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$company->delete();
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', __('Customer deleted successfully.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,25 +3,39 @@
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Machine;
|
use App\Models\Machine\Machine;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class DashboardController extends Controller
|
class DashboardController extends Controller
|
||||||
{
|
{
|
||||||
public function index()
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
// 模擬數據或從資料庫獲取
|
// 每頁顯示筆數限制 (預設為 10)
|
||||||
// 由於目前沒有數據,我們先傳遞一些預設值或空集合
|
$perPage = $request->get('limit', 10);
|
||||||
$totalMachines = Machine::count();
|
|
||||||
$onlineMachines = Machine::where('status', 'online')->count();
|
// 從資料庫獲取真實統計數據
|
||||||
$offlineMachines = Machine::where('status', 'offline')->count();
|
$totalRevenue = \App\Models\Member\MemberWallet::sum('balance');
|
||||||
$errorMachines = Machine::where('status', 'error')->count();
|
$activeMachines = Machine::where('status', 'online')->count();
|
||||||
|
$alertsPending = Machine::where('status', 'error')->count();
|
||||||
|
$memberCount = \App\Models\Member\Member::count();
|
||||||
|
|
||||||
|
// 獲取機台列表 (分頁)
|
||||||
|
$machines = Machine::when($request->search, function($query, $search) {
|
||||||
|
$query->where(function($q) use ($search) {
|
||||||
|
$q->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('serial_no', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->latest()
|
||||||
|
->paginate($perPage)
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
return view('admin.dashboard', compact(
|
return view('admin.dashboard', compact(
|
||||||
'totalMachines',
|
'totalRevenue',
|
||||||
'onlineMachines',
|
'activeMachines',
|
||||||
'offlineMachines',
|
'alertsPending',
|
||||||
'errorMachines'
|
'memberCount',
|
||||||
|
'machines'
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,14 +34,6 @@ class DataConfigController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 帳號管理
|
|
||||||
public function accounts()
|
|
||||||
{
|
|
||||||
return view('admin.placeholder', [
|
|
||||||
'title' => '帳號管理',
|
|
||||||
'description' => '主帳號管理',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 子帳號管理
|
// 子帳號管理
|
||||||
public function subAccounts()
|
public function subAccounts()
|
||||||
|
|||||||
56
app/Http/Controllers/Admin/DepositBonusRuleController.php
Normal file
56
app/Http/Controllers/Admin/DepositBonusRuleController.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Member\DepositBonusRule;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class DepositBonusRuleController extends Controller
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$rules = DepositBonusRule::orderBy('min_amount')->get();
|
||||||
|
return view('admin.deposit-bonus-rules.index', compact('rules'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'min_amount' => 'required|numeric|min:0',
|
||||||
|
'bonus_type' => 'required|in:fixed,percentage',
|
||||||
|
'bonus_value' => 'required|numeric|min:0',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'start_at' => 'nullable|date',
|
||||||
|
'end_at' => 'nullable|date|after:start_at',
|
||||||
|
]);
|
||||||
|
|
||||||
|
DepositBonusRule::create($validated);
|
||||||
|
|
||||||
|
return redirect()->route('admin.deposit-bonus-rules.index')->with('success', '儲值回饋規則已建立');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, DepositBonusRule $depositBonusRule)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'min_amount' => 'required|numeric|min:0',
|
||||||
|
'bonus_type' => 'required|in:fixed,percentage',
|
||||||
|
'bonus_value' => 'required|numeric|min:0',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'start_at' => 'nullable|date',
|
||||||
|
'end_at' => 'nullable|date|after:start_at',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$depositBonusRule->update($validated);
|
||||||
|
|
||||||
|
return redirect()->route('admin.deposit-bonus-rules.index')->with('success', '儲值回饋規則已更新');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(DepositBonusRule $depositBonusRule)
|
||||||
|
{
|
||||||
|
$depositBonusRule->delete();
|
||||||
|
return redirect()->route('admin.deposit-bonus-rules.index')->with('success', '儲值回饋規則已刪除');
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Http/Controllers/Admin/GiftDefinitionController.php
Normal file
58
app/Http/Controllers/Admin/GiftDefinitionController.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Member\GiftDefinition;
|
||||||
|
use App\Models\Member\MembershipTier;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class GiftDefinitionController extends Controller
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$gifts = GiftDefinition::with('tier')->get();
|
||||||
|
$tiers = MembershipTier::orderBy('sort_order')->get();
|
||||||
|
return view('admin.gift-definitions.index', compact('gifts', 'tiers'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'type' => 'required|in:points,coupon,product,discount,cash',
|
||||||
|
'value' => 'required|numeric|min:0',
|
||||||
|
'tier_id' => 'nullable|exists:membership_tiers,id',
|
||||||
|
'trigger' => 'required|in:register,birthday,annual,upgrade,manual',
|
||||||
|
'validity_days' => 'required|integer|min:1',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
GiftDefinition::create($validated);
|
||||||
|
|
||||||
|
return redirect()->route('admin.gift-definitions.index')->with('success', '禮品已建立');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, GiftDefinition $giftDefinition)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'type' => 'required|in:points,coupon,product,discount,cash',
|
||||||
|
'value' => 'required|numeric|min:0',
|
||||||
|
'tier_id' => 'nullable|exists:membership_tiers,id',
|
||||||
|
'trigger' => 'required|in:register,birthday,annual,upgrade,manual',
|
||||||
|
'validity_days' => 'required|integer|min:1',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$giftDefinition->update($validated);
|
||||||
|
|
||||||
|
return redirect()->route('admin.gift-definitions.index')->with('success', '禮品已更新');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(GiftDefinition $giftDefinition)
|
||||||
|
{
|
||||||
|
$giftDefinition->delete();
|
||||||
|
return redirect()->route('admin.gift-definitions.index')->with('success', '禮品已刪除');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,142 +2,91 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Models\Machine\Machine;
|
||||||
use App\Models\Machine;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class MachineController extends Controller
|
class MachineController extends AdminController
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Display a listing of the resource.
|
* 顯示所有機台列表
|
||||||
*/
|
*/
|
||||||
public function index()
|
public function index(Request $request): View
|
||||||
{
|
{
|
||||||
$machines = Machine::latest()->paginate(10);
|
$limit = $request->input('limit', 10);
|
||||||
|
$machines = Machine::query()
|
||||||
|
->when($request->status, function ($query, $status) {
|
||||||
|
return $query->where('status', $status);
|
||||||
|
})
|
||||||
|
->latest()
|
||||||
|
->paginate($limit)
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
return view('admin.machines.index', compact('machines'));
|
return view('admin.machines.index', compact('machines'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the form for creating a new resource.
|
* 顯示特定機台的日誌與詳細資訊
|
||||||
*/
|
*/
|
||||||
public function create()
|
public function show(int $id): View
|
||||||
{
|
{
|
||||||
return view('admin.machines.create');
|
$machine = Machine::with(['logs' => function ($query) {
|
||||||
}
|
$query->latest()->limit(50);
|
||||||
|
}])->findOrFail($id);
|
||||||
|
|
||||||
/**
|
|
||||||
* Store a newly created resource in storage.
|
|
||||||
*/
|
|
||||||
public function store(Request $request)
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'name' => 'required|string|max:255',
|
|
||||||
'location' => 'nullable|string|max:255',
|
|
||||||
'status' => 'required|in:online,offline,error',
|
|
||||||
'temperature' => 'nullable|numeric',
|
|
||||||
'firmware_version' => 'nullable|string|max:50',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Machine::create($validated);
|
|
||||||
|
|
||||||
return redirect()->route('admin.machines.index')
|
|
||||||
->with('success', '機台建立成功');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display the specified resource.
|
|
||||||
*/
|
|
||||||
public function show(Machine $machine)
|
|
||||||
{
|
|
||||||
return view('admin.machines.show', compact('machine'));
|
return view('admin.machines.show', compact('machine'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the form for editing the specified resource.
|
* 顯示所有機台日誌列表
|
||||||
*/
|
*/
|
||||||
public function edit(Machine $machine)
|
public function logs(Request $request): View
|
||||||
{
|
{
|
||||||
return view('admin.machines.edit', compact('machine'));
|
$limit = $request->input('limit', 20);
|
||||||
|
$logs = \App\Models\Machine\MachineLog::with('machine')
|
||||||
|
->when($request->level, function ($query, $level) {
|
||||||
|
return $query->where('level', $level);
|
||||||
|
})
|
||||||
|
->when($request->machine_id, function ($query, $machineId) {
|
||||||
|
return $query->where('machine_id', $machineId);
|
||||||
|
})
|
||||||
|
->latest()
|
||||||
|
->paginate($limit)->withQueryString();
|
||||||
|
|
||||||
|
$machines = Machine::select('id', 'name')->get();
|
||||||
|
|
||||||
|
return view('admin.machines.logs', compact('logs', 'machines'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the specified resource in storage.
|
* 機台權限設定 (開發中)
|
||||||
*/
|
*/
|
||||||
public function update(Request $request, Machine $machine)
|
public function permissions(Request $request): View
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
|
||||||
'name' => 'required|string|max:255',
|
|
||||||
'location' => 'nullable|string|max:255',
|
|
||||||
'status' => 'required|in:online,offline,error',
|
|
||||||
'temperature' => 'nullable|numeric',
|
|
||||||
'firmware_version' => 'nullable|string|max:50',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$machine->update($validated);
|
|
||||||
|
|
||||||
return redirect()->route('admin.machines.index')
|
|
||||||
->with('success', '機台更新成功');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove the specified resource from storage.
|
* 機台使用率統計 (開發中)
|
||||||
*/
|
*/
|
||||||
public function destroy(Machine $machine)
|
public function utilization(Request $request): View
|
||||||
{
|
{
|
||||||
$machine->delete();
|
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
|
||||||
|
|
||||||
return redirect()->route('admin.machines.index')
|
|
||||||
->with('success', '機台已刪除');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 機台日誌
|
/**
|
||||||
public function logs()
|
* 機台到期管理 (開發中)
|
||||||
|
*/
|
||||||
|
public function expiry(Request $request): View
|
||||||
{
|
{
|
||||||
return view('admin.placeholder', [
|
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
|
||||||
'title' => '機台日誌',
|
|
||||||
'description' => '機台操作歷史紀錄回溯',
|
|
||||||
'features' => [
|
|
||||||
'操作時間戳記',
|
|
||||||
'事件類型分類',
|
|
||||||
'操作人員記錄',
|
|
||||||
'詳細描述查詢',
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 機台權限
|
/**
|
||||||
public function permissions()
|
* 機台維護紀錄 (開發中)
|
||||||
|
*/
|
||||||
|
public function maintenance(Request $request): View
|
||||||
{
|
{
|
||||||
return view('admin.placeholder', [
|
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
|
||||||
'title' => '機台權限',
|
|
||||||
'description' => '機台存取權限控管',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 機台稼動率
|
|
||||||
public function utilization()
|
|
||||||
{
|
|
||||||
return view('admin.placeholder', [
|
|
||||||
'title' => '機台稼動率',
|
|
||||||
'description' => '機台運行效率分析',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 效期管理
|
|
||||||
public function expiry()
|
|
||||||
{
|
|
||||||
return view('admin.placeholder', [
|
|
||||||
'title' => '效期管理',
|
|
||||||
'description' => '商品效期與貨道出貨控制',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 維修管理單
|
|
||||||
public function maintenance()
|
|
||||||
{
|
|
||||||
return view('admin.placeholder', [
|
|
||||||
'title' => '維修管理單',
|
|
||||||
'description' => '機台維修工單系統',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
62
app/Http/Controllers/Admin/MembershipTierController.php
Normal file
62
app/Http/Controllers/Admin/MembershipTierController.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Member\MembershipTier;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class MembershipTierController extends Controller
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$tiers = MembershipTier::orderBy('sort_order')->get();
|
||||||
|
return view('admin.membership-tiers.index', compact('tiers'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'annual_fee' => 'required|numeric|min:0',
|
||||||
|
'discount_rate' => 'required|numeric|min:0|max:1',
|
||||||
|
'point_multiplier' => 'required|numeric|min:0',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'is_default' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($request->is_default) {
|
||||||
|
MembershipTier::where('is_default', true)->update(['is_default' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
MembershipTier::create($validated);
|
||||||
|
|
||||||
|
return redirect()->route('admin.membership-tiers.index')->with('success', '會員等級已建立');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, MembershipTier $membershipTier)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'annual_fee' => 'required|numeric|min:0',
|
||||||
|
'discount_rate' => 'required|numeric|min:0|max:1',
|
||||||
|
'point_multiplier' => 'required|numeric|min:0',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'is_default' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($request->is_default && !$membershipTier->is_default) {
|
||||||
|
MembershipTier::where('is_default', true)->update(['is_default' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$membershipTier->update($validated);
|
||||||
|
|
||||||
|
return redirect()->route('admin.membership-tiers.index')->with('success', '會員等級已更新');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(MembershipTier $membershipTier)
|
||||||
|
{
|
||||||
|
$membershipTier->delete();
|
||||||
|
return redirect()->route('admin.membership-tiers.index')->with('success', '會員等級已刪除');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,10 +91,67 @@ class PermissionController extends Controller
|
|||||||
// 權限角色設定
|
// 權限角色設定
|
||||||
public function roles()
|
public function roles()
|
||||||
{
|
{
|
||||||
return view('admin.placeholder', [
|
$limit = request()->input('limit', 10);
|
||||||
'title' => '權限角色設定',
|
$roles = \Spatie\Permission\Models\Role::withCount('users')->latest()->paginate($limit)->withQueryString();
|
||||||
'description' => '角色權限組合設定',
|
return view('admin.permission.roles', compact('roles'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created role in storage.
|
||||||
|
*/
|
||||||
|
public function storeRole(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255|unique:roles,name',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
\Spatie\Permission\Models\Role::create([
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'guard_name' => 'web',
|
||||||
|
'is_system' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', __('Role created successfully.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified role in storage.
|
||||||
|
*/
|
||||||
|
public function updateRole(Request $request, $id)
|
||||||
|
{
|
||||||
|
$role = \Spatie\Permission\Models\Role::findOrFail($id);
|
||||||
|
|
||||||
|
if ($role->is_system) {
|
||||||
|
return redirect()->back()->with('error', __('System roles cannot be renamed.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255|unique:roles,name,' . $id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$role->update(['name' => $validated['name']]);
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', __('Role updated successfully.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified role from storage.
|
||||||
|
*/
|
||||||
|
public function destroyRole($id)
|
||||||
|
{
|
||||||
|
$role = \Spatie\Permission\Models\Role::findOrFail($id);
|
||||||
|
|
||||||
|
if ($role->is_system) {
|
||||||
|
return redirect()->back()->with('error', __('System roles cannot be deleted.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($role->users()->count() > 0) {
|
||||||
|
return redirect()->back()->with('error', __('Cannot delete role with active users.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$role->delete();
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', __('Role deleted successfully.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 其他功能管理
|
// 其他功能管理
|
||||||
@@ -106,6 +163,125 @@ class PermissionController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 帳號管理
|
||||||
|
public function accounts(Request $request)
|
||||||
|
{
|
||||||
|
$query = \App\Models\System\User::query()->with(['company', 'roles']);
|
||||||
|
|
||||||
|
// 租戶隔離:如果不是系統管理員,則只看自己公司的成員
|
||||||
|
if (!auth()->user()->isSystemAdmin()) {
|
||||||
|
$query->where('company_id', auth()->user()->company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜尋
|
||||||
|
if ($search = $request->input('search')) {
|
||||||
|
$query->where(function($q) use ($search) {
|
||||||
|
$q->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('username', 'like', "%{$search}%")
|
||||||
|
->orWhere('email', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公司篩選 (僅限 super-admin)
|
||||||
|
if (auth()->user()->isSystemAdmin() && $request->filled('company_id')) {
|
||||||
|
$query->where('company_id', $request->company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = $request->input('limit', 10);
|
||||||
|
$users = $query->latest()->paginate($limit)->withQueryString();
|
||||||
|
$companies = auth()->user()->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
|
||||||
|
|
||||||
|
return view('admin.data-config.accounts', compact('users', 'companies'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created account in storage.
|
||||||
|
*/
|
||||||
|
public function storeAccount(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'username' => 'required|string|max:255|unique:users,username',
|
||||||
|
'email' => 'nullable|email|max:255|unique:users,email',
|
||||||
|
'password' => 'required|string|min:8',
|
||||||
|
'role' => 'required|string',
|
||||||
|
'status' => 'required|boolean',
|
||||||
|
'company_id' => 'nullable|exists:companies,id',
|
||||||
|
'phone' => 'nullable|string|max:20',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = \App\Models\System\User::create([
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'username' => $validated['username'],
|
||||||
|
'email' => $validated['email'],
|
||||||
|
'password' => \Illuminate\Support\Facades\Hash::make($validated['password']),
|
||||||
|
'status' => $validated['status'],
|
||||||
|
'company_id' => auth()->user()->isSystemAdmin() ? $validated['company_id'] : auth()->user()->company_id,
|
||||||
|
'phone' => $validated['phone'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->assignRole($validated['role']);
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', __('Account created successfully.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified account in storage.
|
||||||
|
*/
|
||||||
|
public function updateAccount(Request $request, $id)
|
||||||
|
{
|
||||||
|
$user = \App\Models\System\User::findOrFail($id);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'username' => 'required|string|max:255|unique:users,username,' . $id,
|
||||||
|
'email' => 'nullable|email|max:255|unique:users,email,' . $id,
|
||||||
|
'password' => 'nullable|string|min:8',
|
||||||
|
'role' => 'required|string',
|
||||||
|
'status' => 'required|boolean',
|
||||||
|
'company_id' => 'nullable|exists:companies,id',
|
||||||
|
'phone' => 'nullable|string|max:20',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$updateData = [
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'username' => $validated['username'],
|
||||||
|
'email' => $validated['email'],
|
||||||
|
'status' => $validated['status'],
|
||||||
|
'phone' => $validated['phone'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (auth()->user()->isSystemAdmin()) {
|
||||||
|
$updateData['company_id'] = $validated['company_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($validated['password'])) {
|
||||||
|
$updateData['password'] = \Illuminate\Support\Facades\Hash::make($validated['password']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->update($updateData);
|
||||||
|
|
||||||
|
$user->syncRoles([$validated['role']]);
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', __('Account updated successfully.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified account from storage.
|
||||||
|
*/
|
||||||
|
public function destroyAccount($id)
|
||||||
|
{
|
||||||
|
$user = \App\Models\System\User::findOrFail($id);
|
||||||
|
|
||||||
|
if ($user->id === auth()->id()) {
|
||||||
|
return redirect()->back()->with('error', __('You cannot delete your own account.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->delete();
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', __('Account deleted successfully.'));
|
||||||
|
}
|
||||||
|
|
||||||
// AI智能預測
|
// AI智能預測
|
||||||
public function aiPrediction()
|
public function aiPrediction()
|
||||||
{
|
{
|
||||||
|
|||||||
54
app/Http/Controllers/Admin/PointRuleController.php
Normal file
54
app/Http/Controllers/Admin/PointRuleController.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Member\PointRule;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PointRuleController extends Controller
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$rules = PointRule::all();
|
||||||
|
return view('admin.point-rules.index', compact('rules'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'trigger' => 'required|in:purchase,deposit,register,birthday,referral',
|
||||||
|
'points_per_unit' => 'required|integer|min:1',
|
||||||
|
'unit_amount' => 'required|numeric|min:0',
|
||||||
|
'validity_days' => 'required|integer|min:1',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
PointRule::create($validated);
|
||||||
|
|
||||||
|
return redirect()->route('admin.point-rules.index')->with('success', '點數規則已建立');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, PointRule $pointRule)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'trigger' => 'required|in:purchase,deposit,register,birthday,referral',
|
||||||
|
'points_per_unit' => 'required|integer|min:1',
|
||||||
|
'unit_amount' => 'required|numeric|min:0',
|
||||||
|
'validity_days' => 'required|integer|min:1',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pointRule->update($validated);
|
||||||
|
|
||||||
|
return redirect()->route('admin.point-rules.index')->with('success', '點數規則已更新');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(PointRule $pointRule)
|
||||||
|
{
|
||||||
|
$pointRule->delete();
|
||||||
|
return redirect()->route('admin.point-rules.index')->with('success', '點數規則已刪除');
|
||||||
|
}
|
||||||
|
}
|
||||||
260
app/Http/Controllers/Api/MemberController.php
Normal file
260
app/Http/Controllers/Api/MemberController.php
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Member\Member;
|
||||||
|
use App\Models\Member\SocialAccount;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
class MemberController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 會員註冊
|
||||||
|
*/
|
||||||
|
public function register(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => ['nullable', 'email', 'unique:members,email'],
|
||||||
|
'phone' => ['nullable', 'string', 'unique:members,phone'],
|
||||||
|
'password' => ['required', Password::min(6)],
|
||||||
|
'birthday' => ['nullable', 'date'],
|
||||||
|
'gender' => ['nullable', 'in:male,female,other'],
|
||||||
|
], [
|
||||||
|
'name.required' => '請輸入姓名',
|
||||||
|
'email.unique' => '此 Email 已被註冊',
|
||||||
|
'phone.unique' => '此手機號碼已被註冊',
|
||||||
|
'password.required' => '請輸入密碼',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => '驗證失敗',
|
||||||
|
'errors' => $validator->errors(),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 必須提供 email 或 phone 其中之一
|
||||||
|
if (empty($request->email) && empty($request->phone)) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => '請提供 Email 或手機號碼',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$member = Member::create([
|
||||||
|
'name' => $request->name,
|
||||||
|
'email' => $request->email,
|
||||||
|
'phone' => $request->phone,
|
||||||
|
'password' => $request->password,
|
||||||
|
'birthday' => $request->birthday,
|
||||||
|
'gender' => $request->gender,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$token = $member->createToken('member-token')->plainTextToken;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => '註冊成功',
|
||||||
|
'data' => [
|
||||||
|
'member' => $member,
|
||||||
|
'token' => $token,
|
||||||
|
],
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 會員登入(Email/Phone + Password)
|
||||||
|
*/
|
||||||
|
public function login(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'account' => ['required', 'string'],
|
||||||
|
'password' => ['required', 'string'],
|
||||||
|
], [
|
||||||
|
'account.required' => '請輸入帳號',
|
||||||
|
'password.required' => '請輸入密碼',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => '驗證失敗',
|
||||||
|
'errors' => $validator->errors(),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 嘗試以 email 或 phone 查詢
|
||||||
|
$member = Member::where('email', $request->account)
|
||||||
|
->orWhere('phone', $request->account)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$member || !Hash::check($request->password, $member->password)) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => '帳號或密碼錯誤',
|
||||||
|
], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$member->is_active) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => '帳號已被停用',
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $member->createToken('member-token')->plainTextToken;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => '登入成功',
|
||||||
|
'data' => [
|
||||||
|
'member' => $member,
|
||||||
|
'token' => $token,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 社群登入
|
||||||
|
*/
|
||||||
|
public function socialLogin(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'provider' => ['required', 'in:line,google,facebook'],
|
||||||
|
'provider_id' => ['required', 'string'],
|
||||||
|
'access_token' => ['nullable', 'string'],
|
||||||
|
'name' => ['nullable', 'string'],
|
||||||
|
'email' => ['nullable', 'email'],
|
||||||
|
'avatar' => ['nullable', 'string'],
|
||||||
|
], [
|
||||||
|
'provider.required' => '請指定登入平台',
|
||||||
|
'provider.in' => '不支援的登入平台',
|
||||||
|
'provider_id.required' => '缺少社群用戶 ID',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => '驗證失敗',
|
||||||
|
'errors' => $validator->errors(),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查詢是否已綁定
|
||||||
|
$socialAccount = SocialAccount::where('provider', $request->provider)
|
||||||
|
->where('provider_id', $request->provider_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($socialAccount) {
|
||||||
|
// 已綁定,直接登入
|
||||||
|
$member = $socialAccount->member;
|
||||||
|
|
||||||
|
// 更新 token
|
||||||
|
$socialAccount->update([
|
||||||
|
'access_token' => $request->access_token,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
// 未綁定,建立新會員
|
||||||
|
$member = Member::create([
|
||||||
|
'name' => $request->name ?? '會員',
|
||||||
|
'email' => $request->email,
|
||||||
|
'avatar' => $request->avatar,
|
||||||
|
'email_verified_at' => $request->email ? now() : null, // 社群登入自動驗證
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 綁定社群帳號
|
||||||
|
$member->socialAccounts()->create([
|
||||||
|
'provider' => $request->provider,
|
||||||
|
'provider_id' => $request->provider_id,
|
||||||
|
'access_token' => $request->access_token,
|
||||||
|
'profile_data' => $request->only(['name', 'email', 'avatar']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$member->is_active) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => '帳號已被停用',
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $member->createToken('member-token')->plainTextToken;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => '登入成功',
|
||||||
|
'data' => [
|
||||||
|
'member' => $member,
|
||||||
|
'token' => $token,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得個人資料
|
||||||
|
*/
|
||||||
|
public function profile(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$member = $request->user();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'member' => $member->load('socialAccounts'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新個人資料
|
||||||
|
*/
|
||||||
|
public function updateProfile(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$member = $request->user();
|
||||||
|
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'birthday' => ['nullable', 'date'],
|
||||||
|
'gender' => ['nullable', 'in:male,female,other'],
|
||||||
|
'avatar' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => '驗證失敗',
|
||||||
|
'errors' => $validator->errors(),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$member->update($request->only(['name', 'birthday', 'gender', 'avatar']));
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => '更新成功',
|
||||||
|
'data' => [
|
||||||
|
'member' => $member,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登出
|
||||||
|
*/
|
||||||
|
public function logout(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$request->user()->currentAccessToken()->delete();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => '登出成功',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/Http/Controllers/Api/V1/ApiController.php
Normal file
11
app/Http/Controllers/Api/V1/ApiController.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Traits\ApiResponse;
|
||||||
|
|
||||||
|
abstract class ApiController extends Controller
|
||||||
|
{
|
||||||
|
use ApiResponse;
|
||||||
|
}
|
||||||
39
app/Http/Controllers/Api/V1/MachineController.php
Normal file
39
app/Http/Controllers/Api/V1/MachineController.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Jobs\Machine\ProcessMachineLog;
|
||||||
|
use App\Models\Machine\Machine;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
|
class MachineController extends ApiController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 接收機台回傳的日誌 (IoT Endpoint)
|
||||||
|
* 採用異步處理 (Queue)
|
||||||
|
*/
|
||||||
|
public function storeLog(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'level' => 'required|string|in:info,warning,error',
|
||||||
|
'message' => 'required|string',
|
||||||
|
'context' => 'nullable|array',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return $this->errorResponse('Validation error', 422, $validator->errors());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 檢查機台是否存在
|
||||||
|
if (!Machine::where('id', $id)->exists()) {
|
||||||
|
return $this->errorResponse('Machine not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 丟入隊列進行異步處理,回傳 202 Accepted
|
||||||
|
ProcessMachineLog::dispatch($id, $request->only(['level', 'message', 'context']));
|
||||||
|
|
||||||
|
return $this->successResponse([], 'Log accepted. Processing asynchronously.', 202);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/Http/Controllers/Api/V1/MemberController.php
Normal file
11
app/Http/Controllers/Api/V1/MemberController.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class MemberController extends Controller
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\User;
|
use App\Models\System\User;
|
||||||
use App\Providers\RouteServiceProvider;
|
use App\Providers\RouteServiceProvider;
|
||||||
use Illuminate\Auth\Events\Registered;
|
use Illuminate\Auth\Events\Registered;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|||||||
25
app/Http/Controllers/MemberController.php
Normal file
25
app/Http/Controllers/MemberController.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Member\Member;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class MemberController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of the members.
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$members = Member::query()
|
||||||
|
->latest()
|
||||||
|
->paginate(10);
|
||||||
|
|
||||||
|
return view('admin.members.index', [
|
||||||
|
'members' => $members,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use Illuminate\Http\RedirectResponse;
|
|||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Redirect;
|
use Illuminate\Support\Facades\Redirect;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class ProfileController extends Controller
|
class ProfileController extends Controller
|
||||||
@@ -16,8 +17,11 @@ class ProfileController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function edit(Request $request): View
|
public function edit(Request $request): View
|
||||||
{
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
return view('profile.edit', [
|
return view('profile.edit', [
|
||||||
'user' => $request->user(),
|
// 只取最新 10 筆登入紀錄
|
||||||
|
'user' => $user->load(['loginLogs' => fn($q) => $q->latest('login_at')->limit(10)]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,35 +30,50 @@ class ProfileController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$request->user()->fill($request->validated());
|
$user = $request->user();
|
||||||
|
$user->fill($request->validated());
|
||||||
|
|
||||||
if ($request->user()->isDirty('email')) {
|
if ($user->isDirty('email')) {
|
||||||
$request->user()->email_verified_at = null;
|
$user->email_verified_at = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$request->user()->save();
|
$user->save();
|
||||||
|
|
||||||
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete the user's account.
|
* Update the user's avatar via AJAX.
|
||||||
*/
|
*/
|
||||||
public function destroy(Request $request): RedirectResponse
|
public function updateAvatar(Request $request): \Illuminate\Http\JsonResponse
|
||||||
{
|
{
|
||||||
$request->validateWithBag('userDeletion', [
|
$request->validate([
|
||||||
'password' => ['required', 'current_password'],
|
'avatar' => ['required', 'image', 'mimes:jpeg,png,jpg,gif', 'max:1024'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
Auth::logout();
|
if ($request->hasFile('avatar')) {
|
||||||
|
// Delete old avatar if exists
|
||||||
|
if ($user->avatar) {
|
||||||
|
Storage::disk('public')->delete($user->avatar);
|
||||||
|
}
|
||||||
|
|
||||||
$user->delete();
|
$path = $request->file('avatar')->store('avatars', 'public');
|
||||||
|
$user->avatar = $path;
|
||||||
|
$user->save();
|
||||||
|
|
||||||
$request->session()->invalidate();
|
return response()->json([
|
||||||
$request->session()->regenerateToken();
|
'success' => true,
|
||||||
|
'avatar_url' => $user->avatar_url,
|
||||||
|
'message' => __('Avatar updated successfully.'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return Redirect::to('/');
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => __('No file uploaded.'),
|
||||||
|
], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
33
app/Http/Controllers/SocialLoginTestController.php
Normal file
33
app/Http/Controllers/SocialLoginTestController.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class SocialLoginTestController extends Controller
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
return view('test.social-login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lineCallback(Request $request)
|
||||||
|
{
|
||||||
|
// 這裡可以實作後端換發 Token 的邏輯
|
||||||
|
// 為了測試方便,我們先直接顯示回傳的 code 與 state
|
||||||
|
// 或者嘗試交換 Token 並取得 User Profile
|
||||||
|
|
||||||
|
$code = $request->input('code');
|
||||||
|
$state = $request->input('state');
|
||||||
|
$error = $request->input('error');
|
||||||
|
|
||||||
|
return view('test.social-login', [
|
||||||
|
'line_data' => [
|
||||||
|
'code' => $code,
|
||||||
|
'state' => $state,
|
||||||
|
'error' => $error
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Http/Controllers/System/LanguageController.php
Normal file
25
app/Http/Controllers/System/LanguageController.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\System;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Session;
|
||||||
|
|
||||||
|
class LanguageController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Switch application language.
|
||||||
|
*
|
||||||
|
* @param string $locale
|
||||||
|
* @return \Illuminate\Http\RedirectResponse
|
||||||
|
*/
|
||||||
|
public function switch($locale)
|
||||||
|
{
|
||||||
|
if (in_array($locale, ['en', 'zh_TW', 'ja'])) {
|
||||||
|
Session::put('locale', $locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->back();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ class Kernel extends HttpKernel
|
|||||||
\App\Http\Middleware\EncryptCookies::class,
|
\App\Http\Middleware\EncryptCookies::class,
|
||||||
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
||||||
\Illuminate\Session\Middleware\StartSession::class,
|
\Illuminate\Session\Middleware\StartSession::class,
|
||||||
|
\App\Http\Middleware\SetLocale::class,
|
||||||
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||||
\App\Http\Middleware\VerifyCsrfToken::class,
|
\App\Http\Middleware\VerifyCsrfToken::class,
|
||||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||||
@@ -64,5 +65,9 @@ class Kernel extends HttpKernel
|
|||||||
'signed' => \App\Http\Middleware\ValidateSignature::class,
|
'signed' => \App\Http\Middleware\ValidateSignature::class,
|
||||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||||
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
|
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
|
||||||
|
'tenant.access' => \App\Http\Middleware\EnsureTenantAccess::class,
|
||||||
|
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
||||||
|
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
|
||||||
|
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
37
app/Http/Middleware/EnsureTenantAccess.php
Normal file
37
app/Http/Middleware/EnsureTenantAccess.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureTenantAccess
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
// 如果是租戶帳號,檢查公司狀態
|
||||||
|
if ($user && $user->isTenant()) {
|
||||||
|
$company = $user->company;
|
||||||
|
|
||||||
|
if (!$company || $company->status === 0) {
|
||||||
|
auth()->logout();
|
||||||
|
return redirect()->route('login')->with('error', __('Your account is associated with a deactivated company.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($company->valid_until && $company->valid_until->isPast()) {
|
||||||
|
auth()->logout();
|
||||||
|
return redirect()->route('login')->with('error', __('Your company contract has expired.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Middleware/SetLocale.php
Normal file
27
app/Http/Middleware/SetLocale.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class SetLocale
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (session()->has('locale')) {
|
||||||
|
$locale = session()->get('locale');
|
||||||
|
if (in_array($locale, ['zh_TW', 'en', 'ja'])) {
|
||||||
|
app()->setLocale($locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ class TrustProxies extends Middleware
|
|||||||
*
|
*
|
||||||
* @var array<int, string>|string|null
|
* @var array<int, string>|string|null
|
||||||
*/
|
*/
|
||||||
protected $proxies;
|
protected $proxies = '*';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The headers that should be used to detect proxies.
|
* The headers that should be used to detect proxies.
|
||||||
|
|||||||
@@ -27,11 +27,22 @@ class LoginRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'email' => ['required', 'string', 'email'],
|
'username' => ['required', 'string'],
|
||||||
'password' => ['required', 'string'],
|
'password' => ['required', 'string'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得驗證規則的自訂錯誤訊息
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'username.required' => '請輸入帳號',
|
||||||
|
'password.required' => '請輸入密碼',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempt to authenticate the request's credentials.
|
* Attempt to authenticate the request's credentials.
|
||||||
*
|
*
|
||||||
@@ -41,11 +52,11 @@ class LoginRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
$this->ensureIsNotRateLimited();
|
$this->ensureIsNotRateLimited();
|
||||||
|
|
||||||
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
if (! Auth::attempt($this->only('username', 'password'), $this->boolean('remember'))) {
|
||||||
RateLimiter::hit($this->throttleKey());
|
RateLimiter::hit($this->throttleKey());
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'email' => trans('auth.failed'),
|
'username' => trans('auth.failed'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +79,7 @@ class LoginRequest extends FormRequest
|
|||||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'email' => trans('auth.throttle', [
|
'username' => trans('auth.throttle', [
|
||||||
'seconds' => $seconds,
|
'seconds' => $seconds,
|
||||||
'minutes' => ceil($seconds / 60),
|
'minutes' => ceil($seconds / 60),
|
||||||
]),
|
]),
|
||||||
@@ -80,6 +91,6 @@ class LoginRequest extends FormRequest
|
|||||||
*/
|
*/
|
||||||
public function throttleKey(): string
|
public function throttleKey(): string
|
||||||
{
|
{
|
||||||
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
|
return Str::transliterate(Str::lower($this->string('username')).'|'.$this->ip());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\System\User;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ class ProfileUpdateRequest extends FormRequest
|
|||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique(User::class)->ignore($this->user()->id)],
|
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique(User::class)->ignore($this->user()->id)],
|
||||||
'phone' => ['nullable', 'string', 'max:20'],
|
'phone' => ['nullable', 'string', 'max:20'],
|
||||||
'avatar' => ['nullable', 'string', 'max:255'],
|
'avatar' => ['nullable', 'image', 'mimes:jpeg,png,jpg,gif', 'max:2048'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
app/Jobs/Machine/ProcessMachineLog.php
Normal file
46
app/Jobs/Machine/ProcessMachineLog.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs\Machine;
|
||||||
|
|
||||||
|
use App\Services\Machine\MachineService;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class ProcessMachineLog implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
protected $machineId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected $logData;
|
||||||
|
|
||||||
|
public function __construct(int $machineId, array $logData)
|
||||||
|
{
|
||||||
|
$this->machineId = $machineId;
|
||||||
|
$this->logData = $logData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMachineId(): int
|
||||||
|
{
|
||||||
|
return $this->machineId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(MachineService $service): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$service->recordLog($this->machineId, $this->logData);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error("Failed to process machine log for machine {$this->machineId}: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/Listeners/LogSuccessfulLogin.php
Normal file
70
app/Listeners/LogSuccessfulLogin.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Models\UserLoginLog;
|
||||||
|
use Illuminate\Auth\Events\Login;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class LogSuccessfulLogin
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The request instance.
|
||||||
|
*
|
||||||
|
* @var \Illuminate\Http\Request
|
||||||
|
*/
|
||||||
|
protected $request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the event listener.
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Http\Request $request
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function __construct(Request $request)
|
||||||
|
{
|
||||||
|
$this->request = $request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the event.
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Auth\Events\Login $event
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function handle(Login $event)
|
||||||
|
{
|
||||||
|
$ip = $this->request->ip();
|
||||||
|
$userAgent = $this->request->userAgent();
|
||||||
|
|
||||||
|
// 防重覆機制 (Debouncing): 10 秒內同使用者、同 IP 的記錄視為重複
|
||||||
|
$recentLog = UserLoginLog::where('user_id', $event->user->id)
|
||||||
|
->where('ip_address', $ip)
|
||||||
|
->where('login_at', '>=', now()->subSeconds(10))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($recentLog) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$agent = new \Jenssegers\Agent\Agent();
|
||||||
|
$agent->setUserAgent($userAgent);
|
||||||
|
|
||||||
|
$deviceType = 'desktop';
|
||||||
|
if ($agent->isTablet()) {
|
||||||
|
$deviceType = 'tablet';
|
||||||
|
} elseif ($agent->isMobile()) {
|
||||||
|
$deviceType = 'mobile';
|
||||||
|
}
|
||||||
|
|
||||||
|
UserLoginLog::create([
|
||||||
|
'user_id' => $event->user->id,
|
||||||
|
'ip_address' => $ip,
|
||||||
|
'user_agent' => $userAgent,
|
||||||
|
'device_type' => $deviceType,
|
||||||
|
'browser' => $agent->browser(),
|
||||||
|
'platform' => $agent->platform(),
|
||||||
|
'login_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models\Machine;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
use App\Traits\TenantScoped;
|
||||||
|
|
||||||
class Machine extends Model
|
class Machine extends Model
|
||||||
{
|
{
|
||||||
|
use HasFactory, TenantScoped;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
'company_id',
|
||||||
'name',
|
'name',
|
||||||
'location',
|
'location',
|
||||||
'status',
|
'status',
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models\Machine;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class MachineLog extends Model
|
class MachineLog extends Model
|
||||||
{
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
const UPDATED_AT = null;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'machine_id',
|
'machine_id',
|
||||||
'level',
|
'level',
|
||||||
60
app/Models/Member/DepositBonusRule.php
Normal file
60
app/Models/Member/DepositBonusRule.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Member;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class DepositBonusRule extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'min_amount',
|
||||||
|
'bonus_type',
|
||||||
|
'bonus_value',
|
||||||
|
'is_active',
|
||||||
|
'start_at',
|
||||||
|
'end_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'min_amount' => 'decimal:2',
|
||||||
|
'bonus_value' => 'decimal:2',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'start_at' => 'datetime',
|
||||||
|
'end_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得目前有效的規則
|
||||||
|
*/
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true)
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereNull('start_at')->orWhere('start_at', '<=', now());
|
||||||
|
})
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereNull('end_at')->orWhere('end_at', '>=', now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 計算回饋金額
|
||||||
|
*/
|
||||||
|
public function calculateBonus(float $depositAmount): float
|
||||||
|
{
|
||||||
|
if ($depositAmount < $this->min_amount) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->bonus_type === 'fixed') {
|
||||||
|
return $this->bonus_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// percentage
|
||||||
|
return $depositAmount * ($this->bonus_value / 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/Models/Member/GiftDefinition.php
Normal file
53
app/Models/Member/GiftDefinition.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Member;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class GiftDefinition extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'type',
|
||||||
|
'value',
|
||||||
|
'tier_id',
|
||||||
|
'trigger',
|
||||||
|
'validity_days',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'value' => 'decimal:2',
|
||||||
|
'validity_days' => 'integer',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 適用等級
|
||||||
|
*/
|
||||||
|
public function tier(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(MembershipTier::class, 'tier_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 發放紀錄
|
||||||
|
*/
|
||||||
|
public function memberGifts(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(MemberGift::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有效禮品
|
||||||
|
*/
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
147
app/Models/Member/Member.php
Normal file
147
app/Models/Member/Member.php
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Member;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class Member extends Authenticatable
|
||||||
|
{
|
||||||
|
use HasApiTokens, HasFactory, Notifiable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 資料表名稱
|
||||||
|
*/
|
||||||
|
protected $table = 'members';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可批量賦值的屬性
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid',
|
||||||
|
'name',
|
||||||
|
'email',
|
||||||
|
'phone',
|
||||||
|
'password',
|
||||||
|
'birthday',
|
||||||
|
'gender',
|
||||||
|
'avatar',
|
||||||
|
'is_active',
|
||||||
|
'email_verified_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 隱藏的屬性
|
||||||
|
*/
|
||||||
|
protected $hidden = [
|
||||||
|
'password',
|
||||||
|
'remember_token',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 屬性轉換
|
||||||
|
*/
|
||||||
|
protected $casts = [
|
||||||
|
'email_verified_at' => 'datetime',
|
||||||
|
'birthday' => 'date',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'password' => 'hashed',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立時自動產生 UUID
|
||||||
|
*/
|
||||||
|
protected static function boot()
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::creating(function ($model) {
|
||||||
|
if (empty($model->uuid)) {
|
||||||
|
$model->uuid = (string) Str::uuid();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 關聯:社群帳號
|
||||||
|
*/
|
||||||
|
public function socialAccounts()
|
||||||
|
{
|
||||||
|
return $this->hasMany(SocialAccount::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 關聯:錢包
|
||||||
|
*/
|
||||||
|
public function wallet()
|
||||||
|
{
|
||||||
|
return $this->hasOne(MemberWallet::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 關聯:點數帳戶
|
||||||
|
*/
|
||||||
|
public function points()
|
||||||
|
{
|
||||||
|
return $this->hasOne(MemberPoint::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 關聯:會員資格紀錄
|
||||||
|
*/
|
||||||
|
public function memberships()
|
||||||
|
{
|
||||||
|
return $this->hasMany(MemberMembership::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 關聯:禮品紀錄
|
||||||
|
*/
|
||||||
|
public function gifts()
|
||||||
|
{
|
||||||
|
return $this->hasMany(MemberGift::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得目前有效的會員資格
|
||||||
|
*/
|
||||||
|
public function activeMembership()
|
||||||
|
{
|
||||||
|
return $this->hasOne(MemberMembership::class)->active()->latest('starts_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 檢查是否已綁定指定社群
|
||||||
|
*/
|
||||||
|
public function hasSocialAccount(string $provider): bool
|
||||||
|
{
|
||||||
|
return $this->socialAccounts()->where('provider', $provider)->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得或建立錢包
|
||||||
|
*/
|
||||||
|
public function getOrCreateWallet(): MemberWallet
|
||||||
|
{
|
||||||
|
return $this->wallet ?? $this->wallet()->create([
|
||||||
|
'balance' => 0,
|
||||||
|
'bonus_balance' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得或建立點數帳戶
|
||||||
|
*/
|
||||||
|
public function getOrCreatePoints(): MemberPoint
|
||||||
|
{
|
||||||
|
return $this->points ?? $this->points()->create([
|
||||||
|
'available_points' => 0,
|
||||||
|
'pending_points' => 0,
|
||||||
|
'expired_points' => 0,
|
||||||
|
'used_points' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Models/Member/MemberGift.php
Normal file
56
app/Models/Member/MemberGift.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Member;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class MemberGift extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'member_id',
|
||||||
|
'gift_definition_id',
|
||||||
|
'status',
|
||||||
|
'claimed_at',
|
||||||
|
'expires_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'claimed_at' => 'datetime',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所屬會員
|
||||||
|
*/
|
||||||
|
public function member(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Member::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 禮品定義
|
||||||
|
*/
|
||||||
|
public function giftDefinition(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(GiftDefinition::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待領取的禮品
|
||||||
|
*/
|
||||||
|
public function scopePending($query)
|
||||||
|
{
|
||||||
|
return $query->where('status', 'pending')
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereNull('expires_at')
|
||||||
|
->orWhere('expires_at', '>', now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/Models/Member/MemberMembership.php
Normal file
65
app/Models/Member/MemberMembership.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Member;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class MemberMembership extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'member_id',
|
||||||
|
'tier_id',
|
||||||
|
'starts_at',
|
||||||
|
'expires_at',
|
||||||
|
'payment_id',
|
||||||
|
'auto_renew',
|
||||||
|
'status',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'starts_at' => 'datetime',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'auto_renew' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所屬會員
|
||||||
|
*/
|
||||||
|
public function member(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Member::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 會員等級
|
||||||
|
*/
|
||||||
|
public function tier(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(MembershipTier::class, 'tier_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否有效
|
||||||
|
*/
|
||||||
|
public function getIsActiveAttribute(): bool
|
||||||
|
{
|
||||||
|
return $this->status === 'active'
|
||||||
|
&& (!$this->expires_at || $this->expires_at->isFuture());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有效會員資格
|
||||||
|
*/
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->where('status', 'active')
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereNull('expires_at')
|
||||||
|
->orWhere('expires_at', '>', now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Models/Member/MemberPoint.php
Normal file
44
app/Models/Member/MemberPoint.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Member;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class MemberPoint extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'member_id',
|
||||||
|
'available_points',
|
||||||
|
'pending_points',
|
||||||
|
'expired_points',
|
||||||
|
'used_points',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'available_points' => 'integer',
|
||||||
|
'pending_points' => 'integer',
|
||||||
|
'expired_points' => 'integer',
|
||||||
|
'used_points' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所屬會員
|
||||||
|
*/
|
||||||
|
public function member(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Member::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 點數異動紀錄
|
||||||
|
*/
|
||||||
|
public function transactions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PointTransaction::class, 'member_id', 'member_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Models/Member/MemberWallet.php
Normal file
48
app/Models/Member/MemberWallet.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Member;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class MemberWallet extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'member_id',
|
||||||
|
'balance',
|
||||||
|
'bonus_balance',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'balance' => 'decimal:2',
|
||||||
|
'bonus_balance' => 'decimal:2',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所屬會員
|
||||||
|
*/
|
||||||
|
public function member(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Member::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交易紀錄
|
||||||
|
*/
|
||||||
|
public function transactions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(WalletTransaction::class, 'member_id', 'member_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 總餘額 (儲值 + 回饋)
|
||||||
|
*/
|
||||||
|
public function getTotalBalanceAttribute(): float
|
||||||
|
{
|
||||||
|
return $this->balance + $this->bonus_balance;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/Models/Member/MembershipTier.php
Normal file
62
app/Models/Member/MembershipTier.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Member;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class MembershipTier extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'annual_fee',
|
||||||
|
'discount_rate',
|
||||||
|
'point_multiplier',
|
||||||
|
'description',
|
||||||
|
'is_default',
|
||||||
|
'sort_order',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'annual_fee' => 'decimal:2',
|
||||||
|
'discount_rate' => 'decimal:2',
|
||||||
|
'point_multiplier' => 'decimal:2',
|
||||||
|
'is_default' => 'boolean',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 此等級的會員紀錄
|
||||||
|
*/
|
||||||
|
public function memberships(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(MemberMembership::class, 'tier_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 此等級的禮品定義
|
||||||
|
*/
|
||||||
|
public function giftDefinitions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(GiftDefinition::class, 'tier_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得預設等級
|
||||||
|
*/
|
||||||
|
public static function getDefault(): ?self
|
||||||
|
{
|
||||||
|
return static::where('is_default', true)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否為免費等級
|
||||||
|
*/
|
||||||
|
public function getIsFreeAttribute(): bool
|
||||||
|
{
|
||||||
|
return $this->annual_fee <= 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Models/Member/PointRule.php
Normal file
47
app/Models/Member/PointRule.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Member;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class PointRule extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'trigger',
|
||||||
|
'points_per_unit',
|
||||||
|
'unit_amount',
|
||||||
|
'validity_days',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'points_per_unit' => 'integer',
|
||||||
|
'unit_amount' => 'decimal:2',
|
||||||
|
'validity_days' => 'integer',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得有效規則
|
||||||
|
*/
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根據金額計算可獲得點數
|
||||||
|
*/
|
||||||
|
public function calculatePoints(float $amount): int
|
||||||
|
{
|
||||||
|
if ($this->unit_amount <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) floor($amount / $this->unit_amount) * $this->points_per_unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Models/Member/PointTransaction.php
Normal file
48
app/Models/Member/PointTransaction.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Member;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PointTransaction extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'member_id',
|
||||||
|
'type',
|
||||||
|
'points',
|
||||||
|
'balance_after',
|
||||||
|
'description',
|
||||||
|
'expires_at',
|
||||||
|
'reference_type',
|
||||||
|
'reference_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'points' => 'integer',
|
||||||
|
'balance_after' => 'integer',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所屬會員
|
||||||
|
*/
|
||||||
|
public function member(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Member::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否已過期
|
||||||
|
*/
|
||||||
|
public function getIsExpiredAttribute(): bool
|
||||||
|
{
|
||||||
|
return $this->expires_at && $this->expires_at->isPast();
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/Models/Member/SocialAccount.php
Normal file
53
app/Models/Member/SocialAccount.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Member;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class SocialAccount extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 資料表名稱
|
||||||
|
*/
|
||||||
|
protected $table = 'social_accounts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可批量賦值的屬性
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'member_id',
|
||||||
|
'provider',
|
||||||
|
'provider_id',
|
||||||
|
'access_token',
|
||||||
|
'refresh_token',
|
||||||
|
'profile_data',
|
||||||
|
'token_expires_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 屬性轉換
|
||||||
|
*/
|
||||||
|
protected $casts = [
|
||||||
|
'profile_data' => 'array',
|
||||||
|
'token_expires_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 隱藏的屬性
|
||||||
|
*/
|
||||||
|
protected $hidden = [
|
||||||
|
'access_token',
|
||||||
|
'refresh_token',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 關聯:會員
|
||||||
|
*/
|
||||||
|
public function member()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Member::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Models/Member/WalletTransaction.php
Normal file
38
app/Models/Member/WalletTransaction.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Member;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class WalletTransaction extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'member_id',
|
||||||
|
'type',
|
||||||
|
'amount',
|
||||||
|
'balance_after',
|
||||||
|
'description',
|
||||||
|
'reference_type',
|
||||||
|
'reference_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'amount' => 'decimal:2',
|
||||||
|
'balance_after' => 'decimal:2',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所屬會員
|
||||||
|
*/
|
||||||
|
public function member(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Member::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models\System;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
48
app/Models/System/Company.php
Normal file
48
app/Models/System/Company.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\System;
|
||||||
|
|
||||||
|
use App\Models\Machine\Machine;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
|
||||||
|
class Company extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'code',
|
||||||
|
'tax_id',
|
||||||
|
'contact_name',
|
||||||
|
'contact_phone',
|
||||||
|
'contact_email',
|
||||||
|
'status',
|
||||||
|
'valid_until',
|
||||||
|
'note',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'valid_until' => 'date',
|
||||||
|
'status' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the users for the company.
|
||||||
|
*/
|
||||||
|
public function users(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the machines for the company.
|
||||||
|
*/
|
||||||
|
public function machines(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Machine::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
app/Models/System/User.php
Normal file
98
app/Models/System/User.php
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\System;
|
||||||
|
|
||||||
|
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
|
|
||||||
|
class User extends Authenticatable
|
||||||
|
{
|
||||||
|
use HasApiTokens, HasFactory, Notifiable, HasRoles;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'company_id',
|
||||||
|
'username',
|
||||||
|
'name',
|
||||||
|
'email',
|
||||||
|
'password',
|
||||||
|
'phone',
|
||||||
|
'avatar',
|
||||||
|
'role',
|
||||||
|
'status',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be hidden for serialization.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $hidden = [
|
||||||
|
'password',
|
||||||
|
'remember_token',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
protected $casts = [
|
||||||
|
'email_verified_at' => 'datetime',
|
||||||
|
'password' => 'hashed',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the login logs for the user.
|
||||||
|
*/
|
||||||
|
public function loginLogs()
|
||||||
|
{
|
||||||
|
return $this->hasMany(\App\Models\UserLoginLog::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the company that owns the user.
|
||||||
|
*/
|
||||||
|
public function company()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Company::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the user is a system administrator.
|
||||||
|
*/
|
||||||
|
public function isSystemAdmin(): bool
|
||||||
|
{
|
||||||
|
return is_null($this->company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the user belongs to a tenant.
|
||||||
|
*/
|
||||||
|
public function isTenant(): bool
|
||||||
|
{
|
||||||
|
return !is_null($this->company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the URL for the user's avatar.
|
||||||
|
*/
|
||||||
|
public function getAvatarUrlAttribute(): string
|
||||||
|
{
|
||||||
|
if ($this->avatar) {
|
||||||
|
return \Illuminate\Support\Facades\Storage::disk('public')->url($this->avatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a default UI Avatar if no avatar is set
|
||||||
|
return "https://ui-avatars.com/api/?name=" . urlencode($this->name) . "&color=7F9CF5&background=EBF4FF";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
|
||||||
use Illuminate\Notifications\Notifiable;
|
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
|
||||||
|
|
||||||
class User extends Authenticatable
|
|
||||||
{
|
|
||||||
use HasApiTokens, HasFactory, Notifiable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The attributes that are mass assignable.
|
|
||||||
*
|
|
||||||
* @var array<int, string>
|
|
||||||
*/
|
|
||||||
protected $fillable = [
|
|
||||||
'name',
|
|
||||||
'email',
|
|
||||||
'password',
|
|
||||||
'phone',
|
|
||||||
'avatar',
|
|
||||||
'role',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The attributes that should be hidden for serialization.
|
|
||||||
*
|
|
||||||
* @var array<int, string>
|
|
||||||
*/
|
|
||||||
protected $hidden = [
|
|
||||||
'password',
|
|
||||||
'remember_token',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The attributes that should be cast.
|
|
||||||
*
|
|
||||||
* @var array<string, string>
|
|
||||||
*/
|
|
||||||
protected $casts = [
|
|
||||||
'email_verified_at' => 'datetime',
|
|
||||||
'password' => 'hashed',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
30
app/Models/UserLoginLog.php
Normal file
30
app/Models/UserLoginLog.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory; // Added this line
|
||||||
|
|
||||||
|
class UserLoginLog extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'ip_address',
|
||||||
|
'user_agent',
|
||||||
|
'device_type',
|
||||||
|
'browser',
|
||||||
|
'platform',
|
||||||
|
'login_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'login_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Listeners\LogSuccessfulLogin;
|
||||||
|
use Illuminate\Auth\Events\Login;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
@@ -19,6 +22,9 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
if (!$this->app->isLocal()) {
|
||||||
|
\Illuminate\Support\Facades\URL::forceScheme('https');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Listeners\LogSuccessfulLogin;
|
||||||
|
use Illuminate\Auth\Events\Login;
|
||||||
use Illuminate\Auth\Events\Registered;
|
use Illuminate\Auth\Events\Registered;
|
||||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||||
@@ -18,6 +20,9 @@ class EventServiceProvider extends ServiceProvider
|
|||||||
Registered::class => [
|
Registered::class => [
|
||||||
SendEmailVerificationNotification::class,
|
SendEmailVerificationNotification::class,
|
||||||
],
|
],
|
||||||
|
Login::class => [
|
||||||
|
LogSuccessfulLogin::class,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
45
app/Services/Machine/MachineService.php
Normal file
45
app/Services/Machine/MachineService.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Machine;
|
||||||
|
|
||||||
|
use App\Models\Machine\Machine;
|
||||||
|
use App\Models\Machine\MachineLog;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class MachineService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 處理機台日誌寫入與狀態更新
|
||||||
|
*/
|
||||||
|
public function recordLog(int $machineId, array $data): MachineLog
|
||||||
|
{
|
||||||
|
$machine = Machine::findOrFail($machineId);
|
||||||
|
|
||||||
|
// 建立日誌紀錄
|
||||||
|
$log = $machine->logs()->create([
|
||||||
|
'level' => $data['level'] ?? 'info',
|
||||||
|
'message' => $data['message'],
|
||||||
|
'context' => $data['context'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 同步更新機台最後活耀時間與狀態
|
||||||
|
$machine->update([
|
||||||
|
'last_heartbeat_at' => now(),
|
||||||
|
'status' => $this->resolveStatus($data),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $log;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根據日誌內容判斷機台是否應標記成錯誤
|
||||||
|
*/
|
||||||
|
protected function resolveStatus(array $data): string
|
||||||
|
{
|
||||||
|
if (isset($data['level']) && $data['level'] === 'error') {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'online';
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Traits/ApiResponse.php
Normal file
49
app/Traits/ApiResponse.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
trait ApiResponse
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 回傳成功的回應
|
||||||
|
*
|
||||||
|
* @param mixed $data
|
||||||
|
* @param string $message
|
||||||
|
* @param int $code
|
||||||
|
* @return JsonResponse
|
||||||
|
*/
|
||||||
|
public function successResponse($data = [], string $message = 'OK', int $code = 200): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'code' => $code,
|
||||||
|
'message' => $message,
|
||||||
|
'data' => empty($data) ? new \stdClass() : $data, // 確保前端收到的是 Object 而非 Empty Array
|
||||||
|
], $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回傳錯誤的回應
|
||||||
|
*
|
||||||
|
* @param string $message
|
||||||
|
* @param int $code
|
||||||
|
* @param mixed $errors
|
||||||
|
* @return JsonResponse
|
||||||
|
*/
|
||||||
|
public function errorResponse(string $message, int $code = 400, $errors = null): JsonResponse
|
||||||
|
{
|
||||||
|
$response = [
|
||||||
|
'success' => false,
|
||||||
|
'code' => $code,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!is_null($errors)) {
|
||||||
|
$response['errors'] = $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($response, $code);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Traits/TenantScoped.php
Normal file
41
app/Traits/TenantScoped.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
trait TenantScoped
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Boot the trait.
|
||||||
|
*/
|
||||||
|
public static function bootTenantScoped(): void
|
||||||
|
{
|
||||||
|
static::addGlobalScope('tenant', function (Builder $query) {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
// 如果使用者已登入且有綁定公司,則自動注入過濾條件
|
||||||
|
if ($user && $user->company_id) {
|
||||||
|
$query->where((new static)->getTable() . '.company_id', $user->company_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 建立資料時,自動填入當前使用者的 company_id
|
||||||
|
static::creating(function ($model) {
|
||||||
|
if (!$model->company_id) {
|
||||||
|
$user = auth()->user();
|
||||||
|
if ($user && $user->company_id) {
|
||||||
|
$model->company_id = $user->company_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the company relationship.
|
||||||
|
*/
|
||||||
|
public function company()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(\App\Models\System\Company::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
artisan
49
artisan
@@ -1,53 +1,18 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Symfony\Component\Console\Input\ArgvInput;
|
||||||
|
|
||||||
define('LARAVEL_START', microtime(true));
|
define('LARAVEL_START', microtime(true));
|
||||||
|
|
||||||
/*
|
// Register the Composer autoloader...
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Register The Auto Loader
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Composer provides a convenient, automatically generated class loader
|
|
||||||
| for our application. We just need to utilize it! We'll require it
|
|
||||||
| into the script here so that we do not have to worry about the
|
|
||||||
| loading of any of our classes manually. It's great to relax.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
require __DIR__.'/vendor/autoload.php';
|
require __DIR__.'/vendor/autoload.php';
|
||||||
|
|
||||||
|
// Bootstrap Laravel and handle the command...
|
||||||
|
/** @var Application $app */
|
||||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||||
|
|
||||||
/*
|
$status = $app->handleCommand(new ArgvInput);
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Run The Artisan Application
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| When we run the console application, the current CLI command will be
|
|
||||||
| executed in this console and the response sent back to a terminal
|
|
||||||
| or another output device for the developers. Here goes nothing!
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
|
|
||||||
|
|
||||||
$status = $kernel->handle(
|
|
||||||
$input = new Symfony\Component\Console\Input\ArgvInput,
|
|
||||||
new Symfony\Component\Console\Output\ConsoleOutput
|
|
||||||
);
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Shutdown The Application
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Once Artisan has finished running, we will fire off the shutdown events
|
|
||||||
| so that any final work may be done by the application before we shut
|
|
||||||
| down the process. This is the last thing to happen to the request.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
$kernel->terminate($input, $status);
|
|
||||||
|
|
||||||
exit($status);
|
exit($status);
|
||||||
|
|||||||
14
compose.yaml
14
compose.yaml
@@ -6,12 +6,12 @@ services:
|
|||||||
args:
|
args:
|
||||||
WWWGROUP: '${WWWGROUP}'
|
WWWGROUP: '${WWWGROUP}'
|
||||||
image: 'sail-8.5/app'
|
image: 'sail-8.5/app'
|
||||||
container_name: start-cloud-laravel
|
container_name: star-cloud-laravel
|
||||||
hostname: start-cloud-laravel
|
hostname: star-cloud-laravel
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- 'host.docker.internal:host-gateway'
|
- 'host.docker.internal:host-gateway'
|
||||||
ports:
|
ports:
|
||||||
- '${APP_PORT:-80}:80'
|
- '${APP_PORT:-80}:8080'
|
||||||
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
|
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
|
||||||
environment:
|
environment:
|
||||||
WWWUSER: '${WWWUSER}'
|
WWWUSER: '${WWWUSER}'
|
||||||
@@ -29,8 +29,8 @@ services:
|
|||||||
|
|
||||||
mysql:
|
mysql:
|
||||||
image: 'mysql/mysql-server:8.0'
|
image: 'mysql/mysql-server:8.0'
|
||||||
container_name: start-cloud-mysql
|
container_name: star-cloud-mysql
|
||||||
hostname: start-cloud-mysql
|
hostname: star-cloud-mysql
|
||||||
ports:
|
ports:
|
||||||
- '${FORWARD_DB_PORT:-3306}:3306'
|
- '${FORWARD_DB_PORT:-3306}:3306'
|
||||||
environment:
|
environment:
|
||||||
@@ -56,8 +56,8 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
redis:
|
redis:
|
||||||
image: 'redis:alpine'
|
image: 'redis:alpine'
|
||||||
container_name: start-cloud-redis
|
container_name: star-cloud-redis
|
||||||
hostname: start-cloud-redis
|
hostname: star-cloud-redis
|
||||||
ports:
|
ports:
|
||||||
- '${FORWARD_REDIS_PORT:-6379}:6379'
|
- '${FORWARD_REDIS_PORT:-6379}:6379'
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -2,24 +2,29 @@
|
|||||||
"name": "laravel/laravel",
|
"name": "laravel/laravel",
|
||||||
"type": "project",
|
"type": "project",
|
||||||
"description": "The skeleton application for the Laravel framework.",
|
"description": "The skeleton application for the Laravel framework.",
|
||||||
"keywords": ["laravel", "framework"],
|
"keywords": [
|
||||||
|
"laravel",
|
||||||
|
"framework"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.1",
|
"php": "^8.2",
|
||||||
"guzzlehttp/guzzle": "^7.2",
|
"guzzlehttp/guzzle": "^7.8",
|
||||||
"laravel/framework": "^10.10",
|
"jenssegers/agent": "^2.6",
|
||||||
"laravel/sanctum": "^3.3",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/tinker": "^2.8"
|
"laravel/sanctum": "^4.3",
|
||||||
|
"laravel/tinker": "^2.10.1",
|
||||||
|
"spatie/laravel-permission": "^7.2"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.9.1",
|
"fakerphp/faker": "^1.23",
|
||||||
"laravel/breeze": "^1.29",
|
"laravel/breeze": "^2.0",
|
||||||
"laravel/pint": "^1.0",
|
"laravel/pail": "^1.2.2",
|
||||||
"laravel/sail": "^1.18",
|
"laravel/pint": "^1.24",
|
||||||
"mockery/mockery": "^1.4.4",
|
"laravel/sail": "^1.41",
|
||||||
"nunomaduro/collision": "^7.0",
|
"mockery/mockery": "^1.6",
|
||||||
"phpunit/phpunit": "^10.1",
|
"nunomaduro/collision": "^8.6",
|
||||||
"spatie/laravel-ignition": "^2.0"
|
"phpunit/phpunit": "^11.5.3"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
|||||||
2980
composer.lock
generated
2980
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -70,7 +70,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'timezone' => 'UTC',
|
'timezone' => env('APP_TIMEZONE', 'Asia/Taipei'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
@@ -83,7 +83,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'locale' => 'en',
|
'locale' => env('APP_LOCALE', 'zh_TW'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ return [
|
|||||||
'providers' => [
|
'providers' => [
|
||||||
'users' => [
|
'users' => [
|
||||||
'driver' => 'eloquent',
|
'driver' => 'eloquent',
|
||||||
'model' => App\Models\User::class,
|
'model' => App\Models\System\User::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
// 'users' => [
|
// 'users' => [
|
||||||
|
|||||||
202
config/permission.php
Normal file
202
config/permission.php
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'models' => [
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasPermissions" trait from this package, we need to know which
|
||||||
|
* Eloquent model should be used to retrieve your permissions. Of course, it
|
||||||
|
* is often just the "Permission" model but you may use whatever you like.
|
||||||
|
*
|
||||||
|
* The model you want to use as a Permission model needs to implement the
|
||||||
|
* `Spatie\Permission\Contracts\Permission` contract.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'permission' => Spatie\Permission\Models\Permission::class,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasRoles" trait from this package, we need to know which
|
||||||
|
* Eloquent model should be used to retrieve your roles. Of course, it
|
||||||
|
* is often just the "Role" model but you may use whatever you like.
|
||||||
|
*
|
||||||
|
* The model you want to use as a Role model needs to implement the
|
||||||
|
* `Spatie\Permission\Contracts\Role` contract.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'role' => Spatie\Permission\Models\Role::class,
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
'table_names' => [
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasRoles" trait from this package, we need to know which
|
||||||
|
* table should be used to retrieve your roles. We have chosen a basic
|
||||||
|
* default value but you may easily change it to any table you like.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'roles' => 'roles',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasPermissions" trait from this package, we need to know which
|
||||||
|
* table should be used to retrieve your permissions. We have chosen a basic
|
||||||
|
* default value but you may easily change it to any table you like.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'permissions' => 'permissions',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasPermissions" trait from this package, we need to know which
|
||||||
|
* table should be used to retrieve your models permissions. We have chosen a
|
||||||
|
* basic default value but you may easily change it to any table you like.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'model_has_permissions' => 'model_has_permissions',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasRoles" trait from this package, we need to know which
|
||||||
|
* table should be used to retrieve your models roles. We have chosen a
|
||||||
|
* basic default value but you may easily change it to any table you like.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'model_has_roles' => 'model_has_roles',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasRoles" trait from this package, we need to know which
|
||||||
|
* table should be used to retrieve your roles permissions. We have chosen a
|
||||||
|
* basic default value but you may easily change it to any table you like.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'role_has_permissions' => 'role_has_permissions',
|
||||||
|
],
|
||||||
|
|
||||||
|
'column_names' => [
|
||||||
|
/*
|
||||||
|
* Change this if you want to name the related pivots other than defaults
|
||||||
|
*/
|
||||||
|
'role_pivot_key' => null, // default 'role_id',
|
||||||
|
'permission_pivot_key' => null, // default 'permission_id',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Change this if you want to name the related model primary key other than
|
||||||
|
* `model_id`.
|
||||||
|
*
|
||||||
|
* For example, this would be nice if your primary keys are all UUIDs. In
|
||||||
|
* that case, name this `model_uuid`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'model_morph_key' => 'model_id',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Change this if you want to use the teams feature and your related model's
|
||||||
|
* foreign key is other than `team_id`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'team_foreign_key' => 'team_id',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When set to true, the method for checking permissions will be registered on the gate.
|
||||||
|
* Set this to false if you want to implement custom logic for checking permissions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'register_permission_check_method' => true,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
|
||||||
|
* this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated
|
||||||
|
* NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it.
|
||||||
|
*/
|
||||||
|
'register_octane_reset_listener' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Events will fire when a role or permission is assigned/unassigned:
|
||||||
|
* \Spatie\Permission\Events\RoleAttachedEvent
|
||||||
|
* \Spatie\Permission\Events\RoleDetachedEvent
|
||||||
|
* \Spatie\Permission\Events\PermissionAttachedEvent
|
||||||
|
* \Spatie\Permission\Events\PermissionDetachedEvent
|
||||||
|
*
|
||||||
|
* To enable, set to true, and then create listeners to watch these events.
|
||||||
|
*/
|
||||||
|
'events_enabled' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Teams Feature.
|
||||||
|
* When set to true the package implements teams using the 'team_foreign_key'.
|
||||||
|
* If you want the migrations to register the 'team_foreign_key', you must
|
||||||
|
* set this to true before doing the migration.
|
||||||
|
* If you already did the migration then you must make a new migration to also
|
||||||
|
* add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions'
|
||||||
|
* (view the latest version of this package's migration file)
|
||||||
|
*/
|
||||||
|
|
||||||
|
'teams' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The class to use to resolve the permissions team id
|
||||||
|
*/
|
||||||
|
'team_resolver' => \Spatie\Permission\DefaultTeamResolver::class,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Passport Client Credentials Grant
|
||||||
|
* When set to true the package will use Passports Client to check permissions
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use_passport_client_credentials' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When set to true, the required permission names are added to exception messages.
|
||||||
|
* This could be considered an information leak in some contexts, so the default
|
||||||
|
* setting is false here for optimum safety.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'display_permission_in_exception' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When set to true, the required role names are added to exception messages.
|
||||||
|
* This could be considered an information leak in some contexts, so the default
|
||||||
|
* setting is false here for optimum safety.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'display_role_in_exception' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* By default wildcard permission lookups are disabled.
|
||||||
|
* See documentation to understand supported syntax.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'enable_wildcard_permission' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The class to use for interpreting wildcard permissions.
|
||||||
|
* If you need to modify delimiters, override the class and specify its name here.
|
||||||
|
*/
|
||||||
|
// 'wildcard_permission' => Spatie\Permission\WildcardPermission::class,
|
||||||
|
|
||||||
|
/* Cache-specific settings */
|
||||||
|
|
||||||
|
'cache' => [
|
||||||
|
|
||||||
|
/*
|
||||||
|
* By default all permissions are cached for 24 hours to speed up performance.
|
||||||
|
* When permissions or roles are updated the cache is flushed automatically.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'expiration_time' => \DateInterval::createFromDateString('24 hours'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The cache key used to store all permissions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'key' => 'spatie.permission.cache',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* You may optionally indicate a specific cache driver to use for permission and
|
||||||
|
* role caching using any of the `store` drivers listed in the cache.php config
|
||||||
|
* file. Using 'default' here means to use the `default` set in cache.php.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'store' => 'default',
|
||||||
|
],
|
||||||
|
];
|
||||||
23
database/factories/Machine/MachineFactory.php
Normal file
23
database/factories/Machine/MachineFactory.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories\Machine;
|
||||||
|
|
||||||
|
use App\Models\Machine\Machine;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class MachineFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Machine::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 'Machine-' . fake()->unique()->numberBetween(101, 999),
|
||||||
|
'location' => fake()->address(),
|
||||||
|
'status' => fake()->randomElement(['online', 'offline', 'error']),
|
||||||
|
'temperature' => fake()->randomFloat(2, 2, 10),
|
||||||
|
'firmware_version' => 'v' . fake()->randomElement(['1.0.0', '1.1.2', '2.0.1']),
|
||||||
|
'last_heartbeat_at' => fake()->dateTimeBetween('-1 day', 'now'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
51
database/factories/Machine/MachineLogFactory.php
Normal file
51
database/factories/Machine/MachineLogFactory.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories\Machine;
|
||||||
|
|
||||||
|
use App\Models\Machine\Machine;
|
||||||
|
use App\Models\Machine\MachineLog;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class MachineLogFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = MachineLog::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$messages = [
|
||||||
|
'info' => [
|
||||||
|
'機台啟動完成',
|
||||||
|
'系統心跳上報',
|
||||||
|
'交易成功 (訂單 #'.fake()->numberBetween(1000, 9999).')',
|
||||||
|
'補貨作業完成',
|
||||||
|
'環境溫度穩定 (24C)',
|
||||||
|
],
|
||||||
|
'warning' => [
|
||||||
|
'貨道 A3 庫存偏低',
|
||||||
|
'通訊品質不穩定',
|
||||||
|
'感測器回報數值異常',
|
||||||
|
'機門開啟次數過多',
|
||||||
|
],
|
||||||
|
'error' => [
|
||||||
|
'馬達轉動失效 (貨道 B2)',
|
||||||
|
'硬幣器卡幣',
|
||||||
|
'散熱風扇停止運作',
|
||||||
|
'電源供應模組故障',
|
||||||
|
'網路連線中斷',
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$level = fake()->randomElement(['info', 'warning', 'error']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'machine_id' => Machine::factory(),
|
||||||
|
'level' => $level,
|
||||||
|
'message' => fake()->randomElement($messages[$level]),
|
||||||
|
'context' => [
|
||||||
|
'ip' => fake()->ipv4(),
|
||||||
|
'uptime' => fake()->numberBetween(1000, 100000),
|
||||||
|
],
|
||||||
|
'created_at' => fake()->dateTimeBetween('-1 day', 'now'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
database/factories/Member/MemberFactory.php
Normal file
27
database/factories/Member/MemberFactory.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories\Member;
|
||||||
|
|
||||||
|
use App\Models\Member\Member;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class MemberFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Member::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'name' => fake()->name(),
|
||||||
|
'email' => fake()->unique()->safeEmail(),
|
||||||
|
'phone' => '09' . fake()->numberBetween(10000000, 99999999),
|
||||||
|
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
|
||||||
|
'birthday' => fake()->date(),
|
||||||
|
'gender' => fake()->randomElement(['male', 'female', 'other']),
|
||||||
|
'is_active' => true,
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories\System;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\System\User>
|
||||||
*/
|
*/
|
||||||
class UserFactory extends Factory
|
class UserFactory extends Factory
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('sessions', function (Blueprint $table) {
|
||||||
|
$table->string('id')->primary();
|
||||||
|
$table->foreignId('user_id')->nullable()->index();
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->text('user_agent')->nullable();
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->integer('last_activity')->index();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('sessions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (!Schema::hasColumn('users', 'username')) {
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('username')->unique()->nullable()->after('id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (Schema::hasColumn('users', 'username')) {
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('username');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('members', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->uuid('uuid')->unique();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('email')->nullable()->unique();
|
||||||
|
$table->string('phone')->nullable()->unique();
|
||||||
|
$table->string('password')->nullable();
|
||||||
|
$table->date('birthday')->nullable();
|
||||||
|
$table->enum('gender', ['male', 'female', 'other'])->nullable();
|
||||||
|
$table->string('avatar')->nullable();
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamp('email_verified_at')->nullable();
|
||||||
|
$table->rememberToken();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('members');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('social_accounts', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('member_id')->constrained('members')->onDelete('cascade');
|
||||||
|
$table->enum('provider', ['line', 'google', 'facebook']);
|
||||||
|
$table->string('provider_id');
|
||||||
|
$table->text('access_token')->nullable();
|
||||||
|
$table->text('refresh_token')->nullable();
|
||||||
|
$table->json('profile_data')->nullable();
|
||||||
|
$table->timestamp('token_expires_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
// 同一平台同一用戶只能綁定一次
|
||||||
|
$table->unique(['provider', 'provider_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('social_accounts');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 會員錢包
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('member_wallets', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('member_id')->constrained('members')->onDelete('cascade');
|
||||||
|
$table->decimal('balance', 12, 2)->default(0)->comment('錢包餘額');
|
||||||
|
$table->decimal('bonus_balance', 12, 2)->default(0)->comment('回饋金餘額');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique('member_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('member_wallets');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 錢包交易紀錄
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('wallet_transactions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('member_id')->constrained('members')->onDelete('cascade');
|
||||||
|
$table->enum('type', ['deposit', 'consume', 'refund', 'bonus', 'adjust'])->comment('交易類型');
|
||||||
|
$table->decimal('amount', 12, 2)->comment('異動金額');
|
||||||
|
$table->decimal('balance_after', 12, 2)->comment('異動後餘額');
|
||||||
|
$table->string('description')->nullable()->comment('說明');
|
||||||
|
$table->string('reference_type')->nullable()->comment('關聯類型');
|
||||||
|
$table->unsignedBigInteger('reference_id')->nullable()->comment('關聯ID');
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
|
||||||
|
$table->index(['member_id', 'created_at']);
|
||||||
|
$table->index(['reference_type', 'reference_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('wallet_transactions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 儲值回饋規則
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('deposit_bonus_rules', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name')->comment('規則名稱');
|
||||||
|
$table->decimal('min_amount', 12, 2)->comment('最低儲值金額');
|
||||||
|
$table->enum('bonus_type', ['fixed', 'percentage'])->comment('回饋類型');
|
||||||
|
$table->decimal('bonus_value', 12, 2)->comment('回饋值');
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->datetime('start_at')->nullable()->comment('開始時間');
|
||||||
|
$table->datetime('end_at')->nullable()->comment('結束時間');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['is_active', 'start_at', 'end_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('deposit_bonus_rules');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 會員點數帳戶
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('member_points', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('member_id')->constrained('members')->onDelete('cascade');
|
||||||
|
$table->integer('available_points')->default(0)->comment('可用點數');
|
||||||
|
$table->integer('pending_points')->default(0)->comment('待生效點數');
|
||||||
|
$table->integer('expired_points')->default(0)->comment('已過期點數(統計)');
|
||||||
|
$table->integer('used_points')->default(0)->comment('已使用點數(統計)');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique('member_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('member_points');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 點數異動紀錄
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('point_transactions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('member_id')->constrained('members')->onDelete('cascade');
|
||||||
|
$table->enum('type', ['earn', 'use', 'expire', 'gift', 'adjust'])->comment('異動類型');
|
||||||
|
$table->integer('points')->comment('異動點數');
|
||||||
|
$table->integer('balance_after')->comment('異動後餘額');
|
||||||
|
$table->string('description')->nullable()->comment('說明');
|
||||||
|
$table->datetime('expires_at')->nullable()->comment('此筆點數到期日');
|
||||||
|
$table->string('reference_type')->nullable()->comment('關聯類型');
|
||||||
|
$table->unsignedBigInteger('reference_id')->nullable()->comment('關聯ID');
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
|
||||||
|
$table->index(['member_id', 'created_at']);
|
||||||
|
$table->index('expires_at');
|
||||||
|
$table->index(['reference_type', 'reference_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('point_transactions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 點數規則
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('point_rules', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name')->comment('規則名稱');
|
||||||
|
$table->enum('trigger', ['purchase', 'deposit', 'register', 'birthday', 'referral'])->comment('觸發條件');
|
||||||
|
$table->integer('points_per_unit')->default(1)->comment('每單位獲得點數');
|
||||||
|
$table->decimal('unit_amount', 12, 2)->default(100)->comment('單位金額');
|
||||||
|
$table->integer('validity_days')->default(365)->comment('點數有效天數');
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('is_active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('point_rules');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 會員等級定義
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('membership_tiers', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name')->comment('等級名稱');
|
||||||
|
$table->decimal('annual_fee', 12, 2)->default(0)->comment('年費金額');
|
||||||
|
$table->decimal('discount_rate', 4, 2)->default(1.00)->comment('折扣比例(0.95=95折)');
|
||||||
|
$table->decimal('point_multiplier', 4, 2)->default(1.00)->comment('點數倍率');
|
||||||
|
$table->text('description')->nullable()->comment('說明');
|
||||||
|
$table->boolean('is_default')->default(false)->comment('是否為預設等級');
|
||||||
|
$table->integer('sort_order')->default(0)->comment('排序');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('is_default');
|
||||||
|
$table->index('sort_order');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('membership_tiers');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 會員等級紀錄
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('member_memberships', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('member_id')->constrained('members')->onDelete('cascade');
|
||||||
|
$table->foreignId('tier_id')->constrained('membership_tiers')->onDelete('cascade');
|
||||||
|
$table->datetime('starts_at')->comment('生效日');
|
||||||
|
$table->datetime('expires_at')->nullable()->comment('到期日');
|
||||||
|
$table->unsignedBigInteger('payment_id')->nullable()->comment('付款紀錄ID');
|
||||||
|
$table->boolean('auto_renew')->default(false)->comment('是否自動續約');
|
||||||
|
$table->enum('status', ['active', 'expired', 'cancelled'])->default('active');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['member_id', 'status']);
|
||||||
|
$table->index('expires_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('member_memberships');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 禮品/福利定義
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('gift_definitions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name')->comment('禮品名稱');
|
||||||
|
$table->enum('type', ['points', 'coupon', 'product', 'discount', 'cash'])->comment('禮品類型');
|
||||||
|
$table->decimal('value', 12, 2)->default(0)->comment('數值');
|
||||||
|
$table->foreignId('tier_id')->nullable()->constrained('membership_tiers')->nullOnDelete()->comment('適用等級');
|
||||||
|
$table->enum('trigger', ['register', 'birthday', 'annual', 'upgrade', 'manual'])->comment('觸發條件');
|
||||||
|
$table->integer('validity_days')->default(30)->comment('有效天數');
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['is_active', 'trigger']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('gift_definitions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 會員禮品發放紀錄
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('member_gifts', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('member_id')->constrained('members')->onDelete('cascade');
|
||||||
|
$table->foreignId('gift_definition_id')->constrained('gift_definitions')->onDelete('cascade');
|
||||||
|
$table->enum('status', ['pending', 'claimed', 'expired'])->default('pending');
|
||||||
|
$table->datetime('claimed_at')->nullable()->comment('領取時間');
|
||||||
|
$table->datetime('expires_at')->nullable()->comment('有效期限');
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
|
||||||
|
$table->index(['member_id', 'status']);
|
||||||
|
$table->index('expires_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('member_gifts');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_login_logs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->text('user_agent')->nullable();
|
||||||
|
$table->timestamp('login_at')->useCurrent();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_login_logs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('user_login_logs', function (Blueprint $table) {
|
||||||
|
$table->string('device_type')->nullable()->after('user_agent'); // desktop, mobile, tablet
|
||||||
|
$table->string('browser')->nullable()->after('device_type');
|
||||||
|
$table->string('platform')->nullable()->after('browser');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('user_login_logs', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['device_type', 'browser', 'platform']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$teams = config('permission.teams');
|
||||||
|
$tableNames = config('permission.table_names');
|
||||||
|
$columnNames = config('permission.column_names');
|
||||||
|
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
|
||||||
|
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
|
||||||
|
|
||||||
|
throw_if(empty($tableNames), 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
|
||||||
|
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
|
||||||
|
*/
|
||||||
|
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
|
||||||
|
$table->id(); // permission id
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('guard_name');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['name', 'guard_name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
|
||||||
|
*/
|
||||||
|
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
|
||||||
|
$table->id(); // role id
|
||||||
|
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
|
||||||
|
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
|
||||||
|
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
|
||||||
|
}
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('guard_name');
|
||||||
|
$table->timestamps();
|
||||||
|
if ($teams || config('permission.testing')) {
|
||||||
|
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
|
||||||
|
} else {
|
||||||
|
$table->unique(['name', 'guard_name']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
|
||||||
|
$table->unsignedBigInteger($pivotPermission);
|
||||||
|
|
||||||
|
$table->string('model_type');
|
||||||
|
$table->unsignedBigInteger($columnNames['model_morph_key']);
|
||||||
|
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
|
||||||
|
|
||||||
|
$table->foreign($pivotPermission)
|
||||||
|
->references('id') // permission id
|
||||||
|
->on($tableNames['permissions'])
|
||||||
|
->cascadeOnDelete();
|
||||||
|
if ($teams) {
|
||||||
|
$table->unsignedBigInteger($columnNames['team_foreign_key']);
|
||||||
|
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
|
||||||
|
|
||||||
|
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
|
||||||
|
'model_has_permissions_permission_model_type_primary');
|
||||||
|
} else {
|
||||||
|
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
|
||||||
|
'model_has_permissions_permission_model_type_primary');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
|
||||||
|
$table->unsignedBigInteger($pivotRole);
|
||||||
|
|
||||||
|
$table->string('model_type');
|
||||||
|
$table->unsignedBigInteger($columnNames['model_morph_key']);
|
||||||
|
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
|
||||||
|
|
||||||
|
$table->foreign($pivotRole)
|
||||||
|
->references('id') // role id
|
||||||
|
->on($tableNames['roles'])
|
||||||
|
->cascadeOnDelete();
|
||||||
|
if ($teams) {
|
||||||
|
$table->unsignedBigInteger($columnNames['team_foreign_key']);
|
||||||
|
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
|
||||||
|
|
||||||
|
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
|
||||||
|
'model_has_roles_role_model_type_primary');
|
||||||
|
} else {
|
||||||
|
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
|
||||||
|
'model_has_roles_role_model_type_primary');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
|
||||||
|
$table->unsignedBigInteger($pivotPermission);
|
||||||
|
$table->unsignedBigInteger($pivotRole);
|
||||||
|
|
||||||
|
$table->foreign($pivotPermission)
|
||||||
|
->references('id') // permission id
|
||||||
|
->on($tableNames['permissions'])
|
||||||
|
->cascadeOnDelete();
|
||||||
|
|
||||||
|
$table->foreign($pivotRole)
|
||||||
|
->references('id') // role id
|
||||||
|
->on($tableNames['roles'])
|
||||||
|
->cascadeOnDelete();
|
||||||
|
|
||||||
|
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
app('cache')
|
||||||
|
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
|
||||||
|
->forget(config('permission.cache.key'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$tableNames = config('permission.table_names');
|
||||||
|
|
||||||
|
throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');
|
||||||
|
|
||||||
|
Schema::dropIfExists($tableNames['role_has_permissions']);
|
||||||
|
Schema::dropIfExists($tableNames['model_has_roles']);
|
||||||
|
Schema::dropIfExists($tableNames['model_has_permissions']);
|
||||||
|
Schema::dropIfExists($tableNames['roles']);
|
||||||
|
Schema::dropIfExists($tableNames['permissions']);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('companies', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name'); // 公司名稱
|
||||||
|
$table->string('code', 20)->unique(); // 公司代碼(簡碼)
|
||||||
|
$table->string('tax_id', 20)->nullable(); // 統一編號
|
||||||
|
$table->string('contact_name', 100)->nullable(); // 聯絡人
|
||||||
|
$table->string('contact_phone', 50)->nullable(); // 聯絡電話
|
||||||
|
$table->string('contact_email')->nullable(); // 聯絡信箱
|
||||||
|
$table->tinyInteger('status')->default(1); // 1:啟用, 0:停用
|
||||||
|
$table->date('valid_until')->nullable(); // 合約期限
|
||||||
|
$table->text('note')->nullable(); // 備註
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('companies');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('machines', function (Blueprint $table) {
|
||||||
|
$table->foreignId('company_id')->nullable()->after('id')
|
||||||
|
->constrained('companies')->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('machines', function (Blueprint $table) {
|
||||||
|
$table->dropConstrainedForeignId('company_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->foreignId('company_id')->nullable()->after('id')
|
||||||
|
->constrained('companies')->nullOnDelete();
|
||||||
|
$table->tinyInteger('status')->default(1)->after('role'); // 1:啟用, 0:停用
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropConstrainedForeignId('company_id');
|
||||||
|
$table->dropColumn('status');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('roles', function (Blueprint $table) {
|
||||||
|
$table->boolean('is_system')->default(false)->after('guard_name');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('roles', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('is_system');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('email')->nullable()->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('email')->nullable(false)->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
46
database/seeders/AdminUserSeeder.php
Normal file
46
database/seeders/AdminUserSeeder.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\System\User;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理員帳號 Seeder
|
||||||
|
*
|
||||||
|
* 執行方式:php artisan db:seed --class=AdminUserSeeder
|
||||||
|
*/
|
||||||
|
class AdminUserSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// 檢查是否已存在 admin 帳號,避免重複建立
|
||||||
|
$admin = User::where('username', 'admin')->first();
|
||||||
|
|
||||||
|
if ($admin) {
|
||||||
|
$this->command->info('Admin 帳號已存在,執行更新密碼與資料。');
|
||||||
|
$admin->update([
|
||||||
|
'name' => 'Admin',
|
||||||
|
'email' => 'admin@star-cloud.com',
|
||||||
|
'password' => Hash::make('password'),
|
||||||
|
]);
|
||||||
|
$admin->assignRole('super-admin');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$admin = User::create([
|
||||||
|
'username' => 'admin',
|
||||||
|
'name' => 'Admin',
|
||||||
|
'email' => 'admin@star-cloud.com',
|
||||||
|
'password' => Hash::make('password'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$admin->assignRole('super-admin');
|
||||||
|
|
||||||
|
$this->command->info('Admin 帳號建立成功!');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,15 +9,17 @@ class DatabaseSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Seed the application's database.
|
* Seed the application's database.
|
||||||
|
*
|
||||||
|
* 執行全部 Seeder:php artisan db:seed
|
||||||
|
* 執行單一 Seeder:php artisan db:seed --class=AdminUserSeeder
|
||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
// \App\Models\User::factory(10)->create();
|
$this->call([
|
||||||
|
RoleSeeder::class,
|
||||||
\App\Models\User::factory()->create([
|
AdminUserSeeder::class,
|
||||||
'name' => 'Admin',
|
MachineSeeder::class,
|
||||||
'email' => 'admin@star-cloud.com',
|
MemberSeeder::class,
|
||||||
'password' => bcrypt('password'),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user