feat: 2026-07-11 全量更新 - 代办集成+会议室预定+知识迭代修复+UI统一+Bug修复
== 已部署上线 (9项) == - 代办事项真实数据源集成 (企微审批API 8bug修复链) - H5/坐席端 Logo样式统一+绿色背景 - 视频引导页修复 (localStorage key v2) - 坐席端 v9 Vue版本修复 (ElMessage._context) - 截图按钮 v10 修复 (getDisplayMedia user gesture) - 扫码样式恢复+H5扫码登录跳转修复 - H5截图快捷键提示 == 代码完成待部署 (3项) == - 知识迭代3Bug修复 (#8 POST端点/#7 MERGE幂等/#6 过期检查) - 会议室预定-小鱼易联终端 (40文件, 40/40测试通过) - IT资产升级审批推送 (asset_service.py) == 需求文档 (2项) == - 坐席端AI辅助消息框-PRD (4项新功能确认) - 坐席端布局优化建议 v2.0 (7天计划) == 新增文档 == - 日报-2026-07-11.md - 知识迭代Bug修复报告-20260711.md - 会议室预定-部署指南.md - CHANGELOG.md 更新 == 测试 == - test_todo_integration.py: 40/40 - test_meetingroom.py: 40/40 - test_bugfix_ki_suggestions.py: 21/21
This commit is contained in:
@@ -1,115 +0,0 @@
|
||||
"""分析智能IT助手数据报表"""
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.load_workbook(
|
||||
r"C:\Users\simon\Downloads\智能IT助手数据报表_20260526T051947.xlsx",
|
||||
data_only=True
|
||||
)
|
||||
|
||||
# 统计查询结果sheet的数据分布
|
||||
hit_count = 0
|
||||
miss_count = 0
|
||||
pending_count = 0
|
||||
processed_count = 0
|
||||
none_count = 0
|
||||
transfer_yes = 0
|
||||
transfer_no = 0
|
||||
intervene_yes = 0
|
||||
intervene_no = 0
|
||||
total_records = 0
|
||||
time_range = []
|
||||
miss_questions = []
|
||||
hit_questions = [] # 命中的问题样例
|
||||
transfer_questions = [] # 转人工的问题样例
|
||||
operator_count = {} # 操作用户统计
|
||||
|
||||
for sheet_name in wb.sheetnames:
|
||||
if not sheet_name.startswith("查询结果"):
|
||||
continue
|
||||
ws = wb[sheet_name]
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
if row[0] is None:
|
||||
continue
|
||||
total_records += 1
|
||||
|
||||
# 知识库命中
|
||||
hit_val = str(row[5]).strip() if row[5] else ""
|
||||
if hit_val == "命中":
|
||||
hit_count += 1
|
||||
elif hit_val == "未命中":
|
||||
miss_count += 1
|
||||
if row[2]:
|
||||
miss_questions.append(str(row[2])[:80])
|
||||
elif hit_val == "待处理":
|
||||
pending_count += 1
|
||||
elif hit_val == "已处理":
|
||||
processed_count += 1
|
||||
elif hit_val == "无":
|
||||
none_count += 1
|
||||
|
||||
# 转人工
|
||||
transfer_val = str(row[6]).strip() if row[6] else ""
|
||||
if transfer_val == "是":
|
||||
transfer_yes += 1
|
||||
if row[2]:
|
||||
transfer_questions.append(str(row[2])[:80])
|
||||
elif transfer_val == "否":
|
||||
transfer_no += 1
|
||||
|
||||
# 人工主动介入
|
||||
intervene_val = str(row[7]).strip() if row[7] else ""
|
||||
if intervene_val == "是":
|
||||
intervene_yes += 1
|
||||
elif intervene_val == "否":
|
||||
intervene_no += 1
|
||||
|
||||
# 操作用户
|
||||
op_val = str(row[9]).strip() if len(row) > 9 and row[9] else ""
|
||||
if op_val and op_val != "无":
|
||||
operator_count[op_val] = operator_count.get(op_val, 0) + 1
|
||||
|
||||
# 时间范围
|
||||
if row[4]:
|
||||
time_str = str(row[4])[:10]
|
||||
if time_str.startswith("2026"):
|
||||
time_range.append(time_str)
|
||||
|
||||
print("=== 数据总量 ===")
|
||||
print(f"总记录数: {total_records}")
|
||||
if time_range:
|
||||
print(f"时间范围: {min(time_range)} ~ {max(time_range)}")
|
||||
|
||||
print("\n=== 知识库命中分布 ===")
|
||||
print(f"命中: {hit_count} ({hit_count/total_records*100:.1f}%)")
|
||||
print(f"未命中: {miss_count} ({miss_count/total_records*100:.1f}%)")
|
||||
print(f"待处理: {pending_count} ({pending_count/total_records*100:.1f}%)")
|
||||
print(f"已处理: {processed_count} ({processed_count/total_records*100:.1f}%)")
|
||||
print(f"无(人工导入): {none_count} ({none_count/total_records*100:.1f}%)")
|
||||
|
||||
print("\n=== 转人工分布 ===")
|
||||
print(f"转人工-是: {transfer_yes} ({transfer_yes/total_records*100:.1f}%)")
|
||||
print(f"转人工-否: {transfer_no} ({transfer_no/total_records*100:.1f}%)")
|
||||
|
||||
print("\n=== 人工主动介入分布 ===")
|
||||
print(f"人工介入-是: {intervene_yes}")
|
||||
print(f"人工介入-否: {intervene_no}")
|
||||
|
||||
print("\n=== 操作用户统计 ===")
|
||||
for op, cnt in sorted(operator_count.items(), key=lambda x: -x[1]):
|
||||
print(f" {op}: {cnt}")
|
||||
|
||||
print(f"\n=== 未命中问题样例 (前30条,共{len(miss_questions)}条) ===")
|
||||
for i, q in enumerate(miss_questions[:30]):
|
||||
print(f" {i+1}. {q}")
|
||||
|
||||
print(f"\n=== 转人工问题样例 (前20条,共{len(transfer_questions)}条) ===")
|
||||
for i, q in enumerate(transfer_questions[:20]):
|
||||
print(f" {i+1}. {q}")
|
||||
|
||||
# 计算自助解决率
|
||||
# 自助解决 = 命中且未转人工
|
||||
auto_resolve = hit_count - transfer_yes # 近似值,因为有些命中但转人工
|
||||
print(f"\n=== 自助解决率估算 ===")
|
||||
print(f"AI命中且未转人工(估算): {hit_count - transfer_yes}")
|
||||
print(f"自助解决率(估算): {(hit_count - transfer_yes)/total_records*100:.1f}%")
|
||||
print(f"官方统计自助解决率: 70.2%")
|
||||
Binary file not shown.
@@ -1,84 +0,0 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# /itdesk/ 500 错误诊断脚本
|
||||
# 在生产服务器 10.90.5.110 上跑(PuTTY 登录后):
|
||||
# cd /opt/wecom-it-desk
|
||||
# bash diagnose-500.sh > /tmp/diag.log 2>&1
|
||||
# cat /tmp/diag.log
|
||||
# =============================================================================
|
||||
|
||||
echo "========== 1. 容器状态 =========="
|
||||
docker compose ps
|
||||
|
||||
echo ""
|
||||
echo "========== 2. /opt/wecom-it-desk 目录结构 =========="
|
||||
ls -la /opt/wecom-it-desk/ 2>&1 | head -20
|
||||
echo "--- frontend-h5/dist ---"
|
||||
ls -la /opt/wecom-it-desk/frontend-h5/dist/ 2>&1 | head -10
|
||||
echo "--- frontend-h5/dist/assets ---"
|
||||
ls -la /opt/wecom-it-desk/frontend-h5/dist/assets/ 2>&1 | head -10
|
||||
echo "--- frontend-agent/dist/assets ---"
|
||||
ls -la /opt/wecom-it-desk/frontend-agent/dist/assets/ 2>&1 | head -10
|
||||
echo "--- frontend-portal/dist/assets ---"
|
||||
ls -la /opt/wecom-it-desk/frontend-portal/dist/assets/ 2>&1 | head -10
|
||||
echo "--- frontend-admin/dist/assets ---"
|
||||
ls -la /opt/wecom-it-desk/frontend-admin/dist/assets/ 2>&1 | head -10
|
||||
|
||||
echo ""
|
||||
echo "========== 3. nginx 容器内文件检查 =========="
|
||||
docker compose exec nginx ls -la /usr/share/nginx/html/ 2>&1 | head -20
|
||||
echo "--- /usr/share/nginx/html/itdesk ---"
|
||||
docker compose exec nginx ls -la /usr/share/nginx/html/itdesk/ 2>&1 | head -10
|
||||
echo "--- /usr/share/nginx/html/itdesk/assets ---"
|
||||
docker compose exec nginx ls -la /usr/share/nginx/html/itdesk/assets/ 2>&1 | head -10
|
||||
echo "--- /usr/share/nginx/ssl/ ---"
|
||||
docker compose exec nginx ls -la /etc/nginx/ssl/ 2>&1 | head -10
|
||||
|
||||
echo ""
|
||||
echo "========== 4. nginx 配置实际生效版本(头部 50 行)=========="
|
||||
docker compose exec nginx cat /etc/nginx/nginx.conf 2>&1 | head -50
|
||||
|
||||
echo ""
|
||||
echo "========== 5. nginx 容器端口监听 =========="
|
||||
docker compose exec nginx netstat -tlnp 2>&1 | head -10
|
||||
echo "(没 netstat 用 ss:)"
|
||||
docker compose exec nginx ss -tlnp 2>&1 | head -10
|
||||
|
||||
echo ""
|
||||
echo "========== 6. 直接 curl 测试各路径 =========="
|
||||
echo "--- /itdesk/ (容器内) ---"
|
||||
docker compose exec nginx curl -ksI https://localhost/itdesk/ 2>&1 | head -20
|
||||
echo "--- /itdesk/ (容器外主机 443) ---"
|
||||
curl -ksI https://localhost:443/itdesk/ 2>&1 | head -20
|
||||
echo "--- /itportal/ ---"
|
||||
curl -ksI https://localhost:443/itportal/ 2>&1 | head -20
|
||||
echo "--- /itdesk/assets/ (探 404) ---"
|
||||
curl -ksI https://localhost:443/itdesk/assets/ 2>&1 | head -20
|
||||
|
||||
echo ""
|
||||
echo "========== 7. 主机实际 URL 域名 =========="
|
||||
curl -ksI https://itsupport.servyou.com.cn/itdesk/ 2>&1 | head -20
|
||||
echo "---"
|
||||
curl -ksI https://itsupport.servyou.com.cn/itportal/ 2>&1 | head -20
|
||||
echo "---"
|
||||
curl -ksI https://itsupport.servyou.com.cn/itagent/ 2>&1 | head -20
|
||||
echo "---"
|
||||
curl -ksI https://itsupport.servyou.com.cn/itadmin/ 2>&1 | head -20
|
||||
|
||||
echo ""
|
||||
echo "========== 8. nginx access log 最近 30 行(找 500 请求)=========="
|
||||
docker compose exec nginx tail -30 /var/log/nginx/access.log 2>&1
|
||||
echo ""
|
||||
echo "========== 9. nginx error log 最近 30 行 =========="
|
||||
docker compose exec nginx tail -30 /var/log/nginx/error.log 2>&1
|
||||
|
||||
echo ""
|
||||
echo "========== 10. backend 容器健康 =========="
|
||||
docker compose ps backend
|
||||
echo "--- backend health endpoint ---"
|
||||
docker compose exec backend curl -ks http://localhost:8000/api/health 2>&1 | head -5
|
||||
|
||||
echo ""
|
||||
echo "========== 11. 看一下后端访问 /api/h5/me (H5 启动时会调)=========="
|
||||
echo "--- /api/h5/me 无 token ---"
|
||||
curl -ks -i -X GET https://itsupport.servyou.com.cn/api/h5/me 2>&1 | head -10
|
||||
@@ -1 +0,0 @@
|
||||
IyEvYmluL2Jhc2gKc2V0ICtlICAgIyBjb2xsZWN0IGV2ZXJ5dGhpbmcsIGRvbid0IGJhaWwKCmVjaG8gJyMjIyMjIyMjIyMjIyBTVEVQIDE6IExvY2F0ZSBwcm9qZWN0IGRpcmVjdG9yeSAjIyMjIyMjIyMjIyMnCmNkIC9vcHQvd2Vjb20taXQtZGVzayAyPiYxCmVjaG8gIkN1cnJlbnQgZGlyOiAkKHB3ZCkiCmxzIC1sYSBkb2NrZXItY29tcG9zZS55bWwgMj4mMQplY2hvICcnCgplY2hvICcjIyMjIyMjIyMjIyMgU1RFUCAyOiBEaWFnbm9zZSAoUkVBRC1PTkxZKSAjIyMjIyMjIyMjIyMnCmVjaG8gJy0tLSBBbGwgd2Vjb21faXRfIGNvbnRhaW5lcnMgLS0tJwpkb2NrZXIgcHMgLWEgLS1mb3JtYXQgInRhYmxlIHt7Lk5hbWVzfX1cdHt7LlN0YXR1c319IiB8IGdyZXAgLUUgIndlY29tX2l0X3xOQU1FUyIKZWNobyAnJwplY2hvICctLS0gRGlzayBzcGFjZSAtLS0nCmRmIC1oIC9vcHQgMj4mMQplY2hvICcnCmVjaG8gJy0tLSBiYWNrZW5kIGxhc3QgNjAgbG9nIGxpbmVzIC0tLScKZG9ja2VyIGxvZ3Mgd2Vjb21faXRfYmFja2VuZCAtLXRhaWwgNjAgMj4mMQplY2hvICcnCmVjaG8gJy0tLSBiYWNrZW5kIGludGVybmFsIGhlYWx0aCBjaGVjayAtLS0nCmRvY2tlciBleGVjIHdlY29tX2l0X2JhY2tlbmQgY3VybCAtcyAtbyAtIC13ICJcbkhUVFBfQ09ERTogJXtodHRwX2NvZGV9XG4iIC0tbWF4LXRpbWUgNSBodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoIDI+JjEKZWNobyAnJwoKZWNobyAnIyMjIyMjIyMjIyMjIFNURVAgMzogUmVzdGFydCBmcm9tIGNvcnJlY3QgZGlyZWN0b3J5
|
||||
@@ -1 +0,0 @@
|
||||
ICMjIyMjIyMjIyMjIycKY2QgL29wdC93ZWNvbS1pdC1kZXNrCmRvY2tlciBjb21wb3NlIHVwIC1kIDI+JjEKZWNobyAnJwplY2hvICdXYWl0aW5nIDE1cyBmb3Igc2VydmljZXMgdG8gc3RhYmlsaXplLi4uJwpzbGVlcCAxNQplY2hvICcnCmVjaG8gJy0tLSBDb250YWluZXJzIGFmdGVyIHJlc3RhcnQgLS0tJwpkb2NrZXIgcHMgLWEgLS1mb3JtYXQgInRhYmxlIHt7Lk5hbWVzfX1cdHt7LlN0YXR1c319IiB8IGdyZXAgLUUgIndlY29tX2l0X3xOQU1FUyIKZWNobyAnJwoKZWNobyAnIyMjIyMjIyMjIyMjIFNURVAgNDogRW5kLXRvLWVuZCB2ZXJpZmljYXRpb24gIyMjIyMjIyMjIyMjJwplY2hvICctLS0gYmFja2VuZCAvaGVhbHRoIC0tLScKY3VybCAtcyAtbyAtIC13ICJcbkhUVFBfQ09ERTogJXtodHRwX2NvZGV9XG4iIC0tbWF4LXRpbWUgNSBodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoCmVjaG8gJycKZWNobyAnLS0tIG5naW54IHJvdXRlcyAoZXhwZWN0IDIwMC8zMDEvMzAyKSAtLS0nCmZvciBwYXRoIGluIC8gL2l0YWdlbnQvIC9pdGg1LyAvaXRhZG1pbi87IGRvCiAgY29kZT0kKGN1cmwgLXMgLW8gL2Rldi9udWxsIC13ICIle2h0dHBfY29kZX0iIC0tbWF4LXRpbWUgNSAiaHR0cDovL2xvY2FsaG9zdCR7cGF0aH0iKQogIGVjaG8gIiAgJHBhdGggLT4gSFRUUCAkY29kZSIKZG9uZQplY2hvICcnCmVjaG8gJyMjIyMjIyMjIyMjIyBET05FICMjIyMjIyMjIyMjIycKZWNobyAnUGFzdGUgQUxMIG91dHB1dCBhYm92ZSBiYWNrIHRvIENsYXVkZSBmb3IgZGlhZ25vc2lzJwo=
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/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'
|
||||
@@ -1,248 +0,0 @@
|
||||
"""
|
||||
从 IT支持知识库.docx 提取结构化内容,导入到 quick_reply_templates 表。
|
||||
|
||||
映射规则:
|
||||
- Heading 1 → 文档一级分类(用于确定 category 字段)
|
||||
- Heading 2 → 文档二级子分类(合并到 title 前缀)
|
||||
- Heading 3 → 快速回复模板标题(title 字段)
|
||||
- Normal → 模板内容(content 字段,多段合并)
|
||||
|
||||
Category 映射:
|
||||
办公电脑 → 硬件
|
||||
软件工具 → 软件
|
||||
办公设备 → 硬件
|
||||
办公网络 → 网络
|
||||
终端安全 → 安全
|
||||
资产管理 → 通用
|
||||
其他业务 → 通用
|
||||
"""
|
||||
import uuid
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from docx import Document
|
||||
|
||||
# =========================================================================
|
||||
# 配置
|
||||
# =========================================================================
|
||||
DOCX_PATH = r"C:\Users\simon\Downloads\IT支持知识库2026-4-24.docx"
|
||||
DB_PATH = r"C:\Users\simon\wecom_it_smart_desk\backend\it_smart_desk.db"
|
||||
|
||||
# Heading 1 → quick_reply category 映射
|
||||
CATEGORY_MAP = {
|
||||
"办公电脑": "硬件",
|
||||
"软件工具": "软件",
|
||||
"办公设备": "硬件",
|
||||
"办公网络": "网络",
|
||||
"终端安全": "安全", # 终端安全涉及账号/密码/安全策略,用"安全"
|
||||
"资产管理": "通用",
|
||||
"其他业务": "通用",
|
||||
}
|
||||
|
||||
def extract_items(doc):
|
||||
"""从文档中提取所有 Heading 3 条目,包含完整的层级上下文。
|
||||
|
||||
遍历流程:
|
||||
1. 记录当前的 Heading 1、Heading 2(建立层级上下文)
|
||||
2. 遇到 Heading 3 → 开始收集该条目下的所有 Normal 段落
|
||||
3. 遇到下个 Heading 3 或 Heading 2/Heading 1 → 条目结束,存储
|
||||
|
||||
Returns:
|
||||
List[dict]: 每个条目含 h1/h2/h3/content 字段
|
||||
"""
|
||||
items = []
|
||||
current_h1 = None
|
||||
current_h2 = None
|
||||
current_item = None # 当前正在收集的条目 {h1, h2, h3, content_lines}
|
||||
|
||||
for para in doc.paragraphs:
|
||||
text = para.text.strip()
|
||||
if not text:
|
||||
continue
|
||||
style = para.style.name if para.style else ""
|
||||
|
||||
# Heading 1 → 更新一级分类,结束当前条目
|
||||
if style == "Heading 1":
|
||||
current_h1 = text
|
||||
if current_item and current_item["content_lines"]:
|
||||
items.append(finalize_item(current_item))
|
||||
current_item = None
|
||||
continue
|
||||
|
||||
# Heading 2 → 更新二级分类,结束当前条目
|
||||
if style == "Heading 2":
|
||||
current_h2 = text
|
||||
if current_item and current_item["content_lines"]:
|
||||
items.append(finalize_item(current_item))
|
||||
current_item = None
|
||||
continue
|
||||
|
||||
# Heading 3 → 新条目开始,保存上一个,创建新的
|
||||
if style == "Heading 3":
|
||||
if current_item and current_item["content_lines"]:
|
||||
items.append(finalize_item(current_item))
|
||||
current_item = {
|
||||
"h1": current_h1,
|
||||
"h2": current_h2,
|
||||
"h3": text,
|
||||
"content_lines": [],
|
||||
}
|
||||
continue
|
||||
|
||||
# Normal / Normal (Web) 等 → 条目内容
|
||||
if current_item:
|
||||
current_item["content_lines"].append(text)
|
||||
|
||||
# 最后一个条目
|
||||
if current_item and current_item["content_lines"]:
|
||||
items.append(finalize_item(current_item))
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def finalize_item(item):
|
||||
"""将 content_lines 合并为单个 content 字符串,并做格式化处理。"""
|
||||
# 合并内容,用换行分隔多段
|
||||
content = "\n".join(item["content_lines"])
|
||||
# 清理多余空白
|
||||
content = content.strip()
|
||||
item["content"] = content
|
||||
del item["content_lines"]
|
||||
return item
|
||||
|
||||
|
||||
def map_category(h1):
|
||||
"""将文档一级分类映射到快速回复的 category 字段。"""
|
||||
for key, cat in CATEGORY_MAP.items():
|
||||
if key in h1 if h1 else False:
|
||||
return cat
|
||||
return "通用"
|
||||
|
||||
|
||||
def to_title(item):
|
||||
"""生成模板标题:Heading 3 本身作为标题。
|
||||
|
||||
如果 Heading 3 文字太长(>128 字符),截断。
|
||||
"""
|
||||
title = item["h3"]
|
||||
if len(title) > 128:
|
||||
title = title[:125] + "..."
|
||||
return title
|
||||
|
||||
|
||||
def check_existing(conn):
|
||||
"""检查是否已有数据,避免重复导入。"""
|
||||
count = conn.execute("SELECT COUNT(*) FROM quick_reply_templates").fetchone()[0]
|
||||
return count
|
||||
|
||||
|
||||
def import_items(conn, items):
|
||||
"""将条目批量插入 quick_reply_templates 表。"""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
|
||||
for i, item in enumerate(items):
|
||||
category = map_category(item["h1"])
|
||||
title = to_title(item)
|
||||
content = item["content"]
|
||||
|
||||
# 跳过内容过短的条目(可能是误抓的标题)
|
||||
if len(content) < 10:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# 检查是否重复(同标题+同分类)
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM quick_reply_templates WHERE title = ? AND category = ?",
|
||||
(title, category)
|
||||
).fetchone()
|
||||
if existing:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
template_id = str(uuid.uuid4())
|
||||
sort_order = i # 保持文档原始顺序
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO quick_reply_templates
|
||||
(id, category, title, content, variables, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
template_id,
|
||||
category,
|
||||
title,
|
||||
content,
|
||||
"[]", # variables: 空列表(JSON 字符串)
|
||||
sort_order,
|
||||
now,
|
||||
now,
|
||||
)
|
||||
)
|
||||
inserted += 1
|
||||
|
||||
conn.commit()
|
||||
return inserted, skipped
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 主流程
|
||||
# =========================================================================
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print(" IT 支持知识库 → 快速回复模板 导入工具")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. 读取文档
|
||||
print(f"\n[1/4] 读取文档: {DOCX_PATH}")
|
||||
doc = Document(DOCX_PATH)
|
||||
|
||||
# 2. 提取条目
|
||||
print("[2/4] 提取结构化条目...")
|
||||
items = extract_items(doc)
|
||||
print(f" → 共提取 {len(items)} 个 Heading 3 条目")
|
||||
|
||||
# 统计分类分布
|
||||
cat_counts = {}
|
||||
for item in items:
|
||||
cat = map_category(item["h1"])
|
||||
cat_counts[cat] = cat_counts.get(cat, 0) + 1
|
||||
print(f" → 分类分布: {dict(sorted(cat_counts.items()))}")
|
||||
|
||||
# 3. 连接数据库
|
||||
print(f"\n[3/4] 连接数据库: {DB_PATH}")
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
existing = check_existing(conn)
|
||||
if existing > 0:
|
||||
print(f" ⚠ 数据库中已有 {existing} 条记录,将跳过重复标题。")
|
||||
|
||||
# 4. 批量导入
|
||||
print("[4/4] 导入数据...")
|
||||
inserted, skipped = import_items(conn, items)
|
||||
|
||||
# 统计
|
||||
total = conn.execute("SELECT COUNT(*) FROM quick_reply_templates").fetchone()[0]
|
||||
by_cat = conn.execute(
|
||||
"SELECT category, COUNT(*) FROM quick_reply_templates GROUP BY category ORDER BY category"
|
||||
).fetchall()
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" 导入完成!")
|
||||
print(f" → 新增: {inserted} 条")
|
||||
print(f" → 跳过(重复/内容过短): {skipped} 条")
|
||||
print(f" → 数据库总计: {total} 条")
|
||||
print(f"\n 按分类统计:")
|
||||
for cat, cnt in by_cat:
|
||||
print(f" {cat}: {cnt}")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
# 展示前 5 条样例
|
||||
print("\n 【导入样例】(前 5 条)")
|
||||
samples = conn.execute(
|
||||
"SELECT category, title, substr(content, 1, 80) FROM quick_reply_templates ORDER BY sort_order LIMIT 5"
|
||||
).fetchall()
|
||||
for cat, title, snippet in samples:
|
||||
print(f" [{cat}] {title}")
|
||||
print(f" {snippet}...")
|
||||
print()
|
||||
|
||||
conn.close()
|
||||
@@ -1,22 +0,0 @@
|
||||
"""安装项目依赖"""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
pip = r"C:\Users\simon\.workbuddy\binaries\python\envs\default\Scripts\pip.exe"
|
||||
|
||||
packages = [
|
||||
"sqlalchemy==2.0.31", "pytest", "pytest-asyncio", "aiosqlite",
|
||||
"fastapi==0.111.0", "httpx==0.27.0", "cryptography==42.0.8",
|
||||
"python-dotenv==1.0.1", "pydantic==2.7.4", "pydantic-settings==2.3.4",
|
||||
"redis==5.0.7", "uvicorn==0.30.1", "python-multipart==0.0.9"
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
[pip, "install"] + packages,
|
||||
capture_output=True, text=True
|
||||
)
|
||||
|
||||
output = f"EXIT: {result.returncode}\n\nSTDOUT (last 2000):\n{result.stdout[-2000:]}\n\nSTDERR (last 1000):\n{result.stderr[-1000:]}"
|
||||
|
||||
with open(r"C:\Users\simon\WorkBuddy\2026-05-21-16-57-26\install_results.txt", "w", encoding="utf-8") as f:
|
||||
f.write(output)
|
||||
@@ -1,31 +0,0 @@
|
||||
"""安装项目依赖 - 最简方式"""
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
|
||||
pip = r"C:\Users\simon\.workbuddy\binaries\python\envs\default\Scripts\pip.exe"
|
||||
|
||||
# 逐步安装,避免一次性安装大包失败
|
||||
packages_list = [
|
||||
# 核心依赖(有预编译 wheel)
|
||||
["sqlalchemy>=2.0.0", "aiosqlite", "pytest", "pytest-asyncio"],
|
||||
# FastAPI 及其依赖
|
||||
["fastapi", "uvicorn", "python-multipart"],
|
||||
# 其他
|
||||
["httpx", "python-dotenv", "redis", "cryptography"],
|
||||
]
|
||||
|
||||
results = []
|
||||
for i, packages in enumerate(packages_list):
|
||||
result = subprocess.run(
|
||||
[pip, "install"] + packages,
|
||||
capture_output=True, text=True
|
||||
)
|
||||
results.append(f"--- Batch {i+1} (exit: {result.returncode}) ---")
|
||||
results.append(result.stdout[-500:] if result.stdout else "(no stdout)")
|
||||
if result.stderr:
|
||||
results.append(result.stderr[-300:])
|
||||
|
||||
output = "\n".join(results)
|
||||
with open(r"C:\Users\simon\WorkBuddy\2026-05-21-16-57-26\install_batch.txt", "w", encoding="utf-8") as f:
|
||||
f.write(output)
|
||||
@@ -1,30 +0,0 @@
|
||||
"""安装依赖 - 仅使用预编译wheel"""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
pip = r"C:\Users\simon\.workbuddy\binaries\python\envs\default\Scripts\pip.exe"
|
||||
|
||||
# 第1步:安装不依赖 pydantic-core 编译的包
|
||||
result1 = subprocess.run(
|
||||
[pip, "install", "--only-binary", ":all:",
|
||||
"sqlalchemy>=2.0.0", "aiosqlite", "pytest", "pytest-asyncio",
|
||||
"httpx", "python-dotenv", "redis", "cryptography",
|
||||
"uvicorn", "python-multipart"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
|
||||
# 第2步:安装 fastapi(会拉取 pydantic)
|
||||
result2 = subprocess.run(
|
||||
[pip, "install", "--only-binary", ":all:", "fastapi"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
|
||||
output = f"=== Step 1 (exit: {result1.returncode}) ===\n"
|
||||
output += f"STDOUT: {result1.stdout[-1500:]}\n"
|
||||
output += f"STDERR: {result1.stderr[-800:]}\n\n"
|
||||
output += f"=== Step 2 (exit: {result2.returncode}) ===\n"
|
||||
output += f"STDOUT: {result2.stdout[-1500:]}\n"
|
||||
output += f"STDERR: {result2.stderr[-800:]}"
|
||||
|
||||
with open(r"C:\Users\simon\WorkBuddy\2026-05-21-16-57-26\install_wheels.txt", "w", encoding="utf-8") as f:
|
||||
f.write(output)
|
||||
@@ -1,64 +0,0 @@
|
||||
# NAS full /volume1/ scan with sudo (English-only)
|
||||
# Step 1: User runs `sudo -v` first (password stays local, never enters Claude)
|
||||
# Step 2: This script reuses that 15-min sudo session
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
$outputFile = "$PSScriptRoot\nas_volumes.txt"
|
||||
|
||||
chcp 65001 | Out-Null
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
Write-Host "===================================" -ForegroundColor Cyan
|
||||
Write-Host " NAS Full Scan (with sudo)" -ForegroundColor Cyan
|
||||
Write-Host "===================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "PREREQUISITE: open another terminal and run:" -ForegroundColor Yellow
|
||||
Write-Host " ssh simon@100.85.152.112" -ForegroundColor White
|
||||
Write-Host " sudo -v <- enter simon's password here, password NOT sent to Claude" -ForegroundColor White
|
||||
Write-Host " (keep that SSH session open for 15 min, sudo session cached)" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Read-Host "Press Enter after you have done sudo -v above"
|
||||
|
||||
# Force allocation so sudo can read password from terminal if needed
|
||||
$cmd = @"
|
||||
sudo bash <<'NAS_EOF'
|
||||
echo '===== [1] All top-level entries under /volume1/ ====='
|
||||
ls -la /volume1/ 2>&1
|
||||
echo ''
|
||||
echo '===== [2] Direct children sizes (1-3 minutes) ====='
|
||||
du -sh /volume1/*/ 2>/dev/null | sort -rh
|
||||
echo ''
|
||||
echo '===== [3] Disk space ====='
|
||||
df -h /volume1 2>&1 | head -3
|
||||
echo ''
|
||||
echo '===== [4] /volume1/homes/ ====='
|
||||
ls -la /volume1/homes/ 2>&1 | head -20
|
||||
echo ''
|
||||
echo '===== [5] /volume1/homes/simon/ top dirs by size ====='
|
||||
du -sh /volume1/homes/simon/*/ 2>/dev/null | sort -rh | head -20
|
||||
echo ''
|
||||
echo '===== [6] /volume1/docker/ top dirs by size (likely big) ====='
|
||||
du -sh /volume1/docker/*/ 2>/dev/null | sort -rh | head -20
|
||||
echo ''
|
||||
echo '===== [7] Largest top-level dirs (top 15) ====='
|
||||
du -sh /volume1/* 2>/dev/null | sort -rh | head -15
|
||||
echo ''
|
||||
echo '===== [8] Mounts / storage pools ====='
|
||||
mount | grep -E 'volume|tank' 2>&1 | head -10
|
||||
echo ''
|
||||
echo '===== DONE ====='
|
||||
NAS_EOF
|
||||
"@
|
||||
|
||||
ssh -t 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 ""
|
||||
Write-Host "Please paste the ENTIRE contents of nas_volumes.txt back" -ForegroundColor Yellow
|
||||
Write-Host "(or just tell me which top-level dir is largest)" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Read-Host "Press Enter to close"
|
||||
@@ -1,43 +0,0 @@
|
||||
# NAS /volume1/ directory listing scan script
|
||||
# Double-click or run in PowerShell, lists all top-level dirs with sizes
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
$outputFile = "$PSScriptRoot\nas_volumes.txt"
|
||||
|
||||
# Force UTF-8 console encoding for SSH output
|
||||
chcp 65001 | Out-Null
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
Write-Host "===================================" -ForegroundColor Cyan
|
||||
Write-Host " NAS /volume1/ Directory Scan" -ForegroundColor Cyan
|
||||
Write-Host "===================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Scanning... du on large dirs may take 1-3 minutes" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
$cmd = @"
|
||||
echo '===== Top-level dirs in /volume1/ ====='
|
||||
ls -la /volume1/ 2>&1 | grep -v '^total'
|
||||
echo ''
|
||||
echo '===== Size by dir (largest first, may take minutes) ====='
|
||||
du -sh /volume1/*/ 2>/dev/null | sort -rh
|
||||
echo ''
|
||||
echo '===== /volume1/homes/ ====='
|
||||
ls -la /volume1/homes/ 2>/dev/null | head -20
|
||||
echo ''
|
||||
echo '===== /volume1/homes/simon/ content ====='
|
||||
ls -la /volume1/homes/simon/ 2>/dev/null | head -30
|
||||
du -sh /volume1/homes/simon/*/ 2>/dev/null | sort -rh | head -20
|
||||
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"
|
||||
@@ -1,7 +0,0 @@
|
||||
fp='/opt/wecom-it-desk/nginx/nginx.conf'
|
||||
c=open(fp).read()
|
||||
p='\n # 真实 IP 还原(2026-06-15 v0.5.1)\n set_real_ip_from 10.0.0.0/8;\n set_real_ip_from 172.16.0.0/12;\n set_real_ip_from 192.168.0.0/16;\n set_real_ip_from 10.212.0.0/16;\n real_ip_header X-Forwarded-For;\n real_ip_recursive on;\n'
|
||||
o='error_log /var/log/nginx/error.log warn;'
|
||||
n=c.replace(o,o+p,1)
|
||||
open(fp,'w').write(n)
|
||||
print('patched, +%d bytes'%(len(n)-len(c)))
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
修复 docker-compose.yml 中 REDIS_URL 默认值的 URL-encode 问题。
|
||||
|
||||
背景:
|
||||
- 2026-06-15 故障:REDIS_URL 里的密码 R3d!s@2026#Secure 含 @ # 两个 URL 保留字符,
|
||||
Python redis 库解析时密码被截断成 R3d!s,导致鉴权失败 → Redis 连接超时
|
||||
- 修复:把密码 URL-encode(@→%40, #→%23, !→%21)
|
||||
- ⚠️ 关键: 只 URL-encode REDIS_URL 那行的密码,redis-server --requirepass
|
||||
和 healthcheck 的 redis-cli -a 都必须保持**明文**(否则 Redis 容器启动失败/鉴权失败)
|
||||
|
||||
用法:
|
||||
sudo python3 /tmp/patch-redis-url.py
|
||||
"""
|
||||
fp = '/opt/wecom-it-desk/docker-compose.yml'
|
||||
|
||||
# 读取当前内容
|
||||
with open(fp, encoding='utf-8') as f:
|
||||
c = f.read()
|
||||
|
||||
# 旧值(精确匹配 REDIS_URL 那一行,带 redis://://@ 上下文,避免误改 --requirepass)
|
||||
old = 'REDIS_URL=redis://:${REDIS_PASSWORD:-R3d!s@2026#Secure}@redis:6379/0'
|
||||
|
||||
# 新值:URL-encode 后的密码(!→%21, @→%40, #→%23),仅 REDIS_URL 这一行
|
||||
new = 'REDIS_URL=redis://:${REDIS_PASSWORD:-R3d%21s%402026%23Secure}@redis:6379/0'
|
||||
|
||||
# 检查是否已经修复过(幂等性)
|
||||
if old in c:
|
||||
print('[OK] 检测到未编码版本,准备修复...')
|
||||
c2 = c.replace(old, new, 1) # 只替换第一次出现(更安全)
|
||||
with open(fp, 'w', encoding='utf-8') as f:
|
||||
f.write(c2)
|
||||
delta = len(c2) - len(c)
|
||||
print('[OK] 已修复:REDIS_URL 行的密码已 URL-encode')
|
||||
print(f'[OK] 文件长度变化:{delta:+d} 字节')
|
||||
elif new in c:
|
||||
print('[OK] 已经修复过,跳过(幂等性 OK)')
|
||||
else:
|
||||
print('[ERROR] 既没找到旧值也没找到新值,请人工检查 docker-compose.yml')
|
||||
print('---')
|
||||
print('当前 REDIS_URL 相关配置:')
|
||||
import subprocess
|
||||
result = subprocess.run(['grep', '-n', 'REDIS_URL\\|REDIS_PASSWORD\\|--requirepass\\|redis-cli', fp],
|
||||
capture_output=True, text=True)
|
||||
print(result.stdout)
|
||||
exit(1)
|
||||
|
||||
# 验证:确保 --requirepass 和 redis-cli 仍然是明文(没被误改)
|
||||
import subprocess
|
||||
result = subprocess.run(['grep', '-nE', 'REDIS_URL|--requirepass|redis-cli.*-a', fp],
|
||||
capture_output=True, text=True)
|
||||
print('---')
|
||||
print('当前所有密码相关行(应只有 REDIS_URL 一行是 URL-encoded,其他保持明文):')
|
||||
print(result.stdout)
|
||||
@@ -1,20 +0,0 @@
|
||||
fp = '/opt/wecom-it-desk/nginx/nginx.conf'
|
||||
with open(fp) as f:
|
||||
c = f.read()
|
||||
patch = '''
|
||||
# ------------------------------------------------------------------
|
||||
# 真实 IP 还原(2026-06-15 v0.5.1 修复)
|
||||
# ------------------------------------------------------------------
|
||||
set_real_ip_from 10.0.0.0/8;
|
||||
set_real_ip_from 172.16.0.0/12;
|
||||
set_real_ip_from 192.168.0.0/16;
|
||||
set_real_ip_from 10.212.0.0/16;
|
||||
real_ip_header X-Forwarded-For;
|
||||
real_ip_recursive on;
|
||||
'''
|
||||
old = 'error_log /var/log/nginx/error.log warn;'
|
||||
new = old + patch
|
||||
new_c = c.replace(old, new, 1)
|
||||
with open(fp, 'w') as f:
|
||||
f.write(new_c)
|
||||
print('patched, +{} bytes'.format(len(new_c) - len(c)))
|
||||
@@ -1,70 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,15 +0,0 @@
|
||||
import docx
|
||||
|
||||
doc = docx.Document(r'C:\Users\simon\Downloads\IT智能在线咨询交接文档-tm.docx')
|
||||
|
||||
with open(r'C:\Users\simon\wecom_it_smart_desk\docs\现有系统交接文档内容.txt', 'w', encoding='utf-8') as f:
|
||||
for para in doc.paragraphs:
|
||||
if para.text.strip():
|
||||
f.write(para.text + '\n')
|
||||
for table in doc.tables:
|
||||
f.write('\n=== TABLE ===\n')
|
||||
for row in table.rows:
|
||||
cells = [cell.text for cell in row.cells]
|
||||
f.write(' | '.join(cells) + '\n')
|
||||
|
||||
print("Done")
|
||||
@@ -1,36 +0,0 @@
|
||||
"""运行测试 - 写入项目目录"""
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
|
||||
# 清除所有 __pycache__
|
||||
backend_dir = r"C:\Users\simon\wecom_it_smart_desk\backend"
|
||||
for root, dirs, files in os.walk(backend_dir):
|
||||
for d in dirs:
|
||||
if d == "__pycache__":
|
||||
try:
|
||||
shutil.rmtree(os.path.join(root, d))
|
||||
except:
|
||||
pass
|
||||
|
||||
python = r"C:\Users\simon\.workbuddy\binaries\python\envs\default\Scripts\python.exe"
|
||||
env = {**os.environ, "PYTHONPATH": backend_dir, "PYTHONDONTWRITEBYTECODE": "1"}
|
||||
|
||||
result = subprocess.run(
|
||||
[python, "-B", "-m", "pytest",
|
||||
os.path.join(backend_dir, "tests"),
|
||||
"-v", "--tb=short", "-x", "--cache-clear"],
|
||||
capture_output=True, text=True,
|
||||
env=env,
|
||||
cwd=backend_dir
|
||||
)
|
||||
|
||||
# 写到项目 deliverables 目录
|
||||
out_dir = r"C:\Users\simon\wecom_it_smart_desk\deliverables"
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
output = f"EXIT CODE: {result.returncode}\n\n=== STDOUT ===\n{result.stdout}\n\n=== STDERR ===\n{result.stderr}\n"
|
||||
|
||||
with open(os.path.join(out_dir, "test_results.txt"), "w", encoding="utf-8") as f:
|
||||
f.write(output)
|
||||
@@ -1,333 +0,0 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 本地回调模拟器
|
||||
# =============================================================================
|
||||
# 模拟企微服务器的回调行为,在本地测试消息收发流程:
|
||||
# 1. GET /api/wecom/callback — 验证 URL 有效性
|
||||
# 2. POST /api/wecom/callback — 推送加密消息
|
||||
#
|
||||
# 使用方式:
|
||||
# 1. 先启动后端(端口 8001)
|
||||
# 2. 运行本脚本:python simulate_wecom.py
|
||||
#
|
||||
# 脚本会自动:
|
||||
# - 使用项目的 WecomCrypto 类加密消息
|
||||
# - 发送 GET 请求验证回调 URL
|
||||
# - 发送 POST 请求模拟员工消息
|
||||
# - 检查后端是否正确处理了消息
|
||||
# =============================================================================
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import string
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
# ---- Windows 终端编码兼容 ----
|
||||
# Windows 默认 GBK 编码无法输出 emoji,强制切换为 UTF-8
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---- 配置 ----
|
||||
# 与 backend/.env 中的配置保持一致
|
||||
CORP_ID = "wwa8c87970b2011f41"
|
||||
TOKEN = "pjJquWIacCdCh9NeQ5axMrTPtQPk"
|
||||
ENCODING_AES_KEY = "42k7Ty5qCQHMwsAlzQdZ9tw87rPxUy0IgqcQgktUjJu"
|
||||
AGENT_ID = "1000133"
|
||||
|
||||
# 后端地址
|
||||
BACKEND_URL = "http://localhost:8000"
|
||||
|
||||
|
||||
# ---- AES 加解密(从项目 WecomCrypto 类提取的核心逻辑)----
|
||||
import base64
|
||||
|
||||
|
||||
class SimpleWecomCrypto:
|
||||
"""简化版企微加解密工具,用于本地测试。"""
|
||||
|
||||
def __init__(self, token: str, encoding_aes_key: str, corp_id: str):
|
||||
self.token = token
|
||||
self.aes_key = base64.b64decode(encoding_aes_key + "=") # 补位 "="
|
||||
self.iv = self.aes_key[:16]
|
||||
self.corp_id = corp_id
|
||||
|
||||
def generate_signature(self, timestamp: str, nonce: str, encrypt: str) -> str:
|
||||
"""生成签名:SHA1(sort([token, timestamp, nonce, encrypt]))"""
|
||||
sort_list = sorted([self.token, timestamp, nonce, encrypt])
|
||||
concat_str = "".join(sort_list)
|
||||
return hashlib.sha1(concat_str.encode("utf-8")).hexdigest()
|
||||
|
||||
def encrypt(self, plaintext: str) -> str:
|
||||
"""AES-CBC 加密(模拟企微加密消息)"""
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
# 构造明文:random(16) + msg_len(4) + msg + corp_id
|
||||
random_str = secrets.token_bytes(16)
|
||||
msg_bytes = plaintext.encode("utf-8")
|
||||
msg_len = struct.pack("!I", len(msg_bytes))
|
||||
corp_id_bytes = self.corp_id.encode("utf-8")
|
||||
plaintext_data = random_str + msg_len + msg_bytes + corp_id_bytes
|
||||
|
||||
# PKCS7 填充(块大小 32)
|
||||
block_size = 32
|
||||
pad_len = block_size - (len(plaintext_data) % block_size)
|
||||
plaintext_data += bytes([pad_len] * pad_len)
|
||||
|
||||
# AES-CBC 加密
|
||||
cipher = Cipher(algorithms.AES(self.aes_key), modes.CBC(self.iv), backend=default_backend())
|
||||
encryptor = cipher.encryptor()
|
||||
encrypted_data = encryptor.update(plaintext_data) + encryptor.finalize()
|
||||
|
||||
return base64.b64encode(encrypted_data).decode("utf-8")
|
||||
|
||||
def build_encrypted_xml(self, reply_content: str) -> str:
|
||||
"""构造企微回调的加密 XML 消息体"""
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(10))
|
||||
|
||||
# 加密消息内容
|
||||
encrypt = self.encrypt(reply_content)
|
||||
|
||||
# 生成签名
|
||||
signature = self.generate_signature(timestamp, nonce, encrypt)
|
||||
|
||||
# 构造 XML(与企微推送格式一致)
|
||||
xml = (
|
||||
f"<xml>"
|
||||
f"<ToUserName><![CDATA[{self.corp_id}]]></ToUserName>"
|
||||
f"<AgentID>{AGENT_ID}</AgentID>"
|
||||
f"<Encrypt><![CDATA[{encrypt}]]></Encrypt>"
|
||||
f"</xml>"
|
||||
)
|
||||
|
||||
return xml, signature, timestamp, nonce
|
||||
|
||||
|
||||
# ---- 测试函数 ----
|
||||
|
||||
|
||||
def test_verify_url(crypto: SimpleWecomCrypto):
|
||||
"""测试 1:验证回调 URL(模拟企微 GET 请求)"""
|
||||
print("=" * 60)
|
||||
print("测试 1: 验证回调 URL(GET /api/wecom/callback)")
|
||||
print("=" * 60)
|
||||
|
||||
# 模拟企微验证 URL 时的 GET 参数
|
||||
# 企微会生成一个随机的 echostr 并加密
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(10))
|
||||
|
||||
# 加密一个随机的 echostr 明文
|
||||
echostr_plaintext = str(secrets.randbelow(10**10)) # 随机数字
|
||||
echostr_encrypted = crypto.encrypt(echostr_plaintext)
|
||||
|
||||
# 生成签名
|
||||
signature = crypto.generate_signature(timestamp, nonce, echostr_encrypted)
|
||||
|
||||
# 构造 GET 请求 URL
|
||||
url = (
|
||||
f"{BACKEND_URL}/api/wecom/callback?"
|
||||
f"msg_signature={urllib.parse.quote(signature)}&"
|
||||
f"timestamp={urllib.parse.quote(timestamp)}&"
|
||||
f"nonce={urllib.parse.quote(nonce)}&"
|
||||
f"echostr={urllib.parse.quote(echostr_encrypted)}"
|
||||
)
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
status = resp.status
|
||||
body = resp.read().decode("utf-8")
|
||||
|
||||
# 验证:后端应返回解密后的 echostr 明文
|
||||
if body.strip() == echostr_plaintext:
|
||||
print(f" [OK] 验证成功! 状态码={status}")
|
||||
print(f" 返回的 echostr 明文匹配: {body[:50]}")
|
||||
else:
|
||||
print(f" [FAIL] 验证失败! 状态码={status}")
|
||||
print(f" 期望: {echostr_plaintext}")
|
||||
print(f" 实际: {body[:200]}")
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f" [FAIL] HTTP 错误: {e.code}")
|
||||
print(f" 响应: {e.read().decode('utf-8')[:200]}")
|
||||
except Exception as e:
|
||||
print(f" [FAIL] 请求失败: {e}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def test_receive_message(crypto: SimpleWecomCrypto, employee_id: str, content: str):
|
||||
"""测试 2:发送员工消息(模拟企微 POST 回调)"""
|
||||
print("=" * 60)
|
||||
print(f"测试 2: 接收员工消息(POST /api/wecom/callback)")
|
||||
print(f" 员工: {employee_id}")
|
||||
print(f" 消息: {content}")
|
||||
print("=" * 60)
|
||||
|
||||
# 构造企微消息 XML(明文)
|
||||
message_xml = (
|
||||
f"<xml>"
|
||||
f"<ToUserName><![CDATA[{CORP_ID}]]></ToUserName>"
|
||||
f"<FromUserName><![CDATA[{employee_id}]]></FromUserName>"
|
||||
f"<CreateTime>{int(time.time())}</CreateTime>"
|
||||
f"<MsgType><![CDATA[text]]></MsgType>"
|
||||
f"<Content><![CDATA[{content}]]></Content>"
|
||||
f"<MsgId>{secrets.randbelow(10**18)}</MsgId>"
|
||||
f"<AgentID>{AGENT_ID}</AgentID>"
|
||||
f"</xml>"
|
||||
)
|
||||
|
||||
# 加密消息 XML
|
||||
encrypted_xml, signature, timestamp, nonce = crypto.build_encrypted_xml(message_xml)
|
||||
|
||||
# 构造 POST 请求 URL(带签名参数)
|
||||
url = (
|
||||
f"{BACKEND_URL}/api/wecom/callback?"
|
||||
f"msg_signature={urllib.parse.quote(signature)}&"
|
||||
f"timestamp={urllib.parse.quote(timestamp)}&"
|
||||
f"nonce={urllib.parse.quote(nonce)}"
|
||||
)
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=encrypted_xml.encode("utf-8"),
|
||||
headers={"Content-Type": "application/xml"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
status = resp.status
|
||||
body = resp.read().decode("utf-8")
|
||||
|
||||
# 企微期望后端返回 "success"
|
||||
if body.strip() == "success":
|
||||
print(f" [OK] 消息处理成功! 状态码={status}")
|
||||
print(f" 后端返回: {body}")
|
||||
else:
|
||||
print(f" [WARN] 状态码={status}")
|
||||
print(f" 返回内容: {body[:200]}")
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f" [FAIL] HTTP 错误: {e.code}")
|
||||
print(f" 响应: {e.read().decode('utf-8')[:200]}")
|
||||
except Exception as e:
|
||||
print(f" [FAIL] 请求失败: {e}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def test_check_conversation(employee_id: str):
|
||||
"""测试 3:检查消息是否成功创建了会话"""
|
||||
print("=" * 60)
|
||||
print(f"测试 3: 检查会话列表(GET /api/agents)")
|
||||
print("=" * 60)
|
||||
|
||||
url = f"{BACKEND_URL}/api/agents"
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
body = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
items = body.get("data", {}).get("items", [])
|
||||
print(f" 当前坐席数量: {len(items)}")
|
||||
|
||||
# 查询会话列表(需要 token,暂时跳过详细验证)
|
||||
print(f" ✅ 后端 API 正常响应")
|
||||
|
||||
except Exception as e:
|
||||
print(f" [FAIL] 检查失败: {e}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def test_health():
|
||||
"""测试 0:后端健康检查"""
|
||||
print("=" * 60)
|
||||
print("测试 0: 后端健康检查")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(f"{BACKEND_URL}/health", method="GET")
|
||||
with urllib.request.urlopen(req, timeout=3) as resp:
|
||||
body = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
if body.get("status") == "ok":
|
||||
print(f" [OK] 后端正常运行")
|
||||
else:
|
||||
print(f" [WARN] 后端响应异常: {body}")
|
||||
except Exception as e:
|
||||
print(f" [FAIL] 后端不可达: {e}")
|
||||
print(f" 请先启动后端:cd backend && python -m uvicorn app.main:app --host 0.0.0.0 --port 8001")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
# ---- 主程序 ----
|
||||
|
||||
|
||||
def main():
|
||||
print()
|
||||
print("[工具] 企微回调本地模拟器")
|
||||
print(f" 后端地址: {BACKEND_URL}")
|
||||
print(f" Corp ID: {CORP_ID}")
|
||||
print(f" Agent ID: {AGENT_ID}")
|
||||
print()
|
||||
|
||||
# 初始化加解密工具
|
||||
try:
|
||||
crypto = SimpleWecomCrypto(
|
||||
token=TOKEN,
|
||||
encoding_aes_key=ENCODING_AES_KEY,
|
||||
corp_id=CORP_ID,
|
||||
)
|
||||
print("[OK] 加解密工具初始化成功")
|
||||
except Exception as e:
|
||||
print(f"[FAIL] 加解密工具初始化失败: {e}")
|
||||
print(" 请检查 ENCODING_AES_KEY 是否为 43 位字符串")
|
||||
return
|
||||
|
||||
print()
|
||||
|
||||
# 测试 0:健康检查
|
||||
test_health()
|
||||
|
||||
# 测试 1:验证回调 URL
|
||||
test_verify_url(crypto)
|
||||
|
||||
# 测试 2:发送员工消息(模拟 3 条不同场景的消息)
|
||||
test_messages = [
|
||||
("zhangsan", "我的电脑无法连接VPN,急!"),
|
||||
("lisi", "请帮我重置邮箱密码"),
|
||||
("wangwu", "我已经问了三次了还没人回复!!!"), # 会触发情绪标记
|
||||
]
|
||||
|
||||
for employee_id, content in test_messages:
|
||||
test_receive_message(crypto, employee_id, content)
|
||||
time.sleep(0.5) # 稍等一下避免请求太快
|
||||
|
||||
# 测试 3:检查会话
|
||||
test_check_conversation("zhangsan")
|
||||
|
||||
print("=" * 60)
|
||||
print("[完成] 模拟测试完成!")
|
||||
print()
|
||||
print("后续步骤:")
|
||||
print(" 1. 在坐席端 (http://localhost:5173) 查看是否有新会话")
|
||||
print(" 2. 在后端日志中查看消息路由和标记检测结果")
|
||||
print(" 3. 确认企微回调流程正常后,配置公网 URL 对接真实企微")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,512 +0,0 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 增强版本地回调模拟器
|
||||
# =============================================================================
|
||||
# 覆盖场景:
|
||||
# 场景 1: 普通咨询(单条消息,中性情绪)
|
||||
# 场景 2: 多轮对话 + 举手标记(同一员工连续追问后要求转人工)
|
||||
# 场景 3: 愤怒情绪 + 需介入标记(连发 4+ 条含愤怒关键词的消息)
|
||||
# 场景 4: 担忧情绪(含"担心"、"出错"等关键词)
|
||||
# 场景 5: 紧急 + 举手双重标记
|
||||
# 场景 6: 已结单后重新咨询(验证新会话创建)
|
||||
#
|
||||
# 使用方式:
|
||||
# 1. 先启动后端(端口 8000)
|
||||
# 2. 运行本脚本:python simulate_wecom_enhanced.py
|
||||
#
|
||||
# 期望结果:前端坐席工作台能看到 6 种不同标签组合的会话
|
||||
# =============================================================================
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import string
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
# ---- Windows 终端编码兼容 ----
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---- 配置 ----
|
||||
# 与 backend/.env 中的配置保持一致
|
||||
CORP_ID = "wwa8c87970b2011f41"
|
||||
TOKEN = "pjJquWIacCdCh9NeQ5axMrTPtQPk"
|
||||
ENCODING_AES_KEY = "42k7Ty5qCQHMwsAlzQdZ9tw87rPxUy0IgqcQgktUjJu"
|
||||
AGENT_ID = "1000133"
|
||||
|
||||
# 后端地址
|
||||
BACKEND_URL = "http://localhost:8000"
|
||||
|
||||
# ---- 场景定义 ----
|
||||
# 每个场景 = (场景名称, 消息列表)
|
||||
# 每条消息 = (employee_id, 消息内容, 预期触发的标记)
|
||||
SCENARIOS = [
|
||||
# ------------------------------------------------------------------
|
||||
# 场景 1: 普通咨询
|
||||
# 预期:无特殊标记,紧急度 1,情绪 neutral
|
||||
# ------------------------------------------------------------------
|
||||
(
|
||||
"场景1: 普通咨询",
|
||||
[
|
||||
("chenwei", "打印机无法连接", "neutral"),
|
||||
],
|
||||
),
|
||||
# ------------------------------------------------------------------
|
||||
# 场景 2: 多轮对话 + 举手标记
|
||||
# 同一员工连续 3 条消息后触发"转人工"
|
||||
# 预期:hand_raise=True, urgency >= 2
|
||||
# ------------------------------------------------------------------
|
||||
(
|
||||
"场景2: 多轮对话+举手",
|
||||
[
|
||||
("liuna", "我的OA系统登录不了", "neutral"),
|
||||
("liuna", "试了好几次都不行", "neutral"),
|
||||
("liuna", "转人工!我要找真人客服", "hand_raise"),
|
||||
],
|
||||
),
|
||||
# ------------------------------------------------------------------
|
||||
# 场景 3: 愤怒情绪 + 需介入标记
|
||||
# 同一员工连发 4 条含愤怒关键词的消息
|
||||
# 预期:emotion=angry, need_intervene=True(>3阈值), urgency >= 3
|
||||
# ------------------------------------------------------------------
|
||||
(
|
||||
"场景3: 愤怒+需介入",
|
||||
[
|
||||
("zhaoqiang", "网络又断了", "neutral"),
|
||||
("zhaoqiang", "这周已经断3次了,崩溃", "angry"),
|
||||
("zhaoqiang", "你们IT到底行不行?投诉", "angry"),
|
||||
("zhaoqiang", "再没人理我我真的要投诉了!!!", "angry"),
|
||||
],
|
||||
),
|
||||
# ------------------------------------------------------------------
|
||||
# 场景 4: 担忧情绪
|
||||
# 预期:emotion=worried, urgency >= 2
|
||||
# ------------------------------------------------------------------
|
||||
(
|
||||
"场景4: 担忧情绪",
|
||||
[
|
||||
("sunli", "担心我的邮件数据丢失了", "worried"),
|
||||
],
|
||||
),
|
||||
# ------------------------------------------------------------------
|
||||
# 场景 5: 紧急 + 举手双重标记
|
||||
# 预期:emotion=urgent, hand_raise=True, urgency >= 3
|
||||
# ------------------------------------------------------------------
|
||||
(
|
||||
"场景5: 紧急+举手",
|
||||
[
|
||||
("wanghai", "服务器马上要宕机了,紧急!转人工!", "urgent+hand_raise"),
|
||||
],
|
||||
),
|
||||
# ------------------------------------------------------------------
|
||||
# 场景 6: 已结单后重新咨询
|
||||
# 注意:此场景需要先有一个已结束的会话
|
||||
# 这里先发一条普通消息创建会话,脚本结束后可在前端手动结单
|
||||
# 然后用同一员工ID再发一条消息,验证新会话创建
|
||||
# 为了自动化测试,这里直接模拟两个阶段
|
||||
# ------------------------------------------------------------------
|
||||
(
|
||||
"场景6: 结单后重新咨询",
|
||||
[
|
||||
("zhoumei", "我的邮箱收不到邮件", "neutral"),
|
||||
# 中间需要前端手动结单,这里暂只发消息
|
||||
# 结单后可再运行一次此脚本验证新会话创建
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ---- AES 加解密(从项目 WecomCrypto 类提取的核心逻辑)----
|
||||
import base64
|
||||
|
||||
|
||||
class SimpleWecomCrypto:
|
||||
"""简化版企微加解密工具,用于本地测试。"""
|
||||
|
||||
def __init__(self, token: str, encoding_aes_key: str, corp_id: str):
|
||||
self.token = token
|
||||
self.aes_key = base64.b64decode(encoding_aes_key + "=") # 补位 "="
|
||||
self.iv = self.aes_key[:16]
|
||||
self.corp_id = corp_id
|
||||
|
||||
def generate_signature(self, timestamp: str, nonce: str, encrypt: str) -> str:
|
||||
"""生成签名:SHA1(sort([token, timestamp, nonce, encrypt]))"""
|
||||
sort_list = sorted([self.token, timestamp, nonce, encrypt])
|
||||
concat_str = "".join(sort_list)
|
||||
return hashlib.sha1(concat_str.encode("utf-8")).hexdigest()
|
||||
|
||||
def encrypt(self, plaintext: str) -> str:
|
||||
"""AES-CBC 加密(模拟企微加密消息)"""
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
# 构造明文:random(16) + msg_len(4) + msg + corp_id
|
||||
random_str = secrets.token_bytes(16)
|
||||
msg_bytes = plaintext.encode("utf-8")
|
||||
msg_len = struct.pack("!I", len(msg_bytes))
|
||||
corp_id_bytes = self.corp_id.encode("utf-8")
|
||||
plaintext_data = random_str + msg_len + msg_bytes + corp_id_bytes
|
||||
|
||||
# PKCS7 填充(块大小 32)
|
||||
block_size = 32
|
||||
pad_len = block_size - (len(plaintext_data) % block_size)
|
||||
plaintext_data += bytes([pad_len] * pad_len)
|
||||
|
||||
# AES-CBC 加密
|
||||
cipher = Cipher(algorithms.AES(self.aes_key), modes.CBC(self.iv), backend=default_backend())
|
||||
encryptor = cipher.encryptor()
|
||||
encrypted_data = encryptor.update(plaintext_data) + encryptor.finalize()
|
||||
|
||||
return base64.b64encode(encrypted_data).decode("utf-8")
|
||||
|
||||
def build_encrypted_xml(self, reply_content: str) -> str:
|
||||
"""构造企微回调的加密 XML 消息体"""
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(10))
|
||||
|
||||
# 加密消息内容
|
||||
encrypt = self.encrypt(reply_content)
|
||||
|
||||
# 生成签名
|
||||
signature = self.generate_signature(timestamp, nonce, encrypt)
|
||||
|
||||
# 构造 XML(与企微推送格式一致)
|
||||
xml = (
|
||||
f"<xml>"
|
||||
f"<ToUserName><![CDATA[{self.corp_id}]]></ToUserName>"
|
||||
f"<AgentID>{AGENT_ID}</AgentID>"
|
||||
f"<Encrypt><![CDATA[{encrypt}]]></Encrypt>"
|
||||
f"</xml>"
|
||||
)
|
||||
|
||||
return xml, signature, timestamp, nonce
|
||||
|
||||
|
||||
# ---- 测试函数 ----
|
||||
|
||||
|
||||
def test_health():
|
||||
"""测试 0:后端健康检查"""
|
||||
print("=" * 60)
|
||||
print("测试 0: 后端健康检查")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(f"{BACKEND_URL}/health", method="GET")
|
||||
with urllib.request.urlopen(req, timeout=3) as resp:
|
||||
body = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
if body.get("status") == "ok":
|
||||
print(" [OK] 后端正常运行")
|
||||
else:
|
||||
print(f" [WARN] 后端响应异常: {body}")
|
||||
except Exception as e:
|
||||
print(f" [FAIL] 后端不可达: {e}")
|
||||
print(" 请先启动后端:cd backend && python -m uvicorn app.main:app --host 0.0.0.0 --port 8000")
|
||||
return False
|
||||
|
||||
print()
|
||||
return True
|
||||
|
||||
|
||||
def test_verify_url(crypto: SimpleWecomCrypto):
|
||||
"""测试 1:验证回调 URL(模拟企微 GET 请求)"""
|
||||
print("=" * 60)
|
||||
print("测试 1: 验证回调 URL(GET /api/wecom/callback)")
|
||||
print("=" * 60)
|
||||
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(10))
|
||||
|
||||
echostr_plaintext = str(secrets.randbelow(10**10))
|
||||
echostr_encrypted = crypto.encrypt(echostr_plaintext)
|
||||
|
||||
signature = crypto.generate_signature(timestamp, nonce, echostr_encrypted)
|
||||
|
||||
url = (
|
||||
f"{BACKEND_URL}/api/wecom/callback?"
|
||||
f"msg_signature={urllib.parse.quote(signature)}&"
|
||||
f"timestamp={urllib.parse.quote(timestamp)}&"
|
||||
f"nonce={urllib.parse.quote(nonce)}&"
|
||||
f"echostr={urllib.parse.quote(echostr_encrypted)}"
|
||||
)
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
status = resp.status
|
||||
body = resp.read().decode("utf-8")
|
||||
|
||||
if body.strip() == echostr_plaintext:
|
||||
print(f" [OK] 验证成功! 状态码={status}")
|
||||
else:
|
||||
print(f" [FAIL] 验证失败! 期望={echostr_plaintext}, 实际={body[:200]}")
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f" [FAIL] HTTP 错误: {e.code}")
|
||||
except Exception as e:
|
||||
print(f" [FAIL] 请求失败: {e}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def send_message(crypto: SimpleWecomCrypto, employee_id: str, content: str) -> bool:
|
||||
"""发送单条员工消息(模拟企微 POST 回调)。
|
||||
|
||||
Args:
|
||||
crypto: 加密工具实例
|
||||
employee_id: 员工企微 UserID
|
||||
content: 消息内容
|
||||
|
||||
Returns:
|
||||
bool: 是否成功(后端返回 "success")
|
||||
"""
|
||||
# 构造企微消息 XML(明文)
|
||||
message_xml = (
|
||||
f"<xml>"
|
||||
f"<ToUserName><![CDATA[{CORP_ID}]]></ToUserName>"
|
||||
f"<FromUserName><![CDATA[{employee_id}]]></FromUserName>"
|
||||
f"<CreateTime>{int(time.time())}</CreateTime>"
|
||||
f"<MsgType><![CDATA[text]]></MsgType>"
|
||||
f"<Content><![CDATA[{content}]]></Content>"
|
||||
f"<MsgId>{secrets.randbelow(10**18)}</MsgId>"
|
||||
f"<AgentID>{AGENT_ID}</AgentID>"
|
||||
f"</xml>"
|
||||
)
|
||||
|
||||
# 加密消息 XML
|
||||
encrypted_xml, signature, timestamp, nonce = crypto.build_encrypted_xml(message_xml)
|
||||
|
||||
# 构造 POST 请求 URL(带签名参数)
|
||||
url = (
|
||||
f"{BACKEND_URL}/api/wecom/callback?"
|
||||
f"msg_signature={urllib.parse.quote(signature)}&"
|
||||
f"timestamp={urllib.parse.quote(timestamp)}&"
|
||||
f"nonce={urllib.parse.quote(nonce)}"
|
||||
)
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=encrypted_xml.encode("utf-8"),
|
||||
headers={"Content-Type": "application/xml"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
|
||||
if body.strip() == "success":
|
||||
print(f" [OK] {employee_id}: \"{content[:30]}{'...' if len(content) > 30 else ''}\"")
|
||||
return True
|
||||
else:
|
||||
print(f" [WARN] 返回非success: {body[:100]}")
|
||||
return False
|
||||
except urllib.error.HTTPError as e:
|
||||
err_body = e.read().decode("utf-8")[:200]
|
||||
print(f" [FAIL] HTTP {e.code}: {err_body}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" [FAIL] {e}")
|
||||
return False
|
||||
|
||||
|
||||
def run_scenario(crypto: SimpleWecomCrypto, scenario_name: str, messages: list):
|
||||
"""运行一个测试场景。
|
||||
|
||||
Args:
|
||||
crypto: 加密工具实例
|
||||
scenario_name: 场景名称
|
||||
messages: 消息列表 [(employee_id, content, expected_tag), ...]
|
||||
"""
|
||||
print("=" * 60)
|
||||
print(f" {scenario_name}")
|
||||
print("=" * 60)
|
||||
print(f" 消息数: {len(messages)}")
|
||||
print(f" 预期标记: {', '.join(set(m[2] for m in messages if m[2] != 'neutral'))}")
|
||||
print()
|
||||
|
||||
success_count = 0
|
||||
for employee_id, content, expected_tag in messages:
|
||||
ok = send_message(crypto, employee_id, content)
|
||||
if ok:
|
||||
success_count += 1
|
||||
# 消息间稍作延迟,模拟真实场景节奏
|
||||
time.sleep(0.3)
|
||||
|
||||
total = len(messages)
|
||||
status = "[OK]" if success_count == total else "[WARN]"
|
||||
print(f"\n {status} 场景结果: {success_count}/{total} 条消息成功")
|
||||
print()
|
||||
|
||||
|
||||
def verify_conversations():
|
||||
"""验证后端会话列表,展示各会话的标记和紧急度。"""
|
||||
print("=" * 60)
|
||||
print(" 验证: 查询会话列表(GET /api/conversations)")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(f"{BACKEND_URL}/api/conversations", method="GET")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
body = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
items = body.get("data", {}).get("items", [])
|
||||
if not items:
|
||||
print(" [WARN] 无会话数据")
|
||||
print()
|
||||
return
|
||||
|
||||
print(f" 共 {len(items)} 个会话:\n")
|
||||
|
||||
# 表头
|
||||
print(f" {'员工ID':<15} {'状态':<10} {'紧急度':<8} {'标记'}")
|
||||
print(f" {'-'*15} {'-'*10} {'-'*8} {'-'*30}")
|
||||
|
||||
for conv in items:
|
||||
emp_id = conv.get("employee_id", "?")
|
||||
status = conv.get("status", "?")
|
||||
urgency = conv.get("urgency_score", "?")
|
||||
tags = conv.get("tags", {})
|
||||
|
||||
# 解析标记为可读字符串
|
||||
tag_parts = []
|
||||
if tags.get("hand_raise"):
|
||||
tag_parts.append("[举手]")
|
||||
if tags.get("need_intervene"):
|
||||
tag_parts.append("[需介入]")
|
||||
emotion = tags.get("emotion", "")
|
||||
if emotion and emotion != "neutral":
|
||||
tag_parts.append(f"[情绪:{emotion}]")
|
||||
repeat = tags.get("repeat_count", 0)
|
||||
if repeat > 1:
|
||||
tag_parts.append(f"[追问x{repeat}]")
|
||||
|
||||
tag_str = " ".join(tag_parts) if tag_parts else "-"
|
||||
print(f" {emp_id:<15} {status:<10} {urgency:<8} {tag_str}")
|
||||
|
||||
print()
|
||||
|
||||
# 验证各场景的预期标记
|
||||
print(" 场景验证结果:")
|
||||
checks = {
|
||||
"chenwei": {"expected_tags": [], "desc": "场景1-普通咨询"},
|
||||
"liuna": {"expected_tags": ["hand_raise"], "desc": "场景2-举手"},
|
||||
"zhaoqiang": {"expected_tags": ["angry", "need_intervene"], "desc": "场景3-愤怒+介入"},
|
||||
"sunli": {"expected_tags": ["worried"], "desc": "场景4-担忧"},
|
||||
"wanghai": {"expected_tags": ["urgent", "hand_raise"], "desc": "场景5-紧急+举手"},
|
||||
"zhoumei": {"expected_tags": [], "desc": "场景6-结单重开"},
|
||||
}
|
||||
|
||||
for conv in items:
|
||||
emp_id = conv.get("employee_id", "")
|
||||
if emp_id not in checks:
|
||||
continue
|
||||
|
||||
check = checks[emp_id]
|
||||
tags = conv.get("tags", {})
|
||||
|
||||
# 收集实际标记
|
||||
actual_tags = []
|
||||
if tags.get("hand_raise"):
|
||||
actual_tags.append("hand_raise")
|
||||
if tags.get("need_intervene"):
|
||||
actual_tags.append("need_intervene")
|
||||
emotion = tags.get("emotion", "")
|
||||
if emotion and emotion != "neutral":
|
||||
actual_tags.append(emotion)
|
||||
|
||||
# 对比
|
||||
expected = set(check["expected_tags"])
|
||||
actual = set(actual_tags)
|
||||
matched = expected & actual
|
||||
missing = expected - actual
|
||||
extra = actual - expected
|
||||
|
||||
if not expected and not actual:
|
||||
result = "[OK] 无特殊标记(符合预期)"
|
||||
elif missing:
|
||||
result = f"[WARN] 缺少标记: {', '.join(missing)}"
|
||||
else:
|
||||
result = f"[OK] 预期标记全部命中"
|
||||
|
||||
print(f" {check['desc']}: {result}")
|
||||
|
||||
print()
|
||||
|
||||
except Exception as e:
|
||||
print(f" [FAIL] 查询失败: {e}")
|
||||
print()
|
||||
|
||||
|
||||
# ---- 主程序 ----
|
||||
|
||||
|
||||
def main():
|
||||
print()
|
||||
print("[工具] 企微回调增强版本地模拟器")
|
||||
print(f" 后端地址: {BACKEND_URL}")
|
||||
print(f" Corp ID: {CORP_ID}")
|
||||
print(f" Agent ID: {AGENT_ID}")
|
||||
print(f" 场景数量: {len(SCENARIOS)}")
|
||||
print()
|
||||
|
||||
# 初始化加解密工具
|
||||
try:
|
||||
crypto = SimpleWecomCrypto(
|
||||
token=TOKEN,
|
||||
encoding_aes_key=ENCODING_AES_KEY,
|
||||
corp_id=CORP_ID,
|
||||
)
|
||||
print("[OK] 加解密工具初始化成功")
|
||||
except Exception as e:
|
||||
print(f"[FAIL] 加解密工具初始化失败: {e}")
|
||||
return
|
||||
|
||||
print()
|
||||
|
||||
# 测试 0:健康检查
|
||||
if not test_health():
|
||||
return
|
||||
|
||||
# 测试 1:验证回调 URL
|
||||
test_verify_url(crypto)
|
||||
|
||||
# 逐场景发送消息
|
||||
print("=" * 60)
|
||||
print(" 开始发送场景消息")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
total_messages = sum(len(msgs) for _, msgs in SCENARIOS)
|
||||
print(f" 共 {len(SCENARIOS)} 个场景, {total_messages} 条消息")
|
||||
print()
|
||||
|
||||
for scenario_name, messages in SCENARIOS:
|
||||
run_scenario(crypto, scenario_name, messages)
|
||||
# 场景间稍长延迟,确保数据库提交完成
|
||||
time.sleep(1)
|
||||
|
||||
# 验证结果
|
||||
verify_conversations()
|
||||
|
||||
print("=" * 60)
|
||||
print("[完成] 增强版模拟测试完成!")
|
||||
print()
|
||||
print("后续步骤:")
|
||||
print(" 1. 在坐席端 (http://localhost:5173) 查看新会话")
|
||||
print(" 2. 观察不同标签组合在前端的展示效果")
|
||||
print(" 3. 测试接单/回复/转交/结单等操作")
|
||||
print(" 4. 结单 zhoumei 的会话后,再运行一次脚本验证场景6")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,78 +0,0 @@
|
||||
cat > /tmp/gitea-stage1.sh <<'NAS_EOF'
|
||||
#!/bin/bash
|
||||
set +e # don't bail on error, collect everything
|
||||
|
||||
DOCKER=/var/packages/ContainerManager/target/usr/bin/docker
|
||||
|
||||
echo '===== [1] Disk space ====='
|
||||
df -h /volume1
|
||||
|
||||
echo ''
|
||||
echo '===== [2] Docker version ====='
|
||||
$DOCKER --version 2>&1
|
||||
$DOCKER info 2>&1 | head -20
|
||||
|
||||
echo ''
|
||||
echo '===== [3] Existing containers (running + stopped) ====='
|
||||
$DOCKER ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' 2>&1
|
||||
|
||||
echo ''
|
||||
echo '===== [4] Existing images (gitea-related highlighted) ====='
|
||||
$DOCKER images --format 'table {{.Repository}}\t{{.Tag}}\t{{.Size}}' 2>&1
|
||||
echo '--- gitea images only ---'
|
||||
$DOCKER images 2>&1 | grep -i gitea
|
||||
|
||||
echo ''
|
||||
echo '===== [5] /volume1/docker structure (top-level) ====='
|
||||
ls -la /volume1/docker/ 2>&1 | head -30
|
||||
echo '--- sub-dir sizes (top 20) ---'
|
||||
sudo du -sh /volume1/docker/*/ 2>/dev/null | sort -rh | head -20
|
||||
|
||||
echo ''
|
||||
echo '===== [6] /volume1/docker/gitea exists? ====='
|
||||
ls -la /volume1/docker/gitea 2>&1
|
||||
|
||||
echo ''
|
||||
echo '===== [7] Listening ports (3000/2222 must be free) ====='
|
||||
ss -tln 2>&1 | grep -E ':3000|:2222|:80|:443' || echo '(none of 3000/2222/80/443 in use)'
|
||||
|
||||
echo ''
|
||||
echo '===== [8] Tailscale ====='
|
||||
/var/packages/Tailscale/target/bin/tailscale status 2>&1 | head -10
|
||||
ip -4 addr show tailscale0 2>&1 | grep inet
|
||||
|
||||
echo ''
|
||||
echo '===== [9] Docker daemon registry config ====='
|
||||
cat /var/packages/ContainerManager/etc/docker/daemon.json 2>&1
|
||||
|
||||
echo ''
|
||||
echo '===== [10] Test Docker Hub reachability ====='
|
||||
curl -s -o /dev/null -w 'docker.io: HTTP %{http_code}, time %{time_total}s\n' \
|
||||
--max-time 8 https://registry-1.docker.io/v2/ 2>&1
|
||||
curl -s -o /dev/null -w 'gcr.io: HTTP %{http_code}, time %{time_total}s\n' \
|
||||
--max-time 8 https://gcr.io/v2/ 2>&1
|
||||
curl -s -o /dev/null -w 'tencentyun mirror: HTTP %{http_code}, time %{time_total}s\n' \
|
||||
--max-time 8 https://mirror.ccs.tencentyun.com/v2/ 2>&1
|
||||
|
||||
echo ''
|
||||
echo '===== [11] User & groups (is simon in docker group?) ====='
|
||||
id
|
||||
groups
|
||||
|
||||
echo ''
|
||||
echo '===== [12] CPU / memory ====='
|
||||
free -h
|
||||
nproc
|
||||
|
||||
echo ''
|
||||
echo '===== STAGE 1 DONE ====='
|
||||
NAS_EOF
|
||||
|
||||
chmod +x /tmp/gitea-stage1.sh
|
||||
echo '=== SCRIPT WRITTEN: /tmp/gitea-stage1.sh ==='
|
||||
echo '=== Press ENTER to execute (sudo will prompt for password) ==='
|
||||
read
|
||||
sudo bash /tmp/gitea-stage1.sh 2>&1 | tee /tmp/gitea-stage1.log
|
||||
echo ''
|
||||
echo '=== LOG SAVED: /tmp/gitea-stage1.log ==='
|
||||
echo '=== Paste the entire output above back to Claude ==='
|
||||
@@ -1,147 +0,0 @@
|
||||
"""
|
||||
企微IT智能服务台 — 在 8001 端口启动后端 + 测试
|
||||
绕过 8000 端口的僵尸 socket 问题
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import json
|
||||
|
||||
PYTHON = r"C:\Users\simon\AppData\Local\Programs\Python\Python312\python.exe"
|
||||
BACKEND_DIR = r"C:\Users\simon\wecom_it_smart_desk\backend"
|
||||
PORT = 8001 # 换端口!8000 有僵尸 socket
|
||||
|
||||
|
||||
def wait_backend_ready(max_wait=20):
|
||||
"""等待后端 /health 返回 200"""
|
||||
start = time.time()
|
||||
while time.time() - start < max_wait:
|
||||
try:
|
||||
req = urllib.request.Request(f"http://localhost:{PORT}/health")
|
||||
with urllib.request.urlopen(req, timeout=3) as resp:
|
||||
if resp.status == 200:
|
||||
print(f" 后端已就绪(耗时 {time.time() - start:.1f} 秒)")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
print(f" ⚠️ 后端就绪等待超时({max_wait} 秒)")
|
||||
return False
|
||||
|
||||
|
||||
def test_endpoint(method, path, data=None):
|
||||
"""测试单个 HTTP 端点"""
|
||||
url = f"http://localhost:{PORT}{path}"
|
||||
body = json.dumps(data).encode() if data else None
|
||||
headers = {"Content-Type": "application/json"} if data else {}
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
return resp.status, resp.read().decode("utf-8", errors="replace")[:500]
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode("utf-8", errors="replace")[:500]
|
||||
except Exception as e:
|
||||
return 0, str(e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Step 1: 先试着杀掉 8001 端口的进程(以防之前用过)
|
||||
print("=" * 60)
|
||||
print("Step 1: 清理端口 8001")
|
||||
print("=" * 60)
|
||||
r = subprocess.run(["netstat", "-ano"], capture_output=True, text=True)
|
||||
for line in r.stdout.splitlines():
|
||||
if ":8001" in line and "LISTENING" in line:
|
||||
pid = line.strip().split()[-1]
|
||||
if pid.isdigit():
|
||||
print(f" 杀掉 PID={pid}")
|
||||
subprocess.run(["taskkill", "/F", "/PID", pid], capture_output=True)
|
||||
print(" OK")
|
||||
|
||||
# Step 2: 启动后端
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("Step 2: 在端口 8001 启动后端")
|
||||
print("=" * 60)
|
||||
log_file = open(
|
||||
r"C:\Users\simon\wecom_it_smart_desk\backend_log_8001.txt",
|
||||
"w", encoding="utf-8"
|
||||
)
|
||||
backend_proc = subprocess.Popen(
|
||||
[PYTHON, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "0.0.0.0", "--port", str(PORT)],
|
||||
cwd=BACKEND_DIR,
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
print(f" 后端进程已启动 (PID={backend_proc.pid})")
|
||||
|
||||
# Step 3: 等待就绪
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("Step 3: 等待后端就绪")
|
||||
print("=" * 60)
|
||||
if not wait_backend_ready():
|
||||
# 读取日志看看什么情况
|
||||
log_file.close()
|
||||
time.sleep(1)
|
||||
try:
|
||||
with open(r"C:\Users\simon\wecom_it_smart_desk\backend_log_8001.txt",
|
||||
"r", encoding="utf-8", errors="replace") as f:
|
||||
print(f.read()[-2000:])
|
||||
except:
|
||||
pass
|
||||
sys.exit(1)
|
||||
|
||||
# Step 4: 测试端点
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("Step 4: 测试所有端点")
|
||||
print("=" * 60)
|
||||
tests = [
|
||||
("GET", "/health", None, "健康检查"),
|
||||
("GET", "/api/test-ping", None, "诊断 Ping"),
|
||||
("GET", "/api/test-error", None, "诊断 Error(测试异常捕获)"),
|
||||
("POST", "/api/agents/login",
|
||||
{"user_id": "test_diag", "name": "诊断用户"}, "坐席登录"),
|
||||
("GET", "/api/agents", None, "坐席列表"),
|
||||
]
|
||||
for method, path, data, desc in tests:
|
||||
status, body = test_endpoint(method, path, data)
|
||||
icon = "✅" if (200 <= status < 300) else "❌"
|
||||
print(f" {icon} {method} {path} => {status}")
|
||||
for line in body.strip().splitlines():
|
||||
print(f" {line}")
|
||||
|
||||
# Step 5: 读取后端日志
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("Step 5: 后端日志(含错误堆栈)")
|
||||
print("=" * 60)
|
||||
time.sleep(1)
|
||||
log_file.close()
|
||||
try:
|
||||
with open(r"C:\Users\simon\wecom_it_smart_desk\backend_log_8001.txt",
|
||||
"r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.read().strip().splitlines()
|
||||
if len(lines) > 60:
|
||||
print(f" (日志共 {len(lines)} 行,显示最后 60 行)")
|
||||
lines = lines[-60:]
|
||||
for line in lines:
|
||||
print(f" {line}")
|
||||
except Exception as e:
|
||||
print(f" 读取日志失败: {e}")
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("🎉 测试完成!")
|
||||
print(f" 后端地址: http://localhost:{PORT}")
|
||||
print(f" 后端 PID: {backend_proc.pid}")
|
||||
print(f" 停止命令: taskkill /F /PID {backend_proc.pid}")
|
||||
print("")
|
||||
print(" ⚠️ 前端需要更新代理端口到 8001")
|
||||
print(" 请在 PowerShell 执行以下命令更新前端:")
|
||||
print(f" (见下方提示)")
|
||||
print("=" * 60)
|
||||
Reference in New Issue
Block a user