from datetime import datetime, timedelta, timezone import asyncio import httpx import json import os import logging from logging.handlers import TimedRotatingFileHandler from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.user_oauth import UserOAuth from app.models.user_oauth_app import UserOAuthApp from app.models.base import async_session from app.config import settings from app.utils.redis import get_redis REDIS_KEY = "douyin:tokens" LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "logs") os.makedirs(LOG_DIR, exist_ok=True) logger = logging.getLogger("token_refresh") logger.setLevel(logging.INFO) def get_log_filename(): return os.path.join(LOG_DIR, f"token_refresh-{datetime.now().strftime('%Y-%m-%d')}.log") class DailyRotatingFileHandler(logging.FileHandler): def __init__(self, directory, encoding=None): self.directory = directory filename = get_log_filename() super().__init__(filename, encoding=encoding) def emit(self, record): current_filename = get_log_filename() if self.baseFilename != current_filename: self.close() self.baseFilename = current_filename self.stream = self._open() super().emit(record) handler = DailyRotatingFileHandler(LOG_DIR, encoding="utf-8") handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s", "%Y-%m-%d %H:%M:%S")) logger.addHandler(handler) REFRESH_THRESHOLD_SECONDS = 800 CHECK_INTERVAL_MINUTES = 5 async def _update_redis_token(oauth_id: str, token: str, expired_at: datetime): """更新Redis缓存中的token""" redis = get_redis() if not redis: logger.warning("Redis连接未配置,跳过缓存更新") return try: cache = { "token": token, "expired_at": expired_at.isoformat(), } await redis.hset(REDIS_KEY, oauth_id, json.dumps(cache)) logger.info(f"Redis缓存已更新: oauth_id={oauth_id}") except Exception as e: logger.error(f"更新Redis缓存失败: {str(e)}") async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSession): """刷新巨量引擎token""" try: async with httpx.AsyncClient() as client: url = "https://api.oceanengine.com/open_api/oauth2/refresh_token/" response = await client.post( url, json={ "app_id": app.app_id, "secret": app.secret, "refresh_token": oauth.refresh_token, }, ) response.raise_for_status() data = response.json() if data.get("code") != 0: logger.error(f"刷新巨量引擎token失败: oauth_id={oauth.id}, 错误信息: {data}") #如果code=40103或者40107,传入refresh_token已失效,失效原因一般是由于refresh_token已被使用,或授权账号重新授权并生成了新的Token if data.get("code") in [40103, 40107]: #清空数据库中的token信息,和Redis缓存中的token oauth.access_token = None oauth.access_token_expired = None oauth.refresh_token = None oauth.refresh_token_expired = None await db.commit() await _update_redis_token(oauth.id, "", None) return data = data.get("data", {}) new_access_token = data.get("access_token", "") new_refresh_token = data.get("refresh_token", "") expires_in = datetime.now(tz=oauth.access_token_expired.tzinfo) + timedelta(seconds=data.get("expires_in", 0)) refresh_token_expires_in = datetime.now(tz=oauth.refresh_token_expired.tzinfo) + timedelta(seconds=data.get("refresh_token_expires_in", 0)) oauth.access_token = new_access_token oauth.access_token_expired = expires_in oauth.refresh_token = new_refresh_token oauth.refresh_token_expires_in = refresh_token_expires_in await db.commit() await _update_redis_token(oauth.id, new_access_token, expires_in) logger.info(f"成功刷新巨量引擎token: oauth_id={oauth.id}, account_id={oauth.account_id}") except httpx.HTTPError as e: logger.error(f"HTTP请求失败: oauth_id={oauth.id}, 错误: {str(e)}") except Exception as e: logger.error(f"刷新巨量引擎token发生异常: oauth_id={oauth.id}, 错误: {str(e)}") async def check_and_refresh_tokens(): """检查并刷新即将过期的token""" async with async_session() as db: now = datetime.now(timezone.utc) query = select(UserOAuth).where( UserOAuth.deleted_at.is_(None), UserOAuth.access_token.is_not(None), UserOAuth.access_token_expired.is_not(None), UserOAuth.refresh_token.is_not(None), UserOAuth.refresh_token_expired.is_not(None), ) result = await db.execute(query) oauth_list = result.scalars().all() for oauth in oauth_list: try: #1.检查access_token是否过期,如果未过期,并且大于800秒,直接跳过不处理 if not oauth.access_token_expired: continue remaining_seconds = (oauth.access_token_expired - now).total_seconds() # access_token剩余时间大于等于800秒,不需要刷新 if remaining_seconds >= REFRESH_THRESHOLD_SECONDS: continue #2.如果access_token过期,或者剩余时间小于800秒,需要刷新token #3.如果需要刷新token,检查refresh_token是否过期,如果refresh_token过期,说明不可刷新,需要直接重新授权,直接跳过不处理 if not oauth.refresh_token_expired: continue refresh_remaining_seconds = (oauth.refresh_token_expired - now).total_seconds() if refresh_remaining_seconds <= 0: continue #5.获取应用配置 app_result = await db.execute( select(UserOAuthApp).where(UserOAuthApp.app_id == oauth.appid) ) app = app_result.scalar_one_or_none() if not app: continue #检查是否为支持的平台(巨量引擎) # port_type: 平台端口(1=巨量,2=磁力,3=巨量星图,4=服务单,5=腾讯) if oauth.port_type in [1]: #刷新token await refresh_juliang_token(oauth, app, db) except Exception as e: #7.增加错误日志 logger.error(f"刷新token失败: {str(e)}") async def token_refresh_scheduler(): """定时任务调度器""" while True: try: await check_and_refresh_tokens() except Exception as e: logger.error(f"定时任务token_refresh_scheduler执行失败: {str(e)}") await asyncio.sleep(CHECK_INTERVAL_MINUTES * 60) def start_token_refresh_task(): """启动token刷新定时任务""" logger.info("启动token刷新定时任务") asyncio.create_task(token_refresh_scheduler())