main.py 39 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187
  1. """
  2. 主应用入口
  3. """
  4. import json
  5. import logging
  6. import urllib.parse
  7. import hashlib
  8. from typing import Optional, List, Dict, Any
  9. from fastapi import FastAPI, WebSocket, HTTPException, Request, Query
  10. from fastapi.responses import RedirectResponse, PlainTextResponse
  11. from fastapi.middleware.cors import CORSMiddleware
  12. from pydantic import BaseModel
  13. from contextlib import asynccontextmanager
  14. import httpx
  15. from xml.etree import ElementTree as ET
  16. from config import settings, get_dashscope_domain
  17. from websocket_handler import WebSocketHandler
  18. from database import init_database, close_database
  19. from services.redis_cache_service import RedisCacheService
  20. from services.user_service import user_service
  21. from services.wechat_jsapi_service import WechatJSAPIService
  22. # 配置日志
  23. logging.basicConfig(
  24. level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO),
  25. format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
  26. datefmt='%Y-%m-%d %H:%M:%S'
  27. )
  28. logger = logging.getLogger(__name__)
  29. @asynccontextmanager
  30. async def lifespan(app: FastAPI):
  31. """应用生命周期管理"""
  32. # 启动时初始化数据库(包含MySQL和Redis,自动启动健康检查任务)
  33. try:
  34. await init_database()
  35. # 获取Redis客户端(已配置健康检查),用于验证连通性
  36. from database import db_manager
  37. await db_manager.get_redis()
  38. # 创建Redis缓存服务,按需获取最新连接
  39. app.state.redis_cache = RedisCacheService(db_manager.get_redis)
  40. except Exception as e:
  41. logger.error(f"❌ 数据库初始化失败: {e}")
  42. app.state.redis_cache = None
  43. # 🔥 启动HTTP连接保活任务(保持连接活跃,避免长时间不交互后响应变慢)
  44. try:
  45. from http_pool import http_pool
  46. from ai_service import AIService
  47. # 创建AIService实例用于保活(共享连接池)
  48. ai_service = AIService()
  49. http_pool.start_keepalive(ai_service=ai_service)
  50. except Exception as e:
  51. logger.warning(f"⚠️ HTTP连接保活任务启动失败: {e}")
  52. yield
  53. # 关闭时清理资源
  54. await close_database()
  55. # 🔥 关闭HTTP连接池(类似SQLAlchemy的Engine.dispose())
  56. from http_pool import http_pool
  57. http_pool.close()
  58. app = FastAPI(
  59. title="Lyrria Backend API",
  60. lifespan=lifespan
  61. )
  62. # 配置CORS
  63. app.add_middleware(
  64. CORSMiddleware,
  65. allow_origins=["*"], # 生产环境应该限制具体域名
  66. allow_credentials=True,
  67. allow_methods=["*"],
  68. allow_headers=["*"],
  69. )
  70. @app.get("/")
  71. async def root():
  72. """健康检查"""
  73. return {"status": "ok", "message": "Lyrria Backend API"}
  74. class CreateUserRequest(BaseModel):
  75. """创建用户请求"""
  76. username: str = ""
  77. bot_name: Optional[str] = None
  78. class UpdateUsernameRequest(BaseModel):
  79. """更新用户名请求"""
  80. username: str
  81. class UpdateBotNameRequest(BaseModel):
  82. """更新机器人名字请求"""
  83. bot_name: str
  84. class RegisterPhoneRequest(BaseModel):
  85. """手机号注册请求"""
  86. phone: str
  87. username: str = ""
  88. class RegisterEmailRequest(BaseModel):
  89. """邮箱注册请求"""
  90. email: str
  91. username: str = ""
  92. class LoginWechatRequest(BaseModel):
  93. """微信登录请求"""
  94. openid: str
  95. username: str = ""
  96. class LoginQQRequest(BaseModel):
  97. """QQ登录请求"""
  98. openid: str
  99. username: str = ""
  100. class LoginPhoneRequest(BaseModel):
  101. """手机号登录请求"""
  102. phone: str
  103. class LoginEmailRequest(BaseModel):
  104. """邮箱登录请求"""
  105. email: str
  106. class UserUIStateResponse(BaseModel):
  107. """用户前端可视化状态"""
  108. user_id: str
  109. current_page: Optional[str] = None
  110. visited_routes: List[str] = []
  111. has_shown_tap_to_chat: bool = False
  112. has_shown_cancel_tooltip: bool = False
  113. has_shown_send_tooltip: bool = False
  114. has_shown_progress_bar_tooltip: bool = False
  115. avatar_c_unlocked: bool = False
  116. class UpdateUserUIStateRequest(BaseModel):
  117. """部分更新用户前端可视化状态"""
  118. current_page: Optional[str] = None
  119. visited_routes: Optional[List[str]] = None
  120. has_shown_tap_to_chat: Optional[bool] = None
  121. has_shown_cancel_tooltip: Optional[bool] = None
  122. has_shown_send_tooltip: Optional[bool] = None
  123. has_shown_progress_bar_tooltip: Optional[bool] = None
  124. avatar_c_unlocked: Optional[bool] = None
  125. @app.post("/api/users/create")
  126. async def create_user(request: CreateUserRequest):
  127. """
  128. 创建游客用户
  129. Returns:
  130. {
  131. "user_id": "uuid",
  132. "username": "",
  133. "user_type": "guest",
  134. "bot_name": "机器人名字(可选)"
  135. }
  136. """
  137. try:
  138. user = await user_service.create_guest_user(
  139. username=request.username,
  140. bot_name=request.bot_name
  141. )
  142. return {
  143. "success": True,
  144. "data": user
  145. }
  146. except Exception as e:
  147. raise HTTPException(status_code=500, detail=f"创建用户失败: {str(e)}")
  148. @app.get("/api/users/{user_id}")
  149. async def get_user(user_id: str):
  150. """
  151. 获取用户信息
  152. Args:
  153. user_id: 用户ID
  154. Returns:
  155. {
  156. "success": true,
  157. "data": {
  158. "user_id": "uuid",
  159. "username": "",
  160. "user_type": "guest",
  161. "bot_name": "机器人名字(可选)",
  162. ...
  163. }
  164. }
  165. """
  166. try:
  167. user = await user_service.get_user(user_id)
  168. if user:
  169. return {
  170. "success": True,
  171. "data": user
  172. }
  173. else:
  174. raise HTTPException(status_code=404, detail="用户不存在")
  175. except HTTPException:
  176. raise
  177. except Exception as e:
  178. raise HTTPException(status_code=500, detail=f"获取用户信息失败: {str(e)}")
  179. @app.put("/api/users/{user_id}/username")
  180. async def update_username(user_id: str, request: UpdateUsernameRequest):
  181. """
  182. 更新用户名
  183. Args:
  184. user_id: 用户ID
  185. request: 更新请求(包含username)
  186. Returns:
  187. {
  188. "success": true,
  189. "message": "更新成功"
  190. }
  191. """
  192. try:
  193. success = await user_service.update_username(user_id, request.username)
  194. if success:
  195. return {
  196. "success": True,
  197. "message": "用户名更新成功"
  198. }
  199. else:
  200. raise HTTPException(status_code=500, detail="更新用户名失败")
  201. except Exception as e:
  202. raise HTTPException(status_code=500, detail=f"更新用户名失败: {str(e)}")
  203. @app.put("/api/users/{user_id}/bot-name")
  204. async def update_bot_name(user_id: str, request: UpdateBotNameRequest):
  205. """
  206. 更新用户给机器人取的名字
  207. Args:
  208. user_id: 用户ID
  209. request: 更新请求(包含bot_name)
  210. Returns:
  211. {
  212. "success": true,
  213. "message": "更新成功"
  214. }
  215. """
  216. import asyncio
  217. import time
  218. start_time = time.time()
  219. try:
  220. # 🔥 添加超时保护(5秒),避免长时间等待
  221. success = await asyncio.wait_for(
  222. user_service.update_bot_name(user_id, request.bot_name),
  223. timeout=5.0
  224. )
  225. duration = (time.time() - start_time) * 1000
  226. if success:
  227. return {
  228. "success": True,
  229. "message": "机器人名字更新成功"
  230. }
  231. else:
  232. raise HTTPException(
  233. status_code=500,
  234. detail=f"更新机器人名字失败(耗时: {duration:.1f}ms)"
  235. )
  236. except asyncio.TimeoutError:
  237. duration = (time.time() - start_time) * 1000
  238. logger.error(f"❌ 更新机器人名字超时: user_id={user_id}, 耗时={duration:.1f}ms")
  239. raise HTTPException(
  240. status_code=504,
  241. detail=f"更新机器人名字超时(耗时: {duration:.1f}ms),请稍后重试"
  242. )
  243. except HTTPException:
  244. raise
  245. except Exception as e:
  246. duration = (time.time() - start_time) * 1000
  247. logger.error(f"❌ 更新机器人名字异常: user_id={user_id}, error={e}, 耗时={duration:.1f}ms")
  248. raise HTTPException(
  249. status_code=500,
  250. detail=f"更新机器人名字失败: {str(e)}(耗时: {duration:.1f}ms)"
  251. )
  252. @app.post("/api/auth/register/phone")
  253. async def register_with_phone(request: RegisterPhoneRequest):
  254. """
  255. 手机号注册
  256. Returns:
  257. {
  258. "success": true,
  259. "data": {
  260. "user_id": "uuid",
  261. "username": "",
  262. "phone": "",
  263. "user_type": "registered",
  264. ...
  265. }
  266. }
  267. """
  268. try:
  269. user = await user_service.register_with_phone(
  270. phone=request.phone,
  271. username=request.username
  272. )
  273. return {
  274. "success": True,
  275. "data": user
  276. }
  277. except ValueError as e:
  278. raise HTTPException(status_code=400, detail=str(e))
  279. except Exception as e:
  280. raise HTTPException(status_code=500, detail=f"注册失败: {str(e)}")
  281. @app.post("/api/auth/register/email")
  282. async def register_with_email(request: RegisterEmailRequest):
  283. """
  284. 邮箱注册
  285. Returns:
  286. {
  287. "success": true,
  288. "data": {
  289. "user_id": "uuid",
  290. "username": "",
  291. "email": "",
  292. "user_type": "registered",
  293. ...
  294. }
  295. }
  296. """
  297. try:
  298. user = await user_service.register_with_email(
  299. email=request.email,
  300. username=request.username
  301. )
  302. return {
  303. "success": True,
  304. "data": user
  305. }
  306. except ValueError as e:
  307. raise HTTPException(status_code=400, detail=str(e))
  308. except Exception as e:
  309. raise HTTPException(status_code=500, detail=f"注册失败: {str(e)}")
  310. @app.post("/api/auth/login/wechat")
  311. async def login_with_wechat(request: LoginWechatRequest):
  312. """
  313. 微信登录/注册
  314. Returns:
  315. {
  316. "success": true,
  317. "data": {
  318. "user_id": "uuid",
  319. "username": "",
  320. "wechat_openid": "",
  321. "user_type": "registered",
  322. ...
  323. }
  324. }
  325. """
  326. try:
  327. user = await user_service.login_with_wechat(
  328. openid=request.openid,
  329. username=request.username
  330. )
  331. return {
  332. "success": True,
  333. "data": user
  334. }
  335. except Exception as e:
  336. raise HTTPException(status_code=500, detail=f"登录失败: {str(e)}")
  337. @app.post("/api/auth/login/qq")
  338. async def login_with_qq(request: LoginQQRequest):
  339. """
  340. QQ登录/注册
  341. Returns:
  342. {
  343. "success": true,
  344. "data": {
  345. "user_id": "uuid",
  346. "username": "",
  347. "qq_openid": "",
  348. "user_type": "registered",
  349. ...
  350. }
  351. }
  352. """
  353. try:
  354. user = await user_service.login_with_qq(
  355. openid=request.openid,
  356. username=request.username
  357. )
  358. return {
  359. "success": True,
  360. "data": user
  361. }
  362. except Exception as e:
  363. raise HTTPException(status_code=500, detail=f"登录失败: {str(e)}")
  364. @app.post("/api/auth/login/phone")
  365. async def login_with_phone(request: LoginPhoneRequest):
  366. """
  367. 手机号登录
  368. Returns:
  369. {
  370. "success": true,
  371. "data": {
  372. "user_id": "uuid",
  373. "phone": "",
  374. ...
  375. }
  376. }
  377. """
  378. try:
  379. user = await user_service.login_with_phone(request.phone)
  380. if user:
  381. return {
  382. "success": True,
  383. "data": user
  384. }
  385. else:
  386. raise HTTPException(status_code=404, detail="用户不存在,请先注册")
  387. except HTTPException:
  388. raise
  389. except Exception as e:
  390. raise HTTPException(status_code=500, detail=f"登录失败: {str(e)}")
  391. @app.post("/api/auth/login/email")
  392. async def login_with_email(request: LoginEmailRequest):
  393. """
  394. 邮箱登录
  395. Returns:
  396. {
  397. "success": true,
  398. "data": {
  399. "user_id": "uuid",
  400. "email": "",
  401. ...
  402. }
  403. }
  404. """
  405. try:
  406. user = await user_service.login_with_email(request.email)
  407. if user:
  408. return {
  409. "success": True,
  410. "data": user
  411. }
  412. else:
  413. raise HTTPException(status_code=404, detail="用户不存在,请先注册")
  414. except HTTPException:
  415. raise
  416. except Exception as e:
  417. raise HTTPException(status_code=500, detail=f"登录失败: {str(e)}")
  418. @app.get("/api/users/{user_id}/ui-state")
  419. async def get_user_ui_state(user_id: str) -> Dict[str, Any]:
  420. """
  421. 获取用户前端可视化状态(页面、路由、提示状态)
  422. """
  423. try:
  424. state = await user_service.get_user_ui_state(user_id)
  425. if not state:
  426. # 没有记录时返回默认值
  427. default_state = UserUIStateResponse(
  428. user_id=user_id,
  429. current_page=None,
  430. visited_routes=[],
  431. )
  432. return {"success": True, "data": default_state.dict()}
  433. return {"success": True, "data": state}
  434. except Exception as e: # noqa: BLE001
  435. raise HTTPException(status_code=500, detail=f"获取用户UI状态失败: {str(e)}") from e
  436. @app.put("/api/users/{user_id}/ui-state")
  437. async def update_user_ui_state(
  438. user_id: str,
  439. request: UpdateUserUIStateRequest,
  440. ) -> Dict[str, Any]:
  441. """
  442. 更新用户前端可视化状态(部分字段)
  443. """
  444. try:
  445. updated = await user_service.upsert_user_ui_state(
  446. user_id=user_id,
  447. current_page=request.current_page,
  448. visited_routes=request.visited_routes,
  449. has_shown_tap_to_chat=request.has_shown_tap_to_chat,
  450. has_shown_cancel_tooltip=request.has_shown_cancel_tooltip,
  451. has_shown_send_tooltip=request.has_shown_send_tooltip,
  452. has_shown_progress_bar_tooltip=request.has_shown_progress_bar_tooltip,
  453. avatar_c_unlocked=request.avatar_c_unlocked,
  454. )
  455. if not updated:
  456. raise HTTPException(status_code=500, detail="更新用户UI状态失败")
  457. return {"success": True, "data": updated}
  458. except HTTPException:
  459. raise
  460. except Exception as e: # noqa: BLE001
  461. raise HTTPException(status_code=500, detail=f"更新用户UI状态失败: {str(e)}") from e
  462. # ==================== OAuth 授权端点 ====================
  463. @app.get("/api/auth/wechat/authorize")
  464. async def wechat_authorize(request: Request):
  465. """
  466. 获取微信授权URL
  467. 根据微信官方文档:https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
  468. - 使用 qrconnect 端点进行网站应用授权
  469. - scope 必须为 snsapi_login
  470. - redirect_uri 需要 urlEncode 处理
  471. - state 用于防止 CSRF 攻击
  472. Returns:
  473. 重定向到微信授权页面
  474. """
  475. if not settings.WECHAT_OPEN_APP_ID or not settings.WECHAT_REDIRECT_URI:
  476. raise HTTPException(status_code=500, detail="微信OAuth未配置,请检查环境变量 WECHAT_OPEN_APP_ID 和 WECHAT_REDIRECT_URI")
  477. # 生成state参数(用于防止CSRF攻击)
  478. import secrets
  479. state = secrets.token_urlsafe(32)
  480. # 将 state 存储到 Redis(如果可用),用于回调时验证
  481. # TTL 设置为 10 分钟(code 的有效期也是 10 分钟)
  482. redis_cache = getattr(request.app.state, 'redis_cache', None)
  483. if redis_cache:
  484. try:
  485. redis_client = await redis_cache._get_redis()
  486. await redis_client.setex(f"oauth:wechat:state:{state}", 600, "1") # 10分钟过期
  487. except Exception as e:
  488. logger.warning(f"存储 state 到 Redis 失败,将跳过验证: {e}")
  489. # 构建微信授权URL(使用开放平台的AppID)
  490. # redirect_uri 会自动通过 urlencode 进行编码
  491. params = {
  492. "appid": settings.WECHAT_OPEN_APP_ID, # 使用开放平台的AppID
  493. "redirect_uri": settings.WECHAT_REDIRECT_URI, # urlencode 会自动编码
  494. "response_type": "code",
  495. "scope": "snsapi_login", # 网站应用必须使用 snsapi_login
  496. "state": state,
  497. }
  498. # 根据官方文档格式:https://open.weixin.qq.com/connect/qrconnect?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=SCOPE&state=STATE#wechat_redirect
  499. auth_url = f"https://open.weixin.qq.com/connect/qrconnect?{urllib.parse.urlencode(params)}#wechat_redirect"
  500. return RedirectResponse(url=auth_url)
  501. @app.get("/api/auth/wechat/callback")
  502. async def wechat_callback(
  503. request: Request,
  504. code: str = Query(..., description="微信授权码"),
  505. state: str = Query(None, description="状态参数")
  506. ):
  507. """
  508. 微信OAuth回调处理
  509. 根据微信官方文档流程:
  510. 1. 验证 state 参数(防止 CSRF 攻击)
  511. 2. 通过 code 换取 access_token
  512. 3. 使用 openid 进行登录/注册
  513. Args:
  514. code: 微信返回的授权码(有效期10分钟,只能使用一次)
  515. state: 状态参数(用于验证请求合法性)
  516. Returns:
  517. 重定向到前端页面(带token)
  518. """
  519. try:
  520. if not settings.WECHAT_OPEN_APP_ID or not settings.WECHAT_OPEN_APP_SECRET:
  521. raise HTTPException(status_code=500, detail="微信OAuth未配置,请检查环境变量 WECHAT_OPEN_APP_ID 和 WECHAT_OPEN_APP_SECRET")
  522. # 1. 验证 state 参数(防止 CSRF 攻击)
  523. if state:
  524. redis_cache = getattr(request.app.state, 'redis_cache', None)
  525. if redis_cache:
  526. try:
  527. redis_client = await redis_cache._get_redis()
  528. state_key = f"oauth:wechat:state:{state}"
  529. exists = await redis_client.exists(state_key)
  530. if not exists:
  531. logger.warning(f"State 验证失败: {state} (可能已过期或被篡改)")
  532. return RedirectResponse(
  533. url=f"{settings.FRONTEND_URL}/register?error=wechat_auth_failed&reason=invalid_state"
  534. )
  535. # 验证成功后删除 state(一次性使用)
  536. await redis_client.delete(state_key)
  537. except Exception as e:
  538. logger.warning(f"State 验证异常: {e},继续处理")
  539. # 2. 通过code获取access_token(使用开放平台的AppID和AppSecret)
  540. # 根据官方文档:https://api.weixin.qq.com/sns/oauth2/access_token
  541. token_url = "https://api.weixin.qq.com/sns/oauth2/access_token"
  542. token_params = {
  543. "appid": settings.WECHAT_OPEN_APP_ID, # 使用开放平台的AppID
  544. "secret": settings.WECHAT_OPEN_APP_SECRET, # 使用开放平台的AppSecret
  545. "code": code,
  546. "grant_type": "authorization_code",
  547. }
  548. async with httpx.AsyncClient() as client:
  549. token_response = await client.get(token_url, params=token_params)
  550. token_data = token_response.json()
  551. if "errcode" in token_data:
  552. logger.error(f"微信获取access_token失败: {token_data}")
  553. return RedirectResponse(
  554. url=f"{settings.FRONTEND_URL}/register?error=wechat_auth_failed"
  555. )
  556. access_token = token_data.get("access_token")
  557. openid = token_data.get("openid")
  558. # refresh_token 可用于刷新 access_token,当前实现暂不保存
  559. if not access_token or not openid:
  560. logger.error(f"微信返回数据不完整: {token_data}")
  561. return RedirectResponse(
  562. url=f"{settings.FRONTEND_URL}/register?error=wechat_auth_failed"
  563. )
  564. # 3. 使用openid登录/注册
  565. # 注意:当前使用 snsapi_login scope,只能获取 openid
  566. # 如需获取用户昵称、头像等信息,需要:
  567. # - 将 scope 改为 snsapi_login,snsapi_userinfo
  568. # - 调用 /sns/userinfo 接口获取用户信息
  569. user = await user_service.login_with_wechat(openid=openid)
  570. # 4. 重定向到前端,携带用户信息
  571. frontend_url = f"{settings.FRONTEND_URL}/register?oauth=wechat&user_id={user['user_id']}&username={urllib.parse.quote(user['username'])}"
  572. return RedirectResponse(url=frontend_url)
  573. except Exception as e:
  574. logger.error(f"微信OAuth回调处理失败: {e}")
  575. return RedirectResponse(
  576. url=f"{settings.FRONTEND_URL}/register?error=wechat_auth_failed"
  577. )
  578. @app.get("/api/auth/qq/authorize")
  579. async def qq_authorize(request: Request):
  580. """
  581. 获取QQ授权URL
  582. Returns:
  583. 重定向到QQ授权页面
  584. """
  585. if not settings.QQ_APP_ID or not settings.QQ_REDIRECT_URI:
  586. raise HTTPException(status_code=500, detail="QQ OAuth未配置,请检查环境变量")
  587. # 生成state参数(用于防止CSRF攻击)
  588. import secrets
  589. state = secrets.token_urlsafe(32)
  590. # 将 state 存储到 Redis 以便回调校验
  591. redis_cache = getattr(request.app.state, 'redis_cache', None)
  592. if redis_cache:
  593. try:
  594. redis_client = await redis_cache._get_redis()
  595. await redis_client.setex(f"oauth:qq:state:{state}", 600, "1")
  596. except Exception as e:
  597. logger.warning(f"存储 QQ state 失败,将跳过校验: {e}")
  598. # 确保回调地址格式正确(去除末尾斜杠,确保与QQ应用配置完全一致)
  599. redirect_uri = settings.QQ_REDIRECT_URI.rstrip('/')
  600. # 构建QQ授权URL
  601. # 注意:QQ开放平台要求redirect_uri必须与配置的回调域完全匹配
  602. params = {
  603. "response_type": "code",
  604. "client_id": settings.QQ_APP_ID,
  605. "redirect_uri": redirect_uri, # 使用处理后的回调地址
  606. "state": state,
  607. "scope": "get_user_info", # QQ授权范围
  608. }
  609. # URL编码参数(确保特殊字符正确编码)
  610. auth_url = f"https://graph.qq.com/oauth2.0/authorize?{urllib.parse.urlencode(params)}"
  611. # 调试日志:记录实际发送的授权URL(隐藏敏感信息)
  612. logger.info(f"🔍 QQ授权URL生成 - AppID: {settings.QQ_APP_ID[:8]}..., 回调地址: {redirect_uri}")
  613. return RedirectResponse(url=auth_url)
  614. @app.get("/api/qq/mp/verify")
  615. async def qq_callback(
  616. request: Request,
  617. code: str = Query(..., description="QQ授权码"),
  618. state: str = Query(None, description="状态参数")
  619. ):
  620. """
  621. QQ OAuth回调处理
  622. Args:
  623. code: QQ返回的授权码
  624. state: 状态参数
  625. Returns:
  626. 重定向到前端页面(带token)
  627. """
  628. try:
  629. # 0. 验证 state 防止 CSRF
  630. if state:
  631. redis_cache = getattr(request.app.state, 'redis_cache', None)
  632. if redis_cache:
  633. try:
  634. redis_client = await redis_cache._get_redis()
  635. state_key = f"oauth:qq:state:{state}"
  636. exists = await redis_client.exists(state_key)
  637. if not exists:
  638. logger.warning(f"QQ state 验证失败: {state}")
  639. return RedirectResponse(
  640. url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed&reason=invalid_state"
  641. )
  642. await redis_client.delete(state_key)
  643. except Exception as state_error:
  644. logger.warning(f"QQ state 验证异常: {state_error},继续流程")
  645. if not settings.QQ_APP_ID or not settings.QQ_APP_KEY:
  646. raise HTTPException(status_code=500, detail="QQ OAuth未配置")
  647. # 小工具:解析 QQ JSONP 或 querystring 响应
  648. def parse_qq_payload(payload_text: str) -> dict:
  649. cleaned = (payload_text or "").strip()
  650. if not cleaned:
  651. return {}
  652. if cleaned.startswith("callback("):
  653. cleaned = cleaned.split("callback(", 1)[1]
  654. cleaned = cleaned.rsplit(");", 1)[0]
  655. try:
  656. return json.loads(cleaned)
  657. except json.JSONDecodeError:
  658. parsed = {}
  659. for part in cleaned.split("&"):
  660. if "=" in part:
  661. key, value = part.split("=", 1)
  662. parsed[key] = value
  663. return parsed
  664. # 1. 通过code获取access_token
  665. # 确保回调地址格式正确(与授权时保持一致)
  666. redirect_uri = settings.QQ_REDIRECT_URI.rstrip('/')
  667. token_url = "https://graph.qq.com/oauth2.0/token"
  668. token_params = {
  669. "grant_type": "authorization_code",
  670. "client_id": settings.QQ_APP_ID,
  671. "client_secret": settings.QQ_APP_KEY,
  672. "code": code,
  673. "redirect_uri": redirect_uri, # 使用处理后的回调地址
  674. "fmt": "json",
  675. }
  676. logger.info(f"🔍 QQ获取token - 回调地址: {redirect_uri}")
  677. async with httpx.AsyncClient() as client:
  678. token_response = await client.get(token_url, params=token_params)
  679. token_text = token_response.text
  680. try:
  681. token_data = token_response.json()
  682. except ValueError:
  683. token_data = parse_qq_payload(token_text)
  684. if "error" in token_data:
  685. logger.error(f"QQ获取access_token失败: {token_data}")
  686. return RedirectResponse(
  687. url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed&reason=token"
  688. )
  689. access_token = token_data.get("access_token")
  690. if not access_token:
  691. logger.error(f"QQ返回数据不完整: {token_data}")
  692. return RedirectResponse(
  693. url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed"
  694. )
  695. # 2. 通过access_token获取openid
  696. openid_url = "https://graph.qq.com/oauth2.0/me"
  697. openid_params = {
  698. "access_token": access_token,
  699. "unionid": 1,
  700. "fmt": "json",
  701. }
  702. async with httpx.AsyncClient() as client:
  703. openid_response = await client.get(openid_url, params=openid_params)
  704. openid_data = parse_qq_payload(openid_response.text)
  705. if "error" in openid_data:
  706. logger.error(f"QQ获取openid失败: {openid_data}")
  707. return RedirectResponse(
  708. url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed"
  709. )
  710. openid = openid_data.get("openid")
  711. unionid = openid_data.get("unionid")
  712. if not openid:
  713. logger.error(f"QQ返回openid为空: {openid_data}")
  714. return RedirectResponse(
  715. url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed"
  716. )
  717. # 2.1 可选:拉取用户信息用于填充昵称
  718. nickname = ""
  719. user_info = {}
  720. user_info_url = "https://graph.qq.com/user/get_user_info"
  721. user_info_params = {
  722. "access_token": access_token,
  723. "oauth_consumer_key": settings.QQ_APP_ID,
  724. "openid": openid,
  725. "fmt": "json",
  726. }
  727. try:
  728. async with httpx.AsyncClient() as client:
  729. user_info_resp = await client.get(
  730. user_info_url,
  731. params=user_info_params,
  732. )
  733. user_info = user_info_resp.json()
  734. if user_info.get("ret") == 0:
  735. nickname = user_info.get("nickname", "") or ""
  736. else:
  737. logger.warning(
  738. f"QQ用户信息获取失败: {user_info}"
  739. )
  740. except Exception as info_error:
  741. logger.warning(f"拉取QQ用户信息异常: {info_error}")
  742. # 3. 使用openid登录/注册
  743. user = await user_service.login_with_qq(
  744. openid=openid,
  745. username=nickname,
  746. unionid=unionid,
  747. )
  748. # 4. 重定向到前端,携带用户信息
  749. frontend_url = f"{settings.FRONTEND_URL}/register?oauth=qq&user_id={user['user_id']}&username={urllib.parse.quote(user['username'])}"
  750. return RedirectResponse(url=frontend_url)
  751. except Exception as e:
  752. logger.error(f"QQ OAuth回调处理失败: {e}")
  753. return RedirectResponse(
  754. url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed"
  755. )
  756. @app.get("/api/wechat/jsapi/config")
  757. async def get_wechat_jsapi_config(
  758. url: str = Query(..., description="当前页面URL(用于生成签名)")
  759. ):
  760. """
  761. 获取微信JS-SDK配置(包含签名)
  762. Args:
  763. url: 当前页面完整URL(前端传入,用于生成签名)
  764. Returns:
  765. {
  766. "success": true,
  767. "data": {
  768. "appId": "微信AppID",
  769. "timestamp": 1234567890,
  770. "nonceStr": "随机字符串",
  771. "signature": "SHA1签名"
  772. }
  773. }
  774. """
  775. try:
  776. if not settings.WECHAT_MP_APP_ID:
  777. logger.error("微信公众平台AppID未配置,请检查环境变量 WECHAT_MP_APP_ID")
  778. raise HTTPException(status_code=500, detail="微信公众平台AppID未配置,请检查环境变量 WECHAT_MP_APP_ID")
  779. # 记录接收到的URL(用于调试)
  780. logger.info(f"📥 收到微信JS-SDK配置请求: url={url[:100]}...")
  781. # 获取Redis客户端(用于缓存jsapi_ticket)
  782. from database import db_manager
  783. redis_getter = db_manager.get_redis
  784. # 创建微信JS-SDK服务
  785. jsapi_service = WechatJSAPIService(redis_getter=redis_getter)
  786. # 获取配置
  787. config = await jsapi_service.get_jsapi_config(url)
  788. if not config:
  789. logger.error("获取微信JS-SDK配置失败(返回None)")
  790. raise HTTPException(status_code=500, detail="获取微信JS-SDK配置失败")
  791. logger.info(f"✅ 微信JS-SDK配置生成成功: appId={config.get('appId', 'N/A')[:10]}..., timestamp={config.get('timestamp')}")
  792. return {
  793. "success": True,
  794. "data": config
  795. }
  796. except HTTPException:
  797. raise
  798. except Exception as e:
  799. logger.error(f"获取微信JS-SDK配置异常: {e}", exc_info=True)
  800. raise HTTPException(status_code=500, detail=f"获取配置失败: {str(e)}")
  801. @app.get("/api/qq/share/config")
  802. async def get_qq_share_config():
  803. """
  804. 获取QQ分享配置(包含AppID和应用名称)
  805. Returns:
  806. {
  807. "success": true,
  808. "data": {
  809. "appId": "QQ AppID",
  810. "appName": "应用名称"
  811. }
  812. }
  813. """
  814. try:
  815. if not settings.QQ_APP_ID:
  816. logger.warning("QQ AppID未配置,返回空配置")
  817. return {
  818. "success": True,
  819. "data": {
  820. "appId": "",
  821. "appName": settings.QQ_APP_NAME
  822. }
  823. }
  824. logger.info(f"📥 收到QQ分享配置请求: appId={settings.QQ_APP_ID[:8]}..., appName={settings.QQ_APP_NAME}")
  825. return {
  826. "success": True,
  827. "data": {
  828. "appId": settings.QQ_APP_ID,
  829. "appName": settings.QQ_APP_NAME
  830. }
  831. }
  832. except Exception as e:
  833. logger.error(f"获取QQ分享配置异常: {e}", exc_info=True)
  834. raise HTTPException(status_code=500, detail=f"获取配置失败: {str(e)}")
  835. # ==================== 微信公众平台服务器配置 ====================
  836. @app.get("/api/wechat/mp/verify")
  837. async def wechat_mp_verify(
  838. signature: str = Query(..., description="微信加密签名"),
  839. timestamp: str = Query(..., description="时间戳"),
  840. nonce: str = Query(..., description="随机数"),
  841. echostr: str = Query(..., description="随机字符串")
  842. ):
  843. """
  844. 微信公众平台服务器验证接口(GET)
  845. 用于微信公众平台配置服务器URL时的验证
  846. Args:
  847. signature: 微信加密签名
  848. timestamp: 时间戳
  849. nonce: 随机数
  850. echostr: 随机字符串
  851. Returns:
  852. 验证成功返回echostr,失败返回空字符串
  853. """
  854. if not settings.WECHAT_MP_TOKEN:
  855. logger.error("微信公众平台Token未配置")
  856. return PlainTextResponse("", status_code=500)
  857. try:
  858. # 1. 将token、timestamp、nonce按字典序排序
  859. token = settings.WECHAT_MP_TOKEN
  860. tmp_arr = [token, timestamp, nonce]
  861. tmp_arr.sort()
  862. # 2. 拼接字符串并SHA1加密
  863. tmp_str = ''.join(tmp_arr)
  864. tmp_str = hashlib.sha1(tmp_str.encode('utf-8')).hexdigest()
  865. # 3. 与signature比较
  866. if tmp_str == signature:
  867. logger.info("✅ 微信公众平台服务器验证成功")
  868. return PlainTextResponse(echostr)
  869. else:
  870. logger.warning(f"⚠️ 微信公众平台服务器验证失败: signature不匹配")
  871. return PlainTextResponse("", status_code=403)
  872. except Exception as e:
  873. logger.error(f"❌ 微信公众平台服务器验证异常: {e}")
  874. return PlainTextResponse("", status_code=500)
  875. @app.post("/api/wechat/mp/message")
  876. async def wechat_mp_message(request: Request):
  877. """
  878. 微信公众平台消息推送接口(POST)
  879. 接收微信公众平台推送的消息(文本、事件等)
  880. Returns:
  881. XML格式的响应消息
  882. """
  883. if not settings.WECHAT_MP_TOKEN:
  884. logger.error("微信公众平台Token未配置")
  885. return PlainTextResponse("", status_code=500)
  886. try:
  887. # 1. 获取请求参数(用于验证签名)
  888. signature = request.query_params.get("signature", "")
  889. timestamp = request.query_params.get("timestamp", "")
  890. nonce = request.query_params.get("nonce", "")
  891. # 2. 验证签名
  892. token = settings.WECHAT_MP_TOKEN
  893. tmp_arr = [token, timestamp, nonce]
  894. tmp_arr.sort()
  895. tmp_str = ''.join(tmp_arr)
  896. tmp_str = hashlib.sha1(tmp_str.encode('utf-8')).hexdigest()
  897. if tmp_str != signature:
  898. logger.warning(f"⚠️ 微信公众平台消息签名验证失败")
  899. return PlainTextResponse("", status_code=403)
  900. # 3. 读取XML消息体
  901. body = await request.body()
  902. xml_data = body.decode('utf-8')
  903. # 4. 解析XML
  904. root = ET.fromstring(xml_data)
  905. msg_type = root.find('MsgType').text if root.find('MsgType') is not None else ''
  906. from_user = root.find('FromUserName').text if root.find('FromUserName') is not None else ''
  907. to_user = root.find('ToUserName').text if root.find('ToUserName') is not None else ''
  908. logger.info(f"📨 收到微信公众平台消息: type={msg_type}, from={from_user}")
  909. # 5. 处理不同类型的消息
  910. if msg_type == 'text':
  911. # 文本消息
  912. content = root.find('Content').text if root.find('Content') is not None else ''
  913. logger.info(f"📝 文本消息内容: {content}")
  914. # 回复文本消息(示例:自动回复)
  915. reply_content = "感谢您的消息!"
  916. reply_xml = f"""<xml>
  917. <ToUserName><![CDATA[{from_user}]]></ToUserName>
  918. <FromUserName><![CDATA[{to_user}]]></FromUserName>
  919. <CreateTime>{int(__import__('time').time())}</CreateTime>
  920. <MsgType><![CDATA[text]]></MsgType>
  921. <Content><![CDATA[{reply_content}]]></Content>
  922. </xml>"""
  923. return PlainTextResponse(reply_xml, media_type="application/xml")
  924. elif msg_type == 'event':
  925. # 事件消息
  926. event = root.find('Event').text if root.find('Event') is not None else ''
  927. logger.info(f"🎯 事件消息: {event}")
  928. # 处理关注/取消关注事件
  929. if event == 'subscribe':
  930. reply_content = "欢迎关注!"
  931. elif event == 'unsubscribe':
  932. reply_content = "感谢您的关注!"
  933. else:
  934. reply_content = "收到事件消息"
  935. reply_xml = f"""<xml>
  936. <ToUserName><![CDATA[{from_user}]]></ToUserName>
  937. <FromUserName><![CDATA[{to_user}]]></FromUserName>
  938. <CreateTime>{int(__import__('time').time())}</CreateTime>
  939. <MsgType><![CDATA[text]]></MsgType>
  940. <Content><![CDATA[{reply_content}]]></Content>
  941. </xml>"""
  942. return PlainTextResponse(reply_xml, media_type="application/xml")
  943. else:
  944. # 其他类型消息,返回空响应
  945. logger.info(f"ℹ️ 未处理的消息类型: {msg_type}")
  946. return PlainTextResponse("success")
  947. except Exception as e:
  948. logger.error(f"❌ 处理微信公众平台消息异常: {e}")
  949. return PlainTextResponse("success") # 返回success避免微信重试
  950. @app.websocket("/ws")
  951. async def websocket_endpoint(websocket: WebSocket):
  952. """WebSocket端点"""
  953. # 从查询参数获取user_id(Lyrria_user_id)
  954. user_id = websocket.query_params.get("user_id")
  955. # 记录连接信息
  956. import logging
  957. logger = logging.getLogger(__name__)
  958. logger.info(f"🔌 WebSocket连接: user_id={user_id}, query_params={dict(websocket.query_params)}")
  959. # 🔥 传入Redis缓存服务和user_id
  960. redis_cache = getattr(app.state, 'redis_cache', None)
  961. handler = WebSocketHandler(websocket, user_id=user_id, redis_cache=redis_cache)
  962. await handler.handle_connection()
  963. if __name__ == "__main__":
  964. import uvicorn
  965. import os
  966. import socket
  967. from pathlib import Path
  968. # 🔥 DNS预解析(减少首次连接延迟)
  969. dashscope_domain = get_dashscope_domain()
  970. try:
  971. socket.getaddrinfo(dashscope_domain, 443)
  972. except Exception as e:
  973. pass
  974. # SSL证书路径配置
  975. # 优先使用环境变量配置的证书路径(生产环境)
  976. ssl_keyfile = settings.SSL_KEYFILE
  977. ssl_certfile = settings.SSL_CERTFILE
  978. # 如果环境变量未配置,使用本地开发证书
  979. if not ssl_keyfile or not ssl_certfile:
  980. ssl_dir = Path(__file__).parent.parent / "ssl"
  981. ssl_keyfile = str(ssl_dir / "key.pem")
  982. ssl_certfile = str(ssl_dir / "cert.pem")
  983. # 检查证书是否存在
  984. if ssl_keyfile and ssl_certfile:
  985. if not os.path.exists(ssl_keyfile) or not os.path.exists(ssl_certfile):
  986. ssl_keyfile = None
  987. ssl_certfile = None
  988. uvicorn.run(
  989. "main:app",
  990. host=settings.host,
  991. port=settings.port,
  992. reload=settings.DEBUG,
  993. ssl_keyfile=ssl_keyfile,
  994. ssl_certfile=ssl_certfile,
  995. )