buff_selection_service.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  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. # 智能选品助手(低=1=1;标准=7天;高=30天)
  130. assistant = (config.get("assistantLevel") or "low").lower()
  131. if assistant == "standard":
  132. inc = _get_system_config_value(db, "buff_ai_standard_range_max_increase_ratio") or "0.3"
  133. dec = _get_system_config_value(db, "buff_ai_standard_range_max_decrease_ratio") or "0.5"
  134. conditions.append(
  135. "`7天在售最低价格平均价` <= `当前在售最低价格` * (1 + :ai_std_inc) "
  136. "AND `7天在售最低价格平均价` >= `当前在售最低价格` * (1 - :ai_std_dec)"
  137. )
  138. params["ai_std_inc"] = float(inc)
  139. params["ai_std_dec"] = float(dec)
  140. elif assistant == "high":
  141. inc = _get_system_config_value(db, "buff_ai_high_range_max_increase_ratio") or "0.4"
  142. dec = _get_system_config_value(db, "buff_ai_high_range_max_decrease_ratio") or "0.5"
  143. conditions.append(
  144. "`30天在售最低价格平均价` <= `当前在售最低价格` * (1 + :ai_high_inc) "
  145. "AND `30天在售最低价格平均价` >= `当前在售最低价格` * (1 - :ai_high_dec)"
  146. )
  147. params["ai_high_inc"] = float(inc)
  148. params["ai_high_dec"] = float(dec)
  149. where_sql = " AND ".join(conditions)
  150. sql = f"SELECT DISTINCT `BUFF饰品ID` FROM buff_goods_filtered_view WHERE {where_sql}"
  151. try:
  152. rows = db.execute(text(sql), params).fetchall()
  153. candidate = [str(r[0]) for r in rows if r[0]]
  154. except Exception as e:
  155. logger.exception("step1 执行主查询异常: %s", e)
  156. candidate = []
  157. # 指定饰品 ID 合并去重
  158. specified = _parse_ids_field(config.get("specifiedIds"))
  159. all_ids = list(dict.fromkeys(candidate + specified))
  160. random.shuffle(all_ids)
  161. return all_ids
  162. def _get_lowest_price(db: Session, goods_id: str) -> Optional[Decimal]:
  163. """获取饰品当前在售最低价格。"""
  164. sql = text(
  165. "SELECT DISTINCT `当前在售最低价格` FROM buff_goods_filtered_view WHERE `BUFF饰品ID` = :gid"
  166. )
  167. row = db.execute(sql, {"gid": goods_id}).fetchone()
  168. if row and row[0] is not None:
  169. return Decimal(str(row[0]))
  170. return None
  171. def _get_wear_ranges(db: Session, goods_id: str) -> List[Tuple[Decimal, Decimal, Decimal]]:
  172. """获取磨损区间列表:(最小磨损值, 最大磨损值, 区间底价),按最小磨损值升序。"""
  173. sql = text(
  174. "SELECT `最小磨损值`, `最大磨损值`, `区间底价` FROM buff_goods_filtered_view "
  175. "WHERE `BUFF饰品ID` = :gid ORDER BY `最小磨损值` ASC"
  176. )
  177. rows = db.execute(sql, {"gid": goods_id}).fetchall()
  178. out = []
  179. for r in rows:
  180. if r[0] is not None and r[1] is not None and r[2] is not None:
  181. out.append((Decimal(str(r[0])), Decimal(str(r[1])), Decimal(str(r[2]))))
  182. return out
  183. def _normal_ranges(ranges: List[Tuple[Decimal, Decimal, Decimal]]) -> List[Tuple[Decimal, Decimal, Decimal]]:
  184. """正常磨损区间:区间底价随最小磨损值递增而单调不升。"""
  185. if not ranges:
  186. return []
  187. out = [ranges[0]]
  188. for i in range(1, len(ranges)):
  189. if ranges[i][2] <= out[-1][2]:
  190. out.append(ranges[i])
  191. else:
  192. break
  193. return out
  194. def _qualified_ranges(
  195. normal: List[Tuple[Decimal, Decimal, Decimal]], compare_price: Decimal
  196. ) -> List[Tuple[Decimal, Decimal, Decimal]]:
  197. """满足利润的区间:区间底价 > 比较价,一旦不满足则截断。"""
  198. out = []
  199. for r in normal:
  200. if r[2] > compare_price:
  201. out.append(r)
  202. else:
  203. break
  204. return out
  205. def _get_goods_display(db: Session, goods_id: str) -> Dict[str, Any]:
  206. """从视图取饰品名称、图片、Steam 链接(取一行即可)。"""
  207. sql = text(
  208. "SELECT `BUFF饰品名称`, `饰品图片URL地址`, `饰品STEAM链接` FROM buff_goods_filtered_view "
  209. "WHERE `BUFF饰品ID` = :gid LIMIT 1"
  210. )
  211. row = db.execute(sql, {"gid": goods_id}).fetchone()
  212. if row:
  213. return {
  214. "goods_name": (row[0] or "").strip() or None,
  215. "icon_url": (row[1] or "").strip() or None,
  216. "steam_link": (row[2] or "").strip() or None,
  217. }
  218. return {"goods_name": None, "icon_url": None, "steam_link": None}
  219. def _sweep_factor(db: Session, level: str) -> Decimal:
  220. """扫货价格系数:low->min, standard->mid, high->max。"""
  221. key = {
  222. "low": "buff_sweep_price_factor_min",
  223. "standard": "buff_sweep_price_factor_mid",
  224. "high": "buff_sweep_price_factor_max",
  225. }.get((level or "low").lower(), "buff_sweep_price_factor_min")
  226. val = _get_system_config_value(db, key) or "0"
  227. return Decimal(str(val))
  228. def run_selection_task(request_id: int, user_id: int, config_id: Optional[int]) -> None:
  229. """
  230. 后台选品任务:更新状态为 running,执行 step1 + step2,最后更新为 completed/failed。
  231. 使用独立 db 会话,在请求返回后执行。
  232. """
  233. db = SessionLocal()
  234. try:
  235. req = db.query(BuffSelectionRequest).filter(
  236. BuffSelectionRequest.id == request_id,
  237. BuffSelectionRequest.user_id == user_id,
  238. ).first()
  239. if not req:
  240. logger.warning("选品请求单不存在或非本人: request_id=%s user_id=%s", request_id, user_id)
  241. return
  242. if req.status != "pending":
  243. logger.info("选品请求单已非 pending,跳过: request_id=%s status=%s", request_id, req.status)
  244. return
  245. req.status = "running"
  246. db.commit()
  247. config = {}
  248. if config_id:
  249. cfg_row = db.query(BuffSelectionConfig).filter(
  250. BuffSelectionConfig.id == config_id,
  251. BuffSelectionConfig.user_id == user_id,
  252. BuffSelectionConfig.is_deleted == False,
  253. ).first()
  254. if cfg_row and cfg_row.config_json:
  255. try:
  256. config = json.loads(cfg_row.config_json)
  257. except Exception:
  258. pass
  259. if not config:
  260. logger.warning("选品配置为空,请求单标记为失败: request_id=%s", request_id)
  261. req.status = "failed"
  262. db.commit()
  263. return
  264. profit_rate = config.get("profitRate")
  265. if profit_rate is None:
  266. profit_rate = 0
  267. profit_rate = float(profit_rate) / 100.0 # 百分比转小数
  268. steam_card_ratio = config.get("steamCardRatio")
  269. if steam_card_ratio is None or float(steam_card_ratio) <= 0:
  270. steam_card_ratio = 1.0
  271. else:
  272. steam_card_ratio = float(steam_card_ratio)
  273. selection_quantity = config.get("selectionQuantity")
  274. if selection_quantity is None:
  275. selection_quantity = 0
  276. selection_quantity = int(selection_quantity)
  277. # 每日选品条数上限(system_config),选品过程中达到则本单正常结束
  278. today_start = beijing_now_naive().replace(hour=0, minute=0, second=0, microsecond=0)
  279. items_limit_daily = _get_system_config_int(db, "buff_selection_daily_items_limit_per_user", 900)
  280. sweep_level = (config.get("sweepAssistantLevel") or config.get("assistantLevel") or "low").lower()
  281. sweep_factor = _sweep_factor(db, sweep_level)
  282. # 第一步
  283. candidate_ids = step1_candidate_ids(db, config)
  284. selected_count = 0
  285. for seq, goods_id in enumerate(candidate_ids, start=1):
  286. # 用户请求停止:重新加载请求单并检查 cancel_requested
  287. db.refresh(req)
  288. if getattr(req, "cancel_requested", False):
  289. logger.info("选品被用户请求停止: request_id=%s", request_id)
  290. req.status = "cancelled"
  291. db.commit()
  292. break
  293. # 本单选品数量达标则正常结束
  294. if selection_quantity > 0 and selected_count >= selection_quantity:
  295. break
  296. # 当日选品总条数已达 system_config 上限则正常结束(不再为本单继续选)
  297. today_items = db.query(BuffSelectionRequestItem).filter(
  298. BuffSelectionRequestItem.user_id == user_id,
  299. BuffSelectionRequestItem.created_at >= today_start,
  300. ).count()
  301. if today_items >= items_limit_daily:
  302. logger.info("选品过程达到每日条数上限,正常结束: request_id=%s items_limit=%s", request_id, items_limit_daily)
  303. break
  304. lowest = _get_lowest_price(db, goods_id)
  305. if lowest is None or lowest <= 0:
  306. log_msg = "无在售最低价"
  307. db.add(BuffSelectionRequestLog(
  308. request_id=request_id,
  309. user_id=user_id,
  310. goods_id=goods_id,
  311. goods_name=None,
  312. sequence_no=seq,
  313. result="not_selected",
  314. message=log_msg,
  315. ))
  316. db.commit()
  317. continue
  318. compare_price = lowest * (Decimal("1") + Decimal(str(profit_rate)))
  319. ranges = _get_wear_ranges(db, goods_id)
  320. normal = _normal_ranges(ranges)
  321. qualified = _qualified_ranges(normal, compare_price)
  322. if not qualified:
  323. log_msg = "无满足利润的磨损区间"
  324. display = _get_goods_display(db, goods_id)
  325. db.add(BuffSelectionRequestLog(
  326. request_id=request_id,
  327. user_id=user_id,
  328. goods_id=goods_id,
  329. goods_name=display.get("goods_name"),
  330. sequence_no=seq,
  331. result="not_selected",
  332. message=log_msg,
  333. ))
  334. db.commit()
  335. continue
  336. # 入选
  337. display = _get_goods_display(db, goods_id)
  338. min_min = min(r[0] for r in qualified)
  339. max_max = max(r[1] for r in qualified)
  340. wear_range_str = f"{min_min}-{max_max}"
  341. price_cny = compare_price * (Decimal("1") + sweep_factor)
  342. price_usd_raw = float(price_cny) / steam_card_ratio
  343. price_usd = Decimal(str(round(price_usd_raw, 2))) + Decimal("0.01")
  344. db.add(BuffSelectionRequestItem(
  345. request_id=request_id,
  346. user_id=user_id,
  347. goods_id=goods_id,
  348. goods_name=display.get("goods_name"),
  349. icon_url=display.get("icon_url"),
  350. steam_link=display.get("steam_link"),
  351. price_cny=price_cny,
  352. price_usd=price_usd,
  353. wear_range=wear_range_str,
  354. ))
  355. db.add(BuffSelectionRequestLog(
  356. request_id=request_id,
  357. user_id=user_id,
  358. goods_id=goods_id,
  359. goods_name=display.get("goods_name"),
  360. sequence_no=seq,
  361. result="selected",
  362. message="已入选",
  363. ))
  364. db.commit()
  365. selected_count += 1
  366. req.status = "completed"
  367. db.commit()
  368. logger.info("选品完成: request_id=%s selected=%s", request_id, selected_count)
  369. except Exception as e:
  370. logger.exception("选品任务异常: request_id=%s %s", request_id, e)
  371. try:
  372. req = db.query(BuffSelectionRequest).filter(
  373. BuffSelectionRequest.id == request_id,
  374. BuffSelectionRequest.user_id == user_id,
  375. ).first()
  376. if req:
  377. req.status = "failed"
  378. db.commit()
  379. except Exception:
  380. db.rollback()
  381. finally:
  382. db.rollback()
  383. finally:
  384. db.close()