Compare commits

...
5 Commits
Author SHA1 Message Date
lz_db 893dfed0ed 1 2026-10-24 00:04:50 +08:00
lz_db 1d440b2916 up 2026-10-18 21:08:54 +08:00
lz_db 1253c159f5 1 2026-10-17 19:04:05 +08:00
lz_db 28b11b4365 1 2026-10-17 09:41:11 +08:00
lz_db c519bdae9f 1 2026-10-17 09:40:54 +08:00
60 changed files with 4037 additions and 1446 deletions
+5
View File
@@ -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)
}
+1
View File
@@ -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: [] },
+6
View File
@@ -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',
+212
View File
@@ -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>
+147 -4
View File
@@ -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; }
+86 -3
View File
@@ -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>
+25 -3
View File
@@ -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")
}
} }
} }
} }
+13 -4
View File
@@ -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';
+304
View File
@@ -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());
+303
View File
@@ -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;
}
}
+40 -37
View File
@@ -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();
+145 -11
View File
@@ -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
View File
@@ -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 {
+7
View File
@@ -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);
+23 -1
View File
@@ -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) {
// 立即静音全部非目标槽位,防止翻页动画期间多个视频同时出声 // 立即静音全部非目标槽位,防止翻页动画期间多个视频同时出声
+6
View File
@@ -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',
+101 -19
View File
@@ -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),
),
],
),
);
}
}
+38 -6
View File
@@ -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,
); );
}, },
), ),
+153 -53
View File
@@ -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),
),
),
),
],
), ),
), ),
); );
+15 -4
View File
@@ -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();
}());
} }
} }
+150 -24
View File
@@ -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),
),
),
),
],
), ),
), ),
); );
+5
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -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
+15 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 验证清单
+163
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+34
View File
@@ -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": "*"},
)
+1 -1
View File
@@ -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.
+5
View File
@@ -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
View File
@@ -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.
+34
View File
@@ -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())
+2 -1
View File
@@ -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.
+7
View File
@@ -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)"""
+20
View File
@@ -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(
+1 -1
View File
@@ -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
+269
View File
@@ -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
+28
View File
@@ -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:
+157
View File
@@ -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))
+41
View File
@@ -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())