feat(feed): U5 限曝光降权 + 精选池导出

限曝光(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<x<9999)持久化进 sort_score 使游戏下沉(cursor 分页仍正确);
  ② 硬限曝光哨兵(>=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) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-18 11:06:35 -07:00
parent 240a18fc33
commit f85ae6c434
12 changed files with 512 additions and 23 deletions

View File

@ -0,0 +1,23 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 模块feed游戏流/精选/限曝光运营)| ownerU5R-ADMIN
-- 文件V25.0.0__feed_rank_add_exposure_limit.sqlFlyway只新增接 V22.0.0 之后;已合入禁止修改,回滚写补偿迁移 V25.0.1
-- 背书plan002 U5R-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 '运营限曝光降权因子(>=00无降权 / >0降权x分(sort_score-=x) / >=9999硬限曝光出流剔除哨兵最终 sort_score=quality+boost-exposure_limit' AFTER `boost`;

View File

@ -7,7 +7,7 @@ import com.wanxiang.huijing.framework.common.exception.ErrorCode;
*
* feed 模块独占 1-103-***-*** .agents/rules/engineering-conventions.md §1.3
* 约定禁止与其它模块错误码段重叠新增错误码在此登记
* 段内业务细分000 游戏流 / 001 互动 / 002 分享 / 003 精选池 / 004 排序行 upsert黄金闭环 §3.2/§3.5
* 段内业务细分000 游戏流 / 001 互动 / 002 分享 / 003 精选池 / 004 排序行 upsert黄金闭环 §3.2/§3.5/ 005 限曝光U5 运营
*
* @author 绘境AI
*/
@ -37,4 +37,10 @@ public interface ErrorCodeConstants {
/** 可见态写侧校验未过mode=PUBLISH_BASELINE 写 status=1 前须满足「project 已发布 + 当前生效版本一致 + 运行包已发布」三条件§3.1 C1 */
ErrorCode FEED_RANK_VISIBILITY_VIOLATION = new ErrorCode(1_103_004_001, "排序行不满足可见态条件,拒绝写入发布基线");
// ========== 限曝光运营 1-103-005-***U5 R-ADMIN==========
/** 限曝光目标非法:仅 project status=4 已发布的游戏可限曝光降权(与精选门禁同口径) */
ErrorCode FEED_EXPOSURE_GAME_NOT_PUBLISHED = new ErrorCode(1_103_005_001, "仅已发布的游戏可设置限曝光");
/** 限曝光目标无排序覆盖层记录:未入流/未发布基线的游戏无可降权的排序行(须先发布或入精选) */
ErrorCode FEED_EXPOSURE_RANK_NOT_EXISTS = new ErrorCode(1_103_005_002, "该游戏无排序记录,无法设置限曝光");
}

View File

@ -67,6 +67,12 @@
<artifactId>huijing-spring-boot-starter-mybatis</artifactId>
</dependency>
<!-- Excel 导出U5 精选池/限曝光排序覆盖层导出 ExcelUtils + @ExcelProperty -->
<dependency>
<groupId>com.wanxiang</groupId>
<artifactId>huijing-spring-boot-starter-excel</artifactId>
</dependency>
<!-- 缓存(游戏流排序快照 Redis TTL 兜底MVP 阶段先占位) -->
<dependency>
<groupId>com.wanxiang</groupId>

View File

