feat(platform): close AI expense value loop

Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
caoxiaozhu
2026-07-17 14:14:08 +08:00
parent 242d68c36f
commit 787bc3a481
507 changed files with 82072 additions and 6344 deletions

View File

@@ -4,8 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db, require_admin_user
from app.core.agent_enums import AgentName, AgentRunSource
@@ -24,9 +23,17 @@ from app.schemas.knowledge import (
LlmWikiSyncWrite,
)
from app.services.agent_runs import AgentRunService
from app.services.knowledge import (
KnowledgeService,
)
from app.services.knowledge import (
KnowledgeService,
)
from app.services.knowledge_onlyoffice_callback import (
handle_onlyoffice_callback,
resolve_onlyoffice_content,
)
from app.services.knowledge_onlyoffice_security import (
OnlyOfficeReplayError,
OnlyOfficeSecurityError,
)
from app.services.knowledge_sync import KnowledgeSyncDispatchService
router = APIRouter(prefix="/knowledge")
@@ -44,11 +51,11 @@ router = APIRouter(prefix="/knowledge")
}
},
)
def get_knowledge_library(
_: Annotated[CurrentUserContext, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
) -> KnowledgeLibraryRead:
return KnowledgeService(db=db).list_library()
def get_knowledge_library(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
) -> KnowledgeLibraryRead:
return KnowledgeService(db=db, tenant_id=current_user.tenant_id).list_library()
@router.get(
@@ -67,14 +74,18 @@ def get_knowledge_library(
},
},
)
def get_llm_wiki_index(
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
def get_llm_wiki_index(
current_user: Annotated[CurrentUserContext, Depends(require_admin_user)],
db: Annotated[Session, Depends(get_db)],
) -> LlmWikiIndexRead:
run_service = AgentRunService(db)
sync_runs = [
item
for item in run_service.list_runs(agent=AgentName.HERMES.value, limit=200)
for item in run_service.list_runs_for_tenant(
tenant_id=current_user.tenant_id,
agent=AgentName.HERMES.value,
limit=200,
)
if str(item.route_json.get("job_type") or "").strip() == "knowledge_index_sync"
]
return LlmWikiIndexRead(documents=[], sync_run_count=len(sync_runs))
@@ -198,7 +209,10 @@ def sync_knowledge_library(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if isinstance(exc, FileNotFoundError):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
@router.get(
"/documents/{document_id}",
@@ -216,13 +230,16 @@ def sync_knowledge_library(
},
},
)
def get_knowledge_document(
document_id: str,
_: Annotated[CurrentUserContext, Depends(get_current_user)],
def get_knowledge_document(
document_id: str,
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
) -> KnowledgeDocumentDetailRead:
try:
return KnowledgeService(db=db).get_document_detail(document_id)
return KnowledgeService(
db=db,
tenant_id=current_user.tenant_id,
).get_document_detail(document_id)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -250,12 +267,24 @@ def get_knowledge_document(
},
},
)
def get_knowledge_document_onlyoffice_config(
document_id: str,
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
) -> KnowledgeOnlyOfficeConfigRead:
try:
return KnowledgeService().build_onlyoffice_config(document_id, current_user)
def get_knowledge_document_onlyoffice_config(
document_id: str,
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
editable: Annotated[
bool,
Query(description="是否申请租户知识文档编辑会话;默认仅预览。"),
] = False,
) -> KnowledgeOnlyOfficeConfigRead:
try:
return KnowledgeService(
db=db,
tenant_id=current_user.tenant_id,
).build_onlyoffice_config(
document_id,
current_user,
editable=editable,
)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -286,7 +315,7 @@ def get_knowledge_document_onlyoffice_config(
},
},
)
def upload_knowledge_document(
def upload_knowledge_document(
content: Annotated[
bytes,
Body(
@@ -296,10 +325,14 @@ def upload_knowledge_document(
],
folder: Annotated[str, Query(min_length=1, description="目标知识库目录名称。")],
filename: Annotated[str, Query(min_length=1, description="原始文件名。")],
current_user: Annotated[CurrentUserContext, Depends(require_admin_user)],
) -> KnowledgeDocumentDetailRead:
try:
return KnowledgeService().upload_document(folder, filename, content, current_user)
current_user: Annotated[CurrentUserContext, Depends(require_admin_user)],
db: Annotated[Session, Depends(get_db)],
) -> KnowledgeDocumentDetailRead:
try:
return KnowledgeService(
db=db,
tenant_id=current_user.tenant_id,
).upload_document(folder, filename, content, current_user)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@@ -324,12 +357,16 @@ def upload_knowledge_document(
},
},
)
def delete_knowledge_document(
document_id: str,
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
) -> KnowledgeActionResponse:
try:
KnowledgeService().delete_document(document_id)
def delete_knowledge_document(
document_id: str,
current_user: Annotated[CurrentUserContext, Depends(require_admin_user)],
db: Annotated[Session, Depends(get_db)],
) -> KnowledgeActionResponse:
try:
KnowledgeService(
db=db,
tenant_id=current_user.tenant_id,
).delete_document(document_id)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -359,19 +396,23 @@ def delete_knowledge_document(
},
},
)
def get_knowledge_document_content(
document_id: str,
disposition: Annotated[
str,
Query(
def get_knowledge_document_content(
document_id: str,
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
disposition: Annotated[
str,
Query(
pattern="^(inline|attachment)$",
description="内容展示方式,支持 `inline` 或 `attachment`。",
),
] = "inline",
_: Annotated[CurrentUserContext, Depends(get_current_user)] = None,
) -> FileResponse:
try:
file_path, media_type, filename = KnowledgeService().get_document_content(document_id)
description="内容展示方式,支持 `inline` 或 `attachment`。",
),
] = "inline",
) -> FileResponse:
try:
file_path, media_type, filename = KnowledgeService(
db=db,
tenant_id=current_user.tenant_id,
).get_document_content(document_id)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -402,24 +443,28 @@ def get_knowledge_document_content(
},
},
)
def get_knowledge_document_onlyoffice_content(
def get_knowledge_document_onlyoffice_content(
document_id: str,
access_token: Annotated[
access_token: Annotated[
str,
Query(min_length=1, description="ONLYOFFICE 临时访问令牌。"),
],
) -> FileResponse:
try:
service = KnowledgeService()
service.validate_onlyoffice_access_token(document_id, access_token)
file_path, media_type, filename = service.get_document_content(document_id)
],
db: Annotated[Session, Depends(get_db)],
) -> FileResponse:
try:
file_path, media_type, filename = resolve_onlyoffice_content(
db=db,
storage_root=None,
document_id=document_id,
access_token=access_token,
)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="知识库文件不存在。",
) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
except (OnlyOfficeSecurityError, OnlyOfficeReplayError) as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
return FileResponse(file_path, media_type=media_type, filename=filename)
@@ -440,18 +485,33 @@ def get_knowledge_document_onlyoffice_content(
},
},
)
def handle_knowledge_document_onlyoffice_callback(
document_id: str,
payload: KnowledgeOnlyOfficeCallbackWrite,
) -> KnowledgeOnlyOfficeCallbackRead:
try:
KnowledgeService().handle_onlyoffice_callback(document_id, payload.model_dump())
def handle_knowledge_document_onlyoffice_callback(
document_id: str,
payload: KnowledgeOnlyOfficeCallbackWrite,
callback_token: Annotated[
str,
Query(min_length=1, description="绑定租户与文档的一次性回调会话令牌。"),
],
db: Annotated[Session, Depends(get_db)],
) -> KnowledgeOnlyOfficeCallbackRead:
try:
handle_onlyoffice_callback(
db=db,
storage_root=None,
document_id=document_id,
callback_token=callback_token,
payload=payload.model_dump(),
)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="知识库文件不存在。",
) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except OnlyOfficeReplayError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except OnlyOfficeSecurityError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return KnowledgeOnlyOfficeCallbackRead()