diff --git a/.gitignore b/.gitignore index 5d381cc..4ffd5fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,162 +1,42 @@ -# ---> Python -# Byte-compiled / optimized / DLL files +# OS files +.DS_Store +Thumbs.db +ehthumbs.db +Desktop.ini + +# IDE +.idea/ +*.swp +*.swo +*~ + +# Python __pycache__/ *.py[cod] *$py.class - -# C extensions *.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ *.egg-info/ -.installed.cfg +dist/ +build/ *.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ +# Virtualenv venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json +env/ +.env -# Pyre type checker -.pyre/ +# Logs +logs/ +*.log -# pytype static type analyzer -.pytype/ +# Uploads +uploads/ -# Cython debug symbols -cython_debug/ +# Output +output/ -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +# Config +config.json +# Git +*.orig diff --git a/app.py b/app.py new file mode 100755 index 0000000..eabf7ba --- /dev/null +++ b/app.py @@ -0,0 +1,295 @@ +import subprocess + +from flask import Flask, render_template, request, send_file, session, redirect, url_for +from datetime import datetime +from common.logger import tool_logger, error_logger, system_logger + +import os, uuid + +# ========================= +# 工具模块导入 +# ========================= +from tools.file_convert import file_convert_page, file_convert_download +from tools.temp_upload import ( + temp_upload_page, + download_temp_file +) + +from tools.excel_merge import ( + read_file, + merge_df, + export_excel, + build_preview +) + +from tools.smart_data_v2 import ( + smart_data_page_v2, + smart_download_v2 +) + +from tools.weekly_permission import weekly_permission_page + +# ⭐ 新增:邮件通知工具 +from tools.mail_notify import mail_notify_page, mail_notify_progress + +# ⭐ 新增:Base64 编解码工具 +from tools.base64_codec import page as base64_page + +# ⭐ 新增:JSON 格式化工具 +from tools.json_tool import page as json_page + +# ⭐ 新增:URL 编解码工具 +from tools.url_codec import page as url_page + +# ⭐ 新增:图片压缩工具 +from tools.image_compress import page as image_compress_page + +# ⭐ 新增:文本差异对比工具 +from tools.text_diff import page as text_diff_page + + +# ========================= +# Flask app +# ========================= +app = Flask(__name__) +app.secret_key = "excel_merge_secret_key" + +UPLOAD_FOLDER = "uploads" +OUTPUT_FOLDER = "output" + +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +os.makedirs(OUTPUT_FOLDER, exist_ok=True) + + +# ========================= +# reset +# ========================= +@app.route("/reset") +def reset(): + session.clear() + return redirect(url_for("index")) + + +# ========================= +# 首页 +# ========================= +@app.route("/") +def index(): + return render_template("index.html") + + +# ========================= +# Excel merge(原逻辑不变) +# ========================= +def handle_excel_merge(): + preview_data = None + download_file = None + message = None + + if request.method == "POST": + try: + file1 = request.files.get("file1") + file2 = request.files.get("file2") + + if file1 and file1.filename: + p1 = os.path.join(UPLOAD_FOLDER, f"{uuid.uuid4()}_{file1.filename}") + file1.save(p1) + session["file1_path"] = p1 + session["file1_name"] = file1.filename + + if file2 and file2.filename: + p2 = os.path.join(UPLOAD_FOLDER, f"{uuid.uuid4()}_{file2.filename}") + file2.save(p2) + session["file2_path"] = p2 + session["file2_name"] = file2.filename + + if not session.get("file1_path") or not session.get("file2_path"): + raise Exception("请上传两个文件") + + key1 = request.form.get("key1", "").strip() + key2 = request.form.get("key2", "").strip() + join_type = request.form.get("join_type", "left") + + session["key1"] = key1 + session["key2"] = key2 + session["join_type"] = join_type + + df1 = read_file(session["file1_path"]) + df2 = read_file(session["file2_path"]) + + merged_df = merge_df(df1, df2, key1, key2, join_type) + + out_name = f"merged_{uuid.uuid4().hex}.xlsx" + out_path = os.path.join(OUTPUT_FOLDER, out_name) + + export_excel(merged_df, out_path) + + preview_data = build_preview(merged_df) + download_file = out_name + + tool_logger.info( + f"[ExcelMerge] file1={session.get('file1_name')} " + f"file2={session.get('file2_name')} rows={len(merged_df)}" + ) + + message = ( + f"整合成功,共 {len(merged_df)} 条记录 | " + f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" + ) + + except Exception as e: + error_logger.exception("[ExcelMergeError]") + message = f"错误:{e}" + + return render_template( + "excel.html", + preview_data=preview_data, + download_file=download_file, + message=message, + file1_name=session.get("file1_name", ""), + file2_name=session.get("file2_name", ""), + key1=session.get("key1", ""), + key2=session.get("key2", ""), + join_type=session.get("join_type", "left"), + ) + + +# ========================= +# download +# ========================= +@app.route("/download/") +def download(filename): + return send_file( + os.path.join(OUTPUT_FOLDER, filename), + as_attachment=True + ) + + +# ========================= +# TOOL HUB(统一入口) +# ========================= +TOOL_ROUTES = {} + + +def tool_dispatch(name): + handler = TOOL_ROUTES.get(name) + if handler: + return handler() + + return render_template(f"{name}.html") + + +@app.route("/tool/mail_notify/progress/") +def mail_progress(task_id): + return mail_notify_progress(task_id) + + +@app.route("/tool/", methods=["GET", "POST"]) +def tool(name): + return tool_dispatch(name) + + +# ========================= +# 工具注册(核心扩展点) +# ========================= +TOOL_ROUTES.update({ + + "excel": handle_excel_merge, + + "smart_data_v2": smart_data_page_v2, + + "file_convert": file_convert_page, + + "weekly_permission": weekly_permission_page, + + # ⭐ 新增邮件通知工具 + "mail_notify": mail_notify_page, + + # ⭐ 新增 Base64 编解码工具 + "base64": base64_page, + + # ⭐ 新增 JSON 格式化工具 + "json": json_page, + + # ⭐ 新增 URL 编解码工具 + "url": url_page, + + # ⭐ 新增图片压缩工具 + "image_compress": image_compress_page, + + # ⭐ 新增文本差异对比工具 + "text_diff": text_diff_page, + +}) + + +# ========================= +# temp upload +# ========================= +@app.route("/tool/temp_upload", methods=["GET", "POST"]) +def temp_upload(): + return temp_upload_page() + + +@app.route("/temp/download/") +def temp_download(filename): + return download_temp_file(filename) + + +# ========================= +# smart data download +# ========================= +@app.route("/tool/smart_data_v2/download/") +def smart_data_v2_download(filename): + return smart_download_v2(filename) + + +# ========================= +# file convert download +# ========================= +@app.route("/download/file_convert/") +def file_convert_download_route(filename): + return file_convert_download(filename) + + +# ========================= +# image compress download +# ========================= +@app.route("/tool/image_compress/download/") +def image_compress_download(filename): + return send_file(os.path.join(OUTPUT_FOLDER, filename), as_attachment=True) + + +cleanup_process = None +def start_cleanup_task(): + """ + 启动定时清理任务 + """ + global cleanup_process + cleanup_path = os.path.join( + os.path.dirname(__file__), + "cleanup_upload.py" + ) + + cleanup_process = subprocess.Popen( + ["python3", cleanup_path] + ) + + system_logger.info( + f"[cleanup] started pid=" + f"{cleanup_process.pid}" + ) + print( + f"[cleanup] started pid=" + f"{cleanup_process.pid}" + ) +# ========================= +# main +# ========================= +if __name__ == "__main__": + + # 防止 Flask debug 启动两次 + if os.environ.get("WERKZEUG_RUN_MAIN") == "true": + start_cleanup_task() + + app.run(host="0.0.0.0", port=8209, debug=True) diff --git a/cleanup_upload.py b/cleanup_upload.py new file mode 100755 index 0000000..b229325 --- /dev/null +++ b/cleanup_upload.py @@ -0,0 +1,135 @@ +import os +import time +import shutil +from datetime import datetime + +from common.logger import system_logger + + +# ========================== +# 项目路径 +# ========================== + +BASE_DIR = os.path.dirname( + os.path.abspath(__file__) +) + +# 需要清理的目录 +CLEAN_DIRS = [ + os.path.join(BASE_DIR, "uploads"), + os.path.join(BASE_DIR, "uploads", "temp") +] + + +# ========================== +# 清理函数 +# ========================== + +def clear_dir(path): + + if not os.path.exists(path): + + system_logger.error( + f"[Cleanup] path not exist: {path}" + ) + return + + deleted_count = 0 + + for filename in os.listdir(path): + + file_path = os.path.join(path, filename) + + try: + + if os.path.isfile(file_path): + os.remove(file_path) + deleted_count += 1 + + elif os.path.isdir(file_path): + shutil.rmtree(file_path) + deleted_count += 1 + + except Exception as e: + + system_logger.exception( + f"[Cleanup] delete failed: {file_path}" + ) + + system_logger.info( + f"[Cleanup] done path={path} deleted={deleted_count}" + ) + + +# ========================== +# 总清理入口 +# ========================== + +def clear_all(): + + system_logger.info( + "[Cleanup] task started" + ) + + for path in CLEAN_DIRS: + clear_dir(path) + + system_logger.info( + "[Cleanup] task finished" + ) + + +# ========================== +# 定时调度 +# ========================== +TTL_SECONDS = 3600 # 1小时 + +def scheduler(): + + system_logger.info("[Cleanup] TTL scheduler started") + + while True: + + now = time.time() + + for path in CLEAN_DIRS: + + if not os.path.exists(path): + continue + + deleted = 0 + + for filename in os.listdir(path): + + file_path = os.path.join(path, filename) + + try: + if not os.path.isfile(file_path): + continue + + # 文件创建时间 + file_time = os.path.getmtime(file_path) + + if now - file_time > TTL_SECONDS: + os.remove(file_path) + deleted += 1 + + except Exception as e: + system_logger.exception(f"[Cleanup] failed: {file_path}") + + if deleted > 0: + system_logger.info(f"[Cleanup] path={path} deleted={deleted}") + + time.sleep(300) # 每5分钟检查一次 + +# ========================== +# main +# ========================== + +if __name__ == "__main__": + + system_logger.info( + "[Cleanup] service started" + ) + + scheduler() \ No newline at end of file diff --git a/common/logger.py b/common/logger.py new file mode 100755 index 0000000..66ad878 --- /dev/null +++ b/common/logger.py @@ -0,0 +1,62 @@ +import logging +import os +from logging.handlers import TimedRotatingFileHandler + + +BASE_DIR = os.path.dirname( + os.path.dirname(os.path.abspath(__file__)) +) + +LOG_DIR = os.path.join(BASE_DIR, "logs") +os.makedirs(LOG_DIR, exist_ok=True) + + +def create_logger(name, filename): + + logger = logging.getLogger(name) + + if logger.handlers: + return logger + + logger.setLevel(logging.INFO) + + log_path = os.path.join(LOG_DIR, filename) + + handler = TimedRotatingFileHandler( + log_path, + when="midnight", + interval=1, + backupCount=30, + encoding="utf-8" + ) + + handler.suffix = "%Y-%m-%d.log" + + formatter = logging.Formatter( + "[%(asctime)s] " + "[%(levelname)s] " + "%(message)s" + ) + + handler.setFormatter(formatter) + + logger.addHandler(handler) + + return logger + + +# 直接可用 +system_logger = create_logger( + "system_logger", + "system.log" +) + +tool_logger = create_logger( + "tool_logger", + "tool.log" +) + +error_logger = create_logger( + "error_logger", + "error.log" +) \ No newline at end of file diff --git a/mail_sender.py b/mail_sender.py new file mode 100644 index 0000000..18aeff1 --- /dev/null +++ b/mail_sender.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +""" +邮件批量通知工具 - CLI 版 +用于在新系统切换时批量给全员发送新的账号口令通知 + +Usage: + python3 mail_sender.py users.txt + python3 mail_sender.py users.txt --smtp smtp.ctvit.com.cn --port 465 + python3 mail_sender.py users.txt --sender zhangguozhong@ctvit.com.cn + +TXT 格式:每行 邮箱 密码(空格 / tab / | / , 都支持) +""" + +import os +import re +import sys +import time +import argparse +import smtplib +import logging +from datetime import datetime +from email.mime.text import MIMEText +from email.header import Header + +# ===================================== +# 日志配置 +# ===================================== +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +logger = logging.getLogger("MailSender") + + +# ===================================== +# 邮件模板 +# ===================================== +DEFAULT_TEMPLATE = """亲爱的用户: + +您好!这是您的账号配置信息: + +您的邮件账号:{email} +您的初始口令:{password} + +请务必妥善保管您的账号信息,不要泄露给他人。 +如有疑问,请联系技术支持部门。 + +--- +此邮件为系统自动发送,请勿直接回复。 +""" + + +# ===================================== +# TXT 解析 +# ===================================== +def parse_users(file_path): + users = [] + with open(file_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + parts = re.split(r"[\s\t\|:;]+", line) + if len(parts) < 2: + continue + email = parts[0].strip() + password = parts[1].strip() + if not email or "@" not in email: + logger.warning(f"跳过无效行(非邮箱格式): {line}") + continue + users.append({"email": email, "password": password}) + return users + + +# ===================================== +# SMTP 连接(自动探测端口) +# ===================================== +def build_smtp(smtp_server, smtp_port, sender_email, sender_pass): + smtp_port = int(smtp_port) + + # 465 → SSL + if smtp_port == 465: + server = smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=30) + logger.info(f"📡 连接 {smtp_server}:{smtp_port} (SSL)") + server.login(sender_email, sender_pass) + return server + + # 587 → STARTTLS + if smtp_port == 587: + server = smtplib.SMTP(smtp_server, smtp_port, timeout=30) + server.ehlo() + server.starttls() + server.ehlo() + logger.info(f"📡 连接 {smtp_server}:{smtp_port} (STARTTLS)") + server.login(sender_email, sender_pass) + return server + + # 25 → 明文 + server = smtplib.SMTP(smtp_server, smtp_port, timeout=30) + logger.info(f"📡 连接 {smtp_server}:{smtp_port} (明文)") + server.login(sender_email, sender_pass) + return server + + +# ===================================== +# 单封发送(含重试) +# ===================================== +def send_mail(server, sender_email, receiver_email, subject, content, reply_to=None): + msg = MIMEText(content, "plain", "utf-8") + msg["From"] = sender_email + msg["To"] = receiver_email + msg["Subject"] = Header(subject or "邮件通知", "utf-8") + if reply_to: + msg["Reply-To"] = reply_to + server.sendmail(sender_email, [receiver_email], msg.as_string()) + + +def send_with_retry( + server, sender_email, receiver_email, + subject, content, reply_to=None, max_retries=3 +): + last_error = None + for attempt in range(1, max_retries + 1): + try: + send_mail(server, sender_email, receiver_email, subject, content, reply_to) + return True, None + except smtplib.SMTPServerDisconnected: + last_error = "连接断开" + if attempt < max_retries: + wait = 2 ** attempt + logger.warning(f" ⚠ 连接断开,{wait}s 后重试 (第{attempt}次)") + time.sleep(wait) + except smtplib.SMTPResponseException as e: + code = e.smtp_code + if 400 <= code < 500 and attempt < max_retries: + last_error = f"SMTP {code}" + wait = 2 ** attempt + logger.warning(f" ⚠ SMTP {code},{wait}s 后重试 (第{attempt}次)") + time.sleep(wait) + else: + return False, f"SMTP {code}: {e}" + except Exception as e: + return False, str(e) + return False, last_error + + +# ===================================== +# 主流程 +# ===================================== +def main(): + parser = argparse.ArgumentParser( + description="📧 邮件批量通知工具 (CLI)", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + python3 mail_sender.py users.txt + python3 mail_sender.py users.txt --port 465 --sender xxx@qq.com + python3 mail_sender.py users.txt --subject "系统账号通知" --delay 2 + python3 mail_sender.py users.txt --template my_template.txt + """, + ) + + parser.add_argument("file", help="用户列表 TXT 文件路径(每行:邮箱 密码)") + parser.add_argument("--smtp", default="smtp.ctvit.com.cn", help="SMTP 服务器地址(默认 smtp.ctvit.com.cn)") + parser.add_argument("--port", type=int, default=25, help="SMTP 端口(默认 25,常见 465/587)") + parser.add_argument("--sender", default="zhangguozhong@ctvit.com.cn", help="发件邮箱") + parser.add_argument("--passwd", help="SMTP 密码/授权码(如不传则交互输入)") + parser.add_argument("--subject", default="账号及口令通知", help="邮件主题") + parser.add_argument("--reply-to", help="回复地址(可选)") + parser.add_argument("--template", help="邮件模板文件路径(替代默认模板,支持 {email} {password})") + parser.add_argument("--delay", type=float, default=1.0, help="每封邮件间隔秒数(默认 1s)") + parser.add_argument("--retry", type=int, default=3, help="失败重试次数(默认 3)") + parser.add_argument("--test", action="store_true", help="测试模式:只给发件人自己发一封测试邮件") + + args = parser.parse_args() + + # ─── 检查文件 ─── + if not os.path.exists(args.file): + logger.error(f"❌ 文件不存在: {args.file}") + sys.exit(1) + + # ─── 读取模板 ─── + if args.template: + with open(args.template, "r", encoding="utf-8") as f: + template = f.read() + logger.info(f"📝 使用自定义模板: {args.template}") + else: + template = DEFAULT_TEMPLATE + logger.info("📝 使用默认模板") + + # ─── 获取密码 ─── + sender_pass = args.passwd + if not sender_pass: + import getpass + sender_pass = getpass.getpass(f"🔑 请输入 {args.sender} 的 SMTP 密码/授权码: ") + + # ─── 解析用户列表 ─── + users = parse_users(args.file) + if not users: + logger.error("❌ 未识别到有效用户数据") + sys.exit(1) + logger.info(f"📋 共读取到 {len(users)} 个用户") + + # ─── 测试发送 ─── + if args.test: + content = template.format(email=args.sender, password="123456") + server = None + try: + server = build_smtp(args.smtp, args.port, args.sender, sender_pass) + send_mail(server, args.sender, args.sender, f"[测试] {args.subject}", content, args.reply_to) + logger.info(f"✅ 测试邮件发送成功(已发送至 {args.sender})") + except Exception as e: + logger.error(f"❌ 测试发送失败: {e}") + sys.exit(1) + finally: + if server: + server.quit() + return + + # ─── 正式发送 ─── + total = len(users) + success_count = 0 + failed_count = 0 + failed_users = [] + + server = None + try: + server = build_smtp(args.smtp, args.port, args.sender, sender_pass) + logger.info(f"✅ 登录成功!开始批量发送(共 {total} 封)...") + print("─" * 50) + + for idx, user in enumerate(users, 1): + email = user["email"] + password = user["password"] + content = template.format(email=email, password=password) + + ok, err = send_with_retry( + server, args.sender, email, + args.subject, content, + reply_to=args.reply_to, + max_retries=args.retry, + ) + + if ok: + success_count += 1 + logger.info(f" [{idx}/{total}] ✅ {email}") + else: + failed_count += 1 + failed_users.append({"email": email, "error": err}) + logger.error(f" [{idx}/{total}] ❌ {email} → {err}") + + # 发送间隔 + if idx < total: + time.sleep(args.delay) + + except smtplib.SMTPAuthenticationError: + logger.error("❌ SMTP 认证失败,请检查邮箱地址和密码/授权码是否正确") + sys.exit(1) + except Exception as e: + logger.error(f"❌ 连接错误: {e}") + sys.exit(1) + finally: + if server: + server.quit() + + # ─── 汇总 ─── + print("═" * 50) + logger.info(f"📊 发送汇总:") + logger.info(f" 总 数:{total}") + logger.info(f" 成 功:{success_count}") + logger.info(f" 失 败:{failed_count}") + + if failed_users: + print() + logger.warning("📋 失败人员列表:") + for fu in failed_users: + logger.warning(f" ❌ {fu['email']} → {fu['error']}") + # 写入失败日志 + log_path = f"failed_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" + with open(log_path, "w", encoding="utf-8") as f: + for fu in failed_users: + f.write(f"{fu['email']}\t{fu['error']}\n") + logger.info(f"📁 失败详情已保存至: {log_path}") + + if failed_count == 0: + logger.info("🎉 全部发送成功!") + elif success_count > 0: + logger.info(f"⚠️ 部分失败,请检查失败列表重发") + + +if __name__ == "__main__": + main() diff --git a/mail_sender_simple.py b/mail_sender_simple.py new file mode 100644 index 0000000..a08368b --- /dev/null +++ b/mail_sender_simple.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +""" +邮件批量通知工具 - 简易版 +直接运行即可发送,无需额外参数 +""" + +import os +import re +import sys +import time +import smtplib +import logging +from datetime import datetime +from email.mime.text import MIMEText +from email.header import Header + +# ===================== 配置区(按需修改) ===================== +SMTP_SERVER = "smtp.ctvit.com.cn" +SMTP_PORT = 25 +SENDER_EMAIL = "zhangguozhong@ctvit.com.cn" +SENDER_PASS = "&TBJ7p^u$HF9hp" +SUBJECT = "账号及口令通知" +REPLY_TO = "" # 回复地址(可选,留空则不填) +DELAY = 1 # 每封间隔(秒) +MAX_RETRIES = 3 # 失败重试次数 +USER_FILE = "users.txt" # 用户列表文件 +TEMPLATE_FILE = "" # 自定义模板文件(留空用默认模板) +# ============================================================= + +DEFAULT_TEMPLATE = """亲爱的用户: + +您好!这是您的账号配置信息: + +您的邮件账号:{email} +您的初始口令:{password} + +请务必妥善保管您的账号信息,不要泄露给他人。 +如有疑问,请联系技术支持部门。 + +--- +此邮件为系统自动发送,请勿直接回复。""" + + +# 日志 +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +logger = logging.getLogger("MailSender") + + +def parse_users(file_path): + users = [] + with open(file_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + parts = re.split(r"[\s\t\|:;]+", line) + if len(parts) < 2: + continue + email = parts[0].strip() + password = parts[1].strip() + if not email or "@" not in email: + logger.warning(f"跳过无效行: {line}") + continue + users.append({"email": email, "password": password}) + return users + + +def build_smtp(): + if SMTP_PORT == 465: + server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=30) + logger.info(f"📡 连接 {SMTP_SERVER}:{SMTP_PORT} (SSL)") + elif SMTP_PORT == 587: + server = smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=30) + server.ehlo() + server.starttls() + server.ehlo() + logger.info(f"📡 连接 {SMTP_SERVER}:{SMTP_PORT} (STARTTLS)") + else: + server = smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=30) + logger.info(f"📡 连接 {SMTP_SERVER}:{SMTP_PORT} (明文)") + + server.login(SENDER_EMAIL, SENDER_PASS) + return server + + +def send_one(server, receiver_email, content): + msg = MIMEText(content, "plain", "utf-8") + msg["From"] = SENDER_EMAIL + msg["To"] = receiver_email + msg["Subject"] = Header(SUBJECT, "utf-8") + if REPLY_TO: + msg["Reply-To"] = REPLY_TO + server.sendmail(SENDER_EMAIL, [receiver_email], msg.as_string()) + + +def send_with_retry(server, receiver_email, content): + for attempt in range(1, MAX_RETRIES + 1): + try: + send_one(server, receiver_email, content) + return True, None + except smtplib.SMTPServerDisconnected: + if attempt < MAX_RETRIES: + wait = 2 ** attempt + logger.warning(f" ⚠ 连接断开,{wait}s 后重试 (第{attempt}次)") + time.sleep(wait) + except smtplib.SMTPResponseException as e: + code = e.smtp_code + if 400 <= code < 500 and attempt < MAX_RETRIES: + wait = 2 ** attempt + logger.warning(f" ⚠ SMTP {code},{wait}s 后重试 (第{attempt}次)") + time.sleep(wait) + else: + return False, f"SMTP {code}: {e}" + except Exception as e: + return False, str(e) + return False, "重试耗尽" + + +def main(): + logger.info("=" * 50) + logger.info("📧 邮件批量通知工具 启动") + logger.info(f" 发件人:{SENDER_EMAIL}") + logger.info(f" SMTP:{SMTP_SERVER}:{SMTP_PORT}") + logger.info(f" 间隔:{DELAY}s  重试:{MAX_RETRIES}次") + logger.info("=" * 50) + + # 1. 检查文件 + if not os.path.exists(USER_FILE): + logger.error(f"❌ 文件不存在: {USER_FILE}") + sys.exit(1) + + # 2. 读取模板 + if TEMPLATE_FILE: + with open(TEMPLATE_FILE, "r", encoding="utf-8") as f: + template = f.read() + logger.info(f"📝 自定义模板: {TEMPLATE_FILE}") + else: + template = DEFAULT_TEMPLATE + logger.info("📝 默认模板") + + # 3. 解析用户 + users = parse_users(USER_FILE) + if not users: + logger.error("❌ 未识别到有效用户") + sys.exit(1) + logger.info(f"📋 共 {len(users)} 个收件人") + + # 4. 测试发送(先发给自己验证) + logger.info("🧪 正在测试发送(发给自己验证)...") + server = None + try: + server = build_smtp() + test_content = template.format(email=SENDER_EMAIL, password="123456") + send_one(server, SENDER_EMAIL, test_content) + logger.info(f"✅ 测试成功!已发送至 {SENDER_EMAIL}") + except smtplib.SMTPAuthenticationError: + logger.error("❌ SMTP 认证失败,请检查密码/授权码") + sys.exit(1) + except Exception as e: + logger.error(f"❌ 测试发送失败: {e}") + logger.error(" 请检查 SMTP 服务器和端口是否正确") + sys.exit(1) + finally: + if server: + try: + server.quit() + except Exception: + pass + + # 5. 正式发送 + total = len(users) + success = 0 + failed = 0 + failed_list = [] + + print() + logger.info(f"🚀 开始批量发送(共 {total} 封)") + print("─" * 50) + + for idx, user in enumerate(users, 1): + email = user["email"] + content = template.format(email=email, password=user["password"]) + + server = None + try: + server = build_smtp() + ok, err = send_with_retry(server, email, content) + except smtplib.SMTPAuthenticationError: + logger.error("❌ SMTP 认证失败") + sys.exit(1) + except Exception as e: + ok, err = False, str(e) + finally: + if server: + try: + server.quit() + except Exception: + pass + + if ok: + success += 1 + logger.info(f" [{idx}/{total}] ✅ {email}") + else: + failed += 1 + failed_list.append({"email": email, "error": err}) + logger.error(f" [{idx}/{total}] ❌ {email} → {err}") + + if idx < total: + time.sleep(DELAY) + + # 6. 汇总 + print("═" * 50) + logger.info(f"📊 发送完成") + logger.info(f" 总 数:{total}") + logger.info(f" 成 功:{success}") + logger.info(f" 失 败:{failed}") + + if failed_list: + print() + logger.warning("📋 失败人员:") + for f in failed_list: + logger.warning(f" ❌ {f['email']} → {f['error']}") + log_file = f"failed_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" + with open(log_file, "w", encoding="utf-8") as f: + for fu in failed_list: + f.write(f"{fu['email']}\t{fu['error']}\n") + logger.info(f"📁 失败详情已保存: {log_file}") + + if failed == 0: + logger.info("🎉 全部发送成功!") + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..ae153e9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +flask +flask-cors +pandas +openpyxl +xlrd +xlsxwriter +python-dotenv +tqdm +chardet +requests +httpx +orjson +pydantic +werkzeug +loguru +rich \ No newline at end of file diff --git a/templates/base64.html b/templates/base64.html new file mode 100644 index 0000000..1b0c4ad --- /dev/null +++ b/templates/base64.html @@ -0,0 +1,202 @@ + + + + + +Base64 编解码 + + + +
+
+
🔐 Base64 编解码
+ + +
+ + +
+
+ + {% if result %} +
+
+ 处理结果 + +
+ +
+ {% endif %} +
+ +
+ + + + \ No newline at end of file diff --git a/templates/excel.html b/templates/excel.html new file mode 100755 index 0000000..4b6cfa8 --- /dev/null +++ b/templates/excel.html @@ -0,0 +1,138 @@ + + + + + +Excel Merge + + + + +
+

