Update auth flows and admin tools

This commit is contained in:
Codex Agent 2026-03-19 15:14:42 +08:00
parent d97c7ca086
commit 26a7b531f3
4 changed files with 657 additions and 1 deletions

View File

@ -16,7 +16,11 @@ from flask import (
url_for,
)
from lawrisk.services.auth_service import get_user_by_username, verify_password
from lawrisk.services.auth_service import (
get_user_by_username,
update_user_account,
verify_password,
)
auth_bp = Blueprint("auth", __name__, url_prefix="/fs-ai-asistant/api/workflow/lawrisk")
@ -233,3 +237,103 @@ def current_user_endpoint() -> Response:
if not user:
return jsonify({"authenticated": False}), 401
return jsonify({"authenticated": True, "user": user})
def _validate_password_complexity(password: str) -> Optional[str]:
"""验证密码复杂度最小8位必须包含字母和数字"""
if len(password) < 8:
return "密码长度至少为 8 位"
if not (any(c.isalpha() for c in password) and any(c.isdigit() for c in password)):
return "密码必须同时包含字母和数字"
return None
@auth_bp.post("/v2/auth/change-password")
@login_required
def change_password_endpoint() -> Response:
"""Allow authenticated users to change their own password."""
try:
payload = request.get_json(silent=True) or {}
current_password = (payload.get("current_password", "") if payload else "").strip()
new_password = (payload.get("new_password", "") if payload else "").strip()
# Validate required fields
if not current_password:
return jsonify({"error": "请输入当前密码"}), 400
if not new_password:
return jsonify({"error": "请输入新密码"}), 400
# Get current user from session
user = get_current_user()
if not user:
return jsonify({"error": "用户未登录"}), 401
print(f"DEBUG: Session user = {user}")
# Get complete user data including password hash
username = user.get("username")
if not username:
return jsonify({"error": "无效的用户信息"}), 400
# 使用 get_user_by_username 获取完整用户数据(包括密码哈希)
user_data = get_user_by_username(username)
if not user_data:
return jsonify({"error": "用户不存在"}), 404
# Debug logging
print(f"DEBUG: username = {username}")
print(f"DEBUG: password_hash exists = {'password_hash' in user_data}")
print(f"DEBUG: password_hash length = {len(user_data.get('password_hash', ''))}")
print(f"DEBUG: current_password provided = {bool(current_password)}")
# Verify current password
password_hash = user_data.get("password_hash", "")
if not password_hash:
print("ERROR: password_hash is empty!")
return jsonify({"error": "系统错误:无法获取密码哈希"}), 500
if not verify_password(current_password, password_hash):
print("ERROR: Password verification failed!")
print(f"DEBUG: Hash preview: {password_hash[:20]}..." if len(password_hash) > 20 else f"DEBUG: Hash: {password_hash}")
return jsonify({"error": "当前密码错误"}), 401
print("DEBUG: Password verified successfully!")
# Get user_id for update operation
user_id = user_data.get("id")
if not user_id:
return jsonify({"error": "无法获取用户ID"}), 400
# Validate new password complexity
complexity_error = _validate_password_complexity(new_password)
if complexity_error:
return jsonify({"error": complexity_error}), 400
# Check if new password is same as current
if verify_password(new_password, user_data.get("password_hash", "")):
return jsonify({"error": "新密码不能与当前密码相同"}), 400
# Update password
print(f"DEBUG: Attempting to update password for user_id = {user_id}")
update_result = update_user_account(
user_id=user_id,
password=new_password,
)
print(f"DEBUG: Update result = {update_result}")
if not update_result:
print("ERROR: Password update returned None/False")
return jsonify({"error": "密码修改失败,请稍后重试"}), 500
# Note: update_user_account already logs the operation internally
print("DEBUG: Password change completed successfully")
return jsonify({"message": "密码修改成功"}), 200
except Exception as e:
# Log full error details
import traceback
print(f"ERROR: Exception in change_password_endpoint")
print(f"ERROR: Type: {type(e).__name__}")
print(f"ERROR: Message: {str(e)}")
print(f"ERROR: Traceback:\n{traceback.format_exc()}")
return jsonify({"error": "密码修改失败,请稍后重试"}), 500

View File

@ -363,6 +363,7 @@ def get_user_by_id(user_id: str) -> Optional[Dict[str, Any]]:
au.role,
au.grade,
au.is_active,
au.password_hash,
au.service_department_id,
au.department_role,
au.created_at,

