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:
jianghaifeng committed 2026-10-16 14:56:44 +08:00
1 parent 4c3d524eff
commit 14b5e4137f
19 files changed
+1355 -11

No files matched your search

@@ -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;
}
}
@@ -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;
}
}