fix(publish): 修创作链路发布 UI 两缺陷——生成回写 currentVersionId + getZones 落实(HJ-FE-WALK-001)

UI 走查逮到、阻断真人 UI 发布的两个后端缺陷(编排器走 task.versionId 旁路掩盖):

Bug A: 生成成功未回写 project.current_version_id
- GameVersionServiceImpl.createForPackage 建版本后调 bindCurrentVersion 回写(幂等+新建两路径)
- 原先该字段只在发布方法置位→生成后未发布阶段恒 null→Publish.vue 取不到 versionId/门禁⑤失败
- 安全:feed/play 用 game_feed_rank 的 versionId 不读 currentVersionId,对已发布游戏回写无副作用

Bug B: feed/zones 返回空(getZones 是 Collections.emptyList 桩)
- FeedServiceImpl.getZones 落实直查 game_zone(type∈(1,2) AND status=1,sort 升序→ZoneRespVO)
- 新增最小只读 GameZoneDO + GameZoneMapper(MVP 既定"同库可直查 game_zone")
- 租户:GameZoneDO @TenantIgnore + staging tenant.enable=false→tenant_id=0 种子专区可取(实证)
- 原桩致发布页专区选择器恒"加载中…"、launchZoneId 必填缺失而无法发布

子代理(Opus)执行+主 agent 核验(diff/租户口径/staging tenant.enable=false 实证);待 mini 构建+重部署+重走查闭合链路②。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-11 05:55:15 +00:00
parent 17b2e6a2ca
commit 29564c8eb7
4 changed files with 159 additions and 4 deletions

View File

