新增统一时间工具,新增后台列表
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.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)
|
||||
@@ -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
|
||||
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:
|
||||
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}")
|
||||
|
||||
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,然后更新数据库
|
||||
#查询email,appid,user_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()
|
||||
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)
|
||||
|
||||
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(
|
||||
# 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()
|
||||
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:
|
||||
await _update_redis_token(oauth_id, access_token, expires_in)
|
||||
|
||||
#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()
|
||||
await db.commit()
|
||||
for item in related_list:
|
||||
await _update_redis_token(item.id, access_token, access_expired)
|
||||
|
||||
for related_oauth in related_oauths:
|
||||
await _update_redis_token(related_oauth.id, access_token, expires_in)
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
@@ -80,23 +93,21 @@ 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
|
||||
|
||||
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", {})
|
||||
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))
|
||||
|
||||
from sqlalchemy import update
|
||||
# 【改动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))
|
||||
|
||||
where_cond = UserOAuth.deleted_at.is_(None)
|
||||
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_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,13 +145,14 @@ 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)
|
||||
@@ -149,77 +162,71 @@ async def check_and_refresh_tokens():
|
||||
|
||||
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刷新定时任务")
|
||||
logger.info("启动token刷新定时后台任务")
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
resp_data = response.json()
|
||||
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)
|
||||
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}'
|
||||
)
|
||||
Reference in New Issue
Block a user