fix(agent): 修复规则中心表格版本和修改记录

补齐规则资产 JSON 读写接口和前端调用,修复 AuditView 导入缺失。

Excel 在线编辑改为比对所有页签并生成最近修改记录,版本快照统一保存到 rules/finance-rules/.versions。

隔离规则表测试存储,避免测试或旧入口写入真实规则目录与 storage/agent_assets。
This commit is contained in:
caoxiaozhu
2026-05-19 15:41:53 +00:00
parent 9472813739
commit d460ee0fe7
13 changed files with 782 additions and 167 deletions

View File

@@ -10,7 +10,6 @@ from urllib.parse import quote
from urllib.request import Request, urlopen
import jwt
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
@@ -44,7 +43,6 @@ from app.schemas.agent_asset import (
)
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import (
AgentAssetSpreadsheetManager,
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
@@ -52,8 +50,9 @@ from app.services.agent_asset_spreadsheet import (
FINANCE_RULES_LIBRARY,
RISK_RULES_LIBRARY,
RULE_LIBRARY_NAMES,
RuleSpreadsheetMeta,
SPREADSHEET_MIME_TYPE,
AgentAssetSpreadsheetManager,
RuleSpreadsheetMeta,
)
from app.services.agent_foundation import AgentFoundationService
from app.services.audit import AuditLogService
@@ -410,7 +409,8 @@ class AgentAssetService:
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_spreadsheet(
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,
@@ -542,7 +542,11 @@ class AgentAssetService:
return file_path, metadata.mime_type, metadata.file_name
asset = self._require_spreadsheet_rule(asset_id)
_, metadata = self._resolve_current_spreadsheet_meta(asset)
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)
@@ -594,6 +598,14 @@ class AgentAssetService:
_, 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)
next_version = self._next_available_version(asset)
metadata = self._store_current_rule_spreadsheet(
asset,
file_name=file_name,
@@ -601,6 +613,45 @@ class AgentAssetService:
actor=actor,
source=source,
)
snapshot_metadata = self.spreadsheet_manager.store_rule_library_spreadsheet_snapshot(
library=self._resolve_spreadsheet_rule_library(asset),
asset_id=asset.id,
version=next_version,
file_name=file_name,
content=content,
actor_name=actor,
source=source,
)
operation_label = (
change_note
or (
"ONLYOFFICE 在线编辑"
if source == "onlyoffice"
else f"上传并覆盖当前规则表:{normalized_name}"
)
)
summary = self._build_spreadsheet_change_summary(
operation_label,
sheet_changes,
cell_changes,
)
version_content = self.spreadsheet_manager.build_version_markdown(
rule_name=asset.name,
version=next_version,
metadata=snapshot_metadata,
)
self.create_version(
asset.id,
AgentAssetVersionCreate(
version=next_version,
content=version_content,
content_type=AgentAssetContentType.MARKDOWN,
change_note=summary,
created_by=actor,
),
actor=actor,
request_id=request_id,
)
self.audit_service.log_action(
actor=actor,
action="edit_rule_spreadsheet",
@@ -608,12 +659,14 @@ class AgentAssetService:
resource_id=asset.id,
before_json={"storage_key": current_metadata.storage_key},
after_json={
"summary": change_note or f"上传并覆盖当前规则表:{normalized_name}",
"changed_sheet_count": 0,
"changed_cell_count": 0,
"sheet_changes": [],
"cell_changes": [],
"summary": summary,
"version": next_version,
"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,
"snapshot_storage_key": snapshot_metadata.storage_key,
},
request_id=request_id,
)
@@ -682,56 +735,16 @@ class AgentAssetService:
if current_metadata.checksum and current_metadata.checksum == self._hash_bytes(content):
return
from io import BytesIO
from openpyxl import load_workbook
try:
base_workbook = self._load_spreadsheet_for_compare(current_metadata)
target_workbook = load_workbook(BytesIO(content), read_only=False, data_only=False)
sheet_changes, cell_changes = self._collect_workbook_changes(
base_workbook, target_workbook
)
changed_sheet_count = len(
{item.sheet_name for item in sheet_changes}
| {item.sheet_name for item in cell_changes}
)
changed_cell_count = len(cell_changes)
if changed_cell_count > 0 or changed_sheet_count > 0:
change_note = f"ONLYOFFICE 在线编辑:涉及 {changed_sheet_count} 个 Sheet{changed_cell_count} 处改动。"
else:
change_note = "ONLYOFFICE 在线编辑保存。"
except Exception:
sheet_changes = []
cell_changes = []
changed_sheet_count = 0
changed_cell_count = 0
change_note = "ONLYOFFICE 在线编辑保存。"
resolved_actor_name = str(actor_name or "").strip() or (
callback.users[0] if callback.users else "ONLYOFFICE"
)
self._store_current_rule_spreadsheet(
asset,
file_name=current_metadata.file_name,
self.upload_rule_spreadsheet(
asset.id,
filename=current_metadata.file_name,
content=content,
actor=resolved_actor_name,
source="onlyoffice",
)
if changed_sheet_count > 0 or changed_cell_count > 0:
self.audit_service.log_action(
actor=resolved_actor_name,
action="edit_rule_spreadsheet",
resource_type=asset.asset_type,
resource_id=asset.id,
before_json={"storage_key": current_metadata.storage_key},
after_json={
"summary": change_note,
"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]],
},
)
def _ensure_ready(self) -> None:
AgentFoundationService(self.db).ensure_foundation_ready()
@@ -782,7 +795,8 @@ class AgentAssetService:
if (
asset.asset_type == AgentAssetType.RULE.value
and str((asset.config_json or {}).get("detail_mode") or "").strip().lower() == "spreadsheet"
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:
@@ -919,60 +933,30 @@ class AgentAssetService:
) -> AgentAssetVersionCompareRead:
self._ensure_ready()
asset = self._require_spreadsheet_rule(asset_id)
resolved_base, base_meta = self._resolve_spreadsheet_version_meta(asset, version=base_version)
resolved_target, target_meta = self._resolve_spreadsheet_version_meta(asset, version=target_version)
resolved_base, base_meta = self._resolve_spreadsheet_version_meta(
asset,
version=base_version,
)
resolved_target, target_meta = self._resolve_spreadsheet_version_meta(
asset,
version=target_version,
)
base_workbook = self._load_spreadsheet_for_compare(base_meta)
target_workbook = self._load_spreadsheet_for_compare(target_meta)
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] = []
changed_sheets: set[str] = set()
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
changed_sheets.add(sheet_name)
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,
)
)
sheet_changes, cell_changes = self._collect_workbook_changes(
base_workbook,
target_workbook,
)
added_sheet_count = sum(1 for item in sheet_changes if item.change_type == "added")
removed_sheet_count = sum(1 for item in sheet_changes if item.change_type == "removed")
return AgentAssetVersionCompareRead(
base_version=resolved_base,
target_version=resolved_target,
added_sheet_count=len(target_sheet_names - base_sheet_names),
removed_sheet_count=len(base_sheet_names - target_sheet_names),
changed_sheet_count=len(changed_sheets),
added_sheet_count=added_sheet_count,
removed_sheet_count=removed_sheet_count,
changed_sheet_count=self._count_changed_sheets(sheet_changes, cell_changes),
changed_cell_count=len(cell_changes),
sheet_changes=sheet_changes,
cell_changes=cell_changes[:500],
@@ -997,6 +981,7 @@ class AgentAssetService:
id=log.id,
actor=log.actor,
changed_at=log.created_at,
version=str((log.after_json or {}).get("version") or "").strip() or None,
summary=str((log.after_json or {}).get("summary") or "ONLYOFFICE 在线编辑保存。"),
sheet_changes=[
AgentAssetSpreadsheetDiffSheetRead.model_validate(item)
@@ -1379,9 +1364,10 @@ class AgentAssetService:
file_name = PREVIEW_RULE_VERSION_FILENAMES[resolved_version]
storage_key = (
Path("agent_assets")
Path("rules")
/ FINANCE_RULES_LIBRARY
/ ".versions"
/ PREVIEW_RULE_ASSET_ID
/ "rule_spreadsheets"
/ resolved_version
/ file_name
).as_posix()
@@ -1404,7 +1390,8 @@ class AgentAssetService:
source="preview",
)
metadata = self.spreadsheet_manager.store_spreadsheet(
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,
@@ -1436,7 +1423,8 @@ class AgentAssetService:
return
actor_name = callback.users[0] if callback.users else "ONLYOFFICE"
self.spreadsheet_manager.store_spreadsheet(
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,
@@ -1482,7 +1470,10 @@ class AgentAssetService:
) -> bool:
role_codes = {str(item).strip() for item in current_user.role_codes}
can_edit = current_user.is_admin or "manager" in role_codes or "finance" in role_codes
return can_edit and AgentAssetService._resolve_working_version(asset) == str(version or "").strip()
return (
can_edit
and AgentAssetService._resolve_working_version(asset) == str(version or "").strip()
)
@staticmethod
def _can_edit_current_spreadsheet(current_user: CurrentUserContext) -> bool:
@@ -1495,7 +1486,8 @@ class AgentAssetService:
version: str,
metadata: RuleSpreadsheetMeta,
) -> str:
raw_key = f"{asset_id}-{version}-{metadata.checksum or metadata.updated_at or metadata.file_name}"
fingerprint = metadata.checksum or metadata.updated_at or metadata.file_name
raw_key = f"{asset_id}-{version}-{fingerprint}"
return "".join(
character if character.isalnum() or character in {"-", "_", ".", "="} else "_"
for character in raw_key
@@ -1570,6 +1562,7 @@ class AgentAssetService:
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)
@@ -1577,6 +1570,19 @@ class AgentAssetService:
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]]:
@@ -1621,8 +1627,50 @@ class AgentAssetService:
)
)
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(
operation_label: str,
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 f"{operation_label}:文件内容已保存,未发现单元格级差异。"
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"{operation_label}{sheet_text},共 {len(cell_changes)} 处单元格改动。"
return f"{operation_label}{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()