feat(app-api): E1 补全 5 个 C 端接口(创作者入驻/作品/看板 + 启动配置/公告)
为什么:联调 10-19 开始,前端照 api-phase1.md §1.9 写会 404,需要先把这 5 个补齐。 契约先行(12-上线检查清单 §一):先重写 api-phase1.md §1.9 —— 把 commerce 三条移出本节标"已实现", 补齐 5 个接口的请求/响应字段、错误码、分页口径、幂等矩阵,并新增 200007 CREATOR_NOT_ACTIVE。 - GET /app/config(匿名):硬编码白名单 6 键 + 契约默认值兜底,绝不整表下发 - GET /app/notices(匿名):ops_notice 只读分页(status/target/发布与过期时间),无数据返回空列表 - POST /app/creator/apply:幂等,防重靠 DB 唯一索引 uk_creator_user,并发冲突读回既有记录 - GET /app/creator/works:非生效创作者报 200007 + 分状态提示,不返回空数组冒充成功 - GET /app/creator/stats:与后台 07 看板同口径(字段/notes 逐条一致),null + notes 语义保留 - 冻结模块零改动:module-creator 仅新增 CreatorAppService/两个 VO 与 1 个公开方法 pageMyDramasOf, 既有 pageMyDramas 一行未动;module-system 仅新增 OpsNotice 实体与 Mapper - Swagger 中文注解按 S1 规范补齐;白名单与 /app/notices 加进安全白名单 - 实测:mvn test 98 条全过(app-api 41→56);package 通过;端到端五接口全部符合预期 (重复 apply 同 id + created:false、pending 看 works/stats 报 200007、approved 后正常)
This commit is contained in:
1 parent
4c3d524eff
commit
14b5e4137f
19 files changed
+1355
-11
No files matched your search
+230
@@ -0,0 +1,230 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.app.controller;
|
||||
|
||||
import com.fruitshorts.app.service.AppConfigService;
|
||||
import com.fruitshorts.app.service.AppNoticeService;
|
||||
import com.fruitshorts.app.service.CreatorStatsService;
|
||||
import com.fruitshorts.app.support.ControllerContractSupport;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.creator.dal.entity.Creator;
|
||||
import com.fruitshorts.creator.service.CreatorAppService;
|
||||
import com.fruitshorts.creator.service.CreatorCenterService;
|
||||
import com.fruitshorts.creator.service.CreatorService;
|
||||
import com.fruitshorts.creator.vo.CreatorApplyReqVO;
|
||||
import com.fruitshorts.creator.vo.CreatorApplyRespVO;
|
||||
import com.fruitshorts.creator.vo.CreatorCenterDramaVO;
|
||||
import com.fruitshorts.framework.security.LoginUser;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
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;
|
||||
|
||||
/**
|
||||
* E1 五个 C 端接口的接入层契约。
|
||||
*
|
||||
* <p>覆盖(对应条目门禁):匿名可达 config/notices、未登录访问 creator 被拒、
|
||||
* 重复 apply 幂等、非创作者看 works 被拒(200007)、`/app/config` 只回白名单键。
|
||||
*/
|
||||
class AppCreatorAndConfigContractTest {
|
||||
|
||||
private static final Long USER = 1001L;
|
||||
|
||||
private AppConfigService appConfigService;
|
||||
private AppNoticeService appNoticeService;
|
||||
private CreatorAppService creatorAppService;
|
||||
private CreatorStatsService creatorStatsService;
|
||||
private MockMvc configMvc;
|
||||
private MockMvc creatorMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
appConfigService = mock(AppConfigService.class);
|
||||
appNoticeService = mock(AppNoticeService.class);
|
||||
creatorAppService = mock(CreatorAppService.class);
|
||||
creatorStatsService = mock(CreatorStatsService.class);
|
||||
configMvc = ControllerContractSupport.mockMvcOf(
|
||||
new AppConfigController(appConfigService, appNoticeService));
|
||||
creatorMvc = ControllerContractSupport.mockMvcOf(new AppCreatorController(
|
||||
creatorAppService, creatorStatsService, appConfigService));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
private void login() {
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||
new LoginUser(USER, "app-user", "APP_USER", List.of()), null, List.of()));
|
||||
}
|
||||
|
||||
// ─────────── /app/config(匿名 + 白名单) ───────────
|
||||
|
||||
@Test
|
||||
void should_return_only_whitelisted_keys_when_config_is_requested_anonymously() throws Exception {
|
||||
// 服务层按白名单组装;这里再钉一遍"响应体里只有白名单键"
|
||||
Map<String, Object> whitelist = new LinkedHashMap<>();
|
||||
whitelist.put("agreementVersion", "2026-10-01");
|
||||
whitelist.put("minAppVersion", "1.0.0");
|
||||
whitelist.put("forceUpdate", false);
|
||||
whitelist.put("noticeEnabled", true);
|
||||
whitelist.put("coinUnlockEnabled", true);
|
||||
whitelist.put("downloadEnabled", true);
|
||||
given(appConfigService.appConfig()).willReturn(whitelist);
|
||||
|
||||
String body = configMvc.perform(get("/app/config"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.agreementVersion").value("2026-10-01"))
|
||||
.andExpect(jsonPath("$.data.forceUpdate").value(false))
|
||||
.andReturn().getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
|
||||
|
||||
// 没登录也能拿到(匿名)
|
||||
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
|
||||
assertThat(body).doesNotContain("password").doesNotContain("secret").doesNotContain("token");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_return_empty_list_when_no_notice_exists() throws Exception {
|
||||
given(appNoticeService.page(1, 20)).willReturn(new PageResult<>(0L, List.of()));
|
||||
|
||||
configMvc.perform(get("/app/notices"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.total").value(0))
|
||||
.andExpect(jsonPath("$.data.list").isArray());
|
||||
}
|
||||
|
||||
// ─────────── /app/creator/**(需登录) ───────────
|
||||
|
||||
@Test
|
||||
void should_reject_creator_apply_when_not_logged_in() throws Exception {
|
||||
// 未设置 SecurityContext:控制器取不到 C 端用户 → 统一 401 信封
|
||||
creatorMvc.perform(post("/app/creator/apply")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"星野工作室\",\"agreementVersion\":\"2026-10-01\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(ErrorCode.UNAUTHORIZED.getCode()));
|
||||
|
||||
verify(creatorAppService, never()).apply(any(), any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_return_current_status_when_applying_twice() throws Exception {
|
||||
login();
|
||||
given(appConfigService.agreementVersion()).willReturn("2026-10-01");
|
||||
CreatorApplyRespVO first = applyResp(7L, CreatorService.STATUS_PENDING, true);
|
||||
CreatorApplyRespVO second = applyResp(7L, CreatorService.STATUS_PENDING, false);
|
||||
given(creatorAppService.apply(eq(USER), any(CreatorApplyReqVO.class), eq("2026-10-01")))
|
||||
.willReturn(first, second);
|
||||
|
||||
String payload = "{\"name\":\"星野工作室\",\"type\":\"COMPANY\",\"agreementVersion\":\"2026-10-01\"}";
|
||||
creatorMvc.perform(post("/app/creator/apply").contentType(MediaType.APPLICATION_JSON).content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.id").value(7))
|
||||
.andExpect(jsonPath("$.data.status").value(CreatorService.STATUS_PENDING))
|
||||
.andExpect(jsonPath("$.data.created").value(true));
|
||||
|
||||
// 第二次提交:同一条记录、created=false(幂等)
|
||||
creatorMvc.perform(post("/app/creator/apply").contentType(MediaType.APPLICATION_JSON).content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.id").value(7))
|
||||
.andExpect(jsonPath("$.data.created").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_when_viewing_works_without_active_creator() throws Exception {
|
||||
login();
|
||||
given(creatorAppService.myWorks(eq(USER), any()))
|
||||
.willThrow(new ServiceException(ErrorCode.CREATOR_NOT_ACTIVE, "尚未提交创作者入驻申请"));
|
||||
|
||||
creatorMvc.perform(get("/app/creator/works"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(ErrorCode.CREATOR_NOT_ACTIVE.getCode()))
|
||||
.andExpect(jsonPath("$.message").value("尚未提交创作者入驻申请"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_return_my_works_when_creator_is_active() throws Exception {
|
||||
login();
|
||||
CreatorCenterDramaVO vo = new CreatorCenterDramaVO();
|
||||
vo.setId(5L);
|
||||
vo.setTitle("命运的相遇");
|
||||
vo.setStatus("draft");
|
||||
given(creatorAppService.myWorks(eq(USER), any())).willReturn(new PageResult<>(1L, List.of(vo)));
|
||||
|
||||
creatorMvc.perform(get("/app/creator/works").param("pageIndex", "1").param("pageSize", "10"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.total").value(1))
|
||||
.andExpect(jsonPath("$.data.list[0].id").value(5))
|
||||
.andExpect(jsonPath("$.data.list[0].title").value("命运的相遇"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_when_viewing_stats_without_active_creator() throws Exception {
|
||||
login();
|
||||
given(creatorAppService.requireActiveCreator(USER))
|
||||
.willThrow(new ServiceException(ErrorCode.CREATOR_NOT_ACTIVE, "入驻申请审核中,请耐心等待"));
|
||||
|
||||
creatorMvc.perform(get("/app/creator/stats"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(ErrorCode.CREATOR_NOT_ACTIVE.getCode()));
|
||||
verify(creatorStatsService, never()).overview(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_return_dashboard_with_null_and_notes_when_stats_is_requested() throws Exception {
|
||||
login();
|
||||
Creator creator = new Creator();
|
||||
creator.setId(7L);
|
||||
creator.setStatus(CreatorService.STATUS_APPROVED);
|
||||
given(creatorAppService.requireActiveCreator(USER)).willReturn(creator);
|
||||
Map<String, Object> dashboard = new LinkedHashMap<>();
|
||||
dashboard.put("drama", Map.of("total", 2));
|
||||
dashboard.put("daily", null);
|
||||
dashboard.put("notes", List.of("daily 暂无数据源"));
|
||||
given(creatorStatsService.overview(creator)).willReturn(dashboard);
|
||||
|
||||
creatorMvc.perform(get("/app/creator/stats"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.drama.total").value(2))
|
||||
// 取不到的指标是 null(不是 0),并在 notes 里说明
|
||||
.andExpect(jsonPath("$.data.daily").value(org.hamcrest.Matchers.nullValue()))
|
||||
.andExpect(jsonPath("$.data.notes[0]").value("daily 暂无数据源"));
|
||||
}
|
||||
|
||||
private CreatorApplyRespVO applyResp(Long id, String status, boolean created) {
|
||||
CreatorApplyRespVO vo = new CreatorApplyRespVO();
|
||||
vo.setId(id);
|
||||
vo.setCode("CR20261016001");
|
||||
vo.setName("星野工作室");
|
||||
vo.setStatus(status);
|
||||
vo.setStatusText(CreatorService.statusText(status));
|
||||
vo.setCreated(created);
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.app.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.creator.dal.entity.Creator;
|
||||
import com.fruitshorts.creator.dal.mapper.CreatorMapper;
|
||||
import com.fruitshorts.creator.service.CreatorAppService;
|
||||
import com.fruitshorts.creator.service.CreatorCenterService;
|
||||
import com.fruitshorts.creator.service.CreatorService;
|
||||
import com.fruitshorts.creator.vo.CreatorApplyReqVO;
|
||||
import com.fruitshorts.creator.vo.CreatorApplyRespVO;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
|
||||
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;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* C 端创作者入驻的**业务规则**(真 service + 替身 Mapper):幂等防重靠 DB、状态机报错口径。
|
||||
*
|
||||
* <p>契约层的 HTTP 表现见 `AppCreatorAndConfigContractTest`;这里钉的是"服务端真的只建一次"。
|
||||
*/
|
||||
class CreatorAppServiceTest {
|
||||
|
||||
private static final Long USER = 1001L;
|
||||
private static final String AGREEMENT = "2026-10-01";
|
||||
|
||||
private CreatorMapper creatorMapper;
|
||||
private CreatorAppService creatorAppService;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfoCache() {
|
||||
// MyBatis-Plus 的 lambda 条件构造器需要实体的 TableInfo 缓存(纯单测没有 MP 上下文)
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), ""), Creator.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
creatorMapper = mock(CreatorMapper.class);
|
||||
creatorAppService = new CreatorAppService(creatorMapper, mock(CreatorCenterService.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_insert_pending_creator_when_applying_for_the_first_time() {
|
||||
given(creatorMapper.selectOne(any())).willReturn(null);
|
||||
given(creatorMapper.selectCount(any())).willReturn(0L);
|
||||
AtomicReference<Creator> saved = captureInsert();
|
||||
|
||||
CreatorApplyRespVO resp = creatorAppService.apply(USER, req(AGREEMENT), AGREEMENT);
|
||||
|
||||
assertThat(resp.isCreated()).isTrue();
|
||||
assertThat(resp.getStatus()).isEqualTo(CreatorService.STATUS_PENDING);
|
||||
assertThat(saved.get().getUserId()).isEqualTo(USER);
|
||||
assertThat(saved.get().getCode()).startsWith("CR");
|
||||
assertThat(saved.get().getStatus()).isEqualTo(CreatorService.STATUS_PENDING);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_return_existing_without_inserting_when_applying_again() {
|
||||
Creator existing = creator(CreatorService.STATUS_PENDING);
|
||||
given(creatorMapper.selectOne(any())).willReturn(existing);
|
||||
|
||||
CreatorApplyRespVO resp = creatorAppService.apply(USER, req(AGREEMENT), AGREEMENT);
|
||||
|
||||
assertThat(resp.isCreated()).isFalse();
|
||||
assertThat(resp.getId()).isEqualTo(existing.getId());
|
||||
// 关键的幂等保证:第二次提交不产生新的插入
|
||||
verify(creatorMapper, never()).insert(any(Creator.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_return_existing_when_concurrent_insert_hits_the_unique_index() {
|
||||
// 第一次查不到(并发对手还没提交完),插入时被唯一索引挡住 → 必须读回既有记录,不能报 500
|
||||
given(creatorMapper.selectOne(any()))
|
||||
.willReturn(null, creator(CreatorService.STATUS_PENDING));
|
||||
given(creatorMapper.selectCount(any())).willReturn(0L);
|
||||
given(creatorMapper.insert(any(Creator.class)))
|
||||
.willThrow(new DuplicateKeyException("uk_creator_user"));
|
||||
|
||||
CreatorApplyRespVO resp = creatorAppService.apply(USER, req(AGREEMENT), AGREEMENT);
|
||||
|
||||
assertThat(resp.isCreated()).isFalse();
|
||||
assertThat(resp.getStatus()).isEqualTo(CreatorService.STATUS_PENDING);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_when_agreement_version_is_not_the_current_one() {
|
||||
assertThatThrownBy(() -> creatorAppService.apply(USER, req("2026-09-01"), AGREEMENT))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.BAD_REQUEST.getCode()))
|
||||
.hasMessageContaining("请先同意最新版协议");
|
||||
verify(creatorMapper, never()).insert(any(Creator.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_return_creator_when_status_is_approved() {
|
||||
given(creatorMapper.selectOne(any())).willReturn(creator(CreatorService.STATUS_APPROVED));
|
||||
|
||||
Creator creator = creatorAppService.requireActiveCreator(USER);
|
||||
|
||||
assertThat(creator.getStatus()).isEqualTo(CreatorService.STATUS_APPROVED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_with_creator_not_active_when_status_is_not_approved() {
|
||||
for (String status : new String[]{CreatorService.STATUS_PENDING, CreatorService.STATUS_REJECTED,
|
||||
CreatorService.STATUS_FROZEN}) {
|
||||
Creator creator = creator(status);
|
||||
creator.setAuditNote("资料不全");
|
||||
given(creatorMapper.selectOne(any())).willReturn(creator);
|
||||
|
||||
assertThatThrownBy(() -> creatorAppService.requireActiveCreator(USER))
|
||||
.as("状态 %s 应被拒", status)
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.CREATOR_NOT_ACTIVE.getCode()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_with_creator_not_active_when_user_never_applied() {
|
||||
given(creatorMapper.selectOne(any())).willReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> creatorAppService.requireActiveCreator(USER))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.CREATOR_NOT_ACTIVE.getCode()))
|
||||
.hasMessage("尚未提交创作者入驻申请");
|
||||
}
|
||||
|
||||
private AtomicReference<Creator> captureInsert() {
|
||||
AtomicReference<Creator> saved = new AtomicReference<>();
|
||||
given(creatorMapper.insert(any(Creator.class))).willAnswer(invocation -> {
|
||||
Creator creator = invocation.getArgument(0);
|
||||
creator.setId(7L);
|
||||
saved.set(creator);
|
||||
return 1;
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
private CreatorApplyReqVO req(String agreementVersion) {
|
||||
CreatorApplyReqVO req = new CreatorApplyReqVO();
|
||||
req.setName("星野工作室");
|
||||
req.setType("COMPANY");
|
||||
req.setContactEmail("creator@example.com");
|
||||
req.setAgreementVersion(agreementVersion);
|
||||
return req;
|
||||
}
|
||||
|
||||
private Creator creator(String status) {
|
||||
Creator creator = new Creator();
|
||||
creator.setId(7L);
|
||||
creator.setCode("CR20261016001");
|
||||
creator.setName("星野工作室");
|
||||
creator.setUserId(USER);
|
||||
creator.setStatus(status);
|
||||
creator.setAuditNote("");
|
||||
return creator;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user