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