Files
group_fqcd_jr/app/service/knowledge_service.py
T

163 lines
6.5 KiB
Python
Raw Normal View History

"""知识引用解析服务(05-接口文档 K001)。
token 形态:``kr1.{user_id}.{knowledge_id}.{expires_at_epoch}.{signature}``。
- 签名:HMAC-SHA256(hex)覆盖 token 前四段,防止伪造与越权拼接;
- 密钥:只从环境变量 ``KNOWLEDGE_REFERENCE_SIGNING_SECRET`` 读取,缺失时失败关闭,
不提供默认值、不硬编码;
- 解析:按 knowledge_id 只读回查 ``fin_knowledge_meta``,仍要求
``review_status='published' AND status='active'`` 且在有效期内;
- 返回:脱敏元数据(标题/版本/类型/集合/标签/有效期),**绝不返回 ``content_text`` 全文**,
避免绕过工具审计直读知识正文。
"""
import hashlib
import hmac
import json
import os
from collections.abc import Callable, Mapping
from datetime import UTC, date, datetime
2026-09-09 21:55:37 +08:00
from typing import Any
from sqlalchemy import text
2026-09-09 21:55:37 +08:00
from app.core.contracts import RequestContext
from app.core.errors import RecoverableAgentError, ReferenceNotFoundError
from app.infrastructure.db import SessionFactory
2026-09-09 21:55:37 +08:00
from app.service.authorization_service import AuthorizationService
REFERENCE_TOKEN_PREFIX = "kr1"
REFERENCE_TOKEN_PARTS = 5
SIGNING_SECRET_ENV = "KNOWLEDGE_REFERENCE_SIGNING_SECRET"
PUBLISHED_REVIEW_STATUS = "published"
ACTIVE_STATUS = "active"
_REFERENCE_SQL = text(
"""
SELECT id, knowledge_type, title, version, milvus_collection, tags,
effective_date, expire_date, review_status, status
FROM fin_knowledge_meta
WHERE id = :knowledge_id
"""
)
def signing_secret() -> str:
"""签名密钥只来自环境变量;缺失即失败关闭(可识别的配置错误)。"""
secret = os.environ.get(SIGNING_SECRET_ENV, "").strip()
if not secret:
raise RecoverableAgentError(f"知识引用签名密钥未配置:{SIGNING_SECRET_ENV}")
return secret
def _signature(secret: str, head: str) -> str:
return hmac.new(secret.encode("utf-8"), head.encode("utf-8"), hashlib.sha256).hexdigest()
def build_reference_token(
*, user_id: str, knowledge_id: int, expires_at: datetime, secret: str | None = None
) -> str:
"""签发侧入口:与解析侧共用同一算法,供引用落库时同步签发 token。
``expires_at`` 必须带时区;无时区视为 UTC 以保持与解析侧一致。
"""
resolved = secret if secret is not None else signing_secret()
moment = expires_at if expires_at.tzinfo is not None else expires_at.replace(tzinfo=UTC)
head = f"{REFERENCE_TOKEN_PREFIX}.{user_id}.{knowledge_id}.{int(moment.timestamp())}"
return f"{head}.{_signature(resolved, head)}"
def _not_found() -> ReferenceNotFoundError:
"""所有解析失败对外表现一致,不泄露失败原因。"""
return ReferenceNotFoundError("引用不存在")
def _as_date(value: object) -> date | None:
return value if isinstance(value, date) else None
def _as_iso(value: date | None) -> str | None:
return value.isoformat() if value is not None else None
def _as_tags(value: object) -> list[str]:
if isinstance(value, str):
try:
value = json.loads(value)
except ValueError:
return []
if isinstance(value, list | tuple):
return [str(item) for item in value]
return []
2026-09-09 21:55:37 +08:00
class KnowledgeReferenceService:
"""只读解析知识引用 token,返回脱敏元数据。"""
def __init__(self, *, session_factory: Callable[[], Any] | None = None) -> None:
self._session_factory: Callable[[], Any] = session_factory or SessionFactory
2026-09-09 21:55:37 +08:00
async def resolve(self, context: RequestContext, token: str) -> dict[str, Any]:
await AuthorizationService.require(context, "knowledge:reference:read")
knowledge_id, expires_at = self._verify_token(context, token)
metadata = await self._load_metadata(knowledge_id)
return self._redact(knowledge_id, expires_at, self._assert_resolvable(metadata))
def _verify_token(self, context: RequestContext, token: str) -> tuple[int, datetime]:
secret = signing_secret()
2026-09-09 21:55:37 +08:00
pieces = token.split(".")
if len(pieces) != REFERENCE_TOKEN_PARTS or pieces[0] != REFERENCE_TOKEN_PREFIX:
raise _not_found()
_, token_user, knowledge_raw, expires_raw, signature = pieces
if not hmac.compare_digest(_signature(secret, ".".join(pieces[:4])), signature):
raise _not_found()
if token_user != context.user_id:
raise _not_found()
if not knowledge_raw.isdecimal() or not expires_raw.isdecimal():
raise _not_found()
expires_at = datetime.fromtimestamp(int(expires_raw), tz=UTC)
if expires_at <= datetime.now(UTC):
raise _not_found()
return int(knowledge_raw), expires_at
async def _load_metadata(self, knowledge_id: int) -> Mapping[str, Any] | None:
async with self._session_factory() as session:
result = await session.execute(_REFERENCE_SQL, {"knowledge_id": knowledge_id})
return result.mappings().first() # type: ignore[no-any-return]
@staticmethod
def _assert_resolvable(metadata: Mapping[str, Any] | None) -> Mapping[str, Any]:
"""校验发布状态与有效期;不满足一律 404,且不泄露失败原因。"""
if metadata is None:
raise _not_found()
if str(metadata["review_status"]) != PUBLISHED_REVIEW_STATUS:
raise _not_found()
if str(metadata["status"]) != ACTIVE_STATUS:
raise _not_found()
today = datetime.now(UTC).date()
effective_date = _as_date(metadata["effective_date"])
if effective_date is not None and effective_date > today:
raise _not_found()
expire_date = _as_date(metadata["expire_date"])
if expire_date is not None and expire_date <= today:
raise _not_found()
return metadata
@staticmethod
def _redact(
knowledge_id: int, expires_at: datetime, metadata: Mapping[str, Any]
) -> dict[str, Any]:
version = metadata["version"]
return {
"knowledge_id": knowledge_id,
"title": str(metadata["title"]),
"knowledge_type": str(metadata["knowledge_type"]),
"version": version if isinstance(version, str) else None,
"collection": str(metadata["milvus_collection"]),
"tags": _as_tags(metadata["tags"]),
"effective_date": _as_iso(_as_date(metadata["effective_date"])),
"expire_date": _as_iso(_as_date(metadata["expire_date"])),
"reference_expires_at": expires_at.isoformat(),
"content_included": False,
}