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 的升级路径)。
335 lines
12 KiB
Python
335 lines
12 KiB
Python
"""极简运营后台集成用例(真实 PostgreSQL,T-P2-09 / FR-ADM-01~05)。
|
||
|
||
核心断言是「顾问全流程不碰客户端 API」:建实体 → 评审 → 发布 → 建规则 →
|
||
发布,全部只走 ``/admin/api/v1``;发布编排的副作用(规则缓存失效 +
|
||
检索缓存失效版本号递增)直接可观察。其余覆盖:非法跃迁 40900、素材登记
|
||
审核约束、缺口看板排序、令牌校验。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import uuid
|
||
from collections.abc import AsyncIterator
|
||
from contextlib import asynccontextmanager
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
ADMIN_TOKEN = "it-admin-token-t12"
|
||
AUTH = {"X-Admin-Token": ADMIN_TOKEN}
|
||
|
||
|
||
def _infra_available() -> bool:
|
||
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
|
||
|
||
|
||
pytestmark = [
|
||
pytest.mark.integration,
|
||
pytest.mark.skipif(
|
||
not _infra_available(),
|
||
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
|
||
),
|
||
]
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
async def _dispose_engine_after_test() -> AsyncIterator[None]:
|
||
"""用例结束即释放全局引擎(理由同 test_kb_entity.py:每用例独立事件循环)。"""
|
||
yield
|
||
from packages.shared.db import dispose_engine
|
||
|
||
await dispose_engine()
|
||
|
||
|
||
@pytest.fixture
|
||
async def client() -> Any:
|
||
import httpx
|
||
|
||
from apps.admin.app import create_admin_app
|
||
from packages.shared.config import Settings
|
||
|
||
# 显式给非空令牌(.env 默认为空 = 未部署语义,那一条在单测覆盖)
|
||
app = create_admin_app(Settings(admin_token=ADMIN_TOKEN))
|
||
transport = httpx.ASGITransport(app=app)
|
||
async with (
|
||
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
|
||
app.router.lifespan_context(app),
|
||
):
|
||
yield http
|
||
|
||
|
||
@asynccontextmanager
|
||
async def _db() -> AsyncIterator[Any]:
|
||
"""直连 session(只读断言与 kb_gap 预插数据),复用全局懒加载工厂。"""
|
||
from packages.shared.db import get_session_factory
|
||
|
||
async with get_session_factory()() as session:
|
||
yield session
|
||
|
||
|
||
def _rule_payload(rule_key: str) -> dict[str, Any]:
|
||
return {
|
||
"rule_key": rule_key,
|
||
"category": "body_shape",
|
||
"priority": 90,
|
||
"confidence": 0.9,
|
||
"conditions": {"body_shape": "A型"},
|
||
"effects": {
|
||
"recommend": [{"slot": "top", "attrs": {"shoulder": ["T12垫肩"]}}],
|
||
"explanation": "T12 后台发布编排集成规则",
|
||
},
|
||
}
|
||
|
||
|
||
async def _create_published_entity(client: Any, suffix: str) -> str:
|
||
"""经后台 HTTP 走完整评审线:draft → reviewing → published。"""
|
||
created = await client.post(
|
||
"/admin/api/v1/kb/items",
|
||
json={"entity_type": "item", "name": f"风衣-{suffix}", "attrs": {"length": "long"}},
|
||
headers=AUTH,
|
||
)
|
||
assert created.status_code == 200
|
||
entity_id = created.json()["data"]["entity_id"]
|
||
reviewing = await client.post(
|
||
f"/admin/api/v1/kb/items/{entity_id}/transition", json={"target": "reviewing"}, headers=AUTH
|
||
)
|
||
assert reviewing.status_code == 200
|
||
published = await client.post(
|
||
f"/admin/api/v1/kb/items/{entity_id}/transition", json={"target": "published"}, headers=AUTH
|
||
)
|
||
assert published.status_code == 200
|
||
return entity_id
|
||
|
||
|
||
async def test_consultant_full_flow_publishes_without_engineering(client: Any) -> None:
|
||
"""顾问全流程只打后台 API;发布编排的两个失效副作用都可观察。"""
|
||
from packages.retrieval import repo as retrieval_repo
|
||
from packages.rules import service as rules_service
|
||
from packages.shared.models import KbEntity
|
||
|
||
suffix = uuid.uuid4().hex[:8]
|
||
rule_key = f"T12-IT-{suffix}"
|
||
|
||
# -- 建实体(draft 起步,操作者记为 admin-console)-----------------------
|
||
created = await client.post(
|
||
"/admin/api/v1/kb/items",
|
||
json={
|
||
"entity_type": "item",
|
||
"name": f"西装外套-{suffix}",
|
||
"attrs": {"lapel": "peak"},
|
||
"source": "搭配手册",
|
||
},
|
||
headers=AUTH,
|
||
)
|
||
assert created.status_code == 200
|
||
entity = created.json()["data"]
|
||
entity_id = entity["entity_id"]
|
||
assert entity["status"] == "draft"
|
||
assert entity["version"] == 1
|
||
assert entity["updated_by"] == "admin-console"
|
||
|
||
# -- reviewing:不是知识可见性变更,不触发失效编排 -----------------------
|
||
v0 = retrieval_repo.INVALIDATION_VERSION
|
||
reviewing = await client.post(
|
||
f"/admin/api/v1/kb/items/{entity_id}/transition",
|
||
json={"target": "reviewing"},
|
||
headers=AUTH,
|
||
)
|
||
assert reviewing.status_code == 200
|
||
assert reviewing.json()["data"]["status"] == "reviewing"
|
||
assert v0 == retrieval_repo.INVALIDATION_VERSION
|
||
|
||
# -- published:双失效(规则缓存 + 检索缓存)的副作用可观察 --------------
|
||
v1 = retrieval_repo.INVALIDATION_VERSION
|
||
published = await client.post(
|
||
f"/admin/api/v1/kb/items/{entity_id}/transition",
|
||
json={"target": "published"},
|
||
headers=AUTH,
|
||
)
|
||
assert published.status_code == 200
|
||
assert published.json()["data"]["status"] == "published"
|
||
assert v1 + 1 == retrieval_repo.INVALIDATION_VERSION
|
||
|
||
# 「检索新内容可见」的简化断言:直接再查库
|
||
async with _db() as session:
|
||
row = await session.get(KbEntity, int(entity_id))
|
||
assert row is not None
|
||
assert row.status == "published"
|
||
|
||
# -- 建规则(draft)→ 发布 → PublishedRules 缓存能载入 -------------------
|
||
rule_created = await client.post(
|
||
"/admin/api/v1/rules", json=_rule_payload(rule_key), headers=AUTH
|
||
)
|
||
assert rule_created.status_code == 200
|
||
rule_body = rule_created.json()["data"]
|
||
assert rule_body["status"] == "draft"
|
||
assert rule_body["author"] == "admin-console"
|
||
|
||
# 预热进程内缓存:发布前不可见
|
||
async with _db() as session:
|
||
pre = await rules_service._published_rules.load(session)
|
||
assert rule_key not in {r.rule_key for r in pre}
|
||
|
||
v2 = retrieval_repo.INVALIDATION_VERSION
|
||
rule_published = await client.post(
|
||
f"/admin/api/v1/rules/{rule_key}/transition",
|
||
json={"target": "published"},
|
||
headers=AUTH,
|
||
)
|
||
assert rule_published.status_code == 200
|
||
assert rule_published.json()["data"]["published_at"] is not None
|
||
assert v2 + 1 == retrieval_repo.INVALIDATION_VERSION
|
||
|
||
# 发布 + 失效后重载:新规则可被 PublishedRules 载入
|
||
async with _db() as session:
|
||
post = await rules_service._published_rules.load(session)
|
||
assert rule_key in {r.rule_key for r in post}
|
||
|
||
# -- 撤稿规则(下线同样触发失效编排),给环境留干净状态 -------------------
|
||
v3 = retrieval_repo.INVALIDATION_VERSION
|
||
archived = await client.post(
|
||
f"/admin/api/v1/rules/{rule_key}/transition",
|
||
json={"target": "archived"},
|
||
headers=AUTH,
|
||
)
|
||
assert archived.status_code == 200
|
||
assert archived.json()["data"]["status"] == "archived"
|
||
assert v3 + 1 == retrieval_repo.INVALIDATION_VERSION
|
||
async with _db() as session:
|
||
after = await rules_service._published_rules.load(session)
|
||
assert rule_key not in {r.rule_key for r in after}
|
||
|
||
|
||
async def test_illegal_transitions_surface_as_40900(client: Any) -> None:
|
||
suffix = uuid.uuid4().hex[:8]
|
||
entity_id = await _create_published_entity(client, suffix)
|
||
|
||
# published → draft:状态机语义冲突 40900(而不是 schema 422)
|
||
backward = await client.post(
|
||
f"/admin/api/v1/kb/items/{entity_id}/transition",
|
||
json={"target": "draft"},
|
||
headers=AUTH,
|
||
)
|
||
assert backward.status_code == 409
|
||
assert backward.json()["code"] == 40900
|
||
|
||
# 规则侧:published 再发布(自跃迁)同样 40900
|
||
rule_key = f"T12-SM-{suffix}"
|
||
created = await client.post("/admin/api/v1/rules", json=_rule_payload(rule_key), headers=AUTH)
|
||
assert created.status_code == 200
|
||
published = await client.post(
|
||
f"/admin/api/v1/rules/{rule_key}/transition",
|
||
json={"target": "published"},
|
||
headers=AUTH,
|
||
)
|
||
assert published.status_code == 200
|
||
republish = await client.post(
|
||
f"/admin/api/v1/rules/{rule_key}/transition",
|
||
json={"target": "published"},
|
||
headers=AUTH,
|
||
)
|
||
assert republish.status_code == 409
|
||
assert republish.json()["code"] == 40900
|
||
|
||
# 收尾下线,不留 published 规则影响其他用例
|
||
cleanup = await client.post(
|
||
f"/admin/api/v1/rules/{rule_key}/transition",
|
||
json={"target": "archived"},
|
||
headers=AUTH,
|
||
)
|
||
assert cleanup.status_code == 200
|
||
|
||
|
||
async def test_asset_register_review_and_tryon_ready_gate(client: Any) -> None:
|
||
suffix = uuid.uuid4().hex[:8]
|
||
entity_id = await _create_published_entity(client, suffix)
|
||
|
||
# 登记时即使请求体给 tryon_ready=true,服务端也强制 False(审核是唯一开关)
|
||
registered = await client.post(
|
||
"/admin/api/v1/assets",
|
||
json={
|
||
"entity_id": entity_id,
|
||
"asset_type": "display",
|
||
"cos_key": f"kb/it/t12/{suffix}/coat.jpg",
|
||
"spec_check": {"bg": "white", "size": "1024x1536"},
|
||
"tryon_ready": True,
|
||
"license": "CC-BY-4.0",
|
||
},
|
||
headers=AUTH,
|
||
)
|
||
assert registered.status_code == 200
|
||
asset = registered.json()["data"]
|
||
asset_id = asset["asset_id"]
|
||
assert asset["tryon_ready"] is False
|
||
assert asset["review_status"] == "pending"
|
||
|
||
# 审核不通过却想打开 tryon_ready → 服务层约束透传成 400
|
||
rejected = await client.post(
|
||
f"/admin/api/v1/assets/{asset_id}/review",
|
||
json={"review_status": "rejected", "tryon_ready": True},
|
||
headers=AUTH,
|
||
)
|
||
assert rejected.status_code == 400
|
||
assert rejected.json()["code"] == 40000
|
||
|
||
# 审核通过 + tryon_ready 一起打开
|
||
approved = await client.post(
|
||
f"/admin/api/v1/assets/{asset_id}/review",
|
||
json={"review_status": "approved", "tryon_ready": True},
|
||
headers=AUTH,
|
||
)
|
||
assert approved.status_code == 200
|
||
assert approved.json()["data"]["tryon_ready"] is True
|
||
assert approved.json()["data"]["review_status"] == "approved"
|
||
|
||
# 列表过滤:tryon_ready=true 命中,false 视图为空
|
||
ready = await client.get(
|
||
f"/admin/api/v1/assets?entity_id={entity_id}&tryon_ready=true", headers=AUTH
|
||
)
|
||
assert ready.status_code == 200
|
||
ready_items = ready.json()["data"]["items"]
|
||
assert [item["asset_id"] for item in ready_items] == [asset_id]
|
||
not_ready = await client.get(
|
||
f"/admin/api/v1/assets?entity_id={entity_id}&tryon_ready=false", headers=AUTH
|
||
)
|
||
assert not_ready.json()["data"]["total"] == 0
|
||
|
||
|
||
async def test_gap_dashboard_orders_by_hit_count_desc(client: Any) -> None:
|
||
from packages.domain.ids import next_id
|
||
from packages.shared.models import KbGap
|
||
|
||
suffix = uuid.uuid4().hex[:8]
|
||
low, high = f"T12-缺口低频-{suffix}", f"T12-缺口高频-{suffix}"
|
||
async with _db() as session:
|
||
session.add(KbGap(gap_id=next_id(), query_text=low, filter_ctx={}, hit_count=2))
|
||
session.add(
|
||
KbGap(
|
||
gap_id=next_id(),
|
||
query_text=high,
|
||
filter_ctx={"body_shape": "A型"},
|
||
hit_count=51,
|
||
)
|
||
)
|
||
await session.commit()
|
||
|
||
resp = await client.get("/admin/api/v1/kb/gaps?limit=200", headers=AUTH)
|
||
assert resp.status_code == 200
|
||
items = resp.json()["data"]["items"]
|
||
texts = [item["query_text"] for item in items]
|
||
assert low in texts and high in texts
|
||
# hit_count 降序:高频缺口的位次必须靠前(历史数据可以穿插在中间)
|
||
assert texts.index(high) < texts.index(low)
|
||
high_row = next(item for item in items if item["query_text"] == high)
|
||
assert high_row["hit_count"] == 51
|
||
assert high_row["filter_ctx"] == {"body_shape": "A型"}
|
||
assert high_row["gap_id"].isdigit() # 雪花 ID 以字符串出现
|
||
|
||
|
||
async def test_missing_or_wrong_token_is_401(client: Any) -> None:
|
||
for headers in (None, {"X-Admin-Token": "wrong"}):
|
||
resp = await client.get("/admin/api/v1/rules", headers=headers)
|
||
assert resp.status_code == 401
|
||
assert resp.json()["code"] == 40100
|