| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811 |
- """
- 主应用入口
- """
- from __future__ import annotations
- import json
- import logging
- import urllib.parse
- import hashlib
- import uuid
- import random
- import base64
- from datetime import datetime, timedelta
- from typing import Optional, List, Dict, Any
- from fastapi import FastAPI, WebSocket, HTTPException, Request, Query, Header, Depends
- from fastapi.exceptions import RequestValidationError
- from fastapi.responses import RedirectResponse, PlainTextResponse, JSONResponse
- from fastapi.middleware.cors import CORSMiddleware
- from starlette.middleware.base import BaseHTTPMiddleware
- from starlette.requests import Request as StarletteRequest
- from starlette.responses import Response
- from pydantic import BaseModel, Field, field_validator, ValidationError as PydanticValidationError
- from contextlib import asynccontextmanager
- import httpx
- from xml.etree import ElementTree as ET
- from sqlalchemy import text, select, update
- from sqlalchemy.orm import selectinload
- from config import settings, get_dashscope_domain
- from websocket_handler import WebSocketHandler
- from database import init_database, close_database, db_manager
- from services.redis_cache_service import RedisCacheService
- from services.user_service import user_service, AuthError
- from ai_service import ai_service
- from error_codes import (
- UNAUTHORIZED_USER_MISMATCH,
- SESSION_NOT_INITIALIZED,
- SESSION_EXPIRED,
- USER_NOT_FOUND,
- USER_NOT_FOUND_REGISTER_FIRST,
- CREATE_USER_FAILED,
- INIT_SESSION_FAILED,
- GET_USER_INFO_FAILED,
- UPDATE_USERNAME_FAILED,
- USER_NOT_FOUND_OR_UPDATE_FAILED,
- UPDATE_BG_IMAGE_FAILED,
- UPDATE_BOT_NAME_FAILED,
- UPDATE_BOT_NAME_TIMEOUT,
- GET_ENERGY_STONES_FAILED,
- GET_AVATARS_FAILED,
- GET_BG_CONFIG_FAILED,
- GET_USER_BG_IMAGES_FAILED,
- CLAIM_SHARE_REWARD_FAILED,
- FEEDBACK_CONTENT_EMPTY,
- SUBMIT_FEEDBACK_FAILED,
- REGISTER_FAILED,
- LOGIN_FAILED,
- PHONE_REQUIRED,
- EMAIL_REQUIRED,
- SEND_TYPE_INVALID,
- SEND_CODE_FAILED,
- CODE_REQUIRED,
- VERIFY_CODE_FAILED,
- TYPE_MUST_BE_EMAIL_OR_PHONE,
- GET_UI_STATE_FAILED,
- UPDATE_UI_STATE_FAILED,
- WECHAT_OAUTH_NOT_CONFIGURED,
- QQ_OAUTH_NOT_CONFIGURED,
- WECHAT_MP_NOT_CONFIGURED,
- WECHAT_JSAPI_CONFIG_FAILED,
- GET_CONFIG_FAILED,
- SERVER_ERROR,
- )
- from services.wechat_jsapi_service import WechatJSAPIService
- from services.energy_stone_service import energy_stone_service
- from models_db import VerificationCode, UserAvatar, UserBackgroundImage, Feedback, BackgroundImage
- from session_store import SessionStore
- # 配置日志
- logging.basicConfig(
- level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO),
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
- datefmt='%Y-%m-%d %H:%M:%S'
- )
- # 🔇 过滤第三方库的 DEBUG 日志(避免刷屏)
- # 这些库的 DEBUG 日志包含 HTTP/2 协议细节、HPACK 压缩等底层信息,对生产环境无意义
- third_party_loggers = [
- 'openai',
- 'openai._base_client',
- 'httpcore',
- 'httpcore.http2',
- 'httpx',
- 'hpack',
- 'hpack.hpack',
- 'hpack.table',
- ]
- for logger_name in third_party_loggers:
- logging.getLogger(logger_name).setLevel(logging.INFO)
- logger = logging.getLogger(__name__)
- @asynccontextmanager
- async def lifespan(app: FastAPI):
- """应用生命周期管理"""
- # 启动时初始化数据库(包含MySQL和Redis,自动启动健康检查任务)
- try:
- await init_database()
-
- # 获取Redis客户端(已配置健康检查),用于验证连通性
- from database import db_manager
- await db_manager.get_redis()
-
- # 创建Redis缓存服务,按需获取最新连接
- app.state.redis_cache = RedisCacheService(db_manager.get_redis)
-
- except Exception as e:
- logger.error(f"❌ 数据库初始化失败: {e}")
- app.state.redis_cache = None
-
- # 🔥 启动HTTP连接保活任务(保持连接活跃,避免长时间不交互后响应变慢)
- try:
- from http_pool import http_pool
- from ai_service import AIService
-
- # 创建AIService实例用于保活(共享连接池)
- ai_service = AIService()
- http_pool.start_keepalive(ai_service=ai_service)
- except Exception as e:
- logger.warning(f"⚠️ HTTP连接保活任务启动失败: {e}")
-
- yield
-
- # 关闭时清理资源
- await close_database()
-
- # 🔥 关闭HTTP连接池(类似SQLAlchemy的Engine.dispose())
- from http_pool import http_pool
- http_pool.close()
- app = FastAPI(
- title="Lyrria Backend API",
- lifespan=lifespan
- )
- # 全局会话存储(单用户单活 token),使用 Redis 持久化
- from database import db_manager # 延迟导入以避免循环依赖问题
- app.state.session_store = SessionStore(
- redis_getter=db_manager.get_redis,
- default_ttl_seconds=settings.SESSION_TOKEN_TTL_SECONDS,
- )
- class APIKeyMiddleware(BaseHTTPMiddleware):
- """API Key验证中间件"""
-
- # 不需要验证的路径(健康检查、WebSocket、微信/QQ回调等)
- EXEMPT_PATHS = [
- "/",
- "/docs",
- "/openapi.json",
- "/redoc",
- "/ws",
- # 微信 OAuth & 公众号回调
- "/api/auth/wechat/authorize",
- "/api/auth/wechat/callback",
- "/api/wechat/mp/verify",
- "/api/wechat/mp/message",
- # QQ OAuth & 回调
- "/api/auth/qq/authorize",
- "/api/qq/mp/verify",
- # QQ 分享配置(SDK 拉取)
- "/api/qq/share/config",
- ]
-
- async def dispatch(self, request: StarletteRequest, call_next):
- # 检查是否为豁免路径
- path = request.url.path
- if any(path.startswith(exempt) for exempt in self.EXEMPT_PATHS):
- return await call_next(request)
-
- # 如果未启用API Key验证,直接通过
- if not settings.API_KEY_ENABLED:
- return await call_next(request)
-
- # 检查API Key
- api_key = request.headers.get("X-Client-Key", "")
- expected_key = settings.API_CLIENT_KEY
-
- if not expected_key:
- logger.warning("⚠️ API_CLIENT_KEY未配置,跳过验证")
- return await call_next(request)
-
- if api_key != expected_key:
- logger.warning(f"❌ API Key验证失败: path={path}, provided_key={'***' if api_key else '(empty)'}")
- return Response(
- content='{"detail":"Invalid API Key"}',
- status_code=403,
- media_type="application/json"
- )
-
- return await call_next(request)
- # 配置CORS - 使用配置中的允许来源
- cors_origins = settings.CORS_ORIGINS if not settings.CORS_ALLOW_ALL else ["*"]
- app.add_middleware(
- CORSMiddleware,
- allow_origins=cors_origins,
- allow_credentials=True,
- allow_methods=["*"],
- allow_headers=["*"],
- )
- # 添加API Key验证中间件(在CORS之后)
- app.add_middleware(APIKeyMiddleware)
- @app.exception_handler(RequestValidationError)
- async def validation_exception_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
- """422 时在服务端打印校验错误,便于排查(如 user_events 缺失字段)"""
- errors = exc.errors()
- for err in errors:
- logger.warning("422 校验失败: loc=%s msg=%s type=%s", err.get("loc"), err.get("msg"), err.get("type"))
- return JSONResponse(status_code=422, content={"detail": errors})
- async def require_user_session_from_headers(
- request: Request,
- header_user_id: Optional[str] = Header(None, alias="X-User-Id"),
- token: Optional[str] = Header(None, alias="X-User-Token"),
- ) -> str:
- """仅根据 X-User-Id + X-User-Token 校验会话,返回 user_id(用于无 path user_id 的接口如 POST /api/user-events)"""
- if not header_user_id or not token:
- logger.warning(
- "require_user_session_from_headers: missing headers - path=%s",
- request.url.path,
- )
- raise HTTPException(status_code=401, detail={"code": SESSION_EXPIRED})
- session_store = getattr(request.app.state, "session_store", None)
- if not session_store:
- logger.error(
- "require_user_session_from_headers: SESSION_NOT_INITIALIZED - path=%s",
- request.url.path,
- )
- raise HTTPException(status_code=500, detail={"code": SESSION_NOT_INITIALIZED})
- is_valid = await session_store.validate(header_user_id, token, refresh=True)
- if not is_valid:
- logger.warning(
- "require_user_session_from_headers: SESSION_EXPIRED - path=%s, user_id=%s",
- request.url.path,
- header_user_id,
- )
- raise HTTPException(status_code=401, detail={"code": SESSION_EXPIRED})
- return header_user_id
- async def require_user_session(
- request: Request,
- user_id: str,
- header_user_id: Optional[str] = Header(None, alias="X-User-Id"),
- token: Optional[str] = Header(None, alias="X-User-Token"),
- ) -> None:
- """
- 基于 user_id + token 的简单 HTTP 鉴权,复用 WebSocket 的 session_store。
- 仅用于需要用户身份的业务接口,保持单用户单活语义。
- """
- if not header_user_id or header_user_id != user_id:
- logger.warning(
- "require_user_session: UNAUTHORIZED_USER_MISMATCH - path=%s, param_user_id=%s, header_user_id=%s",
- request.url.path,
- user_id,
- header_user_id,
- )
- raise HTTPException(status_code=401, detail={"code": UNAUTHORIZED_USER_MISMATCH})
- session_store = getattr(request.app.state, "session_store", None)
- if not session_store:
- logger.error(
- "require_user_session: SESSION_NOT_INITIALIZED - path=%s, user_id=%s",
- request.url.path,
- header_user_id,
- )
- raise HTTPException(status_code=500, detail={"code": SESSION_NOT_INITIALIZED})
- if not token:
- logger.warning(
- "require_user_session: missing X-User-Token header - path=%s, user_id=%s",
- request.url.path,
- header_user_id,
- )
- raise HTTPException(status_code=401, detail={"code": SESSION_EXPIRED})
- is_valid = await session_store.validate(user_id, token, refresh=True)
- if not is_valid:
- try:
- entry = await session_store.get_current_token(user_id)
- except Exception as diag_exc: # noqa: BLE001
- logger.warning(
- "require_user_session: validate failed and get_current_token raised - path=%s, user_id=%s, token_prefix=%s, error=%r",
- request.url.path,
- user_id,
- str(token)[:8],
- diag_exc,
- )
- raise HTTPException(status_code=401, detail={"code": SESSION_EXPIRED})
- if entry is None:
- logger.warning(
- "require_user_session: SESSION_EXPIRED - no active session entry - path=%s, user_id=%s, token_prefix=%s",
- request.url.path,
- user_id,
- str(token)[:8],
- )
- else:
- try:
- expires_iso = datetime.fromtimestamp(entry.expires_at).isoformat()
- except Exception:
- expires_iso = str(entry.expires_at)
- logger.warning(
- "require_user_session: SESSION_EXPIRED - token mismatch or expired - path=%s, user_id=%s, header_token_prefix=%s, stored_token_prefix=%s, stored_expires_at=%s, now=%s",
- request.url.path,
- user_id,
- str(token)[:8],
- str(entry.token)[:8],
- expires_iso,
- datetime.utcnow().isoformat(),
- )
- raise HTTPException(status_code=401, detail={"code": SESSION_EXPIRED})
- async def optional_user_id(
- request: Request,
- header_user_id: Optional[str] = Header(None, alias="X-User-Id"),
- token: Optional[str] = Header(None, alias="X-User-Token"),
- ) -> Optional[str]:
- """可选鉴权:有且有效则返回 user_id,否则返回 None(用于页面访问等不强制登录的接口)"""
- if not header_user_id or not token:
- return None
- session_store = getattr(request.app.state, "session_store", None)
- if not session_store or not await session_store.validate(header_user_id, token, refresh=False):
- return None
- return header_user_id
- @app.get("/")
- async def root():
- """健康检查"""
- return {"status": "ok", "message": "Lyrria Backend API"}
- @app.post("/api/user-events")
- async def create_user_event(
- http_request: Request,
- auth_user_id: str = Depends(require_user_session_from_headers),
- ) -> Dict[str, Any]:
- """
- 前端上报单条用户事件
- - 主要用于页面进入、按钮点击等埋点
- - event_name 必须遵循 .cursorrules 中的“埋点事件命名规范”
- - 从 body 手动读取并校验,避免与依赖中的 Request 冲突导致 body 被当成 query
- """
- try:
- body = await http_request.json()
- except Exception as e:
- logger.warning("user_events 读取 body 失败: %s", e)
- raise HTTPException(status_code=422, detail=[{"type": "value_error", "loc": ("body",), "msg": "Invalid JSON body"}])
- try:
- payload = UserEventPayload.model_validate(body)
- except PydanticValidationError as val_err:
- logger.warning("user_events 校验失败: %s", val_err.errors())
- raise RequestValidationError(val_err.errors()) from val_err
- if payload.user_id != auth_user_id:
- raise HTTPException(status_code=401, detail={"code": UNAUTHORIZED_USER_MISMATCH})
- try:
- ok = await user_service.save_user_event(payload.model_dump())
- if not ok:
- logger.warning("user_events 落库返回 False: event_id=%s, event_name=%s", payload.event_id, payload.event_name)
- raise HTTPException(status_code=500, detail="failed_to_save_user_event")
- logger.info("user_events 已写入: event_name=%s, user_id=%s", payload.event_name, payload.user_id)
- return {"success": True}
- except HTTPException:
- raise
- except Exception as e: # noqa: BLE001
- logger.error(f"❌ 保存用户事件失败: event_id={payload.event_id}, error={e}")
- raise HTTPException(status_code=500, detail="failed_to_save_user_event") from e
- class CreateUserRequest(BaseModel):
- """创建用户请求"""
- username: str = Field("", description="用户名/昵称,可为空字符串")
- bot_name: Optional[str] = Field(None, description="机器人名字,可选")
- user_segment: str = Field("individual", description="用户分群:individual/school/internal等")
- org_id: Optional[str] = Field(None, description="组织ID(学校/机构用户)")
- is_test_user: bool = Field(False, description="是否测试账号")
- region: Optional[str] = Field(None, description="国家/地区代码,如 CN/US")
- class UpdateUsernameRequest(BaseModel):
- """更新用户名请求"""
- username: str
- class UpdateBotNameRequest(BaseModel):
- """更新机器人名字请求"""
- bot_name: str
- class UpdateBackgroundImageIdRequest(BaseModel):
- """更新背景图片ID请求"""
- background_image_id: str
- class UserEventPayload(BaseModel):
- """前端上报用户事件 payload(单条)"""
- event_id: str = Field(..., description="事件ID,客户端或服务端生成,需全局唯一")
- user_id: str = Field(..., description="用户ID(关联 users.user_id)")
- event_name: str = Field(..., description="事件代码,如 page.chatbot__enter(遵循埋点命名规范)")
- event_label: Optional[str] = Field(None, description="事件中文名称/描述")
- event_time: datetime = Field(..., description="事件发生时间(UTC)")
- session_id: Optional[str] = Field(None, description="会话ID,同一次前台停留共享")
- platform: str = Field(..., description="平台:ios/android/web")
- app_version: Optional[str] = Field(None, description="应用版本号,如 v0.1.x")
- timezone_offset_minutes: Optional[int] = Field(
- None,
- description="用户本地相对UTC的分钟偏移量(分钟)",
- )
- properties: Optional[Dict[str, Any]] = Field(
- None,
- description="扩展属性(page、entry、button_id、duration_seconds 等)",
- )
- @field_validator("event_time", mode="before")
- @classmethod
- def parse_event_time(cls, v: Any) -> datetime:
- """接受 ISO 字符串(含 Z 后缀),避免 422"""
- if isinstance(v, datetime):
- return v
- if isinstance(v, str):
- s = v.strip().replace("Z", "+00:00")
- return datetime.fromisoformat(s)
- raise ValueError("event_time 需为 datetime 或 ISO 字符串")
- class RegisterPhoneRequest(BaseModel):
- """手机号注册请求"""
- area_code: Optional[str] = None
- phone: str
- username: str = ""
- user_id: Optional[str] = None # 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
- preferred_language: Optional[str] = None # "zh" | "en"
- class RegisterEmailRequest(BaseModel):
- """邮箱注册请求"""
- email: str
- username: str = ""
- user_id: Optional[str] = None # 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
- preferred_language: Optional[str] = None # "zh" | "en"
- class LoginWechatRequest(BaseModel):
- """微信登录请求"""
- openid: str
- username: str = ""
- class LoginQQRequest(BaseModel):
- """QQ登录请求"""
- openid: str
- username: str = ""
- class LoginPhoneRequest(BaseModel):
- """手机号登录请求"""
- area_code: Optional[str] = None
- phone: str
- preferred_language: Optional[str] = None # "zh" | "en"
- class LoginEmailRequest(BaseModel):
- """邮箱登录请求"""
- email: str
- preferred_language: Optional[str] = None # "zh" | "en"
- class SendCodeRequest(BaseModel):
- """发送验证码请求"""
- area_code: Optional[str] = None # 手机区号(如 +86)
- phone: Optional[str] = None
- email: Optional[str] = None
- send_type: str # "domestic_sms", "international_sms", "email"
- class VerifyCodeRequest(BaseModel):
- """验证验证码请求"""
- phone: Optional[str] = None
- email: Optional[str] = None
- code: str
- send_type: str # "domestic_sms", "international_sms", "email"
- class VerifyAndAuthRequest(BaseModel):
- """V2.0 验证码通过后统一登录/注册请求"""
- type: str # "email" | "phone"
- code: str
- email: Optional[str] = None
- phone: Optional[str] = None
- area_code: Optional[str] = None
- preferred_language: Optional[str] = None # "zh" | "en"
- class CommunityChoiceRequest(BaseModel):
- """V2.0 退出早期共创社区选项"""
- opted_out: bool
- class EarlyCommunityRequest(BaseModel):
- """V2.0 早期共创邮箱(可选)"""
- email: Optional[str] = None
- class UserUIStateResponse(BaseModel):
- """用户前端可视化状态"""
- user_id: str
- current_page: Optional[str] = None
- visited_routes: List[str] = []
- has_shown_tap_to_chat: bool = False
- has_shown_cancel_tooltip: bool = False
- has_shown_send_tooltip: bool = False
- has_shown_progress_bar_tooltip: bool = False
- has_shown_mission_stone_modal: bool = False
- has_shown_conversation_stone_modal: bool = False
- has_clicked_mission_stone: bool = False
- has_clicked_conversation_stone: bool = False
- subtitle_on: bool = False
- avatar_c_unlocked: bool = False
- has_shared_avatar: bool = False
- current_avatar_id: Optional[str] = None
- class PageVisitRequest(BaseModel):
- """页面访问上报"""
- path: str
- class UpdateUserUIStateRequest(BaseModel):
- """部分更新用户前端可视化状态"""
- current_page: Optional[str] = None
- visited_routes: Optional[List[str]] = None
- has_shown_tap_to_chat: Optional[bool] = None
- has_shown_cancel_tooltip: Optional[bool] = None
- has_shown_send_tooltip: Optional[bool] = None
- has_shown_progress_bar_tooltip: Optional[bool] = None
- has_shown_mission_stone_modal: Optional[bool] = None
- has_shown_conversation_stone_modal: Optional[bool] = None
- has_clicked_mission_stone: Optional[bool] = None
- has_clicked_conversation_stone: Optional[bool] = None
- avatar_c_unlocked: Optional[bool] = None
- has_shared_avatar: Optional[bool] = None
- current_avatar_id: Optional[str] = None
- subtitle_on: Optional[bool] = None
- # 分享图片响应模型
- class ShareImageItem(BaseModel):
- id: str
- url: str
- title: Optional[str] = None
- sort_order: Optional[int] = None
- is_active: Optional[bool] = None
- SHARE_IMAGES: List[ShareImageItem] = [
- ShareImageItem(id="share-1", url="https://www.lyrria.com/img/share-1.png"),
- ShareImageItem(id="share-2", url="https://www.lyrria.com/img/share-2.png"),
- ]
- class AvatarGifSet(BaseModel):
- default: str
- positive: str
- negative: str
- talking: str
- async def _generate_session_token(user_id: str) -> Dict[str, Any]:
- """
- 为指定用户生成会话 token(复用 init_session 的逻辑)
-
- Args:
- user_id: 用户ID
-
- Returns:
- 包含 token 和 expires_at 的字典
- """
- session_store = getattr(app.state, "session_store", None)
- token = str(uuid.uuid4())
- expires_at = datetime.utcnow() + timedelta(seconds=settings.SESSION_TOKEN_TTL_SECONDS)
- if session_store:
- await session_store.set_current_token(user_id, token, settings.SESSION_TOKEN_TTL_SECONDS)
- # 主动踢掉旧连接
- await session_store.invalidate_connection(user_id)
- return {
- "token": token,
- "expires_at": expires_at.isoformat() + "Z",
- }
- class SessionInitRequest(BaseModel):
- user_id: Optional[str] = None
- username: Optional[str] = ""
- AVATAR_GIF_CONFIG = {
- "avatar_default": AvatarGifSet(
- default="https://www.lyrria.com/img/01-Default.gif",
- positive="https://www.lyrria.com/img/04-Positive-emotion.gif",
- negative="https://www.lyrria.com/img/05-Negative-emotion.gif",
- talking="https://www.lyrria.com/img/06-talking.gif",
- ),
- "avatar_registered_cap": AvatarGifSet(
- default="https://www.lyrria.com/img/01-Default-Registered.gif",
- positive="https://www.lyrria.com/img/04-Positive-emotion-Registered.gif",
- negative="https://www.lyrria.com/img/05-Negative-emotion-Registered.gif",
- talking="https://www.lyrria.com/img/06-talking-Registered.gif",
- ),
- }
- @app.post("/api/users/create")
- async def create_user(request: CreateUserRequest):
- """
- 创建游客用户
-
- Returns:
- {
- "user_id": "uuid",
- "username": "",
- "user_type": "guest",
- "bot_name": "机器人名字(可选)"
- }
- """
- try:
- user = await user_service.create_guest_user(
- username=request.username,
- bot_name=request.bot_name,
- user_segment=request.user_segment,
- org_id=request.org_id,
- is_test_user=request.is_test_user,
- region=request.region,
- )
- return {
- "success": True,
- "data": user
- }
- except Exception as e:
- logger.exception("创建用户失败")
- raise HTTPException(status_code=500, detail={"code": CREATE_USER_FAILED})
- @app.post("/api/session/init")
- async def init_session(request: SessionInitRequest):
- """
- 初始化或刷新用户会话,返回 user_id + 会话 token(单用户单活)
- """
- try:
- session_store = getattr(app.state, "session_store", None)
- # 1. 获取或创建用户
- user_id = request.user_id
- user_payload: Optional[Dict[str, Any]] = None
- if not user_id:
- user_payload = await user_service.create_guest_user(
- username=request.username or ""
- )
- user_id = user_payload["user_id"]
- else:
- # 确认用户存在
- user_payload = await user_service.get_user(user_id)
- if not user_payload:
- raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND})
- # 2. 签发新 token(覆盖旧 token,单活)
- token = str(uuid.uuid4())
- expires_at = datetime.utcnow() + timedelta(seconds=settings.SESSION_TOKEN_TTL_SECONDS)
- if session_store:
- await session_store.set_current_token(user_id, token, settings.SESSION_TOKEN_TTL_SECONDS)
- # 主动踢掉旧连接
- await session_store.invalidate_connection(user_id)
- data = {
- "user_id": user_id,
- "token": token,
- "expires_at": expires_at.isoformat() + "Z",
- }
- # 如果刚创建用户,透传部分信息,兼容前端缓存逻辑
- if user_payload:
- data.update(
- {
- "username": user_payload.get("username"),
- "user_level": user_payload.get("user_level"),
- "current_background_image_id": user_payload.get(
- "current_background_image_id"
- ),
- "current_background_image_metadata": user_payload.get(
- "current_background_image_metadata"
- ),
- }
- )
- return {"success": True, "data": data}
- except HTTPException:
- raise
- except Exception as e:
- logger.exception("初始化会话失败")
- raise HTTPException(status_code=500, detail={"code": INIT_SESSION_FAILED})
- @app.get("/api/users/{user_id}")
- async def get_user(user_id: str, _: None = Depends(require_user_session)):
- """
- 获取用户信息
-
- Args:
- user_id: 用户ID
-
- Returns:
- {
- "success": true,
- "data": {
- "user_id": "uuid",
- "username": "",
- "user_type": "guest",
- "bot_name": "机器人名字(可选)",
- ...
- }
- }
- """
- try:
- user = await user_service.get_user(user_id)
- if user:
- return {
- "success": True,
- "data": user
- }
- else:
- raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND})
- except HTTPException:
- raise
- except Exception as e:
- logger.exception("获取用户信息失败")
- raise HTTPException(status_code=500, detail={"code": GET_USER_INFO_FAILED})
- @app.get("/api/users/{user_id}/energy-stones/count")
- async def get_energy_stone_counts(user_id: str, _: None = Depends(require_user_session)):
- """
- 获取用户的能量石数量
-
- Args:
- user_id: 用户ID
-
- Returns:
- {
- "success": true,
- "data": {
- "mission_count": 0,
- "conversation_count": 0
- }
- }
- """
- try:
- if not energy_stone_service.db:
- await energy_stone_service.set_db_manager(db_manager)
-
- counts = await energy_stone_service.get_stone_counts(user_id)
- return {
- "success": True,
- "data": counts
- }
- except Exception as e:
- logger.exception("获取能量石数量失败")
- raise HTTPException(status_code=500, detail={"code": GET_ENERGY_STONES_FAILED})
- def _get_tutorial_intro_text(step: int) -> str | None:
- """根据教程步骤返回对应的固定英文文案。"""
- mapping: Dict[int, str] = {
- 1: "Hi! Nice to meet you, my friend. Let me show you my home.",
- 3: "I’ve added captions so we can understand each other even better!",
- 5: "Tap the button and let's talk! How was your day",
- 8: "You can see your Energy Stones here.",
- 9: "You can also see our Bond Level here. It shows how close we are.",
- 10: "That's all! let’s start our first chat!",
- }
- return mapping.get(step)
- @app.get("/api/tutorials/{step}/intro-audio")
- async def get_tutorial_intro_audio(
- step: int,
- _: str = Depends(require_user_session_from_headers),
- ):
- """
- 新手教程步骤:返回对应字幕文案的 TTS 音频。
- - 支持步骤:1 / 3 / 5 / 8 / 9 / 10
- - 文案与前端字幕保持一致(ChatBotStaticShell 中的英文文案)
- - 使用全局 ai_service 进行 TTS 合成,返回单段 Base64 PCM。
- """
- text = _get_tutorial_intro_text(step)
- if text is None:
- raise HTTPException(status_code=404, detail={"code": GET_CONFIG_FAILED})
- try:
- audio_chunks: list[bytes] = []
- async for chunk in ai_service.text_to_speech_stream(text, emotion="friendly"):
- audio_chunks.append(chunk)
- if not audio_chunks:
- raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
- audio_bytes = b"".join(audio_chunks)
- audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")
- return {
- "success": True,
- "data": {
- "audio": audio_base64,
- "sampleRate": 24000,
- "format": "pcm",
- "text": text,
- },
- }
- except HTTPException:
- raise
- except Exception as exc:
- logger.exception("生成教程步骤 TTS 音频失败: %s", exc)
- raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
- @app.get("/api/users/{user_id}/avatars")
- async def get_user_avatars(user_id: str, _: None = Depends(require_user_session)):
- """
- 获取用户已拥有的头像列表
-
- Args:
- user_id: 用户ID
-
- Returns:
- {
- "success": true,
- "data": {
- "avatar_ids": ["avatar_default", "avatar_registered_cap", ...]
- }
- }
- """
- try:
- async with db_manager.get_session() as session:
- result = await session.execute(
- select(UserAvatar.avatar_id).where(UserAvatar.user_id == user_id)
- )
- avatar_ids = [row[0] for row in result.fetchall()]
-
- return {
- "success": True,
- "data": {
- "avatar_ids": avatar_ids
- }
- }
- except Exception as e:
- logger.exception("获取用户头像信息失败")
- raise HTTPException(status_code=500, detail={"code": GET_AVATARS_FAILED})
- @app.get("/api/background-images")
- async def get_background_images():
- """
- 获取所有背景图片配置列表
-
- Returns:
- {
- "success": true,
- "data": [
- {
- "image_id": "default",
- "image_name": "默认背景",
- "image_url": "https://...",
- "thumbnail_url": "https://...",
- "unlock_type": "default",
- "display_order": 0,
- "is_active": true,
- ...
- },
- ...
- ]
- }
- """
- try:
- async with db_manager.get_session() as session:
- result = await session.execute(
- select(BackgroundImage).where(BackgroundImage.is_active == True).order_by(BackgroundImage.display_order)
- )
- background_images = result.scalars().all()
-
- return {
- "success": True,
- "data": [bg_image.to_dict() for bg_image in background_images]
- }
- except Exception as e:
- logger.error(f"获取背景图片配置失败: {e}")
- logger.exception("获取背景图片配置失败")
- raise HTTPException(status_code=500, detail={"code": GET_BG_CONFIG_FAILED})
- @app.get("/api/users/{user_id}/background-images")
- async def get_user_background_images(user_id: str, _: None = Depends(require_user_session)):
- """
- 获取用户已拥有的背景图片列表
-
- Args:
- user_id: 用户ID
-
- Returns:
- {
- "success": true,
- "data": {
- "image_ids": ["default", "happynewyear", ...]
- }
- }
- """
- try:
- async with db_manager.get_session() as session:
- result = await session.execute(
- select(UserBackgroundImage.image_id).where(UserBackgroundImage.user_id == user_id)
- )
- image_ids = [row[0] for row in result.fetchall()]
-
- return {
- "success": True,
- "data": {
- "image_ids": image_ids
- }
- }
- except Exception as e:
- logger.exception("获取用户背景图片信息失败")
- raise HTTPException(status_code=500, detail={"code": GET_USER_BG_IMAGES_FAILED})
- @app.post("/api/users/{user_id}/claim-share-reward")
- async def claim_share_reward(user_id: str, _: None = Depends(require_user_session)):
- """
- 领取分享奖励
- - 给用户添加新年背景图片(happynewyear)
- - 设置用户的当前背景图片为新年的背景
-
- Args:
- user_id: 用户ID
-
- Returns:
- {
- "success": true,
- "data": {
- "user_id": "xxx",
- "background_image_id": "happynewyear",
- "message": "分享奖励已领取"
- }
- }
- """
- try:
- from database import db_manager
- from models_db import User, UserBackgroundImage
- from sqlalchemy import select, update
-
- async with db_manager.get_session() as session:
- # 1. 检查用户是否存在
- result = await session.execute(
- select(User).where(User.user_id == user_id)
- )
- user = result.scalar_one_or_none()
- if not user:
- raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND})
-
- # 2. 检查用户是否已经拥有新年背景
- bg_result = await session.execute(
- select(UserBackgroundImage).where(
- UserBackgroundImage.user_id == user_id,
- UserBackgroundImage.image_id == "happynewyear"
- )
- )
- existing_bg = bg_result.scalar_one_or_none()
-
- # 3. 如果还没有,添加到用户背景图片列表
- if not existing_bg:
- user_bg_image = UserBackgroundImage(
- user_id=user_id,
- image_id="happynewyear",
- obtained_type="gift",
- obtained_source="share_reward"
- )
- session.add(user_bg_image)
- logger.info(f"✅ 为用户添加新年背景图片: user_id={user_id}")
- else:
- logger.info(f"⏭️ 用户已拥有新年背景图片: user_id={user_id}")
-
- # 4. 更新用户的当前背景图片
- await session.execute(
- update(User)
- .where(User.user_id == user_id)
- .values(current_background_image_id="happynewyear")
- )
-
- # 5. 更新用户的 has_shared_avatar 状态
- from models_db import UserUIState
- ui_state_result = await session.execute(
- select(UserUIState).where(UserUIState.user_id == user_id)
- )
- ui_state = ui_state_result.scalar_one_or_none()
- if ui_state:
- ui_state.has_shared_avatar = True
- session.add(ui_state)
- logger.info(f"✅ 更新用户分享头像状态: user_id={user_id}, has_shared_avatar=True")
- else:
- # 如果不存在 UI 状态记录,创建一个新的
- new_ui_state = UserUIState(
- user_id=user_id,
- has_shared_avatar=True
- )
- session.add(new_ui_state)
- logger.info(f"✅ 创建用户UI状态并设置分享头像状态: user_id={user_id}, has_shared_avatar=True")
-
- # 6. 获取背景图片的 metadata(用于前端设置透明度等)
- bg_metadata = None
- try:
- from models_db import BackgroundImage
- bg_info_result = await session.execute(
- select(BackgroundImage).where(BackgroundImage.image_id == "happynewyear")
- )
- bg_info = bg_info_result.scalar_one_or_none()
- if bg_info and bg_info.metadata_json:
- bg_metadata = bg_info.metadata_json
- except Exception as e:
- logger.warning(f"⚠️ 获取背景图片metadata失败: {e}")
-
- await session.commit()
-
- logger.info(f"✅ 用户领取分享奖励成功: user_id={user_id}, background_image_id=happynewyear")
-
- return {
- "success": True,
- "data": {
- "user_id": user_id,
- "background_image_id": "happynewyear",
- "background_image_metadata": bg_metadata,
- "message": "分享奖励已领取"
- }
- }
- except HTTPException:
- raise
- except Exception as e:
- logger.exception("领取分享奖励失败")
- raise HTTPException(status_code=500, detail={"code": CLAIM_SHARE_REWARD_FAILED})
- @app.get("/api/avatar-gifs")
- async def get_avatar_gifs() -> Dict[str, Any]:
- """
- 获取头像 GIF 配置
- - 优先读取数据库 avatar_assets 表(按 avatar_id、state 分组)
- - 字段:avatar_id, state, image_url
- - 失败/无数据时返回静态兜底配置
- """
- try:
- from database import db_manager
- async with db_manager.get_session() as session:
- result = await session.execute(
- text(
- """
- SELECT avatar_id, state, image_url
- FROM avatar_assets
- WHERE state IN ('default', 'positive', 'negative', 'talking')
- ORDER BY avatar_id, state
- """
- )
- )
- rows = result.mappings().all()
- if rows:
- # 按 avatar_id 分组,转换为期望的格式
- data: Dict[str, Dict[str, str]] = {}
- for row in rows:
- avatar_id = row.get("avatar_id")
- state = row.get("state")
- image_url = row.get("image_url")
-
- if not avatar_id or not state or not image_url:
- continue
-
- if avatar_id not in data:
- data[avatar_id] = {}
-
- data[avatar_id][state] = image_url
-
- if data:
- return {"success": True, "data": data}
- logger.warning("avatar_assets 表无记录,返回兜底静态配置")
- except Exception as exc:
- logger.error(f"获取头像 GIF 配置失败,使用兜底: {exc}")
- return {"success": True, "data": {k: v.model_dump() for k, v in AVATAR_GIF_CONFIG.items()}}
- @app.get("/api/share-images")
- async def get_share_images() -> Dict[str, Any]:
- """
- 获取分享图片列表
- - 优先从数据库 share_images 表读取(is_active=1,按 sort_order、id 排序)
- - 失败或无数据时返回兜底静态列表
- """
- try:
- from database import db_manager
- async with db_manager.get_session() as session:
- result = await session.execute(
- text(
- """
- SELECT id, code, url, title, sort_order, is_active
- FROM share_images
- WHERE is_active = 1
- ORDER BY sort_order ASC, id ASC
- """
- )
- )
- rows = result.mappings().all()
- if rows:
- data = [
- {
- "id": str(row.get("code") or row.get("id")),
- "url": row.get("url"),
- "title": row.get("title"),
- "sort_order": row.get("sort_order"),
- "is_active": bool(row.get("is_active")) if row.get("is_active") is not None else None,
- }
- for row in rows
- ]
- return {"success": True, "data": data}
- logger.warning("share_images 表无记录,返回兜底静态列表")
- except Exception as exc:
- logger.error(f"获取分享图片失败,使用兜底: {exc}")
- return {"success": True, "data": [item.model_dump() for item in SHARE_IMAGES]}
- @app.put("/api/users/{user_id}/username")
- async def update_username(user_id: str, request: UpdateUsernameRequest, _: None = Depends(require_user_session)):
- """
- 更新用户名
-
- Args:
- user_id: 用户ID
- request: 更新请求(包含username)
-
- Returns:
- {
- "success": true,
- "message": "更新成功"
- }
- """
- try:
- success = await user_service.update_username(user_id, request.username)
- if success:
- return {
- "success": True,
- "message": "用户名更新成功"
- }
- else:
- raise HTTPException(status_code=500, detail={"code": UPDATE_USERNAME_FAILED})
- except HTTPException:
- raise
- except Exception as e:
- logger.exception("更新用户名失败")
- raise HTTPException(status_code=500, detail={"code": UPDATE_USERNAME_FAILED})
- @app.put("/api/users/{user_id}/background-image-id")
- async def update_background_image_id(user_id: str, request: UpdateBackgroundImageIdRequest, _: None = Depends(require_user_session)):
- """
- 更新用户当前使用的背景图片ID
-
- Args:
- user_id: 用户ID
- request: 更新请求(包含background_image_id)
-
- Returns:
- {
- "success": true,
- "message": "更新成功"
- }
- """
- try:
- success = await user_service.update_current_background_image_id(
- user_id, request.background_image_id
- )
- if not success:
- raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND_OR_UPDATE_FAILED})
- return {"success": True, "message": "更新成功"}
- except HTTPException:
- raise
- except Exception as e:
- logger.exception("更新背景图片ID失败")
- raise HTTPException(status_code=500, detail={"code": UPDATE_BG_IMAGE_FAILED}) from e
- @app.put("/api/users/{user_id}/bot-name")
- async def update_bot_name(user_id: str, request: UpdateBotNameRequest, _: None = Depends(require_user_session)):
- """
- 更新用户给机器人取的名字
-
- Args:
- user_id: 用户ID
- request: 更新请求(包含bot_name)
-
- Returns:
- {
- "success": true,
- "message": "更新成功"
- }
- """
- import asyncio
- import time
-
- start_time = time.time()
-
- try:
- # 🔥 添加超时保护(5秒),避免长时间等待
- success = await asyncio.wait_for(
- user_service.update_bot_name(user_id, request.bot_name),
- timeout=5.0
- )
-
- duration = (time.time() - start_time) * 1000
-
- if success:
- return {
- "success": True,
- "message": "机器人名字更新成功"
- }
- else:
- raise HTTPException(status_code=500, detail={"code": UPDATE_BOT_NAME_FAILED})
- except asyncio.TimeoutError:
- duration = (time.time() - start_time) * 1000
- logger.error(f"更新机器人名字超时: user_id={user_id}, 耗时={duration:.1f}ms")
- raise HTTPException(status_code=504, detail={"code": UPDATE_BOT_NAME_TIMEOUT})
- except HTTPException:
- raise
- except Exception as e:
- logger.exception("更新机器人名字异常")
- raise HTTPException(status_code=500, detail={"code": UPDATE_BOT_NAME_FAILED})
- class SubmitFeedbackRequest(BaseModel):
- """提交反馈请求"""
- content: str
- @app.post("/api/users/{user_id}/feedback")
- async def submit_feedback(user_id: str, request: SubmitFeedbackRequest, _: None = Depends(require_user_session)):
- """
- 提交用户反馈
-
- Args:
- user_id: 用户ID
- request: 反馈请求(包含content)
-
- Returns:
- {
- "success": true,
- "data": {
- "feedback_id": "uuid",
- "user_id": "uuid",
- "content": "反馈内容",
- "status": "pending",
- "created_at": "2024-01-01T00:00:00"
- }
- }
- """
- try:
- # 验证反馈内容
- if not request.content or not request.content.strip():
- raise HTTPException(status_code=400, detail={"code": FEEDBACK_CONTENT_EMPTY})
-
- # 验证用户是否存在
- async with db_manager.get_session() as session:
- from models_db import User
- user_result = await session.execute(
- select(User).where(User.user_id == user_id)
- )
- user = user_result.scalar_one_or_none()
- if not user:
- raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND})
-
- # 创建反馈记录
- feedback_id = str(uuid.uuid4())
- feedback = Feedback(
- feedback_id=feedback_id,
- user_id=user_id,
- content=request.content.strip(),
- status="pending"
- )
- session.add(feedback)
- await session.commit()
- await session.refresh(feedback)
-
- logger.info(f"✅ 用户反馈提交成功: user_id={user_id}, feedback_id={feedback_id}")
-
- return {
- "success": True,
- "data": feedback.to_dict()
- }
- except HTTPException:
- raise
- except Exception as e:
- logger.exception("提交反馈失败")
- raise HTTPException(status_code=500, detail={"code": SUBMIT_FEEDBACK_FAILED})
- @app.post("/api/auth/register/phone")
- async def register_with_phone(request: RegisterPhoneRequest):
- """
- 手机号注册
-
- Returns:
- {
- "success": true,
- "data": {
- "user_id": "uuid",
- "username": "",
- "phone": "",
- "user_type": "registered",
- "token": "会话token",
- "expires_at": "过期时间",
- ...
- }
- }
- """
- try:
- user = await user_service.register_with_phone(
- area_code=request.area_code,
- phone=request.phone,
- username=request.username,
- user_id=request.user_id,
- preferred_language=request.preferred_language,
- )
- # 生成会话 token(注册成功后自动登录)
- session_data = await _generate_session_token(user["user_id"])
- # 将 token 添加到返回数据中
- user.update(session_data)
- return {
- "success": True,
- "data": user
- }
- except AuthError as e:
- raise HTTPException(status_code=400, detail={"code": e.code})
- except Exception as e:
- logger.exception("手机号注册失败")
- raise HTTPException(status_code=500, detail={"code": REGISTER_FAILED})
- @app.post("/api/auth/register/email")
- async def register_with_email(request: RegisterEmailRequest):
- """
- 邮箱注册
-
- Returns:
- {
- "success": true,
- "data": {
- "user_id": "uuid",
- "username": "",
- "email": "",
- "user_type": "registered",
- "token": "会话token",
- "expires_at": "过期时间",
- ...
- }
- }
- """
- try:
- user = await user_service.register_with_email(
- email=request.email,
- username=request.username,
- user_id=request.user_id,
- preferred_language=request.preferred_language,
- )
- # 生成会话 token(注册成功后自动登录)
- session_data = await _generate_session_token(user["user_id"])
- # 将 token 添加到返回数据中
- user.update(session_data)
- return {
- "success": True,
- "data": user
- }
- except AuthError as e:
- raise HTTPException(status_code=400, detail={"code": e.code})
- except Exception as e:
- logger.exception("邮箱注册失败")
- raise HTTPException(status_code=500, detail={"code": REGISTER_FAILED})
- @app.post("/api/auth/login/wechat")
- async def login_with_wechat(request: LoginWechatRequest):
- """
- 微信登录/注册
-
- Returns:
- {
- "success": true,
- "data": {
- "user_id": "uuid",
- "username": "",
- "wechat_openid": "",
- "user_type": "registered",
- "token": "会话token",
- "expires_at": "过期时间",
- ...
- }
- }
- """
- try:
- user = await user_service.login_with_wechat(
- openid=request.openid,
- username=request.username
- )
- # 生成会话 token
- session_data = await _generate_session_token(user["user_id"])
- # 将 token 添加到返回数据中
- user.update(session_data)
- return {
- "success": True,
- "data": user
- }
- except Exception as e:
- logger.exception("微信登录失败")
- raise HTTPException(status_code=500, detail={"code": LOGIN_FAILED})
- @app.post("/api/auth/login/qq")
- async def login_with_qq(request: LoginQQRequest):
- """
- QQ登录/注册
-
- Returns:
- {
- "success": true,
- "data": {
- "user_id": "uuid",
- "username": "",
- "qq_openid": "",
- "user_type": "registered",
- "token": "会话token",
- "expires_at": "过期时间",
- ...
- }
- }
- """
- try:
- user = await user_service.login_with_qq(
- openid=request.openid,
- username=request.username
- )
- # 生成会话 token
- session_data = await _generate_session_token(user["user_id"])
- # 将 token 添加到返回数据中
- user.update(session_data)
- return {
- "success": True,
- "data": user
- }
- except Exception as e:
- logger.exception("QQ登录失败")
- raise HTTPException(status_code=500, detail={"code": LOGIN_FAILED})
- @app.post("/api/auth/login/phone")
- async def login_with_phone(request: LoginPhoneRequest):
- """
- 手机号登录
-
- Returns:
- {
- "success": true,
- "data": {
- "user_id": "uuid",
- "phone": "",
- "token": "会话token",
- "expires_at": "过期时间",
- ...
- }
- }
- """
- try:
- user = await user_service.login_with_phone(
- phone=request.phone,
- area_code=request.area_code,
- preferred_language=request.preferred_language,
- )
- if user:
- # 生成会话 token
- session_data = await _generate_session_token(user["user_id"])
- # 将 token 添加到返回数据中
- user.update(session_data)
- return {
- "success": True,
- "data": user
- }
- else:
- raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND_REGISTER_FIRST})
- except HTTPException:
- raise
- except Exception as e:
- logger.exception("手机号登录失败")
- raise HTTPException(status_code=500, detail={"code": LOGIN_FAILED})
- @app.post("/api/auth/login/email")
- async def login_with_email(request: LoginEmailRequest):
- """
- 邮箱登录
-
- Returns:
- {
- "success": true,
- "data": {
- "user_id": "uuid",
- "email": "",
- "token": "会话token",
- "expires_at": "过期时间",
- ...
- }
- }
- """
- try:
- user = await user_service.login_with_email(
- request.email,
- preferred_language=request.preferred_language,
- )
- if user:
- # 生成会话 token
- session_data = await _generate_session_token(user["user_id"])
- # 将 token 添加到返回数据中
- user.update(session_data)
- return {
- "success": True,
- "data": user
- }
- else:
- raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND_REGISTER_FIRST})
- except HTTPException:
- raise
- except Exception as e:
- logger.exception("邮箱登录失败")
- raise HTTPException(status_code=500, detail={"code": LOGIN_FAILED})
- @app.post("/api/auth/send-code")
- async def send_code(request: SendCodeRequest):
- """
- 生成并保存验证码
-
- 当用户点击 send code 按钮时调用此接口,生成临时验证码并保存到数据库
-
- Args:
- request: 发送验证码请求
- - phone: 手机号(发送类型为短信时必填)
- - email: 邮箱(发送类型为邮箱时必填)
- - send_type: 发送类型 ("domestic_sms", "international_sms", "email")
-
- Returns:
- {
- "success": true,
- "data": {
- "code_id": "验证码ID",
- "message": "验证码已生成,等待发送"
- }
- }
- """
- try:
- # 验证参数
- if request.send_type in ["domestic_sms", "international_sms"]:
- if not request.phone:
- raise HTTPException(status_code=400, detail={"code": PHONE_REQUIRED})
- elif request.send_type == "email":
- if not request.email:
- raise HTTPException(status_code=400, detail={"code": EMAIL_REQUIRED})
- else:
- raise HTTPException(status_code=400, detail={"code": SEND_TYPE_INVALID})
-
- # 生成4位数字验证码
- code = str(random.randint(1000, 9999))
- code_id = str(uuid.uuid4())
-
- # 保存到数据库
- async with db_manager.get_session() as session:
- # 🔥 将同一手机号/邮箱的旧验证码标记为无效
- # 确保同一时间只有一个有效的验证码
- if request.send_type in ["domestic_sms", "international_sms"]:
- # 将同一手机号的旧验证码标记为无效
- await session.execute(
- update(VerificationCode)
- .where(
- VerificationCode.phone == request.phone,
- VerificationCode.send_type == request.send_type,
- VerificationCode.is_valid == True
- )
- .values(is_valid=False)
- )
- elif request.send_type == "email":
- # 将同一邮箱的旧验证码标记为无效
- await session.execute(
- update(VerificationCode)
- .where(
- VerificationCode.email == request.email,
- VerificationCode.send_type == request.send_type,
- VerificationCode.is_valid == True
- )
- .values(is_valid=False)
- )
-
- # 创建新的验证码
- verification_code = VerificationCode(
- code_id=code_id,
- area_code=request.area_code,
- phone=request.phone,
- email=request.email,
- code=code,
- send_type=request.send_type,
- is_sent=False,
- is_valid=True,
- )
- session.add(verification_code)
- await session.commit()
-
- logger.info(f"✅ 验证码已生成: code_id={code_id}, send_type={request.send_type}, area_code={request.area_code}, phone={request.phone}, email={request.email}")
-
- return {
- "success": True,
- "data": {
- "code_id": code_id,
- "message": "验证码已生成,等待发送"
- }
- }
- except HTTPException:
- raise
- except Exception as e:
- logger.exception("生成验证码失败")
- raise HTTPException(status_code=500, detail={"code": SEND_CODE_FAILED})
- @app.post("/api/auth/verify-code")
- async def verify_code(request: VerifyCodeRequest):
- """
- 验证验证码
-
- Args:
- request: 验证验证码请求
- - phone: 手机号(发送类型为短信时必填)
- - email: 邮箱(发送类型为邮箱时必填)
- - code: 验证码
- - send_type: 发送类型 ("domestic_sms", "international_sms", "email")
-
- Returns:
- {
- "success": true,
- "data": {
- "valid": true/false,
- "message": "验证成功" 或 "验证码错误"
- }
- }
- """
- try:
- # 验证参数
- if request.send_type in ["domestic_sms", "international_sms"]:
- if not request.phone:
- raise HTTPException(status_code=400, detail={"code": PHONE_REQUIRED})
- elif request.send_type == "email":
- if not request.email:
- raise HTTPException(status_code=400, detail={"code": EMAIL_REQUIRED})
- else:
- raise HTTPException(status_code=400, detail={"code": SEND_TYPE_INVALID})
-
- if not request.code:
- raise HTTPException(status_code=400, detail={"code": CODE_REQUIRED})
-
- # 查询有效的验证码
- async with db_manager.get_session() as session:
- if request.send_type in ["domestic_sms", "international_sms"]:
- result = await session.execute(
- select(VerificationCode)
- .where(
- VerificationCode.phone == request.phone,
- VerificationCode.send_type == request.send_type,
- VerificationCode.code == request.code,
- VerificationCode.is_valid == True
- )
- .order_by(VerificationCode.created_at.desc())
- .limit(1)
- )
- else:
- result = await session.execute(
- select(VerificationCode)
- .where(
- VerificationCode.email == request.email,
- VerificationCode.send_type == request.send_type,
- VerificationCode.code == request.code,
- VerificationCode.is_valid == True
- )
- .order_by(VerificationCode.created_at.desc())
- .limit(1)
- )
-
- verification_code = result.scalar_one_or_none()
-
- if verification_code:
- # 验证成功,将验证码标记为无效(一次性使用)
- verification_code.is_valid = False
- await session.commit()
-
- logger.info(f"✅ 验证码验证成功: phone={request.phone}, email={request.email}, send_type={request.send_type}")
-
- return {
- "success": True,
- "data": {
- "valid": True,
- "message": "验证成功"
- }
- }
- else:
- logger.warning(f"❌ 验证码验证失败: phone={request.phone}, email={request.email}, code={request.code}")
-
- return {
- "success": True,
- "data": {
- "valid": False,
- "message": "验证码错误或已失效"
- }
- }
-
- except HTTPException:
- raise
- except Exception as e:
- logger.exception("验证验证码失败")
- raise HTTPException(status_code=500, detail={"code": VERIFY_CODE_FAILED})
- @app.post("/api/auth/verify-and-auth")
- async def verify_and_auth(request: VerifyAndAuthRequest):
- """
- V2.0:验证码通过后统一登录或注册。
- 返回 { success, data: { user_id, token, expires_at, is_new_user, ... } }
- """
- if request.type not in ("email", "phone"):
- raise HTTPException(status_code=400, detail={"code": TYPE_MUST_BE_EMAIL_OR_PHONE})
- try:
- user, is_new_user = await user_service.verify_and_auth(
- auth_type=request.type,
- code=request.code,
- email=request.email,
- phone=request.phone,
- area_code=request.area_code,
- preferred_language=request.preferred_language,
- )
- session_data = await _generate_session_token(user["user_id"])
- user.update(session_data)
- user["is_new_user"] = is_new_user
- return {"success": True, "data": user}
- except AuthError as e:
- raise HTTPException(status_code=400, detail={"code": e.code})
- except Exception as e:
- logger.exception("verify-and-auth 失败")
- raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
- @app.get("/api/users/{user_id}/community-choice")
- async def get_community_choice(user_id: str, _: None = Depends(require_user_session)) -> Dict[str, Any]:
- """V2.0:获取用户「退出早期共创社区」选项(登录页回显)"""
- try:
- data = await user_service.get_community_choice(user_id)
- return {"success": True, "data": data or {"opted_out": False}}
- except Exception as e:
- logger.exception("获取 community-choice 失败")
- raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
- @app.put("/api/users/{user_id}/community-choice")
- async def update_community_choice(
- user_id: str,
- request: CommunityChoiceRequest,
- _: None = Depends(require_user_session),
- ):
- """V2.0:更新用户「退出早期共创社区」选项"""
- try:
- await user_service.upsert_community_choice(user_id, request.opted_out)
- return {"success": True}
- except Exception as e:
- logger.exception("更新 community-choice 失败")
- raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
- @app.get("/api/users/{user_id}/early-community")
- async def get_early_community(user_id: str, _: None = Depends(require_user_session)) -> Dict[str, Any]:
- """V2.0:获取用户早期共创状态(是否已展示弹窗、是否已留邮箱)"""
- try:
- data = await user_service.get_early_community(user_id)
- return {"success": True, "data": data or {}}
- except Exception as e:
- logger.exception("获取 early-community 失败")
- raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
- @app.post("/api/users/{user_id}/early-community")
- async def submit_early_community(
- user_id: str,
- request: EarlyCommunityRequest,
- _: None = Depends(require_user_session),
- ):
- """V2.0:提交早期共创(标记弹窗已展示,可选留邮箱)"""
- try:
- data = await user_service.submit_early_community(user_id, request.email)
- return {"success": True, "data": data}
- except Exception as e:
- logger.exception("提交 early-community 失败")
- raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
- @app.get("/api/users/{user_id}/ui-state")
- async def get_user_ui_state(user_id: str, _: None = Depends(require_user_session)) -> Dict[str, Any]:
- """
- 获取用户前端可视化状态(页面、路由、提示状态)
- """
- try:
- state = await user_service.get_user_ui_state(user_id)
- if not state:
- # 没有记录时返回默认值
- default_state = UserUIStateResponse(
- user_id=user_id,
- current_page=None,
- visited_routes=[],
- )
- return {"success": True, "data": default_state.dict()}
- return {"success": True, "data": state}
- except Exception as e: # noqa: BLE001
- logger.exception("获取用户UI状态失败")
- raise HTTPException(status_code=500, detail={"code": GET_UI_STATE_FAILED}) from e
- @app.post("/api/page-visit")
- async def report_page_visit(
- request: PageVisitRequest,
- user_id: Optional[str] = Depends(optional_user_id),
- ) -> Dict[str, Any]:
- """记录一次页面访问(任意界面打开时上报,未登录也可上报)"""
- path = (request.path or "").strip() or "/"
- await user_service.log_page_visit(user_id, path)
- return {"success": True}
- @app.put("/api/users/{user_id}/ui-state")
- async def update_user_ui_state(
- user_id: str,
- request: UpdateUserUIStateRequest,
- _: None = Depends(require_user_session),
- ) -> Dict[str, Any]:
- """
- 更新用户前端可视化状态(部分字段)
- """
- try:
- updated = await user_service.upsert_user_ui_state(
- user_id=user_id,
- current_page=request.current_page,
- visited_routes=request.visited_routes,
- has_shown_tap_to_chat=request.has_shown_tap_to_chat,
- has_shown_cancel_tooltip=request.has_shown_cancel_tooltip,
- has_shown_send_tooltip=request.has_shown_send_tooltip,
- has_shown_progress_bar_tooltip=request.has_shown_progress_bar_tooltip,
- has_shown_mission_stone_modal=request.has_shown_mission_stone_modal,
- has_shown_conversation_stone_modal=request.has_shown_conversation_stone_modal,
- has_clicked_mission_stone=request.has_clicked_mission_stone,
- has_clicked_conversation_stone=request.has_clicked_conversation_stone,
- avatar_c_unlocked=request.avatar_c_unlocked,
- has_shared_avatar=request.has_shared_avatar,
- current_avatar_id=request.current_avatar_id,
- subtitle_on=request.subtitle_on,
- )
- if not updated:
- raise HTTPException(status_code=500, detail={"code": UPDATE_UI_STATE_FAILED})
- return {"success": True, "data": updated}
- except HTTPException:
- raise
- except Exception as e: # noqa: BLE001
- logger.exception("更新用户UI状态失败")
- raise HTTPException(status_code=500, detail={"code": UPDATE_UI_STATE_FAILED}) from e
- @app.get("/api/users/{user_id}/intimacy")
- async def get_user_intimacy(user_id: str, _: None = Depends(require_user_session)) -> Dict[str, Any]:
- """获取用户亲密度:heart_count 0-3,days_since_last_visit 距上次访问天数"""
- try:
- data = await user_service.get_intimacy(user_id)
- return {"success": True, "data": data}
- except Exception as e: # noqa: BLE001
- logger.exception("获取亲密度失败")
- raise HTTPException(status_code=500, detail={"code": "GET_INTIMACY_FAILED"}) from e
- @app.post("/api/users/{user_id}/intimacy/light-up")
- async def light_up_intimacy(user_id: str, _: None = Depends(require_user_session)) -> Dict[str, Any]:
- """点亮一颗心,最多 3 颗"""
- try:
- data = await user_service.light_up_intimacy(user_id)
- return {"success": True, "data": data}
- except Exception as e: # noqa: BLE001
- logger.exception("点亮亲密度失败")
- raise HTTPException(status_code=500, detail={"code": "LIGHT_UP_INTIMACY_FAILED"}) from e
- @app.get("/api/users/{user_id}/intimacy/status")
- async def get_user_intimacy_status(user_id: str, _: None = Depends(require_user_session)) -> Dict[str, Any]:
- """
- 获取用户当天是否可以点亮亲密度。
- 返回:
- {
- "success": true,
- "data": {
- "heart_count": int, # 当前心数 0-3
- "can_light_up_today": bool, # 今天是否还能点亮一次
- }
- }
- """
- try:
- data = await user_service.get_intimacy_status(user_id)
- return {"success": True, "data": data}
- except Exception as e: # noqa: BLE001
- logger.exception("获取亲密度状态失败")
- raise HTTPException(status_code=500, detail={"code": "GET_INTIMACY_STATUS_FAILED"}) from e
- # ==================== OAuth 授权端点 ====================
- @app.get("/api/auth/wechat/authorize")
- async def wechat_authorize(request: Request):
- """
- 获取微信授权URL
-
- 根据微信官方文档:https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
- - 使用 qrconnect 端点进行网站应用授权
- - scope 必须为 snsapi_login
- - redirect_uri 需要 urlEncode 处理
- - state 用于防止 CSRF 攻击
-
- Returns:
- 重定向到微信授权页面
- """
- if not settings.WECHAT_OPEN_APP_ID or not settings.WECHAT_REDIRECT_URI:
- raise HTTPException(status_code=500, detail={"code": WECHAT_OAUTH_NOT_CONFIGURED})
-
- # 生成state参数(用于防止CSRF攻击)
- import secrets
- state = secrets.token_urlsafe(32)
-
- # 将 state 存储到 Redis(如果可用),用于回调时验证
- # TTL 设置为 10 分钟(code 的有效期也是 10 分钟)
- redis_cache = getattr(request.app.state, 'redis_cache', None)
- if redis_cache:
- try:
- redis_client = await redis_cache._get_redis()
- await redis_client.setex(f"oauth:wechat:state:{state}", 600, "1") # 10分钟过期
- except Exception as e:
- logger.warning(f"存储 state 到 Redis 失败,将跳过验证: {e}")
-
- # 构建微信授权URL(使用开放平台的AppID)
- # redirect_uri 会自动通过 urlencode 进行编码
- params = {
- "appid": settings.WECHAT_OPEN_APP_ID, # 使用开放平台的AppID
- "redirect_uri": settings.WECHAT_REDIRECT_URI, # urlencode 会自动编码
- "response_type": "code",
- "scope": "snsapi_login", # 网站应用必须使用 snsapi_login
- "state": state,
- }
-
- # 根据官方文档格式:https://open.weixin.qq.com/connect/qrconnect?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=SCOPE&state=STATE#wechat_redirect
- auth_url = f"https://open.weixin.qq.com/connect/qrconnect?{urllib.parse.urlencode(params)}#wechat_redirect"
-
- return RedirectResponse(url=auth_url)
- @app.get("/api/auth/wechat/callback")
- async def wechat_callback(
- request: Request,
- code: str = Query(..., description="微信授权码"),
- state: str = Query(None, description="状态参数")
- ):
- """
- 微信OAuth回调处理
-
- 根据微信官方文档流程:
- 1. 验证 state 参数(防止 CSRF 攻击)
- 2. 通过 code 换取 access_token
- 3. 使用 openid 进行登录/注册
-
- Args:
- code: 微信返回的授权码(有效期10分钟,只能使用一次)
- state: 状态参数(用于验证请求合法性)
-
- Returns:
- 重定向到前端页面(带token)
- """
- try:
- if not settings.WECHAT_OPEN_APP_ID or not settings.WECHAT_OPEN_APP_SECRET:
- raise HTTPException(status_code=500, detail={"code": WECHAT_OAUTH_NOT_CONFIGURED})
-
- # 1. 验证 state 参数(防止 CSRF 攻击)
- if state:
- redis_cache = getattr(request.app.state, 'redis_cache', None)
- if redis_cache:
- try:
- redis_client = await redis_cache._get_redis()
- state_key = f"oauth:wechat:state:{state}"
- exists = await redis_client.exists(state_key)
- if not exists:
- logger.warning(f"State 验证失败: {state} (可能已过期或被篡改)")
- return RedirectResponse(
- url=f"{settings.FRONTEND_URL}/register?error=wechat_auth_failed&reason=invalid_state"
- )
- # 验证成功后删除 state(一次性使用)
- await redis_client.delete(state_key)
- except Exception as e:
- logger.warning(f"State 验证异常: {e},继续处理")
-
- # 2. 通过code获取access_token(使用开放平台的AppID和AppSecret)
- # 根据官方文档:https://api.weixin.qq.com/sns/oauth2/access_token
- token_url = "https://api.weixin.qq.com/sns/oauth2/access_token"
- token_params = {
- "appid": settings.WECHAT_OPEN_APP_ID, # 使用开放平台的AppID
- "secret": settings.WECHAT_OPEN_APP_SECRET, # 使用开放平台的AppSecret
- "code": code,
- "grant_type": "authorization_code",
- }
-
- async with httpx.AsyncClient() as client:
- token_response = await client.get(token_url, params=token_params)
- token_data = token_response.json()
-
- if "errcode" in token_data:
- logger.error(f"微信获取access_token失败: {token_data}")
- return RedirectResponse(
- url=f"{settings.FRONTEND_URL}/register?error=wechat_auth_failed"
- )
-
- access_token = token_data.get("access_token")
- openid = token_data.get("openid")
- # refresh_token 可用于刷新 access_token,当前实现暂不保存
-
- if not access_token or not openid:
- logger.error(f"微信返回数据不完整: {token_data}")
- return RedirectResponse(
- url=f"{settings.FRONTEND_URL}/register?error=wechat_auth_failed"
- )
-
- # 3. 使用openid登录/注册
- # 注意:当前使用 snsapi_login scope,只能获取 openid
- # 如需获取用户昵称、头像等信息,需要:
- # - 将 scope 改为 snsapi_login,snsapi_userinfo
- # - 调用 /sns/userinfo 接口获取用户信息
- user = await user_service.login_with_wechat(openid=openid)
-
- # 4. 重定向到前端,携带用户信息
- frontend_url = f"{settings.FRONTEND_URL}/register?oauth=wechat&user_id={user['user_id']}&username={urllib.parse.quote(user['username'])}"
- return RedirectResponse(url=frontend_url)
-
- except Exception as e:
- logger.error(f"微信OAuth回调处理失败: {e}")
- return RedirectResponse(
- url=f"{settings.FRONTEND_URL}/register?error=wechat_auth_failed"
- )
- @app.get("/api/auth/qq/authorize")
- async def qq_authorize(
- request: Request,
- from_page: str = Query(None, alias="from", description="来源页面"),
- user_id: str = Query(None, description="可选的现有用户ID,如果提供则更新该用户而不是创建新用户")
- ):
- """
- 获取QQ授权URL
-
- Args:
- from_page: 来源页面(如 first-login),用于回调时决定重定向位置
- user_id: 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
-
- Returns:
- 重定向到QQ授权页面
- """
- if not settings.QQ_APP_ID or not settings.QQ_REDIRECT_URI:
- raise HTTPException(status_code=500, detail={"code": QQ_OAUTH_NOT_CONFIGURED})
-
- # 生成state参数(用于防止CSRF攻击)
- import secrets
- state_token = secrets.token_urlsafe(32)
-
- # state只存储token,from_page和user_id存储在Redis中(避免token包含下划线导致解析错误)
- state = state_token
-
- # 将 state 存储到 Redis 以便回调校验(存储原始 token 和来源信息)
- redis_cache = getattr(request.app.state, 'redis_cache', None)
- state_stored = False
- state_key = f"oauth:qq:state:{state_token}"
-
- logger.info(f"📝 [QQ授权] 开始存储state - state_token={state_token}, from_page={from_page}, user_id={user_id}, state={state}, state_key={state_key}")
-
- if redis_cache:
- try:
- redis_client = await redis_cache._get_redis()
-
- # 🔥 检查Redis连接状态
- try:
- ping_result = await redis_client.ping()
- logger.debug(f"✅ [QQ授权] Redis连接正常: ping={ping_result}")
- except Exception as ping_err:
- logger.error(f"❌ [QQ授权] Redis ping失败: {ping_err}")
-
- # 🔥 检查Redis配置信息(DB、连接池等)
- try:
- redis_info = await redis_client.info('server')
- redis_db = redis_client.connection_pool.connection_kwargs.get('db', 0)
- logger.debug(f"🔍 [QQ授权] Redis配置 - DB={redis_db}, server_version={redis_info.get('redis_version', 'unknown')}")
- except Exception as info_err:
- logger.debug(f"⚠️ [QQ授权] 获取Redis信息失败: {info_err}")
-
- # 🔥 检查key是否已存在(防止重复)
- existing_value = await redis_client.get(state_key)
- if existing_value:
- logger.warning(f"⚠️ [QQ授权] state key已存在,将被覆盖: key={state_key}, old_value={existing_value[:50]}...")
-
- # 存储原始 token、来源信息和 user_id
- state_data = {"token": state_token}
- if from_page:
- state_data["from"] = from_page
- if user_id:
- state_data["user_id"] = user_id
- state_value = json.dumps(state_data)
- logger.debug(f"📦 [QQ授权] 准备存储 - key={state_key}, value={state_value}, TTL=1800s")
-
- # 🔥 增加TTL到1800秒(30分钟),避免授权流程过长导致过期
- await redis_client.setex(state_key, 1800, state_value)
- state_stored = True
-
- # 🔥 验证存储是否成功
- verify_value = await redis_client.get(state_key)
- if verify_value:
- # 🔥 检查TTL
- ttl = await redis_client.ttl(state_key)
- logger.info(f"✅ [QQ授权] QQ state 已存储并验证成功 - key={state_key}, token={state_token[:16]}..., TTL={ttl}s, value_length={len(verify_value)}")
- else:
- logger.error(f"❌ [QQ授权] QQ state 存储后验证失败 - key={state_key}, token={state_token[:16]}...")
-
- # 🔥 尝试查找所有相关的keys
- try:
- pattern = f"oauth:qq:state:*"
- keys = []
- async for key in redis_client.scan_iter(match=pattern, count=100):
- keys.append(key)
- logger.debug(f"🔍 [QQ授权] Redis中存在的QQ state keys: {keys[:10]}... (共{len(keys)}个)")
- except Exception as scan_err:
- logger.debug(f"🔍 [QQ授权] 扫描keys失败: {scan_err}")
- except Exception as e:
- logger.error(f"❌ [QQ授权] 存储 QQ state 失败 - key={state_key}, error={e}", exc_info=True)
- else:
- logger.warning("⚠️ [QQ授权] Redis缓存服务不可用,QQ state 验证将被跳过")
-
- # 确保回调地址格式正确(去除末尾斜杠,确保与QQ应用配置完全一致)
- redirect_uri = settings.QQ_REDIRECT_URI.rstrip('/')
-
- # 构建QQ授权URL
- # 注意:QQ开放平台要求redirect_uri必须与配置的回调域完全匹配
- params = {
- "response_type": "code",
- "client_id": settings.QQ_APP_ID,
- "redirect_uri": redirect_uri, # 使用处理后的回调地址
- "state": state,
- "scope": "get_user_info", # QQ授权范围
- }
-
- # URL编码参数(确保特殊字符正确编码)
- auth_url = f"https://graph.qq.com/oauth2.0/authorize?{urllib.parse.urlencode(params)}"
-
- # 调试日志:记录实际发送的授权URL(隐藏敏感信息)
- logger.info(f"🔍 QQ授权URL生成 - AppID: {settings.QQ_APP_ID[:8]}..., 回调地址: {redirect_uri}, 来源: {from_page}")
-
- return RedirectResponse(url=auth_url)
- @app.get("/api/qq/mp/verify")
- async def qq_callback(
- request: Request,
- code: str = Query(..., description="QQ授权码"),
- state: str = Query(None, description="状态参数")
- ):
- """
- QQ OAuth回调处理
-
- Args:
- code: QQ返回的授权码
- state: 状态参数
-
- Returns:
- 重定向到前端页面(带token)
- """
- try:
- logger.info(f"📥 [QQ回调] 收到回调请求 - code={'***' if code else '(empty)'}, state={state}")
-
- # 0. 验证 state 防止 CSRF,并提取来源信息
- from_page = None
- user_id_from_state = None
- state_token = state # state就是token,不再需要分割
- if state:
- logger.debug(f"🔍 [QQ回调] 开始解析state - state={state}, length={len(state)}")
-
- redis_cache = getattr(request.app.state, 'redis_cache', None)
- state_key = f"oauth:qq:state:{state_token}"
- logger.info(f"🔑 [QQ回调] 准备验证state - state_token={state_token[:16]}..., state_key={state_key}")
-
- if redis_cache:
- try:
- redis_client = await redis_cache._get_redis()
-
- # 🔥 检查Redis连接状态
- try:
- ping_result = await redis_client.ping()
- logger.debug(f"✅ [QQ回调] Redis连接正常: ping={ping_result}")
- except Exception as ping_error:
- logger.error(f"❌ [QQ回调] Redis ping失败: {ping_error}", exc_info=True)
- # Redis连接失败时,跳过验证但记录警告(为了不影响用户体验)
- logger.warning("⚠️ [QQ回调] Redis连接失败,跳过state验证(可能存在安全风险)")
- # 继续处理,不阻止登录流程
- else:
- # 🔥 检查Redis配置信息(DB、连接池等)
- try:
- redis_db = redis_client.connection_pool.connection_kwargs.get('db', 0)
- logger.debug(f"🔍 [QQ回调] Redis配置 - DB={redis_db}")
- except Exception as info_err:
- logger.debug(f"⚠️ [QQ回调] 获取Redis配置失败: {info_err}")
-
- # 🔥 尝试查找key
- logger.debug(f"🔍 [QQ回调] 开始查找key: {state_key}")
- state_data_str = await redis_client.get(state_key)
-
- if not state_data_str:
- # 🔥 增加详细日志,帮助诊断问题
- logger.warning(f"❌ [QQ回调] QQ state 验证失败 - state_token={state_token[:16]}..., state_key={state_key}")
-
- # 🔥 检查key的TTL(如果key存在但值为None,可能是其他问题)
- try:
- exists = await redis_client.exists(state_key)
- logger.debug(f"🔍 [QQ回调] key存在性检查: exists={exists}")
- if exists:
- ttl = await redis_client.ttl(state_key)
- logger.debug(f"🔍 [QQ回调] key存在但值为空,TTL={ttl}s")
- except Exception as exists_err:
- logger.debug(f"🔍 [QQ回调] 检查key存在性失败: {exists_err}")
-
- # 🔥 尝试查找所有相关的state key(用于调试)
- try:
- pattern = f"oauth:qq:state:*"
- keys = []
- async for key in redis_client.scan_iter(match=pattern, count=100):
- keys.append(key)
- if keys:
- logger.warning(f"🔍 [QQ回调] Redis中存在其他QQ state keys (共{len(keys)}个): {keys[:10]}")
- # 🔥 检查这些keys的TTL
- for key in keys[:5]:
- try:
- ttl = await redis_client.ttl(key)
- value = await redis_client.get(key)
- logger.debug(f"🔍 [QQ回调] key详情 - {key}: TTL={ttl}s, value_length={len(value) if value else 0}")
- except Exception as key_err:
- logger.debug(f"🔍 [QQ回调] 获取key详情失败 {key}: {key_err}")
- else:
- logger.warning(f"🔍 [QQ回调] Redis中不存在任何QQ state keys")
- except Exception as scan_error:
- logger.debug(f"🔍 [QQ回调] 扫描Redis keys失败: {scan_error}")
-
- return RedirectResponse(
- url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed&reason=invalid_state"
- )
-
- # 🔥 找到key,记录详细信息
- ttl = await redis_client.ttl(state_key)
- logger.info(f"✅ [QQ回调] 找到state key - key={state_key}, TTL={ttl}s, value_length={len(state_data_str)}")
-
- # 如果存储的是 JSON 格式,解析获取来源信息和user_id
- try:
- state_data = json.loads(state_data_str)
- from_page = state_data.get("from") or from_page
- user_id_from_state = state_data.get("user_id")
- logger.info(f"✅ [QQ回调] QQ state 验证成功 - state_token={state_token[:16]}..., 来源: {from_page}, user_id: {user_id_from_state}, data={state_data}")
- except (json.JSONDecodeError, TypeError) as json_err:
- # 如果不是 JSON 格式,使用从 state 中解析的 from_page
- logger.debug(f"✅ [QQ回调] QQ state 验证成功 (非JSON格式) - state_token={state_token[:16]}..., value={state_data_str[:50]}..., json_err={json_err}")
- pass
-
- # 🔥 删除已使用的state key
- deleted = await redis_client.delete(state_key)
- logger.debug(f"🗑️ [QQ回调] 删除state key - key={state_key}, deleted={deleted}")
-
- except Exception as state_error:
- logger.error(f"❌ [QQ回调] QQ state 验证异常 - state_token={state_token[:16]}..., error={state_error}", exc_info=True)
- # 🔥 验证异常时不阻止流程,但记录详细错误
- else:
- logger.warning("⚠️ [QQ回调] Redis缓存服务不可用,跳过state验证")
-
- if not settings.QQ_APP_ID or not settings.QQ_APP_KEY:
- raise HTTPException(status_code=500, detail={"code": QQ_OAUTH_NOT_CONFIGURED})
-
- # 小工具:解析 QQ JSONP 或 querystring 响应
- def parse_qq_payload(payload_text: str) -> dict:
- cleaned = (payload_text or "").strip()
- if not cleaned:
- return {}
- if cleaned.startswith("callback("):
- cleaned = cleaned.split("callback(", 1)[1]
- cleaned = cleaned.rsplit(");", 1)[0]
- try:
- return json.loads(cleaned)
- except json.JSONDecodeError:
- parsed = {}
- for part in cleaned.split("&"):
- if "=" in part:
- key, value = part.split("=", 1)
- parsed[key] = value
- return parsed
-
- # 1. 通过code获取access_token
- # 确保回调地址格式正确(与授权时保持一致)
- redirect_uri = settings.QQ_REDIRECT_URI.rstrip('/')
-
- token_url = "https://graph.qq.com/oauth2.0/token"
- token_params = {
- "grant_type": "authorization_code",
- "client_id": settings.QQ_APP_ID,
- "client_secret": settings.QQ_APP_KEY,
- "code": code,
- "redirect_uri": redirect_uri, # 使用处理后的回调地址
- "fmt": "json",
- }
-
- logger.info(f"🔍 QQ获取token - 回调地址: {redirect_uri}")
-
- async with httpx.AsyncClient() as client:
- token_response = await client.get(token_url, params=token_params)
- token_text = token_response.text
- try:
- token_data = token_response.json()
- except ValueError:
- token_data = parse_qq_payload(token_text)
-
- if "error" in token_data:
- logger.error(f"QQ获取access_token失败: {token_data}")
- return RedirectResponse(
- url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed&reason=token"
- )
-
- access_token = token_data.get("access_token")
-
- if not access_token:
- logger.error(f"QQ返回数据不完整: {token_data}")
- return RedirectResponse(
- url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed"
- )
-
- # 2. 通过access_token获取openid
- openid_url = "https://graph.qq.com/oauth2.0/me"
- openid_params = {
- "access_token": access_token,
- "unionid": 1,
- "fmt": "json",
- }
-
- async with httpx.AsyncClient() as client:
- openid_response = await client.get(openid_url, params=openid_params)
- openid_data = parse_qq_payload(openid_response.text)
-
- if "error" in openid_data:
- logger.error(f"QQ获取openid失败: {openid_data}")
- return RedirectResponse(
- url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed"
- )
-
- openid = openid_data.get("openid")
- unionid = openid_data.get("unionid")
-
- if not openid:
- logger.error(f"QQ返回openid为空: {openid_data}")
- return RedirectResponse(
- url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed"
- )
-
- # 2.1 可选:拉取用户信息用于填充昵称
- nickname = ""
- user_info = {}
- user_info_url = "https://graph.qq.com/user/get_user_info"
- user_info_params = {
- "access_token": access_token,
- "oauth_consumer_key": settings.QQ_APP_ID,
- "openid": openid,
- "fmt": "json",
- }
- try:
- async with httpx.AsyncClient() as client:
- user_info_resp = await client.get(
- user_info_url,
- params=user_info_params,
- )
- user_info = user_info_resp.json()
- if user_info.get("ret") == 0:
- nickname = user_info.get("nickname", "") or ""
- else:
- logger.warning(
- f"QQ用户信息获取失败: {user_info}"
- )
- except Exception as info_error:
- logger.warning(f"拉取QQ用户信息异常: {info_error}")
-
- # 3. 根据来源页面决定处理逻辑
- if from_page == "login":
- # 从 login 页面来的,需要先检查用户是否存在
- # 如果用户不存在,不允许自动注册,应该提示用户先注册
- logger.info(f"🔍 [QQ回调] 开始检查用户是否存在 - openid={openid[:16]}..., unionid={unionid[:16] if unionid else None}...")
- user_exists = await user_service.check_user_exists_by_qq_openid(openid=openid, unionid=unionid)
- logger.info(f"🔍 [QQ回调] 用户存在性检查结果 - user_exists={user_exists}, openid={openid[:16]}...")
-
- if not user_exists:
- # 用户不存在,重定向到 begin 页面并提示(和手机号登录保持一致)
- logger.info(f"⚠️ QQ用户未注册(来自login页面): openid={openid[:16]}..., unionid={unionid[:16] if unionid else None}...")
- return RedirectResponse(
- url=f"{settings.FRONTEND_URL}/begin?error=user_not_registered&oauth=qq"
- )
-
- # 用户存在,使用openid登录
- # 不传递QQ返回的nickname,避免覆盖用户原有用户名
- logger.info(f"✅ [QQ回调] 用户存在,开始登录 - openid={openid[:16]}...")
- user = await user_service.login_with_qq(
- openid=openid,
- username="", # 不使用QQ返回的nickname
- unionid=unionid,
- )
-
- # 🔧 生成会话 token(前端需要 token 来调用 getUserInfo API)
- session_data = await _generate_session_token(user['user_id'])
- token = session_data['token']
- logger.info(f"✅ [QQ回调] 已生成会话 token - user_id={user['user_id']}, token={token[:16]}...")
-
- # 重定向到聊天界面(传递 token,避免前端调用 ensureSession 时使用错误的 token)
- logger.info(f"✅ [QQ回调] 登录成功,准备重定向到聊天界面 - user_id={user['user_id']}")
- frontend_url = f"{settings.FRONTEND_URL}/chatbot?oauth=qq&user_id={user['user_id']}&token={token}"
- return RedirectResponse(url=frontend_url)
-
- elif from_page == "first-login":
- # 从 first-login 页面来的,允许自动注册
- # 如果提供了 user_id,则更新现有用户;否则创建新用户
- # 不传递QQ返回的nickname,避免覆盖用户原有用户名
- user = await user_service.login_with_qq(
- openid=openid,
- username="", # 不使用QQ返回的nickname
- unionid=unionid,
- user_id=user_id_from_state,
- )
-
- # 重定向到 first-login-reward(不传递username,避免使用QQ返回的nickname)
- frontend_url = f"{settings.FRONTEND_URL}/first-login-reward?oauth=qq&user_id={user['user_id']}"
- return RedirectResponse(url=frontend_url)
-
- else:
- # 其他来源(如 register 页面),允许自动注册
- # 使用openid登录/注册(login_with_qq 会自动创建新用户)
- # 不传递QQ返回的nickname,避免使用QQ昵称作为用户名
- user = await user_service.login_with_qq(
- openid=openid,
- username="", # 不使用QQ返回的nickname
- unionid=unionid,
- )
-
- # 默认重定向到聊天界面(不传递username,由前端从数据库同步)
- frontend_url = f"{settings.FRONTEND_URL}/chatbot?oauth=qq&user_id={user['user_id']}"
- return RedirectResponse(url=frontend_url)
-
- except Exception as e:
- logger.error(f"QQ OAuth回调处理失败: {e}")
- return RedirectResponse(
- url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed"
- )
- @app.get("/api/wechat/jsapi/config")
- async def get_wechat_jsapi_config(
- url: str = Query(..., description="当前页面URL(用于生成签名)")
- ):
- """
- 获取微信JS-SDK配置(包含签名)
-
- Args:
- url: 当前页面完整URL(前端传入,用于生成签名)
-
- Returns:
- {
- "success": true,
- "data": {
- "appId": "微信AppID",
- "timestamp": 1234567890,
- "nonceStr": "随机字符串",
- "signature": "SHA1签名"
- }
- }
- """
- try:
- if not settings.WECHAT_MP_APP_ID:
- logger.error("微信公众平台AppID未配置,请检查环境变量 WECHAT_MP_APP_ID")
- raise HTTPException(status_code=500, detail={"code": WECHAT_MP_NOT_CONFIGURED})
-
- # 记录接收到的URL(用于调试)
- logger.info(f"📥 收到微信JS-SDK配置请求: url={url[:100]}...")
-
- # 获取Redis客户端(用于缓存jsapi_ticket)
- from database import db_manager
- redis_getter = db_manager.get_redis
-
- # 创建微信JS-SDK服务
- jsapi_service = WechatJSAPIService(redis_getter=redis_getter)
-
- # 获取配置
- config = await jsapi_service.get_jsapi_config(url)
-
- if not config:
- logger.error("获取微信JS-SDK配置失败(返回None)")
- raise HTTPException(status_code=500, detail={"code": WECHAT_JSAPI_CONFIG_FAILED})
-
- logger.info(f"✅ 微信JS-SDK配置生成成功: appId={config.get('appId', 'N/A')[:10]}..., timestamp={config.get('timestamp')}")
-
- return {
- "success": True,
- "data": config
- }
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"获取微信JS-SDK配置异常: {e}", exc_info=True)
- logger.exception("获取微信JS-SDK配置异常")
- raise HTTPException(status_code=500, detail={"code": GET_CONFIG_FAILED})
- @app.get("/api/qq/share/config")
- async def get_qq_share_config():
- """
- 获取QQ分享配置(包含AppID和应用名称)
-
- Returns:
- {
- "success": true,
- "data": {
- "appId": "QQ AppID",
- "appName": "应用名称"
- }
- }
- """
- try:
- if not settings.QQ_APP_ID:
- logger.warning("QQ AppID未配置,返回空配置")
- return {
- "success": True,
- "data": {
- "appId": "",
- "appName": settings.QQ_APP_NAME
- }
- }
-
- logger.info(f"📥 收到QQ分享配置请求: appId={settings.QQ_APP_ID[:8]}..., appName={settings.QQ_APP_NAME}")
-
- return {
- "success": True,
- "data": {
- "appId": settings.QQ_APP_ID,
- "appName": settings.QQ_APP_NAME
- }
- }
- except Exception as e:
- logger.exception("获取QQ分享配置异常")
- raise HTTPException(status_code=500, detail={"code": GET_CONFIG_FAILED})
- # ==================== 微信公众平台服务器配置 ====================
- @app.get("/api/wechat/mp/verify")
- async def wechat_mp_verify(
- signature: str = Query(..., description="微信加密签名"),
- timestamp: str = Query(..., description="时间戳"),
- nonce: str = Query(..., description="随机数"),
- echostr: str = Query(..., description="随机字符串")
- ):
- """
- 微信公众平台服务器验证接口(GET)
-
- 用于微信公众平台配置服务器URL时的验证
-
- Args:
- signature: 微信加密签名
- timestamp: 时间戳
- nonce: 随机数
- echostr: 随机字符串
-
- Returns:
- 验证成功返回echostr,失败返回空字符串
- """
- if not settings.WECHAT_MP_TOKEN:
- logger.error("微信公众平台Token未配置")
- return PlainTextResponse("", status_code=500)
-
- try:
- # 1. 将token、timestamp、nonce按字典序排序
- token = settings.WECHAT_MP_TOKEN
- tmp_arr = [token, timestamp, nonce]
- tmp_arr.sort()
-
- # 2. 拼接字符串并SHA1加密
- tmp_str = ''.join(tmp_arr)
- tmp_str = hashlib.sha1(tmp_str.encode('utf-8')).hexdigest()
-
- # 3. 与signature比较
- if tmp_str == signature:
- logger.info("✅ 微信公众平台服务器验证成功")
- return PlainTextResponse(echostr)
- else:
- logger.warning(f"⚠️ 微信公众平台服务器验证失败: signature不匹配")
- return PlainTextResponse("", status_code=403)
- except Exception as e:
- logger.error(f"❌ 微信公众平台服务器验证异常: {e}")
- return PlainTextResponse("", status_code=500)
- @app.post("/api/wechat/mp/message")
- async def wechat_mp_message(request: Request):
- """
- 微信公众平台消息推送接口(POST)
-
- 接收微信公众平台推送的消息(文本、事件等)
-
- Returns:
- XML格式的响应消息
- """
- if not settings.WECHAT_MP_TOKEN:
- logger.error("微信公众平台Token未配置")
- return PlainTextResponse("", status_code=500)
-
- try:
- # 1. 获取请求参数(用于验证签名)
- signature = request.query_params.get("signature", "")
- timestamp = request.query_params.get("timestamp", "")
- nonce = request.query_params.get("nonce", "")
-
- # 2. 验证签名
- token = settings.WECHAT_MP_TOKEN
- tmp_arr = [token, timestamp, nonce]
- tmp_arr.sort()
- tmp_str = ''.join(tmp_arr)
- tmp_str = hashlib.sha1(tmp_str.encode('utf-8')).hexdigest()
-
- if tmp_str != signature:
- logger.warning(f"⚠️ 微信公众平台消息签名验证失败")
- return PlainTextResponse("", status_code=403)
-
- # 3. 读取XML消息体
- body = await request.body()
- xml_data = body.decode('utf-8')
-
- # 4. 解析XML
- root = ET.fromstring(xml_data)
- msg_type = root.find('MsgType').text if root.find('MsgType') is not None else ''
- from_user = root.find('FromUserName').text if root.find('FromUserName') is not None else ''
- to_user = root.find('ToUserName').text if root.find('ToUserName') is not None else ''
-
- logger.info(f"📨 收到微信公众平台消息: type={msg_type}, from={from_user}")
-
- # 5. 处理不同类型的消息
- if msg_type == 'text':
- # 文本消息
- content = root.find('Content').text if root.find('Content') is not None else ''
- logger.info(f"📝 文本消息内容: {content}")
-
- # 回复文本消息(示例:自动回复)
- reply_content = "感谢您的消息!"
- reply_xml = f"""<xml>
- <ToUserName><![CDATA[{from_user}]]></ToUserName>
- <FromUserName><![CDATA[{to_user}]]></FromUserName>
- <CreateTime>{int(__import__('time').time())}</CreateTime>
- <MsgType><![CDATA[text]]></MsgType>
- <Content><![CDATA[{reply_content}]]></Content>
- </xml>"""
- return PlainTextResponse(reply_xml, media_type="application/xml")
-
- elif msg_type == 'event':
- # 事件消息
- event = root.find('Event').text if root.find('Event') is not None else ''
- logger.info(f"🎯 事件消息: {event}")
-
- # 处理关注/取消关注事件
- if event == 'subscribe':
- reply_content = "欢迎关注!"
- elif event == 'unsubscribe':
- reply_content = "感谢您的关注!"
- else:
- reply_content = "收到事件消息"
-
- reply_xml = f"""<xml>
- <ToUserName><![CDATA[{from_user}]]></ToUserName>
- <FromUserName><![CDATA[{to_user}]]></FromUserName>
- <CreateTime>{int(__import__('time').time())}</CreateTime>
- <MsgType><![CDATA[text]]></MsgType>
- <Content><![CDATA[{reply_content}]]></Content>
- </xml>"""
- return PlainTextResponse(reply_xml, media_type="application/xml")
-
- else:
- # 其他类型消息,返回空响应
- logger.info(f"ℹ️ 未处理的消息类型: {msg_type}")
- return PlainTextResponse("success")
-
- except Exception as e:
- logger.error(f"❌ 处理微信公众平台消息异常: {e}")
- return PlainTextResponse("success") # 返回success避免微信重试
- @app.websocket("/ws")
- async def websocket_endpoint(websocket: WebSocket):
- """WebSocket端点"""
- # 从查询参数获取user_id(Lyrria_user_id)与会话token
- user_id = websocket.query_params.get("user_id")
- token = websocket.query_params.get("token")
-
- # 记录连接信息
- import logging
- logger = logging.getLogger(__name__)
- logger.info(
- f"🔌 WebSocket连接: user_id={user_id}, query_params={dict(websocket.query_params)}"
- )
-
- # 🔥 传入Redis缓存服务、user_id、session_store
- redis_cache = getattr(app.state, 'redis_cache', None)
- session_store = getattr(app.state, "session_store", None)
- handler = WebSocketHandler(
- websocket,
- user_id=user_id,
- token=token,
- redis_cache=redis_cache,
- session_store=session_store,
- )
- await handler.handle_connection()
- if __name__ == "__main__":
- import uvicorn
- import os
- import socket
- from pathlib import Path
-
- # 🔥 DNS预解析(减少首次连接延迟)
- dashscope_domain = get_dashscope_domain()
- try:
- socket.getaddrinfo(dashscope_domain, 443)
- except Exception as e:
- pass
-
- # SSL证书路径配置
- # 优先使用环境变量配置的证书路径(生产环境)
- ssl_keyfile = settings.SSL_KEYFILE
- ssl_certfile = settings.SSL_CERTFILE
-
- # 如果环境变量未配置,使用本地开发证书
- if not ssl_keyfile or not ssl_certfile:
- ssl_dir = Path(__file__).parent.parent / "ssl"
- ssl_keyfile = str(ssl_dir / "key.pem")
- ssl_certfile = str(ssl_dir / "cert.pem")
-
- # 检查证书是否存在
- if ssl_keyfile and ssl_certfile:
- if not os.path.exists(ssl_keyfile) or not os.path.exists(ssl_certfile):
- ssl_keyfile = None
- ssl_certfile = None
-
- uvicorn.run(
- "main:app",
- host=settings.host,
- port=settings.port,
- reload=settings.DEBUG,
- ssl_keyfile=ssl_keyfile,
- ssl_certfile=ssl_certfile,
- )
|