"""官方寄售缓存、认证失败识别及可选BARK通知。""" import asyncio import hashlib import json import logging import math import time from uuid import uuid4 import aiohttp from api.core.Gzip import gzip_unzip, gzip_zip from api.types.colg import GOLD_REGION_CODES, OfficialGold from api.utils.thirdpart_gold import RELEASE_LOCK from api.utils.gold_time import UPDATE_LEAD_SECONDS, half_hour_start, refresh_slot from config.main import config logger = logging.getLogger('dcalc.official_gold') URL = 'https://dzhu.qq.com/zangyi/activity/app' REGIONS = ('1', '2', '3A', '3B', '4', '5', '6', '7') CACHE_KEY = 'openapi:colg:real_time_gold_official:v2' LOCK_KEY = f'{CACHE_KEY}:lock' COOLDOWN_KEY = f'{CACHE_KEY}:cooldown' FRESH_SECONDS = 30 * 60 RETENTION_SECONDS = 24 * 60 * 60 ERROR_COOLDOWN_SECONDS = 5 * 60 ALERT_RETENTION_SECONDS = 24 * 60 * 60 class TokenExpired(RuntimeError): pass def normalize_row(item: dict) -> dict: """原报价为点券/100万金币,换算为每100点券的万金币为10000/报价。""" ranks = {} for name in ('goldDealRank', 'goldTopDealRank'): prices = [] for raw in item[name][:5]: try: price = float(raw) except (TypeError, ValueError, OverflowError): continue if math.isfinite(price): prices.append(round(price, 2)) ranks[name] = prices ratios = [10000 / price for prices in ranks.values() for price in prices if price > 0 and math.isfinite(10000 / price)] return OfficialGold.model_validate({ **item, **ranks, 'updataTime': str(half_hour_start(int(item['updataTime']))), 'max': round(max(ratios), 2) if ratios else None, 'min': round(min(ratios), 2) if ratios else None, }).model_dump() def is_auth_failure(payload: dict) -> bool: if any(str(payload.get(key)) == '-30003' for key in ('result', 'returnCode')): return True message = ' '.join(str(payload.get(key, '')) for key in ('returnMsg', 'message', 'msg')).lower() return any(marker in message for marker in ( 'auth verification failed', 'token expired', 'token invalid', 'invalid token', 'token失效', 'token已过期', '登录失效', '登录已过期', '请重新登录', )) async def fetch_gold(*, started_at: float | None = None) -> list[dict]: if started_at is None: started_at = time.time() update_time = str(refresh_slot(started_at)) timeout = aiohttp.ClientTimeout(total=10) async with aiohttp.ClientSession(timeout=timeout) as session: async def fetch_region(region): form = {'r': 'godRank/godDealRank', 'userId': config.HELPER_ID, 'token': config.HELPER_TOKEN, 'serverNo': region} async with session.post(URL, data=form) as resp: if resp.status == 401: raise TokenExpired('官方金币接口认证失败') payload = await resp.json(content_type=None) if not isinstance(payload, dict): raise ValueError('官方金币接口返回格式异常') if is_auth_failure(payload): raise TokenExpired('官方金币接口认证失败') resp.raise_for_status() data = payload['data'] return normalize_row({ 'updataTime': update_time, 'region': f'跨{region}', 'code': GOLD_REGION_CODES[f'跨{region}'], 'goldDealRank': [float(item['price']) for item in data['godDealRank'][:5]], 'goldTopDealRank': [float(item['price']) for item in data['godTopDealRank'][:5]], }) results = await asyncio.gather(*(fetch_region(region) for region in REGIONS), return_exceptions=True) # 任一跨区认证失败只向刷新调用方抛出一次;优先于其他渠道的网络异常。 for result in results: if isinstance(result, TokenExpired): raise result for result in results: if isinstance(result, BaseException): raise result return results async def notify_token_expired(redis) -> bool: bark_url = (config.BARK_URL or '').strip() if not bark_url: return False # 不将Token或BARK设备key写入缓存键、通知正文或日志。 fingerprint = hashlib.sha256(config.HELPER_TOKEN.encode()).hexdigest() key = f'{CACHE_KEY}:token_alert:{fingerprint}' owner = uuid4().hex try: if not await redis.set(key, owner, nx=True, ex=60): return False except Exception: logger.warning('unable to acquire official token notification lock') return False sent = False try: timeout = aiohttp.ClientTimeout(total=5) async with aiohttp.ClientSession(timeout=timeout) as session: async with session.post(bark_url, json={ 'title': '助手Token失效', 'body': '官方接口认证失败,请检查并更新配置', 'group': 'dcalc', }, allow_redirects=False) as resp: resp.raise_for_status() payload = await resp.json(content_type=None) if not isinstance(payload, dict) or payload.get('code') != 200: raise ValueError('BARK拒绝推送') await redis.set(key, 'sent', ex=ALERT_RETENTION_SECONDS) sent = True return True except Exception as exc: # ClientResponseError包含完整请求URL,不输出异常正文或堆栈以免暴露设备key。 logger.warning('official token BARK notification failed (%s)', type(exc).__name__) return False finally: if not sent: try: await redis.eval(RELEASE_LOCK, 1, key, owner) except Exception: logger.warning('unable to release official token notification lock') async def read_cache(redis) -> list[dict]: value = await redis.get(CACHE_KEY) if value is None: return [] try: rows = [normalize_row(item) for item in json.loads(gzip_unzip(value))] if [row['region'] for row in rows] != [f'跨{region}' for region in REGIONS]: return [] if any(not -UPDATE_LEAD_SECONDS <= time.time() - int(row['updataTime']) < RETENTION_SECONDS for row in rows): return [] return rows except (ValueError, TypeError, KeyError, OSError): logger.warning('invalid official gold cache') return [] def is_fresh(rows: list[dict]) -> bool: return (len(rows) == len(REGIONS) and all(int(row['updataTime']) == refresh_slot() for row in rows)) async def refresh_cache(redis) -> bool: if is_fresh(await read_cache(redis)) or await redis.get(COOLDOWN_KEY): return False owner = uuid4().hex if not await redis.set(LOCK_KEY, owner, nx=True, ex=60): return False try: if is_fresh(await read_cache(redis)) or await redis.get(COOLDOWN_KEY): return False try: data = await fetch_gold(started_at=time.time()) except TokenExpired: logger.warning('official gold authentication failed; check HELPER_TOKEN and HELPER_ID') await notify_token_expired(redis) except Exception: logger.exception('fetch official gold failed') else: if await redis.get(LOCK_KEY) not in (owner, owner.encode()): return False await redis.set(CACHE_KEY, gzip_zip(json.dumps(data)), ex=RETENTION_SECONDS) await redis.delete(COOLDOWN_KEY) return True # 网络异常、认证失败均保留上次完整缓存,不写空列表,不改变原更新时间。 await redis.set(COOLDOWN_KEY, '1', ex=ERROR_COOLDOWN_SECONDS) return False finally: await redis.eval(RELEASE_LOCK, 1, LOCK_KEY, owner) async def get_data(redis) -> list[dict]: cached = await read_cache(redis) if cached: return cached # 冷启动时允许请求补齐;与定时任务共用刷新锁,不会重复请求上游。 await refresh_cache(redis) return await read_cache(redis)