Files
video-gen/video-gen-api/app/utils/douyinRequest.py
T
2026-07-06 13:39:08 +08:00

408 lines
16 KiB
Python

import json
import asyncio
from typing import Any, Dict, Optional, Tuple
import httpx
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timedelta
# 【改动1:引入自定义北京时间工具类】
from app.utils.datetime_util import (
BEIJING_TZ,
datetime_to_db_tz_str,
db_tz_str_to_datetime
)
from app.models.user_oauth import UserOAuth
from app.models.user_oauth_app import UserOAuthApp
from app.models.base import async_session
from app.utils.redis import get_redis
from app.utils.logger import get_logger
logger = get_logger("douyin_request", "douyin_request")
class DouyinRequest:
def __init__(self, platform: str = "douyin"):
self._client: Optional[httpx.AsyncClient] = None
self._platform = platform
@property
def client(self) -> httpx.AsyncClient:
if not self._client:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(30.0),
follow_redirects=True,
)
return self._client
@property
def _redis_key(self) -> str:
return f"{self._platform}:tokens"
async def close(self):
if self._client:
await self._client.aclose()
self._client = None
async def _get_redis_token(self, oauth_id: str) -> Optional[str]:
"""
【核心改动:北京时间解析+脏缓存自动清理】
1. 缓存格式异常/字段缺失:直接删除脏缓存
2. Token过期则不返回,交由上层判断refresh是否过期决定是否删除缓存
"""
redis = get_redis()
if not redis:
raise ValueError("Redis连接未配置")
try:
cache_str = await redis.hget(self._redis_key, oauth_id)
if not cache_str:
return None
cache = json.loads(cache_str)
token = cache.get("token")
expired_at_str = cache.get("expired_at")
# 脏缓存:关键字段缺失,直接删除
if not (token and expired_at_str):
await redis.hdel(self._redis_key, oauth_id)
logger.warning(f"oauth_id:{oauth_id} Redis缓存字段缺失,已清理脏数据")
return None
# 【改动3:使用工具类解析带时区时间,不再强制覆盖UTC】
expired_at = db_tz_str_to_datetime(expired_at_str)
now_beijing = datetime.now(tz=BEIJING_TZ)
if expired_at > now_beijing:
return token
# AccessToken已过期,返回None,上层会校验refresh_token状态决定是否清理缓存
return None
except json.JSONDecodeError:
# JSON格式损坏,清理脏缓存
await redis.hdel(self._redis_key, oauth_id)
logger.error(f"oauth_id:{oauth_id} Redis缓存JSON格式异常,已清理脏数据")
return None
except Exception as e:
raise ValueError(f"获取Redis缓存失败: {str(e)}")
async def _set_redis_token(self, oauth_id: str, token: str, expired_at: datetime):
redis = get_redis()
if not redis:
raise ValueError("Redis连接未配置")
# 【改动4:统一转为北京时间序列化存入Redis,和数据库时区对齐】
cache = {
"token": token,
"expired_at": datetime_to_db_tz_str(expired_at),
}
try:
await redis.hset(self._redis_key, oauth_id, json.dumps(cache, ensure_ascii=False))
except Exception as e:
raise ValueError(f"设置Redis缓存失败: {str(e)}")
async def _delete_redis_token(self, oauth_id: str):
"""删除单个oauth_id缓存(授权彻底失效时调用)"""
redis = get_redis()
if not redis:
raise ValueError("Redis连接未配置")
try:
await redis.hdel(self._redis_key, oauth_id)
logger.info(f"oauth_id:{oauth_id} 授权失效,已清理Redis缓存脏数据")
except Exception as e:
raise ValueError(f"删除Redis缓存失败: {str(e)}")
async def get_access_token(self, oauth_id: str, force_refresh: bool = False) -> str:
# 优先读取缓存
if not force_refresh:
token = await self._get_redis_token(oauth_id)
if token:
return token
async with async_session() as db:
oauth_data = await db.execute(
select(UserOAuth).where(
UserOAuth.id == oauth_id,
UserOAuth.deleted_at.is_(None),
).limit(1)
)
oauth_data = oauth_data.scalar_one_or_none()
if not oauth_data:
raise ValueError("无效的oauth_id")
# 【改动5:统一北京时间当前时间】
now_beijing = datetime.now(tz=BEIJING_TZ)
if force_refresh:
# 强制刷新:先检查refresh_token是否为空
if not oauth_data.refresh_token:
# RefreshToken为空 → 授权已失效,清理Redis脏缓存
await self._delete_redis_token(oauth_id)
raise ValueError("授权已过期,请重新授权登录")
# 清空同条件下所有账号缓存与数据库token
where_cond = UserOAuth.deleted_at.is_(None)
if oauth_data.appid:
where_cond = where_cond & (UserOAuth.appid == oauth_data.appid)
if oauth_data.account_username:
where_cond = where_cond & (UserOAuth.account_username == oauth_data.account_username)
if oauth_data.account_userid:
where_cond = where_cond & (UserOAuth.account_userid == oauth_data.account_userid)
await db.execute(
update(UserOAuth).where(where_cond).values(
access_token=None,
access_token_expired=None,
)
)
await db.commit()
related_oauth_ids = await db.execute(select(UserOAuth.id).where(where_cond))
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
for related_id in related_oauth_ids:
await self._delete_redis_token(related_id)
new_token, new_expired_at = await self.refresh_access_token(db, oauth_id, oauth_data.appid, oauth_data.refresh_token)
return new_token
# 数据库AccessToken未过期,写入缓存直接返回
if oauth_data.access_token_expired and oauth_data.access_token_expired > now_beijing:
token = oauth_data.access_token
expired_at = oauth_data.access_token_expired
await self._set_redis_token(oauth_id, token, expired_at)
return token
# 查找同账号下未过期有效token复用
where_cond = UserOAuth.deleted_at.is_(None)
if oauth_data.appid:
where_cond = where_cond & (UserOAuth.appid == oauth_data.appid)
if oauth_data.account_username:
where_cond = where_cond & (UserOAuth.account_username == oauth_data.account_username)
if oauth_data.account_userid:
where_cond = where_cond & (UserOAuth.account_userid == oauth_data.account_userid)
related_oauths = await db.execute(
select(UserOAuth.access_token, UserOAuth.access_token_expired).where(
where_cond,
UserOAuth.access_token_expired.is_not(None),
UserOAuth.access_token_expired > now_beijing,
).limit(1)
)
related_oauth = related_oauths.first()
if related_oauth:
token, expired_at = related_oauth
await self._set_redis_token(oauth_id, token, expired_at)
return token
# =========【核心业务规则实现:判断RefreshToken是否过期】=========
if not oauth_data.refresh_token:
# RefreshToken为空 → 授权已失效,清理Redis脏缓存
await self._delete_redis_token(oauth_id)
raise ValueError("授权已过期,请重新授权登录")
if oauth_data.refresh_token_expired and oauth_data.refresh_token_expired < now_beijing:
# RefreshToken过期 → 授权彻底失效,清理Redis脏缓存
await self._delete_redis_token(oauth_id)
raise ValueError("授权已过期,请重新授权登录")
# RefreshToken有效,执行刷新并更新缓存
new_token, new_expired_at = await self.refresh_access_token(db, oauth_id, oauth_data.appid, oauth_data.refresh_token)
return new_token
async def refresh_access_token(self, db: AsyncSession, oauth_id: str, appid: str, refresh_token: str) -> Tuple[str, datetime]:
oauth_info = await db.execute(
select(UserOAuth.account_username, UserOAuth.account_userid).where(
UserOAuth.id == oauth_id,
UserOAuth.deleted_at.is_(None),
).limit(1)
)
oauth_info = oauth_info.first()
if not oauth_info:
raise ValueError("无效的oauth_id")
account_username, account_userid = oauth_info
result = await db.execute(
select(UserOAuthApp.secret).where(
UserOAuthApp.app_id == appid,
UserOAuthApp.status == 1,
UserOAuthApp.deleted_at.is_(None),
).limit(1)
)
app_secret = result.scalar_one_or_none()
if not app_secret:
raise ValueError("应用已被删除或禁用")
response = await self.client.request(
'POST',
'https://api.oceanengine.com/open_api/oauth2/refresh_token/',
data={
'app_id': appid,
'secret': app_secret,
'refresh_token': refresh_token,
},
)
response.raise_for_status()
data = response.json()
if 'code' not in data:
raise ValueError("刷新access_token失败,接口未返回code")
code = data.get('code', 0)
if code != 0:
#刷新token
raise ValueError(f"刷新access_token失败,接口返回:{data}")
data = data.get('data', {})
new_access_token = data.get('access_token', '')
new_refresh_token = data.get('refresh_token', '')
expires_in = data.get('expires_in', 0)
refresh_token_expires_in = data.get('refresh_token_expires_in', 0)
# 【改动6:北京时间计算过期时间】
now_beijing = datetime.now(tz=BEIJING_TZ)
new_expired_at = now_beijing + timedelta(seconds=expires_in)
new_refresh_expired = now_beijing + timedelta(seconds=refresh_token_expires_in)
where_cond = UserOAuth.deleted_at.is_(None)
if appid:
where_cond = where_cond & (UserOAuth.appid == appid)
if account_username:
where_cond = where_cond & (UserOAuth.account_username == account_username)
if account_userid:
where_cond = where_cond & (UserOAuth.account_userid == account_userid)
await db.execute(
update(UserOAuth).where(where_cond).values(
access_token=new_access_token,
refresh_token=new_refresh_token,
access_token_expired=new_expired_at,
refresh_token_expired=new_refresh_expired,
)
)
await db.commit()
related_oauth_ids = await db.execute(select(UserOAuth.id).where(where_cond))
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
for related_id in related_oauth_ids:
await self._set_redis_token(related_id, new_access_token, new_expired_at)
return new_access_token, new_expired_at
async def request_with_token_with_context(
self,
oauth_id: str,
url: str,
method: str = 'GET',
options: Optional[Dict[str, Any]] = None,
request_count: int = 1,
) -> Any:
options = options or {}
token = await self.get_access_token(oauth_id)
resp_data: Optional[Dict[str, Any]] = None
for i in range(1, request_count + 1):
try:
headers = options.get('headers', {}).copy()
headers['Access-Token'] = token
has_files = 'files' in options
if has_files:
headers.pop('Content-Type', None)
else:
headers.setdefault('Content-Type', 'application/json')
options['headers'] = headers
response = await self.client.request(method, url, **options)
response.raise_for_status()
try:
resp_data = response.json()
except json.JSONDecodeError:
resp_data = {'code': 0, 'data': response.text, 'msg': 'JSON解析失败'}
if 'code' not in resp_data:
await asyncio.sleep(i * 5)
continue
code = resp_data.get('code', 0)
if code in [40102, 40104]:
await asyncio.sleep(i * 5)
token = await self.get_access_token(oauth_id, force_refresh=True)
continue
if code in [40100, 40110]:
wait_time = min(2 * (2 ** (i - 1)), 10)
await asyncio.sleep(wait_time)
continue
if code == 50000:
await asyncio.sleep(i * 10)
continue
return resp_data
except (httpx.HTTPStatusError, httpx.RequestError):
await asyncio.sleep(i * 10)
continue
# 重试耗尽,日志记录
options_log = {}
for key, value in options.items():
if key == 'files':
options_log[key] = {k: (v[0], 'bytes_content', v[2]) for k, v in value.items()}
else:
options_log[key] = value
res = json.dumps(resp_data, ensure_ascii=False) if resp_data else ''
logger.error(
f'DouYin API request failed after {request_count} retries. '
f'url:{url};method:{method};oauth_id:{oauth_id};options:{json.dumps(options_log, ensure_ascii=False)};response:{res}'
)
if resp_data and resp_data.get("code", 0) != 0:
raise ValueError(f'接口返回错误[code:{resp_data.get("code")}]{resp_data.get("message", "接口异常")}')
raise ValueError("网络请求失败,请稍后重试")
async def request_with_context(
self,
url: str,
method: str = 'GET',
options: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
options = options or {}
resp_data: Optional[Dict[str, Any]] = None
for i in range(1, 3):
try:
headers = options.get('headers', {}).copy()
headers.setdefault('Content-Type', 'application/json')
options['headers'] = headers
response = await self.client.request(method, url, **options)
response.raise_for_status()
try:
resp_data = response.json()
except json.JSONDecodeError:
resp_data = {'code': 0, 'data': response.text}
if 'code' not in resp_data:
await asyncio.sleep(i * 5)
continue
if resp_data.get("code", 0) >= 50000:
await asyncio.sleep(i * 10)
continue
return resp_data
except (httpx.HTTPStatusError, httpx.RequestError):
await asyncio.sleep(i * 10)
continue
res = json.dumps(resp_data, ensure_ascii=False) if resp_data else ''
raise RuntimeError(
f'DouYin API request failed after 2 retries. '
f'url:{url};options:{json.dumps(options, ensure_ascii=False)};response:{res}'
)