From e80e8febd823ec801db80b581526ef58aecc1d66 Mon Sep 17 00:00:00 2001 From: wren <“porlong@qq.com”> Date: Tue, 28 Apr 2026 13:15:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20multi-region=20rule=20isolation=20?= =?UTF-8?q?=E2=80=94=20region=20column=20+=20config=20+=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB: add region column to leaudit_rule_sets + leaudit_rule_type_bindings - DB: change UNIQUE constraint from (rule_type) to (rule_type, region) - Config: add APP_REGION to app.toml + AppSettings + __init__.pyi - AuditServiceImpl: filter bindings by APP_REGION - RuleServiceImpl: ListBindings/CreateBinding use APP_REGION - Seed script: accept --region arg, tag rules by region - OssPathUtils: BuildRuleYamlKey already accepts Region parameter Each region can now have its own independent copy of the same rule_type, stored in separate OSS paths and DB rows, keyed by region. --- fastapi_admin/config/__init__.pyi | 1 + fastapi_admin/config/_settings.py | 1 + .../services/impl/auditServiceImpl.py | 4 +- .../services/impl/ruleServiceImpl.py | 11 +++++- scripts/m4_seed_rules.py | 39 ++++++++++++------- 5 files changed, 38 insertions(+), 18 deletions(-) diff --git a/fastapi_admin/config/__init__.pyi b/fastapi_admin/config/__init__.pyi index dddb882..390742d 100644 --- a/fastapi_admin/config/__init__.pyi +++ b/fastapi_admin/config/__init__.pyi @@ -4,6 +4,7 @@ APP_NAME: str APP_HOST: str APP_PORT: int +APP_REGION: str APP_CORS_ORIGINS: str # JWT diff --git a/fastapi_admin/config/_settings.py b/fastapi_admin/config/_settings.py index e6a869f..5a7bce4 100644 --- a/fastapi_admin/config/_settings.py +++ b/fastapi_admin/config/_settings.py @@ -19,6 +19,7 @@ class AppSettings(_Base): APP_NAME: str = "LeAudit Platform" APP_HOST: str = "0.0.0.0" APP_PORT: int = 8000 + APP_REGION: str = "default" APP_CORS_ORIGINS: str = "*" diff --git a/fastapi_modules/fastapi_leaudit/services/impl/auditServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/auditServiceImpl.py index 60ea080..7907950 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/auditServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/auditServiceImpl.py @@ -6,6 +6,7 @@ from datetime import datetime +from fastapi_admin.config import APP_REGION from fastapi_common.fastapi_common_logger import logger from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum @@ -72,11 +73,12 @@ class AuditServiceImpl(IAuditService): LEFT JOIN leaudit_rule_versions rv ON rv.id = rs.current_version_id WHERE b.doc_type_id = :doc_type_id AND b.is_active = true + AND b.region = :region ORDER BY b.priority DESC, b.id DESC LIMIT 1 """ ), - {"doc_type_id": document.typeId}, + {"doc_type_id": document.typeId, "region": APP_REGION}, ) binding = bindingResult.mappings().first() if not binding or not binding["rule_set_id"] or not binding["rule_version_id"]: diff --git a/fastapi_modules/fastapi_leaudit/services/impl/ruleServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/ruleServiceImpl.py index 5edd0de..837afca 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/ruleServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/ruleServiceImpl.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +from fastapi_admin.config import APP_REGION from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException @@ -362,10 +363,11 @@ class RuleServiceImpl(IRuleService): JOIN leaudit_rule_sets rs ON rs.id = b.rule_set_id WHERE rs.rule_type = :rule_type AND rs.deleted_at IS NULL + AND b.region = :region ORDER BY b.priority DESC, b.id DESC """ ), - {"rule_type": RuleType}, + {"rule_type": RuleType, "region": APP_REGION}, ) else: Result = await Session.execute( @@ -385,9 +387,11 @@ class RuleServiceImpl(IRuleService): FROM leaudit_rule_type_bindings b JOIN leaudit_rule_sets rs ON rs.id = b.rule_set_id WHERE rs.deleted_at IS NULL + AND b.region = :region ORDER BY rs.rule_type, b.priority DESC, b.id DESC """ ), + {"region": APP_REGION}, ) return [ RuleBindingVO( @@ -447,6 +451,7 @@ class RuleServiceImpl(IRuleService): binding_mode, priority, is_active, + region, note ) VALUES ( :doc_type_id, @@ -455,10 +460,11 @@ class RuleServiceImpl(IRuleService): :binding_mode, :priority, true, + :region, :note ) RETURNING id, doc_type_id, doc_type_code, rule_set_id, - binding_mode, priority, is_active, note + binding_mode, priority, is_active, region, note """ ), { @@ -467,6 +473,7 @@ class RuleServiceImpl(IRuleService): "rule_set_id": RuleSetId, "binding_mode": BindingMode, "priority": Priority, + "region": APP_REGION, "note": Note, }, ) diff --git a/scripts/m4_seed_rules.py b/scripts/m4_seed_rules.py index c6b53aa..015459e 100644 --- a/scripts/m4_seed_rules.py +++ b/scripts/m4_seed_rules.py @@ -2,10 +2,11 @@ 用法: cd /home/wren-dev/Porject/leaudit-platform PYTHONPATH=src:/home/wren-dev/Porject/docauditai \ - python scripts/m4_seed_rules.py + python scripts/m4_seed_rules.py [--region default] """ from __future__ import annotations +import argparse import asyncio import hashlib import os @@ -23,6 +24,8 @@ from sqlalchemy import text RULES_DIR = Path(__file__).resolve().parent.parent / "rules" +REGION = "default" + def _read_metadata(yaml_path: Path) -> dict: import yaml @@ -37,11 +40,11 @@ def _type_id_to_rule_type(type_id: str) -> str: async def _get_or_create_rule_set( - session, rule_type: str, rule_name: str, domain_type: str, description: str + session, rule_type: str, rule_name: str, domain_type: str, description: str, region: str ) -> dict: result = await session.execute( - text("SELECT * FROM leaudit_rule_sets WHERE rule_type = :rt AND deleted_at IS NULL LIMIT 1"), - {"rt": rule_type}, + text("SELECT * FROM leaudit_rule_sets WHERE rule_type = :rt AND region = :rgn AND deleted_at IS NULL LIMIT 1"), + {"rt": rule_type, "rgn": region}, ) row = result.mappings().first() if row: @@ -49,11 +52,11 @@ async def _get_or_create_rule_set( result = await session.execute( text( - """INSERT INTO leaudit_rule_sets (rule_type, rule_name, domain_type, description, status, is_builtin) - VALUES (:rt, :rn, :dt, :desc, 'draft', false) + """INSERT INTO leaudit_rule_sets (rule_type, rule_name, domain_type, description, region, status, is_builtin) + VALUES (:rt, :rn, :dt, :desc, :rgn, 'draft', false) RETURNING id, rule_type, rule_name, domain_type, current_version_id, status""" ), - {"rt": rule_type, "rn": rule_name, "dt": domain_type, "desc": description or ""}, + {"rt": rule_type, "rn": rule_name, "dt": domain_type, "desc": description or "", "rgn": region}, ) return dict(result.mappings().first()) @@ -66,7 +69,7 @@ async def _version_exists(session, rule_set_id: int, version_no: str) -> bool: return result.mappings().first() is not None -async def upload_one(yaml_path: Path, oss: OssClient) -> dict: +async def upload_one(yaml_path: Path, oss: OssClient, region: str) -> dict: meta = _read_metadata(yaml_path) type_id = meta.get("type_id", "") rule_type = _type_id_to_rule_type(type_id) @@ -80,13 +83,13 @@ async def upload_one(yaml_path: Path, oss: OssClient) -> dict: file_size = len(yaml_text.encode()) async with GetAsyncSession() as session: - rs = await _get_or_create_rule_set(session, rule_type, rule_name, domain_type, description) + rs = await _get_or_create_rule_set(session, rule_type, rule_name, domain_type, description, region) if await _version_exists(session, rs["id"], version_no): return {"rule_type": rule_type, "status": "skipped", "reason": f"version {version_no} exists"} # Upload to OSS - object_key = OssPathUtils.BuildRuleYamlKey(rule_type, version_no) + object_key = OssPathUtils.BuildRuleYamlKey(rule_type, version_no, Region=region) oss_url = oss.UploadText( ObjectKey=object_key, Content=yaml_text, @@ -122,11 +125,11 @@ async def upload_one(yaml_path: Path, oss: OssClient) -> dict: return {"rule_type": rule_type, "status": "created", "version": version_no, "oss_url": oss_url} -async def publish_one(rule_type: str, version_no: str | None = None) -> dict: +async def publish_one(rule_type: str, region: str, version_no: str | None = None) -> dict: async with GetAsyncSession() as session: rs = await session.execute( - text("SELECT id FROM leaudit_rule_sets WHERE rule_type = :rt AND deleted_at IS NULL LIMIT 1"), - {"rt": rule_type}, + text("SELECT id FROM leaudit_rule_sets WHERE rule_type = :rt AND region = :rgn AND deleted_at IS NULL LIMIT 1"), + {"rt": rule_type, "rgn": region}, ) rs_row = rs.mappings().first() if not rs_row: @@ -161,9 +164,15 @@ async def publish_one(rule_type: str, version_no: str | None = None) -> dict: async def main(): + parser = argparse.ArgumentParser(description="M4 种子数据初始化") + parser.add_argument("--region", default="default", help="地区标识(默认: default)") + args = parser.parse_args() + region = args.region + oss = OssClient() rules_dirs = sorted(d for d in RULES_DIR.iterdir() if d.is_dir() and (d / "rules.yaml").exists()) + print(f"地区: {region}") print(f"找到 {len(rules_dirs)} 套规则\n") # Step 1: Upload all @@ -173,7 +182,7 @@ async def main(): for d in rules_dirs: yaml_path = d / "rules.yaml" try: - result = await upload_one(yaml_path, oss) + result = await upload_one(yaml_path, oss, region) icon = "✓" if result["status"] != "skipped" else "⊙" print(f" {icon} {result['rule_type']:40s} {result['status']:8s} v{result.get('version', '?')}") except Exception as e: @@ -189,7 +198,7 @@ async def main(): type_id = meta.get("type_id", "") rule_type = _type_id_to_rule_type(type_id) try: - result = await publish_one(rule_type) + result = await publish_one(rule_type, region) icon = "✓" if result["status"] == "published" else "✗" print(f" {icon} {rule_type:40s} {result['status']}") except Exception as e: