1671 lines
66 KiB
Python
1671 lines
66 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
from collections import defaultdict
|
||
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 sqlalchemy.orm import Session
|
||
|
||
from app.api.deps import CurrentUserContext
|
||
from app.core.agent_enums import (
|
||
AgentAssetContentType,
|
||
AgentAssetStatus,
|
||
AgentAssetType,
|
||
AgentReviewStatus,
|
||
)
|
||
from app.core.config import get_settings
|
||
from app.core.logging import get_logger
|
||
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
|
||
from app.repositories.agent_asset import AgentAssetRepository
|
||
from app.schemas.agent_asset import (
|
||
AgentAssetCreate,
|
||
AgentAssetListItem,
|
||
AgentAssetOnlyOfficeConfigRead,
|
||
AgentAssetRead,
|
||
AgentAssetReviewCreate,
|
||
AgentAssetReviewRead,
|
||
AgentAssetRuleJsonRead,
|
||
AgentAssetRuleJsonWrite,
|
||
AgentAssetSpreadsheetChangeRecordRead,
|
||
AgentAssetSpreadsheetDiffCellRead,
|
||
AgentAssetSpreadsheetDiffSheetRead,
|
||
AgentAssetUpdate,
|
||
AgentAssetVersionCreate,
|
||
AgentAssetVersionRead,
|
||
AgentAssetVersionTimelineItemRead,
|
||
)
|
||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||
from app.services.agent_asset_spreadsheet import (
|
||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||
FINANCE_RULES_LIBRARY,
|
||
RISK_RULES_LIBRARY,
|
||
RULE_LIBRARY_NAMES,
|
||
SPREADSHEET_MIME_TYPE,
|
||
AgentAssetSpreadsheetManager,
|
||
RuleSpreadsheetMeta,
|
||
)
|
||
from app.services.agent_foundation import AgentFoundationService
|
||
from app.services.audit import AuditLogService
|
||
from app.services.settings import resolve_onlyoffice_settings
|
||
|
||
logger = get_logger("app.services.agent_assets")
|
||
|
||
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 AgentAssetService:
|
||
def __init__(self, db: Session) -> None:
|
||
self.db = db
|
||
self.repository = AgentAssetRepository(db)
|
||
self.audit_service = AuditLogService(db)
|
||
self.spreadsheet_manager = AgentAssetSpreadsheetManager()
|
||
self.rule_library_manager = AgentAssetRuleLibraryManager()
|
||
|
||
def list_assets(
|
||
self,
|
||
*,
|
||
asset_type: str | None = None,
|
||
status: str | None = None,
|
||
domain: str | None = None,
|
||
keyword: str | None = None,
|
||
) -> list[AgentAssetListItem]:
|
||
self._ensure_ready()
|
||
if asset_type in {None, "", AgentAssetType.RULE.value}:
|
||
self.sync_platform_risk_rules_from_library()
|
||
assets = self.repository.list(
|
||
asset_type=asset_type, status=status, domain=domain, keyword=keyword
|
||
)
|
||
version_stats = self._collect_version_stats(assets)
|
||
return [
|
||
self._serialize_list_item(asset, version_stats.get(asset.id))
|
||
for asset in assets
|
||
]
|
||
|
||
def get_asset(self, asset_id: str) -> AgentAssetRead | None:
|
||
self._ensure_ready()
|
||
asset = self.repository.get(asset_id)
|
||
if asset is None:
|
||
return None
|
||
|
||
working_version = self._resolve_working_version(asset)
|
||
recent_versions = self._sort_versions(
|
||
self.repository.list_versions(asset_id, limit=5),
|
||
working_version,
|
||
)
|
||
latest_review = (
|
||
self.repository.get_review(asset_id, working_version)
|
||
if working_version
|
||
else next(iter(self.repository.list_reviews(asset_id, limit=1)), None)
|
||
)
|
||
current_version = (
|
||
self.repository.get_version(asset_id, working_version)
|
||
if working_version
|
||
else None
|
||
)
|
||
version_stats = self._collect_version_stats([asset]).get(asset.id)
|
||
return AgentAssetRead(
|
||
**self._serialize_list_item(asset, version_stats).model_dump(),
|
||
current_version_content=self._deserialize_content(current_version)
|
||
if current_version
|
||
else None,
|
||
current_version_content_type=current_version.content_type if current_version else None,
|
||
current_version_change_note=current_version.change_note if current_version else None,
|
||
recent_versions=[
|
||
self._serialize_version(item, asset) for item in recent_versions
|
||
],
|
||
latest_review=AgentAssetReviewRead.model_validate(latest_review)
|
||
if latest_review
|
||
else None,
|
||
)
|
||
|
||
def create_asset(
|
||
self,
|
||
payload: AgentAssetCreate,
|
||
*,
|
||
actor: str,
|
||
request_id: str | None = None,
|
||
) -> AgentAssetRead:
|
||
self._ensure_ready()
|
||
if self.repository.get_by_code(payload.code):
|
||
raise ValueError(f"资产编码 {payload.code} 已存在")
|
||
if payload.status == AgentAssetStatus.ACTIVE:
|
||
raise ValueError("请先创建资产并完成审核,再通过上线接口激活。")
|
||
|
||
asset = AgentAsset(
|
||
asset_type=payload.asset_type.value,
|
||
code=payload.code,
|
||
name=payload.name,
|
||
description=payload.description,
|
||
domain=payload.domain.value,
|
||
scenario_json=payload.scenario_json,
|
||
owner=payload.owner,
|
||
reviewer=payload.reviewer,
|
||
status=payload.status.value,
|
||
config_json=payload.config_json,
|
||
)
|
||
created = self.repository.create_asset(asset)
|
||
self.audit_service.log_action(
|
||
actor=actor,
|
||
action="create_agent_asset",
|
||
resource_type=created.asset_type,
|
||
resource_id=created.id,
|
||
before_json=None,
|
||
after_json=self._asset_snapshot(created),
|
||
request_id=request_id,
|
||
)
|
||
logger.info("Created agent asset id=%s code=%s", created.id, created.code)
|
||
return self.get_asset(created.id) # type: ignore[return-value]
|
||
|
||
def update_asset(
|
||
self,
|
||
asset_id: str,
|
||
payload: AgentAssetUpdate,
|
||
*,
|
||
actor: str,
|
||
request_id: str | None = None,
|
||
) -> AgentAssetRead:
|
||
self._ensure_ready()
|
||
asset = self.repository.get(asset_id)
|
||
if asset is None:
|
||
raise LookupError("Asset not found")
|
||
|
||
before = self._asset_snapshot(asset)
|
||
|
||
if payload.status == AgentAssetStatus.ACTIVE:
|
||
raise ValueError("请使用上线接口激活资产。")
|
||
|
||
for field_name in (
|
||
"name",
|
||
"description",
|
||
"owner",
|
||
"reviewer",
|
||
"current_version",
|
||
"published_version",
|
||
"working_version",
|
||
"config_json",
|
||
"scenario_json",
|
||
):
|
||
value = getattr(payload, field_name)
|
||
if value is not None:
|
||
setattr(asset, field_name, value)
|
||
|
||
if payload.domain is not None:
|
||
asset.domain = payload.domain.value
|
||
if payload.status is not None:
|
||
asset.status = payload.status.value
|
||
if payload.current_version is not None and not self.repository.get_version(
|
||
asset_id, payload.current_version
|
||
):
|
||
raise LookupError(f"版本 {payload.current_version} 不存在")
|
||
if payload.published_version is not None and not self.repository.get_version(
|
||
asset_id, payload.published_version
|
||
):
|
||
raise LookupError(f"版本 {payload.published_version} 不存在")
|
||
if payload.working_version is not None and not self.repository.get_version(
|
||
asset_id, payload.working_version
|
||
):
|
||
raise LookupError(f"版本 {payload.working_version} 不存在")
|
||
if payload.current_version is not None and payload.working_version is None:
|
||
asset.working_version = payload.current_version
|
||
if payload.working_version is not None:
|
||
asset.current_version = payload.working_version
|
||
|
||
updated = self.repository.save_asset(asset)
|
||
self.audit_service.log_action(
|
||
actor=actor,
|
||
action="update_agent_asset",
|
||
resource_type=updated.asset_type,
|
||
resource_id=updated.id,
|
||
before_json=before,
|
||
after_json=self._asset_snapshot(updated),
|
||
request_id=request_id,
|
||
)
|
||
logger.info("Updated agent asset id=%s code=%s", updated.id, updated.code)
|
||
return self.get_asset(updated.id) # type: ignore[return-value]
|
||
|
||
def list_versions(self, asset_id: str, *, limit: int = 20) -> list[AgentAssetVersionRead]:
|
||
self._ensure_ready()
|
||
asset = self.repository.get(asset_id)
|
||
if asset is None:
|
||
raise LookupError("Asset not found")
|
||
versions = self._sort_versions(
|
||
self.repository.list_versions(asset_id, limit=limit),
|
||
self._resolve_working_version(asset),
|
||
)
|
||
return [self._serialize_version(item, asset) for item in versions]
|
||
|
||
def create_version(
|
||
self,
|
||
asset_id: str,
|
||
payload: AgentAssetVersionCreate,
|
||
*,
|
||
actor: str,
|
||
request_id: str | None = None,
|
||
) -> AgentAssetVersionRead:
|
||
self._ensure_ready()
|
||
asset = self.repository.get(asset_id)
|
||
if asset is None:
|
||
raise LookupError("Asset not found")
|
||
if self.repository.get_version(asset_id, payload.version):
|
||
raise ValueError(f"版本号 {payload.version} 已存在")
|
||
|
||
self._validate_version_payload(asset, payload)
|
||
serialized_content = self._serialize_content(payload.content, payload.content_type.value)
|
||
version = AgentAssetVersion(
|
||
asset_id=asset_id,
|
||
version=payload.version,
|
||
content=serialized_content,
|
||
content_type=payload.content_type.value,
|
||
change_note=payload.change_note,
|
||
created_by=payload.created_by,
|
||
)
|
||
created = self.repository.create_version(version)
|
||
|
||
before = self._asset_snapshot(asset)
|
||
asset.working_version = payload.version
|
||
asset.current_version = payload.version
|
||
updated_asset = self.repository.save_asset(asset)
|
||
|
||
self.audit_service.log_action(
|
||
actor=actor,
|
||
action="save_agent_asset_version",
|
||
resource_type=updated_asset.asset_type,
|
||
resource_id=updated_asset.id,
|
||
before_json=before,
|
||
after_json={
|
||
"current_version": updated_asset.current_version,
|
||
"working_version": updated_asset.working_version,
|
||
"published_version": updated_asset.published_version,
|
||
"status": updated_asset.status,
|
||
},
|
||
request_id=request_id,
|
||
)
|
||
logger.info("Created agent asset version asset_id=%s version=%s", asset_id, payload.version)
|
||
return self._serialize_version(created, updated_asset)
|
||
|
||
def create_review(
|
||
self,
|
||
asset_id: str,
|
||
payload: AgentAssetReviewCreate,
|
||
*,
|
||
actor: str,
|
||
request_id: str | None = None,
|
||
) -> AgentAssetReviewRead:
|
||
self._ensure_ready()
|
||
asset = self.repository.get(asset_id)
|
||
if asset is None:
|
||
raise LookupError("Asset not found")
|
||
if (
|
||
asset.asset_type == AgentAssetType.RULE.value
|
||
and payload.review_status == AgentReviewStatus.PENDING
|
||
and payload.version != self._resolve_working_version(asset)
|
||
):
|
||
if self.repository.get_version(asset_id, payload.version) is not None:
|
||
raise ValueError(f"版本 {payload.version} 已存在,不能重复送审。")
|
||
asset = self._create_named_working_copy_for_review(
|
||
asset,
|
||
target_version=payload.version,
|
||
actor=actor,
|
||
request_id=request_id,
|
||
)
|
||
if self.repository.get_version(asset_id, payload.version) is None:
|
||
raise LookupError(f"版本 {payload.version} 不存在")
|
||
if asset.asset_type == AgentAssetType.RULE.value:
|
||
working_version = self._resolve_working_version(asset)
|
||
if payload.version != working_version:
|
||
raise ValueError("只能对当前工作版本发起审核。")
|
||
|
||
review = AgentAssetReview(
|
||
asset_id=asset_id,
|
||
version=payload.version,
|
||
reviewer=payload.reviewer,
|
||
review_status=payload.review_status.value,
|
||
review_note=payload.review_note,
|
||
reviewed_at=None
|
||
if payload.review_status == AgentReviewStatus.PENDING
|
||
else datetime.now(UTC),
|
||
)
|
||
created = self.repository.create_review(review)
|
||
|
||
before = self._asset_snapshot(asset)
|
||
asset.reviewer = payload.reviewer
|
||
if payload.review_status == AgentReviewStatus.PENDING:
|
||
if not asset.published_version:
|
||
asset.status = AgentAssetStatus.REVIEW.value
|
||
elif payload.review_status == AgentReviewStatus.REJECTED:
|
||
if not asset.published_version:
|
||
asset.status = AgentAssetStatus.DRAFT.value
|
||
elif not asset.published_version:
|
||
asset.status = AgentAssetStatus.REVIEW.value
|
||
self.repository.save_asset(asset)
|
||
|
||
self.audit_service.log_action(
|
||
actor=actor,
|
||
action="review_agent_asset",
|
||
resource_type=asset.asset_type,
|
||
resource_id=asset.id,
|
||
before_json=before,
|
||
after_json={
|
||
"review_version": payload.version,
|
||
"review_status": payload.review_status.value,
|
||
"asset_status": asset.status,
|
||
},
|
||
request_id=request_id,
|
||
)
|
||
logger.info(
|
||
"Created review asset_id=%s version=%s status=%s",
|
||
asset_id,
|
||
payload.version,
|
||
payload.review_status.value,
|
||
)
|
||
return AgentAssetReviewRead.model_validate(created)
|
||
|
||
def _create_named_working_copy_for_review(
|
||
self,
|
||
asset: AgentAsset,
|
||
*,
|
||
target_version: str,
|
||
actor: str,
|
||
request_id: str | None = None,
|
||
) -> AgentAsset:
|
||
working_version = self._resolve_working_version(asset)
|
||
if not working_version:
|
||
raise ValueError("当前规则尚未配置工作版本,无法提交审核。")
|
||
|
||
source = self.repository.get_version(asset.id, working_version)
|
||
if source is None:
|
||
raise LookupError(f"版本 {working_version} 不存在")
|
||
|
||
is_spreadsheet_rule = (
|
||
asset.asset_type == AgentAssetType.RULE.value
|
||
and str((asset.config_json or {}).get("detail_mode") or "").strip().lower()
|
||
== "spreadsheet"
|
||
)
|
||
if is_spreadsheet_rule:
|
||
_, metadata = self._resolve_spreadsheet_version_meta(asset, version=working_version)
|
||
file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key)
|
||
if not file_path.exists():
|
||
raise FileNotFoundError(metadata.file_name)
|
||
snapshot_meta = self.spreadsheet_manager.store_rule_library_spreadsheet_snapshot(
|
||
library=self._resolve_spreadsheet_rule_library(asset),
|
||
asset_id=asset.id,
|
||
version=target_version,
|
||
file_name=metadata.file_name,
|
||
content=file_path.read_bytes(),
|
||
actor_name=actor,
|
||
source="review-submit",
|
||
)
|
||
next_content = self.spreadsheet_manager.build_version_markdown(
|
||
rule_name=asset.name,
|
||
version=target_version,
|
||
metadata=snapshot_meta,
|
||
)
|
||
next_content_type = AgentAssetContentType.MARKDOWN
|
||
else:
|
||
next_content = self._deserialize_content(source)
|
||
next_content_type = AgentAssetContentType(source.content_type)
|
||
|
||
self.create_version(
|
||
asset.id,
|
||
AgentAssetVersionCreate(
|
||
version=target_version,
|
||
content=next_content,
|
||
content_type=next_content_type,
|
||
change_note=f"提交审核前固化工作稿为 {target_version}",
|
||
created_by=actor,
|
||
),
|
||
actor=actor,
|
||
request_id=request_id,
|
||
)
|
||
|
||
refreshed = self.repository.get(asset.id)
|
||
if refreshed is None:
|
||
raise LookupError("Asset not found")
|
||
|
||
if is_spreadsheet_rule:
|
||
config_json = dict(refreshed.config_json or {})
|
||
current_document_meta = self._read_current_rule_document_meta(refreshed)
|
||
if current_document_meta is not None:
|
||
rule_document = self.spreadsheet_manager.build_rule_document_config(
|
||
current_document_meta,
|
||
asset_version=target_version,
|
||
)
|
||
rule_document["storage_key"] = current_document_meta.storage_key
|
||
config_json["rule_document"] = rule_document
|
||
refreshed.config_json = config_json
|
||
self.repository.save_asset(refreshed)
|
||
|
||
return refreshed
|
||
|
||
def activate_asset(
|
||
self,
|
||
asset_id: str,
|
||
*,
|
||
actor: str,
|
||
request_id: str | None = None,
|
||
) -> AgentAssetRead:
|
||
self._ensure_ready()
|
||
asset = self.repository.get(asset_id)
|
||
if asset is None:
|
||
raise LookupError("Asset not found")
|
||
candidate_version = self._resolve_working_version(asset)
|
||
if not candidate_version:
|
||
raise ValueError("资产尚未设置工作版本,无法上线。")
|
||
|
||
if asset.asset_type == AgentAssetType.RULE.value:
|
||
review = self.repository.get_review(
|
||
asset.id, candidate_version, AgentReviewStatus.APPROVED.value
|
||
)
|
||
if review is None:
|
||
raise PermissionError("规则工作版本尚未审核通过,不能上线。")
|
||
|
||
before = self._asset_snapshot(asset)
|
||
asset.published_version = candidate_version
|
||
asset.status = AgentAssetStatus.ACTIVE.value
|
||
updated = self.repository.save_asset(asset)
|
||
self.audit_service.log_action(
|
||
actor=actor,
|
||
action="activate_agent_asset",
|
||
resource_type=updated.asset_type,
|
||
resource_id=updated.id,
|
||
before_json=before,
|
||
after_json=self._asset_snapshot(updated),
|
||
request_id=request_id,
|
||
)
|
||
logger.info("Activated agent asset id=%s code=%s", updated.id, updated.code)
|
||
return self.get_asset(updated.id) # type: ignore[return-value]
|
||
|
||
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 = 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",
|
||
)
|
||
|
||
def _ensure_ready(self) -> None:
|
||
AgentFoundationService(self.db).ensure_foundation_ready()
|
||
|
||
def sync_platform_risk_rules_from_library(self) -> int:
|
||
manifest_count = AgentFoundationService(self.db).sync_platform_risk_rules_from_library()
|
||
self.db.commit()
|
||
return manifest_count
|
||
|
||
def _validate_version_payload(
|
||
self, asset: AgentAsset, payload: AgentAssetVersionCreate
|
||
) -> None:
|
||
if (
|
||
asset.asset_type == AgentAssetType.RULE.value
|
||
and payload.content_type != AgentAssetContentType.MARKDOWN
|
||
):
|
||
raise ValueError("规则资产版本内容必须使用 markdown。")
|
||
if (
|
||
asset.asset_type != AgentAssetType.RULE.value
|
||
and payload.content_type != AgentAssetContentType.JSON
|
||
):
|
||
raise ValueError("技能、MCP、任务资产版本内容必须使用 json。")
|
||
if payload.content_type == AgentAssetContentType.MARKDOWN and not isinstance(
|
||
payload.content, str
|
||
):
|
||
raise ValueError("Markdown 内容必须是字符串。")
|
||
if payload.content_type == AgentAssetContentType.JSON and not isinstance(
|
||
payload.content, (dict, list)
|
||
):
|
||
raise ValueError("JSON 内容必须是对象或数组。")
|
||
|
||
def restore_version_as_working_copy(
|
||
self,
|
||
asset_id: str,
|
||
source_version: str,
|
||
*,
|
||
actor: str,
|
||
request_id: str | None = None,
|
||
) -> AgentAssetRead:
|
||
self._ensure_ready()
|
||
asset = self.repository.get(asset_id)
|
||
if asset is None:
|
||
raise LookupError("Asset not found")
|
||
|
||
source = self.repository.get_version(asset_id, source_version)
|
||
if source is None:
|
||
raise LookupError(f"版本 {source_version} 不存在")
|
||
|
||
if (
|
||
asset.asset_type == AgentAssetType.RULE.value
|
||
and str((asset.config_json or {}).get("detail_mode") or "").strip().lower()
|
||
== "spreadsheet"
|
||
):
|
||
metadata = self.spreadsheet_manager.parse_version_markdown(str(source.content or ""))
|
||
if metadata is None:
|
||
raise FileNotFoundError("历史规则表快照不存在,无法恢复。")
|
||
file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key)
|
||
if not file_path.exists():
|
||
raise FileNotFoundError(metadata.file_name)
|
||
restored = self.upload_rule_spreadsheet(
|
||
asset.id,
|
||
filename=metadata.file_name,
|
||
content=file_path.read_bytes(),
|
||
actor=actor,
|
||
request_id=request_id,
|
||
change_note=f"基于历史版本 {source_version} 恢复生成工作稿",
|
||
source="restore",
|
||
)
|
||
self.audit_service.log_action(
|
||
actor=actor,
|
||
action="restore_agent_asset_version",
|
||
resource_type=asset.asset_type,
|
||
resource_id=asset.id,
|
||
before_json={"source_version": source_version},
|
||
after_json={"working_version": restored.working_version},
|
||
request_id=request_id,
|
||
)
|
||
return restored
|
||
|
||
next_version = self._increment_version(self._resolve_working_version(asset))
|
||
self.create_version(
|
||
asset.id,
|
||
AgentAssetVersionCreate(
|
||
version=next_version,
|
||
content=self._deserialize_content(source),
|
||
content_type=AgentAssetContentType(source.content_type),
|
||
change_note=f"基于历史版本 {source_version} 恢复生成工作稿",
|
||
created_by=actor,
|
||
),
|
||
actor=actor,
|
||
request_id=request_id,
|
||
)
|
||
restored = self.get_asset(asset.id)
|
||
self.audit_service.log_action(
|
||
actor=actor,
|
||
action="restore_agent_asset_version",
|
||
resource_type=asset.asset_type,
|
||
resource_id=asset.id,
|
||
before_json={"source_version": source_version},
|
||
after_json={"working_version": next_version},
|
||
request_id=request_id,
|
||
)
|
||
return restored # type: ignore[return-value]
|
||
|
||
def list_version_timeline(self, asset_id: str) -> list[AgentAssetVersionTimelineItemRead]:
|
||
self._ensure_ready()
|
||
asset = self.repository.get(asset_id)
|
||
if asset is None:
|
||
raise LookupError("Asset not found")
|
||
|
||
events: list[AgentAssetVersionTimelineItemRead] = []
|
||
versions = self.repository.list_versions(asset_id)
|
||
for version in versions:
|
||
source_version = self._extract_restore_source_version(version.change_note)
|
||
events.append(
|
||
AgentAssetVersionTimelineItemRead(
|
||
event_type="restored" if source_version else "created",
|
||
version=version.version,
|
||
actor=version.created_by,
|
||
event_time=version.created_at,
|
||
title="恢复生成工作稿" if source_version else "创建工作版本",
|
||
description=version.change_note or "生成新版本",
|
||
note=version.change_note,
|
||
source_version=source_version,
|
||
)
|
||
)
|
||
|
||
for review in self.repository.list_reviews(asset_id):
|
||
event_type = {
|
||
AgentReviewStatus.PENDING.value: "submitted",
|
||
AgentReviewStatus.APPROVED.value: "approved",
|
||
AgentReviewStatus.REJECTED.value: "rejected",
|
||
}.get(review.review_status, "reviewed")
|
||
title = {
|
||
"submitted": "提交审核",
|
||
"approved": "审核通过",
|
||
"rejected": "审核驳回",
|
||
}.get(event_type, "审核处理")
|
||
events.append(
|
||
AgentAssetVersionTimelineItemRead(
|
||
event_type=event_type,
|
||
version=review.version,
|
||
actor=review.reviewer,
|
||
event_time=review.reviewed_at or review.created_at,
|
||
title=title,
|
||
description=review.review_note or "",
|
||
note=review.review_note,
|
||
)
|
||
)
|
||
|
||
audit_logs = self.audit_service.repository.list(
|
||
resource_type=asset.asset_type,
|
||
resource_id=asset.id,
|
||
limit=200,
|
||
)
|
||
for log in audit_logs:
|
||
if log.action != "activate_agent_asset":
|
||
continue
|
||
after_json = log.after_json or {}
|
||
version = str(
|
||
after_json.get("published_version")
|
||
or after_json.get("current_version")
|
||
or ""
|
||
).strip()
|
||
if not version:
|
||
continue
|
||
events.append(
|
||
AgentAssetVersionTimelineItemRead(
|
||
event_type="published",
|
||
version=version,
|
||
actor=log.actor,
|
||
event_time=log.created_at,
|
||
title="正式上线",
|
||
description="该版本已切换为线上正式版本。",
|
||
)
|
||
)
|
||
|
||
return sorted(events, key=lambda item: item.event_time)
|
||
|
||
def list_spreadsheet_change_records(
|
||
self,
|
||
asset_id: str,
|
||
*,
|
||
limit: int = 30,
|
||
) -> list[AgentAssetSpreadsheetChangeRecordRead]:
|
||
self._ensure_ready()
|
||
asset = self._require_spreadsheet_rule(asset_id)
|
||
logs = self.audit_service.repository.list(
|
||
resource_type=asset.asset_type,
|
||
resource_id=asset.id,
|
||
action="edit_rule_spreadsheet",
|
||
limit=min(max(limit, 1), 30),
|
||
)
|
||
return [
|
||
AgentAssetSpreadsheetChangeRecordRead(
|
||
id=log.id,
|
||
actor=log.actor,
|
||
changed_at=log.created_at,
|
||
summary=str((log.after_json or {}).get("summary") or "表格内容已保存。"),
|
||
sheet_changes=[
|
||
AgentAssetSpreadsheetDiffSheetRead.model_validate(item)
|
||
for item in ((log.after_json or {}).get("sheet_changes") or [])
|
||
],
|
||
cell_changes=[
|
||
AgentAssetSpreadsheetDiffCellRead.model_validate(item)
|
||
for item in ((log.after_json or {}).get("cell_changes") or [])
|
||
],
|
||
changed_sheet_count=int((log.after_json or {}).get("changed_sheet_count") or 0),
|
||
changed_cell_count=int((log.after_json or {}).get("changed_cell_count") or 0),
|
||
)
|
||
for log in logs
|
||
]
|
||
|
||
def _serialize_version(
|
||
self, version: AgentAssetVersion, asset: AgentAsset
|
||
) -> AgentAssetVersionRead:
|
||
latest_review = self.repository.get_review(asset.id, version.version)
|
||
working_version = self._resolve_working_version(asset)
|
||
published_version = self._resolve_published_version(asset)
|
||
return AgentAssetVersionRead(
|
||
id=version.id,
|
||
asset_id=version.asset_id,
|
||
version=version.version,
|
||
content=self._deserialize_content(version),
|
||
content_type=version.content_type,
|
||
change_note=version.change_note,
|
||
created_by=version.created_by,
|
||
created_at=version.created_at,
|
||
is_current=version.version == working_version,
|
||
is_published=version.version == published_version,
|
||
is_working=version.version == working_version,
|
||
lifecycle_state=self._resolve_version_lifecycle_state(
|
||
version.version,
|
||
working_version=working_version,
|
||
published_version=published_version,
|
||
latest_review_status=latest_review.review_status if latest_review else "",
|
||
),
|
||
)
|
||
|
||
def _collect_version_stats(
|
||
self, assets: list[AgentAsset]
|
||
) -> dict[str, dict[str, int | str | None]]:
|
||
asset_ids = [item.id for item in assets]
|
||
versions = self.repository.list_versions_for_assets(asset_ids)
|
||
spreadsheet_logs = self.audit_service.repository.list_for_resources(
|
||
resource_type=AgentAssetType.RULE.value,
|
||
resource_ids=[
|
||
item.id
|
||
for item in assets
|
||
if item.asset_type == AgentAssetType.RULE.value
|
||
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
|
||
== "spreadsheet"
|
||
],
|
||
action="edit_rule_spreadsheet",
|
||
)
|
||
working_versions = {
|
||
item.id: self._resolve_working_version(item) for item in assets
|
||
}
|
||
version_counts: dict[str, int] = defaultdict(int)
|
||
modified_by: dict[str, str | None] = {item.id: None for item in assets}
|
||
spreadsheet_edit_counts: dict[str, int] = defaultdict(int)
|
||
spreadsheet_last_actor: dict[str, str | None] = {}
|
||
spreadsheet_last_changed_at: dict[str, datetime] = {}
|
||
|
||
for version in versions:
|
||
version_counts[version.asset_id] += 1
|
||
if (
|
||
modified_by.get(version.asset_id) is None
|
||
and version.version == working_versions.get(version.asset_id)
|
||
):
|
||
modified_by[version.asset_id] = version.created_by
|
||
|
||
for log in spreadsheet_logs:
|
||
spreadsheet_edit_counts[log.resource_id] += 1
|
||
last_changed_at = spreadsheet_last_changed_at.get(log.resource_id)
|
||
if last_changed_at is None or log.created_at >= last_changed_at:
|
||
spreadsheet_last_changed_at[log.resource_id] = log.created_at
|
||
spreadsheet_last_actor[log.resource_id] = log.actor
|
||
|
||
return {
|
||
item.id: {
|
||
"change_count": (
|
||
spreadsheet_edit_counts.get(item.id, 0)
|
||
if item.asset_type == AgentAssetType.RULE.value
|
||
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
|
||
== "spreadsheet"
|
||
and spreadsheet_edit_counts.get(item.id, 0) > 0
|
||
else max(version_counts.get(item.id, 0) - 1, 0)
|
||
),
|
||
"modified_by": (
|
||
spreadsheet_last_actor.get(item.id)
|
||
if item.asset_type == AgentAssetType.RULE.value
|
||
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
|
||
== "spreadsheet"
|
||
and spreadsheet_last_actor.get(item.id)
|
||
else modified_by.get(item.id)
|
||
),
|
||
}
|
||
for item in assets
|
||
}
|
||
|
||
@staticmethod
|
||
def _serialize_list_item(
|
||
asset: AgentAsset,
|
||
version_stats: dict[str, int | str | None] | None = None,
|
||
) -> AgentAssetListItem:
|
||
payload = AgentAssetListItem.model_validate(asset).model_dump()
|
||
payload["change_count"] = int((version_stats or {}).get("change_count") or 0)
|
||
payload["modified_by"] = (
|
||
str((version_stats or {}).get("modified_by") or "").strip() or None
|
||
)
|
||
return AgentAssetListItem.model_validate(payload)
|
||
|
||
@staticmethod
|
||
def _sort_versions(
|
||
versions: list[AgentAssetVersion], current_version: str | None
|
||
) -> list[AgentAssetVersion]:
|
||
return sorted(
|
||
versions,
|
||
key=lambda item: (item.version == current_version, item.created_at),
|
||
reverse=True,
|
||
)
|
||
|
||
@staticmethod
|
||
def _serialize_content(content: Any, content_type: str) -> str:
|
||
if content_type == AgentAssetContentType.MARKDOWN.value:
|
||
return str(content)
|
||
return json.dumps(content, ensure_ascii=False, sort_keys=True, indent=2)
|
||
|
||
@staticmethod
|
||
def _deserialize_content(version: AgentAssetVersion | None) -> Any:
|
||
if version is None:
|
||
return None
|
||
if version.content_type == AgentAssetContentType.MARKDOWN.value:
|
||
return version.content
|
||
return json.loads(version.content)
|
||
|
||
def _require_spreadsheet_rule(self, asset_id: str) -> AgentAsset:
|
||
asset = self.repository.get(asset_id)
|
||
if asset is None:
|
||
raise LookupError("Asset not found")
|
||
if asset.asset_type != AgentAssetType.RULE.value:
|
||
raise ValueError("仅规则资产支持 Excel 规则表。")
|
||
detail_mode = str((asset.config_json or {}).get("detail_mode") or "").strip().lower()
|
||
if detail_mode != "spreadsheet":
|
||
raise ValueError("当前规则未配置 Excel 规则表。")
|
||
return asset
|
||
|
||
def _resolve_spreadsheet_version_meta(
|
||
self,
|
||
asset: AgentAsset,
|
||
*,
|
||
version: str | None = None,
|
||
) -> tuple[str, RuleSpreadsheetMeta]:
|
||
resolved_version = str(version or self._resolve_working_version(asset) or "").strip()
|
||
if not resolved_version:
|
||
raise ValueError("当前规则尚未配置表格版本。")
|
||
|
||
version_row = self.repository.get_version(asset.id, resolved_version)
|
||
if version_row is None:
|
||
raise LookupError(f"版本 {resolved_version} 不存在")
|
||
|
||
# 版本记录中的快照才是不变的事实来源。`/rules` 下的工作簿只是当前
|
||
# 可编辑副本,后续写入不应该反向污染某个已存在版本的内容。
|
||
metadata = self.spreadsheet_manager.parse_version_markdown(str(version_row.content or ""))
|
||
if metadata is None and self._resolve_working_version(asset) == resolved_version:
|
||
metadata = self._read_current_rule_document_meta(asset)
|
||
if metadata is None:
|
||
raise FileNotFoundError("规则表版本快照不存在。")
|
||
return resolved_version, metadata
|
||
|
||
def _resolve_current_spreadsheet_meta(
|
||
self,
|
||
asset: AgentAsset,
|
||
) -> tuple[str, RuleSpreadsheetMeta]:
|
||
config_json = dict(asset.config_json or {})
|
||
current_meta = self._read_current_rule_document_meta(asset)
|
||
file_name = (
|
||
current_meta.file_name
|
||
if current_meta is not None and current_meta.file_name
|
||
else self._resolve_default_spreadsheet_file_name(asset)
|
||
)
|
||
library = self._resolve_spreadsheet_rule_library(asset)
|
||
storage_key = (Path("rules") / library / file_name).as_posix()
|
||
file_path = self.spreadsheet_manager.resolve_storage_path(storage_key)
|
||
|
||
if not file_path.exists():
|
||
content: bytes | None = None
|
||
if current_meta is not None and current_meta.storage_key:
|
||
try:
|
||
legacy_path = self.spreadsheet_manager.resolve_storage_path(
|
||
current_meta.storage_key
|
||
)
|
||
except FileNotFoundError:
|
||
legacy_path = None
|
||
if legacy_path is not None and legacy_path.exists():
|
||
content = legacy_path.read_bytes()
|
||
if content is None:
|
||
content = AgentAssetSpreadsheetManager.build_blank_rule_workbook(
|
||
Path(file_name).stem or "规则表"
|
||
)
|
||
meta = self.spreadsheet_manager.store_rule_library_spreadsheet(
|
||
library=library,
|
||
file_name=file_name,
|
||
content=content,
|
||
actor_name=(
|
||
current_meta.updated_by
|
||
if current_meta is not None and current_meta.updated_by
|
||
else "system"
|
||
),
|
||
source="current-rule",
|
||
)
|
||
else:
|
||
content = file_path.read_bytes()
|
||
meta = RuleSpreadsheetMeta(
|
||
file_name=file_name,
|
||
storage_key=storage_key,
|
||
mime_type=(
|
||
current_meta.mime_type
|
||
if current_meta is not None and current_meta.mime_type
|
||
else SPREADSHEET_MIME_TYPE
|
||
),
|
||
size_bytes=file_path.stat().st_size,
|
||
checksum=self._hash_bytes(content),
|
||
updated_at=datetime.fromtimestamp(file_path.stat().st_mtime, UTC).isoformat(),
|
||
updated_by=(
|
||
current_meta.updated_by
|
||
if current_meta is not None and current_meta.updated_by
|
||
else "system"
|
||
),
|
||
source=(
|
||
current_meta.source
|
||
if current_meta is not None and current_meta.source
|
||
else "current-rule"
|
||
),
|
||
)
|
||
|
||
expected_document = {
|
||
**self.spreadsheet_manager.build_rule_document_config(
|
||
meta,
|
||
asset_version="current",
|
||
),
|
||
"storage_key": meta.storage_key,
|
||
}
|
||
if config_json.get("rule_document") != expected_document:
|
||
config_json["detail_mode"] = "spreadsheet"
|
||
config_json["tag"] = str(config_json.get("tag") or "财务规则").strip() or "财务规则"
|
||
config_json["rule_library"] = library
|
||
config_json["rule_document"] = expected_document
|
||
asset.config_json = config_json
|
||
self.repository.save_asset(asset)
|
||
|
||
return "current", meta
|
||
|
||
def _store_current_rule_spreadsheet(
|
||
self,
|
||
asset: AgentAsset,
|
||
*,
|
||
file_name: str,
|
||
content: bytes,
|
||
actor: str,
|
||
source: str,
|
||
) -> RuleSpreadsheetMeta:
|
||
library = self._resolve_spreadsheet_rule_library(asset)
|
||
metadata = self.spreadsheet_manager.store_rule_library_spreadsheet(
|
||
library=library,
|
||
file_name=file_name,
|
||
content=content,
|
||
actor_name=actor,
|
||
source=source,
|
||
)
|
||
config_json = dict(asset.config_json or {})
|
||
config_json["detail_mode"] = "spreadsheet"
|
||
config_json["tag"] = str(config_json.get("tag") or "财务规则").strip() or "财务规则"
|
||
config_json["rule_library"] = library
|
||
config_json["rule_document"] = {
|
||
**self.spreadsheet_manager.build_rule_document_config(
|
||
metadata,
|
||
asset_version="current",
|
||
),
|
||
"storage_key": metadata.storage_key,
|
||
}
|
||
asset.config_json = config_json
|
||
self.repository.save_asset(asset)
|
||
return metadata
|
||
|
||
@staticmethod
|
||
def _resolve_spreadsheet_rule_library(asset: AgentAsset) -> str:
|
||
config_json = dict(asset.config_json or {})
|
||
library = str(config_json.get("rule_library") or FINANCE_RULES_LIBRARY).strip()
|
||
if library not in RULE_LIBRARY_NAMES:
|
||
return FINANCE_RULES_LIBRARY
|
||
return library
|
||
|
||
@staticmethod
|
||
def _resolve_default_spreadsheet_file_name(asset: AgentAsset) -> str:
|
||
if asset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE:
|
||
return COMPANY_TRAVEL_EXPENSE_RULE_FILENAME
|
||
if asset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE:
|
||
return COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME
|
||
fallback = Path(str(asset.name or "规则表").strip()).name
|
||
return fallback if fallback.lower().endswith(".xlsx") else f"{fallback}.xlsx"
|
||
|
||
def _build_onlyoffice_spreadsheet_config(
|
||
self,
|
||
*,
|
||
asset_id: str,
|
||
current_user: CurrentUserContext,
|
||
metadata: RuleSpreadsheetMeta,
|
||
editable: bool,
|
||
) -> AgentAssetOnlyOfficeConfigRead:
|
||
onlyoffice_settings = 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",
|
||
)
|
||
|
||
@staticmethod
|
||
def _increment_version(version: str | None) -> str:
|
||
normalized = str(version or "").strip().removeprefix("v")
|
||
parts = normalized.split(".")
|
||
if len(parts) != 3 or not all(item.isdigit() for item in parts):
|
||
return "v1.0.0"
|
||
major, minor, patch = [int(item) for item in parts]
|
||
return f"v{major}.{minor}.{patch + 1}"
|
||
|
||
@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
|
||
)
|
||
|
||
@staticmethod
|
||
def _build_onlyoffice_access_token(asset_id: str) -> str:
|
||
onlyoffice_settings = 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()],
|
||
)
|
||
|
||
@staticmethod
|
||
def _hash_bytes(content: bytes) -> str:
|
||
import hashlib
|
||
|
||
return hashlib.sha256(content).hexdigest()
|
||
|
||
@staticmethod
|
||
def _asset_snapshot(asset: AgentAsset) -> dict[str, Any]:
|
||
return {
|
||
"asset_type": asset.asset_type,
|
||
"code": asset.code,
|
||
"name": asset.name,
|
||
"status": asset.status,
|
||
"current_version": asset.current_version,
|
||
"published_version": asset.published_version,
|
||
"working_version": asset.working_version,
|
||
"domain": asset.domain,
|
||
"owner": asset.owner,
|
||
"reviewer": asset.reviewer,
|
||
}
|
||
|
||
@staticmethod
|
||
def _resolve_working_version(asset: AgentAsset) -> str:
|
||
return str(asset.working_version or asset.current_version or "").strip()
|
||
|
||
@staticmethod
|
||
def _resolve_published_version(asset: AgentAsset) -> str:
|
||
return str(asset.published_version or "").strip()
|
||
|
||
@staticmethod
|
||
def _resolve_version_lifecycle_state(
|
||
version: str,
|
||
*,
|
||
working_version: str,
|
||
published_version: str,
|
||
latest_review_status: str,
|
||
) -> str:
|
||
if version == published_version:
|
||
return "published"
|
||
if version != working_version:
|
||
return "history"
|
||
if latest_review_status == AgentReviewStatus.PENDING.value:
|
||
return "pending_review"
|
||
if latest_review_status == AgentReviewStatus.APPROVED.value:
|
||
return "approved"
|
||
if latest_review_status == AgentReviewStatus.REJECTED.value:
|
||
return "rejected"
|
||
return "draft"
|
||
|
||
def _load_spreadsheet_for_compare(self, metadata: RuleSpreadsheetMeta):
|
||
from io import BytesIO
|
||
|
||
from openpyxl import load_workbook
|
||
|
||
file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key)
|
||
if not file_path.exists():
|
||
raise FileNotFoundError(metadata.file_name)
|
||
return load_workbook(BytesIO(file_path.read_bytes()), read_only=False, data_only=False)
|
||
|
||
def _collect_workbook_changes_from_content(
|
||
self,
|
||
base_metadata: RuleSpreadsheetMeta,
|
||
target_content: bytes,
|
||
) -> tuple[list[AgentAssetSpreadsheetDiffSheetRead], list[AgentAssetSpreadsheetDiffCellRead]]:
|
||
from io import BytesIO
|
||
|
||
from openpyxl import load_workbook
|
||
|
||
base_workbook = self._load_spreadsheet_for_compare(base_metadata)
|
||
target_workbook = load_workbook(BytesIO(target_content), read_only=False, data_only=False)
|
||
return self._collect_workbook_changes(base_workbook, target_workbook)
|
||
|
||
def _collect_workbook_changes(
|
||
self, base_workbook, target_workbook
|
||
) -> tuple[list[AgentAssetSpreadsheetDiffSheetRead], list[AgentAssetSpreadsheetDiffCellRead]]:
|
||
base_sheet_names = set(base_workbook.sheetnames)
|
||
target_sheet_names = set(target_workbook.sheetnames)
|
||
sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = []
|
||
for sheet_name in sorted(target_sheet_names - base_sheet_names):
|
||
sheet_changes.append(
|
||
AgentAssetSpreadsheetDiffSheetRead(sheet_name=sheet_name, change_type="added")
|
||
)
|
||
for sheet_name in sorted(base_sheet_names - target_sheet_names):
|
||
sheet_changes.append(
|
||
AgentAssetSpreadsheetDiffSheetRead(sheet_name=sheet_name, change_type="removed")
|
||
)
|
||
|
||
cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = []
|
||
|
||
for sheet_name in sorted(base_sheet_names & target_sheet_names):
|
||
base_sheet = base_workbook[sheet_name]
|
||
target_sheet = target_workbook[sheet_name]
|
||
max_row = max(base_sheet.max_row, target_sheet.max_row)
|
||
max_column = max(base_sheet.max_column, target_sheet.max_column)
|
||
for row_index in range(1, max_row + 1):
|
||
for column_index in range(1, max_column + 1):
|
||
before_value = base_sheet.cell(row=row_index, column=column_index).value
|
||
after_value = target_sheet.cell(row=row_index, column=column_index).value
|
||
if before_value == after_value:
|
||
continue
|
||
if before_value in (None, ""):
|
||
change_type = "added"
|
||
elif after_value in (None, ""):
|
||
change_type = "removed"
|
||
else:
|
||
change_type = "modified"
|
||
cell_changes.append(
|
||
AgentAssetSpreadsheetDiffCellRead(
|
||
sheet_name=sheet_name,
|
||
cell=target_sheet.cell(row=row_index, column=column_index).coordinate,
|
||
change_type=change_type,
|
||
before_value=before_value,
|
||
after_value=after_value,
|
||
)
|
||
)
|
||
|
||
for sheet_name in sorted({item.sheet_name for item in cell_changes}):
|
||
sheet_changes.append(
|
||
AgentAssetSpreadsheetDiffSheetRead(sheet_name=sheet_name, change_type="modified")
|
||
)
|
||
|
||
return sheet_changes, cell_changes
|
||
|
||
@staticmethod
|
||
def _count_changed_sheets(
|
||
sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead],
|
||
cell_changes: list[AgentAssetSpreadsheetDiffCellRead],
|
||
) -> int:
|
||
return len(
|
||
{item.sheet_name for item in sheet_changes}
|
||
| {item.sheet_name for item in cell_changes}
|
||
)
|
||
|
||
@staticmethod
|
||
def _build_spreadsheet_change_summary(
|
||
sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead],
|
||
cell_changes: list[AgentAssetSpreadsheetDiffCellRead],
|
||
) -> str:
|
||
sheet_names = sorted(
|
||
{item.sheet_name for item in sheet_changes}
|
||
| {item.sheet_name for item in cell_changes}
|
||
)
|
||
if not sheet_names:
|
||
return "文件内容已保存,未发现单元格级差异。"
|
||
|
||
preview = "、".join(sheet_names[:3])
|
||
if len(sheet_names) > 3:
|
||
preview = f"{preview} 等"
|
||
sheet_text = f"涉及 {len(sheet_names)} 个工作表({preview})"
|
||
if cell_changes:
|
||
return f"{sheet_text},共 {len(cell_changes)} 处单元格改动。"
|
||
return f"{sheet_text},工作表结构发生变化。"
|
||
|
||
def _next_available_version(self, asset: AgentAsset) -> str:
|
||
candidate = self._increment_version(self._resolve_working_version(asset))
|
||
while self.repository.get_version(asset.id, candidate) is not None:
|
||
candidate = self._increment_version(candidate)
|
||
return candidate
|
||
|
||
@staticmethod
|
||
def _extract_restore_source_version(change_note: str | None) -> str | None:
|
||
normalized = str(change_note or "").strip()
|
||
prefix = "基于历史版本 "
|
||
suffix = " 恢复生成工作稿"
|
||
if not normalized.startswith(prefix) or suffix not in normalized:
|
||
return None
|
||
return normalized.removeprefix(prefix).split(suffix, 1)[0].strip() or None
|
||
|
||
def _resolve_json_risk_rule_document(self, asset: AgentAsset) -> tuple[str, str]:
|
||
config_json = dict(asset.config_json or {})
|
||
detail_mode = str(config_json.get("detail_mode") or "").strip().lower()
|
||
if detail_mode != "json_risk":
|
||
raise ValueError("当前资产不是 JSON 风险规则。")
|
||
|
||
rule_library = str(config_json.get("rule_library") or RISK_RULES_LIBRARY).strip()
|
||
if rule_library not in RULE_LIBRARY_NAMES:
|
||
raise ValueError("规则库目录不合法。")
|
||
|
||
rule_document = config_json.get("rule_document")
|
||
if not isinstance(rule_document, dict):
|
||
raise ValueError("规则资产缺少 rule_document 配置。")
|
||
|
||
file_name = str(rule_document.get("file_name") or "").strip()
|
||
if not file_name:
|
||
raise ValueError("规则资产缺少 JSON 文件名。")
|
||
return rule_library, file_name
|
||
|
||
def read_rule_json(self, asset_id: str) -> AgentAssetRuleJsonRead:
|
||
asset = self.repository.get(asset_id)
|
||
if asset is None:
|
||
raise LookupError("资产不存在。")
|
||
|
||
rule_library, file_name = self._resolve_json_risk_rule_document(asset)
|
||
payload = self.rule_library_manager.read_rule_library_json(
|
||
library=rule_library,
|
||
file_name=file_name,
|
||
)
|
||
return AgentAssetRuleJsonRead(
|
||
file_name=file_name,
|
||
rule_code=str(payload.get("rule_code") or asset.code or ""),
|
||
name=str(payload.get("name") or asset.name or ""),
|
||
description=str(payload.get("description") or asset.description or "").strip(),
|
||
evaluator=str(payload.get("evaluator") or ""),
|
||
ontology_signal=str(payload.get("ontology_signal") or "") or None,
|
||
inputs=payload.get("inputs") if isinstance(payload.get("inputs"), dict) else {},
|
||
outcomes=payload.get("outcomes") if isinstance(payload.get("outcomes"), dict) else {},
|
||
payload=payload,
|
||
)
|
||
|
||
def write_rule_json(
|
||
self,
|
||
asset_id: str,
|
||
*,
|
||
body: AgentAssetRuleJsonWrite,
|
||
actor: str,
|
||
request_id: str | None = None,
|
||
) -> AgentAssetRuleJsonRead:
|
||
asset = self.repository.get(asset_id)
|
||
if asset is None:
|
||
raise LookupError("资产不存在。")
|
||
|
||
rule_library, file_name = self._resolve_json_risk_rule_document(asset)
|
||
payload = dict(body.payload or {})
|
||
asset_code = str(asset.code or "").strip()
|
||
if asset_code and str(payload.get("rule_code") or "").strip() not in {"", asset_code}:
|
||
raise ValueError("规则 JSON 的 rule_code 必须与资产编码一致。")
|
||
if asset_code and not str(payload.get("rule_code") or "").strip():
|
||
payload["rule_code"] = asset_code
|
||
|
||
saved = self.rule_library_manager.write_rule_library_json(
|
||
library=rule_library,
|
||
file_name=file_name,
|
||
payload=payload,
|
||
)
|
||
rule_description = str(saved.get("description") or "").strip()
|
||
if rule_description:
|
||
asset.description = rule_description
|
||
rule_name = str(saved.get("name") or "").strip()
|
||
if rule_name:
|
||
asset.name = rule_name
|
||
risk_category = str(saved.get("risk_category") or "").strip()
|
||
if risk_category:
|
||
config_json = dict(asset.config_json or {})
|
||
config_json["risk_category"] = risk_category
|
||
asset.config_json = config_json
|
||
asset.scenario_json = [risk_category]
|
||
self.audit_service.log_action(
|
||
actor=actor,
|
||
action="update_agent_asset_rule_json",
|
||
resource_type=asset.asset_type,
|
||
resource_id=asset.id,
|
||
before_json={"file_name": file_name},
|
||
after_json={"file_name": file_name, "rule_code": saved.get("rule_code")},
|
||
request_id=request_id,
|
||
)
|
||
self.db.commit()
|
||
return self.read_rule_json(asset_id)
|