袁聪merge:合并分支

This commit is contained in:
2026-09-11 17:26:18 +08:00
95 changed files with 18208 additions and 120 deletions
+49 -53
View File
@@ -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
+345
View File
@@ -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()
+136
View File
@@ -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()
+67
View File
@@ -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())