跳至主要內容
技術

RESTful API 與 Sanctum:讓 LINE Bot 也能開團

RESTful API 與 Sanctum:讓 LINE Bot 也能開團
PHP/Laravel 完全指南 第 11 / 15 篇

本篇是「PHP/Laravel 完全指南」系列的第 11 / 15 篇。你可以從系列總覽開始閱讀,也可以直接接著看本文。

你的網頁應用做得很好,使用者可以開團、跟團、下單。但現實世界不只有瀏覽器。有人想用手機 App 開團,有人想在 LINE 群組裡直接查詢團購狀態,還有合作店家想透過程式自動同步訂單資料。這些需求都指向同一件事:你需要 API。

RESTful API 是讓你的後端從「只服務網頁」進化到「服務所有人」的關鍵。Laravel 這部分該有的都有——API Routes、API Resources、Sanctum Token 認證。你不需要另外架一套 API Server,同一個 Laravel 專案就能同時服務網頁和 API 客戶端。

這一章我們要把揪好買的核心功能——開團列表、跟團操作、訂單查詢——全部包裝成 RESTful API,用 Sanctum 做 Token 認證保護端點,設定 Rate Limiting 防止濫用,還會示範 LINE Bot 整合的概念。一套後端程式碼,同時餵給多個前端消費者。

為什麼需要 API:不只是網頁的世界

前十章我們做的事情,都是「使用者打開瀏覽器 → 伺服器回傳 HTML」。這在桌面端的體驗很好,但想想這些場景:

  • 手機 App:你的合作夥伴想做一個揪好買的 iOS/Android App,它不需要 HTML,它需要 JSON 資料
  • LINE Bot:台灣人都在用 LINE,如果在群組裡打「@揪好買 查團購」就能看到最新團購列表,多方便?
  • 第三方整合:合作店家的 ERP 系統想自動抓訂單資料,它不會開瀏覽器,它用程式 call API
  • SPA 前端:如果你的前端團隊想用 React 或 Vue 重寫介面,它們只需要跟 API 溝通
  • 自動化腳本:你自己想寫一個 cron job 在截止時間自動關閉團購,用 API 最乾淨

這些消費者有一個共通點:它們不需要 HTML,它們需要結構化的資料。而 JSON 就是這個通用語言。

瀏覽器使用者 → routes/web.php → 回傳 HTML(Blade view)
手機 App     → routes/api.php → 回傳 JSON
LINE Bot     → routes/api.php → 回傳 JSON
第三方系統   → routes/api.php → 回傳 JSON

API 不是什麼高深的東西——它就是你的後端用 JSON 格式說話,讓任何程式都能聽懂。

API Routes:api.php 與 web.php 的差異

安裝 API 路由

重要的一點:Laravel 12 預設不包含 routes/api.php。這是刻意的設計——不是每個專案都需要 API。要啟用它,跑一行指令:

php artisan install:api

這個指令幫你做了三件事:

  1. 建立 routes/api.php 檔案
  2. 安裝 Laravel Sanctum(Token 認證套件)
  3. 執行 Sanctum 需要的 migration(personal_access_tokens 表)

裝完後你會在 routes/api.php 裡看到這樣的起始內容:

<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::get('/user', function (Request $request) {
    return $request->user();
})->middleware('auth:sanctum');

web.php vs api.php:兩個世界

這兩個路由檔案的差異不只是「放在不同檔案」而已,它們跑在完全不同的 middleware 環境裡:

