Compare commits

..

17 Commits

Author SHA1 Message Date
a620906209 b93d5ca135 Merge pull request 'chore(graphify): hook 改放本機設定並移除不適用的 graph.json 規則' (#81) from chore/graphify-claude-integration into master
Deploy Production / deploy (push) Successful in 1m31s
Run Tests / test (push) Successful in 36s
Reviewed-on: #81
2026-10-29 10:06:36 +00:00
a620906209 beb52eba4c chore(graphify): hook 改放本機設定並移除不適用的 graph.json 規則
Run Tests / test (pull_request) Successful in 37s
- .claude/settings.json 寫死了本機的 graphify.exe 絕對路徑,
  改併入已被忽略的 .claude/settings.local.json,不進版控
- CLAUDE.md 刪除「已提交的 graph.json 大 diff 屬預期」一條,
  本專案已忽略 graphify-out/,該規則不適用

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 17:46:33 +08:00
a620906209 df1b5bed9b Merge pull request 'chore(graphify): CLAUDE.md 新增 graphify 使用規則' (#80) from chore/graphify-claude-integration into master
Deploy Production / deploy (push) Successful in 1m31s
Run Tests / test (push) Successful in 37s
Reviewed-on: #80
2026-10-29 09:42:27 +00:00
a620906209 1958c3f01a chore(graphify): 新增 Claude Code 搜尋與讀檔 hook 設定
Run Tests / test (pull_request) Successful in 33s
由 graphify 安裝流程寫入,Bash/Grep/Read/Glob 執行前
會呼叫 graphify hook-guard 提示改用圖譜查詢。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 17:41:37 +08:00
a620906209 6dfa36c1cb chore(graphify): CLAUDE.md 新增 graphify 使用規則
由 graphify 安裝流程寫入,要求查詢程式碼問題時先用 graphify query,
改完程式碼後執行 graphify update。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 17:41:37 +08:00
a620906209 2d571cfd52 Merge pull request 'chore(gitignore): 忽略 graphify-out 圖譜輸出' (#79) from docs/k8s-local-simulation-todo into master
Deploy Production / deploy (push) Successful in 1m32s
Run Tests / test (push) Successful in 37s
Reviewed-on: #79
2026-10-29 09:38:30 +00:00
a620906209 34b4706d00 Merge pull request 'docs(graphify): 新增知識圖譜操作筆記' (#78) from docs/graphify-usage-note into master
Deploy Production / deploy (push) Successful in 1m41s
Run Tests / test (push) Successful in 37s
Reviewed-on: #78
2026-10-29 09:37:06 +00:00
a620906209 89b8e8f343 docs(graphify): 新增知識圖譜操作筆記
Run Tests / test (pull_request) Successful in 34s
記錄重建步驟、已知限制(enum case 引用、腳本檔名遮蔽、
子代理並行上限)與前 16 大社群的人工命名對照表。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 17:34:29 +08:00
a620906209 1c1fe6f651 chore(gitignore): 忽略 graphify-out 圖譜輸出
Run Tests / test (pull_request) Successful in 37s
graphify 產生的 graph.json、graph.html、cache 皆可重建,
且 graph.json 每次重建都整份重寫,進版控會造成大量無意義 diff。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 17:28:10 +08:00
a620906209 4f2907eb93 Merge pull request 'docs(analysis): 新增 K8s 本機模擬待辦記錄' (#77) from docs/k8s-local-simulation-todo into master
Deploy Production / deploy (push) Successful in 6m13s
Run Tests / test (push) Successful in 35s
Reviewed-on: #77
2026-10-29 04:58:17 +00:00
a620906209 7bd96ef092 docs(analysis): 新增 K8s 本機模擬待辦記錄
Run Tests / test (pull_request) Successful in 36s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 12:57:13 +08:00
a620906209 d6aae828e9 Merge pull request 'chore(openspec): 歸檔 fix-booking-exception-handling 並同步主規格' (#76) from chore/archive-fix-booking-exception-handling into master
Deploy Production / deploy (push) Successful in 1m40s
Run Tests / test (push) Successful in 36s
Reviewed-on: #76
2026-10-17 17:46:36 +00:00
a620906209 72daf9baa0 chore(openspec): 歸檔 fix-booking-exception-handling 並同步主規格
Run Tests / test (pull_request) Successful in 37s
- booking-lifecycle 主規格新增「預約交易錯誤分類」requirement
- tasks 3.2 正式環境驗證完成(建立、確認預約正常,未觀察到 500)
- 待辦文件標記 M7 完成

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:45:26 +08:00
a620906209 a869b0989a Merge pull request 'fix(booking): 交易內只攔截業務例外,資料庫錯誤改交由例外處理器' (#75) from fix/booking-exception-handling-impl into master
Deploy Production / deploy (push) Successful in 1m42s
Run Tests / test (push) Successful in 37s
Reviewed-on: #75
2026-10-17 17:38:43 +00:00
a620906209 8042f0bafb style(booking): 以 pint 格式化預約 Controller
Run Tests / test (pull_request) Successful in 34s
純格式化,不含邏輯變更。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:35:38 +08:00
a620906209 ddbf956144 fix(booking): 交易內只攔截業務例外,資料庫錯誤改交由例外處理器
新增 BookingRuleViolationException,MemberBookingController::store 與
ProviderBookingController::confirm 只 catch 此型別並回 422(訊息不變)。
QueryException 等其他例外不再被當成 422 回傳 SQL,改由 Laravel 回 500
並回報 Sentry。對應 OpenSpec change fix-booking-exception-handling(M7)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:35:38 +08:00
a620906209 0ee6ce9966 Merge pull request 'fix(openspec): 新增 fix-booking-exception-handling change 規劃' (#74) from fix/booking-exception-handling into master
Deploy Production / deploy (push) Successful in 1m37s
Run Tests / test (push) Successful in 44s
Reviewed-on: #74
2026-10-17 17:12:32 +00:00
15 changed files with 501 additions and 65 deletions
+3
View File
@@ -28,3 +28,6 @@ yarn-error.log
/frontend/.env
# graphify 知識圖譜輸出(可由 graphify 重建,graph.json 每次整份重寫)
/graphify-out
+10
View File
@@ -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).
@@ -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(),
];
}
}
+58
View File
@@ -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 這次建置有效;重建後需重新核對。
@@ -21,7 +21,7 @@
| 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 已下修,可視驗證結果延後或關閉)。
@@ -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)
@@ -4,21 +4,22 @@
## 1. 業務例外與 Controller
- [ ] 1.1 [後端] 新增 `app/Exceptions/BookingRuleViolationException.php`(`final class`,繼承 `\RuntimeException`,不加額外方法);驗證:2.x 測試通過
- [ ] 1.2 [後端] `app/Http/Controllers/API/MemberBookingController.php` 的 `store`:交易閉包內兩處 `throw new \RuntimeException(...)` 改為 `throw new BookingRuleViolationException(...)`,訊息文字不變;`catch (\RuntimeException $e)` 改為 `catch (BookingRuleViolationException $e)`
- [ ] 1.3 [後端] `app/Http/Controllers/API/ProviderBookingController.php` 的 `confirm`:同 1.2,處理 `名額不足,無法確認此預約`
- [ ] 1.4 [後端] 確認 `grep -rn "RuntimeException" app/` 只剩 `BookingRuleViolationException` 本身的繼承宣告
- [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. 測試
- [ ] 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 與既定訊息」)
- [ ] 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 與既定訊息」)
- [ ] 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 與既定訊息」)
- [ ] 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 且不洩漏細節」)
- [ ] 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 且不洩漏細節」)
- [ ] 2.6 [整合測試] 執行 `php artisan test --filter='BookingLifecycleTest|BookingOversellTest'`,確認既有 422 行為不變;完整 `php artisan test` 需在有 GD 的環境執行(見 `cfdive-test-gd` image 的做法)
- [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. 手動驗證
- [ ] 3.1 [手動驗證] 變異驗證(只在本機執行,不提交):暫時把 1.2、1.3 的 catch 改回 `\RuntimeException`,確認 2.4、2.5 失敗後再還原,證明測試抓得到原本的問題
- [ ] 3.2 [手動驗證] 部署後在正式環境建立一筆預約、確認一筆預約,確認業務流程正常(201/200);Sentry 是否收到預約端點的 500 只能等實際錯誤發生,若期間沒有錯誤則記錄為「未觀察到」,不視為失敗(自動化驗證以 2.4、2.5 為準)
- [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
+23
View File
@@ -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` 不變
@@ -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);
}
}