# FruitShorts 短剧服务(后端) 短剧服务的服务端,Spring Boot + PostgreSQL,对外两个入口: 管理后台 `/admin/**`(8080)与客户端 `/app/**`(8081)。 | 文档 | 内容 | | --- | --- | | [docs/prd-phase1.md](docs/prd-phase1.md) | 第一阶段 PRD:范围、验收口径、里程碑 | | [docs/backend-design-phase1.md](docs/backend-design-phase1.md) | 后端设计:1 万并发观看的容量推导、成本、部署 | | [docs/api-phase1.md](docs/api-phase1.md) | **接口清单:后台管理 + 客户端,标注已实现 / 下一批** | | [docs/architecture-design.md](docs/architecture-design.md) | 早期架构设计文档 | | 项 | 版本 | | --- | --- | | 运行时 | JDK 21(LTS),编译目标 Java 17 | | 框架 | Spring Boot 3.2.5、Spring Security 6、MyBatis-Plus 3.5.7 | | 数据库 | PostgreSQL 18 | | 构建 | Maven 3.10 多模块 | ## 目录结构 ``` FruitServer/ ├── fruitshorts-common/ 统一响应、分页、错误码、业务异常 ├── fruitshorts-framework/ JWT 鉴权(管理端/客户端双密钥)、权限校验、全局异常、分区维护、MyBatis-Plus、CORS ├── fruitshorts-module-system/ 管理员、角色、权限、菜单、角色分类、配置、操作日志 ├── fruitshorts-module-content/ 短剧、剧集、题材、轮播、审核发布 ├── fruitshorts-module-media/ 分片上传、媒资、播放地址签名、转码任务与多码率产物 ├── fruitshorts-module-user/ C 端账号:验证码、注册登录、会话、资料、注销、风控 ├── fruitshorts-module-playback/ 播放票据、观看进度与流水、收藏、下载授权 ├── fruitshorts-admin-api/ 启动模块与 /admin/** 控制器(8080) ├── fruitshorts-app-api/ 启动模块与 /app/** 控制器(8081) ├── sql/ 建表、初始化数据、分区维护(见下) └── docs/ PRD、后端设计、接口清单 ``` ## 开发环境 本机环境由 Homebrew 统一管理(版本以本机实际安装为准): | 组件 | 版本 | 说明 | | --- | --- | --- | | Homebrew | 7.0.9 | 远端指向清华镜像,见文末常见问题 | | JDK | OpenJDK 21.0.12.1 | `brew install openjdk@21`,`JAVA_HOME` 已写入 `~/.zshrc` | | Maven | 3.10.0 | `brew install maven`,命令为 `/opt/homebrew/bin/mvn` | | PostgreSQL | 18.6 | `brew install postgresql@18`,`brew services start postgresql@18` 启动 | > `postgresql@18` 是 keg-only,命令行工具在 `/opt/homebrew/opt/postgresql@18/bin`, > 用之前把它加到 PATH:`export PATH="/opt/homebrew/opt/postgresql@18/bin:$PATH"`。 ```sh java -version # openjdk 21.0.12.1 mvn -v # Maven 3.10.0 / Java 21.0.12.1 ``` 说明: - 工程编译目标为 Java 17(`maven.compiler.release=17`),在 JDK 21 上构建与运行。 - 父 POM 显式声明了注解处理器(`full` + `annotationProcessorPaths`): JDK 23 起不再默认扫描 classpath 上的处理器,缺这段配置 Lombok 会失效。 - 旧的 JDK 18 仍留在 `/Library/Java/JavaVirtualMachines/jdk-18.0.2.jdk`,删除需要 sudo, 不影响使用(`java_home` 与 `JAVA_HOME` 都已指向 21)。 ## 快速开始 服务有两个入口,可以只启动其中一个: | 服务 | 模块 | 端口 | 面向 | | --- | --- | --- | --- | | 管理端 | `fruitshorts-admin-api` | 8080 | Vue 后台 | | 客户端 | `fruitshorts-app-api` | 8081 | Flutter 客户端 | ### 1. 初始化数据库 ```sh export PATH="/opt/homebrew/opt/postgresql@18/bin:$PATH" cd /Users/jianghaifeng/Desktop/ReelShort/FruitServer psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -f sql/schema.sql psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -f sql/data.sql psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -f sql/schema-phase1.sql psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -f sql/partition-maintenance.sql psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -f sql/data-phase1.sql ``` | 脚本 | 作用 | | --- | --- | | `schema.sql` / `data.sql` | 系统域与内容域的基础表与初始化数据 | | `schema-phase1.sql` | 第一阶段增量表:用户、播放记录、互动、交易、创作者、转码、运营 | | `partition-maintenance.sql` | 分区函数 + 把高写入表转成分区 + 预建未来 3 个月分区 | | `data-phase1.sql` | 新增权限点、超管授权、占位商品 | 四个脚本都幂等可重复执行,**顺序不要颠倒**:分区转换要求目标表是空的。 数据库名 `fruitshorts`,角色 `fruitshorts`(密码 `fruitshorts`)。 首次搭建环境时先建角色与库: ```sh psql -d postgres -c "CREATE ROLE fruitshorts LOGIN PASSWORD 'fruitshorts';" psql -d postgres -c "CREATE DATABASE fruitshorts OWNER fruitshorts ENCODING 'UTF8';" ``` 连接信息在 `fruitshorts-admin-api/src/main/resources/application.yml`,可用环境变量 `DB_USER` / `DB_PASSWORD` / `JWT_ADMIN_SECRET` 覆盖。 ### 关于 PostgreSQL 的两点说明 - 自增列用 `GENERATED BY DEFAULT AS IDENTITY`;`sql/data.sql` 里用了显式 ID, 脚本末尾会把序列 `setval` 到当前最大值,新增数据不会主键冲突。 - PostgreSQL 没有 `ON UPDATE CURRENT_TIMESTAMP`,`updated_at` 由 `set_updated_at()` 触发器统一维护(见 `sql/schema.sql` 末尾),实体侧仍不参与更新该字段。 - 轮播图的展示位置列名叫 `slot`(`position` 在 PostgreSQL 里是关键字), 实体用 `@TableField("slot")` 映射,对外接口字段仍是 `position`,前端无需改动。 ### 大表分区与保留策略 **为什么要一开始就分区**:`watch_history`(观看流水)、`playback_ticket`(播放票据)、 `order_event`(订单流水)都是「每次行为写一行」的表。按 1 万并发观看估算,观看流水约 300 万行/天、播放票据约 360 万行/天,一年就是十亿级。这类表后补分区的代价很高 (要在维护窗口里整表重建),所以第一阶段就按 `watched_at` / `created_at` 做按月声明式分区。 **光建表没用**:声明式分区不会自动长分区,只建 default 分区的话数据会全堆进 default, 分区等于白做。所以有两层保障: 1. `sql/partition-maintenance.sql` 里的 `ensure_monthly_partitions(table, from, months)` 负责按月建分区,执行脚本时会建好当前月 + 未来 3 个月; 2. `fruitshorts-framework` 的 `PartitionMaintainer` 在**启动时**和**每天 03:17** 各调一次, 跨月时即使服务停机一周也不会漏建。 验证分区是否真的生效(应落在 `watch_history_2026xx` 而不是 `_default`): ```sh psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -c \ "select tableoid::regclass as 分区, count(*) from watch_history group by 1;" ``` **保留策略默认只建不删**。删数据不可逆,第一阶段先解决"写不爆",等保留期与合规确认后 再把 `fruitshorts.partition.retention-enabled` 打开;各表保留月数写在 `PartitionMaintainer` 的 `TABLES` 常量里(观看流水 24 个月、播放票据 6 个月、订单流水 36 个月)。手工清理: ```sql SELECT drop_partitions_before('watch_history', CURRENT_DATE - INTERVAL '24 months'); ``` 分区表有两条 PostgreSQL 限制要注意:唯一索引必须包含分区键,所以 `playback_ticket` 的唯一索引是 `(ticket_no, created_at)`、主键是 `(id, created_at)`。 `coin_ledger` 故意**不分区** —— 它靠 `idempotent_key` 全局唯一保证不重复发币, 这个唯一性比分区更重要。 ### 2. 构建 ```sh cd /Users/jianghaifeng/Desktop/ReelShort/FruitServer mvn -DskipTests package ``` ### 3. 启动 ```sh cd /Users/jianghaifeng/Desktop/ReelShort/FruitServer java -jar fruitshorts-admin-api/target/fruitshorts-admin-api-1.0.0-SNAPSHOT.jar java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar ``` 启动后:服务地址 `http://localhost:8080`,接口文档 `http://localhost:8080/doc.html`。 ## 关键配置 | 配置项 | 默认值 | 说明 | | --- | --- | --- | | `fruitshorts.jwt.admin-secret` | 开发默认值 | 管理端签名密钥,**生产必须用 `JWT_ADMIN_SECRET` 注入** | | `fruitshorts.jwt.app-secret` | 开发默认值 | 客户端签名密钥,与管理端物理隔离,两边 token 不能互换 | | `fruitshorts.jwt.app-access-ttl-minutes` | 120 | 客户端 access token 有效期 | | `fruitshorts.jwt.app-refresh-ttl-days` | 30 | refresh token 有效期,每次刷新轮换 | | `fruitshorts.media.signature-secret` | 开发默认值 | 播放地址签名密钥,生产必须注入 | | `fruitshorts.media.signature-ttl-minutes` | 15 | 播放地址有效期 | | `fruitshorts.media.signature-required` | `false` | **生产必须打开**;打开后 `/files/episode/**` 无有效签名返回 403 | | `fruitshorts.otp.sender` | `log` | 验证码通道,开发打日志;接入短信/邮件时实现 `OtpSender` 并改这里 | | `fruitshorts.partition.enabled` | `true` | 是否自动维护分区 | | `fruitshorts.partition.months-ahead` | 3 | 预建未来几个月分区 | | `fruitshorts.partition.retention-enabled` | `false` | 是否自动清理过期分区(默认只建不删) | 开发阶段签名校验默认关闭,是为了不打断正在联调的客户端。**上线前必须打开**, 否则片源地址可以被任意复用与盗链。 ## 第一阶段实现进度 | 模块 | 状态 | 说明 | | --- | --- | --- | | 01 基础与交付准备 | 已实现 | 多模块骨架、接口契约、双密钥鉴权、分区维护 | | 02 用户账号与登录 | 已实现 | 手机号/邮箱注册登录、验证码反刷、令牌轮换、资料、注销、封禁即时生效 | | 03 内容发现与详情 | 已实现 | 首页/发现/分类/详情;补内容分级、可播区域、版权窗口、标签字段 | | 04 播放器与播放控制 | 部分 | 播放票据 + 签名地址 + 进度已实现;多码率片源待转码 worker | | 05 记录、收藏与下载 | 部分 | 进度、观看流水、收藏已实现;离线下载授权待做 | | 06 评论、点赞与分享 | 待做 | 表已建,接口与实现待补 | | 07 创作者中心 | 待做 | 表已建;转码任务与多码率产物记账已实现 | | 08 平台运营后台 | 部分 | App 用户管理已实现;内容治理、订单、看板待做 | | 09 分集收费与支付 | 待做 | 表已建;播放入口的权益判定端口已预留,接上即生效 | | 10 质量、数据与上线 | 待做 | 压测、埋点、对账待做 | ## 默认账号 密码均为 `admin123`。 | 账号 | 角色 | 权限范围 | | --- | --- | --- | | admin | 超级管理员 | 全部 14 项权限 | | content.editor01 | 内容运营 | 短剧、剧集的增删改与封面上传 | | content.reviewer01 | 审核专员 | 查看内容、审核发布、数据报表 | | data.analyst01 | 数据分析师 | 查看短剧列表与数据报表 | ## 接口一览 ### 认证 `/admin/auth` | 方法 | 路径 | 说明 | | --- | --- | --- | | POST | `/login` | 登录,返回 token 与权限码 | | GET | `/menus` | 当前用户菜单路由树(前端动态路由) | | GET | `/profile` | 当前登录人信息 | | POST | `/logout` | 登出(JWT 无状态,前端清除 token 即可) | | POST | `/password` | 修改自己的密码 | ### 后台管理 | 模块 | 接口 | | --- | --- | | 管理员 | `GET /admin/admin-users/page`、`POST /admin/admin-users`、`PUT\|DELETE /admin/admin-users/{id}`、`PUT /{id}/status`、`PUT /{id}/password` | | 角色 | `GET /admin/roles/page`、`GET /admin/roles/list`、`POST /admin/roles`、`PUT\|DELETE /admin/roles/{id}`、`GET\|PUT /admin/roles/{id}/permissions` | | 角色分类 | `GET\|POST /admin/role-categories`、`PUT\|DELETE /admin/role-categories/{id}` | | 权限点 | `GET /admin/permissions`(按分组返回,供权限配置页使用) | | 菜单 | `GET /admin/menus/tree`、`POST /admin/menus`、`PUT\|DELETE /admin/menus/{id}` | | 系统配置 | `GET\|PUT /admin/configs` | | 操作日志 | `GET /admin/operation-logs/page`(按操作人、模块、动作、结果、时间范围筛选)、`GET /admin/operation-logs/{id}` | | 字典 | `GET /admin/dicts/drama-category`、`GET /admin/dicts/content-status` | ### 内容管理 | 模块 | 接口 | | --- | --- | | 短剧 | `GET /admin/dramas/page`、`GET /admin/dramas/{id}`、`POST /admin/dramas`、`PUT\|DELETE /admin/dramas/{id}` | | 审核发布 | `POST /admin/dramas/{id}/submit`、`/approve`、`/reject`、`/publish`、`/offline` | | 审核记录 | `GET /admin/dramas/{id}/review-records` | | 剧集 | `GET /admin/episodes?dramaId=`、`POST /admin/episodes`、`PUT\|DELETE /admin/episodes/{id}`、`PUT /admin/episodes/sort` | | 轮播图 | `GET /admin/banners/page`、`POST /admin/banners`、`PUT\|DELETE /admin/banners/{id}`、`PUT /admin/banners/{id}/status`、`PUT /admin/banners/sort` | | 短剧类型 | `GET\|POST /admin/drama-categories`、`PUT\|DELETE /admin/drama-categories/{id}`(权限沿用 `drama:*`) | ### 媒资 `/admin/media` | 方法 | 路径 | 说明 | | --- | --- | --- | | POST | `/upload/init` | 建立分片会话,返回 `uploadId`、`chunkSize`、`totalChunks` | | POST | `/upload/chunk` | 上传单个分片(multipart:`uploadId`、`index`、`file`) | | POST | `/upload/complete` | 合并分片,返回媒资(含可访问的 `url`) | | GET | `/{id}` | 查询媒资 | ## 媒资存储(本地) 当前没有对象存储,文件直接落在本机磁盘: | 项 | 默认值 | 说明 | | --- | --- | --- | | 存储目录 | `./data/uploads` | 配置项 `fruitshorts.media.root-dir`,按 `业务类型/年月/uuid.ext` 分目录 | | 访问前缀 | `/files/**` | 配置项 `fruitshorts.media.public-prefix`,映射为静态资源 | | 分片大小 | 5MB | `fruitshorts.media.chunk-size`,单文件上限 2GB | | 请求上限 | 20MB | `spring.servlet.multipart`,只需容纳一个分片 | 要点: - `/files/**` 在 `SecurityConfig` 里是公开路径,因为 `` / `