dieya_risk_prediction_backend/scripts/setup_external_risk.py

178 lines
8.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Configure a backend integration secret or verify an existing DingTalk operator."""
import argparse
import asyncio
import json
import re
import secrets
from pathlib import Path
from app.core.exceptions import AppException
from app.database.session import SessionFactory, engine
from app.models.entities import UserAccount
from app.repositories.users import UserRepository
from app.schemas.integration_risk import IntegrationOperator
from app.services.integration_auth import authorize_operator
from dotenv import dotenv_values
from sqlalchemy import select
def configure_client(env_file: Path, client_id: str) -> bool:
"""Keep existing secrets and unrelated settings; never print secret material."""
if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", client_id):
raise ValueError("client-id 仅允许字母、数字、下划线和短横线,最长 64 位")
text = env_file.read_text(encoding="utf-8-sig") if env_file.exists() else ""
raw = dotenv_values(env_file).get("RISK_INTEGRATION_CLIENTS") if env_file.exists() else None
clients = json.loads(raw or "{}")
if not isinstance(clients, dict) or any(
not isinstance(key, str)
or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", key)
or not isinstance(value, str)
or len(value) < 32
for key, value in clients.items()
):
raise ValueError("已有 RISK_INTEGRATION_CLIENTS 配置无效,请检查 JSON 和密钥长度")
if client_id in clients:
return False
clients[client_id] = secrets.token_urlsafe(32)
# Prefer readable single-quoted JSON; double-escape if an existing secret has an apostrophe.
encoded = json.dumps(clients, ensure_ascii=True, separators=(",", ":"))
value = "'" + encoded + "'" if "'" not in encoded else json.dumps(encoded, ensure_ascii=True)
line = f"RISK_INTEGRATION_CLIENTS={value}"
lines = text.splitlines()
indexes = [
i for i, current in enumerate(lines) if re.match(r"^\s*(?:export\s+)?RISK_INTEGRATION_CLIENTS\s*=", current)
]
if indexes:
lines[indexes[0]] = line
lines = [current for i, current in enumerate(lines) if i not in indexes[1:]]
else:
lines.extend(["", "# 外部系统调用密钥(仅保存在双方后端)", line])
env_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
return True
async def check_operator(union_id: str, user_id: str | None) -> None:
try:
async with SessionFactory() as session:
user = await authorize_operator(
session,
IntegrationOperator(
dingtalk_union_id=union_id,
dingtalk_user_id=user_id,
),
)
print(f"检查通过:本地用户 {user.id},姓名 {user.name},角色 {user.role},账号启用,有风险评估操作权限。")
finally:
await engine.dispose()
def read_operator_mappings(env_file: Path) -> dict:
raw = dotenv_values(env_file).get("RISK_INTEGRATION_OPERATOR_MAPPINGS") if env_file.exists() else None
mappings = json.loads(raw or "{}")
if not isinstance(mappings, dict) or any(
not isinstance(client, str)
or not isinstance(accounts, dict)
or any(
not isinstance(external, str) or not external or not isinstance(local, str) or not local or len(local) > 20
for external, local in accounts.items()
)
for client, accounts in mappings.items()
):
raise ValueError("外部账号映射配置无效")
return mappings
def write_operator_mapping(env_file: Path, client_id: str, external_id: str, employee_no: str) -> None:
mappings = read_operator_mappings(env_file)
accounts = mappings.setdefault(client_id, {})
if external_id in accounts and accounts[external_id] != employee_no:
raise ValueError("外部账号已映射到其他本地账号,不能自动覆盖")
accounts[external_id] = employee_no
encoded = json.dumps(mappings, ensure_ascii=True, separators=(",", ":"))
value = "'" + encoded + "'" if "'" not in encoded else json.dumps(encoded)
key = "RISK_INTEGRATION_OPERATOR_MAPPINGS"
lines = env_file.read_text(encoding="utf-8-sig").splitlines() if env_file.exists() else []
indexes = [i for i, line in enumerate(lines) if re.match(r"^\s*(?:export\s+)?" + key + r"\s*=", line)]
if indexes:
lines[indexes[0]] = f"{key}={value}"
lines = [line for i, line in enumerate(lines) if i not in indexes[1:]]
else:
lines.extend(["", "# 已批准的外部操作人映射", f"{key}={value}"])
env_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
async def allow_external_user(env_file: Path, client_id: str, external_id: str, employee_no: str, name: str) -> None:
if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", client_id):
raise ValueError("调用系统格式错误")
if not external_id.strip() or len(external_id) > 128 or not name.strip() or len(name) > 50:
raise ValueError("账号标识或姓名格式错误")
if not re.fullmatch(r"[A-Za-z0-9_-]{1,20}", employee_no):
raise ValueError("本地账号编号格式错误")
mappings = read_operator_mappings(env_file)
existing = mappings.get(client_id, {}).get(external_id)
if existing and existing != employee_no:
raise ValueError("已有映射不能自动覆盖")
clients = json.loads(dotenv_values(env_file).get("RISK_INTEGRATION_CLIENTS") or "{}")
if client_id not in clients:
raise ValueError("请先配置调用系统密钥")
try:
async with SessionFactory() as session:
user = await session.scalar(select(UserAccount).where(UserAccount.emp_no == employee_no))
if user is None:
permissions = await UserRepository(session).permissions_for_role("user", operate=True)
if not any(item.func_key == "risk.assess" for item in permissions):
raise ValueError("普通角色无评估权限,请由管理员配置")
user = UserAccount(emp_no=employee_no, name=name, role="user", status="启用")
session.add(user)
await session.flush()
elif user.name != name or user.status != "启用":
raise ValueError("已有本地账号姓名不一致或已停用,不能自动修改")
user_id = user.id
await session.commit()
write_operator_mapping(env_file, client_id, external_id, employee_no)
print(f"已配置外部用户:{name}({external_id}),调用系统 {client_id},本地用户编号 {user_id}。")
print("保持本地账号权限;新建账号为普通用户,无密码及钉钉绑定。请重启后端。")
finally:
await engine.dispose()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
actions = parser.add_subparsers(dest="action", required=True)
configure = actions.add_parser("configure", help="Generate a missing client secret without printing it")
configure.add_argument("--client-id", default="dieya-plm")
configure.add_argument("--env-file", type=Path, default=Path(".env"))
check = actions.add_parser("check-user", help="Check UnionId, optional UserId and local permission")
check.add_argument("--union-id", required=True)
check.add_argument("--dingtalk-user-id")
allow = actions.add_parser("allow-user", help="Explicitly register a non-DingTalk operator for one client")
allow.add_argument("--client-id", default="dieya-plm")
allow.add_argument("--external-user-id", required=True)
allow.add_argument("--employee-no", required=True)
allow.add_argument("--name", required=True)
allow.add_argument("--env-file", type=Path, default=Path(".env"))
args = parser.parse_args()
try:
if args.action == "configure":
changed = configure_client(args.env_file, args.client_id)
print(f"{args.client_id}:" + ("密钥已生成并写入配置。" if changed else "已配置,保留现有密钥。"))
print("密钥不会打印。请重启后端,并通过安全渠道向调用方提供该 clientId 对应的密钥。")
elif args.action == "allow-user":
asyncio.run(
allow_external_user(args.env_file, args.client_id, args.external_user_id, args.employee_no, args.name)
)
else:
asyncio.run(check_operator(args.union_id, args.dingtalk_user_id))
except AppException as exc:
print(f"检查不通过({exc.status_code}):{exc.message}")
return 1
except (ValueError, OSError):
print("配置或参数无效,请检查文件、JSON、标识格式及密钥长度;未显示敏感内容。")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())