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:
lili 2026-06-18 13:56:46 -07:00
commit 8238bf2d7a
17 changed files with 756 additions and 0 deletions

View File

@ -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 尽力解析(查不到为 nullcreatorId 必返。
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 }

View File

@ -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 / totalPlaytotalPlay=0 兜 00-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: 我的作品某统计日的跨作品按日聚合项(趋势折线一个点)

View File

@ -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-08telemetry 公开统计数据源telemetry project-api
*
* 口径对目标创作者 status==PUBLISHED(4) 作品聚合 发布数 + 累计试玩(play_count) + 累计点赞(like_count)
* 发布态权威 + 计数列均在 project故聚合收敛在 project 只读幂等 PII无公开作品各计数为 0DTO 永不为 null
* 跨创作者公开读creatorId 为目标创作者非登录人不受归属约束game_project DataPermission 规则
*
* @param creatorId 目标创作者用户 ID
* @return 公开统计聚合 DTOCommonResult 包裹各计数至少为 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);
}

View File

@ -0,0 +1,27 @@
package com.wanxiang.huijing.game.module.project.dto;
import lombok.Data;
import java.io.Serializable;
/**
* 创作者公开统计聚合 DTOP-OPN-08project 对外契约 -api
*
* 口径对某创作者 status==PUBLISHED(4) 作品聚合 发布数 + 累计试玩(play_count) + 累计点赞(like_count)
* 发布态权威 + 计数列均在 projectgame_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;
}

View File

@ -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));
}
}

View File

@ -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) {
// 跨创作者公开读目标创作者来自路径参数 getLoginUserIdService/Mapper 按目标 creatorId + status==4 过滤不受登录人归属约束
return success(projectService.getPublishedByCreator(creatorId, pageReqVO));
}
@GetMapping("/{id}")
@Operation(summary = "项目详情")
@Parameter(name = "id", description = "项目编号", required = true, example = "1024")

View File

@ -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 VOP-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;
}

View File

@ -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-08app @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跨模块 Feigntelemetry 公开统计取
*
* 口径对目标创作者 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 按状态取默认审核中
*

View File

@ -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-08app @PermitAll 跨创作者公开读
*
* 公开语义 status==PUBLISHED(4) 一态排除 5下架/6封禁/草稿/审核中等软删行由 MP 逻辑删除自动排除
* 跨创作者公开读creatorId 为路径参数目标创作者非登录人故不按登录人过滤使任意访客浏览任意创作者主页
* 过滤口径与 DataPermission 说明见 {@link com.wanxiang.huijing.game.module.project.dal.mysql.project.ProjectMapper#selectPublishedPageByCreator}
* 返回直接组装为对外 VO PII每条尽力解析 creatorNamecreatorUserId system nickname查不到为 nullcreatorId 必返
*
* @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 默认取审核中
*

View File

@ -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-08creatorId 为路径参数目标创作者非登录人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=0SUM 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();
}
/**
* 组装创作者公开作品卡 VOP-OPN-08对外展示 PII
*
* publishTime update_time 近似项目表无独立 publish_time ProjectMapper.countPublishedBetween / ProjectApi.countPublished 口径
* creatorName 由调用方按页统一解析后传入同页恒同一 creatorId避免逐条重复 RPCcreatorId 必返
*
* @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

View File

@ -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 模块测试基建仅 Mockitoproject-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;
}
}

View File

@ -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-publishAPPROVE 时调用
@Mock
private PlayerApi playerApi; // 2026-06-10 鉴权件 §6.3createProject/submitPublish 入层创作者白名单校验依赖
@Mock
private AdminUserApi adminUserApi; // P-OPN-08creatorName 解析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 公开作品 listtotal=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());
}
// ============================== 测试夹具 ==============================
/**

View File

@ -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));
}
}

View File

@ -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 VOP-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;
}

View File

@ -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-08app @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);
}

View File

@ -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;
}
// ============================== 私有辅助归属隔离 + 兜底 ==============================
/**

View File

@ -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防御边界 各计数兜 0creatorId 仍必返永不 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());
}
// ============================== 测试辅助 ==============================
/** 构造一行趋势聚合 MapstatDate 可为 LocalDate 或 java.sql.Date验证类型兼容 */