@ -1,21 +1,34 @@
package com.wanxiang.huijing.game.module.feed.controller.admin.feed;
import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.ExposureLimitReqVO;
import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.FeaturedExcelVO;
import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.FeaturedPageReqVO;
import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.FeaturedReqVO;
import com.wanxiang.huijing.game.module.feed.controller.app.feed.vo.FeedCardRespVO;
import com.wanxiang.huijing.game.module.feed.convert.feed.FeedConvert;
import com.wanxiang.huijing.game.module.feed.dal.dataobject.rank.FeedRankDO;
import com.wanxiang.huijing.game.module.feed.service.feed.FeedService;
import com.wanxiang.huijing.framework.apilog.core.annotation.ApiAccessLog;
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
import com.wanxiang.huijing.framework.common.pojo.PageResult;
import com.wanxiang.huijing.framework.excel.core.util.ExcelUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import static com.wanxiang.huijing.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
import static com.wanxiang.huijing.framework.apilog.core.enums.OperateTypeEnum.UPDATE;
import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
/**
@ -54,4 +67,52 @@ public class AdminFeedController {
return success(true);
}
@PostMapping("/exposure-limit")
@Operation(summary = "限曝光降权U5", description = "boost 的反向运营操作:写 exposure_limit 降权,仅已发布且已有排序行的游戏可设置;>=9999 硬限曝光出流剔除")
@PreAuthorize("@ss.hasPermission('feed:featured:update')") // 复用精选运营权限同属排序覆盖层运营动作
@ApiAccessLog(operateType = UPDATE) // 运营操作审计限曝光=更新排序覆盖层
public CommonResult<Boolean> setExposureLimit(@Valid @RequestBody ExposureLimitReqVO reqVO) {
feedService.setExposureLimit(reqVO);
return success(true);
}
@GetMapping("/featured/export-excel")
@Operation(summary = "精选池/限曝光排序覆盖层导出 ExcelU5", 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 VOU5显式映射运营字段本体元信息源自 project 不在此导出
*
* @param rank 排序覆盖层 DO
* @return 导出 VO
*/
private FeaturedExcelVO toFeaturedExcelVO(FeedRankDO rank) {
FeaturedExcelVO vo = new FeaturedExcelVO();
vo.setId(rank.getId());
vo.setGameId(rank.getGameId());
vo.setVersionId(rank.getVersionId());
vo.setZoneId(rank.getZoneId());
vo.setQualityScore(rank.getQualityScore());
vo.setBoost(rank.getBoost());
// 限曝光降权 null 安全存量行/未设置=0与排序口径一致
vo.setExposureLimit(rank.getExposureLimit() == null ? BigDecimal.ZERO : rank.getExposureLimit());
vo.setSortScore(rank.getSortScore());
vo.setPinned(rank.getPinned());
vo.setStatus(rank.getStatus());
vo.setUpdateTime(rank.getUpdateTime());
return vo;
}
}

View File

@ -0,0 +1,34 @@
package com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Data;
/**
* 限曝光降权 Request VOadmin U5 R-ADMIN 运营后端
*
* 运营对单个游戏在游戏流/专区流中限曝光降权 game_feed_rank.exposure_limitboost 的反向操作
* 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 = "限曝光降权因子(>=00取消降权 / >0降权x分 / >=9999硬限曝光(出流剔除)",
requiredMode = Schema.RequiredMode.REQUIRED, example = "20.0")
@NotNull(message = "限曝光降权因子不能为空")
@PositiveOrZero(message = "限曝光降权因子不能为负")
private Float exposureLimit;
}

View File

@ -0,0 +1,54 @@
package com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo;
import cn.idev.excel.annotation.ExcelProperty;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 精选池/限曝光排序覆盖层 Excel VOadmin 端导出U5 R-ADMIN 运营后端
*
* 导出 game_feed_rank 排序覆盖层运营字段精选/加权/限曝光快照供运营对账与审计
* 字段口径与 {@link com.wanxiang.huijing.game.module.feed.dal.dataobject.rank.FeedRankDO} 一致
* 本体元信息标题等源自 projectfeed 不持久化故导出以排序覆盖层运营维度为主
*
* @author 造梦AI
*/
@Data
public class FeaturedExcelVO {
@ExcelProperty("排序记录ID")
private Long id;
@ExcelProperty("游戏ID")
private Long gameId;
@ExcelProperty("版本ID")
private Long versionId;
@ExcelProperty("专区ID(0=混合流)")
private Long zoneId;
@ExcelProperty("质量分")
private BigDecimal qualityScore;
@ExcelProperty("运营加权(boost)")
private BigDecimal boost;
@ExcelProperty("限曝光降权(exposureLimit)")
private BigDecimal exposureLimit;
@ExcelProperty("最终排序分")
private BigDecimal sortScore;
@ExcelProperty("是否精选置顶(0否1是)")
private Integer pinned;
@ExcelProperty("出流状态(0屏蔽1可出流)")
private Integer status;
@ExcelProperty("更新时间")
private LocalDateTime updateTime;
}

