PayrollManagementSystem/backend/app/cli.py

201 lines
7.6 KiB
Python
Raw 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.

"""命令行管理工具。
用法(在 backend/ 目录下执行)::
uv run python -m app.cli create-admin --username admin
密码优先**交互式输入**而非命令行参数:命令行参数会进入 shell 历史,
在多用户机器上还能被 `ps` 看到。`--password` 仅为自动化脚本保留。
"""
import argparse
import getpass
import sys
import time
from datetime import datetime
from sqlalchemy import select
from app.core.config import get_settings
from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES, ROLE_ADMIN
from app.core.db import dispose_engine, get_session_factory
from app.core.security import hash_password
from app.core.tplus_db import dispose_tplus_engine, get_tplus_session_factory
from app.models import Role, User, UserRole
from app.repositories.tplus_repository import get_batch
from app.services.tplus.sync_service import TPlusSyncService
from app.services.tplus.worker import run_once
def _prompt_password() -> str:
"""交互式读取并二次确认密码,循环直到符合策略。"""
while True:
password = getpass.getpass(
f"请输入密码({PASSWORD_MIN_BYTES}-{PASSWORD_MAX_BYTES} 字节): "
)
length = len(password.encode("utf-8"))
if length < PASSWORD_MIN_BYTES:
print(f" 密码不足 {PASSWORD_MIN_BYTES} 字节,请重新输入。")
continue
if length > PASSWORD_MAX_BYTES:
print(f" 密码超过 {PASSWORD_MAX_BYTES} 字节上限(中文一字占 3 字节),请重新输入。")
continue
if password != getpass.getpass("请再次输入以确认: "):
print(" 两次输入不一致,请重新输入。")
continue
return password
def _create_admin(args: argparse.Namespace) -> int:
"""幂等创建管理员账号。
已存在时**不覆盖密码** —— 意外重跑脚本不应把已有账号的密码改掉。
"""
factory = get_session_factory()
with factory() as session:
existing = session.execute(
select(User).where(User.username == args.username)
).scalar_one_or_none()
if existing is not None:
print(f"用户 {args.username!r} 已存在(id={existing.id}),未做任何修改。")
return 0
role = session.execute(
select(Role).where(Role.role_code == ROLE_ADMIN)
).scalar_one_or_none()
if role is None:
print(
"错误:未找到 admin 角色,请先执行数据库迁移(uv run alembic upgrade head)。",
file=sys.stderr,
)
return 1
password = args.password or _prompt_password()
user = User(
username=args.username,
password=hash_password(password),
real_name=args.real_name,
status=1,
)
session.add(user)
session.flush() # 取得自增 id 后才能建关联
session.add(UserRole(user_id=user.id, role_id=role.id))
session.commit()
print(f"已创建管理员 {args.username!r}(id={user.id}),角色:{role.role_name}。")
print("首次登录后请立即修改密码。")
return 0
def _stage_tplus(args: argparse.Namespace) -> int:
"""读取 T+ 源库并写入 MySQL staging。"""
target_factory = get_session_factory()
source_factory = get_tplus_session_factory()
watermark = datetime.fromisoformat(args.watermark) if args.watermark else None
with source_factory() as source_session, target_factory() as target_session:
batch = TPlusSyncService(source_session, target_session).run(
entity_types=args.entity,
watermark=watermark,
batch_size=args.batch_size,
)
print(
f"T+ staging 完成:批次={batch.batch_no} 状态={batch.status} "
f"源记录={batch.source_record_count} 新增={batch.staged_record_count} "
f"失败={batch.failed_record_count}"
)
return 0 if batch.status == "SUCCEEDED" else 1
def _tplus_worker(args: argparse.Namespace) -> int:
"""独立消费持久化任务;HTTP 进程不承担长时间读取。"""
if args.connection_file:
settings = get_settings()
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
if not 2 <= args.poll_seconds <= 60:
print("轮询间隔必须在 2 到 60 秒之间", file=sys.stderr)
return 1
factory = get_session_factory()
try:
while True:
with factory() as session:
batch_no = run_once(session)
batch = get_batch(session, batch_no) if batch_no else None
status = batch.status if batch else None
if batch_no:
print(f"任务完成:{batch_no} 状态={status}", flush=True)
if not args.watch:
return 0 if status in (None, "SUCCEEDED") else 1
if batch_no is None or status != "SUCCEEDED":
time.sleep(args.poll_seconds)
except KeyboardInterrupt:
print("T+ 读取服务已停止")
return 130
except Exception as exc:
print(f"T+ 读取服务停止({type(exc).__name__}),请检查运行环境", file=sys.stderr)
return 1
def main() -> int:
"""命令行入口。"""
parser = argparse.ArgumentParser(
prog="app.cli", description="衍水集团营销人员工资管理系统 - 命令行工具"
)
sub = parser.add_subparsers(dest="command", required=True)
p_admin = sub.add_parser("create-admin", help="创建初始管理员账号(幂等)")
p_admin.add_argument("--username", default="admin", help="登录用户名,默认 admin")
p_admin.add_argument("--real-name", default="系统管理员", help="真实姓名")
p_admin.add_argument(
"--password",
default=None,
help="密码。不传则交互式输入(推荐)——命令行传参会留在 shell 历史中",
)
p_tplus = sub.add_parser("tplus-stage", help="读取 T+ 源库并写入原始 staging")
p_tplus.add_argument(
"--entity",
action="append",
choices=[
"personnel",
"products",
"customers",
"businesses",
"shipments",
"receipts",
"business_items",
"shipment_items",
"receipt_items",
],
required=True,
help="实体类型,可重复指定",
)
p_tplus.add_argument(
"--watermark",
default=None,
help="增量起始时间,ISO 8601 格式;不传表示全量读取",
)
p_tplus.add_argument("--batch-size", type=int, default=500, help="每次从源库读取的行数")
p_worker = sub.add_parser("tplus-worker", help="执行已排队的 T+ 读取或关联核验")
modes = p_worker.add_mutually_exclusive_group()
modes.add_argument("--once", action="store_true", help="执行一个任务后退出(默认)")
modes.add_argument("--watch", action="store_true", help="持续消费手动排队任务")
p_worker.add_argument("--connection-file", default=None, help="显式指定项目外只读连接 JSON")
p_worker.add_argument("--poll-seconds", type=int, default=3, help="空闲轮询间隔,2–60 秒")
args = parser.parse_args()
try:
if args.command == "create-admin":
return _create_admin(args)
if args.command == "tplus-worker":
return _tplus_worker(args)
return _stage_tplus(args)
finally:
dispose_engine()
dispose_tplus_engine()
if __name__ == "__main__":
raise SystemExit(main())