diff --git a/contracts/db-schemas/V25.0.0__feed_rank_add_exposure_limit.sql b/contracts/db-schemas/V25.0.0__feed_rank_add_exposure_limit.sql new file mode 100644 index 00000000..f3cfaa62 --- /dev/null +++ b/contracts/db-schemas/V25.0.0__feed_rank_add_exposure_limit.sql @@ -0,0 +1,23 @@ +-- ============================================================================= +-- 契约 #2 DB 迁移 | 模块:feed(游戏流/精选/限曝光运营)| owner:U5(R-ADMIN) +-- 文件:V25.0.0__feed_rank_add_exposure_limit.sql(Flyway,只新增;接 V22.0.0 之后;已合入禁止修改,回滚写补偿迁移 V25.0.1) +-- 背书:plan002 U5(R-ADMIN admin 运营后端)限曝光特性——精选池(boost 加权)的反向运营手段(降权/限曝光)。 +-- 内容:game_feed_rank 加 exposure_limit —— 运营限曝光降权因子(精选 boost 的反向操作)。 +-- 为何加列而非复用负 boost(关键): +-- 1) boost 语义=运营正向加权(精选/活动),sort_score=quality+boost;若把降权塞进负 boost,会与正向加权口径混淆、 +-- 且算分回灌(QUALITY_REFRESH)重算 sort_score=quality+boost 时无法区分"降权意图"与"负向运营加权",语义污染不可逆; +-- 2) 独立列使降权显式、可审计;最终排序分口径升级为 sort_score = quality + boost - exposure_limit,降权方向自洽; +-- 3) 限曝光须穿越 telemetry 算分回灌存活——QUALITY_REFRESH 仅刷 quality_score 并据保留的 boost/exposure_limit 重算 +-- sort_score(同 boost 处理),故新列在算分回灌后仍生效(与 boost 同等"运营字段保留"地位)。 +-- additive 非阻断:exposure_limit DECIMAL(10,4) NOT NULL DEFAULT 0(存量行=0 即无降权,零行为变更); +-- 存量 INSERT/SELECT 不含本列即得默认 0,读写两侧零中断。 +-- 取值口径:exposure_limit >= 0; +-- 0 = 无降权(默认,现行行为); +-- 0 < x = 降权 x 分(sort_score 减 x,游戏在流中相对下沉); +-- >= 9999 = 硬限曝光(cap-hide)哨兵——buildStream 出流时直接剔除该游戏(不在流中露出),等价"运营软屏蔽" +-- (区别于 status=0 的下架/举报联动屏蔽:限曝光是运营节流,可随时改回,状态不变)。 +-- 回滚:additive → 真要 drop 写 V25.0.1 补偿迁移(不改本文件,遵 V4.0.0 头注铁律)。 +-- 错误码段:feed = 1-103-***-***(本迁移不引入新错误码)。 +-- ============================================================================= +ALTER TABLE `game_feed_rank` + ADD COLUMN `exposure_limit` DECIMAL(10,4) NOT NULL DEFAULT 0 COMMENT '运营限曝光降权因子(>=0):0无降权 / >0降权x分(sort_score-=x) / >=9999硬限曝光出流剔除哨兵;最终 sort_score=quality+boost-exposure_limit' AFTER `boost`; diff --git a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/api/AigcApi.java b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/api/AigcApi.java index de9f26a1..cf4e5b39 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/api/AigcApi.java +++ b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/api/AigcApi.java @@ -10,11 +10,13 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import java.time.LocalDate; import java.util.List; /** @@ -93,4 +95,23 @@ public interface AigcApi { CommonResult retryTask(@RequestParam("taskId") Long taskId, @RequestParam("userId") Long userId); + /** + * 区间内成功生成任务数(U5 经营看板「生成量」数据源;telemetry 经 aigc-api 取) + * + * 只读、幂等;委托既有 {@code AigcTaskMapper.countSucceededBetween}(status=succeeded 且 finish_time 落在区间)。 + * 区间按"自然日 [start 00:00, end 23:59:59]"理解(impl 把 LocalDate 端点展开为全天 LocalDateTime)。 + * additive 暴露:不改 aigc 既有生成/执行器/SAA 代码,仅新增本只读 count seam。 + * + * @param statDateStart 统计起始日(yyyy-MM-dd,含) + * @param statDateEnd 统计结束日(yyyy-MM-dd,含) + * @return 区间内成功生成任务数(CommonResult 包裹;无数据为 0) + */ + @GetMapping(PREFIX + "/count-succeeded") + @Operation(summary = "区间内成功生成任务数(telemetry 看板生成量)") + @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") + CommonResult countSucceededTasks( + @RequestParam("statDateStart") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateStart, + @RequestParam("statDateEnd") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateEnd); + } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/api/AigcApiImpl.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/api/AigcApiImpl.java index b09424e1..24c8a25e 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/api/AigcApiImpl.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/api/AigcApiImpl.java @@ -3,6 +3,7 @@ package com.wanxiang.huijing.game.module.aigc.api; import com.wanxiang.huijing.game.module.aigc.controller.app.task.vo.AigcGenerateReqVO; import com.wanxiang.huijing.game.module.aigc.controller.app.task.vo.TemplateRespVO; import com.wanxiang.huijing.game.module.aigc.dal.dataobject.task.AigcTaskDO; +import com.wanxiang.huijing.game.module.aigc.dal.mysql.task.AigcTaskMapper; import com.wanxiang.huijing.game.module.aigc.dto.AigcGenerateReqDTO; import com.wanxiang.huijing.game.module.aigc.dto.AigcGenerateRespDTO; import com.wanxiang.huijing.game.module.aigc.dto.AigcTaskRespDTO; @@ -14,6 +15,9 @@ import org.springframework.context.annotation.Primary; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.RestController; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.List; import java.util.stream.Collectors; @@ -36,6 +40,12 @@ public class AigcApiImpl implements AigcApi { @Resource private AigcTaskService aigcTaskService; + /** + * 任务 Mapper(U5 经营看板「生成量」只读 count seam 用;additive 注入,不改既有 service) + */ + @Resource + private AigcTaskMapper aigcTaskMapper; + @Override public CommonResult> getTemplateList() { // 透传 aigc 模板注册表(MVP 骨架返回空列表占位) @@ -92,6 +102,16 @@ public class AigcApiImpl implements AigcApi { return success(toGenerateRespDTO(retry)); } + @Override + public CommonResult countSucceededTasks(LocalDate statDateStart, LocalDate statDateEnd) { + // U5 经营看板「生成量」:把自然日端点展开为全天 [start 00:00:00, end 23:59:59.999999999], + // 委托既有 mapper countSucceededBetween(status=succeeded 且 finish_time 落区间)。 + // 租户:随调用方(telemetry 看板,admin 上下文)的租户上下文统计,与 trade/project 看板源口径一致(MVP 单租户=0)。 + LocalDateTime start = statDateStart.atStartOfDay(); + LocalDateTime end = statDateEnd.atTime(LocalTime.MAX); + return success(aigcTaskMapper.countSucceededBetween(start, end)); + } + /** * 任务 DO → 提交/重试返回 DTO(taskId/status/traceId) * diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/mysql/task/AigcTaskMapper.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/mysql/task/AigcTaskMapper.java index 30740baa..bdc3a7d4 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/mysql/task/AigcTaskMapper.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/mysql/task/AigcTaskMapper.java @@ -192,4 +192,24 @@ public interface AigcTaskMapper extends BaseMapperX { AigcTaskStatusEnum.QUEUED.getStatus(), AigcTaskStatusEnum.RUNNING.getStatus())); } + // ========== U5 经营看板「生成量」只读 count(additive,零 DDL;跨模块 telemetry 经 aigc-api 取)========== + + /** + * 区间内成功生成任务数(U5 经营看板「生成量」口径:status=succeeded 且 finish_time 落在 [start,end]) + * + * 用途:telemetry 经营看板(/admin-api/telemetry/dashboard)经 aigc-api countSucceededTasks 取「生成量」。 + * 以 finish_time(终态完成时间)作区间维度——生成量按"何时产出"统计,比 create_time(何时提交)更贴合看板语义。 + * 走 selectCount(零原生 SQL/零 DDL);deleted=0 由 MP 逻辑删除自动追加。 + * + * @param start 区间起始时间(含) + * @param end 区间结束时间(含) + * @return 区间内成功生成任务数(无数据兜底 0) + */ + default Long countSucceededBetween(LocalDateTime start, LocalDateTime end) { + return selectCount(new LambdaQueryWrapperX() + .eq(AigcTaskDO::getStatus, AigcTaskStatusEnum.SUCCEEDED.getStatus()) + .ge(AigcTaskDO::getFinishTime, start) + .le(AigcTaskDO::getFinishTime, end)); + } + } diff --git a/game-cloud/game-module-feed/game-module-feed-api/src/main/java/com/wanxiang/huijing/game/module/feed/enums/ErrorCodeConstants.java b/game-cloud/game-module-feed/game-module-feed-api/src/main/java/com/wanxiang/huijing/game/module/feed/enums/ErrorCodeConstants.java index 81f957a4..8ea2ea2a 100644 --- a/game-cloud/game-module-feed/game-module-feed-api/src/main/java/com/wanxiang/huijing/game/module/feed/enums/ErrorCodeConstants.java +++ b/game-cloud/game-module-feed/game-module-feed-api/src/main/java/com/wanxiang/huijing/game/module/feed/enums/ErrorCodeConstants.java @@ -7,7 +7,7 @@ import com.wanxiang.huijing.framework.common.exception.ErrorCode; * * feed 模块,独占 1-103-***-*** 段(见 .agents/rules/engineering-conventions.md §1.3)。 * 约定:禁止与其它模块错误码段重叠;新增错误码在此登记。 - * 段内业务细分:000 游戏流 / 001 互动 / 002 分享 / 003 精选池 / 004 排序行 upsert(黄金闭环 §3.2/§3.5)。 + * 段内业务细分:000 游戏流 / 001 互动 / 002 分享 / 003 精选池 / 004 排序行 upsert(黄金闭环 §3.2/§3.5)/ 005 限曝光(U5 运营)。 * * @author 绘境AI */ @@ -37,4 +37,10 @@ public interface ErrorCodeConstants { /** 可见态写侧校验未过:mode=PUBLISH_BASELINE 写 status=1 前须满足「project 已发布 + 当前生效版本一致 + 运行包已发布」三条件(§3.1 C1) */ ErrorCode FEED_RANK_VISIBILITY_VIOLATION = new ErrorCode(1_103_004_001, "排序行不满足可见态条件,拒绝写入发布基线"); + // ========== 限曝光运营 1-103-005-***(U5 R-ADMIN)========== + /** 限曝光目标非法:仅 project status=4 已发布的游戏可限曝光降权(与精选门禁同口径) */ + ErrorCode FEED_EXPOSURE_GAME_NOT_PUBLISHED = new ErrorCode(1_103_005_001, "仅已发布的游戏可设置限曝光"); + /** 限曝光目标无排序覆盖层记录:未入流/未发布基线的游戏无可降权的排序行(须先发布或入精选) */ + ErrorCode FEED_EXPOSURE_RANK_NOT_EXISTS = new ErrorCode(1_103_005_002, "该游戏无排序记录,无法设置限曝光"); + } diff --git a/game-cloud/game-module-feed/game-module-feed-server/pom.xml b/game-cloud/game-module-feed/game-module-feed-server/pom.xml index 466e5054..16035717 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/pom.xml +++ b/game-cloud/game-module-feed/game-module-feed-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-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/controller/admin/feed/AdminFeedController.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/controller/admin/feed/AdminFeedController.java index f8b77927..130a7860 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/controller/admin/feed/AdminFeedController.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/controller/admin/feed/AdminFeedController.java @@ -1,21 +1,34 @@ package com.wanxiang.huijing.game.module.feed.controller.admin.feed; +import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.ExposureLimitReqVO; +import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.FeaturedExcelVO; import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.FeaturedPageReqVO; import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.FeaturedReqVO; import com.wanxiang.huijing.game.module.feed.controller.app.feed.vo.FeedCardRespVO; import com.wanxiang.huijing.game.module.feed.convert.feed.FeedConvert; import com.wanxiang.huijing.game.module.feed.dal.dataobject.rank.FeedRankDO; import com.wanxiang.huijing.game.module.feed.service.feed.FeedService; +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.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import static com.wanxiang.huijing.framework.apilog.core.enums.OperateTypeEnum.EXPORT; +import static com.wanxiang.huijing.framework.apilog.core.enums.OperateTypeEnum.UPDATE; import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; /** @@ -54,4 +67,52 @@ public class AdminFeedController { return success(true); } + @PostMapping("/exposure-limit") + @Operation(summary = "限曝光降权(U5)", description = "boost 的反向运营操作:写 exposure_limit 降权,仅已发布且已有排序行的游戏可设置;>=9999 硬限曝光出流剔除") + @PreAuthorize("@ss.hasPermission('feed:featured:update')") // 复用精选运营权限(同属排序覆盖层运营动作) + @ApiAccessLog(operateType = UPDATE) // 运营操作审计(限曝光=更新排序覆盖层) + public CommonResult setExposureLimit(@Valid @RequestBody ExposureLimitReqVO reqVO) { + feedService.setExposureLimit(reqVO); + return success(true); + } + + @GetMapping("/featured/export-excel") + @Operation(summary = "精选池/限曝光排序覆盖层导出 Excel(U5)", description = "导出精选置顶项排序覆盖层运营字段(boost/限曝光/排序分快照),供运营对账") + @PreAuthorize("@ss.hasPermission('feed:featured:export')") + @ApiAccessLog(operateType = EXPORT) + @Parameter(name = "zoneId", description = "可选专区筛选(不传=不限专区)", example = "2") + public void exportFeaturedExcel(@RequestParam(value = "zoneId", required = false) Long zoneId, + HttpServletResponse response) throws IOException { + List list = feedService.getFeaturedList(zoneId); + // 空数据也导出表头(仿 PayOrderController:空列表写表头,前端拿到合法空表) + List excelList = new ArrayList<>(list.size()); + for (FeedRankDO rank : list) { + excelList.add(toFeaturedExcelVO(rank)); + } + ExcelUtils.write(response, "精选池限曝光.xls", "数据", FeaturedExcelVO.class, excelList); + } + + /** + * 排序覆盖层 DO → 导出 Excel VO(U5;显式映射运营字段,本体元信息源自 project 不在此导出) + * + * @param rank 排序覆盖层 DO + * @return 导出 VO + */ + private FeaturedExcelVO toFeaturedExcelVO(FeedRankDO rank) { + FeaturedExcelVO vo = new FeaturedExcelVO(); + vo.setId(rank.getId()); + vo.setGameId(rank.getGameId()); + vo.setVersionId(rank.getVersionId()); + vo.setZoneId(rank.getZoneId()); + vo.setQualityScore(rank.getQualityScore()); + vo.setBoost(rank.getBoost()); + // 限曝光降权 null 安全(存量行/未设置=0),与排序口径一致 + vo.setExposureLimit(rank.getExposureLimit() == null ? BigDecimal.ZERO : rank.getExposureLimit()); + vo.setSortScore(rank.getSortScore()); + vo.setPinned(rank.getPinned()); + vo.setStatus(rank.getStatus()); + vo.setUpdateTime(rank.getUpdateTime()); + return vo; + } + } diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/controller/admin/feed/vo/ExposureLimitReqVO.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/controller/admin/feed/vo/ExposureLimitReqVO.java new file mode 100644 index 00000000..e583ffe2 --- /dev/null +++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/controller/admin/feed/vo/ExposureLimitReqVO.java @@ -0,0 +1,34 @@ +package com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Data; + +/** + * 限曝光降权 Request VO(admin 端,U5 R-ADMIN 运营后端) + * + * 运营对单个游戏在游戏流/专区流中限曝光降权(写 game_feed_rank.exposure_limit,boost 的反向操作)。 + * 仅 project status=4 已发布游戏可限曝光(业务层强校验,与精选门禁同口径);不改 project 本体,仅写排序覆盖层。 + * exposureLimit 取值口径见 {@link com.wanxiang.huijing.game.module.feed.dal.dataobject.rank.FeedRankDO#getExposureLimit()}。 + * + * @author 造梦AI + */ +@Schema(description = "管理后台 - 限曝光降权 Request VO") +@Data +public class ExposureLimitReqVO { + + @Schema(description = "目标游戏 ID(须 project status=4 已发布)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "目标游戏 ID 不能为空") + private Long gameId; + + @Schema(description = "作用专区 ID;不传表示作用于默认混合流(zoneId=0)", example = "2") + private Long zoneId; + + @Schema(description = "限曝光降权因子(>=0):0取消降权 / >0降权x分 / >=9999硬限曝光(出流剔除)", + requiredMode = Schema.RequiredMode.REQUIRED, example = "20.0") + @NotNull(message = "限曝光降权因子不能为空") + @PositiveOrZero(message = "限曝光降权因子不能为负") + private Float exposureLimit; + +} diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/controller/admin/feed/vo/FeaturedExcelVO.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/controller/admin/feed/vo/FeaturedExcelVO.java new file mode 100644 index 00000000..3450ba9a --- /dev/null +++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/controller/admin/feed/vo/FeaturedExcelVO.java @@ -0,0 +1,54 @@ +package com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo; + +import cn.idev.excel.annotation.ExcelProperty; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 精选池/限曝光排序覆盖层 Excel VO(admin 端导出,U5 R-ADMIN 运营后端) + * + * 导出 game_feed_rank 排序覆盖层运营字段(精选/加权/限曝光快照),供运营对账与审计。 + * 字段口径与 {@link com.wanxiang.huijing.game.module.feed.dal.dataobject.rank.FeedRankDO} 一致; + * 本体元信息(标题等)源自 project,feed 不持久化,故导出以排序覆盖层运营维度为主。 + * + * @author 造梦AI + */ +@Data +public class FeaturedExcelVO { + + @ExcelProperty("排序记录ID") + private Long id; + + @ExcelProperty("游戏ID") + private Long gameId; + + @ExcelProperty("版本ID") + private Long versionId; + + @ExcelProperty("专区ID(0=混合流)") + private Long zoneId; + + @ExcelProperty("质量分") + private BigDecimal qualityScore; + + @ExcelProperty("运营加权(boost)") + private BigDecimal boost; + + @ExcelProperty("限曝光降权(exposureLimit)") + private BigDecimal exposureLimit; + + @ExcelProperty("最终排序分") + private BigDecimal sortScore; + + @ExcelProperty("是否精选置顶(0否1是)") + private Integer pinned; + + @ExcelProperty("出流状态(0屏蔽1可出流)") + private Integer status; + + @ExcelProperty("更新时间") + private LocalDateTime updateTime; + +} diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/dal/dataobject/rank/FeedRankDO.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/dal/dataobject/rank/FeedRankDO.java index 91820733..925fff77 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/dal/dataobject/rank/FeedRankDO.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/dal/dataobject/rank/FeedRankDO.java @@ -48,7 +48,14 @@ public class FeedRankDO extends TenantBaseDO { */ private BigDecimal boost; /** - * 最终排序分(= quality_score + boost + 保底/降权修正,降序出流) + * 运营限曝光降权因子(>=0,U5 限曝光;boost 的反向操作,V25.0.0 新增) + * + * 取值:0 无降权 / 0<x 降权 x 分(sort_score 减 x,相对下沉)/ >=9999 硬限曝光哨兵(buildStream 出流剔除)。 + * 最终 sort_score = quality_score + boost - exposure_limit;与 boost 同等"运营字段"地位,穿越 telemetry 算分回灌存活。 + */ + private BigDecimal exposureLimit; + /** + * 最终排序分(= quality_score + boost - exposure_limit + 保底修正,降序出流) */ private BigDecimal sortScore; /** diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/dal/mysql/rank/FeedRankMapper.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/dal/mysql/rank/FeedRankMapper.java index d74e4d3c..62713c88 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/dal/mysql/rank/FeedRankMapper.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/dal/mysql/rank/FeedRankMapper.java @@ -68,6 +68,22 @@ public interface FeedRankMapper extends BaseMapperX { .orderByDesc(FeedRankDO::getSortScore)); } + /** + * 精选池/限曝光导出列表(管理端 U5 导出,不分页,仅精选置顶项;命中 idx_pinned) + * + * 复用 {@link #selectFeaturedPage} 同口径筛选(pinned=1 + 可选专区),去分页取全量供 Excel 导出。 + * 排序覆盖层运营字段全字段导出(含 boost/exposure_limit),供运营对账加权/限曝光快照。 + * + * @param zoneId 可选专区筛选;null=不限专区 + * @return 精选记录列表(按 sort_score 降序;无数据返回空列表) + */ + default List selectFeaturedList(Long zoneId) { + return selectList(new LambdaQueryWrapperX() + .eq(FeedRankDO::getPinned, 1) // 仅精选置顶项(与分页同口径) + .eqIfPresent(FeedRankDO::getZoneId, zoneId) // 可选专区筛选 + .orderByDesc(FeedRankDO::getSortScore)); + } + /** * 按游戏 + 分区取唯一排序记录(精选置顶/取消时定位覆盖层记录,对应 uk_game_zone) * diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedService.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedService.java index efc659e1..14c93b8a 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedService.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedService.java @@ -1,5 +1,6 @@ package com.wanxiang.huijing.game.module.feed.service.feed; +import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.ExposureLimitReqVO; import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.FeaturedPageReqVO; import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.FeaturedReqVO; import com.wanxiang.huijing.game.module.feed.controller.app.feed.vo.FeedInteractReqVO; @@ -77,6 +78,14 @@ public interface FeedService { */ PageResult getFeaturedPage(FeaturedPageReqVO reqVO); + /** + * 精选池/限曝光导出列表(管理端 RBAC;不分页,仅精选置顶项,供 Excel 导出) + * + * @param zoneId 可选专区筛选;null=不限专区 + * @return 精选记录列表(按 sort_score 降序;无数据返回空列表) + */ + List getFeaturedList(Long zoneId); + /** * 精选池置顶/取消 + 运营加权(管理端 RBAC;仅 status=4 已发布可入精选) * @@ -84,6 +93,17 @@ public interface FeedService { */ void setFeatured(FeaturedReqVO reqVO); + /** + * 限曝光降权(管理端 RBAC,U5;boost 的反向操作,仅 status=4 已发布且已有排序行的游戏可设置) + * + * 写 game_feed_rank.exposure_limit 并重算 sort_score = quality + boost - exposure_limit(穿越算分回灌存活); + * exposure_limit>=9999 为硬限曝光哨兵(buildStream 出流剔除)。不改 project 本体,仅写排序覆盖层。 + * 与精选不同:限曝光只降权既有排序行(不新建),无排序行(未发布基线/未入流)则拒绝(FEED_EXPOSURE_RANK_NOT_EXISTS)。 + * + * @param reqVO 限曝光入参 + */ + void setExposureLimit(ExposureLimitReqVO reqVO); + /** * upsert 游戏流排序行(黄金闭环 §3.2/§3.5,两个上游写入点经本方法按 mode 分流,守门③) * diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImpl.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImpl.java index a83b6224..ebaa2401 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImpl.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImpl.java @@ -1,5 +1,6 @@ package com.wanxiang.huijing.game.module.feed.service.feed; +import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.ExposureLimitReqVO; import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.FeaturedPageReqVO; import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.FeaturedReqVO; import com.wanxiang.huijing.game.module.feed.controller.app.feed.vo.FeedCardRespVO; @@ -59,6 +60,14 @@ public class FeedServiceImpl implements FeedService { /** 默认混合流分区维度(game_feed_rank.zone_id=0) */ private static final Long DEFAULT_ZONE_ID = 0L; + /** + * 硬限曝光哨兵阈值(U5 限曝光):exposure_limit >= 此值 = 该游戏被运营硬限曝光,buildStream 出流时直接剔除。 + * + * 区别于 status=0(下架/举报联动屏蔽):限曝光是运营节流、可随时改回、状态不变; + * 取 9999(与 sort_score DECIMAL(10,4) 量级匹配,远超正常 quality+boost 范围,语义即"完全压制")。 + */ + private static final BigDecimal EXPOSURE_LIMIT_CAP_HIDE = BigDecimal.valueOf(9999); + @Resource private FeedRankMapper feedRankMapper; @@ -157,9 +166,11 @@ public class FeedServiceImpl implements FeedService { dbExhausted = true; // 本批为空 → 确认库已取空,无更多 break; } - // 读侧可见态 enforce(§3.1 C1 / Codex H9):rank.status=1 仅覆盖层在流态,仍可能因手工写错/项目降级而与权威态不一致, - // 故批查 project.status 二次过滤——仅 PUBLISHED(4) 出流;未发布/降级(下架/封禁/审核中)剔除。 - visibleAcc.addAll(filterByProjectPublished(batch)); + // 读侧二次过滤(§3.1 C1 / Codex H9 + U5 限曝光): + // ① 可见态:rank.status=1 仅覆盖层在流态,仍可能因手工写错/项目降级而与权威态不一致,故批查 project.status——仅 PUBLISHED(4) 出流; + // ② 硬限曝光:exposure_limit>=哨兵 的游戏被运营完全压制,直接剔除(在出流处剔,不动 status,可随时改回)。 + // 两类剔除均在本二次过滤内完成——续取循环按"本批末条原始库行"推进游标已天然跨过被剔行(不重不漏),故限曝光复用同一机制。 + visibleAcc.addAll(filterVisibleRanks(batch)); // 游标按"本批最后一条原始库行"推进(含被过滤行)——被过滤行也须跨过,否则下轮重复取到 → 续取不重不漏的关键 FeedRankDO lastRaw = batch.get(batch.size() - 1); cursor = new CursorKey(lastRaw.getSortScore(), lastRaw.getId()); @@ -202,15 +213,18 @@ public class FeedServiceImpl implements FeedService { } /** - * 读侧可见态二次过滤(§3.1 C1):批查每条 rank 对应游戏的 project.status,仅 PUBLISHED(4) 保留出流 + * 读侧二次过滤(§3.1 C1 可见态 + U5 限曝光):批查 rank 对应游戏的 project.status, + * 仅 PUBLISHED(4) 且未被硬限曝光的行保留出流 * * 经 ProjectApi 本地 bean 逐 gameId 读 status(同进程方法调用,非真实 Feign);去重 gameId 减少调用次数。 - * 未发布/降级(含游戏不存在 status=null)一律剔除,杜绝「手工写错一条 rank 就出流」(去掉旧 isGamePublished 默认放行)。 + * 两类剔除: + * ① 未发布/降级(含游戏不存在 status=null)——杜绝「手工写错一条 rank 就出流」(去掉旧 isGamePublished 默认放行); + * ② 硬限曝光(exposure_limit>=哨兵)——运营完全压制的游戏不在流中露出(在出流处剔,不动 status)。 * * @param ranks 排序行候选(已按 sort_score 降序) - * @return 仅保留 project 已发布的排序行(保持原降序) + * @return 仅保留 project 已发布且未被硬限曝光的排序行(保持原降序) */ - private List filterByProjectPublished(List ranks) { + private List filterVisibleRanks(List ranks) { if (ranks.isEmpty()) { return ranks; } @@ -220,12 +234,23 @@ public class FeedServiceImpl implements FeedService { statusByGameId.computeIfAbsent(rank.getGameId(), gid -> projectApi.getStatus(gid).getCheckedData()); } - // 仅保留 project.status==PUBLISHED(4) 的行(status=null 即游戏不存在,剔除) + // 保留:project.status==PUBLISHED(4)(status=null 即游戏不存在,剔除)AND 未被硬限曝光 return ranks.stream() .filter(rank -> Objects.equals(statusByGameId.get(rank.getGameId()), ProjectStatusEnum.PUBLISHED.getStatus())) + .filter(rank -> !isExposureCapped(rank.getExposureLimit())) .collect(Collectors.toList()); } + /** + * 判断排序行是否被硬限曝光(exposure_limit>=哨兵 EXPOSURE_LIMIT_CAP_HIDE,U5 限曝光出流剔除判据) + * + * @param exposureLimit 限曝光降权因子(null 安全,视为 0=未限曝光) + * @return true=被硬限曝光(出流剔除) + */ + private static boolean isExposureCapped(BigDecimal exposureLimit) { + return exposureLimit != null && exposureLimit.compareTo(EXPOSURE_LIMIT_CAP_HIDE) >= 0; + } + /** * 跨模块回填卡片本体元信息(§3.1:feed 不持久化本体,经 ProjectApi 本地 bean 读 project 回填) * @@ -414,7 +439,9 @@ public class FeedServiceImpl implements FeedService { rank.setZoneId(zoneId); rank.setQualityScore(BigDecimal.ZERO); // TODO 跨模块回填 telemetry quality_score rank.setBoost(boost); - rank.setSortScore(boost); // 新建时无质量分,排序分先等于 boost + rank.setExposureLimit(BigDecimal.ZERO); // 新建精选默认无限曝光降权 + // 新建时无质量分、无限曝光 → 排序分先等于 boost(= quality(0) + boost - exposure(0)) + rank.setSortScore(boost); rank.setPinned(pinned); rank.setStatus(1); // 可出流 feedRankMapper.insert(rank); @@ -423,13 +450,48 @@ public class FeedServiceImpl implements FeedService { update.setId(existing.getId()); update.setBoost(boost); update.setPinned(pinned); - // 排序分 = 既有质量分 + 新 boost(质量分由 telemetry 回灌维护,此处保留) - BigDecimal quality = existing.getQualityScore() == null ? BigDecimal.ZERO : existing.getQualityScore(); - update.setSortScore(quality.add(boost)); + // 排序分 = 既有质量分 + 新 boost - 既有限曝光降权(质量分由 telemetry 回灌、限曝光由限曝光端维护,此处保留两者) + BigDecimal quality = nullToZero(existing.getQualityScore()); + BigDecimal exposure = nullToZero(existing.getExposureLimit()); + update.setSortScore(deriveSortScore(quality, boost, exposure)); feedRankMapper.updateById(update); } } + // ============================== 限曝光降权(U5 R-ADMIN,boost 的反向操作)============================== + + @Override + public List getFeaturedList(Long zoneId) { + // 不分页取精选置顶项全量(与分页同口径),供 Excel 导出 + return feedRankMapper.selectFeaturedList(zoneId); + } + + @Override + @Transactional(rollbackFor = Exception.class) // 单写覆盖层,加事务以便后续扩展 + public void setExposureLimit(ExposureLimitReqVO reqVO) { + // 门禁①:仅 status=4 已发布可限曝光(与精选门禁同口径) + if (!isGamePublished(reqVO.getGameId())) { + throw exception(FEED_EXPOSURE_GAME_NOT_PUBLISHED); + } + Long zoneId = reqVO.getZoneId() == null ? DEFAULT_ZONE_ID : reqVO.getZoneId(); + // 门禁②:限曝光只降权既有排序行(不新建)——无排序行(未发布基线/未入流)则拒绝,避免产生"无发布态"的孤儿降权行 + FeedRankDO existing = feedRankMapper.selectByGameAndZone(reqVO.getGameId(), zoneId); + if (existing == null) { + throw exception(FEED_EXPOSURE_RANK_NOT_EXISTS); + } + BigDecimal exposure = BigDecimal.valueOf(reqVO.getExposureLimit()); + // 重算排序分 = 既有质量分 + 既有 boost - 新限曝光降权(保留质量分与运营加权,仅改限曝光维度) + BigDecimal quality = nullToZero(existing.getQualityScore()); + BigDecimal boost = nullToZero(existing.getBoost()); + FeedRankDO update = new FeedRankDO(); + update.setId(existing.getId()); + update.setExposureLimit(exposure); + update.setSortScore(deriveSortScore(quality, boost, exposure)); + feedRankMapper.updateById(update); + log.info("[setExposureLimit] 限曝光降权 gameId={}, zoneId={}, exposureLimit={}, sortScore={}", + reqVO.getGameId(), zoneId, exposure, deriveSortScore(quality, boost, exposure)); + } + // ============================== 排序行 upsert(发布基线 / 算分回灌两路,§3.2/§3.5)============================== @Override @@ -480,18 +542,21 @@ public class FeedServiceImpl implements FeedService { rank.setZoneId(zoneId); rank.setQualityScore(toDecimal(req.getQualityScore())); rank.setBoost(toDecimal(req.getBoost())); + rank.setExposureLimit(BigDecimal.ZERO); // 首次发布基线无限曝光降权 rank.setSortScore(toDecimal(req.getSortScore())); rank.setStatus(req.getStatus() == null ? 1 : req.getStatus()); rank.setPinned(req.getPinned() == null ? 0 : req.getPinned()); feedRankMapper.insert(rank); } else { - // 重发布:刷新版本/分数/在流态/置顶(发布基线全字段覆盖) + // 重发布:刷新版本/分数/在流态/置顶(发布基线全字段覆盖),但保留运营限曝光降权(U5;与 boost 同等运营字段地位) + BigDecimal exposure = nullToZero(existing.getExposureLimit()); FeedRankDO update = new FeedRankDO(); update.setId(existing.getId()); update.setVersionId(req.getVersionId()); update.setQualityScore(toDecimal(req.getQualityScore())); update.setBoost(toDecimal(req.getBoost())); - update.setSortScore(toDecimal(req.getSortScore())); + // 排序分须扣减既有限曝光降权(发布基线给的 sort_score 不含限曝光,须在 feed 侧权威扣减,否则重发布会丢限曝光效果) + update.setSortScore(deriveSortScore(toDecimal(req.getQualityScore()), toDecimal(req.getBoost()), exposure)); update.setStatus(req.getStatus() == null ? 1 : req.getStatus()); update.setPinned(req.getPinned() == null ? 0 : req.getPinned()); feedRankMapper.updateById(update); @@ -515,18 +580,21 @@ public class FeedServiceImpl implements FeedService { log.info("[upsertRank] 算分回灌跳过:无发布基线排序行 gameId={}, zoneId={}", req.getGameId(), zoneId); return; } - // 仅刷 quality_score/sort_score,不置 boost/pinned/status(守门③:保留运营加权与在流态)。 - // sort_score 由 feed 侧权威重算 = 新 quality_score + 既有 boost(boost 权威归 feed 运营,telemetry 不持有; - // 故不采信 req.sortScore,避免回灌覆盖运营加权——与 setFeatured 的 sort_score=quality+boost 口径一致)。 + // 仅刷 quality_score/sort_score,不置 boost/pinned/status/exposure_limit(守门③:保留运营加权、在流态、限曝光降权)。 + // sort_score 由 feed 侧权威重算 = 新 quality_score + 既有 boost - 既有 exposure_limit(boost/exposure_limit 权威归 feed 运营, + // telemetry 不持有;故不采信 req.sortScore,避免回灌覆盖运营加权/限曝光——与 setFeatured/setExposureLimit 口径一致)。 + // 这是限曝光"穿越 telemetry 算分回灌存活"的关键:回灌只更质量分,限曝光降权在重算中被保留扣减。 BigDecimal quality = toDecimal(req.getQualityScore()); - BigDecimal boost = existing.getBoost() == null ? BigDecimal.ZERO : existing.getBoost(); + BigDecimal boost = nullToZero(existing.getBoost()); + BigDecimal exposure = nullToZero(existing.getExposureLimit()); + BigDecimal sortScore = deriveSortScore(quality, boost, exposure); FeedRankDO update = new FeedRankDO(); update.setId(existing.getId()); update.setQualityScore(quality); - update.setSortScore(quality.add(boost)); + update.setSortScore(sortScore); feedRankMapper.updateById(update); - log.info("[upsertRank] 算分回灌刷新 gameId={}, zoneId={}, qualityScore={}, boost={}, sortScore={}", - req.getGameId(), zoneId, quality, boost, quality.add(boost)); + log.info("[upsertRank] 算分回灌刷新 gameId={}, zoneId={}, qualityScore={}, boost={}, exposureLimit={}, sortScore={}", + req.getGameId(), zoneId, quality, boost, exposure, sortScore); } // ============================== 排序行全分区下线(下架编排反向操作,数据回路小波·修3)============================== @@ -553,6 +621,31 @@ public class FeedServiceImpl implements FeedService { return value == null ? BigDecimal.ZERO : BigDecimal.valueOf(value); } + /** + * BigDecimal null 安全归零(feed_rank 多个 DECIMAL 列的统一缺省口径) + * + * @param value 可空 BigDecimal + * @return 非空原值;null → BigDecimal.ZERO + */ + private static BigDecimal nullToZero(BigDecimal value) { + return value == null ? BigDecimal.ZERO : value; + } + + /** + * 统一推导最终排序分(U5 起口径升级):sort_score = quality_score + boost - exposure_limit + * + * 全部排序行写入点(setFeatured/setExposureLimit/发布基线重发布/算分回灌)共用本口径, + * 保证 boost(运营加权)与 exposure_limit(运营限曝光降权)两个运营维度在任意写入路径下都被一致保留。 + * + * @param quality 质量分(telemetry 回灌;null 安全交由调用方先归零) + * @param boost 运营加权因子 + * @param exposureLimit 运营限曝光降权因子 + * @return 最终排序分 + */ + private static BigDecimal deriveSortScore(BigDecimal quality, BigDecimal boost, BigDecimal exposureLimit) { + return nullToZero(quality).add(nullToZero(boost)).subtract(nullToZero(exposureLimit)); + } + // ============================== cursor 编解码(复合游标 (sortScore,id),契约 T-FED-06)============================== /** cursor 内部分隔符(sortScore 与 id 之间;BigDecimal/Long 文本均不含 '|',无歧义) */ diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/test/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImplTest.java b/game-cloud/game-module-feed/game-module-feed-server/src/test/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImplTest.java index a485a7ef..0237ae26 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/test/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImplTest.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/test/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImplTest.java @@ -1,5 +1,6 @@ package com.wanxiang.huijing.game.module.feed.service.feed; +import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.ExposureLimitReqVO; import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.FeaturedReqVO; import com.wanxiang.huijing.game.module.feed.controller.app.feed.vo.FeedInteractReqVO; import com.wanxiang.huijing.game.module.feed.controller.app.feed.vo.FeedStreamReqVO; @@ -497,6 +498,117 @@ class FeedServiceImplTest extends BaseMockitoUnitTest { verify(feedRankMapper, never()).insert(any(FeedRankDO.class)); } + // ============================== 限曝光降权(U5 R-ADMIN)============================== + + @Test + void testSetExposureLimit_notPublishedRejected() { + // 门禁①:未发布游戏不可限曝光(与精选门禁同口径);不查/不写排序行 + doReturn(false).when(feedService).isGamePublished(1L); + ServiceException ex = assertThrows(ServiceException.class, + () -> feedService.setExposureLimit(exposureReq(1L, null, 20F))); + assertEquals(FEED_EXPOSURE_GAME_NOT_PUBLISHED.getCode(), ex.getCode()); + verify(feedRankMapper, never()).updateById(any(FeedRankDO.class)); + } + + @Test + void testSetExposureLimit_noRankRejected() { + // 门禁②:已发布但无排序覆盖层记录(未入流/未发布基线)→ 拒绝(不新建孤儿降权行) + doReturn(true).when(feedService).isGamePublished(1L); + when(feedRankMapper.selectByGameAndZone(1L, 0L)).thenReturn(null); + ServiceException ex = assertThrows(ServiceException.class, + () -> feedService.setExposureLimit(exposureReq(1L, null, 20F))); + assertEquals(FEED_EXPOSURE_RANK_NOT_EXISTS.getCode(), ex.getCode()); + verify(feedRankMapper, never()).updateById(any(FeedRankDO.class)); + } + + @Test + void testSetExposureLimit_downweightSubtractedIntoSortScore() { + // 降权写入:sort_score = 既有质量分 + 既有 boost - 新限曝光降权(保留质量分与运营加权,仅改限曝光维度) + doReturn(true).when(feedService).isGamePublished(1L); + FeedRankDO existing = new FeedRankDO(); + existing.setId(7L); + existing.setQualityScore(new BigDecimal("80")); // 既有质量分 + existing.setBoost(new BigDecimal("10")); // 既有运营加权 + when(feedRankMapper.selectByGameAndZone(1L, 2L)).thenReturn(existing); + + feedService.setExposureLimit(exposureReq(1L, 2L, 20F)); // 降权 20 + + ArgumentCaptor captor = ArgumentCaptor.forClass(FeedRankDO.class); + verify(feedRankMapper).updateById(captor.capture()); + FeedRankDO update = captor.getValue(); + assertEquals(7L, update.getId()); + assertEquals(0, new BigDecimal("20").compareTo(update.getExposureLimit())); // 限曝光因子写入 + // sort_score = 80 + 10 - 20 = 70(降权扣减生效) + assertEquals(0, new BigDecimal("70").compareTo(update.getSortScore())); + verify(feedRankMapper, never()).insert(any(FeedRankDO.class)); // 限曝光只更不新建 + } + + @Test + void testGetFeedStream_exposureCappedRowFilteredFromOutput() { + // 硬限曝光(cap)出流剔除:exposure_limit>=9999 的游戏在 buildStream 出流时被剔除(不在流中露出) + // size=2 → target=3。返回 3 条均已发布,但 g2 被硬限曝光 → 可见 [g1,g3]=2 ≤ size 且本批 list = feedService.getFeaturedList(2L); + + assertEquals(1, list.size()); + assertEquals(1L, list.get(0).getGameId()); + verify(feedRankMapper).selectFeaturedList(2L); + } + + @Test + void testGetFeaturedList_emptyReturnsEmpty() { + // 空数据:返回空列表(导出端据此写表头空表) + when(feedRankMapper.selectFeaturedList(isNull())).thenReturn(Collections.emptyList()); + + List list = feedService.getFeaturedList(null); + + assertTrue(list.isEmpty()); + } + // ============================== 排序行 upsert(发布基线 / 算分回灌,§3.2/§3.5)============================== @Test @@ -555,6 +667,31 @@ class FeedServiceImplTest extends BaseMockitoUnitTest { assertNull(update.getStatus()); } + @Test + void testUpsertRank_qualityRefresh_preservesExposureLimitInSortScore() { + // U5 限曝光「穿越算分回灌存活」:回灌只刷质量分,sort_score 重算须扣减既有 exposure_limit(不被回灌覆盖) + FeedRankDO existing = new FeedRankDO(); + existing.setId(7L); + existing.setBoost(new BigDecimal("10")); + existing.setExposureLimit(new BigDecimal("30")); // 既有限曝光降权(运营字段,回灌须保留) + existing.setPinned(1); + existing.setStatus(1); + when(feedRankMapper.selectByGameAndZone(1L, 0L)).thenReturn(existing); + + // 回灌新质量分=88.5(req.sortScore 故意给个不一致值 93.5,验证 feed 侧权威重算而非采信 req) + feedService.upsertRank(refreshReq(1L, 0L, 88.5D, 93.5D)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FeedRankDO.class); + verify(feedRankMapper).updateById(captor.capture()); + FeedRankDO update = captor.getValue(); + assertEquals(0, new BigDecimal("88.5").compareTo(update.getQualityScore())); + // sort_score = 新质量分 88.5 + 既有 boost 10 - 既有限曝光 30 = 68.5(限曝光在回灌后仍生效) + assertEquals(0, new BigDecimal("68.5").compareTo(update.getSortScore())); + // 守门③:exposure_limit/boost/pinned/status 不被置入 update DO(DB 原值保留) + assertNull(update.getExposureLimit()); + assertNull(update.getBoost()); + } + @Test void testUpsertRank_qualityRefresh_noopWhenNoBaseline() { // 无发布基线行:算分回灌空转(不新建孤儿排序行) @@ -698,6 +835,15 @@ class FeedServiceImplTest extends BaseMockitoUnitTest { return reqVO; } + /** 构造限曝光降权入参(U5;gameId/zoneId/exposureLimit) */ + private static ExposureLimitReqVO exposureReq(Long gameId, Long zoneId, Float exposureLimit) { + ExposureLimitReqVO reqVO = new ExposureLimitReqVO(); + reqVO.setGameId(gameId); + reqVO.setZoneId(zoneId); + reqVO.setExposureLimit(exposureLimit); + return reqVO; + } + /** 构造发布基线 upsert 入参(mode=PUBLISH_BASELINE,分数/boost/sort=0,status=1,pinned=0) */ private static FeedRankUpsertReqDTO baselineReq(Long gameId, Long versionId, Long zoneId) { FeedRankUpsertReqDTO req = new FeedRankUpsertReqDTO(); diff --git a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectApi.java b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectApi.java index 4421de3e..eed74202 100644 --- a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectApi.java +++ b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectApi.java @@ -7,10 +7,13 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; +import java.time.LocalDate; + /** * RPC 服务 - 游戏项目 API(project 对外归因契约) * @@ -164,4 +167,23 @@ public interface ProjectApi { @Parameter(name = "gameId", description = "游戏项目 ID", required = true, example = "1024") CommonResult unlistIfPublished(@RequestParam("gameId") Long gameId); + /** + * 区间内发布量 count(U5 经营看板「发布量」数据源;telemetry 经 project-api 取) + * + * 只读、幂等;委托既有 {@code ProjectService.countPublished}(status=PUBLISHED 且 update_time 落区间)。 + * 区间按"自然日 [start 00:00, end 23:59:59]"理解(impl 把 LocalDate 端点展开为全天 LocalDateTime)。 + * 时间维度为 update_time 近似(项目表无独立 publish_time 列),看板量级足够;说明见 ProjectMapper.countPublishedBetween。 + * + * @param statDateStart 统计起始日(yyyy-MM-dd,含) + * @param statDateEnd 统计结束日(yyyy-MM-dd,含) + * @return 区间内已发布项目数(CommonResult 包裹;无数据为 0) + */ + @GetMapping(PREFIX + "/count-published") + @Operation(summary = "区间内发布量(telemetry 看板发布量)") + @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") + CommonResult countPublished( + @RequestParam("statDateStart") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateStart, + @RequestParam("statDateEnd") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateEnd); + } diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectApiImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectApiImpl.java index 0040dd8a..1b6ebceb 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectApiImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectApiImpl.java @@ -11,6 +11,10 @@ import org.springframework.context.annotation.Primary; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.RestController; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; /** @@ -103,4 +107,12 @@ public class ProjectApiImpl implements ProjectApi { return success(projectService.unlistIfPublished(gameId)); } + @Override + public CommonResult countPublished(LocalDate statDateStart, LocalDate statDateEnd) { + // U5 经营看板「发布量」:把自然日端点展开为全天 [start 00:00:00, end 23:59:59.999999999],委托 ProjectService.countPublished + LocalDateTime start = statDateStart.atStartOfDay(); + LocalDateTime end = statDateEnd.atTime(LocalTime.MAX); + return success(projectService.countPublished(start, end)); + } + } diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/dal/mysql/project/ProjectMapper.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/dal/mysql/project/ProjectMapper.java index 5664d9fa..831b5ee9 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/dal/mysql/project/ProjectMapper.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/dal/mysql/project/ProjectMapper.java @@ -3,11 +3,13 @@ package com.wanxiang.huijing.game.module.project.dal.mysql.project; import com.wanxiang.huijing.game.module.project.controller.admin.project.vo.ProjectReviewPageReqVO; import com.wanxiang.huijing.game.module.project.controller.app.project.vo.ProjectPageReqVO; import com.wanxiang.huijing.game.module.project.dal.dataobject.project.ProjectDO; +import com.wanxiang.huijing.game.module.project.enums.ProjectStatusEnum; import com.wanxiang.huijing.framework.common.pojo.PageResult; import com.wanxiang.huijing.framework.mybatis.core.mapper.BaseMapperX; import com.wanxiang.huijing.framework.mybatis.core.query.LambdaQueryWrapperX; import org.apache.ibatis.annotations.Mapper; +import java.time.LocalDateTime; import java.util.List; /** @@ -102,4 +104,24 @@ public interface ProjectMapper extends BaseMapperX { .orderByDesc(ProjectDO::getId)); } + /** + * 区间内发布量 count(U5 经营看板「发布量」,跨模块 Feign:telemetry 看板取) + * + * 口径:当前 status=PUBLISHED(4) 且 update_time 落在 [start,end] 的项目数。selectCount(零原生 SQL/零 DDL)。 + *

