Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fafadfc6ec | ||
|
|
84aa0e17dd | ||
|
|
37c9a141d3 | ||
|
|
6833e01e1f |
No files matched your search
@@ -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
|
||||
|
||||
@@ -26,10 +26,15 @@ mybatis-plus:
|
||||
global-config:
|
||||
banner: false
|
||||
db-config:
|
||||
id-type: auto
|
||||
# 主键由应用侧雪花算法生成(S0),数据库不再自增
|
||||
id-type: assign_id
|
||||
logic-delete-field: deleted
|
||||
logic-delete-value: 1
|
||||
logic-not-delete-value: 0
|
||||
sequence:
|
||||
# 单机开发用固定值;上多实例前必须改成按实例分配(环境变量注入),否则会撞 ID
|
||||
worker-id: 1
|
||||
datacenter-id: 1
|
||||
|
||||
fruitshorts:
|
||||
media:
|
||||
|
||||
@@ -39,6 +39,12 @@
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-module-commerce</artifactId>
|
||||
</dependency>
|
||||
<!-- 契约测试用:MockMvc + Mockito + JUnit5,test scope,不进运行态依赖 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -21,10 +21,15 @@ mybatis-plus:
|
||||
global-config:
|
||||
banner: false
|
||||
db-config:
|
||||
id-type: auto
|
||||
# 主键由应用侧雪花算法生成(S0),数据库不再自增
|
||||
id-type: assign_id
|
||||
logic-delete-field: deleted
|
||||
logic-delete-value: 1
|
||||
logic-not-delete-value: 0
|
||||
sequence:
|
||||
# 单机开发用固定值;上多实例前必须改成按实例分配(环境变量注入),否则会撞 ID
|
||||
worker-id: 1
|
||||
datacenter-id: 1
|
||||
|
||||
fruitshorts:
|
||||
interaction:
|
||||
|
||||
+140
@@ -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;
|
||||
}
|
||||
}
|
||||
+114
@@ -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()));
|
||||
}
|
||||
}
|
||||
+96
@@ -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;
|
||||
}
|
||||
}
|
||||
+320
@@ -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;
|
||||
}
|
||||
}
|
||||
+146
@@ -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;
|
||||
}
|
||||
}
|
||||
+33
@@ -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();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -12,7 +12,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("coin_ledger")
|
||||
public class CoinLedger {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
/** 正数入账、负数出账 */
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("entitlement")
|
||||
public class Entitlement {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Long dramaId;
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("order_event")
|
||||
public class OrderEvent {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String orderNo;
|
||||
private String event;
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("orders")
|
||||
public class Orders {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String orderNo;
|
||||
private Long userId;
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("product")
|
||||
public class Product {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String code;
|
||||
private String name;
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("reconcile_task")
|
||||
public class ReconcileTask {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private LocalDate bizDate;
|
||||
private String platform;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("store_receipt")
|
||||
public class StoreReceipt {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String platform;
|
||||
private String transactionId;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("wallet")
|
||||
public class Wallet {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Long coinBalance;
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("content_banner")
|
||||
public class ContentBanner {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String imageUrl;
|
||||
/** 展示位置,列名为 slot(position 在 PostgreSQL 里是关键字) */
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("drama")
|
||||
public class Drama {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String code;
|
||||
private String title;
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import lombok.Data;
|
||||
@TableName("drama_category")
|
||||
public class DramaCategory {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String name;
|
||||
private Integer sort;
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("drama_episode")
|
||||
public class DramaEpisode {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long dramaId;
|
||||
private Integer epNo;
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("drama_review_record")
|
||||
public class DramaReviewRecord {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String bizType;
|
||||
private Long bizId;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("content_submission")
|
||||
public class ContentSubmission {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long creatorId;
|
||||
private Long dramaId;
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("creator")
|
||||
public class Creator {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
/** 关联 C 端账号 app_user.id */
|
||||
private Long userId;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("creator_document")
|
||||
public class CreatorDocument {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long creatorId;
|
||||
/** ID_CARD / BUSINESS_LICENSE / CONTRACT */
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("content_audit_record")
|
||||
public class ContentAuditRecord {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
/** COMMENT/DRAMA/AVATAR/CREATOR */
|
||||
private String bizType;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("content_comment")
|
||||
public class ContentComment {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long dramaId;
|
||||
private Long episodeId;
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("content_like")
|
||||
public class ContentLike {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
/** DRAMA/EPISODE/COMMENT */
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("content_report")
|
||||
public class ContentReport {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long reporterId;
|
||||
/** COMMENT/USER/DRAMA */
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("content_share")
|
||||
public class ContentShare {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Long dramaId;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("sensitive_word")
|
||||
public class SensitiveWord {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String word;
|
||||
/** POLITICS/PORN/ABUSE/AD/RELIGION */
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("user_blocklist")
|
||||
public class UserBlocklist {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Long blockedUserId;
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("media_asset")
|
||||
public class MediaAsset {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String bizType;
|
||||
private String fileName;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("media_rendition")
|
||||
public class MediaRendition {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long mediaAssetId;
|
||||
private Long dramaId;
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("media_upload_task")
|
||||
public class MediaUploadTask {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String uploadId;
|
||||
private String bizType;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("transcode_task")
|
||||
public class TranscodeTask {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long mediaAssetId;
|
||||
private Long dramaId;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("download_license")
|
||||
public class DownloadLicense {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Long dramaId;
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("download_task")
|
||||
public class DownloadTask {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String deviceId;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("playback_ticket")
|
||||
public class PlaybackTicket {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String ticketNo;
|
||||
private Long userId;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("user_favorite")
|
||||
public class UserFavorite {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Long dramaId;
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("watch_history")
|
||||
public class WatchHistory {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Long dramaId;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("watch_progress")
|
||||
public class WatchProgress {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Long dramaId;
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("sys_admin_user")
|
||||
public class AdminUser {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String username;
|
||||
private String password;
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import lombok.NoArgsConstructor;
|
||||
@TableName("sys_admin_user_role")
|
||||
public class AdminUserRole {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Long roleId;
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("audit_log")
|
||||
public class AuditLog {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
/** ORDER / ENTITLEMENT / USER / CREATOR / PRICE */
|
||||
private String bizType;
|
||||
|
||||
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("sys_menu")
|
||||
public class Menu {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long parentId;
|
||||
private String title;
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("sys_operation_log")
|
||||
public class OperationLog {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long adminUserId;
|
||||
private String username;
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import lombok.Data;
|
||||
@TableName("sys_permission")
|
||||
public class Permission {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String code;
|
||||
private String name;
|
||||
|
||||
@@ -14,7 +14,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("sys_role")
|
||||
public class Role {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String name;
|
||||
private String code;
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("sys_role_category")
|
||||
public class RoleCategory {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String name;
|
||||
private String description;
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import lombok.NoArgsConstructor;
|
||||
@TableName("sys_role_menu")
|
||||
public class RoleMenu {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long roleId;
|
||||
private Long menuId;
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import lombok.NoArgsConstructor;
|
||||
@TableName("sys_role_permission")
|
||||
public class RolePermission {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long roleId;
|
||||
private Long permissionId;
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import lombok.Data;
|
||||
@TableName("sys_config")
|
||||
public class SysConfig {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String configKey;
|
||||
private String configValue;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("app_account_deletion")
|
||||
public class AppAccountDeletion {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
/** pending/cancelled/completed */
|
||||
|
||||
@@ -16,7 +16,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("app_user")
|
||||
public class AppUser {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
/** 对外用户标识,接口只暴露 uid,不暴露自增 ID */
|
||||
private String uid;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("app_user_ban_record")
|
||||
public class AppUserBanRecord {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
/** BAN/LIMIT/MUTE */
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("app_user_consent")
|
||||
public class AppUserConsent {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String scene;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("app_user_credential")
|
||||
public class AppUserCredential {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
/** PHONE/EMAIL/APPLE/GOOGLE */
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("app_user_device")
|
||||
public class AppUserDevice {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String deviceId;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("app_user_otp")
|
||||
public class AppUserOtp {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String scene;
|
||||
private String targetType;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
|
||||
@TableName("app_user_session")
|
||||
public class AppUserSession {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String refreshTokenHash;
|
||||
|
||||
+83
-5
@@ -15,6 +15,7 @@ import com.fruitshorts.user.dal.mapper.AppUserDeviceMapper;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserMapper;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserSessionMapper;
|
||||
import com.fruitshorts.user.enums.AppUserStatus;
|
||||
import com.fruitshorts.user.enums.CredentialType;
|
||||
import com.fruitshorts.user.enums.OtpScene;
|
||||
import com.fruitshorts.user.vo.AppUserRespVO;
|
||||
import com.fruitshorts.user.vo.LoginReqVO;
|
||||
@@ -30,6 +31,7 @@ import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* C 端账号:注册、登录、令牌轮换、登出。
|
||||
@@ -59,7 +61,21 @@ public class AppAuthService {
|
||||
public TokenRespVO register(RegisterReqVO req, String clientIp) {
|
||||
String targetType = AppIdentifier.normalizeType(req.getTargetType());
|
||||
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) {
|
||||
throw new ServiceException(ErrorCode.DATA_EXISTS, "该账号已注册,请直接登录");
|
||||
@@ -75,8 +91,10 @@ public class AppAuthService {
|
||||
credential.setPasswordHash(StringUtils.hasText(req.getPassword())
|
||||
? passwordEncoder.encode(req.getPassword())
|
||||
: "");
|
||||
credential.setVerified(1);
|
||||
credential.setVerifiedAt(LocalDateTime.now());
|
||||
// 邮箱免码注册时先记"未验证"(verified=0),等用户日后点验证链接再置 1;登录不要求 verified
|
||||
boolean verified = !emailRegister || StringUtils.hasText(req.getCode());
|
||||
credential.setVerified(verified ? 1 : 0);
|
||||
credential.setVerifiedAt(verified ? LocalDateTime.now() : null);
|
||||
credential.setLastUsedAt(LocalDateTime.now());
|
||||
credential.setFederationId("");
|
||||
credential.setAppId("");
|
||||
@@ -130,8 +148,13 @@ public class AppAuthService {
|
||||
}
|
||||
|
||||
credential.setLastUsedAt(LocalDateTime.now());
|
||||
credential.setVerified(1);
|
||||
credential.setVerifiedAt(LocalDateTime.now());
|
||||
if (byCode) {
|
||||
// 只有「验证码登录」能证明邮箱/手机号确实归用户所有 → 才算已验证。
|
||||
// 密码登录不改验证状态:知道密码不等于能收到这个邮箱的信,否则
|
||||
// "邮箱免码注册先记未验证" 这条设计会被一次登录悄悄抹掉。
|
||||
credential.setVerified(1);
|
||||
credential.setVerifiedAt(LocalDateTime.now());
|
||||
}
|
||||
credentialMapper.updateById(credential);
|
||||
|
||||
user.setLastLoginAt(LocalDateTime.now());
|
||||
@@ -276,6 +299,61 @@ public class AppAuthService {
|
||||
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) {
|
||||
AppUser user = new AppUser();
|
||||
user.setUid(hasher.newUid());
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.fruitshorts.user.enums.CredentialType;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** 手机号/邮箱的规范化与脱敏。 */
|
||||
public final class AppIdentifier {
|
||||
@@ -60,6 +61,23 @@ public final class AppIdentifier {
|
||||
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) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,10 @@ public class RegisterReqVO {
|
||||
@NotBlank(message = "请输入手机号或邮箱")
|
||||
private String target;
|
||||
|
||||
@NotBlank(message = "请输入验证码")
|
||||
/**
|
||||
* 验证码:`targetType=PHONE` 时必填;`targetType=EMAIL` 时可以不传(邮箱+密码直接注册),
|
||||
* 传了就顺手校验一次(邮箱验证能力保留)。必填性在服务端按 targetType 判断。
|
||||
*/
|
||||
private String code;
|
||||
|
||||
/** 可选:同时设置密码,之后可用密码登录 */
|
||||
|
||||
+67
-64
@@ -27,50 +27,53 @@ BEGIN
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES
|
||||
('app-user:list', '查看 App 用户', '用户管理', 'BUTTON', 101),
|
||||
('app-user:update', '处置 App 用户', '用户管理', 'BUTTON', 102)
|
||||
-- 主键说明:S0 之后数据库不再自增,种子行必须显式给主键。
|
||||
-- 本文件新增的权限点统一用「1000 + sort」当 ID(1101-1156 这段保留区间),
|
||||
-- 避开早期 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;
|
||||
|
||||
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES
|
||||
('download:list', '查看下载授权', '用户管理', 'BUTTON', 103),
|
||||
('download:revoke', '吊销下载授权', '用户管理', 'BUTTON', 104)
|
||||
INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
|
||||
(1103, 'download:list', '查看下载授权', '用户管理', 'BUTTON', 103),
|
||||
(1104, 'download:revoke', '吊销下载授权', '用户管理', 'BUTTON', 104)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES
|
||||
('comment:list', '查看评论', '内容治理', 'BUTTON', 111),
|
||||
('comment:audit', '审核评论', '内容治理', 'BUTTON', 112),
|
||||
('report:list', '查看举报', '内容治理', 'BUTTON', 113),
|
||||
('report:handle', '处理举报', '内容治理', 'BUTTON', 114),
|
||||
('sensitive-word:list', '查看敏感词', '内容治理', 'BUTTON', 115),
|
||||
('sensitive-word:update','维护敏感词', '内容治理', 'BUTTON', 116)
|
||||
INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
|
||||
(1111, 'comment:list', '查看评论', '内容治理', 'BUTTON', 111),
|
||||
(1112, 'comment:audit', '审核评论', '内容治理', 'BUTTON', 112),
|
||||
(1113, 'report:list', '查看举报', '内容治理', 'BUTTON', 113),
|
||||
(1114, 'report:handle', '处理举报', '内容治理', 'BUTTON', 114),
|
||||
(1115, 'sensitive-word:list', '查看敏感词', '内容治理', 'BUTTON', 115),
|
||||
(1116, 'sensitive-word:update','维护敏感词', '内容治理', 'BUTTON', 116)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES
|
||||
('order:list', '查看订单', '订单与权益', 'BUTTON', 121),
|
||||
('order:update', '补发/退款', '订单与权益', 'BUTTON', 122),
|
||||
('entitlement:list', '查看权益', '订单与权益', 'BUTTON', 123),
|
||||
('entitlement:update', '补发/回收权益', '订单与权益', 'BUTTON', 124),
|
||||
('wallet:update', '人工补币', '订单与权益', 'BUTTON', 125),
|
||||
('reconcile:run', '手动对账', '订单与权益', 'BUTTON', 126)
|
||||
INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
|
||||
(1121, 'order:list', '查看订单', '订单与权益', 'BUTTON', 121),
|
||||
(1122, 'order:update', '补发/退款', '订单与权益', 'BUTTON', 122),
|
||||
(1123, 'entitlement:list', '查看权益', '订单与权益', 'BUTTON', 123),
|
||||
(1124, 'entitlement:update', '补发/回收权益', '订单与权益', 'BUTTON', 124),
|
||||
(1125, 'wallet:update', '人工补币', '订单与权益', 'BUTTON', 125),
|
||||
(1126, 'reconcile:run', '手动对账', '订单与权益', 'BUTTON', 126)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES
|
||||
('creator:list', '查看创作者', '创作者管理', 'BUTTON', 131),
|
||||
('creator:audit', '创作者入驻审核','创作者管理', 'BUTTON', 132),
|
||||
('submission:list', '查看内容提交', '创作者管理', 'BUTTON', 133),
|
||||
('submission:audit', '内容提交审核', '创作者管理', 'BUTTON', 134),
|
||||
('price:update', '配置分集价格', '内容管理', 'BUTTON', 135)
|
||||
INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
|
||||
(1131, 'creator:list', '查看创作者', '创作者管理', 'BUTTON', 131),
|
||||
(1132, 'creator:audit', '创作者入驻审核','创作者管理', 'BUTTON', 132),
|
||||
(1133, 'submission:list', '查看内容提交', '创作者管理', 'BUTTON', 133),
|
||||
(1134, 'submission:audit', '内容提交审核', '创作者管理', 'BUTTON', 134),
|
||||
(1135, 'price:update', '配置分集价格', '内容管理', 'BUTTON', 135)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- 看板权限:菜单在 3-4 补页面时一起加,避免菜单指向不存在的组件
|
||||
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES
|
||||
('dashboard:view', '查看运营大盘', '数据看板', 'BUTTON', 141)
|
||||
INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
|
||||
(1141, 'dashboard:view', '查看运营大盘', '数据看板', 'BUTTON', 141)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- 超管默认拥有全部权限点
|
||||
INSERT INTO sys_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
INSERT INTO sys_role_permission (id, role_id, permission_id)
|
||||
SELECT r.id * 1000000 + p.id, r.id, p.id
|
||||
FROM sys_role r
|
||||
CROSS JOIN sys_permission p
|
||||
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,
|
||||
type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort;
|
||||
|
||||
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||
SELECT r.id, m.id
|
||||
INSERT INTO sys_role_menu (id, role_id, menu_id)
|
||||
SELECT r.id * 1000000 + m.id, r.id, m.id
|
||||
FROM sys_role r
|
||||
CROSS JOIN sys_menu m
|
||||
WHERE r.code = 'super_admin'
|
||||
@@ -104,19 +107,19 @@ WHERE r.code = 'super_admin'
|
||||
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)
|
||||
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');
|
||||
|
||||
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)
|
||||
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');
|
||||
|
||||
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)
|
||||
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');
|
||||
|
||||
-- ---------- 菜单:用户管理 ----------
|
||||
@@ -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,
|
||||
type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort;
|
||||
|
||||
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||
SELECT r.id, m.id
|
||||
INSERT INTO sys_role_menu (id, role_id, menu_id)
|
||||
SELECT r.id * 1000000 + m.id, r.id, m.id
|
||||
FROM sys_role r
|
||||
CROSS JOIN sys_menu m
|
||||
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,
|
||||
type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort;
|
||||
|
||||
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||
SELECT r.id, m.id
|
||||
INSERT INTO sys_role_menu (id, role_id, menu_id)
|
||||
SELECT r.id * 1000000 + m.id, r.id, m.id
|
||||
FROM sys_role r
|
||||
CROSS JOIN sys_menu m
|
||||
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;
|
||||
|
||||
-- 权限点按批补:批 1 只有「进创作者中心」这一个,后面各批用到什么再幂等追加
|
||||
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES
|
||||
('creator-center:view', '访问创作者中心', '创作者中心', 'BUTTON', 150),
|
||||
('creator-center:drama:edit', '创作者中心-编辑作品', '创作者中心', 'BUTTON', 151),
|
||||
('creator-center:upload', '创作者中心-上传与挂载', '创作者中心', 'BUTTON', 152),
|
||||
('creator-center:price:update', '创作者中心-配置分集价格', '创作者中心', 'BUTTON', 153),
|
||||
('creator-center:stat:view', '创作者中心-查看看板', '创作者中心', 'BUTTON', 154)
|
||||
INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
|
||||
(1150, 'creator-center:view', '访问创作者中心', '创作者中心', 'BUTTON', 150),
|
||||
(1151, 'creator-center:drama:edit', '创作者中心-编辑作品', '创作者中心', 'BUTTON', 151),
|
||||
(1152, 'creator-center:upload', '创作者中心-上传与挂载', '创作者中心', 'BUTTON', 152),
|
||||
(1153, 'creator-center:price:update', '创作者中心-配置分集价格', '创作者中心', 'BUTTON', 153),
|
||||
(1154, 'creator-center:stat:view', '创作者中心-查看看板', '创作者中心', 'BUTTON', 154)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- 代看创作者(只读):第一阶段只给超管,content_operator 不给
|
||||
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES
|
||||
('creator-center:impersonate', '代看创作者(只读)', '创作者中心', 'BUTTON', 155)
|
||||
INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
|
||||
(1155, 'creator-center:impersonate', '代看创作者(只读)', '创作者中心', 'BUTTON', 155)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- 开通 / 重置创作者账号
|
||||
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES
|
||||
('creator:account', '开通/重置创作者账号', '创作者管理', 'BUTTON', 136)
|
||||
INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
|
||||
(1136, 'creator:account', '开通/重置创作者账号', '创作者管理', 'BUTTON', 136)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
INSERT INTO sys_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
INSERT INTO sys_role_permission (id, role_id, permission_id)
|
||||
SELECT r.id * 1000000 + p.id, r.id, p.id
|
||||
FROM sys_role r
|
||||
CROSS JOIN sys_permission p
|
||||
WHERE r.code = 'super_admin'
|
||||
AND p.code = 'creator:account'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO sys_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
INSERT INTO sys_role_permission (id, role_id, permission_id)
|
||||
SELECT r.id * 1000000 + p.id, r.id, p.id
|
||||
FROM sys_role r
|
||||
CROSS JOIN sys_permission p
|
||||
WHERE r.code = 'super_admin'
|
||||
@@ -212,8 +215,8 @@ WHERE r.code = 'super_admin'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 创作者角色只拿创作者中心这几个点,不给任何后台运营权限
|
||||
INSERT INTO sys_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
INSERT INTO sys_role_permission (id, role_id, permission_id)
|
||||
SELECT r.id * 1000000 + p.id, r.id, p.id
|
||||
FROM sys_role r
|
||||
CROSS JOIN sys_permission p
|
||||
WHERE r.code = 'creator'
|
||||
@@ -222,8 +225,8 @@ WHERE r.code = 'creator'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 超管也留一份,方便排查创作者视角的问题(超管没有绑定创作者主体,进 creator-center 仍会被挡)
|
||||
INSERT INTO sys_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
INSERT INTO sys_role_permission (id, role_id, permission_id)
|
||||
SELECT r.id * 1000000 + p.id, r.id, p.id
|
||||
FROM sys_role r
|
||||
CROSS JOIN sys_permission p
|
||||
WHERE r.code = 'super_admin'
|
||||
@@ -233,8 +236,8 @@ ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 媒资分片上传是复用后台已有的 /admin/media/upload/*,所以创作者角色要用现成的上传权限点,
|
||||
-- 不再造一套 creator-center 的上传接口(上传会话归属与产物归属另在服务层校验)。
|
||||
INSERT INTO sys_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
INSERT INTO sys_role_permission (id, role_id, permission_id)
|
||||
SELECT r.id * 1000000 + p.id, r.id, p.id
|
||||
FROM sys_role r
|
||||
CROSS JOIN sys_permission p
|
||||
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;
|
||||
|
||||
-- 创作者角色:只挂创作者中心这五条(含隐藏的编辑页),**不给任何运营后台菜单**
|
||||
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||
SELECT r.id, m.id
|
||||
INSERT INTO sys_role_menu (id, role_id, menu_id)
|
||||
SELECT r.id * 1000000 + m.id, r.id, m.id
|
||||
FROM sys_role r
|
||||
CROSS JOIN sys_menu m
|
||||
WHERE r.code = 'creator'
|
||||
|
||||
+27
-38
@@ -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;
|
||||
|
||||
-- ---------- 角色-权限 ----------
|
||||
INSERT INTO sys_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id FROM sys_role r CROSS JOIN sys_permission p WHERE r.code = 'super_admin'
|
||||
INSERT INTO sys_role_permission (id, role_id, permission_id)
|
||||
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;
|
||||
|
||||
INSERT INTO sys_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id FROM sys_role r CROSS JOIN sys_permission p
|
||||
INSERT INTO sys_role_permission (id, role_id, permission_id)
|
||||
SELECT r.id * 1000000 + p.id, r.id, p.id FROM sys_role r CROSS JOIN sys_permission p
|
||||
WHERE r.code = 'content_operator'
|
||||
AND p.code IN ('drama:list','drama:create','drama:update','drama:delete',
|
||||
'episode:list','episode:upload','episode:sort','cover:upload',
|
||||
'banner:list','banner:create','banner:update','banner:delete')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO sys_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id FROM sys_role r CROSS JOIN sys_permission p
|
||||
INSERT INTO sys_role_permission (id, role_id, permission_id)
|
||||
SELECT r.id * 1000000 + p.id, r.id, p.id FROM sys_role r CROSS JOIN sys_permission p
|
||||
WHERE r.code = 'content_reviewer'
|
||||
AND p.code IN ('drama:list','episode:list','drama:review','stat:view','banner:list')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO sys_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id FROM sys_role r CROSS JOIN sys_permission p
|
||||
INSERT INTO sys_role_permission (id, role_id, permission_id)
|
||||
SELECT r.id * 1000000 + p.id, r.id, p.id FROM sys_role r CROSS JOIN sys_permission p
|
||||
WHERE r.code = 'data_analyst'
|
||||
AND p.code IN ('drama:list','stat:view','banner:list')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ---------- 角色-菜单 ----------
|
||||
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||
SELECT r.id, m.id FROM sys_role r CROSS JOIN sys_menu m WHERE r.code = 'super_admin'
|
||||
INSERT INTO sys_role_menu (id, role_id, menu_id)
|
||||
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;
|
||||
|
||||
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||
SELECT r.id, m.id FROM sys_role r CROSS JOIN sys_menu m
|
||||
INSERT INTO sys_role_menu (id, role_id, menu_id)
|
||||
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)
|
||||
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,
|
||||
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
|
||||
(1001, 1), (1002, 1), (1003, 2), (1004, 2), (1005, 3), (1006, 4)
|
||||
INSERT INTO sys_admin_user_role (id, user_id, role_id) VALUES
|
||||
(1001000001, 1001, 1), (1002000001, 1002, 1), (1003000002, 1003, 2),
|
||||
(1004000002, 1004, 2), (1005000003, 1005, 3), (1006000004, 1006, 4)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ---------- 系统配置 ----------
|
||||
INSERT INTO sys_config (config_key, config_value, remark) VALUES
|
||||
('workspace.name', 'FruitShorts Studio', '工作空间名称'),
|
||||
('content.default-status', 'draft', '内容默认状态')
|
||||
INSERT INTO sys_config (id, config_key, config_value, remark) VALUES
|
||||
(1, 'workspace.name', 'FruitShorts Studio', '工作空间名称'),
|
||||
(2, 'content.default-status', 'draft', '内容默认状态')
|
||||
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,
|
||||
unlock_coin = EXCLUDED.unlock_coin, status = EXCLUDED.status, play_count = EXCLUDED.play_count;
|
||||
|
||||
-- ---------- 对齐自增序列 ----------
|
||||
-- 上面用了显式 ID,但 IDENTITY 序列不会因此前移;不对齐的话,后续插入会从 1 开始而撞主键。
|
||||
DO $$
|
||||
DECLARE
|
||||
t text;
|
||||
seq text;
|
||||
maxid bigint;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY ARRAY[
|
||||
'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 $$;
|
||||
-- ---------- 关于主键 ----------
|
||||
-- S0 之后主键由应用侧雪花算法生成(MyBatis-Plus IdType.ASSIGN_ID),数据库里已经没有自增序列,
|
||||
-- 所以本文件不再需要"把 IDENTITY 序列对齐到最大 ID"那段收尾逻辑(原来那段是防止显式 ID 之后
|
||||
-- 自增从 1 重新开始而撞主键;序列已经不存在,这个风险也随之消失)。
|
||||
--
|
||||
-- 种子行的主键约定(三端一致,改动时请沿用):
|
||||
-- 1) 普通种子行沿用既有小整数区间(如角色 1-5、菜单 1-30、管理员 1001+);
|
||||
-- 2) 关联表用「左键 * 1000000 + 右键」拼出的确定性 ID,重复执行结果完全一致;
|
||||
-- 3) 应用运行时产生的数据才用雪花 ID(19 位),两者不会相撞。
|
||||
@@ -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;
|
||||
@@ -99,7 +99,7 @@ BEGIN
|
||||
END IF;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS playback_ticket (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
id BIGINT,
|
||||
ticket_no VARCHAR(40) NOT NULL,
|
||||
user_id BIGINT NOT NULL DEFAULT 0,
|
||||
drama_id BIGINT NOT NULL,
|
||||
@@ -134,7 +134,7 @@ BEGIN
|
||||
END IF;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_event (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
id BIGINT,
|
||||
order_no VARCHAR(40) NOT NULL,
|
||||
event VARCHAR(32) NOT NULL,
|
||||
from_status VARCHAR(16) NOT NULL DEFAULT '',
|
||||
|
||||
+41
-41
@@ -9,7 +9,7 @@
|
||||
-- 具体的月分区由 sql/partition-maintenance.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
|
||||
-- 逻辑删除 deleted SMALLINT NOT NULL DEFAULT 0
|
||||
-- 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 (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
id BIGINT PRIMARY KEY,
|
||||
uid VARCHAR(32) NOT NULL, -- 对外展示的用户标识,避免暴露自增 ID
|
||||
nickname VARCHAR(64) 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 端用户';
|
||||
|
||||
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,
|
||||
type VARCHAR(16) NOT NULL, -- PHONE/EMAIL/APPLE/GOOGLE
|
||||
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 端登录凭证(手机号/邮箱/第三方)';
|
||||
|
||||
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,
|
||||
refresh_token_hash VARCHAR(64) NOT NULL, -- SHA-256,不存明文
|
||||
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 端会话(可多设备)';
|
||||
|
||||
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
|
||||
target_type VARCHAR(16) NOT NULL, -- PHONE/EMAIL
|
||||
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 '验证码发送与校验记录(同时用于反刷频控)';
|
||||
|
||||
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,
|
||||
scene VARCHAR(32) NOT NULL, -- PRIVACY/TERMS/MARKETING/PERSONALIZED_AD
|
||||
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 '同意管理记录,商店审核与隐私合规要求可追溯';
|
||||
|
||||
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,
|
||||
device_id VARCHAR(128) NOT NULL,
|
||||
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 '用户设备,用于下载授权与并发播放数限制';
|
||||
|
||||
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,
|
||||
type VARCHAR(16) NOT NULL, -- BAN/LIMIT/MUTE
|
||||
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 '封禁/限制与申诉记录';
|
||||
|
||||
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,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending/cancelled/completed
|
||||
reason VARCHAR(255) NOT NULL DEFAULT '',
|
||||
@@ -226,7 +226,7 @@ COMMENT ON TABLE app_account_deletion IS '账号注销申请与冷静期';
|
||||
-- ============================================================
|
||||
|
||||
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,
|
||||
drama_id BIGINT NOT NULL,
|
||||
episode_id BIGINT NOT NULL,
|
||||
@@ -246,7 +246,7 @@ COMMENT ON TABLE watch_progress IS '每用户每剧一条续看进度(跨端
|
||||
|
||||
-- 观看流水:量大,按 watched_at 做声明式月分区,便于按期归档与清理
|
||||
CREATE TABLE IF NOT EXISTS watch_history (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
id BIGINT,
|
||||
user_id BIGINT NOT NULL,
|
||||
drama_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 个月)';
|
||||
|
||||
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,
|
||||
drama_id BIGINT NOT NULL,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
@@ -280,7 +280,7 @@ COMMENT ON TABLE user_favorite IS '剧集收藏';
|
||||
-- 唯一索引是 (ticket_no, created_at);ticket_no 是 UUID,实际唯一性不受影响。
|
||||
-- 月分区本身由 sql/partition-maintenance.sql 建(含未来 3 个月)。
|
||||
CREATE TABLE IF NOT EXISTS playback_ticket (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
id BIGINT,
|
||||
ticket_no VARCHAR(40) NOT NULL,
|
||||
user_id BIGINT NOT NULL DEFAULT 0, -- 0 表示未登录的免费集
|
||||
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 上(月分区)';
|
||||
|
||||
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,
|
||||
drama_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 (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
id BIGINT PRIMARY KEY,
|
||||
drama_id BIGINT NOT NULL,
|
||||
episode_id BIGINT NOT NULL DEFAULT 0,
|
||||
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 '评论与回复(先审后发)';
|
||||
|
||||
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,
|
||||
target_type VARCHAR(16) NOT NULL, -- DRAMA/EPISODE/COMMENT
|
||||
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 '点赞关系;计数值异步聚合';
|
||||
|
||||
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,
|
||||
target_type VARCHAR(16) NOT NULL, -- COMMENT/USER/DRAMA
|
||||
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 '举报工单';
|
||||
|
||||
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,
|
||||
blocked_user_id BIGINT NOT NULL,
|
||||
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 '拉黑关系';
|
||||
|
||||
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,
|
||||
category VARCHAR(32) NOT NULL DEFAULT '', -- POLITICS/PORN/ABUSE/AD/RELIGION
|
||||
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 '敏感词库';
|
||||
|
||||
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_id BIGINT NOT NULL,
|
||||
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 '审核留证(机审 + 人审都记)';
|
||||
|
||||
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,
|
||||
drama_id BIGINT NOT NULL,
|
||||
episode_id BIGINT NOT NULL DEFAULT 0,
|
||||
@@ -442,7 +442,7 @@ COMMENT ON TABLE content_share IS '分享事件,用于渠道效果与裂变分
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wallet (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
id BIGINT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
coin_balance 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';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS product (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
id BIGINT PRIMARY KEY,
|
||||
code VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
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 '商品配置(金币包;会员与活动价为后续阶段预留)';
|
||||
|
||||
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,
|
||||
user_id BIGINT NOT NULL,
|
||||
product_id BIGINT NOT NULL DEFAULT 0,
|
||||
@@ -522,7 +522,7 @@ COMMENT ON TABLE orders IS '订单;orders 为保留字规避(order 是 SQL
|
||||
|
||||
-- 订单状态流转流水,同样按月分区(同 playback_ticket,主键带分区键)
|
||||
CREATE TABLE IF NOT EXISTS order_event (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
id BIGINT,
|
||||
order_no VARCHAR(40) NOT NULL,
|
||||
event VARCHAR(32) NOT NULL, -- CREATE/VERIFY_OK/VERIFY_FAIL/DELIVER/REFUND/NOTIFY
|
||||
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 '订单状态流转流水,支付争议时的证据链(月分区)';
|
||||
|
||||
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,
|
||||
transaction_id VARCHAR(128) NOT NULL,
|
||||
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 '商店票据去重与校验留证(重放防护)';
|
||||
|
||||
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,
|
||||
change_amount 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 '金币流水,钱包余额的唯一事实来源';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entitlement (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
id BIGINT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
drama_id BIGINT NOT NULL DEFAULT 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 '权益;播放判定优先读这里';
|
||||
|
||||
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,
|
||||
platform VARCHAR(16) 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 '退款与撤销事件,触发权益回收';
|
||||
|
||||
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,
|
||||
platform VARCHAR(16) NOT NULL DEFAULT 'ALL',
|
||||
total_count INT NOT NULL DEFAULT 0,
|
||||
@@ -635,7 +635,7 @@ COMMENT ON TABLE reconcile_task IS '每日对账任务(与商店账单比对
|
||||
-- ============================================================
|
||||
|
||||
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 端账号
|
||||
code VARCHAR(32) 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 '创作者主体与资质';
|
||||
|
||||
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,
|
||||
type VARCHAR(32) NOT NULL, -- ID_CARD/BUSINESS_LICENSE/CONTRACT
|
||||
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 '创作者资质文件';
|
||||
|
||||
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,
|
||||
drama_id BIGINT NOT NULL,
|
||||
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 '创作者内容提交与审核流转';
|
||||
|
||||
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,
|
||||
drama_id BIGINT NOT NULL DEFAULT 0,
|
||||
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 '创作者数据看板日汇总';
|
||||
|
||||
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,
|
||||
period VARCHAR(16) NOT NULL, -- 2026-10
|
||||
revenue BIGINT NOT NULL DEFAULT 0,
|
||||
@@ -737,7 +737,7 @@ COMMENT ON TABLE creator_settlement IS '预留:创作者结算(第二阶段
|
||||
-- ============================================================
|
||||
|
||||
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,
|
||||
drama_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 / 云转码)';
|
||||
|
||||
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,
|
||||
drama_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 (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
id BIGINT PRIMARY KEY,
|
||||
title VARCHAR(128) NOT NULL,
|
||||
content VARCHAR(2000) NOT NULL DEFAULT '',
|
||||
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 '系统公告(创作者审核结果、活动、支付通知)';
|
||||
|
||||
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,
|
||||
new_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 '经营大盘日汇总(含成本口径)';
|
||||
|
||||
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_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
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 拉取,服务端只记账:
|
||||
-- 用它实现"下载中/已暂停/已完成"的跨端展示、断点续传的进度恢复,以及配额统计。
|
||||
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,
|
||||
device_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
drama_id BIGINT NOT NULL,
|
||||
|
||||
+18
-18
@@ -1,7 +1,7 @@
|
||||
-- FruitShorts 短剧服务 · 建表脚本(PostgreSQL 18)
|
||||
--
|
||||
-- 与 MySQL 版本的对应关系:
|
||||
-- AUTO_INCREMENT -> GENERATED BY DEFAULT AS IDENTITY
|
||||
-- AUTO_INCREMENT -> 无(主键由应用侧雪花算法生成:MyBatis-Plus IdType.ASSIGN_ID)
|
||||
-- TINYINT -> SMALLINT
|
||||
-- DATETIME(3) -> TIMESTAMP(3)
|
||||
-- ON UPDATE CURRENT_TIMESTAMP -> set_updated_at() 触发器
|
||||
@@ -11,7 +11,7 @@
|
||||
-- ========== 系统域 ==========
|
||||
|
||||
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, -- 登录名
|
||||
password VARCHAR(100) NOT NULL, -- BCrypt 哈希
|
||||
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 '管理员';
|
||||
|
||||
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,
|
||||
description VARCHAR(255) NOT NULL DEFAULT '',
|
||||
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 '角色分类';
|
||||
|
||||
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,
|
||||
code VARCHAR(64) NOT NULL, -- 如 super_admin
|
||||
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 '角色';
|
||||
|
||||
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,
|
||||
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 '管理员-角色';
|
||||
|
||||
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
|
||||
name VARCHAR(64) NOT NULL,
|
||||
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 '权限点';
|
||||
|
||||
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,
|
||||
title VARCHAR(64) NOT NULL,
|
||||
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 '菜单';
|
||||
|
||||
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,
|
||||
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 '角色-权限';
|
||||
|
||||
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,
|
||||
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 '角色-菜单';
|
||||
|
||||
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_value VARCHAR(1024) 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 '系统配置';
|
||||
|
||||
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,
|
||||
username 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 (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
id BIGINT PRIMARY KEY,
|
||||
name VARCHAR(64) NOT NULL,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
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 '短剧类型';
|
||||
|
||||
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 '',
|
||||
title VARCHAR(128) NOT NULL,
|
||||
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 '短剧';
|
||||
|
||||
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,
|
||||
ep_no INT NOT NULL,
|
||||
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 '剧集';
|
||||
|
||||
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_id BIGINT NOT NULL,
|
||||
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 '审核记录';
|
||||
|
||||
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 '',
|
||||
slot VARCHAR(32) NOT NULL DEFAULT 'HOME_TOP', -- 展示位置:HOME_TOP/DISCOVER
|
||||
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/** 访问
|
||||
|
||||
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',
|
||||
file_name VARCHAR(255) 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 '媒资';
|
||||
|
||||
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,
|
||||
biz_type VARCHAR(32) NOT NULL DEFAULT 'EPISODE',
|
||||
file_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
|
||||
Reference in new issue
Block a user