feat:添加colg对接功能
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
"""官方寄售缓存、认证失败识别及可选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)
|
||||
Reference in New Issue
Block a user