buff_selection_service.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  1. """
  2. BUFF 选品服务:第一步筛选候选 ID + 第二步利润判断与落库。
  3. 逻辑见 docs/buff_selection_filter_logic.md、docs/buff_selection_step2_logic.md。
  4. """
  5. import json
  6. import random
  7. from decimal import Decimal
  8. from typing import List, Dict, Any, Optional, Tuple
  9. from sqlalchemy import text
  10. from sqlalchemy.orm import Session
  11. from app.database import SessionLocal
  12. from app.models.buff_selection_request import BuffSelectionRequest
  13. from app.models.buff_selection_request_item import BuffSelectionRequestItem
  14. from app.models.buff_selection_request_log import BuffSelectionRequestLog
  15. from app.models.buff_selection_config import BuffSelectionConfig
  16. from app.models.system_config import SystemConfig
  17. from app.utils.timezone import beijing_now_naive
  18. from app.core.logger import logger
  19. def _get_system_config_value(db: Session, config_key: str, app_name: str = "backend-selection") -> Optional[str]:
  20. """读取 system_config 配置值。"""
  21. row = db.query(SystemConfig).filter(
  22. SystemConfig.config_key == config_key,
  23. SystemConfig.app_name == app_name,
  24. SystemConfig.is_deleted == False,
  25. ).first()
  26. return row.config_value if row else None
  27. def _get_system_config_int(db: Session, config_key: str, default: int = 0, app_name: str = "backend-selection") -> int:
  28. """读取 system_config 整型配置。"""
  29. val = _get_system_config_value(db, config_key, app_name)
  30. if val is None or val == "":
  31. return default
  32. try:
  33. return int(val)
  34. except (ValueError, TypeError):
  35. return default
  36. def _parse_ids_field(raw: Any) -> List[str]:
  37. """将 excludeIds/specifiedIds(字符串或数组)解析为 ID 列表。"""
  38. if raw is None:
  39. return []
  40. if isinstance(raw, list):
  41. return [str(x).strip() for x in raw if str(x).strip()]
  42. s = str(raw).strip()
  43. if not s:
  44. return []
  45. return [x.strip() for x in s.replace(",", "\n").splitlines() if x.strip()]
  46. def _collect_gun_codes(config: Dict[str, Any]) -> List[str]:
  47. """从配置中收集所有枪械分类编码(步枪/手枪/SMG/霰弹枪/机枪)。"""
  48. codes = []
  49. for key in ("rifle", "pistol", "smg", "shotgun", "machinegun"):
  50. val = config.get(key)
  51. if isinstance(val, list):
  52. codes.extend([str(x).strip() for x in val if str(x).strip()])
  53. return list(dict.fromkeys(codes))
  54. def step1_candidate_ids(db: Session, config: Dict[str, Any]) -> List[str]:
  55. """
  56. 第一步:根据基础过滤条件从 buff_goods_filtered_view 筛出候选 ID,合并指定 ID,排除排除 ID,乱序。
  57. 返回初始化要筛选的饰品 ID 列表(已乱序)。
  58. """
  59. conditions: List[str] = ["1=1"]
  60. params: Dict[str, Any] = {}
  61. # 枪械:都没选 -> 1=1;有选 -> 分类编码 IN (...)
  62. gun_codes = _collect_gun_codes(config)
  63. if gun_codes:
  64. placeholders = ", ".join([f":gun_{i}" for i in range(len(gun_codes))])
  65. conditions.append(f"`分类编码` IN ({placeholders})")
  66. for i, c in enumerate(gun_codes):
  67. params[f"gun_{i}"] = c
  68. # 品质
  69. quality = config.get("quality")
  70. if isinstance(quality, list) and quality:
  71. ph = ", ".join([f":quality_{i}" for i in range(len(quality))])
  72. conditions.append(f"`品质` IN ({ph})")
  73. for i, v in enumerate(quality):
  74. params[f"quality_{i}"] = str(v).strip()
  75. # 类别
  76. category = config.get("category")
  77. if isinstance(category, list) and category:
  78. ph = ", ".join([f":category_{i}" for i in range(len(category))])
  79. conditions.append(f"`类别` IN ({ph})")
  80. for i, v in enumerate(category):
  81. params[f"category_{i}"] = str(v).strip()
  82. # 外观
  83. exterior = config.get("exterior")
  84. if isinstance(exterior, list) and exterior:
  85. ph = ", ".join([f":exterior_{i}" for i in range(len(exterior))])
  86. conditions.append(f"`外观` IN ({ph})")
  87. for i, v in enumerate(exterior):
  88. params[f"exterior_{i}"] = str(v).strip()
  89. # 收藏品
  90. collection = config.get("collection")
  91. if isinstance(collection, list) and collection:
  92. ph = ", ".join([f":collection_{i}" for i in range(len(collection))])
  93. conditions.append(f"`收藏品` IN ({ph})")
  94. for i, v in enumerate(collection):
  95. params[f"collection_{i}"] = str(v).strip()
  96. # BUFF 价格区间
  97. pmin = config.get("buffPriceMin")
  98. pmax = config.get("buffPriceMax")
  99. if pmin is not None and pmax is not None:
  100. conditions.append("`当前在售最低价格` >= :buff_price_min AND `当前在售最低价格` <= :buff_price_max")
  101. params["buff_price_min"] = float(pmin)
  102. params["buff_price_max"] = float(pmax)
  103. elif pmin is not None:
  104. conditions.append("`当前在售最低价格` >= :buff_price_min")
  105. params["buff_price_min"] = float(pmin)
  106. elif pmax is not None:
  107. conditions.append("`当前在售最低价格` <= :buff_price_max")
  108. params["buff_price_max"] = float(pmax)
  109. # BUFF 在售数量
  110. smin = config.get("buffSalesMin")
  111. smax = config.get("buffSalesMax")
  112. if smin is not None and smax is not None:
  113. conditions.append("`在售数量` >= :buff_sales_min AND `在售数量` <= :buff_sales_max")
  114. params["buff_sales_min"] = int(smin)
  115. params["buff_sales_max"] = int(smax)
  116. elif smin is not None:
  117. conditions.append("`在售数量` >= :buff_sales_min")
  118. params["buff_sales_min"] = int(smin)
  119. elif smax is not None:
  120. conditions.append("`在售数量` <= :buff_sales_max")
  121. params["buff_sales_max"] = int(smax)
  122. # 排除的饰品 ID
  123. exclude_ids = _parse_ids_field(config.get("excludeIds"))
  124. if exclude_ids:
  125. ph = ", ".join([f":exclude_{i}" for i in range(len(exclude_ids))])
  126. conditions.append(f"`BUFF饰品ID` NOT IN ({ph})")
  127. for i, v in enumerate(exclude_ids):
  128. params[f"exclude_{i}"] = v
  129. where_sql = " AND ".join(conditions)
  130. sql = f"SELECT DISTINCT `BUFF饰品ID` FROM buff_goods_filtered_view WHERE {where_sql}"
  131. try:
  132. rows = db.execute(text(sql), params).fetchall()
  133. candidate = [str(r[0]) for r in rows if r[0]]
  134. except Exception as e:
  135. logger.exception("step1 执行主查询异常: %s", e)
  136. candidate = []
  137. # 指定饰品 ID 合并去重
  138. specified = _parse_ids_field(config.get("specifiedIds"))
  139. all_ids = list(dict.fromkeys(candidate + specified))
  140. random.shuffle(all_ids)
  141. return all_ids
  142. def _get_lowest_price(db: Session, goods_id: str) -> Optional[Decimal]:
  143. """获取饰品当前在售最低价格。"""
  144. sql = text(
  145. "SELECT MIN(`当前在售最低价格`) FROM buff_goods_filtered_view WHERE `BUFF饰品ID` = :gid"
  146. )
  147. row = db.execute(sql, {"gid": goods_id}).fetchone()
  148. if row and row[0] is not None:
  149. return Decimal(str(row[0]))
  150. return None
  151. def _get_wear_ranges(db: Session, goods_id: str) -> List[Tuple[Decimal, Decimal, Decimal]]:
  152. """获取磨损区间列表:(最小磨损值, 最大磨损值, 区间底价),按最小磨损值升序。"""
  153. sql = text(
  154. "SELECT `最小磨损值`, `最大磨损值`, `区间底价` FROM buff_goods_filtered_view "
  155. "WHERE `BUFF饰品ID` = :gid ORDER BY `最小磨损值` ASC"
  156. )
  157. rows = db.execute(sql, {"gid": goods_id}).fetchall()
  158. out = []
  159. for r in rows:
  160. if r[0] is not None and r[1] is not None and r[2] is not None:
  161. out.append((Decimal(str(r[0])), Decimal(str(r[1])), Decimal(str(r[2]))))
  162. return out
  163. def _get_ai_price_avgs(db: Session, goods_id: str) -> Tuple[Optional[Decimal], Optional[Decimal]]:
  164. """获取 7天/30天在售最低价格平均价。"""
  165. sql = text(
  166. "SELECT `7天在售最低价格平均价`, `30天在售最低价格平均价` "
  167. "FROM buff_goods_filtered_view WHERE `BUFF饰品ID` = :gid LIMIT 1"
  168. )
  169. row = db.execute(sql, {"gid": goods_id}).fetchone()
  170. if not row:
  171. return None, None
  172. avg7 = Decimal(str(row[0])) if row[0] is not None else None
  173. avg30 = Decimal(str(row[1])) if row[1] is not None else None
  174. return avg7, avg30
  175. def _assistant_check(
  176. db: Session, goods_id: str, assistant_level: str, current_lowest: Decimal
  177. ) -> Tuple[bool, str]:
  178. """
  179. 第二步智能选品判断:
  180. - low: 不限制
  181. - standard: 7天均价相对当前价需在配置涨跌幅范围内
  182. - high: 30天均价相对当前价需在配置涨跌幅范围内
  183. """
  184. level = (assistant_level or "low").lower()
  185. if level == "low":
  186. return True, "通过"
  187. avg7, avg30 = _get_ai_price_avgs(db, goods_id)
  188. if level == "standard":
  189. if avg7 is None:
  190. return False, "7天均价缺失,智能选品标准模式不满足"
  191. inc = Decimal(str(_get_system_config_value(db, "buff_ai_standard_range_max_increase_ratio") or "0.3"))
  192. dec = Decimal(str(_get_system_config_value(db, "buff_ai_standard_range_max_decrease_ratio") or "0.5"))
  193. upper = current_lowest * (Decimal("1") + inc)
  194. lower = current_lowest * (Decimal("1") - dec)
  195. if avg7 > upper or avg7 < lower:
  196. return False, f"7天均价 {avg7} 不在智能选品标准范围 [{lower}, {upper}]"
  197. return True, "通过"
  198. if level == "high":
  199. if avg30 is None:
  200. return False, "30天均价缺失,智能选品高模式不满足"
  201. inc = Decimal(str(_get_system_config_value(db, "buff_ai_high_range_max_increase_ratio") or "0.4"))
  202. dec = Decimal(str(_get_system_config_value(db, "buff_ai_high_range_max_decrease_ratio") or "0.5"))
  203. upper = current_lowest * (Decimal("1") + inc)
  204. lower = current_lowest * (Decimal("1") - dec)
  205. if avg30 > upper or avg30 < lower:
  206. return False, f"30天均价 {avg30} 不在智能选品高范围 [{lower}, {upper}]"
  207. return True, "通过"
  208. # 兜底:未知值按 low 处理,避免阻断任务
  209. return True, "通过"
  210. def _normal_ranges(ranges: List[Tuple[Decimal, Decimal, Decimal]]) -> List[Tuple[Decimal, Decimal, Decimal]]:
  211. """正常磨损区间:区间底价随最小磨损值递增而单调不升。"""
  212. if not ranges:
  213. return []
  214. out = [ranges[0]]
  215. for i in range(1, len(ranges)):
  216. if ranges[i][2] <= out[-1][2]:
  217. out.append(ranges[i])
  218. else:
  219. break
  220. return out
  221. def _qualified_ranges(
  222. normal: List[Tuple[Decimal, Decimal, Decimal]], compare_price: Decimal
  223. ) -> List[Tuple[Decimal, Decimal, Decimal]]:
  224. """满足利润的区间:区间底价 > 比较价,一旦不满足则截断。"""
  225. out = []
  226. for r in normal:
  227. if r[2] > compare_price:
  228. out.append(r)
  229. else:
  230. break
  231. return out
  232. def _get_goods_display(db: Session, goods_id: str) -> Dict[str, Any]:
  233. """从视图取饰品名称、图片、Steam 链接(取一行即可)。"""
  234. sql = text(
  235. "SELECT `BUFF饰品名称`, `饰品图片URL地址`, `饰品STEAM链接` FROM buff_goods_filtered_view "
  236. "WHERE `BUFF饰品ID` = :gid LIMIT 1"
  237. )
  238. row = db.execute(sql, {"gid": goods_id}).fetchone()
  239. if row:
  240. return {
  241. "goods_name": (row[0] or "").strip() or None,
  242. "icon_url": (row[1] or "").strip() or None,
  243. "steam_link": (row[2] or "").strip() or None,
  244. }
  245. return {"goods_name": None, "icon_url": None, "steam_link": None}
  246. def _sweep_factor(db: Session, level: str) -> Decimal:
  247. """扫货价格系数:low->min, standard->mid, high->max。"""
  248. key = {
  249. "low": "buff_sweep_price_factor_min",
  250. "standard": "buff_sweep_price_factor_mid",
  251. "high": "buff_sweep_price_factor_max",
  252. }.get((level or "low").lower(), "buff_sweep_price_factor_min")
  253. val = _get_system_config_value(db, key) or "0"
  254. return Decimal(str(val))
  255. def _append_stage_log(
  256. db: Session,
  257. request_id: int,
  258. user_id: int,
  259. sequence_no: int,
  260. message: str,
  261. ) -> None:
  262. """追加选品阶段日志(检索/过滤/计算等),供前端日志弹窗展示。"""
  263. db.add(BuffSelectionRequestLog(
  264. request_id=request_id,
  265. user_id=user_id,
  266. goods_id="__stage__",
  267. goods_name=None,
  268. sequence_no=sequence_no,
  269. result="stage",
  270. message=message,
  271. ))
  272. db.commit()
  273. def run_selection_task(request_id: int, user_id: int, config_id: Optional[int]) -> None:
  274. """
  275. 后台选品任务:更新状态为 running,执行 step1 + step2,最后更新为 completed/failed。
  276. 使用独立 db 会话,在请求返回后执行。
  277. """
  278. db = SessionLocal()
  279. try:
  280. req = db.query(BuffSelectionRequest).filter(
  281. BuffSelectionRequest.id == request_id,
  282. BuffSelectionRequest.user_id == user_id,
  283. ).first()
  284. if not req:
  285. logger.warning("选品请求单不存在或非本人: request_id=%s user_id=%s", request_id, user_id)
  286. return
  287. if req.status != "pending":
  288. logger.info("选品请求单已非 pending,跳过: request_id=%s status=%s", request_id, req.status)
  289. return
  290. req.status = "running"
  291. db.commit()
  292. # 处理竞态:如果在 pending->running 切换窗口已收到停止请求,立即结束任务
  293. db.refresh(req)
  294. if getattr(req, "cancel_requested", False):
  295. logger.info("选品在启动阶段被用户请求停止: request_id=%s", request_id)
  296. req.status = "cancelled"
  297. db.commit()
  298. return
  299. config = {}
  300. if config_id:
  301. cfg_row = db.query(BuffSelectionConfig).filter(
  302. BuffSelectionConfig.id == config_id,
  303. BuffSelectionConfig.user_id == user_id,
  304. BuffSelectionConfig.is_deleted == False,
  305. ).first()
  306. if cfg_row and cfg_row.config_json:
  307. try:
  308. config = json.loads(cfg_row.config_json)
  309. except Exception:
  310. pass
  311. if not config:
  312. logger.warning("选品配置为空,请求单标记为失败: request_id=%s", request_id)
  313. req.status = "failed"
  314. db.commit()
  315. return
  316. profit_rate = config.get("profitRate")
  317. if profit_rate is None:
  318. profit_rate = 0
  319. profit_rate = float(profit_rate) / 100.0 # 百分比转小数
  320. steam_card_ratio = config.get("steamCardRatio")
  321. if steam_card_ratio is None or float(steam_card_ratio) <= 0:
  322. steam_card_ratio = 1.0
  323. else:
  324. steam_card_ratio = float(steam_card_ratio)
  325. selection_quantity = config.get("selectionQuantity")
  326. if selection_quantity is None:
  327. selection_quantity = 0
  328. selection_quantity = int(selection_quantity)
  329. assistant_level = (config.get("assistantLevel") or "low").lower()
  330. # BUFF 价格区间兜底校验:即使 step1 候选因数据异常混入,也在 step2 最终入选前拦截
  331. raw_price_min = config.get("buffPriceMin")
  332. raw_price_max = config.get("buffPriceMax")
  333. try:
  334. buff_price_min = Decimal(str(raw_price_min)) if raw_price_min is not None else None
  335. except Exception:
  336. buff_price_min = None
  337. try:
  338. buff_price_max = Decimal(str(raw_price_max)) if raw_price_max is not None else None
  339. except Exception:
  340. buff_price_max = None
  341. # 每日选品条数上限(system_config),选品过程中达到则本单正常结束
  342. today_start = beijing_now_naive().replace(hour=0, minute=0, second=0, microsecond=0)
  343. items_limit_daily = _get_system_config_int(db, "buff_selection_daily_items_limit_per_user", 900)
  344. sweep_level = (config.get("sweepAssistantLevel") or config.get("assistantLevel") or "low").lower()
  345. sweep_factor = _sweep_factor(db, sweep_level)
  346. # 第一步:检索候选
  347. _append_stage_log(db, request_id, user_id, 0, "检索阶段:开始根据条件检索候选饰品")
  348. candidate_ids = step1_candidate_ids(db, config)
  349. _append_stage_log(db, request_id, user_id, 1, f"检索阶段:完成,共检索到 {len(candidate_ids)} 个候选饰品")
  350. _append_stage_log(db, request_id, user_id, 2, "过滤阶段:开始执行价格、智能选品与利润过滤")
  351. _append_stage_log(db, request_id, user_id, 3, "计算阶段:开始逐个计算区间与目标价格")
  352. selected_count = 0
  353. for seq, goods_id in enumerate(candidate_ids, start=1):
  354. # 用户请求停止:重新加载请求单并检查 cancel_requested
  355. db.refresh(req)
  356. if getattr(req, "cancel_requested", False):
  357. logger.info("选品被用户请求停止: request_id=%s", request_id)
  358. req.status = "cancelled"
  359. db.commit()
  360. break
  361. # 本单选品数量达标则正常结束
  362. if selection_quantity > 0 and selected_count >= selection_quantity:
  363. break
  364. # 当日选品总条数已达 system_config 上限则正常结束(不再为本单继续选)
  365. today_items = db.query(BuffSelectionRequestItem).filter(
  366. BuffSelectionRequestItem.user_id == user_id,
  367. BuffSelectionRequestItem.created_at >= today_start,
  368. ).count()
  369. if today_items >= items_limit_daily:
  370. logger.info("选品过程达到每日条数上限,正常结束: request_id=%s items_limit=%s", request_id, items_limit_daily)
  371. break
  372. lowest = _get_lowest_price(db, goods_id)
  373. if lowest is None or lowest <= 0:
  374. log_msg = "无在售最低价"
  375. db.add(BuffSelectionRequestLog(
  376. request_id=request_id,
  377. user_id=user_id,
  378. goods_id=goods_id,
  379. goods_name=None,
  380. sequence_no=seq,
  381. result="not_selected",
  382. message=log_msg,
  383. ))
  384. db.commit()
  385. continue
  386. if buff_price_min is not None and lowest < buff_price_min:
  387. display = _get_goods_display(db, goods_id)
  388. log_msg = f"当前在售最低价 {lowest} 低于设置下限 {buff_price_min}"
  389. db.add(BuffSelectionRequestLog(
  390. request_id=request_id,
  391. user_id=user_id,
  392. goods_id=goods_id,
  393. goods_name=display.get("goods_name"),
  394. sequence_no=seq,
  395. result="not_selected",
  396. message=log_msg,
  397. ))
  398. db.commit()
  399. continue
  400. if buff_price_max is not None and lowest > buff_price_max:
  401. display = _get_goods_display(db, goods_id)
  402. log_msg = f"当前在售最低价 {lowest} 高于设置上限 {buff_price_max}"
  403. db.add(BuffSelectionRequestLog(
  404. request_id=request_id,
  405. user_id=user_id,
  406. goods_id=goods_id,
  407. goods_name=display.get("goods_name"),
  408. sequence_no=seq,
  409. result="not_selected",
  410. message=log_msg,
  411. ))
  412. db.commit()
  413. continue
  414. assistant_ok, assistant_msg = _assistant_check(db, goods_id, assistant_level, lowest)
  415. if not assistant_ok:
  416. display = _get_goods_display(db, goods_id)
  417. db.add(BuffSelectionRequestLog(
  418. request_id=request_id,
  419. user_id=user_id,
  420. goods_id=goods_id,
  421. goods_name=display.get("goods_name"),
  422. sequence_no=seq,
  423. result="not_selected",
  424. message=assistant_msg,
  425. ))
  426. db.commit()
  427. continue
  428. compare_price = lowest * (Decimal("1") + Decimal(str(profit_rate)))
  429. ranges = _get_wear_ranges(db, goods_id)
  430. normal = _normal_ranges(ranges)
  431. qualified = _qualified_ranges(normal, compare_price)
  432. if not qualified:
  433. log_msg = "无满足利润的磨损区间"
  434. display = _get_goods_display(db, goods_id)
  435. db.add(BuffSelectionRequestLog(
  436. request_id=request_id,
  437. user_id=user_id,
  438. goods_id=goods_id,
  439. goods_name=display.get("goods_name"),
  440. sequence_no=seq,
  441. result="not_selected",
  442. message=log_msg,
  443. ))
  444. db.commit()
  445. continue
  446. # 入选
  447. display = _get_goods_display(db, goods_id)
  448. min_min = min(r[0] for r in qualified)
  449. max_max = max(r[1] for r in qualified)
  450. wear_range_str = f"{min_min}-{max_max}"
  451. # 扫货价 = 商品最低价 × (1 + 扫货系数)
  452. price_cny = lowest * (Decimal("1") + sweep_factor)
  453. price_usd_raw = float(price_cny) / steam_card_ratio
  454. price_usd = Decimal(str(round(price_usd_raw, 2))) + Decimal("0.01")
  455. db.add(BuffSelectionRequestItem(
  456. request_id=request_id,
  457. user_id=user_id,
  458. goods_id=goods_id,
  459. goods_name=display.get("goods_name"),
  460. icon_url=display.get("icon_url"),
  461. steam_link=display.get("steam_link"),
  462. price_cny=price_cny,
  463. price_usd=price_usd,
  464. wear_range=wear_range_str,
  465. ))
  466. db.add(BuffSelectionRequestLog(
  467. request_id=request_id,
  468. user_id=user_id,
  469. goods_id=goods_id,
  470. goods_name=display.get("goods_name"),
  471. sequence_no=seq,
  472. result="selected",
  473. message="已入选",
  474. wear_range=wear_range_str,
  475. price_cny=price_cny,
  476. price_usd=price_usd,
  477. ))
  478. db.commit()
  479. selected_count += 1
  480. _append_stage_log(db, request_id, user_id, 9999998, f"计算阶段:结束,共入选 {selected_count} 个饰品")
  481. if req.status != "cancelled":
  482. req.status = "completed"
  483. db.commit()
  484. _append_stage_log(db, request_id, user_id, 9999999, f"选品任务结束,状态:{req.status}")
  485. logger.info("选品完成: request_id=%s selected=%s", request_id, selected_count)
  486. except Exception as e:
  487. logger.exception("选品任务异常: request_id=%s %s", request_id, e)
  488. try:
  489. req = db.query(BuffSelectionRequest).filter(
  490. BuffSelectionRequest.id == request_id,
  491. BuffSelectionRequest.user_id == user_id,
  492. ).first()
  493. if req:
  494. req.status = "failed"
  495. db.commit()
  496. except Exception:
  497. db.rollback()
  498. finally:
  499. db.rollback()
  500. finally:
  501. db.close()