AI 电子伴侣
创建时间: 2026-04-28最后更新: 2026-08-04

1. 概述

接下来,我们要约定在 web 端和 api 端之间共享请求类型,并使用 Hono RPC 进行通信。

目前 API 侧现在只有一个 GET /health,返回的还是裸 JSON。前端侧也没有真正成型的请求层。继续往下写业务,很快就会碰到三个问题:

  • 请求参数靠口头约定
  • 成功和失败返回格式各写各的
  • 前端拿到接口后,类型还是得自己再补一遍

这类问题一开始不明显,接口一多就会变成维护负担。你会看到同一个错误,在 web、admin、api 三处分别写一遍;同一个字段一旦改名,类型漂移会沿着整条链路扩散。

因此,我们的目标很明确:

  • 将 contract 放进共享包,前后端共用同一份类型和 schema
  • 统一 success / failure 返回结构
  • 前端直接基于 Hono RPC 调用,拿到真实类型推导
  • 用首页上的一次 ping 调用验证整条链路是否通顺

2. 定义 contract 边界

shared contract 只放跨端稳定约定,不放业务实现。放进 packages/contracts 的内容控制在这几类:

  • 业务异常码 BizCode
  • 统一响应元信息 ApiMeta
  • 成功结构 ApiSuccess<T> 和 失败结构 ApiFailure<E>
  • 总响应类型 ApiResponse<T, E>
  • ping 这条最小链路的输入输出 schema 与类型

这里最关键的点有两个。

第一,输入输出都要有 schema

只写 TypeScript type 不够,因为 type 只在编译期存在,真正到接口入口时,传进来的 JSON 还是运行时数据。这里直接用 zod 定义 PingRequestSchemaPingResponseSchema,这样 API 可以校验入参,前端也能复用同一份 contract。

第二,响应 envelope 也要共享

如果每个 route 自己拼 { ok, data, error, meta },后面一多就会失控。所以直接在共享包里提供 buildSuccessbuildFailure 这类纯数据 helper,把结构先固定下来。

可以先把 contract 包写成这样:

packages/contracts/src/index.ts
01
import { z } from 'zod'
02
03
export const BizCode = {
04
COMMON_INVALID_REQUEST: 'COMMON.INVALID_REQUEST',
05
COMMON_NOT_FOUND: 'COMMON.NOT_FOUND',
06
AUTH_UNAUTHORIZED: 'AUTH.UNAUTHORIZED',
07
AUTH_FORBIDDEN: 'AUTH.FORBIDDEN',
08
BIZ_CONFLICT: 'BIZ.CONFLICT',
09
BIZ_RULE_VIOLATION: 'BIZ.RULE_VIOLATION',
10
SYSTEM_INTERNAL_ERROR: 'SYSTEM.INTERNAL_ERROR',
11
SYSTEM_UPSTREAM_TIMEOUT: 'SYSTEM.UPSTREAM_TIMEOUT',
12
} as const
13
14
export type BizCode = (typeof BizCode)[keyof typeof BizCode]
15
16
export interface ApiMeta {
17
requestId: string
18
timestamp: string
19
}
20
21
export interface ApiSuccess<T> {
22
ok: true
23
data: T
24
meta: ApiMeta
25
}
26
27
export interface ApiError<E = unknown> {
28
code: BizCode
29
message: string
30
details?: E
31
}
32
33
export interface ApiFailure<E = unknown> {
34
ok: false
35
error: ApiError<E>
36
meta: ApiMeta
37
}
38
39
export type ApiResponse<T, E = unknown> = ApiSuccess<T> | ApiFailure<E>
40
41
export const PingRequestSchema = z.object({
42
name: z.string().trim().min(1),
43
})
44
45
export const PingResponseSchema = z.object({
46
service: z.literal('api'),
47
message: z.string(),
48
})
49
50
export type PingRequest = z.infer<typeof PingRequestSchema>
51
export type PingResponse = z.infer<typeof PingResponseSchema>
52
53
export function buildSuccess<T>(data: T, meta: ApiMeta): ApiSuccess<T> {
54
return { ok: true, data, meta }
55
}
56
57
export function buildFailure<E = unknown>(
58
error: ApiError<E>,
59
meta: ApiMeta,
60
): ApiFailure<E> {
61
return { ok: false, error, meta }
62
}

到这里,前后端至少已经在「说同一种语言 」。

3. 统一响应格式

接口返回值建议从一开始就分成两层语义:

  • HTTP status,表示传输层结果
  • error.code,表示业务语义

成功结构统一为:

index.json
01
{
02
"ok": true,
03
"data": {
04
"service": "api",
05
"message": "pong, web"
06
},
07
"meta": {
08
"requestId": "d7c4f4ef-67c3-4f48-90b2-0cb6c6f7ea4f",
09
"timestamp": "2026-04-28T08:00:00.000Z"
10
}
11
}

失败结构统一成:

