refactor(app-api): 按新口径撤除 App 侧创作者中心的 works / stats
为什么:用户口径变更「创作者中心是 web 端,和 app 客户端没关系」,契约已由 PM 提交 bb21920
移除两条 C 端定义;本提交把代码对齐到新契约,App 侧创作者相关只保留入驻申请。
- 删除 GET /app/creator/works、GET /app/creator/stats 两个端点及无用注入
- 删除 CreatorStatsService.java(213 行,与后台 07 看板重复的独立实现)
- 删除随之成为死代码的:CreatorAppService.requireActiveCreator / myWorks、
CreatorCenterService.pageMyDramasOf、ErrorCode.CREATOR_NOT_ACTIVE(200007)
- 删除对应测试 7 条(works/stats 契约 4 条 + requireActive 3 条);
保留 apply 幂等与协议版本校验、config 白名单、notices 空列表
- 不动 POST /app/creator/apply、GET /app/config、GET /app/notices;不改 docs/api-phase1.md
- 实测:mvn test 98 → 91 条全过;package 通过;/v3/api-docs 接口数 41 → 39,
creator 相关只剩 /app/creator/apply;两个已撤除路径对已登录客户端返回 404
This commit is contained in:
1 parent
bb219203bc
commit
4418a840ec
7 files changed
+23
-422
No files matched your search
+3
-37
@@ -3,34 +3,25 @@
|
|||||||
package com.fruitshorts.app.controller;
|
package com.fruitshorts.app.controller;
|
||||||
|
|
||||||
import com.fruitshorts.app.service.AppConfigService;
|
import com.fruitshorts.app.service.AppConfigService;
|
||||||
import com.fruitshorts.app.service.CreatorStatsService;
|
|
||||||
import com.fruitshorts.common.pojo.PageResult;
|
|
||||||
import com.fruitshorts.common.pojo.Result;
|
import com.fruitshorts.common.pojo.Result;
|
||||||
import com.fruitshorts.creator.dal.entity.Creator;
|
|
||||||
import com.fruitshorts.creator.service.CreatorAppService;
|
import com.fruitshorts.creator.service.CreatorAppService;
|
||||||
import com.fruitshorts.creator.vo.CreatorApplyReqVO;
|
import com.fruitshorts.creator.vo.CreatorApplyReqVO;
|
||||||
import com.fruitshorts.creator.vo.CreatorApplyRespVO;
|
import com.fruitshorts.creator.vo.CreatorApplyRespVO;
|
||||||
import com.fruitshorts.creator.vo.CreatorCenterDramaQueryReqVO;
|
|
||||||
import com.fruitshorts.creator.vo.CreatorCenterDramaVO;
|
|
||||||
import com.fruitshorts.framework.security.SecurityUtils;
|
import com.fruitshorts.framework.security.SecurityUtils;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* C 端创作者:入驻申请、我的作品、数据看板。
|
* C 端创作者:**只有入驻申请一条**。
|
||||||
*
|
*
|
||||||
* <p>归属一律取**当前登录的 C 端账号**(`creator.user_id`),接口**不接收 creatorId**;
|
* <p>口径(2026-10-16 变更):**创作者中心(工作台 / 作品管理 / 数据看板)由 Web 端承载**(后端契约 §2.10),
|
||||||
* 非生效创作者(未申请 / 审核中 / 被拒 / 冻结)返回 `200007`,**不返回空数组冒充成功**。
|
* App 客户端不再提供 `works` / `stats`。归属一律取当前登录的 C 端账号,接口**不接收 creatorId**。
|
||||||
*/
|
*/
|
||||||
@Tag(name = "客户端-创作者")
|
@Tag(name = "客户端-创作者")
|
||||||
@RestController
|
@RestController
|
||||||
@@ -39,7 +30,6 @@ import java.util.Map;
|
|||||||
public class AppCreatorController {
|
public class AppCreatorController {
|
||||||
|
|
||||||
private final CreatorAppService creatorAppService;
|
private final CreatorAppService creatorAppService;
|
||||||
private final CreatorStatsService creatorStatsService;
|
|
||||||
private final AppConfigService appConfigService;
|
private final AppConfigService appConfigService;
|
||||||
|
|
||||||
/** 入驻申请:幂等,重复提交返回当前状态(不重复建档)。 */
|
/** 入驻申请:幂等,重复提交返回当前状态(不重复建档)。 */
|
||||||
@@ -49,28 +39,4 @@ public class AppCreatorController {
|
|||||||
return Result.ok(creatorAppService.apply(SecurityUtils.getAppUserId(), req,
|
return Result.ok(creatorAppService.apply(SecurityUtils.getAppUserId(), req,
|
||||||
appConfigService.agreementVersion()));
|
appConfigService.agreementVersion()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 我的作品与审核状态:非生效创作者返回 200007。 */
|
|
||||||
@Operation(summary = "我的作品与审核状态(非生效创作者返回 200007)")
|
|
||||||
@GetMapping("/works")
|
|
||||||
public Result<PageResult<CreatorCenterDramaVO>> works(
|
|
||||||
@RequestParam(defaultValue = "1") Integer pageIndex,
|
|
||||||
@RequestParam(defaultValue = "10") Integer pageSize,
|
|
||||||
@RequestParam(required = false) String keyword,
|
|
||||||
@RequestParam(required = false) String status) {
|
|
||||||
CreatorCenterDramaQueryReqVO query = new CreatorCenterDramaQueryReqVO();
|
|
||||||
query.setPageIndex(pageIndex);
|
|
||||||
query.setPageSize(pageSize);
|
|
||||||
query.setKeyword(keyword);
|
|
||||||
query.setStatus(status);
|
|
||||||
return Result.ok(creatorAppService.myWorks(SecurityUtils.getAppUserId(), query));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 创作者数据看板:与后台 07 看板同口径,只读、仅本人作品。 */
|
|
||||||
@Operation(summary = "创作者数据看板(与后台同口径,只读)")
|
|
||||||
@GetMapping("/stats")
|
|
||||||
public Result<Map<String, Object>> stats() {
|
|
||||||
Creator creator = creatorAppService.requireActiveCreator(SecurityUtils.getAppUserId());
|
|
||||||
return Result.ok(creatorStatsService.overview(creator));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
-213
@@ -1,213 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
package com.fruitshorts.app.service;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
|
||||||
import com.fruitshorts.commerce.dal.entity.CoinLedger;
|
|
||||||
import com.fruitshorts.commerce.dal.entity.Entitlement;
|
|
||||||
import com.fruitshorts.commerce.dal.mapper.CoinLedgerMapper;
|
|
||||||
import com.fruitshorts.commerce.dal.mapper.EntitlementMapper;
|
|
||||||
import com.fruitshorts.content.dal.entity.Drama;
|
|
||||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
|
||||||
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
|
|
||||||
import com.fruitshorts.content.dal.mapper.DramaMapper;
|
|
||||||
import com.fruitshorts.content.enums.ContentStatus;
|
|
||||||
import com.fruitshorts.creator.dal.entity.Creator;
|
|
||||||
import com.fruitshorts.interaction.dal.entity.ContentComment;
|
|
||||||
import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper;
|
|
||||||
import com.fruitshorts.playback.dal.entity.UserFavorite;
|
|
||||||
import com.fruitshorts.playback.dal.entity.WatchHistory;
|
|
||||||
import com.fruitshorts.playback.dal.entity.WatchProgress;
|
|
||||||
import com.fruitshorts.playback.dal.mapper.UserFavoriteMapper;
|
|
||||||
import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper;
|
|
||||||
import com.fruitshorts.playback.dal.mapper.WatchProgressMapper;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.util.StringUtils;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* C 端创作者数据看板(`GET /app/creator/stats`,只读)。
|
|
||||||
*
|
|
||||||
* <p><b>口径必须与后台 07 创作者中心看板一致</b>(`admin-api` 的 `CreatorDashboardService`):
|
|
||||||
* 字段名、指标定义、`null` + `notes` 语义逐条对齐。两份实现在指标定义上完全相同,
|
|
||||||
* 差别只有"创作者从哪来"(C 端按 `creator.user_id`,后台按登录绑定)。
|
|
||||||
*
|
|
||||||
* <p>之所以在 app-api 再实现一份而不是共用:`CreatorDashboardService` 物理上在 admin-api,
|
|
||||||
* 而 app-api 不能依赖 admin-api;把指标实现搬到共享模块需要给冻结模块加 commerce/interaction/playback 依赖,
|
|
||||||
* 属更大的结构改动。**因此这里刻意保持"逻辑同样、注释同样"**,
|
|
||||||
* 并已登记为待收敛项(见 `DEPLOY-CHECKLIST.md` 风险小节与 E1 回执)。
|
|
||||||
*
|
|
||||||
* <p>指标口径(与后台一致,前端 tooltip 可直接复用):
|
|
||||||
* <ul>
|
|
||||||
* <li>作品:按 `drama.status` 计数;`episodeTotal` / `episodeWithMedia`(`video_url` 非空)是当前快照。</li>
|
|
||||||
* <li>播放:`viewTotal` / `playTotal` = 短剧表累计计数列求和(不限时间);
|
|
||||||
* `watchedDramas` / `finishedDramas` = `watch_progress` 行数(唯一索引 user_id + drama_id,**用户×剧**口径);
|
|
||||||
* `playEvents7d` / `playEventsTotal` = `watch_history` 流水口径;`finishEvents` = 流水中 `position_ms >= duration_ms`。
|
|
||||||
* 三种口径维度不同,**不可相加**。</li>
|
|
||||||
* <li>互动:`favoriteTotal` / `commentTotal` / `commentPending` 均按本人作品 id 集合统计。</li>
|
|
||||||
* <li>收入:`unlockCount` = `entitlement` 中 `source=ORDER`;`coinIncome` / `coinIncome7d` = `coin_ledger`
|
|
||||||
* `biz_type=UNLOCK` 命中本人分集的扣币绝对值;`paidOrderAmount` **恒为 null**(金币解锁不产生法币订单)。</li>
|
|
||||||
* <li>`daily` **恒为 null**:`creator_stat_daily` 表已建但没有产出任务。</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class CreatorStatsService {
|
|
||||||
|
|
||||||
private static final int RECENT_DAYS = 7;
|
|
||||||
|
|
||||||
private final DramaMapper dramaMapper;
|
|
||||||
private final DramaEpisodeMapper dramaEpisodeMapper;
|
|
||||||
private final WatchProgressMapper watchProgressMapper;
|
|
||||||
private final WatchHistoryMapper watchHistoryMapper;
|
|
||||||
private final UserFavoriteMapper userFavoriteMapper;
|
|
||||||
private final ContentCommentMapper commentMapper;
|
|
||||||
private final EntitlementMapper entitlementMapper;
|
|
||||||
private final CoinLedgerMapper coinLedgerMapper;
|
|
||||||
|
|
||||||
/** 看板数据;调用方先做"是否生效创作者"的校验。 */
|
|
||||||
public Map<String, Object> overview(Creator creator) {
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
|
||||||
LocalDateTime sevenDaysAgo = now.minusDays(RECENT_DAYS);
|
|
||||||
|
|
||||||
List<Drama> dramas = dramaMapper.selectList(Wrappers.<Drama>lambdaQuery()
|
|
||||||
.eq(Drama::getCreatorId, creator.getId())
|
|
||||||
.orderByDesc(Drama::getId));
|
|
||||||
List<Long> dramaIds = dramas.stream().map(Drama::getId).toList();
|
|
||||||
List<DramaEpisode> episodes = dramaIds.isEmpty() ? List.of()
|
|
||||||
: dramaEpisodeMapper.selectList(Wrappers.<DramaEpisode>lambdaQuery()
|
|
||||||
.in(DramaEpisode::getDramaId, dramaIds)
|
|
||||||
.orderByAsc(DramaEpisode::getDramaId)
|
|
||||||
.orderByAsc(DramaEpisode::getEpNo));
|
|
||||||
List<Long> episodeIds = episodes.stream().map(DramaEpisode::getId).toList();
|
|
||||||
|
|
||||||
Map<String, Object> data = new LinkedHashMap<>();
|
|
||||||
data.put("generatedAt", now);
|
|
||||||
data.put("drama", dramaMetrics(dramas, episodes));
|
|
||||||
data.put("playback", playbackMetrics(dramas, dramaIds, sevenDaysAgo));
|
|
||||||
data.put("interaction", interactionMetrics(dramaIds));
|
|
||||||
data.put("revenue", revenueMetrics(dramaIds, episodeIds, sevenDaysAgo));
|
|
||||||
// 按天趋势没有数据源:creator_stat_daily 表已建但无产出,如实给 null
|
|
||||||
data.put("daily", null);
|
|
||||||
data.put("notes", notes());
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> dramaMetrics(List<Drama> dramas, List<DramaEpisode> episodes) {
|
|
||||||
Map<String, Object> drama = new LinkedHashMap<>();
|
|
||||||
drama.put("total", dramas.size());
|
|
||||||
drama.put("draft", countStatus(dramas, ContentStatus.DRAFT));
|
|
||||||
drama.put("reviewing", countStatus(dramas, ContentStatus.REVIEWING));
|
|
||||||
drama.put("approved", countStatus(dramas, ContentStatus.APPROVED));
|
|
||||||
drama.put("published", countStatus(dramas, ContentStatus.PUBLISHED));
|
|
||||||
drama.put("offline", countStatus(dramas, ContentStatus.OFFLINE));
|
|
||||||
drama.put("episodeTotal", episodes.size());
|
|
||||||
drama.put("episodeWithMedia", episodes.stream()
|
|
||||||
.filter(episode -> StringUtils.hasText(episode.getVideoUrl()))
|
|
||||||
.count());
|
|
||||||
return drama;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> playbackMetrics(List<Drama> dramas, List<Long> dramaIds,
|
|
||||||
LocalDateTime sevenDaysAgo) {
|
|
||||||
Map<String, Object> playback = new LinkedHashMap<>();
|
|
||||||
playback.put("viewTotal", dramas.stream()
|
|
||||||
.mapToLong(drama -> drama.getViewCount() == null ? 0L : drama.getViewCount()).sum());
|
|
||||||
playback.put("playTotal", dramas.stream()
|
|
||||||
.mapToLong(drama -> drama.getPlayCount() == null ? 0L : drama.getPlayCount()).sum());
|
|
||||||
|
|
||||||
long watched = dramaIds.isEmpty() ? 0L
|
|
||||||
: orZero(watchProgressMapper.selectCount(Wrappers.<WatchProgress>lambdaQuery()
|
|
||||||
.in(WatchProgress::getDramaId, dramaIds)));
|
|
||||||
long finished = dramaIds.isEmpty() ? 0L
|
|
||||||
: orZero(watchProgressMapper.selectCount(Wrappers.<WatchProgress>lambdaQuery()
|
|
||||||
.in(WatchProgress::getDramaId, dramaIds)
|
|
||||||
.eq(WatchProgress::getFinished, 1)));
|
|
||||||
playback.put("watchedDramas", watched);
|
|
||||||
playback.put("finishedDramas", finished);
|
|
||||||
playback.put("finishRate", watched == 0 ? 0d : Math.round(finished * 1000.0 / watched) / 1000.0);
|
|
||||||
|
|
||||||
playback.put("playEvents7d", dramaIds.isEmpty() ? 0L
|
|
||||||
: orZero(watchHistoryMapper.selectCount(Wrappers.<WatchHistory>lambdaQuery()
|
|
||||||
.in(WatchHistory::getDramaId, dramaIds)
|
|
||||||
.ge(WatchHistory::getWatchedAt, sevenDaysAgo))));
|
|
||||||
playback.put("playEventsTotal", dramaIds.isEmpty() ? 0L
|
|
||||||
: orZero(watchHistoryMapper.selectCount(Wrappers.<WatchHistory>lambdaQuery()
|
|
||||||
.in(WatchHistory::getDramaId, dramaIds))));
|
|
||||||
playback.put("finishEvents", dramaIds.isEmpty() ? 0L
|
|
||||||
: orZero(watchHistoryMapper.selectCount(Wrappers.<WatchHistory>lambdaQuery()
|
|
||||||
.in(WatchHistory::getDramaId, dramaIds)
|
|
||||||
.gt(WatchHistory::getDurationMs, 0L)
|
|
||||||
.apply("position_ms >= duration_ms"))));
|
|
||||||
return playback;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> interactionMetrics(List<Long> dramaIds) {
|
|
||||||
Map<String, Object> interaction = new LinkedHashMap<>();
|
|
||||||
interaction.put("favoriteTotal", dramaIds.isEmpty() ? 0L
|
|
||||||
: orZero(userFavoriteMapper.selectCount(Wrappers.<UserFavorite>lambdaQuery()
|
|
||||||
.in(UserFavorite::getDramaId, dramaIds))));
|
|
||||||
interaction.put("commentTotal", dramaIds.isEmpty() ? 0L
|
|
||||||
: orZero(commentMapper.selectCount(Wrappers.<ContentComment>lambdaQuery()
|
|
||||||
.in(ContentComment::getDramaId, dramaIds))));
|
|
||||||
interaction.put("commentPending", dramaIds.isEmpty() ? 0L
|
|
||||||
: orZero(commentMapper.selectCount(Wrappers.<ContentComment>lambdaQuery()
|
|
||||||
.in(ContentComment::getDramaId, dramaIds)
|
|
||||||
.eq(ContentComment::getStatus, "pending"))));
|
|
||||||
return interaction;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> revenueMetrics(List<Long> dramaIds, List<Long> episodeIds,
|
|
||||||
LocalDateTime sevenDaysAgo) {
|
|
||||||
Map<String, Object> revenue = new LinkedHashMap<>();
|
|
||||||
revenue.put("unlockCount", dramaIds.isEmpty() ? 0L
|
|
||||||
: orZero(entitlementMapper.selectCount(Wrappers.<Entitlement>lambdaQuery()
|
|
||||||
.in(Entitlement::getDramaId, dramaIds)
|
|
||||||
.eq(Entitlement::getSource, "ORDER"))));
|
|
||||||
|
|
||||||
List<String> episodeIdText = episodeIds.stream().map(String::valueOf).toList();
|
|
||||||
revenue.put("coinIncome", episodeIdText.isEmpty() ? 0L : sumUnlockCoin(episodeIdText, null));
|
|
||||||
revenue.put("coinIncome7d", episodeIdText.isEmpty()
|
|
||||||
? 0L : sumUnlockCoin(episodeIdText, sevenDaysAgo));
|
|
||||||
// 金币解锁不产生法币订单;真实收入金额要等 Apple/Google 商店接入,这里如实给 null
|
|
||||||
revenue.put("paidOrderAmount", null);
|
|
||||||
return revenue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 解锁扣币记在 coin_ledger:biz_type=UNLOCK、biz_id=剧集 id、金额为负,取绝对值即收入。 */
|
|
||||||
private long sumUnlockCoin(List<String> episodeIdText, LocalDateTime since) {
|
|
||||||
List<CoinLedger> ledger = coinLedgerMapper.selectList(Wrappers.<CoinLedger>lambdaQuery()
|
|
||||||
.eq(CoinLedger::getBizType, "UNLOCK")
|
|
||||||
.in(CoinLedger::getBizId, episodeIdText)
|
|
||||||
.ge(since != null, CoinLedger::getCreatedAt, since));
|
|
||||||
return ledger.stream()
|
|
||||||
.mapToLong(item -> item.getChangeAmount() == null ? 0L : -item.getChangeAmount())
|
|
||||||
.sum();
|
|
||||||
}
|
|
||||||
|
|
||||||
private long countStatus(List<Drama> dramas, ContentStatus status) {
|
|
||||||
return dramas.stream().filter(drama -> status.getCode().equals(drama.getStatus())).count();
|
|
||||||
}
|
|
||||||
|
|
||||||
private long orZero(Long value) {
|
|
||||||
return value == null ? 0L : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** notes 文案与后台看板逐条一致(口径说明,前端 tooltip 复用)。 */
|
|
||||||
private List<String> notes() {
|
|
||||||
return List.of(
|
|
||||||
"viewTotal / playTotal 是短剧表上的累计计数(不限时间),与 playEvents7d / playEventsTotal 的流水口径不可相加",
|
|
||||||
"watchedDramas / finishedDramas 取自 watch_progress —— 该表唯一索引是 (user_id, drama_id),"
|
|
||||||
+ "每用户每剧只有一条续看进度,所以口径是「用户 × 剧」不是按集;finishRate = 后者 / 前者",
|
|
||||||
"finishEvents 是 watch_history 中 position_ms >= duration_ms 的完播事件数(流水口径,与上面两种口径维度不同)",
|
|
||||||
"coinIncome 取 coin_ledger 中 biz_type=UNLOCK 且命中本人分集的扣币合计;paidOrderAmount 需接入 Apple/Google 商店才有数据,当前为 null",
|
|
||||||
"daily(按天趋势)暂无数据源:creator_stat_daily 表已建但没有产出任务,需要按天聚合 watch_history / entitlement 的定时任务,当前阶段未接入",
|
|
||||||
"统计范围仅限本人作品(drama.creator_id = 当前创作者),不含他人数据"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-62
@@ -4,7 +4,6 @@ package com.fruitshorts.app.controller;
|
|||||||
|
|
||||||
import com.fruitshorts.app.service.AppConfigService;
|
import com.fruitshorts.app.service.AppConfigService;
|
||||||
import com.fruitshorts.app.service.AppNoticeService;
|
import com.fruitshorts.app.service.AppNoticeService;
|
||||||
import com.fruitshorts.app.service.CreatorStatsService;
|
|
||||||
import com.fruitshorts.app.support.ControllerContractSupport;
|
import com.fruitshorts.app.support.ControllerContractSupport;
|
||||||
import com.fruitshorts.common.exception.ErrorCode;
|
import com.fruitshorts.common.exception.ErrorCode;
|
||||||
import com.fruitshorts.common.exception.ServiceException;
|
import com.fruitshorts.common.exception.ServiceException;
|
||||||
@@ -55,7 +54,6 @@ class AppCreatorAndConfigContractTest {
|
|||||||
private AppConfigService appConfigService;
|
private AppConfigService appConfigService;
|
||||||
private AppNoticeService appNoticeService;
|
private AppNoticeService appNoticeService;
|
||||||
private CreatorAppService creatorAppService;
|
private CreatorAppService creatorAppService;
|
||||||
private CreatorStatsService creatorStatsService;
|
|
||||||
private MockMvc configMvc;
|
private MockMvc configMvc;
|
||||||
private MockMvc creatorMvc;
|
private MockMvc creatorMvc;
|
||||||
|
|
||||||
@@ -64,11 +62,10 @@ class AppCreatorAndConfigContractTest {
|
|||||||
appConfigService = mock(AppConfigService.class);
|
appConfigService = mock(AppConfigService.class);
|
||||||
appNoticeService = mock(AppNoticeService.class);
|
appNoticeService = mock(AppNoticeService.class);
|
||||||
creatorAppService = mock(CreatorAppService.class);
|
creatorAppService = mock(CreatorAppService.class);
|
||||||
creatorStatsService = mock(CreatorStatsService.class);
|
|
||||||
configMvc = ControllerContractSupport.mockMvcOf(
|
configMvc = ControllerContractSupport.mockMvcOf(
|
||||||
new AppConfigController(appConfigService, appNoticeService));
|
new AppConfigController(appConfigService, appNoticeService));
|
||||||
creatorMvc = ControllerContractSupport.mockMvcOf(new AppCreatorController(
|
creatorMvc = ControllerContractSupport.mockMvcOf(new AppCreatorController(
|
||||||
creatorAppService, creatorStatsService, appConfigService));
|
creatorAppService, appConfigService));
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
@@ -155,67 +152,9 @@ class AppCreatorAndConfigContractTest {
|
|||||||
.andExpect(jsonPath("$.data.created").value(false));
|
.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) {
|
private CreatorApplyRespVO applyResp(Long id, String status, boolean created) {
|
||||||
CreatorApplyRespVO vo = new CreatorApplyRespVO();
|
CreatorApplyRespVO vo = new CreatorApplyRespVO();
|
||||||
|
|||||||
+12
-44
@@ -9,7 +9,6 @@ import com.fruitshorts.common.exception.ServiceException;
|
|||||||
import com.fruitshorts.creator.dal.entity.Creator;
|
import com.fruitshorts.creator.dal.entity.Creator;
|
||||||
import com.fruitshorts.creator.dal.mapper.CreatorMapper;
|
import com.fruitshorts.creator.dal.mapper.CreatorMapper;
|
||||||
import com.fruitshorts.creator.service.CreatorAppService;
|
import com.fruitshorts.creator.service.CreatorAppService;
|
||||||
import com.fruitshorts.creator.service.CreatorCenterService;
|
|
||||||
import com.fruitshorts.creator.service.CreatorService;
|
import com.fruitshorts.creator.service.CreatorService;
|
||||||
import com.fruitshorts.creator.vo.CreatorApplyReqVO;
|
import com.fruitshorts.creator.vo.CreatorApplyReqVO;
|
||||||
import com.fruitshorts.creator.vo.CreatorApplyRespVO;
|
import com.fruitshorts.creator.vo.CreatorApplyRespVO;
|
||||||
@@ -52,7 +51,7 @@ class CreatorAppServiceTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
creatorMapper = mock(CreatorMapper.class);
|
creatorMapper = mock(CreatorMapper.class);
|
||||||
creatorAppService = new CreatorAppService(creatorMapper, mock(CreatorCenterService.class));
|
creatorAppService = new CreatorAppService(creatorMapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -98,51 +97,9 @@ class CreatorAppServiceTest {
|
|||||||
assertThat(resp.getStatus()).isEqualTo(CreatorService.STATUS_PENDING);
|
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() {
|
private AtomicReference<Creator> captureInsert() {
|
||||||
AtomicReference<Creator> saved = new AtomicReference<>();
|
AtomicReference<Creator> saved = new AtomicReference<>();
|
||||||
@@ -155,6 +112,17 @@ class CreatorAppServiceTest {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_reject_when_agreement_version_is_not_the_current_one() {
|
||||||
|
// 协议版本必须等于 /app/config 下发的当前版本(保留用例:apply 的协议校验)
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
private CreatorApplyReqVO req(String agreementVersion) {
|
private CreatorApplyReqVO req(String agreementVersion) {
|
||||||
CreatorApplyReqVO req = new CreatorApplyReqVO();
|
CreatorApplyReqVO req = new CreatorApplyReqVO();
|
||||||
req.setName("星野工作室");
|
req.setName("星野工作室");
|
||||||
|
|||||||
@@ -37,13 +37,7 @@ public enum ErrorCode {
|
|||||||
*/
|
*/
|
||||||
CREATOR_NOT_BOUND(200004, "当前账号未绑定创作者主体"),
|
CREATOR_NOT_BOUND(200004, "当前账号未绑定创作者主体"),
|
||||||
CREATOR_TARGET_REQUIRED(200005, "请先选择要代看的创作者"),
|
CREATOR_TARGET_REQUIRED(200005, "请先选择要代看的创作者"),
|
||||||
CREATOR_IMPERSONATE_READONLY(200006, "代看模式下只读,不能修改创作者数据"),
|
CREATOR_IMPERSONATE_READONLY(200006, "代看模式下只读,不能修改创作者数据");
|
||||||
|
|
||||||
/**
|
|
||||||
* C 端专用:当前登录账号「不是生效中的创作者」(未申请 / 审核中 / 被驳回 / 已冻结)。
|
|
||||||
* 与后台的 {@link #CREATOR_NOT_BOUND} 区分开 —— 客户端要据此决定"引导去申请"还是"提示等待审核"。
|
|
||||||
*/
|
|
||||||
CREATOR_NOT_ACTIVE(200007, "当前账号不是生效中的创作者");
|
|
||||||
|
|
||||||
private final int code;
|
private final int code;
|
||||||
private final String message;
|
private final String message;
|
||||||
|
|||||||
+6
-40
@@ -5,13 +5,10 @@ package com.fruitshorts.creator.service;
|
|||||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
import com.fruitshorts.common.exception.ErrorCode;
|
import com.fruitshorts.common.exception.ErrorCode;
|
||||||
import com.fruitshorts.common.exception.ServiceException;
|
import com.fruitshorts.common.exception.ServiceException;
|
||||||
import com.fruitshorts.common.pojo.PageResult;
|
|
||||||
import com.fruitshorts.creator.dal.entity.Creator;
|
import com.fruitshorts.creator.dal.entity.Creator;
|
||||||
import com.fruitshorts.creator.dal.mapper.CreatorMapper;
|
import com.fruitshorts.creator.dal.mapper.CreatorMapper;
|
||||||
import com.fruitshorts.creator.vo.CreatorApplyReqVO;
|
import com.fruitshorts.creator.vo.CreatorApplyReqVO;
|
||||||
import com.fruitshorts.creator.vo.CreatorApplyRespVO;
|
import com.fruitshorts.creator.vo.CreatorApplyRespVO;
|
||||||
import com.fruitshorts.creator.vo.CreatorCenterDramaQueryReqVO;
|
|
||||||
import com.fruitshorts.creator.vo.CreatorCenterDramaVO;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
@@ -24,10 +21,13 @@ import java.time.LocalDate;
|
|||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* C 端创作者入驻与作品(`/app/creator/**`)。
|
* C 端创作者入驻(`POST /app/creator/apply`)。
|
||||||
*
|
*
|
||||||
* <p>与管理端创作者中心的关键区别:**归属按 `creator.user_id`(C 端账号)解析**,
|
* <p>口径(2026-10-16 变更):**创作者中心由 Web 端承载**,App 侧只保留入驻申请 ——
|
||||||
* 而管理端按 `creator.admin_user_id`(后台账号绑定)。两条链路互不影响,
|
* 原来的"我的作品 / 数据看板"两条 C 端接口已按新契约撤除。
|
||||||
|
*
|
||||||
|
* <p>与管理端的关键区别:**归属按 `creator.user_id`(C 端账号)解析**,
|
||||||
|
* 管理端按 `creator.admin_user_id`(后台账号绑定)。两条链路互不影响,
|
||||||
* 这里**没有改动**管理端的入驻审核 / 开号 / 作品管理逻辑。
|
* 这里**没有改动**管理端的入驻审核 / 开号 / 作品管理逻辑。
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -36,7 +36,6 @@ import java.time.format.DateTimeFormatter;
|
|||||||
public class CreatorAppService {
|
public class CreatorAppService {
|
||||||
|
|
||||||
private final CreatorMapper creatorMapper;
|
private final CreatorMapper creatorMapper;
|
||||||
private final CreatorCenterService creatorCenterService;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 入驻申请(**幂等**)。
|
* 入驻申请(**幂等**)。
|
||||||
@@ -103,39 +102,6 @@ public class CreatorAppService {
|
|||||||
.last("limit 1"));
|
.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) {
|
private CreatorApplyRespVO toResp(Creator creator, boolean created) {
|
||||||
CreatorApplyRespVO vo = new CreatorApplyRespVO();
|
CreatorApplyRespVO vo = new CreatorApplyRespVO();
|
||||||
vo.setId(creator.getId());
|
vo.setId(creator.getId());
|
||||||
|
|||||||
-19
@@ -171,25 +171,6 @@ public class CreatorCenterService {
|
|||||||
return new PageResult<>(result.getTotal(), toDramaVOList(creator.getId(), result.getRecords()));
|
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) {
|
public CreatorCenterDramaVO myDramaDetail(Long dramaId) {
|
||||||
Creator creator = requireCurrentCreator();
|
Creator creator = requireCurrentCreator();
|
||||||
Drama drama = requireMyDrama(dramaId);
|
Drama drama = requireMyDrama(dramaId);
|
||||||
|
|||||||
Reference in new issue
Block a user