📊 Excel / CSV 文件整合工具

+
+
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+
+ +重新开始 +
+ +{% if message %} +
{{message}}
+{% endif %} + +{% if preview_data %} +
{{preview_data|safe}}
+下载Excel +{% endif %} +
+ + + + + \ No newline at end of file diff --git a/templates/file_convert.html b/templates/file_convert.html new file mode 100755 index 0000000..8427c57 --- /dev/null +++ b/templates/file_convert.html @@ -0,0 +1,136 @@ + + + + + +文件转换工具 + + + +
+

📁 文件格式转换工具

+
+ + + +
+{% if message %}

{{message}}

{% endif %} +{% if preview %}
{{ preview|safe }}
{% endif %} +{% if download_file %} +⬇ 下载文件 +{% endif %} +
+ + \ No newline at end of file diff --git a/templates/image_compress.html b/templates/image_compress.html new file mode 100644 index 0000000..dbec765 --- /dev/null +++ b/templates/image_compress.html @@ -0,0 +1,170 @@ + + + + + +图片压缩工具 + + + +
+
+
🖼️ 图片压缩工具
+
+ + + +
+ 压缩质量 + + {{ quality or 85 }}% +
+ +
+ +
+ + {% if message %} +
{{ message }}
+ {% endif %} + + {% if download_file %} + ⬇ 下载压缩图片 + {% endif %} +
+
+ +
+ + + + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100755 index 0000000..36fa0da --- /dev/null +++ b/templates/index.html @@ -0,0 +1,261 @@ + + + + + +Tool Hub + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/templates/json.html b/templates/json.html new file mode 100644 index 0000000..2dcd64c --- /dev/null +++ b/templates/json.html @@ -0,0 +1,223 @@ + + + + + +JSON 格式化工具 + + + +
+
📋 JSON 格式化工具
+ +
+ + +
+ + + +
+
+ + {% if result %} +
+
+ 输出 + +
+ +
+ {% endif %} +
+ +
+ + + + \ No newline at end of file diff --git a/templates/mail_notify.html b/templates/mail_notify.html new file mode 100755 index 0000000..d239b7a --- /dev/null +++ b/templates/mail_notify.html @@ -0,0 +1,301 @@ + + + + + +邮件批量通知工具 + + + +
+
+ + +
+

