"""命令行管理工具。 用法(在 backend/ 目录下执行):: uv run python -m app.cli create-admin --username admin 密码优先**交互式输入**而非命令行参数:命令行参数会进入 shell 历史, 在多用户机器上还能被 `ps` 看到。`--password` 仅为自动化脚本保留。 """ import argparse import getpass import json import signal import sys import threading from datetime import datetime from pathlib import Path from uuid import uuid4 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() settings = get_settings() stop_file = getattr(args, "stop_file", None) or settings.t_plus_stop_file stopped = threading.Event() owner = str(uuid4()) previous_handlers = {} if threading.current_thread() is threading.main_thread(): for sig in (signal.SIGINT, signal.SIGTERM): previous_handlers[sig] = signal.signal(sig, lambda *_: stopped.set()) try: while not stopped.is_set() and not (stop_file and Path(stop_file).exists()): with factory() as session: batch_no = run_once(session, worker_owner=owner) batch = get_batch(session, batch_no) if batch_no else None if batch_no and batch is None: from app.models import TPlusWorkRun batch = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.run_no == batch_no)) 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": stopped.wait(args.poll_seconds) print("T+读取服务已停止领取新任务", flush=True) return 0 except KeyboardInterrupt: print("T+ 读取服务已停止") return 130 except Exception as exc: print(f"T+ 读取服务停止({type(exc).__name__}),请检查运行环境", file=sys.stderr) return 1 finally: for sig, handler in previous_handlers.items(): signal.signal(sig, handler) try: from app.services.tplus.automation import heartbeat heartbeat(factory, settings, owner, "STOPPED") except Exception as exc: print(f"T+停止状态未能写入({type(exc).__name__}),请检查迁移与运行配置", file=sys.stderr) def _maintenance(args): from app.services.tplus.automation import tick if args.connection_file: settings = get_settings() settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file if args.stop_file: get_settings().t_plus_stop_file = args.stop_file with get_session_factory()() as session: result = tick(session) print(json.dumps(result, ensure_ascii=False)) return 0 def _materials(args): from app.services.tplus.materials import collect_materials, render_markdown if args.connection_file: settings = get_settings() settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file output = Path(args.output_dir).resolve() targets = [output / f"materials-{args.customer_id}-{args.month}.{ext}" for ext in ("json", "md")] if any(p.exists() for p in targets): raise RuntimeError("核对包已存在,请指定新的输出目录,避免覆盖既有验收依据") with get_tplus_session_factory()() as source: report = collect_materials(source, args.customer_id, args.month) output.mkdir(parents=True, exist_ok=True) targets[0].write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") targets[1].write_text(render_markdown(report), encoding="utf-8") print(f"样本核对包已生成;待补{len(report['missing'])}项;真实金额尚未验收:{output}") return 0 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 秒") p_worker.add_argument("--stop-file", default=None, help="检测到停止标记后不再领取新任务") p_maintenance = sub.add_parser("tplus-maintain", help="检查每周原始采集与有限重试,不生成工资") p_maintenance.add_argument("--once", action="store_true", help="执行一次检查(默认)") p_maintenance.add_argument("--connection-file", default=None) p_maintenance.add_argument("--stop-file", default=None) p_materials = sub.add_parser("tplus-materials", help="只读整理客户样本核对资料,不生成工资") p_materials.add_argument("--customer-id", type=int, required=True) p_materials.add_argument("--month", required=True) p_materials.add_argument("--connection-file", default=None) p_materials.add_argument("--output-dir", required=True) args = parser.parse_args() try: if args.command == "create-admin": return _create_admin(args) if args.command == "tplus-worker": return _tplus_worker(args) if args.command == "tplus-maintain": return _maintenance(args) if args.command == "tplus-materials": return _materials(args) return _stage_tplus(args) except Exception as exc: print(f"命令执行失败({type(exc).__name__}),请检查参数和只读连接配置", file=sys.stderr) return 1 finally: dispose_engine() dispose_tplus_engine() if __name__ == "__main__": raise SystemExit(main())