Files
dcalc/backend/api/utils/official_gold.py
T
2026-11-03 21:44:30 +08:00

202 lines
7.9 KiB
Python

"""官方寄售缓存、认证失败识别及可选BARK通知。"""
import asyncio
from datetime import datetime, timezone
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 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(price)
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,
'max': round(max(ratios), 4) if ratios else None,
'min': round(min(ratios), 4) 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() -> list[dict]:
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': str(int(datetime.now(timezone.utc).timestamp())),
'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 0 <= 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(0 <= time.time() - int(row['updataTime']) < FRESH_SECONDS 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()
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)