View File

@ -1089,6 +1089,30 @@
background: #5568d3;
}
.btn-change-password {
background: white;
color: #374151;
padding: 6px 16px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.btn-change-password:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
background: #f9fafb;
border-color: #9ca3af;
}
.btn-change-password:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
background: #e2e8f0;
color: #334155;
@ -1215,6 +1239,32 @@
animation: modalFadeIn 0.3s;
}
.password-strength-indicator {
margin-top: 8px;
font-size: 12px;
color: #6b7280;
}
.password-strength-indicator.weak { color: #ef4444; }
.password-strength-indicator.medium { color: #f59e0b; }
.password-strength-indicator.strong { color: #10b981; }
.password-error {
color: #ef4444;
font-size: 12px;
margin-top: 5px;
display: none;
}
.password-error.show {
display: block;
}
input.error {
border-color: #ef4444 !important;
background-color: #fef2f2;
}
.import-modal-content {
width: 1200px;
max-width: 98vw;
@ -2453,6 +2503,7 @@
<div class="user-alert" id="userStatus"></div>
</div>
<div class="user-actions">
<button type="button" class="btn-change-password" id="changePasswordBtn">修改密码</button>
<button type="button" class="btn-logout" id="logoutBtn">退出登录</button>
</div>
</div>
@ -3005,6 +3056,267 @@
});
}
// 密码修改相关
function openPasswordModal() {
const passwordModal = document.getElementById('passwordModal');
if (!passwordModal) return;
passwordModal.classList.add('show');
document.body.style.overflow = 'hidden';
document.getElementById('passwordChangeForm').reset();
clearPasswordErrors();
updatePasswordStrength('');
// 绑定模态框内的事件
bindPasswordModalEvents();
setTimeout(() => document.getElementById('currentPassword').focus(), 100);
}
function closePasswordModal() {
const passwordModal = document.getElementById('passwordModal');
if (!passwordModal) return;
passwordModal.classList.remove('show');
document.body.style.overflow = '';
document.getElementById('passwordChangeForm').reset();
clearPasswordErrors();
updatePasswordStrength('');
}
function clearPasswordErrors() {
document.querySelectorAll('.password-error').forEach(el => {
el.textContent = '';
el.classList.remove('show');
});
// 清除输入框的错误样式
document.querySelectorAll('#passwordChangeForm input').forEach(input => {
input.classList.remove('error');
});
}
function updatePasswordStrength(password) {
const indicator = document.getElementById('passwordStrength');
if (!password) {
indicator.textContent = '';
indicator.className = 'password-strength-indicator';
return;
}
let strength = 0;
if (password.length >= 8) strength++;
if (password.length >= 12) strength++;
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
if (/\d/.test(password)) strength++;
if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) strength++;
if (strength <= 2) {
indicator.textContent = '密码强度:弱';
indicator.className = 'password-strength-indicator weak';
} else if (strength <= 3) {
indicator.textContent = '密码强度:中';
indicator.className = 'password-strength-indicator medium';
} else {
indicator.textContent = '密码强度:强';
indicator.className = 'password-strength-indicator strong';
}
}
function validatePasswordForm() {
console.log('=== validatePasswordForm called ===');
let isValid = true;
const errors = []; // 收集所有错误消息
clearPasswordErrors();
const currentPassword = document.getElementById('currentPassword').value.trim();
const newPassword = document.getElementById('newPassword').value.trim();
const confirmPassword = document.getElementById('confirmPassword').value.trim();
console.log('currentPassword:', currentPassword ? '***' : '(empty)');
console.log('newPassword:', newPassword ? '***' : '(empty)');
console.log('confirmPassword:', confirmPassword ? '***' : '(empty)');
if (!currentPassword) {
console.log('Validation failed: current password is empty');
showError('currentPasswordError', '请输入当前密码');
errors.push('请输入当前密码');
isValid = false;
}
if (!newPassword) {
console.log('Validation failed: new password is empty');
showError('newPasswordError', '请输入新密码');
errors.push('请输入新密码');
isValid = false;
} else if (newPassword.length < 8) {
console.log('Validation failed: new password too short');
showError('newPasswordError', '新密码长度至少为 8 位');
errors.push('新密码长度至少为 8 位');
isValid = false;
} else if (!/(?=.*[a-zA-Z])(?=.*\d)/.test(newPassword)) {
console.log('Validation failed: new password complexity');
showError('newPasswordError', '新密码必须同时包含字母和数字');
errors.push('新密码必须同时包含字母和数字');
isValid = false;
} else if (newPassword === currentPassword) {
console.log('Validation failed: passwords are the same');
showError('newPasswordError', '新密码不能与当前密码相同');
errors.push('新密码不能与当前密码相同');
isValid = false;
}
if (!confirmPassword) {
console.log('Validation failed: confirm password is empty');
showError('confirmPasswordError', '请确认新密码');
errors.push('请确认新密码');
isValid = false;
} else if (newPassword !== confirmPassword) {
console.log('Validation failed: passwords do not match');
showError('confirmPasswordError', '两次输入的密码不一致');
errors.push('两次输入的密码不一致');
isValid = false;
}
console.log('Validation result:', isValid);
console.log('Errors collected:', errors);
// 如果有错误,在控制台和 alert 中显示
if (!isValid && errors.length > 0) {
const errorMessage = errors.join('\n• ');
console.log('Showing error alert:', errorMessage);
// 延迟一点显示 alert确保 DOM 更新
setTimeout(() => {
alert('❌ 表单验证失败:\n\n• ' + errorMessage);
}, 100);
}
console.log('=== validatePasswordForm end ===');
return isValid;
}
function showError(elementId, message) {
console.log(`showError called: ${elementId} = "${message}"`);
const el = document.getElementById(elementId);
if (!el) {
console.error(`Element not found: ${elementId}`);
alert(`错误:${message}`); // 降级到 alert
return;
}
el.textContent = message;
el.classList.add('show');
// 给对应的输入框添加错误样式
const inputId = elementId.replace('Error', '');
const inputEl = document.getElementById(inputId);
if (inputEl) {
inputEl.classList.add('error');
}
console.log(`Error shown successfully for ${elementId}`);
}
async function handleSubmitPasswordChange() {
console.log('handleSubmitPasswordChange called');
if (!validatePasswordForm()) {
console.log('Form validation failed');
// validatePasswordForm 已经显示了详细的错误消息
return;
}
console.log('Form validation passed');
const currentPassword = document.getElementById('currentPassword').value.trim();
const newPassword = document.getElementById('newPassword').value.trim();
const submitPasswordChange = document.getElementById('submitPasswordChange');
if (!submitPasswordChange) {
console.error('Submit button not found');
return;
}
console.log('Starting password change request...');
submitPasswordChange.disabled = true;
submitPasswordChange.textContent = '提交中...';
try {
const response = await fetch('/fs-ai-asistant/api/workflow/lawrisk/v2/auth/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword
})
});
console.log('Response status:', response.status);
const data = await response.json();
console.log('Response data:', data);
if (response.ok) {
alert('✅ 密码修改成功!');
closePasswordModal();
} else {
alert(`❌ 密码修改失败:${data.error || '未知错误'}`);
}
} catch (error) {
console.error('Password change error:', error);
alert('❌ 密码修改失败,请稍后重试');
} finally {
submitPasswordChange.disabled = false;
submitPasswordChange.textContent = '确认修改';
}
}
// 事件绑定
const changePasswordBtn = document.getElementById('changePasswordBtn');
console.log('changePasswordBtn:', changePasswordBtn);
if (changePasswordBtn) {
changePasswordBtn.addEventListener('click', function(e) {
console.log('changePasswordBtn clicked');
openPasswordModal();
});
}
// 延迟绑定模态框内的事件(因为模态框在页面底部)
function bindPasswordModalEvents() {
const submitPasswordChange = document.getElementById('submitPasswordChange');
console.log('bindPasswordModalEvents - submitPasswordChange:', submitPasswordChange);
if (submitPasswordChange && !submitPasswordChange.hasAttribute('data-bound')) {
submitPasswordChange.addEventListener('click', function(e) {
console.log('submitPasswordChange clicked');
e.preventDefault();
e.stopPropagation();
handleSubmitPasswordChange();
});
submitPasswordChange.setAttribute('data-bound', 'true');
console.log('submitPasswordChange event bound');
}
}
document.getElementById('newPassword')?.addEventListener('input', (e) => {
updatePasswordStrength(e.target.value);
});
document.addEventListener('click', (e) => {
const passwordModal = document.getElementById('passwordModal');
if (passwordModal && e.target === passwordModal) {
closePasswordModal();
}
});
document.addEventListener('keydown', (e) => {
const passwordModal = document.getElementById('passwordModal');
if (e.key === 'Escape' && passwordModal && passwordModal.classList.contains('show')) {
closePasswordModal();
}
});
// 导航状态管理
let currentStep = 1; // 1=区划, 2=事项, 3=详情
let historyStack = []; // 历史记录栈
@ -8118,6 +8430,40 @@
}
});
</script>
<!-- 密码修改模态框 -->
<div class="modal" id="passwordModal">
<div class="modal-content" style="max-width: 450px;">
<div class="modal-header" style="text-align: left; padding: 20px; border-bottom: 1px solid #e5e7eb; margin: 0;">
<h3 style="color: #1f2937; font-size: 18px; margin: 0;">修改密码</h3>
<button type="button" onclick="closePasswordModal()" style="position: absolute; top: 20px; right: 20px; background: none; border: none; font-size: 24px; cursor: pointer; color: #6b7280;"></button>
</div>
<div class="modal-body" style="padding: 30px;">
<form id="passwordChangeForm">
<div style="margin-bottom: 20px;">
<label style="display: block; margin-bottom: 8px; color: #374151; font-weight: 500; font-size: 14px;">当前密码 *</label>
<input type="password" id="currentPassword" name="currentPassword" required style="width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px;">
<div id="currentPasswordError" class="password-error"></div>
</div>
<div style="margin-bottom: 20px;">
<label style="display: block; margin-bottom: 8px; color: #374151; font-weight: 500; font-size: 14px;">新密码 *</label>
<input type="password" id="newPassword" name="newPassword" required style="width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px;">
<div id="passwordStrength" class="password-strength-indicator"></div>
<div id="newPasswordError" class="password-error"></div>
</div>
<div style="margin-bottom: 20px;">
<label style="display: block; margin-bottom: 8px; color: #374151; font-weight: 500; font-size: 14px;">确认新密码 *</label>
<input type="password" id="confirmPassword" name="confirmPassword" required style="width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px;">
<div id="confirmPasswordError" class="password-error"></div>
</div>
</form>
</div>
<div class="modal-footer" style="padding: 20px; border-top: 1px solid #e5e7eb; display: flex; gap: 10px; justify-content: flex-end;">
<button type="button" class="btn btn-secondary" onclick="closePasswordModal()">取消</button>
<button type="button" class="btn btn-primary" id="submitPasswordChange">确认修改</button>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,205 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Excel服务部门层级信息补充脚本
功能
1. 读取Excel文件为每条记录添加"绑定服务部门""服务部门所属部门"两列
2. 根据层级规则和区名关键词自动判断上级部门
3. 生成统计报告并保存补充后的Excel文件
"""
import pandas as pd
import sys
from pathlib import Path
from collections import Counter
# 区名关键词映射表
REGION_KEYWORDS = {
'禅城区': '禅城区服务部门',
'南海区': '南海区服务部门',
'顺德区': '顺德区服务部门',
'三水区': '三水区服务部门',
'高明区': '高明区服务部门'
}
MUNICIPAL_DEPT = '市级服务部门'
def extract_region_service_dept(unit_name):
"""
从单位名称中提取区级服务部门
Args:
unit_name: 单位名称字符串
Returns:
对应的区级服务部门名称如果未找到则返回None
"""
if pd.isna(unit_name):
return None
unit_name_str = str(unit_name)
# 按优先级顺序匹配区名关键词
for keyword, service_dept in REGION_KEYWORDS.items():
if keyword in unit_name_str:
return service_dept
return None
def determine_parent_department(unit_name, level_value):
"""
确定服务部门所属部门
规则
1. 如果层级值包含"市级" 市级服务部门
2. 如果单位名称包含区名关键词 对应区级服务部门
3. 否则默认为市级服务部门兜底策略
Args:
unit_name: 单位名称
level_value: 层级/风险提示/备注列的值
Returns:
服务部门所属部门名称
"""
# 规则1: 检查层级值是否包含"市级"
if pd.notna(level_value) and '市级' in str(level_value):
return MUNICIPAL_DEPT
# 规则2: 从单位名称中提取区级服务部门
region_dept = extract_region_service_dept(unit_name)
if region_dept:
return region_dept
# 规则3: 兜底策略 - 默认为市级服务部门
return MUNICIPAL_DEPT
def supplement_excel_file(input_path, output_path):
"""
补充Excel文件的部门层级信息
Args:
input_path: 输入Excel文件路径
output_path: 输出Excel文件路径
"""
print(f"[INFO] Reading Excel file: {input_path}")
# 读取Excel文件
try:
df = pd.read_excel(input_path)
print(f"[OK] Successfully read {len(df)} records")
except Exception as e:
print(f"[ERROR] Failed to read Excel file: {e}")
sys.exit(1)
# 显示原始列名
print(f"\n[INFO] Original columns ({len(df.columns)} columns):")
for i, col in enumerate(df.columns):
print(f" {i+1}. {col}")
# 添加"绑定服务部门"列(直接复制"单位名称"列)
# 假设第二列是"单位名称"
unit_name_col = df.columns[1] # 第二列
df['绑定服务部门'] = df[unit_name_col]
# 添加"服务部门所属部门"列(根据规则计算)
level_col = df.columns[4] # "层级/风险提示/备注"列第5列
df['服务部门所属部门'] = df.apply(
lambda row: determine_parent_department(row[unit_name_col], row[level_col]),
axis=1
)
print(f"\n[OK] Added 2 columns:")
print(f" - Binding Service Department (copied from '{unit_name_col}')")
print(f" - Parent Service Department (calculated based on hierarchy rules)")
# 生成统计报告
print(f"\n[STATS] Parent Service Department Distribution:")
parent_dept_counts = Counter(df['服务部门所属部门'])
# 按标准顺序显示
standard_order = [MUNICIPAL_DEPT] + list(REGION_KEYWORDS.values())
for dept in standard_order:
count = parent_dept_counts.get(dept, 0)
percentage = (count / len(df) * 100) if len(df) > 0 else 0
print(f" {dept}: {count} 条 ({percentage:.1f}%)")
# 检查无法匹配的记录
unmapped_records = df[
(~df[level_col].astype(str).str.contains('市级', na=False)) &
(~df[unit_name_col].astype(str).str.contains('|'.join(REGION_KEYWORDS.keys()), na=False))
]
if len(unmapped_records) > 0:
print(f"\n[WARNING] Found {len(unmapped_records)} records that could not be matched by region keywords, assigned to Municipal Service Department by default:")
print(" Sample records:")
for idx, row in unmapped_records.head(5).iterrows():
print(f" - {row[unit_name_col]} (层级: {row[level_col]})")
if len(unmapped_records) > 5:
print(f" ... 还有 {len(unmapped_records) - 5}")
# 保存补充后的Excel文件
print(f"\n[SAVE] Saving supplemented Excel file: {output_path}")
try:
df.to_excel(output_path, index=False, engine='openpyxl')
print(f"[OK] Successfully saved {len(df)} records to {output_path}")
except Exception as e:
print(f"[ERROR] Failed to save Excel file: {e}")
sys.exit(1)
# 验证数据完整性
print(f"\n[VERIFY] Data Integrity Validation:")
null_binding = df['绑定服务部门'].isna().sum()
null_parent = df['服务部门所属部门'].isna().sum()
print(f" Total records: {len(df)}")
print(f" Null values in Binding Service Department: {null_binding}")
print(f" Null values in Parent Service Department: {null_parent}")
print(f" Total columns: {len(df.columns)}")
if null_binding == 0 and null_parent == 0:
print(f"[OK] Data integrity validation passed")
else:
print(f"[WARNING] Found null values, please check data source")
# 显示示例数据
print(f"\n[DATA] Sample data (first 5 rows):")
sample_cols = [unit_name_col, level_col, '绑定服务部门', '服务部门所属部门']
print(df[sample_cols].head(10).to_string(index=False))
return df
def main():
"""主函数"""
# 定义文件路径
base_path = Path(__file__).parent.parent
input_file = base_path / 'data' / '法律风险提示管理员名单_合并-20260309.xlsx'
output_file = base_path / 'data' / '法律风险提示管理员名单_合并-20260309_supplemented.xlsx'
print("=" * 80)
print("Excel服务部门层级信息补充工具")
print("=" * 80)
# 检查输入文件是否存在
if not input_file.exists():
print(f"[ERROR] Input file does not exist: {input_file}")
sys.exit(1)
# 执行补充处理
df = supplement_excel_file(str(input_file), str(output_file))
print("\n" + "=" * 80)
print("[OK] Processing completed!")
print(f"[FILE] Output file: {output_file}")
print("=" * 80)
if __name__ == '__main__':
main()