sync_goods.py 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. BUFF饰品价格数据同步脚本(优化版:累加存储 + 实时计算平均价)
  5. 功能:
  6. 1. 请求buff接口获取饰品价格数据(最低售价、最高求购价)
  7. 2. 正确处理steam_url生成
  8. 3. 使用高性能批量插入技术处理数据
  9. 4. 维护近50天的价格数据(每饰品每天1条,累加sum/cnt)
  10. 5. 每次同步完成后直接计算7天和30天的平均价格并同步到 goods_info 表
  11. 使用方法:
  12. python sync_goods.py # 执行一次同步(周期调度请使用 run_buff_sync_loop.sh)
  13. 作者: 游铛铛技术数字员工
  14. 创建时间: 2026-01-28
  15. 更新时间: 2026-04-08
  16. """
  17. import os
  18. import sys
  19. import time
  20. import logging
  21. import json
  22. import requests
  23. import pymysql
  24. from sync_db_errors import is_database_connection_error
  25. from datetime import datetime, timedelta
  26. from typing import List, Dict, Any, Optional, Tuple
  27. import urllib.parse
  28. # ============================================================
  29. # 配置信息
  30. # ============================================================
  31. # BUFF API配置
  32. BUFF_API_URL = "https://buff.163.com/api/market/enterprise/items"
  33. BUFF_API_TOKEN = "c4a1ebaf64d64d1a9a320bc2fcfa1f7e"
  34. # 数据库配置(从环境变量读取)
  35. DB_HOST = os.getenv("DB_HOST", "47.99.164.66")
  36. DB_PORT = int(os.getenv("DB_PORT", "3306"))
  37. DB_USER = os.getenv("DB_USER", "root")
  38. DB_PASSWORD = os.getenv("DB_PASSWORD", "hn02le.34lkdLKD")
  39. DB_NAME = os.getenv("DB_NAME", "buff")
  40. # 大批量 INSERT/SELECT 可能远超 30s;过短会触发 2013 Lost connection(客户端读超时)
  41. DB_READ_TIMEOUT = int(os.getenv("DB_READ_TIMEOUT", "1800"))
  42. DB_WRITE_TIMEOUT = int(os.getenv("DB_WRITE_TIMEOUT", "1800"))
  43. DB_BULK_INSERT_RETRIES = int(os.getenv("DB_BULK_INSERT_RETRIES", "3"))
  44. # Steam社区市场基础URL
  45. STEAM_MARKET_BASE_URL = "https://steamcommunity.com/market/listings/730/"
  46. # 请求配置
  47. REQUEST_TIMEOUT = 60
  48. BATCH_SIZE = int(os.getenv("BULK_INSERT_BATCH_SIZE", "5000"))
  49. MAX_RETRIES = 3
  50. RETRY_DELAY = 5
  51. # 数据保留天数
  52. PRICE_RETENTION_DAYS = 50
  53. # 平均价格计算批次大小
  54. AVG_PRICE_BATCH_SIZE = 10000 # 增加批次大小
  55. # ============================================================
  56. # 配置日志
  57. # ============================================================
  58. def setup_logging():
  59. log_dir = os.path.dirname(os.path.abspath(__file__))
  60. log_file = os.path.join(log_dir, "logs", f"sync_price_{datetime.now().strftime('%Y%m%d')}.log")
  61. os.makedirs(os.path.dirname(log_file), exist_ok=True)
  62. logging.basicConfig(
  63. level=logging.INFO,
  64. format='%(asctime)s - %(levelname)s - %(message)s',
  65. handlers=[
  66. logging.StreamHandler(sys.stdout),
  67. logging.FileHandler(log_file, encoding='utf-8')
  68. ]
  69. )
  70. return logging.getLogger(__name__)
  71. logger = setup_logging()
  72. # ============================================================
  73. # 数据库操作类(修正清空表逻辑)
  74. # ============================================================
  75. class PriceDatabase:
  76. """价格数据库操作类(修正清空表逻辑)"""
  77. def __init__(self):
  78. self.connection = None
  79. self._connect()
  80. def _connect(self):
  81. """建立数据库连接,优化连接参数"""
  82. try:
  83. self.connection = pymysql.connect(
  84. host=DB_HOST,
  85. port=DB_PORT,
  86. user=DB_USER,
  87. password=DB_PASSWORD,
  88. database=DB_NAME,
  89. charset='utf8mb4',
  90. cursorclass=pymysql.cursors.DictCursor,
  91. autocommit=False,
  92. connect_timeout=10,
  93. read_timeout=DB_READ_TIMEOUT,
  94. write_timeout=DB_WRITE_TIMEOUT,
  95. )
  96. logger.info(
  97. f"数据库连接成功: {DB_HOST}:{DB_PORT}/{DB_NAME} "
  98. f"(read_timeout={DB_READ_TIMEOUT}s, write_timeout={DB_WRITE_TIMEOUT}s)"
  99. )
  100. except Exception as e:
  101. logger.error(f"数据库连接失败: {e}")
  102. raise
  103. def _reconnect(self):
  104. try:
  105. if self.connection:
  106. self.connection.close()
  107. except Exception:
  108. pass
  109. self.connection = None
  110. self._connect()
  111. @staticmethod
  112. def _is_transient_db_error(exc: BaseException) -> bool:
  113. if not isinstance(exc, pymysql.err.OperationalError):
  114. return False
  115. code = exc.args[0] if exc.args else 0
  116. return code in (2006, 2013, 2014, 1053, 2003)
  117. def close(self):
  118. if self.connection and self.connection.open:
  119. self.connection.close()
  120. logger.info("数据库连接已关闭")
  121. def _safe_rollback(self) -> None:
  122. """连接已断开时 rollback 会抛 InterfaceError,避免二次异常掩盖原始错误。"""
  123. try:
  124. if self.connection and getattr(self.connection, "open", False):
  125. self.connection.rollback()
  126. except Exception as ex:
  127. logger.warning(f"回滚跳过(连接可能已断开): {ex}")
  128. def clear_table(self, table_name: str):
  129. """清空表(使用TRUNCATE优化)"""
  130. try:
  131. with self.connection.cursor() as cursor:
  132. # 使用TRUNCATE比DELETE更快
  133. cursor.execute(f"TRUNCATE TABLE {table_name}")
  134. self.connection.commit()
  135. logger.info(f"已清空 {table_name} 表")
  136. except Exception as e:
  137. # 如果TRUNCATE失败,回退到DELETE
  138. logger.warning(f"TRUNCATE失败,使用DELETE: {e}")
  139. try:
  140. with self.connection.cursor() as cursor:
  141. cursor.execute(f"DELETE FROM {table_name}")
  142. self.connection.commit()
  143. logger.info(f"已清空 {table_name} 表")
  144. except Exception as e2:
  145. logger.error(f"清空表失败: {e2}")
  146. raise
  147. def _bulk_insert_one_batch(
  148. self,
  149. cursor,
  150. sql: str,
  151. batch: List[Dict[str, Any]],
  152. columns: List[str],
  153. ) -> None:
  154. values = []
  155. for item in batch:
  156. row_values = []
  157. for col in columns:
  158. value = item.get(col)
  159. if value is None:
  160. row_values.append(None)
  161. elif isinstance(value, datetime):
  162. row_values.append(value.strftime('%Y-%m-%d %H:%M:%S'))
  163. elif isinstance(value, (int, float)):
  164. row_values.append(value)
  165. else:
  166. row_values.append(str(value))
  167. values.append(tuple(row_values))
  168. cursor.executemany(sql, values)
  169. def _safe_restore_session_defaults(self, cursor) -> None:
  170. if not self.connection:
  171. return
  172. try:
  173. if getattr(self.connection, "open", False):
  174. cursor.execute("SET SESSION autocommit = 1")
  175. cursor.execute("SET SESSION unique_checks = 1")
  176. cursor.execute("SET SESSION foreign_key_checks = 1")
  177. except Exception as e:
  178. logger.warning(f"恢复会话设置失败(连接可能已断开),可忽略: {e}")
  179. def bulk_insert(self, table_name: str, data_list: List[Dict[str, Any]],
  180. columns: List[str]) -> int:
  181. """高性能批量插入;断连类错误自动重连并重试当前批。"""
  182. if not data_list:
  183. return 0
  184. column_list = ', '.join([f'`{col}`' for col in columns])
  185. placeholders = ', '.join(['%s'] * len(columns))
  186. sql = f"INSERT INTO `{table_name}` ({column_list}) VALUES ({placeholders})"
  187. total_inserted = 0
  188. cursor = self.connection.cursor()
  189. cursor.execute("SET SESSION autocommit = 0")
  190. cursor.execute("SET SESSION unique_checks = 0")
  191. cursor.execute("SET SESSION foreign_key_checks = 0")
  192. try:
  193. for i in range(0, len(data_list), BATCH_SIZE):
  194. batch = data_list[i:i + BATCH_SIZE]
  195. attempt = 0
  196. while True:
  197. try:
  198. self._bulk_insert_one_batch(cursor, sql, batch, columns)
  199. break
  200. except pymysql.err.OperationalError as e:
  201. attempt += 1
  202. if not self._is_transient_db_error(e) or attempt > DB_BULK_INSERT_RETRIES:
  203. raise
  204. logger.warning(
  205. "批量插入单批失败 (%s/%s),将重连后重试当前批: %s",
  206. attempt,
  207. DB_BULK_INSERT_RETRIES,
  208. e,
  209. )
  210. time.sleep(RETRY_DELAY * attempt)
  211. self._reconnect()
  212. cursor = self.connection.cursor()
  213. cursor.execute("SET SESSION autocommit = 0")
  214. cursor.execute("SET SESSION unique_checks = 0")
  215. cursor.execute("SET SESSION foreign_key_checks = 0")
  216. total_inserted += len(batch)
  217. self.connection.commit()
  218. if i > 0 and i % (BATCH_SIZE * 3) == 0:
  219. logger.info(f"批量插入进度: {total_inserted}/{len(data_list)}")
  220. finally:
  221. self._safe_restore_session_defaults(cursor)
  222. try:
  223. cursor.close()
  224. except Exception:
  225. pass
  226. return total_inserted
  227. def insert_daily_price_middle(self, data_list: List[Dict[str, Any]]) -> int:
  228. """批量插入数据到buff_goods_daily_price_middle表"""
  229. if not data_list:
  230. return 0
  231. # 定义列和对应的数据字段
  232. columns = ['goods_id', 'name', 'min_price', 'buy_max_price', 'stat_time', 'create_time']
  233. # 准备数据
  234. now = datetime.now()
  235. processed_data = []
  236. logger.info(f"开始准备 {len(data_list)} 条无tag价格数据...")
  237. start_time = time.time()
  238. for item in data_list:
  239. processed_data.append({
  240. 'goods_id': str(item['goods_id']),
  241. 'name': item['name'],
  242. 'min_price': float(item['min_price']) if item['min_price'] else 0.00,
  243. 'buy_max_price': float(item['buy_max_price']) if item['buy_max_price'] else 0.00,
  244. 'stat_time': datetime.fromtimestamp(item['stat_time']),
  245. 'create_time': now
  246. })
  247. prep_time = time.time() - start_time
  248. logger.info(f"数据准备完成,耗时: {prep_time:.2f}秒")
  249. logger.info(f"开始插入 {len(processed_data)} 条数据到buff_goods_daily_price_middle...")
  250. insert_start = time.time()
  251. result = self.bulk_insert('buff_goods_daily_price_middle', processed_data, columns)
  252. insert_time = time.time() - insert_start
  253. logger.info(f"插入完成: {result} 条,耗时: {insert_time:.2f}秒")
  254. return result
  255. def copy_from_middle_to_daily_price(self) -> int:
  256. """将中间表数据累加到 buff_goods_daily_price 表(按日期聚合)
  257. 逻辑:
  258. 1. 按 goods_id 和日期分组,累加 sum 和 cnt
  259. 2. 使用 ON DUPLICATE KEY UPDATE 实现累加
  260. 3. 同时计算当天的平均价 min_price_avg
  261. """
  262. try:
  263. with self.connection.cursor() as cursor:
  264. sql = """
  265. INSERT INTO buff_goods_daily_price
  266. (goods_id, stat_date, name, min_price, min_price_sum, min_price_cnt,
  267. buy_max_price, buy_max_price_sum, buy_max_price_cnt,
  268. min_price_avg, buy_max_price_avg,
  269. create_time, update_time)
  270. SELECT
  271. goods_id,
  272. DATE(stat_time) as stat_date,
  273. MAX(name) as name,
  274. MIN(min_price) as min_price,
  275. SUM(min_price) as min_price_sum,
  276. COUNT(*) as min_price_cnt,
  277. MAX(buy_max_price) as buy_max_price,
  278. SUM(COALESCE(buy_max_price, 0)) as buy_max_price_sum,
  279. SUM(CASE WHEN buy_max_price > 0 THEN 1 ELSE 0 END) as buy_max_price_cnt,
  280. ROUND(SUM(min_price) / COUNT(*), 2) as min_price_avg,
  281. ROUND(SUM(COALESCE(buy_max_price, 0)) / NULLIF(SUM(CASE WHEN buy_max_price > 0 THEN 1 ELSE 0 END), 0), 2) as buy_max_price_avg,
  282. NOW() as create_time,
  283. NOW() as update_time
  284. FROM buff_goods_daily_price_middle
  285. GROUP BY goods_id, DATE(stat_time)
  286. ON DUPLICATE KEY UPDATE
  287. name = VALUES(name),
  288. min_price = LEAST(COALESCE(min_price, 999999999), VALUES(min_price)),
  289. min_price_sum = min_price_sum + VALUES(min_price_sum),
  290. min_price_cnt = min_price_cnt + VALUES(min_price_cnt),
  291. min_price_avg = ROUND((min_price_sum + VALUES(min_price_sum)) / (min_price_cnt + VALUES(min_price_cnt)), 2),
  292. buy_max_price = GREATEST(COALESCE(buy_max_price, 0), VALUES(buy_max_price)),
  293. buy_max_price_sum = buy_max_price_sum + VALUES(buy_max_price_sum),
  294. buy_max_price_cnt = buy_max_price_cnt + VALUES(buy_max_price_cnt),
  295. buy_max_price_avg = ROUND((buy_max_price_sum + VALUES(buy_max_price_sum)) / NULLIF((buy_max_price_cnt + VALUES(buy_max_price_cnt)), 0), 2),
  296. update_time = NOW()
  297. """
  298. cursor.execute(sql)
  299. affected_rows = cursor.connection.affected_rows()
  300. self.connection.commit()
  301. logger.info(f"累加到主价格表完成,影响行数: {affected_rows}")
  302. return affected_rows
  303. except Exception as e:
  304. logger.error(f"累加数据失败: {e}")
  305. self._safe_rollback()
  306. return 0
  307. def cleanup_old_price_data(self):
  308. """清理超过50天的价格数据(分批删除,避免锁表)"""
  309. cutoff_date = datetime.now() - timedelta(days=50)
  310. try:
  311. with self.connection.cursor() as cursor:
  312. total_deleted = 0
  313. while True:
  314. sql = """
  315. DELETE FROM buff_goods_daily_price
  316. WHERE stat_date < %s
  317. LIMIT 10000
  318. """
  319. cursor.execute(sql, (cutoff_date.date(),))
  320. batch_deleted = cursor.connection.affected_rows()
  321. total_deleted += batch_deleted
  322. if batch_deleted == 0:
  323. break
  324. self.connection.commit()
  325. time.sleep(0.05)
  326. logger.info(f"已清理 {total_deleted} 条超过50天的旧价格数据")
  327. except Exception as e:
  328. logger.error(f"清理旧数据失败: {e}")
  329. self._safe_rollback()
  330. def calculate_and_sync_avg_prices(self, goods_ids: List[str]) -> int:
  331. """计算7天/30天平均价并同步到 buff_goods_info 表
  332. 这个方法在每次同步完成后调用,直接用1条SQL计算所有饰品的平均价
  333. """
  334. if not goods_ids:
  335. return 0
  336. try:
  337. with self.connection.cursor() as cursor:
  338. # 1. 计算并更新 buff_goods_daily_price 表的 week/month avg
  339. # 7天平均价
  340. cursor.execute("""
  341. UPDATE buff_goods_daily_price d
  342. INNER JOIN (
  343. SELECT
  344. goods_id,
  345. stat_date,
  346. ROUND(SUM(min_price_sum) / SUM(min_price_cnt), 2) as week_avg
  347. FROM buff_goods_daily_price
  348. WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
  349. GROUP BY goods_id, stat_date
  350. ) t ON d.goods_id = t.goods_id AND d.stat_date = t.stat_date
  351. SET d.min_price_week_avg = t.week_avg
  352. """)
  353. # 30天平均价
  354. cursor.execute("""
  355. UPDATE buff_goods_daily_price d
  356. INNER JOIN (
  357. SELECT
  358. goods_id,
  359. stat_date,
  360. ROUND(SUM(min_price_sum) / SUM(min_price_cnt), 2) as month_avg
  361. FROM buff_goods_daily_price
  362. WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
  363. GROUP BY goods_id, stat_date
  364. ) t ON d.goods_id = t.goods_id AND d.stat_date = t.stat_date
  365. SET d.min_price_month_avg = t.month_avg
  366. """)
  367. # 2. 同步到 buff_goods_info 表(直接用1条SQL)
  368. # 7天平均价
  369. sql_7d = """
  370. UPDATE buff_goods_info g
  371. INNER JOIN (
  372. SELECT
  373. goods_id,
  374. ROUND(SUM(min_price_sum) / SUM(min_price_cnt), 2) as price_7d_avg
  375. FROM buff_goods_daily_price
  376. WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
  377. AND min_price_sum > 0 AND min_price_cnt > 0
  378. GROUP BY goods_id
  379. ) t ON g.goods_id = t.goods_id
  380. SET g.d7_avg_min_price = t.price_7d_avg,
  381. g.update_time = NOW()
  382. """
  383. cursor.execute(sql_7d)
  384. affected_7d = cursor.connection.affected_rows()
  385. # 30天平均价
  386. sql_30d = """
  387. UPDATE buff_goods_info g
  388. INNER JOIN (
  389. SELECT
  390. goods_id,
  391. ROUND(SUM(min_price_sum) / SUM(min_price_cnt), 2) as price_30d_avg
  392. FROM buff_goods_daily_price
  393. WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
  394. AND min_price_sum > 0 AND min_price_cnt > 0
  395. GROUP BY goods_id
  396. ) t ON g.goods_id = t.goods_id
  397. SET g.d30_avg_min_price = t.price_30d_avg,
  398. g.update_time = NOW()
  399. """
  400. cursor.execute(sql_30d)
  401. affected_30d = cursor.connection.affected_rows()
  402. self.connection.commit()
  403. logger.info(f"平均价计算并同步完成: 7天={affected_7d}条, 30天={affected_30d}条")
  404. return affected_7d
  405. except Exception as e:
  406. logger.error(f"计算平均价失败: {e}")
  407. self._safe_rollback()
  408. return 0
  409. def insert_goods_info_middle(self, data_list: List[Dict[str, Any]]) -> int:
  410. """批量插入数据到buff_goods_info_middle表"""
  411. if not data_list:
  412. return 0
  413. # 定义列和对应的数据字段
  414. columns = ['goods_id', 'name', 'min_price', 'buy_max_price',
  415. 'd7_avg_min_price', 'd30_avg_min_price',
  416. 'buff_url', 'steam_url', 'stat_time', 'create_time']
  417. # 准备数据
  418. now = datetime.now()
  419. processed_data = []
  420. logger.info(f"开始准备 {len(data_list)} 条商品信息数据...")
  421. start_time = time.time()
  422. for item in data_list:
  423. processed_data.append({
  424. 'goods_id': str(item['goods_id']),
  425. 'name': item['name'],
  426. 'min_price': float(item['min_price']) if item['min_price'] else 0.00,
  427. 'buy_max_price': float(item['buy_max_price']) if item['buy_max_price'] else 0.00,
  428. 'd7_avg_min_price': float(item['avg_7d_min_price']) if item['avg_7d_min_price'] else 0.00,
  429. 'd30_avg_min_price': float(item['avg_30d_min_price']) if item['avg_30d_min_price'] else 0.00,
  430. 'buff_url': item['buff_url'],
  431. 'steam_url': item['steam_url'],
  432. 'stat_time': datetime.fromtimestamp(item['stat_time']),
  433. 'create_time': now
  434. })
  435. prep_time = time.time() - start_time
  436. logger.info(f"商品信息数据准备完成,耗时: {prep_time:.2f}秒")
  437. logger.info(f"开始插入 {len(processed_data)} 条数据到buff_goods_info_middle...")
  438. insert_start = time.time()
  439. result = self.bulk_insert('buff_goods_info_middle', processed_data, columns)
  440. insert_time = time.time() - insert_start
  441. logger.info(f"插入完成: {result} 条,耗时: {insert_time:.2f}秒")
  442. return result
  443. def copy_from_middle_to_goods_info(self) -> int:
  444. """将中间表数据插入到buff_goods_info表(先清空再插入)"""
  445. try:
  446. # 先清空目标表
  447. self.clear_table("buff_goods_info")
  448. with self.connection.cursor() as cursor:
  449. # 使用INSERT INTO(因为已经清空了,不需要REPLACE)
  450. sql = """
  451. INSERT INTO buff_goods_info
  452. (goods_id, name, min_price, buy_max_price, d7_avg_min_price, d30_avg_min_price,
  453. buff_url, steam_url, stat_time, create_time, update_time)
  454. SELECT goods_id, name, min_price, buy_max_price, d7_avg_min_price, d30_avg_min_price,
  455. buff_url, steam_url, stat_time, NOW(), NOW()
  456. FROM buff_goods_info_middle
  457. """
  458. cursor.execute(sql)
  459. affected_rows = cursor.connection.affected_rows()
  460. self.connection.commit()
  461. return affected_rows
  462. except Exception as e:
  463. logger.error(f"复制商品信息失败: {e}")
  464. self._safe_rollback()
  465. return 0
  466. def insert_daily_tag_price_middle(self, data_list: List[Dict[str, Any]]) -> int:
  467. """批量插入数据到buff_goods_daily_tag_price_middle表"""
  468. if not data_list:
  469. return 0
  470. # 定义列和对应的数据字段
  471. columns = ['goods_id', 'name', 'min_price', 'buy_max_price',
  472. 'tag_id', 'tag_name', 'stat_time', 'create_time']
  473. # 准备数据
  474. now = datetime.now()
  475. processed_data = []
  476. logger.info(f"开始准备 {len(data_list)} 条有tag价格数据...")
  477. start_time = time.time()
  478. for item in data_list:
  479. processed_data.append({
  480. 'goods_id': str(item['goods_id']),
  481. 'name': item['name'],
  482. 'min_price': float(item['min_price']) if item['min_price'] else 0.00,
  483. 'buy_max_price': float(item['buy_max_price']) if item['buy_max_price'] else 0.00,
  484. 'tag_id': str(item['tag_id']) if item['tag_id'] else None,
  485. 'tag_name': item['tag_name'],
  486. 'stat_time': datetime.fromtimestamp(item['stat_time']),
  487. 'create_time': now
  488. })
  489. prep_time = time.time() - start_time
  490. logger.info(f"有tag价格数据准备完成,耗时: {prep_time:.2f}秒")
  491. logger.info(f"开始插入 {len(processed_data)} 条数据到buff_goods_daily_tag_price_middle...")
  492. insert_start = time.time()
  493. result = self.bulk_insert('buff_goods_daily_tag_price_middle', processed_data, columns)
  494. insert_time = time.time() - insert_start
  495. logger.info(f"插入完成: {result} 条,耗时: {insert_time:.2f}秒")
  496. return result
  497. def copy_from_middle_to_daily_tag_price(self) -> int:
  498. """将中间表数据插入到buff_goods_daily_tag_price表(先清空再插入)"""
  499. try:
  500. # 先清空目标表
  501. self.clear_table("buff_goods_daily_tag_price")
  502. with self.connection.cursor() as cursor:
  503. # 使用INSERT INTO(因为已经清空了,不需要INSERT IGNORE)
  504. sql = """
  505. INSERT INTO buff_goods_daily_tag_price
  506. (goods_id, name, min_price, buy_max_price, tag_id, tag_name, stat_time, create_time, update_time)
  507. SELECT goods_id, name, min_price, buy_max_price, tag_id, tag_name, stat_time, NOW(), NOW()
  508. FROM buff_goods_daily_tag_price_middle
  509. """
  510. cursor.execute(sql)
  511. affected_rows = cursor.connection.affected_rows()
  512. self.connection.commit()
  513. return affected_rows
  514. except Exception as e:
  515. logger.error(f"复制有tag价格失败: {e}")
  516. self._safe_rollback()
  517. return 0
  518. def calculate_average_prices_batch(self, goods_ids: List[str]) -> Dict[str, Tuple[float, float]]:
  519. """批量计算平均价格,优化性能"""
  520. if not goods_ids:
  521. return {}
  522. results = {}
  523. try:
  524. with self.connection.cursor() as cursor:
  525. # 创建临时表优化查询
  526. cursor.execute("""
  527. CREATE TEMPORARY TABLE IF NOT EXISTS temp_goods_ids (
  528. goods_id VARCHAR(64) PRIMARY KEY
  529. ) ENGINE=MEMORY
  530. """)
  531. # 批量插入goods_id到临时表
  532. for i in range(0, len(goods_ids), 10000):
  533. batch = goods_ids[i:i + 10000]
  534. # 使用VALUES列表批量插入
  535. values_placeholders = ','.join(['(%s)'] * len(batch))
  536. cursor.execute(f"INSERT IGNORE INTO temp_goods_ids (goods_id) VALUES {values_placeholders}", batch)
  537. # 使用临时表进行JOIN查询,大幅提高性能
  538. cursor.execute("""
  539. SELECT
  540. t.goods_id,
  541. COALESCE(ROUND(AVG(CASE
  542. WHEN dp.create_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)
  543. AND dp.min_price > 0
  544. THEN dp.min_price
  545. END), 2), 0.00) as avg_7d,
  546. COALESCE(ROUND(AVG(CASE
  547. WHEN dp.create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
  548. AND dp.min_price > 0
  549. THEN dp.min_price
  550. END), 2), 0.00) as avg_30d
  551. FROM temp_goods_ids t
  552. LEFT JOIN buff_goods_daily_price dp ON t.goods_id = dp.goods_id
  553. WHERE dp.create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
  554. GROUP BY t.goods_id
  555. """)
  556. rows = cursor.fetchall()
  557. for row in rows:
  558. results[row['goods_id']] = (
  559. float(row['avg_7d']),
  560. float(row['avg_30d'])
  561. )
  562. # 清理临时表
  563. cursor.execute("DROP TEMPORARY TABLE IF EXISTS temp_goods_ids")
  564. except Exception as e:
  565. logger.error(f"批量计算平均价格失败: {e}")
  566. # 回退到原方法
  567. return self._fallback_calculate_avg_prices(goods_ids)
  568. return results
  569. def _fallback_calculate_avg_prices(self, goods_ids: List[str]) -> Dict[str, Tuple[float, float]]:
  570. """后备的平均价格计算方法"""
  571. results = {}
  572. try:
  573. with self.connection.cursor() as cursor:
  574. for i in range(0, len(goods_ids), AVG_PRICE_BATCH_SIZE):
  575. batch = goods_ids[i:i + AVG_PRICE_BATCH_SIZE]
  576. placeholders = ','.join(['%s'] * len(batch))
  577. cursor.execute(f"""
  578. SELECT
  579. goods_id,
  580. COALESCE(ROUND(AVG(CASE
  581. WHEN create_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)
  582. AND min_price > 0
  583. THEN min_price
  584. END), 2), 0.00) as avg_7d,
  585. COALESCE(ROUND(AVG(CASE
  586. WHEN create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
  587. AND min_price > 0
  588. THEN min_price
  589. END), 2), 0.00) as avg_30d
  590. FROM buff_goods_daily_price
  591. WHERE goods_id IN ({placeholders})
  592. AND create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
  593. GROUP BY goods_id
  594. """, batch)
  595. rows = cursor.fetchall()
  596. for row in rows:
  597. results[row['goods_id']] = (
  598. float(row['avg_7d']),
  599. float(row['avg_30d'])
  600. )
  601. if i % (AVG_PRICE_BATCH_SIZE * 5) == 0:
  602. logger.info(f"平均价格计算进度: {len(results)}/{len(goods_ids)}")
  603. except Exception as e:
  604. logger.error(f"后备平均价格计算失败: {e}")
  605. return results
  606. def get_last_sync_time(self) -> Optional[datetime]:
  607. """获取上次同步时间"""
  608. try:
  609. with self.connection.cursor() as cursor:
  610. cursor.execute("""
  611. SELECT MAX(stat_time) as last_sync
  612. FROM buff_goods_daily_price
  613. WHERE stat_time > DATE_SUB(NOW(), INTERVAL 1 HOUR)
  614. LIMIT 1
  615. """)
  616. result = cursor.fetchone()
  617. return result['last_sync'] if result['last_sync'] else None
  618. except Exception as e:
  619. logger.error(f"获取上次同步时间失败: {e}")
  620. return None
  621. # ============================================================
  622. # BUFF API 操作类(保持不变)
  623. # ============================================================
  624. class BuffPriceAPI:
  625. """BUFF价格API操作类"""
  626. def __init__(self):
  627. self.session = requests.Session()
  628. self.session.headers.update({
  629. "Authorization": f"Bearer {BUFF_API_TOKEN}",
  630. "Accept": "application/json",
  631. "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
  632. })
  633. def fetch_price_data(self, game: str = "cs2", include_sticker: int = 1) -> Dict[str, Any]:
  634. """获取价格数据"""
  635. params = {"game": game, "include_sticker": include_sticker}
  636. for retry in range(MAX_RETRIES):
  637. try:
  638. logger.info(f"请求BUFF API (第{retry + 1}次尝试)...")
  639. response = self.session.get(BUFF_API_URL, params=params, timeout=REQUEST_TIMEOUT)
  640. response.raise_for_status()
  641. data = response.json()
  642. logger.info("=" * 60)
  643. logger.info("🔍 API返回数据结构:")
  644. logger.info(f" 顶层keys: {list(data.keys())}")
  645. # 获取数据统计信息
  646. stat_time = data.get("stat_time", 0)
  647. remaining = data.get("req_remaining", 0)
  648. info_data = data.get("info", [])
  649. logger.info(f" 统计时间: {datetime.fromtimestamp(stat_time)}")
  650. logger.info(f" 剩余调用次数: {remaining}")
  651. logger.info(f" 数据条数: {len(info_data)}")
  652. # 检查第一条数据是否有market_hash_name字段
  653. if info_data and len(info_data) > 0:
  654. first_item = info_data[0]
  655. logger.info(f" 第一条数据字段检查:")
  656. logger.info(f" goods_id: {first_item.get('goods_id')}")
  657. logger.info(f" name: {first_item.get('name')}")
  658. logger.info(f" market_hash_name: {first_item.get('market_hash_name', '不存在')}")
  659. logger.info("=" * 60)
  660. # 验证数据格式
  661. if "info" not in data:
  662. logger.error(f"API返回格式错误: 无info字段, 完整响应: {data}")
  663. raise Exception(f"API错误: {data.get('msg', '未知错误')}")
  664. logger.info(f"API请求成功,获取到 {len(info_data)} 条记录")
  665. logger.info(f"API剩余调用次数: {remaining}")
  666. return data
  667. except requests.exceptions.RequestException as e:
  668. logger.error(f"API请求失败 (尝试 {retry + 1}/{MAX_RETRIES}): {e}")
  669. if 'response' in locals():
  670. logger.error(f"响应状态码: {response.status_code}")
  671. logger.error(f"响应内容: {response.text[:500]}")
  672. if retry < MAX_RETRIES - 1:
  673. time.sleep(RETRY_DELAY)
  674. else:
  675. raise
  676. def generate_steam_url(self, market_hash_name: str) -> str:
  677. """生成Steam社区市场链接"""
  678. if not market_hash_name:
  679. return None
  680. try:
  681. # URL编码market_hash_name
  682. encoded_name = urllib.parse.quote(market_hash_name)
  683. steam_url = f"{STEAM_MARKET_BASE_URL}{encoded_name}"
  684. return steam_url
  685. except Exception as e:
  686. logger.error(f"生成Steam URL失败: {market_hash_name}, 错误: {e}")
  687. return None
  688. def parse_price_data(self, api_response: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]:
  689. """解析价格数据,返回三种类型的数据列表
  690. Returns:
  691. tuple: (daily_price_list, goods_info_list, tag_price_list)
  692. """
  693. logger.info("🔍 开始解析数据...")
  694. stat_time = api_response.get("stat_time", 0)
  695. info_data = api_response.get("info", [])
  696. logger.info(f" stat_time: {datetime.fromtimestamp(stat_time)}")
  697. logger.info(f" info列表长度: {len(info_data)}")
  698. daily_price_list = [] # 无tag的价格数据
  699. tag_price_list = [] # 有tag的价格数据
  700. goods_info_list = [] # 商品信息数据
  701. start_time = time.time()
  702. for item in info_data:
  703. goods_id = item.get("goods_id")
  704. name = item.get("name")
  705. market_hash_name = item.get("market_hash_name") # 获取market_hash_name
  706. update_time = item.get("update_time", stat_time)
  707. sales = item.get("sales", [])
  708. # 生成链接
  709. buff_url = f"https://buff.163.com/goods/{goods_id}"
  710. steam_url = self.generate_steam_url(market_hash_name)
  711. # 处理每个sales条目
  712. has_base_price = False
  713. for sale in sales:
  714. min_price = sale.get("min_price")
  715. buy_max_price = sale.get("buy_max_price")
  716. tag_id = sale.get("tag_id")
  717. tag_name = sale.get("tag_name")
  718. if tag_id and tag_name:
  719. # 有tag的数据
  720. tag_data = {
  721. "goods_id": goods_id,
  722. "name": name,
  723. "min_price": min_price,
  724. "buy_max_price": buy_max_price,
  725. "tag_id": tag_id,
  726. "tag_name": tag_name,
  727. "stat_time": stat_time,
  728. "update_time": update_time
  729. }
  730. tag_price_list.append(tag_data)
  731. else:
  732. # 无tag的数据
  733. price_data = {
  734. "goods_id": goods_id,
  735. "name": name,
  736. "min_price": min_price,
  737. "buy_max_price": buy_max_price,
  738. "stat_time": stat_time,
  739. "update_time": update_time
  740. }
  741. daily_price_list.append(price_data)
  742. # 如果是第一个无tag的条目,也用于商品信息
  743. if not has_base_price:
  744. has_base_price = True
  745. goods_info_data = {
  746. "goods_id": goods_id,
  747. "name": name,
  748. "min_price": min_price,
  749. "buy_max_price": buy_max_price,
  750. "avg_7d_min_price": None,
  751. "avg_30d_min_price": None,
  752. "stat_time": stat_time,
  753. "update_time": update_time,
  754. "buff_url": buff_url,
  755. "steam_url": steam_url # 使用生成的steam_url
  756. }
  757. goods_info_list.append(goods_info_data)
  758. elapsed = time.time() - start_time
  759. logger.info(f" 解析完成,耗时: {elapsed:.2f}秒")
  760. logger.info(f" 无tag价格数据: {len(daily_price_list)} 条")
  761. logger.info(f" 有tag价格数据: {len(tag_price_list)} 条")
  762. logger.info(f" 商品信息数据: {len(goods_info_list)} 条")
  763. # 统计steam_url生成情况
  764. steam_url_count = sum(1 for item in goods_info_list if item.get('steam_url'))
  765. logger.info(f" 成功生成steam_url: {steam_url_count}/{len(goods_info_list)} 条")
  766. return daily_price_list, goods_info_list, tag_price_list
  767. # ============================================================
  768. # 主逻辑(修正清空表顺序)
  769. # ============================================================
  770. class PriceSyncTask:
  771. """价格数据同步任务"""
  772. def __init__(self):
  773. self.db = None
  774. self.api = BuffPriceAPI()
  775. self.last_sync_time = None
  776. def _init_db(self):
  777. """初始化数据库连接"""
  778. if self.db is None:
  779. self.db = PriceDatabase()
  780. def run(self) -> bool:
  781. """执行同步任务"""
  782. start_time = datetime.now()
  783. logger.info("=" * 60)
  784. logger.info(f"开始执行价格数据同步任务: {start_time}")
  785. logger.info("=" * 60)
  786. try:
  787. # 初始化数据库连接
  788. self._init_db()
  789. # 检查是否需要执行
  790. last_sync = self.db.get_last_sync_time()
  791. if last_sync:
  792. time_diff = (start_time - last_sync).total_seconds()
  793. if time_diff < 300: # 5分钟内已同步过
  794. logger.info(f"最近 {time_diff:.0f} 秒内已同步过,跳过本次执行")
  795. return True
  796. # Step 1: 清空中间表
  797. logger.info("[Step 1/9] 清空中间表...")
  798. clear_start = time.time()
  799. self.db.clear_table("buff_goods_daily_price_middle")
  800. self.db.clear_table("buff_goods_info_middle")
  801. self.db.clear_table("buff_goods_daily_tag_price_middle")
  802. logger.info(f"清空中间表耗时: {time.time() - clear_start:.2f}秒")
  803. # Step 2: 调用BUFF API获取数据
  804. logger.info("[Step 2/9] 调用BUFF API获取价格数据...")
  805. api_start = time.time()
  806. api_response = self.api.fetch_price_data(game="cs2", include_sticker=1)
  807. logger.info(f"API调用耗时: {time.time() - api_start:.2f}秒")
  808. # Step 3: 解析数据
  809. logger.info("[Step 3/9] 解析数据...")
  810. parse_start = time.time()
  811. daily_price_list, goods_info_list, tag_price_list = self.api.parse_price_data(api_response)
  812. logger.info(f"数据解析耗时: {time.time() - parse_start:.2f}秒")
  813. if not daily_price_list:
  814. logger.warning("未获取到任何价格数据,任务取消")
  815. return False
  816. # Step 4: 插入无tag价格数据到中间表
  817. logger.info("[Step 4/9] 插入无tag价格数据到中间表...")
  818. inserted_daily_count = self.db.insert_daily_price_middle(daily_price_list)
  819. # Step 5: 更新主价格表
  820. logger.info("[Step 5/9] 更新主价格表...")
  821. copy_start = time.time()
  822. target_inserted = self.db.copy_from_middle_to_daily_price()
  823. logger.info(f"主价格表插入完成: {target_inserted} 条,耗时: {time.time() - copy_start:.2f}秒")
  824. # 每小时清理一次旧数据
  825. current_minute = datetime.now().minute
  826. if current_minute == 0:
  827. logger.info("[Step 5a/9] 清理旧价格数据...")
  828. cleanup_start = time.time()
  829. self.db.cleanup_old_price_data()
  830. logger.info(f"清理旧数据耗时: {time.time() - cleanup_start:.2f}秒")
  831. else:
  832. logger.info("[Step 5a/9] 跳过清理(非整点)")
  833. # Step 6: 计算7天/30天平均价并同步到 goods_info 表
  834. logger.info("[Step 6/9] 计算平均价并同步到 goods_info...")
  835. avg_start = time.time()
  836. goods_ids = [str(item['goods_id']) for item in goods_info_list]
  837. avg_updated = self.db.calculate_and_sync_avg_prices(goods_ids)
  838. logger.info(f"平均价计算并同步完成: {avg_updated} 条,耗时: {time.time() - avg_start:.2f}秒")
  839. # Step 7: 插入商品信息到中间表
  840. logger.info("[Step 7/9] 插入商品信息到中间表...")
  841. inserted_info_count = self.db.insert_goods_info_middle(goods_info_list)
  842. # Step 7a: 清空目标表并更新商品信息主表
  843. logger.info("[Step 7a/9] 清空buff_goods_info表并更新数据...")
  844. copy_info_start = time.time()
  845. info_inserted = self.db.copy_from_middle_to_goods_info()
  846. logger.info(f"商品信息主表插入完成: {info_inserted} 条,耗时: {time.time() - copy_info_start:.2f}秒")
  847. # Step 8: 插入有tag价格数据到中间表
  848. if tag_price_list:
  849. logger.info("[Step 8/9] 插入有tag价格数据到中间表...")
  850. inserted_tag_count = self.db.insert_daily_tag_price_middle(tag_price_list)
  851. # Step 9: 清空目标表并更新有tag价格主表
  852. logger.info("[Step 9/9] 清空buff_goods_daily_tag_price表并更新数据...")
  853. copy_tag_start = time.time()
  854. tag_inserted = self.db.copy_from_middle_to_daily_tag_price()
  855. logger.info(f"有tag价格主表插入完成: {tag_inserted} 条,耗时: {time.time() - copy_tag_start:.2f}秒")
  856. else:
  857. logger.info("没有有tag的价格数据,跳过步骤8-9")
  858. inserted_tag_count = 0
  859. # 清理中间表
  860. logger.info("[清理] 清空中间表数据...")
  861. cleanup_mid_start = time.time()
  862. self.db.clear_table("buff_goods_daily_price_middle")
  863. self.db.clear_table("buff_goods_info_middle")
  864. self.db.clear_table("buff_goods_daily_tag_price_middle")
  865. logger.info(f"清理中间表耗时: {time.time() - cleanup_mid_start:.2f}秒")
  866. # 任务完成
  867. end_time = datetime.now()
  868. duration = (end_time - start_time).total_seconds()
  869. logger.info("=" * 60)
  870. logger.info(f"价格数据同步任务完成! 总耗时: {duration:.2f}秒")
  871. logger.info(f"同步数据量:")
  872. logger.info(f" 无tag价格: {inserted_daily_count} 条")
  873. logger.info(f" 商品信息: {inserted_info_count} 条")
  874. logger.info(f" 有tag价格: {inserted_tag_count} 条")
  875. logger.info(f" 总处理数据: {len(daily_price_list)} 条饰品记录")
  876. logger.info("=" * 60)
  877. return True
  878. except Exception as e:
  879. if is_database_connection_error(e):
  880. logger.exception("数据库连接异常,本轮跳过,下一轮将重试: %s", e)
  881. return True
  882. logger.exception(f"同步任务失败: {e}")
  883. return False
  884. finally:
  885. if self.db:
  886. self.db.close()
  887. self.db = None
  888. def run_once() -> bool:
  889. """执行一次同步任务"""
  890. task = PriceSyncTask()
  891. return task.run()
  892. if __name__ == "__main__":
  893. sys.exit(0 if run_once() else 1)