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 的升级路径)。
83 lines
3.2 KiB
Python
83 lines
3.2 KiB
Python
"""规则路由(FR-ADM-02):决策表维护 + 发布/下线状态机。
|
||
|
||
规则以 ``rule_key``(命中链锚点)为地址,不是数字主键——路径参数即 rule_key。
|
||
HTTP 载荷直接复用 ``packages.rules.dsl.Rule``(封闭词汇表在入口就拦住 typo
|
||
键名),service 层落库前还会再校验一遍。发布与下线都走缓存失效编排:规则
|
||
既影响建议输出(规则缓存),也影响检索加权(rule_boost)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Depends, Query
|
||
|
||
from apps.admin.deps import RuleServiceDep, admin_auth
|
||
from apps.admin.publishing import invalidate_downstream_caches
|
||
from apps.admin.schemas import RuleOut, RuleTransitionRequest, RuleUpsert
|
||
from packages.shared.models import KbRule
|
||
from packages.shared.response import ok
|
||
|
||
router = APIRouter(prefix="/rules", tags=["admin:rules"], dependencies=[Depends(admin_auth)])
|
||
|
||
#: V1 共享令牌鉴权没有操作者身份,author 记渠道来源。
|
||
ADMIN_OPERATOR = "admin-console"
|
||
|
||
|
||
def _serialize(row: KbRule) -> dict[str, Any]:
|
||
return RuleOut(
|
||
rule_id=str(row.rule_id),
|
||
rule_key=row.rule_key,
|
||
category=row.category,
|
||
version=row.version,
|
||
status=row.status,
|
||
priority=row.priority,
|
||
conditions=dict(row.conditions),
|
||
effects=dict(row.effects),
|
||
is_hard=row.is_hard,
|
||
confidence=float(row.confidence),
|
||
evidence=row.evidence,
|
||
author=row.author,
|
||
reviewer=row.reviewer,
|
||
published_at=row.published_at,
|
||
created_at=row.created_at,
|
||
).model_dump()
|
||
|
||
|
||
@router.get("", summary="规则列表(可选 status 过滤)")
|
||
async def list_rules(
|
||
rules: RuleServiceDep,
|
||
status: str | None = Query(default=None, description="draft / published / archived"),
|
||
) -> dict[str, Any]:
|
||
rows = await rules.list(status)
|
||
return ok({"items": [_serialize(row) for row in rows], "total": len(rows)})
|
||
|
||
|
||
@router.post("", summary="新建规则(draft / version=1 起步,DSL 先校验后落库)")
|
||
async def create_rule(payload: RuleUpsert, rules: RuleServiceDep) -> dict[str, Any]:
|
||
row = await rules.create(payload.model_dump(), author=ADMIN_OPERATOR)
|
||
return ok(_serialize(row))
|
||
|
||
|
||
@router.patch("/{rule_key}", summary="修改规则内容(仅 draft;rule_key 不可改)")
|
||
async def update_rule(rule_key: str, payload: RuleUpsert, rules: RuleServiceDep) -> dict[str, Any]:
|
||
row = await rules.update(rule_key, payload.model_dump())
|
||
return ok(_serialize(row))
|
||
|
||
|
||
@router.post(
|
||
"/{rule_key}/transition",
|
||
summary="发布 / 下线规则(均触发规则缓存与检索缓存的失效编排)",
|
||
)
|
||
async def transition_rule(
|
||
rule_key: str, payload: RuleTransitionRequest, rules: RuleServiceDep
|
||
) -> dict[str, Any]:
|
||
# service 的 publish/archive 自带非法跃迁校验(40900)与规则缓存失效;
|
||
# 检索缓存的联动在这里补上(跨域编排属应用层职责,见 publishing 模块)。
|
||
if payload.target == "published":
|
||
row = await rules.publish(rule_key)
|
||
else:
|
||
row = await rules.archive(rule_key)
|
||
invalidate_downstream_caches(domain="kb_rule", key=rule_key, target=payload.target)
|
||
return ok(_serialize(row))
|