接口不是 URL,是系统契约
原创 · 约 34 分钟阅读 · 阅读 --

接口不是 URL,是系统契约

作者: 字与码


古董级程序员,大厂出来后一直在创业公司,现在仍在一线写代码、做产品、和 AI 编程工具较劲。更完整的更新会放在微信公众号「字与码」:工作经历、新技术观察、AI 开发实践,以及这些年踩过的坑,都会陆续写在那里。

《AI时代的程序员修养》前四篇讲了程序、数据结构、执行模型和模块通信。从这一篇开始,进入“把功能做成能扛流量的服务”这一卷。第一件事是接口。

AI 写接口很快。给它一句“实现用户查询接口”,它能马上生成路由、参数、ORM 查询和 JSON 返回。问题是,接口最重要的部分不是 URL,也不是 handler 里那几行代码,而是系统对调用方做出的承诺:字段长什么样,错误怎么算,能不能重试,权限怎么判,版本怎么演进,旧客户端会不会被新字段打坏。

接口不是 URL,是契约。契约没写清楚,代码越快生成,后面越难收拾。

URL 只是入口

很多接口设计讨论会停在这一层:

GET /api/users/{id}
POST /api/orders
GET /api/orders?page=1&page_size=20

这些当然要设计,但它们只是入口。真正的契约至少包括:

  • 请求参数和字段类型。
  • 字段是否必填,默认值是什么。
  • 返回结构是否稳定。
  • 错误码和错误语义。
  • 权限要求。
  • 幂等规则。
  • 分页和排序规则。
  • 限流和超时边界。
  • 版本兼容策略。
  • 示例参数和示例响应。
  • 可观测字段,如 request_idtrace_id

AI 生成接口时,最常见的问题是“看起来能调”。能调不代表能用,更不代表能长期维护。

比如一个查询工具调用历史的接口,如果只写:

GET /tool-history?user_id=xxx

它没有说明很多关键问题:

  • 只能查自己的历史,还是管理员可以查所有人?
  • 时间范围有没有上限?
  • 默认排序是什么?
  • 返回字段里是否包含敏感参数?
  • 分页是 page/page_size,还是 cursor?
  • tool_id 搜索是精确匹配还是模糊匹配?
  • 没有结果时返回空列表,还是 404?
  • 查询太慢时返回什么错误?

这些不明确,前端、后端、测试和数据分析都会各自猜一套。

先写 schema,再写 handler

接口契约里最基础的是 schema。schema 不是“文档装饰”,它应该约束代码。

以创建订单为例,一个随手写的请求可能是:

{
  "user_id": "u_123",
  "items": [
    {"sku": "book_001", "count": 2}
  ],
  "coupon": "NEWUSER"
}

但契约必须更明确:

{
  "user_id": "string, required",
  "items": "array, required, 1..100",
  "items[].sku": "string, required, max 64",
  "items[].quantity": "integer, required, 1..999",
  "coupon_code": "string, optional, max 64",
  "client_order_id": "string, required, max 128"
}

这里有几个小变化很关键。

count 改成 quantity,语义更清楚。items 有长度上限,避免一次请求塞进几万行。quantity 有范围,避免 0、负数或离谱数字。client_order_id 用于幂等,避免客户端重试创建重复订单。

如果用 Python,可以把 schema 直接写成模型:

from pydantic import BaseModel, Field


class OrderItemIn(BaseModel):
    sku: str = Field(min_length=1, max_length=64)
    quantity: int = Field(ge=1, le=999)


class CreateOrderRequest(BaseModel):
    user_id: str = Field(min_length=1, max_length=64)
    items: list[OrderItemIn] = Field(min_length=1, max_length=100)
    coupon_code: str | None = Field(default=None, max_length=64)
    client_order_id: str = Field(min_length=1, max_length=128)

这段代码不复杂,AI 也能写。重点是你要在 prompt 里要求它先定义契约:

先不要写 handler。
请先定义请求和响应 schema,说明每个字段的类型、必填、默认值、长度范围、枚举范围和业务含义。
schema 通过后再写接口实现。

没有 schema 的接口,就像没有类型的系统边界。小项目可以靠记忆,大系统只能靠约束。

响应结构要稳定

接口返回值最怕“今天想起来加一个字段,明天顺手改一个结构”。

一个比较稳的列表响应通常长这样:

{
  "items": [
    {
      "id": "order_123",
      "status": "paid",
      "total_amount": 12900,
      "created_at": "2026-07-03T10:00:00+08:00"
    }
  ],
  "page_info": {
    "next_cursor": "eyJpZCI6...",
    "has_more": true
  },
  "request_id": "req_abc"
}

几个细节值得保留:

  • 列表永远放在 items,不要有时叫 data,有时叫 list
  • 分页信息单独放 page_info,不要混在每一项里。
  • 金额用整数最小货币单位,避免浮点误差。
  • 时间用明确时区的 ISO 8601 字符串。
  • request_id 返回给调用方,方便排障。

AI 经常会根据 handler 内部对象直接返回 ORM:

return order

这很危险。ORM 里可能有内部字段、敏感字段、调试字段,也可能因为模型变化导致接口悄悄变化。响应模型应该单独定义:

class OrderOut(BaseModel):
    id: str
    status: str
    total_amount: int
    created_at: datetime


class ListOrdersResponse(BaseModel):
    items: list[OrderOut]
    page_info: PageInfo
    request_id: str

让 AI 写接口时,可以直接要求:

禁止直接返回 ORM 对象。
必须定义独立 response schema。
内部字段、密钥、成本字段、调试字段不能出现在响应里。

这类约束很具体,AI 才容易遵守。

错误码比成功返回更重要

成功路径通常很简单。接口真正难的是失败。

很多 AI 生成的接口会这样处理错误:

raise HTTPException(status_code=400, detail="invalid request")

或者更糟:

return {"success": False, "message": "error"}

调用方拿到这种错误,很难判断下一步该做什么。应该让错误有稳定结构:

{
  "error": {
    "code": "ORDER_ITEM_OUT_OF_STOCK",
    "message": "部分商品库存不足",
    "retryable": false,
    "details": {
      "sku": "book_001"
    }
  },
  "request_id": "req_abc"
}

错误至少要回答:

  • 机器可识别的错误码是什么?
  • 人能读的提示是什么?
  • 能不能重试?
  • 是参数错误、权限错误、资源不存在、状态冲突,还是系统错误?
  • 有没有可排查的 request_id
  • details 里有没有必要上下文?

HTTP 状态码和业务错误码要配合使用:

HTTP 状态适合场景示例业务码
400请求格式错误、字段不合法INVALID_ARGUMENT
401未登录或 token 无效UNAUTHENTICATED
403已登录但无权限PERMISSION_DENIED
404资源不存在或不可见RESOURCE_NOT_FOUND
409状态冲突、重复操作ORDER_ALREADY_PAID
422语义校验失败ORDER_ITEM_OUT_OF_STOCK
429超过频率或额度RATE_LIMITED
500服务内部错误INTERNAL_ERROR
503下游不可用、临时过载SERVICE_UNAVAILABLE

有了错误码,客户端才能写稳定逻辑。比如 RATE_LIMITED 可以提示稍后再试,ORDER_ALREADY_PAID 可以刷新订单状态,ORDER_ITEM_OUT_OF_STOCK 可以让用户修改购物车。

让 AI 写错误处理时,可以要求:

请定义错误响应统一结构。
每个错误码必须说明:
- HTTP status
- code
- 是否 retryable
- 调用方应该怎么处理
- 是否需要记录安全审计日志

错误契约比成功契约更能看出接口是否成熟。

幂等不是支付接口才需要

