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 b19430c5..cef2deeb 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,13 +38,34 @@ ${revision} - + com.wanxiang game-module-project-api ${revision} + + + com.wanxiang + game-module-trade-api + ${revision} + + + + + com.wanxiang + game-module-aigc-api + ${revision} + + + + + com.wanxiang + huijing-spring-boot-starter-excel + + com.wanxiang diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/controller/admin/stat/AdminTelemetryController.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/controller/admin/stat/AdminTelemetryController.java index e507a4ec..b6d908af 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/controller/admin/stat/AdminTelemetryController.java +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/controller/admin/stat/AdminTelemetryController.java @@ -1,24 +1,37 @@ package com.wanxiang.huijing.game.module.telemetry.controller.admin.stat; +import com.wanxiang.huijing.game.module.telemetry.controller.admin.stat.vo.DashboardExcelVO; +import com.wanxiang.huijing.game.module.telemetry.controller.admin.stat.vo.DashboardRespVO; import com.wanxiang.huijing.game.module.telemetry.controller.admin.stat.vo.GameStatPageReqVO; import com.wanxiang.huijing.game.module.telemetry.controller.admin.stat.vo.GameStatRespVO; import com.wanxiang.huijing.game.module.telemetry.convert.stat.GameStatConvert; import com.wanxiang.huijing.game.module.telemetry.dal.dataobject.stat.GameStatDO; +import com.wanxiang.huijing.game.module.telemetry.service.dashboard.DashboardService; import com.wanxiang.huijing.game.module.telemetry.service.stat.GameStatService; +import com.wanxiang.huijing.framework.apilog.core.annotation.ApiAccessLog; import com.wanxiang.huijing.framework.common.pojo.CommonResult; import com.wanxiang.huijing.framework.common.pojo.PageResult; +import com.wanxiang.huijing.framework.excel.core.util.ExcelUtils; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.io.IOException; +import java.time.LocalDate; +import java.util.Collections; + +import static com.wanxiang.huijing.framework.apilog.core.enums.OperateTypeEnum.EXPORT; import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; /** @@ -39,6 +52,10 @@ public class AdminTelemetryController { @Resource private GameStatService gameStatService; + /** 经营看板聚合 Service(U5 R-ADMIN,跨模块只读聚合 DAU/GMV/生成量/发布量) */ + @Resource + private DashboardService dashboardService; + @GetMapping("/game-stat/page") @Operation(summary = "游戏维度聚合分页", description = "创作者/运营看板数据源;本模块只供数据不渲染 UI") @PreAuthorize("@ss.hasPermission('telemetry:game-stat:query')") @@ -56,4 +73,40 @@ public class AdminTelemetryController { return success(GameStatConvert.toRespVO(stat)); } + // ============================== 经营看板(U5 R-ADMIN,跨模块只读聚合)============================== + + @GetMapping("/dashboard") + @Operation(summary = "经营看板聚合(U5)", description = "跨模块只读聚合 DAU(本模块)+GMV(trade)+生成量(aigc)+发布量(project);缺模块数据兜底 0 不 500") + @PreAuthorize("@ss.hasPermission('telemetry:dashboard:query')") + @Parameter(name = "statDateStart", description = "统计起始日(yyyy-MM-dd)", required = true, example = "2026-06-01") + @Parameter(name = "statDateEnd", description = "统计结束日(yyyy-MM-dd);DAU 取此日当天", required = true, example = "2026-06-30") + public CommonResult getDashboard( + @RequestParam("statDateStart") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateStart, + @RequestParam("statDateEnd") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateEnd) { + return success(dashboardService.getDashboard(statDateStart, statDateEnd)); + } + + @GetMapping("/dashboard/export-excel") + @Operation(summary = "经营看板导出 Excel(U5)", description = "单行导出经营看板四指标区间快照,供运营存档/汇报") + @PreAuthorize("@ss.hasPermission('telemetry:dashboard:query')") + @ApiAccessLog(operateType = EXPORT) + @Parameter(name = "statDateStart", description = "统计起始日(yyyy-MM-dd)", required = true, example = "2026-06-01") + @Parameter(name = "statDateEnd", description = "统计结束日(yyyy-MM-dd)", required = true, example = "2026-06-30") + public void exportDashboardExcel( + @RequestParam("statDateStart") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateStart, + @RequestParam("statDateEnd") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateEnd, + HttpServletResponse response) throws IOException { + DashboardRespVO dashboard = dashboardService.getDashboard(statDateStart, statDateEnd); + DashboardExcelVO excel = new DashboardExcelVO(); + excel.setStatDateStart(dashboard.getStatDateStart()); + excel.setStatDateEnd(dashboard.getStatDateEnd()); + excel.setDau(dashboard.getDau()); + excel.setGmv(dashboard.getGmv()); + excel.setGeneratedCount(dashboard.getGeneratedCount()); + excel.setPublishedCount(dashboard.getPublishedCount()); + excel.setDegraded(dashboard.getDegraded()); + // 单行导出(看板是一行聚合快照);导出口径与 /dashboard 完全一致 + ExcelUtils.write(response, "经营看板.xls", "数据", DashboardExcelVO.class, Collections.singletonList(excel)); + } + } diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/controller/admin/stat/vo/DashboardExcelVO.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/controller/admin/stat/vo/DashboardExcelVO.java new file mode 100644 index 00000000..478031ee --- /dev/null +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/controller/admin/stat/vo/DashboardExcelVO.java @@ -0,0 +1,39 @@ +package com.wanxiang.huijing.game.module.telemetry.controller.admin.stat.vo; + +import cn.idev.excel.annotation.ExcelProperty; +import lombok.Data; + +import java.time.LocalDate; + +/** + * 经营看板聚合 Excel VO(admin 端导出,U5 R-ADMIN 运营后端) + * + * 单行导出经营看板四指标(区间快照),供运营存档/汇报。字段口径与 {@link DashboardRespVO} 一致。 + * + * @author 造梦AI + */ +@Data +public class DashboardExcelVO { + + @ExcelProperty("统计起始日") + private LocalDate statDateStart; + + @ExcelProperty("统计结束日") + private LocalDate statDateEnd; + + @ExcelProperty("DAU日活") + private Long dau; + + @ExcelProperty("GMV原始收入(分)") + private Long gmv; + + @ExcelProperty("生成量") + private Long generatedCount; + + @ExcelProperty("发布量") + private Long publishedCount; + + @ExcelProperty("是否降级(部分数据源不可用)") + private Boolean degraded; + +} diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/controller/admin/stat/vo/DashboardRespVO.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/controller/admin/stat/vo/DashboardRespVO.java new file mode 100644 index 00000000..8d9c6f1c --- /dev/null +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/controller/admin/stat/vo/DashboardRespVO.java @@ -0,0 +1,45 @@ +package com.wanxiang.huijing.game.module.telemetry.controller.admin.stat.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDate; + +/** + * 经营看板聚合 Response VO(admin 端,U5 R-ADMIN 运营后端) + * + * 跨模块只读聚合四指标:DAU(本模块原始事件去重活跃身份)+ GMV(trade 营收)+ 生成量(aigc 成功任务)+ 发布量(project 已发布)。 + * 设计:缺模块数据走 null 安全兜底(对应指标为 0 + degraded 标记该指标降级),整体不 500(看板可用性优先)。 + * 跨模块 READ-ONLY:仅经各模块 -api 读,无任何写。 + * + * @author 造梦AI + */ +@Schema(description = "管理后台 - 经营看板聚合 Response VO") +@Data +public class DashboardRespVO { + + @Schema(description = "统计起始日", example = "2026-06-01") + private LocalDate statDateStart; + + @Schema(description = "统计结束日", example = "2026-06-30") + private LocalDate statDateEnd; + + @Schema(description = "DAU 日活(统计结束日当天去重活跃身份数;本模块原始事件聚合)", example = "1530") + private Long dau; + + @Schema(description = "GMV 区间原始收入合计(分账前,单位:分;trade 营收,缺 trade 兜底 0)", example = "1000000") + private Long gmv; + + @Schema(description = "区间生成量(aigc 成功生成任务数,缺 aigc 兜底 0)", example = "320") + private Long generatedCount; + + @Schema(description = "区间发布量(project 已发布项目数,缺 project 兜底 0)", example = "88") + private Long publishedCount; + + @Schema(description = "降级标记:true=有跨模块数据源不可用、对应指标已兜底 0(看板仍返回不 500)", example = "false") + private Boolean degraded; + + @Schema(description = "降级明细:哪些数据源不可用(如 ['trade','aigc']);正常为空", example = "[]") + private java.util.List degradedSources; + +} diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/dal/mysql/event/TelemetryEventMapper.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/dal/mysql/event/TelemetryEventMapper.java index 0563120b..c1530aa3 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/dal/mysql/event/TelemetryEventMapper.java +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/dal/mysql/event/TelemetryEventMapper.java @@ -1,10 +1,16 @@ package com.wanxiang.huijing.game.module.telemetry.dal.mysql.event; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; import com.wanxiang.huijing.game.module.telemetry.dal.dataobject.event.TelemetryEventDO; 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.Mapper; +import java.util.List; +import java.util.Map; + /** * 遥测原始事件 Mapper * @@ -29,4 +35,39 @@ public interface TelemetryEventMapper extends BaseMapperX { .eq(TelemetryEventDO::getEventId, eventId)); } + // ========== U5 经营看板 DAU(活跃去重):原始事件按 ts 区间去重活跃身份 ========== + + /** + * 区间内日活(DAU):原始事件按 ts(epoch ms) 区间去重活跃身份数(U5 经营看板本地数据源) + * + *

