Initial commit: 浼佷笟寰俊 AI 鏈哄櫒浜哄姪鐞?MVP
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2
backend/app/routers/__init__.py
Normal file
2
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# routers
|
||||
from app.routers import auth, wecom
|
||||
1
backend/app/routers/admin/__init__.py
Normal file
1
backend/app/routers/admin/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# admin routers
|
||||
31
backend/app/routers/admin/kb.py
Normal file
31
backend/app/routers/admin/kb.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Admin API: GET/POST /api/admin/kb/docs/upload。"""
|
||||
from fastapi import APIRouter, Depends, UploadFile
|
||||
from app.deps import get_current_user
|
||||
from app.logging_config import get_trace_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/kb/docs")
|
||||
async def list_kb_docs(_user=Depends(get_current_user)):
|
||||
"""知识库文档列表(占位)。"""
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": [
|
||||
{"id": "1", "filename": "faq.pdf", "size": 102400, "uploaded_at": "2025-02-05T10:00:00Z"},
|
||||
],
|
||||
"trace_id": get_trace_id(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/kb/docs/upload")
|
||||
async def upload_kb_doc(file: UploadFile, _user=Depends(get_current_user)):
|
||||
"""上传知识库文档(占位:先存本地/对象存储)。"""
|
||||
# 占位:实际应保存到对象存储或本地卷
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {"id": "new_1", "filename": file.filename, "size": 0},
|
||||
"trace_id": get_trace_id(),
|
||||
}
|
||||
40
backend/app/routers/admin/sessions.py
Normal file
40
backend/app/routers/admin/sessions.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Admin API: GET /api/admin/sessions, GET /api/admin/sessions/{id}。"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from app.deps import get_current_user
|
||||
from app.logging_config import get_trace_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/sessions")
|
||||
async def list_sessions(_user=Depends(get_current_user)):
|
||||
"""会话列表(占位)。"""
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": [
|
||||
{"id": "1", "external_user_id": "ext_001", "external_name": "客户A", "status": "open", "created_at": "2025-02-05T10:00:00Z"},
|
||||
{"id": "2", "external_user_id": "ext_002", "external_name": "客户B", "status": "transferred", "created_at": "2025-02-05T11:00:00Z"},
|
||||
],
|
||||
"trace_id": get_trace_id(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/sessions/{id}")
|
||||
async def get_session(id: str, _user=Depends(get_current_user)):
|
||||
"""会话详情:消息列表(占位)。"""
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {
|
||||
"id": id,
|
||||
"external_user_id": "ext_001",
|
||||
"external_name": "客户A",
|
||||
"status": "open",
|
||||
"messages": [
|
||||
{"id": 1, "role": "user", "content": "你好", "created_at": "2025-02-05T10:00:00Z"},
|
||||
{"id": 2, "role": "assistant", "content": "您好,有什么可以帮您?", "created_at": "2025-02-05T10:00:01Z"},
|
||||
],
|
||||
},
|
||||
"trace_id": get_trace_id(),
|
||||
}
|
||||
37
backend/app/routers/admin/settings.py
Normal file
37
backend/app/routers/admin/settings.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Admin API: GET/PATCH /api/admin/settings。"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from app.deps import get_current_user
|
||||
from app.logging_config import get_trace_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class UpdateSettingsBody(BaseModel):
|
||||
model_name: str | None = None
|
||||
strategy: dict | None = None
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_settings(_user=Depends(get_current_user)):
|
||||
"""获取设置(占位)。"""
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {
|
||||
"model_name": "gpt-4",
|
||||
"strategy": {"faq_priority": True, "rag_enabled": False},
|
||||
},
|
||||
"trace_id": get_trace_id(),
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/settings")
|
||||
async def update_settings(body: UpdateSettingsBody, _user=Depends(get_current_user)):
|
||||
"""更新设置(占位)。"""
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {"model_name": body.model_name or "gpt-4", "strategy": body.strategy or {}},
|
||||
"trace_id": get_trace_id(),
|
||||
}
|
||||
52
backend/app/routers/admin/tickets.py
Normal file
52
backend/app/routers/admin/tickets.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Admin API: GET/POST/PATCH /api/admin/tickets。"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from app.deps import get_current_user
|
||||
from app.logging_config import get_trace_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CreateTicketBody(BaseModel):
|
||||
session_id: str
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class UpdateTicketBody(BaseModel):
|
||||
status: str | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
@router.get("/tickets")
|
||||
async def list_tickets(_user=Depends(get_current_user)):
|
||||
"""工单列表(占位)。"""
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": [
|
||||
{"id": "1", "session_id": "1", "reason": "转人工", "status": "open", "created_at": "2025-02-05T10:00:00Z"},
|
||||
],
|
||||
"trace_id": get_trace_id(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tickets")
|
||||
async def create_ticket(body: CreateTicketBody, _user=Depends(get_current_user)):
|
||||
"""创建工单(占位)。"""
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {"id": "new_1", "session_id": body.session_id, "reason": body.reason, "status": "open"},
|
||||
"trace_id": get_trace_id(),
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/tickets/{id}")
|
||||
async def update_ticket(id: str, body: UpdateTicketBody, _user=Depends(get_current_user)):
|
||||
"""更新工单(占位)。"""
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {"id": id, "status": body.status or "open", "reason": body.reason},
|
||||
"trace_id": get_trace_id(),
|
||||
}
|
||||
74
backend/app/routers/admin/users.py
Normal file
74
backend/app/routers/admin/users.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Admin API: GET/POST/PATCH/DELETE /api/admin/users(仅管理员可见)。"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from app.deps import get_current_user
|
||||
from app.logging_config import get_trace_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CreateUserBody(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
role: str = "admin"
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class UpdateUserBody(BaseModel):
|
||||
password: str | None = None
|
||||
role: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(current_user=Depends(get_current_user)):
|
||||
"""用户列表(仅管理员)。"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="仅管理员可访问")
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": [
|
||||
{"id": "1", "username": "admin", "role": "admin", "is_active": True, "created_at": "2025-02-05T10:00:00Z"},
|
||||
],
|
||||
"trace_id": get_trace_id(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user(body: CreateUserBody, current_user=Depends(get_current_user)):
|
||||
"""创建用户(仅管理员)。"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="仅管理员可访问")
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {"id": "new_1", "username": body.username, "role": body.role, "is_active": body.is_active},
|
||||
"trace_id": get_trace_id(),
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/users/{id}")
|
||||
async def update_user(id: str, body: UpdateUserBody, current_user=Depends(get_current_user)):
|
||||
"""更新用户(仅管理员)。"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="仅管理员可访问")
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {"id": id, "role": body.role, "is_active": body.is_active},
|
||||
"trace_id": get_trace_id(),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/users/{id}")
|
||||
async def delete_user(id: str, current_user=Depends(get_current_user)):
|
||||
"""删除用户(仅管理员)。"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="仅管理员可访问")
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": None,
|
||||
"trace_id": get_trace_id(),
|
||||
}
|
||||
47
backend/app/routers/auth.py
Normal file
47
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Auth API:POST /api/auth/login、GET /api/auth/me。"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.models import User
|
||||
from app.services.auth_service import (
|
||||
get_user_by_username,
|
||||
verify_password,
|
||||
create_access_token,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class LoginBody(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(body: LoginBody, db: AsyncSession = Depends(get_db)):
|
||||
user = await get_user_by_username(db, body.username)
|
||||
if not user or not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="账号已禁用")
|
||||
token = create_access_token(subject=user.username)
|
||||
return LoginResponse(access_token=token, token_type="bearer")
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(current_user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"id": str(current_user.id),
|
||||
"username": current_user.username,
|
||||
"role": current_user.role,
|
||||
"is_active": current_user.is_active,
|
||||
"created_at": current_user.created_at.isoformat() if current_user.created_at else None,
|
||||
}
|
||||
15
backend/app/routers/health.py
Normal file
15
backend/app/routers/health.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""健康检查:供负载均衡与 CI 验证。"""
|
||||
from fastapi import APIRouter
|
||||
from app.logging_config import get_trace_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {"status": "up"},
|
||||
"trace_id": get_trace_id(),
|
||||
}
|
||||
12
backend/app/routers/knowledge.py
Normal file
12
backend/app/routers/knowledge.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""知识库上传占位:先落本地卷/对象存储占位。"""
|
||||
from fastapi import APIRouter, UploadFile
|
||||
from app.logging_config import get_trace_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_file(file: UploadFile):
|
||||
"""上传知识库文件,占位落盘。"""
|
||||
# 占位:保存到 backend/uploads 或配置的存储
|
||||
return {"code": 0, "message": "ok", "data": {"filename": file.filename}, "trace_id": get_trace_id()}
|
||||
53
backend/app/routers/sessions.py
Normal file
53
backend/app/routers/sessions.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""会话列表与消息:从 DB 读取,需登录。"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_current_user_id
|
||||
from app.logging_config import get_trace_id
|
||||
from app.models import ChatSession, Message
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _session_row(s: ChatSession) -> dict:
|
||||
return {
|
||||
"id": s.id,
|
||||
"external_user_id": s.external_user_id,
|
||||
"external_name": s.external_name,
|
||||
"status": s.status,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _message_row(m: Message) -> dict:
|
||||
return {
|
||||
"id": m.id,
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
"created_at": m.created_at.isoformat() if m.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_sessions(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: str = Depends(get_current_user_id),
|
||||
):
|
||||
"""会话列表。"""
|
||||
r = await db.execute(select(ChatSession).order_by(ChatSession.updated_at.desc()))
|
||||
rows = r.scalars().all()
|
||||
return {"code": 0, "message": "ok", "data": [_session_row(s) for s in rows], "trace_id": get_trace_id()}
|
||||
|
||||
|
||||
@router.get("/{session_id}/messages")
|
||||
async def list_messages(
|
||||
session_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: str = Depends(get_current_user_id),
|
||||
):
|
||||
"""某会话消息列表。"""
|
||||
r = await db.execute(select(Message).where(Message.session_id == session_id).order_by(Message.id))
|
||||
rows = r.scalars().all()
|
||||
return {"code": 0, "message": "ok", "data": [_message_row(m) for m in rows], "trace_id": get_trace_id()}
|
||||
15
backend/app/routers/settings.py
Normal file
15
backend/app/routers/settings.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""设置页占位:仅占位接口。"""
|
||||
from fastapi import APIRouter
|
||||
from app.logging_config import get_trace_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_settings():
|
||||
return {"code": 0, "message": "ok", "data": {}, "trace_id": get_trace_id()}
|
||||
|
||||
|
||||
@router.put("")
|
||||
def update_settings():
|
||||
return {"code": 0, "message": "ok", "data": None, "trace_id": get_trace_id()}
|
||||
67
backend/app/routers/tickets.py
Normal file
67
backend/app/routers/tickets.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""工单转人工:创建工单入库、手动回复调企业微信 API。"""
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_current_user_id
|
||||
from app.logging_config import get_trace_id
|
||||
from app.models import ChatSession, Ticket
|
||||
from app.services.wecom_api import send_text_to_external
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CreateTicketBody(BaseModel):
|
||||
session_id: int
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class SendReplyBody(BaseModel):
|
||||
session_id: int
|
||||
content: str
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_ticket(
|
||||
body: CreateTicketBody,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: str = Depends(get_current_user_id),
|
||||
):
|
||||
"""创建转人工工单并更新会话状态。"""
|
||||
r = await db.execute(select(ChatSession).where(ChatSession.id == body.session_id))
|
||||
session = r.scalar_one_or_none()
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
ticket = Ticket(session_id=body.session_id, reason=body.reason or None)
|
||||
db.add(ticket)
|
||||
session.status = "transferred"
|
||||
await db.flush()
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {"ticket_id": str(ticket.id)},
|
||||
"trace_id": get_trace_id(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/reply")
|
||||
async def send_reply(
|
||||
body: SendReplyBody,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: str = Depends(get_current_user_id),
|
||||
):
|
||||
"""手动回复:通过企业微信 API 发给客户。"""
|
||||
r = await db.execute(select(ChatSession).where(ChatSession.id == body.session_id))
|
||||
session = r.scalar_one_or_none()
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
try:
|
||||
await send_text_to_external(session.external_user_id, body.content)
|
||||
except Exception as e:
|
||||
logger.exception("wecom send reply failed")
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
return {"code": 0, "message": "ok", "data": None, "trace_id": get_trace_id()}
|
||||
163
backend/app/routers/wecom.py
Normal file
163
backend/app/routers/wecom.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""企业微信回调:GET 校验 + POST 消息回调(验签、解密、echo 回复、会话入库)。"""
|
||||
import time
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
from fastapi import APIRouter, Request, Query, Depends
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.services.session_service import get_or_create_session, add_message
|
||||
from app.services.wecom_crypto import (
|
||||
verify_and_decrypt_echostr,
|
||||
verify_signature,
|
||||
parse_encrypted_body,
|
||||
decrypt,
|
||||
parse_decrypted_xml,
|
||||
build_reply_xml,
|
||||
encrypt,
|
||||
make_reply_signature,
|
||||
build_encrypted_response,
|
||||
)
|
||||
from app.logging_config import get_trace_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
def wecom_verify(
|
||||
request: Request,
|
||||
signature: str = Query(None, alias="signature"),
|
||||
msg_signature: str = Query(None, alias="msg_signature"),
|
||||
timestamp: str = Query(..., alias="timestamp"),
|
||||
nonce: str = Query(..., alias="nonce"),
|
||||
echostr: str = Query(..., alias="echostr"),
|
||||
):
|
||||
"""企业微信 GET 验签:校验签名并解密 echostr,原样返回明文。
|
||||
兼容 signature 和 msg_signature 两种参数名。
|
||||
"""
|
||||
trace_id = get_trace_id()
|
||||
# 兼容 signature 和 msg_signature 两种参数名
|
||||
sig = msg_signature or signature
|
||||
if not sig:
|
||||
logger.warning(
|
||||
"wecom verify missing signature",
|
||||
extra={"trace_id": trace_id, "query_params": dict(request.query_params)},
|
||||
)
|
||||
return PlainTextResponse("", status_code=400)
|
||||
plain = verify_and_decrypt_echostr(sig, timestamp, nonce, echostr)
|
||||
if plain is None:
|
||||
logger.warning(
|
||||
"wecom verify failed",
|
||||
extra={"trace_id": trace_id, "timestamp": timestamp, "nonce": nonce},
|
||||
)
|
||||
return PlainTextResponse("", status_code=400)
|
||||
logger.info(
|
||||
"wecom verify success",
|
||||
extra={"trace_id": trace_id, "echostr_length": len(echostr)},
|
||||
)
|
||||
return PlainTextResponse(plain)
|
||||
|
||||
|
||||
@router.post("/callback")
|
||||
async def wecom_callback(
|
||||
request: Request,
|
||||
signature: str = Query(None, alias="signature"),
|
||||
msg_signature: str = Query(None, alias="msg_signature"),
|
||||
timestamp: str = Query(..., alias="timestamp"),
|
||||
nonce: str = Query(..., alias="nonce"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""POST 消息回调:验签、解密、会话与消息入库、文本 echo 回复。
|
||||
兼容 signature 和 msg_signature 两种参数名。
|
||||
"""
|
||||
trace_id = get_trace_id()
|
||||
# 兼容 signature 和 msg_signature 两种参数名
|
||||
sig = msg_signature or signature
|
||||
if not sig:
|
||||
logger.warning(
|
||||
"wecom post missing signature",
|
||||
extra={"trace_id": trace_id, "query_params": dict(request.query_params)},
|
||||
)
|
||||
return PlainTextResponse("", status_code=400)
|
||||
body = await request.body()
|
||||
encrypt_raw, err = parse_encrypted_body(body)
|
||||
if err:
|
||||
logger.warning(
|
||||
"wecom post parse error",
|
||||
extra={"trace_id": trace_id, "error": err},
|
||||
)
|
||||
return PlainTextResponse("", status_code=400)
|
||||
if not verify_signature(sig, timestamp, nonce, encrypt_raw):
|
||||
logger.warning(
|
||||
"wecom post verify failed",
|
||||
extra={"trace_id": trace_id, "timestamp": timestamp},
|
||||
)
|
||||
return PlainTextResponse("", status_code=400)
|
||||
try:
|
||||
plain_xml = decrypt(encrypt_raw)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"wecom decrypt error",
|
||||
extra={"trace_id": trace_id, "error": str(e)},
|
||||
)
|
||||
return PlainTextResponse("", status_code=400)
|
||||
msg = parse_decrypted_xml(plain_xml)
|
||||
if not msg:
|
||||
logger.warning(
|
||||
"wecom xml parse failed",
|
||||
extra={"trace_id": trace_id},
|
||||
)
|
||||
return PlainTextResponse("", status_code=400)
|
||||
to_user = msg.get("ToUserName", "")
|
||||
from_user = msg.get("FromUserName", "") # external_userid
|
||||
msg_id = msg.get("MsgId", "")
|
||||
msg_type = msg.get("MsgType", "")
|
||||
content = (msg.get("Content") or "").strip()
|
||||
content_summary = content[:50] + "..." if len(content) > 50 else content
|
||||
|
||||
# 记录日志:trace_id + external_userid + msgid + 内容摘要
|
||||
logger.info(
|
||||
"wecom message received",
|
||||
extra={
|
||||
"trace_id": trace_id,
|
||||
"external_userid": from_user,
|
||||
"msgid": msg_id,
|
||||
"msg_type": msg_type,
|
||||
"content_summary": content_summary or "(empty)",
|
||||
},
|
||||
)
|
||||
|
||||
# 会话入库:external_user_id = from_user(客户)
|
||||
session = await get_or_create_session(db, from_user, msg.get("Contact"))
|
||||
await add_message(db, session.id, "user", content or "(非文本消息)")
|
||||
|
||||
# Echo 文本:回复"已收到:{用户消息}"
|
||||
if msg_type == "text" and content:
|
||||
reply_content = f"已收到:{content}"
|
||||
else:
|
||||
reply_content = "已收到"
|
||||
|
||||
await add_message(db, session.id, "assistant", reply_content)
|
||||
|
||||
# 回复给客户(被动回复 XML)
|
||||
reply_xml = build_reply_xml(from_user, to_user, reply_content)
|
||||
enc = encrypt(reply_xml)
|
||||
ts = str(int(time.time()))
|
||||
reply_nonce = "".join(random.choices(string.ascii_letters + string.digits, k=16))
|
||||
sig = make_reply_signature(enc, ts, reply_nonce)
|
||||
resp_xml = build_encrypted_response(enc, sig, ts, reply_nonce)
|
||||
|
||||
logger.info(
|
||||
"wecom reply sent",
|
||||
extra={
|
||||
"trace_id": trace_id,
|
||||
"external_userid": from_user,
|
||||
"msgid": msg_id,
|
||||
"reply_summary": reply_content[:50] + "..." if len(reply_content) > 50 else reply_content,
|
||||
},
|
||||
)
|
||||
|
||||
return PlainTextResponse(resp_xml, media_type="application/xml")
|
||||
Reference in New Issue
Block a user