幂等的意思是,同一个操作重复执行多次,最终效果和执行一次一样。

很多人只在支付接口想到幂等。实际上,只要存在客户端重试、网关重试、网络超时、队列重复投递,就要考虑幂等。

创建订单、提交表单、触发任务、发送验证码、执行工具调用、导入文件,都可能需要幂等。

一个典型创建接口可以要求客户端传 Idempotency-Key

POST /api/orders
Idempotency-Key: create-order:user_123:client_order_789

服务端记录这个 key 的处理结果:

create table idempotency_keys (
  key text primary key,
  request_hash text not null,
  status text not null,
  response_body jsonb,
  created_at timestamptz not null,
  expires_at timestamptz not null
);

关键点有两个。

同一个 key、同一个 request hash,可以返回第一次处理结果。同一个 key、不同 request hash,应该拒绝,因为这代表客户端复用了幂等键但请求内容变了。

伪代码可以这样写:

def create_order(request: CreateOrderRequest, idempotency_key: str):
    request_hash = hash_request(request)

    existing = idempotency_store.get(idempotency_key)
    if existing:
        if existing.request_hash != request_hash:
            raise Conflict("IDEMPOTENCY_KEY_REUSED")
        return existing.response_body

    with transaction():
        idempotency_store.reserve(idempotency_key, request_hash)
        order = order_service.create(request)
        response = to_response(order)
        idempotency_store.mark_done(idempotency_key, response)

    return response

这段逻辑还需要处理并发抢同一个 key、处理中状态超时、结果保留时间等细节,但方向是对的。

让 AI 实现创建类接口时,可以直接写:

该接口必须支持幂等。
客户端通过 Idempotency-Key 传入幂等键。
同一 key + 同一请求体返回首次结果。
同一 key + 不同请求体返回 409。
请写并发测试:两个相同 key 的请求同时到达,只能创建一条业务记录。

如果没有这几句,AI 很可能只写一个普通 insert

分页不是 page_size 那么简单

列表接口非常常见,也非常容易被写坏。

最简单的分页是:

GET /orders?page=10&page_size=20

它适合数据量不大、排序稳定性要求不高的后台页面。但在高频接口或大表上,offset 分页会越来越慢,还容易漏数据或重复数据。

比如按创建时间倒序翻页。用户看第一页时有新订单插入,再看第二页,原本第一页末尾的数据可能被挤到第二页,出现重复。或者某些数据被跳过。

更稳定的是 cursor 分页:

GET /orders?limit=20&cursor=eyJjcmVhdGVkX2F0Ijoi...IiwiaWQiOiI...In0=

cursor 里通常包含排序字段和唯一 ID:

{
  "created_at": "2026-07-03T10:00:00+08:00",
  "id": "order_123"
}

查询条件类似:

select *
from orders
where user_id = :user_id
  and (
    created_at < :cursor_created_at
    or (created_at = :cursor_created_at and id < :cursor_id)
  )
order by created_at desc, id desc
limit :limit;

这里 created_at, id 共同保证排序稳定。对应索引也要跟上:

create index idx_orders_user_created_id
on orders (user_id, created_at desc, id desc);

让 AI 写列表接口时,prompt 里要明确:

列表接口使用 cursor 分页,不使用 offset。
排序字段为 created_at desc, id desc,必须稳定。
limit 默认 20,最大 100。
响应返回 next_cursor 和 has_more。
请给出推荐索引。

分页是接口契约,也是数据库访问契约。只写 page_size 不够。

权限要和数据过滤绑定

权限错误很常见,尤其是 AI 生成 CRUD 接口时。

一个危险写法:

def get_order(order_id: str, current_user: User):
    order = order_repository.get(order_id)
    if not order:
        raise NotFound()
    return order

这段代码没有判断订单是否属于当前用户。更好的做法是查询时就绑定权限范围:

