diff --git a/lawrisk/api/auth.py b/lawrisk/api/auth.py index 945ce12..19afdaf 100644 --- a/lawrisk/api/auth.py +++ b/lawrisk/api/auth.py @@ -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 diff --git a/lawrisk/services/auth_service.py b/lawrisk/services/auth_service.py index 83a6db3..431c663 100644 --- a/lawrisk/services/auth_service.py +++ b/lawrisk/services/auth_service.py @@ -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, diff --git a/static/db_admin.html b/static/db_admin.html index 10e62f7..6a71b7f 100644 --- a/static/db_admin.html +++ b/static/db_admin.html @@ -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 @@
+
@@ -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 @@ } }); + + + \ No newline at end of file diff --git a/tools/supplement_excel_with_departments.py b/tools/supplement_excel_with_departments.py new file mode 100644 index 0000000..9fee1c9 --- /dev/null +++ b/tools/supplement_excel_with_departments.py @@ -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()