apps/admin 独立应用:X-Admin-Token 鉴权(空=全部路由不注册=404)、实体/规则
维护与状态机流转、发布编排(publish/archive → 规则缓存 invalidate +
retrieval invalidate_cache 双失效)、素材登记与审核(tryon_ready 门禁透传)、
缺口看板(T-P2-09,FR-ADM-01~05)。V1 共享令牌鉴权,无角色无审计字段,
操作留证靠 structlog request_id。
测试:单测 tests/unit/test_admin_app.py(10 例:空令牌 404、错/缺/非 ASCII
令牌 40100、正确令牌 200 假服务、发布编排接线、幂等重放无副作用);
集成 tests/integration/test_admin_console.py(5 例:顾问全流程不碰客户端
API、双失效副作用可观察、非法跃迁 40900、素材审核门禁、缺口看板排序、
令牌 401)。483 单测 + 43 集成全绿;ruff/mypy/lint-imports 全绿。
偏差说明:
1. packages/retrieval.repo.list_gaps 有缺陷:对 select(KbGap) 的结果调
.mappings(),行键是实体名而非列名,GapSummary(row) 取 row.gap_id 必然
AttributeError(该路径无既有测试覆盖,接真库即暴露)。packages/** 本
任务禁改,故缺口查询在 apps/admin/routers/gaps.py 以等价 SQL 自实现
(hit_count 降序、同频次按 last_seen_at 降序、limit/offset 语义一致);
建议合并阶段修复上游后切回 retrieval.list_gaps。
2. 实体 transition 的 target 收全量四态(任务文档示例仅列 reviewing/
published/archived):按任务测试要求 published→draft 必须返回 40900
(状态机语义冲突)而非 schema 422;draft 作为目标由服务层跃迁矩阵恒拒。
3. 规则路由以 rule_key 为路径参数(任务文档写 /rules/{id},但 RuleService
全程以 rule_key 寻址,无按数字 id 的接口,以代码为准)。
4. 发布编排触发面含 archived(撤稿):依据 retrieval.invalidate_cache 的
自身契约「发布/归档知识后调用」,比文档字面的「发布成功后」多了撤稿
方向;reviewing 等其余状态不触发。
5. updated_by/author 记 "admin-console":V1 共享令牌无操作者身份,字段
留渠道来源;运营账号体系属 V2(见包 docstring 的升级路径)。
82 lines
3.0 KiB
Python
82 lines
3.0 KiB
Python
"""admin 应用工厂(T-P2-09):极简运营后台的装配层。
|
||
|
||
与客户端 API 的装配差异只有三点:路由前缀 ``/admin/api/v1``(区分流量)、
|
||
鉴权换成 ``X-Admin-Token``、**空令牌时一个业务路由都不注册**——「未部署」
|
||
的语义用装配期决定,而不是每个请求里判空(404 更干净,扫描器也探不到面)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import AsyncIterator
|
||
from contextlib import asynccontextmanager
|
||
|
||
from fastapi import FastAPI
|
||
|
||
from apps.admin.routers import assets, gaps, kb, rules
|
||
from packages.shared.config import Settings, get_settings
|
||
from packages.shared.db import dispose_engine, get_session_factory
|
||
from packages.shared.handlers import install_exception_handlers
|
||
from packages.shared.logging import configure_logging, get_logger
|
||
from packages.shared.middleware import RequestContextMiddleware
|
||
from packages.shared.observability import setup_tracing
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
#: 与客户端 API(/api/v1)区分的固定前缀;不提供 /v1 别名(后台没有历史包袱)。
|
||
ADMIN_API_PREFIX = "/admin/api/v1"
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||
settings: Settings = app.state.admin_settings
|
||
# 启动即触碰共享 engine(懒加载单例,复用 packages.shared.db):
|
||
# 连接配置有问题应该在部署的那一刻暴露,而不是等顾问点第一个按钮。
|
||
get_session_factory()
|
||
logger.info(
|
||
"admin_started",
|
||
env=settings.app_env,
|
||
routes_registered=bool(settings.admin_token),
|
||
)
|
||
try:
|
||
yield
|
||
finally:
|
||
await dispose_engine()
|
||
logger.info("admin_stopped")
|
||
|
||
|
||
def create_admin_app(settings: Settings | None = None) -> FastAPI:
|
||
cfg = settings or get_settings()
|
||
configure_logging(
|
||
level=cfg.log_level, json_output=cfg.log_json, extra_redact_keys=cfg.extra_redact_keys
|
||
)
|
||
|
||
# 空令牌 = 等价未部署:业务路由、openapi、docs 全部不装配。
|
||
deployed = bool(cfg.admin_token)
|
||
app = FastAPI(
|
||
title="美搭运营后台",
|
||
version="0.1.0",
|
||
description=(
|
||
"极简运营后台:知识实体/规则维护、素材登记审核、发布编排与缺口看板"
|
||
"(T-P2-09)。鉴权:请求头 X-Admin-Token。"
|
||
),
|
||
lifespan=lifespan,
|
||
openapi_url=f"{ADMIN_API_PREFIX}/openapi.json" if deployed else None,
|
||
docs_url=f"{ADMIN_API_PREFIX}/docs" if deployed and cfg.is_local else None,
|
||
redoc_url=None,
|
||
)
|
||
app.state.admin_settings = cfg
|
||
|
||
app.add_middleware(RequestContextMiddleware)
|
||
install_exception_handlers(app)
|
||
setup_tracing(app, cfg)
|
||
|
||
if deployed:
|
||
for router in (kb.router, rules.router, assets.router, gaps.router):
|
||
app.include_router(router, prefix=ADMIN_API_PREFIX)
|
||
else:
|
||
logger.warning(
|
||
"admin_routes_disabled",
|
||
reason="ADMIN_TOKEN 为空:全部 admin 端点不注册(等价未部署)",
|
||
)
|
||
return app
|