merge(tier1): U5 R-ADMIN admin运营(限曝光/导出/经营看板 + 跨模块只读-api)
This commit is contained in:
commit
03d59b1672
@ -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`;
|
||||
@ -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);
|
||||
|
||||
}
|
||||
|
||||
@ -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)
|
||||
*
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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, "该游戏无排序记录,无法设置限曝光");
|
||||
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
/**
|
||||
|
||||
@ -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)
|
||||
*
|
||||
|
||||
@ -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 分流,守门③)
|
||||
*
|
||||
|
||||
@ -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 文本均不含 '|',无歧义) */
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
// ============================== 私有校验/流转辅助 ==============================
|
||||
|
||||
/**
|
||||
|
||||
@ -38,13 +38,34 @@
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 依赖 project 的 -api:创作者看板归属隔离(getMyGameIds 取"我的作品" gameId 集合后限定聚合范围;只依赖对方 -api 不依赖 -server,守门④无环) -->
|
||||
<!-- 依赖 project 的 -api:创作者看板归属隔离(getMyGameIds 取"我的作品" gameId 集合后限定聚合范围;只依赖对方 -api 不依赖 -server,守门④无环)
|
||||
+ U5 经营看板「发布量」(countPublished) -->
|
||||
<dependency>
|
||||
<groupId>com.wanxiang</groupId>
|
||||
<artifactId>game-module-project-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 依赖 trade 的 -api:U5 经营看板「GMV」(getRevenueReport;只依赖对方 -api 不依赖 -server,守门④无环) -->
|
||||
<dependency>
|
||||
<groupId>com.wanxiang</groupId>
|
||||
<artifactId>game-module-trade-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 依赖 aigc 的 -api:U5 经营看板「生成量」(countSucceededTasks;只依赖对方 -api 不依赖 -server,守门④无环) -->
|
||||
<dependency>
|
||||
<groupId>com.wanxiang</groupId>
|
||||
<artifactId>game-module-aigc-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Excel 导出(U5 经营看板/收益行导出 ExcelUtils + @ExcelProperty) -->
|
||||
<dependency>
|
||||
<groupId>com.wanxiang</groupId>
|
||||
<artifactId>huijing-spring-boot-starter-excel</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 业务组件:数据权限 + 多租户(DO 继承 TenantBaseDO) -->
|
||||
<dependency>
|
||||
<groupId>com.wanxiang</groupId>
|
||||
|
||||
@ -1,24 +1,37 @@
|
||||
package com.wanxiang.huijing.game.module.telemetry.controller.admin.stat;
|
||||
|
||||
import com.wanxiang.huijing.game.module.telemetry.controller.admin.stat.vo.DashboardExcelVO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.controller.admin.stat.vo.DashboardRespVO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.controller.admin.stat.vo.GameStatPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.controller.admin.stat.vo.GameStatRespVO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.convert.stat.GameStatConvert;
|
||||
import com.wanxiang.huijing.game.module.telemetry.dal.dataobject.stat.GameStatDO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.service.dashboard.DashboardService;
|
||||
import com.wanxiang.huijing.game.module.telemetry.service.stat.GameStatService;
|
||||
import com.wanxiang.huijing.framework.apilog.core.annotation.ApiAccessLog;
|
||||
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
import com.wanxiang.huijing.framework.excel.core.util.ExcelUtils;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collections;
|
||||
|
||||
import static com.wanxiang.huijing.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
|
||||
import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
@ -39,6 +52,10 @@ public class AdminTelemetryController {
|
||||
@Resource
|
||||
private GameStatService gameStatService;
|
||||
|
||||
/** 经营看板聚合 Service(U5 R-ADMIN,跨模块只读聚合 DAU/GMV/生成量/发布量) */
|
||||
@Resource
|
||||
private DashboardService dashboardService;
|
||||
|
||||
@GetMapping("/game-stat/page")
|
||||
@Operation(summary = "游戏维度聚合分页", description = "创作者/运营看板数据源;本模块只供数据不渲染 UI")
|
||||
@PreAuthorize("@ss.hasPermission('telemetry:game-stat:query')")
|
||||
@ -56,4 +73,40 @@ public class AdminTelemetryController {
|
||||
return success(GameStatConvert.toRespVO(stat));
|
||||
}
|
||||
|
||||
// ============================== 经营看板(U5 R-ADMIN,跨模块只读聚合)==============================
|
||||
|
||||
@GetMapping("/dashboard")
|
||||
@Operation(summary = "经营看板聚合(U5)", description = "跨模块只读聚合 DAU(本模块)+GMV(trade)+生成量(aigc)+发布量(project);缺模块数据兜底 0 不 500")
|
||||
@PreAuthorize("@ss.hasPermission('telemetry:dashboard:query')")
|
||||
@Parameter(name = "statDateStart", description = "统计起始日(yyyy-MM-dd)", required = true, example = "2026-06-01")
|
||||
@Parameter(name = "statDateEnd", description = "统计结束日(yyyy-MM-dd);DAU 取此日当天", required = true, example = "2026-06-30")
|
||||
public CommonResult<DashboardRespVO> getDashboard(
|
||||
@RequestParam("statDateStart") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateStart,
|
||||
@RequestParam("statDateEnd") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateEnd) {
|
||||
return success(dashboardService.getDashboard(statDateStart, statDateEnd));
|
||||
}
|
||||
|
||||
@GetMapping("/dashboard/export-excel")
|
||||
@Operation(summary = "经营看板导出 Excel(U5)", description = "单行导出经营看板四指标区间快照,供运营存档/汇报")
|
||||
@PreAuthorize("@ss.hasPermission('telemetry:dashboard:query')")
|
||||
@ApiAccessLog(operateType = EXPORT)
|
||||
@Parameter(name = "statDateStart", description = "统计起始日(yyyy-MM-dd)", required = true, example = "2026-06-01")
|
||||
@Parameter(name = "statDateEnd", description = "统计结束日(yyyy-MM-dd)", required = true, example = "2026-06-30")
|
||||
public void exportDashboardExcel(
|
||||
@RequestParam("statDateStart") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateStart,
|
||||
@RequestParam("statDateEnd") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateEnd,
|
||||
HttpServletResponse response) throws IOException {
|
||||
DashboardRespVO dashboard = dashboardService.getDashboard(statDateStart, statDateEnd);
|
||||
DashboardExcelVO excel = new DashboardExcelVO();
|
||||
excel.setStatDateStart(dashboard.getStatDateStart());
|
||||
excel.setStatDateEnd(dashboard.getStatDateEnd());
|
||||
excel.setDau(dashboard.getDau());
|
||||
excel.setGmv(dashboard.getGmv());
|
||||
excel.setGeneratedCount(dashboard.getGeneratedCount());
|
||||
excel.setPublishedCount(dashboard.getPublishedCount());
|
||||
excel.setDegraded(dashboard.getDegraded());
|
||||
// 单行导出(看板是一行聚合快照);导出口径与 /dashboard 完全一致
|
||||
ExcelUtils.write(response, "经营看板.xls", "数据", DashboardExcelVO.class, Collections.singletonList(excel));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
package com.wanxiang.huijing.game.module.telemetry.controller.admin.stat.vo;
|
||||
|
||||
import cn.idev.excel.annotation.ExcelProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 经营看板聚合 Excel VO(admin 端导出,U5 R-ADMIN 运营后端)
|
||||
*
|
||||
* 单行导出经营看板四指标(区间快照),供运营存档/汇报。字段口径与 {@link DashboardRespVO} 一致。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Data
|
||||
public class DashboardExcelVO {
|
||||
|
||||
@ExcelProperty("统计起始日")
|
||||
private LocalDate statDateStart;
|
||||
|
||||
@ExcelProperty("统计结束日")
|
||||
private LocalDate statDateEnd;
|
||||
|
||||
@ExcelProperty("DAU日活")
|
||||
private Long dau;
|
||||
|
||||
@ExcelProperty("GMV原始收入(分)")
|
||||
private Long gmv;
|
||||
|
||||
@ExcelProperty("生成量")
|
||||
private Long generatedCount;
|
||||
|
||||
@ExcelProperty("发布量")
|
||||
private Long publishedCount;
|
||||
|
||||
@ExcelProperty("是否降级(部分数据源不可用)")
|
||||
private Boolean degraded;
|
||||
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package com.wanxiang.huijing.game.module.telemetry.controller.admin.stat.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 经营看板聚合 Response VO(admin 端,U5 R-ADMIN 运营后端)
|
||||
*
|
||||
* 跨模块只读聚合四指标:DAU(本模块原始事件去重活跃身份)+ GMV(trade 营收)+ 生成量(aigc 成功任务)+ 发布量(project 已发布)。
|
||||
* 设计:缺模块数据走 null 安全兜底(对应指标为 0 + degraded 标记该指标降级),整体不 500(看板可用性优先)。
|
||||
* 跨模块 READ-ONLY:仅经各模块 -api 读,无任何写。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Schema(description = "管理后台 - 经营看板聚合 Response VO")
|
||||
@Data
|
||||
public class DashboardRespVO {
|
||||
|
||||
@Schema(description = "统计起始日", example = "2026-06-01")
|
||||
private LocalDate statDateStart;
|
||||
|
||||
@Schema(description = "统计结束日", example = "2026-06-30")
|
||||
private LocalDate statDateEnd;
|
||||
|
||||
@Schema(description = "DAU 日活(统计结束日当天去重活跃身份数;本模块原始事件聚合)", example = "1530")
|
||||
private Long dau;
|
||||
|
||||
@Schema(description = "GMV 区间原始收入合计(分账前,单位:分;trade 营收,缺 trade 兜底 0)", example = "1000000")
|
||||
private Long gmv;
|
||||
|
||||
@Schema(description = "区间生成量(aigc 成功生成任务数,缺 aigc 兜底 0)", example = "320")
|
||||
private Long generatedCount;
|
||||
|
||||
@Schema(description = "区间发布量(project 已发布项目数,缺 project 兜底 0)", example = "88")
|
||||
private Long publishedCount;
|
||||
|
||||
@Schema(description = "降级标记:true=有跨模块数据源不可用、对应指标已兜底 0(看板仍返回不 500)", example = "false")
|
||||
private Boolean degraded;
|
||||
|
||||
@Schema(description = "降级明细:哪些数据源不可用(如 ['trade','aigc']);正常为空", example = "[]")
|
||||
private java.util.List<String> degradedSources;
|
||||
|
||||
}
|
||||
@ -1,10 +1,16 @@
|
||||
package com.wanxiang.huijing.game.module.telemetry.dal.mysql.event;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import com.wanxiang.huijing.game.module.telemetry.dal.dataobject.event.TelemetryEventDO;
|
||||
import com.wanxiang.huijing.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import com.wanxiang.huijing.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import com.wanxiang.huijing.framework.mybatis.core.query.QueryWrapperX;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 遥测原始事件 Mapper
|
||||
*
|
||||
@ -29,4 +35,39 @@ public interface TelemetryEventMapper extends BaseMapperX<TelemetryEventDO> {
|
||||
.eq(TelemetryEventDO::getEventId, eventId));
|
||||
}
|
||||
|
||||
// ========== U5 经营看板 DAU(活跃去重):原始事件按 ts 区间去重活跃身份 ==========
|
||||
|
||||
/**
|
||||
* 区间内日活(DAU):原始事件按 ts(epoch ms) 区间去重活跃身份数(U5 经营看板本地数据源)
|
||||
*
|
||||
* <p>口径:DAU = 区间内有任意事件的去重活跃身份数 = 去重登录用户(user_id>0) + 去重匿名身份(anon_id 非空且 user_id 空/0)。
|
||||
* 登录与匿名分别 COUNT(DISTINCT) 后相加——同一自然人登录前后会被分别计入登录/匿名两类(MVP 看板可接受,
|
||||
* 真精确合并需稳定 device→user 映射,属后续)。</p>
|
||||
* <p>仿 trade/telemetry 既有 selectMaps 聚合范式(不依赖 DO 列别名映射,跨方言稳);
|
||||
* ts 为 epoch 毫秒,由 Service 把自然日端点换算为毫秒区间传入。tenant_id 由 MP 多租户拦截自动追加。</p>
|
||||
*
|
||||
* @param tsStart 区间起始时间戳(epoch 毫秒,含)
|
||||
* @param tsEnd 区间结束时间戳(epoch 毫秒,含)
|
||||
* @return DAU(去重活跃身份数;无数据兜底 0)
|
||||
*/
|
||||
default Long countActiveIdentities(Long tsStart, Long tsEnd) {
|
||||
// 登录用户去重数(仅统计 user_id>0 的登录态身份)
|
||||
List<Map<String, Object>> userRows = selectMaps(new QueryWrapperX<TelemetryEventDO>()
|
||||
.select("COUNT(DISTINCT user_id) AS userCnt")
|
||||
.gt("user_id", 0)
|
||||
.ge("ts", tsStart)
|
||||
.le("ts", tsEnd));
|
||||
long userCnt = MapUtil.getLong(CollUtil.getFirst(userRows), "userCnt", 0L);
|
||||
// 匿名身份去重数(user_id 缺失即匿名:user_id IS NULL OR =0;anon_id 非空才算有效匿名身份)
|
||||
List<Map<String, Object>> anonRows = selectMaps(new QueryWrapperX<TelemetryEventDO>()
|
||||
.select("COUNT(DISTINCT anon_id) AS anonCnt")
|
||||
.and(w -> w.isNull("user_id").or().eq("user_id", 0))
|
||||
.ne("anon_id", "")
|
||||
.isNotNull("anon_id")
|
||||
.ge("ts", tsStart)
|
||||
.le("ts", tsEnd));
|
||||
long anonCnt = MapUtil.getLong(CollUtil.getFirst(anonRows), "anonCnt", 0L);
|
||||
return userCnt + anonCnt;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
package com.wanxiang.huijing.game.module.telemetry.service.dashboard;
|
||||
|
||||
import com.wanxiang.huijing.game.module.telemetry.controller.admin.stat.vo.DashboardRespVO;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 经营看板聚合 Service 接口(U5 R-ADMIN,跨模块只读聚合)
|
||||
*
|
||||
* 承载:DAU(本模块原始事件去重活跃身份)+ GMV(trade)+ 生成量(aigc)+ 发布量(project)四指标聚合。
|
||||
* 跨模块 READ-ONLY:仅经各模块 -api 读,无写;缺模块数据 null 安全兜底,整体不 500(看板可用性优先)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
public interface DashboardService {
|
||||
|
||||
/**
|
||||
* 经营看板四指标聚合(statDateStart~statDateEnd 区间;DAU 取 statDateEnd 当日)
|
||||
*
|
||||
* @param statDateStart 统计起始日(含)
|
||||
* @param statDateEnd 统计结束日(含)
|
||||
* @return 看板聚合 VO(各指标至少为 0;有数据源不可用时 degraded=true 且对应指标兜底 0,永不抛 500)
|
||||
*/
|
||||
DashboardRespVO getDashboard(LocalDate statDateStart, LocalDate statDateEnd);
|
||||
|
||||
}
|
||||
@ -0,0 +1,126 @@
|
||||
package com.wanxiang.huijing.game.module.telemetry.service.dashboard;
|
||||
|
||||
import com.wanxiang.huijing.game.module.aigc.api.AigcApi;
|
||||
import com.wanxiang.huijing.game.module.project.api.ProjectApi;
|
||||
import com.wanxiang.huijing.game.module.telemetry.controller.admin.stat.vo.DashboardRespVO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.dal.mysql.event.TelemetryEventMapper;
|
||||
import com.wanxiang.huijing.game.module.trade.api.TradeApi;
|
||||
import com.wanxiang.huijing.game.module.trade.dto.TradeRevenueReportDTO;
|
||||
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* 经营看板聚合 Service 实现(U5 R-ADMIN,跨模块只读聚合)
|
||||
*
|
||||
* 四指标来源:
|
||||
* - DAU:本模块 game_telemetry_event 去重活跃身份(取 statDateEnd 当日);
|
||||
* - GMV:trade -api getRevenueReport(区间 totalGross);
|
||||
* - 生成量:aigc -api countSucceededTasks(区间 status=succeeded);
|
||||
* - 发布量:project -api countPublished(区间 status=PUBLISHED)。
|
||||
* 容错纪律(看板可用性优先):每个跨模块/本地取数各自 try-catch + 解包兜底——
|
||||
* 任一数据源不可用(Feign 异常/CommonResult 非 0/data=null)→ 该指标兜底 0 + degraded 标记,整体不 500。
|
||||
* 跨模块 READ-ONLY:仅经各模块 -api 读,无任何写。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class DashboardServiceImpl implements DashboardService {
|
||||
|
||||
@Resource
|
||||
private TelemetryEventMapper telemetryEventMapper;
|
||||
|
||||
/** trade -api:GMV(getRevenueReport)。MVP 单体由 trade TradeApiImpl(@RestController @Primary) 就地解析。 */
|
||||
@Resource
|
||||
private TradeApi tradeApi;
|
||||
|
||||
/** aigc -api:生成量(countSucceededTasks)。MVP 单体由 aigc AigcApiImpl(@RestController @Primary) 就地解析。 */
|
||||
@Resource
|
||||
private AigcApi aigcApi;
|
||||
|
||||
/** project -api:发布量(countPublished)。MVP 单体由 project ProjectApiImpl(@RestController @Primary) 就地解析。 */
|
||||
@Resource
|
||||
private ProjectApi projectApi;
|
||||
|
||||
@Override
|
||||
public DashboardRespVO getDashboard(LocalDate statDateStart, LocalDate statDateEnd) {
|
||||
DashboardRespVO vo = new DashboardRespVO();
|
||||
vo.setStatDateStart(statDateStart);
|
||||
vo.setStatDateEnd(statDateEnd);
|
||||
// degradedSources:累计不可用的数据源名,非空即整体 degraded(看板前端据此提示"部分指标暂不可用")
|
||||
List<String> degraded = new ArrayList<>(4);
|
||||
|
||||
// ① DAU(本地):取 statDateEnd 当日的去重活跃身份(自然日 → epoch 毫秒区间)
|
||||
long dayStartMs = toEpochMilli(statDateEnd.atStartOfDay());
|
||||
long dayEndMs = toEpochMilli(statDateEnd.atTime(LocalTime.MAX));
|
||||
vo.setDau(safeCount("telemetry-dau", degraded,
|
||||
() -> telemetryEventMapper.countActiveIdentities(dayStartMs, dayEndMs)));
|
||||
|
||||
// ② GMV(trade -api):区间原始收入合计 totalGross(缺 trade 兜底 0)
|
||||
vo.setGmv(safeCount("trade", degraded, () -> {
|
||||
CommonResult<TradeRevenueReportDTO> result = tradeApi.getRevenueReport(statDateStart, statDateEnd);
|
||||
// 经 CommonResult 解包(非 0 抛异常被外层 catch 兜底);data 或其 gross 为 null 时回 0
|
||||
TradeRevenueReportDTO report = result.getCheckedData();
|
||||
return report == null || report.getTotalGross() == null ? 0L : report.getTotalGross();
|
||||
}));
|
||||
|
||||
// ③ 生成量(aigc -api):区间成功生成任务数(缺 aigc 兜底 0)
|
||||
vo.setGeneratedCount(safeCount("aigc", degraded,
|
||||
() -> aigcApi.countSucceededTasks(statDateStart, statDateEnd).getCheckedData()));
|
||||
|
||||
// ④ 发布量(project -api):区间已发布项目数(缺 project 兜底 0)
|
||||
vo.setPublishedCount(safeCount("project", degraded,
|
||||
() -> projectApi.countPublished(statDateStart, statDateEnd).getCheckedData()));
|
||||
|
||||
vo.setDegradedSources(degraded);
|
||||
vo.setDegraded(!degraded.isEmpty());
|
||||
if (vo.getDegraded()) {
|
||||
// 降级留痕:看板返回成功但有数据源缺失,运营/排障可据日志定位(不抛错、不阻断看板)
|
||||
log.warn("[dashboard] 经营看板部分数据源不可用,已兜底 0:degradedSources={}, start={}, end={}",
|
||||
degraded, statDateStart, statDateEnd);
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单指标取数兜底包装(看板可用性优先):执行取数 supplier,异常/返回 null 一律兜底 0 并登记降级源
|
||||
*
|
||||
* 每个数据源独立兜底——一个模块不可用只降级它自己那一格,其余指标照常返回(整体不 500)。
|
||||
*
|
||||
* @param source 数据源名(降级登记 + 日志用)
|
||||
* @param degraded 降级源累计列表(不可用时追加 source)
|
||||
* @param supplier 取数逻辑(可能抛 Feign 异常 / CommonResult 解包异常)
|
||||
* @return 取到的计数;异常或 null 兜底 0
|
||||
*/
|
||||
private long safeCount(String source, List<String> degraded, Supplier<Long> supplier) {
|
||||
try {
|
||||
Long value = supplier.get();
|
||||
return value == null ? 0L : value;
|
||||
} catch (Exception e) {
|
||||
// 跨模块数据源不可用(Feign 超时/异常、CommonResult 非 0 解包抛错等):兜底 0 + 标记降级,不向上抛(看板不 500)
|
||||
degraded.add(source);
|
||||
log.warn("[dashboard] 数据源 {} 取数失败,该指标兜底 0:{}", source, e.getMessage());
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地自然日时间 → epoch 毫秒(系统默认时区,与 telemetry 事件 ts 入库口径一致)
|
||||
*
|
||||
* @param dateTime 本地日期时间
|
||||
* @return epoch 毫秒
|
||||
*/
|
||||
private static long toEpochMilli(java.time.LocalDateTime dateTime) {
|
||||
return dateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,167 @@
|
||||
package com.wanxiang.huijing.game.module.telemetry.service.dashboard;
|
||||
|
||||
import com.wanxiang.huijing.game.module.aigc.api.AigcApi;
|
||||
import com.wanxiang.huijing.game.module.project.api.ProjectApi;
|
||||
import com.wanxiang.huijing.game.module.telemetry.controller.admin.stat.vo.DashboardRespVO;
|
||||
import com.wanxiang.huijing.game.module.telemetry.dal.mysql.event.TelemetryEventMapper;
|
||||
import com.wanxiang.huijing.game.module.trade.api.TradeApi;
|
||||
import com.wanxiang.huijing.game.module.trade.dto.TradeRevenueReportDTO;
|
||||
import com.wanxiang.huijing.framework.common.exception.ErrorCode;
|
||||
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
|
||||
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* {@link DashboardServiceImpl} 单元测试(纯 Mockito,不依赖 DB)
|
||||
*
|
||||
* 覆盖 U5 经营看板:四指标聚合正确(DAU/GMV/生成量/发布量)+ 缺模块数据 null 安全兜底(不 500)+ 跨模块只读。
|
||||
* 跨模块 -api(trade/aigc/project)与本地 TelemetryEventMapper 全 mock;按 KTD4 测矩阵覆盖正常 + 单源故障 + 全源故障。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
class DashboardServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private DashboardServiceImpl dashboardService;
|
||||
|
||||
@Mock
|
||||
private TelemetryEventMapper telemetryEventMapper; // DAU 本地数据源
|
||||
@Mock
|
||||
private TradeApi tradeApi; // GMV 跨模块源
|
||||
@Mock
|
||||
private AigcApi aigcApi; // 生成量 跨模块源
|
||||
@Mock
|
||||
private ProjectApi projectApi; // 发布量 跨模块源
|
||||
|
||||
private static final LocalDate START = LocalDate.of(2026, 6, 1);
|
||||
private static final LocalDate END = LocalDate.of(2026, 6, 30);
|
||||
|
||||
@Test
|
||||
void testGetDashboard_allSourcesOk_aggregatesCorrectly() {
|
||||
// 四源全可用:DAU=1530 / GMV(totalGross)=1000000 / 生成量=320 / 发布量=88
|
||||
when(telemetryEventMapper.countActiveIdentities(anyLong(), anyLong())).thenReturn(1530L);
|
||||
TradeRevenueReportDTO revenue = new TradeRevenueReportDTO();
|
||||
revenue.setTotalGross(1_000_000L);
|
||||
revenue.setTotalNet(800_000L);
|
||||
when(tradeApi.getRevenueReport(START, END)).thenReturn(CommonResult.success(revenue));
|
||||
when(aigcApi.countSucceededTasks(START, END)).thenReturn(CommonResult.success(320L));
|
||||
when(projectApi.countPublished(START, END)).thenReturn(CommonResult.success(88L));
|
||||
|
||||
DashboardRespVO vo = dashboardService.getDashboard(START, END);
|
||||
|
||||
assertEquals(1530L, vo.getDau());
|
||||
assertEquals(1_000_000L, vo.getGmv()); // GMV 取 totalGross
|
||||
assertEquals(320L, vo.getGeneratedCount());
|
||||
assertEquals(88L, vo.getPublishedCount());
|
||||
assertFalse(vo.getDegraded()); // 全可用不降级
|
||||
assertTrue(vo.getDegradedSources().isEmpty());
|
||||
assertEquals(START, vo.getStatDateStart());
|
||||
assertEquals(END, vo.getStatDateEnd());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetDashboard_tradeThrows_fallbackZeroNot500() {
|
||||
// 缺模块兜底:trade -api 抛异常(Feign 不可用)→ GMV 兜底 0 + degraded 标记 trade,其余指标照常、整体不 500
|
||||
when(telemetryEventMapper.countActiveIdentities(anyLong(), anyLong())).thenReturn(1530L);
|
||||
when(tradeApi.getRevenueReport(START, END)).thenThrow(new RuntimeException("trade feign down"));
|
||||
when(aigcApi.countSucceededTasks(START, END)).thenReturn(CommonResult.success(320L));
|
||||
when(projectApi.countPublished(START, END)).thenReturn(CommonResult.success(88L));
|
||||
|
||||
DashboardRespVO vo = assertDoesNotThrow(() -> dashboardService.getDashboard(START, END));
|
||||
|
||||
assertEquals(0L, vo.getGmv()); // trade 故障 → GMV 兜底 0
|
||||
assertEquals(1530L, vo.getDau()); // 其余照常
|
||||
assertEquals(320L, vo.getGeneratedCount());
|
||||
assertEquals(88L, vo.getPublishedCount());
|
||||
assertTrue(vo.getDegraded());
|
||||
assertTrue(vo.getDegradedSources().contains("trade"));
|
||||
assertEquals(1, vo.getDegradedSources().size()); // 仅 trade 降级
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetDashboard_aigcNon200CommonResult_fallbackZero() {
|
||||
// CommonResult 非 0(getCheckedData 抛 ServiceException)也走兜底:生成量兜底 0 + degraded aigc
|
||||
when(telemetryEventMapper.countActiveIdentities(anyLong(), anyLong())).thenReturn(100L);
|
||||
TradeRevenueReportDTO revenue = new TradeRevenueReportDTO();
|
||||
revenue.setTotalGross(500L);
|
||||
when(tradeApi.getRevenueReport(START, END)).thenReturn(CommonResult.success(revenue));
|
||||
// aigc 返回业务错误码 → getCheckedData() 抛 ServiceException,被兜底捕获
|
||||
when(aigcApi.countSucceededTasks(START, END))
|
||||
.thenReturn(CommonResult.error(new ErrorCode(1_101_000_999, "aigc 内部错误")));
|
||||
when(projectApi.countPublished(START, END)).thenReturn(CommonResult.success(88L));
|
||||
|
||||
DashboardRespVO vo = assertDoesNotThrow(() -> dashboardService.getDashboard(START, END));
|
||||
|
||||
assertEquals(0L, vo.getGeneratedCount()); // aigc 业务错误 → 兜底 0
|
||||
assertEquals(500L, vo.getGmv());
|
||||
assertEquals(88L, vo.getPublishedCount());
|
||||
assertTrue(vo.getDegradedSources().contains("aigc"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetDashboard_tradeDataNull_fallbackZeroNoDegradeFromNullData() {
|
||||
// trade 返回成功但 data=null(区间无营收)→ GMV 兜底 0,但这是正常空数据、非故障:不计降级
|
||||
when(telemetryEventMapper.countActiveIdentities(anyLong(), anyLong())).thenReturn(0L);
|
||||
when(tradeApi.getRevenueReport(START, END)).thenReturn(CommonResult.success(null));
|
||||
when(aigcApi.countSucceededTasks(START, END)).thenReturn(CommonResult.success(0L));
|
||||
when(projectApi.countPublished(START, END)).thenReturn(CommonResult.success(0L));
|
||||
|
||||
DashboardRespVO vo = dashboardService.getDashboard(START, END);
|
||||
|
||||
assertEquals(0L, vo.getGmv()); // data=null → 0
|
||||
assertFalse(vo.getDegraded()); // 正常空数据不算降级(只有抛异常才降级)
|
||||
assertTrue(vo.getDegradedSources().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetDashboard_allCrossModuleDown_dauStillFromLocal() {
|
||||
// 全跨模块源故障:GMV/生成量/发布量 全兜底 0 + 三源降级,但本地 DAU 仍可取(看板核心可用性)
|
||||
when(telemetryEventMapper.countActiveIdentities(anyLong(), anyLong())).thenReturn(999L);
|
||||
when(tradeApi.getRevenueReport(any(), any())).thenThrow(new RuntimeException("down"));
|
||||
when(aigcApi.countSucceededTasks(any(), any())).thenThrow(new RuntimeException("down"));
|
||||
when(projectApi.countPublished(any(), any())).thenThrow(new RuntimeException("down"));
|
||||
|
||||
DashboardRespVO vo = assertDoesNotThrow(() -> dashboardService.getDashboard(START, END));
|
||||
|
||||
assertEquals(999L, vo.getDau()); // 本地 DAU 仍取到
|
||||
assertEquals(0L, vo.getGmv());
|
||||
assertEquals(0L, vo.getGeneratedCount());
|
||||
assertEquals(0L, vo.getPublishedCount());
|
||||
assertTrue(vo.getDegraded());
|
||||
assertEquals(3, vo.getDegradedSources().size()); // trade/aigc/project 三源降级
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetDashboard_crossModuleReadOnly_noWriteMethods() {
|
||||
// 跨模块只读:dashboard 仅调各 -api 的只读 count/report 方法,绝不触发任何写(归属/状态机/下架等写方法 never)
|
||||
when(telemetryEventMapper.countActiveIdentities(anyLong(), anyLong())).thenReturn(1L);
|
||||
TradeRevenueReportDTO revenue = new TradeRevenueReportDTO();
|
||||
revenue.setTotalGross(1L);
|
||||
when(tradeApi.getRevenueReport(START, END)).thenReturn(CommonResult.success(revenue));
|
||||
when(aigcApi.countSucceededTasks(START, END)).thenReturn(CommonResult.success(1L));
|
||||
when(projectApi.countPublished(START, END)).thenReturn(CommonResult.success(1L));
|
||||
|
||||
dashboardService.getDashboard(START, END);
|
||||
|
||||
// 只读方法被调用
|
||||
verify(tradeApi).getRevenueReport(START, END);
|
||||
verify(aigcApi).countSucceededTasks(START, END);
|
||||
verify(projectApi).countPublished(START, END);
|
||||
// 写类方法绝不被调用(跨模块只读铁律)
|
||||
verify(projectApi, never()).createProject(any(), any(), any());
|
||||
verify(projectApi, never()).unlistIfPublished(any());
|
||||
verify(aigcApi, never()).submitGenerate(any());
|
||||
verify(aigcApi, never()).retryTask(any(), any());
|
||||
verifyNoMoreInteractions(tradeApi);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -3,6 +3,7 @@ package com.wanxiang.huijing.game.module.trade.controller.admin;
|
||||
import com.wanxiang.huijing.game.module.trade.controller.admin.vo.GrantBalanceReqVO;
|
||||
import com.wanxiang.huijing.game.module.trade.controller.admin.vo.GrantSubscriptionReqVO;
|
||||
import com.wanxiang.huijing.game.module.trade.controller.admin.vo.IncomePageReqVO;
|
||||
import com.wanxiang.huijing.game.module.trade.controller.admin.vo.RevenueExcelVO;
|
||||
import com.wanxiang.huijing.game.module.trade.controller.admin.vo.RevenueReportVO;
|
||||
import com.wanxiang.huijing.game.module.trade.controller.admin.vo.WithdrawAuditReqVO;
|
||||
import com.wanxiang.huijing.game.module.trade.controller.admin.vo.WithdrawPageReqVO;
|
||||
@ -16,13 +17,16 @@ import com.wanxiang.huijing.game.module.trade.service.account.AccountService;
|
||||
import com.wanxiang.huijing.game.module.trade.service.income.IncomeService;
|
||||
import com.wanxiang.huijing.game.module.trade.service.subscription.SubscriptionService;
|
||||
import com.wanxiang.huijing.game.module.trade.service.withdraw.WithdrawService;
|
||||
import com.wanxiang.huijing.framework.apilog.core.annotation.ApiAccessLog;
|
||||
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
import com.wanxiang.huijing.framework.excel.core.util.ExcelUtils;
|
||||
import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
@ -34,8 +38,11 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collections;
|
||||
|
||||
import static com.wanxiang.huijing.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
|
||||
import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
@ -112,7 +119,41 @@ public class TradeAdminController {
|
||||
public CommonResult<RevenueReportVO> getRevenueReport(
|
||||
@RequestParam("statDateStart") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateStart,
|
||||
@RequestParam("statDateEnd") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateEnd) {
|
||||
// 只读聚合:Σgross / Σnet 来自收益流水,已打款提现来自提现表;平台分成 = gross - net
|
||||
return success(buildRevenueReport(statDateStart, statDateEnd));
|
||||
}
|
||||
|
||||
@GetMapping("/report/revenue/export-excel")
|
||||
@Operation(summary = "平台营收报表导出 Excel(U5)", description = "单行导出营收报表区间聚合(口径同 /report/revenue),供运营对账存档")
|
||||
@PreAuthorize("@ss.hasPermission('trade:report:export')")
|
||||
@ApiAccessLog(operateType = EXPORT)
|
||||
@Parameter(name = "statDateStart", description = "统计起始日(yyyy-MM-dd)", required = true, example = "2026-06-01")
|
||||
@Parameter(name = "statDateEnd", description = "统计结束日(yyyy-MM-dd)", required = true, example = "2026-06-30")
|
||||
public void exportRevenueExcel(
|
||||
@RequestParam("statDateStart") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateStart,
|
||||
@RequestParam("statDateEnd") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate statDateEnd,
|
||||
HttpServletResponse response) throws IOException {
|
||||
RevenueReportVO report = buildRevenueReport(statDateStart, statDateEnd);
|
||||
RevenueExcelVO excel = new RevenueExcelVO();
|
||||
excel.setStatDateStart(report.getStatDateStart());
|
||||
excel.setStatDateEnd(report.getStatDateEnd());
|
||||
excel.setTotalGross(report.getTotalGross());
|
||||
excel.setTotalNet(report.getTotalNet());
|
||||
excel.setTotalPlatform(report.getTotalPlatform());
|
||||
excel.setTotalWithdrawPaid(report.getTotalWithdrawPaid());
|
||||
// 单行导出(营收报表是一行区间聚合);导出口径与 /report/revenue 完全一致
|
||||
ExcelUtils.write(response, "平台营收报表.xls", "数据", RevenueExcelVO.class, Collections.singletonList(excel));
|
||||
}
|
||||
|
||||
/**
|
||||
* 营收报表区间聚合(/report/revenue 与导出共用,避免口径漂移)
|
||||
*
|
||||
* 只读聚合:Σgross / Σnet 来自收益流水,已打款提现来自提现表;平台分成 = gross - net。
|
||||
*
|
||||
* @param statDateStart 统计起始日(含)
|
||||
* @param statDateEnd 统计结束日(含)
|
||||
* @return 营收报表 VO(金额单位分)
|
||||
*/
|
||||
private RevenueReportVO buildRevenueReport(LocalDate statDateStart, LocalDate statDateEnd) {
|
||||
Long totalGross = incomeService.sumGross(statDateStart, statDateEnd);
|
||||
Long totalNet = incomeService.sumNet(statDateStart, statDateEnd);
|
||||
Long totalWithdrawPaid = withdrawService.sumPaidAmount(statDateStart, statDateEnd);
|
||||
@ -124,7 +165,7 @@ public class TradeAdminController {
|
||||
report.setTotalNet(totalNet);
|
||||
report.setTotalPlatform(totalGross - totalNet); // 平台分成 = 原始收入 - 创作者实得
|
||||
report.setTotalWithdrawPaid(totalWithdrawPaid);
|
||||
return success(report);
|
||||
return report;
|
||||
}
|
||||
|
||||
// ============================== U2 经济:赋余额 / 赋订阅 ==============================
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
package com.wanxiang.huijing.game.module.trade.controller.admin.vo;
|
||||
|
||||
import cn.idev.excel.annotation.ExcelProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 平台营收报表 Excel VO(admin 端导出,U5 R-ADMIN 运营后端)
|
||||
*
|
||||
* 单行导出营收报表区间聚合(口径同 {@link RevenueReportVO}),供运营对账存档。金额单位分。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@Data
|
||||
public class RevenueExcelVO {
|
||||
|
||||
@ExcelProperty("统计起始日")
|
||||
private LocalDate statDateStart;
|
||||
|
||||
@ExcelProperty("统计结束日")
|
||||
private LocalDate statDateEnd;
|
||||
|
||||
@ExcelProperty(value = "原始收入合计(分)")
|
||||
private Long totalGross;
|
||||
|
||||
@ExcelProperty(value = "创作者实得合计(分)")
|
||||
private Long totalNet;
|
||||
|
||||
@ExcelProperty(value = "平台分成合计(分)")
|
||||
private Long totalPlatform;
|
||||
|
||||
@ExcelProperty(value = "已打款提现合计(分)")
|
||||
private Long totalWithdrawPaid;
|
||||
|
||||
}
|
||||
@ -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`;
|
||||
Loading…
x
Reference in New Issue
Block a user