袁聪merge:合并分支
This commit is contained in:
+49
-53
@@ -13,73 +13,69 @@ from __future__ import annotations
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
import pymysql
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.core.config import get_settings # noqa: E402
|
||||
|
||||
SQL_FILE = ROOT / "alembic" / "baseline_generated.sql"
|
||||
|
||||
# baseline_generated.sql 只覆盖基线表;以下平台增量表由 Alembic 迁移创建。
|
||||
INCREMENTAL_TABLES = {
|
||||
"agent_run",
|
||||
"domain_event_outbox",
|
||||
"request_idempotency",
|
||||
"config_release",
|
||||
"platform_config_item",
|
||||
"model_endpoint_config",
|
||||
"model_routing_rule",
|
||||
"model_routing_fallback",
|
||||
"prompt_template_version",
|
||||
"outbox_delivery",
|
||||
"svc_conversation_session",
|
||||
"api_request_receipt",
|
||||
}
|
||||
VERSIONS_DIR = ROOT / "alembic" / "versions"
|
||||
|
||||
|
||||
def expected_tables() -> set[str]:
|
||||
content = SQL_FILE.read_text(encoding="utf-8")
|
||||
return set(re.findall(r"CREATE TABLE `?([A-Za-z0-9_]+)`?", content))
|
||||
text = SQL_FILE.read_text(encoding="utf-8")
|
||||
tables = set(re.findall(
|
||||
r"CREATE TABLE\s+(?:IF NOT EXISTS\s+)?`?([A-Za-z0-9_]+)`?",
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
))
|
||||
for path in VERSIONS_DIR.glob("*.py"):
|
||||
tables.update(re.findall(
|
||||
r"CREATE TABLE\s+(?:IF NOT EXISTS\s+)?`?([A-Za-z0-9_]+)`?",
|
||||
path.read_text(encoding="utf-8"),
|
||||
flags=re.IGNORECASE,
|
||||
))
|
||||
return tables
|
||||
|
||||
|
||||
def main() -> int:
|
||||
engine = create_engine(get_settings().mysql_dsn.replace("mysql+asyncmy", "mysql+pymysql"))
|
||||
with engine.connect() as connection:
|
||||
actual = set(
|
||||
connection.execute(
|
||||
text(
|
||||
"SELECT TABLE_NAME FROM information_schema.TABLES "
|
||||
"WHERE TABLE_SCHEMA = DATABASE()"
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
) - {"alembic_version"}
|
||||
expected = expected_tables() | INCREMENTAL_TABLES
|
||||
def mysql_connection() -> pymysql.Connection:
|
||||
from app.core.config import get_settings
|
||||
|
||||
parsed = urlparse(get_settings().mysql_dsn.replace("mysql+asyncmy://", "mysql+pymysql://"))
|
||||
return pymysql.connect(
|
||||
host=parsed.hostname or "127.0.0.1",
|
||||
port=parsed.port or 3306,
|
||||
user=unquote(parsed.username or ""),
|
||||
password=unquote(parsed.password or ""),
|
||||
database=(parsed.path or "/").lstrip("/"),
|
||||
charset="utf8mb4",
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
connection = mysql_connection()
|
||||
database = connection.db.decode() if isinstance(connection.db, bytes) else connection.db
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT table_name FROM information_schema.tables WHERE table_schema=%s", (database,))
|
||||
actual = {row[0] for row in cursor.fetchall()} - {"alembic_version"}
|
||||
expected = expected_tables()
|
||||
missing = expected - actual
|
||||
unexpected = actual - expected
|
||||
if missing or unexpected:
|
||||
print(f"schema audit FAILED: missing={sorted(missing)} unexpected={sorted(unexpected)}")
|
||||
return 1
|
||||
rows = connection.execute(
|
||||
text(
|
||||
"SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE()"
|
||||
)
|
||||
).all()
|
||||
engine.dispose()
|
||||
|
||||
column_counts: dict[str, int] = {}
|
||||
for table, _column in rows:
|
||||
column_counts[table] = column_counts.get(table, 0) + 1
|
||||
without_columns = sorted(table for table in expected if not column_counts.get(table))
|
||||
if without_columns:
|
||||
print(f"schema audit FAILED: tables without columns: {without_columns}")
|
||||
return 1
|
||||
|
||||
raise SystemExit(f"table mismatch missing={sorted(missing)} unexpected={sorted(unexpected)}")
|
||||
cursor.execute(
|
||||
"SELECT table_name, column_name FROM information_schema.columns WHERE table_schema=%s",
|
||||
(database,),
|
||||
)
|
||||
actual_columns: dict[str, set[str]] = {}
|
||||
for table, column in cursor.fetchall():
|
||||
actual_columns.setdefault(table, set()).add(column)
|
||||
for table in sorted(expected):
|
||||
if not actual_columns.get(table):
|
||||
raise SystemExit(f"missing columns for {table}")
|
||||
connection.close()
|
||||
print(f"schema audit passed: {len(expected)} business tables, no missing or unexpected tables")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
"""注册、查看和管理场外邮件 Worker 的 Windows 自启动任务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
|
||||
DEFAULT_TASK_NAME = "NanfangFund-OffsiteWorker"
|
||||
TRUE_VALUES = {"1", "true", "yes", "on", "y"}
|
||||
REQUIRED_TRUE_FLAGS = (
|
||||
"OFFSITE_MAIL_WORKER_ENABLED",
|
||||
"OFFSITE_IMAP_ENABLED",
|
||||
"OFFSITE_OCR_ENABLED",
|
||||
"OFFSITE_DEEPSEEK_ENABLED",
|
||||
)
|
||||
REQUIRED_NON_EMPTY = (
|
||||
"OFFSITE_IMAP_HOST",
|
||||
"OFFSITE_IMAP_USERNAME",
|
||||
"OFFSITE_IMAP_PASSWORD",
|
||||
"OFFSITE_WORKER_USER_ID",
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args()
|
||||
project_dir = Path(args.project_dir).resolve()
|
||||
task_name = str(args.task_name)
|
||||
|
||||
if platform.system().lower() != "windows":
|
||||
raise SystemExit("本脚本只支持 Windows 任务计划程序。")
|
||||
|
||||
if args.command == "install":
|
||||
install_task(
|
||||
task_name=task_name,
|
||||
project_dir=project_dir,
|
||||
python_exe=Path(args.python).resolve(),
|
||||
run_as=str(args.run_as),
|
||||
start_now=bool(args.start_now),
|
||||
skip_env_check=bool(args.skip_env_check),
|
||||
)
|
||||
return
|
||||
if args.command == "run":
|
||||
run_worker(project_dir)
|
||||
return
|
||||
if args.command == "uninstall":
|
||||
run_schtasks(["/Delete", "/TN", task_name, "/F"], check=True)
|
||||
print(f"已删除 Windows 任务:{task_name}")
|
||||
return
|
||||
if args.command == "status":
|
||||
run_schtasks(["/Query", "/TN", task_name, "/FO", "LIST", "/V"], check=True)
|
||||
return
|
||||
if args.command == "start":
|
||||
run_schtasks(["/Run", "/TN", task_name], check=True)
|
||||
print(f"已请求启动 Windows 任务:{task_name}")
|
||||
return
|
||||
if args.command == "stop":
|
||||
run_schtasks(["/End", "/TN", task_name], check=True)
|
||||
print(f"已请求停止 Windows 任务:{task_name}")
|
||||
return
|
||||
parser.print_help()
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
project_dir = Path(__file__).resolve().parents[1]
|
||||
parser = argparse.ArgumentParser(
|
||||
description="将场外邮件 Worker 注册为 Windows 开机自启动任务。"
|
||||
)
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=("install", "run", "uninstall", "status", "start", "stop"),
|
||||
help=(
|
||||
"install 注册或更新;run 由计划任务调用 Worker;status 查看;"
|
||||
"start 启动;stop 停止;uninstall 删除。"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--task-name",
|
||||
default=DEFAULT_TASK_NAME,
|
||||
help=f"Windows 任务名称,默认:{DEFAULT_TASK_NAME}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project-dir",
|
||||
default=str(project_dir),
|
||||
help="后端项目目录,必须包含 .env 和 app/worker。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--python",
|
||||
default=sys.executable,
|
||||
help="运行 Worker 的 Python 路径,默认使用当前 Python。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--run-as",
|
||||
choices=("system", "current"),
|
||||
default="system",
|
||||
help="system 表示开机即启动,通常需要管理员权限;current 表示当前用户登录后启动。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--start-now",
|
||||
action="store_true",
|
||||
help="注册成功后立即启动任务。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-env-check",
|
||||
action="store_true",
|
||||
help="跳过 .env 开关检查,仅注册任务。",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def install_task(
|
||||
*,
|
||||
task_name: str,
|
||||
project_dir: Path,
|
||||
python_exe: Path,
|
||||
run_as: str,
|
||||
start_now: bool,
|
||||
skip_env_check: bool,
|
||||
) -> None:
|
||||
validate_project(project_dir, python_exe)
|
||||
if not skip_env_check:
|
||||
validate_env(project_dir / ".env")
|
||||
if run_as == "system":
|
||||
install_system_task(
|
||||
task_name=task_name,
|
||||
project_dir=project_dir,
|
||||
python_exe=python_exe,
|
||||
start_now=start_now,
|
||||
)
|
||||
return
|
||||
xml = build_task_xml(project_dir=project_dir, python_exe=python_exe, run_as=run_as)
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".xml", delete=False, encoding="utf-16") as file:
|
||||
file.write(xml)
|
||||
xml_path = Path(file.name)
|
||||
try:
|
||||
run_schtasks(["/Create", "/TN", task_name, "/XML", str(xml_path), "/F"], check=True)
|
||||
finally:
|
||||
with contextlib_suppress_os_error():
|
||||
xml_path.unlink()
|
||||
print(f"已注册或更新 Windows 任务:{task_name}")
|
||||
print(f"工作目录:{project_dir}")
|
||||
print(f"Python:{python_exe}")
|
||||
print("任务动作:python -m app.worker")
|
||||
if run_as == "system":
|
||||
print("启动方式:系统启动时自动运行。")
|
||||
else:
|
||||
print("启动方式:当前用户登录时自动运行。")
|
||||
if start_now:
|
||||
run_schtasks(["/Run", "/TN", task_name], check=True)
|
||||
print(f"已请求立即启动任务:{task_name}")
|
||||
|
||||
|
||||
def install_system_task(
|
||||
*,
|
||||
task_name: str,
|
||||
project_dir: Path,
|
||||
python_exe: Path,
|
||||
start_now: bool,
|
||||
) -> None:
|
||||
task_run = build_system_task_command(project_dir=project_dir, python_exe=python_exe)
|
||||
run_schtasks(
|
||||
[
|
||||
"/Create",
|
||||
"/TN",
|
||||
task_name,
|
||||
"/SC",
|
||||
"ONSTART",
|
||||
"/RU",
|
||||
"SYSTEM",
|
||||
"/RL",
|
||||
"HIGHEST",
|
||||
"/TR",
|
||||
task_run,
|
||||
"/F",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
print(f"已注册或更新 Windows 任务:{task_name}")
|
||||
print(f"工作目录:{project_dir}")
|
||||
print(f"Python:{python_exe}")
|
||||
print("任务动作:python -m app.worker")
|
||||
print("启动方式:系统启动时自动运行。")
|
||||
if start_now:
|
||||
run_schtasks(["/Run", "/TN", task_name], check=True)
|
||||
print(f"已请求立即启动任务:{task_name}")
|
||||
|
||||
|
||||
def build_system_task_command(*, project_dir: Path, python_exe: Path) -> str:
|
||||
"""生成不依赖任务计划程序工作目录设置的脚本启动命令。"""
|
||||
launcher = Path(__file__).resolve()
|
||||
return f'"{python_exe}" "{launcher}" run --project-dir "{project_dir}"'
|
||||
|
||||
|
||||
def run_worker(project_dir: Path) -> None:
|
||||
"""由 Windows 计划任务调用,切换目录后启动既有 Worker 入口。"""
|
||||
os.chdir(project_dir)
|
||||
sys.path.insert(0, str(project_dir))
|
||||
from app.worker.__main__ import main as worker_main
|
||||
|
||||
sys.argv = ["app.worker"]
|
||||
worker_main()
|
||||
|
||||
|
||||
def validate_project(project_dir: Path, python_exe: Path) -> None:
|
||||
missing = []
|
||||
if not project_dir.exists():
|
||||
missing.append(f"项目目录不存在:{project_dir}")
|
||||
if not (project_dir / "app" / "worker" / "__main__.py").exists():
|
||||
missing.append("未找到 app/worker/__main__.py")
|
||||
if not (project_dir / ".env").exists():
|
||||
missing.append("未找到 .env")
|
||||
if not python_exe.exists():
|
||||
missing.append(f"Python 不存在:{python_exe}")
|
||||
if shutil.which("schtasks.exe") is None:
|
||||
missing.append("未找到 schtasks.exe")
|
||||
if missing:
|
||||
raise SystemExit("\n".join(missing))
|
||||
|
||||
|
||||
def validate_env(env_path: Path) -> None:
|
||||
values = parse_env(env_path)
|
||||
errors = []
|
||||
for key in REQUIRED_TRUE_FLAGS:
|
||||
if values.get(key, "").strip().strip('"').strip("'").lower() not in TRUE_VALUES:
|
||||
errors.append(f"{key} 必须为 true")
|
||||
for key in REQUIRED_NON_EMPTY:
|
||||
if not values.get(key, "").strip().strip('"').strip("'"):
|
||||
errors.append(f"{key} 不能为空")
|
||||
if errors:
|
||||
lines = [
|
||||
".env 未满足场外 Worker 自动启动条件,已中止注册:",
|
||||
*[f"- {item}" for item in errors],
|
||||
"如只是预注册任务,可追加 --skip-env-check。",
|
||||
]
|
||||
raise SystemExit("\n".join(lines))
|
||||
|
||||
|
||||
def parse_env(env_path: Path) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
values[key.strip()] = value.strip()
|
||||
return values
|
||||
|
||||
|
||||
def build_task_xml(*, project_dir: Path, python_exe: Path, run_as: str) -> str:
|
||||
command = escape(str(python_exe))
|
||||
working_dir = escape(str(project_dir))
|
||||
user_id = "SYSTEM" if run_as == "system" else escape(os.environ.get("USERNAME", ""))
|
||||
logon_type = "ServiceAccount" if run_as == "system" else "InteractiveToken"
|
||||
trigger = "BootTrigger" if run_as == "system" else "LogonTrigger"
|
||||
return f"""<?xml version="1.0" encoding="UTF-16"?>
|
||||
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||||
<RegistrationInfo>
|
||||
<Description>奶龙基金场外申购赎回邮件 Worker 自动启动任务</Description>
|
||||
</RegistrationInfo>
|
||||
<Triggers>
|
||||
<{trigger}>
|
||||
<Enabled>true</Enabled>
|
||||
</{trigger}>
|
||||
</Triggers>
|
||||
<Principals>
|
||||
<Principal id="Author">
|
||||
<UserId>{user_id}</UserId>
|
||||
<LogonType>{logon_type}</LogonType>
|
||||
<RunLevel>HighestAvailable</RunLevel>
|
||||
</Principal>
|
||||
</Principals>
|
||||
<Settings>
|
||||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
||||
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
||||
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||||
<AllowHardTerminate>true</AllowHardTerminate>
|
||||
<StartWhenAvailable>true</StartWhenAvailable>
|
||||
<RunOnlyIfNetworkAvailable>true</RunOnlyIfNetworkAvailable>
|
||||
<IdleSettings>
|
||||
<StopOnIdleEnd>false</StopOnIdleEnd>
|
||||
<RestartOnIdle>false</RestartOnIdle>
|
||||
</IdleSettings>
|
||||
<AllowStartOnDemand>true</AllowStartOnDemand>
|
||||
<Enabled>true</Enabled>
|
||||
<Hidden>false</Hidden>
|
||||
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
||||
<DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession>
|
||||
<UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>
|
||||
<WakeToRun>false</WakeToRun>
|
||||
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
||||
<Priority>7</Priority>
|
||||
<RestartOnFailure>
|
||||
<Interval>PT1M</Interval>
|
||||
<Count>3</Count>
|
||||
</RestartOnFailure>
|
||||
</Settings>
|
||||
<Actions Context="Author">
|
||||
<Exec>
|
||||
<Command>{command}</Command>
|
||||
<Arguments>-m app.worker</Arguments>
|
||||
<WorkingDirectory>{working_dir}</WorkingDirectory>
|
||||
</Exec>
|
||||
</Actions>
|
||||
</Task>
|
||||
"""
|
||||
|
||||
|
||||
def run_schtasks(args: list[str], *, check: bool) -> subprocess.CompletedProcess[str]:
|
||||
command = ["schtasks.exe", *args]
|
||||
result = subprocess.run(command, check=False, text=True, capture_output=True)
|
||||
if result.stdout.strip():
|
||||
print(result.stdout.strip())
|
||||
if result.stderr.strip():
|
||||
print(result.stderr.strip(), file=sys.stderr)
|
||||
if check and result.returncode != 0:
|
||||
hint = ""
|
||||
if "/Create" in args:
|
||||
hint = "\n如果使用 --run-as system,请用管理员身份运行 PowerShell 或终端。"
|
||||
raise SystemExit(f"schtasks 执行失败,退出码:{result.returncode}{hint}")
|
||||
return result
|
||||
|
||||
|
||||
class contextlib_suppress_os_error:
|
||||
def __enter__(self) -> None:
|
||||
return None
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
traceback: object | None,
|
||||
) -> bool:
|
||||
return exc_type is not None and issubclass(exc_type, OSError)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""在独立测试库上跑测试:与开发用的 worker 进程物理隔离。
|
||||
|
||||
为什么需要:`python -m app.worker` 会认领任意 `queued/running/cancel_requested` 运行,
|
||||
并消费 Outbox 事件。只要它与测试共用同一个库,测试造的数据就会被并发消费,表现为
|
||||
"同一个用例全量跑失败、单跑通过"的偶发失败——那不是代码缺陷,是环境干扰。
|
||||
|
||||
流程(每一步都只作用于测试库):
|
||||
|
||||
1. 由 `.env` 的 `MYSQL_DSN` 推导测试库 DSN(只换库名,其余原样);
|
||||
2. 对测试库执行 `alembic upgrade head`(合并两条分支后,迁移链已能从空库建出全部表,
|
||||
不再需要手工导入 `alembic/baseline_generated.sql`);`--rebuild` 会先清空测试库再重建;
|
||||
3. 灌入 RBAC 测试账号(`tools/seed_test_rbac.py`,9001/9002/9003 号段);
|
||||
4. 仅在本进程内用 `MYSQL_DSN` 覆盖,跑 pytest。
|
||||
|
||||
前置(只需一次,用有建库权限的账号执行):
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS jr_agent_test
|
||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||
GRANT ALL PRIVILEGES ON jr_agent_test.* TO 'jr_app'@'localhost';
|
||||
|
||||
用法:
|
||||
|
||||
python -m tools.run_tests_on_test_db # 默认跑 tests/integration
|
||||
python -m tools.run_tests_on_test_db tests # 跑全部测试目录
|
||||
python -m tools.run_tests_on_test_db --rebuild tests # 先清空测试库再由迁移重建
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pymysql
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.core.config import get_settings # noqa: E402
|
||||
|
||||
DEFAULT_DATABASE = "jr_agent_test"
|
||||
|
||||
|
||||
def _connect(**overrides: object) -> pymysql.connections.Connection:
|
||||
settings = get_settings()
|
||||
url = make_url(settings.mysql_dsn)
|
||||
return pymysql.connect(
|
||||
host=url.host or "127.0.0.1",
|
||||
port=url.port or 3306,
|
||||
user=url.username,
|
||||
password=url.password or "",
|
||||
charset="utf8mb4",
|
||||
**overrides, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _table_count(connection: pymysql.connections.Connection, database: str) -> int:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s",
|
||||
(database,),
|
||||
)
|
||||
return int(cursor.fetchone()[0])
|
||||
|
||||
|
||||
def _drop_all_tables(connection: pymysql.connections.Connection, database: str) -> int:
|
||||
"""清空测试库(仅测试库),返回删除的表数。
|
||||
|
||||
先关外键检查再逐张 DROP:表之间存在外键,顺序删除会互相阻塞。
|
||||
"""
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(f"USE `{database}`")
|
||||
cursor.execute(
|
||||
"SELECT table_name FROM information_schema.tables WHERE table_schema=%s",
|
||||
(database,),
|
||||
)
|
||||
tables = [row[0] for row in cursor.fetchall()]
|
||||
cursor.execute("SET FOREIGN_KEY_CHECKS=0")
|
||||
for table in tables:
|
||||
cursor.execute(f"DROP TABLE IF EXISTS `{table}`")
|
||||
cursor.execute("SET FOREIGN_KEY_CHECKS=1")
|
||||
connection.commit()
|
||||
return len(tables)
|
||||
|
||||
|
||||
def _run(step: str, args: list[str], env: dict[str, str]) -> None:
|
||||
print(f"\n>>> {step}: {' '.join(args)}", flush=True)
|
||||
completed = subprocess.run(args, cwd=ROOT, env=env, check=False)
|
||||
if completed.returncode != 0:
|
||||
raise SystemExit(f"{step} 失败(退出码 {completed.returncode})")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
database = os.environ.get("TEST_DB_NAME", DEFAULT_DATABASE)
|
||||
arguments = sys.argv[1:]
|
||||
rebuild = "--rebuild" in arguments
|
||||
pytest_args = [item for item in arguments if item != "--rebuild"] or ["tests/integration"]
|
||||
|
||||
base_url = make_url(get_settings().mysql_dsn)
|
||||
test_url = base_url.set(database=database)
|
||||
if base_url.database == database:
|
||||
raise SystemExit(f"测试库不能与开发库同名(当前:{database})")
|
||||
|
||||
try:
|
||||
connection = _connect(database=database)
|
||||
except pymysql.err.OperationalError as exc:
|
||||
raise SystemExit(
|
||||
f"无法连接测试库 {database}:{exc}\n"
|
||||
"请先用有建库权限的账号执行:\n"
|
||||
f" CREATE DATABASE IF NOT EXISTS {database} "
|
||||
"CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;\n"
|
||||
f" GRANT ALL PRIVILEGES ON {database}.* TO 'jr_app'@'localhost';"
|
||||
) from exc
|
||||
|
||||
with connection:
|
||||
tables = _table_count(connection, database)
|
||||
print(f"测试库 {database} 现有表:{tables}")
|
||||
if rebuild and tables:
|
||||
dropped = _drop_all_tables(connection, database)
|
||||
print(f"--rebuild:已清空测试库(删除 {dropped} 张表)")
|
||||
|
||||
env = {
|
||||
**os.environ,
|
||||
"MYSQL_DSN": test_url.render_as_string(hide_password=False),
|
||||
}
|
||||
python = sys.executable
|
||||
_run("结构迁移", [python, "-m", "alembic", "upgrade", "head"], env)
|
||||
_run("灌入测试账号", [python, "-m", "tools.seed_test_rbac"], env)
|
||||
_run("执行测试", [python, "-m", "pytest", *pytest_args, "-q"], env)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""只读核验场外 Worker 技术账号和最小权限。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.core.contracts import RequestContext # noqa: E402
|
||||
from app.infrastructure.db import SessionFactory, engine # noqa: E402
|
||||
from app.service.identity_service import IdentityService # noqa: E402
|
||||
|
||||
WORKER_USER_NO = "OFFSITE-WORKER"
|
||||
WORKER_USERNAME = "offsite_worker"
|
||||
REQUIRED_ROLE = "operator"
|
||||
REQUIRED_PERMISSION = "offsite:write"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with SessionFactory() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, user_no, username, user_type, employee_role, status
|
||||
FROM sys_user
|
||||
WHERE user_no=:user_no AND username=:username
|
||||
"""
|
||||
),
|
||||
{"user_no": WORKER_USER_NO, "username": WORKER_USERNAME},
|
||||
)
|
||||
).mappings().first()
|
||||
if row is None:
|
||||
raise SystemExit("未找到场外 Worker 技术账号,请先执行 Alembic 迁移")
|
||||
if row["user_type"] != "员工" or row["employee_role"] != REQUIRED_ROLE:
|
||||
raise SystemExit("场外 Worker 技术账号身份属性不符合最小权限方案")
|
||||
if row["status"] != "正常":
|
||||
raise SystemExit("场外 Worker 技术账号未启用")
|
||||
user_id = str(row["id"])
|
||||
|
||||
context = await IdentityService().resolve(
|
||||
RequestContext(user_id=user_id, trace_id="verify-offsite-worker-identity")
|
||||
)
|
||||
roles = set(context.roles)
|
||||
permissions = set(context.permissions)
|
||||
if REQUIRED_ROLE not in roles:
|
||||
raise SystemExit("场外 Worker 技术账号缺少 operator 角色")
|
||||
if REQUIRED_PERMISSION not in permissions:
|
||||
raise SystemExit("场外 Worker 技术账号缺少 offsite:write 权限")
|
||||
extra_permissions = permissions - {REQUIRED_PERMISSION}
|
||||
if extra_permissions:
|
||||
raise SystemExit(f"场外 Worker 技术账号存在额外权限:{sorted(extra_permissions)}")
|
||||
|
||||
print("场外 Worker 技术账号核验通过")
|
||||
print(f"OFFSITE_WORKER_USER_ID={user_id}")
|
||||
print(f"roles={sorted(roles)}")
|
||||
print(f"permissions={sorted(permissions)}")
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user