特性web.phpapi.php
URL 前綴無(/group-buys/api/api/group-buys
Session有(記住登入狀態)無(Stateless)
CSRF 保護有(@csrf無(不需要)
Cookie
認證方式Session-based(瀏覽器)Token-based(Sanctum)
Rate Limiting無預設throttle:api(每分鐘 60 次)
回傳格式HTML(Blade view)JSON

關鍵差異是stateless:每個 API request 都是獨立的,伺服器不會「記得」你是誰。每次請求都要帶著 Token 來證明身份。這就像去便利商店——你每次都要出示會員卡,店員不會記得你昨天來過。

定義 API 路由

// routes/api.php
use App\Http\Controllers\Api\GroupBuyController;

// 公開端點——任何人都能呼叫
Route::get('/group-buys', [GroupBuyController::class, 'index']);
Route::get('/group-buys/{groupBuy}', [GroupBuyController::class, 'show']);

// 受保護端點——需要 Sanctum Token
Route::middleware('auth:sanctum')->group(function () {
    Route::post('/group-buys', [GroupBuyController::class, 'store']);
    Route::post('/group-buys/{groupBuy}/join', [GroupBuyController::class, 'join']);
    Route::get('/my/orders', [GroupBuyController::class, 'myOrders']);
});

注意我把 API Controller 放在 App\Http\Controllers\Api\ 命名空間下——跟 web Controller 分開,讓結構清楚。

php artisan make:controller Api/GroupBuyController --api

--api flag 會生成只有 indexstoreshowupdatedestroy 五個方法的 Controller(沒有 createedit,因為 API 不需要「表單頁面」)。

API Resource:優雅的 JSON 轉換

直接在 Controller 裡 return $groupBuy; 可以嗎?技術上可以,但你會把整個 Model——包括 created_atupdated_at、甚至 password——全部暴露出去。API Resource 讓你精準控制「回傳哪些欄位、長什麼格式」。

建立 Resource

php artisan make:resource GroupBuyResource
<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class GroupBuyResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'           => $this->id,
            'title'        => $this->title,
            'description'  => $this->description,
            'target_amount'=> $this->target_amount,
            'current_amount'=> $this->current_amount,
            'status'       => $this->status->value,
            'deadline'     => $this->deadline->toIso8601String(),
            'organizer'    => [
                'id'   => $this->user->id,
                'name' => $this->user->name,
            ],
            'participants_count' => $this->participants_count,
            'created_at'   => $this->created_at->toIso8601String(),
        ];
    }
}

注意幾件事:

  • 只暴露需要的欄位——user_id 換成巢狀的 organizer 物件,更直覺
  • 日期用 ISO 8601 格式——2026-08-10T08:00:00+08:00,前端好 parse
  • 不暴露 updated_at——API 消費者不需要這個
  • Enum 轉成字串——$this->status->value 而不是整個 Enum 物件

在 Controller 裡使用

use App\Http\Resources\GroupBuyResource;

class GroupBuyController extends Controller
{
    public function index()
    {
        $groupBuys = GroupBuy::withCount('participants')
            ->where('status', 'open')
            ->latest()
            ->paginate(15);

        return GroupBuyResource::collection($groupBuys);
    }

    public function show(GroupBuy $groupBuy)
    {
        $groupBuy->loadCount('participants');

        return new GroupBuyResource($groupBuy);
    }
}

回傳的 JSON 長這樣

單一資源:

{
    "data": {
        "id": 42,
        "title": "芒果季團購",
        "description": "玉井愛文芒果,產地直送",
        "target_amount": 30,
        "current_amount": 18,
        "status": "open",
        "deadline": "2026-08-15T23:59:59+08:00",
        "organizer": {
            "id": 7,
            "name": "小陳"
        },
        "participants_count": 18,
        "created_at": "2026-08-01T10:30:00+08:00"
    }
}

列表(搭配 Pagination):

{
    "data": [
        { "id": 42, "title": "芒果季團購", ... },
        { "id": 41, "title": "阿里山烏龍茶", ... }
    ],
    "links": {
        "first": "http://localhost/api/group-buys?page=1",
        "last": "http://localhost/api/group-buys?page=5",
        "prev": null,
        "next": "http://localhost/api/group-buys?page=2"
    },
    "meta": {
        "current_page": 1,
        "last_page": 5,
        "per_page": 15,
        "total": 67
    }
}

Laravel 自動把 paginate() 的結果包成帶 linksmeta 的結構——前端不用自己算分頁。

ResourceCollection:集合層級的客製化

如果你想在集合回傳時加額外資訊(比如統計數據),可以建一個 Collection class:

php artisan make:resource GroupBuyCollection
class GroupBuyCollection extends ResourceCollection
{
    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection,
            'stats' => [
                'total_open' => GroupBuy::where('status', 'open')->count(),
            ],
        ];
    }
}

安全提醒: Resource 是你 API 的門面。永遠不要暴露 passwordremember_token、內部的 pivot 資料,或任何使用者不該看到的欄位。養成習慣——在 toArray() 裡明確列出每一個欄位,而不是用 parent::toArray() 全部丟出去。

Sanctum Token Authentication

第六章我們用 Session 做瀏覽器的認證。但 API 客戶端(手機 App、LINE Bot、第三方系統)不用瀏覽器,沒有 Cookie 和 Session。它們需要的是 Token-based 認證:每次請求都在 Header 帶一個 token,伺服器驗證這個 token 來識別身份。

Token 認證的流程

1. 使用者用帳號密碼呼叫 /api/login
2. 伺服器驗證成功,回傳一個 token
3. 之後的每個請求,在 Header 帶上:Authorization: Bearer <token>
4. 伺服器看到 token,就知道你是誰
5. 登出時,伺服器把 token 刪掉

建立登入端點

// routes/api.php
use App\Http\Controllers\Api\AuthController;

Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);

Route::middleware('auth:sanctum')->group(function () {
    Route::post('/logout', [AuthController::class, 'logout']);
    Route::get('/user', [AuthController::class, 'user']);
});
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

class AuthController extends Controller
{
    public function register(Request $request)
    {
        $validated = $request->validate([
            'name'     => 'required|string|max:255',
            'email'    => 'required|string|email|unique:users',
            'password' => 'required|string|min:8|confirmed',
        ]);

        $user = User::create([
            'name'     => $validated['name'],
            'email'    => $validated['email'],
            'password' => Hash::make($validated['password']),
        ]);

        $token = $user->createToken('api-token')->plainTextToken;

        return response()->json([
            'user'  => $user->only('id', 'name', 'email'),
            'token' => $token,
        ], 201);
    }

    public function login(Request $request)
    {
        $request->validate([
            'email'    => 'required|email',
            'password' => 'required',
        ]);

        $user = User::where('email', $request->email)->first();

        if (! $user || ! Hash::check($request->password, $user->password)) {
            throw ValidationException::withMessages([
                'email' => ['帳號或密碼錯誤。'],
            ]);
        }

        $token = $user->createToken('api-token')->plainTextToken;

        return response()->json([
            'user'  => $user->only('id', 'name', 'email'),
            'token' => $token,
        ]);
    }

    public function logout(Request $request)
    {
        // 刪除當前使用的 token
        $request->user()->currentAccessToken()->delete();

        return response()->json(['message' => '已登出']);
    }

    public function user(Request $request)
    {
        return response()->json($request->user()->only('id', 'name', 'email', 'role'));
    }
}

createToken() 回傳的 plainTextToken 長這樣:1|abc123def456...,前面的數字是 token ID,後面是實際的 token 值。這個值只會在建立時出現一次——資料庫裡存的是 hash 過的版本。告訴你的 API 消費者:拿到 token 就要存好。

Token Abilities(權限)

不是每個 token 都應該有全部權限。例如,LINE Bot 只需要「讀取」的權限,不該能刪除團購:

// 建立有限權限的 token
$token = $user->createToken('line-bot', ['group-buys:read']);

// 建立完整權限的 token
$token = $user->createToken('mobile-app', ['*']);

在路由裡檢查 ability:

Route::middleware(['auth:sanctum', 'ability:group-buys:read'])->group(function () {
    Route::get('/group-buys', [GroupBuyController::class, 'index']);
    Route::get('/group-buys/{groupBuy}', [GroupBuyController::class, 'show']);
});

Route::middleware(['auth:sanctum', 'ability:group-buys:write'])->group(function () {
    Route::post('/group-buys', [GroupBuyController::class, 'store']);
    Route::delete('/group-buys/{groupBuy}', [GroupBuyController::class, 'destroy']);
});

需要在 bootstrap/app.php 裡註冊 ability middleware:

use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'abilities' => CheckAbilities::class,   // 必須擁有「全部」列出的 abilities
        'ability'   => CheckForAnyAbility::class, // 擁有「其中一個」就通過
    ]);
})

撤銷 Token

// 撤銷當前 token
$request->user()->currentAccessToken()->delete();

// 撤銷所有 token(強制所有裝置登出)
$request->user()->tokens()->delete();

// 撤銷特定 token
$request->user()->tokens()->where('id', $tokenId)->delete();

Sanctum vs JWT

你可能聽過 JWT(JSON Web Token)。在 Laravel 生態系裡,Sanctum 和 JWT 的差異是:

