main.py 86 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343
  1. """
  2. 主应用入口
  3. """
  4. import json
  5. import logging
  6. import urllib.parse
  7. import hashlib
  8. import uuid
  9. import random
  10. from datetime import datetime, timedelta
  11. from typing import Optional, List, Dict, Any
  12. from fastapi import FastAPI, WebSocket, HTTPException, Request, Query, Header, Depends
  13. from fastapi.responses import RedirectResponse, PlainTextResponse
  14. from fastapi.middleware.cors import CORSMiddleware
  15. from starlette.middleware.base import BaseHTTPMiddleware
  16. from starlette.requests import Request as StarletteRequest
  17. from starlette.responses import Response
  18. from pydantic import BaseModel
  19. from contextlib import asynccontextmanager
  20. import httpx
  21. from xml.etree import ElementTree as ET
  22. from sqlalchemy import text, select, update
  23. from sqlalchemy.orm import selectinload
  24. from config import settings, get_dashscope_domain
  25. from websocket_handler import WebSocketHandler
  26. from database import init_database, close_database, db_manager
  27. from services.redis_cache_service import RedisCacheService
  28. from services.user_service import user_service
  29. from services.wechat_jsapi_service import WechatJSAPIService
  30. from services.energy_stone_service import energy_stone_service
  31. from models_db import VerificationCode, UserAvatar, UserBackgroundImage, Feedback, BackgroundImage
  32. from session_store import SessionStore
  33. # 配置日志
  34. logging.basicConfig(
  35. level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO),
  36. format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
  37. datefmt='%Y-%m-%d %H:%M:%S'
  38. )
  39. # 🔇 过滤第三方库的 DEBUG 日志(避免刷屏)
  40. # 这些库的 DEBUG 日志包含 HTTP/2 协议细节、HPACK 压缩等底层信息,对生产环境无意义
  41. third_party_loggers = [
  42. 'openai',
  43. 'openai._base_client',
  44. 'httpcore',
  45. 'httpcore.http2',
  46. 'httpx',
  47. 'hpack',
  48. 'hpack.hpack',
  49. 'hpack.table',
  50. ]
  51. for logger_name in third_party_loggers:
  52. logging.getLogger(logger_name).setLevel(logging.INFO)
  53. logger = logging.getLogger(__name__)
  54. SESSION_TOKEN_TTL_SECONDS = 30 * 60 # 30分钟会话有效期(可滑动续期)
  55. @asynccontextmanager
  56. async def lifespan(app: FastAPI):
  57. """应用生命周期管理"""
  58. # 启动时初始化数据库(包含MySQL和Redis,自动启动健康检查任务)
  59. try:
  60. await init_database()
  61. # 获取Redis客户端(已配置健康检查),用于验证连通性
  62. from database import db_manager
  63. await db_manager.get_redis()
  64. # 创建Redis缓存服务,按需获取最新连接
  65. app.state.redis_cache = RedisCacheService(db_manager.get_redis)
  66. except Exception as e:
  67. logger.error(f"❌ 数据库初始化失败: {e}")
  68. app.state.redis_cache = None
  69. # 🔥 启动HTTP连接保活任务(保持连接活跃,避免长时间不交互后响应变慢)
  70. try:
  71. from http_pool import http_pool
  72. from ai_service import AIService
  73. # 创建AIService实例用于保活(共享连接池)
  74. ai_service = AIService()
  75. http_pool.start_keepalive(ai_service=ai_service)
  76. except Exception as e:
  77. logger.warning(f"⚠️ HTTP连接保活任务启动失败: {e}")
  78. yield
  79. # 关闭时清理资源
  80. await close_database()
  81. # 🔥 关闭HTTP连接池(类似SQLAlchemy的Engine.dispose())
  82. from http_pool import http_pool
  83. http_pool.close()
  84. app = FastAPI(
  85. title="Lyrria Backend API",
  86. lifespan=lifespan
  87. )
  88. # 全局会话存储(单用户单活 token),使用 Redis 持久化
  89. from database import db_manager # 延迟导入以避免循环依赖问题
  90. app.state.session_store = SessionStore(redis_getter=db_manager.get_redis)
  91. class APIKeyMiddleware(BaseHTTPMiddleware):
  92. """API Key验证中间件"""
  93. # 不需要验证的路径(健康检查、WebSocket、微信/QQ回调等)
  94. EXEMPT_PATHS = [
  95. "/",
  96. "/docs",
  97. "/openapi.json",
  98. "/redoc",
  99. "/ws",
  100. # 微信 OAuth & 公众号回调
  101. "/api/auth/wechat/authorize",
  102. "/api/auth/wechat/callback",
  103. "/api/wechat/mp/verify",
  104. "/api/wechat/mp/message",
  105. # QQ OAuth & 回调
  106. "/api/auth/qq/authorize",
  107. "/api/qq/mp/verify",
  108. # QQ 分享配置(SDK 拉取)
  109. "/api/qq/share/config",
  110. ]
  111. async def dispatch(self, request: StarletteRequest, call_next):
  112. # 检查是否为豁免路径
  113. path = request.url.path
  114. if any(path.startswith(exempt) for exempt in self.EXEMPT_PATHS):
  115. return await call_next(request)
  116. # 如果未启用API Key验证,直接通过
  117. if not settings.API_KEY_ENABLED:
  118. return await call_next(request)
  119. # 检查API Key
  120. api_key = request.headers.get("X-Client-Key", "")
  121. expected_key = settings.API_CLIENT_KEY
  122. if not expected_key:
  123. logger.warning("⚠️ API_CLIENT_KEY未配置,跳过验证")
  124. return await call_next(request)
  125. if api_key != expected_key:
  126. logger.warning(f"❌ API Key验证失败: path={path}, provided_key={'***' if api_key else '(empty)'}")
  127. return Response(
  128. content='{"detail":"Invalid API Key"}',
  129. status_code=403,
  130. media_type="application/json"
  131. )
  132. return await call_next(request)
  133. # 配置CORS - 使用配置中的允许来源
  134. cors_origins = settings.CORS_ORIGINS if not settings.CORS_ALLOW_ALL else ["*"]
  135. app.add_middleware(
  136. CORSMiddleware,
  137. allow_origins=cors_origins,
  138. allow_credentials=True,
  139. allow_methods=["*"],
  140. allow_headers=["*"],
  141. )
  142. # 添加API Key验证中间件(在CORS之后)
  143. app.add_middleware(APIKeyMiddleware)
  144. @app.get("/")
  145. async def root():
  146. """健康检查"""
  147. return {"status": "ok", "message": "Lyrria Backend API"}
  148. class CreateUserRequest(BaseModel):
  149. """创建用户请求"""
  150. username: str = ""
  151. bot_name: Optional[str] = None
  152. class UpdateUsernameRequest(BaseModel):
  153. """更新用户名请求"""
  154. username: str
  155. class UpdateBotNameRequest(BaseModel):
  156. """更新机器人名字请求"""
  157. bot_name: str
  158. class UpdateBackgroundImageIdRequest(BaseModel):
  159. """更新背景图片ID请求"""
  160. background_image_id: str
  161. class RegisterPhoneRequest(BaseModel):
  162. """手机号注册请求"""
  163. area_code: Optional[str] = None
  164. phone: str
  165. username: str = ""
  166. user_id: Optional[str] = None # 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
  167. class RegisterEmailRequest(BaseModel):
  168. """邮箱注册请求"""
  169. email: str
  170. username: str = ""
  171. user_id: Optional[str] = None # 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
  172. class LoginWechatRequest(BaseModel):
  173. """微信登录请求"""
  174. openid: str
  175. username: str = ""
  176. class LoginQQRequest(BaseModel):
  177. """QQ登录请求"""
  178. openid: str
  179. username: str = ""
  180. class LoginPhoneRequest(BaseModel):
  181. """手机号登录请求"""
  182. area_code: Optional[str] = None
  183. phone: str
  184. class LoginEmailRequest(BaseModel):
  185. """邮箱登录请求"""
  186. email: str
  187. class SendCodeRequest(BaseModel):
  188. """发送验证码请求"""
  189. area_code: Optional[str] = None # 手机区号(如 +86)
  190. phone: Optional[str] = None
  191. email: Optional[str] = None
  192. send_type: str # "domestic_sms", "international_sms", "email"
  193. class VerifyCodeRequest(BaseModel):
  194. """验证验证码请求"""
  195. phone: Optional[str] = None
  196. email: Optional[str] = None
  197. code: str
  198. send_type: str # "domestic_sms", "international_sms", "email"
  199. class UserUIStateResponse(BaseModel):
  200. """用户前端可视化状态"""
  201. user_id: str
  202. current_page: Optional[str] = None
  203. visited_routes: List[str] = []
  204. has_shown_tap_to_chat: bool = False
  205. has_shown_cancel_tooltip: bool = False
  206. has_shown_send_tooltip: bool = False
  207. has_shown_progress_bar_tooltip: bool = False
  208. has_shown_mission_stone_modal: bool = False
  209. has_shown_conversation_stone_modal: bool = False
  210. has_clicked_mission_stone: bool = False
  211. has_clicked_conversation_stone: bool = False
  212. avatar_c_unlocked: bool = False
  213. has_shared_avatar: bool = False
  214. current_avatar_id: Optional[str] = None
  215. class UpdateUserUIStateRequest(BaseModel):
  216. """部分更新用户前端可视化状态"""
  217. current_page: Optional[str] = None
  218. visited_routes: Optional[List[str]] = None
  219. has_shown_tap_to_chat: Optional[bool] = None
  220. has_shown_cancel_tooltip: Optional[bool] = None
  221. has_shown_send_tooltip: Optional[bool] = None
  222. has_shown_progress_bar_tooltip: Optional[bool] = None
  223. has_shown_mission_stone_modal: Optional[bool] = None
  224. has_shown_conversation_stone_modal: Optional[bool] = None
  225. has_clicked_mission_stone: Optional[bool] = None
  226. has_clicked_conversation_stone: Optional[bool] = None
  227. avatar_c_unlocked: Optional[bool] = None
  228. has_shared_avatar: Optional[bool] = None
  229. current_avatar_id: Optional[str] = None
  230. # 分享图片响应模型
  231. class ShareImageItem(BaseModel):
  232. id: str
  233. url: str
  234. title: Optional[str] = None
  235. sort_order: Optional[int] = None
  236. is_active: Optional[bool] = None
  237. SHARE_IMAGES: List[ShareImageItem] = [
  238. ShareImageItem(id="share-1", url="https://www.lyrria.com/img/share-1.png"),
  239. ShareImageItem(id="share-2", url="https://www.lyrria.com/img/share-2.png"),
  240. ]
  241. class AvatarGifSet(BaseModel):
  242. default: str
  243. positive: str
  244. negative: str
  245. talking: str
  246. async def require_user_session(
  247. request: Request,
  248. user_id: str,
  249. header_user_id: Optional[str] = Header(None, alias="X-User-Id"),
  250. token: Optional[str] = Header(None, alias="X-User-Token"),
  251. ) -> None:
  252. """
  253. 基于 user_id + token 的简单 HTTP 鉴权,复用 WebSocket 的 session_store。
  254. 仅用于需要用户身份的业务接口,保持单用户单活语义。
  255. """
  256. if not header_user_id or header_user_id != user_id:
  257. raise HTTPException(status_code=401, detail="未授权:用户ID不匹配")
  258. session_store = getattr(request.app.state, "session_store", None)
  259. if not session_store:
  260. raise HTTPException(status_code=500, detail="会话服务未初始化")
  261. if not token or not await session_store.validate(user_id, token, refresh=True):
  262. raise HTTPException(status_code=401, detail="未授权:会话已失效,请重新进入")
  263. async def _generate_session_token(user_id: str) -> Dict[str, Any]:
  264. """
  265. 为指定用户生成会话 token(复用 init_session 的逻辑)
  266. Args:
  267. user_id: 用户ID
  268. Returns:
  269. 包含 token 和 expires_at 的字典
  270. """
  271. session_store = getattr(app.state, "session_store", None)
  272. token = str(uuid.uuid4())
  273. expires_at = datetime.utcnow() + timedelta(seconds=SESSION_TOKEN_TTL_SECONDS)
  274. if session_store:
  275. await session_store.set_current_token(user_id, token, SESSION_TOKEN_TTL_SECONDS)
  276. # 主动踢掉旧连接
  277. await session_store.invalidate_connection(user_id)
  278. return {
  279. "token": token,
  280. "expires_at": expires_at.isoformat() + "Z",
  281. }
  282. class SessionInitRequest(BaseModel):
  283. user_id: Optional[str] = None
  284. username: Optional[str] = ""
  285. AVATAR_GIF_CONFIG = {
  286. "avatar_default": AvatarGifSet(
  287. default="https://www.lyrria.com/img/01-Default.gif",
  288. positive="https://www.lyrria.com/img/04-Positive-emotion.gif",
  289. negative="https://www.lyrria.com/img/05-Negative-emotion.gif",
  290. talking="https://www.lyrria.com/img/06-talking.gif",
  291. ),
  292. "avatar_registered_cap": AvatarGifSet(
  293. default="https://www.lyrria.com/img/01-Default-Registered.gif",
  294. positive="https://www.lyrria.com/img/04-Positive-emotion-Registered.gif",
  295. negative="https://www.lyrria.com/img/05-Negative-emotion-Registered.gif",
  296. talking="https://www.lyrria.com/img/06-talking-Registered.gif",
  297. ),
  298. }
  299. @app.post("/api/users/create")
  300. async def create_user(request: CreateUserRequest):
  301. """
  302. 创建游客用户
  303. Returns:
  304. {
  305. "user_id": "uuid",
  306. "username": "",
  307. "user_type": "guest",
  308. "bot_name": "机器人名字(可选)"
  309. }
  310. """
  311. try:
  312. user = await user_service.create_guest_user(
  313. username=request.username,
  314. bot_name=request.bot_name
  315. )
  316. return {
  317. "success": True,
  318. "data": user
  319. }
  320. except Exception as e:
  321. raise HTTPException(status_code=500, detail=f"创建用户失败: {str(e)}")
  322. @app.post("/api/session/init")
  323. async def init_session(request: SessionInitRequest):
  324. """
  325. 初始化或刷新用户会话,返回 user_id + 会话 token(单用户单活)
  326. """
  327. try:
  328. session_store = getattr(app.state, "session_store", None)
  329. # 1. 获取或创建用户
  330. user_id = request.user_id
  331. user_payload: Optional[Dict[str, Any]] = None
  332. if not user_id:
  333. user_payload = await user_service.create_guest_user(
  334. username=request.username or ""
  335. )
  336. user_id = user_payload["user_id"]
  337. else:
  338. # 确认用户存在
  339. user_payload = await user_service.get_user(user_id)
  340. if not user_payload:
  341. raise HTTPException(status_code=404, detail="用户不存在")
  342. # 2. 签发新 token(覆盖旧 token,单活)
  343. token = str(uuid.uuid4())
  344. expires_at = datetime.utcnow() + timedelta(seconds=SESSION_TOKEN_TTL_SECONDS)
  345. if session_store:
  346. await session_store.set_current_token(user_id, token, SESSION_TOKEN_TTL_SECONDS)
  347. # 主动踢掉旧连接
  348. await session_store.invalidate_connection(user_id)
  349. data = {
  350. "user_id": user_id,
  351. "token": token,
  352. "expires_at": expires_at.isoformat() + "Z",
  353. }
  354. # 如果刚创建用户,透传部分信息,兼容前端缓存逻辑
  355. if user_payload:
  356. data.update(
  357. {
  358. "username": user_payload.get("username"),
  359. "user_level": user_payload.get("user_level"),
  360. "current_background_image_id": user_payload.get(
  361. "current_background_image_id"
  362. ),
  363. "current_background_image_metadata": user_payload.get(
  364. "current_background_image_metadata"
  365. ),
  366. }
  367. )
  368. return {"success": True, "data": data}
  369. except HTTPException:
  370. raise
  371. except Exception as e:
  372. logger.error(f"❌ 初始化会话失败: {e}")
  373. raise HTTPException(status_code=500, detail=f"初始化会话失败: {str(e)}")
  374. @app.get("/api/users/{user_id}")
  375. async def get_user(user_id: str, _: None = Depends(require_user_session)):
  376. """
  377. 获取用户信息
  378. Args:
  379. user_id: 用户ID
  380. Returns:
  381. {
  382. "success": true,
  383. "data": {
  384. "user_id": "uuid",
  385. "username": "",
  386. "user_type": "guest",
  387. "bot_name": "机器人名字(可选)",
  388. ...
  389. }
  390. }
  391. """
  392. try:
  393. user = await user_service.get_user(user_id)
  394. if user:
  395. return {
  396. "success": True,
  397. "data": user
  398. }
  399. else:
  400. raise HTTPException(status_code=404, detail="用户不存在")
  401. except HTTPException:
  402. raise
  403. except Exception as e:
  404. raise HTTPException(status_code=500, detail=f"获取用户信息失败: {str(e)}")
  405. @app.get("/api/users/{user_id}/energy-stones/count")
  406. async def get_energy_stone_counts(user_id: str, _: None = Depends(require_user_session)):
  407. """
  408. 获取用户的能量石数量
  409. Args:
  410. user_id: 用户ID
  411. Returns:
  412. {
  413. "success": true,
  414. "data": {
  415. "mission_count": 0,
  416. "conversation_count": 0
  417. }
  418. }
  419. """
  420. try:
  421. if not energy_stone_service.db:
  422. await energy_stone_service.set_db_manager(db_manager)
  423. counts = await energy_stone_service.get_stone_counts(user_id)
  424. return {
  425. "success": True,
  426. "data": counts
  427. }
  428. except Exception as e:
  429. raise HTTPException(status_code=500, detail=f"获取能量石数量失败: {str(e)}")
  430. @app.get("/api/users/{user_id}/avatars")
  431. async def get_user_avatars(user_id: str, _: None = Depends(require_user_session)):
  432. """
  433. 获取用户已拥有的头像列表
  434. Args:
  435. user_id: 用户ID
  436. Returns:
  437. {
  438. "success": true,
  439. "data": {
  440. "avatar_ids": ["avatar_default", "avatar_registered_cap", ...]
  441. }
  442. }
  443. """
  444. try:
  445. async with db_manager.get_session() as session:
  446. result = await session.execute(
  447. select(UserAvatar.avatar_id).where(UserAvatar.user_id == user_id)
  448. )
  449. avatar_ids = [row[0] for row in result.fetchall()]
  450. return {
  451. "success": True,
  452. "data": {
  453. "avatar_ids": avatar_ids
  454. }
  455. }
  456. except Exception as e:
  457. raise HTTPException(status_code=500, detail=f"获取用户头像信息失败: {str(e)}")
  458. @app.get("/api/background-images")
  459. async def get_background_images():
  460. """
  461. 获取所有背景图片配置列表
  462. Returns:
  463. {
  464. "success": true,
  465. "data": [
  466. {
  467. "image_id": "default",
  468. "image_name": "默认背景",
  469. "image_url": "https://...",
  470. "thumbnail_url": "https://...",
  471. "unlock_type": "default",
  472. "display_order": 0,
  473. "is_active": true,
  474. ...
  475. },
  476. ...
  477. ]
  478. }
  479. """
  480. try:
  481. async with db_manager.get_session() as session:
  482. result = await session.execute(
  483. select(BackgroundImage).where(BackgroundImage.is_active == True).order_by(BackgroundImage.display_order)
  484. )
  485. background_images = result.scalars().all()
  486. return {
  487. "success": True,
  488. "data": [bg_image.to_dict() for bg_image in background_images]
  489. }
  490. except Exception as e:
  491. logger.error(f"获取背景图片配置失败: {e}")
  492. raise HTTPException(status_code=500, detail=f"获取背景图片配置失败: {str(e)}")
  493. @app.get("/api/users/{user_id}/background-images")
  494. async def get_user_background_images(user_id: str, _: None = Depends(require_user_session)):
  495. """
  496. 获取用户已拥有的背景图片列表
  497. Args:
  498. user_id: 用户ID
  499. Returns:
  500. {
  501. "success": true,
  502. "data": {
  503. "image_ids": ["default", "happynewyear", ...]
  504. }
  505. }
  506. """
  507. try:
  508. async with db_manager.get_session() as session:
  509. result = await session.execute(
  510. select(UserBackgroundImage.image_id).where(UserBackgroundImage.user_id == user_id)
  511. )
  512. image_ids = [row[0] for row in result.fetchall()]
  513. return {
  514. "success": True,
  515. "data": {
  516. "image_ids": image_ids
  517. }
  518. }
  519. except Exception as e:
  520. raise HTTPException(status_code=500, detail=f"获取用户背景图片信息失败: {str(e)}")
  521. @app.post("/api/users/{user_id}/claim-share-reward")
  522. async def claim_share_reward(user_id: str, _: None = Depends(require_user_session)):
  523. """
  524. 领取分享奖励
  525. - 给用户添加新年背景图片(happynewyear)
  526. - 设置用户的当前背景图片为新年的背景
  527. Args:
  528. user_id: 用户ID
  529. Returns:
  530. {
  531. "success": true,
  532. "data": {
  533. "user_id": "xxx",
  534. "background_image_id": "happynewyear",
  535. "message": "分享奖励已领取"
  536. }
  537. }
  538. """
  539. try:
  540. from database import db_manager
  541. from models_db import User, UserBackgroundImage
  542. from sqlalchemy import select, update
  543. async with db_manager.get_session() as session:
  544. # 1. 检查用户是否存在
  545. result = await session.execute(
  546. select(User).where(User.user_id == user_id)
  547. )
  548. user = result.scalar_one_or_none()
  549. if not user:
  550. raise HTTPException(status_code=404, detail="用户不存在")
  551. # 2. 检查用户是否已经拥有新年背景
  552. bg_result = await session.execute(
  553. select(UserBackgroundImage).where(
  554. UserBackgroundImage.user_id == user_id,
  555. UserBackgroundImage.image_id == "happynewyear"
  556. )
  557. )
  558. existing_bg = bg_result.scalar_one_or_none()
  559. # 3. 如果还没有,添加到用户背景图片列表
  560. if not existing_bg:
  561. user_bg_image = UserBackgroundImage(
  562. user_id=user_id,
  563. image_id="happynewyear",
  564. obtained_type="gift",
  565. obtained_source="share_reward"
  566. )
  567. session.add(user_bg_image)
  568. logger.info(f"✅ 为用户添加新年背景图片: user_id={user_id}")
  569. else:
  570. logger.info(f"⏭️ 用户已拥有新年背景图片: user_id={user_id}")
  571. # 4. 更新用户的当前背景图片
  572. await session.execute(
  573. update(User)
  574. .where(User.user_id == user_id)
  575. .values(current_background_image_id="happynewyear")
  576. )
  577. # 5. 更新用户的 has_shared_avatar 状态
  578. from models_db import UserUIState
  579. ui_state_result = await session.execute(
  580. select(UserUIState).where(UserUIState.user_id == user_id)
  581. )
  582. ui_state = ui_state_result.scalar_one_or_none()
  583. if ui_state:
  584. ui_state.has_shared_avatar = True
  585. session.add(ui_state)
  586. logger.info(f"✅ 更新用户分享头像状态: user_id={user_id}, has_shared_avatar=True")
  587. else:
  588. # 如果不存在 UI 状态记录,创建一个新的
  589. new_ui_state = UserUIState(
  590. user_id=user_id,
  591. has_shared_avatar=True
  592. )
  593. session.add(new_ui_state)
  594. logger.info(f"✅ 创建用户UI状态并设置分享头像状态: user_id={user_id}, has_shared_avatar=True")
  595. # 6. 获取背景图片的 metadata(用于前端设置透明度等)
  596. bg_metadata = None
  597. try:
  598. from models_db import BackgroundImage
  599. bg_info_result = await session.execute(
  600. select(BackgroundImage).where(BackgroundImage.image_id == "happynewyear")
  601. )
  602. bg_info = bg_info_result.scalar_one_or_none()
  603. if bg_info and bg_info.metadata_json:
  604. bg_metadata = bg_info.metadata_json
  605. except Exception as e:
  606. logger.warning(f"⚠️ 获取背景图片metadata失败: {e}")
  607. await session.commit()
  608. logger.info(f"✅ 用户领取分享奖励成功: user_id={user_id}, background_image_id=happynewyear")
  609. return {
  610. "success": True,
  611. "data": {
  612. "user_id": user_id,
  613. "background_image_id": "happynewyear",
  614. "background_image_metadata": bg_metadata,
  615. "message": "分享奖励已领取"
  616. }
  617. }
  618. except HTTPException:
  619. raise
  620. except Exception as e:
  621. logger.error(f"❌ 领取分享奖励失败: user_id={user_id}, error={e}", exc_info=True)
  622. raise HTTPException(status_code=500, detail=f"领取分享奖励失败: {str(e)}")
  623. @app.get("/api/avatar-gifs")
  624. async def get_avatar_gifs() -> Dict[str, Any]:
  625. """
  626. 获取头像 GIF 配置
  627. - 优先读取数据库 avatar_assets 表(按 avatar_id、state 分组)
  628. - 字段:avatar_id, state, image_url
  629. - 失败/无数据时返回静态兜底配置
  630. """
  631. try:
  632. from database import db_manager
  633. async with db_manager.get_session() as session:
  634. result = await session.execute(
  635. text(
  636. """
  637. SELECT avatar_id, state, image_url
  638. FROM avatar_assets
  639. WHERE state IN ('default', 'positive', 'negative', 'talking')
  640. ORDER BY avatar_id, state
  641. """
  642. )
  643. )
  644. rows = result.mappings().all()
  645. if rows:
  646. # 按 avatar_id 分组,转换为期望的格式
  647. data: Dict[str, Dict[str, str]] = {}
  648. for row in rows:
  649. avatar_id = row.get("avatar_id")
  650. state = row.get("state")
  651. image_url = row.get("image_url")
  652. if not avatar_id or not state or not image_url:
  653. continue
  654. if avatar_id not in data:
  655. data[avatar_id] = {}
  656. data[avatar_id][state] = image_url
  657. if data:
  658. return {"success": True, "data": data}
  659. logger.warning("avatar_assets 表无记录,返回兜底静态配置")
  660. except Exception as exc:
  661. logger.error(f"获取头像 GIF 配置失败,使用兜底: {exc}")
  662. return {"success": True, "data": {k: v.model_dump() for k, v in AVATAR_GIF_CONFIG.items()}}
  663. @app.get("/api/share-images")
  664. async def get_share_images() -> Dict[str, Any]:
  665. """
  666. 获取分享图片列表
  667. - 优先从数据库 share_images 表读取(is_active=1,按 sort_order、id 排序)
  668. - 失败或无数据时返回兜底静态列表
  669. """
  670. try:
  671. from database import db_manager
  672. async with db_manager.get_session() as session:
  673. result = await session.execute(
  674. text(
  675. """
  676. SELECT id, code, url, title, sort_order, is_active
  677. FROM share_images
  678. WHERE is_active = 1
  679. ORDER BY sort_order ASC, id ASC
  680. """
  681. )
  682. )
  683. rows = result.mappings().all()
  684. if rows:
  685. data = [
  686. {
  687. "id": str(row.get("code") or row.get("id")),
  688. "url": row.get("url"),
  689. "title": row.get("title"),
  690. "sort_order": row.get("sort_order"),
  691. "is_active": bool(row.get("is_active")) if row.get("is_active") is not None else None,
  692. }
  693. for row in rows
  694. ]
  695. return {"success": True, "data": data}
  696. logger.warning("share_images 表无记录,返回兜底静态列表")
  697. except Exception as exc:
  698. logger.error(f"获取分享图片失败,使用兜底: {exc}")
  699. return {"success": True, "data": [item.model_dump() for item in SHARE_IMAGES]}
  700. @app.put("/api/users/{user_id}/username")
  701. async def update_username(user_id: str, request: UpdateUsernameRequest, _: None = Depends(require_user_session)):
  702. """
  703. 更新用户名
  704. Args:
  705. user_id: 用户ID
  706. request: 更新请求(包含username)
  707. Returns:
  708. {
  709. "success": true,
  710. "message": "更新成功"
  711. }
  712. """
  713. try:
  714. success = await user_service.update_username(user_id, request.username)
  715. if success:
  716. return {
  717. "success": True,
  718. "message": "用户名更新成功"
  719. }
  720. else:
  721. raise HTTPException(status_code=500, detail="更新用户名失败")
  722. except Exception as e:
  723. raise HTTPException(status_code=500, detail=f"更新用户名失败: {str(e)}")
  724. @app.put("/api/users/{user_id}/background-image-id")
  725. async def update_background_image_id(user_id: str, request: UpdateBackgroundImageIdRequest, _: None = Depends(require_user_session)):
  726. """
  727. 更新用户当前使用的背景图片ID
  728. Args:
  729. user_id: 用户ID
  730. request: 更新请求(包含background_image_id)
  731. Returns:
  732. {
  733. "success": true,
  734. "message": "更新成功"
  735. }
  736. """
  737. try:
  738. success = await user_service.update_current_background_image_id(
  739. user_id, request.background_image_id
  740. )
  741. if not success:
  742. raise HTTPException(status_code=404, detail="用户不存在或更新失败")
  743. return {"success": True, "message": "更新成功"}
  744. except HTTPException:
  745. raise
  746. except Exception as e:
  747. raise HTTPException(status_code=500, detail=f"更新背景图片ID失败: {str(e)}") from e
  748. @app.put("/api/users/{user_id}/bot-name")
  749. async def update_bot_name(user_id: str, request: UpdateBotNameRequest, _: None = Depends(require_user_session)):
  750. """
  751. 更新用户给机器人取的名字
  752. Args:
  753. user_id: 用户ID
  754. request: 更新请求(包含bot_name)
  755. Returns:
  756. {
  757. "success": true,
  758. "message": "更新成功"
  759. }
  760. """
  761. import asyncio
  762. import time
  763. start_time = time.time()
  764. try:
  765. # 🔥 添加超时保护(5秒),避免长时间等待
  766. success = await asyncio.wait_for(
  767. user_service.update_bot_name(user_id, request.bot_name),
  768. timeout=5.0
  769. )
  770. duration = (time.time() - start_time) * 1000
  771. if success:
  772. return {
  773. "success": True,
  774. "message": "机器人名字更新成功"
  775. }
  776. else:
  777. raise HTTPException(
  778. status_code=500,
  779. detail=f"更新机器人名字失败(耗时: {duration:.1f}ms)"
  780. )
  781. except asyncio.TimeoutError:
  782. duration = (time.time() - start_time) * 1000
  783. logger.error(f"❌ 更新机器人名字超时: user_id={user_id}, 耗时={duration:.1f}ms")
  784. raise HTTPException(
  785. status_code=504,
  786. detail=f"更新机器人名字超时(耗时: {duration:.1f}ms),请稍后重试"
  787. )
  788. except HTTPException:
  789. raise
  790. except Exception as e:
  791. duration = (time.time() - start_time) * 1000
  792. logger.error(f"❌ 更新机器人名字异常: user_id={user_id}, error={e}, 耗时={duration:.1f}ms")
  793. raise HTTPException(
  794. status_code=500,
  795. detail=f"更新机器人名字失败: {str(e)}(耗时: {duration:.1f}ms)"
  796. )
  797. class SubmitFeedbackRequest(BaseModel):
  798. """提交反馈请求"""
  799. content: str
  800. @app.post("/api/users/{user_id}/feedback")
  801. async def submit_feedback(user_id: str, request: SubmitFeedbackRequest, _: None = Depends(require_user_session)):
  802. """
  803. 提交用户反馈
  804. Args:
  805. user_id: 用户ID
  806. request: 反馈请求(包含content)
  807. Returns:
  808. {
  809. "success": true,
  810. "data": {
  811. "feedback_id": "uuid",
  812. "user_id": "uuid",
  813. "content": "反馈内容",
  814. "status": "pending",
  815. "created_at": "2024-01-01T00:00:00"
  816. }
  817. }
  818. """
  819. try:
  820. # 验证反馈内容
  821. if not request.content or not request.content.strip():
  822. raise HTTPException(status_code=400, detail="反馈内容不能为空")
  823. # 验证用户是否存在
  824. async with db_manager.get_session() as session:
  825. from models_db import User
  826. user_result = await session.execute(
  827. select(User).where(User.user_id == user_id)
  828. )
  829. user = user_result.scalar_one_or_none()
  830. if not user:
  831. raise HTTPException(status_code=404, detail="用户不存在")
  832. # 创建反馈记录
  833. feedback_id = str(uuid.uuid4())
  834. feedback = Feedback(
  835. feedback_id=feedback_id,
  836. user_id=user_id,
  837. content=request.content.strip(),
  838. status="pending"
  839. )
  840. session.add(feedback)
  841. await session.commit()
  842. await session.refresh(feedback)
  843. logger.info(f"✅ 用户反馈提交成功: user_id={user_id}, feedback_id={feedback_id}")
  844. return {
  845. "success": True,
  846. "data": feedback.to_dict()
  847. }
  848. except HTTPException:
  849. raise
  850. except Exception as e:
  851. logger.error(f"❌ 提交反馈失败: user_id={user_id}, error={e}")
  852. raise HTTPException(status_code=500, detail=f"提交反馈失败: {str(e)}")
  853. @app.post("/api/auth/register/phone")
  854. async def register_with_phone(request: RegisterPhoneRequest):
  855. """
  856. 手机号注册
  857. Returns:
  858. {
  859. "success": true,
  860. "data": {
  861. "user_id": "uuid",
  862. "username": "",
  863. "phone": "",
  864. "user_type": "registered",
  865. "token": "会话token",
  866. "expires_at": "过期时间",
  867. ...
  868. }
  869. }
  870. """
  871. try:
  872. user = await user_service.register_with_phone(
  873. area_code=request.area_code,
  874. phone=request.phone,
  875. username=request.username,
  876. user_id=request.user_id
  877. )
  878. # 生成会话 token(注册成功后自动登录)
  879. session_data = await _generate_session_token(user["user_id"])
  880. # 将 token 添加到返回数据中
  881. user.update(session_data)
  882. return {
  883. "success": True,
  884. "data": user
  885. }
  886. except ValueError as e:
  887. raise HTTPException(status_code=400, detail=str(e))
  888. except Exception as e:
  889. raise HTTPException(status_code=500, detail=f"注册失败: {str(e)}")
  890. @app.post("/api/auth/register/email")
  891. async def register_with_email(request: RegisterEmailRequest):
  892. """
  893. 邮箱注册
  894. Returns:
  895. {
  896. "success": true,
  897. "data": {
  898. "user_id": "uuid",
  899. "username": "",
  900. "email": "",
  901. "user_type": "registered",
  902. "token": "会话token",
  903. "expires_at": "过期时间",
  904. ...
  905. }
  906. }
  907. """
  908. try:
  909. user = await user_service.register_with_email(
  910. email=request.email,
  911. username=request.username,
  912. user_id=request.user_id
  913. )
  914. # 生成会话 token(注册成功后自动登录)
  915. session_data = await _generate_session_token(user["user_id"])
  916. # 将 token 添加到返回数据中
  917. user.update(session_data)
  918. return {
  919. "success": True,
  920. "data": user
  921. }
  922. except ValueError as e:
  923. raise HTTPException(status_code=400, detail=str(e))
  924. except Exception as e:
  925. raise HTTPException(status_code=500, detail=f"注册失败: {str(e)}")
  926. @app.post("/api/auth/login/wechat")
  927. async def login_with_wechat(request: LoginWechatRequest):
  928. """
  929. 微信登录/注册
  930. Returns:
  931. {
  932. "success": true,
  933. "data": {
  934. "user_id": "uuid",
  935. "username": "",
  936. "wechat_openid": "",
  937. "user_type": "registered",
  938. "token": "会话token",
  939. "expires_at": "过期时间",
  940. ...
  941. }
  942. }
  943. """
  944. try:
  945. user = await user_service.login_with_wechat(
  946. openid=request.openid,
  947. username=request.username
  948. )
  949. # 生成会话 token
  950. session_data = await _generate_session_token(user["user_id"])
  951. # 将 token 添加到返回数据中
  952. user.update(session_data)
  953. return {
  954. "success": True,
  955. "data": user
  956. }
  957. except Exception as e:
  958. raise HTTPException(status_code=500, detail=f"登录失败: {str(e)}")
  959. @app.post("/api/auth/login/qq")
  960. async def login_with_qq(request: LoginQQRequest):
  961. """
  962. QQ登录/注册
  963. Returns:
  964. {
  965. "success": true,
  966. "data": {
  967. "user_id": "uuid",
  968. "username": "",
  969. "qq_openid": "",
  970. "user_type": "registered",
  971. "token": "会话token",
  972. "expires_at": "过期时间",
  973. ...
  974. }
  975. }
  976. """
  977. try:
  978. user = await user_service.login_with_qq(
  979. openid=request.openid,
  980. username=request.username
  981. )
  982. # 生成会话 token
  983. session_data = await _generate_session_token(user["user_id"])
  984. # 将 token 添加到返回数据中
  985. user.update(session_data)
  986. return {
  987. "success": True,
  988. "data": user
  989. }
  990. except Exception as e:
  991. raise HTTPException(status_code=500, detail=f"登录失败: {str(e)}")
  992. @app.post("/api/auth/login/phone")
  993. async def login_with_phone(request: LoginPhoneRequest):
  994. """
  995. 手机号登录
  996. Returns:
  997. {
  998. "success": true,
  999. "data": {
  1000. "user_id": "uuid",
  1001. "phone": "",
  1002. "token": "会话token",
  1003. "expires_at": "过期时间",
  1004. ...
  1005. }
  1006. }
  1007. """
  1008. try:
  1009. user = await user_service.login_with_phone(
  1010. phone=request.phone,
  1011. area_code=request.area_code
  1012. )
  1013. if user:
  1014. # 生成会话 token
  1015. session_data = await _generate_session_token(user["user_id"])
  1016. # 将 token 添加到返回数据中
  1017. user.update(session_data)
  1018. return {
  1019. "success": True,
  1020. "data": user
  1021. }
  1022. else:
  1023. raise HTTPException(status_code=404, detail="用户不存在,请先注册")
  1024. except HTTPException:
  1025. raise
  1026. except Exception as e:
  1027. raise HTTPException(status_code=500, detail=f"登录失败: {str(e)}")
  1028. @app.post("/api/auth/login/email")
  1029. async def login_with_email(request: LoginEmailRequest):
  1030. """
  1031. 邮箱登录
  1032. Returns:
  1033. {
  1034. "success": true,
  1035. "data": {
  1036. "user_id": "uuid",
  1037. "email": "",
  1038. "token": "会话token",
  1039. "expires_at": "过期时间",
  1040. ...
  1041. }
  1042. }
  1043. """
  1044. try:
  1045. user = await user_service.login_with_email(request.email)
  1046. if user:
  1047. # 生成会话 token
  1048. session_data = await _generate_session_token(user["user_id"])
  1049. # 将 token 添加到返回数据中
  1050. user.update(session_data)
  1051. return {
  1052. "success": True,
  1053. "data": user
  1054. }
  1055. else:
  1056. raise HTTPException(status_code=404, detail="用户不存在,请先注册")
  1057. except HTTPException:
  1058. raise
  1059. except Exception as e:
  1060. raise HTTPException(status_code=500, detail=f"登录失败: {str(e)}")
  1061. @app.post("/api/auth/send-code")
  1062. async def send_code(request: SendCodeRequest):
  1063. """
  1064. 生成并保存验证码
  1065. 当用户点击 send code 按钮时调用此接口,生成临时验证码并保存到数据库
  1066. Args:
  1067. request: 发送验证码请求
  1068. - phone: 手机号(发送类型为短信时必填)
  1069. - email: 邮箱(发送类型为邮箱时必填)
  1070. - send_type: 发送类型 ("domestic_sms", "international_sms", "email")
  1071. Returns:
  1072. {
  1073. "success": true,
  1074. "data": {
  1075. "code_id": "验证码ID",
  1076. "message": "验证码已生成,等待发送"
  1077. }
  1078. }
  1079. """
  1080. try:
  1081. # 验证参数
  1082. if request.send_type in ["domestic_sms", "international_sms"]:
  1083. if not request.phone:
  1084. raise HTTPException(status_code=400, detail="手机号不能为空")
  1085. elif request.send_type == "email":
  1086. if not request.email:
  1087. raise HTTPException(status_code=400, detail="邮箱不能为空")
  1088. else:
  1089. raise HTTPException(status_code=400, detail="无效的发送类型")
  1090. # 生成4位数字验证码
  1091. code = str(random.randint(1000, 9999))
  1092. code_id = str(uuid.uuid4())
  1093. # 保存到数据库
  1094. async with db_manager.get_session() as session:
  1095. # 🔥 将同一手机号/邮箱的旧验证码标记为无效
  1096. # 确保同一时间只有一个有效的验证码
  1097. if request.send_type in ["domestic_sms", "international_sms"]:
  1098. # 将同一手机号的旧验证码标记为无效
  1099. await session.execute(
  1100. update(VerificationCode)
  1101. .where(
  1102. VerificationCode.phone == request.phone,
  1103. VerificationCode.send_type == request.send_type,
  1104. VerificationCode.is_valid == True
  1105. )
  1106. .values(is_valid=False)
  1107. )
  1108. elif request.send_type == "email":
  1109. # 将同一邮箱的旧验证码标记为无效
  1110. await session.execute(
  1111. update(VerificationCode)
  1112. .where(
  1113. VerificationCode.email == request.email,
  1114. VerificationCode.send_type == request.send_type,
  1115. VerificationCode.is_valid == True
  1116. )
  1117. .values(is_valid=False)
  1118. )
  1119. # 创建新的验证码
  1120. verification_code = VerificationCode(
  1121. code_id=code_id,
  1122. area_code=request.area_code,
  1123. phone=request.phone,
  1124. email=request.email,
  1125. code=code,
  1126. send_type=request.send_type,
  1127. is_sent=False,
  1128. is_valid=True,
  1129. )
  1130. session.add(verification_code)
  1131. await session.commit()
  1132. logger.info(f"✅ 验证码已生成: code_id={code_id}, send_type={request.send_type}, area_code={request.area_code}, phone={request.phone}, email={request.email}")
  1133. return {
  1134. "success": True,
  1135. "data": {
  1136. "code_id": code_id,
  1137. "message": "验证码已生成,等待发送"
  1138. }
  1139. }
  1140. except HTTPException:
  1141. raise
  1142. except Exception as e:
  1143. logger.error(f"❌ 生成验证码失败: {e}")
  1144. raise HTTPException(status_code=500, detail=f"生成验证码失败: {str(e)}")
  1145. @app.post("/api/auth/verify-code")
  1146. async def verify_code(request: VerifyCodeRequest):
  1147. """
  1148. 验证验证码
  1149. Args:
  1150. request: 验证验证码请求
  1151. - phone: 手机号(发送类型为短信时必填)
  1152. - email: 邮箱(发送类型为邮箱时必填)
  1153. - code: 验证码
  1154. - send_type: 发送类型 ("domestic_sms", "international_sms", "email")
  1155. Returns:
  1156. {
  1157. "success": true,
  1158. "data": {
  1159. "valid": true/false,
  1160. "message": "验证成功" 或 "验证码错误"
  1161. }
  1162. }
  1163. """
  1164. try:
  1165. # 验证参数
  1166. if request.send_type in ["domestic_sms", "international_sms"]:
  1167. if not request.phone:
  1168. raise HTTPException(status_code=400, detail="手机号不能为空")
  1169. elif request.send_type == "email":
  1170. if not request.email:
  1171. raise HTTPException(status_code=400, detail="邮箱不能为空")
  1172. else:
  1173. raise HTTPException(status_code=400, detail="无效的发送类型")
  1174. if not request.code:
  1175. raise HTTPException(status_code=400, detail="验证码不能为空")
  1176. # 查询有效的验证码
  1177. async with db_manager.get_session() as session:
  1178. if request.send_type in ["domestic_sms", "international_sms"]:
  1179. result = await session.execute(
  1180. select(VerificationCode)
  1181. .where(
  1182. VerificationCode.phone == request.phone,
  1183. VerificationCode.send_type == request.send_type,
  1184. VerificationCode.code == request.code,
  1185. VerificationCode.is_valid == True
  1186. )
  1187. .order_by(VerificationCode.created_at.desc())
  1188. .limit(1)
  1189. )
  1190. else:
  1191. result = await session.execute(
  1192. select(VerificationCode)
  1193. .where(
  1194. VerificationCode.email == request.email,
  1195. VerificationCode.send_type == request.send_type,
  1196. VerificationCode.code == request.code,
  1197. VerificationCode.is_valid == True
  1198. )
  1199. .order_by(VerificationCode.created_at.desc())
  1200. .limit(1)
  1201. )
  1202. verification_code = result.scalar_one_or_none()
  1203. if verification_code:
  1204. # 验证成功,将验证码标记为无效(一次性使用)
  1205. verification_code.is_valid = False
  1206. await session.commit()
  1207. logger.info(f"✅ 验证码验证成功: phone={request.phone}, email={request.email}, send_type={request.send_type}")
  1208. return {
  1209. "success": True,
  1210. "data": {
  1211. "valid": True,
  1212. "message": "验证成功"
  1213. }
  1214. }
  1215. else:
  1216. logger.warning(f"❌ 验证码验证失败: phone={request.phone}, email={request.email}, code={request.code}")
  1217. return {
  1218. "success": True,
  1219. "data": {
  1220. "valid": False,
  1221. "message": "验证码错误或已失效"
  1222. }
  1223. }
  1224. except HTTPException:
  1225. raise
  1226. except Exception as e:
  1227. logger.error(f"❌ 验证验证码失败: {e}")
  1228. raise HTTPException(status_code=500, detail=f"验证验证码失败: {str(e)}")
  1229. @app.get("/api/users/{user_id}/ui-state")
  1230. async def get_user_ui_state(user_id: str, _: None = Depends(require_user_session)) -> Dict[str, Any]:
  1231. """
  1232. 获取用户前端可视化状态(页面、路由、提示状态)
  1233. """
  1234. try:
  1235. state = await user_service.get_user_ui_state(user_id)
  1236. if not state:
  1237. # 没有记录时返回默认值
  1238. default_state = UserUIStateResponse(
  1239. user_id=user_id,
  1240. current_page=None,
  1241. visited_routes=[],
  1242. )
  1243. return {"success": True, "data": default_state.dict()}
  1244. return {"success": True, "data": state}
  1245. except Exception as e: # noqa: BLE001
  1246. raise HTTPException(status_code=500, detail=f"获取用户UI状态失败: {str(e)}") from e
  1247. @app.put("/api/users/{user_id}/ui-state")
  1248. async def update_user_ui_state(
  1249. user_id: str,
  1250. request: UpdateUserUIStateRequest,
  1251. _: None = Depends(require_user_session),
  1252. ) -> Dict[str, Any]:
  1253. """
  1254. 更新用户前端可视化状态(部分字段)
  1255. """
  1256. try:
  1257. updated = await user_service.upsert_user_ui_state(
  1258. user_id=user_id,
  1259. current_page=request.current_page,
  1260. visited_routes=request.visited_routes,
  1261. has_shown_tap_to_chat=request.has_shown_tap_to_chat,
  1262. has_shown_cancel_tooltip=request.has_shown_cancel_tooltip,
  1263. has_shown_send_tooltip=request.has_shown_send_tooltip,
  1264. has_shown_progress_bar_tooltip=request.has_shown_progress_bar_tooltip,
  1265. has_shown_mission_stone_modal=request.has_shown_mission_stone_modal,
  1266. has_shown_conversation_stone_modal=request.has_shown_conversation_stone_modal,
  1267. has_clicked_mission_stone=request.has_clicked_mission_stone,
  1268. has_clicked_conversation_stone=request.has_clicked_conversation_stone,
  1269. avatar_c_unlocked=request.avatar_c_unlocked,
  1270. has_shared_avatar=request.has_shared_avatar,
  1271. current_avatar_id=request.current_avatar_id,
  1272. )
  1273. if not updated:
  1274. raise HTTPException(status_code=500, detail="更新用户UI状态失败")
  1275. return {"success": True, "data": updated}
  1276. except HTTPException:
  1277. raise
  1278. except Exception as e: # noqa: BLE001
  1279. raise HTTPException(status_code=500, detail=f"更新用户UI状态失败: {str(e)}") from e
  1280. # ==================== OAuth 授权端点 ====================
  1281. @app.get("/api/auth/wechat/authorize")
  1282. async def wechat_authorize(request: Request):
  1283. """
  1284. 获取微信授权URL
  1285. 根据微信官方文档:https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
  1286. - 使用 qrconnect 端点进行网站应用授权
  1287. - scope 必须为 snsapi_login
  1288. - redirect_uri 需要 urlEncode 处理
  1289. - state 用于防止 CSRF 攻击
  1290. Returns:
  1291. 重定向到微信授权页面
  1292. """
  1293. if not settings.WECHAT_OPEN_APP_ID or not settings.WECHAT_REDIRECT_URI:
  1294. raise HTTPException(status_code=500, detail="微信OAuth未配置,请检查环境变量 WECHAT_OPEN_APP_ID 和 WECHAT_REDIRECT_URI")
  1295. # 生成state参数(用于防止CSRF攻击)
  1296. import secrets
  1297. state = secrets.token_urlsafe(32)
  1298. # 将 state 存储到 Redis(如果可用),用于回调时验证
  1299. # TTL 设置为 10 分钟(code 的有效期也是 10 分钟)
  1300. redis_cache = getattr(request.app.state, 'redis_cache', None)
  1301. if redis_cache:
  1302. try:
  1303. redis_client = await redis_cache._get_redis()
  1304. await redis_client.setex(f"oauth:wechat:state:{state}", 600, "1") # 10分钟过期
  1305. except Exception as e:
  1306. logger.warning(f"存储 state 到 Redis 失败,将跳过验证: {e}")
  1307. # 构建微信授权URL(使用开放平台的AppID)
  1308. # redirect_uri 会自动通过 urlencode 进行编码
  1309. params = {
  1310. "appid": settings.WECHAT_OPEN_APP_ID, # 使用开放平台的AppID
  1311. "redirect_uri": settings.WECHAT_REDIRECT_URI, # urlencode 会自动编码
  1312. "response_type": "code",
  1313. "scope": "snsapi_login", # 网站应用必须使用 snsapi_login
  1314. "state": state,
  1315. }
  1316. # 根据官方文档格式:https://open.weixin.qq.com/connect/qrconnect?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=SCOPE&state=STATE#wechat_redirect
  1317. auth_url = f"https://open.weixin.qq.com/connect/qrconnect?{urllib.parse.urlencode(params)}#wechat_redirect"
  1318. return RedirectResponse(url=auth_url)
  1319. @app.get("/api/auth/wechat/callback")
  1320. async def wechat_callback(
  1321. request: Request,
  1322. code: str = Query(..., description="微信授权码"),
  1323. state: str = Query(None, description="状态参数")
  1324. ):
  1325. """
  1326. 微信OAuth回调处理
  1327. 根据微信官方文档流程:
  1328. 1. 验证 state 参数(防止 CSRF 攻击)
  1329. 2. 通过 code 换取 access_token
  1330. 3. 使用 openid 进行登录/注册
  1331. Args:
  1332. code: 微信返回的授权码(有效期10分钟,只能使用一次)
  1333. state: 状态参数(用于验证请求合法性)
  1334. Returns:
  1335. 重定向到前端页面(带token)
  1336. """
  1337. try:
  1338. if not settings.WECHAT_OPEN_APP_ID or not settings.WECHAT_OPEN_APP_SECRET:
  1339. raise HTTPException(status_code=500, detail="微信OAuth未配置,请检查环境变量 WECHAT_OPEN_APP_ID 和 WECHAT_OPEN_APP_SECRET")
  1340. # 1. 验证 state 参数(防止 CSRF 攻击)
  1341. if state:
  1342. redis_cache = getattr(request.app.state, 'redis_cache', None)
  1343. if redis_cache:
  1344. try:
  1345. redis_client = await redis_cache._get_redis()
  1346. state_key = f"oauth:wechat:state:{state}"
  1347. exists = await redis_client.exists(state_key)
  1348. if not exists:
  1349. logger.warning(f"State 验证失败: {state} (可能已过期或被篡改)")
  1350. return RedirectResponse(
  1351. url=f"{settings.FRONTEND_URL}/register?error=wechat_auth_failed&reason=invalid_state"
  1352. )
  1353. # 验证成功后删除 state(一次性使用)
  1354. await redis_client.delete(state_key)
  1355. except Exception as e:
  1356. logger.warning(f"State 验证异常: {e},继续处理")
  1357. # 2. 通过code获取access_token(使用开放平台的AppID和AppSecret)
  1358. # 根据官方文档:https://api.weixin.qq.com/sns/oauth2/access_token
  1359. token_url = "https://api.weixin.qq.com/sns/oauth2/access_token"
  1360. token_params = {
  1361. "appid": settings.WECHAT_OPEN_APP_ID, # 使用开放平台的AppID
  1362. "secret": settings.WECHAT_OPEN_APP_SECRET, # 使用开放平台的AppSecret
  1363. "code": code,
  1364. "grant_type": "authorization_code",
  1365. }
  1366. async with httpx.AsyncClient() as client:
  1367. token_response = await client.get(token_url, params=token_params)
  1368. token_data = token_response.json()
  1369. if "errcode" in token_data:
  1370. logger.error(f"微信获取access_token失败: {token_data}")
  1371. return RedirectResponse(
  1372. url=f"{settings.FRONTEND_URL}/register?error=wechat_auth_failed"
  1373. )
  1374. access_token = token_data.get("access_token")
  1375. openid = token_data.get("openid")
  1376. # refresh_token 可用于刷新 access_token,当前实现暂不保存
  1377. if not access_token or not openid:
  1378. logger.error(f"微信返回数据不完整: {token_data}")
  1379. return RedirectResponse(
  1380. url=f"{settings.FRONTEND_URL}/register?error=wechat_auth_failed"
  1381. )
  1382. # 3. 使用openid登录/注册
  1383. # 注意:当前使用 snsapi_login scope,只能获取 openid
  1384. # 如需获取用户昵称、头像等信息,需要:
  1385. # - 将 scope 改为 snsapi_login,snsapi_userinfo
  1386. # - 调用 /sns/userinfo 接口获取用户信息
  1387. user = await user_service.login_with_wechat(openid=openid)
  1388. # 4. 重定向到前端,携带用户信息
  1389. frontend_url = f"{settings.FRONTEND_URL}/register?oauth=wechat&user_id={user['user_id']}&username={urllib.parse.quote(user['username'])}"
  1390. return RedirectResponse(url=frontend_url)
  1391. except Exception as e:
  1392. logger.error(f"微信OAuth回调处理失败: {e}")
  1393. return RedirectResponse(
  1394. url=f"{settings.FRONTEND_URL}/register?error=wechat_auth_failed"
  1395. )
  1396. @app.get("/api/auth/qq/authorize")
  1397. async def qq_authorize(
  1398. request: Request,
  1399. from_page: str = Query(None, alias="from", description="来源页面"),
  1400. user_id: str = Query(None, description="可选的现有用户ID,如果提供则更新该用户而不是创建新用户")
  1401. ):
  1402. """
  1403. 获取QQ授权URL
  1404. Args:
  1405. from_page: 来源页面(如 first-login),用于回调时决定重定向位置
  1406. user_id: 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
  1407. Returns:
  1408. 重定向到QQ授权页面
  1409. """
  1410. if not settings.QQ_APP_ID or not settings.QQ_REDIRECT_URI:
  1411. raise HTTPException(status_code=500, detail="QQ OAuth未配置,请检查环境变量")
  1412. # 生成state参数(用于防止CSRF攻击)
  1413. import secrets
  1414. state_token = secrets.token_urlsafe(32)
  1415. # state只存储token,from_page和user_id存储在Redis中(避免token包含下划线导致解析错误)
  1416. state = state_token
  1417. # 将 state 存储到 Redis 以便回调校验(存储原始 token 和来源信息)
  1418. redis_cache = getattr(request.app.state, 'redis_cache', None)
  1419. state_stored = False
  1420. state_key = f"oauth:qq:state:{state_token}"
  1421. logger.info(f"📝 [QQ授权] 开始存储state - state_token={state_token}, from_page={from_page}, user_id={user_id}, state={state}, state_key={state_key}")
  1422. if redis_cache:
  1423. try:
  1424. redis_client = await redis_cache._get_redis()
  1425. # 🔥 检查Redis连接状态
  1426. try:
  1427. ping_result = await redis_client.ping()
  1428. logger.debug(f"✅ [QQ授权] Redis连接正常: ping={ping_result}")
  1429. except Exception as ping_err:
  1430. logger.error(f"❌ [QQ授权] Redis ping失败: {ping_err}")
  1431. # 🔥 检查Redis配置信息(DB、连接池等)
  1432. try:
  1433. redis_info = await redis_client.info('server')
  1434. redis_db = redis_client.connection_pool.connection_kwargs.get('db', 0)
  1435. logger.debug(f"🔍 [QQ授权] Redis配置 - DB={redis_db}, server_version={redis_info.get('redis_version', 'unknown')}")
  1436. except Exception as info_err:
  1437. logger.debug(f"⚠️ [QQ授权] 获取Redis信息失败: {info_err}")
  1438. # 🔥 检查key是否已存在(防止重复)
  1439. existing_value = await redis_client.get(state_key)
  1440. if existing_value:
  1441. logger.warning(f"⚠️ [QQ授权] state key已存在,将被覆盖: key={state_key}, old_value={existing_value[:50]}...")
  1442. # 存储原始 token、来源信息和 user_id
  1443. state_data = {"token": state_token}
  1444. if from_page:
  1445. state_data["from"] = from_page
  1446. if user_id:
  1447. state_data["user_id"] = user_id
  1448. state_value = json.dumps(state_data)
  1449. logger.debug(f"📦 [QQ授权] 准备存储 - key={state_key}, value={state_value}, TTL=1800s")
  1450. # 🔥 增加TTL到1800秒(30分钟),避免授权流程过长导致过期
  1451. await redis_client.setex(state_key, 1800, state_value)
  1452. state_stored = True
  1453. # 🔥 验证存储是否成功
  1454. verify_value = await redis_client.get(state_key)
  1455. if verify_value:
  1456. # 🔥 检查TTL
  1457. ttl = await redis_client.ttl(state_key)
  1458. logger.info(f"✅ [QQ授权] QQ state 已存储并验证成功 - key={state_key}, token={state_token[:16]}..., TTL={ttl}s, value_length={len(verify_value)}")
  1459. else:
  1460. logger.error(f"❌ [QQ授权] QQ state 存储后验证失败 - key={state_key}, token={state_token[:16]}...")
  1461. # 🔥 尝试查找所有相关的keys
  1462. try:
  1463. pattern = f"oauth:qq:state:*"
  1464. keys = []
  1465. async for key in redis_client.scan_iter(match=pattern, count=100):
  1466. keys.append(key)
  1467. logger.debug(f"🔍 [QQ授权] Redis中存在的QQ state keys: {keys[:10]}... (共{len(keys)}个)")
  1468. except Exception as scan_err:
  1469. logger.debug(f"🔍 [QQ授权] 扫描keys失败: {scan_err}")
  1470. except Exception as e:
  1471. logger.error(f"❌ [QQ授权] 存储 QQ state 失败 - key={state_key}, error={e}", exc_info=True)
  1472. else:
  1473. logger.warning("⚠️ [QQ授权] Redis缓存服务不可用,QQ state 验证将被跳过")
  1474. # 确保回调地址格式正确(去除末尾斜杠,确保与QQ应用配置完全一致)
  1475. redirect_uri = settings.QQ_REDIRECT_URI.rstrip('/')
  1476. # 构建QQ授权URL
  1477. # 注意:QQ开放平台要求redirect_uri必须与配置的回调域完全匹配
  1478. params = {
  1479. "response_type": "code",
  1480. "client_id": settings.QQ_APP_ID,
  1481. "redirect_uri": redirect_uri, # 使用处理后的回调地址
  1482. "state": state,
  1483. "scope": "get_user_info", # QQ授权范围
  1484. }
  1485. # URL编码参数(确保特殊字符正确编码)
  1486. auth_url = f"https://graph.qq.com/oauth2.0/authorize?{urllib.parse.urlencode(params)}"
  1487. # 调试日志:记录实际发送的授权URL(隐藏敏感信息)
  1488. logger.info(f"🔍 QQ授权URL生成 - AppID: {settings.QQ_APP_ID[:8]}..., 回调地址: {redirect_uri}, 来源: {from_page}")
  1489. return RedirectResponse(url=auth_url)
  1490. @app.get("/api/qq/mp/verify")
  1491. async def qq_callback(
  1492. request: Request,
  1493. code: str = Query(..., description="QQ授权码"),
  1494. state: str = Query(None, description="状态参数")
  1495. ):
  1496. """
  1497. QQ OAuth回调处理
  1498. Args:
  1499. code: QQ返回的授权码
  1500. state: 状态参数
  1501. Returns:
  1502. 重定向到前端页面(带token)
  1503. """
  1504. try:
  1505. logger.info(f"📥 [QQ回调] 收到回调请求 - code={'***' if code else '(empty)'}, state={state}")
  1506. # 0. 验证 state 防止 CSRF,并提取来源信息
  1507. from_page = None
  1508. user_id_from_state = None
  1509. state_token = state # state就是token,不再需要分割
  1510. if state:
  1511. logger.debug(f"🔍 [QQ回调] 开始解析state - state={state}, length={len(state)}")
  1512. redis_cache = getattr(request.app.state, 'redis_cache', None)
  1513. state_key = f"oauth:qq:state:{state_token}"
  1514. logger.info(f"🔑 [QQ回调] 准备验证state - state_token={state_token[:16]}..., state_key={state_key}")
  1515. if redis_cache:
  1516. try:
  1517. redis_client = await redis_cache._get_redis()
  1518. # 🔥 检查Redis连接状态
  1519. try:
  1520. ping_result = await redis_client.ping()
  1521. logger.debug(f"✅ [QQ回调] Redis连接正常: ping={ping_result}")
  1522. except Exception as ping_error:
  1523. logger.error(f"❌ [QQ回调] Redis ping失败: {ping_error}", exc_info=True)
  1524. # Redis连接失败时,跳过验证但记录警告(为了不影响用户体验)
  1525. logger.warning("⚠️ [QQ回调] Redis连接失败,跳过state验证(可能存在安全风险)")
  1526. # 继续处理,不阻止登录流程
  1527. else:
  1528. # 🔥 检查Redis配置信息(DB、连接池等)
  1529. try:
  1530. redis_db = redis_client.connection_pool.connection_kwargs.get('db', 0)
  1531. logger.debug(f"🔍 [QQ回调] Redis配置 - DB={redis_db}")
  1532. except Exception as info_err:
  1533. logger.debug(f"⚠️ [QQ回调] 获取Redis配置失败: {info_err}")
  1534. # 🔥 尝试查找key
  1535. logger.debug(f"🔍 [QQ回调] 开始查找key: {state_key}")
  1536. state_data_str = await redis_client.get(state_key)
  1537. if not state_data_str:
  1538. # 🔥 增加详细日志,帮助诊断问题
  1539. logger.warning(f"❌ [QQ回调] QQ state 验证失败 - state_token={state_token[:16]}..., state_key={state_key}")
  1540. # 🔥 检查key的TTL(如果key存在但值为None,可能是其他问题)
  1541. try:
  1542. exists = await redis_client.exists(state_key)
  1543. logger.debug(f"🔍 [QQ回调] key存在性检查: exists={exists}")
  1544. if exists:
  1545. ttl = await redis_client.ttl(state_key)
  1546. logger.debug(f"🔍 [QQ回调] key存在但值为空,TTL={ttl}s")
  1547. except Exception as exists_err:
  1548. logger.debug(f"🔍 [QQ回调] 检查key存在性失败: {exists_err}")
  1549. # 🔥 尝试查找所有相关的state key(用于调试)
  1550. try:
  1551. pattern = f"oauth:qq:state:*"
  1552. keys = []
  1553. async for key in redis_client.scan_iter(match=pattern, count=100):
  1554. keys.append(key)
  1555. if keys:
  1556. logger.warning(f"🔍 [QQ回调] Redis中存在其他QQ state keys (共{len(keys)}个): {keys[:10]}")
  1557. # 🔥 检查这些keys的TTL
  1558. for key in keys[:5]:
  1559. try:
  1560. ttl = await redis_client.ttl(key)
  1561. value = await redis_client.get(key)
  1562. logger.debug(f"🔍 [QQ回调] key详情 - {key}: TTL={ttl}s, value_length={len(value) if value else 0}")
  1563. except Exception as key_err:
  1564. logger.debug(f"🔍 [QQ回调] 获取key详情失败 {key}: {key_err}")
  1565. else:
  1566. logger.warning(f"🔍 [QQ回调] Redis中不存在任何QQ state keys")
  1567. except Exception as scan_error:
  1568. logger.debug(f"🔍 [QQ回调] 扫描Redis keys失败: {scan_error}")
  1569. return RedirectResponse(
  1570. url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed&reason=invalid_state"
  1571. )
  1572. # 🔥 找到key,记录详细信息
  1573. ttl = await redis_client.ttl(state_key)
  1574. logger.info(f"✅ [QQ回调] 找到state key - key={state_key}, TTL={ttl}s, value_length={len(state_data_str)}")
  1575. # 如果存储的是 JSON 格式,解析获取来源信息和user_id
  1576. try:
  1577. state_data = json.loads(state_data_str)
  1578. from_page = state_data.get("from") or from_page
  1579. user_id_from_state = state_data.get("user_id")
  1580. logger.info(f"✅ [QQ回调] QQ state 验证成功 - state_token={state_token[:16]}..., 来源: {from_page}, user_id: {user_id_from_state}, data={state_data}")
  1581. except (json.JSONDecodeError, TypeError) as json_err:
  1582. # 如果不是 JSON 格式,使用从 state 中解析的 from_page
  1583. logger.debug(f"✅ [QQ回调] QQ state 验证成功 (非JSON格式) - state_token={state_token[:16]}..., value={state_data_str[:50]}..., json_err={json_err}")
  1584. pass
  1585. # 🔥 删除已使用的state key
  1586. deleted = await redis_client.delete(state_key)
  1587. logger.debug(f"🗑️ [QQ回调] 删除state key - key={state_key}, deleted={deleted}")
  1588. except Exception as state_error:
  1589. logger.error(f"❌ [QQ回调] QQ state 验证异常 - state_token={state_token[:16]}..., error={state_error}", exc_info=True)
  1590. # 🔥 验证异常时不阻止流程,但记录详细错误
  1591. else:
  1592. logger.warning("⚠️ [QQ回调] Redis缓存服务不可用,跳过state验证")
  1593. if not settings.QQ_APP_ID or not settings.QQ_APP_KEY:
  1594. raise HTTPException(status_code=500, detail="QQ OAuth未配置")
  1595. # 小工具:解析 QQ JSONP 或 querystring 响应
  1596. def parse_qq_payload(payload_text: str) -> dict:
  1597. cleaned = (payload_text or "").strip()
  1598. if not cleaned:
  1599. return {}
  1600. if cleaned.startswith("callback("):
  1601. cleaned = cleaned.split("callback(", 1)[1]
  1602. cleaned = cleaned.rsplit(");", 1)[0]
  1603. try:
  1604. return json.loads(cleaned)
  1605. except json.JSONDecodeError:
  1606. parsed = {}
  1607. for part in cleaned.split("&"):
  1608. if "=" in part:
  1609. key, value = part.split("=", 1)
  1610. parsed[key] = value
  1611. return parsed
  1612. # 1. 通过code获取access_token
  1613. # 确保回调地址格式正确(与授权时保持一致)
  1614. redirect_uri = settings.QQ_REDIRECT_URI.rstrip('/')
  1615. token_url = "https://graph.qq.com/oauth2.0/token"
  1616. token_params = {
  1617. "grant_type": "authorization_code",
  1618. "client_id": settings.QQ_APP_ID,
  1619. "client_secret": settings.QQ_APP_KEY,
  1620. "code": code,
  1621. "redirect_uri": redirect_uri, # 使用处理后的回调地址
  1622. "fmt": "json",
  1623. }
  1624. logger.info(f"🔍 QQ获取token - 回调地址: {redirect_uri}")
  1625. async with httpx.AsyncClient() as client:
  1626. token_response = await client.get(token_url, params=token_params)
  1627. token_text = token_response.text
  1628. try:
  1629. token_data = token_response.json()
  1630. except ValueError:
  1631. token_data = parse_qq_payload(token_text)
  1632. if "error" in token_data:
  1633. logger.error(f"QQ获取access_token失败: {token_data}")
  1634. return RedirectResponse(
  1635. url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed&reason=token"
  1636. )
  1637. access_token = token_data.get("access_token")
  1638. if not access_token:
  1639. logger.error(f"QQ返回数据不完整: {token_data}")
  1640. return RedirectResponse(
  1641. url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed"
  1642. )
  1643. # 2. 通过access_token获取openid
  1644. openid_url = "https://graph.qq.com/oauth2.0/me"
  1645. openid_params = {
  1646. "access_token": access_token,
  1647. "unionid": 1,
  1648. "fmt": "json",
  1649. }
  1650. async with httpx.AsyncClient() as client:
  1651. openid_response = await client.get(openid_url, params=openid_params)
  1652. openid_data = parse_qq_payload(openid_response.text)
  1653. if "error" in openid_data:
  1654. logger.error(f"QQ获取openid失败: {openid_data}")
  1655. return RedirectResponse(
  1656. url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed"
  1657. )
  1658. openid = openid_data.get("openid")
  1659. unionid = openid_data.get("unionid")
  1660. if not openid:
  1661. logger.error(f"QQ返回openid为空: {openid_data}")
  1662. return RedirectResponse(
  1663. url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed"
  1664. )
  1665. # 2.1 可选:拉取用户信息用于填充昵称
  1666. nickname = ""
  1667. user_info = {}
  1668. user_info_url = "https://graph.qq.com/user/get_user_info"
  1669. user_info_params = {
  1670. "access_token": access_token,
  1671. "oauth_consumer_key": settings.QQ_APP_ID,
  1672. "openid": openid,
  1673. "fmt": "json",
  1674. }
  1675. try:
  1676. async with httpx.AsyncClient() as client:
  1677. user_info_resp = await client.get(
  1678. user_info_url,
  1679. params=user_info_params,
  1680. )
  1681. user_info = user_info_resp.json()
  1682. if user_info.get("ret") == 0:
  1683. nickname = user_info.get("nickname", "") or ""
  1684. else:
  1685. logger.warning(
  1686. f"QQ用户信息获取失败: {user_info}"
  1687. )
  1688. except Exception as info_error:
  1689. logger.warning(f"拉取QQ用户信息异常: {info_error}")
  1690. # 3. 根据来源页面决定处理逻辑
  1691. if from_page == "login":
  1692. # 从 login 页面来的,需要先检查用户是否存在
  1693. # 如果用户不存在,不允许自动注册,应该提示用户先注册
  1694. logger.info(f"🔍 [QQ回调] 开始检查用户是否存在 - openid={openid[:16]}..., unionid={unionid[:16] if unionid else None}...")
  1695. user_exists = await user_service.check_user_exists_by_qq_openid(openid=openid, unionid=unionid)
  1696. logger.info(f"🔍 [QQ回调] 用户存在性检查结果 - user_exists={user_exists}, openid={openid[:16]}...")
  1697. if not user_exists:
  1698. # 用户不存在,重定向到 begin 页面并提示(和手机号登录保持一致)
  1699. logger.info(f"⚠️ QQ用户未注册(来自login页面): openid={openid[:16]}..., unionid={unionid[:16] if unionid else None}...")
  1700. return RedirectResponse(
  1701. url=f"{settings.FRONTEND_URL}/begin?error=user_not_registered&oauth=qq"
  1702. )
  1703. # 用户存在,使用openid登录
  1704. # 不传递QQ返回的nickname,避免覆盖用户原有用户名
  1705. logger.info(f"✅ [QQ回调] 用户存在,开始登录 - openid={openid[:16]}...")
  1706. user = await user_service.login_with_qq(
  1707. openid=openid,
  1708. username="", # 不使用QQ返回的nickname
  1709. unionid=unionid,
  1710. )
  1711. # 🔧 生成会话 token(前端需要 token 来调用 getUserInfo API)
  1712. session_data = await _generate_session_token(user['user_id'])
  1713. token = session_data['token']
  1714. logger.info(f"✅ [QQ回调] 已生成会话 token - user_id={user['user_id']}, token={token[:16]}...")
  1715. # 重定向到聊天界面(传递 token,避免前端调用 ensureSession 时使用错误的 token)
  1716. logger.info(f"✅ [QQ回调] 登录成功,准备重定向到聊天界面 - user_id={user['user_id']}")
  1717. frontend_url = f"{settings.FRONTEND_URL}/chatbot?oauth=qq&user_id={user['user_id']}&token={token}"
  1718. return RedirectResponse(url=frontend_url)
  1719. elif from_page == "first-login":
  1720. # 从 first-login 页面来的,允许自动注册
  1721. # 如果提供了 user_id,则更新现有用户;否则创建新用户
  1722. # 不传递QQ返回的nickname,避免覆盖用户原有用户名
  1723. user = await user_service.login_with_qq(
  1724. openid=openid,
  1725. username="", # 不使用QQ返回的nickname
  1726. unionid=unionid,
  1727. user_id=user_id_from_state,
  1728. )
  1729. # 重定向到 first-login-reward(不传递username,避免使用QQ返回的nickname)
  1730. frontend_url = f"{settings.FRONTEND_URL}/first-login-reward?oauth=qq&user_id={user['user_id']}"
  1731. return RedirectResponse(url=frontend_url)
  1732. else:
  1733. # 其他来源(如 register 页面),允许自动注册
  1734. # 使用openid登录/注册(login_with_qq 会自动创建新用户)
  1735. # 不传递QQ返回的nickname,避免使用QQ昵称作为用户名
  1736. user = await user_service.login_with_qq(
  1737. openid=openid,
  1738. username="", # 不使用QQ返回的nickname
  1739. unionid=unionid,
  1740. )
  1741. # 默认重定向到聊天界面(不传递username,由前端从数据库同步)
  1742. frontend_url = f"{settings.FRONTEND_URL}/chatbot?oauth=qq&user_id={user['user_id']}"
  1743. return RedirectResponse(url=frontend_url)
  1744. except Exception as e:
  1745. logger.error(f"QQ OAuth回调处理失败: {e}")
  1746. return RedirectResponse(
  1747. url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed"
  1748. )
  1749. @app.get("/api/wechat/jsapi/config")
  1750. async def get_wechat_jsapi_config(
  1751. url: str = Query(..., description="当前页面URL(用于生成签名)")
  1752. ):
  1753. """
  1754. 获取微信JS-SDK配置(包含签名)
  1755. Args:
  1756. url: 当前页面完整URL(前端传入,用于生成签名)
  1757. Returns:
  1758. {
  1759. "success": true,
  1760. "data": {
  1761. "appId": "微信AppID",
  1762. "timestamp": 1234567890,
  1763. "nonceStr": "随机字符串",
  1764. "signature": "SHA1签名"
  1765. }
  1766. }
  1767. """
  1768. try:
  1769. if not settings.WECHAT_MP_APP_ID:
  1770. logger.error("微信公众平台AppID未配置,请检查环境变量 WECHAT_MP_APP_ID")
  1771. raise HTTPException(status_code=500, detail="微信公众平台AppID未配置,请检查环境变量 WECHAT_MP_APP_ID")
  1772. # 记录接收到的URL(用于调试)
  1773. logger.info(f"📥 收到微信JS-SDK配置请求: url={url[:100]}...")
  1774. # 获取Redis客户端(用于缓存jsapi_ticket)
  1775. from database import db_manager
  1776. redis_getter = db_manager.get_redis
  1777. # 创建微信JS-SDK服务
  1778. jsapi_service = WechatJSAPIService(redis_getter=redis_getter)
  1779. # 获取配置
  1780. config = await jsapi_service.get_jsapi_config(url)
  1781. if not config:
  1782. logger.error("获取微信JS-SDK配置失败(返回None)")
  1783. raise HTTPException(status_code=500, detail="获取微信JS-SDK配置失败")
  1784. logger.info(f"✅ 微信JS-SDK配置生成成功: appId={config.get('appId', 'N/A')[:10]}..., timestamp={config.get('timestamp')}")
  1785. return {
  1786. "success": True,
  1787. "data": config
  1788. }
  1789. except HTTPException:
  1790. raise
  1791. except Exception as e:
  1792. logger.error(f"获取微信JS-SDK配置异常: {e}", exc_info=True)
  1793. raise HTTPException(status_code=500, detail=f"获取配置失败: {str(e)}")
  1794. @app.get("/api/qq/share/config")
  1795. async def get_qq_share_config():
  1796. """
  1797. 获取QQ分享配置(包含AppID和应用名称)
  1798. Returns:
  1799. {
  1800. "success": true,
  1801. "data": {
  1802. "appId": "QQ AppID",
  1803. "appName": "应用名称"
  1804. }
  1805. }
  1806. """
  1807. try:
  1808. if not settings.QQ_APP_ID:
  1809. logger.warning("QQ AppID未配置,返回空配置")
  1810. return {
  1811. "success": True,
  1812. "data": {
  1813. "appId": "",
  1814. "appName": settings.QQ_APP_NAME
  1815. }
  1816. }
  1817. logger.info(f"📥 收到QQ分享配置请求: appId={settings.QQ_APP_ID[:8]}..., appName={settings.QQ_APP_NAME}")
  1818. return {
  1819. "success": True,
  1820. "data": {
  1821. "appId": settings.QQ_APP_ID,
  1822. "appName": settings.QQ_APP_NAME
  1823. }
  1824. }
  1825. except Exception as e:
  1826. logger.error(f"获取QQ分享配置异常: {e}", exc_info=True)
  1827. raise HTTPException(status_code=500, detail=f"获取配置失败: {str(e)}")
  1828. # ==================== 微信公众平台服务器配置 ====================
  1829. @app.get("/api/wechat/mp/verify")
  1830. async def wechat_mp_verify(
  1831. signature: str = Query(..., description="微信加密签名"),
  1832. timestamp: str = Query(..., description="时间戳"),
  1833. nonce: str = Query(..., description="随机数"),
  1834. echostr: str = Query(..., description="随机字符串")
  1835. ):
  1836. """
  1837. 微信公众平台服务器验证接口(GET)
  1838. 用于微信公众平台配置服务器URL时的验证
  1839. Args:
  1840. signature: 微信加密签名
  1841. timestamp: 时间戳
  1842. nonce: 随机数
  1843. echostr: 随机字符串
  1844. Returns:
  1845. 验证成功返回echostr,失败返回空字符串
  1846. """
  1847. if not settings.WECHAT_MP_TOKEN:
  1848. logger.error("微信公众平台Token未配置")
  1849. return PlainTextResponse("", status_code=500)
  1850. try:
  1851. # 1. 将token、timestamp、nonce按字典序排序
  1852. token = settings.WECHAT_MP_TOKEN
  1853. tmp_arr = [token, timestamp, nonce]
  1854. tmp_arr.sort()
  1855. # 2. 拼接字符串并SHA1加密
  1856. tmp_str = ''.join(tmp_arr)
  1857. tmp_str = hashlib.sha1(tmp_str.encode('utf-8')).hexdigest()
  1858. # 3. 与signature比较
  1859. if tmp_str == signature:
  1860. logger.info("✅ 微信公众平台服务器验证成功")
  1861. return PlainTextResponse(echostr)
  1862. else:
  1863. logger.warning(f"⚠️ 微信公众平台服务器验证失败: signature不匹配")
  1864. return PlainTextResponse("", status_code=403)
  1865. except Exception as e:
  1866. logger.error(f"❌ 微信公众平台服务器验证异常: {e}")
  1867. return PlainTextResponse("", status_code=500)
  1868. @app.post("/api/wechat/mp/message")
  1869. async def wechat_mp_message(request: Request):
  1870. """
  1871. 微信公众平台消息推送接口(POST)
  1872. 接收微信公众平台推送的消息(文本、事件等)
  1873. Returns:
  1874. XML格式的响应消息
  1875. """
  1876. if not settings.WECHAT_MP_TOKEN:
  1877. logger.error("微信公众平台Token未配置")
  1878. return PlainTextResponse("", status_code=500)
  1879. try:
  1880. # 1. 获取请求参数(用于验证签名)
  1881. signature = request.query_params.get("signature", "")
  1882. timestamp = request.query_params.get("timestamp", "")
  1883. nonce = request.query_params.get("nonce", "")
  1884. # 2. 验证签名
  1885. token = settings.WECHAT_MP_TOKEN
  1886. tmp_arr = [token, timestamp, nonce]
  1887. tmp_arr.sort()
  1888. tmp_str = ''.join(tmp_arr)
  1889. tmp_str = hashlib.sha1(tmp_str.encode('utf-8')).hexdigest()
  1890. if tmp_str != signature:
  1891. logger.warning(f"⚠️ 微信公众平台消息签名验证失败")
  1892. return PlainTextResponse("", status_code=403)
  1893. # 3. 读取XML消息体
  1894. body = await request.body()
  1895. xml_data = body.decode('utf-8')
  1896. # 4. 解析XML
  1897. root = ET.fromstring(xml_data)
  1898. msg_type = root.find('MsgType').text if root.find('MsgType') is not None else ''
  1899. from_user = root.find('FromUserName').text if root.find('FromUserName') is not None else ''
  1900. to_user = root.find('ToUserName').text if root.find('ToUserName') is not None else ''
  1901. logger.info(f"📨 收到微信公众平台消息: type={msg_type}, from={from_user}")
  1902. # 5. 处理不同类型的消息
  1903. if msg_type == 'text':
  1904. # 文本消息
  1905. content = root.find('Content').text if root.find('Content') is not None else ''
  1906. logger.info(f"📝 文本消息内容: {content}")
  1907. # 回复文本消息(示例:自动回复)
  1908. reply_content = "感谢您的消息!"
  1909. reply_xml = f"""<xml>
  1910. <ToUserName><![CDATA[{from_user}]]></ToUserName>
  1911. <FromUserName><![CDATA[{to_user}]]></FromUserName>
  1912. <CreateTime>{int(__import__('time').time())}</CreateTime>
  1913. <MsgType><![CDATA[text]]></MsgType>
  1914. <Content><![CDATA[{reply_content}]]></Content>
  1915. </xml>"""
  1916. return PlainTextResponse(reply_xml, media_type="application/xml")
  1917. elif msg_type == 'event':
  1918. # 事件消息
  1919. event = root.find('Event').text if root.find('Event') is not None else ''
  1920. logger.info(f"🎯 事件消息: {event}")
  1921. # 处理关注/取消关注事件
  1922. if event == 'subscribe':
  1923. reply_content = "欢迎关注!"
  1924. elif event == 'unsubscribe':
  1925. reply_content = "感谢您的关注!"
  1926. else:
  1927. reply_content = "收到事件消息"
  1928. reply_xml = f"""<xml>
  1929. <ToUserName><![CDATA[{from_user}]]></ToUserName>
  1930. <FromUserName><![CDATA[{to_user}]]></FromUserName>
  1931. <CreateTime>{int(__import__('time').time())}</CreateTime>
  1932. <MsgType><![CDATA[text]]></MsgType>
  1933. <Content><![CDATA[{reply_content}]]></Content>
  1934. </xml>"""
  1935. return PlainTextResponse(reply_xml, media_type="application/xml")
  1936. else:
  1937. # 其他类型消息,返回空响应
  1938. logger.info(f"ℹ️ 未处理的消息类型: {msg_type}")
  1939. return PlainTextResponse("success")
  1940. except Exception as e:
  1941. logger.error(f"❌ 处理微信公众平台消息异常: {e}")
  1942. return PlainTextResponse("success") # 返回success避免微信重试
  1943. @app.websocket("/ws")
  1944. async def websocket_endpoint(websocket: WebSocket):
  1945. """WebSocket端点"""
  1946. # 从查询参数获取user_id(Lyrria_user_id)与会话token
  1947. user_id = websocket.query_params.get("user_id")
  1948. token = websocket.query_params.get("token")
  1949. # 记录连接信息
  1950. import logging
  1951. logger = logging.getLogger(__name__)
  1952. logger.info(
  1953. f"🔌 WebSocket连接: user_id={user_id}, query_params={dict(websocket.query_params)}"
  1954. )
  1955. # 🔥 传入Redis缓存服务、user_id、session_store
  1956. redis_cache = getattr(app.state, 'redis_cache', None)
  1957. session_store = getattr(app.state, "session_store", None)
  1958. handler = WebSocketHandler(
  1959. websocket,
  1960. user_id=user_id,
  1961. token=token,
  1962. redis_cache=redis_cache,
  1963. session_store=session_store,
  1964. )
  1965. await handler.handle_connection()
  1966. if __name__ == "__main__":
  1967. import uvicorn
  1968. import os
  1969. import socket
  1970. from pathlib import Path
  1971. # 🔥 DNS预解析(减少首次连接延迟)
  1972. dashscope_domain = get_dashscope_domain()
  1973. try:
  1974. socket.getaddrinfo(dashscope_domain, 443)
  1975. except Exception as e:
  1976. pass
  1977. # SSL证书路径配置
  1978. # 优先使用环境变量配置的证书路径(生产环境)
  1979. ssl_keyfile = settings.SSL_KEYFILE
  1980. ssl_certfile = settings.SSL_CERTFILE
  1981. # 如果环境变量未配置,使用本地开发证书
  1982. if not ssl_keyfile or not ssl_certfile:
  1983. ssl_dir = Path(__file__).parent.parent / "ssl"
  1984. ssl_keyfile = str(ssl_dir / "key.pem")
  1985. ssl_certfile = str(ssl_dir / "cert.pem")
  1986. # 检查证书是否存在
  1987. if ssl_keyfile and ssl_certfile:
  1988. if not os.path.exists(ssl_keyfile) or not os.path.exists(ssl_certfile):
  1989. ssl_keyfile = None
  1990. ssl_certfile = None
  1991. uvicorn.run(
  1992. "main:app",
  1993. host=settings.host,
  1994. port=settings.port,
  1995. reload=settings.DEBUG,
  1996. ssl_keyfile=ssl_keyfile,
  1997. ssl_certfile=ssl_certfile,
  1998. )