@ -0,0 +1,54 @@
package cn.wanxiang.game.module.feed.dal.dataobject.zone;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 专区 DO(对应表 game_zone,修 HJ-FE-WALK-001 Bug B 新增)
*
* 背景:feed 不拥有 Zone 实体,Zone 权威归 project(owner=project,V1 DDL/种子)。
* 但发布页专区选择器需读「启用专区」列表(getZones),MVP 既定走「同库可直查 game_zone」,
* 故在 feed 侧建此最小只读 DO 直查,待 project -api RPC 就绪后可改 Feign(不影响表结构)。
*
* 租户处置(最易翻车点):game_zone 是 project 的全局基础数据、非租户隔离,V1 种子行 tenant_id=0。
* 故本 DO 刻意 **不继承 TenantBaseDO、改继承 {@link BaseDO}** 且加 {@link TenantIgnore} 注解——
* 使 yudao 租户拦截器 TenantDatabaseInterceptor.computeIgnoreTable 判定为「忽略租户」,
* 查询不追加 tenant_id 条件,从而在 tenant=1 的业务请求下也能取到 tenant_id=0 的种子专区(参照 system DictTypeDO 范式)。
*
* @author 绘境AI
*/
@TableName("game_zone")
@KeySequence("game_zone_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略
@Data
@EqualsAndHashCode(callSuper = true)
@TenantIgnore // 全局基础数据,忽略租户过滤:保证读到 tenant_id=0 的种子专区(详见类注释「租户处置」)
public class GameZoneDO extends BaseDO {
/**
* 专区 ID
*/
@TableId
private Long id;
/**
* 专区名称
*/
private String name;
/**
* 专区类型(双轨):1官方精选 2UGC普通
*/
private Integer type;
/**
* 排序(升序)
*/
private Integer sort;
/**
* 状态:0停用 1启用
*/
private Integer status;
}

View File

@ -0,0 +1,37 @@
package cn.wanxiang.game.module.feed.dal.mysql.zone;
import cn.wanxiang.game.module.feed.dal.dataobject.zone.GameZoneDO;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import org.apache.ibatis.annotations.Mapper;
import java.util.Arrays;
import java.util.List;
/**
* 专区 Mapper(修 HJ-FE-WALK-001 Bug B 新增,feed 侧只读直查 game_zone)
*
* 仅承载发布页专区选择器所需的「启用专区列表」查询;表权威归 project,本模块只读不写。
*
* @author 绘境AI
*/
@Mapper
public interface GameZoneMapper extends BaseMapperX<GameZoneDO> {
/**
* 取启用的双轨专区,按 sort 升序(发布页专区选择器,命中 idx_type_status)
*
* 过滤条件:type ∈ (1官方精选, 2UGC普通) AND status=1 启用。
* deleted 由 MyBatis-Plus @TableLogic 自动追加;tenant 不过滤——GameZoneDO 已 @TenantIgnore,
* 拦截器不追加 tenant_id 条件,故 tenant_id=0 的种子专区在任意租户上下文下均可取到(详见 GameZoneDO 类注释)。
*
* @return 启用专区列表(已按 sort 升序)
*/
default List<GameZoneDO> selectEnabledZones() {
return selectList(new LambdaQueryWrapperX<GameZoneDO>()
.in(GameZoneDO::getType, Arrays.asList(1, 2)) // 双轨类型:1官方精选 / 2UGC普通
.eq(GameZoneDO::getStatus, 1) // 仅启用专区
.orderByAsc(GameZoneDO::getSort)); // 排序升序出列
}
}

View File

@ -13,8 +13,10 @@ import cn.wanxiang.game.module.feed.controller.app.feed.vo.ZoneStreamReqVO;
import cn.wanxiang.game.module.feed.convert.feed.FeedConvert;
import cn.wanxiang.game.module.feed.dal.dataobject.interact.FeedInteractLogDO;
import cn.wanxiang.game.module.feed.dal.dataobject.rank.FeedRankDO;
import cn.wanxiang.game.module.feed.dal.dataobject.zone.GameZoneDO;
import cn.wanxiang.game.module.feed.dal.mysql.interact.FeedInteractLogMapper;
import cn.wanxiang.game.module.feed.dal.mysql.rank.FeedRankMapper;
import cn.wanxiang.game.module.feed.dal.mysql.zone.GameZoneMapper;
import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO;
import cn.wanxiang.game.module.feed.enums.FeedActionEnum;
import cn.wanxiang.game.module.project.api.ProjectApi;
@ -30,7 +32,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -61,6 +63,15 @@ public class FeedServiceImpl implements FeedService {
@Resource
private FeedInteractLogMapper feedInteractLogMapper;
/**
* 专区 Mapper(修 HJ-FE-WALK-001 Bug B:发布页专区选择器读启用专区)
*
* MVP 既定「同库可直查 game_zone」:feed 侧只读直查,待 project -api RPC 就绪后可改 Feign。
* GameZoneDO 已 @TenantIgnore,故种子专区(tenant_id=0)在任意租户上下文下均可取到。
*/
@Resource
private GameZoneMapper gameZoneMapper;
/**
* project 模块 RPC 契约(仅依赖 -api)
*
@ -181,9 +192,21 @@ public class FeedServiceImpl implements FeedService {
@Override
public List<ZoneRespVO> getZones() {
// TODO 跨模块对接 project:读 game_zone(type=1官方精选 2UGC普通、status=1启用),按 sort 升序返回。
// MVP 同库可直查 game_zone,或待 project -api RPC 就绪后改 Feign。骨架阶段返回空列表占位。
return Collections.emptyList();
// 修 HJ-FE-WALK-001 Bug B:原桩恒返回空列表,致发布页专区选择器恒「专区加载中…」、launchZoneId 必填缺失而无法发布。
// 实现:MVP 既定「同库可直查 game_zone」,直查启用的双轨专区(type∈(1,2) AND status=1),按 sort 升序。
// 租户:GameZoneDO 已 @TenantIgnore,查询不追加 tenant_id 条件,故 tenant_id=0 的种子专区在 tenant=1 请求下也能取到。
List<GameZoneDO> zones = gameZoneMapper.selectEnabledZones();
List<ZoneRespVO> result = new ArrayList<>(zones.size());
for (GameZoneDO zone : zones) {
// 仅映射前端选择器所需字段(id/name/type/sort),与契约 ZoneRespVO 对齐
ZoneRespVO vo = new ZoneRespVO();
vo.setId(zone.getId());
vo.setName(zone.getName());
vo.setType(zone.getType());
vo.setSort(zone.getSort());
result.add(vo);
}
return result;
}
// ============================== 互动信号 ==============================

View File

@ -1,6 +1,8 @@
package cn.wanxiang.game.module.project.service.version;
import cn.wanxiang.game.module.project.dal.dataobject.project.ProjectDO;
import cn.wanxiang.game.module.project.dal.dataobject.version.GameVersionDO;
import cn.wanxiang.game.module.project.dal.mysql.project.ProjectMapper;
import cn.wanxiang.game.module.project.dal.mysql.version.GameVersionMapper;
import cn.wanxiang.game.module.project.dto.ProjectVersionCreateForPackageReqDTO;
import jakarta.annotation.Resource;
@ -29,6 +31,14 @@ public class GameVersionServiceImpl implements GameVersionService {
@Resource
private GameVersionMapper gameVersionMapper;
/**
* 游戏项目 Mapper(修 HJ-FE-WALK-001 Bug A:落包建版本后回写 project.current_version_id)
*
* 仅用于把刚建好的版本 ID 绑定为项目「当前生效版本」,使前端发布页据此识别「存在可发布版本」。
*/
@Resource
private ProjectMapper projectMapper;
@Override
public Long createForPackage(ProjectVersionCreateForPackageReqDTO req) {
// 幂等:同 genTaskId 已建版本则直接复用,不重复建(PackageFactory 重复落包安全)
@ -37,6 +47,8 @@ public class GameVersionServiceImpl implements GameVersionService {
if (existing != null) {
log.info("[createForPackage] 落包建版本幂等命中,复用已建版本 genTaskId={}, versionId={}",
req.getGenTaskId(), existing.getId());
// Bug A 修复:幂等命中路径也回写 current_version_id(防御重复落包但项目尚未绑定版本的场景)
bindCurrentVersion(req.getGameId(), existing.getId());
return existing.getId();
}
}
@ -52,9 +64,38 @@ public class GameVersionServiceImpl implements GameVersionService {
gameVersionMapper.insert(version);
log.info("[createForPackage] 落包建版本成功 gameId={}, genTaskId={}, versionId={}",
req.getGameId(), req.getGenTaskId(), version.getId());
// Bug A 修复:新建版本后立即回写 current_version_id,让前端发布页识别「存在可发布版本」并取到 versionId
bindCurrentVersion(req.getGameId(), version.getId());
return version.getId();
}
/**
* 回写项目「当前生效版本」ID(修 HJ-FE-WALK-001 Bug A)
*
* 背景:原先 current_version_id 仅在发布方法里才被置位,导致「生成成功→未发布」阶段恒为 null,
* 前端发布页 Publish.vue 读 project.currentVersionId 取 versionId、门禁⑤「存在可发布版本」也读它,
* 故真人 UI 永远拿不到 versionId、无法发起发布(编排器批跑因走 task.versionId 旁路而掩盖了该缺陷)。
* 此处在落包建版本成功后回写,打通该死锁。
*
* 安全性:feed 出流 / runtime 即玩用的是 game_feed_rank 持久化的 versionId,不读 project.currentVersionId,
* 故对「已发布游戏再次落包」回写 current_version_id 不影响线上出流与试玩,无副作用。
*
* @param gameId 游戏项目 ID(= GamePackage.gameId)
* @param versionId 待绑定为当前生效版本的版本 ID
*/
private void bindCurrentVersion(Long gameId, Long versionId) {
// 入参任一为空则不回写(防御:缺 gameId 无法定位项目、缺 versionId 无意义),直接返回
if (gameId == null || versionId == null) {
return;
}
// 仅更新 current_version_id 单列:其余字段不置入 update DO,MyBatis-Plus 默认策略跳过 null,不覆盖原值
ProjectDO update = new ProjectDO();
update.setId(gameId);
update.setCurrentVersionId(versionId);
projectMapper.updateById(update);
log.info("[createForPackage] 已回写项目当前生效版本 gameId={}, currentVersionId={}", gameId, versionId);
}
@Override
public GameVersionDO getVersion(Long versionId) {
return gameVersionMapper.selectById(versionId);