特性SanctumJWT(tymon/jwt-auth)
官方維護是(Laravel 團隊)否(社群套件)
Token 儲存資料庫無狀態(Token 自帶資訊)
撤銷 Token簡單(刪資料庫記錄)複雜(需要黑名單機制)
適用場景SPA、手機 App、第三方微服務間通訊
複雜度中高
建議90% 的專案用這個除非有跨服務需求

除非你在做微服務架構,否則 Sanctum 就是正確的選擇。官方維護、設定簡單、能撤銷 token,沒什麼理由用 JWT。

Rate Limiting:保護你的 API

公開的 API 如果沒有速率限制,一個寫壞的爬蟲或惡意攻擊就能把你的伺服器打掛。Rate Limiting 限制每個使用者(或 IP)在一段時間內能發多少請求。

預設設定

php artisan install:api 安裝後,api.php 的路由自動帶 throttle:api middleware。預設限制在 bootstrap/app.php 裡:

->withMiddleware(function (Middleware $middleware) {
    $middleware->throttleApi();
    // 等同於 throttle:api,預設 60 次/分鐘
})

自訂 Rate Limiter

AppServiceProviderboot() 方法裡定義:

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Http\Request;

public function boot(): void
{
    // 已登入使用者:每分鐘 120 次
    // 未登入(靠 IP):每分鐘 30 次
    RateLimiter::for('api', function (Request $request) {
        return $request->user()
            ? Limit::perMinute(120)->by($request->user()->id)
            : Limit::perMinute(30)->by($request->ip());
    });

    // 登入端點特別嚴格:每分鐘 5 次(防暴力破解)
    RateLimiter::for('login', function (Request $request) {
        return Limit::perMinute(5)->by($request->ip());
    });
}

套用到路由:

Route::post('/login', [AuthController::class, 'login'])
    ->middleware('throttle:login');

Rate Limit Response Headers

Laravel 自動在回傳的 HTTP Header 裡告訴客戶端剩餘額度:

X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
Retry-After: 58          ← 超過限制時,告訴你幾秒後可以再試

自訂超過限制時的回應

RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)
        ->by($request->user()?->id ?: $request->ip())
        ->response(function (Request $request, array $headers) {
            return response()->json([
                'message' => '請求太頻繁,請稍後再試。',
            ], 429, $headers);
        });
});

API 版本管理策略

API 一旦發布出去,就有人在用。你改了回傳格式,人家的 App 就壞了。版本管理讓你能安全地升級 API 而不影響現有客戶端。

方法一:URL 前綴(最常見)

// routes/api.php

// v1 版本
Route::prefix('v1')->group(function () {
    Route::get('/group-buys', [V1\GroupBuyController::class, 'index']);
});

// v2 版本(未來)
Route::prefix('v2')->group(function () {
    Route::get('/group-buys', [V2\GroupBuyController::class, 'index']);
});

呼叫方式:GET /api/v1/group-buys

方法二:Header 版本控制

GET /api/group-buys
Accept: application/vnd.jiuhaomai.v1+json

比較少見,但更「正統」。

務實的建議

不要過早加版本號。如果你的 API 還在開發階段、消費者只有自己的團隊,直接用 /api/group-buys 就好。等到以下情況才加 versioning:

  • 有外部第三方在用你的 API
  • 你需要做 breaking change(欄位改名、移除、格式變更)
  • 你需要同時維護新舊版本

加版本號的成本是真實的:你要維護兩份 Controller、兩份 Resource、兩份文件。YAGNI(You Ain’t Gonna Need It)——等需要的時候再加。

API 文件化:Scramble

好的 API 沒有文件等於不存在。你的 API 消費者不該來看你的程式碼才知道怎麼呼叫端點。Scramble 是一個能直接從 Laravel 程式碼自動生成 OpenAPI(Swagger)文件的套件——不需要寫任何註解或額外設定。

安裝

composer require dedoc/scramble

就這樣。打開瀏覽器:

http://localhost:8000/docs/api

你會看到一個互動式的 API 文件頁面,自動列出所有端點、參數類型、回傳格式。它是透過分析你的 Route、Controller、FormRequest、Resource 來推斷結構的。

