feat(telemetry): 创作者侧 stats 读端点(goal G2)——/app-api/telemetry/my/{summary,trend}

- 新增创作者侧只读端点:summary(跨我的作品聚合 totalPlay/totalComplete/completionRate)+ game-stat/trend(七日按日)
- 归属隔离:creatorUserId=getLoginUserId()(token,非前端入参)→ 经 ProjectApi.getMyGameIds 取我的 gameId 集合 → 仅聚合这些;空作品短路、completionRate 除零兜 0、days 夹紧[1,90]
- project 加 getMyGameIds(-api seam,镜像 ad/feed 既有消费范式);复用 game_telemetry_game_stat 聚合
- verify:telemetry 38 单测绿 + project test-compile 绿(mini);广告收益由 G1 income-summary 供(遥测表无收益列,已诚实占位)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-16 08:52:51 +00:00
parent 5463796f52
commit b51025e14f
14 changed files with 636 additions and 1 deletions

View File

@ -62,6 +62,46 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/CommonResultBoolean' }
# ===========================================================================
# 创作者看板数据源(产品端,登录态;跨"我的作品"只读聚合,供 studio 看板取数)
# 归属隔离:creatorUserId = getLoginUserId()(token 解析、非前端入参)→ 经 project 取"我的作品" gameId 集合 → 仅聚合这些 gameId,严防越权读他人数据。
# ad/income 口径:本模块聚合表 game_telemetry_game_stat 不含广告/收益列(Wave2 变现范围外,见文件头 ad/income 范围说明);adRevenueTotal 为预留字段,
# 待 ad/trade 收益对接点接通前恒为 0(非遗漏,避免越 trade 钱口径硬边界)。
# ===========================================================================
/app-api/telemetry/my/summary:
get:
tags: [app-telemetry]
summary: 我的作品汇总聚合(创作者看板;跨"我的全部作品"累计 试玩/完玩/完玩率/广告收益)
description: >-
登录创作者维度:先经 project 取"我的作品" gameId 集合,再跨这些 gameId 累加 game_telemetry_game_stat 全历史聚合,
得到 { totalPlay, totalComplete, completionRate, adRevenueTotal }。
completionRate = totalComplete / totalPlay(totalPlay=0 兜 0,避免除零);
adRevenueTotal 当前恒为 0(聚合表无收益列,待 ad/trade 收益对接点,见文件头说明)。
无作品或无聚合数据时各计数返回 0(不报错)。
responses:
'200':
description: 成功(创作者无作品时各字段为 0)
content:
application/json:
schema: { $ref: '#/components/schemas/CommonResultMyStatSummary' }
/app-api/telemetry/my/game-stat/trend:
get:
tags: [app-telemetry]
summary: 我的作品近 N 日趋势(创作者看板;跨"我的全部作品"按日聚合)
description: >-
登录创作者维度:先经 project 取"我的作品" gameId 集合,再跨这些 gameId 按 stat_date 聚合近 days 日
(含今日,倒推 days-1 天;按统计日升序返回,便于前端画折线)。某日无数据则该日不出现在 list(前端按 statDate 对齐补零)。
无作品时返回空 list(不报错)。
parameters:
- { name: days, in: query, required: false, schema: { type: integer, default: 7, minimum: 1, maximum: 90 }, description: 趋势天数(含今日倒推;默认 7,最大 90,越界由服务端夹紧) }
responses:
'200':
description: 成功(创作者无作品时 list 为空数组)
content:
application/json:
schema: { $ref: '#/components/schemas/CommonResultMyTrend' }
# ===========================================================================
# 看板数据源(管理端 RBAC;只读聚合,供产品域 UI 取数)
# ===========================================================================
@ -125,6 +165,21 @@ components:
list: { type: array, items: { $ref: '#/components/schemas/GameStatRespVO' } }
total: { type: integer, format: int64 }
msg: { type: string }
CommonResultMyStatSummary:
type: object
properties:
code: { type: integer }
data: { $ref: '#/components/schemas/MyStatSummaryRespVO' }
msg: { type: string }
CommonResultMyTrend:
type: object
properties:
code: { type: integer }
data:
type: object
properties:
list: { type: array, items: { $ref: '#/components/schemas/MyTrendItemVO' } }
msg: { type: string }
# ---- 事件信封(对齐契约 #5 events.schema.json envelope;此处为上报入参镜像)----
EnvelopeReqVO:
@ -196,6 +251,28 @@ components:
qualityScore: { type: number, format: float, description: '运营质量分(0-100)(T-TEL-04;回灌 feed 排序);≠ aigc 生成质量分(0-1),量纲不同、不可互相回灌' }
updateTime: { type: string, format: date-time }
# ---- 创作者看板 VO(跨"我的作品"聚合,归属隔离)----
MyStatSummaryRespVO:
type: object
description: >-
创作者"我的作品"汇总(跨我的全部 gameId 累计全历史聚合)。
adRevenueTotal 当前恒为 0(聚合表无收益列,待 ad/trade 收益对接点;见文件头 ad/income 范围说明)。
properties:
totalPlay: { type: integer, format: int64, description: '我的全部作品累计试玩数(跨 gameId 求和 play_count)' }
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 收益对接点,避免越钱口径硬边界)' }
MyTrendItemVO:
type: object
description: 我的作品某统计日的跨作品按日聚合项(趋势折线一个点)
properties:
statDate: { type: string, format: date, description: 统计日(yyyy-MM-dd) }
playCount: { type: integer, format: int64, description: '该日我的全部作品试玩数之和' }
completedCount: { type: integer, format: int64, description: '该日我的全部作品完玩数之和' }
playEndCount: { type: integer, format: int64, description: '该日我的全部作品试玩结束数之和' }
likeCount: { type: integer, format: int64, description: '该日我的全部作品点赞数之和' }
shareCount: { type: integer, format: int64, description: '该日我的全部作品分享数之和' }
# ===========================================================================
# 外部依赖对接点契约(MQ 异步消费=脊柱对接点;只定义契约,不实现、不 mock)
# ===========================================================================

