From 1d440b29160157b7eeaf970ee343463780811a24 Mon Sep 17 00:00:00 2001 From: lz_db Date: Sun, 18 Oct 2026 21:08:54 +0800 Subject: [PATCH] up --- docs/01-开发文档.md | 126 +++++- docs/02-技术文档.md | 71 +++- docs/03-接口文档.md | 183 +++++++- docs/04-部署运行文档.md | 394 ++++++++++++------ docs/05-功能流程文档.md | 163 ++++++++ project-ctl.sh | 84 ++++ .../versions/e7a1f3c58d02_add_series_table.py | 42 ++ .../core/__pycache__/errors.cpython-311.pyc | Bin 2184 -> 2336 bytes server/app/core/errors.py | 5 + 9 files changed, 901 insertions(+), 167 deletions(-) create mode 100644 docs/05-功能流程文档.md create mode 100755 project-ctl.sh create mode 100644 server/alembic/versions/e7a1f3c58d02_add_series_table.py diff --git a/docs/01-开发文档.md b/docs/01-开发文档.md index 4b09e15..37caf77 100644 --- a/docs/01-开发文档.md +++ b/docs/01-开发文档.md @@ -56,14 +56,13 @@ video/ # 项目根目录(当前目录) - 游客不可点赞、收藏、评论、上传 #### 2.1.2 注册 -- **优先手机号注册**:手机号 + 短信验证码 + 密码 -- 备选邮箱注册:邮箱 + 邮箱验证码 + 密码 +- 注册页**手机号 / 邮箱 Tab 切换**:手机号 + 短信验证码 + 密码,或 邮箱 + 邮箱验证码 + 密码 +- 邮箱验证码由服务端**真实 SMTP 发送**(`POST /auth/email/send`),验证码写 Redis(`email:code:{email}`,600s),与短信验证码同一套校验机制 - 注册即登录,返回 Token - 约束:手机号/邮箱全局唯一;密码 ≥ 8 位,含字母和数字 #### 2.1.3 登录 -- 手机号 + 密码,或 手机号 + 短信验证码 -- 邮箱 + 密码 +- 支持**手机号或邮箱**作为账号:手机号 + 密码 / 手机号 + 短信验证码 / 邮箱 + 密码 - 登录成功后,游客时期的设备 ID 与账号可关联(观看记录合并,可选) #### 2.1.4 资料管理 @@ -189,7 +188,10 @@ App 调用「观看上报」接口 #### 2.5.4 视频播放 - 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 计数异步落库) #### 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 视频推荐策略 -Feed 支持三种策略,客户端通过接口参数切换: +Feed 支持五种策略,客户端通过接口参数切换(App 顶部 Tab:推荐 / 最新 / 热度 / 口碑 / 热播,各策略独立保活分页状态): | 策略 | 说明 | |---|---| | recommend(默认) | 个性化推荐 | | latest | 最新发布倒序 | -| hot | 热度倒序(播放/点赞/评论加权) | +| hot | 热度倒序:热度分 = (播放×1 + 点赞×5 + 评论×10) / (1 + 发布天数) | +| liked(口碑) | 按点赞量倒序 | +| viewed(热播) | 按播放量倒序 | + +> liked / viewed 为纯数值排序,游标采用(值, id)双键避免同值翻页丢失。 **个性化推荐(recommend)规则:** 1. 候选池:全部"已发布"视频 @@ -224,6 +231,13 @@ Feed 支持三种策略,客户端通过接口参数切换: - 搜索历史:App 端本地存储最近 10 条,不上报服务端 - 搜索词先过敏感词过滤,命中直接返回空结果 +#### 2.5.9 视频下载(离线缓存) +- **入口**:Feed 页 / 播放页长按弹出菜单 →「下载」(已下载/下载中置灰) +- HLS 视频:4 并发下载 ts 分片与 AES-128 密钥,改写为本地 index.m3u8;mp4 整文件直接下载 +- 下载过程展示进度,支持取消;失败自动清理半成品文件 +- 下载列表在「我的 → 已下载」;离线播放走本地媒体代理,支持拖动进度条 +- 已下载视频离线观看**不重复扣积分**(线上播放时已按正常规则计费) + ### 2.6 互动模块 | 功能 | 规则 | @@ -330,7 +344,7 @@ Feed 支持三种策略,客户端通过接口参数切换: |---|---| | 仪表盘 | 今日/累计:DAU、新增注册、充值金额、积分发放与消耗、播放量、待审核数 | | 视频审核(人工) | 待审核列表(在线预览 m3u8)、通过、拒绝(必填原因)、已发布视频下架 | -| 用户管理 | 搜索(ID/手机号/邮箱)、用户详情(钱包/会员/作品/流水)、封禁与解封 | +| 用户管理 | 搜索(ID/手机号/邮箱)、用户详情(钱包/会员/作品/流水)、封禁与解封、**修改密码**(重置后 `auth:epoch` 递增,该用户全端 Token 立即失效) | | 积分管理 | 用户积分流水查询、手动加/减积分(原因必填 + 二次确认,写流水 biz_type=6) | | 订单管理 | 充值订单查询(状态/渠道/时间筛选)、异常订单人工补单(核实已收款后手动入账) | | 提现审核 | 提现申请列表、审核通过(线下打款后确认)/ 拒绝(自动退回资产) | @@ -338,6 +352,8 @@ Feed 支持三种策略,客户端通过接口参数切换: | 平台配置 | 注册赠送体验会员开关与天数等运营参数(system_configs 键值对,实时生效) | | 充值套餐管理 | 档位与赠送积分增删改、上下架、排序,实时生效 | | 活动运营 | 赠送体验会员/积分(支持单个或批量用户) | +| 标签管理 | 标签列表查询(关联视频数)、重命名、删除(联动清理 video_tags) | +| 采集源管理 | 采集源 CRUD(分类名 + 列表 URL 模板)、批量添加、单源/全部同步(后台异步采集外部单视频入库) | | 管理员管理 | 管理员账号增删、角色分配(超级管理员/审核员/运营)、操作日志查询 | 角色权限:超级管理员(全部)、审核员(仅视频审核)、运营(套餐/活动/看板)。 @@ -369,6 +385,8 @@ Feed 支持三种策略,客户端通过接口参数切换: ### 3.1 表清单 +现共 **30 张业务表**。Alembic 迁移链:`init_27_tables` → `add_collection_records` → `add_collection_sources`;线上数据量约 1.8 万视频。 + | 表 | 说明 | |---|---| | users | 用户表 | @@ -397,6 +415,9 @@ Feed 支持三种策略,客户端通过接口参数切换: | notifications | 消息通知表 | | reports | 举报表 | | sensitive_words | 敏感词表 | +| series | 系列(合集)表:多条视频组织成集数列表 | +| collection_sources | 采集源配置表(分类名 + 列表 URL 模板) | +| collection_records | 采集记录表(外部内容源 → 本站系列/视频映射,幂等导入) | ### 3.2 用户域 @@ -792,6 +813,47 @@ Feed 支持三种策略,客户端通过接口参数切换: | word | VARCHAR(50) | 敏感词,**唯一索引** | | 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. 接口设计(概览) @@ -842,16 +904,19 @@ Feed 支持三种策略,客户端通过接口参数切换: | /mine/history | 观看历史 | | | /upload | 上传视频 | 选片→剪辑信息→发布 | | /search | 搜索 | | +| /category | 分类页 | 按分类浏览视频 | | /tags/:name | 标签视频页 | 某标签下的视频列表 | +| /mine/downloads | 已下载 | 离线视频列表(进度/删除/播放) | +| /notifications | 消息通知 | 通知列表、未读标记 | | /settings | 设置 | 退出登录等 | ### 5.2 核心页面布局 #### 5.2.1 首页(视频 Feed) -- 全屏竖向 PageView,每个页面一个视频播放器 -- 顶部:Feed 策略 Tab(推荐 / 最新 / 热度)+ 搜索图标 +- 全屏竖向 PageView,每个页面一个视频播放器(3 槽播放器池:当前页播放 + 下一页预缓冲,划走即停) +- 顶部:Feed 策略 Tab(推荐 / 最新 / 热度 / 口碑 / 热播)+ 搜索图标;各策略分页状态独立保活,切 Tab 不丢进度 - 右侧互动栏(自上而下):作者头像(+关注预留)、点赞(红心+计数)、评论(气泡+计数)、收藏(星标+计数)、分享 -- 左下:作者昵称、标题(标题后展示 #标签,点击跳转标签视频页)、描述(展开/收起) +- 左下:作者昵称、标题(标题后展示 #标签,点击跳转标签视频页;并带时长角标)、描述(展开/收起) - 底部(游客/非会员场景):今日免费剩余次数提示条(剩余 ≤ 3 次时显示) #### 5.2.2 评论页(底部弹层) @@ -885,6 +950,8 @@ Feed 支持三种策略,客户端通过接口参数切换: | 切换视频 | 垂直滑动,松手吸附;滑出即暂停,滑入自动播放并**上报观看计费** | | 单击屏幕 | 暂停 / 继续播放(暂停时显示播放图标) | | 双击屏幕 | 点赞动画(大爱心飘出) | +| 长按屏幕 | 弹出操作菜单:下载视频 / 倍速 / 分享(已下载或下载中置灰) | +| 拖动进度条 | 跟随 thumb 显示时间气泡(x时xx分xx秒,两端自动夹在屏内) | | 游客超限 | 播放第 11 个视频时弹出登录引导弹窗("注册解锁更多视频,今日已看 10/10") | | 用户积分不足 | 弹窗"积分不足":去充值 / 开通会员 两个按钮 | | 扣费提示 | 每次扣积分播放时顶部 Toast"本次消耗 1 积分" | @@ -895,7 +962,7 @@ Feed 支持三种策略,客户端通过接口参数切换: | 项 | 规范 | |---|---| -| 主题 | 深色为主(#121212 背景),保证视频沉浸感;浅色可选(二期) | +| 主题 | 深色为主(#121212 背景),保证视频沉浸感;浅色可选(二期);**弹窗/底部菜单统一白底亮色**(局部包 `ThemeData.light()`,避免深色背景下文字看不清) | | 主色 | #FE2C55(红),辅助色 #25F4EE(青) | | 会员色 | 金色渐变 #F7D794 → #D4A017 | | 文字 | 主 #FFFFFF,次 #B0B0B0,弱 #6E6E6E | @@ -924,6 +991,25 @@ App 不内置固定 API 域名,启动时按以下流程动态获取: - App 启动及回到前台时调用 `GET /app/version`:返回最新版本号、是否强制更新、下载地址、维护公告 - 强制更新:弹窗阻断使用并跳转商店/下载页;非强制:可跳过的提示弹窗;维护公告:全 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.1 框架推荐:**FastAPI** @@ -945,17 +1031,21 @@ App 不内置固定 API 域名,启动时按以下流程动态获取: ``` server/ ├── app/ -│ ├── main.py # 应用入口、中间件、路由注册 +│ ├── main.py # 应用入口、中间件、路由注册、lifespan(settle 周期任务启动) │ ├── core/ # 配置、日志、异常、安全(JWT/密码) -│ ├── api/v1/ # App 路由层:auth/users/videos/interact/vip/pay -│ ├── api/admin/ # 管理后台路由(独立管理员鉴权) +│ ├── api/v1/ # 路由层(11 个文件):admin / auth / common / incentives +│ │ # / interactions / invite / media / membership / payments +│ │ # / users / videos(管理后台同文件 /admin 前缀、独立管理员鉴权) │ ├── schemas/ # Pydantic 请求/响应模型 -│ ├── models/ # SQLAlchemy ORM 模型 -│ ├── services/ # 业务逻辑:计费、积分、会员、视频、互动 +│ ├── models/ # SQLAlchemy ORM 模型(30 张表) +│ ├── services/ # 业务逻辑(17 个):user/auth/wallet/video/interaction/membership +│ │ # /payment/incentive/invite/admin/common/config/sensitive +│ │ # /storage/transcode/settle/collection │ ├── payment/ # 支付适配层:alipay / wechat / usdt │ ├── tasks/ # Celery 任务:转码、播放量落库、订单关单 │ └── utils/ # 短信、邮件、OSS、设备指纹 -├── alembic/ # 数据库迁移 +├── scripts/ # 离线脚本:build_tags_from_titles.py(标签生成)、采集脚本 +├── alembic/ # 数据库迁移(init_27_tables → collection_records → collection_sources) ├── config.yaml # 业务参数配置(积分/会员/支付/上传限制) ├── .env # 密钥类配置(不入库) └── docker-compose.yml diff --git a/docs/02-技术文档.md b/docs/02-技术文档.md index 9a59a95..7382dc9 100644 --- a/docs/02-技术文档.md +++ b/docs/02-技术文档.md @@ -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//` 直接读下载目录。统一声明 `Accept-Ranges` 并支持 Range/206 +> (这是进度条可拖动的根因:ffmpeg/mpv 依据该头判断流可 seek,否则按直播处理)。 +> ts 流量由代理直连源站 CDN,不经过业务服务器。 + ### 1.2 部署架构(单机起步版,docker-compose) | 容器 | 镜像 | 说明 | @@ -54,9 +61,11 @@ 扩展路径: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 关键链路 -- **播放链路**: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 下单 → 调起支付 SDK → 平台异步回调服务端 → 验签 → 事务入账 → App 轮询查单确认结果 @@ -451,6 +460,7 @@ CREATE TABLE sensitive_words ( | `sms:code:{phone}` | String | 300s | 短信验证码;同号 60s 内禁止重发(另建 `sms:limit:{phone}`) | | `email:code:{email}` | String | 600s | 邮箱验证码 | | `auth:refresh:{user_id}` | String | 30d | refresh_token 白名单(支持多端+踢下线) | +| `auth:epoch:{user_id}` | String(计数) | - | 用户令牌纪元:后台改密/强制下线时 INCR,旧 Token 全部失效 | | `token:blacklist:{jti}` | String | token 剩余 TTL | 登出/改密失效 | | `watched:{subject}:{yyyyMMdd}` | Set(video_id) | 48h | 当日已看视频(推荐去重) | | `rec:seed:{yyyyMMdd}` | String | 48h | 当日推荐随机种子 | @@ -475,6 +485,8 @@ CREATE TABLE sensitive_words ( | `words:sensitive` | String(JSON) | 60s | 敏感词库缓存(DFA 词源),后台增删后失效重建 | | `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. 接口设计(技术规范) @@ -541,24 +553,34 @@ Nginx limit_req(IP 维度)+ Redis 滑动窗口(主体维度)双层限流 ``` app/lib/ ├── main.dart -├── config/ # 环境配置、常量、主题 -├── router/ # go_router 路由表、登录守卫 -├── models/ # 数据模型(json_serializable) -├── services/ # api_client(dio)、auth_service、storage、device -├── providers/ # Riverpod providers(auth/feed/wallet/vip) -├── pages/ # 页面(home/login/mine/wallet/vip/upload/...) -│ └── home/widgets/ -├── widgets/ # 通用组件(按钮/弹窗/空态/互动栏) -└── utils/ # 格式化、埋点、防抖 +├── router.dart # go_router 路由表、登录守卫 +├── models/ # 数据模型 +├── services/ # 接口封装(VideoService 等,基于 core/network) +├── providers/ # Riverpod(12 个):auth/feed/comment/wallet/member/payment +│ # /profile/search/upload/asset/notification/tag_videos +├── screens/ # 页面:splash/home(HomeShell+IndexedStack)/feed/search/category +│ # /player/membership/payment/wallet/assets/upload/profile +│ # /auth/settings/notification/downloads +│ └── 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 关键技术点 | 点 | 方案 | |---|---| -| 网络层 | dio 单例 + 拦截器:注入 `Authorization`/`X-Device-ID`/`X-Request-ID`;401 时刷新 Token 并重放原请求(单飞 Future 防并发刷新) | -| 播放器 | media_kit 封装 `ShortVideoPlayer`:预加载下一条(keepAlive 双缓冲)、滑出 dispose、首帧秒开(m3u8 低延迟配置);双击点赞手势区与播放手势分层 | -| Feed 滑动 | PageView(scrollDirection: vertical),onPageChanged 触发**观看上报**(防抖 500ms,同视频当日只报一次,本地记录) | +| 网络层 | dio 单例 + 拦截器:注入 `Authorization`/`X-Device-ID`/`X-Request-ID`;401 时刷新 Token 并重放原请求(单飞 Future 防并发刷新);**无 refresh token 或刷新被拒时清登录态跳登录页,防"僵尸登录态"** | +| 播放器 | media_kit 封装 + **3 槽播放器池**(core/player/feed_player_pool.dart):当前页播放 + 下一页预缓冲,划走即 stop(断音 + 中断缓存下载);`suspend()`/`resume()` 挂起语义配合 `activeFeedStrategyProvider`/`feedTabVisibleProvider` 与 HomeShell(IndexedStack),修复"切到我的页面仍出声"竞态 | +| 本地媒体代理 | 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`(分页游标) | | 本地持久 | device_id(首启 UUID 写 secure_storage)、Token(secure_storage)、观看已上报集合(sp,按日清理) | | 上传 | 优先 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 推荐与标签服务实现 -- Feed 接口参数 `type=recommend|latest|hot`,默认 recommend +- Feed 接口参数 `type=recommend|latest|hot|liked|viewed`,默认 recommend - **recommend(个性化)**: 1. 候选池:近 30 天"已发布"视频 ID(Redis ZSet `feed:latest` 缓存,发布/下架时增删) 2. 个性化分:统计用户近 7 天观看/点赞/收藏视频的标签,得到偏好标签及权重;候选视频命中偏好标签加权,未命中取热度归一分;游客/新用户退化为热度分 3. **每日重排**:`seed = md5(yyyyMMdd + subject_id)`,以该固定种子对打分后列表做确定性洗牌(Fisher-Yates)—— 保证同一用户**每天顺序不同**,且同一天内多次刷新顺序稳定(游标翻页不错乱) 4. **当日去重**:翻页时过滤 `watched:{subject}:{date}`(观看上报时写入);候选耗尽时放开过滤兜底,避免 Feed 刷空 -- **latest**:published_at 游标倒序;**hot**:score = view×1 + like×5 + comment×10(近 7 天,时间衰减) -- 标签:发布时写入 tags / video_tags(标签名全局唯一、大小写不敏感);标签视频页按热度或最新排序 +- **latest**:published_at 游标倒序;**hot**:score = (view×1 + like×5 + comment×10) / (1 + 发布天数),严格倒序;**liked(口碑)**:like_count 倒序;**viewed(热播)**:view_count 倒序 +- liked / viewed 为纯数值排序,游标用**(值, id)双键**(`WHERE (v < ?) OR (v = ? AND id < ?)`),避免同值翻页丢失 +- 标签:发布时写入 tags / video_tags(标签名全局唯一、大小写不敏感);标签视频页按热度或最新排序;存量标签由离线脚本 build_tags_from_titles.py(jieba TF-IDF topK=6 + 番号/书名号提取 + 噪音过滤,幂等)批量生成 ### 6.5 支付适配层 @@ -682,3 +705,19 @@ Celery 周期任务(default 队列): - CI/CD:GitHub Actions(lint → pytest → build image → deploy) - 备份:MySQL 每日全量 + binlog 增量;OSS 跨区复制(可选) - **兜底域名与证书**:`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 各启动一份,任务逻辑必须保持幂等(状态机推进 + 唯一约束),新增周期任务同样遵守 diff --git a/docs/03-接口文档.md b/docs/03-接口文档.md index d87149a..10a2374 100644 --- a/docs/03-接口文档.md +++ b/docs/03-接口文档.md @@ -1,7 +1,7 @@ # 短视频应用接口文档 -- 版本:v1.0 -- 日期:2026-10-14 +- 版本:v1.1 +- 日期:2026-10-18 - Base URL:`https://api.example.com/api/v1` > 注意: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天冻结期) | 是 | | 14 | 用户 | GET | /users/{user_id} | 用户公开主页 | 否 | | 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} | 视频详情(登录返回续播进度) | 否 | | 18 | 视频 | POST | /videos/{video_id}/watch | 观看上报(计费,可带播放进度) | 否 | | 19 | 视频 | POST | /videos/{video_id}/watch/complete | 完整观看上报(创作收益计数) | 否 | @@ -133,7 +133,10 @@ | 56 | 消息 | GET | /notifications/unread_count | 未读数 | 是 | | 57 | 消息 | POST | /notifications/read | 标记已读 | 是 | | 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 } } ``` -**限制**:同号码 60 秒 1 条,同号码每天 10 条;注册场景下已注册号码返回错误码 1001("该手机号已注册")。 +**限制**:同号码 60 秒 1 条,同号码每天 10 条,同 IP 每小时 5 条;注册场景下已注册号码返回错误码 1001("该账号已注册"),login/reset 场景下未注册号码同样返回 1001。未配置短信通道时(开发环境)响应额外返回 `dev_code` 并仅记日志。 ### 3.2 发送邮箱验证码 `POST /auth/email/send` @@ -169,7 +172,7 @@ | email | string | 是 | 邮箱地址 | | 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` @@ -223,6 +226,7 @@ | email | string | 邮箱方式必填 | | | password | string | 密码方式必填 | | | code | string | 验证码方式必填 | | +| revoke | bool | 否 | 注销冻结期内确认撤销注销(默认 false,见 4.6) | **请求示例** @@ -230,7 +234,9 @@ { "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` @@ -240,6 +246,8 @@ **响应**:同 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`(需登录) 无参数。响应 `{ "code": 0 }`。服务端将 access_token 的 jti 加入黑名单并删除 refresh 白名单。 @@ -401,7 +409,7 @@ | cursor | string | 否 | 上一页返回的 next_cursor | | limit | int | 否 | 默认 10,最大 20 | | category_id | int | 否 | 分类筛选 | -| type | string | 否 | 策略:`recommend`(默认,个性化)/ `latest` / `hot` | +| type | string | 否 | 策略:`recommend`(默认,个性化)/ `latest`(最新发布)/ `hot`(热度)/ `liked`(口碑,按点赞量倒序)/ `viewed`(热播,按播放量倒序) | **响应示例** @@ -434,6 +442,11 @@ > `is_liked/is_favorited` 仅登录用户返回真实值,游客恒为 false。 > > **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}`(游客可) @@ -651,6 +664,30 @@ **错误码**: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. 互动模块 @@ -1197,6 +1234,20 @@ App 我的页消息入口红点依赖本接口(直接读 Redis `notif:unread:{ **错误码**: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. 管理后台接口 @@ -1204,7 +1255,7 @@ App 我的页消息入口红点依赖本接口(直接读 Redis `notif:unread:{ 通用约定: - 前缀 `/api/v1/admin`,统一响应结构同 App 接口 - 认证:`Authorization: Bearer `(Token payload `type="admin"`,有效期 12h,与 App 用户体系互不相通) -- 角色:`super`(全部)/ `reviewer`(仅审核)/ `operator`(套餐、活动、看板);越权返回 4001 +- 角色:`super`(全部)/ `reviewer`(仅审核)/ `operator`(套餐、活动、看板、采集源/采集同步、系列/分类/标签查询);越权返回 4001 - 所有写操作记录操作日志;标注【敏感】的接口 `reason` 必填,缺失返回 4002 ### 11.1 管理员登录 `POST /admin/auth/login` @@ -1466,6 +1517,120 @@ App 我的页消息入口红点依赖本接口(直接读 Redis `notif:unread:{ 响应 `{ "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 端关键调用时序 diff --git a/docs/04-部署运行文档.md b/docs/04-部署运行文档.md index f0a2df6..13f1ac6 100644 --- a/docs/04-部署运行文档.md +++ b/docs/04-部署运行文档.md @@ -1,62 +1,166 @@ # 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 | 直接运行,无需编译 | -| 管理后台 | `admin/` | Node ≥ 18 + Vue3 + Vite | `npm run build` → `admin/dist/` 静态文件 | -| App | `app/` | Flutter 3.x(Dart ≥ 3.9) | `flutter build apk` / `flutter build ipa` | +``` + ┌─────────────────────────────────────────┐ + 用户浏览器/App │ 腾讯云服务器 │ + │ │ + 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 -cd server -python3.11 -m venv .venv -source .venv/bin/activate -pip install . # 核心依赖 -pip install ".[pay]" # 可选:支付渠道 SDK(支付宝/微信/Tron),不接支付可跳过 +# 日常连接 +mysql -h 127.0.0.1 -uroot -p256kb.cn -P 3307 video +# 或容器内 +docker exec -it mysql8 mysql -uroot -p256kb.cn video + +# 如需重建容器(数据在 /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 -CREATE DATABASE video CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +- 镜像 `redis:latest`(7.x),端口 **6379**,挂载 `/home/redis/conf` 与 `/home/redis/data` +- 启动命令 `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 -APP_ENV=prod # prod 时 /docs 自动关闭 -APP_HOST=0.0.0.0 -APP_PORT=8000 -DATABASE_URL=mysql+aiomysql://user:pass@127.0.0.1:3306/video?charset=utf8mb4 -REDIS_URL=redis://127.0.0.1:6379/0 -JWT_SECRET=换成足够长的随机串 # 生产必须更换 +[Unit] +Description=Video App FastAPI Backend (uvicorn) +After=network.target docker.service redis.service +Requires=docker.service -# 以下按需配置(不配置则对应功能自动降级:短信/邮件发不出去、支付走 dev_mock、USDT 不启用) -# ALIPAY_APP_ID / ALIPAY_PRIVATE_KEY / ALIPAY_PUBLIC_KEY / ALIPAY_NOTIFY_URL -# WECHAT_MCHID / WECHAT_APP_ID / WECHAT_API_V3_KEY / WECHAT_PRIVATE_KEY / WECHAT_CERT_SERIAL_NO / WECHAT_PLATFORM_CERT / WECHAT_NOTIFY_URL -# TRON_API_KEY(USDT 链上查账) -# SMS_ACCESS_KEY_ID / SMS_ACCESS_KEY_SECRET / SMS_SIGN_NAME(短信验证码) -# SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASSWORD(邮箱验证码) +[Service] +Type=simple +User=root +WorkingDirectory=/root/project/video/server +ExecStart=/root/project/video/server/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 2 +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target ``` -业务参数(积分/会员/激励/提现规则)在 `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 -cd server -.venv/bin/alembic upgrade head # 建 27 张表 +systemctl restart video-api # 改后端代码后重启 +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' import asyncio from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker @@ -77,127 +181,169 @@ asyncio.run(main()) EOF ``` -### 2.4 启动 +后台角色:`super`(全部权限)、`operator`(仪表盘/套餐/赠送)、`reviewer`(视频审核)。 -```bash -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 反代时用): +### 4.4 服务暴露路径(反代依据) | 路径 | 说明 | |---|---| -| `/api/v1/*` | App 全部业务接口 | +| `/api/v1/*` | App 业务接口(9 个路由文件 + admin,共 100+ 端点) | | `/api/v1/admin/*` | 管理后台接口 | -| `/r/{code}` | 推广 H5 落地页(邀请链接) | -| `/static/*` | 本地文件存储(头像/封面/视频,未接 OSS 时) | +| `/r/{code}` | 邀请落地页 | +| `/static/*` | 本地媒体文件 | | `/health` | 健康检查 | -## 3. 管理后台部署(admin/) +## 5. 管理后台发布(admin/) ```bash -cd admin +cd /root/project/video/admin 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 -server { - listen 443 ssl; - server_name admin.example.com; - # ssl_certificate / ssl_certificate_key ... - - root /path/to/video/admin/dist; - 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; } # 推广落地页 -} +root /root/project/video/h5; # index.html 下载页 +location /downloads/ { alias /root/project/video/runtime/downloads/; } # APK +location /videos/ { alias /root/project/video/runtime/videos/; } +location /uploads/ { alias /root/project/video/runtime/uploads/; } +location /r/ { proxy_pass http://127.0.0.1:8000; } # 邀请落地 +location /api/v1/invite/ { proxy_pass http://127.0.0.1:8000; } ``` -浏览器访问 `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` 编译期指定**(测试/自建最简单); -2. 本地缓存的上次成功域名; -3. DNS TXT 发现:解析 `txt.256kb.top` 的 TXT 记录,记录内容即 Base URL 主体; -4. 兜底域名:`md5(yyyyMM) + "_jsnbhh.vip"`(每月变化)。 +### 7.1 域名发现(不硬编码 API 地址) -> 注意:客户端业务路径**不带** `/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`)。 -上线二选一: - -- **方式 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 +### 7.2 一键发版 deploy.sh ```bash -cd app -flutter pub get -# 联调/内测:直连服务器 -flutter build apk --release --dart-define=API_BASE=https://api.example.com/api/v1 -# 正式(走 TXT 域名发现):不带 dart-define 直接构建 -flutter build apk --release +# 1. 改 app/pubspec.yaml 的 version(如 1.3.7+23,+后为 versionCode) +# 2. 执行 +bash /root/project/video/deploy.sh ``` -产物:`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 邀请链接) | -| `app.version.android` / `app.version.ios` | JSON:`latest_version` / `latest_version_code` / `min_version_code` / `update_url` / `notice`,控制版本检查与强制更新 | -| `ios_payment.enabled` | iOS 支付开关(审核期可关闭) | -| 敏感词、轮播公告等 | 按需 | +| keystore | `app/android/app/upload-keystore.jks`(PKCS12,RSA 2048,有效期 30 年) | +| 密码配置 | `app/android/key.properties`(alias=`video_release`) | +| 证书指纹 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 -# 服务端 -curl http://127.0.0.1:8000/health # ok -curl "http://127.0.0.1:8000/api/v1/videos/feed?type=hot&limit=1" # code:0 -curl "http://127.0.0.1:8000/api/v1/search/hot" # code:0 - -# 管理后台:浏览器打开 → 登录 → 视频审核/用户列表有数据 - -# App:安装 APK → 启动页过版本检查 → Feed 出视频 → 注册/登录 → 观看计费 +bash /root/project/video/project-ctl.sh start # docker mysql8/my-redis → 等待就绪 → video-api(enable+start) → 健康检查 +bash /root/project/video/project-ctl.sh stop # video-api 停止并 disable + 停两个容器(重启服务器也不会拉起) +bash /root/project/video/project-ctl.sh restart # 停 → 启 +bash /root/project/video/project-ctl.sh status # 服务/容器/接口探测 ``` -## 7. 已知限制(当前版本) +- 管理范围:`video-api.service` + `mysql8` + `my-redis`;**不含 nginx**(系统组件) +- 服务器重启后如项目处于"运行态",docker 与 systemd 会自动拉起全部组件,无需人工干预 +- 项目当前(2026-10-18 起)处于**暂停态**,恢复运行执行 `start` 即可 -- **媒体存储为本地文件**(`server/uploads`,经 `/static` 访问),接口返回相对路径;生产接入 OSS/CDN 使服务端返回绝对 URL 需二次开发(`storage_service` 已按此边界隔离)。 -- 短信/邮件/USDT/支付宝/微信未配置密钥时自动降级(验证码发不出、支付走 dev_mock),不影响其余功能。 -- CORS 当前为 `*`,生产可在 `app/main.py` 按需收紧。 -- 结算周期任务在 API 进程内运行,见 2.4 单 worker 说明。 +## 9. 备份策略 + +| 对象 | 方式 | 频率建议 | +|---|---|---| +| 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 验证清单 diff --git a/docs/05-功能流程文档.md b/docs/05-功能流程文档.md new file mode 100644 index 0000000..db28b98 --- /dev/null +++ b/docs/05-功能流程文档.md @@ -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 + →「我的 → 已下载」列表(封面/时长/大小)→ 点击经本地代理离线播放(支持拖动)→ 可删除 +``` diff --git a/project-ctl.sh b/project-ctl.sh new file mode 100755 index 0000000..6182949 --- /dev/null +++ b/project-ctl.sh @@ -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 diff --git a/server/alembic/versions/e7a1f3c58d02_add_series_table.py b/server/alembic/versions/e7a1f3c58d02_add_series_table.py new file mode 100644 index 0000000..6453fe3 --- /dev/null +++ b/server/alembic/versions/e7a1f3c58d02_add_series_table.py @@ -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') diff --git a/server/app/core/__pycache__/errors.cpython-311.pyc b/server/app/core/__pycache__/errors.cpython-311.pyc index 8a88367256007d718aaa7371f94815239ac80458..5b300c8fa996f632327722c43e3439c3017ac3a2 100644 GIT binary patch delta 744 zcmXw#%S%*I9LLWc?;YoLpK~Aca(tVwax5E-A{s^`!A#Oz9D+8#yRb!z!u2Q=NF*3n z$-;##qBdnTL=n0O`UCEw#l=;SGug@mLF;~Jw8J@{^ZoGqodf5r=-Wo`YgvvF920xr z?_Ns~@{`N|VV#6On((xDu;PYj3}Q46ahiYxl|iNoC{zWNYM@aabZUS>laQn)nA8G` z+F(-$9GZd@O+%VyAVaf|rMU&-3Xt;zHv}!nCdea2XhllUh73bHvKcy%5$Hr#qR@p~ z9J-MS=t0VG3aLOZQiVRG2K`7K29O2}B9n01HQ|hF!H{c1!F6ERO~F|=4d>hpjJR1i z@8+Pm5PK!i78so+ePfGdeE7lV7q!PPf3H73+}ZiHy?nImT^8oUM@@t*uBH;zVW`k`tE4=N&VS&y}D8NRuA^Js$QrN67t?R z@pe~q{MO9WWVtjqu^%bln7)d6ZeN+4o-a*Jj^CLn-7H<7t+q;=LaZCF7H~b-_r8XU zN7Kn$;ltO?T(QC-V32@?wwN%xH**32bBB*jeQ zNQR#*usD}Qqiu}IT$GbGm5X*FIv0aTvabxYBo~uNDL>uMER+hfiDdiE73T0#s!_Vc z(p)kPDY7h=9Fa`ly2x6%td&a}ku2ZtXYE`%8d6}LT)G-k3`D~>2LjRZ9i-xG eg+NUDR!<<>zBeDqI-|UqAbg@7&-7#$74IY-tTYQaU3UrV~4~LWPykfcR)0w6co=C0n&s6q@YO&+JXv+ z2GQdKaD^$6pg`gRgJ>yuX9=U-|ISbENUPls@l`bYF%l`i_S4^MjVrH?=c2xW*!Mu=sML?%dOicDt6WsX7?C}oLCR%c9c3~H`g z7*aPdqPDR`6&O=H*rs+dq4uyt-NcmYV3+#T#U4HSxIy)Blj`FZHNcD-VxJmePK~ip z2@X_>+bYAM%5kI$ELDj+s>0o~-bXG6IDW$R@6{*#<@z%heEq96Z}DM05~u6;;vILU z^!1$XS6}K!-Bta%dww}cTVLCqtG3yo-H&$gW^4rQOoxd`$491X(PP5b!NhD@I84Mk zSsK@(-yFMdJPZH&SYQz{QR-@LB8!-bTo;c`Vv#nJ6O$31nY_`;6n0cHk?QQBsVoM~ z