diff --git a/.env.prod b/.env.prod index 91a53d2..7725b39 100644 --- a/.env.prod +++ b/.env.prod @@ -6,7 +6,7 @@ DEPLOY_RUN_HOST=0.0.0.0 # 服务监听端口 -DEPLOY_RUN_PORT=5000 +DEPLOY_RUN_PORT=5002 # 数据库配置(正式环境) DB_HOST=192.168.10.250 diff --git a/app.py b/app.py index 3b7c2e0..454f8e8 100644 --- a/app.py +++ b/app.py @@ -4,7 +4,7 @@ Flask 主应用 """ import os from datetime import datetime -from flask import Flask, render_template, Response +from flask import Flask, render_template, Response, request, jsonify, g from flask_cors import CORS from apscheduler.schedulers.background import BackgroundScheduler import pytz @@ -15,6 +15,7 @@ from lib.api import register_blueprints from lib.db_init import init_database_tables from lib.logger import log_info, log_error, log_warning from lib.api.download_api import cleanup_old_temp_zips +from lib.api.auth_api import get_current_user # 创建 Flask 应用 app = Flask(__name__, @@ -36,6 +37,63 @@ else: log_error('数据库表初始化失败,请检查数据库连接', 'app') +# ==================== 登录验证中间件 ==================== + +# 不需要登录的路径白名单 +WHITELIST_PATHS = [ + '/api/auth/login', + '/api/auth/info', + '/public/', + '/static/', + '/favicon.ico', + '/' +] + + +@app.before_request +def check_login(): + """全局登录验证""" + path = request.path + + # 白名单路径直接放行 + for wp in WHITELIST_PATHS: + if path.startswith(wp): + return None + + # 页面请求(HTML)直接放行,由前端控制登录 + if path == '/' or path.endswith('.html'): + return None + + # 静态文件直接放行 + if path.startswith('/public/') or path.startswith('/static/'): + return None + + # API 请求需要验证登录 + if path.startswith('/api/'): + # 先从 header 取 token,再从 query 参数取(用于下载链接) + token = request.headers.get('Authorization', '') + if token.startswith('Bearer '): + token = token[7:] + + if not token: + token = request.args.get('token', '') + + user = None + if token: + from lib.api.auth_api import get_user_by_token + user = get_user_by_token(token) + + if not user: + return jsonify({ + 'success': False, + 'message': '未登录或登录已过期', + 'code': 401 + }), 401 + g.current_user = user + + return None + + # ==================== 页面路由 ==================== @app.route('/') diff --git a/lib/api/__init__.py b/lib/api/__init__.py index d1ee133..9f3cea5 100644 --- a/lib/api/__init__.py +++ b/lib/api/__init__.py @@ -24,6 +24,9 @@ download_bp = Blueprint('download', __name__, url_prefix='/api') # 求和数据管理蓝图 sum_data_bp = Blueprint('sum_data', __name__, url_prefix='/api') +# 认证蓝图 +from .auth_api import auth_bp + # 导入各模块的路由 from . import config_api, entity_api, field_api, report_api, init_api, download_api, sum_data_api @@ -37,3 +40,4 @@ def register_blueprints(app): app.register_blueprint(init_bp) app.register_blueprint(download_bp) app.register_blueprint(sum_data_bp) + app.register_blueprint(auth_bp) diff --git a/lib/api/auth_api.py b/lib/api/auth_api.py new file mode 100644 index 0000000..30a1966 --- /dev/null +++ b/lib/api/auth_api.py @@ -0,0 +1,221 @@ +""" +认证相关 API +登录、退出、修改密码 +""" +import hashlib +import secrets +from datetime import datetime, timedelta +from flask import Blueprint, request, jsonify, g +from lib.db import execute_query, execute_update +from lib.logger import log_info, log_error, log_warning + +auth_bp = Blueprint('auth', __name__, url_prefix='/api/auth') + + +def md5_password(password): + """MD5加密密码""" + return hashlib.md5(password.encode('utf-8')).hexdigest() + + +def generate_token(): + """生成登录令牌""" + return secrets.token_hex(32) + + +def get_user_by_token(token): + """根据token获取用户信息""" + if not token: + return None + + try: + user = execute_query( + "SELECT id, username, real_name, token_expire FROM t_daily_report_admin WHERE token = %s", + (token,) + ) + if user and user[0]: + user_info = user[0] + if user_info.get('token_expire') and user_info['token_expire'] > datetime.now(): + return user_info + except Exception as e: + log_warning(f"[认证] 获取用户信息失败: {e}", 'auth') + + return None + + +def get_current_user(): + """获取当前登录用户(从token)""" + token = request.headers.get('Authorization', '') + if token.startswith('Bearer '): + token = token[7:] + + if not token: + return None + + try: + user = execute_query( + "SELECT id, username, real_name, token_expire FROM t_daily_report_admin WHERE token = %s", + (token,) + ) + if user and user[0]: + user_info = user[0] + if user_info.get('token_expire') and user_info['token_expire'] > datetime.now(): + return user_info + except Exception as e: + log_warning(f"[认证] 获取用户信息失败: {e}", 'auth') + + return None + + +def login_required(f): + """登录验证装饰器""" + from functools import wraps + @wraps(f) + def decorated_function(*args, **kwargs): + user = get_current_user() + if not user: + return jsonify({'success': False, 'message': '未登录或登录已过期', 'code': 401}), 401 + g.current_user = user + return f(*args, **kwargs) + return decorated_function + + +@auth_bp.route('/login', methods=['POST']) +def login(): + """登录""" + try: + data = request.get_json() + username = data.get('username', '').strip() + password = data.get('password', '').strip() + + if not username or not password: + return jsonify({'success': False, 'message': '用户名和密码不能为空'}) + + # 查询用户 + users = execute_query( + "SELECT id, username, password, real_name FROM t_daily_report_admin WHERE username = %s", + (username,) + ) + + if not users or not users[0]: + log_warning(f"[登录] 用户不存在: {username}", 'auth') + return jsonify({'success': False, 'message': '用户名或密码错误'}) + + user = users[0] + + # 验证密码 + password_md5 = md5_password(password) + if user['password'] != password_md5: + log_warning(f"[登录] 密码错误: {username}", 'auth') + return jsonify({'success': False, 'message': '用户名或密码错误'}) + + # 生成token,有效期7天 + token = generate_token() + token_expire = datetime.now() + timedelta(days=7) + + # 获取客户端IP + client_ip = request.remote_addr + + # 更新用户token和登录信息 + execute_update( + """UPDATE t_daily_report_admin + SET token = %s, token_expire = %s, last_login_time = NOW(), last_login_ip = %s + WHERE id = %s""", + (token, token_expire, client_ip, user['id']) + ) + + log_info(f"[登录] 登录成功: {username}", 'auth') + + return jsonify({ + 'success': True, + 'message': '登录成功', + 'data': { + 'token': token, + 'username': user['username'], + 'real_name': user.get('real_name', ''), + 'expires_in': 7 * 24 * 3600 + } + }) + except Exception as e: + log_error(f"[登录] 异常: {e}", 'auth', exc_info=True) + return jsonify({'success': False, 'message': '登录失败: ' + str(e)}) + + +@auth_bp.route('/logout', methods=['POST']) +@login_required +def logout(): + """退出登录""" + try: + user = g.current_user + # 清除token + execute_update( + "UPDATE t_daily_report_admin SET token = NULL, token_expire = NULL WHERE id = %s", + (user['id'],) + ) + log_info(f"[退出] 用户退出登录: {user['username']}", 'auth') + return jsonify({'success': True, 'message': '退出成功'}) + except Exception as e: + log_error(f"[退出] 异常: {e}", 'auth', exc_info=True) + return jsonify({'success': False, 'message': '退出失败: ' + str(e)}) + + +@auth_bp.route('/info', methods=['GET']) +@login_required +def get_user_info(): + """获取当前用户信息""" + try: + user = g.current_user + return jsonify({ + 'success': True, + 'data': { + 'id': user['id'], + 'username': user['username'], + 'real_name': user.get('real_name', '') + } + }) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}) + + +@auth_bp.route('/change-password', methods=['POST']) +@login_required +def change_password(): + """修改密码""" + try: + data = request.get_json() + old_password = data.get('old_password', '').strip() + new_password = data.get('new_password', '').strip() + + if not old_password or not new_password: + return jsonify({'success': False, 'message': '旧密码和新密码不能为空'}) + + if len(new_password) < 6: + return jsonify({'success': False, 'message': '新密码长度不能少于6位'}) + + user = g.current_user + + # 验证旧密码 + users = execute_query( + "SELECT password FROM t_daily_report_admin WHERE id = %s", + (user['id'],) + ) + + if not users or not users[0]: + return jsonify({'success': False, 'message': '用户不存在'}) + + old_password_md5 = md5_password(old_password) + if users[0]['password'] != old_password_md5: + return jsonify({'success': False, 'message': '旧密码错误'}) + + # 更新密码 + new_password_md5 = md5_password(new_password) + execute_update( + "UPDATE t_daily_report_admin SET password = %s, token = NULL, token_expire = NULL, update_time = NOW() WHERE id = %s", + (new_password_md5, user['id']) + ) + + log_info(f"[修改密码] 用户 {user['username']} 修改密码成功", 'auth') + + return jsonify({'success': True, 'message': '密码修改成功,请重新登录'}) + except Exception as e: + log_error(f"[修改密码] 异常: {e}", 'auth', exc_info=True) + return jsonify({'success': False, 'message': '修改失败: ' + str(e)}) diff --git a/lib/db_init.py b/lib/db_init.py index 1f01cde..f62fcbd 100644 --- a/lib/db_init.py +++ b/lib/db_init.py @@ -180,6 +180,47 @@ def init_database_tables(): log_info("[数据库] 配置 sort_order 初始化完成", 'db_init') except Exception as e: log_warning(f"[数据库] 初始化 sort_order 时出错: {e}", 'db_init') + + # 创建管理员用户表 + create_admin_table = """ + CREATE TABLE IF NOT EXISTS t_daily_report_admin ( + id BIGINT NOT NULL COMMENT '用户 ID', + username VARCHAR(50) NOT NULL COMMENT '用户名', + password VARCHAR(200) NOT NULL COMMENT '密码(加密存储)', + real_name VARCHAR(50) NULL COMMENT '真实姓名', + token VARCHAR(200) NULL COMMENT '登录令牌', + token_expire DATETIME NULL COMMENT '令牌过期时间', + last_login_time DATETIME NULL COMMENT '最后登录时间', + last_login_ip VARCHAR(50) NULL COMMENT '最后登录IP', + create_time DATETIME NULL COMMENT '创建时间', + update_time DATETIME NULL COMMENT '更新时间' + ) + UNIQUE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES("replication_num" = "1") + """ + execute_update(create_admin_table) + log_info("[数据库] t_daily_report_admin 表已就绪", 'db_init') + + # 初始化默认管理员账号 + try: + admin_count = execute_query( + "SELECT COUNT(*) as cnt FROM t_daily_report_admin WHERE username = 'admin'" + ) + if not admin_count or admin_count[0]['cnt'] == 0: + log_info("[数据库] 初始化默认管理员账号 admin/123586", 'db_init') + import hashlib + default_password = hashlib.md5('123586'.encode('utf-8')).hexdigest() + admin_id = int(__import__('datetime').datetime.now().timestamp() * 1000) + execute_update( + """INSERT INTO t_daily_report_admin + (id, username, password, real_name, create_time, update_time) + VALUES (%s, %s, %s, %s, NOW(), NOW())""", + (admin_id, 'admin', default_password, '管理员') + ) + log_info("[数据库] 默认管理员账号创建成功", 'db_init') + except Exception as e: + log_warning(f"[数据库] 初始化默认管理员账号时出错: {e}", 'db_init') log_info("[数据库] 初始化完成", 'db_init') return True diff --git a/public/static/css/style.css b/public/static/css/style.css index f637879..e300b55 100644 --- a/public/static/css/style.css +++ b/public/static/css/style.css @@ -867,6 +867,117 @@ color: #666; opacity: 0.5; } +/* ========== 登录页面 ========== */ +.login-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.login-box { + background: white; + padding: 40px; + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + width: 400px; + max-width: 90%; +} + +.login-box h2 { + text-align: center; + color: #333; + margin-bottom: 8px; + font-size: 24px; +} + +.login-subtitle { + text-align: center; + color: #888; + margin-bottom: 30px; + font-size: 14px; +} + +.login-box .form-group { + margin-bottom: 20px; +} + +.login-box label { + display: block; + margin-bottom: 8px; + color: #555; + font-weight: 500; +} + +.login-box input { + width: 100%; + padding: 12px 15px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 14px; + box-sizing: border-box; + transition: border-color 0.3s; +} + +.login-box input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.btn-block { + width: 100%; + padding: 12px; + font-size: 16px; + margin-top: 10px; +} + +.login-tip { + text-align: center; + color: #aaa; + font-size: 12px; + margin-top: 20px; +} + +/* 顶部栏 */ +.header-bar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.user-info { + display: flex; + align-items: center; + gap: 15px; + font-size: 14px; + color: #555; +} + +.user-info span { + font-weight: 500; +} + +.btn-link { + background: none; + border: none; + color: var(--primary); + cursor: pointer; + padding: 4px 8px; + font-size: 14px; +} + +.btn-link:hover { + text-decoration: underline; +} + .page-btn.active { background: var(--primary); color: white; diff --git a/public/static/js/common.js b/public/static/js/common.js index 66d3152..70df0ed 100644 --- a/public/static/js/common.js +++ b/public/static/js/common.js @@ -16,6 +16,199 @@ let entityPageSize = 20; let selectedEntityIds = new Set(); let fieldCustomNames = {}; +// ============== 登录相关 ============== +const AUTH_TOKEN_KEY = 'daily_report_auth_token'; +const AUTH_USER_KEY = 'daily_report_auth_user'; + +// 获取token +function getToken() { + return localStorage.getItem(AUTH_TOKEN_KEY) || ''; +} + +// 保存登录状态 +function setAuth(token, user) { + localStorage.setItem(AUTH_TOKEN_KEY, token); + localStorage.setItem(AUTH_USER_KEY, JSON.stringify(user)); +} + +// 清除登录状态 +function clearAuth() { + localStorage.removeItem(AUTH_TOKEN_KEY); + localStorage.removeItem(AUTH_USER_KEY); +} + +// 检查是否已登录 +function isLoggedIn() { + return !!getToken(); +} + +// 获取当前用户 +function getCurrentUser() { + const userStr = localStorage.getItem(AUTH_USER_KEY); + return userStr ? JSON.parse(userStr) : null; +} + +// 全局拦截 fetch,自动加 token +const originalFetch = window.fetch; +window.fetch = function(url, options = {}) { + const token = getToken(); + if (token) { + options.headers = options.headers || {}; + options.headers['Authorization'] = 'Bearer ' + token; + } + + return originalFetch(url, options).then(response => { + if (response.status === 401) { + clearAuth(); + showLoginPage(); + } + return response; + }); +}; + +// 显示登录页 +function showLoginPage() { + document.getElementById('login-overlay').style.display = 'flex'; + document.getElementById('main-container').style.display = 'none'; + document.getElementById('login-password').value = ''; + document.getElementById('login-password').focus(); +} + +// 隐藏登录页 +function hideLoginPage() { + document.getElementById('login-overlay').style.display = 'none'; + document.getElementById('main-container').style.display = 'block'; + + const user = getCurrentUser(); + if (user) { + document.getElementById('user-display').textContent = user.real_name || user.username; + } +} + +// 登录 +async function handleLogin(event) { + event.preventDefault(); + + const username = document.getElementById('login-username').value.trim(); + const password = document.getElementById('login-password').value.trim(); + + if (!username || !password) { + alert('请输入用户名和密码'); + return; + } + + const btn = document.getElementById('login-btn'); + btn.disabled = true; + btn.textContent = '登录中...'; + + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + + const result = await response.json(); + + if (result.success) { + setAuth(result.data.token, { + username: result.data.username, + real_name: result.data.real_name + }); + hideLoginPage(); + // 登录成功后加载数据 + loadConfigs(); + } else { + alert(result.message || '登录失败'); + } + } catch (error) { + alert('登录失败: ' + error.message); + } finally { + btn.disabled = false; + btn.textContent = '登 录'; + } +} + +// 退出登录 +async function handleLogout() { + if (!confirm('确定要退出登录吗?')) return; + + try { + await fetch('/api/auth/logout', { method: 'POST' }); + } catch (e) { + // 忽略错误 + } + + clearAuth(); + showLoginPage(); +} + +// 修改密码弹窗 +function showChangePasswordModal() { + document.getElementById('old-password').value = ''; + document.getElementById('new-password').value = ''; + document.getElementById('confirm-password').value = ''; + document.getElementById('change-password-modal').style.display = 'block'; +} + +function closeChangePasswordModal() { + document.getElementById('change-password-modal').style.display = 'none'; +} + +async function saveChangePassword() { + const oldPassword = document.getElementById('old-password').value.trim(); + const newPassword = document.getElementById('new-password').value.trim(); + const confirmPassword = document.getElementById('confirm-password').value.trim(); + + if (!oldPassword || !newPassword || !confirmPassword) { + alert('请填写完整信息'); + return; + } + + if (newPassword.length < 6) { + alert('新密码长度不能少于6位'); + return; + } + + if (newPassword !== confirmPassword) { + alert('两次输入的新密码不一致'); + return; + } + + try { + const response = await fetch('/api/auth/change-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + old_password: oldPassword, + new_password: newPassword + }) + }); + + const result = await response.json(); + + if (result.success) { + alert('密码修改成功,请重新登录'); + closeChangePasswordModal(); + clearAuth(); + showLoginPage(); + } else { + alert(result.message || '修改失败'); + } + } catch (error) { + alert('修改失败: ' + error.message); + } +} + +// 页面加载时检查登录状态 +document.addEventListener('DOMContentLoaded', function() { + if (isLoggedIn()) { + hideLoginPage(); + } else { + showLoginPage(); + } +}); + // 标签切换 function switchTab(tabName) { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); diff --git a/public/static/js/history.js b/public/static/js/history.js index 8ede910..1d24078 100644 --- a/public/static/js/history.js +++ b/public/static/js/history.js @@ -209,7 +209,7 @@ function renderHistory(list) {