diff --git "a/README/\345\274\200\345\217\221\346\227\245\345\277\227.md" "b/README/\345\274\200\345\217\221\346\227\245\345\277\227.md" index e18ab3b..e0537bd 100644 --- "a/README/\345\274\200\345\217\221\346\227\245\345\277\227.md" +++ "b/README/\345\274\200\345\217\221\346\227\245\345\277\227.md" @@ -516,6 +516,9 @@ - 【Quick 完整性状态标签】 - Quick 完整性状态列改用 Element Plus 标签组件,healthy 显示绿色。 - warning、error、danger 和 unknown 统一显示红色,保留后端返回的英文状态文字。 +- 【用户最后登录时间修复】 + - `POST /api/login` 在密码验证成功后通过主库更新 `users.last_login_at`,管理员用户列表继续按上海时间展示;Session 恢复和管理员进入聊天模式不会重复计为登录。 + - 最后登录时间写入失败时仍允许完成登录,响应增加稳定警告码,登录页显示一次约 3 秒后自动消失的非阻塞提示;管理员在提示结束后继续跳转后台。 - 【Job 终态与 worker 租约 BUG 修复】 - 成功终态将聊天保存、幂等标记、终态事件和 job=succeeded 收敛到同一个 MySQL 事务,提交前崩溃会整体回滚,重复终态调用不会重复保存聊天。 - worker 的事件、心跳、成功和失败更新统一校验 `job_id + worker_id + attempt_count`,旧 worker 失去租约后不能写事件、聊天或覆盖新 worker 结果。 diff --git a/app/auth/routes.py b/app/auth/routes.py index d561422..c872954 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -9,6 +9,7 @@ import logging auth_bp = Blueprint('auth', __name__, url_prefix='/api') +LAST_LOGIN_RECORD_FAILED_WARNING = 'last_login_record_failed' # 获取注册值,检查注册值 @auth_bp.route('/register', methods=['POST']) @@ -51,7 +52,7 @@ def handle_login(): 处理用户登录请求。 接收前端通过HTTPS发送的明文密码,使用bcrypt进行验证。 """ - from app.auth.service import find_user + from app.auth.service import find_user, record_successful_login data = request.json @@ -76,6 +77,7 @@ def handle_login(): stored_hashed_password = user_data["password_hash"].encode('utf-8') if bcrypt.checkpw(plain_password.encode('utf-8'), stored_hashed_password): logging.info(f"用户登录成功: {username}") + last_login_recorded = record_successful_login(user_data['id']) # 核心修改:在 Session 中存储用户信息 session.clear() # 先清除旧的会话数据 @@ -95,6 +97,8 @@ def handle_login(): 'role': user_data['role'], 'csrf_token': csrf_token, } + if not last_login_recorded: + response_payload['warning_code'] = LAST_LOGIN_RECORD_FAILED_WARNING if redirect_to is not None: response_payload['redirect_to'] = redirect_to return jsonify(response_payload) diff --git a/app/auth/service.py b/app/auth/service.py index 6a9b8b0..4fcd181 100644 --- a/app/auth/service.py +++ b/app/auth/service.py @@ -63,6 +63,31 @@ def find_user_by_id(user_id): return cursor.fetchone() +def record_successful_login(user_id: int) -> bool: + """在主库记录一次密码验证成功的登录时间,失败时返回 False 而不中断登录。""" + try: + with get_write_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + UPDATE users + SET last_login_at = UTC_TIMESTAMP() + WHERE id = %s + """, + (user_id,), + ) + conn.commit() + return True + except Exception as exc: + logging.error( + "记录用户 ID %s 的最后登录时间失败: %s", + user_id, + exc, + exc_info=True, + ) + return False + + # 哈希密码 def hash_password(password): """使用 bcrypt 为明文密码生成不可逆哈希。""" diff --git a/app/static/css/style.css b/app/static/css/style.css index 45bd4df..8b1cc12 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -566,6 +566,32 @@ min-height: 1em; /* 避免没有错误时布局跳动 */ } + .login-transient-warning { + position: fixed; + top: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 3000; + max-width: min(90vw, 520px); + padding: 12px 18px; + border: 1px solid #f0c36d; + border-radius: 8px; + background: #fff7e6; + box-shadow: 0 6px 20px rgba(79, 55, 15, 0.18); + color: #7a4b00; + font-size: 14px; + line-height: 1.5; + text-align: center; + pointer-events: none; + animation: login-warning-lifecycle 3s ease forwards; + } + + @keyframes login-warning-lifecycle { + 0% { opacity: 0; transform: translate(-50%, -8px); } + 10%, 85% { opacity: 1; transform: translate(-50%, 0); } + 100% { opacity: 0; transform: translate(-50%, -8px); } + } + /* 新增:用户信息弹窗样式 */ #userInfoPopup { diff --git a/app/static/js/script.js b/app/static/js/script.js index b83d070..0d491d2 100644 --- a/app/static/js/script.js +++ b/app/static/js/script.js @@ -17,6 +17,8 @@ const backToSettingsButton = document.getElementById('backToSettingsButton'); // const csvUploaderInput = document.getElementById('csvUploader'); // 获取CSV上传器 const uploadCsvButton = document.getElementById('uploadCsvButton'); // 获取上传按钮 const chatArea = document.getElementById('chatArea'); +const LAST_LOGIN_RECORD_FAILED_WARNING = 'last_login_record_failed'; +const LOGIN_WARNING_DURATION_MS = 3000; //全局变量存储当前会话的用户名 //一个标签页里同时并行操作多个会话的话,会造成冲突 let currentUsername = null; @@ -268,12 +270,21 @@ async function handleLogin() { // 登录成功 currentUsername = data.username; // 设置全局变量 currentUserRole = data.role || 'user'; - if (data.redirect_to) { - window.location.assign(data.redirect_to); - return; + const hasLastLoginWarning = data.warning_code === LAST_LOGIN_RECORD_FAILED_WARNING; + if (hasLastLoginWarning) { + showTransientLoginWarning('登录成功,但最后登录时间写入失败'); } - if (currentUserRole === 'admin') { - window.location.assign('/admin/database'); + const redirectTarget = data.redirect_to || ( + currentUserRole === 'admin' ? '/admin/database' : null + ); + if (redirectTarget) { + if (hasLastLoginWarning) { + window.setTimeout(() => { + window.location.assign(redirectTarget); + }, LOGIN_WARNING_DURATION_MS); + } else { + window.location.assign(redirectTarget); + } return; } clearInternalNavigationParameters(); @@ -304,6 +315,26 @@ async function handleLogin() { } // 处理退出登录 - +/** + * 显示一次登录告警,并在固定时间后自动移除,避免阻塞后续页面操作。 + */ +function showTransientLoginWarning(message) { + const existingNotice = document.getElementById('loginTransientWarning'); + if (existingNotice) { + existingNotice.remove(); + } + const notice = document.createElement('div'); + notice.id = 'loginTransientWarning'; + notice.className = 'login-transient-warning'; + notice.setAttribute('role', 'status'); + notice.setAttribute('aria-live', 'polite'); + notice.textContent = message; + document.body.appendChild(notice); + window.setTimeout(() => { + notice.remove(); + }, LOGIN_WARNING_DURATION_MS); +} + async function handleLogout() { const username = currentUsername; // 使用全局变量获取当前用户 (主要用于日志) if (!username) return; // 如果没有当前用户,直接返回 diff --git a/tests/unit/auth/test_auth_service.py b/tests/unit/auth/test_auth_service.py index 0491173..b2196a1 100644 --- a/tests/unit/auth/test_auth_service.py +++ b/tests/unit/auth/test_auth_service.py @@ -16,7 +16,7 @@ for key, value in TEST_ENV.items(): os.environ.setdefault(key, value) -from app.auth.service import find_user, find_user_by_id +from app.auth.service import find_user, find_user_by_id, record_successful_login class FakeCursor: @@ -42,6 +42,7 @@ class FakeConnection: def __init__(self, row): self.fake_cursor = FakeCursor(row) + self.committed = False def __enter__(self): """返回连接自身供 with 使用。""" @@ -56,6 +57,10 @@ def cursor(self, dictionary=False): self.dictionary = dictionary return self.fake_cursor + def commit(self): + """记录认证写入是否提交。""" + self.committed = True + class AuthServiceReadTests(unittest.TestCase): """验证登录和权限查询固定使用主库强一致读。""" @@ -94,6 +99,35 @@ def test_find_user_by_id_uses_strong_read_and_returns_role_state(self): self.assertEqual(result, row) self.assertIn("role, is_active", connection.fake_cursor.sql) + def test_record_successful_login_updates_utc_time_on_primary(self): + """密码验证成功后必须通过主库提交 UTC 最后登录时间。""" + connection = FakeConnection(None) + with patch( + "app.auth.service.get_write_connection", + return_value=connection, + ) as get_write: + result = record_successful_login(7) + + get_write.assert_called_once_with() + self.assertTrue(result) + self.assertTrue(connection.committed) + self.assertIn("SET last_login_at = UTC_TIMESTAMP()", connection.fake_cursor.sql) + self.assertEqual(connection.fake_cursor.params, (7,)) + + def test_record_successful_login_failure_is_non_blocking(self): + """最后登录时间写入异常应返回失败标记,由路由继续完成登录。""" + with ( + patch( + "app.auth.service.get_write_connection", + side_effect=RuntimeError("write failed"), + ), + patch("app.auth.service.logging.error") as log_error, + ): + result = record_successful_login(8) + + self.assertFalse(result) + log_error.assert_called_once() + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/auth/test_stale_session_guard.py b/tests/unit/auth/test_stale_session_guard.py index 1850c34..ce2fa53 100644 --- a/tests/unit/auth/test_stale_session_guard.py +++ b/tests/unit/auth/test_stale_session_guard.py @@ -2,7 +2,7 @@ import sys import types import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch from flask import Flask @@ -82,7 +82,10 @@ def test_active_user_session_does_not_cache_role(self): "role": "admin", "is_active": True, } - with patch("app.auth.session_guard.find_user_by_id", return_value=active_admin): + with ( + patch("app.auth.session_guard.find_user_by_id", return_value=active_admin), + patch("app.auth.service.record_successful_login") as record_login, + ): with app.test_client() as client: with client.session_transaction() as flask_session: flask_session["user_id"] = 3 @@ -100,6 +103,7 @@ def test_active_user_session_does_not_cache_role(self): self.assertNotIn("role", flask_session) self.assertEqual(flask_session["auth_version"], 1) self.assertEqual(flask_session["csrf_token"], payload["csrf_token"]) + record_login.assert_not_called() def test_changed_auth_version_invalidates_old_session(self): """角色、状态或密码变更递增认证版本后,旧 Cookie 必须立即失效。""" @@ -136,6 +140,7 @@ def test_disabled_user_cannot_login(self): "role": "user", "is_active": False, } + service_module.record_successful_login = Mock(return_value=True) with patch.dict(sys.modules, {"app.auth.service": service_module}): with app.test_client() as client: response = client.post( @@ -145,6 +150,7 @@ def test_disabled_user_cannot_login(self): self.assertEqual(response.status_code, 403) self.assertEqual(response.get_json(), {"success": False, "error": "账号已被禁用"}) + service_module.record_successful_login.assert_not_called() def test_admin_login_returns_role_for_frontend_routing(self): """管理员登录成功响应应携带实时角色,供前端进入受保护后台。""" @@ -157,6 +163,7 @@ def test_admin_login_returns_role_for_frontend_routing(self): "role": "admin", "is_active": True, } + service_module.record_successful_login = Mock(return_value=True) with ( patch.dict(sys.modules, {"app.auth.service": service_module}), patch("app.auth.routes.bcrypt.checkpw", return_value=True), @@ -175,6 +182,62 @@ def test_admin_login_returns_role_for_frontend_routing(self): self.assertEqual(payload["redirect_to"], "/admin/database") self.assertIsInstance(payload["csrf_token"], str) self.assertGreaterEqual(len(payload["csrf_token"]), 32) + service_module.record_successful_login.assert_called_once_with(5) + + def test_last_login_write_failure_warns_but_keeps_login_successful(self): + """最后登录时间写入失败时应返回稳定警告码并保留有效 Session。""" + app = build_app() + service_module = types.ModuleType("app.auth.service") + service_module.find_user = lambda _username: { + "id": 7, + "username": "warning-login", + "password_hash": "stored-hash", + "role": "user", + "is_active": True, + } + service_module.record_successful_login = Mock(return_value=False) + with ( + patch.dict(sys.modules, {"app.auth.service": service_module}), + patch("app.auth.routes.bcrypt.checkpw", return_value=True), + ): + with app.test_client() as client: + response = client.post( + "/api/login", + json={"username": "warning-login", "password": "secret"}, + ) + with client.session_transaction() as flask_session: + session_user_id = flask_session["user_id"] + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.get_json()["warning_code"], "last_login_record_failed") + self.assertEqual(session_user_id, 7) + service_module.record_successful_login.assert_called_once_with(7) + + def test_wrong_password_does_not_record_last_login(self): + """密码校验失败时不得更新最后登录时间或创建成功登录响应。""" + app = build_app() + service_module = types.ModuleType("app.auth.service") + service_module.find_user = lambda _username: { + "id": 10, + "username": "wrong-password", + "password_hash": "stored-hash", + "role": "user", + "is_active": True, + } + service_module.record_successful_login = Mock(return_value=True) + with ( + patch.dict(sys.modules, {"app.auth.service": service_module}), + patch("app.auth.routes.bcrypt.checkpw", return_value=False), + app.test_client() as client, + ): + response = client.post( + "/api/login", + json={"username": "wrong-password", "password": "incorrect"}, + ) + + self.assertEqual(response.status_code, 401) + self.assertNotIn("warning_code", response.get_json()) + service_module.record_successful_login.assert_not_called() def test_login_only_returns_server_validated_admin_redirect(self): """登录接口只回显白名单管理页面,并对管理员保留安全默认落点。""" @@ -187,6 +250,7 @@ def test_login_only_returns_server_validated_admin_redirect(self): "role": "admin", "is_active": True, } + service_module.record_successful_login = Mock(return_value=True) with ( patch.dict(sys.modules, {"app.auth.service": service_module}), patch("app.auth.routes.bcrypt.checkpw", return_value=True), @@ -229,6 +293,7 @@ def test_normal_user_with_admin_return_target_reaches_authorization_boundary(sel "role": "user", "is_active": True, } + service_module.record_successful_login = Mock(return_value=True) with ( patch.dict(sys.modules, {"app.auth.service": service_module}), patch("app.auth.routes.bcrypt.checkpw", return_value=True),