This commit is contained in:
lz_db
2026-10-25 11:46:44 +08:00
parent 893dfed0ed
commit 494f667719
63 changed files with 5213 additions and 239 deletions
+104
View File
@@ -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
```
+192
View File
@@ -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>&timestamp=<ts><sign_key>'))"""
base = f"client=pwa&data={data_hex}&timestamp={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&timestamp={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()
+57
View File
@@ -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
View File
@@ -21,11 +21,21 @@ export function offlineVideo(videoId, 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) {
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) {
return request.delete(`/admin/videos/${videoId}`)
+2
View File
@@ -71,6 +71,7 @@ const menuConfig = [
{ title: '发布视频', path: '/videos/create', icon: 'Upload', roles: [] },
{ title: '视频管理', path: '/videos', icon: 'Film', roles: [] },
{ title: '系列管理', path: '/series', icon: 'CollectionTag', roles: [] },
{ title: '来源管理', path: '/sources', icon: 'Flag', roles: [] },
{ title: '采集源管理', path: '/collect-sources', icon: 'Download', roles: [] },
{ title: '分类管理', path: '/categories', icon: 'Menu', roles: [] },
{ title: '标签管理', path: '/tags', icon: 'PriceTag', roles: [] },
@@ -103,6 +104,7 @@ const menuConfig = [
{ title: '推荐风控', path: '/referrals', icon: 'Share', roles: [] },
{ title: '敏感词库', path: '/sensitive-words', icon: 'Collection', roles: [] },
{ title: '平台配置', path: '/configs', icon: 'Setting', roles: [] },
{ title: '数据库备份', path: '/system/backup', icon: 'Coin', roles: [] },
{ title: '操作日志', path: '/logs', icon: 'Document', roles: [] },
],
},
+18
View File
@@ -58,6 +58,12 @@ const routes = [
component: () => import('@/views/SeriesManage.vue'),
meta: { title: '系列管理', icon: 'CollectionTag', roles: [] },
},
{
path: 'sources',
name: 'source-manage',
component: () => import('@/views/SourceManage.vue'),
meta: { title: '来源管理', icon: 'Flag', roles: [] },
},
{
path: 'collect-sources',
name: 'collect-sources',
@@ -148,6 +154,18 @@ const routes = [
component: () => import('@/views/ConfigList.vue'),
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',
name: 'log-list',
+133 -13
View File
@@ -14,12 +14,28 @@
<el-alert type="info" :closable="false" style="margin-bottom: 12px">
添加"分类名 + 列表URL"后点同步即可自动采集;URL 中用 <code v-pre>{{page}}</code> 表示页码占位。
分类名不存在时会自动创建。同步为增量:已入库的视频自动跳过。
分类名不存在时会自动创建。同步为增量:已入库的视频自动跳过。<br>
91 短剧类型:填写源站账号密码,自动登录并按源站分类拉取短剧列表与全部集数,入库为「系列 + 剧集」,仅保存封面地址与播放代理地址。
</el-alert>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="60" />
<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 label="状态" width="80">
<template #default="{ row }">
@@ -31,9 +47,10 @@
<el-table-column prop="last_sync_at" label="上次同步" width="160">
<template #default="{ row }">{{ row.last_sync_at || '从未' }}</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<el-table-column label="操作" width="290" fixed="right">
<template #default="{ row }">
<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="toggleStatus(row)">
{{ row.status === 1 ? '停用' : '启用' }}
@@ -68,13 +85,48 @@
</el-dialog>
<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-input v-model="form.name" maxlength="50" placeholder="如:每日更新(不存在会自动创建分类)" />
</el-form-item>
<el-form-item label="列表 URL">
<el-input v-model="form.list_url" type="textarea" :rows="3"
placeholder="https://example.com/api/v1/videos?categoryId=27&page={{page}}&size=60" />
<el-form-item label="接口地址">
<el-input v-model="form.list_url" type="textarea" :rows="2"
: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>
<template #footer>
@@ -96,9 +148,18 @@ const dialogVisible = ref(false)
const editing = ref(null)
const submitting = ref(false)
const syncingId = ref(0)
const cancellingId = ref(0)
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 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() {
loading.value = true
@@ -133,26 +204,58 @@ async function load() {
function showCreate() {
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
}
function showEdit(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
}
function parseCategoryIds(text) {
return (text || '').split(/[,,\s]+/).map(s => parseInt(s, 10)).filter(n => n > 0)
}
async function save() {
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
try {
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('已更新')
} else {
await request({ url: '/admin/collection/sources', method: 'post', data: form })
await request({ url: '/admin/collection/sources', method: 'post', data: payload })
ElMessage.success('已添加,点击"同步"开始采集')
}
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() {
syncAllLoading.value = true
try {
+58 -8
View File
@@ -8,6 +8,11 @@
<el-input v-model="keyword" placeholder="搜索系列标题" clearable
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-button :loading="batchLoading">批量更新 <el-icon class="el-icon--right"><arrow-down /></el-icon></el-button>
<template #dropdown>
@@ -47,9 +52,15 @@
</template>
</el-table-column>
<el-table-column prop="total_episodes" label="集数" width="90" />
<el-table-column label="来源" width="80">
<el-table-column label="来源" width="110">
<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>
</template>
</el-table-column>
@@ -64,7 +75,7 @@
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<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>
<el-button link type="primary" @click="toggleStatus(row)">
{{ row.status === 1 ? '隐藏' : '恢复' }}
@@ -120,6 +131,11 @@
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</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-input v-model="form.description" type="textarea" :rows="3" maxlength="500" show-word-limit />
</el-form-item>
@@ -151,6 +167,13 @@
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</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>
<template #footer>
<el-button @click="batchDialogVisible = false">取消</el-button>
@@ -173,6 +196,8 @@ const pageSize = 20
const loading = ref(false)
const keyword = ref('')
const categories = ref([])
const sources = ref([])
const filterSourceId = ref(null)
const dialogVisible = ref(false)
const editing = ref(null)
const submitting = ref(false)
@@ -184,12 +209,13 @@ const batchDialogVisible = ref(false)
const batchSubmitting = ref(false)
const form = reactive({
title: '', cover_url: '', description: '', category_id: 0,
title: '', cover_url: '', description: '', category_id: 0, source_id: 0,
})
const batchForm = reactive({
touchStatus: false, status: 1,
touchCategory: false, category_id: 0,
touchSource: false, source_id: 0,
})
function onSelectionChange(rows) {
@@ -224,18 +250,34 @@ async function uploadCover(file) {
onMounted(async () => {
load()
loadSources()
try {
const r = await request({ url: '/admin/categories', method: 'get' })
categories.value = r || []
} 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() {
loading.value = true
try {
const r = await request({
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
total.value = r.total
@@ -246,7 +288,10 @@ async function load() {
function showCreate() {
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
}
@@ -255,6 +300,7 @@ function showEdit(row) {
Object.assign(form, {
title: row.title, cover_url: row.cover_url,
description: row.description, category_id: row.category_id ?? 0,
source_id: row.source_id ?? 0,
})
dialogVisible.value = true
}
@@ -344,12 +390,15 @@ async function handleBatchCmd(cmd) {
}
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
}
async function batchSave() {
if (!batchForm.touchStatus && !batchForm.touchCategory) {
if (!batchForm.touchStatus && !batchForm.touchCategory && !batchForm.touchSource) {
ElMessage.warning('至少勾选一个要修改的字段')
return
}
@@ -358,6 +407,7 @@ async function batchSave() {
const data = { series_ids: selectedIds.value }
if (batchForm.touchStatus) data.status = batchForm.status
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 })
ElMessage.success(`已更新 ${r.affected} 个系列`)
batchDialogVisible.value = false
+155
View File
@@ -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>
+218
View File
@@ -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>
+156
View File
@@ -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>
+113 -5
View File
@@ -8,11 +8,23 @@
</el-select>
<el-input v-model="filters.author_id" placeholder="作者 ID" clearable style="width: 140px"
@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 :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>
<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 label="封面" width="100">
<template #default="{ row }">
@@ -28,6 +40,12 @@
<el-table-column label="分类" width="90">
<template #default="{ row }">{{ categoryName(row.category_id) }}</template>
</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">
<template #default="{ row }">
<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-select>
</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-select v-model="editForm.series_id" placeholder="不归属系列" style="width: 100%" clearable
@change="onEditSeriesChange">
@@ -104,6 +127,20 @@
<el-button type="primary" :loading="editSubmitting" @click="saveEdit">保存</el-button>
</template>
</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>
</template>
@@ -113,7 +150,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
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 { askReason } from '@/utils/reason'
@@ -133,10 +170,17 @@ const rows = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filters = reactive({ keyword: '', status: '', author_id: '' })
const filters = reactive({ keyword: '', status: '', author_id: '', source_id: '' })
const categories = 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 previewUrl = ref('')
@@ -148,7 +192,7 @@ const coverUploading = ref(false)
const editingVideoId = ref(null)
const editForm = reactive({
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,
})
@@ -168,6 +212,7 @@ async function load() {
keyword: filters.keyword,
status: filters.status,
author_id: filters.author_id,
source_id: filters.source_id,
page: page.value,
page_size: pageSize.value,
})
@@ -180,10 +225,35 @@ async function load() {
function search() { page.value = 1; load() }
function reset() {
filters.keyword = ''; filters.status = ''; filters.author_id = ''
filters.keyword = ''; filters.status = ''; filters.author_id = ''; filters.source_id = ''
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) {
previewUrl.value = row.video_url
previewTitle.value = `预览:${row.title}`
@@ -200,6 +270,38 @@ async function offline(row) {
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) {
try {
await ElMessageBox.confirm(
@@ -221,6 +323,7 @@ function openEdit(row) {
description: row.description || '',
cover_url: row.cover_url || '',
category_id: row.category_id || 0,
source_id: row.source_id || 0,
series_id: row.series_id || null,
episode_no: row.episode_no || null,
status: row.status,
@@ -260,6 +363,7 @@ async function saveEdit() {
description: editForm.description,
cover_url: editForm.cover_url,
category_id: editForm.category_id || 0,
source_id: editForm.source_id || 0,
status: editForm.status,
}
// 只有用户动过才提交
@@ -285,6 +389,10 @@ onMounted(async () => {
const r = await request({ url: '/admin/series', method: 'get', params: { page_size: 200 } })
seriesList.value = r?.list || []
} catch (e) {}
try {
const r = await request({ url: '/admin/sources', method: 'get' })
sources.value = r.list || []
} catch (e) {}
})
</script>
+134
View File
@@ -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;
}
}
}
+18
View File
@@ -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._();
}
+2
View File
@@ -27,6 +27,7 @@ class Endpoints {
// ---------- 视频 ----------
static const feed = '/videos/feed';
static const categories = '/videos/categories';
static const sources = '/videos/sources';
static const uploadToken = '/videos/upload/token';
static const uploadFile = '/videos/upload/file';
static const publish = '/videos';
@@ -42,6 +43,7 @@ class Endpoints {
static String videoComments(int videoId) => '/videos/$videoId/comments';
static String videoEdit(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';
// ---------- 互动 ----------
+15
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import '../log/action_logger.dart';
import '../media/local_media_server.dart';
/// 一个播放槽位:Player + 视图控制器 + 当前绑定的视频
@@ -192,6 +193,10 @@ class FeedPlayerPool {
/// 只有 stop 能可靠断开下载;位置存到 _resumePosition,恢复时 rebind
/// 重新 open + seek 续播(分片走本地代理磁盘缓存,零流量)
void suspend() {
ActionLogger.I.log('player_suspend', page: 'feed_pool', detail: {
'generation': _generation,
'currentIndex': _currentIndex,
});
_suspended = true;
// 使在途 bindWindow 过期:其 open 完成后不得再 play
_generation++;
@@ -213,6 +218,11 @@ class FeedPlayerPool {
/// 挂起并 play。_suspended 必须在 await 之后才解除——否则等待期间 completed
/// listener 的 auto-loop 会因为看到 _suspended=false 而提前 play
Future<void> resume() async {
ActionLogger.I.log('player_resume', page: 'feed_pool', detail: {
'generation': _generation,
'currentIndex': _currentIndex,
'suspended': _suspended,
});
final pending = _pendingBind;
if (pending != null) {
try {
@@ -274,6 +284,11 @@ class FeedPlayerPool {
/// 与 pause 不同,stop 后缓存下载断开,且槽内无媒体不会幽灵出声。
/// 返回 Feed 后需重新 bindWindow 恢复当前页。
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)
_generation++;
// stopAll 后通常跟着 rebind 重新播放(如从登录页返回),解除挂起态
+38
View File
@@ -8,6 +8,8 @@ class VideoItem {
this.duration = 0,
required this.author,
this.categoryId = 0,
this.sourceId = 0,
this.sourceName = '',
this.viewCount = 0,
this.likeCount = 0,
this.commentCount = 0,
@@ -19,6 +21,8 @@ class VideoItem {
this.status = -1,
this.statusText = '',
this.rejectReason = '',
this.seriesId = 0,
this.totalEpisodes = 0,
});
final int videoId;
@@ -28,6 +32,11 @@ class VideoItem {
final int duration; // 秒
final VideoAuthor author;
final int categoryId;
/// 内容来源(麻豆 / 91 ...)
final int sourceId;
final String sourceName;
final int viewCount;
final int likeCount;
final int commentCount;
@@ -42,6 +51,12 @@ class VideoItem {
final String statusText;
final String rejectReason;
/// 所属系列 id(0 = 非系列视频);系列聚合 Feed 中为首集 video_id
final int seriesId;
/// 系列总集数(系列聚合 Feed 返回,用于卡片「全N集」角标)
final int totalEpisodes;
/// 拷贝更新(点赞/收藏乐观更新用)
VideoItem copyWith({
bool? isLiked,
@@ -58,6 +73,8 @@ class VideoItem {
duration: duration,
author: author,
categoryId: categoryId,
sourceId: sourceId,
sourceName: sourceName,
viewCount: viewCount,
likeCount: likeCount ?? this.likeCount,
commentCount: commentCount ?? this.commentCount,
@@ -69,6 +86,8 @@ class VideoItem {
status: status,
statusText: statusText,
rejectReason: rejectReason,
seriesId: seriesId,
totalEpisodes: totalEpisodes,
);
factory VideoItem.fromJson(Map<String, dynamic> json) => VideoItem(
@@ -78,6 +97,8 @@ class VideoItem {
videoUrl: (json['video_url'] as String?) ?? '',
duration: (json['duration'] 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(
(json['author'] as Map<String, dynamic>?) ?? const {},
),
@@ -94,6 +115,23 @@ class VideoItem {
status: (json['status'] as num?)?.toInt() ?? -1,
statusText: (json['status_text'] 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?) ?? '',
);
}
+7
View File
@@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/log/action_logger.dart';
import '../core/network/api_client.dart';
import '../core/network/api_exception.dart';
import '../core/storage/token_store.dart';
@@ -46,6 +47,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
try {
final user = await UserService.fetchMe();
state = AuthState(user: user, restored: true);
ActionLogger.I.bindUser(user.email);
} on ApiException catch (e) {
// 仅 Token 确认失效(1002)才清理回游客态;
// 网络异常等情况保留 Token,下次启动/请求重试恢复
@@ -62,9 +64,12 @@ class AuthNotifier extends StateNotifier<AuthState> {
/// 需补拉全量资料,否则积分显示为 0、编辑资料保存会把服务端字段清空。
Future<void> onLoginSuccess(UserModel user) async {
state = AuthState(user: user, restored: true);
ActionLogger.I.bindUser(user.email);
try {
final full = await UserService.fetchMe();
state = state.copyWith(user: full);
// 全量资料里的 email 字段更权威(用户可能登录后改过邮箱)
ActionLogger.I.bindUser(full.email);
} catch (_) {
// 补拉失败不影响登录态,保留简要信息
}
@@ -75,11 +80,13 @@ class AuthNotifier extends StateNotifier<AuthState> {
/// 退出登录:先通知服务端注销(失败忽略,本地照常清理),再清空登录态
Future<void> logout() async {
ActionLogger.I.log('logout', page: 'mine');
try {
await AuthService.logout();
} catch (_) {
// 服务端已不可达或 Token 已失效:本地清理即可
}
ActionLogger.I.unbind();
await _clearLocal();
}
+22 -2
View File
@@ -16,7 +16,10 @@ enum FeedStrategy {
latest('latest', '最新'),
hot('hot', '热度'),
liked('liked', '口碑'),
viewed('viewed', '热播');
viewed('viewed', '热播'),
// 搜索播放专用策略:数据由搜索页注入,不走网络 refresh;
// 顶部 Tab 不显示该项,仅作为独立播放器池存在
search('search', '搜索');
const FeedStrategy(this.apiValue, this.label);
@@ -106,7 +109,10 @@ class FeedState {
class FeedNotifier extends StateNotifier<FeedState> {
FeedNotifier(this._ref, this.strategy) : super(const FeedState()) {
pool = FeedPlayerPool(onWatchComplete: _onWatchComplete)..init();
refresh();
// search 策略由外部 injectItems 灌入数据,不走网络 refresh
if (strategy != FeedStrategy.search) {
refresh();
}
}
final Ref _ref;
@@ -123,6 +129,8 @@ class FeedNotifier extends StateNotifier<FeedState> {
/// 刷新(首屏 / 下拉 / 切策略)
Future<void> refresh() async {
// search 策略不支持下拉刷新(数据由搜索页注入)
if (strategy == FeedStrategy.search) return;
state = state.copyWith(isRefreshing: true, loadFailed: false);
try {
final page = await VideoService.fetchFeed(type: strategy.apiValue);
@@ -142,6 +150,8 @@ class FeedNotifier extends StateNotifier<FeedState> {
/// 加载更多(游标分页)
Future<void> loadMore() async {
// search 策略暂不支持自动加载更多(如需要可由搜索页同步注入新数据)
if (strategy == FeedStrategy.search) return;
if (!state.hasMore || state.isLoadingMore) return;
state = state.copyWith(isLoadingMore: true);
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 清空了槽位,重新绑定当前页并播放。
/// 不上报观看(本次进入 Feed 时已上报过,且 _bindAndReport 有按日去重)。
Future<void> rebindCurrent() async {
+10
View File
@@ -20,6 +20,7 @@ import '../screens/profile/invite_screen.dart';
import '../screens/profile/profile_edit_screen.dart';
import '../screens/profile/user_profile_screen.dart';
import '../screens/profile/video_list_screens.dart';
import '../screens/search/search_feed_screen.dart';
import '../screens/search/search_screen.dart';
import '../screens/search/tag_videos_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 '../services/payment_service.dart';
/// 全局路由观察者:FeedScreen 等需要感知「上层路由 pop 露出本页」的场景使用
final routeObserver = RouteObserver<PageRoute<dynamic>>();
/// 声明式路由(02-技术文档:go_router)
/// /home 对游客开放(游客可浏览 Feed,观看超限 2002 时才引导登录);
/// 受保护页面(上传/钱包/会员等)在各自页面入口校验登录态并跳 /login。
@@ -42,6 +46,7 @@ final routerProvider = Provider<GoRouter>((ref) {
return GoRouter(
initialLocation: '/',
refreshListenable: refresh,
observers: [routeObserver],
redirect: (context, state) {
final auth = ref.read(authProvider);
final loggingIn = state.matchedLocation == '/login' ||
@@ -58,6 +63,11 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute(path: '/register', builder: (_, __) => const RegisterScreen()),
GoRoute(path: '/home', builder: (_, __) => const HomeShell()),
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
// 搜索结果播放(复用 Feed 池的 search 策略,支持上下滑)
GoRoute(
path: '/search/feed',
builder: (_, __) => const SearchFeedScreen(),
),
// 标签视频页(游客可)
GoRoute(
path: '/tags/:name',
+122 -8
View File
@@ -3,8 +3,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/media/cover_cache.dart';
import '../../core/theme/app_theme.dart';
import '../../models/video_item.dart';
import '../../providers/feed_provider.dart';
import '../../services/upload_service.dart';
import '../../services/video_service.dart';
@@ -19,13 +21,25 @@ class CategoryScreen extends ConsumerStatefulWidget {
class _CategoryScreenState extends ConsumerState<CategoryScreen> {
List<VideoCategory> _categories = const [];
List<VideoSource> _sources = const [];
int? _selected; // null = 全部
int? _selectedSource; // null = 全部来源
List<VideoItem> _items = const [];
String? _cursor;
bool _loading = true;
bool _loadingMore = 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();
@override
@@ -56,13 +70,19 @@ class _CategoryScreenState extends ConsumerState<CategoryScreen> {
try {
final results = await Future.wait([
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;
setState(() {
_categories = results[0] as List<VideoCategory>;
_items = (results[1] as FeedPage).items;
_cursor = (results[1] as FeedPage).nextCursor;
_sources = results[1] as List<VideoSource>;
_items = (results[2] as FeedPage).items;
_cursor = (results[2] as FeedPage).nextCursor;
_loading = false;
});
} catch (_) {
@@ -80,6 +100,12 @@ class _CategoryScreenState extends ConsumerState<CategoryScreen> {
await _reload();
}
Future<void> _selectSource(int? id) async {
if (id == _selectedSource) return;
setState(() => _selectedSource = id);
await _reload();
}
Future<void> _reload() async {
setState(() {
_loading = true;
@@ -87,7 +113,11 @@ class _CategoryScreenState extends ConsumerState<CategoryScreen> {
});
try {
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;
setState(() {
_items = page.items;
@@ -108,7 +138,12 @@ class _CategoryScreenState extends ConsumerState<CategoryScreen> {
setState(() => _loadingMore = true);
try {
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;
setState(() {
_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()),
],
),
@@ -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() {
if (_loading && _items.isEmpty) {
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});
final VideoItem item;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
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(
borderRadius: BorderRadius.circular(8),
child: Stack(
@@ -248,6 +322,7 @@ class _CategoryCard extends StatelessWidget {
child: item.coverUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: item.coverUrl,
cacheManager: CoverCacheManager.I,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const Icon(
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(
left: 0,
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/media/cover_cache.dart';
import '../../core/media/download_manager.dart';
import '../../core/theme/app_theme.dart';
import '../../models/video_item.dart';
@@ -107,7 +108,9 @@ class DownloadsScreen extends ConsumerWidget {
width: 72, height: 48,
child: item.coverUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: item.coverUrl, fit: BoxFit.cover)
imageUrl: item.coverUrl,
cacheManager: CoverCacheManager.I,
fit: BoxFit.cover)
: Container(
color: Colors.grey[800],
child: const Icon(Icons.play_circle_outline,
+40 -3
View File
@@ -4,9 +4,11 @@ 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 '../../router.dart';
import 'widgets/comment_sheet.dart';
import 'widgets/feed_overlays.dart';
import 'widgets/feed_video_page.dart';
@@ -23,7 +25,7 @@ class FeedScreen extends ConsumerStatefulWidget {
}
class _FeedScreenState extends ConsumerState<FeedScreen>
with WidgetsBindingObserver {
with WidgetsBindingObserver, RouteAware {
FeedStrategy _strategy = FeedStrategy.recommend;
final PageController _pageController = PageController();
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
void dispose() {
WidgetsBinding.instance.removeObserver(this);
routeObserver.unsubscribe(this);
_eventSub?.cancel();
// 离开 Feed 路由(如跳登录页):彻底停止播放(断音+断缓存下载)。
// feedProvider 是全局常驻,不处理则播放器池在后台继续出声
@@ -101,7 +130,13 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
.read(feedProvider(_strategy).notifier)
.events
.listen(_handleEvent);
_pageController.jumpToPage(0);
// 保留每个策略的播放进度(currentIndex 由 provider 维护):切回时跳到上次位置而非归零
final targetIndex = ref.read(feedProvider(_strategy)).currentIndex;
if (_pageController.hasClients && targetIndex > 0) {
_pageController.jumpToPage(targetIndex);
} else if (_pageController.hasClients) {
_pageController.jumpToPage(0);
}
// jumpToPage 后 resume + rebindCurrent:suspend 已 stop 槽位,仅 play
// 播不起来,必须 rebind 重新 open(有断点记录则 seek 续播)
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -186,7 +221,9 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
child: Row(
children: [
const Spacer(),
for (final s in FeedStrategy.values) ...[
for (final s in FeedStrategy.values.where(
(s) => s != FeedStrategy.search,
)) ...[
_StrategyTab(
label: s.label,
selected: s == _strategy,
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/media/cover_cache.dart';
import '../../../core/network/api_exception.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/comment_item.dart';
@@ -423,7 +424,7 @@ class CommentTile extends StatelessWidget {
radius: compact ? 12 : 16,
backgroundColor: AppColors.background,
backgroundImage: comment.avatarUrl.isNotEmpty
? CachedNetworkImageProvider(comment.avatarUrl)
? CachedNetworkImageProvider(comment.avatarUrl, cacheManager: CoverCacheManager.I)
: null,
child: comment.avatarUrl.isEmpty
? Text(
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart';
import '../../../core/media/download_manager.dart';
import '../../../core/network/api_exception.dart';
import '../../../core/player/feed_player_pool.dart';
import '../../../core/theme/app_theme.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 String _kSiteAdminEmail = '256kb@gmail.com';
bool get _isSiteAdmin {
final u = ref.read(authProvider).user;
return u != null &&
(u.email ?? '').toLowerCase() == _kSiteAdminEmail;
}
@override
void initState() {
super.initState();
@@ -70,6 +80,8 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
_seriesData != null && (_seriesData!['has_series'] as bool? ?? false);
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 [];
@@ -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: (_) {
final dm = ref.read(downloadManagerProvider);
@@ -237,6 +264,44 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
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() {
showModalBottomSheet<void>(
context: context,
@@ -278,10 +343,32 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
maxLines: 1, overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
'共 ${_episodes.length} 集',
style: TextStyle(
fontSize: 12, color: Colors.grey[600]),
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(
'共 ${_episodes.length} 集',
style: TextStyle(
fontSize: 12, color: Colors.grey[600]),
),
],
),
],
),
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/media/cover_cache.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/video_item.dart';
import '../../../providers/auth_provider.dart';
@@ -91,7 +92,7 @@ class _AuthorAvatar extends StatelessWidget {
radius: 24,
backgroundColor: AppColors.surface,
backgroundImage: item.author.avatarUrl.isNotEmpty
? CachedNetworkImageProvider(item.author.avatarUrl)
? CachedNetworkImageProvider(item.author.avatarUrl, cacheManager: CoverCacheManager.I)
: null,
child: item.author.avatarUrl.isEmpty
? Text(
@@ -32,15 +32,41 @@ class _VideoInfoSectionState extends State<VideoInfoSection> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 昵称
Text(
'@${item.author.nickname}',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w700,
shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
),
// 昵称 + 来源徽标
Row(
children: [
Flexible(
child: Text(
'@${item.author.nickname}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w700,
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),
+206 -43
View File
@@ -69,6 +69,10 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
static const _rates = [1.0, 1.5, 2.0, 3.0];
double _rate = 1.0;
// 清屏:隐藏互动栏/信息区/顶部栏/底部渐变,仅保留播放画面与进度条。
// 恢复只能从长按菜单进入,避免单击误触把界面弹回来
bool _cleanMode = false;
bool get _hasSeries =>
_seriesData != null && (_seriesData!['has_series'] as bool? ?? false);
@@ -77,6 +81,8 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
String get _seriesTitle =>
_seriesData?['series']?['title'] as String? ?? '';
String get _seriesSourceName =>
_seriesData?['series']?['source_name'] as String? ?? '';
@override
void initState() {
@@ -310,6 +316,15 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
} 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() {
@@ -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: (_) {
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 get _effectiveDuration => _duration.inMilliseconds > 0
? _duration
@@ -537,16 +625,20 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
),
),
// 底部渐变(提升文字可读性)
const Positioned(
// 底部渐变(提升文字可读性):清屏时一并淡出
Positioned(
left: 0, right: 0, bottom: 0, height: 200,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black54],
child: AnimatedOpacity(
opacity: _cleanMode ? 0 : 1,
duration: const Duration(milliseconds: 200),
child: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black54],
),
),
),
),
@@ -554,51 +646,59 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
),
// ---------- 顶部:返回 + 倍速 ----------
SafeArea(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const BackButton(color: Colors.white),
// 倍速按钮
GestureDetector(
onTap: _onLongPress,
child: Container(
margin: const EdgeInsets.only(right: 16),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.black45,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_rateLabel,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
_HidableOverlay(
hidden: _cleanMode,
child: SafeArea(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const BackButton(color: Colors.white),
// 倍速按钮
GestureDetector(
onTap: _onLongPress,
child: Container(
margin: const EdgeInsets.only(right: 16),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.black45,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_rateLabel,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
],
),
),
),
// ---------- 右侧互动栏 ----------
Positioned(
right: 8, bottom: 96,
child: InteractionBar(
item: interactionItem,
onLike: (_) => _onLike(),
onFavorite: (_) => _onFavorite(),
onComment: (_) => _openComments(),
child: _HidableOverlay(
hidden: _cleanMode,
child: InteractionBar(
item: interactionItem,
onLike: (_) => _onLike(),
onFavorite: (_) => _onFavorite(),
onComment: (_) => _openComments(),
),
),
),
// ---------- 左下信息区 ----------
Positioned(
left: 12, right: 88, bottom: 24,
child: Column(
child: _HidableOverlay(
hidden: _cleanMode,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
@@ -628,6 +728,24 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
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 Icon(Icons.chevron_right,
color: Colors.white, size: 14),
@@ -639,6 +757,7 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
VideoInfoSection(item: _currentItem),
],
),
),
),
// ---------- 进度条:抖音风格一条细线 + 时间刻度 ----------
@@ -761,10 +880,33 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
maxLines: 1, overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
'共 ${_episodes.length} 集',
style: TextStyle(
fontSize: 12, color: Colors.grey[600]),
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(
'共 ${_episodes.length} 集',
style: TextStyle(
fontSize: 12, color: Colors.grey[600]),
),
],
),
],
),
@@ -992,3 +1134,24 @@ class _ProgressPainter extends CustomPainter {
bool shouldRepaint(covariant _ProgressPainter oldDelegate) =>
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,
),
);
}
}
+2 -1
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/media/cover_cache.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/auth_provider.dart';
import '../../providers/notification_provider.dart';
@@ -47,7 +48,7 @@ class _MineScreenState extends ConsumerState<MineScreen> {
radius: 32,
backgroundColor: AppColors.surface,
backgroundImage: user.avatarUrl.isNotEmpty
? CachedNetworkImageProvider(user.avatarUrl)
? CachedNetworkImageProvider(user.avatarUrl, cacheManager: CoverCacheManager.I)
: null,
child: user.avatarUrl.isEmpty
? Text(
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import '../../core/media/cover_cache.dart';
import '../../core/network/api_exception.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/auth_provider.dart';
@@ -150,7 +151,7 @@ class _ProfileEditScreenState extends ConsumerState<ProfileEditScreen> {
radius: 44,
backgroundColor: AppColors.surface,
backgroundImage: _avatarUrl.isNotEmpty
? CachedNetworkImageProvider(_avatarUrl)
? CachedNetworkImageProvider(_avatarUrl, cacheManager: CoverCacheManager.I)
: null,
child: _avatarUrl.isEmpty
? const Icon(Icons.person_outline,
@@ -3,8 +3,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/media/cover_cache.dart';
import '../../core/theme/app_theme.dart';
import '../../models/video_item.dart';
import '../../providers/feed_provider.dart';
import '../../providers/profile_provider.dart';
/// 他人公开主页(《03-接口文档》4.7/4.8,游客可看):
@@ -88,7 +90,7 @@ class _ProfileHeader extends StatelessWidget {
radius: 40,
backgroundColor: AppColors.surface,
backgroundImage: profile.avatarUrl.isNotEmpty
? CachedNetworkImageProvider(profile.avatarUrl)
? CachedNetworkImageProvider(profile.avatarUrl, cacheManager: CoverCacheManager.I)
: null,
child: profile.avatarUrl.isEmpty
? Text(
@@ -155,15 +157,20 @@ class _Stat extends StatelessWidget {
}
}
class _WorkCard extends StatelessWidget {
class _WorkCard extends ConsumerWidget {
const _WorkCard({required this.item});
final VideoItem item;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
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(
borderRadius: BorderRadius.circular(8),
child: Stack(
@@ -174,6 +181,7 @@ class _WorkCard extends StatelessWidget {
child: item.coverUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: item.coverUrl,
cacheManager: CoverCacheManager.I,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const Icon(
Icons.play_circle_outline,
@@ -3,10 +3,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/media/cover_cache.dart';
import '../../core/network/api_exception.dart';
import '../../core/theme/app_theme.dart';
import '../../models/user_profile.dart';
import '../../models/video_item.dart';
import '../../providers/feed_provider.dart';
import '../../providers/profile_provider.dart';
import '../../services/user_service.dart';
import '../../services/video_service.dart';
@@ -77,8 +79,11 @@ class _GridCard extends ConsumerWidget {
final bool editable;
/// 我的作品卡片点击:仅已发布可播放,其余状态提示原因
void _onTap(BuildContext context) {
void _onTap(BuildContext context, WidgetRef ref) {
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);
return;
}
@@ -158,7 +163,7 @@ class _GridCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return GestureDetector(
onTap: () => _onTap(context),
onTap: () => _onTap(context, ref),
onLongPress: editable ? () => _showManageSheet(context, ref) : null,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
@@ -170,6 +175,7 @@ class _GridCard extends ConsumerWidget {
child: item.coverUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: item.coverUrl,
cacheManager: CoverCacheManager.I,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const Icon(
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});
final HistoryItem item;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
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(
borderRadius: BorderRadius.circular(6),
child: Container(
@@ -283,6 +294,7 @@ class _HistoryTile extends StatelessWidget {
child: item.video.coverUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: item.video.coverUrl,
cacheManager: CoverCacheManager.I,
fit: BoxFit.cover,
errorWidget: (_, __, ___) =>
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),
),
],
),
),
);
}
}
+45 -5
View File
@@ -3,8 +3,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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 '../../models/video_item.dart';
import '../../providers/feed_provider.dart';
import '../../providers/search_provider.dart';
/// 搜索页(《01-开发文档》2.5.8):
@@ -24,12 +27,22 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
@override
void 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();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
ActionLogger.I.log('route_exit', page: '/search');
// 首页恢复播放改由 FeedScreen 通过 RouteObserver.didPopNext 感知,这里不再处理
_inputController.dispose();
_scrollController.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 {
const VideoGridCard({super.key, required this.item});
/// 点击行为:把当前列表注入到 feedProvider(FeedStrategy.search),跳 /search/feed
/// 进入全屏 Feed 播放,支持上下滑切换上/下一个视频
class VideoGridCard extends ConsumerWidget {
const VideoGridCard({
super.key,
required this.item,
required this.allItems,
required this.initialIndex,
});
final VideoItem item;
/// 当前列表的所有视频(搜索结果/标签结果)
final List<VideoItem> allItems;
/// 当前点击的视频在列表中的下标
final int initialIndex;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -315,6 +354,7 @@ class VideoGridCard extends StatelessWidget {
child: item.coverUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: item.coverUrl,
cacheManager: CoverCacheManager.I,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const Icon(
Icons.play_circle_outline,
+12 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/feed_provider.dart';
import '../../providers/search_provider.dart';
import '../../providers/tag_videos_provider.dart';
import 'search_screen.dart';
@@ -23,11 +24,17 @@ class _TagVideosScreenState extends ConsumerState<TagVideosScreen> {
@override
void initState() {
super.initState();
// 进入标签页即挂起首页 Feed(保存断点 + 停所有槽位)
WidgetsBinding.instance.addPostFrameCallback((_) {
final strategy = ref.read(activeFeedStrategyProvider);
ref.read(feedProvider(strategy).notifier).pool.suspend();
});
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
// 首页恢复播放由 FeedScreen.didPopNext 统一处理
_scrollController.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,
);
},
),
),
+9 -1
View File
@@ -71,14 +71,22 @@ class UploadService {
/// 视频分类
class VideoCategory {
VideoCategory({required this.categoryId, required this.name});
VideoCategory({
required this.categoryId,
required this.name,
this.hasSeries = false,
});
final int categoryId;
final String name;
/// 系列型分类(如 AI短剧):列表按「剧」聚合展示,一部剧只占一张卡片
final bool hasSeries;
factory VideoCategory.fromJson(Map<String, dynamic> json) => VideoCategory(
categoryId: (json['category_id'] as num?)?.toInt() ?? 0,
name: (json['name'] as String?) ?? '',
hasSeries: (json['has_series'] as bool?) ?? false,
);
}
+18
View File
@@ -8,11 +8,14 @@ class VideoService {
VideoService._();
/// 5.1 Feed 流:type=recommend|latest|hot,游标分页
/// [group]=series 时(系列型分类)按「剧」聚合,一部剧只返回首集一条
static Future<FeedPage> fetchFeed({
String type = 'recommend',
String? cursor,
int limit = 10,
int? categoryId,
int? sourceId,
String? group,
}) async {
final data = await ApiClient.get<Map<String, dynamic>>(
Endpoints.feed,
@@ -21,6 +24,8 @@ class VideoService {
if (cursor != null && cursor.isNotEmpty) 'cursor': cursor,
'limit': limit,
if (categoryId != null) 'category_id': categoryId,
if (sourceId != null) 'source_id': sourceId,
if (group != null) 'group': group,
},
);
final list = ((data['list'] as List?) ?? const [])
@@ -30,6 +35,15 @@ class VideoService {
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)
static Future<Map<String, dynamic>> fetchDetail(int videoId) =>
ApiClient.get<Map<String, dynamic>>(Endpoints.videoDetail(videoId));
@@ -93,6 +107,10 @@ class VideoService {
static Future<void> deleteVideo(int 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,普通分页
static Future<TagVideosPage> fetchTagVideos(
String tagName, {
+3 -1
View File
@@ -2,7 +2,7 @@ name: video_app
description: "短视频应用(Flutter,iOS/Android 双端)"
publish_to: 'none'
version: 1.3.15+31
version: 1.3.28+44
environment:
sdk: ^3.9.2
@@ -46,6 +46,8 @@ dependencies:
flutter_secure_storage: ^9.2.3
# 图片缓存(封面/头像)
cached_network_image: ^3.4.1
# 自定义缓存策略(封面缓存取消大小限制)
flutter_cache_manager: ^3.4.1
# 播放器(HLS m3u8)
media_kit: ^1.1.11
media_kit_video: ^1.2.5
Executable
+463
View File
@@ -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 "============================================================================"
+247 -17
View File
@@ -207,35 +207,249 @@ npm run build # 产物直接覆盖 admin/dist/,即 vm 站点根目录
## 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`。
关键规则摘要:
证书路径(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
root /root/project/video/h5; # index.html 下载页
location /downloads/ { alias /root/project/video/runtime/downloads/; } # APK
location /videos/ { alias /root/project/video/runtime/videos/; }
location /uploads/ { alias /root/project/video/runtime/uploads/; }
location /r/ { proxy_pass http://127.0.0.1:8000; } # 邀请落地
location /api/v1/invite/ { proxy_pass http://127.0.0.1:8000; }
server {
listen 80;
server_name h5.256kb.top;
# ACME 验证保留在 80,其余跳 HTTPS
location ^~ /.well-known/acme-challenge/ {
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
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 /static/ { proxy_pass http://127.0.0.1:8000; }
location /r/ { proxy_pass http://127.0.0.1:8000; }
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;
location ^~ /.well-known/acme-challenge/ {
root /www/wwwroot/acme-challenge/;
}
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 编译与发版
@@ -339,6 +553,8 @@ curl -I https://h5.256kb.top/downloads/video-app.apk # 200
## 11. 新环境从零部署速查
**手动步骤**(理解流程用):
1. 装 Docker / Python3.11 / Node18+ / Flutter / nginx(或宝塔)
2. 建 mysql8、my-redis 容器(§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)
7. 后台「系统配置」:`app.invite_base_url`、`app.version.android/ios` 等(见 03 接口文档配置键)
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.
+377 -24
View File
@@ -66,11 +66,11 @@ async def list_pending_videos(page: int = Query(1, ge=1), page_size: int = Query
@router.get("/videos")
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),
db: AsyncSession = Depends(get_db),
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")
@@ -90,17 +90,90 @@ async def offline_video(video_id: int, req: schema.ReasonReq, request: Request,
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):
title: str | None = _F(None, min_length=1, max_length=100)
description: str | None = None
cover_url: str | None = None
video_url: str | None = None
category_id: int | None = None
source_id: int | None = _F(None, ge=0)
series_id: int | None = None
episode_no: int | None = _F(None, ge=1)
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}")
async def update_video(video_id: int, req: _VideoUpdateReq,
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))
@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}")
async def get_user_detail(user_id: int, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
@@ -557,18 +688,23 @@ async def delete_sensitive_word(word_id: int, request: Request, db: AsyncSession
# ---------- 系列管理 ----------
@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),
admin: AdminIdentity = Depends(require_operator)):
from app.models.video import Series
from sqlalchemy import select, func
stmt = select(Series)
conds = []
if keyword:
stmt = stmt.where(Series.title.contains(keyword))
total = (await db.execute(select(func.count()).select_from(Series).where(
Series.title.contains(keyword) if keyword else True
))).scalar_one()
conds.append(Series.title.contains(keyword))
if source_id is not None:
conds.append(Series.source_id == source_id)
stmt = select(Series).where(*conds)
total = (await db.execute(
select(func.count()).select_from(Series).where(*conds)
)).scalar_one()
rows = (await db.execute(
stmt.order_by(Series.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).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
series_ids = [s.id for s in rows]
source_map: dict[int, int] = {}
collect_map: dict[int, int] = {}
if series_ids:
recs = (await db.execute(
select(CollectionRecord.series_id, CollectionRecord.source_id).where(
CollectionRecord.series_id.in_(series_ids)
)
)).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({
"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,
"description": s.description or "",
"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,
"source_id": source_map.get(s.id),
"created_at": s.created_at.strftime("%Y-%m-%d %H:%M:%S"),
} 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 = ""
description: str = ""
category_id: int = 0
source_id: int = 0
class _SeriesUpdate(BaseModel):
@@ -613,6 +877,7 @@ class _SeriesUpdate(BaseModel):
cover_url: str | None = None
description: str | None = None
category_id: int | None = None
source_id: int | None = None
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)
status: int | None = _Field(None, ge=0, le=1)
category_id: int | None = _Field(None, ge=0)
source_id: int | None = _Field(None, ge=0)
@router.put("/series/batch")
async def batch_update_series(req: _SeriesBatchUpdate, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
"""批量编辑系列属性(当前支持 status / category_id)"""
"""批量编辑系列属性(当前支持 status / category_id / source_id)"""
from app.models.admin import AdminLog
from app.models.video import Series
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, "至少要传一个要修改的字段")
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:
s.category_id = req.category_id
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:
affected += 1
db.add(AdminLog(
admin_id=admin.admin_id, action="series_batch_update",
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),
))
await db.commit()
@@ -698,7 +968,7 @@ async def create_series(req: _SeriesCreate, db: AsyncSession = Depends(get_db),
from app.models.video import Series
s = Series(
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)
await db.commit()
@@ -834,19 +1104,40 @@ async def collect_sync_all(req: _CollectSyncAllReq, request: Request,
class _SourceCreate(BaseModel):
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)
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):
name: str | None = _Field(None, min_length=1, max_length=50)
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)
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:
import json as _json
try:
cfg = _json.loads(s.config or "{}")
except Exception:
cfg = {}
return {
"id": s.id, "name": s.name, "list_url": s.list_url,
"category_id": s.category_id, "status": s.status,
"id": s.id, "name": s.name, "kind": s.kind or "url", "list_url": s.list_url,
"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 "",
"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),
admin: AdminIdentity = Depends(require_operator)):
"""添加采集源:自动按名称建/找分类"""
import json as _json
from app.models.admin import AdminLog
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}} 页码占位符")
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())
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)
await db.flush()
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.refresh(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}")
async def update_source(source_id: int, req: _SourceUpdate, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
import json as _json
from app.models.video import CollectionSource
s = await db.get(CollectionSource, source_id)
if not s:
raise BizException(errors.NOT_FOUND, "采集源不存在")
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}} 页码占位符")
if "name" in d:
d["category_id"] = await _resolve_category(db, 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():
setattr(s, k, v)
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_cancelled_sources: set[int] = set()
async def _run_source_sync(source_id: int, max_pages: int) -> None:
from app.database import SessionFactory
from app.models.video import CollectionSource
from app.services import collection_service
from app.services import aidrama91_service, collection_service
try:
async with SessionFactory() as s:
source = await s.get(CollectionSource, source_id)
if source:
stat = await collection_service.sync_source(s, source, max_pages=max_pages)
logger.info("采集源 %s 同步完成: %s", source_id, stat)
# 传入 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)
except Exception:
logger.exception("采集源 %s 同步失败", source_id)
finally:
_sync_running_sources.discard(source_id)
_sync_cancelled_sources.discard(source_id)
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]})
@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
+74
View File
@@ -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
View File
@@ -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 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.response import BizException
from app.database import get_db
from app.services.collection_service import HEADERS, UPSTREAM
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/m3u8", tags=["媒体"])
img_router = APIRouter(prefix="/img", tags=["媒体"])
# 只允许相对资源路径,防 SSRF
_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",
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": "*"},
)
+295
View File
@@ -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}")
+54 -2
View File
@@ -24,11 +24,13 @@ UPLOAD_RATE_LIMIT = 5
# 注意:/videos/feed、/videos/categories、/videos/search、/videos/upload/* 须先于 /videos/{video_id} 注册
@router.get("/feed")
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)$"),
group: str | None = Query(None, pattern="^(series)$"),
db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
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")
@@ -36,6 +38,12 @@ async def get_categories(db: AsyncSession = Depends(get_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")
async def search_videos(request: Request, keyword: str = Query(min_length=1, max_length=30),
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()
# 站点管理员(按注册邮箱判定):可在 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")
async def report_watch(video_id: int, req: WatchReq, db: AsyncSession = Depends(get_db),
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,
"cover_url": series.cover_url,
"description": series.description,
"source_id": series.source_id or 0,
"total_episodes": len(eps),
},
"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
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({
"has_series": True,
"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,
"title": series.title,
"cover_url": series.cover_url,
"source_id": series.source_id or 0,
"source_name": source_name,
"total_episodes": len(all_eps),
},
"episodes": [
+4 -1
View File
@@ -69,9 +69,10 @@ from app.config import BASE_DIR
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.img_router, prefix="/api/v1")
app.include_router(auth.router, prefix="/api/v1")
app.include_router(users.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(common.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")
# 推广落地页(根路径 /r/{code},对应 referral/me 返回的 invite_url)
app.include_router(invite.router_page)
+2 -1
View File
@@ -12,11 +12,12 @@ from app.models.incentive import (
)
from app.models.interaction import Comment, Favorite, Like
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
__all__ = [
"Base",
"User", "UserWallet", "UserMembership", "MembershipOrder",
"User", "UserWallet", "UserMembership", "MembershipOrder", "UserActionLog",
"Video", "Category", "Tag", "VideoTag", "TranscodeTask",
"Comment", "Like", "Favorite",
"WatchRecord", "PointTransaction", "RechargeOrder", "RechargePackage",
Binary file not shown.
+26
View File
@@ -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"),
)
+26 -2
View File
@@ -1,7 +1,7 @@
"""视频域:videos / categories / tags / video_tags / transcode_tasks"""
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.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)
user_id: Mapped[int] = mapped_column(BIGINT(unsigned=True), comment="作者")
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))
episode_no: Mapped[int | None] = mapped_column()
title: Mapped[str] = mapped_column(String(100), default="")
@@ -43,6 +44,7 @@ class Video(Base):
Index("idx_user_status", "user_id", "status"),
Index("idx_feed", "status", "published_at"),
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="")
author_id: Mapped[int | None] = mapped_column(BIGINT(unsigned=True))
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)
view_count: Mapped[int] = mapped_column(BigInteger, default=0)
status: Mapped[int] = mapped_column(SmallInteger, default=1, comment="1正常 0隐藏")
@@ -113,9 +116,23 @@ class Series(Base):
__table_args__ = (
Index("idx_series_cat_status", "category_id", "status"),
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):
"""采集记录:外部内容源 → 本站系列的映射(幂等导入)"""
@@ -136,14 +153,21 @@ class CollectionRecord(Base):
class CollectionSource(Base):
"""采集源配置:分类名 + 列表URL模板(后台自助添加)"""
"""采集源配置:分类名 + 列表URL模板(后台自助添加)
kind: url(通用 URL 模板,单视频) / aidrama91(91 短剧接口,按系列+集数入库)
config: JSON 字符串,存放接口类采集源所需配置(账号/密码/分类id/每页条数)
"""
__tablename__ = "collection_sources"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
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}}为页码占位")
config: Mapped[str] = mapped_column(Text, default="", comment="JSON 配置")
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停用")
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+20 -3
View File
@@ -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 {
"video_id": video.id,
"title": video.title,
@@ -164,6 +164,8 @@ def _video_item(video: Video, author: User | None) -> dict:
"video_url": video.video_url,
"duration": video.duration,
"category_id": video.category_id or 0,
"source_id": video.source_id or 0,
"source_name": source_name,
"series_id": video.series_id,
"episode_no": video.episode_no,
"status": video.status,
@@ -207,7 +209,7 @@ async def list_pending_videos(db: AsyncSession, page: int, page_size: int) -> di
async def list_videos(
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:
"""视频列表(11.25)"""
stmt = select(Video)
@@ -218,12 +220,27 @@ async def list_videos(
stmt = stmt.where(Video.status == status)
if author_id is not None:
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
rows = (await db.execute(
stmt.order_by(Video.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
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(
+850
View File
@@ -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}&timestamp={ts_sec}{SIGN_KEY}"
sign = hashlib.md5(hashlib.sha256(raw.encode()).hexdigest().encode()).hexdigest()
resp = await client.post(
api_url,
content=f"client=pwa&timestamp={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
+35 -2
View File
@@ -14,6 +14,7 @@ logger = logging.getLogger(__name__)
# 内容源配置(域名轮换时只需改这里)
SOURCE_KEY = "mdcmai4"
SOURCE_CODE = "madou" # sources 表中的 code,用于给导入内容打「来源」标
UPSTREAM = "https://mdcmai4.xyz"
PRODUCT_ID = 1
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='')}"
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]:
"""导入单部短剧,返回 (新增集数, 跳过集数)"""
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],
author_id=AUTHOR_USER_ID,
category_id=CATEGORY_ID,
source_id=await default_source_id(db),
total_episodes=0,
view_count=int(detail.get("heatCount") or 0),
status=1,
@@ -94,6 +111,7 @@ async def _import_drama(client: httpx.AsyncClient, db: AsyncSession, drama_id: i
Video(
user_id=AUTHOR_USER_ID,
category_id=CATEGORY_ID,
source_id=series.source_id,
series_id=series.id,
episode_no=ep_no,
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(
user_id=AUTHOR_USER_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],
description=(item.get("description") or "")[:500],
cover_url=_abs_cover(item)[:500],
@@ -232,13 +251,23 @@ async def sync_source(
max_pages: int = 0,
stop_after_existing: int = 30,
delay: float = 0.25,
should_cancel=None,
) -> dict:
"""按源的 URL 模板翻页采集;连续 stop_after_existing 条已存在则提前停止(增量)"""
stat = {"created": 0, "skipped": 0, "pages": 0, "stopped_early": False}
"""按源的 URL 模板翻页采集;连续 stop_after_existing 条已存在则提前停止(增量)。
should_cancel 返回 True 时每页/每条边界退出(用于手动取消)"""
stat = {"created": 0, "skipped": 0, "pages": 0, "stopped_early": False, "cancelled": False}
consecutive_existing = 0
def _cancelled() -> bool:
return bool(should_cancel and should_cancel())
async with httpx.AsyncClient(follow_redirects=True) as client:
page = 1
while True:
if _cancelled():
stat["cancelled"] = True
logger.info("通用采集源 %s 同步被取消(第 %s 页前)", source.id, page)
return stat
url = source.list_url.replace("{{page}}", str(page))
data = await _fetch_json(client, url)
items = data.get("items", [])
@@ -246,6 +275,10 @@ async def sync_source(
break
stat["pages"] = page
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):
stat["created"] += 1
consecutive_existing = 0
+62
View File
@@ -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
+141 -11
View File
@@ -20,7 +20,7 @@ from app.models.billing import WatchRecord
from app.models.incentive import VideoEarning
from app.models.interaction import Favorite, Like
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.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)
# 内容来源(麻豆 / 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()
favorited: set[int] = set()
if viewer_user_id:
@@ -101,6 +113,8 @@ async def serialize_videos(
"video_url": v.video_url,
"duration": v.duration,
"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)),
"view_count": v.view_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]]:
if not video_ids:
return {}
@@ -157,21 +181,110 @@ async def get_feed(
cursor: str | None,
limit: int,
category_id: int | None,
source_id: int | None = None,
group: str | None = None,
) -> dict:
# group=series:系列型分类(如 AI短剧)按「剧」聚合,每部剧只返回首集一条
if group == "series":
return await _feed_series(db, identity, cursor, limit, category_id, source_id)
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"):
return await _feed_ranked(db, identity, feed_type, cursor, limit, category_id)
return await _feed_scored(db, redis, identity, feed_type, cursor, limit, category_id)
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, 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(
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:
"""liked / viewed:按点赞量 / 播放量严格倒序(值 + id 打破并列游标)"""
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)
if c:
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))
if category_id:
stmt = stmt.where(Video.category_id == category_id)
if source_id:
stmt = stmt.where(Video.source_id == source_id)
return stmt
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:
"""latest:published_at 游标倒序(ts + id 打破并列)"""
stmt = await _base_pool_stmt(category_id)
stmt = _base_pool_stmt(category_id, source_id)
c = _decode_cursor(cursor)
if c:
ts, vid = datetime.fromtimestamp(c["p"]), int(c["i"])
@@ -260,6 +376,7 @@ async def _feed_scored(
cursor: str | None,
limit: int,
category_id: int | None,
source_id: int | None = None,
) -> dict:
"""recommend / hot:候选池打分 → 当日种子确定性加权抽样 → 当日去重(不足兜底)→ 偏移游标"""
now = datetime.now()
@@ -267,7 +384,7 @@ async def _feed_scored(
subject = identity.subject or "anon"
# 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)
).order_by(Video.published_at.desc()).limit(FEED_POOL_SIZE)
pool = list((await db.execute(stmt)).scalars())
@@ -505,7 +622,20 @@ async def list_categories(db: AsyncSession) -> list[dict]:
rows = (await db.execute(
select(Category).where(Category.status == 1).order_by(Category.sort, Category.id)
)).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) ----------
+2 -56
View File
@@ -4,79 +4,25 @@
cd /root/project/video/server
.venv/bin/python scripts/build_tags_from_titles.py [--dry-run]
提取规则:
1. 番号(如 CAWD-601)及其系列前缀(CAWD)
2. 书名号/方括号内容(≤12 字,去掉「无码破解」「中文字幕」等标记词)
3. jieba TF-IDF 关键词 topK=6(过滤停用词、纯数字、单字)
提取规则见 app/services/title_tags.py(幂等可重复执行)。
"""
import argparse
import asyncio
import logging
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import jieba.analyse # noqa: E402
from sqlalchemy import select # noqa: E402
from app.database import SessionFactory, engine # 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")
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 with SessionFactory() as db: