diff --git a/contracts/api-schemas/telemetry.yaml b/contracts/api-schemas/telemetry.yaml index 90671744..9815509d 100644 --- a/contracts/api-schemas/telemetry.yaml +++ b/contracts/api-schemas/telemetry.yaml @@ -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) # =========================================================================== diff --git a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectApi.java b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectApi.java index 7c871dff..158e5473 100644 --- a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectApi.java +++ b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectApi.java @@ -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> getMyGameIds(@RequestParam("creatorUserId") Long creatorUserId); + /** * 封禁联动下架 seam(R3,compliance → project 状态机) * diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectApiImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectApiImpl.java index 0a080614..ea1587d6 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectApiImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectApiImpl.java @@ -61,6 +61,12 @@ public class ProjectApiImpl implements ProjectApi { return success(projectService.createProject(reqVO, creatorUserId)); } + @Override + public CommonResult> getMyGameIds(Long creatorUserId) { + // 创作者看板归属隔离:按 creator_user_id 返回我的全部 gameId;无作品返回空列表(telemetry 据此限定聚合范围) + return success(projectService.getMyGameIds(creatorUserId)); + } + @Override public CommonResult unlistIfPublished(Long gameId) { // R3 封禁联动下架:委托 ProjectService(仅 PUBLISHED 走 reviewProject 状态机下架;非 PUBLISHED 返回 false 不抛异常) diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/dal/mysql/project/ProjectMapper.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/dal/mysql/project/ProjectMapper.java index f7cdae77..5664d9fa 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/dal/mysql/project/ProjectMapper.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/dal/mysql/project/ProjectMapper.java @@ -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 { .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 selectGameIdsByCreatorUserId(Long creatorUserId) { + return selectList(new LambdaQueryWrapperX() + .select(ProjectDO::getId) // 显式只查游戏 ID 列 + .eq(ProjectDO::getCreatorUserId, creatorUserId) // 强制按创作者过滤(归属隔离) + .orderByDesc(ProjectDO::getId)); + } + } diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/project/ProjectService.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/project/ProjectService.java index f67027b9..efea1a35 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/project/ProjectService.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/project/ProjectService.java @@ -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 getMyGameIds(Long creatorUserId); + /** * 取游戏流卡片本体元信息(title/coverUrl/playCount/likeCount,供 feed -api 回填) * diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/project/ProjectServiceImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/project/ProjectServiceImpl.java index a336e874..69f26143 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/project/ProjectServiceImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/project/ProjectServiceImpl.java @@ -273,6 +273,15 @@ public class ProjectServiceImpl implements ProjectService { return project == null ? null : project.getStatus(); } + @Override + public List 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 侧留空不阻断出流) diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/pom.xml b/game-cloud/game-module-telemetry/game-module-telemetry-server/pom.xml index dd93fce1..b19430c5 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/pom.xml +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/pom.xml @@ -38,6 +38,13 @@ ${revision} + + + com.wanxiang + game-module-project-api + ${revision} + + com.wanxiang diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/controller/app/stat/AppMyStatController.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/controller/app/stat/AppMyStatController.java new file mode 100644 index 00000000..fdba4073 --- /dev/null +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/controller/app/stat/AppMyStatController.java @@ -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 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>> getMyTrend( + @RequestParam(value = "days", required = false, defaultValue = "7") Integer days) { + // 鉴权门:登录态必有,creatorUserId 由 token 解析(非前端入参),作归属隔离唯一可信来源 + Long creatorUserId = SecurityFrameworkUtils.getLoginUserId(); + List list = gameStatService.getMyTrend(creatorUserId, days); + // 契约约定外层包 { list:[...] }(与 admin game-stat/page 的 list 风格一致,便于前端解构与未来扩展分页/汇总字段) + return success(Map.of("list", list)); + } + +} diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/controller/app/stat/vo/MyStatSummaryRespVO.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/controller/app/stat/vo/MyStatSummaryRespVO.java new file mode 100644 index 00000000..ac193b36 --- /dev/null +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/controller/app/stat/vo/MyStatSummaryRespVO.java @@ -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; + +} diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/controller/app/stat/vo/MyTrendItemVO.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/controller/app/stat/vo/MyTrendItemVO.java new file mode 100644 index 00000000..7f9e2717 --- /dev/null +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/controller/app/stat/vo/MyTrendItemVO.java @@ -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; + +} diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/dal/mysql/stat/GameStatMapper.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/dal/mysql/stat/GameStatMapper.java index d2104c46..2446b9c7 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/dal/mysql/stat/GameStatMapper.java +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/dal/mysql/stat/GameStatMapper.java @@ -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 { .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 sumMyStat(Collection gameIds) { + return CollUtil.getFirst(selectMaps(new QueryWrapperX() + .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> sumMyStatTrend(Collection gameIds, LocalDate startDate, LocalDate endDate) { + return selectMaps(new QueryWrapperX() + .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 唯一键) * diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/service/stat/GameStatService.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/service/stat/GameStatService.java index 62a5bb3b..dda9d29f 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/service/stat/GameStatService.java +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/service/stat/GameStatService.java @@ -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 getMyTrend(Long creatorUserId, Integer days); + } diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/service/stat/GameStatServiceImpl.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/service/stat/GameStatServiceImpl.java index 5e23ddb9..a1c9bf7c 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/service/stat/GameStatServiceImpl.java +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/service/stat/GameStatServiceImpl.java @@ -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 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 myGameIds = listMyGameIds(creatorUserId); + if (myGameIds.isEmpty()) { + // 无作品:短路返回零值,不发起 IN() 空集合查询(空 IN 在不同方言下语义不一,且无意义) + return zeroSummary(vo); + } + // 归属隔离第 2 步:仅对"我的作品" gameId 集合聚合(IN 范围即隔离边界) + Map 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 getMyTrend(Long creatorUserId, Integer days) { + // 归属隔离第 1 步:经 project 取"我的作品" gameId 集合 + List 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> rows = gameStatMapper.sumMyStatTrend(myGameIds, startDate, today); + List list = new ArrayList<>(rows.size()); + for (Map 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 listMyGameIds(Long creatorUserId) { + List 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()); + } + } diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/com/wanxiang/huijing/game/module/telemetry/service/stat/GameStatServiceImplTest.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/com/wanxiang/huijing/game/module/telemetry/service/stat/GameStatServiceImplTest.java index 287fc2c5..a9eb24ef 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/com/wanxiang/huijing/game/module/telemetry/service/stat/GameStatServiceImplTest.java +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/com/wanxiang/huijing/game/module/telemetry/service/stat/GameStatServiceImplTest.java @@ -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 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 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 r1 = trendRow(LocalDate.of(2026, 6, 14), 100L, 60L, 80L, 5L, 1L); + Map 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 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 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 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 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 trendRow(Object statDate, long play, long complete, + long playEnd, long like, long share) { + Map 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; + } + }