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 的升级路径)。
91 lines
3.4 KiB
Python
91 lines
3.4 KiB
Python
"""admin 应用的依赖装配:鉴权 + session + 各域 service。
|
||
|
||
**不 import ``apps.api`` 的任何东西**(import-linter:apps 之间互不依赖)——
|
||
session 依赖在这里按同样语义自建一份;``ok``/``AppError`` 等统一信封件来自
|
||
``packages.shared``,是允许的共享层。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hmac
|
||
from collections.abc import AsyncIterator
|
||
from typing import Annotated
|
||
|
||
from fastapi import Depends, Request
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from packages.kb.service import KbAssetService, KbService
|
||
from packages.rules.service import RuleService
|
||
from packages.shared.config import Settings
|
||
from packages.shared.db import get_session_factory
|
||
from packages.shared.errors import AppError, ErrorCode
|
||
|
||
#: 鉴权请求头。共享令牌方案(见包 docstring 的 V1 边界说明)。
|
||
ADMIN_TOKEN_HEADER = "X-Admin-Token"
|
||
|
||
|
||
def admin_settings(request: Request) -> Settings:
|
||
"""取应用工厂装配时挂上的 settings。
|
||
|
||
鉴权口径必须与应用装配口径同源:工厂收到的 settings 决定路由是否注册,
|
||
也必须决定令牌是否匹配——两处各读一遍 ``get_settings()`` 会给「换令牌
|
||
重启了一半」的窗口留缝。
|
||
"""
|
||
settings: Settings = request.app.state.admin_settings
|
||
return settings
|
||
|
||
|
||
async def admin_auth(request: Request) -> None:
|
||
"""``X-Admin-Token`` 精确匹配校验,失败 → 40100(UNAUTHENTICATED)。
|
||
|
||
用 ``hmac.compare_digest`` 而非 ``==``:令牌比较不该泄露「对到了第几位」。
|
||
先 encode 成 bytes 再比较——str 版本的 compare_digest 遇到非 ASCII 头会
|
||
直接 TypeError 变 500,那等于给攻击者一个免费的服务端异常。
|
||
"""
|
||
settings = admin_settings(request)
|
||
expected = settings.admin_token.encode()
|
||
if not expected:
|
||
# 正常到不了这里(空令牌时路由根本没注册);留作纵深防御:
|
||
# 万一未来有人在别的 app 里复用这个依赖,也不许空配置放行。
|
||
raise AppError(ErrorCode.UNAUTHENTICATED, message="后台未配置访问令牌")
|
||
provided = request.headers.get(ADMIN_TOKEN_HEADER, "").encode()
|
||
if not hmac.compare_digest(provided, expected):
|
||
raise AppError(
|
||
ErrorCode.UNAUTHENTICATED,
|
||
message="管理令牌缺失或不正确",
|
||
hint=f"请在请求头携带 {ADMIN_TOKEN_HEADER}",
|
||
)
|
||
|
||
|
||
async def session_dep() -> AsyncIterator[AsyncSession]:
|
||
"""请求级事务作用域:成功统一 commit、异常统一 rollback。
|
||
|
||
与 ``apps.api`` 的同名依赖语义一致(包内自建,不跨 app import)。
|
||
"""
|
||
factory = get_session_factory()
|
||
async with factory() as session:
|
||
try:
|
||
yield session
|
||
await session.commit()
|
||
except Exception:
|
||
await session.rollback()
|
||
raise
|
||
|
||
|
||
def kb_service_dep(session: SessionDep) -> KbService:
|
||
return KbService(session)
|
||
|
||
|
||
def kb_asset_service_dep(session: SessionDep) -> KbAssetService:
|
||
return KbAssetService(session)
|
||
|
||
|
||
def rule_service_dep(session: SessionDep) -> RuleService:
|
||
return RuleService(session)
|
||
|
||
|
||
SessionDep = Annotated[AsyncSession, Depends(session_dep)]
|
||
KbServiceDep = Annotated[KbService, Depends(kb_service_dep)]
|
||
KbAssetServiceDep = Annotated[KbAssetService, Depends(kb_asset_service_dep)]
|
||
RuleServiceDep = Annotated[RuleService, Depends(rule_service_dep)]
|