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
+164
@@ -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;
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
|
||||
+33
@@ -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;
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user