當前端開始思考後端架構第四篇:前端權限架構的初步規劃
記錄前端針對權限的初步架構規劃
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,與其互動的是串接來的資料以及背後的業務邏輯, 因此我們可以區分:
- Presentation Layer
- Application Layer (Domain Layer)
- 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 的概念來規劃。
資料層
資料層分別由 Repository 跟 Mapper 組成。
Mapper: 轉換後端語言到前端熟悉的,例如後端傳回來是snake_case,就可以透過Mapper轉換為camelCase。Repository: 專門處理 API 的地方,這裡打完 API 後,可由Mapper去做資料的轉換。
開始 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 }應用程式層(領域層)
這層也由兩個部分組成,分別是 Entity 和 Service,Entity 我們已經定義好了,就剩下 Service。
Service 可以想成是一個營運的角色,它負責管理整個權限的業務邏輯,所以上述提到的散落在各組件的判斷邏輯,我們要逐一收束在這裡。
// 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 🐻:「我們需要動態調整權限,不同客戶的規則都不一樣,而且刪除訂單之前還要檢查有沒有關聯的退款單...」
是不是很常見的情境呢?說不定三個月算是比較好的情況了呢! 😉