feat(telemetry): U5 经营看板聚合+导出 + 营收报表导出
经营看板(跨模块只读聚合,缺数据兜底不 500): - 新端点 GET /admin-api/telemetry/dashboard(telemetry:dashboard:query)→ DashboardRespVO,聚合四指标: DAU(本模块 game_telemetry_event 去重活跃身份:登录 user_id + 匿名 anon_id,取 statDateEnd 当日, 新增 TelemetryEventMapper.countActiveIdentities)+ GMV(trade-api)+ 生成量(aigc-api)+ 发布量(project-api)。 - DashboardServiceImpl 容错:每个数据源独立 try-catch + CommonResult 解包兜底——任一源不可用→该指标兜底 0 + degraded/degradedSources 标记,整体不 500(看板可用性优先);正常空数据(data=null)不算降级。 - 导出 GET /admin-api/telemetry/dashboard/export-excel(EXPORT 审计)单行导出看板快照。 - telemetry-server 加 trade-api/aigc-api 依赖 + starter-excel。 营收报表导出:TradeAdminController 加 GET /admin-api/trade/report/revenue/export-excel (trade:report:export + EXPORT 审计),抽 buildRevenueReport() 与 /report/revenue 共用口径避免漂移。 测试:DashboardServiceImplTest 6(全可用聚合/单源抛异常兜底/CommonResult非0兜底/data=null不降级/ 全跨模块故障DAU仍可取/跨模块只读 never 写方法)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f85ae6c434
commit
ac7ffe4a92
@ -38,13 +38,34 @@
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 依赖 project 的 -api:创作者看板归属隔离(getMyGameIds 取"我的作品" gameId 集合后限定聚合范围;只依赖对方 -api 不依赖 -server,守门④无环) -->
|
||||
<!-- 依赖 project 的 -api:创作者看板归属隔离(getMyGameIds 取"我的作品" gameId 集合后限定聚合范围;只依赖对方 -api 不依赖 -server,守门④无环)
|
||||
+ U5 经营看板「发布量」(countPublished) -->
|
||||
<dependency>
|
||||
<groupId>com.wanxiang</groupId>
|
||||
<artifactId>game-module-project-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 依赖 trade 的 -api:U5 经营看板「GMV」(getRevenueReport;只依赖对方 -api 不依赖 -server,守门④无环) -->
|
||||
<dependency>
|
||||
<groupId>com.wanxiang</groupId>
|
||||
<artifactId>game-module-trade-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 依赖 aigc 的 -api:U5 经营看板「生成量」(countSucceededTasks;只依赖对方 -api 不依赖 -server,守门④无环) -->
|
||||
<dependency>
|
||||
<groupId>com.wanxiang</groupId>
|
||||
<artifactId>game-module-aigc-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Excel 导出(U5 经营看板/收益行导出 ExcelUtils + @ExcelProperty) -->
|
||||
<dependency>
|
||||
<groupId>com.wanxiang</groupId>
|
||||
<artifactId>huijing-spring-boot-starter-excel</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 业务组件:数据权限 + 多租户(DO 继承 TenantBaseDO) -->
|
||||
<dependency>
|
||||
<groupId>com.wanxiang</groupId>
|
||||
|
||||
@ -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<DashboardRespVO> 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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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<String> degradedSources;
|
||||
|
||||
}
|
||||
@ -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<TelemetryEventDO> {
|
||||
.eq(TelemetryEventDO::getEventId, eventId));
|
||||
}
|
||||
|
||||
// ========== U5 经营看板 DAU(活跃去重):原始事件按 ts 区间去重活跃身份 ==========
|
||||
|
||||
/**
|
||||
* 区间内日活(DAU):原始事件按 ts(epoch ms) 区间去重活跃身份数(U5 经营看板本地数据源)
|
||||
*
|
||||
* <p>口径:DAU = 区间内有任意事件的去重活跃身份数 = 去重登录用户(user_id>0) + 去重匿名身份(anon_id 非空且 user_id 空/0)。
|
||||
* 登录与匿名分别 COUNT(DISTINCT) 后相加——同一自然人登录前后会被分别计入登录/匿名两类(MVP 看板可接受,
|
||||
* 真精确合并需稳定 device→user 映射,属后续)。</p>
|
||||
* <p>仿 trade/telemetry 既有 selectMaps 聚合范式(不依赖 DO 列别名映射,跨方言稳);
|
||||
* ts 为 epoch 毫秒,由 Service 把自然日端点换算为毫秒区间传入。tenant_id 由 MP 多租户拦截自动追加。</p>
|
||||
*
|
||||
* @param tsStart 区间起始时间戳(epoch 毫秒,含)
|
||||
* @param tsEnd 区间结束时间戳(epoch 毫秒,含)
|
||||
* @return DAU(去重活跃身份数;无数据兜底 0)
|
||||
*/
|
||||
default Long countActiveIdentities(Long tsStart, Long tsEnd) {
|
||||
// 登录用户去重数(仅统计 user_id>0 的登录态身份)
|
||||
List<Map<String, Object>> userRows = selectMaps(new QueryWrapperX<TelemetryEventDO>()
|
||||
.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<Map<String, Object>> anonRows = selectMaps(new QueryWrapperX<TelemetryEventDO>()
|
||||
.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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
}
|
||||
@ -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<String> 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<TradeRevenueReportDTO> 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<String> degraded, Supplier<Long> 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();
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -67,6 +67,12 @@
|
||||
<artifactId>huijing-spring-boot-starter-mybatis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Excel 导出(U5 营收报表导出 ExcelUtils + @ExcelProperty) -->
|
||||
<dependency>
|
||||
<groupId>com.wanxiang</groupId>
|
||||
<artifactId>huijing-spring-boot-starter-excel</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 缓存 -->
|
||||
<dependency>
|
||||
<groupId>com.wanxiang</groupId>
|
||||
|
||||
@ -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<RevenueReportVO> 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 经济:赋余额 / 赋订阅 ==============================
|
||||
|
||||
@ -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;
|
||||
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user