當前端開始思考後端架構第四篇:前端權限架構的初步規劃

記錄前端針對權限的初步架構規劃

February 24, 2026

上一篇當前端開始思考後端架構第三篇,我們提到「數據範圍擴張」除了是讓權限可以觸及更多資料,也是讓權限具備「精確過濾」的能力,那麼對前端來說,在架構上又有什麼需要注意的呢? 針對前端,我喜歡用實務上的情境來想像,以下是一個假想的情境。

PM 🐻:「我們需要做一個訂單管理後台,目前有三個角色:管理員、營運主管、客服人員。管理員可以做所有事情,營運主管可以查看跟刪除訂單,但只限雙北地區,客服人員只能查看訂單。」

上述的需求很直白,被分配到這個需求的前端工程師可以思考什麼呢?

權限資料哪裡來?

很多時候,當後端沒有準備好時,前端會為了先有初步的 UI ,就先自行定義可能的資料結構,至少在我第一跟第二年甚至現在也會需要先行定義,那麼前端能做的當然是儘可能地去了解後端 API 返回的資料都怎麼規劃, 盡量符合大部分的數據結構。

但是權限資料可否寫在前端呢?權限資料屬於業務邏輯,而業務邏輯必須由後端去定義及保護;此時作為前端應該要向後端詢問是否已經定義好權限? API 是否已經可以串接了。 假設還沒,理想的流程應該是當 PM 提出需求後,前後端一起討論 API 設計,這個階段前端可以問清楚資料結構,當欄位名稱、資料型別以及回傳格式都定義好了,接著可以簡單用一些工具去記錄彼此討論的結果, 這份文件又稱 「API Contract (API 合約)」。

當然如果公司編制不大,那麼盡可能需要向 PM 或者專案負責人釐清需求。

如何取得資料?取得資料後要怎麼放?

對前端來說,除了規劃 UI 之外,最重要的就是串接 API。假設有了 API 合約,前端就可以寫 MOCK 資料。

權限資料一般來說就是當使用者登入後取得,會有類似 GET/me 或是 GET /permissions 之類的 API,後端會回傳登入的使用者的角色與權限資料。

以本例來說,可能回傳的資料是:

{
  "user_id": "user123",
  "role": "operation_manager",
  "permissions": [
    {
      "resource": "order",
      "actions": ["view", "delete"],
      "scope": ["taipei", "newTaipei"]
    }
  ]
}

前端拿到這個權限資料後呢? 可能的想法大概會有: 在組件裡判斷。例如我們會把權限存進 store 內,在需要判斷權限的組件內使用。

const { user } = useAuthStore();
 
const canDelete = 
  user.role === "operation_manager" && ["taipei", "newTaipei"].includes(order.store);
 
return (
  <div>
    <div>訂單詳情...</div>
    {canDelete && <button>刪除</button>}
  </div>
)

這樣的判斷是有效的,只是隨著需求越來越多,要管理的東西越來越雜,可能會有訂單頁面、訂單列表、訂單詳情、訂單審核等,如果類似的組件都按照上述的方式寫的話, 同樣的邏輯將會散落在各頁面,而萬一哪天需求改了,我們則必須確保每一個組件內的邏輯都有修正到。

此時先停下來,思考一下,對前端來說,UI 的定義與功用是什麼?當需要與使用者互動時,UI 的定義會改變嗎?對一個畫面來說,UI 就是 UI,與其互動的是串接來的資料以及背後的業務邏輯, 因此我們可以區分:

  1. Presentation Layer
  2. Application Layer (Domain Layer)
  3. Data Layer

以上我們稱為 Three-Layered Architecture (三層架構)

Presentation Layer 展示層

UI 就是我們的第一層 Presentation Layer,負責 UI 與使用者互動的元件,不管任何業務邏輯。

Applicaiton(Domain) Layer 領域層或是應用程式層

負責業務邏輯,管理類似「可不可以刪除」或者「範圍符不符合」等判斷。

Data Layer 資料層

負責資料的存取,只管資料哪裡來以及如何轉換。

層級職責本例中的角色
Presentation Layer負責 UI 和使用者互動,只管畫面長什麼樣子元件(OrderPage、DeleteButton)
Domain Layer負責業務邏輯,判斷「可不可以做這件事」PermissionService、Permission Entity
Data Layer負責資料存取,管資料從哪裡來、怎麼轉換AuthRepository、PermissionMapper

雖然描述時我們是從展示層開始,但實作我們應該要先從資料層開始,因為展示層依賴領域層,而領域層依賴資料層取得的資料。

實作

接下來試試看用 Three-Layered Architecture 的概念來規劃。

資料層

資料層分別由 RepositoryMapper 組成。

開始 Mapper 前,我們需要先定義資料型態,又稱爲 Entity,雖然 Entity 屬於應用程式層,但得先知道資料形狀,我們才能開始 Mapper

以電商來說,可能的資料夾結構可以依循 Featured-based,如下:

src/
├── features/
│   ├── auth/
│   │   ├── components/
│   │   ├── services/
│   │   ├── repositories/
│   │   └── entities/
│   └── order/
│       ├── components/
│       ├── services/
│       ├── repositories/
│       └── entities/
└── shared/
    ├── components/
    └── utils/

直覺來說,我們會把 permission entity 放在 auth 底下,但 order 內的 service 也需要它,可能其他的 feature 也會需要,因此我們可以建立一個 shared/core 來管理會被 共用 的 entity 或 service。

