Initial commit: 浼佷笟寰俊 AI 鏈哄櫒浜哄姪鐞?MVP

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
bujie9527
2026-02-05 16:36:32 +08:00
commit 59275ed4dc
126 changed files with 9120 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
# routers
from app.routers import auth, wecom

View File

@@ -0,0 +1 @@
# admin routers

View 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(),
}

View 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(),
}

View 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(),
}

View 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(),
}

View 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(),
}

View File

@@ -0,0 +1,47 @@
"""Auth APIPOST /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,
}

View 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(),
}

View 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()}

View 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()}

View 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()}

View 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()}

View 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")