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_NAME: str
|
||||||
APP_HOST: str
|
APP_HOST: str
|
||||||
APP_PORT: int
|
APP_PORT: int
|
||||||
|
APP_REGION: str
|
||||||
APP_CORS_ORIGINS: str
|
APP_CORS_ORIGINS: str
|
||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class AppSettings(_Base):
|
|||||||
APP_NAME: str = "LeAudit Platform"
|
APP_NAME: str = "LeAudit Platform"
|
||||||
APP_HOST: str = "0.0.0.0"
|
APP_HOST: str = "0.0.0.0"
|
||||||
APP_PORT: int = 8000
|
APP_PORT: int = 8000
|
||||||
|
APP_REGION: str = "default"
|
||||||
APP_CORS_ORIGINS: str = "*"
|
APP_CORS_ORIGINS: str = "*"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi_admin.config import APP_REGION
|
||||||
from fastapi_common.fastapi_common_logger import logger
|
from fastapi_common.fastapi_common_logger import logger
|
||||||
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
|
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.domain.responses import StatusCodeEnum
|
||||||
@@ -72,11 +73,12 @@ class AuditServiceImpl(IAuditService):
|
|||||||
LEFT JOIN leaudit_rule_versions rv ON rv.id = rs.current_version_id
|
LEFT JOIN leaudit_rule_versions rv ON rv.id = rs.current_version_id
|
||||||
WHERE b.doc_type_id = :doc_type_id
|
WHERE b.doc_type_id = :doc_type_id
|
||||||
AND b.is_active = true
|
AND b.is_active = true
|
||||||
|
AND b.region = :region
|
||||||
ORDER BY b.priority DESC, b.id DESC
|
ORDER BY b.priority DESC, b.id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"doc_type_id": document.typeId},
|
{"doc_type_id": document.typeId, "region": APP_REGION},
|
||||||
)
|
)
|
||||||
binding = bindingResult.mappings().first()
|
binding = bindingResult.mappings().first()
|
||||||
if not binding or not binding["rule_set_id"] or not binding["rule_version_id"]:
|
if not binding or not binding["rule_set_id"] or not binding["rule_version_id"]:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
from fastapi_admin.config import APP_REGION
|
||||||
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
|
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.domain.responses import StatusCodeEnum
|
||||||
from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException
|
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
|
JOIN leaudit_rule_sets rs ON rs.id = b.rule_set_id
|
||||||
WHERE rs.rule_type = :rule_type
|
WHERE rs.rule_type = :rule_type
|
||||||
AND rs.deleted_at IS NULL
|
AND rs.deleted_at IS NULL
|
||||||
|
AND b.region = :region
|
||||||
ORDER BY b.priority DESC, b.id DESC
|
ORDER BY b.priority DESC, b.id DESC
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"rule_type": RuleType},
|
{"rule_type": RuleType, "region": APP_REGION},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
Result = await Session.execute(
|
Result = await Session.execute(
|
||||||
@@ -385,9 +387,11 @@ class RuleServiceImpl(IRuleService):
|
|||||||
FROM leaudit_rule_type_bindings b
|
FROM leaudit_rule_type_bindings b
|
||||||
JOIN leaudit_rule_sets rs ON rs.id = b.rule_set_id
|
JOIN leaudit_rule_sets rs ON rs.id = b.rule_set_id
|
||||||
WHERE rs.deleted_at IS NULL
|
WHERE rs.deleted_at IS NULL
|
||||||
|
AND b.region = :region
|
||||||
ORDER BY rs.rule_type, b.priority DESC, b.id DESC
|
ORDER BY rs.rule_type, b.priority DESC, b.id DESC
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
{"region": APP_REGION},
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
RuleBindingVO(
|
RuleBindingVO(
|
||||||
@@ -447,6 +451,7 @@ class RuleServiceImpl(IRuleService):
|
|||||||
binding_mode,
|
binding_mode,
|
||||||
priority,
|
priority,
|
||||||
is_active,
|
is_active,
|
||||||
|
region,
|
||||||
note
|
note
|
||||||
) VALUES (
|
) VALUES (
|
||||||
:doc_type_id,
|
:doc_type_id,
|
||||||
@@ -455,10 +460,11 @@ class RuleServiceImpl(IRuleService):
|
|||||||
:binding_mode,
|
:binding_mode,
|
||||||
:priority,
|
:priority,
|
||||||
true,
|
true,
|
||||||
|
:region,
|
||||||
:note
|
:note
|
||||||
)
|
)
|
||||||
RETURNING id, doc_type_id, doc_type_code, rule_set_id,
|
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,
|
"rule_set_id": RuleSetId,
|
||||||
"binding_mode": BindingMode,
|
"binding_mode": BindingMode,
|
||||||
"priority": Priority,
|
"priority": Priority,
|
||||||
|
"region": APP_REGION,
|
||||||
"note": Note,
|
"note": Note,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+24
-15
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
用法: cd /home/wren-dev/Porject/leaudit-platform
|
用法: cd /home/wren-dev/Porject/leaudit-platform
|
||||||
PYTHONPATH=src:/home/wren-dev/Porject/docauditai \
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
@@ -23,6 +24,8 @@ from sqlalchemy import text
|
|||||||
|
|
||||||
RULES_DIR = Path(__file__).resolve().parent.parent / "rules"
|
RULES_DIR = Path(__file__).resolve().parent.parent / "rules"
|
||||||
|
|
||||||
|
REGION = "default"
|
||||||
|
|
||||||
|
|
||||||
def _read_metadata(yaml_path: Path) -> dict:
|
def _read_metadata(yaml_path: Path) -> dict:
|
||||||
import yaml
|
import yaml
|
||||||
@@ -37,11 +40,11 @@ def _type_id_to_rule_type(type_id: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def _get_or_create_rule_set(
|
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:
|
) -> dict:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
text("SELECT * FROM leaudit_rule_sets WHERE rule_type = :rt AND deleted_at IS NULL LIMIT 1"),
|
text("SELECT * FROM leaudit_rule_sets WHERE rule_type = :rt AND region = :rgn AND deleted_at IS NULL LIMIT 1"),
|
||||||
{"rt": rule_type},
|
{"rt": rule_type, "rgn": region},
|
||||||
)
|
)
|
||||||
row = result.mappings().first()
|
row = result.mappings().first()
|
||||||
if row:
|
if row:
|
||||||
@@ -49,11 +52,11 @@ async def _get_or_create_rule_set(
|
|||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
text(
|
text(
|
||||||
"""INSERT INTO leaudit_rule_sets (rule_type, rule_name, domain_type, description, status, is_builtin)
|
"""INSERT INTO leaudit_rule_sets (rule_type, rule_name, domain_type, description, region, status, is_builtin)
|
||||||
VALUES (:rt, :rn, :dt, :desc, 'draft', false)
|
VALUES (:rt, :rn, :dt, :desc, :rgn, 'draft', false)
|
||||||
RETURNING id, rule_type, rule_name, domain_type, current_version_id, status"""
|
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())
|
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
|
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)
|
meta = _read_metadata(yaml_path)
|
||||||
type_id = meta.get("type_id", "")
|
type_id = meta.get("type_id", "")
|
||||||
rule_type = _type_id_to_rule_type(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())
|
file_size = len(yaml_text.encode())
|
||||||
|
|
||||||
async with GetAsyncSession() as session:
|
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):
|
if await _version_exists(session, rs["id"], version_no):
|
||||||
return {"rule_type": rule_type, "status": "skipped", "reason": f"version {version_no} exists"}
|
return {"rule_type": rule_type, "status": "skipped", "reason": f"version {version_no} exists"}
|
||||||
|
|
||||||
# Upload to OSS
|
# 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(
|
oss_url = oss.UploadText(
|
||||||
ObjectKey=object_key,
|
ObjectKey=object_key,
|
||||||
Content=yaml_text,
|
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}
|
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:
|
async with GetAsyncSession() as session:
|
||||||
rs = await session.execute(
|
rs = await session.execute(
|
||||||
text("SELECT id FROM leaudit_rule_sets WHERE rule_type = :rt AND deleted_at IS NULL LIMIT 1"),
|
text("SELECT id FROM leaudit_rule_sets WHERE rule_type = :rt AND region = :rgn AND deleted_at IS NULL LIMIT 1"),
|
||||||
{"rt": rule_type},
|
{"rt": rule_type, "rgn": region},
|
||||||
)
|
)
|
||||||
rs_row = rs.mappings().first()
|
rs_row = rs.mappings().first()
|
||||||
if not rs_row:
|
if not rs_row:
|
||||||
@@ -161,9 +164,15 @@ async def publish_one(rule_type: str, version_no: str | None = None) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
async def main():
|
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()
|
oss = OssClient()
|
||||||
rules_dirs = sorted(d for d in RULES_DIR.iterdir() if d.is_dir() and (d / "rules.yaml").exists())
|
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")
|
print(f"找到 {len(rules_dirs)} 套规则\n")
|
||||||
|
|
||||||
# Step 1: Upload all
|
# Step 1: Upload all
|
||||||
@@ -173,7 +182,7 @@ async def main():
|
|||||||
for d in rules_dirs:
|
for d in rules_dirs:
|
||||||
yaml_path = d / "rules.yaml"
|
yaml_path = d / "rules.yaml"
|
||||||
try:
|
try:
|
||||||
result = await upload_one(yaml_path, oss)
|
result = await upload_one(yaml_path, oss, region)
|
||||||
icon = "✓" if result["status"] != "skipped" else "⊙"
|
icon = "✓" if result["status"] != "skipped" else "⊙"
|
||||||
print(f" {icon} {result['rule_type']:40s} {result['status']:8s} v{result.get('version', '?')}")
|
print(f" {icon} {result['rule_type']:40s} {result['status']:8s} v{result.get('version', '?')}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -189,7 +198,7 @@ async def main():
|
|||||||
type_id = meta.get("type_id", "")
|
type_id = meta.get("type_id", "")
|
||||||
rule_type = _type_id_to_rule_type(type_id)
|
rule_type = _type_id_to_rule_type(type_id)
|
||||||
try:
|
try:
|
||||||
result = await publish_one(rule_type)
|
result = await publish_one(rule_type, region)
|
||||||
icon = "✓" if result["status"] == "published" else "✗"
|
icon = "✓" if result["status"] == "published" else "✗"
|
||||||
print(f" {icon} {rule_type:40s} {result['status']}")
|
print(f" {icon} {rule_type:40s} {result['status']}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user