跟團與成團邏輯:用 Laravel Session 打造從「+1」到「成團確認」
本篇是「PHP/Laravel 完全指南」系列的第 8 / 15 篇。你可以從系列總覽開始閱讀,也可以直接接著看本文。
團購平台的核心,說穿了就是兩個字:+1。有人開團、有人跟團、湊到最低人數就成團。聽起來簡單,但背後的工程考量比你想像的多:
- 使用者還沒登入就想跟團怎麼辦?
- 同時一百個人按下「+1」會不會超賣?
- 截止時間到了但人數不夠要怎麼處理?
- 成團瞬間要同時通知所有跟團者、建立訂單、鎖定庫存,這些動作的順序和原子性都不能出錯。
這一章是「揪好買」的心臟。我們要用 Laravel 的 Session 機制處理使用者的暫存狀態,用 Cache 加速熱門團購的讀取,用 Livewire 做即時的跟團人數更新,然後把「成團條件判斷」這個最關鍵的業務邏輯寫得清楚明白。技術選型不是重點——Session driver 用 database 還是 Redis、Cache 用 file 還是 Memcached,這些換一行設定就能改。真正難的是業務邏輯本身:什麼時候該鎖定、什麼時候該釋放、edge case 怎麼處理。
老實說,框架教學最容易迴避的就是業務邏輯。因為每個專案不一樣,沒有標準答案。但我認為這才是你真正需要練習的部分——把模糊的需求轉化成明確的程式碼。這一章,我們就來面對它。
跟團前先懂 Session:HTTP 是無狀態的
HTTP 本身是無狀態的(stateless)。每一次請求對 server 來說都是全新的陌生人——server 不知道五秒前那個看了團購列表的人,跟現在要按 +1 的人是不是同一個。這就好像你每次走進便利商店,店員都不認得你。
Web 應用程式怎麼解決這個問題?Session。
Session 的運作原理其實很簡單:
- 使用者第一次造訪網站時,server 產生一個隨機 ID(Session ID)
- 這個 ID 透過 Cookie 送回瀏覽器
- 往後每次請求,瀏覽器自動帶上這個 Cookie
- Server 收到 Cookie,用 Session ID 查找對應的資料
- 資料存在 server 端(檔案、資料庫、Redis),不在瀏覽器裡
所以 Session 就是 server 端的暫存記憶體——你放什麼進去,下一次請求都還在。
跨框架對照
| 概念 | Laravel | Express (Node.js) | Django (Python) |
|---|---|---|---|
| Session 套件 | 內建 | express-session | 內建 |
| 儲存位置設定 | SESSION_DRIVER in .env | store option | SESSION_ENGINE |
| 讀取 | session('key') | req.session.key | request.session['key'] |
| 寫入 | session(['key' => 'val']) | req.session.key = 'val' | request.session['key'] = 'val' |
| Flash data | session()->flash() | req.flash() (需套件) | messages framework |
如果你從 Express 轉過來,Laravel 的 Session 概念一模一樣,只是 Express 需要你自己裝 express-session 然後選 store(memory、redis、mongo),而 Laravel 全部幫你包好了——改一行 .env 就切換儲存方式。
Laravel Session 機制:Driver 選擇與設定
打開 .env,找到 SESSION_DRIVER:
SESSION_DRIVER=database
Driver 一覽
| Driver | 說明 | 適合場景 |
|---|---|---|
file | 存在 storage/framework/sessions/ | 單機開發、小型站台 |
database | 存在 sessions 資料表 | Laravel 12 預設,通用且可靠 |
redis | 存在 Redis | 高流量正式環境,速度最快 |
cookie | 加密後存在 Cookie | 輕量,但有 4KB 大小限制 |
array | 存在記憶體(request 結束就消失) | 測試用 |
Laravel 12 新建專案預設用 database。執行 php artisan migrate 時會自動建好 sessions 資料表——你不用額外做任何事。
生產環境建議: 如果你的流量大,Session driver 換成
redis是最直接的升級。改一行SESSION_DRIVER=redis,前提是你裝了 Redis server。開發階段用database就好,不要過早優化。
Session 基本操作
// ── 寫入 ──
session(['cart_quantity' => 3]);
// 或者
session()->put('cart_quantity', 3);
// ── 讀取 ──
$qty = session('cart_quantity'); // 取得值
$qty = session('cart_quantity', 0); // 預設值(key 不存在時回傳 0)
$qty = session()->get('cart_quantity', 0); // 等效寫法
// ── 檢查 ──
if (session()->has('cart_quantity')) {
// key 存在且不為 null
}
if (session()->exists('cart_quantity')) {
// key 存在(可能為 null)
}
// ── 刪除 ──
session()->forget('cart_quantity'); // 刪除單一 key
session()->forget(['cart_quantity', 'selected_group']); // 刪除多個
session()->flush(); // 清空整個 session
// ── 所有資料 ──
$all = session()->all();
Flash Data:一次性訊息
Flash data 是只在「下一次請求」有效的 session 資料,用完即消。最典型的用途是操作成功/失敗的通知訊息:
// Controller 裡
session()->flash('success', '成功加入團購!');
return redirect("/group-buys/{$groupBuy->id}");
// 或用 redirect 的語法糖
return redirect("/group-buys/{$groupBuy->id}")
->with('success', '成功加入團購!');
在 Blade 裡顯示:
@if(session('success'))
<div class="bg-green-100 text-green-700 px-4 py-3 rounded mb-4">{{ session('success') }}</div>
@endif
@if(session('error'))
<div class="bg-red-100 text-red-700 px-4 py-3 rounded mb-4">{{ session('error') }}</div>
@endif
重新整理頁面後,這些訊息就消失了——因為 flash data 只活一次。
跟團邏輯設計:選數量、加入、取消
好,技術工具介紹完了。現在進入這一章的核心——跟團邏輯。
需求拆解
使用者在團購詳情頁看到一個「+1 跟團」按鈕。點下去之前,他要先選擇數量(跟幾份)。點下去之後:
- 驗證——團購還在開團嗎?還沒滿嗎?這個人已經跟過了嗎?
- 寫入——把這個人加到
group_buy_user中間表 - 回饋——頁面顯示「成功加入!」,參與人數即時更新
取消跟團的邏輯相反:從中間表 detach,人數減少。
驗證規則
在寫程式碼之前,先把規則列清楚。這是業務邏輯最重要的步驟——先用人話寫出所有條件,再翻譯成程式碼:
- 團購
status必須是open - 團購
deadline還沒過 - 使用者必須已登入
- 使用者不能重複加入同一個團購
- 如果有
max_participants,目前人數不能超過上限 - 數量必須是正整數
Livewire Component:JoinGroupBuy
php artisan make:livewire JoinGroupBuy
<?php
// app/Livewire/JoinGroupBuy.php
namespace App\Livewire;
use App\Models\GroupBuy;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class JoinGroupBuy extends Component
{
public GroupBuy $groupBuy;
public int $quantity = 1;
public bool $hasJoined = false;
public int $currentQuantity = 0;
public function mount(GroupBuy $groupBuy): void
{
$this->groupBuy = $groupBuy;
if (Auth::check()) {
$existing = $groupBuy->participants()
->where('user_id', Auth::id())
->first();
if ($existing) {
$this->hasJoined = true;
$this->currentQuantity = $existing->pivot->quantity;
}
}
}
public function join(): void
{
// 1. 必須登入
if (! Auth::check()) {
$this->redirect(route('login'));
return;
}
// 2. 驗證數量
$this->validate([
'quantity' => 'required|integer|min:1|max:10',
]);
// 3. 團購還在開團嗎?
if ($this->groupBuy->status !== 'open') {
session()->flash('error', '這個團購已經不接受跟團了。');
return;
}
// 4. 還沒截止嗎?
if ($this->groupBuy->deadline->isPast()) {
session()->flash('error', '這個團購已經截止了。');
return;
}
// 5. 沒有重複加入?
if ($this->hasJoined) {
session()->flash('error', '你已經跟過這個團了。');
return;
}
// 6. 還有名額嗎?
if ($this->groupBuy->max_participants) {
$currentCount = $this->groupBuy->participants()->count();
if ($currentCount >= $this->groupBuy->max_participants) {
session()->flash('error', '這個團已經滿了。');
return;
}
}
// 7. 一切驗證通過,加入團購
$this->groupBuy->participants()->attach(Auth::id(), [
'quantity' => $this->quantity,
]);
$this->hasJoined = true;
$this->currentQuantity = $this->quantity;
// 通知其他 Livewire component 更新
$this->dispatch('participant-updated');
session()->flash('success', "成功跟團!你選了 {$this->quantity} 份。");
}
public function leave(): void
{
if (! $this->hasJoined) {
return;
}
// 已成團的不能退出
if ($this->groupBuy->status !== 'open') {
session()->flash('error', '團購已成團,無法退出。');
return;
}
$this->groupBuy->participants()->detach(Auth::id());
$this->hasJoined = false;
$this->currentQuantity = 0;
$this->dispatch('participant-updated');
session()->flash('success', '你已退出這個團購。');
}
public function render()
{
return view('livewire.join-group-buy');
}
}
對應的 Blade 模板:
<!-- resources/views/livewire/join-group-buy.blade.php -->
<div>
@if(session('success'))
<div class="bg-green-100 text-green-700 px-4 py-3 rounded mb-4">{{ session('success') }}</div>
@endif
@if(session('error'))
<div class="bg-red-100 text-red-700 px-4 py-3 rounded mb-4">{{ session('error') }}</div>
@endif
@if($hasJoined)
<div class="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
<p class="text-indigo-700 font-medium">你已跟團 {{ $currentQuantity }} 份</p>
@if($groupBuy->status === 'open')
<button
wire:click="leave"
wire:confirm="確定要退出嗎?"
class="mt-2 text-sm text-red-600 hover:text-red-800"
>
退出團購
</button>
@endif
</div>
@else
@if($groupBuy->status === 'open' && ! $groupBuy->deadline->isPast())
<div class="flex items-center gap-3">
<label class="text-sm text-gray-600">數量</label>
<select wire:model="quantity" class="rounded border px-3 py-2">
@for($i = 1; $i <= 10; $i++)
<option value="{{ $i }}">{{ $i }} 份</option>
@endfor
</select>
<button
wire:click="join"
class="bg-emerald-600 text-white px-6 py-2 rounded-lg hover:bg-emerald-700 transition"
>
<span wire:loading.remove wire:target="join">+1 跟團</span>
<span wire:loading wire:target="join">處理中...</span>
</button>
</div>
@else
<p class="text-gray-500">此團購已截止或不再接受跟團</p>
@endif
@endif
</div>
為什麼用 attach() / detach()?
回顧第四章的多對多關聯——group_buy_user 是 pivot table,attach() 新增一筆關聯,detach() 移除。這比自己手寫 DB::table('group_buy_user')->insert(...) 乾淨很多,而且 Eloquent 會自動幫你維護 timestamps。
成團條件判斷:最低人數與截止時間
跟團邏輯處理的是個人行為——一個人加入、一個人退出。成團邏輯處理的是整個團的狀態轉換。
狀態轉換圖
┌──────────────┐
│ open │
│ (開團中) │
└──────┬───────┘
│
┌──────────┴──────────┐
│ │
人數 >= 最低門檻 截止時間到了
(可提前成團) 但人數不夠
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ confirmed │ │ cancelled │
│ (已成團) │ │ (已取消) │
└──────────────┘ └──────────────┘
規則很明確:
- 成團:參與人數 >=
min_participants(不管截止時間到了沒,都可以成團) - 取消:截止時間到了,但參與人數 <
min_participants - 開團中:還沒截止,人數也還不夠
不過「人數一夠就提前成團」是我做的產品選擇,不是唯一正解,這裡先講清楚兩件事:
- 提前鎖定有代價:有些團購反而希望跑滿整個截止時間,盡量蒐集人數去衝更低的折扣級距(湊到 50 人比 20 人便宜)。你一達標就 confirm,等於把後面那批人擋在門外,揪團規模反而變小。要不要提前成團,看你的折扣是不是階梯式的——如果是,可能該等截止才結算。
checkAndConfirm()要有人叫它:這個方法不會自己跑。下面你會看到它靠join()事件或每 5 分鐘的 scheduler 觸發,但等一下的JoinGroupBuy::join()其實只有 attach 完 dispatch UI 事件、沒有呼叫checkAndConfirm()。所以實務上的成團時間點是「下一個人 +1」或「scheduler 下一輪」,最慘可能拖好幾分鐘——明明第 5 個人已經進來了,狀態卻還掛在 open。要避免這種「達標但遲遲沒成團」的尷尬,記得在join()的 attach 之後補一行$this->groupBuy->checkAndConfirm(),讓達標當下就結算。
GroupBuy Model 方法:checkAndConfirm()
// app/Models/GroupBuy.php
use Illuminate\Support\Facades\DB;
/**
* 檢查並更新成團狀態
* 回傳是否發生狀態變更
*/
public function checkAndConfirm(): bool
{
// 只有 open 狀態才需要檢查
if ($this->status !== 'open') {
return false;
}
$participantCount = $this->participants()->count();
// 情況一:人數夠了 → 成團
if ($participantCount >= $this->min_participants) {
return $this->confirmGroup();
}
// 情況二:截止了但人數不夠 → 取消
if ($this->deadline->isPast() && $participantCount < $this->min_participants) {
return $this->cancelGroup();
}
// 情況三:還在進行中
return false;
}
private function confirmGroup(): bool
{
return DB::transaction(function () {
// 用悲觀鎖鎖住這筆團購,避免 race condition
$groupBuy = GroupBuy::lockForUpdate()->find($this->id);
// 再次確認狀態(double-check locking)
if ($groupBuy->status !== 'open') {
return false;
}
$groupBuy->update(['status' => 'confirmed']);
// TODO: 第十章會加入——通知所有參與者、建立訂單
// event(new GroupBuyConfirmed($groupBuy));
return true;
});
}
private function cancelGroup(): bool
{
return DB::transaction(function () {
$groupBuy = GroupBuy::lockForUpdate()->find($this->id);
if ($groupBuy->status !== 'open') {
return false;
}
$groupBuy->update(['status' => 'cancelled']);
// TODO: 通知所有參與者團購取消
// event(new GroupBuyCancelled($groupBuy));
return true;
});
}
成團後的通知與訂單建立(GroupBuyConfirmed 事件、Queue 處理)將在第十章:Queue、Event 與通知詳細介紹。
為什麼需要 DB::transaction() 和 lockForUpdate()?
想像這個場景:團購需要 5 人成團,目前已經有 4 個人。第五個人和第六個人幾乎同時按下 +1。
沒有鎖的情況:
使用者 A 讀取人數 → 4 人
使用者 B 讀取人數 → 4 人
使用者 A 加入 → 5 人 → 觸發成團 ✅
使用者 B 加入 → 6 人 → 又觸發成團?或超過上限?❌
有鎖的情況:
使用者 A 取得鎖 → 讀取 4 人 → 加入 → 5 人 → 成團 → 釋放鎖
使用者 B 等待鎖 → 取得鎖 → 讀取 5 人 → 已成團,不再處理 → 釋放鎖
lockForUpdate() 是資料庫的悲觀鎖(SELECT ... FOR UPDATE),確保同一時間只有一個 process 能修改這筆資料。搭配 DB::transaction() 保證整組操作的原子性——要嘛全部成功,要嘛全部回滾。
悲觀鎖不是萬靈丹,講幾句它的代價。
FOR UPDATE在低併發下很好用,但併發一上來,搶不到鎖的 request 會排隊乾等,連線一個個卡住,連線池滿了就開始噴 error;兩筆交易互相等對方的鎖還會 deadlock。它也綁 DB 引擎——MySQL InnoDB / Postgres 行為正常,SQLite 根本不是那樣鎖,你本機測過了上線可能兩種行為。所以前面說「高流量改用 Redis」跟這裡說「用悲觀鎖」其實有點打架:真高流量時,悲觀鎖本身可能就是瓶頸。想換做法的話:樂觀鎖(加個
version欄位,update 時帶條件,撞到就重試)、單一原子UPDATE ... WHERE status = 'open'(靠 DB 自己的行鎖,不用顯式FOR UPDATE)、或把成團檢查丟進 queue 用單一 worker 序列化處理,都比硬鎖更耐操。還有一句更重要的:真正怕超賣的是「名額/庫存」那一層,那裡才該下重手防併發。成團狀態從 open 轉 confirmed 只會發生一次、併發量通常很低,這裡用悲觀鎖是「夠用」而非「最佳」——學概念剛好,別把它當成所有 race condition 的標準答案。
Scheduled Command:定時檢查過期團購
有些團購不會被人手動觸發成團檢查——可能截止時間到了但最後一個人早就加入了,沒有新的 +1 來觸發 checkAndConfirm()。所以我們需要一個定時任務來掃描過期的團購。
php artisan make:command CheckExpiredGroupBuys
<?php
// app/Console/Commands/CheckExpiredGroupBuys.php
namespace App\Console\Commands;
use App\Models\GroupBuy;
use Illuminate\Console\Command;
class CheckExpiredGroupBuys extends Command
{
protected $signature = 'group-buys:check-expired';
protected $description = '檢查已截止的團購,成團或取消';
public function handle(): int
{
$expiredGroups = GroupBuy::where('status', 'open')
->where('deadline', '<=', now())
->get();
$confirmed = 0;
$cancelled = 0;
foreach ($expiredGroups as $groupBuy) {
if ($groupBuy->checkAndConfirm()) {
if ($groupBuy->fresh()->status === 'confirmed') {
$confirmed++;
} else {
$cancelled++;
}
}
}
$this->info("處理完成:{$confirmed} 個成團、{$cancelled} 個取消");
return self::SUCCESS;
}
}
在 routes/console.php(Laravel 12 的排程檔案)裡註冊:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('group-buys:check-expired')->everyFiveMinutes();
每五分鐘跑一次,把所有已截止的團購該成團的成團、該取消的取消。生產環境記得啟動 scheduler:
# crontab -e,加入這一行
* * * * * cd /path-to-project && php artisan schedule:run >> /dev/null 2>&1
Cache Facade:快取熱門資料
團購列表頁可能有幾十個團購,每一個都要 $groupBuy->participants()->count() 去查 pivot table——如果首頁流量大,這些 COUNT 查詢會成為瓶頸。Cache(快取)可以大幅減少資料庫壓力。
Cache 基本操作
use Illuminate\Support\Facades\Cache;
// ── 存入 ──
Cache::put('key', 'value', now()->addMinutes(30)); // 30 分鐘後過期
// ── 讀取 ──
$value = Cache::get('key'); // 不存在回傳 null
$value = Cache::get('key', 'default'); // 不存在回傳 default
// ── remember:不存在就執行 closure 並快取 ──
$count = Cache::remember('group_buy_42_count', now()->addMinutes(5), function () {
return GroupBuy::find(42)->participants()->count();
});
// 第一次:跑 DB 查詢,結果存入快取
// 之後五分鐘內:直接從快取拿,不碰 DB
// ── 刪除 ──
Cache::forget('group_buy_42_count');
// ── 永久快取 ──
Cache::forever('site_settings', $settings);
在揪好買中快取跟團人數
// app/Models/GroupBuy.php
public function cachedParticipantCount(): int
{
return Cache::remember(
"group_buy_{$this->id}_participant_count",
now()->addMinutes(5),
fn () => $this->participants()->count()
);
}
在 JoinGroupBuy component 的 join() 和 leave() 方法裡,加入 cache 失效:
// 加入或退出後,清除快取
Cache::forget("group_buy_{$this->groupBuy->id}_participant_count");
Cache Driver 選擇
# .env
CACHE_STORE=database # Laravel 12 預設
| Driver | 特點 | 適合場景 |
|---|---|---|
file | 零設定 | 開發、小站 |
database | 可靠,預設 | 一般用途 |
redis | 最快,支援 tags | 高流量正式環境 |
array | 不持久化 | 測試 |
跟 Session driver 一樣的故事——開發用 database,流量大了再換 redis。程式碼完全不用改。
登入前後狀態合併策略
這是很容易被忽略的 UX 問題:使用者還沒登入就開始瀏覽團購,甚至把某個團購加到「想跟」清單。登入之後,這些行為不應該消失——要把 session 裡的暫存資料合併到資料庫裡。
場景
- 訪客 A 瀏覽團購 #42,把它加入「收藏清單」(存在 session)
- 訪客 A 按下「+1 跟團」→ 被導向登入頁
- A 登入成功 → 回到團購 #42 → 收藏清單裡應該還有 #42
實作:合併 Session 資料
Laravel 在使用者登入時會觸發 Login event。我們可以監聽這個 event,在登入後把 session 資料合併到資料庫:
<?php
// app/Listeners/MergeSessionDataAfterLogin.php
namespace App\Listeners;
use Illuminate\Auth\Events\Login;
class MergeSessionDataAfterLogin
{
public function handle(Login $event): void
{
$user = $event->user;
// 合併收藏清單
$sessionFavorites = session()->pull('guest_favorites', []);
if (! empty($sessionFavorites)) {
// 把 session 裡的收藏寫到 user 的資料庫記錄
foreach ($sessionFavorites as $groupBuyId) {
$user->favorites()->syncWithoutDetaching([$groupBuyId]);
}
}
// 如果訪客有「想跟團」的意圖,導向那個團購頁
$pendingJoin = session()->pull('pending_join_group_buy');
if ($pendingJoin) {
session()->flash('info', '你可以繼續完成跟團了!');
// Livewire 或 Controller 可以根據這個 flash 做導向
}
}
}
在 EventServiceProvider 或 AppServiceProvider 裡註冊:
// app/Providers/AppServiceProvider.php
use App\Listeners\MergeSessionDataAfterLogin;
use Illuminate\Auth\Events\Login;
use Illuminate\Support\Facades\Event;
public function boot(): void
{
Event::listen(Login::class, MergeSessionDataAfterLogin::class);
}
而在 JoinGroupBuy component 的 join() 方法裡,如果使用者未登入,先記住意圖:
public function join(): void
{
if (! Auth::check()) {
// 記住使用者想跟哪個團
session()->put('pending_join_group_buy', $this->groupBuy->id);
$this->redirect(route('login'));
return;
}
// ...後續驗證和加入邏輯
}
登入成功後,使用者會被導回原頁面,看到一條 flash 訊息提醒他繼續完成跟團。
Session 的妙用: 很多人以為 session 只是「登入狀態」,其實它是通用的暫存工具。訪客瀏覽行為、購物車、表單草稿、多步驟流程的中間狀態——都可以用 session 暫存,登入後再合併到資料庫。
Livewire 即時更新跟團人數
團購詳情頁除了 JoinGroupBuy component,還需要顯示「目前幾人跟團」。這個數字要在有人加入/退出時即時更新——前面的 JoinGroupBuy 會 dispatch participant-updated 事件,我們可以做一個 component 來監聽它。
ParticipantCounter Component
<?php
// app/Livewire/ParticipantCounter.php
namespace App\Livewire;
use App\Models\GroupBuy;
use Livewire\Attributes\On;
use Livewire\Component;
class ParticipantCounter extends Component
{
public GroupBuy $groupBuy;
public int $count = 0;
public int $totalQuantity = 0;
public function mount(GroupBuy $groupBuy): void
{
$this->groupBuy = $groupBuy;
$this->refreshCount();
}
#[On('participant-updated')]
public function refreshCount(): void
{
$this->count = $this->groupBuy->participants()->count();
$this->totalQuantity = (int) $this->groupBuy
->participants()
->sum('group_buy_user.quantity');
}
public function render()
{
$progress = $this->groupBuy->min_participants > 0
? min(100, round(($this->count / $this->groupBuy->min_participants) * 100))
: 0;
return view('livewire.participant-counter', [
'progress' => $progress,
]);
}
}
<!-- resources/views/livewire/participant-counter.blade.php -->
<div wire:poll.30s="refreshCount">
<div class="flex items-baseline gap-2 mb-2">
<span class="text-3xl font-bold text-indigo-600">{{ $count }}</span>
<span class="text-gray-500">/ {{ $groupBuy->min_participants }} 人</span>
@if($groupBuy->max_participants)
<span class="text-gray-400 text-sm">(上限 {{ $groupBuy->max_participants }} 人)</span>
@endif
</div>
{{-- 進度條 --}}
<div class="w-full bg-gray-200 rounded-full h-3 mb-2">
<div
class="bg-emerald-500 h-3 rounded-full transition-all duration-500"
style="width: {{ $progress }}%"
></div>
</div>
<p class="text-sm text-gray-500">
共 {{ $totalQuantity }} 份 @if($progress >= 100)
<span class="text-emerald-600 font-medium">已達成團門檻!</span>
@else
,還差 {{ $groupBuy->min_participants - $count }} 人成團
@endif
</p>
</div>
關鍵設計
#[On('participant-updated')]:PHP 8 Attribute 語法,告訴 Livewire「當收到participant-updated事件時,執行這個方法」。JoinGroupBuy 在加入/退出後 dispatch 這個事件,ParticipantCounter 就會即時更新——同一頁面上的跨 component 通訊。wire:poll.30s:每 30 秒自動跟 server 同步一次。這是為了處理「別人在其他瀏覽器跟團」的情況——你不會收到 Livewire 事件,但 poll 會定時拉最新數字。- 進度條:視覺化呈現成團進度,用百分比計算寬度。CSS
transition-all讓寬度變化有動畫。
wire:poll 的代價: 每 30 秒一次 AJAX 請求。如果頁面上有很多使用者同時瀏覽,這會產生不少請求。正式環境如果流量真的很大,可以考慮用 Laravel Echo + WebSocket 做真正的即時推播。但對揪好買的規模來說,30 秒 poll 完全夠用。
倒數計時:團購截止提醒
截止時間的倒數計時是純前端的事——每秒更新一次,不需要打 server。用 Alpine.js 就對了。
<!-- 嵌入 group-buy show 頁面 -->
<div
x-data="countdown('{{ $groupBuy->deadline->toIso8601String() }}')"
x-init="start()"
class="text-center"
>
<template x-if="!expired">
<div>
<p class="text-sm text-gray-500 mb-1">距離截止還有</p>
<div class="flex justify-center gap-3">
<div class="text-center">
<span class="text-2xl font-bold text-indigo-600" x-text="days"></span>
<p class="text-xs text-gray-400">天</p>
</div>
<span class="text-2xl text-gray-300">:</span>
<div class="text-center">
<span class="text-2xl font-bold text-indigo-600" x-text="hours"></span>
<p class="text-xs text-gray-400">時</p>
</div>
<span class="text-2xl text-gray-300">:</span>
<div class="text-center">
<span class="text-2xl font-bold text-indigo-600" x-text="minutes"></span>
<p class="text-xs text-gray-400">分</p>
</div>
<span class="text-2xl text-gray-300">:</span>
<div class="text-center">
<span class="text-2xl font-bold text-indigo-600" x-text="seconds"></span>
<p class="text-xs text-gray-400">秒</p>
</div>
</div>
</div>
</template>
<template x-if="expired">
<p class="text-red-600 font-medium">此團購已截止</p>
</template>
</div>
Alpine.js 的 countdown 函式放在全域 JS 裡:
// resources/js/countdown.js
document.addEventListener('alpine:init', () => {
Alpine.data('countdown', (deadline) => ({
days: '00',
hours: '00',
minutes: '00',
seconds: '00',
expired: false,
interval: null,
start() {
this.update();
this.interval = setInterval(() => this.update(), 1000);
},
update() {
const now = new Date().getTime();
const target = new Date(deadline).getTime();
const diff = target - now;
if (diff <= 0) {
this.expired = true;
clearInterval(this.interval);
// 截止了,重新整理頁面讓 server 更新狀態
setTimeout(() => window.location.reload(), 2000);
return;
}
this.days = String(Math.floor(diff / (1000 * 60 * 60 * 24))).padStart(2, '0');
this.hours = String(Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))).padStart(
2,
'0'
);
this.minutes = String(Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))).padStart(2, '0');
this.seconds = String(Math.floor((diff % (1000 * 60)) / 1000)).padStart(2, '0');
},
destroy() {
clearInterval(this.interval);
},
}));
});
在 resources/js/app.js 引入:
import './countdown.js';
為什麼倒數計時用 Alpine.js 而不是 Livewire?
因為倒數計時需要每秒更新。如果用 wire:poll.1s,每秒都要跑一趟 AJAX 到 server——這完全沒必要,截止時間又不會變。純前端的 setInterval 搭配 Alpine.js,零 server 負擔,體驗也更流暢。
這是 Livewire + Alpine.js 分工的經典案例:
- 需要 server 資料(人數、狀態) → Livewire
- 純 UI 計算(倒數、動畫) → Alpine.js
實作:揪好買的跟團完整流程
把前面所有元件串起來,做一個完整的團購詳情頁。
路由
// routes/web.php
Route::get('/group-buys/{groupBuy}', function (GroupBuy $groupBuy) {
return view('group-buys.show', compact('groupBuy'));
})->name('group-buys.show');
團購詳情頁
<!-- resources/views/group-buys/show.blade.php -->
<x-layouts.app :title="$groupBuy->title">
<div class="max-w-3xl mx-auto">
{{-- 標題區 --}}
<div class="mb-6">
<div class="flex items-center gap-3 mb-2">
<h1 class="text-2xl font-bold">{{ $groupBuy->title }}</h1>
<span
class="text-sm px-2 py-1 rounded
{{ $groupBuy->status === 'open' ? 'bg-green-100 text-green-700' : '' }}
{{ $groupBuy->status === 'confirmed' ? 'bg-blue-100 text-blue-700' : '' }}
{{ $groupBuy->status === 'cancelled' ? 'bg-red-100 text-red-700' : '' }}
"
>
{{ match($groupBuy->status) { 'open' => '開團中', 'confirmed' => '已成團', 'cancelled' =>
'已取消', default => $groupBuy->status, } }}
</span>
</div>
<p class="text-gray-500">
開團者:{{ $groupBuy->organizer->name }} ・{{ $groupBuy->created_at->diffForHumans() }}
</p>
</div>
{{-- 主要內容 --}}
<div class="grid md:grid-cols-3 gap-6">
{{-- 左欄:商品資訊 --}}
<div class="md:col-span-2 space-y-6">
@if($groupBuy->image)
<img
src="{{ Storage::url($groupBuy->image) }}"
alt="{{ $groupBuy->product_name }}"
class="w-full rounded-xl"
/>
@endif
<div class="prose max-w-none">
<h2>{{ $groupBuy->product_name }}</h2>
<p>{{ $groupBuy->description }}</p>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<p class="text-2xl font-bold text-indigo-600">
${{ number_format($groupBuy->price_per_unit / 100) }}
<span class="text-sm text-gray-500 font-normal">/ 份</span>
</p>
</div>
{{-- 參與者列表 --}}
<div>
<h3 class="font-bold text-lg mb-3">跟團者</h3>
@forelse($groupBuy->participants as $participant)
<div class="flex items-center justify-between py-2 border-b last:border-0">
<span>{{ $participant->name }}</span>
<span class="text-sm text-gray-500">
{{ $participant->pivot->quantity }} 份 @if($participant->pivot->note) ・{{
$participant->pivot->note }} @endif
</span>
</div>
@empty
<p class="text-gray-400">還沒有人跟團,成為第一個吧!</p>
@endforelse
</div>
</div>
{{-- 右欄:跟團操作 --}}
<div class="space-y-6">
{{-- 倒數計時 --}}
@if($groupBuy->status === 'open')
@include('partials.countdown', ['deadline' => $groupBuy->deadline])
@endif
{{-- 跟團人數 --}}
<div class="bg-white rounded-xl border p-5">
<h3 class="font-medium text-gray-700 mb-3">跟團進度</h3>
<livewire:participant-counter :group-buy="$groupBuy" />
</div>
{{-- 跟團按鈕 --}}
<div class="bg-white rounded-xl border p-5">
<livewire:join-group-buy :group-buy="$groupBuy" />
</div>
</div>
</div>
</div>
</x-layouts.app>
流程測試
# 重建資料庫和測試資料
php artisan migrate:fresh --seed
# 啟動開發 server
php artisan serve &
npm run dev &
開兩個瀏覽器(或一個開無痕模式),用不同帳號登入:
- 瀏覽器 A:進入某個團購頁,看到「0 / 5 人」
- 瀏覽器 A:選 2 份,按 +1 跟團 → 成功,顯示「1 / 5 人」
- 瀏覽器 B:登入另一個帳號,進入同一個團購
- 瀏覽器 B:看到「1 / 5 人」(wire:poll 會同步)
- 瀏覽器 B:按 +1 跟團 → 成功,顯示「2 / 5 人」
- 瀏覽器 A:等 30 秒或重新整理 → 看到「2 / 5 人」
再測邊界情況:
- 嘗試重複加入 → 看到「你已經跟過這個團了」
- 在團購截止後按 +1 → 看到「這個團購已經截止了」
- 退出團購 → 人數減少,按鈕恢復成「+1 跟團」
Tinker 測試成團
php artisan tinker
>>> $gb = GroupBuy::where('status', 'open')->first()
>>> $gb->min_participants
# 假設是 3
>>> $gb->participants()->count()
# 假設已經有 3 人
>>> $gb->checkAndConfirm()
# true
>>> $gb->fresh()->status
# "confirmed"
也可以手動跑定時任務:
php artisan group-buys:check-expired
# 處理完成:1 個成團、2 個取消
小結:業務邏輯才是最難的部分
回顧這一章的技術點:
- Session——server 端的暫存記憶體,
put()/get()/flash()三板斧 - Cache——
Cache::remember()快取熱門查詢結果,減少 DB 壓力 - DB Transaction + lockForUpdate()——保護成團判斷的原子性,避免 race condition
- Scheduled Command——定時檢查過期團購,
php artisan schedule:run - Livewire Events——
$this->dispatch()+#[On()]做跨 component 通訊 - Alpine.js——純前端倒數計時,不打 server
- Login Event Listener——登入後合併 session 資料
但老實說,這些技術點都不是這一章最難的部分。最難的是業務邏輯的設計:
- 什麼時候成團?什麼時候取消?什麼時候保持開放?
- 誰能加入?加入的條件是什麼?退出的限制呢?
- Race condition 怎麼處理?狀態轉換的原子性怎麼保證?
- 登入前後的使用者體驗怎麼銜接?
框架給你工具,但不會告訴你「最低成團人數應該設在哪裡檢查」或「截止時間到了但人數不夠要不要寬限十分鐘」。這些是業務決策,只有跟產品經理(或者自己兼任產品經理的你)討論清楚之後,才能轉化成明確的 if-else。
我的建議:先用人話把所有規則列出來,再寫程式碼。本章的驗證清單就是這個做法——六條規則寫清楚了,程式碼幾乎是自動翻譯出來的。最怕的是邊寫邊想、邊改邊加,最後程式碼變成一堆互相矛盾的條件分支,連自己都看不懂。
下一章,我們要面對成團之後最關鍵的問題——收錢。訂單怎麼建立?Stripe 怎麼串接?Laravel Cashier 能幫我們做到什麼?團購的收款跟一般電商不一樣——不是「買了就付」,而是「成團了才收」。這個時序差異會影響整個金流架構的設計。