feat: 完善知识库、策略预览与OnlyOffice集成,增强后端启动依赖检查
This commit is contained in:
@@ -1,48 +1,48 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "x-financial-server"
|
||||
version = "0.1.0"
|
||||
description = "Backend service for X-Financial reimbursement and approval platform."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0,<1.0.0",
|
||||
"uvicorn[standard]>=0.30.0,<1.0.0",
|
||||
"sqlalchemy>=2.0.36,<3.0.0",
|
||||
"alembic>=1.14.0,<2.0.0",
|
||||
"psycopg[binary]>=3.2.0,<4.0.0",
|
||||
"PyJWT>=2.9.0,<3.0.0",
|
||||
"pydantic-settings>=2.6.0,<3.0.0",
|
||||
"python-dotenv>=1.0.1,<2.0.0",
|
||||
"email-validator>=2.2.0,<3.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3.0,<9.0.0",
|
||||
"httpx>=0.28.0,<1.0.0",
|
||||
"ruff>=0.8.0,<1.0.0",
|
||||
]
|
||||
redis = [
|
||||
"redis>=5.2.0,<6.0.0",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["src"]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "B", "UP"]
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "x-financial-server"
|
||||
version = "0.1.0"
|
||||
description = "Backend service for X-Financial reimbursement and approval platform."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0,<1.0.0",
|
||||
"uvicorn[standard]>=0.30.0,<1.0.0",
|
||||
"sqlalchemy>=2.0.36,<3.0.0",
|
||||
"alembic>=1.14.0,<2.0.0",
|
||||
"psycopg[binary]>=3.2.0,<4.0.0",
|
||||
"PyJWT>=2.9.0,<3.0.0",
|
||||
"pydantic-settings>=2.6.0,<3.0.0",
|
||||
"python-dotenv>=1.0.1,<2.0.0",
|
||||
"email-validator>=2.2.0,<3.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3.0,<9.0.0",
|
||||
"httpx>=0.28.0,<1.0.0",
|
||||
"ruff>=0.8.0,<1.0.0",
|
||||
]
|
||||
redis = [
|
||||
"redis>=5.2.0,<6.0.0",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["src"]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "B", "UP"]
|
||||
|
||||
@@ -90,6 +90,10 @@ fi
|
||||
ENV_OVERRIDE_SERVER_HOST_SET=false
|
||||
ENV_OVERRIDE_POSTGRES_HOST_SET=false
|
||||
ENV_OVERRIDE_DATABASE_URL_SET=false
|
||||
ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=false
|
||||
ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET=false
|
||||
ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET=false
|
||||
ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET=false
|
||||
|
||||
if [ "${SERVER_HOST+x}" = x ]; then
|
||||
ENV_OVERRIDE_SERVER_HOST_SET=true
|
||||
@@ -106,6 +110,26 @@ if [ "${DATABASE_URL+x}" = x ]; then
|
||||
ENV_OVERRIDE_DATABASE_URL="$DATABASE_URL"
|
||||
fi
|
||||
|
||||
if [ "${ONLYOFFICE_ENABLED+x}" = x ]; then
|
||||
ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=true
|
||||
ENV_OVERRIDE_ONLYOFFICE_ENABLED="$ONLYOFFICE_ENABLED"
|
||||
fi
|
||||
|
||||
if [ "${ONLYOFFICE_PUBLIC_URL+x}" = x ]; then
|
||||
ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET=true
|
||||
ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL="$ONLYOFFICE_PUBLIC_URL"
|
||||
fi
|
||||
|
||||
if [ "${ONLYOFFICE_BACKEND_URL+x}" = x ]; then
|
||||
ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET=true
|
||||
ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL="$ONLYOFFICE_BACKEND_URL"
|
||||
fi
|
||||
|
||||
if [ "${ONLYOFFICE_JWT_SECRET+x}" = x ]; then
|
||||
ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET=true
|
||||
ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET="$ONLYOFFICE_JWT_SECRET"
|
||||
fi
|
||||
|
||||
set -a
|
||||
. "$ROOT_ENV_FILE"
|
||||
set +a
|
||||
@@ -122,6 +146,22 @@ if [ "$ENV_OVERRIDE_DATABASE_URL_SET" = true ]; then
|
||||
DATABASE_URL="$ENV_OVERRIDE_DATABASE_URL"
|
||||
fi
|
||||
|
||||
if [ "$ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET" = true ]; then
|
||||
ONLYOFFICE_ENABLED="$ENV_OVERRIDE_ONLYOFFICE_ENABLED"
|
||||
fi
|
||||
|
||||
if [ "$ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET" = true ]; then
|
||||
ONLYOFFICE_PUBLIC_URL="$ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL"
|
||||
fi
|
||||
|
||||
if [ "$ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET" = true ]; then
|
||||
ONLYOFFICE_BACKEND_URL="$ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL"
|
||||
fi
|
||||
|
||||
if [ "$ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET" = true ]; then
|
||||
ONLYOFFICE_JWT_SECRET="$ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET"
|
||||
fi
|
||||
|
||||
SERVER_HOST="${SERVER_HOST:-0.0.0.0}"
|
||||
SERVER_PORT="${SERVER_PORT:-8000}"
|
||||
DEFAULT_SERVER_RELOAD="false"
|
||||
@@ -189,7 +229,7 @@ run_bootstrap_python() {
|
||||
}
|
||||
|
||||
dependencies_ready() {
|
||||
"$PYTHON_BIN" -c "import fastapi, uvicorn, sqlalchemy, alembic, pydantic_settings" >/dev/null 2>&1
|
||||
"$PYTHON_BIN" -c "import alembic, dotenv, email_validator, fastapi, jwt, psycopg, pydantic_settings, sqlalchemy, uvicorn" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
pip_ready() {
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
from collections.abc import Generator
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_session_factory
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = get_session_factory()()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CurrentUserContext:
|
||||
username: str
|
||||
name: str
|
||||
role_codes: list[str]
|
||||
is_admin: bool
|
||||
|
||||
|
||||
def get_current_user(
|
||||
x_auth_username: Annotated[str | None, Header()] = None,
|
||||
x_auth_name: Annotated[str | None, Header()] = None,
|
||||
x_auth_role_codes: Annotated[str | None, Header()] = None,
|
||||
x_auth_is_admin: Annotated[str | None, Header()] = None,
|
||||
) -> CurrentUserContext:
|
||||
role_codes = [item.strip() for item in (x_auth_role_codes or "").split(",") if item.strip()]
|
||||
is_admin = str(x_auth_is_admin or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
username = (x_auth_username or "").strip()
|
||||
name = (x_auth_name or username).strip()
|
||||
|
||||
if not username and not name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="请先登录后再访问知识库。",
|
||||
)
|
||||
|
||||
return CurrentUserContext(
|
||||
username=username or name,
|
||||
name=name or username,
|
||||
role_codes=role_codes,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
|
||||
|
||||
def require_admin_user(
|
||||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||
) -> CurrentUserContext:
|
||||
if current_user.is_admin or "manager" in current_user.role_codes:
|
||||
return current_user
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只有管理员可以上传、删除或修改知识库文件。",
|
||||
)
|
||||
from collections.abc import Generator
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_session_factory
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = get_session_factory()()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CurrentUserContext:
|
||||
username: str
|
||||
name: str
|
||||
role_codes: list[str]
|
||||
is_admin: bool
|
||||
|
||||
|
||||
def get_current_user(
|
||||
x_auth_username: Annotated[str | None, Header()] = None,
|
||||
x_auth_name: Annotated[str | None, Header()] = None,
|
||||
x_auth_role_codes: Annotated[str | None, Header()] = None,
|
||||
x_auth_is_admin: Annotated[str | None, Header()] = None,
|
||||
) -> CurrentUserContext:
|
||||
role_codes = [item.strip() for item in (x_auth_role_codes or "").split(",") if item.strip()]
|
||||
is_admin = str(x_auth_is_admin or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
username = (x_auth_username or "").strip()
|
||||
name = (x_auth_name or username).strip()
|
||||
|
||||
if not username and not name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="请先登录后再访问知识库。",
|
||||
)
|
||||
|
||||
return CurrentUserContext(
|
||||
username=username or name,
|
||||
name=name or username,
|
||||
role_codes=role_codes,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
|
||||
|
||||
def require_admin_user(
|
||||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||
) -> CurrentUserContext:
|
||||
if current_user.is_admin or "manager" in current_user.role_codes:
|
||||
return current_user
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只有管理员可以上传、删除或修改知识库文件。",
|
||||
)
|
||||
|
||||
@@ -1,124 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.api.deps import CurrentUserContext, get_current_user, require_admin_user
|
||||
from app.schemas.knowledge import (
|
||||
KnowledgeActionResponse,
|
||||
KnowledgeDocumentDetailRead,
|
||||
KnowledgeLibraryRead,
|
||||
KnowledgeOnlyOfficeCallbackRead,
|
||||
KnowledgeOnlyOfficeConfigRead,
|
||||
)
|
||||
from app.services.knowledge import KnowledgeService
|
||||
|
||||
router = APIRouter(prefix="/knowledge")
|
||||
|
||||
|
||||
@router.get("/library", response_model=KnowledgeLibraryRead)
|
||||
def get_knowledge_library(
|
||||
_: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||
) -> KnowledgeLibraryRead:
|
||||
return KnowledgeService().list_library()
|
||||
|
||||
|
||||
@router.get("/documents/{document_id}", response_model=KnowledgeDocumentDetailRead)
|
||||
def get_knowledge_document(
|
||||
document_id: str,
|
||||
_: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||
) -> KnowledgeDocumentDetailRead:
|
||||
try:
|
||||
return KnowledgeService().get_document_detail(document_id)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
||||
|
||||
|
||||
@router.get("/documents/{document_id}/onlyoffice-config", response_model=KnowledgeOnlyOfficeConfigRead)
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
@router.post("/documents", response_model=KnowledgeDocumentDetailRead, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_knowledge_document(
|
||||
request: Request,
|
||||
folder: Annotated[str, Query(min_length=1)],
|
||||
filename: Annotated[str, Query(min_length=1)],
|
||||
current_user: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
||||
) -> KnowledgeDocumentDetailRead:
|
||||
content = await request.body()
|
||||
try:
|
||||
return KnowledgeService().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
|
||||
|
||||
|
||||
@router.delete("/documents/{document_id}", response_model=KnowledgeActionResponse)
|
||||
def delete_knowledge_document(
|
||||
document_id: str,
|
||||
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
||||
) -> KnowledgeActionResponse:
|
||||
try:
|
||||
KnowledgeService().delete_document(document_id)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
||||
|
||||
return KnowledgeActionResponse(detail="知识库文件已删除。")
|
||||
|
||||
|
||||
@router.get("/documents/{document_id}/content")
|
||||
def get_knowledge_document_content(
|
||||
document_id: str,
|
||||
disposition: Annotated[str, Query(pattern="^(inline|attachment)$")] = "inline",
|
||||
_: Annotated[CurrentUserContext, Depends(get_current_user)] = None,
|
||||
) -> FileResponse:
|
||||
try:
|
||||
file_path, media_type, filename = KnowledgeService().get_document_content(document_id)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
||||
|
||||
_ = disposition
|
||||
return FileResponse(file_path, media_type=media_type, filename=filename)
|
||||
|
||||
|
||||
@router.get("/documents/{document_id}/onlyoffice/content")
|
||||
def get_knowledge_document_onlyoffice_content(
|
||||
document_id: str,
|
||||
access_token: Annotated[str, Query(min_length=1)],
|
||||
) -> FileResponse:
|
||||
try:
|
||||
service = KnowledgeService()
|
||||
service.validate_onlyoffice_access_token(document_id, access_token)
|
||||
file_path, media_type, filename = service.get_document_content(document_id)
|
||||
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
|
||||
|
||||
return FileResponse(file_path, media_type=media_type, filename=filename)
|
||||
|
||||
|
||||
@router.post("/documents/{document_id}/onlyoffice/callback", response_model=KnowledgeOnlyOfficeCallbackRead)
|
||||
async def handle_knowledge_document_onlyoffice_callback(
|
||||
document_id: str,
|
||||
request: Request,
|
||||
) -> KnowledgeOnlyOfficeCallbackRead:
|
||||
payload = await request.json()
|
||||
try:
|
||||
KnowledgeService().handle_onlyoffice_callback(document_id, payload)
|
||||
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
|
||||
|
||||
return KnowledgeOnlyOfficeCallbackRead()
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.api.deps import CurrentUserContext, get_current_user, require_admin_user
|
||||
from app.schemas.knowledge import (
|
||||
KnowledgeActionResponse,
|
||||
KnowledgeDocumentDetailRead,
|
||||
KnowledgeLibraryRead,
|
||||
KnowledgeOnlyOfficeCallbackRead,
|
||||
KnowledgeOnlyOfficeConfigRead,
|
||||
)
|
||||
from app.services.knowledge import KnowledgeService
|
||||
|
||||
router = APIRouter(prefix="/knowledge")
|
||||
|
||||
|
||||
@router.get("/library", response_model=KnowledgeLibraryRead)
|
||||
def get_knowledge_library(
|
||||
_: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||
) -> KnowledgeLibraryRead:
|
||||
return KnowledgeService().list_library()
|
||||
|
||||
|
||||
@router.get("/documents/{document_id}", response_model=KnowledgeDocumentDetailRead)
|
||||
def get_knowledge_document(
|
||||
document_id: str,
|
||||
_: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||
) -> KnowledgeDocumentDetailRead:
|
||||
try:
|
||||
return KnowledgeService().get_document_detail(document_id)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
||||
|
||||
|
||||
@router.get("/documents/{document_id}/onlyoffice-config", response_model=KnowledgeOnlyOfficeConfigRead)
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
@router.post("/documents", response_model=KnowledgeDocumentDetailRead, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_knowledge_document(
|
||||
request: Request,
|
||||
folder: Annotated[str, Query(min_length=1)],
|
||||
filename: Annotated[str, Query(min_length=1)],
|
||||
current_user: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
||||
) -> KnowledgeDocumentDetailRead:
|
||||
content = await request.body()
|
||||
try:
|
||||
return KnowledgeService().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
|
||||
|
||||
|
||||
@router.delete("/documents/{document_id}", response_model=KnowledgeActionResponse)
|
||||
def delete_knowledge_document(
|
||||
document_id: str,
|
||||
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
||||
) -> KnowledgeActionResponse:
|
||||
try:
|
||||
KnowledgeService().delete_document(document_id)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
||||
|
||||
return KnowledgeActionResponse(detail="知识库文件已删除。")
|
||||
|
||||
|
||||
@router.get("/documents/{document_id}/content")
|
||||
def get_knowledge_document_content(
|
||||
document_id: str,
|
||||
disposition: Annotated[str, Query(pattern="^(inline|attachment)$")] = "inline",
|
||||
_: Annotated[CurrentUserContext, Depends(get_current_user)] = None,
|
||||
) -> FileResponse:
|
||||
try:
|
||||
file_path, media_type, filename = KnowledgeService().get_document_content(document_id)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
||||
|
||||
_ = disposition
|
||||
return FileResponse(file_path, media_type=media_type, filename=filename)
|
||||
|
||||
|
||||
@router.get("/documents/{document_id}/onlyoffice/content")
|
||||
def get_knowledge_document_onlyoffice_content(
|
||||
document_id: str,
|
||||
access_token: Annotated[str, Query(min_length=1)],
|
||||
) -> FileResponse:
|
||||
try:
|
||||
service = KnowledgeService()
|
||||
service.validate_onlyoffice_access_token(document_id, access_token)
|
||||
file_path, media_type, filename = service.get_document_content(document_id)
|
||||
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
|
||||
|
||||
return FileResponse(file_path, media_type=media_type, filename=filename)
|
||||
|
||||
|
||||
@router.post("/documents/{document_id}/onlyoffice/callback", response_model=KnowledgeOnlyOfficeCallbackRead)
|
||||
async def handle_knowledge_document_onlyoffice_callback(
|
||||
document_id: str,
|
||||
request: Request,
|
||||
) -> KnowledgeOnlyOfficeCallbackRead:
|
||||
payload = await request.json()
|
||||
try:
|
||||
KnowledgeService().handle_onlyoffice_callback(document_id, payload)
|
||||
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
|
||||
|
||||
return KnowledgeOnlyOfficeCallbackRead()
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints.auth import router as auth_router
|
||||
from app.api.v1.endpoints.bootstrap import router as bootstrap_router
|
||||
from app.api.v1.endpoints.employees import router as employees_router
|
||||
from app.api.v1.endpoints.health import router as health_router
|
||||
from app.api.v1.endpoints.knowledge import router as knowledge_router
|
||||
from app.api.v1.endpoints.reimbursements import router as reimbursements_router
|
||||
from app.api.v1.endpoints.settings import router as settings_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(health_router, tags=["health"])
|
||||
router.include_router(bootstrap_router, tags=["bootstrap"])
|
||||
router.include_router(auth_router, tags=["auth"])
|
||||
router.include_router(knowledge_router, tags=["knowledge"])
|
||||
router.include_router(employees_router, prefix="/employees", tags=["employees"])
|
||||
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
|
||||
router.include_router(settings_router, tags=["settings"])
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints.auth import router as auth_router
|
||||
from app.api.v1.endpoints.bootstrap import router as bootstrap_router
|
||||
from app.api.v1.endpoints.employees import router as employees_router
|
||||
from app.api.v1.endpoints.health import router as health_router
|
||||
from app.api.v1.endpoints.knowledge import router as knowledge_router
|
||||
from app.api.v1.endpoints.reimbursements import router as reimbursements_router
|
||||
from app.api.v1.endpoints.settings import router as settings_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(health_router, tags=["health"])
|
||||
router.include_router(bootstrap_router, tags=["bootstrap"])
|
||||
router.include_router(auth_router, tags=["auth"])
|
||||
router.include_router(knowledge_router, tags=["knowledge"])
|
||||
router.include_router(employees_router, prefix="/employees", tags=["employees"])
|
||||
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
|
||||
router.include_router(settings_router, tags=["settings"])
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.router import api_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import get_logger, setup_logging
|
||||
from app.middleware.logging import AccessLogMiddleware
|
||||
from app.services.employee import prepare_employee_directory
|
||||
from app.services.knowledge import prepare_knowledge_library
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
|
||||
setup_logging(
|
||||
level=settings.log_level,
|
||||
log_dir=settings.log_dir,
|
||||
enable_file=settings.log_file_enabled,
|
||||
)
|
||||
|
||||
logger = get_logger("app.main")
|
||||
logger.info(
|
||||
"Starting %s (env=%s, debug=%s)", settings.app_name, settings.app_env, settings.app_debug
|
||||
)
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
debug=settings.app_debug,
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
app.add_middleware(AccessLogMiddleware)
|
||||
|
||||
if settings.cors_origins:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router, prefix=settings.api_v1_prefix)
|
||||
|
||||
@app.get("/", tags=["root"])
|
||||
def root() -> dict[str, str]:
|
||||
return {"message": f"{settings.app_name} is running"}
|
||||
|
||||
@app.on_event("startup")
|
||||
def _on_startup() -> None:
|
||||
prepare_employee_directory()
|
||||
prepare_knowledge_library()
|
||||
logger.info(
|
||||
"Server ready - host=%s port=%s prefix=%s",
|
||||
settings.app_host,
|
||||
settings.app_port,
|
||||
settings.api_v1_prefix,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.router import api_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import get_logger, setup_logging
|
||||
from app.middleware.logging import AccessLogMiddleware
|
||||
from app.services.employee import prepare_employee_directory
|
||||
from app.services.knowledge import prepare_knowledge_library
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
|
||||
setup_logging(
|
||||
level=settings.log_level,
|
||||
log_dir=settings.log_dir,
|
||||
enable_file=settings.log_file_enabled,
|
||||
)
|
||||
|
||||
logger = get_logger("app.main")
|
||||
logger.info(
|
||||
"Starting %s (env=%s, debug=%s)", settings.app_name, settings.app_env, settings.app_debug
|
||||
)
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
debug=settings.app_debug,
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
app.add_middleware(AccessLogMiddleware)
|
||||
|
||||
if settings.cors_origins:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router, prefix=settings.api_v1_prefix)
|
||||
|
||||
@app.get("/", tags=["root"])
|
||||
def root() -> dict[str, str]:
|
||||
return {"message": f"{settings.app_name} is running"}
|
||||
|
||||
@app.on_event("startup")
|
||||
def _on_startup() -> None:
|
||||
prepare_employee_directory()
|
||||
prepare_knowledge_library()
|
||||
logger.info(
|
||||
"Server ready - host=%s port=%s prefix=%s",
|
||||
settings.app_host,
|
||||
settings.app_port,
|
||||
settings.api_v1_prefix,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
@@ -1 +1 @@
|
||||
__all__ = ["employee", "knowledge", "reimbursement"]
|
||||
__all__ = ["employee", "knowledge", "reimbursement"]
|
||||
|
||||
@@ -1,72 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class KnowledgeFolderRead(BaseModel):
|
||||
name: str
|
||||
count: int
|
||||
icon: str = "mdi mdi-folder"
|
||||
|
||||
|
||||
class KnowledgePreviewStatRead(BaseModel):
|
||||
label: str
|
||||
value: str
|
||||
|
||||
|
||||
class KnowledgePreviewBlockRead(BaseModel):
|
||||
heading: str
|
||||
lines: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KnowledgePreviewPageRead(BaseModel):
|
||||
title: str
|
||||
subtitle: str
|
||||
stats: list[KnowledgePreviewStatRead] = Field(default_factory=list)
|
||||
blocks: list[KnowledgePreviewBlockRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KnowledgeDocumentRead(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
folder: str
|
||||
tag: str
|
||||
time: str
|
||||
version: str
|
||||
state: str
|
||||
stateTone: str
|
||||
owner: str
|
||||
icon: str
|
||||
fileType: str
|
||||
fileTypeLabel: str
|
||||
summary: str
|
||||
mimeType: str
|
||||
extension: str
|
||||
sizeBytes: int
|
||||
canPreview: bool = False
|
||||
|
||||
|
||||
class KnowledgeDocumentDetailRead(KnowledgeDocumentRead):
|
||||
previewKind: str
|
||||
previewPages: list[KnowledgePreviewPageRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KnowledgeOnlyOfficeConfigRead(BaseModel):
|
||||
documentServerUrl: str
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class KnowledgeOnlyOfficeCallbackRead(BaseModel):
|
||||
error: int = 0
|
||||
|
||||
|
||||
class KnowledgeLibraryRead(BaseModel):
|
||||
folders: list[KnowledgeFolderRead] = Field(default_factory=list)
|
||||
documents: list[KnowledgeDocumentRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KnowledgeActionResponse(BaseModel):
|
||||
ok: bool = True
|
||||
detail: str
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class KnowledgeFolderRead(BaseModel):
|
||||
name: str
|
||||
count: int
|
||||
icon: str = "mdi mdi-folder"
|
||||
|
||||
|
||||
class KnowledgePreviewStatRead(BaseModel):
|
||||
label: str
|
||||
value: str
|
||||
|
||||
|
||||
class KnowledgePreviewBlockRead(BaseModel):
|
||||
heading: str
|
||||
lines: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KnowledgePreviewPageRead(BaseModel):
|
||||
title: str
|
||||
subtitle: str
|
||||
stats: list[KnowledgePreviewStatRead] = Field(default_factory=list)
|
||||
blocks: list[KnowledgePreviewBlockRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KnowledgeDocumentRead(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
folder: str
|
||||
tag: str
|
||||
time: str
|
||||
version: str
|
||||
state: str
|
||||
stateTone: str
|
||||
owner: str
|
||||
icon: str
|
||||
fileType: str
|
||||
fileTypeLabel: str
|
||||
summary: str
|
||||
mimeType: str
|
||||
extension: str
|
||||
sizeBytes: int
|
||||
canPreview: bool = False
|
||||
|
||||
|
||||
class KnowledgeDocumentDetailRead(KnowledgeDocumentRead):
|
||||
previewKind: str
|
||||
previewPages: list[KnowledgePreviewPageRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KnowledgeOnlyOfficeConfigRead(BaseModel):
|
||||
documentServerUrl: str
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class KnowledgeOnlyOfficeCallbackRead(BaseModel):
|
||||
error: int = 0
|
||||
|
||||
|
||||
class KnowledgeLibraryRead(BaseModel):
|
||||
folders: list[KnowledgeFolderRead] = Field(default_factory=list)
|
||||
documents: list[KnowledgeDocumentRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KnowledgeActionResponse(BaseModel):
|
||||
ok: bool = True
|
||||
detail: str
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,16 +2,16 @@
|
||||
"version": 1,
|
||||
"documents": [
|
||||
{
|
||||
"id": "fde293670eac4ae2b90a80eeb9f27b5b",
|
||||
"id": "8af9350f0e02488aaf0df2001286b764",
|
||||
"folder": "财务知识库",
|
||||
"original_name": "差旅费季度报销258878.xlsx",
|
||||
"stored_name": "fde293670eac4ae2b90a80eeb9f27b5b__差旅费季度报销258878.xlsx",
|
||||
"stored_name": "8af9350f0e02488aaf0df2001286b764__差旅费季度报销258878.xlsx",
|
||||
"mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"extension": "xlsx",
|
||||
"size_bytes": 11123,
|
||||
"sha256": "ea02e59d3a22a4a02284172acce3fd4c6367a26f1a4fd196dc4f65afed1bd4c5",
|
||||
"created_at": "2026-05-09T03:33:44.101489+00:00",
|
||||
"updated_at": "2026-05-09T03:33:44.101489+00:00",
|
||||
"created_at": "2026-05-09T05:46:24.699125+00:00",
|
||||
"updated_at": "2026-05-09T05:46:24.699125+00:00",
|
||||
"uploaded_by": "admin",
|
||||
"version_number": 1
|
||||
}
|
||||
|
||||
40
server/tests/test_server_start_dependencies.py
Normal file
40
server/tests/test_server_start_dependencies.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
|
||||
|
||||
def test_dependencies_ready_fails_when_jwt_is_missing(tmp_path: Path) -> None:
|
||||
fake_python = tmp_path / "fake-python.sh"
|
||||
fake_python.write_text(
|
||||
"""#!/usr/bin/env bash
|
||||
if [ "$1" = "-c" ]; then
|
||||
case "$2" in
|
||||
*jwt*) exit 1 ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
fi
|
||||
exit 0
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
fake_python.chmod(fake_python.stat().st_mode | stat.S_IEXEC)
|
||||
|
||||
script_path = Path(__file__).resolve().parents[1] / "server_start.sh"
|
||||
script_prefix = script_path.read_text(encoding="utf-8").split('case "$MODE" in', 1)[0]
|
||||
command = f"""{script_prefix}
|
||||
PYTHON_BIN="{fake_python}"
|
||||
dependencies_ready
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["bash", "-c", command],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, "MODE": "test"},
|
||||
cwd=script_path.parent,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
Reference in New Issue
Block a user