2026-09-10 15:55:54 +08:00
|
|
|
|
"""知识引用解析服务(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
|
|
|
|
|
|
|
2026-09-10 15:55:54 +08:00
|
|
|
|
from sqlalchemy import text
|
|
|
|
|
|
|
2026-09-09 21:55:37 +08:00
|
|
|
|
from app.core.contracts import RequestContext
|
2026-09-10 15:55:54 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-09-10 15:55:54 +08:00
|
|
|
|
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:
|
2026-09-10 15:55:54 +08:00
|
|
|
|
"""只读解析知识引用 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")
|
2026-09-10 15:55:54 +08:00
|
|
|
|
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(".")
|
2026-09-10 15:55:54 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}
|