时间维度说明(已知近似):项目表无独立 publish_time 列,故以 update_time 作发布时间近似—— + * 黄金闭环 D-PUB=α auto-publish 下「审核通过即发布」,发布是项目态的关键跃迁;但后续任何编辑会再刷 update_time, + * 故本口径是"区间内有更新的已发布项目数"的近似。MVP 看板量级下足够;若需精确发布量,须另增 publish_time 列(属后续, + * 本波只读 additive 不改 schema)。tenant_id 由 MP 多租户拦截自动追加(随调用方 admin 上下文,MVP 单租户=0)。 + * + * @param start 区间起始时间(含) + * @param end 区间结束时间(含) + * @return 区间内已发布项目数(无数据兜底 0) + */ + default Long countPublishedBetween(LocalDateTime start, LocalDateTime end) { + return selectCount(new LambdaQueryWrapperX() + .eq(ProjectDO::getStatus, ProjectStatusEnum.PUBLISHED.getStatus()) + .ge(ProjectDO::getUpdateTime, start) + .le(ProjectDO::getUpdateTime, end)); + } + } diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/project/ProjectService.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/project/ProjectService.java index efea1a35..967fd1c6 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/project/ProjectService.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/project/ProjectService.java @@ -146,4 +146,16 @@ public interface ProjectService { */ boolean unlistIfPublished(Long gameId); + /** + * 区间内发布量 count(U5 经营看板「发布量」,供 telemetry 跨模块 Feign 调用) + * + * 只读;口径 = 当前 status=PUBLISHED 且 update_time 落区间的项目数(时间维度为 update_time 近似, + * 说明见 {@link com.wanxiang.huijing.game.module.project.dal.mysql.project.ProjectMapper#countPublishedBetween})。 + * + * @param start 区间起始时间(含) + * @param end 区间结束时间(含) + * @return 区间内已发布项目数(无数据为 0) + */ + Long countPublished(java.time.LocalDateTime start, java.time.LocalDateTime end); + } diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/project/ProjectServiceImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/project/ProjectServiceImpl.java index ece1419f..44e3eee9 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/project/ProjectServiceImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/project/ProjectServiceImpl.java @@ -343,6 +343,12 @@ public class ProjectServiceImpl implements ProjectService { return true; } + @Override + public Long countPublished(java.time.LocalDateTime start, java.time.LocalDateTime end) { + // U5 经营看板「发布量」只读:委托 mapper 区间 count(status=PUBLISHED 且 update_time 落区间,时间维度为 update_time 近似) + return projectMapper.countPublishedBetween(start, end); + } + // ============================== 私有校验/流转辅助 ============================== /** 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-api/src/main/java/com/wanxiang/huijing/game/module/trade/api/TradeApi.java b/game-cloud/game-module-trade/game-module-trade-api/src/main/java/com/wanxiang/huijing/game/module/trade/api/TradeApi.java new file mode 100644 index 00000000..25ca37a7 --- /dev/null +++ b/game-cloud/game-module-trade/game-module-trade-api/src/main/java/com/wanxiang/huijing/game/module/trade/api/TradeApi.java @@ -0,0 +1,53 @@ +package com.wanxiang.huijing.game.module.trade.api; + +import com.wanxiang.huijing.game.module.trade.dto.TradeRevenueReportDTO; +import com.wanxiang.huijing.game.module.trade.enums.ApiConstants; +import com.wanxiang.huijing.framework.common.pojo.CommonResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.time.LocalDate; + +/** + * RPC 服务 - 交易营收 API(trade → telemetry seam,U5 经营看板新增) + * + * 用途:telemetry 经营看板(/admin-api/telemetry/dashboard)经本 -api 取区间营收聚合(GMV 口径)。 + * 设计纪律:本 seam 为纯暴露层(@RestController @Primary 委托既有 IncomeService/WithdrawService,不改 service), + * 删除后 trade 既有单测仍绿。跨模块只依赖 trade 的 -api(com.wanxiang.huijing.game.module.trade.api/.dto), + * 禁止 telemetry 依赖 trade 的 -server,避免编译耦合。 + * + * MVP 单体:同进程内由 {@code TradeApiImpl}(@RestController @Primary) 就地解析(仿 huijing DictDataApi); + * 拆微服务后按 {@link ApiConstants#NAME} 走真实 Feign。 + * + * @author 造梦AI + */ +@FeignClient(name = ApiConstants.NAME) +@Tag(name = "RPC 服务 - 交易营收") +public interface TradeApi { + + String PREFIX = ApiConstants.PREFIX; + + /** + * 按结算日区间取平台营收聚合(telemetry 经营看板 GMV 数据源) + * + * 只读、幂等;委托既有 IncomeService.sumGross/sumNet + WithdrawService.sumPaidAmount(口径与 + * /admin-api/trade/report/revenue 完全一致)。区间无数据各项兜底 0(不返回 null)。 + * + * @param statDateStart 统计起始日(yyyy-MM-dd,含) + * @param statDateEnd 统计结束日(yyyy-MM-dd,含) + * @return 营收聚合 DTO(CommonResult 包裹;金额单位分) + */ + @GetMapping(PREFIX + "/get-revenue-report") + @Operation(summary = "取平台营收聚合(telemetry 看板 GMV)") + @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") + CommonResult getRevenueReport( + @RequestParam("statDateStart") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateStart, + @RequestParam("statDateEnd") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateEnd); + +} diff --git a/game-cloud/game-module-trade/game-module-trade-api/src/main/java/com/wanxiang/huijing/game/module/trade/dto/TradeRevenueReportDTO.java b/game-cloud/game-module-trade/game-module-trade-api/src/main/java/com/wanxiang/huijing/game/module/trade/dto/TradeRevenueReportDTO.java new file mode 100644 index 00000000..b94d8561 --- /dev/null +++ b/game-cloud/game-module-trade/game-module-trade-api/src/main/java/com/wanxiang/huijing/game/module/trade/dto/TradeRevenueReportDTO.java @@ -0,0 +1,44 @@ +package com.wanxiang.huijing.game.module.trade.dto; + +import lombok.Data; + +import java.time.LocalDate; + +/** + * 平台营收报表跨模块传输对象(trade → telemetry,U5 经营看板 GMV 数据源) + * + * 用途:telemetry 经营看板(/admin-api/telemetry/dashboard)经 + * {@link com.wanxiang.huijing.game.module.trade.api.TradeApi#getRevenueReport} 拉区间营收聚合(GMV 口径)。 + * 字段与 trade-server 侧 RevenueReportVO 对齐(金额单位:分);totalPlatform = totalGross - totalNet。 + * + * @author 造梦AI + */ +@Data +public class TradeRevenueReportDTO { + + /** + * 统计起始日(含) + */ + private LocalDate statDateStart; + /** + * 统计结束日(含) + */ + private LocalDate statDateEnd; + /** + * 区间内原始收入合计(分账前,单位:分);看板 GMV 主口径 + */ + private Long totalGross; + /** + * 区间内创作者实得合计(单位:分) + */ + private Long totalNet; + /** + * 区间内平台分成合计 = totalGross - totalNet(单位:分) + */ + private Long totalPlatform; + /** + * 区间内已打款提现合计(单位:分) + */ + private Long totalWithdrawPaid; + +} 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/api/TradeApiImpl.java b/game-cloud/game-module-trade/game-module-trade-server/src/main/java/com/wanxiang/huijing/game/module/trade/api/TradeApiImpl.java new file mode 100644 index 00000000..8650fa10 --- /dev/null +++ b/game-cloud/game-module-trade/game-module-trade-server/src/main/java/com/wanxiang/huijing/game/module/trade/api/TradeApiImpl.java @@ -0,0 +1,57 @@ +package com.wanxiang.huijing.game.module.trade.api; + +import com.wanxiang.huijing.game.module.trade.dto.TradeRevenueReportDTO; +import com.wanxiang.huijing.game.module.trade.service.income.IncomeService; +import com.wanxiang.huijing.game.module.trade.service.withdraw.WithdrawService; +import com.wanxiang.huijing.framework.common.pojo.CommonResult; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Primary; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; + +import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; + +/** + * 交易营收 API 实现(提供 RESTful 接口给 telemetry Feign 调用,U5 经营看板 GMV 数据源) + * + * @RestController + @Primary:与 huijing DictDataApiImpl 同构——单体内同进程调用走本实现,拆微服务后走 Feign。 + * 纯暴露层:仅委托既有 {@link IncomeService}/{@link WithdrawService} 只读聚合方法(口径与 /admin-api/trade/report/revenue 一致), + * 不写任何业务逻辑(业务在 service,不改)。删除本类后 trade 既有单测仍绿,即「只暴露不改」回滚门。 + * + * @author 造梦AI + */ +@RestController // 提供 RESTful API 接口,给 Feign 调用 +@Validated +@Primary // 与 @FeignClient 接口同名 Bean 冲突时优先用本地实现(同进程调用就地解析) +@Slf4j +public class TradeApiImpl implements TradeApi { + + @Resource + private IncomeService incomeService; + + @Resource + private WithdrawService withdrawService; + + @Override + public CommonResult getRevenueReport(LocalDate statDateStart, LocalDate statDateEnd) { + // 只读聚合(口径同 TradeAdminController.getRevenueReport):Σgross/Σnet 来自收益流水,已打款提现来自提现表 + Long totalGross = incomeService.sumGross(statDateStart, statDateEnd); + Long totalNet = incomeService.sumNet(statDateStart, statDateEnd); + Long totalWithdrawPaid = withdrawService.sumPaidAmount(statDateStart, statDateEnd); + + TradeRevenueReportDTO dto = new TradeRevenueReportDTO(); + dto.setStatDateStart(statDateStart); + dto.setStatDateEnd(statDateEnd); + dto.setTotalGross(totalGross); + dto.setTotalNet(totalNet); + dto.setTotalPlatform(totalGross - totalNet); // 平台分成 = 原始收入 - 创作者实得 + dto.setTotalWithdrawPaid(totalWithdrawPaid); + // 跨模块只读调用留痕(供 telemetry 看板对账链路追溯) + log.info("[TradeApi.getRevenueReport] 营收聚合 start={}, end={}, gross={}, net={}", statDateStart, statDateEnd, totalGross, totalNet); + return success(dto); + } + +} 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; + +} diff --git a/game-cloud/huijing-server/src/main/resources/db/migration/V25.0.0__feed_rank_add_exposure_limit.sql b/game-cloud/huijing-server/src/main/resources/db/migration/V25.0.0__feed_rank_add_exposure_limit.sql new file mode 100644 index 00000000..f3cfaa62 --- /dev/null +++ b/game-cloud/huijing-server/src/main/resources/db/migration/V25.0.0__feed_rank_add_exposure_limit.sql @@ -0,0 +1,23 @@ +-- ============================================================================= +-- 契约 #2 DB 迁移 | 模块:feed(游戏流/精选/限曝光运营)| owner:U5(R-ADMIN) +-- 文件:V25.0.0__feed_rank_add_exposure_limit.sql(Flyway,只新增;接 V22.0.0 之后;已合入禁止修改,回滚写补偿迁移 V25.0.1) +-- 背书:plan002 U5(R-ADMIN admin 运营后端)限曝光特性——精选池(boost 加权)的反向运营手段(降权/限曝光)。 +-- 内容:game_feed_rank 加 exposure_limit —— 运营限曝光降权因子(精选 boost 的反向操作)。 +-- 为何加列而非复用负 boost(关键): +-- 1) boost 语义=运营正向加权(精选/活动),sort_score=quality+boost;若把降权塞进负 boost,会与正向加权口径混淆、 +-- 且算分回灌(QUALITY_REFRESH)重算 sort_score=quality+boost 时无法区分"降权意图"与"负向运营加权",语义污染不可逆; +-- 2) 独立列使降权显式、可审计;最终排序分口径升级为 sort_score = quality + boost - exposure_limit,降权方向自洽; +-- 3) 限曝光须穿越 telemetry 算分回灌存活——QUALITY_REFRESH 仅刷 quality_score 并据保留的 boost/exposure_limit 重算 +-- sort_score(同 boost 处理),故新列在算分回灌后仍生效(与 boost 同等"运营字段保留"地位)。 +-- additive 非阻断:exposure_limit DECIMAL(10,4) NOT NULL DEFAULT 0(存量行=0 即无降权,零行为变更); +-- 存量 INSERT/SELECT 不含本列即得默认 0,读写两侧零中断。 +-- 取值口径:exposure_limit >= 0; +-- 0 = 无降权(默认,现行行为); +-- 0 < x = 降权 x 分(sort_score 减 x,游戏在流中相对下沉); +-- >= 9999 = 硬限曝光(cap-hide)哨兵——buildStream 出流时直接剔除该游戏(不在流中露出),等价"运营软屏蔽" +-- (区别于 status=0 的下架/举报联动屏蔽:限曝光是运营节流,可随时改回,状态不变)。 +-- 回滚:additive → 真要 drop 写 V25.0.1 补偿迁移(不改本文件,遵 V4.0.0 头注铁律)。 +-- 错误码段:feed = 1-103-***-***(本迁移不引入新错误码)。 +-- ============================================================================= +ALTER TABLE `game_feed_rank` + ADD COLUMN `exposure_limit` DECIMAL(10,4) NOT NULL DEFAULT 0 COMMENT '运营限曝光降权因子(>=0):0无降权 / >0降权x分(sort_score-=x) / >=9999硬限曝光出流剔除哨兵;最终 sort_score=quality+boost-exposure_limit' AFTER `boost`;