chore: initial baseline with P0-safety .gitignore

This commit is contained in:
Simon
2026-06-14 16:49:18 +08:00
commit 63262292d7
510 changed files with 146008 additions and 0 deletions
View File
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Test admin auth flow"""
import urllib.request, json, ssl
ctx = ssl.create_default_context()
# Step 1: Mock login to get token
print("=== 1. Mock Login ===")
data = json.dumps({"employee_id": "admin001"}).encode()
req = urllib.request.Request("https://itsupport.servyou.com.cn/api/h5/mock-login", data=data, method="POST")
req.add_header("Content-Type", "application/json")
try:
resp = urllib.request.urlopen(req, context=ctx, timeout=10)
body = json.loads(resp.read().decode())
token = body["data"]["token"]
print(f"Status: {resp.status}")
print(f"Token: {token}")
print(f"Employee: {body['data'].get('employee_name')}")
except urllib.request.HTTPError as e:
body = json.loads(e.read().decode())
print(f"Error {e.code}: {json.dumps(body, ensure_ascii=False)}")
token = None
if token:
# Step 2: Try to access admin dashboard with token
print("\n=== 2. Admin Dashboard (with token) ===")
req2 = urllib.request.Request("https://itsupport.servyou.com.cn/api/admin/dashboard/overview")
req2.add_header("Authorization", f"Bearer {token}")
try:
resp = urllib.request.urlopen(req2, context=ctx, timeout=10)
print(f"Status: {resp.status}")
body = json.loads(resp.read().decode())
print(f"Code: {body.get('code')}")
print(f"Message: {body.get('message')}")
if body.get("data"):
print(f"Data keys: {list(body['data'].keys())}")
except urllib.request.HTTPError as e:
body = json.loads(e.read().decode())
print(f"Status {e.code}: {json.dumps(body, ensure_ascii=False)}")
# Step 3: List agents
print("\n=== 3. List Agents ===")
req3 = urllib.request.Request("https://itsupport.servyou.com.cn/api/admin/agents")
req3.add_header("Authorization", f"Bearer {token}")
try:
resp = urllib.request.urlopen(req3, context=ctx, timeout=10)
print(f"Status: {resp.status}")
body = json.loads(resp.read().decode())
print(f"Code: {body.get('code')}")
print(f"Data: {json.dumps(body.get('data'), ensure_ascii=False)[:300]}")
except urllib.request.HTTPError as e:
body = json.loads(e.read().decode())
print(f"Status {e.code}: {json.dumps(body, ensure_ascii=False)}")
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""测试 Dify AI 连通性 + 同步管理后台配置"""
import urllib.request, json, ssl
ctx = ssl.create_default_context()
BASE = "https://itsupport.servyou.com.cn"
def api(method, path, data=None, token=None):
url = f"{BASE}{path}"
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, method=method)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", f"Bearer {token}")
try:
resp = urllib.request.urlopen(req, context=ctx, timeout=60)
raw = resp.read().decode()
try:
return resp.status, json.loads(raw)
except json.JSONDecodeError:
return resp.status, {"raw": raw}
except urllib.request.HTTPError as e:
raw = e.read().decode()
try:
return e.code, json.loads(raw)
except json.JSONDecodeError:
return e.code, {"raw": raw}
# Step 1: Admin login
print("=== Step 1: Admin login ===")
code, body = api("POST", "/api/agents/login", {"user_id": "admin001", "name": "\u5b8b\u732e"})
admin_token = body.get("data", {}).get("token", "")
print(f" Code: {body.get('code')}, Token: {'OK' if admin_token else 'NONE'}")
# Step 2: Update Dify integration config in DB
print("\n=== Step 2: Update Dify integration config ===")
code, body = api("PUT", "/api/admin/integrations/dify", {
"api_url": "http://yw-dify.dc.servyou-it.com/dify2openai/v1/chat/completions",
"api_key": "http://yw-dify.dc.servyou-it.com/v1|app-UaTWYdBSwN6VktKQlbh5YN5H|Chat",
}, admin_token)
print(f" Code: {body.get('code')}")
data = body.get("data", {})
if data:
print(f" ID: {data.get('id')}")
print(f" Name: {data.get('name')}")
print(f" Status: {data.get('status')}")
config = data.get("config", {})
print(f" Config: {json.dumps(config, ensure_ascii=False)}")
else:
print(f" Data: {json.dumps(body, ensure_ascii=False)[:300]}")
# Step 3: Verify integration status
print("\n=== Step 3: Verify integration status ===")
code, body = api("GET", "/api/admin/integrations", token=admin_token)
items = body.get("data", {}).get("items", []) if isinstance(body.get("data"), dict) else []
for i in items:
name = i.get("name", "?")
status = i.get("status", "?")
print(f" {name}: {status}")
# Step 4: Test Dify API directly from server (via H5 mock-login + send message)
print("\n=== Step 4: Test Dify AI via H5 message ===")
code, body = api("POST", "/api/h5/mock-login", {"employee_id": "emp001", "employee_name": "\u6d4b\u8bd5\u5458\u5de5"})
h5_token = body.get("data", {}).get("token", "")
print(f" H5 Login: code={body.get('code')}")
if h5_token:
# Get or create conversation
code, body = api("GET", "/api/h5/conversations/current", token=h5_token)
conv_id = body.get("data", {}).get("id") if isinstance(body.get("data"), dict) else None
print(f" Conversation: code={body.get('code')}, id={conv_id}")
if conv_id:
# Send a test message to trigger AI
print(f" Sending test message to trigger Dify AI...")
code, body = api("POST", f"/api/h5/conversations/{conv_id}/messages", {
"content": "hello, this is a test"
}, h5_token)
print(f" Send message: code={body.get('code')}")
msg_data = body.get("data", {})
if msg_data:
print(f" Message ID: {msg_data.get('message_id', msg_data.get('id', 'N/A'))}")
print(f" AI Reply: {json.dumps(msg_data.get('ai_reply', msg_data.get('content', 'N/A')), ensure_ascii=False)[:200]}")
else:
print(f" Response: {json.dumps(body, ensure_ascii=False)[:300]}")
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Test Dify with full error details"""
import urllib.request, json, ssl
ctx = ssl.create_default_context()
BASE = "https://itsupport.servyou.com.cn"
def api(method, path, data=None, token=None, timeout=60):
url = f"{BASE}{path}"
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, method=method)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", f"Bearer {token}")
try:
resp = urllib.request.urlopen(req, context=ctx, timeout=timeout)
raw = resp.read().decode()
try:
return resp.status, json.loads(raw)
except json.JSONDecodeError:
return resp.status, {"raw": raw}
except urllib.request.HTTPError as e:
raw = e.read().decode()
try:
return e.code, json.loads(raw)
except json.JSONDecodeError:
return e.code, {"raw": raw}
# Step 1: H5 mock login
print("=== Step 1: H5 Mock Login ===")
code, body = api("POST", "/api/h5/mock-login", {"employee_id": "emp001", "employee_name": "test"})
print(f" Status: {code}")
print(f" Response: {json.dumps(body, ensure_ascii=False)}")
h5_token = body.get("data", {}).get("token", "")
# Step 2: Get current conversation (with full error)
print("\n=== Step 2: Get Current Conversation ===")
code, body = api("GET", "/api/h5/conversations/current", token=h5_token)
print(f" Status: {code}")
print(f" Response: {json.dumps(body, ensure_ascii=False)}")
# Step 3: Try sending a message directly (POST /h5/conversations/current/messages)
# This should create a conversation if none exists
print("\n=== Step 3: Send Message (auto-create conversation) ===")
code, body = api("POST", "/api/h5/conversations/current/messages", {
"content": "hello"
}, h5_token, timeout=90)
print(f" Status: {code}")
print(f" Response: {json.dumps(body, ensure_ascii=False)[:500]}")
# Step 4: After sending, check conversation again
print("\n=== Step 4: Get Conversation After Message ===")
code, body = api("GET", "/api/h5/conversations/current", token=h5_token)
print(f" Status: {code}")
conv_data = body.get("data", {})
if conv_data:
print(f" Conversation ID: {conv_data.get('id')}")
print(f" Status: {conv_data.get('status')}")
print(f" AI reply count: {conv_data.get('ai_substantive_reply_count')}")
+14
View File
@@ -0,0 +1,14 @@
=== Step 1: H5 Mock Login ===
Status: 200
Response: {"code": 0, "data": {"employee_id": "emp001", "employee_name": "test", "token": "prdaT_l08wzbCIeQKtBu5P7O3NQbkMB_oTzKe74362s", "department": "IT部", "position": "测试岗位", "avatar": ""}, "message": "success"}
=== Step 2: Get Current Conversation ===
Status: 200
Response: {"code": 1005, "data": null, "message": "服务器内部错误: (sqlalchemy.dialects.postgresql.asyncpg.ProgrammingError) <class 'asyncpg.exceptions.UndefinedColumnError'>: column conversations.impact_scope does not exist\n[SQL: SELECT conversations.id, conversations.corp_id, conversations.employee_id, conversations.employee_name, conversations.department, conversations.position, conversations.level, conversations.status, conversations.is_vip, conversations.is_pinned, conversations.is_todo, conversations.urgency_score, conversations.tags, conversations.assigned_agent_id, conversations.collaborating_agent_ids, conversations.participants, conversations.ai_substantive_reply_count, conversations.impact_scope, conversations.is_blocking, conversations.emotion_state, conversations.dify_conversation_id, conversations.last_message_at, conversations.last_message_summary, conversations.created_at, conversations.updated_at \nFROM conversations \nWHERE conversations.employee_id = $1::VARCHAR AND conversations.status IN ($2::VARCHAR, $3::VARCHAR, $4::VARCHAR) ORDER BY conversations.created_at DESC]\n[parameters: ('emp001', 'ai_handling', 'queued', 'serving')]\n(Background on this error at: https://sqlalche.me/e/20/f405)"}
=== Step 3: Send Message (auto-create conversation) ===
Status: 200
Response: {"code": 1005, "data": null, "message": "服务器内部错误: AIHandler.__init__() missing 1 required positional argument: 'ai_service'"}
=== Step 4: Get Conversation After Message ===
Status: 200
+21
View File
@@ -0,0 +1,21 @@
=== Step 1: Admin login ===
Code: 0, Token: OK
=== Step 2: Update Dify integration config ===
Code: 0
ID: dify
Name: Dify AI
Status: connected
Config: {"api_url": "http://yw-dify.dc.servyou-it.com/dify2openai/v1/chat/completions", "api_key_set": true, "access_key_id_set": false, "access_key_secret_set": false, "base_url": null, "api_account_set": false, "api_password_set": false}
=== Step 3: Verify integration status ===
Dify AI: connected
RAGFlow: connected
火绒安全: connected
联软LV7000: disconnected
数据平台: disconnected
北森 eHR: disconnected
=== Step 4: Test Dify AI via H5 message ===
H5 Login: code=0
Conversation: code=1005, id=None
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""正式服务器全链路验证"""
import urllib.request, json, ssl, sys
ctx = ssl.create_default_context()
BASE = "https://itsupport.servyou.com.cn"
def get(path, token=None):
req = urllib.request.Request(f"{BASE}{path}", method="GET")
if token:
req.add_header("Authorization", f"Bearer {token}")
try:
resp = urllib.request.urlopen(req, context=ctx, timeout=10)
raw = resp.read().decode()
try:
return resp.status, json.loads(raw)
except json.JSONDecodeError:
return resp.status, {"raw": raw}
except urllib.request.HTTPError as e:
raw = e.read().decode()
try:
return e.code, json.loads(raw)
except json.JSONDecodeError:
return e.code, {"raw": raw}
def post(path, data, token=None):
req = urllib.request.Request(
f"{BASE}{path}",
data=json.dumps(data).encode(),
method="POST"
)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", f"Bearer {token}")
try:
resp = urllib.request.urlopen(req, context=ctx, timeout=10)
return resp.status, json.loads(resp.read().decode())
except urllib.request.HTTPError as e:
return e.code, json.loads(e.read().decode())
# ===================== 1. 管理员登录 =====================
print("=" * 60)
print("1. 管理员登录 (admin001)")
print("=" * 60)
code, body = post("/api/agents/login", {"user_id": "admin001", "name": "宋献"})
admin_token = body.get("data", {}).get("token", "")
role = body.get("data", {}).get("role", "N/A")
print(f" Status: {code}")
print(f" Code: {body.get('code')}")
print(f" Role: {role}")
print(f" Token: {admin_token[:20]}..." if admin_token else " Token: NONE")
status_icon = "[OK]" if body.get("code") == 0 and role == "admin" else "[FAIL]"
print(f" 结果: {status_icon}")
# ===================== 2. 管理后台 - 仪表盘 =====================
print("\n" + "=" * 60)
print("2. 管理后台 - 仪表盘 /api/admin/dashboard/overview")
print("=" * 60)
code, body = get("/api/admin/dashboard/overview", admin_token)
print(f" Code: {body.get('code')}")
data = body.get("data", {})
if data:
print(f" 活跃会话: {data.get('active_conversations', 'N/A')}")
print(f" 在线坐席: {data.get('online_agents', 'N/A')}")
print(f" 今日消息: {data.get('today_messages', 'N/A')}")
print(f" 数据键: {list(data.keys())}")
status_icon = "[OK]" if body.get("code") == 0 else "[FAIL]"
print(f" 结果: {status_icon}")
# ===================== 3. 管理后台 - 集成列表 =====================
print("\n" + "=" * 60)
print("3. 管理后台 - 集成配置 /api/admin/integrations")
print("=" * 60)
code, body = get("/api/admin/integrations", admin_token)
print(f" Code: {body.get('code')}")
items = body.get("data", {}).get("items", []) if isinstance(body.get("data"), dict) else []
if not items:
print(f" Data: {json.dumps(body.get('data'), ensure_ascii=False)[:200]}")
for i in items:
name = i.get("name", "?")
status = i.get("status", "?")
config = i.get("config", {})
has_key = True # status check is sufficient
icon = "[OK]" if status in ("active", "connected") else "[!!]"
print(f" {icon} {name}: status={status}, config_keys={list(config.keys()) if isinstance(config, dict) else 'N/A'}")
status_icon = "[OK]" if body.get("code") == 0 else "[FAIL]"
print(f" 结果: {status_icon}")
# ===================== 4. 企微回调验证 =====================
print("\n" + "=" * 60)
print("4. 企微回调 URL 验证 /api/wecom/callback")
print("=" * 60)
code, body = get("/api/wecom/callback?msg_signature=5903d061959fc604d0b42d54f78ed7e33e7e9b7c&timestamp=1750000000&nonce=test&echostr=testechostr")
print(f" Status: {code}")
msg = body.get("message", body.get("detail", body.get("raw", str(body)[:200])))
print(f" Response: {str(msg)[:200]}")
# 企微验证时如果签名不对会返回错误,但说明接口可达
status_icon = "[OK - reachable]" if code == 200 or (isinstance(msg, str) and ("签名" in msg or "echostr" in msg or "error" in msg.lower())) else "[FAIL]"
print(f" 结果: {status_icon}")
# ===================== 5. H5 Mock 登录 + 会话 =====================
print("\n" + "=" * 60)
print("5. H5 用户端 - Mock 登录 + 创建会话")
print("=" * 60)
code, body = post("/api/h5/mock-login", {"employee_id": "emp001", "employee_name": "测试员工"})
h5_token = body.get("data", {}).get("token", "")
print(f" Mock登录: code={body.get('code')}, token={'' if h5_token else ''}")
status_icon = "[OK]" if body.get("code") == 0 else "[FAIL]"
print(f" 结果: {status_icon}")
# 获取当前会话
if h5_token:
req = urllib.request.Request(f"{BASE}/api/h5/conversations/current")
req.add_header("Authorization", f"Bearer {h5_token}")
try:
resp = urllib.request.urlopen(req, context=ctx, timeout=10)
conv_body = json.loads(resp.read().decode())
print(f" 当前会话: code={conv_body.get('code')}, data_type={type(conv_body.get('data')).__name__}")
conv_id = conv_body.get("data", {}).get("id") if isinstance(conv_body.get("data"), dict) else None
if conv_id:
print(f" 会话ID: {conv_id}")
except Exception as e:
print(f" 当前会话: error={e}")
# ===================== 6. 坐席登录 =====================
print("\n" + "=" * 60)
print("6. 坐席工作台 - 坐席登录 (sxn)")
print("=" * 60)
code, body = post("/api/agents/login", {"user_id": "sxn", "name": "宋献"})
agent_token = body.get("data", {}).get("token", "")
agent_role = body.get("data", {}).get("role", "N/A")
print(f" Status: {code}")
print(f" Code: {body.get('code')}")
print(f" Role: {agent_role}")
print(f" Token: {'' if agent_token else ''}")
status_icon = "[OK]" if body.get("code") == 0 else "[FAIL]"
print(f" 结果: {status_icon}")
# 坐席获取会话列表
if agent_token:
print("\n --- 坐席获取会话列表 ---")
code2, body2 = get("/api/conversations", agent_token)
print(f" 会话列表: code={body2.get('code')}, count={len(body2.get('data', [])) if isinstance(body2.get('data'), list) else 'N/A'}")
# ===================== 总结 =====================
print("\n" + "=" * 60)
print("验证完成")
print("=" * 60)
+60
View File
@@ -0,0 +1,60 @@
============================================================
1. 管理员登录 (admin001)
============================================================
Status: 200
Code: 0
Role: admin
Token: eaHxj2TAhhXblPtaHimJ...
结果: [OK]
============================================================
2. 管理后台 - 仪表盘 /api/admin/dashboard/overview
============================================================
Code: 0
活跃会话: N/A
在线坐席: 4
今日消息: N/A
数据键: ['online_agents', 'today_conversations', 'avg_response_time', 'ai_hit_rate', 'pending_reviews', 'system_alerts', 'integrations_health']
结果: [OK]
============================================================
3. 管理后台 - 集成配置 /api/admin/integrations
============================================================
Code: 0
[!!] Dify AI: status=disconnected, config_keys=['api_url', 'api_key_set', 'access_key_id_set', 'access_key_secret_set', 'base_url', 'api_account_set', 'api_password_set']
[!!] RAGFlow: status=disconnected, config_keys=['api_url', 'api_key_set', 'access_key_id_set', 'access_key_secret_set', 'base_url', 'api_account_set', 'api_password_set']
[OK] 火绒安全: status=connected, config_keys=['api_url', 'api_key_set', 'access_key_id_set', 'access_key_secret_set', 'base_url', 'api_account_set', 'api_password_set']
[!!] 联软LV7000: status=disconnected, config_keys=['api_url', 'api_key_set', 'access_key_id_set', 'access_key_secret_set', 'base_url', 'api_account_set', 'api_password_set']
[!!] 数据平台: status=disconnected, config_keys=N/A
[!!] 北森 eHR: status=disconnected, config_keys=N/A
结果: [OK]
============================================================
4. 企微回调 URL 验证 /api/wecom/callback
============================================================
Status: 400
Response: 验证失败: 回调URL验证签名失败
结果: [OK - reachable]
============================================================
5. H5 用户端 - Mock 登录 + 创建会话
============================================================
Mock登录: code=0, token=有
结果: [OK]
当前会话: code=1005, data_type=NoneType
============================================================
6. 坐席工作台 - 坐席登录 (sxn)
============================================================
Status: 200
Code: 0
Role: agent
Token: 有
结果: [OK]
--- 坐席获取会话列表 ---
会话列表: code=1005, count=N/A
============================================================
验证完成
============================================================
+50
View File
@@ -0,0 +1,50 @@
"""测试正式服务器 Mock 登录 POST 接口"""
import urllib.request, ssl, json
ctx = ssl.create_default_context()
# 测试 1: Mock 登录
url = "https://itsupport.servyou.com.cn/api/h5/mock-login"
data = json.dumps({"employee_id": "admin001"}).encode()
req = urllib.request.Request(url, data=data, method="POST")
req.add_header("Content-Type", "application/json")
print("=== 测试1: Mock登录 POST /api/h5/mock-login ===")
try:
resp = urllib.request.urlopen(req, context=ctx, timeout=10)
print(f"Status: {resp.status}")
body = resp.read().decode()
print(f"Body: {body}")
result = json.loads(body)
print(f"Code: {result.get('code')}")
print(f"Message: {result.get('message')}")
if result.get("data"):
print(f"Data keys: {list(result['data'].keys())}")
except urllib.request.HTTPError as e:
print(f"Status: {e.code}")
print(f"Body: {e.read().decode()}")
# 测试 2: Admin dashboard (需要auth, 预期401)
print("\n=== 测试2: Admin Dashboard GET /api/admin/dashboard/overview ===")
url2 = "https://itsupport.servyou.com.cn/api/admin/dashboard/overview"
req2 = urllib.request.Request(url2, method="GET")
try:
resp = urllib.request.urlopen(req2, context=ctx, timeout=10)
print(f"Status: {resp.status}")
print(f"Body: {resp.read().decode()}")
except urllib.request.HTTPError as e:
print(f"Status: {e.code}")
print(f"Body: {e.read().decode()}")
# 测试 3: Admin agents list (需要auth)
print("\n=== 测试3: Admin Agents GET /api/admin/agents ===")
url3 = "https://itsupport.servyou.com.cn/api/admin/agents"
req3 = urllib.request.Request(url3, method="GET")
try:
resp = urllib.request.urlopen(req3, context=ctx, timeout=10)
print(f"Status: {resp.status}")
print(f"Body: {resp.read().decode()}")
except urllib.request.HTTPError as e:
print(f"Status: {e.code}")
print(f"Body: {e.read().decode()}")
+14
View File
@@ -0,0 +1,14 @@
=== 娴嬭瘯1: Mock鐧诲綍 POST /api/h5/mock-login ===
Status: 200
Body: {"code":0,"data":{"employee_id":"admin001","employee_name":"娴嬭瘯鐢ㄦ埛","token":"4r4dKWWjOK_a-I55rTUE7fnd3NRqWa73m8ZOVQ8KwYk","department":"IT閮?,"position":"娴嬭瘯宀椾綅","avatar":""},"message":"success"}
Code: 0
Message: success
Data keys: ['employee_id', 'employee_name', 'token', 'department', 'position', 'avatar']
=== 娴嬭瘯2: Admin Dashboard GET /api/admin/dashboard/overview ===
Status: 200
Body: {"code":1002,"data":null,"message":"鏈巿鏉?}
=== 娴嬭瘯3: Admin Agents GET /api/admin/agents ===
Status: 200
Body: {"code":1002,"data":null,"message":"鏈巿鏉?}
View File
+14
View File
@@ -0,0 +1,14 @@
=== 1. Mock Login ===
Status: 200
Token: OFTJ7FdSNlFGMVpCOguILN60VM2NOsCoiJYAUfBBlU0
Employee: 测试用户
=== 2. Admin Dashboard (with token) ===
Status: 200
Code: 1002
Message: 未授权
=== 3. List Agents ===
Status: 200
Code: 1002
Data: null
+115
View File
@@ -0,0 +1,115 @@
"""分析智能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%")
+248
View File
@@ -0,0 +1,248 @@
"""
从 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()
+22
View File
@@ -0,0 +1,22 @@
"""安装项目依赖"""
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)
+31
View File
@@ -0,0 +1,31 @@
"""安装项目依赖 - 最简方式"""
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)
+30
View File
@@ -0,0 +1,30 @@
"""安装依赖 - 仅使用预编译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)
+15
View File
@@ -0,0 +1,15 @@
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")
+36
View File
@@ -0,0 +1,36 @@
"""运行测试 - 写入项目目录"""
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)
+333
View File
@@ -0,0 +1,333 @@
# =============================================================================
# 企微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: 验证回调 URLGET /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()
+512
View File
@@ -0,0 +1,512 @@
# =============================================================================
# 企微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: 验证回调 URLGET /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()
+147
View File
@@ -0,0 +1,147 @@
"""
企微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)
+76
View File
@@ -0,0 +1,76 @@
#!/bin/bash
# =============================================================================
# 企微IT智能服务台 — 前端构建脚本
# =============================================================================
# 说明:构建坐席工作台和 H5 用户端两个前端项目
# 用法:bash scripts/build.sh
# 输出:frontend-agent/dist/ 和 frontend-h5/dist/
# =============================================================================
set -e # 遇到错误立即退出
echo "=========================================="
echo " 企微IT智能服务台 — 前端构建"
echo "=========================================="
# 获取项目根目录(脚本所在目录的上一级)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# --------------------------------------------------
# 1. 构建坐席工作台(frontend-agent
# --------------------------------------------------
echo ""
echo "[1/2] 构建坐席工作台..."
cd "$PROJECT_DIR/frontend-agent"
# 安装依赖(如果 node_modules 不存在)
if [ ! -d "node_modules" ]; then
echo " → 安装依赖..."
npm install
fi
# 构建生产版本
echo " → 构建中..."
npm run build
# 验证构建产物
if [ -d "dist" ]; then
echo " ✅ 坐席工作台构建完成: frontend-agent/dist/"
else
echo " ❌ 坐席工作台构建失败: dist/ 目录不存在"
exit 1
fi
# --------------------------------------------------
# 2. 构建 H5 用户端(frontend-h5
# --------------------------------------------------
echo ""
echo "[2/2] 构建 H5 用户端..."
cd "$PROJECT_DIR/frontend-h5"
# 安装依赖
if [ ! -d "node_modules" ]; then
echo " → 安装依赖..."
npm install
fi
# 构建生产版本
echo " → 构建中..."
npm run build
# 验证构建产物
if [ -d "dist" ]; then
echo " ✅ H5 用户端构建完成: frontend-h5/dist/"
else
echo " ❌ H5 用户端构建失败: dist/ 目录不存在"
exit 1
fi
echo ""
echo "=========================================="
echo " 构建完成!"
echo " 坐席: frontend-agent/dist/"
echo " H5: frontend-h5/dist/"
echo "=========================================="
echo ""
echo "下一步: bash scripts/deploy.sh 启动服务"
+2
View File
@@ -0,0 +1,2 @@
# Test script to verify PowerShell syntax
Write-Host "Test script"
+257
View File
@@ -0,0 +1,257 @@
#!/bin/bash
# =============================================================================
# 企微IT智能服务台 — 一键构建 & 部署脚本(共享域名版)
# =============================================================================
# 说明:与 IT 数据查询平台共享域名 it-dataquery.dc.servyou-it.com
# 路由:
# / → IT 数据查询平台
# /itdesk/ → H5 员工咨询端
# /itagent/ → 坐席工作台
# /api/ → 后端 FastAPI
#
# 用法:
# bash scripts/deploy.sh # 完整构建 + 启动
# bash scripts/deploy.sh --build # 仅构建前端 + 后端镜像
# bash scripts/deploy.sh --up # 仅启动(已构建过)
# bash scripts/deploy.sh --down # 停止所有服务
# bash scripts/deploy.sh --status # 查看服务状态
# bash scripts/deploy.sh --pack # 打包部署文件(用于 SCP 到远程服务器)
# =============================================================================
set -e # 遇到错误立即退出
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
info() { echo -e "${BLUE}[INFO]${NC} $1"; }
ok() { echo -e "${GREEN}[OK]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
# 项目根目录(脚本所在目录的上级)
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_ROOT"
# 部署包名(含日期)
DEPLOY_PKG="it-smart-desk-$(date +%Y%m%d%H%M).tar.gz"
# --------------------------------------------------------------------------
# 前置检查
# --------------------------------------------------------------------------
check_prerequisites() {
info "检查前置条件..."
# 检查 Docker
if ! command -v docker &> /dev/null; then
error "Docker 未安装,请先安装 Docker"
fi
ok "Docker 已安装"
# 检查 Docker Compose
if ! docker compose version &> /dev/null; then
error "Docker Compose 未安装或版本过低"
fi
ok "Docker Compose 可用"
# 检查 .env 文件
if [ ! -f .env ]; then
warn ".env 文件不存在,从模板创建..."
if [ -f .env.production ]; then
cp .env.production .env
warn "已创建 .env,请编辑填入真实配置后再部署"
warn "关键配置:WECOM_CORP_ID, WECOM_SECRET, WECOM_TOKEN, WECOM_ENCODING_AES_KEY"
exit 1
else
error ".env.production 模板不存在"
fi
fi
ok ".env 配置文件就绪"
}
# --------------------------------------------------------------------------
# 创建外部网络(与数据平台互联)
# --------------------------------------------------------------------------
ensure_network() {
info "检查外部网络 it-platform-net..."
if docker network inspect it-platform-net &> /dev/null; then
ok "外部网络 it-platform-net 已存在"
else
info "创建外部网络 it-platform-net..."
docker network create it-platform-net
ok "外部网络创建成功"
fi
}
# --------------------------------------------------------------------------
# 构建前端
# --------------------------------------------------------------------------
build_frontends() {
info "构建 H5 员工咨询端(/itdesk/..."
cd "$PROJECT_ROOT/frontend-h5"
npm install --prefer-offline
npm run build
ok "H5 员工咨询端构建完成 (dist/ → /itdesk/)"
info "构建坐席工作台(/itagent/..."
cd "$PROJECT_ROOT/frontend-agent"
npm install --prefer-offline
npm run build
ok "坐席工作台构建完成 (dist/ → /itagent/)"
cd "$PROJECT_ROOT"
}
# --------------------------------------------------------------------------
# 构建后端镜像
# --------------------------------------------------------------------------
build_backend() {
info "构建后端 Docker 镜像..."
docker compose build backend
ok "后端镜像构建完成"
}
# --------------------------------------------------------------------------
# 启动服务
# --------------------------------------------------------------------------
start_services() {
ensure_network
info "启动所有服务..."
docker compose up -d
ok "所有服务已启动"
echo ""
info "等待服务就绪(约 30 秒,含数据库迁移)..."
sleep 30
# 健康检查
if curl -sf http://localhost:18080/itdesk/health > /dev/null 2>&1; then
ok "后端服务健康检查通过"
else
warn "后端健康检查未通过,请查看日志:docker compose logs backend"
fi
}
# --------------------------------------------------------------------------
# 停止服务
# --------------------------------------------------------------------------
stop_services() {
info "停止所有服务..."
docker compose down
ok "所有服务已停止(数据卷保留,不丢失数据)"
}
# --------------------------------------------------------------------------
# 查看状态
# --------------------------------------------------------------------------
show_status() {
info "服务状态:"
docker compose ps
echo ""
info "资源占用:"
docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}" \
$(docker compose ps -q 2>/dev/null) 2>/dev/null || echo "服务未启动"
}
# --------------------------------------------------------------------------
# 打包部署文件(用于 SCP 到远程服务器)
# --------------------------------------------------------------------------
pack_deploy() {
info "打包部署文件..."
# 确保前端已构建
if [ ! -d "$PROJECT_ROOT/frontend-h5/dist" ] || [ ! -d "$PROJECT_ROOT/frontend-agent/dist" ]; then
warn "前端 dist 不存在,先构建..."
build_frontends
fi
# 打包所需文件(排除 node_modules、.git、本地数据库等)
tar czf "$PROJECT_ROOT/$DEPLOY_PKG" \
--exclude='node_modules' \
--exclude='.git' \
--exclude='__pycache__' \
--exclude='*.pyc' \
--exclude='*.db' \
--exclude='.env' \
--exclude='*.tar.gz' \
-C "$PROJECT_ROOT" \
backend/ \
frontend-h5/dist/ \
frontend-agent/dist/ \
nginx/ \
docker-compose.yml \
.env.production \
scripts/ \
docs/
ok "部署包已创建:$DEPLOY_PKG"
echo ""
info "远程部署步骤:"
echo " 1. scp $DEPLOY_PKG user@server:/opt/it-smart-desk/"
echo " 2. ssh user@server"
echo " 3. cd /opt/it-smart-desk && tar xzf $DEPLOY_PKG"
echo " 4. cp .env.production .env && vim .env # 填入真实配置"
echo " 5. bash scripts/deploy.sh"
}
# --------------------------------------------------------------------------
# 主流程
# --------------------------------------------------------------------------
main() {
echo "========================================="
echo " 企微IT智能服务台 — 部署工具"
echo " 共享域名: it-dataquery.dc.servyou-it.com"
echo "========================================="
echo ""
case "${1:-full}" in
--build)
check_prerequisites
build_frontends
build_backend
ok "构建完成!运行 bash scripts/deploy.sh --up 启动服务"
;;
--up)
check_prerequisites
start_services
;;
--down)
stop_services
;;
--status)
show_status
;;
--pack)
build_frontends
pack_deploy
;;
full|"")
check_prerequisites
build_frontends
build_backend
start_services
echo ""
echo "========================================="
ok "部署完成!"
echo "========================================="
echo ""
echo " H5 员工端:http://it-dataquery.dc.servyou-it.com/itdesk/"
echo " 坐席工作台:http://it-dataquery.dc.servyou-it.com/itagent/"
echo " API 文档: http://it-dataquery.dc.servyou-it.com/api/docs"
echo " 数据平台: http://it-dataquery.dc.servyou-it.com/"
echo ""
echo " 本地测试: http://localhost:18080/itdesk/"
echo " 查看日志:docker compose logs -f"
echo " 停止服务:bash scripts/deploy.sh --down"
;;
*)
echo "用法:bash scripts/deploy.sh [--build|--up|--down|--status|--pack]"
exit 1
;;
esac
}
main "$@"
+65
View File
@@ -0,0 +1,65 @@
# =============================================================================
# 本地开发 — 启动 Portal + 后端 (Windows PowerShell 版)
# =============================================================================
$ErrorActionPreference = "Stop"
$ProjectDir = Split-Path -Parent $PSScriptRoot
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host "IT智能服务台 — 本地开发启动" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
# 1. 初始化角色数据
Write-Host ""
Write-Host ">>> 初始化角色数据..." -ForegroundColor Yellow
Set-Location "$ProjectDir\backend"
python scripts/init_roles.py
# 2. 启动后端(后台)
Write-Host ""
Write-Host ">>> 启动后端服务..." -ForegroundColor Yellow
Set-Location "$ProjectDir\backend"
Start-Process -FilePath "uvicorn" -ArgumentList "app.main:app", "--reload", "--host", "0.0.0.0", "--port", "8000" -WorkingDirectory "$ProjectDir\backend" -PassThru | Tee-Object -Variable backendProc
Write-Host "后端 PID: $($backendProc.Id)"
# 3. 启动 Portal 前端
Write-Host ""
Write-Host ">>> 启动 Portal 前端..." -ForegroundColor Yellow
Set-Location "$ProjectDir\frontend-portal"
Start-Process -FilePath "npm" -ArgumentList "run", "dev" -WorkingDirectory "$ProjectDir\frontend-portal" -PassThru | Tee-Object -Variable portalProc
Write-Host "Portal PID: $($portalProc.Id)"
# 4. 启动 H5 前端
Write-Host ""
Write-Host ">>> 启动 H5 前端..." -ForegroundColor Yellow
Set-Location "$ProjectDir\frontend-h5"
Start-Process -FilePath "npm" -ArgumentList "run", "dev" -WorkingDirectory "$ProjectDir\frontend-h5" -PassThru | Tee-Object -Variable h5Proc
Write-Host "H5 PID: $($h5Proc.Id)"
# 5. 启动 Agent 前端
Write-Host ""
Write-Host ">>> 启动 Agent 前端..." -ForegroundColor Yellow
Set-Location "$ProjectDir\frontend-agent"
Start-Process -FilePath "npm" -ArgumentList "run", "dev" -WorkingDirectory "$ProjectDir\frontend-agent" -PassThru | Tee-Object -Variable agentProc
Write-Host "Agent PID: $($agentProc.Id)"
Write-Host ""
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host "所有服务已启动!" -ForegroundColor Green
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "访问地址:"
Write-Host " Portal: http://localhost:5176/itportal/" -ForegroundColor Yellow
Write-Host " H5 用户端: http://localhost:5174/itdesk/" -ForegroundColor Yellow
Write-Host " 坐席工作台: http://localhost:5173/itagent/" -ForegroundColor Yellow
Write-Host " 后端 API: http://localhost:8000/docs" -ForegroundColor Yellow
Write-Host ""
Write-Host "停止所有服务: 关闭此窗口或按 Ctrl+C" -ForegroundColor Gray
Write-Host ""
# 等待用户按任意键退出
Read-Host "按 Enter 键停止所有服务"
# 停止所有进程
Stop-Process -Id $backendProc.Id, $portalProc.Id, $h5Proc.Id, $agentProc.Id -ErrorAction SilentlyContinue
Write-Host "已停止所有服务" -ForegroundColor Green
+68
View File
@@ -0,0 +1,68 @@
#!/bin/bash
# =============================================================================
# 本地开发 — 启动 Portal + 后端
# =============================================================================
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
echo "=========================================="
echo "IT智能服务台 — 本地开发启动"
echo "=========================================="
# 1. 初始化角色数据(如果需要)
echo ""
echo ">>> 初始化角色数据..."
cd "$PROJECT_DIR/backend"
python scripts/init_roles.py
# 2. 启动后端(后台)
echo ""
echo ">>> 启动后端服务..."
cd "$PROJECT_DIR/backend"
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 &
BACKEND_PID=$!
echo "后端 PID: $BACKEND_PID"
# 3. 启动 Portal 前端
echo ""
echo ">>> 启动 Portal 前端..."
cd "$PROJECT_DIR/frontend-portal"
npm run dev &
PORTAL_PID=$!
echo "Portal PID: $PORTAL_PID"
# 4. 启动 H5 前端
echo ""
echo ">>> 启动 H5 前端..."
cd "$PROJECT_DIR/frontend-h5"
npm run dev &
H5_PID=$!
echo "H5 PID: $H5_PID"
# 5. 启动 Agent 前端
echo ""
echo ">>> 启动 Agent 前端..."
cd "$PROJECT_DIR/frontend-agent"
npm run dev &
AGENT_PID=$!
echo "Agent PID: $AGENT_PID"
echo ""
echo "=========================================="
echo "所有服务已启动!"
echo "=========================================="
echo ""
echo "访问地址:"
echo " Portal: http://localhost:5176/itportal/"
echo " H5 用户端: http://localhost:5174/itdesk/"
echo " 坐席工作台: http://localhost:5173/itagent/"
echo " 后端 API: http://localhost:8000/docs"
echo ""
echo "停止所有服务: Ctrl+C"
echo ""
# 等待所有后台进程
wait
+149
View File
@@ -0,0 +1,149 @@
import sys
filepath = r"D:\资料\03-项目开发\wecom_it_smart_desk\docs\PRD.md"
with open(filepath, "r", encoding="utf-8") as f:
lines = f.readlines()
content = "".join(lines)
# === 1. 修改 §2.2 痛点分析表格:新增"解决阶段"列 ===
old_table = """### 2.2 痛点分析
| # | 痛点 | 现状描述 | 影响 |
|---|------|---------|------|
| 1 | 员工绕过AI直接进人工 | 员工可无需通过AI机器人直接进入人工坐席,跳转一次人工后下次直接咨询人工坐席 | AI筛选比例极低,人工成本高 |
| 2 | 需另开窗口 | AI机器人咨询跳转人工坐席后,需要另开新窗口才能继续沟通 | 体验割裂,员工困惑 |
| 3 | 无法跨主体共享 | AI机器人和企微员工服务模块无法像自建应用一样共享至跨主体上下游企业企微 | 跨企业服务不可达 |
| 4 | 人工咨询依赖个人能力和经验 | 坐席回复质量因人而异,容易受个人情绪和状态的影响,新人依赖老带新,无法保证统一服务水准 | 服务质量不稳定,响应效率受限于个人状态 |
| 5 | 实习生成长慢、辅导价值低 | 实习生在岗时间短且不稳定,成长速度慢,辅导老师投入大量精力但工作价值缺乏优势 | 人才培养投入产出比低,知识传承断档 |
| 6 | 个人经验无法积累传承 | 坐席人员的个人经验和成果无法有效积累、传承、迭代更新 | 人员离职即经验流失,团队整体能力无法持续提升 |
| 7 | 缺乏数据支撑的管理盲区 | 坐席人员能力和绩效、IT支持员工满意度缺乏有效数据支撑 | 管理决策凭感觉,无法量化评估和持续优化 |
"""
new_table = """### 2.2 痛点分析
> **痛点与阶段对应关系**:每条痛点标注了将在哪个演进阶段被解决,便于追溯开发升级功能的针对性。
| # | 痛点 | 现状描述 | 影响 | 解决阶段 |
|---|------|---------|------|---------|
| 1 | 员工绕过AI直接进人工 | 员工可无需通过AI机器人直接进入人工坐席,跳转一次人工后下次直接咨询人工坐席 | AI筛选比例极低,人工成本高 | **阶段二** |
| 2 | 需另开窗口 | AI机器人咨询跳转人工坐席后,需要另开新窗口才能继续沟通 | 体验割裂,员工困惑 | **阶段二** |
| 3 | 无法跨主体共享 | AI机器人和企微员工服务模块无法像自建应用一样共享至跨主体上下游企业企微 | 跨企业服务不可达 | **阶段二** |
| 4 | 人工咨询依赖个人能力和经验 | 坐席回复质量因人而异,容易受个人情绪和状态的影响,新人依赖老带新,无法保证统一服务水准 | 服务质量不稳定,响应效率受限于个人状态 | **阶段三** |
| 5 | 实习生成长慢、辅导价值低 | 实习生在岗时间短且不稳定,成长速度慢,辅导老师投入大量精力但工作价值缺乏优势 | 人才培养投入产出比低,知识传承断档 | **阶段三** |
| 6 | 个人经验无法积累传承 | 坐席人员的个人经验和成果无法有效积累、传承、迭代更新 | 人员离职即经验流失,团队整体能力无法持续提升 | **阶段四** |
| 7 | 缺乏数据支撑的管理盲区 | 坐席人员能力和绩效、IT支持员工满意度缺乏有效数据支撑 | 管理决策凭感觉,无法量化评估和持续优化 | **阶段四** |
"""
if old_table in content:
content = content.replace(old_table, new_table, 1)
print("✅ §2.2 痛点分析表格已更新(新增解决阶段列)")
else:
print("❌ 未找到 §2.2 原始表格,正在尝试逐行定位...")
# 尝试找到 §2.2 的开始位置
in_section = False
for i, line in enumerate(lines):
if "### 2.2 痛点分析" in line:
in_section = True
print(f" 找到 §2.2 起始行: {i+1}")
if in_section and line.strip().startswith("> **核心约束**"):
print(f" §2.2 结束行: {i+1}")
break
# === 2. 修改 §2.2 后面的引用块(痛点关系说明)===
old_quote = """> **核心约束**: 所有对象都是企业内员工,必须避免使用企微微信客服能力。
> **痛点关系**: 痛点1-3为员工体验层问题,痛点4-7为管理与人效层问题。后者是前者的深层根因——正是因为缺乏经验积累(痛点6)和数据支撑(痛点7),才导致服务质量不稳定(痛点4)和新人成长慢(痛点5),最终迫使员工绕过AI直接找"靠谱的老员工"(痛点1)。"""
new_quote = """> **核心约束**: 所有对象都是企业内员工,必须避免使用企微微信客服能力。
> **痛点关系**: 痛点1-3为员工体验层问题(阶段二解决),痛点4-5为坐席能力层问题(阶段三解决),痛点6-7为管理迭代层问题(阶段四解决)。阶段五(自动/辅助审核开单结单)主要解决多系统切换效率问题,进一步提升整体人效。"""
if old_quote in content:
content = content.replace(old_quote, new_quote, 1)
print("✅ §2.2 痛点关系说明已更新(标注解决阶段)")
else:
print("⚠️ 未找到痛点关系引用块,跳过")
# === 3. 修改 §5.1 阶段总览表:新增"解决痛点"列 ===
old_header_51 = """| 阶段 | 目标 | 核心变更 | 现有系统影响 |
|------|------|---------|------------|"""
new_header_51 = """| 阶段 | 目标 | 核心变更 | 解决痛点 | 现有系统影响 |
|------|------|---------|---------|------------|"""
if old_header_51 in content:
content = content.replace(old_header_51, new_header_51, 1)
print("✅ §5.1 表头已更新(新增解决痛点列)")
else:
print("❌ 未找到 §5.1 表头")
# === 4. 修改 §5.1 表格数据行 ===
replacements = [
# (old_line, new_line)
(
"| **阶段一** | AI机器人接入(按服务对象) | 将现有AI机器人从企微1对1消息模式迁入H5自建应用,保留RAGFlow+Dify+千问能力 | AI机器人入口切换,原有1对1窗口保留为降级通道 |",
"| **阶段一** | AI机器人接入(按服务对象) | 将现有AI机器人从企微1对1消息模式迁入H5自建应用,保留RAGFlow+Dify+千问能力 | 痛点1(部分)、API入口统一 | AI机器人入口切换,原有1对1窗口保留为降级通道 |"
),
(
"| **阶段二** | 迁移和集成面向员工的智能咨询功能 | H5员工端完整体验(AI对话+转人工+摇人+评分),双通道消息推送 | 员工服务入口逐步迁移至H5 |",
"| **阶段二** | 迁移和集成面向员工的智能咨询功能 | H5员工端完整体验(AI对话+转人工+摇人+评分),双通道消息推送 | **痛点1/2/3** | 员工服务入口逐步迁移至H5 |"
),
(
"| **阶段三** | 面向坐席的辅助回复和辅助判断 | 坐席工作台 AI Wingman(草稿回复+自动摘要+知识推荐+排查步骤) | 坐席从员工服务后台切换至自研工作台 |",
"| **阶段三** | 面向坐席的辅助回复和辅助判断 | 坐席工作台 AI Wingman(草稿回复+自动摘要+知识推荐+排查步骤) | **痛点4/5** | 坐席从员工服务后台切换至自研工作台 |"
),
(
"| **阶段四** | 日志标准和AI知识库迭代 | 会话标注体系 + AI知识库自动迭代闭环 + 数据统计看板 | AI知识库从人工维护升级为自动迭代 |",
"| **阶段四** | 日志标准和AI知识库迭代 | 会话标注体系 + AI知识库自动迭代闭环 + 数据统计看板 | **痛点6/7** | AI知识库从人工维护升级为自动迭代 |"
),
(
"| **阶段五** | 自动/辅助审核、开单、结单 | 工单/审批/设备异常一站式处理 + AI辅助填单+自动结单 | 替代多系统切换,统一工作台闭环 |",
"| **阶段五** | 自动/辅助审核、开单、结单 | 工单/审批/设备异常一站式处理 + AI辅助填单+自动结单 | 多系统切换效率问题 | 替代多系统切换,统一工作台闭环 |"
),
]
for old, new in replacements:
if old in content:
content = content.replace(old, new, 1)
print(f"✅ §5.1 数据行已更新: {old.split('|')[2].strip()}")
else:
print(f"❌ 未找到 §5.1 数据行: {old.split('|')[2].strip()}")
# === 5. 在 §5.2 各阶段详细规划开头,每个阶段标注"本阶段解决痛点" ===
stage_intros = [
(
"#### 阶段一:AI机器人接入(按服务对象)\n\n**目标**",
"#### 阶段一:AI机器人接入(按服务对象)\n\n> **本阶段解决痛点**:API入口统一(为阶段二解决痛点1/2/3打基础),按服务对象路由。\n\n**目标**"
),
(
"#### 阶段二:迁移和集成面向员工的智能咨询功能\n\n**目标**",
"#### 阶段二:迁移和集成面向员工的智能咨询功能\n\n> **本阶段解决痛点**:痛点1(绕过AI)、痛点2(另开窗口)、痛点3(无法跨主体共享)。\n\n**目标**"
),
(
"#### 阶段三:面向坐席的辅助回复和辅助判断\n\n**目标**",
"#### 阶段三:面向坐席的辅助回复和辅助判断\n\n> **本阶段解决痛点**:痛点4(人工咨询依赖个人能力)、痛点5(实习生成长慢)。\n\n**目标**"
),
(
"#### 阶段四:日志标准和AI知识库迭代\n\n**目标**",
"#### 阶段四:日志标准和AI知识库迭代\n\n> **本阶段解决痛点**:痛点6(个人经验无法积累传承)、痛点7(缺乏数据支撑的管理盲区)。\n\n**目标**"
),
(
"#### 阶段五:自动/辅助审核、开单、结单\n\n**目标**",
"#### 阶段五:自动/辅助审核、开单、结单\n\n> **本阶段解决痛点**:多系统切换效率问题(延伸痛点4/5,进一步提升人效)。\n\n**目标**"
),
]
for old, new in stage_intros:
if old in content:
content = content.replace(old, new, 1)
print(f"✅ §5.2 阶段标注已更新: {old.split('')[1].split('**')[0].strip()}")
else:
print(f"❌ 未找到 §5.2 阶段: {old.split('')[1].split('**')[0].strip()}")
# 写回文件
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
print("\n✅ PRD.md 痛点与阶段对应更新完成")
print(f" 文件: {filepath}")
+47
View File
@@ -0,0 +1,47 @@
import re
filepath = r"D:\资料\03-项目开发\wecom_it_smart_desk\docs\PRD.md"
with open(filepath, "r", encoding="utf-8") as f:
lines = f.readlines()
# 找到 §2.2 痛点分析 的起始行和结束行(下一个 "---" 之前)
start = None
end = None
for i, line in enumerate(lines):
if line.strip() == "### 2.2 痛点分析":
start = i
elif start is not None and line.strip() == "---":
end = i
break
print(f"§2.2 起始行: {start+1}, 结束行: {end+1}")
print(f"--- 前一行内容: {repr(lines[end-1])}")
# 构造新内容(替换 start 到 end-1 行)
new_lines = [
"### 2.2 痛点分析\n",
"\n",
"> **痛点归纳说明**:将原7条痛点归纳为4条核心痛点,每条对应明确的解决阶段,便于追溯开发升级功能的针对性。\n",
"\n",
"| # | 核心痛点 | 具体表现(归纳自原痛点) | 影响 | 解决阶段 |\n",
"|---|---------|----------------------|------|---------|\n",
"| 1 | **员工入口体验差** | ①员工可绕过AI直达人工,AI筛选比例极低;②转人工需另开新窗口,体验割裂;③AI机器人和员工服务无法跨主体共享,跨企业服务不可达 | AI使用率低,员工困惑,服务覆盖范围受限 | **阶段二** |\n",
"| 2 | **坐席能力不稳定** | ①坐席回复质量依赖个人能力和经验,受情绪/状态影响;②实习生成长慢,辅导老师投入大但产出低,知识传承断档 | 服务质量参差不齐,人才培养投入产出比低 | **阶段三** |\n",
"| 3 | **知识无法积累传承** | 坐席个人经验和成果无法有效积累、传承、迭代更新,人员离职即经验流失 | 团队整体能力无法持续提升,重复踩坑 | **阶段四** |\n",
"| 4 | **管理缺乏数据支撑** | 坐席能力和绩效、IT支持员工满意度缺乏有效数据支撑,管理决策凭感觉 | 无法量化评估和持续优化,管理盲区大 | **阶段四** |\n",
"\n",
"> **核心约束**: 所有对象都是企业内员工,必须避免使用企微微信客服能力。\n",
"\n",
"> **痛点与阶段映射**: 痛点1(员工体验层)→ 阶段二解决;痛点2(坐席能力层)→ 阶段三解决;痛点3~4(管理迭代层)→ 阶段四解决。阶段五(自动/辅助审核开单结单)进一步解决多系统切换效率问题,提升整体人效。\n",
"\n",
]
# 替换
lines[start:end] = new_lines
with open(filepath, "w", encoding="utf-8") as f:
f.writelines(lines)
print(f"✅ §2.2 痛点分析已归纳压缩为4条核心痛点(原7条 → 现4条)")
print(f" 替换行范围: {start+1} ~ {end}")
+91
View File
@@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
# 将排查步骤栏从输入框下方移动到人员信息栏下方、消息区域上方
filepath = r"C:\Users\simon\WorkBuddy\2026-05-21-16-57-26\agent-workspace-v5_3.html"
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# 1. 找到排查步骤栏的起始和结束位置
ts_start_marker = '<div class="troubleshoot-bar" id="tsBar">'
ts_start_idx = content.find(ts_start_marker)
if ts_start_idx == -1:
print("ERROR: 找不到排查步骤栏起始标记")
exit(1)
# 找到对应的结束 </div>
pos = ts_start_idx
# 跳过起始标签所在的行
pos = content.find('\n', pos) + 1
depth = 0
ts_end_idx = -1
while pos < len(content):
next_div = content.find('<div', pos)
next_end_div = content.find('</div>', pos)
if next_end_div == -1:
break
if next_div != -1 and next_div < next_end_div:
depth += 1
pos = next_div + 1
else:
if depth == 0:
ts_end_idx = next_end_div + len('</div>')
break
depth -= 1
pos = next_end_div + len('</div>')
if ts_end_idx == -1:
print("ERROR: 找不到排查步骤栏的闭合标签")
exit(1)
ts_bar_html = content[ts_start_idx:ts_end_idx]
print(f"找到排查步骤栏:位置 {ts_start_idx}{ts_end_idx}")
print(f"长度:{len(ts_bar_html)} 字符")
# 2. 从原位置删除排查步骤栏
content_without_ts = content[:ts_start_idx] + content[ts_end_idx:]
# 3. 找到插入位置:在 user-detail-panel 的 </div> 之后,chat-messages 之前
# 目标标记
target_marker = '</div>\n \n <!-- Chat -->\n <div class="chat-messages">'
target_idx = content_without_ts.find(target_marker)
if target_idx == -1:
# 尝试其他格式
target_marker2 = '</div>\n \n <!-- Chat -->'
target_idx = content_without_ts.find(target_marker2)
if target_idx == -1:
print("ERROR: 找不到目标插入位置(user-detail-panel 闭合后)")
# 调试:看看 user-detail-panel 附近的内容
udp_start = content_without_ts.find('<div class="user-detail-panel">')
if udp_start != -1:
print("user-detail-panel 附近内容:")
print(repr(content_without_ts[udp_start:udp_start+500]))
exit(1)
# 找到 target_idx 后,需要定位到 <!-- Chat --> 之后的 <div class="chat-messages"> 之前
# 重新计算
insert_pos = content_without_ts.find('</div>\n \n <!-- Chat -->')
if insert_pos == -1:
print("ERROR: 找不到准确插入点")
exit(1)
insert_pos = content_without_ts.find('>', insert_pos) + 1
# 现在 insert_pos 指向 <!-- Chat --> 之后的位置
else:
insert_pos = target_idx + len('</div>')
# 4. 在 insert_pos 处插入排查步骤栏
new_content = content_without_ts[:insert_pos] + '\n ' + ts_bar_html.strip() + '\n ' + content_without_ts[insert_pos:]
# 5. 写回文件
with open(filepath, 'w', encoding='utf-8') as f:
f.write(new_content)
print("✅ 排查步骤栏已移动到人员信息栏下方、消息区域上方")
print(f"原位置:{ts_start_idx}")
print(f"新位置:{insert_pos}")
+121
View File
@@ -0,0 +1,121 @@
# ==================================================================================================
# 一键重启后端服务(支持从任意位置运行)
# 用法:scripts\restart_backend.ps1
# ==================================================================================================
$SCRIPT_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path
$PROJECT_ROOT = Split-Path -Parent $SCRIPT_DIR
$BACKEND_DIR = Join-Path $PROJECT_ROOT "backend"
# ===================================================================
# Step 1: 杀掉占用 8000 端口的旧进程
# ===================================================================
Write-Host "=== Step 1: Kill old processes on port 8000 ===" -ForegroundColor Yellow
$raw = netstat -ano | Select-String ":8000" | Select-String "LISTENING"
$pids = $raw | ForEach-Object { ($_ -split '\s+')[-1] } | Sort-Object -Unique
foreach ($pid in $pids) {
if ($pid -match '^\d+$') {
Write-Host " Killing PID $pid ..." -ForegroundColor Red
Stop-Process -Id ([int]$pid) -Force -ErrorAction SilentlyContinue
}
}
Start-Sleep -Seconds 2
Write-Host " Done." -ForegroundColor Green
# ===================================================================
# Step 2: 检查 PostgreSQL
# ===================================================================
Write-Host ""
Write-Host "=== Step 2: Check PostgreSQL ===" -ForegroundColor Yellow
# 动态查找 psql.exe(常见安装路径 → PATH
$PG_CANDIDATES = @(
"C:\Program Files\PostgreSQL\16\bin\psql.exe",
"C:\Program Files\PostgreSQL\15\bin\psql.exe",
"C:\Program Files\PostgreSQL\14\bin\psql.exe"
)
$PG_CLI = $PG_CANDIDATES | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $PG_CLI) {
$PG_CLI = Get-Command psql -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source
}
if ($PG_CLI -and (Test-Path $PG_CLI)) {
# 从 .env 文件读取数据库密码(优先 POSTGRES_PASSWORD,其次解析 DATABASE_URL
$envFile = Join-Path $PROJECT_ROOT ".env"
$pgPassword = "postgres" # 兜底默认值
if (Test-Path $envFile) {
$envLines = Get-Content $envFile
# 方式1:直接读取 POSTGRES_PASSWORD
$pwLine = $envLines | Where-Object { $_ -match '^POSTGRES_PASSWORD=' }
if ($pwLine) {
$pgPassword = ($pwLine -replace '^POSTGRES_PASSWORD=' , '').Trim('"')
} else {
# 方式2:从 DATABASE_URL 中解析密码
$dbLine = $envLines | Where-Object { $_ -match '^DATABASE_URL=' }
if ($dbLine -match '://[^:]+:([^@]+)@') {
$pgPassword = $matches[1]
}
}
}
$env:PGPASSWORD = $pgPassword
$pgResult = & $PG_CLI -U postgres -h localhost -c "SELECT 1" -d it_smart_desk 2>&1
if ($pgResult -match "1 row") {
Write-Host " PostgreSQL OK" -ForegroundColor Green
} else {
Write-Host " PostgreSQL FAILED - make sure it is running" -ForegroundColor Red
}
} else {
Write-Host " psql.exe not found. Install PostgreSQL client or add it to PATH." -ForegroundColor Red
}
# ===================================================================
# Step 3: 检查 Redis
# ===================================================================
Write-Host ""
Write-Host "=== Step 3: Check Redis ===" -ForegroundColor Yellow
# 动态查找 redis-cli.exe
$REDIS_CANDIDATES = @(
"C:\Program Files\Redis\redis-cli.exe",
"C:\Program Files (x86)\Redis\redis-cli.exe"
)
$REDIS_CLI = $REDIS_CANDIDATES | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $REDIS_CLI) {
$REDIS_CLI = Get-Command redis-cli -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source
}
if ($REDIS_CLI -and (Test-Path $REDIS_CLI)) {
$redisResult = & $REDIS_CLI ping 2>&1
if ($redisResult -match "PONG") {
Write-Host " Redis OK" -ForegroundColor Green
} else {
Write-Host " Redis FAILED - make sure it is running" -ForegroundColor Red
}
} else {
Write-Host " redis-cli.exe not found. Install Redis client or add it to PATH." -ForegroundColor Red
}
# ===================================================================
# Step 4: 启动后端
# ===================================================================
Write-Host ""
Write-Host "=== Step 4: Starting backend ===" -ForegroundColor Yellow
Set-Location $BACKEND_DIR
# 优先使用 venv 中的 python,找不到则使用 PATH 中的 python
if (Test-Path "venv\Scripts\python.exe") {
$PYTHON_EXE = "venv\Scripts\python.exe"
} elseif (Get-Command python -ErrorAction SilentlyContinue) {
$PYTHON_EXE = "python"
} else {
Write-Host " [ERROR] python not found. Please install Python or create backend\venv." -ForegroundColor Red
Read-Host "Press Enter to exit"
exit 1
exit 1
}
Write-Host " Backend dir : $BACKEND_DIR"
Write-Host " Python : $PYTHON_EXE"
Write-Host ""
& $PYTHON_EXE -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""
在正式服务器上执行:将 admin001 设为管理后台管理员
运行方式:
1. SSH 到堡垒机,再 SSH 到 10.90.5.110
2. 进入部署目录:cd /opt/wecom-it-desk
3. 执行:docker exec -i wecom_it_backend python /app/scripts/set_admin.py
(如果脚本不在容器内,可先 docker cp 进去,或用下面的 SQL 方式)
"""
import sys
import os
# 方法一:直接通过 SQLAlchemy 写入数据库(在容器内执行)
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
import uuid
# 从环境变量读取数据库配置
DB_URL = os.environ.get(
"DATABASE_URL",
"postgresql://wecom:wecom_secret_2026@postgres:5432/wecom_it_desk"
)
engine = create_engine(DB_URL)
Session = sessionmaker(bind=engine)
session = Session()
# 检查 agents 表中是否已有 admin001
result = session.execute(
text("SELECT id, user_id, name, role FROM agents WHERE user_id = :uid"),
{"uid": "admin001"}
).fetchone()
if result:
agent_id, user_id, name, role = result
print(f"✅ 已存在记录:id={agent_id}, user_id={user_id}, name={name}, role={role}")
if role != "admin":
session.execute(
text("UPDATE agents SET role = 'admin' WHERE user_id = :uid"),
{"uid": "admin001"}
)
session.commit()
print(f"✅ 已将 {user_id} 角色更新为 admin")
else:
print(f"ℹ️ 角色已经是 admin,无需修改")
else:
# 不存在则创建
new_id = str(uuid.uuid4())
session.execute(
text(
"INSERT INTO agents (id, user_id, name, status, current_load, max_load, role, skill_tags, created_at, updated_at) "
"VALUES (:id, :uid, :name, 'offline', 0, 5, 'admin', '[]', NOW(), NOW())"
),
{"id": new_id, "uid": "admin001", "name": "系统管理员"}
)
session.commit()
print(f"✅ 已创建管理员记录:id={new_id}, user_id=admin001, role=admin")
session.close()
print("\n完成。")
+182
View File
@@ -0,0 +1,182 @@
# =============================================================================
# 外部系统集成 — 凭据配置脚本
# =============================================================================
# 用法:
# 1. 在下方填入真实凭据(替换 <填入...> 占位符)
# 2. 保存文件
# 3. 运行:cd backend && python -m scripts.setup_integrations
#
# 说明:
# - 脚本会将凭据写入 system_configs 数据库表
# - 已存在的配置键会更新,不会重复插入
# - 运行后可在管理后台 → 系统集成 页面查看和测试连接
# - 此文件已在 .gitignore 中排除,凭据不会提交到 Git
# =============================================================================
# --------------------------------------------------------------------------
# 火绒企业版 — AccessKey 认证模式
# --------------------------------------------------------------------------
# 在火绒管理后台 → 系统设置 → API管理 中创建 AccessKey
HUORONG = {
# 火绒管理后台的内网API地址(含协议和端口)
"base_url": "<填入火绒Base URL,如 http://huorong.oa.servyou-it.com/:8080>",
# AccessKey ID(在火绒后台创建API密钥时生成)
"access_key_id": "59O8K6NSUW",
# AccessKey Secret(创建时仅显示一次,请妥善保管)
"access_key_secret": "VXM7B878BDUN0P5P5KYC",
}
# --------------------------------------------------------------------------
# 联软LV7000 — 账号密码认证模式
# --------------------------------------------------------------------------
# 在联软管理后台 → 系统设置 → API管理 中创建API账号
LIANRUAN = {
# 联软管理后台的内网API地址(含协议和端口,默认端口30098)
"base_url": "<填入联软Base URL,如 http://192.168.x.x:30098>",
# API账号
"api_account": "<填入API账号>",
# API密码
"api_password": "<填入API密码>",
# 验证密钥(部分版本需要,无则留空字符串)
"validate_key": "",
}
# ==========================================================================
# 以下为脚本逻辑,无需修改
# ==========================================================================
import asyncio
import sys
import os
# 将 backend 目录加入 Python 路径,以便导入 app 模块
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from sqlalchemy import select
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
async def upsert_config(
session: AsyncSession,
key: str,
value: str,
description: str,
) -> None:
"""插入或更新一条配置记录。
如果 key 已存在则更新 value,否则插入新记录。
Args:
session: 数据库异步会话
key: 配置键(如 integration_huorong_base_url
value: 配置值
description: 配置说明
"""
# 动态导入,避免在模块级别触发 app 初始化
from app.models.system_config import SystemConfig
# 查询是否已存在该配置键
result = await session.execute(
select(SystemConfig).where(SystemConfig.config_key == key)
)
existing = result.scalar_one_or_none()
if existing:
# 已存在 → 更新值
existing.config_value = value
print(f" ✏️ 更新: {key}")
else:
# 不存在 → 插入新记录
new_config = SystemConfig(
config_key=key,
config_value=value,
description=description,
)
session.add(new_config)
print(f" 新增: {key}")
async def setup_huorong(session: AsyncSession) -> None:
"""将火绒凭据写入数据库。
Args:
session: 数据库异步会话
"""
# 检查是否还是占位符
if HUORONG["base_url"].startswith("<"):
print("⏭️ 火绒:跳过(凭据未填写)")
return
print("\n🔥 配置火绒企业版...")
prefix = "integration_huorong_"
await upsert_config(session, f"{prefix}base_url", HUORONG["base_url"], "火绒 Base URL")
await upsert_config(session, f"{prefix}access_key_id", HUORONG["access_key_id"], "火绒 AccessKey ID")
await upsert_config(session, f"{prefix}access_key_secret", HUORONG["access_key_secret"], "火绒 AccessKey Secret")
await session.commit()
print(" ✅ 火绒配置已保存")
async def setup_lianruan(session: AsyncSession) -> None:
"""将联软凭据写入数据库。
Args:
session: 数据库异步会话
"""
# 检查是否还是占位符
if LIANRUAN["base_url"].startswith("<"):
print("⏭️ 联软:跳过(凭据未填写)")
return
print("\n💻 配置联软LV7000...")
prefix = "integration_lianruan_"
await upsert_config(session, f"{prefix}base_url", LIANRUAN["base_url"], "联软 Base URL")
await upsert_config(session, f"{prefix}api_account", LIANRUAN["api_account"], "联软 API账号")
await upsert_config(session, f"{prefix}api_password", LIANRUAN["api_password"], "联软 API密码")
await upsert_config(session, f"{prefix}validate_key", LIANRUAN["validate_key"], "联软 验证密钥")
await session.commit()
print(" ✅ 联软配置已保存")
async def main() -> None:
"""脚本主入口:读取数据库连接 → 写入凭据配置。"""
# 从 .env 或环境变量获取数据库连接地址
# 优先使用同步驱动(psycopg2),因为此脚本不需要异步数据库操作
database_url = os.environ.get("DATABASE_URL", "")
# 如果是 postgresql:// 开头(同步驱动格式),转为 asyncpg 格式
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
# 回退到本地开发默认值
if not database_url:
database_url = "postgresql+asyncpg://postgres:postgres@localhost:5432/it_smart_desk"
print(f"⚠️ 未设置 DATABASE_URL,使用默认值: {database_url}")
print(f"📦 数据库: {database_url.split('@')[-1]}") # 只显示主机部分,隐藏密码
# 创建异步引擎和会话工厂
engine = create_async_engine(database_url, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
await setup_huorong(session)
await setup_lianruan(session)
# 关闭引擎连接池
await engine.dispose()
print("\n🎉 配置完成!请在管理后台 → 系统集成 中测试连接")
if __name__ == "__main__":
asyncio.run(main())
+30
View File
@@ -0,0 +1,30 @@
@echo off
REM =====================================================================
REM 启动后端服务(支持从任意位置运行)
REM 用法:scripts\start_backend.bat
REM =====================================================================
REM 获取脚本所在目录,然后计算项目根目录(scripts 的上级目录)
set SCRIPT_DIR=%~dp0
set PROJECT_ROOT=%SCRIPT_DIR%..
REM 切换到 backend 目录
cd /d "%PROJECT_ROOT%\backend"
if errorlevel 1 (
echo [ERROR] 找不到 backend 目录:%PROJECT_ROOT%\backend
pause
exit /b 1
)
REM 优先使用 venv 中的 python,找不到则使用 PATH 中的 python
if exist "venv\Scripts\python.exe" (
set PYTHON_EXE=venv\Scripts\python.exe
) else (
set PYTHON_EXE=python
)
echo [INFO] 工作目录:%CD%
echo [INFO] Python%PYTHON_EXE%
echo.
"%PYTHON_EXE%" -X utf8 -m uvicorn app.main:app --host 127.0.0.1 --port 8000