Initial commit: 浼佷笟寰俊 AI 鏈哄櫒浜哄姪鐞?MVP
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# services
|
||||
39
backend/app/services/auth_service.py
Normal file
39
backend/app/services/auth_service.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""密码 bcrypt hash;JWT 创建与解码,带过期时间。"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models import User
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def create_access_token(subject: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
|
||||
to_encode = {"sub": subject, "exp": expire}
|
||||
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> str | None:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||
return payload.get("sub")
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
|
||||
r = await db.execute(select(User).where(User.username == username))
|
||||
return r.scalar_one_or_none()
|
||||
30
backend/app/services/session_service.py
Normal file
30
backend/app/services/session_service.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""会话与消息入库;仅存 public 可见内容,隔离内部信息。"""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import ChatSession, Message
|
||||
|
||||
|
||||
async def get_or_create_session(
|
||||
db: AsyncSession,
|
||||
external_user_id: str,
|
||||
external_name: str | None = None,
|
||||
) -> ChatSession:
|
||||
r = await db.execute(select(ChatSession).where(ChatSession.external_user_id == external_user_id))
|
||||
row = r.scalar_one_or_none()
|
||||
if row:
|
||||
if external_name is not None and row.external_name != external_name:
|
||||
row.external_name = external_name
|
||||
await db.flush()
|
||||
return row
|
||||
session = ChatSession(external_user_id=external_user_id, external_name=external_name or None)
|
||||
db.add(session)
|
||||
await db.flush()
|
||||
return session
|
||||
|
||||
|
||||
async def add_message(db: AsyncSession, session_id: int, role: str, content: str) -> Message:
|
||||
msg = Message(session_id=session_id, role=role, content=content)
|
||||
db.add(msg)
|
||||
await db.flush()
|
||||
return msg
|
||||
54
backend/app/services/wecom_api.py
Normal file
54
backend/app/services/wecom_api.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""企业微信 API 调用:超时与重试,配置来自环境变量。"""
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
TIMEOUT = settings.wecom_api_timeout
|
||||
RETRIES = settings.wecom_api_retries
|
||||
BASE = settings.wecom_api_base.rstrip("/")
|
||||
|
||||
|
||||
async def _request(method: str, path: str, **kwargs: Any) -> dict | None:
|
||||
url = f"{BASE}{path}"
|
||||
for attempt in range(RETRIES + 1):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
||||
r = await client.request(method, url, **kwargs)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.warning("wecom api attempt %s failed: %s", attempt + 1, e)
|
||||
if attempt == RETRIES:
|
||||
raise
|
||||
return None
|
||||
|
||||
|
||||
async def get_access_token() -> str:
|
||||
"""获取 corpid + secret 的 access_token。"""
|
||||
r = await _request(
|
||||
"GET",
|
||||
"/cgi-bin/gettoken",
|
||||
params={"corpid": settings.wecom_corp_id, "corpsecret": settings.wecom_secret},
|
||||
)
|
||||
if not r or r.get("errcode") != 0:
|
||||
raise RuntimeError(r.get("errmsg", "get token failed"))
|
||||
return r["access_token"]
|
||||
|
||||
|
||||
async def send_text_to_external(external_user_id: str, content: str) -> None:
|
||||
"""发送文本消息给外部联系人(客户联系-发送消息到客户)。"""
|
||||
token = await get_access_token()
|
||||
body = {
|
||||
"touser": [external_user_id],
|
||||
"sender": settings.wecom_agent_id,
|
||||
"msgtype": "text",
|
||||
"text": {"content": content},
|
||||
}
|
||||
# 企业微信文档:发送消息到客户 send_message_to_user
|
||||
r = await _request("POST", f"/cgi-bin/externalcontact/send_message_to_user?access_token={token}", json=body)
|
||||
if not r or r.get("errcode") != 0:
|
||||
raise RuntimeError(r.get("errmsg", "send failed"))
|
||||
119
backend/app/services/wecom_crypto.py
Normal file
119
backend/app/services/wecom_crypto.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""企业微信回调加解密与验签(与企微文档一致)。"""
|
||||
import base64
|
||||
import hashlib
|
||||
import struct
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Tuple
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _sha1(s: str) -> str:
|
||||
return hashlib.sha1(s.encode()).hexdigest()
|
||||
|
||||
|
||||
def _check_signature(signature: str, timestamp: str, nonce: str, echostr_or_encrypt: str) -> bool:
|
||||
token = settings.wecom_token
|
||||
lst = [token, timestamp, nonce, echostr_or_encrypt]
|
||||
lst.sort()
|
||||
return _sha1("".join(lst)) == signature
|
||||
|
||||
|
||||
def _aes_key() -> bytes:
|
||||
key_b64 = settings.wecom_encoding_aes_key + "="
|
||||
return base64.b64decode(key_b64)[:32]
|
||||
|
||||
|
||||
def decrypt(encrypt: str) -> str:
|
||||
"""解密企微回调密文(echostr 或 Encrypt 节点内容)。"""
|
||||
key = _aes_key()
|
||||
iv = key[:16]
|
||||
raw = base64.b64decode(encrypt)
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv)
|
||||
dec = cipher.decrypt(raw)
|
||||
# 16 随机字节 + 4 字节长度(big-endian) + 消息 + corpid;先按长度取消息,避免 padding 差异
|
||||
msg_len = struct.unpack(">I", dec[16:20])[0]
|
||||
return dec[20 : 20 + msg_len].decode("utf-8")
|
||||
|
||||
|
||||
def encrypt(plain: str) -> str:
|
||||
"""加密回复内容(明文为 XML 或文本)。"""
|
||||
import os
|
||||
key = _aes_key()
|
||||
iv = key[:16]
|
||||
corpid = settings.wecom_corp_id or "placeholder"
|
||||
msg = plain.encode("utf-8")
|
||||
msg_len = struct.pack(">I", len(msg))
|
||||
rand = os.urandom(16)
|
||||
to_enc = rand + msg_len + msg + corpid.encode("utf-8")
|
||||
from Crypto.Util.Padding import pad
|
||||
to_enc = pad(to_enc, 16)
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv)
|
||||
enc = cipher.encrypt(to_enc)
|
||||
return base64.b64encode(enc).decode("ascii")
|
||||
|
||||
|
||||
def verify_signature(msg_signature: str, timestamp: str, nonce: str, encrypt: str) -> bool:
|
||||
"""校验签名(GET 或 POST 的 Encrypt)。"""
|
||||
return _check_signature(msg_signature, timestamp, nonce, encrypt)
|
||||
|
||||
|
||||
def verify_and_decrypt_echostr(msg_signature: str, timestamp: str, nonce: str, echostr: str) -> str | None:
|
||||
"""GET 校验:验签并解密 echostr,返回明文;失败返回 None。"""
|
||||
if not verify_signature(msg_signature, timestamp, nonce, echostr):
|
||||
return None
|
||||
return decrypt(echostr)
|
||||
|
||||
|
||||
def parse_encrypted_body(body: bytes) -> Tuple[str | None, str | None]:
|
||||
"""解析 POST 请求体 XML,取 Encrypt;验签用 msg_signature/timestamp/nonce 从 query 传。返回 (encrypt_raw, None) 或 (None, error)。"""
|
||||
try:
|
||||
root = ET.fromstring(body)
|
||||
encrypt_el = root.find("Encrypt")
|
||||
if encrypt_el is None or encrypt_el.text is None:
|
||||
return None, "missing Encrypt"
|
||||
return encrypt_el.text.strip(), None
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
|
||||
|
||||
def parse_decrypted_xml(plain_xml: str) -> dict | None:
|
||||
"""解密后的 XML 解析为 dict(ToUserName, FromUserName, MsgType, Content 等)。"""
|
||||
try:
|
||||
root = ET.fromstring(plain_xml)
|
||||
d = {}
|
||||
for c in root:
|
||||
if c.text:
|
||||
d[c.tag] = c.text
|
||||
return d
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def build_reply_xml(to_user: str, from_user: str, content: str) -> str:
|
||||
"""构造文本回复 XML(明文)。"""
|
||||
return f"""<xml>
|
||||
<ToUserName><![CDATA[{to_user}]]></ToUserName>
|
||||
<FromUserName><![CDATA[{from_user}]]></FromUserName>
|
||||
<CreateTime>{int(__import__("time").time())}</CreateTime>
|
||||
<MsgType><![CDATA[text]]></MsgType>
|
||||
<Content><![CDATA[{content}]]></Content>
|
||||
</xml>"""
|
||||
|
||||
|
||||
def make_reply_signature(encrypt: str, timestamp: str, nonce: str) -> str:
|
||||
lst = [settings.wecom_token, timestamp, nonce, encrypt]
|
||||
lst.sort()
|
||||
return _sha1("".join(lst))
|
||||
|
||||
|
||||
def build_encrypted_response(encrypt: str, signature: str, timestamp: str, nonce: str) -> str:
|
||||
"""构造 POST 回复的加密 XML。"""
|
||||
return f"""<xml>
|
||||
<Encrypt><![CDATA[{encrypt}]]></Encrypt>
|
||||
<MsgSignature><![CDATA[{signature}]]></MsgSignature>
|
||||
<TimeStamp>{timestamp}</TimeStamp>
|
||||
<Nonce><![CDATA[{nonce}]]></Nonce>
|
||||
</xml>"""
|
||||
Reference in New Issue
Block a user