index.json
01
{
02
"ok": false,
03
"error": {
04
"code": "COMMON.INVALID_REQUEST",
05
"message": "Invalid request payload",
06
"details": {
07
"fieldErrors": {
08
"name": ["String must contain at least 1 character(s)"]
09
}
10
}
11
},
12
"meta": {
13
"requestId": "8c9b0b52-ef80-44d8-b2e1-d40c6b90a40d",
14
"timestamp": "2026-04-28T08:00:01.000Z"
15
}
16
}

这里的 meta 不只是为了好看。

requestId 让日志串联有锚点,timestamp 让排查时能快速定位请求时间。后面真接日志平台、链路追踪或者 Sentry,这两个字段都能直接复用。

配套的业务异常码可以先收一组通用常量:

  • COMMON.INVALID_REQUEST
  • COMMON.NOT_FOUND
  • AUTH.UNAUTHORIZED
  • AUTH.FORBIDDEN
  • BIZ.CONFLICT
  • BIZ.RULE_VIOLATION
  • SYSTEM.INTERNAL_ERROR
  • SYSTEM.UPSTREAM_TIMEOUT

这组常量先别追求全面,关键是命名风格和职责边界要稳定。后面新增业务 route 时,直接在这个集合里继续扩展。

4. RPC 与异常处理

这里做一个很实用的拆分:

  • apps/api/src/app.ts 负责定义路由、错误处理、导出 AppType
  • apps/api/src/index.ts 只负责 export default app

这样做的目的可以让前端 type-only 导入 AppType,拿到 Hono route 的真实类型推导。

先看 app.ts 的最小形态:

apps/api/src/app.ts
01
import {
02
BizCode,
03
PingRequestSchema,
04
buildFailure,
05
buildSuccess,
06
type ApiMeta,
07
} from '@repo/contracts'
08
import { Hono } from 'hono'
09
import { HTTPException } from 'hono/http-exception'
10
import { validator } from 'hono/validator'
11
12
type AppErrorStatus = 400 | 401 | 403 | 404 | 409 | 422 | 500 | 504
13
14
class AppError extends Error {
15
constructor(
16
readonly code: BizCode,
17
message: string,
18
readonly status: AppErrorStatus,
19
readonly details?: unknown,
20
) {
21
super(message)
22
}
23
}
24
25
const app = new Hono()
26
27
function createMeta(): ApiMeta {
28
return {
29
requestId: crypto.randomUUID(),
30
timestamp: new Date().toISOString(),
31
}
32
}
33
34
app.onError((error, c) => {
35
const meta = createMeta()
36
37
if (error instanceof AppError) {
38
const errorMsg = { code: error.code, message: error.message, details: error.details }
39
const res = buildFailure(errorMsg, meta);
40
return c.json(res, error.status);
41
}
42
43
if (error instanceof HTTPException) {
44
const errorMsg = { code: BizCode.COMMON_INVALID_REQUEST, message: error.message }
45
const res = buildFailure(errorMsg, meta);
46
return c.json(res, error.status);
47
}
48
49
console.error(error)
50
51
const errorMsg = { code: BizCode.SYSTEM_INTERNAL_ERROR, message: 'Internal server error' }
52
const res = buildFailure(errorMsg, meta);
53
return c.json(res, 500);
54
})
55
56
app.notFound((c) => {
57
const errorMsg = { code: BizCode.COMMON_NOT_FOUND, message: 'Not found' }
58
const res = buildFailure(errorMsg, createMeta());
59
return c.json(res, 404);
60
})
61
62
const routes = app
63
.get('/health', (c) => {
64
const res = buildSuccess({ service: 'api' }, createMeta());
65
return c.json(res);
66
})
67
.post('/rpc/system/ping', validator('json', (value, c) => {
68
const parsed = PingRequestSchema.safeParse(value)
69
70
if (!parsed.success) {
71
const errorMsg = {
72
code: BizCode.COMMON_INVALID_REQUEST,
73
message: 'Invalid request payload',
74
details: parsed.error.flatten(),
75
}
76
return c.json(buildFailure(errorMsg, createMeta()), 400);
77
}
78
79
return parsed.data
80
}),
81
(c) => {
82
const payload = c.req.valid('json')
83
const successMsg = { service: 'api', message: `pong, ${payload.name}` }
84
const res = buildSuccess(successMsg, createMeta());
85
return c.json(res);
86
});
87
88
export type AppType = typeof routes;
89
90
export default app;

入口文件就保持极简:

apps/api/src/index.ts
1
import app from './app'
2
3
export default app

这一步的价值在于,route 结构、入参校验、返回值 envelope、异常码映射,已经统一到一个地方。

5. 前端直接走 typed RPC

后端 contract 定好了,接下来就该让前端真正共享这份类型收益。

这里不额外包一层请求 SDK,先在 web 首页直接走一次最小调用,目的就是验证链路,而不是过早抽象。

要做的接入只有三步。

第一步,补 workspace 依赖。

apps/web/package.json 增加:

  • @repo/api
  • @repo/contracts
  • hono

apps/api/package.json 也要补 workspace 包名和 exports,让 AppType 能被前端引用。

