router.py 145 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530
  1. """
  2. API路由 - v1版本
  3. 完整实现认证、充值、心跳、公告、密钥管理等核心接口
  4. 符合统一用户友好错误提示规范
  5. """
  6. from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
  7. from pydantic import BaseModel, Field
  8. from typing import Optional, Dict, Any, List
  9. from sqlalchemy.orm import Session
  10. from datetime import datetime
  11. import time
  12. import hashlib
  13. import json
  14. from app.database import get_db
  15. from app.core.encrypt import encryptor
  16. from app.middleware.security import create_encrypted_response, secure_endpoint, decrypt_request_data
  17. from app.services.auth_service import AuthService, verify_password
  18. from app.services.recharge_service import RechargeService
  19. from app.services.heartbeat_service import HeartbeatService
  20. from app.services.announcement_service import AnnouncementService
  21. from app.services.client_version_service import ClientVersionService
  22. from app.services.key_service import KeyService
  23. from app.services.steam_account_service import SteamAccountService
  24. from app.utils.response import create_client_response, UserFriendlyError
  25. from app.models.buff_filter_option import BuffFilterOption
  26. from app.models.buff_selection_config import BuffSelectionConfig
  27. from app.models.buff_selection_request import BuffSelectionRequest
  28. from app.models.buff_goods_attr import BuffGoodsAttr
  29. from app.models.buff_goods_info import BuffGoodsInfo
  30. from app.services.buff_selection_service import run_selection_task
  31. from fastapi.responses import JSONResponse
  32. from app.utils.timezone import beijing_now_naive
  33. from app.models.system_config import SystemConfig
  34. from app.models.buff_selection_request_item import BuffSelectionRequestItem
  35. from app.models.buff_selection_request_log import BuffSelectionRequestLog
  36. from app.models.buff_sweep_request import BuffSweepRequest
  37. from app.models.buff_sweep_request_item import BuffSweepRequestItem
  38. from app.models.buff_sweep_request_log import BuffSweepRequestLog
  39. from app.models.steam_account import SteamAccount
  40. from app.services.buff_sweep_service import run_sweep_task
  41. from app.utils.machine_code import raw_machine_info_to_machine_code as _raw_to_machine_code
  42. from app.core.logger import logger
  43. router = APIRouter()
  44. # ============ 请求/响应模型 ============
  45. class LoginRequest(BaseModel):
  46. """登录请求"""
  47. username: str = Field(..., min_length=3, max_length=100, description="用户名")
  48. password: str = Field(..., min_length=6, max_length=100, description="密码")
  49. machine_code: str = Field(..., description="机器码")
  50. class RegisterRequest(BaseModel):
  51. """注册请求"""
  52. username: str = Field(..., min_length=3, max_length=100, description="用户名")
  53. password: str = Field(..., min_length=6, max_length=100, description="密码")
  54. email: Optional[str] = Field(None, description="邮箱")
  55. card_code: Optional[str] = Field(None, description="卡密代码")
  56. class RechargeRequest(BaseModel):
  57. """充值请求"""
  58. username: str = Field(..., description="用户名")
  59. password: str = Field(..., description="密码")
  60. card_code: str = Field(..., description="卡密代码")
  61. class UnbindRequest(BaseModel):
  62. """解绑请求"""
  63. username: str = Field(..., description="用户名")
  64. password: str = Field(..., description="密码")
  65. class ForgotPasswordRequest(BaseModel):
  66. """找回密码请求"""
  67. username: str = Field(..., min_length=1, description="用户名")
  68. email: str = Field(..., min_length=1, description="邮箱")
  69. class HeartbeatRequest(BaseModel):
  70. """心跳请求"""
  71. session_id: str = Field(..., description="会话ID")
  72. machine_code: str = Field(..., description="机器码")
  73. class AnnouncementsRequest(BaseModel):
  74. """公告请求"""
  75. page: int = Field(1, ge=1, description="页码")
  76. page_size: int = Field(10, ge=1, le=100, description="每页数量")
  77. class LogoutRequest(BaseModel):
  78. """登出请求"""
  79. session_id: str = Field(..., description="会话ID")
  80. class ClientConfigRequest(BaseModel):
  81. """客户端配置请求"""
  82. session_id: str = Field(..., description="会话ID")
  83. class ClientVersionCheckRequest(BaseModel):
  84. """客户端版本检查请求"""
  85. client_version: str = Field(..., description="当前客户端版本")
  86. platform: Optional[str] = Field("windows", description="平台: windows/macos/linux")
  87. channel: Optional[str] = Field("stable", description="通道: stable/beta")
  88. # ============ 响应模型 ============
  89. class UserInfoResponse(BaseModel):
  90. """用户信息响应"""
  91. user_id: int
  92. username: str
  93. email: Optional[str]
  94. expire_time: Optional[str]
  95. days_remaining: int
  96. status: int
  97. class AnnouncementsResponse(BaseModel):
  98. """公告响应"""
  99. data: List[Dict[str, Any]]
  100. total: int
  101. page: int
  102. page_size: int
  103. class HeartbeatResponse(BaseModel):
  104. """心跳响应"""
  105. status: str
  106. session_id: str
  107. expire_time: str
  108. config: Dict[str, Any]
  109. # ============ 辅助函数 ============
  110. def handle_service_result(success: bool, message: str, data: Any = None,
  111. error_mapping: Optional[Dict[str, int]] = None) -> Dict[str, Any]:
  112. """
  113. 处理服务层返回结果,转换为统一错误响应格式
  114. Args:
  115. success: 是否成功
  116. message: 消息
  117. data: 响应数据
  118. error_mapping: 错误消息到状态码的映射
  119. Returns:
  120. 统一格式的响应字典
  121. """
  122. if success:
  123. return create_client_response(200, message, data)
  124. # 默认错误映射
  125. if error_mapping is None:
  126. error_mapping = {
  127. "密码错误": 401,
  128. "用户名": 401,
  129. "不存在": 404,
  130. "禁用": 403,
  131. "到期": 403,
  132. "已使用": 403,
  133. "已过期": 403
  134. }
  135. # 根据消息内容确定错误码
  136. status_code = 400 # 默认
  137. for key, code in error_mapping.items():
  138. if key in message:
  139. status_code = code
  140. break
  141. # 返回错误响应(使用统一的错误响应格式)
  142. return create_client_response(status_code, message, None)
  143. # ============ 认证接口 ============
  144. def _raw_machine_info_to_machine_code(decrypted_params: dict) -> str:
  145. """从解密参数中取 raw_machine_info,规范化后由后端统一计算 MD5 作为 machine_code(不信任客户端传入的 machine_code)"""
  146. raw = (decrypted_params.get("raw_machine_info") or decrypted_params.get("machine_code") or "").strip()
  147. return _raw_to_machine_code(raw)
  148. @router.post("/auth/login")
  149. @secure_endpoint(require_login=False, require_machine_code=False)
  150. async def login(decrypted_params: dict, request: Request, db: Session, session=None, user=None):
  151. """
  152. 用户登录(安全端点:请求体为加密 params,响应为加密 data)
  153. - 解密后取 username、password、raw_machine_info
  154. - 后端对 raw_machine_info 做 MD5 得到 machine_code,不信任客户端传入的 machine_code
  155. 统一错误提示:401 用户名或密码错误;403 卡密已过期/机器码不匹配/账号被禁用
  156. """
  157. try:
  158. username = (decrypted_params.get("username") or "").strip()
  159. password = decrypted_params.get("password") or ""
  160. machine_code = _raw_machine_info_to_machine_code(decrypted_params)
  161. if not username or not password:
  162. return handle_service_result(False, "请求参数错误,请检查后重试", None, {"参数": 400})
  163. client_version = (decrypted_params.get("client_version") or "").strip()
  164. platform = (decrypted_params.get("platform") or "windows").strip()
  165. channel = (decrypted_params.get("channel") or "stable").strip()
  166. if client_version:
  167. version_service = ClientVersionService(db)
  168. should_block, result = version_service.should_block_client(
  169. client_version=client_version,
  170. platform=platform,
  171. channel=channel,
  172. )
  173. if should_block:
  174. return create_client_response(403, "客户端版本过低,请更新到最新版本后继续使用", {
  175. "version_check": result
  176. })
  177. software_code = (decrypted_params.get("software_code") or "").strip()
  178. auth_service = AuthService(db)
  179. success, message, data = auth_service.login(
  180. username=username,
  181. password=password,
  182. machine_code=machine_code,
  183. software_code=software_code or None
  184. )
  185. if success:
  186. logger.info(f"登录成功: {username}")
  187. return create_client_response(200, message, data)
  188. error_mapping = {"密码错误": 401, "用户名": 401, "禁用": 403, "到期": 403}
  189. return handle_service_result(success, message, None, error_mapping)
  190. except Exception as e:
  191. logger.exception(f"登录接口异常: {e}")
  192. return create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
  193. @router.post("/auth/register")
  194. @secure_endpoint(require_login=False, require_machine_code=False)
  195. async def register(decrypted_params: dict, request: Request, db: Session, session=None, user=None):
  196. """
  197. 用户注册(安全端点:请求体为加密 params)
  198. 统一错误提示:400 用户名已存在
  199. """
  200. try:
  201. username = (decrypted_params.get("username") or "").strip()
  202. password = decrypted_params.get("password") or ""
  203. email = decrypted_params.get("email") or ""
  204. card_code = decrypted_params.get("card_code") or ""
  205. software_code = (decrypted_params.get("software_code") or "").strip()
  206. if not username or not password:
  207. return handle_service_result(False, "请求参数错误,请检查后重试", None, {"参数": 400})
  208. auth_service = AuthService(db)
  209. success, message, data = auth_service.register(
  210. username=username,
  211. password=password,
  212. email=email or None,
  213. card_code=card_code or None,
  214. software_code=software_code or None
  215. )
  216. if success:
  217. return create_client_response(200, message, data)
  218. return handle_service_result(success, message, None, {"已存在": 400})
  219. except Exception as e:
  220. logger.exception(f"注册接口异常: {e}")
  221. return create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
  222. @router.post("/auth/logout")
  223. async def logout(request: Request, db: Session = Depends(get_db)):
  224. """
  225. 用户登出
  226. - 支持两种加密格式:① encrypted_params + aes_key(与 secure_endpoint 一致)② 单 blob:encrypted + key_version
  227. - 也支持明文:{"session_id": "..."}(兼容)
  228. - 登出成功后数据库 sessions 表对应记录的 is_active 置为 0
  229. """
  230. start_time = time.time()
  231. session_id = ""
  232. try:
  233. body = await request.json()
  234. if not isinstance(body, dict):
  235. logger.warning("[logout] 请求体非 dict")
  236. return {
  237. **create_client_response(400, "请求参数错误,请检查后重试", None),
  238. "_execution_time": int((time.time() - start_time) * 1000),
  239. }
  240. body_keys = list(body.keys()) if body else []
  241. logger.info(f"[logout] 收到登出请求 body_keys={body_keys}")
  242. # 格式1:encrypted_params + aes_key(与登录/心跳一致,部分客户端走此格式)
  243. encrypted_params = (body.get("encrypted_params") or "").strip()
  244. aes_key_b64 = (body.get("aes_key") or "").strip()
  245. if encrypted_params and aes_key_b64:
  246. try:
  247. key_version = int(body.get("key_version") or 1)
  248. decrypted = decrypt_request_data(
  249. encrypted_params, key_version, db, encrypted_aes_key=aes_key_b64
  250. )
  251. session_id = (decrypted.get("session_id") or "").strip()
  252. logger.info(f"[logout] 解密(encrypted_params)得到 session_id 长度={len(session_id)}")
  253. except Exception as e:
  254. logger.warning(f"[logout] encrypted_params 解密失败: {e}")
  255. return {
  256. **create_client_response(400, "请求参数错误,请检查后重试", None),
  257. "_execution_time": int((time.time() - start_time) * 1000),
  258. }
  259. # 格式2:单 blob encrypted + key_version(api_client.make_request)
  260. elif body.get("encrypted") and body.get("key_version") is not None:
  261. try:
  262. key_version = int(body.get("key_version", 1))
  263. decrypted = encryptor.decrypt_from_client(
  264. (body.get("encrypted") or "").strip(), key_version, db
  265. )
  266. session_id = (decrypted.get("session_id") or "").strip()
  267. logger.info(f"[logout] 解密(encrypted blob)得到 session_id 长度={len(session_id)}")
  268. except Exception as e:
  269. logger.warning(f"[logout] encrypted blob 解密失败: {e}")
  270. return {
  271. **create_client_response(400, "请求参数错误,请检查后重试", None),
  272. "_execution_time": int((time.time() - start_time) * 1000),
  273. }
  274. else:
  275. # 明文 session_id(兼容)
  276. session_id = (body.get("session_id") or "").strip()
  277. if session_id:
  278. logger.info(f"[logout] 明文 session_id 长度={len(session_id)}")
  279. if not session_id:
  280. logger.warning("[logout] 未解析到 session_id")
  281. return {
  282. **create_client_response(400, "请求参数错误,请检查后重试", None),
  283. "_execution_time": int((time.time() - start_time) * 1000),
  284. }
  285. auth_service = AuthService(db)
  286. success, message = auth_service.logout(session_id=session_id)
  287. logger.info(f"[logout] 登出结果 success={success} session_id_len={len(session_id)}")
  288. execution_time = int((time.time() - start_time) * 1000)
  289. return {
  290. **create_client_response(200 if success else 400, message),
  291. "_execution_time": execution_time,
  292. }
  293. except HTTPException:
  294. raise
  295. except Exception as e:
  296. logger.exception(f"登出接口异常: {e}")
  297. raise HTTPException(
  298. status_code=500,
  299. detail="系统异常,请稍后重试;若持续发生请联系客服",
  300. )
  301. @router.post("/auth/forgot-password")
  302. async def forgot_password(request_data: ForgotPasswordRequest, db: Session = Depends(get_db)):
  303. """
  304. 找回密码:提交用户名和邮箱,匹配则写入工单表,由后台脚本扫描并发送新密码邮件。
  305. 为防泄露,无论是否匹配均返回相同提示。
  306. """
  307. try:
  308. logger.info(f"[Python forgot-password] Step 1 - 收到请求: username='{request_data.username}', email='{request_data.email}'")
  309. auth_service = AuthService(db)
  310. success, message = auth_service.forgot_password(
  311. username=request_data.username.strip(),
  312. email=request_data.email.strip()
  313. )
  314. logger.info(f"[Python forgot-password] Step 2 - 处理结果: success={success}, message={message}")
  315. return create_client_response(200 if success else 500, message, None)
  316. except Exception as e:
  317. logger.exception(f"[Python forgot-password] Step 3 - 发生异常: {e}")
  318. return create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
  319. @router.post("/auth/recharge")
  320. @secure_endpoint(require_login=False, require_machine_code=False)
  321. async def recharge(decrypted_params: dict, request: Request, db: Session, session=None, user=None):
  322. """
  323. 卡密充值(安全端点:请求体为加密 params)
  324. 统一错误提示:404 卡密不存在;403 卡密已使用/已过期
  325. """
  326. try:
  327. username = (decrypted_params.get("username") or "").strip()
  328. password = decrypted_params.get("password") or ""
  329. card_code = (decrypted_params.get("card_code") or "").strip()
  330. software_code = (decrypted_params.get("software_code") or "").strip()
  331. if not username or not password or not card_code:
  332. return handle_service_result(False, "请求参数错误,请检查后重试", None, {"参数": 400})
  333. recharge_service = RechargeService(db)
  334. success, message, data = recharge_service.recharge(
  335. username=username,
  336. password=password,
  337. card_code=card_code,
  338. software_code=software_code or None
  339. )
  340. if success:
  341. return create_client_response(200, message, data)
  342. return handle_service_result(success, message, None, {"不存在": 404, "已使用": 403, "已过期": 403})
  343. except Exception as e:
  344. logger.exception(f"充值接口异常: {e}")
  345. return create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
  346. @router.post("/auth/unbind")
  347. @secure_endpoint(require_login=False, require_machine_code=False)
  348. async def unbind(decrypted_params: dict, request: Request, db: Session, session=None, user=None):
  349. """
  350. 机器解绑(安全端点:请求体为加密 params)
  351. 统一错误提示:404 用户不存在;401 密码错误
  352. """
  353. try:
  354. username = (decrypted_params.get("username") or "").strip()
  355. password = decrypted_params.get("password") or ""
  356. if not username or not password:
  357. return handle_service_result(False, "请求参数错误,请检查后重试", None, {"参数": 400})
  358. machine_code = _raw_machine_info_to_machine_code(decrypted_params)
  359. auth_service = AuthService(db)
  360. success, message = auth_service.unbind(
  361. username=username,
  362. password=password,
  363. machine_code=machine_code
  364. )
  365. if success:
  366. return create_client_response(200, message)
  367. return handle_service_result(success, message, None, {"不存在": 404, "密码错误": 401})
  368. except Exception as e:
  369. logger.exception(f"解绑接口异常: {e}")
  370. return create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
  371. @router.post("/auth/change-password")
  372. @secure_endpoint(require_login=False, require_machine_code=False)
  373. async def change_password(decrypted_params: dict, request: Request, db: Session, session=None, user=None):
  374. """
  375. 修改密码(安全端点:请求体为加密 params,含 username、old_password、new_password)
  376. 统一错误提示:用户不存在、当前密码错误、新密码格式不符合要求
  377. """
  378. try:
  379. username = (decrypted_params.get("username") or "").strip()
  380. old_password = (decrypted_params.get("old_password") or "").strip()
  381. new_password = (decrypted_params.get("new_password") or "").strip()
  382. if not username or not old_password or not new_password:
  383. return handle_service_result(False, "请求参数错误,请检查后重试", None, {"参数": 400})
  384. auth_service = AuthService(db)
  385. success, message = auth_service.change_password(
  386. username=username,
  387. old_password=old_password,
  388. new_password=new_password
  389. )
  390. if success:
  391. return create_client_response(200, message)
  392. return handle_service_result(
  393. success, message, None,
  394. {"用户不存在": 404, "当前密码错误": 401, "新密码": 400}
  395. )
  396. except Exception as e:
  397. logger.exception(f"修改密码接口异常: {e}")
  398. return create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
  399. # ============ 心跳接口 ============
  400. @router.post("/heartbeat")
  401. @secure_endpoint(require_login=True, require_machine_code=True)
  402. async def heartbeat(decrypted_params: dict, request: Request, db: Session, session=None, user=None):
  403. """
  404. 心跳检测(安全端点:请求体为加密 params,含 session_id、machine_code/raw_machine_info)
  405. - 与登录一致:对 raw_machine_info 或 machine_code 做 MD5 后与 session 绑定值比较
  406. - AES+RSA 加密请求与响应
  407. - 需要登录验证
  408. - 返回系统配置(带签名响应,防止中间人攻击)
  409. 统一错误提示:
  410. - 401: 会话已过期
  411. - 403: 卡密已到期
  412. """
  413. try:
  414. session_id = (decrypted_params.get("session_id") or "").strip()
  415. # 与登录接口一致:后端统一用 MD5 计算 machine_code,再与 session.machine_code 比较
  416. machine_code = _raw_machine_info_to_machine_code(decrypted_params)
  417. software_code = (decrypted_params.get("software_code") or "").strip()
  418. client_version = (decrypted_params.get("client_version") or "").strip()
  419. platform = (decrypted_params.get("platform") or "").strip()
  420. channel = (decrypted_params.get("channel") or "").strip()
  421. if not session_id or not machine_code:
  422. return {"code": 400, "msg": "请求参数错误,请检查后重试"}
  423. heartbeat_service = HeartbeatService(db)
  424. success, message, data = heartbeat_service.heartbeat(
  425. session_id=session_id,
  426. machine_code=machine_code,
  427. software_code=software_code or None,
  428. client_version=client_version,
  429. platform=platform,
  430. channel=channel,
  431. )
  432. if success:
  433. logger.debug(f"心跳检测成功: session_id={session_id}, renewed={data.get('session_info', {}).get('renewed', False)}")
  434. return {"code": 200, "msg": message, **data}
  435. else:
  436. error_code = 400
  437. if "过期" in message or "登录" in message:
  438. error_code = 401
  439. elif "到期" in message or "禁用" in message or "版本" in message:
  440. error_code = 403
  441. return {"code": error_code, "msg": message}
  442. except Exception as e:
  443. logger.exception(f"心跳接口异常: {e}")
  444. return {"code": 500, "msg": "系统异常,请稍后重试;若持续发生请联系客服"}
  445. @router.post("/client/version/check")
  446. @secure_endpoint(require_login=False, require_machine_code=False)
  447. async def post_client_version_check(decrypted_params: dict, request: Request, db: Session, session=None, user=None):
  448. """
  449. 客户端版本检查(安全端点)
  450. - 登录前/登录后均可调用
  451. - 返回最新版本、强更策略、下载地址、富文本更新日志
  452. """
  453. try:
  454. client_version = (decrypted_params.get("client_version") or "").strip()
  455. platform = (decrypted_params.get("platform") or "windows").strip()
  456. channel = (decrypted_params.get("channel") or "stable").strip()
  457. if not client_version:
  458. return create_client_response(400, "请求参数错误,请检查后重试", None)
  459. version_service = ClientVersionService(db)
  460. result = version_service.build_check_result(
  461. client_version=client_version,
  462. platform=platform,
  463. channel=channel,
  464. )
  465. return create_client_response(200, "成功", result)
  466. except Exception as e:
  467. logger.exception(f"客户端版本检查接口异常: {e}")
  468. return create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
  469. # ============ 公告接口 ============
  470. @router.get("/announcements")
  471. async def get_announcements(
  472. page: int = Query(1, ge=1, description="页码"),
  473. page_size: int = Query(10, ge=1, le=100, description="每页数量"),
  474. db: Session = Depends(get_db)
  475. ):
  476. """
  477. 获取公告列表
  478. - AES+RSA加密
  479. - 无需登录
  480. """
  481. start_time = time.time()
  482. try:
  483. announcement_service = AnnouncementService(db)
  484. success, message, data = announcement_service.get_announcements(
  485. page=page,
  486. page_size=page_size
  487. )
  488. execution_time = int((time.time() - start_time) * 1000)
  489. return {
  490. **create_client_response(200 if success else 400, message, data),
  491. "_execution_time": execution_time
  492. }
  493. except Exception as e:
  494. logger.exception(f"获取公告接口异常: {e}")
  495. raise HTTPException(
  496. status_code=500,
  497. detail="系统异常,请稍后重试;若持续发生请联系客服"
  498. )
  499. @router.post("/announcements")
  500. @secure_endpoint(require_login=False, require_machine_code=False)
  501. async def post_announcements(decrypted_params: dict, request: Request, db: Session, session=None, user=None):
  502. """
  503. 获取公告列表(安全端点:请求体为加密 params,含 page、page_size、keyword)
  504. """
  505. try:
  506. page = int(decrypted_params.get("page") or 1)
  507. page_size = int(decrypted_params.get("page_size") or 10)
  508. if page < 1:
  509. page = 1
  510. if page_size < 1 or page_size > 100:
  511. page_size = 10
  512. announcement_service = AnnouncementService(db)
  513. success, message, data = announcement_service.get_announcements(
  514. page=page,
  515. page_size=page_size
  516. )
  517. if success:
  518. return create_client_response(200, message, data)
  519. return create_client_response(400, message, data)
  520. except Exception as e:
  521. logger.exception(f"POST 公告接口异常: {e}")
  522. return create_client_response(500, "系统异常,请稍后重试", None)
  523. # ============ 用户信息接口 ============
  524. @router.post("/user/profile")
  525. @secure_endpoint(require_login=True, require_machine_code=True)
  526. async def get_profile(decrypted_params: dict, request: Request, db: Session, session=None, user=None):
  527. """
  528. 获取用户信息(安全端点:请求体为加密 params,含 session_id、machine_code)
  529. - AES+RSA 加密请求与响应
  530. - 需要登录验证
  531. - 返回带签名的安全响应
  532. 统一错误提示:
  533. - 401: 会话已过期
  534. - 403: 账号在其他设备登录
  535. """
  536. try:
  537. if not user:
  538. return {"code": 404, "msg": "用户不存在"}
  539. from app.services.heartbeat_service import (
  540. _get_subscription_expire,
  541. _get_subscription_special_close,
  542. DEFAULT_SOFTWARE_CODE,
  543. )
  544. software_code = (decrypted_params.get("software_code") or "").strip() or DEFAULT_SOFTWARE_CODE
  545. expire_time = _get_subscription_expire(db, user.id, software_code)
  546. days_remaining = 0
  547. if expire_time:
  548. days_remaining = (expire_time - beijing_now_naive()).days
  549. special_close = _get_subscription_special_close(db, user.id, software_code)
  550. user_data = {
  551. "user_id": user.id,
  552. "username": user.username,
  553. "email": user.email,
  554. "expire_time": expire_time.isoformat() if expire_time else None,
  555. "days_remaining": days_remaining,
  556. "special_close": special_close,
  557. "status": user.status
  558. }
  559. logger.debug(f"获取用户信息成功: user_id={user.id}")
  560. return {"code": 200, "msg": "成功", **user_data}
  561. except Exception as e:
  562. logger.exception(f"获取用户信息异常: {e}")
  563. return {"code": 500, "msg": "系统异常,请稍后重试;若持续发生请联系客服"}
  564. # ============ 密钥管理接口 ============
  565. def _post_keys_public_encrypt_response(
  566. public_key_pem: str,
  567. key_version: int,
  568. private_key_pem: str,
  569. client_aes_key_b64: str,
  570. timestamp: int,
  571. nonce: str,
  572. ) -> Dict[str, Any]:
  573. """
  574. 构造 POST /keys/public 的加密响应(与客户端约定一致):
  575. data.encrypted_key = AES-CBC( response_key+response_iv, client_aes_key, iv=client_aes_key[16:32] )
  576. data.encrypted_data = AES-CBC( json(public_key, key_version, signing_public_key), response_key, response_iv )
  577. data.signature = RSA-PSS( timestamp:nonce:encrypted_data_b64 )
  578. """
  579. import base64
  580. import json
  581. import secrets
  582. from cryptography.hazmat.primitives import serialization, hashes
  583. from cryptography.hazmat.primitives.asymmetric import padding as rsa_padding
  584. from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
  585. from cryptography.hazmat.primitives.padding import PKCS7
  586. from cryptography.hazmat.backends import default_backend
  587. client_aes_key_b64 = (client_aes_key_b64 or "").strip()
  588. client_aes = base64.b64decode(client_aes_key_b64)
  589. if len(client_aes) < 32:
  590. raise ValueError("aes_key 长度不足")
  591. key_c = client_aes[:32]
  592. iv_c = client_aes[16:32]
  593. response_key = secrets.token_bytes(32)
  594. response_iv = secrets.token_bytes(16)
  595. payload = json.dumps({
  596. "public_key": public_key_pem,
  597. "key_version": key_version,
  598. "signing_public_key": public_key_pem,
  599. }).encode()
  600. padder = PKCS7(128).padder()
  601. padded = padder.update(payload) + padder.finalize()
  602. cipher_r = Cipher(algorithms.AES(response_key), modes.CBC(response_iv), backend=default_backend())
  603. enc_r = cipher_r.encryptor()
  604. encrypted_data_bytes = enc_r.update(padded) + enc_r.finalize()
  605. encrypted_data_b64 = base64.b64encode(encrypted_data_bytes).decode()
  606. key_iv = response_key + response_iv # 48 字节
  607. # CBC 需 PKCS7 填充,否则客户端 OpenSSL 解密时会按填充校验导致 bad decrypt
  608. padder = PKCS7(128).padder()
  609. key_iv_padded = padder.update(key_iv) + padder.finalize()
  610. cipher_c = Cipher(algorithms.AES(key_c), modes.CBC(iv_c), backend=default_backend())
  611. enc_c = cipher_c.encryptor()
  612. encrypted_key_bytes = enc_c.update(key_iv_padded) + enc_c.finalize()
  613. encrypted_key_b64 = base64.b64encode(encrypted_key_bytes).decode()
  614. logger.info("[keys/public 加密响应] key_iv_len=48 padded_len=%s encrypted_key_len=%s", len(key_iv_padded), len(encrypted_key_bytes))
  615. sign_string = f"{timestamp}:{nonce}:{encrypted_data_b64}"
  616. private_key = serialization.load_pem_private_key(
  617. private_key_pem.encode(), password=None, backend=default_backend()
  618. )
  619. signature = private_key.sign(
  620. sign_string.encode(),
  621. rsa_padding.PSS(mgf=rsa_padding.MGF1(hashes.SHA256()), salt_length=rsa_padding.PSS.MAX_LENGTH),
  622. hashes.SHA256(),
  623. )
  624. signature_b64 = base64.b64encode(signature).decode()
  625. return {
  626. "encrypted_key": encrypted_key_b64,
  627. "encrypted_data": encrypted_data_b64,
  628. "signature": signature_b64,
  629. "signing_public_key": public_key_pem,
  630. "timestamp": timestamp,
  631. "nonce": nonce,
  632. }
  633. @router.post("/keys/public")
  634. async def post_public_key(request: Request, db: Session = Depends(get_db)):
  635. """
  636. POST 获取RSA公钥(与客户端握手约定一致)
  637. 请求体: encrypted_params (AES加密的机器特征), aes_key (base64), timestamp, nonce
  638. 响应 data: encrypted_key, encrypted_data, signature, signing_public_key, timestamp, nonce
  639. """
  640. import base64
  641. from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
  642. from cryptography.hazmat.backends import default_backend
  643. try:
  644. body = await request.json()
  645. encrypted_params_b64 = (body.get("encrypted_params") or "").strip()
  646. aes_key_b64 = (body.get("aes_key") or "").strip()
  647. ts = body.get("timestamp", 0)
  648. nonce = (body.get("nonce") or "").strip()
  649. logger.info(
  650. "[POST /keys/public] encrypted_params_b64_len=%s aes_key_b64_len=%s ts=%s",
  651. len(encrypted_params_b64), len(aes_key_b64), ts,
  652. )
  653. if not aes_key_b64 or not encrypted_params_b64:
  654. return create_client_response(400, "请求参数错误", None)
  655. aes_bytes = base64.b64decode(aes_key_b64)
  656. logger.info("[POST /keys/public] aes_bytes decoded len=%s (需>=32)", len(aes_bytes))
  657. if len(aes_bytes) < 32:
  658. return create_client_response(400, "aes_key 无效", None)
  659. key_a = aes_bytes[:32]
  660. iv_a = aes_bytes[16:32]
  661. try:
  662. enc_bytes = base64.b64decode(encrypted_params_b64)
  663. except Exception:
  664. return create_client_response(400, "encrypted_params 无效", None)
  665. cipher = Cipher(algorithms.AES(key_a), modes.CBC(iv_a), backend=default_backend())
  666. dec = cipher.decryptor()
  667. decrypted_padded = dec.update(enc_bytes) + dec.finalize()
  668. try:
  669. from cryptography.hazmat.primitives.padding import PKCS7
  670. unpadder = PKCS7(128).unpadder()
  671. decrypted_bytes = unpadder.update(decrypted_padded) + unpadder.finalize()
  672. except Exception:
  673. decrypted_bytes = decrypted_padded
  674. try:
  675. raw_machine_info = decrypted_bytes.decode("utf-8", errors="replace")
  676. except Exception:
  677. raw_machine_info = ""
  678. logger.debug("POST /keys/public raw_machine_info length=%s", len(raw_machine_info))
  679. key_service = KeyService(db)
  680. success, message, data = key_service.get_active_key_pair()
  681. if not success or not data:
  682. return create_client_response(500, message or "系统初始化中,请稍后再试", None)
  683. public_key_pem = data["public_key"]
  684. private_key_pem = data["private_key"]
  685. key_version = data["key_version"]
  686. response_data = _post_keys_public_encrypt_response(
  687. public_key_pem, key_version, private_key_pem, aes_key_b64, ts, nonce
  688. )
  689. return create_client_response(200, "获取成功", response_data)
  690. except Exception as e:
  691. logger.exception("POST /keys/public 异常: %s", e)
  692. return create_client_response(500, "系统异常,请稍后重试", None)
  693. @router.get("/keys/public")
  694. async def get_public_key(db: Session = Depends(get_db)):
  695. """
  696. 获取RSA公钥
  697. - 无需登录
  698. - 返回当前活跃版本的RSA公钥
  699. 统一错误提示:
  700. - 500: 系统初始化中
  701. """
  702. start_time = time.time()
  703. try:
  704. key_service = KeyService(db)
  705. success, message, data = key_service.get_active_public_key()
  706. execution_time = int((time.time() - start_time) * 1000)
  707. if success:
  708. return {
  709. **create_client_response(200, message, data),
  710. "_execution_time": execution_time
  711. }
  712. else:
  713. # 公钥不存在时,返回友好的系统初始化提示
  714. return {
  715. **create_client_response(500, "系统初始化中,请稍后再试"),
  716. "_execution_time": execution_time
  717. }
  718. except Exception as e:
  719. logger.exception(f"获取公钥接口异常: {e}")
  720. raise HTTPException(
  721. status_code=500,
  722. detail="系统异常,请稍后重试;若持续发生请联系客服"
  723. )
  724. @router.post("/keys/sync")
  725. @secure_endpoint(require_login=True, require_machine_code=True)
  726. async def sync_public_key(decrypted_params: dict, request: Request, db: Session, session=None, user=None):
  727. """
  728. 同步RSA公钥(已登录状态,符合技术方案 4.3.2)
  729. - 请求体为加密 params,含 session_id、machine_code
  730. - 返回当前激活的 public_key 与 key_version(可能比客户端更新)
  731. """
  732. try:
  733. key_service = KeyService(db)
  734. success, message, data = key_service.get_active_public_key()
  735. if not success or not data:
  736. return {"code": 500, "msg": message or "系统初始化中,请稍后再试"}
  737. return {
  738. "code": 200,
  739. "msg": "成功",
  740. "public_key": data.get("public_key"),
  741. "key_version": data.get("key_version"),
  742. }
  743. except Exception as e:
  744. logger.exception(f"同步公钥异常: {e}")
  745. return {"code": 500, "msg": "系统异常,请稍后重试"}
  746. @router.get("/keys/version")
  747. async def get_key_version(db: Session = Depends(get_db)):
  748. """
  749. 获取当前RSA密钥版本
  750. - 无需登录
  751. """
  752. start_time = time.time()
  753. try:
  754. key_service = KeyService(db)
  755. success, message, data = key_service.get_current_key_version()
  756. execution_time = int((time.time() - start_time) * 1000)
  757. if success:
  758. return {
  759. **create_client_response(200, message, data),
  760. "_execution_time": execution_time
  761. }
  762. else:
  763. return {
  764. **create_client_response(500, message),
  765. "_execution_time": execution_time
  766. }
  767. except Exception as e:
  768. logger.exception(f"获取密钥版本接口异常: {e}")
  769. raise HTTPException(
  770. status_code=500,
  771. detail="系统异常,请稍后重试;若持续发生请联系客服"
  772. )
  773. # ============ 配置管理接口 ============
  774. @router.get("/config/client")
  775. async def get_client_config(db: Session = Depends(get_db)):
  776. """
  777. 获取客户端配置
  778. - 无需登录
  779. - 返回客户端相关的系统配置
  780. """
  781. start_time = time.time()
  782. try:
  783. key_service = KeyService(db)
  784. success, message, data = key_service.get_client_configs()
  785. execution_time = int((time.time() - start_time) * 1000)
  786. if success:
  787. return {
  788. **create_client_response(200, message, data),
  789. "_execution_time": execution_time
  790. }
  791. else:
  792. return {
  793. **create_client_response(500, message),
  794. "_execution_time": execution_time
  795. }
  796. except Exception as e:
  797. logger.exception(f"获取客户端配置接口异常: {e}")
  798. raise HTTPException(
  799. status_code=500,
  800. detail="系统异常,请稍后重试;若持续发生请联系客服"
  801. )
  802. # ============ HMAC密钥管理接口 ============
  803. class HmacKeyRequest(BaseModel):
  804. """HMAC密钥请求"""
  805. username: str = Field(..., description="用户名")
  806. machine_code: str = Field(..., description="机器码")
  807. @router.post("/keys/hmac")
  808. async def sync_hmac_key(request: HmacKeyRequest, db: Session = Depends(get_db)):
  809. """
  810. 同步/获取动态HMAC密钥
  811. - 无需登录(但需要验证用户名和机器码)
  812. - 用于客户端获取或刷新HMAC签名密钥
  813. - 密钥有效期24小时,支持自动轮换
  814. 统一错误提示:
  815. - 401: 用户名不存在/密码错误
  816. - 403: 账号被禁用/机器码不匹配
  817. - 500: 系统异常
  818. """
  819. start_time = time.time()
  820. try:
  821. # 导入HMAC密钥服务
  822. from app.services.hmac_key_service import HmacKeyService
  823. hmac_service = HmacKeyService(db)
  824. # 先验证用户
  825. is_valid, msg, user_id = hmac_service.validate_user_for_hmac(
  826. request.username,
  827. request.machine_code
  828. )
  829. if not is_valid:
  830. # 根据错误类型返回不同的状态码
  831. error_mapping = {
  832. "不存在": 401,
  833. "禁用": 403,
  834. "不匹配": 403,
  835. }
  836. status_code = 400
  837. for key, code in error_mapping.items():
  838. if key in msg:
  839. status_code = code
  840. break
  841. return {
  842. **create_client_response(status_code, msg, None),
  843. "_execution_time": int((time.time() - start_time) * 1000)
  844. }
  845. # 创建HMAC密钥
  846. success, message, data = hmac_service.create_hmac_key(
  847. user_id=user_id,
  848. username=request.username,
  849. machine_code=request.machine_code
  850. )
  851. execution_time = int((time.time() - start_time) * 1000)
  852. if success:
  853. logger.info(f"HMAC密钥同步成功: {request.username}")
  854. return {
  855. **create_client_response(200, message, data),
  856. "_execution_time": execution_time
  857. }
  858. else:
  859. # 根据错误类型返回不同的状态码
  860. error_mapping = {
  861. "不存在": 401,
  862. "禁用": 403,
  863. "不匹配": 403,
  864. "已过期": 403
  865. }
  866. status_code = 400
  867. for key, code in error_mapping.items():
  868. if key in message:
  869. status_code = code
  870. break
  871. return {
  872. **create_client_response(status_code, message, None),
  873. "_execution_time": execution_time
  874. }
  875. except Exception as e:
  876. logger.exception(f"HMAC密钥同步接口异常: {e}")
  877. raise HTTPException(
  878. status_code=500,
  879. detail="系统异常,请稍后重试;若持续发生请联系客服"
  880. )
  881. @router.get("/keys/hmac/status")
  882. async def get_hmac_key_status(
  883. username: str = Query(..., description="用户名"),
  884. machine_code: str = Query(..., description="机器码"),
  885. db: Session = Depends(get_db)
  886. ):
  887. """
  888. 检查HMAC密钥状态
  889. - 无需登录
  890. - 返回当前密钥是否有效及过期时间
  891. """
  892. start_time = time.time()
  893. try:
  894. from app.services.hmac_key_service import HmacKeyService
  895. hmac_service = HmacKeyService(db)
  896. # 先验证用户
  897. is_valid, msg, user_id = hmac_service.validate_user_for_hmac(username, machine_code)
  898. if not is_valid:
  899. return create_client_response(401, msg, None)
  900. # 获取当前密钥状态
  901. success, message, data = hmac_service.get_hmac_key(user_id, machine_code)
  902. execution_time = int((time.time() - start_time) * 1000)
  903. if success:
  904. return {
  905. **create_client_response(200, message, data),
  906. "_execution_time": execution_time
  907. }
  908. else:
  909. return {
  910. **create_client_response(404, message),
  911. "_execution_time": execution_time
  912. }
  913. except Exception as e:
  914. logger.exception(f"获取HMAC密钥状态接口异常: {e}")
  915. raise HTTPException(
  916. status_code=500,
  917. detail="系统异常,请稍后重试;若持续发生请联系客服"
  918. )
  919. @router.delete("/keys/hmac")
  920. async def revoke_hmac_key(
  921. username: str = Query(..., description="用户名"),
  922. machine_code: str = Query(..., description="机器码"),
  923. db: Session = Depends(get_db)
  924. ):
  925. """
  926. 撤销HMAC密钥
  927. - 需要登录
  928. - 用于登出时清除HMAC密钥
  929. """
  930. start_time = time.time()
  931. try:
  932. from app.services.hmac_key_service import HmacKeyService
  933. from app.services.auth_service import AuthService
  934. # 验证用户
  935. auth_service = AuthService(db)
  936. user_id, msg, _ = auth_service.validate_session_or_token(username)
  937. if user_id is None:
  938. return create_client_response(401, msg or "会话已过期", None)
  939. # 撤销HMAC密钥
  940. hmac_service = HmacKeyService(db)
  941. success, message = hmac_service.revoke_hmac_key(user_id, machine_code)
  942. execution_time = int((time.time() - start_time) * 1000)
  943. return {
  944. **create_client_response(200 if success else 500, message, None),
  945. "_execution_time": execution_time
  946. }
  947. except Exception as e:
  948. logger.exception(f"撤销HMAC密钥接口异常: {e}")
  949. raise HTTPException(
  950. status_code=500,
  951. detail="系统异常,请稍后重试;若持续发生请联系客服"
  952. )
  953. # ============ Steam账号管理接口 ============
  954. class CreateSteamAccountRequest(BaseModel):
  955. """创建/绑定 Steam 账号请求(敏感信息存客户端本地,仅传账号标识)"""
  956. user_id: int = Field(..., description="用户ID")
  957. steam_username: str = Field(..., description="Steam用户名")
  958. steam_id: Optional[str] = Field(None, description="Steam ID(来自客户端登录验证)")
  959. class UpdateSteamAccountRequest(BaseModel):
  960. """更新Steam账号请求"""
  961. session_id: str = Field(..., description="会话ID")
  962. machine_code: str = Field(..., description="机器码(raw 或 md5)")
  963. account_id: int = Field(..., description="账号ID")
  964. is_active: Optional[bool] = Field(None, description="是否启用")
  965. class DeleteSteamAccountRequest(BaseModel):
  966. """删除Steam账号请求"""
  967. user_id: int = Field(..., description="用户ID")
  968. account_id: int = Field(..., description="账号ID")
  969. class BindSteamAccountRequest(BaseModel):
  970. """验证绑定Steam账号请求"""
  971. user_id: int = Field(..., description="用户ID")
  972. steam_username: str = Field(..., description="Steam用户名")
  973. steam_id: str = Field(..., description="Steam ID(来自客户端登录验证)")
  974. class SteamAccountLimitRequest(BaseModel):
  975. """获取Steam账号绑定限制请求"""
  976. user_id: int = Field(..., description="用户ID")
  977. @router.post("/steam/account-limit")
  978. async def get_steam_account_limit(request: Request, db: Session = Depends(get_db)):
  979. """
  980. 获取用户Steam账号绑定限制(最大可绑定数、当前已验证绑定数)
  981. - 请求体为加密格式:encrypted + key_version(与 client-tauri make_request 一致)
  982. - 响应为明文 JSON(code, msg, data),便于 client-tauri 直接解析并展示最大可绑定数
  983. """
  984. try:
  985. body = await request.json()
  986. if not isinstance(body, dict) or not body.get("encrypted") or body.get("key_version") is None:
  987. return JSONResponse(
  988. status_code=400,
  989. content=create_client_response(400, "请求参数错误,请检查后重试", None)
  990. )
  991. key_version = int(body.get("key_version", 1))
  992. decrypted = encryptor.decrypt_from_client(
  993. (body.get("encrypted") or "").strip(), key_version, db
  994. )
  995. user_id = decrypted.get("user_id")
  996. if user_id is None:
  997. return JSONResponse(
  998. status_code=400,
  999. content=create_client_response(400, "请求参数错误,请检查后重试", None)
  1000. )
  1001. user_id = int(user_id)
  1002. steam_service = SteamAccountService(db)
  1003. success, message, data = steam_service.get_steam_account_limit(user_id)
  1004. code = 200 if success else 400
  1005. payload = create_client_response(code, message, data if data else {})
  1006. return JSONResponse(status_code=200, content=payload)
  1007. except Exception as e:
  1008. logger.exception(f"获取Steam账号限制接口异常: {e}")
  1009. return JSONResponse(
  1010. status_code=200,
  1011. content=create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
  1012. )
  1013. @router.post("/steam/accounts")
  1014. async def create_steam_account(request: Request, db: Session = Depends(get_db)):
  1015. """
  1016. 创建/绑定Steam账号(与 client-tauri make_request 一致:请求体为 encrypted + key_version)
  1017. - 解密后需包含 user_id, steam_username, steam_id(可选)
  1018. """
  1019. try:
  1020. body = await request.json()
  1021. if not isinstance(body, dict) or not body.get("encrypted") or body.get("key_version") is None:
  1022. return create_encrypted_response(None, db, 400, "请求参数错误,请检查后重试")
  1023. key_version = int(body.get("key_version", 1))
  1024. decrypted = encryptor.decrypt_from_client(
  1025. (body.get("encrypted") or "").strip(), key_version, db
  1026. )
  1027. user_id = decrypted.get("user_id")
  1028. steam_username = (decrypted.get("steam_username") or "").strip()
  1029. if user_id is None or not steam_username:
  1030. return create_encrypted_response(None, db, 400, "请求参数错误,请检查后重试")
  1031. user_id = int(user_id)
  1032. steam_id = decrypted.get("steam_id")
  1033. if steam_id is not None and isinstance(steam_id, str) and not steam_id.strip():
  1034. steam_id = None
  1035. steam_service = SteamAccountService(db)
  1036. success, message, data = steam_service.create_steam_account(
  1037. user_id=user_id,
  1038. steam_username=steam_username,
  1039. steam_id=steam_id,
  1040. )
  1041. if success:
  1042. logger.info(f"Steam账号绑定成功: user_id={user_id}, steam_username={steam_username}")
  1043. return create_encrypted_response(data, db, 200, message)
  1044. error_code = 400
  1045. if "已绑定" in message:
  1046. error_code = 403
  1047. if "最大绑定数量" in message:
  1048. error_code = 403
  1049. return create_encrypted_response(None, db, error_code, message)
  1050. except Exception as e:
  1051. logger.exception(f"创建Steam账号接口异常: {e}")
  1052. return create_encrypted_response(None, db, 500, "系统异常,请稍后重试;若持续发生请联系客服")
  1053. @router.get("/steam/accounts")
  1054. async def get_steam_accounts(
  1055. user_id: int = Query(..., description="用户ID"),
  1056. db: Session = Depends(get_db)
  1057. ):
  1058. """
  1059. 获取用户绑定的Steam账号列表(明文 Query,供管理端或调试)
  1060. - 需登录验证
  1061. """
  1062. start_time = time.time()
  1063. try:
  1064. steam_service = SteamAccountService(db)
  1065. success, message, data = steam_service.get_accounts_by_user(user_id)
  1066. logger.debug(f"获取Steam账号列表: user_id={user_id}, count={len(data) if data else 0}")
  1067. return create_encrypted_response(data if data else [], db, 200 if success else 400, message)
  1068. except Exception as e:
  1069. logger.exception(f"获取Steam账号列表接口异常: {e}")
  1070. return create_encrypted_response(None, db, 500, "系统异常,请稍后重试;若持续发生请联系客服")
  1071. @router.post("/steam/accounts/list")
  1072. async def post_steam_accounts_list(request: Request, db: Session = Depends(get_db)):
  1073. """
  1074. 获取用户绑定的Steam账号列表(加密请求体;响应为明文 code/msg/data,便于 client-tauri 直接展示列表)
  1075. """
  1076. try:
  1077. body = await request.json()
  1078. if not isinstance(body, dict) or not body.get("encrypted") or body.get("key_version") is None:
  1079. return JSONResponse(
  1080. status_code=200,
  1081. content=create_client_response(400, "请求参数错误,请检查后重试", None)
  1082. )
  1083. key_version = int(body.get("key_version", 1))
  1084. decrypted = encryptor.decrypt_from_client(
  1085. (body.get("encrypted") or "").strip(), key_version, db
  1086. )
  1087. user_id = decrypted.get("user_id")
  1088. if user_id is None:
  1089. return JSONResponse(
  1090. status_code=200,
  1091. content=create_client_response(400, "请求参数错误,请检查后重试", None)
  1092. )
  1093. user_id = int(user_id)
  1094. steam_service = SteamAccountService(db)
  1095. success, message, data = steam_service.get_accounts_by_user(user_id)
  1096. logger.debug(f"获取Steam账号列表(list): user_id={user_id}, count={len(data) if data else 0}")
  1097. code = 200 if success else 400
  1098. payload = create_client_response(code, message, data if data else [])
  1099. return JSONResponse(status_code=200, content=payload)
  1100. except Exception as e:
  1101. logger.exception(f"获取Steam账号列表(list)接口异常: {e}")
  1102. return JSONResponse(
  1103. status_code=200,
  1104. content=create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
  1105. )
  1106. @router.post("/steam/accounts/bind")
  1107. async def bind_steam_account(request: Request, db: Session = Depends(get_db)):
  1108. """
  1109. 对已新增的Steam账号进行验证绑定(写入steam_id)
  1110. - 请求体:encrypted + key_version,解密后需包含 user_id, steam_username, steam_id
  1111. - 需先新增账号再调用本接口,已验证绑定数不得超过配置上限
  1112. """
  1113. try:
  1114. body = await request.json()
  1115. if not isinstance(body, dict) or not body.get("encrypted") or body.get("key_version") is None:
  1116. return JSONResponse(
  1117. status_code=200,
  1118. content=create_client_response(400, "请求参数错误,请检查后重试", None)
  1119. )
  1120. key_version = int(body.get("key_version", 1))
  1121. decrypted = encryptor.decrypt_from_client(
  1122. (body.get("encrypted") or "").strip(), key_version, db
  1123. )
  1124. user_id = decrypted.get("user_id")
  1125. steam_username = (decrypted.get("steam_username") or "").strip()
  1126. steam_id = (decrypted.get("steam_id") or "").strip()
  1127. if user_id is None or not steam_username:
  1128. return JSONResponse(
  1129. status_code=200,
  1130. content=create_client_response(400, "请求参数错误,请检查后重试", None)
  1131. )
  1132. user_id = int(user_id)
  1133. if not steam_id:
  1134. return JSONResponse(
  1135. status_code=200,
  1136. content=create_client_response(400, "steam_id 不能为空", None)
  1137. )
  1138. steam_service = SteamAccountService(db)
  1139. success, message, data = steam_service.bind_steam_account(
  1140. user_id=user_id,
  1141. steam_username=steam_username,
  1142. steam_id=steam_id,
  1143. )
  1144. if success:
  1145. logger.info(f"Steam账号验证绑定成功: user_id={user_id}, steam_username={steam_username}, steam_id={steam_id}")
  1146. return JSONResponse(status_code=200, content=create_client_response(200, message, data))
  1147. error_code = 400
  1148. if "已绑定" in message or "最大绑定数量" in message:
  1149. error_code = 403
  1150. if "请先新增" in message:
  1151. error_code = 400
  1152. return JSONResponse(status_code=200, content=create_client_response(error_code, message, None))
  1153. except Exception as e:
  1154. logger.exception(f"Steam账号验证绑定接口异常: {e}")
  1155. return JSONResponse(
  1156. status_code=200,
  1157. content=create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
  1158. )
  1159. @router.put("/steam/accounts")
  1160. async def update_steam_account(
  1161. request: UpdateSteamAccountRequest,
  1162. db: Session = Depends(get_db)
  1163. ):
  1164. """
  1165. 更新Steam账号状态
  1166. - AES+RSA加密
  1167. - 需要登录验证
  1168. """
  1169. try:
  1170. # 统一校验:会话有效 + 机器码一致 + 账号可用 + 订阅未过期
  1171. auth_service = AuthService(db)
  1172. session, err_code, err_msg = auth_service.verify_session_with_machine_and_expiry(
  1173. request.session_id,
  1174. request.machine_code
  1175. )
  1176. if not session:
  1177. return create_encrypted_response(None, db, err_code or 401, err_msg or "会话已过期,请重新登录")
  1178. steam_service = SteamAccountService(db)
  1179. success, message = steam_service.update_account(
  1180. account_id=request.account_id,
  1181. user_id=session.user_id,
  1182. is_active=request.is_active
  1183. )
  1184. if success:
  1185. return create_encrypted_response(None, db, 200, message)
  1186. error_code = 400
  1187. if "不存在" in message:
  1188. error_code = 404
  1189. return create_encrypted_response(None, db, error_code, message)
  1190. except Exception as e:
  1191. logger.exception(f"更新Steam账号接口异常: {e}")
  1192. return create_encrypted_response(None, db, 500, "系统异常,请稍后重试;若持续发生请联系客服")
  1193. @router.delete("/steam/accounts")
  1194. async def delete_steam_account(request: Request, db: Session = Depends(get_db)):
  1195. """
  1196. 删除Steam账号(仅未绑定可删)
  1197. - 请求体:encrypted + key_version,解密后需包含 user_id、account_id
  1198. """
  1199. try:
  1200. body = await request.json()
  1201. if not isinstance(body, dict) or not body.get("encrypted") or body.get("key_version") is None:
  1202. return JSONResponse(
  1203. status_code=200,
  1204. content=create_client_response(400, "请求参数错误,请检查后重试", None)
  1205. )
  1206. key_version = int(body.get("key_version", 1))
  1207. decrypted = encryptor.decrypt_from_client(
  1208. (body.get("encrypted") or "").strip(), key_version, db
  1209. )
  1210. user_id = decrypted.get("user_id")
  1211. account_id = decrypted.get("account_id")
  1212. if user_id is None or account_id is None:
  1213. return JSONResponse(
  1214. status_code=200,
  1215. content=create_client_response(400, "请求参数错误,请检查后重试", None)
  1216. )
  1217. user_id = int(user_id)
  1218. account_id = int(account_id)
  1219. steam_service = SteamAccountService(db)
  1220. success, message = steam_service.delete_account(
  1221. account_id=account_id,
  1222. user_id=user_id
  1223. )
  1224. if success:
  1225. logger.info(f"Steam账号删除成功: account_id={account_id}")
  1226. return JSONResponse(status_code=200, content=create_client_response(200, message, None))
  1227. error_code = 400
  1228. if "不存在" in message:
  1229. error_code = 404
  1230. if "已绑定" in message:
  1231. error_code = 403
  1232. return JSONResponse(status_code=200, content=create_client_response(error_code, message, None))
  1233. except Exception as e:
  1234. logger.exception(f"删除Steam账号接口异常: {e}")
  1235. return JSONResponse(
  1236. status_code=200,
  1237. content=create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
  1238. )
  1239. # ============ BUFF 选品筛选项(从 buff_filter_option 表加载) ============
  1240. def _build_filter_options_data(db: Session, game: str) -> Dict[str, Any]:
  1241. """构建选品筛选项数据(供 GET 明文与 POST 安全端点共用)。"""
  1242. query = db.query(BuffFilterOption).filter(
  1243. BuffFilterOption.game == game,
  1244. BuffFilterOption.is_enabled == True
  1245. ).order_by(BuffFilterOption.filter_type, BuffFilterOption.parent_value, BuffFilterOption.sort_order, BuffFilterOption.id)
  1246. rows = query.all()
  1247. rifle, pistol, smg, shotgun, machinegun = [], [], [], [], []
  1248. quality, category, exterior, collection = [], [], [], []
  1249. itemset_roots, itemset_children = [], []
  1250. for r in rows:
  1251. opt = {"label": r.label, "value": r.value}
  1252. if r.filter_type == "category" and r.parent_value == "rifle":
  1253. rifle.append(opt)
  1254. elif r.filter_type == "category" and r.parent_value == "pistol":
  1255. pistol.append(opt)
  1256. elif r.filter_type == "category" and r.parent_value == "smg":
  1257. smg.append(opt)
  1258. elif r.filter_type == "category" and r.parent_value == "shotgun":
  1259. shotgun.append(opt)
  1260. elif r.filter_type == "category" and r.parent_value == "machinegun":
  1261. machinegun.append(opt)
  1262. elif r.filter_type == "rarity":
  1263. quality.append(opt)
  1264. elif r.filter_type == "quality":
  1265. category.append(opt)
  1266. elif r.filter_type == "exterior":
  1267. exterior.append(opt)
  1268. elif r.filter_type == "itemset":
  1269. if r.parent_value is None:
  1270. itemset_roots.append(r)
  1271. else:
  1272. itemset_children.append(r)
  1273. collection.append(opt)
  1274. collection_tree = []
  1275. for root in sorted(itemset_roots, key=lambda x: (x.sort_order, x.id)):
  1276. children = [c for c in itemset_children if c.parent_value == root.value]
  1277. children.sort(key=lambda x: (x.sort_order, x.id))
  1278. collection_tree.append({
  1279. "title": root.label,
  1280. "value": root.value,
  1281. "key": root.value,
  1282. "children": [{"title": c.label, "value": c.value, "key": c.value} for c in children],
  1283. })
  1284. return {
  1285. "rifle": rifle,
  1286. "pistol": pistol,
  1287. "smg": smg,
  1288. "shotgun": shotgun,
  1289. "machinegun": machinegun,
  1290. "quality": quality,
  1291. "category": category,
  1292. "exterior": exterior,
  1293. "collection": collection,
  1294. "collectionTree": collection_tree,
  1295. }
  1296. @router.post("/buff/filter-options")
  1297. @secure_endpoint(require_login=True, require_machine_code=True)
  1298. async def post_buff_filter_options_secure(
  1299. decrypted_params: dict, request: Request, db: Session, session=None, user=None
  1300. ):
  1301. """
  1302. 获取 BUFF 选品筛选项(安全端点:POST,入参出参密文,时间戳与签名校验)。
  1303. 解密参数:game(可选,默认 csgo)。会话与机器码由装饰器校验。
  1304. 日志:入参密文/明文、出参密文/明文由 SecureEndpoint 与 APILoggingASGIMiddleware 统一记录。
  1305. """
  1306. try:
  1307. game = (decrypted_params.get("game") or "csgo").strip() or "csgo"
  1308. data = _build_filter_options_data(db, game)
  1309. return {"code": 200, "msg": "获取成功", "data": data}
  1310. except Exception as e:
  1311. logger.exception(f"POST /buff/filter-options 异常: {e}")
  1312. return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
  1313. @router.get("/buff/filter-options")
  1314. async def get_buff_filter_options(
  1315. game: str = Query("csgo", description="游戏标识"),
  1316. session_id: str = Query(..., description="会话ID"),
  1317. machine_code: str = Query(..., description="机器码"),
  1318. db: Session = Depends(get_db)
  1319. ):
  1320. """
  1321. 获取 BUFF 选品检索栏下拉选项。需登录,校验会话+机器码+用户过期。
  1322. """
  1323. try:
  1324. if not (session_id or "").strip() or not (machine_code or "").strip():
  1325. return JSONResponse(status_code=200, content=create_client_response(401, "请先登录", None))
  1326. auth_service = AuthService(db)
  1327. session, err_code, err_msg = auth_service.verify_session_with_machine_and_expiry(
  1328. session_id.strip(), (machine_code or "").strip()
  1329. )
  1330. if session is None:
  1331. return JSONResponse(status_code=200, content=create_client_response(err_code or 401, err_msg or "会话已过期,请重新登录", None))
  1332. data = _build_filter_options_data(db, game)
  1333. return JSONResponse(status_code=200, content=create_client_response(200, "获取成功", data))
  1334. except Exception as e:
  1335. logger.exception(f"获取BUFF筛选项异常: {e}")
  1336. return JSONResponse(
  1337. status_code=200,
  1338. content=create_client_response(500, "系统异常,请稍后重试", None)
  1339. )
  1340. @router.get("/buff/goods-attr-list")
  1341. async def get_buff_goods_attr_list(
  1342. goods_ids: str = Query(..., description="逗号分隔的 BUFF 饰品 goods_id 列表"),
  1343. session_id: str = Query(..., description="会话ID"),
  1344. machine_code: str = Query(..., description="机器码"),
  1345. db: Session = Depends(get_db)
  1346. ):
  1347. """
  1348. 根据 goods_id 列表查询 buff_goods_attr 表。需登录,校验会话+机器码+用户过期。
  1349. """
  1350. try:
  1351. if not (session_id or "").strip() or not (machine_code or "").strip():
  1352. return JSONResponse(status_code=200, content=create_client_response(401, "请先登录", None))
  1353. auth_service = AuthService(db)
  1354. session, err_code, err_msg = auth_service.verify_session_with_machine_and_expiry(
  1355. session_id.strip(), (machine_code or "").strip()
  1356. )
  1357. if session is None:
  1358. return JSONResponse(status_code=200, content=create_client_response(err_code or 401, err_msg or "会话已过期,请重新登录", None))
  1359. id_list = [x.strip() for x in (goods_ids or "").split(",") if (x and x.strip())]
  1360. if not id_list:
  1361. return JSONResponse(status_code=200, content=create_client_response(200, "获取成功", []))
  1362. # 限制单次查询数量,避免过长
  1363. id_list = id_list[:500]
  1364. rows = db.query(BuffGoodsAttr).filter(
  1365. BuffGoodsAttr.goods_id.in_(id_list)
  1366. ).all()
  1367. data = [
  1368. {"goods_id": r.goods_id, "name": r.name or "", "icon_url": r.icon_url or ""}
  1369. for r in rows
  1370. ]
  1371. return JSONResponse(status_code=200, content=create_client_response(200, "获取成功", data))
  1372. except Exception as e:
  1373. logger.exception(f"获取BUFF商品属性列表异常: {e}")
  1374. return JSONResponse(
  1375. status_code=200,
  1376. content=create_client_response(500, "系统异常,请稍后重试", None)
  1377. )
  1378. @router.post("/buff/goods-attr-list")
  1379. @secure_endpoint(require_login=True, require_machine_code=True)
  1380. async def post_buff_goods_attr_list_secure(
  1381. decrypted_params: dict, request: Request, db: Session, session=None, user=None
  1382. ):
  1383. """
  1384. 根据 goods_id 列表获取商品属性(安全端点:POST,入参出参密文,时间戳与签名校验)。
  1385. 解密参数:goods_ids(逗号分隔)。日志由 SecureEndpoint 与 APILoggingASGIMiddleware 统一记录。
  1386. """
  1387. try:
  1388. goods_ids_raw = (decrypted_params.get("goods_ids") or "").strip()
  1389. id_list = [x.strip() for x in goods_ids_raw.split(",") if (x and x.strip())]
  1390. if not id_list:
  1391. return {"code": 200, "msg": "获取成功", "data": []}
  1392. id_list = id_list[:500]
  1393. # 从视图查询 steam_link,从 buff_goods_attr 表查询 icon_url 和 name
  1394. from sqlalchemy import text
  1395. sql = text("""
  1396. SELECT
  1397. gv.`BUFF饰品ID` AS goods_id,
  1398. COALESCE(gv.`饰品STEAM链接`, '') AS steam_link,
  1399. COALESCE(ga.name, '') AS name,
  1400. COALESCE(ga.icon_url, '') AS icon_url
  1401. FROM buff_goods_filtered_view gv
  1402. LEFT JOIN buff_goods_attr ga ON gv.`BUFF饰品ID` = ga.goods_id
  1403. WHERE gv.`BUFF饰品ID` IN :id_list
  1404. """)
  1405. result = db.execute(sql, {"id_list": tuple(id_list)}).fetchall()
  1406. data = [{
  1407. "goods_id": str(row[0]),
  1408. "steam_link": row[1],
  1409. "name": row[2],
  1410. "icon_url": row[3],
  1411. } for row in result]
  1412. return {"code": 200, "msg": "获取成功", "data": data}
  1413. except Exception as e:
  1414. logger.exception(f"POST /buff/goods-attr-list 异常: {e}")
  1415. return {"code": 500, "msg": "系统异常,请稍后重试", "data": []}
  1416. # ============ BUFF 选品配置(保存/加载用户检索条件+选品条件) ============
  1417. @router.post("/buff/selection-config")
  1418. async def save_buff_selection_config(request: Request, db: Session = Depends(get_db)):
  1419. """
  1420. 保存 BUFF 选品配置(检索条件+选品条件)。
  1421. 请求体 JSON:session_id(必填), machine_code(必填), name(可选), config_json(必填), is_default(可选)。
  1422. 校验会话+机器码+用户过期后按 user_id + name 写入或更新,同用户至多一个 is_default=1。
  1423. """
  1424. try:
  1425. body = await request.json()
  1426. if not isinstance(body, dict):
  1427. return JSONResponse(status_code=200, content=create_client_response(400, "请求参数错误,请检查后重试", None))
  1428. session_id = (body.get("session_id") or "").strip()
  1429. machine_code = (body.get("machine_code") or "").strip()
  1430. if not session_id:
  1431. return JSONResponse(status_code=200, content=create_client_response(401, "请先登录", None))
  1432. if not machine_code:
  1433. return JSONResponse(status_code=200, content=create_client_response(400, "请求参数错误:缺少 machine_code", None))
  1434. auth_service = AuthService(db)
  1435. session, err_code, err_msg = auth_service.verify_session_with_machine_and_expiry(session_id, machine_code)
  1436. if session is None:
  1437. return JSONResponse(status_code=200, content=create_client_response(err_code or 401, err_msg or "会话已过期,请重新登录", None))
  1438. user_id = session.user_id
  1439. name = (body.get("name") or "默认").strip() or "默认"
  1440. config_json = body.get("config_json")
  1441. if config_json is None:
  1442. return JSONResponse(status_code=200, content=create_client_response(400, "缺少配置内容 config_json", None))
  1443. if not isinstance(config_json, dict):
  1444. try:
  1445. config_json = json.loads(config_json) if isinstance(config_json, str) else None
  1446. except Exception:
  1447. config_json = None
  1448. if not isinstance(config_json, dict):
  1449. return JSONResponse(status_code=200, content=create_client_response(400, "config_json 格式错误", None))
  1450. # 获取系统配置限制
  1451. heartbeat_service = HeartbeatService(db)
  1452. client_config = heartbeat_service.get_client_config()
  1453. max_worker_count = client_config.get('buff_sweep_max_worker_count', 32)
  1454. min_steam_card_ratio = client_config.get('buff_steam_card_ratio_min', 4)
  1455. max_selection_quantity = client_config.get('buff_selection_max_quantity', 300)
  1456. # 验证扫货线程数
  1457. sweep_process_count = config_json.get('sweepProcessCount', 0)
  1458. if sweep_process_count > max_worker_count:
  1459. return JSONResponse(
  1460. status_code=200,
  1461. content=create_client_response(400, f"扫货线程数不能超过系统配置的最大值 {max_worker_count}", None)
  1462. )
  1463. # 验证Steam卡价比例
  1464. steam_card_ratio = config_json.get('steamCardRatio', 0)
  1465. if steam_card_ratio < min_steam_card_ratio:
  1466. return JSONResponse(
  1467. status_code=200,
  1468. content=create_client_response(400, f"Steam卡价比例不能小于系统配置的最小值 {min_steam_card_ratio}", None)
  1469. )
  1470. # 验证选品数量
  1471. selection_quantity = config_json.get('selectionQuantity')
  1472. if selection_quantity is not None:
  1473. try:
  1474. selection_quantity = int(selection_quantity)
  1475. if selection_quantity > max_selection_quantity:
  1476. return JSONResponse(
  1477. status_code=200,
  1478. content=create_client_response(400, f"选品数量不能超过系统配置的最大值 {max_selection_quantity}", None)
  1479. )
  1480. except (ValueError, TypeError):
  1481. pass
  1482. is_default = body.get("is_default", True)
  1483. if not isinstance(is_default, bool):
  1484. is_default = is_default in (1, "1", True, "true")
  1485. existing = db.query(BuffSelectionConfig).filter(
  1486. BuffSelectionConfig.user_id == user_id,
  1487. BuffSelectionConfig.name == name,
  1488. BuffSelectionConfig.is_deleted == False
  1489. ).first()
  1490. config_str = json.dumps(config_json, ensure_ascii=False)
  1491. if existing:
  1492. existing.config_json = config_str
  1493. existing.is_default = bool(is_default)
  1494. existing.updated_at = beijing_now_naive()
  1495. db.commit()
  1496. row_id = existing.id
  1497. else:
  1498. row = BuffSelectionConfig(
  1499. user_id=user_id,
  1500. name=name,
  1501. config_json=config_str,
  1502. is_default=bool(is_default)
  1503. )
  1504. db.add(row)
  1505. db.commit()
  1506. db.refresh(row)
  1507. row_id = row.id
  1508. if is_default:
  1509. db.query(BuffSelectionConfig).filter(
  1510. BuffSelectionConfig.user_id == user_id,
  1511. BuffSelectionConfig.id != row_id,
  1512. BuffSelectionConfig.is_deleted == False
  1513. ).update({BuffSelectionConfig.is_default: False}, synchronize_session=False)
  1514. db.commit()
  1515. return JSONResponse(status_code=200, content=create_client_response(200, "配置信息已保存", {"id": row_id}))
  1516. except Exception as e:
  1517. logger.exception(f"保存BUFF选品配置异常: {e}")
  1518. db.rollback()
  1519. return JSONResponse(status_code=200, content=create_client_response(500, "系统异常,请稍后重试", None))
  1520. @router.get("/buff/selection-config")
  1521. async def get_buff_selection_config(
  1522. session_id: str = Query(..., description="会话ID"),
  1523. machine_code: str = Query(..., description="机器码"),
  1524. db: Session = Depends(get_db)
  1525. ):
  1526. """
  1527. 获取当前用户的 BUFF 选品配置列表及默认配置。
  1528. 校验会话+机器码+用户过期后返回 data.list 与 data.defaultConfig。
  1529. """
  1530. try:
  1531. if not (session_id or "").strip():
  1532. return JSONResponse(status_code=200, content=create_client_response(401, "请先登录", None))
  1533. if not (machine_code or "").strip():
  1534. return JSONResponse(status_code=200, content=create_client_response(400, "请求参数错误:缺少 machine_code", None))
  1535. auth_service = AuthService(db)
  1536. session, err_code, err_msg = auth_service.verify_session_with_machine_and_expiry(
  1537. session_id.strip(), (machine_code or "").strip()
  1538. )
  1539. if session is None:
  1540. return JSONResponse(status_code=200, content=create_client_response(err_code or 401, err_msg or "会话已过期,请重新登录", None))
  1541. user_id = session.user_id
  1542. rows = db.query(BuffSelectionConfig).filter(
  1543. BuffSelectionConfig.user_id == user_id,
  1544. BuffSelectionConfig.is_deleted == False
  1545. ).order_by(BuffSelectionConfig.is_default.desc(), BuffSelectionConfig.updated_at.desc()).all()
  1546. list_data = [{"id": r.id, "name": r.name, "is_default": r.is_default, "updated_at": r.updated_at.isoformat() if r.updated_at else None} for r in rows]
  1547. default_config = None
  1548. for r in rows:
  1549. if r.is_default and r.config_json:
  1550. try:
  1551. default_config = json.loads(r.config_json)
  1552. except Exception:
  1553. pass
  1554. break
  1555. return JSONResponse(status_code=200, content=create_client_response(200, "获取成功", {
  1556. "list": list_data,
  1557. "defaultConfig": default_config
  1558. }))
  1559. except Exception as e:
  1560. logger.exception(f"获取BUFF选品配置异常: {e}")
  1561. return JSONResponse(status_code=200, content=create_client_response(500, "系统异常,请稍后重试", None))
  1562. @router.post("/buff/secure/selection-config")
  1563. @secure_endpoint(require_login=True, require_machine_code=True)
  1564. async def post_buff_selection_config_secure(
  1565. decrypted_params: dict, request: Request, db: Session, session=None, user=None
  1566. ):
  1567. """
  1568. 选品配置获取或保存(安全端点:POST,入参出参密文,时间戳与签名校验)。
  1569. 解密参数:action(get|save);save 时另需 name、config_json、is_default。日志统一记录入参/出参密文与明文。
  1570. """
  1571. try:
  1572. user_id = session.user_id
  1573. action = (decrypted_params.get("action") or "get").strip().lower()
  1574. if action == "get":
  1575. rows = db.query(BuffSelectionConfig).filter(
  1576. BuffSelectionConfig.user_id == user_id,
  1577. BuffSelectionConfig.is_deleted == False
  1578. ).order_by(BuffSelectionConfig.is_default.desc(), BuffSelectionConfig.updated_at.desc()).all()
  1579. list_data = [{"id": r.id, "name": r.name, "is_default": r.is_default, "updated_at": r.updated_at.isoformat() if r.updated_at else None} for r in rows]
  1580. default_config = None
  1581. for r in rows:
  1582. if r.is_default and r.config_json:
  1583. try:
  1584. default_config = json.loads(r.config_json)
  1585. except Exception:
  1586. pass
  1587. break
  1588. return {"code": 200, "msg": "获取成功", "data": {"list": list_data, "defaultConfig": default_config}}
  1589. elif action == "get_by_selection_request_id":
  1590. selection_request_id = decrypted_params.get("selection_request_id")
  1591. if selection_request_id is None:
  1592. return {"code": 400, "msg": "缺少 selection_request_id 参数", "data": None}
  1593. try:
  1594. selection_request_id = int(selection_request_id)
  1595. except (TypeError, ValueError):
  1596. return {"code": 400, "msg": "selection_request_id 参数格式错误", "data": None}
  1597. sel_req = db.query(BuffSelectionRequest).filter(
  1598. BuffSelectionRequest.id == selection_request_id,
  1599. BuffSelectionRequest.user_id == user_id,
  1600. ).first()
  1601. if sel_req is None:
  1602. return {"code": 404, "msg": "选品请求不存在", "data": None}
  1603. # 优先从请求单的选品条件快照获取(新版)
  1604. if sel_req.selection_condition_json:
  1605. try:
  1606. config_json = json.loads(sel_req.selection_condition_json)
  1607. return {"code": 200, "msg": "获取成功", "data": {"config": config_json}}
  1608. except Exception:
  1609. pass
  1610. # 兼容旧数据:如果快照为空,尝试关联配置表获取
  1611. config_id = sel_req.config_id
  1612. if config_id is None:
  1613. return {"code": 404, "msg": "该选品请求未关联配置", "data": None}
  1614. config_record = db.query(BuffSelectionConfig).filter(
  1615. BuffSelectionConfig.id == config_id,
  1616. BuffSelectionConfig.user_id == user_id,
  1617. BuffSelectionConfig.is_deleted == False,
  1618. ).first()
  1619. if config_record is None or not config_record.config_json:
  1620. return {"code": 404, "msg": "配置信息不存在", "data": None}
  1621. try:
  1622. config_json = json.loads(config_record.config_json)
  1623. except Exception:
  1624. return {"code": 500, "msg": "配置解析失败", "data": None}
  1625. return {"code": 200, "msg": "获取成功", "data": {"config": config_json}}
  1626. elif action == "save":
  1627. name = (decrypted_params.get("name") or "默认").strip() or "默认"
  1628. config_json = decrypted_params.get("config_json")
  1629. if config_json is None:
  1630. return {"code": 400, "msg": "缺少配置内容 config_json", "data": None}
  1631. if not isinstance(config_json, dict):
  1632. try:
  1633. config_json = json.loads(config_json) if isinstance(config_json, str) else None
  1634. except Exception:
  1635. config_json = None
  1636. if not isinstance(config_json, dict):
  1637. return {"code": 400, "msg": "config_json 格式错误", "data": None}
  1638. is_default = decrypted_params.get("is_default", True)
  1639. if not isinstance(is_default, bool):
  1640. is_default = is_default in (1, "1", True, "true")
  1641. config_str = json.dumps(config_json, ensure_ascii=False)
  1642. existing = db.query(BuffSelectionConfig).filter(
  1643. BuffSelectionConfig.user_id == user_id,
  1644. BuffSelectionConfig.name == name,
  1645. BuffSelectionConfig.is_deleted == False
  1646. ).first()
  1647. if existing:
  1648. existing.config_json = config_str
  1649. existing.is_default = bool(is_default)
  1650. existing.updated_at = beijing_now_naive()
  1651. db.commit()
  1652. row_id = existing.id
  1653. else:
  1654. row = BuffSelectionConfig(
  1655. user_id=user_id,
  1656. name=name,
  1657. config_json=config_str,
  1658. is_default=bool(is_default)
  1659. )
  1660. db.add(row)
  1661. db.commit()
  1662. db.refresh(row)
  1663. row_id = row.id
  1664. if is_default:
  1665. db.query(BuffSelectionConfig).filter(
  1666. BuffSelectionConfig.user_id == user_id,
  1667. BuffSelectionConfig.id != row_id,
  1668. BuffSelectionConfig.is_deleted == False
  1669. ).update({BuffSelectionConfig.is_default: False}, synchronize_session=False)
  1670. db.commit()
  1671. return {"code": 200, "msg": "配置信息已保存", "data": {"id": row_id}}
  1672. else:
  1673. return {"code": 400, "msg": "action 必须为 get 或 save", "data": None}
  1674. except Exception as e:
  1675. logger.exception(f"POST /buff/secure/selection-config 异常: {e}")
  1676. try:
  1677. db.rollback()
  1678. except Exception:
  1679. pass
  1680. return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
  1681. def _get_system_config_int(db: Session, key: str, default: int = 0) -> int:
  1682. """读取 system_config 整型配置(backend-selection)。"""
  1683. row = db.query(SystemConfig).filter(
  1684. SystemConfig.config_key == key,
  1685. SystemConfig.app_name == "backend-selection",
  1686. SystemConfig.is_deleted == False,
  1687. ).first()
  1688. if not row or not row.config_value:
  1689. return default
  1690. try:
  1691. return int(row.config_value)
  1692. except (ValueError, TypeError):
  1693. return default
  1694. @router.post("/buff/selection-start")
  1695. async def start_buff_selection(
  1696. request: Request,
  1697. background_tasks: BackgroundTasks,
  1698. db: Session = Depends(get_db),
  1699. ):
  1700. """
  1701. 开始选品(异步)。
  1702. 请求体:session_id(必填), machine_code(必填), config_id(可选)。
  1703. 校验会话+机器码+用户过期与每日次数/条数限制后,创建选品请求单并立即返回;后台异步执行选品逻辑。
  1704. """
  1705. try:
  1706. body = await request.json()
  1707. if not isinstance(body, dict):
  1708. return JSONResponse(status_code=200, content=create_client_response(400, "请求参数错误,请检查后重试", None))
  1709. session_id = (body.get("session_id") or "").strip()
  1710. machine_code = (body.get("machine_code") or "").strip()
  1711. if not session_id:
  1712. return JSONResponse(status_code=200, content=create_client_response(401, "请先登录", None))
  1713. if not machine_code:
  1714. return JSONResponse(status_code=200, content=create_client_response(400, "请求参数错误:缺少 machine_code", None))
  1715. auth_service = AuthService(db)
  1716. session, err_code, err_msg = auth_service.verify_session_with_machine_and_expiry(session_id, machine_code)
  1717. if session is None:
  1718. return JSONResponse(status_code=200, content=create_client_response(err_code or 401, err_msg or "会话已过期,请重新登录", None))
  1719. user_id = session.user_id
  1720. # 今日 0 点(北京时间)
  1721. today_start = beijing_now_naive().replace(hour=0, minute=0, second=0, microsecond=0)
  1722. # 每日选品次数限制
  1723. times_limit = _get_system_config_int(db, "buff_selection_daily_times_limit_per_user", 3)
  1724. today_requests = db.query(BuffSelectionRequest).filter(
  1725. BuffSelectionRequest.user_id == user_id,
  1726. BuffSelectionRequest.created_at >= today_start,
  1727. ).count()
  1728. if today_requests >= times_limit:
  1729. return JSONResponse(
  1730. status_code=200,
  1731. content=create_client_response(403, f"今日选品次数已达上限({times_limit} 次),请明日再试", None),
  1732. )
  1733. # 每日选品条数限制(今日已入选的饰品条数)
  1734. items_limit = _get_system_config_int(db, "buff_selection_daily_items_limit_per_user", 900)
  1735. today_items = db.query(BuffSelectionRequestItem).filter(
  1736. BuffSelectionRequestItem.user_id == user_id,
  1737. BuffSelectionRequestItem.created_at >= today_start,
  1738. ).count()
  1739. if today_items >= items_limit:
  1740. return JSONResponse(
  1741. status_code=200,
  1742. content=create_client_response(403, f"今日选品条数已达上限({items_limit} 条),请明日再试", None),
  1743. )
  1744. config_id = body.get("config_id")
  1745. if config_id is not None:
  1746. try:
  1747. config_id = int(config_id)
  1748. except (TypeError, ValueError):
  1749. config_id = None
  1750. if config_id is None:
  1751. default_cfg = db.query(BuffSelectionConfig).filter(
  1752. BuffSelectionConfig.user_id == user_id,
  1753. BuffSelectionConfig.is_default == True,
  1754. BuffSelectionConfig.is_deleted == False,
  1755. ).first()
  1756. if default_cfg:
  1757. config_id = default_cfg.id
  1758. # 获取配置并验证系统限制,同时准备选品条件快照
  1759. selection_condition_json = None
  1760. heartbeat_service = HeartbeatService(db)
  1761. client_config = heartbeat_service.get_client_config()
  1762. max_worker_count = client_config.get('buff_sweep_max_worker_count', 32)
  1763. min_steam_card_ratio = client_config.get('buff_steam_card_ratio_min', 4)
  1764. max_selection_quantity = client_config.get('buff_selection_max_quantity', 300)
  1765. if config_id:
  1766. cfg_record = db.query(BuffSelectionConfig).filter(
  1767. BuffSelectionConfig.id == config_id,
  1768. BuffSelectionConfig.user_id == user_id,
  1769. BuffSelectionConfig.is_deleted == False,
  1770. ).first()
  1771. if cfg_record and cfg_record.config_json:
  1772. try:
  1773. cfg_json = json.loads(cfg_record.config_json)
  1774. # 固化选品条件快照:存储到请求单,扫货时直接使用,不再关联配置表
  1775. selection_condition_json = cfg_record.config_json
  1776. sweep_process_count = cfg_json.get('sweepProcessCount', 0)
  1777. steam_card_ratio = cfg_json.get('steamCardRatio', 0)
  1778. selection_quantity = cfg_json.get('selectionQuantity')
  1779. if sweep_process_count > max_worker_count:
  1780. return JSONResponse(
  1781. status_code=200,
  1782. content=create_client_response(403, f"扫货线程数不能超过系统配置的最大值 {max_worker_count}", None)
  1783. )
  1784. if steam_card_ratio < min_steam_card_ratio:
  1785. return JSONResponse(
  1786. status_code=200,
  1787. content=create_client_response(403, f"Steam卡价比例不能小于系统配置的最小值 {min_steam_card_ratio}", None)
  1788. )
  1789. if selection_quantity is not None:
  1790. try:
  1791. sq = int(selection_quantity)
  1792. if sq > max_selection_quantity:
  1793. return JSONResponse(
  1794. status_code=200,
  1795. content=create_client_response(403, f"选品数量不能超过系统配置的最大值 {max_selection_quantity}", None)
  1796. )
  1797. except (ValueError, TypeError):
  1798. pass
  1799. except (json.JSONDecodeError, TypeError):
  1800. pass
  1801. req = BuffSelectionRequest(
  1802. user_id=user_id,
  1803. config_id=config_id,
  1804. selection_condition_json=selection_condition_json,
  1805. status="pending",
  1806. )
  1807. db.add(req)
  1808. db.commit()
  1809. db.refresh(req)
  1810. request_id = req.id
  1811. background_tasks.add_task(run_selection_task, request_id, user_id, config_id)
  1812. return JSONResponse(
  1813. status_code=200,
  1814. content=create_client_response(200, "选品已开始,正在后台执行", {"request_id": request_id}),
  1815. )
  1816. except Exception as e:
  1817. logger.exception("开始选品异常: %s", e)
  1818. try:
  1819. db.rollback()
  1820. except Exception:
  1821. pass
  1822. return JSONResponse(status_code=200, content=create_client_response(500, "系统异常,请稍后重试", None))
  1823. @router.post("/buff/selection-log")
  1824. async def post_buff_selection_log(
  1825. request: Request,
  1826. db: Session = Depends(get_db),
  1827. ):
  1828. """
  1829. 获取最新一次选品请求的选品日志(非安全端点)。
  1830. 请求体:session_id(必填), machine_code(必填)。
  1831. 返回结构与安全端点保持一致,便于联调。
  1832. """
  1833. try:
  1834. body = await request.json()
  1835. if not isinstance(body, dict):
  1836. return JSONResponse(status_code=200, content=create_client_response(400, "请求参数错误,请检查后重试", None))
  1837. session_id = (body.get("session_id") or "").strip()
  1838. machine_code = (body.get("machine_code") or "").strip()
  1839. if not session_id:
  1840. return JSONResponse(status_code=200, content=create_client_response(401, "请先登录", None))
  1841. if not machine_code:
  1842. return JSONResponse(status_code=200, content=create_client_response(400, "请求参数错误:缺少 machine_code", None))
  1843. auth_service = AuthService(db)
  1844. session, err_code, err_msg = auth_service.verify_session_with_machine_and_expiry(session_id, machine_code)
  1845. if session is None:
  1846. return JSONResponse(status_code=200, content=create_client_response(err_code or 401, err_msg or "会话已过期,请重新登录", None))
  1847. user_id = session.user_id
  1848. req = (
  1849. db.query(BuffSelectionRequest)
  1850. .filter(BuffSelectionRequest.user_id == user_id)
  1851. .order_by(BuffSelectionRequest.id.desc())
  1852. .first()
  1853. )
  1854. if not req:
  1855. return JSONResponse(
  1856. status_code=200,
  1857. content=create_client_response(
  1858. 200,
  1859. "ok",
  1860. {
  1861. "request_id": None,
  1862. "status": None,
  1863. "started_at": None,
  1864. "ended_at": None,
  1865. "logs": [],
  1866. },
  1867. ),
  1868. )
  1869. started_at = req.created_at.strftime("%Y-%m-%d %H:%M:%S") if req.created_at else None
  1870. ended_at = None
  1871. if req.status in ("completed", "failed", "cancelled") and req.updated_at:
  1872. ended_at = req.updated_at.strftime("%Y-%m-%d %H:%M:%S")
  1873. logs = (
  1874. db.query(BuffSelectionRequestLog)
  1875. .filter(
  1876. BuffSelectionRequestLog.request_id == req.id,
  1877. BuffSelectionRequestLog.user_id == user_id,
  1878. )
  1879. .order_by(BuffSelectionRequestLog.sequence_no.asc(), BuffSelectionRequestLog.created_at.asc())
  1880. .all()
  1881. )
  1882. item_map = {}
  1883. if logs:
  1884. selected_goods = [l.goods_id for l in logs if l.result == "selected"]
  1885. if selected_goods:
  1886. items = (
  1887. db.query(BuffSelectionRequestItem)
  1888. .filter(
  1889. BuffSelectionRequestItem.request_id == req.id,
  1890. BuffSelectionRequestItem.user_id == user_id,
  1891. BuffSelectionRequestItem.goods_id.in_(selected_goods),
  1892. )
  1893. .all()
  1894. )
  1895. for it in items:
  1896. item_map[it.goods_id] = {
  1897. "wear_range": it.wear_range,
  1898. "price_cny": float(it.price_cny) if it.price_cny is not None else None,
  1899. "price_usd": float(it.price_usd) if it.price_usd is not None else None,
  1900. "steam_link": it.steam_link or "",
  1901. }
  1902. log_list = []
  1903. for l in logs:
  1904. row = {
  1905. "created_at": l.created_at.strftime("%Y-%m-%d %H:%M:%S") if l.created_at else None,
  1906. "goods_id": l.goods_id,
  1907. "goods_name": l.goods_name or "",
  1908. "result": l.result,
  1909. "message": l.message or "",
  1910. }
  1911. if l.result == "selected":
  1912. log_wear_range = getattr(l, "wear_range", None)
  1913. log_price_cny = float(l.price_cny) if getattr(l, "price_cny", None) is not None else None
  1914. log_price_usd = float(l.price_usd) if getattr(l, "price_usd", None) is not None else None
  1915. if log_wear_range is not None or log_price_cny is not None or log_price_usd is not None:
  1916. row["wear_range"] = log_wear_range
  1917. row["price_cny"] = log_price_cny
  1918. row["price_usd"] = log_price_usd
  1919. row["steam_link"] = ""
  1920. elif l.goods_id in item_map:
  1921. row["wear_range"] = item_map[l.goods_id]["wear_range"]
  1922. row["price_cny"] = item_map[l.goods_id]["price_cny"]
  1923. row["price_usd"] = item_map[l.goods_id]["price_usd"]
  1924. row["steam_link"] = item_map[l.goods_id]["steam_link"]
  1925. else:
  1926. row["wear_range"] = None
  1927. row["price_cny"] = None
  1928. row["price_usd"] = None
  1929. row["steam_link"] = ""
  1930. else:
  1931. row["wear_range"] = None
  1932. row["price_cny"] = None
  1933. row["price_usd"] = None
  1934. row["steam_link"] = ""
  1935. log_list.append(row)
  1936. return JSONResponse(
  1937. status_code=200,
  1938. content=create_client_response(
  1939. 200,
  1940. "ok",
  1941. {
  1942. "request_id": req.id,
  1943. "status": req.status,
  1944. "started_at": started_at,
  1945. "ended_at": ended_at,
  1946. "logs": log_list,
  1947. },
  1948. ),
  1949. )
  1950. except Exception as e:
  1951. logger.exception("POST /buff/selection-log 异常: %s", e)
  1952. try:
  1953. db.rollback()
  1954. except Exception:
  1955. pass
  1956. return JSONResponse(status_code=200, content=create_client_response(500, "系统异常,请稍后重试", None))
  1957. @router.post("/buff/selection-result")
  1958. async def post_buff_selection_result(
  1959. request: Request,
  1960. db: Session = Depends(get_db),
  1961. ):
  1962. """
  1963. 获取最新一次选品请求的状态及选中饰品列表(非安全端点)。
  1964. 请求体:session_id(必填), machine_code(必填)。
  1965. 返回结构与安全端点保持一致(含 steam_link)。
  1966. """
  1967. try:
  1968. body = await request.json()
  1969. if not isinstance(body, dict):
  1970. return JSONResponse(status_code=200, content=create_client_response(400, "请求参数错误,请检查后重试", None))
  1971. session_id = (body.get("session_id") or "").strip()
  1972. machine_code = (body.get("machine_code") or "").strip()
  1973. if not session_id:
  1974. return JSONResponse(status_code=200, content=create_client_response(401, "请先登录", None))
  1975. if not machine_code:
  1976. return JSONResponse(status_code=200, content=create_client_response(400, "请求参数错误:缺少 machine_code", None))
  1977. auth_service = AuthService(db)
  1978. session, err_code, err_msg = auth_service.verify_session_with_machine_and_expiry(session_id, machine_code)
  1979. if session is None:
  1980. return JSONResponse(status_code=200, content=create_client_response(err_code or 401, err_msg or "会话已过期,请重新登录", None))
  1981. user_id = session.user_id
  1982. req = (
  1983. db.query(BuffSelectionRequest)
  1984. .filter(BuffSelectionRequest.user_id == user_id)
  1985. .order_by(BuffSelectionRequest.id.desc())
  1986. .first()
  1987. )
  1988. if not req:
  1989. return JSONResponse(
  1990. status_code=200,
  1991. content=create_client_response(
  1992. 200,
  1993. "ok",
  1994. {
  1995. "request_id": None,
  1996. "status": None,
  1997. "started_at": None,
  1998. "ended_at": None,
  1999. "items": [],
  2000. },
  2001. ),
  2002. )
  2003. started_at = req.created_at.strftime("%Y-%m-%d %H:%M:%S") if req.created_at else None
  2004. ended_at = None
  2005. if req.status in ("completed", "failed", "cancelled") and req.updated_at:
  2006. ended_at = req.updated_at.strftime("%Y-%m-%d %H:%M:%S")
  2007. item_rows = (
  2008. db.query(BuffSelectionRequestItem)
  2009. .filter(
  2010. BuffSelectionRequestItem.request_id == req.id,
  2011. BuffSelectionRequestItem.user_id == user_id,
  2012. )
  2013. .order_by(BuffSelectionRequestItem.id.asc())
  2014. .all()
  2015. )
  2016. items = []
  2017. for it in item_rows:
  2018. items.append({
  2019. "id": it.id,
  2020. "goods_id": it.goods_id,
  2021. "goods_name": it.goods_name or "",
  2022. "icon_url": it.icon_url or "",
  2023. "steam_link": it.steam_link or "",
  2024. "wear_range": it.wear_range or "",
  2025. "price_cny": float(it.price_cny) if it.price_cny is not None else None,
  2026. "price_usd": float(it.price_usd) if it.price_usd is not None else None,
  2027. "created_at": it.created_at.strftime("%Y-%m-%d %H:%M:%S") if it.created_at else None,
  2028. })
  2029. return JSONResponse(
  2030. status_code=200,
  2031. content=create_client_response(
  2032. 200,
  2033. "ok",
  2034. {
  2035. "request_id": req.id,
  2036. "status": req.status,
  2037. "started_at": started_at,
  2038. "ended_at": ended_at,
  2039. "items": items,
  2040. },
  2041. ),
  2042. )
  2043. except Exception as e:
  2044. logger.exception("POST /buff/selection-result 异常: %s", e)
  2045. try:
  2046. db.rollback()
  2047. except Exception:
  2048. pass
  2049. return JSONResponse(status_code=200, content=create_client_response(500, "系统异常,请稍后重试", None))
  2050. @router.post("/buff/secure/selection-start")
  2051. @secure_endpoint(require_login=True, require_machine_code=True)
  2052. async def post_buff_selection_start_secure(
  2053. decrypted_params: dict, request: Request, db: Session, session=None, user=None
  2054. ):
  2055. """
  2056. 开始选品(安全端点:POST,入参出参密文,时间戳与签名校验)。
  2057. 解密参数:config_id(可选)。日志由 SecureEndpoint 与 APILoggingASGIMiddleware 统一记录。
  2058. """
  2059. import asyncio
  2060. try:
  2061. user_id = session.user_id
  2062. unfinished_reqs = (
  2063. db.query(BuffSelectionRequest)
  2064. .filter(
  2065. BuffSelectionRequest.user_id == user_id,
  2066. BuffSelectionRequest.status.in_(("pending", "running")),
  2067. )
  2068. .order_by(BuffSelectionRequest.id.asc())
  2069. .all()
  2070. )
  2071. cleaned_request_ids = []
  2072. if unfinished_reqs:
  2073. for old_req in unfinished_reqs:
  2074. old_req.cancel_requested = True
  2075. old_req.status = "cancelled"
  2076. cleaned_request_ids.append(old_req.id)
  2077. db.flush()
  2078. today_start = beijing_now_naive().replace(hour=0, minute=0, second=0, microsecond=0)
  2079. times_limit = _get_system_config_int(db, "buff_selection_daily_times_limit_per_user", 3)
  2080. today_requests = db.query(BuffSelectionRequest).filter(
  2081. BuffSelectionRequest.user_id == user_id,
  2082. BuffSelectionRequest.created_at >= today_start,
  2083. ).count()
  2084. if today_requests >= times_limit:
  2085. return {"code": 403, "msg": f"今日选品次数已达上限({times_limit} 次),请明日再试", "data": None}
  2086. items_limit = _get_system_config_int(db, "buff_selection_daily_items_limit_per_user", 900)
  2087. today_items = db.query(BuffSelectionRequestItem).filter(
  2088. BuffSelectionRequestItem.user_id == user_id,
  2089. BuffSelectionRequestItem.created_at >= today_start,
  2090. ).count()
  2091. if today_items >= items_limit:
  2092. return {"code": 403, "msg": f"今日选品条数已达上限({items_limit} 条),请明日再试", "data": None}
  2093. config_id = decrypted_params.get("config_id")
  2094. if config_id is not None:
  2095. try:
  2096. config_id = int(config_id)
  2097. except (TypeError, ValueError):
  2098. config_id = None
  2099. if config_id is None:
  2100. default_cfg = db.query(BuffSelectionConfig).filter(
  2101. BuffSelectionConfig.user_id == user_id,
  2102. BuffSelectionConfig.is_default == True,
  2103. BuffSelectionConfig.is_deleted == False,
  2104. ).first()
  2105. if default_cfg:
  2106. config_id = default_cfg.id
  2107. # 获取配置并固化选品条件快照
  2108. selection_condition_json = None
  2109. if config_id:
  2110. cfg_record = db.query(BuffSelectionConfig).filter(
  2111. BuffSelectionConfig.id == config_id,
  2112. BuffSelectionConfig.user_id == user_id,
  2113. BuffSelectionConfig.is_deleted == False,
  2114. ).first()
  2115. if cfg_record and cfg_record.config_json:
  2116. selection_condition_json = cfg_record.config_json
  2117. req = BuffSelectionRequest(
  2118. user_id=user_id,
  2119. config_id=config_id,
  2120. selection_condition_json=selection_condition_json,
  2121. status="pending",
  2122. )
  2123. db.add(req)
  2124. db.commit()
  2125. db.refresh(req)
  2126. request_id = req.id
  2127. loop = asyncio.get_event_loop()
  2128. loop.run_in_executor(None, lambda: run_selection_task(request_id, user_id, config_id))
  2129. msg = "选品已开始,正在后台执行"
  2130. if cleaned_request_ids:
  2131. msg = f"已停止{len(cleaned_request_ids)}个历史未结束选品任务,{msg}"
  2132. return {
  2133. "code": 200,
  2134. "msg": msg,
  2135. "data": {
  2136. "request_id": request_id,
  2137. "cleaned_request_ids": cleaned_request_ids,
  2138. },
  2139. }
  2140. except Exception as e:
  2141. logger.exception("POST /buff/secure/selection-start 异常: %s", e)
  2142. try:
  2143. db.rollback()
  2144. except Exception:
  2145. pass
  2146. return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
  2147. @router.post("/buff/secure/selection-log")
  2148. @secure_endpoint(require_login=True, require_machine_code=True)
  2149. async def post_buff_selection_log_secure(
  2150. decrypted_params: dict, request: Request, db: Session, session=None, user=None
  2151. ):
  2152. """
  2153. 获取最新一次选品请求的选品日志(安全端点)。
  2154. 返回:request_id, status, started_at, ended_at, logs(含每条是否选中、选中时的区间/价格,未选中时的原因)。
  2155. 若请求单未结束(pending/running),前端可定时轮询本接口直至结束。
  2156. """
  2157. try:
  2158. user_id = session.user_id
  2159. req = (
  2160. db.query(BuffSelectionRequest)
  2161. .filter(BuffSelectionRequest.user_id == user_id)
  2162. .order_by(BuffSelectionRequest.id.desc())
  2163. .first()
  2164. )
  2165. if not req:
  2166. return {
  2167. "code": 200,
  2168. "msg": "ok",
  2169. "data": {
  2170. "request_id": None,
  2171. "status": None,
  2172. "started_at": None,
  2173. "ended_at": None,
  2174. "max_sequence_no": 0,
  2175. "logs": [],
  2176. },
  2177. }
  2178. started_at = req.created_at.strftime("%Y-%m-%d %H:%M:%S") if req.created_at else None
  2179. ended_at = None
  2180. if req.status in ("completed", "failed", "cancelled") and req.updated_at:
  2181. ended_at = req.updated_at.strftime("%Y-%m-%d %H:%M:%S")
  2182. last_sequence_no_raw = decrypted_params.get("last_sequence_no")
  2183. limit_raw = decrypted_params.get("limit")
  2184. try:
  2185. last_sequence_no = int(last_sequence_no_raw) if last_sequence_no_raw is not None else None
  2186. except (TypeError, ValueError):
  2187. last_sequence_no = None
  2188. try:
  2189. limit = int(limit_raw) if limit_raw is not None else 200
  2190. except (TypeError, ValueError):
  2191. limit = 200
  2192. if limit <= 0:
  2193. limit = 200
  2194. if limit > 500:
  2195. limit = 500
  2196. logs_q = (
  2197. db.query(BuffSelectionRequestLog)
  2198. .filter(
  2199. BuffSelectionRequestLog.request_id == req.id,
  2200. BuffSelectionRequestLog.user_id == user_id,
  2201. )
  2202. )
  2203. if last_sequence_no is not None and last_sequence_no > 0:
  2204. logs_q = logs_q.filter(BuffSelectionRequestLog.sequence_no > last_sequence_no)
  2205. logs = (
  2206. logs_q
  2207. .order_by(BuffSelectionRequestLog.sequence_no.desc(), BuffSelectionRequestLog.created_at.desc())
  2208. .limit(limit)
  2209. .all()
  2210. )
  2211. item_map = {}
  2212. if logs:
  2213. selected_goods = [l.goods_id for l in logs if l.result == "selected"]
  2214. if selected_goods:
  2215. items = (
  2216. db.query(BuffSelectionRequestItem)
  2217. .filter(
  2218. BuffSelectionRequestItem.request_id == req.id,
  2219. BuffSelectionRequestItem.user_id == user_id,
  2220. BuffSelectionRequestItem.goods_id.in_(selected_goods),
  2221. )
  2222. .all()
  2223. )
  2224. for it in items:
  2225. item_map[it.goods_id] = {
  2226. "wear_range": it.wear_range,
  2227. "price_cny": float(it.price_cny) if it.price_cny is not None else None,
  2228. "price_usd": float(it.price_usd) if it.price_usd is not None else None,
  2229. }
  2230. log_list = []
  2231. for l in logs:
  2232. row = {
  2233. "created_at": l.created_at.strftime("%Y-%m-%d %H:%M:%S") if l.created_at else None,
  2234. "goods_id": l.goods_id,
  2235. "goods_name": l.goods_name or "",
  2236. "result": l.result,
  2237. "message": l.message or "",
  2238. }
  2239. if l.result == "selected":
  2240. log_wear_range = getattr(l, "wear_range", None)
  2241. log_price_cny = float(l.price_cny) if getattr(l, "price_cny", None) is not None else None
  2242. log_price_usd = float(l.price_usd) if getattr(l, "price_usd", None) is not None else None
  2243. if log_wear_range is not None or log_price_cny is not None or log_price_usd is not None:
  2244. row["wear_range"] = log_wear_range
  2245. row["price_cny"] = log_price_cny
  2246. row["price_usd"] = log_price_usd
  2247. elif l.goods_id in item_map:
  2248. row["wear_range"] = item_map[l.goods_id]["wear_range"]
  2249. row["price_cny"] = item_map[l.goods_id]["price_cny"]
  2250. row["price_usd"] = item_map[l.goods_id]["price_usd"]
  2251. else:
  2252. row["wear_range"] = None
  2253. row["price_cny"] = None
  2254. row["price_usd"] = None
  2255. else:
  2256. row["wear_range"] = None
  2257. row["price_cny"] = None
  2258. row["price_usd"] = None
  2259. log_list.append(row)
  2260. max_sequence_no = 0
  2261. if logs:
  2262. max_sequence_no = max(int(l.sequence_no or 0) for l in logs)
  2263. elif last_sequence_no is not None and last_sequence_no > 0:
  2264. max_sequence_no = last_sequence_no
  2265. return {
  2266. "code": 200,
  2267. "msg": "ok",
  2268. "data": {
  2269. "request_id": req.id,
  2270. "status": req.status,
  2271. "started_at": started_at,
  2272. "ended_at": ended_at,
  2273. "max_sequence_no": max_sequence_no,
  2274. "logs": log_list,
  2275. },
  2276. }
  2277. except Exception as e:
  2278. logger.exception("POST /buff/secure/selection-log 异常: %s", e)
  2279. try:
  2280. db.rollback()
  2281. except Exception:
  2282. pass
  2283. return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
  2284. @router.post("/buff/secure/selection-stop")
  2285. @secure_endpoint(require_login=True, require_machine_code=True)
  2286. async def post_buff_selection_stop_secure(
  2287. decrypted_params: dict, request: Request, db: Session, session=None, user=None
  2288. ):
  2289. """
  2290. 停止当前用户所有进行中选品任务(安全端点)。
  2291. 将 status=pending/running 的请求单均标记 cancel_requested;
  2292. 后台选品任务在循环中检测到后即退出并将状态置为 cancelled。
  2293. """
  2294. try:
  2295. user_id = session.user_id
  2296. unfinished = (
  2297. db.query(BuffSelectionRequest)
  2298. .filter(
  2299. BuffSelectionRequest.user_id == user_id,
  2300. BuffSelectionRequest.status.in_(["pending", "running"]),
  2301. )
  2302. .order_by(BuffSelectionRequest.id.asc())
  2303. .all()
  2304. )
  2305. if not unfinished:
  2306. return {"code": 200, "msg": "当前无进行中的选品任务", "data": None}
  2307. for r in unfinished:
  2308. r.cancel_requested = True
  2309. db.commit()
  2310. ids = [r.id for r in unfinished]
  2311. msg = (
  2312. "已发送停止请求,选品任务将尽快结束"
  2313. if len(ids) == 1
  2314. else f"已停止 {len(ids)} 个进行中的选品任务"
  2315. )
  2316. return {"code": 200, "msg": msg, "data": {"request_ids": ids, "request_id": ids[-1]}}
  2317. except Exception as e:
  2318. logger.exception("POST /buff/secure/selection-stop 异常: %s", e)
  2319. try:
  2320. db.rollback()
  2321. except Exception:
  2322. pass
  2323. return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
  2324. @router.post("/buff/secure/selection-stop-logout")
  2325. @secure_endpoint(require_login=True, require_machine_code=True, require_subscription_valid=False)
  2326. async def post_buff_selection_stop_logout_secure(
  2327. decrypted_params: dict, request: Request, db: Session, session=None, user=None
  2328. ):
  2329. """
  2330. 退登专用停止选品任务(安全端点)。
  2331. 仅校验签名、会话和机器码,不校验卡密有效期;用于被动退登场景强一致停止后台选品线程。
  2332. """
  2333. try:
  2334. user_id = session.user_id
  2335. unfinished = (
  2336. db.query(BuffSelectionRequest)
  2337. .filter(
  2338. BuffSelectionRequest.user_id == user_id,
  2339. BuffSelectionRequest.status.in_(["pending", "running"]),
  2340. )
  2341. .order_by(BuffSelectionRequest.id.asc())
  2342. .all()
  2343. )
  2344. if not unfinished:
  2345. return {"code": 200, "msg": "当前无进行中的选品任务", "data": None}
  2346. for r in unfinished:
  2347. r.cancel_requested = True
  2348. db.commit()
  2349. ids = [r.id for r in unfinished]
  2350. return {
  2351. "code": 200,
  2352. "msg": "已发送停止请求,选品任务将尽快结束",
  2353. "data": {"request_ids": ids, "request_id": ids[-1]},
  2354. }
  2355. except Exception as e:
  2356. logger.exception("POST /buff/secure/selection-stop-logout 异常: %s", e)
  2357. try:
  2358. db.rollback()
  2359. except Exception:
  2360. pass
  2361. return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
  2362. @router.post("/buff/secure/selection-result")
  2363. @secure_endpoint(require_login=True, require_machine_code=True)
  2364. async def post_buff_selection_result_secure(
  2365. decrypted_params: dict, request: Request, db: Session, session=None, user=None
  2366. ):
  2367. """
  2368. 获取最新一次选品请求的状态及选中饰品列表(安全端点)。
  2369. 用于前台「选品数据」表格定时刷新:返回 request_id, status, items;
  2370. 当 status 为 completed、failed 或 cancelled 时,前台可停止定时刷新。
  2371. 若传入 selection_request_id,则返回该次选品请求(用于本地扫货按指定批次拉取饰品列表)。
  2372. """
  2373. try:
  2374. user_id = session.user_id
  2375. selection_request_id_raw = decrypted_params.get("selection_request_id")
  2376. req = None
  2377. if selection_request_id_raw is not None:
  2378. try:
  2379. sid = int(selection_request_id_raw)
  2380. except (TypeError, ValueError):
  2381. sid = 0
  2382. if sid > 0:
  2383. req = (
  2384. db.query(BuffSelectionRequest)
  2385. .filter(
  2386. BuffSelectionRequest.user_id == user_id,
  2387. BuffSelectionRequest.id == sid,
  2388. )
  2389. .first()
  2390. )
  2391. if req is None:
  2392. req = (
  2393. db.query(BuffSelectionRequest)
  2394. .filter(BuffSelectionRequest.user_id == user_id)
  2395. .order_by(BuffSelectionRequest.id.desc())
  2396. .first()
  2397. )
  2398. if not req:
  2399. return {
  2400. "code": 200,
  2401. "msg": "ok",
  2402. "data": {
  2403. "request_id": None,
  2404. "status": None,
  2405. "started_at": None,
  2406. "ended_at": None,
  2407. "items": [],
  2408. },
  2409. }
  2410. started_at = req.created_at.strftime("%Y-%m-%d %H:%M:%S") if req.created_at else None
  2411. ended_at = None
  2412. if req.status in ("completed", "failed", "cancelled") and req.updated_at:
  2413. ended_at = req.updated_at.strftime("%Y-%m-%d %H:%M:%S")
  2414. item_rows = (
  2415. db.query(BuffSelectionRequestItem)
  2416. .filter(
  2417. BuffSelectionRequestItem.request_id == req.id,
  2418. BuffSelectionRequestItem.user_id == user_id,
  2419. )
  2420. .order_by(BuffSelectionRequestItem.created_at.desc(), BuffSelectionRequestItem.id.desc())
  2421. .all()
  2422. )
  2423. items = []
  2424. for it in item_rows:
  2425. items.append({
  2426. "id": it.id,
  2427. "goods_id": it.goods_id,
  2428. "goods_name": it.goods_name or "",
  2429. "icon_url": it.icon_url or "",
  2430. "steam_link": it.steam_link or "",
  2431. "wear_range": it.wear_range or "",
  2432. "price_cny": float(it.price_cny) if it.price_cny is not None else None,
  2433. "price_usd": float(it.price_usd) if it.price_usd is not None else None,
  2434. "created_at": it.created_at.strftime("%Y-%m-%d %H:%M:%S") if it.created_at else None,
  2435. })
  2436. return {
  2437. "code": 200,
  2438. "msg": "ok",
  2439. "data": {
  2440. "request_id": req.id,
  2441. "status": req.status,
  2442. "started_at": started_at,
  2443. "ended_at": ended_at,
  2444. "items": items,
  2445. },
  2446. }
  2447. except Exception as e:
  2448. logger.exception("POST /buff/secure/selection-result 异常: %s", e)
  2449. try:
  2450. db.rollback()
  2451. except Exception:
  2452. pass
  2453. return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
  2454. @router.post("/buff/secure/selection-item-delete")
  2455. @secure_endpoint(require_login=True, require_machine_code=True)
  2456. async def post_buff_selection_item_delete_secure(
  2457. decrypted_params: dict, request: Request, db: Session, session=None, user=None
  2458. ):
  2459. """
  2460. 删除选品明细(安全端点)。
  2461. - 仅允许删除当前用户最新一次选品请求的数据
  2462. - 删除后扫货不再扫描该饰品
  2463. """
  2464. try:
  2465. user_id = session.user_id
  2466. item_id_raw = decrypted_params.get("item_id")
  2467. if item_id_raw is None:
  2468. return {"code": 400, "msg": "缺少 item_id", "data": None}
  2469. try:
  2470. item_id = int(item_id_raw)
  2471. except (TypeError, ValueError):
  2472. return {"code": 400, "msg": "item_id 格式错误", "data": None}
  2473. latest_req = (
  2474. db.query(BuffSelectionRequest)
  2475. .filter(BuffSelectionRequest.user_id == user_id)
  2476. .order_by(BuffSelectionRequest.id.desc())
  2477. .first()
  2478. )
  2479. if not latest_req:
  2480. return {"code": 404, "msg": "暂无选品数据", "data": None}
  2481. row = (
  2482. db.query(BuffSelectionRequestItem)
  2483. .filter(
  2484. BuffSelectionRequestItem.id == item_id,
  2485. BuffSelectionRequestItem.user_id == user_id,
  2486. BuffSelectionRequestItem.request_id == latest_req.id,
  2487. )
  2488. .first()
  2489. )
  2490. if not row:
  2491. return {"code": 404, "msg": "未找到可删除的选品数据", "data": None}
  2492. db.delete(row)
  2493. db.commit()
  2494. return {"code": 200, "msg": "删除成功", "data": {"item_id": item_id}}
  2495. except Exception as e:
  2496. logger.exception("POST /buff/secure/selection-item-delete 异常: %s", e)
  2497. try:
  2498. db.rollback()
  2499. except Exception:
  2500. pass
  2501. return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
  2502. @router.post("/buff/secure/selection-batch-import")
  2503. @secure_endpoint(require_login=True, require_machine_code=True)
  2504. async def post_buff_selection_batch_import_secure(
  2505. decrypted_params: dict, request: Request, db: Session, session=None, user=None
  2506. ):
  2507. """
  2508. 批量导入选品数据(安全端点)。
  2509. - 先删除当前用户最新一次选品请求的所有数据
  2510. - 再批量插入新的选品数据
  2511. 入参 items: List[dict],每个 dict 包含:
  2512. goods_id, goods_name(可选,JSON中有name字段), icon_url(从buff_goods_attr表获取), steam_link, price_cny, price_usd, wear_range
  2513. """
  2514. try:
  2515. user_id = session.user_id
  2516. # 获取当前用户最新一次选品请求
  2517. latest_req = (
  2518. db.query(BuffSelectionRequest)
  2519. .filter(
  2520. BuffSelectionRequest.user_id == user_id,
  2521. BuffSelectionRequest.status.in_(["completed", "running", "pending"]),
  2522. )
  2523. .order_by(BuffSelectionRequest.id.desc())
  2524. .first()
  2525. )
  2526. if not latest_req:
  2527. # 没有选品请求,创建一个新的
  2528. latest_req = BuffSelectionRequest(
  2529. user_id=user_id,
  2530. status="completed",
  2531. created_at=beijing_now_naive(),
  2532. )
  2533. db.add(latest_req)
  2534. db.flush()
  2535. else:
  2536. # 删除该请求单下的所有选品明细
  2537. db.query(BuffSelectionRequestItem).filter(
  2538. BuffSelectionRequestItem.request_id == latest_req.id,
  2539. BuffSelectionRequestItem.user_id == user_id,
  2540. ).delete(synchronize_session=False)
  2541. # 解析导入数据
  2542. items = decrypted_params.get("items") or []
  2543. if not isinstance(items, list):
  2544. return {"code": 400, "msg": "items 参数格式错误,必须是数组", "data": None}
  2545. # 收集所有 goods_id,批量查询 buff_goods_attr 获取 icon_url,批量查询 buff_goods_info 获取 steam_url
  2546. all_goods_ids = []
  2547. for item in items:
  2548. if isinstance(item, dict):
  2549. goods_id = str(item.get("goods_id") or "").strip()
  2550. if goods_id:
  2551. all_goods_ids.append(goods_id)
  2552. # 批量查询 icon_url 映射
  2553. icon_url_map = {}
  2554. if all_goods_ids:
  2555. attrs = db.query(BuffGoodsAttr).filter(
  2556. BuffGoodsAttr.goods_id.in_(all_goods_ids)
  2557. ).all()
  2558. for attr in attrs:
  2559. icon_url_map[attr.goods_id] = attr.icon_url
  2560. # 批量查询 steam_url 映射(从 buff_goods_info 表获取)
  2561. steam_url_map = {}
  2562. if all_goods_ids:
  2563. goods_infos = db.query(BuffGoodsInfo).filter(
  2564. BuffGoodsInfo.goods_id.in_(all_goods_ids)
  2565. ).all()
  2566. for info in goods_infos:
  2567. steam_url_map[info.goods_id] = info.steam_url
  2568. inserted_count = 0
  2569. for item in items:
  2570. if not isinstance(item, dict):
  2571. continue
  2572. goods_id = str(item.get("goods_id") or "").strip()
  2573. if not goods_id:
  2574. continue
  2575. # 优先使用 JSON 中的 name 作为 goods_name,如果没有就用 buff_goods_attr 中的 name
  2576. goods_name = str(item.get("goods_name") or item.get("name") or "")[:256]
  2577. # icon_url 从 buff_goods_attr 表获取,没有就保持空
  2578. icon_url = icon_url_map.get(goods_id, "") or ""
  2579. if icon_url:
  2580. icon_url = icon_url[:512]
  2581. # steam_link 优先从 buff_goods_info 表获取,没有就使用 JSON 中的值
  2582. steam_link = steam_url_map.get(goods_id, "") or str(item.get("steam_link") or "")[:512]
  2583. new_item = BuffSelectionRequestItem(
  2584. request_id=latest_req.id,
  2585. user_id=user_id,
  2586. goods_id=goods_id,
  2587. goods_name=goods_name,
  2588. icon_url=icon_url,
  2589. steam_link=steam_link,
  2590. price_cny=item.get("price_cny"),
  2591. price_usd=item.get("price_usd"),
  2592. wear_range=str(item.get("wear_range") or "")[:64],
  2593. created_at=beijing_now_naive(),
  2594. )
  2595. db.add(new_item)
  2596. inserted_count += 1
  2597. db.commit()
  2598. return {
  2599. "code": 200,
  2600. "msg": f"批量导入成功,共导入 {inserted_count} 条数据",
  2601. "data": {"imported_count": inserted_count}
  2602. }
  2603. except Exception as e:
  2604. logger.exception("POST /buff/secure/selection-batch-import 异常: %s", e)
  2605. try:
  2606. db.rollback()
  2607. except Exception:
  2608. pass
  2609. return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
  2610. @router.post("/buff/secure/sweep-bound-steam-usernames")
  2611. @secure_endpoint(require_login=True, require_machine_code=True)
  2612. async def post_buff_sweep_bound_steam_usernames_secure(
  2613. decrypted_params: dict, request: Request, db: Session, session=None, user=None
  2614. ):
  2615. """
  2616. 扫货专用:返回当前用户在后端已绑定且启用的 Steam 账号用户名列表。
  2617. 客户端仅允许使用这些用户名与本地 accounts.json 交集进行扫货。
  2618. """
  2619. try:
  2620. user_id = session.user_id
  2621. rows = (
  2622. db.query(SteamAccount)
  2623. .filter(
  2624. SteamAccount.user_id == user_id,
  2625. SteamAccount.is_deleted == False,
  2626. SteamAccount.is_active == True,
  2627. )
  2628. .order_by(SteamAccount.id.asc())
  2629. .all()
  2630. )
  2631. names = [str(r.steam_username).strip() for r in rows if r.steam_username]
  2632. return {"code": 200, "msg": "ok", "data": {"steam_usernames": names}}
  2633. except Exception as e:
  2634. logger.exception("POST /buff/secure/sweep-bound-steam-usernames 异常: %s", e)
  2635. try:
  2636. db.rollback()
  2637. except Exception:
  2638. pass
  2639. return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
  2640. @router.post("/buff/secure/sweep-start")
  2641. @secure_endpoint(require_login=True, require_machine_code=True)
  2642. async def post_buff_sweep_start_secure(
  2643. decrypted_params: dict, request: Request, db: Session, session=None, user=None
  2644. ):
  2645. """
  2646. 开始扫货(安全端点)。
  2647. - 默认基于最新一次选品请求的数据
  2648. - 若已有 pending/running 的扫货请求,则禁止重复开始
  2649. """
  2650. import asyncio
  2651. try:
  2652. user_id = session.user_id
  2653. unfinished_reqs = (
  2654. db.query(BuffSweepRequest)
  2655. .filter(
  2656. BuffSweepRequest.user_id == user_id,
  2657. BuffSweepRequest.status.in_(("pending", "running")),
  2658. )
  2659. .order_by(BuffSweepRequest.id.asc())
  2660. .all()
  2661. )
  2662. cleaned_request_ids = []
  2663. if unfinished_reqs:
  2664. for old_req in unfinished_reqs:
  2665. old_req.cancel_requested = True
  2666. old_req.status = "cancelled"
  2667. cleaned_request_ids.append(old_req.id)
  2668. db.flush()
  2669. latest_selection = (
  2670. db.query(BuffSelectionRequest)
  2671. .filter(BuffSelectionRequest.user_id == user_id)
  2672. .order_by(BuffSelectionRequest.id.desc())
  2673. .first()
  2674. )
  2675. if not latest_selection:
  2676. return {"code": 404, "msg": "暂无选品数据,请先完成选品", "data": None}
  2677. item_count = (
  2678. db.query(BuffSelectionRequestItem)
  2679. .filter(
  2680. BuffSelectionRequestItem.user_id == user_id,
  2681. BuffSelectionRequestItem.request_id == latest_selection.id,
  2682. )
  2683. .count()
  2684. )
  2685. if item_count <= 0:
  2686. return {"code": 400, "msg": "最新选品数据为空,请先选品或检查是否已删除全部饰品", "data": None}
  2687. req = BuffSweepRequest(
  2688. user_id=user_id,
  2689. selection_request_id=latest_selection.id,
  2690. status="pending",
  2691. )
  2692. db.add(req)
  2693. db.commit()
  2694. db.refresh(req)
  2695. client_local = bool(decrypted_params.get("client_local"))
  2696. if not client_local:
  2697. loop = asyncio.get_event_loop()
  2698. loop.run_in_executor(None, lambda: run_sweep_task(req.id, user_id, latest_selection.id))
  2699. msg = "扫货已开始,正在后台执行"
  2700. else:
  2701. msg = "扫货请求单已创建,等待客户端本地执行"
  2702. if cleaned_request_ids:
  2703. msg = f"已停止{len(cleaned_request_ids)}个历史未结束扫货任务,{msg}"
  2704. return {
  2705. "code": 200,
  2706. "msg": msg,
  2707. "data": {
  2708. "request_id": req.id,
  2709. "selection_request_id": latest_selection.id,
  2710. "cleaned_request_ids": cleaned_request_ids,
  2711. },
  2712. }
  2713. except Exception as e:
  2714. logger.exception("POST /buff/secure/sweep-start 异常: %s", e)
  2715. try:
  2716. db.rollback()
  2717. except Exception:
  2718. pass
  2719. return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
  2720. @router.post("/buff/secure/sweep-stop")
  2721. @secure_endpoint(require_login=True, require_machine_code=True)
  2722. async def post_buff_sweep_stop_secure(
  2723. decrypted_params: dict, request: Request, db: Session, session=None, user=None
  2724. ):
  2725. """停止当前用户所有进行中的扫货任务(安全端点,与 sweep-start 清理逻辑一致)。"""
  2726. try:
  2727. user_id = session.user_id
  2728. unfinished = (
  2729. db.query(BuffSweepRequest)
  2730. .filter(
  2731. BuffSweepRequest.user_id == user_id,
  2732. BuffSweepRequest.status.in_(("pending", "running")),
  2733. )
  2734. .order_by(BuffSweepRequest.id.asc())
  2735. .all()
  2736. )
  2737. if not unfinished:
  2738. return {"code": 200, "msg": "当前无进行中的扫货任务", "data": None}
  2739. for r in unfinished:
  2740. r.cancel_requested = True
  2741. if r.status in ("pending", "running"):
  2742. r.status = "cancelled"
  2743. db.commit()
  2744. ids = [r.id for r in unfinished]
  2745. msg = "扫货已停止" if len(ids) == 1 else f"已停止 {len(ids)} 个进行中的扫货任务"
  2746. return {"code": 200, "msg": msg, "data": {"request_ids": ids, "request_id": ids[-1]}}
  2747. except Exception as e:
  2748. logger.exception("POST /buff/secure/sweep-stop 异常: %s", e)
  2749. try:
  2750. db.rollback()
  2751. except Exception:
  2752. pass
  2753. return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
  2754. @router.post("/buff/secure/sweep-log")
  2755. @secure_endpoint(require_login=True, require_machine_code=True)
  2756. async def post_buff_sweep_log_secure(
  2757. decrypted_params: dict, request: Request, db: Session, session=None, user=None
  2758. ):
  2759. """获取最新一次扫货日志(安全端点)。"""
  2760. try:
  2761. user_id = session.user_id
  2762. req = (
  2763. db.query(BuffSweepRequest)
  2764. .filter(BuffSweepRequest.user_id == user_id)
  2765. .order_by(BuffSweepRequest.id.desc())
  2766. .first()
  2767. )
  2768. if not req:
  2769. return {
  2770. "code": 200,
  2771. "msg": "ok",
  2772. "data": {
  2773. "request_id": None,
  2774. "status": None,
  2775. "started_at": None,
  2776. "ended_at": None,
  2777. "max_sequence_no": 0,
  2778. "logs": [],
  2779. },
  2780. }
  2781. last_sequence_no_raw = decrypted_params.get("last_sequence_no")
  2782. limit_raw = decrypted_params.get("limit")
  2783. try:
  2784. last_sequence_no = int(last_sequence_no_raw) if last_sequence_no_raw is not None else None
  2785. except (TypeError, ValueError):
  2786. last_sequence_no = None
  2787. try:
  2788. limit = int(limit_raw) if limit_raw is not None else 200
  2789. except (TypeError, ValueError):
  2790. limit = 200
  2791. if limit <= 0:
  2792. limit = 200
  2793. if limit > 500:
  2794. limit = 500
  2795. logs_q = (
  2796. db.query(BuffSweepRequestLog)
  2797. .filter(
  2798. BuffSweepRequestLog.user_id == user_id,
  2799. BuffSweepRequestLog.sweep_request_id == req.id,
  2800. )
  2801. )
  2802. if last_sequence_no is not None and last_sequence_no > 0:
  2803. logs_q = logs_q.filter(BuffSweepRequestLog.sequence_no > last_sequence_no)
  2804. logs = (
  2805. logs_q
  2806. .order_by(BuffSweepRequestLog.sequence_no.desc(), BuffSweepRequestLog.created_at.desc())
  2807. .limit(limit)
  2808. .all()
  2809. )
  2810. max_sequence_no = 0
  2811. if logs:
  2812. max_sequence_no = max(int(l.sequence_no or 0) for l in logs)
  2813. elif last_sequence_no is not None and last_sequence_no > 0:
  2814. max_sequence_no = last_sequence_no
  2815. return {
  2816. "code": 200,
  2817. "msg": "ok",
  2818. "data": {
  2819. "request_id": req.id,
  2820. "status": req.status,
  2821. "started_at": req.created_at.strftime("%Y-%m-%d %H:%M:%S") if req.created_at else None,
  2822. "ended_at": req.updated_at.strftime("%Y-%m-%d %H:%M:%S") if req.status in ("completed", "failed", "cancelled") and req.updated_at else None,
  2823. "max_sequence_no": max_sequence_no,
  2824. "logs": [
  2825. {
  2826. "created_at": l.created_at.strftime("%Y-%m-%d %H:%M:%S") if l.created_at else None,
  2827. "goods_id": l.goods_id,
  2828. "goods_name": l.goods_name or "",
  2829. "result": l.result,
  2830. "message": l.message or "",
  2831. "retry_attempts": int(l.retry_attempts or 0),
  2832. "proxy_refreshed": bool(l.proxy_refreshed),
  2833. "target_price_usd": float(l.target_price_usd) if l.target_price_usd is not None else None,
  2834. "purchase_price_usd": float(l.purchase_price_usd) if l.purchase_price_usd is not None else None,
  2835. }
  2836. for l in logs
  2837. ],
  2838. },
  2839. }
  2840. except Exception as e:
  2841. logger.exception("POST /buff/secure/sweep-log 异常: %s", e)
  2842. try:
  2843. db.rollback()
  2844. except Exception:
  2845. pass
  2846. return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
  2847. @router.post("/buff/secure/sweep-log-client-sync")
  2848. @secure_endpoint(require_login=True, require_machine_code=True)
  2849. async def post_buff_sweep_log_client_sync_secure(
  2850. decrypted_params: dict, request: Request, db: Session, session=None, user=None
  2851. ):
  2852. """
  2853. 客户端本地扫货日志同步到后端数据库(安全端点)。
  2854. 说明:
  2855. - 客户端以本地 steam-msg/{request_id}.txt 为准
  2856. - 后端按 user_id + sweep_request_id 做覆盖写入(先删后插),保留最新快照
  2857. """
  2858. try:
  2859. user_id = session.user_id
  2860. sweep_request_id_raw = decrypted_params.get("sweep_request_id")
  2861. if sweep_request_id_raw is None:
  2862. return {"code": 400, "msg": "缺少 sweep_request_id", "data": None}
  2863. try:
  2864. sweep_request_id = int(sweep_request_id_raw)
  2865. except (TypeError, ValueError):
  2866. return {"code": 400, "msg": "sweep_request_id 格式错误", "data": None}
  2867. if sweep_request_id <= 0:
  2868. return {"code": 400, "msg": "sweep_request_id 必须大于0", "data": None}
  2869. logs = decrypted_params.get("logs")
  2870. if not isinstance(logs, list):
  2871. return {"code": 400, "msg": "logs 格式错误", "data": None}
  2872. selection_request_id_raw = decrypted_params.get("selection_request_id")
  2873. status_raw = str(decrypted_params.get("status") or "").strip().lower()
  2874. allowed_status = {"pending", "running", "completed", "failed", "cancelled"}
  2875. normalized_status = status_raw if status_raw in allowed_status else None
  2876. req = (
  2877. db.query(BuffSweepRequest)
  2878. .filter(
  2879. BuffSweepRequest.id == sweep_request_id,
  2880. BuffSweepRequest.user_id == user_id,
  2881. )
  2882. .first()
  2883. )
  2884. if req is None:
  2885. selection_request_id = None
  2886. try:
  2887. if selection_request_id_raw is not None:
  2888. selection_request_id = int(selection_request_id_raw)
  2889. except (TypeError, ValueError):
  2890. selection_request_id = None
  2891. if not selection_request_id or selection_request_id <= 0:
  2892. latest_selection = (
  2893. db.query(BuffSelectionRequest)
  2894. .filter(BuffSelectionRequest.user_id == user_id)
  2895. .order_by(BuffSelectionRequest.id.desc())
  2896. .first()
  2897. )
  2898. if latest_selection:
  2899. selection_request_id = latest_selection.id
  2900. if not selection_request_id or selection_request_id <= 0:
  2901. return {"code": 400, "msg": "缺少有效 selection_request_id,无法创建扫货请求单", "data": None}
  2902. req = BuffSweepRequest(
  2903. id=sweep_request_id,
  2904. user_id=user_id,
  2905. selection_request_id=selection_request_id,
  2906. status=normalized_status or "pending",
  2907. )
  2908. db.add(req)
  2909. elif normalized_status:
  2910. req.status = normalized_status
  2911. # 覆盖式写入:删除同请求历史日志,再插入本次快照
  2912. db.query(BuffSweepRequestLog).filter(
  2913. BuffSweepRequestLog.user_id == user_id,
  2914. BuffSweepRequestLog.sweep_request_id == sweep_request_id,
  2915. ).delete(synchronize_session=False)
  2916. insert_count = 0
  2917. for idx, row in enumerate(logs, start=1):
  2918. if not isinstance(row, dict):
  2919. continue
  2920. goods_id = str(row.get("goods_id") or "").strip()
  2921. goods_name = str(row.get("goods_name") or "").strip()
  2922. result = str(row.get("result") or "").strip() or "skipped"
  2923. message = str(row.get("message") or "").strip()
  2924. if not goods_id:
  2925. goods_id = "-"
  2926. target_price = row.get("target_price_usd")
  2927. purchase_price = row.get("purchase_price_usd")
  2928. try:
  2929. target_price = float(target_price) if target_price is not None else None
  2930. except Exception:
  2931. target_price = None
  2932. try:
  2933. purchase_price = float(purchase_price) if purchase_price is not None else None
  2934. except Exception:
  2935. purchase_price = None
  2936. db.add(BuffSweepRequestLog(
  2937. sweep_request_id=sweep_request_id,
  2938. user_id=user_id,
  2939. goods_id=goods_id,
  2940. goods_name=goods_name,
  2941. sequence_no=idx,
  2942. result=result,
  2943. message=message[:512] if message else "",
  2944. retry_attempts=0,
  2945. proxy_refreshed=False,
  2946. target_price_usd=target_price,
  2947. purchase_price_usd=purchase_price,
  2948. ))
  2949. insert_count += 1
  2950. db.commit()
  2951. return {"code": 200, "msg": "ok", "data": {"sweep_request_id": sweep_request_id, "inserted": insert_count}}
  2952. except Exception as e:
  2953. logger.exception("POST /buff/secure/sweep-log-client-sync 异常: %s", e)
  2954. try:
  2955. db.rollback()
  2956. except Exception:
  2957. pass
  2958. return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
  2959. @router.post("/buff/secure/sweep-item-client-sync")
  2960. @secure_endpoint(require_login=True, require_machine_code=True)
  2961. async def post_buff_sweep_item_client_sync_secure(
  2962. decrypted_params: dict, request: Request, db: Session, session=None, user=None
  2963. ):
  2964. """
  2965. 客户端本地扫货成功明细同步到后端数据库(安全端点)。
  2966. 说明:
  2967. - 客户端提交成功购买 items 快照(含 steam_account)
  2968. - 默认:按 user_id + sweep_request_id 覆盖写入(先删后插)
  2969. - append_only=true:不删除历史行,每笔成功购买追加一行(同一选品明细可有多笔成交,勿按 selection_item_id 覆盖)
  2970. """
  2971. try:
  2972. user_id = session.user_id
  2973. append_only = bool(decrypted_params.get("append_only"))
  2974. sweep_request_id_raw = decrypted_params.get("sweep_request_id")
  2975. if sweep_request_id_raw is None:
  2976. return {"code": 400, "msg": "缺少 sweep_request_id", "data": None}
  2977. try:
  2978. sweep_request_id = int(sweep_request_id_raw)
  2979. except (TypeError, ValueError):
  2980. return {"code": 400, "msg": "sweep_request_id 格式错误", "data": None}
  2981. if sweep_request_id <= 0:
  2982. return {"code": 400, "msg": "sweep_request_id 必须大于0", "data": None}
  2983. items = decrypted_params.get("items")
  2984. if not isinstance(items, list):
  2985. return {"code": 400, "msg": "items 格式错误", "data": None}
  2986. selection_request_id_raw = decrypted_params.get("selection_request_id")
  2987. req = (
  2988. db.query(BuffSweepRequest)
  2989. .filter(
  2990. BuffSweepRequest.id == sweep_request_id,
  2991. BuffSweepRequest.user_id == user_id,
  2992. )
  2993. .first()
  2994. )
  2995. if req is None:
  2996. selection_request_id = None
  2997. try:
  2998. if selection_request_id_raw is not None:
  2999. selection_request_id = int(selection_request_id_raw)
  3000. except (TypeError, ValueError):
  3001. selection_request_id = None
  3002. if not selection_request_id or selection_request_id <= 0:
  3003. latest_selection = (
  3004. db.query(BuffSelectionRequest)
  3005. .filter(BuffSelectionRequest.user_id == user_id)
  3006. .order_by(BuffSelectionRequest.id.desc())
  3007. .first()
  3008. )
  3009. if latest_selection:
  3010. selection_request_id = latest_selection.id
  3011. if not selection_request_id or selection_request_id <= 0:
  3012. return {"code": 400, "msg": "缺少有效 selection_request_id,无法创建扫货请求单", "data": None}
  3013. req = BuffSweepRequest(
  3014. id=sweep_request_id,
  3015. user_id=user_id,
  3016. selection_request_id=selection_request_id,
  3017. status="pending",
  3018. )
  3019. db.add(req)
  3020. if not append_only:
  3021. # 覆盖式写入:删除同请求历史成功明细,再插入本次快照
  3022. db.query(BuffSweepRequestItem).filter(
  3023. BuffSweepRequestItem.user_id == user_id,
  3024. BuffSweepRequestItem.sweep_request_id == sweep_request_id,
  3025. ).delete(synchronize_session=False)
  3026. insert_count = 0
  3027. for row in items:
  3028. if not isinstance(row, dict):
  3029. continue
  3030. try:
  3031. selection_item_id = int(row.get("selection_item_id") or 0)
  3032. except (TypeError, ValueError):
  3033. selection_item_id = 0
  3034. if selection_item_id <= 0:
  3035. continue
  3036. goods_id = str(row.get("goods_id") or "").strip()
  3037. if not goods_id:
  3038. continue
  3039. try:
  3040. item_selection_request_id = int(
  3041. row.get("selection_request_id") or req.selection_request_id
  3042. )
  3043. except (TypeError, ValueError):
  3044. item_selection_request_id = req.selection_request_id
  3045. target_price = row.get("target_price_usd")
  3046. purchase_price = row.get("purchase_price_usd")
  3047. purchase_price_cny = row.get("purchase_price_cny")
  3048. purchase_wear = row.get("purchase_wear")
  3049. try:
  3050. target_price = float(target_price) if target_price is not None else None
  3051. except Exception:
  3052. target_price = None
  3053. try:
  3054. purchase_price = float(purchase_price) if purchase_price is not None else None
  3055. except Exception:
  3056. purchase_price = None
  3057. try:
  3058. purchase_price_cny = float(purchase_price_cny) if purchase_price_cny is not None else None
  3059. except Exception:
  3060. purchase_price_cny = None
  3061. try:
  3062. purchase_wear = float(purchase_wear) if purchase_wear is not None else None
  3063. except Exception:
  3064. purchase_wear = None
  3065. steam_account = str(row.get("steam_account") or "").strip() or None
  3066. db.add(BuffSweepRequestItem(
  3067. sweep_request_id=sweep_request_id,
  3068. user_id=user_id,
  3069. selection_request_id=item_selection_request_id,
  3070. selection_item_id=selection_item_id,
  3071. goods_id=goods_id,
  3072. goods_name=str(row.get("goods_name") or "").strip() or None,
  3073. icon_url=str(row.get("icon_url") or "").strip() or None,
  3074. wear_range=str(row.get("wear_range") or "").strip() or None,
  3075. purchase_wear=purchase_wear,
  3076. target_price_usd=target_price,
  3077. purchase_price_usd=purchase_price,
  3078. purchase_price_cny=purchase_price_cny,
  3079. steam_account=steam_account,
  3080. ))
  3081. insert_count += 1
  3082. db.commit()
  3083. return {"code": 200, "msg": "ok", "data": {"sweep_request_id": sweep_request_id, "inserted": insert_count}}
  3084. except Exception as e:
  3085. logger.exception("POST /buff/secure/sweep-item-client-sync 异常: %s", e)
  3086. try:
  3087. db.rollback()
  3088. except Exception:
  3089. pass
  3090. return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
  3091. @router.post("/buff/secure/sweep-result")
  3092. @secure_endpoint(require_login=True, require_machine_code=True)
  3093. async def post_buff_sweep_result_secure(
  3094. decrypted_params: dict, request: Request, db: Session, session=None, user=None
  3095. ):
  3096. """
  3097. 获取扫货成功列表(安全端点)。
  3098. - 结果始终返回该用户全部扫货成功记录
  3099. - 支持分页 page/page_size
  3100. """
  3101. try:
  3102. user_id = session.user_id
  3103. page = int(decrypted_params.get("page") or 1)
  3104. page_size = int(decrypted_params.get("page_size") or 10)
  3105. if page < 1:
  3106. page = 1
  3107. if page_size < 1 or page_size > 100:
  3108. page_size = 10
  3109. latest_req = (
  3110. db.query(BuffSweepRequest)
  3111. .filter(BuffSweepRequest.user_id == user_id)
  3112. .order_by(BuffSweepRequest.id.desc())
  3113. .first()
  3114. )
  3115. q = db.query(BuffSweepRequestItem).filter(BuffSweepRequestItem.user_id == user_id)
  3116. total = q.count()
  3117. rows = (
  3118. q.order_by(BuffSweepRequestItem.created_at.desc(), BuffSweepRequestItem.id.desc())
  3119. .offset((page - 1) * page_size)
  3120. .limit(page_size)
  3121. .all()
  3122. )
  3123. return {
  3124. "code": 200,
  3125. "msg": "ok",
  3126. "data": {
  3127. "request_id": latest_req.id if latest_req else None,
  3128. "status": latest_req.status if latest_req else None,
  3129. "started_at": latest_req.created_at.strftime("%Y-%m-%d %H:%M:%S") if latest_req and latest_req.created_at else None,
  3130. "ended_at": latest_req.updated_at.strftime("%Y-%m-%d %H:%M:%S") if latest_req and latest_req.status in ("completed", "failed", "cancelled") and latest_req.updated_at else None,
  3131. "page": page,
  3132. "page_size": page_size,
  3133. "total": total,
  3134. "items": [
  3135. {
  3136. "id": r.id,
  3137. "sweep_request_id": r.sweep_request_id,
  3138. "selection_request_id": r.selection_request_id,
  3139. "selection_item_id": r.selection_item_id,
  3140. "goods_id": r.goods_id,
  3141. "goods_name": r.goods_name or "",
  3142. "icon_url": r.icon_url or "",
  3143. "wear_range": r.wear_range or "",
  3144. "purchase_wear": str(r.purchase_wear) if r.purchase_wear is not None else None,
  3145. "target_price_usd": float(r.target_price_usd) if r.target_price_usd is not None else None,
  3146. "purchase_price_usd": float(r.purchase_price_usd) if r.purchase_price_usd is not None else None,
  3147. "purchase_price_cny": float(r.purchase_price_cny) if r.purchase_price_cny is not None else None,
  3148. "steam_account": r.steam_account,
  3149. "created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None,
  3150. }
  3151. for r in rows
  3152. ],
  3153. },
  3154. }
  3155. except Exception as e:
  3156. logger.exception("POST /buff/secure/sweep-result 异常: %s", e)
  3157. try:
  3158. db.rollback()
  3159. except Exception:
  3160. pass
  3161. return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
  3162. # ============ HMAC 密钥(安全端点,登录后密钥交换) ============
  3163. @router.post("/keys/hmac/secure")
  3164. @secure_endpoint(require_login=True, require_machine_code=True)
  3165. async def post_keys_hmac_secure(
  3166. decrypted_params: dict, request: Request, db: Session, session=None, user=None
  3167. ):
  3168. """
  3169. 同步/获取动态HMAC密钥(安全端点:POST,入参出参密文,时间戳与签名校验)。
  3170. 会话与机器码由装饰器校验,无需解密参数。日志统一记录入参/出参密文与明文。
  3171. """
  3172. try:
  3173. from app.services.hmac_key_service import HmacKeyService
  3174. hmac_service = HmacKeyService(db)
  3175. success, message, data = hmac_service.create_hmac_key(
  3176. user_id=user.id,
  3177. username=user.username,
  3178. machine_code=session.machine_code,
  3179. )
  3180. if success and data:
  3181. return {"code": 200, "msg": message or "成功", "data": data}
  3182. return {"code": 400, "msg": message or "HMAC密钥获取失败", "data": None}
  3183. except Exception as e:
  3184. logger.exception("POST /keys/hmac/secure 异常: %s", e)
  3185. return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}