refactor(server): split oversized backend services
This commit is contained in:
450
server/src/app/services/agent_asset_onlyoffice.py
Normal file
450
server/src/app/services/agent_asset_onlyoffice.py
Normal file
@@ -0,0 +1,450 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
import jwt
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.config import get_settings
|
||||
from app.schemas.agent_asset import AgentAssetOnlyOfficeConfigRead
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||
FINANCE_RULES_LIBRARY,
|
||||
SPREADSHEET_MIME_TYPE,
|
||||
AgentAssetSpreadsheetManager,
|
||||
RuleSpreadsheetMeta,
|
||||
)
|
||||
from app.services.settings import resolve_onlyoffice_settings
|
||||
|
||||
PREVIEW_RULE_ASSET_ID = "preview-rule-expense-company-travel-expense"
|
||||
PREVIEW_RULE_CURRENT_VERSION = "v1.2.0"
|
||||
PREVIEW_RULE_VERSION_FILENAMES = {
|
||||
PREVIEW_RULE_CURRENT_VERSION: COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||
"v1.1.0": "鍏徃宸梾璐规姤閿€瑙勫垯-v1.1.0.xlsx",
|
||||
"v1.0.0": "鍏徃宸梾璐规姤閿€瑙勫垯-v1.0.0.xlsx",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OnlyOfficeCallbackPayload:
|
||||
status: int
|
||||
download_url: str
|
||||
users: list[str]
|
||||
|
||||
|
||||
class AgentAssetOnlyOfficeMixin:
|
||||
@staticmethod
|
||||
def _resolve_onlyoffice_settings():
|
||||
from app.services import agent_assets
|
||||
|
||||
return agent_assets.resolve_onlyoffice_settings()
|
||||
|
||||
def build_rule_spreadsheet_onlyoffice_config(
|
||||
self,
|
||||
asset_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
version: str | None = None,
|
||||
) -> AgentAssetOnlyOfficeConfigRead:
|
||||
self._ensure_ready()
|
||||
if asset_id == PREVIEW_RULE_ASSET_ID:
|
||||
resolved_version, metadata = self._ensure_preview_rule_spreadsheet(version=version)
|
||||
return self._build_onlyoffice_spreadsheet_config(
|
||||
asset_id=asset_id,
|
||||
current_user=current_user,
|
||||
metadata=metadata,
|
||||
editable=resolved_version == PREVIEW_RULE_CURRENT_VERSION,
|
||||
)
|
||||
|
||||
asset = self._require_spreadsheet_rule(asset_id)
|
||||
_, metadata = self._resolve_current_spreadsheet_meta(asset)
|
||||
editable = self._can_edit_current_spreadsheet(current_user)
|
||||
return self._build_onlyoffice_spreadsheet_config(
|
||||
asset_id=asset.id,
|
||||
current_user=current_user,
|
||||
metadata=metadata,
|
||||
editable=editable,
|
||||
)
|
||||
|
||||
def get_rule_spreadsheet_content(
|
||||
self,
|
||||
asset_id: str,
|
||||
*,
|
||||
version: str | None = None,
|
||||
) -> tuple[Path, str, str]:
|
||||
self._ensure_ready()
|
||||
if asset_id == PREVIEW_RULE_ASSET_ID:
|
||||
_, metadata = self._ensure_preview_rule_spreadsheet(version=version)
|
||||
file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(metadata.file_name)
|
||||
return file_path, metadata.mime_type, metadata.file_name
|
||||
|
||||
asset = self._require_spreadsheet_rule(asset_id)
|
||||
requested_version = str(version or "").strip()
|
||||
if requested_version and requested_version != "current":
|
||||
_, metadata = self._resolve_spreadsheet_version_meta(asset, version=requested_version)
|
||||
else:
|
||||
_, metadata = self._resolve_current_spreadsheet_meta(asset)
|
||||
file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(metadata.file_name)
|
||||
return file_path, metadata.mime_type, metadata.file_name
|
||||
|
||||
def validate_rule_spreadsheet_access_token(
|
||||
self,
|
||||
asset_id: str,
|
||||
access_token: str,
|
||||
) -> None:
|
||||
onlyoffice_settings = self._resolve_onlyoffice_settings()
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
access_token,
|
||||
onlyoffice_settings.jwt_secret,
|
||||
algorithms=["HS256"],
|
||||
)
|
||||
except jwt.PyJWTError as exc:
|
||||
raise ValueError("ONLYOFFICE 文件访问令牌无效。") from exc
|
||||
|
||||
if (
|
||||
payload.get("scope") != "agent-asset-spreadsheet"
|
||||
or payload.get("asset_id") != asset_id
|
||||
):
|
||||
raise ValueError("ONLYOFFICE 文件访问令牌无效。")
|
||||
|
||||
def upload_rule_spreadsheet(
|
||||
self,
|
||||
asset_id: str,
|
||||
*,
|
||||
filename: str,
|
||||
content: bytes,
|
||||
actor: str,
|
||||
request_id: str | None = None,
|
||||
change_note: str | None = None,
|
||||
source: str = "upload",
|
||||
) -> AgentAssetRead:
|
||||
self._ensure_ready()
|
||||
asset = self._require_spreadsheet_rule(asset_id)
|
||||
normalized_name = Path(str(filename or "").strip()).name.strip()
|
||||
if not normalized_name:
|
||||
raise ValueError("规则表文件名不能为空。")
|
||||
if Path(normalized_name).suffix.lower() != ".xlsx":
|
||||
raise ValueError("当前仅支持上传 .xlsx 格式的规则表。")
|
||||
if not content:
|
||||
raise ValueError("规则表文件内容不能为空。")
|
||||
|
||||
_, current_metadata = self._resolve_current_spreadsheet_meta(asset)
|
||||
file_name = current_metadata.file_name or self._resolve_default_spreadsheet_file_name(asset)
|
||||
sheet_changes, cell_changes = self._collect_workbook_changes_from_content(
|
||||
current_metadata,
|
||||
content,
|
||||
)
|
||||
changed_sheet_count = self._count_changed_sheets(sheet_changes, cell_changes)
|
||||
changed_cell_count = len(cell_changes)
|
||||
|
||||
metadata = self._store_current_rule_spreadsheet(
|
||||
asset,
|
||||
file_name=file_name,
|
||||
content=content,
|
||||
actor=actor,
|
||||
source=source,
|
||||
)
|
||||
summary = self._build_spreadsheet_change_summary(
|
||||
sheet_changes,
|
||||
cell_changes,
|
||||
)
|
||||
self.audit_service.log_action(
|
||||
actor=actor,
|
||||
action="edit_rule_spreadsheet",
|
||||
resource_type=asset.asset_type,
|
||||
resource_id=asset.id,
|
||||
before_json={"storage_key": current_metadata.storage_key},
|
||||
after_json={
|
||||
"summary": summary,
|
||||
"changed_sheet_count": changed_sheet_count,
|
||||
"changed_cell_count": changed_cell_count,
|
||||
"sheet_changes": [item.model_dump() for item in sheet_changes],
|
||||
"cell_changes": [item.model_dump() for item in cell_changes[:500]],
|
||||
"storage_key": metadata.storage_key,
|
||||
},
|
||||
request_id=request_id,
|
||||
)
|
||||
return self.get_asset(asset.id) # type: ignore[return-value]
|
||||
|
||||
def import_rule_spreadsheet_content(
|
||||
self,
|
||||
asset_id: str,
|
||||
*,
|
||||
filename: str,
|
||||
content: bytes,
|
||||
actor: str,
|
||||
request_id: str | None = None,
|
||||
) -> AgentAssetRead:
|
||||
self._ensure_ready()
|
||||
asset = self._require_spreadsheet_rule(asset_id)
|
||||
normalized_name = Path(str(filename or "").strip()).name.strip()
|
||||
if not normalized_name:
|
||||
raise ValueError("待导入表格文件名不能为空。")
|
||||
if Path(normalized_name).suffix.lower() != ".xlsx":
|
||||
raise ValueError("当前仅支持导入 .xlsx 格式的规则表。")
|
||||
|
||||
_, current_metadata = self._resolve_current_spreadsheet_meta(asset)
|
||||
imported_content = self.spreadsheet_manager.rebuild_from_uploaded_content(content)
|
||||
return self.upload_rule_spreadsheet(
|
||||
asset.id,
|
||||
filename=current_metadata.file_name,
|
||||
content=imported_content,
|
||||
actor=actor,
|
||||
request_id=request_id,
|
||||
change_note=f"导入 Excel 表格内容:{normalized_name}",
|
||||
source="content-import",
|
||||
)
|
||||
|
||||
def handle_rule_spreadsheet_onlyoffice_callback(
|
||||
self,
|
||||
asset_id: str,
|
||||
*,
|
||||
version: str | None = None,
|
||||
payload: dict[str, Any],
|
||||
actor_name: str | None = None,
|
||||
) -> None:
|
||||
self._ensure_ready()
|
||||
if asset_id == PREVIEW_RULE_ASSET_ID:
|
||||
self._handle_preview_rule_spreadsheet_onlyoffice_callback(
|
||||
version=version,
|
||||
payload=payload,
|
||||
)
|
||||
return
|
||||
|
||||
asset = self._require_spreadsheet_rule(asset_id)
|
||||
callback = self._parse_onlyoffice_callback(payload)
|
||||
if callback.status not in {2, 6} or not callback.download_url:
|
||||
return
|
||||
|
||||
_, current_metadata = self._resolve_current_spreadsheet_meta(asset)
|
||||
request = Request(
|
||||
callback.download_url,
|
||||
headers={"User-Agent": "x-financial-onlyoffice-agent-asset"},
|
||||
)
|
||||
with urlopen(request, timeout=30) as response: # noqa: S310
|
||||
content = response.read()
|
||||
|
||||
if current_metadata.checksum and current_metadata.checksum == self._hash_bytes(content):
|
||||
return
|
||||
|
||||
resolved_actor_name = str(actor_name or "").strip() or (
|
||||
callback.users[0] if callback.users else "ONLYOFFICE"
|
||||
)
|
||||
self.upload_rule_spreadsheet(
|
||||
asset.id,
|
||||
filename=current_metadata.file_name,
|
||||
content=content,
|
||||
actor=resolved_actor_name,
|
||||
source="onlyoffice",
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _can_edit_current_spreadsheet(current_user: CurrentUserContext) -> bool:
|
||||
role_codes = {str(item).strip() for item in current_user.role_codes}
|
||||
return current_user.is_admin or "manager" in role_codes or "finance" in role_codes
|
||||
|
||||
@staticmethod
|
||||
def _build_onlyoffice_document_key(
|
||||
asset_id: str,
|
||||
metadata: RuleSpreadsheetMeta,
|
||||
) -> str:
|
||||
fingerprint = metadata.checksum or metadata.updated_at or metadata.file_name
|
||||
raw_key = f"{asset_id}-{fingerprint}"
|
||||
return "".join(
|
||||
character if character.isalnum() or character in {"-", "_", ".", "="} else "_"
|
||||
for character in raw_key
|
||||
)
|
||||
|
||||
def _build_onlyoffice_access_token(self, asset_id: str) -> str:
|
||||
onlyoffice_settings = self._resolve_onlyoffice_settings()
|
||||
payload = {
|
||||
"scope": "agent-asset-spreadsheet",
|
||||
"asset_id": asset_id,
|
||||
}
|
||||
return jwt.encode(payload, onlyoffice_settings.jwt_secret, algorithm="HS256")
|
||||
|
||||
@staticmethod
|
||||
def _parse_onlyoffice_callback(payload: dict[str, Any]) -> OnlyOfficeCallbackPayload:
|
||||
return OnlyOfficeCallbackPayload(
|
||||
status=int(payload.get("status") or 0),
|
||||
download_url=str(payload.get("url") or "").strip(),
|
||||
users=[str(item).strip() for item in payload.get("users") or [] if str(item).strip()],
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _build_onlyoffice_spreadsheet_config(
|
||||
self,
|
||||
*,
|
||||
asset_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
metadata: RuleSpreadsheetMeta,
|
||||
editable: bool,
|
||||
) -> AgentAssetOnlyOfficeConfigRead:
|
||||
onlyoffice_settings = self._resolve_onlyoffice_settings()
|
||||
settings = get_settings()
|
||||
if not onlyoffice_settings.enabled:
|
||||
raise ValueError("ONLYOFFICE 预览未启用。")
|
||||
if not onlyoffice_settings.public_url or not onlyoffice_settings.backend_url:
|
||||
raise ValueError("ONLYOFFICE 地址配置不完整。")
|
||||
if not onlyoffice_settings.jwt_secret:
|
||||
raise ValueError("ONLYOFFICE JWT 密钥未配置。")
|
||||
|
||||
backend_base_url = onlyoffice_settings.backend_url.rstrip("/")
|
||||
public_url = onlyoffice_settings.public_url.rstrip("/")
|
||||
access_token = self._build_onlyoffice_access_token(asset_id)
|
||||
document_url = (
|
||||
f"{backend_base_url}{settings.api_v1_prefix}/agent-assets/{asset_id}/spreadsheet/onlyoffice/content"
|
||||
f"?access_token={access_token}"
|
||||
)
|
||||
callback_url = (
|
||||
f"{backend_base_url}{settings.api_v1_prefix}/agent-assets/{asset_id}/spreadsheet/onlyoffice/callback"
|
||||
f"?actor_name={quote(current_user.name)}"
|
||||
)
|
||||
|
||||
config: dict[str, Any] = {
|
||||
"documentType": "cell",
|
||||
"document": {
|
||||
"fileType": Path(metadata.file_name).suffix.lstrip(".").lower() or "xlsx",
|
||||
"key": self._build_onlyoffice_document_key(asset_id, metadata),
|
||||
"title": metadata.file_name,
|
||||
"url": document_url,
|
||||
"permissions": {
|
||||
"download": True,
|
||||
"edit": editable,
|
||||
"print": True,
|
||||
"copy": True,
|
||||
},
|
||||
},
|
||||
"editorConfig": {
|
||||
"mode": "edit" if editable else "view",
|
||||
"lang": "zh-CN",
|
||||
"callbackUrl": callback_url,
|
||||
"user": {
|
||||
"id": current_user.username,
|
||||
"name": current_user.name,
|
||||
},
|
||||
"customization": {
|
||||
"compactHeader": True,
|
||||
"compactToolbar": False,
|
||||
"toolbarNoTabs": False,
|
||||
"autosave": False,
|
||||
"forcesave": editable,
|
||||
},
|
||||
},
|
||||
"width": "100%",
|
||||
"height": "100%",
|
||||
}
|
||||
config["token"] = jwt.encode(config, onlyoffice_settings.jwt_secret, algorithm="HS256")
|
||||
return AgentAssetOnlyOfficeConfigRead(documentServerUrl=public_url, config=config)
|
||||
|
||||
def _ensure_preview_rule_spreadsheet(
|
||||
self,
|
||||
*,
|
||||
version: str | None = None,
|
||||
) -> tuple[str, RuleSpreadsheetMeta]:
|
||||
resolved_version = str(version or PREVIEW_RULE_CURRENT_VERSION).strip()
|
||||
if resolved_version not in PREVIEW_RULE_VERSION_FILENAMES:
|
||||
raise LookupError(f"版本 {resolved_version} 不存在")
|
||||
|
||||
file_name = PREVIEW_RULE_VERSION_FILENAMES[resolved_version]
|
||||
storage_key = (
|
||||
Path("rules")
|
||||
/ FINANCE_RULES_LIBRARY
|
||||
/ ".versions"
|
||||
/ PREVIEW_RULE_ASSET_ID
|
||||
/ resolved_version
|
||||
/ file_name
|
||||
).as_posix()
|
||||
try:
|
||||
file_path = self.spreadsheet_manager.resolve_storage_path(storage_key)
|
||||
except FileNotFoundError:
|
||||
file_path = None
|
||||
|
||||
if file_path is not None and file_path.exists():
|
||||
content = file_path.read_bytes()
|
||||
updated_at = datetime.fromtimestamp(file_path.stat().st_mtime, UTC).isoformat()
|
||||
return resolved_version, RuleSpreadsheetMeta(
|
||||
file_name=file_name,
|
||||
storage_key=storage_key,
|
||||
mime_type=SPREADSHEET_MIME_TYPE,
|
||||
size_bytes=file_path.stat().st_size,
|
||||
checksum=self._hash_bytes(content),
|
||||
updated_at=updated_at,
|
||||
updated_by="ONLYOFFICE 预览",
|
||||
source="preview",
|
||||
)
|
||||
|
||||
metadata = self.spreadsheet_manager.store_rule_library_spreadsheet_snapshot(
|
||||
library=FINANCE_RULES_LIBRARY,
|
||||
asset_id=PREVIEW_RULE_ASSET_ID,
|
||||
version=resolved_version,
|
||||
file_name=file_name,
|
||||
content=AgentAssetSpreadsheetManager.build_company_travel_rule_template(),
|
||||
actor_name="ONLYOFFICE 预览",
|
||||
source="preview",
|
||||
)
|
||||
return resolved_version, metadata
|
||||
|
||||
def _handle_preview_rule_spreadsheet_onlyoffice_callback(
|
||||
self,
|
||||
*,
|
||||
version: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
callback = self._parse_onlyoffice_callback(payload)
|
||||
if callback.status not in {2, 6} or not callback.download_url:
|
||||
return
|
||||
|
||||
resolved_version, metadata = self._ensure_preview_rule_spreadsheet(version=version)
|
||||
request = Request(
|
||||
callback.download_url,
|
||||
headers={"User-Agent": "x-financial-onlyoffice-agent-asset-preview"},
|
||||
)
|
||||
with urlopen(request, timeout=30) as response: # noqa: S310
|
||||
content = response.read()
|
||||
|
||||
if metadata.checksum and metadata.checksum == self._hash_bytes(content):
|
||||
return
|
||||
|
||||
actor_name = callback.users[0] if callback.users else "ONLYOFFICE"
|
||||
self.spreadsheet_manager.store_rule_library_spreadsheet_snapshot(
|
||||
library=FINANCE_RULES_LIBRARY,
|
||||
asset_id=PREVIEW_RULE_ASSET_ID,
|
||||
version=resolved_version,
|
||||
file_name=metadata.file_name,
|
||||
content=content,
|
||||
actor_name=actor_name,
|
||||
source="onlyoffice-preview",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _read_current_rule_document_meta(asset: AgentAsset) -> RuleSpreadsheetMeta | None:
|
||||
payload = (asset.config_json or {}).get("rule_document")
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
return RuleSpreadsheetMeta(
|
||||
file_name=str(payload.get("file_name") or "").strip(),
|
||||
storage_key=str(payload.get("storage_key") or "").strip(),
|
||||
mime_type=(
|
||||
str(payload.get("mime_type") or "").strip()
|
||||
or "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
),
|
||||
size_bytes=int(payload.get("size_bytes") or 0),
|
||||
checksum=str(payload.get("checksum") or "").strip(),
|
||||
updated_at=str(payload.get("updated_at") or "").strip(),
|
||||
updated_by=str(payload.get("updated_by") or "system").strip() or "system",
|
||||
source=str(payload.get("source") or "upload").strip() or "upload",
|
||||
)
|
||||
Reference in New Issue
Block a user