|
|
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone
|
|
|
from typing import Any, List, Literal, Optional, Tuple
|
|
|
|
|
|
from fastapi import APIRouter, Request
|
|
|
-from pydantic import Field, field_validator, model_validator
|
|
|
+from pydantic import Field, PrivateAttr, field_validator, model_validator
|
|
|
|
|
|
from api.base import ApiParams, BaseApi
|
|
|
from api.errors import BusinessValidationError
|
|
|
@@ -21,6 +21,7 @@ FilterField = Literal[
|
|
|
'comment_cnt',
|
|
|
'duration',
|
|
|
'publish_time',
|
|
|
+ 'create_time',
|
|
|
]
|
|
|
FilterOperator = Literal['>', '>=', '=', '<', '<=', 'between', 'in', 'not_in']
|
|
|
SqlFragment = Tuple[str, List[Any]]
|
|
|
@@ -28,6 +29,33 @@ SUPPORTED_PLATFORMS = frozenset({'xiaoniangao', 'xiaoniangaotuijianliu'})
|
|
|
MAX_FILTER_SET_VALUES = 100
|
|
|
|
|
|
|
|
|
+def normalize_datetime(value: Any, field_name: str) -> datetime:
|
|
|
+ """把毫秒时间戳或日期字符串统一转换为东八区无时区时间。"""
|
|
|
+ if isinstance(value, bool):
|
|
|
+ raise ValueError(f'{field_name}时间格式错误: {value}')
|
|
|
+ if isinstance(value, datetime):
|
|
|
+ parsed = value
|
|
|
+ elif isinstance(value, (int, float)) or (isinstance(value, str) and value.strip().isdigit()):
|
|
|
+ try:
|
|
|
+ timestamp = float(value)
|
|
|
+ if not math.isfinite(timestamp):
|
|
|
+ raise ValueError
|
|
|
+ if abs(timestamp) >= 10_000_000_000:
|
|
|
+ timestamp /= 1000
|
|
|
+ parsed = datetime.fromtimestamp(timestamp, tz=CHINA_TIMEZONE)
|
|
|
+ except (OverflowError, OSError, ValueError) as exc:
|
|
|
+ raise ValueError(f'{field_name}时间格式错误: {value}') from exc
|
|
|
+ else:
|
|
|
+ try:
|
|
|
+ parsed = datetime.fromisoformat(str(value).strip())
|
|
|
+ except ValueError as exc:
|
|
|
+ raise ValueError(f'{field_name}时间格式错误: {value}') from exc
|
|
|
+
|
|
|
+ if parsed.tzinfo is not None:
|
|
|
+ return parsed.astimezone(CHINA_TIMEZONE).replace(tzinfo=None)
|
|
|
+ return parsed
|
|
|
+
|
|
|
+
|
|
|
# ==================== 请求参数 ====================
|
|
|
|
|
|
class FilterCondition(ApiParams):
|
|
|
@@ -45,52 +73,33 @@ class FilterCondition(ApiParams):
|
|
|
|
|
|
|
|
|
class PageCursor(ApiParams):
|
|
|
- """稳定翻页游标,对应上一页最后一条数据的自增主键。"""
|
|
|
+ """稳定翻页游标,并固定默认近3天查询的时间锚点。"""
|
|
|
|
|
|
id: int = Field(gt=0)
|
|
|
+ query_time: Optional[datetime] = None
|
|
|
+
|
|
|
+ @field_validator('query_time', mode='before')
|
|
|
+ @classmethod
|
|
|
+ def normalize_query_time(cls, value):
|
|
|
+ return normalize_datetime(value, 'cursor.query_time') if value is not None else None
|
|
|
|
|
|
|
|
|
class VideoQueryParams(ApiParams):
|
|
|
"""查询垂直视频的请求参数。"""
|
|
|
|
|
|
+ _default_query_end: Optional[datetime] = PrivateAttr(default=None)
|
|
|
+
|
|
|
platforms: List[str] = Field(
|
|
|
default_factory=lambda: ['xiaoniangao', 'xiaoniangaotuijianliu'],
|
|
|
min_length=1,
|
|
|
max_length=20,
|
|
|
)
|
|
|
- start_time: Optional[datetime] = None
|
|
|
- end_time: Optional[datetime] = None
|
|
|
keywords: List[str] = Field(default_factory=list, max_length=20)
|
|
|
filter_match_mode: Literal[1, 2] = 2 # 1=OR,2=AND
|
|
|
filters: List[FilterCondition] = Field(default_factory=list, max_length=50)
|
|
|
limit: int = Field(default=500, ge=1, le=settings.API_MAX_LIMIT)
|
|
|
cursor: Optional[PageCursor] = None
|
|
|
|
|
|
- @field_validator('start_time', 'end_time', mode='before')
|
|
|
- @classmethod
|
|
|
- def normalize_query_time(cls, value):
|
|
|
- """毫秒时间戳统一转换为东八区的数据库查询时间,同时兼容日期字符串。"""
|
|
|
- is_number = isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
|
- is_digit_string = isinstance(value, str) and value.strip().isdigit()
|
|
|
- if is_number or is_digit_string:
|
|
|
- try:
|
|
|
- timestamp = float(value)
|
|
|
- if not math.isfinite(timestamp):
|
|
|
- raise ValueError('时间戳必须是有限数字')
|
|
|
- if abs(timestamp) >= 10_000_000_000:
|
|
|
- timestamp /= 1000
|
|
|
- return datetime.fromtimestamp(timestamp, tz=CHINA_TIMEZONE).replace(tzinfo=None)
|
|
|
- except (OverflowError, OSError, ValueError) as exc:
|
|
|
- raise ValueError(f'时间格式错误: {value}') from exc
|
|
|
- return value
|
|
|
-
|
|
|
- @field_validator('start_time', 'end_time', mode='after')
|
|
|
- @classmethod
|
|
|
- def normalize_timezone(cls, value):
|
|
|
- if value is not None and value.tzinfo is not None:
|
|
|
- return value.astimezone(CHINA_TIMEZONE).replace(tzinfo=None)
|
|
|
- return value
|
|
|
-
|
|
|
@field_validator('platforms')
|
|
|
@classmethod
|
|
|
def validate_platforms(cls, values: List[str]) -> List[str]:
|
|
|
@@ -116,17 +125,11 @@ class VideoQueryParams(ApiParams):
|
|
|
return normalized
|
|
|
|
|
|
@model_validator(mode='after')
|
|
|
- def fill_and_validate_time_range(self):
|
|
|
- now = datetime.now(CHINA_TIMEZONE).replace(tzinfo=None)
|
|
|
- if self.start_time is None and self.end_time is None:
|
|
|
- self.end_time = now
|
|
|
- self.start_time = self.end_time - timedelta(days=3)
|
|
|
- elif self.start_time is None:
|
|
|
- self.start_time = self.end_time - timedelta(days=3)
|
|
|
- elif self.end_time is None:
|
|
|
- self.end_time = now
|
|
|
- if self.start_time >= self.end_time:
|
|
|
- raise ValueError('start_time必须早于end_time')
|
|
|
+ def set_default_query_time(self):
|
|
|
+ """没有创建时间筛选时使用近3天;翻页时沿用首屏时间锚点。"""
|
|
|
+ if not any(condition.field == 'create_time' for condition in self.filters):
|
|
|
+ cursor_time = self.cursor.query_time if self.cursor else None
|
|
|
+ self._default_query_end = cursor_time or datetime.now(CHINA_TIMEZONE).replace(tzinfo=None)
|
|
|
return self
|
|
|
|
|
|
|
|
|
@@ -143,23 +146,11 @@ COMPARISON_OPERATORS = frozenset({'>', '>=', '=', '<', '<='})
|
|
|
|
|
|
def normalize_filter_value(field: FilterField, value: Any) -> Any:
|
|
|
"""将请求值转换为数据库可比较的数字或日期字符串。"""
|
|
|
- if field == 'publish_time':
|
|
|
- if isinstance(value, datetime):
|
|
|
- if value.tzinfo is not None:
|
|
|
- value = value.astimezone(CHINA_TIMEZONE).replace(tzinfo=None)
|
|
|
- return value.strftime('%Y-%m-%d %H:%M:%S')
|
|
|
- if isinstance(value, (int, float)) or (isinstance(value, str) and value.strip().isdigit()):
|
|
|
- timestamp = float(value)
|
|
|
- if timestamp >= 10_000_000_000:
|
|
|
- timestamp /= 1000
|
|
|
- return datetime.fromtimestamp(timestamp, tz=CHINA_TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')
|
|
|
+ if field in ('publish_time', 'create_time'):
|
|
|
try:
|
|
|
- parsed = datetime.fromisoformat(str(value).strip())
|
|
|
- if parsed.tzinfo is not None:
|
|
|
- parsed = parsed.astimezone(CHINA_TIMEZONE).replace(tzinfo=None)
|
|
|
- return parsed.strftime('%Y-%m-%d %H:%M:%S')
|
|
|
+ return normalize_datetime(value, field).strftime('%Y-%m-%d %H:%M:%S')
|
|
|
except ValueError as exc:
|
|
|
- raise BusinessValidationError(f'publish_time筛选值格式错误: {value}') from exc
|
|
|
+ raise BusinessValidationError(str(exc)) from exc
|
|
|
if isinstance(value, bool):
|
|
|
raise BusinessValidationError(f'{field}筛选值必须是数字: {value}')
|
|
|
try:
|
|
|
@@ -217,25 +208,45 @@ def build_filter_scope(
|
|
|
platform_placeholders = ', '.join(['%s'] * len(params.platforms))
|
|
|
clauses = [
|
|
|
f'{column("platform")} IN ({platform_placeholders})',
|
|
|
- f'{column("create_time")} >= %s',
|
|
|
- f'{column("create_time")} < %s',
|
|
|
f"{column('video_url')} <> ''",
|
|
|
]
|
|
|
- sql_params: List[Any] = [*params.platforms, params.start_time, params.end_time]
|
|
|
+ sql_params: List[Any] = [*params.platforms]
|
|
|
+
|
|
|
+ if params._default_query_end is not None:
|
|
|
+ clauses.append(f'{column("create_time")} >= %s')
|
|
|
+ clauses.append(f'{column("create_time")} < %s')
|
|
|
+ sql_params.extend([
|
|
|
+ params._default_query_end - timedelta(days=3),
|
|
|
+ params._default_query_end,
|
|
|
+ ])
|
|
|
|
|
|
if params.keywords:
|
|
|
keyword_clause = f'{column("video_title")} LIKE %s'
|
|
|
clauses.append(f"({' OR '.join([keyword_clause] * len(params.keywords))})")
|
|
|
sql_params.extend([f'%{keyword}%' for keyword in params.keywords])
|
|
|
|
|
|
- filter_clauses, filter_params = [], []
|
|
|
+ grouped_filters = {}
|
|
|
for condition in params.filters:
|
|
|
clause, values = compile_filter_condition(condition, table_alias)
|
|
|
- filter_clauses.append(clause)
|
|
|
- filter_params.extend(values)
|
|
|
- if filter_clauses:
|
|
|
+ field_clauses, field_params = grouped_filters.setdefault(condition.field, ([], []))
|
|
|
+ field_clauses.append(clause)
|
|
|
+ field_params.extend(values)
|
|
|
+
|
|
|
+ # 创建时间是查询范围,始终与其他条件使用AND。
|
|
|
+ create_time_group = grouped_filters.pop('create_time', None)
|
|
|
+ if create_time_group:
|
|
|
+ field_clauses, field_params = create_time_group
|
|
|
+ clauses.append(f"({' AND '.join(field_clauses)})")
|
|
|
+ sql_params.extend(field_params)
|
|
|
+
|
|
|
+ # 同一字段的上下界必须使用AND;不同字段之间才应用计划配置的AND/OR模式。
|
|
|
+ if grouped_filters:
|
|
|
+ filter_groups, filter_params = [], []
|
|
|
+ for field_clauses, field_params in grouped_filters.values():
|
|
|
+ filter_groups.append(f"({' AND '.join(field_clauses)})")
|
|
|
+ filter_params.extend(field_params)
|
|
|
joiner = ' OR ' if params.filter_match_mode == 1 else ' AND '
|
|
|
- clauses.append(f"({joiner.join(filter_clauses)})")
|
|
|
+ clauses.append(f"({joiner.join(filter_groups)})")
|
|
|
sql_params.extend(filter_params)
|
|
|
return ' AND '.join(clauses), sql_params
|
|
|
|
|
|
@@ -271,7 +282,9 @@ def build_query(params: VideoQueryParams) -> SqlFragment:
|
|
|
|
|
|
# ==================== 接口实现 ====================
|
|
|
|
|
|
-def timestamp_ms(value: datetime) -> int:
|
|
|
+def timestamp_ms(value: Optional[datetime]) -> Optional[int]:
|
|
|
+ if value is None:
|
|
|
+ return None
|
|
|
if value.tzinfo is None:
|
|
|
value = value.replace(tzinfo=CHINA_TIMEZONE)
|
|
|
else:
|
|
|
@@ -292,13 +305,13 @@ class VideoQueryApi(BaseApi):
|
|
|
has_more = len(rows) > page_size
|
|
|
rows = rows[:page_size]
|
|
|
next_cursor = {'id': rows[-1]['id']} if has_more and rows else None
|
|
|
+ if next_cursor is not None and params._default_query_end is not None:
|
|
|
+ next_cursor['query_time'] = timestamp_ms(params._default_query_end)
|
|
|
return {
|
|
|
'data': rows,
|
|
|
'count': len(rows),
|
|
|
'has_more': has_more,
|
|
|
'next_cursor': next_cursor,
|
|
|
- 'start_time': timestamp_ms(params.start_time),
|
|
|
- 'end_time': timestamp_ms(params.end_time),
|
|
|
}
|
|
|
|
|
|
|