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 @@
# services

View File

@@ -0,0 +1,39 @@
"""密码 bcrypt hashJWT 创建与解码,带过期时间。"""
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()

View 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

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

View 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 解析为 dictToUserName, 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>"""