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
+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: