2026-05-22 10:42:31 +08:00
|
|
|
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
|
2026-05-25 13:35:39 +08:00
|
|
|
from app.models.agent_asset import AgentAsset
|
|
|
|
|
from app.schemas.agent_asset import AgentAssetOnlyOfficeConfigRead, AgentAssetRead
|
2026-05-22 10:42:31 +08:00
|
|
|
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():
|
2026-05-25 13:35:39 +08:00
|
|
|
return resolve_onlyoffice_settings()
|
2026-05-22 10:42:31 +08:00
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
)
|