"""kb 域 service:实体生命周期、关系写入、图谱遍历、自建色板(FR-KB-01/02/05). 状态机与格式校验拆成模块级纯函数(不碰 DB,单测直接覆盖矩阵); ``KbService`` 只负责组装、落库与跨表编排。所有写操作只 ``flush`` 不 ``commit``——事务边界交给 ``session_dep`` 的统一提交/回滚,与 task / billing 域一致。 """ from __future__ import annotations import re from datetime import UTC, datetime from decimal import Decimal from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession 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, GraphHop, GraphNode, GraphPath, GraphQueryResult, RelationCreate, ) from packages.shared.errors import AppError, ErrorCode, NotFoundError from packages.shared.logging import get_logger from packages.shared.models import KbAsset, KbColor, KbEntity, KbRelation logger = get_logger(__name__) #: 合法跃迁矩阵(知识库文档「发布回滚」语义): #: draft → reviewing → published → archived 主线; #: draft/reviewing → archived 撤稿支线; #: archived 终态。其余(含 published→draft、reviewing→draft、自跃迁) #: 一律非法——想改已发布内容,就撤稿归档后以新草稿重录,让新版本重走评审。 LEGAL_TRANSITIONS: frozenset[tuple[str, str]] = frozenset( { ("draft", "reviewing"), ("reviewing", "published"), ("published", "archived"), ("draft", "archived"), ("reviewing", "archived"), } ) #: 内容可编辑的状态。published 内容只读(版本语义的基石),archived 终态。 CONTENT_EDITABLE_STATUSES: frozenset[str] = frozenset({"draft", "reviewing"}) _HEX_COLOR_PATTERN = re.compile(r"^#[0-9A-Fa-f]{6}$") _COLOR_ROLES: frozenset[str] = frozenset({"base", "accent", "avoid"}) # --- 纯校验函数(单测直接覆盖,不触碰 DB) ---------------------------------- def can_transition(current: str, target: str) -> bool: return (current, target) in LEGAL_TRANSITIONS def assert_transition(current: str, target: str) -> None: """非法跃迁 → ``STATE_CONFLICT``(409)。矩阵见 :data:`LEGAL_TRANSITIONS`。""" if not can_transition(current, target): raise AppError( ErrorCode.STATE_CONFLICT, message="实体状态不允许该跃迁", data={"current": current, "target": target}, ) def ensure_content_editable(status: str) -> None: """published/archived 的内容不可直接修改(version 递增的前提)。""" if status not in CONTENT_EDITABLE_STATUSES: raise AppError( ErrorCode.STATE_CONFLICT, message=f"{status} 状态的实体内容不可修改", hint="已发布内容要改:先撤稿归档,再以新草稿重录,新版本重走评审", data={"status": status}, ) def ensure_valid_hex(hex_code: str) -> None: """色板 hex 必须 ``#RRGGBB``(大小写不敏感,入库前不归一化,展示原样保留)。""" if _HEX_COLOR_PATTERN.fullmatch(hex_code) is None: raise AppError( ErrorCode.BAD_REQUEST, message="色板 hex 格式非法,应为 #RRGGBB", data={"hex": hex_code}, ) def ensure_valid_color_role(role: str) -> None: if role not in _COLOR_ROLES: raise AppError( ErrorCode.BAD_REQUEST, message="色板 role 非法,应为 base/accent/avoid", data={"role": role}, ) def ensure_valid_hops(max_hops: int) -> None: """遍历深度 1~3(TD-09 有界遍历;上限同时也是防环的保险丝)。""" if not 1 <= max_hops <= MAX_GRAPH_HOPS: raise AppError( ErrorCode.BAD_REQUEST, message=f"max_hops 超出允许范围 1~{MAX_GRAPH_HOPS}", data={"max_hops": max_hops}, ) # --- service ----------------------------------------------------------------- class KbService: def __init__(self, session: AsyncSession) -> None: self._session = session # -- 实体 ----------------------------------------------------------------- async def create_entity( self, *, payload: EntityCreate, updated_by: str | None = None ) -> KbEntity: """新建实体:固定 draft / version=1 起步。同名同类目拒绝(400)。""" existing = await repo.find_entity_by_name( self._session, entity_type=payload.entity_type, name=payload.name ) if existing is not None: raise AppError( ErrorCode.BAD_REQUEST, message="同名同类目的实体已存在", data={"entity_type": payload.entity_type, "name": payload.name}, ) entity = repo.add_entity( self._session, KbEntity( entity_id=next_id(), entity_type=payload.entity_type, name=payload.name, attrs=payload.attrs, status="draft", version=1, source=payload.source, license=payload.license, updated_by=updated_by, ), ) try: await self._session.flush() except IntegrityError as exc: # 并发同名是唯一预期内的约束冲突(UNIQUE(entity_type,name) 兜底) raise AppError( ErrorCode.BAD_REQUEST, message="同名同类目的实体已存在", data={"entity_type": payload.entity_type, "name": payload.name}, ) from exc logger.info( "kb_entity_created", entity_id=entity.entity_id, entity_type=entity.entity_type, name=entity.name, ) return entity async def get_entity(self, *, entity_id: int) -> KbEntity: entity = await repo.get_entity(self._session, entity_id) if entity is None: raise NotFoundError("实体不存在") return entity async def search_entities( self, *, entity_type: str | None = None, status: str | None = None, name_ilike: str | None = None, limit: int = 20, offset: int = 0, ) -> tuple[list[KbEntity], int]: return await repo.search_entities( self._session, entity_type=entity_type, status=status, name_ilike=name_ilike, limit=limit, offset=offset, ) async def update_entity( self, *, entity_id: int, payload: EntityUpdate, updated_by: str | None = None ) -> KbEntity: """内容修改与状态跃迁共用一个 PATCH 入口,规则: - 内容字段仅 draft/reviewing 可改,每次内容修改 version+1; - 状态变化必须走合法跃迁矩阵;与当前相同的 status 是幂等 no-op; - 先全部校验、后统一变更——半个请求不会落库。 """ entity = await repo.get_entity_for_update(self._session, entity_id) if entity is None: raise NotFoundError("实体不存在") content = payload.content_fields() if content: ensure_content_editable(entity.status) if payload.status is not None and payload.status != entity.status: assert_transition(entity.status, payload.status) if content: for field, value in content.items(): setattr(entity, field, value) entity.version += 1 if payload.status is not None: entity.status = payload.status if content or payload.status is not None: entity.updated_by = updated_by or entity.updated_by entity.updated_at = datetime.now(UTC) try: await self._session.flush() except IntegrityError as exc: # 改名撞上另一条同类目实体 raise AppError( ErrorCode.BAD_REQUEST, message="同名同类目的实体已存在", data={"entity_id": str(entity_id)}, ) from exc logger.info( "kb_entity_updated", entity_id=entity_id, version=entity.version, status=entity.status, content_changed=bool(content), ) return entity # -- 关系 ----------------------------------------------------------------- async def add_relation(self, *, payload: RelationCreate) -> KbRelation: """写入三元组。自环与重复(UNIQUE 三元组)拒绝(400),端点缺失 404。""" if payload.from_entity_id == payload.to_entity_id: raise AppError( ErrorCode.BAD_REQUEST, message="不允许自环关系", data={"entity_id": str(payload.from_entity_id)}, ) for end_id in (payload.from_entity_id, payload.to_entity_id): if await repo.get_entity(self._session, end_id) is None: raise NotFoundError("关联实体不存在") duplicate = await repo.find_relation( self._session, from_id=payload.from_entity_id, rel_type=payload.rel_type, to_id=payload.to_entity_id, ) if duplicate is not None: raise AppError( ErrorCode.BAD_REQUEST, message="该 from/rel_type/to 三元组已存在", data={ "from_entity_id": str(payload.from_entity_id), "rel_type": payload.rel_type, "to_entity_id": str(payload.to_entity_id), }, ) relation = repo.add_relation( self._session, KbRelation( relation_id=next_id(), from_id=payload.from_entity_id, rel_type=payload.rel_type, to_id=payload.to_entity_id, weight=payload.weight, confidence=payload.confidence, evidence=payload.evidence, ), ) await self._session.flush() logger.info( "kb_relation_added", from_id=payload.from_entity_id, rel_type=payload.rel_type, to_id=payload.to_entity_id, ) return relation # -- 图谱 ----------------------------------------------------------------- async def traverse_graph( self, *, entity_id: int, max_hops: int = MAX_GRAPH_HOPS ) -> GraphQueryResult: """1~3 跳可解释路径(T-P2-05)。 CTE 只回 id 与 rel_type 序列;节点摘要(类型/名称/状态)由第二次 查询补齐——遍历有界,涉及的实体集合很小,两次往返远比在递归 SQL 里 JOIN 实体表好维护。 """ ensure_valid_hops(max_hops) await self.get_entity(entity_id=entity_id) # 起点 404 语义保持一致 raw_paths = await repo.traverse(self._session, from_id=entity_id, max_hops=max_hops) node_ids: set[int] = set() for raw in raw_paths: node_ids.update(raw.node_ids) nodes_map = await repo.get_entities_by_ids(self._session, node_ids) paths: list[GraphPath] = [] for raw in raw_paths: nodes = [_node_summary(nodes_map, node_id) for node_id in raw.node_ids] hops = [ GraphHop( from_entity_id=str(hop_from), rel_type=rel_type, to_entity_id=str(hop_to), ) # node_ids 比 rel_types 多一个元素(n 个节点 n-1 跳),首尾 # 错位 zip 即得「第 i 跳 = nodes[i] --rels[i]--> nodes[i+1]」 for hop_from, rel_type, hop_to in zip( raw.node_ids[:-1], raw.rel_types, raw.node_ids[1:], strict=True ) ] paths.append(GraphPath(nodes=nodes, hops=hops)) return GraphQueryResult(from_entity_id=str(entity_id), max_hops=max_hops, paths=paths) # -- 色板(TD-18 自建色板) ------------------------------------------------ async def add_color( self, *, season_code: str, role: str, lab_l: float, lab_a: float, lab_b: float, hex_code: str, name_cn: str, ) -> ColorOut: """录入色板条目。role/hex 校验在 service 层(运营后台 T-P2-09 直接复用)。""" ensure_valid_color_role(role) ensure_valid_hex(hex_code) color = repo.add_color( self._session, KbColor( color_id=next_id(), season_code=season_code, role=role, lab_l=Decimal(str(lab_l)), lab_a=Decimal(str(lab_a)), lab_b=Decimal(str(lab_b)), hex=hex_code, name_cn=name_cn, ), ) await self._session.flush() logger.info("kb_color_added", season_code=season_code, role=role, hex=hex_code) return _color_out(color) async def list_colors(self, *, season_code: str | None = None) -> list[ColorOut]: rows = await repo.list_colors(self._session, season_code=season_code) return [_color_out(row) for row in rows] def _node_summary(nodes_map: dict[int, KbEntity], node_id: int) -> GraphNode: """路径节点摘要。关系端点在写入时校验过存在,理论必中;万一实体行 被运维手工删除,退化为占位摘要而不是让整个图查询 500。""" entity = nodes_map.get(node_id) if entity is None: logger.warning("kb_graph_node_missing", entity_id=node_id) return GraphNode( entity_id=str(node_id), entity_type="unknown", name="unknown", status="unknown" ) return GraphNode( entity_id=str(entity.entity_id), entity_type=entity.entity_type, name=entity.name, status=entity.status, ) def _color_out(color: KbColor) -> ColorOut: return ColorOut( color_id=str(color.color_id), season_code=color.season_code, role=color.role, lab_l=float(color.lab_l), lab_a=float(color.lab_a), lab_b=float(color.lab_b), 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, )