Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README/开发日志.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 结果。
Expand Down
6 changes: 5 additions & 1 deletion app/auth/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down Expand Up @@ -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
Expand All @@ -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() # 先清除旧的会话数据
Expand All @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions app/auth/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 为明文密码生成不可逆哈希。"""
Expand Down
26 changes: 26 additions & 0 deletions app/static/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
41 changes: 36 additions & 5 deletions app/static/js/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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; // 如果没有当前用户,直接返回
Expand Down
36 changes: 35 additions & 1 deletion tests/unit/auth/test_auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -42,6 +42,7 @@ class FakeConnection:

def __init__(self, row):
self.fake_cursor = FakeCursor(row)
self.committed = False

def __enter__(self):
"""返回连接自身供 with 使用。"""
Expand All @@ -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):
"""验证登录和权限查询固定使用主库强一致读。"""
Expand Down Expand Up @@ -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()
69 changes: 67 additions & 2 deletions tests/unit/auth/test_stale_session_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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 必须立即失效。"""
Expand Down Expand Up @@ -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(
Expand All @@ -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):
"""管理员登录成功响应应携带实时角色,供前端进入受保护后台。"""
Expand All @@ -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),
Expand All @@ -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):
"""登录接口只回显白名单管理页面,并对管理员保留安全默认落点。"""
Expand All @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
Loading