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:
@@ -4,6 +4,7 @@
|
||||
APP_NAME: str
|
||||
APP_HOST: str
|
||||
APP_PORT: int
|
||||
APP_REGION: str
|
||||
APP_CORS_ORIGINS: str
|
||||
|
||||
# JWT
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user