📧 邮件批量通知工具 SMTP · TXT Batch

+
上传 TXT → 自动解析账号密码 → 批量发送邮件通知(含自动重试 × 3)
+
+ +
+

🔧 SMTP 配置

+
+ + + +
+
+ +
+

📬 发件人

+
+ + + +
+
🔒 密码仅用于本次请求,不回传、不存储
+
+ +
+

📝 邮件模板

+ +
支持变量:{{email}}{{password}}
+
+ +
+

📎 用户列表

+ +
TXT格式:每行"邮箱 密码"(空格 / | / , / tab 均支持)
+
+ + +
+
+ +{% if message %} +
{{message}}
+{% endif %} + + +
+

⏳ 发送进度

+
+ 📤 总计:0 + ✅ 成功:0 + ❌ 失败:0 + 📶 进度:0% +
+
+
+
+ + + +
邮箱状态
+
+ +
+ +
+
+ + + + diff --git a/templates/smart_data_v2.html b/templates/smart_data_v2.html new file mode 100755 index 0000000..2c49d0c --- /dev/null +++ b/templates/smart_data_v2.html @@ -0,0 +1,219 @@ + + + + + +智能数据匹配引擎 + + + +
+
+

🧠 智能数据匹配引擎 Auto Join · Schema Detect

+
+ +
+

📂 上传数据文件

+
+
+ + +
+ +
+
+ + {% if message %} +
{{ message }}
+ {% endif %} + + {% if match_info %} +
+
🧠 匹配分析结果
+
{{ match_info }}
+
+
+
成功匹配
+
{{ matched_count or 0 }}
+
+
+
未匹配
+
{{ unmatched_count or 0 }}
+
+
+
+ {% endif %} + + {% if preview %} +
+

📊 预览结果

+
{{ preview|safe }}
+ 下载结果 +
+ {% endif %} +
+ + + + \ No newline at end of file diff --git a/templates/temp_text.html b/templates/temp_text.html new file mode 100755 index 0000000..6426db4 --- /dev/null +++ b/templates/temp_text.html @@ -0,0 +1,77 @@ + + + + + +临时文本中转 + + + +
+
+ + +
+{% if msg %}
{{msg}}
{% endif %} +{% if link %}{% endif %} +
+ + \ No newline at end of file diff --git a/templates/temp_text_view.html b/templates/temp_text_view.html new file mode 100755 index 0000000..927ee04 --- /dev/null +++ b/templates/temp_text_view.html @@ -0,0 +1,79 @@ + + + + + +临时文本 + + + + +
+
+
📄 临时文本内容
+ +
+
+
+ + + + \ No newline at end of file diff --git a/templates/temp_upload.html b/templates/temp_upload.html new file mode 100755 index 0000000..59588b4 --- /dev/null +++ b/templates/temp_upload.html @@ -0,0 +1,101 @@ + + + + + +临时文件上传 + + + + +
+

📂 临时文件上传工具 零点自动清空

+
+
+
+ +
+
+ +
+
+
+ +{% if message %} +
{{message}}
+{% endif %} + +
已上传文件
+ + + +{% for file in files %} + + + + + +{% endfor %} + +
文件名大小(KB)操作
{{ file.name }}{{ file.size }}下载
+
+ + \ No newline at end of file diff --git a/templates/text_diff.html b/templates/text_diff.html new file mode 100644 index 0000000..b6c4619 --- /dev/null +++ b/templates/text_diff.html @@ -0,0 +1,216 @@ + + + + + +文本差异对比 + + + +
+
📝 文本差异对比
+ +
+ +
+
+
+ +
+
+ +
+
+ +
+
+ + {% if diff_result %} +
+
+ 📊 对比结果 +
+
{{ diff_result|safe }}
+
+ {% endif %} +
+ +
+ + + + \ No newline at end of file diff --git a/templates/url.html b/templates/url.html new file mode 100644 index 0000000..cfb9fea --- /dev/null +++ b/templates/url.html @@ -0,0 +1,186 @@ + + + + + +URL 编解码 + + + +
+
+
🔗 URL 编解码工具
+ + +
+ + +
+
+ + {% if result %} +
+
+ 处理结果 + +
+ +
+ {% endif %} +
+ +
+ + + + \ No newline at end of file diff --git a/templates/weekly_permission.html b/templates/weekly_permission.html new file mode 100755 index 0000000..e88ad4f --- /dev/null +++ b/templates/weekly_permission.html @@ -0,0 +1,186 @@ + + + + + +每周动态人员授权工具 + + + +
+
+

📌 每周动态人员授权工具 MySQL · SQL Generator

