feat: add review rule publish workflow

This commit is contained in:
2026-05-13 22:49:25 +08:00
parent 2fb0fcc650
commit c7ca3301de
13 changed files with 836 additions and 4 deletions
+75
View File
@@ -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,
+1
View File
@@ -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
+8
View File
@@ -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),
+30
View File
@@ -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()
)
+135
View File
@@ -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)
+80 -1
View File
@@ -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()
+81
View File
@@ -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",
+104 -2
View File
@@ -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>
+21 -1
View File
@@ -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);
+12
View File
@@ -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 和最终批准签署