View File

@ -101,6 +101,21 @@ public interface ProjectApi {
@RequestParam("title") String title,
@RequestParam("templateId") String templateId);
/**
* 按创作者用户 ID 查"我的作品" gameId 集合(创作者看板归属隔离专用)
*
* 用途:telemetry 创作者看板(/app-api/telemetry/my/*)据此把聚合范围严格限定为本人作品,严防越权读他人数据。
* 只读、显式只查 id 列、强制 creator_user_id 等值过滤;无作品返回空列表(非 null)。
* creatorUserId 由调用方在受信边界(getLoginUserId())取得后传入,不裸暴露给前端、不从匿名上下文推断。
*
* @param creatorUserId 创作者用户 ID(项目 owner)
* @return 我的全部 gameId 列表(CommonResult 包裹;无作品为空列表)
*/
@GetMapping(PREFIX + "/get-my-game-ids")
@Operation(summary = "按创作者反查我的全部 gameId(看板归属隔离)")
@Parameter(name = "creatorUserId", description = "创作者用户 ID", required = true, example = "99")
CommonResult<java.util.List<Long>> getMyGameIds(@RequestParam("creatorUserId") Long creatorUserId);
/**
* 封禁联动下架 seam(R3,compliance → project 状态机)
*

View File

@ -61,6 +61,12 @@ public class ProjectApiImpl implements ProjectApi {
return success(projectService.createProject(reqVO, creatorUserId));
}
@Override
public CommonResult<java.util.List<Long>> getMyGameIds(Long creatorUserId) {
// 创作者看板归属隔离:按 creator_user_id 返回我的全部 gameId;无作品返回空列表(telemetry 据此限定聚合范围)
return success(projectService.getMyGameIds(creatorUserId));
}
@Override
public CommonResult<Boolean> unlistIfPublished(Long gameId) {
// R3 封禁联动下架:委托 ProjectService(仅 PUBLISHED 走 reviewProject 状态机下架;非 PUBLISHED 返回 false 不抛异常)

View File

@ -8,6 +8,8 @@ import com.wanxiang.huijing.framework.mybatis.core.mapper.BaseMapperX;
import com.wanxiang.huijing.framework.mybatis.core.query.LambdaQueryWrapperX;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
* 游戏项目 Mapper
*
@ -84,4 +86,20 @@ public interface ProjectMapper extends BaseMapperX<ProjectDO> {
.eq(ProjectDO::getId, id)); // 命中主键
}
/**
* 按创作者用户 ID 查"我的作品" gameId 集合(创作者看板归属隔离,跨模块 Feign:telemetry 取我的聚合范围)
*
* 归属隔离基石:只取 id 单列、强制 creator_user_id 等值过滤——telemetry 看板据此把聚合范围严格限定在本人作品,
* 严防越权读他人数据。显式只查 id、不裸 select*、不加载整行;按 id 倒序保证集合稳定。
*
* @param creatorUserId 创作者用户 ID(由 telemetry 侧 getLoginUserId() 受信传入,非前端入参)
* @return 我的全部 gameId 列表(只含 id 列的 DO 列表;无作品返回空列表,非 null)
*/
default List<ProjectDO> selectGameIdsByCreatorUserId(Long creatorUserId) {
return selectList(new LambdaQueryWrapperX<ProjectDO>()
.select(ProjectDO::getId) // 显式只查游戏 ID 列
.eq(ProjectDO::getCreatorUserId, creatorUserId) // 强制按创作者过滤(归属隔离)
.orderByDesc(ProjectDO::getId));
}
}

View File