+
输入中心名称 + 8位工号,生成授权或重复处理SQL
+
+ +
+
+
+ + +
+
+ + +
+
+ {% if message %} +
{{ message }}
+ {% endif %} +
+ +{% if sql_text %} +
+
+
生成结果
+ +
+
{{ sql_text }}
+
⚠ SQL为模板生成,请确认业务环境后执行
+
+{% endif %} +
+ +
+ + + \ No newline at end of file diff --git a/tools/base64_codec.py b/tools/base64_codec.py new file mode 100644 index 0000000..675ba75 --- /dev/null +++ b/tools/base64_codec.py @@ -0,0 +1,31 @@ +import base64 as b64 + +def encode_text(text): + """Base64 编码""" + return b64.b64encode(text.encode('utf-8')).decode('utf-8') + +def decode_text(text): + """Base64 解码""" + try: + return b64.b64decode(text.encode('utf-8')).decode('utf-8') + except Exception: + return "解码失败,请检查输入是否合法" + + +def page(): + """工具页面""" + from flask import render_template, request + + result = None + input_text = "" + action = request.args.get("action") + raw_text = request.args.get("text", "") + + if raw_text: + input_text = raw_text + if action == "encode_text": + result = encode_text(raw_text) + elif action == "decode_text": + result = decode_text(raw_text) + + return render_template("base64.html", result=result, input_text=input_text) \ No newline at end of file diff --git a/tools/excel_merge.py b/tools/excel_merge.py new file mode 100755 index 0000000..e137b82 --- /dev/null +++ b/tools/excel_merge.py @@ -0,0 +1,64 @@ +# tools/excel_merge.py +from common.logger import tool_logger, error_logger +import pandas as pd + +def read_file(file_path): + ext = file_path.lower().split(".")[-1] + + if ext in ["xlsx", "xls"]: + df = pd.read_excel(file_path, dtype=str) + + elif ext == "csv": + df = None + for enc in ["utf-8", "gbk", "gb18030"]: + try: + df = pd.read_csv(file_path, encoding=enc, dtype=str) + break + except: + pass + + if df is None: + tool_logger.exception("CSV编码无法识别") + raise Exception("CSV编码无法识别") + + else: + tool_logger.exception("仅支持 xlsx/xls/csv") + raise Exception("仅支持 xlsx/xls/csv") + + df.columns = df.columns.astype(str).str.strip() + for c in df.columns: + df[c] = df[c].astype(str).str.strip() + + return df + + +def merge_df(df1, df2, key1, key2, join_type): + if key1 not in df1.columns: + tool_logger.exception(f"文件1不存在字段:{key1}") + raise Exception(f"文件1不存在字段:{key1}") + + if key2 not in df2.columns: + tool_logger.exception(f"文件2不存在字段:{key2}") + raise Exception(f"文件2不存在字段:{key2}") + + merged_df = pd.merge( + df1, + df2, + left_on=key1, + right_on=key2, + how=join_type + ) + + return merged_df + + +def export_excel(df, path): + df.to_excel(path, index=False) + +def build_preview(df): + return df.head(100).fillna("").to_html( + classes="table table-bordered table-hover preview-table", + table_id="previewTable", + index=False, + border=0 + ) \ No newline at end of file diff --git a/tools/file_convert.py b/tools/file_convert.py new file mode 100755 index 0000000..cb17966 --- /dev/null +++ b/tools/file_convert.py @@ -0,0 +1,159 @@ +import os +import uuid +import pandas as pd + +from flask import render_template, request, send_file +from common.logger import system_logger + + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +UPLOAD_FOLDER = os.path.join(BASE_DIR, "uploads") +OUTPUT_FOLDER = os.path.join(BASE_DIR, "output") + +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +os.makedirs(OUTPUT_FOLDER, exist_ok=True) + + +# ========================= +# 1. 读取文件(CSV/TXT/Excel) +# ========================= +def read_any(file_path): + + ext = file_path.lower().split(".")[-1] + + # Excel + if ext in ["xls", "xlsx"]: + df = pd.read_excel(file_path, dtype=str) + return df + + # CSV / TXT + if ext in ["csv", "txt"]: + + seps = [",", "|", "\t", " "] + + for sep in seps: + try: + df = pd.read_csv( + file_path, + sep=sep, + engine="python", + dtype=str, + encoding="utf-8" + ) + + if df.shape[1] > 1: + return df + + except: + continue + + # fallback GBK + for sep in seps: + try: + df = pd.read_csv( + file_path, + sep=sep, + engine="python", + dtype=str, + encoding="gbk" + ) + + if df.shape[1] > 1: + return df + + except: + continue + + raise Exception("无法解析TXT/CSV结构") + + raise Exception("不支持的文件类型") + + +# ========================= +# 2. 转换核心 +# ========================= +def convert_file(df, target): + + out_name = f"convert_{uuid.uuid4().hex}" + + # Excel + if target == "excel": + path = os.path.join(OUTPUT_FOLDER, out_name + ".xlsx") + df.to_excel(path, index=False) + + # CSV + elif target == "csv": + path = os.path.join(OUTPUT_FOLDER, out_name + ".csv") + df.to_csv(path, index=False, encoding="utf-8-sig") + + # TXT(默认 tab) + elif target == "txt": + path = os.path.join(OUTPUT_FOLDER, out_name + ".txt") + df.to_csv(path, index=False, sep="\t", encoding="utf-8") + + else: + raise Exception("未知转换类型") + + return path, os.path.basename(path) + + +# ========================= +# 3. 页面 +# ========================= +def file_convert_page(): + + preview = None + download_file = None + message = None + + if request.method == "POST": + + try: + file = request.files.get("file") + target = request.form.get("target") + + if not file or not file.filename: + raise Exception("请选择文件") + + filename = f"{uuid.uuid4()}_{file.filename}" + path = os.path.join(UPLOAD_FOLDER, filename) + file.save(path) + + df = read_any(path) + + preview = df.head(100).fillna("").to_html( + classes="table table-dark table-hover", + index=False + ) + + out_path, download_file = convert_file(df, target) + + message = f"转换成功:{len(df)} 行" + + system_logger.info( + f"[FileConvert] {file.filename} -> {target}" + ) + + except Exception as e: + message = str(e) + system_logger.exception("[FileConvert ERROR]") + + return render_template( + "file_convert.html", + preview=preview, + download_file=download_file, + message=message + ) + + +# ========================= +# download +# ========================= +def file_convert_download(filename): + + path = os.path.join(OUTPUT_FOLDER, filename) + + system_logger.info(f"[FileConvertDownload] {filename}") + + return send_file(path, as_attachment=True) \ No newline at end of file diff --git a/tools/image_compress.py b/tools/image_compress.py new file mode 100644 index 0000000..1144b71 --- /dev/null +++ b/tools/image_compress.py @@ -0,0 +1,50 @@ +import os, uuid +from flask import render_template, request, send_file + +OUTPUT_FOLDER = os.path.join(os.path.dirname(__file__), "..", "output") +os.makedirs(OUTPUT_FOLDER, exist_ok=True) + +def compress_image(input_stream, quality=85): + """使用 PIL 压缩图片""" + try: + from PIL import Image + img = Image.open(input_stream) + + # 转为 RGB(支持 JPG) + if img.mode in ("RGBA", "P"): + img = img.convert("RGB") + + out_name = f"compressed_{uuid.uuid4().hex}.jpg" + out_path = os.path.join(OUTPUT_FOLDER, out_name) + + img.save(out_path, "JPEG", quality=quality, optimize=True) + return out_name + except Exception as e: + raise Exception(f"图片处理失败: {e}") + +def page(): + from flask import render_template, request + download_file = None + message = None + quality = 85 + + if request.method == "POST": + file = request.files.get("file") + quality = int(request.form.get("quality", 85)) + + if file: + try: + suffix = os.path.splitext(file.filename)[-1].lower() + if suffix not in [".jpg", ".jpeg", ".png", ".webp"]: + raise Exception("仅支持 JPG/PNG/WEBP 格式") + + tmp_path = os.path.join(OUTPUT_FOLDER, f"tmp_{uuid.uuid4().hex}{suffix}") + file.save(tmp_path) + + download_file = compress_image(tmp_path, quality) + os.remove(tmp_path) + message = "✅ 压缩成功" + except Exception as e: + message = f"❌ {e}" + + return render_template("image_compress.html", download_file=download_file, message=message, quality=quality) \ No newline at end of file diff --git a/tools/json_tool.py b/tools/json_tool.py new file mode 100644 index 0000000..30f8534 --- /dev/null +++ b/tools/json_tool.py @@ -0,0 +1,42 @@ +import json, re + +def format_json(text): + try: + obj = json.loads(text) + return json.dumps(obj, indent=2, ensure_ascii=False) + except Exception: + return "JSON 格式错误,请检查输入" + +def minify_json(text): + try: + obj = json.loads(text) + return json.dumps(obj, separators=(',', ':')) + except Exception: + return "JSON 格式错误,请检查输入" + +def validate_json(text): + try: + json.loads(text) + return "✅ 有效的 JSON" + except Exception as e: + return f"❌ 格式错误: {str(e)}" + +def page(): + from flask import render_template, request + result = None + action = request.args.get("action") + text = request.args.get("text", "").strip() + + if text: + if action == "format": + result = format_json(text) + elif action == "minify": + result = minify_json(text) + elif action == "validate": + result = validate_json(text) + else: + result = format_json(text) + else: + result = None + + return render_template("json.html", result=result, text=text) \ No newline at end of file diff --git a/tools/mail_notify.py b/tools/mail_notify.py new file mode 100755 index 0000000..46a0865 --- /dev/null +++ b/tools/mail_notify.py @@ -0,0 +1,377 @@ +import os +import re +import json +import time +import uuid +import smtplib +import threading +import traceback + +from email.mime.text import MIMEText +from email.header import Header +from flask import render_template, request, jsonify +from common.logger import system_logger + +# ===================================== +# Path +# ===================================== +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +UPLOAD_FOLDER = os.path.join(BASE_DIR, "uploads") +PROGRESS_FOLDER = os.path.join(BASE_DIR, "logs", "progress") +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +os.makedirs(PROGRESS_FOLDER, exist_ok=True) + +# ===================================== +# 进度文件存储(跨进程共享) +# ===================================== + +def progress_path(task_id): + return os.path.join(PROGRESS_FOLDER, f"{task_id}.json") + + +def write_progress(task_id, **kwargs): + path = progress_path(task_id) + data = {} + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + # results 列表累积 + old_results = data.get("results", []) + except Exception: + old_results = [] + else: + old_results = [] + + data.update(kwargs) + + # 处理 results 追加 + results_append = data.pop("results_append", None) + if results_append: + old_results.append(results_append) + data["results"] = old_results + elif "results" not in data: + data["results"] = old_results + + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + + +def read_progress(task_id): + path = progress_path(task_id) + if not os.path.exists(path): + return None + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def clean_progress(task_id): + path = progress_path(task_id) + if os.path.exists(path): + os.remove(path) + + +# ===================================== +# TXT 解析 +# ===================================== +def parse_users(file_path): + users = [] + with open(file_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + parts = re.split(r"[\s\t\|:;]+", line) + if len(parts) < 2: + continue + email = parts[0].strip() + password = parts[1].strip() + if not email or "@" not in email: + continue + users.append({"email": email, "password": password}) + return users + + +# ===================================== +# 清理 textarea 缩进 +# ===================================== +def normalize_template(text): + if not text: + return "" + lines = text.splitlines() + while lines and not lines[0].strip(): + lines.pop(0) + while lines and not lines[-1].strip(): + lines.pop() + return "\n".join(lines) + + +# ===================================== +# 模板变量替换 +# ===================================== +def render_template_text(template, email, password): + template = normalize_template(template) + return ( + str(template) + .replace("{{email}}", str(email or "")) + .replace("{{password}}", str(password or "")) + ) + + +# ===================================== +# SMTP 连接 +# ===================================== +def build_smtp(smtp_server, smtp_port, sender_email, sender_pass): + smtp_port = int(smtp_port) + try: + if smtp_port == 465: + server = smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=30) + else: + server = smtplib.SMTP(smtp_server, smtp_port, timeout=30) + server.ehlo() + if smtp_port == 587: + server.starttls() + server.ehlo() + server.login(sender_email, sender_pass) + return server + except Exception as e: + raise Exception(f"SMTP连接失败:{smtp_server}:{smtp_port} {str(e)}") + + +# ===================================== +# 发单封邮件 +# ===================================== +def send_single_mail(server, sender_email, receiver_email, subject, content, reply_to=None): + msg = MIMEText(content, "plain", "utf-8") + msg["From"] = sender_email + msg["To"] = receiver_email + msg["Subject"] = Header(subject or "邮件通知", "utf-8") + if reply_to: + msg["Reply-To"] = reply_to + server.sendmail(sender_email, [receiver_email], msg.as_string()) + + +def send_with_retry(server, sender_email, receiver_email, subject, content, reply_to=None, max_retries=3): + last_error = None + for attempt in range(1, max_retries + 1): + try: + send_single_mail(server, sender_email, receiver_email, subject, content, reply_to) + return True, None + except smtplib.SMTPServerDisconnected: + last_error = "SMTPServerDisconnected" + if attempt < max_retries: + time.sleep(2 ** attempt) + except smtplib.SMTPResponseException as e: + code = e.smtp_code + if 400 <= code < 500 and attempt < max_retries: + last_error = f"SMTP {code}" + time.sleep(2 ** attempt) + else: + return False, str(e) + except Exception as e: + return False, str(e) + return False, last_error + + +# ===================================== +# 异步后台任务:批量发送 +# ===================================== +def batch_send_worker( + task_id, + smtp_server, + smtp_port, + sender_email, + sender_pass, + subject, + template_content, + users, + reply_to, +): + server = None + total = len(users) + success_count = 0 + failed_count = 0 + + write_progress(task_id, status="running", total=total, success=0, failed=0, current=0, results=[]) + + try: + server = build_smtp(smtp_server, smtp_port, sender_email, sender_pass) + + for idx, user in enumerate(users): + email = user.get("email", "") + password = user.get("password", "") + + ok, err = send_with_retry( + server, sender_email, email, + subject, render_template_text(template_content, email, password), + reply_to=reply_to + ) + + if ok: + success_count += 1 + result = {"email": email, "status": "success"} + write_progress(task_id, + success=success_count, + current=idx + 1, + results_append=result + ) + else: + failed_count += 1 + result = {"email": email, "status": f"failed: {err}"} + write_progress(task_id, + failed=failed_count, + current=idx + 1, + results_append=result + ) + + # 每封间隔 1s 防垃圾标记 + time.sleep(1) + + except Exception as e: + write_progress(task_id, error=str(e)) + + finally: + if server: + try: + server.quit() + except Exception: + pass + write_progress(task_id, status="done") + + +# ===================================== +# 进度查询接口 +# ===================================== +def mail_notify_progress(task_id): + data = read_progress(task_id) + if data is None: + return jsonify({"error": "task_id not found"}), 404 + # 只返回最近 50 条结果 + if "results" in data: + data["results"] = data["results"][-50:] + return jsonify(data) + + +# ===================================== +# 页面入口 +# ===================================== +def mail_notify_page(): + message = None + preview_content = "" + task_id = None + + smtp_server = "smtp.ctvit.com.cn" + smtp_port = "25" + reply_to = "" + sender_email = "" + sender_pass = "" + subject = "账号及口令通知" + + template_content = """尊敬的老师/同事: + + 您好! + + 根据系统账号开通安排,您的账号已创建完成。现将相关登录信息通知如下,请妥善保存并注意账号安全。 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +账号(邮箱):{{email}} +初始口令:{{password}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + 请在首次登录后及时修改初始口令,并妥善保管个人账号信息。 + +此致 +敬礼! + +信息技术支持中心""" + + try: + if request.method == "POST": + action = request.form.get("action", "send") + + smtp_server = (request.form.get("smtp_server") or smtp_server).strip() + smtp_port = (request.form.get("smtp_port") or smtp_port).strip() + sender_email = (request.form.get("sender_email") or "").strip() + sender_pass = (request.form.get("sender_pass") or "").strip() + subject = (request.form.get("subject") or subject).strip() + reply_to = (request.form.get("reply_to") or "").strip() + template_content = request.form.get("template_content") or template_content + upload_file = request.files.get("user_file") or request.files.get("file") + + if not sender_email: + raise Exception("请输入发件邮箱") + if not sender_pass: + raise Exception("请输入SMTP密码") + + if action == "test": + preview_content = render_template_text(template_content, sender_email, "123456") + server = None + try: + server = build_smtp(smtp_server, smtp_port, sender_email, sender_pass) + send_single_mail(server, sender_email, sender_email, f"[测试] {subject}", preview_content, reply_to=reply_to) + finally: + if server: + server.quit() + message = "✅ 测试邮件发送成功(已发送至自己邮箱)" + system_logger.info(f"[MailNotify-Test] sender={sender_email}") + + elif action == "send": + if not upload_file or not upload_file.filename: + raise Exception("请上传 TXT 文件") + ext = os.path.splitext(upload_file.filename)[1].lower() + if ext != ".txt": + raise Exception("仅支持 txt 文件") + + temp_name = f"{uuid.uuid4().hex}.txt" + save_path = os.path.join(UPLOAD_FOLDER, temp_name) + upload_file.save(save_path) + + users = parse_users(save_path) + if not users: + raise Exception("未识别到有效用户数据") + + task_id = uuid.uuid4().hex + write_progress(task_id, + status="queued", total=len(users), + success=0, failed=0, current=0, + results=[], error=None + ) + + thread = threading.Thread( + target=batch_send_worker, + args=( + task_id, + smtp_server, smtp_port, + sender_email, sender_pass, + subject, template_content, + users, reply_to, + ), + daemon=True, + ) + thread.start() + + message = f"⏳ 批量发送已启动,任务ID:{task_id}(共 {len(users)} 封,每封间隔1s)" + system_logger.info(f"[MailNotify] task={task_id} total={len(users)} sender={sender_email}") + + preview_content = render_template_text(template_content, "demo@test.com", "123456") + + except Exception as e: + message = f"❌ 错误:{str(e)}" + system_logger.exception("[MailNotify ERROR]") + + # 不回传密码 + return render_template( + "mail_notify.html", + message=message, + smtp_server=smtp_server, + smtp_port=smtp_port, + sender_email=sender_email, + sender_pass="", + reply_to=reply_to, + subject=subject, + template_content=template_content, + preview_content=preview_content, + task_id=task_id, + ) diff --git a/tools/smart_data_v2.py b/tools/smart_data_v2.py new file mode 100755 index 0000000..8d8298a --- /dev/null +++ b/tools/smart_data_v2.py @@ -0,0 +1,333 @@ +import os +import uuid +import pandas as pd +import re + +from flask import render_template, request, send_file +from common.logger import system_logger + + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +UPLOAD_FOLDER = os.path.join(BASE_DIR, "uploads") +OUTPUT_FOLDER = os.path.join(BASE_DIR, "output") + +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +os.makedirs(OUTPUT_FOLDER, exist_ok=True) + + +# ========================= +# 1. 智能读取(v2.3 FINAL) +# ========================= +def smart_read(file_path): + + ext = os.path.splitext(file_path)[-1].lower() + + # ========================= + # 1️⃣ CSV / Excel:强制 header + # ========================= + if ext in [".csv", ".xlsx", ".xls"]: + + try: + if ext == ".csv": + df = pd.read_csv(file_path, dtype=str) + else: + df = pd.read_excel(file_path, dtype=str) + + except Exception as e: + raise Exception(f"读取失败: {e}") + + has_header = True + + # 清洗 + df.columns = [str(c).strip() for c in df.columns] + + for c in df.columns: + df[c] = df[c].astype(str).str.strip() + + system_logger.info( + f"[SmartRead-v2.3] file={file_path} type={ext} header=FORCED shape={df.shape}" + ) + + return df, has_header + + # ========================= + # 2️⃣ TXT / 非结构文件:智能识别 + # ========================= + seps = [",", "|", "\t", r"\s+"] + + df = None + + for sep in seps: + try: + df = pd.read_csv( + file_path, + sep=sep, + engine="python", + header=None, + dtype=str + ) + + if df is not None and df.shape[1] >= 2: + break + + except: + continue + + if df is None: + raise Exception("无法解析文件") + + # ========================= + # 3️⃣ header 判断(仅 TXT 使用) + # ========================= + def is_header_row(row): + + row = [str(x).strip() for x in row] + + keywords = ["id", "uid", "name", "code", "工号", "编号", "time", "date"] + + text_score = 0 + structure_score = 0 + numeric_penalty = 0 + + for v in row: + + v_low = v.lower() + + if any(k in v_low for k in keywords): + text_score += 2 + + if not re.fullmatch(r"\d+", v): + structure_score += 1 + + if len(v) <= 20: + structure_score += 1 + + if re.fullmatch(r"\d{4,20}", v): + numeric_penalty += 1 + + score = text_score + structure_score - numeric_penalty + + return score >= 3 + + has_header = False + + if len(df) >= 2: + + row0 = df.iloc[0].tolist() + row1 = df.iloc[1].tolist() + + score0 = is_header_row(row0) + score1 = is_header_row(row1) + + has_header = score0 and not score1 + + # ========================= + # 4️⃣ 应用 schema + # ========================= + if has_header: + df.columns = [str(x).strip() for x in df.iloc[0].tolist()] + df = df.iloc[1:].reset_index(drop=True) + else: + df.columns = [f"c{i}" for i in range(df.shape[1])] + + # ========================= + # 5️⃣ 清洗 + # ========================= + df.columns = [str(c).strip() for c in df.columns] + + for c in df.columns: + df[c] = df[c].astype(str).str.strip() + + system_logger.info( + f"[SmartRead-v2.3] file={file_path} type=txt header={has_header} shape={df.shape}" + ) + + return df, has_header + + +# ========================= +# 2. 特征评分(不变) +# ========================= +def score_column(series): + + s = series.dropna().astype(str).head(30) + + if len(s) == 0: + return 0 + + score = 0 + + for v in s: + + if re.fullmatch(r"\d{6,12}", v): + score += 3 + + if v.isdigit(): + score += 2 + + if v.startswith("0"): + score += 1 + + if len(v) in [6, 8, 10, 12]: + score += 1 + + return score / len(s) + + +# ========================= +# 3. 自动 key(不变) +# ========================= +def detect_key(df): + + candidates = ["uid", "id", "user_id", "emp_id", "工号", "编号"] + + for col in df.columns: + for c in candidates: + if c in str(col).lower(): + return col + + best_col = None + best_score = 0 + + for col in df.columns: + score = score_column(df[col]) + + if score > best_score: + best_score = score + best_col = col + + if best_score > 0.4: + return best_col + + return None + + +# ========================= +# 4. join(不变) +# ========================= +def auto_join(df1, df2): + + key1 = detect_key(df1) + key2 = detect_key(df2) + + if not key1 or not key2: + raise Exception("无法识别匹配字段") + + result = pd.merge( + df1, + df2, + left_on=key1, + right_on=key2, + how="left", + indicator=True + ) + + result["match_status"] = result["_merge"].map({ + "both": "matched", + "left_only": "unmatched", + "right_only": "orphan" + }).astype(str) + + result.drop(columns=["_merge"], inplace=True) + + matched_count = int((result["match_status"] == "matched").sum()) + unmatched_count = int((result["match_status"] == "unmatched").sum()) + total = len(result) + + match_rate = round(matched_count / total * 100, 2) if total else 0 + + stats = { + "matched_count": matched_count, + "unmatched_count": unmatched_count, + "match_rate": match_rate + } + + return result, key1, key2, stats + + +# ========================= +# 5. 页面(不变) +# ========================= +def smart_data_page_v2(): + + preview = None + download_file = None + message = None + match_info = "" + + matched_count = 0 + unmatched_count = 0 + match_rate = 0 + + if request.method == "POST": + + try: + file1 = request.files.get("file1") + file2 = request.files.get("file2") + + p1 = os.path.join(UPLOAD_FOLDER, f"{uuid.uuid4()}_{file1.filename}") + p2 = os.path.join(UPLOAD_FOLDER, f"{uuid.uuid4()}_{file2.filename}") + + file1.save(p1) + file2.save(p2) + + df1, h1 = smart_read(p1) + df2, h2 = smart_read(p2) + + result, k1, k2, stats = auto_join(df1, df2) + + matched_count = stats["matched_count"] + unmatched_count = stats["unmatched_count"] + match_rate = stats["match_rate"] + + match_info = ( + f"匹配字段:{k1} ↔ {k2} | " + f"匹配率:{match_rate}% | " + f"来源:{'有表头' if h1 else '无表头'} / {'有表头' if h2 else '无表头'}" + ) + + out_name = f"v2_{uuid.uuid4().hex}.xlsx" + out_path = os.path.join(OUTPUT_FOLDER, out_name) + + result.to_excel(out_path, index=False) + + preview = result.head(100).fillna("").to_html( + classes="table table-dark table-hover", + index=False + ) + + download_file = out_name + message = f"匹配成功,共 {len(result)} 条记录" + + system_logger.info( + f"[SmartETL-v2.3] {match_info} " + f"matched={matched_count}, unmatched={unmatched_count}" + ) + + except Exception as e: + message = str(e) + system_logger.exception("[SmartETL-v2 ERROR]") + + return render_template( + "smart_data_v2.html", + preview=preview, + download_file=download_file, + message=message, + match_info=match_info, + matched_count=matched_count, + unmatched_count=unmatched_count, + match_rate=match_rate + ) + + +# ========================= +# download +# ========================= +def smart_download_v2(filename): + + path = os.path.join(OUTPUT_FOLDER, filename) + + system_logger.info(f"[SmartDownload-v2] {filename}") + + return send_file(path, as_attachment=True) \ No newline at end of file diff --git a/tools/temp_text_hub.py b/tools/temp_text_hub.py new file mode 100755 index 0000000..9d05a91 --- /dev/null +++ b/tools/temp_text_hub.py @@ -0,0 +1,140 @@ +import os +import json +import time +import uuid +import re +from flask import render_template, request, url_for + +from common.logger import system_logger + +STORE_FILE = os.path.join( + os.path.dirname(__file__), + "temp_text_store.json" +) + +EXPIRE_SECONDS = 3600 # 1小时 + + +# ========================= +# load +# ========================= +def load_store(): + if not os.path.exists(STORE_FILE): + return {} + + try: + with open(STORE_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except: + return {} + + +# ========================= +# save +# ========================= +def save_store(data): + with open(STORE_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +# ========================= +# markdown轻量格式化(V2新增) +# ========================= +def normalize_markdown(text: str) -> str: + + if not text: + return "" + + text = text.replace("\r\n", "\n").replace("\r", "\n") + + # 去掉多余空行(最多保留1个空行) + text = re.sub(r"\n{3,}", "\n\n", text) + + # 行首尾空格清理 + lines = [line.rstrip() for line in text.split("\n")] + + return "\n".join(lines).strip() + + +# ========================= +# cleanup +# ========================= +def cleanup(store): + now = time.time() + + expired = [ + k for k, v in store.items() + if v["expires_at"] < now + ] + + for k in expired: + del store[k] + + return store + + +# ========================= +# 创建页面 +# ========================= +def temp_text_create_page(): + + message = None + share_link = None + + if request.method == "POST": + + content = request.form.get("content", "") + + if not content.strip(): + return render_template("temp_text.html", message="内容不能为空") + + # ✔ markdown规范化 + content = normalize_markdown(content) + + store = load_store() + store = cleanup(store) + + text_id = uuid.uuid4().hex[:8] + now = time.time() + + store[text_id] = { + "content": content, + "created_at": now, + "expires_at": now + EXPIRE_SECONDS + } + + save_store(store) + + share_link = url_for("temp_text_view_route", text_id=text_id, _external=True) + + system_logger.info(f"[TempTextV2] create id={text_id}") + + message = "创建成功(1小时有效)" + + return render_template( + "temp_text.html", + message=message, + share_link=share_link + ) + + +# ========================= +# 查看页面 +# ========================= +def temp_text_view(text_id): + + store = load_store() + store = cleanup(store) + + if text_id not in store: + return "内容不存在或已过期" + + content = store[text_id]["content"] + + system_logger.info(f"[TempTextV2] view id={text_id}") + + return render_template( + "temp_text_view.html", + content=content, + text_id=text_id + ) \ No newline at end of file diff --git a/tools/temp_upload.py b/tools/temp_upload.py new file mode 100755 index 0000000..09118e7 --- /dev/null +++ b/tools/temp_upload.py @@ -0,0 +1,185 @@ +import os +import time +from flask import render_template, request, send_from_directory +from werkzeug.utils import secure_filename + +from common.logger import system_logger + + +# ========================== +# 路径配置 +# ========================== + +BASE_DIR = os.path.dirname( + os.path.dirname(os.path.abspath(__file__)) +) + +TEMP_FOLDER = os.path.join( + BASE_DIR, + "uploads", + "temp" +) + +os.makedirs(TEMP_FOLDER, exist_ok=True) + + +# ========================== +# 限制配置 +# ========================== + +MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB + +UPLOAD_LIMIT = 5 # 次数 +LIMIT_WINDOW = 60 * 60 # 10分钟 + + +UPLOAD_RECORD = {} # IP限流记录 + + +# ========================== +# 工具函数 +# ========================== + +def get_client_ip(): + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + return request.remote_addr + + +def check_rate_limit(ip): + now = time.time() + + if ip not in UPLOAD_RECORD: + UPLOAD_RECORD[ip] = [] + + # 清理过期记录 + UPLOAD_RECORD[ip] = [ + t for t in UPLOAD_RECORD[ip] + if now - t < LIMIT_WINDOW + ] + + if len(UPLOAD_RECORD[ip]) >= UPLOAD_LIMIT: + remain = int(LIMIT_WINDOW - (now - UPLOAD_RECORD[ip][0])) + + system_logger.info( + f"[TempUpload] rate limited ip={ip} remain={remain}s" + ) + + raise Exception(f"上传过于频繁,请 {remain} 秒后重试") + + UPLOAD_RECORD[ip].append(now) + + +# ========================== +# 页面主逻辑 +# ========================== + +def temp_upload_page(): + + message = None + ip = get_client_ip() + + if request.method == "POST": + + try: + + file = request.files.get("file") + + if not file or not file.filename: + + system_logger.info( + f"[TempUpload] upload rejected (no file) ip={ip}" + ) + + raise Exception("请选择文件") + + # 文件大小检测 + file.seek(0, os.SEEK_END) + file_size = file.tell() + file.seek(0) + + if file_size > MAX_FILE_SIZE: + + system_logger.info( + f"[TempUpload] file too large ip={ip} size={file_size}" + ) + + raise Exception("文件超过50MB限制") + + # IP限流 + check_rate_limit(ip) + + filename = secure_filename(file.filename) + + save_path = os.path.join( + TEMP_FOLDER, + filename + ) + + file.save(save_path) + + message = f"上传成功:{filename}" + + system_logger.info( + f"[TempUpload] upload success " + f"file={filename} ip={ip} size={file_size}" + ) + + except Exception as e: + + message = str(e) + + system_logger.info( + f"[TempUpload] upload failed ip={ip} err={e}" + ) + + # ========================== + # 文件列表 + # ========================== + + files = [] + + for filename in os.listdir(TEMP_FOLDER): + + file_path = os.path.join(TEMP_FOLDER, filename) + + if os.path.isfile(file_path): + + size = os.path.getsize(file_path) + + files.append({ + "name": filename, + "size": round(size / 1024 / 1024, 2) + }) + + files.sort(key=lambda x: x["name"], reverse=True) + + system_logger.info( + f"[TempUpload] list files ip={ip} count={len(files)}" + ) + + return render_template( + "temp_upload.html", + files=files, + message=message + ) + + +# ========================== +# 下载 +# ========================== + +def download_temp_file(filename): + + ip = get_client_ip() + + system_logger.info( + f"[TempUpload] download file={filename} ip={ip}" + ) + + return send_from_directory( + TEMP_FOLDER, + filename, + as_attachment=True + ) \ No newline at end of file diff --git a/tools/test_write.py b/tools/test_write.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/text_diff.py b/tools/text_diff.py new file mode 100644 index 0000000..8637b8d --- /dev/null +++ b/tools/text_diff.py @@ -0,0 +1,17 @@ +import difflib + +def text_diff(text1, text2): + d = difflib.HtmlDiff() + html = d.make_table(text1.splitlines(), text2.splitlines(), context=True) + return html + +def page(): + from flask import render_template, request + diff_result = None + text1 = request.args.get("text1", "").strip() + text2 = request.args.get("text2", "").strip() + + if text1 and text2: + diff_result = text_diff(text1, text2) + + return render_template("text_diff.html", diff_result=diff_result, text1=text1, text2=text2) \ No newline at end of file diff --git a/tools/url_codec.py b/tools/url_codec.py new file mode 100644 index 0000000..29e51f2 --- /dev/null +++ b/tools/url_codec.py @@ -0,0 +1,26 @@ +from urllib.parse import quote, unquote + +def encode_url(text): + return quote(text, safe='') + +def decode_url(text): + try: + return unquote(text) + except Exception: + return "解码失败" + +def page(): + from flask import render_template, request + result = None + input_text = "" + action = request.args.get("action") + text = request.args.get("text", "").strip() + + if text: + input_text = text + if action == "decode": + result = decode_url(text) + else: + result = encode_url(text) + + return render_template("url.html", result=result, input_text=input_text) \ No newline at end of file diff --git a/tools/weekly_permission.py b/tools/weekly_permission.py new file mode 100755 index 0000000..1987a79 --- /dev/null +++ b/tools/weekly_permission.py @@ -0,0 +1,236 @@ +import os +import re +from datetime import datetime + +from flask import render_template, request +from common.logger import system_logger + + +# ========================= +# 路径 +# ========================= +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +UPLOAD_FOLDER = os.path.join(BASE_DIR, "uploads") +OUTPUT_FOLDER = os.path.join(BASE_DIR, "output") + +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +os.makedirs(OUTPUT_FOLDER, exist_ok=True) + + +# ========================= +# 工具:时间 +# ========================= +def now_str(): + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +# ========================= +# 校验工号 +# ========================= +def validate_emp_id(emp_id: str): + if not emp_id: + return False, "工作证号不能为空" + + if not re.fullmatch(r"\d{8}", emp_id): + return False, "工作证号必须为8位数字(如 00313390)" + + return True, None + + +# ========================= +# SQL:授权模式(原逻辑) +# ========================= +def build_auth_sql(center_name: str, emp_id: str): + + like_center = f"%{center_name}%" + ts = now_str() + + return f"""-- ========================================= +-- 每周动态人员授权 SQL(标准版) +-- 生成时间: {ts} +-- 中心: {center_name} +-- 工号: {emp_id} +-- ========================================= + +-- ========================= +-- Step 1: 获取部门ID +-- ========================= +select f_id as department_id +from wd_department +where f_name like '{like_center}'; + +-- ========================= +-- Step 2: 查询部门下已有人员配置 +-- ========================= +select * +from wd_user +where f_department = ( + select f_id + from wd_department + where f_name like '{like_center}' +); + +-- ========================= +-- Step 3: 查询人员基础信息 +-- ========================= +select + f_user_id, + f_user_name, + t.f_phone +from t_wxdept_users t +where f_user_id = '{emp_id}'; + +-- ========================= +-- Step 4: 获取新增主键 +-- ========================= +select max(f_id) + 1 as f_id +from wd_user; + +-- ========================= +-- Step 5: 新增用户(授权核心) +-- ========================= +insert into wd_user values( + '主键', + '人员姓名', + '{emp_id}', + ( + select f_id + from wd_department + where f_name like '{like_center}' + ), + 0, + '手机号', + now() +); +commit; + +-- ========================= +-- Step 6: 更新用户信息(兜底同步) +-- ========================= +update wd_user +set + f_name = '人员姓名', + f_uid = '{emp_id}', + f_desc = '{emp_id}' +where f_id = 10182; +commit; +""" + + +# ========================= +# SQL:重复动态处理(新增核心) +# ========================= +def build_rebuild_sql(center_name: str): + + like_center = f"%{center_name}%" + ts = now_str() + + return f"""-- ========================================= +-- 重复动态处理 SQL +-- 生成时间: {ts} +-- 中心: {center_name} +-- ========================================= + +-- 1. 获取部门ID +select * from wd_department where f_name like '{like_center}'; + +-- 2. 获取最新关联动态ID +select * from wd_department where f_name like '{like_center}'; + +-- 3. 查最新通知记录(按时间) +select * +from wd_document +where f_department_id = ( + select f_id from wd_department where f_name like '{like_center}' +) +order by f_create_date desc; + +-- 4. 查重复通知ID(示例) +select * +from wd_document +where f_department_id = ( + select f_id from wd_department where f_name like '{like_center}' +) +and f_id in (44992, 44991) +order by f_create_date desc; + +-- 5. 查附件 +select * +from wd_attachment +where f_document in (44992, 44991); + +-- 6. 删除重复通知(保留最新) +delete from wd_document +where f_department_id = ( + select f_id from wd_department where f_name like '{like_center}' +) +and f_id in (44991); + +delete from wd_attachment +where f_document in (44991); + +-- 7. 重新确认 +select * from wd_document +where f_department_id = ( + select f_id from wd_department where f_name like '{like_center}' +); + +select * from wd_attachment +where f_document in (44991); +""" + + +# ========================= +# 页面入口 +# ========================= +def weekly_permission_page(): + + sql_text = None + message = None + + center_name = "" + emp_id = "" + + try: + if request.method == "POST": + + center_name = (request.form.get("center_name") or "").strip() + emp_id = (request.form.get("emp_id") or "").strip() + action = (request.form.get("action") or "auth").strip() + + # ===== 校验 ===== + if not center_name: + raise Exception("中心名称不能为空") + + ok, err = validate_emp_id(emp_id) + if not ok: + raise Exception(err) + + # ===== 分流核心 ===== + if action == "auth": + sql_text = build_auth_sql(center_name, emp_id) + message = "授权SQL生成成功" + + elif action == "rebuild": + sql_text = build_rebuild_sql(center_name) + message = "重复动态处理SQL生成成功" + + else: + raise Exception("未知操作类型") + + system_logger.info( + f"[WeeklySQL] action={action}, center={center_name}, emp_id={emp_id}" + ) + + except Exception as e: + message = f"错误:{str(e)}" + system_logger.exception("[WeeklyPermission ERROR]") + + return render_template( + "weekly_permission.html", + sql_text=sql_text, + message=message, + center_name=center_name, + emp_id=emp_id + ) \ No newline at end of file diff --git a/uwsgi.ini b/uwsgi.ini new file mode 100755 index 0000000..7cb110b --- /dev/null +++ b/uwsgi.ini @@ -0,0 +1,57 @@ +[uwsgi] +uid = uwsgi +gid = uwsgi + +# 监听地址和端口 +http-socket = 0.0.0.0:8209 + +# 虚拟环境 +virtualenv = /opt/service/python_prj/pictoHub.env + +# Flask 应用入口 +wsgi-file = /opt/service/python_prj/toolHub/app.py +callable = app + +# 静态文件 +static-map = /static=/opt/service/python_prj/toolHub/static/ + +# 日志 +logto = /var/log/uwsgi/tool-project.log + +# 进程 +processes = 4 + +# 虚拟环境路径 +home = /opt/service/python_prj/pictoHub.env + +# ========== 冷启动优化 ========== + +# worker 预加载应用,不在请求时懒加载 +lazy-apps = false + +# 单实例模式,避免重复加载 Python 解释器 +single-interpreter = true + +# 启用线程 +enable-threads = true + +# 最后一个 worker 不会在所有请求完成后退出,保持热状态 +max-worker-lifetime = 3600 +reload-mercy = 8 + +# 内存超过 256MB 时自动回收 worker +reload-on-rss = 256 +evil-reload-on-rss = 512 + +# 碰后的平滑重启 +honour-stdin = false + +# 定时触发,保持至少一个 worker 热载 +# cheaper-algo = busyness +# cheaper = 1 +# cheaper-initial = 1 +# cheaper-step = 1 +# cheaper-busy-timeout = 30 + +# 热更新 +touch-reload = /opt/service/python_prj/toolHub/app.py \ No newline at end of file