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 尽力解析(查不到为 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) }

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

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

View File

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

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) {
// 跨创作者公开读:目标创作者来自路径参数(非 getLoginUserId),Service/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 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;
}

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-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 端,按状态取,默认审核中)
*

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-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 端,默认取审核中)
*

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-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)

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

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-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());
}
// ============================== 测试夹具 ==============================
/**

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

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

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(防御边界)→ 各计数兜 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,验证类型兼容) */