[split-upload 3/6] f2fd4fa backup via proxy
This commit is contained in:
@@ -0,0 +1,505 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
build_inspection_report.py — 巡检报告 HTML 生成脚本(2026-08-06 落地)
|
||||
========================================================================
|
||||
说明:从对话/看板/Markdown 抽取的巡检数据,按统一模板生成单页 HTML 报告。
|
||||
套用 2026-08-05 早班报告样式 + 4 维分类筛选交互,零 CDN 依赖。
|
||||
|
||||
用法:
|
||||
python scripts/build_inspection_report.py <report_date> <shift>
|
||||
# 例:python scripts/build_inspection_report.py 2026-08-06 早班
|
||||
|
||||
输入参数:
|
||||
- date YYYY-MM-DD
|
||||
- shift 早班/午班/晚班
|
||||
|
||||
输出:
|
||||
docs/07-项目管理/巡检报告/YYYY-MM/YYYY-MM-DD-{shift}.html
|
||||
========================================================================
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
PROJECT_DIR = SCRIPT_DIR.parent
|
||||
DEFAULT_OUT_DIR = PROJECT_DIR / "docs" / "07-项目管理" / "巡检报告"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 巡检数据(2026-08-06 06:00 早班)
|
||||
# ============================================================
|
||||
DATA = {
|
||||
"date": "2026-08-06",
|
||||
"shift": "早班",
|
||||
"time_str": "2026-08-06 06:00",
|
||||
"kanban_version": "v1.9.0-DRAFT",
|
||||
"filter_counts": {
|
||||
"all": 11,
|
||||
"p0": 5,
|
||||
"decide": 2,
|
||||
"doing": 1,
|
||||
"server": 8,
|
||||
"code": 5,
|
||||
"security": 4,
|
||||
"history": 5,
|
||||
},
|
||||
"p0_rows": [
|
||||
{
|
||||
"id": "P0-1",
|
||||
"name": "<code>/itportal/</code> 入口 500 错误",
|
||||
"category": "♻️ 历史遗留",
|
||||
"desc": "2026-08-06 06:00 jumpserver-V2 实测仍 500,nginx error: <code>rewrite or internal redirection cycle while internally redirecting to "/itportal/index.html"</code>。根因:容器内 <code>/usr/share/nginx/html/itportal/</code> 空目录 + nginx <code>location /itportal/</code> 仍配置 <code>try_files $uri /itportal/index.html</code>。建议:<code>docker compose up -d --force-recreate nginx</code>(前提:commit 21830d5 中 nginx.conf 已删除该 location 块)",
|
||||
},
|
||||
{
|
||||
"id": "P0-3",
|
||||
"name": "<code>closing_service.py:467</code> datetime 时区错位",
|
||||
"category": "♻️ 历史遗留",
|
||||
"desc": "<code>datetime.now() - close_time</code> 报 <code>TypeError: can't subtract offset-naive and offset-aware datetimes</code>。影响 <code>POST /h5/conversations/current/reopen</code>。同文件 18 处 <code>datetime.now()</code> 全是 naive,且 <code>from datetime import datetime, timedelta</code> 未 import timezone。24h 内未触发,但用户使用 H5 重开会话即会爆",
|
||||
},
|
||||
{
|
||||
"id": "P0-4",
|
||||
"name": "<code>employee_profile_service.py:236</code> SessionLocal NoneType 🔥",
|
||||
"category": "🆕 位置修正",
|
||||
"desc": "<strong>看板 v1.8 行号描述错误</strong>:实际错误位置是 <code>app/services/employee_profile_service.py:236</code> 而非 <code>h5_ai_task.py:1198</code>。<code>db = SessionLocal()</code> 返回 None → <code>TypeError: 'NoneType' object is not callable</code>。2026-08-05 24h 内累计 10+ 次(01:33 / 03:25 / 04:07 / 04:11 多波次),影响 H5 IT 资产推荐异步推送。<strong>同根因 P0-5</strong>",
|
||||
},
|
||||
{
|
||||
"id": "P0-5",
|
||||
"name": "容器 <code>app/constants/</code> 打包错误 🔥",
|
||||
"category": "🆕 新增",
|
||||
"desc": "容器内 <code>/app/app/constants/__init__.py</code> 11635 bytes 内容是 automation.py(两个文件互换了),<code>/app/app/constants/automation.py</code> <strong>不存在</strong>。直接后果:连续 10+ 次 <code>ModuleNotFoundError: No module named 'app.constants.ai_reply_mode'</code> + 同根因连锁导致 P0-4(SessionLocal 绑定失败)。建议:① 临时 <code>docker cp</code> 修补 → ② 长期排查 backend 镜像构建流程",
|
||||
},
|
||||
{
|
||||
"id": "P0-6",
|
||||
"name": "后端 OAuth 40029 抖动",
|
||||
"category": "ℹ️ 信息类",
|
||||
"desc": "218.75.34.87 等外部 IP 持续试探 <code>/api/auth/validate-sso</code> + 偶发 OAuth code 失效。属 WAF 扫描背景噪声,已 fail2ban 监控。无需主动处理",
|
||||
},
|
||||
],
|
||||
"decide_rows": [
|
||||
{
|
||||
"id": "BLK-A",
|
||||
"name": "企微会议室 Secret",
|
||||
"duration": "⚠️ <strong>26 天</strong>(自 2026-07-11)",
|
||||
"owner": "需企业微信管理后台申请。Owner: 平台组",
|
||||
},
|
||||
{
|
||||
"id": "BLK-B",
|
||||
"name": "ITSM API 授权",
|
||||
"duration": "⚠️ <strong>26 天</strong>(自 2026-07-11)",
|
||||
"owner": "需向 ITSM 平台方申请 app_id/app_secret。Owner: 平台组",
|
||||
},
|
||||
],
|
||||
"doing_rows": [
|
||||
{
|
||||
"id": "#81",
|
||||
"name": "敏感词检测 + 语气优化",
|
||||
"status": "v1.2 待排期",
|
||||
"desc": "v1.1 阶段 1 已于 2026-07-28 完成(词库入库 + 后台管理 UI + 12 配套 API)。剩余 "语气优化、灰度策略细化" 待 v1.2 排期",
|
||||
},
|
||||
],
|
||||
"server_rows": [
|
||||
{"name": "Docker 容器", "status": "ok", "label": "正常", "desc": "5/5 healthy:nginx (Up 12h) / backend (Up 17h) / redis (Up 2w) / postgres (Up 3w) / neo4j (Up 3w)。无 Exited/Restarting/Unhealthy"},
|
||||
{"name": "后端 health", "status": "ok", "label": "正常", "desc": "<code>/health</code> → HTTP 200(容器内 3.9ms);外部 <code>curl http://localhost:8000</code> 000(端口未映射给宿主,nginx 反代访问正常)"},
|
||||
{"name": "后端 5xx 错误", "status": "fail", "label": "异常", "desc": "24h 内仍见 2 类 Traceback:P0-5 ModuleNotFoundError(10+ 次)+ P0-4 SessionLocal NoneType(10+ 次)"},
|
||||
{"name": "PostgreSQL", "status": "ok", "label": "正常", "desc": "<code>pg_isready -U postgres</code> → <code>accepting connections</code>"},
|
||||
{"name": "Redis", "status": "ok", "label": "正常", "desc": "<code>redis_version:7.4.9</code> / 独立模式 + 密码认证(NOAUTH 正常);已配密码 <code>WKzl7jgKTkeWxFWGIBXfzokm59Gvfr76</code>"},
|
||||
{"name": "Nginx 配置", "status": "ok", "label": "正常", "desc": "<code>nginx -t</code> → syntax OK;80/443 已 bind"},
|
||||
{"name": "前端入口", "status": "fail", "label": "异常", "desc": "<code>/itportal/</code> <strong>500</strong>(rewrite cycle);<code>/h5/</code>、<code>/itdesk/</code> 200 OK;<code>/</code> 200(612 bytes root index.html)"},
|
||||
{"name": "磁盘 / 内存", "status": "ok", "label": "正常", "desc": "130G 可用(13% 用)/ 3.4G used / 11G available / 负载 0.99 / 已运行 54 天"},
|
||||
],
|
||||
"code_rows": [
|
||||
{"name": "🟢 鉴权补漏", "desc": "sensitive_words.py 13 端点已正确加 <code>dependencies=[Depends(require_admin)]</code> (line 47-51)。看板描述脱节,v1.9 已迁移至已完成"},
|
||||
{"name": "🔴 容器/源码不一致", "desc": "容器内 <code>app/constants/__init__.py</code> = automation.py 内容。排查镜像构建流程,重打 backend 镜像"},
|
||||
{"name": "🟡 死代码 / 残留", "desc": "console.log 在 <code>src/frontend-*/src</code> 已搜不到。看板描述脱节,v1.9 已迁移至已完成"},
|
||||
{"name": "🟢 时区隐患", "desc": "closing_service.py 全部 18 处 <code>datetime.now()</code> 全是 naive,无 import timezone。建议全文件改造为 <code>datetime.now(tz=timezone.utc)</code>"},
|
||||
{"name": "🟢 未使用导入 / 硬编码", "desc": "未发现明显问题(快速扫描未命中);源码层无硬编码(生产 env 路径已隔离)"},
|
||||
],
|
||||
"security_rows": [
|
||||
{"name": "🟡 API 鉴权", "desc": "sensitive_words.py 13 端点已 require_admin;其余 admin API 状态需逐项复核(本次未做全量扫描)"},
|
||||
{"name": "🟢 敏感数据处理", "desc": "数据库连接串、Redis 密码均在 env 注入,未硬编码到源码"},
|
||||
{"name": "🟡 依赖包", "desc": "<code>cryptography==42.0.8</code> / <code>pydantic==2.7.4</code>(2026-08-05 巡检已记录,仍待升级)"},
|
||||
{"name": "🟡 日志敏感信息", "desc": "生产 logs 记录了用户 OAuth code(如 <code>code=OFVMVR7IB2dgFVWX...</code>),属企微临时凭据,建议在 <code>wecom_service.py:1411</code> 打印前 mask"},
|
||||
],
|
||||
"history_rows": [
|
||||
{"name": "P0-2 sensitive_words.py 13 端点无鉴权", "category": "♻️→✅", "desc": "看板 v1.8 标\"待治理\",2026-08-06 06:00 巡检发现<strong>代码已修复</strong>(router 加 <code>dependencies=[Depends(require_admin)]</code> line 47-51),v1.9 看板已迁移至已完成区"},
|
||||
{"name": "P1-1 前端 console.log 残留 208 行", "category": "♻️→✅", "desc": "看板 v1.8 标\"待清理\",2026-08-06 06:00 巡检发现 <code>rg/find</code> 搜不到任何 console.log,v1.9 看板已迁移至已完成区"},
|
||||
{"name": "P0-4 行号描述修正", "category": "🔄 修正", "desc": "看板 v1.8 写 <code>h5_ai_task.py:1198</code>,实际是 <code>employee_profile_service.py:236</code>。<strong>同根因 P0-5</strong>,建议合并排查"},
|
||||
{"name": "BLK-A/B 阻塞时长刷新", "category": "📅 +1d", "desc": "自 2026-07-11 起,2026-08-06 已 <strong>26 天</strong>(较昨日 +1 自然日),远超 3 天阈值"},
|
||||
{"name": "/itportal/ 500 持续 29 天", "category": "🔥 持续", "desc": "2026-07-08 起至今,今日 06:00 jumpserver-V2 实测仍 500。需 PM 派单部署 commit 21830d5 或清理 nginx location 块"},
|
||||
],
|
||||
"pm_alert": [
|
||||
"🔴 P0-NEW1(constants 包打包错误)必须当日修:直接导致 H5 异步任务全部崩溃(资产推荐推送 NoneType)。修复方向:① 临时 <code>docker cp</code> 修补 → ② 长期重打 backend 镜像",
|
||||
"🔴 /itportal 500 已持续 29 天:根因明确(容器内空 dist + nginx location 块残留)。建议执行 <code>docker compose up -d --force-recreate nginx</code>",
|
||||
"🟡 看板 v1.8.0-FROZEN 2 项描述脱节:P0-2 sensitive_words / P1-1 console.log 实际已修复 / 已清理,v1.9 已修正",
|
||||
"⚠️ BLK-A/B 阻塞已 26 天(+23 天超过 3 天阈值):建议 PM 升级催办或重新评估优先级",
|
||||
"🟡 closing_service.py:467 时区隐患:24h 内未触发,修复成本低(<code>from datetime import timezone</code> + 18 处替换)",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 样式(复用 2026-08-05 早班报告色板,零 CDN)
|
||||
# ============================================================
|
||||
CSS = r"""
|
||||
:root {
|
||||
--accent: #07C160;
|
||||
--accent-soft: #E7F8EF;
|
||||
--warn: #FA9D3B;
|
||||
--danger: #FA5151;
|
||||
--info: #10AEFF;
|
||||
--text: #1F2329;
|
||||
--text-soft: #646A73;
|
||||
--bg: #FFFFFF;
|
||||
--bg-soft: #F7F8FA;
|
||||
--border: #DEE0E3;
|
||||
--shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
--radius: 8px;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--bg-soft);
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
.container { max-width: 1080px; margin: 0 auto; padding: 32px 24px 64px; }
|
||||
header {
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #06AD56 100%);
|
||||
color: #fff;
|
||||
padding: 32px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
header h1 { font-size: 26px; font-weight: 600; margin-bottom: 8px; }
|
||||
header .meta { font-size: 13px; opacity: 0.92; }
|
||||
header .meta span { margin-right: 18px; }
|
||||
header .meta strong { font-weight: 600; }
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 32px 0 14px;
|
||||
padding-left: 10px;
|
||||
border-left: 4px solid var(--accent);
|
||||
}
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.summary-card {
|
||||
background: #fff;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
text-align: center;
|
||||
}
|
||||
.summary-card .num { font-size: 24px; font-weight: 700; color: var(--accent); }
|
||||
.summary-card .label { font-size: 12px; color: var(--text-soft); margin-top: 4px; }
|
||||
.summary-card.warn .num { color: var(--warn); }
|
||||
.summary-card.danger .num { color: var(--danger); }
|
||||
.summary-card.info .num { color: var(--info); }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; margin-bottom: 24px; }
|
||||
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
th { background: var(--bg-soft); font-size: 12px; font-weight: 600; color: var(--text-soft); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||
.badge.p0 { background: #FEE; color: var(--danger); border: 1px solid var(--danger); }
|
||||
.badge.p1 { background: #FFF3E0; color: var(--warn); border: 1px solid var(--warn); }
|
||||
.badge.decide { background: #E6F4FF; color: var(--info); border: 1px solid var(--info); }
|
||||
.badge.doing { background: var(--accent-soft); color: var(--accent); border: 1px solid var(--accent); }
|
||||
.badge.ok { background: var(--accent-soft); color: var(--accent); }
|
||||
.badge.fail { background: #FEE; color: var(--danger); }
|
||||
code { background: var(--bg-soft); padding: 2px 6px; border-radius: 3px; font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; }
|
||||
.alert { background: #FFF8E6; border-left: 4px solid var(--warn); padding: 12px 16px; margin: 16px 0; border-radius: 4px; }
|
||||
.alert.danger { background: #FEE; border-left-color: var(--danger); }
|
||||
.alert strong { display: block; margin-bottom: 4px; }
|
||||
.legend { color: var(--text-soft); font-size: 12px; margin-top: 4px; margin-bottom: 16px; }
|
||||
footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid var(--border); color: var(--text-soft); font-size: 12px; text-align: center; }
|
||||
ul.condensed { list-style: none; padding: 0; }
|
||||
ul.condensed li { padding: 8px 12px; border-bottom: 1px dashed var(--border); background: #fff; }
|
||||
ul.condensed li:last-child { border-bottom: none; }
|
||||
ul.condensed li:first-child { border-radius: var(--radius) var(--radius) 0 0; }
|
||||
ul.condensed li:last-child { border-radius: 0 0 var(--radius) var(--radius); box-shadow: var(--shadow); }
|
||||
|
||||
.filter-bar { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0 8px; padding: 12px 14px; background: #fff; border-radius: var(--radius); box-shadow: var(--shadow); align-items: center; }
|
||||
.filter-bar .filter-label { font-size: 12px; color: var(--text-soft); font-weight: 600; margin-right: 4px; }
|
||||
.filter-btn { cursor: pointer; border: 1px solid var(--border); background: #fff; color: var(--text-soft); padding: 6px 12px; border-radius: 16px; font-size: 12px; font-weight: 600; transition: all 0.15s ease; user-select: none; }
|
||||
.filter-btn:hover { transform: translateY(-1px); box-shadow: 0 2px 6px rgba(0,0,0,0.08); }
|
||||
.filter-btn.active { color: #fff; border-color: transparent; }
|
||||
.filter-btn[data-cat="all"].active { background: var(--text); }
|
||||
.filter-btn[data-cat="p0"].active { background: var(--danger); }
|
||||
.filter-btn[data-cat="decide"].active { background: var(--info); }
|
||||
.filter-btn[data-cat="doing"].active { background: var(--accent); }
|
||||
.filter-btn[data-cat="server"].active { background: var(--danger); }
|
||||
.filter-btn[data-cat="code"].active { background: #8B5CF6; }
|
||||
.filter-btn[data-cat="security"].active { background: #DC2626; }
|
||||
.filter-btn[data-cat="history"].active { background: #64748B; }
|
||||
.filter-btn .badge-count { display: inline-block; margin-left: 6px; padding: 1px 6px; background: rgba(0,0,0,0.08); border-radius: 8px; font-size: 10px; }
|
||||
.filter-btn.active .badge-count { background: rgba(255,255,255,0.25); }
|
||||
.hidden { display: none !important; }
|
||||
"""
|
||||
|
||||
|
||||
# ============================================================
|
||||
# HTML 渲染
|
||||
# ============================================================
|
||||
def render_html(data: dict) -> str:
|
||||
fc = data["filter_counts"]
|
||||
|
||||
# P0 表格
|
||||
p0_rows_html = ""
|
||||
for r in data["p0_rows"]:
|
||||
p0_rows_html += f""" <tr data-cat="p0">
|
||||
<td><span class="badge p0">{r['id']}</span></td>
|
||||
<td>{r['name']}</td>
|
||||
<td>{r['category']}</td>
|
||||
<td>{r['desc']}</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
# 等决策表格
|
||||
decide_rows_html = ""
|
||||
for r in data["decide_rows"]:
|
||||
decide_rows_html += f""" <tr data-cat="decide">
|
||||
<td><span class="badge decide">{r['id']}</span></td>
|
||||
<td>{r['name']}</td>
|
||||
<td>{r['duration']}</td>
|
||||
<td>{r['owner']}</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
# 进行中表格
|
||||
doing_rows_html = ""
|
||||
for r in data["doing_rows"]:
|
||||
doing_rows_html += f""" <tr data-cat="doing">
|
||||
<td><span class="badge doing">{r['id']}</span></td>
|
||||
<td>{r['name']}</td>
|
||||
<td>{r['status']}</td>
|
||||
<td>{r['desc']}</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
# 服务器状态
|
||||
server_rows_html = ""
|
||||
for r in data["server_rows"]:
|
||||
server_rows_html += f""" <tr data-cat="server">
|
||||
<td>{r['name']}</td>
|
||||
<td><span class="badge {r['status']}">{r['label']}</span></td>
|
||||
<td>{r['desc']}</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
# 代码质量
|
||||
code_items_html = "\n".join(
|
||||
f' <li data-cat="code"><strong>{r["name"]}</strong>:{r["desc"]}</li>'
|
||||
for r in data["code_rows"]
|
||||
)
|
||||
|
||||
# 安全
|
||||
security_items_html = "\n".join(
|
||||
f' <li data-cat="security"><strong>{r["name"]}</strong>:{r["desc"]}</li>'
|
||||
for r in data["security_rows"]
|
||||
)
|
||||
|
||||
# 对比/关注
|
||||
history_rows_html = ""
|
||||
for r in data["history_rows"]:
|
||||
history_rows_html += f""" <tr data-cat="history">
|
||||
<td>{r['name']}</td>
|
||||
<td>{r['category']}</td>
|
||||
<td>{r['desc']}</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
# PM 立即关注
|
||||
pm_items_html = "\n".join(
|
||||
f' <li data-cat="all">{item}</li>' for item in data["pm_alert"]
|
||||
)
|
||||
|
||||
# 摘要卡片
|
||||
summary_html = f"""
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card danger"><div class="num">{len(data['p0_rows'])}</div><div class="label">🔴 P0 必做</div></div>
|
||||
<div class="summary-card info"><div class="num">{len(data['decide_rows'])}</div><div class="label">🟢 等决策</div></div>
|
||||
<div class="summary-card"><div class="num">{len(data['doing_rows'])}</div><div class="label">⏳ 进行中</div></div>
|
||||
<div class="summary-card {'danger' if any(r['status']=='fail' for r in data['server_rows']) else ''}"><div class="num">{sum(1 for r in data['server_rows'] if r['status']=='fail')}</div><div class="label">🖥️ 服务器异常项</div></div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='14' font-size='14'>📋</text></svg>">
|
||||
<title>早班巡检报告 · {data['date']} 06:00 · v3.0</title>
|
||||
<style>{CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<header>
|
||||
<h1>📋 早班巡检报告 · IT 智能服务台</h1>
|
||||
<div class="meta">
|
||||
<span>📅 <strong>{data['time_str']}</strong>(GMT+8)</span>
|
||||
<span>🛰️ 服务器 <strong>10.90.5.110</strong></span>
|
||||
<span>📄 看板 <strong>{data['kanban_version']}</strong></span>
|
||||
<span>🤖 执行 <strong>Duckula</strong></span>
|
||||
<span>📦 看板源 <code>docs/07-项目管理/项目状态看板.md</code></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{summary_html}
|
||||
|
||||
<!-- 分类筛选 -->
|
||||
<div class="filter-bar">
|
||||
<span class="filter-label">🔍 分类筛选</span>
|
||||
<button class="filter-btn active" data-cat="all">全部<span class="badge-count">{fc['all']}</span></button>
|
||||
<button class="filter-btn" data-cat="p0">🔴 P0<span class="badge-count">{fc['p0']}</span></button>
|
||||
<button class="filter-btn" data-cat="decide">🟢 等决策<span class="badge-count">{fc['decide']}</span></button>
|
||||
<button class="filter-btn" data-cat="doing">⏳ 进行中<span class="badge-count">{fc['doing']}</span></button>
|
||||
<button class="filter-btn" data-cat="server">🖥️ 服务器<span class="badge-count">{fc['server']}</span></button>
|
||||
<button class="filter-btn" data-cat="code">🔍 代码<span class="badge-count">{fc['code']}</span></button>
|
||||
<button class="filter-btn" data-cat="security">🔐 安全<span class="badge-count">{fc['security']}</span></button>
|
||||
<button class="filter-btn" data-cat="history">🔄 对比/关注<span class="badge-count">{fc['history']}</span></button>
|
||||
</div>
|
||||
|
||||
<!-- P0 必做 -->
|
||||
<h2 data-section="p0">🔴 P0 必做(高优先级)</h2>
|
||||
<p class="legend">本次巡检最高优先级阻塞 / 待修复项</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>编号</th><th>任务</th><th>类别</th><th>说明</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{p0_rows_html} </tbody>
|
||||
</table>
|
||||
|
||||
<!-- 等用户决策 -->
|
||||
<h2 data-section="decide">🟢 等用户决策(阻塞项)</h2>
|
||||
<p class="legend">均超 3 天阈值,需 PM 升级催办</p>
|
||||
<table>
|
||||
<thead><tr><th>编号</th><th>任务</th><th>阻塞时长</th><th>卡点 / Owner</th></tr></thead>
|
||||
<tbody>
|
||||
{decide_rows_html} </tbody>
|
||||
</table>
|
||||
|
||||
<!-- 进行中 -->
|
||||
<h2 data-section="doing">⏳ 进行中 (In Progress)</h2>
|
||||
<table>
|
||||
<thead><tr><th>编号</th><th>任务</th><th>状态</th><th>说明</th></tr></thead>
|
||||
<tbody>
|
||||
{doing_rows_html} </tbody>
|
||||
</table>
|
||||
|
||||
<!-- 服务器状态 -->
|
||||
<h2 data-section="server">🖥️ 服务器运行状态</h2>
|
||||
<table>
|
||||
<thead><tr><th>组件</th><th>状态</th><th>实测数据</th></tr></thead>
|
||||
<tbody>
|
||||
{server_rows_html} </tbody>
|
||||
</table>
|
||||
|
||||
<!-- 代码规范与质量 -->
|
||||
<h2 data-section="code">🔍 代码规范与质量检查</h2>
|
||||
<ul class="condensed">
|
||||
{code_items_html}
|
||||
</ul>
|
||||
|
||||
<!-- 安全检查 -->
|
||||
<h2 data-section="security">🔐 安全检查</h2>
|
||||
<ul class="condensed">
|
||||
{security_items_html}
|
||||
</ul>
|
||||
|
||||
<!-- 对比/关注 -->
|
||||
<h2 data-section="history">🔄 与昨日对比 / PM 关注项</h2>
|
||||
<table>
|
||||
<thead><tr><th>条目</th><th>变化</th><th>说明</th></tr></thead>
|
||||
<tbody>
|
||||
{history_rows_html} </tbody>
|
||||
</table>
|
||||
|
||||
<!-- PM 立即关注 -->
|
||||
<h2 data-section="all">⚠️ PM 立即关注</h2>
|
||||
<ul class="condensed">
|
||||
{pm_items_html}
|
||||
</ul>
|
||||
|
||||
<footer>
|
||||
📋 报告生成时间:{data['time_str']} · 看板版本 {data['kanban_version']} · Duckula(达寇拉)自动化生成<br>
|
||||
📎 数据源:jumpserver-V2 实测 + 后端 24h 日志审计 + 源码扫描 + 容器内文件核对<br>
|
||||
🔗 服务地址:<code>https://itsupport.servyou.com.cn/docs/inspection/2026-08/2026-08-06-早班.html</code>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {{
|
||||
// 简易分类筛选
|
||||
const all = {{ all: true }};
|
||||
document.querySelectorAll('.filter-btn').forEach((btn) => {{
|
||||
btn.addEventListener('click', () => filter(btn.dataset.cat));
|
||||
}});
|
||||
function filter(cat) {{
|
||||
document.querySelectorAll('.filter-btn').forEach((b) => b.classList.remove('active'));
|
||||
document.querySelector('.filter-btn[data-cat="' + cat + '"]').classList.add('active');
|
||||
document.querySelectorAll('tr[data-cat], li[data-cat]').forEach((el) => {{
|
||||
if (cat === 'all') {{ el.classList.remove('hidden'); }}
|
||||
else if (el.dataset.cat === cat) {{ el.classList.remove('hidden'); }}
|
||||
else {{ el.classList.add('hidden'); }}
|
||||
}});
|
||||
document.querySelectorAll('h2[data-section]').forEach((h) => {{
|
||||
if (cat === 'all' || h.dataset.section === cat) {{ h.classList.remove('hidden'); }}
|
||||
else {{ h.classList.add('hidden'); }}
|
||||
}});
|
||||
history.replaceState(null, '', '#' + cat);
|
||||
}}
|
||||
const initCat = (location.hash || '#all').slice(1);
|
||||
if (['all','p0','decide','doing','server','code','security','history'].includes(initCat)) {{
|
||||
filter(initCat);
|
||||
}}
|
||||
const keys = ['1','2','3','4','5','6','7','8','0'];
|
||||
const map = ['p0','decide','doing','server','code','security','history','overview','all'];
|
||||
document.addEventListener('keydown', (e) => {{
|
||||
if (['INPUT','TEXTAREA'].includes(e.target.tagName)) return;
|
||||
const idx = keys.indexOf(e.key);
|
||||
if (idx >= 0 && map[idx]) filter(map[idx]);
|
||||
}});
|
||||
}})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return html
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Build inspection report HTML")
|
||||
parser.add_argument("date", help="Report date (YYYY-MM-DD)")
|
||||
parser.add_argument("shift", help="Shift (早班/午班/晚班)")
|
||||
args = parser.parse_args()
|
||||
|
||||
year_month = args.date[:7] # YYYY-MM
|
||||
out_dir = DEFAULT_OUT_DIR / year_month
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_file = out_dir / f"{args.date}-{args.shift}.html"
|
||||
|
||||
DATA["date"] = args.date
|
||||
DATA["shift"] = args.shift
|
||||
DATA["time_str"] = f"{args.date} 06:00" if args.shift == "早班" else f"{args.date} 14:00"
|
||||
|
||||
html = render_html(DATA)
|
||||
out_file.write_text(html, encoding="utf-8")
|
||||
print(f"[OK] Generated: {out_file} ({len(html)} bytes)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
SELECT id, created_at, message_type, sender_type, content
|
||||
FROM messages
|
||||
WHERE sender_type = 'ai'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10;
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
set +e # collect everything, don't bail
|
||||
|
||||
echo '############ STEP 1: Locate project directory ############'
|
||||
cd /opt/wecom-it-desk 2>&1
|
||||
echo "Current dir: $(pwd)"
|
||||
ls -la docker-compose.yml 2>&1
|
||||
echo ''
|
||||
|
||||
echo '############ STEP 2: Diagnose (READ-ONLY) ############'
|
||||
echo '--- All wecom_it_ containers ---'
|
||||
docker ps -a --format "table {{.Names}}\t{{.Status}}" | grep -E "wecom_it_|NAMES"
|
||||
echo ''
|
||||
echo '--- Disk space ---'
|
||||
df -h /opt 2>&1
|
||||
echo ''
|
||||
echo '--- backend last 60 log lines ---'
|
||||
docker logs wecom_it_backend --tail 60 2>&1
|
||||
echo ''
|
||||
echo '--- backend internal health check ---'
|
||||
docker exec wecom_it_backend curl -s -o - -w "\nHTTP_CODE: %{http_code}\n" --max-time 5 http://localhost:8000/health 2>&1
|
||||
echo ''
|
||||
|
||||
echo '############ STEP 3: Restart from correct directory ############'
|
||||
cd /opt/wecom-it-desk
|
||||
docker compose up -d 2>&1
|
||||
echo ''
|
||||
echo 'Waiting 15s for services to stabilize...'
|
||||
sleep 15
|
||||
echo ''
|
||||
echo '--- Containers after restart ---'
|
||||
docker ps -a --format "table {{.Names}}\t{{.Status}}" | grep -E "wecom_it_|NAMES"
|
||||
echo ''
|
||||
|
||||
echo '############ STEP 4: End-to-end verification ############'
|
||||
echo '--- backend /health ---'
|
||||
curl -s -o - -w "\nHTTP_CODE: %{http_code}\n" --max-time 5 http://localhost:8000/health
|
||||
echo ''
|
||||
echo '--- nginx routes (expect 200/301/302) ---'
|
||||
for path in / /itagent/ /ith5/ /itadmin/; do
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://localhost${path}")
|
||||
echo " $path -> HTTP $code"
|
||||
done
|
||||
echo ''
|
||||
echo '############ DONE ############'
|
||||
echo 'Paste ALL output above back to Claude for diagnosis'
|
||||
@@ -0,0 +1,70 @@
|
||||
# NAS probe script (English-only, prevents PowerShell 5.1 GBK encoding issue)
|
||||
# Output saved to nas_probe_output.txt
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
$outputFile = "$PSScriptRoot\nas_probe_output.txt"
|
||||
|
||||
chcp 65001 | Out-Null
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
Write-Host "===================================" -ForegroundColor Cyan
|
||||
Write-Host " NAS Probe Script" -ForegroundColor Cyan
|
||||
Write-Host "===================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Connecting via Tailscale: simon@100.85.152.112" -ForegroundColor Yellow
|
||||
Write-Host "Read-only probe, output saved to:" -ForegroundColor Yellow
|
||||
Write-Host " $outputFile" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host "SSH will prompt for the simon user password..." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
$cmd = @"
|
||||
echo '===== [1] DSM Version ====='
|
||||
cat /etc.defaults/VERSION 2>/dev/null | head -10
|
||||
uname -a
|
||||
echo ''
|
||||
echo '===== [2] Docker availability ====='
|
||||
which docker && docker --version
|
||||
ls /var/packages/ContainerManager/target/usr/bin/docker 2>/dev/null
|
||||
/var/packages/ContainerManager/target/usr/bin/docker --version 2>&1
|
||||
echo ''
|
||||
echo '===== [3] All containers (running + stopped) ====='
|
||||
/var/packages/ContainerManager/target/usr/bin/docker ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' 2>&1 | head -40
|
||||
echo ''
|
||||
echo '===== [4] /volume1/docker structure ====='
|
||||
ls -la /volume1/docker/ 2>&1 | head -40
|
||||
echo '--- sub-dir sizes ---'
|
||||
du -sh /volume1/docker/*/ 2>/dev/null | head -30
|
||||
echo ''
|
||||
echo '===== [5] Listening ports (22/80/443/3000/3022/18080) ====='
|
||||
ss -tln 2>&1 | head -30
|
||||
echo ''
|
||||
echo '===== [6] Tailscale ====='
|
||||
ls /var/packages/Tailscale/target/bin/ 2>/dev/null
|
||||
/var/packages/Tailscale/target/bin/tailscale status 2>/dev/null | head -10
|
||||
echo ''
|
||||
echo '===== [7] Existing Gitea ====='
|
||||
/var/packages/ContainerManager/target/usr/bin/docker ps -a | grep -i gitea
|
||||
ls -la /volume1/docker/gitea 2>&1 | head -10
|
||||
echo ''
|
||||
echo '===== [8] Disk space ====='
|
||||
df -h /volume1 2>&1 | head -3
|
||||
echo ''
|
||||
echo '===== [9] User and permissions ====='
|
||||
id
|
||||
echo ''
|
||||
echo '===== [10] Installed packages ====='
|
||||
ls /var/packages/ 2>/dev/null | grep -iE 'docker|container|tail|portain'
|
||||
echo ''
|
||||
echo '===== DONE ====='
|
||||
"@
|
||||
|
||||
ssh simon@100.85.152.112 $cmd 2>&1 | Tee-Object -FilePath $outputFile -Encoding UTF8
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "===================================" -ForegroundColor Green
|
||||
Write-Host " Done. Output saved to:" -ForegroundColor Green
|
||||
Write-Host " $outputFile" -ForegroundColor White
|
||||
Write-Host "===================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Read-Host "Press Enter to close"
|
||||
@@ -0,0 +1 @@
|
||||
UPDATE agents SET status = 'offline' WHERE id = 'agent-sxn-001';
|
||||
@@ -0,0 +1,3 @@
|
||||
import requests
|
||||
r = requests.get('http://127.0.0.1:8000/api/h5/agents/online-status')
|
||||
print(r.text)
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test detect-intent API - correct path without /api prefix."""
|
||||
import json
|
||||
import httpx
|
||||
|
||||
# Backend routes are at root level (no /api prefix, nginx adds it)
|
||||
url = "http://localhost:8000/approval/detect-intent"
|
||||
|
||||
test_messages = [
|
||||
"我要申请VPN",
|
||||
"我的电脑坏了",
|
||||
"我要申请办公用品",
|
||||
"你好,今天天气怎么样?",
|
||||
]
|
||||
|
||||
for msg in test_messages:
|
||||
print(f"\n=== Testing: {msg} ===")
|
||||
try:
|
||||
resp = httpx.post(
|
||||
url,
|
||||
json={"text": msg},
|
||||
timeout=30.0,
|
||||
)
|
||||
print(f" HTTP: {resp.status_code}")
|
||||
print(f" Response: {resp.text[:500]}")
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
result = data.get("data", data)
|
||||
print(f" is_approval: {result.get('is_approval_request')}")
|
||||
print(f" confidence: {result.get('confidence')}")
|
||||
print(f" type: {result.get('approval_type')}")
|
||||
print(f" source: {result.get('source')}")
|
||||
except Exception as e:
|
||||
print(f" ERROR: {type(e).__name__}: {e}")
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
"""测试容器能否访问 Dify"""
|
||||
import httpx
|
||||
import socket
|
||||
|
||||
# 测试 DNS 解析
|
||||
try:
|
||||
ip = socket.gethostbyname('yw-dify.dc.servyou-it.com')
|
||||
print(f"DNS resolved: yw-dify.dc.servyou-it.com -> {ip}")
|
||||
except Exception as e:
|
||||
print(f"DNS failed: {e}")
|
||||
|
||||
# 测试 HTTP 连接
|
||||
async def test_dify():
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
# 测试根路径
|
||||
try:
|
||||
r = await client.get('http://yw-dify.dc.servyou-it.com/')
|
||||
print(f"GET / -> {r.status_code}")
|
||||
except Exception as e:
|
||||
print(f"GET / failed: {type(e).__name__}: {e}")
|
||||
|
||||
# 测试 API 端点
|
||||
try:
|
||||
r = await client.post(
|
||||
'http://yw-dify.dc.servyou-it.com/v1/chat-messages',
|
||||
json={"inputs": {}, "query": "test", "response_mode": "blocking", "user": "test"}
|
||||
)
|
||||
print(f"POST /v1/chat-messages -> {r.status_code}")
|
||||
except Exception as e:
|
||||
print(f"POST /v1/chat-messages failed: {type(e).__name__}: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
asyncio.run(test_dify())
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
"""测试 Dify 多个端口"""
|
||||
import httpx
|
||||
import asyncio
|
||||
|
||||
async def test_ports():
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
ports = [80, 8080, 3000, 5000]
|
||||
for port in ports:
|
||||
url = f"http://10.80.0.240:{port}/"
|
||||
try:
|
||||
r = await client.get(url)
|
||||
print(f"Port {port}: {r.status_code}")
|
||||
except Exception as e:
|
||||
print(f"Port {port}: FAIL - {type(e).__name__}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(test_ports())
|
||||
@@ -0,0 +1,9 @@
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
from app.services.asset_recommend_service import AssetRecommendService
|
||||
|
||||
svc = AssetRecommendService()
|
||||
cards = svc.match_keywords('VPN连不上怎么办')
|
||||
print(f'Found {len(cards)} cards')
|
||||
for c in cards:
|
||||
print(f' - {c.title}: {c.description}')
|
||||
@@ -0,0 +1 @@
|
||||
UPDATE agents SET status = 'offline';
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
"""重新打包前端部署文件 - 使用正确目录结构"""
|
||||
import os
|
||||
import tarfile
|
||||
|
||||
os.chdir('D:/资料/03-项目开发/wecom_it_smart_desk')
|
||||
|
||||
# 重新打包 frontend-agent
|
||||
print("Repacking frontend-agent...")
|
||||
if os.path.exists('frontend-agent/dist'):
|
||||
with tarfile.open('frontend-agent-dist-v2.tar.gz', 'w:gz') as tar:
|
||||
tar.add('frontend-agent/dist', arcname='dist')
|
||||
print('Created: frontend-agent-dist-v2.tar.gz')
|
||||
|
||||
# 重新打包 frontend-h5
|
||||
print("Repacking frontend-h5...")
|
||||
if os.path.exists('frontend-h5/dist'):
|
||||
with tarfile.open('frontend-h5-dist-v2.tar.gz', 'w:gz') as tar:
|
||||
tar.add('frontend-h5/dist', arcname='dist')
|
||||
print('Created: frontend-h5-dist-v2.tar.gz')
|
||||
|
||||
print('Done!')
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,475 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# =============================================================================
|
||||
# IT 智能服务台 — 自动化测试套件全景生成器
|
||||
# =============================================================================
|
||||
# 用途:
|
||||
# 1. 扫描 src/backend/tests/ 下所有 test_*.py 文件(含子目录)
|
||||
# 2. 调用 pytest --collect-only 统计每个文件的用例数
|
||||
# 3. 按主题分类(基于文件名关键字 + 目录前缀)
|
||||
# 4. 输出 Markdown 表格到 docs/03-测试文档/00-测试规范/测试套件全景.md
|
||||
#
|
||||
# 使用方式:
|
||||
# cd src/backend
|
||||
# python ../../scripts/test_inventory.py
|
||||
# python ../../scripts/test_inventory.py --dry-run # 仅打印不写文件
|
||||
# python ../../scripts/test_inventory.py --baseline 1311/88/4 # 填入最近一次 pytest 跑分
|
||||
#
|
||||
# 关联文档:
|
||||
# - 测试方法论指南: docs/03-测试文档/00-测试规范/测试方法论指南.md
|
||||
# - 测试套件全景: docs/03-测试文档/00-测试规范/测试套件全景.md
|
||||
# - 整改记录 #12: docs/04-运维文档/部署运维/00-文档规范化整改记录.md
|
||||
# =============================================================================
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 主题分类器(基于文件名关键字)
|
||||
# -----------------------------------------------------------------------------
|
||||
TOPIC_RULES: List[Tuple[str, List[str]]] = [
|
||||
("🔐 鉴权 / 认证 / RBAC", [
|
||||
r"auth", r"otp", r"mfa", r"rbac", r"admin_user", r"admin_ip",
|
||||
r"sensitive_words_auth", r"high_risk_guard", r"token_anomaly",
|
||||
]),
|
||||
("🤖 自动化 / 工作流", [
|
||||
r"automation", r"global_intent", r"information_item",
|
||||
r"session_manager", r"timeout_cleaner",
|
||||
]),
|
||||
("📋 审批工作流", [
|
||||
r"approval", r"asset_approval",
|
||||
]),
|
||||
("💬 消息 / 路由 / 会话", [
|
||||
r"message_", r"messages_uuid", r"nontext_message", r"voice_asr",
|
||||
r"routing_service", r"ai_reply",
|
||||
]),
|
||||
("👥 会话协作 / 邀请", [
|
||||
r"conversation", r"collaboration", r"employee_", r"invite_",
|
||||
]),
|
||||
("🧠 知识 / RAG", [
|
||||
r"knowledge", r"bugfix_ki", r"confidence_gate", r"topic_detector",
|
||||
]),
|
||||
("🛡️ 内容审核", [
|
||||
r"content_moderation",
|
||||
]),
|
||||
("🎯 诊断 / 推荐 / 评分 / Wingman", [
|
||||
r"triage", r"recommend", r"scoring", r"wingman",
|
||||
r"match_keywords", r"evaluation",
|
||||
]),
|
||||
("🏢 企微 / H5 集成", [
|
||||
r"wecom_crypto", r"h5_",
|
||||
]),
|
||||
("🖥️ 资产 / 基础 / 工具", [
|
||||
r"env_gating", r"exclusion", r"p2_p3", r"response_contract",
|
||||
r"api_basic", r"backend_observer", r"avatar_service",
|
||||
r"meetingroom", r"org_tree", r"neo4j_client", r"todo_integration",
|
||||
r"byod",
|
||||
]),
|
||||
("🔌 WS / 实时推送", [
|
||||
r"ws_",
|
||||
]),
|
||||
("🧪 Tier 1 API / 通用", [
|
||||
r"tier1_api", r"agents",
|
||||
]),
|
||||
("⚠️ 预存在 collection 错误", [
|
||||
r"approval_detect_intent", r"byod",
|
||||
]),
|
||||
]
|
||||
|
||||
TOPIC_ORDER = [t[0] for t in TOPIC_RULES]
|
||||
|
||||
|
||||
def classify(filename: str) -> str:
|
||||
"""根据文件名关键字匹配主题。"""
|
||||
name = filename.lower()
|
||||
if any(re.search(p, name) for p in TOPIC_RULES[-1][1]):
|
||||
return TOPIC_RULES[-1][0]
|
||||
for topic, patterns in TOPIC_RULES[:-1]:
|
||||
if any(re.search(p, name) for p in patterns):
|
||||
return topic
|
||||
return "🗂️ 其他 / 未分类"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# pytest --collect-only 解析
|
||||
# -----------------------------------------------------------------------------
|
||||
PYTEST_COLLECT_CMD = [
|
||||
sys.executable, "-m", "pytest",
|
||||
"tests/",
|
||||
"--collect-only",
|
||||
"-q",
|
||||
"--no-header",
|
||||
]
|
||||
|
||||
|
||||
def collect_per_file(backend_dir: Path, ignore_files: List[str]) -> Dict[str, int]:
|
||||
"""扫描 tests/ 下所有 test_*.py(含子目录),用 pytest --collect-only 统计用例数。
|
||||
|
||||
返回 key 格式:
|
||||
- tests/test_foo.py → "test_foo"
|
||||
- tests/automation/test_foo.py → "automation/test_foo"
|
||||
"""
|
||||
counts: Dict[str, int] = defaultdict(int)
|
||||
|
||||
# 1. 找到所有测试文件(含子目录)
|
||||
test_files: List[Path] = []
|
||||
tests_dir = backend_dir / "tests"
|
||||
for tf in tests_dir.glob("test_*.py"):
|
||||
test_files.append(tf)
|
||||
for sub in tests_dir.iterdir():
|
||||
if sub.is_dir() and (sub / "__init__.py").exists():
|
||||
for tf in sub.glob("test_*.py"):
|
||||
test_files.append(tf)
|
||||
|
||||
# 2. 构造精确 pytest 命令(每个文件单独 collect,避开 collection 错误传染)
|
||||
for tf in sorted(test_files):
|
||||
rel = tf.relative_to(tests_dir).with_suffix("") # e.g. "test_foo" 或 "automation/test_foo"
|
||||
key = str(rel).replace("\\", "/")
|
||||
stem = tf.stem
|
||||
if stem in ignore_files and "/" not in key:
|
||||
counts[key] = 0
|
||||
continue
|
||||
cmd = [
|
||||
sys.executable, "-m", "pytest",
|
||||
str(tf.relative_to(backend_dir)),
|
||||
"--collect-only", "-q", "--no-header",
|
||||
]
|
||||
try:
|
||||
r = subprocess.run(
|
||||
cmd, cwd=backend_dir, capture_output=True, text=True,
|
||||
encoding="utf-8", timeout=60,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
counts[key] = -1
|
||||
continue
|
||||
# 解析 "X tests collected" 或 "X test collected"
|
||||
text = r.stdout + r.stderr
|
||||
m = re.search(r"(\d+)\s+tests?\s+collected", text)
|
||||
if m:
|
||||
counts[key] = int(m.group(1))
|
||||
else:
|
||||
counts[key] = 0 # collection 错误 → 0
|
||||
|
||||
# 3. 标 ignore 文件为 0
|
||||
for f in ignore_files:
|
||||
if f not in counts:
|
||||
counts[f] = 0
|
||||
return dict(counts)
|
||||
|
||||
|
||||
def collect_errors(backend_dir: Path) -> List[Dict[str, str]]:
|
||||
"""收集 collection 错误(import 损坏等)。"""
|
||||
result = subprocess.run(
|
||||
PYTEST_COLLECT_CMD,
|
||||
cwd=backend_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
errors = []
|
||||
for line in (result.stdout + result.stderr).splitlines():
|
||||
if "ERROR collecting" in line or "ImportError" in line or "ModuleNotFoundError" in line:
|
||||
errors.append({"line": line.strip()})
|
||||
return errors
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 文件级说明(人工维护)
|
||||
# -----------------------------------------------------------------------------
|
||||
NOTE_BY_FILE: Dict[str, str] = {
|
||||
# 鉴权
|
||||
"test_admin_ip_whitelist": "后台 IP 白名单",
|
||||
"test_admin_user": "后台管理员账号",
|
||||
"test_agents_auth": "坐席鉴权",
|
||||
"test_rbac_verification": "角色权限矩阵",
|
||||
"test_auth_qrcode": "扫码登录",
|
||||
"test_auth_unified": "统一认证(AUTH-03)",
|
||||
"test_mfa": "MFA 旧路径(已迁移到 auth-otp)",
|
||||
"test_otp_bind_flow": "OTP 绑定流程",
|
||||
"test_otp_unified": "OTP 统一认证",
|
||||
"test_token_anomaly": "Token 异常检测",
|
||||
"test_sensitive_words_auth": "**本次新增**(BUG-通用-004 鉴权补漏回归)",
|
||||
"test_high_risk_guard": "高危操作 OTP 守卫",
|
||||
# 自动化
|
||||
"test_automation_executor": "自动化执行器",
|
||||
"test_automation_intent_router": "意图路由器",
|
||||
"test_automation_services": "自动化服务层",
|
||||
"test_automation_session_manager": "会话管理器",
|
||||
"test_automation_unit": "单元测试",
|
||||
"test_automation_approval": "自动化审批",
|
||||
# automation 子目录
|
||||
"automation/test_global_intent_router": "全局意图路由(子目录)",
|
||||
"automation/test_information_item_service": "信息项服务(子目录)",
|
||||
"automation/test_session_manager_pause_resume": "会话暂停恢复(子目录)",
|
||||
"automation/test_timeout_cleaner": "超时清理(子目录)",
|
||||
# 审批
|
||||
"test_approval_state_machine": "审批状态机",
|
||||
"test_approval_webhook": "审批 Webhook",
|
||||
"test_asset_approval_urge": "资产审批催办",
|
||||
"test_approval_detect_intent": "⚠️ collection 错(import 损坏,预存在)",
|
||||
# 消息
|
||||
"test_message_router": "消息路由器",
|
||||
"test_message_dedup": "消息去重",
|
||||
"test_message_experience": "体验优化",
|
||||
"test_message_id_type_bug": "UUID 类型 bug 回归",
|
||||
"test_messages_uuid": "UUID 消息",
|
||||
"test_nontext_message": "非文本消息(图片/语音)",
|
||||
"test_voice_asr": "语音转文字(百度 ASR)",
|
||||
"test_routing_service": "路由服务(含 broken link)",
|
||||
"test_ai_reply_gate": "AI 回复闸门",
|
||||
"test_ai_reply_mode_api": "AI 回复模式 API",
|
||||
# 协作
|
||||
"test_conversation_grab": "会话抢占(摇人)",
|
||||
"test_conversations": "会话管理",
|
||||
"test_collaboration": "协作功能",
|
||||
"test_employee_history_messages": "员工历史消息",
|
||||
"test_employee_profile_service": "员工档案",
|
||||
"test_invite_link": "邀请链接",
|
||||
"test_invite_participant": "邀请参与者",
|
||||
"test_invite_status": "邀请状态",
|
||||
# 知识
|
||||
"test_knowledge_iteration": "知识迭代",
|
||||
"test_bugfix_ki_suggestions": "KI 建议 bug 修复回归",
|
||||
"test_confidence_gate": "置信度闸门",
|
||||
"test_topic_detector": "主题检测",
|
||||
# 审核
|
||||
"test_content_moderation": "敏感词命中(中台核心)",
|
||||
# 诊断
|
||||
"test_triage": "分诊",
|
||||
"test_recommend_progress_service": "推荐进度",
|
||||
"test_scoring_service": "评分服务",
|
||||
"test_wingman": "Wingman 模块",
|
||||
"test_wingman_service": "Wingman 服务层",
|
||||
"test_match_keywords_email": "关键词匹配",
|
||||
"test_evaluation": "评估",
|
||||
# 企微
|
||||
"test_wecom_crypto": "企微加解密",
|
||||
"test_h5_oauth": "H5 OAuth",
|
||||
"test_h5_shake": "H5 摇一摇",
|
||||
"test_h5_asset_pipeline": "H5 资产推送管道",
|
||||
"test_h5_mask_option_select": "H5 蒙层/选项/选择",
|
||||
# 基础
|
||||
"test_env_gating": "环境隔离",
|
||||
"test_exclusion": "排除规则",
|
||||
"test_p2_p3": "P2/P3 优先级",
|
||||
"test_response_contract": "响应契约",
|
||||
"test_api_basic": "API 基础冒烟",
|
||||
"test_backend_observer": "后端观察者",
|
||||
"test_avatar_service": "头像服务",
|
||||
"test_meetingroom": "会议室预定",
|
||||
"test_org_tree": "组织架构树",
|
||||
"test_neo4j_client": "Neo4j 客户端",
|
||||
"test_todo_integration": "待办集成",
|
||||
"test_byod": "⚠️ collection 错(预存在)",
|
||||
# WS
|
||||
"test_ws_endpoints": "WebSocket 端点",
|
||||
"test_ws_push_to_employee": "WS 推送给员工",
|
||||
# Tier 1
|
||||
"test_tier1_api": "Tier 1 API 通用",
|
||||
"test_agents": "坐席基础",
|
||||
# 快速回复
|
||||
"test_quick_rules": "⚠️ collection 错(预存在)",
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Markdown 渲染
|
||||
# -----------------------------------------------------------------------------
|
||||
def render_markdown(
|
||||
file_case_counts: Dict[str, int],
|
||||
topics: Dict[str, List[Tuple[str, int]]],
|
||||
errors: List[Dict[str, str]],
|
||||
total_pct: float,
|
||||
passed: int,
|
||||
failed: int,
|
||||
xfailed: int,
|
||||
) -> str:
|
||||
"""生成测试套件全景.md 的 Markdown 内容。"""
|
||||
total_files = len(file_case_counts)
|
||||
total_tests = sum(c for c in file_case_counts.values() if c > 0)
|
||||
error_files = sum(1 for c in file_case_counts.values() if c == 0)
|
||||
|
||||
lines = [
|
||||
"# 自动化测试套件全景(src/backend/tests/)",
|
||||
"",
|
||||
"> **版本**: v1.0 | **生效日期**: 2026-08-05 | **维护人**: Duckula",
|
||||
"> 自动生成工具:`scripts/test_inventory.py`",
|
||||
"> (手动重跑:`cd src/backend && python ../../scripts/test_inventory.py --baseline 1311/88/4`)",
|
||||
"",
|
||||
"本索引按 pytest 测试文件列出后端测试套件全貌,**与 `docs/03-测试文档/04-版本测试报告/` 互补**:",
|
||||
"- 文档报告(TC/TR/BUG):按需求/版本/缺陷分类",
|
||||
"- 本文档(测试套件全景):按 pytest 文件 + 主题分类",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## 📊 总览数据",
|
||||
"",
|
||||
f"- **测试文件总数**: {total_files}(含子目录)",
|
||||
f"- **测试用例总数**: {total_tests}",
|
||||
f"- **预存在 collection 错误**: {error_files} 个文件(已忽略,不计入用例)",
|
||||
f"- **最近一次 baseline**: {passed} passed / {failed} failed / {xfailed} xfailed (≈ {total_pct:.1f}%)",
|
||||
f"- **关联规范**: [`测试方法论指南.md`](./测试方法论指南.md)(Tier 0 / Tier 1 / Tier 2 / E2E 四层)",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## 📁 按主题分类(pytest 文件 → 主题)",
|
||||
"",
|
||||
]
|
||||
|
||||
for topic in TOPIC_ORDER:
|
||||
if topic not in topics or not topics[topic]:
|
||||
continue
|
||||
items = sorted(topics[topic], key=lambda x: -x[1])
|
||||
valid_count = sum(c for _, c in items if c > 0)
|
||||
err_count = sum(1 for _, c in items if c == 0)
|
||||
lines.append(f"### {topic}({len(items)} 文件 / {valid_count} 用例" + (f" / {err_count} collection 错" if err_count else "") + ")")
|
||||
lines.append("")
|
||||
lines.append("| 测试文件 | 用例数 | 说明 |")
|
||||
lines.append("|----------|--------|------|")
|
||||
for key, count in items:
|
||||
note = NOTE_BY_FILE.get(key, "")
|
||||
if count == 0:
|
||||
lines.append(f"| `tests/{key}.py` | ⚠️ 0 | {note or 'collection 错误'} |")
|
||||
else:
|
||||
lines.append(f"| `tests/{key}.py` | {count} | {note} |")
|
||||
lines.append("")
|
||||
|
||||
# 预存在错误
|
||||
if errors:
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("## ⚠️ 预存在 collection 错误(已知,跳过)")
|
||||
lines.append("")
|
||||
lines.append("| 错误信息 |")
|
||||
lines.append("|----------|")
|
||||
for e in errors[:10]:
|
||||
lines.append(f"| `{e['line'][:120]}` |")
|
||||
lines.append("")
|
||||
|
||||
lines.extend([
|
||||
"---",
|
||||
"",
|
||||
"## 🚀 常用命令",
|
||||
"",
|
||||
"```bash",
|
||||
"cd src/backend",
|
||||
"",
|
||||
"# 跑全部有效套件(跳过 3 个预损坏)",
|
||||
"python -m pytest tests/ -q \\",
|
||||
" --ignore=tests/test_approval_detect_intent.py \\",
|
||||
" --ignore=tests/test_byod.py \\",
|
||||
" --ignore=tests/test_quick_rules.py",
|
||||
"",
|
||||
"# 跑指定主题(按文件名匹配)",
|
||||
"python -m pytest tests/test_sensitive_words_auth.py tests/test_content_moderation.py -v",
|
||||
"",
|
||||
"# 跑本次新增的鉴权回归(最关键)",
|
||||
"python -m pytest tests/test_sensitive_words_auth.py -v",
|
||||
"",
|
||||
"# 重新生成本文档",
|
||||
"python ../../scripts/test_inventory.py --baseline 1311/88/4",
|
||||
"```",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## 🔗 关联文档",
|
||||
"",
|
||||
"- 测试方法论指南: `./测试方法论指南.md`",
|
||||
"- 测试分类索引: `../README.md`",
|
||||
"- TC(功能测试用例): `../03-功能测试用例/`",
|
||||
"- TR(版本测试报告): `../04-版本测试报告/`",
|
||||
"- BUG 单: `../05-缺陷单/`",
|
||||
"- 整改记录 #12: `../../04-运维文档/部署运维/00-文档规范化整改记录.md`",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
f"> 最后更新:2026-08-05 | 自动生成 baseline:{passed}/{total_tests} 通过",
|
||||
])
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Main
|
||||
# -----------------------------------------------------------------------------
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="生成测试套件全景 Markdown")
|
||||
parser.add_argument(
|
||||
"--backend-dir",
|
||||
default="src/backend",
|
||||
help="backend 目录相对路径(默认 src/backend)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default="docs/03-测试文档/00-测试规范/测试套件全景.md",
|
||||
help="输出 Markdown 文件路径",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ignore",
|
||||
nargs="*",
|
||||
default=["test_approval_detect_intent", "test_byod", "test_quick_rules"],
|
||||
help="预存在 collection 错误,跳过 collect",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="仅打印不写文件",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--baseline",
|
||||
type=str,
|
||||
default=None,
|
||||
help="最近一次 pytest 跑分('passed/failed/xfailed',如 '1311/88/4')",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
backend_dir = repo_root / args.backend_dir
|
||||
output_path = repo_root / args.output
|
||||
|
||||
if not backend_dir.exists():
|
||||
print(f"❌ backend 目录不存在: {backend_dir}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"🔍 扫描 {backend_dir}/tests/ ...", file=sys.stderr)
|
||||
counts = collect_per_file(backend_dir, args.ignore)
|
||||
print(f" 找到 {len(counts)} 个测试文件(耗时取决于 collection 速度)", file=sys.stderr)
|
||||
|
||||
topics: Dict[str, List[Tuple[str, int]]] = defaultdict(list)
|
||||
for key, count in sorted(counts.items()):
|
||||
topic = classify(key)
|
||||
topics[topic].append((key, count))
|
||||
|
||||
errors = collect_errors(backend_dir)
|
||||
|
||||
if args.baseline:
|
||||
parts = args.baseline.split("/")
|
||||
passed, failed, xfailed = int(parts[0]), int(parts[1]), int(parts[2])
|
||||
else:
|
||||
passed, failed, xfailed = 0, 0, 0
|
||||
total = passed + failed + xfailed
|
||||
total_pct = 100 * passed / total if total else 0
|
||||
|
||||
markdown = render_markdown(counts, topics, errors, total_pct, passed, failed, xfailed)
|
||||
|
||||
if args.dry_run:
|
||||
print(markdown)
|
||||
return 0
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(markdown, encoding="utf-8")
|
||||
valid_total = sum(c for c in counts.values() if c > 0)
|
||||
print(f"✅ 已写入 {output_path}", file=sys.stderr)
|
||||
print(f" {len(counts)} 文件 / {valid_total} 用例 / {sum(1 for c in counts.values() if c == 0)} collection 错", file=sys.stderr)
|
||||
print(f" baseline: {passed} passed / {failed} failed / {xfailed} xfailed", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
# 测试快速规则 API - 验证 priority 修复
|
||||
set -e
|
||||
|
||||
echo "===== 1. 登录获取 admin token ====="
|
||||
LOGIN_RESP=$(curl -s -X POST http://localhost:8090/api/admin/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"admin","password":"admin123"}')
|
||||
echo "Login response: $LOGIN_RESP"
|
||||
|
||||
TOKEN=$(echo "$LOGIN_RESP" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("data",{}).get("token",""))')
|
||||
echo "Token length: ${#TOKEN}"
|
||||
|
||||
echo ""
|
||||
echo "===== 2. GET /admin/quick-rules?rule_type=routing_target ====="
|
||||
curl -s 'http://localhost:8090/api/admin/quick-rules?rule_type=routing_target&page=1&size=20' \
|
||||
-H "Authorization: Bearer ${TOKEN}" | python3 -m json.tool
|
||||
|
||||
echo ""
|
||||
echo "===== 3. GET /admin/quick-rules?rule_type=routing_target&category=行政 ====="
|
||||
curl -s --get 'http://localhost:8090/api/admin/quick-rules' \
|
||||
--data-urlencode 'rule_type=routing_target' \
|
||||
--data-urlencode 'category=行政' \
|
||||
--data-urlencode 'page=1' \
|
||||
--data-urlencode 'size=20' \
|
||||
-H "Authorization: Bearer ${TOKEN}" | python3 -m json.tool
|
||||
|
||||
echo ""
|
||||
echo "===== 4. GET /admin/quick-rules?rule_type=routing_target&category=人力资源 ====="
|
||||
curl -s --get 'http://localhost:8090/api/admin/quick-rules' \
|
||||
--data-urlencode 'rule_type=routing_target' \
|
||||
--data-urlencode 'category=人力资源' \
|
||||
--data-urlencode 'page=1' \
|
||||
--data-urlencode 'size=20' \
|
||||
-H "Authorization: Bearer ${TOKEN}" | python3 -m json.tool
|
||||
|
||||
echo ""
|
||||
echo "===== 5. GET /admin/quick-rules?rule_type=routing_target&category=IT服务 ====="
|
||||
curl -s --get 'http://localhost:8090/api/admin/quick-rules' \
|
||||
--data-urlencode 'rule_type=routing_target' \
|
||||
--data-urlencode 'category=IT服务' \
|
||||
--data-urlencode 'page=1' \
|
||||
--data-urlencode 'size=20' \
|
||||
-H "Authorization: Bearer ${TOKEN}" | python3 -m json.tool
|
||||
|
||||
echo ""
|
||||
echo "===== 6. 后端日志 - 确认无 ValidationError ====="
|
||||
docker logs --tail 100 wecom_it_backend 2>&1 | grep -iE 'quickrule|pydantic|validation|priority' | tail -20
|
||||
|
||||
echo ""
|
||||
echo "===== 验收完成 ====="
|
||||
Reference in New Issue
Block a user