def get_order(order_id: str, current_user: User):
    order = order_repository.get_visible_order(
        order_id=order_id,
        user_id=current_user.id,
    )
    if not order:
        raise NotFound()
    return order

为什么无权限也返回 404,而不是 403?这取决于业务。很多用户资源接口会用 404 避免暴露资源是否存在。后台管理接口则可能返回 403,并记录审计日志。

权限契约要写清楚:

  • 谁能调用这个接口?
  • 用户只能看自己的数据,还是可以按组织、角色、项目范围查看?
  • 管理员是否能跨用户查询?
  • 无权限返回 403 还是 404?
  • 是否记录审计日志?
  • 响应字段是否按权限脱敏?

字段级权限也很重要。同一个接口,普通用户和管理员看到的字段可能不同:

{
  "id": "tool_call_123",
  "tool_id": "fin_cap_001",
  "status": "success",
  "created_at": "2026-07-03T10:00:00+08:00"
}

管理员可能还需要看到成本、供应商、错误栈;普通用户不应该看到这些内部信息。

让 AI 写接口时,可以要求:

权限必须在查询条件中体现,不能先查全量再在内存过滤。
普通用户只能访问自己的资源;管理员可以按 user_id 查询。
无权限访问普通用户资源时返回 404。
管理员查询行为需要记录审计日志。
响应字段按角色过滤,普通用户不能看到内部成本和供应商错误详情。

这几条会直接影响 repository、service 和 response schema。

版本兼容要从第一天开始

接口一旦有人用,就会变成公共承诺。哪怕只是内部接口,也要考虑版本兼容。

最常见的兼容问题:

  • 删除字段。
  • 改字段类型。
  • 改枚举含义。
  • 把原本可选字段改成必填。
  • 改错误码。
  • 改分页顺序。
  • 改金额单位。
  • 把空列表改成 null。

这些改动都可能让调用方出问题。

兼容演进通常遵循几条规则:

  • 新增字段一般安全,调用方应忽略未知字段。
  • 新增枚举不一定安全,旧客户端可能不认识。
  • 字段废弃要先标记 deprecated,再观察调用方。
  • 老字段保留一段迁移期。
  • 语义变化最好使用新字段,而不是复用旧字段。
  • 大版本不兼容时,用版本路径或 header 明确区分。

比如接口原来返回:

{
  "status": "running"
}

后来想增加 timeout。如果旧客户端只认 pending/running/success/failed,新增枚举可能导致显示异常。可以同时返回更稳定的展示状态:

{
  "status": "timeout",
  "display_status": "failed",
  "can_retry": true
}

或者在契约里要求客户端对未知状态按 unknown 处理。

让 AI 修改接口时,不要只说“加个字段”。可以写:

这是已上线接口,必须保持向后兼容。
不能删除字段,不能修改已有字段类型。
新增枚举值前要说明旧客户端处理策略。
新增字段必须可选。
请列出兼容性风险和迁移步骤。

AI 写新功能很快,破坏兼容也很快。版本规则必须提前写进指令。

示例参数不是装饰

很多接口文档有字段表,却没有好样例。没有样例,AI 写调用方、测试用例和遥测脚本时就容易乱猜。

一个好的示例应该覆盖真实场景,不只是最短 happy path。

以搜索接口为例,弱样例可能是:

{
  "query": "test"
}

这几乎没有价值。更好的样例应该贴近真实用户:

{
  "query": "查询贵州茅台最近一年的分红和股息率",
  "language": "zh",
  "filters": {
    "provider_type": "finance",
    "market": "cn"
  },
  "limit": 10
}

它能帮助调用方理解接口用途,也能帮助测试覆盖真实路径。

示例还应该包括错误样例:

{
  "query": "",
  "limit": 1000
}

预期错误:

{
  "error": {
    "code": "INVALID_ARGUMENT",
    "message": "query 不能为空,limit 不能超过 100",
    "retryable": false
  },
  "request_id": "req_abc"
}

