diff --git a/server/rules/finance-rules/公司差旅费报销规则.xlsx b/server/rules/finance-rules/公司差旅费报销规则.xlsx index e65ad78..3d00070 100644 Binary files a/server/rules/finance-rules/公司差旅费报销规则.xlsx and b/server/rules/finance-rules/公司差旅费报销规则.xlsx differ diff --git a/server/src/app/api/v1/endpoints/agent_assets.py b/server/src/app/api/v1/endpoints/agent_assets.py index 217b53f..cefc7e4 100644 --- a/server/src/app/api/v1/endpoints/agent_assets.py +++ b/server/src/app/api/v1/endpoints/agent_assets.py @@ -23,9 +23,11 @@ from app.schemas.agent_asset import ( AgentAssetRead, AgentAssetReviewCreate, AgentAssetReviewRead, + AgentAssetRuleJsonRead, + AgentAssetRuleJsonWrite, AgentAssetSpreadsheetChangeRecordRead, - AgentAssetVersionCompareRead, AgentAssetUpdate, + AgentAssetVersionCompareRead, AgentAssetVersionCreate, AgentAssetVersionRead, AgentAssetVersionTimelineItemRead, @@ -50,7 +52,7 @@ RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_u def _handle_asset_error(exc: Exception) -> None: - if isinstance(exc, LookupError): + if isinstance(exc, (LookupError, FileNotFoundError)): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc if isinstance(exc, PermissionError): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc @@ -111,6 +113,48 @@ def get_agent_asset(asset_id: str, db: DbSession) -> AgentAssetRead: return asset +@router.get( + "/{asset_id}/rule-json", + response_model=AgentAssetRuleJsonRead, + summary="读取风险规则 JSON", + description="读取 JSON 风险规则资产绑定的规则文件内容。", +) +def get_agent_asset_rule_json( + asset_id: str, + _: CurrentUser, + db: DbSession, +) -> AgentAssetRuleJsonRead: + try: + return AgentAssetService(db).read_rule_json(asset_id) + except Exception as exc: + _handle_asset_error(exc) + + +@router.put( + "/{asset_id}/rule-json", + response_model=AgentAssetRuleJsonRead, + summary="保存风险规则 JSON", + description="保存 JSON 风险规则资产绑定的规则文件内容,并写入审计日志。", +) +def save_agent_asset_rule_json( + asset_id: str, + payload: AgentAssetRuleJsonWrite, + current_user: RuleEditorUser, + db: DbSession, + x_actor: ActorHeader = None, + x_request_id: RequestIdHeader = None, +) -> AgentAssetRuleJsonRead: + try: + return AgentAssetService(db).write_rule_json( + asset_id, + body=payload, + actor=(x_actor or current_user.name or "system").strip() or "system", + request_id=x_request_id, + ) + except Exception as exc: + _handle_asset_error(exc) + + @router.get( "/{asset_id}/spreadsheet/onlyoffice-config", response_model=AgentAssetOnlyOfficeConfigRead, diff --git a/server/src/app/repositories/agent_asset.py b/server/src/app/repositories/agent_asset.py index 96d1197..ff6cde6 100644 --- a/server/src/app/repositories/agent_asset.py +++ b/server/src/app/repositories/agent_asset.py @@ -56,6 +56,17 @@ class AgentAssetRepository: stmt = stmt.limit(limit) return list(self.db.scalars(stmt).all()) + def list_versions_for_assets(self, asset_ids: list[str]) -> list[AgentAssetVersion]: + if not asset_ids: + return [] + + stmt = ( + select(AgentAssetVersion) + .where(AgentAssetVersion.asset_id.in_(asset_ids)) + .order_by(AgentAssetVersion.asset_id, AgentAssetVersion.created_at.desc()) + ) + return list(self.db.scalars(stmt).all()) + def get_version(self, asset_id: str, version: str) -> AgentAssetVersion | None: stmt = select(AgentAssetVersion).where( AgentAssetVersion.asset_id == asset_id, diff --git a/server/src/app/repositories/audit_log.py b/server/src/app/repositories/audit_log.py index 0636b11..a74f39b 100644 --- a/server/src/app/repositories/audit_log.py +++ b/server/src/app/repositories/audit_log.py @@ -28,6 +28,28 @@ class AuditLogRepository: stmt = stmt.order_by(AuditLog.created_at.desc()).limit(limit) return list(self.db.scalars(stmt).all()) + def list_for_resources( + self, + *, + resource_type: str, + resource_ids: list[str], + action: str | None = None, + limit: int | None = None, + ) -> list[AuditLog]: + if not resource_ids: + return [] + + stmt = select(AuditLog).where( + AuditLog.resource_type == resource_type, + AuditLog.resource_id.in_(resource_ids), + ) + if action: + stmt = stmt.where(AuditLog.action == action) + stmt = stmt.order_by(AuditLog.created_at.desc()) + if limit is not None: + stmt = stmt.limit(limit) + return list(self.db.scalars(stmt).all()) + def create(self, log: AuditLog) -> AuditLog: self.db.add(log) self.db.commit() diff --git a/server/src/app/schemas/agent_asset.py b/server/src/app/schemas/agent_asset.py index 0311d20..3bd7ee1 100644 --- a/server/src/app/schemas/agent_asset.py +++ b/server/src/app/schemas/agent_asset.py @@ -93,6 +93,22 @@ class AgentAssetOnlyOfficeCallbackWrite(BaseModel): users: list[str] = Field(default_factory=list, description="当前编辑用户列表。") +class AgentAssetRuleJsonWrite(BaseModel): + payload: dict[str, Any] = Field(default_factory=dict) + + +class AgentAssetRuleJsonRead(BaseModel): + file_name: str + rule_code: str + name: str + description: str = "" + evaluator: str = "" + ontology_signal: str | None = None + inputs: dict[str, Any] = Field(default_factory=dict) + outcomes: dict[str, Any] = Field(default_factory=dict) + payload: dict[str, Any] = Field(default_factory=dict) + + class AgentAssetVersionTimelineItemRead(BaseModel): event_type: str version: str @@ -129,8 +145,10 @@ class AgentAssetVersionCompareRead(BaseModel): class AgentAssetSpreadsheetChangeRecordRead(BaseModel): + id: str actor: str changed_at: datetime + version: str | None = None summary: str sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = Field(default_factory=list) cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = Field(default_factory=list) @@ -172,6 +190,8 @@ class AgentAssetListItem(BaseModel): published_version: str | None working_version: str | None config_json: dict[str, Any] + change_count: int = 0 + modified_by: str | None = None created_at: datetime updated_at: datetime diff --git a/server/src/app/services/agent_asset_rule_library.py b/server/src/app/services/agent_asset_rule_library.py new file mode 100644 index 0000000..d122d76 --- /dev/null +++ b/server/src/app/services/agent_asset_rule_library.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from app.core.config import SERVER_DIR +from app.services.agent_asset_spreadsheet import RULE_LIBRARY_NAMES + +JSON_RULE_MIME_TYPE = "application/json" + + +class AgentAssetRuleLibraryManager: + def __init__(self, rule_root: Path | None = None) -> None: + self.rule_root = Path(rule_root or (SERVER_DIR / "rules")).resolve() + + def ensure_rule_library_dirs(self) -> None: + for library in sorted(RULE_LIBRARY_NAMES): + (self.rule_root / library).mkdir(parents=True, exist_ok=True) + + def resolve_rule_library_path(self, *, library: str, file_name: str) -> Path: + normalized_library = str(library or "").strip() + if normalized_library not in RULE_LIBRARY_NAMES: + raise ValueError("Invalid rule library.") + + normalized_name = Path(str(file_name or "").strip()).name.strip() + if not normalized_name or not normalized_name.endswith(".json"): + raise ValueError("Rule JSON file name must end with .json.") + + library_dir = (self.rule_root / normalized_library).resolve() + target_path = (library_dir / normalized_name).resolve() + try: + target_path.relative_to(library_dir) + except ValueError: + raise ValueError("Invalid rule JSON path.") from None + return target_path + + def read_rule_library_json(self, *, library: str, file_name: str) -> dict[str, Any]: + target_path = self.resolve_rule_library_path(library=library, file_name=file_name) + if not target_path.exists(): + raise FileNotFoundError("Rule JSON file not found.") + + try: + payload = json.loads(target_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError("Rule JSON file is invalid.") from exc + + if not isinstance(payload, dict): + raise ValueError("Rule JSON payload must be an object.") + return payload + + def write_rule_library_json( + self, + *, + library: str, + file_name: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + if not isinstance(payload, dict): + raise ValueError("Rule JSON payload must be an object.") + + rule_code = str(payload.get("rule_code") or "").strip() + if not rule_code: + raise ValueError("Rule JSON must include rule_code.") + + evaluator = str(payload.get("evaluator") or "").strip() + if not evaluator: + raise ValueError("Rule JSON must include evaluator.") + + target_path = self.resolve_rule_library_path(library=library, file_name=file_name) + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_text( + f"{json.dumps(payload, ensure_ascii=False, indent=2)}\n", + encoding="utf-8", + ) + return payload + + def list_rule_library_json_files(self, *, library: str) -> list[str]: + library_dir = self.resolve_rule_library_path( + library=library, + file_name="placeholder.json", + ).parent + library_dir.mkdir(parents=True, exist_ok=True) + return sorted(path.name for path in library_dir.glob("*.json") if path.is_file()) diff --git a/server/src/app/services/agent_asset_spreadsheet.py b/server/src/app/services/agent_asset_spreadsheet.py index f9fe93c..bbc477f 100644 --- a/server/src/app/services/agent_asset_spreadsheet.py +++ b/server/src/app/services/agent_asset_spreadsheet.py @@ -69,26 +69,13 @@ class AgentAssetSpreadsheetManager: actor_name: str, source: str = "upload", ) -> RuleSpreadsheetMeta: - normalized_name = Path(str(file_name or "").strip()).name.strip() - if not normalized_name: - raise ValueError("规则表文件名不能为空。") - if not content: - raise ValueError("规则表文件内容不能为空。") - - relative_path = Path("agent_assets") / asset_id / "rule_spreadsheets" / version / normalized_name - target_path = (self.storage_root / relative_path).resolve() - target_path.parent.mkdir(parents=True, exist_ok=True) - target_path.write_bytes(content) - - mime_type = mimetypes.guess_type(normalized_name)[0] or SPREADSHEET_MIME_TYPE - return RuleSpreadsheetMeta( - file_name=normalized_name, - storage_key=relative_path.as_posix(), - mime_type=mime_type, - size_bytes=len(content), - checksum=hashlib.sha256(content).hexdigest(), - updated_at=datetime.now(UTC).isoformat(), - updated_by=str(actor_name or "system").strip() or "system", + return self.store_rule_library_spreadsheet_snapshot( + library=FINANCE_RULES_LIBRARY, + asset_id=asset_id, + version=version, + file_name=file_name, + content=content, + actor_name=actor_name, source=source, ) @@ -117,7 +104,74 @@ class AgentAssetSpreadsheetManager: try: target_path.relative_to(self.rule_root) except ValueError: - raise ValueError("规则库文件路径不合法。") + raise ValueError("规则库文件路径不合法。") from None + + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_bytes(content) + + mime_type = mimetypes.guess_type(normalized_name)[0] or SPREADSHEET_MIME_TYPE + return RuleSpreadsheetMeta( + file_name=normalized_name, + storage_key=relative_path.as_posix(), + mime_type=mime_type, + size_bytes=len(content), + checksum=hashlib.sha256(content).hexdigest(), + updated_at=datetime.now(UTC).isoformat(), + updated_by=str(actor_name or "system").strip() or "system", + source=source, + ) + + def store_rule_library_spreadsheet_snapshot( + self, + *, + library: str, + asset_id: str, + version: str, + file_name: str, + content: bytes, + actor_name: str, + source: str = "rule-library-version", + ) -> RuleSpreadsheetMeta: + normalized_library = str(library or "").strip() + if normalized_library not in RULE_LIBRARY_NAMES: + raise ValueError("规则库目录不合法。") + + raw_asset_id = str(asset_id or "").strip() + raw_version = str(version or "").strip() + normalized_asset_id = Path(raw_asset_id).name.strip() + normalized_version = Path(raw_version).name.strip() + normalized_name = Path(str(file_name or "").strip()).name.strip() + if ( + not normalized_asset_id + or normalized_asset_id in {".", ".."} + or normalized_asset_id != raw_asset_id + ): + raise ValueError("规则资产 ID 不合法。") + if ( + not normalized_version + or normalized_version in {".", ".."} + or normalized_version != raw_version + ): + raise ValueError("规则表版本号不合法。") + if not normalized_name: + raise ValueError("规则表文件名不能为空。") + if not content: + raise ValueError("规则表文件内容不能为空。") + + self.ensure_rule_library_dirs() + relative_path = ( + Path("rules") + / normalized_library + / ".versions" + / normalized_asset_id + / normalized_version + / normalized_name + ) + target_path = (SERVER_DIR / relative_path).resolve() + try: + target_path.relative_to(self.rule_root) + except ValueError: + raise ValueError("规则库版本文件路径不合法。") from None target_path.parent.mkdir(parents=True, exist_ok=True) target_path.write_bytes(content) @@ -149,7 +203,7 @@ class AgentAssetSpreadsheetManager: try: resolved.relative_to(allowed_root) except ValueError: - raise FileNotFoundError("规则表文件不存在。") + raise FileNotFoundError("规则表文件不存在。") from None return resolved @staticmethod @@ -230,11 +284,46 @@ class AgentAssetSpreadsheetManager: def build_company_travel_rule_template() -> bytes: standard_rows = [ ["费用分类", "适用场景", "票据要求", "报销标准", "审批要求", "备注"], - ["长途交通", "飞机、高铁、火车等跨城出行", "行程单、车票、发票", "据实报销", "超预算需直属领导审批", "优先选择公共交通"], - ["住宿费", "出差住宿", "酒店发票、入住清单", "一线城市 650/晚;二线城市 500/晚;其他城市 380/晚", "超标需总监审批", "协议酒店优先"], - ["市内交通", "出租车、网约车、地铁、公交", "发票或电子行程单", "150/天", "超限需补充说明", "夜间或无公共交通场景可豁免"], - ["餐补", "出差期间日常补助", "无需票据", "120/天", "系统自动核定", "当天往返默认不享受"], - ["招待餐费", "客户接待或项目宴请", "餐饮发票、参与人清单", "300/人", "需业务负责人审批", "需关联客户或项目"], + [ + "长途交通", + "飞机、高铁、火车等跨城出行", + "行程单、车票、发票", + "据实报销", + "超预算需直属领导审批", + "优先选择公共交通", + ], + [ + "住宿费", + "出差住宿", + "酒店发票、入住清单", + "一线城市 650/晚;二线城市 500/晚;其他城市 380/晚", + "超标需总监审批", + "协议酒店优先", + ], + [ + "市内交通", + "出租车、网约车、地铁、公交", + "发票或电子行程单", + "150/天", + "超限需补充说明", + "夜间或无公共交通场景可豁免", + ], + [ + "餐补", + "出差期间日常补助", + "无需票据", + "120/天", + "系统自动核定", + "当天往返默认不享受", + ], + [ + "招待餐费", + "客户接待或项目宴请", + "餐饮发票、参与人清单", + "300/人", + "需业务负责人审批", + "需关联客户或项目", + ], ] instruction_rows = [ ["字段", "填写说明"], @@ -308,21 +397,41 @@ def _build_xlsx_bytes(sheets: list[tuple[str, list[list[object]]]]) -> bytes: def _build_content_types_xml(sheets: list[tuple[str, list[list[object]]]]) -> str: overrides = [ - '', - '', - '', - '', + ( + '' + ), + ( + '' + ), + ( + '' + ), + ( + '' + ), ] overrides.extend( [ - f'' + ( + f'' + ) for index, _ in enumerate(sheets, start=1) ] ) return ( '' '' - '' + '' '' f'{"".join(overrides)}' "" @@ -333,9 +442,15 @@ def _build_root_rels_xml() -> str: return ( '' '' - '' - '' - '' + '' + '' + '' "" ) @@ -347,11 +462,16 @@ def _build_app_xml(sheets: list[tuple[str, list[list[object]]]]) -> str: sheet_count = len(sheets) return ( '' - '' 'Microsoft Excel' - f"Worksheets{sheet_count}" - f"{titles}" + '' + "Worksheets" + f"{sheet_count}" + "" + f'' + f"{titles}" "" ) @@ -359,7 +479,8 @@ def _build_app_xml(sheets: list[tuple[str, list[list[object]]]]) -> str: def _build_core_xml(created_at: str) -> str: return ( '' - ' str: def _build_workbook_rels_xml(sheets: list[tuple[str, list[list[object]]]]) -> str: relationships = "".join( [ - f'' + ( + f'' + ) for index, _ in enumerate(sheets, start=1) ] ) @@ -414,10 +539,15 @@ def _build_styles_xml() -> str: '' '' '' - '' + '' + '' '' - '' - '' + '' + '' + "" + '' + '' + "" '' '' ) diff --git a/server/src/app/services/agent_assets.py b/server/src/app/services/agent_assets.py index acbcf4b..78e2a5e 100644 --- a/server/src/app/services/agent_assets.py +++ b/server/src/app/services/agent_assets.py @@ -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() diff --git a/server/src/app/services/risk_ontology_bridge.py b/server/src/app/services/risk_ontology_bridge.py new file mode 100644 index 0000000..95b119c --- /dev/null +++ b/server/src/app/services/risk_ontology_bridge.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from app.schemas.ontology import OntologyParseResult + +RISK_SIGNAL_TO_RULE_CODES: dict[str, list[str]] = { + "location_mismatch": ["risk.travel.destination_receipt_location"], + "base_location_overlap": ["risk.travel.base_location_overlap"], + "intracity_travel": ["risk.travel.intracity_travel_claim"], + "multi_city_itinerary": ["risk.travel.multi_city_reason_required"], + "hotel_itinerary_mismatch": ["risk.travel.hotel_without_itinerary"], + "duplicate_invoice": ["risk.invoice.duplicate_invoice"], + "buyer_name_mismatch": ["risk.invoice.claimant_buyer_name_match"], + "document_expense_mismatch": ["risk.invoice.document_expense_mismatch"], + "cross_year_invoice": ["risk.invoice.cross_year_invoice"], + "void_or_red_invoice": ["risk.invoice.void_or_red_invoice"], + "vague_goods_description": ["risk.invoice.vague_goods_description"], + "entertainment_missing_detail": ["risk.expense.entertainment_missing_detail"], + "meal_as_travel": ["risk.expense.meal_localized_as_travel"], + "consecutive_transport_receipts": ["risk.expense.consecutive_transport_receipts"], + "reason_too_brief": ["risk.expense.reason_too_brief"], +} + +TEXT_SIGNAL_KEYWORDS: dict[str, tuple[str, ...]] = { + "location_mismatch": ("地点", "行程", "出差地", "票据地", "城市不一致"), + "duplicate_invoice": ("重复", "同一张票", "重复报销", "发票重复"), + "buyer_name_mismatch": ("购买方", "抬头", "开票单位"), + "document_expense_mismatch": ("附件", "票据", "单据", "材料不一致"), + "cross_year_invoice": ("跨年", "以前年度", "去年发票"), + "void_or_red_invoice": ("作废", "红冲", "红字"), + "vague_goods_description": ("商品名称", "品名", "笼统"), + "entertainment_missing_detail": ("招待", "宴请", "陪同", "客户餐"), + "meal_as_travel": ("餐费", "差旅餐", "本地餐"), + "consecutive_transport_receipts": ("连续交通", "多张车票", "打车"), + "reason_too_brief": ("事由", "说明太短", "理由不足"), +} + + +def list_all_platform_risk_rule_codes() -> list[str]: + return sorted({code for codes in RISK_SIGNAL_TO_RULE_CODES.values() for code in codes}) + + +def resolve_rule_codes_from_ontology(ontology: OntologyParseResult) -> list[str]: + resolved: list[str] = [] + for signal in ontology.risk_flags: + for rule_code in RISK_SIGNAL_TO_RULE_CODES.get(str(signal or "").strip(), []): + if rule_code not in resolved: + resolved.append(rule_code) + return resolved + + +def infer_risk_signals_from_text(text: str) -> list[str]: + normalized = str(text or "").strip().lower() + if not normalized: + return [] + + signals: list[str] = [] + for signal, keywords in TEXT_SIGNAL_KEYWORDS.items(): + if any(keyword.lower() in normalized for keyword in keywords): + signals.append(signal) + return signals + + +def resolve_rule_codes_for_risk_check( + ontology: OntologyParseResult, + *, + query_text: str = "", +) -> list[str]: + if ontology.intent != "risk_check": + return [] + + resolved = resolve_rule_codes_from_ontology(ontology) + for signal in infer_risk_signals_from_text(query_text): + for rule_code in RISK_SIGNAL_TO_RULE_CODES.get(signal, []): + if rule_code not in resolved: + resolved.append(rule_code) + + return resolved or list_all_platform_risk_rule_codes() diff --git a/server/tests/test_agent_asset_service.py b/server/tests/test_agent_asset_service.py index aaa4793..83d0869 100644 --- a/server/tests/test_agent_asset_service.py +++ b/server/tests/test_agent_asset_service.py @@ -1,7 +1,9 @@ from __future__ import annotations +import shutil import uuid from io import BytesIO +from pathlib import Path import pytest from openpyxl import Workbook, load_workbook @@ -9,6 +11,7 @@ from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool +from app.api.deps import CurrentUserContext from app.core.agent_enums import ( AgentAssetContentType, AgentAssetDomain, @@ -19,13 +22,18 @@ from app.core.agent_enums import ( AgentRunSource, AgentRunStatus, ) +from app.core.config import SERVER_DIR from app.db.base import Base from app.schemas.agent_asset import ( AgentAssetCreate, AgentAssetReviewCreate, AgentAssetVersionCreate, ) -from app.api.deps import CurrentUserContext +from app.services.agent_asset_spreadsheet import ( + COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, + COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, + FINANCE_RULES_LIBRARY, +) from app.services.agent_assets import AgentAssetService from app.services.agent_runs import AgentRunService from app.services.audit import AuditLogService @@ -33,6 +41,38 @@ from app.services.expense_rule_runtime import ExpenseRuleRuntimeService from app.services.settings import OnlyOfficeRuntimeConfig +@pytest.fixture(autouse=True) +def isolate_rule_file_storage(tmp_path, monkeypatch) -> None: + temp_server_dir = tmp_path / "server" + temp_rules_root = temp_server_dir / "rules" + temp_finance_rules = temp_rules_root / FINANCE_RULES_LIBRARY + temp_finance_rules.mkdir(parents=True, exist_ok=True) + + real_finance_rules = SERVER_DIR / "rules" / FINANCE_RULES_LIBRARY + for file_name in ( + COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, + COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, + ): + source_path = real_finance_rules / file_name + if source_path.exists(): + shutil.copy2(source_path, temp_finance_rules / file_name) + + monkeypatch.setattr( + "app.services.agent_asset_spreadsheet.SERVER_DIR", + temp_server_dir, + ) + + def init_manager(self, storage_root=None, rule_root=None) -> None: + self.storage_root = Path(storage_root or tmp_path / "storage").resolve() + self.asset_root = (self.storage_root / "agent_assets").resolve() + self.rule_root = Path(rule_root or temp_rules_root).resolve() + + monkeypatch.setattr( + "app.services.agent_asset_spreadsheet.AgentAssetSpreadsheetManager.__init__", + init_manager, + ) + + def build_session() -> Session: engine = create_engine( "sqlite+pysqlite:///:memory:", @@ -55,6 +95,19 @@ def build_workbook_bytes(rows: list[list[object]], *, sheet_name: str = "规则 return buffer.getvalue() +def build_multi_sheet_workbook_bytes(sheets: dict[str, list[list[object]]]) -> bytes: + workbook = Workbook() + default_sheet = workbook.active + workbook.remove(default_sheet) + for sheet_name, rows in sheets.items(): + sheet = workbook.create_sheet(sheet_name) + for row in rows: + sheet.append(row) + buffer = BytesIO() + workbook.save(buffer) + return buffer.getvalue() + + def test_agent_asset_service_seeds_assets_and_enforces_review_before_activation() -> None: with build_session() as db: service = AgentAssetService(db) @@ -62,7 +115,8 @@ def test_agent_asset_service_seeds_assets_and_enforces_review_before_activation( rules = service.list_assets(asset_type=AgentAssetType.RULE.value) assert len(rules) >= 3 assert any( - item.code == "rule.expense.travel_risk_control_standard" and item.status == AgentAssetStatus.ACTIVE.value + item.code == "rule.expense.travel_risk_control_standard" + and item.status == AgentAssetStatus.ACTIVE.value for item in rules ) assert all( @@ -218,7 +272,10 @@ def test_expense_rule_runtime_uses_published_version_instead_of_working_version( rule.id, AgentAssetVersionCreate( version="v1.1.1", - content="# 工作稿\n\n```expense-rule\n{\"kind\":\"travel_policy\",\"version\":1}\n```", + content=( + "# 工作稿\n\n" + '```expense-rule\n{"kind":"travel_policy","version":1}\n```' + ), content_type=AgentAssetContentType.MARKDOWN, change_note="未上线草稿", created_by="finance_user", @@ -285,7 +342,10 @@ def test_spreadsheet_version_compare_returns_sheet_and_cell_changes() -> None: assert diff.changed_sheet_count == 1 assert diff.changed_cell_count == 3 - assert any(item.cell == "B2" and item.change_type == "modified" for item in diff.cell_changes) + assert any( + item.cell == "B2" and item.change_type == "modified" + for item in diff.cell_changes + ) assert any(item.cell == "A3" and item.change_type == "added" for item in diff.cell_changes) @@ -311,7 +371,10 @@ def test_working_spreadsheet_version_reads_immutable_snapshot_instead_of_live_co current_asset = service.repository.get(rule.id) assert current_asset is not None live_storage_key = str((current_asset.config_json or {})["rule_document"]["storage_key"]) + assert live_storage_key.startswith(f"rules/{FINANCE_RULES_LIBRARY}/") + assert "agent_assets" not in live_storage_key live_path = service.spreadsheet_manager.resolve_storage_path(live_storage_key) + assert not service.spreadsheet_manager.asset_root.exists() original_live_bytes = live_path.read_bytes() try: live_path.write_bytes(build_workbook_bytes([["城市", "住宿"], ["北京", 999]])) @@ -322,6 +385,9 @@ def test_working_spreadsheet_version_reads_immutable_snapshot_instead_of_live_co ) assert snapshot_path != live_path + assert FINANCE_RULES_LIBRARY in snapshot_path.parts + assert ".versions" in snapshot_path.parts + assert "agent_assets" not in snapshot_path.parts workbook = load_workbook(snapshot_path, data_only=False) assert workbook.active["B2"].value == 500 finally: @@ -366,6 +432,55 @@ def test_spreadsheet_change_records_return_recent_edit_details() -> None: assert records[0].cell_changes[0].cell == "B2" +def test_spreadsheet_change_records_include_all_modified_sheets() -> None: + with build_session() as db: + service = AgentAssetService(db) + rule = next( + item + for item in service.list_assets(asset_type=AgentAssetType.RULE.value) + if item.code == "rule.expense.company_travel_expense_reimbursement" + ) + + service.upload_rule_spreadsheet( + rule.id, + filename="公司差旅费报销规则.xlsx", + content=build_multi_sheet_workbook_bytes( + { + "差旅标准": [["城市", "住宿"], ["北京", 500]], + "填表说明": [["字段", "说明"], ["住宿", "按城市标准"]], + } + ), + actor="finance_user", + ) + detail = service.get_asset(rule.id) + assert detail is not None + first_version = detail.working_version + + service.upload_rule_spreadsheet( + rule.id, + filename="公司差旅费报销规则.xlsx", + content=build_multi_sheet_workbook_bytes( + { + "差旅标准": [["城市", "住宿"], ["北京", 550]], + "填表说明": [["字段", "说明"], ["住宿", "按城市等级标准"]], + } + ), + actor="finance_user", + ) + + records = service.list_spreadsheet_change_records(rule.id) + latest = records[0] + changed_sheets = {item.sheet_name for item in latest.sheet_changes} + changed_cell_sheets = {item.sheet_name for item in latest.cell_changes} + + assert latest.version != first_version + assert latest.changed_sheet_count == 2 + assert {"差旅标准", "填表说明"}.issubset(changed_sheets) + assert {"差旅标准", "填表说明"}.issubset(changed_cell_sheets) + assert "差旅标准" in latest.summary + assert "填表说明" in latest.summary + + def test_editable_spreadsheet_onlyoffice_config_enables_forcesave(monkeypatch) -> None: with build_session() as db: monkeypatch.setattr( diff --git a/web/src/services/agentAssets.js b/web/src/services/agentAssets.js index 7ab2d75..e592553 100644 --- a/web/src/services/agentAssets.js +++ b/web/src/services/agentAssets.js @@ -136,6 +136,18 @@ export function fetchAgentAssetVersionTimeline(assetId) { return apiRequest(`/agent-assets/${assetId}/version-timeline`) } +export function fetchAgentAssetRuleJson(assetId) { + return apiRequest(`/agent-assets/${assetId}/rule-json`) +} + +export function saveAgentAssetRuleJson(assetId, payload, options = {}) { + return apiRequest(`/agent-assets/${assetId}/rule-json`, { + method: 'PUT', + body: JSON.stringify(payload), + headers: buildWriteHeaders(options) + }) +} + export function compareAgentAssetSpreadsheetVersions(assetId, baseVersion, targetVersion) { const query = new URLSearchParams({ base_version: String(baseVersion || '').trim(), diff --git a/web/src/views/AuditView.vue b/web/src/views/AuditView.vue index fde0dad..93fe85e 100644 --- a/web/src/views/AuditView.vue +++ b/web/src/views/AuditView.vue @@ -165,7 +165,7 @@
- {{ item.changed_cell_count }} 处改动 + {{ item.changeCountLabel }}

{{ item.summary }}

+ 关联版本:{{ item.version }} + + 涉及工作表:{{ item.sheetPreview.join('、') }} + +
修改时间 {{ selectedSpreadsheetChangeRecord.time }} +
+ 关联版本 + {{ selectedSpreadsheetChangeRecord.version }} +
修改工作表 {{ selectedSpreadsheetChangeRecord.changed_sheet_count }} @@ -1154,7 +1163,7 @@ {{ item.sheet_name }} · {{ item.meta.label }}
-

本次没有新增或删除工作表。

+

本次没有工作表级变化。

diff --git a/web/src/views/scripts/AuditView.js b/web/src/views/scripts/AuditView.js index 1a2e381..175143c 100644 --- a/web/src/views/scripts/AuditView.js +++ b/web/src/views/scripts/AuditView.js @@ -1695,12 +1695,30 @@ export default { } return (spreadsheetChangeRecordsByAsset.value[selectedSkill.value.id] || []) .filter((item) => item?.changed_at) - .map((item) => ({ - ...item, - time: formatDateTime(item.changed_at), - previewChanges: Array.isArray(item.cell_changes) ? item.cell_changes.slice(0, 3) : [], - remainingChangeCount: Math.max((item.changed_cell_count || 0) - 3, 0) - })) + .map((item) => { + const sheetNames = [ + ...(Array.isArray(item.sheet_changes) + ? item.sheet_changes.map((change) => normalizeText(change.sheet_name)) + : []), + ...(Array.isArray(item.cell_changes) + ? item.cell_changes.map((change) => normalizeText(change.sheet_name)) + : []) + ].filter(Boolean) + const changedSheetNames = [...new Set(sheetNames)] + const previewChanges = Array.isArray(item.cell_changes) ? item.cell_changes.slice(0, 3) : [] + return { + ...item, + time: formatDateTime(item.changed_at), + changeCountLabel: item.changed_cell_count + ? `${item.changed_cell_count} 处改动` + : `${item.changed_sheet_count || changedSheetNames.length || 0} 个工作表`, + changedSheetNames, + sheetPreview: changedSheetNames.slice(0, 4), + remainingSheetCount: Math.max(changedSheetNames.length - 4, 0), + previewChanges, + remainingChangeCount: Math.max((item.changed_cell_count || 0) - previewChanges.length, 0) + } + }) }) const selectedSpreadsheetChangeSheetRows = computed(() => Array.isArray(selectedSpreadsheetChangeRecord.value?.sheet_changes) @@ -2133,13 +2151,24 @@ export default { ) .join('|') : '' + const sheetSignature = Array.isArray(latest.sheet_changes) + ? latest.sheet_changes + .map((item) => + [item?.sheet_name, item?.change_type] + .map((value) => normalizeText(value)) + .join(':') + ) + .join('|') + : '' return [ latest.id, latest.changed_at, latest.actor, + latest.version, latest.summary, latest.changed_sheet_count, latest.changed_cell_count, + sheetSignature, previewSignature ] .map((value) => normalizeText(value)) @@ -2538,7 +2567,21 @@ export default { loadSpreadsheetChangeRecords(assetId).catch(() => {}) } if (selectedSkill.value.usesJsonRiskRule) { - await loadRiskRuleJson(assetId) + try { + await loadRiskRuleJson(assetId) + } catch (jsonError) { + console.warn('Failed to load risk rule JSON:', jsonError) + const jsonMessage = + jsonError?.message || '风险规则 JSON 文件缺失或无法读取,请同步规则库后重试。' + toast(jsonMessage) + selectedSkill.value = { + ...selectedSkill.value, + riskRuleJsonText: '{}', + riskRuleDescription: + selectedSkill.value.riskRuleDescription || + '规则 JSON 尚未就绪,请联系管理员执行平台风险规则同步。' + } + } } } } catch (error) {