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
+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)