diff --git a/backend/app/api/review_rules.py b/backend/app/api/review_rules.py index 5f03e1b3..418e7839 100644 --- a/backend/app/api/review_rules.py +++ b/backend/app/api/review_rules.py @@ -16,6 +16,9 @@ ROUTE_PERMISSION_ACTIONS = { "create": "review_rules:create", "update": "review_rules:update", "update_status": "review_rules:update_status", + "publish": "review_rules:publish", + "unpublish": "review_rules:unpublish", + "rollback": "review_rules:rollback", "delete": "review_rules:delete", } @@ -155,6 +158,78 @@ def update_review_rule_status( return success_response(result) +@router.post("/{rule_id}/publish") +def publish_review_rule( + rule_id: str, + review_rule_service: ReviewRuleService = Depends(get_review_rule_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = review_rule_service.publish_rule(current_user, rule_id) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="publish_review_rule", + resource_type="review_rule", + resource_id=result["id"], + detail_json={ + "name": result["name"], + "rule_code": result.get("rule_code"), + "published_at": result.get("published_at"), + "latest_governance_asset_id": result.get("latest_governance_asset_id"), + }, + ) + return success_response(result) + + +@router.post("/{rule_id}/unpublish") +def unpublish_review_rule( + rule_id: str, + review_rule_service: ReviewRuleService = Depends(get_review_rule_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = review_rule_service.unpublish_rule(current_user, rule_id) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="unpublish_review_rule", + resource_type="review_rule", + resource_id=result["id"], + detail_json={ + "name": result["name"], + "rule_code": result.get("rule_code"), + }, + ) + return success_response(result) + + +@router.post("/{rule_id}/rollback") +def rollback_review_rule( + rule_id: str, + target_version_label: str = Form(...), + change_summary: Optional[str] = Form(default=None), + review_rule_service: ReviewRuleService = Depends(get_review_rule_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = review_rule_service.rollback_rule(current_user, rule_id, target_version_label, change_summary) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="rollback_review_rule", + resource_type="review_rule", + resource_id=result["id"], + detail_json={ + "name": result["name"], + "rule_code": result.get("rule_code"), + "target_version_label": target_version_label, + "current_version_label": result.get("current_version_label"), + }, + ) + return success_response(result) + + @router.delete("/{rule_id}") def delete_review_rule( rule_id: str, diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index aa6e9009..0078c5ee 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -16,6 +16,7 @@ from app.models.message import Message from app.models.pilot_request import PilotRequest from app.models.review_result import ReviewResult from app.models.review_rule import ReviewRule +from app.models.review_rule_version import ReviewRuleVersion from app.models.review_task import ReviewTask from app.models.tenant import Tenant from app.models.user import User diff --git a/backend/app/models/review_rule.py b/backend/app/models/review_rule.py index b7d90925..1e4e99ba 100644 --- a/backend/app/models/review_rule.py +++ b/backend/app/models/review_rule.py @@ -13,6 +13,7 @@ class ReviewRule(Base): id: Mapped[str] = mapped_column(String(64), primary_key=True) tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) name: Mapped[str] = mapped_column(String(100), nullable=False) + rule_code: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) keyword: Mapped[str] = mapped_column(String(100), nullable=False) contract_type: Mapped[str] = mapped_column(String(50), default="all") risk_level: Mapped[str] = mapped_column(String(32), default="medium") @@ -20,6 +21,13 @@ class ReviewRule(Base): suggestion: Mapped[Optional[str]] = mapped_column(Text, nullable=True) legal_basis: Mapped[Optional[str]] = mapped_column(Text, nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True) + current_version_label: Mapped[str] = mapped_column(String(32), default="v1.0") + latest_change_summary: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + latest_version_created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + version_count: Mapped[int] = mapped_column(default=1) + published_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + published_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + latest_governance_asset_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), diff --git a/backend/app/models/review_rule_version.py b/backend/app/models/review_rule_version.py new file mode 100644 index 00000000..35c0f28d --- /dev/null +++ b/backend/app/models/review_rule_version.py @@ -0,0 +1,30 @@ +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class ReviewRuleVersion(Base): + __tablename__ = "review_rule_versions" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + rule_id: Mapped[str] = mapped_column(String(64), ForeignKey("review_rules.id"), nullable=False, index=True) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + version_label: Mapped[str] = mapped_column(String(32), nullable=False) + change_summary: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + rule_code: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + keyword: Mapped[str] = mapped_column(String(100), nullable=False) + contract_type: Mapped[str] = mapped_column(String(50), default="all") + risk_level: Mapped[str] = mapped_column(String(32), default="medium") + description: Mapped[str] = mapped_column(Text, nullable=False) + suggestion: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + legal_basis: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + is_current: Mapped[bool] = mapped_column(Boolean, default=False) + created_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + created_by_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/repositories/review_rule_version_repository.py b/backend/app/repositories/review_rule_version_repository.py new file mode 100644 index 00000000..6446a970 --- /dev/null +++ b/backend/app/repositories/review_rule_version_repository.py @@ -0,0 +1,51 @@ +from typing import List, Optional + +from sqlalchemy.orm import Session + +from app.models.review_rule_version import ReviewRuleVersion + + +class ReviewRuleVersionRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, version: ReviewRuleVersion) -> ReviewRuleVersion: + self.db.add(version) + self.db.commit() + self.db.refresh(version) + return version + + def list_by_rule_id(self, tenant_id: str, rule_id: str) -> List[ReviewRuleVersion]: + return ( + self.db.query(ReviewRuleVersion) + .filter( + ReviewRuleVersion.tenant_id == tenant_id, + ReviewRuleVersion.rule_id == rule_id, + ) + .order_by(ReviewRuleVersion.created_at.desc()) + .all() + ) + + def clear_current(self, tenant_id: str, rule_id: str) -> None: + rows = ( + self.db.query(ReviewRuleVersion) + .filter( + ReviewRuleVersion.tenant_id == tenant_id, + ReviewRuleVersion.rule_id == rule_id, + ) + .all() + ) + for item in rows: + item.is_current = False + self.db.commit() + + def get_by_version_label(self, tenant_id: str, rule_id: str, version_label: str) -> Optional[ReviewRuleVersion]: + return ( + self.db.query(ReviewRuleVersion) + .filter( + ReviewRuleVersion.tenant_id == tenant_id, + ReviewRuleVersion.rule_id == rule_id, + ReviewRuleVersion.version_label == version_label, + ) + .first() + ) diff --git a/backend/app/services/review_rule_service.py b/backend/app/services/review_rule_service.py index a258cead..09442463 100644 --- a/backend/app/services/review_rule_service.py +++ b/backend/app/services/review_rule_service.py @@ -1,17 +1,23 @@ from typing import Any, Dict, Optional from uuid import uuid4 +from datetime import datetime, timezone from fastapi import HTTPException, status from sqlalchemy.orm import Session from app.models.review_rule import ReviewRule +from app.models.review_rule_version import ReviewRuleVersion +from app.repositories.governance_asset_repository import GovernanceAssetRepository from app.repositories.review_rule_repository import ReviewRuleRepository +from app.repositories.review_rule_version_repository import ReviewRuleVersionRepository class ReviewRuleService: def __init__(self, db: Session): self.db = db self.repository = ReviewRuleRepository(db) + self.version_repository = ReviewRuleVersionRepository(db) + self.governance_asset_repository = GovernanceAssetRepository(db) def list_rules(self, current_user: Dict[str, Any], page: int = 1, page_size: int = 20) -> dict: self._require_admin(current_user) @@ -43,6 +49,7 @@ class ReviewRuleService: id=f"rule-{uuid4().hex[:12]}", tenant_id=current_user["tenant_id"], name=name.strip(), + rule_code=self._normalize_rule_code(None, name, keyword), keyword=keyword.strip(), contract_type=(contract_type or "all").strip() or "all", risk_level=risk_level.strip(), @@ -50,8 +57,13 @@ class ReviewRuleService: suggestion=suggestion.strip() if suggestion else None, legal_basis=legal_basis.strip() if legal_basis else None, is_active=is_active, + current_version_label="v1.0", + latest_change_summary="Initial rule version", + latest_version_created_at=self._now(), + version_count=1, ) created = self.repository.create(review_rule) + self._create_version_snapshot(created, current_user, "Initial rule version", is_current=True) return self._to_item(created) def update_rule( @@ -69,7 +81,9 @@ class ReviewRuleService: ) -> dict: self._require_admin(current_user) rule = self._require_rule(current_user, rule_id) + next_version_label = self._bump_version_label(rule.current_version_label) rule.name = name.strip() + rule.rule_code = self._normalize_rule_code(rule.rule_code, name, keyword) rule.keyword = keyword.strip() rule.contract_type = (contract_type or "all").strip() or "all" rule.risk_level = risk_level.strip() @@ -77,7 +91,12 @@ class ReviewRuleService: rule.suggestion = suggestion.strip() if suggestion else None rule.legal_basis = legal_basis.strip() if legal_basis else None rule.is_active = is_active + rule.current_version_label = next_version_label + rule.latest_change_summary = f"{next_version_label} rule update" + rule.latest_version_created_at = self._now() + rule.version_count = max(rule.version_count or 0, 1) + 1 updated = self.repository.update(rule) + self._create_version_snapshot(updated, current_user, rule.latest_change_summary, is_current=True) return self._to_item(updated) def update_rule_status(self, current_user: Dict[str, Any], rule_id: str, is_active: bool) -> dict: @@ -87,6 +106,55 @@ class ReviewRuleService: updated = self.repository.update(rule) return self._to_item(updated) + def publish_rule(self, current_user: Dict[str, Any], rule_id: str) -> dict: + self._require_admin(current_user) + rule = self._require_rule(current_user, rule_id) + governance_asset = self._require_publishable_governance_asset(current_user, rule) + rule.is_active = True + rule.published_at = self._now() + rule.published_by = current_user.get("id") + rule.latest_governance_asset_id = governance_asset.id + updated = self.repository.update(rule) + return self._to_item(updated) + + def unpublish_rule(self, current_user: Dict[str, Any], rule_id: str) -> dict: + self._require_admin(current_user) + rule = self._require_rule(current_user, rule_id) + rule.is_active = False + rule.published_at = None + rule.published_by = None + rule.latest_governance_asset_id = None + updated = self.repository.update(rule) + return self._to_item(updated) + + def rollback_rule(self, current_user: Dict[str, Any], rule_id: str, target_version_label: str, change_summary: Optional[str] = None) -> dict: + self._require_admin(current_user) + rule = self._require_rule(current_user, rule_id) + target_version = self.version_repository.get_by_version_label(current_user["tenant_id"], rule_id, target_version_label) + if target_version is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="rule version not found") + + next_version_label = self._bump_version_label(rule.current_version_label) + rule.name = target_version.name + rule.rule_code = target_version.rule_code + rule.keyword = target_version.keyword + rule.contract_type = target_version.contract_type + rule.risk_level = target_version.risk_level + rule.description = target_version.description + rule.suggestion = target_version.suggestion + rule.legal_basis = target_version.legal_basis + rule.is_active = False + rule.current_version_label = next_version_label + rule.latest_change_summary = (change_summary or f"Rollback to {target_version_label}").strip() + rule.latest_version_created_at = self._now() + rule.version_count = max(rule.version_count or 0, 1) + 1 + rule.published_at = None + rule.published_by = None + rule.latest_governance_asset_id = None + updated = self.repository.update(rule) + self._create_version_snapshot(updated, current_user, rule.latest_change_summary, is_current=True) + return self._to_item(updated) + def delete_rule(self, current_user: Dict[str, Any], rule_id: str) -> dict: self._require_admin(current_user) rule = self._require_rule(current_user, rule_id) @@ -104,10 +172,70 @@ class ReviewRuleService: if current_user.get("role") != "admin": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="forbidden") + def _normalize_rule_code(self, rule_code: Optional[str], name: str, keyword: str) -> str: + normalized = rule_code.strip() if rule_code else "" + if normalized: + return normalized + explicit_map = { + "自动续约预警": "RULE-AUTO-RENEWAL-001", + "付款节点预警": "RULE-PAYMENT-MILESTONE-001", + } + if name.strip() in explicit_map: + return explicit_map[name.strip()] + slug = "".join(char if char.isalnum() else "-" for char in (name.strip() or keyword.strip() or "GENERAL").upper()) + slug = "-".join(part for part in slug.split("-") if part) or "GENERAL" + return f"RULE-{slug[:48]}-001" + + def _require_publishable_governance_asset(self, current_user: Dict[str, Any], rule: ReviewRule): + if not rule.rule_code: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="rule_code is required before publish") + governance_asset = self.governance_asset_repository.get_by_identity(current_user["tenant_id"], "review_risk_rule", rule.rule_code) + if governance_asset is None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="governance asset not found for rule_code") + if governance_asset.production_status not in {"ready", "approved"}: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="governance asset is not approved for production publish") + return governance_asset + + def _create_version_snapshot(self, rule: ReviewRule, current_user: Dict[str, Any], change_summary: str, *, is_current: bool) -> ReviewRuleVersion: + self.version_repository.clear_current(rule.tenant_id, rule.id) + version = ReviewRuleVersion( + id=f"rulever-{uuid4().hex[:12]}", + rule_id=rule.id, + tenant_id=rule.tenant_id, + version_label=rule.current_version_label or "v1.0", + change_summary=change_summary, + name=rule.name, + rule_code=rule.rule_code, + keyword=rule.keyword, + contract_type=rule.contract_type, + risk_level=rule.risk_level, + description=rule.description, + suggestion=rule.suggestion, + legal_basis=rule.legal_basis, + is_active=rule.is_active, + is_current=is_current, + created_by=current_user.get("id"), + created_by_name=current_user.get("display_name"), + created_at=rule.latest_version_created_at or self._now(), + ) + return self.version_repository.create(version) + + def _bump_version_label(self, current_version_label: Optional[str]) -> str: + if not current_version_label: + return "v1.1" + major_minor = current_version_label.strip().lstrip("v").split(".") + if len(major_minor) != 2 or not all(part.isdigit() for part in major_minor): + return "v1.1" + return f"v{major_minor[0]}.{int(major_minor[1]) + 1}" + + def _now(self): + return datetime.now(timezone.utc) + def _to_item(self, review_rule: ReviewRule) -> dict: return { "id": review_rule.id, "name": review_rule.name, + "rule_code": review_rule.rule_code, "keyword": review_rule.keyword, "contract_type": review_rule.contract_type, "risk_level": review_rule.risk_level, @@ -115,6 +243,13 @@ class ReviewRuleService: "suggestion": review_rule.suggestion, "legal_basis": review_rule.legal_basis, "is_active": review_rule.is_active, + "published_at": review_rule.published_at.isoformat() if review_rule.published_at else None, + "published_by": review_rule.published_by, + "latest_governance_asset_id": review_rule.latest_governance_asset_id, + "current_version_label": review_rule.current_version_label or "v1.0", + "latest_change_summary": review_rule.latest_change_summary, + "latest_version_created_at": review_rule.latest_version_created_at.isoformat() if review_rule.latest_version_created_at else None, + "version_count": max(review_rule.version_count or 0, 1), "created_at": review_rule.created_at.isoformat(), "updated_at": review_rule.updated_at.isoformat(), } diff --git a/backend/migrations/versions/20260513_0019_add_review_rule_publish_fields.py b/backend/migrations/versions/20260513_0019_add_review_rule_publish_fields.py new file mode 100644 index 00000000..dfb4cd46 --- /dev/null +++ b/backend/migrations/versions/20260513_0019_add_review_rule_publish_fields.py @@ -0,0 +1,191 @@ +"""add review rule publish fields + +Revision ID: 20260513_0019 +Revises: 20260513_0018 +Create Date: 2026-05-13 23:10:00 +""" + +from __future__ import annotations + +from typing import Optional + +import sqlalchemy as sa +from alembic import op + + +revision = "20260513_0019" +down_revision = "20260513_0018" +branch_labels = None +depends_on = None + + +def _table_names(inspector: sa.Inspector) -> set[str]: + return set(inspector.get_table_names()) + + +def _column_names(inspector: sa.Inspector, table_name: str) -> set[str]: + return {column["name"] for column in inspector.get_columns(table_name)} + + +def _index_names(inspector: sa.Inspector, table_name: str) -> set[str]: + return {index["name"] for index in inspector.get_indexes(table_name)} + + +def _derive_rule_code(name: str, keyword: str) -> str: + normalized_name = (name or "").strip().upper() + normalized_keyword = (keyword or "").strip().upper() + explicit_map = { + "自动续约预警": "RULE-AUTO-RENEWAL-001", + "付款节点预警": "RULE-PAYMENT-MILESTONE-001", + } + if name in explicit_map: + return explicit_map[name] + slug = normalized_name or normalized_keyword or "GENERAL" + slug = "".join(char if char.isalnum() else "-" for char in slug) + slug = "-".join(part for part in slug.split("-") if part) or "GENERAL" + return f"RULE-{slug[:48]}-001" + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "review_rules" not in _table_names(inspector): + return + + columns = _column_names(inspector, "review_rules") + if "rule_code" not in columns: + op.add_column("review_rules", sa.Column("rule_code", sa.String(length=64), nullable=True)) + if "current_version_label" not in columns: + op.add_column("review_rules", sa.Column("current_version_label", sa.String(length=32), nullable=True)) + if "latest_change_summary" not in columns: + op.add_column("review_rules", sa.Column("latest_change_summary", sa.String(length=500), nullable=True)) + if "latest_version_created_at" not in columns: + op.add_column("review_rules", sa.Column("latest_version_created_at", sa.DateTime(timezone=True), nullable=True)) + if "version_count" not in columns: + op.add_column("review_rules", sa.Column("version_count", sa.Integer(), nullable=True)) + if "published_at" not in columns: + op.add_column("review_rules", sa.Column("published_at", sa.DateTime(timezone=True), nullable=True)) + if "published_by" not in columns: + op.add_column("review_rules", sa.Column("published_by", sa.String(length=64), nullable=True)) + if "latest_governance_asset_id" not in columns: + op.add_column("review_rules", sa.Column("latest_governance_asset_id", sa.String(length=64), nullable=True)) + + inspector = sa.inspect(bind) + indexes = _index_names(inspector, "review_rules") + if "ix_review_rules_rule_code" not in indexes: + op.create_index("ix_review_rules_rule_code", "review_rules", ["rule_code"], unique=False) + + if "review_rule_versions" not in _table_names(inspector): + op.create_table( + "review_rule_versions", + sa.Column("id", sa.String(length=64), primary_key=True), + sa.Column("rule_id", sa.String(length=64), nullable=False), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("version_label", sa.String(length=32), nullable=False), + sa.Column("change_summary", sa.String(length=500), nullable=True), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("rule_code", sa.String(length=64), nullable=True), + sa.Column("keyword", sa.String(length=100), nullable=False), + sa.Column("contract_type", sa.String(length=50), nullable=False), + sa.Column("risk_level", sa.String(length=32), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.Column("suggestion", sa.Text(), nullable=True), + sa.Column("legal_basis", sa.Text(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("is_current", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("created_by", sa.String(length=64), nullable=True), + sa.Column("created_by_name", sa.String(length=100), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["rule_id"], ["review_rules.id"]), + ) + op.create_index("ix_review_rule_versions_rule_id", "review_rule_versions", ["rule_id"], unique=False) + op.create_index("ix_review_rule_versions_tenant_rule", "review_rule_versions", ["tenant_id", "rule_id"], unique=False) + + now = sa.func.now() + bind.execute( + sa.text( + """ + UPDATE review_rules + SET current_version_label = COALESCE(current_version_label, 'v1.0'), + latest_change_summary = COALESCE(latest_change_summary, 'Initial rule version'), + latest_version_created_at = COALESCE(latest_version_created_at, updated_at, created_at, CURRENT_TIMESTAMP), + version_count = COALESCE(version_count, 1) + """ + ) + ) + + rows = bind.execute(sa.text("SELECT id, name, keyword, rule_code, tenant_id, contract_type, risk_level, description, suggestion, legal_basis, is_active, created_at, updated_at FROM review_rules")).mappings().all() + for row in rows: + if not row["rule_code"]: + bind.execute( + sa.text("UPDATE review_rules SET rule_code = :rule_code WHERE id = :rule_id"), + {"rule_id": row["id"], "rule_code": _derive_rule_code(row.get("name") or "", row.get("keyword") or "")}, + ) + + existing = bind.execute( + sa.text("SELECT 1 FROM review_rule_versions WHERE rule_id = :rule_id LIMIT 1"), + {"rule_id": row["id"]}, + ).scalar() + if existing: + continue + bind.execute( + sa.text( + """ + INSERT INTO review_rule_versions ( + id, rule_id, tenant_id, version_label, change_summary, name, rule_code, + keyword, contract_type, risk_level, description, suggestion, legal_basis, + is_active, is_current, created_by, created_by_name, created_at + ) VALUES ( + :id, :rule_id, :tenant_id, 'v1.0', 'Initial rule version', :name, :rule_code, + :keyword, :contract_type, :risk_level, :description, :suggestion, :legal_basis, + :is_active, true, null, null, :created_at + ) + """ + ), + { + "id": f"rulever-{row['id'][-12:]}", + "rule_id": row["id"], + "tenant_id": row["tenant_id"], + "name": row["name"], + "rule_code": row["rule_code"] or _derive_rule_code(row.get("name") or "", row.get("keyword") or ""), + "keyword": row["keyword"], + "contract_type": row["contract_type"] or "all", + "risk_level": row["risk_level"] or "medium", + "description": row["description"], + "suggestion": row["suggestion"], + "legal_basis": row["legal_basis"], + "is_active": row["is_active"], + "created_at": row["updated_at"] or row["created_at"], + }, + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "review_rule_versions" in _table_names(inspector): + indexes = _index_names(inspector, "review_rule_versions") + if "ix_review_rule_versions_tenant_rule" in indexes: + op.drop_index("ix_review_rule_versions_tenant_rule", table_name="review_rule_versions") + if "ix_review_rule_versions_rule_id" in indexes: + op.drop_index("ix_review_rule_versions_rule_id", table_name="review_rule_versions") + op.drop_table("review_rule_versions") + + if "review_rules" not in _table_names(inspector): + return + indexes = _index_names(inspector, "review_rules") + if "ix_review_rules_rule_code" in indexes: + op.drop_index("ix_review_rules_rule_code", table_name="review_rules") + columns = _column_names(inspector, "review_rules") + for column_name in [ + "latest_governance_asset_id", + "published_by", + "published_at", + "version_count", + "latest_version_created_at", + "latest_change_summary", + "current_version_label", + "rule_code", + ]: + if column_name in columns: + op.drop_column("review_rules", column_name) diff --git a/backend/tests/test_review_rule_service.py b/backend/tests/test_review_rule_service.py index 6568e92e..33858e1e 100644 --- a/backend/tests/test_review_rule_service.py +++ b/backend/tests/test_review_rule_service.py @@ -13,6 +13,7 @@ def create_test_rule(rule_id="rule-123", tenant_id="tenant-demo-001", **kwargs): "id": rule_id, "tenant_id": tenant_id, "name": "自动续约预警", + "rule_code": "RULE-AUTO-RENEWAL-001", "keyword": "自动续约", "contract_type": "all", "risk_level": "high", @@ -20,6 +21,13 @@ def create_test_rule(rule_id="rule-123", tenant_id="tenant-demo-001", **kwargs): "suggestion": "补充通知期限。", "legal_basis": "《民法典》相关规则。", "is_active": True, + "current_version_label": "v1.0", + "latest_change_summary": "Initial rule version", + "latest_version_created_at": datetime.now(timezone.utc), + "version_count": 1, + "published_at": None, + "published_by": None, + "latest_governance_asset_id": None, "created_at": datetime.now(timezone.utc), "updated_at": datetime.now(timezone.utc) } @@ -31,6 +39,10 @@ class ReviewRuleServiceTestCase(unittest.TestCase): def setUp(self): self.mock_db = Mock() self.service = ReviewRuleService(self.mock_db) + self.service.version_repository = Mock() + self.service.version_repository.create = Mock(side_effect=lambda version: version) + self.service.version_repository.clear_current = Mock() + self.service.governance_asset_repository = Mock() def test_list_rules_admin_success(self): # Mock current user @@ -56,6 +68,7 @@ class ReviewRuleServiceTestCase(unittest.TestCase): self.assertEqual(len(result["items"]), 1) self.assertEqual(result["items"][0]["id"], "rule-123") self.assertEqual(result["items"][0]["name"], "自动续约预警") + self.assertEqual(result["items"][0]["rule_code"], "RULE-AUTO-RENEWAL-001") def test_list_rules_member_forbidden(self): # Mock current user (member role) @@ -113,6 +126,7 @@ class ReviewRuleServiceTestCase(unittest.TestCase): self.assertEqual(result["contract_type"], "procurement") self.assertEqual(result["risk_level"], "medium") self.assertEqual(result["is_active"], True) + self.assertEqual(result["current_version_label"], "v1.0") def test_update_rule_success(self): # Mock current user @@ -148,6 +162,7 @@ class ReviewRuleServiceTestCase(unittest.TestCase): self.assertEqual(result["name"], "自动续约预警更新") self.assertEqual(result["description"], "更新后的描述。") self.assertEqual(result["is_active"], False) + self.assertEqual(result["current_version_label"], "v1.1") def test_update_rule_not_found(self): # Mock current user @@ -204,6 +219,70 @@ class ReviewRuleServiceTestCase(unittest.TestCase): self.assertEqual(result["id"], "rule-123") self.assertEqual(result["is_active"], False) + def test_publish_and_unpublish_rule(self): + current_user = { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin" + } + mock_existing_rule = create_test_rule() + governance_asset = Mock() + governance_asset.id = "govast-rule-123" + governance_asset.production_status = "ready" + self.service.repository.get_by_id = Mock(return_value=mock_existing_rule) + self.service.repository.update = Mock(return_value=mock_existing_rule) + self.service.governance_asset_repository.get_by_identity = Mock(return_value=governance_asset) + + published = self.service.publish_rule(current_user, "rule-123") + self.assertEqual(published["published_by"], "user-admin") + self.assertEqual(published["latest_governance_asset_id"], "govast-rule-123") + + unpublished = self.service.unpublish_rule(current_user, "rule-123") + self.assertIsNone(unpublished["published_at"]) + self.assertIsNone(unpublished["latest_governance_asset_id"]) + + def test_publish_rule_requires_ready_governance_asset(self): + current_user = { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin" + } + mock_existing_rule = create_test_rule() + self.service.repository.get_by_id = Mock(return_value=mock_existing_rule) + self.service.governance_asset_repository.get_by_identity = Mock(return_value=None) + with self.assertRaises(HTTPException) as context: + self.service.publish_rule(current_user, "rule-123") + self.assertEqual(context.exception.status_code, 409) + + def test_rollback_rule_creates_new_version(self): + current_user = { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin" + } + mock_existing_rule = create_test_rule( + current_version_label="v1.2", + version_count=3, + latest_change_summary="Latest version", + ) + target_version = Mock() + target_version.name = "自动续约预警" + target_version.rule_code = "RULE-AUTO-RENEWAL-001" + target_version.keyword = "自动续约" + target_version.contract_type = "all" + target_version.risk_level = "high" + target_version.description = "建议补充通知期限和终止条件。" + target_version.suggestion = "补充通知期限。" + target_version.legal_basis = "《民法典》相关规则。" + self.service.repository.get_by_id = Mock(return_value=mock_existing_rule) + self.service.repository.update = Mock(return_value=mock_existing_rule) + self.service.version_repository.get_by_version_label = Mock(return_value=target_version) + + rolled_back = self.service.rollback_rule(current_user, "rule-123", "v1.0") + + self.assertEqual(rolled_back["current_version_label"], "v1.3") + self.assertFalse(rolled_back["is_active"]) + def test_delete_rule_success(self): # Mock current user current_user = { @@ -278,4 +357,4 @@ class ReviewRuleServiceTestCase(unittest.TestCase): if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/backend/tests/test_review_rules_api.py b/backend/tests/test_review_rules_api.py index 93ee00c4..7ea46b47 100644 --- a/backend/tests/test_review_rules_api.py +++ b/backend/tests/test_review_rules_api.py @@ -25,6 +25,7 @@ class StubReviewRuleService: { "id": "rule-123", "name": "自动续约预警", + "rule_code": "RULE-AUTO-RENEWAL-001", "keyword": "自动续约", "contract_type": "all", "risk_level": "high", @@ -32,6 +33,13 @@ class StubReviewRuleService: "suggestion": "补充通知期限。", "legal_basis": "《民法典》相关规则。", "is_active": True, + "published_at": None, + "published_by": None, + "latest_governance_asset_id": None, + "current_version_label": "v1.0", + "latest_change_summary": "Initial rule version", + "latest_version_created_at": "2026-04-16T00:00:00+00:00", + "version_count": 1, "created_at": "2026-04-16T00:00:00+00:00", "updated_at": "2026-04-16T00:00:00+00:00", } @@ -47,6 +55,7 @@ class StubReviewRuleService: return { "id": "rule-123", "name": name, + "rule_code": "RULE-PAYMENT-MILESTONE-001", "keyword": keyword, "contract_type": contract_type, "risk_level": risk_level, @@ -54,6 +63,13 @@ class StubReviewRuleService: "suggestion": suggestion, "legal_basis": legal_basis, "is_active": is_active, + "published_at": None, + "published_by": None, + "latest_governance_asset_id": None, + "current_version_label": "v1.0", + "latest_change_summary": "Initial rule version", + "latest_version_created_at": "2026-04-16T00:00:00+00:00", + "version_count": 1, "created_at": "2026-04-16T00:00:00+00:00", "updated_at": "2026-04-16T00:00:00+00:00", } @@ -64,6 +80,7 @@ class StubReviewRuleService: return { "id": rule_id, "name": name, + "rule_code": "RULE-PAYMENT-MILESTONE-001", "keyword": keyword, "contract_type": contract_type, "risk_level": risk_level, @@ -71,6 +88,13 @@ class StubReviewRuleService: "suggestion": suggestion, "legal_basis": legal_basis, "is_active": is_active, + "published_at": None, + "published_by": None, + "latest_governance_asset_id": None, + "current_version_label": "v1.1", + "latest_change_summary": "v1.1 rule update", + "latest_version_created_at": "2026-04-16T00:00:00+00:00", + "version_count": 2, "created_at": "2026-04-16T00:00:00+00:00", "updated_at": "2026-04-16T00:00:00+00:00", } @@ -81,6 +105,7 @@ class StubReviewRuleService: return { "id": rule_id, "name": "自动续约预警", + "rule_code": "RULE-AUTO-RENEWAL-001", "keyword": "自动续约", "contract_type": "all", "risk_level": "high", @@ -88,10 +113,44 @@ class StubReviewRuleService: "suggestion": "补充通知期限。", "legal_basis": "《民法典》相关规则。", "is_active": is_active, + "published_at": None, + "published_by": None, + "latest_governance_asset_id": None, + "current_version_label": "v1.0", + "latest_change_summary": "Initial rule version", + "latest_version_created_at": "2026-04-16T00:00:00+00:00", + "version_count": 1, "created_at": "2026-04-16T00:00:00+00:00", "updated_at": "2026-04-16T00:00:00+00:00", } + def publish_rule(self, current_user, rule_id): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + result = self.update_rule_status(current_user, rule_id, True) + result["published_at"] = "2026-05-13T09:00:00+00:00" + result["published_by"] = current_user["id"] + result["latest_governance_asset_id"] = "govast-rule-123" + return result + + def unpublish_rule(self, current_user, rule_id): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + result = self.update_rule_status(current_user, rule_id, False) + result["published_at"] = None + result["published_by"] = None + result["latest_governance_asset_id"] = None + return result + + def rollback_rule(self, current_user, rule_id, target_version_label, change_summary=None): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + result = self.unpublish_rule(current_user, rule_id) + result["current_version_label"] = "v1.2" + result["latest_change_summary"] = change_summary or f"Rollback to {target_version_label}" + result["version_count"] = 3 + return result + def delete_rule(self, current_user, rule_id): if current_user["role"] != "admin": raise HTTPException(status_code=403, detail="forbidden") @@ -131,6 +190,7 @@ class ReviewRulesApiTestCase(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["data"]["items"][0]["name"], "自动续约预警") + self.assertEqual(response.json()["data"]["items"][0]["rule_code"], "RULE-AUTO-RENEWAL-001") def test_admin_can_create_review_rule(self): app.dependency_overrides[require_current_user] = lambda: { @@ -219,6 +279,27 @@ class ReviewRulesApiTestCase(unittest.TestCase): }, ) + def test_admin_can_publish_unpublish_and_rollback_review_rule(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + } + + publish_response = self.client.post("/api/v1/review-rules/rule-123/publish") + unpublish_response = self.client.post("/api/v1/review-rules/rule-123/unpublish") + rollback_response = self.client.post("/api/v1/review-rules/rule-123/rollback", data={"target_version_label": "v1.0"}) + + self.assertEqual(publish_response.status_code, 200) + self.assertEqual(unpublish_response.status_code, 200) + self.assertEqual(rollback_response.status_code, 200) + self.assertEqual(publish_response.json()["data"]["published_by"], "user-admin") + self.assertEqual(unpublish_response.json()["data"]["published_at"], None) + self.assertEqual(rollback_response.json()["data"]["current_version_label"], "v1.2") + self.assertEqual(self.audit_log_service.events[-3]["action"], "publish_review_rule") + self.assertEqual(self.audit_log_service.events[-2]["action"], "unpublish_review_rule") + self.assertEqual(self.audit_log_service.events[-1]["action"], "rollback_review_rule") + def test_member_cannot_delete_review_rule(self): app.dependency_overrides[require_current_user] = lambda: { "id": "user-member", diff --git a/frontend/src/app/rules/page.tsx b/frontend/src/app/rules/page.tsx index 4d24884e..21bbebc3 100644 --- a/frontend/src/app/rules/page.tsx +++ b/frontend/src/app/rules/page.tsx @@ -4,7 +4,16 @@ import { Alert, Button, Card, Empty, Form, Input, Popconfirm, Select, Space, Swi import type { ColumnsType, TablePaginationConfig } from "antd/es/table"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { createReviewRule, deleteReviewRule, listReviewRules, updateReviewRule, updateReviewRuleStatus } from "@/services/review-rule"; +import { + createReviewRule, + deleteReviewRule, + listReviewRules, + publishReviewRule, + rollbackReviewRule, + unpublishReviewRule, + updateReviewRule, + updateReviewRuleStatus, +} from "@/services/review-rule"; import { useAuthStore } from "@/stores/auth.store"; import type { ReviewRuleItem } from "@/types/api"; @@ -56,6 +65,8 @@ export default function RulesPage() { const [items, setItems] = useState([]); const [editingId, setEditingId] = useState(null); const [deletingId, setDeletingId] = useState(null); + const [publishingId, setPublishingId] = useState(null); + const [rollbackingId, setRollbackingId] = useState(null); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); const [total, setTotal] = useState(0); @@ -187,6 +198,63 @@ export default function RulesPage() { } }; + const handlePublish = async (item: ReviewRuleItem) => { + setPublishingId(item.id); + try { + const result = await publishReviewRule(item.id); + if (result.code !== 0) { + messageApi.error(result.message || "规则发布失败"); + return; + } + messageApi.success("规则已发布到生产可用集合"); + await loadRules(page, pageSize); + } catch (error) { + messageApi.error(error instanceof Error ? error.message : "规则发布失败,请稍后重试"); + } finally { + setPublishingId(null); + } + }; + + const handleUnpublish = async (item: ReviewRuleItem) => { + setPublishingId(item.id); + try { + const result = await unpublishReviewRule(item.id); + if (result.code !== 0) { + messageApi.error(result.message || "规则下线失败"); + return; + } + messageApi.success("规则已从生产可用集合移除"); + await loadRules(page, pageSize); + } catch (error) { + messageApi.error(error instanceof Error ? error.message : "规则下线失败,请稍后重试"); + } finally { + setPublishingId(null); + } + }; + + const handleRollback = async (item: ReviewRuleItem) => { + setRollbackingId(item.id); + try { + const targetVersion = item.current_version_label.startsWith("v") + ? `v${Math.max(0, Number(item.current_version_label.split(".")[1] ?? "1") - 1)}` + : "v1.0"; + const result = await rollbackReviewRule(item.id, { + target_version_label: targetVersion === "v0" ? "v1.0" : targetVersion, + change_summary: `Rollback from ${item.current_version_label}`, + }); + if (result.code !== 0) { + messageApi.error(result.message || "规则回滚失败"); + return; + } + messageApi.success("规则已回滚为新版本"); + await loadRules(page, pageSize); + } catch (error) { + messageApi.error(error instanceof Error ? error.message : "规则回滚失败,请稍后重试"); + } finally { + setRollbackingId(null); + } + }; + const handleTableChange = (pagination: TablePaginationConfig) => { const nextPage = pagination.current ?? 1; const nextPageSize = pagination.pageSize ?? pageSize; @@ -207,6 +275,13 @@ export default function RulesPage() { key: "keyword", width: 140, }, + { + title: "规则编号", + dataIndex: "rule_code", + key: "rule_code", + width: 180, + render: (value: string | null) => value ? {value} : "-", + }, { title: "合同类型", dataIndex: "contract_type", @@ -221,6 +296,19 @@ export default function RulesPage() { width: 120, render: (value: string) => getRiskLevelTag(value), }, + { + title: "发布状态", + key: "publish_status", + width: 180, + render: (_, item) => ( + + {item.published_at ? "已发布" : "未发布"} + + {item.published_at ? formatDateTime(item.published_at) : "需治理批准后发布"} + + + ), + }, { title: "状态", dataIndex: "is_active", @@ -244,6 +332,20 @@ export default function RulesPage() { + {item.published_at ? ( + + ) : ( + + )} + {item.version_count > 1 ? ( + + ) : null} ), }} - scroll={{ x: 1100 }} + scroll={{ x: 1440 }} /> diff --git a/frontend/src/services/review-rule.ts b/frontend/src/services/review-rule.ts index 0bebe1bf..120da0b4 100644 --- a/frontend/src/services/review-rule.ts +++ b/frontend/src/services/review-rule.ts @@ -1,5 +1,5 @@ import { http } from "@/services/http"; -import type { ApiResponse, PageResponse, ReviewRuleCreatePayload, ReviewRuleItem, ReviewRuleUpdatePayload } from "@/types/api"; +import type { ApiResponse, PageResponse, ReviewRuleCreatePayload, ReviewRuleItem, ReviewRuleRollbackPayload, ReviewRuleUpdatePayload } from "@/types/api"; export async function listReviewRules(page = 1, pageSize = 20) { const response = await http.get>>("/review-rules", { @@ -30,6 +30,26 @@ export async function updateReviewRuleStatus(ruleId: string, isActive: boolean) return response.data; } +export async function publishReviewRule(ruleId: string) { + const response = await http.post>(`/review-rules/${ruleId}/publish`); + return response.data; +} + +export async function unpublishReviewRule(ruleId: string) { + const response = await http.post>(`/review-rules/${ruleId}/unpublish`); + return response.data; +} + +export async function rollbackReviewRule(ruleId: string, payload: ReviewRuleRollbackPayload) { + const formData = new FormData(); + formData.append("target_version_label", payload.target_version_label); + if (payload.change_summary) { + formData.append("change_summary", payload.change_summary); + } + const response = await http.post>(`/review-rules/${ruleId}/rollback`, formData); + return response.data; +} + function buildReviewRuleFormData(payload: ReviewRuleCreatePayload | ReviewRuleUpdatePayload) { const formData = new FormData(); formData.append("name", payload.name); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index fcab4ba7..17aaa7fa 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -276,6 +276,7 @@ export type KnowledgeBaseDocumentListResponse = PageResponse; export type UserItem = { diff --git a/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md b/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md index 0c2517fb..bc07a093 100644 --- a/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md +++ b/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md @@ -1018,3 +1018,50 @@ - 文件中心里的“版本视角”仍较基础,后续还可继续增强生产资料版本链可见性 - 真实 reviewer 仍需把这些生产资料证据从 `TEMPLATE` 改成 `READY` - 正式生产放行继续阻塞在真人复核和最终批准签署 + +## 2026-05-13 GOV-API-007 review_rules 分支完成,knowledge_bases 分支识别为 shared-ai 侧阻塞 + +### 本轮完成项 +- 已为 `review_rules` 完成正式发布工作流: + - `review_rules` 增加 `rule_code`、`current_version_label`、`latest_change_summary`、`latest_version_created_at`、`version_count`、`published_at`、`published_by`、`latest_governance_asset_id` + - 新增 `review_rule_versions` 版本快照表与迁移:`backend/migrations/versions/20260513_0019_add_review_rule_publish_fields.py` + - 新增规则发布接口: + - `POST /api/v1/review-rules/{rule_id}/publish` + - `POST /api/v1/review-rules/{rule_id}/unpublish` + - `POST /api/v1/review-rules/{rule_id}/rollback` + - 规则发布前会校验 `rule_code` 对应的 `review_risk_rule` 治理资产是否已进入可发布状态 + - 规则回滚会生成新的规则版本,而不是覆盖历史内容 +- 已补齐规则页的基础治理动作: + - 列表显示 `rule_code` 和发布状态 + - 管理员可在规则页直接执行“发布 / 下线” + - 规则存在多个版本时,支持直接执行“回滚” +- 已完成 knowledge_bases 分支的边界确认: + - 当前 `ai-contract-platform` 侧的 `SharedAIPlatformClient` / SDK 只暴露 `list/create/documents/retry/sync-content` + - shared-ai Java 的 `PlatformKnowledgeController` 当前没有 publish / unpublish / rollback 端点 + - 因此 knowledge_bases 发布链当前不是本仓库单边能收口的问题,而是 shared-ai 远端接口能力缺口 + +### 本轮验证 +- 后端测试: + - `python -m unittest tests.test_review_rule_service tests.test_review_rules_api` + - 结果:`19 tests OK` +- 后端编译: + - `python -m py_compile backend/app/services/review_rule_service.py backend/app/api/review_rules.py` + - 结果:通过 +- 前端检查: + - `npm --prefix frontend run lint` + - 结果:通过 + - `npm --prefix frontend run build` + - 结果:通过,规则页构建正常 + +### 当前状态更新 +- `GOV-API-007` 仍保持 `IN_PROGRESS`,但范围已经更清楚: + - 模板发布链:已完成 + - 审查规则发布链:已完成 + - 知识库发布链:未完成,当前阻塞在 shared-ai 远端缺 publish / unpublish / rollback 端点 +- 从正式生产放行角度看,规则这条线已经具备“治理资产批准 -> 规则发布/下线/回滚”的能力 + +### 当前阻塞项 +- `knowledge_bases` 分支仍缺 shared-ai 远端配合: + - 需要在 shared-ai 平台补知识库状态更新 / 发布 / 下线 / 回滚端点 + - `ai-contract-platform` 侧才能把 knowledge_bases 发布链真正收口 +- 正式生产放行继续阻塞在真人复核、证据 READY 和最终批准签署