apps/api/package.json
01
{
02
"name": "@repo/api",
03
"type": "module",
04
"exports": {
05
".": "./src/app.ts"
06
},
07
"scripts": {
08
"dev": "wrangler dev",
09
"deploy": "wrangler deploy --minify",
10
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
11
"check-types": "tsc --noEmit"
12
},
13
"dependencies": {
14
"@repo/contracts": "workspace:*",
15
"hono": "^4.12.14"
16
},
17
"devDependencies": {
18
"typescript": "catalog:",
19
"wrangler": "^4.4.0"
20
}
21
}

apps/web/next.config.js 也要把共享包加进 transpilePackages

apps/web/next.config.js
1
/** @type {import('next').NextConfig} */
2
const nextConfig = {
3
transpilePackages: ['@repo/ui', '@repo/contracts', '@repo/api'],
4
}
5
6
export default nextConfig

第二步,首页直接连 RPC。

这里用 hc<AppType>() 建 client,前端就能拿到 route 对应的参数和返回值推导。

apps/web/app/page.tsx
01
import type { AppType } from '@repo/api'
02
import {
03
BizCode,
04
type ApiResponse,
05
type PingRequest,
06
type PingResponse,
07
} from '@repo/contracts'
08
import { hc, type InferResponseType } from 'hono/client'
09
10
const apiBaseUrl = process.env.API_BASE_URL ?? 'http://127.0.0.1:8787'
11
const rpcPayload: PingRequest = { name: 'web' }
12
13
type PingRpcResponse = InferResponseType<
14
ReturnType<typeof hc<AppType>>['rpc']['system']['ping']['$post']
15
>
16
17
async function getPingResponse(): Promise<PingRpcResponse> {
18
const client = hc<AppType>(apiBaseUrl)
19
20
try {
21
const response = await client.rpc.system.ping.$post({
22
json: rpcPayload,
23
})
24
25
return await response.json()
26
} catch (error) {
27
return {
28
ok: false,
29
error: {
30
code: BizCode.SYSTEM_UPSTREAM_TIMEOUT,
31
message: error instanceof Error ? error.message : 'API request failed',
32
},
33
meta: {
34
requestId: 'unavailable',
35
timestamp: new Date().toISOString(),
36
},
37
} satisfies ApiResponse<PingResponse>
38
}
39
}

第三步,把调用结果直接展示在首页。

这个展示区块不需要做花活,能看清请求体、返回值和错误码就够了。

apps/web/app/page.tsx
01
const requestBody = JSON.stringify(rpcPayload, null, 2)
02
const responseBody = JSON.stringify(pingResult, null, 2)
03
04
<section className="py-10">
05
<Card className="overflow-hidden border border-border bg-background shadow-soft">
06
<CardContent className="space-y-5 p-6">
07
<div className="space-y-2">
08
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">
09
RPC validation
10
</p>
11
<h2 className="text-2xl font-semibold tracking-tight text-foreground">
12
Shared request and response contract
13
</h2>
14
</div>
15
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
16
<span className="rounded-full border border-border px-3 py-1">
17
POST /rpc/system/ping
18
</span>
19
<span className="rounded-full border border-border px-3 py-1">
20
{pingResult.ok ? 'ok=true' : `code=${pingResult.error.code}`}
21
</span>
22
</div>
23
<div className="grid gap-4 lg:grid-cols-2">
24
<div className="rounded-2xl border border-border bg-muted/40 p-4">
25
<p className="text-sm font-medium text-foreground">Request</p>
26
<pre className="mt-3 overflow-x-auto whitespace-pre-wrap break-all text-xs leading-6 text-muted-foreground">
27
{requestBody}
28
</pre>
29
</div>
30
<div className="rounded-2xl border border-border bg-muted/40 p-4">
31
<p className="text-sm font-medium text-foreground">Response</p>
32
<pre className="mt-3 overflow-x-auto whitespace-pre-wrap break-all text-xs leading-6 text-muted-foreground">
33
{responseBody}
34
</pre>
35
</div>
36
</div>
37
</CardContent>
38
</Card>
39
</section>

这样,我们就可以直接在首页上验证共享请求类型和响应类型是否跑通。

6. 验证

做到这里,其实已经验证了四件关键的事。

共享请求类型已经跑通。

PingRequestSchemaPingRequest 来自同一个共享包,API 用它校验,前端用它约束入参。字段一旦变化,前后端会一起感知。

共享响应类型已经跑通。

PingResponseApiResponse<T> 让成功结构、失败结构、元信息结构都固定下来。后面新增接口,不需要每次重新发明一套返回格式。

异常码已经有了统一出口。

无论是 notFound、参数校验失败,还是运行时异常,最后都会汇总到统一的 failure envelope。前端读取错误信息时,也不再猜字段名。

Hono RPC 的类型推导已经接上。

前端通过 hc<AppType>() 直接消费 API route 类型,这意味着 route 路径、请求体、返回值三者已经串到一起。

这个阶段先别急着抽出通用 rpcClientfetcherservice layer。当前目标只是验证方向,最小链路能跑通,后面的抽象才有依据。

跑通之后,后面的用户、鉴权、任务、消息这些业务 route,就都有统一入口可接了:先在 packages/contracts 补 schema 和类型,再在 apps/api/src/app.ts 补 route,最后让前端通过 hc<AppType>() 消费。