Compare commits
25 Commits
9e4331d4f7
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b93d5ca135 | |||
| beb52eba4c | |||
| df1b5bed9b | |||
| 1958c3f01a | |||
| 6dfa36c1cb | |||
| 2d571cfd52 | |||
| 34b4706d00 | |||
| 89b8e8f343 | |||
| 1c1fe6f651 | |||
| 4f2907eb93 | |||
| 7bd96ef092 | |||
| d6aae828e9 | |||
| 72daf9baa0 | |||
| a869b0989a | |||
| 8042f0bafb | |||
| ddbf956144 | |||
| 0ee6ce9966 | |||
| dcbf3e4a9a | |||
| d27aff1a7d | |||
| 990f84c21a | |||
| ffed798ed5 | |||
| d83d941993 | |||
| 544894fa55 | |||
| 90fc8f2d49 | |||
| 16ccb4c18c |
@@ -1,6 +1,7 @@
|
||||
APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
# 正式環境必須設為 APP_ENV=production、APP_DEBUG=false,否則錯誤頁會洩漏堆疊追蹤與環境變數
|
||||
APP_DEBUG=true
|
||||
APP_TIMEZONE=UTC
|
||||
APP_URL=http://localhost
|
||||
|
||||
@@ -28,3 +28,6 @@ yarn-error.log
|
||||
/frontend/.env
|
||||
|
||||
|
||||
|
||||
# graphify 知識圖譜輸出(可由 graphify 重建,graph.json 每次整份重寫)
|
||||
/graphify-out
|
||||
|
||||
@@ -155,9 +155,9 @@
|
||||
|
||||
## 授權規範
|
||||
|
||||
- 已知缺口:`routes/api.php` 的 `member`、`provider` 路由群組只有 `auth:sanctum`,**沒有角色 middleware**;只有 `admin` 群組有 `EnsureAdmin`(待辦見 `docs/analysis/2026-10-14-project-review-todo.md` H1)
|
||||
- 在 middleware 補上之前:新增或修改 member / provider 路由時,Controller 必須明確檢查呼叫者角色與資源所有權,不可假設路由群組已有保護
|
||||
- middleware 補上之後:新路由必須掛對應角色 middleware,並保留 Controller 內的資源所有權檢查
|
||||
- `routes/api.php` 的 `member`、`provider`、`admin` 路由群組分別掛 `auth:sanctum` + `member`/`provider`/`admin` 角色 middleware(`EnsureMember`/`EnsureProvider`/`EnsureAdmin`,規格見 `openspec/specs/role-authorization/`)
|
||||
- 新路由必須掛對應角色 middleware,並保留 Controller 內的資源所有權檢查(middleware 只擋跨角色,不擋同角色存取他人資源)
|
||||
- member + provider 共用的路由(通知、即時訊息)沒有角色 middleware,Controller 必須自行檢查資源所有權;admin 能否存取這類路由尚未定案
|
||||
- 每個涉及權限的路由都必須有跨角色測試(member / provider / admin / 未登入各自的預期結果,參考 `tests/Feature/AdminEndpointAuthTest.php`)
|
||||
|
||||
---
|
||||
@@ -213,3 +213,13 @@
|
||||
- 發現問題直接說,不要繞彎
|
||||
- 有不確定的地方,明確標注「我不確定,需要你確認」
|
||||
- 不要重複確認已經決定的技術選擇
|
||||
|
||||
## graphify
|
||||
|
||||
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
||||
|
||||
Rules:
|
||||
- When graphify-out/graph.json exists, run `graphify query "<question>"` before your first grep, search or source-file read for a codebase question, and put this rule in every subagent prompt that explores code. Read raw files only for lines the graph or an earlier read already located. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
||||
|
||||
+120
-57
@@ -38,40 +38,48 @@ class MemberApiDoc
|
||||
* operationId="createBooking",
|
||||
* tags={"會員預約"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* required={"offer_id","schedule_id","participants"},
|
||||
*
|
||||
* @OA\Property(property="offer_id", type="integer", example=1, description="課程 ID"),
|
||||
* @OA\Property(property="schedule_id", type="integer", example=2, description="時段 ID"),
|
||||
* @OA\Property(property="participants", type="integer", example=2, description="參加人數"),
|
||||
* @OA\Property(property="note", type="string", nullable=true, example="需要器材租借")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=201,
|
||||
* description="預約建立成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="預約成功"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=422,
|
||||
* description="驗證失敗或時段已滿",
|
||||
*
|
||||
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=403,
|
||||
* description="無權限(非 member 角色)",
|
||||
*
|
||||
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function createBooking()
|
||||
{
|
||||
}
|
||||
public function createBooking() {}
|
||||
|
||||
/**
|
||||
* 取得我的預約列表
|
||||
@@ -83,22 +91,26 @@ class MemberApiDoc
|
||||
* operationId="listMemberBookings",
|
||||
* tags={"會員預約"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
|
||||
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=403, description="非 member 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function listMemberBookings()
|
||||
{
|
||||
}
|
||||
public function listMemberBookings() {}
|
||||
|
||||
/**
|
||||
* 取得單一預約
|
||||
@@ -109,22 +121,25 @@ class MemberApiDoc
|
||||
* operationId="getMemberBooking",
|
||||
* tags={"會員預約"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=403, description="無權限存取", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* @OA\Response(response=403, description="非 member 角色或非本人預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function getMemberBooking()
|
||||
{
|
||||
}
|
||||
public function getMemberBooking() {}
|
||||
|
||||
/**
|
||||
* 取消預約
|
||||
@@ -136,22 +151,25 @@ class MemberApiDoc
|
||||
* operationId="cancelBooking",
|
||||
* tags={"會員預約"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取消成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="預約已取消")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="無權限或狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
*
|
||||
* @OA\Response(response=403, description="非 member 角色、非本人預約或狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function cancelBooking()
|
||||
{
|
||||
}
|
||||
public function cancelBooking() {}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Member Reviews
|
||||
@@ -167,32 +185,37 @@ class MemberApiDoc
|
||||
* operationId="createReview",
|
||||
* tags={"會員評價"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* required={"booking_id","rating"},
|
||||
*
|
||||
* @OA\Property(property="booking_id", type="integer", example=5),
|
||||
* @OA\Property(property="rating", type="integer", minimum=1, maximum=5, example=4),
|
||||
* @OA\Property(property="comment", type="string", nullable=true, example="課程非常棒!"),
|
||||
* @OA\Property(property="is_anonymous", type="boolean", example=false)
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=201,
|
||||
* description="評價建立成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="評價已提交"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Review")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="預約未完成或非本人預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
*
|
||||
* @OA\Response(response=403, description="非 member 角色、預約未完成或非本人預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function createReview()
|
||||
{
|
||||
}
|
||||
public function createReview() {}
|
||||
|
||||
/**
|
||||
* 更新評價
|
||||
@@ -203,30 +226,36 @@ class MemberApiDoc
|
||||
* operationId="updateReview",
|
||||
* tags={"會員評價"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="rating", type="integer", minimum=1, maximum=5, example=5),
|
||||
* @OA\Property(property="comment", type="string", nullable=true, example="更新後的評語"),
|
||||
* @OA\Property(property="is_anonymous", type="boolean", example=true)
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="更新成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Review")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
*
|
||||
* @OA\Response(response=403, description="非 member 角色或非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function updateReview()
|
||||
{
|
||||
}
|
||||
public function updateReview() {}
|
||||
|
||||
/**
|
||||
* 刪除評價
|
||||
@@ -237,22 +266,25 @@ class MemberApiDoc
|
||||
* operationId="deleteReview",
|
||||
* tags={"會員評價"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="刪除成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="評價已刪除")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
*
|
||||
* @OA\Response(response=403, description="非 member 角色或非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function deleteReview()
|
||||
{
|
||||
}
|
||||
public function deleteReview() {}
|
||||
|
||||
/**
|
||||
* 標記評價為有幫助
|
||||
@@ -264,22 +296,25 @@ class MemberApiDoc
|
||||
* operationId="markReviewHelpful",
|
||||
* tags={"會員評價"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="操作成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="helpful_count", type="integer", example=4),
|
||||
* @OA\Property(property="has_voted", type="boolean", example=true)
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function markReviewHelpful()
|
||||
{
|
||||
}
|
||||
public function markReviewHelpful() {}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Booking Messages(即時訊息,Member + Provider 共用)
|
||||
@@ -295,10 +330,13 @@ class MemberApiDoc
|
||||
* operationId="getMessageUnreadCounts",
|
||||
* tags={"即時訊息"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
@@ -310,9 +348,7 @@ class MemberApiDoc
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function getMessageUnreadCounts()
|
||||
{
|
||||
}
|
||||
public function getMessageUnreadCounts() {}
|
||||
|
||||
/**
|
||||
* 取得預約訊息列表
|
||||
@@ -324,17 +360,23 @@ class MemberApiDoc
|
||||
* operationId="listBookingMessages",
|
||||
* tags={"即時訊息"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="booking", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="array",
|
||||
*
|
||||
* @OA\Items(
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="booking_id", type="integer", example=12),
|
||||
* @OA\Property(property="sender_type", type="string", enum={"member","provider"}, example="member"),
|
||||
@@ -346,13 +388,12 @@ class MemberApiDoc
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=403, description="非參與方或預約狀態不符", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function listBookingMessages()
|
||||
{
|
||||
}
|
||||
public function listBookingMessages() {}
|
||||
|
||||
/**
|
||||
* 發送預約訊息
|
||||
@@ -364,21 +405,29 @@ class MemberApiDoc
|
||||
* operationId="sendBookingMessage",
|
||||
* tags={"即時訊息"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="booking", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\MediaType(
|
||||
* mediaType="multipart/form-data",
|
||||
*
|
||||
* @OA\Schema(
|
||||
*
|
||||
* @OA\Property(property="message", type="string", nullable=true, example="請問需要自備潛水裝備嗎?"),
|
||||
* @OA\Property(property="image", type="string", format="binary", nullable=true, description="圖片(jpg/png,message 與 image 至少擇一)")
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=201,
|
||||
* description="訊息已送出",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
@@ -393,13 +442,12 @@ class MemberApiDoc
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=403, description="非參與方或預約狀態不符", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=422, description="message 與 image 皆為空", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function sendBookingMessage()
|
||||
{
|
||||
}
|
||||
public function sendBookingMessage() {}
|
||||
|
||||
/**
|
||||
* 標記訊息為已讀
|
||||
@@ -411,21 +459,24 @@ class MemberApiDoc
|
||||
* operationId="markMessagesRead",
|
||||
* tags={"即時訊息"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="booking", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="標記成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="已標記為已讀")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=403, description="非參與方", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function markMessagesRead()
|
||||
{
|
||||
}
|
||||
public function markMessagesRead() {}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Notifications (Member + Provider 共用)
|
||||
@@ -441,18 +492,24 @@ class MemberApiDoc
|
||||
* operationId="listNotifications",
|
||||
* tags={"通知"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="array",
|
||||
*
|
||||
* @OA\Items(
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="type", type="string", example="booking_confirmed"),
|
||||
* @OA\Property(property="title", type="string", example="預約已確認"),
|
||||
@@ -466,9 +523,7 @@ class MemberApiDoc
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function listNotifications()
|
||||
{
|
||||
}
|
||||
public function listNotifications() {}
|
||||
|
||||
/**
|
||||
* 取得未讀通知數量
|
||||
@@ -479,19 +534,20 @@ class MemberApiDoc
|
||||
* operationId="getUnreadNotificationCount",
|
||||
* tags={"通知"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="unread_count", type="integer", example=3)
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function getUnreadNotificationCount()
|
||||
{
|
||||
}
|
||||
public function getUnreadNotificationCount() {}
|
||||
|
||||
/**
|
||||
* 標記單一通知為已讀
|
||||
@@ -502,21 +558,24 @@ class MemberApiDoc
|
||||
* operationId="markNotificationRead",
|
||||
* tags={"通知"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="通知 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="標記成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="通知已標記為已讀")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="通知不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function markNotificationRead()
|
||||
{
|
||||
}
|
||||
public function markNotificationRead() {}
|
||||
|
||||
/**
|
||||
* 全部通知標記為已讀
|
||||
@@ -527,19 +586,20 @@ class MemberApiDoc
|
||||
* operationId="markAllNotificationsRead",
|
||||
* tags={"通知"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="標記成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="所有通知已標記為已讀")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function markAllNotificationsRead()
|
||||
{
|
||||
}
|
||||
public function markAllNotificationsRead() {}
|
||||
|
||||
/**
|
||||
* 刪除通知
|
||||
@@ -550,19 +610,22 @@ class MemberApiDoc
|
||||
* operationId="deleteNotification",
|
||||
* tags={"通知"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="通知 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="刪除成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="通知已刪除")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="通知不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function deleteNotification()
|
||||
{
|
||||
}
|
||||
public function deleteNotification() {}
|
||||
}
|
||||
|
||||
+179
-88
@@ -42,10 +42,13 @@ class ProviderApiDoc
|
||||
* operationId="getProviderVerification",
|
||||
* tags={"教練驗證"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
@@ -55,8 +58,10 @@ class ProviderApiDoc
|
||||
* @OA\Property(
|
||||
* property="certifications",
|
||||
* type="array",
|
||||
*
|
||||
* @OA\Items(
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="url", type="string", example="http://localhost:8080/storage/providers/5/certifications/uuid.jpg")
|
||||
* )
|
||||
@@ -64,12 +69,11 @@ class ProviderApiDoc
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
*
|
||||
* @OA\Response(response=403, description="非 provider 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function getProviderVerification()
|
||||
{
|
||||
}
|
||||
public function getProviderVerification() {}
|
||||
|
||||
/**
|
||||
* 上傳證照圖片
|
||||
@@ -81,20 +85,27 @@ class ProviderApiDoc
|
||||
* operationId="uploadCertification",
|
||||
* tags={"教練驗證"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\MediaType(
|
||||
* mediaType="multipart/form-data",
|
||||
*
|
||||
* @OA\Schema(
|
||||
* required={"certification"},
|
||||
*
|
||||
* @OA\Property(property="certification", type="string", format="binary", description="證照圖片(jpg/png,最大 5MB)")
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="上傳成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
@@ -104,13 +115,12 @@ class ProviderApiDoc
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* @OA\Response(response=403, description="非 provider 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function uploadCertification()
|
||||
{
|
||||
}
|
||||
public function uploadCertification() {}
|
||||
|
||||
/**
|
||||
* 刪除證照圖片
|
||||
@@ -122,22 +132,25 @@ class ProviderApiDoc
|
||||
* operationId="deleteCertification",
|
||||
* tags={"教練驗證"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="證照 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="刪除成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="證照已刪除")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="無權限或當前狀態不可刪除", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
*
|
||||
* @OA\Response(response=403, description="非 provider 角色、非本人證照或當前狀態不可刪除", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="證照不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function deleteCertification()
|
||||
{
|
||||
}
|
||||
public function deleteCertification() {}
|
||||
|
||||
/**
|
||||
* 送出驗證申請
|
||||
@@ -149,21 +162,23 @@ class ProviderApiDoc
|
||||
* operationId="submitVerification",
|
||||
* tags={"教練驗證"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="送審成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="驗證申請已送出")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=422, description="尚未上傳證照或當前狀態不可送審", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* @OA\Response(response=403, description="非 provider 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function submitVerification()
|
||||
{
|
||||
}
|
||||
public function submitVerification() {}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Provider Offers CRUD
|
||||
@@ -179,22 +194,26 @@ class ProviderApiDoc
|
||||
* operationId="listProviderOffers",
|
||||
* tags={"教練課程"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/DivingOffer")),
|
||||
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=403, description="非 provider 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function listProviderOffers()
|
||||
{
|
||||
}
|
||||
public function listProviderOffers() {}
|
||||
|
||||
/**
|
||||
* 建立課程
|
||||
@@ -206,10 +225,13 @@ class ProviderApiDoc
|
||||
* operationId="createProviderOffer",
|
||||
* tags={"教練課程"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* required={"title","description","region","price","max_participants"},
|
||||
*
|
||||
* @OA\Property(property="title", type="string", example="基礎開放水域課程"),
|
||||
* @OA\Property(property="description", type="string", example="適合初學者的 OWD 課程"),
|
||||
* @OA\Property(property="region", type="string", example="墾丁"),
|
||||
@@ -218,22 +240,24 @@ class ProviderApiDoc
|
||||
* @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"初學","OWD"})
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=201,
|
||||
* description="課程建立成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="課程建立成功"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* @OA\Response(response=403, description="非 provider 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function createProviderOffer()
|
||||
{
|
||||
}
|
||||
public function createProviderOffer() {}
|
||||
|
||||
/**
|
||||
* 取得單一課程(Provider 視角)
|
||||
@@ -245,22 +269,25 @@ class ProviderApiDoc
|
||||
* operationId="getProviderOffer",
|
||||
* tags={"教練課程"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
*
|
||||
* @OA\Response(response=403, description="非 provider 角色或非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function getProviderOffer()
|
||||
{
|
||||
}
|
||||
public function getProviderOffer() {}
|
||||
|
||||
/**
|
||||
* 更新課程
|
||||
@@ -271,10 +298,14 @@ class ProviderApiDoc
|
||||
* operationId="updateProviderOffer",
|
||||
* tags={"教練課程"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="title", type="string", example="進階開放水域課程"),
|
||||
* @OA\Property(property="description", type="string", example="AOWD 課程"),
|
||||
* @OA\Property(property="region", type="string", example="小琉球"),
|
||||
@@ -284,21 +315,23 @@ class ProviderApiDoc
|
||||
* @OA\Property(property="is_active", type="boolean", example=true)
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="更新成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
*
|
||||
* @OA\Response(response=403, description="非 provider 角色或非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function updateProviderOffer()
|
||||
{
|
||||
}
|
||||
public function updateProviderOffer() {}
|
||||
|
||||
/**
|
||||
* 刪除課程
|
||||
@@ -309,22 +342,25 @@ class ProviderApiDoc
|
||||
* operationId="deleteProviderOffer",
|
||||
* tags={"教練課程"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="刪除成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="課程已刪除")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
*
|
||||
* @OA\Response(response=403, description="非 provider 角色或非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function deleteProviderOffer()
|
||||
{
|
||||
}
|
||||
public function deleteProviderOffer() {}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Provider Offer Images
|
||||
@@ -340,32 +376,39 @@ class ProviderApiDoc
|
||||
* operationId="uploadOfferCover",
|
||||
* tags={"教練圖片"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\MediaType(
|
||||
* mediaType="multipart/form-data",
|
||||
*
|
||||
* @OA\Schema(
|
||||
* required={"cover_image"},
|
||||
*
|
||||
* @OA\Property(property="cover_image", type="string", format="binary", description="封面圖片(jpg/png,最大 5MB)")
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="上傳成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="cover_image_url", type="string", example="https://example.com/covers/1.jpg")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* @OA\Response(response=403, description="非 provider 角色或非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function uploadOfferCover()
|
||||
{
|
||||
}
|
||||
public function uploadOfferCover() {}
|
||||
|
||||
/**
|
||||
* 刪除封面圖片
|
||||
@@ -376,21 +419,24 @@ class ProviderApiDoc
|
||||
* operationId="deleteOfferCover",
|
||||
* tags={"教練圖片"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="刪除成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="封面圖片已刪除")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
*
|
||||
* @OA\Response(response=403, description="非 provider 角色或非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function deleteOfferCover()
|
||||
{
|
||||
}
|
||||
public function deleteOfferCover() {}
|
||||
|
||||
/**
|
||||
* 上傳相簿圖片
|
||||
@@ -402,37 +448,45 @@ class ProviderApiDoc
|
||||
* operationId="uploadOfferImages",
|
||||
* tags={"教練圖片"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\MediaType(
|
||||
* mediaType="multipart/form-data",
|
||||
*
|
||||
* @OA\Schema(
|
||||
* required={"images[]"},
|
||||
*
|
||||
* @OA\Property(
|
||||
* property="images[]",
|
||||
* type="array",
|
||||
*
|
||||
* @OA\Items(type="string", format="binary"),
|
||||
* description="相簿圖片(每張最大 5MB)"
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="上傳成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="images", type="array", @OA\Items(type="string"), example={"https://example.com/img/1.jpg"})
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* @OA\Response(response=403, description="非 provider 角色或非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function uploadOfferImages()
|
||||
{
|
||||
}
|
||||
public function uploadOfferImages() {}
|
||||
|
||||
/**
|
||||
* 刪除相簿圖片
|
||||
@@ -443,22 +497,25 @@ class ProviderApiDoc
|
||||
* operationId="deleteOfferImage",
|
||||
* tags={"教練圖片"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="圖片 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="刪除成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="圖片已刪除")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人圖片", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
*
|
||||
* @OA\Response(response=403, description="非 provider 角色或非本人圖片", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="圖片不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function deleteOfferImage()
|
||||
{
|
||||
}
|
||||
public function deleteOfferImage() {}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Provider Schedules
|
||||
@@ -474,20 +531,24 @@ class ProviderApiDoc
|
||||
* operationId="listProviderSchedules",
|
||||
* tags={"教練時段"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="offer_id", in="query", required=false, description="課程 ID 篩選", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/CourseSchedule"))
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=403, description="非 provider 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function listProviderSchedules()
|
||||
{
|
||||
}
|
||||
public function listProviderSchedules() {}
|
||||
|
||||
/**
|
||||
* 建立時段
|
||||
@@ -498,31 +559,36 @@ class ProviderApiDoc
|
||||
* operationId="createProviderSchedule",
|
||||
* tags={"教練時段"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* required={"offer_id","start_date","end_date","available_slots"},
|
||||
*
|
||||
* @OA\Property(property="offer_id", type="integer", example=1),
|
||||
* @OA\Property(property="start_date", type="string", format="date", example="2025-07-01"),
|
||||
* @OA\Property(property="end_date", type="string", format="date", example="2025-07-03"),
|
||||
* @OA\Property(property="available_slots", type="integer", example=4)
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=201,
|
||||
* description="時段建立成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/CourseSchedule")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* @OA\Response(response=403, description="非 provider 角色或非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function createProviderSchedule()
|
||||
{
|
||||
}
|
||||
public function createProviderSchedule() {}
|
||||
|
||||
/**
|
||||
* 更新時段
|
||||
@@ -533,31 +599,37 @@ class ProviderApiDoc
|
||||
* operationId="updateProviderSchedule",
|
||||
* tags={"教練時段"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="時段 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="start_date", type="string", format="date", example="2025-07-05"),
|
||||
* @OA\Property(property="end_date", type="string", format="date", example="2025-07-07"),
|
||||
* @OA\Property(property="available_slots", type="integer", example=6),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true)
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="更新成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/CourseSchedule")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
*
|
||||
* @OA\Response(response=403, description="非 provider 角色或非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="時段不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function updateProviderSchedule()
|
||||
{
|
||||
}
|
||||
public function updateProviderSchedule() {}
|
||||
|
||||
/**
|
||||
* 刪除時段
|
||||
@@ -568,22 +640,25 @@ class ProviderApiDoc
|
||||
* operationId="deleteProviderSchedule",
|
||||
* tags={"教練時段"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="時段 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="刪除成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="時段已刪除")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
*
|
||||
* @OA\Response(response=403, description="非 provider 角色或非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="時段不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function deleteProviderSchedule()
|
||||
{
|
||||
}
|
||||
public function deleteProviderSchedule() {}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Provider Bookings Management
|
||||
@@ -599,22 +674,26 @@ class ProviderApiDoc
|
||||
* operationId="listProviderBookings",
|
||||
* tags={"教練預約"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
|
||||
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取得成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
|
||||
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=403, description="非 provider 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function listProviderBookings()
|
||||
{
|
||||
}
|
||||
public function listProviderBookings() {}
|
||||
|
||||
/**
|
||||
* 確認預約
|
||||
@@ -625,23 +704,26 @@ class ProviderApiDoc
|
||||
* operationId="confirmBooking",
|
||||
* tags={"教練預約"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="確認成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="預約已確認"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
*
|
||||
* @OA\Response(response=403, description="非 provider 角色或非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=422, description="狀態不允許確認", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function confirmBooking()
|
||||
{
|
||||
}
|
||||
public function confirmBooking() {}
|
||||
|
||||
/**
|
||||
* 拒絕預約
|
||||
@@ -652,23 +734,26 @@ class ProviderApiDoc
|
||||
* operationId="rejectBooking",
|
||||
* tags={"教練預約"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="拒絕成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="預約已拒絕"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
*
|
||||
* @OA\Response(response=403, description="非 provider 角色或非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=422, description="狀態不允許拒絕", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function rejectBooking()
|
||||
{
|
||||
}
|
||||
public function rejectBooking() {}
|
||||
|
||||
/**
|
||||
* 標記預約完成
|
||||
@@ -679,23 +764,26 @@ class ProviderApiDoc
|
||||
* operationId="completeBookingByProvider",
|
||||
* tags={"教練預約"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="標記成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="預約已完成"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
*
|
||||
* @OA\Response(response=403, description="非 provider 角色或非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=422, description="狀態不允許完成", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function completeBookingByProvider()
|
||||
{
|
||||
}
|
||||
public function completeBookingByProvider() {}
|
||||
|
||||
/**
|
||||
* 取消預約(Provider)
|
||||
@@ -706,21 +794,24 @@ class ProviderApiDoc
|
||||
* operationId="cancelBookingByProvider",
|
||||
* tags={"教練預約"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="取消成功",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="預約已取消"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Booking")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
*
|
||||
* @OA\Response(response=403, description="非 provider 角色或非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=422, description="狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function cancelBookingByProvider()
|
||||
{
|
||||
}
|
||||
public function cancelBookingByProvider() {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
final class BookingRuleViolationException extends \RuntimeException {}
|
||||
@@ -4,11 +4,12 @@ namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Enums\ScheduleStatus;
|
||||
use App\Exceptions\BookingRuleViolationException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Booking;
|
||||
use App\Models\CourseSchedule;
|
||||
use App\Notifications\BookingCreatedNotification;
|
||||
use App\Notifications\BookingCancelledNotification;
|
||||
use App\Notifications\BookingCreatedNotification;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -22,7 +23,7 @@ class MemberBookingController extends Controller
|
||||
->where('member_id', $request->user()->id)
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(fn($b) => $this->formatBooking($b));
|
||||
->map(fn ($b) => $this->formatBooking($b));
|
||||
|
||||
return response()->json(['status' => true, 'data' => $bookings]);
|
||||
}
|
||||
@@ -40,9 +41,9 @@ class MemberBookingController extends Controller
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'schedule_id' => 'required|integer|exists:course_schedules,id',
|
||||
'schedule_id' => 'required|integer|exists:course_schedules,id',
|
||||
'participants' => 'required|integer|min:1',
|
||||
'notes' => 'nullable|string|max:500',
|
||||
'notes' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$schedule = CourseSchedule::with('divingOffer.provider.providerProfile')->findOrFail($data['schedule_id']);
|
||||
@@ -50,7 +51,7 @@ class MemberBookingController extends Controller
|
||||
// Layer 1:快速失敗
|
||||
// 可見性繞過防護:課程教練未通過審核時不可建立新預約(既有預約不受影響,見 provider-verification 規格)
|
||||
$offer = $schedule->divingOffer;
|
||||
if ($offer->provider_id !== null && !($offer->provider?->providerProfile?->is_verified)) {
|
||||
if ($offer->provider_id !== null && ! ($offer->provider?->providerProfile?->is_verified)) {
|
||||
// is_verified 為 accessor(= verification_status === approved)
|
||||
return response()->json(['status' => false, 'message' => '此課程目前不開放預約'], 422);
|
||||
}
|
||||
@@ -68,7 +69,7 @@ class MemberBookingController extends Controller
|
||||
// Layer 2:lockForUpdate 後二次驗證
|
||||
$schedule = CourseSchedule::lockForUpdate()->find($schedule->id);
|
||||
if ($data['participants'] > $schedule->remainingSpots()) {
|
||||
throw new \RuntimeException('名額不足,請重新選擇');
|
||||
throw new BookingRuleViolationException('名額不足,請重新選擇');
|
||||
}
|
||||
|
||||
// 重複預約檢查
|
||||
@@ -77,19 +78,19 @@ class MemberBookingController extends Controller
|
||||
->whereIn('status', [BookingStatus::Pending->value, BookingStatus::Confirmed->value])
|
||||
->exists();
|
||||
if ($duplicate) {
|
||||
throw new \RuntimeException('您已預約此時段');
|
||||
throw new BookingRuleViolationException('您已預約此時段');
|
||||
}
|
||||
|
||||
return Booking::create([
|
||||
'schedule_id' => $schedule->id,
|
||||
'member_id' => $memberId,
|
||||
'schedule_id' => $schedule->id,
|
||||
'member_id' => $memberId,
|
||||
'participants' => $data['participants'],
|
||||
'total_price' => $schedule->divingOffer->price * $data['participants'],
|
||||
'status' => BookingStatus::Pending,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
'total_price' => $schedule->divingOffer->price * $data['participants'],
|
||||
'status' => BookingStatus::Pending,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
});
|
||||
} catch (\RuntimeException $e) {
|
||||
} catch (BookingRuleViolationException $e) {
|
||||
return response()->json(['status' => false, 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
@@ -98,13 +99,13 @@ class MemberBookingController extends Controller
|
||||
$provider = $booking->schedule->divingOffer->provider;
|
||||
$provider->notify(new BookingCreatedNotification($booking));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('BookingCreatedNotification failed: ' . $e->getMessage());
|
||||
Log::error('BookingCreatedNotification failed: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'status' => true,
|
||||
'message' => '預約已送出,等待教練確認',
|
||||
'data' => $this->formatBooking($booking->fresh(['schedule.divingOffer'])),
|
||||
'data' => $this->formatBooking($booking->fresh(['schedule.divingOffer'])),
|
||||
], 201);
|
||||
}
|
||||
|
||||
@@ -116,13 +117,13 @@ class MemberBookingController extends Controller
|
||||
}
|
||||
|
||||
$canCancelFrom = [BookingStatus::Pending, BookingStatus::Confirmed];
|
||||
if (!in_array($booking->status, $canCancelFrom)) {
|
||||
if (! in_array($booking->status, $canCancelFrom)) {
|
||||
return response()->json(['status' => false, 'message' => '此預約狀態無法取消'], 422);
|
||||
}
|
||||
|
||||
// 24h 截止驗證
|
||||
$schedule = $booking->schedule;
|
||||
$courseStart = Carbon::parse($schedule->scheduled_date->toDateString() . ' ' . $schedule->start_time);
|
||||
$courseStart = Carbon::parse($schedule->scheduled_date->toDateString().' '.$schedule->start_time);
|
||||
if (now()->diffInHours($courseStart, false) < 24) {
|
||||
return response()->json(['status' => false, 'message' => '距課程開始不足 24 小時,無法取消,請聯繫教練'], 422);
|
||||
}
|
||||
@@ -148,7 +149,7 @@ class MemberBookingController extends Controller
|
||||
$provider = $booking->schedule->divingOffer->provider;
|
||||
$provider->notify(new BookingCancelledNotification($booking, cancelledBy: 'member'));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('BookingCancelledNotification(member) failed: ' . $e->getMessage());
|
||||
Log::error('BookingCancelledNotification(member) failed: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'message' => '預約已取消']);
|
||||
@@ -157,20 +158,21 @@ class MemberBookingController extends Controller
|
||||
private function formatBooking(Booking $b): array
|
||||
{
|
||||
$offer = $b->schedule?->divingOffer;
|
||||
|
||||
return [
|
||||
'id' => $b->id,
|
||||
'offer_id' => $offer?->id,
|
||||
'offer_title' => $offer?->title,
|
||||
'id' => $b->id,
|
||||
'offer_id' => $offer?->id,
|
||||
'offer_title' => $offer?->title,
|
||||
'offer_location' => $offer?->location,
|
||||
'offer_region' => $offer?->region,
|
||||
'offer_price' => $offer?->price,
|
||||
'offer_region' => $offer?->region,
|
||||
'offer_price' => $offer?->price,
|
||||
'scheduled_date' => $b->schedule?->scheduled_date?->toDateString(),
|
||||
'start_time' => $b->schedule?->start_time,
|
||||
'participants' => $b->participants,
|
||||
'total_price' => $b->total_price,
|
||||
'status' => $b->status->value,
|
||||
'notes' => $b->notes,
|
||||
'created_at' => $b->created_at?->toISOString(),
|
||||
'start_time' => $b->schedule?->start_time,
|
||||
'participants' => $b->participants,
|
||||
'total_price' => $b->total_price,
|
||||
'status' => $b->status->value,
|
||||
'notes' => $b->notes,
|
||||
'created_at' => $b->created_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Enums\ScheduleStatus;
|
||||
use App\Exceptions\BookingRuleViolationException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Booking;
|
||||
use App\Notifications\BookingCancelledNotification;
|
||||
@@ -20,10 +21,10 @@ class ProviderBookingController extends Controller
|
||||
{
|
||||
$provider = $request->user();
|
||||
$bookings = Booking::with(['member', 'schedule.divingOffer'])
|
||||
->whereHas('schedule', fn($q) => $q->where('provider_id', $provider->id))
|
||||
->whereHas('schedule', fn ($q) => $q->where('provider_id', $provider->id))
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(fn($b) => $this->formatBooking($b));
|
||||
->map(fn ($b) => $this->formatBooking($b));
|
||||
|
||||
return response()->json(['status' => true, 'data' => $bookings]);
|
||||
}
|
||||
@@ -33,7 +34,7 @@ class ProviderBookingController extends Controller
|
||||
$booking = Booking::with('schedule')->findOrFail($id);
|
||||
$this->authorizeProvider($request, $booking);
|
||||
|
||||
if (!$booking->canTransitionTo(BookingStatus::Confirmed)) {
|
||||
if (! $booking->canTransitionTo(BookingStatus::Confirmed)) {
|
||||
return response()->json(['status' => false, 'message' => '當前狀態無法確認'], 422);
|
||||
}
|
||||
|
||||
@@ -43,7 +44,7 @@ class ProviderBookingController extends Controller
|
||||
$remaining = $schedule->max_participants - $schedule->current_participants;
|
||||
|
||||
if ($booking->participants > $remaining) {
|
||||
throw new \RuntimeException('名額不足,無法確認此預約');
|
||||
throw new BookingRuleViolationException('名額不足,無法確認此預約');
|
||||
}
|
||||
|
||||
$booking->update(['status' => BookingStatus::Confirmed]);
|
||||
@@ -54,7 +55,7 @@ class ProviderBookingController extends Controller
|
||||
$schedule->update(['status' => ScheduleStatus::Full]);
|
||||
}
|
||||
});
|
||||
} catch (\RuntimeException $e) {
|
||||
} catch (BookingRuleViolationException $e) {
|
||||
return response()->json(['status' => false, 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
@@ -62,7 +63,7 @@ class ProviderBookingController extends Controller
|
||||
$booking->load('member');
|
||||
$booking->member->notify(new BookingConfirmedNotification($booking));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('BookingConfirmedNotification failed: ' . $e->getMessage());
|
||||
Log::error('BookingConfirmedNotification failed: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'message' => '預約已確認', 'data' => $this->formatBooking($booking->fresh(['member', 'schedule.divingOffer']))]);
|
||||
@@ -73,7 +74,7 @@ class ProviderBookingController extends Controller
|
||||
$booking = Booking::with('schedule')->findOrFail($id);
|
||||
$this->authorizeProvider($request, $booking);
|
||||
|
||||
if (!$booking->canTransitionTo(BookingStatus::Rejected)) {
|
||||
if (! $booking->canTransitionTo(BookingStatus::Rejected)) {
|
||||
return response()->json(['status' => false, 'message' => '當前狀態無法拒絕'], 422);
|
||||
}
|
||||
|
||||
@@ -83,7 +84,7 @@ class ProviderBookingController extends Controller
|
||||
$booking->load('member');
|
||||
$booking->member->notify(new BookingRejectedNotification($booking));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('BookingRejectedNotification failed: ' . $e->getMessage());
|
||||
Log::error('BookingRejectedNotification failed: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'message' => '預約已拒絕']);
|
||||
@@ -94,7 +95,7 @@ class ProviderBookingController extends Controller
|
||||
$booking = Booking::with('schedule')->findOrFail($id);
|
||||
$this->authorizeProvider($request, $booking);
|
||||
|
||||
if (!$booking->canTransitionTo(BookingStatus::ProviderCancelled)) {
|
||||
if (! $booking->canTransitionTo(BookingStatus::ProviderCancelled)) {
|
||||
return response()->json(['status' => false, 'message' => '當前狀態無法取消'], 422);
|
||||
}
|
||||
|
||||
@@ -114,7 +115,7 @@ class ProviderBookingController extends Controller
|
||||
$booking->load('member');
|
||||
$booking->member->notify(new BookingCancelledNotification($booking, cancelledBy: 'provider'));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('BookingCancelledNotification(provider) failed: ' . $e->getMessage());
|
||||
Log::error('BookingCancelledNotification(provider) failed: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'message' => '預約已取消']);
|
||||
@@ -125,7 +126,7 @@ class ProviderBookingController extends Controller
|
||||
$booking = Booking::with('schedule')->findOrFail($id);
|
||||
$this->authorizeProvider($request, $booking);
|
||||
|
||||
if (!$booking->canTransitionTo(BookingStatus::Completed)) {
|
||||
if (! $booking->canTransitionTo(BookingStatus::Completed)) {
|
||||
return response()->json(['status' => false, 'message' => '只有已確認的預約才能標記完成'], 422);
|
||||
}
|
||||
|
||||
@@ -135,7 +136,7 @@ class ProviderBookingController extends Controller
|
||||
$booking->load('member');
|
||||
$booking->member->notify(new BookingCompletedNotification($booking));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('BookingCompletedNotification failed: ' . $e->getMessage());
|
||||
Log::error('BookingCompletedNotification failed: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'message' => '預約已標記為完成']);
|
||||
@@ -151,17 +152,17 @@ class ProviderBookingController extends Controller
|
||||
private function formatBooking(Booking $b): array
|
||||
{
|
||||
return [
|
||||
'id' => $b->id,
|
||||
'member_name' => $b->member?->name,
|
||||
'member_email' => $b->member?->email,
|
||||
'offer_title' => $b->schedule?->divingOffer?->title,
|
||||
'id' => $b->id,
|
||||
'member_name' => $b->member?->name,
|
||||
'member_email' => $b->member?->email,
|
||||
'offer_title' => $b->schedule?->divingOffer?->title,
|
||||
'scheduled_date' => $b->schedule?->scheduled_date?->toDateString(),
|
||||
'start_time' => $b->schedule?->start_time,
|
||||
'participants' => $b->participants,
|
||||
'total_price' => $b->total_price,
|
||||
'status' => $b->status->value,
|
||||
'notes' => $b->notes,
|
||||
'created_at' => $b->created_at?->toISOString(),
|
||||
'start_time' => $b->schedule?->start_time,
|
||||
'participants' => $b->participants,
|
||||
'total_price' => $b->total_price,
|
||||
'status' => $b->status->value,
|
||||
'notes' => $b->notes,
|
||||
'created_at' => $b->created_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureMember
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user() || ! $request->user()->isMember()) {
|
||||
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureProvider
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user() || ! $request->user()->isProvider()) {
|
||||
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
$middleware->trustProxies(at: ['*']);
|
||||
$middleware->alias([
|
||||
'admin' => \App\Http\Middleware\EnsureAdmin::class,
|
||||
'member' => \App\Http\Middleware\EnsureMember::class,
|
||||
'provider' => \App\Http\Middleware\EnsureProvider::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# graphify 知識圖譜操作筆記
|
||||
|
||||
graphify 把專案的程式碼、OpenSpec 文件、設定檔轉成可查詢的知識圖譜,輸出在 `graphify-out/`。
|
||||
該目錄被 `.gitignore` 忽略(`graph.json` 每次重建都整份重寫,進版控只會產生無意義 diff),換機器或清掉後需自行重建。
|
||||
|
||||
規則(先 query 再 grep、改完程式碼跑 `graphify update .`)見根目錄 `CLAUDE.md`,此處不重複。
|
||||
|
||||
## 建置紀錄(2026-10-29)
|
||||
|
||||
- 規模:2429 節點、4369 邊、約 117 個社群
|
||||
- 來源:253 個程式碼檔以 AST 抽取;218 份文件與 5 張圖片以子代理做語意抽取
|
||||
- 語意抽取沒有設 `GEMINI_API_KEY`,由 Claude Code 的子代理擔任 LLM
|
||||
|
||||
## 安裝與重建
|
||||
|
||||
```bash
|
||||
uv tool install --upgrade graphifyy # 安裝(Windows,graphify 位於 uv tool 目錄)
|
||||
/graphify . # 在 Claude Code 內完整建置
|
||||
graphify update . # 只重新處理變更的檔案(AST 免費,語意抽取才耗 token)
|
||||
graphify query "<問題>" # 查詢既有圖譜
|
||||
```
|
||||
|
||||
完整建置的語意抽取需派約 15 個子代理,**同時最多 4 個**(超過會被拒絕),要分批派。
|
||||
|
||||
## 已知限制與踩過的坑
|
||||
|
||||
| 項目 | 說明 | 處理 |
|
||||
|------|------|------|
|
||||
| enum case 引用 | AST 只看得到類別被引用,看不到 `BookingStatus::Pending` 這類 case 的使用處,所以 `Pending`/`Confirmed`/`Completed` 在圖上看起來孤立 | 查 enum case 的使用處改用 grep |
|
||||
| 腳本檔名 | 輔助腳本不可命名為 `ast.py`,會遮蔽標準函式庫造成循環匯入 | 換名稱,並用 `python -I` 執行 |
|
||||
| 二進位檔覆蓋率 | PNG 沒有「行數」,覆蓋率檢查會誤報 partial(`hero.png`) | 可忽略 |
|
||||
| 子代理只讀檔頭 | `.pi/agents/` 三個檔案只讀了檔頭,未寫入 manifest | 與 `.claude/agents/` 僅工具名稱寫法不同,已決定不補 |
|
||||
| 同端點邊合併 | YAML 中重複的鍵(如 `steps` 底下多個 `name`)會被合併成一條邊 | 正常現象,不是圖損壞 |
|
||||
|
||||
## 社群命名
|
||||
|
||||
graphify 預設以「連線最多的節點名稱 + cluster」命名社群。前 16 大社群已手動改名(依成員與所在資料夾判斷):
|
||||
|
||||
| 編號 | 名稱 |
|
||||
|------|------|
|
||||
| 0 | 預約狀態機與生命週期測試 |
|
||||
| 1 | 預約通知(Notification 類別) |
|
||||
| 2 | 早期 OpenSpec 設計文件(預約/評價/會員入口) |
|
||||
| 3 | 教練端前端(排程/評價/個人檔案) |
|
||||
| 4 | 預約即時訊息(Reverb 事件) |
|
||||
| 5 | 認證強化 spec(鎖定/OAuth state/稽核補強) |
|
||||
| 6 | 課程排程與預約 API |
|
||||
| 7 | 會員端前端(我的預約/課程列表) |
|
||||
| 8 | AuthController 認證 |
|
||||
| 9 | 前端與根目錄 package.json |
|
||||
| 10 | 課程詳情頁前端(評價/預約) |
|
||||
| 11 | 前端 Pinia 狀態與通知 |
|
||||
| 12 | 評價功能測試 |
|
||||
| 13 | Docker Compose 服務 |
|
||||
| 14 | Feature 測試基底與授權測試 |
|
||||
| 15 | 管理後台前端 |
|
||||
|
||||
社群編號可能隨重建而改變,此表只對 2026-10-29 這次建置有效;重建後需重新核對。
|
||||
@@ -13,15 +13,15 @@
|
||||
|
||||
| # | 優先 | 項目 | 是否走 OpenSpec | 建議 change 名稱 |
|
||||
|---|------|------|----------------|------------------|
|
||||
| H1 | 🔴 高 | member/provider 路由缺角色 middleware | 是 | `add-role-middleware` |
|
||||
| H2 | 🔴 高 | 正式環境 `APP_DEBUG` 與預設值風險 | 否(設定確認)+ 視情況改 `.env.example` | — |
|
||||
| H1 | ✅ 已完成(PR #71、#72) | member/provider 路由缺角色 middleware | 是 | `add-role-middleware` |
|
||||
| H2 | ✅ 已完成(2026-10-18 確認) | 正式環境 `APP_DEBUG` 與預設值風險 | 否(設定確認)+ 視情況改 `.env.example` | — |
|
||||
| M1 | 🟡 中 | AuthController 過大、無 FormRequest/Service 層 | 是 | `refactor-auth-controller` |
|
||||
| M2 | 🟡 中 | 預約名額邏輯重複 | 是 | `extract-booking-service` |
|
||||
| M3 | 🟡 中(已下修,見內文) | 通知派送的例外處理與失敗可觀測性 | 視範圍而定 | `harden-notification-dispatch` |
|
||||
| M4 | 🟡 中 | 前端 token 儲存不一致(admin 用 localStorage) | 是 | `unify-frontend-token-storage` |
|
||||
| M5 | 🟡 中 | CORS 在正式環境仍允許 localhost | 是(小) | `restrict-cors-by-env` |
|
||||
| M6 | 🟡 中 | CI 使用 SQLite,鎖定/超賣測試可能假綠 | 是 | `ci-mysql-test-job` |
|
||||
| M7 | 🟡 中 | 預約流程 `catch (RuntimeException)` 把例外訊息直接回給使用者 | 是(小) | `fix-booking-exception-handling` |
|
||||
| M7 | ✅ 已完成(PR #74、#75) | 預約流程 `catch (RuntimeException)` 把例外訊息直接回給使用者 | 是(小) | `fix-booking-exception-handling` |
|
||||
| M8 | 🟡 中(影響資料正確性,需並發才觸發) | 預約取消/確認的狀態在鎖之前讀取,並發下名額可能重複增減 | 是 | `fix-booking-state-race`(建議併入 M2) |
|
||||
|
||||
建議順序:H1 → H2 → M7 → M6 → M5 → M8 與 M2 合併處理 → M1 → M4 → M3(M3 已下修,可視驗證結果延後或關閉)。
|
||||
@@ -40,12 +40,12 @@
|
||||
- 目前只有 `ReviewController`、refresh 端點自己檢查角色,其餘端點無防護。
|
||||
|
||||
**待辦**
|
||||
- [ ] 新增 `EnsureMember`、`EnsureProvider` middleware(仿 `app/Http/Middleware/EnsureAdmin.php`),並在 `bootstrap/app.php` 註冊 alias
|
||||
- [ ] 套用到 `member`、`provider` 路由群組
|
||||
- [ ] 通知、即時訊息這類「member + provider 共用」群組維持現狀,但需確認 admin 是否應被排除
|
||||
- [ ] 新增 Feature 測試(仿 `tests/Feature/AdminEndpointAuthTest.php`):每個角色 × 每組路由,期望 403
|
||||
- [ ] 更新 spec:`provider-auth`、`admin-auth`;視需要新增 `role-authorization` spec
|
||||
- [ ] 更新 Swagger 註解(`app/Docs/*`)的 403 回應
|
||||
- [x] 新增 `EnsureMember`、`EnsureProvider` middleware(仿 `app/Http/Middleware/EnsureAdmin.php`),並在 `bootstrap/app.php` 註冊 alias
|
||||
- [x] 套用到 `member`、`provider` 路由群組
|
||||
- [ ] 通知、即時訊息這類「member + provider 共用」群組維持現狀,但需確認 admin 是否應被排除(未定案:本次維持現狀,admin 是否排除另案決定)
|
||||
- [x] 新增 Feature 測試(仿 `tests/Feature/AdminEndpointAuthTest.php`):每個角色 × 每組路由,期望 403
|
||||
- [x] 更新 spec:`provider-auth`、`admin-auth`;視需要新增 `role-authorization` spec(實際做法:新增 `role-authorization` 主規格,未修改 `provider-auth`、`admin-auth`)
|
||||
- [x] 更新 Swagger 註解(`app/Docs/*`)的 403 回應
|
||||
|
||||
**驗收條件(草案,需轉為 spec Scenario)**
|
||||
1. member 呼叫任一 `/api/provider/*` 回 403
|
||||
@@ -70,11 +70,11 @@
|
||||
若 VPS 上 `.env` 沿用相同值,錯誤頁會洩漏堆疊追蹤、環境變數與 SQL。
|
||||
|
||||
**待辦**
|
||||
- [ ] **需要你確認**:登入 VPS 檢查實際 `.env` 的 `APP_DEBUG`、`APP_ENV`(我無法從此環境確認)
|
||||
- [ ] 確認 `config:cache` 後的實際值:`docker compose exec app php artisan tinker --execute="dump(config('app.debug'));"`
|
||||
- [ ] `.env.example` 加註解說明正式環境必須 `APP_DEBUG=false`
|
||||
- [ ] 評估是否在 deploy.yml 加入啟動檢查:`APP_ENV=production` 且 `APP_DEBUG` 為 false,否則部署失敗
|
||||
- [ ] 本機 `.env` 中的 DB 密碼為弱密碼(`cfdivepass`),確認正式環境未使用
|
||||
- [x] **需要你確認**:登入 VPS 檢查實際 `.env` 的 `APP_DEBUG`、`APP_ENV`(我無法從此環境確認)(2026-10-18:`APP_DEBUG=false`、`APP_ENV=production`)
|
||||
- [x] 確認 `config:cache` 後的實際值:`docker compose exec app php artisan tinker --execute="dump(config('app.debug'));"`
|
||||
- [x] `.env.example` 加註解說明正式環境必須 `APP_DEBUG=false`
|
||||
- [x] 評估是否在 deploy.yml 加入啟動檢查:`APP_ENV=production` 且 `APP_DEBUG` 為 false,否則部署失敗(2026-10-18 決定暫不做:正式環境已確認,需要時另開 change)
|
||||
- [x] 本機 `.env` 中的 DB 密碼為弱密碼(`cfdivepass`),確認正式環境未使用(2026-10-18:正式環境未使用預設密碼,且 MySQL 未對外開 port)
|
||||
|
||||
**備註**
|
||||
這項大多是設定確認,不一定需要 OpenSpec change;若加入部署檢查則屬 `env-cloud-annotations` spec 範圍。
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# CFDivePlatform K8s 本機模擬待辦
|
||||
|
||||
> 記錄日期:2026-10-29
|
||||
> 狀態:**僅記錄,尚未規劃、尚未動工**(Hank 指示「先記待辦」)。
|
||||
> 範圍:在本機以 Docker Desktop Kubernetes 模擬部署,驗證 manifest 與流程;`docker-compose.yml` 維持本地開發用,不更動。
|
||||
> 實作原則:涉及行為或部署流程變更,先走 OpenSpec(`/opsx:propose`);本文的決策與清單是規劃輸入,不是 spec。
|
||||
> 搭配閱讀:`docs/analysis/2026-10-14-project-review-todo.md`(M6、M8 與本項有相依)。
|
||||
|
||||
---
|
||||
|
||||
## 已做的決策(2026-10-29 討論)
|
||||
|
||||
| 項目 | 決策 | 理由 |
|
||||
|---|---|---|
|
||||
| 模擬環境 | Docker Desktop 內建 Kubernetes,**Kubeadm(單節點)模式** | 目的是驗證 manifest 與部署流程,不需要多節點;設定最簡單 |
|
||||
| 與 Compose 的關係 | Compose 照舊作為本地開發環境,K8s 另放獨立目錄(暫定 `k8s/`) | 兩邊互不影響,避免動到現有 `deploy.yml` |
|
||||
| image | 沿用現有 `Dockerfile` 的 `app`、`web` 兩個 target | 與正式環境的 compose(不含 `compose.override.yml`)同一種形態,落差最小 |
|
||||
| 隔離 | 每個專案一個 namespace | 另一個 .NET 專案會一起模擬 |
|
||||
| 上機器的時機 | 本機模擬驗證完成後,再決定實際叢集 | VPS 資源不足,見下節 |
|
||||
|
||||
## 為什麼不直接在現有 VPS 上裝 K3s
|
||||
|
||||
2026-10-29 實測:2 核、1.9GB RAM(可用約 770MB)、磁碟剩 9.2GB(72%),且已跑 13 個容器(含 Gitea、Gitea runner、Nginx Proxy Manager)。
|
||||
K3s 單節點自身約需 0.5–1GB,會讓 OOM 風險落在現有正式站與 CI。若之後要上實體叢集,建議另租至少 2 核 4GB、40GB 磁碟的機器。
|
||||
|
||||
---
|
||||
|
||||
## 總覽
|
||||
|
||||
| # | 項目 | 依賴 | 是否走 OpenSpec |
|
||||
|---|------|------|----------------|
|
||||
| K1 | 決定 Ingress controller | — | 否(決策) |
|
||||
| K2 | 盤點 CFDive 的 K8s 資源清單 | — | 否(盤點) |
|
||||
| K3 | 撰寫 CFDive manifest(`k8s/`) | K1、K2 | 是 |
|
||||
| K4 | 本機部署與驗證 | K3 | 是(含驗證項目) |
|
||||
| K5 | 另一個 .NET 專案納入同一叢集 | K1、K4 | 另案(該專案的 repo) |
|
||||
| K6 | 上傳檔案切 S3/R2 | 外部帳號 | 是(既有暫緩項目) |
|
||||
| K7 | 上實體叢集前的差距清單 | K4 | 否(文件) |
|
||||
|
||||
---
|
||||
|
||||
## K1 決定 Ingress controller
|
||||
|
||||
**現況**
|
||||
Docker Desktop 沒有內建 Ingress。K3s 預設是 Traefik;原本考慮的 ingress-nginx,**據我所知 Kubernetes 社群已宣布它在 2026 年 3 月退役,此點為記憶,尚未查證**。
|
||||
|
||||
**待辦**
|
||||
- [ ] 查證 ingress-nginx 目前的維護狀態(官方公告)
|
||||
- [ ] 在 Traefik 與 Gateway API 實作之間決定(暫定傾向 Traefik:與 K3s 預設一致,之後搬 K3s 的 manifest 改動最少)
|
||||
- [ ] 確認 Reverb(WebSocket)在所選 Ingress 上的路由與逾時設定
|
||||
|
||||
## K2 盤點 CFDive 的 K8s 資源清單
|
||||
|
||||
**現況(依 `docker-compose.yml` 服務名稱,2026-10-29 核對)**
|
||||
`app`、`nginx`、`frontend`、`db`、`scheduler`、`queue-worker`、`redis`、`reverb`。volumes 為 `mysql-data`、`redis-data`、`storage-app-public`。
|
||||
|
||||
**暫定對應(未驗證)**
|
||||
- `app`、`nginx`、`frontend`、`queue-worker`、`reverb`:各一個 Deployment+必要的 Service
|
||||
- `scheduler`:Deployment,**副本固定 1**(多副本會讓排程重複執行)
|
||||
- migration/cache/swagger:目前在 `deploy.yml`,需改為 Job(或 initContainer)
|
||||
- `.env`:拆成 ConfigMap(非機密)與 Secret(機密);`env_file:` 在 K8s 沒有對等物
|
||||
- `db`、`redis`:本機模擬時先決定放叢集內,或沿用 Compose 容器經 `host.docker.internal` 連線
|
||||
|
||||
**待辦**
|
||||
- [ ] 逐一核對各服務的 port、healthcheck、啟動指令、依賴順序
|
||||
- [ ] 決定 MySQL/Redis 放叢集內還是沿用 Compose(叢集內較貼近正式環境,Compose 較省事)
|
||||
- [ ] 注意 `tests/bootstrap.php` 的覆蓋清單:擴大環境變數注入範圍時需同步檢查(見專案記憶中 `env_file` 事件)
|
||||
|
||||
## K3 撰寫 CFDive manifest
|
||||
|
||||
**待辦**
|
||||
- [ ] 目錄結構(`k8s/` 下用 Kustomize 或純 YAML,擇一)
|
||||
- [ ] 各 Deployment 的探針(readiness/liveness)對應現有 healthcheck
|
||||
- [ ] 加上 `requests`/`limits`,總量抓接近目標機器(例如 4GB 節點),避免本機資源充裕而漏掉 OOM 問題
|
||||
- [ ] Secret 結構與正式環境相同,本機只用假值
|
||||
- [ ] 環境值用正式設定:`APP_ENV=production`、`APP_DEBUG=false`、MySQL `8.0`、Redis `alpine`
|
||||
- [ ] 不帶入 `compose.override.yml` 的 bind mount
|
||||
|
||||
## K4 本機部署與驗證
|
||||
|
||||
**待辦**
|
||||
- [ ] Docker Desktop 啟用 Kubernetes(Kubeadm 模式),確認 `kubectl` 指向正確 context
|
||||
- [ ] 本機 build `app`、`web`、`frontend` image,`imagePullPolicy` 設為 `IfNotPresent`(或 `Never`)
|
||||
- [ ] 驗證:登入、預約、評價主流程;Reverb 即時訊息(WebSocket 經 Ingress);queue job 實際被消化;scheduler 只有一個實例
|
||||
- [ ] 驗證:migration Job 失敗時的行為(不應讓新版本 Pod 起來)
|
||||
- [ ] 驗證:滾動更新與回滾(`kubectl rollout`)
|
||||
- [ ] 記錄與 Compose 行為不一致之處
|
||||
|
||||
## K5 另一個 .NET 專案納入同一叢集
|
||||
|
||||
**注意事項(屬該專案 repo,這裡只記備忘)**
|
||||
- ASP.NET Core(.NET 8 以後)官方 image 預設非 root、監聽 8080,Service 的 `targetPort` 要對
|
||||
- 巢狀設定以環境變數覆蓋時用雙底線,例如 `ConnectionStrings__Default`
|
||||
- 需自行提供健康檢查端點供探針使用
|
||||
- EF migration 用 Job,不要在應用啟動時跑
|
||||
- 多副本時需處理 Data Protection 金鑰共用,否則副本間 cookie/antiforgery 互相無法解密
|
||||
|
||||
**待辦**
|
||||
- [ ] 兩個專案用不同 namespace 與 hostname(例如 `cfdive.localhost`、`other.localhost`),共用同一個 Ingress controller
|
||||
|
||||
## K6 上傳檔案切 S3/R2
|
||||
|
||||
**現況**
|
||||
`storage:migrate-to-s3` 指令與 `FILESYSTEM_DISK` 設定已完成(PR #55、#56),但 R2/S3 帳號尚未申請,VPS 仍是 `FILESYSTEM_DISK=local`,上傳檔案存在具名 volume `storage-app-public`。
|
||||
多副本時 ReadWriteMany 儲存是最大阻礙;**本機 hostpath 單機無法代表這個問題**,所以這項要在上實體叢集前完成。
|
||||
|
||||
**待辦**
|
||||
- [ ] 取得 S3 相容服務帳號後,另開 OpenSpec change 接續(不要回頭改已歸檔的 `cloud-ready-s3-storage`)
|
||||
|
||||
## K7 上實體叢集前的差距清單
|
||||
|
||||
本機模擬**無法**驗證的項目,上實體叢集後需另外確認:
|
||||
|
||||
| 項目 | 原因 |
|
||||
|---|---|
|
||||
| 多節點調度、節點故障 | Kubeadm 模式為單節點 |
|
||||
| 檔案儲存的 ReadWriteMany | 本機 hostpath 僅單機 |
|
||||
| TLS/憑證、真實網域 | 本機用 `*.localhost`,無 Let's Encrypt |
|
||||
| 真實流量與資源競爭 | 本機資源充裕 |
|
||||
| Gitea Actions 到叢集的部署流程 | 需等有目標叢集;另需 image registry(目前 image 在 VPS 上 build,是否有 registry 未確認) |
|
||||
|
||||
**待辦**
|
||||
- [ ] 確認 VPS 現行部署是否有 image registry;沒有的話,上叢集前要決定放哪裡
|
||||
- [ ] 決定實體叢集(另租 K3s 機器,或雲端託管)
|
||||
|
||||
---
|
||||
|
||||
## 開放問題
|
||||
|
||||
- 本機機器規格(Docker Desktop 分配給 WSL2 的記憶體)未確認;兩個專案加 MySQL/Redis 同時跑,建議 8GB 以上
|
||||
- Kubeadm 模式下本機 image 是否可直接被 Pod 使用,依記憶判斷為可,**尚未在實際版本驗證**
|
||||
- Ingress 最終選哪個(K1)
|
||||
+23
-23
@@ -4,41 +4,41 @@
|
||||
|
||||
## 1. Member 角色 middleware
|
||||
|
||||
- [ ] 1.1 [後端] 新增 `app/Http/Middleware/EnsureMember.php`(結構同 `EnsureAdmin`,改用 `isMember()`),並在 `bootstrap/app.php` 的 `$middleware->alias([...])` 加上 `'member' => \App\Http\Middleware\EnsureMember::class`;驗證:1.3 起的測試通過
|
||||
- [ ] 1.2 [後端] `routes/api.php` 的 member 群組改為 `Route::middleware(['auth:sanctum', 'member'])->prefix('member')`;驗證:`php artisan route:list --path=api/member` 顯示 `member` middleware,且 `register`、`login` 不受影響
|
||||
- [ ] 1.3 [整合測試] `test_member_can_access_member_routes`:member 呼叫 `GET /api/member/profile`、`GET /api/member/bookings` 回 200(對應 Scenario「member 可存取 member 路由」)
|
||||
- [ ] 1.4 [整合測試] `test_provider_is_rejected_on_member_routes`:provider 呼叫 `GET /api/member/profile`、`GET /api/member/bookings`、`POST /api/member/bookings`、`POST /api/member/reviews`,皆回 403,並斷言 body 為 `{ status: false, message: "無權限存取" }`;`POST /api/member/reviews` 另斷言 `reviews` 筆數不變(對應 Scenario「provider 呼叫 member 路由被拒絕」)
|
||||
- [ ] 1.5 [整合測試] `test_admin_is_rejected_on_member_routes`:admin 呼叫 `GET /api/member/profile`、`GET /api/member/bookings`、`POST /api/member/bookings`,皆回 403 與相同 body(對應 Scenario「admin 呼叫 member 路由被拒絕」)
|
||||
- [ ] 1.6 [整合測試] `test_non_member_booking_attempt_creates_no_booking`:建立可預約時段,provider 帶合法 `schedule_id` 呼叫 `POST /api/member/bookings`,回 403,`bookings` 筆數與 `current_participants` 不變(對應 Scenario「非 member 建立預約不會產生資料」)
|
||||
- [ ] 1.7 [整合測試] `test_unauthenticated_request_is_rejected_on_member_routes`:不帶 token 呼叫 `GET /api/member/profile`、`GET /api/member/bookings` 回 401(對應 Scenario「未登入呼叫 member 路由」)
|
||||
- [x] 1.1 [後端] 新增 `app/Http/Middleware/EnsureMember.php`(結構同 `EnsureAdmin`,改用 `isMember()`),並在 `bootstrap/app.php` 的 `$middleware->alias([...])` 加上 `'member' => \App\Http\Middleware\EnsureMember::class`;驗證:1.3 起的測試通過
|
||||
- [x] 1.2 [後端] `routes/api.php` 的 member 群組改為 `Route::middleware(['auth:sanctum', 'member'])->prefix('member')`;驗證:`php artisan route:list --path=api/member` 顯示 `member` middleware,且 `register`、`login` 不受影響
|
||||
- [x] 1.3 [整合測試] `test_member_can_access_member_routes`:member 呼叫 `GET /api/member/profile`、`GET /api/member/bookings` 回 200(對應 Scenario「member 可存取 member 路由」)
|
||||
- [x] 1.4 [整合測試] `test_provider_is_rejected_on_member_routes`:provider 呼叫 `GET /api/member/profile`、`GET /api/member/bookings`、`POST /api/member/bookings`、`POST /api/member/reviews`,皆回 403,並斷言 body 為 `{ status: false, message: "無權限存取" }`;`POST /api/member/reviews` 另斷言 `reviews` 筆數不變(對應 Scenario「provider 呼叫 member 路由被拒絕」)
|
||||
- [x] 1.5 [整合測試] `test_admin_is_rejected_on_member_routes`:admin 呼叫 `GET /api/member/profile`、`GET /api/member/bookings`、`POST /api/member/bookings`,皆回 403 與相同 body(對應 Scenario「admin 呼叫 member 路由被拒絕」)
|
||||
- [x] 1.6 [整合測試] `test_non_member_booking_attempt_creates_no_booking`:建立可預約時段,provider 帶合法 `schedule_id` 呼叫 `POST /api/member/bookings`,回 403,`bookings` 筆數與 `current_participants` 不變(對應 Scenario「非 member 建立預約不會產生資料」)
|
||||
- [x] 1.7 [整合測試] `test_unauthenticated_request_is_rejected_on_member_routes`:不帶 token 呼叫 `GET /api/member/profile`、`GET /api/member/bookings` 回 401(對應 Scenario「未登入呼叫 member 路由」)
|
||||
|
||||
## 2. Provider 角色 middleware
|
||||
|
||||
- [ ] 2.1 [後端] 新增 `app/Http/Middleware/EnsureProvider.php`(結構同 `EnsureAdmin`,改用 `isProvider()`,**不檢查** `verification_status`),並在 `bootstrap/app.php` 加上 `'provider' => \App\Http\Middleware\EnsureProvider::class`;驗證:2.3 起的測試通過
|
||||
- [ ] 2.2 [後端] `routes/api.php` 的 provider 群組改為 `Route::middleware(['auth:sanctum', 'provider'])->prefix('provider')`;驗證:`php artisan route:list --path=api/provider` 顯示 `provider` middleware,且 `register`、`login` 不受影響
|
||||
- [ ] 2.3 [整合測試] `test_provider_can_access_provider_routes`:provider(含 `providerProfile`)呼叫 `GET /api/provider/profile`、`/offers`、`/schedules`、`/bookings` 回 200(對應 Scenario「provider 可存取 provider 路由」)
|
||||
- [ ] 2.4 [整合測試] `test_unapproved_provider_can_access_verification`:`verification_status` 為 `unsubmitted`、`pending`、`rejected` 的 provider 呼叫 `GET /api/provider/verification` 回 200(對應 Scenario「未通過審核的 provider 仍可進入 provider 路由」)
|
||||
- [ ] 2.5 [整合測試] `test_member_is_rejected_on_provider_routes`:member 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`、`POST /api/provider/offers`、`POST /api/provider/schedules`、`GET /api/provider/bookings`、`GET /api/provider/verification`,皆回 403 與 `{ status: false, message: "無權限存取" }`;`POST /api/provider/schedules` 另斷言 `course_schedules` 筆數不變(對應 Scenario「member 呼叫 provider 路由被拒絕」)
|
||||
- [ ] 2.6 [整合測試] `test_admin_is_rejected_on_provider_routes`:admin 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`、`POST /api/provider/offers`、`GET /api/provider/bookings`,皆回 403 與相同 body(對應 Scenario「admin 呼叫 provider 路由被拒絕」)
|
||||
- [ ] 2.7 [整合測試] `test_non_provider_offer_attempt_creates_no_offer`:member 帶通過驗證的完整課程資料呼叫 `POST /api/provider/offers`,回 403,`diving_offers` 筆數不變(對應 Scenario「非 provider 建立課程不會產生資料」)
|
||||
- [ ] 2.8 [整合測試] `test_unauthenticated_request_is_rejected_on_provider_routes`:不帶 token 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers` 回 401(對應 Scenario「未登入呼叫 provider 路由」)
|
||||
- [x] 2.1 [後端] 新增 `app/Http/Middleware/EnsureProvider.php`(結構同 `EnsureAdmin`,改用 `isProvider()`,**不檢查** `verification_status`),並在 `bootstrap/app.php` 加上 `'provider' => \App\Http\Middleware\EnsureProvider::class`;驗證:2.3 起的測試通過
|
||||
- [x] 2.2 [後端] `routes/api.php` 的 provider 群組改為 `Route::middleware(['auth:sanctum', 'provider'])->prefix('provider')`;驗證:`php artisan route:list --path=api/provider` 顯示 `provider` middleware,且 `register`、`login` 不受影響
|
||||
- [x] 2.3 [整合測試] `test_provider_can_access_provider_routes`:provider(含 `providerProfile`)呼叫 `GET /api/provider/profile`、`/offers`、`/schedules`、`/bookings` 回 200(對應 Scenario「provider 可存取 provider 路由」)
|
||||
- [x] 2.4 [整合測試] `test_unapproved_provider_can_access_verification`:`verification_status` 為 `unsubmitted`、`pending`、`rejected` 的 provider 呼叫 `GET /api/provider/verification` 回 200(對應 Scenario「未通過審核的 provider 仍可進入 provider 路由」)
|
||||
- [x] 2.5 [整合測試] `test_member_is_rejected_on_provider_routes`:member 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`、`POST /api/provider/offers`、`POST /api/provider/schedules`、`GET /api/provider/bookings`、`GET /api/provider/verification`,皆回 403 與 `{ status: false, message: "無權限存取" }`;`POST /api/provider/schedules` 另斷言 `course_schedules` 筆數不變(對應 Scenario「member 呼叫 provider 路由被拒絕」)
|
||||
- [x] 2.6 [整合測試] `test_admin_is_rejected_on_provider_routes`:admin 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`、`POST /api/provider/offers`、`GET /api/provider/bookings`,皆回 403 與相同 body(對應 Scenario「admin 呼叫 provider 路由被拒絕」)
|
||||
- [x] 2.7 [整合測試] `test_non_provider_offer_attempt_creates_no_offer`:member 帶通過驗證的完整課程資料呼叫 `POST /api/provider/offers`,回 403,`diving_offers` 筆數不變(對應 Scenario「非 provider 建立課程不會產生資料」)
|
||||
- [x] 2.8 [整合測試] `test_unauthenticated_request_is_rejected_on_provider_routes`:不帶 token 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers` 回 401(對應 Scenario「未登入呼叫 provider 路由」)
|
||||
|
||||
## 3. 共用路由與既有規格的回歸
|
||||
|
||||
- [ ] 3.1 [整合測試] `test_member_and_provider_can_read_notifications`:member 與 provider 各自呼叫 `GET /api/notifications` 回 200(對應 Scenario「member 與 provider 皆可讀取通知」)
|
||||
- [ ] 3.2 [整合測試] `test_member_and_provider_can_read_unread_message_counts`:member 與 provider 各自呼叫 `GET /api/bookings/messages/unread-counts` 回 200(對應 Scenario「member 與 provider 皆可讀取未讀訊息數」)
|
||||
- [ ] 3.3 [整合測試] 執行 `php artisan test --filter=BookingListTest`,確認 `feature-test-coverage`「預約列表端點測試覆蓋」的四個 Scenario(member 隔離、provider 隔離、admin 全部、未認證 401)在新 middleware 下仍全綠(「非 member/非 provider 於 middleware 層 403」由 1.4、1.5、2.5、2.6 覆蓋);同時更新 `tests/Feature/BookingListTest.php` 第 19–24 行 docblock 中「無 role check」「不靠 middleware role 強制」的過時描述
|
||||
- [x] 3.1 [整合測試] `test_member_and_provider_can_read_notifications`:member 與 provider 各自呼叫 `GET /api/notifications` 回 200(對應 Scenario「member 與 provider 皆可讀取通知」)
|
||||
- [x] 3.2 [整合測試] `test_member_and_provider_can_read_unread_message_counts`:member 與 provider 各自呼叫 `GET /api/bookings/messages/unread-counts` 回 200(對應 Scenario「member 與 provider 皆可讀取未讀訊息數」)
|
||||
- [x] 3.3 [整合測試] 執行 `php artisan test --filter=BookingListTest`,確認 `feature-test-coverage`「預約列表端點測試覆蓋」的四個 Scenario(member 隔離、provider 隔離、admin 全部、未認證 401)在新 middleware 下仍全綠(「非 member/非 provider 於 middleware 層 403」由 1.4、1.5、2.5、2.6 覆蓋);同時更新 `tests/Feature/BookingListTest.php` 第 19–24 行 docblock 中「無 role check」「不靠 middleware role 強制」的過時描述
|
||||
|
||||
## 4. 全套回歸、Swagger 與格式
|
||||
|
||||
- [ ] 4.1 [整合測試] 執行完整 `php artisan test`(需在容器內,因 `CourseImageTest` 需要 GD);若有既有測試因角色錯誤改回 403,判斷是測試用錯角色(修正測試並在 commit 說明)還是依賴跨角色行為(停下回報)
|
||||
- [ ] 4.2 [後端] 核對 `app/Docs/MemberApiDoc.php`、`app/Docs/ProviderApiDoc.php` 每個端點的 403 回應描述涵蓋「角色不符」;缺的補上,並執行 `php artisan l5-swagger:generate` 確認無錯誤
|
||||
- [ ] 4.3 [後端] 執行 `./vendor/bin/pint --test app/Http/Middleware/EnsureMember.php app/Http/Middleware/EnsureProvider.php bootstrap/app.php routes/api.php tests/Feature/RoleAuthorizationTest.php`(以及 4.1/4.2 若有修改的檔案),確認無格式問題
|
||||
- [x] 4.1 [整合測試] 執行完整 `php artisan test`(需在容器內,因 `CourseImageTest` 需要 GD);若有既有測試因角色錯誤改回 403,判斷是測試用錯角色(修正測試並在 commit 說明)還是依賴跨角色行為(停下回報)
|
||||
- [x] 4.2 [後端] 核對 `app/Docs/MemberApiDoc.php`、`app/Docs/ProviderApiDoc.php` 每個端點的 403 回應描述涵蓋「角色不符」;缺的補上,並執行 `php artisan l5-swagger:generate` 確認無錯誤
|
||||
- [x] 4.3 [後端] 執行 `./vendor/bin/pint --test app/Http/Middleware/EnsureMember.php app/Http/Middleware/EnsureProvider.php bootstrap/app.php routes/api.php tests/Feature/RoleAuthorizationTest.php`(以及 4.1/4.2 若有修改的檔案),確認無格式問題
|
||||
|
||||
## 5. 手動驗證
|
||||
|
||||
- [ ] 5.1 [整合測試](手動,非自動化測試)本機 Docker 環境以 member token 呼叫 `POST /api/provider/offers`,以 provider token 呼叫 `POST /api/member/bookings`,確認兩者皆回 403
|
||||
- [ ] 5.2 [整合測試](手動,非自動化測試)本機前端:會員端登入後瀏覽「我的預約」、教練端登入後瀏覽課程/時段/預約/審核頁,確認功能正常、沒有出現 403
|
||||
- [x] 5.1 [整合測試](手動,非自動化測試)本機 Docker 環境以 member token 呼叫 `POST /api/provider/offers`,以 provider token 呼叫 `POST /api/member/bookings`,確認兩者皆回 403
|
||||
- [x] 5.2 [整合測試](手動,非自動化測試)本機前端:會員端登入後瀏覽「我的預約」、教練端登入後瀏覽課程/時段/預約/審核頁,確認功能正常、沒有出現 403
|
||||
|
||||
## Workflow follow-up
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-18
|
||||
@@ -0,0 +1,65 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
背景見 proposal.md「Why」。現況:
|
||||
|
||||
- `MemberBookingController::store` 的 `DB::transaction` 閉包拋 `\RuntimeException('名額不足,請重新選擇')`、`\RuntimeException('您已預約此時段')`;`ProviderBookingController::confirm` 拋 `\RuntimeException('名額不足,無法確認此預約')`。兩處都以 `catch (\RuntimeException $e)` 回 422 + `$e->getMessage()`
|
||||
- `bootstrap/app.php` 只有 `Sentry\Laravel\Integration::handles($exceptions)`,沒有自訂 render;未被攔截的例外由 Laravel 預設處理器回應,`APP_DEBUG=false` 時 JSON body 為 `{"message":"Server Error"}`
|
||||
- 全專案 grep `RuntimeException` 只有這兩個 Controller;其他 `catch (\Throwable)` 都只包通知派送(記 log 後繼續),不在本次範圍
|
||||
- `phpunit.xml` 沒有設定 `APP_DEBUG`,測試會沿用 `.env` 的值(本機為 true),因此驗證「不洩漏細節」的測試必須自行設定 `app.debug = false`
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals**
|
||||
- 只有刻意拋出的業務例外會變成 422;其他例外一律走 Laravel 例外處理器
|
||||
|
||||
**Non-Goals**
|
||||
- 不統一全站 500 的 JSON 格式(不在 `withExceptions` 加自訂 render);500 body 沒有 `status` 欄位,與其他端點未攔截的 500 一致
|
||||
- 不處理 M8(鎖之前讀取狀態的併發問題)與 M2(抽出 `BookingService`);本次只改例外型別與 catch 範圍
|
||||
- 不調整前端;資料庫錯誤時前端會顯示 `Server Error`
|
||||
- 不拆分 `booking-lifecycle` 中超過 500 字元的「預約狀態機」requirement(不在本次修改範圍,需要時另開 change)
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1:新增窄型業務例外 `App\Exceptions\BookingRuleViolationException`
|
||||
- 放在新目錄 `app/Exceptions/`(目前不存在,Laravel 11 慣例位置)
|
||||
- 繼承 `\RuntimeException`。安全性來自 Controller 只 catch 這個具體類別,而不是父類別的選擇;繼承 `RuntimeException` 讓語意(執行期業務規則失敗)不變
|
||||
- 訊息只由程式內字串常值傳入,不包含使用者輸入或其他例外的訊息,因此可以安全回給使用者
|
||||
- **替代方案**:
|
||||
- 在 catch 內判斷 `instanceof QueryException` 後重新拋出:仍會漏接其他非業務的 `RuntimeException`(例如 `PDOException` 以外的驅動錯誤),是黑名單做法,不採用
|
||||
- 交易閉包回傳錯誤碼而非拋例外:拋例外才能觸發 `DB::transaction` 的 rollback,改回傳值需要手動處理 rollback,不採用
|
||||
|
||||
### D2:非業務例外不在 Controller 攔截
|
||||
- 依使用者決定:交給 Laravel 例外處理器。好處是 Sentry 透過既有 `Integration::handles` 自動收到,不需在 Controller 呼叫 `report()`
|
||||
- **替代方案**:Controller 內 catch `\Throwable` + `report()` + 專案格式 500,或在 `withExceptions` 全域統一 JSON 500 格式。前者造成兩個端點格式與其他端點不一致;後者影響全站所有端點,範圍超出 M7。兩者皆不採用
|
||||
|
||||
### D3:測試方式
|
||||
- **觸發資料庫例外**:在測試內註冊 Eloquent model 事件,於交易內拋出 `Illuminate\Database\QueryException`,其 SQL 字串含資料表與欄位名稱。這樣能真的經過 Controller 的 catch 路徑,不需 mock DB facade
|
||||
- 例外必須發生在交易內**已有一筆寫入之後**,否則「資料沒有變動」在沒有 rollback 時也會成立,無法證明 rollback:建立預約用 `Booking::created`(INSERT 之後);確認預約用 `CourseSchedule::updating`(預約狀態已更新、`increment` 時觸發;Laravel 11 的 `Model::increment` 會觸發 `updating` 事件)
|
||||
- **斷言 500 body**:以 `assertExactJson(['message' => 'Server Error'])` 鎖住完整 body,避免空 body 也讓「不含 SQL」的斷言通過
|
||||
- **觸發 store 交易內的名額不足**:此分支只在併發時發生(交易前已有相同檢查)。測試以 `CourseSchedule::retrieved` 事件,在交易內第二次讀取時段時把 `current_participants` 設為滿額,模擬另一筆預約搶先佔位
|
||||
- **斷言回報**:以 `Exceptions::fake()` 搭配 `Exceptions::assertReported(QueryException::class)`;fake 仍委派原處理器 render,因此可同時斷言 500 回應。Sentry 本身是否收到屬於整合設定,CI 無法驗證,以「例外有被回報」作為可測量的替代
|
||||
- **斷言不洩漏**:設定 `config(['app.debug' => false])` 後,斷言 body 不含 `insert into`/`update`、`bookings` 與測試用 SQL 中的欄位名稱
|
||||
- **業務失敗的 422**:以 `assertExactJson` 斷言完整 body,確保訊息文字沒有被改動;並在 `Exceptions::fake()` 下斷言 `Exceptions::assertNotReported(BookingRuleViolationException::class)`,業務失敗不應出現在 Sentry
|
||||
- **變異驗證**:暫時把 catch 改回 `\RuntimeException`,確認 500 測試會失敗。這是本機手動步驟、不提交,放在 tasks 的手動驗證 group,不算 Scenario 覆蓋
|
||||
|
||||
## 安全確認
|
||||
|
||||
- **輸入驗證**:沿用既有 `$request->validate()`,不變;沒有拼接 SQL 或 shell
|
||||
- **資料庫存取**:沿用 Eloquent/Query Builder 與 `lockForUpdate`,不變;不新增查詢,無 N+1 變化
|
||||
- **權限**:路由不變。`POST /api/member/bookings` 掛 `auth:sanctum` + `member` middleware;`PUT /api/provider/bookings/{id}/confirm` 掛 `auth:sanctum` + `provider` middleware,Controller 內 `authorizeProvider()` 檢查預約屬於呼叫者的課程。本次不改任何權限邏輯
|
||||
- **資訊洩漏**:本次的主要目的;回給使用者的訊息只剩 D1 中的程式內常值,以及 Laravel 的通用 500 訊息
|
||||
- **正式環境前提**:500 body 不洩漏細節依賴 `APP_DEBUG=false`;2026-10-18 已在 VPS 確認(H2)
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [資料庫錯誤的 HTTP 狀態由 422 改為 500,前端與監控會看到更多 5xx] → 這是正確分類;目前前端都以 `e.response?.data?.message` 顯示,不會壞掉,只是文字變成 `Server Error`
|
||||
- [之後有人在交易閉包內又拋一般 `RuntimeException` 表示業務失敗,會變成 500] → 測試以 `assertExactJson` 鎖住三個業務訊息的 422;新增業務規則時必須使用 `BookingRuleViolationException`
|
||||
- [死結、逾時這類可重試的暫時性錯誤,使用者只看到 `Server Error`,沒有「請稍後重試」的提示] → 本次接受;若之後要區分可重試錯誤,屬於全站錯誤格式的另一個 change
|
||||
- [`APP_DEBUG=true` 的環境(本機、若有預備環境)500 body 仍會含例外訊息與 SQL] → 這是 Laravel debug 模式的設計行為,只有正式環境(已確認 `APP_DEBUG=false`)保證不洩漏;任何對外開放的環境都必須設為 false
|
||||
- [SQLite 測試無法重現真實的死結或逾時] → 測試只需要證明「任何 `QueryException` 都會走 500」,例外類型本身與資料庫驅動無關,SQLite 足以驗證分類邏輯
|
||||
|
||||
## Migration Plan
|
||||
|
||||
純程式碼變更,無 migration、無設定變更。部署後若需回滾,revert 這個 PR 即可。
|
||||
@@ -0,0 +1,29 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
`MemberBookingController::store` 與 `ProviderBookingController::confirm` 用 `catch (\RuntimeException $e)` 把交易內的業務失敗轉成 422,並直接回傳 `$e->getMessage()`。`Illuminate\Database\QueryException` 經由 `PDOException` 也是 `RuntimeException` 的子類別,因此交易內的資料庫錯誤(死結、逾時、約束違反、連線中斷)會被同一個 catch 接住:SQL 與資料表結構被當成 422 訊息回給使用者(資訊洩漏),且不經過 Laravel 例外處理器、不會送到 Sentry,營運端看不到伺服器故障(待辦 M7,`docs/analysis/2026-10-14-project-review-todo.md`)。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增專用的業務例外 `BookingRuleViolationException`;兩個預約交易閉包內的業務規則失敗(名額不足、重複預約)改拋此例外
|
||||
- 兩個 Controller 只 catch `BookingRuleViolationException` 並回 422 與其訊息;其他例外(含 `QueryException`)不再被攔截,交給 Laravel 例外處理器產生 500 並回報(既有 `Sentry\Laravel\Integration::handles` 會送到 Sentry)
|
||||
- 業務失敗的 422 狀態碼與訊息文字維持不變
|
||||
- 資料庫錯誤的回應由「422 + 原始例外訊息」改為「500 + Laravel 預設通用訊息」(`APP_DEBUG=false` 時 body 為 `{"message":"Server Error"}`)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
(無)
|
||||
|
||||
### Modified Capabilities
|
||||
- `booking-lifecycle`:新增「預約交易錯誤分類」requirement,明定業務規則失敗回 422 與既有訊息、非業務例外回 500 且不洩漏資料庫細節並被回報
|
||||
|
||||
## Impact
|
||||
|
||||
- **程式碼**:新增 `app/Exceptions/BookingRuleViolationException.php`;修改 `app/Http/Controllers/API/MemberBookingController.php`(`store`)、`app/Http/Controllers/API/ProviderBookingController.php`(`confirm`)
|
||||
- **API 回應格式**:業務失敗(422)不變。資料庫錯誤的回應由 422 改為 500,body 為 Laravel 預設格式 `{"message":"Server Error"}`,**不含**專案慣例的 `status` 欄位——這是刻意選擇(交給 Laravel 處理、不另寫轉換),與其他端點未被攔截的 500 一致
|
||||
- **前端**:`CourseDetailView.vue`、`coach/BookingManagerView.vue` 以 `e.response?.data?.message` 顯示錯誤;資料庫錯誤時會顯示 `Server Error`(原本顯示 SQL 訊息),不需修改前端
|
||||
- **監控**:預約交易內的資料庫錯誤開始出現在 Sentry
|
||||
- **測試**:新增 Feature 測試;既有 `BookingOversellTest`、`BookingLifecycleTest` 的 422 行為不變
|
||||
- **相依、migration、路由、Swagger**:無變更(`app/Docs/` 目前沒有記錄這兩個端點的 500,維持不新增)
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 預約交易錯誤分類
|
||||
建立預約(`POST /api/member/bookings`)與確認預約(`PUT /api/provider/bookings/{id}/confirm`)的交易中,系統 SHALL 區分業務規則失敗與伺服器端故障:業務規則失敗(名額不足、重複預約)回傳 422 與既定訊息;其他任何例外(含資料庫錯誤)MUST NOT 以 422 回傳,MUST 回傳 500,回應內容不得包含 SQL、資料表或欄位名稱,且該例外 MUST 交由例外回報機制記錄(正式環境會送至 Sentry)。兩種情況下交易都 MUST rollback,不留下部分寫入。
|
||||
|
||||
#### Scenario: 重複預約仍回 422 與既定訊息
|
||||
- **WHEN** 已登入 member 對同一時段已有 `pending` 預約,再次送出 `POST /api/member/bookings`(`schedule_id` 相同、`participants` 1)
|
||||
- **THEN** 系統回傳 422,body 為 `{"status": false, "message": "您已預約此時段"}`,該 member 在此時段的預約筆數不變
|
||||
|
||||
#### Scenario: 交易內名額不足仍回 422 與既定訊息
|
||||
- **WHEN** 已登入 member 送出 `POST /api/member/bookings` 時通過交易前的名額檢查,但交易內鎖定時段後重新計算的剩餘名額小於 `participants`
|
||||
- **THEN** 系統回傳 422,body 為 `{"status": false, "message": "名額不足,請重新選擇"}`,不建立預約
|
||||
|
||||
#### Scenario: 確認預約時名額不足仍回 422 與既定訊息
|
||||
- **WHEN** 已登入 provider 對自己課程的 `pending` 預約送出 `PUT /api/provider/bookings/{id}/confirm`,而該預約人數大於時段剩餘名額
|
||||
- **THEN** 系統回傳 422,body 為 `{"status": false, "message": "名額不足,無法確認此預約"}`,預約維持 `pending`,`current_participants` 不變
|
||||
|
||||
#### Scenario: 建立預約時資料庫錯誤回 500 且不洩漏細節
|
||||
- **WHEN** `APP_DEBUG` 為 false,已登入 member 送出合法的 `POST /api/member/bookings`,而交易內寫入預約之後發生資料庫例外
|
||||
- **THEN** 系統回傳 500,body 為通用錯誤訊息且不含 SQL 語句、`bookings` 等資料表名稱與欄位名稱,資料庫例外被回報,`bookings` 筆數不變
|
||||
|
||||
#### Scenario: 確認預約時資料庫錯誤回 500 且不洩漏細節
|
||||
- **WHEN** `APP_DEBUG` 為 false,已登入 provider 對自己課程的 `pending` 預約送出 `PUT /api/provider/bookings/{id}/confirm`(名額足夠),而交易內預約狀態已更新、更新時段名額時發生資料庫例外
|
||||
- **THEN** 系統回傳 500,body 為通用錯誤訊息且不含 SQL 語句、資料表名稱與欄位名稱,資料庫例外被回報,預約維持 `pending`,`current_participants` 不變
|
||||
@@ -0,0 +1,25 @@
|
||||
# Tasks
|
||||
|
||||
測試集中在新檔 `tests/Feature/BookingExceptionHandlingTest.php`(建立 provider/時段的輔助方法參考 `tests/Feature/BookingOversellTest.php`)。每個測試的 docblock 要寫明「為什麼」:資料庫錯誤曾以 422 把 SQL 回給使用者,且不會送到 Sentry。觸發與斷言方式見 design.md D3。
|
||||
|
||||
## 1. 業務例外與 Controller
|
||||
|
||||
- [x] 1.1 [後端] 新增 `app/Exceptions/BookingRuleViolationException.php`(`final class`,繼承 `\RuntimeException`,不加額外方法);驗證:2.x 測試通過
|
||||
- [x] 1.2 [後端] `app/Http/Controllers/API/MemberBookingController.php` 的 `store`:交易閉包內兩處 `throw new \RuntimeException(...)` 改為 `throw new BookingRuleViolationException(...)`,訊息文字不變;`catch (\RuntimeException $e)` 改為 `catch (BookingRuleViolationException $e)`
|
||||
- [x] 1.3 [後端] `app/Http/Controllers/API/ProviderBookingController.php` 的 `confirm`:同 1.2,處理 `名額不足,無法確認此預約`
|
||||
- [x] 1.4 [後端] 確認 `grep -rn "RuntimeException" app/` 只剩 `BookingRuleViolationException` 本身的繼承宣告
|
||||
|
||||
## 2. 測試
|
||||
|
||||
- [x] 2.1 [整合測試] `test_duplicate_booking_returns_422_with_rule_message`:member 已有該時段 `pending` 預約,再次 `POST /api/member/bookings`,回 422,`assertExactJson(['status' => false, 'message' => '您已預約此時段'])`,該 member 在此時段的預約仍為 1 筆;測試開頭 `Exceptions::fake()`,最後 `Exceptions::assertNotReported(BookingRuleViolationException::class)`(對應 Scenario「重複預約仍回 422 與既定訊息」)
|
||||
- [x] 2.2 [整合測試] `test_capacity_lost_inside_transaction_returns_422_with_rule_message`:以 `CourseSchedule::retrieved` 事件在交易內把 `current_participants` 設為滿額(事件只能改動交易內 `lockForUpdate()->find()` 那次讀取;交易前的第一次讀取若被改動,會被前置檢查以「人數超過剩餘名額」攔下,測不到交易內路徑),`POST /api/member/bookings` 回 422,`assertExactJson(['status' => false, 'message' => '名額不足,請重新選擇'])`,`bookings` 筆數為 0;同 2.1 以 `Exceptions::fake()` 斷言未回報(對應 Scenario「交易內名額不足仍回 422 與既定訊息」)
|
||||
- [x] 2.3 [整合測試] `test_confirm_without_capacity_returns_422_with_rule_message`:provider 確認人數大於剩餘名額的 `pending` 預約,回 422,`assertExactJson(['status' => false, 'message' => '名額不足,無法確認此預約'])`,預約仍為 `pending`、`current_participants` 不變;同 2.1 以 `Exceptions::fake()` 斷言未回報(對應 Scenario「確認預約時名額不足仍回 422 與既定訊息」)
|
||||
- [x] 2.4 [整合測試] `test_database_error_on_create_returns_500_without_sql`:`config(['app.debug' => false])`、`Exceptions::fake()`,`Booking::created`(INSERT 已執行之後)拋出 SQL 含 `insert into "bookings"` 與欄位名稱的 `QueryException`;合法 `POST /api/member/bookings` 回 500,`assertExactJson(['message' => 'Server Error'])`,body 不含 `insert into`、`bookings`、`member_id`,`Exceptions::assertReported(QueryException::class)`,`bookings` 筆數為 0(INSERT 已發生,筆數為 0 才能證明 rollback)(對應 Scenario「建立預約時資料庫錯誤回 500 且不洩漏細節」)
|
||||
- [x] 2.5 [整合測試] `test_database_error_on_confirm_returns_500_without_sql`:同 2.4 的設定,`CourseSchedule::updating`(在交易內預約狀態已更新、`increment` 執行時觸發)拋出 SQL 含 `update "course_schedules"` 的 `QueryException`;provider 確認名額足夠的 `pending` 預約,回 500,`assertExactJson(['message' => 'Server Error'])`,body 不含 `update`、`course_schedules`、`current_participants`,`Exceptions::assertReported(QueryException::class)`,預約仍為 `pending`(狀態更新已執行,仍為 `pending` 才能證明 rollback)、`current_participants` 不變(對應 Scenario「確認預約時資料庫錯誤回 500 且不洩漏細節」)
|
||||
- [x] 2.6 [整合測試] 執行 `php artisan test --filter='BookingLifecycleTest|BookingOversellTest'`,確認既有 422 行為不變;完整 `php artisan test` 需在有 GD 的環境執行(見 `cfdive-test-gd` image 的做法)
|
||||
|
||||
## 3. 手動驗證
|
||||
|
||||
- [x] 3.1 [手動驗證] 變異驗證(只在本機執行,不提交):暫時把 1.2、1.3 的 catch 改回 `\RuntimeException`,確認 2.4、2.5 失敗後再還原,證明測試抓得到原本的問題
|
||||
- [x] 3.2 [手動驗證] 部署後在正式環境建立一筆預約、確認一筆預約,確認業務流程正常(201/200);Sentry 是否收到預約端點的 500 只能等實際錯誤發生,若期間沒有錯誤則記錄為「未觀察到」,不視為失敗(自動化驗證以 2.4、2.5 為準)
|
||||
- 2026-10-18 結果:Hank 在正式環境建立、確認預約皆正常,未出現 500;Sentry 未觀察到預約端點的 500
|
||||
@@ -61,8 +61,8 @@ context: |
|
||||
## 後端慣例
|
||||
- Controller 位於 `app/Http/Controllers/API/`,Swagger 註解位於 `app/Docs/`
|
||||
- 路由集中於 `routes/api.php`;admin 群組以 `admin` middleware 保護
|
||||
- member / provider 路由群組目前僅有 `auth:sanctum`,角色檢查需在 Controller 內自行處理
|
||||
(已知缺口,見 docs/analysis/2026-10-14-project-review-todo.md H1;新增路由時不可假設已有角色保護)
|
||||
- member / provider 路由群組以 `member`/`provider` middleware 保護(role-authorization spec);
|
||||
middleware 只擋跨角色,資源所有權仍需在 Controller 內檢查
|
||||
|
||||
## API 回應格式
|
||||
成功:`{ status: true, message?: "...", data: {...} 或 [...] }`
|
||||
|
||||
@@ -133,3 +133,26 @@ Member SHALL 能查詢自己所有預約的列表及詳情,含課程連結與
|
||||
|
||||
- **WHEN** notify 呼叫拋出例外
|
||||
- **THEN** 預約狀態已正確儲存,HTTP response 成功回傳,錯誤記錄至 Laravel log
|
||||
|
||||
### Requirement: 預約交易錯誤分類
|
||||
建立預約(`POST /api/member/bookings`)與確認預約(`PUT /api/provider/bookings/{id}/confirm`)的交易中,系統 SHALL 區分業務規則失敗與伺服器端故障:業務規則失敗(名額不足、重複預約)回傳 422 與既定訊息;其他任何例外(含資料庫錯誤)MUST NOT 以 422 回傳,MUST 回傳 500,回應內容不得包含 SQL、資料表或欄位名稱,且該例外 MUST 交由例外回報機制記錄(正式環境會送至 Sentry)。兩種情況下交易都 MUST rollback,不留下部分寫入。
|
||||
|
||||
#### Scenario: 重複預約仍回 422 與既定訊息
|
||||
- **WHEN** 已登入 member 對同一時段已有 `pending` 預約,再次送出 `POST /api/member/bookings`(`schedule_id` 相同、`participants` 1)
|
||||
- **THEN** 系統回傳 422,body 為 `{"status": false, "message": "您已預約此時段"}`,該 member 在此時段的預約筆數不變
|
||||
|
||||
#### Scenario: 交易內名額不足仍回 422 與既定訊息
|
||||
- **WHEN** 已登入 member 送出 `POST /api/member/bookings` 時通過交易前的名額檢查,但交易內鎖定時段後重新計算的剩餘名額小於 `participants`
|
||||
- **THEN** 系統回傳 422,body 為 `{"status": false, "message": "名額不足,請重新選擇"}`,不建立預約
|
||||
|
||||
#### Scenario: 確認預約時名額不足仍回 422 與既定訊息
|
||||
- **WHEN** 已登入 provider 對自己課程的 `pending` 預約送出 `PUT /api/provider/bookings/{id}/confirm`,而該預約人數大於時段剩餘名額
|
||||
- **THEN** 系統回傳 422,body 為 `{"status": false, "message": "名額不足,無法確認此預約"}`,預約維持 `pending`,`current_participants` 不變
|
||||
|
||||
#### Scenario: 建立預約時資料庫錯誤回 500 且不洩漏細節
|
||||
- **WHEN** `APP_DEBUG` 為 false,已登入 member 送出合法的 `POST /api/member/bookings`,而交易內寫入預約之後發生資料庫例外
|
||||
- **THEN** 系統回傳 500,body 為通用錯誤訊息且不含 SQL 語句、`bookings` 等資料表名稱與欄位名稱,資料庫例外被回報,`bookings` 筆數不變
|
||||
|
||||
#### Scenario: 確認預約時資料庫錯誤回 500 且不洩漏細節
|
||||
- **WHEN** `APP_DEBUG` 為 false,已登入 provider 對自己課程的 `pending` 預約送出 `PUT /api/provider/bookings/{id}/confirm`(名額足夠),而交易內預約狀態已更新、更新時段名額時發生資料庫例外
|
||||
- **THEN** 系統回傳 500,body 為通用錯誤訊息且不含 SQL 語句、資料表名稱與欄位名稱,資料庫例外被回報,預約維持 `pending`,`current_participants` 不變
|
||||
|
||||
@@ -126,8 +126,8 @@
|
||||
|
||||
### Requirement: 預約列表端點測試覆蓋
|
||||
測試套件 SHALL 驗證下列三個路由的資料隔離行為:
|
||||
- `GET /api/member/bookings`(middleware: `auth:sanctum`,隔離靠 `where member_id = auth()->id()`)
|
||||
- `GET /api/provider/bookings`(middleware: `auth:sanctum`,隔離靠 `whereHas schedule.provider_id`)
|
||||
- `GET /api/member/bookings`(middleware: `auth:sanctum` + `member`,非 member 角色於 middleware 層強制 403;隔離靠 `where member_id = auth()->id()`)
|
||||
- `GET /api/provider/bookings`(middleware: `auth:sanctum` + `provider`,非 provider 角色於 middleware 層強制 403;隔離靠 `whereHas schedule.provider_id`)
|
||||
- `GET /api/admin/bookings`(middleware: `auth:sanctum` + `admin`,EnsureAdmin 於 middleware 層強制 403)
|
||||
|
||||
#### Scenario: Member 只能看到自己的預約
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# role-authorization Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
依使用者角色限制 member、provider 專屬 API 路由群組的存取,避免已登入的其他角色跨角色呼叫課程、時段、預約等業務端點。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Member 專屬路由僅限 member 角色存取
|
||||
`/api/member/*` 路由群組(不含公開的 `POST /api/member/register`、`POST /api/member/login`)SHALL 只允許 `role = member` 的已登入使用者存取。其他角色 MUST 收到 HTTP 403,回應為 `{ "status": false, "message": "無權限存取" }`,且請求 MUST NOT 產生任何資料變更。未帶 token 的請求 MUST 收到 HTTP 401。
|
||||
|
||||
#### Scenario: member 可存取 member 路由
|
||||
- **WHEN** 一個 `role = member` 的使用者,以該使用者的 token 呼叫 `GET /api/member/profile` 與 `GET /api/member/bookings`
|
||||
- **THEN** 回傳 HTTP 200,`status` 為 `true`
|
||||
|
||||
#### Scenario: provider 呼叫 member 路由被拒絕
|
||||
- **WHEN** 一個 `role = provider` 的使用者,以該使用者的 token 呼叫 `GET /api/member/profile`、`GET /api/member/bookings`、`POST /api/member/bookings`、`POST /api/member/reviews`
|
||||
- **THEN** 每個請求都回傳 HTTP 403,body 為 `{ "status": false, "message": "無權限存取" }`
|
||||
|
||||
#### Scenario: admin 呼叫 member 路由被拒絕
|
||||
- **WHEN** 一個 `role = admin` 的使用者,以該使用者的 token 呼叫 `GET /api/member/profile`、`GET /api/member/bookings`、`POST /api/member/bookings`
|
||||
- **THEN** 每個請求都回傳 HTTP 403,body 為 `{ "status": false, "message": "無權限存取" }`
|
||||
|
||||
#### Scenario: 非 member 建立預約不會產生資料
|
||||
- **WHEN** 一個可預約的課程時段,以及一個 `role = provider` 的使用者,以該使用者的 token 呼叫 `POST /api/member/bookings` 並帶入合法的時段 id
|
||||
- **THEN** 回傳 HTTP 403,`bookings` 資料表筆數不變,該時段的 `current_participants` 不變
|
||||
|
||||
#### Scenario: 未登入呼叫 member 路由
|
||||
- **WHEN** 不帶 token 呼叫 `GET /api/member/profile`、`GET /api/member/bookings`
|
||||
- **THEN** 回傳 HTTP 401
|
||||
|
||||
### Requirement: Provider 專屬路由僅限 provider 角色存取
|
||||
`/api/provider/*` 路由群組(不含公開的 `POST /api/provider/register`、`POST /api/provider/login`)SHALL 只允許 `role = provider` 的已登入使用者存取,且 MUST NOT 以教練審核狀態作為進入條件。其他角色 MUST 收到 HTTP 403,回應為 `{ "status": false, "message": "無權限存取" }`,且請求 MUST NOT 產生任何資料變更。未帶 token 的請求 MUST 收到 HTTP 401。
|
||||
|
||||
#### Scenario: provider 可存取 provider 路由
|
||||
- **WHEN** 一個 `role = provider` 的使用者,以該使用者的 token 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`、`GET /api/provider/schedules`、`GET /api/provider/bookings`
|
||||
- **THEN** 回傳 HTTP 200,`status` 為 `true`
|
||||
|
||||
#### Scenario: 未通過審核的 provider 仍可進入 provider 路由
|
||||
- **WHEN** 一個 `role = provider`、`verification_status` 不是 approved(例如 unsubmitted 或 pending)的使用者,以該使用者的 token 呼叫 `GET /api/provider/verification`
|
||||
- **THEN** 回傳 HTTP 200,不會因角色 middleware 回 403
|
||||
|
||||
#### Scenario: member 呼叫 provider 路由被拒絕
|
||||
- **WHEN** 一個 `role = member` 的使用者,以該使用者的 token 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`、`POST /api/provider/offers`、`POST /api/provider/schedules`、`GET /api/provider/bookings`、`GET /api/provider/verification`
|
||||
- **THEN** 每個請求都回傳 HTTP 403,body 為 `{ "status": false, "message": "無權限存取" }`
|
||||
|
||||
#### Scenario: admin 呼叫 provider 路由被拒絕
|
||||
- **WHEN** 一個 `role = admin` 的使用者,以該使用者的 token 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`、`POST /api/provider/offers`、`GET /api/provider/bookings`
|
||||
- **THEN** 每個請求都回傳 HTTP 403,body 為 `{ "status": false, "message": "無權限存取" }`
|
||||
|
||||
#### Scenario: 非 provider 建立課程不會產生資料
|
||||
- **WHEN** 一個 `role = member` 的使用者,以該使用者的 token 呼叫 `POST /api/provider/offers` 並帶入通過驗證的完整課程資料
|
||||
- **THEN** 回傳 HTTP 403,`diving_offers` 資料表筆數不變
|
||||
|
||||
#### Scenario: 未登入呼叫 provider 路由
|
||||
- **WHEN** 不帶 token 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`
|
||||
- **THEN** 回傳 HTTP 401
|
||||
|
||||
### Requirement: 跨角色共用路由不受角色限制影響
|
||||
通知(`/api/notifications/*`)與預約即時訊息(`/api/bookings/{booking}/messages*`、`/api/bookings/messages/unread-counts`)路由 SHALL 維持既有行為:member 與 provider 皆可存取,存取權限由既有的資源歸屬檢查決定,不套用 member 或 provider 角色限制。
|
||||
|
||||
#### Scenario: member 與 provider 皆可讀取通知
|
||||
- **WHEN** 一個 `role = member` 與一個 `role = provider` 的使用者,分別以各自的 token 呼叫 `GET /api/notifications`
|
||||
- **THEN** 兩者皆回傳 HTTP 200
|
||||
|
||||
#### Scenario: member 與 provider 皆可讀取未讀訊息數
|
||||
- **WHEN** 一個 `role = member` 與一個 `role = provider` 的使用者,分別以各自的 token 呼叫 `GET /api/bookings/messages/unread-counts`
|
||||
- **THEN** 兩者皆回傳 HTTP 200
|
||||
+67
-67
@@ -1,22 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Broadcast;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\API\AuthController;
|
||||
use App\Http\Controllers\API\DivingOfferController;
|
||||
use App\Http\Controllers\API\ProviderOfferController;
|
||||
use App\Http\Controllers\API\ScheduleController;
|
||||
use App\Http\Controllers\API\ProviderBookingController;
|
||||
use App\Http\Controllers\API\MemberBookingController;
|
||||
use App\Http\Controllers\API\ReviewController;
|
||||
use App\Http\Controllers\API\AdminReviewController;
|
||||
use App\Http\Controllers\API\AdminBookingController;
|
||||
use App\Http\Controllers\API\CourseImageController;
|
||||
use App\Http\Controllers\API\AdminOfferController;
|
||||
use App\Http\Controllers\API\AdminReviewController;
|
||||
use App\Http\Controllers\API\AdminStatsController;
|
||||
use App\Http\Controllers\API\AdminUserController;
|
||||
use App\Http\Controllers\API\AdminOfferController;
|
||||
use App\Http\Controllers\API\AuthController;
|
||||
use App\Http\Controllers\API\BookingMessageController;
|
||||
use App\Http\Controllers\API\CourseImageController;
|
||||
use App\Http\Controllers\API\DivingOfferController;
|
||||
use App\Http\Controllers\API\MemberBookingController;
|
||||
use App\Http\Controllers\API\NotificationController;
|
||||
use App\Http\Controllers\API\ProviderBookingController;
|
||||
use App\Http\Controllers\API\ProviderOfferController;
|
||||
use App\Http\Controllers\API\ReviewController;
|
||||
use App\Http\Controllers\API\ScheduleController;
|
||||
use Illuminate\Support\Facades\Broadcast;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Broadcast::routes(['middleware' => ['auth:sanctum']]);
|
||||
|
||||
@@ -24,7 +24,7 @@ Broadcast::routes(['middleware' => ['auth:sanctum']]);
|
||||
Route::get('/diving-offers', [DivingOfferController::class, 'index']);
|
||||
Route::get('/diving-offers/{id}', [DivingOfferController::class, 'show']);
|
||||
Route::get('/diving-offers/{id}/schedules', [ScheduleController::class, 'publicList']);
|
||||
Route::get('/diving-offers/{id}/reviews', [ReviewController::class, 'publicList']);
|
||||
Route::get('/diving-offers/{id}/reviews', [ReviewController::class, 'publicList']);
|
||||
|
||||
// 會員註冊/登入
|
||||
Route::post('/member/register', [AuthController::class, 'registerMember']);
|
||||
@@ -37,7 +37,7 @@ Route::middleware([\Illuminate\Session\Middleware\StartSession::class])->group(f
|
||||
});
|
||||
|
||||
// 會員專屬 API(需登入)
|
||||
Route::middleware(['auth:sanctum'])->prefix('member')->group(function () {
|
||||
Route::middleware(['auth:sanctum', 'member'])->prefix('member')->group(function () {
|
||||
// 會員登出
|
||||
Route::post('/logout', [AuthController::class, 'logoutMember']);
|
||||
// Token Refresh
|
||||
@@ -49,14 +49,14 @@ Route::middleware(['auth:sanctum'])->prefix('member')->group(function () {
|
||||
// 修改密碼
|
||||
Route::put('/change-password', [AuthController::class, 'changeMemberPassword']);
|
||||
// 預約
|
||||
Route::get('/bookings', [MemberBookingController::class, 'index']);
|
||||
Route::post('/bookings', [MemberBookingController::class, 'store']);
|
||||
Route::get('/bookings/{id}', [MemberBookingController::class, 'show']);
|
||||
Route::delete('/bookings/{id}', [MemberBookingController::class, 'destroy']);
|
||||
Route::get('/bookings', [MemberBookingController::class, 'index']);
|
||||
Route::post('/bookings', [MemberBookingController::class, 'store']);
|
||||
Route::get('/bookings/{id}', [MemberBookingController::class, 'show']);
|
||||
Route::delete('/bookings/{id}', [MemberBookingController::class, 'destroy']);
|
||||
// 評價
|
||||
Route::post('/reviews', [ReviewController::class, 'store']);
|
||||
Route::put('/reviews/{id}', [ReviewController::class, 'update']);
|
||||
Route::delete('/reviews/{id}',[ReviewController::class, 'destroy']);
|
||||
Route::post('/reviews', [ReviewController::class, 'store']);
|
||||
Route::put('/reviews/{id}', [ReviewController::class, 'update']);
|
||||
Route::delete('/reviews/{id}', [ReviewController::class, 'destroy']);
|
||||
});
|
||||
|
||||
// 有幫助投票(需登入,但不限 member prefix)
|
||||
@@ -67,7 +67,7 @@ Route::post('/provider/register', [AuthController::class, 'registerProvider']);
|
||||
Route::middleware('throttle:10,1')->post('/provider/login', [AuthController::class, 'loginProvider']);
|
||||
|
||||
// 服務提供者專屬 API(需登入)
|
||||
Route::middleware(['auth:sanctum'])->prefix('provider')->group(function () {
|
||||
Route::middleware(['auth:sanctum', 'provider'])->prefix('provider')->group(function () {
|
||||
// 服務提供者登出
|
||||
Route::post('/logout', [AuthController::class, 'logoutProvider']);
|
||||
// Token Refresh
|
||||
@@ -79,32 +79,32 @@ Route::middleware(['auth:sanctum'])->prefix('provider')->group(function () {
|
||||
// 修改密碼
|
||||
Route::put('/change-password', [AuthController::class, 'changeProviderPassword']);
|
||||
// 教練課程管理
|
||||
Route::get('/offers', [ProviderOfferController::class, 'index']);
|
||||
Route::post('/offers', [ProviderOfferController::class, 'store']);
|
||||
Route::get('/offers/{id}', [ProviderOfferController::class, 'show']);
|
||||
Route::put('/offers/{id}', [ProviderOfferController::class, 'update']);
|
||||
Route::delete('/offers/{id}', [ProviderOfferController::class, 'destroy']);
|
||||
Route::get('/offers', [ProviderOfferController::class, 'index']);
|
||||
Route::post('/offers', [ProviderOfferController::class, 'store']);
|
||||
Route::get('/offers/{id}', [ProviderOfferController::class, 'show']);
|
||||
Route::put('/offers/{id}', [ProviderOfferController::class, 'update']);
|
||||
Route::delete('/offers/{id}', [ProviderOfferController::class, 'destroy']);
|
||||
// 驗證申請(證照送審)
|
||||
Route::get('/verification', [\App\Http\Controllers\API\ProviderVerificationController::class, 'show']);
|
||||
Route::post('/verification/certifications', [\App\Http\Controllers\API\ProviderVerificationController::class, 'uploadCertification']);
|
||||
Route::delete('/verification/certifications/{id}', [\App\Http\Controllers\API\ProviderVerificationController::class, 'deleteCertification']);
|
||||
Route::post('/verification/submit', [\App\Http\Controllers\API\ProviderVerificationController::class, 'submit']);
|
||||
Route::get('/verification', [\App\Http\Controllers\API\ProviderVerificationController::class, 'show']);
|
||||
Route::post('/verification/certifications', [\App\Http\Controllers\API\ProviderVerificationController::class, 'uploadCertification']);
|
||||
Route::delete('/verification/certifications/{id}', [\App\Http\Controllers\API\ProviderVerificationController::class, 'deleteCertification']);
|
||||
Route::post('/verification/submit', [\App\Http\Controllers\API\ProviderVerificationController::class, 'submit']);
|
||||
// 課程圖片
|
||||
Route::post('/offers/{id}/cover', [CourseImageController::class, 'uploadCover']);
|
||||
Route::delete('/offers/{id}/cover', [CourseImageController::class, 'deleteCover']);
|
||||
Route::post('/offers/{id}/images', [CourseImageController::class, 'uploadImage']);
|
||||
Route::delete('/images/{id}', [CourseImageController::class, 'deleteImage']);
|
||||
Route::post('/offers/{id}/cover', [CourseImageController::class, 'uploadCover']);
|
||||
Route::delete('/offers/{id}/cover', [CourseImageController::class, 'deleteCover']);
|
||||
Route::post('/offers/{id}/images', [CourseImageController::class, 'uploadImage']);
|
||||
Route::delete('/images/{id}', [CourseImageController::class, 'deleteImage']);
|
||||
// 時段管理
|
||||
Route::get('/schedules', [ScheduleController::class, 'index']);
|
||||
Route::post('/schedules', [ScheduleController::class, 'store']);
|
||||
Route::put('/schedules/{id}', [ScheduleController::class, 'update']);
|
||||
Route::delete('/schedules/{id}', [ScheduleController::class, 'destroy']);
|
||||
Route::get('/schedules', [ScheduleController::class, 'index']);
|
||||
Route::post('/schedules', [ScheduleController::class, 'store']);
|
||||
Route::put('/schedules/{id}', [ScheduleController::class, 'update']);
|
||||
Route::delete('/schedules/{id}', [ScheduleController::class, 'destroy']);
|
||||
// 預約管理
|
||||
Route::get('/bookings', [ProviderBookingController::class, 'index']);
|
||||
Route::put('/bookings/{id}/confirm', [ProviderBookingController::class, 'confirm']);
|
||||
Route::put('/bookings/{id}/reject', [ProviderBookingController::class, 'reject']);
|
||||
Route::put('/bookings/{id}/cancel', [ProviderBookingController::class, 'cancel']);
|
||||
Route::put('/bookings/{id}/complete', [ProviderBookingController::class, 'complete']);
|
||||
Route::get('/bookings', [ProviderBookingController::class, 'index']);
|
||||
Route::put('/bookings/{id}/confirm', [ProviderBookingController::class, 'confirm']);
|
||||
Route::put('/bookings/{id}/reject', [ProviderBookingController::class, 'reject']);
|
||||
Route::put('/bookings/{id}/cancel', [ProviderBookingController::class, 'cancel']);
|
||||
Route::put('/bookings/{id}/complete', [ProviderBookingController::class, 'complete']);
|
||||
});
|
||||
|
||||
// 管理員登入(帳號建立僅限主機端 php artisan app:create-admin,不提供公開註冊)
|
||||
@@ -129,42 +129,42 @@ Route::middleware(['auth:sanctum', 'admin'])->prefix('admin')->group(function ()
|
||||
// 統計數據
|
||||
Route::get('/stats', [AdminStatsController::class, 'index']);
|
||||
// 用戶管理
|
||||
Route::get('/members', [AdminUserController::class, 'members']);
|
||||
Route::get('/members/{id}', [AdminUserController::class, 'member']);
|
||||
Route::put('/members/{id}/toggle-active', [AdminUserController::class, 'toggleMemberActive']);
|
||||
Route::get('/providers', [AdminUserController::class, 'providers']);
|
||||
Route::get('/providers/{id}', [AdminUserController::class, 'provider']);
|
||||
Route::put('/providers/{id}/toggle-active', [AdminUserController::class, 'toggleProviderActive']);
|
||||
Route::get('/members', [AdminUserController::class, 'members']);
|
||||
Route::get('/members/{id}', [AdminUserController::class, 'member']);
|
||||
Route::put('/members/{id}/toggle-active', [AdminUserController::class, 'toggleMemberActive']);
|
||||
Route::get('/providers', [AdminUserController::class, 'providers']);
|
||||
Route::get('/providers/{id}', [AdminUserController::class, 'provider']);
|
||||
Route::put('/providers/{id}/toggle-active', [AdminUserController::class, 'toggleProviderActive']);
|
||||
// 教練審核(toggle-verified 已由審核狀態機取代)
|
||||
Route::get('/verifications', [\App\Http\Controllers\API\AdminVerificationController::class, 'index']);
|
||||
Route::put('/verifications/{userId}/approve', [\App\Http\Controllers\API\AdminVerificationController::class, 'approve']);
|
||||
Route::put('/verifications/{userId}/reject', [\App\Http\Controllers\API\AdminVerificationController::class, 'reject']);
|
||||
Route::get('/verifications', [\App\Http\Controllers\API\AdminVerificationController::class, 'index']);
|
||||
Route::put('/verifications/{userId}/approve', [\App\Http\Controllers\API\AdminVerificationController::class, 'approve']);
|
||||
Route::put('/verifications/{userId}/reject', [\App\Http\Controllers\API\AdminVerificationController::class, 'reject']);
|
||||
// 課程管理
|
||||
Route::get('/offers', [AdminOfferController::class, 'index']);
|
||||
Route::delete('/offers/{id}', [AdminOfferController::class, 'destroy']);
|
||||
Route::get('/offers', [AdminOfferController::class, 'index']);
|
||||
Route::delete('/offers/{id}', [AdminOfferController::class, 'destroy']);
|
||||
// 預約管理
|
||||
Route::get('/bookings', [AdminBookingController::class, 'index']);
|
||||
Route::put('/bookings/{id}/complete', [AdminBookingController::class, 'complete']);
|
||||
Route::get('/bookings', [AdminBookingController::class, 'index']);
|
||||
Route::put('/bookings/{id}/complete', [AdminBookingController::class, 'complete']);
|
||||
// 評價管理
|
||||
Route::get('/reviews', [AdminReviewController::class, 'index']);
|
||||
Route::delete('/reviews/{id}', [AdminReviewController::class, 'destroy']);
|
||||
Route::get('/reviews', [AdminReviewController::class, 'index']);
|
||||
Route::delete('/reviews/{id}', [AdminReviewController::class, 'destroy']);
|
||||
});
|
||||
|
||||
// 通知(Member + Provider 共用)
|
||||
Route::middleware('auth:sanctum')->prefix('notifications')->group(function () {
|
||||
Route::get('/', [NotificationController::class, 'index']);
|
||||
Route::get('/', [NotificationController::class, 'index']);
|
||||
Route::get('/unread-count', [NotificationController::class, 'unreadCount']);
|
||||
Route::patch('/read-all', [NotificationController::class, 'markAllRead']);
|
||||
Route::patch('/{id}/read', [NotificationController::class, 'markRead']);
|
||||
Route::delete('/{id}', [NotificationController::class, 'destroy']);
|
||||
Route::patch('/read-all', [NotificationController::class, 'markAllRead']);
|
||||
Route::patch('/{id}/read', [NotificationController::class, 'markRead']);
|
||||
Route::delete('/{id}', [NotificationController::class, 'destroy']);
|
||||
});
|
||||
|
||||
// 即時訊息(Member + Provider 共用,依 booking 參與方驗證)
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
// unread-counts 必須在 {booking} 之前,否則會被 route model binding 吃掉
|
||||
Route::get('/bookings/messages/unread-counts', [BookingMessageController::class, 'unreadCounts']);
|
||||
Route::get('/bookings/{booking}/messages', [BookingMessageController::class, 'index']);
|
||||
Route::post('/bookings/{booking}/messages', [BookingMessageController::class, 'store']);
|
||||
Route::get('/bookings/messages/unread-counts', [BookingMessageController::class, 'unreadCounts']);
|
||||
Route::get('/bookings/{booking}/messages', [BookingMessageController::class, 'index']);
|
||||
Route::post('/bookings/{booking}/messages', [BookingMessageController::class, 'store']);
|
||||
Route::post('/bookings/{booking}/messages/read', [BookingMessageController::class, 'markRead']);
|
||||
});
|
||||
|
||||
@@ -172,4 +172,4 @@ Route::middleware('auth:sanctum')->group(function () {
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
Route::post('/logout', [AuthController::class, 'logout']);
|
||||
Route::get('/user', [AuthController::class, 'user']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Enums\ScheduleStatus;
|
||||
use App\Exceptions\BookingRuleViolationException;
|
||||
use App\Models\Booking;
|
||||
use App\Models\CourseSchedule;
|
||||
use App\Models\DivingOffer;
|
||||
use App\Models\ProviderProfile;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Exceptions;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 預約交易的錯誤分類:業務規則失敗回 422,其他例外交給 Laravel 例外處理器。
|
||||
*
|
||||
* 原本兩個 Controller 以 catch (\RuntimeException) 接住交易內的錯誤,而
|
||||
* QueryException 也是 RuntimeException 的子類別,資料庫錯誤因此以 422
|
||||
* 把 SQL 與資料表結構回給使用者,且不會被回報到 Sentry。
|
||||
*/
|
||||
class BookingExceptionHandlingTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Notification::fake();
|
||||
Exceptions::fake();
|
||||
}
|
||||
|
||||
private function makeProviderWithSchedule(int $maxParticipants, int $currentParticipants = 0): array
|
||||
{
|
||||
$provider = User::factory()->create(['role' => 'provider']);
|
||||
|
||||
ProviderProfile::create([
|
||||
'user_id' => $provider->id,
|
||||
'verification_status' => 'approved',
|
||||
]);
|
||||
|
||||
$offer = DivingOffer::create([
|
||||
'provider_id' => $provider->id,
|
||||
'title' => 'Exception Course',
|
||||
'location' => 'Test',
|
||||
'spot' => 'Test Spot',
|
||||
'price' => 1000,
|
||||
'region' => '南部',
|
||||
'rating' => 0,
|
||||
'reviews' => 0,
|
||||
]);
|
||||
|
||||
$schedule = CourseSchedule::create([
|
||||
'diving_offer_id' => $offer->id,
|
||||
'provider_id' => $provider->id,
|
||||
'scheduled_date' => now()->addDays(7)->toDateString(),
|
||||
'start_time' => '09:00',
|
||||
'max_participants' => $maxParticipants,
|
||||
'current_participants' => $currentParticipants,
|
||||
'status' => ScheduleStatus::Open,
|
||||
]);
|
||||
|
||||
return [$provider, $schedule];
|
||||
}
|
||||
|
||||
private function makePendingBooking(CourseSchedule $schedule, int $participants, ?User $member = null): Booking
|
||||
{
|
||||
return Booking::create([
|
||||
'schedule_id' => $schedule->id,
|
||||
'member_id' => ($member ?? User::factory()->create(['role' => 'member']))->id,
|
||||
'participants' => $participants,
|
||||
'total_price' => 1000 * $participants,
|
||||
'status' => BookingStatus::Pending,
|
||||
]);
|
||||
}
|
||||
|
||||
// 刻意不用 "database is locked" 這類併發錯誤訊息:巢狀交易(RefreshDatabase)中 Laravel 會改拋 DeadlockException
|
||||
private function makeQueryException(string $sql): QueryException
|
||||
{
|
||||
return new QueryException('sqlite', $sql, [], new \PDOException('SQLSTATE[23000]: Integrity constraint violation'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 業務失敗的訊息是前端直接顯示給使用者的文字,改型別不能改到它;
|
||||
* 也不應回報到 Sentry,否則正常的業務拒絕會淹沒真正的伺服器故障。
|
||||
*/
|
||||
public function test_duplicate_booking_returns_422_with_rule_message(): void
|
||||
{
|
||||
[, $schedule] = $this->makeProviderWithSchedule(5);
|
||||
$member = User::factory()->create(['role' => 'member']);
|
||||
$this->makePendingBooking($schedule, 1, $member);
|
||||
|
||||
$this->actingAs($member)
|
||||
->postJson('/api/member/bookings', ['schedule_id' => $schedule->id, 'participants' => 1])
|
||||
->assertStatus(422)
|
||||
->assertExactJson(['status' => false, 'message' => '您已預約此時段']);
|
||||
|
||||
$this->assertSame(1, Booking::where('member_id', $member->id)->where('schedule_id', $schedule->id)->count());
|
||||
Exceptions::assertNotReported(BookingRuleViolationException::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 交易內名額不足只在併發時發生:交易前的檢查通過後,另一筆預約搶先佔位。
|
||||
* 只改動第二次讀取(交易內 lockForUpdate),第一次讀取若被改動會被前置檢查攔下,測不到這條路徑。
|
||||
*/
|
||||
public function test_capacity_lost_inside_transaction_returns_422_with_rule_message(): void
|
||||
{
|
||||
[, $schedule] = $this->makeProviderWithSchedule(2);
|
||||
$member = User::factory()->create(['role' => 'member']);
|
||||
|
||||
$retrievalCount = 0;
|
||||
CourseSchedule::retrieved(function (CourseSchedule $retrieved) use (&$retrievalCount) {
|
||||
$retrievalCount++;
|
||||
if ($retrievalCount === 2) {
|
||||
$retrieved->current_participants = $retrieved->max_participants;
|
||||
}
|
||||
});
|
||||
|
||||
$this->actingAs($member)
|
||||
->postJson('/api/member/bookings', ['schedule_id' => $schedule->id, 'participants' => 1])
|
||||
->assertStatus(422)
|
||||
->assertExactJson(['status' => false, 'message' => '名額不足,請重新選擇']);
|
||||
|
||||
$this->assertSame(0, Booking::count());
|
||||
Exceptions::assertNotReported(BookingRuleViolationException::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 確認時的名額檢查是防超賣的最後一道防線,其 422 訊息與「不留下部分寫入」都必須維持。
|
||||
*/
|
||||
public function test_confirm_without_capacity_returns_422_with_rule_message(): void
|
||||
{
|
||||
[$provider, $schedule] = $this->makeProviderWithSchedule(2, 1);
|
||||
$booking = $this->makePendingBooking($schedule, 2);
|
||||
|
||||
$this->actingAs($provider)
|
||||
->putJson("/api/provider/bookings/{$booking->id}/confirm")
|
||||
->assertStatus(422)
|
||||
->assertExactJson(['status' => false, 'message' => '名額不足,無法確認此預約']);
|
||||
|
||||
$this->assertSame(BookingStatus::Pending, $booking->fresh()->status);
|
||||
$this->assertSame(1, $schedule->fresh()->current_participants);
|
||||
Exceptions::assertNotReported(BookingRuleViolationException::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 資料庫錯誤曾以 422 把 SQL 回給使用者且不會送到 Sentry。
|
||||
* 例外在 INSERT 之後才拋出,筆數為 0 才能證明交易確實 rollback。
|
||||
*/
|
||||
public function test_database_error_on_create_returns_500_without_sql(): void
|
||||
{
|
||||
config(['app.debug' => false]);
|
||||
[, $schedule] = $this->makeProviderWithSchedule(5);
|
||||
$member = User::factory()->create(['role' => 'member']);
|
||||
|
||||
Booking::created(function () {
|
||||
throw $this->makeQueryException('insert into "bookings" ("schedule_id", "member_id", "participants") values (?, ?, ?)');
|
||||
});
|
||||
|
||||
$this->actingAs($member)
|
||||
->postJson('/api/member/bookings', ['schedule_id' => $schedule->id, 'participants' => 1])
|
||||
->assertStatus(500)
|
||||
->assertExactJson(['message' => 'Server Error'])
|
||||
->assertDontSee('insert into', false)
|
||||
->assertDontSee('bookings', false)
|
||||
->assertDontSee('member_id', false);
|
||||
|
||||
Exceptions::assertReported(QueryException::class);
|
||||
$this->assertSame(0, Booking::count());
|
||||
}
|
||||
|
||||
/**
|
||||
* 同上,針對確認預約。例外在預約狀態更新之後才拋出,預約仍為 pending 才能證明 rollback。
|
||||
*/
|
||||
public function test_database_error_on_confirm_returns_500_without_sql(): void
|
||||
{
|
||||
config(['app.debug' => false]);
|
||||
[$provider, $schedule] = $this->makeProviderWithSchedule(5);
|
||||
$booking = $this->makePendingBooking($schedule, 2);
|
||||
|
||||
CourseSchedule::updating(function () {
|
||||
throw $this->makeQueryException('update "course_schedules" set "current_participants" = "current_participants" + 2 where "id" = ?');
|
||||
});
|
||||
|
||||
$this->actingAs($provider)
|
||||
->putJson("/api/provider/bookings/{$booking->id}/confirm")
|
||||
->assertStatus(500)
|
||||
->assertExactJson(['message' => 'Server Error'])
|
||||
->assertDontSee('update', false)
|
||||
->assertDontSee('course_schedules', false)
|
||||
->assertDontSee('current_participants', false);
|
||||
|
||||
Exceptions::assertReported(QueryException::class);
|
||||
$this->assertSame(BookingStatus::Pending, $booking->fresh()->status);
|
||||
$this->assertSame(0, $schedule->fresh()->current_participants);
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,12 @@ use Tests\TestCase;
|
||||
* 預約列表端點資料隔離(booking-lifecycle 規格)。
|
||||
*
|
||||
* 路由對應(routes/api.php 已確認):
|
||||
* GET /api/member/bookings → MemberBookingController::index(auth:sanctum,無 role check)
|
||||
* GET /api/provider/bookings → ProviderBookingController::index(auth:sanctum,無 role check)
|
||||
* GET /api/member/bookings → MemberBookingController::index(auth:sanctum + member middleware)
|
||||
* GET /api/provider/bookings → ProviderBookingController::index(auth:sanctum + provider middleware)
|
||||
* GET /api/admin/bookings → AdminBookingController::index(auth:sanctum + admin middleware)
|
||||
*
|
||||
* 資料隔離靠 controller query 條件,不靠 middleware role 強制,
|
||||
* 因此測試需分別以正確角色 actingAs 驗證隔離邊界。
|
||||
* 角色 middleware 只擋跨角色呼叫(見 RoleAuthorizationTest);同角色之間的資料隔離
|
||||
* 靠 controller query 條件,因此測試需分別以正確角色 actingAs 驗證隔離邊界。
|
||||
*/
|
||||
class BookingListTest extends TestCase
|
||||
{
|
||||
@@ -34,22 +34,22 @@ class BookingListTest extends TestCase
|
||||
|
||||
$offer = DivingOffer::create([
|
||||
'provider_id' => $provider->id,
|
||||
'title' => 'Dive Course',
|
||||
'location' => 'Kenting',
|
||||
'price' => 3000,
|
||||
'region' => '南部',
|
||||
'rating' => 0,
|
||||
'reviews' => 0,
|
||||
'title' => 'Dive Course',
|
||||
'location' => 'Kenting',
|
||||
'price' => 3000,
|
||||
'region' => '南部',
|
||||
'rating' => 0,
|
||||
'reviews' => 0,
|
||||
]);
|
||||
|
||||
$schedule = CourseSchedule::create([
|
||||
'diving_offer_id' => $offer->id,
|
||||
'provider_id' => $provider->id,
|
||||
'scheduled_date' => now()->addDays(30)->toDateString(),
|
||||
'start_time' => '09:00',
|
||||
'max_participants' => 10,
|
||||
'diving_offer_id' => $offer->id,
|
||||
'provider_id' => $provider->id,
|
||||
'scheduled_date' => now()->addDays(30)->toDateString(),
|
||||
'start_time' => '09:00',
|
||||
'max_participants' => 10,
|
||||
'current_participants' => 0,
|
||||
'status' => ScheduleStatus::Open,
|
||||
'status' => ScheduleStatus::Open,
|
||||
]);
|
||||
|
||||
return [$provider, $schedule];
|
||||
@@ -58,11 +58,11 @@ class BookingListTest extends TestCase
|
||||
private function makePendingBooking(User $member, CourseSchedule $schedule): Booking
|
||||
{
|
||||
return Booking::create([
|
||||
'schedule_id' => $schedule->id,
|
||||
'member_id' => $member->id,
|
||||
'schedule_id' => $schedule->id,
|
||||
'member_id' => $member->id,
|
||||
'participants' => 1,
|
||||
'total_price' => 3000,
|
||||
'status' => BookingStatus::Pending,
|
||||
'total_price' => 3000,
|
||||
'status' => BookingStatus::Pending,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -71,8 +71,8 @@ class BookingListTest extends TestCase
|
||||
public function test_member_sees_only_own_bookings(): void
|
||||
{
|
||||
[$provider, $schedule] = $this->makeProviderWithSchedule();
|
||||
$memberA = User::factory()->create(['role' => 'member']);
|
||||
$memberB = User::factory()->create(['role' => 'member']);
|
||||
$memberA = User::factory()->create(['role' => 'member']);
|
||||
$memberB = User::factory()->create(['role' => 'member']);
|
||||
$bookingA = $this->makePendingBooking($memberA, $schedule);
|
||||
$bookingB = $this->makePendingBooking($memberB, $schedule);
|
||||
|
||||
@@ -90,7 +90,7 @@ class BookingListTest extends TestCase
|
||||
{
|
||||
[$providerA, $scheduleA] = $this->makeProviderWithSchedule();
|
||||
[$providerB, $scheduleB] = $this->makeProviderWithSchedule();
|
||||
$member = User::factory()->create(['role' => 'member']);
|
||||
$member = User::factory()->create(['role' => 'member']);
|
||||
$bookingA = $this->makePendingBooking($member, $scheduleA);
|
||||
$bookingB = $this->makePendingBooking($member, $scheduleB);
|
||||
|
||||
@@ -106,11 +106,11 @@ class BookingListTest extends TestCase
|
||||
|
||||
public function test_admin_sees_all_bookings(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
[$providerA, $scheduleA] = $this->makeProviderWithSchedule();
|
||||
[$providerB, $scheduleB] = $this->makeProviderWithSchedule();
|
||||
$memberA = User::factory()->create(['role' => 'member']);
|
||||
$memberB = User::factory()->create(['role' => 'member']);
|
||||
$memberA = User::factory()->create(['role' => 'member']);
|
||||
$memberB = User::factory()->create(['role' => 'member']);
|
||||
$bookingA = $this->makePendingBooking($memberA, $scheduleA);
|
||||
$bookingB = $this->makePendingBooking($memberB, $scheduleB);
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\ScheduleStatus;
|
||||
use App\Models\CourseSchedule;
|
||||
use App\Models\DivingOffer;
|
||||
use App\Models\ProviderProfile;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Member / Provider 路由群組角色邊界(role-authorization 規格)。
|
||||
*
|
||||
* 在 EnsureMember / EnsureProvider 之前,任何已登入帳號都能跨角色呼叫:
|
||||
* member 可建立課程(provider_id 被設成自己)、provider 可建立預約。
|
||||
* 這裡驗證角色不符一律在 middleware 層回 403 且不產生資料;
|
||||
* POST 請求都帶通過驗證的完整資料,確保 403 不是被 422 或資源檢查遮蓋。
|
||||
*/
|
||||
class RoleAuthorizationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const FORBIDDEN_BODY = ['status' => false, 'message' => '無權限存取'];
|
||||
|
||||
private function createProvider(string $verificationStatus = 'approved'): User
|
||||
{
|
||||
$provider = User::factory()->create(['role' => 'provider']);
|
||||
ProviderProfile::create(['user_id' => $provider->id, 'verification_status' => $verificationStatus]);
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
private function createOfferWithOpenSchedule(User $provider): CourseSchedule
|
||||
{
|
||||
$offer = DivingOffer::create([
|
||||
'provider_id' => $provider->id,
|
||||
'title' => 'Dive Course',
|
||||
'location' => 'Kenting',
|
||||
'price' => 3000,
|
||||
'region' => '南部',
|
||||
'rating' => 0,
|
||||
'reviews' => 0,
|
||||
]);
|
||||
|
||||
return CourseSchedule::create([
|
||||
'diving_offer_id' => $offer->id,
|
||||
'provider_id' => $provider->id,
|
||||
'scheduled_date' => now()->addDays(30)->toDateString(),
|
||||
'start_time' => '09:00',
|
||||
'max_participants' => 10,
|
||||
'current_participants' => 0,
|
||||
'status' => ScheduleStatus::Open,
|
||||
]);
|
||||
}
|
||||
|
||||
private function validOfferPayload(): array
|
||||
{
|
||||
return [
|
||||
'title' => 'Cross Role Course',
|
||||
'location' => 'Green Island',
|
||||
'price' => 2000,
|
||||
'region' => '東部',
|
||||
];
|
||||
}
|
||||
|
||||
private function assertForbidden(User $user, string $method, string $uri, array $payload = []): void
|
||||
{
|
||||
$this->actingAs($user)
|
||||
->json($method, $uri, $payload)
|
||||
->assertStatus(403)
|
||||
->assertExactJson(self::FORBIDDEN_BODY);
|
||||
}
|
||||
|
||||
// ── Member 路由 ──────────────────────────────────────────────
|
||||
|
||||
public function test_member_can_access_member_routes(): void
|
||||
{
|
||||
$member = User::factory()->create(['role' => 'member']);
|
||||
|
||||
$this->actingAs($member)->getJson('/api/member/profile')->assertOk()->assertJsonPath('status', true);
|
||||
$this->actingAs($member)->getJson('/api/member/bookings')->assertOk()->assertJsonPath('status', true);
|
||||
}
|
||||
|
||||
public function test_provider_is_rejected_on_member_routes(): void
|
||||
{
|
||||
$provider = $this->createProvider();
|
||||
$schedule = $this->createOfferWithOpenSchedule($provider);
|
||||
|
||||
$this->assertForbidden($provider, 'GET', '/api/member/profile');
|
||||
$this->assertForbidden($provider, 'GET', '/api/member/bookings');
|
||||
$this->assertForbidden($provider, 'POST', '/api/member/bookings', [
|
||||
'schedule_id' => $schedule->id,
|
||||
'participants' => 1,
|
||||
]);
|
||||
$this->assertForbidden($provider, 'POST', '/api/member/reviews', [
|
||||
'diving_offer_id' => $schedule->diving_offer_id,
|
||||
'rating' => 5,
|
||||
'comment' => 'self review',
|
||||
]);
|
||||
|
||||
// provider 為自己的課程寫評價會灌水評分
|
||||
$this->assertDatabaseCount('reviews', 0);
|
||||
}
|
||||
|
||||
public function test_admin_is_rejected_on_member_routes(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$schedule = $this->createOfferWithOpenSchedule($this->createProvider());
|
||||
|
||||
$this->assertForbidden($admin, 'GET', '/api/member/profile');
|
||||
$this->assertForbidden($admin, 'GET', '/api/member/bookings');
|
||||
$this->assertForbidden($admin, 'POST', '/api/member/bookings', [
|
||||
'schedule_id' => $schedule->id,
|
||||
'participants' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_non_member_booking_attempt_creates_no_booking(): void
|
||||
{
|
||||
$provider = $this->createProvider();
|
||||
$schedule = $this->createOfferWithOpenSchedule($provider);
|
||||
|
||||
$this->assertForbidden($provider, 'POST', '/api/member/bookings', [
|
||||
'schedule_id' => $schedule->id,
|
||||
'participants' => 2,
|
||||
]);
|
||||
|
||||
// 越權預約若寫入會佔用真實學員的名額
|
||||
$this->assertDatabaseCount('bookings', 0);
|
||||
$this->assertSame(0, $schedule->fresh()->current_participants);
|
||||
}
|
||||
|
||||
public function test_unauthenticated_request_is_rejected_on_member_routes(): void
|
||||
{
|
||||
$this->getJson('/api/member/profile')->assertStatus(401);
|
||||
$this->getJson('/api/member/bookings')->assertStatus(401);
|
||||
}
|
||||
|
||||
// ── Provider 路由 ────────────────────────────────────────────
|
||||
|
||||
public function test_provider_can_access_provider_routes(): void
|
||||
{
|
||||
$provider = $this->createProvider();
|
||||
|
||||
foreach (['/api/provider/profile', '/api/provider/offers', '/api/provider/schedules', '/api/provider/bookings'] as $uri) {
|
||||
$this->actingAs($provider)->getJson($uri)->assertOk()->assertJsonPath('status', true);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_unapproved_provider_can_access_verification(): void
|
||||
{
|
||||
// 未通過審核的教練必須能進入 provider 路由才能送審或補件
|
||||
foreach (['unsubmitted', 'pending', 'rejected'] as $status) {
|
||||
$provider = $this->createProvider($status);
|
||||
|
||||
$this->actingAs($provider)
|
||||
->getJson('/api/provider/verification')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.verification_status', $status);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_member_is_rejected_on_provider_routes(): void
|
||||
{
|
||||
$member = User::factory()->create(['role' => 'member']);
|
||||
$schedule = $this->createOfferWithOpenSchedule($this->createProvider());
|
||||
|
||||
$this->assertForbidden($member, 'GET', '/api/provider/profile');
|
||||
$this->assertForbidden($member, 'GET', '/api/provider/offers');
|
||||
$this->assertForbidden($member, 'POST', '/api/provider/offers', $this->validOfferPayload());
|
||||
$this->assertForbidden($member, 'POST', '/api/provider/schedules', [
|
||||
'diving_offer_id' => $schedule->diving_offer_id,
|
||||
'scheduled_date' => now()->addDays(10)->toDateString(),
|
||||
'start_time' => '10:00',
|
||||
'max_participants' => 5,
|
||||
]);
|
||||
$this->assertForbidden($member, 'GET', '/api/provider/bookings');
|
||||
$this->assertForbidden($member, 'GET', '/api/provider/verification');
|
||||
|
||||
$this->assertDatabaseCount('course_schedules', 1);
|
||||
}
|
||||
|
||||
public function test_admin_is_rejected_on_provider_routes(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->assertForbidden($admin, 'GET', '/api/provider/profile');
|
||||
$this->assertForbidden($admin, 'GET', '/api/provider/offers');
|
||||
$this->assertForbidden($admin, 'POST', '/api/provider/offers', $this->validOfferPayload());
|
||||
$this->assertForbidden($admin, 'GET', '/api/provider/bookings');
|
||||
}
|
||||
|
||||
public function test_non_provider_offer_attempt_creates_no_offer(): void
|
||||
{
|
||||
$member = User::factory()->create(['role' => 'member']);
|
||||
|
||||
$this->assertForbidden($member, 'POST', '/api/provider/offers', $this->validOfferPayload());
|
||||
|
||||
// 越權建立的課程 provider_id 會是 member,出現在公開列表
|
||||
$this->assertDatabaseCount('diving_offers', 0);
|
||||
}
|
||||
|
||||
public function test_unauthenticated_request_is_rejected_on_provider_routes(): void
|
||||
{
|
||||
$this->getJson('/api/provider/profile')->assertStatus(401);
|
||||
$this->getJson('/api/provider/offers')->assertStatus(401);
|
||||
}
|
||||
|
||||
// ── 跨角色共用路由 ───────────────────────────────────────────
|
||||
|
||||
public function test_member_and_provider_can_read_notifications(): void
|
||||
{
|
||||
$member = User::factory()->create(['role' => 'member']);
|
||||
$provider = $this->createProvider();
|
||||
|
||||
$this->actingAs($member)->getJson('/api/notifications')->assertOk();
|
||||
$this->actingAs($provider)->getJson('/api/notifications')->assertOk();
|
||||
}
|
||||
|
||||
public function test_member_and_provider_can_read_unread_message_counts(): void
|
||||
{
|
||||
$member = User::factory()->create(['role' => 'member']);
|
||||
$provider = $this->createProvider();
|
||||
|
||||
$this->actingAs($member)->getJson('/api/bookings/messages/unread-counts')->assertOk();
|
||||
$this->actingAs($provider)->getJson('/api/bookings/messages/unread-counts')->assertOk();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user