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:
parent
5463796f52
commit
b51025e14f
@ -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)
|
||||
# ===========================================================================
|
||||
|
||||
@ -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 状态机)
|
||||
*
|
||||
|
||||
@ -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 不抛异常)
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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 回填)
|
||||
*
|
||||
|
||||
@ -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 侧留空不阻断出流)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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 唯一键)
|
||||
*
|
||||
|
||||
@ -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);
|
||||
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user