@ -114,6 +114,17 @@ public interface ProjectService {
*/
Integer getStatus(Long gameId);
/**
* 按创作者用户 ID 查"我的作品" gameId 集合(创作者看板归属隔离专用,供跨模块 Feign 调用,如 telemetry 我的聚合)
*
* 只读、显式只查 id 单列、强制 creator_user_id 等值过滤;无作品返回空列表(非 null)。
* 归属隔离基石:telemetry 看板据此把聚合范围限定在本人作品,严防越权读他人数据。
*
* @param creatorUserId 创作者用户 ID(调用方在受信边界由 getLoginUserId() 取得后传入)
* @return 我的全部 gameId 列表;无作品返回空列表
*/
java.util.List<Long> getMyGameIds(Long creatorUserId);
/**
* 取游戏流卡片本体元信息(title/coverUrl/playCount/likeCount,供 feed -api 回填)
*

View File

@ -273,6 +273,15 @@ public class ProjectServiceImpl implements ProjectService {
return project == null ? null : project.getStatus();
}
@Override
public List<Long> getMyGameIds(Long creatorUserId) {
// 创作者看板归属隔离只读:显式只查 id 单列、强制 creator_user_id 过滤;无作品返回空列表(非 null)
// telemetry 看板据此把聚合范围严格限定为本人作品,从源头杜绝越权读他人数据
return projectMapper.selectGameIdsByCreatorUserId(creatorUserId).stream()
.map(ProjectDO::getId)
.collect(java.util.stream.Collectors.toList());
}
@Override
public ProjectFeedMetaDTO getFeedMeta(Long gameId) {
// feed 出流回填:读 game_project 本体元信息映射为对外 DTO;游戏不存在返回 null(feed 侧留空不阻断出流)

View File

@ -38,6 +38,13 @@
<version>${revision}</version>
</dependency>
<!-- 依赖 project 的 -api:创作者看板归属隔离(getMyGameIds 取"我的作品" gameId 集合后限定聚合范围;只依赖对方 -api 不依赖 -server,守门④无环) -->
<dependency>
<groupId>com.wanxiang</groupId>
<artifactId>game-module-project-api</artifactId>
<version>${revision}</version>
</dependency>
<!-- 业务组件:数据权限 + 多租户(DO 继承 TenantBaseDO) -->
<dependency>
<groupId>com.wanxiang</groupId>

View File

@ -0,0 +1,62 @@
package com.wanxiang.huijing.game.module.telemetry.controller.app.stat;
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.service.stat.GameStatService;
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils;
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 org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
/**
* 产品端(game-studio)- 创作者看板数据源控制器(只读聚合)
*
* 端前缀 /app-api 由框架按包名 controller.app.* 自动添加。
* 鉴权:需用户 Token(不加 @PermitAll,匿名不可访问);creatorUserId = getLoginUserId()(token 解析、非前端入参)。
* 归属隔离:聚合范围严格限定为"我的作品"(Service 经 project 取 gameId 集合后限定),创作者只见自己数据,严防越权读他人数据。
* 本模块只供数据,不渲染看板 UI(供 studio 取数画完玩率/广告收益/七日趋势)。
*
* @author 绘境AI
*/
@Tag(name = "用户 App - 创作者看板数据源")
@RestController
@RequestMapping("/telemetry/my")
@Validated
public class AppMyStatController {
@Resource
private GameStatService gameStatService;
@GetMapping("/summary")
@Operation(summary = "我的作品汇总聚合", description = "跨我的全部作品累计 试玩/完玩/完玩率/广告收益;创作者只见自己数据")
public CommonResult<MyStatSummaryRespVO> getMySummary() {
// 鉴权门:登录态必有,creatorUserId 由 token 解析(非前端入参),作归属隔离唯一可信来源
Long creatorUserId = SecurityFrameworkUtils.getLoginUserId();
return success(gameStatService.getMySummary(creatorUserId));
}
@GetMapping("/game-stat/trend")
@Operation(summary = "我的作品近 N 日趋势", description = "跨我的全部作品按日聚合(统计日升序);默认 7 日,越界服务端夹紧到 [1,90]")
@Parameter(name = "days", description = "趋势天数(含今日倒推;默认 7,最大 90)", example = "7")
public CommonResult<Map<String, List<MyTrendItemVO>>> getMyTrend(
@RequestParam(value = "days", required = false, defaultValue = "7") Integer days) {
// 鉴权门:登录态必有,creatorUserId 由 token 解析(非前端入参),作归属隔离唯一可信来源
Long creatorUserId = SecurityFrameworkUtils.getLoginUserId();
List<MyTrendItemVO> list = gameStatService.getMyTrend(creatorUserId, days);
// 契约约定外层包 { list:[...] }(与 admin game-stat/page 的 list 风格一致,便于前端解构与未来扩展分页/汇总字段)
return success(Map.of("list", list));
}
}

View File

@ -0,0 +1,35 @@
package com.wanxiang.huijing.game.module.telemetry.controller.app.stat.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 创作者「我的作品」汇总聚合 Response VO(对齐契约 MyStatSummaryRespVO)
*
* 跨当前登录创作者的全部作品(gameId 集合)累计全历史聚合:试玩/完玩/完玩率/广告收益。
* 归属隔离:聚合范围 = 经 project 取得的「我的作品」gameId 集合(getLoginUserId 受信传入),严防越权读他人数据。
*
* 量纲/口径约定:
* - completionRate = totalComplete / totalPlay(0-1 比值,totalPlay=0 兜 0,避免除零)。
* - adRevenueTotal 当前恒为 0:本模块聚合表 game_telemetry_game_stat 不含广告/收益列(Wave2 变现范围外,见 telemetry.yaml 文件头 ad/income 范围说明);
* 为预留字段,待 ad/trade 收益对接点接通后再回填,刻意不越 trade 钱口径硬边界。
*
* @author 绘境AI
*/
@Schema(description = "用户 App - 我的作品汇总聚合 Response VO")
@Data
public class MyStatSummaryRespVO {
@Schema(description = "我的全部作品累计试玩数(跨 gameId 求和 play_count)", example = "12800")
private Long totalPlay;
@Schema(description = "我的全部作品累计完玩数(跨 gameId 求和 completed_count)", example = "8600")
private Long totalComplete;
@Schema(description = "完玩率 = totalComplete / totalPlay(0-1 比值;totalPlay=0 兜 0)", example = "0.6719")
private Float completionRate;
@Schema(description = "我的全部作品累计广告收益(当前恒为 0,预留字段;待 ad/trade 收益对接点)", example = "0.0")
private Float adRevenueTotal;
}

View File

@ -0,0 +1,38 @@
package com.wanxiang.huijing.game.module.telemetry.controller.app.stat.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDate;
/**
* 创作者「我的作品」近 N 日趋势项 Response VO(对齐契约 MyTrendItemVO;趋势折线的一个点)
*
* 跨当前登录创作者全部作品(gameId 集合)按 stat_date 聚合的某一日数据。某日无数据则该日不出现在 list,
* 前端按 statDate 对齐补零。归属隔离同 summary:聚合范围严格限定为「我的作品」。
*
* @author 绘境AI
*/
@Schema(description = "用户 App - 我的作品近 N 日趋势项 VO")
@Data
public class MyTrendItemVO {
@Schema(description = "统计日(yyyy-MM-dd)", example = "2026-06-15")
private LocalDate statDate;
@Schema(description = "该日我的全部作品试玩数之和", example = "1280")
private Long playCount;
@Schema(description = "该日我的全部作品完玩数之和", example = "860")
private Long completedCount;
@Schema(description = "该日我的全部作品试玩结束数之和", example = "1100")
private Long playEndCount;
@Schema(description = "该日我的全部作品点赞数之和", example = "320")
private Long likeCount;
@Schema(description = "该日我的全部作品分享数之和", example = "64")
private Long shareCount;
}

View File

@ -1,15 +1,21 @@
package com.wanxiang.huijing.game.module.telemetry.dal.mysql.stat;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.map.MapUtil;
import com.wanxiang.huijing.game.module.telemetry.controller.admin.stat.vo.GameStatPageReqVO;
import com.wanxiang.huijing.game.module.telemetry.dal.dataobject.stat.GameStatDO;
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 org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* 游戏维度聚合 Mapper(看板数据源只读端)
@ -50,6 +56,50 @@ public interface GameStatMapper extends BaseMapperX<GameStatDO> {
.last("LIMIT 1"));
}
/**
* 创作者「我的作品」全历史汇总聚合(跨给定 gameId 集合求和 play_count / completed_count)
*
* 仿 trade IncomeMapper.sumGrossBetween 的 selectMaps 聚合范式(比 DO 投影更稳,不依赖列别名映射)。
* 归属隔离:gameIds 由 telemetry Service 经 project 取「我的作品」后传入,强制 game_id IN (...) 限定范围,严防越权读他人数据。
* 显式列聚合(SUM),不裸 select*;调用方须保证 gameIds 非空(空集合不应进入本查询,由 Service 短路)。
*
* @param gameIds 我的全部 gameId 集合(必须非空)
* @return 含 playSum/completeSum 的单行 Map(无数据兜底 0);供 Service 取值组装 summary
*/
default Map<String, Object> sumMyStat(Collection<Long> gameIds) {
return CollUtil.getFirst(selectMaps(new QueryWrapperX<GameStatDO>()
.select("IFNULL(SUM(play_count), 0) AS playSum",
"IFNULL(SUM(completed_count), 0) AS completeSum")
.in("game_id", gameIds)));
}
/**
* 创作者「我的作品」近 N 日趋势聚合(跨给定 gameId 集合,按 stat_date 分组求和,统计日升序)
*
* 同 selectMaps 聚合范式;归属隔离同 sumMyStat(game_id IN 我的作品)。日期区间 [startDate, 今日] 含端点。
* 显式列聚合 + GROUP BY stat_date + ORDER BY stat_date ASC(便于前端画折线,缺失日由前端对齐补零)。
* 调用方须保证 gameIds 非空(空集合由 Service 短路返回空列表)。
*
* @param gameIds 我的全部 gameId 集合(必须非空)
* @param startDate 趋势起始日(含;= 今日倒推 days-1 天)
* @param endDate 趋势结束日(含;通常为今日)
* @return 每行含 statDate/playSum/completeSum/playEndSum/likeSum/shareSum 的 Map 列表(按 statDate 升序);无数据返回空列表
*/
default List<Map<String, Object>> sumMyStatTrend(Collection<Long> gameIds, LocalDate startDate, LocalDate endDate) {
return selectMaps(new QueryWrapperX<GameStatDO>()
.select("stat_date AS statDate",
"IFNULL(SUM(play_count), 0) AS playSum",
"IFNULL(SUM(completed_count), 0) AS completeSum",
"IFNULL(SUM(play_end_count), 0) AS playEndSum",
"IFNULL(SUM(like_count), 0) AS likeSum",
"IFNULL(SUM(share_count), 0) AS shareSum")
.in("game_id", gameIds)
.ge("stat_date", startDate)
.le("stat_date", endDate)
.groupBy("stat_date")
.orderByAsc("stat_date"));
}
/**
* 按 游戏×统计日 取聚合行(黄金闭环 §3.5 C5 同步写累加:命中 uk_game_date 唯一键)
*

View File

@ -1,13 +1,18 @@
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.dal.dataobject.stat.GameStatDO;
import com.wanxiang.huijing.framework.common.pojo.PageResult;
import java.util.List;
/**
* 游戏维度聚合 Service 接口(看板数据源只读端)
*
* 承载"快照存在性校验"等读端规则;本模块只供数据,不渲染看板 UI。
* 创作者看板(getMySummary/getMyTrend)额外承载"归属隔离"读端规则:先经 project 取"我的作品"再限定聚合范围。
*
* @author 绘境AI
*/
@ -29,4 +34,28 @@ public interface GameStatService {
*/
GameStatDO getLatestGameStat(Long gameId);
/**
* 创作者「我的作品」汇总聚合(app 端创作者看板;跨我的全部作品累计 试玩/完玩/完玩率/广告收益)
*
* 归属隔离:先经 project 取 creatorUserId 的"我的作品" gameId 集合,仅对这些 gameId 聚合,严防越权读他人数据。
* 无作品/无聚合数据时各计数返回 0(不报错)。completionRate=totalComplete/totalPlay(totalPlay=0 兜 0)。
* adRevenueTotal 当前恒为 0(聚合表无收益列,待 ad/trade 收益对接点)。
*
* @param creatorUserId 当前登录创作者 ID(由 token 解析,非前端入参)
* @return 我的作品汇总 VO(各字段至少为 0,永不返回 null)
*/
MyStatSummaryRespVO getMySummary(Long creatorUserId);
/**
* 创作者「我的作品」近 N 日趋势(app 端创作者看板;跨我的全部作品按日聚合,统计日升序)
*
* 归属隔离同 getMySummary。日期窗口 = 含今日倒推 days-1 天;某日无数据则该日不在 list(前端对齐补零)。
* days 越界由服务端夹紧到 [1, 90]。无作品时返回空列表(不报错)。
*
* @param creatorUserId 当前登录创作者 ID(由 token 解析,非前端入参)
* @param days 趋势天数(含今日倒推;越界夹紧到 [1,90])
* @return 趋势项列表(按统计日升序;无数据为空列表,永不返回 null)
*/
List<MyTrendItemVO> getMyTrend(Long creatorUserId, Integer days);
}

