merge(P-OPN-08): 创作者公开聚合API(project+telemetry @PermitAll·仅status==4·无PII·评审SHIP 0P0/0P1+P2软降级整改) → dev/2.0.0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
8238bf2d7a
@ -96,6 +96,25 @@ paths:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultProjectPublish' }
|
||||
|
||||
/app-api/project/public/creator/{creatorId}:
|
||||
get:
|
||||
tags: [app-project]
|
||||
summary: 创作者公开作品聚合(P-OPN-08,@PermitAll 跨创作者公开读)
|
||||
description: >-
|
||||
创作者主页对外展示其公开作品列表。公开 = 项目 status==4(已发布) 仅此一态(排除 5下架/6封禁/草稿/审核中等);
|
||||
无 PII,仅作品基础信息 + 聚合计数。匿名可访问(@PermitAll),按 creatorId 路径参数取任意创作者数据(非登录人,
|
||||
故不受创作者归属过滤)。creatorName 尽力解析(查不到为 null),creatorId 必返。
|
||||
parameters:
|
||||
- { name: creatorId, in: path, required: true, schema: { type: integer, format: int64 }, description: 创作者用户 ID }
|
||||
- { name: pageNo, in: query, required: false, schema: { type: integer, default: 1 } }
|
||||
- { name: pageSize, in: query, required: false, schema: { type: integer, default: 10 } }
|
||||
responses:
|
||||
'200':
|
||||
description: 成功(仅 status==4 已发布作品)
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultPublicCreatorProjectPage' }
|
||||
|
||||
/admin-api/project/review/page:
|
||||
get:
|
||||
tags: [admin-project]
|
||||
@ -164,6 +183,16 @@ components:
|
||||
list: { type: array, items: { $ref: '#/components/schemas/ProjectRespVO' } }
|
||||
total: { type: integer, format: int64 }
|
||||
msg: { type: string }
|
||||
CommonResultPublicCreatorProjectPage:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: integer }
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
list: { type: array, items: { $ref: '#/components/schemas/PublicCreatorProjectRespVO' } }
|
||||
total: { type: integer, format: int64 }
|
||||
msg: { type: string }
|
||||
|
||||
# ---- 请求/响应 VO ----
|
||||
ProjectCreateReqVO:
|
||||
@ -226,3 +255,17 @@ components:
|
||||
playCount: { type: integer, format: int64 }
|
||||
likeCount: { type: integer, format: int64 }
|
||||
createTime: { type: string, format: date-time }
|
||||
# P-OPN-08 创作者公开作品卡(对外展示,无 PII;仅 status==4 已发布作品才会出现在列表)
|
||||
PublicCreatorProjectRespVO:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer, format: int64, description: 游戏项目 ID }
|
||||
title: { type: string, description: 标题 }
|
||||
summary: { type: string, description: 简介 }
|
||||
coverUrl: { type: string, description: 封面图 URL }
|
||||
ageRating: { type: string, description: '适龄提示:all/8+/12+/16+' }
|
||||
playCount: { type: integer, format: int64, description: 累计试玩数 }
|
||||
likeCount: { type: integer, format: int64, description: 累计点赞数 }
|
||||
publishTime: { type: string, format: date-time, description: '发布时间(项目表无独立 publish_time 列,取 update_time 近似,同 count-published 口径)' }
|
||||
creatorId: { type: integer, format: int64, description: 创作者用户 ID(必返,作主页归属锚) }
|
||||
creatorName: { type: string, description: 创作者昵称(尽力解析;查不到为 null,无 PII) }
|
||||
|
||||
@ -102,6 +102,24 @@ paths:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultMyTrend' }
|
||||
|
||||
/app-api/telemetry/public/creator/{creatorId}/summary:
|
||||
get:
|
||||
tags: [app-telemetry]
|
||||
summary: 创作者公开统计聚合(P-OPN-08,@PermitAll 跨创作者公开读)
|
||||
description: >-
|
||||
创作者主页对外展示其公开统计:对该创作者 status==4(已发布) 作品聚合 { publishedCount, totalPlayCount, totalLikeCount }。
|
||||
匿名可访问(@PermitAll),按 creatorId 路径参数取任意创作者(非登录人,不受归属过滤);公开 = 仅 status==4 一态,无 PII。
|
||||
统计口径取 game_project 表的 play_count/like_count 计数列(同 feed 卡 / 创作者主页列表口径,发布态权威 + 计数同源)。
|
||||
无公开作品时各计数返回 0(不报错)。
|
||||
parameters:
|
||||
- { name: creatorId, in: path, required: true, schema: { type: integer, format: int64 }, description: 创作者用户 ID }
|
||||
responses:
|
||||
'200':
|
||||
description: 成功(创作者无公开作品时各字段为 0)
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultPublicCreatorSummary' }
|
||||
|
||||
# ===========================================================================
|
||||
# 看板数据源(管理端 RBAC;只读聚合,供产品域 UI 取数)
|
||||
# ===========================================================================
|
||||
@ -180,6 +198,12 @@ components:
|
||||
properties:
|
||||
list: { type: array, items: { $ref: '#/components/schemas/MyTrendItemVO' } }
|
||||
msg: { type: string }
|
||||
CommonResultPublicCreatorSummary:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: integer }
|
||||
data: { $ref: '#/components/schemas/PublicCreatorSummaryRespVO' }
|
||||
msg: { type: string }
|
||||
|
||||
# ---- 事件信封(对齐契约 #5 events.schema.json envelope;此处为上报入参镜像)----
|
||||
EnvelopeReqVO:
|
||||
@ -262,6 +286,18 @@ components:
|
||||
totalComplete: { type: integer, format: int64, description: '我的全部作品累计完玩数(跨 gameId 求和 completed_count)' }
|
||||
completionRate: { type: number, format: float, description: '完玩率 = totalComplete / totalPlay(totalPlay=0 兜 0,0-1 比值)' }
|
||||
adRevenueTotal: { type: number, format: float, description: '我的全部作品累计广告收益(当前恒为 0,预留字段;待 ad/trade 收益对接点,避免越钱口径硬边界)' }
|
||||
# P-OPN-08 创作者公开统计聚合(对外展示,无 PII;口径 = 该创作者 status==4 已发布作品的 play_count/like_count 累计)
|
||||
PublicCreatorSummaryRespVO:
|
||||
type: object
|
||||
description: >-
|
||||
创作者公开主页统计:对该创作者 status==4(已发布) 作品聚合发布数 + 累计试玩 + 累计点赞。
|
||||
统计源 = game_project 表的 play_count/like_count 计数列(与 feed 卡 / 创作者公开作品列表同源;发布态权威在 project)。
|
||||
无公开作品时各计数为 0(不报错)。
|
||||
properties:
|
||||
creatorId: { type: integer, format: int64, description: 创作者用户 ID(必返,作主页归属锚) }
|
||||
publishedCount: { type: integer, format: int64, description: '该创作者已发布(status==4)作品数' }
|
||||
totalPlayCount: { type: integer, format: int64, description: '该创作者已发布作品累计试玩数(跨作品求和 play_count)' }
|
||||
totalLikeCount: { type: integer, format: int64, description: '该创作者已发布作品累计点赞数(跨作品求和 like_count)' }
|
||||
MyTrendItemVO:
|
||||
type: object
|
||||
description: 我的作品某统计日的跨作品按日聚合项(趋势折线一个点)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package com.wanxiang.huijing.game.module.project.api;
|
||||
|
||||
import com.wanxiang.huijing.game.module.project.enums.ApiConstants;
|
||||
import com.wanxiang.huijing.game.module.project.dto.CreatorPublicSummaryDTO;
|
||||
import com.wanxiang.huijing.game.module.project.dto.ProjectFeedMetaDTO;
|
||||
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@ -186,4 +187,19 @@ public interface ProjectApi {
|
||||
@RequestParam("statDateStart") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateStart,
|
||||
@RequestParam("statDateEnd") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateEnd);
|
||||
|
||||
/**
|
||||
* 创作者公开统计聚合(P-OPN-08,telemetry 公开统计数据源;telemetry 经 project-api 取)
|
||||
*
|
||||
* 口径:对目标创作者 status==PUBLISHED(4) 作品聚合 发布数 + 累计试玩(play_count) + 累计点赞(like_count)。
|
||||
* 发布态权威 + 计数列均在 project,故聚合收敛在 project 侧。只读、幂等、无 PII;无公开作品各计数为 0(DTO 永不为 null)。
|
||||
* 跨创作者公开读:creatorId 为目标创作者(非登录人),不受归属约束(game_project 无 DataPermission 规则)。
|
||||
*
|
||||
* @param creatorId 目标创作者用户 ID
|
||||
* @return 公开统计聚合 DTO(CommonResult 包裹;各计数至少为 0)
|
||||
*/
|
||||
@GetMapping(PREFIX + "/get-creator-published-summary")
|
||||
@Operation(summary = "创作者公开统计聚合(telemetry 公开统计,P-OPN-08)")
|
||||
@Parameter(name = "creatorId", description = "创作者用户 ID", required = true, example = "99")
|
||||
CommonResult<CreatorPublicSummaryDTO> getCreatorPublishedSummary(@RequestParam("creatorId") Long creatorId);
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
package com.wanxiang.huijing.game.module.project.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 创作者公开统计聚合 DTO(P-OPN-08,project 对外契约 -api)
|
||||
*
|
||||
* 口径:对某创作者 status==PUBLISHED(4) 作品聚合 发布数 + 累计试玩(play_count) + 累计点赞(like_count)。
|
||||
* 发布态权威 + 计数列均在 project(game_project),故聚合收敛在 project 侧;telemetry 公开统计经
|
||||
* {@link com.wanxiang.huijing.game.module.project.api.ProjectApi#getCreatorPublishedSummary} 取本 DTO 后组装对外 VO。
|
||||
* 跨模块共享:消费方(telemetry)只引用本 -api,不依赖 project 的 -server。无 PII(仅计数)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Data
|
||||
public class CreatorPublicSummaryDTO implements Serializable {
|
||||
|
||||
/** 已发布(status==4)作品数 */
|
||||
private Long publishedCount;
|
||||
/** 已发布作品累计试玩数(跨作品求和 play_count) */
|
||||
private Long totalPlayCount;
|
||||
/** 已发布作品累计点赞数(跨作品求和 like_count) */
|
||||
private Long totalLikeCount;
|
||||
|
||||
}
|
||||
@ -2,6 +2,7 @@ package com.wanxiang.huijing.game.module.project.api;
|
||||
|
||||
import com.wanxiang.huijing.game.module.project.controller.app.project.vo.ProjectCreateReqVO;
|
||||
import com.wanxiang.huijing.game.module.project.dal.dataobject.version.GameVersionDO;
|
||||
import com.wanxiang.huijing.game.module.project.dto.CreatorPublicSummaryDTO;
|
||||
import com.wanxiang.huijing.game.module.project.dto.ProjectFeedMetaDTO;
|
||||
import com.wanxiang.huijing.game.module.project.service.project.ProjectService;
|
||||
import com.wanxiang.huijing.game.module.project.service.version.GameVersionService;
|
||||
@ -115,4 +116,10 @@ public class ProjectApiImpl implements ProjectApi {
|
||||
return success(projectService.countPublished(start, end));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<CreatorPublicSummaryDTO> getCreatorPublishedSummary(Long creatorId) {
|
||||
// P-OPN-08 telemetry 公开统计:对目标创作者 status==4 作品聚合(发布数/累计试玩/累计点赞);无公开作品各计数为 0(非 null)
|
||||
return success(projectService.getPublishedSummaryByCreator(creatorId));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import com.wanxiang.huijing.game.module.project.controller.app.project.vo.Projec
|
||||
import com.wanxiang.huijing.game.module.project.controller.app.project.vo.ProjectPublishReqVO;
|
||||
import com.wanxiang.huijing.game.module.project.controller.app.project.vo.ProjectPublishRespVO;
|
||||
import com.wanxiang.huijing.game.module.project.controller.app.project.vo.ProjectRespVO;
|
||||
import com.wanxiang.huijing.game.module.project.controller.app.project.vo.PublicCreatorProjectRespVO;
|
||||
import com.wanxiang.huijing.game.module.project.convert.project.ProjectConvert;
|
||||
import com.wanxiang.huijing.game.module.project.dal.dataobject.project.ProjectDO;
|
||||
import com.wanxiang.huijing.game.module.project.service.project.ProjectService;
|
||||
@ -16,6 +17,7 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.annotation.security.PermitAll;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@ -54,6 +56,16 @@ public class AppProjectController {
|
||||
return success(projectService.createProject(createReqVO, userId));
|
||||
}
|
||||
|
||||
@GetMapping("/public/creator/{creatorId}")
|
||||
@PermitAll // P-OPN-08 匿名合法:创作者公开主页跨创作者公开读(仅 status==4 已发布作品,无 PII)
|
||||
@Operation(summary = "创作者公开作品聚合", description = "@PermitAll 跨创作者公开读;仅 status==4 已发布作品,creatorId 为目标创作者(非登录人)")
|
||||
@Parameter(name = "creatorId", description = "创作者用户 ID", required = true, example = "99")
|
||||
public CommonResult<PageResult<PublicCreatorProjectRespVO>> getPublicCreatorProjects(
|
||||
@PathVariable("creatorId") Long creatorId, @Valid ProjectPageReqVO pageReqVO) {
|
||||
// 跨创作者公开读:目标创作者来自路径参数(非 getLoginUserId),Service/Mapper 按目标 creatorId + status==4 过滤,不受登录人归属约束
|
||||
return success(projectService.getPublishedByCreator(creatorId, pageReqVO));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "项目详情")
|
||||
@Parameter(name = "id", description = "项目编号", required = true, example = "1024")
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
package com.wanxiang.huijing.game.module.project.controller.app.project.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 创作者公开作品卡 Response VO(P-OPN-08,对外展示,无 PII)
|
||||
*
|
||||
* 对齐契约 project.yaml PublicCreatorProjectRespVO。
|
||||
* 公开语义:仅 status==4(已发布) 作品才会出现在列表(排除 5下架/6封禁/草稿/审核中等)——过滤在 Service/Mapper 强制。
|
||||
* 跨创作者公开读:creatorId 为路径参数传入(非登录人),故不含归属过滤;creatorName 尽力解析(查不到为 null)。
|
||||
* 刻意不含 status/tags/templateId/launchZoneId/currentVersionId/featured 等内部字段,最小对外暴露面。
|
||||
*
|
||||
* @author 绘境AI
|
||||
*/
|
||||
@Schema(description = "用户 App - 创作者公开作品卡 Response VO")
|
||||
@Data
|
||||
public class PublicCreatorProjectRespVO {
|
||||
|
||||
@Schema(description = "游戏项目 ID", example = "1024")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "标题", example = "我的放置小镇")
|
||||
private String title;
|
||||
|
||||
@Schema(description = "简介", example = "一款轻松放置类小游戏")
|
||||
private String summary;
|
||||
|
||||
@Schema(description = "封面图 URL", example = "https://cdn.example.com/cover.png")
|
||||
private String coverUrl;
|
||||
|
||||
@Schema(description = "适龄提示:all/8+/12+/16+", example = "all")
|
||||
private String ageRating;
|
||||
|
||||
@Schema(description = "累计试玩数", example = "12800")
|
||||
private Long playCount;
|
||||
|
||||
@Schema(description = "累计点赞数", example = "860")
|
||||
private Long likeCount;
|
||||
|
||||
/**
|
||||
* 发布时间。项目表无独立 publish_time 列,故取 update_time 近似(与 ProjectMapper.countPublishedBetween / ProjectApi.countPublished 同口径)。
|
||||
*/
|
||||
@Schema(description = "发布时间(取 update_time 近似,同 count-published 口径)", example = "2026-06-15 10:30:00")
|
||||
private LocalDateTime publishTime;
|
||||
|
||||
@Schema(description = "创作者用户 ID(必返,作主页归属锚)", example = "99")
|
||||
private Long creatorId;
|
||||
|
||||
@Schema(description = "创作者昵称(尽力解析;查不到为 null,无 PII)", example = "小王")
|
||||
private String creatorName;
|
||||
|
||||
}
|
||||
@ -7,10 +7,13 @@ import com.wanxiang.huijing.game.module.project.enums.ProjectStatusEnum;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
import com.wanxiang.huijing.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import com.wanxiang.huijing.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import com.wanxiang.huijing.framework.mybatis.core.query.QueryWrapperX;
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 游戏项目 Mapper
|
||||
@ -34,6 +37,48 @@ public interface ProjectMapper extends BaseMapperX<ProjectDO> {
|
||||
.orderByDesc(ProjectDO::getId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创作者公开作品分页(P-OPN-08,app 端 @PermitAll 跨创作者公开读)
|
||||
*
|
||||
* <p><b>跨创作者公开读(关键正确性)</b>:creatorUserId 为<b>路径参数传入的目标创作者</b>(非登录人),
|
||||
* 故此处<b>刻意不按登录人过滤</b>——返回该创作者的公开作品,使任意访客都能浏览任意创作者主页。
|
||||
* 本仓 DataPermission 仅注册了 system 的 DeptDataPermissionRule(仅作用 AdminUserDO/DeptDO 表),
|
||||
* game_project 表无任何 DataPermission 规则,故 MP 数据权限拦截器对本查询<b>不追加任何过滤</b>;
|
||||
* project 的"创作者只见自己数据"一向由 Service/Mapper 手动 eq(creatorUserId) 强制,而非拦截器。
|
||||
* 因此公开读只需"按目标 creatorId 等值"即天然跨创作者,无需 @DataPermission(enable=false)(无规则可禁)。
|
||||
* <p><b>公开态过滤</b>:强制 status==PUBLISHED(4) 仅此一态——排除草稿/审核中/已通过/已拒绝/已下架(5)/已封禁(6)。
|
||||
* 软删行(deleted=1)由 MyBatis-Plus 逻辑删除自动排除(BaseDO.deleted),无需显式条件。
|
||||
*
|
||||
* @param reqVO 分页参数(仅 pageNo/pageSize;无状态筛选——公开恒为 4)
|
||||
* @param creatorUserId 目标创作者用户 ID(路径参数,非登录人)
|
||||
* @return 该创作者已发布作品分页(按 id 倒序,新作在前)
|
||||
*/
|
||||
default PageResult<ProjectDO> selectPublishedPageByCreator(ProjectPageReqVO reqVO, Long creatorUserId) {
|
||||
return selectPage(reqVO, new LambdaQueryWrapperX<ProjectDO>()
|
||||
.eq(ProjectDO::getCreatorUserId, creatorUserId) // 按"目标创作者"过滤(跨创作者公开读,非登录人)
|
||||
.eq(ProjectDO::getStatus, ProjectStatusEnum.PUBLISHED.getStatus()) // 公开恒为已发布(4):排除 5下架/6封禁/草稿/审核中等
|
||||
.orderByDesc(ProjectDO::getId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创作者已发布作品聚合(P-OPN-08,跨模块 Feign:telemetry 公开统计取)
|
||||
*
|
||||
* 口径:对目标创作者 status==PUBLISHED(4) 作品聚合 发布数(COUNT) + 累计试玩(SUM play_count) + 累计点赞(SUM like_count)。
|
||||
* 跨创作者公开读同 selectPublishedPageByCreator(按目标 creatorId 等值,非登录人;game_project 无 DataPermission 规则)。
|
||||
* selectMaps 聚合范式(仿 sumMyStat),单语句一次取齐三聚合值;软删行由 MP 逻辑删除自动排除。
|
||||
*
|
||||
* @param creatorUserId 目标创作者用户 ID
|
||||
* @return 含 publishedCount/playSum/likeSum 的单行 Map(无公开作品兜底 0)
|
||||
*/
|
||||
default Map<String, Object> sumPublishedByCreator(Long creatorUserId) {
|
||||
return CollUtil.getFirst(selectMaps(new QueryWrapperX<ProjectDO>()
|
||||
.select("COUNT(*) AS publishedCount",
|
||||
"IFNULL(SUM(play_count), 0) AS playSum",
|
||||
"IFNULL(SUM(like_count), 0) AS likeSum")
|
||||
.eq("creator_user_id", creatorUserId) // 按目标创作者(跨创作者公开读)
|
||||
.eq("status", ProjectStatusEnum.PUBLISHED.getStatus()))); // 公开恒为已发布(4)
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核队列分页(admin 端,按状态取,默认审核中)
|
||||
*
|
||||
|
||||
@ -7,7 +7,9 @@ import com.wanxiang.huijing.game.module.project.controller.app.project.vo.Projec
|
||||
import com.wanxiang.huijing.game.module.project.controller.app.project.vo.ProjectPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.project.controller.app.project.vo.ProjectPublishReqVO;
|
||||
import com.wanxiang.huijing.game.module.project.controller.app.project.vo.ProjectPublishRespVO;
|
||||
import com.wanxiang.huijing.game.module.project.controller.app.project.vo.PublicCreatorProjectRespVO;
|
||||
import com.wanxiang.huijing.game.module.project.dal.dataobject.project.ProjectDO;
|
||||
import com.wanxiang.huijing.game.module.project.dto.CreatorPublicSummaryDTO;
|
||||
import com.wanxiang.huijing.game.module.project.dto.ProjectFeedMetaDTO;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
|
||||
@ -68,6 +70,31 @@ public interface ProjectService {
|
||||
*/
|
||||
ProjectPublishRespVO submitPublish(Long id, ProjectPublishReqVO reqVO, Long userId);
|
||||
|
||||
/**
|
||||
* 创作者公开作品分页(P-OPN-08,app 端 @PermitAll 跨创作者公开读)
|
||||
*
|
||||
* 公开语义:仅 status==PUBLISHED(4) 一态(排除 5下架/6封禁/草稿/审核中等);软删行由 MP 逻辑删除自动排除。
|
||||
* 跨创作者公开读:creatorId 为路径参数(目标创作者,非登录人),故不按登录人过滤——使任意访客浏览任意创作者主页。
|
||||
* 过滤口径与 DataPermission 说明见 {@link com.wanxiang.huijing.game.module.project.dal.mysql.project.ProjectMapper#selectPublishedPageByCreator}。
|
||||
* 返回直接组装为对外 VO(无 PII):每条尽力解析 creatorName(creatorUserId → system nickname,查不到为 null),creatorId 必返。
|
||||
*
|
||||
* @param creatorId 目标创作者用户 ID(路径参数)
|
||||
* @param reqVO 分页参数(pageNo/pageSize)
|
||||
* @return 该创作者已发布作品分页 VO(无公开作品时 list 为空、total=0)
|
||||
*/
|
||||
PageResult<PublicCreatorProjectRespVO> getPublishedByCreator(Long creatorId, ProjectPageReqVO reqVO);
|
||||
|
||||
/**
|
||||
* 创作者已发布作品聚合(P-OPN-08,供 telemetry 公开统计跨模块 Feign 调用)
|
||||
*
|
||||
* 口径:对目标创作者 status==PUBLISHED(4) 作品聚合 发布数 + 累计试玩(play_count) + 累计点赞(like_count)。
|
||||
* 发布态权威 + 计数列均在 project,故聚合收敛于此;只读、无 PII;无公开作品各计数为 0(永不返回 null)。
|
||||
*
|
||||
* @param creatorId 目标创作者用户 ID
|
||||
* @return 公开统计聚合 DTO(各计数至少为 0)
|
||||
*/
|
||||
CreatorPublicSummaryDTO getPublishedSummaryByCreator(Long creatorId);
|
||||
|
||||
/**
|
||||
* 审核队列分页(admin 端,默认取审核中)
|
||||
*
|
||||
|
||||
@ -7,10 +7,12 @@ import com.wanxiang.huijing.game.module.project.controller.app.project.vo.Projec
|
||||
import com.wanxiang.huijing.game.module.project.controller.app.project.vo.ProjectPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.project.controller.app.project.vo.ProjectPublishReqVO;
|
||||
import com.wanxiang.huijing.game.module.project.controller.app.project.vo.ProjectPublishRespVO;
|
||||
import com.wanxiang.huijing.game.module.project.controller.app.project.vo.PublicCreatorProjectRespVO;
|
||||
import com.wanxiang.huijing.game.module.project.controller.app.project.vo.GateResultVO;
|
||||
import com.wanxiang.huijing.game.module.project.convert.project.ProjectConvert;
|
||||
import com.wanxiang.huijing.game.module.project.dal.dataobject.project.ProjectDO;
|
||||
import com.wanxiang.huijing.game.module.project.dal.dataobject.review.ReviewRecordDO;
|
||||
import com.wanxiang.huijing.game.module.project.dto.CreatorPublicSummaryDTO;
|
||||
import com.wanxiang.huijing.game.module.project.dto.ProjectFeedMetaDTO;
|
||||
import com.wanxiang.huijing.game.module.project.dal.mysql.project.ProjectMapper;
|
||||
import com.wanxiang.huijing.game.module.project.dal.mysql.review.ReviewRecordMapper;
|
||||
@ -214,6 +216,32 @@ public class ProjectServiceImpl implements ProjectService {
|
||||
return resp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<PublicCreatorProjectRespVO> getPublishedByCreator(Long creatorId, ProjectPageReqVO reqVO) {
|
||||
// 跨创作者公开读(P-OPN-08):creatorId 为路径参数(目标创作者,非登录人),mapper 按目标 creatorId + status==4 过滤,
|
||||
// 不按登录人过滤——使匿名访客浏览任意创作者主页(DataPermission 不作用 game_project,详见 mapper 注释)。
|
||||
PageResult<ProjectDO> page = projectMapper.selectPublishedPageByCreator(reqVO, creatorId);
|
||||
// 作者昵称对全页统一解析一次(同页恒为同一 creatorId,避免逐条重复 RPC):尽力解析,查不到为 null(无 PII)
|
||||
String creatorName = resolveAuthorNickname(creatorId);
|
||||
List<PublicCreatorProjectRespVO> list = new ArrayList<>(page.getList().size());
|
||||
for (ProjectDO project : page.getList()) {
|
||||
list.add(toPublicCreatorVO(project, creatorId, creatorName));
|
||||
}
|
||||
return new PageResult<>(list, page.getTotal());
|
||||
}
|
||||
|
||||
@Override
|
||||
public CreatorPublicSummaryDTO getPublishedSummaryByCreator(Long creatorId) {
|
||||
// 公开统计聚合(P-OPN-08):对目标创作者 status==4 作品聚合 发布数 + 累计试玩 + 累计点赞(发布态与计数列均在 project,收敛于此)
|
||||
java.util.Map<String, Object> row = projectMapper.sumPublishedByCreator(creatorId);
|
||||
CreatorPublicSummaryDTO dto = new CreatorPublicSummaryDTO();
|
||||
// selectMaps 单行聚合:无公开作品时 COUNT=0、SUM 经 IFNULL 兜 0(永不返回 null)
|
||||
dto.setPublishedCount(cn.hutool.core.map.MapUtil.getLong(row, "publishedCount", 0L));
|
||||
dto.setTotalPlayCount(cn.hutool.core.map.MapUtil.getLong(row, "playSum", 0L));
|
||||
dto.setTotalLikeCount(cn.hutool.core.map.MapUtil.getLong(row, "likeSum", 0L));
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<ProjectDO> getReviewPage(ProjectReviewPageReqVO reqVO) {
|
||||
return projectMapper.selectReviewPage(reqVO);
|
||||
@ -325,6 +353,32 @@ public class ProjectServiceImpl implements ProjectService {
|
||||
return user == null ? null : user.getNickname();
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装创作者公开作品卡 VO(P-OPN-08,对外展示,无 PII)
|
||||
*
|
||||
* publishTime 取 update_time 近似:项目表无独立 publish_time 列,同 ProjectMapper.countPublishedBetween / ProjectApi.countPublished 口径。
|
||||
* creatorName 由调用方按页统一解析后传入(同页恒同一 creatorId,避免逐条重复 RPC);creatorId 必返。
|
||||
*
|
||||
* @param project 已发布(status==4)项目 DO
|
||||
* @param creatorId 目标创作者用户 ID(= project.creatorUserId)
|
||||
* @param creatorName 已解析的创作者昵称(查不到为 null)
|
||||
* @return 公开作品卡 VO
|
||||
*/
|
||||
private PublicCreatorProjectRespVO toPublicCreatorVO(ProjectDO project, Long creatorId, String creatorName) {
|
||||
PublicCreatorProjectRespVO vo = new PublicCreatorProjectRespVO();
|
||||
vo.setId(project.getId());
|
||||
vo.setTitle(project.getTitle());
|
||||
vo.setSummary(project.getSummary());
|
||||
vo.setCoverUrl(project.getCoverUrl());
|
||||
vo.setAgeRating(project.getAgeRating());
|
||||
vo.setPlayCount(project.getPlayCount());
|
||||
vo.setLikeCount(project.getLikeCount());
|
||||
vo.setPublishTime(project.getUpdateTime()); // update_time 近似发布时间(项目表无 publish_time 列)
|
||||
vo.setCreatorId(creatorId);
|
||||
vo.setCreatorName(creatorName);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean unlistIfPublished(Long gameId) {
|
||||
// R3 封禁联动下架:状态查询 + 下架决策均收敛在 project 权威侧,复用既有 reviewProject 状态机(不重写=D2)
|
||||
|
||||
@ -0,0 +1,125 @@
|
||||
package com.wanxiang.huijing.game.module.project.dal.mysql.project;
|
||||
|
||||
import com.wanxiang.huijing.game.module.project.controller.app.project.vo.ProjectPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.project.dal.dataobject.project.ProjectDO;
|
||||
import com.wanxiang.huijing.game.module.project.enums.ProjectStatusEnum;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.withSettings;
|
||||
|
||||
/**
|
||||
* {@link ProjectMapper} P-OPN-08 公开查询 SQL 形态守卫单测(跨创作者公开读 + status==4 过滤)
|
||||
*
|
||||
* 验证边界(如实声明):game 模块测试基建仅 Mockito(project-server 无 test resources / 无 H2),无法执行真 SQL。
|
||||
* 故本测试以「CALLS_REAL_METHODS 跑 default 方法 → 捕获其构建的 Wrapper → 断言渲染 SQL 与绑定参数」做形态守卫,
|
||||
* 证明公开查询对 status 的过滤恒为 PUBLISHED(4)、且按"目标 creator_user_id"过滤(非登录人),
|
||||
* 防止后续改动悄然破坏"公开=仅已发布""跨创作者公开读"语义。MySQL 真实执行 + 租户拦截器改写由 staging 冒烟闭环验证。
|
||||
*
|
||||
* Lambda 缓存:LambdaQueryWrapper.getTargetSql() 渲染列名依赖 MP 运行期 lambda 缓存(TableInfo),纯单测未引导 SqlSessionFactory,
|
||||
* 故在 @BeforeAll 以 TableInfoHelper.initTableInfo 预注册 ProjectDO 的表信息(MP 标准测试惯用法),使列名得以解析。
|
||||
*
|
||||
* @author 绘境AI
|
||||
*/
|
||||
class ProjectMapperTest {
|
||||
|
||||
/** 预注册 ProjectDO 的 MP 表信息,使 LambdaQueryWrapper.getTargetSql() 能解析 lambda 列名 */
|
||||
@BeforeAll
|
||||
static void initLambdaCache() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), ProjectDO.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* selectPublishedPageByCreator 守卫:① 过滤列含 creator_user_id + status;② status 绑定值恒为 PUBLISHED(4);
|
||||
* ③ 绑定 creator_user_id=目标创作者(非登录人);④ 反向——绝不放行下架(5)/封禁(6)。
|
||||
*
|
||||
* 捕获点:分页 default 链最终落到 MP 的 {@code selectPage(IPage, Wrapper)}(见 BaseMapperX.selectPage 三参体),在此捕获 Wrapper。
|
||||
*/
|
||||
@Test
|
||||
void testSelectPublishedPageByCreator_filtersStatusPublishedAndByCreator() {
|
||||
ProjectMapper mapper = mock(ProjectMapper.class, withSettings().defaultAnswer(CALLS_REAL_METHODS));
|
||||
// 让分页底层 selectPage(IPage, Wrapper) 不真正查库,返回空页(仅需捕获 Wrapper)
|
||||
doReturn(emptyPage()).when(mapper).selectPage(any(IPage.class), any(Wrapper.class));
|
||||
|
||||
Long targetCreator = 11L; // 目标创作者(非登录人)
|
||||
mapper.selectPublishedPageByCreator(new ProjectPageReqVO(), targetCreator);
|
||||
|
||||
Wrapper<?> wrapper = captureWrapperFromSelectPage(mapper);
|
||||
assertPublishedAndByCreator(wrapper, targetCreator, "公开作品分页查询");
|
||||
}
|
||||
|
||||
/**
|
||||
* sumPublishedByCreator 守卫:聚合查询同样按 creator_user_id 过滤 + status 恒为 PUBLISHED(4)。
|
||||
*
|
||||
* 捕获点:selectMaps(Wrapper)。
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
void testSumPublishedByCreator_filtersStatusPublishedAndByCreator() {
|
||||
ProjectMapper mapper = mock(ProjectMapper.class, withSettings().defaultAnswer(CALLS_REAL_METHODS));
|
||||
doReturn(Collections.emptyList()).when(mapper).selectMaps(any(Wrapper.class));
|
||||
|
||||
Long targetCreator = 11L;
|
||||
mapper.sumPublishedByCreator(targetCreator);
|
||||
|
||||
ArgumentCaptor<Wrapper> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(mapper).selectMaps(captor.capture());
|
||||
assertPublishedAndByCreator(captor.getValue(), targetCreator, "公开统计聚合查询");
|
||||
}
|
||||
|
||||
// ============================== 断言/捕获辅助 ==============================
|
||||
|
||||
/** 从分页 default 链捕获最终下传给 MP selectPage(IPage,Wrapper) 的 Wrapper */
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
private Wrapper<?> captureWrapperFromSelectPage(ProjectMapper mapper) {
|
||||
ArgumentCaptor<Wrapper> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(mapper).selectPage(any(IPage.class), captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一断言:渲染 SQL 含 creator_user_id + status 过滤列;绑定参数含 PUBLISHED(4) 与目标创作者;且不放行下架(5)/封禁(6)。
|
||||
*
|
||||
* 顺序要点:先 getTargetSql() 触发段渲染(顺带物化 paramNameValuePairs),再读绑定参数。
|
||||
*/
|
||||
private void assertPublishedAndByCreator(Wrapper<?> wrapper, Long targetCreator, String desc) {
|
||||
String sql = wrapper.getTargetSql(); // 触发渲染并物化绑定参数
|
||||
assertTrue(sql.contains("creator_user_id"), desc + "必须按 creator_user_id 过滤(跨创作者公开读=按目标创作者):" + sql);
|
||||
assertTrue(sql.contains("status"), desc + "必须含 status 过滤:" + sql);
|
||||
|
||||
Map<String, Object> params = ((AbstractWrapper<?, ?, ?>) wrapper).getParamNameValuePairs();
|
||||
assertTrue(params.containsValue(ProjectStatusEnum.PUBLISHED.getStatus()),
|
||||
desc + "的 status 绑定值必须为 PUBLISHED(4)(公开=仅已发布,排除 5下架/6封禁):" + params);
|
||||
assertTrue(params.containsValue(targetCreator),
|
||||
desc + "的 creator_user_id 绑定值必须为目标创作者(非登录人):" + params);
|
||||
assertFalse(params.containsValue(ProjectStatusEnum.UNLISTED.getStatus()), desc + "不得放行已下架(5)");
|
||||
assertFalse(params.containsValue(ProjectStatusEnum.BANNED.getStatus()), desc + "不得放行已封禁(6)");
|
||||
}
|
||||
|
||||
/** 构造一个空的 MP 分页对象(仅供 default 链返回,不参与断言) */
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
private IPage<ProjectDO> emptyPage() {
|
||||
IPage<ProjectDO> page = mock(IPage.class);
|
||||
doReturn((List<ProjectDO>) Collections.<ProjectDO>emptyList()).when(page).getRecords();
|
||||
doReturn(0L).when(page).getTotal();
|
||||
return page;
|
||||
}
|
||||
|
||||
}
|
||||
@ -3,12 +3,15 @@ package com.wanxiang.huijing.game.module.project.service.project;
|
||||
import com.wanxiang.huijing.game.module.project.controller.admin.project.vo.ReviewReqVO;
|
||||
import com.wanxiang.huijing.game.module.project.controller.app.project.vo.ProjectCreateReqVO;
|
||||
import com.wanxiang.huijing.game.module.project.controller.app.project.vo.ProjectDraftReqVO;
|
||||
import com.wanxiang.huijing.game.module.project.controller.app.project.vo.ProjectPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.project.controller.app.project.vo.ProjectPublishReqVO;
|
||||
import com.wanxiang.huijing.game.module.project.controller.app.project.vo.ProjectPublishRespVO;
|
||||
import com.wanxiang.huijing.game.module.project.controller.app.project.vo.PublicCreatorProjectRespVO;
|
||||
import com.wanxiang.huijing.game.module.project.dal.dataobject.project.ProjectDO;
|
||||
import com.wanxiang.huijing.game.module.project.dal.dataobject.review.ReviewRecordDO;
|
||||
import com.wanxiang.huijing.game.module.project.dal.mysql.project.ProjectMapper;
|
||||
import com.wanxiang.huijing.game.module.project.dal.mysql.review.ReviewRecordMapper;
|
||||
import com.wanxiang.huijing.game.module.project.dto.CreatorPublicSummaryDTO;
|
||||
import com.wanxiang.huijing.game.module.project.enums.ProjectStatusEnum;
|
||||
import com.wanxiang.huijing.game.module.project.service.publish.PublishOrchestrationService;
|
||||
import com.wanxiang.huijing.game.module.compliance.api.ComplianceGateApi;
|
||||
@ -16,17 +19,23 @@ import com.wanxiang.huijing.game.module.compliance.dto.GateVerdict;
|
||||
import com.wanxiang.huijing.game.module.compliance.enums.ComplianceVerdictEnum;
|
||||
import com.wanxiang.huijing.framework.common.exception.ServiceException;
|
||||
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.wanxiang.huijing.module.system.api.passport.PlayerApi;
|
||||
import com.wanxiang.huijing.module.system.api.user.AdminUserApi;
|
||||
import com.wanxiang.huijing.module.system.api.user.dto.AdminUserRespDTO;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static com.wanxiang.huijing.game.module.project.enums.ErrorCodeConstants.*;
|
||||
import static com.wanxiang.huijing.module.system.enums.ErrorCodeConstants.PLAYER_CREATE_NOT_IN_WHITELIST;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
@ -52,6 +61,8 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
||||
private PublishOrchestrationService publishOrchestrationService; // §3.2 C2 发布编排(α auto-publish,APPROVE 时调用)
|
||||
@Mock
|
||||
private PlayerApi playerApi; // 2026-06-10 鉴权件 §6.3:createProject/submitPublish 入层创作者白名单校验依赖
|
||||
@Mock
|
||||
private AdminUserApi adminUserApi; // P-OPN-08:creatorName 解析(creatorUserId → system nickname)依赖
|
||||
|
||||
/**
|
||||
* 桩白名单校验放行(2026-06-10 鉴权件 §6.3):聚焦项目/发布编排逻辑,创作者视为已通过。
|
||||
@ -352,6 +363,115 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
||||
verify(reviewRecordMapper, never()).insert(any(ReviewRecordDO.class));
|
||||
}
|
||||
|
||||
// ============================== P-OPN-08 创作者公开聚合(跨创作者公开读 + status==4 + creatorName) ==============================
|
||||
|
||||
/**
|
||||
* 公开作品分页:跨创作者公开读——按"目标 creatorId(路径参数)"取数,且走 status 过滤查询(selectPublishedPageByCreator),
|
||||
* 不退化到"我的项目"登录人查询(selectMyPage)。这是跨创作者公开读不被归属约束的关键证据。
|
||||
*/
|
||||
@Test
|
||||
void testGetPublishedByCreator_crossCreator_usesTargetCreatorAndPublishedQuery() {
|
||||
// 当前若有登录人是 99,但目标创作者是 11(他人)——公开读必须取 11 的数据,不受 99 约束
|
||||
Long targetCreator = 11L;
|
||||
ProjectDO published = ownedProject(1L, targetCreator, ProjectStatusEnum.PUBLISHED.getStatus());
|
||||
published.setTitle("他人的已发布作品");
|
||||
published.setPlayCount(500L);
|
||||
published.setLikeCount(30L);
|
||||
when(projectMapper.selectPublishedPageByCreator(any(ProjectPageReqVO.class), eq(targetCreator)))
|
||||
.thenReturn(new PageResult<>(List.of(published), 1L));
|
||||
// creatorName 解析:目标创作者 11 的昵称
|
||||
AdminUserRespDTO user = new AdminUserRespDTO();
|
||||
user.setNickname("他人昵称");
|
||||
when(adminUserApi.getUser(targetCreator)).thenReturn(user);
|
||||
|
||||
PageResult<PublicCreatorProjectRespVO> page = projectService.getPublishedByCreator(targetCreator, new ProjectPageReqVO());
|
||||
|
||||
// 取的是目标创作者 11 的公开作品(跨创作者公开读成立)
|
||||
assertEquals(1L, page.getTotal());
|
||||
PublicCreatorProjectRespVO vo = page.getList().get(0);
|
||||
assertEquals(1L, vo.getId());
|
||||
assertEquals(targetCreator, vo.getCreatorId()); // creatorId 回填为目标创作者(必返)
|
||||
assertEquals("他人昵称", vo.getCreatorName()); // creatorName 尽力解析
|
||||
assertEquals(500L, vo.getPlayCount());
|
||||
assertEquals(30L, vo.getLikeCount());
|
||||
// 关键证据:走 status 过滤的公开查询,绝不走"我的项目"登录人查询(不被归属约束)
|
||||
verify(projectMapper).selectPublishedPageByCreator(any(ProjectPageReqVO.class), eq(targetCreator));
|
||||
verify(projectMapper, never()).selectMyPage(any(), any());
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开作品分页:creatorName 解析失败(用户查不到)→ creatorName 为 null,但 creatorId 仍必返(无 PII 兜底)。
|
||||
*/
|
||||
@Test
|
||||
void testGetPublishedByCreator_creatorNameNullSafe() {
|
||||
Long targetCreator = 11L;
|
||||
ProjectDO published = ownedProject(1L, targetCreator, ProjectStatusEnum.PUBLISHED.getStatus());
|
||||
when(projectMapper.selectPublishedPageByCreator(any(ProjectPageReqVO.class), eq(targetCreator)))
|
||||
.thenReturn(new PageResult<>(List.of(published), 1L));
|
||||
when(adminUserApi.getUser(targetCreator)).thenReturn(null); // 用户不存在/已删
|
||||
|
||||
PageResult<PublicCreatorProjectRespVO> page = projectService.getPublishedByCreator(targetCreator, new ProjectPageReqVO());
|
||||
|
||||
PublicCreatorProjectRespVO vo = page.getList().get(0);
|
||||
assertEquals(targetCreator, vo.getCreatorId()); // creatorId 必返
|
||||
assertNull(vo.getCreatorName()); // 查不到为 null(无 PII)
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开作品分页:目标创作者 0 公开作品 → 空 list、total=0(不报错)。
|
||||
*/
|
||||
@Test
|
||||
void testGetPublishedByCreator_emptyWhenNoPublished() {
|
||||
Long targetCreator = 11L;
|
||||
when(projectMapper.selectPublishedPageByCreator(any(ProjectPageReqVO.class), eq(targetCreator)))
|
||||
.thenReturn(new PageResult<>(List.of(), 0L));
|
||||
|
||||
PageResult<PublicCreatorProjectRespVO> page = projectService.getPublishedByCreator(targetCreator, new ProjectPageReqVO());
|
||||
|
||||
assertEquals(0L, page.getTotal());
|
||||
assertTrue(page.getList().isEmpty());
|
||||
// 空页不必解析昵称(无条目)——但即使解析也无害;此处只断言不报错与空结果
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开统计聚合:对目标创作者 status==4 作品聚合 发布数/累计试玩/累计点赞,数值正确。
|
||||
*/
|
||||
@Test
|
||||
void testGetPublishedSummaryByCreator_aggregates() {
|
||||
Long targetCreator = 11L;
|
||||
java.util.Map<String, Object> row = new java.util.HashMap<>();
|
||||
row.put("publishedCount", 3L);
|
||||
row.put("playSum", 1500L);
|
||||
row.put("likeSum", 90L);
|
||||
when(projectMapper.sumPublishedByCreator(targetCreator)).thenReturn(row);
|
||||
|
||||
CreatorPublicSummaryDTO dto = projectService.getPublishedSummaryByCreator(targetCreator);
|
||||
|
||||
assertEquals(3L, dto.getPublishedCount());
|
||||
assertEquals(1500L, dto.getTotalPlayCount());
|
||||
assertEquals(90L, dto.getTotalLikeCount());
|
||||
verify(projectMapper).sumPublishedByCreator(targetCreator); // 按目标创作者聚合(跨创作者公开读)
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开统计聚合:无公开作品(聚合行计数为 0)→ 各计数为 0(不报错、不返回 null)。
|
||||
*/
|
||||
@Test
|
||||
void testGetPublishedSummaryByCreator_zeroWhenNoPublished() {
|
||||
Long targetCreator = 11L;
|
||||
java.util.Map<String, Object> row = new java.util.HashMap<>();
|
||||
row.put("publishedCount", 0L);
|
||||
row.put("playSum", 0L);
|
||||
row.put("likeSum", 0L);
|
||||
when(projectMapper.sumPublishedByCreator(targetCreator)).thenReturn(row);
|
||||
|
||||
CreatorPublicSummaryDTO dto = projectService.getPublishedSummaryByCreator(targetCreator);
|
||||
|
||||
assertEquals(0L, dto.getPublishedCount());
|
||||
assertEquals(0L, dto.getTotalPlayCount());
|
||||
assertEquals(0L, dto.getTotalLikeCount());
|
||||
}
|
||||
|
||||
// ============================== 测试夹具 ==============================
|
||||
|
||||
/**
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
package com.wanxiang.huijing.game.module.telemetry.controller.app.stat;
|
||||
|
||||
import com.wanxiang.huijing.game.module.telemetry.controller.app.stat.vo.PublicCreatorSummaryRespVO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.service.stat.GameStatService;
|
||||
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.annotation.security.PermitAll;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* 产品端(game-studio)- 创作者公开统计控制器(P-OPN-08,只读聚合,匿名可访问)
|
||||
*
|
||||
* 端前缀 /app-api 由框架按包名 controller.app.* 自动添加。
|
||||
* 与 {@link AppMyStatController} 的区别:本控制器是<b>跨创作者公开读</b>——creatorId 为路径参数(目标创作者,非登录人),
|
||||
* 加 @PermitAll 匿名合法;公开 = 仅 status==4(已发布) 作品聚合,无 PII。刻意与"我的看板"(强制登录、归属隔离)分控制器,
|
||||
* 避免把公开读塞进登录态读端口、语义混淆。
|
||||
* 数据源经 project-api(发布态权威 + 计数列均在 project),本控制器只透传 Service。
|
||||
*
|
||||
* @author 绘境AI
|
||||
*/
|
||||
@Tag(name = "用户 App - 创作者公开统计")
|
||||
@RestController
|
||||
@RequestMapping("/telemetry/public")
|
||||
@Validated
|
||||
public class AppPublicStatController {
|
||||
|
||||
@Resource
|
||||
private GameStatService gameStatService;
|
||||
|
||||
@GetMapping("/creator/{creatorId}/summary")
|
||||
@PermitAll // P-OPN-08 匿名合法:创作者公开主页统计跨创作者公开读(仅 status==4 作品聚合,无 PII)
|
||||
@Operation(summary = "创作者公开统计聚合", description = "@PermitAll 跨创作者公开读;对该创作者 status==4 作品聚合 发布数/累计试玩/累计点赞")
|
||||
@Parameter(name = "creatorId", description = "创作者用户 ID", required = true, example = "99")
|
||||
public CommonResult<PublicCreatorSummaryRespVO> getPublicCreatorSummary(@PathVariable("creatorId") Long creatorId) {
|
||||
// 跨创作者公开读:目标创作者来自路径参数(非 getLoginUserId),聚合范围 = 该创作者 status==4 作品(经 project-api)
|
||||
return success(gameStatService.getPublicCreatorSummary(creatorId));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package com.wanxiang.huijing.game.module.telemetry.controller.app.stat.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 创作者公开统计聚合 Response VO(P-OPN-08,对外展示,无 PII)
|
||||
*
|
||||
* 对齐契约 telemetry.yaml PublicCreatorSummaryRespVO。
|
||||
* 口径:对目标创作者 status==4(已发布) 作品聚合 发布数 + 累计试玩 + 累计点赞。
|
||||
* 数据源:经 project-api {@code ProjectApi.getCreatorPublishedSummary} 取(发布态权威 + play_count/like_count 计数列均在 project,
|
||||
* 与 feed 卡 / 创作者公开作品列表同源)。跨创作者公开读:creatorId 为路径参数(目标创作者,非登录人)。
|
||||
* 无公开作品时各计数为 0(不报错)。
|
||||
*
|
||||
* @author 绘境AI
|
||||
*/
|
||||
@Schema(description = "用户 App - 创作者公开统计聚合 Response VO")
|
||||
@Data
|
||||
public class PublicCreatorSummaryRespVO {
|
||||
|
||||
@Schema(description = "创作者用户 ID(必返,作主页归属锚)", example = "99")
|
||||
private Long creatorId;
|
||||
|
||||
@Schema(description = "该创作者已发布(status==4)作品数", example = "12")
|
||||
private Long publishedCount;
|
||||
|
||||
@Schema(description = "该创作者已发布作品累计试玩数(跨作品求和 play_count)", example = "128000")
|
||||
private Long totalPlayCount;
|
||||
|
||||
@Schema(description = "该创作者已发布作品累计点赞数(跨作品求和 like_count)", example = "8600")
|
||||
private Long totalLikeCount;
|
||||
|
||||
}
|
||||
@ -3,6 +3,7 @@ package com.wanxiang.huijing.game.module.telemetry.service.stat;
|
||||
import com.wanxiang.huijing.game.module.telemetry.controller.admin.stat.vo.GameStatPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.controller.app.stat.vo.MyStatSummaryRespVO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.controller.app.stat.vo.MyTrendItemVO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.controller.app.stat.vo.PublicCreatorSummaryRespVO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.dal.dataobject.stat.GameStatDO;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
|
||||
@ -58,4 +59,16 @@ public interface GameStatService {
|
||||
*/
|
||||
List<MyTrendItemVO> getMyTrend(Long creatorUserId, Integer days);
|
||||
|
||||
/**
|
||||
* 创作者公开统计聚合(P-OPN-08,app 端 @PermitAll 跨创作者公开读)
|
||||
*
|
||||
* 与 getMySummary 的区别:① 公开读,creatorId 为路径参数(目标创作者,非登录人),不受归属隔离约束;
|
||||
* ② 口径为该创作者 status==4(已发布) 作品的 发布数 + 累计试玩 + 累计点赞,数据源经 project-api(发布态权威 + 计数列均在 project),
|
||||
* 不读 telemetry 聚合表(避免 telemetry 日聚合再去 join project 发布态的脆弱跨表)。无公开作品各计数返回 0(不报错)。
|
||||
*
|
||||
* @param creatorId 目标创作者用户 ID(路径参数)
|
||||
* @return 公开统计聚合 VO(含回填的 creatorId;各计数至少为 0,永不返回 null)
|
||||
*/
|
||||
PublicCreatorSummaryRespVO getPublicCreatorSummary(Long creatorId);
|
||||
|
||||
}
|
||||
|
||||
@ -2,9 +2,11 @@ package com.wanxiang.huijing.game.module.telemetry.service.stat;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import com.wanxiang.huijing.game.module.project.api.ProjectApi;
|
||||
import com.wanxiang.huijing.game.module.project.dto.CreatorPublicSummaryDTO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.controller.admin.stat.vo.GameStatPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.controller.app.stat.vo.MyStatSummaryRespVO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.controller.app.stat.vo.MyTrendItemVO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.controller.app.stat.vo.PublicCreatorSummaryRespVO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.dal.dataobject.stat.GameStatDO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.dal.mysql.stat.GameStatMapper;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
@ -116,6 +118,35 @@ public class GameStatServiceImpl implements GameStatService {
|
||||
return list;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PublicCreatorSummaryRespVO getPublicCreatorSummary(Long creatorId) {
|
||||
// P-OPN-08 跨创作者公开读:creatorId 为路径参数(目标创作者,非登录人),不走归属隔离。
|
||||
// 数据源经 project-api:对该创作者 status==4 作品聚合(发布数/累计试玩/累计点赞)——发布态权威 + play_count/like_count 计数列均在 project,
|
||||
// 故聚合收敛于 project,不读本模块日聚合表(避免 telemetry 日聚合再 join project 发布态的脆弱跨表)。
|
||||
// 经对方 -api 的 CommonResult.getCheckedData() 解包(失败抛业务异常,不静默吞)。
|
||||
PublicCreatorSummaryRespVO vo = new PublicCreatorSummaryRespVO();
|
||||
vo.setCreatorId(creatorId); // 回填目标创作者 ID(作主页归属锚,必返)
|
||||
// 公开主页只读聚合:契约「无作品→计数恒 0,永不报错」。下游(project)异常时【软降级零汇总】,
|
||||
// 不把瞬时下游故障变 5xx(与 list 端点的非脆失败语义对齐;评审 P2 整改)。失败/返回 null 统一兜零。
|
||||
CreatorPublicSummaryDTO dto;
|
||||
try {
|
||||
dto = projectApi.getCreatorPublishedSummary(creatorId).getCheckedData();
|
||||
} catch (Exception e) {
|
||||
log.warn("[getPublicCreatorSummary] project 侧聚合失败,软降级零汇总 creatorId={}", creatorId, e);
|
||||
dto = null;
|
||||
}
|
||||
if (dto == null) {
|
||||
vo.setPublishedCount(0L);
|
||||
vo.setTotalPlayCount(0L);
|
||||
vo.setTotalLikeCount(0L);
|
||||
return vo;
|
||||
}
|
||||
vo.setPublishedCount(dto.getPublishedCount());
|
||||
vo.setTotalPlayCount(dto.getTotalPlayCount());
|
||||
vo.setTotalLikeCount(dto.getTotalLikeCount());
|
||||
return vo;
|
||||
}
|
||||
|
||||
// ============================== 私有辅助(归属隔离 + 兜底) ==============================
|
||||
|
||||
/**
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
package com.wanxiang.huijing.game.module.telemetry.service.stat;
|
||||
|
||||
import com.wanxiang.huijing.game.module.project.api.ProjectApi;
|
||||
import com.wanxiang.huijing.game.module.project.dto.CreatorPublicSummaryDTO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.controller.app.stat.vo.MyStatSummaryRespVO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.controller.app.stat.vo.MyTrendItemVO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.controller.app.stat.vo.PublicCreatorSummaryRespVO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.dal.dataobject.stat.GameStatDO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.dal.mysql.stat.GameStatMapper;
|
||||
import com.wanxiang.huijing.framework.common.exception.ServiceException;
|
||||
@ -191,6 +193,68 @@ class GameStatServiceImplTest extends BaseMockitoUnitTest {
|
||||
.sumMyStatTrend(any(), any(LocalDate.class), any(LocalDate.class));
|
||||
}
|
||||
|
||||
// ============================== getPublicCreatorSummary 跨创作者公开读(P-OPN-08) ==============================
|
||||
|
||||
/**
|
||||
* 公开统计:跨创作者公开读——按"目标 creatorId(路径参数)"经 project-api 取聚合,回填 creatorId,数值透传正确。
|
||||
* 关键证据:调用的是 getCreatorPublishedSummary(目标 creatorId),不走"我的作品"归属隔离链(getMyGameIds/sumMyStat 零调用)。
|
||||
*/
|
||||
@Test
|
||||
void testGetPublicCreatorSummary_crossCreator_aggregatesViaProjectApi() {
|
||||
Long targetCreator = 11L; // 目标创作者(非登录人)
|
||||
CreatorPublicSummaryDTO dto = new CreatorPublicSummaryDTO();
|
||||
dto.setPublishedCount(3L);
|
||||
dto.setTotalPlayCount(1500L);
|
||||
dto.setTotalLikeCount(90L);
|
||||
when(projectApi.getCreatorPublishedSummary(targetCreator)).thenReturn(CommonResult.success(dto));
|
||||
|
||||
PublicCreatorSummaryRespVO vo = gameStatService.getPublicCreatorSummary(targetCreator);
|
||||
|
||||
assertEquals(targetCreator, vo.getCreatorId()); // 回填目标创作者(必返)
|
||||
assertEquals(3L, vo.getPublishedCount());
|
||||
assertEquals(1500L, vo.getTotalPlayCount());
|
||||
assertEquals(90L, vo.getTotalLikeCount());
|
||||
// 跨创作者公开读证据:经 project-api 按目标创作者取,绝不走"我的作品"归属隔离链
|
||||
verify(projectApi).getCreatorPublishedSummary(targetCreator);
|
||||
verify(projectApi, never()).getMyGameIds(any());
|
||||
verify(gameStatMapper, never()).sumMyStat(any());
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开统计:project 侧返回 null(防御边界)→ 各计数兜 0、creatorId 仍必返(永不 null)。
|
||||
*/
|
||||
@Test
|
||||
void testGetPublicCreatorSummary_nullDtoZeroGuard() {
|
||||
Long targetCreator = 11L;
|
||||
when(projectApi.getCreatorPublishedSummary(targetCreator)).thenReturn(CommonResult.success(null));
|
||||
|
||||
PublicCreatorSummaryRespVO vo = gameStatService.getPublicCreatorSummary(targetCreator);
|
||||
|
||||
assertEquals(targetCreator, vo.getCreatorId());
|
||||
assertEquals(0L, vo.getPublishedCount());
|
||||
assertEquals(0L, vo.getTotalPlayCount());
|
||||
assertEquals(0L, vo.getTotalLikeCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开统计:project 侧下游异常(getCheckedData 解包失败 / 瞬时 DB 错)→ 软降级零汇总,
|
||||
* 绝不把 5xx 抛给匿名公开页(契约「永不报错」;评审 P2 整改,与 list 端点非脆失败对齐)。
|
||||
*/
|
||||
@Test
|
||||
void testGetPublicCreatorSummary_downstreamError_softDegradesToZero() {
|
||||
Long targetCreator = 11L;
|
||||
when(projectApi.getCreatorPublishedSummary(targetCreator))
|
||||
.thenThrow(new RuntimeException("downstream boom"));
|
||||
|
||||
// 不抛异常:软降级为零汇总(creatorId 仍必返)
|
||||
PublicCreatorSummaryRespVO vo = gameStatService.getPublicCreatorSummary(targetCreator);
|
||||
|
||||
assertEquals(targetCreator, vo.getCreatorId());
|
||||
assertEquals(0L, vo.getPublishedCount());
|
||||
assertEquals(0L, vo.getTotalPlayCount());
|
||||
assertEquals(0L, vo.getTotalLikeCount());
|
||||
}
|
||||
|
||||
// ============================== 测试辅助 ==============================
|
||||
|
||||
/** 构造一行趋势聚合 Map(statDate 可为 LocalDate 或 java.sql.Date,验证类型兼容) */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user