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:
lili 2026-06-18 11:06:57 -07:00
parent f85ae6c434
commit ac7ffe4a92
11 changed files with 604 additions and 3 deletions

View File

@ -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>

View File

@ -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));
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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&gt;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;
}
}

View File

@ -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);
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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>

View File

@ -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 经济:赋余额 / 赋订阅 ==============================

View File

@ -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;
}