feat: add review rule publish workflow
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
unittest.main()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ReviewRuleItem[]>([]);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||
const [rollbackingId, setRollbackingId] = useState<string | null>(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 ? <Text code>{value}</Text> : "-",
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Tag color={item.published_at ? "green" : "default"}>{item.published_at ? "已发布" : "未发布"}</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.published_at ? formatDateTime(item.published_at) : "需治理批准后发布"}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "is_active",
|
||||
@@ -244,6 +332,20 @@ export default function RulesPage() {
|
||||
<Button size="small" onClick={() => setEditingId(item.id)}>
|
||||
编辑
|
||||
</Button>
|
||||
{item.published_at ? (
|
||||
<Button size="small" loading={publishingId === item.id} onClick={() => void handleUnpublish(item)}>
|
||||
下线
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="small" type="primary" loading={publishingId === item.id} onClick={() => void handlePublish(item)}>
|
||||
发布
|
||||
</Button>
|
||||
)}
|
||||
{item.version_count > 1 ? (
|
||||
<Button size="small" loading={rollbackingId === item.id} onClick={() => void handleRollback(item)}>
|
||||
回滚
|
||||
</Button>
|
||||
) : null}
|
||||
<Switch
|
||||
checked={item.is_active}
|
||||
loading={statusChangingId === item.id}
|
||||
@@ -370,7 +472,7 @@ export default function RulesPage() {
|
||||
</Space>
|
||||
),
|
||||
}}
|
||||
scroll={{ x: 1100 }}
|
||||
scroll={{ x: 1440 }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -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<ApiResponse<PageResponse<ReviewRuleItem>>>("/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<ApiResponse<ReviewRuleItem>>(`/review-rules/${ruleId}/publish`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function unpublishReviewRule(ruleId: string) {
|
||||
const response = await http.post<ApiResponse<ReviewRuleItem>>(`/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<ApiResponse<ReviewRuleItem>>(`/review-rules/${ruleId}/rollback`, formData);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
function buildReviewRuleFormData(payload: ReviewRuleCreatePayload | ReviewRuleUpdatePayload) {
|
||||
const formData = new FormData();
|
||||
formData.append("name", payload.name);
|
||||
|
||||
@@ -276,6 +276,7 @@ export type KnowledgeBaseDocumentListResponse = PageResponse<KnowledgeBaseDocume
|
||||
export type ReviewRuleItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
rule_code: string | null;
|
||||
keyword: string;
|
||||
contract_type: string;
|
||||
risk_level: string;
|
||||
@@ -283,6 +284,13 @@ export type ReviewRuleItem = {
|
||||
suggestion: string | null;
|
||||
legal_basis: string | null;
|
||||
is_active: boolean;
|
||||
published_at: string | null;
|
||||
published_by: string | null;
|
||||
latest_governance_asset_id: string | null;
|
||||
current_version_label: string;
|
||||
latest_change_summary: string | null;
|
||||
latest_version_created_at: string | null;
|
||||
version_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -299,6 +307,10 @@ export type ReviewRuleCreatePayload = {
|
||||
};
|
||||
|
||||
export type ReviewRuleUpdatePayload = ReviewRuleCreatePayload;
|
||||
export type ReviewRuleRollbackPayload = {
|
||||
target_version_label: string;
|
||||
change_summary?: string;
|
||||
};
|
||||
export type ReviewRuleListResponse = PageResponse<ReviewRuleItem>;
|
||||
|
||||
export type UserItem = {
|
||||
|
||||
@@ -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 和最终批准签署
|
||||
|
||||
Reference in New Issue
Block a user