提交 6a43d4b2 authored 作者: 陈泽健's avatar 陈泽健

feat: 新增问题排查助手Web服务,支持AI智能分析和登录认证

新增功能:
- 问题排查Web服务(Flask后端 + AI分析)
- 用户登录认证系统(Session + 权限管理)
- 缓存管理功能(统计 + 清除)
- 排查结果导出为Word文档
- 流式返回优化(SSE)

技术实现:
- auth.py: 用户认证模块,支持密码加密和防暴力破解
- decorators.py: 权限验证装饰器(登录/管理员)
- server.py: Flask后端服务,提供RESTful API
- search_engine.py: TF-IDF搜索引擎
- safety_filter.py: 三层安全过滤器

用户管理:
- 管理员账号:admin / Admin@2026
- 普通用户账号:user / User@2026
- 权限分级管理(管理员可访问缓存管理)

部署信息:
- 服务地址:http://192.168.5.60:8088
- 依赖包:Flask, werkzeug, python-docx
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 36ded115
...@@ -589,6 +589,20 @@ systemctl list-unit-files | grep enabled # 开机自启服务 ...@@ -589,6 +589,20 @@ systemctl list-unit-files | grep enabled # 开机自启服务
- **回归**:复测结果 - **回归**:复测结果
- 若是新出现的、可能复现的问题 → 写一条记忆,避免下次重复排查 - 若是新出现的、可能复现的问题 → 写一条记忆,避免下次重复排查
## 问题追踪文档(必须执行)
**每次处理问题都必须更新对应的问题追踪文档**,无论问题是否已完全解决:
- **文档命名规范**:`Docs/PRD/<项目名>/_ISSUE_<IP或标识>_问题追踪.md`
例如:`Docs/PRD/达梦数据库/_ISSUE_192.168.5.40_问题追踪.md`
- **文档内容至少包含**:
1. **已完成修复** — 已解决的问题列表,每项含问题描述、根因、修复方案、状态
2. **待修复问题** — 尚未解决的问题,含阻塞原因和依赖
3. **环境信息** — 服务器 IP、容器、数据库凭据等
4. **执行记录** — 每次操作的时间、操作内容、结果(用表格记录)
5. **下一步操作** — 明确的后续步骤和 SQL/命令
- **更新时机**:每次执行排查/修复操作后**立即更新**,不等到全部完成
## 关联记忆 ## 关联记忆
- `server-976-ssh-admin` — 9.76 用 admin+sudo(root 被禁) - `server-976-ssh-admin` — 9.76 用 admin+sudo(root 被禁)
...@@ -605,3 +619,73 @@ systemctl list-unit-files | grep enabled # 开机自启服务 ...@@ -605,3 +619,73 @@ systemctl list-unit-files | grep enabled # 开机自启服务
- `server-546-emqx-acl-authorization-check` — 5.46 mqtt@cmdb 能订阅 `/ideatop/#` 系 acl.conf 第36行已授权,排查:查 authorization.sources → acl.conf → 规则匹配顺序 → 超级用户绕过 ACL - `server-546-emqx-acl-authorization-check` — 5.46 mqtt@cmdb 能订阅 `/ideatop/#` 系 acl.conf 第36行已授权,排查:查 authorization.sources → acl.conf → 规则匹配顺序 → 超级用户绕过 ACL
- `server-546-emqx-superuser-bypass-acl` — 5.46 mqtt@cmdb 能订阅无规则的 `/yunweitest` 系 `is_superuser=TRUE` 绕过 ACL,`no_match=allow` 默认放行,排查:查 bootstrap 文件 is_superuser 字段 - `server-546-emqx-superuser-bypass-acl` — 5.46 mqtt@cmdb 能订阅无规则的 `/yunweitest` 系 `is_superuser=TRUE` 绕过 ACL,`no_match=allow` 默认放行,排查:查 bootstrap 文件 is_superuser 字段
- `config-duplicate-key-overrides` — 改配置项不生效:同文件存在两个相同 key,后一个覆盖前一个;改了前面没改后面=改了等于没改。铁律:改前 `grep -n` 全文确认只出现一次,重复则删多余项或全部改齐 - `config-duplicate-key-overrides` — 改配置项不生效:同文件存在两个相同 key,后一个覆盖前一个;改了前面没改后面=改了等于没改。铁律:改前 `grep -n` 全文确认只出现一次,重复则删多余项或全部改齐
## 关联文件
- `code/Troubleshoot助手.html` — 交互式问题排查知识库网页工具,可独立在浏览器中打开使用。与 SKILL.md 内容同源(基于 346 条历史问题记录提炼),覆盖 18 类问题分类,支持选分类→填现象→输出排查思路+典型根因+处置方案
- `code/web/` — **Web 排查助手**(新一代):
- `server.py` — Flask 后端服务,渐进式两阶段请求(搜索 + AI 分析),默认端口 8088
- `search_engine.py` — TF-IDF + 关键词匹配搜索引擎,基于 357 条历史问题记录
- `safety_filter.py` — 三层安全过滤器(危险命令黑名单 + 相关性检查 + 白名单验证)
- `templates/index.html` — 前端页面,支持项目名称输入 + 系统类型选择 + APK 产品输入 + 问题提交入库
- `config.json` — 配置文件(Claude API 地址/密钥/模型、系统类型列表)
- `code/xlsx_to_md.py` — Excel→Markdown 知识库转换脚本
- `code/build_index.py` — 搜索索引生成脚本
- `code/start.bat` — Windows 启动脚本
## 后续开发规范
后续如有开发需求或问题处理需求,应在 `Docs/PRD/问题知识库/` 目录下创建对应的文档:
- **需求文档**:`Docs/PRD/问题知识库/PRD_需求文档_<功能名>.md` — 描述功能需求、背景、验收标准
- **计划执行文档**:`Docs/PRD/问题知识库/PRD_计划执行_<功能名>.md` — 描述实施步骤、技术方案、执行记录
所有与 Troubleshoot 技能相关的代码实现应放在 `code/` 目录下。
## Web 排查助手(开发完成 2026-07-11)
Web 排查助手是一个基于 Flask 的 Web 应用,为现场同事提供智能问题排查服务。
### 启动方式
```bash
cd .claude/skills/Troubleshoot/code/web
# 依赖安装(首次)
pip install -r ../requirements.txt
# 启动服务
python server.py
# 访问:http://localhost:8088
# Windows 启动脚本:双击 ../start.bat
```
### 功能架构
```
用户输入(项目名称 + 系统类型 + APK产品 + 问题描述)
阶段一:搜索引擎匹配相似案例(< 3 秒返回)
阶段二:AI 深度分析生成排查步骤(20-30 秒)
安全过滤器(三层过滤确保输出安全)
展示排查结果 + 问题入库闭环
```
### API 接口
| 接口 | 方法 | 说明 |
|------|------|------|
| `/` | GET | 主页面 |
| `/api/projects` | GET | 获取项目列表 |
| `/api/search` | POST | 仅搜索匹配案例(阶段一,快速) |
| `/api/analyze` | POST | AI 深度分析(阶段二,较慢) |
| `/api/troubleshoot` | POST | 一键排查(搜索+分析,兼容旧接口) |
| `/api/submit` | POST | 提交问题记录入库 |
### 配置说明
编辑 `code/web/config.json`
- `claude_api_base`:Claude API 地址
- `claude_api_key`:API 密钥(必填,否则使用 mock 模式)
- `claude_model`:模型名称
# -*- coding: utf-8 -*-
"""
build_index.py — 问题知识库索引生成工具
功能:
扫描问题记录 Markdown 文件,生成:
1. 索引.md — 可读的分类索引
2. 搜索索引.json — 供 search_engine.py 使用的结构化索引
用法:
python build_index.py
"""
import os
import re
import json
from pathlib import Path
from datetime import datetime
from collections import defaultdict
# ============================================================
# 配置
# ============================================================
SCRIPT_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = SCRIPT_DIR.parent.parent.parent.parent
DATA_DIR = PROJECT_ROOT / "Docs" / "PRD" / "问题知识库"
RECORDS_DIR = DATA_DIR / "问题记录"
INDEX_FILE = DATA_DIR / "索引.md"
SEARCH_INDEX_FILE = DATA_DIR / "搜索索引.json"
def parse_frontmatter(content):
"""解析 Markdown 文件的 frontmatter"""
fm = {}
match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
if not match:
return fm
fm_text = match.group(1)
for line in fm_text.split('\n'):
if ':' not in line:
continue
key, value = line.split(':', 1)
key = key.strip()
value = value.strip()
# 处理列表格式 [a, b, c]
if value.startswith('[') and value.endswith(']'):
items = value[1:-1].split(',')
fm[key] = [i.strip().strip('"\'') for i in items if i.strip()]
# 处理字符串格式 "xxx"
elif value.startswith('"') and value.endswith('"'):
fm[key] = value[1:-1]
elif value.startswith("'") and value.endswith("'"):
fm[key] = value[1:-1]
else:
fm[key] = value
return fm
def extract_content(content):
"""提取 Markdown 正文内容(去除 frontmatter)"""
# 去除 frontmatter
content = re.sub(r'^---\n.*?\n---\n*', '', content, flags=re.DOTALL)
return content.strip()
def scan_records():
"""扫描所有问题记录文件"""
records = []
for subdir in ['日常', '项目', '归档']:
dir_path = RECORDS_DIR / subdir
if not dir_path.exists():
continue
for md_file in dir_path.glob('*.md'):
try:
content = md_file.read_text(encoding='utf-8')
fm = parse_frontmatter(content)
body = extract_content(content)
# 提取现象(第一个 # 标题后的内容)
phenomenon = ""
phen_match = re.search(r'^#\s+(.+)$', body, re.MULTILINE)
if phen_match:
phenomenon = phen_match.group(1)
# 构建 full_text 用于搜索
full_text = body.lower()
record = {
'id': fm.get('id', ''),
'file': f"问题记录/{subdir}/{md_file.name}",
'title': phenomenon or md_file.stem,
'phenomenon': phenomenon,
'project': fm.get('project', ''),
'source': subdir,
'status': fm.get('status', '未知'),
'category': fm.get('category', []),
'keywords': fm.get('keywords', []),
'recorder': fm.get('recorder', ''),
'responsible': fm.get('responsible', ''),
'date': fm.get('date', ''),
'full_text': full_text,
}
records.append(record)
except Exception as e:
print(f"⚠️ 解析文件失败:{md_file.name} - {e}")
return records
def build_search_index(records):
"""构建搜索索引(JSON 格式)"""
# 按分类建立索引
categories = defaultdict(list)
for i, r in enumerate(records):
for cat in r.get('category', []):
categories[cat].append(i)
# 按项目建立索引
projects = defaultdict(list)
for i, r in enumerate(records):
proj = r.get('project', '')
if proj:
projects[proj].append(i)
# 按状态建立索引
statuses = defaultdict(list)
for i, r in enumerate(records):
status = r.get('status', '未知')
statuses[status].append(i)
# 提取所有项目名称(用于前端下拉建议)
project_names = sorted(set(r.get('project', '') for r in records if r.get('project')))
index = {
'last_update': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'total': len(records),
'records': records,
'categories': dict(categories),
'projects': dict(projects),
'project_names': project_names,
'statuses': dict(statuses),
'stats': {
'已解决': len(statuses.get('已解决', [])),
'未解决': len(statuses.get('未解决', [])),
'未知': len(statuses.get('未知', [])),
}
}
return index
def build_markdown_index(records):
"""生成可读的 Markdown 索引"""
lines = []
lines.append("# 问题知识库索引")
lines.append("")
lines.append(f"> 最后更新:{datetime.now().strftime('%Y-%m-%d %H:%M')}")
lines.append(f"> 总记录数:{len(records)} 条")
lines.append("")
# 统计
stats = defaultdict(int)
for r in records:
stats[r.get('status', '未知')] += 1
lines.append(f"> 已解决:{stats.get('已解决', 0)} 条 | 未解决:{stats.get('未解决', 0)} 条 | 未知:{stats.get('未知', 0)} 条")
lines.append("")
lines.append("---")
lines.append("")
# 按分类浏览
lines.append("## 按分类浏览")
lines.append("")
category_records = defaultdict(list)
for r in records:
for cat in r.get('category', []):
category_records[cat].append(r)
for cat in sorted(category_records.keys()):
recs = category_records[cat]
lines.append(f"### {cat} ({len(recs)} 条)")
for r in recs[:10]: # 每类最多显示 10 条
status_icon = "✅" if r.get('status') == '已解决' else "⏳"
proj = f"[{r.get('project')}]" if r.get('project') else ""
lines.append(f"- {status_icon} [{proj}{r.get('title', '未知')}](./{r.get('file')})")
if len(recs) > 10:
lines.append(f" - ... 还有 {len(recs) - 10} 条")
lines.append("")
# 按项目浏览
lines.append("## 按项目浏览")
lines.append("")
project_records = defaultdict(list)
for r in records:
proj = r.get('project', '')
if proj:
project_records[proj].append(r)
for proj in sorted(project_records.keys()):
recs = project_records[proj]
lines.append(f"### {proj} ({len(recs)} 条)")
for r in recs[:10]:
status_icon = "✅" if r.get('status') == '已解决' else "⏳"
lines.append(f"- {status_icon} [{r.get('title', '未知')}](./{r.get('file')})")
if len(recs) > 10:
lines.append(f" - ... 还有 {len(recs) - 10} 条")
lines.append("")
# 未解决问题列表
unresolved = [r for r in records if r.get('status') not in ('已解决', '已关闭')]
if unresolved:
lines.append("## 未解决问题")
lines.append("")
for r in unresolved[:20]:
proj = f"[{r.get('project')}] " if r.get('project') else ""
lines.append(f"- [ ] {proj}{r.get('title', '未知')}")
if len(unresolved) > 20:
lines.append(f"- ... 还有 {len(unresolved) - 20} 条未解决")
lines.append("")
return "\n".join(lines)
def main():
"""主函数"""
if hasattr(__import__('sys').stdout, 'reconfigure'):
__import__('sys').stdout.reconfigure(encoding='utf-8')
print("📂 扫描问题记录...")
records = scan_records()
print(f" 找到 {len(records)} 条记录")
if not records:
print("❌ 未找到任何记录")
return
# 生成搜索索引
print("🔍 生成搜索索引...")
search_index = build_search_index(records)
SEARCH_INDEX_FILE.write_text(json.dumps(search_index, ensure_ascii=False, indent=2), encoding='utf-8')
print(f" ✅ {SEARCH_INDEX_FILE}")
# 生成 Markdown 索引
print("📝 生成 Markdown 索引...")
md_index = build_markdown_index(records)
INDEX_FILE.write_text(md_index, encoding='utf-8')
print(f" ✅ {INDEX_FILE}")
# 输出统计
print(f"\n📊 统计:")
print(f" 总记录:{len(records)} 条")
print(f" 分类数:{len(search_index['categories'])} 个")
print(f" 项目数:{len(search_index['project_names'])} 个")
print(f" 已解决:{search_index['stats']['已解决']} 条")
print(f" 未解决:{search_index['stats']['未解决']} 条")
print(f"\n🎉 索引生成完成!")
if __name__ == "__main__":
main()
\ No newline at end of file
# Troubleshoot Web 应用依赖
flask>=2.3.0
openpyxl>=3.1.0
jieba>=0.42.1
scikit-learn>=1.3.0
python-dateutil>=2.8.0
requests>=2.31.0
python-docx>=0.8.11
werkzeug>=2.3.0
\ No newline at end of file
@echo off
chcp 65001 >nul
cd /d "%~dp0code\web"
echo ==================================================
echo 问题排查助手 Web 服务
echo ==================================================
echo 访问地址: http://localhost:8088
echo 按 Ctrl+C 停止服务
echo ==================================================
python server.py
pause
# 更新脚本 - 问题排查助手 Web 服务
# 用于将本地更新同步到 192.168.5.60 服务器
param(
[string]$Server = "192.168.5.60",
[string]$RemotePath = "C:\troubleshoot",
[string]$LocalPath = "E:\github\ubains-module-test\develop\.claude\skills\Troubleshoot"
)
Write-Host "=================================================="
Write-Host "更新问题排查助手 Web 服务"
Write-Host "目标服务器: $Server"
Write-Host "目标路径: $RemotePath"
Write-Host "=================================================="
# 检查网络连通性
Write-Host "`n[1/5] 检查网络连通性..."
if (-not (Test-Connection -ComputerName $Server -Count 1 -Quiet)) {
Write-Host "错误: 无法连接到服务器 $Server"
exit 1
}
Write-Host "OK: 服务器可达"
# 检查远程服务是否运行
Write-Host "`n[2/5] 检查服务状态..."
try {
$response = Invoke-WebRequest -Uri "http://$Server`:8088/" -UseBasicParsing -TimeoutSec 5
if ($response.StatusCode -eq 200) {
Write-Host "OK: Web 服务正在运行"
}
} catch {
Write-Host "警告: 无法访问 Web 服务"
}
# 使用 net use 连接(如果需要)
Write-Host "`n[3/5] 准备文件传输..."
Write-Host "请确保你有服务器 $Server 的管理员权限"
# 需要更新的文件列表
$filesToUpdate = @(
"code\web\server.py",
"code\web\search_engine.py",
"code\web\templates\index.html",
"code\web\cache_manager.py",
"code\requirements.txt",
"SKILL.md"
)
Write-Host "`n[4/5] 需要更新的文件:"
foreach ($file in $filesToUpdate) {
$localFile = Join-Path $LocalPath $file
if (Test-Path $localFile) {
Write-Host " - $file (存在)"
} else {
Write-Host " - $file (不存在!)"
}
}
Write-Host "`n[5/5] 请手动执行以下步骤:"
Write-Host ""
Write-Host "方法一: 使用远程桌面"
Write-Host " 1. 远程桌面连接到 $Server"
Write-Host " 2. 停止 Web 服务(关闭 Python 进程或 Ctrl+C)"
Write-Host " 3. 复制以下文件到 $RemotePath:"
foreach ($file in $filesToUpdate) {
Write-Host " - $file"
}
Write-Host " 4. 重启服务: cd $RemotePath\code\web && python server.py"
Write-Host ""
Write-Host "方法二: 使用 SMB 共享"
Write-Host " 1. 打开 \\$Server\c$\troubleshoot\"
Write-Host " 2. 复制文件"
Write-Host " 3. 通过远程桌面或 taskkill 重启服务"
Write-Host ""
Write-Host "方法三: 使用 pscp/scp(如果已安装)"
Write-Host " pscp -r code\web\server.py admin@${Server}:$RemotePath/code/web/"
Write-Host " pscp -r code\web\search_engine.py admin@${Server}:$RemotePath/code/web/"
Write-Host " pscp SKILL.md admin@${Server}:$RemotePath/"
Write-Host ""
Write-Host "=================================================="
Write-Host "文件准备完毕,请选择一种方法进行更新"
Write-Host "=================================================="
\ No newline at end of file
# -*- coding: utf-8 -*-
"""
上传登录认证功能到 192.168.5.60 服务器
"""
import paramiko
import os
import time
# 服务器配置
HOST = '192.168.5.60'
USER = 'ubains'
PASSWORD = 'Ubains@123'
REMOTE_BASE = '/opt/troubleshoot'
# 本地路径
LOCAL_BASE = r'E:\github\ubains-module-test\develop\.claude\skills\Troubleshoot\code'
# 需要上传的文件
FILES_TO_UPLOAD = [
('web/auth.py', 'web/auth.py'),
('web/decorators.py', 'web/decorators.py'),
('web/server.py', 'web/server.py'),
('web/templates/login.html', 'web/templates/login.html'),
('web/templates/index.html', 'web/templates/index.html'),
('requirements.txt', 'requirements.txt'),
]
def upload_files():
"""上传文件到服务器"""
print("="*60)
print("上传登录认证功能到服务器 192.168.5.60")
print("="*60)
# 连接 SSH
print("\n[1/6] 连接 SSH...")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
ssh.connect(HOST, username=USER, password=PASSWORD)
print("OK: SSH 连接成功")
# 备份原文件
print("\n[2/6] 备份原文件...")
timestamp = time.strftime('%Y%m%d_%H%M%S')
files_to_backup = ['web/server.py', 'web/templates/index.html']
for file_path in files_to_backup:
stdin, stdout, stderr = ssh.exec_command(f'cd {REMOTE_BASE} && cp {file_path} {file_path}.bak.{timestamp}')
stdout.read()
print(f"OK: 已备份到 *.bak.{timestamp}")
# 安装依赖
print("\n[3/6] 安装依赖包...")
stdin, stdout, stderr = ssh.exec_command(f'cd {REMOTE_BASE}/code && pip install werkzeug>=2.3.0')
stdout.read()
print("OK: werkzeug 已安装")
# 上传新文件
print("\n[4/6] 上传新文件...")
sftp = ssh.open_sftp()
for local_rel, remote_rel in FILES_TO_UPLOAD:
local_path = os.path.join(LOCAL_BASE, local_rel)
remote_path = f"{REMOTE_BASE}/{remote_rel}"
print(f" 上传: {local_rel}")
try:
sftp.put(local_path, remote_path)
print(f" OK: 已上传")
except Exception as e:
print(f" 错误: {e}")
sftp.close()
# 重启服务
print("\n[5/6] 重启服务...")
stdin, stdout, stderr = ssh.exec_command('pkill -f "python.*server.py"')
stdout.read()
time.sleep(2)
stdin, stdout, stderr = ssh.exec_command(f'cd {REMOTE_BASE}/web && nohup python server.py > /tmp/troubleshoot.log 2>&1 &')
stdout.read()
time.sleep(3)
print("OK: 服务已重启")
# 验证服务
print("\n[6/6] 验证服务...")
stdin, stdout, stderr = ssh.exec_command('curl -s -I http://localhost:8088/login | head -n 1')
result = stdout.read().decode()
if '200 OK' in result or '302' in result:
print("OK: 登录页面可访问")
else:
print(f"警告: 登录页面访问异常 - {result}")
# 检查主页是否重定向到登录页
stdin, stdout, stderr = ssh.exec_command('curl -s -I http://localhost:8088/ | head -n 1')
result = stdout.read().decode()
if '302' in result:
print("OK: 未登录访问主页正确重定向")
else:
print(f"提示: 主页响应 - {result}")
print("\n" + "="*60)
print("部署完成!")
print(f"访问地址: http://192.168.5.60:8088")
print("默认账号: admin / Admin@2026")
print("="*60)
except Exception as e:
print(f"错误: {e}")
return False
finally:
ssh.close()
return True
if __name__ == '__main__':
upload_files()
\ No newline at end of file
# -*- coding: utf-8 -*-
"""
上传更新到 192.168.5.60 服务器
"""
import paramiko
import os
import time
# 服务器配置
HOST = '192.168.5.60'
USER = 'ubains'
PASSWORD = 'Ubains@123'
REMOTE_BASE = '/opt/troubleshoot'
# 本地路径
LOCAL_BASE = r'E:\github\ubains-module-test\develop\.claude\skills\Troubleshoot\code'
# 需要上传的文件
FILES_TO_UPLOAD = [
('web/server.py', 'web/server.py'),
('web/templates/index.html', 'web/templates/index.html'),
('requirements.txt', 'requirements.txt'),
]
def upload_files():
"""上传文件到服务器"""
print("="*60)
print("上传更新到服务器 192.168.5.60")
print("="*60)
# 连接 SSH
print("\n[1/5] 连接 SSH...")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
ssh.connect(HOST, username=USER, password=PASSWORD)
print("OK: SSH 连接成功")
# 备份原文件
print("\n[2/5] 备份原文件...")
timestamp = time.strftime('%Y%m%d_%H%M%S')
stdin, stdout, stderr = ssh.exec_command(f'cd {REMOTE_BASE} && cp web/server.py web/server.py.bak.{timestamp}')
stdout.read()
stdin, stdout, stderr = ssh.exec_command(f'cd {REMOTE_BASE} && cp web/templates/index.html web/templates/index.html.bak.{timestamp}')
stdout.read()
print(f"OK: 已备份到 *.bak.{timestamp}")
# 上传新文件
print("\n[3/5] 上传新文件...")
sftp = ssh.open_sftp()
for local_rel, remote_rel in FILES_TO_UPLOAD:
local_path = os.path.join(LOCAL_BASE, local_rel)
remote_path = f"{REMOTE_BASE}/{remote_rel}"
print(f" 上传: {local_rel}")
try:
sftp.put(local_path, remote_path)
print(f" OK: 已上传")
except Exception as e:
print(f" 错误: {e}")
sftp.close()
# 重启服务
print("\n[4/5] 重启服务...")
stdin, stdout, stderr = ssh.exec_command('pkill -f "python.*server.py"')
stdout.read()
time.sleep(1)
stdin, stdout, stderr = ssh.exec_command(f'cd {REMOTE_BASE}/web && nohup python server.py > /tmp/troubleshoot.log 2>&1 &')
stdout.read()
time.sleep(2)
print("OK: 服务已重启")
# 验证服务
print("\n[5/5] 验证服务...")
stdin, stdout, stderr = ssh.exec_command('curl -s http://localhost:8088/api/projects')
result = stdout.read().decode()
if '"success":true' in result:
print("OK: 服务正常运行")
print(f" 响应预览: {result[:100]}...")
else:
print("警告: 服务可能未正常启动")
print(f" 响应: {result}")
print("\n" + "="*60)
print("更新完成!")
print(f"访问地址: http://192.168.5.60:8088")
print("="*60)
except Exception as e:
print(f"错误: {e}")
return False
finally:
ssh.close()
return True
if __name__ == '__main__':
upload_files()
\ No newline at end of file
# -*- coding: utf-8 -*-
"""
上传 users.json 到服务器
"""
import paramiko
import time
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('192.168.5.60', username='ubains', password='Ubains@123')
# 上传 users.json
print('上传 users.json...')
sftp = ssh.open_sftp()
sftp.put(r'E:\github\ubains-module-test\develop\.claude\skills\Troubleshoot\code\web\users.json', '/opt/troubleshoot/web/users.json')
sftp.close()
print('OK: users.json 已上传')
# 重启服务
print('重启服务...')
stdin, stdout, stderr = ssh.exec_command('pkill -f "python.*server.py"')
stdout.read()
time.sleep(2)
stdin, stdout, stderr = ssh.exec_command('cd /opt/troubleshoot/web && nohup python server.py > /tmp/troubleshoot.log 2>&1 &')
stdout.read()
time.sleep(3)
print('OK: 服务已重启')
ssh.close()
print('\n完成!')
print('新增普通用户账号:')
print(' 用户名:user')
print(' 密码:User@2026')
print(' 角色:普通用户')
\ No newline at end of file
# -*- coding: utf-8 -*-
"""
auth.py — 用户认证模块
功能:
- 用户登录验证
- 密码加密与验证
- Session 管理
- 用户数据读写
- 防暴力破解保护
"""
import os
import json
import time
from pathlib import Path
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
# 用户数据文件路径
USERS_FILE = Path(__file__).parent / "users.json"
# 登录失败记录(防暴力破解)
login_attempts = {} # {"username": {"count": 0, "lock_until": 0}}
class UserManager:
"""用户管理器"""
def __init__(self):
self.users = self._load_users()
def _load_users(self):
"""加载用户数据"""
if not USERS_FILE.exists():
# 创建默认管理员账号
default_users = {
"users": [{
"id": 1,
"username": "admin",
"password_hash": generate_password_hash("Admin@2026"),
"role": "admin",
"created_at": datetime.now().isoformat(),
"last_login": None,
"login_count": 0,
"active": True
}]
}
self._save_users(default_users)
return default_users
with open(USERS_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
def _save_users(self, data):
"""保存用户数据"""
with open(USERS_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def authenticate(self, username, password):
"""
验证用户登录
Returns:
成功:用户字典
失败:None
"""
# 检查账号是否被锁定
if self._is_locked(username):
return None
# 查找用户
user = None
for u in self.users.get('users', []):
if u['username'] == username:
user = u
break
if not user:
self._record_failed_login(username)
return None
# 检查账号是否激活
if not user.get('active', True):
return None
# 验证密码
if check_password_hash(user['password_hash'], password):
# 登录成功,清除失败记录
if username in login_attempts:
del login_attempts[username]
# 更新登录信息
user['last_login'] = datetime.now().isoformat()
user['login_count'] = user.get('login_count', 0) + 1
self._save_users(self.users)
return user
else:
# 登录失败,记录
self._record_failed_login(username)
return None
def _is_locked(self, username):
"""检查账号是否被锁定"""
if username not in login_attempts:
return False
attempts = login_attempts[username]
if attempts['count'] >= 5:
# 检查锁定是否过期(15分钟)
if time.time() < attempts['lock_until']:
return True
else:
# 锁定过期,清除记录
del login_attempts[username]
return False
return False
def _record_failed_login(self, username):
"""记录登录失败"""
if username not in login_attempts:
login_attempts[username] = {'count': 0, 'lock_until': 0}
login_attempts[username]['count'] += 1
# 失败5次后锁定15分钟
if login_attempts[username]['count'] >= 5:
login_attempts[username]['lock_until'] = time.time() + 900 # 15分钟
def get_user(self, username):
"""获取用户信息"""
for u in self.users.get('users', []):
if u['username'] == username:
return u
return None
def get_user_by_id(self, user_id):
"""通过ID获取用户"""
for u in self.users.get('users', []):
if u['id'] == user_id:
return u
return None
def add_user(self, username, password, role='user'):
"""添加新用户"""
# 检查用户名是否已存在
if self.get_user(username):
return False
new_user = {
"id": len(self.users['users']) + 1,
"username": username,
"password_hash": generate_password_hash(password),
"role": role,
"created_at": datetime.now().isoformat(),
"last_login": None,
"login_count": 0,
"active": True
}
self.users['users'].append(new_user)
self._save_users(self.users)
return True
def update_password(self, username, new_password):
"""修改密码"""
user = self.get_user(username)
if not user:
return False
user['password_hash'] = generate_password_hash(new_password)
self._save_users(self.users)
return True
def toggle_user_active(self, username, active):
"""启用/禁用用户"""
user = self.get_user(username)
if not user:
return False
user['active'] = active
self._save_users(self.users)
return True
def get_all_users(self):
"""获取所有用户列表"""
return self.users.get('users', [])
# 全局用户管理器实例
user_manager = UserManager()
# -*- coding: utf-8 -*-
"""
cache_manager.py — 问题排查助手缓存管理模块
功能:
- 基于 JSON 文件的缓存存储
- 支持缓存过期检查
- 支持缓存清理
- 支持缓存统计
用法:
from cache_manager import CacheManager
cache = CacheManager(cache_dir='./cache', expire_hours=24)
# 获取缓存
cached = cache.get(project_name, system_type, apk_product, query)
# 设置缓存
cache.set(project_name, system_type, apk_product, query, response, matched_cases)
# 清理过期缓存
cache.clear_expired()
"""
import json
import hashlib
import time
import os
from pathlib import Path
from datetime import datetime
class CacheManager:
"""问题排查结果缓存管理器"""
def __init__(self, cache_dir, expire_hours=24, max_size_mb=100):
"""
初始化缓存管理器。
参数:
cache_dir: 缓存目录
expire_hours: 缓存过期时间(小时)
max_size_mb: 最大缓存大小(MB),超过时自动清理最旧的缓存
"""
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.expire_seconds = expire_hours * 3600
self.max_size_bytes = max_size_mb * 1024 * 1024
def _get_cache_key(self, project_name, system_type, apk_product, query):
"""生成缓存键(MD5 哈希)"""
content = f"{project_name}|{system_type}|{apk_product}|{query}"
return hashlib.md5(content.encode('utf-8')).hexdigest()
def get(self, project_name, system_type, apk_product, query):
"""
获取缓存结果。
返回:
缓存数据字典,或 None(缓存不存在或已过期)
"""
key = self._get_cache_key(project_name, system_type, apk_product, query)
cache_file = self.cache_dir / f"{key}.json"
if not cache_file.exists():
return None
try:
with open(cache_file, 'r', encoding='utf-8') as f:
data = json.load(f)
# 检查过期
if time.time() - data.get('timestamp', 0) > self.expire_seconds:
cache_file.unlink()
return None
return data
except Exception as e:
print(f"[缓存] 读取缓存失败: {e}")
return None
def set(self, project_name, system_type, apk_product, query, response, matched_cases):
"""
设置缓存。
参数:
project_name: 项目名称
system_type: 系统类型
apk_product: APK 产品
query: 问题描述
response: AI 响应内容
matched_cases: 匹配案例列表
"""
key = self._get_cache_key(project_name, system_type, apk_product, query)
cache_file = self.cache_dir / f"{key}.json"
data = {
'timestamp': time.time(),
'datetime': datetime.now().isoformat(),
'project_name': project_name,
'system_type': system_type,
'apk_product': apk_product,
'query': query,
'response': response,
'matched_cases': matched_cases,
}
try:
with open(cache_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"[缓存] 已缓存: {key[:8]}...")
except Exception as e:
print(f"[缓存] 写入缓存失败: {e}")
def clear_expired(self):
"""清理过期缓存"""
cleared = 0
for cache_file in self.cache_dir.glob("*.json"):
try:
with open(cache_file, 'r', encoding='utf-8') as f:
data = json.load(f)
if time.time() - data.get('timestamp', 0) > self.expire_seconds:
cache_file.unlink()
cleared += 1
except:
pass
if cleared > 0:
print(f"[缓存] 已清理 {cleared} 个过期缓存")
return cleared
def clear_all(self):
"""清空所有缓存"""
cleared = 0
for cache_file in self.cache_dir.glob("*.json"):
cache_file.unlink()
cleared += 1
print(f"[缓存] 已清空 {cleared} 个缓存")
return cleared
def get_stats(self):
"""获取缓存统计信息"""
total_files = 0
total_size = 0
oldest_time = None
newest_time = None
for cache_file in self.cache_dir.glob("*.json"):
total_files += 1
total_size += cache_file.stat().st_size
try:
with open(cache_file, 'r', encoding='utf-8') as f:
data = json.load(f)
ts = data.get('timestamp', 0)
if oldest_time is None or ts < oldest_time:
oldest_time = ts
if newest_time is None or ts > newest_time:
newest_time = ts
except:
pass
return {
'total_files': total_files,
'total_size_mb': round(total_size / (1024 * 1024), 2),
'oldest': datetime.fromtimestamp(oldest_time).isoformat() if oldest_time else None,
'newest': datetime.fromtimestamp(newest_time).isoformat() if newest_time else None,
}
def check_and_clean_if_needed(self):
"""检查缓存大小,超过限制时清理最旧的缓存"""
total_size = sum(f.stat().st_size for f in self.cache_dir.glob("*.json"))
if total_size > self.max_size_bytes:
print(f"[缓存] 缓存大小 {total_size / (1024*1024):.2f}MB 超过限制,清理最旧的缓存...")
# 获取所有缓存文件及其时间戳
cache_files = []
for cache_file in self.cache_dir.glob("*.json"):
try:
with open(cache_file, 'r', encoding='utf-8') as f:
data = json.load(f)
cache_files.append((cache_file, data.get('timestamp', 0)))
except:
cache_files.append((cache_file, 0))
# 按时间戳排序,删除最旧的 20%
cache_files.sort(key=lambda x: x[1])
delete_count = max(1, len(cache_files) // 5)
for cache_file, _ in cache_files[:delete_count]:
cache_file.unlink()
print(f"[缓存] 已清理 {delete_count} 个最旧缓存")
# 模块级单例
_cache_manager = None
def get_cache_manager():
"""获取缓存管理器单例"""
global _cache_manager
if _cache_manager is None:
from pathlib import Path
cache_dir = Path(__file__).resolve().parent / "cache"
_cache_manager = CacheManager(cache_dir, expire_hours=24)
return _cache_manager
if __name__ == "__main__":
# 测试
cache = CacheManager("./test_cache", expire_hours=1)
# 测试设置缓存
cache.set("厦门银行", "标准版预定2.0", "门口屏5.0", "MQTT连接失败",
"这是测试响应", [{"rank": 1, "score": 0.9}])
# 测试获取缓存
result = cache.get("厦门银行", "标准版预定2.0", "门口屏5.0", "MQTT连接失败")
print(f"缓存命中: {result is not None}")
# 测试统计
stats = cache.get_stats()
print(f"缓存统计: {stats}")
# 清理测试缓存
cache.clear_all()
{
"claude_api_base": "https://office.ubainsyun.com:8400",
"claude_api_key": "在这里填写你的API密钥",
"models": [
{
"id": "glm-4-flash",
"name": "快速模式",
"description": "5-10秒,适合快速排查"
},
{
"id": "glm-5.1",
"name": "标准模式",
"description": "20-50秒,推荐默认"
},
{
"id": "glm-4-plus",
"name": "深度模式",
"description": "30-60秒,复杂问题"
}
],
"default_model": "glm-5.1",
"system_types": [
{"value": "std20", "label": "标准版预定2.0"},
{"value": "ops", "label": "标准版运维集控系统"},
{"value": "new_unified", "label": "新统一平台"},
{"value": "unified", "label": "统一平台"}
]
}
\ No newline at end of file
# -*- coding: utf-8 -*-
"""
decorators.py — 权限验证装饰器
功能:
- 登录验证装饰器
- 角色权限验证装饰器
"""
from functools import wraps
from flask import session, jsonify, request, redirect, url_for
def login_required(f):
"""登录验证装饰器"""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user' not in session:
return jsonify({
'success': False,
'error': '未登录',
'code': 401
}), 401
return f(*args, **kwargs)
return decorated_function
def admin_required(f):
"""管理员权限装饰器"""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user' not in session:
return jsonify({
'success': False,
'error': '未登录',
'code': 401
}), 401
user = session.get('user', {})
if user.get('role') != 'admin':
return jsonify({
'success': False,
'error': '权限不足,仅管理员可访问',
'code': 403
}), 403
return f(*args, **kwargs)
return decorated_function
def page_login_required(f):
"""
页面级登录验证装饰器(用于页面路由)
未登录时重定向到登录页,而不是返回 JSON
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user' not in session:
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated_function
\ No newline at end of file
此差异已折叠。
# -*- coding: utf-8 -*-
"""
search_engine.py — 问题知识库搜索引擎
功能:
基于关键词匹配 + TF-IDF 相似度,从问题知识库中检索最相关的历史案例。
用法:
from search_engine import SearchEngine
engine = SearchEngine()
results = engine.search("门口屏 MQTT 连接失败", top_k=5)
"""
import os
import re
import json
import math
from pathlib import Path
from collections import defaultdict, Counter
# ============================================================
# 配置
# ============================================================
SCRIPT_DIR = Path(__file__).resolve().parent # .../web
DATA_DIR = SCRIPT_DIR.parent # 部署时即 /opt/troubleshoot
# 搜索索引路径(按优先级查找:部署环境 → 开发环境)
SEARCH_INDEX_PATHS = [
DATA_DIR / "搜索索引.json", # 部署环境(/opt/troubleshoot/)
Path("E:/github/ubains-module-test/develop/Docs/PRD/问题知识库/搜索索引.json"), # 开发环境绝对路径
SCRIPT_DIR.parent.parent.parent.parent / "Docs" / "PRD" / "问题知识库" / "搜索索引.json", # 相对路径
]
def find_search_index():
"""按优先级查找搜索索引文件"""
for path in SEARCH_INDEX_PATHS:
if path.exists():
print(f"[搜索引擎] 找到索引文件:{path}")
return path
raise FileNotFoundError(f"搜索索引不存在,已查找路径:{SEARCH_INDEX_PATHS}")
# 停用词(过滤无意义的词)
STOPWORDS = {
'的', '了', '是', '在', '有', '我', '不', '和', '与', '或',
'这个', '那个', '怎么', '为什么', '哪里', '什么', '可以',
'应该', '需要', '请', '帮', '一下', '吗', '呢', '啊', '吧',
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'to', 'of', 'in',
'and', 'or', 'not', 'for', 'on', 'at', 'by', 'be', 'it', 'its',
'as', 'if', 'then', 'than', 'that', 'this', 'these', 'those',
}
def load_search_index():
"""加载搜索索引"""
index_file = find_search_index()
with open(index_file, 'r', encoding='utf-8') as f:
return json.load(f)
def tokenize(text):
"""中文分词(简单实现:按字切分 + 关键词提取)"""
# 提取中文词组(2-4字)
cn_words = re.findall(r'[一-龥]{2,4}', text)
# 提取英文单词
en_words = re.findall(r'[a-zA-Z]{2,}', text.lower())
# 提取数字
numbers = re.findall(r'\d+', text)
tokens = cn_words + en_words + numbers
# 过滤停用词
tokens = [t for t in tokens if t not in STOPWORDS]
return tokens
def compute_tf(tokens):
"""计算词频 (TF)"""
tf = Counter(tokens)
total = len(tokens) if tokens else 1
return {word: count / total for word, count in tf.items()}
def compute_idf(documents):
"""计算逆文档频率 (IDF)"""
N = len(documents)
df = defaultdict(int)
for doc in documents:
unique_tokens = set(doc)
for token in unique_tokens:
df[token] += 1
idf = {}
for token, count in df.items():
# 平滑处理:避免除零
idf[token] = math.log((N + 1) / (count + 1)) + 1
return idf
def compute_tfidf(tf, idf):
"""计算 TF-IDF 向量"""
return {word: tf_val * idf.get(word, 1) for word, tf_val in tf.items()}
def cosine_similarity(vec1, vec2):
"""计算余弦相似度"""
# 找到共同词
common_words = set(vec1.keys()) & set(vec2.keys())
if not common_words:
return 0.0
# 计算点积
dot_product = sum(vec1[w] * vec2[w] for w in common_words)
# 计算模长
norm1 = math.sqrt(sum(v ** 2 for v in vec1.values()))
norm2 = math.sqrt(sum(v ** 2 for v in vec2.values()))
if norm1 == 0 or norm2 == 0:
return 0.0
return dot_product / (norm1 * norm2)
class SearchEngine:
"""问题知识库搜索引擎"""
def __init__(self):
"""初始化:加载索引并构建 TF-IDF 模型"""
self.index = load_search_index()
self.records = self.index.get('records', [])
# 预处理:为每条记录构建 TF-IDF 向量
self.doc_tokens = []
self.doc_vectors = []
self.idf = {}
if self.records:
# 对所有记录进行分词
self.doc_tokens = [
tokenize(r.get('full_text', '') + ' ' + r.get('title', ''))
for r in self.records
]
# 计算 IDF
self.idf = compute_idf(self.doc_tokens)
# 预计算每条记录的 TF-IDF 向量
self.doc_vectors = [
compute_tfidf(compute_tf(tokens), self.idf)
for tokens in self.doc_tokens
]
print(f"[搜索引擎] 初始化完成:{len(self.records)} 条记录")
def search(self, query, top_k=5, project_filter=None, category_filter=None):
"""
搜索相似问题记录。
参数:
query: 搜索查询字符串
top_k: 返回结果数量
project_filter: 项目名称过滤(可选)
category_filter: 分类过滤(可选)
返回:
[
{
'rank': 1,
'score': 0.85,
'record': {...} # 原始记录
},
...
]
"""
if not self.records:
return []
# 对查询进行分词和向量化
query_tokens = tokenize(query)
query_tf = compute_tf(query_tokens)
query_vector = compute_tfidf(query_tf, self.idf)
# 计算与每条记录的相似度
scores = []
for i, doc_vector in enumerate(self.doc_vectors):
# 应用过滤器
record = self.records[i]
if project_filter:
if record.get('project', '').lower() != project_filter.lower():
continue
if category_filter:
if category_filter not in record.get('category', []):
continue
# 计算相似度
similarity = cosine_similarity(query_vector, doc_vector)
# 关键词匹配加分
matched_keywords = 0
for kw in record.get('keywords', []):
if kw.lower() in query.lower():
matched_keywords += 1
keyword_bonus = matched_keywords * 0.05 # 每匹配一个关键词加 5%
# 项目匹配加分
project_bonus = 0
if project_filter and record.get('project', '').lower() == project_filter.lower():
project_bonus = 0.1 # 同项目加 10%
# 综合得分
final_score = min(1.0, similarity + keyword_bonus + project_bonus)
if final_score > 0:
scores.append((i, final_score))
# 按得分排序
scores.sort(key=lambda x: x[1], reverse=True)
# 返回 Top K
results = []
for rank, (idx, score) in enumerate(scores[:top_k], start=1):
results.append({
'rank': rank,
'score': round(score, 3),
'record': self.records[idx],
})
return results
def get_projects(self):
"""获取所有项目名称列表"""
return self.index.get('project_names', [])
def get_categories(self):
"""获取所有分类列表"""
return list(self.index.get('categories', {}).keys())
# 模块级单例
_engine = None
def get_engine():
"""获取搜索引擎单例"""
global _engine
if _engine is None:
_engine = SearchEngine()
return _engine
if __name__ == "__main__":
# 测试
engine = SearchEngine()
test_queries = [
"门口屏 MQTT 连接失败",
"redis 连接失败",
"预定系统启动失败",
"配置文件不生效",
]
for q in test_queries:
print(f"\n{'='*50}")
print(f"查询:{q}")
print(f"{'='*50}")
results = engine.search(q, top_k=3)
for r in results:
rec = r['record']
print(f"[{r['rank']}] 得分:{r['score']}")
print(f" 项目:{rec.get('project', '无')}")
print(f" 标题:{rec.get('title', '无')}")
print(f" 分类:{rec.get('category', [])}")
此差异已折叠。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - 问题排查助手</title>
<style>
:root {
--primary: #2563eb;
--primary-hover: #1d4ed8;
--danger: #dc2626;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-500: #6b7280;
--gray-700: #374151;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Microsoft YaHei", sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-card {
background: white;
border-radius: 16px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
width: 100%;
max-width: 400px;
overflow: hidden;
}
.login-header {
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 100%);
color: white;
padding: 32px;
text-align: center;
}
.login-header h1 {
font-size: 24px;
font-weight: 700;
margin-bottom: 8px;
}
.login-header p {
opacity: 0.8;
font-size: 14px;
}
.login-body {
padding: 32px;
}
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--gray-700);
margin-bottom: 8px;
}
.form-input {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--gray-200);
border-radius: 8px;
font-size: 15px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.checkbox-label {
display: flex;
align-items: center;
font-size: 14px;
color: var(--gray-700);
cursor: pointer;
}
.checkbox-label input {
margin-right: 8px;
width: 16px;
height: 16px;
}
.btn-login {
width: 100%;
padding: 14px;
background: var(--primary);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
margin-top: 24px;
}
.btn-login:hover {
background: var(--primary-hover);
}
.btn-login:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-msg {
background: #fee2e2;
border: 1px solid #fca5a5;
color: #991b1b;
padding: 12px;
border-radius: 8px;
font-size: 14px;
margin-top: 16px;
display: none;
}
.login-footer {
background: var(--gray-50);
padding: 16px 32px;
text-align: center;
border-top: 1px solid var(--gray-200);
}
.login-footer p {
font-size: 13px;
color: var(--gray-500);
}
</style>
</head>
<body>
<div class="login-card">
<div class="login-header">
<h1>🔐 问题排查助手</h1>
<p>请登录以继续访问系统</p>
</div>
<div class="login-body">
<form id="loginForm">
<div class="form-group">
<label class="form-label">用户名</label>
<input type="text" id="username"
class="form-input"
placeholder="请输入用户名"
autocomplete="username"
required>
</div>
<div class="form-group">
<label class="form-label">密码</label>
<input type="password" id="password"
class="form-input"
placeholder="请输入密码"
autocomplete="current-password"
required>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="remember">
记住登录状态
</label>
</div>
<button type="submit" class="btn-login" id="loginBtn">
🔑 登 录
</button>
<div id="errorMsg" class="error-msg"></div>
</form>
</div>
<div class="login-footer">
<p>提示:默认管理员账号 admin / Admin@2026</p>
</div>
</div>
<script>
// 登录表单提交
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const remember = document.getElementById('remember').checked;
if (!username || !password) {
showError('请输入用户名和密码');
return;
}
// 显示加载状态
const loginBtn = document.getElementById('loginBtn');
const originalText = loginBtn.textContent;
loginBtn.textContent = '⏳ 登录中...';
loginBtn.disabled = true;
try {
const resp = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, remember }),
credentials: 'include'
});
const data = await resp.json();
if (data.success) {
// 登录成功,跳转主页
window.location.href = '/';
} else {
showError(data.error || '登录失败');
loginBtn.textContent = originalText;
loginBtn.disabled = false;
}
} catch (e) {
showError('网络错误,请稍后重试');
loginBtn.textContent = originalText;
loginBtn.disabled = false;
}
});
function showError(msg) {
const errorEl = document.getElementById('errorMsg');
errorEl.textContent = msg;
errorEl.style.display = 'block';
}
// 清除错误提示
document.getElementById('username').addEventListener('input', () => {
document.getElementById('errorMsg').style.display = 'none';
});
document.getElementById('password').addEventListener('input', () => {
document.getElementById('errorMsg').style.display = 'none';
});
</script>
</body>
</html>
\ No newline at end of file
{
"users": [
{
"id": 1,
"username": "admin",
"password_hash": "scrypt:32768:8:1$4pS80HZDH81TmN0w$4e2359f166b15cb24d2a13dce9d97fb558eb3d801b60d04c22c6f35122a3035a53396c42a35765f4dbe55c9349477798b42b2cbaa67f9fa18e7e51ce0d93697f",
"role": "admin",
"created_at": "2026-07-12T16:00:00",
"last_login": "2026-07-12T16:06:00",
"login_count": 5,
"active": true
},
{
"id": 2,
"username": "user",
"password_hash": "scrypt:32768:8:1$nus73sCDpqNJTD3X$fd9fcf0f0a6633a1e68606e3b3cac55ed250d5e961c33d421cc52458759ac6bb64bf4ce01810c2356be92f37536691faf687f3a42fcba36581182a52c14de6e1",
"role": "user",
"created_at": "2026-07-12T16:10:00",
"last_login": null,
"login_count": 0,
"active": true
}
]
}
\ No newline at end of file
此差异已折叠。
此差异已折叠。
...@@ -165,6 +165,77 @@ add_missing_columns() { ...@@ -165,6 +165,77 @@ add_missing_columns() {
| SYS_PERMISSION_GROUP | IS_BASE | INT DEFAULT 0 | 是否基础权限组 | ✅ 已添加 | | SYS_PERMISSION_GROUP | IS_BASE | INT DEFAULT 0 | 是否基础权限组 | ✅ 已添加 |
| SYS_PERMISSION_GROUP | GROUP_NAME | VARCHAR(100) NOT NULL | 权限组名称 | ✅ 已添加 | | SYS_PERMISSION_GROUP | GROUP_NAME | VARCHAR(100) NOT NULL | 权限组名称 | ✅ 已添加 |
| RMS_MEETING_TEMPLATE | COMMENT | VARCHAR(100) | 备注(保留字,需双引号) | ✅ 已添加 | | RMS_MEETING_TEMPLATE | COMMENT | VARCHAR(100) | 备注(保留字,需双引号) | ✅ 已添加 |
| SYS_CONFIGURATION | CONFIG_TYPE | - | 清理重复大写字段(建表DDL生成大小写两个版本) | ✅ 已删除 |
---
## 字段名大小写问题(2026-07-11 补充)
### 问题现象
Java 应用连接达梦时报错:
```
dm.jdbc.driver.DMException: 第1 行附近出现错误:
无效的列名[USER_ID]
无效的列名[TYPE_ID]
```
### 根本原因
达梦数据库中 **272 张表的 3546 个字段名是小写的**(如 `user_id`),而 Java 应用 MyBatis 查询时使用大写(`USER_ID`),导致字段名不匹配。
典型示例:
- `RMS_MANAGE_USER_AREA` 表字段:`['id', 'user_id', 'area_id', ...]` — 全部小写
- `SYS_CONFIGURATION` 表字段:`['config_id', 'config_json', 'type_id', ...]` — 大部分小写
### 解决方案
新增 `uppercase_column_names()` 函数,在部署脚本中自动将所有小写字段名转换为大写:
```bash
uppercase_column_names() {
log_info "统一字段名大写..."
local user=$1
# 查找所有小写字段名并生成重命名 SQL
# 排除 user 表(保留关键字)
local sql="/tmp/rename_cols_${user}.sql"
docker exec "$CONTAINER" /opt/dmdbms/bin/disql SYSDBA/'dNrprU&2S!'@localhost:$DM_PORT \
-e "SELECT 'ALTER TABLE \"' || TABLE_NAME || '\" RENAME COLUMN \"' || COLUMN_NAME || '\" TO ' || UPPER(COLUMN_NAME) || ';'
FROM DBA_TAB_COLUMNS
WHERE OWNER='$user'
AND COLUMN_NAME != UPPER(COLUMN_NAME)
AND TABLE_NAME != 'user';" 2>/dev/null > "$sql"
if [[ -s "$sql" ]]; then
grep -E '^ALTER TABLE' "$sql" > "${sql}.clean"
if [[ -s "${sql}.clean" ]]; then
docker cp "${sql}.clean" "$CONTAINER:/opt/dmdbms/bak/rename_cols_${user}.sql"
docker exec "$CONTAINER" bash -c \
"export LD_LIBRARY_PATH=/opt/dmdbms/bin && \
cat /opt/dmdbms/bak/rename_cols_${user}.sql | /opt/dmdbms/bin/disql SYSDBA/dNrprU\&2S\!@localhost:$DM_PORT" 2>/dev/null
local cnt=$(wc -l < "${sql}.clean")
log_info " 字段名大写转换完成: $cnt 个字段"
docker exec "$CONTAINER" rm -f "/opt/dmdbms/bak/rename_cols_${user}.sql"
fi
else
log_info " 所有字段名已是大写,无需处理"
fi
rm -f "$sql" "${sql}.clean"
}
```
### 执行结果汇总(更新版)
| 操作 | 数量 |
|------|------|
| 总表数 | 325 |
| 表名重命名(小写→大写) | 272 |
| 字段名重命名(小写→大写) | 3546 |
| 删除重复表 | 3 |
| 保留小写表 | 1(`user`,保留关键字) |
| 补充字段 | 3 |
| 清理重复字段 | 1(SYS_CONFIGURATION.CONFIG_TYPE) |
--- ---
...@@ -191,28 +262,35 @@ add_missing_columns() { ...@@ -191,28 +262,35 @@ add_missing_columns() {
- 带引号的标识符保持原样(区分大小写) - 带引号的标识符保持原样(区分大小写)
- 建议建表时不使用引号,让表名统一为大写 - 建议建表时不使用引号,让表名统一为大写
4. **迁移建议**:MySQL 到达梦迁移时,应在建表 SQL 中统一使用大写表名,避免后续兼容性问题 4. **达梦字段名规则**
- MySQL 到达梦迁移时,字段名同样会被保留为小写(如果建表 DDL 使用了双引号)
- 必须在部署后统一转换为大写,否则 Java 查询会报"无效的列名"
5. **迁移建议**:MySQL 到达梦迁移时,应在建表 SQL 中统一使用大写表名和字段名,避免后续兼容性问题
--- ---
## 相关脚本文件 ## 相关脚本文件
### 自动化部署脚本 ### 自动化部署脚本
- `自动化部署脚本/x86架构/达梦数据库/import_dm8_databases.sh` - `自动化部署脚本/x86架构/达梦数据库/import_dm8_databases.sh`(v3,含字段名大写转换)
- `自动化部署脚本/arm架构/达梦数据库/import_dm8_databases.sh` - `自动化部署脚本/arm架构/达梦数据库/import_dm8_databases.sh`(v3,含字段名大写转换)
### 临时工具脚本 ### 临时工具脚本
- `compare_db_tables.py` - 对比表差异 - `compare_db_tables.py` - 对比表差异
- `rename_tables_upper.py` - 批量重命名 - `rename_tables_upper.py` - 批量重命名
- `cleanup_tables.py` - 清理重复表 - `cleanup_tables.py` - 清理重复表
- `check_missing_columns.py` - 检查缺失字段 - `check_missing_columns.py` - 检查缺失字段
- `add_missing_columns.py` - 补充缺失字段 - `add_missing_columns.py` - 补充缺失字段
- `check_column_case.py` - 检查字段名大小写(新增)
- `fix_column_case.py` - 生成字段名大写转换 SQL(新增)
--- ---
## 执行时间 ## 执行时间
2026-07-10 - 表名大小写修复:2026-07-10
- 字段名大小写修复:2026-07-11
## 执行人 ## 执行人
......
此差异已折叠。
此差异已折叠。
{
"last_convert": "2026-07-11 11:02:01",
"source_file": "问题反馈跟踪表(测试使用) (1).xlsx",
"total": 357,
"breakdown": {
"日常": 25,
"项目": 332,
"归档": 0
}
}
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论