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,164 @@
// Author: fruit.com.cn
package com.fruitshorts.creator.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
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.dal.mapper.CreatorMapper;
import com.fruitshorts.creator.vo.CreatorApplyReqVO;
import com.fruitshorts.creator.vo.CreatorApplyRespVO;
import com.fruitshorts.creator.vo.CreatorCenterDramaQueryReqVO;
import com.fruitshorts.creator.vo.CreatorCenterDramaVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
/**
* C 端创作者入驻与作品(`/app/creator/**`)。
*
* <p>与管理端创作者中心的关键区别:**归属按 `creator.user_id`(C 端账号)解析**,
* 而管理端按 `creator.admin_user_id`(后台账号绑定)。两条链路互不影响,
* 这里**没有改动**管理端的入驻审核 / 开号 / 作品管理逻辑。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CreatorAppService {
private final CreatorMapper creatorMapper;
private final CreatorCenterService creatorCenterService;
/**
* 入驻申请(**幂等**)。
*
* <p>防重靠数据库:`uk_creator_user(user_id) where user_id > 0`。
* 同一账号重复提交只返回当前状态,不会重复建档;并发提交由唯一索引兜底(冲突后读回)。
*
* @param currentAgreementVersion 当前协议版本(来自 `/app/config` 白名单),不一致直接 400
*/
@Transactional(rollbackFor = Exception.class)
public CreatorApplyRespVO apply(Long userId, CreatorApplyReqVO req, String currentAgreementVersion) {
if (userId == null) {
throw new ServiceException(ErrorCode.UNAUTHORIZED);
}
if (!StringUtils.hasText(req.getAgreementVersion())
|| !req.getAgreementVersion().trim().equals(currentAgreementVersion)) {
throw new ServiceException(ErrorCode.BAD_REQUEST,
"请先同意最新版协议(当前版本:" + currentAgreementVersion + ")");
}
Creator existing = findByUserId(userId);
if (existing != null) {
return toResp(existing, false);
}
Creator creator = new Creator();
creator.setCode(generateCode());
creator.setName(req.getName().trim());
creator.setType(StringUtils.hasText(req.getType()) ? req.getType().trim().toUpperCase() : "INDIVIDUAL");
creator.setContactName(nullToEmpty(req.getContactName()));
creator.setContactMobile(nullToEmpty(req.getContactMobile()));
creator.setContactEmail(nullToEmpty(req.getContactEmail()));
creator.setCountryCode(StringUtils.hasText(req.getCountryCode()) ? req.getCountryCode() : "ID");
creator.setUserId(userId);
creator.setAdminUserId(0L);
creator.setStatus(CreatorService.STATUS_PENDING);
creator.setAuditNote("");
creator.setAuditBy(0L);
creator.setSettlementCurrency("IDR");
creator.setBankAccount("");
creator.setTaxNo("");
creator.setKycStatus("not_submitted");
creator.setShareRate(BigDecimal.ZERO);
try {
creatorMapper.insert(creator);
} catch (DuplicateKeyException ex) {
// 并发重复提交:唯一索引挡住,读回既有记录按幂等处理
Creator raced = findByUserId(userId);
if (raced != null) {
return toResp(raced, false);
}
// 编号撞车(同日序号并发):让调用方重试即可,不产生半条脏数据
log.warn("C 端入驻申请编号冲突:user={} code={}", userId, creator.getCode());
throw new ServiceException(ErrorCode.DATA_EXISTS, "申请提交冲突,请重试");
}
return toResp(creator, true);
}
/** 当前 C 端账号对应的创作者主体(可能为空)。 */
public Creator findByUserId(Long userId) {
if (userId == null) {
return null;
}
return creatorMapper.selectOne(Wrappers.<Creator>lambdaQuery()
.eq(Creator::getUserId, userId)
.last("limit 1"));
}
/**
* 要求当前 C 端账号是**生效中的创作者**,否则按状态给出明确错误(`200007`)。
*
* <p>刻意不返回空列表:客户端要能区分"没有作品"和"你还不是创作者"。
*/
public Creator requireActiveCreator(Long userId) {
Creator creator = findByUserId(userId);
if (creator == null) {
throw new ServiceException(ErrorCode.CREATOR_NOT_ACTIVE, "尚未提交创作者入驻申请");
}
String status = creator.getStatus() == null ? "" : creator.getStatus();
switch (status) {
case CreatorService.STATUS_APPROVED -> {
return creator;
}
case CreatorService.STATUS_PENDING -> throw new ServiceException(ErrorCode.CREATOR_NOT_ACTIVE,
"入驻申请审核中,请耐心等待");
case CreatorService.STATUS_REJECTED -> throw new ServiceException(ErrorCode.CREATOR_NOT_ACTIVE,
"入驻申请未通过:" + (StringUtils.hasText(creator.getAuditNote())
? creator.getAuditNote() : "可联系运营了解原因"));
case CreatorService.STATUS_FROZEN -> throw new ServiceException(ErrorCode.CREATOR_NOT_ACTIVE,
"创作者账号已冻结,请联系运营");
default -> throw new ServiceException(ErrorCode.CREATOR_NOT_ACTIVE,
"当前创作者状态不可用:" + status);
}
}
/** 我的作品(复用管理端创作者中心的查询与 VO 映射,口径一致)。 */
public PageResult<CreatorCenterDramaVO> myWorks(Long userId, CreatorCenterDramaQueryReqVO req) {
Creator creator = requireActiveCreator(userId);
return creatorCenterService.pageMyDramasOf(creator, req);
}
private CreatorApplyRespVO toResp(Creator creator, boolean created) {
CreatorApplyRespVO vo = new CreatorApplyRespVO();
vo.setId(creator.getId());
vo.setCode(creator.getCode());
vo.setName(creator.getName());
vo.setType(creator.getType());
vo.setStatus(creator.getStatus());
vo.setStatusText(CreatorService.statusText(creator.getStatus()));
vo.setAuditNote(creator.getAuditNote());
vo.setAppliedAt(creator.getCreatedAt());
vo.setAuditAt(creator.getAuditAt());
vo.setCreated(created);
return vo;
}
/** 编号格式与后台一致:CR + 日期 + 3 位序号(唯一索引 uk_creator_code 兜底)。 */
private String generateCode() {
Long count = creatorMapper.selectCount(null);
return "CR" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))
+ String.format("%03d", (count == null ? 0 : count + 1));
}
private String nullToEmpty(String value) {
return value == null ? "" : value;
}
}
@@ -171,6 +171,25 @@ public class CreatorCenterService {
return new PageResult<>(result.getTotal(), toDramaVOList(creator.getId(), result.getRecords()));
}
/**
* 指定创作者的作品分页(**C 端用**)。
*
* <p>与管理端 {@link #pageMyDramas} 共用同一套查询与 VO 映射,区别只在"创作者从哪来":
* 管理端由后台账号绑定关系解析,C 端由 `creator.user_id`(C 端账号)解析。
* 之所以单独开一个方法而不是把 creatorId 做成参数往上传:**不接受调用方传归属**,避免横向越权入口。
*/
public PageResult<CreatorCenterDramaVO> pageMyDramasOf(Creator creator,
CreatorCenterDramaQueryReqVO req) {
Page<Drama> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
IPage<Drama> result = dramaMapper.selectPage(page, Wrappers.<Drama>lambdaQuery()
.eq(Drama::getCreatorId, creator.getId())
.like(StringUtils.hasText(req.getKeyword()), Drama::getTitle, req.getKeyword())
.eq(StringUtils.hasText(req.getStatus()), Drama::getStatus, req.getStatus())
.orderByDesc(Drama::getUpdatedAt)
.orderByDesc(Drama::getId));
return new PageResult<>(result.getTotal(), toDramaVOList(creator.getId(), result.getRecords()));
}
public CreatorCenterDramaVO myDramaDetail(Long dramaId) {
Creator creator = requireCurrentCreator();
Drama drama = requireMyDrama(dramaId);
@@ -0,0 +1,33 @@
// Author: fruit.com.cn
package com.fruitshorts.creator.vo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* C 端创作者入驻申请(`POST /app/creator/apply`)。
*
* <p>字段口径与后台录入(`CreatorSaveReqVO`)保持一致;**没有 creatorId / userId** ——
* 归属一律取当前登录的 C 端账号,客户端传不进来(没有横向越权入口)。
*/
@Data
public class CreatorApplyReqVO {
@NotBlank(message = "请填写创作者名称")
@Size(max = 128, message = "创作者名称过长")
private String name;
/** INDIVIDUAL(默认)/ COMPANY。 */
private String type;
private String contactName;
private String contactMobile;
private String contactEmail;
private String countryCode;
/** 必须等于 `/app/config` 下发的当前协议版本。 */
@NotBlank(message = "请先同意最新版协议")
private String agreementVersion;
}
@@ -0,0 +1,27 @@
// Author: fruit.com.cn
package com.fruitshorts.creator.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* C 端入驻申请结果(幂等:无论新建还是已存在,都返回当前状态)。
*/
@Data
public class CreatorApplyRespVO {
private Long id;
private String code;
private String name;
private String type;
private String status;
private String statusText;
private String auditNote;
private LocalDateTime appliedAt;
private LocalDateTime auditAt;
/** true = 本次真的新建了申请;false = 已有记录(幂等返回)。 */
private boolean created;
}