merge(tier1): U5 R-ADMIN admin运营(限曝光/导出/经营看板 + 跨模块只读-api)

This commit is contained in:
lili 2026-06-18 11:31:32 -07:00
commit 03d59b1672
34 changed files with 1405 additions and 26 deletions

View File

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

View File

@ -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<AigcGenerateRespDTO> 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<Long> countSucceededTasks(
@RequestParam("statDateStart") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateStart,
@RequestParam("statDateEnd") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateEnd);
}

View File

@ -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<List<AigcTemplateDTO>> getTemplateList() {
// 透传 aigc 模板注册表(MVP 骨架返回空列表占位)
@ -92,6 +102,16 @@ public class AigcApiImpl implements AigcApi {
return success(toGenerateRespDTO(retry));
}
@Override
public CommonResult<Long> 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)
*

View File

@ -192,4 +192,24 @@ public interface AigcTaskMapper extends BaseMapperX<AigcTaskDO> {
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<AigcTaskDO>()
.eq(AigcTaskDO::getStatus, AigcTaskStatusEnum.SUCCEEDED.getStatus())
.ge(AigcTaskDO::getFinishTime, start)
.le(AigcTaskDO::getFinishTime, end));
}
}

View File

@ -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, "该游戏无排序记录,无法设置限曝光");
}

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>
<!-- 缓存(游戏流排序快照 Redis TTL 兜底,MVP 阶段先占位) -->
<dependency>
<groupId>com.wanxiang</groupId>

View File

@ -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<Boolean> 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<FeedRankDO> list = feedService.getFeaturedList(zoneId);
// 空数据也导出表头(仿 PayOrderController:空列表写表头,前端拿到合法空表)
List<FeaturedExcelVO> 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;
}
}

View File

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

View File

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

View File

@ -48,7 +48,14 @@ public class FeedRankDO extends TenantBaseDO {
*/
private BigDecimal boost;
/**
* 最终排序分(= quality_score + boost + 保底/降权修正,降序出流)
* 运营限曝光降权因子(>=0,U5 限曝光;boost 的反向操作,V25.0.0 新增)
*
* 取值:0 无降权 / 0&lt;x 降权 x 分(sort_score 减 x,相对下沉)/ &gt;=9999 硬限曝光哨兵(buildStream 出流剔除)。
* 最终 sort_score = quality_score + boost - exposure_limit;与 boost 同等"运营字段"地位,穿越 telemetry 算分回灌存活。
*/
private BigDecimal exposureLimit;
/**
* 最终排序分(= quality_score + boost - exposure_limit + 保底修正,降序出流)
*/
private BigDecimal sortScore;
/**

View File

@ -68,6 +68,22 @@ public interface FeedRankMapper extends BaseMapperX<FeedRankDO> {
.orderByDesc(FeedRankDO::getSortScore));
}
/**
* 精选池/限曝光导出列表(管理端 U5 导出,不分页,仅精选置顶项;命中 idx_pinned)
*
* 复用 {@link #selectFeaturedPage} 同口径筛选(pinned=1 + 可选专区),去分页取全量供 Excel 导出。
* 排序覆盖层运营字段全字段导出(含 boost/exposure_limit),供运营对账加权/限曝光快照。
*
* @param zoneId 可选专区筛选;null=不限专区
* @return 精选记录列表(按 sort_score 降序;无数据返回空列表)
*/
default List<FeedRankDO> selectFeaturedList(Long zoneId) {
return selectList(new LambdaQueryWrapperX<FeedRankDO>()
.eq(FeedRankDO::getPinned, 1) // 仅精选置顶项(与分页同口径)
.eqIfPresent(FeedRankDO::getZoneId, zoneId) // 可选专区筛选
.orderByDesc(FeedRankDO::getSortScore));
}
/**
* 按游戏 + 分区取唯一排序记录(精选置顶/取消时定位覆盖层记录,对应 uk_game_zone)
*

View File

@ -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<FeedRankDO> getFeaturedPage(FeaturedPageReqVO reqVO);
/**
* 精选池/限曝光导出列表(管理端 RBAC;不分页,仅精选置顶项,供 Excel 导出)
*
* @param zoneId 可选专区筛选;null=不限专区
* @return 精选记录列表(按 sort_score 降序;无数据返回空列表)
*/
List<FeedRankDO> 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 分流,守门③)
*

View File

