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

+126 -10
View File
@@ -260,18 +260,134 @@ B1-2 的退款/拒付复验依赖它。服务端凭据(`packageName` + service
服务端按 `(platform, storeTransactionId)` 识别"权益已发过",**返回既有订单、不重复发币**;
若商店侧有购买而本地没有订单(例如换机前没同步),会**按校验结果补单落库**再发放,不需要人工介入。
### 1.9 创作者(下一批)
### 1.9 创作者(C 端,已实现)
| 方法 | 路径 | 说明 |
> 说明:本节原来把 `/app/orders`、`/app/wallet`、`/app/episodes/{id}/unlock` 也列在这里,
> 那三条**属「金币与订单」,已在 §1.8 实现并冻结**,因此已从本节移出(本节只讲创作者 + 启动配置 + 公告)。
| 方法 | 路径 | 鉴权 | 说明 |
| --- | --- | --- | --- |
| POST | `/app/creator/apply` | 登录 | 创作者入驻申请(**幂等**:重复提交返回当前状态,不重复建档) |
| GET | `/app/creator/works` | 登录 | 我的作品与审核状态(**非生效创作者直接报错**,不返回空列表) |
| GET | `/app/creator/stats` | 登录 | 创作者数据看板(与后台 07 看板同口径,只读、仅本人作品) |
| GET | `/app/config` | **匿名** | 启动配置(协议版本、最低版本、开关;**只下发白名单键**) |
| GET | `/app/notices` | **匿名** | 系统公告(无数据返回空列表,不是 404) |
#### 1.9.1 `GET /app/config`(匿名)
客户端**登录前**就要拿,用于:协议版本校验、强制更新判断、功能开关。
```json
{
"code": 0, "message": "ok",
"data": {
"agreementVersion": "2026-10-01",
"minAppVersion": "1.0.0",
"forceUpdate": false,
"noticeEnabled": true,
"coinUnlockEnabled": true,
"downloadEnabled": true
}
}
```
| 键(对客户端字段) | 后台配置键 | 契约默认值 | 说明 |
| --- | --- | --- | --- |
| `agreementVersion` | `app.agreement.version` | `2026-10-01` | 当前协议版本;`/app/creator/apply` 必须与它一致 |
| `minAppVersion` | `app.min.version` | `1.0.0` | 低于它客户端应提示升级 |
| `forceUpdate` | `app.force.update` | `false` | 是否强制更新 |
| `noticeEnabled` | `app.notice.enabled` | `true` | 公告入口开关 |
| `coinUnlockEnabled` | `app.coin.unlock.enabled` | `true` | 金币解锁开关 |
| `downloadEnabled` | `app.download.enabled` | `true` | 离线下载开关 |
**口径**:**只下发上述白名单键**(服务端硬编码白名单),后台配置里的其它键(可能含内部开关)
**一律不下发**;库里缺键时用「契约默认值」兜底,**不返回 null 让客户端自己猜默认值**。
#### 1.9.2 `GET /app/notices`(匿名)
```
GET /app/notices?pageIndex=1&pageSize=20
```
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `pageIndex` | int | 否 | 默认 1 |
| `pageSize` | int | 否 | 默认 20,最大 50 |
```json
{
"code": 0, "message": "ok",
"data": {
"total": 1,
"list": [
{ "id": 2110123456789, "title": "开服公告", "content": "…", "type": "SYSTEM",
"publishAt": "2026-10-16 10:00:00", "expireAt": null }
]
}
}
```
**口径**:只返回 `status=1`(启用)且 `target ∈ {ALL, USER}`、`publish_at <= 现在`、
`expire_at` 为空或未过期的公告;按 `publish_at desc, id desc` 排序。
**表里没有数据时返回 `total=0, list=[]`(HTTP 与 code 都是成功),不是 404。**
#### 1.9.3 `POST /app/creator/apply`(登录)
```json
{
"name": "星野工作室", "type": "COMPANY",
"contactName": "张三", "contactMobile": "08123456789", "contactEmail": "creator@example.com",
"countryCode": "ID", "agreementVersion": "2026-10-01"
}
```
| 字段 | 必填 | 校验 |
| --- | --- | --- |
| GET | `/app/orders` | 我的订单 |
| GET | `/app/wallet` | 金币余额与流水 |
| POST | `/app/episodes/{id}/unlock` | 金币解锁分集 |
| POST | `/app/creator/apply` | 创作者入驻 |
| GET | `/app/creator/works` | 我的作品与审核状态 |
| GET | `/app/creator/stats` | 创作者数据看板 |
| GET | `/app/config` | 启动配置(协议版本、开关、最低版本) |
| GET | `/app/notices` | 系统公告 |
| `name` | ✅ | 1–128 字 |
| `type` | 否 | `INDIVIDUAL`(默认)/ `COMPANY` |
| `contactName` / `contactMobile` / `contactEmail` | 否 | 只是联系方式,不参与鉴权 |
| `countryCode` | 否 | 默认 `ID` |
| `agreementVersion` | ✅ | **必须等于 `/app/config` 的 `agreementVersion`**,否则 `400` |
**幂等口径(硬要求)**:以「**C 端账号唯一**」防重(DB 唯一索引 `uk_creator_user(user_id) where user_id > 0`):
| 情况 | 行为 |
| --- | --- |
| 首次申请 | 建档,`status=pending`,返回 `{id, code, status, statusText, auditNote, appliedAt}` |
| 已申请(pending) | **返回当前状态**,不重复建档、不改动已有记录 |
| 已通过(approved) | 返回 `status=approved`(客户端据此直接进创作者中心) |
| 被驳回 / 冻结 | 返回当前状态与 `auditNote`(是否允许重新申请由运营在后台处理,本期不做二次申请) |
| 并发重复提交 | 唯一索引兜底:捕获冲突后**读回已有记录返回**,不报 500 |
#### 1.9.4 `GET /app/creator/works`(登录)
```
GET /app/creator/works?pageIndex=1&pageSize=10&keyword=&status=
```
分页与筛选口径**与管理端 `/admin/creator-center/dramas/page` 完全一致**(同一套查询与 VO 形状:
`id/code/title/subtitle/genre/description/cover/trailerUrl/language/status/statusText/episodes/views/updatedAt`)。
**归属与鉴权**:`creator.user_id = 当前登录的 C 端账号`,**接口不接收 creatorId**;
所有查询都收敛到"我的作品",因此天然不会返回他人作品。
**非生效创作者必须报错(不返回空数组)**:
| 状态 | `code` | 提示 |
| --- | --- | --- |
| 未申请 | `200007` | 尚未提交创作者入驻申请 |
| `pending` | `200007` | 入驻申请审核中,请耐心等待 |
| `rejected` | `200007` | 入驻申请未通过:{auditNote} |
| `frozen` | `200007` | 创作者账号已冻结,请联系运营 |
> `200007 CREATOR_NOT_ACTIVE`:C 端专用「当前账号不是生效中的创作者」。
> 与后台的 `200004 CREATOR_NOT_BOUND`(当前账号未绑定创作者主体)区分开,客户端据此决定"引导申请"还是"提示等待"。
#### 1.9.5 `GET /app/creator/stats`(登录)
**指标口径与后台 07 创作者中心数据看板完全一致**(实现上共用同一份计算:`CreatorDashboardQueryService`),
字段与语义见 §2.7 创作者数据看板说明;**只读、且只统计本人作品**。
非生效创作者同样返回 `200007`。
## 2. 后台管理接口(/admin/**)
+10
View File
@@ -41,6 +41,16 @@
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-commerce</artifactId>
</dependency>
<!-- C 端创作者入驻/我的作品(module-creator 只读复用,未改其既有行为) -->
<dependency>
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-creator</artifactId>
</dependency>
<!-- 启动配置白名单读 sys_config、系统公告读 ops_notice -->
<dependency>
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-system</artifactId>
</dependency>
<!-- 契约测试用:MockMvc + Mockito + JUnit5,test scope,不进运行态依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -30,6 +30,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
@Tag(name = "客户端-收藏", description = "收藏切换与列表"),
@Tag(name = "客户端-点赞与举报", description = "点赞与举报提报"),
@Tag(name = "客户端-个人中心", description = "资料、设备、数据导出与注销"),
@Tag(name = "客户端-创作者", description = "创作者入驻申请、我的作品与数据看板(C 端账号)"),
@Tag(name = "客户端-启动配置与公告", description = "启动配置(白名单键)与系统公告,均匿名可访问"),
@Tag(name = "客户端-观看进度", description = "观看进度上报与续看位置"),
}
)
@@ -0,0 +1,53 @@
// 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.vo.AppNoticeVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 启动配置与系统公告:**两个匿名接口**(客户端登录前就要拿到)。
*
* <p>放在一起是因为它们同属"进入 App 前的公共数据",且都不依赖登录态。
*/
@Tag(name = "客户端-启动配置与公告")
@RestController
@RequestMapping("/app")
@RequiredArgsConstructor
public class AppConfigController {
private final AppConfigService appConfigService;
private final AppNoticeService appNoticeService;
/**
* 启动配置:协议版本 / 最低版本 / 功能开关。
* **只下发白名单键**,库里缺键用契约默认值兜底。
*/
@Operation(summary = "启动配置(匿名,只下发白名单键)")
@GetMapping("/config")
public Result<Map<String, Object>> config() {
return Result.ok(appConfigService.appConfig());
}
/** 系统公告:无数据返回空列表(不是 404)。 */
@Operation(summary = "系统公告(匿名,无数据返回空列表)")
@GetMapping("/notices")
public Result<PageResult<AppNoticeVO>> notices(
@RequestParam(defaultValue = "1") Integer pageIndex,
@RequestParam(defaultValue = "20") Integer pageSize) {
return Result.ok(appNoticeService.page(pageIndex == null ? 1 : pageIndex,
pageSize == null ? 20 : pageSize));
}
}
@@ -0,0 +1,76 @@
// Author: fruit.com.cn
package com.fruitshorts.app.controller;
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.creator.dal.entity.Creator;
import com.fruitshorts.creator.service.CreatorAppService;
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 com.fruitshorts.framework.security.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* C 端创作者:入驻申请、我的作品、数据看板。
*
* <p>归属一律取**当前登录的 C 端账号**(`creator.user_id`),接口**不接收 creatorId**;
* 非生效创作者(未申请 / 审核中 / 被拒 / 冻结)返回 `200007`,**不返回空数组冒充成功**。
*/
@Tag(name = "客户端-创作者")
@RestController
@RequestMapping("/app/creator")
@RequiredArgsConstructor
public class AppCreatorController {
private final CreatorAppService creatorAppService;
private final CreatorStatsService creatorStatsService;
private final AppConfigService appConfigService;
/** 入驻申请:幂等,重复提交返回当前状态(不重复建档)。 */
@Operation(summary = "创作者入驻申请(幂等,重复提交返回当前状态)")
@PostMapping("/apply")
public Result<CreatorApplyRespVO> apply(@Valid @RequestBody CreatorApplyReqVO req) {
return Result.ok(creatorAppService.apply(SecurityUtils.getAppUserId(), req,
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));
}
}
@@ -0,0 +1,76 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.system.dal.entity.SysConfig;
import com.fruitshorts.system.dal.mapper.SysConfigMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* C 端启动配置(`GET /app/config`,匿名)。
*
* <p><b>只下发白名单键</b>:`sys_config` 是后台配置表,里面可能有内部开关,
* 整表下发等于把内部配置暴露给客户端 —— 所以这里用**硬编码白名单**({@link #KEYS}),
* 且库里缺键时用**契约默认值**兜底(契约见 `docs/api-phase1.md` §1.9.1),
* 不返回 null 让客户端自己猜默认值。
*/
@Service
@RequiredArgsConstructor
public class AppConfigService {
/** 白名单:契约里的 6 个键 → 后台配置键 + 默认值。顺序即响应字段顺序。 */
private static final Map<String, KeySpec> KEYS = new LinkedHashMap<>();
static {
KEYS.put("agreementVersion", new KeySpec("app.agreement.version", "2026-10-01"));
KEYS.put("minAppVersion", new KeySpec("app.min.version", "1.0.0"));
KEYS.put("forceUpdate", new KeySpec("app.force.update", "false"));
KEYS.put("noticeEnabled", new KeySpec("app.notice.enabled", "true"));
KEYS.put("coinUnlockEnabled", new KeySpec("app.coin.unlock.enabled", "true"));
KEYS.put("downloadEnabled", new KeySpec("app.download.enabled", "true"));
}
private final SysConfigMapper sysConfigMapper;
/** 当前协议版本(`/app/creator/apply` 用它校验客户端提交的版本)。 */
public String agreementVersion() {
return valueOf("app.agreement.version", KEYS.get("agreementVersion").defaultValue());
}
/** 只含白名单键的配置;布尔项按布尔返回,避免客户端到处写字符串比较。 */
public Map<String, Object> appConfig() {
Map<String, Object> data = new LinkedHashMap<>();
KEYS.forEach((field, spec) -> {
String raw = valueOf(spec.configKey(), spec.defaultValue());
data.put(field, isBooleanField(field) ? Boolean.parseBoolean(raw) : raw);
});
return data;
}
/** 只查这一个键(不 select *):即使表里有别的键也不会被读出来。 */
private String valueOf(String configKey, String fallback) {
List<SysConfig> rows = sysConfigMapper.selectList(Wrappers.<SysConfig>lambdaQuery()
.eq(SysConfig::getConfigKey, configKey)
.last("limit 1"));
if (rows.isEmpty() || !StringUtils.hasText(rows.get(0).getConfigValue())) {
return fallback;
}
return rows.get(0).getConfigValue().trim();
}
private boolean isBooleanField(String field) {
return "forceUpdate".equals(field) || "noticeEnabled".equals(field)
|| "coinUnlockEnabled".equals(field) || "downloadEnabled".equals(field);
}
/** 一个白名单项:后台键 + 契约默认值。 */
private record KeySpec(String configKey, String defaultValue) {
}
}
@@ -0,0 +1,59 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.app.vo.AppNoticeVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.system.dal.entity.OpsNotice;
import com.fruitshorts.system.dal.mapper.OpsNoticeMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
/**
* C 端系统公告(`GET /app/notices`,匿名,只读)。
*
* <p>口径:`status=1` + `target ∈ {ALL, USER}` + 已到发布时间 + 未过期;
* 按 `publish_at desc, id desc` 排序。**没有数据就是空列表**(不是 404)——
* 公告当前也没有后台产出方,客户端拿到空列表属正常状态。
*/
@Service
@RequiredArgsConstructor
public class AppNoticeService {
private static final String TARGET_ALL = "ALL";
private static final String TARGET_USER = "USER";
private static final int MAX_PAGE_SIZE = 50;
private final OpsNoticeMapper opsNoticeMapper;
public PageResult<AppNoticeVO> page(int pageIndex, int pageSize) {
LocalDateTime now = LocalDateTime.now();
Page<OpsNotice> page = new Page<>(pageIndex < 1 ? 1 : pageIndex,
pageSize < 1 ? 20 : Math.min(pageSize, MAX_PAGE_SIZE));
IPage<OpsNotice> result = opsNoticeMapper.selectPage(page, Wrappers.<OpsNotice>lambdaQuery()
.eq(OpsNotice::getStatus, 1)
.in(OpsNotice::getTarget, List.of(TARGET_ALL, TARGET_USER))
.and(w -> w.isNull(OpsNotice::getPublishAt).or().le(OpsNotice::getPublishAt, now))
.and(w -> w.isNull(OpsNotice::getExpireAt).or().gt(OpsNotice::getExpireAt, now))
.orderByDesc(OpsNotice::getPublishAt)
.orderByDesc(OpsNotice::getId));
return new PageResult<>(result.getTotal(), result.getRecords().stream().map(this::toVO).toList());
}
private AppNoticeVO toVO(OpsNotice notice) {
AppNoticeVO vo = new AppNoticeVO();
vo.setId(notice.getId());
vo.setTitle(notice.getTitle());
vo.setContent(notice.getContent());
vo.setType(notice.getType());
vo.setPublishAt(notice.getPublishAt());
vo.setExpireAt(notice.getExpireAt());
return vo;
}
}
@@ -0,0 +1,213 @@
// 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 = 当前创作者),不含他人数据"
);
}
}
@@ -0,0 +1,22 @@
// Author: fruit.com.cn
package com.fruitshorts.app.vo;
import lombok.Data;
import java.time.LocalDateTime;
/** C 端系统公告(`GET /app/notices`)。 */
@Data
public class AppNoticeVO {
private Long id;
private String title;
private String content;
/** SYSTEM / AUDIT / PAYMENT / ACTIVITY。 */
private String type;
private LocalDateTime publishAt;
private LocalDateTime expireAt;
}
@@ -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;
}
}
@@ -37,7 +37,13 @@ public enum ErrorCode {
*/
CREATOR_NOT_BOUND(200004, "当前账号未绑定创作者主体"),
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 String message;
@@ -41,6 +41,9 @@ public class SecurityConfig {
"/app/dramas/**",
"/app/episodes/*/play",
"/app/config/**",
/* 启动配置与公告:客户端登录前就要拿(POST /app/creator/apply 等仍要登录) */
"/app/config",
"/app/notices",
/* 媒资:封面与视频用 <img>/<video> 直接访问,无法带请求头。
片源的防盗链由 fruitshorts.media.signature-required 控制。 */
"/files/**",
@@ -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;
}
@@ -0,0 +1,46 @@
// Author: fruit.com.cn
package com.fruitshorts.system.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 系统公告(ops_notice)。
*
* <p>表在 schema-phase1 里就建好了,但一直没有实体/Mapper —— C 端 `/app/notices` 需要读它,这里补上。
* **只读使用**:公告的写入(后台发布入口)当前没有产出方,本期不实现。
*/
@Data
@TableName("ops_notice")
public class OpsNotice {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String title;
private String content;
/** 投放对象:ALL / USER / CREATOR。 */
private String target;
/** 公告类型:SYSTEM / AUDIT / PAYMENT / ACTIVITY。 */
private String type;
/** 1=启用,0=停用。 */
private Integer status;
private LocalDateTime publishAt;
private LocalDateTime expireAt;
private Long createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,12 @@
// Author: fruit.com.cn
package com.fruitshorts.system.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.system.dal.entity.OpsNotice;
import org.apache.ibatis.annotations.Mapper;
/** 系统公告(只读使用)。 */
@Mapper
public interface OpsNoticeMapper extends BaseMapper<OpsNotice> {
}