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