@ -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<FeedRankDO> filterByProjectPublished(List<FeedRankDO> ranks) {
private List<FeedRankDO> filterVisibleRanks(List<FeedRankDO> 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<FeedRankDO> 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 文本均不含 '|',无歧义) */

View File

@ -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<FeedRankDO> 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 且本批<target=3? 本批=3=target → 不算到尾
// 为简化到底判定:返回 2 条(<target 3 → 库到尾),g2 硬限曝光被剔 → 仅 g1 出流
FeedRankDO g1 = rankWithId(1L, 1L, 10L, 0L, new BigDecimal("90"));
FeedRankDO g2 = rankWithId(2L, 2L, 11L, 0L, new BigDecimal("80"));
g2.setExposureLimit(new BigDecimal("9999")); // 硬限曝光哨兵 → 出流剔除
when(feedRankMapper.selectStreamByZone(eq(0L), isNull(), isNull(), eq(3)))
.thenReturn(Arrays.asList(g1, g2));
when(projectApi.getStatus(1L)).thenReturn(CommonResult.success(4));
when(projectApi.getStatus(2L)).thenReturn(CommonResult.success(4)); // 已发布但被硬限曝光
when(projectApi.getFeedMeta(1L)).thenReturn(CommonResult.success(null));
FeedStreamReqVO reqVO = new FeedStreamReqVO();
reqVO.setSize(2);
FeedStreamRespVO resp = feedService.getFeedStream(reqVO, 99L);
assertEquals(1, resp.getList().size()); // g2 被硬限曝光剔除,仅 g1 出流
assertEquals(1L, resp.getList().get(0).getGameId());
assertFalse(resp.getHasMore()); // 过滤后不足 size + 库到尾 → 到底
// g2 已发布但因硬限曝光被剔——验证 cap 在出流处生效(status 未动,可随时改回)
}
@Test
void testGetFeedStream_normalDownweightNotFilteredOut() {
// 普通降权(0<exposure_limit<9999)不剔除出流——只通过 sort_score 下沉(持久化已扣减),出流仍露出
FeedRankDO g1 = rankWithId(1L, 1L, 10L, 0L, new BigDecimal("90"));
FeedRankDO g2 = rankWithId(2L, 2L, 11L, 0L, new BigDecimal("50"));
g2.setExposureLimit(new BigDecimal("30")); // 普通降权(已反映在 sort_score=50),不剔除
when(feedRankMapper.selectStreamByZone(eq(0L), isNull(), isNull(), eq(3)))
.thenReturn(Arrays.asList(g1, g2));
stubPublished(1L, 2L);
when(projectApi.getFeedMeta(any())).thenReturn(CommonResult.success(null));
FeedStreamReqVO reqVO = new FeedStreamReqVO();
reqVO.setSize(2);
FeedStreamRespVO resp = feedService.getFeedStream(reqVO, 99L);
assertEquals(2, resp.getList().size()); // 普通降权仍出流(只是排名靠后)
assertEquals(2L, resp.getList().get(1).getGameId()); // g2 降权后排在 g1 之后
}
@Test
void testGetFeaturedList_delegatesToMapper() {
// 导出列表:委托 mapper 取精选置顶项全量(不分页),可选专区透传
when(feedRankMapper.selectFeaturedList(2L))
.thenReturn(Arrays.asList(rankWithId(1L, 1L, 10L, 2L, new BigDecimal("90"))));
List<FeedRankDO> 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<FeedRankDO> 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<FeedRankDO> 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();

View File

@ -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<Boolean> 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<Long> countPublished(
@RequestParam("statDateStart") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateStart,
@RequestParam("statDateEnd") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateEnd);
}

View File

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

View File

@ -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<ProjectDO> {
.orderByDesc(ProjectDO::getId));
}
/**
* 区间内发布量 count(U5 经营看板「发布量」,跨模块 Feign:telemetry 看板取)
*
* 口径:当前 status=PUBLISHED(4) 且 update_time 落在 [start,end] 的项目数。selectCount(零原生 SQL/零 DDL)。
* <p><b>时间维度说明(已知近似)</b>:项目表无独立 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<ProjectDO>()
.eq(ProjectDO::getStatus, ProjectStatusEnum.PUBLISHED.getStatus())
.ge(ProjectDO::getUpdateTime, start)
.le(ProjectDO::getUpdateTime, end));
}
}

View File

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

View File

@ -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);
}
// ============================== 私有校验/流转辅助 ==============================
/**

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

@ -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<TradeRevenueReportDTO> getRevenueReport(
@RequestParam("statDateStart") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateStart,
@RequestParam("statDateEnd") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateEnd);
}

View File

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

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

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

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

View File

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