為什麼推薦 Scramble

  • 零設定:裝完就能用,不用在程式碼裡寫一堆 annotation
  • 自動同步:程式碼改了文件就改了,不會有文件過期的問題
  • OpenAPI 標準:輸出的是標準的 OpenAPI 3.x spec,可以匯入 Postman、Insomnia
  • 互動式測試:在文件頁面直接送請求測試

如果你需要更細緻的文件控制(加範例、加說明),也可以搭配 PHPDoc 補充:

/**
 * 取得團購列表
 *
 * 回傳所有進行中的團購,支援分頁。
 */
public function index()
{
    // ...
}

CORS 設定:跨域請求處理

如果你的 React 或 Vue 前端跑在 localhost:3000,API 跑在 localhost:8000,瀏覽器會擋住跨域請求。這不是 bug,是瀏覽器的安全機制——CORS(Cross-Origin Resource Sharing)。

什麼是 CORS

當瀏覽器從 A 網域發請求到 B 網域時,B 必須在 Response Header 裡明確說「我允許 A 來存取」。如果沒有這些 Header,瀏覽器會直接擋掉回應。

前端(localhost:3000)→ 請求 → API(localhost:8000)

                         API 回傳 CORS Header:
                         Access-Control-Allow-Origin: http://localhost:3000

                         瀏覽器檢查 Header → OK → 前端拿到資料

Laravel 的 CORS 設定

Laravel 內建 CORS 處理(由全域 middleware 中的 HandleCors 自動處理,不發佈設定檔也能運作)。Laravel 12 預設不會發佈這個設定檔,需先執行 php artisan config:publish cors,才會在 config/cors.php 產生它,接著就能編輯:

return [
    'paths' => ['api/*'],  // 哪些路徑要處理 CORS

    'allowed_methods' => ['*'],  // GET, POST, PUT, DELETE...

    'allowed_origins' => [
        'http://localhost:3000',      // 開發環境前端
        'https://jiuhaomai.tw',      // 正式環境
    ],

    'allowed_origins_patterns' => [],

    'allowed_headers' => ['*'],

    'exposed_headers' => [],

    'max_age' => 0,  // Preflight 快取秒數

    'supports_credentials' => false,
];

常見踩坑

  1. allowed_origins 設成 ['*'] 很方便但不安全——正式環境一定要列出明確的 domain
  2. Preflight request(OPTIONS)被 Nginx 擋掉——確保你的 Nginx/Apache 設定允許 OPTIONS 方法通過
  3. 帶 credentials 時不能用 *——如果 supports_credentialstrueallowed_origins 不能用萬用字元,必須列明
  4. 忘記加 api/* 到 paths——如果你的 API 路由不在 api/ 底下,CORS 設定不會生效

提醒: CORS 是瀏覽器的機制。手機 App 和 server-to-server 的請求不受 CORS 限制。所以你的 LINE Bot 和後端爬蟲不需要擔心 CORS。

實作:揪好買 API 與 LINE Bot 概念整合

把前面學的全部串起來。我們要幫揪好買建一組完整的 API 端點。

完整的 API 路由

// routes/api.php
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\GroupBuyController;

// 認證端點
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login'])
    ->middleware('throttle:login');

// 公開端點
Route::get('/group-buys', [GroupBuyController::class, 'index']);
Route::get('/group-buys/{groupBuy}', [GroupBuyController::class, 'show']);

// 受保護端點
Route::middleware('auth:sanctum')->group(function () {
    // 認證
    Route::post('/logout', [AuthController::class, 'logout']);
    Route::get('/user', [AuthController::class, 'user']);

    // 團購操作
    Route::post('/group-buys', [GroupBuyController::class, 'store']);
    Route::post('/group-buys/{groupBuy}/join', [GroupBuyController::class, 'join']);

    // 我的資料
    Route::get('/my/orders', [GroupBuyController::class, 'myOrders']);
});

GroupBuyController(API 版)

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Resources\GroupBuyResource;
use App\Models\GroupBuy;
use Illuminate\Http\Request;

class GroupBuyController extends Controller
{
    /**
     * 取得進行中的團購列表
     */
    public function index(Request $request)
    {
        $groupBuys = GroupBuy::withCount('participants')
            ->where('status', 'open')
            ->when($request->query('search'), function ($query, $search) {
                $query->where('title', 'like', "%{$search}%");
            })
            ->latest()
            ->paginate(15);

        return GroupBuyResource::collection($groupBuys);
    }

    /**
     * 取得單一團購詳情
     */
    public function show(GroupBuy $groupBuy)
    {
        $groupBuy->loadCount('participants');
        $groupBuy->load('user:id,name');

        return new GroupBuyResource($groupBuy);
    }

    /**
     * 建立新團購(需要認證)
     */
    public function store(Request $request)
    {
        $this->authorize('create', GroupBuy::class);

        $validated = $request->validate([
            'title'         => 'required|string|max:255',
            'description'   => 'required|string',
            'target_amount' => 'required|integer|min:2',
            'deadline'      => 'required|date|after:now',
            'price_per_unit'=> 'required|numeric|min:0',
        ]);

        $groupBuy = $request->user()->groupBuys()->create($validated);

        return (new GroupBuyResource($groupBuy))
            ->response()
            ->setStatusCode(201);
    }

    /**
     * 加入團購
     */
    public function join(Request $request, GroupBuy $groupBuy)
    {
        // 檢查團購是否還開放
        if ($groupBuy->status !== 'open') {
            return response()->json([
                'message' => '此團購已截止或已取消。',
            ], 422);
        }

        // 檢查是否已經加入
        if ($groupBuy->participants()->where('user_id', $request->user()->id)->exists()) {
            return response()->json([
                'message' => '你已經加入這個團購了。',
            ], 422);
        }

        $validated = $request->validate([
            'quantity' => 'required|integer|min:1',
        ]);

        $groupBuy->participants()->attach($request->user()->id, [
            'quantity' => $validated['quantity'],
        ]);

        return response()->json([
            'message' => '成功加入團購!',
        ]);
    }

    /**
     * 我的訂單
     */
    public function myOrders(Request $request)
    {
        $orders = $request->user()
            ->joinedGroupBuys()
            ->withPivot('quantity')
            ->withCount('participants')
            ->latest()
            ->paginate(15);

        return GroupBuyResource::collection($orders);
    }
}

