feat: add template publish workflow

This commit is contained in:
2026-05-13 21:44:29 +08:00
parent 74af8c1f3b
commit 1da8acf9a4
11 changed files with 721 additions and 6 deletions
+76
View File
@@ -16,6 +16,9 @@ ROUTE_PERMISSION_ACTIONS = {
"create": "templates:create",
"seed_defaults": "templates:seed_defaults",
"update": "templates:update",
"publish": "templates:publish",
"unpublish": "templates:unpublish",
"rollback": "templates:rollback",
"delete": "templates:delete",
}
@@ -192,6 +195,79 @@ def update_template(
return success_response(result)
@router.post("/{template_id}/publish")
def publish_template(
template_id: str,
template_service: TemplateService = Depends(get_template_service),
audit_log_service: AuditLogService = Depends(get_audit_log_service),
current_user: dict = Depends(require_current_user),
):
result = template_service.publish_template(current_user, template_id)
audit_log_service.record_event(
tenant_id=current_user.get("tenant_id"),
user_id=current_user.get("id"),
action="publish_template",
resource_type="template",
resource_id=result["id"],
detail_json={
"name": result["name"],
"template_code": result.get("template_code"),
"published_at": result.get("published_at"),
"published_by": result.get("published_by"),
"latest_governance_asset_id": result.get("latest_governance_asset_id"),
},
)
return success_response(result)
@router.post("/{template_id}/unpublish")
def unpublish_template(
template_id: str,
template_service: TemplateService = Depends(get_template_service),
audit_log_service: AuditLogService = Depends(get_audit_log_service),
current_user: dict = Depends(require_current_user),
):
result = template_service.unpublish_template(current_user, template_id)
audit_log_service.record_event(
tenant_id=current_user.get("tenant_id"),
user_id=current_user.get("id"),
action="unpublish_template",
resource_type="template",
resource_id=result["id"],
detail_json={
"name": result["name"],
"template_code": result.get("template_code"),
},
)
return success_response(result)
@router.post("/{template_id}/rollback")
def rollback_template(
template_id: str,
target_version_label: str = Form(...),
change_summary: Optional[str] = Form(default=None),
template_service: TemplateService = Depends(get_template_service),
audit_log_service: AuditLogService = Depends(get_audit_log_service),
current_user: dict = Depends(require_current_user),
):
result = template_service.rollback_template(current_user, template_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_template",
resource_type="template",
resource_id=result["id"],
detail_json={
"name": result["name"],
"template_code": result.get("template_code"),
"target_version_label": target_version_label,
"current_version_label": result.get("current_version_label"),
},
)
return success_response(result)
@router.delete("/{template_id}")
def delete_template(
template_id: str,
+4
View File
@@ -14,6 +14,7 @@ class ContractTemplate(Base):
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
contract_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
template_code: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
template_category: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
industry_category: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
description: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
@@ -24,6 +25,9 @@ class ContractTemplate(Base):
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_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
updated_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
+129
View File
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from app.models.contract_template import ContractTemplate
from app.models.contract_template_version import ContractTemplateVersion
from app.repositories.governance_asset_repository import GovernanceAssetRepository
from app.repositories.contract_template_repository import ContractTemplateRepository
from app.repositories.contract_template_version_repository import ContractTemplateVersionRepository
@@ -120,6 +121,7 @@ class TemplateService:
self.db = db
self.repository = ContractTemplateRepository(db) if db is not None else None
self.version_repository = ContractTemplateVersionRepository(db) if db is not None else None
self.governance_asset_repository = GovernanceAssetRepository(db) if db is not None else None
def list_templates(
self,
@@ -196,6 +198,7 @@ class TemplateService:
tenant_id=current_user["tenant_id"],
name=name.strip(),
contract_type=normalized_contract_type,
template_code=self._normalize_template_code(None, normalized_contract_type, name),
template_category=self._normalize_template_category(template_category, normalized_contract_type),
industry_category=self._normalize_industry_category(industry_category, normalized_contract_type),
description=description.strip() if description else None,
@@ -236,6 +239,7 @@ class TemplateService:
contract_template.name = name.strip()
contract_template.contract_type = normalized_contract_type
contract_template.template_code = self._normalize_template_code(contract_template.template_code, normalized_contract_type, name)
contract_template.template_category = self._normalize_template_category(template_category, normalized_contract_type)
contract_template.industry_category = self._normalize_industry_category(industry_category, normalized_contract_type)
contract_template.description = description.strip() if description else None
@@ -277,6 +281,7 @@ class TemplateService:
tenant_id=tenant_id,
name=template_data["name"],
contract_type=template_data["contract_type"],
template_code=self._normalize_template_code(None, template_data["contract_type"], template_data["name"]),
template_category=template_data["template_category"],
industry_category=template_data["industry_category"],
description=template_data["description"],
@@ -303,6 +308,73 @@ class TemplateService:
"template_names": [item["name"] for item in created_items],
}
def publish_template(self, current_user: Dict[str, Any], template_id: str) -> dict:
self._require_admin(current_user)
repository = self._require_repository()
governance_repository = self._require_governance_asset_repository()
contract_template = self._require_template(current_user, template_id)
governance_asset = self._require_publishable_governance_asset(current_user, contract_template)
contract_template.enabled = True
contract_template.published_at = self._now()
contract_template.published_by = current_user.get("id")
contract_template.latest_governance_asset_id = governance_asset.id
contract_template.updated_by = current_user.get("id")
updated = repository.update(contract_template)
return self._to_item(updated)
def unpublish_template(self, current_user: Dict[str, Any], template_id: str) -> dict:
self._require_admin(current_user)
repository = self._require_repository()
contract_template = self._require_template(current_user, template_id)
contract_template.enabled = False
contract_template.published_at = None
contract_template.published_by = None
contract_template.latest_governance_asset_id = None
contract_template.updated_by = current_user.get("id")
updated = repository.update(contract_template)
return self._to_item(updated)
def rollback_template(
self,
current_user: Dict[str, Any],
template_id: str,
target_version_label: str,
change_summary: Optional[str] = None,
) -> dict:
self._require_admin(current_user)
repository = self._require_repository()
version_repository = self._require_version_repository()
contract_template = self._require_template(current_user, template_id)
target_version = next(
(item for item in version_repository.list_by_template_id(current_user["tenant_id"], template_id) if item.version_label == target_version_label),
None,
)
if target_version is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="template version not found")
next_version_label = self._bump_version_label(contract_template.current_version_label)
normalized_change_summary = self._normalize_change_summary(change_summary, default=f"Rollback to {target_version_label}")
contract_template.name = target_version.name
contract_template.contract_type = target_version.contract_type
contract_template.template_category = target_version.template_category
contract_template.industry_category = target_version.industry_category
contract_template.description = target_version.description
contract_template.content = target_version.content
contract_template.variables_json = target_version.variables_json
contract_template.current_version_label = next_version_label
contract_template.latest_change_summary = normalized_change_summary
contract_template.latest_version_created_at = self._now()
contract_template.version_count = max(contract_template.version_count or 0, 1) + 1
contract_template.enabled = False
contract_template.published_at = None
contract_template.published_by = None
contract_template.latest_governance_asset_id = None
contract_template.updated_by = current_user.get("id")
updated = repository.update(contract_template)
self._create_version_snapshot(updated, current_user, normalized_change_summary, is_current=True)
return self._to_item(updated)
def _require_template(self, current_user: Dict[str, Any], template_id: str) -> ContractTemplate:
repository = self._require_repository()
contract_template = repository.get_by_id(template_id)
@@ -319,6 +391,11 @@ class TemplateService:
raise RuntimeError("template repository is not configured")
return self.repository
def _require_governance_asset_repository(self) -> GovernanceAssetRepository:
if self.governance_asset_repository is None:
raise RuntimeError("governance asset repository is not configured")
return self.governance_asset_repository
def _require_version_repository(self) -> ContractTemplateVersionRepository:
if self.version_repository is None:
raise RuntimeError("template version repository is not configured")
@@ -416,6 +493,54 @@ class TemplateService:
"nda": "general-enterprise",
}.get((contract_type or "").strip().lower(), "general-enterprise")
def _normalize_template_code(self, template_code: Optional[str], contract_type: Optional[str], name: str) -> str:
normalized = template_code.strip() if template_code else ""
if normalized:
return normalized
normalized_contract_type = (contract_type or "").strip().lower()
explicit_map = {
("labor", "劳动合同标准模板"): "TPL-LABOR-CONTRACT-001",
("procurement", "采购合同标准模板"): "TPL-PURCHASE-GOODS-001",
("nda", "保密协议标准模板"): "TPL-NDA-COMMERCIAL-001",
("sales", "销售合同模板"): "TPL-SALES-GOODS-001",
}
mapped = explicit_map.get((normalized_contract_type, name.strip()))
if mapped:
return mapped
if "saas" in name.strip().lower() or "软件即服务" in name:
return "TPL-SOFTWARE-SAAS-001"
if "技术开发" in name:
return "TPL-TECH-DEVELOPMENT-001"
if "合作框架" in name:
return "TPL-COOPERATION-FRAMEWORK-001"
if "委托服务" in name:
return "TPL-COMMISSION-SERVICE-001"
if "房屋租赁" in name:
return "TPL-LEASE-HOUSE-001"
if normalized_contract_type == "service":
return "TPL-SERVICE-GENERAL-001"
if normalized_contract_type == "procurement":
return "TPL-PURCHASE-GOODS-001"
if normalized_contract_type == "labor":
return "TPL-LABOR-CONTRACT-001"
if normalized_contract_type == "nda":
return "TPL-NDA-COMMERCIAL-001"
if normalized_contract_type == "sales":
return "TPL-SALES-GOODS-001"
slug = re.sub(r"[^A-Za-z0-9]+", "-", name.strip().upper()).strip("-") or "GENERAL"
return f"TPL-{slug[:48]}-001"
def _require_publishable_governance_asset(self, current_user: Dict[str, Any], contract_template: ContractTemplate):
governance_repository = self._require_governance_asset_repository()
if not contract_template.template_code:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="template_code is required before publish")
governance_asset = governance_repository.get_by_identity(current_user["tenant_id"], "contract_template", contract_template.template_code)
if governance_asset is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="governance asset not found for template_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 _bump_version_label(self, current_version_label: Optional[str]) -> str:
if not current_version_label:
return "v1.1"
@@ -434,12 +559,16 @@ class TemplateService:
"id": contract_template.id,
"name": contract_template.name,
"contract_type": contract_template.contract_type,
"template_code": contract_template.template_code,
"template_category": contract_template.template_category,
"industry_category": contract_template.industry_category,
"description": contract_template.description,
"content": contract_template.content,
"variables": contract_template.variables_json or [],
"enabled": contract_template.enabled,
"published_at": contract_template.published_at.isoformat() if contract_template.published_at else None,
"published_by": contract_template.published_by,
"latest_governance_asset_id": contract_template.latest_governance_asset_id,
"current_version_label": contract_template.current_version_label or "v1.0",
"latest_change_summary": contract_template.latest_change_summary,
"latest_version_created_at": (
@@ -0,0 +1,136 @@
"""add template publish fields
Revision ID: 20260513_0018
Revises: 20260512_0017
Create Date: 2026-05-13 16:30:00
"""
from __future__ import annotations
from typing import Optional
import sqlalchemy as sa
from alembic import op
revision = "20260513_0018"
down_revision = "20260512_0017"
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_template_code(contract_type: Optional[str], name: str) -> str:
normalized_contract_type = (contract_type or "").strip().lower()
normalized_name = (name or "").strip().lower()
explicit_map = {
("labor", "劳动合同标准模板"): "TPL-LABOR-CONTRACT-001",
("procurement", "采购合同标准模板"): "TPL-PURCHASE-GOODS-001",
("nda", "保密协议标准模板"): "TPL-NDA-COMMERCIAL-001",
("sales", "销售合同模板"): "TPL-SALES-GOODS-001",
}
for (mapped_contract_type, mapped_name), template_code in explicit_map.items():
if normalized_contract_type == mapped_contract_type and name == mapped_name:
return template_code
if "saas" in normalized_name or "软件即服务" in name:
return "TPL-SOFTWARE-SAAS-001"
if "技术开发" in name:
return "TPL-TECH-DEVELOPMENT-001"
if "合作框架" in name:
return "TPL-COOPERATION-FRAMEWORK-001"
if "委托服务" in name:
return "TPL-COMMISSION-SERVICE-001"
if "房屋租赁" in name:
return "TPL-LEASE-HOUSE-001"
if "销售" in name:
return "TPL-SALES-GOODS-001"
if normalized_contract_type == "service":
return "TPL-SERVICE-GENERAL-001"
if normalized_contract_type == "procurement":
return "TPL-PURCHASE-GOODS-001"
if normalized_contract_type == "labor":
return "TPL-LABOR-CONTRACT-001"
if normalized_contract_type == "nda":
return "TPL-NDA-COMMERCIAL-001"
slug = (
normalized_name.replace("/", " ")
.replace("\\", " ")
.replace("-", " ")
.replace("_", " ")
)
slug = "-".join(part for part in slug.split() if part) or "GENERAL"
return f"TPL-{slug[:48].upper()}-001"
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "contract_templates" not in _table_names(inspector):
return
columns = _column_names(inspector, "contract_templates")
if "template_code" not in columns:
op.add_column("contract_templates", sa.Column("template_code", sa.String(length=64), nullable=True))
if "published_at" not in columns:
op.add_column("contract_templates", sa.Column("published_at", sa.DateTime(timezone=True), nullable=True))
if "published_by" not in columns:
op.add_column("contract_templates", sa.Column("published_by", sa.String(length=64), nullable=True))
if "latest_governance_asset_id" not in columns:
op.add_column("contract_templates", sa.Column("latest_governance_asset_id", sa.String(length=64), nullable=True))
inspector = sa.inspect(bind)
indexes = _index_names(inspector, "contract_templates")
if "ix_contract_templates_template_code" not in indexes:
op.create_index("ix_contract_templates_template_code", "contract_templates", ["template_code"], unique=False)
rows = bind.execute(sa.text("SELECT id, contract_type, name, template_code FROM contract_templates")).mappings().all()
for row in rows:
if row["template_code"]:
continue
bind.execute(
sa.text(
"""
UPDATE contract_templates
SET template_code = :template_code
WHERE id = :template_id
"""
),
{
"template_id": row["id"],
"template_code": _derive_template_code(row.get("contract_type"), row.get("name") or ""),
},
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "contract_templates" not in _table_names(inspector):
return
indexes = _index_names(inspector, "contract_templates")
if "ix_contract_templates_template_code" in indexes:
op.drop_index("ix_contract_templates_template_code", table_name="contract_templates")
columns = _column_names(inspector, "contract_templates")
if "latest_governance_asset_id" in columns:
op.drop_column("contract_templates", "latest_governance_asset_id")
if "published_by" in columns:
op.drop_column("contract_templates", "published_by")
if "published_at" in columns:
op.drop_column("contract_templates", "published_at")
if "template_code" in columns:
op.drop_column("contract_templates", "template_code")
+87
View File
@@ -75,11 +75,30 @@ class StubVersionRepository:
item.is_current = False
class StubGovernanceAsset:
def __init__(self, asset_id: str, production_status: str = "ready"):
self.id = f"govast-{asset_id.lower()}"
self.asset_id = asset_id
self.production_status = production_status
class StubGovernanceAssetRepository:
def __init__(self):
self.items = {}
def add(self, asset_type, asset_id, production_status="ready"):
self.items[(asset_type, asset_id)] = StubGovernanceAsset(asset_id, production_status)
def get_by_identity(self, tenant_id, asset_type, source_asset_id):
return self.items.get((asset_type, source_asset_id))
class TemplateServiceTestCase(unittest.TestCase):
def setUp(self):
self.service = TemplateService(db=None)
self.service.repository = StubRepository()
self.service.version_repository = StubVersionRepository()
self.service.governance_asset_repository = StubGovernanceAssetRepository()
self.current_user = {
"id": "user-admin",
"tenant_id": "tenant-demo-001",
@@ -108,6 +127,7 @@ class TemplateServiceTestCase(unittest.TestCase):
self.assertEqual(created["description"], "Standard employment template")
self.assertEqual(created["content"], "Clause one")
self.assertEqual(created["variables"][0]["name"], "employee_name")
self.assertEqual(created["template_code"], "TPL-LABOR-CONTRACT-001")
self.assertEqual(created["template_category"], "employment-hr")
self.assertEqual(created["industry_category"], "general-enterprise")
self.assertEqual(created["current_version_label"], "v1.0")
@@ -202,6 +222,7 @@ class TemplateServiceTestCase(unittest.TestCase):
self.assertEqual(updated["current_version_label"], "v1.1")
self.assertEqual(updated["latest_change_summary"], "Add amount variable")
self.assertEqual(updated["version_count"], 2)
self.assertEqual(updated["template_code"], "TPL-PURCHASE-GOODS-001")
self.assertEqual(updated["template_category"], "procurement-supply")
self.assertEqual(updated["industry_category"], "general-enterprise")
@@ -274,6 +295,72 @@ class TemplateServiceTestCase(unittest.TestCase):
self.assertEqual(filtered["total"], 1)
self.assertEqual(filtered["items"][0]["name"], "Service Template")
def test_publish_and_unpublish_template_require_governance_asset(self):
created = self.service.create_template(
self.current_user,
name="Procurement Template",
contract_type="procurement",
description="Ready for publish",
content="Template body",
variables_json=None,
enabled=False,
)
self.service.governance_asset_repository.add("contract_template", "TPL-PURCHASE-GOODS-001", production_status="ready")
published = self.service.publish_template(self.current_user, created["id"])
self.assertEqual(published["published_by"], self.current_user["id"])
self.assertIsNotNone(published["published_at"])
self.assertEqual(published["latest_governance_asset_id"], "govast-tpl-purchase-goods-001")
self.assertTrue(published["enabled"])
unpublished = self.service.unpublish_template(self.current_user, created["id"])
self.assertIsNone(unpublished["published_at"])
self.assertIsNone(unpublished["published_by"])
self.assertIsNone(unpublished["latest_governance_asset_id"])
self.assertFalse(unpublished["enabled"])
def test_publish_template_blocks_without_ready_governance_asset(self):
created = self.service.create_template(
self.current_user,
name="Procurement Template",
contract_type="procurement",
description="Ready for publish",
content="Template body",
variables_json=None,
enabled=False,
)
with self.assertRaises(HTTPException) as context:
self.service.publish_template(self.current_user, created["id"])
self.assertEqual(context.exception.status_code, 409)
def test_rollback_template_creates_new_version_and_unpublishes(self):
created = self.service.create_template(
self.current_user,
name="Procurement Template",
contract_type="procurement",
description="Original",
content="Original content",
variables_json=None,
enabled=True,
change_summary="Initial version",
)
self.service.update_template(
self.current_user,
created["id"],
name="Procurement Template Updated",
contract_type="procurement",
description="Updated",
content="Updated content",
variables_json=None,
enabled=True,
change_summary="Second version",
)
rolled_back = self.service.rollback_template(self.current_user, created["id"], "v1.0")
self.assertEqual(rolled_back["content"], "Original content")
self.assertEqual(rolled_back["current_version_label"], "v1.2")
self.assertFalse(rolled_back["enabled"])
self.assertIsNone(rolled_back["published_at"])
def test_variables_json_must_be_valid_object_array(self):
with self.assertRaises(HTTPException) as invalid_json_context:
self.service.create_template(
+80
View File
@@ -31,12 +31,16 @@ class StubTemplateService:
"id": "tpl-123",
"name": "Employment Template",
"contract_type": contract_type or "labor",
"template_code": "TPL-LABOR-CONTRACT-001",
"template_category": template_category or "employment-hr",
"industry_category": industry_category or "general-enterprise",
"description": "Standard employment template",
"content": "Clause one",
"variables": [{"name": "employee_name", "label": "Employee Name"}],
"enabled": True,
"published_at": None,
"published_by": None,
"latest_governance_asset_id": None,
"current_version_label": "v1.2",
"latest_change_summary": "Add probation clause",
"latest_version_created_at": "2026-04-17T00:00:00+00:00",
@@ -67,6 +71,7 @@ class StubTemplateService:
"change_summary": "Add probation clause",
"name": "Employment Template",
"contract_type": "labor",
"template_code": "TPL-LABOR-CONTRACT-001",
"template_category": "employment-hr",
"industry_category": "general-enterprise",
"description": "Standard employment template",
@@ -87,12 +92,16 @@ class StubTemplateService:
"id": "tpl-123",
"name": name,
"contract_type": contract_type,
"template_code": "TPL-PROCUREMENT-DEMO-001",
"template_category": template_category or "general-business",
"industry_category": industry_category or "general-enterprise",
"description": description,
"content": content,
"variables": self._parse_variables(variables_json),
"enabled": enabled,
"published_at": None,
"published_by": None,
"latest_governance_asset_id": None,
"current_version_label": "v1.0",
"latest_change_summary": change_summary or "Initial template version",
"latest_version_created_at": "2026-04-16T00:00:00+00:00",
@@ -110,12 +119,16 @@ class StubTemplateService:
"id": template_id,
"name": name,
"contract_type": contract_type,
"template_code": "TPL-PROCUREMENT-DEMO-001",
"template_category": template_category or "general-business",
"industry_category": industry_category or "general-enterprise",
"description": description,
"content": content,
"variables": self._parse_variables(variables_json),
"enabled": enabled,
"published_at": None,
"published_by": None,
"latest_governance_asset_id": None,
"current_version_label": "v1.1",
"latest_change_summary": change_summary or "v1.1 template update",
"latest_version_created_at": "2026-04-16T00:00:00+00:00",
@@ -138,6 +151,51 @@ class StubTemplateService:
"template_names": [],
}
def publish_template(self, current_user, template_id):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
return {
"id": template_id,
"name": "Employment Template",
"contract_type": "labor",
"template_code": "TPL-LABOR-CONTRACT-001",
"template_category": "employment-hr",
"industry_category": "general-enterprise",
"description": "Standard employment template",
"content": "Clause one",
"variables": [{"name": "employee_name", "label": "Employee Name"}],
"enabled": True,
"published_at": "2026-05-13T08:00:00+00:00",
"published_by": current_user["id"],
"latest_governance_asset_id": "govast-tpl-labor-contract-001",
"current_version_label": "v1.2",
"latest_change_summary": "Add probation clause",
"latest_version_created_at": "2026-04-17T00:00:00+00:00",
"version_count": 3,
"created_by": "user-admin",
"updated_by": current_user["id"],
"created_at": "2026-04-16T00:00:00+00:00",
"updated_at": "2026-05-13T08:00:00+00:00",
}
def unpublish_template(self, current_user, template_id):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
result = self.publish_template(current_user, template_id)
result["enabled"] = False
result["published_at"] = None
result["published_by"] = None
result["latest_governance_asset_id"] = None
return result
def rollback_template(self, current_user, template_id, target_version_label, change_summary=None):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
result = self.unpublish_template(current_user, template_id)
result["current_version_label"] = "v1.3"
result["latest_change_summary"] = change_summary or f"Rollback to {target_version_label}"
return result
def delete_template(self, current_user, template_id):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
@@ -173,6 +231,7 @@ class TemplatesApiTestCase(unittest.TestCase):
payload = response.json()["data"]["items"][0]
self.assertEqual(payload["name"], "Employment Template")
self.assertEqual(payload["contract_type"], "labor")
self.assertEqual(payload["template_code"], "TPL-LABOR-CONTRACT-001")
self.assertEqual(payload["template_category"], "employment-hr")
self.assertEqual(payload["industry_category"], "general-enterprise")
self.assertEqual(payload["current_version_label"], "v1.2")
@@ -363,6 +422,27 @@ class TemplatesApiTestCase(unittest.TestCase):
},
)
def test_admin_can_publish_unpublish_and_rollback_template(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/templates/tpl-123/publish")
unpublish_response = self.client.post("/api/v1/templates/tpl-123/unpublish")
rollback_response = self.client.post("/api/v1/templates/tpl-123/rollback", data={"target_version_label": "v1.1"})
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.3")
self.assertEqual(self.audit_log_service.events[-3]["action"], "publish_template")
self.assertEqual(self.audit_log_service.events[-2]["action"], "unpublish_template")
self.assertEqual(self.audit_log_service.events[-1]["action"], "rollback_template")
def test_member_cannot_access_templates(self):
app.dependency_overrides[require_current_user] = lambda: {
"id": "user-member",
+126 -4
View File
@@ -5,7 +5,18 @@ 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 { createTemplate, deleteTemplate, getTemplateVersions, listTemplatesWithFilters, seedDefaultTemplates, updateTemplate, updateTemplateStatus } from "@/services/template";
import {
createTemplate,
deleteTemplate,
getTemplateVersions,
listTemplatesWithFilters,
publishTemplate,
rollbackTemplate,
seedDefaultTemplates,
unpublishTemplate,
updateTemplate,
updateTemplateStatus,
} from "@/services/template";
import { useAuthStore } from "@/stores/auth.store";
import { formatIndustryCategory, formatTemplateCategory, industryCategoryOptions, templateCategoryOptions } from "@/lib/template-taxonomy";
import type { TemplateItem, TemplateVersionItem } from "@/types/api";
@@ -101,6 +112,8 @@ export default function TemplatesPage() {
const [seeding, setSeeding] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [statusChangingId, setStatusChangingId] = useState<string | null>(null);
const [publishingId, setPublishingId] = useState<string | null>(null);
const [rollbackingId, setRollbackingId] = useState<string | null>(null);
const [items, setItems] = useState<TemplateItem[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [contractTypeFilter, setContractTypeFilter] = useState<string | undefined>(undefined);
@@ -307,6 +320,66 @@ export default function TemplatesPage() {
}
};
const handlePublish = async (item: TemplateItem) => {
setPublishingId(item.id);
try {
const result = await publishTemplate(item.id);
if (result.code !== 0) {
messageApi.error(result.message || "模板发布失败");
return;
}
messageApi.success("模板已发布到生产可用集合");
await loadTemplates(page, pageSize, contractTypeFilter, templateCategoryFilter, industryCategoryFilter);
} catch (error) {
messageApi.error(error instanceof Error ? error.message : "模板发布失败,请稍后重试");
} finally {
setPublishingId(null);
}
};
const handleUnpublish = async (item: TemplateItem) => {
setPublishingId(item.id);
try {
const result = await unpublishTemplate(item.id);
if (result.code !== 0) {
messageApi.error(result.message || "模板下线失败");
return;
}
messageApi.success("模板已从生产可用集合移除");
await loadTemplates(page, pageSize, contractTypeFilter, templateCategoryFilter, industryCategoryFilter);
} catch (error) {
messageApi.error(error instanceof Error ? error.message : "模板下线失败,请稍后重试");
} finally {
setPublishingId(null);
}
};
const handleRollback = async (item: TemplateItem, versionLabel: string) => {
setRollbackingId(`${item.id}:${versionLabel}`);
try {
const result = await rollbackTemplate(item.id, {
target_version_label: versionLabel,
change_summary: `Rollback to ${versionLabel}`,
});
if (result.code !== 0) {
messageApi.error(result.message || "模板回滚失败");
return;
}
messageApi.success(`模板已回滚为新版本,来源 ${versionLabel}`);
setVersionHistoryMap((current) => {
const next = { ...current };
delete next[item.id];
return next;
});
await loadTemplates(page, pageSize, contractTypeFilter, templateCategoryFilter, industryCategoryFilter);
await loadTemplateVersions(item.id);
} 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;
@@ -322,7 +395,7 @@ export default function TemplatesPage() {
}
};
const versionColumns: ColumnsType<TemplateVersionItem> = [
const buildVersionColumns = (templateId: string): ColumnsType<TemplateVersionItem> => [
{
title: "版本",
dataIndex: "version_label",
@@ -369,6 +442,26 @@ export default function TemplatesPage() {
width: 180,
render: (value: string) => formatDateTime(value),
},
{
title: "操作",
key: "actions",
width: 120,
render: (_, version, index) =>
index > 0 ? (
<Button
size="small"
loading={rollbackingId === `${templateId}:${version.version_label}`}
onClick={() => {
const targetTemplate = items.find((entry) => entry.id === templateId);
if (targetTemplate) {
void handleRollback(targetTemplate, version.version_label);
}
}}
>
回滚到此版本
</Button>
) : null,
},
];
const columns: ColumnsType<TemplateItem> = [
@@ -394,6 +487,13 @@ export default function TemplatesPage() {
width: 160,
render: (value: string | null) => formatIndustryCategory(value),
},
{
title: "模板编号",
dataIndex: "template_code",
key: "template_code",
width: 170,
render: (value: string | null) => value ? <Text code>{value}</Text> : "-",
},
{
title: "当前版本",
dataIndex: "current_version_label",
@@ -414,6 +514,19 @@ export default function TemplatesPage() {
key: "latest_change_summary",
render: (value: string | null) => 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: "enabled",
@@ -449,6 +562,15 @@ export default function TemplatesPage() {
<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>
)}
<Popconfirm title="确认删除这个模板?" okText="删除" cancelText="取消" onConfirm={() => void handleDelete(item.id)}>
<Button danger size="small" loading={deletingId === item.id}>
删除
@@ -681,7 +803,7 @@ export default function TemplatesPage() {
{versionLoadingId === item.id && !versions ? (
<Text type="secondary">版本历史加载中...</Text>
) : versions && versions.length > 0 ? (
<Table rowKey="id" size="small" pagination={false} columns={versionColumns} dataSource={versions} />
<Table rowKey="id" size="small" pagination={false} columns={buildVersionColumns(item.id)} dataSource={versions} />
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无版本历史。" />
)}
@@ -690,7 +812,7 @@ export default function TemplatesPage() {
);
},
}}
scroll={{ x: 1400 }}
scroll={{ x: 1720 }}
/>
</Card>
</div>
+27
View File
@@ -4,6 +4,7 @@ import type {
PageResponse,
TemplateCreatePayload,
TemplateItem,
TemplateRollbackPayload,
TemplateVersionListResponse,
TemplateUpdatePayload,
} from "@/types/api";
@@ -60,6 +61,12 @@ function buildTemplateFormData(payload: TemplateUpdatePayload) {
if (payload.contract_type) {
formData.append("contract_type", payload.contract_type);
}
if (payload.template_category) {
formData.append("template_category", payload.template_category);
}
if (payload.industry_category) {
formData.append("industry_category", payload.industry_category);
}
if (payload.description) {
formData.append("description", payload.description);
}
@@ -90,6 +97,26 @@ export async function updateTemplateStatus(templateId: string, template: Templat
});
}
export async function publishTemplate(templateId: string) {
const response = await http.post<ApiResponse<TemplateItem>>(`/templates/${templateId}/publish`);
return response.data;
}
export async function unpublishTemplate(templateId: string) {
const response = await http.post<ApiResponse<TemplateItem>>(`/templates/${templateId}/unpublish`);
return response.data;
}
export async function rollbackTemplate(templateId: string, payload: TemplateRollbackPayload) {
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<TemplateItem>>(`/templates/${templateId}/rollback`, formData);
return response.data;
}
export async function deleteTemplate(templateId: string) {
await http.delete(`/templates/${templateId}`);
}
+8
View File
@@ -358,12 +358,16 @@ export type TemplateItem = {
id: string;
name: string;
contract_type: string | null;
template_code: string | null;
template_category: string | null;
industry_category: string | null;
description: string | null;
content: string;
variables: TemplateVariableItem[];
enabled: 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;
@@ -405,6 +409,10 @@ export type TemplateCreatePayload = {
};
export type TemplateUpdatePayload = TemplateCreatePayload;
export type TemplateRollbackPayload = {
target_version_label: string;
change_summary?: string;
};
export type TemplateListResponse = PageResponse<TemplateItem>;
export type TemplateVersionListResponse = {
template_id: string;
@@ -923,3 +923,49 @@
- reviewer 填写真实 `return-bundle.csv`
- reviewer 将 evidence 从 `TEMPLATE` 改成 `READY`
- 最终批准人填写真实批准日期并签署清单
## 2026-05-13 GOV-DB-006 完成与 GOV-API-007 模板发布链落地
### 本轮完成项
- 已完成 `GOV-DB-006`:
- `contract_templates` 增加 `template_code`、`published_at`、`published_by`、`latest_governance_asset_id`
- 新增迁移:`backend/migrations/versions/20260513_0018_add_template_publish_fields.py`
- 现有模板会按合同类型/名称自动补齐模板编号,用于和 formal-launch 治理资产映射
- 已完成 `GOV-API-007` 的模板分支:
- 新增模板发布接口:`POST /api/v1/templates/{template_id}/publish`
- 新增模板下线接口:`POST /api/v1/templates/{template_id}/unpublish`
- 新增模板回滚接口:`POST /api/v1/templates/{template_id}/rollback`
- 模板发布前会校验 `template_code` 对应治理资产是否处于生产可发布状态
- 模板回滚会生成新的模板版本,而不是直接覆盖旧版本
- 已补齐模板页的基础操作:
- 模板列表显示 `template_code` 和发布状态
- 管理员可在模板页直接执行“发布 / 下线”
- 在版本历史中可直接执行“回滚到此版本”
- 已顺手修复模板前端表单一个老问题:
- `template_category` 和 `industry_category` 现在会真正随表单提交,不再只停在前端显示层
### 本轮验证
- 后端测试:
- `python -m unittest tests.test_template_service tests.test_templates_api`
- 结果:`21 tests OK`
- 后端编译:
- `python -m py_compile backend/app/services/template_service.py backend/app/api/templates.py`
- 结果:通过
- 前端检查:
- `npm --prefix frontend run lint`
- 结果:通过
- `npm --prefix frontend run build`
- 结果:通过,模板页构建正常
### 当前状态更新
- `GOV-DB-006` 已从 `PLANNED` 更新为 `COMPLETED`
- `GOV-API-007` 已进入 `IN_PROGRESS`
- 模板发布链已具备
- 规则发布链、知识库发布链仍未补齐
- 现在模板层已具备“编辑 -> 版本化 -> 发布 -> 下线 -> 回滚”的正式治理动作
### 当前阻塞项
- `GOV-API-007` 还未全量完成:
- `review_rules` 仍缺 publish / unpublish / rollback
- `knowledge_bases` 仍缺 publish / unpublish / rollback
- 正式生产放行层面仍然继续阻塞在真实人工复核和最终批准签署
@@ -4,7 +4,7 @@ batch,priority,area,task_id,title,current_gap,target_deliverable,depends_on,acce
5A,P0,database,GOV-DB-003,新增复核决议表,当前复核结论只存在CSV回填模板中,新增 governance_review_decisions 表支持 approved rejected changes_required 等结果,GOV-DB-001;GOV-DB-002,系统可记录多次复核历史和最新有效结论,COMPLETED
5A,P0,database,GOV-DB-004,新增证据登记表,当前证据路径和证据状态只存在文档与脚本规则中,新增 governance_evidences 表支持 file_id url status checksum reviewer metadata,GOV-DB-001,系统可校验证据是否存在且不是模板占位,COMPLETED
5A,P0,database,GOV-DB-005,新增发布批次与门禁结果表,当前上线批准状态只存在 PowerShell 产物中,新增 governance_release_batches 和 governance_gate_runs 表,GOV-DB-001;GOV-DB-003;GOV-DB-004,系统可追踪一次发布批次的门禁快照和结果,COMPLETED
5A,P1,database,GOV-DB-006,模板表补治理字段,contract_templates 只有内容和启停没有版本与发布元数据,为 contract_templates 增加 template_code version published_at published_by latest_governance_asset_id,GOV-DB-001,模板可区分草稿版和生产发布版,PLANNED
5A,P1,database,GOV-DB-006,模板表补治理字段,contract_templates 只有内容和启停没有版本与发布元数据,为 contract_templates 增加 template_code version published_at published_by latest_governance_asset_id,GOV-DB-001,模板可区分草稿版和生产发布版,COMPLETED
5A,P1,database,GOV-DB-007,知识库文档表补法源元数据,知识库文档缺少效力级别生效日期发文机关和法源类型,为知识库相关表增加 source_type authority effective_date region tags,none,知识库可按法源维度治理和过滤,PLANNED
5B,P0,backend_api,GOV-API-001,新增治理资产列表接口,当前没有统一治理视图接口,新增 /governance/assets 列表筛选汇总接口,GOV-DB-001,前端可按资产类型状态角色查看治理资产,COMPLETED
5B,P0,backend_api,GOV-API-002,新增复核分配接口,当前无法在系统内分配真实复核人,新增 /governance/assignments 创建更新撤销接口,GOV-DB-002,管理员可在系统内完成复核任务分配,COMPLETED
@@ -12,7 +12,7 @@ batch,priority,area,task_id,title,current_gap,target_deliverable,depends_on,acce
5B,P0,backend_api,GOV-API-004,新增证据上传绑定接口,当前系统没有专门的治理证据绑定接口,新增 /governance/evidences 上传绑定替换校验接口,GOV-DB-004,系统可阻止模板证据进入已批准状态,COMPLETED
5B,P0,backend_api,GOV-API-005,新增上线批准汇总接口,当前没有系统内 release approval summary,新增 /governance/releases/summary 和 /governance/releases/{id} 接口,GOV-DB-005,前端可直接展示当前上线阻断项与通过项,COMPLETED
5B,P0,backend_api,GOV-API-006,新增CSV台账导入接口,当前正式上线台账只靠仓库脚本导入,新增 /governance/imports/formal-launch 接口读取既有 CSV 映射到治理表,GOV-DB-001;GOV-DB-002;GOV-DB-003;GOV-DB-004,旧台账可一次性导入数据库且保留来源信息,COMPLETED
5B,P1,backend_api,GOV-API-007,模板规则知识库发布接口,当前内容模块没有生产发布动作,为 templates review-rules knowledge-bases 增加 publish/unpublish/rollback 接口,GOV-DB-005;GOV-DB-006,已批准资产才能被发布到生产可用集合,PLANNED
5B,P1,backend_api,GOV-API-007,模板规则知识库发布接口,当前内容模块没有生产发布动作,为 templates review-rules knowledge-bases 增加 publish/unpublish/rollback 接口,GOV-DB-005;GOV-DB-006,已批准资产才能被发布到生产可用集合,IN_PROGRESS
5C,P0,frontend,GOV-WEB-001,新增复核总控中心页面,当前没有统一治理控制台,新增 /governance 页面展示资产统计阻断项角色视图和发布批次,GOV-API-001;GOV-API-005,管理员可在单页看到当前正式上线治理全貌,COMPLETED
5C,P0,frontend,GOV-WEB-002,新增复核人工作台页面,当前真实法务业务运营没有系统内待办页,新增 /governance/workbench 页面支持按角色查看待处理任务,GOV-API-001;GOV-API-002;GOV-API-003,复核人可只看到与自己相关的任务包,COMPLETED
5C,P0,frontend,GOV-WEB-003,新增资产详情与证据页,当前无法在系统内查看单资产的签署链和证据链,新增治理资产详情页和证据面板,GOV-API-001;GOV-API-003;GOV-API-004,可在页面内查看每个资产的复核记录和证据状态,COMPLETED
1 batch priority area task_id title current_gap target_deliverable depends_on acceptance status
4 5A P0 database GOV-DB-003 新增复核决议表 当前复核结论只存在CSV回填模板中 新增 governance_review_decisions 表支持 approved rejected changes_required 等结果 GOV-DB-001;GOV-DB-002 系统可记录多次复核历史和最新有效结论 COMPLETED
5 5A P0 database GOV-DB-004 新增证据登记表 当前证据路径和证据状态只存在文档与脚本规则中 新增 governance_evidences 表支持 file_id url status checksum reviewer metadata GOV-DB-001 系统可校验证据是否存在且不是模板占位 COMPLETED
6 5A P0 database GOV-DB-005 新增发布批次与门禁结果表 当前上线批准状态只存在 PowerShell 产物中 新增 governance_release_batches 和 governance_gate_runs 表 GOV-DB-001;GOV-DB-003;GOV-DB-004 系统可追踪一次发布批次的门禁快照和结果 COMPLETED
7 5A P1 database GOV-DB-006 模板表补治理字段 contract_templates 只有内容和启停没有版本与发布元数据 为 contract_templates 增加 template_code version published_at published_by latest_governance_asset_id GOV-DB-001 模板可区分草稿版和生产发布版 PLANNED COMPLETED
8 5A P1 database GOV-DB-007 知识库文档表补法源元数据 知识库文档缺少效力级别生效日期发文机关和法源类型 为知识库相关表增加 source_type authority effective_date region tags none 知识库可按法源维度治理和过滤 PLANNED
9 5B P0 backend_api GOV-API-001 新增治理资产列表接口 当前没有统一治理视图接口 新增 /governance/assets 列表筛选汇总接口 GOV-DB-001 前端可按资产类型状态角色查看治理资产 COMPLETED
10 5B P0 backend_api GOV-API-002 新增复核分配接口 当前无法在系统内分配真实复核人 新增 /governance/assignments 创建更新撤销接口 GOV-DB-002 管理员可在系统内完成复核任务分配 COMPLETED
12 5B P0 backend_api GOV-API-004 新增证据上传绑定接口 当前系统没有专门的治理证据绑定接口 新增 /governance/evidences 上传绑定替换校验接口 GOV-DB-004 系统可阻止模板证据进入已批准状态 COMPLETED
13 5B P0 backend_api GOV-API-005 新增上线批准汇总接口 当前没有系统内 release approval summary 新增 /governance/releases/summary 和 /governance/releases/{id} 接口 GOV-DB-005 前端可直接展示当前上线阻断项与通过项 COMPLETED
14 5B P0 backend_api GOV-API-006 新增CSV台账导入接口 当前正式上线台账只靠仓库脚本导入 新增 /governance/imports/formal-launch 接口读取既有 CSV 映射到治理表 GOV-DB-001;GOV-DB-002;GOV-DB-003;GOV-DB-004 旧台账可一次性导入数据库且保留来源信息 COMPLETED
15 5B P1 backend_api GOV-API-007 模板规则知识库发布接口 当前内容模块没有生产发布动作 为 templates review-rules knowledge-bases 增加 publish/unpublish/rollback 接口 GOV-DB-005;GOV-DB-006 已批准资产才能被发布到生产可用集合 PLANNED IN_PROGRESS
16 5C P0 frontend GOV-WEB-001 新增复核总控中心页面 当前没有统一治理控制台 新增 /governance 页面展示资产统计阻断项角色视图和发布批次 GOV-API-001;GOV-API-005 管理员可在单页看到当前正式上线治理全貌 COMPLETED
17 5C P0 frontend GOV-WEB-002 新增复核人工作台页面 当前真实法务业务运营没有系统内待办页 新增 /governance/workbench 页面支持按角色查看待处理任务 GOV-API-001;GOV-API-002;GOV-API-003 复核人可只看到与自己相关的任务包 COMPLETED
18 5C P0 frontend GOV-WEB-003 新增资产详情与证据页 当前无法在系统内查看单资产的签署链和证据链 新增治理资产详情页和证据面板 GOV-API-001;GOV-API-003;GOV-API-004 可在页面内查看每个资产的复核记录和证据状态 COMPLETED