diff --git a/docs/api-phase1.md b/docs/api-phase1.md index 03e812d..51d5bad 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -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/**) diff --git a/fruitshorts-app-api/pom.xml b/fruitshorts-app-api/pom.xml index f2b651f..5ce6f7b 100644 --- a/fruitshorts-app-api/pom.xml +++ b/fruitshorts-app-api/pom.xml @@ -41,6 +41,16 @@ com.fruitshorts fruitshorts-module-commerce + + + com.fruitshorts + fruitshorts-module-creator + + + + com.fruitshorts + fruitshorts-module-system + org.springframework.boot diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/AppApiApplication.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/AppApiApplication.java index 8bbd283..856172b 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/AppApiApplication.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/AppApiApplication.java @@ -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 = "观看进度上报与续看位置"), } ) diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppConfigController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppConfigController.java new file mode 100644 index 0000000..ecb6d06 --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppConfigController.java @@ -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; + +/** + * 启动配置与系统公告:**两个匿名接口**(客户端登录前就要拿到)。 + * + *

放在一起是因为它们同属"进入 App 前的公共数据",且都不依赖登录态。 + */ +@Tag(name = "客户端-启动配置与公告") +@RestController +@RequestMapping("/app") +@RequiredArgsConstructor +public class AppConfigController { + + private final AppConfigService appConfigService; + private final AppNoticeService appNoticeService; + + /** + * 启动配置:协议版本 / 最低版本 / 功能开关。 + * **只下发白名单键**,库里缺键用契约默认值兜底。 + */ + @Operation(summary = "启动配置(匿名,只下发白名单键)") + @GetMapping("/config") + public Result> config() { + return Result.ok(appConfigService.appConfig()); + } + + /** 系统公告:无数据返回空列表(不是 404)。 */ + @Operation(summary = "系统公告(匿名,无数据返回空列表)") + @GetMapping("/notices") + public Result> notices( + @RequestParam(defaultValue = "1") Integer pageIndex, + @RequestParam(defaultValue = "20") Integer pageSize) { + return Result.ok(appNoticeService.page(pageIndex == null ? 1 : pageIndex, + pageSize == null ? 20 : pageSize)); + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCreatorController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCreatorController.java new file mode 100644 index 0000000..644cfff --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCreatorController.java @@ -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 端创作者:入驻申请、我的作品、数据看板。 + * + *

归属一律取**当前登录的 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 apply(@Valid @RequestBody CreatorApplyReqVO req) { + return Result.ok(creatorAppService.apply(SecurityUtils.getAppUserId(), req, + appConfigService.agreementVersion())); + } + + /** 我的作品与审核状态:非生效创作者返回 200007。 */ + @Operation(summary = "我的作品与审核状态(非生效创作者返回 200007)") + @GetMapping("/works") + public Result> 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> stats() { + Creator creator = creatorAppService.requireActiveCreator(SecurityUtils.getAppUserId()); + return Result.ok(creatorStatsService.overview(creator)); + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppConfigService.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppConfigService.java new file mode 100644 index 0000000..ab30c3d --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppConfigService.java @@ -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`,匿名)。 + * + *

只下发白名单键:`sys_config` 是后台配置表,里面可能有内部开关, + * 整表下发等于把内部配置暴露给客户端 —— 所以这里用**硬编码白名单**({@link #KEYS}), + * 且库里缺键时用**契约默认值**兜底(契约见 `docs/api-phase1.md` §1.9.1), + * 不返回 null 让客户端自己猜默认值。 + */ +@Service +@RequiredArgsConstructor +public class AppConfigService { + + /** 白名单:契约里的 6 个键 → 后台配置键 + 默认值。顺序即响应字段顺序。 */ + private static final Map 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 appConfig() { + Map 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 rows = sysConfigMapper.selectList(Wrappers.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) { + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppNoticeService.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppNoticeService.java new file mode 100644 index 0000000..1e1ef0e --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppNoticeService.java @@ -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`,匿名,只读)。 + * + *

口径:`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 page(int pageIndex, int pageSize) { + LocalDateTime now = LocalDateTime.now(); + Page page = new Page<>(pageIndex < 1 ? 1 : pageIndex, + pageSize < 1 ? 20 : Math.min(pageSize, MAX_PAGE_SIZE)); + IPage result = opsNoticeMapper.selectPage(page, Wrappers.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; + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/CreatorStatsService.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/CreatorStatsService.java new file mode 100644 index 0000000..45227ee --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/CreatorStatsService.java @@ -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`,只读)。 + * + *

口径必须与后台 07 创作者中心看板一致(`admin-api` 的 `CreatorDashboardService`): + * 字段名、指标定义、`null` + `notes` 语义逐条对齐。两份实现在指标定义上完全相同, + * 差别只有"创作者从哪来"(C 端按 `creator.user_id`,后台按登录绑定)。 + * + *

之所以在 app-api 再实现一份而不是共用:`CreatorDashboardService` 物理上在 admin-api, + * 而 app-api 不能依赖 admin-api;把指标实现搬到共享模块需要给冻结模块加 commerce/interaction/playback 依赖, + * 属更大的结构改动。**因此这里刻意保持"逻辑同样、注释同样"**, + * 并已登记为待收敛项(见 `DEPLOY-CHECKLIST.md` 风险小节与 E1 回执)。 + * + *

指标口径(与后台一致,前端 tooltip 可直接复用): + *

+ */ +@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 overview(Creator creator) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime sevenDaysAgo = now.minusDays(RECENT_DAYS); + + List dramas = dramaMapper.selectList(Wrappers.lambdaQuery() + .eq(Drama::getCreatorId, creator.getId()) + .orderByDesc(Drama::getId)); + List dramaIds = dramas.stream().map(Drama::getId).toList(); + List episodes = dramaIds.isEmpty() ? List.of() + : dramaEpisodeMapper.selectList(Wrappers.lambdaQuery() + .in(DramaEpisode::getDramaId, dramaIds) + .orderByAsc(DramaEpisode::getDramaId) + .orderByAsc(DramaEpisode::getEpNo)); + List episodeIds = episodes.stream().map(DramaEpisode::getId).toList(); + + Map 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 dramaMetrics(List dramas, List episodes) { + Map 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 playbackMetrics(List dramas, List dramaIds, + LocalDateTime sevenDaysAgo) { + Map 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.lambdaQuery() + .in(WatchProgress::getDramaId, dramaIds))); + long finished = dramaIds.isEmpty() ? 0L + : orZero(watchProgressMapper.selectCount(Wrappers.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.lambdaQuery() + .in(WatchHistory::getDramaId, dramaIds) + .ge(WatchHistory::getWatchedAt, sevenDaysAgo)))); + playback.put("playEventsTotal", dramaIds.isEmpty() ? 0L + : orZero(watchHistoryMapper.selectCount(Wrappers.lambdaQuery() + .in(WatchHistory::getDramaId, dramaIds)))); + playback.put("finishEvents", dramaIds.isEmpty() ? 0L + : orZero(watchHistoryMapper.selectCount(Wrappers.lambdaQuery() + .in(WatchHistory::getDramaId, dramaIds) + .gt(WatchHistory::getDurationMs, 0L) + .apply("position_ms >= duration_ms")))); + return playback; + } + + private Map interactionMetrics(List dramaIds) { + Map interaction = new LinkedHashMap<>(); + interaction.put("favoriteTotal", dramaIds.isEmpty() ? 0L + : orZero(userFavoriteMapper.selectCount(Wrappers.lambdaQuery() + .in(UserFavorite::getDramaId, dramaIds)))); + interaction.put("commentTotal", dramaIds.isEmpty() ? 0L + : orZero(commentMapper.selectCount(Wrappers.lambdaQuery() + .in(ContentComment::getDramaId, dramaIds)))); + interaction.put("commentPending", dramaIds.isEmpty() ? 0L + : orZero(commentMapper.selectCount(Wrappers.lambdaQuery() + .in(ContentComment::getDramaId, dramaIds) + .eq(ContentComment::getStatus, "pending")))); + return interaction; + } + + private Map revenueMetrics(List dramaIds, List episodeIds, + LocalDateTime sevenDaysAgo) { + Map revenue = new LinkedHashMap<>(); + revenue.put("unlockCount", dramaIds.isEmpty() ? 0L + : orZero(entitlementMapper.selectCount(Wrappers.lambdaQuery() + .in(Entitlement::getDramaId, dramaIds) + .eq(Entitlement::getSource, "ORDER")))); + + List 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 episodeIdText, LocalDateTime since) { + List ledger = coinLedgerMapper.selectList(Wrappers.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 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 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 = 当前创作者),不含他人数据" + ); + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppNoticeVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppNoticeVO.java new file mode 100644 index 0000000..b9e77cc --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppNoticeVO.java @@ -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; +} diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCreatorAndConfigContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCreatorAndConfigContractTest.java new file mode 100644 index 0000000..b8f0188 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCreatorAndConfigContractTest.java @@ -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 端接口的接入层契约。 + * + *

覆盖(对应条目门禁):匿名可达 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 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 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; + } +} diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/CreatorAppServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/CreatorAppServiceTest.java new file mode 100644 index 0000000..fafbfc1 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/CreatorAppServiceTest.java @@ -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、状态机报错口径。 + * + *

