feat: multi-region rule isolation — region column + config + queries

- 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.
This commit is contained in:
wren
2026-04-28 13:15:26 +08:00
parent 4e706f0d19
commit e80e8febd8
5 changed files with 38 additions and 18 deletions
+1
View File
@@ -4,6 +4,7 @@
APP_NAME: str
APP_HOST: str
APP_PORT: int
APP_REGION: str
APP_CORS_ORIGINS: str
# JWT
+1
View File
@@ -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 = "*"
@@ -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"]:
@@ -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,
},
)
+24 -15
View File
@@ -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: