chore(prep): shared groundwork for batch-3 (admin/golden/cache)

第三批(T12 后台/T13 黄金集/T14 检索缓存)协调提交:
- kb:KbAssetService(登记/审核,tryon_ready 只能随 approved 打开)+ Asset 模型与 repo
- retrieval:list_gaps 缺口看板查询 + invalidate_cache() 钩子(T14 背后实现真缓存)
- 配置:ADMIN_TOKEN(空=后台 404)+ 检索缓存三参数
- pytest golden marker(默认跳过,CI 强制跑)+ Makefile golden + CI 防线 5c
- 测试隔离修复:test_auth_sms 加 autouse 清 sms:* 键夹具(频控计数跨运行
  累积会毒化后续运行——随机 IP 空间仅 254 且部分用例固定 device_id)
This commit is contained in:
xsl
2026-08-14 20:57:52 +08:00
parent 8ec8d2cefb
commit 9c1802130d
11 changed files with 236 additions and 5 deletions
+37 -1
View File
@@ -15,7 +15,7 @@ from dataclasses import dataclass
from sqlalchemy import ColumnElement, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from packages.shared.models import KbColor, KbEntity, KbRelation
from packages.shared.models import KbAsset, KbColor, KbEntity, KbRelation
#: 递归遍历 SQL(1~3 跳)。``path`` 记录已经过的实体 id,``rels`` 与逐跳
#: rel_type 平行——每行结果本身就是一条完整可解释路径,不需要在 Python
@@ -166,3 +166,39 @@ async def list_colors(session: AsyncSession, *, season_code: str | None = None)
def add_color(session: AsyncSession, color: KbColor) -> KbColor:
session.add(color)
return color
async def get_asset(session: AsyncSession, asset_id: int) -> KbAsset | None:
return await session.get(KbAsset, asset_id)
async def list_assets(
session: AsyncSession,
*,
entity_id: int | None = None,
tryon_ready: bool | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[KbAsset], int]:
"""素材列表(含总数)。tryon_ready 过滤走 partial 索引。"""
from sqlalchemy import func, select
conditions = []
if entity_id is not None:
conditions.append(KbAsset.entity_id == entity_id)
if tryon_ready is not None:
conditions.append(KbAsset.tryon_ready == tryon_ready)
base = select(KbAsset)
if conditions:
base = base.where(*conditions)
total = (await session.execute(select(func.count()).select_from(base.subquery()))).scalar_one()
rows = (
await session.execute(base.order_by(KbAsset.asset_id.desc()).limit(limit).offset(offset))
).scalars()
return list(rows), int(total)
def add_asset(session: AsyncSession, asset: KbAsset) -> KbAsset:
session.add(asset)
return asset
+32
View File
@@ -164,3 +164,35 @@ class ColorOut(BaseModel):
lab_b: float
hex: str = Field(description="#RRGGBB")
name_cn: str
class AssetCreate(BaseModel):
"""素材登记(kb_asset)。cos_key 是 COS 上的对象键(真实文件经 T5 预签名
直传后,由运营在这里登记元数据);spec_check 记录白底规格校验结果。"""
entity_id: str
asset_type: str = Field(description="display / tryon / detail 等素材类型")
cos_key: str = Field(min_length=1, max_length=255)
spec_check: dict[str, Any] = Field(default_factory=dict)
tryon_ready: bool = False
license: str | None = None
class AssetReview(BaseModel):
"""素材审核结论:review_status 与 tryon_ready 一起更新——
tryon_ready=true 是换装闭环放行的唯一开关,不允许绕过审核单独打开。"""
review_status: Literal["approved", "rejected", "pending"]
tryon_ready: bool = False
class AssetOut(BaseModel):
asset_id: str
entity_id: str
asset_type: str
cos_key: str
spec_check: dict[str, Any]
tryon_ready: bool
review_status: str
license: str | None
created_at: datetime
+62 -1
View File
@@ -19,6 +19,8 @@ from packages.domain.ids import next_id
from packages.kb import repo
from packages.kb.schemas import (
MAX_GRAPH_HOPS,
AssetCreate,
AssetReview,
ColorOut,
EntityCreate,
EntityUpdate,
@@ -30,7 +32,7 @@ from packages.kb.schemas import (
)
from packages.shared.errors import AppError, ErrorCode, NotFoundError
from packages.shared.logging import get_logger
from packages.shared.models import KbColor, KbEntity, KbRelation
from packages.shared.models import KbAsset, KbColor, KbEntity, KbRelation
logger = get_logger(__name__)
@@ -390,3 +392,62 @@ def _color_out(color: KbColor) -> ColorOut:
hex=color.hex,
name_cn=color.name_cn,
)
class KbAssetService:
"""素材登记与审核(kb_asset)。与实体/图谱分开放:素材由运营流水线
(T-P2-07/15)高频写入,审核是唯一能把 tryon_ready 打开的入口。"""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def register_asset(self, payload: AssetCreate) -> KbAsset:
entity = await repo.get_entity(self._session, int(payload.entity_id))
if entity is None:
raise AppError(ErrorCode.NOT_FOUND, message="实体不存在,先建实体再登记素材")
asset = repo.add_asset(
self._session,
KbAsset(
asset_id=next_id(),
entity_id=entity.entity_id,
asset_type=payload.asset_type,
cos_key=payload.cos_key,
spec_check=payload.spec_check,
tryon_ready=False, # 登记时恒为 False,只有审核能打开
review_status="pending",
license=payload.license,
),
)
await self._session.flush()
return asset
async def review_asset(self, asset_id: int, payload: AssetReview) -> KbAsset:
asset = await repo.get_asset(self._session, asset_id)
if asset is None:
raise AppError(ErrorCode.NOT_FOUND, message="素材不存在")
# tryon_ready 只能随 approved 一起打开:审核是唯一放行开关
if payload.tryon_ready and payload.review_status != "approved":
raise AppError(
ErrorCode.BAD_REQUEST,
message="tryon_ready 只能在审核通过时开启",
)
asset.review_status = payload.review_status
asset.tryon_ready = payload.tryon_ready
await self._session.flush()
return asset
async def list_assets(
self,
*,
entity_id: int | None = None,
tryon_ready: bool | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[KbAsset], int]:
return await repo.list_assets(
self._session,
entity_id=entity_id,
tryon_ready=tryon_ready,
limit=limit,
offset=offset,
)