ParticipantResource(跟團者資訊)

如果 API 需要回傳參與者列表,也用 Resource 包裝:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class ParticipantResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'user' => [
                'id'   => $this->id,
                'name' => $this->name,
            ],
            'quantity'  => $this->pivot->quantity,
            'joined_at' => $this->pivot->created_at?->toIso8601String(),
        ];
    }
}

用 curl 測試

API 不需要瀏覽器就能測試。用 curlhttpie 在終端機直接打:

# 註冊
curl -X POST http://localhost:8000/api/register \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "name": "小陳",
    "email": "chen@example.com",
    "password": "password123",
    "password_confirmation": "password123"
  }'

# 回傳:{"user":{"id":1,"name":"小陳","email":"chen@example.com"},"token":"1|abc123..."}
# 登入
curl -X POST http://localhost:8000/api/login \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"email": "chen@example.com", "password": "password123"}'
# 查看團購列表(不需要 token)
curl http://localhost:8000/api/group-buys \
  -H "Accept: application/json"
# 加入團購(需要 token)
curl -X POST http://localhost:8000/api/group-buys/42/join \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -H "Authorization: Bearer 1|abc123..." \
  -d '{"quantity": 2}'
# 如果你裝了 httpie,語法更簡潔
http POST localhost:8000/api/login email=chen@example.com password=password123
http localhost:8000/api/group-buys Authorization:"Bearer 1|abc123..."

重要: 請求 API 時一定要帶 Accept: application/json Header。如果不帶,Laravel 在驗證失敗時會嘗試 redirect 到一個 HTML 頁面——在 API 裡這會變成很奇怪的 302 回應。

LINE Bot Webhook 概念整合

LINE Bot 的運作邏輯是:使用者在 LINE 群組裡傳訊息 → LINE 平台把訊息轉送到你的 webhook URL → 你的伺服器處理後回覆。

使用者在 LINE 傳「查團購」

LINE 平台 → POST /api/line/webhook(你的伺服器)

伺服器解析訊息,查詢 GroupBuy 資料

伺服器透過 LINE Messaging API 回覆結果

webhook 端點的概念實作:

// routes/api.php
Route::post('/line/webhook', [LineWebhookController::class, 'handle']);
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\GroupBuy;
use Illuminate\Http\Request;