src/
├── features/
│   ├── auth/
│   │   ├── repositories/
│   │   │   └── AuthRepository.ts
│   │   └── mappers/
│   │       └── PermissionMapper.ts
│   └── order/
│       └── components/
│           └── OrderPage.vue
└── shared/
    ├── entities/
    │   └── Permission.ts        ← 共用的 Entity
    └── services/
        └── PermissionService.ts ← 共用的 Service
定義 Entity
// shared/entities/Permission.ts
 
export interface Permission {
  resource: string
  actions: string[]
  scope: string[] | "all"
}
 
export interface UserPermission {
  userId: string
  role: string
  permissions: Permission[]
}

定義 Mapper

另外可以先看團隊想要用物件導向來封裝或者函式導向,程式碼風格與規範,需要在專案前期就要先確認。

// features/auth/mappers/PermissionMapper.ts
const toPermissions = (p:any) => ({
  resource: p.resource,
  actions: p.actions,
  scope: p.scope
})
 
const toDomainData = (apiResponse: any): UserPermission => ({
  userId: apiResponse.user_id,
  role: apiResponse.role,
  permissions: apiResponse.permissions.map(toPermissions) 
  // .map() 裡面只做一件事,若只是「把收到的東西,原封不動直接轉交給下一個函式」,可以省略中間的過程,完整的過程如下
  // apiResponse.permissions.map((item) => toPermission(item))
})
 
export { toDomainData }

原始的 API 回傳若是

{
  "user_id": "12345",
  "role": "admin",
  "created_at": "2026-03-16",
  "permissions": [
    {
      "resource": "article",
      "actions": ["create", "read"],
      "scope": "global",
      "extra_data_we_dont_need": "xyz" 
    }
  ]
}

經過轉換後就會變成

{
  "userId": "12345",
  "role": "admin",
  "permissons": [
    {
      "resource": "article",
      "actions": ["create", "read"],
      "scope": "global"
    }
  ]
}

這裡涉及到了一個規範:Tolerant Reader Pattern

意思是「只取我需要的欄位,忽略我不認識或不需要的欄位」。

有發現 extra_data_we_dont_need 在轉換後直接消失嗎?

我們下一篇再來深入討論 😉

假設哪天後端不小心把 user_id 改成 userId,我們就只需要改這個 Mapper 就好。

定義 Repository

Repository 的唯一任務就是管理 API,接收串接 API 後拿到的資料,並轉交給 MApper 去做資料轉換,然後一層一層往上交付。

// features/auth/repositories/AuthRepository.ts
 
import { toDomainData } from '../mappers/PermissionMapper'
 
const createAuthRepository = (httpClient: typeof fetch) => {
  return {
    getMe: async(): Promise<UserPermission> => {
      const res = await httpClient('/api/me')
      const apiData = await res.json()
 
      return toDomainData(apiData)
    }
  }
}
 
export { createAuthRepository }

應用程式層(領域層)

這層也由兩個部分組成,分別是 EntityServiceEntity 我們已經定義好了,就剩下 ServiceService 可以想成是一個營運的角色,它負責管理整個權限的業務邏輯,所以上述提到的散落在各組件的判斷邏輯,我們要逐一收束在這裡。

// shared/services/PermissionService.ts
 
const createPermissionService = (userPermission: UserPermission) => {
  return {
    hasPermission: (action: string, resource: string, scope?: string): boolean => {
      const permission = userPermission.permissions
        .find((p) => p.resource === resource)
 
      if (!permission) return false
 
      const isActionAllowed = permission.actions.includes(action)
      const isWithinScope =
        permission.scope === "all" ||
        (scope ? permission.scope.includes(scope) : true)
 
      return isActionAllowed && isWithinScope
    }
  }
}
 
export { createPermissionService }

展示層

這是最上層的,只負責視覺的展現,裡面不該有任何判斷邏輯或者資料獲取等。這裡先不討論更近一步地使用 context 或是 Custom Hook。 可能的寫法就是先在 OrderPage 的地方拿資料。

import { useEffect, useState } from 'react'
import { createAuthRepository } from 'auth/repositories/AuthRepository'
import { createOrderRepository } from 'order/repository/OrderRepository'
import { createPermissionService } from '../services/PermissionService'
import { OrderDetail } from './OrderDetail'
 
const authRepo = createAuthRepository(window.fetch)
// 雖然上面沒有寫,但訂單相關的 API,做法一樣,建立一個 Repository 去管理
const orderRepo = creatOrderRepository(window.fetch)
 
 
export default function OrderPage({ orderId }: { orderId: number }) {
  const [permissionService, setPermissionService] = useState(null)
  const [myOrder, setMyOrder] = useState(null)
 
  useEffect(() => {
    const init = async() => {
      try {
        const [authData, orderData] = await Promise.all([
          authRepo.getMe(),
          orderRepo.getOrderById(orderId)
        ])
 
        setPermissionService(createPermissionService(authData));
        setMyOrder(orderData);
      } catch (error) {
        console.error(error.message)
      }
    }
    init()
  }, [])
 
  if (!permissionService) {
    return (
      <div>載入中...</div>
    )
  }
 
  return (
    <OrderDetail order={myOrder} permissionService={permissionService} />
  )
}

可能之後再來討論是否將 service 做成 custom hook,也可以討論是否要用狀態管理套件以及資料快取套件等來處理 repository。 現在的架構在這個簡單情境下運作得很好。但三個月後,PM 🐻 走過來了...

PM 🐻:「我們需要動態調整權限,不同客戶的規則都不一樣,而且刪除訂單之前還要檢查有沒有關聯的退款單...」

是不是很常見的情境呢?說不定三個月算是比較好的情況了呢! 😉

回到部落格 🏃🏽‍♀️