From f85ae6c43467c7bbb6aa706e5b782860a1a7ed3e Mon Sep 17 00:00:00 2001 From: lili Date: Thu, 18 Jun 2026 11:06:35 -0700 Subject: [PATCH] =?UTF-8?q?feat(feed):=20U5=20=E9=99=90=E6=9B=9D=E5=85=89?= =?UTF-8?q?=E9=99=8D=E6=9D=83=20+=20=E7=B2=BE=E9=80=89=E6=B1=A0=E5=AF=BC?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 限曝光(boost 的反向运营操作): - Flyway V25.0.0 加 game_feed_rank.exposure_limit DECIMAL(10,4) NOT NULL DEFAULT 0(additive, 镜像 contracts/db-schemas);FeedRankDO 加 exposureLimit 字段。 - 排序口径升级为 sort_score = quality + boost - exposure_limit,统一收敛在 deriveSortScore()—— setFeatured/setExposureLimit/发布基线重发布/算分回灌四写入点共用,保证 boost/exposure_limit 两个 运营维度在任意写入路径都被保留(限曝光穿越 telemetry 算分回灌存活,已单测验证)。 - 两层效果:① 普通降权(0=9999)在 buildStream 出流处剔除(复用既有二次过滤续取机制,不动 status、可随时改回)。 - 新端点 POST /admin-api/feed/exposure-limit(复用 feed:featured:update 权限 + @ApiAccessLog UPDATE 审计), 门禁=仅已发布且已有排序行(不新建孤儿降权行);错误码段 1-103-005-***。 导出:GET /admin-api/feed/featured/export-excel(feed:featured:export + EXPORT 审计)经 ExcelUtils 导出精选/限曝光排序覆盖层运营字段;空数据写表头。feed-server 加 starter-excel 依赖。 测试:FeedServiceImplTest 25→35(限曝光门禁/降权扣减/硬限曝光出流剔除/普通降权不剔/回灌保留限曝光/导出列表空与非空)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../V25.0.0__feed_rank_add_exposure_limit.sql | 23 +++ .../module/feed/enums/ErrorCodeConstants.java | 8 +- .../game-module-feed-server/pom.xml | 6 + .../admin/feed/AdminFeedController.java | 61 ++++++++ .../admin/feed/vo/ExposureLimitReqVO.java | 34 ++++ .../admin/feed/vo/FeaturedExcelVO.java | 54 +++++++ .../feed/dal/dataobject/rank/FeedRankDO.java | 9 +- .../feed/dal/mysql/rank/FeedRankMapper.java | 16 ++ .../module/feed/service/feed/FeedService.java | 20 +++ .../feed/service/feed/FeedServiceImpl.java | 135 +++++++++++++--- .../service/feed/FeedServiceImplTest.java | 146 ++++++++++++++++++ .../V25.0.0__feed_rank_add_exposure_limit.sql | 23 +++ 12 files changed, 512 insertions(+), 23 deletions(-) create mode 100644 contracts/db-schemas/V25.0.0__feed_rank_add_exposure_limit.sql create mode 100644 game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/controller/admin/feed/vo/ExposureLimitReqVO.java create mode 100644 game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/controller/admin/feed/vo/FeaturedExcelVO.java create mode 100644 game-cloud/huijing-server/src/main/resources/db/migration/V25.0.0__feed_rank_add_exposure_limit.sql 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`;