class LineWebhookController extends Controller
{
    public function handle(Request $request)
    {
        // LINE 送來的 webhook 事件
        $events = $request->input('events', []);

        foreach ($events as $event) {
            if ($event['type'] !== 'message') {
                continue;
            }

            $text = $event['message']['text'] ?? '';
            $replyToken = $event['replyToken'];

            if (str_contains($text, '查團購')) {
                $this->replyGroupBuyList($replyToken);
            } elseif (str_contains($text, '開團中')) {
                $this->replyOpenGroupBuys($replyToken);
            }
        }

        return response()->json(['status' => 'ok']);
    }

    private function replyGroupBuyList(string $replyToken): void
    {
        $groupBuys = GroupBuy::where('status', 'open')
            ->latest()
            ->take(5)
            ->get();

        $message = "目前開團中的團購:\n\n";

        foreach ($groupBuys as $gb) {
            $message .= "🛒 {$gb->title}\n";
            $message .= "   截止:{$gb->deadline->format('m/d H:i')}\n";
            $message .= "   目標:{$gb->current_amount}/{$gb->target_amount} 人\n\n";
        }

        // 實際整合時透過 LINE Messaging API SDK 回覆
        // v7 SDK:$messagingApi->replyMessage(new ReplyMessageRequest([
        //     'replyToken' => $replyToken,
        //     'messages' => [new TextMessage(['type' => 'text', 'text' => $message])],
        // ])); // 舊版 LINEBot::replyText 已淘汰
    }

    private function replyOpenGroupBuys(string $replyToken): void
    {
        $count = GroupBuy::where('status', 'open')->count();

        $message = "目前有 {$count} 個團購進行中!\n輸入「查團購」看詳細列表。";

        // v7 SDK:$messagingApi->replyMessage(new ReplyMessageRequest([...]));(舊版 LINEBot::replyText 已淘汰)
    }
}

這裡只展示概念——實際的 LINE Bot 整合需要安裝 linecorp/line-bot-sdk、設定 Channel Access Token 和 Channel Secret、做 Signature 驗證。但核心架構就是這樣:一個 webhook 端點,接收訊息、查詢資料庫、回覆結果。你的 API 和 LINE Bot 共用同一個資料庫和 Model,不需要重寫任何商業邏輯。

錯誤回應的統一格式

好的 API 在出錯時也要回傳結構化的 JSON,而不是 HTML 錯誤頁面。在 bootstrap/app.php 統一處理:

use Illuminate\Http\Request;

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->shouldRenderJsonWhen(function (Request $request) {
        return $request->is('api/*') || $request->expectsJson();
    });
})

這樣所有 /api/* 路由的例外都會以 JSON 格式回傳:

{
    "message": "此團購已截止或已取消。",
    "errors": {}
}

驗證錯誤則會自動回傳 422 和欄位明細:

{
    "message": "The title field is required.",
    "errors": {
        "title": ["The title field is required."],
        "deadline": ["The deadline must be a date after now."]
    }
}

小結:一套後端,多個前端

這一章我們把揪好買從「只服務瀏覽器」升級成「服務所有人」:

API 基礎:

  • php artisan install:api 啟用 API 路由和 Sanctum
  • routes/api.php 是 stateless 的——沒有 Session、沒有 CSRF,靠 Token 認證
  • API Resource 精準控制 JSON 輸出,搭配 Pagination 自動生成分頁資訊

認證與安全:

  • Sanctum Token 認證——createToken() 建立、Bearer Header 攜帶、delete() 撤銷
  • Token Abilities 做細粒度權限控制
  • Rate Limiting 防止 API 被濫用——已登入使用者和匿名使用者各自獨立限制

實務面:

  • CORS 設定讓 SPA 前端能跨域存取
  • Scramble 自動生成 API 文件,零設定
  • LINE Bot webhook 概念——同一個 Laravel 專案服務所有消費者
  • API 版本管理 YAGNI——等需要時再加

現在揪好買有了完整的 API 端點,手機 App 可以呼叫、LINE Bot 可以整合、第三方系統可以串接。但平台越來越大,開團主需要看統計報表,管理員需要後台管理介面,團購列表開始變慢——下一章,我們要用 Filament 3 快速建立管理後台,並深入 Eloquent 進階查詢技巧,解決真實世界的效能問題。

留言討論

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