Compare commits
5
Commits
0b36fd3383
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
893dfed0ed | ||
|
|
1d440b2916 | ||
|
|
1253c159f5 | ||
|
|
28b11b4365 | ||
|
|
c519bdae9f |
@@ -15,3 +15,8 @@ export function getUserDetail(userId) {
|
|||||||
export function setUserStatus(userId, data) {
|
export function setUserStatus(userId, data) {
|
||||||
return request.put(`/admin/users/${userId}/status`, data)
|
return request.put(`/admin/users/${userId}/status`, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 后台重置用户密码(敏感) { new_password, reason } */
|
||||||
|
export function resetUserPassword(userId, data) {
|
||||||
|
return request.put(`/admin/users/${userId}/password`, data)
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ const menuConfig = [
|
|||||||
{ title: '发布视频', path: '/videos/create', icon: 'Upload', roles: [] },
|
{ title: '发布视频', path: '/videos/create', icon: 'Upload', roles: [] },
|
||||||
{ title: '视频管理', path: '/videos', icon: 'Film', roles: [] },
|
{ title: '视频管理', path: '/videos', icon: 'Film', roles: [] },
|
||||||
{ title: '系列管理', path: '/series', icon: 'CollectionTag', roles: [] },
|
{ title: '系列管理', path: '/series', icon: 'CollectionTag', roles: [] },
|
||||||
|
{ title: '采集源管理', path: '/collect-sources', icon: 'Download', roles: [] },
|
||||||
{ title: '分类管理', path: '/categories', icon: 'Menu', roles: [] },
|
{ title: '分类管理', path: '/categories', icon: 'Menu', roles: [] },
|
||||||
{ title: '标签管理', path: '/tags', icon: 'PriceTag', roles: [] },
|
{ title: '标签管理', path: '/tags', icon: 'PriceTag', roles: [] },
|
||||||
{ title: '评论管理', path: '/comments', icon: 'ChatDotRound', roles: [] },
|
{ title: '评论管理', path: '/comments', icon: 'ChatDotRound', roles: [] },
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ const routes = [
|
|||||||
component: () => import('@/views/SeriesManage.vue'),
|
component: () => import('@/views/SeriesManage.vue'),
|
||||||
meta: { title: '系列管理', icon: 'CollectionTag', roles: [] },
|
meta: { title: '系列管理', icon: 'CollectionTag', roles: [] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'collect-sources',
|
||||||
|
name: 'collect-sources',
|
||||||
|
component: () => import('@/views/CollectSources.vue'),
|
||||||
|
meta: { title: '采集源管理', icon: 'Download', roles: [] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'categories',
|
path: 'categories',
|
||||||
name: 'category-manage',
|
name: 'category-manage',
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<template>
|
||||||
|
<div class="collect-sources">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="header">
|
||||||
|
<span>采集源管理</span>
|
||||||
|
<div>
|
||||||
|
<el-button :loading="syncAllLoading" @click="syncAll">全部同步</el-button>
|
||||||
|
<el-button @click="batchVisible = true">批量添加</el-button>
|
||||||
|
<el-button type="primary" @click="showCreate">+ 添加采集源</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-alert type="info" :closable="false" style="margin-bottom: 12px">
|
||||||
|
添加"分类名 + 列表URL"后点同步即可自动采集;URL 中用 <code v-pre>{{page}}</code> 表示页码占位。
|
||||||
|
分类名不存在时会自动创建。同步为增量:已入库的视频自动跳过。
|
||||||
|
</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 prop="list_url" label="列表 URL" min-width="320" show-overflow-tooltip />
|
||||||
|
<el-table-column label="状态" width="80">
|
||||||
|
<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 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">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="warning" :loading="syncingId === row.id" @click="syncOne(row)">同步</el-button>
|
||||||
|
<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="batchVisible" title="批量添加采集源" width="640px">
|
||||||
|
<el-alert type="info" :closable="false" style="margin-bottom: 10px">
|
||||||
|
每行一条,格式:<code v-pre>分类名 URL</code>(URL 可带反引号)。提交后自动创建分类并立即开始采集。
|
||||||
|
</el-alert>
|
||||||
|
<el-input v-model="batchText" type="textarea" :rows="10"
|
||||||
|
:placeholder="'每日更新 https://mdcmai4.xyz/api/v1/videos?categoryId=27&page={{page}}&size=60\n清纯少女 https://mdcmai4.xyz/api/v1/videos?categoryId=13&page={{page}}&size=60'" />
|
||||||
|
<div v-if="batchResult" style="margin-top: 10px; font-size: 13px">
|
||||||
|
<div v-if="batchResult.created.length" style="color: #67c23a">
|
||||||
|
已创建 {{ batchResult.created.length }} 个并启动采集:{{ batchResult.created.map(c => c.name).join('、') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="batchResult.skipped.length" style="color: #e6a23c">
|
||||||
|
跳过 {{ batchResult.skipped.length }} 条(URL 已存在):{{ batchResult.skipped.map(s => s.name).join('、') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="batchResult.failed.length" style="color: #f56c6c">
|
||||||
|
失败 {{ batchResult.failed.length }} 条:{{ batchResult.failed.map(f => f.line).join(';') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="batchVisible = false">关闭</el-button>
|
||||||
|
<el-button type="primary" :loading="batchSubmitting" @click="submitBatch">提交</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="editing ? '编辑采集源' : '添加采集源'" width="560px">
|
||||||
|
<el-form :model="form" label-width="90px">
|
||||||
|
<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>
|
||||||
|
</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 { reactive, ref, onMounted } 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 syncingId = ref(0)
|
||||||
|
const syncAllLoading = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({ name: '', list_url: '' })
|
||||||
|
|
||||||
|
const batchVisible = ref(false)
|
||||||
|
const batchText = ref('')
|
||||||
|
const batchSubmitting = ref(false)
|
||||||
|
const batchResult = ref(null)
|
||||||
|
|
||||||
|
async function submitBatch() {
|
||||||
|
if (!batchText.value.trim()) { ElMessage.warning('请粘贴内容'); return }
|
||||||
|
batchSubmitting.value = true
|
||||||
|
batchResult.value = null
|
||||||
|
try {
|
||||||
|
const r = await request({ url: '/admin/collection/sources/batch', method: 'post', data: { text: batchText.value } })
|
||||||
|
batchResult.value = r
|
||||||
|
if (r.created.length) { batchText.value = ''; setTimeout(load, 3000) }
|
||||||
|
else load()
|
||||||
|
} catch (e) {} finally {
|
||||||
|
batchSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const r = await request({ url: '/admin/collection/sources', method: 'get' })
|
||||||
|
list.value = r.list
|
||||||
|
} catch (e) {} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreate() {
|
||||||
|
editing.value = null
|
||||||
|
Object.assign(form, { name: '', list_url: '' })
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEdit(row) {
|
||||||
|
editing.value = row
|
||||||
|
Object.assign(form, { name: row.name, list_url: row.list_url })
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!form.name || !form.list_url) { ElMessage.warning('请填写完整'); return }
|
||||||
|
if (!form.list_url.includes('{{page}}')) { ElMessage.warning('URL 需包含 {{page}} 页码占位符'); return }
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (editing.value) {
|
||||||
|
await request({ url: `/admin/collection/sources/${editing.value.id}`, method: 'put', data: form })
|
||||||
|
ElMessage.success('已更新')
|
||||||
|
} else {
|
||||||
|
await request({ url: '/admin/collection/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/collection/sources/${row.id}`, method: 'put',
|
||||||
|
data: { status: row.status === 1 ? 0 : 1 },
|
||||||
|
})
|
||||||
|
ElMessage.success('已更新')
|
||||||
|
load()
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del(row) {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认删除采集源「${row.name}」?已采集的视频不会被删除。`, '警告', { type: 'warning' }
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
await request({ url: `/admin/collection/sources/${row.id}`, method: 'delete' })
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
load()
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncOne(row) {
|
||||||
|
syncingId.value = row.id
|
||||||
|
try {
|
||||||
|
await request({ url: `/admin/collection/sources/${row.id}/sync`, method: 'post', data: { max_pages: 0 } })
|
||||||
|
ElMessage.success(`「${row.name}」已触发后台同步,稍后刷新查看`)
|
||||||
|
setTimeout(load, 20000)
|
||||||
|
} catch (e) {} finally {
|
||||||
|
syncingId.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAll() {
|
||||||
|
syncAllLoading.value = true
|
||||||
|
try {
|
||||||
|
const r = await request({ url: '/admin/collection/sources/sync-all', method: 'post' })
|
||||||
|
ElMessage.success(`已触发 ${r.started.length} 个源的后台同步`)
|
||||||
|
setTimeout(load, 20000)
|
||||||
|
} catch (e) {} finally {
|
||||||
|
syncAllLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
</style>
|
||||||
@@ -4,15 +4,39 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<span>系列管理</span>
|
<span>系列管理</span>
|
||||||
<div>
|
<div class="header-right">
|
||||||
<el-input v-model="keyword" placeholder="搜索系列标题" clearable
|
<el-input v-model="keyword" placeholder="搜索系列标题" clearable
|
||||||
style="width: 200px; margin-right: 8px" @keyup.enter="load" />
|
style="width: 200px; margin-right: 8px" @keyup.enter="load" />
|
||||||
<el-button type="primary" @click="showCreate">+ 新建系列</el-button>
|
|
||||||
|
<el-dropdown trigger="click" @command="handleBatchCmd">
|
||||||
|
<el-button :loading="batchLoading">批量更新 <el-icon class="el-icon--right"><arrow-down /></el-icon></el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="rebuild" :disabled="batchLoading">
|
||||||
|
<el-icon><refresh-left /></el-icon> 重算集数(total_episodes)
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="syncDrama" :disabled="batchLoading">
|
||||||
|
<el-icon><download /></el-icon> 同步短剧采集(新集数)
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="syncSources" :disabled="batchLoading">
|
||||||
|
<el-icon><collection-tag /></el-icon> 同步视频采集源
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
|
||||||
|
<el-button v-if="selectedIds.length" type="warning" plain
|
||||||
|
style="margin-left: 8px"
|
||||||
|
@click="showBatchEdit">批量编辑(已选 {{ selectedIds.length }})</el-button>
|
||||||
|
|
||||||
|
<el-button type="primary" style="margin-left: 8px" @click="showCreate">+ 新建系列</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-table :data="list" v-loading="loading" stripe>
|
<el-table :data="list" v-loading="loading" stripe
|
||||||
|
@selection-change="onSelectionChange">
|
||||||
|
<el-table-column type="selection" width="42" />
|
||||||
<el-table-column prop="id" label="ID" width="70" />
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
<el-table-column prop="title" label="系列标题" min-width="160">
|
<el-table-column prop="title" label="系列标题" min-width="160">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -23,6 +47,12 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="total_episodes" label="集数" width="90" />
|
<el-table-column prop="total_episodes" label="集数" width="90" />
|
||||||
|
<el-table-column label="来源" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.source_id" size="small" type="warning">采集</el-tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="80">
|
<el-table-column label="状态" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||||
@@ -31,9 +61,11 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="created_at" label="创建时间" width="170" />
|
<el-table-column prop="created_at" label="创建时间" width="170" />
|
||||||
<el-table-column label="操作" width="180" fixed="right">
|
<el-table-column label="操作" width="230" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" @click="showEdit(row)">编辑</el-button>
|
<el-button link type="primary" @click="showEdit(row)">编辑</el-button>
|
||||||
|
<el-button v-if="row.source_id" link type="warning"
|
||||||
|
:loading="syncingId === row.id" @click="syncOne(row)">更新</el-button>
|
||||||
<el-button link type="primary" @click="toggleStatus(row)">
|
<el-button link type="primary" @click="toggleStatus(row)">
|
||||||
{{ row.status === 1 ? '隐藏' : '恢复' }}
|
{{ row.status === 1 ? '隐藏' : '恢复' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -97,12 +129,41 @@
|
|||||||
<el-button type="primary" :loading="submitting" @click="save">保存</el-button>
|
<el-button type="primary" :loading="submitting" @click="save">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 批量编辑弹窗 -->
|
||||||
|
<el-dialog v-model="batchDialogVisible" title="批量编辑系列" width="400px">
|
||||||
|
<div style="margin-bottom: 12px; color: #606266; font-size: 13px">
|
||||||
|
共选中 <b>{{ selectedIds.length }}</b> 个系列,只修改下面勾选的字段:
|
||||||
|
</div>
|
||||||
|
<el-form :model="batchForm" label-width="80px">
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-switch v-model="batchForm.touchStatus" active-text="修改" inactive-text="不改" />
|
||||||
|
<el-select v-if="batchForm.touchStatus" v-model="batchForm.status"
|
||||||
|
style="width: 120px; margin-left: 12px">
|
||||||
|
<el-option label="正常" :value="1" />
|
||||||
|
<el-option label="隐藏" :value="0" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="分类">
|
||||||
|
<el-switch v-model="batchForm.touchCategory" active-text="修改" inactive-text="不改" />
|
||||||
|
<el-select v-if="batchForm.touchCategory" v-model="batchForm.category_id"
|
||||||
|
placeholder="请选择" style="width: 180px; margin-left: 12px">
|
||||||
|
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="batchDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="batchSubmitting" @click="batchSave">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref, onMounted } from 'vue'
|
import { reactive, ref, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { ArrowDown, RefreshLeft, Download, CollectionTag } from '@element-plus/icons-vue'
|
||||||
import request from '@/api/request'
|
import request from '@/api/request'
|
||||||
|
|
||||||
const list = ref([])
|
const list = ref([])
|
||||||
@@ -116,11 +177,25 @@ const dialogVisible = ref(false)
|
|||||||
const editing = ref(null)
|
const editing = ref(null)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const coverUploading = ref(false)
|
const coverUploading = ref(false)
|
||||||
|
const syncingId = ref(0)
|
||||||
|
const selectedIds = ref([])
|
||||||
|
const batchLoading = ref(false)
|
||||||
|
const batchDialogVisible = ref(false)
|
||||||
|
const batchSubmitting = ref(false)
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
title: '', cover_url: '', description: '', category_id: 0,
|
title: '', cover_url: '', description: '', category_id: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const batchForm = reactive({
|
||||||
|
touchStatus: false, status: 1,
|
||||||
|
touchCategory: false, category_id: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
function onSelectionChange(rows) {
|
||||||
|
selectedIds.value = rows.map(r => r.id)
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadCover(file) {
|
async function uploadCover(file) {
|
||||||
if (!file || !file.raw) return
|
if (!file || !file.raw) return
|
||||||
const allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
const allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
||||||
@@ -224,10 +299,78 @@ async function del(row) {
|
|||||||
load()
|
load()
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncOne(row) {
|
||||||
|
syncingId.value = row.id
|
||||||
|
try {
|
||||||
|
const r = await request({ url: `/admin/collection/sync/${row.id}`, method: 'post' })
|
||||||
|
ElMessage.success(`同步完成:新增 ${r.episodes_created} 集,已存在 ${r.episodes_skipped} 集`)
|
||||||
|
load()
|
||||||
|
} catch (e) {} finally {
|
||||||
|
syncingId.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 批量操作 ----------
|
||||||
|
|
||||||
|
async function handleBatchCmd(cmd) {
|
||||||
|
batchLoading.value = true
|
||||||
|
try {
|
||||||
|
if (cmd === 'rebuild') {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'将遍历全部系列,按已发布视频真实数量重算 total_episodes。确定执行?',
|
||||||
|
'确认', { type: 'warning' }
|
||||||
|
)
|
||||||
|
const r = await request({ url: '/admin/series/rebuild-counts', method: 'post' })
|
||||||
|
ElMessage.success(`共 ${r.total} 个系列,修复了 ${r.fixed} 个`)
|
||||||
|
load()
|
||||||
|
} else if (cmd === 'syncDrama') {
|
||||||
|
const r = await request({
|
||||||
|
url: '/admin/collection/sync-all', method: 'post', data: { pages: 2 },
|
||||||
|
})
|
||||||
|
ElMessage.success(`已触发后台增量同步(pages=2),稍后刷新查看`)
|
||||||
|
setTimeout(load, 15000)
|
||||||
|
} else if (cmd === 'syncSources') {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'将同时触发所有 23 个视频采集源的全量同步(异步后台执行)。确定?',
|
||||||
|
'确认', { type: 'warning' }
|
||||||
|
)
|
||||||
|
const r = await request({ url: '/admin/collection/sources/sync-all', method: 'post' })
|
||||||
|
ElMessage.success(`已触发 ${r.started?.length || 0} 个采集源同步`)
|
||||||
|
}
|
||||||
|
} catch (e) {} finally {
|
||||||
|
batchLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBatchEdit() {
|
||||||
|
Object.assign(batchForm, { touchStatus: false, status: 1, touchCategory: false, category_id: 0 })
|
||||||
|
batchDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchSave() {
|
||||||
|
if (!batchForm.touchStatus && !batchForm.touchCategory) {
|
||||||
|
ElMessage.warning('至少勾选一个要修改的字段')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batchSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const data = { series_ids: selectedIds.value }
|
||||||
|
if (batchForm.touchStatus) data.status = batchForm.status
|
||||||
|
if (batchForm.touchCategory) data.category_id = batchForm.category_id
|
||||||
|
const r = await request({ url: '/admin/series/batch', method: 'put', data })
|
||||||
|
ElMessage.success(`已更新 ${r.affected} 个系列`)
|
||||||
|
batchDialogVisible.value = false
|
||||||
|
load()
|
||||||
|
} catch (e) {} finally {
|
||||||
|
batchSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.header { display: flex; justify-content: space-between; align-items: center; }
|
.header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.header-right { display: flex; align-items: center; }
|
||||||
.title-col { display: flex; align-items: center; gap: 8px; }
|
.title-col { display: flex; align-items: center; gap: 8px; }
|
||||||
.cover { width: 40px; height: 40px; border-radius: 4px; object-fit: cover; }
|
.cover { width: 40px; height: 40px; border-radius: 4px; object-fit: cover; }
|
||||||
.cover-upload { display: flex; align-items: center; }
|
.cover-upload { display: flex; align-items: center; }
|
||||||
|
|||||||
@@ -48,9 +48,18 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="created_at" label="注册时间" width="170" />
|
<el-table-column prop="created_at" label="注册时间" width="170" />
|
||||||
<el-table-column label="操作" width="170" fixed="right">
|
<el-table-column label="操作" width="230" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" :icon="View" @click="toDetail(row)">详情</el-button>
|
<el-button link type="primary" :icon="View" @click="toDetail(row)">详情</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.status !== 3"
|
||||||
|
link
|
||||||
|
type="warning"
|
||||||
|
:icon="Key"
|
||||||
|
@click="openResetPwd(row)"
|
||||||
|
>
|
||||||
|
改密
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="row.status === 1"
|
v-if="row.status === 1"
|
||||||
link
|
link
|
||||||
@@ -84,16 +93,52 @@
|
|||||||
@current-change="load"
|
@current-change="load"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 重置密码弹窗 -->
|
||||||
|
<el-dialog v-model="pwdDialog.visible" title="重置用户密码" width="420px">
|
||||||
|
<el-form label-width="80px">
|
||||||
|
<el-form-item label="用户">
|
||||||
|
<span>{{ pwdDialog.nickname }} #{{ pwdDialog.userId }}</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="新密码">
|
||||||
|
<el-input
|
||||||
|
v-model="pwdDialog.newPassword"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="6-100 位"
|
||||||
|
maxlength="100"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="操作原因">
|
||||||
|
<el-input
|
||||||
|
v-model="pwdDialog.reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="必填,将记录操作日志"
|
||||||
|
maxlength="200"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div style="color: var(--el-color-warning); font-size: 12px; margin-top: -8px">
|
||||||
|
重置后该用户所有端登录态立即失效,需用新密码重新登录
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="pwdDialog.visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="pwdDialog.submitting" @click="submitResetPwd">
|
||||||
|
确定
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Lock, Refresh, Search, Unlock, View } from '@element-plus/icons-vue'
|
import { Key, Lock, Refresh, Search, Unlock, View } from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { listUsers, setUserStatus } from '@/api/users'
|
import { listUsers, resetUserPassword, setUserStatus } from '@/api/users'
|
||||||
import { askReason } from '@/utils/reason'
|
import { askReason } from '@/utils/reason'
|
||||||
|
|
||||||
const STATUS_TAG = { 1: 'success', 2: 'danger', 3: 'info' }
|
const STATUS_TAG = { 1: 'success', 2: 'danger', 3: 'info' }
|
||||||
@@ -154,5 +199,43 @@ async function toggleStatus(row, target) {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pwdDialog = reactive({
|
||||||
|
visible: false,
|
||||||
|
userId: 0,
|
||||||
|
nickname: '',
|
||||||
|
newPassword: '',
|
||||||
|
reason: '',
|
||||||
|
submitting: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
function openResetPwd(row) {
|
||||||
|
pwdDialog.userId = row.user_id
|
||||||
|
pwdDialog.nickname = row.nickname || ''
|
||||||
|
pwdDialog.newPassword = ''
|
||||||
|
pwdDialog.reason = ''
|
||||||
|
pwdDialog.visible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitResetPwd() {
|
||||||
|
const pwd = pwdDialog.newPassword.trim()
|
||||||
|
const reason = pwdDialog.reason.trim()
|
||||||
|
if (pwd.length < 6 || pwd.length > 100) {
|
||||||
|
ElMessage.warning('新密码长度需为 6-100 位')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!reason) {
|
||||||
|
ElMessage.warning('请填写操作原因')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pwdDialog.submitting = true
|
||||||
|
try {
|
||||||
|
await resetUserPassword(pwdDialog.userId, { new_password: pwd, reason })
|
||||||
|
ElMessage.success('密码已重置,该用户全端登录态已失效')
|
||||||
|
pwdDialog.visible = false
|
||||||
|
} finally {
|
||||||
|
pwdDialog.submitting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -5,6 +8,13 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 正式签名配置:从 android/key.properties 读取(该文件与 keystore 均已 gitignore,不入库)
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.jsnbhh.video_app"
|
namespace = "com.jsnbhh.video_app"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -30,11 +40,23 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String
|
||||||
|
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
// key.properties 存在时用正式签名;否则回退 debug(保证 CI/新环境能编译)
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfigs.getByName("release")
|
||||||
|
} else {
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
/// 全局配置常量(域名发现、公共请求头、版本号,对应《01-开发文档》5.5/5.6/5.7)
|
/// 全局配置常量(域名发现、公共请求头、版本号,对应《01-开发文档》5.5/5.6/5.7)
|
||||||
class AppConfig {
|
class AppConfig {
|
||||||
AppConfig._();
|
AppConfig._();
|
||||||
|
|
||||||
/// App 版本(对应 pubspec version 的 name 部分;version_code 对应 build 号)
|
/// App 版本(运行时从 PackageInfo 读取,始终与 APK 真实 versionName/versionCode 一致;
|
||||||
/// ⚠️ 每次发版必须与 pubspec.yaml 的 version 同步递增,否则老用户会一直提示更新
|
/// 不要改回硬编码——硬编码一旦与 pubspec 脱节,老用户会无限收到更新提示)
|
||||||
static const String appVersion = '1.2.0';
|
static late final String appVersion;
|
||||||
static const int appVersionCode = 12;
|
static late final int appVersionCode;
|
||||||
|
|
||||||
|
/// 启动时初始化版本信息(main() 中 runApp 前必须调用)
|
||||||
|
static Future<void> initVersion() async {
|
||||||
|
final info = await PackageInfo.fromPlatform();
|
||||||
|
appVersion = info.version;
|
||||||
|
appVersionCode = int.tryParse(info.buildNumber) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// 平台标识(运行时按 iOS/Android 判定,见 platformName)
|
/// 平台标识(运行时按 iOS/Android 判定,见 platformName)
|
||||||
static const String platformNameIOS = 'ios';
|
static const String platformNameIOS = 'ios';
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
import '../../models/video_item.dart';
|
||||||
|
import 'local_media_server.dart';
|
||||||
|
|
||||||
|
/// 已下载视频元数据(持久化在 downloads/<videoId>/meta.json)
|
||||||
|
class DownloadItem {
|
||||||
|
DownloadItem({
|
||||||
|
required this.videoId,
|
||||||
|
required this.title,
|
||||||
|
required this.coverUrl,
|
||||||
|
required this.duration,
|
||||||
|
required this.sizeBytes,
|
||||||
|
required this.mp4,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int videoId;
|
||||||
|
final String title;
|
||||||
|
final String coverUrl;
|
||||||
|
final int duration; // 秒
|
||||||
|
final int sizeBytes;
|
||||||
|
final bool mp4;
|
||||||
|
final int createdAt; // 毫秒时间戳
|
||||||
|
|
||||||
|
/// 本地播放地址(经 LocalMediaServer /local 路由,支持 Range)
|
||||||
|
String get playUrl =>
|
||||||
|
LocalMediaServer.I.localPlayUrl(videoId, mp4: mp4);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'video_id': videoId,
|
||||||
|
'title': title,
|
||||||
|
'cover_url': coverUrl,
|
||||||
|
'duration': duration,
|
||||||
|
'size_bytes': sizeBytes,
|
||||||
|
'mp4': mp4,
|
||||||
|
'created_at': createdAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory DownloadItem.fromJson(Map<String, dynamic> j) => DownloadItem(
|
||||||
|
videoId: j['video_id'] as int,
|
||||||
|
title: (j['title'] as String?) ?? '',
|
||||||
|
coverUrl: (j['cover_url'] as String?) ?? '',
|
||||||
|
duration: (j['duration'] as num?)?.toInt() ?? 0,
|
||||||
|
sizeBytes: (j['size_bytes'] as num?)?.toInt() ?? 0,
|
||||||
|
mp4: j['mp4'] as bool? ?? false,
|
||||||
|
createdAt: (j['created_at'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载任务状态机:value 为 0..1 进度;done/error 用独立集合
|
||||||
|
class DownloadsState {
|
||||||
|
const DownloadsState({
|
||||||
|
this.progress = const {},
|
||||||
|
this.errors = const {},
|
||||||
|
this.items = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
final Map<int, double> progress; // 下载中
|
||||||
|
final Map<int, String> errors; // 失败信息
|
||||||
|
final List<DownloadItem> items; // 已完成(按下载时间倒序)
|
||||||
|
|
||||||
|
DownloadsState copyWith({
|
||||||
|
Map<int, double>? progress,
|
||||||
|
Map<int, String>? errors,
|
||||||
|
List<DownloadItem>? items,
|
||||||
|
}) =>
|
||||||
|
DownloadsState(
|
||||||
|
progress: progress ?? this.progress,
|
||||||
|
errors: errors ?? this.errors,
|
||||||
|
items: items ?? this.items,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class DownloadManager extends StateNotifier<DownloadsState> {
|
||||||
|
DownloadManager() : super(const DownloadsState());
|
||||||
|
|
||||||
|
final Dio _dio = Dio(BaseOptions(
|
||||||
|
connectTimeout: const Duration(seconds: 15),
|
||||||
|
receiveTimeout: const Duration(minutes: 10),
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 '
|
||||||
|
'(KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36',
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
final Map<int, CancelToken> _cancel = {};
|
||||||
|
|
||||||
|
bool isDownloaded(int videoId) =>
|
||||||
|
state.items.any((e) => e.videoId == videoId);
|
||||||
|
bool isDownloading(int videoId) => state.progress.containsKey(videoId);
|
||||||
|
|
||||||
|
/// 启动时扫描下载目录恢复列表
|
||||||
|
Future<void> init() async {
|
||||||
|
final dir = LocalMediaServer.I.downloadsDir;
|
||||||
|
final items = <DownloadItem>[];
|
||||||
|
await for (final e in dir.list()) {
|
||||||
|
if (e is! Directory) continue;
|
||||||
|
final meta = File(p.join(e.path, 'meta.json'));
|
||||||
|
if (!await meta.exists()) continue;
|
||||||
|
try {
|
||||||
|
items.add(DownloadItem.fromJson(
|
||||||
|
jsonDecode(await meta.readAsString()) as Map<String, dynamic>));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
items.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||||
|
state = state.copyWith(items: items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 开始下载(已在下载/已下载则忽略)
|
||||||
|
Future<void> start(VideoItem item) async {
|
||||||
|
if (isDownloading(item.videoId) || isDownloaded(item.videoId)) return;
|
||||||
|
|
||||||
|
final dir = Directory(p.join(
|
||||||
|
LocalMediaServer.I.downloadsDir.path, '${item.videoId}'));
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
final cancel = CancelToken();
|
||||||
|
_cancel[item.videoId] = cancel;
|
||||||
|
_setProgress(item.videoId, 0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final isMp4 = item.videoUrl.contains('.mp4') &&
|
||||||
|
!item.videoUrl.contains('m3u8');
|
||||||
|
int size;
|
||||||
|
if (isMp4) {
|
||||||
|
size = await _downloadMp4(item.videoUrl, dir, item.videoId, cancel);
|
||||||
|
} else {
|
||||||
|
size = await _downloadHls(item.videoUrl, dir, item.videoId, cancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
final meta = DownloadItem(
|
||||||
|
videoId: item.videoId,
|
||||||
|
title: item.title,
|
||||||
|
coverUrl: item.coverUrl,
|
||||||
|
duration: item.duration,
|
||||||
|
sizeBytes: size,
|
||||||
|
mp4: isMp4,
|
||||||
|
createdAt: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
);
|
||||||
|
await File(p.join(dir.path, 'meta.json'))
|
||||||
|
.writeAsString(jsonEncode(meta.toJson()));
|
||||||
|
|
||||||
|
final progress = Map<int, double>.from(state.progress)
|
||||||
|
..remove(item.videoId);
|
||||||
|
state = state.copyWith(
|
||||||
|
progress: progress,
|
||||||
|
items: [meta, ...state.items],
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// 失败/取消:清掉半成品目录
|
||||||
|
try {
|
||||||
|
await dir.delete(recursive: true);
|
||||||
|
} catch (_) {}
|
||||||
|
final progress = Map<int, double>.from(state.progress)
|
||||||
|
..remove(item.videoId);
|
||||||
|
if (e is DioException && CancelToken.isCancel(e)) {
|
||||||
|
state = state.copyWith(progress: progress);
|
||||||
|
} else {
|
||||||
|
final errors = Map<int, String>.from(state.errors)
|
||||||
|
..[item.videoId] = '下载失败,请重试';
|
||||||
|
state = state.copyWith(progress: progress, errors: errors);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
_cancel.remove(item.videoId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancel(int videoId) => _cancel[videoId]?.cancel();
|
||||||
|
|
||||||
|
Future<void> delete(int videoId) async {
|
||||||
|
_cancel[videoId]?.cancel();
|
||||||
|
final dir = Directory(
|
||||||
|
p.join(LocalMediaServer.I.downloadsDir.path, '$videoId'));
|
||||||
|
try {
|
||||||
|
await dir.delete(recursive: true);
|
||||||
|
} catch (_) {}
|
||||||
|
state = state.copyWith(
|
||||||
|
items: state.items.where((e) => e.videoId != videoId).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- MP4 ----------
|
||||||
|
|
||||||
|
Future<int> _downloadMp4(
|
||||||
|
String url, Directory dir, int videoId, CancelToken cancel) async {
|
||||||
|
final file = File(p.join(dir.path, 'video.mp4'));
|
||||||
|
await _dio.download(
|
||||||
|
url,
|
||||||
|
file.path,
|
||||||
|
cancelToken: cancel,
|
||||||
|
onReceiveProgress: (r, t) {
|
||||||
|
if (t > 0) _setProgress(videoId, r / t);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return file.length();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- HLS ----------
|
||||||
|
|
||||||
|
/// 拉取 m3u8 → 下载 key + 全部 ts → 改写为本地相对引用
|
||||||
|
Future<int> _downloadHls(
|
||||||
|
String url, Directory dir, int videoId, CancelToken cancel) async {
|
||||||
|
var playlist = (await _dio.get<String>(url, cancelToken: cancel)).data ?? '';
|
||||||
|
var base = Uri.parse(url);
|
||||||
|
|
||||||
|
// master playlist(多变体):取第一个变体再拉一次
|
||||||
|
if (playlist.contains('#EXT-X-STREAM-INF')) {
|
||||||
|
final variant = const LineSplitter()
|
||||||
|
.convert(playlist)
|
||||||
|
.firstWhere((l) => l.trim().isNotEmpty && !l.startsWith('#'),
|
||||||
|
orElse: () => '');
|
||||||
|
if (variant.isEmpty) throw Exception('空变体列表');
|
||||||
|
final variantUrl = base.resolve(variant.trim()).toString();
|
||||||
|
playlist =
|
||||||
|
(await _dio.get<String>(variantUrl, cancelToken: cancel)).data ?? '';
|
||||||
|
base = Uri.parse(variantUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
final lines = const LineSplitter().convert(playlist);
|
||||||
|
final segUrls = <Uri>[
|
||||||
|
for (final l in lines)
|
||||||
|
if (l.trim().isNotEmpty && !l.trim().startsWith('#'))
|
||||||
|
base.resolve(l.trim()),
|
||||||
|
];
|
||||||
|
if (segUrls.isEmpty) throw Exception('无分片');
|
||||||
|
|
||||||
|
var total = 0;
|
||||||
|
var done = 0;
|
||||||
|
|
||||||
|
// key(可能多个,逐标签改写时按出现顺序对应)
|
||||||
|
final keyUris = <String>[];
|
||||||
|
for (final l in lines) {
|
||||||
|
for (final m in RegExp(r'URI="([^"]+)"').allMatches(l)) {
|
||||||
|
final abs = base.resolve(m.group(1)!).toString();
|
||||||
|
if (!keyUris.contains(abs)) keyUris.add(abs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final keyNames = <String, String>{};
|
||||||
|
for (var i = 0; i < keyUris.length; i++) {
|
||||||
|
final name = i == 0 ? 'key.bin' : 'key_$i.bin';
|
||||||
|
final bytes = (await _dio.get<List<int>>(keyUris[i],
|
||||||
|
cancelToken: cancel,
|
||||||
|
options: Options(responseType: ResponseType.bytes)))
|
||||||
|
.data;
|
||||||
|
if (bytes == null || bytes.isEmpty) throw Exception('密钥下载失败');
|
||||||
|
await File(p.join(dir.path, name)).writeAsBytes(bytes);
|
||||||
|
keyNames[keyUris[i]] = name;
|
||||||
|
total += bytes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ts 分片:4 并发
|
||||||
|
const concurrency = 4;
|
||||||
|
for (var i = 0; i < segUrls.length; i += concurrency) {
|
||||||
|
final batch = segUrls.sublist(
|
||||||
|
i, (i + concurrency).clamp(0, segUrls.length));
|
||||||
|
final results = await Future.wait(batch.map((u) => _dio.get<List<int>>(
|
||||||
|
u.toString(),
|
||||||
|
cancelToken: cancel,
|
||||||
|
options: Options(responseType: ResponseType.bytes))));
|
||||||
|
for (var j = 0; j < batch.length; j++) {
|
||||||
|
final bytes = results[j].data;
|
||||||
|
if (bytes == null || bytes.isEmpty) throw Exception('分片下载失败');
|
||||||
|
await File(p.join(dir.path, 'seg_${i + j}.ts')).writeAsBytes(bytes);
|
||||||
|
total += bytes.length;
|
||||||
|
done++;
|
||||||
|
}
|
||||||
|
_setProgress(videoId, done / segUrls.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改写 playlist 为本地相对引用
|
||||||
|
var segIndex = 0;
|
||||||
|
final out = StringBuffer();
|
||||||
|
for (final raw in lines) {
|
||||||
|
final line = raw.trim();
|
||||||
|
if (line.isEmpty) continue;
|
||||||
|
if (line.startsWith('#')) {
|
||||||
|
out.writeln(line.replaceAllMapped(RegExp(r'URI="([^"]+)"'), (m) {
|
||||||
|
final abs = base.resolve(m.group(1)!).toString();
|
||||||
|
return 'URI="${keyNames[abs] ?? m.group(1)!}"';
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
out.writeln('seg_${segIndex++}.ts');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await File(p.join(dir.path, 'index.m3u8')).writeAsString(out.toString());
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setProgress(int videoId, double value) {
|
||||||
|
final progress = Map<int, double>.from(state.progress);
|
||||||
|
progress[videoId] = value;
|
||||||
|
state = state.copyWith(progress: progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final downloadManagerProvider =
|
||||||
|
StateNotifierProvider<DownloadManager, DownloadsState>(
|
||||||
|
(ref) => DownloadManager());
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:shelf/shelf.dart' as shelf;
|
||||||
|
import 'package:shelf/shelf_io.dart' as shelf_io;
|
||||||
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
|
||||||
|
/// 本地媒体服务(127.0.0.1 环回 HTTP):
|
||||||
|
///
|
||||||
|
/// 1. HLS 分片缓存(1G LRU):播放器请求经 /hls 改写 m3u8 内的 ts/key 绝对
|
||||||
|
/// 地址到 /seg,/seg 命中磁盘缓存直接返回,未命中回源并落盘 —— 回看不下载。
|
||||||
|
/// 注意:ts 链接带时效签名,缓存键取「去 query 的路径 sha1」,m3u8 每次回源。
|
||||||
|
/// 2. 已下载视频离线播放:/local/<videoId>/<file> 直接读下载目录(支持 Range)。
|
||||||
|
///
|
||||||
|
/// 只有 m3u8 走代理;mp4 直连(库里仅个位数 mp4,不做缓存)。
|
||||||
|
class LocalMediaServer {
|
||||||
|
LocalMediaServer._();
|
||||||
|
static final LocalMediaServer I = LocalMediaServer._();
|
||||||
|
|
||||||
|
static const int _cacheLimit = 1024 * 1024 * 1024; // 1G
|
||||||
|
static const int _evictTo = 900 * 1024 * 1024; // 清理到 900M(滞后防抖)
|
||||||
|
|
||||||
|
final Dio _dio = Dio(BaseOptions(
|
||||||
|
connectTimeout: const Duration(seconds: 15),
|
||||||
|
receiveTimeout: const Duration(minutes: 5),
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 '
|
||||||
|
'(KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36',
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
bool _started = false;
|
||||||
|
late int _port;
|
||||||
|
late Directory cacheDir; // <temp>/hls_cache(系统可清理,适合缓存语义)
|
||||||
|
late Directory downloadsDir; // <appSupport>/downloads(持久)
|
||||||
|
|
||||||
|
int _cacheBytes = 0;
|
||||||
|
int _writesSinceEvictCheck = 0;
|
||||||
|
|
||||||
|
/// 同一分片并发回源去重(playlist 内可能重复引用 / 多槽位同时 open)
|
||||||
|
final Map<String, Future<List<int>>> _inflight = {};
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
if (_started) return;
|
||||||
|
final tmp = await getTemporaryDirectory();
|
||||||
|
cacheDir = Directory(p.join(tmp.path, 'hls_cache'));
|
||||||
|
await cacheDir.create(recursive: true);
|
||||||
|
final support = await getApplicationSupportDirectory();
|
||||||
|
downloadsDir = Directory(p.join(support.path, 'downloads'));
|
||||||
|
await downloadsDir.create(recursive: true);
|
||||||
|
_cacheBytes = await _dirSize(cacheDir);
|
||||||
|
|
||||||
|
final router = Router()
|
||||||
|
..get('/hls', _handlePlaylist)
|
||||||
|
..get('/seg', _handleSegment)
|
||||||
|
..get('/local/<vid>/<name>', _handleLocal);
|
||||||
|
|
||||||
|
final server = await shelf_io.serve(
|
||||||
|
const shelf.Pipeline().addHandler(router.call),
|
||||||
|
InternetAddress.loopbackIPv4,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
_port = server.port;
|
||||||
|
_started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 播放地址改写:网络 m3u8 → 本地缓存代理;其余(本地地址/mp4/空)原样返回
|
||||||
|
String wrap(String url) {
|
||||||
|
if (!_started || url.isEmpty) return url;
|
||||||
|
final u = Uri.tryParse(url);
|
||||||
|
if (u == null || (u.scheme != 'http' && u.scheme != 'https')) return url;
|
||||||
|
if (u.host == '127.0.0.1' || u.host == 'localhost') return url;
|
||||||
|
if (url.contains('m3u8')) {
|
||||||
|
return 'http://127.0.0.1:$_port/hls?src=${Uri.encodeComponent(url)}';
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已下载视频的本地播放地址
|
||||||
|
String localPlayUrl(int videoId, {bool mp4 = false}) =>
|
||||||
|
'http://127.0.0.1:$_port/local/$videoId/${mp4 ? 'video.mp4' : 'index.m3u8'}';
|
||||||
|
|
||||||
|
// ---------- /hls:回源拉 m3u8,改写分片地址到 /seg ----------
|
||||||
|
|
||||||
|
Future<shelf.Response> _handlePlaylist(shelf.Request req) async {
|
||||||
|
final src = req.url.queryParameters['src'];
|
||||||
|
if (src == null || src.isEmpty) {
|
||||||
|
return shelf.Response(400, body: 'missing src');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final resp = await _dio.get<String>(src);
|
||||||
|
final text = resp.data ?? '';
|
||||||
|
if (!text.trimLeft().startsWith('#EXTM3U')) {
|
||||||
|
return shelf.Response(502, body: 'bad playlist');
|
||||||
|
}
|
||||||
|
return _bytesResponse(
|
||||||
|
req,
|
||||||
|
utf8.encode(_rewritePlaylist(text, Uri.parse(src))),
|
||||||
|
'application/vnd.apple.mpegurl',
|
||||||
|
noCache: true,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return shelf.Response(502, body: 'upstream error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _segProxyUrl(Uri absolute) =>
|
||||||
|
'http://127.0.0.1:$_port/seg?u=${Uri.encodeComponent(absolute.toString())}';
|
||||||
|
|
||||||
|
String _rewritePlaylist(String text, Uri base) {
|
||||||
|
final out = StringBuffer();
|
||||||
|
for (final rawLine in const LineSplitter().convert(text)) {
|
||||||
|
final line = rawLine.trim();
|
||||||
|
if (line.isEmpty) continue;
|
||||||
|
if (line.startsWith('#')) {
|
||||||
|
// 改写标签内的 URI="..."(EXT-X-KEY / EXT-X-MAP 等)
|
||||||
|
out.writeln(line.replaceAllMapped(
|
||||||
|
RegExp(r'URI="([^"]+)"'),
|
||||||
|
(m) => 'URI="${_segProxyUrl(base.resolve(m.group(1)!))}"',
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
out.writeln(_segProxyUrl(base.resolve(line)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- /seg:分片缓存(键 = 去签名路径 sha1)----------
|
||||||
|
|
||||||
|
/// 统一字节响应:始终声明 Accept-Ranges,带 Range 头则返回 206 切片。
|
||||||
|
/// (ffmpeg/mpv 依据 Accept-Ranges 判断流可拖动,否则按直播处理:
|
||||||
|
/// duration=0 → 进度条不走、无法 seek)
|
||||||
|
shelf.Response _bytesResponse(
|
||||||
|
shelf.Request req, List<int> bytes, String contentType,
|
||||||
|
{bool noCache = false}) {
|
||||||
|
final headers = <String, String>{
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
if (noCache) 'Cache-Control': 'no-cache' else 'Cache-Control': 'max-age=31536000',
|
||||||
|
};
|
||||||
|
final range = req.headers['range'];
|
||||||
|
if (range != null && range.startsWith('bytes=')) {
|
||||||
|
final m = RegExp(r'bytes=(\d+)-(\d*)').firstMatch(range);
|
||||||
|
if (m != null) {
|
||||||
|
final start = int.parse(m.group(1)!);
|
||||||
|
if (start < bytes.length) {
|
||||||
|
final endStr = m.group(2) ?? '';
|
||||||
|
final end = endStr.isEmpty
|
||||||
|
? bytes.length - 1
|
||||||
|
: int.parse(endStr).clamp(0, bytes.length - 1);
|
||||||
|
return shelf.Response(206,
|
||||||
|
body: bytes.sublist(start, end + 1),
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Range': 'bytes $start-$end/${bytes.length}',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shelf.Response.ok(bytes, headers: headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _cacheKeyFor(Uri u) {
|
||||||
|
final stripped = Uri(scheme: u.scheme, host: u.host, port: u.port, path: u.path);
|
||||||
|
return sha1.convert(utf8.encode(stripped.toString())).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<shelf.Response> _handleSegment(shelf.Request req) async {
|
||||||
|
final raw = req.url.queryParameters['u'];
|
||||||
|
if (raw == null || raw.isEmpty) return shelf.Response(400, body: 'missing u');
|
||||||
|
final u = Uri.tryParse(raw);
|
||||||
|
if (u == null) return shelf.Response(400, body: 'bad u');
|
||||||
|
|
||||||
|
final file = File(p.join(cacheDir.path, _cacheKeyFor(u)));
|
||||||
|
if (await file.exists()) {
|
||||||
|
// 触活:LRU 以 mtime 排序
|
||||||
|
await file.setLastModified(DateTime.now());
|
||||||
|
return _bytesResponse(req, await file.readAsBytes(), _contentType(u.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final bytes = await (_inflight[raw] ??= _fetch(u));
|
||||||
|
return _bytesResponse(req, bytes, _contentType(u.path));
|
||||||
|
} catch (e) {
|
||||||
|
return shelf.Response(502, body: 'fetch error: $e');
|
||||||
|
} finally {
|
||||||
|
unawaited(Future(() async {
|
||||||
|
await Future<void>.delayed(const Duration(seconds: 30));
|
||||||
|
_inflight.remove(raw);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<int>> _fetch(Uri u) async {
|
||||||
|
final resp = await _dio.get<List<int>>(
|
||||||
|
u.toString(),
|
||||||
|
options: Options(responseType: ResponseType.bytes),
|
||||||
|
);
|
||||||
|
final bytes = resp.data ?? const <int>[];
|
||||||
|
if (bytes.isEmpty) throw Exception('empty body');
|
||||||
|
|
||||||
|
final file = File(p.join(cacheDir.path, _cacheKeyFor(u)));
|
||||||
|
// tmp 名唯一化:不同签名的同一分片并发回源时避免写同一个临时文件
|
||||||
|
final tmp = File(
|
||||||
|
'${file.path}.${DateTime.now().microsecondsSinceEpoch}.tmp');
|
||||||
|
await tmp.writeAsBytes(bytes, flush: true);
|
||||||
|
await tmp.rename(file.path);
|
||||||
|
_cacheBytes += bytes.length;
|
||||||
|
_maybeEvict();
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LRU 清理:超 1G 时按 mtime 从旧到新删到 900M(每 20 次写入最多全扫一次)
|
||||||
|
void _maybeEvict() {
|
||||||
|
if (_cacheBytes <= _cacheLimit) return;
|
||||||
|
if (++_writesSinceEvictCheck < 20) return;
|
||||||
|
_writesSinceEvictCheck = 0;
|
||||||
|
unawaited(Future(() async {
|
||||||
|
final entries = <File>[];
|
||||||
|
await for (final e in cacheDir.list()) {
|
||||||
|
if (e is File) entries.add(e);
|
||||||
|
}
|
||||||
|
entries.sort((a, b) =>
|
||||||
|
a.lastModifiedSync().compareTo(b.lastModifiedSync()));
|
||||||
|
for (final f in entries) {
|
||||||
|
if (_cacheBytes <= _evictTo) break;
|
||||||
|
try {
|
||||||
|
final len = await f.length();
|
||||||
|
await f.delete();
|
||||||
|
_cacheBytes -= len;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- /local:已下载内容(Range 支持 mp4 拖动)----------
|
||||||
|
|
||||||
|
Future<shelf.Response> _handleLocal(
|
||||||
|
shelf.Request req, String vid, String name) async {
|
||||||
|
// 防路径穿越
|
||||||
|
if (name.contains('/') || name.contains('..') || vid.contains('..')) {
|
||||||
|
return shelf.Response(400, body: 'bad path');
|
||||||
|
}
|
||||||
|
final file = File(p.join(downloadsDir.path, vid, name));
|
||||||
|
if (!await file.exists()) return shelf.Response.notFound('gone');
|
||||||
|
|
||||||
|
final total = await file.length();
|
||||||
|
final range = req.headers['range'];
|
||||||
|
if (range != null && range.startsWith('bytes=')) {
|
||||||
|
final m = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(range);
|
||||||
|
if (m != null) {
|
||||||
|
final start = int.tryParse(m.group(1) ?? '') ?? 0;
|
||||||
|
final endStr = m.group(2) ?? '';
|
||||||
|
final end = endStr.isEmpty
|
||||||
|
? total - 1
|
||||||
|
: (int.tryParse(endStr) ?? total - 1).clamp(0, total - 1);
|
||||||
|
final raf = await file.open();
|
||||||
|
try {
|
||||||
|
await raf.setPosition(start);
|
||||||
|
final bytes = await raf.read(end - start + 1);
|
||||||
|
return shelf.Response(206,
|
||||||
|
body: bytes,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': _contentType(name),
|
||||||
|
'Content-Range': 'bytes $start-$end/$total',
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Content-Length': '${bytes.length}',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await raf.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shelf.Response.ok(await file.readAsBytes(), headers: {
|
||||||
|
'Content-Type': _contentType(name),
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _contentType(String path) {
|
||||||
|
if (path.endsWith('.m3u8')) return 'application/vnd.apple.mpegurl';
|
||||||
|
if (path.endsWith('.ts')) return 'video/mp2t';
|
||||||
|
if (path.endsWith('.mp4')) return 'video/mp4';
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> _dirSize(Directory dir) async {
|
||||||
|
var total = 0;
|
||||||
|
await for (final e in dir.list()) {
|
||||||
|
if (e is File) {
|
||||||
|
try {
|
||||||
|
total += await e.length();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -199,49 +199,52 @@ class _ErrorInterceptor extends Interceptor {
|
|||||||
final apiError = err.error;
|
final apiError = err.error;
|
||||||
final status = err.response?.statusCode;
|
final status = err.response?.statusCode;
|
||||||
|
|
||||||
// 401:静默刷新重试一次
|
// 401:静默刷新重试一次;无法刷新(无 refresh / 刷新被拒 / 重试仍 401)
|
||||||
|
// 统一按会话失效处理,否则 APP 会卡在"本地有 user 但 access 已过期"的
|
||||||
|
// 僵尸登录态:我的页正常显示资料卡,点会员/钱包等全部 401 加载失败
|
||||||
if (status == 401 &&
|
if (status == 401 &&
|
||||||
err.requestOptions.extra['retried'] != true &&
|
err.requestOptions.extra['retried'] != true) {
|
||||||
TokenStore.refreshToken != null) {
|
if (TokenStore.refreshToken != null) {
|
||||||
String? newToken;
|
String? newToken;
|
||||||
try {
|
|
||||||
newToken = await _refreshToken();
|
|
||||||
} on ApiException catch (refreshErr) {
|
|
||||||
// 刷新请求本身网络失败:不清登录态,透传错误
|
|
||||||
return handler.next(DioException(
|
|
||||||
requestOptions: err.requestOptions,
|
|
||||||
error: refreshErr,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if (newToken != null) {
|
|
||||||
try {
|
try {
|
||||||
final opts = err.requestOptions;
|
newToken = await _refreshToken();
|
||||||
opts.extra['retried'] = true;
|
} on ApiException catch (refreshErr) {
|
||||||
opts.headers['Authorization'] = 'Bearer $newToken';
|
// 刷新请求本身网络失败:不清登录态,透传错误
|
||||||
final resp = await ApiClient._dio.fetch<dynamic>(opts);
|
|
||||||
return handler.resolve(resp);
|
|
||||||
} on DioException catch (retryErr) {
|
|
||||||
final retryApi = retryErr.error;
|
|
||||||
final retryStatus = retryErr.response?.statusCode;
|
|
||||||
// 重试后仍是鉴权失败(401 / 1002)→ 确认会话失效,登出;
|
|
||||||
// 其余失败(网络抖动、业务错误等)不清登录态,直接透传错误
|
|
||||||
final stillAuthFailed = retryStatus == 401 ||
|
|
||||||
(retryApi is ApiException && retryApi.isUnauthorized);
|
|
||||||
if (!stillAuthFailed) {
|
|
||||||
return handler.next(DioException(
|
|
||||||
requestOptions: err.requestOptions,
|
|
||||||
error: retryApi is ApiException
|
|
||||||
? retryApi
|
|
||||||
: ApiException(-1, '网络异常,请稍后重试'),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// 非 Dio 失败:视为网络异常,不清登录态
|
|
||||||
return handler.next(DioException(
|
return handler.next(DioException(
|
||||||
requestOptions: err.requestOptions,
|
requestOptions: err.requestOptions,
|
||||||
error: ApiException(-1, '网络异常,请稍后重试'),
|
error: refreshErr,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if (newToken != null) {
|
||||||
|
try {
|
||||||
|
final opts = err.requestOptions;
|
||||||
|
opts.extra['retried'] = true;
|
||||||
|
opts.headers['Authorization'] = 'Bearer $newToken';
|
||||||
|
final resp = await ApiClient._dio.fetch<dynamic>(opts);
|
||||||
|
return handler.resolve(resp);
|
||||||
|
} on DioException catch (retryErr) {
|
||||||
|
final retryApi = retryErr.error;
|
||||||
|
final retryStatus = retryErr.response?.statusCode;
|
||||||
|
// 重试后仍是鉴权失败(401 / 1002)→ 确认会话失效,登出;
|
||||||
|
// 其余失败(网络抖动、业务错误等)不清登录态,直接透传错误
|
||||||
|
final stillAuthFailed = retryStatus == 401 ||
|
||||||
|
(retryApi is ApiException && retryApi.isUnauthorized);
|
||||||
|
if (!stillAuthFailed) {
|
||||||
|
return handler.next(DioException(
|
||||||
|
requestOptions: err.requestOptions,
|
||||||
|
error: retryApi is ApiException
|
||||||
|
? retryApi
|
||||||
|
: ApiException(-1, '网络异常,请稍后重试'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 非 Dio 失败:视为网络异常,不清登录态
|
||||||
|
return handler.next(DioException(
|
||||||
|
requestOptions: err.requestOptions,
|
||||||
|
error: ApiException(-1, '网络异常,请稍后重试'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await TokenStore.clear();
|
await TokenStore.clear();
|
||||||
ApiClient.onSessionExpired?.call();
|
ApiClient.onSessionExpired?.call();
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import 'dart:async';
|
|||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import 'package:media_kit_video/media_kit_video.dart';
|
import 'package:media_kit_video/media_kit_video.dart';
|
||||||
|
|
||||||
|
import '../media/local_media_server.dart';
|
||||||
|
|
||||||
/// 一个播放槽位:Player + 视图控制器 + 当前绑定的视频
|
/// 一个播放槽位:Player + 视图控制器 + 当前绑定的视频
|
||||||
class PlayerSlot {
|
class PlayerSlot {
|
||||||
PlayerSlot(this.player);
|
PlayerSlot(this.player);
|
||||||
@@ -44,10 +46,38 @@ class FeedPlayerPool {
|
|||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
int get currentIndex => _currentIndex;
|
int get currentIndex => _currentIndex;
|
||||||
|
|
||||||
|
/// 代际计数器:每次 bindWindow 自增,await 恢复后校验,过期调用直接退出。
|
||||||
|
/// 快速连划时多个 bindWindow 并发 await open,无互斥会导致旧调用恢复后
|
||||||
|
/// 误 play 装有旧媒体的槽位(幽灵声音来源)。
|
||||||
|
int _generation = 0;
|
||||||
|
|
||||||
|
/// 是否挂起(切出首页 Tab / App 退后台):挂起期间 bindWindow 的 open
|
||||||
|
/// 完成后不允许自动 play —— 否则「先暂停、open 后完成又 play」的竞态
|
||||||
|
/// 会让视频在后台继续出声(切到"我的"听到同一视频声音的根因)
|
||||||
|
bool _suspended = false;
|
||||||
|
|
||||||
|
/// 断点续播记录:suspend 时保存当前槽位置(stop 断下载但位置不丢),
|
||||||
|
/// rebind 重新 open 后 seek 回断点再播;用后即清
|
||||||
|
int _resumeVideoId = -1;
|
||||||
|
Duration _resumePosition = Duration.zero;
|
||||||
|
|
||||||
|
/// 最近一次 bindWindow(resume 时需等在途 open 完成再 play,避免 play 被
|
||||||
|
/// open(play:false) 内部的 pause 覆盖导致恢复后不播)
|
||||||
|
Future<void>? _pendingBind;
|
||||||
|
|
||||||
/// 初始化(创建 3 槽位并订阅各自进度流)
|
/// 初始化(创建 3 槽位并订阅各自进度流)
|
||||||
void init() {
|
void init() {
|
||||||
for (var i = 0; i < slotCount; i++) {
|
for (var i = 0; i < slotCount; i++) {
|
||||||
final player = _playerFactory?.call() ?? Player();
|
// bufferSize 映射 mpv demuxer-max-bytes(前向缓存,默认 32MB)。
|
||||||
|
// 压到 6MB:pause 时最多再缓冲 ~6MB(当前 HLS 片段下完即停),
|
||||||
|
// 不会像默认配置那样暂停后还预读后续多个分片偷跑流量;
|
||||||
|
// 恢复播放时 demuxer 缓存仍在,从暂停位置无缝继续(抖音行为)。
|
||||||
|
final player = _playerFactory?.call() ??
|
||||||
|
Player(
|
||||||
|
configuration: PlayerConfiguration(
|
||||||
|
bufferSize: 6 * 1024 * 1024,
|
||||||
|
),
|
||||||
|
);
|
||||||
final slot = PlayerSlot(player);
|
final slot = PlayerSlot(player);
|
||||||
_slots.add(slot);
|
_slots.add(slot);
|
||||||
_hookSlot(slot);
|
_hookSlot(slot);
|
||||||
@@ -65,26 +95,40 @@ class FeedPlayerPool {
|
|||||||
bool get isCurrentPlaying => currentSlot.player.state.playing;
|
bool get isCurrentPlaying => currentSlot.player.state.playing;
|
||||||
|
|
||||||
/// 绑定窗口:index 为当前页;items 为已解析好播放地址的窗口数据源
|
/// 绑定窗口:index 为当前页;items 为已解析好播放地址的窗口数据源
|
||||||
Future<void> bindWindow(int index, List<({int videoId, String url})> items) async {
|
///
|
||||||
|
/// 窗口策略 [index, index+1]:只保留当前页(播放)和下一页(预缓冲)。
|
||||||
|
/// 上一页槽位 stop 清空:一是满足"划走即停止缓存下载"(pause 不停止缓冲,
|
||||||
|
/// stop 才断开),二是 stop 后槽内无媒体,即使误 play 也不会出声(防幽灵)。
|
||||||
|
Future<void> bindWindow(int index, List<({int videoId, String url})> items) {
|
||||||
|
final fut = _bindWindow(index, items);
|
||||||
|
_pendingBind = fut;
|
||||||
|
return fut;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _bindWindow(
|
||||||
|
int index, List<({int videoId, String url})> items) async {
|
||||||
|
final gen = ++_generation;
|
||||||
// 1. 先切换当前页指针(currentSlot / isCurrentPlaying / 完成回调都依赖它)
|
// 1. 先切换当前页指针(currentSlot / isCurrentPlaying / 完成回调都依赖它)
|
||||||
_currentIndex = index;
|
_currentIndex = index;
|
||||||
|
|
||||||
// 窗口内应保留的槽位集合 [index-1, index, index+1]
|
// 窗口内应保留的槽位集合 [index, index+1]
|
||||||
final activeSet = <int>{};
|
final activeSet = <int>{};
|
||||||
for (var offset = -1; offset <= 1; offset++) {
|
for (var offset = 0; offset <= 1; offset++) {
|
||||||
final i = index + offset;
|
final i = index + offset;
|
||||||
if (i >= 0 && i < items.length) activeSet.add(slotOf(i));
|
if (i >= 0 && i < items.length) activeSet.add(slotOf(i));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 立即静音(同步执行,不等后续网络 open):
|
// 2. 立即静音(同步执行,不等后续网络 open):
|
||||||
// - 出窗槽位:stop(断音 + 清缓存),并复位
|
// - 出窗槽位:stop(断音 + 断缓存 + 清媒体),并复位
|
||||||
// - 窗口内槽位:全部 pause(含刚划走的上一页,防止切页期间串音)
|
// - 窗口内非当前槽位:pause(下一页预缓冲不自动播)
|
||||||
// 这一步必须早于任何 await,否则 open 新视频期间旧视频仍在出声
|
// 这一步必须早于任何 await,否则 open 新视频期间旧视频仍在出声
|
||||||
for (var s = 0; s < slotCount; s++) {
|
for (var s = 0; s < slotCount; s++) {
|
||||||
final slot = _slots[s];
|
final slot = _slots[s];
|
||||||
if (slot.videoId < 0) continue;
|
if (slot.videoId < 0) continue;
|
||||||
if (activeSet.contains(s)) {
|
if (activeSet.contains(s)) {
|
||||||
try { slot.player.pause(); } catch (_) {}
|
if (s != slotOf(index)) {
|
||||||
|
try { slot.player.pause(); } catch (_) {}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
unawaited(slot.player.stop());
|
unawaited(slot.player.stop());
|
||||||
slot.videoId = -1;
|
slot.videoId = -1;
|
||||||
@@ -93,7 +137,7 @@ class FeedPlayerPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 窗口内槽位按需加载(open 完才 play)
|
// 3. 窗口内槽位按需加载(open 完才 play)
|
||||||
for (var offset = -1; offset <= 1; offset++) {
|
for (var offset = 0; offset <= 1; offset++) {
|
||||||
final i = index + offset;
|
final i = index + offset;
|
||||||
if (i < 0 || i >= items.length) continue;
|
if (i < 0 || i >= items.length) continue;
|
||||||
final slot = _slots[slotOf(i)];
|
final slot = _slots[slotOf(i)];
|
||||||
@@ -102,14 +146,85 @@ class FeedPlayerPool {
|
|||||||
slot.videoId = item.videoId;
|
slot.videoId = item.videoId;
|
||||||
slot.completeNotified = false;
|
slot.completeNotified = false;
|
||||||
if (item.url.isNotEmpty) {
|
if (item.url.isNotEmpty) {
|
||||||
await slot.player.open(Media(item.url), play: false);
|
// 断点续播:open 前设置 mpv start 属性,mpv 在文件加载完成后自动
|
||||||
|
// 定位到断点。注意不能 open 后再复位 start——open() 发出 loadfile
|
||||||
|
// 即返回、文件异步加载,过早复位会让 mpv 加载时读到 none 从头播。
|
||||||
|
// 所以每次 open 前都显式设置(断点秒数或 none),无残留污染
|
||||||
|
final resumePos =
|
||||||
|
(i == index && item.videoId == _resumeVideoId && _resumeVideoId >= 0)
|
||||||
|
? _resumePosition
|
||||||
|
: Duration.zero;
|
||||||
|
await _setMpvStart(slot.player, resumePos);
|
||||||
|
// 经本地代理:HLS 分片 1G LRU 缓存,回看命中缓存不再下载
|
||||||
|
await slot.player.open(Media(LocalMediaServer.I.wrap(item.url)), play: false);
|
||||||
|
// await 期间用户可能又翻页:本次绑定已过期,退出(最新绑定会接管)
|
||||||
|
if (gen != _generation) return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 当前槽:播放
|
// 4. 当前槽:播放(播前再校验代际与挂起态,防过期调用/后台挂起时唤醒媒体)
|
||||||
|
if (gen != _generation || _suspended) return;
|
||||||
|
// 断点已在 step 3 经 mpv start 属性应用,此处消费掉记录
|
||||||
|
_resumeVideoId = -1;
|
||||||
|
_resumePosition = Duration.zero;
|
||||||
currentSlot.player.play();
|
currentSlot.player.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 设置 mpv start 属性(zero 表示复位为 none)。仅 Native 平台有效,
|
||||||
|
/// 测试注入的 fake player 无 platform,静默忽略
|
||||||
|
Future<void> _setMpvStart(Player player, Duration pos) async {
|
||||||
|
try {
|
||||||
|
final platform = player.platform;
|
||||||
|
if (platform is NativePlayer) {
|
||||||
|
await platform.setProperty(
|
||||||
|
'start',
|
||||||
|
pos > Duration.zero
|
||||||
|
? (pos.inMilliseconds / 1000).toStringAsFixed(3)
|
||||||
|
: 'none',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 挂起(切出首页底部 Tab / App 退后台 / 切走当前策略):
|
||||||
|
/// 记录当前槽播放位置后 stop 全部槽位。mpv 的 pause 只停播放线程,
|
||||||
|
/// demuxer 对 HLS 流仍持续预读分片(实测 demuxer-max-bytes 限不住),
|
||||||
|
/// 只有 stop 能可靠断开下载;位置存到 _resumePosition,恢复时 rebind
|
||||||
|
/// 重新 open + seek 续播(分片走本地代理磁盘缓存,零流量)
|
||||||
|
void suspend() {
|
||||||
|
_suspended = true;
|
||||||
|
// 使在途 bindWindow 过期:其 open 完成后不得再 play
|
||||||
|
_generation++;
|
||||||
|
for (var s = 0; s < slotCount; s++) {
|
||||||
|
final slot = _slots[s];
|
||||||
|
if (slot.videoId < 0) continue;
|
||||||
|
if (s == slotOf(_currentIndex)) {
|
||||||
|
_resumeVideoId = slot.videoId;
|
||||||
|
_resumePosition = slot.player.state.position;
|
||||||
|
}
|
||||||
|
unawaited(slot.player.stop());
|
||||||
|
slot.videoId = -1;
|
||||||
|
slot.completeNotified = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 恢复(切回首页 / App 回前台 / 策略切回):先等待在途 bindWindow 的 open 完成
|
||||||
|
/// (否则 play 会被 open(play:false) 的内部 pause 覆盖导致恢复后不播),然后解除
|
||||||
|
/// 挂起并 play。_suspended 必须在 await 之后才解除——否则等待期间 completed
|
||||||
|
/// listener 的 auto-loop 会因为看到 _suspended=false 而提前 play
|
||||||
|
Future<void> resume() async {
|
||||||
|
final pending = _pendingBind;
|
||||||
|
if (pending != null) {
|
||||||
|
try {
|
||||||
|
await pending;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
_suspended = false;
|
||||||
|
try {
|
||||||
|
currentSlot.player.play();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
/// 每槽位独立订阅:≥95% 或播完触发一次 complete 回调
|
/// 每槽位独立订阅:≥95% 或播完触发一次 complete 回调
|
||||||
void _hookSlot(PlayerSlot slot) {
|
void _hookSlot(PlayerSlot slot) {
|
||||||
var lastPosition = Duration.zero;
|
var lastPosition = Duration.zero;
|
||||||
@@ -135,7 +250,7 @@ class FeedPlayerPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 自动循环(抖音/TikTok 式体验:当前槽播完从头再来)
|
// 自动循环(抖音/TikTok 式体验:当前槽播完从头再来)
|
||||||
if (slot == currentSlot) {
|
if (slot == currentSlot && !_suspended) {
|
||||||
slot.player.seek(Duration.zero);
|
slot.player.seek(Duration.zero);
|
||||||
slot.player.play();
|
slot.player.play();
|
||||||
}
|
}
|
||||||
@@ -155,6 +270,25 @@ class FeedPlayerPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 全部停止并清空(进入全屏播放器前调用):停音 + 断缓存 + 清媒体。
|
||||||
|
/// 与 pause 不同,stop 后缓存下载断开,且槽内无媒体不会幽灵出声。
|
||||||
|
/// 返回 Feed 后需重新 bindWindow 恢复当前页。
|
||||||
|
void stopAll() {
|
||||||
|
// 使所有进行中的 bindWindow 过期(其 await 恢复后不会再 play)
|
||||||
|
_generation++;
|
||||||
|
// stopAll 后通常跟着 rebind 重新播放(如从登录页返回),解除挂起态
|
||||||
|
_suspended = false;
|
||||||
|
// 彻底重置语义:清掉 suspend 保存的断点,rebind 从头播
|
||||||
|
_resumeVideoId = -1;
|
||||||
|
_resumePosition = Duration.zero;
|
||||||
|
for (final slot in _slots) {
|
||||||
|
if (slot.videoId < 0) continue;
|
||||||
|
unawaited(slot.player.stop());
|
||||||
|
slot.videoId = -1;
|
||||||
|
slot.completeNotified = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 释放全部资源(Feed 页 dispose 时调用)
|
/// 释放全部资源(Feed 页 dispose 时调用)
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
for (final sub in _subs) {
|
for (final sub in _subs) {
|
||||||
|
|||||||
+30
-1
@@ -2,15 +2,20 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
|
||||||
|
import 'core/media/download_manager.dart';
|
||||||
|
import 'core/media/local_media_server.dart';
|
||||||
import 'core/storage/local_store.dart';
|
import 'core/storage/local_store.dart';
|
||||||
import 'core/storage/search_history_store.dart';
|
import 'core/storage/search_history_store.dart';
|
||||||
import 'core/storage/watch_dedup_store.dart';
|
import 'core/storage/watch_dedup_store.dart';
|
||||||
import 'core/theme/app_theme.dart';
|
import 'core/theme/app_theme.dart';
|
||||||
|
import 'core/config/app_config.dart';
|
||||||
import 'router.dart';
|
import 'router.dart';
|
||||||
|
|
||||||
/// App 入口:本地存储 + MediaKit + MaterialApp.router
|
/// App 入口:本地存储 + MediaKit + MaterialApp.router
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
// 版本信息(版本检查/请求头 X-App-Version 依赖,必须先初始化)
|
||||||
|
await AppConfig.initVersion();
|
||||||
// MediaKit(libmpv 底层)全局初始化:每个进程必须且仅调一次
|
// MediaKit(libmpv 底层)全局初始化:每个进程必须且仅调一次
|
||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
// 本地存储初始化(设备指纹在 splash 阶段生成)
|
// 本地存储初始化(设备指纹在 splash 阶段生成)
|
||||||
@@ -19,8 +24,32 @@ Future<void> main() async {
|
|||||||
await SearchHistoryStore.init();
|
await SearchHistoryStore.init();
|
||||||
// 观看上报去重(持久化;不初始化则观看计费/游客免费额度链路失效)
|
// 观看上报去重(持久化;不初始化则观看计费/游客免费额度链路失效)
|
||||||
await WatchDedupStore.init();
|
await WatchDedupStore.init();
|
||||||
|
// 本地媒体服务:HLS 1G 分片缓存(回看免下载)+ 已下载视频离线播放
|
||||||
|
await LocalMediaServer.I.init();
|
||||||
|
|
||||||
runApp(const ProviderScope(child: VideoApp()));
|
runApp(ProviderScope(
|
||||||
|
overrides: const [],
|
||||||
|
child: const _AppInitializer(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化需要 Provider 环境的单例(下载管理器扫描本地已下载列表)
|
||||||
|
class _AppInitializer extends ConsumerStatefulWidget {
|
||||||
|
const _AppInitializer();
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_AppInitializer> createState() => _AppInitializerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppInitializerState extends ConsumerState<_AppInitializer> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
ref.read(downloadManagerProvider.notifier).init();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => const VideoApp();
|
||||||
}
|
}
|
||||||
|
|
||||||
class VideoApp extends ConsumerWidget {
|
class VideoApp extends ConsumerWidget {
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
state = state.copyWith(restored: true);
|
state = state.copyWith(restored: true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 数据完整性防线:有 access 无 refresh 是不完整登录态(2h 后必然 401
|
||||||
|
// 且无法静默刷新),直接清理回游客态引导重新登录,避免僵尸登录态
|
||||||
|
if (TokenStore.refreshToken == null || TokenStore.refreshToken!.isEmpty) {
|
||||||
|
await TokenStore.clear();
|
||||||
|
state = state.copyWith(restored: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
final user = await UserService.fetchMe();
|
final user = await UserService.fetchMe();
|
||||||
state = AuthState(user: user, restored: true);
|
state = AuthState(user: user, restored: true);
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import 'auth_provider.dart';
|
|||||||
enum FeedStrategy {
|
enum FeedStrategy {
|
||||||
recommend('recommend', '推荐'),
|
recommend('recommend', '推荐'),
|
||||||
latest('latest', '最新'),
|
latest('latest', '最新'),
|
||||||
hot('hot', '热度');
|
hot('hot', '热度'),
|
||||||
|
liked('liked', '口碑'),
|
||||||
|
viewed('viewed', '热播');
|
||||||
|
|
||||||
const FeedStrategy(this.apiValue, this.label);
|
const FeedStrategy(this.apiValue, this.label);
|
||||||
|
|
||||||
@@ -22,6 +24,15 @@ enum FeedStrategy {
|
|||||||
final String label;
|
final String label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Feed 顶部当前选中的策略:HomeShell 切底部 Tab 回来时,
|
||||||
|
/// 只恢复该策略对应的播放器池(其余池保持挂起,防止幽灵声)
|
||||||
|
final activeFeedStrategyProvider =
|
||||||
|
StateProvider<FeedStrategy>((_) => FeedStrategy.recommend);
|
||||||
|
|
||||||
|
/// 首页底部 Tab 当前是否可见:App 回前台时仅在可见状态下恢复播放
|
||||||
|
/// (否则在"我的"等页面退后台再回来会误恢复声音)
|
||||||
|
final feedTabVisibleProvider = StateProvider<bool>((_) => true);
|
||||||
|
|
||||||
/// Feed 页 UI 事件(Notifier 不持有 BuildContext,通过事件流驱动弹窗/Toast)
|
/// Feed 页 UI 事件(Notifier 不持有 BuildContext,通过事件流驱动弹窗/Toast)
|
||||||
sealed class FeedEvent {
|
sealed class FeedEvent {
|
||||||
const FeedEvent();
|
const FeedEvent();
|
||||||
@@ -148,6 +159,17 @@ class FeedNotifier extends StateNotifier<FeedState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从全屏播放器返回后恢复:stopAll 清空了槽位,重新绑定当前页并播放。
|
||||||
|
/// 不上报观看(本次进入 Feed 时已上报过,且 _bindAndReport 有按日去重)。
|
||||||
|
Future<void> rebindCurrent() async {
|
||||||
|
final index = state.currentIndex;
|
||||||
|
if (index < 0 || index >= state.items.length) return;
|
||||||
|
await pool.bindWindow(
|
||||||
|
index,
|
||||||
|
[for (final v in state.items) (videoId: v.videoId, url: resolvePlayUrl(v))],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 翻页:滑出即暂停、滑入自动播放并上报观看计费(《01-开发文档》5.3)
|
/// 翻页:滑出即暂停、滑入自动播放并上报观看计费(《01-开发文档》5.3)
|
||||||
void onPageChanged(int index) {
|
void onPageChanged(int index) {
|
||||||
// 立即静音全部非目标槽位,防止翻页动画期间多个视频同时出声
|
// 立即静音全部非目标槽位,防止翻页动画期间多个视频同时出声
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../screens/assets/assets_screen.dart';
|
|||||||
import '../screens/assets/withdraw_screen.dart';
|
import '../screens/assets/withdraw_screen.dart';
|
||||||
import '../screens/auth/login_screen.dart';
|
import '../screens/auth/login_screen.dart';
|
||||||
import '../screens/auth/register_screen.dart';
|
import '../screens/auth/register_screen.dart';
|
||||||
|
import '../screens/downloads/downloads_screen.dart';
|
||||||
import '../screens/home/home_shell.dart';
|
import '../screens/home/home_shell.dart';
|
||||||
import '../screens/membership/membership_screen.dart';
|
import '../screens/membership/membership_screen.dart';
|
||||||
import '../screens/notification/notifications_screen.dart';
|
import '../screens/notification/notifications_screen.dart';
|
||||||
@@ -94,6 +95,11 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
builder: (_, __) => VideoGridScreen.favorites(),
|
builder: (_, __) => VideoGridScreen.favorites(),
|
||||||
),
|
),
|
||||||
GoRoute(path: '/mine/history', builder: (_, __) => const HistoryScreen()),
|
GoRoute(path: '/mine/history', builder: (_, __) => const HistoryScreen()),
|
||||||
|
// 已下载(离线可看)
|
||||||
|
GoRoute(
|
||||||
|
path: '/mine/downloads',
|
||||||
|
builder: (_, __) => const DownloadsScreen(),
|
||||||
|
),
|
||||||
// 编辑资料 / 头像(页面内兜底未登录引导)
|
// 编辑资料 / 头像(页面内兜底未登录引导)
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/profile/edit',
|
path: '/profile/edit',
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import '../../core/theme/app_theme.dart';
|
|||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../services/auth_service.dart';
|
import '../../services/auth_service.dart';
|
||||||
|
|
||||||
/// 注册页:手机号 + 验证码 + 密码;邀请码选填(口令/模糊归因命中时自动预填)
|
/// 注册页:手机号/邮箱 + 验证码 + 密码;邀请码选填(口令/模糊归因命中时自动预填)
|
||||||
class RegisterScreen extends ConsumerStatefulWidget {
|
class RegisterScreen extends ConsumerStatefulWidget {
|
||||||
const RegisterScreen({super.key});
|
const RegisterScreen({super.key});
|
||||||
|
|
||||||
@@ -19,11 +19,16 @@ class RegisterScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||||
final _phone = TextEditingController();
|
final _account = TextEditingController();
|
||||||
final _code = TextEditingController();
|
final _code = TextEditingController();
|
||||||
final _password = TextEditingController();
|
final _password = TextEditingController();
|
||||||
final _nickname = TextEditingController();
|
final _nickname = TextEditingController();
|
||||||
late final TextEditingController _inviteCode;
|
late final TextEditingController _inviteCode;
|
||||||
|
|
||||||
|
/// 注册方式:phone / email
|
||||||
|
String _mode = 'phone';
|
||||||
|
bool get _isEmail => _mode == 'email';
|
||||||
|
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
@@ -31,6 +36,9 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
int _countdown = 0;
|
int _countdown = 0;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
|
|
||||||
|
/// 邮箱格式校验
|
||||||
|
static final _emailReg = RegExp(r'^[\w.+-]+@[\w-]+\.[\w.-]+$');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -41,7 +49,7 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_phone.dispose();
|
_account.dispose();
|
||||||
_code.dispose();
|
_code.dispose();
|
||||||
_password.dispose();
|
_password.dispose();
|
||||||
_nickname.dispose();
|
_nickname.dispose();
|
||||||
@@ -49,20 +57,41 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _switchMode(String mode) {
|
||||||
|
if (mode == _mode) return;
|
||||||
|
setState(() {
|
||||||
|
_mode = mode;
|
||||||
|
_error = null;
|
||||||
|
// 切换方式后账号和验证码需要重新填写
|
||||||
|
_account.clear();
|
||||||
|
_code.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _sendCode() async {
|
Future<void> _sendCode() async {
|
||||||
final phone = _phone.text.trim();
|
final account = _account.text.trim();
|
||||||
if (!RegExp(r'^1\d{10}$').hasMatch(phone)) {
|
if (_isEmail) {
|
||||||
setState(() => _error = '请输入正确的手机号');
|
if (!_emailReg.hasMatch(account)) {
|
||||||
return;
|
setState(() => _error = '请输入正确的邮箱地址');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!RegExp(r'^1\d{10}$').hasMatch(account)) {
|
||||||
|
setState(() => _error = '请输入正确的手机号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final devCode = await AuthService.sendSmsCode(phone, 'register');
|
// 未配置短信/邮件通道时服务端走开发模式,直接返回验证码
|
||||||
|
final devCode = _isEmail
|
||||||
|
? await AuthService.sendEmailCode(account, 'register')
|
||||||
|
: await AuthService.sendSmsCode(account, 'register');
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = null;
|
_error = null;
|
||||||
_countdown = 60;
|
_countdown = 60;
|
||||||
});
|
});
|
||||||
if (devCode != null && mounted) {
|
if (devCode != null && mounted) {
|
||||||
// 短信通道未配置时的开发模式:直接展示验证码(上线配置短信后服务端不再返回)
|
// 通道未配置时的开发模式:直接展示验证码(上线配置后服务端不再返回)
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('开发模式验证码:$devCode(5 分钟内有效)'), duration: const Duration(seconds: 6)),
|
SnackBar(content: Text('开发模式验证码:$devCode(5 分钟内有效)'), duration: const Duration(seconds: 6)),
|
||||||
);
|
);
|
||||||
@@ -84,11 +113,11 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _submit() async {
|
Future<void> _submit() async {
|
||||||
final phone = _phone.text.trim();
|
final account = _account.text.trim();
|
||||||
final code = _code.text.trim();
|
final code = _code.text.trim();
|
||||||
final password = _password.text;
|
final password = _password.text;
|
||||||
if (phone.isEmpty || code.isEmpty || password.isEmpty) {
|
if (account.isEmpty || code.isEmpty || password.isEmpty) {
|
||||||
setState(() => _error = '请填写手机号、验证码和密码');
|
setState(() => _error = _isEmail ? '请填写邮箱、验证码和密码' : '请填写手机号、验证码和密码');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -97,8 +126,8 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
final result = await AuthService.register(
|
final result = await AuthService.register(
|
||||||
type: 'phone',
|
type: _mode,
|
||||||
account: phone,
|
account: account,
|
||||||
code: code,
|
code: code,
|
||||||
password: password,
|
password: password,
|
||||||
nickname: _nickname.text.trim(),
|
nickname: _nickname.text.trim(),
|
||||||
@@ -132,11 +161,14 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
children: [
|
children: [
|
||||||
const Text('创建账号', style: TextStyle(fontSize: 26, fontWeight: FontWeight.w700)),
|
const Text('创建账号', style: TextStyle(fontSize: 26, fontWeight: FontWeight.w700)),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 24),
|
||||||
|
// 手机号 / 邮箱 切换
|
||||||
|
_ModeSwitch(current: _mode, onChanged: _switchMode),
|
||||||
|
const SizedBox(height: 24),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _phone,
|
controller: _account,
|
||||||
keyboardType: TextInputType.phone,
|
keyboardType: _isEmail ? TextInputType.emailAddress : TextInputType.phone,
|
||||||
decoration: const InputDecoration(hintText: '手机号'),
|
decoration: InputDecoration(hintText: _isEmail ? '邮箱' : '手机号'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
@@ -145,7 +177,7 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _code,
|
controller: _code,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
decoration: const InputDecoration(hintText: '短信验证码'),
|
decoration: InputDecoration(hintText: _isEmail ? '邮箱验证码' : '短信验证码'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -201,3 +233,53 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 注册方式切换条(手机号 / 邮箱)
|
||||||
|
class _ModeSwitch extends StatelessWidget {
|
||||||
|
const _ModeSwitch({required this.current, required this.onChanged});
|
||||||
|
|
||||||
|
final String current;
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surface,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(3),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_tab(context, '手机号', 'phone'),
|
||||||
|
_tab(context, '邮箱', 'email'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tab(BuildContext context, String label, String value) {
|
||||||
|
final selected = current == value;
|
||||||
|
return Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => onChanged(value),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 9),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? Theme.of(context).scaffoldBackgroundColor : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
|
||||||
|
color: selected ? AppColors.textPrimary : AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/media/download_manager.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../models/video_item.dart';
|
||||||
|
|
||||||
|
/// 已下载视频列表(我的 → 已下载):
|
||||||
|
/// 下载中任务(进度/取消)+ 已完成列表(点击播放 / 左滑或长按删除)
|
||||||
|
class DownloadsScreen extends ConsumerWidget {
|
||||||
|
const DownloadsScreen({super.key});
|
||||||
|
|
||||||
|
static String _fmtSize(int bytes) {
|
||||||
|
if (bytes >= 1 << 30) return '${(bytes / (1 << 30)).toStringAsFixed(2)} GB';
|
||||||
|
if (bytes >= 1 << 20) return '${(bytes / (1 << 20)).toStringAsFixed(1)} MB';
|
||||||
|
return '${(bytes / 1024).toStringAsFixed(0)} KB';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _fmtDuration(int seconds) {
|
||||||
|
final m = seconds ~/ 60;
|
||||||
|
final s = seconds % 60;
|
||||||
|
return '$m:${s.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _play(BuildContext context, DownloadItem item) {
|
||||||
|
context.push('/player',
|
||||||
|
extra: VideoItem(
|
||||||
|
videoId: item.videoId,
|
||||||
|
title: item.title,
|
||||||
|
coverUrl: item.coverUrl,
|
||||||
|
videoUrl: item.playUrl,
|
||||||
|
duration: item.duration,
|
||||||
|
author: VideoAuthor(userId: 0),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(
|
||||||
|
BuildContext context, WidgetRef ref, DownloadItem item) async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('删除下载'),
|
||||||
|
content: Text('删除「${item.title}」?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('取消')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('删除', style: TextStyle(color: Colors.red))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok == true) {
|
||||||
|
ref.read(downloadManagerProvider.notifier).delete(item.videoId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final state = ref.watch(downloadManagerProvider);
|
||||||
|
final downloading = state.progress.entries.toList();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('已下载')),
|
||||||
|
body: ListView(
|
||||||
|
children: [
|
||||||
|
// 下载中
|
||||||
|
for (final e in downloading)
|
||||||
|
ListTile(
|
||||||
|
leading: const SizedBox(
|
||||||
|
width: 56, height: 56,
|
||||||
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
),
|
||||||
|
title: Text('视频 #${e.key}',
|
||||||
|
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
subtitle: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: LinearProgressIndicator(value: e.value),
|
||||||
|
),
|
||||||
|
trailing: Text('${(e.value * 100).toStringAsFixed(0)}%'),
|
||||||
|
onLongPress: () =>
|
||||||
|
ref.read(downloadManagerProvider.notifier).cancel(e.key),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (downloading.isNotEmpty) const Divider(),
|
||||||
|
|
||||||
|
// 已完成
|
||||||
|
if (state.items.isEmpty && downloading.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 120),
|
||||||
|
child: Center(
|
||||||
|
child: Text('暂无下载\n长按视频可下载',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: AppColors.textWeak)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final item in state.items)
|
||||||
|
ListTile(
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
|
leading: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 72, height: 48,
|
||||||
|
child: item.coverUrl.isNotEmpty
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: item.coverUrl, fit: BoxFit.cover)
|
||||||
|
: Container(
|
||||||
|
color: Colors.grey[800],
|
||||||
|
child: const Icon(Icons.play_circle_outline,
|
||||||
|
color: Colors.white54),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(item.title.isEmpty ? '视频 #${item.videoId}' : item.title,
|
||||||
|
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
subtitle: Text(
|
||||||
|
'${_fmtDuration(item.duration)} · ${_fmtSize(item.sizeBytes)}',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline,
|
||||||
|
color: AppColors.textWeak),
|
||||||
|
onPressed: () => _confirmDelete(context, ref, item),
|
||||||
|
),
|
||||||
|
onTap: () => _play(context, item),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,30 +32,49 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
// 登记当前策略,HomeShell 切回首页时只恢复这个策略的播放器池
|
||||||
|
ref.read(activeFeedStrategyProvider.notifier).state = _strategy;
|
||||||
// 监听计费/UI 事件(弹窗与 Toast 由 Notifier 事件流驱动)
|
// 监听计费/UI 事件(弹窗与 Toast 由 Notifier 事件流驱动)
|
||||||
_eventSub = ref
|
_eventSub = ref
|
||||||
.read(feedProvider(_strategy).notifier)
|
.read(feedProvider(_strategy).notifier)
|
||||||
.events
|
.events
|
||||||
.listen(_handleEvent);
|
.listen(_handleEvent);
|
||||||
|
// 从登录页等返回时恢复播放:provider 全局常驻保留了列表,
|
||||||
|
// 但槽位已在 dispose 时 stopAll,需重新绑定(冷启动列表为空时为 no-op)
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) ref.read(feedProvider(_strategy).notifier).rebindCurrent();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_eventSub?.cancel();
|
_eventSub?.cancel();
|
||||||
|
// 离开 Feed 路由(如跳登录页):彻底停止播放(断音+断缓存下载)。
|
||||||
|
// feedProvider 是全局常驻,不处理则播放器池在后台继续出声
|
||||||
|
ref.read(feedProvider(_strategy).notifier).pool.stopAll();
|
||||||
_pageController.dispose();
|
_pageController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// App 退后台暂停、回前台恢复播放
|
/// App 退后台挂起、回前台从断点续播。inactive(下拉通知栏等瞬时态)
|
||||||
|
/// 只轻量 pause 当前槽防出声;paused(真退后台)才走 suspend(stop)
|
||||||
|
/// 彻底断下载——避免瞬时 inactive 触发 stop+重载的画面闪烁
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
final notifier = ref.read(feedProvider(_strategy).notifier);
|
final notifier = ref.read(feedProvider(_strategy).notifier);
|
||||||
if (state == AppLifecycleState.paused ||
|
if (state == AppLifecycleState.paused) {
|
||||||
state == AppLifecycleState.inactive) {
|
notifier.pool.suspend();
|
||||||
|
} else if (state == AppLifecycleState.inactive) {
|
||||||
notifier.pool.pauseCurrent();
|
notifier.pool.pauseCurrent();
|
||||||
} else if (state == AppLifecycleState.resumed) {
|
} else if (state == AppLifecycleState.resumed) {
|
||||||
notifier.pool.playCurrent();
|
// 仅在首页 Tab 可见时恢复(在"我的"等页面回前台不播)
|
||||||
|
if (ref.read(feedTabVisibleProvider)) {
|
||||||
|
unawaited(() async {
|
||||||
|
await notifier.pool.resume();
|
||||||
|
await notifier.rebindCurrent();
|
||||||
|
}());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,15 +92,27 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
|||||||
|
|
||||||
void _switchStrategy(FeedStrategy next) {
|
void _switchStrategy(FeedStrategy next) {
|
||||||
if (next == _strategy) return;
|
if (next == _strategy) return;
|
||||||
// 暂停旧策略播放,切换后新策略独立维护状态与播放器池
|
// 挂起旧策略池(防在途 open 完成后自动出声)
|
||||||
ref.read(feedProvider(_strategy).notifier).pool.pauseCurrent();
|
ref.read(feedProvider(_strategy).notifier).pool.suspend();
|
||||||
setState(() => _strategy = next);
|
setState(() => _strategy = next);
|
||||||
|
ref.read(activeFeedStrategyProvider.notifier).state = next;
|
||||||
_eventSub?.cancel();
|
_eventSub?.cancel();
|
||||||
_eventSub = ref
|
_eventSub = ref
|
||||||
.read(feedProvider(_strategy).notifier)
|
.read(feedProvider(_strategy).notifier)
|
||||||
.events
|
.events
|
||||||
.listen(_handleEvent);
|
.listen(_handleEvent);
|
||||||
_pageController.jumpToPage(0);
|
_pageController.jumpToPage(0);
|
||||||
|
// jumpToPage 后 resume + rebindCurrent:suspend 已 stop 槽位,仅 play
|
||||||
|
// 播不起来,必须 rebind 重新 open(有断点记录则 seek 续播)
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
unawaited(() async {
|
||||||
|
final n = ref.read(feedProvider(_strategy).notifier);
|
||||||
|
await n.pool.resume();
|
||||||
|
await n.rebindCurrent();
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 打开评论弹层:打开期间暂停当前播放,关闭后恢复
|
/// 打开评论弹层:打开期间暂停当前播放,关闭后恢复
|
||||||
@@ -142,6 +173,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
|||||||
onLike: notifier.toggleLike,
|
onLike: notifier.toggleLike,
|
||||||
onFavorite: notifier.toggleFavorite,
|
onFavorite: notifier.toggleFavorite,
|
||||||
onComment: _openComments,
|
onComment: _openComments,
|
||||||
|
onReturnFromPlayer: notifier.rebindCurrent,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
|
import '../../../core/media/download_manager.dart';
|
||||||
import '../../../core/player/feed_player_pool.dart';
|
import '../../../core/player/feed_player_pool.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../models/video_item.dart';
|
import '../../../models/video_item.dart';
|
||||||
@@ -25,6 +26,7 @@ class FeedVideoPage extends ConsumerStatefulWidget {
|
|||||||
required this.onLike,
|
required this.onLike,
|
||||||
this.onFavorite,
|
this.onFavorite,
|
||||||
this.onComment,
|
this.onComment,
|
||||||
|
this.onReturnFromPlayer,
|
||||||
});
|
});
|
||||||
|
|
||||||
final VideoItem item;
|
final VideoItem item;
|
||||||
@@ -35,6 +37,9 @@ class FeedVideoPage extends ConsumerStatefulWidget {
|
|||||||
final void Function(int videoId)? onFavorite;
|
final void Function(int videoId)? onFavorite;
|
||||||
final void Function(VideoItem item)? onComment;
|
final void Function(VideoItem item)? onComment;
|
||||||
|
|
||||||
|
/// 从全屏播放器返回后的恢复回调(重新绑定 Feed 当前页)
|
||||||
|
final VoidCallback? onReturnFromPlayer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<FeedVideoPage> createState() => _FeedVideoPageState();
|
ConsumerState<FeedVideoPage> createState() => _FeedVideoPageState();
|
||||||
}
|
}
|
||||||
@@ -43,6 +48,10 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
int _likeBurstTrigger = 0;
|
int _likeBurstTrigger = 0;
|
||||||
Map<String, dynamic>? _seriesData;
|
Map<String, dynamic>? _seriesData;
|
||||||
|
|
||||||
|
/// 清屏模式:隐藏右侧互动栏与底部信息区,只留画面和进度条。
|
||||||
|
/// 单击屏幕仍是暂停/继续;恢复显示只能通过长按菜单的「恢复清屏」
|
||||||
|
bool _cleanMode = false;
|
||||||
|
|
||||||
static const _rates = [1.0, 1.5, 2.0, 3.0];
|
static const _rates = [1.0, 1.5, 2.0, 3.0];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -89,7 +98,10 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
),
|
),
|
||||||
builder: (sheetContext) => Padding(
|
builder: (sheetContext) => Theme(
|
||||||
|
// 弹窗是白底,APP 主题为暗色(默认白字),需显式套用亮色主题
|
||||||
|
data: ThemeData.light(),
|
||||||
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -165,8 +177,57 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
_share();
|
_share();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// 清屏 / 恢复清屏:隐藏右侧互动栏与底部信息,只留进度条。
|
||||||
|
// 恢复只能从长按菜单进入,避免单击误触把界面弹回来
|
||||||
|
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 ? '已清屏,长按视频可恢复' : '已恢复显示');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// 下载(已下载/下载中置灰)
|
||||||
|
Builder(builder: (_) {
|
||||||
|
final dm = ref.read(downloadManagerProvider);
|
||||||
|
final downloaded = dm.items
|
||||||
|
.any((e) => e.videoId == widget.item.videoId);
|
||||||
|
final downloading =
|
||||||
|
dm.progress.containsKey(widget.item.videoId);
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: Icon(downloaded
|
||||||
|
? Icons.check_circle_outline
|
||||||
|
: Icons.download_outlined),
|
||||||
|
title: Text(downloaded
|
||||||
|
? '已下载'
|
||||||
|
: (downloading ? '下载中…' : '下载')),
|
||||||
|
enabled: !downloaded && !downloading,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
showTopToast(context, '开始下载,可在「我的 → 已下载」查看');
|
||||||
|
ref
|
||||||
|
.read(downloadManagerProvider.notifier)
|
||||||
|
.start(widget.item)
|
||||||
|
.catchError((_) {
|
||||||
|
if (mounted) showTopToast(context, '下载失败,请重试');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -185,7 +246,9 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
),
|
),
|
||||||
builder: (_) => DraggableScrollableSheet(
|
builder: (_) => Theme(
|
||||||
|
data: ThemeData.light(),
|
||||||
|
child: DraggableScrollableSheet(
|
||||||
initialChildSize: 0.6,
|
initialChildSize: 0.6,
|
||||||
minChildSize: 0.4,
|
minChildSize: 0.4,
|
||||||
maxChildSize: 0.9,
|
maxChildSize: 0.9,
|
||||||
@@ -273,6 +336,7 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -287,16 +351,20 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
duration: (ep['duration'] as num?)?.toInt() ?? 0,
|
duration: (ep['duration'] as num?)?.toInt() ?? 0,
|
||||||
author: widget.item.author,
|
author: widget.item.author,
|
||||||
);
|
);
|
||||||
// 先暂停 Feed 页播放器,再打开 SimplePlayerScreen
|
// 彻底停止 Feed 层播放器:stopAll 断音 + 断缓存下载(pause 不停止缓冲)
|
||||||
widget.pool.pauseExcept(slotIndex: -1);
|
widget.pool.stopAll();
|
||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
context.push('/player', extra: newItem);
|
context.push('/player', extra: newItem).then((_) {
|
||||||
|
// 从播放器返回:恢复 Feed 当前页(槽位已清空,需重新绑定)
|
||||||
|
if (mounted) widget.onReturnFromPlayer?.call();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
|
// 单击始终暂停/继续(清屏态也一样),恢复清屏需走长按菜单
|
||||||
onTap: widget.pool.toggleCurrent,
|
onTap: widget.pool.toggleCurrent,
|
||||||
onDoubleTap: _onDoubleTap,
|
onDoubleTap: _onDoubleTap,
|
||||||
onLongPress: _onLongPress,
|
onLongPress: _onLongPress,
|
||||||
@@ -305,28 +373,33 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// 底部渐变(提升文字可读性)
|
// 底部渐变(提升文字可读性):清屏时一并淡出
|
||||||
const Positioned(
|
Positioned(
|
||||||
left: 0, right: 0, bottom: 0, height: 160,
|
left: 0, right: 0, bottom: 0, height: 160,
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
child: DecoratedBox(
|
child: AnimatedOpacity(
|
||||||
decoration: BoxDecoration(
|
opacity: _cleanMode ? 0 : 1,
|
||||||
gradient: LinearGradient(
|
duration: const Duration(milliseconds: 200),
|
||||||
begin: Alignment.topCenter,
|
child: const DecoratedBox(
|
||||||
end: Alignment.bottomCenter,
|
decoration: BoxDecoration(
|
||||||
colors: [Colors.transparent, Colors.black54],
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Colors.transparent, Colors.black54],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 播放器(含抖音风格细进度条)
|
// 播放器(含抖音风格细进度条)——清屏时仍保留
|
||||||
VideoPlayerView(
|
VideoPlayerView(
|
||||||
pool: widget.pool,
|
pool: widget.pool,
|
||||||
slotIndex: widget.slotIndex,
|
slotIndex: widget.slotIndex,
|
||||||
coverUrl: widget.item.coverUrl,
|
coverUrl: widget.item.coverUrl,
|
||||||
isCurrent: widget.isCurrent,
|
isCurrent: widget.isCurrent,
|
||||||
|
durationSeconds: widget.item.duration,
|
||||||
),
|
),
|
||||||
|
|
||||||
// 双击爱心动画
|
// 双击爱心动画
|
||||||
@@ -335,54 +408,60 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
// 右侧互动栏(头像/点赞/评论/收藏 — 分享已移至长按菜单)
|
// 右侧互动栏(头像/点赞/评论/收藏 — 分享已移至长按菜单)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 8, bottom: 96,
|
right: 8, bottom: 96,
|
||||||
child: InteractionBar(
|
child: _HidableOverlay(
|
||||||
item: widget.item,
|
hidden: _cleanMode,
|
||||||
onLike: widget.onLike,
|
child: InteractionBar(
|
||||||
onFavorite: widget.onFavorite,
|
item: widget.item,
|
||||||
onComment: widget.onComment,
|
onLike: widget.onLike,
|
||||||
|
onFavorite: widget.onFavorite,
|
||||||
|
onComment: widget.onComment,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 左下信息区 + 系列标题(系列标题在信息区上方,不重叠)
|
// 左下信息区 + 系列标题(系列标题在信息区上方,不重叠)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 12, right: 88, bottom: 24,
|
left: 12, right: 88, bottom: 24,
|
||||||
child: Column(
|
child: _HidableOverlay(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
hidden: _cleanMode,
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
// 系列标题(当前视频属于系列时显示,点击弹出系列列表)
|
mainAxisSize: MainAxisSize.min,
|
||||||
if (_hasSeries)
|
children: [
|
||||||
GestureDetector(
|
// 系列标题(当前视频属于系列时显示,点击弹出系列列表)
|
||||||
onTap: _showSeriesSheet,
|
if (_hasSeries)
|
||||||
child: Container(
|
GestureDetector(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
onTap: _showSeriesSheet,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
color: Colors.black54,
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
borderRadius: BorderRadius.circular(8),
|
decoration: BoxDecoration(
|
||||||
),
|
color: Colors.black54,
|
||||||
child: Row(
|
borderRadius: BorderRadius.circular(8),
|
||||||
mainAxisSize: MainAxisSize.min,
|
),
|
||||||
children: [
|
child: Row(
|
||||||
const Icon(Icons.playlist_play, color: Colors.white, size: 16),
|
mainAxisSize: MainAxisSize.min,
|
||||||
const SizedBox(width: 4),
|
children: [
|
||||||
Text(
|
const Icon(Icons.playlist_play, color: Colors.white, size: 16),
|
||||||
_seriesTitle,
|
const SizedBox(width: 4),
|
||||||
style: const TextStyle(
|
Text(
|
||||||
color: Colors.white,
|
_seriesTitle,
|
||||||
fontSize: 13,
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.w600,
|
color: Colors.white,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 2),
|
||||||
const SizedBox(width: 2),
|
const Icon(Icons.chevron_right, color: Colors.white, size: 14),
|
||||||
const Icon(Icons.chevron_right, color: Colors.white, size: 14),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
// 视频信息(昵称/标题/描述)
|
||||||
// 视频信息(昵称/标题/描述)
|
VideoInfoSection(item: widget.item),
|
||||||
VideoInfoSection(item: widget.item),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -391,3 +470,24 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 可隐藏浮层:淡出 + 禁用点击。隐藏时仍占位但不拦截手势,
|
||||||
|
/// 保证点击/长按能穿透到下层的手势层
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ class VideoInfoSection extends StatefulWidget {
|
|||||||
class _VideoInfoSectionState extends State<VideoInfoSection> {
|
class _VideoInfoSectionState extends State<VideoInfoSection> {
|
||||||
bool _expanded = false;
|
bool _expanded = false;
|
||||||
|
|
||||||
|
static String _fmtSeconds(int seconds) {
|
||||||
|
final h = seconds ~/ 3600;
|
||||||
|
final m = ((seconds % 3600) ~/ 60).toString().padLeft(2, '0');
|
||||||
|
final s = (seconds % 60).toString().padLeft(2, '0');
|
||||||
|
return h > 0 ? '$h:$m:$s' : '$m:$s';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final item = widget.item;
|
final item = widget.item;
|
||||||
@@ -37,7 +44,7 @@ class _VideoInfoSectionState extends State<VideoInfoSection> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// 标题 + #标签(点击标签跳转标签视频页)
|
// 标题 + 时长 + #标签(点击标签跳转标签视频页)
|
||||||
Wrap(
|
Wrap(
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
spacing: 6,
|
spacing: 6,
|
||||||
@@ -52,6 +59,25 @@ class _VideoInfoSectionState extends State<VideoInfoSection> {
|
|||||||
shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
|
shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// 标题后显示视频时长
|
||||||
|
if (item.duration > 0)
|
||||||
|
Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black38,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
border: Border.all(color: Colors.white24, width: 0.5),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_fmtSeconds(item.duration),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 11,
|
||||||
|
shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
for (final tag in item.tags)
|
for (final tag in item.tags)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => context.push('/tags/${Uri.encodeComponent(tag)}'),
|
onTap: () => context.push('/tags/${Uri.encodeComponent(tag)}'),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class VideoPlayerView extends StatefulWidget {
|
|||||||
required this.slotIndex,
|
required this.slotIndex,
|
||||||
required this.coverUrl,
|
required this.coverUrl,
|
||||||
required this.isCurrent,
|
required this.isCurrent,
|
||||||
|
this.durationSeconds = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
final FeedPlayerPool pool;
|
final FeedPlayerPool pool;
|
||||||
@@ -23,6 +24,10 @@ class VideoPlayerView extends StatefulWidget {
|
|||||||
/// 该页是否为当前页(暂停图标只在当前页显示,避免划走后旧页残留大图标)
|
/// 该页是否为当前页(暂停图标只在当前页显示,避免划走后旧页残留大图标)
|
||||||
final bool isCurrent;
|
final bool isCurrent;
|
||||||
|
|
||||||
|
/// DB 里的视频时长(秒):流 duration 异常(如被当直播流报 0)时兜底,
|
||||||
|
/// 保证进度条/拖动可用
|
||||||
|
final int durationSeconds;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<VideoPlayerView> createState() => _VideoPlayerViewState();
|
State<VideoPlayerView> createState() => _VideoPlayerViewState();
|
||||||
}
|
}
|
||||||
@@ -45,9 +50,15 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 有效时长:流上报优先,异常(duration=0)回退 DB 时长
|
||||||
|
int get _totalMs {
|
||||||
|
final streamMs = _duration.inMilliseconds;
|
||||||
|
return streamMs > 0 ? streamMs : widget.durationSeconds * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
double get _value {
|
double get _value {
|
||||||
if (_seeking) return _seekValue;
|
if (_seeking) return _seekValue;
|
||||||
final total = _duration.inMilliseconds;
|
final total = _totalMs;
|
||||||
if (total <= 0) return 0;
|
if (total <= 0) return 0;
|
||||||
final cur = _position.inMilliseconds.clamp(0, total);
|
final cur = _position.inMilliseconds.clamp(0, total);
|
||||||
return cur / total;
|
return cur / total;
|
||||||
@@ -66,7 +77,7 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
|
|||||||
|
|
||||||
void _onSeekEnd(double v) {
|
void _onSeekEnd(double v) {
|
||||||
setState(() => _seeking = false);
|
setState(() => _seeking = false);
|
||||||
final totalMs = _duration.inMilliseconds;
|
final totalMs = _totalMs;
|
||||||
if (totalMs > 0) {
|
if (totalMs > 0) {
|
||||||
_slot.player.seek(Duration(milliseconds: (v * totalMs).round()));
|
_slot.player.seek(Duration(milliseconds: (v * totalMs).round()));
|
||||||
}
|
}
|
||||||
@@ -120,7 +131,7 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 抖音风格细进度条(贴底,常驻)
|
// 抖音风格细进度条(贴底,常驻;拖动时显示目标时间气泡)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0, right: 0, bottom: 0,
|
left: 0, right: 0, bottom: 0,
|
||||||
child: _ThinProgressBar(
|
child: _ThinProgressBar(
|
||||||
@@ -128,6 +139,7 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
|
|||||||
onChanged: _onSeek,
|
onChanged: _onSeek,
|
||||||
onChangeStart: _onSeekStart,
|
onChangeStart: _onSeekStart,
|
||||||
onChangeEnd: _onSeekEnd,
|
onChangeEnd: _onSeekEnd,
|
||||||
|
totalMs: _totalMs,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -141,16 +153,29 @@ class _ThinProgressBar extends StatefulWidget {
|
|||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
required this.onChangeStart,
|
required this.onChangeStart,
|
||||||
required this.onChangeEnd,
|
required this.onChangeEnd,
|
||||||
|
this.totalMs = 0,
|
||||||
});
|
});
|
||||||
final double value;
|
final double value;
|
||||||
final ValueChanged<double> onChanged;
|
final ValueChanged<double> onChanged;
|
||||||
final ValueChanged<double> onChangeStart;
|
final ValueChanged<double> onChangeStart;
|
||||||
final ValueChanged<double> onChangeEnd;
|
final ValueChanged<double> onChangeEnd;
|
||||||
|
|
||||||
|
/// 视频总时长(毫秒):拖动气泡显示目标位置用时/分/秒
|
||||||
|
final int totalMs;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_ThinProgressBar> createState() => _ThinProgressBarState();
|
State<_ThinProgressBar> createState() => _ThinProgressBarState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 拖动气泡时间:>1 小时显示 x时xx分xx秒,否则 xx分xx秒
|
||||||
|
String _seekBubbleText(double value, int totalMs) {
|
||||||
|
final d = Duration(milliseconds: (value * totalMs).round());
|
||||||
|
final h = d.inHours;
|
||||||
|
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||||
|
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||||
|
return h > 0 ? '$h时$m分$s秒' : '$m分$s秒';
|
||||||
|
}
|
||||||
|
|
||||||
class _ThinProgressBarState extends State<_ThinProgressBar> {
|
class _ThinProgressBarState extends State<_ThinProgressBar> {
|
||||||
bool _dragging = false;
|
bool _dragging = false;
|
||||||
double _dragValue = 0;
|
double _dragValue = 0;
|
||||||
@@ -187,15 +212,44 @@ class _ThinProgressBarState extends State<_ThinProgressBar> {
|
|||||||
setState(() => _dragging = false);
|
setState(() => _dragging = false);
|
||||||
widget.onChangeEnd(_dragValue.clamp(0.0, 1.0));
|
widget.onChangeEnd(_dragValue.clamp(0.0, 1.0));
|
||||||
},
|
},
|
||||||
child: Container(
|
child: SizedBox(
|
||||||
height: 32, // 触摸热区 32px(视觉 2px,好点)
|
height: 32, // 触摸热区 32px(视觉 2px,好点)
|
||||||
alignment: Alignment.bottomCenter,
|
child: Stack(
|
||||||
child: CustomPaint(
|
clipBehavior: Clip.none,
|
||||||
painter: _ProgressPainter(
|
alignment: Alignment.bottomCenter,
|
||||||
value: _dragging ? _dragValue : widget.value,
|
children: [
|
||||||
dragging: _dragging,
|
Align(
|
||||||
),
|
alignment: Alignment.bottomCenter,
|
||||||
size: Size(w, _dragging ? 20 : 4),
|
child: CustomPaint(
|
||||||
|
painter: _ProgressPainter(
|
||||||
|
value: _dragging ? _dragValue : widget.value,
|
||||||
|
dragging: _dragging,
|
||||||
|
),
|
||||||
|
size: Size(w, _dragging ? 20 : 4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 拖动中的时间气泡(跟随 thumb,两端自动夹在屏内)
|
||||||
|
if (_dragging && widget.totalMs > 0)
|
||||||
|
Positioned(
|
||||||
|
bottom: 14,
|
||||||
|
left: (_dragValue * w - 38)
|
||||||
|
.clamp(0.0, (w - 76).clamp(0.0, w)),
|
||||||
|
child: Container(
|
||||||
|
width: 76,
|
||||||
|
height: 24,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.82),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_seekBubbleText(_dragValue, widget.totalMs),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white, fontSize: 11.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
@@ -26,16 +28,25 @@ class _HomeShellState extends ConsumerState<HomeShell> {
|
|||||||
|
|
||||||
void _onTabSelected(int i) {
|
void _onTabSelected(int i) {
|
||||||
if (i == _index) return;
|
if (i == _index) return;
|
||||||
// 切到非首页:暂停所有 Feed 策略的播放器池(IndexedStack 保活,否则声音会继续)
|
// 切到非首页:挂起所有 Feed 策略池——suspend 记录断点后 stop 全槽位
|
||||||
|
// (mpv pause 停不住 HLS 预读,只有 stop 能可靠断下载);
|
||||||
|
// 回首页 rebind 重新 open + seek 断点续播,缓存分片零流量(抖音行为)
|
||||||
if (i != 0) {
|
if (i != 0) {
|
||||||
for (final s in _feedStrategies) {
|
for (final s in _feedStrategies) {
|
||||||
ref.read(feedProvider(s).notifier).pool.pauseCurrent();
|
ref.read(feedProvider(s).notifier).pool.suspend();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ref.read(feedTabVisibleProvider.notifier).state = i == 0;
|
||||||
setState(() => _index = i);
|
setState(() => _index = i);
|
||||||
// 切回首页:恢复当前策略播放(FeedScreen 内部 _strategy 是 recommend)
|
// 切回首页:只恢复当前选中策略的池(其余保持挂起)
|
||||||
if (i == 0) {
|
if (i == 0) {
|
||||||
ref.read(feedProvider(FeedStrategy.recommend).notifier).pool.playCurrent();
|
final active = ref.read(activeFeedStrategyProvider);
|
||||||
|
final notifier = ref.read(feedProvider(active).notifier);
|
||||||
|
unawaited(() async {
|
||||||
|
await notifier.pool.resume();
|
||||||
|
// 兜底:挂起期间首屏 bind 可能尚未发生,补一次当前页绑定播放
|
||||||
|
await notifier.rebindCurrent();
|
||||||
|
}());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import 'package:media_kit_video/media_kit_video.dart';
|
|||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
import '../../core/config/test_stream.dart';
|
import '../../core/config/test_stream.dart';
|
||||||
|
import '../../core/media/download_manager.dart';
|
||||||
|
import '../../core/media/local_media_server.dart';
|
||||||
import '../../core/network/api_exception.dart';
|
import '../../core/network/api_exception.dart';
|
||||||
import '../../core/storage/watch_dedup_store.dart';
|
import '../../core/storage/watch_dedup_store.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
@@ -100,7 +102,9 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _open(VideoItem item) {
|
void _open(VideoItem item) {
|
||||||
_player.open(Media(resolvePlayUrl(item)), play: true);
|
// 本地已下载地址(127.0.0.1)原样播放;网络 m3u8 经本地代理走 1G 分片缓存
|
||||||
|
_player.open(Media(LocalMediaServer.I.wrap(resolvePlayUrl(item))),
|
||||||
|
play: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _toggle() {
|
void _toggle() {
|
||||||
@@ -317,7 +321,10 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
|||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
),
|
),
|
||||||
builder: (sheetContext) => Padding(
|
builder: (sheetContext) => Theme(
|
||||||
|
// 弹窗是白底,APP 主题为暗色(默认白字),需显式套用亮色主题
|
||||||
|
data: ThemeData.light(),
|
||||||
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -395,21 +402,64 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
|||||||
_share();
|
_share();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// 下载(已下载/下载中置灰;本地已下载视频不再重复下载)
|
||||||
|
Builder(builder: (_) {
|
||||||
|
final dm = ref.read(downloadManagerProvider);
|
||||||
|
final downloaded =
|
||||||
|
dm.items.any((e) => e.videoId == _currentItem.videoId);
|
||||||
|
final downloading =
|
||||||
|
dm.progress.containsKey(_currentItem.videoId);
|
||||||
|
final isLocal = _currentItem.videoUrl.contains('127.0.0.1');
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: Icon(downloaded || isLocal
|
||||||
|
? Icons.check_circle_outline
|
||||||
|
: Icons.download_outlined),
|
||||||
|
title: Text(downloaded || isLocal
|
||||||
|
? '已下载'
|
||||||
|
: (downloading ? '下载中…' : '下载')),
|
||||||
|
enabled: !downloaded && !downloading && !isLocal,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
showTopToast(context, '开始下载,可在「我的 → 已下载」查看');
|
||||||
|
ref
|
||||||
|
.read(downloadManagerProvider.notifier)
|
||||||
|
.start(_currentItem)
|
||||||
|
.catchError((_) {
|
||||||
|
if (mounted) showTopToast(context, '下载失败,请重试');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 进度条 ----------
|
// ---------- 进度条 ----------
|
||||||
|
|
||||||
|
/// 有效时长:流上报优先,异常(如被当直播 duration=0)回退到 DB 时长
|
||||||
|
Duration get _effectiveDuration => _duration.inMilliseconds > 0
|
||||||
|
? _duration
|
||||||
|
: Duration(seconds: _currentItem.duration);
|
||||||
|
|
||||||
double get _sliderValue {
|
double get _sliderValue {
|
||||||
if (_seeking) return _seekValue;
|
if (_seeking) return _seekValue;
|
||||||
if (_duration.inMilliseconds <= 0) return 0;
|
if (_effectiveDuration.inMilliseconds <= 0) return 0;
|
||||||
return (_position.inMilliseconds / _duration.inMilliseconds)
|
return (_position.inMilliseconds / _effectiveDuration.inMilliseconds)
|
||||||
.clamp(0.0, 1.0);
|
.clamp(0.0, 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String _fmtDuration(Duration d) {
|
||||||
|
final h = d.inHours;
|
||||||
|
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||||
|
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||||
|
return h > 0 ? '$h:$m:$s' : '$m:$s';
|
||||||
|
}
|
||||||
|
|
||||||
void _onSeekStart(double v) {
|
void _onSeekStart(double v) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_seeking = true;
|
_seeking = true;
|
||||||
@@ -423,9 +473,9 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
|||||||
|
|
||||||
void _onSeekEnd(double v) {
|
void _onSeekEnd(double v) {
|
||||||
setState(() => _seeking = false);
|
setState(() => _seeking = false);
|
||||||
if (_duration.inMilliseconds > 0) {
|
if (_effectiveDuration.inMilliseconds > 0) {
|
||||||
final target = Duration(
|
final target = Duration(
|
||||||
milliseconds: (v * _duration.inMilliseconds).round(),
|
milliseconds: (v * _effectiveDuration.inMilliseconds).round(),
|
||||||
);
|
);
|
||||||
_player.seek(target);
|
_player.seek(target);
|
||||||
}
|
}
|
||||||
@@ -591,14 +641,45 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ---------- 进度条:抖音风格一条细线 ----------
|
// ---------- 进度条:抖音风格一条细线 + 时间刻度 ----------
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0, right: 0, bottom: 0,
|
left: 0, right: 0, bottom: 0,
|
||||||
child: _ThinProgressBar(
|
child: Column(
|
||||||
value: _sliderValue,
|
mainAxisSize: MainAxisSize.min,
|
||||||
onChanged: _onSeek,
|
children: [
|
||||||
onChangeStart: _onSeekStart,
|
// 时间刻度:左当前(拖动时为目标位置),右总时长
|
||||||
onChangeEnd: _onSeekEnd,
|
Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_fmtDuration(_seeking
|
||||||
|
? Duration(
|
||||||
|
milliseconds: (_seekValue *
|
||||||
|
_effectiveDuration.inMilliseconds)
|
||||||
|
.round())
|
||||||
|
: _position),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70, fontSize: 11),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_fmtDuration(_effectiveDuration),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70, fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ThinProgressBar(
|
||||||
|
value: _sliderValue,
|
||||||
|
onChanged: _onSeek,
|
||||||
|
onChangeStart: _onSeekStart,
|
||||||
|
onChangeEnd: _onSeekEnd,
|
||||||
|
totalMs: _effectiveDuration.inMilliseconds,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -648,7 +729,9 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
|||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
),
|
),
|
||||||
builder: (_) => DraggableScrollableSheet(
|
builder: (_) => Theme(
|
||||||
|
data: ThemeData.light(),
|
||||||
|
child: DraggableScrollableSheet(
|
||||||
initialChildSize: 0.6,
|
initialChildSize: 0.6,
|
||||||
minChildSize: 0.4,
|
minChildSize: 0.4,
|
||||||
maxChildSize: 0.9,
|
maxChildSize: 0.9,
|
||||||
@@ -744,6 +827,7 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -757,6 +841,7 @@ class _ThinProgressBar extends StatefulWidget {
|
|||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
required this.onChangeStart,
|
required this.onChangeStart,
|
||||||
required this.onChangeEnd,
|
required this.onChangeEnd,
|
||||||
|
this.totalMs = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
final double value;
|
final double value;
|
||||||
@@ -764,10 +849,22 @@ class _ThinProgressBar extends StatefulWidget {
|
|||||||
final ValueChanged<double> onChangeStart;
|
final ValueChanged<double> onChangeStart;
|
||||||
final ValueChanged<double> onChangeEnd;
|
final ValueChanged<double> onChangeEnd;
|
||||||
|
|
||||||
|
/// 视频总时长(毫秒):拖动时气泡显示目标位置用时/分/秒
|
||||||
|
final int totalMs;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_ThinProgressBar> createState() => _ThinProgressBarState();
|
State<_ThinProgressBar> createState() => _ThinProgressBarState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 拖动气泡时间:>1 小时显示 x时xx分xx秒,否则 xx分xx秒
|
||||||
|
String _seekBubbleText(double value, int totalMs) {
|
||||||
|
final d = Duration(milliseconds: (value * totalMs).round());
|
||||||
|
final h = d.inHours;
|
||||||
|
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||||
|
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||||
|
return h > 0 ? '$h时$m分$s秒' : '$m分$s秒';
|
||||||
|
}
|
||||||
|
|
||||||
class _ThinProgressBarState extends State<_ThinProgressBar> {
|
class _ThinProgressBarState extends State<_ThinProgressBar> {
|
||||||
bool _dragging = false;
|
bool _dragging = false;
|
||||||
double _dragValue = 0;
|
double _dragValue = 0;
|
||||||
@@ -805,19 +902,48 @@ class _ThinProgressBarState extends State<_ThinProgressBar> {
|
|||||||
_start(d.localPosition, w);
|
_start(d.localPosition, w);
|
||||||
},
|
},
|
||||||
onTapUp: (_) => _end(w),
|
onTapUp: (_) => _end(w),
|
||||||
child: Container(
|
child: SizedBox(
|
||||||
height: 32, // 触摸热区 32px(比视觉线条大,提升交互)
|
height: 32, // 触摸热区 32px(比视觉线条大,提升交互)
|
||||||
alignment: Alignment.bottomCenter,
|
child: Stack(
|
||||||
child: AnimatedContainer(
|
clipBehavior: Clip.none,
|
||||||
duration: const Duration(milliseconds: 150),
|
alignment: Alignment.bottomCenter,
|
||||||
height: _dragging ? 4 : 2,
|
children: [
|
||||||
width: double.infinity,
|
Align(
|
||||||
child: CustomPaint(
|
alignment: Alignment.bottomCenter,
|
||||||
painter: _ProgressPainter(
|
child: AnimatedContainer(
|
||||||
value: _dragging ? _dragValue : widget.value,
|
duration: const Duration(milliseconds: 150),
|
||||||
dragging: _dragging,
|
height: _dragging ? 4 : 2,
|
||||||
|
width: double.infinity,
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _ProgressPainter(
|
||||||
|
value: _dragging ? _dragValue : widget.value,
|
||||||
|
dragging: _dragging,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
// 拖动中的时间气泡(跟随 thumb,两端自动夹在屏内)
|
||||||
|
if (_dragging && widget.totalMs > 0)
|
||||||
|
Positioned(
|
||||||
|
bottom: 14,
|
||||||
|
left: (_dragValue * w - 38)
|
||||||
|
.clamp(0.0, (w - 76).clamp(0.0, w)),
|
||||||
|
child: Container(
|
||||||
|
width: 76,
|
||||||
|
height: 24,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.82),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_seekBubbleText(_dragValue, widget.totalMs),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white, fontSize: 11.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -142,6 +142,11 @@ class _MineScreenState extends ConsumerState<MineScreen> {
|
|||||||
label: '观看历史',
|
label: '观看历史',
|
||||||
onTap: () => context.push('/mine/history'),
|
onTap: () => context.push('/mine/history'),
|
||||||
),
|
),
|
||||||
|
_Entry(
|
||||||
|
icon: Icons.download_outlined,
|
||||||
|
label: '已下载',
|
||||||
|
onTap: () => context.push('/mine/downloads'),
|
||||||
|
),
|
||||||
_Entry(
|
_Entry(
|
||||||
icon: Icons.account_balance_wallet_outlined,
|
icon: Icons.account_balance_wallet_outlined,
|
||||||
label: '钱包',
|
label: '钱包',
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
// 版本信息
|
// 版本信息
|
||||||
const ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.info_outline, color: AppColors.textSecondary),
|
leading: const Icon(Icons.info_outline, color: AppColors.textSecondary),
|
||||||
title: Text('当前版本'),
|
title: const Text('当前版本'),
|
||||||
trailing: Text(
|
trailing: Text(
|
||||||
'v${AppConfig.appVersion} (${AppConfig.appVersionCode})',
|
'v${AppConfig.appVersion} (${AppConfig.appVersionCode})',
|
||||||
style: TextStyle(color: AppColors.textWeak, fontSize: 13),
|
style: TextStyle(color: AppColors.textWeak, fontSize: 13),
|
||||||
|
|||||||
-1082
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -2,7 +2,7 @@ name: video_app
|
|||||||
description: "短视频应用(Flutter,iOS/Android 双端)"
|
description: "短视频应用(Flutter,iOS/Android 双端)"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.2.0+12
|
version: 1.3.15+31
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
@@ -32,6 +32,9 @@ dependencies:
|
|||||||
|
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
|
|
||||||
|
# 应用包信息(运行时读取真实 versionName/versionCode,修复更新弹窗误判)
|
||||||
|
package_info_plus: ^8.3.0
|
||||||
|
|
||||||
# 状态管理(02-技术文档 2.1)
|
# 状态管理(02-技术文档 2.1)
|
||||||
flutter_riverpod: ^2.6.1
|
flutter_riverpod: ^2.6.1
|
||||||
# 网络
|
# 网络
|
||||||
@@ -56,6 +59,11 @@ dependencies:
|
|||||||
image_picker: ^1.1.2
|
image_picker: ^1.1.2
|
||||||
# 外部浏览器/商店跳转(强制更新下载页)
|
# 外部浏览器/商店跳转(强制更新下载页)
|
||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
|
# 本地媒体服务:HLS 分片缓存(1G LRU)+ 已下载视频离线播放
|
||||||
|
shelf: ^1.4.2
|
||||||
|
shelf_router: ^1.1.4
|
||||||
|
path_provider: ^2.1.5
|
||||||
|
path: ^1.9.0
|
||||||
tobias: ^5.3.4
|
tobias: ^5.3.4
|
||||||
fluwx: ^5.7.7
|
fluwx: ^5.7.7
|
||||||
|
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ VERSION_CODE="${PUBSPEC_VERSION##*+}" # 2
|
|||||||
echo " pubspec: $VERSION_NAME versionCode=$VERSION_CODE"
|
echo " pubspec: $VERSION_NAME versionCode=$VERSION_CODE"
|
||||||
|
|
||||||
# DB 当前 latest_version_code(用于参考)
|
# DB 当前 latest_version_code(用于参考)
|
||||||
DB_CURRENT_CODE=$(mysql -u "$DB_USER" -p"$DB_PASS" -P "$DB_PORT" "$DB_NAME" -Nse \
|
DB_CURRENT_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, '$.latest_version_code')) \
|
"SELECT JSON_UNQUOTE(JSON_EXTRACT(config_value, '$.latest_version_code')) \
|
||||||
FROM system_configs WHERE config_key='app.version.android';" 2>/dev/null || echo "0")
|
FROM system_configs WHERE config_key='app.version.android';" 2>/dev/null || echo "0")
|
||||||
DB_CURRENT_VER=$(mysql -u "$DB_USER" -p"$DB_PASS" -P "$DB_PORT" "$DB_NAME" -Nse \
|
DB_CURRENT_VER=$(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, '$.latest_version')) \
|
"SELECT JSON_UNQUOTE(JSON_EXTRACT(config_value, '$.latest_version')) \
|
||||||
FROM system_configs WHERE config_key='app.version.android';" 2>/dev/null || echo "unknown")
|
FROM system_configs WHERE config_key='app.version.android';" 2>/dev/null || echo "unknown")
|
||||||
echo " DB 当前 latest: $DB_CURRENT_VER code=$DB_CURRENT_CODE"
|
echo " DB 当前 latest: $DB_CURRENT_VER code=$DB_CURRENT_CODE"
|
||||||
@@ -65,6 +65,10 @@ cd "$APP_DIR"
|
|||||||
flutter build apk --release 2>&1 | tail -5
|
flutter build apk --release 2>&1 | tail -5
|
||||||
echo
|
echo
|
||||||
|
|
||||||
|
# 停掉 Gradle Daemon(编译残留常驻 ~3GB 内存,下次编译会自动重启)
|
||||||
|
echo "▶ 清理 Gradle Daemon..."
|
||||||
|
(cd "$APP_DIR/android" && ./gradlew --stop >/dev/null 2>&1) || true
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# 4. 部署 APK
|
# 4. 部署 APK
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@@ -84,15 +88,21 @@ if [ -n "${1:-}" ]; then
|
|||||||
NEW_CODE="$1"
|
NEW_CODE="$1"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
UPDATE_JSON="{\"latest_version\":\"$VERSION_NAME\",\"latest_version_code\":$NEW_CODE,\"min_version_code\":1,\"update_url\":\"https://h5.256kb.top/downloads/video-app.apk\",\"notice\":\"\"}"
|
# 读出当前 min_version_code,发版时保留不重置
|
||||||
|
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\":$NEW_CODE,\"min_version_code\":$MIN_CODE,\"update_url\":\"https://h5.256kb.top/downloads/video-app.apk\",\"notice\":\"\"}"
|
||||||
|
|
||||||
echo "▶ 同步 DB app.version.android → $UPDATE_JSON"
|
echo "▶ 同步 DB app.version.android → $UPDATE_JSON"
|
||||||
mysql -u "$DB_USER" -p"$DB_PASS" -P "$DB_PORT" "$DB_NAME" -e "
|
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)
|
INSERT INTO system_configs (config_key, config_value, description)
|
||||||
VALUES ('app.version.android', '$UPDATE_JSON', 'Android 版本检查配置')
|
VALUES ('app.version.android', '$UPDATE_JSON', 'Android 版本检查配置')
|
||||||
ON DUPLICATE KEY UPDATE config_value=VALUES(config_value), updated_at=NOW();
|
ON DUPLICATE KEY UPDATE config_value=VALUES(config_value), updated_at=NOW();
|
||||||
" 2>/dev/null
|
" 2>/dev/null
|
||||||
redis-cli DEL config:system >/dev/null 2>&1 || true
|
docker exec my-redis redis-cli DEL config:system >/dev/null 2>&1 || true
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "✅ 发版完成"
|
echo "✅ 发版完成"
|
||||||
|
|||||||
+108
-18
@@ -56,14 +56,13 @@ video/ # 项目根目录(当前目录)
|
|||||||
- 游客不可点赞、收藏、评论、上传
|
- 游客不可点赞、收藏、评论、上传
|
||||||
|
|
||||||
#### 2.1.2 注册
|
#### 2.1.2 注册
|
||||||
- **优先手机号注册**:手机号 + 短信验证码 + 密码
|
- 注册页**手机号 / 邮箱 Tab 切换**:手机号 + 短信验证码 + 密码,或 邮箱 + 邮箱验证码 + 密码
|
||||||
- 备选邮箱注册:邮箱 + 邮箱验证码 + 密码
|
- 邮箱验证码由服务端**真实 SMTP 发送**(`POST /auth/email/send`),验证码写 Redis(`email:code:{email}`,600s),与短信验证码同一套校验机制
|
||||||
- 注册即登录,返回 Token
|
- 注册即登录,返回 Token
|
||||||
- 约束:手机号/邮箱全局唯一;密码 ≥ 8 位,含字母和数字
|
- 约束:手机号/邮箱全局唯一;密码 ≥ 8 位,含字母和数字
|
||||||
|
|
||||||
#### 2.1.3 登录
|
#### 2.1.3 登录
|
||||||
- 手机号 + 密码,或 手机号 + 短信验证码
|
- 支持**手机号或邮箱**作为账号:手机号 + 密码 / 手机号 + 短信验证码 / 邮箱 + 密码
|
||||||
- 邮箱 + 密码
|
|
||||||
- 登录成功后,游客时期的设备 ID 与账号可关联(观看记录合并,可选)
|
- 登录成功后,游客时期的设备 ID 与账号可关联(观看记录合并,可选)
|
||||||
|
|
||||||
#### 2.1.4 资料管理
|
#### 2.1.4 资料管理
|
||||||
@@ -189,7 +188,10 @@ App 调用「观看上报」接口
|
|||||||
|
|
||||||
#### 2.5.4 视频播放
|
#### 2.5.4 视频播放
|
||||||
- m3u8(HLS)流媒体播放,App 竖屏全屏沉浸式播放
|
- m3u8(HLS)流媒体播放,App 竖屏全屏沉浸式播放
|
||||||
- 上滑/下滑切换视频,预加载下一个视频
|
- 上滑/下滑切换视频;**3 槽播放器池**预加载:当前页播放 + 下一页预缓冲,划走即 stop(断音 + 中断缓存下载)
|
||||||
|
- 切到"我的"等页面时播放器池**挂起(suspend)静音断流**,回到 Feed 且首页 Tab 可见才恢复(resume),修复"切页仍出声"问题
|
||||||
|
- 网络 m3u8 播放地址改写为 **App 内嵌本地媒体代理**(127.0.0.1 HTTP 服务):m3u8 每次回源保证签名时效,ts 分片落盘缓存(1GB LRU),支持拖动进度条(Range/206);ts 流量直连源站 CDN,不经过业务服务器
|
||||||
|
- 进度条拖动时显示**时间气泡**(x时xx分xx秒),标题后带**时长角标**
|
||||||
- 记录播放量(进入播放即 +1,Redis 计数异步落库)
|
- 记录播放量(进入播放即 +1,Redis 计数异步落库)
|
||||||
|
|
||||||
#### 2.5.5 视频标签(Tag)
|
#### 2.5.5 视频标签(Tag)
|
||||||
@@ -197,15 +199,20 @@ App 调用「观看上报」接口
|
|||||||
- 播放页在**标题后展示标签**(#标签 样式)
|
- 播放页在**标题后展示标签**(#标签 样式)
|
||||||
- 点击标签进入"标签视频列表页":展示该标签下全部已发布视频(支持 热度/最新 排序)
|
- 点击标签进入"标签视频列表页":展示该标签下全部已发布视频(支持 热度/最新 排序)
|
||||||
- 标签是推荐策略中个性化加权的主要特征
|
- 标签是推荐策略中个性化加权的主要特征
|
||||||
|
- 存量视频标签由**离线脚本批量生成**(server/scripts/build_tags_from_titles.py:jieba TF-IDF topK=6 + 番号/书名号提取 + 噪音过滤,幂等可重跑;已生成 2.2 万+ 标签、9.9 万+ 关联),后台提供"标签管理"页维护
|
||||||
|
|
||||||
#### 2.5.6 视频推荐策略
|
#### 2.5.6 视频推荐策略
|
||||||
Feed 支持三种策略,客户端通过接口参数切换:
|
Feed 支持五种策略,客户端通过接口参数切换(App 顶部 Tab:推荐 / 最新 / 热度 / 口碑 / 热播,各策略独立保活分页状态):
|
||||||
|
|
||||||
| 策略 | 说明 |
|
| 策略 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| recommend(默认) | 个性化推荐 |
|
| recommend(默认) | 个性化推荐 |
|
||||||
| latest | 最新发布倒序 |
|
| latest | 最新发布倒序 |
|
||||||
| hot | 热度倒序(播放/点赞/评论加权) |
|
| hot | 热度倒序:热度分 = (播放×1 + 点赞×5 + 评论×10) / (1 + 发布天数) |
|
||||||
|
| liked(口碑) | 按点赞量倒序 |
|
||||||
|
| viewed(热播) | 按播放量倒序 |
|
||||||
|
|
||||||
|
> liked / viewed 为纯数值排序,游标采用(值, id)双键避免同值翻页丢失。
|
||||||
|
|
||||||
**个性化推荐(recommend)规则:**
|
**个性化推荐(recommend)规则:**
|
||||||
1. 候选池:全部"已发布"视频
|
1. 候选池:全部"已发布"视频
|
||||||
@@ -224,6 +231,13 @@ Feed 支持三种策略,客户端通过接口参数切换:
|
|||||||
- 搜索历史:App 端本地存储最近 10 条,不上报服务端
|
- 搜索历史:App 端本地存储最近 10 条,不上报服务端
|
||||||
- 搜索词先过敏感词过滤,命中直接返回空结果
|
- 搜索词先过敏感词过滤,命中直接返回空结果
|
||||||
|
|
||||||
|
#### 2.5.9 视频下载(离线缓存)
|
||||||
|
- **入口**:Feed 页 / 播放页长按弹出菜单 →「下载」(已下载/下载中置灰)
|
||||||
|
- HLS 视频:4 并发下载 ts 分片与 AES-128 密钥,改写为本地 index.m3u8;mp4 整文件直接下载
|
||||||
|
- 下载过程展示进度,支持取消;失败自动清理半成品文件
|
||||||
|
- 下载列表在「我的 → 已下载」;离线播放走本地媒体代理,支持拖动进度条
|
||||||
|
- 已下载视频离线观看**不重复扣积分**(线上播放时已按正常规则计费)
|
||||||
|
|
||||||
### 2.6 互动模块
|
### 2.6 互动模块
|
||||||
|
|
||||||
| 功能 | 规则 |
|
| 功能 | 规则 |
|
||||||
@@ -330,7 +344,7 @@ Feed 支持三种策略,客户端通过接口参数切换:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 仪表盘 | 今日/累计:DAU、新增注册、充值金额、积分发放与消耗、播放量、待审核数 |
|
| 仪表盘 | 今日/累计:DAU、新增注册、充值金额、积分发放与消耗、播放量、待审核数 |
|
||||||
| 视频审核(人工) | 待审核列表(在线预览 m3u8)、通过、拒绝(必填原因)、已发布视频下架 |
|
| 视频审核(人工) | 待审核列表(在线预览 m3u8)、通过、拒绝(必填原因)、已发布视频下架 |
|
||||||
| 用户管理 | 搜索(ID/手机号/邮箱)、用户详情(钱包/会员/作品/流水)、封禁与解封 |
|
| 用户管理 | 搜索(ID/手机号/邮箱)、用户详情(钱包/会员/作品/流水)、封禁与解封、**修改密码**(重置后 `auth:epoch` 递增,该用户全端 Token 立即失效) |
|
||||||
| 积分管理 | 用户积分流水查询、手动加/减积分(原因必填 + 二次确认,写流水 biz_type=6) |
|
| 积分管理 | 用户积分流水查询、手动加/减积分(原因必填 + 二次确认,写流水 biz_type=6) |
|
||||||
| 订单管理 | 充值订单查询(状态/渠道/时间筛选)、异常订单人工补单(核实已收款后手动入账) |
|
| 订单管理 | 充值订单查询(状态/渠道/时间筛选)、异常订单人工补单(核实已收款后手动入账) |
|
||||||
| 提现审核 | 提现申请列表、审核通过(线下打款后确认)/ 拒绝(自动退回资产) |
|
| 提现审核 | 提现申请列表、审核通过(线下打款后确认)/ 拒绝(自动退回资产) |
|
||||||
@@ -338,6 +352,8 @@ Feed 支持三种策略,客户端通过接口参数切换:
|
|||||||
| 平台配置 | 注册赠送体验会员开关与天数等运营参数(system_configs 键值对,实时生效) |
|
| 平台配置 | 注册赠送体验会员开关与天数等运营参数(system_configs 键值对,实时生效) |
|
||||||
| 充值套餐管理 | 档位与赠送积分增删改、上下架、排序,实时生效 |
|
| 充值套餐管理 | 档位与赠送积分增删改、上下架、排序,实时生效 |
|
||||||
| 活动运营 | 赠送体验会员/积分(支持单个或批量用户) |
|
| 活动运营 | 赠送体验会员/积分(支持单个或批量用户) |
|
||||||
|
| 标签管理 | 标签列表查询(关联视频数)、重命名、删除(联动清理 video_tags) |
|
||||||
|
| 采集源管理 | 采集源 CRUD(分类名 + 列表 URL 模板)、批量添加、单源/全部同步(后台异步采集外部单视频入库) |
|
||||||
| 管理员管理 | 管理员账号增删、角色分配(超级管理员/审核员/运营)、操作日志查询 |
|
| 管理员管理 | 管理员账号增删、角色分配(超级管理员/审核员/运营)、操作日志查询 |
|
||||||
|
|
||||||
角色权限:超级管理员(全部)、审核员(仅视频审核)、运营(套餐/活动/看板)。
|
角色权限:超级管理员(全部)、审核员(仅视频审核)、运营(套餐/活动/看板)。
|
||||||
@@ -369,6 +385,8 @@ Feed 支持三种策略,客户端通过接口参数切换:
|
|||||||
|
|
||||||
### 3.1 表清单
|
### 3.1 表清单
|
||||||
|
|
||||||
|
现共 **30 张业务表**。Alembic 迁移链:`init_27_tables` → `add_collection_records` → `add_collection_sources`;线上数据量约 1.8 万视频。
|
||||||
|
|
||||||
| 表 | 说明 |
|
| 表 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| users | 用户表 |
|
| users | 用户表 |
|
||||||
@@ -397,6 +415,9 @@ Feed 支持三种策略,客户端通过接口参数切换:
|
|||||||
| notifications | 消息通知表 |
|
| notifications | 消息通知表 |
|
||||||
| reports | 举报表 |
|
| reports | 举报表 |
|
||||||
| sensitive_words | 敏感词表 |
|
| sensitive_words | 敏感词表 |
|
||||||
|
| series | 系列(合集)表:多条视频组织成集数列表 |
|
||||||
|
| collection_sources | 采集源配置表(分类名 + 列表 URL 模板) |
|
||||||
|
| collection_records | 采集记录表(外部内容源 → 本站系列/视频映射,幂等导入) |
|
||||||
|
|
||||||
### 3.2 用户域
|
### 3.2 用户域
|
||||||
|
|
||||||
@@ -792,6 +813,47 @@ Feed 支持三种策略,客户端通过接口参数切换:
|
|||||||
| word | VARCHAR(50) | 敏感词,**唯一索引** |
|
| word | VARCHAR(50) | 敏感词,**唯一索引** |
|
||||||
| created_at | DATETIME | |
|
| created_at | DATETIME | |
|
||||||
|
|
||||||
|
### 3.10 采集域
|
||||||
|
|
||||||
|
#### series 系列(合集)表
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| id | BIGINT PK AUTO_INCREMENT | |
|
||||||
|
| title | VARCHAR(100) | 系列标题 |
|
||||||
|
| cover_url | VARCHAR(500) | 封面 |
|
||||||
|
| description | VARCHAR(500) | 简介 |
|
||||||
|
| author_id | BIGINT NULL | 作者 |
|
||||||
|
| category_id | INT | 分类 |
|
||||||
|
| total_episodes | INT DEFAULT 0 | 总集数 |
|
||||||
|
| view_count | BIGINT DEFAULT 0 | 播放量 |
|
||||||
|
| status | SMALLINT DEFAULT 1 | 1正常 0隐藏 |
|
||||||
|
| created_at / updated_at | DATETIME | |
|
||||||
|
|
||||||
|
索引:`idx_series_cat_status(category_id, status)`、`idx_series_status_created(status, created_at)`;videos 表以 `series_id` 关联所属系列
|
||||||
|
|
||||||
|
#### collection_sources 采集源配置表
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| id | BIGINT PK AUTO_INCREMENT | |
|
||||||
|
| name | VARCHAR(50) | 分类名(添加时自动建/找本站分类) |
|
||||||
|
| list_url | VARCHAR(500) | 列表 URL 模板,`{{page}}` 为页码占位 |
|
||||||
|
| category_id | INT UNSIGNED | 本站分类 id |
|
||||||
|
| status | SMALLINT DEFAULT 1 | 1启用 0停用 |
|
||||||
|
| last_sync_at | DATETIME NULL | 最近同步时间 |
|
||||||
|
| created_at / updated_at | DATETIME | |
|
||||||
|
|
||||||
|
#### collection_records 采集记录表
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| id | BIGINT PK AUTO_INCREMENT | |
|
||||||
|
| source_key | VARCHAR(50) | 内容源标识 |
|
||||||
|
| source_id | BIGINT | 源站内容 id |
|
||||||
|
| series_id | BIGINT NULL | 本站系列 id |
|
||||||
|
| video_id | BIGINT NULL | 本站视频 id(单视频类) |
|
||||||
|
| created_at | DATETIME | |
|
||||||
|
|
||||||
|
约束:唯一索引 `uk_source(source_key, source_id)` 保证幂等导入(重复同步不产生重复视频);索引 `idx_record_series(series_id)`、`idx_record_video(video_id)`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 接口设计(概览)
|
## 4. 接口设计(概览)
|
||||||
@@ -842,16 +904,19 @@ Feed 支持三种策略,客户端通过接口参数切换:
|
|||||||
| /mine/history | 观看历史 | |
|
| /mine/history | 观看历史 | |
|
||||||
| /upload | 上传视频 | 选片→剪辑信息→发布 |
|
| /upload | 上传视频 | 选片→剪辑信息→发布 |
|
||||||
| /search | 搜索 | |
|
| /search | 搜索 | |
|
||||||
|
| /category | 分类页 | 按分类浏览视频 |
|
||||||
| /tags/:name | 标签视频页 | 某标签下的视频列表 |
|
| /tags/:name | 标签视频页 | 某标签下的视频列表 |
|
||||||
|
| /mine/downloads | 已下载 | 离线视频列表(进度/删除/播放) |
|
||||||
|
| /notifications | 消息通知 | 通知列表、未读标记 |
|
||||||
| /settings | 设置 | 退出登录等 |
|
| /settings | 设置 | 退出登录等 |
|
||||||
|
|
||||||
### 5.2 核心页面布局
|
### 5.2 核心页面布局
|
||||||
|
|
||||||
#### 5.2.1 首页(视频 Feed)
|
#### 5.2.1 首页(视频 Feed)
|
||||||
- 全屏竖向 PageView,每个页面一个视频播放器
|
- 全屏竖向 PageView,每个页面一个视频播放器(3 槽播放器池:当前页播放 + 下一页预缓冲,划走即停)
|
||||||
- 顶部:Feed 策略 Tab(推荐 / 最新 / 热度)+ 搜索图标
|
- 顶部:Feed 策略 Tab(推荐 / 最新 / 热度 / 口碑 / 热播)+ 搜索图标;各策略分页状态独立保活,切 Tab 不丢进度
|
||||||
- 右侧互动栏(自上而下):作者头像(+关注预留)、点赞(红心+计数)、评论(气泡+计数)、收藏(星标+计数)、分享
|
- 右侧互动栏(自上而下):作者头像(+关注预留)、点赞(红心+计数)、评论(气泡+计数)、收藏(星标+计数)、分享
|
||||||
- 左下:作者昵称、标题(标题后展示 #标签,点击跳转标签视频页)、描述(展开/收起)
|
- 左下:作者昵称、标题(标题后展示 #标签,点击跳转标签视频页;并带时长角标)、描述(展开/收起)
|
||||||
- 底部(游客/非会员场景):今日免费剩余次数提示条(剩余 ≤ 3 次时显示)
|
- 底部(游客/非会员场景):今日免费剩余次数提示条(剩余 ≤ 3 次时显示)
|
||||||
|
|
||||||
#### 5.2.2 评论页(底部弹层)
|
#### 5.2.2 评论页(底部弹层)
|
||||||
@@ -885,6 +950,8 @@ Feed 支持三种策略,客户端通过接口参数切换:
|
|||||||
| 切换视频 | 垂直滑动,松手吸附;滑出即暂停,滑入自动播放并**上报观看计费** |
|
| 切换视频 | 垂直滑动,松手吸附;滑出即暂停,滑入自动播放并**上报观看计费** |
|
||||||
| 单击屏幕 | 暂停 / 继续播放(暂停时显示播放图标) |
|
| 单击屏幕 | 暂停 / 继续播放(暂停时显示播放图标) |
|
||||||
| 双击屏幕 | 点赞动画(大爱心飘出) |
|
| 双击屏幕 | 点赞动画(大爱心飘出) |
|
||||||
|
| 长按屏幕 | 弹出操作菜单:下载视频 / 倍速 / 分享(已下载或下载中置灰) |
|
||||||
|
| 拖动进度条 | 跟随 thumb 显示时间气泡(x时xx分xx秒,两端自动夹在屏内) |
|
||||||
| 游客超限 | 播放第 11 个视频时弹出登录引导弹窗("注册解锁更多视频,今日已看 10/10") |
|
| 游客超限 | 播放第 11 个视频时弹出登录引导弹窗("注册解锁更多视频,今日已看 10/10") |
|
||||||
| 用户积分不足 | 弹窗"积分不足":去充值 / 开通会员 两个按钮 |
|
| 用户积分不足 | 弹窗"积分不足":去充值 / 开通会员 两个按钮 |
|
||||||
| 扣费提示 | 每次扣积分播放时顶部 Toast"本次消耗 1 积分" |
|
| 扣费提示 | 每次扣积分播放时顶部 Toast"本次消耗 1 积分" |
|
||||||
@@ -895,7 +962,7 @@ Feed 支持三种策略,客户端通过接口参数切换:
|
|||||||
|
|
||||||
| 项 | 规范 |
|
| 项 | 规范 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 主题 | 深色为主(#121212 背景),保证视频沉浸感;浅色可选(二期) |
|
| 主题 | 深色为主(#121212 背景),保证视频沉浸感;浅色可选(二期);**弹窗/底部菜单统一白底亮色**(局部包 `ThemeData.light()`,避免深色背景下文字看不清) |
|
||||||
| 主色 | #FE2C55(红),辅助色 #25F4EE(青) |
|
| 主色 | #FE2C55(红),辅助色 #25F4EE(青) |
|
||||||
| 会员色 | 金色渐变 #F7D794 → #D4A017 |
|
| 会员色 | 金色渐变 #F7D794 → #D4A017 |
|
||||||
| 文字 | 主 #FFFFFF,次 #B0B0B0,弱 #6E6E6E |
|
| 文字 | 主 #FFFFFF,次 #B0B0B0,弱 #6E6E6E |
|
||||||
@@ -924,6 +991,25 @@ App 不内置固定 API 域名,启动时按以下流程动态获取:
|
|||||||
- App 启动及回到前台时调用 `GET /app/version`:返回最新版本号、是否强制更新、下载地址、维护公告
|
- App 启动及回到前台时调用 `GET /app/version`:返回最新版本号、是否强制更新、下载地址、维护公告
|
||||||
- 强制更新:弹窗阻断使用并跳转商店/下载页;非强制:可跳过的提示弹窗;维护公告:全 App 只读提示
|
- 强制更新:弹窗阻断使用并跳转商店/下载页;非强制:可跳过的提示弹窗;维护公告:全 App 只读提示
|
||||||
|
|
||||||
|
### 5.8 工程结构(与当前代码对齐)
|
||||||
|
|
||||||
|
```
|
||||||
|
app/lib/
|
||||||
|
├── screens/ # 页面:splash / home(HomeShell+IndexedStack) / feed / search / category
|
||||||
|
│ # / player / membership / payment / wallet / assets / upload
|
||||||
|
│ # / profile / auth / settings / notification / downloads
|
||||||
|
├── providers/ # Riverpod 状态(12 个):auth / feed / comment / wallet / member
|
||||||
|
│ # / payment / profile / search / upload / asset / notification
|
||||||
|
│ # / tag_videos
|
||||||
|
└── core/ # 子系统:config(环境配置)/ media(local_media_server 本地代理、
|
||||||
|
# download_manager 下载)/ network(api_client、域名发现)/
|
||||||
|
# player(feed_player_pool 播放器池)/ storage(token、本地存储)/
|
||||||
|
# theme / utils
|
||||||
|
```
|
||||||
|
|
||||||
|
- Feed 策略状态按策略保活:`feedProviderFamily(strategy)` 每个策略一个独立 FeedNotifier;`activeFeedStrategyProvider` + `feedTabVisibleProvider` 配合 HomeShell(IndexedStack)在切页/回前台时挂起与恢复正确的播放器池
|
||||||
|
- 401 兜底:无 refresh token 或刷新被拒时清登录态并跳登录页,防止"僵尸登录态"
|
||||||
|
|
||||||
## 6. 后端设计
|
## 6. 后端设计
|
||||||
|
|
||||||
### 6.1 框架推荐:**FastAPI**
|
### 6.1 框架推荐:**FastAPI**
|
||||||
@@ -945,17 +1031,21 @@ App 不内置固定 API 域名,启动时按以下流程动态获取:
|
|||||||
```
|
```
|
||||||
server/
|
server/
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── main.py # 应用入口、中间件、路由注册
|
│ ├── main.py # 应用入口、中间件、路由注册、lifespan(settle 周期任务启动)
|
||||||
│ ├── core/ # 配置、日志、异常、安全(JWT/密码)
|
│ ├── core/ # 配置、日志、异常、安全(JWT/密码)
|
||||||
│ ├── api/v1/ # App 路由层:auth/users/videos/interact/vip/pay
|
│ ├── api/v1/ # 路由层(11 个文件):admin / auth / common / incentives
|
||||||
│ ├── api/admin/ # 管理后台路由(独立管理员鉴权)
|
│ │ # / interactions / invite / media / membership / payments
|
||||||
|
│ │ # / users / videos(管理后台同文件 /admin 前缀、独立管理员鉴权)
|
||||||
│ ├── schemas/ # Pydantic 请求/响应模型
|
│ ├── schemas/ # Pydantic 请求/响应模型
|
||||||
│ ├── models/ # SQLAlchemy ORM 模型
|
│ ├── models/ # SQLAlchemy ORM 模型(30 张表)
|
||||||
│ ├── services/ # 业务逻辑:计费、积分、会员、视频、互动
|
│ ├── services/ # 业务逻辑(17 个):user/auth/wallet/video/interaction/membership
|
||||||
|
│ │ # /payment/incentive/invite/admin/common/config/sensitive
|
||||||
|
│ │ # /storage/transcode/settle/collection
|
||||||
│ ├── payment/ # 支付适配层:alipay / wechat / usdt
|
│ ├── payment/ # 支付适配层:alipay / wechat / usdt
|
||||||
│ ├── tasks/ # Celery 任务:转码、播放量落库、订单关单
|
│ ├── tasks/ # Celery 任务:转码、播放量落库、订单关单
|
||||||
│ └── utils/ # 短信、邮件、OSS、设备指纹
|
│ └── utils/ # 短信、邮件、OSS、设备指纹
|
||||||
├── alembic/ # 数据库迁移
|
├── scripts/ # 离线脚本:build_tags_from_titles.py(标签生成)、采集脚本
|
||||||
|
├── alembic/ # 数据库迁移(init_27_tables → collection_records → collection_sources)
|
||||||
├── config.yaml # 业务参数配置(积分/会员/支付/上传限制)
|
├── config.yaml # 业务参数配置(积分/会员/支付/上传限制)
|
||||||
├── .env # 密钥类配置(不入库)
|
├── .env # 密钥类配置(不入库)
|
||||||
└── docker-compose.yml
|
└── docker-compose.yml
|
||||||
|
|||||||
+55
-16
@@ -41,6 +41,13 @@
|
|||||||
└───────────────────────────────────────────────────────────┘
|
└───────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **客户端层补充:App 内嵌本地媒体代理**(app/lib/core/media/local_media_server.dart)。
|
||||||
|
> App 进程内启动 127.0.0.1 环回 HTTP 服务(shelf),网络 m3u8 播放地址统一改写走该代理:
|
||||||
|
> m3u8 每次回源(签名 URL 有时效,不落盘);ts 分片按「去签名路径」落盘缓存、AES-128 密钥同步缓存;
|
||||||
|
> 已下载视频经 `/local/<videoId>/<file>` 直接读下载目录。统一声明 `Accept-Ranges` 并支持 Range/206
|
||||||
|
> (这是进度条可拖动的根因:ffmpeg/mpv 依据该头判断流可 seek,否则按直播处理)。
|
||||||
|
> ts 流量由代理直连源站 CDN,不经过业务服务器。
|
||||||
|
|
||||||
### 1.2 部署架构(单机起步版,docker-compose)
|
### 1.2 部署架构(单机起步版,docker-compose)
|
||||||
|
|
||||||
| 容器 | 镜像 | 说明 |
|
| 容器 | 镜像 | 说明 |
|
||||||
@@ -54,9 +61,11 @@
|
|||||||
|
|
||||||
扩展路径:App 无状态 → 多副本 + SLB;MySQL → 云 RDS 主从;Redis → 云 Redis;转码 → 独立 GPU/多核 Worker 机器或云转码服务。
|
扩展路径:App 无状态 → 多副本 + SLB;MySQL → 云 RDS 主从;Redis → 云 Redis;转码 → 独立 GPU/多核 Worker 机器或云转码服务。
|
||||||
|
|
||||||
|
**当前生产形态(与上表演进关系)**:单台云主机。后端由 systemd 托管(`video-api.service`:`uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 2`),MySQL 8(容器 `mysql8`)与 Redis(容器 `my-redis`)跑在 Docker;项目根目录 `project-ctl.sh` 提供 start/stop/restart/status 一键启停(同时管 video-api.service 与两个容器)。注意:**`--workers 2` 下 settle_service 周期任务随每个 worker 的 lifespan 各启动一份**,任务逻辑必须保持幂等(按状态机推进、唯一约束防重),避免重复结算。
|
||||||
|
|
||||||
### 1.3 关键链路
|
### 1.3 关键链路
|
||||||
|
|
||||||
- **播放链路**:App → `GET /hls/{vid}/index.m3u8` → Nginx → OSS/CDN(m3u8 与 ts 均为静态文件,天然适合 CDN 加速)
|
- **播放链路**:App 将网络 m3u8 地址改写为内嵌本地代理(127.0.0.1)→ 代理回源取 m3u8(签名时效)→ ts 分片命中本地缓存直出,未命中直连源站 CDN 下载并落盘(1GB LRU)→ 全程支持 Range/206 拖动
|
||||||
- **上传链路**:App → 获取上传凭证 → 直传 OSS(推荐,节省服务端带宽)或经服务端中转 → 回调发布 → 转码 → 审核 → 上架
|
- **上传链路**:App → 获取上传凭证 → 直传 OSS(推荐,节省服务端带宽)或经服务端中转 → 回调发布 → 转码 → 审核 → 上架
|
||||||
- **支付链路**:App 下单 → 调起支付 SDK → 平台异步回调服务端 → 验签 → 事务入账 → App 轮询查单确认结果
|
- **支付链路**:App 下单 → 调起支付 SDK → 平台异步回调服务端 → 验签 → 事务入账 → App 轮询查单确认结果
|
||||||
|
|
||||||
@@ -451,6 +460,7 @@ CREATE TABLE sensitive_words (
|
|||||||
| `sms:code:{phone}` | String | 300s | 短信验证码;同号 60s 内禁止重发(另建 `sms:limit:{phone}`) |
|
| `sms:code:{phone}` | String | 300s | 短信验证码;同号 60s 内禁止重发(另建 `sms:limit:{phone}`) |
|
||||||
| `email:code:{email}` | String | 600s | 邮箱验证码 |
|
| `email:code:{email}` | String | 600s | 邮箱验证码 |
|
||||||
| `auth:refresh:{user_id}` | String | 30d | refresh_token 白名单(支持多端+踢下线) |
|
| `auth:refresh:{user_id}` | String | 30d | refresh_token 白名单(支持多端+踢下线) |
|
||||||
|
| `auth:epoch:{user_id}` | String(计数) | - | 用户令牌纪元:后台改密/强制下线时 INCR,旧 Token 全部失效 |
|
||||||
| `token:blacklist:{jti}` | String | token 剩余 TTL | 登出/改密失效 |
|
| `token:blacklist:{jti}` | String | token 剩余 TTL | 登出/改密失效 |
|
||||||
| `watched:{subject}:{yyyyMMdd}` | Set(video_id) | 48h | 当日已看视频(推荐去重) |
|
| `watched:{subject}:{yyyyMMdd}` | Set(video_id) | 48h | 当日已看视频(推荐去重) |
|
||||||
| `rec:seed:{yyyyMMdd}` | String | 48h | 当日推荐随机种子 |
|
| `rec:seed:{yyyyMMdd}` | String | 48h | 当日推荐随机种子 |
|
||||||
@@ -475,6 +485,8 @@ CREATE TABLE sensitive_words (
|
|||||||
| `words:sensitive` | String(JSON) | 60s | 敏感词库缓存(DFA 词源),后台增删后失效重建 |
|
| `words:sensitive` | String(JSON) | 60s | 敏感词库缓存(DFA 词源),后台增删后失效重建 |
|
||||||
| `config:system` | Hash | 60s | system_configs 全量缓存,后台修改即时失效 |
|
| `config:system` | Hash | 60s | system_configs 全量缓存,后台修改即时失效 |
|
||||||
|
|
||||||
|
> **连接注意事项**:Redis 客户端必须显式指定 `protocol=2`(RESP2)。线上服务端为 Redis 5.x,不支持 RESP3 的 HELLO 握手,而 redis-py 8.x 默认尝试 protocol=3,不显式指定会在连接期直接报错——这是踩过的坑(见 server/app/redis_client.py)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 接口设计(技术规范)
|
## 4. 接口设计(技术规范)
|
||||||
@@ -541,24 +553,34 @@ Nginx limit_req(IP 维度)+ Redis 滑动窗口(主体维度)双层限流
|
|||||||
```
|
```
|
||||||
app/lib/
|
app/lib/
|
||||||
├── main.dart
|
├── main.dart
|
||||||
├── config/ # 环境配置、常量、主题
|
├── router.dart # go_router 路由表、登录守卫
|
||||||
├── router/ # go_router 路由表、登录守卫
|
├── models/ # 数据模型
|
||||||
├── models/ # 数据模型(json_serializable)
|
├── services/ # 接口封装(VideoService 等,基于 core/network)
|
||||||
├── services/ # api_client(dio)、auth_service、storage、device
|
├── providers/ # Riverpod(12 个):auth/feed/comment/wallet/member/payment
|
||||||
├── providers/ # Riverpod providers(auth/feed/wallet/vip)
|
│ # /profile/search/upload/asset/notification/tag_videos
|
||||||
├── pages/ # 页面(home/login/mine/wallet/vip/upload/...)
|
├── screens/ # 页面:splash/home(HomeShell+IndexedStack)/feed/search/category
|
||||||
│ └── home/widgets/
|
│ # /player/membership/payment/wallet/assets/upload/profile
|
||||||
├── widgets/ # 通用组件(按钮/弹窗/空态/互动栏)
|
│ # /auth/settings/notification/downloads
|
||||||
└── utils/ # 格式化、埋点、防抖
|
│ └── feed/widgets/
|
||||||
|
└── core/ # 子系统:
|
||||||
|
├── config/ # 环境配置、常量
|
||||||
|
├── media/ # local_media_server(127.0.0.1 本地代理+HLS 缓存)、download_manager(下载)
|
||||||
|
├── network/ # api_client(dio 拦截器)、api_domain(域名发现)、endpoints
|
||||||
|
├── player/ # feed_player_pool(3 槽播放器池)
|
||||||
|
├── storage/ # token_store、local_store、观看去重、搜索历史
|
||||||
|
├── theme/ # 主题(深色主主题;弹窗局部亮色)
|
||||||
|
└── utils/
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5.2 关键技术点
|
### 5.2 关键技术点
|
||||||
|
|
||||||
| 点 | 方案 |
|
| 点 | 方案 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 网络层 | dio 单例 + 拦截器:注入 `Authorization`/`X-Device-ID`/`X-Request-ID`;401 时刷新 Token 并重放原请求(单飞 Future 防并发刷新) |
|
| 网络层 | dio 单例 + 拦截器:注入 `Authorization`/`X-Device-ID`/`X-Request-ID`;401 时刷新 Token 并重放原请求(单飞 Future 防并发刷新);**无 refresh token 或刷新被拒时清登录态跳登录页,防"僵尸登录态"** |
|
||||||
| 播放器 | media_kit 封装 `ShortVideoPlayer`:预加载下一条(keepAlive 双缓冲)、滑出 dispose、首帧秒开(m3u8 低延迟配置);双击点赞手势区与播放手势分层 |
|
| 播放器 | media_kit 封装 + **3 槽播放器池**(core/player/feed_player_pool.dart):当前页播放 + 下一页预缓冲,划走即 stop(断音 + 中断缓存下载);`suspend()`/`resume()` 挂起语义配合 `activeFeedStrategyProvider`/`feedTabVisibleProvider` 与 HomeShell(IndexedStack),修复"切到我的页面仍出声"竞态 |
|
||||||
| Feed 滑动 | PageView(scrollDirection: vertical),onPageChanged 触发**观看上报**(防抖 500ms,同视频当日只报一次,本地记录) |
|
| 本地媒体代理 | App 内嵌 127.0.0.1 HTTP 服务(core/media/local_media_server.dart):网络 m3u8 改写走代理;m3u8 每次回源(签名时效)、ts 按「去签名路径」落盘缓存、AES-128 密钥缓存;**LRU 上限 1GB,超限按 mtime 清到 900MB**(系统临时目录,滞后防抖);统一 Range/206(修复进度条不走/拖动无效的根因) |
|
||||||
|
| 视频下载 | core/media/download_manager.dart:m3u8 → 4 并发下载 ts + 密钥 → 改写本地 index.m3u8;mp4 整文件直下;进度/取消/失败清理;入口为 Feed/播放页长按菜单,列表在「我的 → 已下载」,离线播放走本地代理支持拖动,不重复扣积分 |
|
||||||
|
| Feed 滑动 | PageView(scrollDirection: vertical),onPageChanged 触发**观看上报**(防抖 500ms,同视频当日只报一次,本地记录);5 个策略 Tab 各保活独立分页状态(family provider),liked/viewed 用(值,id)双键游标 |
|
||||||
| 状态 | Riverpod:`authStateProvider`(游客/登录)、`walletProvider`、`vipProvider`、`feedProvider`(分页游标) |
|
| 状态 | Riverpod:`authStateProvider`(游客/登录)、`walletProvider`、`vipProvider`、`feedProvider`(分页游标) |
|
||||||
| 本地持久 | device_id(首启 UUID 写 secure_storage)、Token(secure_storage)、观看已上报集合(sp,按日清理) |
|
| 本地持久 | device_id(首启 UUID 写 secure_storage)、Token(secure_storage)、观看已上报集合(sp,按日清理) |
|
||||||
| 上传 | 优先 OSS STS 直传(后端签 STS,App 分片上传,失败续传);兜底服务端 multipart |
|
| 上传 | 优先 OSS STS 直传(后端签 STS,App 分片上传,失败续传);兜底服务端 multipart |
|
||||||
@@ -613,14 +635,15 @@ ffmpeg -ss 00:00:01 -i input.mp4 -frames:v 1 -q:v 2 out/cover.jpg
|
|||||||
|
|
||||||
### 6.4 推荐与标签服务实现
|
### 6.4 推荐与标签服务实现
|
||||||
|
|
||||||
- Feed 接口参数 `type=recommend|latest|hot`,默认 recommend
|
- Feed 接口参数 `type=recommend|latest|hot|liked|viewed`,默认 recommend
|
||||||
- **recommend(个性化)**:
|
- **recommend(个性化)**:
|
||||||
1. 候选池:近 30 天"已发布"视频 ID(Redis ZSet `feed:latest` 缓存,发布/下架时增删)
|
1. 候选池:近 30 天"已发布"视频 ID(Redis ZSet `feed:latest` 缓存,发布/下架时增删)
|
||||||
2. 个性化分:统计用户近 7 天观看/点赞/收藏视频的标签,得到偏好标签及权重;候选视频命中偏好标签加权,未命中取热度归一分;游客/新用户退化为热度分
|
2. 个性化分:统计用户近 7 天观看/点赞/收藏视频的标签,得到偏好标签及权重;候选视频命中偏好标签加权,未命中取热度归一分;游客/新用户退化为热度分
|
||||||
3. **每日重排**:`seed = md5(yyyyMMdd + subject_id)`,以该固定种子对打分后列表做确定性洗牌(Fisher-Yates)—— 保证同一用户**每天顺序不同**,且同一天内多次刷新顺序稳定(游标翻页不错乱)
|
3. **每日重排**:`seed = md5(yyyyMMdd + subject_id)`,以该固定种子对打分后列表做确定性洗牌(Fisher-Yates)—— 保证同一用户**每天顺序不同**,且同一天内多次刷新顺序稳定(游标翻页不错乱)
|
||||||
4. **当日去重**:翻页时过滤 `watched:{subject}:{date}`(观看上报时写入);候选耗尽时放开过滤兜底,避免 Feed 刷空
|
4. **当日去重**:翻页时过滤 `watched:{subject}:{date}`(观看上报时写入);候选耗尽时放开过滤兜底,避免 Feed 刷空
|
||||||
- **latest**:published_at 游标倒序;**hot**:score = view×1 + like×5 + comment×10(近 7 天,时间衰减)
|
- **latest**:published_at 游标倒序;**hot**:score = (view×1 + like×5 + comment×10) / (1 + 发布天数),严格倒序;**liked(口碑)**:like_count 倒序;**viewed(热播)**:view_count 倒序
|
||||||
- 标签:发布时写入 tags / video_tags(标签名全局唯一、大小写不敏感);标签视频页按热度或最新排序
|
- liked / viewed 为纯数值排序,游标用**(值, id)双键**(`WHERE (v < ?) OR (v = ? AND id < ?)`),避免同值翻页丢失
|
||||||
|
- 标签:发布时写入 tags / video_tags(标签名全局唯一、大小写不敏感);标签视频页按热度或最新排序;存量标签由离线脚本 build_tags_from_titles.py(jieba TF-IDF topK=6 + 番号/书名号提取 + 噪音过滤,幂等)批量生成
|
||||||
|
|
||||||
### 6.5 支付适配层
|
### 6.5 支付适配层
|
||||||
|
|
||||||
@@ -682,3 +705,19 @@ Celery 周期任务(default 队列):
|
|||||||
- CI/CD:GitHub Actions(lint → pytest → build image → deploy)
|
- CI/CD:GitHub Actions(lint → pytest → build image → deploy)
|
||||||
- 备份:MySQL 每日全量 + binlog 增量;OSS 跨区复制(可选)
|
- 备份:MySQL 每日全量 + binlog 增量;OSS 跨区复制(可选)
|
||||||
- **兜底域名与证书**:`jsnbhh.vip` 系列兜底域名配置 DNS 泛解析 + 通配符证书(`*.jsnbhh.vip`),接入 CDN 回源;主域名失效时 App 按域名发现策略自动切换(见 5.2)
|
- **兜底域名与证书**:`jsnbhh.vip` 系列兜底域名配置 DNS 泛解析 + 通配符证书(`*.jsnbhh.vip`),接入 CDN 回源;主域名失效时 App 按域名发现策略自动切换(见 5.2)
|
||||||
|
|
||||||
|
#### 发版流水线(deploy.sh)
|
||||||
|
项目根目录 `deploy.sh` 一键发版,流程全自动:
|
||||||
|
1. 解析 pubspec.yaml 版本号 → `flutter analyze`(warning 不阻断)→ `flutter build apk --release`
|
||||||
|
2. 产物覆盖 `runtime/downloads/video-app.apk`(nginx h5.256kb.top 下载页 alias 到此)
|
||||||
|
3. 同步 DB `system_configs.app.version.android` 的 latest_version / latest_version_code(**保留 min_version_code 不重置**,需强制更新时人工调大)
|
||||||
|
4. `docker exec my-redis` 清 `config:system` 缓存使版本配置即时生效
|
||||||
|
5. 自动 `gradlew --stop` 释放守护进程内存
|
||||||
|
|
||||||
|
#### Android 正式签名
|
||||||
|
- keystore:`app/android/app/upload-keystore.jks`(PKCS12,alias `video_release`)+ `app/android/key.properties`,两者均 gitignore;备份在 `/root/backups/video-app-signing/`
|
||||||
|
- `build.gradle.kts` 条件签名:key.properties 存在用 release 签名,否则回退 debug(保证 CI/新环境可编译)
|
||||||
|
|
||||||
|
#### 生产运行与启停
|
||||||
|
- systemd `video-api.service`:`uvicorn 127.0.0.1:8000 --workers 2`;`project-ctl.sh start/stop/restart/status` 统一管 video-api.service + docker `mysql8`/`my-redis`
|
||||||
|
- **注意**:`--workers 2` 时 settle_service 周期任务随每个 worker 的 lifespan 各启动一份,任务逻辑必须保持幂等(状态机推进 + 唯一约束),新增周期任务同样遵守
|
||||||
|
|||||||
+174
-9
@@ -1,7 +1,7 @@
|
|||||||
# 短视频应用接口文档
|
# 短视频应用接口文档
|
||||||
|
|
||||||
- 版本:v1.0
|
- 版本:v1.1
|
||||||
- 日期:2026-10-14
|
- 日期:2026-10-18
|
||||||
- Base URL:`https://api.example.com/api/v1`
|
- Base URL:`https://api.example.com/api/v1`
|
||||||
|
|
||||||
> 注意:App 不硬编码 Base URL。启动时优先解析 `txt.256kb.top` 的 DNS TXT 记录获取真实 API 域名;解析失败时按 `md5(yyyyMM) + "_jsnbhh.vip"` 计算兜底域名(详见《01-开发文档》5.5 节)。本文示例域名仅为占位。
|
> 注意:App 不硬编码 Base URL。启动时优先解析 `txt.256kb.top` 的 DNS TXT 记录获取真实 API 域名;解析失败时按 `md5(yyyyMM) + "_jsnbhh.vip"` 计算兜底域名(详见《01-开发文档》5.5 节)。本文示例域名仅为占位。
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
| 13 | 用户 | POST | /users/me/deactivate | 注销账号(7天冻结期) | 是 |
|
| 13 | 用户 | POST | /users/me/deactivate | 注销账号(7天冻结期) | 是 |
|
||||||
| 14 | 用户 | GET | /users/{user_id} | 用户公开主页 | 否 |
|
| 14 | 用户 | GET | /users/{user_id} | 用户公开主页 | 否 |
|
||||||
| 15 | 用户 | GET | /users/{user_id}/videos | 他人作品列表 | 否 |
|
| 15 | 用户 | GET | /users/{user_id}/videos | 他人作品列表 | 否 |
|
||||||
| 16 | 视频 | GET | /videos/feed | 推荐视频流(支持 recommend/latest/hot) | 否 |
|
| 16 | 视频 | GET | /videos/feed | 视频流(recommend/latest/hot/liked/viewed) | 否 |
|
||||||
| 17 | 视频 | GET | /videos/{video_id} | 视频详情(登录返回续播进度) | 否 |
|
| 17 | 视频 | GET | /videos/{video_id} | 视频详情(登录返回续播进度) | 否 |
|
||||||
| 18 | 视频 | POST | /videos/{video_id}/watch | 观看上报(计费,可带播放进度) | 否 |
|
| 18 | 视频 | POST | /videos/{video_id}/watch | 观看上报(计费,可带播放进度) | 否 |
|
||||||
| 19 | 视频 | POST | /videos/{video_id}/watch/complete | 完整观看上报(创作收益计数) | 否 |
|
| 19 | 视频 | POST | /videos/{video_id}/watch/complete | 完整观看上报(创作收益计数) | 否 |
|
||||||
@@ -133,7 +133,10 @@
|
|||||||
| 56 | 消息 | GET | /notifications/unread_count | 未读数 | 是 |
|
| 56 | 消息 | GET | /notifications/unread_count | 未读数 | 是 |
|
||||||
| 57 | 消息 | POST | /notifications/read | 标记已读 | 是 |
|
| 57 | 消息 | POST | /notifications/read | 标记已读 | 是 |
|
||||||
| 58 | 通用 | POST | /reports | 提交举报 | 是 |
|
| 58 | 通用 | POST | /reports | 提交举报 | 是 |
|
||||||
| 59~90 | 后台管理 | * | /admin/** | 登录、看板、审核、视频/评论管理、用户、积分、订单、补单、套餐、赠送、提现审核、推荐风控、举报处理、敏感词、平台配置、日志,见第 11 章 | 管理员 |
|
| 59 | 视频 | GET | /videos/series/{series_id} | 系列详情 + 全部集数列表 | 否 |
|
||||||
|
| 60 | 视频 | GET | /videos/{video_id}/series | 当前视频所属系列 + 上下集 | 否 |
|
||||||
|
| 61 | 通用 | GET | /m3u8/proxy | m3u8 播放列表代理(回源拉取,TS 分片不经过服务器) | 否 |
|
||||||
|
| 62~105 | 后台管理 | * | /admin/** | 登录、看板、审核、视频/系列/评论管理、用户(含重置密码)、积分、订单、补单/退款、套餐、赠送、提现审核、推荐风控、举报处理、敏感词、平台配置、采集源/采集同步、分类、标签、日志,见第 11 章 | 管理员 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -160,7 +163,7 @@
|
|||||||
{ "code": 0, "message": "ok", "data": { "expire_seconds": 300, "retry_after": 60 } }
|
{ "code": 0, "message": "ok", "data": { "expire_seconds": 300, "retry_after": 60 } }
|
||||||
```
|
```
|
||||||
|
|
||||||
**限制**:同号码 60 秒 1 条,同号码每天 10 条;注册场景下已注册号码返回错误码 1001("该手机号已注册")。
|
**限制**:同号码 60 秒 1 条,同号码每天 10 条,同 IP 每小时 5 条;注册场景下已注册号码返回错误码 1001("该账号已注册"),login/reset 场景下未注册号码同样返回 1001。未配置短信通道时(开发环境)响应额外返回 `dev_code` 并仅记日志。
|
||||||
|
|
||||||
### 3.2 发送邮箱验证码 `POST /auth/email/send`
|
### 3.2 发送邮箱验证码 `POST /auth/email/send`
|
||||||
|
|
||||||
@@ -169,7 +172,7 @@
|
|||||||
| email | string | 是 | 邮箱地址 |
|
| email | string | 是 | 邮箱地址 |
|
||||||
| scene | string | 是 | `register` / `login` / `reset` |
|
| scene | string | 是 | `register` / `login` / `reset` |
|
||||||
|
|
||||||
响应同 3.1,`expire_seconds=600`。
|
验证码由服务端通过 SMTP 真实发送(STARTTLS),并写入 Redis(`email:code:{scene}:{email}`,10 分钟有效,验证通过即删除)。响应结构同 3.1,`expire_seconds=600`;限流规则同 3.1(同邮箱 60 秒 1 条、每天 10 条、同 IP 每小时 5 条)。未配置 SMTP 时按开发模式处理:响应额外返回 `dev_code` 并仅记日志。
|
||||||
|
|
||||||
### 3.3 注册 `POST /auth/register`
|
### 3.3 注册 `POST /auth/register`
|
||||||
|
|
||||||
@@ -223,6 +226,7 @@
|
|||||||
| email | string | 邮箱方式必填 | |
|
| email | string | 邮箱方式必填 | |
|
||||||
| password | string | 密码方式必填 | |
|
| password | string | 密码方式必填 | |
|
||||||
| code | string | 验证码方式必填 | |
|
| code | string | 验证码方式必填 | |
|
||||||
|
| revoke | bool | 否 | 注销冻结期内确认撤销注销(默认 false,见 4.6) |
|
||||||
|
|
||||||
**请求示例**
|
**请求示例**
|
||||||
|
|
||||||
@@ -230,7 +234,9 @@
|
|||||||
{ "type": "phone_password", "phone": "13800138000", "password": "abc12345" }
|
{ "type": "phone_password", "phone": "13800138000", "password": "abc12345" }
|
||||||
```
|
```
|
||||||
|
|
||||||
**响应**:同 3.3。连续失败 5 次锁定 15 分钟(错误码 1005)。
|
**响应**:同 3.3。连续失败 5 次锁定 15 分钟(错误码 1005,按 IP 维度计数)。
|
||||||
|
|
||||||
|
> 登录账号仅支持**手机号或邮箱**(按 type 使用对应字段),不支持昵称登录。冻结期内账号登录返回 1009 且 `data.can_revoke=true`;确认撤销后带 `"revoke": true` 重试即恢复账号并正常签发 Token。
|
||||||
|
|
||||||
### 3.5 刷新令牌 `POST /auth/token/refresh`
|
### 3.5 刷新令牌 `POST /auth/token/refresh`
|
||||||
|
|
||||||
@@ -240,6 +246,8 @@
|
|||||||
|
|
||||||
**响应**:同 3.3 的 token 部分;refresh_token 旋转(旧值立即失效)。
|
**响应**:同 3.3 的 token 部分;refresh_token 旋转(旧值立即失效)。
|
||||||
|
|
||||||
|
> Token 体系:access_token 有效期 2 小时,refresh_token 30 天(Redis 白名单 Set,支持多端同时在线)。服务端按用户维护令牌纪元 `auth:epoch:{user_id}`,签发时写入 Token 的 `ep` 声明,每次请求都校验:修改/重置密码、后台重置密码(11.34)后纪元递增并清空 refresh 白名单,该用户所有旧 Token(含未过期的 access)立即失效。
|
||||||
|
|
||||||
### 3.6 退出登录 `POST /auth/logout`(需登录)
|
### 3.6 退出登录 `POST /auth/logout`(需登录)
|
||||||
|
|
||||||
无参数。响应 `{ "code": 0 }`。服务端将 access_token 的 jti 加入黑名单并删除 refresh 白名单。
|
无参数。响应 `{ "code": 0 }`。服务端将 access_token 的 jti 加入黑名单并删除 refresh 白名单。
|
||||||
@@ -401,7 +409,7 @@
|
|||||||
| cursor | string | 否 | 上一页返回的 next_cursor |
|
| cursor | string | 否 | 上一页返回的 next_cursor |
|
||||||
| limit | int | 否 | 默认 10,最大 20 |
|
| limit | int | 否 | 默认 10,最大 20 |
|
||||||
| category_id | int | 否 | 分类筛选 |
|
| category_id | int | 否 | 分类筛选 |
|
||||||
| type | string | 否 | 策略:`recommend`(默认,个性化)/ `latest` / `hot` |
|
| type | string | 否 | 策略:`recommend`(默认,个性化)/ `latest`(最新发布)/ `hot`(热度)/ `liked`(口碑,按点赞量倒序)/ `viewed`(热播,按播放量倒序) |
|
||||||
|
|
||||||
**响应示例**
|
**响应示例**
|
||||||
|
|
||||||
@@ -434,6 +442,11 @@
|
|||||||
> `is_liked/is_favorited` 仅登录用户返回真实值,游客恒为 false。
|
> `is_liked/is_favorited` 仅登录用户返回真实值,游客恒为 false。
|
||||||
>
|
>
|
||||||
> **recommend 策略**:以"当天日期 + 用户/设备"为随机种子每日重排(同一用户每天的推荐顺序不同、同一天内多次刷新顺序稳定);当日已观看的视频尽量不再出现(候选不足时放开过滤兜底);个性化基于近 7 天观看/点赞/收藏视频的标签偏好加权,游客及新用户退化为热度排序。
|
> **recommend 策略**:以"当天日期 + 用户/设备"为随机种子每日重排(同一用户每天的推荐顺序不同、同一天内多次刷新顺序稳定);当日已观看的视频尽量不再出现(候选不足时放开过滤兜底);个性化基于近 7 天观看/点赞/收藏视频的标签偏好加权,游客及新用户退化为热度排序。
|
||||||
|
>
|
||||||
|
> **游标格式**(均为 base64url 编码的 JSON,客户端无需解析,原样回传即可):
|
||||||
|
> - `liked` / `viewed`:「值,id」双键游标 `{"n": 末条点赞数/播放数, "i": 末条 video_id}`,按"值 DESC, id DESC"严格翻页,点赞/播放数变化不影响已翻过的页
|
||||||
|
> - `latest`:`{"p": 末条发布时间戳, "i": 末条 video_id}`
|
||||||
|
> - `recommend` / `hot`:当日种子下的偏移游标
|
||||||
|
|
||||||
### 5.2 视频详情 `GET /videos/{video_id}`(游客可)
|
### 5.2 视频详情 `GET /videos/{video_id}`(游客可)
|
||||||
|
|
||||||
@@ -651,6 +664,30 @@
|
|||||||
|
|
||||||
**错误码**:2107 无权操作(非作者本人);2003 视频不存在。
|
**错误码**:2107 无权操作(非作者本人);2003 视频不存在。
|
||||||
|
|
||||||
|
### 5.14 系列详情 `GET /videos/series/{series_id}`(游客可)
|
||||||
|
|
||||||
|
返回系列信息 + 全部已发布集数(按 `episode_no` 升序),用于短剧播放页选集。
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"series": { "id": 12, "title": "霸道总裁", "cover_url": "https://...", "description": "...", "total_episodes": 80 },
|
||||||
|
"episodes": [
|
||||||
|
{ "video_id": 88001, "episode_no": 1, "title": "第1集", "cover_url": "https://...", "duration": 95, "video_url": "https://.../index.m3u8" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误码**:1006 系列不存在或已隐藏。
|
||||||
|
|
||||||
|
### 5.15 当前视频所属系列 `GET /videos/{video_id}/series`(游客可)
|
||||||
|
|
||||||
|
用于 PlayerScreen 系列抽屉入口。视频不属于任何系列(或未发布)时返回 `{ "has_series": false }`;否则返回系列信息、全部集数(字段同 5.14)及 `current_episode_no` / `prev_video_id` / `next_video_id`(无上下集时为 null)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 互动模块
|
## 6. 互动模块
|
||||||
@@ -1197,6 +1234,20 @@ App 我的页消息入口红点依赖本接口(直接读 Redis `notif:unread:{
|
|||||||
|
|
||||||
**错误码**:2106 请勿重复举报(同一用户对同一对象仅一次有效举报,唯一索引 `uk_user_target` 保证);限流 10 次/天。
|
**错误码**:2106 请勿重复举报(同一用户对同一对象仅一次有效举报,唯一索引 `uk_user_target` 保证);限流 10 次/天。
|
||||||
|
|
||||||
|
### 10.6 m3u8 播放列表代理 `GET /m3u8/proxy`(游客可)
|
||||||
|
|
||||||
|
采集内容源站的 m3u8 内 TS 链接带时效签名,需经服务端每次回源拉取新鲜播放列表;**TS 分片不经过本服务器**,由客户端按播放列表中的源站地址直连下载。
|
||||||
|
|
||||||
|
**查询参数**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| path | string | 是 | 源站播放列表的相对资源路径(防 SSRF:仅允许字母数字及 `/._-`,禁止 `..`),1-300 字 |
|
||||||
|
|
||||||
|
**响应**:非统一 JSON 包装,直接返回 m3u8 文本(`Content-Type: application/vnd.apple.mpegurl`,`Cache-Control: no-cache`,`Access-Control-Allow-Origin: *`)。
|
||||||
|
|
||||||
|
**错误码**:1001 非法路径;1006 播放列表获取失败(源站非 200 或内容非 m3u8)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. 管理后台接口
|
## 11. 管理后台接口
|
||||||
@@ -1204,7 +1255,7 @@ App 我的页消息入口红点依赖本接口(直接读 Redis `notif:unread:{
|
|||||||
通用约定:
|
通用约定:
|
||||||
- 前缀 `/api/v1/admin`,统一响应结构同 App 接口
|
- 前缀 `/api/v1/admin`,统一响应结构同 App 接口
|
||||||
- 认证:`Authorization: Bearer <admin_token>`(Token payload `type="admin"`,有效期 12h,与 App 用户体系互不相通)
|
- 认证:`Authorization: Bearer <admin_token>`(Token payload `type="admin"`,有效期 12h,与 App 用户体系互不相通)
|
||||||
- 角色:`super`(全部)/ `reviewer`(仅审核)/ `operator`(套餐、活动、看板);越权返回 4001
|
- 角色:`super`(全部)/ `reviewer`(仅审核)/ `operator`(套餐、活动、看板、采集源/采集同步、系列/分类/标签查询);越权返回 4001
|
||||||
- 所有写操作记录操作日志;标注【敏感】的接口 `reason` 必填,缺失返回 4002
|
- 所有写操作记录操作日志;标注【敏感】的接口 `reason` 必填,缺失返回 4002
|
||||||
|
|
||||||
### 11.1 管理员登录 `POST /admin/auth/login`
|
### 11.1 管理员登录 `POST /admin/auth/login`
|
||||||
@@ -1466,6 +1517,120 @@ App 我的页消息入口红点依赖本接口(直接读 Redis `notif:unread:{
|
|||||||
|
|
||||||
响应 `{ "code": 0 }`,同样触发词库缓存重建。
|
响应 `{ "code": 0 }`,同样触发词库缓存重建。
|
||||||
|
|
||||||
|
### 11.33 重置用户密码 `PUT /admin/users/{user_id}/password`【敏感】(仅 super)
|
||||||
|
|
||||||
|
客服场景下由后台直接重置某个 App 用户的登录密码。
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| new_password | string | 是 | 新密码,6-100 位(bcrypt 入库,不校验字母数字组合) |
|
||||||
|
| reason | string | 是 | 操作原因(写入 admin_logs,action=`user_password_reset`) |
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{ "new_password": "newpass123", "reason": "用户申诉核实通过" }
|
||||||
|
// 响应
|
||||||
|
{ "code": 0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
重置后该用户**全端 Token 立即失效**:服务端递增 `auth:epoch:{user_id}` 并删除 refresh 白名单(机制见 3.5),用户需用新密码重新登录。
|
||||||
|
|
||||||
|
**错误码**:1006 用户不存在;1001 账号已注销不可操作。
|
||||||
|
|
||||||
|
### 11.34 退款登记 `POST /admin/orders/{order_no}/refund`【敏感】(仅 super)
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| reason | string | 是 | 退款原因 |
|
||||||
|
|
||||||
|
### 11.35 修改管理员密码 `PUT /admin/auth/password`(仅 super)
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| old_password | string | 是 | 当前密码 |
|
||||||
|
| new_password | string | 是 | 新密码,6-100 位 |
|
||||||
|
|
||||||
|
响应 `{ "code": 0 }`。
|
||||||
|
|
||||||
|
### 11.36 后台新增视频 `POST /admin/videos`(仅 super)
|
||||||
|
|
||||||
|
两种模式二选一:
|
||||||
|
- **upload_token 模式**:先调 `POST /admin/videos/upload` 拿凭证 → `POST /admin/videos/upload/file` 上传 mp4/mov/mkv/avi(multipart,字段 `file` + query `token`)→ 拿到 `upload_token` 后调本接口(服务端自动转码)
|
||||||
|
- **video_url 模式**:直接填已转码的 m3u8 地址(外站/CDN 资源)
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| title | string | 是 | 1-100 字 |
|
||||||
|
| description | string | 否 | ≤500 字 |
|
||||||
|
| category_id | int | 是 | 分类 ID |
|
||||||
|
| tag_names | array[string] | 否 | 标签 |
|
||||||
|
| upload_token | string | 模式A必填 | upload/file 返回的凭证(封面可选 cover_upload_token) |
|
||||||
|
| video_url | string | 模式B必填 | m3u8 地址 |
|
||||||
|
| cover_url | string | 模式B可选 | 封面 |
|
||||||
|
| duration | int | 否 | 时长(秒),1-36000 |
|
||||||
|
| status | int | 否 | 1待审核(默认)/ 2直接发布 |
|
||||||
|
|
||||||
|
配套接口:
|
||||||
|
- `POST /admin/videos/upload`:获取 admin 上传凭证,返回 `{ upload_url, method, token, expire_seconds: 3600 }`
|
||||||
|
- `POST /admin/videos/upload/file?token=xxx`:接收文件落盘 server/uploads,返回 `{ upload_token, object_key, file_size }`(凭证一次性,upload_token 24h 内有效)
|
||||||
|
- `POST /admin/upload/image`:通用图片上传(封面/头像/系列图,jpg/jpeg/png/webp/gif),返回可直接访问的完整 URL `{ url, file_size }`
|
||||||
|
|
||||||
|
### 11.37 后台编辑视频 `PUT /admin/videos/{video_id}`(仅 super)
|
||||||
|
|
||||||
|
均可选,传哪个改哪个:`title`(1-100)/ `description` / `cover_url` / `video_url` / `category_id` / `series_id` / `episode_no`(≥1)/ `status`(0-5)。`series_id` 显式传 `null` 表示移出系列(同时清空 episode_no)。响应 `{ "code": 0, "data": { "video_id": 88001 } }`。
|
||||||
|
|
||||||
|
### 11.38 后台删除视频 `DELETE /admin/videos/{video_id}`(仅 super)
|
||||||
|
|
||||||
|
**物理删除**:先解绑系列,再级联删除标签/点赞/收藏/评论/观看记录/收益/举报等全部关联数据。响应 `{ "code": 0, "data": { "video_id": 88001, "deleted": true } }`。
|
||||||
|
|
||||||
|
### 11.39 系列管理
|
||||||
|
|
||||||
|
| 方法 | 路径 | 角色 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | /admin/series | operator | 系列分页列表(keyword 按标题模糊),含 `source_id`(采集来源标记,供"同步更新"按钮) |
|
||||||
|
| POST | /admin/series | super | 新建系列:`title`(1-100 必填)、`cover_url`、`description`、`category_id` |
|
||||||
|
| PUT | /admin/series/{series_id} | super | 改 `title` / `cover_url` / `description` / `category_id` / `status`(0隐藏 1正常) |
|
||||||
|
| DELETE | /admin/series/{series_id} | super | 删除系列(集数视频的 series_id 需先解绑) |
|
||||||
|
| POST | /admin/videos/{video_id}/series | super | 绑定视频到系列:`{ "series_id": 12, "episode_no": 3 }`,自动重算 series.total_episodes(仅计已发布) |
|
||||||
|
|
||||||
|
### 11.40 短剧采集同步(operator)
|
||||||
|
|
||||||
|
- `POST /admin/collection/sync/{series_id}`:手动同步单部采集短剧(补充源站新出的集数);非采集内容返回 1006。响应为同步统计 `{新增集数等}`,写 admin_logs(action=`collection_sync`)
|
||||||
|
- `POST /admin/collection/sync-all`:触发内置短剧源增量同步,**后台异步执行,立即返回** `{ "started": true, "pages": 2 }`。入参 `pages`(1-20,默认 2)为增量同步的列表页数;已有任务进行中返回 1005
|
||||||
|
|
||||||
|
### 11.41 采集源管理(单视频类,自助添加)
|
||||||
|
|
||||||
|
采集源 = "分类名 + 列表页 URL 模板",URL 必须包含 `{{page}}` 页码占位符;创建/改名时按名称自动建/找分类。
|
||||||
|
|
||||||
|
| 方法 | 路径 | 角色 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | /admin/collection/sources | operator | 全部源列表(id/name/list_url/category_id/status/last_sync_at/created_at) |
|
||||||
|
| POST | /admin/collection/sources | operator | 新增源:`name`(1-50)、`list_url`(须含 `{{page}}`,否则 1001) |
|
||||||
|
| PUT | /admin/collection/sources/{source_id} | operator | 改 `name` / `list_url` / `status`(0停用 1启用) |
|
||||||
|
| DELETE | /admin/collection/sources/{source_id} | super | 删除源 |
|
||||||
|
| POST | /admin/collection/sources/batch | operator | 批量添加:body `{ "text": "分类名 URL\n..." }` 每行一条(URL 可带反引号);返回 `created/skipped/failed` 清单,新建源自动触发后台采集 |
|
||||||
|
| POST | /admin/collection/sources/{source_id}/sync | operator | 同步单个源(后台异步):入参 `max_pages`(0=不限,增量自动提前停止,上限 200);该源同步中返回 1005 |
|
||||||
|
| POST | /admin/collection/sources/sync-all | operator | 同步全部启用源(后台异步),返回 `{ "started": [...], "skipped_running": [...] }` |
|
||||||
|
|
||||||
|
### 11.42 分类管理
|
||||||
|
|
||||||
|
| 方法 | 路径 | 角色 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | /admin/categories | operator | 全部启用分类(不分页,供下拉框):`[{id, name}]` |
|
||||||
|
| GET | /admin/categories/admin | operator | 分页列表(keyword 模糊),含 `sort` / `status` / 实时统计的 `video_count` |
|
||||||
|
| POST | /admin/categories | super | 新建:`name`、`sort`(默认0)、`status`(默认1);重名返回错误 |
|
||||||
|
| PUT | /admin/categories/{cid} | super | 改 `name` / `sort` / `status` |
|
||||||
|
| DELETE | /admin/categories/{cid} | super | 删除;分类下仍有视频时拒绝并提示数量 |
|
||||||
|
|
||||||
|
### 11.43 标签管理
|
||||||
|
|
||||||
|
| 方法 | 路径 | 角色 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | /admin/tags | operator | 分页列表(keyword 模糊,page_size 默认 50 最大 200),`video_count` 由 video_tags 实时统计 |
|
||||||
|
| POST | /admin/tags | super | 新建:`name`(去空格,空名报错);已存在返回错误 |
|
||||||
|
| PUT | /admin/tags/{tid} | super | 改名 |
|
||||||
|
| DELETE | /admin/tags/{tid} | super | 删除标签并清理全部视频关联 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. App 端关键调用时序
|
## 12. App 端关键调用时序
|
||||||
|
|||||||
+270
-124
@@ -1,62 +1,166 @@
|
|||||||
# 04-部署运行文档
|
# 04-部署运行文档
|
||||||
|
|
||||||
> 本文说明如何在服务器上把项目跑起来。项目共三端:**服务端**(FastAPI,无需编译)、**管理后台**(Vue3,构建为静态文件)、**App**(Flutter,**必须编译**出 APK/IPA)。
|
> 本文记录**真实生产环境**的部署架构与运维操作手册(截至 2026-10-18)。
|
||||||
|
> 服务器:腾讯云 CVM(14G 内存),已装宝塔面板;系统 Linux。
|
||||||
|
> 项目根目录:`/root/project/video`,三个业务域名均启用 HTTPS。
|
||||||
|
|
||||||
## 1. 组成与依赖
|
## 1. 总体架构
|
||||||
|
|
||||||
| 端 | 目录 | 技术栈 | 运行/构建产物 |
|
```
|
||||||
|---|---|---|---|
|
┌─────────────────────────────────────────┐
|
||||||
| 服务端 | `server/` | Python ≥ 3.11 + FastAPI + MySQL 8 + Redis 7 | 直接运行,无需编译 |
|
用户浏览器/App │ 腾讯云服务器 │
|
||||||
| 管理后台 | `admin/` | Node ≥ 18 + Vue3 + Vite | `npm run build` → `admin/dist/` 静态文件 |
|
│ │
|
||||||
| App | `app/` | Flutter 3.x(Dart ≥ 3.9) | `flutter build apk` / `flutter build ipa` |
|
h5.256kb.top ───────► │ nginx(宝塔) ──► /root/project/video/h5 │
|
||||||
|
(下载页/邀请落地页) │ ├─ /downloads /videos /uploads │
|
||||||
|
│ │ → /root/project/video/runtime/* │
|
||||||
|
│ └─ /r/ /api/v1/invite/ ──┐ │
|
||||||
|
vm.256kb.top ───────► │ root:/root/project/video/admin/dist │
|
||||||
|
(管理后台 SPA) │ /api/ /static/ /r/ ──────┤ │
|
||||||
|
│ ▼ │
|
||||||
|
vapi.256kb.top ──────► │ 全量反代 ──────────► uvicorn 127.0.0.1:8000
|
||||||
|
(App API 入口) │ (video-api.service,│
|
||||||
|
│ 2 workers) │
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌───────────┘ └────┐ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ docker: mysql8 docker: my-redis
|
||||||
|
│ 127.0.0.1:3307 127.0.0.1:6379
|
||||||
|
│ 数据卷 /opt/mysql8-data 配置/数据 /home/redis
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
|
||||||
服务器需先装好:MySQL 8(utf8mb4)、Redis 7、Python 3.11+、Nginx(托管后台 + 反向代理)。
|
视频 ts 分片流量:App → 直连源站 CDN(不经过本服务器,见 02-技术文档)
|
||||||
|
```
|
||||||
|
|
||||||
## 2. 服务端部署(server/)
|
| 域名 | 用途 | nginx 要点 |
|
||||||
|
|---|---|---|
|
||||||
|
| `h5.256kb.top` | App 下载页 + 邀请落地页 + 静态媒体 | root=`/root/project/video/h5`;`/downloads/` `/videos/` `/uploads/` alias 到 `runtime/`;`/r/`、`/api/v1/invite/` 反代 8000 |
|
||||||
|
| `vm.256kb.top` | 管理后台 | root=`/root/project/video/admin/dist`(SPA 回退 index.html);`/api/` `/static/` `/r/` 反代 8000 |
|
||||||
|
| `vapi.256kb.top` | App 业务 API | 全量反代 127.0.0.1:8000 |
|
||||||
|
| `codeserver.256kb.top` | code-server(运维用,默认容器未自启) | 反代 code-server 容器 |
|
||||||
|
|
||||||
### 2.1 安装依赖
|
证书:Let's Encrypt ECC,由 acme.sh/宝塔申请到 `/www/server/panel/vhost/cert/<域名>/fullchain.pem`,crontab 有续期任务(`5 4,10,16,22 * * * acme.sh --cron` + 宝塔 acme_v2.py),80 端口仅用于 acme 挑战与跳转 HTTPS。
|
||||||
|
|
||||||
|
## 2. 目录规划
|
||||||
|
|
||||||
|
| 路径 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `/root/project/video/server` | FastAPI 后端源码 + `.venv` 虚拟环境 + `.env` + `config.yaml` |
|
||||||
|
| `/root/project/video/admin` | Vue3 后台源码;构建产物 `admin/dist/` 即 vm 站点根目录 |
|
||||||
|
| `/root/project/video/app` | Flutter App 源码(编译机即本服务器) |
|
||||||
|
| `/root/project/video/h5` | H5 下载页(`index.html`) |
|
||||||
|
| `/root/project/video/runtime` | 运行期产出:`downloads/`(APK)、`videos/`(转码视频)、`uploads/`(上传文件) |
|
||||||
|
| `/root/project/video/docs` | 项目文档(本目录) |
|
||||||
|
| `/root/project/video/deploy.sh` | App 一键发版脚本(见 §7) |
|
||||||
|
| `/root/project/video/project-ctl.sh` | 项目一键启停脚本(见 §8) |
|
||||||
|
| `/opt/mysql8-data` | MySQL 8 数据卷(Docker 挂载) |
|
||||||
|
| `/home/redis/conf`、`/home/redis/data` | Redis 配置与数据(Docker 挂载) |
|
||||||
|
| `/root/backups/video-app-signing` | App 签名 keystore 备份(**极其重要**,见 §7.3) |
|
||||||
|
|
||||||
|
## 3. Docker 组件(MySQL / Redis)
|
||||||
|
|
||||||
|
两个容器均为 `--restart unless-stopped`:服务器重启自动拉起;**手动 `docker stop` 后重启不会恢复**(project-ctl.sh stop 利用此语义实现"暂停")。
|
||||||
|
|
||||||
|
### 3.1 MySQL 8(容器名 mysql8)
|
||||||
|
|
||||||
|
- 镜像 `mysql:8`(当前 8.4.11),端口映射 **宿主机 3307 → 容器 3306**
|
||||||
|
- 数据卷 `/opt/mysql8-data:/var/lib/mysql`,时区 Asia/Shanghai
|
||||||
|
- 业务库 `video`,账号 `root` / 密码 `256kb.cn`
|
||||||
|
- **连接必须加 `-h 127.0.0.1`**:不加时 mysql 客户端走 unix socket 会连到宿主机旧版 MySQL 5.7,曾导致 deploy.sh 把版本号写错库
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
# 日常连接
|
||||||
python3.11 -m venv .venv
|
mysql -h 127.0.0.1 -uroot -p256kb.cn -P 3307 video
|
||||||
source .venv/bin/activate
|
# 或容器内
|
||||||
pip install . # 核心依赖
|
docker exec -it mysql8 mysql -uroot -p256kb.cn video
|
||||||
pip install ".[pay]" # 可选:支付渠道 SDK(支付宝/微信/Tron),不接支付可跳过
|
|
||||||
|
# 如需重建容器(数据在 /opt/mysql8-data,不丢)
|
||||||
|
docker run -d --name mysql8 --restart unless-stopped \
|
||||||
|
-p 3307:3306 -v /opt/mysql8-data:/var/lib/mysql \
|
||||||
|
-e TZ=Asia/Shanghai -e MYSQL_ROOT_PASSWORD=256kb.cn mysql:8
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 准备数据库与 .env
|
### 3.2 Redis(容器名 my-redis)
|
||||||
|
|
||||||
```sql
|
- 镜像 `redis:latest`(7.x),端口 **6379**,挂载 `/home/redis/conf` 与 `/home/redis/data`
|
||||||
CREATE DATABASE video CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
- 启动命令 `redis-server /usr/local/etc/redis/redis.conf`
|
||||||
|
- **注意:宿主机曾装过 Redis 5.0.3 并已禁用自启**。若它抢占了 6379,新版 redis-py 客户端默认 RESP3 的 `HELLO` 握手会失败 → 后端全接口 500。排查时先确认 `docker ps` 里 my-redis 在跑、且 6379 被 docker-proxy 占用
|
||||||
|
- 后端 redis-py 客户端显式指定 `protocol=2`(RESP2),新旧服务端都兼容
|
||||||
|
- 清业务缓存一律容器内执行:`docker exec my-redis redis-cli DEL config:system`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 如需重建容器
|
||||||
|
docker run -d --name my-redis --restart unless-stopped \
|
||||||
|
-p 6379:6379 -v /home/redis/conf:/usr/local/etc/redis -v /home/redis/data:/data \
|
||||||
|
redis:latest redis-server /usr/local/etc/redis/redis.conf
|
||||||
```
|
```
|
||||||
|
|
||||||
在 `server/.env` 配置环境项(不入库的连接串/密钥):
|
## 4. 后端服务(video-api.service)
|
||||||
|
|
||||||
|
### 4.1 systemd 单元(/etc/systemd/system/video-api.service)
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
APP_ENV=prod # prod 时 /docs 自动关闭
|
[Unit]
|
||||||
APP_HOST=0.0.0.0
|
Description=Video App FastAPI Backend (uvicorn)
|
||||||
APP_PORT=8000
|
After=network.target docker.service redis.service
|
||||||
DATABASE_URL=mysql+aiomysql://user:pass@127.0.0.1:3306/video?charset=utf8mb4
|
Requires=docker.service
|
||||||
REDIS_URL=redis://127.0.0.1:6379/0
|
|
||||||
JWT_SECRET=换成足够长的随机串 # 生产必须更换
|
|
||||||
|
|
||||||
# 以下按需配置(不配置则对应功能自动降级:短信/邮件发不出去、支付走 dev_mock、USDT 不启用)
|
[Service]
|
||||||
# ALIPAY_APP_ID / ALIPAY_PRIVATE_KEY / ALIPAY_PUBLIC_KEY / ALIPAY_NOTIFY_URL
|
Type=simple
|
||||||
# WECHAT_MCHID / WECHAT_APP_ID / WECHAT_API_V3_KEY / WECHAT_PRIVATE_KEY / WECHAT_CERT_SERIAL_NO / WECHAT_PLATFORM_CERT / WECHAT_NOTIFY_URL
|
User=root
|
||||||
# TRON_API_KEY(USDT 链上查账)
|
WorkingDirectory=/root/project/video/server
|
||||||
# SMS_ACCESS_KEY_ID / SMS_ACCESS_KEY_SECRET / SMS_SIGN_NAME(短信验证码)
|
ExecStart=/root/project/video/server/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 2
|
||||||
# SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASSWORD(邮箱验证码)
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
```
|
```
|
||||||
|
|
||||||
业务参数(积分/会员/激励/提现规则)在 `server/config.yaml`,其中部分项上线后可在管理后台「系统配置」热修改。
|
- 只监听 **127.0.0.1:8000**,对外全靠 nginx 反代
|
||||||
|
- `--workers 2`:⚠️ 结算周期任务(推荐激活/创作收益/超时关单)随每个 worker 的 lifespan 各启动一份(`app/services/settle_service.py`)。当前任务均为条件更新、重复执行无副作用;**修改周期任务逻辑时必须保证幂等**
|
||||||
|
- 日志走 journald:`journalctl -u video-api -f`
|
||||||
|
|
||||||
### 2.3 初始化表结构 + 首个管理员
|
常用命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
systemctl restart video-api # 改后端代码后重启
|
||||||
.venv/bin/alembic upgrade head # 建 27 张表
|
systemctl enable --now video-api # 启动并恢复开机自启
|
||||||
|
journalctl -u video-api -n 100 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
# 创建后台管理员(代码不自动播种,需手动执行一次)
|
### 4.2 环境配置(server/.env)
|
||||||
|
|
||||||
|
键清单(敏感值不入文档;`.env` 本身不入 git):
|
||||||
|
|
||||||
|
```
|
||||||
|
APP_ENV=prod APP_HOST APP_PORT
|
||||||
|
DATABASE_URL=mysql+aiomysql://root:256kb.cn@127.0.0.1:3307/video?charset=utf8mb4
|
||||||
|
REDIS_URL=redis://127.0.0.1:6379/0
|
||||||
|
CELERY_BROKER_URL CELERY_RESULT_BACKEND # 预留,当前未启用
|
||||||
|
JWT_SECRET ACCESS_TOKEN_TTL REFRESH_TOKEN_TTL ADMIN_TOKEN_TTL
|
||||||
|
OSS_ENDPOINT OSS_BUCKET OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET # 未启用则为占位
|
||||||
|
ALIPAY_APP_ID ALIPAY_PRIVATE_KEY ALIPAY_PUBLIC_KEY # 支付占位→dev_mock
|
||||||
|
WECHAT_MCHID WECHAT_APP_ID WECHAT_PRIVATE_KEY
|
||||||
|
TRON_API_KEY # USDT 查账
|
||||||
|
SMSBAO_USERNAME SMSBAO_API_KEY SMS_SIGN_NAME # 短信宝验证码
|
||||||
|
SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASSWORD # 邮箱验证码(已启用)
|
||||||
|
```
|
||||||
|
|
||||||
|
业务参数在 `server/config.yaml`(顶层键:`points` / `membership` / `recharge` / `referral` / `asset` / `withdraw` / `upload`),其中白名单项可在后台「系统配置」热改。
|
||||||
|
|
||||||
|
### 4.3 数据库初始化(新环境一次性)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/project/video/server
|
||||||
|
python3.11 -m venv .venv && .venv/bin/pip install .
|
||||||
|
docker exec mysql8 mysql -uroot -p256kb.cn -e \
|
||||||
|
"CREATE DATABASE IF NOT EXISTS video CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||||
|
.venv/bin/alembic upgrade head # 4 个迁移,建 30 张业务表(含 series 补漏迁移 e7a1f3c58d02)
|
||||||
|
```
|
||||||
|
|
||||||
|
创建首个超管(代码不自动播种):
|
||||||
|
|
||||||
|
```bash
|
||||||
.venv/bin/python - <<'EOF'
|
.venv/bin/python - <<'EOF'
|
||||||
import asyncio
|
import asyncio
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||||
@@ -77,127 +181,169 @@ asyncio.run(main())
|
|||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.4 启动
|
后台角色:`super`(全部权限)、`operator`(仪表盘/套餐/赠送)、`reviewer`(视频审核)。
|
||||||
|
|
||||||
```bash
|
### 4.4 服务暴露路径(反代依据)
|
||||||
cd server
|
|
||||||
.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
- 健康检查:`curl http://127.0.0.1:8000/health` → `{"status":"ok"}`
|
|
||||||
- **生产请单 worker 运行**(不要加 `--workers > 1`):结算周期任务(推荐激活/创作收益/超时关单)随进程内 lifespan 启动,多 worker 会重复执行。
|
|
||||||
- 生产建议用 systemd/supervisor 托管进程。
|
|
||||||
|
|
||||||
服务暴露的路径(Nginx 反代时用):
|
|
||||||
|
|
||||||
| 路径 | 说明 |
|
| 路径 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `/api/v1/*` | App 全部业务接口 |
|
| `/api/v1/*` | App 业务接口(9 个路由文件 + admin,共 100+ 端点) |
|
||||||
| `/api/v1/admin/*` | 管理后台接口 |
|
| `/api/v1/admin/*` | 管理后台接口 |
|
||||||
| `/r/{code}` | 推广 H5 落地页(邀请链接) |
|
| `/r/{code}` | 邀请落地页 |
|
||||||
| `/static/*` | 本地文件存储(头像/封面/视频,未接 OSS 时) |
|
| `/static/*` | 本地媒体文件 |
|
||||||
| `/health` | 健康检查 |
|
| `/health` | 健康检查 |
|
||||||
|
|
||||||
## 3. 管理后台部署(admin/)
|
## 5. 管理后台发布(admin/)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd admin
|
cd /root/project/video/admin
|
||||||
npm ci
|
npm ci
|
||||||
npm run build # 产物在 admin/dist/
|
npm run build # 产物直接覆盖 admin/dist/,即 vm 站点根目录
|
||||||
```
|
```
|
||||||
|
|
||||||
Nginx 参考配置(同一站点托管后台 + 反代 API):
|
- 纯静态站点,**构建即发布,无需重启任何服务**;浏览器强刷(Ctrl+F5)生效
|
||||||
|
- 前端 API baseURL 为 `/api/v1`,由 vm 站点的 `/api/` 反代到后端
|
||||||
|
- 菜单按角色显示:仪表盘/套餐/赠送=operator,视频审核=reviewer,其余=super
|
||||||
|
|
||||||
|
## 6. nginx 配置(宝塔管理)
|
||||||
|
|
||||||
|
配置文件:`/www/server/panel/vhost/nginx/{h5,vm,vapi,codeserver}.256kb.top.conf`。
|
||||||
|
改配置在宝塔面板「网站 → 设置 → 配置文件」编辑,或改文件后 `nginx -t && nginx -s reload`。
|
||||||
|
|
||||||
|
关键规则摘要:
|
||||||
|
|
||||||
|
**h5.256kb.top**
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
root /root/project/video/h5; # index.html 下载页
|
||||||
listen 443 ssl;
|
location /downloads/ { alias /root/project/video/runtime/downloads/; } # APK
|
||||||
server_name admin.example.com;
|
location /videos/ { alias /root/project/video/runtime/videos/; }
|
||||||
# ssl_certificate / ssl_certificate_key ...
|
location /uploads/ { alias /root/project/video/runtime/uploads/; }
|
||||||
|
location /r/ { proxy_pass http://127.0.0.1:8000; } # 邀请落地
|
||||||
root /path/to/video/admin/dist;
|
location /api/v1/invite/ { proxy_pass http://127.0.0.1:8000; }
|
||||||
index index.html;
|
|
||||||
|
|
||||||
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; } # 推广落地页
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
浏览器访问 `https://admin.example.com`,用 2.3 创建的账号登录。
|
**vm.256kb.top**
|
||||||
|
|
||||||
## 4. App 编译(app/)—— 需要编译
|
```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; }
|
||||||
|
```
|
||||||
|
|
||||||
App 是 Flutter 工程,**必须编译出安装包**分发给用户,服务器上不运行 App。
|
**vapi.256kb.top**:`location / { proxy_pass http://127.0.0.1:8000; }` 全量反代。
|
||||||
|
|
||||||
### 4.1 关键:App 如何找到服务器(不硬编码域名)
|
站点日志在 `/www/wwwlogs/<域名>.log`(排查 App 接口 401/500 时非常管用)。
|
||||||
|
|
||||||
启动时按优先级确定 API Base URL([api_domain.dart](../app/lib/core/network/api_domain.dart)):
|
## 7. App 编译与发版
|
||||||
|
|
||||||
1. **`--dart-define=API_BASE` 编译期指定**(测试/自建最简单);
|
### 7.1 域名发现(不硬编码 API 地址)
|
||||||
2. 本地缓存的上次成功域名;
|
|
||||||
3. DNS TXT 发现:解析 `txt.256kb.top` 的 TXT 记录,记录内容即 Base URL 主体;
|
|
||||||
4. 兜底域名:`md5(yyyyMM) + "_jsnbhh.vip"`(每月变化)。
|
|
||||||
|
|
||||||
> 注意:客户端业务路径**不带** `/api/v1` 前缀,因此 Base URL 必须包含前缀,例如 `https://api.example.com/api/v1`(TXT 记录内容写 `api.example.com/api/v1` 即可,客户端自动补 `https://`)。
|
启动时优先级:`--dart-define=API_BASE` > 本地缓存 > DNS TXT(`txt.256kb.top`) > 兜底域名(md5(yyyyMM)+后缀)。
|
||||||
|
正式包**不带 dart-define 直接构建**,走 TXT 发现(当前指向 `vapi.256kb.top/api/v1`)。
|
||||||
|
|
||||||
上线二选一:
|
### 7.2 一键发版 deploy.sh
|
||||||
|
|
||||||
- **方式 A(推荐,免改代码)**:把 `txt.256kb.top` 的 TXT 记录指到你的 API 入口(内容:`api.example.com/api/v1`),并按第 3 节方式用 Nginx 把 `/api/v1/` 反代到 uvicorn。
|
|
||||||
- **方式 B(自己的域名体系)**:修改 [app_config.dart](../app/lib/core/config/app_config.dart) 的 `dnsTxtHost` / `fallbackDomainSuffix` 为你控制的域名后重新编译。
|
|
||||||
|
|
||||||
### 4.2 Android
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd app
|
# 1. 改 app/pubspec.yaml 的 version(如 1.3.7+23,+后为 versionCode)
|
||||||
flutter pub get
|
# 2. 执行
|
||||||
# 联调/内测:直连服务器
|
bash /root/project/video/deploy.sh
|
||||||
flutter build apk --release --dart-define=API_BASE=https://api.example.com/api/v1
|
|
||||||
# 正式(走 TXT 域名发现):不带 dart-define 直接构建
|
|
||||||
flutter build apk --release
|
|
||||||
```
|
```
|
||||||
|
|
||||||
产物:`build/app/outputs/flutter-apk/app-release.apk`,直接分发安装即可。
|
脚本自动完成:`flutter analyze`(warning 不阻断)→ `flutter build apk --release` → `gradlew --stop` 清理 Gradle Daemon(防内存泄漏)→ 覆盖 `runtime/downloads/video-app.apk` → 同步 DB `system_configs.app.version.android`(latest_version/code 更新、**min_version_code 保留**、连接指定 `-h 127.0.0.1`)→ `docker exec my-redis` 清配置缓存。
|
||||||
|
老用户打开 App 触发 `/api/v1/app/version` 检查 → 弹更新提示 → 跳 h5 下载页。强制更新:把 `min_version_code` 调大于用户版本。
|
||||||
|
|
||||||
### 4.3 iOS
|
### 7.3 正式签名(2026-10-17 起,v1.3.7+23 为首个正式签名版)
|
||||||
|
|
||||||
```bash
|
| 项 | 值 |
|
||||||
flutter build ipa --release
|
|
||||||
```
|
|
||||||
|
|
||||||
需要 macOS + Xcode + Apple 开发者证书签名(本机未装 Xcode 时可只交付 Android)。iOS 端上架前在管理后台「系统配置」确认 `ios_payment.enabled` 开关(规避苹果虚拟支付审核)。
|
|
||||||
|
|
||||||
### 4.4 支付渠道打包注意
|
|
||||||
|
|
||||||
`app/pubspec.yaml` 中 `tobias.url_scheme`、`fluwx.app_id`、两处 `universal_link` 是占位值,**正式支付上线前必须替换为商户申请的真实值**并重新编译;未接真实支付时服务端返回 `dev_mock`,App 自动走轮询查单兜底。
|
|
||||||
|
|
||||||
## 5. 上线后必改的后台配置
|
|
||||||
|
|
||||||
登录管理后台「系统配置」:
|
|
||||||
|
|
||||||
| 配置 key | 说明 |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| `app.invite_base_url` | 推广落地页地址,改为 `https://你的域名/r/`(影响 App 邀请链接) |
|
| keystore | `app/android/app/upload-keystore.jks`(PKCS12,RSA 2048,有效期 30 年) |
|
||||||
| `app.version.android` / `app.version.ios` | JSON:`latest_version` / `latest_version_code` / `min_version_code` / `update_url` / `notice`,控制版本检查与强制更新 |
|
| 密码配置 | `app/android/key.properties`(alias=`video_release`) |
|
||||||
| `ios_payment.enabled` | iOS 支付开关(审核期可关闭) |
|
| 证书指纹 SHA-256 | `8fb75f6a:3a9f:bc39:...:aef4`(完整值见 `apksigner verify --print-certs`) |
|
||||||
| 敏感词、轮播公告等 | 按需 |
|
| git | 两者均已 gitignore,**不入库** |
|
||||||
|
| 备份 | `/root/backups/video-app-signing/`(keystore + key.properties) |
|
||||||
|
|
||||||
## 6. 端到端验证清单
|
`build.gradle.kts` 逻辑:key.properties 存在 → release 签名;否则回退 debug(保证新环境可编译)。
|
||||||
|
|
||||||
|
> ⚠️ **keystore 丢失 = 永远无法给老用户推更新**。除服务器备份外,务必另存一份到本地电脑/网盘。
|
||||||
|
> ⚠️ 首个正式签名版与旧 debug 签名不一致,**老用户必须先卸载再安装**(版本 notice 已注明)。
|
||||||
|
> 侧载安装时小米等厂商会提示"未备案",勾选"已了解风险"即可继续安装;消除该提示需办理 App ICP 备案。
|
||||||
|
|
||||||
|
### 7.4 iOS
|
||||||
|
|
||||||
|
`flutter build ipa --release`(需 macOS + Xcode + 开发者证书,当前仅交付 Android)。上架前确认后台 `ios_payment.enabled` 开关规避虚拟支付审核。
|
||||||
|
|
||||||
|
## 8. 项目启停 project-ctl.sh
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 服务端
|
bash /root/project/video/project-ctl.sh start # docker mysql8/my-redis → 等待就绪 → video-api(enable+start) → 健康检查
|
||||||
curl http://127.0.0.1:8000/health # ok
|
bash /root/project/video/project-ctl.sh stop # video-api 停止并 disable + 停两个容器(重启服务器也不会拉起)
|
||||||
curl "http://127.0.0.1:8000/api/v1/videos/feed?type=hot&limit=1" # code:0
|
bash /root/project/video/project-ctl.sh restart # 停 → 启
|
||||||
curl "http://127.0.0.1:8000/api/v1/search/hot" # code:0
|
bash /root/project/video/project-ctl.sh status # 服务/容器/接口探测
|
||||||
|
|
||||||
# 管理后台:浏览器打开 → 登录 → 视频审核/用户列表有数据
|
|
||||||
|
|
||||||
# App:安装 APK → 启动页过版本检查 → Feed 出视频 → 注册/登录 → 观看计费
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 7. 已知限制(当前版本)
|
- 管理范围:`video-api.service` + `mysql8` + `my-redis`;**不含 nginx**(系统组件)
|
||||||
|
- 服务器重启后如项目处于"运行态",docker 与 systemd 会自动拉起全部组件,无需人工干预
|
||||||
|
- 项目当前(2026-10-18 起)处于**暂停态**,恢复运行执行 `start` 即可
|
||||||
|
|
||||||
- **媒体存储为本地文件**(`server/uploads`,经 `/static` 访问),接口返回相对路径;生产接入 OSS/CDN 使服务端返回绝对 URL 需二次开发(`storage_service` 已按此边界隔离)。
|
## 9. 备份策略
|
||||||
- 短信/邮件/USDT/支付宝/微信未配置密钥时自动降级(验证码发不出、支付走 dev_mock),不影响其余功能。
|
|
||||||
- CORS 当前为 `*`,生产可在 `app/main.py` 按需收紧。
|
| 对象 | 方式 | 频率建议 |
|
||||||
- 结算周期任务在 API 进程内运行,见 2.4 单 worker 说明。
|
|---|---|---|
|
||||||
|
| MySQL 数据 | `docker exec mysql8 mysqldump -uroot -p256kb.cn video \| gzip > backup-$(date +%F).sql.gz`(或宝塔计划任务) | 每日 |
|
||||||
|
| keystore + key.properties | 已备份 `/root/backups/video-app-signing/`,另离线保存 | 变更即备 |
|
||||||
|
| `runtime/`(视频/APK/上传) | 文件级备份(rsync/对象存储) | 每周 |
|
||||||
|
| `server/.env`、`config.yaml` | 纳入文件备份 | 变更即备 |
|
||||||
|
|
||||||
|
恢复:导入 sql → `alembic upgrade head` 校验版本 → 启动服务。
|
||||||
|
|
||||||
|
## 10. 日常运维与故障排查
|
||||||
|
|
||||||
|
### 10.1 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u video-api -f # 后端日志
|
||||||
|
docker logs -f mysql8 / my-redis # 容器日志
|
||||||
|
tail -f /www/wwwlogs/vapi.256kb.top.log # App 接口访问日志
|
||||||
|
docker exec my-redis redis-cli DEL config:system # 清系统配置缓存
|
||||||
|
docker exec my-redis redis-cli KEYS 'auth:*' # 查登录态键
|
||||||
|
free -h && docker stats --no-stream # 资源占用
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 历史故障速查表
|
||||||
|
|
||||||
|
| 症状 | 根因 | 处置 |
|
||||||
|
|---|---|---|
|
||||||
|
| 后端全接口 500,Redis 报错 | 服务器重启后 my-redis 未自启,宿主机 Redis 5.0.3 抢 6379,RESP3 握手失败 | 停本机 redis 自启;docker start my-redis;客户端已固定 protocol=2 |
|
||||||
|
| 重启后服务缺失/端口错乱 | Docker 容器未配 restart 策略 | 两容器均已 `unless-stopped` |
|
||||||
|
| App 更新提示反复弹 | 硬编码版本号与 APK 不一致 | 已改用 package_info_plus 读真实版本号 |
|
||||||
|
| 发版后 DB 版本没更新 | deploy.sh mysql 未加 `-h 127.0.0.1` 写错宿主机库 | 已修复;注意检查 |
|
||||||
|
| 编译后内存爆满 | Gradle Daemon 常驻 | deploy.sh 已自动 `gradlew --stop` |
|
||||||
|
| IDE 里 OpenJDK 吃内存 | Trae 的 fwcd.kotlin 扩展语言服务器 | 禁用该扩展并重载窗口 |
|
||||||
|
| 编译卡死无输出 | Gradle 进程僵死 | 杀掉重跑即可 |
|
||||||
|
| 会员/钱包页加载失败 | 僵尸登录态(access 过期+无 refresh) | App 端已修复(401 清态+启动检测);临时:退出重登 |
|
||||||
|
| 切"我的"仍听到视频声 | 播放器池 open 竞态 | 已修复(suspend/resume) |
|
||||||
|
| 进度条不走/拖动无效 | 本地代理未支持 Range | 已修复(Accept-Ranges + 206) |
|
||||||
|
|
||||||
|
### 10.3 端到端验证清单(发版/重启后)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/health # {"status":"ok"}
|
||||||
|
curl "http://127.0.0.1:8000/api/v1/videos/feed?type=hot&limit=1" # code:0
|
||||||
|
curl -I https://h5.256kb.top/downloads/video-app.apk # 200
|
||||||
|
# 浏览器开 https://vm.256kb.top → 登录 → 仪表盘有数据
|
||||||
|
# App:启动过版本检查 → Feed 出视频 → 登录 → 播放/计费正常
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. 新环境从零部署速查
|
||||||
|
|
||||||
|
1. 装 Docker / Python3.11 / Node18+ / Flutter / nginx(或宝塔)
|
||||||
|
2. 建 mysql8、my-redis 容器(§3 命令)
|
||||||
|
3. `server/` 建 venv、装依赖、写 `.env`、`alembic upgrade head`、建超管(§4.3)
|
||||||
|
4. 写 systemd unit(§4.1)→ `systemctl enable --now video-api`
|
||||||
|
5. `admin/` 构建 dist;配三个 nginx 站点 + 证书(§6)
|
||||||
|
6. App:放入 keystore + key.properties → `deploy.sh` 发版(§7)
|
||||||
|
7. 后台「系统配置」:`app.invite_base_url`、`app.version.android/ios` 等(见 03 接口文档配置键)
|
||||||
|
8. 跑 §10.3 验证清单
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# 05-功能流程文档
|
||||||
|
|
||||||
|
> 本文按"角色 → 端到端流程"梳理当前项目的全部业务功能(截至 2026-10-18,App v1.3.7)。
|
||||||
|
> 技术实现细节见《02-技术文档》,接口字段见《03-接口文档》,数值参数以 `server/config.yaml` 与后台「系统配置」为准。
|
||||||
|
|
||||||
|
## 1. 角色与端
|
||||||
|
|
||||||
|
| 角色 | 使用端 | 能力 |
|
||||||
|
|---|---|---|
|
||||||
|
| 游客 | App | 每日免费观看 10 部、浏览/搜索(部分接口需登录) |
|
||||||
|
| 注册用户 | App | 观看计费、点赞/收藏/评论、充值/会员、邀请、上传(若开放)、提现 |
|
||||||
|
| 会员 | App | 有效期内观看不扣积分 |
|
||||||
|
| 管理员 | 后台 vm.256kb.top | super=全部;operator=仪表盘/套餐/赠送;reviewer=视频审核 |
|
||||||
|
|
||||||
|
## 2. 账号体系流程
|
||||||
|
|
||||||
|
### 2.1 注册
|
||||||
|
|
||||||
|
```
|
||||||
|
App 注册页(手机号/邮箱 Tab 切换)
|
||||||
|
├─ 手机号注册 → /auth/sms/send(短信宝)→ 填验证码+密码(+邀请码) → 注册
|
||||||
|
└─ 邮箱注册 → /auth/email/send(SMTP)→ 填验证码+密码(+邀请码) → 注册
|
||||||
|
│
|
||||||
|
├─ 验证码写 Redis(5 分钟有效,防刷频控)
|
||||||
|
├─ 填邀请码 → 建立 referral 绑定(风控:同人单日绑定≤10、同 IP 单日注册≤5)
|
||||||
|
└─ 可选「注册送试用会员」:后台开关 register_gift_trial.enabled / .days
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 登录与登录态
|
||||||
|
|
||||||
|
- 登录账号支持**手机号或邮箱**(不支持昵称);密码 bcrypt 校验
|
||||||
|
- 令牌体系:`access_token`(短效)+ `refresh_token`(长效,Redis 白名单)
|
||||||
|
- App 收到 401 → 自动用 refresh 换新 access;**refresh 也失效/缺失 → 清登录态回游客**(防僵尸登录态)
|
||||||
|
- 全端下线机制:改密/后台改密后 `auth:epoch:{uid}` 递增并删除 refresh,旧 token 全部立即失效
|
||||||
|
- 后台管理员独立 `admin_token`(含角色, super 放行一切)
|
||||||
|
|
||||||
|
## 3. 观看与计费流程(核心)
|
||||||
|
|
||||||
|
```
|
||||||
|
打开 App → Splash(版本检查/app/version → 必要时弹更新)
|
||||||
|
→ Feed(推荐/最新/热度/口碑/热播 五 Tab,各自保活、独立翻页)
|
||||||
|
→ 进入某视频:
|
||||||
|
1. 播放器池加载(当前播放 + 下一个预缓冲;ts 走 App 内嵌缓存代理,命中免下载)
|
||||||
|
2. 计费判定(仅登录用户):
|
||||||
|
├─ 会员有效期内 → 免费
|
||||||
|
├─ 今日免费额度未用完(10 部/日)→ 免费,额度-1
|
||||||
|
├─ 该视频今日已付过费(去重) → 免费
|
||||||
|
└─ 否则扣 1 积分/部;积分不足 → 弹充值引导
|
||||||
|
3. 观看上报 → watch_records(完播判定:进度≥95%)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 数值:`points.daily_free_count=10`、`points.watch_cost=1`、`asset.complete_progress=0.95`
|
||||||
|
- 热度公式:`(播放×1 + 点赞×5 + 评论×10) ÷ (1 + 发布天数)`;口碑=按点赞量、热播=按播放量
|
||||||
|
- 评论奖励:每评论 10 个不同视频奖 1 积分(≥5 字,日限 5 次)
|
||||||
|
|
||||||
|
## 4. 充值与会员流程
|
||||||
|
|
||||||
|
### 4.1 积分充值
|
||||||
|
|
||||||
|
```
|
||||||
|
App「钱包/充值」→ 选套餐(后台可配 recharge_packages,1 元=10 积分,支持自定义金额)
|
||||||
|
→ 下单(recharge_orders,30 分钟超时自动关单)
|
||||||
|
→ 支付渠道:支付宝 / 微信 / USDT-TRC20(rate 7.2,19 确认)
|
||||||
|
(未配置真实密钥时服务端返回 dev_mock,App 走轮询查单兜底)
|
||||||
|
→ 回调/查单到账 → 积分入账(point_transactions)
|
||||||
|
→ 若该用户有邀请人 → 充值额的 5% 以资产返利给邀请人
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 会员
|
||||||
|
|
||||||
|
- 档位:试用 1 天 5 积分 / 月 30 天 300 / 年 365 天 3650 / 永久 10000(均可后台开关)
|
||||||
|
- 用**积分**兑换 → 写 user_membership + membership_orders;有效期内观看不扣积分
|
||||||
|
- 管理员可在后台「赠送管理」直接赠送会员/积分(operator 权限)
|
||||||
|
|
||||||
|
## 5. 邀请裂变流程(referral)
|
||||||
|
|
||||||
|
```
|
||||||
|
用户 A 分享邀请链接(h5.256kb.top/r/{code})→ 用户 B 打开落地页下载 App
|
||||||
|
→ B 注册时带邀请码 → 绑定 referral
|
||||||
|
→ A 立即得 10 积分(register_reward_points,不限量)
|
||||||
|
→ B 在 72h 内观看≥3 个视频 → referral「激活」(周期任务结算)
|
||||||
|
→ B 每次充值 → A 得充值积分 5% 的资产返利
|
||||||
|
风控:A 单日新绑定>10 → 冻结审核;同 IP 单日注册>5 → 标记风险;后台「推荐风控」可人工处理
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 创作激励与提现流程
|
||||||
|
|
||||||
|
```
|
||||||
|
作者发布视频(见 §8)→ 视频产生数据
|
||||||
|
→ 激励结算(周期任务):
|
||||||
|
每 1000 次完整观看 = 1 资产;每 100 次点赞 = 1 资产(video_earnings → asset_wallets)
|
||||||
|
→ 资产价值:10 资产 = 1 元
|
||||||
|
→ 提现:满 100 资产(=10 元)可提,渠道 支付宝/微信/USDT,当前手续费 0
|
||||||
|
→ withdrawals 提交 → 后台「提现审核」人工通过/驳回 → 打款登记(asset_transactions 全程流水)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 互动功能流程
|
||||||
|
|
||||||
|
- **点赞/收藏**:即时生效,计入视频计数与热度分;收藏还用于推荐策略的标签偏好(收藏+5 > 点赞+3 > 观看+1)
|
||||||
|
- **评论**:发布前过**敏感词过滤**(后台可配词库与开关 `sensitive.filter.enabled`);后台可删评
|
||||||
|
- **举报**:用户举报视频/评论 → reports → 后台「举报处理」(待处理/已处理/已驳回)
|
||||||
|
- **通知**:审核结果、结算、系统公告 → notifications → App 消息中心(未读数)
|
||||||
|
|
||||||
|
## 8. 内容供给流程(三条路)
|
||||||
|
|
||||||
|
### 8.1 用户/管理员上传
|
||||||
|
|
||||||
|
```
|
||||||
|
上传 mp4/mov/mkv/avi(≤500MB,时长 5~600s)
|
||||||
|
→ transcode_tasks 转码 HLS 720p(4s 分片)
|
||||||
|
→ videos.status=1 待审核 → 后台「视频审核」(reviewer) 通过/拒绝(拒绝填原因回显作者)
|
||||||
|
→ 通过:status=2 上架、通知作者、进入各 Feed 池
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 采集源同步(当前内容主力,约 1.8 万部)
|
||||||
|
|
||||||
|
```
|
||||||
|
后台「采集源管理」配置源站(URL 模板等)→ 手动「同步单源/全部同步」
|
||||||
|
→ collection_service 按模板拉取 → collection_records 记录明细(成功/失败/关联 video_id)
|
||||||
|
→ 入库存量视频直接上架(status=2)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 标题关键词标签(离线脚本)
|
||||||
|
|
||||||
|
```
|
||||||
|
server/scripts/build_tags_from_titles.py(幂等,可重复跑)
|
||||||
|
→ jieba TF-IDF topK=6 + 番号/书名号/括号提取 - 噪音词过滤
|
||||||
|
→ 写 tags / video_tags,回算 tags.video_count
|
||||||
|
→ App 搜索热词/标签视频页、后台「标签管理」均消费此数据
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 视频与后台管理状态机
|
||||||
|
|
||||||
|
**视频 status**:0 草稿 / 1 待审核 / 2 已上架 / 3 审核拒绝 / 4 已下架(后台可编辑标题/封面/分类/系列/剧集、上下架、删除——删除会级联清理标签/点赞/收藏/评论/观看/收益/举报)
|
||||||
|
|
||||||
|
**系列 series**:视频可绑定 series_id + episode_no 组成剧集;App 播放页提供选集;`total_episodes` 自动维护
|
||||||
|
|
||||||
|
**用户管理**:列表/详情(观看、充值、推荐关系)→ 可 封禁/解封(status 1/2/3)、调整积分(正负流水+原因)、**修改密码**(全端立即下线,记 admin_logs)
|
||||||
|
|
||||||
|
**其余后台能力**:仪表盘(今日 DAU/新增/充值/积分/播放+待审数)、积分流水、充值订单、套餐 CRUD、分类管理、提现审核、推荐风控、举报处理、敏感词、系统配置(白名单热改)、操作日志(admin_logs 全量留痕)
|
||||||
|
|
||||||
|
## 10. App 版本更新流程
|
||||||
|
|
||||||
|
```
|
||||||
|
deploy.sh 发版 → APK 覆盖 h5.256kb.top/downloads/video-app.apk
|
||||||
|
+ DB app.version.android 更新(latest/min/update_url/notice)
|
||||||
|
App 每次启动 → /app/version 比对本地真实 versionCode(package_info_plus)
|
||||||
|
├─ local < min_version_code → 强制更新弹窗(不可跳过)
|
||||||
|
├─ local < latest → 提示更新(可跳过),装新版后自动消失
|
||||||
|
└─ notice 非空 → 弹窗附带公告文案
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. 本地缓存与下载流程(App 端体验层)
|
||||||
|
|
||||||
|
```
|
||||||
|
播放:网络 m3u8 → 改写走 127.0.0.1 本地代理
|
||||||
|
├─ m3u8 每次回源(签名时效)→ ts 地址改写指向本地
|
||||||
|
├─ ts 按「去签名路径」磁盘缓存,命中直接返回(回看/重开 App 免下载)
|
||||||
|
└─ LRU 上限 1GB(超限按访问时间清到 900MB)
|
||||||
|
|
||||||
|
下载:Feed/播放页长按 →「下载」→ 拉 m3u8 → 4 并发下 ts+密钥 → 生成本地 index.m3u8
|
||||||
|
→「我的 → 已下载」列表(封面/时长/大小)→ 点击经本地代理离线播放(支持拖动)→ 可删除
|
||||||
|
```
|
||||||
Executable
+84
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ============================================================================
|
||||||
|
# 视频项目一键启停脚本
|
||||||
|
# 管理范围:video-api.service(后端) + Docker 容器 mysql8 / my-redis
|
||||||
|
# 不含 nginx(系统组件,可能服务其他站点;项目停止后接口 502 属正常)
|
||||||
|
# 用法:bash project-ctl.sh {start|stop|restart|status}
|
||||||
|
# ============================================================================
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SERVICE="video-api.service"
|
||||||
|
CONTAINERS=("mysql8" "my-redis")
|
||||||
|
|
||||||
|
wait_mysql() {
|
||||||
|
echo -n " 等待 MySQL 就绪..."
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if docker exec mysql8 mysqladmin ping -h 127.0.0.1 -uroot -p256kb.cn >/dev/null 2>&1; then
|
||||||
|
echo " OK"; return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo " 超时(30s),请检查容器日志"; return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_redis() {
|
||||||
|
echo -n " 等待 Redis 就绪..."
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
if [ "$(docker exec my-redis redis-cli ping 2>/dev/null)" = "PONG" ]; then
|
||||||
|
echo " OK"; return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo " 超时(15s)"; return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
do_start() {
|
||||||
|
echo "▶ 启动 Docker 容器: ${CONTAINERS[*]}"
|
||||||
|
docker start "${CONTAINERS[@]}" >/dev/null
|
||||||
|
wait_mysql && wait_redis || return 1
|
||||||
|
|
||||||
|
echo "▶ 启动后端服务 $SERVICE(并恢复开机自启)"
|
||||||
|
systemctl enable --now "$SERVICE"
|
||||||
|
sleep 2
|
||||||
|
local code
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8000/api/v1/app/version || true)
|
||||||
|
if [ "$code" = "200" ]; then
|
||||||
|
echo "✅ 项目已启动(接口健康检查 200)"
|
||||||
|
else
|
||||||
|
echo "⚠️ 服务已启动但健康检查返回 $code,请查看: journalctl -u $SERVICE -n 50"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
do_stop() {
|
||||||
|
echo "▶ 停止后端服务 $SERVICE(并取消开机自启,防止重启后自动拉起)"
|
||||||
|
systemctl disable --now "$SERVICE" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "▶ 停止 Docker 容器: ${CONTAINERS[*]}"
|
||||||
|
docker stop "${CONTAINERS[@]}" >/dev/null
|
||||||
|
echo " (容器策略为 unless-stopped,手动停止后服务器重启也不会自动恢复)"
|
||||||
|
echo "✅ 项目已停止"
|
||||||
|
}
|
||||||
|
|
||||||
|
do_status() {
|
||||||
|
echo "── 后端服务 ──"
|
||||||
|
systemctl status "$SERVICE" --no-pager -l | head -4
|
||||||
|
echo
|
||||||
|
echo "── Docker 容器 ──"
|
||||||
|
docker ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep -E "NAMES|mysql8|my-redis"
|
||||||
|
echo
|
||||||
|
echo "── 接口探测 ──"
|
||||||
|
local code
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 http://127.0.0.1:8000/api/v1/app/version || true)
|
||||||
|
echo " /api/v1/app/version → HTTP $code"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
start) do_start ;;
|
||||||
|
stop) do_stop ;;
|
||||||
|
restart) do_stop; echo; do_start ;;
|
||||||
|
status) do_status ;;
|
||||||
|
*)
|
||||||
|
echo "用法: bash $0 {start|stop|restart|status}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
+1
-1
@@ -12,7 +12,7 @@ CELERY_RESULT_BACKEND=redis://127.0.0.1:6379/2
|
|||||||
# ===== JWT =====
|
# ===== JWT =====
|
||||||
JWT_SECRET=0e79fddb233856d260b0072a979d483c1c07dbdb0c8c9b1d66bd3f540499093c
|
JWT_SECRET=0e79fddb233856d260b0072a979d483c1c07dbdb0c8c9b1d66bd3f540499093c
|
||||||
ACCESS_TOKEN_TTL=7200
|
ACCESS_TOKEN_TTL=7200
|
||||||
REFRESH_TOKEN_TTL=2592000
|
REFRESH_TOKEN_TTL=15552000
|
||||||
ADMIN_TOKEN_TTL=43200
|
ADMIN_TOKEN_TTL=43200
|
||||||
|
|
||||||
# ===== 对象存储 OSS(未接 OSS 时媒体走本地 /static)=====
|
# ===== 对象存储 OSS(未接 OSS 时媒体走本地 /static)=====
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""add collection_records
|
||||||
|
|
||||||
|
Revision ID: b3c7d9e21f4a
|
||||||
|
Revises: d659ecc1b6e7
|
||||||
|
Create Date: 2026-10-17 12:00:00.000000
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
revision = 'b3c7d9e21f4a'
|
||||||
|
down_revision = 'd659ecc1b6e7'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table('collection_records',
|
||||||
|
sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('source_key', sa.String(length=50), nullable=False, comment='内容源标识'),
|
||||||
|
sa.Column('source_id', mysql.BIGINT(unsigned=True), nullable=False, comment='源站内容 id'),
|
||||||
|
sa.Column('series_id', mysql.BIGINT(unsigned=True), nullable=False, comment='本站系列 id'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('uk_source', 'collection_records', ['source_key', 'source_id'], unique=True)
|
||||||
|
op.create_index('idx_record_series', 'collection_records', ['series_id'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('idx_record_series', table_name='collection_records')
|
||||||
|
op.drop_index('uk_source', table_name='collection_records')
|
||||||
|
op.drop_table('collection_records')
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""add collection_sources + collection_records.video_id
|
||||||
|
|
||||||
|
Revision ID: c8f2a15e93b7
|
||||||
|
Revises: b3c7d9e21f4a
|
||||||
|
Create Date: 2026-10-17 13:00:00.000000
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
revision = 'c8f2a15e93b7'
|
||||||
|
down_revision = 'b3c7d9e21f4a'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table('collection_sources',
|
||||||
|
sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=50), nullable=False, comment='分类名'),
|
||||||
|
sa.Column('list_url', sa.String(length=500), nullable=False, comment='列表URL模板, {{page}}为页码占位'),
|
||||||
|
sa.Column('category_id', mysql.INTEGER(unsigned=True), nullable=False, comment='本站分类id'),
|
||||||
|
sa.Column('status', mysql.SMALLINT(), nullable=False, server_default='1', comment='1启用 0停用'),
|
||||||
|
sa.Column('last_sync_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('list_url', name='uk_list_url')
|
||||||
|
)
|
||||||
|
op.add_column('collection_records', sa.Column('video_id', mysql.BIGINT(unsigned=True), nullable=True, comment='本站视频id(单视频类采集)'))
|
||||||
|
op.alter_column('collection_records', 'series_id', existing_type=mysql.BIGINT(unsigned=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.alter_column('collection_records', 'series_id', existing_type=mysql.BIGINT(unsigned=True), nullable=False)
|
||||||
|
op.drop_column('collection_records', 'video_id')
|
||||||
|
op.drop_table('collection_sources')
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""add series table(补漏:线上表已存在,此前无迁移,新环境部署会缺表)
|
||||||
|
|
||||||
|
Revision ID: e7a1f3c58d02
|
||||||
|
Revises: c8f2a15e93b7
|
||||||
|
Create Date: 2026-10-18 21:00:00.000000
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
revision = 'e7a1f3c58d02'
|
||||||
|
down_revision = 'c8f2a15e93b7'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'series',
|
||||||
|
sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=100), nullable=False, comment='系列标题'),
|
||||||
|
sa.Column('cover_url', sa.String(length=500), nullable=False, server_default='', comment='封面'),
|
||||||
|
sa.Column('description', sa.String(length=500), nullable=False, server_default='', comment='简介'),
|
||||||
|
sa.Column('author_id', mysql.BIGINT(unsigned=True), nullable=True, comment='作者'),
|
||||||
|
sa.Column('category_id', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('total_episodes', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('view_count', mysql.BIGINT(), nullable=False, server_default='0'),
|
||||||
|
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.Column('updated_at', sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.current_timestamp(),
|
||||||
|
onupdate=sa.func.current_timestamp()),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
)
|
||||||
|
op.create_index('idx_cat_status', 'series', ['category_id', 'status'])
|
||||||
|
op.create_index('idx_status_created', 'series', ['status', 'created_at'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('idx_status_created', table_name='series')
|
||||||
|
op.drop_index('idx_cat_status', table_name='series')
|
||||||
|
op.drop_table('series')
|
||||||
Binary file not shown.
+400
-1
@@ -5,12 +5,15 @@
|
|||||||
- operator:套餐、活动、看板 → 仪表盘 / 套餐 CRUD / 赠送
|
- operator:套餐、活动、看板 → 仪表盘 / 套餐 CRUD / 赠送
|
||||||
- 其余接口仅 super
|
- 其余接口仅 super
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
import redis.asyncio as aioredis
|
import redis.asyncio as aioredis
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
from pydantic import BaseModel, BaseModel as _BM, Field as _F, Field as _Field
|
from pydantic import BaseModel, BaseModel as _BM, Field as _F, Field as _Field
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.response import ok
|
from app.core import errors
|
||||||
|
from app.core.response import ok, BizException
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.deps import AdminIdentity, require_roles
|
from app.deps import AdminIdentity, require_roles
|
||||||
from app.redis_client import get_redis
|
from app.redis_client import get_redis
|
||||||
@@ -19,6 +22,8 @@ from app.services import admin_service
|
|||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["管理后台"])
|
router = APIRouter(prefix="/admin", tags=["管理后台"])
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 角色依赖:super 自动放行
|
# 角色依赖:super 自动放行
|
||||||
require_reviewer = require_roles("reviewer")
|
require_reviewer = require_roles("reviewer")
|
||||||
require_operator = require_roles("operator")
|
require_operator = require_roles("operator")
|
||||||
@@ -341,6 +346,15 @@ async def set_user_status(user_id: int, req: schema.UserStatusReq, request: Requ
|
|||||||
return ok(await admin_service.set_user_status(db, redis, admin, user_id, req, _ip(request)))
|
return ok(await admin_service.set_user_status(db, redis, admin, user_id, req, _ip(request)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/users/{user_id}/password")
|
||||||
|
async def reset_user_password(user_id: int, req: schema.UserPasswordResetReq, request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
admin: AdminIdentity = Depends(require_super)):
|
||||||
|
await admin_service.reset_user_password(db, redis, admin, user_id, req, _ip(request))
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/points/adjust")
|
@router.post("/points/adjust")
|
||||||
async def adjust_points(req: schema.PointsAdjustReq, request: Request,
|
async def adjust_points(req: schema.PointsAdjustReq, request: Request,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -558,6 +572,19 @@ async def list_series(keyword: str = "", page: int = 1, page_size: int = 20,
|
|||||||
rows = (await db.execute(
|
rows = (await db.execute(
|
||||||
stmt.order_by(Series.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
stmt.order_by(Series.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
|
|
||||||
|
# 关联采集记录(标记采集来源,供"同步更新"按钮使用)
|
||||||
|
from app.models.video import CollectionRecord
|
||||||
|
series_ids = [s.id for s in rows]
|
||||||
|
source_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}
|
||||||
|
|
||||||
return ok({
|
return ok({
|
||||||
"total": total,
|
"total": total,
|
||||||
"list": [{
|
"list": [{
|
||||||
@@ -565,6 +592,7 @@ async def list_series(keyword: str = "", page: int = 1, page_size: int = 20,
|
|||||||
"description": s.description or "",
|
"description": s.description or "",
|
||||||
"category_id": s.category_id or 0,
|
"category_id": s.category_id or 0,
|
||||||
"total_episodes": s.total_episodes, "status": s.status,
|
"total_episodes": s.total_episodes, "status": s.status,
|
||||||
|
"source_id": source_map.get(s.id),
|
||||||
"created_at": s.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
"created_at": s.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
} for s in rows],
|
} for s in rows],
|
||||||
})
|
})
|
||||||
@@ -593,6 +621,77 @@ class _VideoSeriesBind(BaseModel):
|
|||||||
episode_no: int = _Field(ge=1)
|
episode_no: int = _Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 批量操作(必须在 /series/{series_id} 路由之前注册,否则被参数路由抢先匹配)----------
|
||||||
|
|
||||||
|
class _SeriesRebuildResp(BaseModel):
|
||||||
|
total: int = 0
|
||||||
|
fixed: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/series/rebuild-counts")
|
||||||
|
async def rebuild_series_counts(db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminIdentity = Depends(require_super)):
|
||||||
|
"""重算所有系列的 total_episodes(按 status=2 的视频真实计数)"""
|
||||||
|
from app.models.video import Series, Video
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
|
||||||
|
rows = (await db.execute(select(Series.id, Series.total_episodes))).all()
|
||||||
|
total = len(rows)
|
||||||
|
fixed = 0
|
||||||
|
for sid, old_cnt in rows:
|
||||||
|
new_cnt = (await db.execute(
|
||||||
|
select(func.count(Video.id)).where(Video.series_id == sid, Video.status == 2)
|
||||||
|
)).scalar_one()
|
||||||
|
if old_cnt != new_cnt:
|
||||||
|
s = await db.get(Series, sid)
|
||||||
|
s.total_episodes = new_cnt
|
||||||
|
fixed += 1
|
||||||
|
await db.commit()
|
||||||
|
return ok(_SeriesRebuildResp(total=total, fixed=fixed))
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@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)"""
|
||||||
|
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:
|
||||||
|
raise BizException(errors.PARAM_ERROR, "至少要传一个要修改的字段")
|
||||||
|
|
||||||
|
stmt = select(Series).where(Series.id.in_(req.series_ids))
|
||||||
|
rows = (await db.execute(stmt)).scalars().all()
|
||||||
|
affected = 0
|
||||||
|
for s in rows:
|
||||||
|
changed = False
|
||||||
|
if req.status is not None and s.status != req.status:
|
||||||
|
s.status = req.status
|
||||||
|
changed = True
|
||||||
|
if req.category_id is not None and s.category_id != req.category_id:
|
||||||
|
s.category_id = req.category_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}",
|
||||||
|
ip=_ip(request),
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
return ok({"affected": affected})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/series")
|
@router.post("/series")
|
||||||
async def create_series(req: _SeriesCreate, db: AsyncSession = Depends(get_db),
|
async def create_series(req: _SeriesCreate, db: AsyncSession = Depends(get_db),
|
||||||
admin: AdminIdentity = Depends(require_super)):
|
admin: AdminIdentity = Depends(require_super)):
|
||||||
@@ -664,6 +763,306 @@ async def bind_video_series(video_id: int, req: _VideoSeriesBind,
|
|||||||
return ok()
|
return ok()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 短剧采集 ----------
|
||||||
|
|
||||||
|
_collect_sync_running = False
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/collection/sync/{series_id}")
|
||||||
|
async def collect_sync_one(series_id: int, request: Request, db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminIdentity = Depends(require_operator)):
|
||||||
|
"""手动同步单部采集短剧(补充源站新出的集数)"""
|
||||||
|
from app.models.admin import AdminLog
|
||||||
|
from app.models.video import CollectionRecord
|
||||||
|
from app.services import collection_service
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
source_id = (await db.execute(
|
||||||
|
select(CollectionRecord.source_id).where(
|
||||||
|
CollectionRecord.source_key == collection_service.SOURCE_KEY,
|
||||||
|
CollectionRecord.series_id == series_id,
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if not source_id:
|
||||||
|
raise BizException(errors.NOT_FOUND, "该系列不是采集内容")
|
||||||
|
|
||||||
|
stat = await collection_service.sync_drama_by_id(db, source_id)
|
||||||
|
db.add(AdminLog(admin_id=admin.admin_id, action="collection_sync",
|
||||||
|
target=f"series:{series_id}", detail=str(stat), ip=_ip(request)))
|
||||||
|
await db.commit()
|
||||||
|
return ok(stat)
|
||||||
|
|
||||||
|
|
||||||
|
class _CollectSyncAllReq(BaseModel):
|
||||||
|
pages: int = _Field(2, ge=1, le=20) # 增量同步的列表页数
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/collection/sync-all")
|
||||||
|
async def collect_sync_all(req: _CollectSyncAllReq, request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminIdentity = Depends(require_operator)):
|
||||||
|
"""触发增量同步(后台异步执行,立即返回)"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.database import SessionFactory
|
||||||
|
from app.models.admin import AdminLog
|
||||||
|
from app.services import collection_service
|
||||||
|
|
||||||
|
global _collect_sync_running
|
||||||
|
if _collect_sync_running:
|
||||||
|
raise BizException(errors.RATE_LIMITED, "已有同步任务进行中")
|
||||||
|
|
||||||
|
db.add(AdminLog(admin_id=admin.admin_id, action="collection_sync_all",
|
||||||
|
target="", detail=f"pages={req.pages}", ip=_ip(request)))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def _run() -> None:
|
||||||
|
global _collect_sync_running
|
||||||
|
_collect_sync_running = True
|
||||||
|
try:
|
||||||
|
async with SessionFactory() as s:
|
||||||
|
await collection_service.sync_short_dramas(s, max_pages=req.pages)
|
||||||
|
finally:
|
||||||
|
_collect_sync_running = False
|
||||||
|
|
||||||
|
asyncio.create_task(_run())
|
||||||
|
return ok({"started": True, "pages": req.pages})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 采集源管理(单视频类,自助添加)----------
|
||||||
|
|
||||||
|
|
||||||
|
class _SourceCreate(BaseModel):
|
||||||
|
name: str = _Field(min_length=1, max_length=50)
|
||||||
|
list_url: str = _Field(min_length=10, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
status: int | None = _Field(None, ge=0, le=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _source_dict(s) -> dict:
|
||||||
|
return {
|
||||||
|
"id": s.id, "name": s.name, "list_url": s.list_url,
|
||||||
|
"category_id": s.category_id, "status": s.status,
|
||||||
|
"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"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_category(db: AsyncSession, name: str) -> int:
|
||||||
|
"""按名称找分类,不存在则创建"""
|
||||||
|
from app.models.video import Category
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
cid = (await db.execute(select(Category.id).where(Category.name == name))).scalar_one_or_none()
|
||||||
|
if cid:
|
||||||
|
return cid
|
||||||
|
max_sort = (await db.execute(select(func.coalesce(func.max(Category.sort), 0)))).scalar_one()
|
||||||
|
c = Category(name=name, sort=max_sort + 1, status=1)
|
||||||
|
db.add(c)
|
||||||
|
await db.flush()
|
||||||
|
return c.id
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/collection/sources")
|
||||||
|
async def list_sources(db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminIdentity = Depends(require_operator)):
|
||||||
|
from app.models.video import CollectionSource
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
rows = (await db.execute(select(CollectionSource).order_by(CollectionSource.id.desc()))).scalars().all()
|
||||||
|
return ok({"list": [_source_dict(s) for s in rows]})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/collection/sources")
|
||||||
|
async def create_source(req: _SourceCreate, request: Request, db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminIdentity = Depends(require_operator)):
|
||||||
|
"""添加采集源:自动按名称建/找分类"""
|
||||||
|
from app.models.admin import AdminLog
|
||||||
|
from app.models.video import CollectionSource
|
||||||
|
|
||||||
|
if "{{page}}" not in req.list_url:
|
||||||
|
raise BizException(errors.PARAM_ERROR, "URL 需包含 {{page}} 页码占位符")
|
||||||
|
|
||||||
|
cid = await _resolve_category(db, req.name.strip())
|
||||||
|
s = CollectionSource(name=req.name.strip(), list_url=req.list_url.strip(), category_id=cid)
|
||||||
|
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)))
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(s)
|
||||||
|
return ok(_source_dict(s))
|
||||||
|
|
||||||
|
|
||||||
|
@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)):
|
||||||
|
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"]:
|
||||||
|
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()
|
||||||
|
for k, v in d.items():
|
||||||
|
setattr(s, k, v)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(s)
|
||||||
|
return ok(_source_dict(s))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/collection/sources/{source_id}")
|
||||||
|
async def delete_source(source_id: int, db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminIdentity = Depends(require_super)):
|
||||||
|
from app.models.video import CollectionSource
|
||||||
|
|
||||||
|
s = await db.get(CollectionSource, source_id)
|
||||||
|
if not s:
|
||||||
|
raise BizException(errors.NOT_FOUND, "采集源不存在")
|
||||||
|
await db.delete(s)
|
||||||
|
await db.commit()
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
|
||||||
|
class _SourceBatchReq(BaseModel):
|
||||||
|
text: str = _Field(min_length=1, max_length=20000)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/collection/sources/batch")
|
||||||
|
async def batch_create_sources(req: _SourceBatchReq, request: Request, db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminIdentity = Depends(require_operator)):
|
||||||
|
"""批量添加采集源:每行一条,格式 "分类名 URL"(URL 可带反引号),成功后自动触发采集"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from app.models.admin import AdminLog
|
||||||
|
from app.models.video import CollectionSource
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
line_re = re.compile(r"^\s*(?P<name>.+?)[\s]+[`'\"]*(?P<url>https?://[^\s`'\"]+)[`'\"]*\s*$")
|
||||||
|
created, skipped, failed = [], [], []
|
||||||
|
|
||||||
|
for raw in req.text.splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
m = line_re.match(line)
|
||||||
|
if not m or "{{page}}" not in m.group("url"):
|
||||||
|
failed.append({"line": line[:80], "reason": "格式错误或缺少 {{page}} 占位"})
|
||||||
|
continue
|
||||||
|
name, url = m.group("name").strip().strip("`'\" "), m.group("url")
|
||||||
|
if not name or len(name) > 50:
|
||||||
|
failed.append({"line": line[:80], "reason": "分类名为空或过长"})
|
||||||
|
continue
|
||||||
|
exists = (await db.execute(
|
||||||
|
select(CollectionSource.id).where(CollectionSource.list_url == url)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if exists:
|
||||||
|
skipped.append({"name": name, "reason": "URL 已存在"})
|
||||||
|
continue
|
||||||
|
cid = await _resolve_category(db, name)
|
||||||
|
s = CollectionSource(name=name, list_url=url, category_id=cid)
|
||||||
|
db.add(s)
|
||||||
|
await db.flush()
|
||||||
|
created.append({"id": s.id, "name": name})
|
||||||
|
|
||||||
|
db.add(AdminLog(admin_id=admin.admin_id, action="collection_source_batch",
|
||||||
|
target="", detail=f"created={len(created)} skipped={len(skipped)} failed={len(failed)}",
|
||||||
|
ip=_ip(request)))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# 新建的源自动触发后台采集
|
||||||
|
if created:
|
||||||
|
import asyncio
|
||||||
|
for c in created:
|
||||||
|
if c["id"] not in _sync_running_sources:
|
||||||
|
_sync_running_sources.add(c["id"])
|
||||||
|
asyncio.create_task(_run_source_sync(c["id"], 0))
|
||||||
|
|
||||||
|
return ok({"created": created, "skipped": skipped, "failed": failed,
|
||||||
|
"sync_started": [c["id"] for c in created]})
|
||||||
|
|
||||||
|
|
||||||
|
_sync_running_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
|
||||||
|
|
||||||
|
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)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("采集源 %s 同步失败", source_id)
|
||||||
|
finally:
|
||||||
|
_sync_running_sources.discard(source_id)
|
||||||
|
|
||||||
|
|
||||||
|
class _SourceSyncReq(BaseModel):
|
||||||
|
max_pages: int = _Field(0, ge=0, le=200) # 0=不限(增量会自动提前停止)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/collection/sources/{source_id}/sync")
|
||||||
|
async def sync_one_source(source_id: int, req: _SourceSyncReq, request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminIdentity = Depends(require_operator)):
|
||||||
|
"""同步单个采集源(后台异步,立即返回)"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.models.admin import AdminLog
|
||||||
|
from app.models.video import CollectionSource
|
||||||
|
|
||||||
|
s = await db.get(CollectionSource, source_id)
|
||||||
|
if not s:
|
||||||
|
raise BizException(errors.NOT_FOUND, "采集源不存在")
|
||||||
|
if source_id in _sync_running_sources:
|
||||||
|
raise BizException(errors.RATE_LIMITED, "该源正在同步中")
|
||||||
|
|
||||||
|
db.add(AdminLog(admin_id=admin.admin_id, action="collection_source_sync",
|
||||||
|
target=f"source:{source_id}", detail=f"max_pages={req.max_pages}", ip=_ip(request)))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
_sync_running_sources.add(source_id)
|
||||||
|
asyncio.create_task(_run_source_sync(source_id, req.max_pages))
|
||||||
|
return ok({"started": True, "source_id": source_id})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/collection/sources/sync-all")
|
||||||
|
async def sync_all_sources(request: Request, db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminIdentity = Depends(require_operator)):
|
||||||
|
"""同步全部启用的采集源(后台异步)"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.models.admin import AdminLog
|
||||||
|
from app.models.video import CollectionSource
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
ids = (await db.execute(
|
||||||
|
select(CollectionSource.id).where(CollectionSource.status == 1)
|
||||||
|
)).scalars().all()
|
||||||
|
started = [i for i in ids if i not in _sync_running_sources]
|
||||||
|
for i in started:
|
||||||
|
_sync_running_sources.add(i)
|
||||||
|
asyncio.create_task(_run_source_sync(i, 0))
|
||||||
|
|
||||||
|
db.add(AdminLog(admin_id=admin.admin_id, action="collection_sources_sync_all",
|
||||||
|
target="", detail=f"sources={started}", ip=_ip(request)))
|
||||||
|
await db.commit()
|
||||||
|
return ok({"started": started, "skipped_running": [i for i in ids if i not in started]})
|
||||||
|
|
||||||
|
|
||||||
# ---------- 分类管理 ----------
|
# ---------- 分类管理 ----------
|
||||||
|
|
||||||
from pydantic import BaseModel as _BM
|
from pydantic import BaseModel as _BM
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""媒体代理:m3u8 播放列表转发(源站 playlist 内 TS 链接带时效签名,每次回源拉取新鲜版本)"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Query, Response
|
||||||
|
|
||||||
|
from app.core.errors import NOT_FOUND, PARAM_ERROR
|
||||||
|
from app.core.response import BizException
|
||||||
|
from app.services.collection_service import HEADERS, UPSTREAM
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/m3u8", tags=["媒体"])
|
||||||
|
|
||||||
|
# 只允许相对资源路径,防 SSRF
|
||||||
|
_PATH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9/._-]*$")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/proxy")
|
||||||
|
async def m3u8_proxy(path: str = Query(min_length=1, max_length=300)):
|
||||||
|
if ".." in path or not _PATH_RE.match(path):
|
||||||
|
raise BizException(PARAM_ERROR, "非法路径")
|
||||||
|
|
||||||
|
url = f"{UPSTREAM}/api/v1/m3u8/proxy"
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
resp = await client.get(url, params={"path": path}, headers=HEADERS, timeout=20)
|
||||||
|
|
||||||
|
text = resp.text
|
||||||
|
if resp.status_code != 200 or not text.lstrip().startswith("#EXTM3U"):
|
||||||
|
raise BizException(NOT_FOUND, "播放列表获取失败")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=text,
|
||||||
|
media_type="application/vnd.apple.mpegurl",
|
||||||
|
headers={"Cache-Control": "no-cache", "Access-Control-Allow-Origin": "*"},
|
||||||
|
)
|
||||||
@@ -25,7 +25,7 @@ UPLOAD_RATE_LIMIT = 5
|
|||||||
@router.get("/feed")
|
@router.get("/feed")
|
||||||
async def get_feed(cursor: str | None = Query(None), limit: int = Query(10, ge=1, le=20),
|
async def get_feed(cursor: str | None = Query(None), limit: int = Query(10, ge=1, le=20),
|
||||||
category_id: int | None = Query(None),
|
category_id: int | None = Query(None),
|
||||||
type: str = Query("recommend", pattern="^(recommend|latest|hot)$"),
|
type: str = Query("recommend", pattern="^(recommend|latest|hot|liked|viewed)$"),
|
||||||
db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
|
db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
|
||||||
identity: Identity = Depends(get_identity)):
|
identity: Identity = Depends(get_identity)):
|
||||||
return ok(await video_service.get_feed(db, redis, identity, type, cursor, limit, category_id))
|
return ok(await video_service.get_feed(db, redis, identity, type, cursor, limit, category_id))
|
||||||
|
|||||||
Binary file not shown.
@@ -10,6 +10,9 @@ FORBIDDEN = 1003 # 无权限(功能需登录/已被封禁)
|
|||||||
CAPTCHA_ERROR = 1004 # 验证码错误或已过期
|
CAPTCHA_ERROR = 1004 # 验证码错误或已过期
|
||||||
RATE_LIMITED = 1005 # 请求过于频繁
|
RATE_LIMITED = 1005 # 请求过于频繁
|
||||||
NOT_FOUND = 1006 # 资源不存在
|
NOT_FOUND = 1006 # 资源不存在
|
||||||
|
DUPLICATE = 1007 # 资源已存在(重复)
|
||||||
|
CONFLICT = 1008 # 状态冲突(存在关联数据,无法操作)
|
||||||
|
INVALID_PARAMS = PARAM_ERROR # 语义别名:参数错误
|
||||||
ACCOUNT_DEACTIVATED = 1009 # 账号已注销(冻结期内登录可撤销)
|
ACCOUNT_DEACTIVATED = 1009 # 账号已注销(冻结期内登录可撤销)
|
||||||
|
|
||||||
# 2xxx 业务
|
# 2xxx 业务
|
||||||
@@ -45,6 +48,8 @@ DEFAULT_MESSAGES = {
|
|||||||
CAPTCHA_ERROR: "验证码错误或已过期",
|
CAPTCHA_ERROR: "验证码错误或已过期",
|
||||||
RATE_LIMITED: "请求过于频繁,请稍后再试",
|
RATE_LIMITED: "请求过于频繁,请稍后再试",
|
||||||
NOT_FOUND: "资源不存在",
|
NOT_FOUND: "资源不存在",
|
||||||
|
DUPLICATE: "资源已存在",
|
||||||
|
CONFLICT: "存在关联数据,无法操作",
|
||||||
ACCOUNT_DEACTIVATED: "账号已注销",
|
ACCOUNT_DEACTIVATED: "账号已注销",
|
||||||
POINTS_NOT_ENOUGH: "积分不足,请充值或开通会员",
|
POINTS_NOT_ENOUGH: "积分不足,请充值或开通会员",
|
||||||
FREE_LIMIT_EXCEEDED: "今日免费观看次数已用完",
|
FREE_LIMIT_EXCEEDED: "今日免费观看次数已用完",
|
||||||
|
|||||||
+2
-1
@@ -69,8 +69,9 @@ from app.config import BASE_DIR
|
|||||||
app.mount("/static", StaticFiles(directory=BASE_DIR / "uploads"), name="static")
|
app.mount("/static", StaticFiles(directory=BASE_DIR / "uploads"), name="static")
|
||||||
|
|
||||||
# 业务路由(按模块逐步注册)
|
# 业务路由(按模块逐步注册)
|
||||||
from app.api.v1 import admin, auth, common, incentives, interactions, invite, membership, payments, users, videos
|
from app.api.v1 import admin, auth, common, incentives, interactions, invite, media, membership, payments, users, videos
|
||||||
|
|
||||||
|
app.include_router(media.router, prefix="/api/v1")
|
||||||
app.include_router(auth.router, prefix="/api/v1")
|
app.include_router(auth.router, prefix="/api/v1")
|
||||||
app.include_router(users.router, prefix="/api/v1")
|
app.include_router(users.router, prefix="/api/v1")
|
||||||
app.include_router(videos.router, prefix="/api/v1")
|
app.include_router(videos.router, prefix="/api/v1")
|
||||||
|
|||||||
Binary file not shown.
@@ -114,3 +114,37 @@ class Series(Base):
|
|||||||
Index("idx_series_cat_status", "category_id", "status"),
|
Index("idx_series_cat_status", "category_id", "status"),
|
||||||
Index("idx_series_status_created", "status", "created_at"),
|
Index("idx_series_status_created", "status", "created_at"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionRecord(Base):
|
||||||
|
"""采集记录:外部内容源 → 本站系列的映射(幂等导入)"""
|
||||||
|
|
||||||
|
__tablename__ = "collection_records"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
||||||
|
source_key: Mapped[str] = mapped_column(String(50), comment="内容源标识")
|
||||||
|
source_id: Mapped[int] = mapped_column(BIGINT(unsigned=True), comment="源站内容 id")
|
||||||
|
series_id: Mapped[int | None] = mapped_column(BIGINT(unsigned=True), comment="本站系列 id")
|
||||||
|
video_id: Mapped[int | None] = mapped_column(BIGINT(unsigned=True), comment="本站视频 id(单视频类)")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("uk_source", "source_key", "source_id", unique=True),
|
||||||
|
Index("idx_record_series", "series_id"),
|
||||||
|
Index("idx_record_video", "video_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionSource(Base):
|
||||||
|
"""采集源配置:分类名 + 列表URL模板(后台自助添加)"""
|
||||||
|
|
||||||
|
__tablename__ = "collection_sources"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(50), comment="分类名")
|
||||||
|
list_url: Mapped[str] = mapped_column(String(500), comment="列表URL模板,{{page}}为页码占位")
|
||||||
|
category_id: Mapped[int] = mapped_column(INTEGER(unsigned=True), 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())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import redis.asyncio as aioredis
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
redis_client: aioredis.Redis = aioredis.from_url(settings.redis_url, decode_responses=True)
|
# protocol=2:服务器为 Redis 5.x,不支持 RESP3 的 HELLO 握手(redis-py 8.x 默认尝试 3)
|
||||||
|
redis_client: aioredis.Redis = aioredis.from_url(settings.redis_url, decode_responses=True, protocol=2)
|
||||||
|
|
||||||
|
|
||||||
async def get_redis() -> aioredis.Redis:
|
async def get_redis() -> aioredis.Redis:
|
||||||
|
|||||||
Binary file not shown.
@@ -37,6 +37,13 @@ class PointsAdjustReq(BaseModel):
|
|||||||
reason: str = Field(min_length=1, max_length=200)
|
reason: str = Field(min_length=1, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class UserPasswordResetReq(BaseModel):
|
||||||
|
"""后台重置用户密码"""
|
||||||
|
|
||||||
|
new_password: str = Field(min_length=6, max_length=100)
|
||||||
|
reason: str = Field(min_length=1, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
class ResettleReq(BaseModel):
|
class ResettleReq(BaseModel):
|
||||||
"""人工补单(11.12)"""
|
"""人工补单(11.12)"""
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -462,6 +462,26 @@ async def set_user_status(
|
|||||||
return {"user_id": user_id, "status": req.status, "status_text": USER_STATUS_TEXT[req.status]}
|
return {"user_id": user_id, "status": req.status, "status_text": USER_STATUS_TEXT[req.status]}
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_user_password(
|
||||||
|
db: AsyncSession, redis: aioredis.Redis, admin: AdminIdentity,
|
||||||
|
user_id: int, req, ip: str,
|
||||||
|
) -> None:
|
||||||
|
"""后台重置用户密码【敏感】:重置后全端 Token 立即失效"""
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise BizException(errors.NOT_FOUND, "用户不存在")
|
||||||
|
if user.status == 3:
|
||||||
|
raise BizException(errors.PARAM_ERROR, "账号已注销,不可操作")
|
||||||
|
|
||||||
|
user.password_hash = hash_password(req.new_password)
|
||||||
|
# 全端 Token 失效:递增令牌纪元 + 清 refresh 白名单
|
||||||
|
await redis.incr(f"auth:epoch:{user_id}")
|
||||||
|
await redis.delete(f"auth:refresh:{user_id}")
|
||||||
|
await _log(db, admin.admin_id, "user_password_reset", target=str(user_id),
|
||||||
|
detail=req.reason, ip=ip)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
# ---------- 积分 ----------
|
# ---------- 积分 ----------
|
||||||
|
|
||||||
async def adjust_points(
|
async def adjust_points(
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from app.services import config_service, invite_service, membership_service, sen
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
REFRESH_TTL = 30 * 24 * 3600
|
REFRESH_TTL = settings.refresh_token_ttl # 与 .env 的 REFRESH_TOKEN_TTL 同步
|
||||||
DEACTIVATE_FREEZE_DAYS = 7
|
DEACTIVATE_FREEZE_DAYS = 7
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""短剧采集服务:从内容源拉取短剧列表/详情,导入为 series + videos(幂等)"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.video import CollectionRecord, CollectionSource, Series, Video
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 内容源配置(域名轮换时只需改这里)
|
||||||
|
SOURCE_KEY = "mdcmai4"
|
||||||
|
UPSTREAM = "https://mdcmai4.xyz"
|
||||||
|
PRODUCT_ID = 1
|
||||||
|
CATEGORY_ID = 12 # 本站分类:AI短剧
|
||||||
|
AUTHOR_USER_ID = 1 # 导入内容归属用户
|
||||||
|
SELF_API_BASE = "https://vapi.256kb.top" # 本站 API 对外地址
|
||||||
|
|
||||||
|
HEADERS = {"User-Agent": "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_json(client: httpx.AsyncClient, url: str, params: dict | None = None) -> dict:
|
||||||
|
resp = await client.get(url, params=params, headers=HEADERS, timeout=20)
|
||||||
|
resp.raise_for_status()
|
||||||
|
body = resp.json()
|
||||||
|
if body.get("code") != 200 or not isinstance(body.get("data"), dict):
|
||||||
|
raise RuntimeError(f"上游响应异常: {url} -> {str(body)[:200]}")
|
||||||
|
return body["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _proxy_url(path: str) -> str:
|
||||||
|
"""把源站相对 m3u8 路径转为本站代理地址(域名轮换只改 SELF_API_BASE/上游常量)"""
|
||||||
|
return f"{SELF_API_BASE}/api/v1/m3u8/proxy?path={quote(path, safe='')}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _import_drama(client: httpx.AsyncClient, db: AsyncSession, drama_id: int) -> tuple[int, int]:
|
||||||
|
"""导入单部短剧,返回 (新增集数, 跳过集数)"""
|
||||||
|
detail = await _fetch_json(client, f"{UPSTREAM}/api/v1/short-dramas/{drama_id}", {"productId": PRODUCT_ID})
|
||||||
|
|
||||||
|
record = (
|
||||||
|
await db.execute(
|
||||||
|
select(CollectionRecord).where(
|
||||||
|
CollectionRecord.source_key == SOURCE_KEY, CollectionRecord.source_id == drama_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
published_at = None
|
||||||
|
try:
|
||||||
|
published_at = datetime.fromisoformat(detail.get("publishedAt", "")) or None
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
series = await db.get(Series, record.series_id) if record else None
|
||||||
|
if series is None:
|
||||||
|
series = Series(
|
||||||
|
title=(detail.get("title") or f"短剧{drama_id}")[:100],
|
||||||
|
cover_url=f"{UPSTREAM}{detail.get('coverUrl', '')}",
|
||||||
|
description=(detail.get("description") or "")[:500],
|
||||||
|
author_id=AUTHOR_USER_ID,
|
||||||
|
category_id=CATEGORY_ID,
|
||||||
|
total_episodes=0,
|
||||||
|
view_count=int(detail.get("heatCount") or 0),
|
||||||
|
status=1,
|
||||||
|
)
|
||||||
|
db.add(series)
|
||||||
|
await db.flush()
|
||||||
|
if record: # 系列曾被删除:重挂映射
|
||||||
|
record.series_id = series.id
|
||||||
|
else:
|
||||||
|
db.add(CollectionRecord(source_key=SOURCE_KEY, source_id=drama_id, series_id=series.id))
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# 已有集数去重
|
||||||
|
existing = set(
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(Video.episode_no).where(Video.series_id == series.id, Video.episode_no.is_not(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
|
||||||
|
created = skipped = 0
|
||||||
|
for ep in detail.get("episodes", []):
|
||||||
|
ep_no = int(ep.get("episodeNo") or 0)
|
||||||
|
if not ep_no or ep_no in existing:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
db.add(
|
||||||
|
Video(
|
||||||
|
user_id=AUTHOR_USER_ID,
|
||||||
|
category_id=CATEGORY_ID,
|
||||||
|
series_id=series.id,
|
||||||
|
episode_no=ep_no,
|
||||||
|
title=(ep.get("titleOverride") or ep.get("title") or f"第{ep_no}集")[:100],
|
||||||
|
description=(detail.get("description") or "")[:500],
|
||||||
|
cover_url=f"{UPSTREAM}{detail.get('coverUrl', '')}",
|
||||||
|
video_url=_proxy_url(ep.get("videoUrl", "")),
|
||||||
|
duration=int(ep.get("durationSec") or 0),
|
||||||
|
status=2,
|
||||||
|
published_at=published_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing.add(ep_no)
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
series.total_episodes = len(existing)
|
||||||
|
return created, skipped
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_drama_by_id(db: AsyncSession, drama_id: int) -> dict:
|
||||||
|
"""手动同步单部短剧(新增或补充缺失集数);返回统计"""
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
created, skipped = await _import_drama(client, db, drama_id)
|
||||||
|
await db.commit()
|
||||||
|
return {"drama_id": drama_id, "episodes_created": created, "episodes_skipped": skipped}
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_short_dramas(
|
||||||
|
db: AsyncSession,
|
||||||
|
max_pages: int = 0,
|
||||||
|
page_size: int = 20,
|
||||||
|
sort_by: str = "heat",
|
||||||
|
delay: float = 0.25,
|
||||||
|
) -> dict:
|
||||||
|
"""按列表页增量同步短剧;返回统计"""
|
||||||
|
stat = {"dramas_new": 0, "dramas_exist": 0, "episodes_created": 0, "episodes_skipped": 0, "pages": 0}
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
data = await _fetch_json(
|
||||||
|
client,
|
||||||
|
f"{UPSTREAM}/api/v1/short-dramas",
|
||||||
|
{"productId": PRODUCT_ID, "sortBy": sort_by, "page": page, "size": page_size},
|
||||||
|
)
|
||||||
|
items = data.get("items", [])
|
||||||
|
stat["pages"] = page
|
||||||
|
logger.info("列表第 %s/%s 页,%s 部", page, data.get("totalPages"), len(items))
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
drama_id = int(item.get("id") or 0)
|
||||||
|
if not drama_id:
|
||||||
|
continue
|
||||||
|
exists = (
|
||||||
|
await db.execute(
|
||||||
|
select(CollectionRecord.id).where(
|
||||||
|
CollectionRecord.source_key == SOURCE_KEY, CollectionRecord.source_id == drama_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if exists:
|
||||||
|
stat["dramas_exist"] += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
created, skipped = await _import_drama(client, db, drama_id)
|
||||||
|
await db.commit()
|
||||||
|
stat["dramas_new"] += 1
|
||||||
|
stat["episodes_created"] += created
|
||||||
|
stat["episodes_skipped"] += skipped
|
||||||
|
logger.info("导入短剧 %s《%s》:新增 %s 集", drama_id, item.get("title"), created)
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
logger.exception("导入短剧 %s 失败,跳过", drama_id)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
total_pages = int(data.get("totalPages") or 1)
|
||||||
|
if page >= total_pages or (max_pages and page >= max_pages):
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
return stat
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 单视频采集(通用 URL 模板,后台自助配置的采集源)----------
|
||||||
|
|
||||||
|
def _abs_cover(item: dict) -> str:
|
||||||
|
"""封面转绝对地址:源站 /api/... 代理路径或 /uploads/... 静态路径"""
|
||||||
|
u = item.get("coverUrl") or ""
|
||||||
|
if u.startswith("http"):
|
||||||
|
return u
|
||||||
|
return f"{UPSTREAM}{u}" if u else ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _import_video_item(db: AsyncSession, source: CollectionSource, item: dict) -> bool:
|
||||||
|
"""导入单条视频,已存在返回 False"""
|
||||||
|
vid = int(item.get("id") or 0)
|
||||||
|
if not vid or not (item.get("videoUrl") or "").endswith(".m3u8"):
|
||||||
|
return False
|
||||||
|
exists = (
|
||||||
|
await db.execute(
|
||||||
|
select(CollectionRecord.id).where(
|
||||||
|
CollectionRecord.source_key == SOURCE_KEY, CollectionRecord.source_id == vid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if exists:
|
||||||
|
return False
|
||||||
|
|
||||||
|
published_at = None
|
||||||
|
try:
|
||||||
|
published_at = datetime.fromisoformat(item.get("publishedAt", "")) or None
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
v = Video(
|
||||||
|
user_id=AUTHOR_USER_ID,
|
||||||
|
category_id=source.category_id,
|
||||||
|
title=(item.get("title") or f"视频{vid}")[:100],
|
||||||
|
description=(item.get("description") or "")[:500],
|
||||||
|
cover_url=_abs_cover(item)[:500],
|
||||||
|
video_url=_proxy_url(item["videoUrl"]),
|
||||||
|
duration=int(item.get("durationSec") or 0),
|
||||||
|
status=2,
|
||||||
|
view_count=int(item.get("viewCount") or 0),
|
||||||
|
like_count=int(item.get("likeCount") or 0),
|
||||||
|
favorite_count=int(item.get("favoriteCount") or 0),
|
||||||
|
published_at=published_at,
|
||||||
|
)
|
||||||
|
db.add(v)
|
||||||
|
await db.flush()
|
||||||
|
db.add(CollectionRecord(source_key=SOURCE_KEY, source_id=vid, video_id=v.id))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_source(
|
||||||
|
db: AsyncSession,
|
||||||
|
source: CollectionSource,
|
||||||
|
max_pages: int = 0,
|
||||||
|
stop_after_existing: int = 30,
|
||||||
|
delay: float = 0.25,
|
||||||
|
) -> dict:
|
||||||
|
"""按源的 URL 模板翻页采集;连续 stop_after_existing 条已存在则提前停止(增量)"""
|
||||||
|
stat = {"created": 0, "skipped": 0, "pages": 0, "stopped_early": False}
|
||||||
|
consecutive_existing = 0
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
url = source.list_url.replace("{{page}}", str(page))
|
||||||
|
data = await _fetch_json(client, url)
|
||||||
|
items = data.get("items", [])
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
stat["pages"] = page
|
||||||
|
for item in items:
|
||||||
|
if await _import_video_item(db, source, item):
|
||||||
|
stat["created"] += 1
|
||||||
|
consecutive_existing = 0
|
||||||
|
else:
|
||||||
|
stat["skipped"] += 1
|
||||||
|
consecutive_existing += 1
|
||||||
|
if consecutive_existing >= stop_after_existing:
|
||||||
|
stat["stopped_early"] = True
|
||||||
|
break
|
||||||
|
await db.commit()
|
||||||
|
if stat["stopped_early"]:
|
||||||
|
break
|
||||||
|
total_pages = int(data.get("totalPages") or page)
|
||||||
|
if page >= total_pages or (max_pages and page >= max_pages):
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
source.last_sync_at = datetime.now()
|
||||||
|
await db.commit()
|
||||||
|
return stat
|
||||||
@@ -160,9 +160,37 @@ async def get_feed(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
if feed_type == "latest":
|
if feed_type == "latest":
|
||||||
return await _feed_latest(db, identity, cursor, limit, category_id)
|
return await _feed_latest(db, identity, cursor, limit, category_id)
|
||||||
|
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_scored(db, redis, identity, feed_type, cursor, limit, category_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _feed_ranked(
|
||||||
|
db: AsyncSession, identity: Identity, feed_type: str, cursor: str | None,
|
||||||
|
limit: int, category_id: int | None,
|
||||||
|
) -> dict:
|
||||||
|
"""liked / viewed:按点赞量 / 播放量严格倒序(值 + id 打破并列游标)"""
|
||||||
|
col = Video.like_count if feed_type == "liked" else Video.view_count
|
||||||
|
stmt = await _base_pool_stmt(category_id)
|
||||||
|
c = _decode_cursor(cursor)
|
||||||
|
if c:
|
||||||
|
val, vid = int(c["n"]), int(c["i"])
|
||||||
|
stmt = stmt.where((col < val) | ((col == val) & (Video.id < vid)))
|
||||||
|
rows = list(
|
||||||
|
(await db.execute(stmt.order_by(col.desc(), Video.id.desc()).limit(limit + 1))).scalars()
|
||||||
|
)
|
||||||
|
page, rows = (len(rows) > limit), rows[:limit]
|
||||||
|
next_cursor = None
|
||||||
|
if page and rows:
|
||||||
|
last = rows[-1]
|
||||||
|
last_val = last.like_count if feed_type == "liked" else last.view_count
|
||||||
|
next_cursor = _encode_cursor({"n": last_val, "i": last.id})
|
||||||
|
return {
|
||||||
|
"next_cursor": next_cursor,
|
||||||
|
"list": await serialize_videos(db, rows, identity.user_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _base_pool_stmt(category_id: int | None):
|
async def _base_pool_stmt(category_id: int | None):
|
||||||
stmt = select(Video).where(Video.status == STATUS_PUBLISHED, Video.published_at.isnot(None))
|
stmt = select(Video).where(Video.status == STATUS_PUBLISHED, Video.published_at.isnot(None))
|
||||||
if category_id:
|
if category_id:
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""一次性脚本:根据现有视频标题拆分关键词写入 tags / video_tags。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
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(过滤停用词、纯数字、单字)
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
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:
|
||||||
|
videos = (await db.execute(select(Video.id, Video.title))).all()
|
||||||
|
log.info("共 %d 个视频", len(videos))
|
||||||
|
|
||||||
|
tag_map: dict[str, int] = {} # name -> tag_id(含新建暂存)
|
||||||
|
video_count: dict[str, int] = {} # name -> 关联视频数
|
||||||
|
links: list[tuple[int, str]] = [] # (video_id, tag_name)
|
||||||
|
|
||||||
|
for vid, title in videos:
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
kws = extract_keywords(title)
|
||||||
|
for k in kws:
|
||||||
|
links.append((vid, k))
|
||||||
|
video_count[k] = video_count.get(k, 0) + 1
|
||||||
|
|
||||||
|
log.info("提取出 %d 个不同标签,%d 条视频-标签关联", len(video_count), len(links))
|
||||||
|
top = sorted(video_count.items(), key=lambda x: -x[1])[:15]
|
||||||
|
log.info("Top15 标签: %s", top)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
await engine.dispose()
|
||||||
|
return
|
||||||
|
|
||||||
|
# 预载已存在的 tags(运行中的后端也可能并发写 tags,全程幂等)
|
||||||
|
existing = (await db.execute(select(Tag.name, Tag.id))).all()
|
||||||
|
tag_map: dict[str, int] = {n.casefold(): i for n, i in existing}
|
||||||
|
|
||||||
|
# 写入 tags:INSERT IGNORE 防并发/重复,再取回 id
|
||||||
|
from sqlalchemy import text
|
||||||
|
new_names = [n[:20] for n in video_count if n[:20].casefold() not in tag_map]
|
||||||
|
for i in range(0, len(new_names), 500):
|
||||||
|
chunk = new_names[i:i + 500]
|
||||||
|
await db.execute(
|
||||||
|
text("INSERT IGNORE INTO tags (name, video_count) VALUES " +
|
||||||
|
",".join("(:n%d, 0)" % j for j in range(len(chunk)))),
|
||||||
|
{f"n{j}": v for j, v in enumerate(chunk)},
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
existing = (await db.execute(select(Tag.name, Tag.id))).all()
|
||||||
|
tag_map = {n.casefold(): i for n, i in existing}
|
||||||
|
|
||||||
|
# 写入 video_tags:INSERT IGNORE(uk_video_tag 自动去重)
|
||||||
|
seen_link: set[tuple[int, int]] = set()
|
||||||
|
rows: list[dict] = []
|
||||||
|
for vid, name in links:
|
||||||
|
tid = tag_map.get(name[:20].casefold())
|
||||||
|
if tid is None or (vid, tid) in seen_link:
|
||||||
|
continue
|
||||||
|
seen_link.add((vid, tid))
|
||||||
|
rows.append({"v": vid, "t": tid})
|
||||||
|
for i in range(0, len(rows), 1000):
|
||||||
|
chunk = rows[i:i + 1000]
|
||||||
|
await db.execute(
|
||||||
|
text("INSERT IGNORE INTO video_tags (video_id, tag_id) VALUES " +
|
||||||
|
",".join("(:v%d, :t%d)" % (j, j) for j in range(len(chunk)))),
|
||||||
|
{**{f"v{j}": r["v"] for j, r in enumerate(chunk)},
|
||||||
|
**{f"t{j}": r["t"] for j, r in enumerate(chunk)}},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 统一回算冗余计数
|
||||||
|
await db.execute(text(
|
||||||
|
"UPDATE tags t SET t.video_count = "
|
||||||
|
"(SELECT COUNT(*) FROM video_tags vt WHERE vt.tag_id = t.id)"
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
log.info("写入完成:tags=%d(新增 %d),video_tags=%d", len(tag_map), len(new_names), len(rows))
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="只打印统计不写库")
|
||||||
|
args = parser.parse_args()
|
||||||
|
asyncio.run(main(args.dry_run))
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""短剧采集 CLI
|
||||||
|
用法:
|
||||||
|
.venv/bin/python scripts/collect_short_dramas.py # 全量增量同步
|
||||||
|
.venv/bin/python scripts/collect_short_dramas.py --pages 1 --size 3 # 试跑
|
||||||
|
.venv/bin/python scripts/collect_short_dramas.py --drama-id 130 # 手动更新单部
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from app.database import SessionFactory # noqa: E402
|
||||||
|
from app.services.collection_service import sync_drama_by_id, sync_short_dramas # noqa: E402
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="短剧采集")
|
||||||
|
parser.add_argument("--drama-id", type=int, default=0, help="只同步指定源站短剧 id")
|
||||||
|
parser.add_argument("--pages", type=int, default=0, help="最多同步的列表页数,0=全部")
|
||||||
|
parser.add_argument("--size", type=int, default=20, help="每页条数")
|
||||||
|
parser.add_argument("--sort", choices=["heat", "latest"], default="heat")
|
||||||
|
parser.add_argument("--delay", type=float, default=0.25, help="上游请求间隔秒")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
async with SessionFactory() as db:
|
||||||
|
if args.drama_id:
|
||||||
|
stat = await sync_drama_by_id(db, args.drama_id)
|
||||||
|
else:
|
||||||
|
stat = await sync_short_dramas(
|
||||||
|
db, max_pages=args.pages, page_size=args.size, sort_by=args.sort, delay=args.delay
|
||||||
|
)
|
||||||
|
print("同步完成:", stat)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
Reference in New Issue
Block a user