新增统一时间工具,新增后台列表
This commit is contained in:
@@ -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.contact import router as contact_router
|
||||||
from app.api.v1.home_materials import router as home_materials_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.admin import router as admin_module_router
|
||||||
|
from app.api.v1.material_admin import router as material_admin_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(auth_router)
|
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(contact_router)
|
||||||
api_router.include_router(home_materials_router)
|
api_router.include_router(home_materials_router)
|
||||||
api_router.include_router(admin_module_router)
|
api_router.include_router(admin_module_router)
|
||||||
|
api_router.include_router(material_admin_router)
|
||||||
@@ -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
|
||||||
@@ -1,20 +1,22 @@
|
|||||||
import random
|
import random
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 import UserOAuth
|
||||||
from app.models.user_oauth_app import UserOAuthApp
|
from app.models.user_oauth_app import UserOAuthApp
|
||||||
from app.utils.id_gen import generate_id
|
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:
|
async def get_available_app(open_type: int, db: AsyncSession) -> dict:
|
||||||
# 从 user_oauth_app 表查询可用应用
|
if not isinstance(open_type, int):
|
||||||
# 过滤条件:open_type 匹配、status=1(正常)、deleted_at is None
|
raise ValueError("open_type必须为整数类型")
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(UserOAuthApp).where(
|
select(UserOAuthApp).where(
|
||||||
UserOAuthApp.open_type == open_type,
|
UserOAuthApp.open_type == open_type,
|
||||||
@@ -30,8 +32,6 @@ async def get_available_app(open_type: int, db: AsyncSession) -> dict:
|
|||||||
available_apps = []
|
available_apps = []
|
||||||
for app in apps:
|
for app in apps:
|
||||||
app_id = app.app_id
|
app_id = app.app_id
|
||||||
|
|
||||||
# 查询该应用当前授权数量
|
|
||||||
count_result = await db.execute(
|
count_result = await db.execute(
|
||||||
select(func.count(UserOAuth.id)).where(
|
select(func.count(UserOAuth.id)).where(
|
||||||
UserOAuth.appid == app_id,
|
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
|
count = count_result.scalar() or 0
|
||||||
|
|
||||||
# 检查是否达到最大授权数
|
|
||||||
max_users = app.max_count
|
max_users = app.max_count
|
||||||
if count < max_users:
|
if count < max_users:
|
||||||
available_apps.append({
|
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:
|
async def build_oauth_url(open_type: int, user_id: str, db: AsyncSession) -> str:
|
||||||
if open_type == 1:
|
if open_type == 1:
|
||||||
#千川
|
|
||||||
return await _build_jl_oauth_url(open_type, user_id, db)
|
return await _build_jl_oauth_url(open_type, user_id, db)
|
||||||
elif open_type in [2, 3]:
|
elif open_type in [2, 3]:
|
||||||
#广告,本地推
|
|
||||||
return await _build_jl_oauth_url(2, user_id, db)
|
return await _build_jl_oauth_url(2, user_id, db)
|
||||||
elif open_type == 5:
|
elif open_type == 5:
|
||||||
#快手代理商
|
|
||||||
return "无配置"
|
return "无配置"
|
||||||
elif open_type == 9:
|
elif open_type == 9:
|
||||||
#腾讯营销K2
|
|
||||||
return "无配置"
|
return "无配置"
|
||||||
elif open_type == 10:
|
elif open_type == 10:
|
||||||
#腾讯营销K3
|
|
||||||
return "无配置"
|
return "无配置"
|
||||||
else:
|
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:
|
async def _build_jl_oauth_url(open_type: int, user_id: str, db: AsyncSession) -> str:
|
||||||
app = await get_available_app(open_type, db)
|
app = await get_available_app(open_type, db)
|
||||||
app_id = app.get("app_id")
|
app_id = app.get("app_id")
|
||||||
@@ -92,36 +87,40 @@ 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:
|
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(
|
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()
|
app = result.scalar_one_or_none()
|
||||||
if not app:
|
if not app:
|
||||||
raise ValueError("应用配置不存在")
|
raise ValueError("应用配置不存在或已禁用")
|
||||||
|
|
||||||
open_type = app.open_type
|
open_type = app.open_type
|
||||||
secret = app.secret
|
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)
|
return await get_juliang_token(app_id, secret, code, open_type, user_id, db)
|
||||||
elif open_type == 5:
|
elif open_type == 5:
|
||||||
#快手代理商
|
|
||||||
return await get_kuaishou_token(app_id, secret, code, open_type, user_id, db)
|
return await get_kuaishou_token(app_id, secret, code, open_type, user_id, db)
|
||||||
elif open_type == 9 or open_type == 10:
|
elif open_type in (9, 10):
|
||||||
#腾讯营销K2或腾讯营销K3
|
|
||||||
return await get_tencent_token(app_id, secret, code, open_type, user_id, db)
|
return await get_tencent_token(app_id, secret, code, open_type, user_id, db)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"不支持的应用类型: {open_type}")
|
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 def get_juliang_token(app_id: str, secret: str, code: str, open_type: int, user_id: str, db: AsyncSession) -> dict:
|
||||||
|
timeout = httpx.Timeout(30.0)
|
||||||
async with httpx.AsyncClient() as client:
|
try:
|
||||||
#1.请求token
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
# 1. 获取token
|
||||||
url = "https://api.oceanengine.com/open_api/oauth2/access_token/"
|
url = "https://api.oceanengine.com/open_api/oauth2/access_token/"
|
||||||
response = await client.post(
|
resp = await client.post(
|
||||||
url,
|
url,
|
||||||
json={
|
json={
|
||||||
"app_id": app_id,
|
"app_id": app_id,
|
||||||
@@ -129,156 +128,158 @@ async def get_juliang_token(app_id: str, secret: str, code: str, open_type: int,
|
|||||||
"auth_code": code,
|
"auth_code": code,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = response.json()
|
data = resp.json()
|
||||||
if data.get("code") != 0:
|
if data.get("code") != 0:
|
||||||
raise ValueError(data.get("message", "获取token失败")+f",错误信息:{data.get('message', '')}")
|
msg = data.get("message", "获取token失败")
|
||||||
data = data.get("data", {})
|
raise ValueError(f"获取token失败:{msg}")
|
||||||
access_token = data.get("access_token", "")
|
|
||||||
refresh_token = data.get("refresh_token", "")
|
resp_data = data.get("data", {})
|
||||||
expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("expires_in", 0))
|
access_token = resp_data.get("access_token", "")
|
||||||
refresh_token_expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("refresh_token_expires_in", 0))
|
refresh_token = resp_data.get("refresh_token", "")
|
||||||
#2.获取已授权角色账户,一个授权可能有多个角色账户
|
expires_sec = resp_data.get("expires_in", 7200)
|
||||||
url = "https://api.oceanengine.com/open_api/oauth2/advertiser/get/"
|
refresh_expires_sec = resp_data.get("refresh_token_expires_in", 30 * 24 * 3600)
|
||||||
response = await client.get(
|
|
||||||
url,
|
# 【修复:统一使用北京时间计算过期时间】
|
||||||
|
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},
|
headers={"Access-Token": access_token},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = response.json()
|
data = resp.json()
|
||||||
if data.get("code") != 0:
|
if data.get("code") != 0:
|
||||||
raise ValueError(data.get("message", "获取已授权账户失败")+f",错误信息:{data.get('message', '')}")
|
raise ValueError(f"获取已授权账户失败:{data.get('message')}")
|
||||||
data = data.get("data", {})
|
account_list = data.get("data", {}).get("list", [])
|
||||||
account_list = data.get("list", [])
|
|
||||||
#3.获取已授权登录信息
|
# 3. 获取登录用户信息
|
||||||
url = "https://api.oceanengine.com/open_api/2/user/info/"
|
resp = await client.get(
|
||||||
response = await client.get(
|
"https://api.oceanengine.com/open_api/2/user/info/",
|
||||||
url,
|
|
||||||
headers={"Access-Token": access_token},
|
headers={"Access-Token": access_token},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = response.json()
|
data = resp.json()
|
||||||
if data.get("code") != 0:
|
if data.get("code") != 0:
|
||||||
raise ValueError(data.get("message", "获取已授权登录信息失败")+f",错误信息:{data.get('message', '')}")
|
raise ValueError(f"获取登录信息失败:{data.get('message')}")
|
||||||
data = data.get("data", {})
|
|
||||||
account_username = data.get("email", "")
|
user_info = data.get("data", {})
|
||||||
account_userid = str(data.get("id", ""))
|
account_username = user_info.get("email", "")
|
||||||
material_auth_status = data.get("material_auth_status", False)
|
account_userid = str(user_info.get("id", ""))
|
||||||
#4.根据登录信息判断是否新增token或更新token,不同的登录信息对应不同的token,然后更新数据库
|
material_auth_status = user_info.get("material_auth_status", False)
|
||||||
#查询email,appid,user_id是否存在已授权记录
|
|
||||||
oauth = await db.execute(
|
# 4. 查询当前用户+应用+登录账号下的授权记录
|
||||||
select(UserOAuth)
|
oauth_query = select(UserOAuth).where(
|
||||||
.where(UserOAuth.account_username == account_username, UserOAuth.account_userid == account_userid, UserOAuth.appid == app_id, UserOAuth.user_id == user_id)
|
UserOAuth.account_username == account_username,
|
||||||
.limit(1)
|
UserOAuth.account_userid == account_userid,
|
||||||
|
UserOAuth.appid == app_id,
|
||||||
|
UserOAuth.user_id == user_id,
|
||||||
|
UserOAuth.deleted_at.is_(None)
|
||||||
)
|
)
|
||||||
oauth = oauth.scalar_one_or_none()
|
oauth_exist = await db.execute(oauth_query.limit(1))
|
||||||
if not oauth:
|
oauth_exist = oauth_exist.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not oauth_exist:
|
||||||
|
# 新增授权
|
||||||
new_oauth_ids = []
|
new_oauth_ids = []
|
||||||
for account in account_list:
|
for account in account_list:
|
||||||
oauth_id = generate_id()
|
oauth_id = generate_id()
|
||||||
new_oauth_ids.append(oauth_id)
|
new_oauth_ids.append(oauth_id)
|
||||||
#新增授权记录
|
|
||||||
db.add(UserOAuth(
|
db.add(UserOAuth(
|
||||||
id=oauth_id,
|
id=oauth_id,
|
||||||
account_id = str(account.get("account_id", "")),
|
account_id=str(account.get("account_id", "")),
|
||||||
account_name = account.get("account_name", ""),
|
account_name=account.get("account_name", ""),
|
||||||
account_role = account.get("account_role", ""),
|
account_role=account.get("account_role", ""),
|
||||||
account_username = account_username,
|
account_username=account_username,
|
||||||
account_userid = account_userid,
|
account_userid=account_userid,
|
||||||
user_id = user_id,
|
user_id=user_id,
|
||||||
appid = app_id,
|
appid=app_id,
|
||||||
open_type = open_type,
|
open_type=open_type,
|
||||||
port_type = 1,
|
port_type=1,
|
||||||
access_token = access_token,
|
access_token=access_token,
|
||||||
access_token_expired = expires_in,
|
access_token_expired=access_expired,
|
||||||
refresh_token = refresh_token,
|
refresh_token=refresh_token,
|
||||||
refresh_token_expired = refresh_token_expires_in,
|
refresh_token_expired=refresh_expired,
|
||||||
material_auth_status = material_auth_status,
|
material_auth_status=material_auth_status,
|
||||||
))
|
))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
# 批量写入Redis
|
||||||
|
|
||||||
for oauth_id in new_oauth_ids:
|
for oauth_id in new_oauth_ids:
|
||||||
await _update_redis_token(oauth_id, access_token, expires_in)
|
await _update_redis_token(oauth_id, access_token, access_expired)
|
||||||
else:
|
else:
|
||||||
# 查询现有授权记录(未删除的)
|
# 查询当前所有有效授权账户
|
||||||
existing_accounts = await db.execute(
|
exist_query = select(UserOAuth).where(
|
||||||
select(UserOAuth).where(
|
|
||||||
UserOAuth.account_username == account_username,
|
UserOAuth.account_username == account_username,
|
||||||
UserOAuth.account_userid == account_userid,
|
UserOAuth.account_userid == account_userid,
|
||||||
UserOAuth.appid == app_id,
|
UserOAuth.appid == app_id,
|
||||||
UserOAuth.user_id == user_id,
|
UserOAuth.user_id == user_id,
|
||||||
UserOAuth.deleted_at.is_(None),
|
UserOAuth.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
)
|
exist_res = await db.execute(exist_query)
|
||||||
existing_accounts = {acc.account_id: acc for acc in existing_accounts.scalars().all()}
|
exist_map = {item.account_id: item for item in exist_res.scalars().all()}
|
||||||
|
|
||||||
# 新的账户列表
|
new_account_ids = {str(acc.get("account_id")) for acc in account_list}
|
||||||
new_account_ids = {str(account.get("account_id")) for account in account_list}
|
old_account_ids = set(exist_map.keys())
|
||||||
old_account_ids = set(existing_accounts.keys())
|
|
||||||
|
|
||||||
# 需要更新Redis的oauth_id列表
|
|
||||||
update_redis_ids = []
|
update_redis_ids = []
|
||||||
|
|
||||||
# 1. 软删除已消失的账户
|
# 下线的账户:软删除 + 清理Redis脏缓存
|
||||||
for account_id in old_account_ids - new_account_ids:
|
for del_account_id in old_account_ids - new_account_ids:
|
||||||
existing_accounts[account_id].deleted_at = datetime.now(timezone.utc)
|
del_oauth = exist_map[del_account_id]
|
||||||
|
del_oauth.deleted_at = now_beijing
|
||||||
|
await _delete_redis_token(del_oauth.id)
|
||||||
|
|
||||||
# 2. 更新或新增账户
|
# 更新/新增当前授权账户
|
||||||
for account in account_list:
|
for account in account_list:
|
||||||
account_id = str(account.get("account_id", ""))
|
aid = str(account.get("account_id", ""))
|
||||||
if account_id in existing_accounts:
|
if aid in exist_map:
|
||||||
# 更新现有记录
|
item = exist_map[aid]
|
||||||
existing_oauth = existing_accounts[account_id]
|
item.account_name = account.get("account_name", "")
|
||||||
existing_oauth.account_name = account.get("account_name", "")
|
item.account_role = account.get("account_role", "")
|
||||||
existing_oauth.account_role = account.get("account_role", "")
|
item.access_token = access_token
|
||||||
existing_oauth.access_token = access_token
|
item.access_token_expired = access_expired
|
||||||
existing_oauth.access_token_expired = expires_in
|
item.refresh_token = refresh_token
|
||||||
existing_oauth.refresh_token = refresh_token
|
item.refresh_token_expired = refresh_expired
|
||||||
existing_oauth.refresh_token_expired = refresh_token_expires_in
|
item.material_auth_status = material_auth_status
|
||||||
existing_oauth.material_auth_status = material_auth_status
|
update_redis_ids.append(item.id)
|
||||||
update_redis_ids.append(existing_oauth.id)
|
|
||||||
else:
|
else:
|
||||||
# 新增记录
|
|
||||||
oauth_id = generate_id()
|
oauth_id = generate_id()
|
||||||
update_redis_ids.append(oauth_id)
|
update_redis_ids.append(oauth_id)
|
||||||
db.add(UserOAuth(
|
db.add(UserOAuth(
|
||||||
id=oauth_id,
|
id=oauth_id,
|
||||||
account_id=str(account_id),
|
account_id=aid,
|
||||||
account_name=account.get("account_name", ""),
|
account_name=account.get("account_name", ""),
|
||||||
account_role=account.get("account_role", ""),
|
account_role=account.get("account_role", ""),
|
||||||
account_username=account_username,
|
account_username=account_username,
|
||||||
account_userid = account_userid,
|
account_userid=account_userid,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
appid=app_id,
|
appid=app_id,
|
||||||
open_type=open_type,
|
open_type=open_type,
|
||||||
port_type=1,
|
port_type=1,
|
||||||
access_token = access_token,
|
access_token=access_token,
|
||||||
access_token_expired=expires_in,
|
access_token_expired=access_expired,
|
||||||
refresh_token=refresh_token,
|
refresh_token=refresh_token,
|
||||||
refresh_token_expired=refresh_token_expires_in,
|
refresh_token_expired=refresh_expired,
|
||||||
material_auth_status=material_auth_status,
|
material_auth_status=material_auth_status,
|
||||||
))
|
))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
# 更新有效账号缓存
|
||||||
|
for oid in update_redis_ids:
|
||||||
|
await _update_redis_token(oid, access_token, access_expired)
|
||||||
|
|
||||||
for oauth_id in update_redis_ids:
|
# 5. 同登录账号、同应用、其他用户下的授权批量同步最新token
|
||||||
await _update_redis_token(oauth_id, access_token, expires_in)
|
related_query = select(UserOAuth).where(
|
||||||
|
|
||||||
#5.本次更新成功以后,判断是否有其他同一个appid,同一个授权登录账号的授权记录,如果有,则更新token信息
|
|
||||||
from sqlalchemy import update
|
|
||||||
|
|
||||||
related_oauths = await db.execute(
|
|
||||||
select(UserOAuth).where(
|
|
||||||
UserOAuth.account_username == account_username,
|
UserOAuth.account_username == account_username,
|
||||||
UserOAuth.account_userid == account_userid,
|
UserOAuth.account_userid == account_userid,
|
||||||
UserOAuth.appid == app_id,
|
UserOAuth.appid == app_id,
|
||||||
UserOAuth.user_id != user_id,
|
UserOAuth.user_id != user_id,
|
||||||
UserOAuth.deleted_at.is_(None),
|
UserOAuth.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
)
|
related_res = await db.execute(related_query)
|
||||||
related_oauths = related_oauths.scalars().all()
|
related_list = related_res.scalars().all()
|
||||||
|
if related_list:
|
||||||
if related_oauths:
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(UserOAuth).where(
|
update(UserOAuth).where(
|
||||||
UserOAuth.account_username == account_username,
|
UserOAuth.account_username == account_username,
|
||||||
@@ -288,25 +289,30 @@ async def get_juliang_token(app_id: str, secret: str, code: str, open_type: int,
|
|||||||
UserOAuth.deleted_at.is_(None),
|
UserOAuth.deleted_at.is_(None),
|
||||||
).values(
|
).values(
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
access_token_expired=expires_in,
|
access_token_expired=access_expired,
|
||||||
refresh_token=refresh_token,
|
refresh_token=refresh_token,
|
||||||
refresh_token_expired=refresh_token_expires_in,
|
refresh_token_expired=refresh_expired,
|
||||||
material_auth_status=material_auth_status,
|
material_auth_status=material_auth_status,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
for item in related_list:
|
||||||
for related_oauth in related_oauths:
|
await _update_redis_token(item.id, access_token, access_expired)
|
||||||
await _update_redis_token(related_oauth.id, access_token, expires_in)
|
|
||||||
|
|
||||||
return {"message": "授权成功"}
|
return {"message": "授权成功"}
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise ValueError(f"第三方接口请求异常:{str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"授权处理异常:{str(e)}")
|
||||||
|
|
||||||
|
|
||||||
async def get_kuaishou_token(app_id: str, secret: str, code: str, oauth_type: int, user_id: str, db: AsyncSession) -> dict:
|
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:
|
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(
|
async def get_oauth_list(
|
||||||
@@ -318,36 +324,35 @@ async def get_oauth_list(
|
|||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 10,
|
page_size: int = 10,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if page < 1:
|
# 分页参数容错
|
||||||
page = 1
|
page = max(page, 1)
|
||||||
if page_size < 1:
|
page_size = max(min(page_size, 100), 1)
|
||||||
page_size = 10
|
|
||||||
|
|
||||||
query = select(UserOAuth).where(
|
base_where = [
|
||||||
UserOAuth.user_id == user_id,
|
UserOAuth.user_id == user_id,
|
||||||
UserOAuth.deleted_at.is_(None),
|
UserOAuth.deleted_at.is_(None),
|
||||||
)
|
]
|
||||||
|
|
||||||
if account_userid:
|
if account_userid:
|
||||||
query = query.where(UserOAuth.account_userid == account_userid)
|
base_where.append(UserOAuth.account_userid == account_userid)
|
||||||
if open_type:
|
if open_type:
|
||||||
query = query.where(UserOAuth.open_type == open_type)
|
base_where.append(UserOAuth.open_type == open_type)
|
||||||
if account_id:
|
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())
|
data_stmt = select(UserOAuth).where(*base_where)\
|
||||||
|
.order_by(UserOAuth.created_at.desc())\
|
||||||
offset = (page - 1) * page_size
|
.offset((page - 1) * page_size)\
|
||||||
query = query.offset(offset).limit(page_size)
|
.limit(page_size)
|
||||||
|
result = await db.execute(data_stmt)
|
||||||
result = await db.execute(query)
|
data_list = result.scalars().all()
|
||||||
oauth_list = result.scalars().all()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"data": oauth_list,
|
"data": data_list,
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
|
|||||||
@@ -1,49 +1,64 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta
|
||||||
import asyncio
|
import asyncio
|
||||||
import httpx
|
import httpx
|
||||||
import json
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 import UserOAuth
|
||||||
from app.models.user_oauth_app import UserOAuthApp
|
from app.models.user_oauth_app import UserOAuthApp
|
||||||
from app.models.base import async_session
|
from app.models.base import async_session
|
||||||
from app.config import settings
|
|
||||||
from app.utils.redis import get_redis
|
from app.utils.redis import get_redis
|
||||||
from app.utils.logger import get_logger
|
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"
|
REDIS_KEY = "douyin:tokens"
|
||||||
logger = get_logger("token_refresh", "token_refresh")
|
logger = get_logger("token_refresh", "token_refresh")
|
||||||
|
|
||||||
|
# 配置常量
|
||||||
|
|
||||||
REFRESH_THRESHOLD_SECONDS = 800
|
REFRESH_THRESHOLD_SECONDS = 800
|
||||||
CHECK_INTERVAL_MINUTES = 5
|
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):
|
async def _update_redis_token(oauth_id: str, token: str, expired_at: datetime):
|
||||||
"""更新Redis缓存中的token"""
|
"""更新Redis缓存中的token(统一北京时间序列化)"""
|
||||||
redis = get_redis()
|
redis = get_redis()
|
||||||
if not redis:
|
if not redis:
|
||||||
logger.warning("Redis连接未配置,跳过缓存更新")
|
logger.warning("Redis连接未配置,跳过缓存更新")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cache = {
|
cache = {
|
||||||
"token": token,
|
"token": token,
|
||||||
"expired_at": expired_at.isoformat(),
|
"expired_at": datetime_to_db_tz_str(expired_at),
|
||||||
}
|
}
|
||||||
await redis.hset(REDIS_KEY, oauth_id, json.dumps(cache))
|
await redis.hset(REDIS_KEY, oauth_id, json.dumps(cache))
|
||||||
logger.info(f"Redis缓存已更新: oauth_id={oauth_id}")
|
logger.info(f"Redis缓存已更新: oauth_id={oauth_id}")
|
||||||
except Exception as e:
|
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):
|
async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSession):
|
||||||
"""刷新巨量引擎token"""
|
"""刷新巨量引擎token"""
|
||||||
try:
|
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/"
|
url = "https://api.oceanengine.com/open_api/oauth2/refresh_token/"
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
url,
|
url,
|
||||||
@@ -55,13 +70,11 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
|
|||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
if data.get("code") != 0:
|
if data.get("code") != 0:
|
||||||
logger.error(f"刷新巨量引擎token失败: oauth_id={oauth.id}, 错误信息: {data}")
|
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]:
|
if data.get("code") in [40103, 40107]:
|
||||||
#清空数据库中的token信息,和Redis缓存中的token
|
|
||||||
from sqlalchemy import update
|
|
||||||
|
|
||||||
where_cond = UserOAuth.deleted_at.is_(None)
|
where_cond = UserOAuth.deleted_at.is_(None)
|
||||||
if oauth.appid:
|
if oauth.appid:
|
||||||
where_cond = where_cond & (UserOAuth.appid == oauth.appid)
|
where_cond = where_cond & (UserOAuth.appid == oauth.appid)
|
||||||
@@ -80,23 +93,21 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
|
|||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
related_oauth_ids = await db.execute(
|
related_oauth_ids = await db.execute(select(UserOAuth.id).where(where_cond))
|
||||||
select(UserOAuth.id).where(where_cond)
|
|
||||||
)
|
|
||||||
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
||||||
|
# 【改动2:失效直接删除Redis缓存,不再写入空值】
|
||||||
for related_id in related_oauth_ids:
|
for related_id in related_oauth_ids:
|
||||||
await _update_redis_token(related_id, "", None)
|
await _delete_redis_token(related_id)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
resp_data = data.get("data", {})
|
||||||
|
new_access_token = resp_data.get("access_token", "")
|
||||||
|
new_refresh_token = resp_data.get("refresh_token", "")
|
||||||
|
|
||||||
data = data.get("data", {})
|
# 【改动3:全部使用北京时间计算过期时间,统一时区】
|
||||||
new_access_token = data.get("access_token", "")
|
now_beijing = datetime.now(tz=BEIJING_TZ)
|
||||||
new_refresh_token = data.get("refresh_token", "")
|
expires_in = now_beijing + timedelta(seconds=resp_data.get("expires_in", 0))
|
||||||
expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("expires_in", 0))
|
refresh_expires = now_beijing + timedelta(seconds=resp_data.get("refresh_token_expires_in", 0))
|
||||||
refresh_token_expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("refresh_token_expires_in", 0))
|
|
||||||
|
|
||||||
from sqlalchemy import update
|
|
||||||
|
|
||||||
where_cond = UserOAuth.deleted_at.is_(None)
|
where_cond = UserOAuth.deleted_at.is_(None)
|
||||||
if oauth.appid:
|
if oauth.appid:
|
||||||
@@ -111,19 +122,20 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
|
|||||||
access_token=new_access_token,
|
access_token=new_access_token,
|
||||||
access_token_expired=expires_in,
|
access_token_expired=expires_in,
|
||||||
refresh_token=new_refresh_token,
|
refresh_token=new_refresh_token,
|
||||||
refresh_token_expired=refresh_token_expires_in,
|
refresh_token_expired=refresh_expires,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
related_oauth_ids = await db.execute(
|
related_oauth_ids = await db.execute(select(UserOAuth.id).where(where_cond))
|
||||||
select(UserOAuth.id).where(where_cond)
|
|
||||||
)
|
|
||||||
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
||||||
for related_id in related_oauth_ids:
|
for related_id in related_oauth_ids:
|
||||||
await _update_redis_token(related_id, new_access_token, expires_in)
|
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:
|
except httpx.HTTPError as e:
|
||||||
logger.error(f"HTTP请求失败: oauth_id={oauth.id}, 错误: {str(e)}")
|
logger.error(f"HTTP请求失败: oauth_id={oauth.id}, 错误: {str(e)}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -133,13 +145,14 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
|
|||||||
async def check_and_refresh_tokens():
|
async def check_and_refresh_tokens():
|
||||||
"""检查并刷新即将过期的token"""
|
"""检查并刷新即将过期的token"""
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
now = datetime.now(timezone.utc)
|
# 【改动4:统一使用北京时间当前时间,避免时区运算异常】
|
||||||
|
now_beijing = datetime.now(tz=BEIJING_TZ)
|
||||||
|
|
||||||
query = select(UserOAuth).where(
|
query = select(UserOAuth).where(
|
||||||
UserOAuth.deleted_at.is_(None),
|
UserOAuth.deleted_at.is_(None),
|
||||||
UserOAuth.refresh_token.is_not(None),
|
UserOAuth.refresh_token.is_not(None),
|
||||||
UserOAuth.refresh_token_expired.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)
|
result = await db.execute(query)
|
||||||
@@ -149,77 +162,71 @@ async def check_and_refresh_tokens():
|
|||||||
|
|
||||||
for oauth in oauth_list:
|
for oauth in oauth_list:
|
||||||
try:
|
try:
|
||||||
#检查是否为支持的平台(巨量引擎)
|
|
||||||
# port_type: 平台端口(1=巨量,2=磁力,3=巨量星图,4=服务单,5=腾讯)
|
|
||||||
if oauth.port_type not in [1]:
|
if oauth.port_type not in [1]:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
#构建登录账号唯一标识,同一登录账号共享token
|
# 唯一键优化:过滤空值避免拼接异常
|
||||||
key_parts = []
|
key_parts = []
|
||||||
if oauth.appid:
|
if oauth.appid:
|
||||||
key_parts.append(oauth.appid)
|
key_parts.append(oauth.appid)
|
||||||
if oauth.account_username:
|
if oauth.account_username:
|
||||||
key_parts.append(oauth.account_username)
|
key_parts.append(oauth.account_username)
|
||||||
if oauth.account_userid:
|
if oauth.account_userid:
|
||||||
key_parts.append(oauth.account_userid)
|
key_parts.append(str(oauth.account_userid))
|
||||||
login_key = "|".join(key_parts)
|
login_key = "|".join(key_parts)
|
||||||
|
|
||||||
#同一登录账号已刷新过,直接跳过(避免使用旧数据判断)
|
|
||||||
if login_key in refreshed_keys:
|
if login_key in refreshed_keys:
|
||||||
logger.debug(f"跳过重复刷新: oauth_id={oauth.id}, 同一登录账号已刷新")
|
logger.debug(f"跳过重复刷新: oauth_id={oauth.id}, 同一登录账号已刷新")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
#获取应用配置
|
# 【改动5:过滤已禁用、已删除应用】
|
||||||
app_result = await db.execute(
|
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()
|
app = app_result.scalar_one_or_none()
|
||||||
|
|
||||||
if not app:
|
if not app:
|
||||||
|
logger.warning(f"oauth_id:{oauth.id} 对应应用不存在/已禁用,跳过刷新")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
#检查access_token是否需要刷新
|
|
||||||
need_refresh = False
|
need_refresh = False
|
||||||
|
if not oauth.access_token or not oauth.access_token_expired:
|
||||||
# access_token为空,需要刷新
|
|
||||||
if not oauth.access_token:
|
|
||||||
need_refresh = True
|
need_refresh = True
|
||||||
# access_token_expired为空,需要刷新
|
|
||||||
elif not oauth.access_token_expired:
|
|
||||||
need_refresh = True
|
|
||||||
# access_token即将过期(剩余时间小于800秒),需要刷新
|
|
||||||
else:
|
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
|
need_refresh = True
|
||||||
|
|
||||||
if not need_refresh:
|
if not need_refresh:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
#refresh_token已在查询条件中过滤,确保有效才能刷新
|
|
||||||
|
|
||||||
#刷新token
|
|
||||||
await refresh_juliang_token(oauth, app, db)
|
await refresh_juliang_token(oauth, app, db)
|
||||||
|
|
||||||
refreshed_keys.add(login_key)
|
refreshed_keys.add(login_key)
|
||||||
|
|
||||||
|
# 简单限流,防止瞬间大量请求
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
#7.增加错误日志
|
logger.error(f"oauth_id:{oauth.id} 刷新token异常: {str(e)}", exc_info=True)
|
||||||
logger.error(f"刷新token失败: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
async def token_refresh_scheduler():
|
async def token_refresh_scheduler():
|
||||||
"""定时任务调度器"""
|
"""定时任务调度器"""
|
||||||
|
logger.info("开始执行定时Token刷新任务")
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
await check_and_refresh_tokens()
|
await check_and_refresh_tokens()
|
||||||
except Exception as e:
|
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)
|
await asyncio.sleep(CHECK_INTERVAL_MINUTES * 60)
|
||||||
|
|
||||||
|
|
||||||
def start_token_refresh_task():
|
def start_token_refresh_task():
|
||||||
"""启动token刷新定时任务"""
|
"""启动token刷新定时任务"""
|
||||||
logger.info("启动token刷新定时任务")
|
logger.info("启动token刷新定时后台任务")
|
||||||
asyncio.create_task(token_refresh_scheduler())
|
asyncio.create_task(token_refresh_scheduler())
|
||||||
@@ -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秒 / 2025:01:01 等格式
|
||||||
|
"""
|
||||||
|
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"
|
||||||
|
]
|
||||||
@@ -5,8 +5,14 @@ from typing import Any, Dict, Optional, Tuple
|
|||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 import UserOAuth
|
||||||
from app.models.user_oauth_app import UserOAuthApp
|
from app.models.user_oauth_app import UserOAuthApp
|
||||||
from app.models.base import async_session
|
from app.models.base import async_session
|
||||||
@@ -40,50 +46,75 @@ class DouyinRequest:
|
|||||||
self._client = None
|
self._client = None
|
||||||
|
|
||||||
async def _get_redis_token(self, oauth_id: str) -> Optional[str]:
|
async def _get_redis_token(self, oauth_id: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
【核心改动:北京时间解析+脏缓存自动清理】
|
||||||
|
1. 缓存格式异常/字段缺失:直接删除脏缓存
|
||||||
|
2. Token过期则不返回,交由上层判断refresh是否过期决定是否删除缓存
|
||||||
|
"""
|
||||||
redis = get_redis()
|
redis = get_redis()
|
||||||
if not redis:
|
if not redis:
|
||||||
raise ValueError("Redis连接未配置")
|
raise ValueError("Redis连接未配置")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cache_str = await redis.hget(self._redis_key, oauth_id)
|
cache_str = await redis.hget(self._redis_key, oauth_id)
|
||||||
if cache_str:
|
if not cache_str:
|
||||||
|
return None
|
||||||
|
|
||||||
cache = json.loads(cache_str)
|
cache = json.loads(cache_str)
|
||||||
token = cache.get("token")
|
token = cache.get("token")
|
||||||
expired_at_str = cache.get("expired_at")
|
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 (token and expired_at_str):
|
||||||
|
await redis.hdel(self._redis_key, oauth_id)
|
||||||
|
logger.warning(f"oauth_id:{oauth_id} Redis缓存字段缺失,已清理脏数据")
|
||||||
return None
|
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):
|
async def _set_redis_token(self, oauth_id: str, token: str, expired_at: datetime):
|
||||||
redis = get_redis()
|
redis = get_redis()
|
||||||
if not redis:
|
if not redis:
|
||||||
raise ValueError("Redis连接未配置")
|
raise ValueError("Redis连接未配置")
|
||||||
|
# 【改动4:统一转为北京时间序列化存入Redis,和数据库时区对齐】
|
||||||
try:
|
|
||||||
cache = {
|
cache = {
|
||||||
"token": token,
|
"token": token,
|
||||||
"expired_at": expired_at.isoformat(),
|
"expired_at": datetime_to_db_tz_str(expired_at),
|
||||||
}
|
}
|
||||||
await redis.hset(self._redis_key, oauth_id, json.dumps(cache))
|
try:
|
||||||
|
await redis.hset(self._redis_key, oauth_id, json.dumps(cache, ensure_ascii=False))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"设置Redis缓存失败: {e}")
|
raise ValueError(f"设置Redis缓存失败: {str(e)}")
|
||||||
|
|
||||||
async def _delete_redis_token(self, oauth_id: str):
|
async def _delete_redis_token(self, oauth_id: str):
|
||||||
|
"""删除单个oauth_id缓存(授权彻底失效时调用)"""
|
||||||
redis = get_redis()
|
redis = get_redis()
|
||||||
if not redis:
|
if not redis:
|
||||||
raise ValueError("Redis连接未配置")
|
raise ValueError("Redis连接未配置")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await redis.hdel(self._redis_key, oauth_id)
|
await redis.hdel(self._redis_key, oauth_id)
|
||||||
|
logger.info(f"oauth_id:{oauth_id} 授权失效,已清理Redis缓存脏数据")
|
||||||
except Exception as e:
|
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:
|
async def get_access_token(self, oauth_id: str, force_refresh: bool = False) -> str:
|
||||||
|
# 优先读取缓存
|
||||||
if not force_refresh:
|
if not force_refresh:
|
||||||
token = await self._get_redis_token(oauth_id)
|
token = await self._get_redis_token(oauth_id)
|
||||||
if token:
|
if token:
|
||||||
@@ -101,7 +132,11 @@ class DouyinRequest:
|
|||||||
if not oauth_data:
|
if not oauth_data:
|
||||||
raise ValueError("无效的oauth_id")
|
raise ValueError("无效的oauth_id")
|
||||||
|
|
||||||
|
# 【改动5:统一北京时间当前时间】
|
||||||
|
now_beijing = datetime.now(tz=BEIJING_TZ)
|
||||||
|
|
||||||
if force_refresh:
|
if force_refresh:
|
||||||
|
# 强制刷新:清空同条件下所有账号缓存与数据库token
|
||||||
where_cond = UserOAuth.deleted_at.is_(None)
|
where_cond = UserOAuth.deleted_at.is_(None)
|
||||||
if oauth_data.appid:
|
if oauth_data.appid:
|
||||||
where_cond = where_cond & (UserOAuth.appid == oauth_data.appid)
|
where_cond = where_cond & (UserOAuth.appid == oauth_data.appid)
|
||||||
@@ -118,9 +153,7 @@ class DouyinRequest:
|
|||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
related_oauth_ids = await db.execute(
|
related_oauth_ids = await db.execute(select(UserOAuth.id).where(where_cond))
|
||||||
select(UserOAuth.id).where(where_cond)
|
|
||||||
)
|
|
||||||
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
||||||
for related_id in related_oauth_ids:
|
for related_id in related_oauth_ids:
|
||||||
await self._delete_redis_token(related_id)
|
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)
|
new_token, new_expired_at = await self.refresh_access_token(db, oauth_id, oauth_data.appid, oauth_data.refresh_token)
|
||||||
return new_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
|
token = oauth_data.access_token
|
||||||
expired_at = oauth_data.access_token_expired
|
expired_at = oauth_data.access_token_expired
|
||||||
await self._set_redis_token(oauth_id, token, expired_at)
|
await self._set_redis_token(oauth_id, token, expired_at)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
# 查找同账号下未过期有效token复用
|
||||||
where_cond = UserOAuth.deleted_at.is_(None)
|
where_cond = UserOAuth.deleted_at.is_(None)
|
||||||
if oauth_data.appid:
|
if oauth_data.appid:
|
||||||
where_cond = where_cond & (UserOAuth.appid == 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(
|
select(UserOAuth.access_token, UserOAuth.access_token_expired).where(
|
||||||
where_cond,
|
where_cond,
|
||||||
UserOAuth.access_token_expired.is_not(None),
|
UserOAuth.access_token_expired.is_not(None),
|
||||||
UserOAuth.access_token_expired > datetime.now(timezone.utc),
|
UserOAuth.access_token_expired > now_beijing,
|
||||||
).limit(1)
|
).limit(1)
|
||||||
)
|
)
|
||||||
related_oauth = related_oauths.first()
|
related_oauth = related_oauths.first()
|
||||||
@@ -155,9 +190,13 @@ class DouyinRequest:
|
|||||||
await self._set_redis_token(oauth_id, token, expired_at)
|
await self._set_redis_token(oauth_id, token, expired_at)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
if oauth_data.refresh_token_expired and oauth_data.refresh_token_expired < datetime.now(timezone.utc):
|
# =========【核心业务规则实现:判断RefreshToken是否过期】=========
|
||||||
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)
|
new_token, new_expired_at = await self.refresh_access_token(db, oauth_id, oauth_data.appid, oauth_data.refresh_token)
|
||||||
return new_token
|
return new_token
|
||||||
|
|
||||||
@@ -211,7 +250,10 @@ class DouyinRequest:
|
|||||||
expires_in = data.get('expires_in', 0)
|
expires_in = data.get('expires_in', 0)
|
||||||
refresh_token_expires_in = data.get('refresh_token_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)
|
where_cond = UserOAuth.deleted_at.is_(None)
|
||||||
if appid:
|
if appid:
|
||||||
@@ -222,20 +264,16 @@ class DouyinRequest:
|
|||||||
where_cond = where_cond & (UserOAuth.account_userid == account_userid)
|
where_cond = where_cond & (UserOAuth.account_userid == account_userid)
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(UserOAuth).where(
|
update(UserOAuth).where(where_cond).values(
|
||||||
where_cond,
|
|
||||||
).values(
|
|
||||||
access_token=new_access_token,
|
access_token=new_access_token,
|
||||||
refresh_token=new_refresh_token,
|
refresh_token=new_refresh_token,
|
||||||
access_token_expired=new_expired_at,
|
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()
|
await db.commit()
|
||||||
|
|
||||||
related_oauth_ids = await db.execute(
|
related_oauth_ids = await db.execute(select(UserOAuth.id).where(where_cond))
|
||||||
select(UserOAuth.id).where(where_cond)
|
|
||||||
)
|
|
||||||
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
||||||
|
|
||||||
for related_id in related_oauth_ids:
|
for related_id in related_oauth_ids:
|
||||||
@@ -243,92 +281,79 @@ class DouyinRequest:
|
|||||||
|
|
||||||
return new_access_token, new_expired_at
|
return new_access_token, new_expired_at
|
||||||
|
|
||||||
# 有token请求
|
|
||||||
async def request_with_token_with_context(
|
async def request_with_token_with_context(
|
||||||
self,
|
self,
|
||||||
oauth_id: str,
|
oauth_id: str,
|
||||||
url: str,
|
url: str,
|
||||||
method: str = 'GET',
|
method: str = 'GET',
|
||||||
options: any = None,
|
options: Optional[Dict[str, Any]] = None,
|
||||||
request_count: int = 1,
|
request_count: int = 1,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
options = options or {}
|
options = options or {}
|
||||||
token = await self.get_access_token(oauth_id)
|
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:
|
try:
|
||||||
headers = options.get('headers', {}).copy()
|
headers = options.get('headers', {}).copy()
|
||||||
headers['Access-Token'] = token
|
headers['Access-Token'] = token
|
||||||
|
|
||||||
has_files = 'files' in options
|
has_files = 'files' in options
|
||||||
if has_files:
|
if has_files:
|
||||||
# 移除可能错误设置的 Content-Type,让库自动生成 multipart 头
|
|
||||||
headers.pop('Content-Type', None)
|
headers.pop('Content-Type', None)
|
||||||
else:
|
else:
|
||||||
headers.setdefault('Content-Type', 'application/json')
|
headers.setdefault('Content-Type', 'application/json')
|
||||||
|
|
||||||
options['headers'] = headers
|
options['headers'] = headers
|
||||||
|
|
||||||
response = await self.client.request(method, url, **options)
|
response = await self.client.request(method, url, **options)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = response.json()
|
resp_data = response.json()
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
data = {'code': 0, 'data': response.text, 'msg':'JSON解析失败'}
|
resp_data = {'code': 0, 'data': response.text, 'msg': 'JSON解析失败'}
|
||||||
|
|
||||||
if 'code' not in data:
|
if 'code' not in resp_data:
|
||||||
await asyncio.sleep(i * 5)
|
await asyncio.sleep(i * 5)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
code = data.get('code', 0)
|
code = resp_data.get('code', 0)
|
||||||
|
|
||||||
if code in [40102, 40104]:
|
if code in [40102, 40104]:
|
||||||
await asyncio.sleep(i * 5)
|
await asyncio.sleep(i * 5)
|
||||||
token = await self.get_access_token(oauth_id, force_refresh=True)
|
token = await self.get_access_token(oauth_id, force_refresh=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if code in [40100, 40110]:
|
if code in [40100, 40110]:
|
||||||
wait_time = min(2 * (2 ** (i - 1)), 10)
|
wait_time = min(2 * (2 ** (i - 1)), 10)
|
||||||
await asyncio.sleep(wait_time)
|
await asyncio.sleep(wait_time)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if code == 50000:
|
if code == 50000:
|
||||||
await asyncio.sleep(i * 10)
|
await asyncio.sleep(i * 10)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return data
|
return resp_data
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
except (httpx.HTTPStatusError, httpx.RequestError):
|
||||||
await asyncio.sleep(i * 10)
|
|
||||||
continue
|
|
||||||
except httpx.RequestError as e:
|
|
||||||
await asyncio.sleep(i * 10)
|
await asyncio.sleep(i * 10)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 重试耗尽,日志记录
|
||||||
options_log = {}
|
options_log = {}
|
||||||
if options:
|
|
||||||
for key, value in options.items():
|
for key, value in options.items():
|
||||||
if key == 'files':
|
if key == 'files':
|
||||||
options_log[key] = {k: (v[0], 'bytes_content', v[2]) for k, v in value.items()}
|
options_log[key] = {k: (v[0], 'bytes_content', v[2]) for k, v in value.items()}
|
||||||
else:
|
else:
|
||||||
options_log[key] = value
|
options_log[key] = value
|
||||||
|
|
||||||
res = json.dumps(data, ensure_ascii=False) if 'data' in locals() else ''
|
res = json.dumps(resp_data, ensure_ascii=False) if resp_data else ''
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
f'DouYin API request failed after {request_count} retries. '
|
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}'
|
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:
|
if resp_data and resp_data.get("code", 0) != 0:
|
||||||
raise ValueError(f'接口返回错误[code:{data.get("code", "接口编码")}]{data.get("message", "接口返回错误")}')
|
raise ValueError(f'接口返回错误[code:{resp_data.get("code")}]{resp_data.get("message", "接口异常")}')
|
||||||
else:
|
raise ValueError("网络请求失败,请稍后重试")
|
||||||
raise ValueError('网络错误,稍后重试。')
|
|
||||||
|
|
||||||
|
|
||||||
# 无token请求
|
|
||||||
async def request_with_context(
|
async def request_with_context(
|
||||||
self,
|
self,
|
||||||
url: str,
|
url: str,
|
||||||
@@ -336,8 +361,9 @@ class DouyinRequest:
|
|||||||
options: Optional[Dict[str, Any]] = None,
|
options: Optional[Dict[str, Any]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
options = options or {}
|
options = options or {}
|
||||||
|
resp_data: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
for i in range(1, 2):
|
for i in range(1, 3):
|
||||||
try:
|
try:
|
||||||
headers = options.get('headers', {}).copy()
|
headers = options.get('headers', {}).copy()
|
||||||
headers.setdefault('Content-Type', 'application/json')
|
headers.setdefault('Content-Type', 'application/json')
|
||||||
@@ -347,30 +373,24 @@ class DouyinRequest:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = response.json()
|
resp_data = response.json()
|
||||||
except json.JSONDecodeError:
|
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)
|
await asyncio.sleep(i * 5)
|
||||||
continue
|
continue
|
||||||
|
if resp_data.get("code", 0) >= 50000:
|
||||||
|
await asyncio.sleep(i * 10)
|
||||||
|
continue
|
||||||
|
return resp_data
|
||||||
|
|
||||||
code = data.get('code', 0)
|
except (httpx.HTTPStatusError, httpx.RequestError):
|
||||||
if code >= 50000:
|
|
||||||
await asyncio.sleep(i * 10)
|
await asyncio.sleep(i * 10)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return data
|
res = json.dumps(resp_data, ensure_ascii=False) if resp_data else ''
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
await asyncio.sleep(i * 10)
|
|
||||||
continue
|
|
||||||
except httpx.RequestError as e:
|
|
||||||
await asyncio.sleep(i * 10)
|
|
||||||
continue
|
|
||||||
|
|
||||||
res = json.dumps(data) if 'data' in locals() else ''
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f'DouYin API request failed after 5 retries. '
|
f'DouYin API request failed after 2 retries. '
|
||||||
f'url:{url};options:{json.dumps(options)};response:{res}'
|
f'url:{url};options:{json.dumps(options, ensure_ascii=False)};response:{res}'
|
||||||
)
|
)
|
||||||
Reference in New Issue
Block a user