跳转至

内部对象直接 return,客户端看到了不该看的东西

上一课结束时,AI 学习助手已经有了一个结构清晰的 typed core:

@dataclass(frozen=True)
class LearningGoal:
    topic: str
    weekly_hours: int

@dataclass(frozen=True)
class Plan:
    topic: str
    daily_minutes: int
    steps: list[str]
    _internal_score: float = 0.0
    created_by: str = "system"

def build_plan(goal: LearningGoal) -> Plan:
    daily_minutes = goal.weekly_hours * 60 // 7
    return Plan(
        topic=goal.topic,
        daily_minutes=daily_minutes,
        steps=[f"每天学习 {goal.topic} {daily_minutes} 分钟"],
        _internal_score=0.72,
        created_by="plan_engine_v2",
    )

模块边界画好了,类型契约写清了,内部协作一切井然有序。现在产品说:用户要从 浏览器调用这个功能。

你的第一反应可能和很多人一样:加一个路由,把 Plan 对象序列化成 JSON 返回 就行了。毕竟数据都在那里,何必再折腾一层?

这一章就从这个"何必折腾"开始。我们要亲眼看看直觉做法会把什么东西送到客户端 手里,然后理解为什么 HTTP 公开接口需要自己独立的契约设计。

运行环境

Python 3.12、FastAPI 0.115+、Pydantic v2。

python -m pip install "fastapi[standard]>=0.115" "pydantic>=2,<3"

本章观察的行为在 FastAPI 0.115+ 和 Pydantic v2 各小版本中一致。

1. 把内部对象直接扔给客户端

假设你用 Flask 的直觉来暴露 build_plan

from flask import Flask, request, jsonify

app = Flask(__name__)

@app.post("/plans")
def create_plan():
    data = request.get_json()
    goal = LearningGoal(topic=data["topic"], weekly_hours=data["weekly_hours"])
    plan = build_plan(goal)
    return jsonify(plan.__dict__)

看起来五行就搞定了。现在发一个请求:

curl -X POST http://localhost:5000/plans \
  -H "Content-Type: application/json" \
  -d '{"topic": "Python typing", "weekly_hours": 6}'

先别往下看。预测一下:客户端会收到哪些字段?

拿到的 JSON 响应是这样的:

{
  "topic": "Python typing",
  "daily_minutes": 51,
  "steps": ["每天学习 Python typing 51 分钟"],
  "_internal_score": 0.72,
  "created_by": "plan_engine_v2"
}

看到 _internal_score 了吗?这是一个内部质量评分,用于后续的排序和优化迭代。 它对客户端毫无意义,甚至可能让竞争对手看到你的评分算法倾向。

created_by 呢?这是内部追踪字段,记录是哪个引擎版本生成的计划。用户不需要 知道 plan_engine_v2 的存在,运维团队的内部命名也不该出现在公开接口里。

问题不在于这两个字段有多机密——而在于你从未决定过它们应该出现。__dict__ 把 内部对象的所有属性一股脑倒了出去,客户端收到了什么完全取决于对象碰巧有什么 字段。

这就像把整个抽屉端给客人,里面有文件也有便签纸和零食发票。你没打算给他们看 发票,只是懒得挑了。

现在停下来想一想:Plan 的哪些字段是客户端真正需要的?哪些字段不应该出现在 HTTP 响应中?

答案很直接:客户端需要 topicdaily_minutessteps——这些是学习计划 的内容。_internal_scorecreated_by 是实现细节,不是客户端需要知道的 信息。

这个观察引出一个更根本的问题:内部函数签名和 HTTP 公开接口,它们各自在向谁 做承诺?

2. 内部签名和 HTTP 接口,面对的是不同的人

上面的问题不是偶然出现的。它指向一个更根本的分工:

def build_plan(goal: LearningGoal) -> Plan

这个函数签名面向谁?面向项目内部的开发者和类型工具。它说的是:

  • 给我一个 LearningGoal,我还你一个 Plan
  • IDE 可以据此补全、跳转和类型检查
  • 调用方知道需要准备什么、能拿回什么

它不需要回答:用户的请求从哪条路径进来?用什么 HTTP 方法?请求体是什么格式? 成功时返回什么状态码?因为在内部调用场景里,这些问题不存在。

