Author SHA1 Message Date
Vicky fafadfc6ec test(backend): C 端核心链路契约测试 37 条,mvn test 不再是 No tests to run
为什么:红线第 8 条要求后端 mvn test 通过且核心接口有契约测试;此前 13 个模块全是
No tests to run.,这个"通过"是空的。按 M2 口径只补 C 端核心链路最小集。

- 接入层契约(MockMvc,真控制器 + 真全局异常处理器 + service 替身):
  /app/auth 注册与登录、目录四条路由、播放票据 FREE 与 COIN_REQUIRED 的字段形态
- 业务规则(真 service + 替身 Mapper):
  EMAIL 免码不走验证码且 verified=0、PHONE 必须带码、验证码错/过期被拒、
  密码强度、注册频控 429、账号重复 200001、密码错误 100000、封禁 100001、
  免费/已购/未解锁/无片源四个播放分支
- 依赖只加到 fruitshorts-app-api(spring-boot-starter-test,test scope),不动其它模块
- 测试放在接入层模块,避免动 01-08 冻结模块目录;只新增测试,未改业务逻辑
- 实测:mvn test BUILD SUCCESS,app-api Tests run: 37, Failures: 0, Errors: 0;
  mvn -DskipTests package 仍 BUILD SUCCESS
2026-10-16 09:52:52 +08:00
Vicky 84aa0e17dd fix: S0 后续——种子脚本补显式主键(去掉 identity 后新库种不进数据)
为什么:S0 把主键默认值去掉了,但 sql/data.sql 和 sql/data-phase1.sql 里有一批 INSERT
没给 id(原来靠 IDENTITY 生成),在新库上直接报
`null value in column "id" ... violates not-null constraint`,30 条菜单只种进去 14 条。
线上库因为数据早已存在所以看不出来,是"新装"这条路断了。

- 关联表(sys_role_permission / sys_role_menu / sys_admin_user_role)改用
  「左键 * 1000000 + 右键」的确定性 ID,重复执行结果一致,也不会撞雪花 ID
- data-phase1 新增权限点用「1000 + sort」(1101-1156 保留区间)显式给 ID,避开早期 IDENTITY 用过的 1-251
- 商品占位行补 901-903;sys_config 补 1-2
- 删掉 data.sql 结尾"对齐 IDENTITY 序列"的收尾块(序列已不存在),改为写明种子主键约定
- 实测:新库上 schema+data 五个脚本连跑三轮零 ERROR 且行数稳定(30/48/75/51/6/3/2/5/4);
  线上库跑种子前后行数不变(30/48/75/51/3/2)
2026-10-16 09:18:09 +08:00
Vicky 37c9a141d3 feat: S0 主键改雪花(DDL 去 identity + 实体 ASSIGN_ID + 雪花配置)
为什么:自增主键把 ID 分配锁在单库序列上,分库分表时无法横向扩展。趁线上业务数据
近零(只有系统种子表有量)先改,代价最低。

- sql/schema.sql、sql/schema-phase1.sql、sql/partition-maintenance.sql:61 处
  GENERATED BY DEFAULT AS IDENTITY 去掉,主键改 BIGINT 无默认值
- 52 个实体 @TableId IdType.AUTO -> IdType.ASSIGN_ID;两个入口 id-type: assign_id,
  并加 global-config.sequence.worker-id/datacenter-id(单机固定值,多实例需按实例分配)
- 新增 sql/migrate-s0-snowflake.sql:存量库幂等迁移,走 DROP IDENTITY 保留数据,不重建表
- 实测:三份建表脚本在新库连跑两遍幂等;线上 57 张表 identity 归零;
  watch_history/playback_ticket/order_event 的主键与唯一索引仍含分区键
2026-10-16 09:00:38 +08:00
Vicky 6833e01e1f feat: C 端 1-1 邮箱+密码注册(免码不阻塞,带风控)
为什么:客户端要做的第一件事是账号,但现状下邮箱注册走不通 —— RegisterReqVO.code 是 @NotBlank,
而 OTP 通道现在是 LogOtpSender(只写日志、发不出去),等于注册入口堵死。本轮把"邮箱+密码"打通,
同时按口径补上免码必须带的风控。

- RegisterReqVO.code 放开非必填(PHONE 仍必填,必填性按 targetType 在服务端判断)
- 邮箱注册分支:格式校验(AppIdentifier.requireEmailFormat)+ 密码强度(8-64 位且含字母数字);
  传了 code 就顺手校验一次,邮箱验证能力保留但不阻塞
- 注册频控:同 IP 24h ≤5、同设备 24h ≤3(数 app_user.register_ip / register_device_id,不新建计数表),超出 429
- 顺带修一个真问题:login() 原来**无条件**把 credential 标成 verified=1,
  密码登录并不证明邮箱归用户所有,会把"未验证"状态悄悄抹掉;改成只有验证码登录才置 1
- docs/api-phase1.md 补注册契约表格与残留风险(IP/设备号可伪造、OTP 只写日志、邮箱可达性未验证)

实测:免码注册成功(邮箱大小写与空格被规范化);弱密码/坏邮箱 400;重复 200001;
邮箱+密码登录成功;PHONE 不带码仍报"请先获取验证码";同设备第 4 次、同 IP 第 6 次分别 429;
免码注册后 verified=0 且经一次密码登录仍是 0。
2026-10-15 17:20:31 +08:00
72 changed files with 1356 additions and 223 deletions

No files matched your search