让 AI 生成接口文档时,可以要求:

每个接口至少给出:
- 一个真实成功样例。
- 一个边界成功样例。
- 一个参数错误样例。
- 一个权限错误样例。
- 一个可重试系统错误样例。
示例必须使用真实业务语义,不能用 foo/bar/test。

示例越真实,接口越不容易长歪。

契约测试把承诺钉住

接口契约最终要靠测试固定下来。

普通单测检查实现细节,契约测试检查对外承诺。它不关心内部怎么查库,只关心输入输出是否符合协议。

一个契约测试可以这样写:

def test_create_order_validation(client):
    response = client.post("/api/orders", json={
        "user_id": "u_123",
        "items": [],
        "client_order_id": "c_001",
    })

    assert response.status_code == 400
    body = response.json()
    assert body["error"]["code"] == "INVALID_ARGUMENT"
    assert body["error"]["retryable"] is False
    assert "request_id" in body

幂等测试更重要:

def test_create_order_idempotency(client):
    payload = {
        "user_id": "u_123",
        "items": [{"sku": "book_001", "quantity": 1}],
        "client_order_id": "c_001",
    }
    headers = {"Idempotency-Key": "create-order:u_123:c_001"}

    first = client.post("/api/orders", json=payload, headers=headers)
    second = client.post("/api/orders", json=payload, headers=headers)

    assert first.status_code == 200
    assert second.status_code == 200
    assert first.json()["id"] == second.json()["id"]

分页测试也不能少:

def test_list_orders_cursor_is_stable(client, seed_orders):
    first = client.get("/api/orders?limit=20")
    next_cursor = first.json()["page_info"]["next_cursor"]

    create_new_order()

    second = client.get(f"/api/orders?limit=20&cursor={next_cursor}")

    first_ids = {item["id"] for item in first.json()["items"]}
    second_ids = {item["id"] for item in second.json()["items"]}
    assert first_ids.isdisjoint(second_ids)

这些测试不是为了追求覆盖率好看,而是为了防止后来 AI 或人类重构时破坏契约。

一份给 AI 的接口契约提示词

以后让 AI 写接口,可以先用这段 prompt:

先不要写接口实现。

请为这个 API 设计契约:
1. URL、HTTP method、认证方式和权限范围。
2. 请求 schema:字段类型、必填、默认值、长度范围、枚举、业务含义。
3. 响应 schema:成功结构、字段脱敏、金额和时间格式。
4. 错误结构:HTTP status、业务 code、retryable、调用方处理建议。
5. 幂等规则:哪些操作需要 Idempotency-Key,重复请求如何处理。
6. 分页和排序:是否使用 cursor,排序字段是否稳定,limit 上限。
7. 版本兼容:新增字段、废弃字段、新增枚举、旧客户端策略。
8. 可观测性:request_id、trace_id、审计日志、关键指标。
9. 示例:真实成功样例、边界样例、参数错误、权限错误、可重试错误。
10. 契约测试:至少 5 个测试用例。

契约确认后,再写 handler、service、repository 和测试代码。

这段 prompt 看起来比“帮我写接口”麻烦,但它能省掉后面大量返工。

写接口前,先问这些问题

接口上线以后,调用方会把它当承诺。写代码前,至少问一遍:

  • 这个字段以后能不能改名?
  • 这个枚举以后能不能扩展?
  • 这个错误调用方能不能自动处理?
  • 这个创建接口能不能安全重试?
  • 这个列表翻页会不会漏数据或重复数据?
  • 这个资源是否可能被越权访问?
  • 这个响应有没有泄漏内部字段?
  • 老客户端看到新字段、新枚举、新错误码会怎样?
  • 出问题时,能不能用 request_id 查到完整路径?

AI 能快速写出能跑的接口。程序员要做的是把“能跑”推进到“能被别人长期依赖”。

这就是接口契约的价值。它不是文档洁癖,而是系统之间最基本的信任。