契约层的 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 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 captureInsert() { + AtomicReference 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; + } +} diff --git a/fruitshorts-common/src/main/java/com/fruitshorts/common/exception/ErrorCode.java b/fruitshorts-common/src/main/java/com/fruitshorts/common/exception/ErrorCode.java index b7fc80b..0a30f80 100644 --- a/fruitshorts-common/src/main/java/com/fruitshorts/common/exception/ErrorCode.java +++ b/fruitshorts-common/src/main/java/com/fruitshorts/common/exception/ErrorCode.java @@ -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; diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java index c1f9fb2..0d6248a 100644 --- a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java @@ -41,6 +41,9 @@ public class SecurityConfig { "/app/dramas/**", "/app/episodes/*/play", "/app/config/**", + /* 启动配置与公告:客户端登录前就要拿(POST /app/creator/apply 等仍要登录) */ + "/app/config", + "/app/notices", /* 媒资:封面与视频用 /

与管理端创作者中心的关键区别:**归属按 `creator.user_id`(C 端账号)解析**, + * 而管理端按 `creator.admin_user_id`(后台账号绑定)。两条链路互不影响, + * 这里**没有改动**管理端的入驻审核 / 开号 / 作品管理逻辑。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CreatorAppService { + + private final CreatorMapper creatorMapper; + private final CreatorCenterService creatorCenterService; + + /** + * 入驻申请(**幂等**)。 + * + *

防重靠数据库:`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.lambdaQuery() + .eq(Creator::getUserId, userId) + .last("limit 1")); + } + + /** + * 要求当前 C 端账号是**生效中的创作者**,否则按状态给出明确错误(`200007`)。 + * + *

刻意不返回空列表:客户端要能区分"没有作品"和"你还不是创作者"。 + */ + 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 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; + } +} diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorCenterService.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorCenterService.java index 93a4c63..5278162 100644 --- a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorCenterService.java +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorCenterService.java @@ -171,6 +171,25 @@ public class CreatorCenterService { return new PageResult<>(result.getTotal(), toDramaVOList(creator.getId(), result.getRecords())); } + /** + * 指定创作者的作品分页(**C 端用**)。 + * + *

与管理端 {@link #pageMyDramas} 共用同一套查询与 VO 映射,区别只在"创作者从哪来": + * 管理端由后台账号绑定关系解析,C 端由 `creator.user_id`(C 端账号)解析。 + * 之所以单独开一个方法而不是把 creatorId 做成参数往上传:**不接受调用方传归属**,避免横向越权入口。 + */ + public PageResult pageMyDramasOf(Creator creator, + CreatorCenterDramaQueryReqVO req) { + Page page = new Page<>(req.getSafePageIndex(), req.getSafePageSize()); + IPage result = dramaMapper.selectPage(page, Wrappers.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); diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorApplyReqVO.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorApplyReqVO.java new file mode 100644 index 0000000..c3e7d3c --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorApplyReqVO.java @@ -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`)。 + * + *

字段口径与后台录入(`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; +} diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorApplyRespVO.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorApplyRespVO.java new file mode 100644 index 0000000..3d3d01d --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorApplyRespVO.java @@ -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; +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/OpsNotice.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/OpsNotice.java new file mode 100644 index 0000000..9720457 --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/OpsNotice.java @@ -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)。 + * + *

表在 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; +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/OpsNoticeMapper.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/OpsNoticeMapper.java new file mode 100644 index 0000000..21d4f05 --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/OpsNoticeMapper.java @@ -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 { +}