口径:DAU = 区间内有任意事件的去重活跃身份数 = 去重登录用户(user_id>0) + 去重匿名身份(anon_id 非空且 user_id 空/0)。 + * 登录与匿名分别 COUNT(DISTINCT) 后相加——同一自然人登录前后会被分别计入登录/匿名两类(MVP 看板可接受, + * 真精确合并需稳定 device→user 映射,属后续)。

+ *

仿 trade/telemetry 既有 selectMaps 聚合范式(不依赖 DO 列别名映射,跨方言稳); + * ts 为 epoch 毫秒,由 Service 把自然日端点换算为毫秒区间传入。tenant_id 由 MP 多租户拦截自动追加。

+ * + * @param tsStart 区间起始时间戳(epoch 毫秒,含) + * @param tsEnd 区间结束时间戳(epoch 毫秒,含) + * @return DAU(去重活跃身份数;无数据兜底 0) + */ + default Long countActiveIdentities(Long tsStart, Long tsEnd) { + // 登录用户去重数(仅统计 user_id>0 的登录态身份) + List> userRows = selectMaps(new QueryWrapperX() + .select("COUNT(DISTINCT user_id) AS userCnt") + .gt("user_id", 0) + .ge("ts", tsStart) + .le("ts", tsEnd)); + long userCnt = MapUtil.getLong(CollUtil.getFirst(userRows), "userCnt", 0L); + // 匿名身份去重数(user_id 缺失即匿名:user_id IS NULL OR =0;anon_id 非空才算有效匿名身份) + List> anonRows = selectMaps(new QueryWrapperX() + .select("COUNT(DISTINCT anon_id) AS anonCnt") + .and(w -> w.isNull("user_id").or().eq("user_id", 0)) + .ne("anon_id", "") + .isNotNull("anon_id") + .ge("ts", tsStart) + .le("ts", tsEnd)); + long anonCnt = MapUtil.getLong(CollUtil.getFirst(anonRows), "anonCnt", 0L); + return userCnt + anonCnt; + } + } diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/service/dashboard/DashboardService.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/service/dashboard/DashboardService.java new file mode 100644 index 00000000..2c60bf9f --- /dev/null +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/service/dashboard/DashboardService.java @@ -0,0 +1,26 @@ +package com.wanxiang.huijing.game.module.telemetry.service.dashboard; + +import com.wanxiang.huijing.game.module.telemetry.controller.admin.stat.vo.DashboardRespVO; + +import java.time.LocalDate; + +/** + * 经营看板聚合 Service 接口(U5 R-ADMIN,跨模块只读聚合) + * + * 承载:DAU(本模块原始事件去重活跃身份)+ GMV(trade)+ 生成量(aigc)+ 发布量(project)四指标聚合。 + * 跨模块 READ-ONLY:仅经各模块 -api 读,无写;缺模块数据 null 安全兜底,整体不 500(看板可用性优先)。 + * + * @author 造梦AI + */ +public interface DashboardService { + + /** + * 经营看板四指标聚合(statDateStart~statDateEnd 区间;DAU 取 statDateEnd 当日) + * + * @param statDateStart 统计起始日(含) + * @param statDateEnd 统计结束日(含) + * @return 看板聚合 VO(各指标至少为 0;有数据源不可用时 degraded=true 且对应指标兜底 0,永不抛 500) + */ + DashboardRespVO getDashboard(LocalDate statDateStart, LocalDate statDateEnd); + +} diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/service/dashboard/DashboardServiceImpl.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/service/dashboard/DashboardServiceImpl.java new file mode 100644 index 00000000..e2c1031e --- /dev/null +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/com/wanxiang/huijing/game/module/telemetry/service/dashboard/DashboardServiceImpl.java @@ -0,0 +1,126 @@ +package com.wanxiang.huijing.game.module.telemetry.service.dashboard; + +import com.wanxiang.huijing.game.module.aigc.api.AigcApi; +import com.wanxiang.huijing.game.module.project.api.ProjectApi; +import com.wanxiang.huijing.game.module.telemetry.controller.admin.stat.vo.DashboardRespVO; +import com.wanxiang.huijing.game.module.telemetry.dal.mysql.event.TelemetryEventMapper; +import com.wanxiang.huijing.game.module.trade.api.TradeApi; +import com.wanxiang.huijing.game.module.trade.dto.TradeRevenueReportDTO; +import com.wanxiang.huijing.framework.common.pojo.CommonResult; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +/** + * 经营看板聚合 Service 实现(U5 R-ADMIN,跨模块只读聚合) + * + * 四指标来源: + * - DAU:本模块 game_telemetry_event 去重活跃身份(取 statDateEnd 当日); + * - GMV:trade -api getRevenueReport(区间 totalGross); + * - 生成量:aigc -api countSucceededTasks(区间 status=succeeded); + * - 发布量:project -api countPublished(区间 status=PUBLISHED)。 + * 容错纪律(看板可用性优先):每个跨模块/本地取数各自 try-catch + 解包兜底—— + * 任一数据源不可用(Feign 异常/CommonResult 非 0/data=null)→ 该指标兜底 0 + degraded 标记,整体不 500。 + * 跨模块 READ-ONLY:仅经各模块 -api 读,无任何写。 + * + * @author 造梦AI + */ +@Slf4j +@Service +public class DashboardServiceImpl implements DashboardService { + + @Resource + private TelemetryEventMapper telemetryEventMapper; + + /** trade -api:GMV(getRevenueReport)。MVP 单体由 trade TradeApiImpl(@RestController @Primary) 就地解析。 */ + @Resource + private TradeApi tradeApi; + + /** aigc -api:生成量(countSucceededTasks)。MVP 单体由 aigc AigcApiImpl(@RestController @Primary) 就地解析。 */ + @Resource + private AigcApi aigcApi; + + /** project -api:发布量(countPublished)。MVP 单体由 project ProjectApiImpl(@RestController @Primary) 就地解析。 */ + @Resource + private ProjectApi projectApi; + + @Override + public DashboardRespVO getDashboard(LocalDate statDateStart, LocalDate statDateEnd) { + DashboardRespVO vo = new DashboardRespVO(); + vo.setStatDateStart(statDateStart); + vo.setStatDateEnd(statDateEnd); + // degradedSources:累计不可用的数据源名,非空即整体 degraded(看板前端据此提示"部分指标暂不可用") + List degraded = new ArrayList<>(4); + + // ① DAU(本地):取 statDateEnd 当日的去重活跃身份(自然日 → epoch 毫秒区间) + long dayStartMs = toEpochMilli(statDateEnd.atStartOfDay()); + long dayEndMs = toEpochMilli(statDateEnd.atTime(LocalTime.MAX)); + vo.setDau(safeCount("telemetry-dau", degraded, + () -> telemetryEventMapper.countActiveIdentities(dayStartMs, dayEndMs))); + + // ② GMV(trade -api):区间原始收入合计 totalGross(缺 trade 兜底 0) + vo.setGmv(safeCount("trade", degraded, () -> { + CommonResult result = tradeApi.getRevenueReport(statDateStart, statDateEnd); + // 经 CommonResult 解包(非 0 抛异常被外层 catch 兜底);data 或其 gross 为 null 时回 0 + TradeRevenueReportDTO report = result.getCheckedData(); + return report == null || report.getTotalGross() == null ? 0L : report.getTotalGross(); + })); + + // ③ 生成量(aigc -api):区间成功生成任务数(缺 aigc 兜底 0) + vo.setGeneratedCount(safeCount("aigc", degraded, + () -> aigcApi.countSucceededTasks(statDateStart, statDateEnd).getCheckedData())); + + // ④ 发布量(project -api):区间已发布项目数(缺 project 兜底 0) + vo.setPublishedCount(safeCount("project", degraded, + () -> projectApi.countPublished(statDateStart, statDateEnd).getCheckedData())); + + vo.setDegradedSources(degraded); + vo.setDegraded(!degraded.isEmpty()); + if (vo.getDegraded()) { + // 降级留痕:看板返回成功但有数据源缺失,运营/排障可据日志定位(不抛错、不阻断看板) + log.warn("[dashboard] 经营看板部分数据源不可用,已兜底 0:degradedSources={}, start={}, end={}", + degraded, statDateStart, statDateEnd); + } + return vo; + } + + /** + * 单指标取数兜底包装(看板可用性优先):执行取数 supplier,异常/返回 null 一律兜底 0 并登记降级源 + * + * 每个数据源独立兜底——一个模块不可用只降级它自己那一格,其余指标照常返回(整体不 500)。 + * + * @param source 数据源名(降级登记 + 日志用) + * @param degraded 降级源累计列表(不可用时追加 source) + * @param supplier 取数逻辑(可能抛 Feign 异常 / CommonResult 解包异常) + * @return 取到的计数;异常或 null 兜底 0 + */ + private long safeCount(String source, List degraded, Supplier supplier) { + try { + Long value = supplier.get(); + return value == null ? 0L : value; + } catch (Exception e) { + // 跨模块数据源不可用(Feign 超时/异常、CommonResult 非 0 解包抛错等):兜底 0 + 标记降级,不向上抛(看板不 500) + degraded.add(source); + log.warn("[dashboard] 数据源 {} 取数失败,该指标兜底 0:{}", source, e.getMessage()); + return 0L; + } + } + + /** + * 本地自然日时间 → epoch 毫秒(系统默认时区,与 telemetry 事件 ts 入库口径一致) + * + * @param dateTime 本地日期时间 + * @return epoch 毫秒 + */ + private static long toEpochMilli(java.time.LocalDateTime dateTime) { + return dateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + } + +} diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/com/wanxiang/huijing/game/module/telemetry/service/dashboard/DashboardServiceImplTest.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/com/wanxiang/huijing/game/module/telemetry/service/dashboard/DashboardServiceImplTest.java new file mode 100644 index 00000000..251789d0 --- /dev/null +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/com/wanxiang/huijing/game/module/telemetry/service/dashboard/DashboardServiceImplTest.java @@ -0,0 +1,167 @@ +package com.wanxiang.huijing.game.module.telemetry.service.dashboard; + +import com.wanxiang.huijing.game.module.aigc.api.AigcApi; +import com.wanxiang.huijing.game.module.project.api.ProjectApi; +import com.wanxiang.huijing.game.module.telemetry.controller.admin.stat.vo.DashboardRespVO; +import com.wanxiang.huijing.game.module.telemetry.dal.mysql.event.TelemetryEventMapper; +import com.wanxiang.huijing.game.module.trade.api.TradeApi; +import com.wanxiang.huijing.game.module.trade.dto.TradeRevenueReportDTO; +import com.wanxiang.huijing.framework.common.exception.ErrorCode; +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.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +/** + * {@link DashboardServiceImpl} 单元测试(纯 Mockito,不依赖 DB) + * + * 覆盖 U5 经营看板:四指标聚合正确(DAU/GMV/生成量/发布量)+ 缺模块数据 null 安全兜底(不 500)+ 跨模块只读。 + * 跨模块 -api(trade/aigc/project)与本地 TelemetryEventMapper 全 mock;按 KTD4 测矩阵覆盖正常 + 单源故障 + 全源故障。 + * + * @author 造梦AI + */ +class DashboardServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private DashboardServiceImpl dashboardService; + + @Mock + private TelemetryEventMapper telemetryEventMapper; // DAU 本地数据源 + @Mock + private TradeApi tradeApi; // GMV 跨模块源 + @Mock + private AigcApi aigcApi; // 生成量 跨模块源 + @Mock + private ProjectApi projectApi; // 发布量 跨模块源 + + private static final LocalDate START = LocalDate.of(2026, 6, 1); + private static final LocalDate END = LocalDate.of(2026, 6, 30); + + @Test + void testGetDashboard_allSourcesOk_aggregatesCorrectly() { + // 四源全可用:DAU=1530 / GMV(totalGross)=1000000 / 生成量=320 / 发布量=88 + when(telemetryEventMapper.countActiveIdentities(anyLong(), anyLong())).thenReturn(1530L); + TradeRevenueReportDTO revenue = new TradeRevenueReportDTO(); + revenue.setTotalGross(1_000_000L); + revenue.setTotalNet(800_000L); + when(tradeApi.getRevenueReport(START, END)).thenReturn(CommonResult.success(revenue)); + when(aigcApi.countSucceededTasks(START, END)).thenReturn(CommonResult.success(320L)); + when(projectApi.countPublished(START, END)).thenReturn(CommonResult.success(88L)); + + DashboardRespVO vo = dashboardService.getDashboard(START, END); + + assertEquals(1530L, vo.getDau()); + assertEquals(1_000_000L, vo.getGmv()); // GMV 取 totalGross + assertEquals(320L, vo.getGeneratedCount()); + assertEquals(88L, vo.getPublishedCount()); + assertFalse(vo.getDegraded()); // 全可用不降级 + assertTrue(vo.getDegradedSources().isEmpty()); + assertEquals(START, vo.getStatDateStart()); + assertEquals(END, vo.getStatDateEnd()); + } + + @Test + void testGetDashboard_tradeThrows_fallbackZeroNot500() { + // 缺模块兜底:trade -api 抛异常(Feign 不可用)→ GMV 兜底 0 + degraded 标记 trade,其余指标照常、整体不 500 + when(telemetryEventMapper.countActiveIdentities(anyLong(), anyLong())).thenReturn(1530L); + when(tradeApi.getRevenueReport(START, END)).thenThrow(new RuntimeException("trade feign down")); + when(aigcApi.countSucceededTasks(START, END)).thenReturn(CommonResult.success(320L)); + when(projectApi.countPublished(START, END)).thenReturn(CommonResult.success(88L)); + + DashboardRespVO vo = assertDoesNotThrow(() -> dashboardService.getDashboard(START, END)); + + assertEquals(0L, vo.getGmv()); // trade 故障 → GMV 兜底 0 + assertEquals(1530L, vo.getDau()); // 其余照常 + assertEquals(320L, vo.getGeneratedCount()); + assertEquals(88L, vo.getPublishedCount()); + assertTrue(vo.getDegraded()); + assertTrue(vo.getDegradedSources().contains("trade")); + assertEquals(1, vo.getDegradedSources().size()); // 仅 trade 降级 + } + + @Test + void testGetDashboard_aigcNon200CommonResult_fallbackZero() { + // CommonResult 非 0(getCheckedData 抛 ServiceException)也走兜底:生成量兜底 0 + degraded aigc + when(telemetryEventMapper.countActiveIdentities(anyLong(), anyLong())).thenReturn(100L); + TradeRevenueReportDTO revenue = new TradeRevenueReportDTO(); + revenue.setTotalGross(500L); + when(tradeApi.getRevenueReport(START, END)).thenReturn(CommonResult.success(revenue)); + // aigc 返回业务错误码 → getCheckedData() 抛 ServiceException,被兜底捕获 + when(aigcApi.countSucceededTasks(START, END)) + .thenReturn(CommonResult.error(new ErrorCode(1_101_000_999, "aigc 内部错误"))); + when(projectApi.countPublished(START, END)).thenReturn(CommonResult.success(88L)); + + DashboardRespVO vo = assertDoesNotThrow(() -> dashboardService.getDashboard(START, END)); + + assertEquals(0L, vo.getGeneratedCount()); // aigc 业务错误 → 兜底 0 + assertEquals(500L, vo.getGmv()); + assertEquals(88L, vo.getPublishedCount()); + assertTrue(vo.getDegradedSources().contains("aigc")); + } + + @Test + void testGetDashboard_tradeDataNull_fallbackZeroNoDegradeFromNullData() { + // trade 返回成功但 data=null(区间无营收)→ GMV 兜底 0,但这是正常空数据、非故障:不计降级 + when(telemetryEventMapper.countActiveIdentities(anyLong(), anyLong())).thenReturn(0L); + when(tradeApi.getRevenueReport(START, END)).thenReturn(CommonResult.success(null)); + when(aigcApi.countSucceededTasks(START, END)).thenReturn(CommonResult.success(0L)); + when(projectApi.countPublished(START, END)).thenReturn(CommonResult.success(0L)); + + DashboardRespVO vo = dashboardService.getDashboard(START, END); + + assertEquals(0L, vo.getGmv()); // data=null → 0 + assertFalse(vo.getDegraded()); // 正常空数据不算降级(只有抛异常才降级) + assertTrue(vo.getDegradedSources().isEmpty()); + } + + @Test + void testGetDashboard_allCrossModuleDown_dauStillFromLocal() { + // 全跨模块源故障:GMV/生成量/发布量 全兜底 0 + 三源降级,但本地 DAU 仍可取(看板核心可用性) + when(telemetryEventMapper.countActiveIdentities(anyLong(), anyLong())).thenReturn(999L); + when(tradeApi.getRevenueReport(any(), any())).thenThrow(new RuntimeException("down")); + when(aigcApi.countSucceededTasks(any(), any())).thenThrow(new RuntimeException("down")); + when(projectApi.countPublished(any(), any())).thenThrow(new RuntimeException("down")); + + DashboardRespVO vo = assertDoesNotThrow(() -> dashboardService.getDashboard(START, END)); + + assertEquals(999L, vo.getDau()); // 本地 DAU 仍取到 + assertEquals(0L, vo.getGmv()); + assertEquals(0L, vo.getGeneratedCount()); + assertEquals(0L, vo.getPublishedCount()); + assertTrue(vo.getDegraded()); + assertEquals(3, vo.getDegradedSources().size()); // trade/aigc/project 三源降级 + } + + @Test + void testGetDashboard_crossModuleReadOnly_noWriteMethods() { + // 跨模块只读:dashboard 仅调各 -api 的只读 count/report 方法,绝不触发任何写(归属/状态机/下架等写方法 never) + when(telemetryEventMapper.countActiveIdentities(anyLong(), anyLong())).thenReturn(1L); + TradeRevenueReportDTO revenue = new TradeRevenueReportDTO(); + revenue.setTotalGross(1L); + when(tradeApi.getRevenueReport(START, END)).thenReturn(CommonResult.success(revenue)); + when(aigcApi.countSucceededTasks(START, END)).thenReturn(CommonResult.success(1L)); + when(projectApi.countPublished(START, END)).thenReturn(CommonResult.success(1L)); + + dashboardService.getDashboard(START, END); + + // 只读方法被调用 + verify(tradeApi).getRevenueReport(START, END); + verify(aigcApi).countSucceededTasks(START, END); + verify(projectApi).countPublished(START, END); + // 写类方法绝不被调用(跨模块只读铁律) + verify(projectApi, never()).createProject(any(), any(), any()); + verify(projectApi, never()).unlistIfPublished(any()); + verify(aigcApi, never()).submitGenerate(any()); + verify(aigcApi, never()).retryTask(any(), any()); + verifyNoMoreInteractions(tradeApi); + } + +} diff --git a/game-cloud/game-module-trade/game-module-trade-server/pom.xml b/game-cloud/game-module-trade/game-module-trade-server/pom.xml index 3076f6e2..e3a92e81 100644 --- a/game-cloud/game-module-trade/game-module-trade-server/pom.xml +++ b/game-cloud/game-module-trade/game-module-trade-server/pom.xml @@ -67,6 +67,12 @@ huijing-spring-boot-starter-mybatis
+ + + com.wanxiang + huijing-spring-boot-starter-excel + + com.wanxiang diff --git a/game-cloud/game-module-trade/game-module-trade-server/src/main/java/com/wanxiang/huijing/game/module/trade/controller/admin/TradeAdminController.java b/game-cloud/game-module-trade/game-module-trade-server/src/main/java/com/wanxiang/huijing/game/module/trade/controller/admin/TradeAdminController.java index 1039c8f1..1964aa64 100644 --- a/game-cloud/game-module-trade/game-module-trade-server/src/main/java/com/wanxiang/huijing/game/module/trade/controller/admin/TradeAdminController.java +++ b/game-cloud/game-module-trade/game-module-trade-server/src/main/java/com/wanxiang/huijing/game/module/trade/controller/admin/TradeAdminController.java @@ -3,6 +3,7 @@ package com.wanxiang.huijing.game.module.trade.controller.admin; import com.wanxiang.huijing.game.module.trade.controller.admin.vo.GrantBalanceReqVO; import com.wanxiang.huijing.game.module.trade.controller.admin.vo.GrantSubscriptionReqVO; import com.wanxiang.huijing.game.module.trade.controller.admin.vo.IncomePageReqVO; +import com.wanxiang.huijing.game.module.trade.controller.admin.vo.RevenueExcelVO; import com.wanxiang.huijing.game.module.trade.controller.admin.vo.RevenueReportVO; import com.wanxiang.huijing.game.module.trade.controller.admin.vo.WithdrawAuditReqVO; import com.wanxiang.huijing.game.module.trade.controller.admin.vo.WithdrawPageReqVO; @@ -16,13 +17,16 @@ import com.wanxiang.huijing.game.module.trade.service.account.AccountService; import com.wanxiang.huijing.game.module.trade.service.income.IncomeService; import com.wanxiang.huijing.game.module.trade.service.subscription.SubscriptionService; import com.wanxiang.huijing.game.module.trade.service.withdraw.WithdrawService; +import com.wanxiang.huijing.framework.apilog.core.annotation.ApiAccessLog; import com.wanxiang.huijing.framework.common.pojo.CommonResult; import com.wanxiang.huijing.framework.common.pojo.PageResult; +import com.wanxiang.huijing.framework.excel.core.util.ExcelUtils; 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 jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.security.access.prepost.PreAuthorize; @@ -34,8 +38,11 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.io.IOException; import java.time.LocalDate; +import java.util.Collections; +import static com.wanxiang.huijing.framework.apilog.core.enums.OperateTypeEnum.EXPORT; import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; /** @@ -112,7 +119,41 @@ public class TradeAdminController { public CommonResult getRevenueReport( @RequestParam("statDateStart") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateStart, @RequestParam("statDateEnd") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateEnd) { - // 只读聚合:Σgross / Σnet 来自收益流水,已打款提现来自提现表;平台分成 = gross - net + return success(buildRevenueReport(statDateStart, statDateEnd)); + } + + @GetMapping("/report/revenue/export-excel") + @Operation(summary = "平台营收报表导出 Excel(U5)", description = "单行导出营收报表区间聚合(口径同 /report/revenue),供运营对账存档") + @PreAuthorize("@ss.hasPermission('trade:report:export')") + @ApiAccessLog(operateType = EXPORT) + @Parameter(name = "statDateStart", description = "统计起始日(yyyy-MM-dd)", required = true, example = "2026-06-01") + @Parameter(name = "statDateEnd", description = "统计结束日(yyyy-MM-dd)", required = true, example = "2026-06-30") + public void exportRevenueExcel( + @RequestParam("statDateStart") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateStart, + @RequestParam("statDateEnd") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateEnd, + HttpServletResponse response) throws IOException { + RevenueReportVO report = buildRevenueReport(statDateStart, statDateEnd); + RevenueExcelVO excel = new RevenueExcelVO(); + excel.setStatDateStart(report.getStatDateStart()); + excel.setStatDateEnd(report.getStatDateEnd()); + excel.setTotalGross(report.getTotalGross()); + excel.setTotalNet(report.getTotalNet()); + excel.setTotalPlatform(report.getTotalPlatform()); + excel.setTotalWithdrawPaid(report.getTotalWithdrawPaid()); + // 单行导出(营收报表是一行区间聚合);导出口径与 /report/revenue 完全一致 + ExcelUtils.write(response, "平台营收报表.xls", "数据", RevenueExcelVO.class, Collections.singletonList(excel)); + } + + /** + * 营收报表区间聚合(/report/revenue 与导出共用,避免口径漂移) + * + * 只读聚合:Σgross / Σnet 来自收益流水,已打款提现来自提现表;平台分成 = gross - net。 + * + * @param statDateStart 统计起始日(含) + * @param statDateEnd 统计结束日(含) + * @return 营收报表 VO(金额单位分) + */ + private RevenueReportVO buildRevenueReport(LocalDate statDateStart, LocalDate statDateEnd) { Long totalGross = incomeService.sumGross(statDateStart, statDateEnd); Long totalNet = incomeService.sumNet(statDateStart, statDateEnd); Long totalWithdrawPaid = withdrawService.sumPaidAmount(statDateStart, statDateEnd); @@ -124,7 +165,7 @@ public class TradeAdminController { report.setTotalNet(totalNet); report.setTotalPlatform(totalGross - totalNet); // 平台分成 = 原始收入 - 创作者实得 report.setTotalWithdrawPaid(totalWithdrawPaid); - return success(report); + return report; } // ============================== U2 经济:赋余额 / 赋订阅 ============================== diff --git a/game-cloud/game-module-trade/game-module-trade-server/src/main/java/com/wanxiang/huijing/game/module/trade/controller/admin/vo/RevenueExcelVO.java b/game-cloud/game-module-trade/game-module-trade-server/src/main/java/com/wanxiang/huijing/game/module/trade/controller/admin/vo/RevenueExcelVO.java new file mode 100644 index 00000000..1f0eca96 --- /dev/null +++ b/game-cloud/game-module-trade/game-module-trade-server/src/main/java/com/wanxiang/huijing/game/module/trade/controller/admin/vo/RevenueExcelVO.java @@ -0,0 +1,36 @@ +package com.wanxiang.huijing.game.module.trade.controller.admin.vo; + +import cn.idev.excel.annotation.ExcelProperty; +import lombok.Data; + +import java.time.LocalDate; + +/** + * 平台营收报表 Excel VO(admin 端导出,U5 R-ADMIN 运营后端) + * + * 单行导出营收报表区间聚合(口径同 {@link RevenueReportVO}),供运营对账存档。金额单位分。 + * + * @author 造梦AI + */ +@Data +public class RevenueExcelVO { + + @ExcelProperty("统计起始日") + private LocalDate statDateStart; + + @ExcelProperty("统计结束日") + private LocalDate statDateEnd; + + @ExcelProperty(value = "原始收入合计(分)") + private Long totalGross; + + @ExcelProperty(value = "创作者实得合计(分)") + private Long totalNet; + + @ExcelProperty(value = "平台分成合计(分)") + private Long totalPlatform; + + @ExcelProperty(value = "已打款提现合计(分)") + private Long totalWithdrawPaid; + +}