View File

@ -1,6 +1,10 @@
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.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.dal.dataobject.stat.GameStatDO;
import com.wanxiang.huijing.game.module.telemetry.dal.mysql.stat.GameStatMapper;
import com.wanxiang.huijing.framework.common.pojo.PageResult;
@ -8,6 +12,12 @@ import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static com.wanxiang.huijing.game.module.telemetry.enums.ErrorCodeConstants.TELEMETRY_GAME_STAT_NOT_EXISTS;
import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
@ -16,6 +26,8 @@ import static com.wanxiang.huijing.framework.common.exception.util.ServiceExcept
*
* 纯读:分页与最新快照查询;存在性校验在此承载,可单测、可追溯。
* 聚合写入(增量累加 / quality_score 计算)属消费侧职责,不在本读端服务实现(见 mq.consumer 对接点)。
* 创作者看板(getMySummary/getMyTrend)承载"归属隔离":先经 project 取"我的作品" gameId 集合,仅聚合这些 gameId,
* 从源头杜绝越权读他人数据;"我的作品"为空时短路返回零值/空列表,不发起 IN() 空集合查询。
*
* @author 绘境AI
*/
@ -23,9 +35,21 @@ import static com.wanxiang.huijing.framework.common.exception.util.ServiceExcept
@Service
public class GameStatServiceImpl implements GameStatService {
/** 趋势天数下限(含今日;夹紧用) */
private static final int TREND_DAYS_MIN = 1;
/** 趋势天数上限(防止跨度过大扫描;夹紧用,与契约 maximum 一致) */
private static final int TREND_DAYS_MAX = 90;
@Resource
private GameStatMapper gameStatMapper;
/**
* 依赖 project 的 -api:创作者看板取"我的作品" gameId 集合(归属隔离基石;跨模块只依赖对方 -api,禁依赖 -server)。
* MVP 单体:由 project 的 ProjectApiImpl(@RestController @Primary) 就地解析。
*/
@Resource
private ProjectApi projectApi;
@Override
public PageResult<GameStatDO> getGameStatPage(GameStatPageReqVO reqVO) {
return gameStatMapper.selectStatPage(reqVO);
@ -41,4 +65,107 @@ public class GameStatServiceImpl implements GameStatService {
return stat;
}
@Override
public MyStatSummaryRespVO getMySummary(Long creatorUserId) {
MyStatSummaryRespVO vo = new MyStatSummaryRespVO();
// 归属隔离第 1 步:经 project 取"我的作品" gameId 集合(强制按 creatorUserId 过滤,越权读不可达)
List<Long> myGameIds = listMyGameIds(creatorUserId);
if (myGameIds.isEmpty()) {
// 无作品:短路返回零值,不发起 IN() 空集合查询(空 IN 在不同方言下语义不一,且无意义)
return zeroSummary(vo);
}
// 归属隔离第 2 步:仅对"我的作品" gameId 集合聚合(IN 范围即隔离边界)
Map<String, Object> row = gameStatMapper.sumMyStat(myGameIds);
long totalPlay = MapUtil.getLong(row, "playSum", 0L);
long totalComplete = MapUtil.getLong(row, "completeSum", 0L);
vo.setTotalPlay(totalPlay);
vo.setTotalComplete(totalComplete);
// 完玩率 = 完玩 / 试玩;分母为 0 兜 0,避免除零
vo.setCompletionRate(safeRate(totalComplete, totalPlay));
// adRevenueTotal 当前恒为 0:聚合表无收益列(Wave2 变现范围外,待 ad/trade 收益对接点;不越钱口径硬边界)
vo.setAdRevenueTotal(0f);
return vo;
}
@Override
public List<MyTrendItemVO> getMyTrend(Long creatorUserId, Integer days) {
// 归属隔离第 1 步:经 project 取"我的作品" gameId 集合
List<Long> myGameIds = listMyGameIds(creatorUserId);
if (myGameIds.isEmpty()) {
// 无作品:返回空列表(不发起空 IN 查询)
return new ArrayList<>();
}
// days 夹紧到 [1,90](含今日倒推),防越界/负数/跨度过大全表扫
int span = clampDays(days);
LocalDate today = LocalDate.now();
LocalDate startDate = today.minusDays(span - 1L); // 含今日,故倒推 span-1 天
// 归属隔离第 2 步:仅对"我的作品"按日聚合(IN 范围即隔离边界),统计日升序
List<Map<String, Object>> rows = gameStatMapper.sumMyStatTrend(myGameIds, startDate, today);
List<MyTrendItemVO> list = new ArrayList<>(rows.size());
for (Map<String, Object> row : rows) {
MyTrendItemVO item = new MyTrendItemVO();
// statDate:selectMaps 回来的日期可能是 java.sql.Date / LocalDate,统一转 LocalDate(null 安全)
item.setStatDate(toLocalDate(row.get("statDate")));
item.setPlayCount(MapUtil.getLong(row, "playSum", 0L));
item.setCompletedCount(MapUtil.getLong(row, "completeSum", 0L));
item.setPlayEndCount(MapUtil.getLong(row, "playEndSum", 0L));
item.setLikeCount(MapUtil.getLong(row, "likeSum", 0L));
item.setShareCount(MapUtil.getLong(row, "shareSum", 0L));
list.add(item);
}
return list;
}
// ============================== 私有辅助(归属隔离 + 兜底) ==============================
/**
* 经 project 取当前创作者的"我的作品" gameId 集合(归属隔离基石)。
* 经对方 -api 的 CommonResult.getCheckedData() 解包(失败抛业务异常,不静默吞);防御性兜底为不可变空列表。
*/
private List<Long> listMyGameIds(Long creatorUserId) {
List<Long> ids = projectApi.getMyGameIds(creatorUserId).getCheckedData();
return ids == null ? Collections.emptyList() : ids;
}
/** 汇总零值兜底(无作品时各计数为 0、收益为 0、完玩率为 0) */
private MyStatSummaryRespVO zeroSummary(MyStatSummaryRespVO vo) {
vo.setTotalPlay(0L);
vo.setTotalComplete(0L);
vo.setCompletionRate(0f);
vo.setAdRevenueTotal(0f);
return vo;
}
/** 完玩率 = 完玩 / 试玩;分母为 0 兜 0,避免除零 */
private float safeRate(long numerator, long denominator) {
if (denominator <= 0L) {
return 0f;
}
return (float) ((double) numerator / (double) denominator);
}
/** days 夹紧到 [TREND_DAYS_MIN, TREND_DAYS_MAX];null 取契约默认 7 */
private int clampDays(Integer days) {
int d = days == null ? 7 : days;
if (d < TREND_DAYS_MIN) {
return TREND_DAYS_MIN;
}
return Math.min(d, TREND_DAYS_MAX);
}
/** 数据库日期对象 → LocalDate(兼容 java.sql.Date / LocalDate;null 安全) */
private LocalDate toLocalDate(Object value) {
if (value == null) {
return null;
}
if (value instanceof LocalDate) {
return (LocalDate) value;
}
if (value instanceof java.sql.Date) {
return ((java.sql.Date) value).toLocalDate();
}
// 兜底:其它日期表示(如 java.util.Date 子类)走 toString 解析(yyyy-MM-dd),失败则交由上层感知
return LocalDate.parse(value.toString());
}
}

View File

@ -1,23 +1,40 @@
package com.wanxiang.huijing.game.module.telemetry.service.stat;
import com.wanxiang.huijing.game.module.project.api.ProjectApi;
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.dal.dataobject.stat.GameStatDO;
import com.wanxiang.huijing.game.module.telemetry.dal.mysql.stat.GameStatMapper;
import com.wanxiang.huijing.framework.common.exception.ServiceException;
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static com.wanxiang.huijing.game.module.telemetry.enums.ErrorCodeConstants.TELEMETRY_GAME_STAT_NOT_EXISTS;
import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link GameStatServiceImpl} 单元测试(纯 Mockito,不依赖 DB)
*
* 覆盖看板读端规则守卫:最新快照存在性校验(不存在抛错,不返回 null)。
* 覆盖:
* 1. 看板读端规则守卫:最新快照存在性校验(不存在抛错,不返回 null)。
* 2. 创作者看板归属隔离:只见自己作品(聚合范围 = ProjectApi 返回的 gameId 集合)、空作品短路不查库、
* 汇总聚合正确、completionRate 除零兜底、趋势 days 夹紧与按日映射、空数据兜底。
*
* @author 绘境AI
*/
@ -29,6 +46,9 @@ class GameStatServiceImplTest extends BaseMockitoUnitTest {
@Mock
private GameStatMapper gameStatMapper;
@Mock
private ProjectApi projectApi; // 归属隔离来源:取"我的作品" gameId 集合
// ============================== getLatestGameStat 存在性校验 ==============================
@Test
@ -55,4 +75,135 @@ class GameStatServiceImplTest extends BaseMockitoUnitTest {
assertEquals(0, new BigDecimal("78.50").compareTo(result.getQualityScore()));
}
// ============================== getMySummary 归属隔离 + 聚合 + 除零 ==============================
/** 汇总:仅对"我的作品" gameId 集合聚合,且数值正确、完玩率正常 */
@Test
void testGetMySummary_aggregatesOnlyMyGames() {
// 当前创作者 99 的"我的作品" = [10, 20](归属隔离边界)
when(projectApi.getMyGameIds(99L)).thenReturn(CommonResult.success(List.of(10L, 20L)));
Map<String, Object> row = new HashMap<>();
row.put("playSum", 1000L);
row.put("completeSum", 600L);
when(gameStatMapper.sumMyStat(List.of(10L, 20L))).thenReturn(row);
MyStatSummaryRespVO vo = gameStatService.getMySummary(99L);
assertEquals(1000L, vo.getTotalPlay());
assertEquals(600L, vo.getTotalComplete());
assertEquals(0.6f, vo.getCompletionRate(), 1e-6); // 600/1000
assertEquals(0f, vo.getAdRevenueTotal()); // 预留字段恒 0
// 归属隔离断言:聚合参数必须是 ProjectApi 给的 gameId 集合(不含他人游戏)
verify(gameStatMapper).sumMyStat(List.of(10L, 20L));
}
/** 汇总:无作品 → 短路返回零值,且绝不发起 IN() 空集合查库(防越权/防空 IN) */
@Test
void testGetMySummary_noGames_shortCircuit() {
when(projectApi.getMyGameIds(99L)).thenReturn(CommonResult.success(emptyList()));
MyStatSummaryRespVO vo = gameStatService.getMySummary(99L);
assertEquals(0L, vo.getTotalPlay());
assertEquals(0L, vo.getTotalComplete());
assertEquals(0f, vo.getCompletionRate());
assertEquals(0f, vo.getAdRevenueTotal());
verify(gameStatMapper, never()).sumMyStat(any()); // 无作品不查库
}
/** 汇总:试玩数为 0 → 完玩率除零兜底为 0(不抛 ArithmeticException) */
@Test
void testGetMySummary_completionRateDivideByZeroGuard() {
when(projectApi.getMyGameIds(99L)).thenReturn(CommonResult.success(singletonList(10L)));
Map<String, Object> row = new HashMap<>();
row.put("playSum", 0L);
row.put("completeSum", 0L);
when(gameStatMapper.sumMyStat(singletonList(10L))).thenReturn(row);
MyStatSummaryRespVO vo = gameStatService.getMySummary(99L);
assertEquals(0L, vo.getTotalPlay());
assertEquals(0f, vo.getCompletionRate()); // 除零保护
}
// ============================== getMyTrend 归属隔离 + 映射 + 夹紧 ==============================
/** 趋势:仅对"我的作品"按日聚合,行映射正确、按日期升序透传 */
@Test
void testGetMyTrend_mapsRowsForMyGames() {
when(projectApi.getMyGameIds(99L)).thenReturn(CommonResult.success(List.of(10L, 20L)));
Map<String, Object> r1 = trendRow(LocalDate.of(2026, 6, 14), 100L, 60L, 80L, 5L, 1L);
Map<String, Object> r2 = trendRow(LocalDate.of(2026, 6, 15), 200L, 120L, 160L, 9L, 2L);
when(gameStatMapper.sumMyStatTrend(eq(List.of(10L, 20L)), any(LocalDate.class), any(LocalDate.class)))
.thenReturn(List.of(r1, r2));
List<MyTrendItemVO> list = gameStatService.getMyTrend(99L, 7);
assertEquals(2, list.size());
assertEquals(LocalDate.of(2026, 6, 14), list.get(0).getStatDate());
assertEquals(100L, list.get(0).getPlayCount());
assertEquals(60L, list.get(0).getCompletedCount());
assertEquals(80L, list.get(0).getPlayEndCount());
assertEquals(5L, list.get(0).getLikeCount());
assertEquals(1L, list.get(0).getShareCount());
assertEquals(LocalDate.of(2026, 6, 15), list.get(1).getStatDate());
assertEquals(200L, list.get(1).getPlayCount());
}
/** 趋势:java.sql.Date 的 statDate 也能正确转 LocalDate(兼容驱动返回类型) */
@Test
void testGetMyTrend_sqlDateStatDateConverted() {
when(projectApi.getMyGameIds(99L)).thenReturn(CommonResult.success(singletonList(10L)));
Map<String, Object> row = trendRow(java.sql.Date.valueOf("2026-06-15"), 10L, 5L, 8L, 0L, 0L);
when(gameStatMapper.sumMyStatTrend(any(), any(LocalDate.class), any(LocalDate.class)))
.thenReturn(singletonList(row));
List<MyTrendItemVO> list = gameStatService.getMyTrend(99L, 7);
assertEquals(1, list.size());
assertEquals(LocalDate.of(2026, 6, 15), list.get(0).getStatDate());
}
/** 趋势:无作品 → 返回空列表,且不查库 */
@Test
void testGetMyTrend_noGames_emptyList() {
when(projectApi.getMyGameIds(99L)).thenReturn(CommonResult.success(emptyList()));
List<MyTrendItemVO> list = gameStatService.getMyTrend(99L, 7);
assertTrue(list.isEmpty());
verify(gameStatMapper, never()).sumMyStatTrend(any(), any(), any());
}
/** 趋势:days 越界(0/超 90/null)被夹紧到 [1,90],仍正常查库(不抛、不全表扫) */
@Test
void testGetMyTrend_daysClamped() {
// 用具体入参 99L 桩(避免 anyLong() 原始类型 matcher 与 Long 入参自动装箱冲突,污染 Mockito matcher 栈)
when(projectApi.getMyGameIds(99L)).thenReturn(CommonResult.success(singletonList(10L)));
when(gameStatMapper.sumMyStatTrend(any(), any(LocalDate.class), any(LocalDate.class)))
.thenReturn(emptyList());
// 三个越界入参都不应抛异常,均能落到查库(夹紧由 Service 内部完成)
assertDoesNotThrow(() -> gameStatService.getMyTrend(99L, 0));
assertDoesNotThrow(() -> gameStatService.getMyTrend(99L, 9999));
assertDoesNotThrow(() -> gameStatService.getMyTrend(99L, null));
verify(gameStatMapper, org.mockito.Mockito.times(3))
.sumMyStatTrend(any(), any(LocalDate.class), any(LocalDate.class));
}
// ============================== 测试辅助 ==============================
/** 构造一行趋势聚合 Map(statDate 可为 LocalDate 或 java.sql.Date,验证类型兼容) */
private Map<String, Object> trendRow(Object statDate, long play, long complete,
long playEnd, long like, long share) {
Map<String, Object> row = new HashMap<>();
row.put("statDate", statDate);
row.put("playSum", play);
row.put("completeSum", complete);
row.put("playEndSum", playEnd);
row.put("likeSum", like);
row.put("shareSum", share);
return row;
}
}