跳至主要內容
技術

跟團與成團邏輯:用 Laravel Session 打造從「+1」到「成團確認」

跟團與成團邏輯:用 Laravel Session 打造從「+1」到「成團確認」
PHP/Laravel 完全指南 第 8 / 15 篇

本篇是「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 的運作原理其實很簡單:

  1. 使用者第一次造訪網站時,server 產生一個隨機 ID(Session ID)
  2. 這個 ID 透過 Cookie 送回瀏覽器
  3. 往後每次請求,瀏覽器自動帶上這個 Cookie
  4. Server 收到 Cookie,用 Session ID 查找對應的資料
  5. 資料存在 server 端(檔案、資料庫、Redis),不在瀏覽器裡

所以 Session 就是 server 端的暫存記憶體——你放什麼進去,下一次請求都還在。

跨框架對照

概念LaravelExpress (Node.js)Django (Python)
Session 套件內建express-session內建
儲存位置設定SESSION_DRIVER in .envstore optionSESSION_ENGINE
讀取session('key')req.session.keyrequest.session['key']
寫入session(['key' => 'val'])req.session.key = 'val'request.session['key'] = 'val'
Flash datasession()->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 跟團」按鈕。點下去之前,他要先選擇數量(跟幾份)。點下去之後:

  1. 驗證——團購還在開團嗎?還沒滿嗎?這個人已經跟過了嗎?
  2. 寫入——把這個人加到 group_buy_user 中間表
  3. 回饋——頁面顯示「成功加入!」,參與人數即時更新

取消跟團的邏輯相反:從中間表 detach,人數減少。

驗證規則

在寫程式碼之前,先把規則列清楚。這是業務邏輯最重要的步驟——先用人話寫出所有條件,再翻譯成程式碼:

  1. 團購 status 必須是 open
  2. 團購 deadline 還沒過
  3. 使用者必須已登入
  4. 使用者不能重複加入同一個團購
  5. 如果有 max_participants,目前人數不能超過上限
  6. 數量必須是正整數

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   │
    │  (已成團)    │     │  (已取消)    │
    └──────────────┘     └──────────────┘

規則很明確:

  1. 成團:參與人數 >= min_participants(不管截止時間到了沒,都可以成團)
  2. 取消:截止時間到了,但參與人數 < min_participants
  3. 開團中:還沒截止,人數也還不夠

不過「人數一夠就提前成團」是我做的產品選擇,不是唯一正解,這裡先講清楚兩件事:

  • 提前鎖定有代價:有些團購反而希望跑滿整個截止時間,盡量蒐集人數去衝更低的折扣級距(湊到 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 裡的暫存資料合併到資料庫裡。

場景

  1. 訪客 A 瀏覽團購 #42,把它加入「收藏清單」(存在 session)
  2. 訪客 A 按下「+1 跟團」→ 被導向登入頁
  3. 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 做導向
        }
    }
}

EventServiceProviderAppServiceProvider 裡註冊:

// 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 &

開兩個瀏覽器(或一個開無痕模式),用不同帳號登入:

  1. 瀏覽器 A:進入某個團購頁,看到「0 / 5 人」
  2. 瀏覽器 A:選 2 份,按 +1 跟團 → 成功,顯示「1 / 5 人」
  3. 瀏覽器 B:登入另一個帳號,進入同一個團購
  4. 瀏覽器 B:看到「1 / 5 人」(wire:poll 會同步)
  5. 瀏覽器 B:按 +1 跟團 → 成功,顯示「2 / 5 人」
  6. 瀏覽器 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 能幫我們做到什麼?團購的收款跟一般電商不一樣——不是「買了就付」,而是「成團了才收」。這個時序差異會影響整個金流架構的設計。

留言討論

esc
輸入關鍵字搜尋文章...
查看收藏 →