# FruitShorts 短剧服务 · 后端架构设计 > 技术栈:JDK 21(LTS,编译目标 Java 17)+ Spring Boot 3.2 + PostgreSQL 18 + Redis > > 实现说明:项目已从 MySQL 切换到 PostgreSQL。本文档第 5 章的 DDL 仍按 MySQL 语法书写, > 实际建表语句见 `sql/schema.sql`(PostgreSQL 版),两者的字段与索引一一对应; > 主要差异是自增列用 IDENTITY、`updated_at` 用触发器维护、索引独立创建。 > 面向两端:管理后台(ReelShortWeb / Vue3)、客户端(ReelShort / Flutter) > 版本:v1(2026-10-14) --- ## 1. 背景与目标 ### 1.1 现状 | 端 | 目录 | 技术 | 状态 | | --- | --- | --- | --- | | 管理后台 | `ReelShort/ReelShortWeb` | Vue3 + Vite + element-plus-admin | 页面完成,数据为前端 mock | | 客户端 | `ReelShort/ReelShort` | Flutter | 页面完成,数据为本地演示数据 | | 服务端 | `FruitServer` | Spring Boot 3 + MyBatis-Plus + PostgreSQL | 已实现 | 两端的领域模型已经落在代码里,后端要按它们已有的字段和交互来设计,而不是重新发明一套: - 后台:短剧(`title / genre / status / episodes / views / updatedAt / description / cover`)、剧集(`dramaId / number / title / duration / status / views`)、角色、角色分类、权限点、菜单、管理员、系统设置。 - 客户端:短剧目录、剧集列表(`isFree` 决定能否播放)、收藏、点赞、评论、观看历史、金币、会员。 - 后台现在靠 mock 提供登录(`POST /mock/user/login`)与菜单路由树(`GET /mock/role/list`);统一响应壳是 `{ code: 0, message, data }`,鉴权头是 `Authorization`,`code === 401` 触发登出。后端必须能一比一替换掉这两个 mock。 ### 1.2 设计目标 1. 一套服务同时支撑 B 端(管理后台)与 C 端(客户端),共用内容域数据。 2. 先做模块化单体,边界清晰,规模上来后可按模块拆分,不做超前微服务化。 3. 内容生产链路(上传 → 转码 → 审核 → 发布)与播放链路(鉴权 → 签名地址 → 防盗链)是两条主线,一次设计到位。 4. 变现能力(金币 / 会员 / 单集解锁 / 充值订单)从第一天就纳入领域模型,避免后期改表。 --- ## 2. 总体架构 ``` ┌──────────────────┐ ┌──────────────────┐ │ Flutter 客户端 │ │ Vue 管理后台 │ └────────┬─────────┘ └────────┬─────────┘ │ /app/** │ /admin/** └────────────┬──────────────┘ ▼ ┌───────────────┐ │ Nginx / SLB │ 路由、TLS、限流、静态资源 └───────┬───────┘ ▼ ┌─────────────────────────────┐ │ Spring Boot 应用(单体多模块)│ │ admin-api / app-api / job │ └───┬───────┬────────┬────────┘ │ │ │ ┌─────▼──┐ ┌──▼───┐ ┌──▼─────────┐ │ PgSQL │ │Redis │ │ 对象存储+CDN │ └────────┘ └──────┘ └─────┬──────┘ │ ┌────────▼────────┐ │ 转码 Worker(FFmpeg)│ 消费队列,产出 HLS/MP4 └─────────────────┘ ``` 分层原则: - 接入层:`admin-api` 与 `app-api` 是两个独立入口,各自独立的鉴权过滤器、限流策略、参数校验,互不污染。 - 领域层:按业务域切模块(system / content / media / member / interaction / trade / stat),模块之间只通过 Service 接口调用,禁止跨模块直连 Mapper。 - 基础设施层:`framework` 收敛所有技术细节(安全、缓存、MyBatis、日志、OSS SDK),业务模块不直接依赖第三方 SDK。 --- ## 3. 技术选型 | 领域 | 选型 | 说明 | | --- | --- | --- | | 语言/运行时 | JDK 21(LTS) | 编译目标 Java 17;Spring Boot 3 要求 17+ | | 框架 | Spring Boot 3.2.x + Spring MVC | 前后端分离,纯 REST | | 持久层 | MyBatis-Plus 3.5.x | 后台 CRUD 密集,代码生成 + 分页插件省事 | | 数据库 | PostgreSQL 18(UTF8) | 逻辑删除 + 乐观锁字段统一规范;DDL 见 sql/schema.sql | | 缓存 | Redis 7 | 登录态、验证码、热点目录、播放签名、分布式锁 | | 鉴权 | Spring Security + JWT(jjwt) | B 端与 C 端共用签发逻辑,各自独立过滤器 | | 对象存储 | 阿里云 OSS / S3 兼容(本地用 MinIO) | 视频与封面,分片直传 | | 转码 | FFmpeg + 异步队列 | 产出 HLS(多码率)+ MP4 兜底 + 首帧封面 | | 消息队列 | RabbitMQ(起步) | 转码任务、订单超时、统计埋点;量级大再换 Kafka | | 接口文档 | springdoc-openapi | 自动生成 OpenAPI 3 | | 定时任务 | Spring Scheduling / XXL-Job | 定时发布、日报聚合、支付对账 | | 构建 | Maven 多模块 | 见第 4 节 | --- ## 4. 工程结构(Maven 多模块) ``` java/ ├── pom.xml # 聚合父 POM,统一依赖版本 ├── fruitshorts-common/ # 统一响应/异常/常量/枚举/工具 ├── fruitshorts-framework/ # 安全、MyBatis、Redis、日志、限流、OSS、MQ 客户端 ├── fruitshorts-module-system/ # 管理员、角色、权限、菜单、配置、操作日志 ├── fruitshorts-module-content/ # 短剧、剧集、分类标签、审核发布 ├── fruitshorts-module-media/ # 媒资上传、转码、播放地址签名 ├── fruitshorts-module-member/ # C 端用户、登录、设备、资料 ├── fruitshorts-module-interaction/ # 收藏、点赞、评论、观看历史 ├── fruitshorts-module-trade/ # 金币、流水、解锁、订单、VIP ├── fruitshorts-module-stat/ # 数据看板、日聚合 ├── fruitshorts-admin-api/ # B 端接口,/admin/** ├── fruitshorts-app-api/ # C 端接口,/app/** └── fruitshorts-job/ # 定时任务与异步消费者 ``` 起步阶段若不想铺这么开,可先合并为 `fruitshorts-common` + `fruitshorts-server`(含 system/content/media)+ `fruitshorts-app-api`,但包结构仍按域划分(`com.fruitshorts.content.*`),后续拆模块零成本。 模块内部结构保持一致: ``` com.fruitshorts.content ├── controller/ # 仅 admin-api / app-api 暴露 ├── service/ # 接口 + impl ├── dal/ │ ├── entity/ # 数据库实体 │ ├── mapper/ # MyBatis-Plus Mapper │ └── redis/ # 该域的缓存操作 ├── convert/ # entity <-> VO/DTO 转换(MapStruct) └── api/ # 暴露给其它模块的 Service 接口 ``` 命名约定: - 包名 `com.fruitshorts..` - 实体 `Drama`、`DramaEpisode`、`AppUser` - 请求对象 `XxxCreateReqVO` / `XxxUpdateReqVO` / `XxxPageReqVO`,响应对象 `XxxRespVO` - 表名 `<域前缀>_<业务>`,如 `drama`、`drama_episode`、`sys_role`、`app_user`、`stat_drama_daily` --- ## 5. 领域模型与数据库设计 ### 5.1 设计约定 - 主键统一 `bigint unsigned`;对外可选用雪花 ID,不暴露自增趋势。 - 所有表带 `created_at / updated_at`,业务表带 `deleted tinyint default 0`(逻辑删除)。 - 金额一律用 `int` 分或 `bigint` 微单位,禁用 float;金币用 `bigint`。 - 状态字段存英文码,中文只用于前端展示,不把界面文案写进数据库。后台现有文案映射: | 前端展示 | 存储码 | | --- | --- | | 草稿 | `draft` | | 审核中 / 待审核 | `reviewing` | | 待发布 | `approved` | | 已上线 | `published` | | 已下线 | `offline` | ### 5.2 系统域(B 端权限) ```sql CREATE TABLE sys_admin_user ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL COMMENT '登录名', password VARCHAR(100) NOT NULL COMMENT 'BCrypt 哈希', real_name VARCHAR(64) NOT NULL DEFAULT '', mobile VARCHAR(20) NOT NULL DEFAULT '', email VARCHAR(128) NOT NULL DEFAULT '', avatar VARCHAR(255) NOT NULL DEFAULT '', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', ip_whitelist VARCHAR(255) NOT NULL DEFAULT '' COMMENT '空=不限', two_factor TINYINT NOT NULL DEFAULT 0 COMMENT '1已开启 0未开启', last_login_at DATETIME(3) NULL, last_login_ip VARCHAR(64) NOT NULL DEFAULT '', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), deleted TINYINT NOT NULL DEFAULT 0, UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sys_role_category ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, description VARCHAR(255) NOT NULL DEFAULT '', sort INT NOT NULL DEFAULT 0, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), deleted TINYINT NOT NULL DEFAULT 0, UNIQUE KEY uk_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sys_role ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, code VARCHAR(64) NOT NULL COMMENT '如 super_admin / content_operator', category_id BIGINT UNSIGNED NOT NULL DEFAULT 0, level INT NOT NULL DEFAULT 9 COMMENT '越小权限越高', description VARCHAR(255) NOT NULL DEFAULT '', status TINYINT NOT NULL DEFAULT 1, is_builtin TINYINT NOT NULL DEFAULT 0 COMMENT '内置角色不可删', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), deleted TINYINT NOT NULL DEFAULT 0, UNIQUE KEY uk_code (code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sys_admin_user_role ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, role_id BIGINT UNSIGNED NOT NULL, UNIQUE KEY uk_user_role (user_id, role_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sys_permission ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, code VARCHAR(128) NOT NULL COMMENT '如 drama:create', name VARCHAR(64) NOT NULL COMMENT '如 创建短剧', group_name VARCHAR(64) NOT NULL COMMENT '如 短剧管理', type VARCHAR(16) NOT NULL DEFAULT 'BUTTON' COMMENT 'MENU/BUTTON', sort INT NOT NULL DEFAULT 0, UNIQUE KEY uk_code (code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sys_menu ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, parent_id BIGINT UNSIGNED NOT NULL DEFAULT 0, title VARCHAR(64) NOT NULL, route_path VARCHAR(128) NOT NULL DEFAULT '', route_name VARCHAR(64) NOT NULL DEFAULT '', component VARCHAR(255) NOT NULL DEFAULT '' COMMENT '如 views/contentManagement/DramaManagementView', icon VARCHAR(64) NOT NULL DEFAULT '', permission_code VARCHAR(128) NOT NULL DEFAULT '', type VARCHAR(16) NOT NULL DEFAULT 'MENU' COMMENT 'CATALOG/MENU/BUTTON', sort INT NOT NULL DEFAULT 0, visible TINYINT NOT NULL DEFAULT 1, status TINYINT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sys_role_permission ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, role_id BIGINT UNSIGNED NOT NULL, permission_id BIGINT UNSIGNED NOT NULL, UNIQUE KEY uk_role_perm (role_id, permission_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sys_role_menu ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, role_id BIGINT UNSIGNED NOT NULL, menu_id BIGINT UNSIGNED NOT NULL, UNIQUE KEY uk_role_menu (role_id, menu_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sys_config ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, config_key VARCHAR(128) NOT NULL, config_value VARCHAR(1024) NOT NULL DEFAULT '', remark VARCHAR(255) NOT NULL DEFAULT '', UNIQUE KEY uk_key (config_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sys_operation_log ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, admin_user_id BIGINT UNSIGNED NOT NULL, username VARCHAR(64) NOT NULL DEFAULT '', module VARCHAR(64) NOT NULL DEFAULT '', action VARCHAR(64) NOT NULL DEFAULT '', target_type VARCHAR(64) NOT NULL DEFAULT '', target_id BIGINT UNSIGNED NOT NULL DEFAULT 0, detail JSON NULL, ip VARCHAR(64) NOT NULL DEFAULT '', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), KEY idx_admin_time (admin_user_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` ### 5.3 内容域 ```sql CREATE TABLE drama_category ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, sort INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1, UNIQUE KEY uk_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE drama ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, code VARCHAR(64) NOT NULL DEFAULT '' COMMENT '对外编号', title VARCHAR(128) NOT NULL, subtitle VARCHAR(128) NOT NULL DEFAULT '', category_id BIGINT UNSIGNED NOT NULL DEFAULT 0, genre VARCHAR(64) NOT NULL DEFAULT '' COMMENT '展示用题材', synopsis VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '简介,对应前端 description', cover_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '竖版封面', horizontal_cover_url VARCHAR(512) NOT NULL DEFAULT '', language VARCHAR(16) NOT NULL DEFAULT 'zh-CN', total_episodes INT NOT NULL DEFAULT 0 COMMENT '冗余,由剧集同步', free_episodes INT NOT NULL DEFAULT 3 COMMENT '前 N 集免费', unlock_coin INT NOT NULL DEFAULT 0 COMMENT '默认单集解锁金币', status VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT 'draft/reviewing/approved/published/offline', publish_at DATETIME(3) NULL COMMENT '定时上线时间', published_at DATETIME(3) NULL, view_count BIGINT NOT NULL DEFAULT 0 COMMENT '展示口径播放量', play_count BIGINT NOT NULL DEFAULT 0, favorite_count BIGINT NOT NULL DEFAULT 0, like_count BIGINT NOT NULL DEFAULT 0, sort INT NOT NULL DEFAULT 0, create_by BIGINT UNSIGNED NOT NULL DEFAULT 0, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), deleted TINYINT NOT NULL DEFAULT 0, KEY idx_status_publish (status, publish_at), KEY idx_category (category_id), KEY idx_updated (updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE drama_episode ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, drama_id BIGINT UNSIGNED NOT NULL, ep_no INT NOT NULL COMMENT '第几集,对应前端 number', title VARCHAR(128) NOT NULL DEFAULT '', duration_sec INT NOT NULL DEFAULT 0 COMMENT '前端展示 mm:ss,存秒', cover_url VARCHAR(512) NOT NULL DEFAULT '', video_asset_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '指向 media_asset', is_free TINYINT NOT NULL DEFAULT 0, unlock_coin INT NOT NULL DEFAULT 0, status VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT 'draft/approved/published/offline', release_at DATETIME(3) NULL COMMENT '预约/定时发布', play_count BIGINT NOT NULL DEFAULT 0, sort INT NOT NULL DEFAULT 0, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), deleted TINYINT NOT NULL DEFAULT 0, UNIQUE KEY uk_drama_ep (drama_id, ep_no), KEY idx_drama_status (drama_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE drama_review_record ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(16) NOT NULL COMMENT 'DRAMA/EPISODE', biz_id BIGINT UNSIGNED NOT NULL, action VARCHAR(16) NOT NULL COMMENT 'SUBMIT/APPROVE/REJECT/PUBLISH/OFFLINE', reason VARCHAR(500) NOT NULL DEFAULT '', operator_id BIGINT UNSIGNED NOT NULL DEFAULT 0, operator_name VARCHAR(64) NOT NULL DEFAULT '', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), KEY idx_biz (biz_type, biz_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` ### 5.4 媒资域 ```sql CREATE TABLE media_asset ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(16) NOT NULL COMMENT 'EPISODE/DRAMA_COVER/AVATAR', file_name VARCHAR(255) NOT NULL DEFAULT '', object_key VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'OSS 原始文件 key', source_url VARCHAR(512) NOT NULL DEFAULT '', size_bytes BIGINT NOT NULL DEFAULT 0, md5 VARCHAR(64) NOT NULL DEFAULT '', duration_sec INT NOT NULL DEFAULT 0, width INT NOT NULL DEFAULT 0, height INT NOT NULL DEFAULT 0, status VARCHAR(16) NOT NULL DEFAULT 'uploading' COMMENT 'uploading/uploaded/transcoding/ready/failed', hls_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'm3u8 播放地址', mp4_url VARCHAR(512) NOT NULL DEFAULT '', poster_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '首帧封面', create_by BIGINT UNSIGNED NOT NULL DEFAULT 0, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), deleted TINYINT NOT NULL DEFAULT 0, KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE transcode_task ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, asset_id BIGINT UNSIGNED NOT NULL, spec VARCHAR(32) NOT NULL DEFAULT '720p' COMMENT '360p/540p/720p/1080p', status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/running/success/failed', progress INT NOT NULL DEFAULT 0, error_msg VARCHAR(500) NOT NULL DEFAULT '', started_at DATETIME(3) NULL, finished_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), KEY idx_asset (asset_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` ### 5.5 会员与互动域(C 端) ```sql CREATE TABLE app_user ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, nickname VARCHAR(64) NOT NULL DEFAULT '', avatar VARCHAR(512) NOT NULL DEFAULT '', mobile VARCHAR(20) NOT NULL DEFAULT '', email VARCHAR(128) NOT NULL DEFAULT '', password VARCHAR(100) NOT NULL DEFAULT '' COMMENT '可空,优先验证码登录', gender VARCHAR(16) NOT NULL DEFAULT 'unknown', birthday DATE NULL, register_channel VARCHAR(32) NOT NULL DEFAULT 'app', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0封禁', vip_expire_at DATETIME(3) NULL COMMENT '冗余,便于查询', last_login_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), deleted TINYINT NOT NULL DEFAULT 0, UNIQUE KEY uk_mobile (mobile) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE user_watch_history ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, drama_id BIGINT UNSIGNED NOT NULL, episode_id BIGINT UNSIGNED NOT NULL, position_sec INT NOT NULL DEFAULT 0, duration_sec INT NOT NULL DEFAULT 0, finished TINYINT NOT NULL DEFAULT 0, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), UNIQUE KEY uk_user_episode (user_id, episode_id), KEY idx_user_time (user_id, updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE user_favorite ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, drama_id BIGINT UNSIGNED NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), UNIQUE KEY uk_user_drama (user_id, drama_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE user_like ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, drama_id BIGINT UNSIGNED NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), UNIQUE KEY uk_user_drama (user_id, drama_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE drama_comment ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, drama_id BIGINT UNSIGNED NOT NULL, episode_id BIGINT UNSIGNED NOT NULL DEFAULT 0, user_id BIGINT UNSIGNED NOT NULL, parent_id BIGINT UNSIGNED NOT NULL DEFAULT 0, content VARCHAR(500) NOT NULL, like_count INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0隐藏', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), deleted TINYINT NOT NULL DEFAULT 0, KEY idx_drama_time (drama_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` ### 5.6 交易域(金币 / 会员 / 解锁) ```sql CREATE TABLE user_coin_account ( user_id BIGINT UNSIGNED PRIMARY KEY, balance BIGINT NOT NULL DEFAULT 0, total_recharge BIGINT NOT NULL DEFAULT 0, total_consume BIGINT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0 COMMENT '乐观锁', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE coin_transaction ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, type VARCHAR(16) NOT NULL COMMENT 'RECHARGE/UNLOCK/REFUND/GIFT/ADJUST', amount BIGINT NOT NULL COMMENT '正数入账,负数出账', balance_after BIGINT NOT NULL, biz_type VARCHAR(32) NOT NULL DEFAULT '', biz_id BIGINT UNSIGNED NOT NULL DEFAULT 0, idempotent_key VARCHAR(128) NOT NULL DEFAULT '', remark VARCHAR(255) NOT NULL DEFAULT '', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), UNIQUE KEY uk_idempotent (idempotent_key), KEY idx_user_time (user_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE episode_unlock ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, drama_id BIGINT UNSIGNED NOT NULL, episode_id BIGINT UNSIGNED NOT NULL, unlock_type VARCHAR(16) NOT NULL COMMENT 'COIN/VIP', coin_cost BIGINT NOT NULL DEFAULT 0, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), UNIQUE KEY uk_user_episode (user_id, episode_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE vip_plan ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, days INT NOT NULL, price_cent INT NOT NULL, original_price_cent INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1, sort INT NOT NULL DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE app_order ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, user_id BIGINT UNSIGNED NOT NULL, biz_type VARCHAR(16) NOT NULL COMMENT 'RECHARGE/VIP', product_id BIGINT UNSIGNED NOT NULL DEFAULT 0, subject VARCHAR(128) NOT NULL DEFAULT '', amount_cent INT NOT NULL, coin_amount BIGINT NOT NULL DEFAULT 0, channel VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'alipay/wechat/apple/stripe', channel_trade_no VARCHAR(128) NOT NULL DEFAULT '', status VARCHAR(16) NOT NULL DEFAULT 'CREATED' COMMENT 'CREATED/PAID/CLOSED/REFUNDED', paid_at DATETIME(3) NULL, expire_at DATETIME(3) NULL COMMENT '超时关单时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_channel_trade (channel, channel_trade_no), KEY idx_user_status (user_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE vip_membership ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, plan_id BIGINT UNSIGNED NOT NULL, order_id BIGINT UNSIGNED NOT NULL, start_at DATETIME(3) NOT NULL, expire_at DATETIME(3) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), KEY idx_user (user_id, expire_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` ### 5.7 统计域 ```sql CREATE TABLE stat_drama_daily ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, stat_date DATE NOT NULL, drama_id BIGINT UNSIGNED NOT NULL, play_count BIGINT NOT NULL DEFAULT 0, uv BIGINT NOT NULL DEFAULT 0, new_favorite BIGINT NOT NULL DEFAULT 0, unlock_count BIGINT NOT NULL DEFAULT 0, coin_income BIGINT NOT NULL DEFAULT 0, vip_income_cent BIGINT NOT NULL DEFAULT 0, UNIQUE KEY uk_date_drama (stat_date, drama_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE stat_episode_daily ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, stat_date DATE NOT NULL, episode_id BIGINT UNSIGNED NOT NULL, drama_id BIGINT UNSIGNED NOT NULL, play_count BIGINT NOT NULL DEFAULT 0, uv BIGINT NOT NULL DEFAULT 0, avg_watch_sec INT NOT NULL DEFAULT 0, UNIQUE KEY uk_date_episode (stat_date, episode_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` --- ## 6. 接口设计 ### 6.1 统一约定 - 基础路径:B 端 `/admin/**`,C 端 `/app/**`。 - 响应壳(与后台 axios 拦截器一致):`{ "code": 0, "message": "ok", "data": ... }`;失败 `code != 0`;未登录 `code = 401`。 - 分页:请求 `pageIndex`(从 1 开始)、`pageSize`;响应 `{ "total": n, "list": [...] }`。 - 鉴权:请求头 `Authorization: Bearer `;B 端与 C 端 token 独立签发、独立校验。 - 时间:`yyyy-MM-dd HH:mm:ss` 或毫秒时间戳,全项目统一一种。 ### 6.2 B 端接口 必须与现有 mock 结构兼容的两个接口: | 方法 | 路径 | 说明 | 兼容要求 | | --- | --- | --- | --- | | POST | `/admin/auth/login` | 管理员登录 | 返回 `data` 至少含 `username / role / roleId / token`,字段名与 `mock/user/index.mock.ts` 一致;`permissions` 换为真实权限码数组 | | GET | `/admin/auth/menus` | 当前用户菜单路由树 | 结构与 `mock/role/index.mock.ts` 的 `adminList` 完全一致(`path / component / name / meta.title / meta.icon`),`component` 仍是前端组件字符串 | 其余 B 端接口: | 模块 | 接口 | | --- | --- | | 认证 | `POST /admin/auth/login`、`POST /admin/auth/logout`、`GET /admin/auth/profile`、`POST /admin/auth/password` | | 管理员 | `GET /admin/admin-users/page`、`POST /admin/admin-users`、`PUT /admin/admin-users/{id}`、`DELETE /admin/admin-users/{id}`、`PUT /admin/admin-users/{id}/status`、`PUT /admin/admin-users/{id}/password` | | 角色 | `GET /admin/roles/page`、`POST /admin/roles`、`PUT /admin/roles/{id}`、`DELETE /admin/roles/{id}`、`GET /admin/roles/{id}/permissions`、`PUT /admin/roles/{id}/permissions` | | 角色分类 | `GET /admin/role-categories`、`POST /admin/role-categories`、`PUT /admin/role-categories/{id}`、`DELETE /admin/role-categories/{id}` | | 权限点 | `GET /admin/permissions`(按 group 分组,直接喂权限配置页) | | 菜单 | `GET /admin/menus/tree`、`POST /admin/menus`、`PUT /admin/menus/{id}`、`DELETE /admin/menus/{id}` | | 短剧 | `GET /admin/dramas/page`、`GET /admin/dramas/{id}`、`POST /admin/dramas`、`PUT /admin/dramas/{id}`、`DELETE /admin/dramas/{id}`、`PUT /admin/dramas/{id}/status` | | 剧集 | `GET /admin/dramas/{dramaId}/episodes`、`POST /admin/episodes`、`PUT /admin/episodes/{id}`、`DELETE /admin/episodes/{id}`、`PUT /admin/episodes/sort` | | 审核 | `POST /admin/dramas/{id}/submit`、`/approve`、`/reject`、`/publish`、`/offline` | | 媒资 | `POST /admin/media/upload-token`(分片直传凭证)、`POST /admin/media/upload`(小文件)、`GET /admin/media/{id}`(转码进度) | | 字典 | `GET /admin/dicts/{type}`(题材、状态等) | | 设置 | `GET /admin/configs`、`PUT /admin/configs` | | 统计 | `GET /admin/stat/overview`、`GET /admin/stat/dramas`、`GET /admin/stat/trend` | 短剧列表响应示例(字段名对齐前端 `src/data/dramas.js`,前端只需换数据源): ```json { "id": 1, "title": "替嫁新娘:总裁的掌心宠", "genre": "都市情感", "status": "published", "statusText": "已上线", "episodes": 68, "views": 2400000, "cover": "https://cdn.example.com/drama/1/cover.jpg", "description": "一场意外替嫁,让她成为冷面总裁的契约新娘。", "updatedAt": "2025-06-12 10:00:00" } ``` ### 6.3 C 端接口 | 模块 | 接口 | 说明 | | --- | --- | --- | | 认证 | `POST /app/auth/sms-code` | 发送验证码(Redis 限流防刷) | | | `POST /app/auth/login` | 手机号+验证码 / 邮箱+密码,返回 `accessToken`、`refreshToken`、用户资料 | | | `POST /app/auth/refresh` | 刷新 token | | | `POST /app/auth/logout` | 注销当前设备 | | 用户 | `GET /app/user/profile`、`PUT /app/user/profile`、`POST /app/user/avatar` | 对应客户端「我的」页 | | 目录 | `GET /app/home/feed` | 首页分区流(推荐、热播、新上线) | | | `GET /app/discover/sections` | 发现页分区/轮播 | | | `GET /app/categories` | 题材分类 | | | `GET /app/dramas/{id}` | 详情:简介、总集数、免费集数、收藏/点赞数、当前用户是否收藏/点赞 | | | `GET /app/dramas/{id}/episodes` | 剧集列表(含 `isFree`、`locked`、`unlockCoin`) | | 播放 | `POST /app/episodes/{id}/play` | 播放鉴权,返回签名播放地址与续播位置 | | | `POST /app/history` | 上报观看进度(播放中每 10~15s 节流上报一次) | | | `GET /app/history` | 观看历史列表 | | 互动 | `GET/POST /app/dramas/{id}/favorites`、`GET/POST /app/dramas/{id}/likes`、`GET/POST /app/dramas/{id}/comments`、`POST /app/comments/{id}/like`、`DELETE /app/comments/{id}` | 对应 `savedDramaIds / likedDramaIds / commentsByDrama` | | 钱包 | `GET /app/wallet` | 金币余额、会员状态、到期时间 | | | `GET /app/wallet/plans` | 充值档位与 VIP 套餐 | | | `POST /app/orders` | 创建充值/会员订单 | | | `POST /app/pay/callback/{channel}` | 支付回调(公开接口,验签 + 幂等) | | 解锁 | `POST /app/episodes/{id}/unlock` | 金币解锁单集;VIP 用户直接放行 | | 其他 | `GET /app/configs` | 客户端启动配置(公告、分享文案、开关) | 播放接口响应示例: ```json { "episodeId": 101, "dramaId": 1, "epNo": 4, "duration": 138, "accessible": true, "accessType": "FREE", "playUrl": "https://cdn.example.com/hls/1/101/index.m3u8?auth_key=...&expires=1730000000", "posterUrl": "https://cdn.example.com/hls/1/101/poster.jpg", "resumePosition": 42 } ``` `accessible = false` 时返回 `accessType = COIN_REQUIRED | VIP_REQUIRED` 与 `unlockCoin`,客户端据此弹出解锁或开通会员面板。 --- ## 7. 关键流程 ### 7.1 内容生产(后台) ``` 创建短剧草稿 → 上传封面 → 批量上传剧集视频(分片直传 OSS) → 发布转码任务(MQ 异步) → FFmpeg 产出 HLS/封面 → 回调更新 media_asset → 填写剧集信息(集号、时长、免费标记、解锁金币) → 提交审核(draft → reviewing) → 审核通过(reviewing → approved) → 定时或手动发布(approved → published,publish_at 到点由 job 自动上线) → 客户端目录可见 ``` - 转码异步化,后台轮询 `GET /admin/media/{id}` 拿进度即可,不用长连接。 - 审核、发布、下线三个动作全部写 `drama_review_record` 与 `sys_operation_log`,可追溯。 ### 7.2 播放链路(客户端) ``` 客户端请求 POST /app/episodes/{id}/play → 校验登录态与用户状态 → 判定可见性:剧 / 集是否 published → 判定播放权:is_free 命中,或 episode_unlock 命中,或 VIP 有效 → 有权:生成带签名的 CDN 地址(有效期 10~30 分钟)→ 返回 playUrl + 续播位置 → 无权:返回 COIN_REQUIRED / VIP_REQUIRED,客户端弹窗引导 ``` 防盗链三条措施:签名地址短时效、CDN Referer/UA 白名单、HLS 分片加密(AES-128,密钥接口同样鉴权)。 ### 7.3 解锁扣费(幂等) ``` POST /app/episodes/{id}/unlock → 幂等键 user_id + episode_id:先查 episode_unlock,命中直接返回成功 → Redis 分布式锁 lock:unlock:{userId}:{episodeId},挡住连点重复扣费 → 一个事务内:扣 user_coin_account(乐观锁 version 校验) → 写 coin_transaction → 写 episode_unlock → 返回最新余额 ``` 余额不足返回明确业务码,客户端直接跳充值页。 ### 7.4 支付回调 ``` POST /app/pay/callback/{channel} → 验签 → 幂等(channel + channel_trade_no 唯一索引) → 一个事务内:订单置为 PAID → 金币入账(写流水)或开通/续期 VIP → 更新 app_user.vip_expire_at → 返回渠道要求的成功应答 ``` 配套对账任务:定时拉取渠道账单与本地订单比对,处理「渠道成功、本地漏回调」。 --- ## 8. 非功能设计 ### 8.1 鉴权与权限 - B 端:JWT 载荷含 `userId / username / roleId`,过滤器解析后写入 `SecurityContext`;权限校验用注解 `@PreAuthorize("@ss.hasPermission('drama:create')")`,权限码风格与前端 `src/permissions/permissions.js` 对齐。 - C 端:`accessToken` 短时效(2h)+ `refreshToken` 长时效(30d),存 Redis 支持主动踢下线;键设计 `user:token:{userId}:{deviceId}`。 - 后台高危操作(删除、发布、改权限)二次校验密码并写 `sys_operation_log`;`two_factor` 字段已在表结构预留。 ### 8.2 缓存策略 | 数据 | Key | 策略 | | --- | --- | --- | | 首页/发现页分区 | `cache:home:feed` | 5 分钟过期,发布/上线/下线时主动失效 | | 短剧详情 | `cache:drama:{id}` | 10 分钟 + 主动失效 | | 分类字典 | `cache:dict:category` | 1 小时 | | 后台权限码 | `cache:perm:role:{roleId}` | 30 分钟,改角色权限时失效 | | 验证码 | `sms:code:{mobile}` | 5 分钟,发送限流 1 次/分、5 次/天 | | 播放签名 | —— | 不缓存,实时生成 | 热点 Key 加随机后缀防雪崩;空结果缓存短空值防穿透。 ### 8.3 限流与防刷 - 接口级限流(Redis 令牌桶):登录、验证码、评论、解锁扣费。 - 播放量与观看时长上报去重(`user_id + episode_id + 时间窗`),防止刷量污染统计。 - 评论接入敏感词过滤,可扩展到第三方内容安全审核。 - 客户端接口做版本兼容(`X-App-Version`),避免旧版本升级后崩溃。 ### 8.4 数据量与扩展 - 增长最快的表:`user_watch_history`、`drama_comment`、`coin_transaction`、`stat_*_daily`。 - 单表超 2000 万行或写入明显变慢时,按 `user_id` 取模分表(ShardingSphere),对业务代码透明。 - `stat_*_daily` 属聚合表,量级可控,用分区表即可。 - 图片与视频全部走 CDN,应用服务器不落盘媒体文件。 ### 8.5 可观测与运维 - 日志:JSON 结构化,traceId 贯穿全链路;后台敏感操作另写 `sys_operation_log`。 - 监控:接口 P99、错误率、转码队列积压、支付回调失败率、Redis 命中率。 - 健康检查 `/actuator/health` 暴露给负载均衡。 - 环境:dev / test / prod 三套配置,密钥通过环境变量注入,不写进仓库。 --- ## 9. 部署与演进 起步形态(单体): ``` Nginx ├── fruitshorts-admin-api (/admin) ├── fruitshorts-app-api (/app) └── fruitshorts-job (定时任务、异步消费) PostgreSQL(主从) / Redis / OSS(或 MinIO) / 转码 Worker(独立容器,可多实例水平扩容) ``` Docker Compose 足够跑通全链路,上线后按量升级到 K8s。 演进路径:单体多模块 → 转码与统计独立进程 → 读写分离 → 内容域/交易域拆为独立服务 → 网关统一入口。拆分只在某模块成为独立瓶颈或有独立迭代节奏时进行,不为拆而拆。 --- ## 10. 与现有前端的对接改造点 ### 10.1 管理后台(ReelShortWeb) 1. `.env.*`:`VITE_USE_MOCK=false`,`VITE_API_BASE_PATH` 指向后端地址。 2. 登录:`src/api/login/index.ts` 的 `/mock/user/login` → `/admin/auth/login`,返回体保持 `{ username, role, roleId, token, permissions }`,前端改动最小。 3. 菜单:`/mock/role/list` → `/admin/auth/menus`,返回同样的路由树结构,`src/router/index.ts` 无需改动。 4. 业务数据源替换: - `src/data/dramas.js` → `GET /admin/dramas/page` - `src/data/episodes.js` → `GET /admin/dramas/{dramaId}/episodes` - `src/data/roles.js` → `GET /admin/roles/page` - `src/views/userManagement/data/adminUsers.js` → `GET /admin/admin-users/page` - `src/data/customMenus.js`(localStorage)→ `GET/POST /admin/menus` - `src/stores/rbac.js` 的本地权限表 → 登录返回的 `permissions` + `GET /admin/permissions` 5. 展示文案(已上线/审核中/草稿)改由 `status` 码映射,`src/utils/status.js` 可逐步改用后端返回的 `statusText`。 ### 10.2 客户端(ReelShort / Flutter) 1. `DemoCatalogRepository` → HTTP 实现,对接 `/app/home/feed`、`/app/dramas/{id}`、`/app/dramas/{id}/episodes`。 2. `SharedPreferencesEngagementRepository`(收藏/点赞/评论本地存储)→ HTTP 实现,`EngagementSnapshot` 变为服务端快照。 3. 观看历史本地存储 → `/app/history`;登录后做一次本地数据合并上传。 4. `Episode.isFree` 与 `PlaybackSession.canPlay` → 由 `/play` 返回的 `accessible / accessType` 驱动。 5. 「我的」页金币与会员(当前是 `$ 0` 假数据)→ `/app/wallet`。 6. 新增登录页,token 用 `flutter_secure_storage` 保存,`refreshToken` 自动续期。 --- ## 11. 迭代建议 **M1 · 打通后台** 系统域(登录、RBAC、菜单、管理员)+ 内容域(短剧/剧集 CRUD)+ 媒资上传(先整文件上传,转码后置)+ 统一响应与鉴权框架。目标:后台 mock 全部替换为真实接口。 **M2 · 打通客户端** C 端登录、目录/详情/剧集、观看历史、收藏点赞评论、播放鉴权与签名地址。目标:客户端真实播放,前端演示仓库全部退役。 **M3 · 变现与质量** 转码流水线(HLS 多码率)、金币与会员、单集解锁、支付与对账、后台数据看板、限流防刷、监控告警。 **M4 · 规模化** 读写分离、热点缓存优化、大表分表、内容安全、多语言内容(`drama.language` 已预留)。 --- ## 附录 A · 错误码规划 | 码 | 含义 | | --- | --- | | 0 | 成功 | | 400 | 参数校验失败 | | 401 | 未登录 / token 失效 | | 403 | 无权限 | | 404 | 资源不存在 | | 429 | 请求过于频繁 | | 500 | 系统异常 | | 100001 | 金币余额不足 | | 100002 | 剧集未发布或不存在 | | 100003 | 剧集已解锁(幂等返回成功) | | 100004 | 订单已支付或已关闭 | | 100005 | VIP 已过期 | ## 附录 B · 配置骨架(application.yml) ```yaml server: port: 8080 spring: datasource: url: jdbc:postgresql://127.0.0.1:5432/fruitshorts username: ${DB_USER:fruitshorts} password: ${DB_PASSWORD:fruitshorts} data: redis: host: 127.0.0.1 port: 6379 servlet: multipart: max-file-size: 2GB max-request-size: 2GB fruitshorts: jwt: admin-secret: ${JWT_ADMIN_SECRET} app-secret: ${JWT_APP_SECRET} app-access-ttl: 2h app-refresh-ttl: 30d oss: endpoint: ${OSS_ENDPOINT} bucket: ${OSS_BUCKET} access-key: ${OSS_KEY} secret-key: ${OSS_SECRET} cdn: play-domain: https://cdn.example.com sign-ttl-seconds: 1800 ```