但一旦要让远程客户端调用这个功能,情况完全变了。客户端看不到你的 Python 代码,它只能通过网络发送一个 HTTP 请求。它需要知道的是:

维度 客户端必须知道的信息
路径 向哪个 URL 发送请求(/plans
方法 用 POST、GET 还是 PUT
请求体 JSON 里应该包含哪些字段、什么类型
状态码 成功时收到 200 还是 201
响应体 返回的 JSON 包含哪些字段

这些信息合在一起,构成了你对远程客户端的公开承诺

把这两件事并排看:

内部函数签名:
  消费者 → 项目内部的开发者、IDE、类型检查器
  承诺   → 参数类型和返回类型
  不涉及 → 路径、HTTP 方法、状态码、序列化格式

HTTP 公开契约:
  消费者 → 远程客户端(浏览器、移动端、其他服务)
  承诺   → 路径 + 方法 + 参数来源 + 状态码 + 响应体结构
  不涉及 → 内部实现类型、私有字段、引擎版本

现在可以解释为什么直接 return plan.__dict__ 会出事:你把一个面向内部开发者 的对象结构,当作了面向远程客户端的公开承诺。两者的消费者不同、职责不同、 该包含的信息也不同。

这不是"多此一举"。这是两个不同岗位的工作。内部类型负责让团队高效协作, 公开契约负责让客户端正确使用你的服务。硬要一个人干两份工作,结局通常是两边 都干不好。

理解了这个分工之后,下一个自然的问题是:既然需要独立设计 HTTP 公开契约,有没有 一种方式能在一处把路径、方法、参数来源、状态码和响应结构同时声明清楚?

3. FastAPI 如何在一处同时声明公开契约

知道了需要独立设计 HTTP 公开契约后,下一个问题是:怎么声明它?

先看一个最小的 FastAPI endpoint:

from fastapi import APIRouter
from pydantic import BaseModel

router = APIRouter()

class CreatePlanRequest(BaseModel):
    topic: str
    weekly_hours: int

class PlanResponse(BaseModel):
    topic: str
    daily_minutes: int
    steps: list[str]

@router.post("/plans", status_code=201, response_model=PlanResponse)
async def create_plan(request: CreatePlanRequest) -> Plan:
    goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
    return build_plan(goal)

别急着问"这比 Flask 多了什么"。先观察这段代码声明了哪些公开契约的维度:

  1. @router.post("/plans", ...) — 路径是 /plans,方法是 POST
  2. status_code=201 — 成功时返回 HTTP 201 Created
  3. request: CreatePlanRequest — 请求体是 JSON,必须包含 topic(字符串) 和 weekly_hours(整数);FastAPI 自动从请求体绑定参数
  4. response_model=PlanResponse — 响应体只包含 topicdaily_minutessteps

一个装饰器加一个函数签名,同时完成了:

  • 路由绑定(路径 + 方法)
  • 参数来源声明(请求体 JSON)
  • 输入类型和校验规则(Pydantic 模型)
  • 成功状态码
  • 响应结构声明

这不是因为 FastAPI 特别"魔法"。它是把你本来就需要分别声明的公开契约维度, 集中到了同一个位置。相比 Flask 中路径、参数解析、校验和响应分散在不同代码行 甚至不同文件中,FastAPI 让你在一处就能看见完整的 HTTP 公开契约。

有了公开契约的声明方式,接下来要回答一个关键问题:函数内部返回了包含内部字段的 完整对象,但 response_model 只声明了部分字段——最终到达客户端的内容由谁决定?

4. response_model 的过滤:你 return 了什么 ≠ 客户端收到什么

回到上面那段代码。注意函数内部做了什么:

async def create_plan(request: CreatePlanRequest) -> Plan:
    goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
    return build_plan(goal)

build_plan 返回的是完整的 Plan 对象——包含 _internal_scorecreated_by。函数确实 return 了一个带有内部字段的对象。

但装饰器声明了 response_model=PlanResponse,而 PlanResponse 只有三个 字段:topicdaily_minutessteps

先预测:客户端最终会收到什么?

发送请求试试:

curl -X POST http://localhost:8000/plans \
  -H "Content-Type: application/json" \
  -d '{"topic": "Python typing", "weekly_hours": 6}'

响应:

{
  "topic": "Python typing",
  "daily_minutes": 51,
  "steps": ["每天学习 Python typing 51 分钟"]
}

_internal_scorecreated_by 消失了。

这是本章的核心发现:函数内部返回什么,和客户端实际收到什么,之间存在一道 受控的过滤层response_model 在序列化阶段只提取自己声明的字段,其余一律 不进入 HTTP 响应。

你的函数可以放心返回内部业务对象,不需要手动剔除字段、不需要构造另一个字典。 response_model 像一个只允许特定字段通过的闸口——声明了什么才放行什么。

这里有一个容易混淆的细节值得厘清:response_model 和函数返回值的类型标注 (-> Plan)是两件不同的事。类型标注 -> Plan 是给类型检查器看的,它保证 函数内部的实现一致性——你说要返回 Plan,类型检查器就确保你确实返回了 Plan。 而 response_model=PlanResponse 是给 FastAPI 框架看的,它决定序列化时提取 哪些字段发送给客户端。一个管内部协作的正确性,一个管外部响应的边界。两者可以 指向不同的类型,各自承担各自的职责。

现在做一个思想实验:如果去掉 response_model 会怎样?

@router.post("/plans", status_code=201)
async def create_plan(request: CreatePlanRequest) -> Plan:
    goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
    return build_plan(goal)

没有了 response_model 的过滤,FastAPI 会尝试直接序列化返回值。如果 Plan 是 dataclass 且所有字段都可序列化,客户端就会收到完整的内部对象——包括 _internal_scorecreated_by

这就回到了本章开头的 Flask 场景:内部字段泄露给远程客户端。

response_model 不只是"方便的序列化工具"。它是公开契约对响应的最后一道边界: 即使函数实现发生变化、内部对象增加了新字段,只要 PlanResponse 没有声明那些 字段,客户端就永远看不到它们。

这意味着你的公开契约可以独立于内部实现演化。内部加了 _debug_trace 字段? 客户端无感。重构了 Plan 的内部结构?只要 PlanResponse 声明的字段仍然能 从返回值中提取到,公开契约就保持稳定。

到这里,我们已经知道如何声明公开契约、如何通过 response_model 过滤内部字段。 最后一个需要回答的架构问题是:这些负责 HTTP 翻译的代码,应该放在哪里?它和 core 之间的依赖关系应该指向哪个方向?

5. 适配层依赖 core,而不是反过来

最后一个关键判断:这些代码应该怎么组织?

正确的依赖方向:

# router.py(HTTP 适配层)
from core import build_plan, LearningGoal
from schemas import CreatePlanRequest, PlanResponse

@router.post("/plans", status_code=201, response_model=PlanResponse)
async def create_plan(request: CreatePlanRequest) -> Plan:
    goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
    return build_plan(goal)

router.py 知道 HTTP、知道请求模型和响应模型、知道如何调用 core。它是 适配层——负责把 HTTP 请求翻译成 core 能理解的输入,再把 core 输出翻译成 HTTP 响应。

现在看一个反例:

# core.py(反面教材)
from fastapi import Request

def build_plan(request: Request) -> dict:
    data = await request.json()
    topic = data["topic"]
    weekly_hours = int(data["weekly_hours"])
    daily_minutes = weekly_hours * 60 // 7
    return {
        "topic": topic,
        "daily_minutes": daily_minutes,
        "steps": [f"每天学习 {topic} {daily_minutes} 分钟"],
    }

这段代码让 core.py 直接导入了 fastapi.Request。业务逻辑现在知道自己运行 在 HTTP 框架里,知道请求是 JSON 格式,甚至亲自做了类型转换。

后果是什么?

  • 如果以后想从消息队列调用同一个 build_plan,你得把 MQ 消息伪装成 Request 对象——荒诞至极。
  • 如果想给 build_plan 写单元测试,你得构造一个假的 HTTP Request——本来只是 测试计划生成逻辑,现在还要和 HTTP 框架纠缠。
  • core 不再是独立的业务模块,而是 FastAPI 的附庸。

为什么反向依赖会让测试变得困难?因为单元测试的核心价值是快速验证业务逻辑的 正确性。当 core 依赖了 fastapi.Request,测试 build_plan 时你必须先构造 一个符合 Starlette ASGI 规范的 Request 对象——设置 scope、body stream、 headers。这些都与"一个话题加上每周学习时长,能不能正确生成计划"毫无关系。 测试代码中大部分行数都在搭建框架脚手架,而不是验证业务规则。如果依赖方向 正确,测试只需要 build_plan(LearningGoal("Python", 6)) 一行就能拿到结果。

正确的方向始终是:

router.py (HTTP 适配层)
    │  import
core.py (typed core)

箭头从适配层指向 core。core 不知道也不需要知道自己会被谁调用——HTTP、CLI、 测试、消息队列都可以直接使用它的公开接口。

这延续了 W01-L01 建立的模块边界原则:核心业务通过明确的类型签名表达自己的 输入输出契约,不应依赖任何特定传输方式或框架。

边界与常见误区

误区一:把内部 Pydantic 业务模型直接当 response_model

# 假设 Plan 也是 BaseModel 且包含 _internal_score
@router.post("/plans", response_model=Plan)  # 危险!

如果 Plan 包含内部字段,直接把它用作 response_model 就失去了过滤保护。 response_model 应该是专门为公开契约设计的模型,只声明客户端需要看到的字段。 一个实际的反面信号是:当你发现某个 response_model 的字段列表和内部模型完全 一样时,就该停下来问自己——是公开契约碰巧和内部结构相同,还是你偷懒复用了?

误区二:认为 FastAPI 从签名推断的 schema 就是理想的公开契约

FastAPI 确实能从函数返回类型推断响应 schema。但"能推断"不等于"推断出来的 就是你想要的公开契约"。如果返回类型是内部对象,推断出的 schema 也会包含内部 字段。公开契约应该是你主动设计的,不是框架碰巧猜出来的。

举个具体例子:如果函数声明 -> Plan 且不指定 response_model,FastAPI 会用 Plan 的所有字段生成 OpenAPI schema。客户端开发者看到文档里有 _internal_score 字段,就会认为这是他们可以依赖的公开接口。等你以后想移除这个字段,就成了破坏性 变更。

误区三:混淆"函数返回值类型正确"和"客户端收到的响应与声明一致"

函数返回一个 Plan,类型检查器很满意。但如果没有 response_model,客户端收到 的 JSON 可能包含未声明的字段。类型正确是内部协作的保证,response_model 才是 外部契约的保证。这两道防线面向不同方向:类型标注向内(保证开发者之间的约定), response_model 向外(保证对客户端的承诺)。

误区四:混淆 Pydantic 边界模型和 HTTP 请求模型

W01-L01 建立的 Pydantic 边界模型(如 LearningGoal)和 HTTP 请求模型(如 CreatePlanRequest)都用 BaseModel 定义,看起来形状相似甚至字段相同。但它们 的设计驱动力不同:

  • core 边界模型的字段由"业务逻辑需要什么输入"驱动。LearningGoaltopicweekly_hours,是因为 build_plan 的算法需要这两个值。
  • HTTP 请求模型的字段由"客户端需要提供什么"驱动。CreatePlanRequest 的 字段可能和 LearningGoal 一样,但这是巧合——如果将来 API 需要支持批量创建, 请求模型可能变成 goals: list[...] 的结构,而 core 边界模型不会跟着变。

两者的变更原因不同:一个因业务规则变而变,一个因客户端交互需求变而变。即使 当前长得一样,它们是两个独立的设计决策。把它们混为一谈,等于把适配层的变更 和业务逻辑的变更耦合在一起。

本章小结

从 W01-L01 的 typed core 走到 HTTP 公开接口,第一个需要建立的判断是:内部 类型和 HTTP 公开契约面向不同消费者、承担不同职责。

内部函数签名通过参数类型和返回类型,向开发者和类型工具承诺调用契约。HTTP 公开 契约通过路径、方法、参数来源、状态码和响应体结构,向远程客户端承诺操作语义。

FastAPI path operation 将这些维度集中在一处声明:装饰器承担路径和方法,函数 签名承担参数来源和类型,response_model 承担响应结构。response_model 在 序列化阶段过滤字段,确保函数内部返回什么不等于客户端收到什么。

HTTP 适配层依赖 typed core 的公开接口,负责将 HTTP 请求转换为 core 期望的 输入、将 core 输出通过 response_model 过滤后返回给客户端。core 不应感知 HTTP 框架细节——这保证了业务逻辑可以被任何入口复用。

练习

练习一:为内部模型设计公开响应

下面是一个会话管理系统的内部模型:

@dataclass(frozen=True)
class UserSession:
    session_token: str
    ip_address: str
    last_active: str
    display_name: str
    preferences: dict[str, str]
    _fraud_score: float
    _ab_test_group: str
    login_method: str

产品需求:提供 GET /sessions/current 接口,让前端获取当前用户的会话信息, 用于页面右上角显示用户名和偏好设置。

任务: 1. 判断哪些字段不应出现在公开 API 响应中,为每个排除的字段说明理由 2. 设计一个 UserSessionResponse 模型,只包含客户端需要的字段

练习二:从代码中提取公开契约

阅读下面的 FastAPI endpoint,提取出它对远程客户端做出的全部公开承诺:

from fastapi import APIRouter
from pydantic import BaseModel

router = APIRouter()

class SubmitFeedbackRequest(BaseModel):
    lesson_id: str
    rating: int
    comment: str | None = None

class FeedbackResponse(BaseModel):
    feedback_id: str
    lesson_id: str
    status: str

@router.post("/feedback", status_code=201, response_model=FeedbackResponse)
async def submit_feedback(body: SubmitFeedbackRequest) -> Feedback:
    result = feedback_service.record(body.lesson_id, body.rating, body.comment)
    return result

任务: 1. 列出这个 endpoint 公开契约的每个维度(路径、方法、请求体字段及类型、成功 状态码、响应体字段) 2. 对比:内部函数 feedback_service.record 的签名向调用方承诺了什么? 3. 指出哪些信息只在 HTTP 公开契约中存在,record 的函数签名无法表达

练习三:预测 response_model 过滤结果

一个订单系统的 endpoint:

class OrderDetail(BaseModel):
    order_id: str
    customer_name: str
    items: list[str]
    total_price: float
    profit_margin: float
    supplier_cost: float
    internal_priority: int

class OrderPublicView(BaseModel):
    order_id: str
    customer_name: str
    items: list[str]
    total_price: float

@router.get("/orders/{order_id}", response_model=OrderPublicView)
async def get_order(order_id: str):
    return order_repo.get_full_detail(order_id)
    # 假设返回的是完整的 OrderDetail 对象

预测: 1. 客户端收到的 JSON 包含哪些字段?profit_marginsupplier_costinternal_priority 会出现吗? 2. 如果把 response_model=OrderPublicView 改为 response_model=OrderDetail, 客户端会看到什么变化? 3. 如果 order_repo.get_full_detail 将来增加了 _warehouse_location 字段, 在保留当前 response_model 的情况下,客户端是否会感知到这个变化?

练习四:判断依赖方向

以下是两个项目的模块导入代码,判断哪个的依赖方向有问题:

项目 A 的 router.py

from core.planning import build_plan, LearningGoal
from api.schemas import CreatePlanRequest, PlanResponse

@router.post("/plans", status_code=201, response_model=PlanResponse)
async def create_plan(request: CreatePlanRequest):
    goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
    return build_plan(goal)

项目 B 的 core/planning.py

from fastapi import Request
from fastapi.responses import JSONResponse

async def build_plan(request: Request) -> JSONResponse:
    data = await request.json()
    plan = _compute_plan(data["topic"], data["weekly_hours"])
    return JSONResponse(content=plan, status_code=201)

任务: 1. 指出哪个项目的依赖方向有问题 2. 用两句话解释:(a) 为什么项目 B 的写法会让 core 无法被非 HTTP 入口复用? (b) 如果将来需要同时从 CLI 和消息队列调用 build_plan,项目 B 会面临什么 困境?

练习解析

练习一解析

不应出现在响应中的字段及理由:

  • session_token:会话令牌是认证凭证,暴露在响应体中等于把钥匙交给了任何能 截获响应的中间人。即使是 HTTPS 传输,客户端也不应在普通业务响应中重复拿到 自己的 token。
  • ip_address:用户的 IP 地址属于隐私信息,不应在业务接口中返回。这个字段 的存在是为了后端的安全审计和异常检测,不是为了给前端展示。
  • _fraud_score:内部风控评分,属于实现细节。如果暴露,恶意用户可以根据分数 变化推断风控规则,从而规避检测。
  • _ab_test_group:内部实验分组信息。暴露后可能干扰实验结果(用户可能因为 知道自己在测试组而改变行为),也泄露了产品策略。
  • login_method:用户通过什么方式登录(密码、OAuth、SSO),属于安全相关的 内部状态。前端页面显示用户名和偏好设置不需要这个信息。

应该出现在响应中的字段:display_namepreferenceslast_active

其中 last_active 是否公开取决于业务场景:如果产品需要在页面上显示"上次活跃 时间",就应该包含;如果这个字段纯粹用于后端的会话过期判断,则不应包含。这 提醒我们:公开契约的字段选择不是纯技术决策,而是"客户端当前任务需要什么信息" 的业务决策。

设计的响应模型:

class UserSessionResponse(BaseModel):
    display_name: str
    preferences: dict[str, str]
    last_active: str

常见错误推理:有人会认为"字段名没有下划线前缀就可以公开"。但 session_tokenip_address 都没有下划线前缀,照样不该出现。判断标准不是命名约定,而是 "客户端完成其任务是否需要这个信息"。

快速自检方式:对每个字段问自己——"如果前端工程师在控制台看到这个字段, 他会用它做什么?"如果答案是"什么都做不了"或"不应该用",就不该出现在响应里。

练习二解析

公开契约的各个维度:

维度 承诺内容
路径 /feedback
方法 POST
请求体字段 lesson_id(字符串,必填)、rating(整数,必填)、comment(字符串或 null,选填)
成功状态码 201 Created
响应体字段 feedback_id(字符串)、lesson_id(字符串)、status(字符串)

内部函数 feedback_service.record(lesson_id, rating, comment) 向调用方承诺:

  • 接受三个参数:lesson_id、rating、comment
  • 返回一个 Feedback 对象(从返回类型标注推断)
  • 调用方需要自行准备正确类型的参数

只在 HTTP 公开契约中存在、record 函数签名无法表达的信息:

  • URL 路径(/feedback
  • HTTP 方法(POST)
  • 成功状态码(201)
  • 参数来源(从请求体 JSON 绑定)
  • 字段的"选填"语义(comment 可以为 null)
  • 响应体的具体字段选择(经过 response_model 过滤)

这些都是网络层面的交互协议,函数签名这种纯语言层面的契约无法表达。

练习三解析

  1. 客户端收到的 JSON 只包含 order_idcustomer_nameitemstotal_price 四个字段。profit_marginsupplier_costinternal_priority 不会出现——因为 response_model=OrderPublicView 只声明了四个字段,序列化时其余字段被过滤掉。

  2. 如果改为 response_model=OrderDetail,客户端会收到全部七个字段,包括 profit_margin(利润率)、supplier_cost(供应商成本)和 internal_priority(内部优先级)。这意味着客户可以看到你的利润空间和 供应商价格——这是严重的商业信息泄露。

  3. 不会。即使 order_repo.get_full_detail 返回的对象新增了 _warehouse_location 字段,由于 OrderPublicView 没有声明这个字段, response_model 的过滤层会阻止它进入 HTTP 响应。客户端完全无感。这正是 response_model 的核心价值:公开契约独立于内部实现演化。

练习四解析

项目 B 的依赖方向有问题。

(a) 项目 B 的 core/planning.py 导入了 fastapi.Requestfastapi.responses.JSONResponse,意味着业务逻辑被绑定到了 HTTP 框架。任何 非 HTTP 入口(CLI 脚本、定时任务、消息队列消费者)如果想调用 build_plan, 必须构造一个符合 ASGI 规范的 Request 对象——但这些入口根本没有 HTTP 请求。

(b) 如果将来需要同时从 CLI 和消息队列调用 build_plan,项目 B 面临的困境 是:要么为每个入口伪造 Request 对象(荒诞且脆弱),要么把 build_plan 的 核心算法重新抽取为一个不依赖框架的纯函数——等于承认最初的设计方向是错的, 需要重构。

项目 A 的方向正确:router.py(适配层)导入 core 的公开接口,core 本身对 HTTP 框架一无所知。箭头从适配层指向 core,core 可以被任何入口直接使用, 测试时只需 build_plan(LearningGoal("Python", 6)) 一行即可验证业务逻辑。

参考资料