main.py 239 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012
  1. """
  2. 主应用入口
  3. """
  4. from __future__ import annotations
  5. import json
  6. import logging
  7. import urllib.parse
  8. import hashlib
  9. import uuid
  10. import math
  11. import random
  12. from decimal import Decimal
  13. import base64
  14. from datetime import datetime, timedelta
  15. from typing import Optional, List, Dict, Any, Literal
  16. from fastapi import FastAPI, WebSocket, HTTPException, Request, Query, Header, Depends
  17. from fastapi.exceptions import RequestValidationError
  18. from fastapi.responses import RedirectResponse, PlainTextResponse, JSONResponse
  19. from fastapi.middleware.cors import CORSMiddleware
  20. from starlette.middleware.base import BaseHTTPMiddleware
  21. from starlette.requests import Request as StarletteRequest
  22. from starlette.responses import Response
  23. from pydantic import BaseModel, Field, field_validator, ValidationError as PydanticValidationError
  24. from contextlib import asynccontextmanager
  25. import httpx
  26. from xml.etree import ElementTree as ET
  27. from sqlalchemy import text, select, update
  28. from sqlalchemy.orm import selectinload
  29. from config import settings, get_dashscope_domain
  30. from websocket_handler import WebSocketHandler
  31. from database import init_database, close_database, db_manager
  32. from services.redis_cache_service import RedisCacheService
  33. from services.user_service import user_service, AuthError
  34. from ai_service import ai_service
  35. from error_codes import (
  36. UNAUTHORIZED_USER_MISMATCH,
  37. SESSION_NOT_INITIALIZED,
  38. SESSION_EXPIRED,
  39. USER_NOT_FOUND,
  40. USER_NOT_FOUND_REGISTER_FIRST,
  41. CREATE_USER_FAILED,
  42. INIT_SESSION_FAILED,
  43. GET_USER_INFO_FAILED,
  44. UPDATE_USERNAME_FAILED,
  45. USER_NOT_FOUND_OR_UPDATE_FAILED,
  46. UPDATE_BG_IMAGE_FAILED,
  47. UPDATE_BOT_NAME_FAILED,
  48. UPDATE_BOT_NAME_TIMEOUT,
  49. UPDATE_PREFERRED_LANGUAGE_FAILED,
  50. GET_ENERGY_STONES_FAILED,
  51. GET_AVATARS_FAILED,
  52. GET_BG_CONFIG_FAILED,
  53. GET_USER_BG_IMAGES_FAILED,
  54. CLAIM_SHARE_REWARD_FAILED,
  55. FEEDBACK_CONTENT_EMPTY,
  56. SUBMIT_FEEDBACK_FAILED,
  57. REGISTER_FAILED,
  58. LOGIN_FAILED,
  59. PHONE_REQUIRED,
  60. EMAIL_REQUIRED,
  61. SEND_TYPE_INVALID,
  62. SEND_CODE_FAILED,
  63. CODE_REQUIRED,
  64. VERIFY_CODE_FAILED,
  65. TYPE_MUST_BE_EMAIL_OR_PHONE,
  66. GET_UI_STATE_FAILED,
  67. UPDATE_UI_STATE_FAILED,
  68. WECHAT_OAUTH_NOT_CONFIGURED,
  69. QQ_OAUTH_NOT_CONFIGURED,
  70. WECHAT_MP_NOT_CONFIGURED,
  71. WECHAT_JSAPI_CONFIG_FAILED,
  72. GET_CONFIG_FAILED,
  73. SERVER_ERROR,
  74. INSUFFICIENT_CONVERSATION_ENERGY,
  75. PURCHASE_LIMIT_REACHED,
  76. SHOP_ITEM_NOT_FOUND,
  77. CART_QUANTITY_LIMIT_EXCEEDED,
  78. )
  79. from services.wechat_jsapi_service import WechatJSAPIService
  80. from services.energy_stone_service import energy_stone_service
  81. from services.scenario_topic_service import scenario_topic_service
  82. from services.llm_task_service import llm_task_service
  83. from scenario_config import is_special_ab_experiment_user
  84. from models_db import VerificationCode, UserAvatar, UserBackgroundImage, Feedback, BackgroundImage, User
  85. from session_store import SessionStore
  86. from shop_item_image_utils import scene_image_name_from_shop_row
  87. # 配置日志
  88. logging.basicConfig(
  89. level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO),
  90. format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
  91. datefmt='%Y-%m-%d %H:%M:%S'
  92. )
  93. # 🔇 过滤第三方库的 DEBUG 日志(避免刷屏)
  94. # 这些库的 DEBUG 日志包含 HTTP/2 协议细节、HPACK 压缩等底层信息,对生产环境无意义
  95. third_party_loggers = [
  96. 'openai',
  97. 'openai._base_client',
  98. 'httpcore',
  99. 'httpcore.http2',
  100. 'httpx',
  101. 'hpack',
  102. 'hpack.hpack',
  103. 'hpack.table',
  104. ]
  105. for logger_name in third_party_loggers:
  106. logging.getLogger(logger_name).setLevel(logging.INFO)
  107. logger = logging.getLogger(__name__)
  108. @asynccontextmanager
  109. async def lifespan(app: FastAPI):
  110. """应用生命周期管理"""
  111. # 启动时初始化数据库(包含MySQL和Redis,自动启动健康检查任务)
  112. try:
  113. await init_database()
  114. # 获取Redis客户端(已配置健康检查),用于验证连通性
  115. from database import db_manager
  116. await db_manager.get_redis()
  117. # 创建Redis缓存服务,按需获取最新连接
  118. app.state.redis_cache = RedisCacheService(db_manager.get_redis)
  119. except Exception as e:
  120. logger.error(f"❌ 数据库初始化失败: {e}")
  121. app.state.redis_cache = None
  122. # 🔥 启动HTTP连接保活任务(保持连接活跃,避免长时间不交互后响应变慢)
  123. try:
  124. from http_pool import http_pool
  125. from ai_service import AIService
  126. # 创建AIService实例用于保活(共享连接池)
  127. ai_service = AIService()
  128. http_pool.start_keepalive(ai_service=ai_service)
  129. except Exception as e:
  130. logger.warning(f"⚠️ HTTP连接保活任务启动失败: {e}")
  131. yield
  132. # 关闭时清理资源
  133. await close_database()
  134. # 🔥 关闭HTTP连接池(类似SQLAlchemy的Engine.dispose())
  135. from http_pool import http_pool
  136. http_pool.close()
  137. app = FastAPI(
  138. title="Lyrria Backend API",
  139. lifespan=lifespan
  140. )
  141. # 全局会话存储(单用户单活 token),使用 Redis 持久化
  142. from database import db_manager # 延迟导入以避免循环依赖问题
  143. app.state.session_store = SessionStore(
  144. redis_getter=db_manager.get_redis,
  145. default_ttl_seconds=settings.SESSION_TOKEN_TTL_SECONDS,
  146. )
  147. class APIKeyMiddleware(BaseHTTPMiddleware):
  148. """API Key验证中间件"""
  149. # 不需要验证的路径(健康检查、WebSocket、微信/QQ回调等)
  150. EXEMPT_PATHS = [
  151. "/",
  152. "/docs",
  153. "/openapi.json",
  154. "/redoc",
  155. "/ws",
  156. # 微信 OAuth & 公众号回调
  157. "/api/auth/wechat/authorize",
  158. "/api/auth/wechat/callback",
  159. "/api/wechat/mp/verify",
  160. "/api/wechat/mp/message",
  161. # QQ OAuth & 回调
  162. "/api/auth/qq/authorize",
  163. "/api/qq/mp/verify",
  164. # QQ 分享配置(SDK 拉取)
  165. "/api/qq/share/config",
  166. # web-admin 免登录管理接口(按需求)
  167. "/api/admin",
  168. ]
  169. async def dispatch(self, request: StarletteRequest, call_next):
  170. # 检查是否为豁免路径
  171. path = request.url.path
  172. if any(path.startswith(exempt) for exempt in self.EXEMPT_PATHS):
  173. return await call_next(request)
  174. # 如果未启用API Key验证,直接通过
  175. if not settings.API_KEY_ENABLED:
  176. return await call_next(request)
  177. # 检查API Key
  178. api_key = request.headers.get("X-Client-Key", "")
  179. expected_key = settings.API_CLIENT_KEY
  180. if not expected_key:
  181. logger.warning("⚠️ API_CLIENT_KEY未配置,跳过验证")
  182. return await call_next(request)
  183. if api_key != expected_key:
  184. logger.warning(f"❌ API Key验证失败: path={path}, provided_key={'***' if api_key else '(empty)'}")
  185. return Response(
  186. content='{"detail":"Invalid API Key"}',
  187. status_code=403,
  188. media_type="application/json"
  189. )
  190. return await call_next(request)
  191. # 配置CORS - 使用配置中的允许来源
  192. cors_origins = settings.CORS_ORIGINS if not settings.CORS_ALLOW_ALL else ["*"]
  193. app.add_middleware(
  194. CORSMiddleware,
  195. allow_origins=cors_origins,
  196. allow_credentials=True,
  197. allow_methods=["*"],
  198. allow_headers=["*"],
  199. )
  200. # 添加API Key验证中间件(在CORS之后)
  201. app.add_middleware(APIKeyMiddleware)
  202. @app.exception_handler(RequestValidationError)
  203. async def validation_exception_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
  204. """422 时在服务端打印校验错误,便于排查(如 user_events 缺失字段)"""
  205. errors = exc.errors()
  206. for err in errors:
  207. logger.warning("422 校验失败: loc=%s msg=%s type=%s", err.get("loc"), err.get("msg"), err.get("type"))
  208. return JSONResponse(status_code=422, content={"detail": errors})
  209. async def require_user_session_from_headers(
  210. request: Request,
  211. header_user_id: Optional[str] = Header(None, alias="X-User-Id"),
  212. token: Optional[str] = Header(None, alias="X-User-Token"),
  213. ) -> str:
  214. """仅根据 X-User-Id + X-User-Token 校验会话,返回 user_id(用于无 path user_id 的接口如 POST /api/user-events)"""
  215. if not header_user_id or not token:
  216. logger.warning(
  217. "require_user_session_from_headers: missing headers - path=%s",
  218. request.url.path,
  219. )
  220. raise HTTPException(status_code=401, detail={"code": SESSION_EXPIRED})
  221. session_store = getattr(request.app.state, "session_store", None)
  222. if not session_store:
  223. logger.error(
  224. "require_user_session_from_headers: SESSION_NOT_INITIALIZED - path=%s",
  225. request.url.path,
  226. )
  227. raise HTTPException(status_code=500, detail={"code": SESSION_NOT_INITIALIZED})
  228. is_valid = await session_store.validate(header_user_id, token, refresh=True)
  229. if not is_valid:
  230. logger.warning(
  231. "require_user_session_from_headers: SESSION_EXPIRED - path=%s, user_id=%s",
  232. request.url.path,
  233. header_user_id,
  234. )
  235. raise HTTPException(status_code=401, detail={"code": SESSION_EXPIRED})
  236. return header_user_id
  237. async def require_user_session(
  238. request: Request,
  239. user_id: str,
  240. header_user_id: Optional[str] = Header(None, alias="X-User-Id"),
  241. token: Optional[str] = Header(None, alias="X-User-Token"),
  242. ) -> None:
  243. """
  244. 基于 user_id + token 的简单 HTTP 鉴权,复用 WebSocket 的 session_store。
  245. 仅用于需要用户身份的业务接口,保持单用户单活语义。
  246. """
  247. if not header_user_id or header_user_id != user_id:
  248. logger.warning(
  249. "require_user_session: UNAUTHORIZED_USER_MISMATCH - path=%s, param_user_id=%s, header_user_id=%s",
  250. request.url.path,
  251. user_id,
  252. header_user_id,
  253. )
  254. raise HTTPException(status_code=401, detail={"code": UNAUTHORIZED_USER_MISMATCH})
  255. session_store = getattr(request.app.state, "session_store", None)
  256. if not session_store:
  257. logger.error(
  258. "require_user_session: SESSION_NOT_INITIALIZED - path=%s, user_id=%s",
  259. request.url.path,
  260. header_user_id,
  261. )
  262. raise HTTPException(status_code=500, detail={"code": SESSION_NOT_INITIALIZED})
  263. if not token:
  264. logger.warning(
  265. "require_user_session: missing X-User-Token header - path=%s, user_id=%s",
  266. request.url.path,
  267. header_user_id,
  268. )
  269. raise HTTPException(status_code=401, detail={"code": SESSION_EXPIRED})
  270. is_valid = await session_store.validate(user_id, token, refresh=True)
  271. if not is_valid:
  272. try:
  273. entry = await session_store.get_current_token(user_id)
  274. except Exception as diag_exc: # noqa: BLE001
  275. logger.warning(
  276. "require_user_session: validate failed and get_current_token raised - path=%s, user_id=%s, token_prefix=%s, error=%r",
  277. request.url.path,
  278. user_id,
  279. str(token)[:8],
  280. diag_exc,
  281. )
  282. raise HTTPException(status_code=401, detail={"code": SESSION_EXPIRED})
  283. if entry is None:
  284. logger.warning(
  285. "require_user_session: SESSION_EXPIRED - no active session entry - path=%s, user_id=%s, token_prefix=%s",
  286. request.url.path,
  287. user_id,
  288. str(token)[:8],
  289. )
  290. else:
  291. try:
  292. expires_iso = datetime.fromtimestamp(entry.expires_at).isoformat()
  293. except Exception:
  294. expires_iso = str(entry.expires_at)
  295. logger.warning(
  296. "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",
  297. request.url.path,
  298. user_id,
  299. str(token)[:8],
  300. str(entry.token)[:8],
  301. expires_iso,
  302. datetime.utcnow().isoformat(),
  303. )
  304. raise HTTPException(status_code=401, detail={"code": SESSION_EXPIRED})
  305. async def optional_user_id(
  306. request: Request,
  307. header_user_id: Optional[str] = Header(None, alias="X-User-Id"),
  308. token: Optional[str] = Header(None, alias="X-User-Token"),
  309. ) -> Optional[str]:
  310. """可选鉴权:有且有效则返回 user_id,否则返回 None(用于页面访问等不强制登录的接口)"""
  311. if not header_user_id or not token:
  312. return None
  313. session_store = getattr(request.app.state, "session_store", None)
  314. if not session_store or not await session_store.validate(header_user_id, token, refresh=False):
  315. return None
  316. return header_user_id
  317. class AdminCreateEnergyGiftInboxRequest(BaseModel):
  318. title_zh: str = Field(..., min_length=1, max_length=200)
  319. title_en: str = Field(..., min_length=1, max_length=200)
  320. content_zh: str = Field(..., min_length=1, max_length=10000)
  321. content_en: str = Field(..., min_length=1, max_length=10000)
  322. energy_stone_amount: int = Field(..., gt=0, le=100000)
  323. user_ids: List[str] = Field(..., min_length=1)
  324. @field_validator("user_ids")
  325. @classmethod
  326. def validate_user_ids(cls, value: List[str]) -> List[str]:
  327. normalized = [str(v).strip() for v in value if str(v).strip()]
  328. if not normalized:
  329. raise ValueError("user_ids不能为空")
  330. return list(dict.fromkeys(normalized))
  331. class AdminCreateNewsInboxRequest(BaseModel):
  332. title_zh: str = Field(..., min_length=1, max_length=200)
  333. title_en: str = Field(..., min_length=1, max_length=200)
  334. content_zh: str = Field(..., min_length=1, max_length=10000)
  335. content_en: str = Field(..., min_length=1, max_length=10000)
  336. class AdminCreateLlmTaskRequest(BaseModel):
  337. task_name: str = Field(..., min_length=1, max_length=255)
  338. task_prompt: str = Field(..., min_length=1, max_length=65535)
  339. lumi_first_line: str = Field(..., min_length=1, max_length=2000)
  340. class AdminUpdateLlmTaskRequest(AdminCreateLlmTaskRequest):
  341. pass
  342. @app.get("/")
  343. async def root():
  344. """健康检查"""
  345. return {"status": "ok", "message": "Lyrria Backend API"}
  346. @app.post("/api/user-events")
  347. async def create_user_event(
  348. http_request: Request,
  349. auth_user_id: str = Depends(require_user_session_from_headers),
  350. ) -> Dict[str, Any]:
  351. """
  352. 前端上报单条用户事件
  353. - 主要用于页面进入、按钮点击等埋点
  354. - event_name 必须遵循 .cursorrules 中的“埋点事件命名规范”
  355. - 从 body 手动读取并校验,避免与依赖中的 Request 冲突导致 body 被当成 query
  356. """
  357. try:
  358. body = await http_request.json()
  359. except Exception as e:
  360. logger.warning("user_events 读取 body 失败: %s", e)
  361. raise HTTPException(status_code=422, detail=[{"type": "value_error", "loc": ("body",), "msg": "Invalid JSON body"}])
  362. try:
  363. payload = UserEventPayload.model_validate(body)
  364. except PydanticValidationError as val_err:
  365. logger.warning("user_events 校验失败: %s", val_err.errors())
  366. raise RequestValidationError(val_err.errors()) from val_err
  367. if payload.user_id != auth_user_id:
  368. raise HTTPException(status_code=401, detail={"code": UNAUTHORIZED_USER_MISMATCH})
  369. try:
  370. ok = await user_service.save_user_event(payload.model_dump())
  371. if not ok:
  372. logger.warning("user_events 落库返回 False: event_id=%s, event_name=%s", payload.event_id, payload.event_name)
  373. raise HTTPException(status_code=500, detail="failed_to_save_user_event")
  374. logger.info("user_events 已写入: event_name=%s, user_id=%s", payload.event_name, payload.user_id)
  375. return {"success": True}
  376. except HTTPException:
  377. raise
  378. except Exception as e: # noqa: BLE001
  379. logger.error(f"❌ 保存用户事件失败: event_id={payload.event_id}, error={e}")
  380. raise HTTPException(status_code=500, detail="failed_to_save_user_event") from e
  381. class CreateUserRequest(BaseModel):
  382. """创建用户请求"""
  383. username: str = Field("", description="用户名/昵称,可为空字符串")
  384. bot_name: Optional[str] = Field(None, description="机器人名字,可选")
  385. user_segment: str = Field("individual", description="用户分群:individual/school/internal等")
  386. org_id: Optional[str] = Field(None, description="组织ID(学校/机构用户)")
  387. is_test_user: bool = Field(False, description="是否测试账号")
  388. region: Optional[str] = Field(None, description="国家/地区代码,如 CN/US")
  389. class UpdateUsernameRequest(BaseModel):
  390. """更新用户名请求"""
  391. username: str
  392. class UpdateBotNameRequest(BaseModel):
  393. """更新机器人名字请求"""
  394. bot_name: str
  395. class UpdatePreferredLanguageRequest(BaseModel):
  396. """更新用户界面语言偏好(zh/en)"""
  397. preferred_language: Literal["zh", "en"]
  398. class UpdateBackgroundImageIdRequest(BaseModel):
  399. """更新背景图片ID请求"""
  400. background_image_id: str
  401. class UpdateChatbotBackdropRequest(BaseModel):
  402. """切换 ChatBot 幕布请求"""
  403. backdrop_item_id: int
  404. class ChatbotFurnitureActionRequest(BaseModel):
  405. """装备/卸下 ChatBot 家具请求"""
  406. item_id: int
  407. class ChatbotShopPurchaseRequest(BaseModel):
  408. """ChatBot 商店购买家具/幕布"""
  409. item_id: int
  410. class ChatbotShopCartAddRequest(BaseModel):
  411. """ChatBot 商店加入购物车(家具/幕布)"""
  412. item_id: int
  413. class ChatbotShopCartRemoveRequest(BaseModel):
  414. """ChatBot 商店移除购物车商品(家具/幕布)"""
  415. item_id: int
  416. class ChatbotShopCartPurchaseRequest(BaseModel):
  417. """ChatBot 商店购买购物车全部商品(家具/幕布)"""
  418. pass
  419. def _floor_shop_price_energy(price: object) -> int:
  420. """与 energy_stone_service 一致:售价向下取整为整颗对话能量石。"""
  421. try:
  422. v = float(price or 0)
  423. except (TypeError, ValueError):
  424. return 0
  425. if v <= 0:
  426. return 0
  427. return int(math.floor(v))
  428. class UserEventPayload(BaseModel):
  429. """前端上报用户事件 payload(单条)"""
  430. event_id: str = Field(..., description="事件ID,客户端或服务端生成,需全局唯一")
  431. user_id: str = Field(..., description="用户ID(关联 users.user_id)")
  432. event_name: str = Field(..., description="事件代码,如 page.chatbot__enter(遵循埋点命名规范)")
  433. event_label: Optional[str] = Field(None, description="事件中文名称/描述")
  434. event_time: datetime = Field(..., description="事件发生时间(UTC)")
  435. session_id: Optional[str] = Field(None, description="会话ID,同一次前台停留共享")
  436. platform: str = Field(..., description="平台:ios/android/web")
  437. app_version: Optional[str] = Field(None, description="应用版本号,如 v0.1.x")
  438. timezone_offset_minutes: Optional[int] = Field(
  439. None,
  440. description="用户本地相对UTC的分钟偏移量(分钟)",
  441. )
  442. properties: Optional[Dict[str, Any]] = Field(
  443. None,
  444. description="扩展属性(page、entry、button_id、duration_seconds 等)",
  445. )
  446. @field_validator("event_time", mode="before")
  447. @classmethod
  448. def parse_event_time(cls, v: Any) -> datetime:
  449. """接受 ISO 字符串(含 Z 后缀),避免 422"""
  450. if isinstance(v, datetime):
  451. return v
  452. if isinstance(v, str):
  453. s = v.strip().replace("Z", "+00:00")
  454. return datetime.fromisoformat(s)
  455. raise ValueError("event_time 需为 datetime 或 ISO 字符串")
  456. class RegisterPhoneRequest(BaseModel):
  457. """手机号注册请求"""
  458. area_code: Optional[str] = None
  459. phone: str
  460. username: str = ""
  461. user_id: Optional[str] = None # 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
  462. preferred_language: Optional[str] = None # "zh" | "en"
  463. class RegisterEmailRequest(BaseModel):
  464. """邮箱注册请求"""
  465. email: str
  466. username: str = ""
  467. user_id: Optional[str] = None # 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
  468. preferred_language: Optional[str] = None # "zh" | "en"
  469. class LoginWechatRequest(BaseModel):
  470. """微信登录请求"""
  471. openid: str
  472. username: str = ""
  473. class LoginQQRequest(BaseModel):
  474. """QQ登录请求"""
  475. openid: str
  476. username: str = ""
  477. class LoginPhoneRequest(BaseModel):
  478. """手机号登录请求"""
  479. area_code: Optional[str] = None
  480. phone: str
  481. preferred_language: Optional[str] = None # "zh" | "en"
  482. class LoginEmailRequest(BaseModel):
  483. """邮箱登录请求"""
  484. email: str
  485. preferred_language: Optional[str] = None # "zh" | "en"
  486. class EmailContinueRequest(BaseModel):
  487. """邮箱 Continue:特殊 A/B 用户免验证码登录,其余用户发验证码"""
  488. email: str
  489. preferred_language: Optional[str] = None # "zh" | "en"
  490. opted_out: Optional[bool] = None # 是否退出早期共创社区
  491. class SendCodeRequest(BaseModel):
  492. """发送验证码请求"""
  493. area_code: Optional[str] = None # 手机区号(如 +86)
  494. phone: Optional[str] = None
  495. email: Optional[str] = None
  496. send_type: str # "domestic_sms", "international_sms", "email"
  497. class VerifyCodeRequest(BaseModel):
  498. """验证验证码请求"""
  499. phone: Optional[str] = None
  500. email: Optional[str] = None
  501. code: str
  502. send_type: str # "domestic_sms", "international_sms", "email"
  503. class VerifyAndAuthRequest(BaseModel):
  504. """V2.0 验证码通过后统一登录/注册请求"""
  505. type: str # "email" | "phone"
  506. code: str
  507. email: Optional[str] = None
  508. phone: Optional[str] = None
  509. area_code: Optional[str] = None
  510. preferred_language: Optional[str] = None # "zh" | "en"
  511. class CommunityChoiceRequest(BaseModel):
  512. """V2.0 退出早期共创社区选项"""
  513. opted_out: bool
  514. class EarlyCommunityRequest(BaseModel):
  515. """V2.0 早期共创邮箱(可选)"""
  516. email: Optional[str] = None
  517. class UserUIStateResponse(BaseModel):
  518. """用户前端可视化状态"""
  519. user_id: str
  520. current_page: Optional[str] = None
  521. visited_routes: List[str] = []
  522. has_shown_tap_to_chat: bool = False
  523. has_shown_cancel_tooltip: bool = False
  524. has_shown_send_tooltip: bool = False
  525. has_shown_progress_bar_tooltip: bool = False
  526. has_shown_mission_stone_modal: bool = False
  527. has_shown_conversation_stone_modal: bool = False
  528. has_clicked_mission_stone: bool = False
  529. has_clicked_conversation_stone: bool = False
  530. subtitle_on: bool = False
  531. avatar_c_unlocked: bool = False
  532. has_shared_avatar: bool = False
  533. current_avatar_id: Optional[str] = None
  534. sfx_enabled: bool = True
  535. bgm_enabled: bool = True
  536. class PageVisitRequest(BaseModel):
  537. """页面访问上报"""
  538. path: str
  539. class UpdateUserUIStateRequest(BaseModel):
  540. """部分更新用户前端可视化状态"""
  541. current_page: Optional[str] = None
  542. visited_routes: Optional[List[str]] = None
  543. has_shown_tap_to_chat: Optional[bool] = None
  544. has_shown_cancel_tooltip: Optional[bool] = None
  545. has_shown_send_tooltip: Optional[bool] = None
  546. has_shown_progress_bar_tooltip: Optional[bool] = None
  547. has_shown_mission_stone_modal: Optional[bool] = None
  548. has_shown_conversation_stone_modal: Optional[bool] = None
  549. has_clicked_mission_stone: Optional[bool] = None
  550. has_clicked_conversation_stone: Optional[bool] = None
  551. has_shown_first_shop_tooltip: Optional[bool] = None
  552. has_clicked_shop_in_settings: Optional[bool] = None
  553. avatar_c_unlocked: Optional[bool] = None
  554. has_shared_avatar: Optional[bool] = None
  555. current_avatar_id: Optional[str] = None
  556. subtitle_on: Optional[bool] = None
  557. sfx_enabled: Optional[bool] = None
  558. bgm_enabled: Optional[bool] = None
  559. # 分享图片响应模型
  560. class ShareImageItem(BaseModel):
  561. id: str
  562. url: str
  563. title: Optional[str] = None
  564. sort_order: Optional[int] = None
  565. is_active: Optional[bool] = None
  566. SHARE_IMAGES: List[ShareImageItem] = [
  567. ShareImageItem(id="share-1", url="https://www.lyrria.com/img/share-1.png"),
  568. ShareImageItem(id="share-2", url="https://www.lyrria.com/img/share-2.png"),
  569. ]
  570. class AvatarGifSet(BaseModel):
  571. default: str
  572. positive: str
  573. negative: str
  574. talking: str
  575. async def _generate_session_token(user_id: str) -> Dict[str, Any]:
  576. """
  577. 为指定用户生成会话 token(复用 init_session 的逻辑)
  578. Args:
  579. user_id: 用户ID
  580. Returns:
  581. 包含 token 和 expires_at 的字典
  582. """
  583. session_store = getattr(app.state, "session_store", None)
  584. token = str(uuid.uuid4())
  585. expires_at = datetime.utcnow() + timedelta(seconds=settings.SESSION_TOKEN_TTL_SECONDS)
  586. if session_store:
  587. await session_store.set_current_token(user_id, token, settings.SESSION_TOKEN_TTL_SECONDS)
  588. # 主动踢掉旧连接
  589. await session_store.invalidate_connection(user_id)
  590. return {
  591. "token": token,
  592. "expires_at": expires_at.isoformat() + "Z",
  593. }
  594. class SessionInitRequest(BaseModel):
  595. user_id: Optional[str] = None
  596. username: Optional[str] = ""
  597. AVATAR_GIF_CONFIG = {
  598. "avatar_default": AvatarGifSet(
  599. default="https://www.lyrria.com/img/01-Default.gif",
  600. positive="https://www.lyrria.com/img/04-Positive-emotion.gif",
  601. negative="https://www.lyrria.com/img/05-Negative-emotion.gif",
  602. talking="https://www.lyrria.com/img/06-talking.gif",
  603. ),
  604. "avatar_registered_cap": AvatarGifSet(
  605. default="https://www.lyrria.com/img/01-Default-Registered.gif",
  606. positive="https://www.lyrria.com/img/04-Positive-emotion-Registered.gif",
  607. negative="https://www.lyrria.com/img/05-Negative-emotion-Registered.gif",
  608. talking="https://www.lyrria.com/img/06-talking-Registered.gif",
  609. ),
  610. }
  611. @app.post("/api/users/create")
  612. async def create_user(request: CreateUserRequest):
  613. """
  614. 创建游客用户
  615. Returns:
  616. {
  617. "user_id": "uuid",
  618. "username": "",
  619. "user_type": "guest",
  620. "bot_name": "机器人名字(可选)"
  621. }
  622. """
  623. try:
  624. user = await user_service.create_guest_user(
  625. username=request.username,
  626. bot_name=request.bot_name,
  627. user_segment=request.user_segment,
  628. org_id=request.org_id,
  629. is_test_user=request.is_test_user,
  630. region=request.region,
  631. )
  632. return {
  633. "success": True,
  634. "data": user
  635. }
  636. except Exception as e:
  637. logger.exception("创建用户失败")
  638. raise HTTPException(status_code=500, detail={"code": CREATE_USER_FAILED})
  639. @app.post("/api/session/init")
  640. async def init_session(request: SessionInitRequest):
  641. """
  642. 初始化或刷新用户会话,返回 user_id + 会话 token(单用户单活)
  643. """
  644. try:
  645. session_store = getattr(app.state, "session_store", None)
  646. # 1. 获取或创建用户
  647. user_id = request.user_id
  648. user_payload: Optional[Dict[str, Any]] = None
  649. if not user_id:
  650. user_payload = await user_service.create_guest_user(
  651. username=request.username or ""
  652. )
  653. user_id = user_payload["user_id"]
  654. else:
  655. # 确认用户存在
  656. user_payload = await user_service.get_user(user_id)
  657. if not user_payload:
  658. raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND})
  659. # 2. 签发新 token(覆盖旧 token,单活)
  660. token = str(uuid.uuid4())
  661. expires_at = datetime.utcnow() + timedelta(seconds=settings.SESSION_TOKEN_TTL_SECONDS)
  662. if session_store:
  663. await session_store.set_current_token(user_id, token, settings.SESSION_TOKEN_TTL_SECONDS)
  664. # 主动踢掉旧连接
  665. await session_store.invalidate_connection(user_id)
  666. data = {
  667. "user_id": user_id,
  668. "token": token,
  669. "expires_at": expires_at.isoformat() + "Z",
  670. }
  671. # 如果刚创建用户,透传部分信息,兼容前端缓存逻辑
  672. if user_payload:
  673. data.update(
  674. {
  675. "username": user_payload.get("username"),
  676. "user_level": user_payload.get("user_level"),
  677. "current_background_image_id": user_payload.get(
  678. "current_background_image_id"
  679. ),
  680. "current_background_image_metadata": user_payload.get(
  681. "current_background_image_metadata"
  682. ),
  683. }
  684. )
  685. return {"success": True, "data": data}
  686. except HTTPException:
  687. raise
  688. except Exception as e:
  689. logger.exception("初始化会话失败")
  690. raise HTTPException(status_code=500, detail={"code": INIT_SESSION_FAILED})
  691. @app.get("/api/users/{user_id}")
  692. async def get_user(user_id: str, _: None = Depends(require_user_session)):
  693. """
  694. 获取用户信息
  695. Args:
  696. user_id: 用户ID
  697. Returns:
  698. {
  699. "success": true,
  700. "data": {
  701. "user_id": "uuid",
  702. "username": "",
  703. "user_type": "guest",
  704. "bot_name": "机器人名字(可选)",
  705. ...
  706. }
  707. }
  708. """
  709. try:
  710. user = await user_service.get_user(user_id)
  711. if user:
  712. return {
  713. "success": True,
  714. "data": user
  715. }
  716. else:
  717. raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND})
  718. except HTTPException:
  719. raise
  720. except Exception as e:
  721. logger.exception("获取用户信息失败")
  722. raise HTTPException(status_code=500, detail={"code": GET_USER_INFO_FAILED})
  723. @app.get("/api/users/{user_id}/energy-stones/count")
  724. async def get_energy_stone_counts(user_id: str, _: None = Depends(require_user_session)):
  725. """
  726. 获取用户的能量石数量
  727. Args:
  728. user_id: 用户ID
  729. Returns:
  730. {
  731. "success": true,
  732. "data": {
  733. "mission_count": 0,
  734. "conversation_count": 0
  735. }
  736. }
  737. """
  738. try:
  739. if not energy_stone_service.db:
  740. await energy_stone_service.set_db_manager(db_manager)
  741. counts = await energy_stone_service.get_stone_counts(user_id)
  742. return {
  743. "success": True,
  744. "data": counts
  745. }
  746. except Exception as e:
  747. logger.exception("获取能量石数量失败")
  748. raise HTTPException(status_code=500, detail={"code": GET_ENERGY_STONES_FAILED})
  749. @app.get("/api/users/{user_id}/journal-latest-topic")
  750. async def get_journal_latest_topic(user_id: str, _: None = Depends(require_user_session)):
  751. """
  752. 个人页「日记本」左侧图:用户最近在 ChatBot 触发特殊情景话题的最新一条记录。
  753. 无记录时 data 为 null。
  754. """
  755. try:
  756. if not scenario_topic_service.db:
  757. await scenario_topic_service.set_db_manager(db_manager)
  758. latest = await scenario_topic_service.get_latest_log_for_journal(user_id)
  759. return {"success": True, "data": latest}
  760. except Exception:
  761. logger.exception("获取日记本最近情景话题失败")
  762. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  763. @app.get("/api/users/{user_id}/ab-llm-task")
  764. async def get_ab_llm_task(user_id: str, _: None = Depends(require_user_session)):
  765. """A/B 组实验用户:返回当前最新 LLM 任务名称(不含 prompt)。"""
  766. try:
  767. user = await user_service.get_user(user_id)
  768. if not user:
  769. raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND})
  770. if not is_special_ab_experiment_user(user):
  771. raise HTTPException(status_code=403, detail={"code": "FORBIDDEN"})
  772. task = await llm_task_service.get_latest_task_cached()
  773. if not task:
  774. return {"success": True, "data": None}
  775. return {"success": True, "data": {"task_name": task["task_name"]}}
  776. except HTTPException:
  777. raise
  778. except Exception:
  779. logger.exception("获取 A/B LLM 任务失败")
  780. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  781. def _get_tutorial_intro_text(step: int) -> str | None:
  782. """根据教程步骤返回对应的固定英文文案。"""
  783. mapping: Dict[int, str] = {
  784. 1: "Hi! Nice to meet you, my friend. Let me show you my home.",
  785. 3: "I’ve added captions so we can understand each other even better!",
  786. 5: "Tap the button and let's talk! How was your day",
  787. 9: "You can see your Energy Stones here.",
  788. 10: "You can also see our Bond Level here. It shows how close we are.",
  789. 11: "That's all! let's start our first chat!",
  790. }
  791. return mapping.get(step)
  792. @app.get("/api/tutorials/{step}/intro-audio")
  793. async def get_tutorial_intro_audio(
  794. step: int,
  795. _: str = Depends(require_user_session_from_headers),
  796. ):
  797. """
  798. 新手教程步骤:返回对应字幕文案的 TTS 音频。
  799. - 支持步骤:1 / 3 / 5 / 9 / 10 / 11
  800. - 文案与前端字幕保持一致(ChatBotStaticShell 中的英文文案)
  801. - 使用全局 ai_service 进行 TTS 合成,返回单段 Base64 PCM。
  802. """
  803. text = _get_tutorial_intro_text(step)
  804. if text is None:
  805. raise HTTPException(status_code=404, detail={"code": GET_CONFIG_FAILED})
  806. try:
  807. audio_chunks: list[bytes] = []
  808. async for chunk in ai_service.text_to_speech_stream(text, emotion="friendly"):
  809. audio_chunks.append(chunk)
  810. if not audio_chunks:
  811. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  812. audio_bytes = b"".join(audio_chunks)
  813. audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")
  814. return {
  815. "success": True,
  816. "data": {
  817. "audio": audio_base64,
  818. "sampleRate": 24000,
  819. "format": "pcm",
  820. "text": text,
  821. },
  822. }
  823. except HTTPException:
  824. raise
  825. except Exception as exc:
  826. logger.exception("生成教程步骤 TTS 音频失败: %s", exc)
  827. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  828. @app.get("/api/users/{user_id}/avatars")
  829. async def get_user_avatars(user_id: str, _: None = Depends(require_user_session)):
  830. """
  831. 获取用户已拥有的头像列表
  832. Args:
  833. user_id: 用户ID
  834. Returns:
  835. {
  836. "success": true,
  837. "data": {
  838. "avatar_ids": ["avatar_default", "avatar_registered_cap", ...]
  839. }
  840. }
  841. """
  842. try:
  843. async with db_manager.get_session() as session:
  844. result = await session.execute(
  845. select(UserAvatar.avatar_id).where(UserAvatar.user_id == user_id)
  846. )
  847. avatar_ids = [row[0] for row in result.fetchall()]
  848. return {
  849. "success": True,
  850. "data": {
  851. "avatar_ids": avatar_ids
  852. }
  853. }
  854. except Exception as e:
  855. logger.exception("获取用户头像信息失败")
  856. raise HTTPException(status_code=500, detail={"code": GET_AVATARS_FAILED})
  857. @app.get("/api/background-images")
  858. async def get_background_images():
  859. """
  860. 获取所有背景图片配置列表
  861. Returns:
  862. {
  863. "success": true,
  864. "data": [
  865. {
  866. "image_id": "default",
  867. "image_name": "默认背景",
  868. "image_url": "https://...",
  869. "thumbnail_url": "https://...",
  870. "unlock_type": "default",
  871. "display_order": 0,
  872. "is_active": true,
  873. ...
  874. },
  875. ...
  876. ]
  877. }
  878. """
  879. try:
  880. async with db_manager.get_session() as session:
  881. result = await session.execute(
  882. select(BackgroundImage).where(BackgroundImage.is_active == True).order_by(BackgroundImage.display_order)
  883. )
  884. background_images = result.scalars().all()
  885. return {
  886. "success": True,
  887. "data": [bg_image.to_dict() for bg_image in background_images]
  888. }
  889. except Exception as e:
  890. logger.error(f"获取背景图片配置失败: {e}")
  891. logger.exception("获取背景图片配置失败")
  892. raise HTTPException(status_code=500, detail={"code": GET_BG_CONFIG_FAILED})
  893. @app.get("/api/users/{user_id}/background-images")
  894. async def get_user_background_images(user_id: str, _: None = Depends(require_user_session)):
  895. """
  896. 获取用户已拥有的背景图片列表
  897. Args:
  898. user_id: 用户ID
  899. Returns:
  900. {
  901. "success": true,
  902. "data": {
  903. "image_ids": ["default", "happynewyear", ...]
  904. }
  905. }
  906. """
  907. try:
  908. async with db_manager.get_session() as session:
  909. result = await session.execute(
  910. select(UserBackgroundImage.image_id).where(UserBackgroundImage.user_id == user_id)
  911. )
  912. image_ids = [row[0] for row in result.fetchall()]
  913. return {
  914. "success": True,
  915. "data": {
  916. "image_ids": image_ids
  917. }
  918. }
  919. except Exception as e:
  920. logger.exception("获取用户背景图片信息失败")
  921. raise HTTPException(status_code=500, detail={"code": GET_USER_BG_IMAGES_FAILED})
  922. @app.get("/api/users/{user_id}/chatbot-layout")
  923. async def get_chatbot_layout(user_id: str, _: None = Depends(require_user_session)):
  924. """
  925. 获取用户 /chatbot 顶部装饰区布局(背景幕布 + 5x4 家具网格)
  926. 返回给前端用于动态渲染。
  927. """
  928. try:
  929. async with db_manager.get_session() as session:
  930. # 兼容:chatbot_layouts / user_chatbot_backdrops 表里的 user_id 可能是 BIGINT(users.id)
  931. # 也可能是 VARCHAR(users.user_id)。运行时探测信息架构来决定映射方式。
  932. async def _get_user_id_is_bigint(table_name: str) -> bool:
  933. res = await session.execute(
  934. text(
  935. """
  936. SELECT DATA_TYPE
  937. FROM information_schema.columns
  938. WHERE table_schema = :db
  939. AND table_name = :table
  940. AND column_name = 'user_id'
  941. """
  942. ),
  943. {"db": settings.MYSQL_DATABASE, "table": table_name},
  944. )
  945. row = res.mappings().first()
  946. data_type = (row or {}).get("DATA_TYPE") or ""
  947. return "bigint" in data_type.lower()
  948. layout_user_id_is_bigint = await _get_user_id_is_bigint("chatbot_layouts")
  949. backdrop_user_id_is_bigint = await _get_user_id_is_bigint("user_chatbot_backdrops")
  950. layout_user_id_where_expr = (
  951. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  952. if layout_user_id_is_bigint
  953. else ":user_id"
  954. )
  955. backdrop_user_id_where_expr = (
  956. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  957. if backdrop_user_id_is_bigint
  958. else ":user_id"
  959. )
  960. layout_row_res = await session.execute(
  961. text(
  962. f"""
  963. SELECT
  964. id,
  965. grid_rows,
  966. grid_cols,
  967. cell_width,
  968. cell_height,
  969. decor_area_width,
  970. decor_area_height,
  971. updated_at
  972. FROM chatbot_layouts l
  973. WHERE l.user_id = {layout_user_id_where_expr}
  974. ORDER BY l.updated_at DESC, l.id DESC
  975. LIMIT 1
  976. """
  977. ),
  978. {"user_id": user_id},
  979. )
  980. layout_row = layout_row_res.mappings().first()
  981. backdrop_res = await session.execute(
  982. text(
  983. f"""
  984. SELECT
  985. ub.z_index,
  986. ub.backdrop_item_id,
  987. COALESCE(NULLIF(TRIM(si.render_image_name), ''), si.image_name) AS image_name
  988. FROM user_chatbot_backdrops ub
  989. JOIN shop_items si
  990. ON ub.backdrop_item_id = si.id
  991. WHERE ub.user_id = {backdrop_user_id_where_expr}
  992. ORDER BY ub.z_index DESC, ub.id DESC
  993. LIMIT 1
  994. """
  995. ),
  996. {"user_id": user_id},
  997. )
  998. backdrop_row = backdrop_res.mappings().first()
  999. current_backdrop_item_id = (
  1000. int(backdrop_row["backdrop_item_id"])
  1001. if backdrop_row and backdrop_row.get("backdrop_item_id") is not None
  1002. else None
  1003. )
  1004. async def _table_exists(table_name: str) -> bool:
  1005. res = await session.execute(
  1006. text(
  1007. """
  1008. SELECT COUNT(*) AS c
  1009. FROM information_schema.tables
  1010. WHERE table_schema = :db
  1011. AND table_name = :table
  1012. """
  1013. ),
  1014. {"db": settings.MYSQL_DATABASE, "table": table_name},
  1015. )
  1016. row = res.mappings().first()
  1017. return bool(row and int(row.get("c") or 0) > 0)
  1018. items: list[dict[str, Any]] = []
  1019. slots: list[dict[str, Any]] = []
  1020. has_new_slot_tables = (
  1021. await _table_exists("user_chatbot_slot_placements")
  1022. and await _table_exists("chatbot_backdrop_slots")
  1023. )
  1024. if has_new_slot_tables and current_backdrop_item_id is not None:
  1025. slots_res = await session.execute(
  1026. text(
  1027. """
  1028. SELECT
  1029. s.id,
  1030. s.slot_code,
  1031. s.placement_type,
  1032. s.start_row,
  1033. s.start_col,
  1034. s.size_rows,
  1035. s.size_cols,
  1036. COALESCE(s.z_index, 0) AS z_index,
  1037. s.render_mode,
  1038. s.offset_x,
  1039. s.offset_y,
  1040. s.pixel_width,
  1041. s.pixel_height
  1042. FROM chatbot_backdrop_slots s
  1043. WHERE s.backdrop_item_id = :backdrop_item_id
  1044. AND s.is_active = 1
  1045. ORDER BY COALESCE(s.z_index, 0) ASC, s.id ASC
  1046. """
  1047. ),
  1048. {"backdrop_item_id": current_backdrop_item_id},
  1049. )
  1050. slots = [dict(r) for r in slots_res.mappings().all()]
  1051. slot_items_res = await session.execute(
  1052. text(
  1053. f"""
  1054. SELECT
  1055. usp.id,
  1056. usp.slot_code AS slot_code,
  1057. s.placement_type,
  1058. s.start_row,
  1059. s.start_col,
  1060. s.size_rows,
  1061. s.size_cols,
  1062. COALESCE(usp.z_index, s.z_index, si.default_z_index, 0) AS z_index,
  1063. usp.image_name_snapshot,
  1064. usp.item_id,
  1065. COALESCE(si.scene_anchor_type, 'none') AS scene_anchor_type,
  1066. s.render_mode,
  1067. s.offset_x,
  1068. s.offset_y,
  1069. s.pixel_width,
  1070. s.pixel_height
  1071. FROM user_chatbot_slot_placements usp
  1072. JOIN chatbot_backdrop_slots s
  1073. ON s.backdrop_item_id = usp.backdrop_item_id
  1074. AND s.slot_code = usp.slot_code
  1075. AND s.is_active = 1
  1076. LEFT JOIN shop_items si ON si.id = usp.item_id
  1077. WHERE usp.user_id = {layout_user_id_where_expr}
  1078. AND usp.backdrop_item_id = :backdrop_item_id
  1079. ORDER BY COALESCE(usp.z_index, s.z_index, si.default_z_index, 0) ASC, usp.id ASC
  1080. """
  1081. ),
  1082. {"user_id": user_id, "backdrop_item_id": current_backdrop_item_id},
  1083. )
  1084. items = [dict(r) for r in slot_items_res.mappings().all()]
  1085. # 兼容旧模型(灰度期间):新槽位数据为空时回退旧的 chatbot_layout_items
  1086. if not items and layout_row and layout_row.get("id") is not None:
  1087. layout_id = layout_row["id"]
  1088. items_res = await session.execute(
  1089. text(
  1090. """
  1091. SELECT
  1092. li.id,
  1093. li.placement_type,
  1094. li.start_row,
  1095. li.start_col,
  1096. li.size_rows,
  1097. li.size_cols,
  1098. li.z_index,
  1099. li.image_name_snapshot,
  1100. li.item_id,
  1101. COALESCE(si.scene_anchor_type, 'none') AS scene_anchor_type,
  1102. 'grid' AS render_mode,
  1103. 0 AS offset_x,
  1104. 0 AS offset_y,
  1105. NULL AS pixel_width,
  1106. NULL AS pixel_height
  1107. FROM chatbot_layout_items li
  1108. LEFT JOIN shop_items si ON si.id = li.item_id
  1109. WHERE li.layout_id = :layout_id
  1110. ORDER BY li.z_index ASC, li.id ASC
  1111. """
  1112. ),
  1113. {"layout_id": layout_id},
  1114. )
  1115. items = [dict(r) for r in items_res.mappings().all()]
  1116. data: dict[str, Any] = {
  1117. "grid_rows": int(layout_row.get("grid_rows")) if layout_row else 5,
  1118. "grid_cols": int(layout_row.get("grid_cols")) if layout_row else 4,
  1119. "cell_width": int(layout_row.get("cell_width")) if layout_row else 96,
  1120. "cell_height": int(layout_row.get("cell_height")) if layout_row else 96,
  1121. "decor_area_width": int(layout_row.get("decor_area_width")) if layout_row else 393,
  1122. "decor_area_height": int(layout_row.get("decor_area_height")) if layout_row else 482,
  1123. "backdrop": None,
  1124. "items": items,
  1125. "slots": slots,
  1126. }
  1127. if backdrop_row and backdrop_row.get("image_name"):
  1128. data["backdrop"] = {
  1129. "image_name": backdrop_row["image_name"],
  1130. "z_index": int(backdrop_row.get("z_index") or 0),
  1131. }
  1132. return {"success": True, "data": data}
  1133. except Exception as exc: # noqa: BLE001
  1134. logger.exception("获取用户 /chatbot 布局失败: %s", exc)
  1135. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  1136. async def _mysql_user_id_column_is_bigint(session, table_name: str) -> bool:
  1137. res = await session.execute(
  1138. text(
  1139. """
  1140. SELECT DATA_TYPE
  1141. FROM information_schema.columns
  1142. WHERE table_schema = :db
  1143. AND table_name = :table
  1144. AND column_name = 'user_id'
  1145. """
  1146. ),
  1147. {"db": settings.MYSQL_DATABASE, "table": table_name},
  1148. )
  1149. row = res.mappings().first()
  1150. data_type = (row or {}).get("DATA_TYPE") or ""
  1151. return "bigint" in str(data_type).lower()
  1152. async def _chatbot_in_use_item_ids(session, user_id: str) -> set[int]:
  1153. """当前幕布 + 槽位/布局中已装备的 shop_items.id 集合。"""
  1154. layout_user_id_expr = (
  1155. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  1156. if await _mysql_user_id_column_is_bigint(session, "chatbot_layouts")
  1157. else ":user_id"
  1158. )
  1159. backdrop_user_id_expr = (
  1160. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  1161. if await _mysql_user_id_column_is_bigint(session, "user_chatbot_backdrops")
  1162. else ":user_id"
  1163. )
  1164. current_backdrop_res = await session.execute(
  1165. text(
  1166. f"""
  1167. SELECT ub.backdrop_item_id
  1168. FROM user_chatbot_backdrops ub
  1169. WHERE ub.user_id = {backdrop_user_id_expr}
  1170. ORDER BY ub.updated_at DESC, ub.id DESC
  1171. LIMIT 1
  1172. """
  1173. ),
  1174. {"user_id": user_id},
  1175. )
  1176. current_backdrop_row = current_backdrop_res.mappings().first()
  1177. current_backdrop_item_id = (
  1178. int(current_backdrop_row["backdrop_item_id"])
  1179. if current_backdrop_row and current_backdrop_row.get("backdrop_item_id") is not None
  1180. else None
  1181. )
  1182. in_use_item_ids: set[int] = set()
  1183. if current_backdrop_item_id is not None:
  1184. in_use_item_ids.add(current_backdrop_item_id)
  1185. slot_table_res = await session.execute(
  1186. text(
  1187. """
  1188. SELECT COUNT(*) AS c
  1189. FROM information_schema.tables
  1190. WHERE table_schema = :db
  1191. AND table_name = 'user_chatbot_slot_placements'
  1192. """
  1193. ),
  1194. {"db": settings.MYSQL_DATABASE},
  1195. )
  1196. has_slot_table = int((slot_table_res.mappings().first() or {}).get("c") or 0) > 0
  1197. if has_slot_table and current_backdrop_item_id is not None:
  1198. slot_items_res = await session.execute(
  1199. text(
  1200. f"""
  1201. SELECT DISTINCT usp.item_id
  1202. FROM user_chatbot_slot_placements usp
  1203. WHERE usp.user_id = {layout_user_id_expr}
  1204. AND usp.backdrop_item_id = :backdrop_item_id
  1205. AND usp.item_id IS NOT NULL
  1206. """
  1207. ),
  1208. {"user_id": user_id, "backdrop_item_id": current_backdrop_item_id},
  1209. )
  1210. for row in slot_items_res.mappings().all():
  1211. iid = row.get("item_id")
  1212. if iid is not None:
  1213. in_use_item_ids.add(int(iid))
  1214. else:
  1215. latest_layout_res = await session.execute(
  1216. text(
  1217. f"""
  1218. SELECT l.id
  1219. FROM chatbot_layouts l
  1220. WHERE l.user_id = {layout_user_id_expr}
  1221. ORDER BY l.updated_at DESC, l.id DESC
  1222. LIMIT 1
  1223. """
  1224. ),
  1225. {"user_id": user_id},
  1226. )
  1227. latest_layout_row = latest_layout_res.mappings().first()
  1228. if latest_layout_row and latest_layout_row.get("id") is not None:
  1229. layout_item_res = await session.execute(
  1230. text(
  1231. """
  1232. SELECT DISTINCT li.item_id
  1233. FROM chatbot_layout_items li
  1234. WHERE li.layout_id = :layout_id
  1235. AND li.item_id IS NOT NULL
  1236. """
  1237. ),
  1238. {"layout_id": int(latest_layout_row["id"])},
  1239. )
  1240. for row in layout_item_res.mappings().all():
  1241. iid = row.get("item_id")
  1242. if iid is not None:
  1243. in_use_item_ids.add(int(iid))
  1244. return in_use_item_ids
  1245. @app.get("/api/users/{user_id}/chatbot-owned-items")
  1246. async def get_chatbot_owned_items(user_id: str, _: None = Depends(require_user_session)):
  1247. """获取用户已拥有的 ChatBot 商品(家具 + 幕布),用于前端背包展示。"""
  1248. try:
  1249. async with db_manager.get_session() as session:
  1250. assets_user_id_expr = (
  1251. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  1252. if await _mysql_user_id_column_is_bigint(session, "user_assets")
  1253. else ":user_id"
  1254. )
  1255. in_use_item_ids = await _chatbot_in_use_item_ids(session, user_id)
  1256. owned_res = await session.execute(
  1257. text(
  1258. f"""
  1259. SELECT
  1260. si.id,
  1261. si.item_code,
  1262. si.name,
  1263. si.name_zh,
  1264. si.name_en,
  1265. si.has_special_scene_trigger,
  1266. si.image_name,
  1267. si.render_image_name,
  1268. si.item_type,
  1269. si.biz_type,
  1270. si.sort_order
  1271. FROM user_assets ua
  1272. JOIN shop_items si
  1273. ON si.id = ua.item_id
  1274. WHERE ua.user_id = {assets_user_id_expr}
  1275. AND ua.owned_qty > 0
  1276. AND si.is_active = 1
  1277. AND si.item_type IN ('furniture', 'backdrop')
  1278. ORDER BY si.sort_order ASC, si.id ASC
  1279. """
  1280. ),
  1281. {"user_id": user_id},
  1282. )
  1283. rows = owned_res.mappings().all()
  1284. return {
  1285. "success": True,
  1286. "data": [
  1287. {
  1288. "item_id": int(r["id"]),
  1289. "item_code": r.get("item_code"),
  1290. "name": r.get("name"),
  1291. "name_zh": r.get("name_zh"),
  1292. "name_en": r.get("name_en"),
  1293. "has_special_scene_trigger": int(r.get("has_special_scene_trigger") or 0),
  1294. "image_name": r.get("image_name"),
  1295. "render_image_name": r.get("render_image_name"),
  1296. "item_type": r.get("item_type"),
  1297. "biz_type": r.get("biz_type"),
  1298. "is_in_use": int(r["id"]) in in_use_item_ids,
  1299. }
  1300. for r in rows
  1301. ],
  1302. }
  1303. except Exception as exc: # noqa: BLE001
  1304. logger.exception("获取用户已购商品失败: %s", exc)
  1305. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  1306. @app.get("/api/users/{user_id}/chatbot-shop-catalog")
  1307. async def get_chatbot_shop_catalog(user_id: str, _: None = Depends(require_user_session)):
  1308. """上架中的家具 + 幕布全库(含是否已拥有、是否当前使用中),用于 Lumi 商店等场景。"""
  1309. try:
  1310. async with db_manager.get_session() as session:
  1311. assets_user_id_expr = (
  1312. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  1313. if await _mysql_user_id_column_is_bigint(session, "user_assets")
  1314. else ":user_id"
  1315. )
  1316. in_use_item_ids = await _chatbot_in_use_item_ids(session, user_id)
  1317. catalog_res = await session.execute(
  1318. text(
  1319. f"""
  1320. SELECT
  1321. si.id,
  1322. si.item_code,
  1323. si.name,
  1324. si.name_zh,
  1325. si.name_en,
  1326. si.has_special_scene_trigger,
  1327. si.image_name,
  1328. si.render_image_name,
  1329. si.item_type,
  1330. si.biz_type,
  1331. si.sort_order,
  1332. si.price_energy,
  1333. si.max_per_user,
  1334. si.repeat_purchase_mode,
  1335. COALESCE(ua.owned_qty, 0) AS owned_qty,
  1336. CASE
  1337. WHEN COALESCE(ua.owned_qty, 0) > 0 THEN 1
  1338. ELSE 0
  1339. END AS is_owned
  1340. FROM shop_items si
  1341. LEFT JOIN user_assets ua
  1342. ON ua.item_id = si.id
  1343. AND ua.user_id = {assets_user_id_expr}
  1344. WHERE si.is_active = 1
  1345. AND si.item_type IN ('furniture', 'backdrop')
  1346. ORDER BY si.sort_order ASC, si.id ASC
  1347. """
  1348. ),
  1349. {"user_id": user_id},
  1350. )
  1351. rows = catalog_res.mappings().all()
  1352. return {
  1353. "success": True,
  1354. "data": [
  1355. {
  1356. "item_id": int(r["id"]),
  1357. "item_code": r.get("item_code"),
  1358. "name": r.get("name"),
  1359. "name_zh": r.get("name_zh"),
  1360. "name_en": r.get("name_en"),
  1361. "has_special_scene_trigger": int(r.get("has_special_scene_trigger") or 0),
  1362. "image_name": r.get("image_name"),
  1363. "render_image_name": r.get("render_image_name"),
  1364. "item_type": r.get("item_type"),
  1365. "biz_type": r.get("biz_type"),
  1366. "is_in_use": int(r["id"]) in in_use_item_ids,
  1367. "is_owned": bool(int(r.get("is_owned") or 0)),
  1368. "price_energy": float(r.get("price_energy") or 0),
  1369. "max_per_user": (
  1370. int(r["max_per_user"])
  1371. if r.get("max_per_user") is not None
  1372. else None
  1373. ),
  1374. "owned_qty": int(r.get("owned_qty") or 0),
  1375. "repeat_purchase_mode": r.get("repeat_purchase_mode") or "single",
  1376. }
  1377. for r in rows
  1378. ],
  1379. }
  1380. except Exception as exc: # noqa: BLE001
  1381. logger.exception("获取 ChatBot 商品库失败: %s", exc)
  1382. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  1383. @app.get("/api/users/{user_id}/chatbot-shop-cart")
  1384. async def get_chatbot_shop_cart(user_id: str, _: None = Depends(require_user_session)):
  1385. """用户 ChatBot 商店购物车(家具/幕布)。"""
  1386. try:
  1387. async with db_manager.get_session() as session:
  1388. user_row = await session.execute(
  1389. text("SELECT id FROM users WHERE user_id = :user_id LIMIT 1"),
  1390. {"user_id": user_id},
  1391. )
  1392. ur = user_row.mappings().first()
  1393. if not ur:
  1394. raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND})
  1395. internal_uid = int(ur["id"])
  1396. cart_res = await session.execute(
  1397. text(
  1398. """
  1399. SELECT id FROM shopping_carts
  1400. WHERE user_id = :uid
  1401. ORDER BY id ASC
  1402. LIMIT 1
  1403. """
  1404. ),
  1405. {"uid": internal_uid, "user_id": user_id},
  1406. )
  1407. cart_row = cart_res.mappings().first()
  1408. if not cart_row:
  1409. return {"success": True, "data": {"items": [], "cart_total_quantity": 0}}
  1410. cart_id = int(cart_row["id"])
  1411. items_res = await session.execute(
  1412. text(
  1413. """
  1414. SELECT item_id, quantity, unit_price_energy, unit_price_crystal
  1415. FROM shopping_cart_items
  1416. WHERE cart_id = :cid
  1417. ORDER BY id ASC
  1418. """
  1419. ),
  1420. {"cid": cart_id},
  1421. )
  1422. rows = items_res.mappings().all()
  1423. items = [
  1424. {
  1425. "item_id": int(r["item_id"]),
  1426. "quantity": int(r.get("quantity") or 0),
  1427. "unit_price_energy": float(r.get("unit_price_energy") or 0),
  1428. "unit_price_crystal": float(r.get("unit_price_crystal") or 0),
  1429. }
  1430. for r in rows
  1431. ]
  1432. total_q = sum(i["quantity"] for i in items)
  1433. return {"success": True, "data": {"items": items, "cart_total_quantity": total_q}}
  1434. except HTTPException:
  1435. raise
  1436. except Exception as exc: # noqa: BLE001
  1437. logger.exception("获取 ChatBot 购物车失败: %s", exc)
  1438. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  1439. @app.post("/api/users/{user_id}/chatbot-shop-cart/add")
  1440. async def add_chatbot_shop_cart_item(
  1441. user_id: str,
  1442. request: ChatbotShopCartAddRequest,
  1443. _: None = Depends(require_user_session),
  1444. ):
  1445. """上架家具/幕布加入购物车;数量与已购合计不得超过 shop_items.max_per_user(若有限)。"""
  1446. try:
  1447. async with db_manager.get_session() as session:
  1448. assets_user_id_expr = (
  1449. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  1450. if await _mysql_user_id_column_is_bigint(session, "user_assets")
  1451. else ":user_id"
  1452. )
  1453. user_row = await session.execute(
  1454. text("SELECT id FROM users WHERE user_id = :user_id LIMIT 1"),
  1455. {"user_id": user_id},
  1456. )
  1457. ur = user_row.mappings().first()
  1458. if not ur:
  1459. raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND})
  1460. internal_uid = int(ur["id"])
  1461. item_res = await session.execute(
  1462. text(
  1463. """
  1464. SELECT
  1465. id,
  1466. price_energy,
  1467. price_crystal,
  1468. max_per_user,
  1469. repeat_purchase_mode,
  1470. is_active,
  1471. item_type
  1472. FROM shop_items
  1473. WHERE id = :item_id
  1474. LIMIT 1
  1475. """
  1476. ),
  1477. {"item_id": request.item_id},
  1478. )
  1479. item_row = item_res.mappings().first()
  1480. if not item_row or int(item_row.get("is_active") or 0) != 1:
  1481. raise HTTPException(status_code=404, detail={"code": SHOP_ITEM_NOT_FOUND})
  1482. if str(item_row.get("item_type") or "") not in ("furniture", "backdrop"):
  1483. raise HTTPException(status_code=404, detail={"code": SHOP_ITEM_NOT_FOUND})
  1484. owned_res = await session.execute(
  1485. text(
  1486. f"""
  1487. SELECT COALESCE(ua.owned_qty, 0) AS owned_qty
  1488. FROM shop_items si
  1489. LEFT JOIN user_assets ua
  1490. ON ua.item_id = si.id
  1491. AND ua.user_id = {assets_user_id_expr}
  1492. WHERE si.id = :item_id
  1493. LIMIT 1
  1494. """
  1495. ),
  1496. {"user_id": user_id, "item_id": request.item_id},
  1497. )
  1498. owned_qty = int((owned_res.mappings().first() or {}).get("owned_qty") or 0)
  1499. repeat_mode = str(item_row.get("repeat_purchase_mode") or "single")
  1500. if repeat_mode == "single" and owned_qty >= 1:
  1501. raise HTTPException(status_code=400, detail={"code": PURCHASE_LIMIT_REACHED})
  1502. cart_res = await session.execute(
  1503. text(
  1504. """
  1505. SELECT id FROM shopping_carts
  1506. WHERE user_id = :uid
  1507. ORDER BY id ASC
  1508. LIMIT 1
  1509. """
  1510. ),
  1511. {"uid": internal_uid},
  1512. )
  1513. cart_row = cart_res.mappings().first()
  1514. if not cart_row:
  1515. await session.execute(
  1516. text("INSERT INTO shopping_carts (user_id) VALUES (:uid)"),
  1517. {"uid": internal_uid},
  1518. )
  1519. oid_ins = await session.execute(text("SELECT LAST_INSERT_ID() AS cid"))
  1520. cart_id = int(oid_ins.scalar_one() or 0)
  1521. else:
  1522. cart_id = int(cart_row["id"])
  1523. line_res = await session.execute(
  1524. text(
  1525. """
  1526. SELECT id, quantity FROM shopping_cart_items
  1527. WHERE cart_id = :cid AND item_id = :item_id
  1528. LIMIT 1
  1529. """
  1530. ),
  1531. {"cid": cart_id, "item_id": request.item_id},
  1532. )
  1533. line = line_res.mappings().first()
  1534. current_cart_qty = int(line.get("quantity") or 0) if line else 0
  1535. new_cart_qty = current_cart_qty + 1
  1536. max_per = item_row.get("max_per_user")
  1537. max_per_int = int(max_per) if max_per is not None else None
  1538. if max_per_int is not None and owned_qty + new_cart_qty > max_per_int:
  1539. raise HTTPException(status_code=400, detail={"code": CART_QUANTITY_LIMIT_EXCEEDED})
  1540. try:
  1541. pe = Decimal(str(item_row.get("price_energy") or 0))
  1542. pc = Decimal(str(item_row.get("price_crystal") or 0))
  1543. except (ArithmeticError, ValueError, TypeError):
  1544. pe = Decimal("0")
  1545. pc = Decimal("0")
  1546. if line:
  1547. await session.execute(
  1548. text(
  1549. """
  1550. UPDATE shopping_cart_items
  1551. SET quantity = :q, updated_at = NOW()
  1552. WHERE id = :id
  1553. """
  1554. ),
  1555. {"q": new_cart_qty, "id": int(line["id"])},
  1556. )
  1557. else:
  1558. await session.execute(
  1559. text(
  1560. """
  1561. INSERT INTO shopping_cart_items
  1562. (cart_id, item_id, quantity, unit_price_energy, unit_price_crystal)
  1563. VALUES
  1564. (:cid, :iid, 1, :pe, :pc)
  1565. """
  1566. ),
  1567. {"cid": cart_id, "iid": int(request.item_id), "pe": pe, "pc": pc},
  1568. )
  1569. sum_res = await session.execute(
  1570. text(
  1571. """
  1572. SELECT COALESCE(SUM(quantity), 0) AS tq
  1573. FROM shopping_cart_items
  1574. WHERE cart_id = :cid
  1575. """
  1576. ),
  1577. {"cid": cart_id},
  1578. )
  1579. cart_total = int(sum_res.scalar_one() or 0)
  1580. await session.commit()
  1581. return {
  1582. "success": True,
  1583. "data": {
  1584. "item_id": int(request.item_id),
  1585. "item_quantity": new_cart_qty,
  1586. "cart_total_quantity": cart_total,
  1587. },
  1588. }
  1589. except HTTPException:
  1590. raise
  1591. except Exception as exc: # noqa: BLE001
  1592. logger.exception("ChatBot 购物车加购失败: %s", exc)
  1593. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  1594. @app.post("/api/users/{user_id}/chatbot-shop-cart/remove")
  1595. async def remove_chatbot_shop_cart_item(
  1596. user_id: str,
  1597. request: ChatbotShopCartRemoveRequest,
  1598. _: None = Depends(require_user_session),
  1599. ):
  1600. """移除购物车中的家具/幕布商品(删除该 item 的购物车行)"""
  1601. try:
  1602. async with db_manager.get_session() as session:
  1603. assets_user_id_expr = (
  1604. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  1605. if await _mysql_user_id_column_is_bigint(session, "user_assets")
  1606. else ":user_id"
  1607. )
  1608. user_row = await session.execute(
  1609. text("SELECT id FROM users WHERE user_id = :user_id LIMIT 1"),
  1610. {"user_id": user_id},
  1611. )
  1612. ur = user_row.mappings().first()
  1613. if not ur:
  1614. raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND})
  1615. internal_uid = int(ur["id"])
  1616. await session.execute(
  1617. text(
  1618. """
  1619. DELETE sci
  1620. FROM shopping_cart_items sci
  1621. INNER JOIN shopping_carts sc ON sc.id = sci.cart_id
  1622. WHERE sc.user_id = :uid
  1623. AND sci.item_id = :item_id
  1624. """
  1625. ),
  1626. {"uid": internal_uid, "item_id": int(request.item_id)},
  1627. )
  1628. sum_res = await session.execute(
  1629. text(
  1630. """
  1631. SELECT COALESCE(SUM(sci.quantity), 0) AS tq
  1632. FROM shopping_cart_items sci
  1633. INNER JOIN shopping_carts sc ON sc.id = sci.cart_id
  1634. WHERE sc.user_id = :uid
  1635. """
  1636. ),
  1637. {"uid": internal_uid},
  1638. )
  1639. cart_total = int(sum_res.scalar_one() or 0)
  1640. await session.commit()
  1641. return {
  1642. "success": True,
  1643. "data": {"item_id": int(request.item_id), "cart_total_quantity": cart_total},
  1644. }
  1645. except HTTPException:
  1646. raise
  1647. except Exception as exc: # noqa: BLE001
  1648. logger.exception("ChatBot 移除购物车失败: %s", exc)
  1649. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  1650. @app.post("/api/users/{user_id}/chatbot-shop-cart/purchase")
  1651. async def purchase_chatbot_shop_cart_item(
  1652. user_id: str,
  1653. request: ChatbotShopCartPurchaseRequest,
  1654. _: None = Depends(require_user_session),
  1655. ):
  1656. """购买购物车中的所有家具/幕布商品(一次性校验与扣费)"""
  1657. try:
  1658. async with db_manager.get_session() as session:
  1659. assets_user_id_expr = (
  1660. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  1661. if await _mysql_user_id_column_is_bigint(session, "user_assets")
  1662. else ":user_id"
  1663. )
  1664. user_row = await session.execute(
  1665. text("SELECT id FROM users WHERE user_id = :user_id LIMIT 1"),
  1666. {"user_id": user_id},
  1667. )
  1668. ur = user_row.mappings().first()
  1669. if not ur:
  1670. raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND})
  1671. internal_uid = int(ur["id"])
  1672. cart_res = await session.execute(
  1673. text(
  1674. """
  1675. SELECT id FROM shopping_carts
  1676. WHERE user_id = :uid
  1677. ORDER BY id ASC
  1678. LIMIT 1
  1679. """
  1680. ),
  1681. {"uid": internal_uid},
  1682. )
  1683. cart_row = cart_res.mappings().first()
  1684. if not cart_row:
  1685. return {"success": True, "data": {"total_quantity": 0, "total_cost_energy": 0}}
  1686. cart_id = int(cart_row["id"])
  1687. cart_lines_res = await session.execute(
  1688. text(
  1689. f"""
  1690. SELECT
  1691. sci.item_id,
  1692. sci.quantity AS cart_qty,
  1693. si.price_energy,
  1694. si.price_crystal,
  1695. si.max_per_user,
  1696. si.repeat_purchase_mode,
  1697. si.is_active,
  1698. si.item_type,
  1699. si.name,
  1700. si.image_name,
  1701. COALESCE(ua.owned_qty, 0) AS owned_qty
  1702. FROM shopping_cart_items sci
  1703. INNER JOIN shopping_carts sc ON sc.id = sci.cart_id
  1704. INNER JOIN shop_items si ON si.id = sci.item_id
  1705. LEFT JOIN user_assets ua
  1706. ON ua.item_id = si.id
  1707. AND ua.user_id = {assets_user_id_expr}
  1708. WHERE sc.user_id = :uid
  1709. ORDER BY sci.id ASC
  1710. """
  1711. ),
  1712. {"uid": internal_uid, "user_id": user_id},
  1713. )
  1714. cart_lines = cart_lines_res.mappings().all()
  1715. if not cart_lines:
  1716. return {"success": True, "data": {"total_quantity": 0, "total_cost_energy": 0}}
  1717. # 预校验:限购/重复模式 + 总价校验(在写入前完成,避免部分成功)
  1718. total_qty = 0
  1719. total_cost_energy = 0
  1720. for r in cart_lines:
  1721. item_id = int(r["item_id"])
  1722. cart_qty = int(r.get("cart_qty") or 0)
  1723. if cart_qty <= 0:
  1724. continue
  1725. if str(r.get("is_active") or 0) != "1":
  1726. raise HTTPException(status_code=404, detail={"code": SHOP_ITEM_NOT_FOUND})
  1727. if str(r.get("item_type") or "") not in ("furniture", "backdrop"):
  1728. raise HTTPException(status_code=404, detail={"code": SHOP_ITEM_NOT_FOUND})
  1729. owned_qty = int(r.get("owned_qty") or 0)
  1730. max_per = r.get("max_per_user")
  1731. max_per_int = int(max_per) if max_per is not None else None
  1732. if max_per_int is not None and owned_qty + cart_qty > max_per_int:
  1733. raise HTTPException(status_code=400, detail={"code": CART_QUANTITY_LIMIT_EXCEEDED})
  1734. repeat_mode = str(r.get("repeat_purchase_mode") or "single")
  1735. # repeat_purchase_mode=single:用户购买后即无法再购买
  1736. if repeat_mode == "single" and owned_qty + cart_qty > 1:
  1737. raise HTTPException(status_code=400, detail={"code": PURCHASE_LIMIT_REACHED})
  1738. unit_cost_energy = _floor_shop_price_energy(r.get("price_energy"))
  1739. total_qty += cart_qty
  1740. total_cost_energy += unit_cost_energy * cart_qty
  1741. # 校验余额并扣费
  1742. bal_res = await session.execute(
  1743. text(
  1744. """
  1745. SELECT id, conversation_energy_stone_total
  1746. FROM users
  1747. WHERE user_id = :user_id
  1748. LIMIT 1
  1749. """
  1750. ),
  1751. {"user_id": user_id},
  1752. )
  1753. bal_row = bal_res.mappings().first()
  1754. if not bal_row:
  1755. raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND})
  1756. balance = int(bal_row.get("conversation_energy_stone_total") or 0)
  1757. if total_cost_energy > balance:
  1758. raise HTTPException(status_code=400, detail={"code": INSUFFICIENT_CONVERSATION_ENERGY})
  1759. upd_res = await session.execute(
  1760. text(
  1761. """
  1762. UPDATE users
  1763. SET conversation_energy_stone_total = conversation_energy_stone_total - :cost
  1764. WHERE user_id = :user_id
  1765. AND conversation_energy_stone_total >= :cost
  1766. """
  1767. ),
  1768. {"cost": total_cost_energy, "user_id": user_id},
  1769. )
  1770. if (getattr(upd_res, "rowcount", None) or 0) < 1:
  1771. raise HTTPException(status_code=400, detail={"code": INSUFFICIENT_CONVERSATION_ENERGY})
  1772. # 写入订单 + 更新拥有量 + 清空购物车(仍在同一事务里)
  1773. await session.execute(
  1774. text(
  1775. """
  1776. INSERT INTO purchase_orders
  1777. (user_id, cart_id, status, total_quantity, total_cost_energy, total_cost_crystal, completed_at)
  1778. VALUES
  1779. (:uid, :cart_id, 'completed', :total_quantity, :tce, 0.00, NOW())
  1780. """
  1781. ),
  1782. {
  1783. "uid": internal_uid,
  1784. "cart_id": cart_id,
  1785. "total_quantity": total_qty,
  1786. "tce": Decimal(str(total_cost_energy)),
  1787. },
  1788. )
  1789. oid_row = await session.execute(text("SELECT LAST_INSERT_ID() AS oid"))
  1790. order_id_raw = oid_row.scalar_one()
  1791. order_id = int(order_id_raw) if order_id_raw is not None else 0
  1792. if order_id <= 0:
  1793. raise RuntimeError("purchase_orders insert did not return id")
  1794. # 更新用户资产:逐条处理,避免不同 item 存在差异导致 SQL 复杂化
  1795. for r in cart_lines:
  1796. item_id = int(r["item_id"])
  1797. cart_qty = int(r.get("cart_qty") or 0)
  1798. if cart_qty <= 0:
  1799. continue
  1800. owned_qty = int(r.get("owned_qty") or 0)
  1801. max_per = r.get("max_per_user")
  1802. max_per_int = int(max_per) if max_per is not None else None
  1803. # owned_qty 已在预校验阶段确认;这里不再重复校验,保证业务逻辑一致
  1804. unit_cost_energy = _floor_shop_price_energy(r.get("price_energy"))
  1805. unit_pe = Decimal(str(unit_cost_energy))
  1806. try:
  1807. unit_pc = Decimal(str(r.get("price_crystal")) if r.get("price_crystal") is not None else "0")
  1808. except (ArithmeticError, ValueError, TypeError):
  1809. unit_pc = Decimal("0")
  1810. chk = await session.execute(
  1811. text(
  1812. f"""
  1813. SELECT id FROM user_assets
  1814. WHERE user_id = {assets_user_id_expr} AND item_id = :item_id
  1815. LIMIT 1
  1816. """
  1817. ),
  1818. {"user_id": user_id, "item_id": item_id},
  1819. )
  1820. existing_uaid = chk.scalar_one_or_none()
  1821. if existing_uaid is not None:
  1822. await session.execute(
  1823. text(
  1824. """
  1825. UPDATE user_assets
  1826. SET owned_qty = owned_qty + :q,
  1827. last_acquired_at = NOW(),
  1828. updated_at = NOW()
  1829. WHERE id = :id
  1830. """
  1831. ),
  1832. {"q": cart_qty, "id": int(existing_uaid)},
  1833. )
  1834. else:
  1835. await session.execute(
  1836. text(
  1837. f"""
  1838. INSERT INTO user_assets
  1839. (user_id, item_id, owned_qty, used_qty, last_acquired_at, created_at, updated_at)
  1840. VALUES
  1841. ({assets_user_id_expr}, :item_id, :q, 0, NOW(), NOW(), NOW())
  1842. """
  1843. ),
  1844. {"user_id": user_id, "item_id": item_id, "q": cart_qty},
  1845. )
  1846. item_name_snap = str(r.get("name") or "")[:100]
  1847. image_name_snap = str(r.get("image_name") or "")[:255]
  1848. await session.execute(
  1849. text(
  1850. """
  1851. INSERT INTO purchase_order_items
  1852. (order_id, item_id, quantity, unit_price_energy, unit_price_crystal,
  1853. cost_energy, cost_crystal, item_name_snapshot, image_name_snapshot)
  1854. VALUES
  1855. (:order_id, :item_id, :qty, :upe, :upc, :ce, 0.00, :name_snap, :img_snap)
  1856. """
  1857. ),
  1858. {
  1859. "order_id": order_id,
  1860. "item_id": item_id,
  1861. "qty": cart_qty,
  1862. "upe": unit_pe,
  1863. "upc": unit_pc,
  1864. "ce": unit_pe * cart_qty,
  1865. "name_snap": item_name_snap,
  1866. "img_snap": image_name_snap,
  1867. },
  1868. )
  1869. # 清空购物车(该 cart_id 下的所有行)
  1870. await session.execute(
  1871. text(
  1872. """
  1873. DELETE sci
  1874. FROM shopping_cart_items sci
  1875. INNER JOIN shopping_carts sc ON sc.id = sci.cart_id
  1876. WHERE sc.user_id = :uid
  1877. """
  1878. ),
  1879. {"uid": internal_uid},
  1880. )
  1881. await session.commit()
  1882. return {
  1883. "success": True,
  1884. "data": {"total_quantity": total_qty, "total_cost_energy": total_cost_energy},
  1885. }
  1886. except HTTPException:
  1887. raise
  1888. except Exception as exc: # noqa: BLE001
  1889. logger.exception("ChatBot 购物车购买失败: %s", exc)
  1890. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  1891. @app.post("/api/users/{user_id}/chatbot-shop-purchase")
  1892. async def purchase_chatbot_shop_item(
  1893. user_id: str,
  1894. request: ChatbotShopPurchaseRequest,
  1895. _: None = Depends(require_user_session),
  1896. ):
  1897. """使用对话能量石购买上架中的家具/幕布。"""
  1898. try:
  1899. async with db_manager.get_session() as session:
  1900. assets_user_id_expr = (
  1901. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  1902. if await _mysql_user_id_column_is_bigint(session, "user_assets")
  1903. else ":user_id"
  1904. )
  1905. item_res = await session.execute(
  1906. text(
  1907. """
  1908. SELECT
  1909. id,
  1910. price_energy,
  1911. price_crystal,
  1912. name,
  1913. image_name,
  1914. max_per_user,
  1915. repeat_purchase_mode,
  1916. is_active,
  1917. item_type
  1918. FROM shop_items
  1919. WHERE id = :item_id
  1920. LIMIT 1
  1921. """
  1922. ),
  1923. {"item_id": request.item_id},
  1924. )
  1925. item_row = item_res.mappings().first()
  1926. if not item_row or int(item_row.get("is_active") or 0) != 1:
  1927. raise HTTPException(status_code=404, detail={"code": SHOP_ITEM_NOT_FOUND})
  1928. if str(item_row.get("item_type") or "") not in ("furniture", "backdrop"):
  1929. raise HTTPException(status_code=404, detail={"code": SHOP_ITEM_NOT_FOUND})
  1930. owned_res = await session.execute(
  1931. text(
  1932. f"""
  1933. SELECT COALESCE(ua.owned_qty, 0) AS owned_qty
  1934. FROM shop_items si
  1935. LEFT JOIN user_assets ua
  1936. ON ua.item_id = si.id
  1937. AND ua.user_id = {assets_user_id_expr}
  1938. WHERE si.id = :item_id
  1939. LIMIT 1
  1940. """
  1941. ),
  1942. {"user_id": user_id, "item_id": request.item_id},
  1943. )
  1944. owned_row = owned_res.mappings().first()
  1945. owned_qty = int((owned_row or {}).get("owned_qty") or 0)
  1946. max_per = item_row.get("max_per_user")
  1947. max_per_int = int(max_per) if max_per is not None else None
  1948. if max_per_int is not None and owned_qty >= max_per_int:
  1949. raise HTTPException(status_code=400, detail={"code": PURCHASE_LIMIT_REACHED})
  1950. repeat_mode = str(item_row.get("repeat_purchase_mode") or "single")
  1951. if repeat_mode == "single" and owned_qty >= 1:
  1952. raise HTTPException(status_code=400, detail={"code": PURCHASE_LIMIT_REACHED})
  1953. cost = _floor_shop_price_energy(item_row.get("price_energy"))
  1954. bal_res = await session.execute(
  1955. text(
  1956. """
  1957. SELECT id, conversation_energy_stone_total
  1958. FROM users
  1959. WHERE user_id = :user_id
  1960. LIMIT 1
  1961. """
  1962. ),
  1963. {"user_id": user_id},
  1964. )
  1965. bal_row = bal_res.mappings().first()
  1966. if not bal_row:
  1967. raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND})
  1968. balance = int(bal_row.get("conversation_energy_stone_total") or 0)
  1969. if cost > balance:
  1970. raise HTTPException(
  1971. status_code=400,
  1972. detail={"code": INSUFFICIENT_CONVERSATION_ENERGY},
  1973. )
  1974. upd_res = await session.execute(
  1975. text(
  1976. """
  1977. UPDATE users
  1978. SET conversation_energy_stone_total = conversation_energy_stone_total - :cost
  1979. WHERE user_id = :user_id
  1980. AND conversation_energy_stone_total >= :cost
  1981. """
  1982. ),
  1983. {"cost": cost, "user_id": user_id},
  1984. )
  1985. if (getattr(upd_res, "rowcount", None) or 0) < 1:
  1986. raise HTTPException(
  1987. status_code=400,
  1988. detail={"code": INSUFFICIENT_CONVERSATION_ENERGY},
  1989. )
  1990. chk = await session.execute(
  1991. text(
  1992. f"""
  1993. SELECT id FROM user_assets
  1994. WHERE user_id = {assets_user_id_expr} AND item_id = :item_id
  1995. LIMIT 1
  1996. """
  1997. ),
  1998. {"user_id": user_id, "item_id": request.item_id},
  1999. )
  2000. existing_uaid = chk.scalar_one_or_none()
  2001. if existing_uaid is not None:
  2002. await session.execute(
  2003. text(
  2004. """
  2005. UPDATE user_assets
  2006. SET owned_qty = owned_qty + 1,
  2007. last_acquired_at = NOW(),
  2008. updated_at = NOW()
  2009. WHERE id = :id
  2010. """
  2011. ),
  2012. {"id": int(existing_uaid)},
  2013. )
  2014. else:
  2015. await session.execute(
  2016. text(
  2017. f"""
  2018. INSERT INTO user_assets
  2019. (user_id, item_id, owned_qty, used_qty, last_acquired_at, created_at, updated_at)
  2020. VALUES
  2021. ({assets_user_id_expr}, :item_id, 1, 0, NOW(), NOW(), NOW())
  2022. """
  2023. ),
  2024. {"user_id": user_id, "item_id": request.item_id},
  2025. )
  2026. internal_uid = int(bal_row["id"])
  2027. # 直购成功:购物车中若有同商品则减 1 件,避免与已购数量叠加超过上限
  2028. await session.execute(
  2029. text(
  2030. """
  2031. UPDATE shopping_cart_items sci
  2032. INNER JOIN shopping_carts sc ON sc.id = sci.cart_id
  2033. SET sci.quantity = sci.quantity - 1,
  2034. sci.updated_at = NOW()
  2035. WHERE sc.user_id = :uid
  2036. AND sci.item_id = :item_id
  2037. AND sci.quantity >= 1
  2038. """
  2039. ),
  2040. {"uid": internal_uid, "item_id": int(request.item_id)},
  2041. )
  2042. await session.execute(
  2043. text(
  2044. """
  2045. DELETE sci FROM shopping_cart_items sci
  2046. INNER JOIN shopping_carts sc ON sc.id = sci.cart_id
  2047. WHERE sc.user_id = :uid
  2048. AND sci.item_id = :item_id
  2049. AND sci.quantity = 0
  2050. """
  2051. ),
  2052. {"uid": internal_uid, "item_id": int(request.item_id)},
  2053. )
  2054. # 购买订单主表/明细(弹窗直购无购物车,cart_id 用 0 占位)
  2055. item_name_snap = str(item_row.get("name") or "")[:100]
  2056. image_name_snap = str(item_row.get("image_name") or "")[:255]
  2057. try:
  2058. pc_snap = item_row.get("price_crystal")
  2059. unit_pc = Decimal(str(pc_snap)) if pc_snap is not None else Decimal("0")
  2060. except (ArithmeticError, ValueError, TypeError):
  2061. unit_pc = Decimal("0")
  2062. unit_pe = Decimal(str(cost))
  2063. await session.execute(
  2064. text(
  2065. """
  2066. INSERT INTO purchase_orders
  2067. (user_id, cart_id, status, total_quantity, total_cost_energy, total_cost_crystal, completed_at)
  2068. VALUES
  2069. (:uid, 0, 'completed', 1, :tce, 0.00, NOW())
  2070. """
  2071. ),
  2072. {"uid": internal_uid, "tce": unit_pe},
  2073. )
  2074. oid_row = await session.execute(text("SELECT LAST_INSERT_ID() AS oid"))
  2075. order_id_raw = oid_row.scalar_one()
  2076. order_id = int(order_id_raw) if order_id_raw is not None else 0
  2077. if order_id <= 0:
  2078. raise RuntimeError("purchase_orders insert did not return id")
  2079. await session.execute(
  2080. text(
  2081. """
  2082. INSERT INTO purchase_order_items
  2083. (order_id, item_id, quantity, unit_price_energy, unit_price_crystal,
  2084. cost_energy, cost_crystal, item_name_snapshot, image_name_snapshot)
  2085. VALUES
  2086. (:order_id, :item_id, 1, :upe, :upc, :ce, 0.00, :name_snap, :img_snap)
  2087. """
  2088. ),
  2089. {
  2090. "order_id": order_id,
  2091. "item_id": int(request.item_id),
  2092. "upe": unit_pe,
  2093. "upc": unit_pc,
  2094. "ce": unit_pe,
  2095. "name_snap": item_name_snap,
  2096. "img_snap": image_name_snap,
  2097. },
  2098. )
  2099. await session.commit()
  2100. return {
  2101. "success": True,
  2102. "data": {
  2103. "item_id": int(request.item_id),
  2104. "cost_energy": cost,
  2105. "order_id": order_id,
  2106. },
  2107. }
  2108. except HTTPException:
  2109. raise
  2110. except Exception as exc: # noqa: BLE001
  2111. logger.exception("ChatBot 商店购买失败: %s", exc)
  2112. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  2113. @app.put("/api/users/{user_id}/chatbot-backdrop")
  2114. async def update_chatbot_backdrop(
  2115. user_id: str,
  2116. request: UpdateChatbotBackdropRequest,
  2117. _: None = Depends(require_user_session),
  2118. ):
  2119. """切换用户 ChatBot 当前幕布。"""
  2120. try:
  2121. async with db_manager.get_session() as session:
  2122. async def _get_user_id_is_bigint(table_name: str) -> bool:
  2123. res = await session.execute(
  2124. text(
  2125. """
  2126. SELECT DATA_TYPE
  2127. FROM information_schema.columns
  2128. WHERE table_schema = :db
  2129. AND table_name = :table
  2130. AND column_name = 'user_id'
  2131. """
  2132. ),
  2133. {"db": settings.MYSQL_DATABASE, "table": table_name},
  2134. )
  2135. row = res.mappings().first()
  2136. return "bigint" in str((row or {}).get("DATA_TYPE") or "").lower()
  2137. backdrop_user_id_expr = (
  2138. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  2139. if await _get_user_id_is_bigint("user_chatbot_backdrops")
  2140. else ":user_id"
  2141. )
  2142. placement_user_id_expr = (
  2143. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  2144. if await _get_user_id_is_bigint("user_chatbot_slot_placements")
  2145. else ":user_id"
  2146. )
  2147. assets_user_id_expr = (
  2148. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  2149. if await _get_user_id_is_bigint("user_assets")
  2150. else ":user_id"
  2151. )
  2152. owned_res = await session.execute(
  2153. text(
  2154. f"""
  2155. SELECT
  2156. ua.id AS user_asset_id,
  2157. si.id AS item_id,
  2158. si.item_type,
  2159. COALESCE(si.default_z_index, 0) AS default_z_index
  2160. FROM user_assets ua
  2161. JOIN shop_items si ON si.id = ua.item_id
  2162. WHERE ua.user_id = {assets_user_id_expr}
  2163. AND ua.item_id = :item_id
  2164. AND ua.owned_qty > 0
  2165. AND si.is_active = 1
  2166. LIMIT 1
  2167. """
  2168. ),
  2169. {"user_id": user_id, "item_id": request.backdrop_item_id},
  2170. )
  2171. owned_row = owned_res.mappings().first()
  2172. if not owned_row:
  2173. raise HTTPException(status_code=400, detail={"code": "ITEM_NOT_OWNED"})
  2174. if str(owned_row.get("item_type") or "") != "backdrop":
  2175. raise HTTPException(status_code=400, detail={"code": "ITEM_NOT_BACKDROP"})
  2176. target_backdrop_item_id = int(request.backdrop_item_id)
  2177. current_backdrop_res = await session.execute(
  2178. text(
  2179. f"""
  2180. SELECT backdrop_item_id
  2181. FROM user_chatbot_backdrops
  2182. WHERE user_id = {backdrop_user_id_expr}
  2183. ORDER BY updated_at DESC, id DESC
  2184. LIMIT 1
  2185. """
  2186. ),
  2187. {"user_id": user_id},
  2188. )
  2189. current_backdrop_row = current_backdrop_res.mappings().first()
  2190. source_backdrop_item_id = (
  2191. int(current_backdrop_row["backdrop_item_id"])
  2192. if current_backdrop_row and current_backdrop_row.get("backdrop_item_id") is not None
  2193. else None
  2194. )
  2195. await session.execute(
  2196. text(
  2197. f"""
  2198. DELETE FROM user_chatbot_backdrops
  2199. WHERE user_id = {backdrop_user_id_expr}
  2200. """
  2201. ),
  2202. {"user_id": user_id},
  2203. )
  2204. await session.execute(
  2205. text(
  2206. f"""
  2207. INSERT INTO user_chatbot_backdrops
  2208. (user_id, backdrop_item_id, z_index, created_at, updated_at)
  2209. VALUES
  2210. ({backdrop_user_id_expr}, :backdrop_item_id, :z_index, NOW(), NOW())
  2211. """
  2212. ),
  2213. {
  2214. "user_id": user_id,
  2215. "backdrop_item_id": target_backdrop_item_id,
  2216. "z_index": int(owned_row.get("default_z_index") or 0),
  2217. },
  2218. )
  2219. slot_table_exists_res = await session.execute(
  2220. text(
  2221. """
  2222. SELECT COUNT(*) AS c
  2223. FROM information_schema.tables
  2224. WHERE table_schema = :db
  2225. AND table_name = 'user_chatbot_slot_placements'
  2226. """
  2227. ),
  2228. {"db": settings.MYSQL_DATABASE},
  2229. )
  2230. slot_table_exists = int((slot_table_exists_res.mappings().first() or {}).get("c") or 0) > 0
  2231. # 幕布切换时:先清空目标幕布已有摆放,再尝试按槽位复制来源幕布的有效摆放。
  2232. if slot_table_exists:
  2233. await session.execute(
  2234. text(
  2235. f"""
  2236. DELETE FROM user_chatbot_slot_placements
  2237. WHERE user_id = {placement_user_id_expr}
  2238. AND backdrop_item_id = :target_backdrop_item_id
  2239. """
  2240. ),
  2241. {"user_id": user_id, "target_backdrop_item_id": target_backdrop_item_id},
  2242. )
  2243. if source_backdrop_item_id is not None and source_backdrop_item_id != target_backdrop_item_id:
  2244. await session.execute(
  2245. text(
  2246. f"""
  2247. INSERT INTO user_chatbot_slot_placements
  2248. (user_id, backdrop_item_id, slot_code, user_asset_id, item_id, z_index,
  2249. image_name_snapshot, placed_at, updated_at)
  2250. SELECT
  2251. src.user_id,
  2252. :target_backdrop_item_id AS backdrop_item_id,
  2253. src.slot_code,
  2254. src.user_asset_id,
  2255. src.item_id,
  2256. src.z_index,
  2257. src.image_name_snapshot,
  2258. NOW(),
  2259. NOW()
  2260. FROM user_chatbot_slot_placements src
  2261. JOIN chatbot_backdrop_slots target_slot
  2262. ON target_slot.backdrop_item_id = :target_backdrop_item_id
  2263. AND target_slot.slot_code = src.slot_code
  2264. AND target_slot.is_active = 1
  2265. WHERE src.user_id = {placement_user_id_expr}
  2266. AND src.backdrop_item_id = :source_backdrop_item_id
  2267. ON DUPLICATE KEY UPDATE
  2268. user_asset_id = VALUES(user_asset_id),
  2269. item_id = VALUES(item_id),
  2270. z_index = VALUES(z_index),
  2271. image_name_snapshot = VALUES(image_name_snapshot),
  2272. placed_at = VALUES(placed_at),
  2273. updated_at = NOW()
  2274. """
  2275. ),
  2276. {
  2277. "user_id": user_id,
  2278. "source_backdrop_item_id": source_backdrop_item_id,
  2279. "target_backdrop_item_id": target_backdrop_item_id,
  2280. },
  2281. )
  2282. await session.commit()
  2283. return {
  2284. "success": True,
  2285. "data": {"backdrop_item_id": target_backdrop_item_id},
  2286. }
  2287. except HTTPException:
  2288. raise
  2289. except Exception as exc: # noqa: BLE001
  2290. logger.exception("切换 ChatBot 幕布失败: %s", exc)
  2291. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  2292. @app.get("/api/users/{user_id}/chatbot-furniture/equip-preview")
  2293. async def get_chatbot_furniture_equip_preview(
  2294. user_id: str,
  2295. item_id: int = Query(..., ge=1),
  2296. _: None = Depends(require_user_session),
  2297. ):
  2298. """
  2299. 装备前预览:解析与 POST equip 相同的槽位规则,返回槽位几何与场景图文件名。
  2300. 无有效槽位或数据不全时 data 为 null(前端不展示预览即可)。
  2301. """
  2302. try:
  2303. async with db_manager.get_session() as session:
  2304. async def _get_user_id_is_bigint(table_name: str) -> bool:
  2305. res = await session.execute(
  2306. text(
  2307. """
  2308. SELECT DATA_TYPE
  2309. FROM information_schema.columns
  2310. WHERE table_schema = :db
  2311. AND table_name = :table
  2312. AND column_name = 'user_id'
  2313. """
  2314. ),
  2315. {"db": settings.MYSQL_DATABASE, "table": table_name},
  2316. )
  2317. row = res.mappings().first()
  2318. return "bigint" in str((row or {}).get("DATA_TYPE") or "").lower()
  2319. backdrop_user_id_expr = (
  2320. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  2321. if await _get_user_id_is_bigint("user_chatbot_backdrops")
  2322. else ":user_id"
  2323. )
  2324. item_res = await session.execute(
  2325. text(
  2326. """
  2327. SELECT
  2328. si.id AS item_id,
  2329. si.item_type,
  2330. si.image_name,
  2331. si.render_image_name,
  2332. COALESCE(si.default_z_index, 0) AS default_z_index
  2333. FROM shop_items si
  2334. WHERE si.id = :item_id
  2335. AND si.is_active = 1
  2336. LIMIT 1
  2337. """
  2338. ),
  2339. {"item_id": item_id},
  2340. )
  2341. item_row = item_res.mappings().first()
  2342. if not item_row or str(item_row.get("item_type") or "") != "furniture":
  2343. return {"success": True, "data": None}
  2344. rules_table_res = await session.execute(
  2345. text(
  2346. """
  2347. SELECT COUNT(*) AS c
  2348. FROM information_schema.tables
  2349. WHERE table_schema = :db
  2350. AND table_name = 'shop_item_slot_rules'
  2351. """
  2352. ),
  2353. {"db": settings.MYSQL_DATABASE},
  2354. )
  2355. if int((rules_table_res.mappings().first() or {}).get("c") or 0) == 0:
  2356. return {"success": True, "data": None}
  2357. current_backdrop_res = await session.execute(
  2358. text(
  2359. f"""
  2360. SELECT backdrop_item_id
  2361. FROM user_chatbot_backdrops
  2362. WHERE user_id = {backdrop_user_id_expr}
  2363. ORDER BY updated_at DESC, id DESC
  2364. LIMIT 1
  2365. """
  2366. ),
  2367. {"user_id": user_id},
  2368. )
  2369. current_backdrop_row = current_backdrop_res.mappings().first()
  2370. if not current_backdrop_row or current_backdrop_row.get("backdrop_item_id") is None:
  2371. return {"success": True, "data": None}
  2372. backdrop_item_id = int(current_backdrop_row["backdrop_item_id"])
  2373. target_slot_res = await session.execute(
  2374. text(
  2375. """
  2376. SELECT
  2377. r.slot_code,
  2378. r.id AS rule_id
  2379. FROM shop_item_slot_rules r
  2380. JOIN chatbot_backdrop_slots s
  2381. ON s.backdrop_item_id = r.backdrop_item_id
  2382. AND s.slot_code = r.slot_code
  2383. AND s.is_active = 1
  2384. WHERE r.item_id = :item_id
  2385. AND r.backdrop_item_id = :backdrop_item_id
  2386. AND r.is_active = 1
  2387. ORDER BY COALESCE(r.is_default, 0) DESC, COALESCE(r.priority, 9999) ASC, r.id ASC
  2388. LIMIT 1
  2389. """
  2390. ),
  2391. {"item_id": item_id, "backdrop_item_id": backdrop_item_id},
  2392. )
  2393. target_slot_row = target_slot_res.mappings().first()
  2394. if not target_slot_row or not target_slot_row.get("slot_code"):
  2395. return {"success": True, "data": None}
  2396. slot_code = str(target_slot_row["slot_code"])
  2397. slot_geo_res = await session.execute(
  2398. text(
  2399. """
  2400. SELECT
  2401. s.id,
  2402. s.slot_code,
  2403. s.placement_type,
  2404. s.start_row,
  2405. s.start_col,
  2406. s.size_rows,
  2407. s.size_cols,
  2408. COALESCE(s.z_index, 0) AS z_index,
  2409. s.render_mode,
  2410. s.offset_x,
  2411. s.offset_y,
  2412. s.pixel_width,
  2413. s.pixel_height
  2414. FROM chatbot_backdrop_slots s
  2415. WHERE s.backdrop_item_id = :backdrop_item_id
  2416. AND s.slot_code = :slot_code
  2417. AND s.is_active = 1
  2418. LIMIT 1
  2419. """
  2420. ),
  2421. {"backdrop_item_id": backdrop_item_id, "slot_code": slot_code},
  2422. )
  2423. slot_row = slot_geo_res.mappings().first()
  2424. if not slot_row:
  2425. return {"success": True, "data": None}
  2426. preview_image_name = scene_image_name_from_shop_row(dict(item_row))
  2427. item_z_index = int(item_row.get("default_z_index") or 0)
  2428. slot_payload = {
  2429. "id": int(slot_row["id"]),
  2430. "slot_code": str(slot_row.get("slot_code") or ""),
  2431. "placement_type": str(slot_row.get("placement_type") or "floor"),
  2432. "start_row": int(slot_row.get("start_row") or 1),
  2433. "start_col": int(slot_row.get("start_col") or 1),
  2434. "size_rows": int(slot_row.get("size_rows") or 1),
  2435. "size_cols": int(slot_row.get("size_cols") or 1),
  2436. # 与 POST /chatbot-furniture/equip 保持一致:渲染层级取商品 default_z_index
  2437. "z_index": item_z_index,
  2438. "render_mode": slot_row.get("render_mode"),
  2439. "offset_x": slot_row.get("offset_x"),
  2440. "offset_y": slot_row.get("offset_y"),
  2441. "pixel_width": slot_row.get("pixel_width"),
  2442. "pixel_height": slot_row.get("pixel_height"),
  2443. }
  2444. return {
  2445. "success": True,
  2446. "data": {
  2447. "slot": slot_payload,
  2448. "preview_image_name": preview_image_name,
  2449. },
  2450. }
  2451. except Exception as exc: # noqa: BLE001
  2452. logger.exception("获取家具装备预览失败: %s", exc)
  2453. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  2454. @app.post("/api/users/{user_id}/chatbot-furniture/equip")
  2455. async def equip_chatbot_furniture(
  2456. user_id: str,
  2457. request: ChatbotFurnitureActionRequest,
  2458. _: None = Depends(require_user_session),
  2459. ):
  2460. """装备家具:同槽位已有元素则替换,否则新增。"""
  2461. try:
  2462. async with db_manager.get_session() as session:
  2463. async def _get_user_id_is_bigint(table_name: str) -> bool:
  2464. res = await session.execute(
  2465. text(
  2466. """
  2467. SELECT DATA_TYPE
  2468. FROM information_schema.columns
  2469. WHERE table_schema = :db
  2470. AND table_name = :table
  2471. AND column_name = 'user_id'
  2472. """
  2473. ),
  2474. {"db": settings.MYSQL_DATABASE, "table": table_name},
  2475. )
  2476. row = res.mappings().first()
  2477. return "bigint" in str((row or {}).get("DATA_TYPE") or "").lower()
  2478. assets_user_id_expr = (
  2479. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  2480. if await _get_user_id_is_bigint("user_assets")
  2481. else ":user_id"
  2482. )
  2483. backdrop_user_id_expr = (
  2484. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  2485. if await _get_user_id_is_bigint("user_chatbot_backdrops")
  2486. else ":user_id"
  2487. )
  2488. placement_user_id_expr = (
  2489. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  2490. if await _get_user_id_is_bigint("user_chatbot_slot_placements")
  2491. else ":user_id"
  2492. )
  2493. owned_res = await session.execute(
  2494. text(
  2495. f"""
  2496. SELECT
  2497. ua.id AS user_asset_id,
  2498. si.id AS item_id,
  2499. si.item_type,
  2500. si.image_name,
  2501. si.render_image_name,
  2502. COALESCE(si.default_z_index, 0) AS default_z_index
  2503. FROM user_assets ua
  2504. JOIN shop_items si ON si.id = ua.item_id
  2505. WHERE ua.user_id = {assets_user_id_expr}
  2506. AND ua.item_id = :item_id
  2507. AND ua.owned_qty > 0
  2508. AND si.is_active = 1
  2509. LIMIT 1
  2510. """
  2511. ),
  2512. {"user_id": user_id, "item_id": request.item_id},
  2513. )
  2514. owned_row = owned_res.mappings().first()
  2515. if not owned_row:
  2516. raise HTTPException(status_code=400, detail={"code": "ITEM_NOT_OWNED"})
  2517. if str(owned_row.get("item_type") or "") != "furniture":
  2518. raise HTTPException(status_code=400, detail={"code": "ITEM_NOT_FURNITURE"})
  2519. current_backdrop_res = await session.execute(
  2520. text(
  2521. f"""
  2522. SELECT backdrop_item_id
  2523. FROM user_chatbot_backdrops
  2524. WHERE user_id = {backdrop_user_id_expr}
  2525. ORDER BY updated_at DESC, id DESC
  2526. LIMIT 1
  2527. """
  2528. ),
  2529. {"user_id": user_id},
  2530. )
  2531. current_backdrop_row = current_backdrop_res.mappings().first()
  2532. if not current_backdrop_row or current_backdrop_row.get("backdrop_item_id") is None:
  2533. raise HTTPException(status_code=400, detail={"code": "BACKDROP_NOT_SET"})
  2534. backdrop_item_id = int(current_backdrop_row["backdrop_item_id"])
  2535. target_slot_res = await session.execute(
  2536. text(
  2537. """
  2538. SELECT
  2539. r.slot_code,
  2540. r.id AS rule_id
  2541. FROM shop_item_slot_rules r
  2542. JOIN chatbot_backdrop_slots s
  2543. ON s.backdrop_item_id = r.backdrop_item_id
  2544. AND s.slot_code = r.slot_code
  2545. AND s.is_active = 1
  2546. WHERE r.item_id = :item_id
  2547. AND r.backdrop_item_id = :backdrop_item_id
  2548. AND r.is_active = 1
  2549. ORDER BY COALESCE(r.is_default, 0) DESC, COALESCE(r.priority, 9999) ASC, r.id ASC
  2550. LIMIT 1
  2551. """
  2552. ),
  2553. {"item_id": request.item_id, "backdrop_item_id": backdrop_item_id},
  2554. )
  2555. target_slot_row = target_slot_res.mappings().first()
  2556. if not target_slot_row or not target_slot_row.get("slot_code"):
  2557. raise HTTPException(status_code=400, detail={"code": "NO_VALID_SLOT_FOR_ITEM"})
  2558. slot_code = str(target_slot_row["slot_code"])
  2559. dependency_table_res = await session.execute(
  2560. text(
  2561. """
  2562. SELECT COUNT(*) AS c
  2563. FROM information_schema.tables
  2564. WHERE table_schema = :db
  2565. AND table_name = 'chatbot_slot_dependencies'
  2566. """
  2567. ),
  2568. {"db": settings.MYSQL_DATABASE},
  2569. )
  2570. has_dependency_table = int((dependency_table_res.mappings().first() or {}).get("c") or 0) > 0
  2571. if has_dependency_table:
  2572. user_lang_res = await session.execute(
  2573. select(User.preferred_language).where(User.user_id == user_id).limit(1)
  2574. )
  2575. preferred_language = user_lang_res.scalar_one_or_none()
  2576. is_zh = str(preferred_language or "zh").lower().startswith("zh")
  2577. dependency_res = await session.execute(
  2578. text(
  2579. """
  2580. SELECT
  2581. dep.requires_slot_code,
  2582. dep.requires_biz_type,
  2583. cur.slot_name_zh AS current_slot_name_zh,
  2584. cur.slot_name_en AS current_slot_name_en,
  2585. req.slot_name_zh AS requires_slot_name_zh,
  2586. req.slot_name_en AS requires_slot_name_en
  2587. FROM chatbot_slot_dependencies
  2588. dep
  2589. LEFT JOIN chatbot_backdrop_slots cur
  2590. ON cur.backdrop_item_id = dep.backdrop_item_id
  2591. AND cur.slot_code = dep.slot_code
  2592. LEFT JOIN chatbot_backdrop_slots req
  2593. ON req.backdrop_item_id = dep.backdrop_item_id
  2594. AND req.slot_code = dep.requires_slot_code
  2595. WHERE dep.backdrop_item_id = :backdrop_item_id
  2596. AND dep.slot_code = :slot_code
  2597. AND dep.is_active = 1
  2598. ORDER BY COALESCE(dep.priority, 9999) ASC, dep.id ASC
  2599. """
  2600. ),
  2601. {"backdrop_item_id": backdrop_item_id, "slot_code": slot_code},
  2602. )
  2603. dependency_rows = dependency_res.mappings().all()
  2604. for dep in dependency_rows:
  2605. requires_slot_code = str(dep.get("requires_slot_code") or "").strip()
  2606. requires_biz_type = dep.get("requires_biz_type")
  2607. if not requires_slot_code:
  2608. continue
  2609. required_placement_res = await session.execute(
  2610. text(
  2611. f"""
  2612. SELECT
  2613. usp.item_id,
  2614. si.biz_type
  2615. FROM user_chatbot_slot_placements usp
  2616. LEFT JOIN shop_items si ON si.id = usp.item_id
  2617. WHERE usp.user_id = {placement_user_id_expr}
  2618. AND usp.backdrop_item_id = :backdrop_item_id
  2619. AND usp.slot_code = :slot_code
  2620. LIMIT 1
  2621. """
  2622. ),
  2623. {
  2624. "user_id": user_id,
  2625. "backdrop_item_id": backdrop_item_id,
  2626. "slot_code": requires_slot_code,
  2627. },
  2628. )
  2629. required_placement = required_placement_res.mappings().first()
  2630. if not required_placement:
  2631. current_slot_name = (
  2632. str(dep.get("current_slot_name_zh") or slot_code)
  2633. if is_zh
  2634. else str(dep.get("current_slot_name_en") or slot_code)
  2635. )
  2636. requires_slot_name = (
  2637. str(dep.get("requires_slot_name_zh") or requires_slot_code)
  2638. if is_zh
  2639. else str(dep.get("requires_slot_name_en") or requires_slot_code)
  2640. )
  2641. detail_message = (
  2642. f"请先在「{requires_slot_name}」放置支撑家具后,再放置「{current_slot_name}」。"
  2643. if is_zh
  2644. else f"Please place a support item in \"{requires_slot_name}\" before placing items in \"{current_slot_name}\"."
  2645. )
  2646. raise HTTPException(
  2647. status_code=400,
  2648. detail={"code": "SLOT_DEPENDENCY_NOT_SATISFIED", "message": detail_message},
  2649. )
  2650. if requires_biz_type:
  2651. required_biz_type = str(required_placement.get("biz_type") or "")
  2652. if required_biz_type != str(requires_biz_type):
  2653. current_slot_name = (
  2654. str(dep.get("current_slot_name_zh") or slot_code)
  2655. if is_zh
  2656. else str(dep.get("current_slot_name_en") or slot_code)
  2657. )
  2658. requires_slot_name = (
  2659. str(dep.get("requires_slot_name_zh") or requires_slot_code)
  2660. if is_zh
  2661. else str(dep.get("requires_slot_name_en") or requires_slot_code)
  2662. )
  2663. required_type_name = (
  2664. "桌类家具"
  2665. if str(requires_biz_type) == "table_cabinet" and is_zh
  2666. else "table item"
  2667. if str(requires_biz_type) == "table_cabinet"
  2668. else str(requires_biz_type)
  2669. )
  2670. detail_message = (
  2671. f"请先在「{requires_slot_name}」放置{required_type_name}后,再放置「{current_slot_name}」。"
  2672. if is_zh
  2673. else f"Please place a {required_type_name} in \"{requires_slot_name}\" before placing items in \"{current_slot_name}\"."
  2674. )
  2675. raise HTTPException(
  2676. status_code=400,
  2677. detail={"code": "SLOT_DEPENDENCY_NOT_SATISFIED", "message": detail_message},
  2678. )
  2679. await session.execute(
  2680. text(
  2681. f"""
  2682. INSERT INTO user_chatbot_slot_placements
  2683. (user_id, backdrop_item_id, slot_code, user_asset_id, item_id, z_index, image_name_snapshot, placed_at, updated_at)
  2684. VALUES
  2685. ({placement_user_id_expr}, :backdrop_item_id, :slot_code, :user_asset_id, :item_id, :z_index, :image_name_snapshot, NOW(), NOW())
  2686. ON DUPLICATE KEY UPDATE
  2687. user_asset_id = VALUES(user_asset_id),
  2688. item_id = VALUES(item_id),
  2689. z_index = VALUES(z_index),
  2690. image_name_snapshot = VALUES(image_name_snapshot),
  2691. updated_at = NOW()
  2692. """
  2693. ),
  2694. {
  2695. "user_id": user_id,
  2696. "backdrop_item_id": backdrop_item_id,
  2697. "slot_code": slot_code,
  2698. "user_asset_id": int(owned_row.get("user_asset_id") or 0),
  2699. "item_id": int(request.item_id),
  2700. "z_index": int(owned_row.get("default_z_index") or 0),
  2701. "image_name_snapshot": scene_image_name_from_shop_row(owned_row),
  2702. },
  2703. )
  2704. await session.commit()
  2705. return {
  2706. "success": True,
  2707. "data": {
  2708. "item_id": int(request.item_id),
  2709. "slot_code": slot_code,
  2710. "backdrop_item_id": backdrop_item_id,
  2711. },
  2712. }
  2713. except HTTPException:
  2714. raise
  2715. except Exception as exc: # noqa: BLE001
  2716. logger.exception("装备 ChatBot 家具失败: %s", exc)
  2717. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  2718. @app.post("/api/users/{user_id}/chatbot-furniture/unequip")
  2719. async def unequip_chatbot_furniture(
  2720. user_id: str,
  2721. request: ChatbotFurnitureActionRequest,
  2722. _: None = Depends(require_user_session),
  2723. ):
  2724. """卸下家具:移除当前幕布下该 item 的槽位摆放记录。"""
  2725. try:
  2726. async with db_manager.get_session() as session:
  2727. async def _get_user_id_is_bigint(table_name: str) -> bool:
  2728. res = await session.execute(
  2729. text(
  2730. """
  2731. SELECT DATA_TYPE
  2732. FROM information_schema.columns
  2733. WHERE table_schema = :db
  2734. AND table_name = :table
  2735. AND column_name = 'user_id'
  2736. """
  2737. ),
  2738. {"db": settings.MYSQL_DATABASE, "table": table_name},
  2739. )
  2740. row = res.mappings().first()
  2741. return "bigint" in str((row or {}).get("DATA_TYPE") or "").lower()
  2742. assets_user_id_expr = (
  2743. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  2744. if await _get_user_id_is_bigint("user_assets")
  2745. else ":user_id"
  2746. )
  2747. backdrop_user_id_expr = (
  2748. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  2749. if await _get_user_id_is_bigint("user_chatbot_backdrops")
  2750. else ":user_id"
  2751. )
  2752. placement_user_id_expr = (
  2753. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  2754. if await _get_user_id_is_bigint("user_chatbot_slot_placements")
  2755. else ":user_id"
  2756. )
  2757. owned_res = await session.execute(
  2758. text(
  2759. f"""
  2760. SELECT
  2761. si.id AS item_id,
  2762. si.item_type
  2763. FROM user_assets ua
  2764. JOIN shop_items si ON si.id = ua.item_id
  2765. WHERE ua.user_id = {assets_user_id_expr}
  2766. AND ua.item_id = :item_id
  2767. AND ua.owned_qty > 0
  2768. AND si.is_active = 1
  2769. LIMIT 1
  2770. """
  2771. ),
  2772. {"user_id": user_id, "item_id": request.item_id},
  2773. )
  2774. owned_row = owned_res.mappings().first()
  2775. if not owned_row:
  2776. raise HTTPException(status_code=400, detail={"code": "ITEM_NOT_OWNED"})
  2777. if str(owned_row.get("item_type") or "") != "furniture":
  2778. raise HTTPException(status_code=400, detail={"code": "ITEM_NOT_FURNITURE"})
  2779. current_backdrop_res = await session.execute(
  2780. text(
  2781. f"""
  2782. SELECT backdrop_item_id
  2783. FROM user_chatbot_backdrops
  2784. WHERE user_id = {backdrop_user_id_expr}
  2785. ORDER BY updated_at DESC, id DESC
  2786. LIMIT 1
  2787. """
  2788. ),
  2789. {"user_id": user_id},
  2790. )
  2791. current_backdrop_row = current_backdrop_res.mappings().first()
  2792. if not current_backdrop_row or current_backdrop_row.get("backdrop_item_id") is None:
  2793. raise HTTPException(status_code=400, detail={"code": "BACKDROP_NOT_SET"})
  2794. backdrop_item_id = int(current_backdrop_row["backdrop_item_id"])
  2795. dependency_table_res = await session.execute(
  2796. text(
  2797. """
  2798. SELECT COUNT(*) AS c
  2799. FROM information_schema.tables
  2800. WHERE table_schema = :db
  2801. AND table_name = 'chatbot_slot_dependencies'
  2802. """
  2803. ),
  2804. {"db": settings.MYSQL_DATABASE},
  2805. )
  2806. has_dependency_table = int((dependency_table_res.mappings().first() or {}).get("c") or 0) > 0
  2807. user_lang_res = await session.execute(
  2808. select(User.preferred_language).where(User.user_id == user_id).limit(1)
  2809. )
  2810. preferred_language = user_lang_res.scalar_one_or_none()
  2811. is_zh = str(preferred_language or "zh").lower().startswith("zh")
  2812. cascade_removed_count = 0
  2813. cascade_removed_item_ids: list[int] = []
  2814. cascade_notice: Optional[str] = None
  2815. if has_dependency_table:
  2816. cascade_notice_meta_res = await session.execute(
  2817. text(
  2818. f"""
  2819. SELECT
  2820. dep.slot_code AS child_slot_code,
  2821. dep.requires_slot_code AS base_slot_code,
  2822. child_slot.slot_name_zh AS child_slot_name_zh,
  2823. child_slot.slot_name_en AS child_slot_name_en,
  2824. base_slot.slot_name_zh AS base_slot_name_zh,
  2825. base_slot.slot_name_en AS base_slot_name_en
  2826. FROM user_chatbot_slot_placements child
  2827. JOIN chatbot_slot_dependencies dep
  2828. ON dep.backdrop_item_id = child.backdrop_item_id
  2829. AND dep.slot_code = child.slot_code
  2830. AND dep.is_active = 1
  2831. JOIN user_chatbot_slot_placements base
  2832. ON base.user_id = child.user_id
  2833. AND base.backdrop_item_id = child.backdrop_item_id
  2834. AND base.slot_code = dep.requires_slot_code
  2835. LEFT JOIN chatbot_backdrop_slots child_slot
  2836. ON child_slot.backdrop_item_id = dep.backdrop_item_id
  2837. AND child_slot.slot_code = dep.slot_code
  2838. LEFT JOIN chatbot_backdrop_slots base_slot
  2839. ON base_slot.backdrop_item_id = dep.backdrop_item_id
  2840. AND base_slot.slot_code = dep.requires_slot_code
  2841. WHERE child.user_id = {placement_user_id_expr}
  2842. AND child.backdrop_item_id = :backdrop_item_id
  2843. AND base.item_id = :item_id
  2844. AND child.item_id <> :item_id
  2845. LIMIT 1
  2846. """
  2847. ),
  2848. {
  2849. "user_id": user_id,
  2850. "backdrop_item_id": backdrop_item_id,
  2851. "item_id": int(request.item_id),
  2852. },
  2853. )
  2854. cascade_notice_meta = cascade_notice_meta_res.mappings().first()
  2855. cascade_item_res = await session.execute(
  2856. text(
  2857. f"""
  2858. SELECT DISTINCT child.item_id
  2859. FROM user_chatbot_slot_placements child
  2860. JOIN chatbot_slot_dependencies dep
  2861. ON dep.backdrop_item_id = child.backdrop_item_id
  2862. AND dep.slot_code = child.slot_code
  2863. AND dep.is_active = 1
  2864. JOIN user_chatbot_slot_placements base
  2865. ON base.user_id = child.user_id
  2866. AND base.backdrop_item_id = child.backdrop_item_id
  2867. AND base.slot_code = dep.requires_slot_code
  2868. WHERE child.user_id = {placement_user_id_expr}
  2869. AND child.backdrop_item_id = :backdrop_item_id
  2870. AND base.item_id = :item_id
  2871. AND child.item_id <> :item_id
  2872. """
  2873. ),
  2874. {
  2875. "user_id": user_id,
  2876. "backdrop_item_id": backdrop_item_id,
  2877. "item_id": int(request.item_id),
  2878. },
  2879. )
  2880. cascade_removed_item_ids = [
  2881. int(row["item_id"])
  2882. for row in cascade_item_res.mappings().all()
  2883. if row.get("item_id") is not None
  2884. ]
  2885. cascade_delete_res = await session.execute(
  2886. text(
  2887. f"""
  2888. DELETE child
  2889. FROM user_chatbot_slot_placements child
  2890. JOIN chatbot_slot_dependencies dep
  2891. ON dep.backdrop_item_id = child.backdrop_item_id
  2892. AND dep.slot_code = child.slot_code
  2893. AND dep.is_active = 1
  2894. JOIN user_chatbot_slot_placements base
  2895. ON base.user_id = child.user_id
  2896. AND base.backdrop_item_id = child.backdrop_item_id
  2897. AND base.slot_code = dep.requires_slot_code
  2898. WHERE child.user_id = {placement_user_id_expr}
  2899. AND child.backdrop_item_id = :backdrop_item_id
  2900. AND base.item_id = :item_id
  2901. AND child.item_id <> :item_id
  2902. """
  2903. ),
  2904. {
  2905. "user_id": user_id,
  2906. "backdrop_item_id": backdrop_item_id,
  2907. "item_id": int(request.item_id),
  2908. },
  2909. )
  2910. cascade_removed_count = int(cascade_delete_res.rowcount or 0)
  2911. if cascade_removed_count > 0 and cascade_notice_meta:
  2912. base_name = (
  2913. str(cascade_notice_meta.get("base_slot_name_zh") or cascade_notice_meta.get("base_slot_code") or "")
  2914. if is_zh
  2915. else str(cascade_notice_meta.get("base_slot_name_en") or cascade_notice_meta.get("base_slot_code") or "")
  2916. )
  2917. child_name = (
  2918. str(cascade_notice_meta.get("child_slot_name_zh") or cascade_notice_meta.get("child_slot_code") or "")
  2919. if is_zh
  2920. else str(cascade_notice_meta.get("child_slot_name_en") or cascade_notice_meta.get("child_slot_code") or "")
  2921. )
  2922. cascade_notice = (
  2923. f"你卸下了「{base_name}」的支撑家具,「{child_name}」上的关联物品已自动卸下。"
  2924. if is_zh
  2925. else f"You removed the support item in \"{base_name}\", so linked items in \"{child_name}\" were auto-unequipped."
  2926. )
  2927. delete_res = await session.execute(
  2928. text(
  2929. f"""
  2930. DELETE FROM user_chatbot_slot_placements
  2931. WHERE user_id = {placement_user_id_expr}
  2932. AND backdrop_item_id = :backdrop_item_id
  2933. AND item_id = :item_id
  2934. """
  2935. ),
  2936. {
  2937. "user_id": user_id,
  2938. "backdrop_item_id": backdrop_item_id,
  2939. "item_id": int(request.item_id),
  2940. },
  2941. )
  2942. removed_count = int(delete_res.rowcount or 0)
  2943. await session.commit()
  2944. return {
  2945. "success": True,
  2946. "data": {
  2947. "item_id": int(request.item_id),
  2948. "removed_count": removed_count,
  2949. "backdrop_item_id": backdrop_item_id,
  2950. "cascade_removed_count": cascade_removed_count,
  2951. "cascade_removed_item_ids": cascade_removed_item_ids,
  2952. "cascade_notice": cascade_notice,
  2953. },
  2954. }
  2955. except HTTPException:
  2956. raise
  2957. except Exception as exc: # noqa: BLE001
  2958. logger.exception("卸下 ChatBot 家具失败: %s", exc)
  2959. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  2960. @app.post("/api/users/{user_id}/claim-share-reward")
  2961. async def claim_share_reward(user_id: str, _: None = Depends(require_user_session)):
  2962. """
  2963. 领取分享奖励
  2964. - 给用户添加新年背景图片(happynewyear)
  2965. - 设置用户的当前背景图片为新年的背景
  2966. Args:
  2967. user_id: 用户ID
  2968. Returns:
  2969. {
  2970. "success": true,
  2971. "data": {
  2972. "user_id": "xxx",
  2973. "background_image_id": "happynewyear",
  2974. "message": "分享奖励已领取"
  2975. }
  2976. }
  2977. """
  2978. try:
  2979. from database import db_manager
  2980. from models_db import User, UserBackgroundImage
  2981. from sqlalchemy import select, update
  2982. async with db_manager.get_session() as session:
  2983. # 1. 检查用户是否存在
  2984. result = await session.execute(
  2985. select(User).where(User.user_id == user_id)
  2986. )
  2987. user = result.scalar_one_or_none()
  2988. if not user:
  2989. raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND})
  2990. # 2. 检查用户是否已经拥有新年背景
  2991. bg_result = await session.execute(
  2992. select(UserBackgroundImage).where(
  2993. UserBackgroundImage.user_id == user_id,
  2994. UserBackgroundImage.image_id == "happynewyear"
  2995. )
  2996. )
  2997. existing_bg = bg_result.scalar_one_or_none()
  2998. # 3. 如果还没有,添加到用户背景图片列表
  2999. if not existing_bg:
  3000. user_bg_image = UserBackgroundImage(
  3001. user_id=user_id,
  3002. image_id="happynewyear",
  3003. obtained_type="gift",
  3004. obtained_source="share_reward"
  3005. )
  3006. session.add(user_bg_image)
  3007. logger.info(f"✅ 为用户添加新年背景图片: user_id={user_id}")
  3008. else:
  3009. logger.info(f"⏭️ 用户已拥有新年背景图片: user_id={user_id}")
  3010. # 4. 更新用户的当前背景图片
  3011. await session.execute(
  3012. update(User)
  3013. .where(User.user_id == user_id)
  3014. .values(current_background_image_id="happynewyear")
  3015. )
  3016. # 5. 更新用户的 has_shared_avatar 状态
  3017. from models_db import UserUIState
  3018. ui_state_result = await session.execute(
  3019. select(UserUIState).where(UserUIState.user_id == user_id)
  3020. )
  3021. ui_state = ui_state_result.scalar_one_or_none()
  3022. if ui_state:
  3023. ui_state.has_shared_avatar = True
  3024. session.add(ui_state)
  3025. logger.info(f"✅ 更新用户分享头像状态: user_id={user_id}, has_shared_avatar=True")
  3026. else:
  3027. # 如果不存在 UI 状态记录,创建一个新的
  3028. new_ui_state = UserUIState(
  3029. user_id=user_id,
  3030. has_shared_avatar=True
  3031. )
  3032. session.add(new_ui_state)
  3033. logger.info(f"✅ 创建用户UI状态并设置分享头像状态: user_id={user_id}, has_shared_avatar=True")
  3034. # 6. 获取背景图片的 metadata(用于前端设置透明度等)
  3035. bg_metadata = None
  3036. try:
  3037. from models_db import BackgroundImage
  3038. bg_info_result = await session.execute(
  3039. select(BackgroundImage).where(BackgroundImage.image_id == "happynewyear")
  3040. )
  3041. bg_info = bg_info_result.scalar_one_or_none()
  3042. if bg_info and bg_info.metadata_json:
  3043. bg_metadata = bg_info.metadata_json
  3044. except Exception as e:
  3045. logger.warning(f"⚠️ 获取背景图片metadata失败: {e}")
  3046. await session.commit()
  3047. logger.info(f"✅ 用户领取分享奖励成功: user_id={user_id}, background_image_id=happynewyear")
  3048. return {
  3049. "success": True,
  3050. "data": {
  3051. "user_id": user_id,
  3052. "background_image_id": "happynewyear",
  3053. "background_image_metadata": bg_metadata,
  3054. "message": "分享奖励已领取"
  3055. }
  3056. }
  3057. except HTTPException:
  3058. raise
  3059. except Exception as e:
  3060. logger.exception("领取分享奖励失败")
  3061. raise HTTPException(status_code=500, detail={"code": CLAIM_SHARE_REWARD_FAILED})
  3062. @app.get("/api/avatar-gifs")
  3063. async def get_avatar_gifs() -> Dict[str, Any]:
  3064. """
  3065. 获取头像 GIF 配置
  3066. - 优先读取数据库 avatar_assets 表(按 avatar_id、state 分组)
  3067. - 字段:avatar_id, state, image_url
  3068. - 失败/无数据时返回静态兜底配置
  3069. """
  3070. try:
  3071. from database import db_manager
  3072. async with db_manager.get_session() as session:
  3073. result = await session.execute(
  3074. text(
  3075. """
  3076. SELECT avatar_id, state, image_url
  3077. FROM avatar_assets
  3078. WHERE state IN ('default', 'positive', 'negative', 'talking')
  3079. ORDER BY avatar_id, state
  3080. """
  3081. )
  3082. )
  3083. rows = result.mappings().all()
  3084. if rows:
  3085. # 按 avatar_id 分组,转换为期望的格式
  3086. data: Dict[str, Dict[str, str]] = {}
  3087. for row in rows:
  3088. avatar_id = row.get("avatar_id")
  3089. state = row.get("state")
  3090. image_url = row.get("image_url")
  3091. if not avatar_id or not state or not image_url:
  3092. continue
  3093. if avatar_id not in data:
  3094. data[avatar_id] = {}
  3095. data[avatar_id][state] = image_url
  3096. if data:
  3097. return {"success": True, "data": data}
  3098. logger.warning("avatar_assets 表无记录,返回兜底静态配置")
  3099. except Exception as exc:
  3100. logger.error(f"获取头像 GIF 配置失败,使用兜底: {exc}")
  3101. return {"success": True, "data": {k: v.model_dump() for k, v in AVATAR_GIF_CONFIG.items()}}
  3102. @app.get("/api/share-images")
  3103. async def get_share_images() -> Dict[str, Any]:
  3104. """
  3105. 获取分享图片列表
  3106. - 优先从数据库 share_images 表读取(is_active=1,按 sort_order、id 排序)
  3107. - 失败或无数据时返回兜底静态列表
  3108. """
  3109. try:
  3110. from database import db_manager
  3111. async with db_manager.get_session() as session:
  3112. result = await session.execute(
  3113. text(
  3114. """
  3115. SELECT id, code, url, title, sort_order, is_active
  3116. FROM share_images
  3117. WHERE is_active = 1
  3118. ORDER BY sort_order ASC, id ASC
  3119. """
  3120. )
  3121. )
  3122. rows = result.mappings().all()
  3123. if rows:
  3124. data = [
  3125. {
  3126. "id": str(row.get("code") or row.get("id")),
  3127. "url": row.get("url"),
  3128. "title": row.get("title"),
  3129. "sort_order": row.get("sort_order"),
  3130. "is_active": bool(row.get("is_active")) if row.get("is_active") is not None else None,
  3131. }
  3132. for row in rows
  3133. ]
  3134. return {"success": True, "data": data}
  3135. logger.warning("share_images 表无记录,返回兜底静态列表")
  3136. except Exception as exc:
  3137. logger.error(f"获取分享图片失败,使用兜底: {exc}")
  3138. return {"success": True, "data": [item.model_dump() for item in SHARE_IMAGES]}
  3139. @app.put("/api/users/{user_id}/username")
  3140. async def update_username(user_id: str, request: UpdateUsernameRequest, _: None = Depends(require_user_session)):
  3141. """
  3142. 更新用户名
  3143. Args:
  3144. user_id: 用户ID
  3145. request: 更新请求(包含username)
  3146. Returns:
  3147. {
  3148. "success": true,
  3149. "message": "更新成功"
  3150. }
  3151. """
  3152. try:
  3153. success = await user_service.update_username(user_id, request.username)
  3154. if success:
  3155. return {
  3156. "success": True,
  3157. "message": "用户名更新成功"
  3158. }
  3159. else:
  3160. raise HTTPException(status_code=500, detail={"code": UPDATE_USERNAME_FAILED})
  3161. except HTTPException:
  3162. raise
  3163. except Exception as e:
  3164. logger.exception("更新用户名失败")
  3165. raise HTTPException(status_code=500, detail={"code": UPDATE_USERNAME_FAILED})
  3166. @app.put("/api/users/{user_id}/background-image-id")
  3167. async def update_background_image_id(user_id: str, request: UpdateBackgroundImageIdRequest, _: None = Depends(require_user_session)):
  3168. """
  3169. 更新用户当前使用的背景图片ID
  3170. Args:
  3171. user_id: 用户ID
  3172. request: 更新请求(包含background_image_id)
  3173. Returns:
  3174. {
  3175. "success": true,
  3176. "message": "更新成功"
  3177. }
  3178. """
  3179. try:
  3180. success = await user_service.update_current_background_image_id(
  3181. user_id, request.background_image_id
  3182. )
  3183. if not success:
  3184. raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND_OR_UPDATE_FAILED})
  3185. return {"success": True, "message": "更新成功"}
  3186. except HTTPException:
  3187. raise
  3188. except Exception as e:
  3189. logger.exception("更新背景图片ID失败")
  3190. raise HTTPException(status_code=500, detail={"code": UPDATE_BG_IMAGE_FAILED}) from e
  3191. @app.put("/api/users/{user_id}/bot-name")
  3192. async def update_bot_name(user_id: str, request: UpdateBotNameRequest, _: None = Depends(require_user_session)):
  3193. """
  3194. 更新用户给机器人取的名字
  3195. Args:
  3196. user_id: 用户ID
  3197. request: 更新请求(包含bot_name)
  3198. Returns:
  3199. {
  3200. "success": true,
  3201. "message": "更新成功"
  3202. }
  3203. """
  3204. import asyncio
  3205. import time
  3206. start_time = time.time()
  3207. try:
  3208. # 🔥 添加超时保护(5秒),避免长时间等待
  3209. success = await asyncio.wait_for(
  3210. user_service.update_bot_name(user_id, request.bot_name),
  3211. timeout=5.0
  3212. )
  3213. duration = (time.time() - start_time) * 1000
  3214. if success:
  3215. return {
  3216. "success": True,
  3217. "message": "机器人名字更新成功"
  3218. }
  3219. else:
  3220. raise HTTPException(status_code=500, detail={"code": UPDATE_BOT_NAME_FAILED})
  3221. except asyncio.TimeoutError:
  3222. duration = (time.time() - start_time) * 1000
  3223. logger.error(f"更新机器人名字超时: user_id={user_id}, 耗时={duration:.1f}ms")
  3224. raise HTTPException(status_code=504, detail={"code": UPDATE_BOT_NAME_TIMEOUT})
  3225. except HTTPException:
  3226. raise
  3227. except Exception as e:
  3228. logger.exception("更新机器人名字异常")
  3229. raise HTTPException(status_code=500, detail={"code": UPDATE_BOT_NAME_FAILED})
  3230. @app.put("/api/users/{user_id}/preferred-language")
  3231. async def update_preferred_language(
  3232. user_id: str,
  3233. request: UpdatePreferredLanguageRequest,
  3234. _: None = Depends(require_user_session),
  3235. ):
  3236. """更新用户首选界面语言(写入 users.preferred_language)"""
  3237. try:
  3238. ok = await user_service.update_preferred_language(user_id, request.preferred_language)
  3239. if not ok:
  3240. raise HTTPException(status_code=500, detail={"code": UPDATE_PREFERRED_LANGUAGE_FAILED})
  3241. return {"success": True, "message": "语言偏好已更新"}
  3242. except HTTPException:
  3243. raise
  3244. except Exception:
  3245. logger.exception("更新首选语言失败")
  3246. raise HTTPException(status_code=500, detail={"code": UPDATE_PREFERRED_LANGUAGE_FAILED})
  3247. class SubmitFeedbackRequest(BaseModel):
  3248. """提交反馈请求"""
  3249. content: str
  3250. @app.post("/api/users/{user_id}/feedback")
  3251. async def submit_feedback(user_id: str, request: SubmitFeedbackRequest, _: None = Depends(require_user_session)):
  3252. """
  3253. 提交用户反馈
  3254. Args:
  3255. user_id: 用户ID
  3256. request: 反馈请求(包含content)
  3257. Returns:
  3258. {
  3259. "success": true,
  3260. "data": {
  3261. "feedback_id": "uuid",
  3262. "user_id": "uuid",
  3263. "content": "反馈内容",
  3264. "status": "pending",
  3265. "created_at": "2024-01-01T00:00:00"
  3266. }
  3267. }
  3268. """
  3269. try:
  3270. # 验证反馈内容
  3271. if not request.content or not request.content.strip():
  3272. raise HTTPException(status_code=400, detail={"code": FEEDBACK_CONTENT_EMPTY})
  3273. # 验证用户是否存在
  3274. async with db_manager.get_session() as session:
  3275. from models_db import User
  3276. user_result = await session.execute(
  3277. select(User).where(User.user_id == user_id)
  3278. )
  3279. user = user_result.scalar_one_or_none()
  3280. if not user:
  3281. raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND})
  3282. # 创建反馈记录
  3283. feedback_id = str(uuid.uuid4())
  3284. feedback = Feedback(
  3285. feedback_id=feedback_id,
  3286. user_id=user_id,
  3287. content=request.content.strip(),
  3288. status="pending"
  3289. )
  3290. session.add(feedback)
  3291. await session.commit()
  3292. await session.refresh(feedback)
  3293. logger.info(f"✅ 用户反馈提交成功: user_id={user_id}, feedback_id={feedback_id}")
  3294. return {
  3295. "success": True,
  3296. "data": feedback.to_dict()
  3297. }
  3298. except HTTPException:
  3299. raise
  3300. except Exception as e:
  3301. logger.exception("提交反馈失败")
  3302. raise HTTPException(status_code=500, detail={"code": SUBMIT_FEEDBACK_FAILED})
  3303. @app.post("/api/auth/register/phone")
  3304. async def register_with_phone(request: RegisterPhoneRequest):
  3305. """
  3306. 手机号注册
  3307. Returns:
  3308. {
  3309. "success": true,
  3310. "data": {
  3311. "user_id": "uuid",
  3312. "username": "",
  3313. "phone": "",
  3314. "user_type": "registered",
  3315. "token": "会话token",
  3316. "expires_at": "过期时间",
  3317. ...
  3318. }
  3319. }
  3320. """
  3321. try:
  3322. user = await user_service.register_with_phone(
  3323. area_code=request.area_code,
  3324. phone=request.phone,
  3325. username=request.username,
  3326. user_id=request.user_id,
  3327. preferred_language=request.preferred_language,
  3328. )
  3329. # 生成会话 token(注册成功后自动登录)
  3330. session_data = await _generate_session_token(user["user_id"])
  3331. # 将 token 添加到返回数据中
  3332. user.update(session_data)
  3333. return {
  3334. "success": True,
  3335. "data": user
  3336. }
  3337. except AuthError as e:
  3338. raise HTTPException(status_code=400, detail={"code": e.code})
  3339. except Exception as e:
  3340. logger.exception("手机号注册失败")
  3341. raise HTTPException(status_code=500, detail={"code": REGISTER_FAILED})
  3342. @app.post("/api/auth/register/email")
  3343. async def register_with_email(request: RegisterEmailRequest):
  3344. """
  3345. 邮箱注册
  3346. Returns:
  3347. {
  3348. "success": true,
  3349. "data": {
  3350. "user_id": "uuid",
  3351. "username": "",
  3352. "email": "",
  3353. "user_type": "registered",
  3354. "token": "会话token",
  3355. "expires_at": "过期时间",
  3356. ...
  3357. }
  3358. }
  3359. """
  3360. try:
  3361. user = await user_service.register_with_email(
  3362. email=request.email,
  3363. username=request.username,
  3364. user_id=request.user_id,
  3365. preferred_language=request.preferred_language,
  3366. )
  3367. # 生成会话 token(注册成功后自动登录)
  3368. session_data = await _generate_session_token(user["user_id"])
  3369. # 将 token 添加到返回数据中
  3370. user.update(session_data)
  3371. return {
  3372. "success": True,
  3373. "data": user
  3374. }
  3375. except AuthError as e:
  3376. raise HTTPException(status_code=400, detail={"code": e.code})
  3377. except Exception as e:
  3378. logger.exception("邮箱注册失败")
  3379. raise HTTPException(status_code=500, detail={"code": REGISTER_FAILED})
  3380. @app.post("/api/auth/login/wechat")
  3381. async def login_with_wechat(request: LoginWechatRequest):
  3382. """
  3383. 微信登录/注册
  3384. Returns:
  3385. {
  3386. "success": true,
  3387. "data": {
  3388. "user_id": "uuid",
  3389. "username": "",
  3390. "wechat_openid": "",
  3391. "user_type": "registered",
  3392. "token": "会话token",
  3393. "expires_at": "过期时间",
  3394. ...
  3395. }
  3396. }
  3397. """
  3398. try:
  3399. user = await user_service.login_with_wechat(
  3400. openid=request.openid,
  3401. username=request.username
  3402. )
  3403. # 生成会话 token
  3404. session_data = await _generate_session_token(user["user_id"])
  3405. # 将 token 添加到返回数据中
  3406. user.update(session_data)
  3407. return {
  3408. "success": True,
  3409. "data": user
  3410. }
  3411. except Exception as e:
  3412. logger.exception("微信登录失败")
  3413. raise HTTPException(status_code=500, detail={"code": LOGIN_FAILED})
  3414. @app.post("/api/auth/login/qq")
  3415. async def login_with_qq(request: LoginQQRequest):
  3416. """
  3417. QQ登录/注册
  3418. Returns:
  3419. {
  3420. "success": true,
  3421. "data": {
  3422. "user_id": "uuid",
  3423. "username": "",
  3424. "qq_openid": "",
  3425. "user_type": "registered",
  3426. "token": "会话token",
  3427. "expires_at": "过期时间",
  3428. ...
  3429. }
  3430. }
  3431. """
  3432. try:
  3433. user = await user_service.login_with_qq(
  3434. openid=request.openid,
  3435. username=request.username
  3436. )
  3437. # 生成会话 token
  3438. session_data = await _generate_session_token(user["user_id"])
  3439. # 将 token 添加到返回数据中
  3440. user.update(session_data)
  3441. return {
  3442. "success": True,
  3443. "data": user
  3444. }
  3445. except Exception as e:
  3446. logger.exception("QQ登录失败")
  3447. raise HTTPException(status_code=500, detail={"code": LOGIN_FAILED})
  3448. @app.post("/api/auth/login/phone")
  3449. async def login_with_phone(request: LoginPhoneRequest):
  3450. """
  3451. 手机号登录
  3452. Returns:
  3453. {
  3454. "success": true,
  3455. "data": {
  3456. "user_id": "uuid",
  3457. "phone": "",
  3458. "token": "会话token",
  3459. "expires_at": "过期时间",
  3460. ...
  3461. }
  3462. }
  3463. """
  3464. try:
  3465. user = await user_service.login_with_phone(
  3466. phone=request.phone,
  3467. area_code=request.area_code,
  3468. preferred_language=request.preferred_language,
  3469. )
  3470. if user:
  3471. # 生成会话 token
  3472. session_data = await _generate_session_token(user["user_id"])
  3473. # 将 token 添加到返回数据中
  3474. user.update(session_data)
  3475. return {
  3476. "success": True,
  3477. "data": user
  3478. }
  3479. else:
  3480. raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND_REGISTER_FIRST})
  3481. except HTTPException:
  3482. raise
  3483. except Exception as e:
  3484. logger.exception("手机号登录失败")
  3485. raise HTTPException(status_code=500, detail={"code": LOGIN_FAILED})
  3486. @app.post("/api/auth/login/email")
  3487. async def login_with_email(request: LoginEmailRequest):
  3488. """
  3489. 邮箱登录
  3490. Returns:
  3491. {
  3492. "success": true,
  3493. "data": {
  3494. "user_id": "uuid",
  3495. "email": "",
  3496. "token": "会话token",
  3497. "expires_at": "过期时间",
  3498. ...
  3499. }
  3500. }
  3501. """
  3502. try:
  3503. user = await user_service.login_with_email(
  3504. request.email,
  3505. preferred_language=request.preferred_language,
  3506. )
  3507. if user:
  3508. # 生成会话 token
  3509. session_data = await _generate_session_token(user["user_id"])
  3510. # 将 token 添加到返回数据中
  3511. user.update(session_data)
  3512. return {
  3513. "success": True,
  3514. "data": user
  3515. }
  3516. else:
  3517. raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND_REGISTER_FIRST})
  3518. except HTTPException:
  3519. raise
  3520. except Exception as e:
  3521. logger.exception("邮箱登录失败")
  3522. raise HTTPException(status_code=500, detail={"code": LOGIN_FAILED})
  3523. @app.post("/api/auth/email-continue")
  3524. async def email_continue(request: EmailContinueRequest):
  3525. """
  3526. 邮箱登录页 Continue:
  3527. - A/B 特殊用户:免验证码直接登录/首次注册
  3528. - 其他用户:发送验证码,前端跳转验证码输入页
  3529. """
  3530. try:
  3531. if await user_service.is_special_ab_email(request.email):
  3532. user, is_new_user = await user_service.auth_special_email_without_code(
  3533. request.email,
  3534. preferred_language=request.preferred_language,
  3535. )
  3536. session_data = await _generate_session_token(user["user_id"])
  3537. user.update(session_data)
  3538. user["is_new_user"] = is_new_user
  3539. if request.opted_out is not None:
  3540. try:
  3541. await user_service.upsert_community_choice(user["user_id"], request.opted_out)
  3542. except Exception:
  3543. logger.warning("特殊用户登录:同步 community_choice 失败", exc_info=True)
  3544. return {
  3545. "success": True,
  3546. "data": {
  3547. "requires_verification": False,
  3548. **user,
  3549. },
  3550. }
  3551. send_resp = await send_code(
  3552. SendCodeRequest(email=request.email, send_type="email"),
  3553. )
  3554. return {
  3555. "success": True,
  3556. "data": {
  3557. "requires_verification": True,
  3558. "code_id": send_resp["data"]["code_id"],
  3559. "message": send_resp["data"]["message"],
  3560. },
  3561. }
  3562. except AuthError as e:
  3563. raise HTTPException(status_code=400, detail={"code": e.code})
  3564. except HTTPException:
  3565. raise
  3566. except Exception:
  3567. logger.exception("email-continue 失败")
  3568. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  3569. @app.post("/api/auth/send-code")
  3570. async def send_code(request: SendCodeRequest):
  3571. """
  3572. 生成并保存验证码
  3573. 当用户点击 send code 按钮时调用此接口,生成临时验证码并保存到数据库
  3574. Args:
  3575. request: 发送验证码请求
  3576. - phone: 手机号(发送类型为短信时必填)
  3577. - email: 邮箱(发送类型为邮箱时必填)
  3578. - send_type: 发送类型 ("domestic_sms", "international_sms", "email")
  3579. Returns:
  3580. {
  3581. "success": true,
  3582. "data": {
  3583. "code_id": "验证码ID",
  3584. "message": "验证码已生成,等待发送"
  3585. }
  3586. }
  3587. """
  3588. try:
  3589. # 验证参数
  3590. if request.send_type in ["domestic_sms", "international_sms"]:
  3591. if not request.phone:
  3592. raise HTTPException(status_code=400, detail={"code": PHONE_REQUIRED})
  3593. elif request.send_type == "email":
  3594. if not request.email:
  3595. raise HTTPException(status_code=400, detail={"code": EMAIL_REQUIRED})
  3596. else:
  3597. raise HTTPException(status_code=400, detail={"code": SEND_TYPE_INVALID})
  3598. # 生成4位数字验证码
  3599. code = str(random.randint(1000, 9999))
  3600. code_id = str(uuid.uuid4())
  3601. # 保存到数据库
  3602. async with db_manager.get_session() as session:
  3603. # 🔥 将同一手机号/邮箱的旧验证码标记为无效
  3604. # 确保同一时间只有一个有效的验证码
  3605. if request.send_type in ["domestic_sms", "international_sms"]:
  3606. # 将同一手机号的旧验证码标记为无效
  3607. await session.execute(
  3608. update(VerificationCode)
  3609. .where(
  3610. VerificationCode.phone == request.phone,
  3611. VerificationCode.send_type == request.send_type,
  3612. VerificationCode.is_valid == True
  3613. )
  3614. .values(is_valid=False)
  3615. )
  3616. elif request.send_type == "email":
  3617. # 将同一邮箱的旧验证码标记为无效
  3618. await session.execute(
  3619. update(VerificationCode)
  3620. .where(
  3621. VerificationCode.email == request.email,
  3622. VerificationCode.send_type == request.send_type,
  3623. VerificationCode.is_valid == True
  3624. )
  3625. .values(is_valid=False)
  3626. )
  3627. # 创建新的验证码
  3628. verification_code = VerificationCode(
  3629. code_id=code_id,
  3630. area_code=request.area_code,
  3631. phone=request.phone,
  3632. email=request.email,
  3633. code=code,
  3634. send_type=request.send_type,
  3635. is_sent=False,
  3636. is_valid=True,
  3637. )
  3638. session.add(verification_code)
  3639. await session.commit()
  3640. logger.info(f"✅ 验证码已生成: code_id={code_id}, send_type={request.send_type}, area_code={request.area_code}, phone={request.phone}, email={request.email}")
  3641. return {
  3642. "success": True,
  3643. "data": {
  3644. "code_id": code_id,
  3645. "message": "验证码已生成,等待发送"
  3646. }
  3647. }
  3648. except HTTPException:
  3649. raise
  3650. except Exception as e:
  3651. logger.exception("生成验证码失败")
  3652. raise HTTPException(status_code=500, detail={"code": SEND_CODE_FAILED})
  3653. @app.post("/api/auth/verify-code")
  3654. async def verify_code(request: VerifyCodeRequest):
  3655. """
  3656. 验证验证码
  3657. Args:
  3658. request: 验证验证码请求
  3659. - phone: 手机号(发送类型为短信时必填)
  3660. - email: 邮箱(发送类型为邮箱时必填)
  3661. - code: 验证码
  3662. - send_type: 发送类型 ("domestic_sms", "international_sms", "email")
  3663. Returns:
  3664. {
  3665. "success": true,
  3666. "data": {
  3667. "valid": true/false,
  3668. "message": "验证成功" 或 "验证码错误"
  3669. }
  3670. }
  3671. """
  3672. try:
  3673. # 验证参数
  3674. if request.send_type in ["domestic_sms", "international_sms"]:
  3675. if not request.phone:
  3676. raise HTTPException(status_code=400, detail={"code": PHONE_REQUIRED})
  3677. elif request.send_type == "email":
  3678. if not request.email:
  3679. raise HTTPException(status_code=400, detail={"code": EMAIL_REQUIRED})
  3680. else:
  3681. raise HTTPException(status_code=400, detail={"code": SEND_TYPE_INVALID})
  3682. if not request.code:
  3683. raise HTTPException(status_code=400, detail={"code": CODE_REQUIRED})
  3684. # 查询有效的验证码
  3685. async with db_manager.get_session() as session:
  3686. if request.send_type in ["domestic_sms", "international_sms"]:
  3687. result = await session.execute(
  3688. select(VerificationCode)
  3689. .where(
  3690. VerificationCode.phone == request.phone,
  3691. VerificationCode.send_type == request.send_type,
  3692. VerificationCode.code == request.code,
  3693. VerificationCode.is_valid == True
  3694. )
  3695. .order_by(VerificationCode.created_at.desc())
  3696. .limit(1)
  3697. )
  3698. else:
  3699. result = await session.execute(
  3700. select(VerificationCode)
  3701. .where(
  3702. VerificationCode.email == request.email,
  3703. VerificationCode.send_type == request.send_type,
  3704. VerificationCode.code == request.code,
  3705. VerificationCode.is_valid == True
  3706. )
  3707. .order_by(VerificationCode.created_at.desc())
  3708. .limit(1)
  3709. )
  3710. verification_code = result.scalar_one_or_none()
  3711. if verification_code:
  3712. # 验证成功,将验证码标记为无效(一次性使用)
  3713. verification_code.is_valid = False
  3714. await session.commit()
  3715. logger.info(f"✅ 验证码验证成功: phone={request.phone}, email={request.email}, send_type={request.send_type}")
  3716. return {
  3717. "success": True,
  3718. "data": {
  3719. "valid": True,
  3720. "message": "验证成功"
  3721. }
  3722. }
  3723. else:
  3724. logger.warning(f"❌ 验证码验证失败: phone={request.phone}, email={request.email}, code={request.code}")
  3725. return {
  3726. "success": True,
  3727. "data": {
  3728. "valid": False,
  3729. "message": "验证码错误或已失效"
  3730. }
  3731. }
  3732. except HTTPException:
  3733. raise
  3734. except Exception as e:
  3735. logger.exception("验证验证码失败")
  3736. raise HTTPException(status_code=500, detail={"code": VERIFY_CODE_FAILED})
  3737. @app.post("/api/auth/verify-and-auth")
  3738. async def verify_and_auth(request: VerifyAndAuthRequest):
  3739. """
  3740. V2.0:验证码通过后统一登录或注册。
  3741. 返回 { success, data: { user_id, token, expires_at, is_new_user, ... } }
  3742. """
  3743. if request.type not in ("email", "phone"):
  3744. raise HTTPException(status_code=400, detail={"code": TYPE_MUST_BE_EMAIL_OR_PHONE})
  3745. try:
  3746. user, is_new_user = await user_service.verify_and_auth(
  3747. auth_type=request.type,
  3748. code=request.code,
  3749. email=request.email,
  3750. phone=request.phone,
  3751. area_code=request.area_code,
  3752. preferred_language=request.preferred_language,
  3753. )
  3754. session_data = await _generate_session_token(user["user_id"])
  3755. user.update(session_data)
  3756. user["is_new_user"] = is_new_user
  3757. return {"success": True, "data": user}
  3758. except AuthError as e:
  3759. raise HTTPException(status_code=400, detail={"code": e.code})
  3760. except Exception as e:
  3761. logger.exception("verify-and-auth 失败")
  3762. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  3763. @app.get("/api/users/{user_id}/community-choice")
  3764. async def get_community_choice(user_id: str, _: None = Depends(require_user_session)) -> Dict[str, Any]:
  3765. """V2.0:获取用户「退出早期共创社区」选项(登录页回显)"""
  3766. try:
  3767. data = await user_service.get_community_choice(user_id)
  3768. return {"success": True, "data": data or {"opted_out": False}}
  3769. except Exception as e:
  3770. logger.exception("获取 community-choice 失败")
  3771. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  3772. @app.put("/api/users/{user_id}/community-choice")
  3773. async def update_community_choice(
  3774. user_id: str,
  3775. request: CommunityChoiceRequest,
  3776. _: None = Depends(require_user_session),
  3777. ):
  3778. """V2.0:更新用户「退出早期共创社区」选项"""
  3779. try:
  3780. await user_service.upsert_community_choice(user_id, request.opted_out)
  3781. return {"success": True}
  3782. except Exception as e:
  3783. logger.exception("更新 community-choice 失败")
  3784. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  3785. @app.get("/api/users/{user_id}/early-community")
  3786. async def get_early_community(user_id: str, _: None = Depends(require_user_session)) -> Dict[str, Any]:
  3787. """V2.0:获取用户早期共创状态(是否已展示弹窗、是否已留邮箱)"""
  3788. try:
  3789. data = await user_service.get_early_community(user_id)
  3790. return {"success": True, "data": data or {}}
  3791. except Exception as e:
  3792. logger.exception("获取 early-community 失败")
  3793. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  3794. @app.post("/api/users/{user_id}/early-community")
  3795. async def submit_early_community(
  3796. user_id: str,
  3797. request: EarlyCommunityRequest,
  3798. _: None = Depends(require_user_session),
  3799. ):
  3800. """V2.0:提交早期共创(标记弹窗已展示,可选留邮箱)"""
  3801. try:
  3802. data = await user_service.submit_early_community(user_id, request.email)
  3803. return {"success": True, "data": data}
  3804. except Exception as e:
  3805. logger.exception("提交 early-community 失败")
  3806. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  3807. @app.get("/api/users/{user_id}/ui-state")
  3808. async def get_user_ui_state(user_id: str, _: None = Depends(require_user_session)) -> Dict[str, Any]:
  3809. """
  3810. 获取用户前端可视化状态(页面、路由、提示状态)
  3811. """
  3812. try:
  3813. state = await user_service.get_user_ui_state(user_id)
  3814. if not state:
  3815. # 没有记录时返回默认值
  3816. default_state = UserUIStateResponse(
  3817. user_id=user_id,
  3818. current_page=None,
  3819. visited_routes=[],
  3820. )
  3821. return {"success": True, "data": default_state.dict()}
  3822. return {"success": True, "data": state}
  3823. except Exception as e: # noqa: BLE001
  3824. logger.exception("获取用户UI状态失败")
  3825. raise HTTPException(status_code=500, detail={"code": GET_UI_STATE_FAILED}) from e
  3826. @app.post("/api/page-visit")
  3827. async def report_page_visit(
  3828. request: PageVisitRequest,
  3829. user_id: Optional[str] = Depends(optional_user_id),
  3830. ) -> Dict[str, Any]:
  3831. """记录一次页面访问(任意界面打开时上报,未登录也可上报)"""
  3832. path = (request.path or "").strip() or "/"
  3833. await user_service.log_page_visit(user_id, path)
  3834. return {"success": True}
  3835. @app.put("/api/users/{user_id}/ui-state")
  3836. async def update_user_ui_state(
  3837. user_id: str,
  3838. request: UpdateUserUIStateRequest,
  3839. _: None = Depends(require_user_session),
  3840. ) -> Dict[str, Any]:
  3841. """
  3842. 更新用户前端可视化状态(部分字段)
  3843. """
  3844. try:
  3845. updated = await user_service.upsert_user_ui_state(
  3846. user_id=user_id,
  3847. current_page=request.current_page,
  3848. visited_routes=request.visited_routes,
  3849. has_shown_tap_to_chat=request.has_shown_tap_to_chat,
  3850. has_shown_cancel_tooltip=request.has_shown_cancel_tooltip,
  3851. has_shown_send_tooltip=request.has_shown_send_tooltip,
  3852. has_shown_progress_bar_tooltip=request.has_shown_progress_bar_tooltip,
  3853. has_shown_mission_stone_modal=request.has_shown_mission_stone_modal,
  3854. has_shown_conversation_stone_modal=request.has_shown_conversation_stone_modal,
  3855. has_clicked_mission_stone=request.has_clicked_mission_stone,
  3856. has_clicked_conversation_stone=request.has_clicked_conversation_stone,
  3857. has_shown_first_shop_tooltip=request.has_shown_first_shop_tooltip,
  3858. has_clicked_shop_in_settings=request.has_clicked_shop_in_settings,
  3859. avatar_c_unlocked=request.avatar_c_unlocked,
  3860. has_shared_avatar=request.has_shared_avatar,
  3861. current_avatar_id=request.current_avatar_id,
  3862. subtitle_on=request.subtitle_on,
  3863. sfx_enabled=request.sfx_enabled,
  3864. bgm_enabled=request.bgm_enabled,
  3865. )
  3866. if not updated:
  3867. raise HTTPException(status_code=500, detail={"code": UPDATE_UI_STATE_FAILED})
  3868. return {"success": True, "data": updated}
  3869. except HTTPException:
  3870. raise
  3871. except Exception as e: # noqa: BLE001
  3872. logger.exception("更新用户UI状态失败")
  3873. raise HTTPException(status_code=500, detail={"code": UPDATE_UI_STATE_FAILED}) from e
  3874. @app.get("/api/users/{user_id}/intimacy")
  3875. async def get_user_intimacy(user_id: str, _: None = Depends(require_user_session)) -> Dict[str, Any]:
  3876. """获取用户亲密度:heart_count 0-3,days_since_last_visit 距上次访问天数"""
  3877. try:
  3878. data = await user_service.get_intimacy(user_id)
  3879. return {"success": True, "data": data}
  3880. except Exception as e: # noqa: BLE001
  3881. logger.exception("获取亲密度失败")
  3882. raise HTTPException(status_code=500, detail={"code": "GET_INTIMACY_FAILED"}) from e
  3883. @app.post("/api/users/{user_id}/intimacy/light-up")
  3884. async def light_up_intimacy(user_id: str, _: None = Depends(require_user_session)) -> Dict[str, Any]:
  3885. """点亮一颗心,最多 3 颗"""
  3886. try:
  3887. data = await user_service.light_up_intimacy(user_id)
  3888. return {"success": True, "data": data}
  3889. except Exception as e: # noqa: BLE001
  3890. logger.exception("点亮亲密度失败")
  3891. raise HTTPException(status_code=500, detail={"code": "LIGHT_UP_INTIMACY_FAILED"}) from e
  3892. @app.get("/api/users/{user_id}/intimacy/status")
  3893. async def get_user_intimacy_status(user_id: str, _: None = Depends(require_user_session)) -> Dict[str, Any]:
  3894. """
  3895. 获取用户当天是否可以点亮亲密度。
  3896. 返回:
  3897. {
  3898. "success": true,
  3899. "data": {
  3900. "heart_count": int, # 当前心数 0-3
  3901. "can_light_up_today": bool, # 今天是否还能点亮一次
  3902. }
  3903. }
  3904. """
  3905. try:
  3906. data = await user_service.get_intimacy_status(user_id)
  3907. return {"success": True, "data": data}
  3908. except Exception as e: # noqa: BLE001
  3909. logger.exception("获取亲密度状态失败")
  3910. raise HTTPException(status_code=500, detail={"code": "GET_INTIMACY_STATUS_FAILED"}) from e
  3911. # ==================== OAuth 授权端点 ====================
  3912. @app.get("/api/auth/wechat/authorize")
  3913. async def wechat_authorize(request: Request):
  3914. """
  3915. 获取微信授权URL
  3916. 根据微信官方文档:https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
  3917. - 使用 qrconnect 端点进行网站应用授权
  3918. - scope 必须为 snsapi_login
  3919. - redirect_uri 需要 urlEncode 处理
  3920. - state 用于防止 CSRF 攻击
  3921. Returns:
  3922. 重定向到微信授权页面
  3923. """
  3924. if not settings.WECHAT_OPEN_APP_ID or not settings.WECHAT_REDIRECT_URI:
  3925. raise HTTPException(status_code=500, detail={"code": WECHAT_OAUTH_NOT_CONFIGURED})
  3926. # 生成state参数(用于防止CSRF攻击)
  3927. import secrets
  3928. state = secrets.token_urlsafe(32)
  3929. # 将 state 存储到 Redis(如果可用),用于回调时验证
  3930. # TTL 设置为 10 分钟(code 的有效期也是 10 分钟)
  3931. redis_cache = getattr(request.app.state, 'redis_cache', None)
  3932. if redis_cache:
  3933. try:
  3934. redis_client = await redis_cache._get_redis()
  3935. await redis_client.setex(f"oauth:wechat:state:{state}", 600, "1") # 10分钟过期
  3936. except Exception as e:
  3937. logger.warning(f"存储 state 到 Redis 失败,将跳过验证: {e}")
  3938. # 构建微信授权URL(使用开放平台的AppID)
  3939. # redirect_uri 会自动通过 urlencode 进行编码
  3940. params = {
  3941. "appid": settings.WECHAT_OPEN_APP_ID, # 使用开放平台的AppID
  3942. "redirect_uri": settings.WECHAT_REDIRECT_URI, # urlencode 会自动编码
  3943. "response_type": "code",
  3944. "scope": "snsapi_login", # 网站应用必须使用 snsapi_login
  3945. "state": state,
  3946. }
  3947. # 根据官方文档格式:https://open.weixin.qq.com/connect/qrconnect?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=SCOPE&state=STATE#wechat_redirect
  3948. auth_url = f"https://open.weixin.qq.com/connect/qrconnect?{urllib.parse.urlencode(params)}#wechat_redirect"
  3949. return RedirectResponse(url=auth_url)
  3950. @app.get("/api/auth/wechat/callback")
  3951. async def wechat_callback(
  3952. request: Request,
  3953. code: str = Query(..., description="微信授权码"),
  3954. state: str = Query(None, description="状态参数")
  3955. ):
  3956. """
  3957. 微信OAuth回调处理
  3958. 根据微信官方文档流程:
  3959. 1. 验证 state 参数(防止 CSRF 攻击)
  3960. 2. 通过 code 换取 access_token
  3961. 3. 使用 openid 进行登录/注册
  3962. Args:
  3963. code: 微信返回的授权码(有效期10分钟,只能使用一次)
  3964. state: 状态参数(用于验证请求合法性)
  3965. Returns:
  3966. 重定向到前端页面(带token)
  3967. """
  3968. try:
  3969. if not settings.WECHAT_OPEN_APP_ID or not settings.WECHAT_OPEN_APP_SECRET:
  3970. raise HTTPException(status_code=500, detail={"code": WECHAT_OAUTH_NOT_CONFIGURED})
  3971. # 1. 验证 state 参数(防止 CSRF 攻击)
  3972. if state:
  3973. redis_cache = getattr(request.app.state, 'redis_cache', None)
  3974. if redis_cache:
  3975. try:
  3976. redis_client = await redis_cache._get_redis()
  3977. state_key = f"oauth:wechat:state:{state}"
  3978. exists = await redis_client.exists(state_key)
  3979. if not exists:
  3980. logger.warning(f"State 验证失败: {state} (可能已过期或被篡改)")
  3981. return RedirectResponse(
  3982. url=f"{settings.FRONTEND_URL}/register?error=wechat_auth_failed&reason=invalid_state"
  3983. )
  3984. # 验证成功后删除 state(一次性使用)
  3985. await redis_client.delete(state_key)
  3986. except Exception as e:
  3987. logger.warning(f"State 验证异常: {e},继续处理")
  3988. # 2. 通过code获取access_token(使用开放平台的AppID和AppSecret)
  3989. # 根据官方文档:https://api.weixin.qq.com/sns/oauth2/access_token
  3990. token_url = "https://api.weixin.qq.com/sns/oauth2/access_token"
  3991. token_params = {
  3992. "appid": settings.WECHAT_OPEN_APP_ID, # 使用开放平台的AppID
  3993. "secret": settings.WECHAT_OPEN_APP_SECRET, # 使用开放平台的AppSecret
  3994. "code": code,
  3995. "grant_type": "authorization_code",
  3996. }
  3997. async with httpx.AsyncClient() as client:
  3998. token_response = await client.get(token_url, params=token_params)
  3999. token_data = token_response.json()
  4000. if "errcode" in token_data:
  4001. logger.error(f"微信获取access_token失败: {token_data}")
  4002. return RedirectResponse(
  4003. url=f"{settings.FRONTEND_URL}/register?error=wechat_auth_failed"
  4004. )
  4005. access_token = token_data.get("access_token")
  4006. openid = token_data.get("openid")
  4007. # refresh_token 可用于刷新 access_token,当前实现暂不保存
  4008. if not access_token or not openid:
  4009. logger.error(f"微信返回数据不完整: {token_data}")
  4010. return RedirectResponse(
  4011. url=f"{settings.FRONTEND_URL}/register?error=wechat_auth_failed"
  4012. )
  4013. # 3. 使用openid登录/注册
  4014. # 注意:当前使用 snsapi_login scope,只能获取 openid
  4015. # 如需获取用户昵称、头像等信息,需要:
  4016. # - 将 scope 改为 snsapi_login,snsapi_userinfo
  4017. # - 调用 /sns/userinfo 接口获取用户信息
  4018. user = await user_service.login_with_wechat(openid=openid)
  4019. # 4. 重定向到前端,携带用户信息
  4020. frontend_url = f"{settings.FRONTEND_URL}/register?oauth=wechat&user_id={user['user_id']}&username={urllib.parse.quote(user['username'])}"
  4021. return RedirectResponse(url=frontend_url)
  4022. except Exception as e:
  4023. logger.error(f"微信OAuth回调处理失败: {e}")
  4024. return RedirectResponse(
  4025. url=f"{settings.FRONTEND_URL}/register?error=wechat_auth_failed"
  4026. )
  4027. @app.get("/api/auth/qq/authorize")
  4028. async def qq_authorize(
  4029. request: Request,
  4030. from_page: str = Query(None, alias="from", description="来源页面"),
  4031. user_id: str = Query(None, description="可选的现有用户ID,如果提供则更新该用户而不是创建新用户")
  4032. ):
  4033. """
  4034. 获取QQ授权URL
  4035. Args:
  4036. from_page: 来源页面(如 first-login),用于回调时决定重定向位置
  4037. user_id: 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
  4038. Returns:
  4039. 重定向到QQ授权页面
  4040. """
  4041. if not settings.QQ_APP_ID or not settings.QQ_REDIRECT_URI:
  4042. raise HTTPException(status_code=500, detail={"code": QQ_OAUTH_NOT_CONFIGURED})
  4043. # 生成state参数(用于防止CSRF攻击)
  4044. import secrets
  4045. state_token = secrets.token_urlsafe(32)
  4046. # state只存储token,from_page和user_id存储在Redis中(避免token包含下划线导致解析错误)
  4047. state = state_token
  4048. # 将 state 存储到 Redis 以便回调校验(存储原始 token 和来源信息)
  4049. redis_cache = getattr(request.app.state, 'redis_cache', None)
  4050. state_stored = False
  4051. state_key = f"oauth:qq:state:{state_token}"
  4052. logger.info(f"📝 [QQ授权] 开始存储state - state_token={state_token}, from_page={from_page}, user_id={user_id}, state={state}, state_key={state_key}")
  4053. if redis_cache:
  4054. try:
  4055. redis_client = await redis_cache._get_redis()
  4056. # 🔥 检查Redis连接状态
  4057. try:
  4058. ping_result = await redis_client.ping()
  4059. logger.debug(f"✅ [QQ授权] Redis连接正常: ping={ping_result}")
  4060. except Exception as ping_err:
  4061. logger.error(f"❌ [QQ授权] Redis ping失败: {ping_err}")
  4062. # 🔥 检查Redis配置信息(DB、连接池等)
  4063. try:
  4064. redis_info = await redis_client.info('server')
  4065. redis_db = redis_client.connection_pool.connection_kwargs.get('db', 0)
  4066. logger.debug(f"🔍 [QQ授权] Redis配置 - DB={redis_db}, server_version={redis_info.get('redis_version', 'unknown')}")
  4067. except Exception as info_err:
  4068. logger.debug(f"⚠️ [QQ授权] 获取Redis信息失败: {info_err}")
  4069. # 🔥 检查key是否已存在(防止重复)
  4070. existing_value = await redis_client.get(state_key)
  4071. if existing_value:
  4072. logger.warning(f"⚠️ [QQ授权] state key已存在,将被覆盖: key={state_key}, old_value={existing_value[:50]}...")
  4073. # 存储原始 token、来源信息和 user_id
  4074. state_data = {"token": state_token}
  4075. if from_page:
  4076. state_data["from"] = from_page
  4077. if user_id:
  4078. state_data["user_id"] = user_id
  4079. state_value = json.dumps(state_data)
  4080. logger.debug(f"📦 [QQ授权] 准备存储 - key={state_key}, value={state_value}, TTL=1800s")
  4081. # 🔥 增加TTL到1800秒(30分钟),避免授权流程过长导致过期
  4082. await redis_client.setex(state_key, 1800, state_value)
  4083. state_stored = True
  4084. # 🔥 验证存储是否成功
  4085. verify_value = await redis_client.get(state_key)
  4086. if verify_value:
  4087. # 🔥 检查TTL
  4088. ttl = await redis_client.ttl(state_key)
  4089. logger.info(f"✅ [QQ授权] QQ state 已存储并验证成功 - key={state_key}, token={state_token[:16]}..., TTL={ttl}s, value_length={len(verify_value)}")
  4090. else:
  4091. logger.error(f"❌ [QQ授权] QQ state 存储后验证失败 - key={state_key}, token={state_token[:16]}...")
  4092. # 🔥 尝试查找所有相关的keys
  4093. try:
  4094. pattern = f"oauth:qq:state:*"
  4095. keys = []
  4096. async for key in redis_client.scan_iter(match=pattern, count=100):
  4097. keys.append(key)
  4098. logger.debug(f"🔍 [QQ授权] Redis中存在的QQ state keys: {keys[:10]}... (共{len(keys)}个)")
  4099. except Exception as scan_err:
  4100. logger.debug(f"🔍 [QQ授权] 扫描keys失败: {scan_err}")
  4101. except Exception as e:
  4102. logger.error(f"❌ [QQ授权] 存储 QQ state 失败 - key={state_key}, error={e}", exc_info=True)
  4103. else:
  4104. logger.warning("⚠️ [QQ授权] Redis缓存服务不可用,QQ state 验证将被跳过")
  4105. # 确保回调地址格式正确(去除末尾斜杠,确保与QQ应用配置完全一致)
  4106. redirect_uri = settings.QQ_REDIRECT_URI.rstrip('/')
  4107. # 构建QQ授权URL
  4108. # 注意:QQ开放平台要求redirect_uri必须与配置的回调域完全匹配
  4109. params = {
  4110. "response_type": "code",
  4111. "client_id": settings.QQ_APP_ID,
  4112. "redirect_uri": redirect_uri, # 使用处理后的回调地址
  4113. "state": state,
  4114. "scope": "get_user_info", # QQ授权范围
  4115. }
  4116. # URL编码参数(确保特殊字符正确编码)
  4117. auth_url = f"https://graph.qq.com/oauth2.0/authorize?{urllib.parse.urlencode(params)}"
  4118. # 调试日志:记录实际发送的授权URL(隐藏敏感信息)
  4119. logger.info(f"🔍 QQ授权URL生成 - AppID: {settings.QQ_APP_ID[:8]}..., 回调地址: {redirect_uri}, 来源: {from_page}")
  4120. return RedirectResponse(url=auth_url)
  4121. @app.get("/api/qq/mp/verify")
  4122. async def qq_callback(
  4123. request: Request,
  4124. code: str = Query(..., description="QQ授权码"),
  4125. state: str = Query(None, description="状态参数")
  4126. ):
  4127. """
  4128. QQ OAuth回调处理
  4129. Args:
  4130. code: QQ返回的授权码
  4131. state: 状态参数
  4132. Returns:
  4133. 重定向到前端页面(带token)
  4134. """
  4135. try:
  4136. logger.info(f"📥 [QQ回调] 收到回调请求 - code={'***' if code else '(empty)'}, state={state}")
  4137. # 0. 验证 state 防止 CSRF,并提取来源信息
  4138. from_page = None
  4139. user_id_from_state = None
  4140. state_token = state # state就是token,不再需要分割
  4141. if state:
  4142. logger.debug(f"🔍 [QQ回调] 开始解析state - state={state}, length={len(state)}")
  4143. redis_cache = getattr(request.app.state, 'redis_cache', None)
  4144. state_key = f"oauth:qq:state:{state_token}"
  4145. logger.info(f"🔑 [QQ回调] 准备验证state - state_token={state_token[:16]}..., state_key={state_key}")
  4146. if redis_cache:
  4147. try:
  4148. redis_client = await redis_cache._get_redis()
  4149. # 🔥 检查Redis连接状态
  4150. try:
  4151. ping_result = await redis_client.ping()
  4152. logger.debug(f"✅ [QQ回调] Redis连接正常: ping={ping_result}")
  4153. except Exception as ping_error:
  4154. logger.error(f"❌ [QQ回调] Redis ping失败: {ping_error}", exc_info=True)
  4155. # Redis连接失败时,跳过验证但记录警告(为了不影响用户体验)
  4156. logger.warning("⚠️ [QQ回调] Redis连接失败,跳过state验证(可能存在安全风险)")
  4157. # 继续处理,不阻止登录流程
  4158. else:
  4159. # 🔥 检查Redis配置信息(DB、连接池等)
  4160. try:
  4161. redis_db = redis_client.connection_pool.connection_kwargs.get('db', 0)
  4162. logger.debug(f"🔍 [QQ回调] Redis配置 - DB={redis_db}")
  4163. except Exception as info_err:
  4164. logger.debug(f"⚠️ [QQ回调] 获取Redis配置失败: {info_err}")
  4165. # 🔥 尝试查找key
  4166. logger.debug(f"🔍 [QQ回调] 开始查找key: {state_key}")
  4167. state_data_str = await redis_client.get(state_key)
  4168. if not state_data_str:
  4169. # 🔥 增加详细日志,帮助诊断问题
  4170. logger.warning(f"❌ [QQ回调] QQ state 验证失败 - state_token={state_token[:16]}..., state_key={state_key}")
  4171. # 🔥 检查key的TTL(如果key存在但值为None,可能是其他问题)
  4172. try:
  4173. exists = await redis_client.exists(state_key)
  4174. logger.debug(f"🔍 [QQ回调] key存在性检查: exists={exists}")
  4175. if exists:
  4176. ttl = await redis_client.ttl(state_key)
  4177. logger.debug(f"🔍 [QQ回调] key存在但值为空,TTL={ttl}s")
  4178. except Exception as exists_err:
  4179. logger.debug(f"🔍 [QQ回调] 检查key存在性失败: {exists_err}")
  4180. # 🔥 尝试查找所有相关的state key(用于调试)
  4181. try:
  4182. pattern = f"oauth:qq:state:*"
  4183. keys = []
  4184. async for key in redis_client.scan_iter(match=pattern, count=100):
  4185. keys.append(key)
  4186. if keys:
  4187. logger.warning(f"🔍 [QQ回调] Redis中存在其他QQ state keys (共{len(keys)}个): {keys[:10]}")
  4188. # 🔥 检查这些keys的TTL
  4189. for key in keys[:5]:
  4190. try:
  4191. ttl = await redis_client.ttl(key)
  4192. value = await redis_client.get(key)
  4193. logger.debug(f"🔍 [QQ回调] key详情 - {key}: TTL={ttl}s, value_length={len(value) if value else 0}")
  4194. except Exception as key_err:
  4195. logger.debug(f"🔍 [QQ回调] 获取key详情失败 {key}: {key_err}")
  4196. else:
  4197. logger.warning(f"🔍 [QQ回调] Redis中不存在任何QQ state keys")
  4198. except Exception as scan_error:
  4199. logger.debug(f"🔍 [QQ回调] 扫描Redis keys失败: {scan_error}")
  4200. return RedirectResponse(
  4201. url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed&reason=invalid_state"
  4202. )
  4203. # 🔥 找到key,记录详细信息
  4204. ttl = await redis_client.ttl(state_key)
  4205. logger.info(f"✅ [QQ回调] 找到state key - key={state_key}, TTL={ttl}s, value_length={len(state_data_str)}")
  4206. # 如果存储的是 JSON 格式,解析获取来源信息和user_id
  4207. try:
  4208. state_data = json.loads(state_data_str)
  4209. from_page = state_data.get("from") or from_page
  4210. user_id_from_state = state_data.get("user_id")
  4211. logger.info(f"✅ [QQ回调] QQ state 验证成功 - state_token={state_token[:16]}..., 来源: {from_page}, user_id: {user_id_from_state}, data={state_data}")
  4212. except (json.JSONDecodeError, TypeError) as json_err:
  4213. # 如果不是 JSON 格式,使用从 state 中解析的 from_page
  4214. logger.debug(f"✅ [QQ回调] QQ state 验证成功 (非JSON格式) - state_token={state_token[:16]}..., value={state_data_str[:50]}..., json_err={json_err}")
  4215. pass
  4216. # 🔥 删除已使用的state key
  4217. deleted = await redis_client.delete(state_key)
  4218. logger.debug(f"🗑️ [QQ回调] 删除state key - key={state_key}, deleted={deleted}")
  4219. except Exception as state_error:
  4220. logger.error(f"❌ [QQ回调] QQ state 验证异常 - state_token={state_token[:16]}..., error={state_error}", exc_info=True)
  4221. # 🔥 验证异常时不阻止流程,但记录详细错误
  4222. else:
  4223. logger.warning("⚠️ [QQ回调] Redis缓存服务不可用,跳过state验证")
  4224. if not settings.QQ_APP_ID or not settings.QQ_APP_KEY:
  4225. raise HTTPException(status_code=500, detail={"code": QQ_OAUTH_NOT_CONFIGURED})
  4226. # 小工具:解析 QQ JSONP 或 querystring 响应
  4227. def parse_qq_payload(payload_text: str) -> dict:
  4228. cleaned = (payload_text or "").strip()
  4229. if not cleaned:
  4230. return {}
  4231. if cleaned.startswith("callback("):
  4232. cleaned = cleaned.split("callback(", 1)[1]
  4233. cleaned = cleaned.rsplit(");", 1)[0]
  4234. try:
  4235. return json.loads(cleaned)
  4236. except json.JSONDecodeError:
  4237. parsed = {}
  4238. for part in cleaned.split("&"):
  4239. if "=" in part:
  4240. key, value = part.split("=", 1)
  4241. parsed[key] = value
  4242. return parsed
  4243. # 1. 通过code获取access_token
  4244. # 确保回调地址格式正确(与授权时保持一致)
  4245. redirect_uri = settings.QQ_REDIRECT_URI.rstrip('/')
  4246. token_url = "https://graph.qq.com/oauth2.0/token"
  4247. token_params = {
  4248. "grant_type": "authorization_code",
  4249. "client_id": settings.QQ_APP_ID,
  4250. "client_secret": settings.QQ_APP_KEY,
  4251. "code": code,
  4252. "redirect_uri": redirect_uri, # 使用处理后的回调地址
  4253. "fmt": "json",
  4254. }
  4255. logger.info(f"🔍 QQ获取token - 回调地址: {redirect_uri}")
  4256. async with httpx.AsyncClient() as client:
  4257. token_response = await client.get(token_url, params=token_params)
  4258. token_text = token_response.text
  4259. try:
  4260. token_data = token_response.json()
  4261. except ValueError:
  4262. token_data = parse_qq_payload(token_text)
  4263. if "error" in token_data:
  4264. logger.error(f"QQ获取access_token失败: {token_data}")
  4265. return RedirectResponse(
  4266. url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed&reason=token"
  4267. )
  4268. access_token = token_data.get("access_token")
  4269. if not access_token:
  4270. logger.error(f"QQ返回数据不完整: {token_data}")
  4271. return RedirectResponse(
  4272. url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed"
  4273. )
  4274. # 2. 通过access_token获取openid
  4275. openid_url = "https://graph.qq.com/oauth2.0/me"
  4276. openid_params = {
  4277. "access_token": access_token,
  4278. "unionid": 1,
  4279. "fmt": "json",
  4280. }
  4281. async with httpx.AsyncClient() as client:
  4282. openid_response = await client.get(openid_url, params=openid_params)
  4283. openid_data = parse_qq_payload(openid_response.text)
  4284. if "error" in openid_data:
  4285. logger.error(f"QQ获取openid失败: {openid_data}")
  4286. return RedirectResponse(
  4287. url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed"
  4288. )
  4289. openid = openid_data.get("openid")
  4290. unionid = openid_data.get("unionid")
  4291. if not openid:
  4292. logger.error(f"QQ返回openid为空: {openid_data}")
  4293. return RedirectResponse(
  4294. url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed"
  4295. )
  4296. # 2.1 可选:拉取用户信息用于填充昵称
  4297. nickname = ""
  4298. user_info = {}
  4299. user_info_url = "https://graph.qq.com/user/get_user_info"
  4300. user_info_params = {
  4301. "access_token": access_token,
  4302. "oauth_consumer_key": settings.QQ_APP_ID,
  4303. "openid": openid,
  4304. "fmt": "json",
  4305. }
  4306. try:
  4307. async with httpx.AsyncClient() as client:
  4308. user_info_resp = await client.get(
  4309. user_info_url,
  4310. params=user_info_params,
  4311. )
  4312. user_info = user_info_resp.json()
  4313. if user_info.get("ret") == 0:
  4314. nickname = user_info.get("nickname", "") or ""
  4315. else:
  4316. logger.warning(
  4317. f"QQ用户信息获取失败: {user_info}"
  4318. )
  4319. except Exception as info_error:
  4320. logger.warning(f"拉取QQ用户信息异常: {info_error}")
  4321. # 3. 根据来源页面决定处理逻辑
  4322. if from_page == "login":
  4323. # 从 login 页面来的,需要先检查用户是否存在
  4324. # 如果用户不存在,不允许自动注册,应该提示用户先注册
  4325. logger.info(f"🔍 [QQ回调] 开始检查用户是否存在 - openid={openid[:16]}..., unionid={unionid[:16] if unionid else None}...")
  4326. user_exists = await user_service.check_user_exists_by_qq_openid(openid=openid, unionid=unionid)
  4327. logger.info(f"🔍 [QQ回调] 用户存在性检查结果 - user_exists={user_exists}, openid={openid[:16]}...")
  4328. if not user_exists:
  4329. # 用户不存在,重定向到 begin 页面并提示(和手机号登录保持一致)
  4330. logger.info(f"⚠️ QQ用户未注册(来自login页面): openid={openid[:16]}..., unionid={unionid[:16] if unionid else None}...")
  4331. return RedirectResponse(
  4332. url=f"{settings.FRONTEND_URL}/begin?error=user_not_registered&oauth=qq"
  4333. )
  4334. # 用户存在,使用openid登录
  4335. # 不传递QQ返回的nickname,避免覆盖用户原有用户名
  4336. logger.info(f"✅ [QQ回调] 用户存在,开始登录 - openid={openid[:16]}...")
  4337. user = await user_service.login_with_qq(
  4338. openid=openid,
  4339. username="", # 不使用QQ返回的nickname
  4340. unionid=unionid,
  4341. )
  4342. # 🔧 生成会话 token(前端需要 token 来调用 getUserInfo API)
  4343. session_data = await _generate_session_token(user['user_id'])
  4344. token = session_data['token']
  4345. logger.info(f"✅ [QQ回调] 已生成会话 token - user_id={user['user_id']}, token={token[:16]}...")
  4346. # 重定向到聊天界面(传递 token,避免前端调用 ensureSession 时使用错误的 token)
  4347. logger.info(f"✅ [QQ回调] 登录成功,准备重定向到聊天界面 - user_id={user['user_id']}")
  4348. frontend_url = f"{settings.FRONTEND_URL}/chatbot?oauth=qq&user_id={user['user_id']}&token={token}"
  4349. return RedirectResponse(url=frontend_url)
  4350. elif from_page == "first-login":
  4351. # 从 first-login 页面来的,允许自动注册
  4352. # 如果提供了 user_id,则更新现有用户;否则创建新用户
  4353. # 不传递QQ返回的nickname,避免覆盖用户原有用户名
  4354. user = await user_service.login_with_qq(
  4355. openid=openid,
  4356. username="", # 不使用QQ返回的nickname
  4357. unionid=unionid,
  4358. user_id=user_id_from_state,
  4359. )
  4360. # 重定向到 first-login-reward(不传递username,避免使用QQ返回的nickname)
  4361. frontend_url = f"{settings.FRONTEND_URL}/first-login-reward?oauth=qq&user_id={user['user_id']}"
  4362. return RedirectResponse(url=frontend_url)
  4363. else:
  4364. # 其他来源(如 register 页面),允许自动注册
  4365. # 使用openid登录/注册(login_with_qq 会自动创建新用户)
  4366. # 不传递QQ返回的nickname,避免使用QQ昵称作为用户名
  4367. user = await user_service.login_with_qq(
  4368. openid=openid,
  4369. username="", # 不使用QQ返回的nickname
  4370. unionid=unionid,
  4371. )
  4372. # 默认重定向到聊天界面(不传递username,由前端从数据库同步)
  4373. frontend_url = f"{settings.FRONTEND_URL}/chatbot?oauth=qq&user_id={user['user_id']}"
  4374. return RedirectResponse(url=frontend_url)
  4375. except Exception as e:
  4376. logger.error(f"QQ OAuth回调处理失败: {e}")
  4377. return RedirectResponse(
  4378. url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed"
  4379. )
  4380. @app.get("/api/wechat/jsapi/config")
  4381. async def get_wechat_jsapi_config(
  4382. url: str = Query(..., description="当前页面URL(用于生成签名)")
  4383. ):
  4384. """
  4385. 获取微信JS-SDK配置(包含签名)
  4386. Args:
  4387. url: 当前页面完整URL(前端传入,用于生成签名)
  4388. Returns:
  4389. {
  4390. "success": true,
  4391. "data": {
  4392. "appId": "微信AppID",
  4393. "timestamp": 1234567890,
  4394. "nonceStr": "随机字符串",
  4395. "signature": "SHA1签名"
  4396. }
  4397. }
  4398. """
  4399. try:
  4400. if not settings.WECHAT_MP_APP_ID:
  4401. logger.error("微信公众平台AppID未配置,请检查环境变量 WECHAT_MP_APP_ID")
  4402. raise HTTPException(status_code=500, detail={"code": WECHAT_MP_NOT_CONFIGURED})
  4403. # 记录接收到的URL(用于调试)
  4404. logger.info(f"📥 收到微信JS-SDK配置请求: url={url[:100]}...")
  4405. # 获取Redis客户端(用于缓存jsapi_ticket)
  4406. from database import db_manager
  4407. redis_getter = db_manager.get_redis
  4408. # 创建微信JS-SDK服务
  4409. jsapi_service = WechatJSAPIService(redis_getter=redis_getter)
  4410. # 获取配置
  4411. config = await jsapi_service.get_jsapi_config(url)
  4412. if not config:
  4413. logger.error("获取微信JS-SDK配置失败(返回None)")
  4414. raise HTTPException(status_code=500, detail={"code": WECHAT_JSAPI_CONFIG_FAILED})
  4415. logger.info(f"✅ 微信JS-SDK配置生成成功: appId={config.get('appId', 'N/A')[:10]}..., timestamp={config.get('timestamp')}")
  4416. return {
  4417. "success": True,
  4418. "data": config
  4419. }
  4420. except HTTPException:
  4421. raise
  4422. except Exception as e:
  4423. logger.error(f"获取微信JS-SDK配置异常: {e}", exc_info=True)
  4424. logger.exception("获取微信JS-SDK配置异常")
  4425. raise HTTPException(status_code=500, detail={"code": GET_CONFIG_FAILED})
  4426. @app.get("/api/qq/share/config")
  4427. async def get_qq_share_config():
  4428. """
  4429. 获取QQ分享配置(包含AppID和应用名称)
  4430. Returns:
  4431. {
  4432. "success": true,
  4433. "data": {
  4434. "appId": "QQ AppID",
  4435. "appName": "应用名称"
  4436. }
  4437. }
  4438. """
  4439. try:
  4440. if not settings.QQ_APP_ID:
  4441. logger.warning("QQ AppID未配置,返回空配置")
  4442. return {
  4443. "success": True,
  4444. "data": {
  4445. "appId": "",
  4446. "appName": settings.QQ_APP_NAME
  4447. }
  4448. }
  4449. logger.info(f"📥 收到QQ分享配置请求: appId={settings.QQ_APP_ID[:8]}..., appName={settings.QQ_APP_NAME}")
  4450. return {
  4451. "success": True,
  4452. "data": {
  4453. "appId": settings.QQ_APP_ID,
  4454. "appName": settings.QQ_APP_NAME
  4455. }
  4456. }
  4457. except Exception as e:
  4458. logger.exception("获取QQ分享配置异常")
  4459. raise HTTPException(status_code=500, detail={"code": GET_CONFIG_FAILED})
  4460. # ==================== 微信公众平台服务器配置 ====================
  4461. @app.get("/api/wechat/mp/verify")
  4462. async def wechat_mp_verify(
  4463. signature: str = Query(..., description="微信加密签名"),
  4464. timestamp: str = Query(..., description="时间戳"),
  4465. nonce: str = Query(..., description="随机数"),
  4466. echostr: str = Query(..., description="随机字符串")
  4467. ):
  4468. """
  4469. 微信公众平台服务器验证接口(GET)
  4470. 用于微信公众平台配置服务器URL时的验证
  4471. Args:
  4472. signature: 微信加密签名
  4473. timestamp: 时间戳
  4474. nonce: 随机数
  4475. echostr: 随机字符串
  4476. Returns:
  4477. 验证成功返回echostr,失败返回空字符串
  4478. """
  4479. if not settings.WECHAT_MP_TOKEN:
  4480. logger.error("微信公众平台Token未配置")
  4481. return PlainTextResponse("", status_code=500)
  4482. try:
  4483. # 1. 将token、timestamp、nonce按字典序排序
  4484. token = settings.WECHAT_MP_TOKEN
  4485. tmp_arr = [token, timestamp, nonce]
  4486. tmp_arr.sort()
  4487. # 2. 拼接字符串并SHA1加密
  4488. tmp_str = ''.join(tmp_arr)
  4489. tmp_str = hashlib.sha1(tmp_str.encode('utf-8')).hexdigest()
  4490. # 3. 与signature比较
  4491. if tmp_str == signature:
  4492. logger.info("✅ 微信公众平台服务器验证成功")
  4493. return PlainTextResponse(echostr)
  4494. else:
  4495. logger.warning(f"⚠️ 微信公众平台服务器验证失败: signature不匹配")
  4496. return PlainTextResponse("", status_code=403)
  4497. except Exception as e:
  4498. logger.error(f"❌ 微信公众平台服务器验证异常: {e}")
  4499. return PlainTextResponse("", status_code=500)
  4500. @app.post("/api/wechat/mp/message")
  4501. async def wechat_mp_message(request: Request):
  4502. """
  4503. 微信公众平台消息推送接口(POST)
  4504. 接收微信公众平台推送的消息(文本、事件等)
  4505. Returns:
  4506. XML格式的响应消息
  4507. """
  4508. if not settings.WECHAT_MP_TOKEN:
  4509. logger.error("微信公众平台Token未配置")
  4510. return PlainTextResponse("", status_code=500)
  4511. try:
  4512. # 1. 获取请求参数(用于验证签名)
  4513. signature = request.query_params.get("signature", "")
  4514. timestamp = request.query_params.get("timestamp", "")
  4515. nonce = request.query_params.get("nonce", "")
  4516. # 2. 验证签名
  4517. token = settings.WECHAT_MP_TOKEN
  4518. tmp_arr = [token, timestamp, nonce]
  4519. tmp_arr.sort()
  4520. tmp_str = ''.join(tmp_arr)
  4521. tmp_str = hashlib.sha1(tmp_str.encode('utf-8')).hexdigest()
  4522. if tmp_str != signature:
  4523. logger.warning(f"⚠️ 微信公众平台消息签名验证失败")
  4524. return PlainTextResponse("", status_code=403)
  4525. # 3. 读取XML消息体
  4526. body = await request.body()
  4527. xml_data = body.decode('utf-8')
  4528. # 4. 解析XML
  4529. root = ET.fromstring(xml_data)
  4530. msg_type = root.find('MsgType').text if root.find('MsgType') is not None else ''
  4531. from_user = root.find('FromUserName').text if root.find('FromUserName') is not None else ''
  4532. to_user = root.find('ToUserName').text if root.find('ToUserName') is not None else ''
  4533. logger.info(f"📨 收到微信公众平台消息: type={msg_type}, from={from_user}")
  4534. # 5. 处理不同类型的消息
  4535. if msg_type == 'text':
  4536. # 文本消息
  4537. content = root.find('Content').text if root.find('Content') is not None else ''
  4538. logger.info(f"📝 文本消息内容: {content}")
  4539. # 回复文本消息(示例:自动回复)
  4540. reply_content = "感谢您的消息!"
  4541. reply_xml = f"""<xml>
  4542. <ToUserName><![CDATA[{from_user}]]></ToUserName>
  4543. <FromUserName><![CDATA[{to_user}]]></FromUserName>
  4544. <CreateTime>{int(__import__('time').time())}</CreateTime>
  4545. <MsgType><![CDATA[text]]></MsgType>
  4546. <Content><![CDATA[{reply_content}]]></Content>
  4547. </xml>"""
  4548. return PlainTextResponse(reply_xml, media_type="application/xml")
  4549. elif msg_type == 'event':
  4550. # 事件消息
  4551. event = root.find('Event').text if root.find('Event') is not None else ''
  4552. logger.info(f"🎯 事件消息: {event}")
  4553. # 处理关注/取消关注事件
  4554. if event == 'subscribe':
  4555. reply_content = "欢迎关注!"
  4556. elif event == 'unsubscribe':
  4557. reply_content = "感谢您的关注!"
  4558. else:
  4559. reply_content = "收到事件消息"
  4560. reply_xml = f"""<xml>
  4561. <ToUserName><![CDATA[{from_user}]]></ToUserName>
  4562. <FromUserName><![CDATA[{to_user}]]></FromUserName>
  4563. <CreateTime>{int(__import__('time').time())}</CreateTime>
  4564. <MsgType><![CDATA[text]]></MsgType>
  4565. <Content><![CDATA[{reply_content}]]></Content>
  4566. </xml>"""
  4567. return PlainTextResponse(reply_xml, media_type="application/xml")
  4568. else:
  4569. # 其他类型消息,返回空响应
  4570. logger.info(f"ℹ️ 未处理的消息类型: {msg_type}")
  4571. return PlainTextResponse("success")
  4572. except Exception as e:
  4573. logger.error(f"❌ 处理微信公众平台消息异常: {e}")
  4574. return PlainTextResponse("success") # 返回success避免微信重试
  4575. @app.post("/api/admin/inbox/energy-gifts")
  4576. async def admin_create_energy_gift_inbox(payload: AdminCreateEnergyGiftInboxRequest):
  4577. """创建能量石赠送站内信(指定用户)。"""
  4578. user_ids = payload.user_ids
  4579. now = datetime.utcnow()
  4580. try:
  4581. async with db_manager.get_session() as session:
  4582. insert_message_sql = text(
  4583. """
  4584. INSERT INTO inbox_messages (
  4585. message_type, title, title_en, content, content_en, target_type, energy_stone_amount, created_by
  4586. ) VALUES (
  4587. 'energy_gift', :title_zh, :title_en, :content_zh, :content_en, 'users', :energy_stone_amount, 'web-admin'
  4588. )
  4589. """
  4590. )
  4591. result = await session.execute(
  4592. insert_message_sql,
  4593. {
  4594. "title_zh": payload.title_zh.strip(),
  4595. "title_en": payload.title_en.strip(),
  4596. "content_zh": payload.content_zh.strip(),
  4597. "content_en": payload.content_en.strip(),
  4598. "energy_stone_amount": payload.energy_stone_amount,
  4599. },
  4600. )
  4601. message_id = int(result.lastrowid)
  4602. insert_target_sql = text(
  4603. """
  4604. INSERT INTO inbox_message_targets (message_id, user_id, created_at)
  4605. VALUES (:message_id, :user_id, :created_at)
  4606. ON DUPLICATE KEY UPDATE created_at = VALUES(created_at)
  4607. """
  4608. )
  4609. for uid in user_ids:
  4610. await session.execute(
  4611. insert_target_sql,
  4612. {"message_id": message_id, "user_id": uid, "created_at": now},
  4613. )
  4614. await session.commit()
  4615. return {"success": True, "data": {"message_id": message_id, "created_count": len(user_ids)}}
  4616. except Exception:
  4617. logger.exception("创建能量石站内信失败")
  4618. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  4619. @app.post("/api/admin/inbox/news")
  4620. async def admin_create_news_inbox(payload: AdminCreateNewsInboxRequest):
  4621. """创建新闻通知站内信(创建即发送,全员)。"""
  4622. try:
  4623. async with db_manager.get_session() as session:
  4624. insert_sql = text(
  4625. """
  4626. INSERT INTO inbox_messages (
  4627. message_type, title, title_en, content, content_en, target_type, energy_stone_amount, created_by
  4628. ) VALUES (
  4629. 'news_notice', :title_zh, :title_en, :content_zh, :content_en, 'all_users', 0, 'web-admin'
  4630. )
  4631. """
  4632. )
  4633. result = await session.execute(
  4634. insert_sql,
  4635. {
  4636. "title_zh": payload.title_zh.strip(),
  4637. "title_en": payload.title_en.strip(),
  4638. "content_zh": payload.content_zh.strip(),
  4639. "content_en": payload.content_en.strip(),
  4640. },
  4641. )
  4642. await session.commit()
  4643. return {"success": True, "data": {"message_id": int(result.lastrowid)}}
  4644. except Exception:
  4645. logger.exception("创建新闻站内信失败")
  4646. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  4647. @app.get("/api/admin/llm-tasks")
  4648. async def admin_list_llm_tasks(
  4649. page: int = Query(1, ge=1),
  4650. page_size: int = Query(20, ge=1, le=100),
  4651. ):
  4652. """A/B 实验:LLM 任务列表(web-admin)。"""
  4653. try:
  4654. data = await llm_task_service.list_tasks(page=page, page_size=page_size)
  4655. return {"success": True, "data": data}
  4656. except Exception:
  4657. logger.exception("查询 LLM 任务列表失败")
  4658. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  4659. @app.post("/api/admin/llm-tasks")
  4660. async def admin_create_llm_task(payload: AdminCreateLlmTaskRequest):
  4661. """A/B 实验:新建 LLM 任务(新建即成为当前最新任务)。"""
  4662. try:
  4663. task = await llm_task_service.create_task(
  4664. task_name=payload.task_name.strip(),
  4665. task_prompt=payload.task_prompt.strip(),
  4666. lumi_first_line=payload.lumi_first_line.strip(),
  4667. )
  4668. return {"success": True, "data": task}
  4669. except ValueError as e:
  4670. raise HTTPException(status_code=400, detail={"code": "INVALID_PARAMS", "message": str(e)})
  4671. except Exception:
  4672. logger.exception("创建 LLM 任务失败")
  4673. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  4674. @app.put("/api/admin/llm-tasks/{task_id}")
  4675. async def admin_update_llm_task(task_id: int, payload: AdminUpdateLlmTaskRequest):
  4676. """A/B 实验:更新 LLM 任务。"""
  4677. try:
  4678. task = await llm_task_service.update_task(
  4679. task_id=task_id,
  4680. task_name=payload.task_name.strip(),
  4681. task_prompt=payload.task_prompt.strip(),
  4682. lumi_first_line=payload.lumi_first_line.strip(),
  4683. )
  4684. if not task:
  4685. raise HTTPException(status_code=404, detail={"code": "NOT_FOUND"})
  4686. return {"success": True, "data": task}
  4687. except HTTPException:
  4688. raise
  4689. except ValueError as e:
  4690. raise HTTPException(status_code=400, detail={"code": "INVALID_PARAMS", "message": str(e)})
  4691. except Exception:
  4692. logger.exception("更新 LLM 任务失败")
  4693. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  4694. @app.delete("/api/admin/llm-tasks/{task_id}")
  4695. async def admin_delete_llm_task(task_id: int):
  4696. """A/B 实验:删除 LLM 任务。"""
  4697. try:
  4698. deleted = await llm_task_service.delete_task(task_id)
  4699. if not deleted:
  4700. raise HTTPException(status_code=404, detail={"code": "NOT_FOUND"})
  4701. return {"success": True, "data": None}
  4702. except HTTPException:
  4703. raise
  4704. except Exception:
  4705. logger.exception("删除 LLM 任务失败")
  4706. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  4707. @app.get("/api/admin/users")
  4708. async def admin_list_users(
  4709. keyword: Optional[str] = Query(None, description="按 user_id/username/email/phone 模糊搜索"),
  4710. page: int = Query(1, ge=1),
  4711. page_size: int = Query(20, ge=1, le=100),
  4712. ):
  4713. """供 web-admin 选择用户列表使用。"""
  4714. offset = (page - 1) * page_size
  4715. kw = (keyword or "").strip()
  4716. try:
  4717. async with db_manager.get_session() as session:
  4718. if kw:
  4719. like_kw = f"%{kw}%"
  4720. list_sql = text(
  4721. """
  4722. SELECT user_id, username, email, phone
  4723. FROM users
  4724. WHERE user_id LIKE :kw
  4725. OR username LIKE :kw
  4726. OR email LIKE :kw
  4727. OR phone LIKE :kw
  4728. ORDER BY id DESC
  4729. LIMIT :limit OFFSET :offset
  4730. """
  4731. )
  4732. count_sql = text(
  4733. """
  4734. SELECT COUNT(1)
  4735. FROM users
  4736. WHERE user_id LIKE :kw
  4737. OR username LIKE :kw
  4738. OR email LIKE :kw
  4739. OR phone LIKE :kw
  4740. """
  4741. )
  4742. params: Dict[str, Any] = {"kw": like_kw, "limit": page_size, "offset": offset}
  4743. else:
  4744. list_sql = text(
  4745. """
  4746. SELECT user_id, username, email, phone
  4747. FROM users
  4748. ORDER BY id DESC
  4749. LIMIT :limit OFFSET :offset
  4750. """
  4751. )
  4752. count_sql = text("SELECT COUNT(1) FROM users")
  4753. params = {"limit": page_size, "offset": offset}
  4754. rows = (await session.execute(list_sql, params)).mappings().all()
  4755. total = int((await session.execute(count_sql, {"kw": f"%{kw}%"} if kw else {})).scalar_one())
  4756. items = [
  4757. {
  4758. "user_id": row["user_id"],
  4759. "username": row["username"],
  4760. "email": row["email"],
  4761. "phone": row["phone"],
  4762. }
  4763. for row in rows
  4764. ]
  4765. return {"success": True, "data": {"items": items, "total": total, "page": page, "page_size": page_size}}
  4766. except Exception:
  4767. logger.exception("查询管理端用户列表失败")
  4768. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  4769. @app.get("/api/admin/inbox/messages")
  4770. async def admin_list_inbox_messages(
  4771. message_type: Optional[str] = Query(None, description="energy_gift/news_notice"),
  4772. title_keyword: Optional[str] = Query(None, description="标题关键字"),
  4773. start_date: Optional[str] = Query(None, description="开始日期,格式 YYYY-MM-DD"),
  4774. end_date: Optional[str] = Query(None, description="结束日期,格式 YYYY-MM-DD"),
  4775. page: int = Query(1, ge=1),
  4776. page_size: int = Query(20, ge=1, le=100),
  4777. ):
  4778. """管理端站内信历史列表。"""
  4779. if message_type and message_type not in {"energy_gift", "news_notice"}:
  4780. raise HTTPException(status_code=400, detail="invalid message_type")
  4781. offset = (page - 1) * page_size
  4782. where_clauses: list[str] = []
  4783. params: Dict[str, Any] = {"limit": page_size, "offset": offset}
  4784. if message_type:
  4785. where_clauses.append("m.message_type = :message_type")
  4786. params["message_type"] = message_type
  4787. if title_keyword and title_keyword.strip():
  4788. where_clauses.append("(m.title LIKE :title_keyword OR COALESCE(m.title_en, '') LIKE :title_keyword)")
  4789. params["title_keyword"] = f"%{title_keyword.strip()}%"
  4790. if start_date and start_date.strip():
  4791. where_clauses.append("DATE(m.created_at) >= :start_date")
  4792. params["start_date"] = start_date.strip()
  4793. if end_date and end_date.strip():
  4794. where_clauses.append("DATE(m.created_at) <= :end_date")
  4795. params["end_date"] = end_date.strip()
  4796. where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
  4797. try:
  4798. async with db_manager.get_session() as session:
  4799. list_sql = text(
  4800. f"""
  4801. SELECT
  4802. m.id,
  4803. m.message_type,
  4804. m.title,
  4805. COALESCE(m.title_en, '') AS title_en,
  4806. m.target_type,
  4807. m.energy_stone_amount,
  4808. m.created_at,
  4809. CASE
  4810. WHEN m.target_type = 'all_users' THEN (SELECT COUNT(1) FROM users)
  4811. ELSE COALESCE(t.target_count, 0)
  4812. END AS target_count,
  4813. COALESCE(s.read_count, 0) AS read_count,
  4814. COALESCE(s.claim_count, 0) AS claim_count
  4815. FROM inbox_messages m
  4816. LEFT JOIN (
  4817. SELECT message_id, COUNT(1) AS target_count
  4818. FROM inbox_message_targets
  4819. GROUP BY message_id
  4820. ) t ON m.id = t.message_id
  4821. LEFT JOIN (
  4822. SELECT
  4823. message_id,
  4824. SUM(CASE WHEN read_status = 1 THEN 1 ELSE 0 END) AS read_count,
  4825. SUM(CASE WHEN claim_status = 1 THEN 1 ELSE 0 END) AS claim_count
  4826. FROM inbox_user_message_states
  4827. GROUP BY message_id
  4828. ) s ON m.id = s.message_id
  4829. {where_sql}
  4830. ORDER BY m.created_at DESC, m.id DESC
  4831. LIMIT :limit OFFSET :offset
  4832. """
  4833. )
  4834. count_sql = text(
  4835. f"""
  4836. SELECT COUNT(1)
  4837. FROM inbox_messages m
  4838. {where_sql}
  4839. """
  4840. )
  4841. rows = (await session.execute(list_sql, params)).mappings().all()
  4842. count_params = {k: v for k, v in params.items() if k not in {"limit", "offset"}}
  4843. total = int((await session.execute(count_sql, count_params)).scalar_one())
  4844. items = [
  4845. {
  4846. "id": int(row["id"]),
  4847. "message_type": row["message_type"],
  4848. "title": row["title"],
  4849. "title_zh": row["title"],
  4850. "title_en": row["title_en"],
  4851. "target_type": row["target_type"],
  4852. "energy_stone_amount": int(row["energy_stone_amount"] or 0),
  4853. "target_count": int(row["target_count"] or 0),
  4854. "read_count": int(row["read_count"] or 0),
  4855. "claim_count": int(row["claim_count"] or 0),
  4856. "created_at": row["created_at"].isoformat() if row["created_at"] else None,
  4857. }
  4858. for row in rows
  4859. ]
  4860. return {"success": True, "data": {"items": items, "total": total, "page": page, "page_size": page_size}}
  4861. except Exception:
  4862. logger.exception("查询管理端站内信历史失败")
  4863. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  4864. @app.get("/api/admin/inbox/messages/{message_id}")
  4865. async def admin_get_inbox_message_detail(
  4866. message_id: int,
  4867. page: int = Query(1, ge=1),
  4868. page_size: int = Query(50, ge=1, le=200),
  4869. ):
  4870. """管理端站内信详情(含用户阅读/领取状态)。"""
  4871. offset = (page - 1) * page_size
  4872. try:
  4873. async with db_manager.get_session() as session:
  4874. message_sql = text(
  4875. """
  4876. SELECT id, message_type, title, COALESCE(title_en, '') AS title_en, content, COALESCE(content_en, '') AS content_en, target_type, energy_stone_amount, created_at
  4877. FROM inbox_messages
  4878. WHERE id = :message_id
  4879. LIMIT 1
  4880. """
  4881. )
  4882. message_row = (await session.execute(message_sql, {"message_id": message_id})).mappings().first()
  4883. if not message_row:
  4884. raise HTTPException(status_code=404, detail="message not found")
  4885. summary_sql = text(
  4886. """
  4887. SELECT
  4888. COALESCE(SUM(CASE WHEN read_status = 1 THEN 1 ELSE 0 END), 0) AS read_count,
  4889. COALESCE(SUM(CASE WHEN claim_status = 1 THEN 1 ELSE 0 END), 0) AS claim_count
  4890. FROM inbox_user_message_states
  4891. WHERE message_id = :message_id
  4892. """
  4893. )
  4894. summary_row = (await session.execute(summary_sql, {"message_id": message_id})).mappings().first()
  4895. read_count = int(summary_row["read_count"] or 0) if summary_row else 0
  4896. claim_count = int(summary_row["claim_count"] or 0) if summary_row else 0
  4897. if message_row["target_type"] == "all_users":
  4898. target_count = int((await session.execute(text("SELECT COUNT(1) FROM users"))).scalar_one())
  4899. state_list_sql = text(
  4900. """
  4901. SELECT
  4902. s.user_id,
  4903. u.username,
  4904. u.phone,
  4905. u.email,
  4906. s.read_status,
  4907. s.claim_status,
  4908. s.deleted_status
  4909. FROM inbox_user_message_states s
  4910. LEFT JOIN users u ON s.user_id = u.user_id
  4911. WHERE s.message_id = :message_id
  4912. ORDER BY s.updated_at DESC, s.id DESC
  4913. LIMIT :limit OFFSET :offset
  4914. """
  4915. )
  4916. state_count_sql = text(
  4917. "SELECT COUNT(1) FROM inbox_user_message_states WHERE message_id = :message_id"
  4918. )
  4919. state_rows = (
  4920. await session.execute(
  4921. state_list_sql, {"message_id": message_id, "limit": page_size, "offset": offset}
  4922. )
  4923. ).mappings().all()
  4924. state_total = int((await session.execute(state_count_sql, {"message_id": message_id})).scalar_one())
  4925. else:
  4926. target_count_sql = text(
  4927. "SELECT COUNT(1) FROM inbox_message_targets WHERE message_id = :message_id"
  4928. )
  4929. target_count = int((await session.execute(target_count_sql, {"message_id": message_id})).scalar_one())
  4930. state_list_sql = text(
  4931. """
  4932. SELECT
  4933. t.user_id,
  4934. u.username,
  4935. u.phone,
  4936. u.email,
  4937. COALESCE(s.read_status, 0) AS read_status,
  4938. COALESCE(s.claim_status, 0) AS claim_status,
  4939. COALESCE(s.deleted_status, 0) AS deleted_status
  4940. FROM inbox_message_targets t
  4941. LEFT JOIN users u ON t.user_id = u.user_id
  4942. LEFT JOIN inbox_user_message_states s
  4943. ON s.message_id = t.message_id AND s.user_id = t.user_id
  4944. WHERE t.message_id = :message_id
  4945. ORDER BY t.id DESC
  4946. LIMIT :limit OFFSET :offset
  4947. """
  4948. )
  4949. state_count_sql = text("SELECT COUNT(1) FROM inbox_message_targets WHERE message_id = :message_id")
  4950. state_rows = (
  4951. await session.execute(
  4952. state_list_sql, {"message_id": message_id, "limit": page_size, "offset": offset}
  4953. )
  4954. ).mappings().all()
  4955. state_total = int((await session.execute(state_count_sql, {"message_id": message_id})).scalar_one())
  4956. state_items = [
  4957. {
  4958. "user_id": row["user_id"],
  4959. "username": row["username"],
  4960. "phone": row["phone"],
  4961. "email": row["email"],
  4962. "read_status": int(row["read_status"] or 0),
  4963. "claim_status": int(row["claim_status"] or 0),
  4964. "deleted_status": int(row["deleted_status"] or 0),
  4965. }
  4966. for row in state_rows
  4967. ]
  4968. return {
  4969. "success": True,
  4970. "data": {
  4971. "message": {
  4972. "id": int(message_row["id"]),
  4973. "message_type": message_row["message_type"],
  4974. "title": message_row["title"],
  4975. "title_zh": message_row["title"],
  4976. "title_en": message_row["title_en"],
  4977. "content": message_row["content"],
  4978. "content_zh": message_row["content"],
  4979. "content_en": message_row["content_en"],
  4980. "target_type": message_row["target_type"],
  4981. "energy_stone_amount": int(message_row["energy_stone_amount"] or 0),
  4982. "created_at": message_row["created_at"].isoformat() if message_row["created_at"] else None,
  4983. "target_count": target_count,
  4984. "read_count": read_count,
  4985. "claim_count": claim_count,
  4986. },
  4987. "states": {
  4988. "items": state_items,
  4989. "total": state_total,
  4990. "page": page,
  4991. "page_size": page_size,
  4992. },
  4993. },
  4994. }
  4995. except HTTPException:
  4996. raise
  4997. except Exception:
  4998. logger.exception("查询管理端站内信详情失败")
  4999. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  5000. @app.delete("/api/admin/inbox/messages/{message_id}")
  5001. async def admin_delete_inbox_message(message_id: int):
  5002. """管理端删除站内信(物理删除消息与关联状态)。"""
  5003. try:
  5004. async with db_manager.get_session() as session:
  5005. exists_sql = text("SELECT id FROM inbox_messages WHERE id = :message_id LIMIT 1")
  5006. exists_row = (await session.execute(exists_sql, {"message_id": message_id})).mappings().first()
  5007. if not exists_row:
  5008. raise HTTPException(status_code=404, detail="message not found")
  5009. await session.execute(
  5010. text("DELETE FROM inbox_user_message_states WHERE message_id = :message_id"),
  5011. {"message_id": message_id},
  5012. )
  5013. await session.execute(
  5014. text("DELETE FROM inbox_message_targets WHERE message_id = :message_id"),
  5015. {"message_id": message_id},
  5016. )
  5017. await session.execute(
  5018. text("DELETE FROM inbox_messages WHERE id = :message_id"),
  5019. {"message_id": message_id},
  5020. )
  5021. await session.commit()
  5022. return {"success": True}
  5023. except HTTPException:
  5024. raise
  5025. except Exception:
  5026. logger.exception("管理端删除站内信失败")
  5027. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  5028. @app.get("/api/users/{user_id}/inbox/messages")
  5029. async def get_user_inbox_messages(
  5030. user_id: str,
  5031. page: int = Query(1, ge=1),
  5032. page_size: int = Query(20, ge=1, le=100),
  5033. message_type: Optional[str] = Query(None, description="energy_gift/news_notice"),
  5034. _: None = Depends(require_user_session),
  5035. ):
  5036. """查询用户站内信列表(仅返回当前用户未删除消息)。"""
  5037. if message_type and message_type not in {"energy_gift", "news_notice"}:
  5038. raise HTTPException(status_code=400, detail="invalid message_type")
  5039. offset = (page - 1) * page_size
  5040. try:
  5041. async with db_manager.get_session() as session:
  5042. where_type_clause = "AND m.message_type = :message_type" if message_type else ""
  5043. list_sql = text(
  5044. f"""
  5045. SELECT
  5046. m.id,
  5047. m.message_type,
  5048. m.title,
  5049. COALESCE(m.title_en, '') AS title_en,
  5050. m.energy_stone_amount,
  5051. m.created_at,
  5052. COALESCE(s.read_status, 0) AS read_status,
  5053. COALESCE(s.claim_status, 0) AS claim_status
  5054. FROM inbox_messages m
  5055. LEFT JOIN inbox_message_targets t
  5056. ON m.id = t.message_id AND t.user_id = :user_id
  5057. LEFT JOIN inbox_user_message_states s
  5058. ON m.id = s.message_id AND s.user_id = :user_id
  5059. WHERE
  5060. (m.target_type = 'all_users' OR t.user_id IS NOT NULL)
  5061. AND COALESCE(s.deleted_status, 0) = 0
  5062. {where_type_clause}
  5063. ORDER BY m.created_at DESC, m.id DESC
  5064. LIMIT :limit OFFSET :offset
  5065. """
  5066. )
  5067. count_sql = text(
  5068. f"""
  5069. SELECT COUNT(1) AS total
  5070. FROM inbox_messages m
  5071. LEFT JOIN inbox_message_targets t
  5072. ON m.id = t.message_id AND t.user_id = :user_id
  5073. LEFT JOIN inbox_user_message_states s
  5074. ON m.id = s.message_id AND s.user_id = :user_id
  5075. WHERE
  5076. (m.target_type = 'all_users' OR t.user_id IS NOT NULL)
  5077. AND COALESCE(s.deleted_status, 0) = 0
  5078. {where_type_clause}
  5079. """
  5080. )
  5081. params: Dict[str, Any] = {"user_id": user_id, "limit": page_size, "offset": offset}
  5082. if message_type:
  5083. params["message_type"] = message_type
  5084. rows = (await session.execute(list_sql, params)).mappings().all()
  5085. total = int((await session.execute(count_sql, params)).scalar_one())
  5086. items = []
  5087. for row in rows:
  5088. items.append(
  5089. {
  5090. "id": int(row["id"]),
  5091. "type": row["message_type"],
  5092. "title": row["title"],
  5093. "title_zh": row["title"],
  5094. "title_en": row["title_en"],
  5095. "energy_stone_amount": int(row["energy_stone_amount"] or 0),
  5096. "read_status": int(row["read_status"]),
  5097. "claim_status": int(row["claim_status"]),
  5098. "created_at": row["created_at"].isoformat() if row["created_at"] else None,
  5099. }
  5100. )
  5101. return {"success": True, "data": {"items": items, "total": total, "page": page, "page_size": page_size}}
  5102. except Exception:
  5103. logger.exception("查询站内信列表失败")
  5104. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  5105. @app.get("/api/users/{user_id}/inbox/messages/{message_id}")
  5106. async def get_user_inbox_message_detail(
  5107. user_id: str,
  5108. message_id: int,
  5109. mark_read: bool = Query(True),
  5110. _: None = Depends(require_user_session),
  5111. ):
  5112. """查询站内信详情,默认标记为已读。"""
  5113. try:
  5114. async with db_manager.get_session() as session:
  5115. detail_sql = text(
  5116. """
  5117. SELECT
  5118. m.id,
  5119. m.message_type,
  5120. m.title,
  5121. COALESCE(m.title_en, '') AS title_en,
  5122. m.content,
  5123. COALESCE(m.content_en, '') AS content_en,
  5124. m.energy_stone_amount,
  5125. m.created_at,
  5126. COALESCE(s.read_status, 0) AS read_status,
  5127. COALESCE(s.deleted_status, 0) AS deleted_status,
  5128. COALESCE(s.claim_status, 0) AS claim_status
  5129. FROM inbox_messages m
  5130. LEFT JOIN inbox_message_targets t
  5131. ON m.id = t.message_id AND t.user_id = :user_id
  5132. LEFT JOIN inbox_user_message_states s
  5133. ON m.id = s.message_id AND s.user_id = :user_id
  5134. WHERE
  5135. m.id = :message_id
  5136. AND (m.target_type = 'all_users' OR t.user_id IS NOT NULL)
  5137. LIMIT 1
  5138. """
  5139. )
  5140. row = (await session.execute(detail_sql, {"user_id": user_id, "message_id": message_id})).mappings().first()
  5141. if not row or int(row["deleted_status"]) == 1:
  5142. raise HTTPException(status_code=404, detail="message not found")
  5143. if mark_read and int(row["read_status"]) == 0:
  5144. upsert_read_sql = text(
  5145. """
  5146. INSERT INTO inbox_user_message_states (
  5147. message_id, user_id, read_status, read_at
  5148. ) VALUES (
  5149. :message_id, :user_id, 1, NOW()
  5150. )
  5151. ON DUPLICATE KEY UPDATE
  5152. read_status = 1,
  5153. read_at = IFNULL(read_at, NOW())
  5154. """
  5155. )
  5156. await session.execute(upsert_read_sql, {"message_id": message_id, "user_id": user_id})
  5157. await session.commit()
  5158. return {
  5159. "success": True,
  5160. "data": {
  5161. "id": int(row["id"]),
  5162. "type": row["message_type"],
  5163. "title": row["title"],
  5164. "title_zh": row["title"],
  5165. "title_en": row["title_en"],
  5166. "content": row["content"],
  5167. "content_zh": row["content"],
  5168. "content_en": row["content_en"],
  5169. "energy_stone_amount": int(row["energy_stone_amount"] or 0),
  5170. "read_status": 1 if mark_read else int(row["read_status"]),
  5171. "claim_status": int(row["claim_status"]),
  5172. "created_at": row["created_at"].isoformat() if row["created_at"] else None,
  5173. },
  5174. }
  5175. except HTTPException:
  5176. raise
  5177. except Exception:
  5178. logger.exception("查询站内信详情失败")
  5179. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  5180. @app.post("/api/users/{user_id}/inbox/messages/{message_id}/read")
  5181. async def mark_user_inbox_message_read(
  5182. user_id: str, message_id: int, _: None = Depends(require_user_session)
  5183. ):
  5184. """显式标记消息已读。"""
  5185. try:
  5186. async with db_manager.get_session() as session:
  5187. upsert_sql = text(
  5188. """
  5189. INSERT INTO inbox_user_message_states (message_id, user_id, read_status, read_at)
  5190. VALUES (:message_id, :user_id, 1, NOW())
  5191. ON DUPLICATE KEY UPDATE
  5192. read_status = 1,
  5193. read_at = IFNULL(read_at, NOW())
  5194. """
  5195. )
  5196. await session.execute(upsert_sql, {"message_id": message_id, "user_id": user_id})
  5197. await session.commit()
  5198. return {"success": True}
  5199. except Exception:
  5200. logger.exception("标记站内信已读失败")
  5201. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  5202. @app.post("/api/users/{user_id}/inbox/messages/{message_id}/claim")
  5203. async def claim_user_inbox_energy_stones(
  5204. user_id: str, message_id: int, _: None = Depends(require_user_session)
  5205. ):
  5206. """领取能量石站内信奖励(幂等:每条仅可领取一次)。"""
  5207. try:
  5208. async with db_manager.get_session() as session:
  5209. message_sql = text(
  5210. """
  5211. SELECT
  5212. m.id,
  5213. m.message_type,
  5214. m.energy_stone_amount
  5215. FROM inbox_messages m
  5216. LEFT JOIN inbox_message_targets t
  5217. ON m.id = t.message_id AND t.user_id = :user_id
  5218. WHERE
  5219. m.id = :message_id
  5220. AND (m.target_type = 'all_users' OR t.user_id IS NOT NULL)
  5221. LIMIT 1
  5222. """
  5223. )
  5224. message_row = (
  5225. await session.execute(message_sql, {"user_id": user_id, "message_id": message_id})
  5226. ).mappings().first()
  5227. if not message_row:
  5228. raise HTTPException(status_code=404, detail="message not found")
  5229. if message_row["message_type"] != "energy_gift":
  5230. raise HTTPException(status_code=400, detail="message is not energy gift")
  5231. state_sql = text(
  5232. """
  5233. SELECT id, claim_status
  5234. FROM inbox_user_message_states
  5235. WHERE message_id = :message_id AND user_id = :user_id
  5236. FOR UPDATE
  5237. """
  5238. )
  5239. state_row = (
  5240. await session.execute(state_sql, {"message_id": message_id, "user_id": user_id})
  5241. ).mappings().first()
  5242. if state_row and int(state_row["claim_status"]) == 1:
  5243. raise HTTPException(status_code=409, detail="already claimed")
  5244. amount = int(message_row["energy_stone_amount"] or 0)
  5245. if amount <= 0:
  5246. raise HTTPException(status_code=400, detail="invalid energy amount")
  5247. update_user_sql = text(
  5248. """
  5249. UPDATE users
  5250. SET conversation_energy_stone_total = conversation_energy_stone_total + :amount
  5251. WHERE user_id = :user_id
  5252. """
  5253. )
  5254. update_result = await session.execute(update_user_sql, {"amount": amount, "user_id": user_id})
  5255. if int(update_result.rowcount or 0) <= 0:
  5256. raise HTTPException(status_code=404, detail="user not found")
  5257. if state_row:
  5258. update_state_sql = text(
  5259. """
  5260. UPDATE inbox_user_message_states
  5261. SET claim_status = 1,
  5262. claimed_energy_stones = :amount,
  5263. claimed_at = NOW(),
  5264. read_status = 1,
  5265. read_at = IFNULL(read_at, NOW()),
  5266. updated_at = NOW()
  5267. WHERE message_id = :message_id AND user_id = :user_id
  5268. """
  5269. )
  5270. await session.execute(update_state_sql, {"amount": amount, "message_id": message_id, "user_id": user_id})
  5271. else:
  5272. insert_state_sql = text(
  5273. """
  5274. INSERT INTO inbox_user_message_states (
  5275. message_id, user_id, read_status, claim_status,
  5276. claimed_energy_stones, read_at, claimed_at
  5277. ) VALUES (
  5278. :message_id, :user_id, 1, 1,
  5279. :amount, NOW(), NOW()
  5280. )
  5281. """
  5282. )
  5283. await session.execute(insert_state_sql, {"message_id": message_id, "user_id": user_id, "amount": amount})
  5284. total_sql = text("SELECT conversation_energy_stone_total FROM users WHERE user_id = :user_id LIMIT 1")
  5285. total_count = int((await session.execute(total_sql, {"user_id": user_id})).scalar_one())
  5286. await session.commit()
  5287. return {"success": True, "data": {"claimed_energy_stones": amount, "user_total_energy_stones": total_count}}
  5288. except HTTPException:
  5289. raise
  5290. except Exception:
  5291. logger.exception("领取站内信能量石失败")
  5292. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  5293. @app.delete("/api/users/{user_id}/inbox/messages/{message_id}")
  5294. async def delete_user_inbox_message(
  5295. user_id: str, message_id: int, _: None = Depends(require_user_session)
  5296. ):
  5297. """删除新闻通知(用户维度软删除)。"""
  5298. try:
  5299. async with db_manager.get_session() as session:
  5300. message_sql = text(
  5301. """
  5302. SELECT
  5303. m.id,
  5304. m.message_type
  5305. FROM inbox_messages m
  5306. LEFT JOIN inbox_message_targets t
  5307. ON m.id = t.message_id AND t.user_id = :user_id
  5308. WHERE
  5309. m.id = :message_id
  5310. AND (m.target_type = 'all_users' OR t.user_id IS NOT NULL)
  5311. LIMIT 1
  5312. """
  5313. )
  5314. message_row = (
  5315. await session.execute(message_sql, {"user_id": user_id, "message_id": message_id})
  5316. ).mappings().first()
  5317. if not message_row:
  5318. raise HTTPException(status_code=404, detail="message not found")
  5319. if message_row["message_type"] != "news_notice":
  5320. raise HTTPException(status_code=400, detail="energy gift message cannot be deleted")
  5321. upsert_sql = text(
  5322. """
  5323. INSERT INTO inbox_user_message_states (
  5324. message_id, user_id, deleted_status, deleted_at
  5325. ) VALUES (
  5326. :message_id, :user_id, 1, NOW()
  5327. )
  5328. ON DUPLICATE KEY UPDATE
  5329. deleted_status = 1,
  5330. deleted_at = NOW()
  5331. """
  5332. )
  5333. await session.execute(upsert_sql, {"message_id": message_id, "user_id": user_id})
  5334. await session.commit()
  5335. return {"success": True}
  5336. except HTTPException:
  5337. raise
  5338. except Exception:
  5339. logger.exception("删除站内信失败")
  5340. raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
  5341. @app.websocket("/ws")
  5342. async def websocket_endpoint(websocket: WebSocket):
  5343. """WebSocket端点"""
  5344. # 从查询参数获取user_id(Lyrria_user_id)与会话token
  5345. user_id = websocket.query_params.get("user_id")
  5346. token = websocket.query_params.get("token")
  5347. # 记录连接信息
  5348. import logging
  5349. logger = logging.getLogger(__name__)
  5350. logger.info(
  5351. f"🔌 WebSocket连接: user_id={user_id}, query_params={dict(websocket.query_params)}"
  5352. )
  5353. # 🔥 传入Redis缓存服务、user_id、session_store
  5354. redis_cache = getattr(app.state, 'redis_cache', None)
  5355. session_store = getattr(app.state, "session_store", None)
  5356. handler = WebSocketHandler(
  5357. websocket,
  5358. user_id=user_id,
  5359. token=token,
  5360. redis_cache=redis_cache,
  5361. session_store=session_store,
  5362. )
  5363. await handler.handle_connection()
  5364. if __name__ == "__main__":
  5365. import uvicorn
  5366. import os
  5367. import socket
  5368. from pathlib import Path
  5369. # 🔥 DNS预解析(减少首次连接延迟)
  5370. dashscope_domain = get_dashscope_domain()
  5371. try:
  5372. socket.getaddrinfo(dashscope_domain, 443)
  5373. except Exception as e:
  5374. pass
  5375. # SSL证书路径配置
  5376. # 优先使用环境变量配置的证书路径(生产环境)
  5377. ssl_keyfile = settings.SSL_KEYFILE
  5378. ssl_certfile = settings.SSL_CERTFILE
  5379. # 如果环境变量未配置,使用本地开发证书
  5380. if not ssl_keyfile or not ssl_certfile:
  5381. ssl_dir = Path(__file__).parent.parent / "ssl"
  5382. ssl_keyfile = str(ssl_dir / "key.pem")
  5383. ssl_certfile = str(ssl_dir / "cert.pem")
  5384. # 检查证书是否存在
  5385. if ssl_keyfile and ssl_certfile:
  5386. if not os.path.exists(ssl_keyfile) or not os.path.exists(ssl_certfile):
  5387. ssl_keyfile = None
  5388. ssl_certfile = None
  5389. uvicorn.run(
  5390. "main:app",
  5391. host=settings.host,
  5392. port=settings.port,
  5393. reload=settings.DEBUG,
  5394. ssl_keyfile=ssl_keyfile,
  5395. ssl_certfile=ssl_certfile,
  5396. )