+24
View File
@@ -74,6 +74,30 @@ POST /app/auth/register
} }
``` ```
**邮箱 + 密码注册(C 端第一批口径)**
```json
POST /app/auth/register
{
"targetType": "EMAIL", "target": "User@Example.com ", "password": "nonton2026",
"deviceId": "d-1", "countryCode": "ID", "agreementVersion": "2026-10-01"
}
```
| 规则 | 口径 |
| --- | --- |
| `targetType=EMAIL` | **`code` 可以不传**(邮箱+密码直接注册);传了就走一次验证码校验(邮箱验证能力保留) |
| `targetType=PHONE` | 维持原状:`code` **必填** |
| 邮箱规范化 | 去空格 + 转小写(`batch1@Example.com ` → `batch1@example.com`);格式不合法直接 `400 邮箱格式不正确` |
| 密码强度 | 8–64 位,且**同时包含字母与数字**(不强制特殊字符:低端机输入成本高,拦机器注册靠频控) |
| 注册频控 | 同 IP 24h ≤ **5** 个、同设备 24h ≤ **3** 个,超出返回 `429`(`TOO_MANY_REQUESTS`) |
| 邮箱验证状态 | 免码注册时 `app_user_credential.verified = 0`(保留"待验证"),**不阻塞登录**;只有**验证码登录**会把 `verified` 置 1,密码登录不改变该状态 |
频控口径落库在 `app_user.register_ip` / `register_device_id` 上(按 24h 计数,不新建计数表)。
**残留风险**:IP 取自 `X-Forwarded-For`、设备号由客户端上报,都能伪造,只能挡脚本批量;
真要防住需要接图形/行为验证码或风控服务,本阶段未做。验证码通道目前是 `LogOtpSender`(只写日志不发信),
所以邮箱**可达性**未验证 —— 这也是"不阻塞注册"的前提。
响应(登录、刷新同结构): 响应(登录、刷新同结构):
```json ```json
@@ -26,10 +26,15 @@ mybatis-plus:
global-config: global-config:
banner: false banner: false
db-config: db-config:
id-type: auto # 主键由应用侧雪花算法生成(S0),数据库不再自增
id-type: assign_id
logic-delete-field: deleted logic-delete-field: deleted
logic-delete-value: 1 logic-delete-value: 1
logic-not-delete-value: 0 logic-not-delete-value: 0
sequence:
# 单机开发用固定值;上多实例前必须改成按实例分配(环境变量注入),否则会撞 ID
worker-id: 1
datacenter-id: 1
fruitshorts: fruitshorts:
media: media:
+6
View File
@@ -39,6 +39,12 @@
<groupId>com.fruitshorts</groupId> <groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-commerce</artifactId> <artifactId>fruitshorts-module-commerce</artifactId>
</dependency> </dependency>
<!-- 契约测试用:MockMvc + Mockito + JUnit5,test scope,不进运行态依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -21,10 +21,15 @@ mybatis-plus:
global-config: global-config:
banner: false banner: false
db-config: db-config:
id-type: auto # 主键由应用侧雪花算法生成(S0),数据库不再自增
id-type: assign_id
logic-delete-field: deleted logic-delete-field: deleted
logic-delete-value: 1 logic-delete-value: 1
logic-not-delete-value: 0 logic-not-delete-value: 0
sequence:
# 单机开发用固定值;上多实例前必须改成按实例分配(环境变量注入),否则会撞 ID
worker-id: 1
datacenter-id: 1
fruitshorts: fruitshorts:
interaction: interaction:
@@ -0,0 +1,140 @@
// Author: fruit.com.cn
package com.fruitshorts.app.controller;
import com.fruitshorts.app.support.ControllerContractSupport;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.user.service.AppAuthService;
import com.fruitshorts.user.service.AppOtpService;
import com.fruitshorts.user.vo.TokenRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.nullValue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* `/app/auth/**` 的接入层契约:路由、请求体绑定、统一响应包 `{code,message,data}`、错误码映射。
*
* <p>业务规则(免码、密码强度、频控)在 {@code AppAuthServiceTest} / {@code AppOtpServiceTest} 里用真服务验证,
* 这里只钉"接口长什么样"。
*/
class AppAuthControllerContractTest {
private AppOtpService otpService;
private AppAuthService authService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
otpService = mock(AppOtpService.class);
authService = mock(AppAuthService.class);
mockMvc = ControllerContractSupport.mockMvcOf(new AppAuthController(otpService, authService));
}
@Test
void should_return_access_token_when_email_register_succeeds() throws Exception {
given(authService.register(any(), any())).willReturn(tokens("access-1", "refresh-1"));
mockMvc.perform(post("/app/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetType\":\"EMAIL\",\"target\":\"a@b.com\",\"password\":\"abcd1234\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.message").value("ok"))
.andExpect(jsonPath("$.data.accessToken").value("access-1"))
.andExpect(jsonPath("$.data.tokenType").value("Bearer"))
.andExpect(jsonPath("$.data.newUser").value(true));
}
@Test
void should_return_data_exists_code_when_email_is_already_registered() throws Exception {
given(authService.register(any(), any())).willThrow(
new ServiceException(ErrorCode.DATA_EXISTS, "该账号已注册,请直接登录"));
mockMvc.perform(post("/app/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetType\":\"EMAIL\",\"target\":\"a@b.com\",\"password\":\"abcd1234\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.DATA_EXISTS.getCode()))
.andExpect(jsonPath("$.message").value("该账号已注册,请直接登录"))
.andExpect(jsonPath("$.data").value(nullValue()));
}
@Test
void should_map_otp_required_error_when_phone_register_has_no_code() throws Exception {
given(authService.register(any(), any())).willThrow(
new ServiceException(ErrorCode.BAD_REQUEST, "请先获取验证码"));
mockMvc.perform(post("/app/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetType\":\"PHONE\",\"target\":\"08123456789\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode()))
.andExpect(jsonPath("$.message").value("请先获取验证码"));
}
@Test
void should_return_bad_request_code_when_register_body_has_no_target() throws Exception {
mockMvc.perform(post("/app/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetType\":\"EMAIL\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode()))
.andExpect(jsonPath("$.message").value(containsString("请输入手机号或邮箱")));
}
@Test
void should_return_access_token_when_password_login_succeeds() throws Exception {
given(authService.login(any(), any())).willReturn(tokens("access-2", "refresh-2"));
mockMvc.perform(post("/app/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetType\":\"EMAIL\",\"target\":\"a@b.com\",\"password\":\"abcd1234\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.accessToken").value("access-2"));
}
@Test
void should_return_login_failed_code_when_password_is_wrong() throws Exception {
given(authService.login(any(), any())).willThrow(new ServiceException(ErrorCode.LOGIN_FAILED));
mockMvc.perform(post("/app/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetType\":\"EMAIL\",\"target\":\"a@b.com\",\"password\":\"wrongpass1\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.LOGIN_FAILED.getCode()))
.andExpect(jsonPath("$.message").value("账号或密码错误"));
}
@Test
void should_return_ok_envelope_when_otp_send_succeeds() throws Exception {
mockMvc.perform(post("/app/auth/otp")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"scene\":\"REGISTER\",\"targetType\":\"PHONE\",\"target\":\"08123456789\",\"deviceId\":\"d-1\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").value(nullValue()));
verify(otpService).send(any(), any());
}
private TokenRespVO tokens(String accessToken, String refreshToken) {
TokenRespVO vo = new TokenRespVO();
vo.setAccessToken(accessToken);
vo.setRefreshToken(refreshToken);
vo.setExpiresIn(7200);
vo.setNewUser(true);
return vo;
}
}
@@ -0,0 +1,114 @@
// Author: fruit.com.cn
package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.support.ControllerContractSupport;
import com.fruitshorts.app.vo.AppBannerVO;
import com.fruitshorts.app.vo.AppCategoryVO;
import com.fruitshorts.app.vo.AppDramaDetailVO;
import com.fruitshorts.app.vo.AppFeedVO;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.playback.service.PlaybackService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.hamcrest.Matchers.nullValue;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 目录接口的接入层契约:`/app/home/feed`、`/app/discover/feed`、`/app/categories`、`/app/dramas/{id}`。
*
* <p>关注三件事:路由通不通、路径参数有没有绑对、响应壳是不是 `{code,message,data}`。
*/
class AppCatalogControllerContractTest {
private AppCatalogService catalogService;
private MockMvc catalogMvc;
private MockMvc dramaMvc;
@BeforeEach
void setUp() {
catalogService = mock(AppCatalogService.class);
catalogMvc = ControllerContractSupport.mockMvcOf(new AppCatalogController(catalogService));
dramaMvc = ControllerContractSupport.mockMvcOf(
new AppDramaController(catalogService, mock(PlaybackService.class)));
}
@Test
void should_return_banners_and_sections_when_home_feed_is_requested() throws Exception {
AppBannerVO banner = new AppBannerVO();
banner.setId(9L);
banner.setImageUrl("/files/banner/9.png");
banner.setLinkType("DRAMA");
banner.setLinkValue("12");
AppFeedVO feed = new AppFeedVO(List.of(banner), List.of());
given(catalogService.homeFeed()).willReturn(feed);
catalogMvc.perform(get("/app/home/feed"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.banners[0].id").value(9))
.andExpect(jsonPath("$.data.banners[0].imageUrl").value("/files/banner/9.png"))
.andExpect(jsonPath("$.data.banners[0].linkType").value("DRAMA"));
}
@Test
void should_return_sections_when_discover_feed_is_requested() throws Exception {
AppFeedVO feed = new AppFeedVO(List.of(), List.of());
given(catalogService.discoverFeed()).willReturn(feed);
catalogMvc.perform(get("/app/discover/feed"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.sections").isArray());
}
@Test
void should_return_category_list_when_categories_are_requested() throws Exception {
AppCategoryVO category = new AppCategoryVO(3L, "都市");
given(catalogService.categories()).willReturn(List.of(category));
catalogMvc.perform(get("/app/categories"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data[0].id").value(3))
.andExpect(jsonPath("$.data[0].name").value("都市"));
}
@Test
void should_bind_path_variable_when_drama_detail_is_requested() throws Exception {
AppDramaDetailVO detail = new AppDramaDetailVO();
detail.setId(42L);
detail.setTitle("命运的相遇");
detail.setEpisodes(List.of());
given(catalogService.dramaDetail(42L)).willReturn(detail);
dramaMvc.perform(get("/app/dramas/42"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").value(42))
.andExpect(jsonPath("$.data.title").value("命运的相遇"));
verify(catalogService).dramaDetail(42L);
}
@Test
void should_return_data_not_found_code_when_drama_does_not_exist() throws Exception {
given(catalogService.dramaDetail(404L))
.willThrow(new ServiceException(ErrorCode.DATA_NOT_FOUND, "短剧不存在"));
dramaMvc.perform(get("/app/dramas/404"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.DATA_NOT_FOUND.getCode()))
.andExpect(jsonPath("$.data").value(nullValue()));
}
}
@@ -0,0 +1,96 @@
// Author: fruit.com.cn
package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.support.ControllerContractSupport;
import com.fruitshorts.playback.service.PlaybackService;
import com.fruitshorts.playback.vo.PlayRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 播放票据接口的接入层契约:`/app/episodes/{id}/play` 的免费/付费两种形态与参数透传。
*
* <p>免费 / 付费的**判定规则**在 {@code PlaybackServiceTest} 里用真服务验证;
* 这里钉的是"下发给客户端的字段长什么样"(客户端 Flutter 依赖这些字段名)。
*/
class AppPlaybackContractTest {
private PlaybackService playbackService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
playbackService = mock(PlaybackService.class);
mockMvc = ControllerContractSupport.mockMvcOf(
new AppDramaController(mock(AppCatalogService.class), playbackService));
}
@Test
void should_return_free_access_when_episode_is_free() throws Exception {
given(playbackService.play(isNull(), eq(11L), any(), any()))
.willReturn(ticket(11L, true, PlaybackService.ACCESS_FREE, "/files/ep.mp4?s=1"));
mockMvc.perform(get("/app/episodes/11/play").header("X-Device-Id", "device-9"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.episodeId").value(11))
.andExpect(jsonPath("$.data.accessible").value(true))
.andExpect(jsonPath("$.data.accessType").value(PlaybackService.ACCESS_FREE))
.andExpect(jsonPath("$.data.playUrl").value("/files/ep.mp4?s=1"));
}
@Test
void should_return_coin_required_when_episode_is_locked_and_not_entitled() throws Exception {
PlayRespVO locked = ticket(12L, false, PlaybackService.ACCESS_COIN_REQUIRED, "");
locked.setUnlockCoin(60);
given(playbackService.play(isNull(), eq(12L), any(), any())).willReturn(locked);
mockMvc.perform(get("/app/episodes/12/play"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.accessible").value(false))
.andExpect(jsonPath("$.data.accessType").value(PlaybackService.ACCESS_COIN_REQUIRED))
.andExpect(jsonPath("$.data.playUrl").value(""))
.andExpect(jsonPath("$.data.unlockCoin").value(60));
}
@Test
void should_pass_episode_id_and_device_header_when_play_is_requested() throws Exception {
given(playbackService.play(isNull(), eq(13L), eq("device-9"), any()))
.willReturn(ticket(13L, true, PlaybackService.ACCESS_FREE, "/files/ep13.mp4?s=1"));
mockMvc.perform(get("/app/episodes/13/play").header("X-Device-Id", "device-9"))
.andExpect(status().isOk());
verify(playbackService).play(isNull(), eq(13L), eq("device-9"), any());
}
private PlayRespVO ticket(Long episodeId, boolean accessible, String accessType, String playUrl) {
PlayRespVO vo = new PlayRespVO();
vo.setEpisodeId(episodeId);
vo.setDramaId(7L);
vo.setNumber(1);
vo.setTitle("第 1 集");
vo.setDurationSec(120);
vo.setAccessible(accessible);
vo.setAccessType(accessType);
vo.setPlayUrl(playUrl);
vo.setQualities(List.of("AUTO"));
vo.setUnlockCoin(0);
vo.setPreviewSeconds(0);
return vo;
}
}
@@ -0,0 +1,320 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.framework.security.JwtTokenProvider;
import com.fruitshorts.user.dal.entity.AppUser;
import com.fruitshorts.user.dal.entity.AppUserCredential;
import com.fruitshorts.user.dal.entity.AppUserOtp;
import com.fruitshorts.user.dal.entity.AppUserSession;
import com.fruitshorts.user.dal.mapper.AppUserConsentMapper;
import com.fruitshorts.user.dal.mapper.AppUserCredentialMapper;
import com.fruitshorts.user.dal.mapper.AppUserDeviceMapper;
import com.fruitshorts.user.dal.mapper.AppUserMapper;
import com.fruitshorts.user.dal.mapper.AppUserOtpMapper;
import com.fruitshorts.user.dal.mapper.AppUserSessionMapper;
import com.fruitshorts.user.enums.AppUserStatus;
import com.fruitshorts.user.service.AppAuthService;
import com.fruitshorts.user.service.AppOtpService;
import com.fruitshorts.user.service.CredentialHasher;
import com.fruitshorts.user.service.OtpSender;
import com.fruitshorts.user.vo.LoginReqVO;
import com.fruitshorts.user.vo.RegisterReqVO;
import com.fruitshorts.user.vo.TokenRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/**
* C 端注册 / 登录的**业务规则**测试:真 `AppAuthService` + 替身 Mapper,不连库。
*
* <p>覆盖几件接入层测不到的事:EMAIL 免码到底走没走验证码、密码强度、注册频控、
* 账号重复、密码比对与封禁判断。验证码本身的校验规则在 `AppOtpServiceTest`。
*/
class AppAuthServiceTest {
private AppUserMapper userMapper;
private AppUserCredentialMapper credentialMapper;
private AppUserSessionMapper sessionMapper;
private AppUserConsentMapper consentMapper;
private AppUserDeviceMapper deviceMapper;
private AppOtpService otpService;
private CredentialHasher hasher;
private JwtTokenProvider tokenProvider;
private PasswordEncoder passwordEncoder;
private AppAuthService authService;
@BeforeEach
void setUp() {
userMapper = mock(AppUserMapper.class);
credentialMapper = mock(AppUserCredentialMapper.class);
sessionMapper = mock(AppUserSessionMapper.class);
consentMapper = mock(AppUserConsentMapper.class);
deviceMapper = mock(AppUserDeviceMapper.class);
otpService = mock(AppOtpService.class);
hasher = mock(CredentialHasher.class);
tokenProvider = mock(JwtTokenProvider.class);
passwordEncoder = mock(PasswordEncoder.class);
authService = new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper,
deviceMapper, otpService, hasher, tokenProvider, passwordEncoder);
}
@Test
void should_register_without_otp_when_target_type_is_email() {
stubHappyRegister();
AtomicReference<AppUserCredential> saved = captureCredential();
TokenRespVO resp = authService.register(emailRegister("abcd1234", null), "203.0.113.8");
assertThat(resp.getAccessToken()).isEqualTo("access-1");
assertThat(resp.getRefreshToken()).isEqualTo("refresh-1");
assertThat(resp.isNewUser()).isTrue();
// 免码注册不等于"已验证":邮箱可达性没验证过,所以先记 0,等日后点验证链接再置 1
assertThat(saved.get().getVerified()).isZero();
verify(otpService, never()).consume(anyString(), anyString(), anyString(), any());
}
@Test
void should_consume_otp_when_email_register_carries_a_code() {
stubHappyRegister();
authService.register(emailRegister("abcd1234", "654321"), "203.0.113.8");
verify(otpService).consume(eq("REGISTER"), eq("EMAIL"), eq("a@b.com"), eq("654321"));
}
@Test
void should_consume_otp_when_target_type_is_phone() {
stubHappyRegister();
authService.register(phoneRegister("+628123456789", "654321"), "203.0.113.8");
verify(otpService).consume(eq("REGISTER"), eq("PHONE"), eq("+628123456789"), eq("654321"));
}
@Test
void should_reject_phone_register_when_no_code_was_requested() {
AppAuthService serviceWithRealOtp = serviceWithRealOtpService(null);
assertThatThrownBy(() -> serviceWithRealOtp.register(phoneRegister("+628123456789", ""), "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.hasMessage("请先获取验证码");
}
@Test
void should_reject_phone_register_when_code_is_wrong() {
AppUserOtp issued = new AppUserOtp();
issued.setCodeHash("hash-of-654321");
issued.setExpireAt(LocalDateTime.now().plusMinutes(5));
issued.setFailCount(0);
AppAuthService serviceWithRealOtp = serviceWithRealOtpService(issued);
assertThatThrownBy(() -> serviceWithRealOtp.register(phoneRegister("+628123456789", "000000"), "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.hasMessage("验证码不正确");
}
@Test
void should_reject_email_register_when_password_is_weak() {
RegisterReqVO req = emailRegister("abcdefgh", null);
assertThatThrownBy(() -> authService.register(req, "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.hasMessageContaining("密码需同时包含字母和数字");
}
@Test
void should_reject_email_register_when_format_is_invalid() {
RegisterReqVO req = emailRegister("abcd1234", null);
req.setTarget("not-an-email");
assertThatThrownBy(() -> authService.register(req, "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.hasMessage("邮箱格式不正确");
}
@Test
void should_return_data_exists_when_email_is_already_registered() {
given(userMapper.selectCount(any())).willReturn(0L);
AppUserCredential existing = new AppUserCredential();
existing.setUserId(1001L);
given(credentialMapper.selectOne(any())).willReturn(existing);
assertThatThrownBy(() -> authService.register(emailRegister("abcd1234", null), "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
.isEqualTo(ErrorCode.DATA_EXISTS.getCode()))
.hasMessage("该账号已注册,请直接登录");
}
@Test
void should_reject_register_when_ip_reaches_the_daily_limit() {
given(userMapper.selectCount(any())).willReturn(5L);
assertThatThrownBy(() -> authService.register(emailRegister("abcd1234", null), "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
.isEqualTo(ErrorCode.TOO_MANY_REQUESTS.getCode()))
.hasMessageContaining("当前网络注册过于频繁");
}
@Test
void should_return_tokens_when_password_matches() {
stubLogin(credential("bcrypt-hash"), user(AppUserStatus.NORMAL));
given(passwordEncoder.matches("abcd1234", "bcrypt-hash")).willReturn(true);
TokenRespVO resp = authService.login(emailLogin("abcd1234"), "203.0.113.8");
assertThat(resp.getAccessToken()).isEqualTo("access-1");
assertThat(resp.getUser().getUid()).isEqualTo("UAB12CD");
verify(sessionMapper).insert(any(AppUserSession.class));
}
@Test
void should_reject_login_when_password_does_not_match() {
stubLogin(credential("bcrypt-hash"), user(AppUserStatus.NORMAL));
given(passwordEncoder.matches("wrongpass1", "bcrypt-hash")).willReturn(false);
assertThatThrownBy(() -> authService.login(emailLogin("wrongpass1"), "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
.isEqualTo(ErrorCode.LOGIN_FAILED.getCode()))
.hasMessage("账号或密码错误");
}
@Test
void should_reject_login_when_account_does_not_exist() {
given(credentialMapper.selectOne(any())).willReturn(null);
assertThatThrownBy(() -> authService.login(emailLogin("abcd1234"), "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
.isEqualTo(ErrorCode.LOGIN_FAILED.getCode()))
.hasMessage("账号不存在,请先注册");
}
@Test
void should_reject_login_when_account_is_banned() {
stubLogin(credential("bcrypt-hash"), user(AppUserStatus.BANNED));
given(passwordEncoder.matches("abcd1234", "bcrypt-hash")).willReturn(true);
assertThatThrownBy(() -> authService.login(emailLogin("abcd1234"), "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
.isEqualTo(ErrorCode.ACCOUNT_DISABLED.getCode()));
}
private void stubHappyRegister() {
given(userMapper.selectCount(any())).willReturn(0L);
given(credentialMapper.selectOne(any())).willReturn(null);
given(hasher.newUid()).willReturn("UAB12CD");
given(hasher.newRefreshToken()).willReturn("refresh-1");
given(hasher.sha256(anyString())).willReturn("hash-1");
given(passwordEncoder.encode(anyString())).willReturn("bcrypt-hash");
given(tokenProvider.createAppToken(any(), anyString(), any())).willReturn("access-1");
given(tokenProvider.accessTtlSeconds()).willReturn(7200L);
given(tokenProvider.appRefreshTtl()).willReturn(Duration.ofDays(30));
AtomicReference<AppUser> inserted = new AtomicReference<>();
given(userMapper.insert(any(AppUser.class))).willAnswer(invocation -> {
AppUser saved = invocation.getArgument(0);
saved.setId(1001L);
inserted.set(saved);
return 1;
});
given(userMapper.selectById(any())).willAnswer(invocation -> inserted.get());
}
private AtomicReference<AppUserCredential> captureCredential() {
AtomicReference<AppUserCredential> saved = new AtomicReference<>();
given(credentialMapper.insert(any(AppUserCredential.class))).willAnswer(invocation -> {
saved.set(invocation.getArgument(0));
return 1;
});
return saved;
}
private void stubLogin(AppUserCredential credential, AppUser user) {
given(credentialMapper.selectOne(any())).willReturn(credential);
given(userMapper.selectById(any())).willReturn(user);
given(hasher.newRefreshToken()).willReturn("refresh-1");
given(hasher.sha256(anyString())).willReturn("hash-1");
given(tokenProvider.createAppToken(any(), anyString(), any())).willReturn("access-1");
given(tokenProvider.accessTtlSeconds()).willReturn(7200L);
given(tokenProvider.appRefreshTtl()).willReturn(Duration.ofDays(30));
}
private AppAuthService serviceWithRealOtpService(AppUserOtp issued) {
AppUserOtpMapper otpMapper = mock(AppUserOtpMapper.class);
CredentialHasher otpHasher = mock(CredentialHasher.class);
given(otpMapper.selectOne(any())).willReturn(issued);
given(otpHasher.hashOtp(any())).willReturn("hash-of-something-else");
AppOtpService realOtpService = new AppOtpService(otpMapper, mock(OtpSender.class), otpHasher);
return new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper,
deviceMapper, realOtpService, hasher, tokenProvider, passwordEncoder);
}
private RegisterReqVO emailRegister(String password, String code) {
RegisterReqVO req = new RegisterReqVO();
req.setTargetType("EMAIL");
req.setTarget("a@b.com");
req.setPassword(password);
req.setCode(code);
req.setDeviceId("device-9");
req.setAgreementVersion("2026-10-01");
return req;
}
private RegisterReqVO phoneRegister(String target, String code) {
RegisterReqVO req = new RegisterReqVO();
req.setTargetType("PHONE");
req.setTarget(target);
req.setCode(code);
req.setDeviceId("device-9");
req.setAgreementVersion("2026-10-01");
return req;
}
private LoginReqVO emailLogin(String password) {
LoginReqVO req = new LoginReqVO();
req.setTargetType("EMAIL");
req.setTarget("a@b.com");
req.setPassword(password);
req.setDeviceId("device-9");
return req;
}
private AppUserCredential credential(String passwordHash) {
AppUserCredential credential = new AppUserCredential();
credential.setId(1L);
credential.setUserId(1001L);
credential.setType("EMAIL");
credential.setIdentifier("a@b.com");
credential.setPasswordHash(passwordHash);
return credential;
}
private AppUser user(String status) {
AppUser user = new AppUser();
user.setId(1001L);
user.setUid("UAB12CD");
user.setNickname("UserAB12CD");
user.setStatus(status);
user.setCountryCode("ID");
return user;
}
}
@@ -0,0 +1,104 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.user.dal.entity.AppUserOtp;
import com.fruitshorts.user.dal.mapper.AppUserOtpMapper;
import com.fruitshorts.user.service.AppOtpService;
import com.fruitshorts.user.service.CredentialHasher;
import com.fruitshorts.user.service.OtpSender;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* 验证码**校验规则**测试:真 `AppOtpService` + 替身 Mapper。
*
* <p>注册/登录链路"PHONE 必须带验证码"的拒绝对外表现,靠这里的真规则 + `AppAuthServiceTest`
* 里的"必须调用 consume"两条一起钉住,避免两边都用替身导致互相假装。
*/
class AppOtpServiceTest {
private AppUserOtpMapper otpMapper;
private CredentialHasher hasher;
private AppOtpService otpService;
@BeforeEach
void setUp() {
otpMapper = mock(AppUserOtpMapper.class);
hasher = mock(CredentialHasher.class);
otpService = new AppOtpService(otpMapper, mock(OtpSender.class), hasher);
}
@Test
void should_reject_consume_when_no_code_was_requested() {
given(otpMapper.selectOne(any())).willReturn(null);
assertThatThrownBy(() -> otpService.consume("REGISTER", "PHONE", "+628123456789", "123456"))
.isInstanceOf(ServiceException.class)
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
.isEqualTo(ErrorCode.BAD_REQUEST.getCode()))
.hasMessage("请先获取验证码");
}
@Test
void should_reject_consume_when_code_is_wrong() {
AppUserOtp issued = issuedOtp("hash-of-123456");
given(otpMapper.selectOne(any())).willReturn(issued);
given(hasher.hashOtp("000000")).willReturn("hash-of-000000");
assertThatThrownBy(() -> otpService.consume("REGISTER", "PHONE", "+628123456789", "000000"))
.isInstanceOf(ServiceException.class)
.hasMessage("验证码不正确");
// 失败要计数,连续错 5 次就作废(防爆破)
assertThat(issued.getFailCount()).isEqualTo(1);
}
@Test
void should_reject_consume_when_code_is_expired() {
AppUserOtp issued = issuedOtp("hash-of-123456");
issued.setExpireAt(LocalDateTime.now().minusMinutes(1));
given(otpMapper.selectOne(any())).willReturn(issued);
assertThatThrownBy(() -> otpService.consume("REGISTER", "PHONE", "+628123456789", "123456"))
.isInstanceOf(ServiceException.class)
.hasMessage("验证码已过期");
}
@Test
void should_mark_code_used_when_code_matches() {
AppUserOtp issued = issuedOtp("hash-of-123456");
AtomicReference<AppUserOtp> updated = new AtomicReference<>();
given(otpMapper.selectOne(any())).willReturn(issued);
given(hasher.hashOtp("123456")).willReturn("hash-of-123456");
given(otpMapper.updateById(any(AppUserOtp.class))).willAnswer(invocation -> {
updated.set(invocation.getArgument(0));
return 1;
});
otpService.consume("REGISTER", "PHONE", "+628123456789", "123456");
assertThat(updated.get().getUsedAt()).isNotNull();
}
private AppUserOtp issuedOtp(String codeHash) {
AppUserOtp otp = new AppUserOtp();
otp.setId(1L);
otp.setScene("REGISTER");
otp.setTargetType("PHONE");
otp.setTarget("+628123456789");
otp.setCodeHash(codeHash);
otp.setExpireAt(LocalDateTime.now().plusMinutes(5));
otp.setFailCount(0);
return otp;
}
}
@@ -0,0 +1,146 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.media.service.PlaybackSigner;
import com.fruitshorts.media.service.TranscodeService;
import com.fruitshorts.playback.dal.entity.PlaybackTicket;
import com.fruitshorts.playback.dal.mapper.PlaybackTicketMapper;
import com.fruitshorts.playback.service.EntitlementPort;
import com.fruitshorts.playback.service.PlaybackService;
import com.fruitshorts.playback.vo.PlayRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/**
* 播放票据的**判定规则**测试:真 `PlaybackService` + 替身依赖,不连库。
*
* <p>两条关键分支(接入层只验证字段形态,判定在这里):免费集一律给签名地址;
* 付费集在没权益时**不给地址**,只回 `COIN_REQUIRED` 与所需金币。
*/
class PlaybackServiceTest {
private EpisodeService episodeService;
private DramaService dramaService;
private PlaybackTicketMapper ticketMapper;
private PlaybackSigner signer;
private EntitlementPort entitlementPort;
private TranscodeService transcodeService;
private PlaybackService playbackService;
@BeforeEach
void setUp() {
episodeService = mock(EpisodeService.class);
dramaService = mock(DramaService.class);
ticketMapper = mock(PlaybackTicketMapper.class);
signer = mock(PlaybackSigner.class);
entitlementPort = mock(EntitlementPort.class);
transcodeService = mock(TranscodeService.class);
playbackService = new PlaybackService(episodeService, dramaService, ticketMapper, signer,
entitlementPort, transcodeService);
given(dramaService.getPublished(anyLong())).willReturn(drama());
given(transcodeService.listRenditions(anyLong())).willReturn(List.of());
given(signer.sign(anyString())).willReturn("/files/episode/1.mp4?e=1&s=sig");
}
@Test
void should_return_free_access_when_episode_is_free() {
given(episodeService.getPublished(11L)).willReturn(episode(11L, 1, 0, "/files/episode/1.mp4"));
PlayRespVO resp = playbackService.play(null, 11L, "device-9", "203.0.113.8");
assertThat(resp.getAccessible()).isTrue();
assertThat(resp.getAccessType()).isEqualTo(PlaybackService.ACCESS_FREE);
assertThat(resp.getPlayUrl()).isEqualTo("/files/episode/1.mp4?e=1&s=sig");
// 未登录不记票据(票据是"已登录用户"的签发审计)
verify(ticketMapper, never()).insert(any(PlaybackTicket.class));
}
@Test
void should_return_coin_required_when_episode_is_locked_and_not_entitled() {
given(episodeService.getPublished(12L)).willReturn(episode(12L, 0, 60, "/files/episode/2.mp4"));
given(entitlementPort.isUnlocked(anyLong(), anyLong(), anyLong())).willReturn(false);
PlayRespVO resp = playbackService.play(1001L, 12L, "device-9", "203.0.113.8");
assertThat(resp.getAccessible()).isFalse();
assertThat(resp.getAccessType()).isEqualTo(PlaybackService.ACCESS_COIN_REQUIRED);
assertThat(resp.getPlayUrl()).isEmpty();
assertThat(resp.getUnlockCoin()).isEqualTo(60);
verify(ticketMapper, never()).insert(any(PlaybackTicket.class));
}
@Test
void should_return_entitled_access_when_entitlement_port_confirms_unlock() {
given(episodeService.getPublished(13L)).willReturn(episode(13L, 0, 60, "/files/episode/3.mp4"));
given(entitlementPort.isUnlocked(eq(1001L), anyLong(), eq(13L))).willReturn(true);
PlayRespVO resp = playbackService.play(1001L, 13L, "device-9", "203.0.113.8");
assertThat(resp.getAccessible()).isTrue();
assertThat(resp.getAccessType()).isEqualTo(PlaybackService.ACCESS_ENTITLED);
assertThat(resp.getPlayUrl()).isEqualTo("/files/episode/1.mp4?e=1&s=sig");
verify(ticketMapper).insert(any(PlaybackTicket.class));
}
@Test
void should_return_no_media_when_episode_has_no_source() {
given(episodeService.getPublished(14L)).willReturn(episode(14L, 1, 0, ""));
PlayRespVO resp = playbackService.play(isNull(), 14L, "device-9", "203.0.113.8");
assertThat(resp.getAccessible()).isFalse();
assertThat(resp.getAccessType()).isEqualTo(PlaybackService.ACCESS_NO_MEDIA);
assertThat(resp.getPlayUrl()).isEmpty();
}
@Test
void should_prefer_hls_url_when_the_episode_has_multi_bitrate_renditions() {
DramaEpisode episode = episode(15L, 1, 0, "/files/episode/origin.mp4");
episode.setHlsUrl("/files/episode/15/master.m3u8");
given(episodeService.getPublished(15L)).willReturn(episode);
PlayRespVO resp = playbackService.play(null, 15L, "device-9", "203.0.113.8");
assertThat(resp.getPlayUrl()).isEqualTo("/files/episode/1.mp4?e=1&s=sig");
verify(signer).sign("/files/episode/15/master.m3u8");
}
private DramaEpisode episode(Long id, int isFree, int unlockCoin, String videoUrl) {
DramaEpisode episode = new DramaEpisode();
episode.setId(id);
episode.setDramaId(7L);
episode.setEpNo(1);
episode.setTitle("第 1 集");
episode.setDurationSec(120);
episode.setIsFree(isFree);
episode.setUnlockCoin(unlockCoin);
episode.setPreviewSeconds(0);
episode.setVideoUrl(videoUrl);
episode.setStatus("published");
return episode;
}
private Drama drama() {
Drama drama = new Drama();
drama.setId(7L);
drama.setTitle("命运的相遇");
return drama;
}
}
@@ -0,0 +1,33 @@
// Author: fruit.com.cn
package com.fruitshorts.app.support;
import com.fruitshorts.framework.log.AccessDeniedRecorder;
import com.fruitshorts.framework.web.GlobalExceptionHandler;
import org.mockito.Mockito;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
/**
* 控制器契约测试的公共装配:真控制器 + 真全局异常处理器 + 真校验器,**不启 Spring 容器**。
*
* <p>为什么不用 {@code @WebMvcTest}:{@code AppApiApplication} 上挂着
* {@code @MapperScan("com.fruitshorts.**.dal.mapper")},而切片测试不装配 DataSource / MyBatis,
* 扫描出的 Mapper 工厂会因为缺少 {@code SqlSessionFactory} 直接报错;要绕开就得再加一套测试专用的
* 引导配置。这里的 standaloneSetup 同样只装 Web 层(路由、参数绑定、消息转换、异常处理),
* 覆盖面不变而更轻、更稳。
*/
public final class ControllerContractSupport {
private ControllerContractSupport() {
}
/** 组装一个只含指定控制器与全局异常处理的 MockMvc。 */
@SuppressWarnings("unchecked")
public static MockMvc mockMvcOf(Object controller) {
AccessDeniedRecorder recorder = new AccessDeniedRecorder(Mockito.mock(ObjectProvider.class));
return MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new GlobalExceptionHandler(recorder))
.build();
}
}
@@ -12,7 +12,7 @@ import java.time.LocalDateTime;
@TableName("coin_ledger") @TableName("coin_ledger")
public class CoinLedger { public class CoinLedger {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long userId; private Long userId;
/** 正数入账、负数出账 */ /** 正数入账、负数出账 */
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("entitlement") @TableName("entitlement")
public class Entitlement { public class Entitlement {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long userId; private Long userId;
private Long dramaId; private Long dramaId;
@@ -12,7 +12,7 @@ import java.time.LocalDateTime;
@TableName("order_event") @TableName("order_event")
public class OrderEvent { public class OrderEvent {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String orderNo; private String orderNo;
private String event; private String event;
@@ -16,7 +16,7 @@ import java.time.LocalDateTime;
@TableName("orders") @TableName("orders")
public class Orders { public class Orders {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String orderNo; private String orderNo;
private Long userId; private Long userId;
@@ -16,7 +16,7 @@ import java.time.LocalDateTime;
@TableName("product") @TableName("product")
public class Product { public class Product {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String code; private String code;
private String name; private String name;
@@ -14,7 +14,7 @@ import java.time.LocalDateTime;
@TableName("reconcile_task") @TableName("reconcile_task")
public class ReconcileTask { public class ReconcileTask {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private LocalDate bizDate; private LocalDate bizDate;
private String platform; private String platform;
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("store_receipt") @TableName("store_receipt")
public class StoreReceipt { public class StoreReceipt {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String platform; private String platform;
private String transactionId; private String transactionId;
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("wallet") @TableName("wallet")
public class Wallet { public class Wallet {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long userId; private Long userId;
private Long coinBalance; private Long coinBalance;
@@ -14,7 +14,7 @@ import java.time.LocalDateTime;
@TableName("content_banner") @TableName("content_banner")
public class ContentBanner { public class ContentBanner {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String imageUrl; private String imageUrl;
/** 展示位置,列名为 slot(position 在 PostgreSQL 里是关键字) */ /** 展示位置,列名为 slot(position 在 PostgreSQL 里是关键字) */
@@ -14,7 +14,7 @@ import java.time.LocalDateTime;
@TableName("drama") @TableName("drama")
public class Drama { public class Drama {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String code; private String code;
private String title; private String title;
@@ -9,7 +9,7 @@ import lombok.Data;
@TableName("drama_category") @TableName("drama_category")
public class DramaCategory { public class DramaCategory {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String name; private String name;
private Integer sort; private Integer sort;
@@ -14,7 +14,7 @@ import java.time.LocalDateTime;
@TableName("drama_episode") @TableName("drama_episode")
public class DramaEpisode { public class DramaEpisode {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long dramaId; private Long dramaId;
private Integer epNo; private Integer epNo;
@@ -11,7 +11,7 @@ import java.time.LocalDateTime;
@TableName("drama_review_record") @TableName("drama_review_record")
public class DramaReviewRecord { public class DramaReviewRecord {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String bizType; private String bizType;
private Long bizId; private Long bizId;
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("content_submission") @TableName("content_submission")
public class ContentSubmission { public class ContentSubmission {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long creatorId; private Long creatorId;
private Long dramaId; private Long dramaId;
@@ -16,7 +16,7 @@ import java.time.LocalDateTime;
@TableName("creator") @TableName("creator")
public class Creator { public class Creator {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
/** 关联 C 端账号 app_user.id */ /** 关联 C 端账号 app_user.id */
private Long userId; private Long userId;
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("creator_document") @TableName("creator_document")
public class CreatorDocument { public class CreatorDocument {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long creatorId; private Long creatorId;
/** ID_CARD / BUSINESS_LICENSE / CONTRACT */ /** ID_CARD / BUSINESS_LICENSE / CONTRACT */
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("content_audit_record") @TableName("content_audit_record")
public class ContentAuditRecord { public class ContentAuditRecord {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
/** COMMENT/DRAMA/AVATAR/CREATOR */ /** COMMENT/DRAMA/AVATAR/CREATOR */
private String bizType; private String bizType;
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("content_comment") @TableName("content_comment")
public class ContentComment { public class ContentComment {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long dramaId; private Long dramaId;
private Long episodeId; private Long episodeId;
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
@TableName("content_like") @TableName("content_like")
public class ContentLike { public class ContentLike {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long userId; private Long userId;
/** DRAMA/EPISODE/COMMENT */ /** DRAMA/EPISODE/COMMENT */
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("content_report") @TableName("content_report")
public class ContentReport { public class ContentReport {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long reporterId; private Long reporterId;
/** COMMENT/USER/DRAMA */ /** COMMENT/USER/DRAMA */
@@ -12,7 +12,7 @@ import java.time.LocalDateTime;
@TableName("content_share") @TableName("content_share")
public class ContentShare { public class ContentShare {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long userId; private Long userId;
private Long dramaId; private Long dramaId;
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("sensitive_word") @TableName("sensitive_word")
public class SensitiveWord { public class SensitiveWord {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String word; private String word;
/** POLITICS/PORN/ABUSE/AD/RELIGION */ /** POLITICS/PORN/ABUSE/AD/RELIGION */
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
@TableName("user_blocklist") @TableName("user_blocklist")
public class UserBlocklist { public class UserBlocklist {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long userId; private Long userId;
private Long blockedUserId; private Long blockedUserId;
@@ -14,7 +14,7 @@ import java.time.LocalDateTime;
@TableName("media_asset") @TableName("media_asset")
public class MediaAsset { public class MediaAsset {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String bizType; private String bizType;
private String fileName; private String fileName;
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("media_rendition") @TableName("media_rendition")
public class MediaRendition { public class MediaRendition {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long mediaAssetId; private Long mediaAssetId;
private Long dramaId; private Long dramaId;
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
@TableName("media_upload_task") @TableName("media_upload_task")
public class MediaUploadTask { public class MediaUploadTask {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String uploadId; private String uploadId;
private String bizType; private String bizType;
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("transcode_task") @TableName("transcode_task")
public class TranscodeTask { public class TranscodeTask {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long mediaAssetId; private Long mediaAssetId;
private Long dramaId; private Long dramaId;
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("download_license") @TableName("download_license")
public class DownloadLicense { public class DownloadLicense {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long userId; private Long userId;
private Long dramaId; private Long dramaId;
@@ -18,7 +18,7 @@ import java.time.LocalDateTime;
@TableName("download_task") @TableName("download_task")
public class DownloadTask { public class DownloadTask {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long userId; private Long userId;
private String deviceId; private String deviceId;
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("playback_ticket") @TableName("playback_ticket")
public class PlaybackTicket { public class PlaybackTicket {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String ticketNo; private String ticketNo;
private Long userId; private Long userId;
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("user_favorite") @TableName("user_favorite")
public class UserFavorite { public class UserFavorite {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long userId; private Long userId;
private Long dramaId; private Long dramaId;
@@ -16,7 +16,7 @@ import java.time.LocalDateTime;
@TableName("watch_history") @TableName("watch_history")
public class WatchHistory { public class WatchHistory {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long userId; private Long userId;
private Long dramaId; private Long dramaId;
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("watch_progress") @TableName("watch_progress")
public class WatchProgress { public class WatchProgress {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long userId; private Long userId;
private Long dramaId; private Long dramaId;
@@ -14,7 +14,7 @@ import java.time.LocalDateTime;
@TableName("sys_admin_user") @TableName("sys_admin_user")
public class AdminUser { public class AdminUser {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String username; private String username;
private String password; private String password;
@@ -13,7 +13,7 @@ import lombok.NoArgsConstructor;
@TableName("sys_admin_user_role") @TableName("sys_admin_user_role")
public class AdminUserRole { public class AdminUserRole {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long userId; private Long userId;
private Long roleId; private Long roleId;
@@ -17,7 +17,7 @@ import java.time.LocalDateTime;
@TableName("audit_log") @TableName("audit_log")
public class AuditLog { public class AuditLog {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
/** ORDER / ENTITLEMENT / USER / CREATOR / PRICE */ /** ORDER / ENTITLEMENT / USER / CREATOR / PRICE */
private String bizType; private String bizType;
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
@TableName("sys_menu") @TableName("sys_menu")
public class Menu { public class Menu {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long parentId; private Long parentId;
private String title; private String title;
@@ -11,7 +11,7 @@ import java.time.LocalDateTime;
@TableName("sys_operation_log") @TableName("sys_operation_log")
public class OperationLog { public class OperationLog {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long adminUserId; private Long adminUserId;
private String username; private String username;
@@ -9,7 +9,7 @@ import lombok.Data;
@TableName("sys_permission") @TableName("sys_permission")
public class Permission { public class Permission {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String code; private String code;
private String name; private String name;
@@ -14,7 +14,7 @@ import java.time.LocalDateTime;
@TableName("sys_role") @TableName("sys_role")
public class Role { public class Role {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String name; private String name;
private String code; private String code;
@@ -14,7 +14,7 @@ import java.time.LocalDateTime;
@TableName("sys_role_category") @TableName("sys_role_category")
public class RoleCategory { public class RoleCategory {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String name; private String name;
private String description; private String description;
@@ -13,7 +13,7 @@ import lombok.NoArgsConstructor;
@TableName("sys_role_menu") @TableName("sys_role_menu")
public class RoleMenu { public class RoleMenu {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long roleId; private Long roleId;
private Long menuId; private Long menuId;
@@ -13,7 +13,7 @@ import lombok.NoArgsConstructor;
@TableName("sys_role_permission") @TableName("sys_role_permission")
public class RolePermission { public class RolePermission {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long roleId; private Long roleId;
private Long permissionId; private Long permissionId;
@@ -9,7 +9,7 @@ import lombok.Data;
@TableName("sys_config") @TableName("sys_config")
public class SysConfig { public class SysConfig {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String configKey; private String configKey;
private String configValue; private String configValue;
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("app_account_deletion") @TableName("app_account_deletion")
public class AppAccountDeletion { public class AppAccountDeletion {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long userId; private Long userId;
/** pending/cancelled/completed */ /** pending/cancelled/completed */
@@ -16,7 +16,7 @@ import java.time.LocalDateTime;
@TableName("app_user") @TableName("app_user")
public class AppUser { public class AppUser {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
/** 对外用户标识,接口只暴露 uid,不暴露自增 ID */ /** 对外用户标识,接口只暴露 uid,不暴露自增 ID */
private String uid; private String uid;
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("app_user_ban_record") @TableName("app_user_ban_record")
public class AppUserBanRecord { public class AppUserBanRecord {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long userId; private Long userId;
/** BAN/LIMIT/MUTE */ /** BAN/LIMIT/MUTE */
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("app_user_consent") @TableName("app_user_consent")
public class AppUserConsent { public class AppUserConsent {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long userId; private Long userId;
private String scene; private String scene;
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("app_user_credential") @TableName("app_user_credential")
public class AppUserCredential { public class AppUserCredential {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long userId; private Long userId;
/** PHONE/EMAIL/APPLE/GOOGLE */ /** PHONE/EMAIL/APPLE/GOOGLE */
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("app_user_device") @TableName("app_user_device")
public class AppUserDevice { public class AppUserDevice {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long userId; private Long userId;
private String deviceId; private String deviceId;
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("app_user_otp") @TableName("app_user_otp")
public class AppUserOtp { public class AppUserOtp {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String scene; private String scene;
private String targetType; private String targetType;
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("app_user_session") @TableName("app_user_session")
public class AppUserSession { public class AppUserSession {
@TableId(type = IdType.AUTO) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long userId; private Long userId;
private String refreshTokenHash; private String refreshTokenHash;
@@ -15,6 +15,7 @@ import com.fruitshorts.user.dal.mapper.AppUserDeviceMapper;
import com.fruitshorts.user.dal.mapper.AppUserMapper; import com.fruitshorts.user.dal.mapper.AppUserMapper;
import com.fruitshorts.user.dal.mapper.AppUserSessionMapper; import com.fruitshorts.user.dal.mapper.AppUserSessionMapper;
import com.fruitshorts.user.enums.AppUserStatus; import com.fruitshorts.user.enums.AppUserStatus;
import com.fruitshorts.user.enums.CredentialType;
import com.fruitshorts.user.enums.OtpScene; import com.fruitshorts.user.enums.OtpScene;
import com.fruitshorts.user.vo.AppUserRespVO; import com.fruitshorts.user.vo.AppUserRespVO;
import com.fruitshorts.user.vo.LoginReqVO; import com.fruitshorts.user.vo.LoginReqVO;
@@ -30,6 +31,7 @@ import org.springframework.util.StringUtils;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.regex.Pattern;
/** /**
* C 端账号:注册、登录、令牌轮换、登出。 * C 端账号:注册、登录、令牌轮换、登出。
@@ -59,7 +61,21 @@ public class AppAuthService {
public TokenRespVO register(RegisterReqVO req, String clientIp) { public TokenRespVO register(RegisterReqVO req, String clientIp) {
String targetType = AppIdentifier.normalizeType(req.getTargetType()); String targetType = AppIdentifier.normalizeType(req.getTargetType());
String target = AppIdentifier.normalize(targetType, req.getTarget()); String target = AppIdentifier.normalize(targetType, req.getTarget());
appOtpService.consume(OtpScene.REGISTER.name(), targetType, target, req.getCode());
boolean emailRegister = CredentialType.EMAIL.name().equals(targetType);
if (emailRegister) {
// 邮箱+密码注册:不阻塞于验证码(OTP 目前只写日志、发不出去),但必须有风控 + 密码强度
AppIdentifier.requireEmailFormat(target);
requirePasswordStrength(req.getPassword());
if (StringUtils.hasText(req.getCode())) {
// 用户主动走了邮箱验证,就把验证能力用上(保留但不阻塞)
appOtpService.consume(OtpScene.REGISTER.name(), targetType, target, req.getCode());
}
} else {
// 手机号注册维持原状:必须验证码
appOtpService.consume(OtpScene.REGISTER.name(), targetType, target, req.getCode());
}
guardRegisterRisk(clientIp, req.getDeviceId());
if (findCredential(targetType, target) != null) { if (findCredential(targetType, target) != null) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "该账号已注册,请直接登录"); throw new ServiceException(ErrorCode.DATA_EXISTS, "该账号已注册,请直接登录");
@@ -75,8 +91,10 @@ public class AppAuthService {
credential.setPasswordHash(StringUtils.hasText(req.getPassword()) credential.setPasswordHash(StringUtils.hasText(req.getPassword())
? passwordEncoder.encode(req.getPassword()) ? passwordEncoder.encode(req.getPassword())
: ""); : "");
credential.setVerified(1); // 邮箱免码注册时先记"未验证"(verified=0),等用户日后点验证链接再置 1;登录不要求 verified
credential.setVerifiedAt(LocalDateTime.now()); boolean verified = !emailRegister || StringUtils.hasText(req.getCode());
credential.setVerified(verified ? 1 : 0);
credential.setVerifiedAt(verified ? LocalDateTime.now() : null);
credential.setLastUsedAt(LocalDateTime.now()); credential.setLastUsedAt(LocalDateTime.now());
credential.setFederationId(""); credential.setFederationId("");
credential.setAppId(""); credential.setAppId("");
@@ -130,8 +148,13 @@ public class AppAuthService {
} }
credential.setLastUsedAt(LocalDateTime.now()); credential.setLastUsedAt(LocalDateTime.now());
credential.setVerified(1); if (byCode) {
credential.setVerifiedAt(LocalDateTime.now()); // 只有「验证码登录」能证明邮箱/手机号确实归用户所有 → 才算已验证。
// 密码登录不改验证状态:知道密码不等于能收到这个邮箱的信,否则
// "邮箱免码注册先记未验证" 这条设计会被一次登录悄悄抹掉。
credential.setVerified(1);
credential.setVerifiedAt(LocalDateTime.now());
}
credentialMapper.updateById(credential); credentialMapper.updateById(credential);
user.setLastLoginAt(LocalDateTime.now()); user.setLastLoginAt(LocalDateTime.now());
@@ -276,6 +299,61 @@ public class AppAuthService {
return resp; return resp;
} }
/** 每个 IP 24 小时内最多注册几个账号 */
private static final int MAX_REGISTER_PER_IP_PER_DAY = 5;
/** 每台设备 24 小时内最多注册几个账号 */
private static final int MAX_REGISTER_PER_DEVICE_PER_DAY = 3;
private static final int PASSWORD_MIN_LENGTH = 8;
private static final int PASSWORD_MAX_LENGTH = 64;
private static final Pattern HAS_LETTER = Pattern.compile("[A-Za-z]");
private static final Pattern HAS_DIGIT = Pattern.compile("[0-9]");
/**
* 密码强度下限:8~64 位,且同时包含字母与数字。
*
* 有意不要求特殊字符:印尼低端机用户输入成本高,强制特殊字符会把注册转化率打下去,
* 真正拦机器注册靠的是下面的频控。
*/
private void requirePasswordStrength(String password) {
String value = password == null ? "" : password;
if (value.length() < PASSWORD_MIN_LENGTH || value.length() > PASSWORD_MAX_LENGTH) {
throw new ServiceException(ErrorCode.BAD_REQUEST,
"密码长度需在 " + PASSWORD_MIN_LENGTH + "-" + PASSWORD_MAX_LENGTH + " 位之间");
}
if (!HAS_LETTER.matcher(value).find() || !HAS_DIGIT.matcher(value).find()) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "密码需同时包含字母和数字");
}
}
/**
* 注册频控(邮箱免码注册必须带的兜底):同 IP 24h ≤5 个、同设备 24h ≤3 个。
*
* 直接数 `app_user` 上的 register_ip / register_device_id,不新建计数表:
* 注册量本来就低,这两列也已经有数据,省一张表与一套清理任务。
* 注意这是**弱风控**:IP 与设备号都是客户端/网络给的,改一下就能绕过,只挡脚本批量。
*/
private void guardRegisterRisk(String clientIp, String deviceId) {
LocalDateTime since = LocalDateTime.now().minusDays(1);
String ip = AppIdentifier.nullSafe(clientIp);
if (StringUtils.hasText(ip)) {
Long byIp = appUserMapper.selectCount(Wrappers.<AppUser>lambdaQuery()
.eq(AppUser::getRegisterIp, ip)
.ge(AppUser::getCreatedAt, since));
if (byIp != null && byIp >= MAX_REGISTER_PER_IP_PER_DAY) {
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "当前网络注册过于频繁,请稍后再试");
}
}
String device = AppIdentifier.nullSafe(deviceId);
if (StringUtils.hasText(device)) {
Long byDevice = appUserMapper.selectCount(Wrappers.<AppUser>lambdaQuery()
.eq(AppUser::getRegisterDeviceId, device)
.ge(AppUser::getCreatedAt, since));
if (byDevice != null && byDevice >= MAX_REGISTER_PER_DEVICE_PER_DAY) {
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "该设备注册过于频繁,请稍后再试");
}
}
}
private AppUser createUser(RegisterReqVO req, String clientIp) { private AppUser createUser(RegisterReqVO req, String clientIp) {
AppUser user = new AppUser(); AppUser user = new AppUser();
user.setUid(hasher.newUid()); user.setUid(hasher.newUid());
@@ -6,6 +6,7 @@ import com.fruitshorts.user.enums.CredentialType;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.util.Locale; import java.util.Locale;
import java.util.regex.Pattern;
/** 手机号/邮箱的规范化与脱敏。 */ /** 手机号/邮箱的规范化与脱敏。 */
public final class AppIdentifier { public final class AppIdentifier {
@@ -60,6 +61,23 @@ public final class AppIdentifier {
return identifier.substring(0, 4) + "****" + identifier.substring(identifier.length() - 3); return identifier.substring(0, 4) + "****" + identifier.substring(identifier.length() - 3);
} }
/**
* 邮箱格式校验。
*
* 只做"看起来像邮箱"的基础校验(本地部分 + 域名 + 至少一个点,域名后缀 2 位以上):
* 真实可达性要靠发信验证,而本轮邮箱验证是**保留但不阻塞**的,所以格式不合法直接拒,
* 格式合法但收不到信也不挡注册。
*/
private static final Pattern EMAIL_PATTERN =
Pattern.compile("^[A-Za-z0-9._%+\\-]{1,64}@[A-Za-z0-9]([A-Za-z0-9\\-]{0,61}[A-Za-z0-9])?"
+ "(\\.[A-Za-z0-9]([A-Za-z0-9\\-]{0,61}[A-Za-z0-9])?)*\\.[A-Za-z]{2,}$");
public static void requireEmailFormat(String email) {
if (!StringUtils.hasText(email) || email.length() > 191 || !EMAIL_PATTERN.matcher(email).matches()) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "邮箱格式不正确");
}
}
public static String nullSafe(String value) { public static String nullSafe(String value) {
return value == null ? "" : value; return value == null ? "" : value;
} }
@@ -13,7 +13,10 @@ public class RegisterReqVO {
@NotBlank(message = "请输入手机号或邮箱") @NotBlank(message = "请输入手机号或邮箱")
private String target; private String target;
@NotBlank(message = "请输入验证码") /**
* 验证码:`targetType=PHONE` 时必填;`targetType=EMAIL` 时可以不传(邮箱+密码直接注册),
* 传了就顺手校验一次(邮箱验证能力保留)。必填性在服务端按 targetType 判断。
*/
private String code; private String code;
/** 可选:同时设置密码,之后可用密码登录 */ /** 可选:同时设置密码,之后可用密码登录 */
+67 -64
View File
@@ -27,50 +27,53 @@ BEGIN
END LOOP; END LOOP;
END $$; END $$;
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES -- 主键说明:S0 之后数据库不再自增,种子行必须显式给主键。
('app-user:list', '查看 App 用户', '用户管理', 'BUTTON', 101), -- 本文件新增的权限点统一用「1000 + sort」当 ID(1101-1156 这段保留区间),
('app-user:update', '处置 App 用户', '用户管理', 'BUTTON', 102) -- 避开早期 IDENTITY 已经用掉的 1-251;应用运行时产生的行才用雪花 ID。
INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
(1101, 'app-user:list', '查看 App 用户', '用户管理', 'BUTTON', 101),
(1102, 'app-user:update', '处置 App 用户', '用户管理', 'BUTTON', 102)
ON CONFLICT (code) DO NOTHING; ON CONFLICT (code) DO NOTHING;
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
('download:list', '查看下载授权', '用户管理', 'BUTTON', 103), (1103, 'download:list', '查看下载授权', '用户管理', 'BUTTON', 103),
('download:revoke', '吊销下载授权', '用户管理', 'BUTTON', 104) (1104, 'download:revoke', '吊销下载授权', '用户管理', 'BUTTON', 104)
ON CONFLICT (code) DO NOTHING; ON CONFLICT (code) DO NOTHING;
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
('comment:list', '查看评论', '内容治理', 'BUTTON', 111), (1111, 'comment:list', '查看评论', '内容治理', 'BUTTON', 111),
('comment:audit', '审核评论', '内容治理', 'BUTTON', 112), (1112, 'comment:audit', '审核评论', '内容治理', 'BUTTON', 112),
('report:list', '查看举报', '内容治理', 'BUTTON', 113), (1113, 'report:list', '查看举报', '内容治理', 'BUTTON', 113),
('report:handle', '处理举报', '内容治理', 'BUTTON', 114), (1114, 'report:handle', '处理举报', '内容治理', 'BUTTON', 114),
('sensitive-word:list', '查看敏感词', '内容治理', 'BUTTON', 115), (1115, 'sensitive-word:list', '查看敏感词', '内容治理', 'BUTTON', 115),
('sensitive-word:update','维护敏感词', '内容治理', 'BUTTON', 116) (1116, 'sensitive-word:update','维护敏感词', '内容治理', 'BUTTON', 116)
ON CONFLICT (code) DO NOTHING; ON CONFLICT (code) DO NOTHING;
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
('order:list', '查看订单', '订单与权益', 'BUTTON', 121), (1121, 'order:list', '查看订单', '订单与权益', 'BUTTON', 121),
('order:update', '补发/退款', '订单与权益', 'BUTTON', 122), (1122, 'order:update', '补发/退款', '订单与权益', 'BUTTON', 122),
('entitlement:list', '查看权益', '订单与权益', 'BUTTON', 123), (1123, 'entitlement:list', '查看权益', '订单与权益', 'BUTTON', 123),
('entitlement:update', '补发/回收权益', '订单与权益', 'BUTTON', 124), (1124, 'entitlement:update', '补发/回收权益', '订单与权益', 'BUTTON', 124),
('wallet:update', '人工补币', '订单与权益', 'BUTTON', 125), (1125, 'wallet:update', '人工补币', '订单与权益', 'BUTTON', 125),
('reconcile:run', '手动对账', '订单与权益', 'BUTTON', 126) (1126, 'reconcile:run', '手动对账', '订单与权益', 'BUTTON', 126)
ON CONFLICT (code) DO NOTHING; ON CONFLICT (code) DO NOTHING;
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
('creator:list', '查看创作者', '创作者管理', 'BUTTON', 131), (1131, 'creator:list', '查看创作者', '创作者管理', 'BUTTON', 131),
('creator:audit', '创作者入驻审核','创作者管理', 'BUTTON', 132), (1132, 'creator:audit', '创作者入驻审核','创作者管理', 'BUTTON', 132),
('submission:list', '查看内容提交', '创作者管理', 'BUTTON', 133), (1133, 'submission:list', '查看内容提交', '创作者管理', 'BUTTON', 133),
('submission:audit', '内容提交审核', '创作者管理', 'BUTTON', 134), (1134, 'submission:audit', '内容提交审核', '创作者管理', 'BUTTON', 134),
('price:update', '配置分集价格', '内容管理', 'BUTTON', 135) (1135, 'price:update', '配置分集价格', '内容管理', 'BUTTON', 135)
ON CONFLICT (code) DO NOTHING; ON CONFLICT (code) DO NOTHING;
-- 看板权限:菜单在 3-4 补页面时一起加,避免菜单指向不存在的组件 -- 看板权限:菜单在 3-4 补页面时一起加,避免菜单指向不存在的组件
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
('dashboard:view', '查看运营大盘', '数据看板', 'BUTTON', 141) (1141, 'dashboard:view', '查看运营大盘', '数据看板', 'BUTTON', 141)
ON CONFLICT (code) DO NOTHING; ON CONFLICT (code) DO NOTHING;
-- 超管默认拥有全部权限点 -- 超管默认拥有全部权限点
INSERT INTO sys_role_permission (role_id, permission_id) INSERT INTO sys_role_permission (id, role_id, permission_id)
SELECT r.id, p.id SELECT r.id * 1000000 + p.id, r.id, p.id
FROM sys_role r FROM sys_role r
CROSS JOIN sys_permission p CROSS JOIN sys_permission p
WHERE r.code = 'super_admin' WHERE r.code = 'super_admin'
@@ -95,8 +98,8 @@ ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.
redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code, redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code,
type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort; type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort;
INSERT INTO sys_role_menu (role_id, menu_id) INSERT INTO sys_role_menu (id, role_id, menu_id)
SELECT r.id, m.id SELECT r.id * 1000000 + m.id, r.id, m.id
FROM sys_role r FROM sys_role r
CROSS JOIN sys_menu m CROSS JOIN sys_menu m
WHERE r.code = 'super_admin' WHERE r.code = 'super_admin'
@@ -104,19 +107,19 @@ WHERE r.code = 'super_admin'
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- 第一阶段新增的白名单数据(当前阶段都是免费内容,先把演示商品占位好) -- 第一阶段新增的白名单数据(当前阶段都是免费内容,先把演示商品占位好)
INSERT INTO product (code, name, type, platform, store_product_id, coin_amount, bonus_coin, INSERT INTO product (id, code, name, type, platform, store_product_id, coin_amount, bonus_coin,
price_amount, currency, region_codes, sort, status) price_amount, currency, region_codes, sort, status)
SELECT 'coin_100', '100 金币', 'COIN_PACK', 'ALL', '', 100, 0, 0.99, 'USD', 'ID', 1, 1 SELECT 901, 'coin_100', '100 金币', 'COIN_PACK', 'ALL', '', 100, 0, 0.99, 'USD', 'ID', 1, 1
WHERE NOT EXISTS (SELECT 1 FROM product WHERE code = 'coin_100'); WHERE NOT EXISTS (SELECT 1 FROM product WHERE code = 'coin_100');
INSERT INTO product (code, name, type, platform, store_product_id, coin_amount, bonus_coin, INSERT INTO product (id, code, name, type, platform, store_product_id, coin_amount, bonus_coin,
price_amount, currency, region_codes, sort, status) price_amount, currency, region_codes, sort, status)
SELECT 'coin_550', '550 金币', 'COIN_PACK', 'ALL', '', 500, 50, 4.99, 'USD', 'ID', 2, 1 SELECT 902, 'coin_550', '550 金币', 'COIN_PACK', 'ALL', '', 500, 50, 4.99, 'USD', 'ID', 2, 1
WHERE NOT EXISTS (SELECT 1 FROM product WHERE code = 'coin_550'); WHERE NOT EXISTS (SELECT 1 FROM product WHERE code = 'coin_550');
INSERT INTO product (code, name, type, platform, store_product_id, coin_amount, bonus_coin, INSERT INTO product (id, code, name, type, platform, store_product_id, coin_amount, bonus_coin,
price_amount, currency, region_codes, sort, status) price_amount, currency, region_codes, sort, status)
SELECT 'coin_1200', '1200 金币', 'COIN_PACK', 'ALL', '', 1000, 200, 9.99, 'USD', 'ID', 3, 1 SELECT 903, 'coin_1200', '1200 金币', 'COIN_PACK', 'ALL', '', 1000, 200, 9.99, 'USD', 'ID', 3, 1
WHERE NOT EXISTS (SELECT 1 FROM product WHERE code = 'coin_1200'); WHERE NOT EXISTS (SELECT 1 FROM product WHERE code = 'coin_1200');
-- ---------- 菜单:用户管理 ---------- -- ---------- 菜单:用户管理 ----------
@@ -131,8 +134,8 @@ ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.
redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code, redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code,
type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort; type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort;
INSERT INTO sys_role_menu (role_id, menu_id) INSERT INTO sys_role_menu (id, role_id, menu_id)
SELECT r.id, m.id SELECT r.id * 1000000 + m.id, r.id, m.id
FROM sys_role r FROM sys_role r
CROSS JOIN sys_menu m CROSS JOIN sys_menu m
WHERE r.code = 'super_admin' WHERE r.code = 'super_admin'
@@ -156,8 +159,8 @@ ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.
redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code, redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code,
type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort; type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort;
INSERT INTO sys_role_menu (role_id, menu_id) INSERT INTO sys_role_menu (id, role_id, menu_id)
SELECT r.id, m.id SELECT r.id * 1000000 + m.id, r.id, m.id
FROM sys_role r FROM sys_role r
CROSS JOIN sys_menu m CROSS JOIN sys_menu m
WHERE r.code = 'super_admin' WHERE r.code = 'super_admin'
@@ -177,34 +180,34 @@ ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, code = EXCLUDED.code,
description = EXCLUDED.description, category_id = EXCLUDED.category_id, level = EXCLUDED.level; description = EXCLUDED.description, category_id = EXCLUDED.category_id, level = EXCLUDED.level;
-- 权限点按批补:批 1 只有「进创作者中心」这一个,后面各批用到什么再幂等追加 -- 权限点按批补:批 1 只有「进创作者中心」这一个,后面各批用到什么再幂等追加
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
('creator-center:view', '访问创作者中心', '创作者中心', 'BUTTON', 150), (1150, 'creator-center:view', '访问创作者中心', '创作者中心', 'BUTTON', 150),
('creator-center:drama:edit', '创作者中心-编辑作品', '创作者中心', 'BUTTON', 151), (1151, 'creator-center:drama:edit', '创作者中心-编辑作品', '创作者中心', 'BUTTON', 151),
('creator-center:upload', '创作者中心-上传与挂载', '创作者中心', 'BUTTON', 152), (1152, 'creator-center:upload', '创作者中心-上传与挂载', '创作者中心', 'BUTTON', 152),
('creator-center:price:update', '创作者中心-配置分集价格', '创作者中心', 'BUTTON', 153), (1153, 'creator-center:price:update', '创作者中心-配置分集价格', '创作者中心', 'BUTTON', 153),
('creator-center:stat:view', '创作者中心-查看看板', '创作者中心', 'BUTTON', 154) (1154, 'creator-center:stat:view', '创作者中心-查看看板', '创作者中心', 'BUTTON', 154)
ON CONFLICT (code) DO NOTHING; ON CONFLICT (code) DO NOTHING;
-- 代看创作者(只读):第一阶段只给超管,content_operator 不给 -- 代看创作者(只读):第一阶段只给超管,content_operator 不给
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
('creator-center:impersonate', '代看创作者(只读)', '创作者中心', 'BUTTON', 155) (1155, 'creator-center:impersonate', '代看创作者(只读)', '创作者中心', 'BUTTON', 155)
ON CONFLICT (code) DO NOTHING; ON CONFLICT (code) DO NOTHING;
-- 开通 / 重置创作者账号 -- 开通 / 重置创作者账号
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
('creator:account', '开通/重置创作者账号', '创作者管理', 'BUTTON', 136) (1136, 'creator:account', '开通/重置创作者账号', '创作者管理', 'BUTTON', 136)
ON CONFLICT (code) DO NOTHING; ON CONFLICT (code) DO NOTHING;
INSERT INTO sys_role_permission (role_id, permission_id) INSERT INTO sys_role_permission (id, role_id, permission_id)
SELECT r.id, p.id SELECT r.id * 1000000 + p.id, r.id, p.id
FROM sys_role r FROM sys_role r
CROSS JOIN sys_permission p CROSS JOIN sys_permission p
WHERE r.code = 'super_admin' WHERE r.code = 'super_admin'
AND p.code = 'creator:account' AND p.code = 'creator:account'
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
INSERT INTO sys_role_permission (role_id, permission_id) INSERT INTO sys_role_permission (id, role_id, permission_id)
SELECT r.id, p.id SELECT r.id * 1000000 + p.id, r.id, p.id
FROM sys_role r FROM sys_role r
CROSS JOIN sys_permission p CROSS JOIN sys_permission p
WHERE r.code = 'super_admin' WHERE r.code = 'super_admin'
@@ -212,8 +215,8 @@ WHERE r.code = 'super_admin'
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- 创作者角色只拿创作者中心这几个点,不给任何后台运营权限 -- 创作者角色只拿创作者中心这几个点,不给任何后台运营权限
INSERT INTO sys_role_permission (role_id, permission_id) INSERT INTO sys_role_permission (id, role_id, permission_id)
SELECT r.id, p.id SELECT r.id * 1000000 + p.id, r.id, p.id
FROM sys_role r FROM sys_role r
CROSS JOIN sys_permission p CROSS JOIN sys_permission p
WHERE r.code = 'creator' WHERE r.code = 'creator'
@@ -222,8 +225,8 @@ WHERE r.code = 'creator'
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- 超管也留一份,方便排查创作者视角的问题(超管没有绑定创作者主体,进 creator-center 仍会被挡) -- 超管也留一份,方便排查创作者视角的问题(超管没有绑定创作者主体,进 creator-center 仍会被挡)
INSERT INTO sys_role_permission (role_id, permission_id) INSERT INTO sys_role_permission (id, role_id, permission_id)
SELECT r.id, p.id SELECT r.id * 1000000 + p.id, r.id, p.id
FROM sys_role r FROM sys_role r
CROSS JOIN sys_permission p CROSS JOIN sys_permission p
WHERE r.code = 'super_admin' WHERE r.code = 'super_admin'
@@ -233,8 +236,8 @@ ON CONFLICT DO NOTHING;
-- 媒资分片上传是复用后台已有的 /admin/media/upload/*,所以创作者角色要用现成的上传权限点, -- 媒资分片上传是复用后台已有的 /admin/media/upload/*,所以创作者角色要用现成的上传权限点,
-- 不再造一套 creator-center 的上传接口(上传会话归属与产物归属另在服务层校验)。 -- 不再造一套 creator-center 的上传接口(上传会话归属与产物归属另在服务层校验)。
INSERT INTO sys_role_permission (role_id, permission_id) INSERT INTO sys_role_permission (id, role_id, permission_id)
SELECT r.id, p.id SELECT r.id * 1000000 + p.id, r.id, p.id
FROM sys_role r FROM sys_role r
CROSS JOIN sys_permission p CROSS JOIN sys_permission p
WHERE r.code = 'creator' WHERE r.code = 'creator'
@@ -260,8 +263,8 @@ ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.
sort = EXCLUDED.sort, visible = EXCLUDED.visible; sort = EXCLUDED.sort, visible = EXCLUDED.visible;
-- 创作者角色:只挂创作者中心这五条(含隐藏的编辑页),**不给任何运营后台菜单** -- 创作者角色:只挂创作者中心这五条(含隐藏的编辑页),**不给任何运营后台菜单**
INSERT INTO sys_role_menu (role_id, menu_id) INSERT INTO sys_role_menu (id, role_id, menu_id)
SELECT r.id, m.id SELECT r.id * 1000000 + m.id, r.id, m.id
FROM sys_role r FROM sys_role r
CROSS JOIN sys_menu m CROSS JOIN sys_menu m
WHERE r.code = 'creator' WHERE r.code = 'creator'
+27 -38
View File
@@ -76,37 +76,37 @@ ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.
type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort; type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort;
-- ---------- 角色-权限 ---------- -- ---------- 角色-权限 ----------
INSERT INTO sys_role_permission (role_id, permission_id) INSERT INTO sys_role_permission (id, role_id, permission_id)
SELECT r.id, p.id FROM sys_role r CROSS JOIN sys_permission p WHERE r.code = 'super_admin' SELECT r.id * 1000000 + p.id, r.id, p.id FROM sys_role r CROSS JOIN sys_permission p WHERE r.code = 'super_admin'
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
INSERT INTO sys_role_permission (role_id, permission_id) INSERT INTO sys_role_permission (id, role_id, permission_id)
SELECT r.id, p.id FROM sys_role r CROSS JOIN sys_permission p SELECT r.id * 1000000 + p.id, r.id, p.id FROM sys_role r CROSS JOIN sys_permission p
WHERE r.code = 'content_operator' WHERE r.code = 'content_operator'
AND p.code IN ('drama:list','drama:create','drama:update','drama:delete', AND p.code IN ('drama:list','drama:create','drama:update','drama:delete',
'episode:list','episode:upload','episode:sort','cover:upload', 'episode:list','episode:upload','episode:sort','cover:upload',
'banner:list','banner:create','banner:update','banner:delete') 'banner:list','banner:create','banner:update','banner:delete')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
INSERT INTO sys_role_permission (role_id, permission_id) INSERT INTO sys_role_permission (id, role_id, permission_id)
SELECT r.id, p.id FROM sys_role r CROSS JOIN sys_permission p SELECT r.id * 1000000 + p.id, r.id, p.id FROM sys_role r CROSS JOIN sys_permission p
WHERE r.code = 'content_reviewer' WHERE r.code = 'content_reviewer'
AND p.code IN ('drama:list','episode:list','drama:review','stat:view','banner:list') AND p.code IN ('drama:list','episode:list','drama:review','stat:view','banner:list')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
INSERT INTO sys_role_permission (role_id, permission_id) INSERT INTO sys_role_permission (id, role_id, permission_id)
SELECT r.id, p.id FROM sys_role r CROSS JOIN sys_permission p SELECT r.id * 1000000 + p.id, r.id, p.id FROM sys_role r CROSS JOIN sys_permission p
WHERE r.code = 'data_analyst' WHERE r.code = 'data_analyst'
AND p.code IN ('drama:list','stat:view','banner:list') AND p.code IN ('drama:list','stat:view','banner:list')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- ---------- 角色-菜单 ---------- -- ---------- 角色-菜单 ----------
INSERT INTO sys_role_menu (role_id, menu_id) INSERT INTO sys_role_menu (id, role_id, menu_id)
SELECT r.id, m.id FROM sys_role r CROSS JOIN sys_menu m WHERE r.code = 'super_admin' SELECT r.id * 1000000 + m.id, r.id, m.id FROM sys_role r CROSS JOIN sys_menu m WHERE r.code = 'super_admin'
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
INSERT INTO sys_role_menu (role_id, menu_id) INSERT INTO sys_role_menu (id, role_id, menu_id)
SELECT r.id, m.id FROM sys_role r CROSS JOIN sys_menu m SELECT r.id * 1000000 + m.id, r.id, m.id FROM sys_role r CROSS JOIN sys_menu m
WHERE r.code IN ('content_operator','content_reviewer','data_analyst') AND m.id IN (1,2,3,4,5,12,13) WHERE r.code IN ('content_operator','content_reviewer','data_analyst') AND m.id IN (1,2,3,4,5,12,13)
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
@@ -122,14 +122,15 @@ INSERT INTO sys_admin_user (id, username, password, real_name, mobile, email, st
ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, real_name = EXCLUDED.real_name, ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, real_name = EXCLUDED.real_name,
status = EXCLUDED.status, two_factor = EXCLUDED.two_factor, last_login_at = EXCLUDED.last_login_at; status = EXCLUDED.status, two_factor = EXCLUDED.two_factor, last_login_at = EXCLUDED.last_login_at;
INSERT INTO sys_admin_user_role (user_id, role_id) VALUES INSERT INTO sys_admin_user_role (id, user_id, role_id) VALUES
(1001, 1), (1002, 1), (1003, 2), (1004, 2), (1005, 3), (1006, 4) (1001000001, 1001, 1), (1002000001, 1002, 1), (1003000002, 1003, 2),
(1004000002, 1004, 2), (1005000003, 1005, 3), (1006000004, 1006, 4)
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- ---------- 系统配置 ---------- -- ---------- 系统配置 ----------
INSERT INTO sys_config (config_key, config_value, remark) VALUES INSERT INTO sys_config (id, config_key, config_value, remark) VALUES
('workspace.name', 'FruitShorts Studio', '工作空间名称'), (1, 'workspace.name', 'FruitShorts Studio', '工作空间名称'),
('content.default-status', 'draft', '内容默认状态') (2, 'content.default-status', 'draft', '内容默认状态')
ON CONFLICT (config_key) DO UPDATE SET config_value = EXCLUDED.config_value, remark = EXCLUDED.remark; ON CONFLICT (config_key) DO UPDATE SET config_value = EXCLUDED.config_value, remark = EXCLUDED.remark;
-- ---------- 短剧类型 ---------- -- ---------- 短剧类型 ----------
@@ -168,24 +169,12 @@ ON CONFLICT (id) DO UPDATE SET drama_id = EXCLUDED.drama_id, ep_no = EXCLUDED.ep
title = EXCLUDED.title, duration_sec = EXCLUDED.duration_sec, is_free = EXCLUDED.is_free, title = EXCLUDED.title, duration_sec = EXCLUDED.duration_sec, is_free = EXCLUDED.is_free,
unlock_coin = EXCLUDED.unlock_coin, status = EXCLUDED.status, play_count = EXCLUDED.play_count; unlock_coin = EXCLUDED.unlock_coin, status = EXCLUDED.status, play_count = EXCLUDED.play_count;
-- ---------- 对齐自增序列 ---------- -- ---------- 关于主键 ----------
-- 上面用了显式 ID,但 IDENTITY 序列不会因此前移;不对齐的话,后续插入会从 1 开始而撞主键。 -- S0 之后主键由应用侧雪花算法生成(MyBatis-Plus IdType.ASSIGN_ID),数据库里已经没有自增序列,
DO $$ -- 所以本文件不再需要"把 IDENTITY 序列对齐到最大 ID"那段收尾逻辑(原来那段是防止显式 ID 之后
DECLARE -- 自增从 1 重新开始而撞主键;序列已经不存在,这个风险也随之消失)。
t text; --
seq text; -- 种子行的主键约定(三端一致,改动时请沿用):
maxid bigint; -- 1) 普通种子行沿用既有小整数区间(如角色 1-5、菜单 1-30、管理员 1001+);
BEGIN -- 2) 关联表用「左键 * 1000000 + 右键」拼出的确定性 ID,重复执行结果完全一致;
FOREACH t IN ARRAY ARRAY[ -- 3) 应用运行时产生的数据才用雪花 ID(19 位),两者不会相撞。
'sys_admin_user','sys_role_category','sys_role','sys_admin_user_role','sys_permission',
'sys_menu','sys_role_permission','sys_role_menu','sys_config','sys_operation_log',
'drama_category','drama','drama_episode','drama_review_record','content_banner',
'media_asset','media_upload_task'
] LOOP
seq := pg_get_serial_sequence(t, 'id');
EXECUTE format('SELECT COALESCE(MAX(id), 0) FROM %I', t) INTO maxid;
IF seq IS NOT NULL AND maxid > 0 THEN
PERFORM setval(seq, maxid);
END IF;
END LOOP;
END $$;
+49
View File
@@ -0,0 +1,49 @@
-- Author: fruit.com.cn
--
-- S0 主键改雪花 · 存量库迁移(PostgreSQL 18)
--
-- 背景:第一阶段建表用的是 GENERATED BY DEFAULT AS IDENTITY(数据库自增序列),
-- 这会让 ID 分配集中在单库的序列上,一旦分库分表就无法横向扩展。
-- S0 把主键改成应用侧雪花 ID(MyBatis-Plus IdType.ASSIGN_ID),
-- DDL 侧只需要把 identity 去掉——序列不再需要。
--
-- 本脚本只做一件事:把所有 identity 列改成普通 BIGINT(**保留已有数据,不重建表**)。
-- 刻意不用 DROP TABLE / TRUNCATE,避免误伤存量数据。
-- 幂等:已经迁移过的库再跑一遍不做任何改动,NOTICE 里的数字会是 0。
--
-- 执行:psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -f sql/migrate-s0-snowflake.sql
-- 复查:脚本末尾的注释 SQL(改写后应返回 0 行)
DO $$
DECLARE
r record;
v_done int := 0;
BEGIN
-- relispartition = false 表示"只处理顶层表":
-- 分区父表在 pg_class 里 relkind = 'p',月分区子表 relispartition = true;
-- identity 定义在父表上,子表跟着继承,所以逐个改子表是多余的。
FOR r IN
SELECT c.oid::regclass AS tbl, a.attname AS col
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid
WHERE n.nspname = 'public'
AND c.relkind IN ('r', 'p')
AND c.relispartition = false
AND a.attidentity <> ''
AND a.attnum > 0
ORDER BY c.relname
LOOP
EXECUTE format('ALTER TABLE %s ALTER COLUMN %I DROP IDENTITY IF EXISTS', r.tbl, r.col);
v_done := v_done + 1;
END LOOP;
RAISE NOTICE 'S0 迁移完成:处理 % 个 identity 列(重复执行时为 0)', v_done;
END $$;
-- 复查用(结果应为 0 行):
-- SELECT c.relname, a.attname
-- FROM pg_class c
-- JOIN pg_namespace n ON n.oid = c.relnamespace
-- JOIN pg_attribute a ON a.attrelid = c.oid
-- WHERE n.nspname = 'public' AND a.attidentity <> ''
-- ORDER BY 1, 2;
+2 -2
View File
@@ -99,7 +99,7 @@ BEGIN
END IF; END IF;
CREATE TABLE IF NOT EXISTS playback_ticket ( CREATE TABLE IF NOT EXISTS playback_ticket (
id BIGINT GENERATED BY DEFAULT AS IDENTITY, id BIGINT,
ticket_no VARCHAR(40) NOT NULL, ticket_no VARCHAR(40) NOT NULL,
user_id BIGINT NOT NULL DEFAULT 0, user_id BIGINT NOT NULL DEFAULT 0,
drama_id BIGINT NOT NULL, drama_id BIGINT NOT NULL,
@@ -134,7 +134,7 @@ BEGIN
END IF; END IF;
CREATE TABLE IF NOT EXISTS order_event ( CREATE TABLE IF NOT EXISTS order_event (
id BIGINT GENERATED BY DEFAULT AS IDENTITY, id BIGINT,
order_no VARCHAR(40) NOT NULL, order_no VARCHAR(40) NOT NULL,
event VARCHAR(32) NOT NULL, event VARCHAR(32) NOT NULL,
from_status VARCHAR(16) NOT NULL DEFAULT '', from_status VARCHAR(16) NOT NULL DEFAULT '',
+41 -41
View File
@@ -9,7 +9,7 @@
-- 具体的月分区由 sql/partition-maintenance.sql 创建(并把已存在的普通表转成分区表)。 -- 具体的月分区由 sql/partition-maintenance.sql 创建(并把已存在的普通表转成分区表)。
-- --
-- 命名与类型约定(与 sql/schema.sql 保持一致): -- 命名与类型约定(与 sql/schema.sql 保持一致):
-- BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY -- BIGINT PRIMARY KEY(不设默认值;主键由应用侧雪花算法生成:MyBatis-Plus IdType.ASSIGN_ID)
-- 布尔语义用 SMALLINT(1/0),时间用 TIMESTAMP(3),默认 CURRENT_TIMESTAMP -- 布尔语义用 SMALLINT(1/0),时间用 TIMESTAMP(3),默认 CURRENT_TIMESTAMP
-- 逻辑删除 deleted SMALLINT NOT NULL DEFAULT 0 -- 逻辑删除 deleted SMALLINT NOT NULL DEFAULT 0
-- updated_at 由 set_updated_at() 触发器维护 -- updated_at 由 set_updated_at() 触发器维护
@@ -53,7 +53,7 @@ ALTER TABLE media_asset ADD COLUMN IF NOT EXISTS storage_type VARCHAR(16) NO
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS app_user ( CREATE TABLE IF NOT EXISTS app_user (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
uid VARCHAR(32) NOT NULL, -- 对外展示的用户标识,避免暴露自增 ID uid VARCHAR(32) NOT NULL, -- 对外展示的用户标识,避免暴露自增 ID
nickname VARCHAR(64) NOT NULL DEFAULT '', nickname VARCHAR(64) NOT NULL DEFAULT '',
avatar_url VARCHAR(512) NOT NULL DEFAULT '', avatar_url VARCHAR(512) NOT NULL DEFAULT '',
@@ -89,7 +89,7 @@ CREATE INDEX IF NOT EXISTS idx_app_user_created ON app_user (created_at DESC);
COMMENT ON TABLE app_user IS 'C 端用户'; COMMENT ON TABLE app_user IS 'C 端用户';
CREATE TABLE IF NOT EXISTS app_user_credential ( CREATE TABLE IF NOT EXISTS app_user_credential (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
type VARCHAR(16) NOT NULL, -- PHONE/EMAIL/APPLE/GOOGLE type VARCHAR(16) NOT NULL, -- PHONE/EMAIL/APPLE/GOOGLE
identifier VARCHAR(191) NOT NULL, -- E.164 手机号 / 邮箱 / 第三方唯一标识 identifier VARCHAR(191) NOT NULL, -- E.164 手机号 / 邮箱 / 第三方唯一标识
@@ -108,7 +108,7 @@ CREATE INDEX IF NOT EXISTS idx_app_credential_user ON app_user_credential (user_
COMMENT ON TABLE app_user_credential IS 'C 端登录凭证(手机号/邮箱/第三方)'; COMMENT ON TABLE app_user_credential IS 'C 端登录凭证(手机号/邮箱/第三方)';
CREATE TABLE IF NOT EXISTS app_user_session ( CREATE TABLE IF NOT EXISTS app_user_session (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
refresh_token_hash VARCHAR(64) NOT NULL, -- SHA-256,不存明文 refresh_token_hash VARCHAR(64) NOT NULL, -- SHA-256,不存明文
device_id VARCHAR(128) NOT NULL DEFAULT '', device_id VARCHAR(128) NOT NULL DEFAULT '',
@@ -130,7 +130,7 @@ CREATE INDEX IF NOT EXISTS idx_app_session_user ON app_user_session (user_id, re
COMMENT ON TABLE app_user_session IS 'C 端会话(可多设备)'; COMMENT ON TABLE app_user_session IS 'C 端会话(可多设备)';
CREATE TABLE IF NOT EXISTS app_user_otp ( CREATE TABLE IF NOT EXISTS app_user_otp (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
scene VARCHAR(16) NOT NULL, -- REGISTER/LOGIN/RESET_PWD/BIND scene VARCHAR(16) NOT NULL, -- REGISTER/LOGIN/RESET_PWD/BIND
target_type VARCHAR(16) NOT NULL, -- PHONE/EMAIL target_type VARCHAR(16) NOT NULL, -- PHONE/EMAIL
target VARCHAR(191) NOT NULL, target VARCHAR(191) NOT NULL,
@@ -150,7 +150,7 @@ CREATE INDEX IF NOT EXISTS idx_app_otp_ip ON app_user_otp (send_ip, created_at D
COMMENT ON TABLE app_user_otp IS '验证码发送与校验记录(同时用于反刷频控)'; COMMENT ON TABLE app_user_otp IS '验证码发送与校验记录(同时用于反刷频控)';
CREATE TABLE IF NOT EXISTS app_user_consent ( CREATE TABLE IF NOT EXISTS app_user_consent (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
scene VARCHAR(32) NOT NULL, -- PRIVACY/TERMS/MARKETING/PERSONALIZED_AD scene VARCHAR(32) NOT NULL, -- PRIVACY/TERMS/MARKETING/PERSONALIZED_AD
version VARCHAR(32) NOT NULL DEFAULT '', version VARCHAR(32) NOT NULL DEFAULT '',
@@ -165,7 +165,7 @@ CREATE INDEX IF NOT EXISTS idx_app_consent_user ON app_user_consent (user_id, sc
COMMENT ON TABLE app_user_consent IS '同意管理记录,商店审核与隐私合规要求可追溯'; COMMENT ON TABLE app_user_consent IS '同意管理记录,商店审核与隐私合规要求可追溯';
CREATE TABLE IF NOT EXISTS app_user_device ( CREATE TABLE IF NOT EXISTS app_user_device (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
device_id VARCHAR(128) NOT NULL, device_id VARCHAR(128) NOT NULL,
platform VARCHAR(16) NOT NULL DEFAULT '', platform VARCHAR(16) NOT NULL DEFAULT '',
@@ -181,7 +181,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_app_device ON app_user_device (user_id, dev
COMMENT ON TABLE app_user_device IS '用户设备,用于下载授权与并发播放数限制'; COMMENT ON TABLE app_user_device IS '用户设备,用于下载授权与并发播放数限制';
CREATE TABLE IF NOT EXISTS app_user_ban_record ( CREATE TABLE IF NOT EXISTS app_user_ban_record (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
type VARCHAR(16) NOT NULL, -- BAN/LIMIT/MUTE type VARCHAR(16) NOT NULL, -- BAN/LIMIT/MUTE
reason_code VARCHAR(32) NOT NULL DEFAULT '', reason_code VARCHAR(32) NOT NULL DEFAULT '',
@@ -205,7 +205,7 @@ CREATE INDEX IF NOT EXISTS idx_app_ban_user ON app_user_ban_record (user_id, sta
COMMENT ON TABLE app_user_ban_record IS '封禁/限制与申诉记录'; COMMENT ON TABLE app_user_ban_record IS '封禁/限制与申诉记录';
CREATE TABLE IF NOT EXISTS app_account_deletion ( CREATE TABLE IF NOT EXISTS app_account_deletion (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending/cancelled/completed status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending/cancelled/completed
reason VARCHAR(255) NOT NULL DEFAULT '', reason VARCHAR(255) NOT NULL DEFAULT '',
@@ -226,7 +226,7 @@ COMMENT ON TABLE app_account_deletion IS '账号注销申请与冷静期';
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS watch_progress ( CREATE TABLE IF NOT EXISTS watch_progress (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
drama_id BIGINT NOT NULL, drama_id BIGINT NOT NULL,
episode_id BIGINT NOT NULL, episode_id BIGINT NOT NULL,
@@ -246,7 +246,7 @@ COMMENT ON TABLE watch_progress IS '每用户每剧一条续看进度(跨端
-- 观看流水:量大,按 watched_at 做声明式月分区,便于按期归档与清理 -- 观看流水:量大,按 watched_at 做声明式月分区,便于按期归档与清理
CREATE TABLE IF NOT EXISTS watch_history ( CREATE TABLE IF NOT EXISTS watch_history (
id BIGINT GENERATED BY DEFAULT AS IDENTITY, id BIGINT,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
drama_id BIGINT NOT NULL, drama_id BIGINT NOT NULL,
episode_id BIGINT NOT NULL, episode_id BIGINT NOT NULL,
@@ -263,7 +263,7 @@ CREATE INDEX IF NOT EXISTS idx_watch_history_drama ON watch_history (drama_id, w
COMMENT ON TABLE watch_history IS '观看流水(月分区,保留 12–24 个月)'; COMMENT ON TABLE watch_history IS '观看流水(月分区,保留 12–24 个月)';
CREATE TABLE IF NOT EXISTS user_favorite ( CREATE TABLE IF NOT EXISTS user_favorite (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
drama_id BIGINT NOT NULL, drama_id BIGINT NOT NULL,
sort INT NOT NULL DEFAULT 0, sort INT NOT NULL DEFAULT 0,
@@ -280,7 +280,7 @@ COMMENT ON TABLE user_favorite IS '剧集收藏';
-- 唯一索引是 (ticket_no, created_at);ticket_no 是 UUID,实际唯一性不受影响。 -- 唯一索引是 (ticket_no, created_at);ticket_no 是 UUID,实际唯一性不受影响。
-- 月分区本身由 sql/partition-maintenance.sql 建(含未来 3 个月)。 -- 月分区本身由 sql/partition-maintenance.sql 建(含未来 3 个月)。
CREATE TABLE IF NOT EXISTS playback_ticket ( CREATE TABLE IF NOT EXISTS playback_ticket (
id BIGINT GENERATED BY DEFAULT AS IDENTITY, id BIGINT,
ticket_no VARCHAR(40) NOT NULL, ticket_no VARCHAR(40) NOT NULL,
user_id BIGINT NOT NULL DEFAULT 0, -- 0 表示未登录的免费集 user_id BIGINT NOT NULL DEFAULT 0, -- 0 表示未登录的免费集
drama_id BIGINT NOT NULL, drama_id BIGINT NOT NULL,
@@ -303,7 +303,7 @@ CREATE INDEX IF NOT EXISTS idx_playback_ticket_user
COMMENT ON TABLE playback_ticket IS '播放票据签发审计;真正的鉴权在签名 URL 上(月分区)'; COMMENT ON TABLE playback_ticket IS '播放票据签发审计;真正的鉴权在签名 URL 上(月分区)';
CREATE TABLE IF NOT EXISTS download_license ( CREATE TABLE IF NOT EXISTS download_license (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
drama_id BIGINT NOT NULL, drama_id BIGINT NOT NULL,
episode_id BIGINT NOT NULL, episode_id BIGINT NOT NULL,
@@ -326,7 +326,7 @@ COMMENT ON TABLE download_license IS '离线下载授权与设备绑定(换机
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS content_comment ( CREATE TABLE IF NOT EXISTS content_comment (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
drama_id BIGINT NOT NULL, drama_id BIGINT NOT NULL,
episode_id BIGINT NOT NULL DEFAULT 0, episode_id BIGINT NOT NULL DEFAULT 0,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
@@ -352,7 +352,7 @@ CREATE INDEX IF NOT EXISTS idx_comment_user ON content_comment (user_id, created
COMMENT ON TABLE content_comment IS '评论与回复(先审后发)'; COMMENT ON TABLE content_comment IS '评论与回复(先审后发)';
CREATE TABLE IF NOT EXISTS content_like ( CREATE TABLE IF NOT EXISTS content_like (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
target_type VARCHAR(16) NOT NULL, -- DRAMA/EPISODE/COMMENT target_type VARCHAR(16) NOT NULL, -- DRAMA/EPISODE/COMMENT
target_id BIGINT NOT NULL, target_id BIGINT NOT NULL,
@@ -364,7 +364,7 @@ CREATE INDEX IF NOT EXISTS idx_content_like_target ON content_like (target_type,
COMMENT ON TABLE content_like IS '点赞关系;计数值异步聚合'; COMMENT ON TABLE content_like IS '点赞关系;计数值异步聚合';
CREATE TABLE IF NOT EXISTS content_report ( CREATE TABLE IF NOT EXISTS content_report (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
reporter_id BIGINT NOT NULL, reporter_id BIGINT NOT NULL,
target_type VARCHAR(16) NOT NULL, -- COMMENT/USER/DRAMA target_type VARCHAR(16) NOT NULL, -- COMMENT/USER/DRAMA
target_id BIGINT NOT NULL, target_id BIGINT NOT NULL,
@@ -383,7 +383,7 @@ CREATE INDEX IF NOT EXISTS idx_report_status ON content_report (status, created_
COMMENT ON TABLE content_report IS '举报工单'; COMMENT ON TABLE content_report IS '举报工单';
CREATE TABLE IF NOT EXISTS user_blocklist ( CREATE TABLE IF NOT EXISTS user_blocklist (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
blocked_user_id BIGINT NOT NULL, blocked_user_id BIGINT NOT NULL,
source VARCHAR(16) NOT NULL DEFAULT 'MANUAL', -- MANUAL/COMMENT source VARCHAR(16) NOT NULL DEFAULT 'MANUAL', -- MANUAL/COMMENT
@@ -394,7 +394,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_user_blocklist ON user_blocklist (user_id,
COMMENT ON TABLE user_blocklist IS '拉黑关系'; COMMENT ON TABLE user_blocklist IS '拉黑关系';
CREATE TABLE IF NOT EXISTS sensitive_word ( CREATE TABLE IF NOT EXISTS sensitive_word (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
word VARCHAR(64) NOT NULL, word VARCHAR(64) NOT NULL,
category VARCHAR(32) NOT NULL DEFAULT '', -- POLITICS/PORN/ABUSE/AD/RELIGION category VARCHAR(32) NOT NULL DEFAULT '', -- POLITICS/PORN/ABUSE/AD/RELIGION
level VARCHAR(16) NOT NULL DEFAULT 'REVIEW', -- BLOCK/REVIEW/REPLACE level VARCHAR(16) NOT NULL DEFAULT 'REVIEW', -- BLOCK/REVIEW/REPLACE
@@ -409,7 +409,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_sensitive_word ON sensitive_word (language,
COMMENT ON TABLE sensitive_word IS '敏感词库'; COMMENT ON TABLE sensitive_word IS '敏感词库';
CREATE TABLE IF NOT EXISTS content_audit_record ( CREATE TABLE IF NOT EXISTS content_audit_record (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
biz_type VARCHAR(16) NOT NULL, -- COMMENT/DRAMA/AVATAR/CREATOR biz_type VARCHAR(16) NOT NULL, -- COMMENT/DRAMA/AVATAR/CREATOR
biz_id BIGINT NOT NULL, biz_id BIGINT NOT NULL,
round INT NOT NULL DEFAULT 1, round INT NOT NULL DEFAULT 1,
@@ -427,7 +427,7 @@ CREATE INDEX IF NOT EXISTS idx_audit_biz ON content_audit_record (biz_type, biz_
COMMENT ON TABLE content_audit_record IS '审核留证(机审 + 人审都记)'; COMMENT ON TABLE content_audit_record IS '审核留证(机审 + 人审都记)';
CREATE TABLE IF NOT EXISTS content_share ( CREATE TABLE IF NOT EXISTS content_share (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL DEFAULT 0, user_id BIGINT NOT NULL DEFAULT 0,
drama_id BIGINT NOT NULL, drama_id BIGINT NOT NULL,
episode_id BIGINT NOT NULL DEFAULT 0, episode_id BIGINT NOT NULL DEFAULT 0,
@@ -442,7 +442,7 @@ COMMENT ON TABLE content_share IS '分享事件,用于渠道效果与裂变分
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS wallet ( CREATE TABLE IF NOT EXISTS wallet (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
coin_balance BIGINT NOT NULL DEFAULT 0, coin_balance BIGINT NOT NULL DEFAULT 0,
total_recharged_coin BIGINT NOT NULL DEFAULT 0, total_recharged_coin BIGINT NOT NULL DEFAULT 0,
@@ -457,7 +457,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_wallet_user ON wallet (user_id);
COMMENT ON TABLE wallet IS '金币钱包;所有变动必须同时写 coin_ledger'; COMMENT ON TABLE wallet IS '金币钱包;所有变动必须同时写 coin_ledger';
CREATE TABLE IF NOT EXISTS product ( CREATE TABLE IF NOT EXISTS product (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
code VARCHAR(64) NOT NULL, code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL, name VARCHAR(128) NOT NULL,
type VARCHAR(16) NOT NULL DEFAULT 'COIN_PACK', -- COIN_PACK/VIP/EPISODE_PACK(VIP 为第二阶段预留) type VARCHAR(16) NOT NULL DEFAULT 'COIN_PACK', -- COIN_PACK/VIP/EPISODE_PACK(VIP 为第二阶段预留)
@@ -483,7 +483,7 @@ CREATE INDEX IF NOT EXISTS idx_product_platform ON product (platform, status);
COMMENT ON TABLE product IS '商品配置(金币包;会员与活动价为后续阶段预留)'; COMMENT ON TABLE product IS '商品配置(金币包;会员与活动价为后续阶段预留)';
CREATE TABLE IF NOT EXISTS orders ( CREATE TABLE IF NOT EXISTS orders (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
order_no VARCHAR(40) NOT NULL, order_no VARCHAR(40) NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
product_id BIGINT NOT NULL DEFAULT 0, product_id BIGINT NOT NULL DEFAULT 0,
@@ -522,7 +522,7 @@ COMMENT ON TABLE orders IS '订单;orders 为保留字规避(order 是 SQL
-- 订单状态流转流水,同样按月分区(同 playback_ticket,主键带分区键) -- 订单状态流转流水,同样按月分区(同 playback_ticket,主键带分区键)
CREATE TABLE IF NOT EXISTS order_event ( CREATE TABLE IF NOT EXISTS order_event (
id BIGINT GENERATED BY DEFAULT AS IDENTITY, id BIGINT,
order_no VARCHAR(40) NOT NULL, order_no VARCHAR(40) NOT NULL,
event VARCHAR(32) NOT NULL, -- CREATE/VERIFY_OK/VERIFY_FAIL/DELIVER/REFUND/NOTIFY event VARCHAR(32) NOT NULL, -- CREATE/VERIFY_OK/VERIFY_FAIL/DELIVER/REFUND/NOTIFY
from_status VARCHAR(16) NOT NULL DEFAULT '', from_status VARCHAR(16) NOT NULL DEFAULT '',
@@ -537,7 +537,7 @@ CREATE INDEX IF NOT EXISTS idx_order_event_no ON order_event (order_no, created_
COMMENT ON TABLE order_event IS '订单状态流转流水,支付争议时的证据链(月分区)'; COMMENT ON TABLE order_event IS '订单状态流转流水,支付争议时的证据链(月分区)';
CREATE TABLE IF NOT EXISTS store_receipt ( CREATE TABLE IF NOT EXISTS store_receipt (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
platform VARCHAR(16) NOT NULL, platform VARCHAR(16) NOT NULL,
transaction_id VARCHAR(128) NOT NULL, transaction_id VARCHAR(128) NOT NULL,
original_transaction_id VARCHAR(128) NOT NULL DEFAULT '', original_transaction_id VARCHAR(128) NOT NULL DEFAULT '',
@@ -557,7 +557,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_store_receipt ON store_receipt (platform, t
COMMENT ON TABLE store_receipt IS '商店票据去重与校验留证(重放防护)'; COMMENT ON TABLE store_receipt IS '商店票据去重与校验留证(重放防护)';
CREATE TABLE IF NOT EXISTS coin_ledger ( CREATE TABLE IF NOT EXISTS coin_ledger (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
change_amount BIGINT NOT NULL, -- 正数入账,负数出账 change_amount BIGINT NOT NULL, -- 正数入账,负数出账
balance_after BIGINT NOT NULL, balance_after BIGINT NOT NULL,
@@ -573,7 +573,7 @@ CREATE INDEX IF NOT EXISTS idx_coin_ledger_user ON coin_ledger (user_id, created
COMMENT ON TABLE coin_ledger IS '金币流水,钱包余额的唯一事实来源'; COMMENT ON TABLE coin_ledger IS '金币流水,钱包余额的唯一事实来源';
CREATE TABLE IF NOT EXISTS entitlement ( CREATE TABLE IF NOT EXISTS entitlement (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
drama_id BIGINT NOT NULL DEFAULT 0, drama_id BIGINT NOT NULL DEFAULT 0,
episode_id BIGINT NOT NULL DEFAULT 0, -- 0 表示整剧权益 episode_id BIGINT NOT NULL DEFAULT 0, -- 0 表示整剧权益
@@ -593,7 +593,7 @@ CREATE INDEX IF NOT EXISTS idx_entitlement_user ON entitlement (user_id, status)
COMMENT ON TABLE entitlement IS '权益;播放判定优先读这里'; COMMENT ON TABLE entitlement IS '权益;播放判定优先读这里';
CREATE TABLE IF NOT EXISTS refund_record ( CREATE TABLE IF NOT EXISTS refund_record (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
order_no VARCHAR(40) NOT NULL, order_no VARCHAR(40) NOT NULL,
platform VARCHAR(16) NOT NULL DEFAULT '', platform VARCHAR(16) NOT NULL DEFAULT '',
refund_id VARCHAR(128) NOT NULL DEFAULT '', refund_id VARCHAR(128) NOT NULL DEFAULT '',
@@ -613,7 +613,7 @@ CREATE INDEX IF NOT EXISTS idx_refund_status ON refund_record (status, created_a
COMMENT ON TABLE refund_record IS '退款与撤销事件,触发权益回收'; COMMENT ON TABLE refund_record IS '退款与撤销事件,触发权益回收';
CREATE TABLE IF NOT EXISTS reconcile_task ( CREATE TABLE IF NOT EXISTS reconcile_task (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
biz_date DATE NOT NULL, biz_date DATE NOT NULL,
platform VARCHAR(16) NOT NULL DEFAULT 'ALL', platform VARCHAR(16) NOT NULL DEFAULT 'ALL',
total_count INT NOT NULL DEFAULT 0, total_count INT NOT NULL DEFAULT 0,
@@ -635,7 +635,7 @@ COMMENT ON TABLE reconcile_task IS '每日对账任务(与商店账单比对
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS creator ( CREATE TABLE IF NOT EXISTS creator (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL, -- 关联 C 端账号 user_id BIGINT NOT NULL, -- 关联 C 端账号
code VARCHAR(32) NOT NULL, code VARCHAR(32) NOT NULL,
name VARCHAR(128) NOT NULL, name VARCHAR(128) NOT NULL,
@@ -663,7 +663,7 @@ CREATE INDEX IF NOT EXISTS idx_creator_status ON creator (status, created_at DES
COMMENT ON TABLE creator IS '创作者主体与资质'; COMMENT ON TABLE creator IS '创作者主体与资质';
CREATE TABLE IF NOT EXISTS creator_document ( CREATE TABLE IF NOT EXISTS creator_document (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
creator_id BIGINT NOT NULL, creator_id BIGINT NOT NULL,
type VARCHAR(32) NOT NULL, -- ID_CARD/BUSINESS_LICENSE/CONTRACT type VARCHAR(32) NOT NULL, -- ID_CARD/BUSINESS_LICENSE/CONTRACT
file_url VARCHAR(512) NOT NULL, file_url VARCHAR(512) NOT NULL,
@@ -678,7 +678,7 @@ CREATE INDEX IF NOT EXISTS idx_creator_doc ON creator_document (creator_id, type
COMMENT ON TABLE creator_document IS '创作者资质文件'; COMMENT ON TABLE creator_document IS '创作者资质文件';
CREATE TABLE IF NOT EXISTS content_submission ( CREATE TABLE IF NOT EXISTS content_submission (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
creator_id BIGINT NOT NULL, creator_id BIGINT NOT NULL,
drama_id BIGINT NOT NULL, drama_id BIGINT NOT NULL,
type VARCHAR(16) NOT NULL DEFAULT 'CREATE', -- CREATE/UPDATE/PUBLISH/OFFLINE type VARCHAR(16) NOT NULL DEFAULT 'CREATE', -- CREATE/UPDATE/PUBLISH/OFFLINE
@@ -698,7 +698,7 @@ CREATE INDEX IF NOT EXISTS idx_submission_status ON content_submission (status,
COMMENT ON TABLE content_submission IS '创作者内容提交与审核流转'; COMMENT ON TABLE content_submission IS '创作者内容提交与审核流转';
CREATE TABLE IF NOT EXISTS creator_stat_daily ( CREATE TABLE IF NOT EXISTS creator_stat_daily (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
creator_id BIGINT NOT NULL, creator_id BIGINT NOT NULL,
drama_id BIGINT NOT NULL DEFAULT 0, drama_id BIGINT NOT NULL DEFAULT 0,
stat_date DATE NOT NULL, stat_date DATE NOT NULL,
@@ -715,7 +715,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_creator_stat ON creator_stat_daily (creator
COMMENT ON TABLE creator_stat_daily IS '创作者数据看板日汇总'; COMMENT ON TABLE creator_stat_daily IS '创作者数据看板日汇总';
CREATE TABLE IF NOT EXISTS creator_settlement ( CREATE TABLE IF NOT EXISTS creator_settlement (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
creator_id BIGINT NOT NULL, creator_id BIGINT NOT NULL,
period VARCHAR(16) NOT NULL, -- 2026-10 period VARCHAR(16) NOT NULL, -- 2026-10
revenue BIGINT NOT NULL DEFAULT 0, revenue BIGINT NOT NULL DEFAULT 0,
@@ -737,7 +737,7 @@ COMMENT ON TABLE creator_settlement IS '预留:创作者结算(第二阶段
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS transcode_task ( CREATE TABLE IF NOT EXISTS transcode_task (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
media_asset_id BIGINT NOT NULL DEFAULT 0, media_asset_id BIGINT NOT NULL DEFAULT 0,
drama_id BIGINT NOT NULL DEFAULT 0, drama_id BIGINT NOT NULL DEFAULT 0,
episode_id BIGINT NOT NULL DEFAULT 0, episode_id BIGINT NOT NULL DEFAULT 0,
@@ -758,7 +758,7 @@ CREATE INDEX IF NOT EXISTS idx_transcode_episode ON transcode_task (episode_id);
COMMENT ON TABLE transcode_task IS '转码任务队列(本地 worker / 云转码)'; COMMENT ON TABLE transcode_task IS '转码任务队列(本地 worker / 云转码)';
CREATE TABLE IF NOT EXISTS media_rendition ( CREATE TABLE IF NOT EXISTS media_rendition (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
media_asset_id BIGINT NOT NULL DEFAULT 0, media_asset_id BIGINT NOT NULL DEFAULT 0,
drama_id BIGINT NOT NULL DEFAULT 0, drama_id BIGINT NOT NULL DEFAULT 0,
episode_id BIGINT NOT NULL DEFAULT 0, episode_id BIGINT NOT NULL DEFAULT 0,
@@ -787,7 +787,7 @@ COMMENT ON TABLE media_rendition IS '多码率产物;播放票据按此生成
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS ops_notice ( CREATE TABLE IF NOT EXISTS ops_notice (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
title VARCHAR(128) NOT NULL, title VARCHAR(128) NOT NULL,
content VARCHAR(2000) NOT NULL DEFAULT '', content VARCHAR(2000) NOT NULL DEFAULT '',
target VARCHAR(16) NOT NULL DEFAULT 'ALL', -- ALL/USER/CREATOR target VARCHAR(16) NOT NULL DEFAULT 'ALL', -- ALL/USER/CREATOR
@@ -804,7 +804,7 @@ CREATE INDEX IF NOT EXISTS idx_notice_target ON ops_notice (target, status, publ
COMMENT ON TABLE ops_notice IS '系统公告(创作者审核结果、活动、支付通知)'; COMMENT ON TABLE ops_notice IS '系统公告(创作者审核结果、活动、支付通知)';
CREATE TABLE IF NOT EXISTS ops_daily_stat ( CREATE TABLE IF NOT EXISTS ops_daily_stat (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
stat_date DATE NOT NULL, stat_date DATE NOT NULL,
new_user_count BIGINT NOT NULL DEFAULT 0, new_user_count BIGINT NOT NULL DEFAULT 0,
active_user_count BIGINT NOT NULL DEFAULT 0, active_user_count BIGINT NOT NULL DEFAULT 0,
@@ -824,7 +824,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_ops_daily_stat ON ops_daily_stat (stat_date
COMMENT ON TABLE ops_daily_stat IS '经营大盘日汇总(含成本口径)'; COMMENT ON TABLE ops_daily_stat IS '经营大盘日汇总(含成本口径)';
CREATE TABLE IF NOT EXISTS audit_log ( CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
biz_type VARCHAR(32) NOT NULL, -- ORDER/ENTITLEMENT/USER/CREATOR/PRICE biz_type VARCHAR(32) NOT NULL, -- ORDER/ENTITLEMENT/USER/CREATOR/PRICE
biz_id VARCHAR(64) NOT NULL DEFAULT '', biz_id VARCHAR(64) NOT NULL DEFAULT '',
action VARCHAR(32) NOT NULL, action VARCHAR(32) NOT NULL,
@@ -878,7 +878,7 @@ ALTER TABLE app_user ADD COLUMN IF NOT EXISTS download_max_quality VARCHAR(16) N
-- 下载任务与断点续传进度。分片由客户端直接向 CDN 拉取,服务端只记账: -- 下载任务与断点续传进度。分片由客户端直接向 CDN 拉取,服务端只记账:
-- 用它实现"下载中/已暂停/已完成"的跨端展示、断点续传的进度恢复,以及配额统计。 -- 用它实现"下载中/已暂停/已完成"的跨端展示、断点续传的进度恢复,以及配额统计。
CREATE TABLE IF NOT EXISTS download_task ( CREATE TABLE IF NOT EXISTS download_task (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
device_id VARCHAR(128) NOT NULL DEFAULT '', device_id VARCHAR(128) NOT NULL DEFAULT '',
drama_id BIGINT NOT NULL, drama_id BIGINT NOT NULL,
+18 -18
View File
@@ -1,7 +1,7 @@
-- FruitShorts 短剧服务 · 建表脚本(PostgreSQL 18) -- FruitShorts 短剧服务 · 建表脚本(PostgreSQL 18)
-- --
-- 与 MySQL 版本的对应关系: -- 与 MySQL 版本的对应关系:
-- AUTO_INCREMENT -> GENERATED BY DEFAULT AS IDENTITY -- AUTO_INCREMENT -> 无(主键由应用侧雪花算法生成:MyBatis-Plus IdType.ASSIGN_ID)
-- TINYINT -> SMALLINT -- TINYINT -> SMALLINT
-- DATETIME(3) -> TIMESTAMP(3) -- DATETIME(3) -> TIMESTAMP(3)
-- ON UPDATE CURRENT_TIMESTAMP -> set_updated_at() 触发器 -- ON UPDATE CURRENT_TIMESTAMP -> set_updated_at() 触发器
@@ -11,7 +11,7 @@
-- ========== 系统域 ========== -- ========== 系统域 ==========
CREATE TABLE IF NOT EXISTS sys_admin_user ( CREATE TABLE IF NOT EXISTS sys_admin_user (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
username VARCHAR(64) NOT NULL, -- 登录名 username VARCHAR(64) NOT NULL, -- 登录名
password VARCHAR(100) NOT NULL, -- BCrypt 哈希 password VARCHAR(100) NOT NULL, -- BCrypt 哈希
real_name VARCHAR(64) NOT NULL DEFAULT '', real_name VARCHAR(64) NOT NULL DEFAULT '',
@@ -31,7 +31,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_admin_user_username ON sys_admin_user (
COMMENT ON TABLE sys_admin_user IS '管理员'; COMMENT ON TABLE sys_admin_user IS '管理员';
CREATE TABLE IF NOT EXISTS sys_role_category ( CREATE TABLE IF NOT EXISTS sys_role_category (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
name VARCHAR(64) NOT NULL, name VARCHAR(64) NOT NULL,
description VARCHAR(255) NOT NULL DEFAULT '', description VARCHAR(255) NOT NULL DEFAULT '',
sort INT NOT NULL DEFAULT 0, sort INT NOT NULL DEFAULT 0,
@@ -43,7 +43,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_role_category_name ON sys_role_category
COMMENT ON TABLE sys_role_category IS '角色分类'; COMMENT ON TABLE sys_role_category IS '角色分类';
CREATE TABLE IF NOT EXISTS sys_role ( CREATE TABLE IF NOT EXISTS sys_role (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
name VARCHAR(64) NOT NULL, name VARCHAR(64) NOT NULL,
code VARCHAR(64) NOT NULL, -- 如 super_admin code VARCHAR(64) NOT NULL, -- 如 super_admin
category_id BIGINT NOT NULL DEFAULT 0, category_id BIGINT NOT NULL DEFAULT 0,
@@ -59,7 +59,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_role_code ON sys_role (code);
COMMENT ON TABLE sys_role IS '角色'; COMMENT ON TABLE sys_role IS '角色';
CREATE TABLE IF NOT EXISTS sys_admin_user_role ( CREATE TABLE IF NOT EXISTS sys_admin_user_role (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL role_id BIGINT NOT NULL
); );
@@ -68,7 +68,7 @@ CREATE INDEX IF NOT EXISTS idx_sys_admin_user_role_role ON sys_admin_user_role (
COMMENT ON TABLE sys_admin_user_role IS '管理员-角色'; COMMENT ON TABLE sys_admin_user_role IS '管理员-角色';
CREATE TABLE IF NOT EXISTS sys_permission ( CREATE TABLE IF NOT EXISTS sys_permission (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
code VARCHAR(128) NOT NULL, -- 如 drama:create code VARCHAR(128) NOT NULL, -- 如 drama:create
name VARCHAR(64) NOT NULL, name VARCHAR(64) NOT NULL,
group_name VARCHAR(64) NOT NULL DEFAULT '', group_name VARCHAR(64) NOT NULL DEFAULT '',
@@ -79,7 +79,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_permission_code ON sys_permission (code
COMMENT ON TABLE sys_permission IS '权限点'; COMMENT ON TABLE sys_permission IS '权限点';
CREATE TABLE IF NOT EXISTS sys_menu ( CREATE TABLE IF NOT EXISTS sys_menu (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
parent_id BIGINT NOT NULL DEFAULT 0, parent_id BIGINT NOT NULL DEFAULT 0,
title VARCHAR(64) NOT NULL, title VARCHAR(64) NOT NULL,
route_path VARCHAR(128) NOT NULL DEFAULT '', route_path VARCHAR(128) NOT NULL DEFAULT '',
@@ -101,7 +101,7 @@ CREATE INDEX IF NOT EXISTS idx_sys_menu_parent ON sys_menu (parent_id);
COMMENT ON TABLE sys_menu IS '菜单'; COMMENT ON TABLE sys_menu IS '菜单';
CREATE TABLE IF NOT EXISTS sys_role_permission ( CREATE TABLE IF NOT EXISTS sys_role_permission (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
role_id BIGINT NOT NULL, role_id BIGINT NOT NULL,
permission_id BIGINT NOT NULL permission_id BIGINT NOT NULL
); );
@@ -109,7 +109,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_role_permission ON sys_role_permission
COMMENT ON TABLE sys_role_permission IS '角色-权限'; COMMENT ON TABLE sys_role_permission IS '角色-权限';
CREATE TABLE IF NOT EXISTS sys_role_menu ( CREATE TABLE IF NOT EXISTS sys_role_menu (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
role_id BIGINT NOT NULL, role_id BIGINT NOT NULL,
menu_id BIGINT NOT NULL menu_id BIGINT NOT NULL
); );
@@ -117,7 +117,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_role_menu ON sys_role_menu (role_id, me
COMMENT ON TABLE sys_role_menu IS '角色-菜单'; COMMENT ON TABLE sys_role_menu IS '角色-菜单';
CREATE TABLE IF NOT EXISTS sys_config ( CREATE TABLE IF NOT EXISTS sys_config (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
config_key VARCHAR(128) NOT NULL, config_key VARCHAR(128) NOT NULL,
config_value VARCHAR(1024) NOT NULL DEFAULT '', config_value VARCHAR(1024) NOT NULL DEFAULT '',
remark VARCHAR(255) NOT NULL DEFAULT '' remark VARCHAR(255) NOT NULL DEFAULT ''
@@ -126,7 +126,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_config_key ON sys_config (config_key);
COMMENT ON TABLE sys_config IS '系统配置'; COMMENT ON TABLE sys_config IS '系统配置';
CREATE TABLE IF NOT EXISTS sys_operation_log ( CREATE TABLE IF NOT EXISTS sys_operation_log (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
admin_user_id BIGINT NOT NULL DEFAULT 0, admin_user_id BIGINT NOT NULL DEFAULT 0,
username VARCHAR(64) NOT NULL DEFAULT '', username VARCHAR(64) NOT NULL DEFAULT '',
module VARCHAR(64) NOT NULL DEFAULT '', module VARCHAR(64) NOT NULL DEFAULT '',
@@ -148,7 +148,7 @@ COMMENT ON TABLE sys_operation_log IS '操作日志';
-- ========== 内容域 ========== -- ========== 内容域 ==========
CREATE TABLE IF NOT EXISTS drama_category ( CREATE TABLE IF NOT EXISTS drama_category (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
name VARCHAR(64) NOT NULL, name VARCHAR(64) NOT NULL,
sort INT NOT NULL DEFAULT 0, sort INT NOT NULL DEFAULT 0,
status SMALLINT NOT NULL DEFAULT 1 status SMALLINT NOT NULL DEFAULT 1
@@ -157,7 +157,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_drama_category_name ON drama_category (name
COMMENT ON TABLE drama_category IS '短剧类型'; COMMENT ON TABLE drama_category IS '短剧类型';
CREATE TABLE IF NOT EXISTS drama ( CREATE TABLE IF NOT EXISTS drama (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
code VARCHAR(64) NOT NULL DEFAULT '', code VARCHAR(64) NOT NULL DEFAULT '',
title VARCHAR(128) NOT NULL, title VARCHAR(128) NOT NULL,
subtitle VARCHAR(128) NOT NULL DEFAULT '', subtitle VARCHAR(128) NOT NULL DEFAULT '',
@@ -194,7 +194,7 @@ CREATE INDEX IF NOT EXISTS idx_drama_status_category ON drama (status, category_
COMMENT ON TABLE drama IS '短剧'; COMMENT ON TABLE drama IS '短剧';
CREATE TABLE IF NOT EXISTS drama_episode ( CREATE TABLE IF NOT EXISTS drama_episode (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
drama_id BIGINT NOT NULL, drama_id BIGINT NOT NULL,
ep_no INT NOT NULL, ep_no INT NOT NULL,
title VARCHAR(128) NOT NULL DEFAULT '', title VARCHAR(128) NOT NULL DEFAULT '',
@@ -217,7 +217,7 @@ CREATE INDEX IF NOT EXISTS idx_drama_episode_status ON drama_episode (drama_id,
COMMENT ON TABLE drama_episode IS '剧集'; COMMENT ON TABLE drama_episode IS '剧集';
CREATE TABLE IF NOT EXISTS drama_review_record ( CREATE TABLE IF NOT EXISTS drama_review_record (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
biz_type VARCHAR(16) NOT NULL, -- DRAMA/EPISODE biz_type VARCHAR(16) NOT NULL, -- DRAMA/EPISODE
biz_id BIGINT NOT NULL, biz_id BIGINT NOT NULL,
action VARCHAR(16) NOT NULL, -- SUBMIT/APPROVE/REJECT/PUBLISH/OFFLINE action VARCHAR(16) NOT NULL, -- SUBMIT/APPROVE/REJECT/PUBLISH/OFFLINE
@@ -230,7 +230,7 @@ CREATE INDEX IF NOT EXISTS idx_drama_review_biz ON drama_review_record (biz_type
COMMENT ON TABLE drama_review_record IS '审核记录'; COMMENT ON TABLE drama_review_record IS '审核记录';
CREATE TABLE IF NOT EXISTS content_banner ( CREATE TABLE IF NOT EXISTS content_banner (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
image_url VARCHAR(512) NOT NULL DEFAULT '', image_url VARCHAR(512) NOT NULL DEFAULT '',
slot VARCHAR(32) NOT NULL DEFAULT 'HOME_TOP', -- 展示位置:HOME_TOP/DISCOVER slot VARCHAR(32) NOT NULL DEFAULT 'HOME_TOP', -- 展示位置:HOME_TOP/DISCOVER
link_type VARCHAR(16) NOT NULL DEFAULT 'NONE', -- NONE/DRAMA/URL link_type VARCHAR(16) NOT NULL DEFAULT 'NONE', -- NONE/DRAMA/URL
@@ -249,7 +249,7 @@ COMMENT ON TABLE content_banner IS '轮播图';
-- 本地存储版本:文件落在 fruitshorts.media.root-dir 下,通过 /files/** 访问 -- 本地存储版本:文件落在 fruitshorts.media.root-dir 下,通过 /files/** 访问
CREATE TABLE IF NOT EXISTS media_asset ( CREATE TABLE IF NOT EXISTS media_asset (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
biz_type VARCHAR(32) NOT NULL DEFAULT 'EPISODE', biz_type VARCHAR(32) NOT NULL DEFAULT 'EPISODE',
file_name VARCHAR(255) NOT NULL DEFAULT '', file_name VARCHAR(255) NOT NULL DEFAULT '',
object_key VARCHAR(512) NOT NULL DEFAULT '', object_key VARCHAR(512) NOT NULL DEFAULT '',
@@ -274,7 +274,7 @@ CREATE INDEX IF NOT EXISTS idx_media_asset_biz_type ON media_asset (biz_type);
COMMENT ON TABLE media_asset IS '媒资'; COMMENT ON TABLE media_asset IS '媒资';
CREATE TABLE IF NOT EXISTS media_upload_task ( CREATE TABLE IF NOT EXISTS media_upload_task (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id BIGINT PRIMARY KEY,
upload_id VARCHAR(64) NOT NULL, upload_id VARCHAR(64) NOT NULL,
biz_type VARCHAR(32) NOT NULL DEFAULT 'EPISODE', biz_type VARCHAR(32) NOT NULL DEFAULT 'EPISODE',
file_name VARCHAR(255) NOT NULL DEFAULT '', file_name VARCHAR(255) NOT NULL DEFAULT '',