新增统一时间工具,新增后台列表

This commit is contained in:
18610128193
2026-07-03 18:30:54 +08:00
parent a0016b436e
commit 3c0647a6aa
7 changed files with 1225 additions and 374 deletions
+2
View File
@@ -30,6 +30,7 @@ from app.api.v1.resources_material import router as resources_material_router
from app.api.v1.contact import router as contact_router
from app.api.v1.home_materials import router as home_materials_router
from app.api.admin import router as admin_module_router
from app.api.v1.material_admin import router as material_admin_router
api_router = APIRouter()
api_router.include_router(auth_router)
@@ -62,3 +63,4 @@ api_router.include_router(resources_material_router)
api_router.include_router(contact_router)
api_router.include_router(home_materials_router)
api_router.include_router(admin_module_router)
api_router.include_router(material_admin_router)
+168
View File
@@ -0,0 +1,168 @@
from typing import Any, Optional, List
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Query, Depends, Body
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.models.resources_material import ResourcesMaterial
from app.models.material_cost import MaterialCost
from app.models.user_oauth import UserOAuth
from app.models.user_oauth_account import UserOAuthAccount
from app.models.pre_test_template import PreTestTemplate
from app.models.upload_task import UploadTask
from app.dependencies import get_current_user, get_db, get_admin_user
from app.services.material_consumption_queue import sync_all_advertisers_consumption, _fetch_and_save_consumption
from app.services.material_consumption_service import get_consumption_list, format_consumption_response
from app.services.material_admin_service import get_oauth_list, get_material_list, get_pre_test_template_list, get_upload_task_list
router = APIRouter(prefix="/material-admin", tags=["material-admin"])
@router.get("/oauth-list", summary="管理员查看所有授权列表")
async def admin_get_oauth_list(
id: Optional[str] = Query(None, description="主键id"),
phone: Optional[str] = Query(None, description="用户登录手机号"),
account_id: Optional[str] = Query(None, description="授权账户id"),
account_userid: Optional[str] = Query(None, description="授权登录账号id"),
created_at: Optional[List[datetime]] = Query(None, description="授权时间"),
appid: Optional[str] = Query(None, description="应用id"),
open_type: Optional[int] = Query(None, description="开户方式"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(10, ge=1, le=3000, description="每页数量"),
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(get_admin_user),
) -> Any | dict:
try:
oauth_list, total = await get_oauth_list(
id=id,
db=db,
phone=phone,
account_id=account_id,
account_userid=account_userid,
created_at=created_at,
appid=appid,
open_type=open_type,
page=page,
page_size=page_size,
)
return {
"code": 0,
"data": oauth_list,
"pagination": {
"page": page,
"page_size": page_size,
"total": total,
},
}
except Exception as e:
return {
"code": 1,
"msg": str(e),
"data": None,
}
@router.get("/material-list", summary="管理员查看所有素材资源列表")
async def admin_get_material_list(
phone: Optional[str] = Query(None, description="用户登录手机号"),
id: Optional[str] = Query(None, description="主键id"),
resource_type: Optional[str] = Query(None, description="素材资源类型"),
advertiser_id: Optional[str] = Query(None, description="广告主id"),
material_id: Optional[str] = Query(None, description="素材id"),
upload_id: Optional[str] = Query(None, description="平台id【视频id/图片id】"),
created_at: Optional[List[datetime]] = Query(None, description="创建时间"),
status: Optional[str] = Query(None, description="前测状态"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(10, ge=1, le=3000, description="每页数量"),
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(get_admin_user),
) -> Any | dict:
materials, total = await get_material_list(
db=db,
phone=phone,
id=id,
resource_type=resource_type,
advertiser_id=advertiser_id,
material_id=material_id,
upload_id=upload_id,
created_at=created_at,
status=status,
page=page,
page_size=page_size,
)
return {
"code": 0,
"data": materials,
"pagination": {
"page": page,
"page_size": page_size,
"total": total,
},
}
@router.get("/pre-test-template-list", summary="管理员查看所有前测模板列表")
async def admin_get_pre_test_template_list(
phone: Optional[str] = Query(None, description="用户登录手机号"),
id: Optional[str] = Query(None, description="主键id"),
created_at: Optional[datetime] = Query(None, description="创建时间"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(10, ge=1, le=3000, description="每页数量"),
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(get_admin_user),
) -> Any | dict:
templates, total = await get_pre_test_template_list(
db=db,
phone=phone,
id=id,
created_at=created_at,
page=page,
page_size=page_size,
)
return {
"code": 0,
"data": templates,
"pagination": {
"page": page,
"page_size": page_size,
"total": total,
},
}
@router.get("/upload-task-list", summary="管理员查看推送任务列表")
async def admin_get_upload_task_list(
phone: Optional[str] = Query(None, description="用户登录手机号"),
id: Optional[str] = Query(None, description="主键id"),
created_at: Optional[List[str]] = Query(None, description="创建时间范围,示例: ['2023-01-01', '2023-03-01']"),
advertiser_id: Optional[str] = Query(None, description="广告主id"),
status: Optional[int] = Query(None, description="前测状态"),
resource_id: Optional[str] = Query(None, description="资源id"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(10, ge=1, le=3000, description="每页数量"),
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(get_admin_user),
) -> Any | dict:
tasks, total = await get_upload_task_list(
db=db,
phone=phone,
id=id,
created_at=created_at,
advertiser_id=advertiser_id,
status=status,
resource_id=resource_id,
page=page,
page_size=page_size,
)
return {
"code": 0,
"data": tasks,
"pagination": {
"page": page,
"page_size": page_size,
"total": total,
},
}
@@ -0,0 +1,379 @@
from typing import Optional, List, Tuple
from datetime import datetime
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.models.user_oauth import UserOAuth
from app.models.user_oauth_account import UserOAuthAccount
from app.models.resources_material import ResourcesMaterial
from app.models.pre_test_template import PreTestTemplate
from app.models.upload_task import UploadTask
from app.utils.datetime_util import BEIJING_TZ, datetime_to_db_tz_str, db_tz_str_to_datetime, parse_date_range
async def get_oauth_list(
id: Optional[str] = None,
db: AsyncSession = None,
phone: Optional[str] = None,
account_id: Optional[str] = None,
account_userid: Optional[str] = None,
created_at: Optional[datetime] = None,
appid: Optional[str] = None,
open_type: Optional[int] = None,
page: int = 1,
page_size: int = 10,
) -> Tuple[List[UserOAuth], int]:
offset = (page - 1) * page_size
query = select(UserOAuth).where(UserOAuth.deleted_at.is_(None))
if id:
query = query.where(UserOAuth.id == id)
if phone:
query = query.join(User, User.id == UserOAuth.user_id).where(User.phone == phone)
if account_id:
query = query.where(UserOAuth.account_id == account_id)
if account_userid:
query = query.where(UserOAuth.account_userid == account_userid)
# 【改动3】适配入参 created_at=["2025-01-01","2026-01-01"] 字符串日期场景
if created_at and len(created_at) == 2:
try:
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
query = query.where(
UserOAuth.created_at >= start_dt,
UserOAuth.created_at <= end_dt
)
except ValueError:
# 捕获日期格式错误,非法时间直接不附加该查询条件
pass
if appid:
query = query.where(UserOAuth.appid == appid)
if open_type:
query = query.where(UserOAuth.open_type == open_type)
count_query = select(func.count(UserOAuth.id)).where(UserOAuth.deleted_at.is_(None))
if phone:
count_query = count_query.join(User, User.id == UserOAuth.user_id).where(User.phone == phone)
if account_id:
count_query = count_query.where(UserOAuth.account_id == account_id)
if account_userid:
count_query = count_query.where(UserOAuth.account_userid == account_userid)
if created_at:
try:
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
count_query = count_query.where(
UserOAuth.created_at >= start_dt,
UserOAuth.created_at <= end_dt
)
except ValueError:
# 捕获日期格式错误,非法时间直接不附加该查询条件
pass
if appid:
count_query = count_query.where(UserOAuth.appid == appid)
if open_type:
count_query = count_query.where(UserOAuth.open_type == open_type)
total_result = await db.execute(count_query)
total = total_result.scalar_one()
query = query.order_by(UserOAuth.id.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
list = [];
for oauth in result.scalars().all():
user_query = select(User).where(User.id == oauth.user_id)
user_result = await db.execute(user_query)
user = user_result.scalar_one()
if user:
phone = user.phone
else:
phone = None
item = {
"id": oauth.id,
"account_id": oauth.account_id,
"account_name": oauth.account_name,
"account_role": oauth.account_role,
"account_username": oauth.account_username,
"account_userid": oauth.account_userid,
"user_id": oauth.user_id,
"user_phone": phone,
"open_type": oauth.open_type,
"port_type": oauth.port_type,
"appid": oauth.appid,
"access_token": oauth.access_token,
"refresh_token": oauth.refresh_token,
"access_token_expired": oauth.access_token_expired,
"refresh_token_expired": oauth.refresh_token_expired,
"created_at": oauth.created_at,
"updated_at": oauth.updated_at,
}
list.append(item)
return list, total
async def get_material_list(
db: AsyncSession,
phone: Optional[str] = None,
id: Optional[str] = None,
resource_type: Optional[str] = None,
advertiser_id: Optional[str] = None,
material_id: Optional[str] = None,
upload_id: Optional[str] = None,
created_at: Optional[datetime] = None,
status: Optional[str] = None,
page: int = 1,
page_size: int = 20,
) -> Tuple[List[ResourcesMaterial], int]:
offset = (page - 1) * page_size
query = select(ResourcesMaterial).where(ResourcesMaterial.deleted_at.is_(None))
if phone:
query = query.join(UserOAuth, UserOAuth.id == ResourcesMaterial.oauth_id).join(User, User.id == UserOAuth.user_id).where(User.phone == phone)
if id:
query = query.where(ResourcesMaterial.id == id)
if resource_type:
query = query.where(ResourcesMaterial.resource_type == resource_type)
if advertiser_id:
query = query.where(ResourcesMaterial.advertiser_id == advertiser_id)
if material_id:
query = query.where(ResourcesMaterial.material_id == material_id)
if upload_id:
query = query.where(ResourcesMaterial.upload_id == upload_id)
if created_at:
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
query = query.where(
ResourcesMaterial.created_at >= start_dt,
ResourcesMaterial.created_at <= end_dt
)
if status:
query = query.where(ResourcesMaterial.status == status)
count_query = select(func.count(ResourcesMaterial.id)).where(ResourcesMaterial.deleted_at.is_(None))
if phone:
count_query = count_query.join(UserOAuth, UserOAuth.id == ResourcesMaterial.oauth_id).join(User, User.id == UserOAuth.user_id).where(User.phone == phone)
if id:
count_query = count_query.where(ResourcesMaterial.id == id)
if resource_type:
count_query = count_query.where(ResourcesMaterial.resource_type == resource_type)
if advertiser_id:
count_query = count_query.where(ResourcesMaterial.advertiser_id == advertiser_id)
if material_id:
count_query = count_query.where(ResourcesMaterial.material_id == material_id)
if upload_id:
count_query = count_query.where(ResourcesMaterial.upload_id == upload_id)
if created_at:
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
count_query = count_query.where(
ResourcesMaterial.created_at >= start_dt,
ResourcesMaterial.created_at <= end_dt
)
if status:
count_query = count_query.where(ResourcesMaterial.status == status)
total_result = await db.execute(count_query)
total = total_result.scalar_one()
query = query.order_by(ResourcesMaterial.id.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
list = []
for item in result.scalars().all():
user_query = select(User).where(User.id == item.user_id)
user_result = await db.execute(user_query)
user = user_result.scalar_one()
if user:
phone = user.phone
else:
phone = None
item = {
"id": item.id,
"oauth_id": item.oauth_id,
"user_phone": phone,
"advertiser_id": item.advertiser_id,
"target_table": item.target_table,
"target_id": item.target_id,
"material_id": item.material_id,
"upload_id": item.upload_id,
"resource_type": item.resource_type,
"user_id": item.user_id,
"created_at": item.created_at,
"updated_at": item.updated_at,
"task_id": item.task_id,
"note": item.note,
"status": item.status,
"pre_result": item.pre_result,
"pre_test_template_id": item.pre_test_template_id,
}
list.append(item)
return list, total
async def get_pre_test_template_list(
db: AsyncSession,
phone: Optional[str] = None,
id: Optional[str] = None,
created_at: Optional[datetime] = None,
page: int = 1,
page_size: int = 10,
) -> Tuple[List[PreTestTemplate], int]:
offset = (page - 1) * page_size
query = select(PreTestTemplate).where(PreTestTemplate.deleted_at.is_(None))
if phone:
query = query.join(User, User.id == PreTestTemplate.user_id).where(User.phone == phone)
if id:
query = query.where(PreTestTemplate.id == id)
if created_at:
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
query = query.where(
PreTestTemplate.created_at >= start_dt,
PreTestTemplate.created_at <= end_dt
)
count_query = select(func.count(PreTestTemplate.id)).where(PreTestTemplate.deleted_at.is_(None))
if phone:
count_query = count_query.join(User, User.id == PreTestTemplate.user_id).where(User.phone == phone)
if id:
count_query = count_query.where(PreTestTemplate.id == id)
if created_at:
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
count_query = count_query.where(
PreTestTemplate.created_at >= start_dt,
PreTestTemplate.created_at <= end_dt
)
total_result = await db.execute(count_query)
total = total_result.scalar_one()
query = query.order_by(PreTestTemplate.id.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
list = []
for item in result.scalars().all():
user_query = select(User).where(User.id == item.user_id)
user_result = await db.execute(user_query)
user = user_result.scalar_one()
if user:
phone = user.phone
else:
phone = None
item = {
"id": item.id,
"name": item.name,
"user_phone": phone,
"user_id": item.user_id,
"platform": item.platform,
"external_action": item.external_action,
"cpa_bid": item.cpa_bid,
"audience_gender": item.audience_gender,
"audience_age": item.audience_age,
"audience_region": item.audience_region,
"audience_network": item.audience_network,
"cus_name": item.cus_name,
"pricing_type": item.pricing_type,
"cost_cap": item.cost_cap,
"target_cost": item.target_cost,
"nobid": item.nobid,
"cpc_bid": item.cpc_bid,
"budget": item.budget,
"is_default": item.is_default,
"created_at": item.created_at,
"updated_at": item.updated_at,
"note": item.note,
}
list.append(item)
return list, total
async def get_upload_task_list(
db: AsyncSession,
phone: Optional[str] = None,
id: Optional[str] = None,
created_at: Optional[List[str]] = None,
advertiser_id: Optional[str] = None,
status: Optional[int] = None,
resource_id: Optional[str] = None,
page: int = 1,
page_size: int = 10,
) -> Tuple[List[UploadTask], int]:
offset = (page - 1) * page_size
query = select(UploadTask).where(UploadTask.deleted_at.is_(None))
if phone:
query = query.join(User, User.id == UploadTask.user_id).where(User.phone == phone)
if id:
query = query.where(UploadTask.id == id)
if created_at and len(created_at) >= 2:
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
query = query.where(
UploadTask.created_at >= start_dt,
UploadTask.created_at <= end_dt
)
if advertiser_id:
query = query.where(UploadTask.advertiser_id == advertiser_id)
if status:
query = query.where(UploadTask.status == status)
if resource_id:
query = query.where(UploadTask.resource_id == resource_id)
count_query = select(func.count(UploadTask.id)).where(UploadTask.deleted_at.is_(None))
if phone:
count_query = count_query.join(User, User.id == UploadTask.user_id).where(User.phone == phone)
if id:
count_query = count_query.where(UploadTask.id == id)
if created_at and len(created_at) >= 2:
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
count_query = count_query.where(
UploadTask.created_at >= start_dt,
UploadTask.created_at <= end_dt
)
if advertiser_id:
count_query = count_query.where(UploadTask.advertiser_id == advertiser_id)
if status:
count_query = count_query.where(UploadTask.status == status)
if resource_id:
count_query = count_query.where(UploadTask.resource_id == resource_id)
total_result = await db.execute(count_query)
total = total_result.scalar_one()
query = query.order_by(UploadTask.id.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
list = []
for item in result.scalars().all():
user_query = select(User).where(User.id == item.user_id)
user_result = await db.execute(user_query)
user = user_result.scalar_one()
if user:
phone = user.phone
else:
phone = None
item = {
"id": item.id,
"user_phone": phone,
"user_id": item.user_id,
"advertiser_id": item.advertiser_id,
"resource_id": item.resource_id,
"status": item.status,
"note": item.note,
"oauth_id": item.oauth_id,
"created_at": item.created_at,
"updated_at": item.updated_at,
"other_info": item.other_info,
}
list.append(item)
return list, total
+208 -203
View File
@@ -1,20 +1,22 @@
import random
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta
import httpx
from sqlalchemy import select, func
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
# 引入项目统一北京时间时区
from app.utils.datetime_util import BEIJING_TZ
from app.models.user_oauth import UserOAuth
from app.models.user_oauth_app import UserOAuthApp
from app.utils.id_gen import generate_id
from app.tasks.token_refresh_task import _update_redis_token
from app.tasks.token_refresh_task import _update_redis_token, _delete_redis_token
#随机获取一个可用的应用配置
# 随机获取一个可用的应用配置
async def get_available_app(open_type: int, db: AsyncSession) -> dict:
# 从 user_oauth_app 表查询可用应用
# 过滤条件:open_type 匹配、status=1(正常)、deleted_at is None
if not isinstance(open_type, int):
raise ValueError("open_type必须为整数类型")
result = await db.execute(
select(UserOAuthApp).where(
UserOAuthApp.open_type == open_type,
@@ -30,8 +32,6 @@ async def get_available_app(open_type: int, db: AsyncSession) -> dict:
available_apps = []
for app in apps:
app_id = app.app_id
# 查询该应用当前授权数量
count_result = await db.execute(
select(func.count(UserOAuth.id)).where(
UserOAuth.appid == app_id,
@@ -39,8 +39,6 @@ async def get_available_app(open_type: int, db: AsyncSession) -> dict:
)
)
count = count_result.scalar() or 0
# 检查是否达到最大授权数
max_users = app.max_count
if count < max_users:
available_apps.append({
@@ -59,24 +57,21 @@ async def get_available_app(open_type: int, db: AsyncSession) -> dict:
async def build_oauth_url(open_type: int, user_id: str, db: AsyncSession) -> str:
if open_type == 1:
#千川
return await _build_jl_oauth_url(open_type, user_id, db)
elif open_type in [2, 3]:
#广告,本地推
return await _build_jl_oauth_url(2, user_id, db)
elif open_type == 5:
#快手代理商
return "无配置"
elif open_type == 9:
#腾讯营销K2
return "无配置"
elif open_type == 10:
#腾讯营销K3
return "无配置"
else:
raise ValueError(f"不支持的应用类型: {app_type}")
# 修复BUG:变量名错误 app_type -> open_type
raise ValueError(f"不支持的应用类型: {open_type}")
#千川授权链接构建
# 千川授权链接构建
async def _build_jl_oauth_url(open_type: int, user_id: str, db: AsyncSession) -> str:
app = await get_available_app(open_type, db)
app_id = app.get("app_id")
@@ -92,221 +87,232 @@ async def _build_jl_oauth_url(open_type: int, user_id: str, db: AsyncSession) ->
async def get_token(code: str, user_id: str, app_id: str, db: AsyncSession) -> dict:
#1.根据app_id查询应用配置
if not all([code, user_id, app_id]):
raise ValueError("code、user_id、app_id不能为空")
result = await db.execute(
select(UserOAuthApp).where(UserOAuthApp.app_id == app_id)
select(UserOAuthApp).where(
UserOAuthApp.app_id == app_id,
UserOAuthApp.deleted_at.is_(None),
UserOAuthApp.status == 1
)
)
app = result.scalar_one_or_none()
if not app:
raise ValueError("应用配置不存在")
raise ValueError("应用配置不存在或已禁用")
open_type = app.open_type
secret = app.secret
if open_type == 1 or open_type == 2:
#千川或广告
if open_type in (1, 2):
return await get_juliang_token(app_id, secret, code, open_type, user_id, db)
elif open_type == 5:
#快手代理商
return await get_kuaishou_token(app_id, secret, code, open_type, user_id, db)
elif open_type == 9 or open_type == 10:
#腾讯营销K2或腾讯营销K3
elif open_type in (9, 10):
return await get_tencent_token(app_id, secret, code, open_type, user_id, db)
else:
raise ValueError(f"不支持的应用类型: {open_type}")
async def get_juliang_token(app_id: str, secret: str, code: str, open_type: int, user_id: str, db: AsyncSession) -> dict:
async with httpx.AsyncClient() as client:
#1.请求token
url = "https://api.oceanengine.com/open_api/oauth2/access_token/"
response = await client.post(
url,
json={
"app_id": app_id,
"secret": secret,
"auth_code": code,
},
)
response.raise_for_status()
data = response.json()
if data.get("code") != 0:
raise ValueError(data.get("message", "获取token失败")+f",错误信息:{data.get('message', '')}")
data = data.get("data", {})
access_token = data.get("access_token", "")
refresh_token = data.get("refresh_token", "")
expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("expires_in", 0))
refresh_token_expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("refresh_token_expires_in", 0))
#2.获取已授权角色账户,一个授权可能有多个角色账户
url = "https://api.oceanengine.com/open_api/oauth2/advertiser/get/"
response = await client.get(
url,
headers={"Access-Token": access_token},
)
response.raise_for_status()
data = response.json()
if data.get("code") != 0:
raise ValueError(data.get("message", "获取授权账户失败")+f",错误信息:{data.get('message', '')}")
data = data.get("data", {})
account_list = data.get("list", [])
#3.获取已授权登录信息
url = "https://api.oceanengine.com/open_api/2/user/info/"
response = await client.get(
url,
headers={"Access-Token": access_token},
)
response.raise_for_status()
data = response.json()
if data.get("code") != 0:
raise ValueError(data.get("message", "获取已授权登录信息失败")+f",错误信息:{data.get('message', '')}")
data = data.get("data", {})
account_username = data.get("email", "")
account_userid = str(data.get("id", ""))
material_auth_status = data.get("material_auth_status", False)
#4.根据登录信息判断是否新增token或更新token,不同的登录信息对应不同的token,然后更新数据库
#查询emailappiduser_id是否存在已授权记录
oauth = await db.execute(
select(UserOAuth)
.where(UserOAuth.account_username == account_username, UserOAuth.account_userid == account_userid, UserOAuth.appid == app_id, UserOAuth.user_id == user_id)
.limit(1)
)
oauth = oauth.scalar_one_or_none()
if not oauth:
new_oauth_ids = []
for account in account_list:
oauth_id = generate_id()
new_oauth_ids.append(oauth_id)
#新增授权记录
db.add(UserOAuth(
id=oauth_id,
account_id = str(account.get("account_id", "")),
account_name = account.get("account_name", ""),
account_role = account.get("account_role", ""),
account_username = account_username,
account_userid = account_userid,
user_id = user_id,
appid = app_id,
open_type = open_type,
port_type = 1,
access_token = access_token,
access_token_expired = expires_in,
refresh_token = refresh_token,
refresh_token_expired = refresh_token_expires_in,
material_auth_status = material_auth_status,
))
await db.commit()
for oauth_id in new_oauth_ids:
await _update_redis_token(oauth_id, access_token, expires_in)
else:
# 查询现有授权记录(未删除的)
existing_accounts = await db.execute(
select(UserOAuth).where(
timeout = httpx.Timeout(30.0)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
# 1. 获取token
url = "https://api.oceanengine.com/open_api/oauth2/access_token/"
resp = await client.post(
url,
json={
"app_id": app_id,
"secret": secret,
"auth_code": code,
},
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
msg = data.get("message", "获取token失败")
raise ValueError(f"获取token失败:{msg}")
resp_data = data.get("data", {})
access_token = resp_data.get("access_token", "")
refresh_token = resp_data.get("refresh_token", "")
expires_sec = resp_data.get("expires_in", 7200)
refresh_expires_sec = resp_data.get("refresh_token_expires_in", 30 * 24 * 3600)
# 【修复:统一使用北京时间计算过期时间】
now_beijing = datetime.now(tz=BEIJING_TZ)
access_expired = now_beijing + timedelta(seconds=expires_sec)
refresh_expired = now_beijing + timedelta(seconds=refresh_expires_sec)
# 2. 获取授权广告账户列表
resp = await client.get(
"https://api.oceanengine.com/open_api/oauth2/advertiser/get/",
headers={"Access-Token": access_token},
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise ValueError(f"获取已授权账户失败:{data.get('message')}")
account_list = data.get("data", {}).get("list", [])
# 3. 获取登录用户信息
resp = await client.get(
"https://api.oceanengine.com/open_api/2/user/info/",
headers={"Access-Token": access_token},
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise ValueError(f"获取登录信息失败:{data.get('message')}")
user_info = data.get("data", {})
account_username = user_info.get("email", "")
account_userid = str(user_info.get("id", ""))
material_auth_status = user_info.get("material_auth_status", False)
# 4. 查询当前用户+应用+登录账号下的授权记录
oauth_query = select(UserOAuth).where(
UserOAuth.account_username == account_username,
UserOAuth.account_userid == account_userid,
UserOAuth.appid == app_id,
UserOAuth.user_id == user_id,
UserOAuth.deleted_at.is_(None)
)
oauth_exist = await db.execute(oauth_query.limit(1))
oauth_exist = oauth_exist.scalar_one_or_none()
if not oauth_exist:
# 新增授权
new_oauth_ids = []
for account in account_list:
oauth_id = generate_id()
new_oauth_ids.append(oauth_id)
db.add(UserOAuth(
id=oauth_id,
account_id=str(account.get("account_id", "")),
account_name=account.get("account_name", ""),
account_role=account.get("account_role", ""),
account_username=account_username,
account_userid=account_userid,
user_id=user_id,
appid=app_id,
open_type=open_type,
port_type=1,
access_token=access_token,
access_token_expired=access_expired,
refresh_token=refresh_token,
refresh_token_expired=refresh_expired,
material_auth_status=material_auth_status,
))
await db.commit()
# 批量写入Redis
for oauth_id in new_oauth_ids:
await _update_redis_token(oauth_id, access_token, access_expired)
else:
# 查询当前所有有效授权账户
exist_query = select(UserOAuth).where(
UserOAuth.account_username == account_username,
UserOAuth.account_userid == account_userid,
UserOAuth.appid == app_id,
UserOAuth.user_id == user_id,
UserOAuth.deleted_at.is_(None),
)
)
existing_accounts = {acc.account_id: acc for acc in existing_accounts.scalars().all()}
exist_res = await db.execute(exist_query)
exist_map = {item.account_id: item for item in exist_res.scalars().all()}
# 新的账户列表
new_account_ids = {str(account.get("account_id")) for account in account_list}
old_account_ids = set(existing_accounts.keys())
new_account_ids = {str(acc.get("account_id")) for acc in account_list}
old_account_ids = set(exist_map.keys())
update_redis_ids = []
# 需要更新Redis的oauth_id列表
update_redis_ids = []
# 下线的账户:软删除 + 清理Redis脏缓存
for del_account_id in old_account_ids - new_account_ids:
del_oauth = exist_map[del_account_id]
del_oauth.deleted_at = now_beijing
await _delete_redis_token(del_oauth.id)
# 1. 软删除已消失的账户
for account_id in old_account_ids - new_account_ids:
existing_accounts[account_id].deleted_at = datetime.now(timezone.utc)
# 2. 更新或新增账户
for account in account_list:
account_id = str(account.get("account_id", ""))
if account_id in existing_accounts:
# 更新现有记录
existing_oauth = existing_accounts[account_id]
existing_oauth.account_name = account.get("account_name", "")
existing_oauth.account_role = account.get("account_role", "")
existing_oauth.access_token = access_token
existing_oauth.access_token_expired = expires_in
existing_oauth.refresh_token = refresh_token
existing_oauth.refresh_token_expired = refresh_token_expires_in
existing_oauth.material_auth_status = material_auth_status
update_redis_ids.append(existing_oauth.id)
else:
# 新增记录
oauth_id = generate_id()
update_redis_ids.append(oauth_id)
db.add(UserOAuth(
# 更新/新增当前授权账户
for account in account_list:
aid = str(account.get("account_id", ""))
if aid in exist_map:
item = exist_map[aid]
item.account_name = account.get("account_name", "")
item.account_role = account.get("account_role", "")
item.access_token = access_token
item.access_token_expired = access_expired
item.refresh_token = refresh_token
item.refresh_token_expired = refresh_expired
item.material_auth_status = material_auth_status
update_redis_ids.append(item.id)
else:
oauth_id = generate_id()
update_redis_ids.append(oauth_id)
db.add(UserOAuth(
id=oauth_id,
account_id=str(account_id),
account_id=aid,
account_name=account.get("account_name", ""),
account_role=account.get("account_role", ""),
account_username=account_username,
account_userid = account_userid,
account_userid=account_userid,
user_id=user_id,
appid=app_id,
open_type=open_type,
port_type=1,
access_token = access_token,
access_token_expired=expires_in,
access_token=access_token,
access_token_expired=access_expired,
refresh_token=refresh_token,
refresh_token_expired=refresh_token_expires_in,
refresh_token_expired=refresh_expired,
material_auth_status=material_auth_status,
))
await db.commit()
for oauth_id in update_redis_ids:
await _update_redis_token(oauth_id, access_token, expires_in)
await db.commit()
# 更新有效账号缓存
for oid in update_redis_ids:
await _update_redis_token(oid, access_token, access_expired)
#5.本次更新成功以后,判断是否有其他同一个appid,同一个授权登录账号的授权记录,如果有,则更新token信息
from sqlalchemy import update
related_oauths = await db.execute(
select(UserOAuth).where(
# 5. 同登录账号、同应用、其他用户下的授权批量同步最新token
related_query = select(UserOAuth).where(
UserOAuth.account_username == account_username,
UserOAuth.account_userid == account_userid,
UserOAuth.appid == app_id,
UserOAuth.user_id != user_id,
UserOAuth.deleted_at.is_(None),
)
)
related_oauths = related_oauths.scalars().all()
if related_oauths:
await db.execute(
update(UserOAuth).where(
UserOAuth.account_username == account_username,
UserOAuth.account_userid == account_userid,
UserOAuth.appid == app_id,
UserOAuth.user_id != user_id,
UserOAuth.deleted_at.is_(None),
).values(
access_token=access_token,
access_token_expired=expires_in,
refresh_token=refresh_token,
refresh_token_expired=refresh_token_expires_in,
material_auth_status=material_auth_status,
related_res = await db.execute(related_query)
related_list = related_res.scalars().all()
if related_list:
await db.execute(
update(UserOAuth).where(
UserOAuth.account_username == account_username,
UserOAuth.account_userid == account_userid,
UserOAuth.appid == app_id,
UserOAuth.user_id != user_id,
UserOAuth.deleted_at.is_(None),
).values(
access_token=access_token,
access_token_expired=access_expired,
refresh_token=refresh_token,
refresh_token_expired=refresh_expired,
material_auth_status=material_auth_status,
)
)
)
await db.commit()
for related_oauth in related_oauths:
await _update_redis_token(related_oauth.id, access_token, expires_in)
await db.commit()
for item in related_list:
await _update_redis_token(item.id, access_token, access_expired)
return {"message": "授权成功"}
except httpx.HTTPError as e:
raise ValueError(f"第三方接口请求异常:{str(e)}")
except Exception as e:
raise ValueError(f"授权处理异常:{str(e)}")
return {"message": "授权成功"}
async def get_kuaishou_token(app_id: str, secret: str, code: str, oauth_type: int, user_id: str, db: AsyncSession) -> dict:
return "未配置"
return {"message": "快手渠道暂未实现授权逻辑"}
async def get_tencent_token(app_id: str, secret: str, code: str, oauth_type: int, user_id: str, db: AsyncSession) -> str:
return "未配置"
return "腾讯营销渠道暂未实现授权逻辑"
async def get_oauth_list(
@@ -318,36 +324,35 @@ async def get_oauth_list(
page: int = 1,
page_size: int = 10,
) -> dict:
if page < 1:
page = 1
if page_size < 1:
page_size = 10
# 分页参数容错
page = max(page, 1)
page_size = max(min(page_size, 100), 1)
query = select(UserOAuth).where(
base_where = [
UserOAuth.user_id == user_id,
UserOAuth.deleted_at.is_(None),
)
]
if account_userid:
query = query.where(UserOAuth.account_userid == account_userid)
base_where.append(UserOAuth.account_userid == account_userid)
if open_type:
query = query.where(UserOAuth.open_type == open_type)
base_where.append(UserOAuth.open_type == open_type)
if account_id:
query = query.where(UserOAuth.account_id == account_id)
base_where.append(UserOAuth.account_id == account_id)
query = query.order_by(UserOAuth.created_at.desc())
# 统计总条数(优化:使用count,避免全量查询)
count_stmt = select(func.count(UserOAuth.id)).where(*base_where)
total = await db.scalar(count_stmt) or 0
total_result = await db.execute(query.with_only_columns(UserOAuth.id))
total = len(total_result.scalars().all())
offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size)
result = await db.execute(query)
oauth_list = result.scalars().all()
# 分页查询数据
data_stmt = select(UserOAuth).where(*base_where)\
.order_by(UserOAuth.created_at.desc())\
.offset((page - 1) * page_size)\
.limit(page_size)
result = await db.execute(data_stmt)
data_list = result.scalars().all()
return {
"data": oauth_list,
"data": data_list,
"total": total,
"page": page,
"page_size": page_size,
+87 -80
View File
@@ -1,49 +1,64 @@
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta
import asyncio
import httpx
import json
from typing import Optional
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
# 引入自定义北京时间工具类
from app.utils.datetime_util import BEIJING_TZ
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
from app.utils.logger import get_logger
from app.utils.datetime_util import datetime_to_db_tz_str, db_tz_str_to_datetime
REDIS_KEY = "douyin:tokens"
logger = get_logger("token_refresh", "token_refresh")
# 配置常量
REFRESH_THRESHOLD_SECONDS = 800
CHECK_INTERVAL_MINUTES = 5
HTTP_TIMEOUT = httpx.Timeout(30.0)
async def _delete_redis_token(oauth_id: str):
"""【改动1:废弃空值覆盖,直接删除Hash脏缓存】refresh失效则移除字段"""
redis = get_redis()
if not redis:
logger.warning("Redis连接未配置,跳过缓存清理")
return
try:
await redis.hdel(REDIS_KEY, oauth_id)
logger.info(f"清理失效授权Redis缓存: oauth_id={oauth_id}")
except Exception as e:
logger.error(f"删除Redis缓存失败 oauth_id:{oauth_id}, err:{str(e)}")
async def _update_redis_token(oauth_id: str, token: str, expired_at: datetime):
"""更新Redis缓存中的token"""
"""更新Redis缓存中的token(统一北京时间序列化)"""
redis = get_redis()
if not redis:
logger.warning("Redis连接未配置,跳过缓存更新")
return
try:
cache = {
"token": token,
"expired_at": expired_at.isoformat(),
}
"token": token,
"expired_at": datetime_to_db_tz_str(expired_at),
}
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)}")
logger.error(f"更新Redis缓存失败 oauth_id:{oauth_id}, err:{str(e)}")
async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSession):
"""刷新巨量引擎token"""
try:
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
url = "https://api.oceanengine.com/open_api/oauth2/refresh_token/"
response = await client.post(
url,
@@ -55,13 +70,11 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
)
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
# refresh_token已失效
if data.get("code") in [40103, 40107]:
#清空数据库中的token信息,和Redis缓存中的token
from sqlalchemy import update
where_cond = UserOAuth.deleted_at.is_(None)
if oauth.appid:
where_cond = where_cond & (UserOAuth.appid == oauth.appid)
@@ -69,7 +82,7 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
where_cond = where_cond & (UserOAuth.account_username == oauth.account_username)
if oauth.account_userid:
where_cond = where_cond & (UserOAuth.account_userid == oauth.account_userid)
await db.execute(
update(UserOAuth).where(where_cond).values(
access_token=None,
@@ -79,25 +92,23 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
)
)
await db.commit()
related_oauth_ids = await db.execute(
select(UserOAuth.id).where(where_cond)
)
related_oauth_ids = await db.execute(select(UserOAuth.id).where(where_cond))
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
# 【改动2:失效直接删除Redis缓存,不再写入空值】
for related_id in related_oauth_ids:
await _update_redis_token(related_id, "", None)
await _delete_redis_token(related_id)
return
data = data.get("data", {})
new_access_token = data.get("access_token", "")
new_refresh_token = data.get("refresh_token", "")
expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("expires_in", 0))
refresh_token_expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("refresh_token_expires_in", 0))
resp_data = data.get("data", {})
new_access_token = resp_data.get("access_token", "")
new_refresh_token = resp_data.get("refresh_token", "")
# 【改动3:全部使用北京时间计算过期时间,统一时区】
now_beijing = datetime.now(tz=BEIJING_TZ)
expires_in = now_beijing + timedelta(seconds=resp_data.get("expires_in", 0))
refresh_expires = now_beijing + timedelta(seconds=resp_data.get("refresh_token_expires_in", 0))
from sqlalchemy import update
where_cond = UserOAuth.deleted_at.is_(None)
if oauth.appid:
where_cond = where_cond & (UserOAuth.appid == oauth.appid)
@@ -105,25 +116,26 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
where_cond = where_cond & (UserOAuth.account_username == oauth.account_username)
if oauth.account_userid:
where_cond = where_cond & (UserOAuth.account_userid == oauth.account_userid)
await db.execute(
update(UserOAuth).where(where_cond).values(
access_token=new_access_token,
access_token_expired=expires_in,
refresh_token=new_refresh_token,
refresh_token_expired=refresh_token_expires_in,
refresh_token_expired=refresh_expires,
)
)
await db.commit()
related_oauth_ids = await db.execute(
select(UserOAuth.id).where(where_cond)
)
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 _update_redis_token(related_id, new_access_token, expires_in)
logger.info(f"成功刷新巨量引擎token: oauth_id={oauth.id}, account_id={oauth.account_id}, 关联账户数={len(related_oauth_ids)}")
logger.info(
f"成功刷新巨量引擎token: oauth_id={oauth.id}, "
f"account_id={oauth.account_id}, 关联账户数={len(related_oauth_ids)}"
)
except httpx.HTTPError as e:
logger.error(f"HTTP请求失败: oauth_id={oauth.id}, 错误: {str(e)}")
except Exception as e:
@@ -133,93 +145,88 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
async def check_and_refresh_tokens():
"""检查并刷新即将过期的token"""
async with async_session() as db:
now = datetime.now(timezone.utc)
# 【改动4:统一使用北京时间当前时间,避免时区运算异常】
now_beijing = datetime.now(tz=BEIJING_TZ)
query = select(UserOAuth).where(
UserOAuth.deleted_at.is_(None),
UserOAuth.refresh_token.is_not(None),
UserOAuth.refresh_token_expired.is_not(None),
UserOAuth.refresh_token_expired > now,
UserOAuth.refresh_token_expired > now_beijing,
)
result = await db.execute(query)
oauth_list = result.scalars().all()
refreshed_keys = set()
for oauth in oauth_list:
try:
#检查是否为支持的平台(巨量引擎)
# port_type: 平台端口(1=巨量,2=磁力,3=巨量星图,4=服务单,5=腾讯)
if oauth.port_type not in [1]:
continue
#构建登录账号唯一标识,同一登录账号共享token
# 唯一键优化:过滤空值避免拼接异常
key_parts = []
if oauth.appid:
key_parts.append(oauth.appid)
if oauth.account_username:
key_parts.append(oauth.account_username)
if oauth.account_userid:
key_parts.append(oauth.account_userid)
key_parts.append(str(oauth.account_userid))
login_key = "|".join(key_parts)
#同一登录账号已刷新过,直接跳过(避免使用旧数据判断)
if login_key in refreshed_keys:
logger.debug(f"跳过重复刷新: oauth_id={oauth.id}, 同一登录账号已刷新")
continue
#获取应用配置
# 【改动5:过滤已禁用、已删除应用】
app_result = await db.execute(
select(UserOAuthApp).where(UserOAuthApp.app_id == oauth.appid)
select(UserOAuthApp).where(
UserOAuthApp.app_id == oauth.appid,
UserOAuthApp.status == 1,
UserOAuthApp.deleted_at.is_(None)
)
)
app = app_result.scalar_one_or_none()
if not app:
logger.warning(f"oauth_id:{oauth.id} 对应应用不存在/已禁用,跳过刷新")
continue
#检查access_token是否需要刷新
need_refresh = False
# access_token为空,需要刷新
if not oauth.access_token:
if not oauth.access_token or not oauth.access_token_expired:
need_refresh = True
# access_token_expired为空,需要刷新
elif not oauth.access_token_expired:
need_refresh = True
# access_token即将过期(剩余时间小于800秒),需要刷新
else:
remaining_seconds = (oauth.access_token_expired - now).total_seconds()
if remaining_seconds < REFRESH_THRESHOLD_SECONDS:
# 同时区时间运算,不会抛异常
remain_sec = (oauth.access_token_expired - now_beijing).total_seconds()
if remain_sec < REFRESH_THRESHOLD_SECONDS:
need_refresh = True
if not need_refresh:
continue
#refresh_token已在查询条件中过滤,确保有效才能刷新
#刷新token
await refresh_juliang_token(oauth, app, db)
refreshed_keys.add(login_key)
# 简单限流,防止瞬间大量请求
await asyncio.sleep(0.2)
except Exception as e:
#7.增加错误日志
logger.error(f"刷新token失败: {str(e)}")
logger.error(f"oauth_id:{oauth.id} 刷新token异常: {str(e)}", exc_info=True)
async def token_refresh_scheduler():
"""定时任务调度器"""
logger.info("开始执行定时Token刷新任务")
while True:
try:
await check_and_refresh_tokens()
except Exception as e:
logger.error(f"定时任务token_refresh_scheduler执行失败: {str(e)}")
logger.error(f"定时任务token_refresh_scheduler执行失败: {str(e)}", exc_info=True)
await asyncio.sleep(CHECK_INTERVAL_MINUTES * 60)
def start_token_refresh_task():
"""启动token刷新定时任务"""
logger.info("启动token刷新定时任务")
asyncio.create_task(token_refresh_scheduler())
logger.info("启动token刷新定时后台任务")
asyncio.create_task(token_refresh_scheduler())
+270
View File
@@ -0,0 +1,270 @@
from datetime import datetime, timedelta, timezone, date
from typing import Optional, Union, Tuple
from dateutil import parser
# ===================== 时区与格式化常量(PHP风格映射) =====================
# 北京时间 东八区 UTC+8
BEIJING_TZ = timezone(timedelta(hours=8))
UTC_TZ = timezone.utc
# Python strftime 标准格式
FORMAT_DATETIME = "%Y-%m-%d %H:%M:%S"
FORMAT_DATE = "%Y-%m-%d"
FORMAT_TIME = "%H:%M:%S"
# PHP格式 -> Python格式 映射,贴近PHP使用习惯
PHP_FMT_MAP = {
"Y-m-d": FORMAT_DATE,
"Y-m-d H:i:s": FORMAT_DATETIME,
"H:i:s": FORMAT_TIME
}
# ===================== 私有公共工具函数(内部复用) =====================
def _normalize_date_str(date_str: str) -> str:
"""
预处理中文格式日期字符串,统一转为横杠分隔标准格式
支持:2025年01月01日 12时30分00秒 / 20250101 等格式
"""
if not isinstance(date_str, str):
return ""
s = date_str.strip()
# 中文、全角符号替换清洗
s = s.replace("", ":") \
.replace("", "-") \
.replace("", "-") \
.replace("", " ") \
.replace("", ":") \
.replace("", ":") \
.replace("", "")
return s
def _get_python_fmt(fmt: str) -> str:
"""兼容PHP格式字符串,自动转为Python strftime格式"""
return PHP_FMT_MAP.get(fmt, fmt)
def _convert_ts_to_datetime(timestamp: Union[int, float], tz: timezone = BEIJING_TZ) -> datetime:
"""
统一处理 10位秒 / 13位毫秒 时间戳 -> 带时区datetime
"""
ts = float(timestamp)
# 毫秒时间戳兼容
if ts > 10 ** 12:
ts /= 1000
return datetime.fromtimestamp(ts, tz=tz)
def _parse_datetime_with_tz(dt_str: str, fmt: str, tz: timezone = BEIJING_TZ) -> Optional[datetime]:
"""
通用:固定格式日期字符串解析+绑定时区,支持中文日期预处理
仅支持指定fmt格式,不支持带时区后缀字符串
"""
try:
fmt = _get_python_fmt(fmt)
normalize_str = _normalize_date_str(dt_str)
dt = datetime.strptime(normalize_str, fmt)
return dt.replace(tzinfo=tz)
except (ValueError, TypeError):
return None
def _smart_parse_datetime(dt_str: str, base_dt: datetime, is_timezone: bool) -> Optional[datetime]:
"""
私有:dateutil智能解析日期(用于带时区、相对时间、中文复杂日期)
"""
clean_str = _normalize_date_str(dt_str)
try:
parsed_dt = parser.parse(clean_str, default=base_dt, tzinfos=None)
if is_timezone:
# 保留原始时区,无时区则兜底北京时间
if parsed_dt.tzinfo is None:
parsed_dt = parsed_dt.replace(tzinfo=BEIJING_TZ)
else:
# 普通日期强制绑定北京时间
parsed_dt = parsed_dt.replace(tzinfo=BEIJING_TZ)
return parsed_dt
except (parser.ParserError, ValueError, TypeError):
return None
# ===================== 对外工具方法(对标PHP + dateutil增强) =====================
def time() -> int:
"""
对标 PHP time()
获取当前北京时间 10位 秒级时间戳
"""
return int(datetime.now(tz=BEIJING_TZ).timestamp())
def microtime(get_as_float: bool = False) -> Union[float, str]:
"""
对标 PHP microtime()
:param get_as_float: True 返回浮点时间戳,False 返回 "微秒 秒" 字符串
"""
now = datetime.now(tz=BEIJING_TZ)
ts = now.timestamp()
if get_as_float:
return ts
sec = int(ts)
usec = int((ts - sec) * 1000000)
return f"{usec:06d} {sec}"
def strtotime(
time_str: str,
now: Optional[int] = None,
is_timezone: bool = False
) -> Optional[int]:
"""
【增强版 对标 PHP strtotime】依托 dateutil 支持相对时间、带时区时间解析
:param time_str: 待解析日期/相对时间字符串
:param now: 基准时间戳,默认当前北京时间
:param is_timezone: 是否为带时区格式的时间字符串(如 2026-07-03 10:01:29.621351+08
- True:优先使用字符串自带时区,无时区则兜底北京时间
- False:默认按北京时间解析普通日期字符串
:return: 秒级时间戳,解析失败返回 None
"""
if not isinstance(time_str, str) or not time_str.strip():
return None
# 基准时间:默认当前北京时间
base_dt = datetime.fromtimestamp(now, tz=BEIJING_TZ) if now else datetime.now(tz=BEIJING_TZ)
parsed_dt = _smart_parse_datetime(time_str, base_dt, is_timezone)
if not parsed_dt:
return None
return int(parsed_dt.timestamp())
def date(fmt: str, timestamp: Optional[int] = None) -> str:
"""
对标 PHP date()
时间戳格式化,默认北京时间
:param fmt: 支持 Y-m-d / Y-m-d H:i:s 或 %Y-%m-%d 原生格式
:param timestamp: 秒时间戳,None则取当前时间
"""
if timestamp is None:
dt = datetime.now(tz=BEIJING_TZ)
else:
dt = _convert_ts_to_datetime(timestamp)
python_fmt = _get_python_fmt(fmt)
return dt.strftime(python_fmt)
def timestamp_to_datetime(timestamp: Union[int, float], fmt: str = "Y-m-d H:i:s") -> str:
"""
时间戳(秒/毫秒)→ 北京时间格式化字符串
:param timestamp: 10位秒 /13位毫秒
:param fmt: PHP风格格式 Y-m-d / Y-m-d H:i:s
"""
dt = _convert_ts_to_datetime(timestamp)
python_fmt = _get_python_fmt(fmt)
return dt.strftime(python_fmt)
def datetime_to_timestamp(dt_str: str, fmt: str = "Y-m-d H:i:s") -> Optional[int]:
"""
日期字符串(含中文日期) → 北京时间秒时间戳
:param dt_str: 日期字符串
:param fmt: PHP风格格式化模板
:return: 秒时间戳,解析失败返回None
"""
dt = _parse_datetime_with_tz(dt_str, fmt)
if not dt:
return None
return int(dt.timestamp())
def get_today_start_timestamp() -> int:
"""获取今日 00:00:00 北京时间 秒时间戳"""
today: date = datetime.now(tz=BEIJING_TZ).date()
start_dt = datetime.combine(today, datetime.min.time(), tzinfo=BEIJING_TZ)
return int(start_dt.timestamp())
def get_today_end_timestamp() -> int:
"""获取今日 23:59:59.999999 北京时间 秒时间戳"""
today: date = datetime.now(tz=BEIJING_TZ).date()
end_dt = datetime.combine(today, datetime.max.time(), tzinfo=BEIJING_TZ)
return int(end_dt.timestamp())
def parse_date_range(date_list: list, fmt: str = "Y-m-d", is_timezone: bool = False) -> Optional[Tuple[datetime, datetime]]:
"""
时间范围解析:["2025-01-01","2026-01-01"] → (开始0点,结束23:59:59.999999) 带北京时间
:param date_list: 长度为2的日期字符串数组
:param fmt: 日期格式,默认Y-m-d
:param is_timezone: 是否为带时区格式的时间字符串(如 2026-07-03 10:01:29.621351+08
- True:使用dateutil智能解析,优先保留字符串自带时区,无时区兜底北京时间
- False:按指定fmt格式精准解析,强制北京时间
:return: (start_dt, end_dt) 格式非法返回None
"""
# 强参数校验
if not isinstance(date_list, list) or len(date_list) != 2:
return None
start_str, end_str = date_list[0].strip(), date_list[1].strip()
if not start_str or not end_str:
return None
base_now = datetime.now(tz=BEIJING_TZ)
if is_timezone:
# 带时区场景:智能解析,支持 2026-07-03 08:16:26.88303+08
start_dt = _smart_parse_datetime(start_str, base_now, is_timezone=True)
end_dt = _smart_parse_datetime(end_str, base_now, is_timezone=True)
else:
# 普通前端日期:固定格式解析
start_dt = _parse_datetime_with_tz(start_str, fmt, BEIJING_TZ)
end_dt = _parse_datetime_with_tz(end_str, fmt, BEIJING_TZ)
if not start_dt or not end_dt:
return None
# 结束时间补全到当日最后一毫秒
end_dt = end_dt.replace(hour=23, minute=59, second=59, microsecond=999999)
return start_dt, end_dt
def db_tz_str_to_datetime(db_datetime_str: str) -> Optional[datetime]:
"""
数据库timestamptz格式字符串(2026-07-03 08:16:26.88303+08)转为带时区datetime
"""
try:
dt = datetime.fromisoformat(db_datetime_str)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=BEIJING_TZ)
return dt
except (ValueError, TypeError):
return None
def datetime_to_db_tz_str(dt: datetime) -> str:
"""
带时区datetime转为数据库timestamptz标准字符串,统一北京时间存储
"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=BEIJING_TZ)
else:
dt = dt.astimezone(BEIJING_TZ)
return dt.isoformat()
# 导出列表
__all__ = [
"BEIJING_TZ",
"UTC_TZ",
"FORMAT_DATETIME",
"FORMAT_DATE",
"FORMAT_TIME",
"time",
"microtime",
"strtotime",
"date",
"timestamp_to_datetime",
"datetime_to_timestamp",
"get_today_start_timestamp",
"get_today_end_timestamp",
"parse_date_range",
"db_tz_str_to_datetime",
"datetime_to_db_tz_str"
]
+111 -91
View File
@@ -5,8 +5,14 @@ from typing import Any, Dict, Optional, Tuple
import httpx
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timezone, timedelta
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
@@ -40,50 +46,75 @@ class DouyinRequest:
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 cache_str:
cache = json.loads(cache_str)
token = cache.get("token")
expired_at_str = cache.get("expired_at")
if token and expired_at_str:
expired_at = datetime.fromisoformat(expired_at_str).replace(tzinfo=timezone.utc)
if expired_at > datetime.now(timezone.utc):
return token
except Exception as e:
raise ValueError(f"获取Redis缓存失败: {e}")
if not cache_str:
return None
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:
cache = {
"token": token,
"expired_at": expired_at.isoformat(),
}
await redis.hset(self._redis_key, oauth_id, json.dumps(cache))
await redis.hset(self._redis_key, oauth_id, json.dumps(cache, ensure_ascii=False))
except Exception as e:
raise ValueError(f"设置Redis缓存失败: {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缓存失败: {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:
@@ -101,7 +132,11 @@ class DouyinRequest:
if not oauth_data:
raise ValueError("无效的oauth_id")
# 【改动5:统一北京时间当前时间】
now_beijing = datetime.now(tz=BEIJING_TZ)
if force_refresh:
# 强制刷新:清空同条件下所有账号缓存与数据库token
where_cond = UserOAuth.deleted_at.is_(None)
if oauth_data.appid:
where_cond = where_cond & (UserOAuth.appid == oauth_data.appid)
@@ -118,9 +153,7 @@ class DouyinRequest:
)
await db.commit()
related_oauth_ids = await db.execute(
select(UserOAuth.id).where(where_cond)
)
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)
@@ -128,12 +161,14 @@ class DouyinRequest:
new_token, new_expired_at = await self.refresh_access_token(db, oauth_id, oauth_data.appid, oauth_data.refresh_token)
return new_token
if oauth_data.access_token_expired and oauth_data.access_token_expired > datetime.now(timezone.utc):
# 数据库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)
@@ -146,7 +181,7 @@ class DouyinRequest:
select(UserOAuth.access_token, UserOAuth.access_token_expired).where(
where_cond,
UserOAuth.access_token_expired.is_not(None),
UserOAuth.access_token_expired > datetime.now(timezone.utc),
UserOAuth.access_token_expired > now_beijing,
).limit(1)
)
related_oauth = related_oauths.first()
@@ -155,9 +190,13 @@ class DouyinRequest:
await self._set_redis_token(oauth_id, token, expired_at)
return token
if oauth_data.refresh_token_expired and oauth_data.refresh_token_expired < datetime.now(timezone.utc):
raise ValueError("授权已过期,请重新授权")
# =========【核心业务规则实现:判断RefreshToken是否过期】=========
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
@@ -171,7 +210,7 @@ class DouyinRequest:
oauth_info = oauth_info.first()
if not oauth_info:
raise ValueError("无效的oauth_id")
account_username, account_userid = oauth_info
result = await db.execute(
@@ -211,7 +250,10 @@ class DouyinRequest:
expires_in = data.get('expires_in', 0)
refresh_token_expires_in = data.get('refresh_token_expires_in', 0)
new_expired_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
# 【改动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:
@@ -222,20 +264,16 @@ class DouyinRequest:
where_cond = where_cond & (UserOAuth.account_userid == account_userid)
await db.execute(
update(UserOAuth).where(
where_cond,
).values(
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=datetime.now(timezone.utc) + timedelta(seconds=refresh_token_expires_in),
refresh_token_expired=new_refresh_expired,
)
)
await db.commit()
related_oauth_ids = await db.execute(
select(UserOAuth.id).where(where_cond)
)
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:
@@ -243,92 +281,79 @@ class DouyinRequest:
return new_access_token, new_expired_at
# 有token请求
async def request_with_token_with_context(
self,
oauth_id: str,
url: str,
method: str = 'GET',
options: any = None,
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):
# 有一些错误是触发频次管理的,需要重试
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:
# 移除可能错误设置的 Content-Type,让库自动生成 multipart 头
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:
data = response.json()
except json.JSONDecodeError:
data = {'code': 0, 'data': response.text, 'msg':'JSON解析失败'}
if 'code' not in data:
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 = data.get('code', 0)
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 data
return resp_data
except httpx.HTTPStatusError as e:
await asyncio.sleep(i * 10)
continue
except httpx.RequestError as e:
except (httpx.HTTPStatusError, httpx.RequestError):
await asyncio.sleep(i * 10)
continue
# 重试耗尽,日志记录
options_log = {}
if options:
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(data, ensure_ascii=False) if 'data' in locals() else ''
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 'data' in locals() and data.get('code', 0) != 0:
raise ValueError(f'接口返回错误[code:{data.get("code", "接口编码")}]{data.get("message", "接口返回错误")}')
else:
raise ValueError('网络错误,稍后重试。')
if resp_data and resp_data.get("code", 0) != 0:
raise ValueError(f'接口返回错误[code:{resp_data.get("code")}]{resp_data.get("message", "接口异常")}')
raise ValueError("网络请求失败,请稍后重试")
# 无token请求
async def request_with_context(
self,
url: str,
@@ -336,8 +361,9 @@ class DouyinRequest:
options: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
options = options or {}
resp_data: Optional[Dict[str, Any]] = None
for i in range(1, 2):
for i in range(1, 3):
try:
headers = options.get('headers', {}).copy()
headers.setdefault('Content-Type', 'application/json')
@@ -347,30 +373,24 @@ class DouyinRequest:
response.raise_for_status()
try:
data = response.json()
resp_data = response.json()
except json.JSONDecodeError:
data = {'code': 0, 'data': response.text}
resp_data = {'code': 0, 'data': response.text}
if 'code' not in data:
if 'code' not in resp_data:
await asyncio.sleep(i * 5)
continue
code = data.get('code', 0)
if code >= 50000:
if resp_data.get("code", 0) >= 50000:
await asyncio.sleep(i * 10)
continue
return resp_data
return data
except httpx.HTTPStatusError as e:
await asyncio.sleep(i * 10)
continue
except httpx.RequestError as e:
except (httpx.HTTPStatusError, httpx.RequestError):
await asyncio.sleep(i * 10)
continue
res = json.dumps(data) if 'data' in locals() else ''
res = json.dumps(resp_data, ensure_ascii=False) if resp_data else ''
raise RuntimeError(
f'DouYin API request failed after 5 retries. '
f'url:{url};options:{json.dumps(options)};response:{res}'
f'DouYin API request failed after 2 retries. '
f'url:{url};options:{json.dumps(options, ensure_ascii=False)};response:{res}'
)