分類: 最佳實踐

  • 企業級多租戶認證:Token 驗證實戰

    商業價值:多租戶認證讓「一套系統服務多個商戶」,是 SaaS 模式的技術基礎。這直接支撐 導讀篇提到的 70% 人力成本降低(多商戶共用系統,不用每家都建一套)。

    前言:SaaS 系統的認證挑戰

    在多租戶 SaaS 系統中,我們面臨兩種使用者:

    角色 說明 存取範圍
    商戶 (Merchant) 使用系統的客戶 只能看到自己的資料
    平台 (Platform) 系統管理者 可以看到所有商戶資料
    安全需求:

    • 商戶 A 絕對不能看到商戶 B 的訂單
    • Token 被盜用時要能快速失效
    • 支援多裝置同時登入

    Token 設計

    Token 結構

    {
    “tokenId”: “uuid-xxxx-xxxx”,
    “userType”: “MERCHANT”,
    “userId”: “U001”,
    “merchantId”: “M001”,
    “permissions”: [“ORDER_READ”, “ORDER_WRITE”],
    “issuedAt”: “2024-03-18T10:00:00Z”,
    “expiresAt”: “2024-03-18T12:00:00Z”
    }

    Token 驗證流程

    請求進來


    ┌─────────────────────┐
    │ 1. 檢查 Token 存在 │
    └──────────┬──────────┘


    ┌─────────────────────┐
    │ 2. 檢查 Token 有效 │ ──── 過期/無效 ──→ 401 Unauthorized
    └──────────┬──────────┘


    ┌─────────────────────┐
    │ 3. 載入使用者資訊 │
    └──────────┬──────────┘


    ┌─────────────────────┐
    │ 4. 設定安全上下文 │
    └──────────┬──────────┘


    繼續處理請求

    Filter 實作

    @Component
    public class TokenAuthFilter extends OncePerRequestFilter {

    @Autowired
    private TokenService tokenService;

    @Autowired
    private TokenCache tokenCache;

    @Override
    protected void doFilterInternal(
    HttpServletRequest request,
    HttpServletResponse response,
    FilterChain chain) throws Exception {

    // 1. 從 Header 取得 Token
    String token = extractToken(request);

    if (token == null) {
    sendError(response, “Missing token”);
    return;
    }

    // 2. 驗證 Token(先查快取)
    TokenInfo tokenInfo = tokenCache.get(token);
    if (tokenInfo == null) {
    tokenInfo = tokenService.validate(token);
    if (tokenInfo != null) {
    tokenCache.put(token, tokenInfo);
    }
    }

    if (tokenInfo == null || tokenInfo.isExpired()) {
    sendError(response, “Invalid or expired token”);
    return;
    }

    // 3. 設定安全上下文
    SecurityContext.set(tokenInfo);

    try {
    chain.doFilter(request, response);
    } finally {
    SecurityContext.clear();
    }
    }

    private String extractToken(HttpServletRequest request) {
    String header = request.getHeader(“Authorization”);
    if (header != null && header.startsWith(“Bearer “)) {
    return header.substring(7);
    }
    return null;
    }
    }


    商戶隔離

    所有資料存取都要加上商戶過濾:

    @Repository
    public class OrderRepository {

    public List<Order> findOrders(OrderQuery query) {
    // 從安全上下文取得當前商戶
    TokenInfo token = SecurityContext.get();

    if (token.isMerchant()) {
    // 商戶只能查自己的資料
    query.setMerchantId(token.getMerchantId());
    }
    // 平台角色可以查所有商戶(不加過濾條件)

    return jdbcTemplate.query(
    “SELECT * FROM orders WHERE merchant_id = ? AND …”,
    query.getMerchantId(),

    );
    }
    }

    安全保證:即使前端傳入其他商戶 ID,後端也會強制覆蓋為當前登入商戶的 ID。

    Token 快取策略

    策略 設定 原因
    快取時間 5 分鐘 減少 DB 查詢,但不會太舊
    最大數量 10,000 避免記憶體爆炸
    淘汰策略 LRU 不常用的 Token 先移除
    @Bean
    public Cache<String, TokenInfo> tokenCache() {
    return Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofMinutes(5))
    .recordStats()
    .build();
    }

    Token 失效機制

    當需要強制登出時:

    @Service
    public class TokenService {

    /**
    * 強制失效單一 Token
    */

    public void revokeToken(String tokenId) {
    // 1. 從資料庫標記失效
    tokenRepository.markRevoked(tokenId);

    // 2. 從快取移除
    tokenCache.invalidate(tokenId);

    // 3. 發布失效事件(通知其他節點)
    eventPublisher.publish(new TokenRevokedEvent(tokenId));
    }

    /**
    * 強制登出商戶所有 Token
    */

    public void revokeAllTokens(String merchantId) {
    List<String> tokens = tokenRepository.findByMerchant(merchantId);
    tokens.forEach(this::revokeToken);
    }
    }


    權限檢查

    @RestController
    public class OrderController {

    // 需要 ORDER_READ 權限
    @RequirePermission(“ORDER_READ”)
    @GetMapping(“/api/orders”)
    public List<Order> listOrders() {
    return orderService.findOrders();
    }

    // 需要 ORDER_WRITE 權限
    @RequirePermission(“ORDER_WRITE”)
    @PostMapping(“/api/orders/{id}/ship”)
    public Order shipOrder(@PathVariable String id) {
    return orderService.ship(id);
    }
    }


    安全總結

    安全機制 實作方式 防護目標
    Token 驗證 Filter 攔截 未授權存取
    商戶隔離 強制覆蓋 merchantId 資料外洩
    Token 快取 Caffeine + 分散式同步 效能
    強制失效 事件發布 + 快取清除 帳號被盜
    權限檢查 註解 + AOP 越權操作

    為什麼不用其他方案?

    方案 優點 缺點 結論
    Session-based 簡單、狀態伺服器端管理 不適合分散式、需要 Session 複製 單機可用
    JWT(無狀態) 不需查 DB、自包含 無法主動失效、Token 可能很大 短期 Token 可用
    OAuth 2.0 標準協議、支援第三方 複雜、需要額外服務 需要 SSO 時用
    Token + 快取 可主動失效、效能好 需要 Redis SaaS 系統首選

    實戰踩坑

    坑 1:商戶資料外洩

    早期版本有個 API 忘記加商戶過濾,某商戶發現可以透過改 URL 的 orderId 看到別人的訂單。好險被內部測試發現,立刻修復並全面檢查所有 API。教訓:所有 Repository 方法預設都要加 merchantId 過濾

    坑 2:Token 快取同步問題

    三台 Server,使用者在 A 機登出,但 B、C 機的快取還有 Token。結果登出後還能繼續操作 30 秒。解法:登出時發布事件到所有節點,同步清除快取。

    坑 3:權限設計太細

    最初設計了 50+ 種權限,結果商戶搞不懂、客服也解釋不清。後來簡化成 5 個角色(管理員、主管、操作員、財務、唯讀),大幅降低維護成本。


    系列導航

    ◀ 上一篇
    Kafka 事件驅動
    📚 返回目錄 下一篇 ▶
    健康檢查
  • 多通路電商系統架構:用工廠模式整合 17 個平台

    商業價值:這篇介紹的工廠模式讓「新增平台從 2-3 個月縮短到 2-3 週」,直接影響 導讀篇提到的 80% 擴展成本降低

    前言:當你要同時對接 17 個電商平台

    在多通路電商系統中,我們需要整合多個平台:

    平台類型 範例 特性
    綜合電商 蝦皮、Momo、Yahoo、PChome 訂單量大、API 複雜
    開店平台 Shopify、Shopline、91APP 彈性高、客製化多
    國際平台 樂天、Coupang、Amazon 多語系、跨境物流
    問題:每個平台的 API 格式、認證方式、資料結構都不同。如果用 if-else 判斷,程式碼會變成災難。

    解決方案:工廠模式 + 策略模式

    核心思想:定義統一介面,每個平台各自實作。新增平台時,只需要新增一個實作類別。

    Step 1:定義統一介面

    所有平台都必須實作這個介面:

    /**
    * 電商平台動作介面
    * 所有平台整合都必須實作這個介面
    */

    public interface ChannelAction {

    // 取得平台設定(API URL、版本等)
    ChannelSetting getSetting();

    // 取得平台授權 Token
    TokenResult getAccessToken(Merchant merchant);

    // 驗證必要資料是否齊全
    boolean validateRequired(ActionRequest request);

    // 執行實際動作(同步訂單、更新庫存等)
    ActionResult execute(ActionRequest request);
    }

    Step 2:每個平台各自實作

    蝦皮實作範例:

    @Component
    public class ShopeeAction implements ChannelAction {

    @Override
    public ChannelSetting getSetting() {
    return ChannelSetting.builder()
    .apiUrl(“https://partner.shopeemobile.com”)
    .version(“v2”)
    .authType(AuthType.OAUTH2)
    .build();
    }

    @Override
    public TokenResult getAccessToken(Merchant merchant) {
    // 蝦皮使用 OAuth2 + 簽章驗證
    String signature = generateSignature(merchant);
    return callShopeeAuthAPI(merchant, signature);
    }

    @Override
    public ActionResult execute(ActionRequest request) {
    // 呼叫蝦皮 API 執行動作
    return callShopeeAPI(request);
    }
    }

    Momo 實作範例:

    @Component
    public class MomoAction implements ChannelAction {

    @Override
    public ChannelSetting getSetting() {
    return ChannelSetting.builder()
    .apiUrl(“https://api.momo.com.tw”)
    .version(“v1”)
    .authType(AuthType.API_KEY)
    .build();
    }

    @Override
    public TokenResult getAccessToken(Merchant merchant) {
    // Momo 使用 API Key 驗證
    return TokenResult.of(merchant.getMomoApiKey());
    }

    @Override
    public ActionResult execute(ActionRequest request) {
    // 呼叫 Momo API 執行動作
    return callMomoAPI(request);
    }
    }

    Step 3:工廠類別統一管理

    @Component
    public class ChannelFactory {

    private final Map<ChannelType, ChannelAction> actionMap;

    // Spring 自動注入所有 ChannelAction 實作
    public ChannelFactory(List<ChannelAction> actions) {
    this.actionMap = actions.stream()
    .collect(Collectors.toMap(
    action -> action.getSetting().getChannelType(),
    action -> action
    ));
    }

    /**
    * 根據通路類型取得對應的實作
    */

    public ChannelAction getAction(ChannelType channelType) {
    ChannelAction action = actionMap.get(channelType);
    if (action == null) {
    throw new UnsupportedChannelException(
    “不支援的通路: “ + channelType
    );
    }
    return action;
    }
    }


    使用方式

    業務邏輯層只需要這樣呼叫:

    @Service
    public class OrderSyncService {

    @Autowired
    private ChannelFactory channelFactory;

    public SyncResult syncOrders(ChannelType channel, Merchant merchant) {
    // 1. 取得對應的通路實作
    ChannelAction action = channelFactory.getAction(channel);

    // 2. 取得授權 Token
    TokenResult token = action.getAccessToken(merchant);

    // 3. 執行同步
    ActionRequest request = ActionRequest.builder()
    .merchant(merchant)
    .token(token)
    .actionType(ActionType.SYNC_ORDERS)
    .build();

    return action.execute(request);
    }
    }

    優點:不管是蝦皮、Momo、Yahoo 還是其他平台,呼叫方式完全一樣。新增平台時,業務邏輯層完全不用改。

    新增平台有多簡單?

    假設要新增 Coupang 韓國平台:

    @Component
    public class CoupangAction implements ChannelAction {

    @Override
    public ChannelSetting getSetting() {
    return ChannelSetting.builder()
    .apiUrl(“https://api-gateway.coupang.com”)
    .version(“v2”)
    .authType(AuthType.HMAC)
    .build();
    }

    // … 實作其他方法
    }

    步驟 工作內容 影響範圍
    1 建立 CoupangAction 類別 只有新檔案
    2 實作 ChannelAction 介面 只有新檔案
    3 加上 @Component 註解 只有新檔案
    4 完成!Spring 自動註冊 零修改現有程式

    設計模式總結

    模式 用途 在這裡的應用
    策略模式 定義演算法家族,讓它們可互換 每個平台是一個策略
    工廠模式 封裝物件建立邏輯 根據通路類型取得實作
    依賴注入 解耦合 Spring 自動管理
    效益:

    • 新增通路:從 2-3 個月縮短到 2-3 週
    • 維護成本:改一個平台不影響其他平台
    • 測試:每個平台可以獨立單元測試

    為什麼不用其他方案?

    方案 優點 缺點 結論
    if-else 判斷 簡單直接 每次加平台要改核心程式碼 小規模可用,超過 3 個平台就很痛苦
    Switch Case 比 if-else 清楚 還是要改核心程式碼 同上
    反射 + 設定檔 完全不改程式碼 除錯困難、IDE 無法追蹤 過度設計,維護成本高
    工廠 + 策略 新增只加檔案、Spring 自動註冊 需要理解設計模式 中大型系統的最佳平衡

    實戰踩坑

    坑 1:平台 API 變更沒通知

    蝦皮某次 API 升級,回傳欄位名稱從 order_sn 改成 ordersn。因為每個平台有獨立的 Action 類別,我們只需要改 ShopeeAction,其他 16 個平台完全不受影響。如果用 if-else,改錯一行就全部爆炸。

    坑 2:忘記加 @Component

    新人寫好 CoupangAction 卻沒加 @Component,Spring 沒註冊到 Factory。呼叫時直接噴 UnsupportedChannelException。後來在程式碼審查加入檢查項:「確認新 Action 有 @Component」。

    坑 3:介面設計太死

    最初 ChannelAction 只有 execute() 一個方法。後來發現有些平台需要 OAuth 刷新 Token、有些需要 Webhook 處理。介面改了三次才穩定。教訓:先做 3-5 個平台再抽象,別一開始就過度設計


    系列導航

    ◀ 上一篇
    導讀篇
    📚 返回目錄 下一篇 ▶
    Kafka 事件驅動