Skip to content
Merged
4 changes: 3 additions & 1 deletion frontend-architecture-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ pages -> features -> entities -> shared
| `pages` | 八个路由页面 |
| `features` | 用户操作:角色设置、生成、审核、导出;以及流程推进 `workflow-controller` |
| `entities` | 上表业务模块 |
| `shared` | 无业务语义的形状,目前只有分页 |
| `shared` | 无业务语义的分页形状、HTTP 传输与通用 UI |

`app` 只做启动和路由,不构造服务、不向下注入。

Expand All @@ -50,6 +50,8 @@ pages -> features -> entities -> shared
3. 跨模块只从模块目录的 `index.ts` 进入;`entities` 统一从 `@/entities` 使用。
4. `entities` 内部模块之间可以互相导入,对外仍是一个门。

`shared/api` 只处理后端所有模块共用的传输契约:从环境读取 API 地址、附加调用方提供的 access token、解包统一响应、识别业务码并转换分页字段。登录模块通过 `registerApiAccessTokenProvider` 注册惰性读取函数,各业务 API 统一使用 `getApiAccessToken`;公共层只保存读取函数,不保存、刷新或解析 token。各 `XxxApis` 的路径、字段映射与实例仍跟随对应 `entities` 模块。

---

## 3. 接口命名
Expand Down
4 changes: 4 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,8 @@ CI 按上面顺序全跑一遍。

页面自己决定宽度与留白,`AppShell` 只提供顶栏,不再统一夹一个居中容器。

`shared/api` 提供后续业务接口可复用的公共 HTTP 请求能力。

运行项目前需要配置 `VITE_API_BASE_URL`。Bearer token 由登录模块取得后,通过 `registerApiAccessTokenProvider` 注册读取函数;业务请求统一从该边界读取。本轮不定义 token 的保存方式。

与后端尚未对齐的接口见 `API_CONTRACT.md`
5 changes: 4 additions & 1 deletion frontend/src/shared/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@
## 现有内容

- `pagination/` —— 与传输协议无关的分页请求与结果形状。
- `api/` —— 后端公共 HTTP 客户端:统一响应解包、业务码、分页、Bearer 请求头和传输错误。

`api/` 默认从 `VITE_API_BASE_URL` 读取服务地址,并在发出请求时调用可选的 `getAccessToken`。业务 API 统一使用 `getApiAccessToken`,后续登录模块通过 `registerApiAccessTokenProvider` 注册实际读取函数。公共层只保存这个函数,不决定 token 如何登录取得、保存或刷新。

## 后续允许放入

- `ui/` —— 按钮、弹窗、加载状态等不含业务含义的展示组件
- `hooks/` —— 通用浏览器或 React 行为,例如媒体查询、键盘快捷键
- `utils/` —— 纯函数工具,例如日期格式化、文件大小显示
- `config/` —— 前端通用常量与运行时配置读取
- `config/` —— `api/` 之外的前端通用常量与运行时配置读取

**这些目录只在出现真实代码时创建,不为占位提前建空文件。**

Expand Down
268 changes: 268 additions & 0 deletions frontend/src/shared/api/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import { afterEach, describe, expect, it, vi } from 'vitest'

import {
ApiError,
createApiClient,
getApiAccessToken,
registerApiAccessTokenProvider,
} from './index'

afterEach(() => vi.unstubAllEnvs())

