diff --git a/prd-app-factory-config-unification.md b/prd-app-factory-config-unification.md new file mode 100644 index 0000000..073ac9c --- /dev/null +++ b/prd-app-factory-config-unification.md @@ -0,0 +1,471 @@ +# PRD: App Factory + Config 统一重构(PR1) + +## 背景 + +当前 `ephron.ren` 已经从单一 MVP 演进成多服务单仓结构,包含: + +- `auth` +- `blog` +- `canvas` +- `home` +- `prompt` +- `shared` + +但服务初始化与配置层仍保留大量早期复制实现,主要集中在: + +- 五个服务的 `src/main.py` +- 五个服务的 `src/config.py` + +这导致后续每次要统一安全头、health、docs、错误处理、配置约定时,都需要多处重复修改,并且很容易发生服务之间的行为漂移。 + +本 PRD 定义第一阶段重构:**只收敛 app 初始化骨架与 config 读取骨架,不改业务逻辑**。 + +--- + +## 问题定义 + +### 1. `main.py` 高度重复 +五个服务都重复做了以下工作: + +- 调用 `validate_config()` +- 创建 `FastAPI(...)` +- 安装 `security headers` +- 创建并挂载 `limiter` +- 挂载 `static` +- 注册 routers +- 注册 404/500 handler +- 提供 `/health` + +这种重复意味着: +- 加一个通用能力要改 5 份 +- 某个服务漏改的概率很高 +- 测试也更难形成统一契约 + +### 2. `config.py` 既重复又不一致 +当前配置层存在两个问题: + +#### 重复 +各服务都各自实现了: +- `.env` 加载 +- 必填/可选环境变量读取 +- config summary 输出 +- 环境模式判断 + +#### 不一致 +- `home` 使用 `ENVIRONMENT` +- 其他服务使用 `ENV` + +这会提高部署、调试和文档维护成本。 + +### 3. 当前 shared 层只抽了一半 +项目已经有这些共享能力: + +- `shared/security_headers.py` +- `shared/limiter.py` +- `shared/health.py` +- `shared/templating.py` +- `shared/ports.py` + +说明项目已经具备共享层思路,但还没有把最核心的 app/config 装配流程统一起来。 + +--- + +## 本次目标 + +本 PR1 完成后,应该达到: + +1. 五个服务 `main.py` 只保留: + - service 专属 lifespan + - router 列表 + - service 元信息 + - 调用统一 app factory + +2. 五个服务 `config.py` 改为复用共享 helper: + - `.env` 加载 + - required/optional env 读取 + - 环境模式判定 + - summary 输出 + +3. `ENV` 成为统一主环境变量,`home` 暂时兼容 `ENVIRONMENT` + +4. 不改变外部行为: + - URL 不变 + - `/health` 响应结构不变 + - 404/500 页面行为兼容 + - dev 模式 docs_url 保持可用 + +--- + +## 非目标 + +这次**不做**: + +- 不重做 service API +- 不收敛数据库 migration 归属 +- 不彻底消灭所有 `sys.path.insert` +- 不迁移目录结构 +- 不改业务权限与业务逻辑 + +这些属于后续 PR。 + +--- + +## 方案设计 + +## 一、共享 app factory + +新增: + +- `shared/app_factory.py` + +职责: +- 创建标准化 `FastAPI` app +- 安装安全头 +- 安装 limiter +- 挂载静态目录 +- 注册 routers +- 注册默认错误处理 +- 注册 `/health` + +建议接口: + +```python +def create_service_app( + *, + service_name: str, + title: str, + description: str, + version: str, + is_development: bool, + templates_dir, + routers: list, + static_dir=None, + lifespan=None, +): + ... +``` + +### 兼容要求 +- `service_name` 用于 health 响应,例如 `blog.ephron.ren` +- `static_dir=None` 时跳过 static 挂载 +- `canvas` 当前是“目录存在才挂载”,新实现必须兼容 + +--- + +## 二、共享错误处理安装器 + +新增: + +- `shared/error_handlers.py` + +职责: +- 安装 404/500 handler +- `/api/...` 返回 JSON +- 页面路径返回模板 +- production 不暴露原始异常文本 +- development 可注入错误信息方便调试 + +建议接口: + +```python +def install_default_error_handlers(app, *, templates_dir, is_development): ... +``` + +### 兼容要求 +- API 404 继续返回 `{"detail": "Not Found"}` +- 页面 404/500 继续使用各服务自己模板目录中的 `404.html` / `500.html` + +--- + +## 三、共享 config helper + +新增: + +- `shared/config_base.py` + +职责: +- 加载服务目录下 `.env` +- 提供 required/optional env 读取 helper +- 统一环境模式判定 +- 统一 config summary 输出 + +建议接口: + +```python +from pathlib import Path + +def load_service_env(service_root: Path) -> None: ... +def get_required_env(key: str) -> str: ... +def get_optional_env(key: str, default: str) -> str: ... +def resolve_environment(*, legacy_key: str | None = None) -> tuple[str, bool]: ... +def print_config_summary(service_name: str, items: dict[str, str]) -> None: ... +``` + +### 兼容要求 +- `resolve_environment(legacy_key="ENVIRONMENT")` 在 `ENV` 缺失时回退旧字段 +- 若 `ENV` 和 `ENVIRONMENT` 同时存在,以 `ENV` 为准 + +--- + +## 具体实施步骤 + +## Step 1:先补 shared 层单元测试 +新增: + +- `tests/test_shared_config_base.py` +- `tests/test_error_handlers.py` +- `tests/test_app_factory.py` + +覆盖这些能力: + +### `test_shared_config_base.py` +- 能加载指定服务目录 `.env` +- required env 缺失时报清晰错误 +- `ENV` 优先于 `ENVIRONMENT` +- summary 输出格式稳定 + +### `test_error_handlers.py` +- `/api/...` 404 返回 JSON +- 页面 404 返回模板 +- production 500 不暴露原始异常文本 +- development 500 可显示 `error_message` + +### `test_app_factory.py` +- 自动安装安全头 +- 自动安装 limiter +- 自动挂 routers +- `static_dir` 存在时挂载 `/static` +- `static_dir=None` 时不挂载 +- 自动提供 `/health` +- dev 模式 docs_url 存在,prod 模式关闭 + +--- + +## Step 2:实现 `shared/config_base.py` +约束: +- 共享层只处理“如何读配置”,不处理每个服务自己的业务字段 +- 不在这里写数据库或业务逻辑 + +--- + +## Step 3:实现 `shared/error_handlers.py` +要求: +- 内部复用 `shared.templating.create_templates` +- 用 path 前缀判断 API/页面行为 +- production 不直接暴露 `str(exc)` + +--- + +## Step 4:实现 `shared/app_factory.py` +应复用: +- `shared.security_headers.install_security_headers` +- `shared.limiter.create_limiter` +- `shared.error_handlers.install_default_error_handlers` +- `shared.health.build_health_response` + +不要做的事: +- 不在 factory 中写数据库初始化 +- 不在 factory 中塞业务判断 + +factory 只负责通用 app 壳。 + +--- + +## Step 5:改造五个服务 `config.py` +涉及: +- `auth/src/config.py` +- `blog/src/config.py` +- `canvas/src/config.py` +- `home/src/config.py` +- `prompt/src/config.py` + +### 逐服务注意点 + +#### Auth +保留: +- `AUTH_SECRET_KEY` +- `DATABASE_PATH` +- `COOKIE_DOMAIN` +- `COOKIE_NAME` +- `TOKEN_MAX_AGE` +- `TEMPLATES_DIR` + +要求: +- `validate_config()` 不变 +- summary 输出语义基本不变 + +#### Blog +保留: +- `CONTENT_DIR` 校验 +- `CACHE_DIR.mkdir(...)` +- `SEARCH_INDEX_DIR.mkdir(...)` + +#### Canvas +保留: +- `CONTENT_DIR` 不存在时自动创建 + +不能把当前“自动建目录”改成“配置错误退出”。 + +#### Prompt +保留: +- `DATABASE_PATH` +- `TOKEN_MAX_AGE` +- 当前 summary 语义 + +#### Home +重点: +- 进入统一 config helper 体系 +- 兼容 `ENVIRONMENT` +- `AUTH_BASE_URL / AUTH_LOGIN_URL` 行为不变 + +必须验证: +- 仅设置 `ENVIRONMENT=development` 时仍能进入开发模式 +- 若同时设置 `ENV=production` 和 `ENVIRONMENT=development`,以 `ENV` 为准 + +--- + +## Step 6:改造五个服务 `main.py` +涉及: +- `auth/src/main.py` +- `blog/src/main.py` +- `canvas/src/main.py` +- `home/src/main.py` +- `prompt/src/main.py` + +目标: +- 删除重复 app 初始化逻辑 +- 改为调用 `shared.app_factory.create_service_app(...)` + +每个服务保留: +- service 专属 lifespan +- router 列表 +- service 元信息 + +### 特别注意:Home 的 `/health` +`home/src/routes/pages.py` 当前也定义了 `/health`。 + +这次应统一只保留一处,建议: +- 删除 `pages.py` 中的 `/health` +- 统一由 app factory 提供 + +否则容易出现重复注册或行为漂移。 + +--- + +## Step 7:调整回归测试 +建议检查并更新: +- `tests/test_security_hardening.py` +- `tests/test_frontend_backend_reuse_contract.py` + +新增建议断言: +- 所有服务继续使用 shared template factory +- 所有服务通过统一 app factory 提供 health +- 所有服务继续保留安全头 +- home 环境兼容逻辑同时覆盖 `ENV` 与 `ENVIRONMENT` + +--- + +## Step 8:完整验证 +建议执行: + +```bash +python -m pytest tests -q +python -m pytest auth/tests -q +python -m pytest blog/tests -q +python -m pytest canvas/tests -q +python -m pytest home/tests -q +python -m pytest prompt/tests -q +``` + +如本地可启动,再手动验证: + +```bash +python main.py --reload +``` + +检查: +- 五个服务均能启动 +- `/health` 正常 +- API 不存在路径返回 JSON 404 +- 页面不存在路径返回各自 404 模板 + +--- + +## 预计改动文件 + +### 新增 +- `shared/config_base.py` +- `shared/error_handlers.py` +- `shared/app_factory.py` +- `tests/test_shared_config_base.py` +- `tests/test_error_handlers.py` +- `tests/test_app_factory.py` + +### 修改 +- `auth/src/config.py` +- `blog/src/config.py` +- `canvas/src/config.py` +- `home/src/config.py` +- `prompt/src/config.py` +- `auth/src/main.py` +- `blog/src/main.py` +- `canvas/src/main.py` +- `home/src/main.py` +- `prompt/src/main.py` +- `home/src/routes/pages.py`(删除重复 `/health`) +- `tests/test_security_hardening.py` +- `tests/test_frontend_backend_reuse_contract.py` + +--- + +## 风险与注意事项 + +### 1. Home 环境变量兼容风险 +历史部署如果只写了 `ENVIRONMENT`,不能因为统一而直接失效。 + +### 2. 404/500 行为变化风险 +抽象错误处理时,很容易把 API 路径判断或模板上下文改坏,必须用单元测试兜住。 + +### 3. Canvas static 行为变化风险 +当前是“存在才挂载”,不能在 app factory 中粗暴统一成“总是挂载”。 + +### 4. 测试依赖旧实现细节 +如果某些测试直接断言 `main.py` 中出现某段文本,改造后会失效,需要调整为验证行为而不是验证源码写法。 + +--- + +## 建议提交拆分 + +建议拆成 5 个 commit,便于实现与 review: + +1. `test: add shared config and app factory coverage` +2. `feat: add shared config base` +3. `feat: add shared error handlers and app factory` +4. `refactor: migrate service config modules to shared helpers` +5. `refactor: migrate service main modules to shared app factory` + +--- + +## 完成定义 + +本 PR1 完成后,必须满足: + +- [ ] 五个服务 `main.py` 不再手写重复 app 装配 +- [ ] 五个服务 `config.py` 使用共享 helper +- [ ] `ENV` 成为统一主环境变量,home 兼容 `ENVIRONMENT` +- [ ] `/health` 外部行为不变 +- [ ] 404/500 行为兼容 +- [ ] 全部现有测试通过,新增 shared 测试通过 +- [ ] 没有新增 `sys.path.insert` 使用点 + +--- + +## 后续建议 + +PR1 合并后,建议继续: + +1. PR2:抽 `shared/service_api/*` 与 `tests/helpers/*` +2. PR3:逐步收敛 import 结构,减少 `src` 冲突与 `sys.path.insert` +3. PR4:统一 DB schema / migration ownership + +这样可以保证每一步都是建立在更稳定的基础上,而不是继续在重复结构上迭代。 \ No newline at end of file