1
This commit is contained in:
@@ -0,0 +1,104 @@
|
|||||||
|
首页-默认
|
||||||
|
|
||||||
|
```JSON
|
||||||
|
{
|
||||||
|
"oauth_type": "pwa",
|
||||||
|
"oauth_id": "cXNQe5FQXIkaW8Ny1792802204704",
|
||||||
|
"trace_id": "",
|
||||||
|
"aff_x_code": "",
|
||||||
|
"version": "7.5.0",
|
||||||
|
"mod": "index",
|
||||||
|
"code": "home",
|
||||||
|
"page": 3,
|
||||||
|
"token": "3fba65f06e1e308c819e343474075461",
|
||||||
|
"timestamp": 1792811390633
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
首页-精选-最新
|
||||||
|
|
||||||
|
```JSON
|
||||||
|
{
|
||||||
|
"oauth_type": "pwa",
|
||||||
|
"oauth_id": "cXNQe5FQXIkaW8Ny1792802204704",
|
||||||
|
"trace_id": "",
|
||||||
|
"aff_x_code": "",
|
||||||
|
"version": "7.5.0",
|
||||||
|
"mod": "choice",
|
||||||
|
"code": "vlog",
|
||||||
|
"type": "new",
|
||||||
|
"page": 1,
|
||||||
|
"limit": 30,
|
||||||
|
"token": "3fba65f06e1e308c819e343474075461",
|
||||||
|
"timestamp": 1792810190406
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
短剧-原创-全部
|
||||||
|
|
||||||
|
```JSON
|
||||||
|
{
|
||||||
|
"oauth_type": "pwa",
|
||||||
|
"oauth_id": "cXNQe5FQXIkaW8Ny1792802204704",
|
||||||
|
"trace_id": "",
|
||||||
|
"aff_x_code": "",
|
||||||
|
"version": "7.5.0",
|
||||||
|
"mod": "aidrama",
|
||||||
|
"code": "home",
|
||||||
|
"page": 1,
|
||||||
|
"limit": 10,
|
||||||
|
"pay_type": "all",
|
||||||
|
"sort": "all",
|
||||||
|
"category_ids": [
|
||||||
|
4
|
||||||
|
],
|
||||||
|
"token": "3fba65f06e1e308c819e343474075461",
|
||||||
|
"timestamp": 1792811903280
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
短剧-详情页(请求)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"oauth_type": "pwa",
|
||||||
|
"oauth_id": "cXNQe5FQXIkaW8Ny1792802204704",
|
||||||
|
"trace_id": "",
|
||||||
|
"aff_x_code": "",
|
||||||
|
"version": "7.5.0",
|
||||||
|
"mod": "aidrama",
|
||||||
|
"code": "detail",
|
||||||
|
"drama_id": 1008,
|
||||||
|
"token": "3fba65f06e1e308c819e343474075461",
|
||||||
|
"timestamp": 1792812296047
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
短剧-请求所有集数
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"oauth_type": "pwa",
|
||||||
|
"oauth_id": "cXNQe5FQXIkaW8Ny1792802204704",
|
||||||
|
"trace_id": "",
|
||||||
|
"aff_x_code": "",
|
||||||
|
"version": "7.5.0",
|
||||||
|
"mod": "aidrama",
|
||||||
|
"code": "episodes",
|
||||||
|
"drama_id": 1008,
|
||||||
|
"page": 1,
|
||||||
|
"limit": 100,
|
||||||
|
"token": "3fba65f06e1e308c819e343474075461",
|
||||||
|
"timestamp": 1792812296047
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
短剧-获得所有集数
|
||||||
|
|
||||||
|
```json
|
||||||
|
aaa
|
||||||
|
aaa
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""91Video PWA 登录脚本
|
||||||
|
|
||||||
|
通过逆向 pwa4.jl8v0p5t.cc 的加密协议,调用登录接口获取 token。
|
||||||
|
|
||||||
|
依赖: pip install requests pycryptodome
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from Crypto.Cipher import AES
|
||||||
|
|
||||||
|
# ===== 协议常量 (从前端 JS 逆向得到) =====
|
||||||
|
API_URL = "https://api4.zqpnd5a7.cc/pwa.php"
|
||||||
|
|
||||||
|
# AES-256 密钥与 IV (请求加密 / 响应解密共用)
|
||||||
|
AES_KEY = b"cc88ddc9357ff461e08f047aedee692b" # 32 bytes
|
||||||
|
AES_IV = b"e89225cfbbimgkcu" # 16 bytes
|
||||||
|
|
||||||
|
# 签名用的密钥 (即 AES 密钥)
|
||||||
|
SIGN_KEY = "cc88ddc9357ff461e08f047aedee692b"
|
||||||
|
|
||||||
|
# oauth_id 随机字符集
|
||||||
|
OAUTH_CHARSET = (
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
)
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (Linux; Android 10; SM-G973F) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/120.0.0.0 Mobile Safari/537.36"
|
||||||
|
),
|
||||||
|
"Origin": "https://pwa4.jl8v0p5t.cc",
|
||||||
|
"Referer": "https://pwa4.jl8v0p5t.cc/",
|
||||||
|
"Accept": "application/json, text/plain, */*",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _aes_encrypt(plaintext: str) -> str:
|
||||||
|
"""AES-256-CFB (segment_size=128) 加密, 返回大写十六进制字符串。"""
|
||||||
|
cipher = AES.new(AES_KEY, AES.MODE_CFB, AES_IV, segment_size=128)
|
||||||
|
return cipher.encrypt(plaintext.encode("utf-8")).hex().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _aes_decrypt(hex_data: str) -> str:
|
||||||
|
"""AES-256-CFB (segment_size=128) 解密。"""
|
||||||
|
cipher = AES.new(AES_KEY, AES.MODE_CFB, AES_IV, segment_size=128)
|
||||||
|
return cipher.decrypt(bytes.fromhex(hex_data)).decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sign(data_hex: str, timestamp_sec: int) -> str:
|
||||||
|
"""生成 sign: MD5(SHA256('client=pwa&data=<data>×tamp=<ts><sign_key>'))"""
|
||||||
|
base = f"client=pwa&data={data_hex}×tamp={timestamp_sec}{SIGN_KEY}"
|
||||||
|
sha = hashlib.sha256(base.encode("utf-8")).hexdigest()
|
||||||
|
return hashlib.md5(sha.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_oauth_id() -> str:
|
||||||
|
"""生成 oauth_id: 16 位随机字符 + 毫秒时间戳。"""
|
||||||
|
rand_part = "".join(random.choices(OAUTH_CHARSET, k=16))
|
||||||
|
return rand_part + str(int(time.time() * 1000))
|
||||||
|
|
||||||
|
|
||||||
|
def _post(session: requests.Session, payload: dict) -> dict:
|
||||||
|
"""加密 payload 并发送请求,返回解密后的 JSON 响应。"""
|
||||||
|
ts_ms = int(time.time() * 1000)
|
||||||
|
ts_sec = ts_ms // 1000
|
||||||
|
payload["timestamp"] = ts_ms
|
||||||
|
|
||||||
|
plaintext = json.dumps(payload, separators=(",", ":"))
|
||||||
|
data_hex = _aes_encrypt(plaintext)
|
||||||
|
sign = _make_sign(data_hex, ts_sec)
|
||||||
|
|
||||||
|
body = f"client=pwa×tamp={ts_sec}&data={data_hex}&sign={sign}"
|
||||||
|
|
||||||
|
resp = session.post(API_URL, data=body, headers=HEADERS, timeout=30)
|
||||||
|
if resp.status_code == 503:
|
||||||
|
raise RuntimeError(
|
||||||
|
"服务端返回 503。若 oauth_id 未经 system/index 注册会触发此错误;"
|
||||||
|
"也可能是 CloudFront 限流,请稍后重试。"
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
result = resp.json()
|
||||||
|
if result.get("errcode") != 0:
|
||||||
|
raise RuntimeError(f"接口返回错误: {result}")
|
||||||
|
|
||||||
|
encrypted_data = result.get("data", "")
|
||||||
|
if not encrypted_data:
|
||||||
|
return result
|
||||||
|
|
||||||
|
decrypted = _aes_decrypt(encrypted_data)
|
||||||
|
return json.loads(decrypted)
|
||||||
|
|
||||||
|
|
||||||
|
def login(account: str, password: str):
|
||||||
|
"""登录并返回 (session, oauth_id, 解析后的登录响应)。
|
||||||
|
|
||||||
|
登录前必须先调用 system/index 注册 oauth_id,
|
||||||
|
否则服务端会对未注册的 oauth_id 返回 503。
|
||||||
|
"""
|
||||||
|
session = requests.Session()
|
||||||
|
oauth_id = _gen_oauth_id()
|
||||||
|
|
||||||
|
base = {
|
||||||
|
"oauth_type": "pwa",
|
||||||
|
"oauth_id": oauth_id,
|
||||||
|
"trace_id": "",
|
||||||
|
"aff_x_code": "",
|
||||||
|
"version": "7.5.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1) 先调用 system/index 注册 oauth_id
|
||||||
|
_post(session, dict(base, mod="system", code="index", data={}, token=""))
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
# 2) 再调用登录接口
|
||||||
|
payload = dict(
|
||||||
|
base,
|
||||||
|
mod="user",
|
||||||
|
code="regOrLogin",
|
||||||
|
account=account,
|
||||||
|
password=password,
|
||||||
|
type="login",
|
||||||
|
)
|
||||||
|
result = _post(session, payload)
|
||||||
|
return session, oauth_id, result
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_home(session: requests.Session, oauth_id: str, token: str, page: int) -> dict:
|
||||||
|
"""请求首页列表 (mod=index, code=home) 并返回解密后的数据。"""
|
||||||
|
payload = {
|
||||||
|
"oauth_type": "pwa",
|
||||||
|
"oauth_id": oauth_id,
|
||||||
|
"trace_id": "",
|
||||||
|
"aff_x_code": "",
|
||||||
|
"version": "7.5.0",
|
||||||
|
"mod": "index",
|
||||||
|
"code": "home",
|
||||||
|
"page": page,
|
||||||
|
"token": token,
|
||||||
|
}
|
||||||
|
return _post(session, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
account = "16774351679"
|
||||||
|
password = "admin123456"
|
||||||
|
|
||||||
|
print(f"正在登录账号: {account}")
|
||||||
|
session, oauth_id, result = login(account, password)
|
||||||
|
|
||||||
|
inner = result.get("data", result)
|
||||||
|
if not inner.get("success"):
|
||||||
|
print("登录失败:", inner.get("msg", result))
|
||||||
|
return
|
||||||
|
|
||||||
|
token = inner.get("token")
|
||||||
|
print(f"登录成功! token: {token}")
|
||||||
|
|
||||||
|
# 请求首页第 1~10 页并解密保存
|
||||||
|
for page in range(1, 2):
|
||||||
|
try:
|
||||||
|
data = fetch_home(session, oauth_id, token, page)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"第 {page} 页请求失败: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
out_file = f"home_page_{page}.json"
|
||||||
|
with open(out_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
# 打印摘要:data 直接就是视频列表
|
||||||
|
items = data.get("data") if isinstance(data.get("data"), list) else []
|
||||||
|
print(f"第 {page} 页: 已解密并保存到 {out_file} (共 {len(items)} 条)")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# data_str = '46CF9369809931B4D43024E4A689EADDF078D83A100348DB8CB1B386522B6F5811D57BAD96801571597E550A83F20B865F34F7CEA7BCA0ED2CE780257CE0D818F2E36A04941BC58B0767C3263377995656FC9C251C58BB8C3C5BF36DBFD0B65348FF1031D9E381377593BB8B32F39C8F0AF2DFF7579BA2B260E6073B9B6A6EB1FC309116F38380C7EA555684C9BF4464D172B1DA3513461AB5E1A1865FF4E7AE146D980129EFFE464773B03212543462D1CBB31A79EDE0B9402A17677CCF1526213B4E787BAF7F6252A0C445412C519DCF663CA477D6CC9F1E7C8586F2D486EADE8A12A16FB215BCBCB9D4128DB568FEC5D572863A61AE0D55B36D00'
|
||||||
|
# decrypted = json.loads(_aes_decrypt(data_str))
|
||||||
|
|
||||||
|
# print("-----")
|
||||||
|
# print(json.dumps(decrypted, ensure_ascii=False, indent=2))
|
||||||
|
main()
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
/** 列出所有备份文件 */
|
||||||
|
export function listBackups() {
|
||||||
|
return request.get('/admin/system/backups')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 立即创建一次备份(同步等待返回,可能耗时几秒~几十秒) */
|
||||||
|
export function createBackup() {
|
||||||
|
// 备份耗时较长,单独放宽超时到 5 分钟
|
||||||
|
return request.post('/admin/system/backup', null, { timeout: 5 * 60 * 1000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除指定备份 */
|
||||||
|
export function deleteBackup(filename) {
|
||||||
|
return request.delete(`/admin/system/backups/${encodeURIComponent(filename)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从已有备份恢复(高危) */
|
||||||
|
export function restoreFromExisting(filename) {
|
||||||
|
return request.post(
|
||||||
|
`/admin/system/backups/${encodeURIComponent(filename)}/restore`,
|
||||||
|
null,
|
||||||
|
{ timeout: 10 * 60 * 1000 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传 .sql.gz 文件并恢复(高危) */
|
||||||
|
export function restoreFromUpload(file) {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
return request.post('/admin/system/restore', fd, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 10 * 60 * 1000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下载备份文件(手动携带 Authorization 头,绕过 axios 直接打开) */
|
||||||
|
export async function downloadBackup(filename) {
|
||||||
|
const { getToken } = await import('@/utils/auth')
|
||||||
|
const token = getToken()
|
||||||
|
const resp = await fetch(`/api/v1/admin/system/backups/${encodeURIComponent(filename)}/download`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`下载失败:${resp.status}`)
|
||||||
|
}
|
||||||
|
const blob = await resp.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
+11
-1
@@ -21,11 +21,21 @@ export function offlineVideo(videoId, data) {
|
|||||||
return request.post(`/admin/videos/${videoId}/offline`, data)
|
return request.post(`/admin/videos/${videoId}/offline`, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 视频基础编辑 { title, description, cover_url, category_id, series_id, episode_no, status } */
|
/** 视频基础编辑 { title, description, cover_url, category_id, source_id, series_id, episode_no, status } */
|
||||||
export function updateVideo(videoId, data) {
|
export function updateVideo(videoId, data) {
|
||||||
return request.put(`/admin/videos/${videoId}`, data)
|
return request.put(`/admin/videos/${videoId}`, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 批量编辑视频 { video_ids, source_id?, status?, category_id? } */
|
||||||
|
export function batchUpdateVideos(data) {
|
||||||
|
return request.put('/admin/videos/batch', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量下架视频 { video_ids, reason }(复用单条下架:通知作者 + 写日志 + 清缓存) */
|
||||||
|
export function batchOfflineVideos(data) {
|
||||||
|
return request.post('/admin/videos/batch-offline', data)
|
||||||
|
}
|
||||||
|
|
||||||
/** 删除视频(级联删除所有关联数据:标签/点赞/收藏/评论/观看记录/收益/举报) */
|
/** 删除视频(级联删除所有关联数据:标签/点赞/收藏/评论/观看记录/收益/举报) */
|
||||||
export function deleteVideo(videoId) {
|
export function deleteVideo(videoId) {
|
||||||
return request.delete(`/admin/videos/${videoId}`)
|
return request.delete(`/admin/videos/${videoId}`)
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ const menuConfig = [
|
|||||||
{ title: '发布视频', path: '/videos/create', icon: 'Upload', roles: [] },
|
{ title: '发布视频', path: '/videos/create', icon: 'Upload', roles: [] },
|
||||||
{ title: '视频管理', path: '/videos', icon: 'Film', roles: [] },
|
{ title: '视频管理', path: '/videos', icon: 'Film', roles: [] },
|
||||||
{ title: '系列管理', path: '/series', icon: 'CollectionTag', roles: [] },
|
{ title: '系列管理', path: '/series', icon: 'CollectionTag', roles: [] },
|
||||||
|
{ title: '来源管理', path: '/sources', icon: 'Flag', roles: [] },
|
||||||
{ title: '采集源管理', path: '/collect-sources', icon: 'Download', roles: [] },
|
{ title: '采集源管理', path: '/collect-sources', icon: 'Download', roles: [] },
|
||||||
{ title: '分类管理', path: '/categories', icon: 'Menu', roles: [] },
|
{ title: '分类管理', path: '/categories', icon: 'Menu', roles: [] },
|
||||||
{ title: '标签管理', path: '/tags', icon: 'PriceTag', roles: [] },
|
{ title: '标签管理', path: '/tags', icon: 'PriceTag', roles: [] },
|
||||||
@@ -103,6 +104,7 @@ const menuConfig = [
|
|||||||
{ title: '推荐风控', path: '/referrals', icon: 'Share', roles: [] },
|
{ title: '推荐风控', path: '/referrals', icon: 'Share', roles: [] },
|
||||||
{ title: '敏感词库', path: '/sensitive-words', icon: 'Collection', roles: [] },
|
{ title: '敏感词库', path: '/sensitive-words', icon: 'Collection', roles: [] },
|
||||||
{ title: '平台配置', path: '/configs', icon: 'Setting', roles: [] },
|
{ title: '平台配置', path: '/configs', icon: 'Setting', roles: [] },
|
||||||
|
{ title: '数据库备份', path: '/system/backup', icon: 'Coin', roles: [] },
|
||||||
{ title: '操作日志', path: '/logs', icon: 'Document', roles: [] },
|
{ title: '操作日志', path: '/logs', icon: 'Document', roles: [] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ const routes = [
|
|||||||
component: () => import('@/views/SeriesManage.vue'),
|
component: () => import('@/views/SeriesManage.vue'),
|
||||||
meta: { title: '系列管理', icon: 'CollectionTag', roles: [] },
|
meta: { title: '系列管理', icon: 'CollectionTag', roles: [] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'sources',
|
||||||
|
name: 'source-manage',
|
||||||
|
component: () => import('@/views/SourceManage.vue'),
|
||||||
|
meta: { title: '来源管理', icon: 'Flag', roles: [] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'collect-sources',
|
path: 'collect-sources',
|
||||||
name: 'collect-sources',
|
name: 'collect-sources',
|
||||||
@@ -148,6 +154,18 @@ const routes = [
|
|||||||
component: () => import('@/views/ConfigList.vue'),
|
component: () => import('@/views/ConfigList.vue'),
|
||||||
meta: { title: '平台配置', icon: 'Setting', roles: [] },
|
meta: { title: '平台配置', icon: 'Setting', roles: [] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'system/backup',
|
||||||
|
name: 'system-backup',
|
||||||
|
component: () => import('@/views/SystemBackup.vue'),
|
||||||
|
meta: { title: '数据库备份', icon: 'Coin', roles: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'user-action-logs',
|
||||||
|
name: 'user-action-logs',
|
||||||
|
component: () => import('@/views/UserActionLogs.vue'),
|
||||||
|
meta: { title: '用户操作日志', icon: 'Document', roles: [] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'logs',
|
path: 'logs',
|
||||||
name: 'log-list',
|
name: 'log-list',
|
||||||
|
|||||||
@@ -14,12 +14,28 @@
|
|||||||
|
|
||||||
<el-alert type="info" :closable="false" style="margin-bottom: 12px">
|
<el-alert type="info" :closable="false" style="margin-bottom: 12px">
|
||||||
添加"分类名 + 列表URL"后点同步即可自动采集;URL 中用 <code v-pre>{{page}}</code> 表示页码占位。
|
添加"分类名 + 列表URL"后点同步即可自动采集;URL 中用 <code v-pre>{{page}}</code> 表示页码占位。
|
||||||
分类名不存在时会自动创建。同步为增量:已入库的视频自动跳过。
|
分类名不存在时会自动创建。同步为增量:已入库的视频自动跳过。<br>
|
||||||
|
91 短剧类型:填写源站账号密码,自动登录并按源站分类拉取短剧列表与全部集数,入库为「系列 + 剧集」,仅保存封面地址与播放代理地址。
|
||||||
</el-alert>
|
</el-alert>
|
||||||
|
|
||||||
<el-table :data="list" v-loading="loading" stripe>
|
<el-table :data="list" v-loading="loading" stripe>
|
||||||
<el-table-column prop="id" label="ID" width="60" />
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
<el-table-column prop="name" label="分类名" width="130" />
|
<el-table-column prop="name" label="分类名" width="130" />
|
||||||
|
<el-table-column label="类型" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.kind === 'aidrama91' ? 'success' : (row.kind === 'aidrama91_vlog' ? 'warning' : 'info')" size="small">
|
||||||
|
{{ row.kind === 'aidrama91' ? '91短剧' : (row.kind === 'aidrama91_vlog' ? '91短视频' : '通用URL') }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="来源" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="sourceName(row.source_id)" type="warning" size="small">
|
||||||
|
{{ sourceName(row.source_id) }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">未绑定</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="list_url" label="列表 URL" min-width="320" show-overflow-tooltip />
|
<el-table-column prop="list_url" label="列表 URL" min-width="320" show-overflow-tooltip />
|
||||||
<el-table-column label="状态" width="80">
|
<el-table-column label="状态" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -31,9 +47,10 @@
|
|||||||
<el-table-column prop="last_sync_at" label="上次同步" width="160">
|
<el-table-column prop="last_sync_at" label="上次同步" width="160">
|
||||||
<template #default="{ row }">{{ row.last_sync_at || '从未' }}</template>
|
<template #default="{ row }">{{ row.last_sync_at || '从未' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="240" fixed="right">
|
<el-table-column label="操作" width="290" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="warning" :loading="syncingId === row.id" @click="syncOne(row)">同步</el-button>
|
<el-button link type="warning" :loading="syncingId === row.id" @click="syncOne(row)">同步</el-button>
|
||||||
|
<el-button link type="danger" :loading="cancellingId === row.id" @click="cancelSync(row)">取消同步</el-button>
|
||||||
<el-button link type="primary" @click="showEdit(row)">编辑</el-button>
|
<el-button link type="primary" @click="showEdit(row)">编辑</el-button>
|
||||||
<el-button link type="primary" @click="toggleStatus(row)">
|
<el-button link type="primary" @click="toggleStatus(row)">
|
||||||
{{ row.status === 1 ? '停用' : '启用' }}
|
{{ row.status === 1 ? '停用' : '启用' }}
|
||||||
@@ -68,13 +85,48 @@
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisible" :title="editing ? '编辑采集源' : '添加采集源'" width="560px">
|
<el-dialog v-model="dialogVisible" :title="editing ? '编辑采集源' : '添加采集源'" width="560px">
|
||||||
<el-form :model="form" label-width="90px">
|
<el-form :model="form" label-width="100px">
|
||||||
|
<el-form-item label="采集类型">
|
||||||
|
<el-radio-group v-model="form.kind" :disabled="!!editing">
|
||||||
|
<el-radio value="url">通用 URL 模板</el-radio>
|
||||||
|
<el-radio value="aidrama91">91 短剧(AI短剧)</el-radio>
|
||||||
|
<el-radio value="aidrama91_vlog">91 短视频</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="分类名">
|
<el-form-item label="分类名">
|
||||||
<el-input v-model="form.name" maxlength="50" placeholder="如:每日更新(不存在会自动创建分类)" />
|
<el-input v-model="form.name" maxlength="50" placeholder="如:每日更新(不存在会自动创建分类)" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="列表 URL">
|
<el-form-item label="接口地址">
|
||||||
<el-input v-model="form.list_url" type="textarea" :rows="3"
|
<el-input v-model="form.list_url" type="textarea" :rows="2"
|
||||||
placeholder="https://example.com/api/v1/videos?categoryId=27&page={{page}}&size=60" />
|
:placeholder="form.kind === 'aidrama91'
|
||||||
|
? 'https://api4.zqpnd5a7.cc/pwa.php'
|
||||||
|
: 'https://example.com/api/v1/videos?categoryId=27&page={{page}}&size=60'" />
|
||||||
|
</el-form-item>
|
||||||
|
<template v-if="form.kind === 'aidrama91' || form.kind === 'aidrama91_vlog'">
|
||||||
|
<el-form-item label="源站账号">
|
||||||
|
<el-input v-model="form.account" placeholder="91 站点登录手机号/账号" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="源站密码">
|
||||||
|
<el-input v-model="form.password" type="password" show-password
|
||||||
|
:placeholder="editing && editing.has_token ? '留空表示不修改' : '91 站点登录密码'" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="form.kind === 'aidrama91'" label="源站分类">
|
||||||
|
<el-input v-model="form.category_ids_text" placeholder="逗号分隔,如 4(AI短剧)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="每页条数">
|
||||||
|
<el-input-number v-model="form.page_size" :min="1" :max="50" />
|
||||||
|
<span style="margin-left: 8px; color: #909399; font-size: 12px">
|
||||||
|
{{ form.kind === 'aidrama91' ? '短剧单页上限 20' : '短视频单页上限 50' }}
|
||||||
|
</span>
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
<el-form-item label="内容来源">
|
||||||
|
<el-select v-model="form.source_id" placeholder="采集入库时打上该来源标签" style="width: 100%">
|
||||||
|
<el-option v-for="s in sources" :key="s.id" :label="s.name" :value="s.id" />
|
||||||
|
</el-select>
|
||||||
|
<div v-if="form.kind === 'aidrama91'" style="color: #909399; font-size: 12px">
|
||||||
|
91 短剧会自动绑定「91」来源,无需手动选择
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -96,9 +148,18 @@ const dialogVisible = ref(false)
|
|||||||
const editing = ref(null)
|
const editing = ref(null)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const syncingId = ref(0)
|
const syncingId = ref(0)
|
||||||
|
const cancellingId = ref(0)
|
||||||
const syncAllLoading = ref(false)
|
const syncAllLoading = ref(false)
|
||||||
|
|
||||||
const form = reactive({ name: '', list_url: '' })
|
const form = reactive({
|
||||||
|
kind: 'url', name: '', list_url: '', source_id: 0,
|
||||||
|
account: '', password: '', category_ids_text: '4', page_size: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
const sources = ref([])
|
||||||
|
function sourceName(id) {
|
||||||
|
return sources.value.find(s => s.id === id)?.name || ''
|
||||||
|
}
|
||||||
|
|
||||||
const batchVisible = ref(false)
|
const batchVisible = ref(false)
|
||||||
const batchText = ref('')
|
const batchText = ref('')
|
||||||
@@ -119,7 +180,17 @@ async function submitBatch() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(() => {
|
||||||
|
load()
|
||||||
|
loadSources()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadSources() {
|
||||||
|
try {
|
||||||
|
const r = await request({ url: '/admin/sources', method: 'get' })
|
||||||
|
sources.value = r.list || []
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -133,26 +204,58 @@ async function load() {
|
|||||||
|
|
||||||
function showCreate() {
|
function showCreate() {
|
||||||
editing.value = null
|
editing.value = null
|
||||||
Object.assign(form, { name: '', list_url: '' })
|
Object.assign(form, {
|
||||||
|
kind: 'url', name: '', list_url: '', source_id: sources.value[0]?.id ?? 0,
|
||||||
|
account: '', password: '', category_ids_text: '4', page_size: 20,
|
||||||
|
})
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function showEdit(row) {
|
function showEdit(row) {
|
||||||
editing.value = row
|
editing.value = row
|
||||||
Object.assign(form, { name: row.name, list_url: row.list_url })
|
Object.assign(form, {
|
||||||
|
kind: row.kind || 'url',
|
||||||
|
name: row.name, list_url: row.list_url, source_id: row.source_id || 0,
|
||||||
|
account: row.account || '', password: '',
|
||||||
|
category_ids_text: (row.category_ids || [4]).join(','),
|
||||||
|
page_size: row.page_size || 20,
|
||||||
|
})
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseCategoryIds(text) {
|
||||||
|
return (text || '').split(/[,,\s]+/).map(s => parseInt(s, 10)).filter(n => n > 0)
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!form.name || !form.list_url) { ElMessage.warning('请填写完整'); return }
|
if (!form.name || !form.list_url) { ElMessage.warning('请填写完整'); return }
|
||||||
if (!form.list_url.includes('{{page}}')) { ElMessage.warning('URL 需包含 {{page}} 页码占位符'); return }
|
const isAidrama = form.kind === 'aidrama91' || form.kind === 'aidrama91_vlog'
|
||||||
|
if (isAidrama) {
|
||||||
|
if (!form.account) { ElMessage.warning('请填写源站账号'); return }
|
||||||
|
if (!editing.value && !form.password) { ElMessage.warning('请填写源站密码'); return }
|
||||||
|
} else if (!form.list_url.includes('{{page}}')) {
|
||||||
|
ElMessage.warning('URL 需包含 {{page}} 页码占位符'); return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
kind: form.kind, name: form.name, list_url: form.list_url,
|
||||||
|
source_id: form.source_id || 0,
|
||||||
|
}
|
||||||
|
if (isAidrama) {
|
||||||
|
const ids = parseCategoryIds(form.category_ids_text)
|
||||||
|
payload.account = form.account
|
||||||
|
payload.category_ids = ids.length ? ids : [4]
|
||||||
|
payload.page_size = form.page_size || 20
|
||||||
|
if (form.password) payload.password = form.password
|
||||||
|
}
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
if (editing.value) {
|
if (editing.value) {
|
||||||
await request({ url: `/admin/collection/sources/${editing.value.id}`, method: 'put', data: form })
|
await request({ url: `/admin/collection/sources/${editing.value.id}`, method: 'put', data: payload })
|
||||||
ElMessage.success('已更新')
|
ElMessage.success('已更新')
|
||||||
} else {
|
} else {
|
||||||
await request({ url: '/admin/collection/sources', method: 'post', data: form })
|
await request({ url: '/admin/collection/sources', method: 'post', data: payload })
|
||||||
ElMessage.success('已添加,点击"同步"开始采集')
|
ElMessage.success('已添加,点击"同步"开始采集')
|
||||||
}
|
}
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
@@ -195,6 +298,23 @@ async function syncOne(row) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cancelSync(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要取消「${row.name}」的同步任务吗?任务会在当前页/当前条目处理完后自然停止。`,
|
||||||
|
'取消同步',
|
||||||
|
{ type: 'warning', confirmButtonText: '取消同步', cancelButtonText: '返回' },
|
||||||
|
)
|
||||||
|
} catch (_) { return }
|
||||||
|
cancellingId.value = row.id
|
||||||
|
try {
|
||||||
|
await request({ url: `/admin/collection/sources/${row.id}/sync/cancel`, method: 'post' })
|
||||||
|
ElMessage.success(`「${row.name}」已请求取消同步,稍后自动停止`)
|
||||||
|
} catch (e) {} finally {
|
||||||
|
cancellingId.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function syncAll() {
|
async function syncAll() {
|
||||||
syncAllLoading.value = true
|
syncAllLoading.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -8,6 +8,11 @@
|
|||||||
<el-input v-model="keyword" placeholder="搜索系列标题" clearable
|
<el-input v-model="keyword" placeholder="搜索系列标题" clearable
|
||||||
style="width: 200px; margin-right: 8px" @keyup.enter="load" />
|
style="width: 200px; margin-right: 8px" @keyup.enter="load" />
|
||||||
|
|
||||||
|
<el-select v-model="filterSourceId" placeholder="全部来源" clearable
|
||||||
|
style="width: 130px; margin-right: 8px" @change="onFilterChange">
|
||||||
|
<el-option v-for="s in sources" :key="s.id" :label="s.name" :value="s.id" />
|
||||||
|
</el-select>
|
||||||
|
|
||||||
<el-dropdown trigger="click" @command="handleBatchCmd">
|
<el-dropdown trigger="click" @command="handleBatchCmd">
|
||||||
<el-button :loading="batchLoading">批量更新 <el-icon class="el-icon--right"><arrow-down /></el-icon></el-button>
|
<el-button :loading="batchLoading">批量更新 <el-icon class="el-icon--right"><arrow-down /></el-icon></el-button>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
@@ -47,9 +52,15 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="total_episodes" label="集数" width="90" />
|
<el-table-column prop="total_episodes" label="集数" width="90" />
|
||||||
<el-table-column label="来源" width="80">
|
<el-table-column label="来源" width="110">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag v-if="row.source_id" size="small" type="warning">采集</el-tag>
|
<el-tag v-if="row.source_name" size="small" type="warning">{{ row.source_name }}</el-tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="采集" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.collect_source_id" size="small" type="success">已采集</el-tag>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -64,7 +75,7 @@
|
|||||||
<el-table-column label="操作" width="230" fixed="right">
|
<el-table-column label="操作" width="230" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" @click="showEdit(row)">编辑</el-button>
|
<el-button link type="primary" @click="showEdit(row)">编辑</el-button>
|
||||||
<el-button v-if="row.source_id" link type="warning"
|
<el-button v-if="row.collect_source_id" link type="warning"
|
||||||
:loading="syncingId === row.id" @click="syncOne(row)">更新</el-button>
|
:loading="syncingId === row.id" @click="syncOne(row)">更新</el-button>
|
||||||
<el-button link type="primary" @click="toggleStatus(row)">
|
<el-button link type="primary" @click="toggleStatus(row)">
|
||||||
{{ row.status === 1 ? '隐藏' : '恢复' }}
|
{{ row.status === 1 ? '隐藏' : '恢复' }}
|
||||||
@@ -120,6 +131,11 @@
|
|||||||
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="来源">
|
||||||
|
<el-select v-model="form.source_id" placeholder="请选择来源" style="width: 100%">
|
||||||
|
<el-option v-for="s in sources" :key="s.id" :label="s.name" :value="s.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="简介">
|
<el-form-item label="简介">
|
||||||
<el-input v-model="form.description" type="textarea" :rows="3" maxlength="500" show-word-limit />
|
<el-input v-model="form.description" type="textarea" :rows="3" maxlength="500" show-word-limit />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -151,6 +167,13 @@
|
|||||||
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="来源">
|
||||||
|
<el-switch v-model="batchForm.touchSource" active-text="修改" inactive-text="不改" />
|
||||||
|
<el-select v-if="batchForm.touchSource" v-model="batchForm.source_id"
|
||||||
|
placeholder="请选择来源" style="width: 180px; margin-left: 12px">
|
||||||
|
<el-option v-for="s in sources" :key="s.id" :label="s.name" :value="s.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="batchDialogVisible = false">取消</el-button>
|
<el-button @click="batchDialogVisible = false">取消</el-button>
|
||||||
@@ -173,6 +196,8 @@ const pageSize = 20
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const categories = ref([])
|
const categories = ref([])
|
||||||
|
const sources = ref([])
|
||||||
|
const filterSourceId = ref(null)
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const editing = ref(null)
|
const editing = ref(null)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
@@ -184,12 +209,13 @@ const batchDialogVisible = ref(false)
|
|||||||
const batchSubmitting = ref(false)
|
const batchSubmitting = ref(false)
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
title: '', cover_url: '', description: '', category_id: 0,
|
title: '', cover_url: '', description: '', category_id: 0, source_id: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const batchForm = reactive({
|
const batchForm = reactive({
|
||||||
touchStatus: false, status: 1,
|
touchStatus: false, status: 1,
|
||||||
touchCategory: false, category_id: 0,
|
touchCategory: false, category_id: 0,
|
||||||
|
touchSource: false, source_id: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
function onSelectionChange(rows) {
|
function onSelectionChange(rows) {
|
||||||
@@ -224,18 +250,34 @@ async function uploadCover(file) {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
load()
|
load()
|
||||||
|
loadSources()
|
||||||
try {
|
try {
|
||||||
const r = await request({ url: '/admin/categories', method: 'get' })
|
const r = await request({ url: '/admin/categories', method: 'get' })
|
||||||
categories.value = r || []
|
categories.value = r || []
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function loadSources() {
|
||||||
|
try {
|
||||||
|
const r = await request({ url: '/admin/sources', method: 'get' })
|
||||||
|
sources.value = r.list || []
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFilterChange() {
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const r = await request({
|
const r = await request({
|
||||||
url: '/admin/series', method: 'get',
|
url: '/admin/series', method: 'get',
|
||||||
params: { keyword: keyword.value, page: page.value, page_size: pageSize },
|
params: {
|
||||||
|
keyword: keyword.value, page: page.value, page_size: pageSize,
|
||||||
|
source_id: filterSourceId.value ?? undefined,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
list.value = r.list
|
list.value = r.list
|
||||||
total.value = r.total
|
total.value = r.total
|
||||||
@@ -246,7 +288,10 @@ async function load() {
|
|||||||
|
|
||||||
function showCreate() {
|
function showCreate() {
|
||||||
editing.value = null
|
editing.value = null
|
||||||
Object.assign(form, { title: '', cover_url: '', description: '', category_id: 0 })
|
Object.assign(form, {
|
||||||
|
title: '', cover_url: '', description: '', category_id: 0,
|
||||||
|
source_id: sources.value[0]?.id ?? 0,
|
||||||
|
})
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +300,7 @@ function showEdit(row) {
|
|||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
title: row.title, cover_url: row.cover_url,
|
title: row.title, cover_url: row.cover_url,
|
||||||
description: row.description, category_id: row.category_id ?? 0,
|
description: row.description, category_id: row.category_id ?? 0,
|
||||||
|
source_id: row.source_id ?? 0,
|
||||||
})
|
})
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -344,12 +390,15 @@ async function handleBatchCmd(cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showBatchEdit() {
|
function showBatchEdit() {
|
||||||
Object.assign(batchForm, { touchStatus: false, status: 1, touchCategory: false, category_id: 0 })
|
Object.assign(batchForm, {
|
||||||
|
touchStatus: false, status: 1, touchCategory: false, category_id: 0,
|
||||||
|
touchSource: false, source_id: sources.value[0]?.id ?? 0,
|
||||||
|
})
|
||||||
batchDialogVisible.value = true
|
batchDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function batchSave() {
|
async function batchSave() {
|
||||||
if (!batchForm.touchStatus && !batchForm.touchCategory) {
|
if (!batchForm.touchStatus && !batchForm.touchCategory && !batchForm.touchSource) {
|
||||||
ElMessage.warning('至少勾选一个要修改的字段')
|
ElMessage.warning('至少勾选一个要修改的字段')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -358,6 +407,7 @@ async function batchSave() {
|
|||||||
const data = { series_ids: selectedIds.value }
|
const data = { series_ids: selectedIds.value }
|
||||||
if (batchForm.touchStatus) data.status = batchForm.status
|
if (batchForm.touchStatus) data.status = batchForm.status
|
||||||
if (batchForm.touchCategory) data.category_id = batchForm.category_id
|
if (batchForm.touchCategory) data.category_id = batchForm.category_id
|
||||||
|
if (batchForm.touchSource) data.source_id = batchForm.source_id
|
||||||
const r = await request({ url: '/admin/series/batch', method: 'put', data })
|
const r = await request({ url: '/admin/series/batch', method: 'put', data })
|
||||||
ElMessage.success(`已更新 ${r.affected} 个系列`)
|
ElMessage.success(`已更新 ${r.affected} 个系列`)
|
||||||
batchDialogVisible.value = false
|
batchDialogVisible.value = false
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="header">
|
||||||
|
<span>来源管理</span>
|
||||||
|
<el-button type="primary" @click="showCreate">+ 新建来源</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 12px"
|
||||||
|
title="视频和系列按「来源」区分片商(如 麻豆、91)。采集时自动打标,也可在视频/系列管理里批量修改。来源下有内容时不能删除,可改为停用。" />
|
||||||
|
|
||||||
|
<el-table :data="list" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
|
<el-table-column prop="name" label="来源名称" width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag type="warning" size="small">{{ row.name }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="code" label="标识" width="140">
|
||||||
|
<template #default="{ row }">{{ row.code || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="sort" label="排序" width="80" />
|
||||||
|
<el-table-column prop="video_count" label="视频数" width="100" />
|
||||||
|
<el-table-column prop="series_count" label="系列数" width="100" />
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||||
|
{{ row.status === 1 ? '启用' : '停用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="200" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="showEdit(row)">编辑</el-button>
|
||||||
|
<el-button link type="primary" @click="toggleStatus(row)">
|
||||||
|
{{ row.status === 1 ? '停用' : '启用' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="danger" @click="del(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="editing ? '编辑来源' : '新建来源'" width="420px">
|
||||||
|
<el-form :model="form" label-width="90px">
|
||||||
|
<el-form-item label="名称">
|
||||||
|
<el-input v-model="form.name" maxlength="50" placeholder="如 麻豆、91" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="标识">
|
||||||
|
<el-input v-model="form.code" maxlength="50" placeholder="如 madou(选填,用于采集自动打标)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="排序">
|
||||||
|
<el-input-number v-model="form.sort" :min="0" :max="9999" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-switch v-model="form.status" :active-value="1" :inactive-value="0"
|
||||||
|
active-text="启用" inactive-text="停用" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="save">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import request from '@/api/request'
|
||||||
|
|
||||||
|
const list = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editing = ref(null)
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({ name: '', code: '', sort: 0, status: 1 })
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const r = await request({ url: '/admin/sources', method: 'get' })
|
||||||
|
list.value = r.list || []
|
||||||
|
} catch (e) {} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreate() {
|
||||||
|
editing.value = null
|
||||||
|
Object.assign(form, { name: '', code: '', sort: 0, status: 1 })
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEdit(row) {
|
||||||
|
editing.value = row
|
||||||
|
Object.assign(form, {
|
||||||
|
name: row.name, code: row.code || '', sort: row.sort, status: row.status,
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!form.name.trim()) { ElMessage.warning('请填来源名称'); return }
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (editing.value) {
|
||||||
|
await request({ url: `/admin/sources/${editing.value.id}`, method: 'put', data: form })
|
||||||
|
ElMessage.success('已更新')
|
||||||
|
} else {
|
||||||
|
await request({ url: '/admin/sources', method: 'post', data: form })
|
||||||
|
ElMessage.success('已创建')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
load()
|
||||||
|
} catch (e) {} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleStatus(row) {
|
||||||
|
try {
|
||||||
|
await request({
|
||||||
|
url: `/admin/sources/${row.id}`, method: 'put',
|
||||||
|
data: { status: row.status === 1 ? 0 : 1 },
|
||||||
|
})
|
||||||
|
ElMessage.success('已更新')
|
||||||
|
load()
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认删除来源「${row.name}」?来源下有视频或系列时不允许删除。`,
|
||||||
|
'警告', { type: 'warning' }
|
||||||
|
)
|
||||||
|
} catch { return }
|
||||||
|
try {
|
||||||
|
await request({ url: `/admin/sources/${row.id}`, method: 'delete' })
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
load()
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<el-alert type="warning" :closable="false" style="margin-bottom: 16px">
|
||||||
|
<template #title>
|
||||||
|
<div>
|
||||||
|
<b>数据库备份与恢复(仅 super)</b>
|
||||||
|
<div style="font-size: 12px; margin-top: 4px; line-height: 1.6">
|
||||||
|
· 备份:mysqldump 全库导出 + gzip,文件保存在服务器 <code>runtime/backups/</code>。<br>
|
||||||
|
· 恢复:会<b style="color: #f56c6c">覆盖整个数据库</b>,操作前请确保已创建新备份,并输入「确认恢复」二次确认。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-button type="primary" :icon="Plus" :loading="creating" @click="onCreate">
|
||||||
|
立即备份
|
||||||
|
</el-button>
|
||||||
|
<el-button type="warning" :icon="Upload" :loading="restoring" @click="onUploadRestore">
|
||||||
|
上传 .sql.gz 恢复
|
||||||
|
</el-button>
|
||||||
|
<el-button :icon="Refresh" @click="load">刷新列表</el-button>
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept=".sql.gz,application/gzip"
|
||||||
|
style="display:none"
|
||||||
|
@change="onFileChosen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="rows" border stripe>
|
||||||
|
<el-table-column prop="filename" label="文件名" min-width="240" />
|
||||||
|
<el-table-column label="大小" width="120">
|
||||||
|
<template #default="{ row }">{{ formatSize(row.size) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="created_at" label="备份时间" width="200" />
|
||||||
|
<el-table-column label="操作" width="320" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" :icon="Download" @click="onDownload(row)">下载</el-button>
|
||||||
|
<el-button size="small" type="warning" :icon="RefreshLeft" @click="onRestore(row)">
|
||||||
|
恢复
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="danger" :icon="Delete" @click="onDelete(row)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty><el-empty description="暂无备份" /></template>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Delete,
|
||||||
|
Download,
|
||||||
|
Plus,
|
||||||
|
Refresh,
|
||||||
|
RefreshLeft,
|
||||||
|
Upload,
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createBackup,
|
||||||
|
deleteBackup,
|
||||||
|
downloadBackup,
|
||||||
|
listBackups,
|
||||||
|
restoreFromExisting,
|
||||||
|
restoreFromUpload,
|
||||||
|
} from '@/api/system'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const creating = ref(false)
|
||||||
|
const restoring = ref(false)
|
||||||
|
const rows = ref([])
|
||||||
|
const fileInputRef = ref(null)
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (!bytes) return '0 B'
|
||||||
|
if (bytes >= 1 << 30) return `${(bytes / (1 << 30)).toFixed(2)} GB`
|
||||||
|
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(2)} MB`
|
||||||
|
if (bytes >= 1 << 10) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${bytes} B`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await listBackups()
|
||||||
|
rows.value = data.items || []
|
||||||
|
} catch (_) {
|
||||||
|
// request.js 已统一报错
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreate() {
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const data = await createBackup()
|
||||||
|
ElMessage.success(`备份成功:${data.filename}(${formatSize(data.size)})`)
|
||||||
|
await load()
|
||||||
|
} catch (_) {
|
||||||
|
// 已提示
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDownload(row) {
|
||||||
|
try {
|
||||||
|
await downloadBackup(row.filename)
|
||||||
|
ElMessage.success('开始下载')
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message || '下载失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定删除备份文件「${row.filename}」吗?删除后无法恢复。`,
|
||||||
|
'删除确认',
|
||||||
|
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
||||||
|
)
|
||||||
|
} catch (_) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteBackup(row.filename)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
await load()
|
||||||
|
} catch (_) {
|
||||||
|
// 已提示
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 二次确认:必须输入「确认恢复」才能继续 */
|
||||||
|
async function confirmRestore(tip) {
|
||||||
|
try {
|
||||||
|
const { value } = await ElMessageBox.prompt(
|
||||||
|
`${tip}\n\n此操作会覆盖当前数据库全部数据,不可恢复!\n请输入「确认恢复」继续:`,
|
||||||
|
'高危操作',
|
||||||
|
{
|
||||||
|
type: 'error',
|
||||||
|
confirmButtonText: '执行恢复',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputPlaceholder: '确认恢复',
|
||||||
|
inputValidator: (v) => (v === '确认恢复' ? true : '请输入完整文字「确认恢复」'),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return value === '确认恢复'
|
||||||
|
} catch (_) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRestore(row) {
|
||||||
|
const okToRun = await confirmRestore(`将从备份「${row.filename}」恢复数据库。`)
|
||||||
|
if (!okToRun) return
|
||||||
|
restoring.value = true
|
||||||
|
try {
|
||||||
|
await restoreFromExisting(row.filename)
|
||||||
|
ElMessage.success('恢复完成')
|
||||||
|
} catch (_) {
|
||||||
|
// 已提示
|
||||||
|
} finally {
|
||||||
|
restoring.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUploadRestore() {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFileChosen(event) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
event.target.value = '' // 重置,确保下次还能选同一文件
|
||||||
|
if (!file) return
|
||||||
|
if (!file.name.endsWith('.sql.gz')) {
|
||||||
|
ElMessage.error('仅支持 .sql.gz 格式')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const okToRun = await confirmRestore(
|
||||||
|
`将上传「${file.name}」(${formatSize(file.size)})并恢复数据库。`,
|
||||||
|
)
|
||||||
|
if (!okToRun) return
|
||||||
|
restoring.value = true
|
||||||
|
try {
|
||||||
|
await restoreFromUpload(file)
|
||||||
|
ElMessage.success('上传并恢复完成')
|
||||||
|
await load()
|
||||||
|
} catch (_) {
|
||||||
|
// 已提示
|
||||||
|
} finally {
|
||||||
|
restoring.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="header">
|
||||||
|
<span class="title">用户操作日志</span>
|
||||||
|
<span class="tip">仅 256kb@gmail.com 等白名单账号会产生上报数据</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form inline @submit.prevent>
|
||||||
|
<el-form-item label="用户ID">
|
||||||
|
<el-input v-model="filters.userId" placeholder="user_id" clearable style="width:140px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="行为">
|
||||||
|
<el-select v-model="filters.action" placeholder="全部" clearable style="width:200px">
|
||||||
|
<el-option v-for="a in actionOptions" :key="a" :label="a" :value="a" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input v-model="filters.keyword" placeholder="detail 包含" clearable style="width:200px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="时间">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="filters.range"
|
||||||
|
type="daterange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始"
|
||||||
|
end-placeholder="结束"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
style="width:260px"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" @click="load(1)">查询</el-button>
|
||||||
|
<el-button @click="reset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table :data="rows" v-loading="loading" stripe size="small">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="created_at" label="时间" width="160" />
|
||||||
|
<el-table-column prop="user_id" label="用户" width="80" />
|
||||||
|
<el-table-column prop="action" label="行为" width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="tagType(row.action)">{{ row.action }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="page" label="页面" width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="detail" label="上下文" min-width="320" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="app_version" label="版本" width="100" />
|
||||||
|
<el-table-column prop="device_info" label="设备" min-width="180" show-overflow-tooltip />
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pager">
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
layout="total, prev, pager, next, sizes"
|
||||||
|
:total="total"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:page-sizes="[20, 50, 100, 200]"
|
||||||
|
:current-page="page"
|
||||||
|
@current-change="load"
|
||||||
|
@size-change="onSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import request from '../api/request'
|
||||||
|
|
||||||
|
const rows = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(50)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const filters = reactive({
|
||||||
|
userId: '',
|
||||||
|
action: '',
|
||||||
|
keyword: '',
|
||||||
|
range: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const actionOptions = [
|
||||||
|
'log_session_start',
|
||||||
|
'log_session_end',
|
||||||
|
'logout',
|
||||||
|
'route_enter',
|
||||||
|
'route_exit',
|
||||||
|
'feed_did_pop_next',
|
||||||
|
'player_stopall',
|
||||||
|
'player_suspend',
|
||||||
|
'player_resume',
|
||||||
|
'api_error',
|
||||||
|
]
|
||||||
|
|
||||||
|
function tagType(action) {
|
||||||
|
if (action.startsWith('player_')) return 'warning'
|
||||||
|
if (action.startsWith('route_')) return 'success'
|
||||||
|
if (action === 'api_error') return 'danger'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(p) {
|
||||||
|
if (typeof p === 'number') page.value = p
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
}
|
||||||
|
if (filters.userId) params.user_id = Number(filters.userId)
|
||||||
|
if (filters.action) params.action = filters.action
|
||||||
|
if (filters.keyword) params.keyword = filters.keyword
|
||||||
|
if (filters.range && filters.range[0]) {
|
||||||
|
params.start = filters.range[0]
|
||||||
|
params.end = filters.range[1]
|
||||||
|
}
|
||||||
|
const { data } = await request.get('/admin/user-action-logs', { params })
|
||||||
|
rows.value = data.items || []
|
||||||
|
total.value = data.total || 0
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e?.message || '加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
filters.userId = ''
|
||||||
|
filters.action = ''
|
||||||
|
filters.keyword = ''
|
||||||
|
filters.range = null
|
||||||
|
load(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSizeChange(s) {
|
||||||
|
pageSize.value = s
|
||||||
|
load(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => load(1))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { padding: 16px; }
|
||||||
|
.header { display: flex; align-items: baseline; gap: 12px; }
|
||||||
|
.title { font-weight: 600; }
|
||||||
|
.tip { color: #909399; font-size: 12px; }
|
||||||
|
.pager { margin-top: 12px; display: flex; justify-content: flex-end; }
|
||||||
|
</style>
|
||||||
@@ -8,11 +8,23 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
<el-input v-model="filters.author_id" placeholder="作者 ID" clearable style="width: 140px"
|
<el-input v-model="filters.author_id" placeholder="作者 ID" clearable style="width: 140px"
|
||||||
@keyup.enter="search" />
|
@keyup.enter="search" />
|
||||||
|
<el-select v-model="filters.source_id" placeholder="来源" clearable style="width: 120px"
|
||||||
|
@change="search">
|
||||||
|
<el-option v-for="s in sources" :key="s.id" :label="s.name" :value="s.id" />
|
||||||
|
</el-select>
|
||||||
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
|
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
|
||||||
<el-button :icon="Refresh" @click="reset">重置</el-button>
|
<el-button :icon="Refresh" @click="reset">重置</el-button>
|
||||||
|
<el-button v-if="selectedIds.length" type="warning" plain @click="openBatchSource">
|
||||||
|
批量改来源(已选 {{ selectedIds.length }})
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="selectedIds.length" type="danger" plain :loading="batchOfflineSubmitting"
|
||||||
|
@click="batchOffline">
|
||||||
|
批量下架(已选 {{ selectedIds.length }})
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="rows" border stripe>
|
<el-table v-loading="loading" :data="rows" border stripe @selection-change="onSelectionChange">
|
||||||
|
<el-table-column type="selection" width="42" />
|
||||||
<el-table-column prop="video_id" label="ID" width="80" />
|
<el-table-column prop="video_id" label="ID" width="80" />
|
||||||
<el-table-column label="封面" width="100">
|
<el-table-column label="封面" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -28,6 +40,12 @@
|
|||||||
<el-table-column label="分类" width="90">
|
<el-table-column label="分类" width="90">
|
||||||
<template #default="{ row }">{{ categoryName(row.category_id) }}</template>
|
<template #default="{ row }">{{ categoryName(row.category_id) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="来源" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.source_name" size="small" type="warning">{{ row.source_name }}</el-tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="100">
|
<el-table-column label="状态" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="statusTagType(row.status)" size="small">{{ row.status_text }}</el-tag>
|
<el-tag :type="statusTagType(row.status)" size="small">{{ row.status_text }}</el-tag>
|
||||||
@@ -81,6 +99,11 @@
|
|||||||
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="来源">
|
||||||
|
<el-select v-model="editForm.source_id" placeholder="请选择来源" style="width: 100%">
|
||||||
|
<el-option v-for="s in sources" :key="s.id" :label="s.name" :value="s.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="归属系列">
|
<el-form-item label="归属系列">
|
||||||
<el-select v-model="editForm.series_id" placeholder="不归属系列" style="width: 100%" clearable
|
<el-select v-model="editForm.series_id" placeholder="不归属系列" style="width: 100%" clearable
|
||||||
@change="onEditSeriesChange">
|
@change="onEditSeriesChange">
|
||||||
@@ -104,6 +127,20 @@
|
|||||||
<el-button type="primary" :loading="editSubmitting" @click="saveEdit">保存</el-button>
|
<el-button type="primary" :loading="editSubmitting" @click="saveEdit">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 批量改来源 -->
|
||||||
|
<el-dialog v-model="batchVisible" title="批量修改来源" width="400px">
|
||||||
|
<div style="margin-bottom: 12px; color: #606266; font-size: 13px">
|
||||||
|
共选中 <b>{{ selectedIds.length }}</b> 个视频,统一改为:
|
||||||
|
</div>
|
||||||
|
<el-select v-model="batchSourceId" placeholder="请选择来源" style="width: 100%">
|
||||||
|
<el-option v-for="s in sources" :key="s.id" :label="s.name" :value="s.id" />
|
||||||
|
</el-select>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="batchVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="batchSubmitting" @click="saveBatchSource">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -113,7 +150,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
|||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
|
||||||
import request from '@/api/request'
|
import request from '@/api/request'
|
||||||
import { listVideos, offlineVideo, updateVideo, deleteVideo } from '@/api/videos'
|
import { listVideos, offlineVideo, updateVideo, deleteVideo, batchUpdateVideos, batchOfflineVideos } from '@/api/videos'
|
||||||
import VideoPreview from '@/components/VideoPreview.vue'
|
import VideoPreview from '@/components/VideoPreview.vue'
|
||||||
import { askReason } from '@/utils/reason'
|
import { askReason } from '@/utils/reason'
|
||||||
|
|
||||||
@@ -133,10 +170,17 @@ const rows = ref([])
|
|||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
const filters = reactive({ keyword: '', status: '', author_id: '' })
|
const filters = reactive({ keyword: '', status: '', author_id: '', source_id: '' })
|
||||||
|
|
||||||
const categories = ref([])
|
const categories = ref([])
|
||||||
const seriesList = ref([])
|
const seriesList = ref([])
|
||||||
|
const sources = ref([])
|
||||||
|
|
||||||
|
const selectedIds = ref([])
|
||||||
|
const batchVisible = ref(false)
|
||||||
|
const batchSourceId = ref(0)
|
||||||
|
const batchSubmitting = ref(false)
|
||||||
|
const batchOfflineSubmitting = ref(false)
|
||||||
|
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
const previewUrl = ref('')
|
const previewUrl = ref('')
|
||||||
@@ -148,7 +192,7 @@ const coverUploading = ref(false)
|
|||||||
const editingVideoId = ref(null)
|
const editingVideoId = ref(null)
|
||||||
const editForm = reactive({
|
const editForm = reactive({
|
||||||
title: '', description: '', cover_url: '',
|
title: '', description: '', cover_url: '',
|
||||||
category_id: 0, series_id: null, episode_no: null,
|
category_id: 0, source_id: 0, series_id: null, episode_no: null,
|
||||||
status: 2,
|
status: 2,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -168,6 +212,7 @@ async function load() {
|
|||||||
keyword: filters.keyword,
|
keyword: filters.keyword,
|
||||||
status: filters.status,
|
status: filters.status,
|
||||||
author_id: filters.author_id,
|
author_id: filters.author_id,
|
||||||
|
source_id: filters.source_id,
|
||||||
page: page.value,
|
page: page.value,
|
||||||
page_size: pageSize.value,
|
page_size: pageSize.value,
|
||||||
})
|
})
|
||||||
@@ -180,10 +225,35 @@ async function load() {
|
|||||||
|
|
||||||
function search() { page.value = 1; load() }
|
function search() { page.value = 1; load() }
|
||||||
function reset() {
|
function reset() {
|
||||||
filters.keyword = ''; filters.status = ''; filters.author_id = ''
|
filters.keyword = ''; filters.status = ''; filters.author_id = ''; filters.source_id = ''
|
||||||
search()
|
search()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onSelectionChange(sel) {
|
||||||
|
selectedIds.value = sel.map(r => r.video_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBatchSource() {
|
||||||
|
batchSourceId.value = sources.value[0]?.id ?? 0
|
||||||
|
batchVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBatchSource() {
|
||||||
|
if (!batchSourceId.value) { ElMessage.warning('请选择来源'); return }
|
||||||
|
batchSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const r = await batchUpdateVideos({
|
||||||
|
video_ids: selectedIds.value,
|
||||||
|
source_id: batchSourceId.value,
|
||||||
|
})
|
||||||
|
ElMessage.success(`已更新 ${r.affected} 个视频`)
|
||||||
|
batchVisible.value = false
|
||||||
|
load()
|
||||||
|
} catch (e) {} finally {
|
||||||
|
batchSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openPreview(row) {
|
function openPreview(row) {
|
||||||
previewUrl.value = row.video_url
|
previewUrl.value = row.video_url
|
||||||
previewTitle.value = `预览:${row.title}`
|
previewTitle.value = `预览:${row.title}`
|
||||||
@@ -200,6 +270,38 @@ async function offline(row) {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function batchOffline() {
|
||||||
|
if (!selectedIds.value.length) return
|
||||||
|
// 仅已发布的可下架,过滤一次减少后端空跑
|
||||||
|
const targets = rows.value.filter(r => selectedIds.value.includes(r.video_id) && r.status === 2)
|
||||||
|
if (!targets.length) {
|
||||||
|
ElMessage.warning('选中的视频里没有「已发布」状态的,无需下架')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let reason
|
||||||
|
try {
|
||||||
|
reason = await askReason({
|
||||||
|
title: '批量下架视频',
|
||||||
|
message: `将下架 ${targets.length} 个已发布视频(其余 ${selectedIds.value.length - targets.length} 个非已发布状态会自动跳过),并通知作者`,
|
||||||
|
})
|
||||||
|
} catch { return }
|
||||||
|
batchOfflineSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const r = await batchOfflineVideos({
|
||||||
|
video_ids: targets.map(r => r.video_id),
|
||||||
|
reason,
|
||||||
|
})
|
||||||
|
if (r.skipped?.length) {
|
||||||
|
ElMessage.warning(`已下架 ${r.success} 个,跳过 ${r.skipped.length} 个(ID: ${r.skipped.slice(0, 5).join(',')}${r.skipped.length > 5 ? '…' : ''})`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`已批量下架 ${r.success} 个视频`)
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
} catch (e) {} finally {
|
||||||
|
batchOfflineSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteVideoRow(row) {
|
async function deleteVideoRow(row) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
@@ -221,6 +323,7 @@ function openEdit(row) {
|
|||||||
description: row.description || '',
|
description: row.description || '',
|
||||||
cover_url: row.cover_url || '',
|
cover_url: row.cover_url || '',
|
||||||
category_id: row.category_id || 0,
|
category_id: row.category_id || 0,
|
||||||
|
source_id: row.source_id || 0,
|
||||||
series_id: row.series_id || null,
|
series_id: row.series_id || null,
|
||||||
episode_no: row.episode_no || null,
|
episode_no: row.episode_no || null,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
@@ -260,6 +363,7 @@ async function saveEdit() {
|
|||||||
description: editForm.description,
|
description: editForm.description,
|
||||||
cover_url: editForm.cover_url,
|
cover_url: editForm.cover_url,
|
||||||
category_id: editForm.category_id || 0,
|
category_id: editForm.category_id || 0,
|
||||||
|
source_id: editForm.source_id || 0,
|
||||||
status: editForm.status,
|
status: editForm.status,
|
||||||
}
|
}
|
||||||
// 只有用户动过才提交
|
// 只有用户动过才提交
|
||||||
@@ -285,6 +389,10 @@ onMounted(async () => {
|
|||||||
const r = await request({ url: '/admin/series', method: 'get', params: { page_size: 200 } })
|
const r = await request({ url: '/admin/series', method: 'get', params: { page_size: 200 } })
|
||||||
seriesList.value = r?.list || []
|
seriesList.value = r?.list || []
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
const r = await request({ url: '/admin/sources', method: 'get' })
|
||||||
|
sources.value = r.list || []
|
||||||
|
} catch (e) {}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:collection';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
|
import '../network/api_client.dart';
|
||||||
|
|
||||||
|
/// 用户操作日志上报器(仅白名单邮箱用户启用)
|
||||||
|
///
|
||||||
|
/// 用法:
|
||||||
|
/// ```dart
|
||||||
|
/// ActionLogger.I.bindUser(userEmail);
|
||||||
|
/// ActionLogger.I.log('route_push', page: '/search/feed', detail: {...});
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// 设计:
|
||||||
|
/// - 白名单:只有 email == 256kb@gmail.com 时启用,其他账号调用为 no-op
|
||||||
|
/// - 缓冲:本地 Queue 缓冲,达到 BATCH_SIZE 或超过 FLUSH_INTERVAL 自动批量上传
|
||||||
|
/// - 静默失败:网络错误/服务端 403 不重试不上抛,避免影响业务
|
||||||
|
class ActionLogger {
|
||||||
|
ActionLogger._();
|
||||||
|
static final ActionLogger I = ActionLogger._();
|
||||||
|
|
||||||
|
/// 白名单邮箱(与后端 ALLOWED_EMAILS 保持一致)
|
||||||
|
static const String _allowedEmail = '256kb@gmail.com';
|
||||||
|
|
||||||
|
static const int _batchSize = 30;
|
||||||
|
static const Duration _flushInterval = Duration(seconds: 10);
|
||||||
|
static const int _maxQueueLen = 500; // 防止离线积压撑爆内存
|
||||||
|
|
||||||
|
bool _enabled = false;
|
||||||
|
String _userEmail = '';
|
||||||
|
String _deviceInfo = '';
|
||||||
|
String _appVersion = '';
|
||||||
|
|
||||||
|
final Queue<Map<String, dynamic>> _queue = Queue();
|
||||||
|
Timer? _flushTimer;
|
||||||
|
bool _flushing = false;
|
||||||
|
|
||||||
|
bool get enabled => _enabled;
|
||||||
|
|
||||||
|
/// 登录成功后调用,开启上报;非白名单邮箱则关闭
|
||||||
|
void bindUser(String? email) {
|
||||||
|
_userEmail = email ?? '';
|
||||||
|
_enabled = _userEmail.toLowerCase() == _allowedEmail;
|
||||||
|
if (_enabled) {
|
||||||
|
_initMeta();
|
||||||
|
_startFlushTimer();
|
||||||
|
log('log_session_start', page: 'app', detail: {'email': _userEmail});
|
||||||
|
} else {
|
||||||
|
_stopFlushTimer();
|
||||||
|
_queue.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登出/切换账号时调用
|
||||||
|
void unbind() {
|
||||||
|
if (_enabled) {
|
||||||
|
log('log_session_end', page: 'app', detail: {'email': _userEmail});
|
||||||
|
// 立即上传残余
|
||||||
|
unawaited(flush());
|
||||||
|
}
|
||||||
|
_enabled = false;
|
||||||
|
_userEmail = '';
|
||||||
|
_stopFlushTimer();
|
||||||
|
// 不清 queue:flush 已在飞,让它带完最后这批
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initMeta() async {
|
||||||
|
try {
|
||||||
|
final info = await PackageInfo.fromPlatform();
|
||||||
|
_appVersion = '${info.version}+${info.buildNumber}';
|
||||||
|
} catch (_) {
|
||||||
|
_appVersion = 'unknown';
|
||||||
|
}
|
||||||
|
_deviceInfo = '${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startFlushTimer() {
|
||||||
|
_flushTimer?.cancel();
|
||||||
|
_flushTimer = Timer.periodic(_flushInterval, (_) => unawaited(flush()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopFlushTimer() {
|
||||||
|
_flushTimer?.cancel();
|
||||||
|
_flushTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记一条日志。enabled=false 时直接 no-op
|
||||||
|
void log(
|
||||||
|
String action, {
|
||||||
|
String page = '',
|
||||||
|
Map<String, dynamic>? detail,
|
||||||
|
String? detailRaw,
|
||||||
|
}) {
|
||||||
|
if (!_enabled) return;
|
||||||
|
if (_queue.length >= _maxQueueLen) {
|
||||||
|
_queue.removeFirst(); // 丢最老的,保留最新
|
||||||
|
}
|
||||||
|
_queue.add({
|
||||||
|
'action': action,
|
||||||
|
'page': page,
|
||||||
|
'detail': detailRaw ?? (detail != null ? jsonEncode(detail) : ''),
|
||||||
|
'device_info': _deviceInfo,
|
||||||
|
'app_version': _appVersion,
|
||||||
|
'client_ts': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
});
|
||||||
|
if (_queue.length >= _batchSize) {
|
||||||
|
unawaited(flush());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 立即上传队列里的所有日志
|
||||||
|
Future<void> flush() async {
|
||||||
|
if (!_enabled || _flushing || _queue.isEmpty) return;
|
||||||
|
_flushing = true;
|
||||||
|
try {
|
||||||
|
final batch = <Map<String, dynamic>>[];
|
||||||
|
while (_queue.isNotEmpty && batch.length < 100) {
|
||||||
|
batch.add(_queue.removeFirst());
|
||||||
|
}
|
||||||
|
await ApiClient.I.post<Map<String, dynamic>>(
|
||||||
|
'/logs/action',
|
||||||
|
data: {'items': batch},
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// 静默失败:上报通道绝不能影响业务
|
||||||
|
} finally {
|
||||||
|
_flushing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
|
||||||
|
/// 视频封面图的全局缓存管理器:
|
||||||
|
/// - stalePeriod=30 天:30 天内重复访问直接命中缓存,不发请求
|
||||||
|
/// - maxNrOfCacheObjects=100000:取消原默认 200 张的容量限制,足够覆盖整个浏览历史
|
||||||
|
/// 缓存目录由系统管理,卸载/清理 App 数据时一并清除
|
||||||
|
class CoverCacheManager extends CacheManager with ImageCacheManager {
|
||||||
|
CoverCacheManager._()
|
||||||
|
: super(
|
||||||
|
Config(
|
||||||
|
'coverCache',
|
||||||
|
stalePeriod: const Duration(days: 30),
|
||||||
|
maxNrOfCacheObjects: 100000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
static final CoverCacheManager I = CoverCacheManager._();
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ class Endpoints {
|
|||||||
// ---------- 视频 ----------
|
// ---------- 视频 ----------
|
||||||
static const feed = '/videos/feed';
|
static const feed = '/videos/feed';
|
||||||
static const categories = '/videos/categories';
|
static const categories = '/videos/categories';
|
||||||
|
static const sources = '/videos/sources';
|
||||||
static const uploadToken = '/videos/upload/token';
|
static const uploadToken = '/videos/upload/token';
|
||||||
static const uploadFile = '/videos/upload/file';
|
static const uploadFile = '/videos/upload/file';
|
||||||
static const publish = '/videos';
|
static const publish = '/videos';
|
||||||
@@ -42,6 +43,7 @@ class Endpoints {
|
|||||||
static String videoComments(int videoId) => '/videos/$videoId/comments';
|
static String videoComments(int videoId) => '/videos/$videoId/comments';
|
||||||
static String videoEdit(int videoId) => '/videos/$videoId';
|
static String videoEdit(int videoId) => '/videos/$videoId';
|
||||||
static String videoDelete(int videoId) => '/videos/$videoId';
|
static String videoDelete(int videoId) => '/videos/$videoId';
|
||||||
|
static String videoOffline(int videoId) => '/videos/$videoId/offline';
|
||||||
static String tagVideos(String tagName) => '/tags/$tagName/videos';
|
static String tagVideos(String tagName) => '/tags/$tagName/videos';
|
||||||
|
|
||||||
// ---------- 互动 ----------
|
// ---------- 互动 ----------
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
|||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import 'package:media_kit_video/media_kit_video.dart';
|
import 'package:media_kit_video/media_kit_video.dart';
|
||||||
|
|
||||||
|
import '../log/action_logger.dart';
|
||||||
import '../media/local_media_server.dart';
|
import '../media/local_media_server.dart';
|
||||||
|
|
||||||
/// 一个播放槽位:Player + 视图控制器 + 当前绑定的视频
|
/// 一个播放槽位:Player + 视图控制器 + 当前绑定的视频
|
||||||
@@ -192,6 +193,10 @@ class FeedPlayerPool {
|
|||||||
/// 只有 stop 能可靠断开下载;位置存到 _resumePosition,恢复时 rebind
|
/// 只有 stop 能可靠断开下载;位置存到 _resumePosition,恢复时 rebind
|
||||||
/// 重新 open + seek 续播(分片走本地代理磁盘缓存,零流量)
|
/// 重新 open + seek 续播(分片走本地代理磁盘缓存,零流量)
|
||||||
void suspend() {
|
void suspend() {
|
||||||
|
ActionLogger.I.log('player_suspend', page: 'feed_pool', detail: {
|
||||||
|
'generation': _generation,
|
||||||
|
'currentIndex': _currentIndex,
|
||||||
|
});
|
||||||
_suspended = true;
|
_suspended = true;
|
||||||
// 使在途 bindWindow 过期:其 open 完成后不得再 play
|
// 使在途 bindWindow 过期:其 open 完成后不得再 play
|
||||||
_generation++;
|
_generation++;
|
||||||
@@ -213,6 +218,11 @@ class FeedPlayerPool {
|
|||||||
/// 挂起并 play。_suspended 必须在 await 之后才解除——否则等待期间 completed
|
/// 挂起并 play。_suspended 必须在 await 之后才解除——否则等待期间 completed
|
||||||
/// listener 的 auto-loop 会因为看到 _suspended=false 而提前 play
|
/// listener 的 auto-loop 会因为看到 _suspended=false 而提前 play
|
||||||
Future<void> resume() async {
|
Future<void> resume() async {
|
||||||
|
ActionLogger.I.log('player_resume', page: 'feed_pool', detail: {
|
||||||
|
'generation': _generation,
|
||||||
|
'currentIndex': _currentIndex,
|
||||||
|
'suspended': _suspended,
|
||||||
|
});
|
||||||
final pending = _pendingBind;
|
final pending = _pendingBind;
|
||||||
if (pending != null) {
|
if (pending != null) {
|
||||||
try {
|
try {
|
||||||
@@ -274,6 +284,11 @@ class FeedPlayerPool {
|
|||||||
/// 与 pause 不同,stop 后缓存下载断开,且槽内无媒体不会幽灵出声。
|
/// 与 pause 不同,stop 后缓存下载断开,且槽内无媒体不会幽灵出声。
|
||||||
/// 返回 Feed 后需重新 bindWindow 恢复当前页。
|
/// 返回 Feed 后需重新 bindWindow 恢复当前页。
|
||||||
void stopAll() {
|
void stopAll() {
|
||||||
|
ActionLogger.I.log('player_stopall', page: 'feed_pool', detail: {
|
||||||
|
'generation': _generation,
|
||||||
|
'currentIndex': _currentIndex,
|
||||||
|
'slots': [for (final s in _slots) s.videoId],
|
||||||
|
});
|
||||||
// 使所有进行中的 bindWindow 过期(其 await 恢复后不会再 play)
|
// 使所有进行中的 bindWindow 过期(其 await 恢复后不会再 play)
|
||||||
_generation++;
|
_generation++;
|
||||||
// stopAll 后通常跟着 rebind 重新播放(如从登录页返回),解除挂起态
|
// stopAll 后通常跟着 rebind 重新播放(如从登录页返回),解除挂起态
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ class VideoItem {
|
|||||||
this.duration = 0,
|
this.duration = 0,
|
||||||
required this.author,
|
required this.author,
|
||||||
this.categoryId = 0,
|
this.categoryId = 0,
|
||||||
|
this.sourceId = 0,
|
||||||
|
this.sourceName = '',
|
||||||
this.viewCount = 0,
|
this.viewCount = 0,
|
||||||
this.likeCount = 0,
|
this.likeCount = 0,
|
||||||
this.commentCount = 0,
|
this.commentCount = 0,
|
||||||
@@ -19,6 +21,8 @@ class VideoItem {
|
|||||||
this.status = -1,
|
this.status = -1,
|
||||||
this.statusText = '',
|
this.statusText = '',
|
||||||
this.rejectReason = '',
|
this.rejectReason = '',
|
||||||
|
this.seriesId = 0,
|
||||||
|
this.totalEpisodes = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int videoId;
|
final int videoId;
|
||||||
@@ -28,6 +32,11 @@ class VideoItem {
|
|||||||
final int duration; // 秒
|
final int duration; // 秒
|
||||||
final VideoAuthor author;
|
final VideoAuthor author;
|
||||||
final int categoryId;
|
final int categoryId;
|
||||||
|
|
||||||
|
/// 内容来源(麻豆 / 91 ...)
|
||||||
|
final int sourceId;
|
||||||
|
final String sourceName;
|
||||||
|
|
||||||
final int viewCount;
|
final int viewCount;
|
||||||
final int likeCount;
|
final int likeCount;
|
||||||
final int commentCount;
|
final int commentCount;
|
||||||
@@ -42,6 +51,12 @@ class VideoItem {
|
|||||||
final String statusText;
|
final String statusText;
|
||||||
final String rejectReason;
|
final String rejectReason;
|
||||||
|
|
||||||
|
/// 所属系列 id(0 = 非系列视频);系列聚合 Feed 中为首集 video_id
|
||||||
|
final int seriesId;
|
||||||
|
|
||||||
|
/// 系列总集数(系列聚合 Feed 返回,用于卡片「全N集」角标)
|
||||||
|
final int totalEpisodes;
|
||||||
|
|
||||||
/// 拷贝更新(点赞/收藏乐观更新用)
|
/// 拷贝更新(点赞/收藏乐观更新用)
|
||||||
VideoItem copyWith({
|
VideoItem copyWith({
|
||||||
bool? isLiked,
|
bool? isLiked,
|
||||||
@@ -58,6 +73,8 @@ class VideoItem {
|
|||||||
duration: duration,
|
duration: duration,
|
||||||
author: author,
|
author: author,
|
||||||
categoryId: categoryId,
|
categoryId: categoryId,
|
||||||
|
sourceId: sourceId,
|
||||||
|
sourceName: sourceName,
|
||||||
viewCount: viewCount,
|
viewCount: viewCount,
|
||||||
likeCount: likeCount ?? this.likeCount,
|
likeCount: likeCount ?? this.likeCount,
|
||||||
commentCount: commentCount ?? this.commentCount,
|
commentCount: commentCount ?? this.commentCount,
|
||||||
@@ -69,6 +86,8 @@ class VideoItem {
|
|||||||
status: status,
|
status: status,
|
||||||
statusText: statusText,
|
statusText: statusText,
|
||||||
rejectReason: rejectReason,
|
rejectReason: rejectReason,
|
||||||
|
seriesId: seriesId,
|
||||||
|
totalEpisodes: totalEpisodes,
|
||||||
);
|
);
|
||||||
|
|
||||||
factory VideoItem.fromJson(Map<String, dynamic> json) => VideoItem(
|
factory VideoItem.fromJson(Map<String, dynamic> json) => VideoItem(
|
||||||
@@ -78,6 +97,8 @@ class VideoItem {
|
|||||||
videoUrl: (json['video_url'] as String?) ?? '',
|
videoUrl: (json['video_url'] as String?) ?? '',
|
||||||
duration: (json['duration'] as num?)?.toInt() ?? 0,
|
duration: (json['duration'] as num?)?.toInt() ?? 0,
|
||||||
categoryId: (json['category_id'] as num?)?.toInt() ?? 0,
|
categoryId: (json['category_id'] as num?)?.toInt() ?? 0,
|
||||||
|
sourceId: (json['source_id'] as num?)?.toInt() ?? 0,
|
||||||
|
sourceName: (json['source_name'] as String?) ?? '',
|
||||||
author: VideoAuthor.fromJson(
|
author: VideoAuthor.fromJson(
|
||||||
(json['author'] as Map<String, dynamic>?) ?? const {},
|
(json['author'] as Map<String, dynamic>?) ?? const {},
|
||||||
),
|
),
|
||||||
@@ -94,6 +115,23 @@ class VideoItem {
|
|||||||
status: (json['status'] as num?)?.toInt() ?? -1,
|
status: (json['status'] as num?)?.toInt() ?? -1,
|
||||||
statusText: (json['status_text'] as String?) ?? '',
|
statusText: (json['status_text'] as String?) ?? '',
|
||||||
rejectReason: (json['reject_reason'] as String?) ?? '',
|
rejectReason: (json['reject_reason'] as String?) ?? '',
|
||||||
|
seriesId: (json['series_id'] as num?)?.toInt() ?? 0,
|
||||||
|
totalEpisodes: (json['total_episodes'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 内容来源(片商):麻豆 / 91 ...
|
||||||
|
class VideoSource {
|
||||||
|
VideoSource({required this.sourceId, required this.name, this.code = ''});
|
||||||
|
|
||||||
|
final int sourceId;
|
||||||
|
final String name;
|
||||||
|
final String code;
|
||||||
|
|
||||||
|
factory VideoSource.fromJson(Map<String, dynamic> json) => VideoSource(
|
||||||
|
sourceId: (json['source_id'] as num?)?.toInt() ?? 0,
|
||||||
|
name: (json['name'] as String?) ?? '',
|
||||||
|
code: (json['code'] as String?) ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../core/log/action_logger.dart';
|
||||||
import '../core/network/api_client.dart';
|
import '../core/network/api_client.dart';
|
||||||
import '../core/network/api_exception.dart';
|
import '../core/network/api_exception.dart';
|
||||||
import '../core/storage/token_store.dart';
|
import '../core/storage/token_store.dart';
|
||||||
@@ -46,6 +47,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
try {
|
try {
|
||||||
final user = await UserService.fetchMe();
|
final user = await UserService.fetchMe();
|
||||||
state = AuthState(user: user, restored: true);
|
state = AuthState(user: user, restored: true);
|
||||||
|
ActionLogger.I.bindUser(user.email);
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
// 仅 Token 确认失效(1002)才清理回游客态;
|
// 仅 Token 确认失效(1002)才清理回游客态;
|
||||||
// 网络异常等情况保留 Token,下次启动/请求重试恢复
|
// 网络异常等情况保留 Token,下次启动/请求重试恢复
|
||||||
@@ -62,9 +64,12 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
/// 需补拉全量资料,否则积分显示为 0、编辑资料保存会把服务端字段清空。
|
/// 需补拉全量资料,否则积分显示为 0、编辑资料保存会把服务端字段清空。
|
||||||
Future<void> onLoginSuccess(UserModel user) async {
|
Future<void> onLoginSuccess(UserModel user) async {
|
||||||
state = AuthState(user: user, restored: true);
|
state = AuthState(user: user, restored: true);
|
||||||
|
ActionLogger.I.bindUser(user.email);
|
||||||
try {
|
try {
|
||||||
final full = await UserService.fetchMe();
|
final full = await UserService.fetchMe();
|
||||||
state = state.copyWith(user: full);
|
state = state.copyWith(user: full);
|
||||||
|
// 全量资料里的 email 字段更权威(用户可能登录后改过邮箱)
|
||||||
|
ActionLogger.I.bindUser(full.email);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// 补拉失败不影响登录态,保留简要信息
|
// 补拉失败不影响登录态,保留简要信息
|
||||||
}
|
}
|
||||||
@@ -75,11 +80,13 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
|
|
||||||
/// 退出登录:先通知服务端注销(失败忽略,本地照常清理),再清空登录态
|
/// 退出登录:先通知服务端注销(失败忽略,本地照常清理),再清空登录态
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
|
ActionLogger.I.log('logout', page: 'mine');
|
||||||
try {
|
try {
|
||||||
await AuthService.logout();
|
await AuthService.logout();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// 服务端已不可达或 Token 已失效:本地清理即可
|
// 服务端已不可达或 Token 已失效:本地清理即可
|
||||||
}
|
}
|
||||||
|
ActionLogger.I.unbind();
|
||||||
await _clearLocal();
|
await _clearLocal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ enum FeedStrategy {
|
|||||||
latest('latest', '最新'),
|
latest('latest', '最新'),
|
||||||
hot('hot', '热度'),
|
hot('hot', '热度'),
|
||||||
liked('liked', '口碑'),
|
liked('liked', '口碑'),
|
||||||
viewed('viewed', '热播');
|
viewed('viewed', '热播'),
|
||||||
|
// 搜索播放专用策略:数据由搜索页注入,不走网络 refresh;
|
||||||
|
// 顶部 Tab 不显示该项,仅作为独立播放器池存在
|
||||||
|
search('search', '搜索');
|
||||||
|
|
||||||
const FeedStrategy(this.apiValue, this.label);
|
const FeedStrategy(this.apiValue, this.label);
|
||||||
|
|
||||||
@@ -106,8 +109,11 @@ class FeedState {
|
|||||||
class FeedNotifier extends StateNotifier<FeedState> {
|
class FeedNotifier extends StateNotifier<FeedState> {
|
||||||
FeedNotifier(this._ref, this.strategy) : super(const FeedState()) {
|
FeedNotifier(this._ref, this.strategy) : super(const FeedState()) {
|
||||||
pool = FeedPlayerPool(onWatchComplete: _onWatchComplete)..init();
|
pool = FeedPlayerPool(onWatchComplete: _onWatchComplete)..init();
|
||||||
|
// search 策略由外部 injectItems 灌入数据,不走网络 refresh
|
||||||
|
if (strategy != FeedStrategy.search) {
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final Ref _ref;
|
final Ref _ref;
|
||||||
final FeedStrategy strategy;
|
final FeedStrategy strategy;
|
||||||
@@ -123,6 +129,8 @@ class FeedNotifier extends StateNotifier<FeedState> {
|
|||||||
|
|
||||||
/// 刷新(首屏 / 下拉 / 切策略)
|
/// 刷新(首屏 / 下拉 / 切策略)
|
||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
|
// search 策略不支持下拉刷新(数据由搜索页注入)
|
||||||
|
if (strategy == FeedStrategy.search) return;
|
||||||
state = state.copyWith(isRefreshing: true, loadFailed: false);
|
state = state.copyWith(isRefreshing: true, loadFailed: false);
|
||||||
try {
|
try {
|
||||||
final page = await VideoService.fetchFeed(type: strategy.apiValue);
|
final page = await VideoService.fetchFeed(type: strategy.apiValue);
|
||||||
@@ -142,6 +150,8 @@ class FeedNotifier extends StateNotifier<FeedState> {
|
|||||||
|
|
||||||
/// 加载更多(游标分页)
|
/// 加载更多(游标分页)
|
||||||
Future<void> loadMore() async {
|
Future<void> loadMore() async {
|
||||||
|
// search 策略暂不支持自动加载更多(如需要可由搜索页同步注入新数据)
|
||||||
|
if (strategy == FeedStrategy.search) return;
|
||||||
if (!state.hasMore || state.isLoadingMore) return;
|
if (!state.hasMore || state.isLoadingMore) return;
|
||||||
state = state.copyWith(isLoadingMore: true);
|
state = state.copyWith(isLoadingMore: true);
|
||||||
try {
|
try {
|
||||||
@@ -159,6 +169,16 @@ class FeedNotifier extends StateNotifier<FeedState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 外部数据注入(仅 search 策略用):把搜索结果整列表灌入并定位到点击的那一条。
|
||||||
|
/// 池子由 SearchFeedScreen 独占,注入后立即绑定播放。
|
||||||
|
Future<void> injectItems(List<VideoItem> items, int initialIndex) async {
|
||||||
|
if (strategy != FeedStrategy.search) return;
|
||||||
|
if (items.isEmpty) return;
|
||||||
|
final index = initialIndex.clamp(0, items.length - 1);
|
||||||
|
state = FeedState(items: items, currentIndex: index);
|
||||||
|
await _bindAndReport(index);
|
||||||
|
}
|
||||||
|
|
||||||
/// 从全屏播放器返回后恢复:stopAll 清空了槽位,重新绑定当前页并播放。
|
/// 从全屏播放器返回后恢复:stopAll 清空了槽位,重新绑定当前页并播放。
|
||||||
/// 不上报观看(本次进入 Feed 时已上报过,且 _bindAndReport 有按日去重)。
|
/// 不上报观看(本次进入 Feed 时已上报过,且 _bindAndReport 有按日去重)。
|
||||||
Future<void> rebindCurrent() async {
|
Future<void> rebindCurrent() async {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import '../screens/profile/invite_screen.dart';
|
|||||||
import '../screens/profile/profile_edit_screen.dart';
|
import '../screens/profile/profile_edit_screen.dart';
|
||||||
import '../screens/profile/user_profile_screen.dart';
|
import '../screens/profile/user_profile_screen.dart';
|
||||||
import '../screens/profile/video_list_screens.dart';
|
import '../screens/profile/video_list_screens.dart';
|
||||||
|
import '../screens/search/search_feed_screen.dart';
|
||||||
import '../screens/search/search_screen.dart';
|
import '../screens/search/search_screen.dart';
|
||||||
import '../screens/search/tag_videos_screen.dart';
|
import '../screens/search/tag_videos_screen.dart';
|
||||||
import '../screens/settings/settings_screen.dart';
|
import '../screens/settings/settings_screen.dart';
|
||||||
@@ -28,6 +29,9 @@ import '../screens/upload/upload_screen.dart';
|
|||||||
import '../screens/wallet/wallet_screen.dart';
|
import '../screens/wallet/wallet_screen.dart';
|
||||||
import '../services/payment_service.dart';
|
import '../services/payment_service.dart';
|
||||||
|
|
||||||
|
/// 全局路由观察者:FeedScreen 等需要感知「上层路由 pop 露出本页」的场景使用
|
||||||
|
final routeObserver = RouteObserver<PageRoute<dynamic>>();
|
||||||
|
|
||||||
/// 声明式路由(02-技术文档:go_router)
|
/// 声明式路由(02-技术文档:go_router)
|
||||||
/// /home 对游客开放(游客可浏览 Feed,观看超限 2002 时才引导登录);
|
/// /home 对游客开放(游客可浏览 Feed,观看超限 2002 时才引导登录);
|
||||||
/// 受保护页面(上传/钱包/会员等)在各自页面入口校验登录态并跳 /login。
|
/// 受保护页面(上传/钱包/会员等)在各自页面入口校验登录态并跳 /login。
|
||||||
@@ -42,6 +46,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: '/',
|
initialLocation: '/',
|
||||||
refreshListenable: refresh,
|
refreshListenable: refresh,
|
||||||
|
observers: [routeObserver],
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
final auth = ref.read(authProvider);
|
final auth = ref.read(authProvider);
|
||||||
final loggingIn = state.matchedLocation == '/login' ||
|
final loggingIn = state.matchedLocation == '/login' ||
|
||||||
@@ -58,6 +63,11 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
GoRoute(path: '/register', builder: (_, __) => const RegisterScreen()),
|
GoRoute(path: '/register', builder: (_, __) => const RegisterScreen()),
|
||||||
GoRoute(path: '/home', builder: (_, __) => const HomeShell()),
|
GoRoute(path: '/home', builder: (_, __) => const HomeShell()),
|
||||||
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
||||||
|
// 搜索结果播放(复用 Feed 池的 search 策略,支持上下滑)
|
||||||
|
GoRoute(
|
||||||
|
path: '/search/feed',
|
||||||
|
builder: (_, __) => const SearchFeedScreen(),
|
||||||
|
),
|
||||||
// 标签视频页(游客可)
|
// 标签视频页(游客可)
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/tags/:name',
|
path: '/tags/:name',
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/media/cover_cache.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../models/video_item.dart';
|
import '../../models/video_item.dart';
|
||||||
|
import '../../providers/feed_provider.dart';
|
||||||
import '../../services/upload_service.dart';
|
import '../../services/upload_service.dart';
|
||||||
import '../../services/video_service.dart';
|
import '../../services/video_service.dart';
|
||||||
|
|
||||||
@@ -19,13 +21,25 @@ class CategoryScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _CategoryScreenState extends ConsumerState<CategoryScreen> {
|
class _CategoryScreenState extends ConsumerState<CategoryScreen> {
|
||||||
List<VideoCategory> _categories = const [];
|
List<VideoCategory> _categories = const [];
|
||||||
|
List<VideoSource> _sources = const [];
|
||||||
int? _selected; // null = 全部
|
int? _selected; // null = 全部
|
||||||
|
int? _selectedSource; // null = 全部来源
|
||||||
List<VideoItem> _items = const [];
|
List<VideoItem> _items = const [];
|
||||||
String? _cursor;
|
String? _cursor;
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
bool _loadingMore = false;
|
bool _loadingMore = false;
|
||||||
bool _failed = false;
|
bool _failed = false;
|
||||||
|
|
||||||
|
/// 当前选中的分类是否为系列型(如 AI短剧):按「剧」聚合,一部剧一张卡片
|
||||||
|
bool get _seriesMode {
|
||||||
|
final id = _selected;
|
||||||
|
if (id == null) return false;
|
||||||
|
for (final c in _categories) {
|
||||||
|
if (c.categoryId == id) return c.hasSeries;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
final _scroll = ScrollController();
|
final _scroll = ScrollController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -56,13 +70,19 @@ class _CategoryScreenState extends ConsumerState<CategoryScreen> {
|
|||||||
try {
|
try {
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
UploadService.fetchCategories(),
|
UploadService.fetchCategories(),
|
||||||
VideoService.fetchFeed(type: 'hot', categoryId: _selected, limit: 10),
|
VideoService.fetchSources(),
|
||||||
|
VideoService.fetchFeed(
|
||||||
|
type: 'hot',
|
||||||
|
categoryId: _selected,
|
||||||
|
sourceId: _selectedSource,
|
||||||
|
limit: 10),
|
||||||
]);
|
]);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_categories = results[0] as List<VideoCategory>;
|
_categories = results[0] as List<VideoCategory>;
|
||||||
_items = (results[1] as FeedPage).items;
|
_sources = results[1] as List<VideoSource>;
|
||||||
_cursor = (results[1] as FeedPage).nextCursor;
|
_items = (results[2] as FeedPage).items;
|
||||||
|
_cursor = (results[2] as FeedPage).nextCursor;
|
||||||
_loading = false;
|
_loading = false;
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -80,6 +100,12 @@ class _CategoryScreenState extends ConsumerState<CategoryScreen> {
|
|||||||
await _reload();
|
await _reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _selectSource(int? id) async {
|
||||||
|
if (id == _selectedSource) return;
|
||||||
|
setState(() => _selectedSource = id);
|
||||||
|
await _reload();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _reload() async {
|
Future<void> _reload() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
@@ -87,7 +113,11 @@ class _CategoryScreenState extends ConsumerState<CategoryScreen> {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
final page = await VideoService.fetchFeed(
|
final page = await VideoService.fetchFeed(
|
||||||
type: 'hot', categoryId: _selected, limit: 10);
|
type: 'hot',
|
||||||
|
categoryId: _selected,
|
||||||
|
sourceId: _selectedSource,
|
||||||
|
group: _seriesMode ? 'series' : null,
|
||||||
|
limit: 10);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_items = page.items;
|
_items = page.items;
|
||||||
@@ -108,7 +138,12 @@ class _CategoryScreenState extends ConsumerState<CategoryScreen> {
|
|||||||
setState(() => _loadingMore = true);
|
setState(() => _loadingMore = true);
|
||||||
try {
|
try {
|
||||||
final page = await VideoService.fetchFeed(
|
final page = await VideoService.fetchFeed(
|
||||||
type: 'hot', cursor: _cursor, categoryId: _selected, limit: 10);
|
type: 'hot',
|
||||||
|
cursor: _cursor,
|
||||||
|
categoryId: _selected,
|
||||||
|
sourceId: _selectedSource,
|
||||||
|
group: _seriesMode ? 'series' : null,
|
||||||
|
limit: 10);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_items = [..._items, ...page.items];
|
_items = [..._items, ...page.items];
|
||||||
@@ -157,6 +192,19 @@ class _CategoryScreenState extends ConsumerState<CategoryScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// 来源筛选条(麻豆 / 91 ...,多于一个来源时才显示)
|
||||||
|
if (_sources.length > 1)
|
||||||
|
SizedBox(
|
||||||
|
height: 40,
|
||||||
|
child: ListView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
children: [
|
||||||
|
_sourceChip('全部来源', null),
|
||||||
|
for (final s in _sources) _sourceChip(s.name, s.sourceId),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
Expanded(child: _buildBody()),
|
Expanded(child: _buildBody()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -180,6 +228,26 @@ class _CategoryScreenState extends ConsumerState<CategoryScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _sourceChip(String label, int? id) {
|
||||||
|
final active = id == _selectedSource;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8, top: 4),
|
||||||
|
child: FilterChip(
|
||||||
|
label: Text(label, style: const TextStyle(fontSize: 12)),
|
||||||
|
selected: active,
|
||||||
|
onSelected: (_) => _selectSource(id),
|
||||||
|
selectedColor: AppColors.primary.withValues(alpha: 0.18),
|
||||||
|
backgroundColor: AppColors.surface,
|
||||||
|
side: BorderSide(
|
||||||
|
color: active ? AppColors.primary : AppColors.textWeak.withValues(alpha: 0.3)),
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: active ? AppColors.primary : AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildBody() {
|
Widget _buildBody() {
|
||||||
if (_loading && _items.isEmpty) {
|
if (_loading && _items.isEmpty) {
|
||||||
return const Center(
|
return const Center(
|
||||||
@@ -229,15 +297,21 @@ class _CategoryScreenState extends ConsumerState<CategoryScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CategoryCard extends StatelessWidget {
|
class _CategoryCard extends ConsumerWidget {
|
||||||
const _CategoryCard({required this.item});
|
const _CategoryCard({required this.item});
|
||||||
|
|
||||||
final VideoItem item;
|
final VideoItem item;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => context.push('/search/play', extra: item),
|
// 进入播放页前停掉首页 Feed 的播放器池,返回时再恢复,避免「双重声音」
|
||||||
|
onTap: () {
|
||||||
|
// 挂起首页池(保存断点 + 停所有槽位),恢复由 FeedScreen.didPopNext 统一处理
|
||||||
|
final strategy = ref.read(activeFeedStrategyProvider);
|
||||||
|
ref.read(feedProvider(strategy).notifier).pool.suspend();
|
||||||
|
context.push('/search/play', extra: item);
|
||||||
|
},
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -248,6 +322,7 @@ class _CategoryCard extends StatelessWidget {
|
|||||||
child: item.coverUrl.isNotEmpty
|
child: item.coverUrl.isNotEmpty
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: item.coverUrl,
|
imageUrl: item.coverUrl,
|
||||||
|
cacheManager: CoverCacheManager.I,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorWidget: (_, __, ___) => const Icon(
|
errorWidget: (_, __, ___) => const Icon(
|
||||||
Icons.play_circle_outline,
|
Icons.play_circle_outline,
|
||||||
@@ -282,6 +357,45 @@ class _CategoryCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// 来源角标(麻豆 / 91 ...)
|
||||||
|
if (item.sourceName.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
left: 6,
|
||||||
|
top: 6,
|
||||||
|
child: Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primary.withValues(alpha: 0.85),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
item.sourceName,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 系列角标「全N集」:仅系列型分类(AI短剧)聚合卡片显示
|
||||||
|
if (item.totalEpisodes > 0)
|
||||||
|
Positioned(
|
||||||
|
left: 6,
|
||||||
|
bottom: 28,
|
||||||
|
child: Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.65),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'全${item.totalEpisodes}集',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
// 标题
|
// 标题
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/media/cover_cache.dart';
|
||||||
import '../../core/media/download_manager.dart';
|
import '../../core/media/download_manager.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../models/video_item.dart';
|
import '../../models/video_item.dart';
|
||||||
@@ -107,7 +108,9 @@ class DownloadsScreen extends ConsumerWidget {
|
|||||||
width: 72, height: 48,
|
width: 72, height: 48,
|
||||||
child: item.coverUrl.isNotEmpty
|
child: item.coverUrl.isNotEmpty
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: item.coverUrl, fit: BoxFit.cover)
|
imageUrl: item.coverUrl,
|
||||||
|
cacheManager: CoverCacheManager.I,
|
||||||
|
fit: BoxFit.cover)
|
||||||
: Container(
|
: Container(
|
||||||
color: Colors.grey[800],
|
color: Colors.grey[800],
|
||||||
child: const Icon(Icons.play_circle_outline,
|
child: const Icon(Icons.play_circle_outline,
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/log/action_logger.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../models/video_item.dart';
|
import '../../models/video_item.dart';
|
||||||
import '../../providers/feed_provider.dart';
|
import '../../providers/feed_provider.dart';
|
||||||
|
import '../../router.dart';
|
||||||
import 'widgets/comment_sheet.dart';
|
import 'widgets/comment_sheet.dart';
|
||||||
import 'widgets/feed_overlays.dart';
|
import 'widgets/feed_overlays.dart';
|
||||||
import 'widgets/feed_video_page.dart';
|
import 'widgets/feed_video_page.dart';
|
||||||
@@ -23,7 +25,7 @@ class FeedScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _FeedScreenState extends ConsumerState<FeedScreen>
|
class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||||
with WidgetsBindingObserver {
|
with WidgetsBindingObserver, RouteAware {
|
||||||
FeedStrategy _strategy = FeedStrategy.recommend;
|
FeedStrategy _strategy = FeedStrategy.recommend;
|
||||||
final PageController _pageController = PageController();
|
final PageController _pageController = PageController();
|
||||||
StreamSubscription<FeedEvent>? _eventSub;
|
StreamSubscription<FeedEvent>? _eventSub;
|
||||||
@@ -46,9 +48,36 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
// 注册路由感知:当上层路由 pop 露出 FeedScreen 时(didPopNext),
|
||||||
|
// 若首页 Tab 仍选中,恢复播放——这是处理「搜索页/分类页返回后无声」的唯一可靠入口
|
||||||
|
final route = ModalRoute.of(context);
|
||||||
|
if (route is PageRoute) {
|
||||||
|
routeObserver.subscribe(this, route);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上层路由 pop 后本路由重新可见:恢复播放
|
||||||
|
@override
|
||||||
|
void didPopNext() {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!ref.read(feedTabVisibleProvider)) return;
|
||||||
|
ActionLogger.I.log('feed_did_pop_next', page: '/home', detail: {
|
||||||
|
'strategy': _strategy.name,
|
||||||
|
});
|
||||||
|
final notifier = ref.read(feedProvider(_strategy).notifier);
|
||||||
|
unawaited(() async {
|
||||||
|
await notifier.pool.resume();
|
||||||
|
await notifier.rebindCurrent();
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
routeObserver.unsubscribe(this);
|
||||||
_eventSub?.cancel();
|
_eventSub?.cancel();
|
||||||
// 离开 Feed 路由(如跳登录页):彻底停止播放(断音+断缓存下载)。
|
// 离开 Feed 路由(如跳登录页):彻底停止播放(断音+断缓存下载)。
|
||||||
// feedProvider 是全局常驻,不处理则播放器池在后台继续出声
|
// feedProvider 是全局常驻,不处理则播放器池在后台继续出声
|
||||||
@@ -101,7 +130,13 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
|||||||
.read(feedProvider(_strategy).notifier)
|
.read(feedProvider(_strategy).notifier)
|
||||||
.events
|
.events
|
||||||
.listen(_handleEvent);
|
.listen(_handleEvent);
|
||||||
|
// 保留每个策略的播放进度(currentIndex 由 provider 维护):切回时跳到上次位置而非归零
|
||||||
|
final targetIndex = ref.read(feedProvider(_strategy)).currentIndex;
|
||||||
|
if (_pageController.hasClients && targetIndex > 0) {
|
||||||
|
_pageController.jumpToPage(targetIndex);
|
||||||
|
} else if (_pageController.hasClients) {
|
||||||
_pageController.jumpToPage(0);
|
_pageController.jumpToPage(0);
|
||||||
|
}
|
||||||
// jumpToPage 后 resume + rebindCurrent:suspend 已 stop 槽位,仅 play
|
// jumpToPage 后 resume + rebindCurrent:suspend 已 stop 槽位,仅 play
|
||||||
// 播不起来,必须 rebind 重新 open(有断点记录则 seek 续播)
|
// 播不起来,必须 rebind 重新 open(有断点记录则 seek 续播)
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -186,7 +221,9 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
for (final s in FeedStrategy.values) ...[
|
for (final s in FeedStrategy.values.where(
|
||||||
|
(s) => s != FeedStrategy.search,
|
||||||
|
)) ...[
|
||||||
_StrategyTab(
|
_StrategyTab(
|
||||||
label: s.label,
|
label: s.label,
|
||||||
selected: s == _strategy,
|
selected: s == _strategy,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../core/media/cover_cache.dart';
|
||||||
import '../../../core/network/api_exception.dart';
|
import '../../../core/network/api_exception.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../models/comment_item.dart';
|
import '../../../models/comment_item.dart';
|
||||||
@@ -423,7 +424,7 @@ class CommentTile extends StatelessWidget {
|
|||||||
radius: compact ? 12 : 16,
|
radius: compact ? 12 : 16,
|
||||||
backgroundColor: AppColors.background,
|
backgroundColor: AppColors.background,
|
||||||
backgroundImage: comment.avatarUrl.isNotEmpty
|
backgroundImage: comment.avatarUrl.isNotEmpty
|
||||||
? CachedNetworkImageProvider(comment.avatarUrl)
|
? CachedNetworkImageProvider(comment.avatarUrl, cacheManager: CoverCacheManager.I)
|
||||||
: null,
|
: null,
|
||||||
child: comment.avatarUrl.isEmpty
|
child: comment.avatarUrl.isEmpty
|
||||||
? Text(
|
? Text(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
import '../../../core/media/download_manager.dart';
|
import '../../../core/media/download_manager.dart';
|
||||||
|
import '../../../core/network/api_exception.dart';
|
||||||
import '../../../core/player/feed_player_pool.dart';
|
import '../../../core/player/feed_player_pool.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../models/video_item.dart';
|
import '../../../models/video_item.dart';
|
||||||
@@ -54,6 +55,15 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
|
|
||||||
static const _rates = [1.0, 1.5, 2.0, 3.0];
|
static const _rates = [1.0, 1.5, 2.0, 3.0];
|
||||||
|
|
||||||
|
/// 站点管理员邮箱(长按弹窗显示「下架视频」)
|
||||||
|
static const String _kSiteAdminEmail = '256kb@gmail.com';
|
||||||
|
|
||||||
|
bool get _isSiteAdmin {
|
||||||
|
final u = ref.read(authProvider).user;
|
||||||
|
return u != null &&
|
||||||
|
(u.email ?? '').toLowerCase() == _kSiteAdminEmail;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -70,6 +80,8 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
_seriesData != null && (_seriesData!['has_series'] as bool? ?? false);
|
_seriesData != null && (_seriesData!['has_series'] as bool? ?? false);
|
||||||
|
|
||||||
String get _seriesTitle => _seriesData?['series']?['title'] as String? ?? '';
|
String get _seriesTitle => _seriesData?['series']?['title'] as String? ?? '';
|
||||||
|
String get _seriesSourceName =>
|
||||||
|
_seriesData?['series']?['source_name'] as String? ?? '';
|
||||||
|
|
||||||
List<dynamic> get _episodes => (_seriesData?['episodes'] as List?) ?? const [];
|
List<dynamic> get _episodes => (_seriesData?['episodes'] as List?) ?? const [];
|
||||||
|
|
||||||
@@ -197,6 +209,21 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// 下架视频:仅站点管理员可见(按 email 判定)
|
||||||
|
if (_isSiteAdmin)
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.remove_circle_outline,
|
||||||
|
color: Colors.redAccent),
|
||||||
|
title: const Text('下架视频',
|
||||||
|
style: TextStyle(color: Colors.redAccent)),
|
||||||
|
subtitle: const Text('从首页/搜索/分类/系列移除该视频'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
_confirmOffline();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
// 下载(已下载/下载中置灰)
|
// 下载(已下载/下载中置灰)
|
||||||
Builder(builder: (_) {
|
Builder(builder: (_) {
|
||||||
final dm = ref.read(downloadManagerProvider);
|
final dm = ref.read(downloadManagerProvider);
|
||||||
@@ -237,6 +264,44 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
await Share.share(text, subject: widget.item.title);
|
await Share.share(text, subject: widget.item.title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 下架(管理员) ----------
|
||||||
|
|
||||||
|
Future<void> _confirmOffline() async {
|
||||||
|
final targetId = widget.item.videoId;
|
||||||
|
final title = widget.item.title;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('下架该视频?'),
|
||||||
|
content: Text(
|
||||||
|
'《$title》\n\n下架后将立刻从首页、搜索、分类、系列列表中移除,可在后台恢复。',
|
||||||
|
maxLines: 5, overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.redAccent),
|
||||||
|
child: const Text('下架'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await VideoService.offlineVideo(targetId);
|
||||||
|
if (!mounted) return;
|
||||||
|
showTopToast(context, '已下架');
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
showTopToast(context, e is ApiException ? e.message : '下架失败,请重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _showSeriesSheet() {
|
void _showSeriesSheet() {
|
||||||
showModalBottomSheet<void>(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -278,6 +343,26 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
maxLines: 1, overflow: TextOverflow.ellipsis,
|
maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (_seriesSourceName.isNotEmpty) ...[
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 5, vertical: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primary.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_seriesSourceName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: AppColors.primary,
|
||||||
|
fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
],
|
||||||
Text(
|
Text(
|
||||||
'共 ${_episodes.length} 集',
|
'共 ${_episodes.length} 集',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -285,6 +370,8 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../core/media/cover_cache.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../models/video_item.dart';
|
import '../../../models/video_item.dart';
|
||||||
import '../../../providers/auth_provider.dart';
|
import '../../../providers/auth_provider.dart';
|
||||||
@@ -91,7 +92,7 @@ class _AuthorAvatar extends StatelessWidget {
|
|||||||
radius: 24,
|
radius: 24,
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
backgroundImage: item.author.avatarUrl.isNotEmpty
|
backgroundImage: item.author.avatarUrl.isNotEmpty
|
||||||
? CachedNetworkImageProvider(item.author.avatarUrl)
|
? CachedNetworkImageProvider(item.author.avatarUrl, cacheManager: CoverCacheManager.I)
|
||||||
: null,
|
: null,
|
||||||
child: item.author.avatarUrl.isEmpty
|
child: item.author.avatarUrl.isEmpty
|
||||||
? Text(
|
? Text(
|
||||||
|
|||||||
@@ -32,9 +32,14 @@ class _VideoInfoSectionState extends State<VideoInfoSection> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// 昵称
|
// 昵称 + 来源徽标
|
||||||
Text(
|
Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
'@${item.author.nickname}',
|
'@${item.author.nickname}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@@ -42,6 +47,27 @@ class _VideoInfoSectionState extends State<VideoInfoSection> {
|
|||||||
shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
|
shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
if (item.sourceName.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primary.withValues(alpha: 0.85),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
item.sourceName,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// 标题 + 时长 + #标签(点击标签跳转标签视频页)
|
// 标题 + 时长 + #标签(点击标签跳转标签视频页)
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
|||||||
static const _rates = [1.0, 1.5, 2.0, 3.0];
|
static const _rates = [1.0, 1.5, 2.0, 3.0];
|
||||||
double _rate = 1.0;
|
double _rate = 1.0;
|
||||||
|
|
||||||
|
// 清屏:隐藏互动栏/信息区/顶部栏/底部渐变,仅保留播放画面与进度条。
|
||||||
|
// 恢复只能从长按菜单进入,避免单击误触把界面弹回来
|
||||||
|
bool _cleanMode = false;
|
||||||
|
|
||||||
bool get _hasSeries =>
|
bool get _hasSeries =>
|
||||||
_seriesData != null && (_seriesData!['has_series'] as bool? ?? false);
|
_seriesData != null && (_seriesData!['has_series'] as bool? ?? false);
|
||||||
|
|
||||||
@@ -77,6 +81,8 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
|||||||
|
|
||||||
String get _seriesTitle =>
|
String get _seriesTitle =>
|
||||||
_seriesData?['series']?['title'] as String? ?? '';
|
_seriesData?['series']?['title'] as String? ?? '';
|
||||||
|
String get _seriesSourceName =>
|
||||||
|
_seriesData?['series']?['source_name'] as String? ?? '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -310,6 +316,15 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 站点管理员邮箱(长按弹窗显示「下架视频」)
|
||||||
|
static const String _kSiteAdminEmail = '256kb@gmail.com';
|
||||||
|
|
||||||
|
bool get _isSiteAdmin {
|
||||||
|
final u = ref.read(authProvider).user;
|
||||||
|
return u != null &&
|
||||||
|
(u.email ?? '').toLowerCase() == _kSiteAdminEmail;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 长按 ----------
|
// ---------- 长按 ----------
|
||||||
|
|
||||||
void _onLongPress() {
|
void _onLongPress() {
|
||||||
@@ -403,6 +418,40 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// 清屏 / 恢复清屏:隐藏右侧互动栏与底部信息,只留播放画面与进度条。
|
||||||
|
// 恢复只能从长按菜单进入,避免单击误触把界面弹回来
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: Icon(_cleanMode
|
||||||
|
? Icons.fullscreen
|
||||||
|
: Icons.fullscreen_exit),
|
||||||
|
title: Text(_cleanMode ? '恢复清屏' : '清屏'),
|
||||||
|
subtitle: Text(_cleanMode
|
||||||
|
? '重新显示点赞、评论、收藏、作者与标题'
|
||||||
|
: '隐藏点赞、评论、收藏、作者与标题'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
setState(() => _cleanMode = !_cleanMode);
|
||||||
|
showTopToast(
|
||||||
|
context, _cleanMode ? '已清屏,长按视频可恢复' : '已恢复显示');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// 下架视频:仅站点管理员可见(按 email 判定)
|
||||||
|
if (_isSiteAdmin)
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.remove_circle_outline,
|
||||||
|
color: Colors.redAccent),
|
||||||
|
title: const Text('下架视频',
|
||||||
|
style: TextStyle(color: Colors.redAccent)),
|
||||||
|
subtitle: const Text('从首页/搜索/分类/系列移除该视频'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
_confirmOffline();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
// 下载(已下载/下载中置灰;本地已下载视频不再重复下载)
|
// 下载(已下载/下载中置灰;本地已下载视频不再重复下载)
|
||||||
Builder(builder: (_) {
|
Builder(builder: (_) {
|
||||||
final dm = ref.read(downloadManagerProvider);
|
final dm = ref.read(downloadManagerProvider);
|
||||||
@@ -439,8 +488,47 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 进度条 ----------
|
// ---------- 下架(管理员) ----------
|
||||||
|
|
||||||
|
Future<void> _confirmOffline() async {
|
||||||
|
final targetId = _currentItem.videoId;
|
||||||
|
final title = _currentItem.title;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('下架该视频?'),
|
||||||
|
content: Text(
|
||||||
|
'《$title》\n\n下架后将立刻从首页、搜索、分类、系列列表中移除,可在后台恢复。',
|
||||||
|
maxLines: 5, overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.redAccent),
|
||||||
|
child: const Text('下架'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await VideoService.offlineVideo(targetId);
|
||||||
|
if (!mounted) return;
|
||||||
|
showTopToast(context, '已下架');
|
||||||
|
// 播放页停留没意义:返回上一页(触发外层列表刷新由列表自身负责)
|
||||||
|
context.pop();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
showTopToast(context, e is ApiException ? e.message : '下架失败,请重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 进度条 ----------
|
||||||
/// 有效时长:流上报优先,异常(如被当直播 duration=0)回退到 DB 时长
|
/// 有效时长:流上报优先,异常(如被当直播 duration=0)回退到 DB 时长
|
||||||
Duration get _effectiveDuration => _duration.inMilliseconds > 0
|
Duration get _effectiveDuration => _duration.inMilliseconds > 0
|
||||||
? _duration
|
? _duration
|
||||||
@@ -537,11 +625,14 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 底部渐变(提升文字可读性)
|
// 底部渐变(提升文字可读性):清屏时一并淡出
|
||||||
const Positioned(
|
Positioned(
|
||||||
left: 0, right: 0, bottom: 0, height: 200,
|
left: 0, right: 0, bottom: 0, height: 200,
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
child: DecoratedBox(
|
child: AnimatedOpacity(
|
||||||
|
opacity: _cleanMode ? 0 : 1,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: const DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
@@ -552,9 +643,12 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// ---------- 顶部:返回 + 倍速 ----------
|
// ---------- 顶部:返回 + 倍速 ----------
|
||||||
SafeArea(
|
_HidableOverlay(
|
||||||
|
hidden: _cleanMode,
|
||||||
|
child: SafeArea(
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
@@ -583,10 +677,13 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// ---------- 右侧互动栏 ----------
|
// ---------- 右侧互动栏 ----------
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 8, bottom: 96,
|
right: 8, bottom: 96,
|
||||||
|
child: _HidableOverlay(
|
||||||
|
hidden: _cleanMode,
|
||||||
child: InteractionBar(
|
child: InteractionBar(
|
||||||
item: interactionItem,
|
item: interactionItem,
|
||||||
onLike: (_) => _onLike(),
|
onLike: (_) => _onLike(),
|
||||||
@@ -594,10 +691,13 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
|||||||
onComment: (_) => _openComments(),
|
onComment: (_) => _openComments(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// ---------- 左下信息区 ----------
|
// ---------- 左下信息区 ----------
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 12, right: 88, bottom: 24,
|
left: 12, right: 88, bottom: 24,
|
||||||
|
child: _HidableOverlay(
|
||||||
|
hidden: _cleanMode,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -628,6 +728,24 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
|||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (_seriesSourceName.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 5, vertical: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primary,
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_seriesSourceName,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(width: 2),
|
const SizedBox(width: 2),
|
||||||
const Icon(Icons.chevron_right,
|
const Icon(Icons.chevron_right,
|
||||||
color: Colors.white, size: 14),
|
color: Colors.white, size: 14),
|
||||||
@@ -640,6 +758,7 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// ---------- 进度条:抖音风格一条细线 + 时间刻度 ----------
|
// ---------- 进度条:抖音风格一条细线 + 时间刻度 ----------
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -761,6 +880,27 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
|||||||
maxLines: 1, overflow: TextOverflow.ellipsis,
|
maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (_seriesSourceName.isNotEmpty) ...[
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 5, vertical: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
AppColors.primary.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_seriesSourceName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: AppColors.primary,
|
||||||
|
fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
],
|
||||||
Text(
|
Text(
|
||||||
'共 ${_episodes.length} 集',
|
'共 ${_episodes.length} 集',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -768,6 +908,8 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -992,3 +1134,24 @@ class _ProgressPainter extends CustomPainter {
|
|||||||
bool shouldRepaint(covariant _ProgressPainter oldDelegate) =>
|
bool shouldRepaint(covariant _ProgressPainter oldDelegate) =>
|
||||||
oldDelegate.value != value || oldDelegate.dragging != dragging;
|
oldDelegate.value != value || oldDelegate.dragging != dragging;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 可隐藏浮层:淡出 + 禁用点击。隐藏时仍占位但不拦截手势,
|
||||||
|
/// 保证点击/长按能穿透到下层的手势层
|
||||||
|
class _HidableOverlay extends StatelessWidget {
|
||||||
|
const _HidableOverlay({required this.hidden, required this.child});
|
||||||
|
|
||||||
|
final bool hidden;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IgnorePointer(
|
||||||
|
ignoring: hidden,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: hidden ? 0 : 1,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/media/cover_cache.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../providers/notification_provider.dart';
|
import '../../providers/notification_provider.dart';
|
||||||
@@ -47,7 +48,7 @@ class _MineScreenState extends ConsumerState<MineScreen> {
|
|||||||
radius: 32,
|
radius: 32,
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
backgroundImage: user.avatarUrl.isNotEmpty
|
backgroundImage: user.avatarUrl.isNotEmpty
|
||||||
? CachedNetworkImageProvider(user.avatarUrl)
|
? CachedNetworkImageProvider(user.avatarUrl, cacheManager: CoverCacheManager.I)
|
||||||
: null,
|
: null,
|
||||||
child: user.avatarUrl.isEmpty
|
child: user.avatarUrl.isEmpty
|
||||||
? Text(
|
? Text(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
|
import '../../core/media/cover_cache.dart';
|
||||||
import '../../core/network/api_exception.dart';
|
import '../../core/network/api_exception.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
@@ -150,7 +151,7 @@ class _ProfileEditScreenState extends ConsumerState<ProfileEditScreen> {
|
|||||||
radius: 44,
|
radius: 44,
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
backgroundImage: _avatarUrl.isNotEmpty
|
backgroundImage: _avatarUrl.isNotEmpty
|
||||||
? CachedNetworkImageProvider(_avatarUrl)
|
? CachedNetworkImageProvider(_avatarUrl, cacheManager: CoverCacheManager.I)
|
||||||
: null,
|
: null,
|
||||||
child: _avatarUrl.isEmpty
|
child: _avatarUrl.isEmpty
|
||||||
? const Icon(Icons.person_outline,
|
? const Icon(Icons.person_outline,
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/media/cover_cache.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../models/video_item.dart';
|
import '../../models/video_item.dart';
|
||||||
|
import '../../providers/feed_provider.dart';
|
||||||
import '../../providers/profile_provider.dart';
|
import '../../providers/profile_provider.dart';
|
||||||
|
|
||||||
/// 他人公开主页(《03-接口文档》4.7/4.8,游客可看):
|
/// 他人公开主页(《03-接口文档》4.7/4.8,游客可看):
|
||||||
@@ -88,7 +90,7 @@ class _ProfileHeader extends StatelessWidget {
|
|||||||
radius: 40,
|
radius: 40,
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
backgroundImage: profile.avatarUrl.isNotEmpty
|
backgroundImage: profile.avatarUrl.isNotEmpty
|
||||||
? CachedNetworkImageProvider(profile.avatarUrl)
|
? CachedNetworkImageProvider(profile.avatarUrl, cacheManager: CoverCacheManager.I)
|
||||||
: null,
|
: null,
|
||||||
child: profile.avatarUrl.isEmpty
|
child: profile.avatarUrl.isEmpty
|
||||||
? Text(
|
? Text(
|
||||||
@@ -155,15 +157,20 @@ class _Stat extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _WorkCard extends StatelessWidget {
|
class _WorkCard extends ConsumerWidget {
|
||||||
const _WorkCard({required this.item});
|
const _WorkCard({required this.item});
|
||||||
|
|
||||||
final VideoItem item;
|
final VideoItem item;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => context.push('/search/play', extra: item),
|
// 挂起首页池(保存断点 + 停所有槽位),恢复由 FeedScreen.didPopNext 统一处理
|
||||||
|
onTap: () {
|
||||||
|
final strategy = ref.read(activeFeedStrategyProvider);
|
||||||
|
ref.read(feedProvider(strategy).notifier).pool.suspend();
|
||||||
|
context.push('/search/play', extra: item);
|
||||||
|
},
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -174,6 +181,7 @@ class _WorkCard extends StatelessWidget {
|
|||||||
child: item.coverUrl.isNotEmpty
|
child: item.coverUrl.isNotEmpty
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: item.coverUrl,
|
imageUrl: item.coverUrl,
|
||||||
|
cacheManager: CoverCacheManager.I,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorWidget: (_, __, ___) => const Icon(
|
errorWidget: (_, __, ___) => const Icon(
|
||||||
Icons.play_circle_outline,
|
Icons.play_circle_outline,
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/media/cover_cache.dart';
|
||||||
import '../../core/network/api_exception.dart';
|
import '../../core/network/api_exception.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../models/user_profile.dart';
|
import '../../models/user_profile.dart';
|
||||||
import '../../models/video_item.dart';
|
import '../../models/video_item.dart';
|
||||||
|
import '../../providers/feed_provider.dart';
|
||||||
import '../../providers/profile_provider.dart';
|
import '../../providers/profile_provider.dart';
|
||||||
import '../../services/user_service.dart';
|
import '../../services/user_service.dart';
|
||||||
import '../../services/video_service.dart';
|
import '../../services/video_service.dart';
|
||||||
@@ -77,8 +79,11 @@ class _GridCard extends ConsumerWidget {
|
|||||||
final bool editable;
|
final bool editable;
|
||||||
|
|
||||||
/// 我的作品卡片点击:仅已发布可播放,其余状态提示原因
|
/// 我的作品卡片点击:仅已发布可播放,其余状态提示原因
|
||||||
void _onTap(BuildContext context) {
|
void _onTap(BuildContext context, WidgetRef ref) {
|
||||||
if (item.status == -1 || item.status == 2) {
|
if (item.status == -1 || item.status == 2) {
|
||||||
|
// 挂起首页池(保存断点 + 停所有槽位),恢复由 FeedScreen.didPopNext 统一处理
|
||||||
|
final strategy = ref.read(activeFeedStrategyProvider);
|
||||||
|
ref.read(feedProvider(strategy).notifier).pool.suspend();
|
||||||
context.push('/search/play', extra: item);
|
context.push('/search/play', extra: item);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -158,7 +163,7 @@ class _GridCard extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _onTap(context),
|
onTap: () => _onTap(context, ref),
|
||||||
onLongPress: editable ? () => _showManageSheet(context, ref) : null,
|
onLongPress: editable ? () => _showManageSheet(context, ref) : null,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -170,6 +175,7 @@ class _GridCard extends ConsumerWidget {
|
|||||||
child: item.coverUrl.isNotEmpty
|
child: item.coverUrl.isNotEmpty
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: item.coverUrl,
|
imageUrl: item.coverUrl,
|
||||||
|
cacheManager: CoverCacheManager.I,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorWidget: (_, __, ___) => const Icon(
|
errorWidget: (_, __, ___) => const Icon(
|
||||||
Icons.play_circle_outline,
|
Icons.play_circle_outline,
|
||||||
@@ -265,15 +271,20 @@ class HistoryScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HistoryTile extends StatelessWidget {
|
class _HistoryTile extends ConsumerWidget {
|
||||||
const _HistoryTile({required this.item});
|
const _HistoryTile({required this.item});
|
||||||
|
|
||||||
final HistoryItem item;
|
final HistoryItem item;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
onTap: () => context.push('/search/play', extra: item.video),
|
// 挂起首页池(保存断点 + 停所有槽位),恢复由 FeedScreen.didPopNext 统一处理
|
||||||
|
onTap: () {
|
||||||
|
final strategy = ref.read(activeFeedStrategyProvider);
|
||||||
|
ref.read(feedProvider(strategy).notifier).pool.suspend();
|
||||||
|
context.push('/search/play', extra: item.video);
|
||||||
|
},
|
||||||
leading: ClipRRect(
|
leading: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -283,6 +294,7 @@ class _HistoryTile extends StatelessWidget {
|
|||||||
child: item.video.coverUrl.isNotEmpty
|
child: item.video.coverUrl.isNotEmpty
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: item.video.coverUrl,
|
imageUrl: item.video.coverUrl,
|
||||||
|
cacheManager: CoverCacheManager.I,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorWidget: (_, __, ___) =>
|
errorWidget: (_, __, ___) =>
|
||||||
const Icon(Icons.play_circle_outline, size: 24),
|
const Icon(Icons.play_circle_outline, size: 24),
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/log/action_logger.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../models/video_item.dart';
|
||||||
|
import '../../providers/feed_provider.dart';
|
||||||
|
import '../../providers/search_provider.dart';
|
||||||
|
import '../../router.dart';
|
||||||
|
import '../feed/widgets/comment_sheet.dart';
|
||||||
|
import '../feed/widgets/feed_overlays.dart';
|
||||||
|
import '../feed/widgets/feed_video_page.dart';
|
||||||
|
|
||||||
|
/// 搜索结果播放页:复用 Feed 播放器池的「第 6 个策略」,
|
||||||
|
/// 支持上下滑切上/下一个视频、双击点赞、长按倍速/分享、评论等完整体验。
|
||||||
|
/// 数据由搜索页 injectItems 注入,不走网络刷新;触底加载更多由搜索 provider 完成后再注入。
|
||||||
|
class SearchFeedScreen extends ConsumerStatefulWidget {
|
||||||
|
const SearchFeedScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<SearchFeedScreen> createState() => _SearchFeedScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SearchFeedScreenState extends ConsumerState<SearchFeedScreen>
|
||||||
|
with WidgetsBindingObserver, RouteAware {
|
||||||
|
static const _strategy = FeedStrategy.search;
|
||||||
|
final PageController _pageController = PageController();
|
||||||
|
StreamSubscription<FeedEvent>? _eventSub;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
ActionLogger.I.log('route_enter', page: '/search/feed');
|
||||||
|
_eventSub = ref
|
||||||
|
.read(feedProvider(_strategy).notifier)
|
||||||
|
.events
|
||||||
|
.listen(_handleEvent);
|
||||||
|
// 注入完成后跳到目标 index(数据由搜索页在 push 前 injectItems 灌入)
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final idx = ref.read(feedProvider(_strategy)).currentIndex;
|
||||||
|
if (idx > 0 && _pageController.hasClients) {
|
||||||
|
_pageController.jumpToPage(idx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
final route = ModalRoute.of(context);
|
||||||
|
if (route is PageRoute) routeObserver.subscribe(this, route);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本页之上又 push 了新路由(评论/分享/登录等):停掉搜索池,避免返回路径复杂时残留声音
|
||||||
|
@override
|
||||||
|
void didPushNext() {
|
||||||
|
ref.read(feedProvider(_strategy).notifier).pool.stopAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上层路由 pop 回本页:恢复播放
|
||||||
|
@override
|
||||||
|
void didPopNext() {
|
||||||
|
if (!mounted) return;
|
||||||
|
final notifier = ref.read(feedProvider(_strategy).notifier);
|
||||||
|
unawaited(() async {
|
||||||
|
await notifier.pool.resume();
|
||||||
|
await notifier.rebindCurrent();
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
ActionLogger.I.log('route_exit', page: '/search/feed');
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
routeObserver.unsubscribe(this);
|
||||||
|
_eventSub?.cancel();
|
||||||
|
// 离开搜索播放:直接停掉搜索策略的池(断音 + 断缓存下载)。
|
||||||
|
ref.read(feedProvider(_strategy).notifier).pool.stopAll();
|
||||||
|
_pageController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App 退后台挂起、回前台从断点续播
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
final notifier = ref.read(feedProvider(_strategy).notifier);
|
||||||
|
if (state == AppLifecycleState.paused) {
|
||||||
|
notifier.pool.suspend();
|
||||||
|
} else if (state == AppLifecycleState.inactive) {
|
||||||
|
notifier.pool.pauseCurrent();
|
||||||
|
} else if (state == AppLifecycleState.resumed) {
|
||||||
|
// 本页不可见时(压在登录页/评论页下)不恢复播放
|
||||||
|
if (ModalRoute.of(context)?.isCurrent ?? false) {
|
||||||
|
unawaited(() async {
|
||||||
|
await notifier.pool.resume();
|
||||||
|
await notifier.rebindCurrent();
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleEvent(FeedEvent event) {
|
||||||
|
if (!mounted) return;
|
||||||
|
switch (event) {
|
||||||
|
case FeedToastEvent(:final message):
|
||||||
|
showTopToast(context, message);
|
||||||
|
case FeedGuestLimitEvent():
|
||||||
|
showGuestLimitDialog(context);
|
||||||
|
case FeedPointsInsufficientEvent(:final points):
|
||||||
|
showPointsInsufficientDialog(context, points);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开评论弹层:打开期间暂停当前播放,关闭后恢复
|
||||||
|
Future<void> _openComments(VideoItem item) async {
|
||||||
|
final notifier = ref.read(feedProvider(_strategy).notifier);
|
||||||
|
notifier.pool.pauseCurrent();
|
||||||
|
await showCommentSheet(
|
||||||
|
context,
|
||||||
|
videoId: item.videoId,
|
||||||
|
onCountChanged: (delta) =>
|
||||||
|
notifier.adjustCommentCount(item.videoId, delta),
|
||||||
|
);
|
||||||
|
if (mounted) notifier.pool.playCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final state = ref.watch(feedProvider(_strategy));
|
||||||
|
final notifier = ref.read(feedProvider(_strategy).notifier);
|
||||||
|
final keyword = ref.watch(searchProvider).keyword;
|
||||||
|
|
||||||
|
return PopScope(
|
||||||
|
// 系统返回键/手势也要先停搜索池再放行 pop
|
||||||
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
|
if (didPop) {
|
||||||
|
ref.read(feedProvider(_strategy).notifier).pool.stopAll();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
// 内容区
|
||||||
|
if (state.items.isEmpty)
|
||||||
|
const Center(
|
||||||
|
child: Text('暂无视频', style: TextStyle(color: AppColors.textSecondary)),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: (n) {
|
||||||
|
// 手指开始拖动即静音,避免划动过程中上一个视频仍在出声
|
||||||
|
if (n is ScrollStartNotification && n.dragDetails != null) {
|
||||||
|
notifier.pool.pauseCurrent();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: PageView.builder(
|
||||||
|
controller: _pageController,
|
||||||
|
scrollDirection: Axis.vertical,
|
||||||
|
itemCount: state.items.length,
|
||||||
|
onPageChanged: notifier.onPageChanged,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = state.items[index];
|
||||||
|
return FeedVideoPage(
|
||||||
|
key: ValueKey(item.videoId),
|
||||||
|
item: item,
|
||||||
|
slotIndex: notifier.pool.slotOf(index),
|
||||||
|
pool: notifier.pool,
|
||||||
|
isCurrent: index == state.currentIndex,
|
||||||
|
onLike: notifier.toggleLike,
|
||||||
|
onFavorite: notifier.toggleFavorite,
|
||||||
|
onComment: _openComments,
|
||||||
|
onReturnFromPlayer: notifier.rebindCurrent,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 顶部:返回按钮 + 搜索关键词
|
||||||
|
SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_ios,
|
||||||
|
size: 20, color: Colors.white),
|
||||||
|
onPressed: () {
|
||||||
|
// 先停搜索池再 pop,避免依赖 dispose 时机导致声音残留
|
||||||
|
ref.read(feedProvider(_strategy).notifier).pool.stopAll();
|
||||||
|
context.pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (keyword.isNotEmpty)
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
keyword,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: Colors.white,
|
||||||
|
shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 底部:免费剩余次数提示条(≤3 时显示)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: FreeQuotaBanner(remaining: state.freeRemainingToday),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,11 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/media/cover_cache.dart';
|
||||||
|
import '../../core/log/action_logger.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../models/video_item.dart';
|
import '../../models/video_item.dart';
|
||||||
|
import '../../providers/feed_provider.dart';
|
||||||
import '../../providers/search_provider.dart';
|
import '../../providers/search_provider.dart';
|
||||||
|
|
||||||
/// 搜索页(《01-开发文档》2.5.8):
|
/// 搜索页(《01-开发文档》2.5.8):
|
||||||
@@ -24,12 +27,22 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
ActionLogger.I.log('route_enter', page: '/search');
|
||||||
|
// 打开搜索页挂起首页 Feed:保存当前播放断点 + 停所有槽位。
|
||||||
|
// 用 suspend 而非 stopAll——stopAll 会清掉断点导致返回时从头播,
|
||||||
|
// suspend 会把当前槽位置存到 _resumePosition,rebindCurrent 时经 mpv start 续播
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final strategy = ref.read(activeFeedStrategyProvider);
|
||||||
|
ref.read(feedProvider(strategy).notifier).pool.suspend();
|
||||||
|
});
|
||||||
ref.read(searchProvider.notifier).loadHot();
|
ref.read(searchProvider.notifier).loadHot();
|
||||||
_scrollController.addListener(_onScroll);
|
_scrollController.addListener(_onScroll);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
ActionLogger.I.log('route_exit', page: '/search');
|
||||||
|
// 首页恢复播放改由 FeedScreen 通过 RouteObserver.didPopNext 感知,这里不再处理
|
||||||
_inputController.dispose();
|
_inputController.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -283,7 +296,11 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return VideoGridCard(item: state.items[index]);
|
return VideoGridCard(
|
||||||
|
item: state.items[index],
|
||||||
|
allItems: state.items,
|
||||||
|
initialIndex: index,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -293,15 +310,37 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 视频网格卡片:封面 + 标题 + 作者 + 播放量(搜索结果 / 标签视频页共用)
|
/// 视频网格卡片:封面 + 标题 + 作者 + 播放量(搜索结果 / 标签视频页共用)
|
||||||
class VideoGridCard extends StatelessWidget {
|
/// 点击行为:把当前列表注入到 feedProvider(FeedStrategy.search),跳 /search/feed
|
||||||
const VideoGridCard({super.key, required this.item});
|
/// 进入全屏 Feed 播放,支持上下滑切换上/下一个视频
|
||||||
|
class VideoGridCard extends ConsumerWidget {
|
||||||
|
const VideoGridCard({
|
||||||
|
super.key,
|
||||||
|
required this.item,
|
||||||
|
required this.allItems,
|
||||||
|
required this.initialIndex,
|
||||||
|
});
|
||||||
|
|
||||||
final VideoItem item;
|
final VideoItem item;
|
||||||
|
|
||||||
|
/// 当前列表的所有视频(搜索结果/标签结果)
|
||||||
|
final List<VideoItem> allItems;
|
||||||
|
|
||||||
|
/// 当前点击的视频在列表中的下标
|
||||||
|
final int initialIndex;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => context.push('/search/play', extra: item),
|
// 宿主页(SearchScreen / TagVideosScreen)已在进入时挂起首页 Feed,
|
||||||
|
// 这里只负责把列表注入 search 策略池并跳转;首页池恢复由宿主页 dispose 统一处理
|
||||||
|
onTap: () async {
|
||||||
|
final searchFeedNotifier =
|
||||||
|
ref.read(feedProvider(FeedStrategy.search).notifier);
|
||||||
|
await searchFeedNotifier.injectItems(allItems, initialIndex);
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
context.push('/search/feed');
|
||||||
|
},
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -315,6 +354,7 @@ class VideoGridCard extends StatelessWidget {
|
|||||||
child: item.coverUrl.isNotEmpty
|
child: item.coverUrl.isNotEmpty
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: item.coverUrl,
|
imageUrl: item.coverUrl,
|
||||||
|
cacheManager: CoverCacheManager.I,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorWidget: (_, __, ___) => const Icon(
|
errorWidget: (_, __, ___) => const Icon(
|
||||||
Icons.play_circle_outline,
|
Icons.play_circle_outline,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../providers/feed_provider.dart';
|
||||||
import '../../providers/search_provider.dart';
|
import '../../providers/search_provider.dart';
|
||||||
import '../../providers/tag_videos_provider.dart';
|
import '../../providers/tag_videos_provider.dart';
|
||||||
import 'search_screen.dart';
|
import 'search_screen.dart';
|
||||||
@@ -23,11 +24,17 @@ class _TagVideosScreenState extends ConsumerState<TagVideosScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// 进入标签页即挂起首页 Feed(保存断点 + 停所有槽位)
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final strategy = ref.read(activeFeedStrategyProvider);
|
||||||
|
ref.read(feedProvider(strategy).notifier).pool.suspend();
|
||||||
|
});
|
||||||
_scrollController.addListener(_onScroll);
|
_scrollController.addListener(_onScroll);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
// 首页恢复播放由 FeedScreen.didPopNext 统一处理
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -129,7 +136,11 @@ class _TagVideosScreenState extends ConsumerState<TagVideosScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return VideoGridCard(item: state.items[index]);
|
return VideoGridCard(
|
||||||
|
item: state.items[index],
|
||||||
|
allItems: state.items,
|
||||||
|
initialIndex: index,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -71,14 +71,22 @@ class UploadService {
|
|||||||
|
|
||||||
/// 视频分类
|
/// 视频分类
|
||||||
class VideoCategory {
|
class VideoCategory {
|
||||||
VideoCategory({required this.categoryId, required this.name});
|
VideoCategory({
|
||||||
|
required this.categoryId,
|
||||||
|
required this.name,
|
||||||
|
this.hasSeries = false,
|
||||||
|
});
|
||||||
|
|
||||||
final int categoryId;
|
final int categoryId;
|
||||||
final String name;
|
final String name;
|
||||||
|
|
||||||
|
/// 系列型分类(如 AI短剧):列表按「剧」聚合展示,一部剧只占一张卡片
|
||||||
|
final bool hasSeries;
|
||||||
|
|
||||||
factory VideoCategory.fromJson(Map<String, dynamic> json) => VideoCategory(
|
factory VideoCategory.fromJson(Map<String, dynamic> json) => VideoCategory(
|
||||||
categoryId: (json['category_id'] as num?)?.toInt() ?? 0,
|
categoryId: (json['category_id'] as num?)?.toInt() ?? 0,
|
||||||
name: (json['name'] as String?) ?? '',
|
name: (json['name'] as String?) ?? '',
|
||||||
|
hasSeries: (json['has_series'] as bool?) ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ class VideoService {
|
|||||||
VideoService._();
|
VideoService._();
|
||||||
|
|
||||||
/// 5.1 Feed 流:type=recommend|latest|hot,游标分页
|
/// 5.1 Feed 流:type=recommend|latest|hot,游标分页
|
||||||
|
/// [group]=series 时(系列型分类)按「剧」聚合,一部剧只返回首集一条
|
||||||
static Future<FeedPage> fetchFeed({
|
static Future<FeedPage> fetchFeed({
|
||||||
String type = 'recommend',
|
String type = 'recommend',
|
||||||
String? cursor,
|
String? cursor,
|
||||||
int limit = 10,
|
int limit = 10,
|
||||||
int? categoryId,
|
int? categoryId,
|
||||||
|
int? sourceId,
|
||||||
|
String? group,
|
||||||
}) async {
|
}) async {
|
||||||
final data = await ApiClient.get<Map<String, dynamic>>(
|
final data = await ApiClient.get<Map<String, dynamic>>(
|
||||||
Endpoints.feed,
|
Endpoints.feed,
|
||||||
@@ -21,6 +24,8 @@ class VideoService {
|
|||||||
if (cursor != null && cursor.isNotEmpty) 'cursor': cursor,
|
if (cursor != null && cursor.isNotEmpty) 'cursor': cursor,
|
||||||
'limit': limit,
|
'limit': limit,
|
||||||
if (categoryId != null) 'category_id': categoryId,
|
if (categoryId != null) 'category_id': categoryId,
|
||||||
|
if (sourceId != null) 'source_id': sourceId,
|
||||||
|
if (group != null) 'group': group,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final list = ((data['list'] as List?) ?? const [])
|
final list = ((data['list'] as List?) ?? const [])
|
||||||
@@ -30,6 +35,15 @@ class VideoService {
|
|||||||
return FeedPage(items: list, nextCursor: data['next_cursor'] as String?);
|
return FeedPage(items: list, nextCursor: data['next_cursor'] as String?);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 内容来源列表(麻豆 / 91 ...),分类页筛选用
|
||||||
|
static Future<List<VideoSource>> fetchSources() async {
|
||||||
|
final data = await ApiClient.get<Map<String, dynamic>>(Endpoints.sources);
|
||||||
|
return ((data['list'] as List?) ?? const [])
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(VideoSource.fromJson)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
/// 5.2 视频详情(登录返回续播进度 my_position)
|
/// 5.2 视频详情(登录返回续播进度 my_position)
|
||||||
static Future<Map<String, dynamic>> fetchDetail(int videoId) =>
|
static Future<Map<String, dynamic>> fetchDetail(int videoId) =>
|
||||||
ApiClient.get<Map<String, dynamic>>(Endpoints.videoDetail(videoId));
|
ApiClient.get<Map<String, dynamic>>(Endpoints.videoDetail(videoId));
|
||||||
@@ -93,6 +107,10 @@ class VideoService {
|
|||||||
static Future<void> deleteVideo(int videoId) =>
|
static Future<void> deleteVideo(int videoId) =>
|
||||||
ApiClient.delete<Map<String, dynamic>>(Endpoints.videoDelete(videoId));
|
ApiClient.delete<Map<String, dynamic>>(Endpoints.videoDelete(videoId));
|
||||||
|
|
||||||
|
/// 下架视频(仅站点管理员 256kb@gmail.com,App 长按菜单)
|
||||||
|
static Future<void> offlineVideo(int videoId) =>
|
||||||
|
ApiClient.post<Map<String, dynamic>>(Endpoints.videoOffline(videoId));
|
||||||
|
|
||||||
/// 5.9 标签视频列表(游客可):sort=hot(默认)| latest,普通分页
|
/// 5.9 标签视频列表(游客可):sort=hot(默认)| latest,普通分页
|
||||||
static Future<TagVideosPage> fetchTagVideos(
|
static Future<TagVideosPage> fetchTagVideos(
|
||||||
String tagName, {
|
String tagName, {
|
||||||
|
|||||||
+3
-1
@@ -2,7 +2,7 @@ name: video_app
|
|||||||
description: "短视频应用(Flutter,iOS/Android 双端)"
|
description: "短视频应用(Flutter,iOS/Android 双端)"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.3.15+31
|
version: 1.3.28+44
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
@@ -46,6 +46,8 @@ dependencies:
|
|||||||
flutter_secure_storage: ^9.2.3
|
flutter_secure_storage: ^9.2.3
|
||||||
# 图片缓存(封面/头像)
|
# 图片缓存(封面/头像)
|
||||||
cached_network_image: ^3.4.1
|
cached_network_image: ^3.4.1
|
||||||
|
# 自定义缓存策略(封面缓存取消大小限制)
|
||||||
|
flutter_cache_manager: ^3.4.1
|
||||||
# 播放器(HLS m3u8)
|
# 播放器(HLS m3u8)
|
||||||
media_kit: ^1.1.11
|
media_kit: ^1.1.11
|
||||||
media_kit_video: ^1.2.5
|
media_kit_video: ^1.2.5
|
||||||
|
|||||||
Executable
+463
@@ -0,0 +1,463 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ============================================================================
|
||||||
|
# 全栈部署脚本:从零到上线(首次部署 / 服务器重装 / 数据迁移后的完整拉起)
|
||||||
|
#
|
||||||
|
# 与 deploy.sh 区别:
|
||||||
|
# deploy.sh = App 发版专用(编译 APK → 部署 → 同步 DB 版本号)
|
||||||
|
# deploy-full.sh = 整个项目首装/重装/迁移恢复,覆盖 Docker / 后端 / nginx / 后台前端
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# bash deploy-full.sh # 完整执行(首次部署)
|
||||||
|
# bash deploy-full.sh --skip-apk # 跳过 App 编译(已有 APK 时提速)
|
||||||
|
# bash deploy-full.sh --skip-nginx # 跳过 nginx 配置写入(仅重启服务)
|
||||||
|
# bash deploy-full.sh --skip-admin # 跳过 admin 前端构建
|
||||||
|
# bash deploy-full.sh --only-backend # 只重启后端 + 健康检查
|
||||||
|
#
|
||||||
|
# 前置条件:
|
||||||
|
# - Debian/Ubuntu 系统,已安装:nginx, docker, mysql-client, python3.11+,
|
||||||
|
# nodejs 20+, npm, flutter SDK (在 /opt/flutter)
|
||||||
|
# - 项目代码已 clone 到 /root/project/video
|
||||||
|
# - DNS 已把 h5/vm/vapi.256kb.top 解析到本机
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---------- 参数解析 ----------
|
||||||
|
SKIP_APK=0
|
||||||
|
SKIP_NGINX=0
|
||||||
|
SKIP_ADMIN=0
|
||||||
|
ONLY_BACKEND=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--skip-apk) SKIP_APK=1 ;;
|
||||||
|
--skip-nginx) SKIP_NGINX=1 ;;
|
||||||
|
--skip-admin) SKIP_ADMIN=1 ;;
|
||||||
|
--only-backend) ONLY_BACKEND=1 ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '1,30p' "$0"; exit 0 ;;
|
||||||
|
*) echo "未知参数: $arg"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
PROJECT_ROOT="/root/project/video"
|
||||||
|
SERVER_DIR="$PROJECT_ROOT/server"
|
||||||
|
APP_DIR="$PROJECT_ROOT/app"
|
||||||
|
ADMIN_DIR="$PROJECT_ROOT/admin"
|
||||||
|
RUNTIME_DIR="$PROJECT_ROOT/runtime"
|
||||||
|
H5_DIR="$PROJECT_ROOT/h5"
|
||||||
|
|
||||||
|
NGINX_CONF_DIR="/www/server/panel/vhost/nginx"
|
||||||
|
ACME_WEBROOT="/www/wwwroot/acme-challenge"
|
||||||
|
|
||||||
|
DB_NAME="video"
|
||||||
|
DB_USER="root"
|
||||||
|
DB_PASS="256kb.cn"
|
||||||
|
DB_PORT="3307"
|
||||||
|
|
||||||
|
log() { echo; echo "▶ $*"; }
|
||||||
|
ok() { echo " ✅ $*"; }
|
||||||
|
warn() { echo " ⚠️ $*"; }
|
||||||
|
die() { echo " ❌ $*"; exit 1; }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 0. 前置检查
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
log "0. 前置检查"
|
||||||
|
[ -d "$PROJECT_ROOT" ] || die "项目目录不存在:$PROJECT_ROOT"
|
||||||
|
[ -f "$SERVER_DIR/.env" ] || die "缺少 $SERVER_DIR/.env,请参考 docs/04-部署运行文档.md 4.2 节配置"
|
||||||
|
[ -d "$SERVER_DIR/.venv" ] || warn "$SERVER_DIR/.venv 不存在,稍后需要执行 python -m venv .venv && pip install -r requirements.txt"
|
||||||
|
command -v nginx >/dev/null || die "未安装 nginx"
|
||||||
|
command -v docker >/dev/null || die "未安装 docker"
|
||||||
|
command -v mysql >/dev/null || die "未安装 mysql-client"
|
||||||
|
ok "前置检查通过"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. 目录结构
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
log "1. 创建运行目录"
|
||||||
|
mkdir -p "$RUNTIME_DIR"/{uploads,videos,downloads,backups} "$H5_DIR" "$ACME_WEBROOT"
|
||||||
|
chmod 755 "$RUNTIME_DIR" "$RUNTIME_DIR"/{uploads,videos,downloads,backups}
|
||||||
|
ok "目录创建完成"
|
||||||
|
|
||||||
|
[ "$ONLY_BACKEND" = "1" ] && { SKIP_APK=1; SKIP_NGINX=1; SKIP_ADMIN=1; }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Docker 组件(MySQL / Redis)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
log "2. 启动 Docker 容器"
|
||||||
|
|
||||||
|
# 2.1 MySQL 8(容器名 mysql8,端口 3307,数据 /opt/mysql8-data)
|
||||||
|
if ! docker ps -a --format '{{.Names}}' | grep -qx mysql8; then
|
||||||
|
log "2.1 首次创建 mysql8 容器"
|
||||||
|
mkdir -p /opt/mysql8-data
|
||||||
|
docker run -d --name mysql8 \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 3307:3306 \
|
||||||
|
-e MYSQL_ROOT_PASSWORD="$DB_PASS" \
|
||||||
|
-e TZ=Asia/Shanghai \
|
||||||
|
-v /opt/mysql8-data:/var/lib/mysql \
|
||||||
|
mysql:8 \
|
||||||
|
--character-set-server=utf8mb4 \
|
||||||
|
--collation-server=utf8mb4_unicode_ci \
|
||||||
|
--default-authentication-plugin=mysql_native_password
|
||||||
|
ok "mysql8 创建完成"
|
||||||
|
else
|
||||||
|
docker start mysql8 >/dev/null && ok "mysql8 已启动"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2.2 Redis(容器名 my-redis,端口 6379)
|
||||||
|
if ! docker ps -a --format '{{.Names}}' | grep -qx my-redis; then
|
||||||
|
log "2.2 首次创建 my-redis 容器"
|
||||||
|
mkdir -p /home/redis/data /home/redis/conf
|
||||||
|
docker run -d --name my-redis \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 6379:6379 \
|
||||||
|
-v /home/redis/data:/data \
|
||||||
|
-v /home/redis/conf:/usr/local/etc/redis \
|
||||||
|
redis:latest \
|
||||||
|
redis-server --appendonly yes
|
||||||
|
ok "my-redis 创建完成"
|
||||||
|
else
|
||||||
|
docker start my-redis >/dev/null && ok "my-redis 已启动"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2.3 等待容器就绪
|
||||||
|
echo -n " 等待 MySQL 就绪..."
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
docker exec mysql8 mysqladmin ping -h 127.0.0.1 -uroot -p"$DB_PASS" >/dev/null 2>&1 && { echo " OK"; break; }
|
||||||
|
[ "$i" = "30" ] && die "MySQL 启动超时"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo -n " 等待 Redis 就绪..."
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
[ "$(docker exec my-redis redis-cli ping 2>/dev/null)" = "PONG" ] && { echo " OK"; break; }
|
||||||
|
[ "$i" = "15" ] && die "Redis 启动超时"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# 2.4 创建数据库(首次)
|
||||||
|
docker exec mysql8 mysql -uroot -p"$DB_PASS" -e \
|
||||||
|
"CREATE DATABASE IF NOT EXISTS $DB_NAME CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" \
|
||||||
|
&& ok "数据库 $DB_NAME 已确认存在"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. 后端服务(video-api.service)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
log "3. 部署后端服务"
|
||||||
|
|
||||||
|
# 3.1 Python 虚拟环境
|
||||||
|
if [ ! -d "$SERVER_DIR/.venv" ]; then
|
||||||
|
log "3.1 创建虚拟环境"
|
||||||
|
cd "$SERVER_DIR"
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install -U pip
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
ok ".venv 安装完成"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3.2 数据库迁移
|
||||||
|
log "3.2 执行 alembic 迁移"
|
||||||
|
cd "$SERVER_DIR"
|
||||||
|
.venv/bin/alembic upgrade head
|
||||||
|
ok "数据库迁移完成"
|
||||||
|
|
||||||
|
# 3.3 systemd 单元
|
||||||
|
log "3.3 写入 systemd 单元"
|
||||||
|
cat > /etc/systemd/system/video-api.service <<'UNIT'
|
||||||
|
[Unit]
|
||||||
|
Description=Video App FastAPI Backend (uvicorn)
|
||||||
|
After=network.target docker.service redis.service
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/root/project/video/server
|
||||||
|
ExecStart=/root/project/video/server/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 2
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now video-api.service
|
||||||
|
sleep 3
|
||||||
|
systemctl is-active --quiet video-api.service && ok "video-api.service 已启动并设为开机自启" \
|
||||||
|
|| die "video-api.service 启动失败,请 journalctl -u video-api -n 50 查看"
|
||||||
|
|
||||||
|
# 3.4 后端健康检查
|
||||||
|
echo -n " 健康检查..."
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8000/health || true)
|
||||||
|
[ "$code" = "200" ] && { echo " OK ($code)"; break; }
|
||||||
|
[ "$i" = "10" ] && die "后端健康检查失败(10 次尝试后仍非 200)"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. nginx 配置
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if [ "$SKIP_NGINX" = "0" ]; then
|
||||||
|
log "4. 写入 nginx 配置(h5 / vm / vapi 三个站点)"
|
||||||
|
|
||||||
|
# 通用 SSL 配置片段(这里用 heredoc 内嵌,避免每次写三个文件重复)
|
||||||
|
_ssl_block() {
|
||||||
|
local d="$1"
|
||||||
|
cat <<SSL
|
||||||
|
ssl_certificate /www/server/panel/vhost/cert/$d/fullchain.pem;
|
||||||
|
ssl_certificate_key /www/server/panel/vhost/cert/$d/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:!MD5;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
SSL
|
||||||
|
}
|
||||||
|
|
||||||
|
_acme_block() {
|
||||||
|
cat <<ACME
|
||||||
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
|
root /www/wwwroot/acme-challenge/;
|
||||||
|
}
|
||||||
|
ACME
|
||||||
|
}
|
||||||
|
|
||||||
|
_proxy_block() {
|
||||||
|
cat <<PROXY
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
PROXY
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4.1 h5.256kb.top
|
||||||
|
cat > "$NGINX_CONF_DIR/h5.256kb.top.conf" <<NGINX
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name h5.256kb.top;
|
||||||
|
$(_acme_block)
|
||||||
|
location / { return 301 https://\$host\$request_uri; }
|
||||||
|
}
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name h5.256kb.top;
|
||||||
|
$(_ssl_block h5.256kb.top)
|
||||||
|
index index.html;
|
||||||
|
root $H5_DIR;
|
||||||
|
|
||||||
|
$(_acme_block)
|
||||||
|
|
||||||
|
location ~ /\\. { deny all; }
|
||||||
|
|
||||||
|
location /downloads/ {
|
||||||
|
alias $RUNTIME_DIR/downloads/;
|
||||||
|
autoindex off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /videos/ {
|
||||||
|
alias $RUNTIME_DIR/videos/;
|
||||||
|
autoindex off;
|
||||||
|
add_header Access-Control-Allow-Origin *;
|
||||||
|
location ~* \\.m3u8\$ {
|
||||||
|
add_header Content-Type application/vnd.apple.mpegurl;
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
}
|
||||||
|
location ~* \\.ts\$ {
|
||||||
|
add_header Content-Type video/mp2t;
|
||||||
|
add_header Cache-Control "max-age=86400";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
location /uploads/ {
|
||||||
|
alias $RUNTIME_DIR/uploads/;
|
||||||
|
autoindex off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /r/ {
|
||||||
|
$(_proxy_block)
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/v1/invite/ {
|
||||||
|
$(_proxy_block)
|
||||||
|
}
|
||||||
|
|
||||||
|
access_log /www/wwwlogs/h5.256kb.top.log;
|
||||||
|
error_log /www/wwwlogs/h5.256kb.top.error.log;
|
||||||
|
}
|
||||||
|
NGINX
|
||||||
|
ok "h5.256kb.top.conf"
|
||||||
|
|
||||||
|
# 4.2 vm.256kb.top
|
||||||
|
cat > "$NGINX_CONF_DIR/vm.256kb.top.conf" <<NGINX
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name vm.256kb.top;
|
||||||
|
$(_acme_block)
|
||||||
|
location / { return 301 https://\$host\$request_uri; }
|
||||||
|
}
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name vm.256kb.top;
|
||||||
|
$(_ssl_block vm.256kb.top)
|
||||||
|
index index.html;
|
||||||
|
root $ADMIN_DIR/dist;
|
||||||
|
|
||||||
|
$(_acme_block)
|
||||||
|
|
||||||
|
location ~ /\\. { deny all; }
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
$(_proxy_block)
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static/ {
|
||||||
|
$(_proxy_block)
|
||||||
|
}
|
||||||
|
|
||||||
|
location /r/ {
|
||||||
|
$(_proxy_block)
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files \$uri \$uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
access_log /www/wwwlogs/vm.256kb.top.log;
|
||||||
|
error_log /www/wwwlogs/vm.256kb.top.error.log;
|
||||||
|
}
|
||||||
|
NGINX
|
||||||
|
ok "vm.256kb.top.conf"
|
||||||
|
|
||||||
|
# 4.3 vapi.256kb.top
|
||||||
|
cat > "$NGINX_CONF_DIR/vapi.256kb.top.conf" <<NGINX
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name vapi.256kb.top;
|
||||||
|
$(_acme_block)
|
||||||
|
location / { return 301 https://\$host\$request_uri; }
|
||||||
|
}
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name vapi.256kb.top;
|
||||||
|
$(_ssl_block vapi.256kb.top)
|
||||||
|
|
||||||
|
$(_acme_block)
|
||||||
|
|
||||||
|
location ~ /\\. { deny all; }
|
||||||
|
|
||||||
|
location / {
|
||||||
|
$(_proxy_block)
|
||||||
|
client_max_body_size 500m;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
|
access_log /www/wwwlogs/vapi.256kb.top.log;
|
||||||
|
error_log /www/wwwlogs/vapi.256kb.top.error.log;
|
||||||
|
}
|
||||||
|
NGINX
|
||||||
|
ok "vapi.256kb.top.conf"
|
||||||
|
|
||||||
|
nginx -t && nginx -s reload && ok "nginx 配置已重载"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. HTTPS 证书(acme.sh)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if [ "$SKIP_NGINX" = "0" ] && [ ! -f "/www/server/panel/vhost/cert/h5.256kb.top/fullchain.pem" ]; then
|
||||||
|
log "5. 签发 Let's Encrypt 证书(acme.sh,ECC)"
|
||||||
|
if [ ! -d "$HOME/.acme.sh" ]; then
|
||||||
|
warn "未检测到 acme.sh,请先安装:curl https://get.acme.sh | sh -s email=you@example.com"
|
||||||
|
warn "然后重新执行 bash deploy-full.sh"
|
||||||
|
else
|
||||||
|
for d in h5.256kb.top vm.256kb.top vapi.256kb.top; do
|
||||||
|
if [ ! -f "/www/server/panel/vhost/cert/$d/fullchain.pem" ]; then
|
||||||
|
echo " 签发 $d..."
|
||||||
|
~/.acme.sh/acme.sh --issue -d "$d" --webroot "$ACME_WEBROOT" --keylength ec-256
|
||||||
|
mkdir -p "/www/server/panel/vhost/cert/$d"
|
||||||
|
~/.acme.sh/acme.sh --install-cert -d "$d" --ecc \
|
||||||
|
--fullchain-file "/www/server/panel/vhost/cert/$d/fullchain.pem" \
|
||||||
|
--key-file "/www/server/panel/vhost/cert/$d/privkey.pem" \
|
||||||
|
--reloadcmd "nginx -t && nginx -s reload"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
ok "证书签发完成"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. 后台前端构建
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if [ "$SKIP_ADMIN" = "0" ]; then
|
||||||
|
log "6. 构建后台前端 admin/"
|
||||||
|
cd "$ADMIN_DIR"
|
||||||
|
[ -d node_modules ] || npm install
|
||||||
|
npm run build
|
||||||
|
ok "admin/dist 构建完成(nginx vm.256kb.top 直接指向这里,无需重启)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. App 编译与部署(APK)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if [ "$SKIP_APK" = "0" ]; then
|
||||||
|
log "7. 编译 App APK"
|
||||||
|
[ -f "$APP_DIR/pubspec.yaml" ] || die "缺少 $APP_DIR/pubspec.yaml"
|
||||||
|
cd "$APP_DIR"
|
||||||
|
/opt/flutter/bin/flutter build apk --release
|
||||||
|
mkdir -p "$RUNTIME_DIR/downloads"
|
||||||
|
cp -f "$APP_DIR/build/app/outputs/flutter-apk/app-release.apk" \
|
||||||
|
"$RUNTIME_DIR/downloads/video-app.apk"
|
||||||
|
chmod 644 "$RUNTIME_DIR/downloads/video-app.apk"
|
||||||
|
(cd "$APP_DIR/android" && ./gradlew --stop >/dev/null 2>&1) || true
|
||||||
|
ok "APK 已部署到 $RUNTIME_DIR/downloads/video-app.apk"
|
||||||
|
|
||||||
|
# 同步 DB 版本号(让老用户收到更新提示)
|
||||||
|
PUBSPEC_VERSION=$(grep -E '^version:' "$APP_DIR/pubspec.yaml" | awk '{print $2}')
|
||||||
|
VERSION_NAME="${PUBSPEC_VERSION%+*}"
|
||||||
|
VERSION_CODE="${PUBSPEC_VERSION##*+}"
|
||||||
|
MIN_CODE=$(mysql -h 127.0.0.1 -u"$DB_USER" -p"$DB_PASS" -P"$DB_PORT" "$DB_NAME" -Nse \
|
||||||
|
"SELECT JSON_UNQUOTE(JSON_EXTRACT(config_value,'\$.min_version_code')) \
|
||||||
|
FROM system_configs WHERE config_key='app.version.android';" 2>/dev/null || echo "")
|
||||||
|
MIN_CODE="${MIN_CODE:-1}"
|
||||||
|
UPDATE_JSON="{\"latest_version\":\"$VERSION_NAME\",\"latest_version_code\":$VERSION_CODE,\"min_version_code\":$MIN_CODE,\"update_url\":\"https://h5.256kb.top/downloads/video-app.apk\",\"notice\":\"\"}"
|
||||||
|
mysql -h 127.0.0.1 -u"$DB_USER" -p"$DB_PASS" -P"$DB_PORT" "$DB_NAME" -e "
|
||||||
|
INSERT INTO system_configs (config_key, config_value, description)
|
||||||
|
VALUES ('app.version.android', '$UPDATE_JSON', 'Android 版本检查配置')
|
||||||
|
ON DUPLICATE KEY UPDATE config_value=VALUES(config_value), updated_at=NOW();
|
||||||
|
"
|
||||||
|
docker exec my-redis redis-cli DEL config:system >/dev/null 2>&1 || true
|
||||||
|
ok "DB 版本号同步:$VERSION_NAME+$VERSION_CODE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. 端到端验证
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
log "8. 端到端验证"
|
||||||
|
echo -n " 后端本地接口..."
|
||||||
|
curl -sf http://127.0.0.1:8000/health >/dev/null && echo " OK" || echo " FAIL"
|
||||||
|
|
||||||
|
echo -n " vapi 公网接口..."
|
||||||
|
curl -sf https://vapi.256kb.top/api/v1/app/version >/dev/null && echo " OK" || echo " FAIL"
|
||||||
|
|
||||||
|
echo -n " h5 下载页..."
|
||||||
|
curl -sfI https://h5.256kb.top/downloads/video-app.apk >/dev/null && echo " OK" || echo " FAIL"
|
||||||
|
|
||||||
|
echo -n " vm 后台首页..."
|
||||||
|
curl -sf https://vm.256kb.top/ >/dev/null && echo " OK" || echo " FAIL"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "============================================================================"
|
||||||
|
echo "✅ 部署完成"
|
||||||
|
echo "============================================================================"
|
||||||
|
echo " 后端: http://127.0.0.1:8000 (systemd: video-api.service)"
|
||||||
|
echo " 后台: https://vm.256kb.top (admin/dist 静态站点)"
|
||||||
|
echo " 接口: https://vapi.256kb.top/api/v1/..."
|
||||||
|
echo " 下载页: https://h5.256kb.top/downloads/video-app.apk"
|
||||||
|
echo
|
||||||
|
echo " 启停: bash $PROJECT_ROOT/project-ctl.sh {start|stop|restart|status}"
|
||||||
|
echo " 日志: journalctl -u video-api -f"
|
||||||
|
echo " 备份: https://vm.256kb.top → 运营管理 → 数据库备份"
|
||||||
|
echo "============================================================================"
|
||||||
+246
-16
@@ -207,35 +207,249 @@ npm run build # 产物直接覆盖 admin/dist/,即 vm 站点根目录
|
|||||||
|
|
||||||
## 6. nginx 配置(宝塔管理)
|
## 6. nginx 配置(宝塔管理)
|
||||||
|
|
||||||
配置文件:`/www/server/panel/vhost/nginx/{h5,vm,vapi,codeserver}.256kb.top.conf`。
|
配置文件:`/www/server/panel/vhost/nginx/{h5,vm,vapi}.256kb.top.conf`。
|
||||||
改配置在宝塔面板「网站 → 设置 → 配置文件」编辑,或改文件后 `nginx -t && nginx -s reload`。
|
改配置在宝塔面板「网站 → 设置 → 配置文件」编辑,或改文件后 `nginx -t && nginx -s reload`。
|
||||||
|
|
||||||
关键规则摘要:
|
证书路径(acme.sh ECC 证书,自动续期):
|
||||||
|
- `/www/server/panel/vhost/cert/<域名>/fullchain.pem`
|
||||||
|
- `/www/server/panel/vhost/cert/<域名>/privkey.pem`
|
||||||
|
|
||||||
**h5.256kb.top**
|
站点日志在 `/www/wwwlogs/<域名>.log` 和 `/www/wwwlogs/<域名>.error.log`(排查 App 接口 401/500 时非常管用)。
|
||||||
|
|
||||||
|
### 6.1 h5.256kb.top(用户下载页 + HLS 资源)
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
root /root/project/video/h5; # index.html 下载页
|
server {
|
||||||
location /downloads/ { alias /root/project/video/runtime/downloads/; } # APK
|
listen 80;
|
||||||
location /videos/ { alias /root/project/video/runtime/videos/; }
|
server_name h5.256kb.top;
|
||||||
location /uploads/ { alias /root/project/video/runtime/uploads/; }
|
# ACME 验证保留在 80,其余跳 HTTPS
|
||||||
location /r/ { proxy_pass http://127.0.0.1:8000; } # 邀请落地
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
location /api/v1/invite/ { proxy_pass http://127.0.0.1:8000; }
|
root /www/wwwroot/acme-challenge/;
|
||||||
|
}
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name h5.256kb.top;
|
||||||
|
ssl_certificate /www/server/panel/vhost/cert/h5.256kb.top/fullchain.pem;
|
||||||
|
ssl_certificate_key /www/server/panel/vhost/cert/h5.256kb.top/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:!MD5;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
index index.html;
|
||||||
|
root /root/project/video/h5;
|
||||||
|
|
||||||
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
|
root /www/wwwroot/acme-challenge/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 禁止访问隐藏文件
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
|
||||||
|
# APK 下载
|
||||||
|
location /downloads/ {
|
||||||
|
alias /root/project/video/runtime/downloads/;
|
||||||
|
autoindex off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HLS 视频(hls.js 需要跨域 + 正确 MIME)
|
||||||
|
location /videos/ {
|
||||||
|
alias /root/project/video/runtime/videos/;
|
||||||
|
autoindex off;
|
||||||
|
add_header Access-Control-Allow-Origin *;
|
||||||
|
location ~* \.m3u8$ {
|
||||||
|
add_header Content-Type application/vnd.apple.mpegurl;
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
}
|
||||||
|
location ~* \.ts$ {
|
||||||
|
add_header Content-Type video/mp2t;
|
||||||
|
add_header Cache-Control "max-age=86400";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 上传资源
|
||||||
|
location /uploads/ {
|
||||||
|
alias /root/project/video/runtime/uploads/;
|
||||||
|
autoindex off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 邀请注册落地页 → 后端
|
||||||
|
location /r/ {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 邀请相关 API → 后端
|
||||||
|
location /api/v1/invite/ {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
access_log /www/wwwlogs/h5.256kb.top.log;
|
||||||
|
error_log /www/wwwlogs/h5.256kb.top.error.log;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**vm.256kb.top**
|
### 6.2 vm.256kb.top(管理后台 Vue SPA + 后端 API)
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name vm.256kb.top;
|
||||||
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
|
root /www/wwwroot/acme-challenge/;
|
||||||
|
}
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name vm.256kb.top;
|
||||||
|
ssl_certificate /www/server/panel/vhost/cert/vm.256kb.top/fullchain.pem;
|
||||||
|
ssl_certificate_key /www/server/panel/vhost/cert/vm.256kb.top/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:!MD5;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
index index.html;
|
||||||
root /root/project/video/admin/dist;
|
root /root/project/video/admin/dist;
|
||||||
location / { try_files $uri $uri/ /index.html; } # SPA 回退
|
|
||||||
location /api/ { proxy_pass http://127.0.0.1:8000; }
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
location /static/ { proxy_pass http://127.0.0.1:8000; }
|
root /www/wwwroot/acme-challenge/;
|
||||||
location /r/ { proxy_pass http://127.0.0.1:8000; }
|
}
|
||||||
|
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 后端 API
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 后端本地静态资源(uploads 挂载点)
|
||||||
|
location /static/ {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 邀请落地页
|
||||||
|
location /r/ {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vue SPA history 路由回退
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
access_log /www/wwwlogs/vm.256kb.top.log;
|
||||||
|
error_log /www/wwwlogs/vm.256kb.top.error.log;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**vapi.256kb.top**:`location / { proxy_pass http://127.0.0.1:8000; }` 全量反代。
|
### 6.3 vapi.256kb.top(App 接口域名,全量反代)
|
||||||
|
|
||||||
站点日志在 `/www/wwwlogs/<域名>.log`(排查 App 接口 401/500 时非常管用)。
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name vapi.256kb.top;
|
||||||
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
|
root /www/wwwroot/acme-challenge/;
|
||||||
|
}
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name vapi.256kb.top;
|
||||||
|
ssl_certificate /www/server/panel/vhost/cert/vapi.256kb.top/fullchain.pem;
|
||||||
|
ssl_certificate_key /www/server/panel/vhost/cert/vapi.256kb.top/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:!MD5;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
|
||||||
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
|
root /www/wwwroot/acme-challenge/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 全部反代到 FastAPI
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# 上传大视频需要
|
||||||
|
client_max_body_size 500m;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
|
access_log /www/wwwlogs/vapi.256kb.top.log;
|
||||||
|
error_log /www/wwwlogs/vapi.256kb.top.error.log;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 证书签发与续期(acme.sh)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 首次签发(三个域名各一次,ECC 证书)
|
||||||
|
~/.acme.sh/acme.sh --issue -d h5.256kb.top --webroot /www/wwwroot/acme-challenge/ --keylength ec-256
|
||||||
|
~/.acme.sh/acme.sh --issue -d vm.256kb.top --webroot /www/wwwroot/acme-challenge/ --keylength ec-256
|
||||||
|
~/.acme.sh/acme.sh --issue -d vapi.256kb.top --webroot /www/wwwroot/acme-challenge/ --keylength ec-256
|
||||||
|
|
||||||
|
# 安装到宝塔证书目录(签发后 acme.sh 会自动 deploy 到 reloadcmd)
|
||||||
|
~/.acme.sh/acme.sh --install-cert -d h5.256kb.top --ecc \
|
||||||
|
--fullchain-file /www/server/panel/vhost/cert/h5.256kb.top/fullchain.pem \
|
||||||
|
--key-file /www/server/panel/vhost/cert/h5.256kb.top/privkey.pem \
|
||||||
|
--reloadcmd "nginx -t && nginx -s reload"
|
||||||
|
# vm / vapi 同理
|
||||||
|
|
||||||
|
# acme.sh 已在 crontab 注册续期任务,可用以下命令查看
|
||||||
|
crontab -l | grep acme
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 检查/排错
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nginx -t # 校验语法
|
||||||
|
nginx -s reload # 平滑重载
|
||||||
|
nginx -T | grep -A 20 'server_name vm' # 看实际生效配置
|
||||||
|
tail -f /www/wwwlogs/vapi.256kb.top.error.log
|
||||||
|
```
|
||||||
|
|
||||||
## 7. App 编译与发版
|
## 7. App 编译与发版
|
||||||
|
|
||||||
@@ -339,6 +553,8 @@ curl -I https://h5.256kb.top/downloads/video-app.apk # 200
|
|||||||
|
|
||||||
## 11. 新环境从零部署速查
|
## 11. 新环境从零部署速查
|
||||||
|
|
||||||
|
**手动步骤**(理解流程用):
|
||||||
|
|
||||||
1. 装 Docker / Python3.11 / Node18+ / Flutter / nginx(或宝塔)
|
1. 装 Docker / Python3.11 / Node18+ / Flutter / nginx(或宝塔)
|
||||||
2. 建 mysql8、my-redis 容器(§3 命令)
|
2. 建 mysql8、my-redis 容器(§3 命令)
|
||||||
3. `server/` 建 venv、装依赖、写 `.env`、`alembic upgrade head`、建超管(§4.3)
|
3. `server/` 建 venv、装依赖、写 `.env`、`alembic upgrade head`、建超管(§4.3)
|
||||||
@@ -347,3 +563,17 @@ curl -I https://h5.256kb.top/downloads/video-app.apk # 200
|
|||||||
6. App:放入 keystore + key.properties → `deploy.sh` 发版(§7)
|
6. App:放入 keystore + key.properties → `deploy.sh` 发版(§7)
|
||||||
7. 后台「系统配置」:`app.invite_base_url`、`app.version.android/ios` 等(见 03 接口文档配置键)
|
7. 后台「系统配置」:`app.invite_base_url`、`app.version.android/ios` 等(见 03 接口文档配置键)
|
||||||
8. 跑 §10.3 验证清单
|
8. 跑 §10.3 验证清单
|
||||||
|
|
||||||
|
**一键脚本**(推荐,已封装上面 1-6 步):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash /root/project/video/deploy-full.sh # 完整执行(首次部署)
|
||||||
|
bash /root/project/video/deploy-full.sh --skip-apk # 已有 APK 跳过 App 编译
|
||||||
|
bash /root/project/video/deploy-full.sh --skip-nginx # nginx 已配好,仅重启服务
|
||||||
|
bash /root/project/video/deploy-full.sh --skip-admin # 跳过 admin 前端构建
|
||||||
|
bash /root/project/video/deploy-full.sh --only-backend # 只重启后端 + 健康检查
|
||||||
|
```
|
||||||
|
|
||||||
|
`deploy-full.sh` 与 `deploy.sh` 的区别:
|
||||||
|
- `deploy.sh`:App 发版专用(编译 APK → 部署 → 同步 DB 版本号),日常迭代用
|
||||||
|
- `deploy-full.sh`:整栈首装/重装/迁移恢复,覆盖 Docker / 后端 / nginx / 后台前端 / 证书 / APK
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""collection_sources 增加 kind/config(接口类采集源)
|
||||||
|
|
||||||
|
Revision ID: a7c2e5f18b04
|
||||||
|
Revises: f1b7d4a9c310
|
||||||
|
Create Date: 2026-10-24 15:00:00.000000
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'a7c2e5f18b04'
|
||||||
|
down_revision = 'f1b7d4a9c310'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('collection_sources', sa.Column(
|
||||||
|
'kind', sa.String(length=20), nullable=False, server_default='url',
|
||||||
|
comment='采集类型 url/aidrama91'))
|
||||||
|
# TEXT 在 MySQL 上不能用普通默认值:先加可空列,回填空串后再收紧
|
||||||
|
op.add_column('collection_sources', sa.Column('config', sa.Text(), nullable=True))
|
||||||
|
op.execute("UPDATE collection_sources SET config = '' WHERE config IS NULL")
|
||||||
|
op.alter_column('collection_sources', 'config', nullable=False,
|
||||||
|
existing_type=sa.Text(), existing_nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('collection_sources', 'config')
|
||||||
|
op.drop_column('collection_sources', 'kind')
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""add_user_action_logs
|
||||||
|
|
||||||
|
Revision ID: cd1b499eba7a
|
||||||
|
Revises: a7c2e5f18b04
|
||||||
|
Create Date: 2026-10-25 11:03:01.792362
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
revision = 'cd1b499eba7a'
|
||||||
|
down_revision = 'a7c2e5f18b04'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table('user_action_logs',
|
||||||
|
sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('user_id', mysql.BIGINT(unsigned=True), nullable=False),
|
||||||
|
sa.Column('action', sa.String(length=50), nullable=False, comment='route_push/player_stopall/api_error/...'),
|
||||||
|
sa.Column('page', sa.String(length=100), nullable=False, comment='路由路径或页面标识'),
|
||||||
|
sa.Column('detail', sa.String(length=1000), nullable=False, comment='行为上下文(JSON 字符串)'),
|
||||||
|
sa.Column('device_info', sa.String(length=200), nullable=False, comment='机型/系统/版本'),
|
||||||
|
sa.Column('app_version', sa.String(length=20), nullable=False, comment='1.3.26+42'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_action_created', 'user_action_logs', ['action', 'created_at'], unique=False)
|
||||||
|
op.create_index('idx_user_created', 'user_action_logs', ['user_id', 'created_at'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('idx_user_created', table_name='user_action_logs')
|
||||||
|
op.drop_index('idx_action_created', table_name='user_action_logs')
|
||||||
|
op.drop_table('user_action_logs')
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""add sources table + source_id on videos/series/collection_sources
|
||||||
|
|
||||||
|
Revision ID: f1b7d4a9c310
|
||||||
|
Revises: e7a1f3c58d02
|
||||||
|
Create Date: 2026-10-24 10:00:00.000000
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
revision = 'f1b7d4a9c310'
|
||||||
|
down_revision = 'e7a1f3c58d02'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 内容来源表(片商)
|
||||||
|
op.create_table(
|
||||||
|
'sources',
|
||||||
|
sa.Column('id', mysql.INTEGER(unsigned=True), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=50), nullable=False, comment='来源名称,如 麻豆'),
|
||||||
|
sa.Column('code', sa.String(length=50), nullable=False, server_default='', comment='英文/拼音标识'),
|
||||||
|
sa.Column('sort', sa.Integer(), nullable=False, server_default='0', comment='越小越靠前'),
|
||||||
|
sa.Column('status', mysql.TINYINT(), nullable=False, server_default='1', comment='1启用 0停用'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 存量数据全部归为「麻豆」
|
||||||
|
op.execute("INSERT INTO sources (name, code, sort, status) VALUES ('麻豆', 'madou', 1, 1)")
|
||||||
|
|
||||||
|
for table in ('videos', 'series', 'collection_sources'):
|
||||||
|
op.add_column(table, sa.Column('source_id', mysql.INTEGER(unsigned=True),
|
||||||
|
nullable=False, server_default='0', comment='内容来源 id'))
|
||||||
|
op.execute(f"UPDATE {table} SET source_id = (SELECT id FROM sources WHERE code = 'madou')")
|
||||||
|
|
||||||
|
op.create_index('idx_video_source', 'videos', ['source_id', 'status'])
|
||||||
|
op.create_index('idx_series_source', 'series', ['source_id', 'status'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('idx_series_source', table_name='series')
|
||||||
|
op.drop_index('idx_video_source', table_name='videos')
|
||||||
|
op.drop_column('collection_sources', 'source_id')
|
||||||
|
op.drop_column('series', 'source_id')
|
||||||
|
op.drop_column('videos', 'source_id')
|
||||||
|
op.drop_table('sources')
|
||||||
Binary file not shown.
+376
-23
@@ -66,11 +66,11 @@ async def list_pending_videos(page: int = Query(1, ge=1), page_size: int = Query
|
|||||||
|
|
||||||
@router.get("/videos")
|
@router.get("/videos")
|
||||||
async def list_videos(keyword: str | None = Query(None), status: int | None = Query(None),
|
async def list_videos(keyword: str | None = Query(None), status: int | None = Query(None),
|
||||||
author_id: int | None = Query(None),
|
author_id: int | None = Query(None), source_id: int | None = Query(None),
|
||||||
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
|
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
admin: AdminIdentity = Depends(require_super)):
|
admin: AdminIdentity = Depends(require_super)):
|
||||||
return ok(await admin_service.list_videos(db, keyword, status, author_id, page, page_size))
|
return ok(await admin_service.list_videos(db, keyword, status, author_id, page, page_size, source_id))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/videos/{video_id}/review")
|
@router.post("/videos/{video_id}/review")
|
||||||
@@ -90,17 +90,90 @@ async def offline_video(video_id: int, req: schema.ReasonReq, request: Request,
|
|||||||
return ok()
|
return ok()
|
||||||
|
|
||||||
|
|
||||||
|
class _VideoBatchOffline(_BM):
|
||||||
|
video_ids: list[int] = _Field(min_length=1, max_length=500)
|
||||||
|
reason: str = _Field(default="管理员批量下架", max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
# 批量路由须在 /videos/{video_id} 之前注册,否则 "batch-offline" 会被当成 video_id
|
||||||
|
@router.post("/videos/batch-offline")
|
||||||
|
async def batch_offline_videos(req: _VideoBatchOffline, request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
admin: AdminIdentity = Depends(require_super)):
|
||||||
|
"""批量下架:复用单条下架逻辑(通知作者、写日志、清详情缓存),跳过非已发布"""
|
||||||
|
success = 0
|
||||||
|
skipped: list[int] = []
|
||||||
|
for vid in req.video_ids:
|
||||||
|
try:
|
||||||
|
await admin_service.offline_video(
|
||||||
|
db, redis, admin, vid, req.reason, _ip(request))
|
||||||
|
success += 1
|
||||||
|
except BizException:
|
||||||
|
await db.rollback()
|
||||||
|
skipped.append(vid)
|
||||||
|
return ok({"success": success, "skipped": skipped})
|
||||||
|
|
||||||
|
|
||||||
class _VideoUpdateReq(_BM):
|
class _VideoUpdateReq(_BM):
|
||||||
title: str | None = _F(None, min_length=1, max_length=100)
|
title: str | None = _F(None, min_length=1, max_length=100)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
cover_url: str | None = None
|
cover_url: str | None = None
|
||||||
video_url: str | None = None
|
video_url: str | None = None
|
||||||
category_id: int | None = None
|
category_id: int | None = None
|
||||||
|
source_id: int | None = _F(None, ge=0)
|
||||||
series_id: int | None = None
|
series_id: int | None = None
|
||||||
episode_no: int | None = _F(None, ge=1)
|
episode_no: int | None = _F(None, ge=1)
|
||||||
status: int | None = _F(None, ge=0, le=5)
|
status: int | None = _F(None, ge=0, le=5)
|
||||||
|
|
||||||
|
|
||||||
|
class _VideoBatchUpdate(BaseModel):
|
||||||
|
video_ids: list[int] = _Field(min_length=1, max_length=500)
|
||||||
|
source_id: int | None = _Field(None, ge=0)
|
||||||
|
status: int | None = _Field(None, ge=0, le=5)
|
||||||
|
category_id: int | None = _Field(None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
# 批量路由须在 /videos/{video_id} 之前注册,否则 "batch" 会被当成 video_id
|
||||||
|
@router.put("/videos/batch")
|
||||||
|
async def batch_update_videos(req: _VideoBatchUpdate, request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminIdentity = Depends(require_super)):
|
||||||
|
"""批量编辑视频属性(来源 / 状态 / 分类)"""
|
||||||
|
from app.models.admin import AdminLog
|
||||||
|
from app.models.video import Video
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
if req.source_id is None and req.status is None and req.category_id is None:
|
||||||
|
raise BizException(errors.PARAM_ERROR, "至少要传一个要修改的字段")
|
||||||
|
|
||||||
|
rows = (await db.execute(select(Video).where(Video.id.in_(req.video_ids)))).scalars().all()
|
||||||
|
affected = 0
|
||||||
|
for v in rows:
|
||||||
|
changed = False
|
||||||
|
if req.source_id is not None and v.source_id != req.source_id:
|
||||||
|
v.source_id = req.source_id
|
||||||
|
changed = True
|
||||||
|
if req.status is not None and v.status != req.status:
|
||||||
|
v.status = req.status
|
||||||
|
changed = True
|
||||||
|
if req.category_id is not None and v.category_id != req.category_id:
|
||||||
|
v.category_id = req.category_id
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
affected += 1
|
||||||
|
|
||||||
|
db.add(AdminLog(
|
||||||
|
admin_id=admin.admin_id, action="video_batch_update",
|
||||||
|
target=f"videos:{len(req.video_ids)}",
|
||||||
|
detail=f"source_id={req.source_id} status={req.status} "
|
||||||
|
f"category_id={req.category_id} affected={affected}",
|
||||||
|
ip=_ip(request),
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
return ok({"affected": affected})
|
||||||
|
|
||||||
|
|
||||||
@router.put("/videos/{video_id}")
|
@router.put("/videos/{video_id}")
|
||||||
async def update_video(video_id: int, req: _VideoUpdateReq,
|
async def update_video(video_id: int, req: _VideoUpdateReq,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -332,6 +405,64 @@ async def list_users(keyword: str | None = Query(None), status: int | None = Que
|
|||||||
return ok(await admin_service.list_users(db, keyword, status, page, page_size))
|
return ok(await admin_service.list_users(db, keyword, status, page, page_size))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user-action-logs")
|
||||||
|
async def list_user_action_logs(user_id: int | None = Query(None),
|
||||||
|
action: str | None = Query(None),
|
||||||
|
keyword: str | None = Query(None),
|
||||||
|
start: str | None = Query(None),
|
||||||
|
end: str | None = Query(None),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminIdentity = Depends(require_super)):
|
||||||
|
"""用户操作日志查询(仅 super 可见)。用于排查特定用户 bug 时间线"""
|
||||||
|
from app.models.user_action_log import UserActionLog
|
||||||
|
from sqlalchemy import select, func, desc
|
||||||
|
|
||||||
|
conds = []
|
||||||
|
if user_id:
|
||||||
|
conds.append(UserActionLog.user_id == user_id)
|
||||||
|
if action:
|
||||||
|
conds.append(UserActionLog.action == action)
|
||||||
|
if keyword:
|
||||||
|
conds.append(UserActionLog.detail.like(f"%{keyword}%"))
|
||||||
|
if start:
|
||||||
|
conds.append(UserActionLog.created_at >= start)
|
||||||
|
if end:
|
||||||
|
conds.append(UserActionLog.created_at <= end + " 23:59:59")
|
||||||
|
|
||||||
|
total = (await db.execute(
|
||||||
|
select(func.count()).select_from(UserActionLog).where(*conds)
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(UserActionLog)
|
||||||
|
.where(*conds)
|
||||||
|
.order_by(desc(UserActionLog.id))
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)).scalars().all()
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"user_id": r.user_id,
|
||||||
|
"action": r.action,
|
||||||
|
"page": r.page,
|
||||||
|
"detail": r.detail,
|
||||||
|
"device_info": r.device_info,
|
||||||
|
"app_version": r.app_version,
|
||||||
|
"created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else "",
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users/{user_id}")
|
@router.get("/users/{user_id}")
|
||||||
async def get_user_detail(user_id: int, db: AsyncSession = Depends(get_db),
|
async def get_user_detail(user_id: int, db: AsyncSession = Depends(get_db),
|
||||||
admin: AdminIdentity = Depends(require_super)):
|
admin: AdminIdentity = Depends(require_super)):
|
||||||
@@ -557,18 +688,23 @@ async def delete_sensitive_word(word_id: int, request: Request, db: AsyncSession
|
|||||||
# ---------- 系列管理 ----------
|
# ---------- 系列管理 ----------
|
||||||
|
|
||||||
@router.get("/series")
|
@router.get("/series")
|
||||||
async def list_series(keyword: str = "", page: int = 1, page_size: int = 20,
|
async def list_series(keyword: str = "", source_id: int | None = None,
|
||||||
|
page: int = 1, page_size: int = 20,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
admin: AdminIdentity = Depends(require_operator)):
|
admin: AdminIdentity = Depends(require_operator)):
|
||||||
from app.models.video import Series
|
from app.models.video import Series
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
|
|
||||||
stmt = select(Series)
|
conds = []
|
||||||
if keyword:
|
if keyword:
|
||||||
stmt = stmt.where(Series.title.contains(keyword))
|
conds.append(Series.title.contains(keyword))
|
||||||
total = (await db.execute(select(func.count()).select_from(Series).where(
|
if source_id is not None:
|
||||||
Series.title.contains(keyword) if keyword else True
|
conds.append(Series.source_id == source_id)
|
||||||
))).scalar_one()
|
|
||||||
|
stmt = select(Series).where(*conds)
|
||||||
|
total = (await db.execute(
|
||||||
|
select(func.count()).select_from(Series).where(*conds)
|
||||||
|
)).scalar_one()
|
||||||
rows = (await db.execute(
|
rows = (await db.execute(
|
||||||
stmt.order_by(Series.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
stmt.order_by(Series.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
@@ -576,14 +712,16 @@ async def list_series(keyword: str = "", page: int = 1, page_size: int = 20,
|
|||||||
# 关联采集记录(标记采集来源,供"同步更新"按钮使用)
|
# 关联采集记录(标记采集来源,供"同步更新"按钮使用)
|
||||||
from app.models.video import CollectionRecord
|
from app.models.video import CollectionRecord
|
||||||
series_ids = [s.id for s in rows]
|
series_ids = [s.id for s in rows]
|
||||||
source_map: dict[int, int] = {}
|
collect_map: dict[int, int] = {}
|
||||||
if series_ids:
|
if series_ids:
|
||||||
recs = (await db.execute(
|
recs = (await db.execute(
|
||||||
select(CollectionRecord.series_id, CollectionRecord.source_id).where(
|
select(CollectionRecord.series_id, CollectionRecord.source_id).where(
|
||||||
CollectionRecord.series_id.in_(series_ids)
|
CollectionRecord.series_id.in_(series_ids)
|
||||||
)
|
)
|
||||||
)).all()
|
)).all()
|
||||||
source_map = {r.series_id: r.source_id for r in recs}
|
collect_map = {r.series_id: r.source_id for r in recs}
|
||||||
|
|
||||||
|
names = await admin_service.source_names_map(db, {s.source_id for s in rows})
|
||||||
|
|
||||||
return ok({
|
return ok({
|
||||||
"total": total,
|
"total": total,
|
||||||
@@ -591,13 +729,138 @@ async def list_series(keyword: str = "", page: int = 1, page_size: int = 20,
|
|||||||
"id": s.id, "title": s.title, "cover_url": s.cover_url,
|
"id": s.id, "title": s.title, "cover_url": s.cover_url,
|
||||||
"description": s.description or "",
|
"description": s.description or "",
|
||||||
"category_id": s.category_id or 0,
|
"category_id": s.category_id or 0,
|
||||||
|
"source_id": s.source_id or 0,
|
||||||
|
"source_name": names.get(s.source_id, ""),
|
||||||
|
"collect_source_id": collect_map.get(s.id),
|
||||||
"total_episodes": s.total_episodes, "status": s.status,
|
"total_episodes": s.total_episodes, "status": s.status,
|
||||||
"source_id": source_map.get(s.id),
|
|
||||||
"created_at": s.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
"created_at": s.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
} for s in rows],
|
} for s in rows],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 内容来源管理(麻豆 / 91 / ...)----------
|
||||||
|
|
||||||
|
class _ContentSourceCreate(BaseModel):
|
||||||
|
name: str = _Field(min_length=1, max_length=50)
|
||||||
|
code: str = ""
|
||||||
|
sort: int = 0
|
||||||
|
status: int = _Field(1, ge=0, le=1)
|
||||||
|
|
||||||
|
|
||||||
|
class _ContentSourceUpdate(BaseModel):
|
||||||
|
name: str | None = _Field(None, min_length=1, max_length=50)
|
||||||
|
code: str | None = None
|
||||||
|
sort: int | None = None
|
||||||
|
status: int | None = _Field(None, ge=0, le=1)
|
||||||
|
|
||||||
|
|
||||||
|
async def _content_source_counts(db: AsyncSession, ids: list[int]) -> tuple[dict[int, int], dict[int, int]]:
|
||||||
|
"""批量统计各来源下的视频数/系列数"""
|
||||||
|
from app.models.video import Series, Video
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
if not ids:
|
||||||
|
return {}, {}
|
||||||
|
v_rows = (await db.execute(
|
||||||
|
select(Video.source_id, func.count(Video.id))
|
||||||
|
.where(Video.source_id.in_(ids)).group_by(Video.source_id)
|
||||||
|
)).all()
|
||||||
|
s_rows = (await db.execute(
|
||||||
|
select(Series.source_id, func.count(Series.id))
|
||||||
|
.where(Series.source_id.in_(ids)).group_by(Series.source_id)
|
||||||
|
)).all()
|
||||||
|
return {r[0]: r[1] for r in v_rows}, {r[0]: r[1] for r in s_rows}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sources")
|
||||||
|
async def list_content_sources(db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminIdentity = Depends(require_operator)):
|
||||||
|
from app.models.video import Source
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
rows = (await db.execute(select(Source).order_by(Source.sort, Source.id))).scalars().all()
|
||||||
|
v_cnt, s_cnt = await _content_source_counts(db, [s.id for s in rows])
|
||||||
|
return ok({"list": [{
|
||||||
|
"id": s.id, "name": s.name, "code": s.code, "sort": s.sort, "status": s.status,
|
||||||
|
"video_count": v_cnt.get(s.id, 0), "series_count": s_cnt.get(s.id, 0),
|
||||||
|
} for s in rows]})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sources")
|
||||||
|
async def create_content_source(req: _ContentSourceCreate, request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminIdentity = Depends(require_super)):
|
||||||
|
from app.models.admin import AdminLog
|
||||||
|
from app.models.video import Source
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
name = req.name.strip()
|
||||||
|
exists = (await db.execute(select(Source.id).where(Source.name == name))).scalar_one_or_none()
|
||||||
|
if exists:
|
||||||
|
raise BizException(errors.PARAM_ERROR, "来源名称已存在")
|
||||||
|
s = Source(name=name, code=req.code.strip(), sort=req.sort, status=req.status)
|
||||||
|
db.add(s)
|
||||||
|
await db.flush()
|
||||||
|
db.add(AdminLog(admin_id=admin.admin_id, action="source_create",
|
||||||
|
target=f"source:{s.id}", detail=name, ip=_ip(request)))
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(s)
|
||||||
|
return ok({"id": s.id, "name": s.name, "code": s.code, "sort": s.sort, "status": s.status,
|
||||||
|
"video_count": 0, "series_count": 0})
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/sources/{source_id}")
|
||||||
|
async def update_content_source(source_id: int, req: _ContentSourceUpdate, request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminIdentity = Depends(require_super)):
|
||||||
|
from app.models.admin import AdminLog
|
||||||
|
from app.models.video import Source
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
s = await db.get(Source, source_id)
|
||||||
|
if not s:
|
||||||
|
raise BizException(errors.NOT_FOUND, "来源不存在")
|
||||||
|
d = req.model_dump(exclude_unset=True)
|
||||||
|
if "name" in d:
|
||||||
|
d["name"] = d["name"].strip()
|
||||||
|
dup = (await db.execute(
|
||||||
|
select(Source.id).where(Source.name == d["name"], Source.id != source_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if dup:
|
||||||
|
raise BizException(errors.PARAM_ERROR, "来源名称已存在")
|
||||||
|
for k, v in d.items():
|
||||||
|
setattr(s, k, v)
|
||||||
|
db.add(AdminLog(admin_id=admin.admin_id, action="source_update",
|
||||||
|
target=f"source:{source_id}", detail=str(d), ip=_ip(request)))
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(s)
|
||||||
|
return ok({"id": s.id, "name": s.name, "code": s.code, "sort": s.sort, "status": s.status})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/sources/{source_id}")
|
||||||
|
async def delete_content_source(source_id: int, request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminIdentity = Depends(require_super)):
|
||||||
|
from app.models.admin import AdminLog
|
||||||
|
from app.models.video import CollectionSource, Source
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
s = await db.get(Source, source_id)
|
||||||
|
if not s:
|
||||||
|
raise BizException(errors.NOT_FOUND, "来源不存在")
|
||||||
|
v_cnt, s_cnt = await _content_source_counts(db, [source_id])
|
||||||
|
if v_cnt.get(source_id, 0) or s_cnt.get(source_id, 0):
|
||||||
|
raise BizException(errors.PARAM_ERROR, "该来源下仍有视频或系列,不能删除(可改为停用)")
|
||||||
|
# 采集源解除绑定
|
||||||
|
await db.execute(update(CollectionSource).where(CollectionSource.source_id == source_id)
|
||||||
|
.values(source_id=0))
|
||||||
|
await db.delete(s)
|
||||||
|
db.add(AdminLog(admin_id=admin.admin_id, action="source_delete",
|
||||||
|
target=f"source:{source_id}", detail=s.name, ip=_ip(request)))
|
||||||
|
await db.commit()
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
|
||||||
# ---------- 系列管理 ----------
|
# ---------- 系列管理 ----------
|
||||||
|
|
||||||
|
|
||||||
@@ -606,6 +869,7 @@ class _SeriesCreate(BaseModel):
|
|||||||
cover_url: str = ""
|
cover_url: str = ""
|
||||||
description: str = ""
|
description: str = ""
|
||||||
category_id: int = 0
|
category_id: int = 0
|
||||||
|
source_id: int = 0
|
||||||
|
|
||||||
|
|
||||||
class _SeriesUpdate(BaseModel):
|
class _SeriesUpdate(BaseModel):
|
||||||
@@ -613,6 +877,7 @@ class _SeriesUpdate(BaseModel):
|
|||||||
cover_url: str | None = None
|
cover_url: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
category_id: int | None = None
|
category_id: int | None = None
|
||||||
|
source_id: int | None = None
|
||||||
status: int | None = _Field(None, ge=0, le=1)
|
status: int | None = _Field(None, ge=0, le=1)
|
||||||
|
|
||||||
|
|
||||||
@@ -654,18 +919,19 @@ class _SeriesBatchUpdate(BaseModel):
|
|||||||
series_ids: list[int] = _Field(min_length=1, max_length=200)
|
series_ids: list[int] = _Field(min_length=1, max_length=200)
|
||||||
status: int | None = _Field(None, ge=0, le=1)
|
status: int | None = _Field(None, ge=0, le=1)
|
||||||
category_id: int | None = _Field(None, ge=0)
|
category_id: int | None = _Field(None, ge=0)
|
||||||
|
source_id: int | None = _Field(None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/series/batch")
|
@router.put("/series/batch")
|
||||||
async def batch_update_series(req: _SeriesBatchUpdate, request: Request,
|
async def batch_update_series(req: _SeriesBatchUpdate, request: Request,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
admin: AdminIdentity = Depends(require_super)):
|
admin: AdminIdentity = Depends(require_super)):
|
||||||
"""批量编辑系列属性(当前支持 status / category_id)"""
|
"""批量编辑系列属性(当前支持 status / category_id / source_id)"""
|
||||||
from app.models.admin import AdminLog
|
from app.models.admin import AdminLog
|
||||||
from app.models.video import Series
|
from app.models.video import Series
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
if req.status is None and req.category_id is None:
|
if req.status is None and req.category_id is None and req.source_id is None:
|
||||||
raise BizException(errors.PARAM_ERROR, "至少要传一个要修改的字段")
|
raise BizException(errors.PARAM_ERROR, "至少要传一个要修改的字段")
|
||||||
|
|
||||||
stmt = select(Series).where(Series.id.in_(req.series_ids))
|
stmt = select(Series).where(Series.id.in_(req.series_ids))
|
||||||
@@ -679,13 +945,17 @@ async def batch_update_series(req: _SeriesBatchUpdate, request: Request,
|
|||||||
if req.category_id is not None and s.category_id != req.category_id:
|
if req.category_id is not None and s.category_id != req.category_id:
|
||||||
s.category_id = req.category_id
|
s.category_id = req.category_id
|
||||||
changed = True
|
changed = True
|
||||||
|
if req.source_id is not None and s.source_id != req.source_id:
|
||||||
|
s.source_id = req.source_id
|
||||||
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
affected += 1
|
affected += 1
|
||||||
|
|
||||||
db.add(AdminLog(
|
db.add(AdminLog(
|
||||||
admin_id=admin.admin_id, action="series_batch_update",
|
admin_id=admin.admin_id, action="series_batch_update",
|
||||||
target=f"series:{','.join(str(i) for i in req.series_ids)}",
|
target=f"series:{','.join(str(i) for i in req.series_ids)}",
|
||||||
detail=f"status={req.status} category_id={req.category_id} affected={affected}",
|
detail=f"status={req.status} category_id={req.category_id} "
|
||||||
|
f"source_id={req.source_id} affected={affected}",
|
||||||
ip=_ip(request),
|
ip=_ip(request),
|
||||||
))
|
))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -698,7 +968,7 @@ async def create_series(req: _SeriesCreate, db: AsyncSession = Depends(get_db),
|
|||||||
from app.models.video import Series
|
from app.models.video import Series
|
||||||
s = Series(
|
s = Series(
|
||||||
title=req.title, cover_url=req.cover_url, description=req.description,
|
title=req.title, cover_url=req.cover_url, description=req.description,
|
||||||
category_id=req.category_id, status=1,
|
category_id=req.category_id, source_id=req.source_id, status=1,
|
||||||
)
|
)
|
||||||
db.add(s)
|
db.add(s)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -834,19 +1104,40 @@ async def collect_sync_all(req: _CollectSyncAllReq, request: Request,
|
|||||||
|
|
||||||
class _SourceCreate(BaseModel):
|
class _SourceCreate(BaseModel):
|
||||||
name: str = _Field(min_length=1, max_length=50)
|
name: str = _Field(min_length=1, max_length=50)
|
||||||
|
kind: str = _Field("url", pattern="^(url|aidrama91|aidrama91_vlog)$")
|
||||||
list_url: str = _Field(min_length=10, max_length=500)
|
list_url: str = _Field(min_length=10, max_length=500)
|
||||||
|
source_id: int = 0
|
||||||
|
account: str = "" # aidrama91:源站登录账号
|
||||||
|
password: str = "" # aidrama91:源站登录密码
|
||||||
|
category_ids: list[int] = _Field(default_factory=lambda: [4]) # aidrama91:源站分类
|
||||||
|
page_size: int = _Field(20, ge=1, le=20)
|
||||||
|
|
||||||
|
|
||||||
class _SourceUpdate(BaseModel):
|
class _SourceUpdate(BaseModel):
|
||||||
name: str | None = _Field(None, min_length=1, max_length=50)
|
name: str | None = _Field(None, min_length=1, max_length=50)
|
||||||
list_url: str | None = _Field(None, min_length=10, max_length=500)
|
list_url: str | None = _Field(None, min_length=10, max_length=500)
|
||||||
|
source_id: int | None = None
|
||||||
status: int | None = _Field(None, ge=0, le=1)
|
status: int | None = _Field(None, ge=0, le=1)
|
||||||
|
account: str | None = None
|
||||||
|
password: str | None = None
|
||||||
|
category_ids: list[int] | None = None
|
||||||
|
page_size: int | None = _Field(None, ge=1, le=20)
|
||||||
|
|
||||||
|
|
||||||
def _source_dict(s) -> dict:
|
def _source_dict(s) -> dict:
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
try:
|
||||||
|
cfg = _json.loads(s.config or "{}")
|
||||||
|
except Exception:
|
||||||
|
cfg = {}
|
||||||
return {
|
return {
|
||||||
"id": s.id, "name": s.name, "list_url": s.list_url,
|
"id": s.id, "name": s.name, "kind": s.kind or "url", "list_url": s.list_url,
|
||||||
"category_id": s.category_id, "status": s.status,
|
"category_id": s.category_id, "source_id": s.source_id or 0, "status": s.status,
|
||||||
|
"account": cfg.get("account", ""),
|
||||||
|
"category_ids": cfg.get("category_ids") or [],
|
||||||
|
"page_size": cfg.get("page_size") or 20,
|
||||||
|
"has_token": bool(cfg.get("token")),
|
||||||
"last_sync_at": s.last_sync_at.strftime("%Y-%m-%d %H:%M:%S") if s.last_sync_at else "",
|
"last_sync_at": s.last_sync_at.strftime("%Y-%m-%d %H:%M:%S") if s.last_sync_at else "",
|
||||||
"created_at": s.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
"created_at": s.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
}
|
}
|
||||||
@@ -881,18 +1172,34 @@ async def list_sources(db: AsyncSession = Depends(get_db),
|
|||||||
async def create_source(req: _SourceCreate, request: Request, db: AsyncSession = Depends(get_db),
|
async def create_source(req: _SourceCreate, request: Request, db: AsyncSession = Depends(get_db),
|
||||||
admin: AdminIdentity = Depends(require_operator)):
|
admin: AdminIdentity = Depends(require_operator)):
|
||||||
"""添加采集源:自动按名称建/找分类"""
|
"""添加采集源:自动按名称建/找分类"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
from app.models.admin import AdminLog
|
from app.models.admin import AdminLog
|
||||||
from app.models.video import CollectionSource
|
from app.models.video import CollectionSource
|
||||||
|
from app.services import aidrama91_service
|
||||||
|
|
||||||
if "{{page}}" not in req.list_url:
|
if req.kind == "url" and "{{page}}" not in req.list_url:
|
||||||
raise BizException(errors.PARAM_ERROR, "URL 需包含 {{page}} 页码占位符")
|
raise BizException(errors.PARAM_ERROR, "URL 需包含 {{page}} 页码占位符")
|
||||||
|
if req.kind in ("aidrama91", "aidrama91_vlog") and not (req.account and req.password):
|
||||||
|
raise BizException(errors.PARAM_ERROR, "91 源需填写源站账号和密码")
|
||||||
|
|
||||||
cid = await _resolve_category(db, req.name.strip())
|
cid = await _resolve_category(db, req.name.strip())
|
||||||
s = CollectionSource(name=req.name.strip(), list_url=req.list_url.strip(), category_id=cid)
|
cfg = {}
|
||||||
|
if req.kind in ("aidrama91", "aidrama91_vlog"):
|
||||||
|
cfg = {"account": req.account, "password": req.password,
|
||||||
|
"category_ids": req.category_ids or [4], "page_size": req.page_size}
|
||||||
|
s = CollectionSource(name=req.name.strip(), kind=req.kind, list_url=req.list_url.strip(),
|
||||||
|
config=_json.dumps(cfg, ensure_ascii=False),
|
||||||
|
category_id=cid, source_id=req.source_id)
|
||||||
|
if req.kind in ("aidrama91", "aidrama91_vlog"):
|
||||||
|
s.source_id = await aidrama91_service.refresh_source_id(db, s)
|
||||||
|
if req.kind == "aidrama91_vlog":
|
||||||
|
s.category_id = await aidrama91_service.ensure_local_category(
|
||||||
|
db, aidrama91_service.VLOG_CATEGORY_NAME)
|
||||||
db.add(s)
|
db.add(s)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
db.add(AdminLog(admin_id=admin.admin_id, action="collection_source_create",
|
db.add(AdminLog(admin_id=admin.admin_id, action="collection_source_create",
|
||||||
target=f"source:{s.id}", detail=f"{s.name} {s.list_url}", ip=_ip(request)))
|
target=f"source:{s.id}", detail=f"{s.name} {s.kind} {s.list_url}", ip=_ip(request)))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(s)
|
await db.refresh(s)
|
||||||
return ok(_source_dict(s))
|
return ok(_source_dict(s))
|
||||||
@@ -901,17 +1208,28 @@ async def create_source(req: _SourceCreate, request: Request, db: AsyncSession =
|
|||||||
@router.put("/collection/sources/{source_id}")
|
@router.put("/collection/sources/{source_id}")
|
||||||
async def update_source(source_id: int, req: _SourceUpdate, db: AsyncSession = Depends(get_db),
|
async def update_source(source_id: int, req: _SourceUpdate, db: AsyncSession = Depends(get_db),
|
||||||
admin: AdminIdentity = Depends(require_operator)):
|
admin: AdminIdentity = Depends(require_operator)):
|
||||||
|
import json as _json
|
||||||
|
|
||||||
from app.models.video import CollectionSource
|
from app.models.video import CollectionSource
|
||||||
|
|
||||||
s = await db.get(CollectionSource, source_id)
|
s = await db.get(CollectionSource, source_id)
|
||||||
if not s:
|
if not s:
|
||||||
raise BizException(errors.NOT_FOUND, "采集源不存在")
|
raise BizException(errors.NOT_FOUND, "采集源不存在")
|
||||||
d = req.model_dump(exclude_unset=True)
|
d = req.model_dump(exclude_unset=True)
|
||||||
if "list_url" in d and "{{page}}" not in d["list_url"]:
|
if "list_url" in d and (s.kind or "url") == "url" and "{{page}}" not in d["list_url"]:
|
||||||
raise BizException(errors.PARAM_ERROR, "URL 需包含 {{page}} 页码占位符")
|
raise BizException(errors.PARAM_ERROR, "URL 需包含 {{page}} 页码占位符")
|
||||||
if "name" in d:
|
if "name" in d:
|
||||||
d["category_id"] = await _resolve_category(db, d["name"].strip())
|
d["category_id"] = await _resolve_category(db, d["name"].strip())
|
||||||
d["name"] = d["name"].strip()
|
d["name"] = d["name"].strip()
|
||||||
|
|
||||||
|
# 接口类源的账号/密码/分类等写入 config
|
||||||
|
cfg_keys = ("account", "password", "category_ids", "page_size")
|
||||||
|
cfg = _json.loads(s.config or "{}")
|
||||||
|
for k in cfg_keys:
|
||||||
|
if k in d:
|
||||||
|
cfg[k] = d.pop(k)
|
||||||
|
s.config = _json.dumps(cfg, ensure_ascii=False)
|
||||||
|
|
||||||
for k, v in d.items():
|
for k, v in d.items():
|
||||||
setattr(s, k, v)
|
setattr(s, k, v)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -991,23 +1309,42 @@ async def batch_create_sources(req: _SourceBatchReq, request: Request, db: Async
|
|||||||
|
|
||||||
|
|
||||||
_sync_running_sources: set[int] = set()
|
_sync_running_sources: set[int] = set()
|
||||||
|
_sync_cancelled_sources: set[int] = set()
|
||||||
|
|
||||||
|
|
||||||
async def _run_source_sync(source_id: int, max_pages: int) -> None:
|
async def _run_source_sync(source_id: int, max_pages: int) -> None:
|
||||||
from app.database import SessionFactory
|
from app.database import SessionFactory
|
||||||
from app.models.video import CollectionSource
|
from app.models.video import CollectionSource
|
||||||
from app.services import collection_service
|
from app.services import aidrama91_service, collection_service
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with SessionFactory() as s:
|
async with SessionFactory() as s:
|
||||||
source = await s.get(CollectionSource, source_id)
|
source = await s.get(CollectionSource, source_id)
|
||||||
if source:
|
if source:
|
||||||
stat = await collection_service.sync_source(s, source, max_pages=max_pages)
|
# 传入 should_cancel 回调,循环里每页/每条检查一次
|
||||||
|
def _cancelled() -> bool:
|
||||||
|
return source_id in _sync_cancelled_sources
|
||||||
|
|
||||||
|
if source.kind == aidrama91_service.KIND:
|
||||||
|
await aidrama91_service.refresh_source_id(s, source)
|
||||||
|
stat = await aidrama91_service.sync_source(
|
||||||
|
s, source, max_pages=max_pages, should_cancel=_cancelled)
|
||||||
|
elif source.kind == aidrama91_service.VLOG_KIND:
|
||||||
|
await aidrama91_service.refresh_source_id(s, source)
|
||||||
|
stat = await aidrama91_service.sync_vlog_source(
|
||||||
|
s, source, max_pages=max_pages, should_cancel=_cancelled)
|
||||||
|
else:
|
||||||
|
stat = await collection_service.sync_source(
|
||||||
|
s, source, max_pages=max_pages, should_cancel=_cancelled)
|
||||||
|
if source_id in _sync_cancelled_sources:
|
||||||
|
logger.info("采集源 %s 同步被取消: %s", source_id, stat)
|
||||||
|
else:
|
||||||
logger.info("采集源 %s 同步完成: %s", source_id, stat)
|
logger.info("采集源 %s 同步完成: %s", source_id, stat)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("采集源 %s 同步失败", source_id)
|
logger.exception("采集源 %s 同步失败", source_id)
|
||||||
finally:
|
finally:
|
||||||
_sync_running_sources.discard(source_id)
|
_sync_running_sources.discard(source_id)
|
||||||
|
_sync_cancelled_sources.discard(source_id)
|
||||||
|
|
||||||
|
|
||||||
class _SourceSyncReq(BaseModel):
|
class _SourceSyncReq(BaseModel):
|
||||||
@@ -1063,6 +1400,22 @@ async def sync_all_sources(request: Request, db: AsyncSession = Depends(get_db),
|
|||||||
return ok({"started": started, "skipped_running": [i for i in ids if i not in started]})
|
return ok({"started": started, "skipped_running": [i for i in ids if i not in started]})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/collection/sources/{source_id}/sync/cancel")
|
||||||
|
async def cancel_source_sync(source_id: int, request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminIdentity = Depends(require_operator)):
|
||||||
|
"""请求取消某个采集源的后台同步:仅打个标记,正在执行的任务在下个分页边界自然退出"""
|
||||||
|
from app.models.admin import AdminLog
|
||||||
|
|
||||||
|
if source_id not in _sync_running_sources:
|
||||||
|
raise BizException(errors.NOT_FOUND, "该采集源当前没有正在运行的同步任务")
|
||||||
|
_sync_cancelled_sources.add(source_id)
|
||||||
|
db.add(AdminLog(admin_id=admin.admin_id, action="collection_source_sync_cancel",
|
||||||
|
target=f"source:{source_id}", detail="", ip=_ip(request)))
|
||||||
|
await db.commit()
|
||||||
|
return ok({"cancel_requested": True, "source_id": source_id})
|
||||||
|
|
||||||
|
|
||||||
# ---------- 分类管理 ----------
|
# ---------- 分类管理 ----------
|
||||||
|
|
||||||
from pydantic import BaseModel as _BM
|
from pydantic import BaseModel as _BM
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""用户操作日志上报:仅特定账号(如 256kb@gmail.com)开启,用于排查 bug"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.deps import Identity, require_user
|
||||||
|
from app.core import errors
|
||||||
|
from app.core.response import BizException, ok
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.user_action_log import UserActionLog
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# 仅这些邮箱的用户允许上报操作日志(白名单,避免给所有用户上传)
|
||||||
|
ALLOWED_EMAILS = {"256kb@gmail.com"}
|
||||||
|
|
||||||
|
# 单条 detail 最大长度(防御性截断)
|
||||||
|
MAX_DETAIL_LEN = 800
|
||||||
|
# 单次批量最大条数
|
||||||
|
MAX_BATCH = 100
|
||||||
|
|
||||||
|
|
||||||
|
class ActionLogItem(BaseModel):
|
||||||
|
action: str = Field(..., max_length=50)
|
||||||
|
page: str = Field("", max_length=100)
|
||||||
|
detail: str = Field("", max_length=MAX_DETAIL_LEN)
|
||||||
|
device_info: str = Field("", max_length=200)
|
||||||
|
app_version: str = Field("", max_length=20)
|
||||||
|
# 客户端本地时间戳(毫秒),用于在服务端时钟漂移时仍能排序
|
||||||
|
client_ts: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ActionLogBatch(BaseModel):
|
||||||
|
items: list[ActionLogItem] = Field(..., max_length=MAX_BATCH)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logs/action")
|
||||||
|
async def report_action_log(
|
||||||
|
body: ActionLogBatch,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
identity: Identity = Depends(require_user),
|
||||||
|
):
|
||||||
|
"""接收 App 上报的用户操作日志。仅白名单邮箱可上报,其他账号返回 403 不上报"""
|
||||||
|
user = (await db.execute(
|
||||||
|
select(User).where(User.id == identity.user_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if not user or user.email not in ALLOWED_EMAILS:
|
||||||
|
# 静默拒绝:不报错,不写入,避免在普通用户端暴露此通道存在
|
||||||
|
raise BizException(errors.FORBIDDEN, "forbidden")
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
UserActionLog(
|
||||||
|
user_id=user.id,
|
||||||
|
action=item.action[:50],
|
||||||
|
page=item.page[:100],
|
||||||
|
detail=item.detail[:MAX_DETAIL_LEN],
|
||||||
|
device_info=item.device_info[:200],
|
||||||
|
app_version=item.app_version[:20],
|
||||||
|
)
|
||||||
|
for item in body.items
|
||||||
|
]
|
||||||
|
db.add_all(rows)
|
||||||
|
await db.commit()
|
||||||
|
logger.info(
|
||||||
|
"user_action_log user=%s count=%s ip=%s",
|
||||||
|
user.id, len(rows), request.client.host if request.client else "-",
|
||||||
|
)
|
||||||
|
return ok({"received": len(rows)})
|
||||||
+106
-2
@@ -1,14 +1,25 @@
|
|||||||
"""媒体代理:m3u8 播放列表转发(源站 playlist 内 TS 链接带时效签名,每次回源拉取新鲜版本)"""
|
"""媒体代理
|
||||||
|
|
||||||
|
- /m3u8/proxy :源站路径转发(playlist 内 TS 链接带时效签名,每次回源拉新鲜版本)
|
||||||
|
- /m3u8/aidrama :91 短剧单集播放列表(上游 m3u8 地址 4 小时过期,播放时现取)
|
||||||
|
- /img/aidrama :91 封面图解密代理(CDN 返回 AES 加密图片字节,代理解密后输出)
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Query, Response
|
from fastapi import APIRouter, Depends, Query, Response
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.errors import NOT_FOUND, PARAM_ERROR
|
from app.core.errors import NOT_FOUND, PARAM_ERROR
|
||||||
from app.core.response import BizException
|
from app.core.response import BizException
|
||||||
|
from app.database import get_db
|
||||||
from app.services.collection_service import HEADERS, UPSTREAM
|
from app.services.collection_service import HEADERS, UPSTREAM
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/m3u8", tags=["媒体"])
|
router = APIRouter(prefix="/m3u8", tags=["媒体"])
|
||||||
|
img_router = APIRouter(prefix="/img", tags=["媒体"])
|
||||||
|
|
||||||
# 只允许相对资源路径,防 SSRF
|
# 只允许相对资源路径,防 SSRF
|
||||||
_PATH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9/._-]*$")
|
_PATH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9/._-]*$")
|
||||||
@@ -32,3 +43,96 @@ async def m3u8_proxy(path: str = Query(min_length=1, max_length=300)):
|
|||||||
media_type="application/vnd.apple.mpegurl",
|
media_type="application/vnd.apple.mpegurl",
|
||||||
headers={"Cache-Control": "no-cache", "Access-Control-Allow-Origin": "*"},
|
headers={"Cache-Control": "no-cache", "Access-Control-Allow-Origin": "*"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/aidrama")
|
||||||
|
async def aidrama_playlist(
|
||||||
|
d: int = Query(gt=0, description="源站短剧 id"),
|
||||||
|
e: int = Query(gt=0, description="源站集 id"),
|
||||||
|
n: int | None = Query(None, ge=1, description="集号(用于定位分页)"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""91 短剧播放列表:上游每次播放重新签发 TS 地址,避免存下来的签名过期"""
|
||||||
|
from app.services import aidrama91_service
|
||||||
|
|
||||||
|
try:
|
||||||
|
source = await aidrama91_service.get_collection_source(db)
|
||||||
|
text = await aidrama91_service.fetch_playlist(source, d, e, n)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("91 播放列表获取失败 drama=%s ep=%s", d, e)
|
||||||
|
raise BizException(NOT_FOUND, "播放列表获取失败")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=text,
|
||||||
|
media_type="application/vnd.apple.mpegurl",
|
||||||
|
headers={"Cache-Control": "no-cache", "Access-Control-Allow-Origin": "*"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vlog")
|
||||||
|
async def vlog_playlist(
|
||||||
|
vid: int = Query(gt=0, description="源站短视频 id"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""91 短视频播放列表:playUrl 带时效签名,播放时现取"""
|
||||||
|
from app.services import aidrama91_service
|
||||||
|
|
||||||
|
try:
|
||||||
|
source = await aidrama91_service.get_vlog_source(db)
|
||||||
|
text = await aidrama91_service.fetch_vlog_playlist(source, vid)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("91 vlog 播放列表获取失败 vid=%s", vid)
|
||||||
|
raise BizException(NOT_FOUND, "播放列表获取失败")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=text,
|
||||||
|
media_type="application/vnd.apple.mpegurl",
|
||||||
|
headers={"Cache-Control": "no-cache", "Access-Control-Allow-Origin": "*"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@img_router.get("/aidrama", include_in_schema=False)
|
||||||
|
async def aidrama_image(u: str = Query(min_length=10, max_length=600)):
|
||||||
|
"""91 封面解密代理:CDN 图为 AES-128-CBC 加密字节,解密后输出真实图片"""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from app.services import aidrama91_service
|
||||||
|
|
||||||
|
parsed = urlparse(u)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||||
|
raise BizException(PARAM_ERROR, "非法图片地址")
|
||||||
|
host = parsed.hostname.lower()
|
||||||
|
if not (host == aidrama91_service.IMG_HOST_SUFFIX.lstrip(".")
|
||||||
|
or host.endswith(aidrama91_service.IMG_HOST_SUFFIX)):
|
||||||
|
raise BizException(PARAM_ERROR, "图片域名不在白名单")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
resp = await client.get(u, headers={
|
||||||
|
"User-Agent": "Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
|
||||||
|
}, timeout=20)
|
||||||
|
if resp.status_code != 200 or not resp.content:
|
||||||
|
raise BizException(NOT_FOUND, "图片获取失败")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = aidrama91_service.decrypt_image(resp.content)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("91 封面解密失败 url=%s", u[:120])
|
||||||
|
raise BizException(NOT_FOUND, "图片解密失败")
|
||||||
|
|
||||||
|
# 识别真实格式(密文无 padding,尾部可能有 1 字节冗余,浏览器可正常渲染)
|
||||||
|
if data[:3] == b"\xff\xd8\xff":
|
||||||
|
mime = "image/jpeg"
|
||||||
|
elif data[:8] == b"\x89PNG\r\n\x1a\n":
|
||||||
|
mime = "image/png"
|
||||||
|
elif data[:6] in (b"GIF87a", b"GIF89a"):
|
||||||
|
mime = "image/gif"
|
||||||
|
else:
|
||||||
|
raise BizException(NOT_FOUND, "图片解密异常")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=data,
|
||||||
|
media_type=mime,
|
||||||
|
headers={"Cache-Control": "public, max-age=86400",
|
||||||
|
"Access-Control-Allow-Origin": "*"},
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"""系统维护:数据库备份 / 恢复(仅 super 角色)
|
||||||
|
|
||||||
|
实现要点:
|
||||||
|
- 备份:mysqldump 全库导出 → gzip 压缩 → 存到 runtime/backups/
|
||||||
|
- 恢复:上传 .sql.gz → gunzip → mysql 导入。**高危操作**,调用前需前端二次确认
|
||||||
|
- 所有文件操作限定在 backups/ 目录内,文件名做白名单校验防路径穿越
|
||||||
|
- 下载/恢复端点均验证 admin 身份,避免泄露数据库内容
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import gzip
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, UploadFile, File
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.core import errors
|
||||||
|
from app.core.response import ok, BizException
|
||||||
|
from app.deps import AdminIdentity, require_roles
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/system", tags=["管理后台-系统"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
require_super = require_roles()
|
||||||
|
|
||||||
|
# 备份目录(项目 runtime/backups/,与 uploads/videos 平级)
|
||||||
|
BACKUP_DIR = Path("/root/project/video/runtime/backups")
|
||||||
|
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 备份文件名白名单:video_backup_YYYYMMDD_HHMMSS.sql.gz
|
||||||
|
_FILENAME_RE = re.compile(r"^video_backup_\d{8}_\d{6}\.sql\.gz$")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_db_url() -> dict:
|
||||||
|
"""从 settings.database_url 解析 mysqldump 需要的连接参数"""
|
||||||
|
url = get_settings().database_url
|
||||||
|
# mysql+aiomysql://user:pass@host:port/dbname?charset=utf8mb4
|
||||||
|
p = urlparse(url)
|
||||||
|
return {
|
||||||
|
"host": p.hostname or "127.0.0.1",
|
||||||
|
"port": p.port or 3306,
|
||||||
|
"user": p.username or "root",
|
||||||
|
"password": p.password or "",
|
||||||
|
"database": p.path.lstrip("/").split("?")[0],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_filename(filename: str) -> Path:
|
||||||
|
"""校验文件名合法性,返回完整路径。任何非白名单字符直接拒绝"""
|
||||||
|
if not _FILENAME_RE.match(filename):
|
||||||
|
raise BizException(errors.INVALID_PARAMS, "非法的备份文件名")
|
||||||
|
path = BACKUP_DIR / filename
|
||||||
|
# resolve 后必须仍在 backups 目录内(防符号链接逃逸)
|
||||||
|
if not str(path.resolve()).startswith(str(BACKUP_DIR.resolve())):
|
||||||
|
raise BizException(errors.INVALID_PARAMS, "非法的备份文件路径")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/backups")
|
||||||
|
async def list_backups(admin: AdminIdentity = Depends(require_super)):
|
||||||
|
"""列出所有备份文件(按修改时间倒序)"""
|
||||||
|
items = []
|
||||||
|
for f in BACKUP_DIR.glob("video_backup_*.sql.gz"):
|
||||||
|
if not _FILENAME_RE.match(f.name):
|
||||||
|
continue
|
||||||
|
stat = f.stat()
|
||||||
|
items.append({
|
||||||
|
"filename": f.name,
|
||||||
|
"size": stat.st_size,
|
||||||
|
"created_at": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
})
|
||||||
|
items.sort(key=lambda x: x["filename"], reverse=True)
|
||||||
|
return ok({"items": items, "total": len(items)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/backup")
|
||||||
|
async def create_backup(admin: AdminIdentity = Depends(require_super)):
|
||||||
|
"""触发一次备份:mysqldump → gzip。同步等待完成(一般几秒到几十秒)"""
|
||||||
|
db = _parse_db_url()
|
||||||
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"video_backup_{ts}.sql.gz"
|
||||||
|
target = BACKUP_DIR / filename
|
||||||
|
|
||||||
|
# mysqldump 通过 stdin 传密码避免命令行泄露(MYSQL_PWD 环境变量也可,但子进程隔离更稳)
|
||||||
|
cmd = [
|
||||||
|
"mysqldump",
|
||||||
|
f"--host={db['host']}",
|
||||||
|
f"--port={db['port']}",
|
||||||
|
f"--user={db['user']}",
|
||||||
|
"--single-transaction", # InnoDB 一致性快照,不锁表
|
||||||
|
"--quick", # 大表逐行读取,避免内存撑爆
|
||||||
|
"--default-character-set=utf8mb4",
|
||||||
|
"--routines", "--triggers",
|
||||||
|
db["database"],
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc_dump = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env={"MYSQL_PWD": db["password"], "PATH": "/usr/bin:/bin"},
|
||||||
|
)
|
||||||
|
# 用 gzip 流式压缩写入
|
||||||
|
with open(target, "wb") as fout:
|
||||||
|
with gzip.GzipFile(fileobj=fout, mode="wb", compresslevel=6) as gz:
|
||||||
|
while True:
|
||||||
|
chunk = await proc_dump.stdout.read(1 << 20) # 1MB
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
gz.write(chunk)
|
||||||
|
stderr = await proc_dump.stderr.read()
|
||||||
|
await proc_dump.wait()
|
||||||
|
|
||||||
|
if proc_dump.returncode != 0:
|
||||||
|
target.unlink(missing_ok=True)
|
||||||
|
err_msg = stderr.decode("utf-8", errors="ignore")[:500]
|
||||||
|
logger.error(f"mysqldump failed: {err_msg}")
|
||||||
|
raise BizException(errors.SERVER_ERROR, f"备份失败:{err_msg}")
|
||||||
|
|
||||||
|
size = target.stat().st_size
|
||||||
|
if size < 100: # 异常小文件视为失败
|
||||||
|
target.unlink(missing_ok=True)
|
||||||
|
raise BizException(errors.SERVER_ERROR, "备份文件异常(过小)")
|
||||||
|
|
||||||
|
logger.info(f"DB backup created: {filename} ({size} bytes) by admin#{admin.admin_id}")
|
||||||
|
return ok({"filename": filename, "size": size})
|
||||||
|
except BizException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
target.unlink(missing_ok=True)
|
||||||
|
logger.exception("backup failed")
|
||||||
|
raise BizException(errors.SERVER_ERROR, f"备份异常:{e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/backups/{filename}/download")
|
||||||
|
async def download_backup(filename: str, admin: AdminIdentity = Depends(require_super)):
|
||||||
|
"""下载备份文件"""
|
||||||
|
path = _validate_filename(filename)
|
||||||
|
if not path.exists():
|
||||||
|
raise BizException(errors.NOT_FOUND, "备份文件不存在")
|
||||||
|
return FileResponse(
|
||||||
|
path,
|
||||||
|
media_type="application/gzip",
|
||||||
|
filename=filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/backups/{filename}")
|
||||||
|
async def delete_backup(filename: str, admin: AdminIdentity = Depends(require_super)):
|
||||||
|
"""删除指定备份"""
|
||||||
|
path = _validate_filename(filename)
|
||||||
|
if not path.exists():
|
||||||
|
raise BizException(errors.NOT_FOUND, "备份文件不存在")
|
||||||
|
path.unlink()
|
||||||
|
logger.info(f"DB backup deleted: {filename} by admin#{admin.admin_id}")
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/restore")
|
||||||
|
async def restore_backup(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
admin: AdminIdentity = Depends(require_super),
|
||||||
|
):
|
||||||
|
"""上传 .sql.gz 并导入数据库(覆盖现有数据,高危操作)。
|
||||||
|
|
||||||
|
流程:
|
||||||
|
1. 接收上传文件,暂存到 backups/_restore_tmp.sql.gz
|
||||||
|
2. gunzip 解压 + mysql 导入(事务由 dump 内的 SET 语句保证)
|
||||||
|
3. 无论成败清理临时文件
|
||||||
|
"""
|
||||||
|
if not file.filename or not file.filename.endswith(".sql.gz"):
|
||||||
|
raise BizException(errors.INVALID_PARAMS, "仅支持 .sql.gz 格式")
|
||||||
|
|
||||||
|
db = _parse_db_url()
|
||||||
|
tmp_path = BACKUP_DIR / "_restore_tmp.sql.gz"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 限制 500MB
|
||||||
|
size = 0
|
||||||
|
with open(tmp_path, "wb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(1 << 20)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
size += len(chunk)
|
||||||
|
if size > 500 * 1024 * 1024:
|
||||||
|
raise BizException(errors.INVALID_PARAMS, "备份文件过大(>500MB)")
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
# 解压 + 导入(用 zcat 管道到 mysql,避免中间落地大 SQL 文件)
|
||||||
|
# gunzip -c file.sql.gz | mysql -h.. -u.. dbname
|
||||||
|
zcat_cmd = ["gunzip", "-c", str(tmp_path)]
|
||||||
|
mysql_cmd = [
|
||||||
|
"mysql",
|
||||||
|
f"--host={db['host']}",
|
||||||
|
f"--port={db['port']}",
|
||||||
|
f"--user={db['user']}",
|
||||||
|
"--default-character-set=utf8mb4",
|
||||||
|
db["database"],
|
||||||
|
]
|
||||||
|
|
||||||
|
proc_zcat = await asyncio.create_subprocess_exec(
|
||||||
|
*zcat_cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
proc_mysql = await asyncio.create_subprocess_exec(
|
||||||
|
*mysql_cmd,
|
||||||
|
stdin=proc_zcat.stdout,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env={"MYSQL_PWD": db["password"], "PATH": "/usr/bin:/bin"},
|
||||||
|
)
|
||||||
|
# zcat.stdout 已被 mysql 消费,避免 zcat 因管道堵塞而无法退出
|
||||||
|
if proc_zcat.stdout is not None:
|
||||||
|
proc_zcat.stdout = None # 让 asyncio 不要等待它
|
||||||
|
_, zcat_err = await proc_zcat.wait(), b""
|
||||||
|
mysql_out, mysql_err = await proc_mysql.communicate()
|
||||||
|
|
||||||
|
if proc_mysql.returncode != 0:
|
||||||
|
err_msg = mysql_err.decode("utf-8", errors="ignore")[:500]
|
||||||
|
logger.error(f"mysql restore failed: {err_msg}")
|
||||||
|
raise BizException(errors.SERVER_ERROR, f"恢复失败:{err_msg}")
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
f"DB restored from upload ({file.filename}, {size} bytes) by admin#{admin.admin_id}"
|
||||||
|
)
|
||||||
|
return ok({"restored": True, "size": size})
|
||||||
|
except BizException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("restore failed")
|
||||||
|
raise BizException(errors.SERVER_ERROR, f"恢复异常:{e}")
|
||||||
|
finally:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/backups/{filename}/restore")
|
||||||
|
async def restore_from_existing(
|
||||||
|
filename: str,
|
||||||
|
admin: AdminIdentity = Depends(require_super),
|
||||||
|
):
|
||||||
|
"""从服务器已有备份恢复(等价于先下载再上传恢复,但省去传输)"""
|
||||||
|
path = _validate_filename(filename)
|
||||||
|
if not path.exists():
|
||||||
|
raise BizException(errors.NOT_FOUND, "备份文件不存在")
|
||||||
|
|
||||||
|
db = _parse_db_url()
|
||||||
|
|
||||||
|
try:
|
||||||
|
zcat_cmd = ["gunzip", "-c", str(path)]
|
||||||
|
mysql_cmd = [
|
||||||
|
"mysql",
|
||||||
|
f"--host={db['host']}",
|
||||||
|
f"--port={db['port']}",
|
||||||
|
f"--user={db['user']}",
|
||||||
|
"--default-character-set=utf8mb4",
|
||||||
|
db["database"],
|
||||||
|
]
|
||||||
|
|
||||||
|
proc_zcat = await asyncio.create_subprocess_exec(
|
||||||
|
*zcat_cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
proc_mysql = await asyncio.create_subprocess_exec(
|
||||||
|
*mysql_cmd,
|
||||||
|
stdin=proc_zcat.stdout,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env={"MYSQL_PWD": db["password"], "PATH": "/usr/bin:/bin"},
|
||||||
|
)
|
||||||
|
if proc_zcat.stdout is not None:
|
||||||
|
proc_zcat.stdout = None
|
||||||
|
await proc_zcat.wait()
|
||||||
|
_, mysql_err = await proc_mysql.communicate()
|
||||||
|
|
||||||
|
if proc_mysql.returncode != 0:
|
||||||
|
err_msg = mysql_err.decode("utf-8", errors="ignore")[:500]
|
||||||
|
logger.error(f"mysql restore failed: {err_msg}")
|
||||||
|
raise BizException(errors.SERVER_ERROR, f"恢复失败:{err_msg}")
|
||||||
|
|
||||||
|
logger.warning(f"DB restored from {filename} by admin#{admin.admin_id}")
|
||||||
|
return ok({"restored": True, "filename": filename})
|
||||||
|
except BizException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("restore from existing failed")
|
||||||
|
raise BizException(errors.SERVER_ERROR, f"恢复异常:{e}")
|
||||||
@@ -24,11 +24,13 @@ UPLOAD_RATE_LIMIT = 5
|
|||||||
# 注意:/videos/feed、/videos/categories、/videos/search、/videos/upload/* 须先于 /videos/{video_id} 注册
|
# 注意:/videos/feed、/videos/categories、/videos/search、/videos/upload/* 须先于 /videos/{video_id} 注册
|
||||||
@router.get("/feed")
|
@router.get("/feed")
|
||||||
async def get_feed(cursor: str | None = Query(None), limit: int = Query(10, ge=1, le=20),
|
async def get_feed(cursor: str | None = Query(None), limit: int = Query(10, ge=1, le=20),
|
||||||
category_id: int | None = Query(None),
|
category_id: int | None = Query(None), source_id: int | None = Query(None),
|
||||||
type: str = Query("recommend", pattern="^(recommend|latest|hot|liked|viewed)$"),
|
type: str = Query("recommend", pattern="^(recommend|latest|hot|liked|viewed)$"),
|
||||||
|
group: str | None = Query(None, pattern="^(series)$"),
|
||||||
db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
|
db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
|
||||||
identity: Identity = Depends(get_identity)):
|
identity: Identity = Depends(get_identity)):
|
||||||
return ok(await video_service.get_feed(db, redis, identity, type, cursor, limit, category_id))
|
return ok(await video_service.get_feed(
|
||||||
|
db, redis, identity, type, cursor, limit, category_id, source_id, group))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/categories")
|
@router.get("/categories")
|
||||||
@@ -36,6 +38,12 @@ async def get_categories(db: AsyncSession = Depends(get_db)):
|
|||||||
return ok(await video_service.list_categories(db))
|
return ok(await video_service.list_categories(db))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sources")
|
||||||
|
async def get_sources(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""内容来源列表(麻豆/91...),供 App 分类页筛选"""
|
||||||
|
return ok(await video_service.list_sources(db))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/search")
|
@router.get("/search")
|
||||||
async def search_videos(request: Request, keyword: str = Query(min_length=1, max_length=30),
|
async def search_videos(request: Request, keyword: str = Query(min_length=1, max_length=30),
|
||||||
sort: str = Query("hot", pattern="^(hot|latest)$"),
|
sort: str = Query("hot", pattern="^(hot|latest)$"),
|
||||||
@@ -103,6 +111,38 @@ async def delete_video(video_id: int, db: AsyncSession = Depends(get_db),
|
|||||||
return ok()
|
return ok()
|
||||||
|
|
||||||
|
|
||||||
|
# 站点管理员(按注册邮箱判定):可在 App 长按弹窗里下架任意视频
|
||||||
|
SITE_ADMIN_EMAIL = "256kb@gmail.com"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{video_id}/offline")
|
||||||
|
async def offline_video_app(
|
||||||
|
video_id: int,
|
||||||
|
reason: str = Form(default="管理员长按下架"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
identity: Identity = Depends(require_user),
|
||||||
|
):
|
||||||
|
"""App 端长按下架(仅限指定管理员邮箱);与后台 /admin/videos/{id}/offline 复用同一业务"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core import errors
|
||||||
|
from app.core.response import BizException
|
||||||
|
from app.deps import AdminIdentity
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import admin_service
|
||||||
|
|
||||||
|
user = (await db.execute(
|
||||||
|
select(User).where(User.id == identity.user_id)
|
||||||
|
)).scalars().first()
|
||||||
|
if user is None or (user.email or "").lower() != SITE_ADMIN_EMAIL:
|
||||||
|
raise BizException(errors.FORBIDDEN, "无权限")
|
||||||
|
|
||||||
|
admin = AdminIdentity(admin_id=user.id, role="super")
|
||||||
|
await admin_service.offline_video(db, redis, admin, video_id, reason, ip="app")
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{video_id}/watch")
|
@router.post("/{video_id}/watch")
|
||||||
async def report_watch(video_id: int, req: WatchReq, db: AsyncSession = Depends(get_db),
|
async def report_watch(video_id: int, req: WatchReq, db: AsyncSession = Depends(get_db),
|
||||||
redis: aioredis.Redis = Depends(get_redis),
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
@@ -141,6 +181,7 @@ async def get_series(series_id: int, db: AsyncSession = Depends(get_db)):
|
|||||||
"title": series.title,
|
"title": series.title,
|
||||||
"cover_url": series.cover_url,
|
"cover_url": series.cover_url,
|
||||||
"description": series.description,
|
"description": series.description,
|
||||||
|
"source_id": series.source_id or 0,
|
||||||
"total_episodes": len(eps),
|
"total_episodes": len(eps),
|
||||||
},
|
},
|
||||||
"episodes": [
|
"episodes": [
|
||||||
@@ -181,6 +222,15 @@ async def get_video_series(video_id: int, db: AsyncSession = Depends(get_db)):
|
|||||||
prev_ep = all_eps[current_idx - 1] if current_idx > 0 else None
|
prev_ep = all_eps[current_idx - 1] if current_idx > 0 else None
|
||||||
next_ep = all_eps[current_idx + 1] if 0 <= current_idx < len(all_eps) - 1 else None
|
next_ep = all_eps[current_idx + 1] if 0 <= current_idx < len(all_eps) - 1 else None
|
||||||
|
|
||||||
|
# 系列所属来源名称
|
||||||
|
from app.models.video import Source
|
||||||
|
|
||||||
|
source_name = ""
|
||||||
|
if series.source_id:
|
||||||
|
source_name = (await db.execute(
|
||||||
|
select(Source.name).where(Source.id == series.source_id)
|
||||||
|
)).scalar_one_or_none() or ""
|
||||||
|
|
||||||
return ok({
|
return ok({
|
||||||
"has_series": True,
|
"has_series": True,
|
||||||
"current_episode_no": video.episode_no,
|
"current_episode_no": video.episode_no,
|
||||||
@@ -188,6 +238,8 @@ async def get_video_series(video_id: int, db: AsyncSession = Depends(get_db)):
|
|||||||
"id": series.id,
|
"id": series.id,
|
||||||
"title": series.title,
|
"title": series.title,
|
||||||
"cover_url": series.cover_url,
|
"cover_url": series.cover_url,
|
||||||
|
"source_id": series.source_id or 0,
|
||||||
|
"source_name": source_name,
|
||||||
"total_episodes": len(all_eps),
|
"total_episodes": len(all_eps),
|
||||||
},
|
},
|
||||||
"episodes": [
|
"episodes": [
|
||||||
|
|||||||
+4
-1
@@ -69,9 +69,10 @@ from app.config import BASE_DIR
|
|||||||
app.mount("/static", StaticFiles(directory=BASE_DIR / "uploads"), name="static")
|
app.mount("/static", StaticFiles(directory=BASE_DIR / "uploads"), name="static")
|
||||||
|
|
||||||
# 业务路由(按模块逐步注册)
|
# 业务路由(按模块逐步注册)
|
||||||
from app.api.v1 import admin, auth, common, incentives, interactions, invite, media, membership, payments, users, videos
|
from app.api.v1 import admin, auth, common, incentives, interactions, invite, logs, media, membership, payments, users, videos, system
|
||||||
|
|
||||||
app.include_router(media.router, prefix="/api/v1")
|
app.include_router(media.router, prefix="/api/v1")
|
||||||
|
app.include_router(media.img_router, prefix="/api/v1")
|
||||||
app.include_router(auth.router, prefix="/api/v1")
|
app.include_router(auth.router, prefix="/api/v1")
|
||||||
app.include_router(users.router, prefix="/api/v1")
|
app.include_router(users.router, prefix="/api/v1")
|
||||||
app.include_router(videos.router, prefix="/api/v1")
|
app.include_router(videos.router, prefix="/api/v1")
|
||||||
@@ -86,6 +87,8 @@ app.include_router(incentives.router_referral, prefix="/api/v1")
|
|||||||
app.include_router(incentives.router_assets, prefix="/api/v1")
|
app.include_router(incentives.router_assets, prefix="/api/v1")
|
||||||
app.include_router(common.router, prefix="/api/v1")
|
app.include_router(common.router, prefix="/api/v1")
|
||||||
app.include_router(admin.router, prefix="/api/v1")
|
app.include_router(admin.router, prefix="/api/v1")
|
||||||
|
app.include_router(system.router, prefix="/api/v1")
|
||||||
|
app.include_router(logs.router, prefix="/api/v1")
|
||||||
app.include_router(invite.router, prefix="/api/v1")
|
app.include_router(invite.router, prefix="/api/v1")
|
||||||
# 推广落地页(根路径 /r/{code},对应 referral/me 返回的 invite_url)
|
# 推广落地页(根路径 /r/{code},对应 referral/me 返回的 invite_url)
|
||||||
app.include_router(invite.router_page)
|
app.include_router(invite.router_page)
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ from app.models.incentive import (
|
|||||||
)
|
)
|
||||||
from app.models.interaction import Comment, Favorite, Like
|
from app.models.interaction import Comment, Favorite, Like
|
||||||
from app.models.user import MembershipOrder, User, UserMembership, UserWallet
|
from app.models.user import MembershipOrder, User, UserMembership, UserWallet
|
||||||
|
from app.models.user_action_log import UserActionLog
|
||||||
from app.models.video import Category, Tag, TranscodeTask, Video, VideoTag
|
from app.models.video import Category, Tag, TranscodeTask, Video, VideoTag
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
"User", "UserWallet", "UserMembership", "MembershipOrder",
|
"User", "UserWallet", "UserMembership", "MembershipOrder", "UserActionLog",
|
||||||
"Video", "Category", "Tag", "VideoTag", "TranscodeTask",
|
"Video", "Category", "Tag", "VideoTag", "TranscodeTask",
|
||||||
"Comment", "Like", "Favorite",
|
"Comment", "Like", "Favorite",
|
||||||
"WatchRecord", "PointTransaction", "RechargeOrder", "RechargePackage",
|
"WatchRecord", "PointTransaction", "RechargeOrder", "RechargePackage",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,26 @@
|
|||||||
|
"""用户操作日志:用于排查 bug 时还原用户行为时间线(仅特定账号开启上报)"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Index, String, func
|
||||||
|
from sqlalchemy.dialects.mysql import BIGINT
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class UserActionLog(Base):
|
||||||
|
__tablename__ = "user_action_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(BIGINT(unsigned=True))
|
||||||
|
action: Mapped[str] = mapped_column(String(50), comment="route_push/player_stopall/api_error/...")
|
||||||
|
page: Mapped[str] = mapped_column(String(100), default="", comment="路由路径或页面标识")
|
||||||
|
detail: Mapped[str] = mapped_column(String(1000), default="", comment="行为上下文(JSON 字符串)")
|
||||||
|
device_info: Mapped[str] = mapped_column(String(200), default="", comment="机型/系统/版本")
|
||||||
|
app_version: Mapped[str] = mapped_column(String(20), default="", comment="1.3.26+42")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_user_created", "user_id", "created_at"),
|
||||||
|
Index("idx_action_created", "action", "created_at"),
|
||||||
|
)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""视频域:videos / categories / tags / video_tags / transcode_tasks"""
|
"""视频域:videos / categories / tags / video_tags / transcode_tasks"""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, DateTime, Index, SmallInteger, String, func
|
from sqlalchemy import BigInteger, DateTime, Index, SmallInteger, String, Text, func
|
||||||
from sqlalchemy.dialects.mysql import BIGINT, INTEGER
|
from sqlalchemy.dialects.mysql import BIGINT, INTEGER
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ class Video(Base):
|
|||||||
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
||||||
user_id: Mapped[int] = mapped_column(BIGINT(unsigned=True), comment="作者")
|
user_id: Mapped[int] = mapped_column(BIGINT(unsigned=True), comment="作者")
|
||||||
category_id: Mapped[int] = mapped_column(default=0)
|
category_id: Mapped[int] = mapped_column(default=0)
|
||||||
|
source_id: Mapped[int] = mapped_column(INTEGER(unsigned=True), default=0, comment="内容来源 id")
|
||||||
series_id: Mapped[int | None] = mapped_column(BIGINT(unsigned=True))
|
series_id: Mapped[int | None] = mapped_column(BIGINT(unsigned=True))
|
||||||
episode_no: Mapped[int | None] = mapped_column()
|
episode_no: Mapped[int | None] = mapped_column()
|
||||||
title: Mapped[str] = mapped_column(String(100), default="")
|
title: Mapped[str] = mapped_column(String(100), default="")
|
||||||
@@ -43,6 +44,7 @@ class Video(Base):
|
|||||||
Index("idx_user_status", "user_id", "status"),
|
Index("idx_user_status", "user_id", "status"),
|
||||||
Index("idx_feed", "status", "published_at"),
|
Index("idx_feed", "status", "published_at"),
|
||||||
Index("idx_category", "category_id", "status"),
|
Index("idx_category", "category_id", "status"),
|
||||||
|
Index("idx_video_source", "source_id", "status"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -104,6 +106,7 @@ class Series(Base):
|
|||||||
description: Mapped[str] = mapped_column(String(500), default="")
|
description: Mapped[str] = mapped_column(String(500), default="")
|
||||||
author_id: Mapped[int | None] = mapped_column(BIGINT(unsigned=True))
|
author_id: Mapped[int | None] = mapped_column(BIGINT(unsigned=True))
|
||||||
category_id: Mapped[int] = mapped_column(default=0)
|
category_id: Mapped[int] = mapped_column(default=0)
|
||||||
|
source_id: Mapped[int] = mapped_column(INTEGER(unsigned=True), default=0, comment="内容来源 id")
|
||||||
total_episodes: Mapped[int] = mapped_column(default=0)
|
total_episodes: Mapped[int] = mapped_column(default=0)
|
||||||
view_count: Mapped[int] = mapped_column(BigInteger, default=0)
|
view_count: Mapped[int] = mapped_column(BigInteger, default=0)
|
||||||
status: Mapped[int] = mapped_column(SmallInteger, default=1, comment="1正常 0隐藏")
|
status: Mapped[int] = mapped_column(SmallInteger, default=1, comment="1正常 0隐藏")
|
||||||
@@ -113,9 +116,23 @@ class Series(Base):
|
|||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("idx_series_cat_status", "category_id", "status"),
|
Index("idx_series_cat_status", "category_id", "status"),
|
||||||
Index("idx_series_status_created", "status", "created_at"),
|
Index("idx_series_status_created", "status", "created_at"),
|
||||||
|
Index("idx_series_source", "source_id", "status"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Source(Base):
|
||||||
|
"""内容来源(片商):麻豆、91 等。视频/系列/采集源都按它区分出处"""
|
||||||
|
|
||||||
|
__tablename__ = "sources"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(INTEGER(unsigned=True), primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(50), unique=True, comment="来源名称,如 麻豆")
|
||||||
|
code: Mapped[str] = mapped_column(String(50), default="", comment="英文/拼音标识,如 madou")
|
||||||
|
sort: Mapped[int] = mapped_column(default=0, comment="越小越靠前")
|
||||||
|
status: Mapped[int] = mapped_column(SmallInteger, default=1, comment="1启用 0停用")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
class CollectionRecord(Base):
|
class CollectionRecord(Base):
|
||||||
"""采集记录:外部内容源 → 本站系列的映射(幂等导入)"""
|
"""采集记录:外部内容源 → 本站系列的映射(幂等导入)"""
|
||||||
|
|
||||||
@@ -136,14 +153,21 @@ class CollectionRecord(Base):
|
|||||||
|
|
||||||
|
|
||||||
class CollectionSource(Base):
|
class CollectionSource(Base):
|
||||||
"""采集源配置:分类名 + 列表URL模板(后台自助添加)"""
|
"""采集源配置:分类名 + 列表URL模板(后台自助添加)
|
||||||
|
|
||||||
|
kind: url(通用 URL 模板,单视频) / aidrama91(91 短剧接口,按系列+集数入库)
|
||||||
|
config: JSON 字符串,存放接口类采集源所需配置(账号/密码/分类id/每页条数)
|
||||||
|
"""
|
||||||
|
|
||||||
__tablename__ = "collection_sources"
|
__tablename__ = "collection_sources"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
||||||
name: Mapped[str] = mapped_column(String(50), comment="分类名")
|
name: Mapped[str] = mapped_column(String(50), comment="分类名")
|
||||||
|
kind: Mapped[str] = mapped_column(String(20), default="url", comment="采集类型 url/aidrama91")
|
||||||
list_url: Mapped[str] = mapped_column(String(500), comment="列表URL模板,{{page}}为页码占位")
|
list_url: Mapped[str] = mapped_column(String(500), comment="列表URL模板,{{page}}为页码占位")
|
||||||
|
config: Mapped[str] = mapped_column(Text, default="", comment="JSON 配置")
|
||||||
category_id: Mapped[int] = mapped_column(INTEGER(unsigned=True), comment="本站分类 id")
|
category_id: Mapped[int] = mapped_column(INTEGER(unsigned=True), comment="本站分类 id")
|
||||||
|
source_id: Mapped[int] = mapped_column(INTEGER(unsigned=True), default=0, comment="内容来源 id(采集入库时打标)")
|
||||||
status: Mapped[int] = mapped_column(SmallInteger, default=1, comment="1启用 0停用")
|
status: Mapped[int] = mapped_column(SmallInteger, default=1, comment="1启用 0停用")
|
||||||
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime)
|
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -155,7 +155,7 @@ async def dashboard(db: AsyncSession) -> dict:
|
|||||||
|
|
||||||
# ---------- 视频审核 / 管理 ----------
|
# ---------- 视频审核 / 管理 ----------
|
||||||
|
|
||||||
def _video_item(video: Video, author: User | None) -> dict:
|
def _video_item(video: Video, author: User | None, source_name: str = "") -> dict:
|
||||||
return {
|
return {
|
||||||
"video_id": video.id,
|
"video_id": video.id,
|
||||||
"title": video.title,
|
"title": video.title,
|
||||||
@@ -164,6 +164,8 @@ def _video_item(video: Video, author: User | None) -> dict:
|
|||||||
"video_url": video.video_url,
|
"video_url": video.video_url,
|
||||||
"duration": video.duration,
|
"duration": video.duration,
|
||||||
"category_id": video.category_id or 0,
|
"category_id": video.category_id or 0,
|
||||||
|
"source_id": video.source_id or 0,
|
||||||
|
"source_name": source_name,
|
||||||
"series_id": video.series_id,
|
"series_id": video.series_id,
|
||||||
"episode_no": video.episode_no,
|
"episode_no": video.episode_no,
|
||||||
"status": video.status,
|
"status": video.status,
|
||||||
@@ -207,7 +209,7 @@ async def list_pending_videos(db: AsyncSession, page: int, page_size: int) -> di
|
|||||||
|
|
||||||
async def list_videos(
|
async def list_videos(
|
||||||
db: AsyncSession, keyword: str | None, status: int | None, author_id: int | None,
|
db: AsyncSession, keyword: str | None, status: int | None, author_id: int | None,
|
||||||
page: int, page_size: int,
|
page: int, page_size: int, source_id: int | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""视频列表(11.25)"""
|
"""视频列表(11.25)"""
|
||||||
stmt = select(Video)
|
stmt = select(Video)
|
||||||
@@ -218,12 +220,27 @@ async def list_videos(
|
|||||||
stmt = stmt.where(Video.status == status)
|
stmt = stmt.where(Video.status == status)
|
||||||
if author_id is not None:
|
if author_id is not None:
|
||||||
stmt = stmt.where(Video.user_id == author_id)
|
stmt = stmt.where(Video.user_id == author_id)
|
||||||
|
if source_id is not None:
|
||||||
|
stmt = stmt.where(Video.source_id == source_id)
|
||||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
|
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
|
||||||
rows = (await db.execute(
|
rows = (await db.execute(
|
||||||
stmt.order_by(Video.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
stmt.order_by(Video.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
authors = await _authors_map(db, rows)
|
authors = await _authors_map(db, rows)
|
||||||
return {"total": total, "list": [_video_item(v, authors.get(v.user_id)) for v in rows]}
|
names = await source_names_map(db, {v.source_id for v in rows})
|
||||||
|
return {"total": total,
|
||||||
|
"list": [_video_item(v, authors.get(v.user_id), names.get(v.source_id, "")) for v in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
async def source_names_map(db: AsyncSession, ids: set[int]) -> dict[int, str]:
|
||||||
|
"""来源 id → 名称"""
|
||||||
|
from app.models.video import Source
|
||||||
|
|
||||||
|
ids = {i for i in ids if i}
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
rows = (await db.execute(select(Source.id, Source.name).where(Source.id.in_(ids)))).all()
|
||||||
|
return {r.id: r.name for r in rows}
|
||||||
|
|
||||||
|
|
||||||
async def review_video(
|
async def review_video(
|
||||||
|
|||||||
@@ -0,0 +1,850 @@
|
|||||||
|
"""91 短剧(AI短剧)采集:加密接口客户端 + 系列/集数入库
|
||||||
|
|
||||||
|
上游协议:pwa.php 单入口,AES-256-CFB(segment 128) 加密请求体 + MD5(SHA256) 签名。
|
||||||
|
时间戳签名有效期约 4 小时,因此**不存原始 m3u8 地址**,改为存本站代理地址,
|
||||||
|
播放时再向上游换取新鲜签名(见 /api/v1/m3u8/aidrama)。
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.video import (
|
||||||
|
Category,
|
||||||
|
CollectionRecord,
|
||||||
|
CollectionSource,
|
||||||
|
Series,
|
||||||
|
Tag,
|
||||||
|
Video,
|
||||||
|
VideoTag,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
KIND = "aidrama91"
|
||||||
|
VLOG_KIND = "aidrama91_vlog" # 91 非系列短视频
|
||||||
|
SOURCE_KEY = "91aidrama" # collection_records.source_key
|
||||||
|
VLOG_SOURCE_KEY = "91vlog" # 短视频采集记录 key
|
||||||
|
SOURCE_CODE = "91" # sources.code
|
||||||
|
SELF_API_BASE = "https://vapi.256kb.top"
|
||||||
|
AUTHOR_USER_ID = 1
|
||||||
|
VLOG_CATEGORY_NAME = "91短视频" # vlog 无真实分类,归到本地分类
|
||||||
|
|
||||||
|
# 协议常量(前端 JS 逆向)
|
||||||
|
AES_KEY = b"cc88ddc9357ff461e08f047aedee692b"
|
||||||
|
AES_IV = b"e89225cfbbimgkcu"
|
||||||
|
SIGN_KEY = "cc88ddc9357ff461e08f047aedee692b"
|
||||||
|
APP_VERSION = "7.5.0"
|
||||||
|
CLIENT_ORIGIN = "https://pwa4.jl8v0p5t.cc"
|
||||||
|
|
||||||
|
# 封面图解密:CDN 返回的是 AES-128-CBC 加密的图片字节(无 padding),
|
||||||
|
# 源站前端 DecryptImage 解密后展示 —— 本站通过 /api/v1/img/aidrama 代理解密
|
||||||
|
IMG_AES_KEY = b"f5d965df75336270"
|
||||||
|
IMG_AES_IV = b"97b60394abc2fbe1"
|
||||||
|
IMG_HOST_SUFFIX = ".drleao.cn" # 封面 CDN 域名白名单(防 SSRF)
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
|
||||||
|
),
|
||||||
|
"Origin": CLIENT_ORIGIN,
|
||||||
|
"Referer": f"{CLIENT_ORIGIN}/",
|
||||||
|
"Accept": "application/json, text/plain, */*",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
}
|
||||||
|
|
||||||
|
OAUTH_CHARSET = string.ascii_letters + string.digits
|
||||||
|
MAX_PAGE_LIMIT = 20 # 上游单页上限(传 100 实际只返回 20)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 加密 / 签名 ----------
|
||||||
|
|
||||||
|
def _cfb(key: bytes, iv: bytes):
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.decrepit.ciphers.modes import CFB
|
||||||
|
except ImportError: # cryptography < 43
|
||||||
|
from cryptography.hazmat.primitives.ciphers.modes import CFB
|
||||||
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
|
||||||
|
|
||||||
|
return Cipher(algorithms.AES(key), CFB(iv))
|
||||||
|
|
||||||
|
|
||||||
|
def _encrypt(plaintext: str) -> str:
|
||||||
|
op = _cfb(AES_KEY, AES_IV).encryptor()
|
||||||
|
return (op.update(plaintext.encode()) + op.finalize()).hex().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt(hex_data: str) -> str:
|
||||||
|
op = _cfb(AES_KEY, AES_IV).decryptor()
|
||||||
|
return (op.update(bytes.fromhex(hex_data)) + op.finalize()).decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _new_oauth_id() -> str:
|
||||||
|
return "".join(random.choices(OAUTH_CHARSET, k=16)) + str(int(time.time() * 1000))
|
||||||
|
|
||||||
|
|
||||||
|
async def _post(client: httpx.AsyncClient, api_url: str, payload: dict) -> dict:
|
||||||
|
"""加密并发送,返回解密后的业务数据(已剥掉外层包装)"""
|
||||||
|
ts_ms = int(time.time() * 1000)
|
||||||
|
payload = dict(payload, timestamp=ts_ms)
|
||||||
|
data_hex = _encrypt(json.dumps(payload, separators=(",", ":")))
|
||||||
|
ts_sec = ts_ms // 1000
|
||||||
|
raw = f"client=pwa&data={data_hex}×tamp={ts_sec}{SIGN_KEY}"
|
||||||
|
sign = hashlib.md5(hashlib.sha256(raw.encode()).hexdigest().encode()).hexdigest()
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
api_url,
|
||||||
|
content=f"client=pwa×tamp={ts_sec}&data={data_hex}&sign={sign}",
|
||||||
|
headers=HEADERS,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise RuntimeError(f"上游 HTTP {resp.status_code}(oauth_id 可能未注册或已被限流)")
|
||||||
|
body = resp.json()
|
||||||
|
if body.get("errcode") not in (0, None):
|
||||||
|
raise RuntimeError(f"上游错误: {str(body)[:200]}")
|
||||||
|
payload_text = body.get("data")
|
||||||
|
if not payload_text:
|
||||||
|
return body
|
||||||
|
return json.loads(_decrypt(payload_text))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(oauth_id: str, token: str) -> dict:
|
||||||
|
return {
|
||||||
|
"oauth_type": "pwa", "oauth_id": oauth_id, "trace_id": "",
|
||||||
|
"aff_x_code": "", "version": APP_VERSION, "token": token,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 会话 ----------
|
||||||
|
|
||||||
|
async def create_session(
|
||||||
|
client: httpx.AsyncClient, api_url: str, account: str, password: str
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""注册 oauth_id 并登录,返回 (oauth_id, token)"""
|
||||||
|
oauth_id = _new_oauth_id()
|
||||||
|
# 上游对未注册的 oauth_id 直接 503,必须先 system/index
|
||||||
|
await _post(client, api_url, dict(_base(oauth_id, ""), mod="system", code="index", data={}))
|
||||||
|
result = await _post(client, api_url, dict(
|
||||||
|
_base(oauth_id, ""), mod="user", code="regOrLogin",
|
||||||
|
account=account, password=password, type="login",
|
||||||
|
))
|
||||||
|
inner = result.get("data", result) if isinstance(result, dict) else {}
|
||||||
|
token = inner.get("token") or ""
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError(f"91 登录失败: {inner.get('msg') or str(result)[:150]}")
|
||||||
|
return oauth_id, token
|
||||||
|
|
||||||
|
|
||||||
|
# 进程内 token 缓存(2 个 uvicorn worker → 最多 2 次登录);失效时自动重登
|
||||||
|
_TOKENS: dict[int, tuple[str, str, float]] = {}
|
||||||
|
TOKEN_TTL = 3600
|
||||||
|
|
||||||
|
|
||||||
|
async def open_session(
|
||||||
|
client: httpx.AsyncClient, source: CollectionSource, force: bool = False
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""获取可用的 (oauth_id, token):进程缓存 → DB 配置 → 重新登录"""
|
||||||
|
key = int(source.id or 0)
|
||||||
|
cfg = load_config(source)
|
||||||
|
if not force:
|
||||||
|
hit = _TOKENS.get(key)
|
||||||
|
if hit and hit[2] > time.time():
|
||||||
|
return hit[0], hit[1]
|
||||||
|
if cfg.get("oauth_id") and cfg.get("token"):
|
||||||
|
_TOKENS[key] = (cfg["oauth_id"], cfg["token"], time.time() + TOKEN_TTL)
|
||||||
|
return cfg["oauth_id"], cfg["token"]
|
||||||
|
|
||||||
|
oauth_id, token = await create_session(
|
||||||
|
client, source.list_url, cfg["account"], cfg["password"])
|
||||||
|
_TOKENS[key] = (oauth_id, token, time.time() + TOKEN_TTL)
|
||||||
|
return oauth_id, token
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(source: CollectionSource) -> dict:
|
||||||
|
try:
|
||||||
|
cfg = json.loads(source.config or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
cfg = {}
|
||||||
|
cfg.setdefault("account", "")
|
||||||
|
cfg.setdefault("password", "")
|
||||||
|
cfg.setdefault("category_ids", [4])
|
||||||
|
cfg.setdefault("page_size", MAX_PAGE_LIMIT)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 业务接口 ----------
|
||||||
|
|
||||||
|
async def fetch_list(
|
||||||
|
client: httpx.AsyncClient, api_url: str, oauth_id: str, token: str,
|
||||||
|
page: int, limit: int, category_ids: list[int],
|
||||||
|
) -> dict:
|
||||||
|
"""短剧列表;返回 {list, total, has_more}"""
|
||||||
|
data = await _post(client, api_url, dict(
|
||||||
|
_base(oauth_id, token), mod="aidrama", code="home",
|
||||||
|
page=page, limit=min(limit, MAX_PAGE_LIMIT), pay_type="all", sort="all",
|
||||||
|
category_ids=category_ids or [4],
|
||||||
|
))
|
||||||
|
inner = data.get("data") or {}
|
||||||
|
return {
|
||||||
|
"list": inner.get("list") or [],
|
||||||
|
"total": int(inner.get("total") or 0),
|
||||||
|
"has_more": bool(inner.get("has_more")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_detail(
|
||||||
|
client: httpx.AsyncClient, api_url: str, oauth_id: str, token: str, drama_id: int
|
||||||
|
) -> dict:
|
||||||
|
data = await _post(client, api_url, dict(
|
||||||
|
_base(oauth_id, token), mod="aidrama", code="detail", drama_id=drama_id,
|
||||||
|
))
|
||||||
|
return (data.get("data") or {}).get("row") or {}
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_episodes(
|
||||||
|
client: httpx.AsyncClient, api_url: str, oauth_id: str, token: str,
|
||||||
|
drama_id: int, page: int = 1, limit: int = MAX_PAGE_LIMIT,
|
||||||
|
) -> dict:
|
||||||
|
"""集数列表;返回 {list, has_more}"""
|
||||||
|
data = await _post(client, api_url, dict(
|
||||||
|
_base(oauth_id, token), mod="aidrama", code="episodes",
|
||||||
|
drama_id=drama_id, page=page, limit=min(limit, MAX_PAGE_LIMIT),
|
||||||
|
))
|
||||||
|
inner = data.get("data") or {}
|
||||||
|
return {"list": inner.get("list") or [], "has_more": bool(inner.get("has_more"))}
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_all_episodes(
|
||||||
|
client: httpx.AsyncClient, api_url: str, oauth_id: str, token: str, drama_id: int
|
||||||
|
) -> list[dict]:
|
||||||
|
"""翻页取全部集数(每页 20 上限)"""
|
||||||
|
out: list[dict] = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
part = await fetch_episodes(client, api_url, oauth_id, token, drama_id, page=page)
|
||||||
|
out.extend(part["list"])
|
||||||
|
if not part["has_more"] or not part["list"]:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- vlog 短视频接口 ----------
|
||||||
|
|
||||||
|
VLOG_MAX_LIMIT = 50 # 实测 vlog 单页上限 50(传 100 只返回 50)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_vlog_list(
|
||||||
|
client: httpx.AsyncClient, api_url: str, oauth_id: str, token: str,
|
||||||
|
page: int, limit: int = VLOG_MAX_LIMIT, vlog_type: str = "new",
|
||||||
|
) -> dict:
|
||||||
|
"""vlog 列表(非系列短视频);返回 {list, has_more}"""
|
||||||
|
data = await _post(client, api_url, dict(
|
||||||
|
_base(oauth_id, token), mod="choice", code="vlog",
|
||||||
|
type=vlog_type, page=page, limit=min(limit, VLOG_MAX_LIMIT),
|
||||||
|
))
|
||||||
|
lst = data.get("data") or []
|
||||||
|
if not isinstance(lst, list):
|
||||||
|
lst = []
|
||||||
|
return {"list": lst, "has_more": len(lst) >= min(limit, VLOG_MAX_LIMIT)}
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_vlog_detail(
|
||||||
|
client: httpx.AsyncClient, api_url: str, oauth_id: str, token: str, vid: int
|
||||||
|
) -> dict:
|
||||||
|
"""vlog 详情(含 playUrl / tags)"""
|
||||||
|
data = await _post(client, api_url, dict(
|
||||||
|
_base(oauth_id, token), mod="index", code="detail", id=vid))
|
||||||
|
return data.get("data") or {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 字段解析 ----------
|
||||||
|
|
||||||
|
def _pick_url(raw: str | dict, prefer: str = "720") -> str:
|
||||||
|
"""封面字段是 JSON 字符串或 dict:{"ori","360","720"},取指定档位"""
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
obj = json.loads(raw) if isinstance(raw, str) else raw
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return raw if isinstance(raw, str) else ""
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
return ""
|
||||||
|
return obj.get(prefer) or obj.get("ori") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def proxied_cover(url: str) -> str:
|
||||||
|
"""封面地址改写为本站解密代理(CDN 图是加密的,直连无法显示)"""
|
||||||
|
if not url or "/api/v1/img/aidrama" in url:
|
||||||
|
return url
|
||||||
|
if not url.startswith(("http://", "https://")):
|
||||||
|
return url
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
return f"{SELF_API_BASE}/api/v1/img/aidrama?u={quote(url, safe='')}"
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_image(data: bytes) -> bytes:
|
||||||
|
"""AES-128-CBC(NoPadding) 解密 CDN 加密图片,返回原始 JPEG/PNG 字节"""
|
||||||
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
|
||||||
|
op = Cipher(algorithms.AES(IMG_AES_KEY), modes.CBC(IMG_AES_IV)).decryptor()
|
||||||
|
return op.update(data) + op.finalize()
|
||||||
|
|
||||||
|
|
||||||
|
def _play_url(drama_id: int, episode_id: int, episode_no: int | None) -> str:
|
||||||
|
"""本站代理地址(含 m3u8 字样,App 端会走本地缓存代理)"""
|
||||||
|
url = f"{SELF_API_BASE}/api/v1/m3u8/aidrama?d={drama_id}&e={episode_id}"
|
||||||
|
if episode_no:
|
||||||
|
url += f"&n={episode_no}"
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_dt(ts) -> datetime | None:
|
||||||
|
try:
|
||||||
|
n = int(ts)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return datetime.fromtimestamp(n) if n > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_count(v) -> int:
|
||||||
|
"""解析播放/点赞等计数,兼容 '143.7万' / '1.2万' / '34' / 34"""
|
||||||
|
if v is None:
|
||||||
|
return 0
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
return int(v)
|
||||||
|
s = str(v).strip()
|
||||||
|
if not s:
|
||||||
|
return 0
|
||||||
|
if s.endswith("万"):
|
||||||
|
try:
|
||||||
|
return int(float(s[:-1]) * 10000)
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return int(s)
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _vlog_play_url(vid: int) -> str:
|
||||||
|
"""vlog 单视频播放代理地址(含 m3u8 字样,App 走本地缓存)"""
|
||||||
|
return f"{SELF_API_BASE}/api/v1/m3u8/vlog?vid={vid}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 入库 ----------
|
||||||
|
|
||||||
|
async def _upsert_tags(db: AsyncSession, names: list[str]) -> list[int]:
|
||||||
|
"""按名称取/建标签,返回 tag id 列表"""
|
||||||
|
ids: list[int] = []
|
||||||
|
for name in {n.strip() for n in names if n and n.strip()}:
|
||||||
|
name = name[:20]
|
||||||
|
tid = (await db.execute(select(Tag.id).where(Tag.name == name))).scalar_one_or_none()
|
||||||
|
if not tid:
|
||||||
|
t = Tag(name=name, video_count=0)
|
||||||
|
db.add(t)
|
||||||
|
await db.flush()
|
||||||
|
tid = t.id
|
||||||
|
ids.append(tid)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
async def import_drama(
|
||||||
|
db: AsyncSession, client: httpx.AsyncClient, source: CollectionSource,
|
||||||
|
drama: dict, oauth_id: str, token: str,
|
||||||
|
api_url: str | None = None, category_id: int | None = None, source_id: int | None = None,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""导入单部短剧(系列 + 集数视频),返回 (新增集数, 跳过集数)"""
|
||||||
|
drama_id = int(drama.get("id") or 0)
|
||||||
|
if not drama_id:
|
||||||
|
return 0, 0
|
||||||
|
api_url = api_url or source.list_url
|
||||||
|
cat = category_id if category_id is not None else source.category_id
|
||||||
|
sid = source_id if source_id is not None else source.source_id
|
||||||
|
|
||||||
|
cfg = load_config(source)
|
||||||
|
detail = drama
|
||||||
|
try:
|
||||||
|
full = await fetch_detail(client, api_url, oauth_id, token, drama_id)
|
||||||
|
if full:
|
||||||
|
detail = {**drama, **full}
|
||||||
|
except Exception:
|
||||||
|
logger.warning("短剧 %s 详情获取失败,使用列表字段", drama_id)
|
||||||
|
|
||||||
|
record = (await db.execute(
|
||||||
|
select(CollectionRecord).where(
|
||||||
|
CollectionRecord.source_key == SOURCE_KEY,
|
||||||
|
CollectionRecord.source_id == drama_id,
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
cover = proxied_cover(_pick_url(detail.get("cover") or ""))
|
||||||
|
tags = list(detail.get("tag_list") or []) + list(detail.get("category_list") or [])
|
||||||
|
# 像既有采集一样:按标题拆分关键词作为 tag
|
||||||
|
from app.services.title_tags import extract_keywords
|
||||||
|
|
||||||
|
tags += extract_keywords(detail.get("title") or "")
|
||||||
|
total_declared = int(detail.get("total_episodes") or 0)
|
||||||
|
|
||||||
|
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=cover[:500],
|
||||||
|
description=(detail.get("description") or "")[:500],
|
||||||
|
author_id=AUTHOR_USER_ID,
|
||||||
|
category_id=cat,
|
||||||
|
source_id=sid,
|
||||||
|
total_episodes=total_declared,
|
||||||
|
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()
|
||||||
|
else:
|
||||||
|
# 已有系列:同步标题/封面/简介(源站可能改过)
|
||||||
|
series.title = (detail.get("title") or series.title)[:100]
|
||||||
|
series.cover_url = (cover or series.cover_url)[:500]
|
||||||
|
series.total_episodes = max(series.total_episodes, total_declared)
|
||||||
|
|
||||||
|
existing = set((await db.execute(
|
||||||
|
select(Video.episode_no).where(
|
||||||
|
Video.series_id == series.id, Video.episode_no.is_not(None))
|
||||||
|
)).scalars().all())
|
||||||
|
|
||||||
|
episodes = await fetch_all_episodes(client, api_url, oauth_id, token, drama_id)
|
||||||
|
tag_ids: list[int] | None = None
|
||||||
|
created = skipped = 0
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
for ep in episodes:
|
||||||
|
ep_id = int(ep.get("id") or 0)
|
||||||
|
ep_no = int(ep.get("episode_number") or 0)
|
||||||
|
if not ep_id or not ep_no or ep_no in existing:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
ep_cover = proxied_cover(_pick_url(ep.get("cover") or "")) or cover
|
||||||
|
video = Video(
|
||||||
|
user_id=AUTHOR_USER_ID,
|
||||||
|
category_id=cat,
|
||||||
|
source_id=sid,
|
||||||
|
series_id=series.id,
|
||||||
|
episode_no=ep_no,
|
||||||
|
title=(ep.get("title") or f"第{ep_no}集")[:100],
|
||||||
|
description=(detail.get("description") or "")[:500],
|
||||||
|
cover_url=ep_cover[:500],
|
||||||
|
video_url=_play_url(drama_id, ep_id, ep_no),
|
||||||
|
duration=int(ep.get("duration") or 0),
|
||||||
|
status=2,
|
||||||
|
like_count=int(ep.get("like") or 0),
|
||||||
|
favorite_count=int(ep.get("favorite_num") or 0),
|
||||||
|
comment_count=int(ep.get("comment_count") or 0),
|
||||||
|
published_at=_safe_dt(ep.get("created_at")) or now,
|
||||||
|
)
|
||||||
|
db.add(video)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# 标签挂到每集视频上(系列标签来自 tag_list / category_list)
|
||||||
|
if tags:
|
||||||
|
if tag_ids is None:
|
||||||
|
tag_ids = await _upsert_tags(db, tags)
|
||||||
|
for tid in tag_ids:
|
||||||
|
db.add(VideoTag(video_id=video.id, tag_id=tid))
|
||||||
|
|
||||||
|
existing.add(ep_no)
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
series.total_episodes = max(len(existing), total_declared)
|
||||||
|
return created, skipped
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_source(
|
||||||
|
db: AsyncSession, source: CollectionSource, max_pages: int = 0, delay: float = 0.3,
|
||||||
|
should_cancel=None,
|
||||||
|
) -> dict:
|
||||||
|
"""按列表翻页增量采集(已导入的短剧跳过,仍会补同步新集数)
|
||||||
|
|
||||||
|
should_cancel: 可选回调,返回 True 时在每页/每条边界退出循环(用于手动取消)
|
||||||
|
"""
|
||||||
|
cfg = load_config(source)
|
||||||
|
stat: dict = {
|
||||||
|
"kind": KIND, "dramas_new": 0, "dramas_exist": 0,
|
||||||
|
"episodes_created": 0, "episodes_skipped": 0, "pages": 0, "total": 0,
|
||||||
|
}
|
||||||
|
page_size = min(int(cfg.get("page_size") or MAX_PAGE_LIMIT), MAX_PAGE_LIMIT)
|
||||||
|
category_ids = cfg.get("category_ids") or [4]
|
||||||
|
|
||||||
|
def _cancelled() -> bool:
|
||||||
|
return bool(should_cancel and should_cancel())
|
||||||
|
|
||||||
|
for attempt in (1, 2):
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
oauth_id, token = await open_session(client, source, force=attempt == 2)
|
||||||
|
api_url = source.list_url
|
||||||
|
cat = source.category_id
|
||||||
|
sid = source.source_id
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
if _cancelled():
|
||||||
|
logger.info("91 短剧同步被取消(第 %s 页前)", page)
|
||||||
|
return stat
|
||||||
|
data = await fetch_list(
|
||||||
|
client, api_url, oauth_id, token, page, page_size, category_ids)
|
||||||
|
stat["pages"] = page
|
||||||
|
stat["total"] = data["total"]
|
||||||
|
items = data["list"]
|
||||||
|
logger.info("91 短剧第 %s 页:%s 部(共 %s)", page, len(items), data["total"])
|
||||||
|
|
||||||
|
for drama in items:
|
||||||
|
if _cancelled():
|
||||||
|
logger.info("91 短剧同步被取消(处理 drama 中)")
|
||||||
|
return stat
|
||||||
|
drama_id = int(drama.get("id") or 0)
|
||||||
|
if not drama_id:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
created, skipped = await import_drama(
|
||||||
|
db, client, source, drama, oauth_id, token,
|
||||||
|
api_url=api_url, category_id=cat, source_id=sid)
|
||||||
|
await db.commit()
|
||||||
|
if created > 0:
|
||||||
|
stat["dramas_new"] += 1
|
||||||
|
else:
|
||||||
|
stat["dramas_exist"] += 1
|
||||||
|
stat["episodes_created"] += created
|
||||||
|
stat["episodes_skipped"] += skipped
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
logger.exception("导入 91 短剧 %s 失败,跳过", drama_id)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
if not data["has_more"] or not items:
|
||||||
|
break
|
||||||
|
if max_pages and page >= max_pages:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
cfg["oauth_id"], cfg["token"] = oauth_id, token
|
||||||
|
source.config = json.dumps(cfg, ensure_ascii=False)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
_TOKENS.pop(int(source.id or 0), None)
|
||||||
|
if attempt == 2:
|
||||||
|
source.last_sync_at = datetime.now()
|
||||||
|
await db.commit()
|
||||||
|
raise
|
||||||
|
logger.warning("91 同步失败,重新登录后重试一次")
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
source.last_sync_at = datetime.now()
|
||||||
|
await db.commit()
|
||||||
|
return stat
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_source_id(db: AsyncSession, source: CollectionSource) -> int:
|
||||||
|
"""确保采集源绑定的 sources 记录存在,返回来源 id"""
|
||||||
|
if source.source_id:
|
||||||
|
return source.source_id
|
||||||
|
from app.models.video import Source
|
||||||
|
|
||||||
|
sid = (await db.execute(
|
||||||
|
select(Source.id).where(Source.code == SOURCE_CODE)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if not sid:
|
||||||
|
max_sort = 0
|
||||||
|
s = Source(name="91", code=SOURCE_CODE, sort=max_sort + 1, status=1)
|
||||||
|
db.add(s)
|
||||||
|
await db.flush()
|
||||||
|
sid = s.id
|
||||||
|
source.source_id = sid
|
||||||
|
return sid
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_local_category(db: AsyncSession, name: str) -> int:
|
||||||
|
"""按采集源名称找/建本站分类"""
|
||||||
|
cid = (await db.execute(
|
||||||
|
select(Category.id).where(Category.name == name)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if cid:
|
||||||
|
return cid
|
||||||
|
c = Category(name=name, sort=99, status=1)
|
||||||
|
db.add(c)
|
||||||
|
await db.flush()
|
||||||
|
return c.id
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_playlist(source: CollectionSource, drama_id: int, episode_id: int,
|
||||||
|
episode_no: int | None = None) -> str:
|
||||||
|
"""换取某集的最新 m3u8 文本(签名有时效,每次播放现取;token 失效自动重登)"""
|
||||||
|
for attempt in (1, 2):
|
||||||
|
try:
|
||||||
|
return await _fetch_playlist_once(
|
||||||
|
source, drama_id, episode_id, episode_no, force=attempt == 2)
|
||||||
|
except Exception:
|
||||||
|
_TOKENS.pop(int(source.id or 0), None)
|
||||||
|
if attempt == 2:
|
||||||
|
raise
|
||||||
|
logger.warning("91 播放列表获取失败,重新登录后重试一次")
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
raise RuntimeError("播放列表获取失败")
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_playlist_once(
|
||||||
|
source: CollectionSource, drama_id: int, episode_id: int,
|
||||||
|
episode_no: int | None, force: bool,
|
||||||
|
) -> str:
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
oauth_id, token = await open_session(client, source, force=force)
|
||||||
|
|
||||||
|
target: dict | None = None
|
||||||
|
# 有集号就直接算所在页;否则从第 1 页起扫(最多 5 页兜底)
|
||||||
|
pages: list[int] = []
|
||||||
|
if episode_no and episode_no > 0:
|
||||||
|
pages.append((episode_no - 1) // MAX_PAGE_LIMIT + 1)
|
||||||
|
pages.extend(p for p in (1, 2, 3, 4, 5) if p not in pages)
|
||||||
|
|
||||||
|
for page in pages:
|
||||||
|
part = await fetch_episodes(
|
||||||
|
client, source.list_url, oauth_id, token, drama_id, page=page)
|
||||||
|
for ep in part["list"]:
|
||||||
|
if int(ep.get("id") or 0) == episode_id:
|
||||||
|
target = ep
|
||||||
|
break
|
||||||
|
if target or not part["has_more"]:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target:
|
||||||
|
raise RuntimeError("源站未找到该集")
|
||||||
|
|
||||||
|
m3u8 = target.get("full_m3u8") or ""
|
||||||
|
if not m3u8:
|
||||||
|
raise RuntimeError("该集暂无可播放地址(可能需付费或未解锁)")
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
m3u8, headers={"User-Agent": HEADERS["User-Agent"]}, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
if not resp.text.lstrip().startswith("#EXTM3U"):
|
||||||
|
raise RuntimeError("源站播放列表异常")
|
||||||
|
return resp.text
|
||||||
|
|
||||||
|
|
||||||
|
async def get_collection_source(db: AsyncSession, source_id: int | None = None) -> CollectionSource:
|
||||||
|
"""取 91 采集源(播放代理用)"""
|
||||||
|
stmt = select(CollectionSource).where(CollectionSource.kind == KIND, CollectionSource.status == 1)
|
||||||
|
if source_id:
|
||||||
|
stmt = stmt.where(CollectionSource.id == source_id)
|
||||||
|
row = (await db.execute(stmt.order_by(CollectionSource.id))).scalars().first()
|
||||||
|
if not row:
|
||||||
|
raise RuntimeError("未配置 91 采集源")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def get_vlog_source(db: AsyncSession, source_id: int | None = None) -> CollectionSource:
|
||||||
|
"""取 91 短视频采集源(播放代理用)"""
|
||||||
|
stmt = select(CollectionSource).where(
|
||||||
|
CollectionSource.kind == VLOG_KIND, CollectionSource.status == 1)
|
||||||
|
if source_id:
|
||||||
|
stmt = stmt.where(CollectionSource.id == source_id)
|
||||||
|
row = (await db.execute(stmt.order_by(CollectionSource.id))).scalars().first()
|
||||||
|
if not row:
|
||||||
|
raise RuntimeError("未配置 91 短视频采集源")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- vlog 入库 ----------
|
||||||
|
|
||||||
|
async def import_vlog_video(
|
||||||
|
db: AsyncSession, client: httpx.AsyncClient, source: CollectionSource,
|
||||||
|
item: dict, oauth_id: str, token: str,
|
||||||
|
api_url: str | None = None, category_id: int | None = None, source_id: int | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""导入单条 vlog 短视频,返回是否新建"""
|
||||||
|
vid = int(item.get("id") or 0)
|
||||||
|
if not vid:
|
||||||
|
return False
|
||||||
|
api_url = api_url or source.list_url
|
||||||
|
cat = category_id if category_id is not None else source.category_id
|
||||||
|
sid = source_id if source_id is not None else source.source_id
|
||||||
|
|
||||||
|
record = (await db.execute(select(CollectionRecord).where(
|
||||||
|
CollectionRecord.source_key == VLOG_SOURCE_KEY,
|
||||||
|
CollectionRecord.source_id == vid,
|
||||||
|
))).scalar_one_or_none()
|
||||||
|
if record:
|
||||||
|
v = await db.get(Video, record.video_id) if record.video_id else None
|
||||||
|
if v:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 详情取 playUrl + tags
|
||||||
|
detail = item
|
||||||
|
try:
|
||||||
|
full = await fetch_vlog_detail(client, api_url, oauth_id, token, vid)
|
||||||
|
if full:
|
||||||
|
detail = {**item, **full}
|
||||||
|
except Exception:
|
||||||
|
logger.warning("vlog %s 详情获取失败,使用列表字段", vid)
|
||||||
|
|
||||||
|
cover = proxied_cover(_pick_url(detail.get("thumbImg") or detail.get("cover_thumb") or ""))
|
||||||
|
title = (detail.get("title") or f"短视频{vid}")[:100]
|
||||||
|
tags = list(detail.get("tags") or [])
|
||||||
|
from app.services.title_tags import extract_keywords
|
||||||
|
|
||||||
|
tags += extract_keywords(title)
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
video = Video(
|
||||||
|
user_id=AUTHOR_USER_ID,
|
||||||
|
category_id=cat,
|
||||||
|
source_id=sid,
|
||||||
|
title=title,
|
||||||
|
description=(str(detail.get("descriptions") or "")[:500]),
|
||||||
|
cover_url=cover[:500],
|
||||||
|
video_url=_vlog_play_url(vid),
|
||||||
|
duration=int(detail.get("duration") or 0),
|
||||||
|
status=2,
|
||||||
|
like_count=_parse_count(detail.get("like")),
|
||||||
|
favorite_count=0,
|
||||||
|
comment_count=_parse_count(detail.get("comment")),
|
||||||
|
view_count=_parse_count(detail.get("play_count")),
|
||||||
|
published_at=now,
|
||||||
|
)
|
||||||
|
db.add(video)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
if tags:
|
||||||
|
tag_ids = await _upsert_tags(db, tags)
|
||||||
|
for tid in tag_ids:
|
||||||
|
db.add(VideoTag(video_id=video.id, tag_id=tid))
|
||||||
|
|
||||||
|
if record:
|
||||||
|
record.video_id = video.id
|
||||||
|
else:
|
||||||
|
db.add(CollectionRecord(source_key=VLOG_SOURCE_KEY, source_id=vid, video_id=video.id))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_vlog_source(
|
||||||
|
db: AsyncSession, source: CollectionSource, max_pages: int = 0, delay: float = 0.3,
|
||||||
|
should_cancel=None,
|
||||||
|
) -> dict:
|
||||||
|
"""vlog 短视频翻页增量采集。should_cancel 返回 True 时每页/每条边界退出"""
|
||||||
|
cfg = load_config(source)
|
||||||
|
stat: dict = {
|
||||||
|
"kind": VLOG_KIND, "videos_new": 0, "videos_exist": 0,
|
||||||
|
"pages": 0,
|
||||||
|
}
|
||||||
|
page_size = min(int(cfg.get("page_size") or VLOG_MAX_LIMIT), VLOG_MAX_LIMIT)
|
||||||
|
|
||||||
|
def _cancelled() -> bool:
|
||||||
|
return bool(should_cancel and should_cancel())
|
||||||
|
|
||||||
|
for attempt in (1, 2):
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
oauth_id, token = await open_session(client, source, force=attempt == 2)
|
||||||
|
# 缓存源基本字段,避免 rollback 后 ORM 对象过期导致属性访问失败
|
||||||
|
api_url = source.list_url
|
||||||
|
category_id = source.category_id
|
||||||
|
source_id = source.source_id
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
if _cancelled():
|
||||||
|
logger.info("91 vlog 同步被取消(第 %s 页前)", page)
|
||||||
|
return stat
|
||||||
|
data = await fetch_vlog_list(
|
||||||
|
client, api_url, oauth_id, token, page, page_size)
|
||||||
|
stat["pages"] = page
|
||||||
|
items = data["list"]
|
||||||
|
logger.info("91 vlog 第 %s 页:%s 条", page, len(items))
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if _cancelled():
|
||||||
|
logger.info("91 vlog 同步被取消(处理 item 中)")
|
||||||
|
return stat
|
||||||
|
vid = int(item.get("id") or 0)
|
||||||
|
if not vid:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
created = await import_vlog_video(
|
||||||
|
db, client, source, item, oauth_id, token,
|
||||||
|
api_url=api_url, category_id=category_id, source_id=source_id)
|
||||||
|
await db.commit()
|
||||||
|
if created:
|
||||||
|
stat["videos_new"] += 1
|
||||||
|
else:
|
||||||
|
stat["videos_exist"] += 1
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
logger.exception("导入 91 vlog %s 失败,跳过", vid)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
if not data["has_more"] or not items:
|
||||||
|
break
|
||||||
|
if max_pages and page >= max_pages:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
cfg["oauth_id"], cfg["token"] = oauth_id, token
|
||||||
|
source.config = json.dumps(cfg, ensure_ascii=False)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
_TOKENS.pop(int(source.id or 0), None)
|
||||||
|
if attempt == 2:
|
||||||
|
source.last_sync_at = datetime.now()
|
||||||
|
await db.commit()
|
||||||
|
raise
|
||||||
|
logger.warning("91 vlog 同步失败,重新登录后重试一次")
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
source.last_sync_at = datetime.now()
|
||||||
|
await db.commit()
|
||||||
|
return stat
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_vlog_playlist(source: CollectionSource, vid: int) -> str:
|
||||||
|
"""换取 vlog 播放列表(playUrl 带时效签名,播放时现取)"""
|
||||||
|
for attempt in (1, 2):
|
||||||
|
try:
|
||||||
|
return await _fetch_vlog_playlist_once(source, vid, force=attempt == 2)
|
||||||
|
except Exception:
|
||||||
|
_TOKENS.pop(int(source.id or 0), None)
|
||||||
|
if attempt == 2:
|
||||||
|
raise
|
||||||
|
logger.warning("91 vlog 播放列表获取失败,重新登录后重试一次")
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
raise RuntimeError("播放列表获取失败")
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_vlog_playlist_once(source: CollectionSource, vid: int, force: bool) -> str:
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
oauth_id, token = await open_session(client, source, force=force)
|
||||||
|
detail = await fetch_vlog_detail(client, source.list_url, oauth_id, token, vid)
|
||||||
|
m3u8 = detail.get("playUrl") or ""
|
||||||
|
if not m3u8:
|
||||||
|
raise RuntimeError("该视频暂无可播放地址(可能需付费或未解锁)")
|
||||||
|
resp = await client.get(
|
||||||
|
m3u8, headers={"User-Agent": HEADERS["User-Agent"]}, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
if not resp.text.lstrip().startswith("#EXTM3U"):
|
||||||
|
raise RuntimeError("源站播放列表异常")
|
||||||
|
return resp.text
|
||||||
@@ -14,6 +14,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# 内容源配置(域名轮换时只需改这里)
|
# 内容源配置(域名轮换时只需改这里)
|
||||||
SOURCE_KEY = "mdcmai4"
|
SOURCE_KEY = "mdcmai4"
|
||||||
|
SOURCE_CODE = "madou" # sources 表中的 code,用于给导入内容打「来源」标
|
||||||
UPSTREAM = "https://mdcmai4.xyz"
|
UPSTREAM = "https://mdcmai4.xyz"
|
||||||
PRODUCT_ID = 1
|
PRODUCT_ID = 1
|
||||||
CATEGORY_ID = 12 # 本站分类:AI短剧
|
CATEGORY_ID = 12 # 本站分类:AI短剧
|
||||||
@@ -37,6 +38,21 @@ def _proxy_url(path: str) -> str:
|
|||||||
return f"{SELF_API_BASE}/api/v1/m3u8/proxy?path={quote(path, safe='')}"
|
return f"{SELF_API_BASE}/api/v1/m3u8/proxy?path={quote(path, safe='')}"
|
||||||
|
|
||||||
|
|
||||||
|
async def default_source_id(db: AsyncSession) -> int:
|
||||||
|
"""麻豆等内容源的来源 id(sources.code 匹配;缺失时取第一个启用来源)"""
|
||||||
|
from app.models.video import Source
|
||||||
|
|
||||||
|
sid = (
|
||||||
|
await db.execute(select(Source.id).where(Source.code == SOURCE_CODE))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if sid:
|
||||||
|
return sid
|
||||||
|
sid = (
|
||||||
|
await db.execute(select(Source.id).where(Source.status == 1).order_by(Source.sort, Source.id))
|
||||||
|
).scalars().first()
|
||||||
|
return sid or 0
|
||||||
|
|
||||||
|
|
||||||
async def _import_drama(client: httpx.AsyncClient, db: AsyncSession, drama_id: int) -> tuple[int, int]:
|
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})
|
detail = await _fetch_json(client, f"{UPSTREAM}/api/v1/short-dramas/{drama_id}", {"productId": PRODUCT_ID})
|
||||||
@@ -63,6 +79,7 @@ async def _import_drama(client: httpx.AsyncClient, db: AsyncSession, drama_id: i
|
|||||||
description=(detail.get("description") or "")[:500],
|
description=(detail.get("description") or "")[:500],
|
||||||
author_id=AUTHOR_USER_ID,
|
author_id=AUTHOR_USER_ID,
|
||||||
category_id=CATEGORY_ID,
|
category_id=CATEGORY_ID,
|
||||||
|
source_id=await default_source_id(db),
|
||||||
total_episodes=0,
|
total_episodes=0,
|
||||||
view_count=int(detail.get("heatCount") or 0),
|
view_count=int(detail.get("heatCount") or 0),
|
||||||
status=1,
|
status=1,
|
||||||
@@ -94,6 +111,7 @@ async def _import_drama(client: httpx.AsyncClient, db: AsyncSession, drama_id: i
|
|||||||
Video(
|
Video(
|
||||||
user_id=AUTHOR_USER_ID,
|
user_id=AUTHOR_USER_ID,
|
||||||
category_id=CATEGORY_ID,
|
category_id=CATEGORY_ID,
|
||||||
|
source_id=series.source_id,
|
||||||
series_id=series.id,
|
series_id=series.id,
|
||||||
episode_no=ep_no,
|
episode_no=ep_no,
|
||||||
title=(ep.get("titleOverride") or ep.get("title") or f"第{ep_no}集")[:100],
|
title=(ep.get("titleOverride") or ep.get("title") or f"第{ep_no}集")[:100],
|
||||||
@@ -209,6 +227,7 @@ async def _import_video_item(db: AsyncSession, source: CollectionSource, item: d
|
|||||||
v = Video(
|
v = Video(
|
||||||
user_id=AUTHOR_USER_ID,
|
user_id=AUTHOR_USER_ID,
|
||||||
category_id=source.category_id,
|
category_id=source.category_id,
|
||||||
|
source_id=source.source_id or await default_source_id(db),
|
||||||
title=(item.get("title") or f"视频{vid}")[:100],
|
title=(item.get("title") or f"视频{vid}")[:100],
|
||||||
description=(item.get("description") or "")[:500],
|
description=(item.get("description") or "")[:500],
|
||||||
cover_url=_abs_cover(item)[:500],
|
cover_url=_abs_cover(item)[:500],
|
||||||
@@ -232,13 +251,23 @@ async def sync_source(
|
|||||||
max_pages: int = 0,
|
max_pages: int = 0,
|
||||||
stop_after_existing: int = 30,
|
stop_after_existing: int = 30,
|
||||||
delay: float = 0.25,
|
delay: float = 0.25,
|
||||||
|
should_cancel=None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""按源的 URL 模板翻页采集;连续 stop_after_existing 条已存在则提前停止(增量)"""
|
"""按源的 URL 模板翻页采集;连续 stop_after_existing 条已存在则提前停止(增量)。
|
||||||
stat = {"created": 0, "skipped": 0, "pages": 0, "stopped_early": False}
|
should_cancel 返回 True 时每页/每条边界退出(用于手动取消)"""
|
||||||
|
stat = {"created": 0, "skipped": 0, "pages": 0, "stopped_early": False, "cancelled": False}
|
||||||
consecutive_existing = 0
|
consecutive_existing = 0
|
||||||
|
|
||||||
|
def _cancelled() -> bool:
|
||||||
|
return bool(should_cancel and should_cancel())
|
||||||
|
|
||||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
page = 1
|
page = 1
|
||||||
while True:
|
while True:
|
||||||
|
if _cancelled():
|
||||||
|
stat["cancelled"] = True
|
||||||
|
logger.info("通用采集源 %s 同步被取消(第 %s 页前)", source.id, page)
|
||||||
|
return stat
|
||||||
url = source.list_url.replace("{{page}}", str(page))
|
url = source.list_url.replace("{{page}}", str(page))
|
||||||
data = await _fetch_json(client, url)
|
data = await _fetch_json(client, url)
|
||||||
items = data.get("items", [])
|
items = data.get("items", [])
|
||||||
@@ -246,6 +275,10 @@ async def sync_source(
|
|||||||
break
|
break
|
||||||
stat["pages"] = page
|
stat["pages"] = page
|
||||||
for item in items:
|
for item in items:
|
||||||
|
if _cancelled():
|
||||||
|
stat["cancelled"] = True
|
||||||
|
logger.info("通用采集源 %s 同步被取消(处理 item 中)", source.id)
|
||||||
|
return stat
|
||||||
if await _import_video_item(db, source, item):
|
if await _import_video_item(db, source, item):
|
||||||
stat["created"] += 1
|
stat["created"] += 1
|
||||||
consecutive_existing = 0
|
consecutive_existing = 0
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""标题关键词提取:从视频/系列标题拆分出词作为 tag。
|
||||||
|
|
||||||
|
提取规则(原 scripts/build_tags_from_titles.py,抽成公共模块供采集入库复用):
|
||||||
|
1. 番号(如 CAWD-601)及其系列前缀(CAWD)
|
||||||
|
2. 书名号/方括号内容(≤12 字,去掉「无码破解」「中文字幕」等标记词)
|
||||||
|
3. jieba TF-IDF 关键词 topK=6(过滤停用词、纯数字、单字)
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
import jieba.analyse
|
||||||
|
|
||||||
|
STOP = set(
|
||||||
|
"第 集 部 篇 全集 完结 中文字幕 无码 破解 无码破解 字幕 高清 最新 精选 视频 在线 观看 "
|
||||||
|
"一个 一起 没有 为了 开始 结果 只能 直接 就是 没想到 真爽".split()
|
||||||
|
)
|
||||||
|
|
||||||
|
CODE_RE = re.compile(r"[A-Za-z]{2,6}-\d{2,4}")
|
||||||
|
EPISODE_RE = re.compile(r"第[0-9一二三四五六七八九十百千零]+[集部期话卷章]")
|
||||||
|
PUNCT_RE = re.compile(r"[《》「」『』\[\]【】()()_\-::,,.。!!??…、/\\|~~\s]+")
|
||||||
|
BRACKET_RE = re.compile(r"[《「『\[【((]([^《》「」『』\[\]【】()()]{2,30})[》」』\]】))]")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_keywords(title: str) -> list[str]:
|
||||||
|
if not title:
|
||||||
|
return []
|
||||||
|
kws: list[str] = []
|
||||||
|
|
||||||
|
# 1. 番号 + 系列前缀
|
||||||
|
for code in CODE_RE.findall(title.upper()):
|
||||||
|
kws.append(code)
|
||||||
|
prefix = code.split("-")[0]
|
||||||
|
if len(prefix) >= 2:
|
||||||
|
kws.append(prefix)
|
||||||
|
|
||||||
|
# 2. 书名号/括号内容(剧名/主题)
|
||||||
|
for inner in BRACKET_RE.findall(title):
|
||||||
|
inner = inner.strip()
|
||||||
|
if 2 <= len(inner) <= 12 and inner not in STOP and not CODE_RE.search(inner):
|
||||||
|
kws.append(inner)
|
||||||
|
|
||||||
|
# 3. jieba TF-IDF
|
||||||
|
t = EPISODE_RE.sub(" ", title)
|
||||||
|
t = PUNCT_RE.sub(" ", t)
|
||||||
|
for w in jieba.analyse.extract_tags(t, topK=6):
|
||||||
|
w = w.strip()
|
||||||
|
if not (2 <= len(w) <= 12):
|
||||||
|
continue
|
||||||
|
if w in STOP or re.fullmatch(r"\d+", w):
|
||||||
|
continue
|
||||||
|
kws.append(w)
|
||||||
|
|
||||||
|
# 去重保序(按 casefold 键,避免 MySQL 不区分大小写的唯一键冲突)
|
||||||
|
seen: set[str] = set()
|
||||||
|
out: list[str] = []
|
||||||
|
for k in kws:
|
||||||
|
if k.isascii():
|
||||||
|
k = k.upper()
|
||||||
|
key = k.casefold()
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
out.append(k)
|
||||||
|
return out
|
||||||
@@ -20,7 +20,7 @@ from app.models.billing import WatchRecord
|
|||||||
from app.models.incentive import VideoEarning
|
from app.models.incentive import VideoEarning
|
||||||
from app.models.interaction import Favorite, Like
|
from app.models.interaction import Favorite, Like
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.video import Category, Tag, Video, VideoTag
|
from app.models.video import Category, Series, Tag, Video, VideoTag
|
||||||
from app.schemas.video import PublishVideoReq, UploadTokenReq
|
from app.schemas.video import PublishVideoReq, UploadTokenReq
|
||||||
from app.services import membership_service, sensitive_service, storage_service, wallet_service
|
from app.services import membership_service, sensitive_service, storage_service, wallet_service
|
||||||
|
|
||||||
@@ -79,6 +79,18 @@ async def serialize_videos(
|
|||||||
|
|
||||||
tags_map = await get_tags_map(db, video_ids)
|
tags_map = await get_tags_map(db, video_ids)
|
||||||
|
|
||||||
|
# 内容来源(麻豆 / 91 ...)
|
||||||
|
from app.models.video import Source
|
||||||
|
|
||||||
|
source_ids = {v.source_id for v in videos if v.source_id}
|
||||||
|
source_map = {}
|
||||||
|
if source_ids:
|
||||||
|
source_map = {
|
||||||
|
r.id: r.name for r in (
|
||||||
|
await db.execute(select(Source.id, Source.name).where(Source.id.in_(source_ids)))
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
|
||||||
liked: set[int] = set()
|
liked: set[int] = set()
|
||||||
favorited: set[int] = set()
|
favorited: set[int] = set()
|
||||||
if viewer_user_id:
|
if viewer_user_id:
|
||||||
@@ -101,6 +113,8 @@ async def serialize_videos(
|
|||||||
"video_url": v.video_url,
|
"video_url": v.video_url,
|
||||||
"duration": v.duration,
|
"duration": v.duration,
|
||||||
"category_id": v.category_id,
|
"category_id": v.category_id,
|
||||||
|
"source_id": v.source_id or 0,
|
||||||
|
"source_name": source_map.get(v.source_id, ""),
|
||||||
"author": _author_brief(authors.get(v.user_id)),
|
"author": _author_brief(authors.get(v.user_id)),
|
||||||
"view_count": v.view_count,
|
"view_count": v.view_count,
|
||||||
"like_count": v.like_count,
|
"like_count": v.like_count,
|
||||||
@@ -114,6 +128,16 @@ async def serialize_videos(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_sources(db: AsyncSession) -> dict:
|
||||||
|
"""启用中的内容来源列表(App 分类页来源筛选)"""
|
||||||
|
from app.models.video import Source
|
||||||
|
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(Source).where(Source.status == 1).order_by(Source.sort, Source.id)
|
||||||
|
)).scalars().all()
|
||||||
|
return {"list": [{"source_id": s.id, "name": s.name, "code": s.code} for s in rows]}
|
||||||
|
|
||||||
|
|
||||||
async def get_tags_map(db: AsyncSession, video_ids: list[int]) -> dict[int, list[str]]:
|
async def get_tags_map(db: AsyncSession, video_ids: list[int]) -> dict[int, list[str]]:
|
||||||
if not video_ids:
|
if not video_ids:
|
||||||
return {}
|
return {}
|
||||||
@@ -157,21 +181,110 @@ async def get_feed(
|
|||||||
cursor: str | None,
|
cursor: str | None,
|
||||||
limit: int,
|
limit: int,
|
||||||
category_id: int | None,
|
category_id: int | None,
|
||||||
|
source_id: int | None = None,
|
||||||
|
group: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
# group=series:系列型分类(如 AI短剧)按「剧」聚合,每部剧只返回首集一条
|
||||||
|
if group == "series":
|
||||||
|
return await _feed_series(db, identity, cursor, limit, category_id, source_id)
|
||||||
if feed_type == "latest":
|
if feed_type == "latest":
|
||||||
return await _feed_latest(db, identity, cursor, limit, category_id)
|
return await _feed_latest(db, identity, cursor, limit, category_id, source_id)
|
||||||
if feed_type in ("liked", "viewed"):
|
if feed_type in ("liked", "viewed"):
|
||||||
return await _feed_ranked(db, identity, feed_type, cursor, limit, category_id)
|
return await _feed_ranked(db, identity, feed_type, cursor, limit, category_id, source_id)
|
||||||
return await _feed_scored(db, redis, identity, feed_type, cursor, limit, category_id)
|
return await _feed_scored(db, redis, identity, feed_type, cursor, limit, category_id, source_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _feed_series(
|
||||||
|
db: AsyncSession,
|
||||||
|
identity: Identity,
|
||||||
|
cursor: str | None,
|
||||||
|
limit: int,
|
||||||
|
category_id: int | None,
|
||||||
|
source_id: int | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""系列聚合 Feed:一部剧一条(视频 id 为首集),按剧集播放量倒序。
|
||||||
|
|
||||||
|
卡片封面/标题取 Series(回退首集封面),点击后客户端拿到的 video_id 即首集,
|
||||||
|
播放页会按 /videos/{id}/series 拉全集列表,从而支持选集。
|
||||||
|
"""
|
||||||
|
if not category_id:
|
||||||
|
return {"next_cursor": None, "list": []}
|
||||||
|
|
||||||
|
# 仅返回至少有一集已发布的系列
|
||||||
|
live_series_ids = select(Video.series_id).where(
|
||||||
|
Video.status == STATUS_PUBLISHED, Video.series_id.isnot(None)
|
||||||
|
).distinct()
|
||||||
|
stmt = select(Series).where(
|
||||||
|
Series.status == 1,
|
||||||
|
Series.category_id == category_id,
|
||||||
|
Series.id.in_(live_series_ids),
|
||||||
|
)
|
||||||
|
if source_id:
|
||||||
|
stmt = stmt.where(Series.source_id == source_id)
|
||||||
|
|
||||||
|
offset = int(_decode_cursor(cursor).get("o", 0))
|
||||||
|
rows = list(
|
||||||
|
(await db.execute(
|
||||||
|
stmt.order_by(Series.view_count.desc(), Series.id.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit + 1)
|
||||||
|
)).scalars()
|
||||||
|
)
|
||||||
|
page, rows = (len(rows) > limit), rows[:limit]
|
||||||
|
if not rows:
|
||||||
|
return {"next_cursor": None, "list": []}
|
||||||
|
|
||||||
|
# 取这些剧的全部已发布集数,按 episode_no + id 找首集,同时统计实际集数
|
||||||
|
series_ids = [s.id for s in rows]
|
||||||
|
episodes = list(
|
||||||
|
(await db.execute(
|
||||||
|
select(Video).where(
|
||||||
|
Video.series_id.in_(series_ids), Video.status == STATUS_PUBLISHED
|
||||||
|
)
|
||||||
|
)).scalars()
|
||||||
|
)
|
||||||
|
first_ep: dict[int, Video] = {}
|
||||||
|
ep_count: dict[int, int] = {}
|
||||||
|
for v in episodes:
|
||||||
|
ep_count[v.series_id] = ep_count.get(v.series_id, 0) + 1
|
||||||
|
cur = first_ep.get(v.series_id)
|
||||||
|
if cur is None or _ep_key(v) < _ep_key(cur):
|
||||||
|
first_ep[v.series_id] = v
|
||||||
|
|
||||||
|
first_videos = [first_ep[s.id] for s in rows if s.id in first_ep]
|
||||||
|
serialized = await serialize_videos(db, first_videos, identity.user_id)
|
||||||
|
by_id = {it["video_id"]: it for it in serialized}
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for s in rows:
|
||||||
|
v = first_ep.get(s.id)
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
it = by_id[v.id]
|
||||||
|
# 系列封面/标题优先,空值回退首集自身字段
|
||||||
|
it["title"] = s.title or it["title"]
|
||||||
|
it["cover_url"] = s.cover_url or it["cover_url"]
|
||||||
|
it["view_count"] = s.view_count
|
||||||
|
it["series_id"] = s.id
|
||||||
|
it["total_episodes"] = ep_count.get(s.id, s.total_episodes)
|
||||||
|
items.append(it)
|
||||||
|
|
||||||
|
next_cursor = _encode_cursor({"o": offset + limit}) if page else None
|
||||||
|
return {"next_cursor": next_cursor, "list": items}
|
||||||
|
|
||||||
|
|
||||||
|
def _ep_key(v: Video) -> tuple[int, int]:
|
||||||
|
"""首集排序键:episode_no 缺失视为最大,再按 id 兜底"""
|
||||||
|
return (v.episode_no if v.episode_no is not None else 1_000_000, v.id)
|
||||||
|
|
||||||
|
|
||||||
async def _feed_ranked(
|
async def _feed_ranked(
|
||||||
db: AsyncSession, identity: Identity, feed_type: str, cursor: str | None,
|
db: AsyncSession, identity: Identity, feed_type: str, cursor: str | None,
|
||||||
limit: int, category_id: int | None,
|
limit: int, category_id: int | None, source_id: int | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""liked / viewed:按点赞量 / 播放量严格倒序(值 + id 打破并列游标)"""
|
"""liked / viewed:按点赞量 / 播放量严格倒序(值 + id 打破并列游标)"""
|
||||||
col = Video.like_count if feed_type == "liked" else Video.view_count
|
col = Video.like_count if feed_type == "liked" else Video.view_count
|
||||||
stmt = await _base_pool_stmt(category_id)
|
stmt = _base_pool_stmt(category_id, source_id)
|
||||||
c = _decode_cursor(cursor)
|
c = _decode_cursor(cursor)
|
||||||
if c:
|
if c:
|
||||||
val, vid = int(c["n"]), int(c["i"])
|
val, vid = int(c["n"]), int(c["i"])
|
||||||
@@ -191,18 +304,21 @@ async def _feed_ranked(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _base_pool_stmt(category_id: int | None):
|
def _base_pool_stmt(category_id: int | None, source_id: int | None = None):
|
||||||
stmt = select(Video).where(Video.status == STATUS_PUBLISHED, Video.published_at.isnot(None))
|
stmt = select(Video).where(Video.status == STATUS_PUBLISHED, Video.published_at.isnot(None))
|
||||||
if category_id:
|
if category_id:
|
||||||
stmt = stmt.where(Video.category_id == category_id)
|
stmt = stmt.where(Video.category_id == category_id)
|
||||||
|
if source_id:
|
||||||
|
stmt = stmt.where(Video.source_id == source_id)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
async def _feed_latest(
|
async def _feed_latest(
|
||||||
db: AsyncSession, identity: Identity, cursor: str | None, limit: int, category_id: int | None
|
db: AsyncSession, identity: Identity, cursor: str | None, limit: int, category_id: int | None,
|
||||||
|
source_id: int | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""latest:published_at 游标倒序(ts + id 打破并列)"""
|
"""latest:published_at 游标倒序(ts + id 打破并列)"""
|
||||||
stmt = await _base_pool_stmt(category_id)
|
stmt = _base_pool_stmt(category_id, source_id)
|
||||||
c = _decode_cursor(cursor)
|
c = _decode_cursor(cursor)
|
||||||
if c:
|
if c:
|
||||||
ts, vid = datetime.fromtimestamp(c["p"]), int(c["i"])
|
ts, vid = datetime.fromtimestamp(c["p"]), int(c["i"])
|
||||||
@@ -260,6 +376,7 @@ async def _feed_scored(
|
|||||||
cursor: str | None,
|
cursor: str | None,
|
||||||
limit: int,
|
limit: int,
|
||||||
category_id: int | None,
|
category_id: int | None,
|
||||||
|
source_id: int | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""recommend / hot:候选池打分 → 当日种子确定性加权抽样 → 当日去重(不足兜底)→ 偏移游标"""
|
"""recommend / hot:候选池打分 → 当日种子确定性加权抽样 → 当日去重(不足兜底)→ 偏移游标"""
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
@@ -267,7 +384,7 @@ async def _feed_scored(
|
|||||||
subject = identity.subject or "anon"
|
subject = identity.subject or "anon"
|
||||||
|
|
||||||
# 1. 候选池:近 30 天已发布,封顶 FEED_POOL_SIZE
|
# 1. 候选池:近 30 天已发布,封顶 FEED_POOL_SIZE
|
||||||
stmt = (await _base_pool_stmt(category_id)).where(
|
stmt = _base_pool_stmt(category_id, source_id).where(
|
||||||
Video.published_at > now - timedelta(days=30)
|
Video.published_at > now - timedelta(days=30)
|
||||||
).order_by(Video.published_at.desc()).limit(FEED_POOL_SIZE)
|
).order_by(Video.published_at.desc()).limit(FEED_POOL_SIZE)
|
||||||
pool = list((await db.execute(stmt)).scalars())
|
pool = list((await db.execute(stmt)).scalars())
|
||||||
@@ -505,7 +622,20 @@ async def list_categories(db: AsyncSession) -> list[dict]:
|
|||||||
rows = (await db.execute(
|
rows = (await db.execute(
|
||||||
select(Category).where(Category.status == 1).order_by(Category.sort, Category.id)
|
select(Category).where(Category.status == 1).order_by(Category.sort, Category.id)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
return [{"category_id": c.id, "name": c.name} for c in rows]
|
# 系列型分类标记(该分类下存在正常状态的系列),App 据此按「剧」聚合展示
|
||||||
|
cnt_rows = (await db.execute(
|
||||||
|
select(Series.category_id, func.count()).where(Series.status == 1)
|
||||||
|
.group_by(Series.category_id)
|
||||||
|
)).all()
|
||||||
|
series_cnt = {cid: n for cid, n in cnt_rows}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"category_id": c.id,
|
||||||
|
"name": c.name,
|
||||||
|
"has_series": series_cnt.get(c.id, 0) > 0,
|
||||||
|
}
|
||||||
|
for c in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# ---------- 上传发布(5.5 / 5.6 / 5.7) ----------
|
# ---------- 上传发布(5.5 / 5.6 / 5.7) ----------
|
||||||
|
|||||||
@@ -4,79 +4,25 @@
|
|||||||
cd /root/project/video/server
|
cd /root/project/video/server
|
||||||
.venv/bin/python scripts/build_tags_from_titles.py [--dry-run]
|
.venv/bin/python scripts/build_tags_from_titles.py [--dry-run]
|
||||||
|
|
||||||
提取规则:
|
提取规则见 app/services/title_tags.py(幂等可重复执行)。
|
||||||
1. 番号(如 CAWD-601)及其系列前缀(CAWD)
|
|
||||||
2. 书名号/方括号内容(≤12 字,去掉「无码破解」「中文字幕」等标记词)
|
|
||||||
3. jieba TF-IDF 关键词 topK=6(过滤停用词、纯数字、单字)
|
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
import jieba.analyse # noqa: E402
|
|
||||||
from sqlalchemy import select # noqa: E402
|
from sqlalchemy import select # noqa: E402
|
||||||
|
|
||||||
from app.database import SessionFactory, engine # noqa: E402
|
from app.database import SessionFactory, engine # noqa: E402
|
||||||
from app.models.video import Tag, Video, VideoTag # noqa: E402
|
from app.models.video import Tag, Video, VideoTag # noqa: E402
|
||||||
|
from app.services.title_tags import extract_keywords # noqa: E402
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
STOP = set(
|
|
||||||
"第 集 部 篇 全集 完结 中文字幕 无码 破解 无码破解 字幕 高清 最新 精选 视频 在线 观看 "
|
|
||||||
"一个 一起 没有 为了 开始 结果 只能 直接 就是 没想到 真爽".split()
|
|
||||||
)
|
|
||||||
|
|
||||||
CODE_RE = re.compile(r"[A-Za-z]{2,6}-\d{2,4}")
|
|
||||||
EPISODE_RE = re.compile(r"第[0-9一二三四五六七八九十百千零]+[集部期话卷章]")
|
|
||||||
PUNCT_RE = re.compile(r"[《》「」『』\[\]【】()()_\-::,,.。!!??…、/\\|~~\s]+")
|
|
||||||
BRACKET_RE = re.compile(r"[《「『\[【((]([^《》「」『』\[\]【】()()]{2,30})[》」』\]】))]")
|
|
||||||
|
|
||||||
|
|
||||||
def extract_keywords(title: str) -> list[str]:
|
|
||||||
kws: list[str] = []
|
|
||||||
|
|
||||||
# 1. 番号 + 系列前缀
|
|
||||||
for code in CODE_RE.findall(title.upper()):
|
|
||||||
kws.append(code)
|
|
||||||
prefix = code.split("-")[0]
|
|
||||||
if len(prefix) >= 2:
|
|
||||||
kws.append(prefix)
|
|
||||||
|
|
||||||
# 2. 书名号/括号内容(剧名/主题)
|
|
||||||
for inner in BRACKET_RE.findall(title):
|
|
||||||
inner = inner.strip()
|
|
||||||
if 2 <= len(inner) <= 12 and inner not in STOP and not CODE_RE.search(inner):
|
|
||||||
kws.append(inner)
|
|
||||||
|
|
||||||
# 3. jieba TF-IDF
|
|
||||||
t = EPISODE_RE.sub(" ", title)
|
|
||||||
t = PUNCT_RE.sub(" ", t)
|
|
||||||
for w in jieba.analyse.extract_tags(t, topK=6):
|
|
||||||
w = w.strip()
|
|
||||||
if not (2 <= len(w) <= 12):
|
|
||||||
continue
|
|
||||||
if w in STOP or re.fullmatch(r"\d+", w):
|
|
||||||
continue
|
|
||||||
kws.append(w)
|
|
||||||
|
|
||||||
# 去重保序(按 casefold 键,避免 MySQL 不区分大小写的唯一键冲突)
|
|
||||||
seen: set[str] = set()
|
|
||||||
out: list[str] = []
|
|
||||||
for k in kws:
|
|
||||||
if k.isascii():
|
|
||||||
k = k.upper()
|
|
||||||
key = k.casefold()
|
|
||||||
if key not in seen:
|
|
||||||
seen.add(key)
|
|
||||||
out.append(k)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
async def main(dry_run: bool) -> None:
|
async def main(dry_run: bool) -> None:
|
||||||
async with SessionFactory() as db:
|
async with SessionFactory() as db:
|
||||||
|
|||||||
Reference in New Issue
Block a user