describe('createApiClient', () => {
it('reads the latest registered token provider and restores the previous provider', () => {
const unregisterFirst = registerApiAccessTokenProvider(() => 'first-token')
const unregisterSecond = registerApiAccessTokenProvider(() => 'second-token')

expect(getApiAccessToken()).toBe('second-token')
unregisterSecond()
expect(getApiAccessToken()).toBe('first-token')
unregisterFirst()
expect(getApiAccessToken()).toBeUndefined()
})

it('returns data from a successful backend response envelope', async () => {
const client = createApiClient({
baseUrl: 'https://api.windup.test',
fetchFn: async () =>
new Response(
JSON.stringify({
code: 200,
message: 'success',
data: { id: 7 },
}),
{ headers: { 'content-type': 'application/json' } },
),
})

await expect(client.request<{ id: number }>('/resources/7')).resolves.toEqual({ id: 7 })
})

it('rejects a backend business error even when HTTP status is 200', async () => {
const client = createApiClient({
baseUrl: 'https://api.windup.test',
fetchFn: async () =>
new Response(
JSON.stringify({
code: 400,
message: '请求参数错误',
data: { field: 'name' },
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
})

const error = await client.request('/resources').catch((reason: unknown) => reason)

expect(error).toBeInstanceOf(ApiError)
expect(error).toMatchObject({
kind: 'business',
code: 400,
status: 200,
message: '请求参数错误',
data: { field: 'name' },
})
})

it('maps a successful list response to a camel-case list result', async () => {
const client = createApiClient({
baseUrl: 'https://api.windup.test',
fetchFn: async () =>
new Response(
JSON.stringify({
code: 200,
message: 'success',
data: [{ id: 7 }, { id: 8 }],
total: 42,
page: 2,
page_size: 20,
}),
{ headers: { 'content-type': 'application/json' } },
),
})

await expect(client.requestList<{ id: number }>('/resources')).resolves.toEqual({
items: [{ id: 7 }, { id: 8 }],
total: 42,
page: 2,
pageSize: 20,
})
})

it('serializes query values and a JSON request body', async () => {
let capturedRequest: Request | undefined
const client = createApiClient({
baseUrl: 'https://api.windup.test/',
fetchFn: async (input, init) => {
capturedRequest = new Request(input, init)
return new Response(JSON.stringify({ code: 200, message: 'success', data: null }))
},
})

await client.request('/resources', {
method: 'POST',
query: { page: 2, page_size: 20, user_id: null },
json: { name: 'sample' },
})

if (!capturedRequest) throw new Error('request was not sent')
expect(capturedRequest.url).toBe('https://api.windup.test/resources?page=2&page_size=20')
expect(capturedRequest.headers.get('content-type')).toBe('application/json')
await expect(capturedRequest.json()).resolves.toEqual({ name: 'sample' })
})

it('adds the current access token as a Bearer authorization header', async () => {
let authorization: string | null = null
const client = createApiClient({
baseUrl: 'https://api.windup.test',
getAccessToken: () => 'access-token',
fetchFn: async (input, init) => {
authorization = new Request(input, init).headers.get('authorization')
return new Response(JSON.stringify({ code: 200, message: 'success', data: null }))
},
})

await client.request('/auth/me')

expect(authorization).toBe('Bearer access-token')
})

it('wraps a rejected fetch as a network ApiError', async () => {
const connectionError = new TypeError('Failed to fetch')
const client = createApiClient({
baseUrl: 'https://api.windup.test',
fetchFn: async () => Promise.reject(connectionError),
})

const error = await client.request('/resources').catch((reason: unknown) => reason)

expect(error).toBeInstanceOf(ApiError)
expect(error).toMatchObject({
kind: 'network',
code: null,
status: null,
message: '网络请求失败',
cause: connectionError,
})
})

it('rejects a successful HTTP response that does not match the backend envelope', async () => {
const client = createApiClient({
baseUrl: 'https://api.windup.test',
fetchFn: async () =>
new Response(JSON.stringify({ data: { id: 7 } }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
})

const error = await client.request('/resources/7').catch((reason: unknown) => reason)

expect(error).toBeInstanceOf(ApiError)
expect(error).toMatchObject({
kind: 'invalid-response',
code: null,
status: 200,
message: '后端响应格式无效',
})
})

it('rejects a list response with invalid pagination fields', async () => {
const client = createApiClient({
baseUrl: 'https://api.windup.test',
fetchFn: async () =>
new Response(
JSON.stringify({
code: 200,
message: 'success',
data: [{ id: 7 }],
total: 1,
page: 1,
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
})

const error = await client.requestList('/resources').catch((reason: unknown) => reason)

expect(error).toBeInstanceOf(ApiError)
expect(error).toMatchObject({
kind: 'invalid-response',
status: 200,
message: '后端列表响应格式无效',
})
})

it('reports a non-envelope HTTP failure as an HTTP ApiError', async () => {
const client = createApiClient({
baseUrl: 'https://api.windup.test',
fetchFn: async () => new Response('gateway unavailable', { status: 503 }),
})

const error = await client.request('/resources').catch((reason: unknown) => reason)

expect(error).toBeInstanceOf(ApiError)
expect(error).toMatchObject({
kind: 'http',
code: null,
status: 503,
})
})

it('prioritizes an HTTP failure when the response also has a non-200 business code', async () => {
const client = createApiClient({
baseUrl: 'https://api.windup.test',
fetchFn: async () =>
new Response(
JSON.stringify({
code: 500,
message: '服务暂时不可用',
data: { request_id: 'request-7' },
}),
{ status: 503, headers: { 'content-type': 'application/json' } },
),
})

const error = await client.request('/resources').catch((reason: unknown) => reason)

expect(error).toBeInstanceOf(ApiError)
expect(error).toMatchObject({
kind: 'http',
code: null,
status: 503,
message: 'HTTP 请求失败',
data: { request_id: 'request-7' },
})
})

it('does not accept a success envelope carried by a failed HTTP response', async () => {
const client = createApiClient({
baseUrl: 'https://api.windup.test',
fetchFn: async () =>
new Response(JSON.stringify({ code: 200, message: 'success', data: { id: 7 } }), {
status: 500,
headers: { 'content-type': 'application/json' },
}),
})

const error = await client.request('/resources/7').catch((reason: unknown) => reason)

expect(error).toBeInstanceOf(ApiError)
expect(error).toMatchObject({ kind: 'http', code: null, status: 500 })
})

it('uses VITE_API_BASE_URL when an explicit base URL is not provided', async () => {
vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test/root/')
let requestUrl = ''
const client = createApiClient({
fetchFn: async (input) => {
requestUrl = String(input)
return new Response(JSON.stringify({ code: 200, message: 'success', data: null }))
},
})

await client.request('/resources')

expect(requestUrl).toBe('https://api.windup.test/root/resources')
})
})
Loading