View File

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

View File

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

View File

@ -1,5 +1,6 @@
package com.wanxiang.huijing.game.module.feed.service.feed;
import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.ExposureLimitReqVO;
import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.FeaturedPageReqVO;
import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.FeaturedReqVO;
import com.wanxiang.huijing.game.module.feed.controller.app.feed.vo.FeedInteractReqVO;
@ -77,6 +78,14 @@ public interface FeedService {
*/
PageResult<FeedRankDO> getFeaturedPage(FeaturedPageReqVO reqVO);
/**
* 精选池/限曝光导出列表管理端 RBAC不分页仅精选置顶项 Excel 导出
*
* @param zoneId 可选专区筛选null=不限专区
* @return 精选记录列表 sort_score 降序无数据返回空列表
*/
List<FeedRankDO> getFeaturedList(Long zoneId);
/**
* 精选池置顶/取消 + 运营加权管理端 RBAC status=4 已发布可入精选
*
@ -84,6 +93,17 @@ public interface FeedService {
*/
void setFeatured(FeaturedReqVO reqVO);
/**
* 限曝光降权管理端 RBACU5boost 的反向操作 status=4 已发布且已有排序行的游戏可设置
*
* game_feed_rank.exposure_limit 并重算 sort_score = quality + boost - exposure_limit穿越算分回灌存活
* exposure_limit>=9999 为硬限曝光哨兵buildStream 出流剔除不改 project 本体仅写排序覆盖层
* 与精选不同限曝光只降权既有排序行不新建无排序行未发布基线/未入流则拒绝FEED_EXPOSURE_RANK_NOT_EXISTS
*
* @param reqVO 限曝光入参
*/
void setExposureLimit(ExposureLimitReqVO reqVO);
/**
* upsert 游戏流排序行黄金闭环 §3.2/§3.5两个上游写入点经本方法按 mode 分流守门
*

View File

@ -1,5 +1,6 @@
package com.wanxiang.huijing.game.module.feed.service.feed;
import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.ExposureLimitReqVO;
import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.FeaturedPageReqVO;
import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.FeaturedReqVO;
import com.wanxiang.huijing.game.module.feed.controller.app.feed.vo.FeedCardRespVO;
@ -59,6 +60,14 @@ public class FeedServiceImpl implements FeedService {
/** 默认混合流分区维度game_feed_rank.zone_id=0 */
private static final Long DEFAULT_ZONE_ID = 0L;
/**
* 硬限曝光哨兵阈值U5 限曝光exposure_limit >= 此值 = 该游戏被运营硬限曝光buildStream 出流时直接剔除
*
* 区别于 status=0下架/举报联动屏蔽限曝光是运营节流可随时改回状态不变
* 9999 sort_score DECIMAL(10,4) 量级匹配远超正常 quality+boost 范围语义即"完全压制"
*/
private static final BigDecimal EXPOSURE_LIMIT_CAP_HIDE = BigDecimal.valueOf(9999);
@Resource
private FeedRankMapper feedRankMapper;
@ -157,9 +166,11 @@ public class FeedServiceImpl implements FeedService {
dbExhausted = true; // 本批为空 确认库已取空无更多
break;
}
// 读侧可见态 enforce§3.1 C1 / Codex H9rank.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_HIDEU5 限曝光出流剔除判据
*
* @param exposureLimit 限曝光降权因子null 安全视为 0=未限曝光
* @return true=被硬限曝光出流剔除
*/
private static boolean isExposureCapped(BigDecimal exposureLimit) {
return exposureLimit != null && exposureLimit.compareTo(EXPOSURE_LIMIT_CAP_HIDE) >= 0;
}
/**
* 跨模块回填卡片本体元信息§3.1feed 不持久化本体 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-ADMINboost 的反向操作==============================
@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 + 既有 boostboost 权威归 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_limitboost/exposure_limit 权威归 feed 运营
// telemetry 不持有故不采信 req.sortScore避免回灌覆盖运营加权/限曝光 setFeatured/setExposureLimit 口径一致
// 这是限曝光"穿越 telemetry 算分回灌存活"的关键回灌只更质量分限曝光降权在重算中被保留扣减
BigDecimal quality = toDecimal(req.getQualityScore());
BigDecimal boost = existing.getBoost() == null ? BigDecimal.ZERO : existing.getBoost();
BigDecimal boost = nullToZero(existing.getBoost());
BigDecimal exposure = nullToZero(existing.getExposureLimit());
BigDecimal sortScore = deriveSortScore(quality, boost, exposure);
FeedRankDO update = new FeedRankDO();
update.setId(existing.getId());
update.setQualityScore(quality);
update.setSortScore(quality.add(boost));
update.setSortScore(sortScore);
feedRankMapper.updateById(update);
log.info("[upsertRank] 算分回灌刷新 gameId={}, zoneId={}, qualityScore={}, boost={}, sortScore={}",
req.getGameId(), zoneId, quality, boost, quality.add(boost));
log.info("[upsertRank] 算分回灌刷新 gameId={}, zoneId={}, qualityScore={}, boost={}, exposureLimit={}, sortScore={}",
req.getGameId(), zoneId, quality, boost, exposure, sortScore);
}
// ============================== 排序行全分区下线下架编排反向操作数据回路小波·修3==============================
@ -553,6 +621,31 @@ public class FeedServiceImpl implements FeedService {
return value == null ? BigDecimal.ZERO : BigDecimal.valueOf(value);
}
/**
* BigDecimal null 安全归零feed_rank 多个 DECIMAL 列的统一缺省口径
*
* @param value 可空 BigDecimal
* @return 非空原值null BigDecimal.ZERO
*/
private static BigDecimal nullToZero(BigDecimal value) {
return value == null ? BigDecimal.ZERO : value;
}
/**
* 统一推导最终排序分U5 起口径升级sort_score = quality_score + boost - exposure_limit
*
* 全部排序行写入点setFeatured/setExposureLimit/发布基线重发布/算分回灌共用本口径
* 保证 boost运营加权 exposure_limit运营限曝光降权两个运营维度在任意写入路径下都被一致保留
*
* @param quality 质量分telemetry 回灌null 安全交由调用方先归零
* @param boost 运营加权因子
* @param exposureLimit 运营限曝光降权因子
* @return 最终排序分
*/
private static BigDecimal deriveSortScore(BigDecimal quality, BigDecimal boost, BigDecimal exposureLimit) {
return nullToZero(quality).add(nullToZero(boost)).subtract(nullToZero(exposureLimit));
}
// ============================== cursor 编解码复合游标 (sortScore,id)契约 T-FED-06==============================
/** cursor 内部分隔符sortScore 与 id 之间BigDecimal/Long 文本均不含 '|',无歧义) */

View File

@ -1,5 +1,6 @@
package com.wanxiang.huijing.game.module.feed.service.feed;
import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.ExposureLimitReqVO;
import com.wanxiang.huijing.game.module.feed.controller.admin.feed.vo.FeaturedReqVO;
import com.wanxiang.huijing.game.module.feed.controller.app.feed.vo.FeedInteractReqVO;
import com.wanxiang.huijing.game.module.feed.controller.app.feed.vo.FeedStreamReqVO;
@ -497,6 +498,117 @@ class FeedServiceImplTest extends BaseMockitoUnitTest {
verify(feedRankMapper, never()).insert(any(FeedRankDO.class));
}
// ============================== 限曝光降权U5 R-ADMIN==============================
@Test
void testSetExposureLimit_notPublishedRejected() {
// 门禁未发布游戏不可限曝光与精选门禁同口径不查/不写排序行
doReturn(false).when(feedService).isGamePublished(1L);
ServiceException ex = assertThrows(ServiceException.class,
() -> feedService.setExposureLimit(exposureReq(1L, null, 20F)));
assertEquals(FEED_EXPOSURE_GAME_NOT_PUBLISHED.getCode(), ex.getCode());
verify(feedRankMapper, never()).updateById(any(FeedRankDO.class));
}
@Test
void testSetExposureLimit_noRankRejected() {
// 门禁已发布但无排序覆盖层记录未入流/未发布基线 拒绝不新建孤儿降权行
doReturn(true).when(feedService).isGamePublished(1L);
when(feedRankMapper.selectByGameAndZone(1L, 0L)).thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
() -> feedService.setExposureLimit(exposureReq(1L, null, 20F)));
assertEquals(FEED_EXPOSURE_RANK_NOT_EXISTS.getCode(), ex.getCode());
verify(feedRankMapper, never()).updateById(any(FeedRankDO.class));
}
@Test
void testSetExposureLimit_downweightSubtractedIntoSortScore() {
// 降权写入sort_score = 既有质量分 + 既有 boost - 新限曝光降权保留质量分与运营加权仅改限曝光维度
doReturn(true).when(feedService).isGamePublished(1L);
FeedRankDO existing = new FeedRankDO();
existing.setId(7L);
existing.setQualityScore(new BigDecimal("80")); // 既有质量分
existing.setBoost(new BigDecimal("10")); // 既有运营加权
when(feedRankMapper.selectByGameAndZone(1L, 2L)).thenReturn(existing);
feedService.setExposureLimit(exposureReq(1L, 2L, 20F)); // 降权 20
ArgumentCaptor<FeedRankDO> captor = ArgumentCaptor.forClass(FeedRankDO.class);
verify(feedRankMapper).updateById(captor.capture());
FeedRankDO update = captor.getValue();
assertEquals(7L, update.getId());
assertEquals(0, new BigDecimal("20").compareTo(update.getExposureLimit())); // 限曝光因子写入
// sort_score = 80 + 10 - 20 = 70降权扣减生效
assertEquals(0, new BigDecimal("70").compareTo(update.getSortScore()));
verify(feedRankMapper, never()).insert(any(FeedRankDO.class)); // 限曝光只更不新建
}
@Test
void testGetFeedStream_exposureCappedRowFilteredFromOutput() {
// 硬限曝光cap出流剔除exposure_limit>=9999 的游戏在 buildStream 出流时被剔除不在流中露出
// size=2 target=3返回 3 条均已发布 g2 被硬限曝光 可见 [g1,g3]=2 size 且本批<target=3? 本批=3=target 不算到尾
// 为简化到底判定返回 2 <target 3 库到尾g2 硬限曝光被剔 g1 出流
FeedRankDO g1 = rankWithId(1L, 1L, 10L, 0L, new BigDecimal("90"));
FeedRankDO g2 = rankWithId(2L, 2L, 11L, 0L, new BigDecimal("80"));
g2.setExposureLimit(new BigDecimal("9999")); // 硬限曝光哨兵 出流剔除
when(feedRankMapper.selectStreamByZone(eq(0L), isNull(), isNull(), eq(3)))
.thenReturn(Arrays.asList(g1, g2));
when(projectApi.getStatus(1L)).thenReturn(CommonResult.success(4));
when(projectApi.getStatus(2L)).thenReturn(CommonResult.success(4)); // 已发布但被硬限曝光
when(projectApi.getFeedMeta(1L)).thenReturn(CommonResult.success(null));
FeedStreamReqVO reqVO = new FeedStreamReqVO();
reqVO.setSize(2);
FeedStreamRespVO resp = feedService.getFeedStream(reqVO, 99L);
assertEquals(1, resp.getList().size()); // g2 被硬限曝光剔除 g1 出流
assertEquals(1L, resp.getList().get(0).getGameId());
assertFalse(resp.getHasMore()); // 过滤后不足 size + 库到尾 到底
// g2 已发布但因硬限曝光被剔验证 cap 在出流处生效status 未动可随时改回
}
@Test
void testGetFeedStream_normalDownweightNotFilteredOut() {
// 普通降权0<exposure_limit<9999不剔除出流只通过 sort_score 下沉持久化已扣减出流仍露出
FeedRankDO g1 = rankWithId(1L, 1L, 10L, 0L, new BigDecimal("90"));
FeedRankDO g2 = rankWithId(2L, 2L, 11L, 0L, new BigDecimal("50"));
g2.setExposureLimit(new BigDecimal("30")); // 普通降权已反映在 sort_score=50不剔除
when(feedRankMapper.selectStreamByZone(eq(0L), isNull(), isNull(), eq(3)))
.thenReturn(Arrays.asList(g1, g2));
stubPublished(1L, 2L);
when(projectApi.getFeedMeta(any())).thenReturn(CommonResult.success(null));
FeedStreamReqVO reqVO = new FeedStreamReqVO();
reqVO.setSize(2);
FeedStreamRespVO resp = feedService.getFeedStream(reqVO, 99L);
assertEquals(2, resp.getList().size()); // 普通降权仍出流只是排名靠后
assertEquals(2L, resp.getList().get(1).getGameId()); // g2 降权后排在 g1 之后
}
@Test
void testGetFeaturedList_delegatesToMapper() {
// 导出列表委托 mapper 取精选置顶项全量不分页可选专区透传
when(feedRankMapper.selectFeaturedList(2L))
.thenReturn(Arrays.asList(rankWithId(1L, 1L, 10L, 2L, new BigDecimal("90"))));
List<FeedRankDO> list = feedService.getFeaturedList(2L);
assertEquals(1, list.size());
assertEquals(1L, list.get(0).getGameId());
verify(feedRankMapper).selectFeaturedList(2L);
}
@Test
void testGetFeaturedList_emptyReturnsEmpty() {
// 空数据返回空列表导出端据此写表头空表
when(feedRankMapper.selectFeaturedList(isNull())).thenReturn(Collections.emptyList());
List<FeedRankDO> list = feedService.getFeaturedList(null);
assertTrue(list.isEmpty());
}
// ============================== 排序行 upsert发布基线 / 算分回灌§3.2/§3.5==============================
@Test
@ -555,6 +667,31 @@ class FeedServiceImplTest extends BaseMockitoUnitTest {
assertNull(update.getStatus());
}
@Test
void testUpsertRank_qualityRefresh_preservesExposureLimitInSortScore() {
// U5 限曝光穿越算分回灌存活回灌只刷质量分sort_score 重算须扣减既有 exposure_limit不被回灌覆盖
FeedRankDO existing = new FeedRankDO();
existing.setId(7L);
existing.setBoost(new BigDecimal("10"));
existing.setExposureLimit(new BigDecimal("30")); // 既有限曝光降权运营字段回灌须保留
existing.setPinned(1);
existing.setStatus(1);
when(feedRankMapper.selectByGameAndZone(1L, 0L)).thenReturn(existing);
// 回灌新质量分=88.5req.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 DODB 原值保留
assertNull(update.getExposureLimit());
assertNull(update.getBoost());
}
@Test
void testUpsertRank_qualityRefresh_noopWhenNoBaseline() {
// 无发布基线行算分回灌空转不新建孤儿排序行
@ -698,6 +835,15 @@ class FeedServiceImplTest extends BaseMockitoUnitTest {
return reqVO;
}
/** 构造限曝光降权入参U5gameId/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=0status=1pinned=0 */
private static FeedRankUpsertReqDTO baselineReq(Long gameId, Long versionId, Long zoneId) {
FeedRankUpsertReqDTO req = new FeedRankUpsertReqDTO();

View File

@ -0,0 +1,23 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 模块feed游戏流/精选/限曝光运营)| ownerU5R-ADMIN
-- 文件V25.0.0__feed_rank_add_exposure_limit.sqlFlyway只新增接 V22.0.0 之后;已合入禁止修改,回滚写补偿迁移 V25.0.1
-- 背书plan002 U5R-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 '运营限曝光降权因子(>=00无降权 / >0降权x分(sort_score-=x) / >=9999硬限曝光出流剔除哨兵最终 sort_score=quality+boost-exposure_limit' AFTER `boost`;