1
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -462,6 +462,26 @@ async def set_user_status(
|
||||
return {"user_id": user_id, "status": req.status, "status_text": USER_STATUS_TEXT[req.status]}
|
||||
|
||||
|
||||
async def reset_user_password(
|
||||
db: AsyncSession, redis: aioredis.Redis, admin: AdminIdentity,
|
||||
user_id: int, req, ip: str,
|
||||
) -> None:
|
||||
"""后台重置用户密码【敏感】:重置后全端 Token 立即失效"""
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise BizException(errors.NOT_FOUND, "用户不存在")
|
||||
if user.status == 3:
|
||||
raise BizException(errors.PARAM_ERROR, "账号已注销,不可操作")
|
||||
|
||||
user.password_hash = hash_password(req.new_password)
|
||||
# 全端 Token 失效:递增令牌纪元 + 清 refresh 白名单
|
||||
await redis.incr(f"auth:epoch:{user_id}")
|
||||
await redis.delete(f"auth:refresh:{user_id}")
|
||||
await _log(db, admin.admin_id, "user_password_reset", target=str(user_id),
|
||||
detail=req.reason, ip=ip)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ---------- 积分 ----------
|
||||
|
||||
async def adjust_points(
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"""短剧采集服务:从内容源拉取短剧列表/详情,导入为 series + videos(幂等)"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.video import CollectionRecord, CollectionSource, Series, Video
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 内容源配置(域名轮换时只需改这里)
|
||||
SOURCE_KEY = "mdcmai4"
|
||||
UPSTREAM = "https://mdcmai4.xyz"
|
||||
PRODUCT_ID = 1
|
||||
CATEGORY_ID = 12 # 本站分类:AI短剧
|
||||
AUTHOR_USER_ID = 1 # 导入内容归属用户
|
||||
SELF_API_BASE = "https://vapi.256kb.top" # 本站 API 对外地址
|
||||
|
||||
HEADERS = {"User-Agent": "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36"}
|
||||
|
||||
|
||||
async def _fetch_json(client: httpx.AsyncClient, url: str, params: dict | None = None) -> dict:
|
||||
resp = await client.get(url, params=params, headers=HEADERS, timeout=20)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
if body.get("code") != 200 or not isinstance(body.get("data"), dict):
|
||||
raise RuntimeError(f"上游响应异常: {url} -> {str(body)[:200]}")
|
||||
return body["data"]
|
||||
|
||||
|
||||
def _proxy_url(path: str) -> str:
|
||||
"""把源站相对 m3u8 路径转为本站代理地址(域名轮换只改 SELF_API_BASE/上游常量)"""
|
||||
return f"{SELF_API_BASE}/api/v1/m3u8/proxy?path={quote(path, safe='')}"
|
||||
|
||||
|
||||
async def _import_drama(client: httpx.AsyncClient, db: AsyncSession, drama_id: int) -> tuple[int, int]:
|
||||
"""导入单部短剧,返回 (新增集数, 跳过集数)"""
|
||||
detail = await _fetch_json(client, f"{UPSTREAM}/api/v1/short-dramas/{drama_id}", {"productId": PRODUCT_ID})
|
||||
|
||||
record = (
|
||||
await db.execute(
|
||||
select(CollectionRecord).where(
|
||||
CollectionRecord.source_key == SOURCE_KEY, CollectionRecord.source_id == drama_id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
published_at = None
|
||||
try:
|
||||
published_at = datetime.fromisoformat(detail.get("publishedAt", "")) or None
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
series = await db.get(Series, record.series_id) if record else None
|
||||
if series is None:
|
||||
series = Series(
|
||||
title=(detail.get("title") or f"短剧{drama_id}")[:100],
|
||||
cover_url=f"{UPSTREAM}{detail.get('coverUrl', '')}",
|
||||
description=(detail.get("description") or "")[:500],
|
||||
author_id=AUTHOR_USER_ID,
|
||||
category_id=CATEGORY_ID,
|
||||
total_episodes=0,
|
||||
view_count=int(detail.get("heatCount") or 0),
|
||||
status=1,
|
||||
)
|
||||
db.add(series)
|
||||
await db.flush()
|
||||
if record: # 系列曾被删除:重挂映射
|
||||
record.series_id = series.id
|
||||
else:
|
||||
db.add(CollectionRecord(source_key=SOURCE_KEY, source_id=drama_id, series_id=series.id))
|
||||
await db.flush()
|
||||
|
||||
# 已有集数去重
|
||||
existing = set(
|
||||
(
|
||||
await db.execute(
|
||||
select(Video.episode_no).where(Video.series_id == series.id, Video.episode_no.is_not(None))
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
|
||||
created = skipped = 0
|
||||
for ep in detail.get("episodes", []):
|
||||
ep_no = int(ep.get("episodeNo") or 0)
|
||||
if not ep_no or ep_no in existing:
|
||||
skipped += 1
|
||||
continue
|
||||
db.add(
|
||||
Video(
|
||||
user_id=AUTHOR_USER_ID,
|
||||
category_id=CATEGORY_ID,
|
||||
series_id=series.id,
|
||||
episode_no=ep_no,
|
||||
title=(ep.get("titleOverride") or ep.get("title") or f"第{ep_no}集")[:100],
|
||||
description=(detail.get("description") or "")[:500],
|
||||
cover_url=f"{UPSTREAM}{detail.get('coverUrl', '')}",
|
||||
video_url=_proxy_url(ep.get("videoUrl", "")),
|
||||
duration=int(ep.get("durationSec") or 0),
|
||||
status=2,
|
||||
published_at=published_at,
|
||||
)
|
||||
)
|
||||
existing.add(ep_no)
|
||||
created += 1
|
||||
|
||||
series.total_episodes = len(existing)
|
||||
return created, skipped
|
||||
|
||||
|
||||
async def sync_drama_by_id(db: AsyncSession, drama_id: int) -> dict:
|
||||
"""手动同步单部短剧(新增或补充缺失集数);返回统计"""
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
created, skipped = await _import_drama(client, db, drama_id)
|
||||
await db.commit()
|
||||
return {"drama_id": drama_id, "episodes_created": created, "episodes_skipped": skipped}
|
||||
|
||||
|
||||
async def sync_short_dramas(
|
||||
db: AsyncSession,
|
||||
max_pages: int = 0,
|
||||
page_size: int = 20,
|
||||
sort_by: str = "heat",
|
||||
delay: float = 0.25,
|
||||
) -> dict:
|
||||
"""按列表页增量同步短剧;返回统计"""
|
||||
stat = {"dramas_new": 0, "dramas_exist": 0, "episodes_created": 0, "episodes_skipped": 0, "pages": 0}
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
page = 1
|
||||
while True:
|
||||
data = await _fetch_json(
|
||||
client,
|
||||
f"{UPSTREAM}/api/v1/short-dramas",
|
||||
{"productId": PRODUCT_ID, "sortBy": sort_by, "page": page, "size": page_size},
|
||||
)
|
||||
items = data.get("items", [])
|
||||
stat["pages"] = page
|
||||
logger.info("列表第 %s/%s 页,%s 部", page, data.get("totalPages"), len(items))
|
||||
|
||||
for item in items:
|
||||
drama_id = int(item.get("id") or 0)
|
||||
if not drama_id:
|
||||
continue
|
||||
exists = (
|
||||
await db.execute(
|
||||
select(CollectionRecord.id).where(
|
||||
CollectionRecord.source_key == SOURCE_KEY, CollectionRecord.source_id == drama_id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists:
|
||||
stat["dramas_exist"] += 1
|
||||
continue
|
||||
try:
|
||||
created, skipped = await _import_drama(client, db, drama_id)
|
||||
await db.commit()
|
||||
stat["dramas_new"] += 1
|
||||
stat["episodes_created"] += created
|
||||
stat["episodes_skipped"] += skipped
|
||||
logger.info("导入短剧 %s《%s》:新增 %s 集", drama_id, item.get("title"), created)
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
logger.exception("导入短剧 %s 失败,跳过", drama_id)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
total_pages = int(data.get("totalPages") or 1)
|
||||
if page >= total_pages or (max_pages and page >= max_pages):
|
||||
break
|
||||
page += 1
|
||||
await asyncio.sleep(delay)
|
||||
return stat
|
||||
|
||||
|
||||
# ---------- 单视频采集(通用 URL 模板,后台自助配置的采集源)----------
|
||||
|
||||
def _abs_cover(item: dict) -> str:
|
||||
"""封面转绝对地址:源站 /api/... 代理路径或 /uploads/... 静态路径"""
|
||||
u = item.get("coverUrl") or ""
|
||||
if u.startswith("http"):
|
||||
return u
|
||||
return f"{UPSTREAM}{u}" if u else ""
|
||||
|
||||
|
||||
async def _import_video_item(db: AsyncSession, source: CollectionSource, item: dict) -> bool:
|
||||
"""导入单条视频,已存在返回 False"""
|
||||
vid = int(item.get("id") or 0)
|
||||
if not vid or not (item.get("videoUrl") or "").endswith(".m3u8"):
|
||||
return False
|
||||
exists = (
|
||||
await db.execute(
|
||||
select(CollectionRecord.id).where(
|
||||
CollectionRecord.source_key == SOURCE_KEY, CollectionRecord.source_id == vid
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists:
|
||||
return False
|
||||
|
||||
published_at = None
|
||||
try:
|
||||
published_at = datetime.fromisoformat(item.get("publishedAt", "")) or None
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
v = Video(
|
||||
user_id=AUTHOR_USER_ID,
|
||||
category_id=source.category_id,
|
||||
title=(item.get("title") or f"视频{vid}")[:100],
|
||||
description=(item.get("description") or "")[:500],
|
||||
cover_url=_abs_cover(item)[:500],
|
||||
video_url=_proxy_url(item["videoUrl"]),
|
||||
duration=int(item.get("durationSec") or 0),
|
||||
status=2,
|
||||
view_count=int(item.get("viewCount") or 0),
|
||||
like_count=int(item.get("likeCount") or 0),
|
||||
favorite_count=int(item.get("favoriteCount") or 0),
|
||||
published_at=published_at,
|
||||
)
|
||||
db.add(v)
|
||||
await db.flush()
|
||||
db.add(CollectionRecord(source_key=SOURCE_KEY, source_id=vid, video_id=v.id))
|
||||
return True
|
||||
|
||||
|
||||
async def sync_source(
|
||||
db: AsyncSession,
|
||||
source: CollectionSource,
|
||||
max_pages: int = 0,
|
||||
stop_after_existing: int = 30,
|
||||
delay: float = 0.25,
|
||||
) -> dict:
|
||||
"""按源的 URL 模板翻页采集;连续 stop_after_existing 条已存在则提前停止(增量)"""
|
||||
stat = {"created": 0, "skipped": 0, "pages": 0, "stopped_early": False}
|
||||
consecutive_existing = 0
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
page = 1
|
||||
while True:
|
||||
url = source.list_url.replace("{{page}}", str(page))
|
||||
data = await _fetch_json(client, url)
|
||||
items = data.get("items", [])
|
||||
if not items:
|
||||
break
|
||||
stat["pages"] = page
|
||||
for item in items:
|
||||
if await _import_video_item(db, source, item):
|
||||
stat["created"] += 1
|
||||
consecutive_existing = 0
|
||||
else:
|
||||
stat["skipped"] += 1
|
||||
consecutive_existing += 1
|
||||
if consecutive_existing >= stop_after_existing:
|
||||
stat["stopped_early"] = True
|
||||
break
|
||||
await db.commit()
|
||||
if stat["stopped_early"]:
|
||||
break
|
||||
total_pages = int(data.get("totalPages") or page)
|
||||
if page >= total_pages or (max_pages and page >= max_pages):
|
||||
break
|
||||
page += 1
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
source.last_sync_at = datetime.now()
|
||||
await db.commit()
|
||||
return stat
|
||||
@@ -160,9 +160,37 @@ async def get_feed(
|
||||
) -> dict:
|
||||
if feed_type == "latest":
|
||||
return await _feed_latest(db, identity, cursor, limit, category_id)
|
||||
if feed_type in ("liked", "viewed"):
|
||||
return await _feed_ranked(db, identity, feed_type, cursor, limit, category_id)
|
||||
return await _feed_scored(db, redis, identity, feed_type, cursor, limit, category_id)
|
||||
|
||||
|
||||
async def _feed_ranked(
|
||||
db: AsyncSession, identity: Identity, feed_type: str, cursor: str | None,
|
||||
limit: int, category_id: int | None,
|
||||
) -> dict:
|
||||
"""liked / viewed:按点赞量 / 播放量严格倒序(值 + id 打破并列游标)"""
|
||||
col = Video.like_count if feed_type == "liked" else Video.view_count
|
||||
stmt = await _base_pool_stmt(category_id)
|
||||
c = _decode_cursor(cursor)
|
||||
if c:
|
||||
val, vid = int(c["n"]), int(c["i"])
|
||||
stmt = stmt.where((col < val) | ((col == val) & (Video.id < vid)))
|
||||
rows = list(
|
||||
(await db.execute(stmt.order_by(col.desc(), Video.id.desc()).limit(limit + 1))).scalars()
|
||||
)
|
||||
page, rows = (len(rows) > limit), rows[:limit]
|
||||
next_cursor = None
|
||||
if page and rows:
|
||||
last = rows[-1]
|
||||
last_val = last.like_count if feed_type == "liked" else last.view_count
|
||||
next_cursor = _encode_cursor({"n": last_val, "i": last.id})
|
||||
return {
|
||||
"next_cursor": next_cursor,
|
||||
"list": await serialize_videos(db, rows, identity.user_id),
|
||||
}
|
||||
|
||||
|
||||
async def _base_pool_stmt(category_id: int | None):
|
||||
stmt = select(Video).where(Video.status == STATUS_PUBLISHED, Video.published_at.isnot(None))
|
||||
if category_id:
|
||||
|
||||
Reference in New Issue
Block a user