| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530 |
- """
- API路由 - v1版本
- 完整实现认证、充值、心跳、公告、密钥管理等核心接口
- 符合统一用户友好错误提示规范
- """
- from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
- from pydantic import BaseModel, Field
- from typing import Optional, Dict, Any, List
- from sqlalchemy.orm import Session
- from datetime import datetime
- import time
- import hashlib
- import json
- from app.database import get_db
- from app.core.encrypt import encryptor
- from app.middleware.security import create_encrypted_response, secure_endpoint, decrypt_request_data
- from app.services.auth_service import AuthService, verify_password
- from app.services.recharge_service import RechargeService
- from app.services.heartbeat_service import HeartbeatService
- from app.services.announcement_service import AnnouncementService
- from app.services.client_version_service import ClientVersionService
- from app.services.key_service import KeyService
- from app.services.steam_account_service import SteamAccountService
- from app.utils.response import create_client_response, UserFriendlyError
- from app.models.buff_filter_option import BuffFilterOption
- from app.models.buff_selection_config import BuffSelectionConfig
- from app.models.buff_selection_request import BuffSelectionRequest
- from app.models.buff_goods_attr import BuffGoodsAttr
- from app.models.buff_goods_info import BuffGoodsInfo
- from app.services.buff_selection_service import run_selection_task
- from fastapi.responses import JSONResponse
- from app.utils.timezone import beijing_now_naive
- from app.models.system_config import SystemConfig
- from app.models.buff_selection_request_item import BuffSelectionRequestItem
- from app.models.buff_selection_request_log import BuffSelectionRequestLog
- from app.models.buff_sweep_request import BuffSweepRequest
- from app.models.buff_sweep_request_item import BuffSweepRequestItem
- from app.models.buff_sweep_request_log import BuffSweepRequestLog
- from app.models.steam_account import SteamAccount
- from app.services.buff_sweep_service import run_sweep_task
- from app.utils.machine_code import raw_machine_info_to_machine_code as _raw_to_machine_code
- from app.core.logger import logger
- router = APIRouter()
- # ============ 请求/响应模型 ============
- class LoginRequest(BaseModel):
- """登录请求"""
- username: str = Field(..., min_length=3, max_length=100, description="用户名")
- password: str = Field(..., min_length=6, max_length=100, description="密码")
- machine_code: str = Field(..., description="机器码")
- class RegisterRequest(BaseModel):
- """注册请求"""
- username: str = Field(..., min_length=3, max_length=100, description="用户名")
- password: str = Field(..., min_length=6, max_length=100, description="密码")
- email: Optional[str] = Field(None, description="邮箱")
- card_code: Optional[str] = Field(None, description="卡密代码")
- class RechargeRequest(BaseModel):
- """充值请求"""
- username: str = Field(..., description="用户名")
- password: str = Field(..., description="密码")
- card_code: str = Field(..., description="卡密代码")
- class UnbindRequest(BaseModel):
- """解绑请求"""
- username: str = Field(..., description="用户名")
- password: str = Field(..., description="密码")
- class ForgotPasswordRequest(BaseModel):
- """找回密码请求"""
- username: str = Field(..., min_length=1, description="用户名")
- email: str = Field(..., min_length=1, description="邮箱")
- class HeartbeatRequest(BaseModel):
- """心跳请求"""
- session_id: str = Field(..., description="会话ID")
- machine_code: str = Field(..., description="机器码")
- class AnnouncementsRequest(BaseModel):
- """公告请求"""
- page: int = Field(1, ge=1, description="页码")
- page_size: int = Field(10, ge=1, le=100, description="每页数量")
- class LogoutRequest(BaseModel):
- """登出请求"""
- session_id: str = Field(..., description="会话ID")
- class ClientConfigRequest(BaseModel):
- """客户端配置请求"""
- session_id: str = Field(..., description="会话ID")
- class ClientVersionCheckRequest(BaseModel):
- """客户端版本检查请求"""
- client_version: str = Field(..., description="当前客户端版本")
- platform: Optional[str] = Field("windows", description="平台: windows/macos/linux")
- channel: Optional[str] = Field("stable", description="通道: stable/beta")
- # ============ 响应模型 ============
- class UserInfoResponse(BaseModel):
- """用户信息响应"""
- user_id: int
- username: str
- email: Optional[str]
- expire_time: Optional[str]
- days_remaining: int
- status: int
- class AnnouncementsResponse(BaseModel):
- """公告响应"""
- data: List[Dict[str, Any]]
- total: int
- page: int
- page_size: int
- class HeartbeatResponse(BaseModel):
- """心跳响应"""
- status: str
- session_id: str
- expire_time: str
- config: Dict[str, Any]
- # ============ 辅助函数 ============
- def handle_service_result(success: bool, message: str, data: Any = None,
- error_mapping: Optional[Dict[str, int]] = None) -> Dict[str, Any]:
- """
- 处理服务层返回结果,转换为统一错误响应格式
-
- Args:
- success: 是否成功
- message: 消息
- data: 响应数据
- error_mapping: 错误消息到状态码的映射
-
- Returns:
- 统一格式的响应字典
- """
- if success:
- return create_client_response(200, message, data)
-
- # 默认错误映射
- if error_mapping is None:
- error_mapping = {
- "密码错误": 401,
- "用户名": 401,
- "不存在": 404,
- "禁用": 403,
- "到期": 403,
- "已使用": 403,
- "已过期": 403
- }
-
- # 根据消息内容确定错误码
- status_code = 400 # 默认
- for key, code in error_mapping.items():
- if key in message:
- status_code = code
- break
-
- # 返回错误响应(使用统一的错误响应格式)
- return create_client_response(status_code, message, None)
- # ============ 认证接口 ============
- def _raw_machine_info_to_machine_code(decrypted_params: dict) -> str:
- """从解密参数中取 raw_machine_info,规范化后由后端统一计算 MD5 作为 machine_code(不信任客户端传入的 machine_code)"""
- raw = (decrypted_params.get("raw_machine_info") or decrypted_params.get("machine_code") or "").strip()
- return _raw_to_machine_code(raw)
- @router.post("/auth/login")
- @secure_endpoint(require_login=False, require_machine_code=False)
- async def login(decrypted_params: dict, request: Request, db: Session, session=None, user=None):
- """
- 用户登录(安全端点:请求体为加密 params,响应为加密 data)
- - 解密后取 username、password、raw_machine_info
- - 后端对 raw_machine_info 做 MD5 得到 machine_code,不信任客户端传入的 machine_code
- 统一错误提示:401 用户名或密码错误;403 卡密已过期/机器码不匹配/账号被禁用
- """
- try:
- username = (decrypted_params.get("username") or "").strip()
- password = decrypted_params.get("password") or ""
- machine_code = _raw_machine_info_to_machine_code(decrypted_params)
- if not username or not password:
- return handle_service_result(False, "请求参数错误,请检查后重试", None, {"参数": 400})
- client_version = (decrypted_params.get("client_version") or "").strip()
- platform = (decrypted_params.get("platform") or "windows").strip()
- channel = (decrypted_params.get("channel") or "stable").strip()
- if client_version:
- version_service = ClientVersionService(db)
- should_block, result = version_service.should_block_client(
- client_version=client_version,
- platform=platform,
- channel=channel,
- )
- if should_block:
- return create_client_response(403, "客户端版本过低,请更新到最新版本后继续使用", {
- "version_check": result
- })
- software_code = (decrypted_params.get("software_code") or "").strip()
- auth_service = AuthService(db)
- success, message, data = auth_service.login(
- username=username,
- password=password,
- machine_code=machine_code,
- software_code=software_code or None
- )
- if success:
- logger.info(f"登录成功: {username}")
- return create_client_response(200, message, data)
- error_mapping = {"密码错误": 401, "用户名": 401, "禁用": 403, "到期": 403}
- return handle_service_result(success, message, None, error_mapping)
- except Exception as e:
- logger.exception(f"登录接口异常: {e}")
- return create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
- @router.post("/auth/register")
- @secure_endpoint(require_login=False, require_machine_code=False)
- async def register(decrypted_params: dict, request: Request, db: Session, session=None, user=None):
- """
- 用户注册(安全端点:请求体为加密 params)
- 统一错误提示:400 用户名已存在
- """
- try:
- username = (decrypted_params.get("username") or "").strip()
- password = decrypted_params.get("password") or ""
- email = decrypted_params.get("email") or ""
- card_code = decrypted_params.get("card_code") or ""
- software_code = (decrypted_params.get("software_code") or "").strip()
- if not username or not password:
- return handle_service_result(False, "请求参数错误,请检查后重试", None, {"参数": 400})
- auth_service = AuthService(db)
- success, message, data = auth_service.register(
- username=username,
- password=password,
- email=email or None,
- card_code=card_code or None,
- software_code=software_code or None
- )
- if success:
- return create_client_response(200, message, data)
- return handle_service_result(success, message, None, {"已存在": 400})
- except Exception as e:
- logger.exception(f"注册接口异常: {e}")
- return create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
- @router.post("/auth/logout")
- async def logout(request: Request, db: Session = Depends(get_db)):
- """
- 用户登出
- - 支持两种加密格式:① encrypted_params + aes_key(与 secure_endpoint 一致)② 单 blob:encrypted + key_version
- - 也支持明文:{"session_id": "..."}(兼容)
- - 登出成功后数据库 sessions 表对应记录的 is_active 置为 0
- """
- start_time = time.time()
- session_id = ""
- try:
- body = await request.json()
- if not isinstance(body, dict):
- logger.warning("[logout] 请求体非 dict")
- return {
- **create_client_response(400, "请求参数错误,请检查后重试", None),
- "_execution_time": int((time.time() - start_time) * 1000),
- }
- body_keys = list(body.keys()) if body else []
- logger.info(f"[logout] 收到登出请求 body_keys={body_keys}")
- # 格式1:encrypted_params + aes_key(与登录/心跳一致,部分客户端走此格式)
- encrypted_params = (body.get("encrypted_params") or "").strip()
- aes_key_b64 = (body.get("aes_key") or "").strip()
- if encrypted_params and aes_key_b64:
- try:
- key_version = int(body.get("key_version") or 1)
- decrypted = decrypt_request_data(
- encrypted_params, key_version, db, encrypted_aes_key=aes_key_b64
- )
- session_id = (decrypted.get("session_id") or "").strip()
- logger.info(f"[logout] 解密(encrypted_params)得到 session_id 长度={len(session_id)}")
- except Exception as e:
- logger.warning(f"[logout] encrypted_params 解密失败: {e}")
- return {
- **create_client_response(400, "请求参数错误,请检查后重试", None),
- "_execution_time": int((time.time() - start_time) * 1000),
- }
- # 格式2:单 blob encrypted + key_version(api_client.make_request)
- elif body.get("encrypted") and body.get("key_version") is not None:
- try:
- key_version = int(body.get("key_version", 1))
- decrypted = encryptor.decrypt_from_client(
- (body.get("encrypted") or "").strip(), key_version, db
- )
- session_id = (decrypted.get("session_id") or "").strip()
- logger.info(f"[logout] 解密(encrypted blob)得到 session_id 长度={len(session_id)}")
- except Exception as e:
- logger.warning(f"[logout] encrypted blob 解密失败: {e}")
- return {
- **create_client_response(400, "请求参数错误,请检查后重试", None),
- "_execution_time": int((time.time() - start_time) * 1000),
- }
- else:
- # 明文 session_id(兼容)
- session_id = (body.get("session_id") or "").strip()
- if session_id:
- logger.info(f"[logout] 明文 session_id 长度={len(session_id)}")
- if not session_id:
- logger.warning("[logout] 未解析到 session_id")
- return {
- **create_client_response(400, "请求参数错误,请检查后重试", None),
- "_execution_time": int((time.time() - start_time) * 1000),
- }
- auth_service = AuthService(db)
- success, message = auth_service.logout(session_id=session_id)
- logger.info(f"[logout] 登出结果 success={success} session_id_len={len(session_id)}")
- execution_time = int((time.time() - start_time) * 1000)
- return {
- **create_client_response(200 if success else 400, message),
- "_execution_time": execution_time,
- }
- except HTTPException:
- raise
- except Exception as e:
- logger.exception(f"登出接口异常: {e}")
- raise HTTPException(
- status_code=500,
- detail="系统异常,请稍后重试;若持续发生请联系客服",
- )
- @router.post("/auth/forgot-password")
- async def forgot_password(request_data: ForgotPasswordRequest, db: Session = Depends(get_db)):
- """
- 找回密码:提交用户名和邮箱,匹配则写入工单表,由后台脚本扫描并发送新密码邮件。
- 为防泄露,无论是否匹配均返回相同提示。
- """
- try:
- logger.info(f"[Python forgot-password] Step 1 - 收到请求: username='{request_data.username}', email='{request_data.email}'")
- auth_service = AuthService(db)
- success, message = auth_service.forgot_password(
- username=request_data.username.strip(),
- email=request_data.email.strip()
- )
- logger.info(f"[Python forgot-password] Step 2 - 处理结果: success={success}, message={message}")
- return create_client_response(200 if success else 500, message, None)
- except Exception as e:
- logger.exception(f"[Python forgot-password] Step 3 - 发生异常: {e}")
- return create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
- @router.post("/auth/recharge")
- @secure_endpoint(require_login=False, require_machine_code=False)
- async def recharge(decrypted_params: dict, request: Request, db: Session, session=None, user=None):
- """
- 卡密充值(安全端点:请求体为加密 params)
- 统一错误提示:404 卡密不存在;403 卡密已使用/已过期
- """
- try:
- username = (decrypted_params.get("username") or "").strip()
- password = decrypted_params.get("password") or ""
- card_code = (decrypted_params.get("card_code") or "").strip()
- software_code = (decrypted_params.get("software_code") or "").strip()
- if not username or not password or not card_code:
- return handle_service_result(False, "请求参数错误,请检查后重试", None, {"参数": 400})
- recharge_service = RechargeService(db)
- success, message, data = recharge_service.recharge(
- username=username,
- password=password,
- card_code=card_code,
- software_code=software_code or None
- )
- if success:
- return create_client_response(200, message, data)
- return handle_service_result(success, message, None, {"不存在": 404, "已使用": 403, "已过期": 403})
- except Exception as e:
- logger.exception(f"充值接口异常: {e}")
- return create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
- @router.post("/auth/unbind")
- @secure_endpoint(require_login=False, require_machine_code=False)
- async def unbind(decrypted_params: dict, request: Request, db: Session, session=None, user=None):
- """
- 机器解绑(安全端点:请求体为加密 params)
- 统一错误提示:404 用户不存在;401 密码错误
- """
- try:
- username = (decrypted_params.get("username") or "").strip()
- password = decrypted_params.get("password") or ""
- if not username or not password:
- return handle_service_result(False, "请求参数错误,请检查后重试", None, {"参数": 400})
- machine_code = _raw_machine_info_to_machine_code(decrypted_params)
- auth_service = AuthService(db)
- success, message = auth_service.unbind(
- username=username,
- password=password,
- machine_code=machine_code
- )
- if success:
- return create_client_response(200, message)
- return handle_service_result(success, message, None, {"不存在": 404, "密码错误": 401})
- except Exception as e:
- logger.exception(f"解绑接口异常: {e}")
- return create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
- @router.post("/auth/change-password")
- @secure_endpoint(require_login=False, require_machine_code=False)
- async def change_password(decrypted_params: dict, request: Request, db: Session, session=None, user=None):
- """
- 修改密码(安全端点:请求体为加密 params,含 username、old_password、new_password)
- 统一错误提示:用户不存在、当前密码错误、新密码格式不符合要求
- """
- try:
- username = (decrypted_params.get("username") or "").strip()
- old_password = (decrypted_params.get("old_password") or "").strip()
- new_password = (decrypted_params.get("new_password") or "").strip()
- if not username or not old_password or not new_password:
- return handle_service_result(False, "请求参数错误,请检查后重试", None, {"参数": 400})
- auth_service = AuthService(db)
- success, message = auth_service.change_password(
- username=username,
- old_password=old_password,
- new_password=new_password
- )
- if success:
- return create_client_response(200, message)
- return handle_service_result(
- success, message, None,
- {"用户不存在": 404, "当前密码错误": 401, "新密码": 400}
- )
- except Exception as e:
- logger.exception(f"修改密码接口异常: {e}")
- return create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
- # ============ 心跳接口 ============
- @router.post("/heartbeat")
- @secure_endpoint(require_login=True, require_machine_code=True)
- async def heartbeat(decrypted_params: dict, request: Request, db: Session, session=None, user=None):
- """
- 心跳检测(安全端点:请求体为加密 params,含 session_id、machine_code/raw_machine_info)
- - 与登录一致:对 raw_machine_info 或 machine_code 做 MD5 后与 session 绑定值比较
- - AES+RSA 加密请求与响应
- - 需要登录验证
- - 返回系统配置(带签名响应,防止中间人攻击)
- 统一错误提示:
- - 401: 会话已过期
- - 403: 卡密已到期
- """
- try:
- session_id = (decrypted_params.get("session_id") or "").strip()
- # 与登录接口一致:后端统一用 MD5 计算 machine_code,再与 session.machine_code 比较
- machine_code = _raw_machine_info_to_machine_code(decrypted_params)
- software_code = (decrypted_params.get("software_code") or "").strip()
- client_version = (decrypted_params.get("client_version") or "").strip()
- platform = (decrypted_params.get("platform") or "").strip()
- channel = (decrypted_params.get("channel") or "").strip()
- if not session_id or not machine_code:
- return {"code": 400, "msg": "请求参数错误,请检查后重试"}
- heartbeat_service = HeartbeatService(db)
- success, message, data = heartbeat_service.heartbeat(
- session_id=session_id,
- machine_code=machine_code,
- software_code=software_code or None,
- client_version=client_version,
- platform=platform,
- channel=channel,
- )
- if success:
- logger.debug(f"心跳检测成功: session_id={session_id}, renewed={data.get('session_info', {}).get('renewed', False)}")
- return {"code": 200, "msg": message, **data}
- else:
- error_code = 400
- if "过期" in message or "登录" in message:
- error_code = 401
- elif "到期" in message or "禁用" in message or "版本" in message:
- error_code = 403
- return {"code": error_code, "msg": message}
- except Exception as e:
- logger.exception(f"心跳接口异常: {e}")
- return {"code": 500, "msg": "系统异常,请稍后重试;若持续发生请联系客服"}
- @router.post("/client/version/check")
- @secure_endpoint(require_login=False, require_machine_code=False)
- async def post_client_version_check(decrypted_params: dict, request: Request, db: Session, session=None, user=None):
- """
- 客户端版本检查(安全端点)
- - 登录前/登录后均可调用
- - 返回最新版本、强更策略、下载地址、富文本更新日志
- """
- try:
- client_version = (decrypted_params.get("client_version") or "").strip()
- platform = (decrypted_params.get("platform") or "windows").strip()
- channel = (decrypted_params.get("channel") or "stable").strip()
- if not client_version:
- return create_client_response(400, "请求参数错误,请检查后重试", None)
- version_service = ClientVersionService(db)
- result = version_service.build_check_result(
- client_version=client_version,
- platform=platform,
- channel=channel,
- )
- return create_client_response(200, "成功", result)
- except Exception as e:
- logger.exception(f"客户端版本检查接口异常: {e}")
- return create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
- # ============ 公告接口 ============
- @router.get("/announcements")
- async def get_announcements(
- page: int = Query(1, ge=1, description="页码"),
- page_size: int = Query(10, ge=1, le=100, description="每页数量"),
- db: Session = Depends(get_db)
- ):
- """
- 获取公告列表
- - AES+RSA加密
- - 无需登录
- """
- start_time = time.time()
-
- try:
- announcement_service = AnnouncementService(db)
- success, message, data = announcement_service.get_announcements(
- page=page,
- page_size=page_size
- )
-
- execution_time = int((time.time() - start_time) * 1000)
-
- return {
- **create_client_response(200 if success else 400, message, data),
- "_execution_time": execution_time
- }
-
- except Exception as e:
- logger.exception(f"获取公告接口异常: {e}")
- raise HTTPException(
- status_code=500,
- detail="系统异常,请稍后重试;若持续发生请联系客服"
- )
- @router.post("/announcements")
- @secure_endpoint(require_login=False, require_machine_code=False)
- async def post_announcements(decrypted_params: dict, request: Request, db: Session, session=None, user=None):
- """
- 获取公告列表(安全端点:请求体为加密 params,含 page、page_size、keyword)
- """
- try:
- page = int(decrypted_params.get("page") or 1)
- page_size = int(decrypted_params.get("page_size") or 10)
- if page < 1:
- page = 1
- if page_size < 1 or page_size > 100:
- page_size = 10
- announcement_service = AnnouncementService(db)
- success, message, data = announcement_service.get_announcements(
- page=page,
- page_size=page_size
- )
- if success:
- return create_client_response(200, message, data)
- return create_client_response(400, message, data)
- except Exception as e:
- logger.exception(f"POST 公告接口异常: {e}")
- return create_client_response(500, "系统异常,请稍后重试", None)
- # ============ 用户信息接口 ============
- @router.post("/user/profile")
- @secure_endpoint(require_login=True, require_machine_code=True)
- async def get_profile(decrypted_params: dict, request: Request, db: Session, session=None, user=None):
- """
- 获取用户信息(安全端点:请求体为加密 params,含 session_id、machine_code)
- - AES+RSA 加密请求与响应
- - 需要登录验证
- - 返回带签名的安全响应
-
- 统一错误提示:
- - 401: 会话已过期
- - 403: 账号在其他设备登录
- """
- try:
- if not user:
- return {"code": 404, "msg": "用户不存在"}
- from app.services.heartbeat_service import (
- _get_subscription_expire,
- _get_subscription_special_close,
- DEFAULT_SOFTWARE_CODE,
- )
- software_code = (decrypted_params.get("software_code") or "").strip() or DEFAULT_SOFTWARE_CODE
- expire_time = _get_subscription_expire(db, user.id, software_code)
- days_remaining = 0
- if expire_time:
- days_remaining = (expire_time - beijing_now_naive()).days
- special_close = _get_subscription_special_close(db, user.id, software_code)
- user_data = {
- "user_id": user.id,
- "username": user.username,
- "email": user.email,
- "expire_time": expire_time.isoformat() if expire_time else None,
- "days_remaining": days_remaining,
- "special_close": special_close,
- "status": user.status
- }
- logger.debug(f"获取用户信息成功: user_id={user.id}")
- return {"code": 200, "msg": "成功", **user_data}
- except Exception as e:
- logger.exception(f"获取用户信息异常: {e}")
- return {"code": 500, "msg": "系统异常,请稍后重试;若持续发生请联系客服"}
- # ============ 密钥管理接口 ============
- def _post_keys_public_encrypt_response(
- public_key_pem: str,
- key_version: int,
- private_key_pem: str,
- client_aes_key_b64: str,
- timestamp: int,
- nonce: str,
- ) -> Dict[str, Any]:
- """
- 构造 POST /keys/public 的加密响应(与客户端约定一致):
- data.encrypted_key = AES-CBC( response_key+response_iv, client_aes_key, iv=client_aes_key[16:32] )
- data.encrypted_data = AES-CBC( json(public_key, key_version, signing_public_key), response_key, response_iv )
- data.signature = RSA-PSS( timestamp:nonce:encrypted_data_b64 )
- """
- import base64
- import json
- import secrets
- from cryptography.hazmat.primitives import serialization, hashes
- from cryptography.hazmat.primitives.asymmetric import padding as rsa_padding
- from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
- from cryptography.hazmat.primitives.padding import PKCS7
- from cryptography.hazmat.backends import default_backend
- client_aes_key_b64 = (client_aes_key_b64 or "").strip()
- client_aes = base64.b64decode(client_aes_key_b64)
- if len(client_aes) < 32:
- raise ValueError("aes_key 长度不足")
- key_c = client_aes[:32]
- iv_c = client_aes[16:32]
- response_key = secrets.token_bytes(32)
- response_iv = secrets.token_bytes(16)
- payload = json.dumps({
- "public_key": public_key_pem,
- "key_version": key_version,
- "signing_public_key": public_key_pem,
- }).encode()
- padder = PKCS7(128).padder()
- padded = padder.update(payload) + padder.finalize()
- cipher_r = Cipher(algorithms.AES(response_key), modes.CBC(response_iv), backend=default_backend())
- enc_r = cipher_r.encryptor()
- encrypted_data_bytes = enc_r.update(padded) + enc_r.finalize()
- encrypted_data_b64 = base64.b64encode(encrypted_data_bytes).decode()
- key_iv = response_key + response_iv # 48 字节
- # CBC 需 PKCS7 填充,否则客户端 OpenSSL 解密时会按填充校验导致 bad decrypt
- padder = PKCS7(128).padder()
- key_iv_padded = padder.update(key_iv) + padder.finalize()
- cipher_c = Cipher(algorithms.AES(key_c), modes.CBC(iv_c), backend=default_backend())
- enc_c = cipher_c.encryptor()
- encrypted_key_bytes = enc_c.update(key_iv_padded) + enc_c.finalize()
- encrypted_key_b64 = base64.b64encode(encrypted_key_bytes).decode()
- logger.info("[keys/public 加密响应] key_iv_len=48 padded_len=%s encrypted_key_len=%s", len(key_iv_padded), len(encrypted_key_bytes))
- sign_string = f"{timestamp}:{nonce}:{encrypted_data_b64}"
- private_key = serialization.load_pem_private_key(
- private_key_pem.encode(), password=None, backend=default_backend()
- )
- signature = private_key.sign(
- sign_string.encode(),
- rsa_padding.PSS(mgf=rsa_padding.MGF1(hashes.SHA256()), salt_length=rsa_padding.PSS.MAX_LENGTH),
- hashes.SHA256(),
- )
- signature_b64 = base64.b64encode(signature).decode()
- return {
- "encrypted_key": encrypted_key_b64,
- "encrypted_data": encrypted_data_b64,
- "signature": signature_b64,
- "signing_public_key": public_key_pem,
- "timestamp": timestamp,
- "nonce": nonce,
- }
- @router.post("/keys/public")
- async def post_public_key(request: Request, db: Session = Depends(get_db)):
- """
- POST 获取RSA公钥(与客户端握手约定一致)
- 请求体: encrypted_params (AES加密的机器特征), aes_key (base64), timestamp, nonce
- 响应 data: encrypted_key, encrypted_data, signature, signing_public_key, timestamp, nonce
- """
- import base64
- from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
- from cryptography.hazmat.backends import default_backend
- try:
- body = await request.json()
- encrypted_params_b64 = (body.get("encrypted_params") or "").strip()
- aes_key_b64 = (body.get("aes_key") or "").strip()
- ts = body.get("timestamp", 0)
- nonce = (body.get("nonce") or "").strip()
- logger.info(
- "[POST /keys/public] encrypted_params_b64_len=%s aes_key_b64_len=%s ts=%s",
- len(encrypted_params_b64), len(aes_key_b64), ts,
- )
- if not aes_key_b64 or not encrypted_params_b64:
- return create_client_response(400, "请求参数错误", None)
- aes_bytes = base64.b64decode(aes_key_b64)
- logger.info("[POST /keys/public] aes_bytes decoded len=%s (需>=32)", len(aes_bytes))
- if len(aes_bytes) < 32:
- return create_client_response(400, "aes_key 无效", None)
- key_a = aes_bytes[:32]
- iv_a = aes_bytes[16:32]
- try:
- enc_bytes = base64.b64decode(encrypted_params_b64)
- except Exception:
- return create_client_response(400, "encrypted_params 无效", None)
- cipher = Cipher(algorithms.AES(key_a), modes.CBC(iv_a), backend=default_backend())
- dec = cipher.decryptor()
- decrypted_padded = dec.update(enc_bytes) + dec.finalize()
- try:
- from cryptography.hazmat.primitives.padding import PKCS7
- unpadder = PKCS7(128).unpadder()
- decrypted_bytes = unpadder.update(decrypted_padded) + unpadder.finalize()
- except Exception:
- decrypted_bytes = decrypted_padded
- try:
- raw_machine_info = decrypted_bytes.decode("utf-8", errors="replace")
- except Exception:
- raw_machine_info = ""
- logger.debug("POST /keys/public raw_machine_info length=%s", len(raw_machine_info))
- key_service = KeyService(db)
- success, message, data = key_service.get_active_key_pair()
- if not success or not data:
- return create_client_response(500, message or "系统初始化中,请稍后再试", None)
- public_key_pem = data["public_key"]
- private_key_pem = data["private_key"]
- key_version = data["key_version"]
- response_data = _post_keys_public_encrypt_response(
- public_key_pem, key_version, private_key_pem, aes_key_b64, ts, nonce
- )
- return create_client_response(200, "获取成功", response_data)
- except Exception as e:
- logger.exception("POST /keys/public 异常: %s", e)
- return create_client_response(500, "系统异常,请稍后重试", None)
- @router.get("/keys/public")
- async def get_public_key(db: Session = Depends(get_db)):
- """
- 获取RSA公钥
- - 无需登录
- - 返回当前活跃版本的RSA公钥
-
- 统一错误提示:
- - 500: 系统初始化中
- """
- start_time = time.time()
-
- try:
- key_service = KeyService(db)
- success, message, data = key_service.get_active_public_key()
-
- execution_time = int((time.time() - start_time) * 1000)
-
- if success:
- return {
- **create_client_response(200, message, data),
- "_execution_time": execution_time
- }
- else:
- # 公钥不存在时,返回友好的系统初始化提示
- return {
- **create_client_response(500, "系统初始化中,请稍后再试"),
- "_execution_time": execution_time
- }
-
- except Exception as e:
- logger.exception(f"获取公钥接口异常: {e}")
- raise HTTPException(
- status_code=500,
- detail="系统异常,请稍后重试;若持续发生请联系客服"
- )
- @router.post("/keys/sync")
- @secure_endpoint(require_login=True, require_machine_code=True)
- async def sync_public_key(decrypted_params: dict, request: Request, db: Session, session=None, user=None):
- """
- 同步RSA公钥(已登录状态,符合技术方案 4.3.2)
- - 请求体为加密 params,含 session_id、machine_code
- - 返回当前激活的 public_key 与 key_version(可能比客户端更新)
- """
- try:
- key_service = KeyService(db)
- success, message, data = key_service.get_active_public_key()
- if not success or not data:
- return {"code": 500, "msg": message or "系统初始化中,请稍后再试"}
- return {
- "code": 200,
- "msg": "成功",
- "public_key": data.get("public_key"),
- "key_version": data.get("key_version"),
- }
- except Exception as e:
- logger.exception(f"同步公钥异常: {e}")
- return {"code": 500, "msg": "系统异常,请稍后重试"}
- @router.get("/keys/version")
- async def get_key_version(db: Session = Depends(get_db)):
- """
- 获取当前RSA密钥版本
- - 无需登录
- """
- start_time = time.time()
-
- try:
- key_service = KeyService(db)
- success, message, data = key_service.get_current_key_version()
-
- execution_time = int((time.time() - start_time) * 1000)
-
- if success:
- return {
- **create_client_response(200, message, data),
- "_execution_time": execution_time
- }
- else:
- return {
- **create_client_response(500, message),
- "_execution_time": execution_time
- }
-
- except Exception as e:
- logger.exception(f"获取密钥版本接口异常: {e}")
- raise HTTPException(
- status_code=500,
- detail="系统异常,请稍后重试;若持续发生请联系客服"
- )
- # ============ 配置管理接口 ============
- @router.get("/config/client")
- async def get_client_config(db: Session = Depends(get_db)):
- """
- 获取客户端配置
- - 无需登录
- - 返回客户端相关的系统配置
- """
- start_time = time.time()
-
- try:
- key_service = KeyService(db)
- success, message, data = key_service.get_client_configs()
-
- execution_time = int((time.time() - start_time) * 1000)
-
- if success:
- return {
- **create_client_response(200, message, data),
- "_execution_time": execution_time
- }
- else:
- return {
- **create_client_response(500, message),
- "_execution_time": execution_time
- }
-
- except Exception as e:
- logger.exception(f"获取客户端配置接口异常: {e}")
- raise HTTPException(
- status_code=500,
- detail="系统异常,请稍后重试;若持续发生请联系客服"
- )
- # ============ HMAC密钥管理接口 ============
- class HmacKeyRequest(BaseModel):
- """HMAC密钥请求"""
- username: str = Field(..., description="用户名")
- machine_code: str = Field(..., description="机器码")
- @router.post("/keys/hmac")
- async def sync_hmac_key(request: HmacKeyRequest, db: Session = Depends(get_db)):
- """
- 同步/获取动态HMAC密钥
- - 无需登录(但需要验证用户名和机器码)
- - 用于客户端获取或刷新HMAC签名密钥
- - 密钥有效期24小时,支持自动轮换
-
- 统一错误提示:
- - 401: 用户名不存在/密码错误
- - 403: 账号被禁用/机器码不匹配
- - 500: 系统异常
- """
- start_time = time.time()
-
- try:
- # 导入HMAC密钥服务
- from app.services.hmac_key_service import HmacKeyService
-
- hmac_service = HmacKeyService(db)
-
- # 先验证用户
- is_valid, msg, user_id = hmac_service.validate_user_for_hmac(
- request.username,
- request.machine_code
- )
-
- if not is_valid:
- # 根据错误类型返回不同的状态码
- error_mapping = {
- "不存在": 401,
- "禁用": 403,
- "不匹配": 403,
- }
-
- status_code = 400
- for key, code in error_mapping.items():
- if key in msg:
- status_code = code
- break
-
- return {
- **create_client_response(status_code, msg, None),
- "_execution_time": int((time.time() - start_time) * 1000)
- }
-
- # 创建HMAC密钥
- success, message, data = hmac_service.create_hmac_key(
- user_id=user_id,
- username=request.username,
- machine_code=request.machine_code
- )
-
- execution_time = int((time.time() - start_time) * 1000)
-
- if success:
- logger.info(f"HMAC密钥同步成功: {request.username}")
- return {
- **create_client_response(200, message, data),
- "_execution_time": execution_time
- }
- else:
- # 根据错误类型返回不同的状态码
- error_mapping = {
- "不存在": 401,
- "禁用": 403,
- "不匹配": 403,
- "已过期": 403
- }
-
- status_code = 400
- for key, code in error_mapping.items():
- if key in message:
- status_code = code
- break
-
- return {
- **create_client_response(status_code, message, None),
- "_execution_time": execution_time
- }
-
- except Exception as e:
- logger.exception(f"HMAC密钥同步接口异常: {e}")
- raise HTTPException(
- status_code=500,
- detail="系统异常,请稍后重试;若持续发生请联系客服"
- )
- @router.get("/keys/hmac/status")
- async def get_hmac_key_status(
- username: str = Query(..., description="用户名"),
- machine_code: str = Query(..., description="机器码"),
- db: Session = Depends(get_db)
- ):
- """
- 检查HMAC密钥状态
- - 无需登录
- - 返回当前密钥是否有效及过期时间
- """
- start_time = time.time()
-
- try:
- from app.services.hmac_key_service import HmacKeyService
-
- hmac_service = HmacKeyService(db)
-
- # 先验证用户
- is_valid, msg, user_id = hmac_service.validate_user_for_hmac(username, machine_code)
-
- if not is_valid:
- return create_client_response(401, msg, None)
-
- # 获取当前密钥状态
- success, message, data = hmac_service.get_hmac_key(user_id, machine_code)
-
- execution_time = int((time.time() - start_time) * 1000)
-
- if success:
- return {
- **create_client_response(200, message, data),
- "_execution_time": execution_time
- }
- else:
- return {
- **create_client_response(404, message),
- "_execution_time": execution_time
- }
-
- except Exception as e:
- logger.exception(f"获取HMAC密钥状态接口异常: {e}")
- raise HTTPException(
- status_code=500,
- detail="系统异常,请稍后重试;若持续发生请联系客服"
- )
- @router.delete("/keys/hmac")
- async def revoke_hmac_key(
- username: str = Query(..., description="用户名"),
- machine_code: str = Query(..., description="机器码"),
- db: Session = Depends(get_db)
- ):
- """
- 撤销HMAC密钥
- - 需要登录
- - 用于登出时清除HMAC密钥
- """
- start_time = time.time()
-
- try:
- from app.services.hmac_key_service import HmacKeyService
- from app.services.auth_service import AuthService
-
- # 验证用户
- auth_service = AuthService(db)
- user_id, msg, _ = auth_service.validate_session_or_token(username)
-
- if user_id is None:
- return create_client_response(401, msg or "会话已过期", None)
-
- # 撤销HMAC密钥
- hmac_service = HmacKeyService(db)
- success, message = hmac_service.revoke_hmac_key(user_id, machine_code)
-
- execution_time = int((time.time() - start_time) * 1000)
-
- return {
- **create_client_response(200 if success else 500, message, None),
- "_execution_time": execution_time
- }
-
- except Exception as e:
- logger.exception(f"撤销HMAC密钥接口异常: {e}")
- raise HTTPException(
- status_code=500,
- detail="系统异常,请稍后重试;若持续发生请联系客服"
- )
- # ============ Steam账号管理接口 ============
- class CreateSteamAccountRequest(BaseModel):
- """创建/绑定 Steam 账号请求(敏感信息存客户端本地,仅传账号标识)"""
- user_id: int = Field(..., description="用户ID")
- steam_username: str = Field(..., description="Steam用户名")
- steam_id: Optional[str] = Field(None, description="Steam ID(来自客户端登录验证)")
- class UpdateSteamAccountRequest(BaseModel):
- """更新Steam账号请求"""
- session_id: str = Field(..., description="会话ID")
- machine_code: str = Field(..., description="机器码(raw 或 md5)")
- account_id: int = Field(..., description="账号ID")
- is_active: Optional[bool] = Field(None, description="是否启用")
- class DeleteSteamAccountRequest(BaseModel):
- """删除Steam账号请求"""
- user_id: int = Field(..., description="用户ID")
- account_id: int = Field(..., description="账号ID")
- class BindSteamAccountRequest(BaseModel):
- """验证绑定Steam账号请求"""
- user_id: int = Field(..., description="用户ID")
- steam_username: str = Field(..., description="Steam用户名")
- steam_id: str = Field(..., description="Steam ID(来自客户端登录验证)")
- class SteamAccountLimitRequest(BaseModel):
- """获取Steam账号绑定限制请求"""
- user_id: int = Field(..., description="用户ID")
- @router.post("/steam/account-limit")
- async def get_steam_account_limit(request: Request, db: Session = Depends(get_db)):
- """
- 获取用户Steam账号绑定限制(最大可绑定数、当前已验证绑定数)
- - 请求体为加密格式:encrypted + key_version(与 client-tauri make_request 一致)
- - 响应为明文 JSON(code, msg, data),便于 client-tauri 直接解析并展示最大可绑定数
- """
- try:
- body = await request.json()
- if not isinstance(body, dict) or not body.get("encrypted") or body.get("key_version") is None:
- return JSONResponse(
- status_code=400,
- content=create_client_response(400, "请求参数错误,请检查后重试", None)
- )
- key_version = int(body.get("key_version", 1))
- decrypted = encryptor.decrypt_from_client(
- (body.get("encrypted") or "").strip(), key_version, db
- )
- user_id = decrypted.get("user_id")
- if user_id is None:
- return JSONResponse(
- status_code=400,
- content=create_client_response(400, "请求参数错误,请检查后重试", None)
- )
- user_id = int(user_id)
- steam_service = SteamAccountService(db)
- success, message, data = steam_service.get_steam_account_limit(user_id)
- code = 200 if success else 400
- payload = create_client_response(code, message, data if data else {})
- return JSONResponse(status_code=200, content=payload)
- except Exception as e:
- logger.exception(f"获取Steam账号限制接口异常: {e}")
- return JSONResponse(
- status_code=200,
- content=create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
- )
- @router.post("/steam/accounts")
- async def create_steam_account(request: Request, db: Session = Depends(get_db)):
- """
- 创建/绑定Steam账号(与 client-tauri make_request 一致:请求体为 encrypted + key_version)
- - 解密后需包含 user_id, steam_username, steam_id(可选)
- """
- try:
- body = await request.json()
- if not isinstance(body, dict) or not body.get("encrypted") or body.get("key_version") is None:
- return create_encrypted_response(None, db, 400, "请求参数错误,请检查后重试")
- key_version = int(body.get("key_version", 1))
- decrypted = encryptor.decrypt_from_client(
- (body.get("encrypted") or "").strip(), key_version, db
- )
- user_id = decrypted.get("user_id")
- steam_username = (decrypted.get("steam_username") or "").strip()
- if user_id is None or not steam_username:
- return create_encrypted_response(None, db, 400, "请求参数错误,请检查后重试")
- user_id = int(user_id)
- steam_id = decrypted.get("steam_id")
- if steam_id is not None and isinstance(steam_id, str) and not steam_id.strip():
- steam_id = None
- steam_service = SteamAccountService(db)
- success, message, data = steam_service.create_steam_account(
- user_id=user_id,
- steam_username=steam_username,
- steam_id=steam_id,
- )
- if success:
- logger.info(f"Steam账号绑定成功: user_id={user_id}, steam_username={steam_username}")
- return create_encrypted_response(data, db, 200, message)
- error_code = 400
- if "已绑定" in message:
- error_code = 403
- if "最大绑定数量" in message:
- error_code = 403
- return create_encrypted_response(None, db, error_code, message)
- except Exception as e:
- logger.exception(f"创建Steam账号接口异常: {e}")
- return create_encrypted_response(None, db, 500, "系统异常,请稍后重试;若持续发生请联系客服")
- @router.get("/steam/accounts")
- async def get_steam_accounts(
- user_id: int = Query(..., description="用户ID"),
- db: Session = Depends(get_db)
- ):
- """
- 获取用户绑定的Steam账号列表(明文 Query,供管理端或调试)
- - 需登录验证
- """
- start_time = time.time()
- try:
- steam_service = SteamAccountService(db)
- success, message, data = steam_service.get_accounts_by_user(user_id)
- logger.debug(f"获取Steam账号列表: user_id={user_id}, count={len(data) if data else 0}")
- return create_encrypted_response(data if data else [], db, 200 if success else 400, message)
- except Exception as e:
- logger.exception(f"获取Steam账号列表接口异常: {e}")
- return create_encrypted_response(None, db, 500, "系统异常,请稍后重试;若持续发生请联系客服")
- @router.post("/steam/accounts/list")
- async def post_steam_accounts_list(request: Request, db: Session = Depends(get_db)):
- """
- 获取用户绑定的Steam账号列表(加密请求体;响应为明文 code/msg/data,便于 client-tauri 直接展示列表)
- """
- try:
- body = await request.json()
- if not isinstance(body, dict) or not body.get("encrypted") or body.get("key_version") is None:
- return JSONResponse(
- status_code=200,
- content=create_client_response(400, "请求参数错误,请检查后重试", None)
- )
- key_version = int(body.get("key_version", 1))
- decrypted = encryptor.decrypt_from_client(
- (body.get("encrypted") or "").strip(), key_version, db
- )
- user_id = decrypted.get("user_id")
- if user_id is None:
- return JSONResponse(
- status_code=200,
- content=create_client_response(400, "请求参数错误,请检查后重试", None)
- )
- user_id = int(user_id)
- steam_service = SteamAccountService(db)
- success, message, data = steam_service.get_accounts_by_user(user_id)
- logger.debug(f"获取Steam账号列表(list): user_id={user_id}, count={len(data) if data else 0}")
- code = 200 if success else 400
- payload = create_client_response(code, message, data if data else [])
- return JSONResponse(status_code=200, content=payload)
- except Exception as e:
- logger.exception(f"获取Steam账号列表(list)接口异常: {e}")
- return JSONResponse(
- status_code=200,
- content=create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
- )
- @router.post("/steam/accounts/bind")
- async def bind_steam_account(request: Request, db: Session = Depends(get_db)):
- """
- 对已新增的Steam账号进行验证绑定(写入steam_id)
- - 请求体:encrypted + key_version,解密后需包含 user_id, steam_username, steam_id
- - 需先新增账号再调用本接口,已验证绑定数不得超过配置上限
- """
- try:
- body = await request.json()
- if not isinstance(body, dict) or not body.get("encrypted") or body.get("key_version") is None:
- return JSONResponse(
- status_code=200,
- content=create_client_response(400, "请求参数错误,请检查后重试", None)
- )
- key_version = int(body.get("key_version", 1))
- decrypted = encryptor.decrypt_from_client(
- (body.get("encrypted") or "").strip(), key_version, db
- )
- user_id = decrypted.get("user_id")
- steam_username = (decrypted.get("steam_username") or "").strip()
- steam_id = (decrypted.get("steam_id") or "").strip()
- if user_id is None or not steam_username:
- return JSONResponse(
- status_code=200,
- content=create_client_response(400, "请求参数错误,请检查后重试", None)
- )
- user_id = int(user_id)
- if not steam_id:
- return JSONResponse(
- status_code=200,
- content=create_client_response(400, "steam_id 不能为空", None)
- )
- steam_service = SteamAccountService(db)
- success, message, data = steam_service.bind_steam_account(
- user_id=user_id,
- steam_username=steam_username,
- steam_id=steam_id,
- )
- if success:
- logger.info(f"Steam账号验证绑定成功: user_id={user_id}, steam_username={steam_username}, steam_id={steam_id}")
- return JSONResponse(status_code=200, content=create_client_response(200, message, data))
- error_code = 400
- if "已绑定" in message or "最大绑定数量" in message:
- error_code = 403
- if "请先新增" in message:
- error_code = 400
- return JSONResponse(status_code=200, content=create_client_response(error_code, message, None))
- except Exception as e:
- logger.exception(f"Steam账号验证绑定接口异常: {e}")
- return JSONResponse(
- status_code=200,
- content=create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
- )
- @router.put("/steam/accounts")
- async def update_steam_account(
- request: UpdateSteamAccountRequest,
- db: Session = Depends(get_db)
- ):
- """
- 更新Steam账号状态
- - AES+RSA加密
- - 需要登录验证
- """
- try:
- # 统一校验:会话有效 + 机器码一致 + 账号可用 + 订阅未过期
- auth_service = AuthService(db)
- session, err_code, err_msg = auth_service.verify_session_with_machine_and_expiry(
- request.session_id,
- request.machine_code
- )
- if not session:
- return create_encrypted_response(None, db, err_code or 401, err_msg or "会话已过期,请重新登录")
- steam_service = SteamAccountService(db)
- success, message = steam_service.update_account(
- account_id=request.account_id,
- user_id=session.user_id,
- is_active=request.is_active
- )
- if success:
- return create_encrypted_response(None, db, 200, message)
- error_code = 400
- if "不存在" in message:
- error_code = 404
- return create_encrypted_response(None, db, error_code, message)
- except Exception as e:
- logger.exception(f"更新Steam账号接口异常: {e}")
- return create_encrypted_response(None, db, 500, "系统异常,请稍后重试;若持续发生请联系客服")
- @router.delete("/steam/accounts")
- async def delete_steam_account(request: Request, db: Session = Depends(get_db)):
- """
- 删除Steam账号(仅未绑定可删)
- - 请求体:encrypted + key_version,解密后需包含 user_id、account_id
- """
- try:
- body = await request.json()
- if not isinstance(body, dict) or not body.get("encrypted") or body.get("key_version") is None:
- return JSONResponse(
- status_code=200,
- content=create_client_response(400, "请求参数错误,请检查后重试", None)
- )
- key_version = int(body.get("key_version", 1))
- decrypted = encryptor.decrypt_from_client(
- (body.get("encrypted") or "").strip(), key_version, db
- )
- user_id = decrypted.get("user_id")
- account_id = decrypted.get("account_id")
- if user_id is None or account_id is None:
- return JSONResponse(
- status_code=200,
- content=create_client_response(400, "请求参数错误,请检查后重试", None)
- )
- user_id = int(user_id)
- account_id = int(account_id)
- steam_service = SteamAccountService(db)
- success, message = steam_service.delete_account(
- account_id=account_id,
- user_id=user_id
- )
- if success:
- logger.info(f"Steam账号删除成功: account_id={account_id}")
- return JSONResponse(status_code=200, content=create_client_response(200, message, None))
- error_code = 400
- if "不存在" in message:
- error_code = 404
- if "已绑定" in message:
- error_code = 403
- return JSONResponse(status_code=200, content=create_client_response(error_code, message, None))
- except Exception as e:
- logger.exception(f"删除Steam账号接口异常: {e}")
- return JSONResponse(
- status_code=200,
- content=create_client_response(500, "系统异常,请稍后重试;若持续发生请联系客服", None)
- )
- # ============ BUFF 选品筛选项(从 buff_filter_option 表加载) ============
- def _build_filter_options_data(db: Session, game: str) -> Dict[str, Any]:
- """构建选品筛选项数据(供 GET 明文与 POST 安全端点共用)。"""
- query = db.query(BuffFilterOption).filter(
- BuffFilterOption.game == game,
- BuffFilterOption.is_enabled == True
- ).order_by(BuffFilterOption.filter_type, BuffFilterOption.parent_value, BuffFilterOption.sort_order, BuffFilterOption.id)
- rows = query.all()
- rifle, pistol, smg, shotgun, machinegun = [], [], [], [], []
- quality, category, exterior, collection = [], [], [], []
- itemset_roots, itemset_children = [], []
- for r in rows:
- opt = {"label": r.label, "value": r.value}
- if r.filter_type == "category" and r.parent_value == "rifle":
- rifle.append(opt)
- elif r.filter_type == "category" and r.parent_value == "pistol":
- pistol.append(opt)
- elif r.filter_type == "category" and r.parent_value == "smg":
- smg.append(opt)
- elif r.filter_type == "category" and r.parent_value == "shotgun":
- shotgun.append(opt)
- elif r.filter_type == "category" and r.parent_value == "machinegun":
- machinegun.append(opt)
- elif r.filter_type == "rarity":
- quality.append(opt)
- elif r.filter_type == "quality":
- category.append(opt)
- elif r.filter_type == "exterior":
- exterior.append(opt)
- elif r.filter_type == "itemset":
- if r.parent_value is None:
- itemset_roots.append(r)
- else:
- itemset_children.append(r)
- collection.append(opt)
- collection_tree = []
- for root in sorted(itemset_roots, key=lambda x: (x.sort_order, x.id)):
- children = [c for c in itemset_children if c.parent_value == root.value]
- children.sort(key=lambda x: (x.sort_order, x.id))
- collection_tree.append({
- "title": root.label,
- "value": root.value,
- "key": root.value,
- "children": [{"title": c.label, "value": c.value, "key": c.value} for c in children],
- })
- return {
- "rifle": rifle,
- "pistol": pistol,
- "smg": smg,
- "shotgun": shotgun,
- "machinegun": machinegun,
- "quality": quality,
- "category": category,
- "exterior": exterior,
- "collection": collection,
- "collectionTree": collection_tree,
- }
- @router.post("/buff/filter-options")
- @secure_endpoint(require_login=True, require_machine_code=True)
- async def post_buff_filter_options_secure(
- decrypted_params: dict, request: Request, db: Session, session=None, user=None
- ):
- """
- 获取 BUFF 选品筛选项(安全端点:POST,入参出参密文,时间戳与签名校验)。
- 解密参数:game(可选,默认 csgo)。会话与机器码由装饰器校验。
- 日志:入参密文/明文、出参密文/明文由 SecureEndpoint 与 APILoggingASGIMiddleware 统一记录。
- """
- try:
- game = (decrypted_params.get("game") or "csgo").strip() or "csgo"
- data = _build_filter_options_data(db, game)
- return {"code": 200, "msg": "获取成功", "data": data}
- except Exception as e:
- logger.exception(f"POST /buff/filter-options 异常: {e}")
- return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
- @router.get("/buff/filter-options")
- async def get_buff_filter_options(
- game: str = Query("csgo", description="游戏标识"),
- session_id: str = Query(..., description="会话ID"),
- machine_code: str = Query(..., description="机器码"),
- db: Session = Depends(get_db)
- ):
- """
- 获取 BUFF 选品检索栏下拉选项。需登录,校验会话+机器码+用户过期。
- """
- try:
- if not (session_id or "").strip() or not (machine_code or "").strip():
- return JSONResponse(status_code=200, content=create_client_response(401, "请先登录", None))
- auth_service = AuthService(db)
- session, err_code, err_msg = auth_service.verify_session_with_machine_and_expiry(
- session_id.strip(), (machine_code or "").strip()
- )
- if session is None:
- return JSONResponse(status_code=200, content=create_client_response(err_code or 401, err_msg or "会话已过期,请重新登录", None))
- data = _build_filter_options_data(db, game)
- return JSONResponse(status_code=200, content=create_client_response(200, "获取成功", data))
- except Exception as e:
- logger.exception(f"获取BUFF筛选项异常: {e}")
- return JSONResponse(
- status_code=200,
- content=create_client_response(500, "系统异常,请稍后重试", None)
- )
- @router.get("/buff/goods-attr-list")
- async def get_buff_goods_attr_list(
- goods_ids: str = Query(..., description="逗号分隔的 BUFF 饰品 goods_id 列表"),
- session_id: str = Query(..., description="会话ID"),
- machine_code: str = Query(..., description="机器码"),
- db: Session = Depends(get_db)
- ):
- """
- 根据 goods_id 列表查询 buff_goods_attr 表。需登录,校验会话+机器码+用户过期。
- """
- try:
- if not (session_id or "").strip() or not (machine_code or "").strip():
- return JSONResponse(status_code=200, content=create_client_response(401, "请先登录", None))
- auth_service = AuthService(db)
- session, err_code, err_msg = auth_service.verify_session_with_machine_and_expiry(
- session_id.strip(), (machine_code or "").strip()
- )
- if session is None:
- return JSONResponse(status_code=200, content=create_client_response(err_code or 401, err_msg or "会话已过期,请重新登录", None))
- id_list = [x.strip() for x in (goods_ids or "").split(",") if (x and x.strip())]
- if not id_list:
- return JSONResponse(status_code=200, content=create_client_response(200, "获取成功", []))
- # 限制单次查询数量,避免过长
- id_list = id_list[:500]
- rows = db.query(BuffGoodsAttr).filter(
- BuffGoodsAttr.goods_id.in_(id_list)
- ).all()
- data = [
- {"goods_id": r.goods_id, "name": r.name or "", "icon_url": r.icon_url or ""}
- for r in rows
- ]
- return JSONResponse(status_code=200, content=create_client_response(200, "获取成功", data))
- except Exception as e:
- logger.exception(f"获取BUFF商品属性列表异常: {e}")
- return JSONResponse(
- status_code=200,
- content=create_client_response(500, "系统异常,请稍后重试", None)
- )
- @router.post("/buff/goods-attr-list")
- @secure_endpoint(require_login=True, require_machine_code=True)
- async def post_buff_goods_attr_list_secure(
- decrypted_params: dict, request: Request, db: Session, session=None, user=None
- ):
- """
- 根据 goods_id 列表获取商品属性(安全端点:POST,入参出参密文,时间戳与签名校验)。
- 解密参数:goods_ids(逗号分隔)。日志由 SecureEndpoint 与 APILoggingASGIMiddleware 统一记录。
- """
- try:
- goods_ids_raw = (decrypted_params.get("goods_ids") or "").strip()
- id_list = [x.strip() for x in goods_ids_raw.split(",") if (x and x.strip())]
- if not id_list:
- return {"code": 200, "msg": "获取成功", "data": []}
- id_list = id_list[:500]
- # 从视图查询 steam_link,从 buff_goods_attr 表查询 icon_url 和 name
- from sqlalchemy import text
- sql = text("""
- SELECT
- gv.`BUFF饰品ID` AS goods_id,
- COALESCE(gv.`饰品STEAM链接`, '') AS steam_link,
- COALESCE(ga.name, '') AS name,
- COALESCE(ga.icon_url, '') AS icon_url
- FROM buff_goods_filtered_view gv
- LEFT JOIN buff_goods_attr ga ON gv.`BUFF饰品ID` = ga.goods_id
- WHERE gv.`BUFF饰品ID` IN :id_list
- """)
- result = db.execute(sql, {"id_list": tuple(id_list)}).fetchall()
- data = [{
- "goods_id": str(row[0]),
- "steam_link": row[1],
- "name": row[2],
- "icon_url": row[3],
- } for row in result]
- return {"code": 200, "msg": "获取成功", "data": data}
- except Exception as e:
- logger.exception(f"POST /buff/goods-attr-list 异常: {e}")
- return {"code": 500, "msg": "系统异常,请稍后重试", "data": []}
- # ============ BUFF 选品配置(保存/加载用户检索条件+选品条件) ============
- @router.post("/buff/selection-config")
- async def save_buff_selection_config(request: Request, db: Session = Depends(get_db)):
- """
- 保存 BUFF 选品配置(检索条件+选品条件)。
- 请求体 JSON:session_id(必填), machine_code(必填), name(可选), config_json(必填), is_default(可选)。
- 校验会话+机器码+用户过期后按 user_id + name 写入或更新,同用户至多一个 is_default=1。
- """
- try:
- body = await request.json()
- if not isinstance(body, dict):
- return JSONResponse(status_code=200, content=create_client_response(400, "请求参数错误,请检查后重试", None))
- session_id = (body.get("session_id") or "").strip()
- machine_code = (body.get("machine_code") or "").strip()
- if not session_id:
- return JSONResponse(status_code=200, content=create_client_response(401, "请先登录", None))
- if not machine_code:
- return JSONResponse(status_code=200, content=create_client_response(400, "请求参数错误:缺少 machine_code", None))
- auth_service = AuthService(db)
- session, err_code, err_msg = auth_service.verify_session_with_machine_and_expiry(session_id, machine_code)
- if session is None:
- return JSONResponse(status_code=200, content=create_client_response(err_code or 401, err_msg or "会话已过期,请重新登录", None))
- user_id = session.user_id
- name = (body.get("name") or "默认").strip() or "默认"
- config_json = body.get("config_json")
- if config_json is None:
- return JSONResponse(status_code=200, content=create_client_response(400, "缺少配置内容 config_json", None))
- if not isinstance(config_json, dict):
- try:
- config_json = json.loads(config_json) if isinstance(config_json, str) else None
- except Exception:
- config_json = None
- if not isinstance(config_json, dict):
- return JSONResponse(status_code=200, content=create_client_response(400, "config_json 格式错误", None))
- # 获取系统配置限制
- heartbeat_service = HeartbeatService(db)
- client_config = heartbeat_service.get_client_config()
- max_worker_count = client_config.get('buff_sweep_max_worker_count', 32)
- min_steam_card_ratio = client_config.get('buff_steam_card_ratio_min', 4)
- max_selection_quantity = client_config.get('buff_selection_max_quantity', 300)
- # 验证扫货线程数
- sweep_process_count = config_json.get('sweepProcessCount', 0)
- if sweep_process_count > max_worker_count:
- return JSONResponse(
- status_code=200,
- content=create_client_response(400, f"扫货线程数不能超过系统配置的最大值 {max_worker_count}", None)
- )
- # 验证Steam卡价比例
- steam_card_ratio = config_json.get('steamCardRatio', 0)
- if steam_card_ratio < min_steam_card_ratio:
- return JSONResponse(
- status_code=200,
- content=create_client_response(400, f"Steam卡价比例不能小于系统配置的最小值 {min_steam_card_ratio}", None)
- )
- # 验证选品数量
- selection_quantity = config_json.get('selectionQuantity')
- if selection_quantity is not None:
- try:
- selection_quantity = int(selection_quantity)
- if selection_quantity > max_selection_quantity:
- return JSONResponse(
- status_code=200,
- content=create_client_response(400, f"选品数量不能超过系统配置的最大值 {max_selection_quantity}", None)
- )
- except (ValueError, TypeError):
- pass
- is_default = body.get("is_default", True)
- if not isinstance(is_default, bool):
- is_default = is_default in (1, "1", True, "true")
- existing = db.query(BuffSelectionConfig).filter(
- BuffSelectionConfig.user_id == user_id,
- BuffSelectionConfig.name == name,
- BuffSelectionConfig.is_deleted == False
- ).first()
- config_str = json.dumps(config_json, ensure_ascii=False)
- if existing:
- existing.config_json = config_str
- existing.is_default = bool(is_default)
- existing.updated_at = beijing_now_naive()
- db.commit()
- row_id = existing.id
- else:
- row = BuffSelectionConfig(
- user_id=user_id,
- name=name,
- config_json=config_str,
- is_default=bool(is_default)
- )
- db.add(row)
- db.commit()
- db.refresh(row)
- row_id = row.id
- if is_default:
- db.query(BuffSelectionConfig).filter(
- BuffSelectionConfig.user_id == user_id,
- BuffSelectionConfig.id != row_id,
- BuffSelectionConfig.is_deleted == False
- ).update({BuffSelectionConfig.is_default: False}, synchronize_session=False)
- db.commit()
- return JSONResponse(status_code=200, content=create_client_response(200, "配置信息已保存", {"id": row_id}))
- except Exception as e:
- logger.exception(f"保存BUFF选品配置异常: {e}")
- db.rollback()
- return JSONResponse(status_code=200, content=create_client_response(500, "系统异常,请稍后重试", None))
- @router.get("/buff/selection-config")
- async def get_buff_selection_config(
- session_id: str = Query(..., description="会话ID"),
- machine_code: str = Query(..., description="机器码"),
- db: Session = Depends(get_db)
- ):
- """
- 获取当前用户的 BUFF 选品配置列表及默认配置。
- 校验会话+机器码+用户过期后返回 data.list 与 data.defaultConfig。
- """
- try:
- if not (session_id or "").strip():
- return JSONResponse(status_code=200, content=create_client_response(401, "请先登录", None))
- if not (machine_code or "").strip():
- return JSONResponse(status_code=200, content=create_client_response(400, "请求参数错误:缺少 machine_code", None))
- auth_service = AuthService(db)
- session, err_code, err_msg = auth_service.verify_session_with_machine_and_expiry(
- session_id.strip(), (machine_code or "").strip()
- )
- if session is None:
- return JSONResponse(status_code=200, content=create_client_response(err_code or 401, err_msg or "会话已过期,请重新登录", None))
- user_id = session.user_id
- rows = db.query(BuffSelectionConfig).filter(
- BuffSelectionConfig.user_id == user_id,
- BuffSelectionConfig.is_deleted == False
- ).order_by(BuffSelectionConfig.is_default.desc(), BuffSelectionConfig.updated_at.desc()).all()
- 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]
- default_config = None
- for r in rows:
- if r.is_default and r.config_json:
- try:
- default_config = json.loads(r.config_json)
- except Exception:
- pass
- break
- return JSONResponse(status_code=200, content=create_client_response(200, "获取成功", {
- "list": list_data,
- "defaultConfig": default_config
- }))
- except Exception as e:
- logger.exception(f"获取BUFF选品配置异常: {e}")
- return JSONResponse(status_code=200, content=create_client_response(500, "系统异常,请稍后重试", None))
- @router.post("/buff/secure/selection-config")
- @secure_endpoint(require_login=True, require_machine_code=True)
- async def post_buff_selection_config_secure(
- decrypted_params: dict, request: Request, db: Session, session=None, user=None
- ):
- """
- 选品配置获取或保存(安全端点:POST,入参出参密文,时间戳与签名校验)。
- 解密参数:action(get|save);save 时另需 name、config_json、is_default。日志统一记录入参/出参密文与明文。
- """
- try:
- user_id = session.user_id
- action = (decrypted_params.get("action") or "get").strip().lower()
- if action == "get":
- rows = db.query(BuffSelectionConfig).filter(
- BuffSelectionConfig.user_id == user_id,
- BuffSelectionConfig.is_deleted == False
- ).order_by(BuffSelectionConfig.is_default.desc(), BuffSelectionConfig.updated_at.desc()).all()
- 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]
- default_config = None
- for r in rows:
- if r.is_default and r.config_json:
- try:
- default_config = json.loads(r.config_json)
- except Exception:
- pass
- break
- return {"code": 200, "msg": "获取成功", "data": {"list": list_data, "defaultConfig": default_config}}
- elif action == "get_by_selection_request_id":
- selection_request_id = decrypted_params.get("selection_request_id")
- if selection_request_id is None:
- return {"code": 400, "msg": "缺少 selection_request_id 参数", "data": None}
- try:
- selection_request_id = int(selection_request_id)
- except (TypeError, ValueError):
- return {"code": 400, "msg": "selection_request_id 参数格式错误", "data": None}
- sel_req = db.query(BuffSelectionRequest).filter(
- BuffSelectionRequest.id == selection_request_id,
- BuffSelectionRequest.user_id == user_id,
- ).first()
- if sel_req is None:
- return {"code": 404, "msg": "选品请求不存在", "data": None}
- # 优先从请求单的选品条件快照获取(新版)
- if sel_req.selection_condition_json:
- try:
- config_json = json.loads(sel_req.selection_condition_json)
- return {"code": 200, "msg": "获取成功", "data": {"config": config_json}}
- except Exception:
- pass
- # 兼容旧数据:如果快照为空,尝试关联配置表获取
- config_id = sel_req.config_id
- if config_id is None:
- return {"code": 404, "msg": "该选品请求未关联配置", "data": None}
- config_record = db.query(BuffSelectionConfig).filter(
- BuffSelectionConfig.id == config_id,
- BuffSelectionConfig.user_id == user_id,
- BuffSelectionConfig.is_deleted == False,
- ).first()
- if config_record is None or not config_record.config_json:
- return {"code": 404, "msg": "配置信息不存在", "data": None}
- try:
- config_json = json.loads(config_record.config_json)
- except Exception:
- return {"code": 500, "msg": "配置解析失败", "data": None}
- return {"code": 200, "msg": "获取成功", "data": {"config": config_json}}
- elif action == "save":
- name = (decrypted_params.get("name") or "默认").strip() or "默认"
- config_json = decrypted_params.get("config_json")
- if config_json is None:
- return {"code": 400, "msg": "缺少配置内容 config_json", "data": None}
- if not isinstance(config_json, dict):
- try:
- config_json = json.loads(config_json) if isinstance(config_json, str) else None
- except Exception:
- config_json = None
- if not isinstance(config_json, dict):
- return {"code": 400, "msg": "config_json 格式错误", "data": None}
- is_default = decrypted_params.get("is_default", True)
- if not isinstance(is_default, bool):
- is_default = is_default in (1, "1", True, "true")
- config_str = json.dumps(config_json, ensure_ascii=False)
- existing = db.query(BuffSelectionConfig).filter(
- BuffSelectionConfig.user_id == user_id,
- BuffSelectionConfig.name == name,
- BuffSelectionConfig.is_deleted == False
- ).first()
- if existing:
- existing.config_json = config_str
- existing.is_default = bool(is_default)
- existing.updated_at = beijing_now_naive()
- db.commit()
- row_id = existing.id
- else:
- row = BuffSelectionConfig(
- user_id=user_id,
- name=name,
- config_json=config_str,
- is_default=bool(is_default)
- )
- db.add(row)
- db.commit()
- db.refresh(row)
- row_id = row.id
- if is_default:
- db.query(BuffSelectionConfig).filter(
- BuffSelectionConfig.user_id == user_id,
- BuffSelectionConfig.id != row_id,
- BuffSelectionConfig.is_deleted == False
- ).update({BuffSelectionConfig.is_default: False}, synchronize_session=False)
- db.commit()
- return {"code": 200, "msg": "配置信息已保存", "data": {"id": row_id}}
- else:
- return {"code": 400, "msg": "action 必须为 get 或 save", "data": None}
- except Exception as e:
- logger.exception(f"POST /buff/secure/selection-config 异常: {e}")
- try:
- db.rollback()
- except Exception:
- pass
- return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
- def _get_system_config_int(db: Session, key: str, default: int = 0) -> int:
- """读取 system_config 整型配置(backend-selection)。"""
- row = db.query(SystemConfig).filter(
- SystemConfig.config_key == key,
- SystemConfig.app_name == "backend-selection",
- SystemConfig.is_deleted == False,
- ).first()
- if not row or not row.config_value:
- return default
- try:
- return int(row.config_value)
- except (ValueError, TypeError):
- return default
- @router.post("/buff/selection-start")
- async def start_buff_selection(
- request: Request,
- background_tasks: BackgroundTasks,
- db: Session = Depends(get_db),
- ):
- """
- 开始选品(异步)。
- 请求体:session_id(必填), machine_code(必填), config_id(可选)。
- 校验会话+机器码+用户过期与每日次数/条数限制后,创建选品请求单并立即返回;后台异步执行选品逻辑。
- """
- try:
- body = await request.json()
- if not isinstance(body, dict):
- return JSONResponse(status_code=200, content=create_client_response(400, "请求参数错误,请检查后重试", None))
- session_id = (body.get("session_id") or "").strip()
- machine_code = (body.get("machine_code") or "").strip()
- if not session_id:
- return JSONResponse(status_code=200, content=create_client_response(401, "请先登录", None))
- if not machine_code:
- return JSONResponse(status_code=200, content=create_client_response(400, "请求参数错误:缺少 machine_code", None))
- auth_service = AuthService(db)
- session, err_code, err_msg = auth_service.verify_session_with_machine_and_expiry(session_id, machine_code)
- if session is None:
- return JSONResponse(status_code=200, content=create_client_response(err_code or 401, err_msg or "会话已过期,请重新登录", None))
- user_id = session.user_id
- # 今日 0 点(北京时间)
- today_start = beijing_now_naive().replace(hour=0, minute=0, second=0, microsecond=0)
- # 每日选品次数限制
- times_limit = _get_system_config_int(db, "buff_selection_daily_times_limit_per_user", 3)
- today_requests = db.query(BuffSelectionRequest).filter(
- BuffSelectionRequest.user_id == user_id,
- BuffSelectionRequest.created_at >= today_start,
- ).count()
- if today_requests >= times_limit:
- return JSONResponse(
- status_code=200,
- content=create_client_response(403, f"今日选品次数已达上限({times_limit} 次),请明日再试", None),
- )
- # 每日选品条数限制(今日已入选的饰品条数)
- items_limit = _get_system_config_int(db, "buff_selection_daily_items_limit_per_user", 900)
- today_items = db.query(BuffSelectionRequestItem).filter(
- BuffSelectionRequestItem.user_id == user_id,
- BuffSelectionRequestItem.created_at >= today_start,
- ).count()
- if today_items >= items_limit:
- return JSONResponse(
- status_code=200,
- content=create_client_response(403, f"今日选品条数已达上限({items_limit} 条),请明日再试", None),
- )
- config_id = body.get("config_id")
- if config_id is not None:
- try:
- config_id = int(config_id)
- except (TypeError, ValueError):
- config_id = None
- if config_id is None:
- default_cfg = db.query(BuffSelectionConfig).filter(
- BuffSelectionConfig.user_id == user_id,
- BuffSelectionConfig.is_default == True,
- BuffSelectionConfig.is_deleted == False,
- ).first()
- if default_cfg:
- config_id = default_cfg.id
- # 获取配置并验证系统限制,同时准备选品条件快照
- selection_condition_json = None
- heartbeat_service = HeartbeatService(db)
- client_config = heartbeat_service.get_client_config()
- max_worker_count = client_config.get('buff_sweep_max_worker_count', 32)
- min_steam_card_ratio = client_config.get('buff_steam_card_ratio_min', 4)
- max_selection_quantity = client_config.get('buff_selection_max_quantity', 300)
- if config_id:
- cfg_record = db.query(BuffSelectionConfig).filter(
- BuffSelectionConfig.id == config_id,
- BuffSelectionConfig.user_id == user_id,
- BuffSelectionConfig.is_deleted == False,
- ).first()
- if cfg_record and cfg_record.config_json:
- try:
- cfg_json = json.loads(cfg_record.config_json)
- # 固化选品条件快照:存储到请求单,扫货时直接使用,不再关联配置表
- selection_condition_json = cfg_record.config_json
- sweep_process_count = cfg_json.get('sweepProcessCount', 0)
- steam_card_ratio = cfg_json.get('steamCardRatio', 0)
- selection_quantity = cfg_json.get('selectionQuantity')
- if sweep_process_count > max_worker_count:
- return JSONResponse(
- status_code=200,
- content=create_client_response(403, f"扫货线程数不能超过系统配置的最大值 {max_worker_count}", None)
- )
- if steam_card_ratio < min_steam_card_ratio:
- return JSONResponse(
- status_code=200,
- content=create_client_response(403, f"Steam卡价比例不能小于系统配置的最小值 {min_steam_card_ratio}", None)
- )
- if selection_quantity is not None:
- try:
- sq = int(selection_quantity)
- if sq > max_selection_quantity:
- return JSONResponse(
- status_code=200,
- content=create_client_response(403, f"选品数量不能超过系统配置的最大值 {max_selection_quantity}", None)
- )
- except (ValueError, TypeError):
- pass
- except (json.JSONDecodeError, TypeError):
- pass
- req = BuffSelectionRequest(
- user_id=user_id,
- config_id=config_id,
- selection_condition_json=selection_condition_json,
- status="pending",
- )
- db.add(req)
- db.commit()
- db.refresh(req)
- request_id = req.id
- background_tasks.add_task(run_selection_task, request_id, user_id, config_id)
- return JSONResponse(
- status_code=200,
- content=create_client_response(200, "选品已开始,正在后台执行", {"request_id": request_id}),
- )
- except Exception as e:
- logger.exception("开始选品异常: %s", e)
- try:
- db.rollback()
- except Exception:
- pass
- return JSONResponse(status_code=200, content=create_client_response(500, "系统异常,请稍后重试", None))
- @router.post("/buff/selection-log")
- async def post_buff_selection_log(
- request: Request,
- db: Session = Depends(get_db),
- ):
- """
- 获取最新一次选品请求的选品日志(非安全端点)。
- 请求体:session_id(必填), machine_code(必填)。
- 返回结构与安全端点保持一致,便于联调。
- """
- try:
- body = await request.json()
- if not isinstance(body, dict):
- return JSONResponse(status_code=200, content=create_client_response(400, "请求参数错误,请检查后重试", None))
- session_id = (body.get("session_id") or "").strip()
- machine_code = (body.get("machine_code") or "").strip()
- if not session_id:
- return JSONResponse(status_code=200, content=create_client_response(401, "请先登录", None))
- if not machine_code:
- return JSONResponse(status_code=200, content=create_client_response(400, "请求参数错误:缺少 machine_code", None))
- auth_service = AuthService(db)
- session, err_code, err_msg = auth_service.verify_session_with_machine_and_expiry(session_id, machine_code)
- if session is None:
- return JSONResponse(status_code=200, content=create_client_response(err_code or 401, err_msg or "会话已过期,请重新登录", None))
- user_id = session.user_id
- req = (
- db.query(BuffSelectionRequest)
- .filter(BuffSelectionRequest.user_id == user_id)
- .order_by(BuffSelectionRequest.id.desc())
- .first()
- )
- if not req:
- return JSONResponse(
- status_code=200,
- content=create_client_response(
- 200,
- "ok",
- {
- "request_id": None,
- "status": None,
- "started_at": None,
- "ended_at": None,
- "logs": [],
- },
- ),
- )
- started_at = req.created_at.strftime("%Y-%m-%d %H:%M:%S") if req.created_at else None
- ended_at = None
- if req.status in ("completed", "failed", "cancelled") and req.updated_at:
- ended_at = req.updated_at.strftime("%Y-%m-%d %H:%M:%S")
- logs = (
- db.query(BuffSelectionRequestLog)
- .filter(
- BuffSelectionRequestLog.request_id == req.id,
- BuffSelectionRequestLog.user_id == user_id,
- )
- .order_by(BuffSelectionRequestLog.sequence_no.asc(), BuffSelectionRequestLog.created_at.asc())
- .all()
- )
- item_map = {}
- if logs:
- selected_goods = [l.goods_id for l in logs if l.result == "selected"]
- if selected_goods:
- items = (
- db.query(BuffSelectionRequestItem)
- .filter(
- BuffSelectionRequestItem.request_id == req.id,
- BuffSelectionRequestItem.user_id == user_id,
- BuffSelectionRequestItem.goods_id.in_(selected_goods),
- )
- .all()
- )
- for it in items:
- item_map[it.goods_id] = {
- "wear_range": it.wear_range,
- "price_cny": float(it.price_cny) if it.price_cny is not None else None,
- "price_usd": float(it.price_usd) if it.price_usd is not None else None,
- "steam_link": it.steam_link or "",
- }
- log_list = []
- for l in logs:
- row = {
- "created_at": l.created_at.strftime("%Y-%m-%d %H:%M:%S") if l.created_at else None,
- "goods_id": l.goods_id,
- "goods_name": l.goods_name or "",
- "result": l.result,
- "message": l.message or "",
- }
- if l.result == "selected":
- log_wear_range = getattr(l, "wear_range", None)
- log_price_cny = float(l.price_cny) if getattr(l, "price_cny", None) is not None else None
- log_price_usd = float(l.price_usd) if getattr(l, "price_usd", None) is not None else None
- if log_wear_range is not None or log_price_cny is not None or log_price_usd is not None:
- row["wear_range"] = log_wear_range
- row["price_cny"] = log_price_cny
- row["price_usd"] = log_price_usd
- row["steam_link"] = ""
- elif l.goods_id in item_map:
- row["wear_range"] = item_map[l.goods_id]["wear_range"]
- row["price_cny"] = item_map[l.goods_id]["price_cny"]
- row["price_usd"] = item_map[l.goods_id]["price_usd"]
- row["steam_link"] = item_map[l.goods_id]["steam_link"]
- else:
- row["wear_range"] = None
- row["price_cny"] = None
- row["price_usd"] = None
- row["steam_link"] = ""
- else:
- row["wear_range"] = None
- row["price_cny"] = None
- row["price_usd"] = None
- row["steam_link"] = ""
- log_list.append(row)
- return JSONResponse(
- status_code=200,
- content=create_client_response(
- 200,
- "ok",
- {
- "request_id": req.id,
- "status": req.status,
- "started_at": started_at,
- "ended_at": ended_at,
- "logs": log_list,
- },
- ),
- )
- except Exception as e:
- logger.exception("POST /buff/selection-log 异常: %s", e)
- try:
- db.rollback()
- except Exception:
- pass
- return JSONResponse(status_code=200, content=create_client_response(500, "系统异常,请稍后重试", None))
- @router.post("/buff/selection-result")
- async def post_buff_selection_result(
- request: Request,
- db: Session = Depends(get_db),
- ):
- """
- 获取最新一次选品请求的状态及选中饰品列表(非安全端点)。
- 请求体:session_id(必填), machine_code(必填)。
- 返回结构与安全端点保持一致(含 steam_link)。
- """
- try:
- body = await request.json()
- if not isinstance(body, dict):
- return JSONResponse(status_code=200, content=create_client_response(400, "请求参数错误,请检查后重试", None))
- session_id = (body.get("session_id") or "").strip()
- machine_code = (body.get("machine_code") or "").strip()
- if not session_id:
- return JSONResponse(status_code=200, content=create_client_response(401, "请先登录", None))
- if not machine_code:
- return JSONResponse(status_code=200, content=create_client_response(400, "请求参数错误:缺少 machine_code", None))
- auth_service = AuthService(db)
- session, err_code, err_msg = auth_service.verify_session_with_machine_and_expiry(session_id, machine_code)
- if session is None:
- return JSONResponse(status_code=200, content=create_client_response(err_code or 401, err_msg or "会话已过期,请重新登录", None))
- user_id = session.user_id
- req = (
- db.query(BuffSelectionRequest)
- .filter(BuffSelectionRequest.user_id == user_id)
- .order_by(BuffSelectionRequest.id.desc())
- .first()
- )
- if not req:
- return JSONResponse(
- status_code=200,
- content=create_client_response(
- 200,
- "ok",
- {
- "request_id": None,
- "status": None,
- "started_at": None,
- "ended_at": None,
- "items": [],
- },
- ),
- )
- started_at = req.created_at.strftime("%Y-%m-%d %H:%M:%S") if req.created_at else None
- ended_at = None
- if req.status in ("completed", "failed", "cancelled") and req.updated_at:
- ended_at = req.updated_at.strftime("%Y-%m-%d %H:%M:%S")
- item_rows = (
- db.query(BuffSelectionRequestItem)
- .filter(
- BuffSelectionRequestItem.request_id == req.id,
- BuffSelectionRequestItem.user_id == user_id,
- )
- .order_by(BuffSelectionRequestItem.id.asc())
- .all()
- )
- items = []
- for it in item_rows:
- items.append({
- "id": it.id,
- "goods_id": it.goods_id,
- "goods_name": it.goods_name or "",
- "icon_url": it.icon_url or "",
- "steam_link": it.steam_link or "",
- "wear_range": it.wear_range or "",
- "price_cny": float(it.price_cny) if it.price_cny is not None else None,
- "price_usd": float(it.price_usd) if it.price_usd is not None else None,
- "created_at": it.created_at.strftime("%Y-%m-%d %H:%M:%S") if it.created_at else None,
- })
- return JSONResponse(
- status_code=200,
- content=create_client_response(
- 200,
- "ok",
- {
- "request_id": req.id,
- "status": req.status,
- "started_at": started_at,
- "ended_at": ended_at,
- "items": items,
- },
- ),
- )
- except Exception as e:
- logger.exception("POST /buff/selection-result 异常: %s", e)
- try:
- db.rollback()
- except Exception:
- pass
- return JSONResponse(status_code=200, content=create_client_response(500, "系统异常,请稍后重试", None))
- @router.post("/buff/secure/selection-start")
- @secure_endpoint(require_login=True, require_machine_code=True)
- async def post_buff_selection_start_secure(
- decrypted_params: dict, request: Request, db: Session, session=None, user=None
- ):
- """
- 开始选品(安全端点:POST,入参出参密文,时间戳与签名校验)。
- 解密参数:config_id(可选)。日志由 SecureEndpoint 与 APILoggingASGIMiddleware 统一记录。
- """
- import asyncio
- try:
- user_id = session.user_id
- unfinished_reqs = (
- db.query(BuffSelectionRequest)
- .filter(
- BuffSelectionRequest.user_id == user_id,
- BuffSelectionRequest.status.in_(("pending", "running")),
- )
- .order_by(BuffSelectionRequest.id.asc())
- .all()
- )
- cleaned_request_ids = []
- if unfinished_reqs:
- for old_req in unfinished_reqs:
- old_req.cancel_requested = True
- old_req.status = "cancelled"
- cleaned_request_ids.append(old_req.id)
- db.flush()
- today_start = beijing_now_naive().replace(hour=0, minute=0, second=0, microsecond=0)
- times_limit = _get_system_config_int(db, "buff_selection_daily_times_limit_per_user", 3)
- today_requests = db.query(BuffSelectionRequest).filter(
- BuffSelectionRequest.user_id == user_id,
- BuffSelectionRequest.created_at >= today_start,
- ).count()
- if today_requests >= times_limit:
- return {"code": 403, "msg": f"今日选品次数已达上限({times_limit} 次),请明日再试", "data": None}
- items_limit = _get_system_config_int(db, "buff_selection_daily_items_limit_per_user", 900)
- today_items = db.query(BuffSelectionRequestItem).filter(
- BuffSelectionRequestItem.user_id == user_id,
- BuffSelectionRequestItem.created_at >= today_start,
- ).count()
- if today_items >= items_limit:
- return {"code": 403, "msg": f"今日选品条数已达上限({items_limit} 条),请明日再试", "data": None}
- config_id = decrypted_params.get("config_id")
- if config_id is not None:
- try:
- config_id = int(config_id)
- except (TypeError, ValueError):
- config_id = None
- if config_id is None:
- default_cfg = db.query(BuffSelectionConfig).filter(
- BuffSelectionConfig.user_id == user_id,
- BuffSelectionConfig.is_default == True,
- BuffSelectionConfig.is_deleted == False,
- ).first()
- if default_cfg:
- config_id = default_cfg.id
- # 获取配置并固化选品条件快照
- selection_condition_json = None
- if config_id:
- cfg_record = db.query(BuffSelectionConfig).filter(
- BuffSelectionConfig.id == config_id,
- BuffSelectionConfig.user_id == user_id,
- BuffSelectionConfig.is_deleted == False,
- ).first()
- if cfg_record and cfg_record.config_json:
- selection_condition_json = cfg_record.config_json
- req = BuffSelectionRequest(
- user_id=user_id,
- config_id=config_id,
- selection_condition_json=selection_condition_json,
- status="pending",
- )
- db.add(req)
- db.commit()
- db.refresh(req)
- request_id = req.id
- loop = asyncio.get_event_loop()
- loop.run_in_executor(None, lambda: run_selection_task(request_id, user_id, config_id))
- msg = "选品已开始,正在后台执行"
- if cleaned_request_ids:
- msg = f"已停止{len(cleaned_request_ids)}个历史未结束选品任务,{msg}"
- return {
- "code": 200,
- "msg": msg,
- "data": {
- "request_id": request_id,
- "cleaned_request_ids": cleaned_request_ids,
- },
- }
- except Exception as e:
- logger.exception("POST /buff/secure/selection-start 异常: %s", e)
- try:
- db.rollback()
- except Exception:
- pass
- return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
- @router.post("/buff/secure/selection-log")
- @secure_endpoint(require_login=True, require_machine_code=True)
- async def post_buff_selection_log_secure(
- decrypted_params: dict, request: Request, db: Session, session=None, user=None
- ):
- """
- 获取最新一次选品请求的选品日志(安全端点)。
- 返回:request_id, status, started_at, ended_at, logs(含每条是否选中、选中时的区间/价格,未选中时的原因)。
- 若请求单未结束(pending/running),前端可定时轮询本接口直至结束。
- """
- try:
- user_id = session.user_id
- req = (
- db.query(BuffSelectionRequest)
- .filter(BuffSelectionRequest.user_id == user_id)
- .order_by(BuffSelectionRequest.id.desc())
- .first()
- )
- if not req:
- return {
- "code": 200,
- "msg": "ok",
- "data": {
- "request_id": None,
- "status": None,
- "started_at": None,
- "ended_at": None,
- "max_sequence_no": 0,
- "logs": [],
- },
- }
- started_at = req.created_at.strftime("%Y-%m-%d %H:%M:%S") if req.created_at else None
- ended_at = None
- if req.status in ("completed", "failed", "cancelled") and req.updated_at:
- ended_at = req.updated_at.strftime("%Y-%m-%d %H:%M:%S")
- last_sequence_no_raw = decrypted_params.get("last_sequence_no")
- limit_raw = decrypted_params.get("limit")
- try:
- last_sequence_no = int(last_sequence_no_raw) if last_sequence_no_raw is not None else None
- except (TypeError, ValueError):
- last_sequence_no = None
- try:
- limit = int(limit_raw) if limit_raw is not None else 200
- except (TypeError, ValueError):
- limit = 200
- if limit <= 0:
- limit = 200
- if limit > 500:
- limit = 500
- logs_q = (
- db.query(BuffSelectionRequestLog)
- .filter(
- BuffSelectionRequestLog.request_id == req.id,
- BuffSelectionRequestLog.user_id == user_id,
- )
- )
- if last_sequence_no is not None and last_sequence_no > 0:
- logs_q = logs_q.filter(BuffSelectionRequestLog.sequence_no > last_sequence_no)
- logs = (
- logs_q
- .order_by(BuffSelectionRequestLog.sequence_no.desc(), BuffSelectionRequestLog.created_at.desc())
- .limit(limit)
- .all()
- )
- item_map = {}
- if logs:
- selected_goods = [l.goods_id for l in logs if l.result == "selected"]
- if selected_goods:
- items = (
- db.query(BuffSelectionRequestItem)
- .filter(
- BuffSelectionRequestItem.request_id == req.id,
- BuffSelectionRequestItem.user_id == user_id,
- BuffSelectionRequestItem.goods_id.in_(selected_goods),
- )
- .all()
- )
- for it in items:
- item_map[it.goods_id] = {
- "wear_range": it.wear_range,
- "price_cny": float(it.price_cny) if it.price_cny is not None else None,
- "price_usd": float(it.price_usd) if it.price_usd is not None else None,
- }
- log_list = []
- for l in logs:
- row = {
- "created_at": l.created_at.strftime("%Y-%m-%d %H:%M:%S") if l.created_at else None,
- "goods_id": l.goods_id,
- "goods_name": l.goods_name or "",
- "result": l.result,
- "message": l.message or "",
- }
- if l.result == "selected":
- log_wear_range = getattr(l, "wear_range", None)
- log_price_cny = float(l.price_cny) if getattr(l, "price_cny", None) is not None else None
- log_price_usd = float(l.price_usd) if getattr(l, "price_usd", None) is not None else None
- if log_wear_range is not None or log_price_cny is not None or log_price_usd is not None:
- row["wear_range"] = log_wear_range
- row["price_cny"] = log_price_cny
- row["price_usd"] = log_price_usd
- elif l.goods_id in item_map:
- row["wear_range"] = item_map[l.goods_id]["wear_range"]
- row["price_cny"] = item_map[l.goods_id]["price_cny"]
- row["price_usd"] = item_map[l.goods_id]["price_usd"]
- else:
- row["wear_range"] = None
- row["price_cny"] = None
- row["price_usd"] = None
- else:
- row["wear_range"] = None
- row["price_cny"] = None
- row["price_usd"] = None
- log_list.append(row)
- max_sequence_no = 0
- if logs:
- max_sequence_no = max(int(l.sequence_no or 0) for l in logs)
- elif last_sequence_no is not None and last_sequence_no > 0:
- max_sequence_no = last_sequence_no
- return {
- "code": 200,
- "msg": "ok",
- "data": {
- "request_id": req.id,
- "status": req.status,
- "started_at": started_at,
- "ended_at": ended_at,
- "max_sequence_no": max_sequence_no,
- "logs": log_list,
- },
- }
- except Exception as e:
- logger.exception("POST /buff/secure/selection-log 异常: %s", e)
- try:
- db.rollback()
- except Exception:
- pass
- return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
- @router.post("/buff/secure/selection-stop")
- @secure_endpoint(require_login=True, require_machine_code=True)
- async def post_buff_selection_stop_secure(
- decrypted_params: dict, request: Request, db: Session, session=None, user=None
- ):
- """
- 停止当前用户所有进行中选品任务(安全端点)。
- 将 status=pending/running 的请求单均标记 cancel_requested;
- 后台选品任务在循环中检测到后即退出并将状态置为 cancelled。
- """
- try:
- user_id = session.user_id
- unfinished = (
- db.query(BuffSelectionRequest)
- .filter(
- BuffSelectionRequest.user_id == user_id,
- BuffSelectionRequest.status.in_(["pending", "running"]),
- )
- .order_by(BuffSelectionRequest.id.asc())
- .all()
- )
- if not unfinished:
- return {"code": 200, "msg": "当前无进行中的选品任务", "data": None}
- for r in unfinished:
- r.cancel_requested = True
- db.commit()
- ids = [r.id for r in unfinished]
- msg = (
- "已发送停止请求,选品任务将尽快结束"
- if len(ids) == 1
- else f"已停止 {len(ids)} 个进行中的选品任务"
- )
- return {"code": 200, "msg": msg, "data": {"request_ids": ids, "request_id": ids[-1]}}
- except Exception as e:
- logger.exception("POST /buff/secure/selection-stop 异常: %s", e)
- try:
- db.rollback()
- except Exception:
- pass
- return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
- @router.post("/buff/secure/selection-stop-logout")
- @secure_endpoint(require_login=True, require_machine_code=True, require_subscription_valid=False)
- async def post_buff_selection_stop_logout_secure(
- decrypted_params: dict, request: Request, db: Session, session=None, user=None
- ):
- """
- 退登专用停止选品任务(安全端点)。
- 仅校验签名、会话和机器码,不校验卡密有效期;用于被动退登场景强一致停止后台选品线程。
- """
- try:
- user_id = session.user_id
- unfinished = (
- db.query(BuffSelectionRequest)
- .filter(
- BuffSelectionRequest.user_id == user_id,
- BuffSelectionRequest.status.in_(["pending", "running"]),
- )
- .order_by(BuffSelectionRequest.id.asc())
- .all()
- )
- if not unfinished:
- return {"code": 200, "msg": "当前无进行中的选品任务", "data": None}
- for r in unfinished:
- r.cancel_requested = True
- db.commit()
- ids = [r.id for r in unfinished]
- return {
- "code": 200,
- "msg": "已发送停止请求,选品任务将尽快结束",
- "data": {"request_ids": ids, "request_id": ids[-1]},
- }
- except Exception as e:
- logger.exception("POST /buff/secure/selection-stop-logout 异常: %s", e)
- try:
- db.rollback()
- except Exception:
- pass
- return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
- @router.post("/buff/secure/selection-result")
- @secure_endpoint(require_login=True, require_machine_code=True)
- async def post_buff_selection_result_secure(
- decrypted_params: dict, request: Request, db: Session, session=None, user=None
- ):
- """
- 获取最新一次选品请求的状态及选中饰品列表(安全端点)。
- 用于前台「选品数据」表格定时刷新:返回 request_id, status, items;
- 当 status 为 completed、failed 或 cancelled 时,前台可停止定时刷新。
- 若传入 selection_request_id,则返回该次选品请求(用于本地扫货按指定批次拉取饰品列表)。
- """
- try:
- user_id = session.user_id
- selection_request_id_raw = decrypted_params.get("selection_request_id")
- req = None
- if selection_request_id_raw is not None:
- try:
- sid = int(selection_request_id_raw)
- except (TypeError, ValueError):
- sid = 0
- if sid > 0:
- req = (
- db.query(BuffSelectionRequest)
- .filter(
- BuffSelectionRequest.user_id == user_id,
- BuffSelectionRequest.id == sid,
- )
- .first()
- )
- if req is None:
- req = (
- db.query(BuffSelectionRequest)
- .filter(BuffSelectionRequest.user_id == user_id)
- .order_by(BuffSelectionRequest.id.desc())
- .first()
- )
- if not req:
- return {
- "code": 200,
- "msg": "ok",
- "data": {
- "request_id": None,
- "status": None,
- "started_at": None,
- "ended_at": None,
- "items": [],
- },
- }
- started_at = req.created_at.strftime("%Y-%m-%d %H:%M:%S") if req.created_at else None
- ended_at = None
- if req.status in ("completed", "failed", "cancelled") and req.updated_at:
- ended_at = req.updated_at.strftime("%Y-%m-%d %H:%M:%S")
- item_rows = (
- db.query(BuffSelectionRequestItem)
- .filter(
- BuffSelectionRequestItem.request_id == req.id,
- BuffSelectionRequestItem.user_id == user_id,
- )
- .order_by(BuffSelectionRequestItem.created_at.desc(), BuffSelectionRequestItem.id.desc())
- .all()
- )
- items = []
- for it in item_rows:
- items.append({
- "id": it.id,
- "goods_id": it.goods_id,
- "goods_name": it.goods_name or "",
- "icon_url": it.icon_url or "",
- "steam_link": it.steam_link or "",
- "wear_range": it.wear_range or "",
- "price_cny": float(it.price_cny) if it.price_cny is not None else None,
- "price_usd": float(it.price_usd) if it.price_usd is not None else None,
- "created_at": it.created_at.strftime("%Y-%m-%d %H:%M:%S") if it.created_at else None,
- })
- return {
- "code": 200,
- "msg": "ok",
- "data": {
- "request_id": req.id,
- "status": req.status,
- "started_at": started_at,
- "ended_at": ended_at,
- "items": items,
- },
- }
- except Exception as e:
- logger.exception("POST /buff/secure/selection-result 异常: %s", e)
- try:
- db.rollback()
- except Exception:
- pass
- return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
- @router.post("/buff/secure/selection-item-delete")
- @secure_endpoint(require_login=True, require_machine_code=True)
- async def post_buff_selection_item_delete_secure(
- decrypted_params: dict, request: Request, db: Session, session=None, user=None
- ):
- """
- 删除选品明细(安全端点)。
- - 仅允许删除当前用户最新一次选品请求的数据
- - 删除后扫货不再扫描该饰品
- """
- try:
- user_id = session.user_id
- item_id_raw = decrypted_params.get("item_id")
- if item_id_raw is None:
- return {"code": 400, "msg": "缺少 item_id", "data": None}
- try:
- item_id = int(item_id_raw)
- except (TypeError, ValueError):
- return {"code": 400, "msg": "item_id 格式错误", "data": None}
- latest_req = (
- db.query(BuffSelectionRequest)
- .filter(BuffSelectionRequest.user_id == user_id)
- .order_by(BuffSelectionRequest.id.desc())
- .first()
- )
- if not latest_req:
- return {"code": 404, "msg": "暂无选品数据", "data": None}
- row = (
- db.query(BuffSelectionRequestItem)
- .filter(
- BuffSelectionRequestItem.id == item_id,
- BuffSelectionRequestItem.user_id == user_id,
- BuffSelectionRequestItem.request_id == latest_req.id,
- )
- .first()
- )
- if not row:
- return {"code": 404, "msg": "未找到可删除的选品数据", "data": None}
- db.delete(row)
- db.commit()
- return {"code": 200, "msg": "删除成功", "data": {"item_id": item_id}}
- except Exception as e:
- logger.exception("POST /buff/secure/selection-item-delete 异常: %s", e)
- try:
- db.rollback()
- except Exception:
- pass
- return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
- @router.post("/buff/secure/selection-batch-import")
- @secure_endpoint(require_login=True, require_machine_code=True)
- async def post_buff_selection_batch_import_secure(
- decrypted_params: dict, request: Request, db: Session, session=None, user=None
- ):
- """
- 批量导入选品数据(安全端点)。
- - 先删除当前用户最新一次选品请求的所有数据
- - 再批量插入新的选品数据
- 入参 items: List[dict],每个 dict 包含:
- goods_id, goods_name(可选,JSON中有name字段), icon_url(从buff_goods_attr表获取), steam_link, price_cny, price_usd, wear_range
- """
- try:
- user_id = session.user_id
- # 获取当前用户最新一次选品请求
- latest_req = (
- db.query(BuffSelectionRequest)
- .filter(
- BuffSelectionRequest.user_id == user_id,
- BuffSelectionRequest.status.in_(["completed", "running", "pending"]),
- )
- .order_by(BuffSelectionRequest.id.desc())
- .first()
- )
- if not latest_req:
- # 没有选品请求,创建一个新的
- latest_req = BuffSelectionRequest(
- user_id=user_id,
- status="completed",
- created_at=beijing_now_naive(),
- )
- db.add(latest_req)
- db.flush()
- else:
- # 删除该请求单下的所有选品明细
- db.query(BuffSelectionRequestItem).filter(
- BuffSelectionRequestItem.request_id == latest_req.id,
- BuffSelectionRequestItem.user_id == user_id,
- ).delete(synchronize_session=False)
- # 解析导入数据
- items = decrypted_params.get("items") or []
- if not isinstance(items, list):
- return {"code": 400, "msg": "items 参数格式错误,必须是数组", "data": None}
- # 收集所有 goods_id,批量查询 buff_goods_attr 获取 icon_url,批量查询 buff_goods_info 获取 steam_url
- all_goods_ids = []
- for item in items:
- if isinstance(item, dict):
- goods_id = str(item.get("goods_id") or "").strip()
- if goods_id:
- all_goods_ids.append(goods_id)
- # 批量查询 icon_url 映射
- icon_url_map = {}
- if all_goods_ids:
- attrs = db.query(BuffGoodsAttr).filter(
- BuffGoodsAttr.goods_id.in_(all_goods_ids)
- ).all()
- for attr in attrs:
- icon_url_map[attr.goods_id] = attr.icon_url
- # 批量查询 steam_url 映射(从 buff_goods_info 表获取)
- steam_url_map = {}
- if all_goods_ids:
- goods_infos = db.query(BuffGoodsInfo).filter(
- BuffGoodsInfo.goods_id.in_(all_goods_ids)
- ).all()
- for info in goods_infos:
- steam_url_map[info.goods_id] = info.steam_url
- inserted_count = 0
- for item in items:
- if not isinstance(item, dict):
- continue
- goods_id = str(item.get("goods_id") or "").strip()
- if not goods_id:
- continue
- # 优先使用 JSON 中的 name 作为 goods_name,如果没有就用 buff_goods_attr 中的 name
- goods_name = str(item.get("goods_name") or item.get("name") or "")[:256]
- # icon_url 从 buff_goods_attr 表获取,没有就保持空
- icon_url = icon_url_map.get(goods_id, "") or ""
- if icon_url:
- icon_url = icon_url[:512]
- # steam_link 优先从 buff_goods_info 表获取,没有就使用 JSON 中的值
- steam_link = steam_url_map.get(goods_id, "") or str(item.get("steam_link") or "")[:512]
- new_item = BuffSelectionRequestItem(
- request_id=latest_req.id,
- user_id=user_id,
- goods_id=goods_id,
- goods_name=goods_name,
- icon_url=icon_url,
- steam_link=steam_link,
- price_cny=item.get("price_cny"),
- price_usd=item.get("price_usd"),
- wear_range=str(item.get("wear_range") or "")[:64],
- created_at=beijing_now_naive(),
- )
- db.add(new_item)
- inserted_count += 1
- db.commit()
- return {
- "code": 200,
- "msg": f"批量导入成功,共导入 {inserted_count} 条数据",
- "data": {"imported_count": inserted_count}
- }
- except Exception as e:
- logger.exception("POST /buff/secure/selection-batch-import 异常: %s", e)
- try:
- db.rollback()
- except Exception:
- pass
- return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
- @router.post("/buff/secure/sweep-bound-steam-usernames")
- @secure_endpoint(require_login=True, require_machine_code=True)
- async def post_buff_sweep_bound_steam_usernames_secure(
- decrypted_params: dict, request: Request, db: Session, session=None, user=None
- ):
- """
- 扫货专用:返回当前用户在后端已绑定且启用的 Steam 账号用户名列表。
- 客户端仅允许使用这些用户名与本地 accounts.json 交集进行扫货。
- """
- try:
- user_id = session.user_id
- rows = (
- db.query(SteamAccount)
- .filter(
- SteamAccount.user_id == user_id,
- SteamAccount.is_deleted == False,
- SteamAccount.is_active == True,
- )
- .order_by(SteamAccount.id.asc())
- .all()
- )
- names = [str(r.steam_username).strip() for r in rows if r.steam_username]
- return {"code": 200, "msg": "ok", "data": {"steam_usernames": names}}
- except Exception as e:
- logger.exception("POST /buff/secure/sweep-bound-steam-usernames 异常: %s", e)
- try:
- db.rollback()
- except Exception:
- pass
- return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
- @router.post("/buff/secure/sweep-start")
- @secure_endpoint(require_login=True, require_machine_code=True)
- async def post_buff_sweep_start_secure(
- decrypted_params: dict, request: Request, db: Session, session=None, user=None
- ):
- """
- 开始扫货(安全端点)。
- - 默认基于最新一次选品请求的数据
- - 若已有 pending/running 的扫货请求,则禁止重复开始
- """
- import asyncio
- try:
- user_id = session.user_id
- unfinished_reqs = (
- db.query(BuffSweepRequest)
- .filter(
- BuffSweepRequest.user_id == user_id,
- BuffSweepRequest.status.in_(("pending", "running")),
- )
- .order_by(BuffSweepRequest.id.asc())
- .all()
- )
- cleaned_request_ids = []
- if unfinished_reqs:
- for old_req in unfinished_reqs:
- old_req.cancel_requested = True
- old_req.status = "cancelled"
- cleaned_request_ids.append(old_req.id)
- db.flush()
- latest_selection = (
- db.query(BuffSelectionRequest)
- .filter(BuffSelectionRequest.user_id == user_id)
- .order_by(BuffSelectionRequest.id.desc())
- .first()
- )
- if not latest_selection:
- return {"code": 404, "msg": "暂无选品数据,请先完成选品", "data": None}
- item_count = (
- db.query(BuffSelectionRequestItem)
- .filter(
- BuffSelectionRequestItem.user_id == user_id,
- BuffSelectionRequestItem.request_id == latest_selection.id,
- )
- .count()
- )
- if item_count <= 0:
- return {"code": 400, "msg": "最新选品数据为空,请先选品或检查是否已删除全部饰品", "data": None}
- req = BuffSweepRequest(
- user_id=user_id,
- selection_request_id=latest_selection.id,
- status="pending",
- )
- db.add(req)
- db.commit()
- db.refresh(req)
- client_local = bool(decrypted_params.get("client_local"))
- if not client_local:
- loop = asyncio.get_event_loop()
- loop.run_in_executor(None, lambda: run_sweep_task(req.id, user_id, latest_selection.id))
- msg = "扫货已开始,正在后台执行"
- else:
- msg = "扫货请求单已创建,等待客户端本地执行"
- if cleaned_request_ids:
- msg = f"已停止{len(cleaned_request_ids)}个历史未结束扫货任务,{msg}"
- return {
- "code": 200,
- "msg": msg,
- "data": {
- "request_id": req.id,
- "selection_request_id": latest_selection.id,
- "cleaned_request_ids": cleaned_request_ids,
- },
- }
- except Exception as e:
- logger.exception("POST /buff/secure/sweep-start 异常: %s", e)
- try:
- db.rollback()
- except Exception:
- pass
- return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
- @router.post("/buff/secure/sweep-stop")
- @secure_endpoint(require_login=True, require_machine_code=True)
- async def post_buff_sweep_stop_secure(
- decrypted_params: dict, request: Request, db: Session, session=None, user=None
- ):
- """停止当前用户所有进行中的扫货任务(安全端点,与 sweep-start 清理逻辑一致)。"""
- try:
- user_id = session.user_id
- unfinished = (
- db.query(BuffSweepRequest)
- .filter(
- BuffSweepRequest.user_id == user_id,
- BuffSweepRequest.status.in_(("pending", "running")),
- )
- .order_by(BuffSweepRequest.id.asc())
- .all()
- )
- if not unfinished:
- return {"code": 200, "msg": "当前无进行中的扫货任务", "data": None}
- for r in unfinished:
- r.cancel_requested = True
- if r.status in ("pending", "running"):
- r.status = "cancelled"
- db.commit()
- ids = [r.id for r in unfinished]
- msg = "扫货已停止" if len(ids) == 1 else f"已停止 {len(ids)} 个进行中的扫货任务"
- return {"code": 200, "msg": msg, "data": {"request_ids": ids, "request_id": ids[-1]}}
- except Exception as e:
- logger.exception("POST /buff/secure/sweep-stop 异常: %s", e)
- try:
- db.rollback()
- except Exception:
- pass
- return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
- @router.post("/buff/secure/sweep-log")
- @secure_endpoint(require_login=True, require_machine_code=True)
- async def post_buff_sweep_log_secure(
- decrypted_params: dict, request: Request, db: Session, session=None, user=None
- ):
- """获取最新一次扫货日志(安全端点)。"""
- try:
- user_id = session.user_id
- req = (
- db.query(BuffSweepRequest)
- .filter(BuffSweepRequest.user_id == user_id)
- .order_by(BuffSweepRequest.id.desc())
- .first()
- )
- if not req:
- return {
- "code": 200,
- "msg": "ok",
- "data": {
- "request_id": None,
- "status": None,
- "started_at": None,
- "ended_at": None,
- "max_sequence_no": 0,
- "logs": [],
- },
- }
- last_sequence_no_raw = decrypted_params.get("last_sequence_no")
- limit_raw = decrypted_params.get("limit")
- try:
- last_sequence_no = int(last_sequence_no_raw) if last_sequence_no_raw is not None else None
- except (TypeError, ValueError):
- last_sequence_no = None
- try:
- limit = int(limit_raw) if limit_raw is not None else 200
- except (TypeError, ValueError):
- limit = 200
- if limit <= 0:
- limit = 200
- if limit > 500:
- limit = 500
- logs_q = (
- db.query(BuffSweepRequestLog)
- .filter(
- BuffSweepRequestLog.user_id == user_id,
- BuffSweepRequestLog.sweep_request_id == req.id,
- )
- )
- if last_sequence_no is not None and last_sequence_no > 0:
- logs_q = logs_q.filter(BuffSweepRequestLog.sequence_no > last_sequence_no)
- logs = (
- logs_q
- .order_by(BuffSweepRequestLog.sequence_no.desc(), BuffSweepRequestLog.created_at.desc())
- .limit(limit)
- .all()
- )
- max_sequence_no = 0
- if logs:
- max_sequence_no = max(int(l.sequence_no or 0) for l in logs)
- elif last_sequence_no is not None and last_sequence_no > 0:
- max_sequence_no = last_sequence_no
- return {
- "code": 200,
- "msg": "ok",
- "data": {
- "request_id": req.id,
- "status": req.status,
- "started_at": req.created_at.strftime("%Y-%m-%d %H:%M:%S") if req.created_at else None,
- "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,
- "max_sequence_no": max_sequence_no,
- "logs": [
- {
- "created_at": l.created_at.strftime("%Y-%m-%d %H:%M:%S") if l.created_at else None,
- "goods_id": l.goods_id,
- "goods_name": l.goods_name or "",
- "result": l.result,
- "message": l.message or "",
- "retry_attempts": int(l.retry_attempts or 0),
- "proxy_refreshed": bool(l.proxy_refreshed),
- "target_price_usd": float(l.target_price_usd) if l.target_price_usd is not None else None,
- "purchase_price_usd": float(l.purchase_price_usd) if l.purchase_price_usd is not None else None,
- }
- for l in logs
- ],
- },
- }
- except Exception as e:
- logger.exception("POST /buff/secure/sweep-log 异常: %s", e)
- try:
- db.rollback()
- except Exception:
- pass
- return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
- @router.post("/buff/secure/sweep-log-client-sync")
- @secure_endpoint(require_login=True, require_machine_code=True)
- async def post_buff_sweep_log_client_sync_secure(
- decrypted_params: dict, request: Request, db: Session, session=None, user=None
- ):
- """
- 客户端本地扫货日志同步到后端数据库(安全端点)。
- 说明:
- - 客户端以本地 steam-msg/{request_id}.txt 为准
- - 后端按 user_id + sweep_request_id 做覆盖写入(先删后插),保留最新快照
- """
- try:
- user_id = session.user_id
- sweep_request_id_raw = decrypted_params.get("sweep_request_id")
- if sweep_request_id_raw is None:
- return {"code": 400, "msg": "缺少 sweep_request_id", "data": None}
- try:
- sweep_request_id = int(sweep_request_id_raw)
- except (TypeError, ValueError):
- return {"code": 400, "msg": "sweep_request_id 格式错误", "data": None}
- if sweep_request_id <= 0:
- return {"code": 400, "msg": "sweep_request_id 必须大于0", "data": None}
- logs = decrypted_params.get("logs")
- if not isinstance(logs, list):
- return {"code": 400, "msg": "logs 格式错误", "data": None}
- selection_request_id_raw = decrypted_params.get("selection_request_id")
- status_raw = str(decrypted_params.get("status") or "").strip().lower()
- allowed_status = {"pending", "running", "completed", "failed", "cancelled"}
- normalized_status = status_raw if status_raw in allowed_status else None
- req = (
- db.query(BuffSweepRequest)
- .filter(
- BuffSweepRequest.id == sweep_request_id,
- BuffSweepRequest.user_id == user_id,
- )
- .first()
- )
- if req is None:
- selection_request_id = None
- try:
- if selection_request_id_raw is not None:
- selection_request_id = int(selection_request_id_raw)
- except (TypeError, ValueError):
- selection_request_id = None
- if not selection_request_id or selection_request_id <= 0:
- latest_selection = (
- db.query(BuffSelectionRequest)
- .filter(BuffSelectionRequest.user_id == user_id)
- .order_by(BuffSelectionRequest.id.desc())
- .first()
- )
- if latest_selection:
- selection_request_id = latest_selection.id
- if not selection_request_id or selection_request_id <= 0:
- return {"code": 400, "msg": "缺少有效 selection_request_id,无法创建扫货请求单", "data": None}
- req = BuffSweepRequest(
- id=sweep_request_id,
- user_id=user_id,
- selection_request_id=selection_request_id,
- status=normalized_status or "pending",
- )
- db.add(req)
- elif normalized_status:
- req.status = normalized_status
- # 覆盖式写入:删除同请求历史日志,再插入本次快照
- db.query(BuffSweepRequestLog).filter(
- BuffSweepRequestLog.user_id == user_id,
- BuffSweepRequestLog.sweep_request_id == sweep_request_id,
- ).delete(synchronize_session=False)
- insert_count = 0
- for idx, row in enumerate(logs, start=1):
- if not isinstance(row, dict):
- continue
- goods_id = str(row.get("goods_id") or "").strip()
- goods_name = str(row.get("goods_name") or "").strip()
- result = str(row.get("result") or "").strip() or "skipped"
- message = str(row.get("message") or "").strip()
- if not goods_id:
- goods_id = "-"
- target_price = row.get("target_price_usd")
- purchase_price = row.get("purchase_price_usd")
- try:
- target_price = float(target_price) if target_price is not None else None
- except Exception:
- target_price = None
- try:
- purchase_price = float(purchase_price) if purchase_price is not None else None
- except Exception:
- purchase_price = None
- db.add(BuffSweepRequestLog(
- sweep_request_id=sweep_request_id,
- user_id=user_id,
- goods_id=goods_id,
- goods_name=goods_name,
- sequence_no=idx,
- result=result,
- message=message[:512] if message else "",
- retry_attempts=0,
- proxy_refreshed=False,
- target_price_usd=target_price,
- purchase_price_usd=purchase_price,
- ))
- insert_count += 1
- db.commit()
- return {"code": 200, "msg": "ok", "data": {"sweep_request_id": sweep_request_id, "inserted": insert_count}}
- except Exception as e:
- logger.exception("POST /buff/secure/sweep-log-client-sync 异常: %s", e)
- try:
- db.rollback()
- except Exception:
- pass
- return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
- @router.post("/buff/secure/sweep-item-client-sync")
- @secure_endpoint(require_login=True, require_machine_code=True)
- async def post_buff_sweep_item_client_sync_secure(
- decrypted_params: dict, request: Request, db: Session, session=None, user=None
- ):
- """
- 客户端本地扫货成功明细同步到后端数据库(安全端点)。
- 说明:
- - 客户端提交成功购买 items 快照(含 steam_account)
- - 默认:按 user_id + sweep_request_id 覆盖写入(先删后插)
- - append_only=true:不删除历史行,每笔成功购买追加一行(同一选品明细可有多笔成交,勿按 selection_item_id 覆盖)
- """
- try:
- user_id = session.user_id
- append_only = bool(decrypted_params.get("append_only"))
- sweep_request_id_raw = decrypted_params.get("sweep_request_id")
- if sweep_request_id_raw is None:
- return {"code": 400, "msg": "缺少 sweep_request_id", "data": None}
- try:
- sweep_request_id = int(sweep_request_id_raw)
- except (TypeError, ValueError):
- return {"code": 400, "msg": "sweep_request_id 格式错误", "data": None}
- if sweep_request_id <= 0:
- return {"code": 400, "msg": "sweep_request_id 必须大于0", "data": None}
- items = decrypted_params.get("items")
- if not isinstance(items, list):
- return {"code": 400, "msg": "items 格式错误", "data": None}
- selection_request_id_raw = decrypted_params.get("selection_request_id")
- req = (
- db.query(BuffSweepRequest)
- .filter(
- BuffSweepRequest.id == sweep_request_id,
- BuffSweepRequest.user_id == user_id,
- )
- .first()
- )
- if req is None:
- selection_request_id = None
- try:
- if selection_request_id_raw is not None:
- selection_request_id = int(selection_request_id_raw)
- except (TypeError, ValueError):
- selection_request_id = None
- if not selection_request_id or selection_request_id <= 0:
- latest_selection = (
- db.query(BuffSelectionRequest)
- .filter(BuffSelectionRequest.user_id == user_id)
- .order_by(BuffSelectionRequest.id.desc())
- .first()
- )
- if latest_selection:
- selection_request_id = latest_selection.id
- if not selection_request_id or selection_request_id <= 0:
- return {"code": 400, "msg": "缺少有效 selection_request_id,无法创建扫货请求单", "data": None}
- req = BuffSweepRequest(
- id=sweep_request_id,
- user_id=user_id,
- selection_request_id=selection_request_id,
- status="pending",
- )
- db.add(req)
- if not append_only:
- # 覆盖式写入:删除同请求历史成功明细,再插入本次快照
- db.query(BuffSweepRequestItem).filter(
- BuffSweepRequestItem.user_id == user_id,
- BuffSweepRequestItem.sweep_request_id == sweep_request_id,
- ).delete(synchronize_session=False)
- insert_count = 0
- for row in items:
- if not isinstance(row, dict):
- continue
- try:
- selection_item_id = int(row.get("selection_item_id") or 0)
- except (TypeError, ValueError):
- selection_item_id = 0
- if selection_item_id <= 0:
- continue
- goods_id = str(row.get("goods_id") or "").strip()
- if not goods_id:
- continue
- try:
- item_selection_request_id = int(
- row.get("selection_request_id") or req.selection_request_id
- )
- except (TypeError, ValueError):
- item_selection_request_id = req.selection_request_id
- target_price = row.get("target_price_usd")
- purchase_price = row.get("purchase_price_usd")
- purchase_price_cny = row.get("purchase_price_cny")
- purchase_wear = row.get("purchase_wear")
- try:
- target_price = float(target_price) if target_price is not None else None
- except Exception:
- target_price = None
- try:
- purchase_price = float(purchase_price) if purchase_price is not None else None
- except Exception:
- purchase_price = None
- try:
- purchase_price_cny = float(purchase_price_cny) if purchase_price_cny is not None else None
- except Exception:
- purchase_price_cny = None
- try:
- purchase_wear = float(purchase_wear) if purchase_wear is not None else None
- except Exception:
- purchase_wear = None
- steam_account = str(row.get("steam_account") or "").strip() or None
- db.add(BuffSweepRequestItem(
- sweep_request_id=sweep_request_id,
- user_id=user_id,
- selection_request_id=item_selection_request_id,
- selection_item_id=selection_item_id,
- goods_id=goods_id,
- goods_name=str(row.get("goods_name") or "").strip() or None,
- icon_url=str(row.get("icon_url") or "").strip() or None,
- wear_range=str(row.get("wear_range") or "").strip() or None,
- purchase_wear=purchase_wear,
- target_price_usd=target_price,
- purchase_price_usd=purchase_price,
- purchase_price_cny=purchase_price_cny,
- steam_account=steam_account,
- ))
- insert_count += 1
- db.commit()
- return {"code": 200, "msg": "ok", "data": {"sweep_request_id": sweep_request_id, "inserted": insert_count}}
- except Exception as e:
- logger.exception("POST /buff/secure/sweep-item-client-sync 异常: %s", e)
- try:
- db.rollback()
- except Exception:
- pass
- return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
- @router.post("/buff/secure/sweep-result")
- @secure_endpoint(require_login=True, require_machine_code=True)
- async def post_buff_sweep_result_secure(
- decrypted_params: dict, request: Request, db: Session, session=None, user=None
- ):
- """
- 获取扫货成功列表(安全端点)。
- - 结果始终返回该用户全部扫货成功记录
- - 支持分页 page/page_size
- """
- try:
- user_id = session.user_id
- page = int(decrypted_params.get("page") or 1)
- page_size = int(decrypted_params.get("page_size") or 10)
- if page < 1:
- page = 1
- if page_size < 1 or page_size > 100:
- page_size = 10
- latest_req = (
- db.query(BuffSweepRequest)
- .filter(BuffSweepRequest.user_id == user_id)
- .order_by(BuffSweepRequest.id.desc())
- .first()
- )
- q = db.query(BuffSweepRequestItem).filter(BuffSweepRequestItem.user_id == user_id)
- total = q.count()
- rows = (
- q.order_by(BuffSweepRequestItem.created_at.desc(), BuffSweepRequestItem.id.desc())
- .offset((page - 1) * page_size)
- .limit(page_size)
- .all()
- )
- return {
- "code": 200,
- "msg": "ok",
- "data": {
- "request_id": latest_req.id if latest_req else None,
- "status": latest_req.status if latest_req else None,
- "started_at": latest_req.created_at.strftime("%Y-%m-%d %H:%M:%S") if latest_req and latest_req.created_at else None,
- "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,
- "page": page,
- "page_size": page_size,
- "total": total,
- "items": [
- {
- "id": r.id,
- "sweep_request_id": r.sweep_request_id,
- "selection_request_id": r.selection_request_id,
- "selection_item_id": r.selection_item_id,
- "goods_id": r.goods_id,
- "goods_name": r.goods_name or "",
- "icon_url": r.icon_url or "",
- "wear_range": r.wear_range or "",
- "purchase_wear": str(r.purchase_wear) if r.purchase_wear is not None else None,
- "target_price_usd": float(r.target_price_usd) if r.target_price_usd is not None else None,
- "purchase_price_usd": float(r.purchase_price_usd) if r.purchase_price_usd is not None else None,
- "purchase_price_cny": float(r.purchase_price_cny) if r.purchase_price_cny is not None else None,
- "steam_account": r.steam_account,
- "created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None,
- }
- for r in rows
- ],
- },
- }
- except Exception as e:
- logger.exception("POST /buff/secure/sweep-result 异常: %s", e)
- try:
- db.rollback()
- except Exception:
- pass
- return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
- # ============ HMAC 密钥(安全端点,登录后密钥交换) ============
- @router.post("/keys/hmac/secure")
- @secure_endpoint(require_login=True, require_machine_code=True)
- async def post_keys_hmac_secure(
- decrypted_params: dict, request: Request, db: Session, session=None, user=None
- ):
- """
- 同步/获取动态HMAC密钥(安全端点:POST,入参出参密文,时间戳与签名校验)。
- 会话与机器码由装饰器校验,无需解密参数。日志统一记录入参/出参密文与明文。
- """
- try:
- from app.services.hmac_key_service import HmacKeyService
- hmac_service = HmacKeyService(db)
- success, message, data = hmac_service.create_hmac_key(
- user_id=user.id,
- username=user.username,
- machine_code=session.machine_code,
- )
- if success and data:
- return {"code": 200, "msg": message or "成功", "data": data}
- return {"code": 400, "msg": message or "HMAC密钥获取失败", "data": None}
- except Exception as e:
- logger.exception("POST /keys/hmac/secure 异常: %s", e)
- return {"code": 500, "msg": "系统异常,请稍后重试", "data": None}
|