feat(B1): 黄金闭环后端实现(step②) — 发布编排/同步写数据回路/PackageStore/version 链
step② 五模块 impl(编译验证待 mini-desktop,与 step① 合并编译): - project: PublishOrchestrationService(@Transactional 六步原子,注入 Feed/RuntimePackageApi 本地 bean=H5)+GameVersion DO/Mapper/Service(genTaskId 幂等)+ProjectVersionApiImpl+reviewProject α(REVIEWING→PUBLISHED 跳过 APPROVED)+ProjectApi.getStatus - feed: FeedApiImpl(@Primary)+upsertRank 两 mode 分流(PUBLISH_BASELINE 写前校验可见态前三条件 / QUALITY_REFRESH 保留 boost/pinned/status)+buildStream 二次过滤+去 isGamePublished 默认放行 - runtime: RuntimePackageApiImpl(@Primary 委托 publish/getStatus)+PackageStore(DB impl 读写 package_json)+manifest 端点(原样 JSON 不包 CommonResult) - telemetry: EventIngestServiceImpl 同步写本地事务(eventId 幂等→仅真插入累加 stat→算 quality_score→FeedApi 回灌→置已聚合,失败整批回滚);MQ 仅留 TODO - aigc: completeWithVersion(taskId 幂等) 主 agent 独立核验: 发布编排事务原子/reviewProject α/同步写逻辑 已读核;守门④无环复检通过;新增 ProjectApi.getStatus+RuntimePackageApi.getStatus(enforce 只读) 已知限制(T+1 非阻断): stat 累加为 MP select+update 非原子,并发可能丢增量(顺序 A/B 不受影响,真流量前改原生原子 upsert) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e670eaf476
commit
1740d8c226
@ -78,4 +78,17 @@ public interface AigcTaskService {
|
|||||||
*/
|
*/
|
||||||
PageResult<AigcTaskDO> getAdminTaskPage(AigcTaskAdminPageReqVO reqVO);
|
PageResult<AigcTaskDO> getAdminTaskPage(AigcTaskAdminPageReqVO reqVO);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成任务并回填版本 ID(黄金闭环 §3.3 C3 / Codex H7,供 PackageFactory 落包后调用)
|
||||||
|
*
|
||||||
|
* 置 game_aigc_task.version_id + status=succeeded(成功终态),按 taskId 幂等
|
||||||
|
* (重复回填同任务安全:已是 succeeded 且 version_id 一致则直接返回,不二次写入)。
|
||||||
|
* studio 轮询完成态据此拿到 versionId({@code StudioServiceImpl} 取 task.getVersionId())——
|
||||||
|
* 仅 PackageFactory 插 version 不够,必须回填 aigc 任务才闭环。
|
||||||
|
*
|
||||||
|
* @param taskId 生成任务 ID
|
||||||
|
* @param versionId 产物落地版本 ID(project.game_version.id,由 ProjectVersionApi.createForPackage 产出)
|
||||||
|
*/
|
||||||
|
void completeWithVersion(Long taskId, Long versionId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -140,6 +140,31 @@ public class AigcTaskServiceImpl implements AigcTaskService {
|
|||||||
return aigcTaskMapper.selectAdminPage(reqVO);
|
return aigcTaskMapper.selectAdminPage(reqVO);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void completeWithVersion(Long taskId, Long versionId) {
|
||||||
|
// §3.3 C3:PackageFactory 落包后回填——置 version_id + status=succeeded,按 taskId 幂等供 studio 轮询取 versionId
|
||||||
|
AigcTaskDO task = aigcTaskMapper.selectById(taskId);
|
||||||
|
if (task == null) {
|
||||||
|
// 任务不存在:回填目标缺失(PackageFactory 应基于真实任务落包),抛任务不存在便于排障
|
||||||
|
throw exception(AIGC_TASK_NOT_EXISTS);
|
||||||
|
}
|
||||||
|
// 幂等:已是成功终态且 version_id 一致 → 重复回填安全,直接返回不二次写入
|
||||||
|
if (Objects.equals(task.getStatus(), AigcTaskStatusEnum.SUCCEEDED.getStatus())
|
||||||
|
&& Objects.equals(task.getVersionId(), versionId)) {
|
||||||
|
log.info("[completeWithVersion] 回填幂等命中,跳过 taskId={}, versionId={}", taskId, versionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 置成功终态 + 回填版本 ID + 终态完成时间
|
||||||
|
AigcTaskDO update = new AigcTaskDO();
|
||||||
|
update.setId(taskId);
|
||||||
|
update.setVersionId(versionId);
|
||||||
|
update.setStatus(AigcTaskStatusEnum.SUCCEEDED.getStatus());
|
||||||
|
update.setProgress(100);
|
||||||
|
update.setFinishTime(LocalDateTime.now());
|
||||||
|
aigcTaskMapper.updateById(update);
|
||||||
|
log.info("[completeWithVersion] 任务完成并回填版本 taskId={}, versionId={}", taskId, versionId);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================== 私有校验/工具 ==============================
|
// ============================== 私有校验/工具 ==============================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import cn.iocoder.yudao.framework.common.exception.ErrorCode;
|
|||||||
*
|
*
|
||||||
* feed 模块,独占 1-103-***-*** 段(见 .agents/rules/engineering-conventions.md §1.3)。
|
* feed 模块,独占 1-103-***-*** 段(见 .agents/rules/engineering-conventions.md §1.3)。
|
||||||
* 约定:禁止与其它模块错误码段重叠;新增错误码在此登记。
|
* 约定:禁止与其它模块错误码段重叠;新增错误码在此登记。
|
||||||
* 段内业务细分:000 游戏流 / 001 互动 / 002 分享 / 003 精选池。
|
* 段内业务细分:000 游戏流 / 001 互动 / 002 分享 / 003 精选池 / 004 排序行 upsert(黄金闭环 §3.2/§3.5)。
|
||||||
*
|
*
|
||||||
* @author 绘境AI
|
* @author 绘境AI
|
||||||
*/
|
*/
|
||||||
@ -31,4 +31,10 @@ public interface ErrorCodeConstants {
|
|||||||
/** 精选目标非法:仅 project status=4 已发布的游戏可入精选(对齐契约 1-103-003-001) */
|
/** 精选目标非法:仅 project status=4 已发布的游戏可入精选(对齐契约 1-103-003-001) */
|
||||||
ErrorCode FEED_FEATURED_GAME_NOT_PUBLISHED = new ErrorCode(1_103_003_001, "仅已发布的游戏可加入精选池");
|
ErrorCode FEED_FEATURED_GAME_NOT_PUBLISHED = new ErrorCode(1_103_003_001, "仅已发布的游戏可加入精选池");
|
||||||
|
|
||||||
|
// ========== 排序行 upsert 1-103-004-***(黄金闭环 §3.2/§3.5)==========
|
||||||
|
/** upsert 模式缺失:FeedRankUpsertReqDTO.mode 必填(分流发布基线/算分回灌,守门③) */
|
||||||
|
ErrorCode FEED_RANK_UPSERT_MODE_REQUIRED = new ErrorCode(1_103_004_000, "排序行 upsert 模式不能为空");
|
||||||
|
/** 可见态写侧校验未过:mode=PUBLISH_BASELINE 写 status=1 前须满足「project 已发布 + 当前生效版本一致 + 运行包已发布」三条件(§3.1 C1) */
|
||||||
|
ErrorCode FEED_RANK_VISIBILITY_VIOLATION = new ErrorCode(1_103_004_001, "排序行不满足可见态条件,拒绝写入发布基线");
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,13 +31,20 @@
|
|||||||
<version>${revision}</version>
|
<version>${revision}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- 跨模块契约:project -api(GAP-3 分享回填 currentVersionId,仅依赖 -api 不依赖 -server) -->
|
<!-- 跨模块契约:project -api(GAP-3 分享回填 currentVersionId;§3.1/§3.2 可见态 enforce 读 project.status,仅依赖 -api 不依赖 -server) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cn.iocoder.cloud</groupId>
|
<groupId>cn.iocoder.cloud</groupId>
|
||||||
<artifactId>game-module-project-api</artifactId>
|
<artifactId>game-module-project-api</artifactId>
|
||||||
<version>${revision}</version>
|
<version>${revision}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- 跨模块契约:runtime -api(§3.1 C1 可见态第三条件:upsertRank(PUBLISH_BASELINE) 写侧 enforce 读 runtime_package.status,仅依赖 -api 不依赖 -server,守门④无环) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.iocoder.cloud</groupId>
|
||||||
|
<artifactId>game-module-runtime-api</artifactId>
|
||||||
|
<version>${revision}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- 业务组件:数据权限 + 多租户(DO 继承 TenantBaseDO) -->
|
<!-- 业务组件:数据权限 + 多租户(DO 继承 TenantBaseDO) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cn.iocoder.cloud</groupId>
|
<groupId>cn.iocoder.cloud</groupId>
|
||||||
|
|||||||
@ -0,0 +1,37 @@
|
|||||||
|
package cn.wanxiang.game.module.feed.api;
|
||||||
|
|
||||||
|
import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO;
|
||||||
|
import cn.wanxiang.game.module.feed.service.feed.FeedService;
|
||||||
|
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游戏流排序 API 实现(黄金闭环 §3.2/§3.5,提供 RESTful 接口给跨模块 Feign 调用:发布编排 + telemetry 算分回灌写排序行)
|
||||||
|
*
|
||||||
|
* @RestController + @Primary:与 yudao DictDataApiImpl / 既有 ProjectApiImpl 同构——单体内同进程调用走本实现,拆微服务后走 Feign(守门④/§3.2 H5)。
|
||||||
|
* 仅委托 {@link FeedService#upsertRank}(按 mode 分流 + 写侧可见态 enforce),不写业务逻辑(业务在 Service)。
|
||||||
|
* 事务原子(§3.2 Codex H5):上游(发布编排/算分回灌)注入本 @Primary bean,在其本地 @Transactional 内调用,本方法抛错则上游整体回滚。
|
||||||
|
*
|
||||||
|
* @author 造梦AI
|
||||||
|
*/
|
||||||
|
@RestController // 提供 RESTful API 接口,给 Feign 调用
|
||||||
|
@Validated
|
||||||
|
@Primary // 与 @FeignClient 接口同名 Bean 冲突时优先用本地实现(同进程调用就地解析,上游事务内本地调用)
|
||||||
|
public class FeedApiImpl implements FeedApi {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private FeedService feedService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CommonResult<Boolean> upsertRank(FeedRankUpsertReqDTO req) {
|
||||||
|
// 按 mode 分流:PUBLISH_BASELINE(发布基线,写前 enforce 可见态三条件)/ QUALITY_REFRESH(仅刷分,保留 boost/pinned/status)
|
||||||
|
feedService.upsertRank(req);
|
||||||
|
return success(Boolean.TRUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import cn.wanxiang.game.module.feed.controller.app.feed.vo.ShareMetaRespVO;
|
|||||||
import cn.wanxiang.game.module.feed.controller.app.feed.vo.ZoneRespVO;
|
import cn.wanxiang.game.module.feed.controller.app.feed.vo.ZoneRespVO;
|
||||||
import cn.wanxiang.game.module.feed.controller.app.feed.vo.ZoneStreamReqVO;
|
import cn.wanxiang.game.module.feed.controller.app.feed.vo.ZoneStreamReqVO;
|
||||||
import cn.wanxiang.game.module.feed.dal.dataobject.rank.FeedRankDO;
|
import cn.wanxiang.game.module.feed.dal.dataobject.rank.FeedRankDO;
|
||||||
|
import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO;
|
||||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -83,4 +84,18 @@ public interface FeedService {
|
|||||||
*/
|
*/
|
||||||
void setFeatured(FeaturedReqVO reqVO);
|
void setFeatured(FeaturedReqVO reqVO);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* upsert 游戏流排序行(黄金闭环 §3.2/§3.5,两个上游写入点经本方法按 mode 分流,守门③)
|
||||||
|
*
|
||||||
|
* - PUBLISH_BASELINE(发布编排,§3.2 C2):写 status=1 前校验可见态前三条件
|
||||||
|
* (project.status==PUBLISHED(4) AND project.current_version_id==versionId AND runtime_package(versionId).status==1),
|
||||||
|
* 不满足拒写(抛错使发布事务回滚);满足则写全字段(含 status=1 入流)。
|
||||||
|
* - QUALITY_REFRESH(telemetry 算分回灌,§3.5 C5):仅更 quality_score/sort_score,保留 boost/pinned/status(不覆盖运营加权与在流态)。
|
||||||
|
*
|
||||||
|
* 同步事务语义:由调用方在其本地 @Transactional 内调用,本方法抛错则上游整体回滚。
|
||||||
|
*
|
||||||
|
* @param req 排序行 upsert 入参(含 mode 分流)
|
||||||
|
*/
|
||||||
|
void upsertRank(FeedRankUpsertReqDTO req);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,8 +14,12 @@ 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.rank.FeedRankDO;
|
||||||
import cn.wanxiang.game.module.feed.dal.mysql.interact.FeedInteractLogMapper;
|
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.rank.FeedRankMapper;
|
||||||
|
import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO;
|
||||||
import cn.wanxiang.game.module.feed.enums.FeedActionEnum;
|
import cn.wanxiang.game.module.feed.enums.FeedActionEnum;
|
||||||
import cn.wanxiang.game.module.project.api.ProjectApi;
|
import cn.wanxiang.game.module.project.api.ProjectApi;
|
||||||
|
import cn.wanxiang.game.module.project.enums.ProjectStatusEnum;
|
||||||
|
import cn.wanxiang.game.module.runtime.api.RuntimePackageApi;
|
||||||
|
import cn.wanxiang.game.module.runtime.enums.PackageStatusEnum;
|
||||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@ -25,8 +29,11 @@ import org.springframework.util.StringUtils;
|
|||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import static cn.wanxiang.game.module.feed.enums.ErrorCodeConstants.*;
|
import static cn.wanxiang.game.module.feed.enums.ErrorCodeConstants.*;
|
||||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||||
@ -56,11 +63,20 @@ public class FeedServiceImpl implements FeedService {
|
|||||||
* project 模块 RPC 契约(仅依赖 -api)
|
* project 模块 RPC 契约(仅依赖 -api)
|
||||||
*
|
*
|
||||||
* GAP-3:分享元数据回填当前生效版本 ID(getCurrentVersionId),供分享落地页直达即玩加载。
|
* GAP-3:分享元数据回填当前生效版本 ID(getCurrentVersionId),供分享落地页直达即玩加载。
|
||||||
|
* §3.1/§3.2 可见态 enforce:getStatus 读 project.status(写侧第一条件 + 读侧二次过滤);getCurrentVersionId 用于写侧第二条件。
|
||||||
* MVP 单体:由 project 模块 ProjectApiImpl(@RestController @Primary) 就地解析。
|
* MVP 单体:由 project 模块 ProjectApiImpl(@RestController @Primary) 就地解析。
|
||||||
*/
|
*/
|
||||||
@Resource
|
@Resource
|
||||||
private ProjectApi projectApi;
|
private ProjectApi projectApi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* runtime 模块 RPC 契约(仅依赖 -api,§3.1 C1 写侧可见态第三条件)
|
||||||
|
*
|
||||||
|
* getStatus 读 game_runtime_package(versionId).status(须==1 已发布)。MVP 单体:由 runtime 模块 RuntimePackageApiImpl(@RestController @Primary) 就地解析。
|
||||||
|
*/
|
||||||
|
@Resource
|
||||||
|
private RuntimePackageApi runtimePackageApi;
|
||||||
|
|
||||||
// ============================== 游戏流 ==============================
|
// ============================== 游戏流 ==============================
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -89,15 +105,44 @@ public class FeedServiceImpl implements FeedService {
|
|||||||
*/
|
*/
|
||||||
private FeedStreamRespVO buildStream(Long zoneId, Integer size, Long userId) {
|
private FeedStreamRespVO buildStream(Long zoneId, Integer size, Long userId) {
|
||||||
List<FeedRankDO> ranks = feedRankMapper.selectStreamByZone(zoneId, size);
|
List<FeedRankDO> ranks = feedRankMapper.selectStreamByZone(zoneId, size);
|
||||||
|
// 读侧可见态 enforce(§3.1 C1 / Codex H9):rank.status=1 只是覆盖层在流态,仍可能因手工写错/项目降级而与权威态不一致,
|
||||||
|
// 故对 rank 结果批查 project.status 二次过滤——仅 project.status==PUBLISHED(4) 的游戏出流,未发布/降级(下架/封禁/审核中)不出流。
|
||||||
|
List<FeedRankDO> visibleRanks = filterByProjectPublished(ranks);
|
||||||
FeedStreamRespVO resp = new FeedStreamRespVO();
|
FeedStreamRespVO resp = new FeedStreamRespVO();
|
||||||
resp.setList(FeedConvert.toCardList(ranks));
|
resp.setList(FeedConvert.toCardList(visibleRanks));
|
||||||
// TODO 跨模块回填:本体元信息读 project(同库直查或 RPC);登录态用户互动 liked/favorited 读 game_feed_interact_log。
|
// TODO 跨模块回填:本体元信息读 project(同库直查或 RPC);登录态用户互动 liked/favorited 读 game_feed_interact_log。
|
||||||
// TODO cursor 续接:以末条 sortScore 编码 nextCursor;不足 size 条则 hasMore=false。骨架先返回空串占位。
|
// TODO cursor 续接:以末条 sortScore 编码 nextCursor;不足 size 条则 hasMore=false。骨架先返回空串占位。
|
||||||
resp.setNextCursor("");
|
resp.setNextCursor("");
|
||||||
resp.setHasMore(ranks.size() >= size);
|
// hasMore 以二次过滤后的真实出流条数判定(避免被过滤掉的行误判还有更多)
|
||||||
|
resp.setHasMore(visibleRanks.size() >= size);
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读侧可见态二次过滤(§3.1 C1):批查每条 rank 对应游戏的 project.status,仅 PUBLISHED(4) 保留出流
|
||||||
|
*
|
||||||
|
* 经 ProjectApi 本地 bean 逐 gameId 读 status(同进程方法调用,非真实 Feign);去重 gameId 减少调用次数。
|
||||||
|
* 未发布/降级(含游戏不存在 status=null)一律剔除,杜绝「手工写错一条 rank 就出流」(去掉旧 isGamePublished 默认放行)。
|
||||||
|
*
|
||||||
|
* @param ranks 排序行候选(已按 sort_score 降序)
|
||||||
|
* @return 仅保留 project 已发布的排序行(保持原降序)
|
||||||
|
*/
|
||||||
|
private List<FeedRankDO> filterByProjectPublished(List<FeedRankDO> ranks) {
|
||||||
|
if (ranks.isEmpty()) {
|
||||||
|
return ranks;
|
||||||
|
}
|
||||||
|
// 去重 gameId → 批量读 project.status(缓存到 map,避免同一游戏多次 RPC)
|
||||||
|
Map<Long, Integer> statusByGameId = new LinkedHashMap<>();
|
||||||
|
for (FeedRankDO rank : ranks) {
|
||||||
|
statusByGameId.computeIfAbsent(rank.getGameId(),
|
||||||
|
gid -> projectApi.getStatus(gid).getCheckedData());
|
||||||
|
}
|
||||||
|
// 仅保留 project.status==PUBLISHED(4) 的行(status=null 即游戏不存在,剔除)
|
||||||
|
return ranks.stream()
|
||||||
|
.filter(rank -> Objects.equals(statusByGameId.get(rank.getGameId()), ProjectStatusEnum.PUBLISHED.getStatus()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<ZoneRespVO> getZones() {
|
public List<ZoneRespVO> getZones() {
|
||||||
// TODO 跨模块对接 project:读 game_zone(type=1官方精选 2UGC普通、status=1启用),按 sort 升序返回。
|
// TODO 跨模块对接 project:读 game_zone(type=1官方精选 2UGC普通、status=1启用),按 sort 升序返回。
|
||||||
@ -244,6 +289,117 @@ public class FeedServiceImpl implements FeedService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================== 排序行 upsert(发布基线 / 算分回灌两路,§3.2/§3.5)==============================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class) // 由上游(发布编排/算分回灌)本地事务内调用;本方法抛错则上游整体回滚
|
||||||
|
public void upsertRank(FeedRankUpsertReqDTO req) {
|
||||||
|
if (req.getMode() == null) {
|
||||||
|
// mode 必填:缺失无法判定写入字段集,按非法入参拒绝(守门③)
|
||||||
|
throw exception(FEED_RANK_UPSERT_MODE_REQUIRED);
|
||||||
|
}
|
||||||
|
if (FeedRankUpsertReqDTO.UpsertMode.PUBLISH_BASELINE == req.getMode()) {
|
||||||
|
upsertPublishBaseline(req);
|
||||||
|
} else {
|
||||||
|
upsertQualityRefresh(req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布基线 upsert(mode=PUBLISH_BASELINE,§3.2 C2):写 status=1 前校验可见态前三条件,满足则写全字段
|
||||||
|
*
|
||||||
|
* 可见态前三条件(§3.1 C1 写侧 enforce,经 ProjectApi/RuntimePackageApi 本地 bean 读,不满足拒写抛错回滚):
|
||||||
|
* ① project.status==PUBLISHED(4);② project.current_version_id==versionId;③ runtime_package(versionId).status==1 已发布。
|
||||||
|
*
|
||||||
|
* @param req 排序行入参
|
||||||
|
*/
|
||||||
|
private void upsertPublishBaseline(FeedRankUpsertReqDTO req) {
|
||||||
|
// 条件①:project 已发布
|
||||||
|
if (!isGamePublished(req.getGameId())) {
|
||||||
|
throw exception(FEED_RANK_VISIBILITY_VIOLATION);
|
||||||
|
}
|
||||||
|
// 条件②:current_version_id 与待写版本一致(避免把非当前版本写进流)
|
||||||
|
Long currentVersionId = projectApi.getCurrentVersionId(req.getGameId()).getCheckedData();
|
||||||
|
if (!Objects.equals(currentVersionId, req.getVersionId())) {
|
||||||
|
throw exception(FEED_RANK_VISIBILITY_VIOLATION);
|
||||||
|
}
|
||||||
|
// 条件③:runtime_package 已发布(status=1)
|
||||||
|
Integer packageStatus = req.getVersionId() == null ? null
|
||||||
|
: runtimePackageApi.getStatus(req.getVersionId()).getCheckedData();
|
||||||
|
if (!Objects.equals(packageStatus, PackageStatusEnum.PUBLISHED.getStatus())) {
|
||||||
|
throw exception(FEED_RANK_VISIBILITY_VIOLATION);
|
||||||
|
}
|
||||||
|
// upsert:命中 uk_game_zone 则更新全字段,否则新插(复用 setFeatured 的 select+insert/updateById 范式,享 tenant/审计自动填充)
|
||||||
|
Long zoneId = req.getZoneId() == null ? DEFAULT_ZONE_ID : req.getZoneId();
|
||||||
|
FeedRankDO existing = feedRankMapper.selectByGameAndZone(req.getGameId(), zoneId);
|
||||||
|
if (existing == null) {
|
||||||
|
FeedRankDO rank = new FeedRankDO();
|
||||||
|
rank.setGameId(req.getGameId());
|
||||||
|
rank.setVersionId(req.getVersionId());
|
||||||
|
rank.setZoneId(zoneId);
|
||||||
|
rank.setQualityScore(toDecimal(req.getQualityScore()));
|
||||||
|
rank.setBoost(toDecimal(req.getBoost()));
|
||||||
|
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 {
|
||||||
|
// 重发布:刷新版本/分数/在流态/置顶(发布基线全字段覆盖)
|
||||||
|
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()));
|
||||||
|
update.setStatus(req.getStatus() == null ? 1 : req.getStatus());
|
||||||
|
update.setPinned(req.getPinned() == null ? 0 : req.getPinned());
|
||||||
|
feedRankMapper.updateById(update);
|
||||||
|
}
|
||||||
|
log.info("[upsertRank] 发布基线写入 gameId={}, zoneId={}, versionId={}", req.getGameId(), zoneId, req.getVersionId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 算分回灌 upsert(mode=QUALITY_REFRESH,§3.5 C5):仅更 quality_score/sort_score,保留 boost/pinned/status(守门③)
|
||||||
|
*
|
||||||
|
* 仅对已存在的排序行做刷新(纯 UPDATE,不新建)——未发布游戏无发布基线行则无可刷新(telemetry 只对已发布游戏回灌有意义)。
|
||||||
|
* 仅设置 quality_score/sort_score 两个字段(其余字段不置入 update DO,MyBatis-Plus 默认策略跳过 null,故 boost/pinned/status 原值保留)。
|
||||||
|
*
|
||||||
|
* @param req 排序行入参
|
||||||
|
*/
|
||||||
|
private void upsertQualityRefresh(FeedRankUpsertReqDTO req) {
|
||||||
|
Long zoneId = req.getZoneId() == null ? DEFAULT_ZONE_ID : req.getZoneId();
|
||||||
|
FeedRankDO existing = feedRankMapper.selectByGameAndZone(req.getGameId(), zoneId);
|
||||||
|
if (existing == null) {
|
||||||
|
// 无发布基线行:无可回灌(游戏未发布或未入流),记日志后返回(不新建,避免回灌产生「无发布态」的孤儿排序行)
|
||||||
|
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 口径一致)。
|
||||||
|
BigDecimal quality = toDecimal(req.getQualityScore());
|
||||||
|
BigDecimal boost = existing.getBoost() == null ? BigDecimal.ZERO : existing.getBoost();
|
||||||
|
FeedRankDO update = new FeedRankDO();
|
||||||
|
update.setId(existing.getId());
|
||||||
|
update.setQualityScore(quality);
|
||||||
|
update.setSortScore(quality.add(boost));
|
||||||
|
feedRankMapper.updateById(update);
|
||||||
|
log.info("[upsertRank] 算分回灌刷新 gameId={}, zoneId={}, qualityScore={}, boost={}, sortScore={}",
|
||||||
|
req.getGameId(), zoneId, quality, boost, quality.add(boost));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Double 排序值 → BigDecimal(feed_rank 列为 DECIMAL10,4;null 安全缺省 0)
|
||||||
|
*
|
||||||
|
* 用 BigDecimal.valueOf 避免 new BigDecimal(double) 的二进制浮点失真。
|
||||||
|
*
|
||||||
|
* @param value DTO 侧 Double 值
|
||||||
|
* @return BigDecimal 值
|
||||||
|
*/
|
||||||
|
private static BigDecimal toDecimal(Double value) {
|
||||||
|
return value == null ? BigDecimal.ZERO : BigDecimal.valueOf(value);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================== 私有校验/辅助 ==============================
|
// ============================== 私有校验/辅助 ==============================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -274,20 +430,19 @@ public class FeedServiceImpl implements FeedService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验游戏是否 project status=4 已发布(分享/精选的发布门禁)
|
* 校验游戏是否 project status=4 已发布(分享/精选的发布门禁 + 可见态写侧第一条件复用)
|
||||||
*
|
*
|
||||||
* 跨模块读 project 的对接点(同库直查 game_project.status 或 project -api RPC)。
|
* §3.1 C1:去掉旧「骨架默认放行 true」——经 ProjectApi 本地 bean 读 project.status,仅 PUBLISHED(4) 为 true。
|
||||||
* 设计为可重写 seam:骨架阶段默认放行(true),实际接入时替换为真实查询。
|
* 仍保留 protected seam 便于单测桩控(@Spy)。
|
||||||
*
|
*
|
||||||
* @param gameId 游戏 ID
|
* @param gameId 游戏 ID
|
||||||
* @return true=已发布
|
* @return true=已发布(project.status==PUBLISHED(4))
|
||||||
*/
|
*/
|
||||||
protected boolean isGamePublished(Long gameId) {
|
protected boolean isGamePublished(Long gameId) {
|
||||||
// TODO 跨模块对接 project:查 game_project where id=gameId and status=4。
|
|
||||||
// MVP 同库可直查,或待 project -api RPC 就绪后改 Feign(含已删/封禁过滤)。
|
|
||||||
// 骨架阶段默认放行,避免阻断联调;集成阶段必须替换为真实校验。
|
|
||||||
Objects.requireNonNull(gameId, "gameId 不能为空");
|
Objects.requireNonNull(gameId, "gameId 不能为空");
|
||||||
return true;
|
// 经 project -api 本地 bean 读 status(同进程,非 Feign);游戏不存在 status=null,非 PUBLISHED 一律 false
|
||||||
|
Integer status = projectApi.getStatus(gameId).getCheckedData();
|
||||||
|
return Objects.equals(status, ProjectStatusEnum.PUBLISHED.getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,9 @@ 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.rank.FeedRankDO;
|
||||||
import cn.wanxiang.game.module.feed.dal.mysql.interact.FeedInteractLogMapper;
|
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.rank.FeedRankMapper;
|
||||||
|
import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO;
|
||||||
import cn.wanxiang.game.module.project.api.ProjectApi;
|
import cn.wanxiang.game.module.project.api.ProjectApi;
|
||||||
|
import cn.wanxiang.game.module.runtime.api.RuntimePackageApi;
|
||||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||||
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
|
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
|
||||||
@ -49,7 +51,9 @@ class FeedServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private FeedInteractLogMapper feedInteractLogMapper;
|
private FeedInteractLogMapper feedInteractLogMapper;
|
||||||
@Mock
|
@Mock
|
||||||
private ProjectApi projectApi; // GAP-3:分享回填 currentVersionId 的 project 契约
|
private ProjectApi projectApi; // GAP-3:分享回填 currentVersionId 的 project 契约;§3.1/§3.2 可见态 enforce 读 status
|
||||||
|
@Mock
|
||||||
|
private RuntimePackageApi runtimePackageApi; // §3.1 C1 可见态第三条件:读 runtime_package.status
|
||||||
|
|
||||||
// ============================== 游戏流 ==============================
|
// ============================== 游戏流 ==============================
|
||||||
|
|
||||||
@ -59,6 +63,9 @@ class FeedServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
when(feedRankMapper.selectStreamByZone(eq(0L), eq(2))).thenReturn(Arrays.asList(
|
when(feedRankMapper.selectStreamByZone(eq(0L), eq(2))).thenReturn(Arrays.asList(
|
||||||
rank(1L, 10L, 0L, new BigDecimal("90")),
|
rank(1L, 10L, 0L, new BigDecimal("90")),
|
||||||
rank(2L, 11L, 0L, new BigDecimal("80"))));
|
rank(2L, 11L, 0L, new BigDecimal("80"))));
|
||||||
|
// §3.1 C1 读侧可见态二次过滤:两游戏均 project.status=PUBLISHED(4) 才出流(不 stub 则 getStatus 返回 null 被剔除)
|
||||||
|
when(projectApi.getStatus(1L)).thenReturn(CommonResult.success(4));
|
||||||
|
when(projectApi.getStatus(2L)).thenReturn(CommonResult.success(4));
|
||||||
FeedStreamReqVO reqVO = new FeedStreamReqVO();
|
FeedStreamReqVO reqVO = new FeedStreamReqVO();
|
||||||
reqVO.setSize(2);
|
reqVO.setSize(2);
|
||||||
|
|
||||||
@ -70,6 +77,24 @@ class FeedServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
assertEquals("", resp.getNextCursor()); // 骨架阶段 cursor 占位空串
|
assertEquals("", resp.getNextCursor()); // 骨架阶段 cursor 占位空串
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetFeedStream_filterOutNonPublished() {
|
||||||
|
// §3.1 C1 读侧 enforce:rank 在流(status=1)但 project 非 PUBLISHED 的行必须被剔除(手工写错/降级不出流)
|
||||||
|
when(feedRankMapper.selectStreamByZone(eq(0L), eq(2))).thenReturn(Arrays.asList(
|
||||||
|
rank(1L, 10L, 0L, new BigDecimal("90")), // project 已发布 → 出流
|
||||||
|
rank(2L, 11L, 0L, new BigDecimal("80")))); // project 已下架(5) → 剔除
|
||||||
|
when(projectApi.getStatus(1L)).thenReturn(CommonResult.success(4));
|
||||||
|
when(projectApi.getStatus(2L)).thenReturn(CommonResult.success(5));
|
||||||
|
FeedStreamReqVO reqVO = new FeedStreamReqVO();
|
||||||
|
reqVO.setSize(2);
|
||||||
|
|
||||||
|
FeedStreamRespVO resp = feedService.getFeedStream(reqVO, 99L);
|
||||||
|
|
||||||
|
assertEquals(1, resp.getList().size()); // 仅保留已发布游戏
|
||||||
|
assertEquals(1L, resp.getList().get(0).getGameId());
|
||||||
|
assertFalse(resp.getHasMore()); // 过滤后不足 size → 无下一页
|
||||||
|
}
|
||||||
|
|
||||||
// ============================== 互动:动作校验 ==============================
|
// ============================== 互动:动作校验 ==============================
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -231,6 +256,84 @@ class FeedServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
verify(feedRankMapper, never()).insert(any(FeedRankDO.class));
|
verify(feedRankMapper, never()).insert(any(FeedRankDO.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================== 排序行 upsert(发布基线 / 算分回灌,§3.2/§3.5)==============================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testUpsertRank_publishBaseline_insertWhenVisibilityPass() {
|
||||||
|
// 可见态前三条件全过:project 已发布(4) + current_version_id==versionId + runtime_package 已发布(1)
|
||||||
|
doReturn(true).when(feedService).isGamePublished(1L); // 条件①(复用 isGamePublished seam)
|
||||||
|
when(projectApi.getCurrentVersionId(1L)).thenReturn(CommonResult.success(2048L)); // 条件②
|
||||||
|
when(runtimePackageApi.getStatus(2048L)).thenReturn(CommonResult.success(1)); // 条件③
|
||||||
|
when(feedRankMapper.selectByGameAndZone(1L, 0L)).thenReturn(null); // 无既有行 → 新插
|
||||||
|
|
||||||
|
feedService.upsertRank(baselineReq(1L, 2048L, 0L));
|
||||||
|
|
||||||
|
ArgumentCaptor<FeedRankDO> captor = ArgumentCaptor.forClass(FeedRankDO.class);
|
||||||
|
verify(feedRankMapper).insert(captor.capture());
|
||||||
|
FeedRankDO saved = captor.getValue();
|
||||||
|
assertEquals(1, saved.getStatus()); // 发布即入流 status=1
|
||||||
|
assertEquals(0, saved.getPinned());
|
||||||
|
assertEquals(2048L, saved.getVersionId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testUpsertRank_publishBaseline_rejectedWhenPackageNotPublished() {
|
||||||
|
// 条件①②过,条件③不满足(运行包 status=0 预览就绪,未发布)→ 拒写抛错(发布事务回滚)
|
||||||
|
doReturn(true).when(feedService).isGamePublished(1L);
|
||||||
|
when(projectApi.getCurrentVersionId(1L)).thenReturn(CommonResult.success(2048L));
|
||||||
|
when(runtimePackageApi.getStatus(2048L)).thenReturn(CommonResult.success(0));
|
||||||
|
|
||||||
|
ServiceException ex = assertThrows(ServiceException.class,
|
||||||
|
() -> feedService.upsertRank(baselineReq(1L, 2048L, 0L)));
|
||||||
|
assertEquals(FEED_RANK_VISIBILITY_VIOLATION.getCode(), ex.getCode());
|
||||||
|
verify(feedRankMapper, never()).insert(any(FeedRankDO.class));
|
||||||
|
verify(feedRankMapper, never()).updateById(any(FeedRankDO.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testUpsertRank_qualityRefresh_onlyUpdatesScoresPreservesBoostPinnedStatus() {
|
||||||
|
// 算分回灌:仅更 quality_score/sort_score,保留 boost/pinned/status(守门③)——update DO 不含这三字段
|
||||||
|
FeedRankDO existing = new FeedRankDO();
|
||||||
|
existing.setId(7L);
|
||||||
|
existing.setBoost(new BigDecimal("5"));
|
||||||
|
existing.setPinned(1);
|
||||||
|
existing.setStatus(1);
|
||||||
|
when(feedRankMapper.selectByGameAndZone(1L, 0L)).thenReturn(existing);
|
||||||
|
|
||||||
|
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(7L, update.getId());
|
||||||
|
assertEquals(0, new BigDecimal("88.5").compareTo(update.getQualityScore()));
|
||||||
|
assertEquals(0, new BigDecimal("93.5").compareTo(update.getSortScore()));
|
||||||
|
// 守门③:boost/pinned/status 不被置入 update DO(保持 null,MyBatis-Plus 跳过 → DB 原值保留)
|
||||||
|
assertNull(update.getBoost());
|
||||||
|
assertNull(update.getPinned());
|
||||||
|
assertNull(update.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testUpsertRank_qualityRefresh_noopWhenNoBaseline() {
|
||||||
|
// 无发布基线行:算分回灌空转(不新建孤儿排序行)
|
||||||
|
when(feedRankMapper.selectByGameAndZone(1L, 0L)).thenReturn(null);
|
||||||
|
|
||||||
|
feedService.upsertRank(refreshReq(1L, 0L, 88.5D, 93.5D));
|
||||||
|
|
||||||
|
verify(feedRankMapper, never()).insert(any(FeedRankDO.class));
|
||||||
|
verify(feedRankMapper, never()).updateById(any(FeedRankDO.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testUpsertRank_modeRequired() {
|
||||||
|
// mode 缺失 → 非法入参拒绝(守门③)
|
||||||
|
FeedRankUpsertReqDTO req = new FeedRankUpsertReqDTO();
|
||||||
|
req.setGameId(1L);
|
||||||
|
ServiceException ex = assertThrows(ServiceException.class, () -> feedService.upsertRank(req));
|
||||||
|
assertEquals(FEED_RANK_UPSERT_MODE_REQUIRED.getCode(), ex.getCode());
|
||||||
|
}
|
||||||
|
|
||||||
// ============================== 测试夹具 ==============================
|
// ============================== 测试夹具 ==============================
|
||||||
|
|
||||||
/** 构造排序覆盖层记录 */
|
/** 构造排序覆盖层记录 */
|
||||||
@ -262,4 +365,30 @@ class FeedServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
return reqVO;
|
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();
|
||||||
|
req.setGameId(gameId);
|
||||||
|
req.setVersionId(versionId);
|
||||||
|
req.setZoneId(zoneId);
|
||||||
|
req.setQualityScore(0D);
|
||||||
|
req.setBoost(0D);
|
||||||
|
req.setSortScore(0D);
|
||||||
|
req.setStatus(1);
|
||||||
|
req.setPinned(0);
|
||||||
|
req.setMode(FeedRankUpsertReqDTO.UpsertMode.PUBLISH_BASELINE);
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造算分回灌 upsert 入参(mode=QUALITY_REFRESH,仅 quality_score/sort_score) */
|
||||||
|
private static FeedRankUpsertReqDTO refreshReq(Long gameId, Long zoneId, Double qualityScore, Double sortScore) {
|
||||||
|
FeedRankUpsertReqDTO req = new FeedRankUpsertReqDTO();
|
||||||
|
req.setGameId(gameId);
|
||||||
|
req.setZoneId(zoneId);
|
||||||
|
req.setQualityScore(qualityScore);
|
||||||
|
req.setSortScore(sortScore);
|
||||||
|
req.setMode(FeedRankUpsertReqDTO.UpsertMode.QUALITY_REFRESH);
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,6 +51,21 @@ public interface ProjectApi {
|
|||||||
@Parameter(name = "gameId", description = "游戏项目 ID", required = true, example = "1024")
|
@Parameter(name = "gameId", description = "游戏项目 ID", required = true, example = "1024")
|
||||||
CommonResult<Long> getCurrentVersionId(@RequestParam("gameId") Long gameId);
|
CommonResult<Long> getCurrentVersionId(@RequestParam("gameId") Long gameId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按游戏 ID 反查项目状态机值(§3.1/§3.2 C1/C2 可见态校验:feed 读写双侧 enforce 依赖)
|
||||||
|
*
|
||||||
|
* 用途:feed 写侧(PUBLISH_BASELINE upsert 前校验 project.status==PUBLISHED(4))+ 读侧(buildStream 对 rank 结果批查 project.status 二次过滤,未发布/降级不出流)。
|
||||||
|
* 显式只查 status 单列、命中主键;游戏不存在时 data 返回 null(由调用方按业务决定,如 feed 写侧视为不满足可见态拒写)。
|
||||||
|
* 状态值语义见 {@link cn.wanxiang.game.module.project.enums.ProjectStatusEnum}(4=已发布)。
|
||||||
|
*
|
||||||
|
* @param gameId 游戏项目 ID(game_project.id)
|
||||||
|
* @return 项目状态机值(CommonResult 包裹;游戏不存在为 null)
|
||||||
|
*/
|
||||||
|
@GetMapping(PREFIX + "/get-status")
|
||||||
|
@Operation(summary = "按游戏 ID 反查项目状态机值(可见态 enforce,§3.1/§3.2)")
|
||||||
|
@Parameter(name = "gameId", description = "游戏项目 ID", required = true, example = "1024")
|
||||||
|
CommonResult<Integer> getStatus(@RequestParam("gameId") Long gameId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建项目(studio 写类 RPC,建草稿;显式传 creatorUserId 作 owner)
|
* 创建项目(studio 写类 RPC,建草稿;显式传 creatorUserId 作 owner)
|
||||||
*
|
*
|
||||||
|
|||||||
@ -12,6 +12,10 @@ import java.util.Objects;
|
|||||||
* 合法流转:0草稿 →1审核中 →(2已通过 / 3已拒绝) →4已发布 →(5已下架 / 6已封禁)。
|
* 合法流转:0草稿 →1审核中 →(2已通过 / 3已拒绝) →4已发布 →(5已下架 / 6已封禁)。
|
||||||
* 注意:状态机校验在 ProjectService 承载,DO 层不做校验(见 db-schemas 注释)。
|
* 注意:状态机校验在 ProjectService 承载,DO 层不做校验(见 db-schemas 注释)。
|
||||||
*
|
*
|
||||||
|
* D-PUB=α auto-publish(黄金闭环 §1,2026-06-09 拍板):审核通过即自动发布——
|
||||||
|
* 人工 reviewProject(APPROVE) 在同一本地事务内经发布编排把项目态直接 REVIEWING(1)→PUBLISHED(4),
|
||||||
|
* 故 APPROVED(2) 仅作审核记录的决策值(game_review_record.decision=通过),项目态不停留 APPROVED(2)。
|
||||||
|
*
|
||||||
* @author 绘境AI
|
* @author 绘境AI
|
||||||
*/
|
*/
|
||||||
@Getter
|
@Getter
|
||||||
|
|||||||
@ -38,6 +38,12 @@ public class ProjectApiImpl implements ProjectApi {
|
|||||||
return success(projectService.getCurrentVersionId(gameId));
|
return success(projectService.getCurrentVersionId(gameId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CommonResult<Integer> getStatus(Long gameId) {
|
||||||
|
// §3.1/§3.2 可见态 enforce:按 game_project.id 返回 status;不存在返回 null(由 feed 写侧拒写、读侧过滤)
|
||||||
|
return success(projectService.getStatus(gameId));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CommonResult<Long> createProject(Long creatorUserId, String title, String templateId) {
|
public CommonResult<Long> createProject(Long creatorUserId, String title, String templateId) {
|
||||||
// studio 写类 RPC:RPC 无登录上下文,owner=显式 creatorUserId(调用方受信传入),不裸暴露给前端
|
// studio 写类 RPC:RPC 无登录上下文,owner=显式 creatorUserId(调用方受信传入),不裸暴露给前端
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
package cn.wanxiang.game.module.project.api;
|
||||||
|
|
||||||
|
import cn.wanxiang.game.module.project.dto.ProjectVersionCreateForPackageReqDTO;
|
||||||
|
import cn.wanxiang.game.module.project.service.version.GameVersionService;
|
||||||
|
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游戏版本 API 实现(黄金闭环 §3.3 C3,提供 RESTful 接口给跨模块 Feign 调用:PackageFactory 落包建版本)
|
||||||
|
*
|
||||||
|
* @RestController + @Primary:与 yudao DictDataApiImpl / 既有 ProjectApiImpl 同构——单体内同进程调用走本实现,拆微服务后走 Feign(守门④/§3.2 H5)。
|
||||||
|
* 仅委托 {@link GameVersionService} 落包建版本(幂等),不写业务逻辑(业务在 Service)。
|
||||||
|
*
|
||||||
|
* @author 造梦AI
|
||||||
|
*/
|
||||||
|
@RestController // 提供 RESTful API 接口,给 Feign 调用
|
||||||
|
@Validated
|
||||||
|
@Primary // 与 @FeignClient 接口同名 Bean 冲突时优先用本地实现(同进程调用就地解析)
|
||||||
|
public class ProjectVersionApiImpl implements ProjectVersionApi {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private GameVersionService gameVersionService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CommonResult<Long> createForPackage(ProjectVersionCreateForPackageReqDTO req) {
|
||||||
|
// 落包建版本:委托 GameVersionService 创建/复用 game_version(status=2 预览就绪),按 genTaskId 幂等
|
||||||
|
return success(gameVersionService.createForPackage(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
package cn.wanxiang.game.module.project.dal.dataobject.version;
|
||||||
|
|
||||||
|
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||||
|
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游戏版本 DO(对应表 game_version,GameVersion 版本管理 + 草稿/发布,黄金闭环 §3.3 C3 新增)
|
||||||
|
*
|
||||||
|
* 继承 {@link TenantBaseDO}:自动携带 creator/create_time/updater/update_time/deleted + tenant_id 审计/租户列。
|
||||||
|
* version 权威归 project(与 game_version 表归属一致,Codex H6/H7):
|
||||||
|
* - PackageFactory 落包后经 {@code ProjectVersionApi.createForPackage} 建版本(status=2 预览就绪),按 gen_task_id 幂等;
|
||||||
|
* - 发布编排({@code PublishOrchestrationService})发布成功后显式置 status=3(已发布),不依赖 runtime 回写。
|
||||||
|
* 状态机校验在 Service 承载,DO 层只承载数据。
|
||||||
|
*
|
||||||
|
* @author 造梦AI
|
||||||
|
*/
|
||||||
|
@TableName("game_version")
|
||||||
|
@KeySequence("game_version_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class GameVersionDO extends TenantBaseDO {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 版本 ID(= GamePackage.versionId)
|
||||||
|
*/
|
||||||
|
private Long id;
|
||||||
|
/**
|
||||||
|
* 所属游戏 ID(game_project.id)
|
||||||
|
*/
|
||||||
|
private Long gameId;
|
||||||
|
/**
|
||||||
|
* 版本号(同游戏内递增)
|
||||||
|
*/
|
||||||
|
private Integer versionNo;
|
||||||
|
/**
|
||||||
|
* 生成任务 ID(aigc taskId);落包建版本幂等键:同 gen_task_id 复用已建版本,不重复建
|
||||||
|
*/
|
||||||
|
private String genTaskId;
|
||||||
|
/**
|
||||||
|
* GamePackage(manifest)OSS URL;MVP 无 OSS 包存 DB(退场契约 §3.4),为空落空串
|
||||||
|
*/
|
||||||
|
private String packageUrl;
|
||||||
|
/**
|
||||||
|
* 包总字节(编译期门禁)
|
||||||
|
*/
|
||||||
|
private Long bundleSize;
|
||||||
|
/**
|
||||||
|
* 整包 sha256(hex,Manifest 完整性校验;与 manifest 端点原样响应字节同源计算 §3.4 C4)
|
||||||
|
*/
|
||||||
|
private String checksum;
|
||||||
|
/**
|
||||||
|
* 版本状态:0草稿 1已编译 2预览就绪 3已发布
|
||||||
|
*
|
||||||
|
* 落包建版本写 2(预览就绪);发布编排成功后由本编排显式置 3(已发布,§3.2 C2 步骤④,不依赖 runtime TODO,Codex H6)。
|
||||||
|
*/
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
}
|
||||||
@ -70,4 +70,18 @@ public interface ProjectMapper extends BaseMapperX<ProjectDO> {
|
|||||||
.eq(ProjectDO::getId, id)); // 命中主键
|
.eq(ProjectDO::getId, id)); // 命中主键
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按游戏 ID 查项目状态机值(§3.1/§3.2 可见态 enforce,跨模块 Feign:feed 读写双侧校验)
|
||||||
|
*
|
||||||
|
* 只取 status 单列、命中主键(id)——显式列、不裸 select*、不加载整行,最小化跨模块查询开销。
|
||||||
|
*
|
||||||
|
* @param id 游戏项目 ID(主键)
|
||||||
|
* @return 仅含 status 的 DO;游戏不存在返回 null
|
||||||
|
*/
|
||||||
|
default ProjectDO selectStatusById(Long id) {
|
||||||
|
return selectOne(new LambdaQueryWrapperX<ProjectDO>()
|
||||||
|
.select(ProjectDO::getStatus) // 显式只查状态机所需列
|
||||||
|
.eq(ProjectDO::getId, id)); // 命中主键
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
package cn.wanxiang.game.module.project.dal.mysql.version;
|
||||||
|
|
||||||
|
import cn.wanxiang.game.module.project.dal.dataobject.version.GameVersionDO;
|
||||||
|
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||||
|
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游戏版本 Mapper(黄金闭环 §3.3 C3 新增)
|
||||||
|
*
|
||||||
|
* @author 造梦AI
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface GameVersionMapper extends BaseMapperX<GameVersionDO> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按生成任务 ID 取已建版本(落包建版本幂等:同 gen_task_id 复用,不重复建)
|
||||||
|
*
|
||||||
|
* 命中 idx_game_version 不直接适用,按 gen_task_id 过滤取一行(同任务对应一版本,业务量级下行数有限)。
|
||||||
|
*
|
||||||
|
* @param genTaskId 生成任务 ID(aigc taskId)
|
||||||
|
* @return 已建版本;不存在返回 null
|
||||||
|
*/
|
||||||
|
default GameVersionDO selectByGenTaskId(String genTaskId) {
|
||||||
|
return selectOne(new LambdaQueryWrapperX<GameVersionDO>()
|
||||||
|
.eq(GameVersionDO::getGenTaskId, genTaskId));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -103,6 +103,16 @@ public interface ProjectService {
|
|||||||
*/
|
*/
|
||||||
Long getCurrentVersionId(Long gameId);
|
Long getCurrentVersionId(Long gameId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按游戏 ID 反查项目状态机值(§3.1/§3.2 可见态 enforce,供 feed 跨模块 Feign 读写双侧校验)
|
||||||
|
*
|
||||||
|
* 只读、显式只查 status 单列、命中主键;游戏不存在返回 null(由调用方处理,如 feed 写侧视为不满足可见态拒写)。
|
||||||
|
*
|
||||||
|
* @param gameId 游戏项目 ID(game_project.id)
|
||||||
|
* @return 项目状态机值;游戏不存在返回 null
|
||||||
|
*/
|
||||||
|
Integer getStatus(Long gameId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 封禁联动下架(R3,供 compliance 跨模块调用)
|
* 封禁联动下架(R3,供 compliance 跨模块调用)
|
||||||
*
|
*
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import cn.wanxiang.game.module.project.dal.mysql.project.ProjectMapper;
|
|||||||
import cn.wanxiang.game.module.project.dal.mysql.review.ReviewRecordMapper;
|
import cn.wanxiang.game.module.project.dal.mysql.review.ReviewRecordMapper;
|
||||||
import cn.wanxiang.game.module.project.enums.ProjectStatusEnum;
|
import cn.wanxiang.game.module.project.enums.ProjectStatusEnum;
|
||||||
import cn.wanxiang.game.module.project.enums.ReviewDecisionEnum;
|
import cn.wanxiang.game.module.project.enums.ReviewDecisionEnum;
|
||||||
|
import cn.wanxiang.game.module.project.service.publish.PublishOrchestrationService;
|
||||||
import cn.wanxiang.game.module.compliance.api.ComplianceGateApi;
|
import cn.wanxiang.game.module.compliance.api.ComplianceGateApi;
|
||||||
import cn.wanxiang.game.module.compliance.dto.ComplianceGateReqDTO;
|
import cn.wanxiang.game.module.compliance.dto.ComplianceGateReqDTO;
|
||||||
import cn.wanxiang.game.module.compliance.dto.GateVerdict;
|
import cn.wanxiang.game.module.compliance.dto.GateVerdict;
|
||||||
@ -62,6 +63,13 @@ public class ProjectServiceImpl implements ProjectService {
|
|||||||
@Resource
|
@Resource
|
||||||
private ComplianceGateApi complianceGateApi;
|
private ComplianceGateApi complianceGateApi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布编排 Service(§3.2 C2,α auto-publish)。
|
||||||
|
* reviewProject(APPROVE) 在同一本地事务内委托其串联「校验包→翻包→翻版本态→翻项目态 PUBLISHED→写 feed 基线」,任一步失败整体回滚。
|
||||||
|
*/
|
||||||
|
@Resource
|
||||||
|
private PublishOrchestrationService publishOrchestrationService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PageResult<ProjectDO> getMyProjectPage(ProjectPageReqVO reqVO, Long userId) {
|
public PageResult<ProjectDO> getMyProjectPage(ProjectPageReqVO reqVO, Long userId) {
|
||||||
return projectMapper.selectMyPage(reqVO, userId);
|
return projectMapper.selectMyPage(reqVO, userId);
|
||||||
@ -181,28 +189,37 @@ public class ProjectServiceImpl implements ProjectService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class) // 改状态 + 落审核记录,需同事务
|
@Transactional(rollbackFor = Exception.class) // 改状态 + 落审核记录 +(APPROVE)发布编排,需同一本地事务(§3.2 C2 事务原子)
|
||||||
public void reviewProject(ReviewReqVO reqVO, Long reviewerUserId) {
|
public void reviewProject(ReviewReqVO reqVO, Long reviewerUserId) {
|
||||||
ProjectDO project = projectMapper.selectById(reqVO.getGameId());
|
ProjectDO project = projectMapper.selectById(reqVO.getGameId());
|
||||||
if (project == null) {
|
if (project == null) {
|
||||||
throw exception(PROJECT_NOT_EXISTS);
|
throw exception(PROJECT_NOT_EXISTS);
|
||||||
}
|
}
|
||||||
// 决策 → 目标状态(含合法流转校验)
|
boolean isApprove = Objects.equals(reqVO.getDecision(), ReviewDecisionEnum.APPROVE.getDecision());
|
||||||
|
// 决策 → 目标状态(含合法流转校验):APPROVE 仍校验「仅审核中可通过」,但 α 下项目态不停留 APPROVED(2),由发布编排直翻 PUBLISHED(4)
|
||||||
Integer targetStatus = resolveReviewTargetStatus(reqVO.getDecision(), project.getStatus());
|
Integer targetStatus = resolveReviewTargetStatus(reqVO.getDecision(), project.getStatus());
|
||||||
// 更新项目状态
|
if (!isApprove) {
|
||||||
ProjectDO update = new ProjectDO();
|
// REJECT/UNLIST 分支不变:直接写目标状态(REJECTED(3) / UNLISTED(5))
|
||||||
update.setId(project.getId());
|
ProjectDO update = new ProjectDO();
|
||||||
update.setStatus(targetStatus);
|
update.setId(project.getId());
|
||||||
projectMapper.updateById(update);
|
update.setStatus(targetStatus);
|
||||||
// 落审核记录(锁风门 Gate 二态:通过=pass,拒绝/下架=block)
|
projectMapper.updateById(update);
|
||||||
|
}
|
||||||
|
// 落审核记录(锁风门 Gate 二态:通过=pass,拒绝/下架=block)。
|
||||||
|
// D-PUB=α:APPROVE 先落 APPROVED 审核记录(decision 仍为通过、gate=pass),项目态不停留 APPROVED——见 ProjectStatusEnum 注释。
|
||||||
ReviewRecordDO record = new ReviewRecordDO();
|
ReviewRecordDO record = new ReviewRecordDO();
|
||||||
record.setGameId(reqVO.getGameId());
|
record.setGameId(reqVO.getGameId());
|
||||||
record.setVersionId(reqVO.getVersionId());
|
record.setVersionId(reqVO.getVersionId());
|
||||||
record.setReviewerUserId(reviewerUserId);
|
record.setReviewerUserId(reviewerUserId);
|
||||||
record.setDecision(reqVO.getDecision());
|
record.setDecision(reqVO.getDecision());
|
||||||
record.setGateResult(Objects.equals(reqVO.getDecision(), ReviewDecisionEnum.APPROVE.getDecision()) ? "pass" : "block");
|
record.setGateResult(isApprove ? "pass" : "block");
|
||||||
record.setReason(reqVO.getReason());
|
record.setReason(reqVO.getReason());
|
||||||
reviewRecordMapper.insert(record);
|
reviewRecordMapper.insert(record);
|
||||||
|
// D-PUB=α(§1/§3.2 C2):审核通过即自动发布——在同一本地事务内调发布编排,使项目 REVIEWING(1)→PUBLISHED(4)(跳过常驻 APPROVED(2))。
|
||||||
|
// 编排注入 RuntimePackageApi/FeedApi 的 @Primary 本地 bean(同进程,非 Feign),任一步失败整体回滚(审核记录与状态同回滚)。
|
||||||
|
if (isApprove) {
|
||||||
|
publishOrchestrationService.publish(project);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -219,6 +236,13 @@ public class ProjectServiceImpl implements ProjectService {
|
|||||||
return project == null ? null : project.getCurrentVersionId();
|
return project == null ? null : project.getCurrentVersionId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer getStatus(Long gameId) {
|
||||||
|
// §3.1/§3.2 可见态 enforce 只读:显式只查 status 单列、命中主键;游戏不存在返回 null(feed 写侧据此拒写、读侧据此过滤)
|
||||||
|
ProjectDO project = projectMapper.selectStatusById(gameId);
|
||||||
|
return project == null ? null : project.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean unlistIfPublished(Long gameId) {
|
public boolean unlistIfPublished(Long gameId) {
|
||||||
// R3 封禁联动下架:状态查询 + 下架决策均收敛在 project 权威侧,复用既有 reviewProject 状态机(不重写=D2)
|
// R3 封禁联动下架:状态查询 + 下架决策均收敛在 project 权威侧,复用既有 reviewProject 状态机(不重写=D2)
|
||||||
|
|||||||
@ -0,0 +1,26 @@
|
|||||||
|
package cn.wanxiang.game.module.project.service.publish;
|
||||||
|
|
||||||
|
import cn.wanxiang.game.module.project.dal.dataobject.project.ProjectDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布编排 Service 接口(黄金闭环 §3.2 C2 核心,新增)
|
||||||
|
*
|
||||||
|
* 职责:把「发布」串成一个本地事务原子操作——校验包就绪 → 翻包 0→1 → 翻版本态 3 → 项目态 PUBLISHED(4) → 写 feed 发布基线。
|
||||||
|
* 触发点(α auto-publish,D-PUB §1):人工 {@code ProjectServiceImpl.reviewProject(APPROVE)} 在同一事务内调用本编排。
|
||||||
|
*
|
||||||
|
* 事务原子硬约束(Codex H5,B1 必守):本编排注入 FeedApi/RuntimePackageApi 的 @Primary 本地 bean,
|
||||||
|
* 在调用方(reviewProject 的 @Transactional)同一本地事务内串联;事务内禁止 Feign/HTTP/@Async/REQUIRES_NEW,
|
||||||
|
* 任一步抛错则 project/package/version/feed 全回滚(含审核记录),保证「发布」要么全成、要么全不成。
|
||||||
|
*
|
||||||
|
* @author 造梦AI
|
||||||
|
*/
|
||||||
|
public interface PublishOrchestrationService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行发布编排(在调用方本地事务内串联,§3.2 C2 六步)
|
||||||
|
*
|
||||||
|
* @param project 待发布项目 DO(须已查出,提供 id/currentVersionId/launchZoneId)
|
||||||
|
*/
|
||||||
|
void publish(ProjectDO project);
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
package cn.wanxiang.game.module.project.service.publish;
|
||||||
|
|
||||||
|
import cn.wanxiang.game.module.project.dal.dataobject.project.ProjectDO;
|
||||||
|
import cn.wanxiang.game.module.project.dal.mysql.project.ProjectMapper;
|
||||||
|
import cn.wanxiang.game.module.project.enums.ProjectStatusEnum;
|
||||||
|
import cn.wanxiang.game.module.project.service.version.GameVersionService;
|
||||||
|
import cn.wanxiang.game.module.feed.api.FeedApi;
|
||||||
|
import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO;
|
||||||
|
import cn.wanxiang.game.module.runtime.api.RuntimePackageApi;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import static cn.wanxiang.game.module.project.enums.ErrorCodeConstants.PROJECT_PUBLISH_PACKAGE_NOT_READY;
|
||||||
|
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布编排 Service 实现(黄金闭环 §3.2 C2 核心,新增)
|
||||||
|
*
|
||||||
|
* 事务原子硬约束(Codex H5):本类注入的 {@link FeedApi}/{@link RuntimePackageApi} 在 MVP 单体内由对方模块的
|
||||||
|
* @RestController @Primary 本地实现就地解析(同进程方法调用,非真实 Feign/HTTP),故能与调用方(reviewProject 的
|
||||||
|
* @Transactional)落在同一本地事务;本类方法标 @Transactional(REQUIRED 默认传播,与调用方事务合并),
|
||||||
|
* 事务内禁止 @Async/REQUIRES_NEW,任一步抛错则全回滚。
|
||||||
|
*
|
||||||
|
* @author 造梦AI
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class PublishOrchestrationServiceImpl implements PublishOrchestrationService {
|
||||||
|
|
||||||
|
/** 运行包状态:预览就绪(发布前置态,仅此态可被发布翻 0→1) */
|
||||||
|
private static final int PACKAGE_STATUS_PREVIEW_READY = 0;
|
||||||
|
/** 发布基线默认专区:0 主混合流(项目未设 launch_zone_id 时回落) */
|
||||||
|
private static final long DEFAULT_LAUNCH_ZONE_ID = 0L;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ProjectMapper projectMapper;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private GameVersionService gameVersionService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* runtime 发布翻包 seam(仅依赖 -api)。MVP 单体由 runtime 模块 RuntimePackageApiImpl(@RestController @Primary) 就地解析,
|
||||||
|
* 事务内本地调用(非 Feign),抛错则发布事务整体回滚。
|
||||||
|
*/
|
||||||
|
@Resource
|
||||||
|
private RuntimePackageApi runtimePackageApi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* feed 写排序 seam(仅依赖 -api)。MVP 单体由 feed 模块 FeedApiImpl(@RestController @Primary) 就地解析,
|
||||||
|
* 事务内本地调用(非 Feign),抛错则发布事务整体回滚(故障注入测试:feed upsert 抛错 → project/package/version 全回滚)。
|
||||||
|
*/
|
||||||
|
@Resource
|
||||||
|
private FeedApi feedApi;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class) // 与调用方 reviewProject 事务合并(REQUIRED);六步任一失败全回滚
|
||||||
|
public void publish(ProjectDO project) {
|
||||||
|
Long versionId = project.getCurrentVersionId();
|
||||||
|
log.info("[publish] 发布编排开始 gameId={}, currentVersionId={}", project.getId(), versionId);
|
||||||
|
|
||||||
|
// ① 校验当前生效版本运行包就绪(status==0 预览就绪):currentVersionId 缺失或运行包未就绪→抛错回滚、审核不落
|
||||||
|
// 经 RuntimePackageApi 本地 bean 读 game_runtime_package(versionId).status(无就绪运行包 data 为 null)
|
||||||
|
Integer packageStatus = versionId == null ? null
|
||||||
|
: runtimePackageApi.getStatus(versionId).getCheckedData();
|
||||||
|
if (packageStatus == null || packageStatus != PACKAGE_STATUS_PREVIEW_READY) {
|
||||||
|
log.warn("[publish] 运行包未就绪,拒绝发布并回滚 gameId={}, versionId={}, packageStatus={}",
|
||||||
|
project.getId(), versionId, packageStatus);
|
||||||
|
throw exception(PROJECT_PUBLISH_PACKAGE_NOT_READY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 翻包 0→1 已发布(复用 runtime publishPackage 逻辑;version/project 回写不在 runtime,归本编排,Codex H6)
|
||||||
|
runtimePackageApi.publish(versionId);
|
||||||
|
|
||||||
|
// ③ 翻版本态 game_version(versionId).status=3 已发布(版本权威归 project,本编排显式写,不依赖 runtime TODO)
|
||||||
|
gameVersionService.markPublished(versionId);
|
||||||
|
|
||||||
|
// ④ 翻项目态 project.status=PUBLISHED(4)(全仓首个 PUBLISHED 写入点)
|
||||||
|
ProjectDO update = new ProjectDO();
|
||||||
|
update.setId(project.getId());
|
||||||
|
update.setStatus(ProjectStatusEnum.PUBLISHED.getStatus());
|
||||||
|
projectMapper.updateById(update);
|
||||||
|
|
||||||
|
// ⑤ 写 feed 发布基线(mode=PUBLISH_BASELINE,与 §3.5 算分回灌 QUALITY_REFRESH 分流)
|
||||||
|
// zone_id=launch_zone_id 或 0;quality_score/boost/sort_score=0、status=1 入流、pinned=0
|
||||||
|
FeedRankUpsertReqDTO req = new FeedRankUpsertReqDTO();
|
||||||
|
req.setGameId(project.getId());
|
||||||
|
req.setVersionId(versionId);
|
||||||
|
req.setZoneId(project.getLaunchZoneId() == null ? DEFAULT_LAUNCH_ZONE_ID : project.getLaunchZoneId());
|
||||||
|
req.setQualityScore(0D);
|
||||||
|
req.setBoost(0D);
|
||||||
|
req.setSortScore(0D);
|
||||||
|
req.setStatus(1);
|
||||||
|
req.setPinned(0);
|
||||||
|
req.setMode(FeedRankUpsertReqDTO.UpsertMode.PUBLISH_BASELINE);
|
||||||
|
feedApi.upsertRank(req);
|
||||||
|
|
||||||
|
log.info("[publish] 发布编排完成 gameId={}, versionId={}, zoneId={}", project.getId(), versionId, req.getZoneId());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
package cn.wanxiang.game.module.project.service.version;
|
||||||
|
|
||||||
|
import cn.wanxiang.game.module.project.dal.dataobject.version.GameVersionDO;
|
||||||
|
import cn.wanxiang.game.module.project.dto.ProjectVersionCreateForPackageReqDTO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游戏版本 Service 接口(黄金闭环 §3.3 C3 新增)
|
||||||
|
*
|
||||||
|
* 承载:落包建版本(PackageFactory → project,按 genTaskId 幂等)、发布编排显式翻版本态(status=3)。
|
||||||
|
* version 权威归 project(Codex H6/H7):版本由 project 写,runtime 不建版本、不回写版本态。
|
||||||
|
*
|
||||||
|
* @author 造梦AI
|
||||||
|
*/
|
||||||
|
public interface GameVersionService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 落包建版本(PackageFactory 落包后创建/复用 game_version,返回 versionId)
|
||||||
|
*
|
||||||
|
* 创建态 status=2(预览就绪);按 req.genTaskId 幂等——同任务复用已建版本,不重复建(避免重复落包重复建版本)。
|
||||||
|
* 不写 current_version_id(仍由创作者 submitPublish 写入);本方法只建版本,不动当前生效版本。
|
||||||
|
*
|
||||||
|
* @param req 建版本入参(gameId/genTaskId/packageUrl 可空/bundleSize/checksum)
|
||||||
|
* @return 新建或复用的版本 ID
|
||||||
|
*/
|
||||||
|
Long createForPackage(ProjectVersionCreateForPackageReqDTO req);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取版本 DO(发布编排取当前生效版本以做版本态流转)
|
||||||
|
*
|
||||||
|
* @param versionId 版本 ID
|
||||||
|
* @return 版本 DO;不存在返回 null
|
||||||
|
*/
|
||||||
|
GameVersionDO getVersion(Long versionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 置版本为已发布(status=3,发布编排步骤④显式写,§3.2 C2 / Codex H6)
|
||||||
|
*
|
||||||
|
* 由 {@code PublishOrchestrationService} 在发布本地事务内调用(不依赖 runtime 回写)。
|
||||||
|
*
|
||||||
|
* @param versionId 版本 ID
|
||||||
|
*/
|
||||||
|
void markPublished(Long versionId);
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
package cn.wanxiang.game.module.project.service.version;
|
||||||
|
|
||||||
|
import cn.wanxiang.game.module.project.dal.dataobject.version.GameVersionDO;
|
||||||
|
import cn.wanxiang.game.module.project.dal.mysql.version.GameVersionMapper;
|
||||||
|
import cn.wanxiang.game.module.project.dto.ProjectVersionCreateForPackageReqDTO;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游戏版本 Service 实现(黄金闭环 §3.3 C3 新增)
|
||||||
|
*
|
||||||
|
* 业务规则(落包建版本幂等、版本态流转)在此承载,可单测、可追溯。
|
||||||
|
*
|
||||||
|
* @author 造梦AI
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class GameVersionServiceImpl implements GameVersionService {
|
||||||
|
|
||||||
|
/** 版本状态:预览就绪(落包建版本态,scene=preview 可取包) */
|
||||||
|
private static final int STATUS_PREVIEW_READY = 2;
|
||||||
|
/** 版本状态:已发布(发布编排成功后置位) */
|
||||||
|
private static final int STATUS_PUBLISHED = 3;
|
||||||
|
/** 落包建版本默认版本号(MVP 单版本场景;多版本递增留后续) */
|
||||||
|
private static final int DEFAULT_VERSION_NO = 1;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private GameVersionMapper gameVersionMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Long createForPackage(ProjectVersionCreateForPackageReqDTO req) {
|
||||||
|
// 幂等:同 genTaskId 已建版本则直接复用,不重复建(PackageFactory 重复落包安全)
|
||||||
|
if (StringUtils.hasText(req.getGenTaskId())) {
|
||||||
|
GameVersionDO existing = gameVersionMapper.selectByGenTaskId(req.getGenTaskId());
|
||||||
|
if (existing != null) {
|
||||||
|
log.info("[createForPackage] 落包建版本幂等命中,复用已建版本 genTaskId={}, versionId={}",
|
||||||
|
req.getGenTaskId(), existing.getId());
|
||||||
|
return existing.getId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 新建版本:status=2 预览就绪;package_url 可空(MVP 无 OSS 包存 DB,退场契约 §3.4)落空串
|
||||||
|
GameVersionDO version = new GameVersionDO();
|
||||||
|
version.setGameId(req.getGameId());
|
||||||
|
version.setVersionNo(DEFAULT_VERSION_NO);
|
||||||
|
version.setGenTaskId(req.getGenTaskId());
|
||||||
|
version.setPackageUrl(StringUtils.hasText(req.getPackageUrl()) ? req.getPackageUrl() : "");
|
||||||
|
version.setBundleSize(req.getBundleSize() == null ? 0L : req.getBundleSize());
|
||||||
|
version.setChecksum(StringUtils.hasText(req.getChecksum()) ? req.getChecksum() : "");
|
||||||
|
version.setStatus(STATUS_PREVIEW_READY);
|
||||||
|
gameVersionMapper.insert(version);
|
||||||
|
log.info("[createForPackage] 落包建版本成功 gameId={}, genTaskId={}, versionId={}",
|
||||||
|
req.getGameId(), req.getGenTaskId(), version.getId());
|
||||||
|
return version.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public GameVersionDO getVersion(Long versionId) {
|
||||||
|
return gameVersionMapper.selectById(versionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void markPublished(Long versionId) {
|
||||||
|
// 发布编排步骤④(§3.2 C2 / Codex H6):版本权威归 project,由本编排显式置已发布,不依赖 runtime 回写
|
||||||
|
GameVersionDO update = new GameVersionDO();
|
||||||
|
update.setId(versionId);
|
||||||
|
update.setStatus(STATUS_PUBLISHED);
|
||||||
|
gameVersionMapper.updateById(update);
|
||||||
|
log.info("[markPublished] 版本已置已发布 versionId={}", versionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import cn.wanxiang.game.module.project.dal.dataobject.review.ReviewRecordDO;
|
|||||||
import cn.wanxiang.game.module.project.dal.mysql.project.ProjectMapper;
|
import cn.wanxiang.game.module.project.dal.mysql.project.ProjectMapper;
|
||||||
import cn.wanxiang.game.module.project.dal.mysql.review.ReviewRecordMapper;
|
import cn.wanxiang.game.module.project.dal.mysql.review.ReviewRecordMapper;
|
||||||
import cn.wanxiang.game.module.project.enums.ProjectStatusEnum;
|
import cn.wanxiang.game.module.project.enums.ProjectStatusEnum;
|
||||||
|
import cn.wanxiang.game.module.project.service.publish.PublishOrchestrationService;
|
||||||
import cn.wanxiang.game.module.compliance.api.ComplianceGateApi;
|
import cn.wanxiang.game.module.compliance.api.ComplianceGateApi;
|
||||||
import cn.wanxiang.game.module.compliance.dto.GateVerdict;
|
import cn.wanxiang.game.module.compliance.dto.GateVerdict;
|
||||||
import cn.wanxiang.game.module.compliance.enums.ComplianceVerdictEnum;
|
import cn.wanxiang.game.module.compliance.enums.ComplianceVerdictEnum;
|
||||||
@ -45,6 +46,8 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
private ReviewRecordMapper reviewRecordMapper;
|
private ReviewRecordMapper reviewRecordMapper;
|
||||||
@Mock
|
@Mock
|
||||||
private ComplianceGateApi complianceGateApi; // R1 合规锁风门(发布前注入)
|
private ComplianceGateApi complianceGateApi; // R1 合规锁风门(发布前注入)
|
||||||
|
@Mock
|
||||||
|
private PublishOrchestrationService publishOrchestrationService; // §3.2 C2 发布编排(α auto-publish,APPROVE 时调用)
|
||||||
|
|
||||||
// ============================== createProject ==============================
|
// ============================== createProject ==============================
|
||||||
|
|
||||||
@ -218,22 +221,23 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
// ============================== reviewProject 流转 ==============================
|
// ============================== reviewProject 流转 ==============================
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testReviewProject_approveFromReviewing() {
|
void testReviewProject_approveFromReviewing_autoPublish() {
|
||||||
|
// D-PUB=α:APPROVE 不直接写 APPROVED(2),而是落 pass 审核记录 + 调发布编排(编排内写 PUBLISHED,本测试 mock 编排)
|
||||||
ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.REVIEWING.getStatus());
|
ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.REVIEWING.getStatus());
|
||||||
when(projectMapper.selectById(1L)).thenReturn(project);
|
when(projectMapper.selectById(1L)).thenReturn(project);
|
||||||
ReviewReqVO reqVO = reviewReq(1L, 1); // 1=通过
|
ReviewReqVO reqVO = reviewReq(1L, 1); // 1=通过
|
||||||
|
|
||||||
projectService.reviewProject(reqVO, 7L);
|
projectService.reviewProject(reqVO, 7L);
|
||||||
|
|
||||||
// 项目状态 → 已通过
|
|
||||||
ArgumentCaptor<ProjectDO> captor = ArgumentCaptor.forClass(ProjectDO.class);
|
|
||||||
verify(projectMapper).updateById(captor.capture());
|
|
||||||
assertEquals(ProjectStatusEnum.APPROVED.getStatus(), captor.getValue().getStatus());
|
|
||||||
// 落审核记录(pass)
|
// 落审核记录(pass)
|
||||||
ArgumentCaptor<ReviewRecordDO> rc = ArgumentCaptor.forClass(ReviewRecordDO.class);
|
ArgumentCaptor<ReviewRecordDO> rc = ArgumentCaptor.forClass(ReviewRecordDO.class);
|
||||||
verify(reviewRecordMapper).insert(rc.capture());
|
verify(reviewRecordMapper).insert(rc.capture());
|
||||||
assertEquals("pass", rc.getValue().getGateResult());
|
assertEquals("pass", rc.getValue().getGateResult());
|
||||||
assertEquals(7L, rc.getValue().getReviewerUserId());
|
assertEquals(7L, rc.getValue().getReviewerUserId());
|
||||||
|
// α:在同一事务内委托发布编排(项目态 → PUBLISHED 由编排承载)
|
||||||
|
verify(publishOrchestrationService).publish(project);
|
||||||
|
// APPROVE 分支不再由本方法直接写项目状态(不调 projectMapper.updateById;PUBLISHED 写入在编排内,已 mock)
|
||||||
|
verify(projectMapper, never()).updateById(any(ProjectDO.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.Operation;
|
|||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import org.springframework.cloud.openfeign.FeignClient;
|
import org.springframework.cloud.openfeign.FeignClient;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
@ -43,4 +44,19 @@ public interface RuntimePackageApi {
|
|||||||
@Parameter(name = "versionId", description = "版本 ID", required = true, example = "1024")
|
@Parameter(name = "versionId", description = "版本 ID", required = true, example = "1024")
|
||||||
CommonResult<Boolean> publish(@RequestParam("versionId") Long versionId);
|
CommonResult<Boolean> publish(@RequestParam("versionId") Long versionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按版本 ID 反查运行包状态机值(§3.1 C1 可见态第三条件:feed 写侧 PUBLISH_BASELINE enforce 依赖)
|
||||||
|
*
|
||||||
|
* 用途:feed 写侧 mode=PUBLISH_BASELINE upsert 前校验 game_runtime_package(versionId).status==1(已发布),不满足拒写。
|
||||||
|
* 委托 {@code RuntimePackageService}:显式只查 status;该版本无就绪运行包时 data 返回 null(feed 写侧视为不满足可见态拒写)。
|
||||||
|
* 状态值语义见 {@link cn.wanxiang.game.module.runtime.enums.PackageStatusEnum}(0预览就绪 1已发布 2已失效)。
|
||||||
|
*
|
||||||
|
* @param versionId 版本 ID(project.game_version.id)
|
||||||
|
* @return 运行包状态机值(CommonResult 包裹;无就绪运行包为 null)
|
||||||
|
*/
|
||||||
|
@GetMapping(PREFIX + "/get-status")
|
||||||
|
@Operation(summary = "按版本 ID 反查运行包状态机值(可见态第三条件 enforce,§3.1)")
|
||||||
|
@Parameter(name = "versionId", description = "版本 ID", required = true, example = "1024")
|
||||||
|
CommonResult<Integer> getStatus(@RequestParam("versionId") Long versionId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,41 @@
|
|||||||
|
package cn.wanxiang.game.module.runtime.api;
|
||||||
|
|
||||||
|
import cn.wanxiang.game.module.runtime.service.pkg.RuntimePackageService;
|
||||||
|
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行包发布 API 实现(黄金闭环 §3.2 C2,提供 RESTful 接口给跨模块 Feign 调用:发布编排翻包 + 可见态校验)
|
||||||
|
*
|
||||||
|
* @RestController + @Primary:与 yudao DictDataApiImpl / 既有 ProjectApiImpl 同构——单体内同进程调用走本实现,拆微服务后走 Feign(守门④/§3.2 H5)。
|
||||||
|
* 仅委托 {@link RuntimePackageService}:publish 翻包 0→1(不在此回写 version/project,那归 project 编排,Codex H6);getStatus 供 feed 写侧可见态 enforce。
|
||||||
|
*
|
||||||
|
* @author 造梦AI
|
||||||
|
*/
|
||||||
|
@RestController // 提供 RESTful API 接口,给 Feign 调用
|
||||||
|
@Validated
|
||||||
|
@Primary // 与 @FeignClient 接口同名 Bean 冲突时优先用本地实现(同进程调用就地解析,发布编排事务内本地调用)
|
||||||
|
public class RuntimePackageApiImpl implements RuntimePackageApi {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private RuntimePackageService runtimePackageService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CommonResult<Boolean> publish(Long versionId) {
|
||||||
|
// 发布翻包:委托 RuntimePackageService.publishPackage(0→1 已发布;幂等;未就绪抛 RUNTIME_PACKAGE_NOT_READY)
|
||||||
|
runtimePackageService.publishPackage(versionId);
|
||||||
|
return success(Boolean.TRUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CommonResult<Integer> getStatus(Long versionId) {
|
||||||
|
// §3.1 C1 可见态第三条件:返回 game_runtime_package(versionId).status;无就绪运行包返回 null(feed 写侧据此拒写)
|
||||||
|
return success(runtimePackageService.getPackageStatus(versionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import cn.wanxiang.game.module.runtime.dal.dataobject.pkg.RuntimePackageDO;
|
|||||||
import cn.wanxiang.game.module.runtime.dal.dataobject.session.RuntimeSessionDO;
|
import cn.wanxiang.game.module.runtime.dal.dataobject.session.RuntimeSessionDO;
|
||||||
import cn.wanxiang.game.module.runtime.enums.RuntimeSceneEnum;
|
import cn.wanxiang.game.module.runtime.enums.RuntimeSceneEnum;
|
||||||
import cn.wanxiang.game.module.runtime.service.pkg.RuntimePackageService;
|
import cn.wanxiang.game.module.runtime.service.pkg.RuntimePackageService;
|
||||||
|
import cn.wanxiang.game.module.runtime.service.pkg.store.PackageStore;
|
||||||
import cn.wanxiang.game.module.runtime.service.session.RuntimeSessionService;
|
import cn.wanxiang.game.module.runtime.service.session.RuntimeSessionService;
|
||||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||||
@ -18,9 +19,13 @@ import io.swagger.v3.oas.annotations.Parameter;
|
|||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import static cn.wanxiang.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_NOT_READY;
|
||||||
|
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -43,6 +48,12 @@ public class AppRuntimeController {
|
|||||||
@Resource
|
@Resource
|
||||||
private RuntimeSessionService runtimeSessionService;
|
private RuntimeSessionService runtimeSessionService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整包 manifest 存储抽象(§3.4 C4):MVP=DbPackageStore(读 game_runtime_package.package_json),M3 换 OSS impl 调用方不变。
|
||||||
|
*/
|
||||||
|
@Resource
|
||||||
|
private PackageStore packageStore;
|
||||||
|
|
||||||
@GetMapping("/package/{versionId}")
|
@GetMapping("/package/{versionId}")
|
||||||
@Operation(summary = "取版本运行包清单", description = "预览/试玩宿主据此渲染 iframe、桥接 SDK ←#3、做完整性校验 T-RT-15")
|
@Operation(summary = "取版本运行包清单", description = "预览/试玩宿主据此渲染 iframe、桥接 SDK ←#3、做完整性校验 T-RT-15")
|
||||||
@Parameter(name = "versionId", description = "版本 ID", required = true, example = "2048")
|
@Parameter(name = "versionId", description = "版本 ID", required = true, example = "2048")
|
||||||
@ -56,6 +67,28 @@ public class AppRuntimeController {
|
|||||||
return success(RuntimeConvert.toPackageRespVO(pkg));
|
return success(RuntimeConvert.toPackageRespVO(pkg));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping(value = "/package/{versionId}/manifest", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
@Operation(summary = "取版本运行包 manifest 原始 JSON",
|
||||||
|
description = "§3.4 C4:返回 manifest 原始 JSON 文本,不包 CommonResult、不 parse/re-serialize;宿主对响应文本算 sha256 与 checksum 严格比对后注入运行容器")
|
||||||
|
@Parameter(name = "versionId", description = "版本 ID", required = true, example = "2048")
|
||||||
|
@Parameter(name = "scene", description = "preview 创作者预览 / play 玩家试玩", example = "play")
|
||||||
|
public String getPackageManifestRaw(
|
||||||
|
@PathVariable("versionId") Long versionId,
|
||||||
|
@RequestParam(value = "scene", required = false, defaultValue = "play") String scene) {
|
||||||
|
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||||
|
// 取包门禁与 GET /package/{versionId} 同源:复用 getPackageManifest 做 scene+status 权威判定 + 预览归属
|
||||||
|
// (未就绪/未发布/越权预览由 Service 抛对应错误码,经全局异常处理返回 CommonResult JSON,与取包端点一致)
|
||||||
|
runtimePackageService.getPackageManifest(versionId, scene, userId);
|
||||||
|
// 经 PackageStore 读 manifest 原始 JSON;返回类型为 String → GlobalResponseBodyHandler 不拦截(只拦 CommonResult),
|
||||||
|
// StringHttpMessageConverter 原样写出字节、不重序列化(保字节一致性,§3.4 C4)
|
||||||
|
String manifest = packageStore.getManifest(versionId);
|
||||||
|
if (!StringUtils.hasText(manifest)) {
|
||||||
|
// 门禁通过但 manifest 未落库(PackageFactory 未写 package_json):视为运行包未就绪,与取包统一错误码
|
||||||
|
throw exception(RUNTIME_PACKAGE_NOT_READY);
|
||||||
|
}
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/session/start")
|
@PostMapping("/session/start")
|
||||||
@Operation(summary = "开始试玩会话", description = "宿主在游戏 game_start(←#3) 时调用;clientPlayToken 幂等")
|
@Operation(summary = "开始试玩会话", description = "宿主在游戏 game_start(←#3) 时调用;clientPlayToken 幂等")
|
||||||
public CommonResult<SessionStartRespVO> startSession(@Valid @RequestBody SessionStartReqVO reqVO) {
|
public CommonResult<SessionStartRespVO> startSession(@Valid @RequestBody SessionStartReqVO reqVO) {
|
||||||
|
|||||||
@ -41,9 +41,11 @@ public class RuntimeConvert {
|
|||||||
}
|
}
|
||||||
// 平铺字段(gameId/versionId/templateId/packageUrl/entry/runtimeVersion/preloadPolicy/bundleSize/checksum 同名直拷)
|
// 平铺字段(gameId/versionId/templateId/packageUrl/entry/runtimeVersion/preloadPolicy/bundleSize/checksum 同名直拷)
|
||||||
RuntimePackageRespVO vo = BeanUtils.toBean(pkg, RuntimePackageRespVO.class);
|
RuntimePackageRespVO vo = BeanUtils.toBean(pkg, RuntimePackageRespVO.class);
|
||||||
// GAP-2 分支A(纯派生,零 DDL):manifestUrl 由 packageUrl 同前缀派生 .../manifest.json,
|
// manifestUrl 解析(§3.4 C4 退场契约):
|
||||||
// 宿主据此先 fetch manifest 并按 checksum 做 sha256 完整性校验,通过后再注入运行容器(T-RT-15)。
|
// - MVP 无 OSS(packageUrl 空,整包存 DB):指向 manifest 端点 /app-api/runtime/package/{versionId}/manifest,
|
||||||
vo.setManifestUrl(deriveManifestUrl(pkg.getPackageUrl()));
|
// 宿主 fetch 该端点取原始 JSON、对响应文本算 sha256 与 checksum 比对后注入(相对 URL 由前端 resolve 到 API base,§2.5);
|
||||||
|
// - M3 接 OSS(packageUrl 非空):回落 GAP-2 分支A 由 packageUrl 同前缀派生 .../manifest.json(OSS/CDN 版本化 URL)。
|
||||||
|
vo.setManifestUrl(resolveManifestUrl(pkg));
|
||||||
// 沙箱策略单独组装:sandboxAttr 直拷,allowOrigins 逗号串拆 List
|
// 沙箱策略单独组装:sandboxAttr 直拷,allowOrigins 逗号串拆 List
|
||||||
SandboxPolicyVO sandbox = new SandboxPolicyVO();
|
SandboxPolicyVO sandbox = new SandboxPolicyVO();
|
||||||
sandbox.setSandboxAttr(pkg.getSandboxAttr());
|
sandbox.setSandboxAttr(pkg.getSandboxAttr());
|
||||||
@ -52,6 +54,21 @@ public class RuntimeConvert {
|
|||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 manifestUrl(§3.4 C4 退场契约:MVP 走 DB 端点 / M3 走 OSS 派生)
|
||||||
|
*
|
||||||
|
* @param pkg 运行包 DO(取 packageUrl 判 MVP/OSS,versionId 拼端点路径)
|
||||||
|
* @return manifestUrl:packageUrl 空 → manifest 端点相对路径;非空 → 同前缀派生 .json
|
||||||
|
*/
|
||||||
|
public static String resolveManifestUrl(RuntimePackageDO pkg) {
|
||||||
|
if (!StringUtils.hasText(pkg.getPackageUrl())) {
|
||||||
|
// MVP 无 OSS:整包存 DB,manifestUrl 指向原样服务端点(相对路径,前端 resolve 到 API base)
|
||||||
|
return "/app-api/runtime/package/" + pkg.getVersionId() + "/manifest";
|
||||||
|
}
|
||||||
|
// M3 OSS:保留 GAP-2 分支A 派生
|
||||||
|
return deriveManifestUrl(pkg.getPackageUrl());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 由 packageUrl 同前缀派生 manifest.json 取包清单 URL(GAP-2 分支A,纯派生不增列)
|
* 由 packageUrl 同前缀派生 manifest.json 取包清单 URL(GAP-2 分支A,纯派生不增列)
|
||||||
*
|
*
|
||||||
|
|||||||
@ -79,5 +79,13 @@ public class RuntimePackageDO extends TenantBaseDO {
|
|||||||
* 0→1 由 publish 对接点回写,并同步置 project.game_version.status=3。
|
* 0→1 由 publish 对接点回写,并同步置 project.game_version.status=3。
|
||||||
*/
|
*/
|
||||||
private Integer status;
|
private Integer status;
|
||||||
|
/**
|
||||||
|
* 整包 manifest 原始 JSON(黄金闭环 §3.4 C4,V10 新增 package_json LONGTEXT)
|
||||||
|
*
|
||||||
|
* MVP 无 OSS:整包存 DB,由 {@code PackageStore} 的 DB impl 读写本列(M3 接 OSS 后下线,退场契约)。
|
||||||
|
* manifest 端点 GET /app-api/runtime/package/{versionId}/manifest 经 PackageStore 读出原样返回,
|
||||||
|
* 不包 CommonResult、不 parse/re-serialize(任何重序列化都破坏字节一致性导致宿主 sha256 校验失败)。
|
||||||
|
*/
|
||||||
|
private String packageJson;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,4 +38,15 @@ public interface RuntimePackageService {
|
|||||||
*/
|
*/
|
||||||
void publishPackage(Long versionId);
|
void publishPackage(Long versionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取版本运行包状态机值(§3.1 C1 可见态第三条件:供 feed 写侧 PUBLISH_BASELINE enforce)
|
||||||
|
*
|
||||||
|
* 只读:显式取 game_runtime_package(versionId).status;该版本无就绪运行包返回 null(feed 写侧据此视为不满足可见态拒写)。
|
||||||
|
* 状态值语义见 {@link cn.wanxiang.game.module.runtime.enums.PackageStatusEnum}。
|
||||||
|
*
|
||||||
|
* @param versionId 版本 ID
|
||||||
|
* @return 运行包状态机值;无就绪运行包返回 null
|
||||||
|
*/
|
||||||
|
Integer getPackageStatus(Long versionId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -74,13 +74,19 @@ public class RuntimePackageServiceImpl implements RuntimePackageService {
|
|||||||
update.setId(pkg.getId());
|
update.setId(pkg.getId());
|
||||||
update.setStatus(PackageStatusEnum.PUBLISHED.getStatus());
|
update.setStatus(PackageStatusEnum.PUBLISHED.getStatus());
|
||||||
runtimePackageMapper.updateById(update);
|
runtimePackageMapper.updateById(update);
|
||||||
// TODO 对接点【project 发布编排回写】:发布态写入链路最后一跳——回写 project.game_version.status=3(已发布)。
|
// 注(黄金闭环 §3.2 C2 / Codex H6):version/project 回写不在此——发布态写入链路由 project 的 PublishOrchestrationService 承载,
|
||||||
// 待 project 模块 -api(Feign:ProjectVersionApi#updateVersionStatusToPublished(versionId))就绪后在此补全;
|
// 其在同一本地事务内先调本方法翻包 0→1,再显式置 game_version.status=3 与 project.status=PUBLISHED(4),本模块只负责翻包态。
|
||||||
// 注意:跨模块远程调用须放在本地事务提交之后,并做超时/失败补偿(重试或运营告警)。
|
log.info("[publishPackage] 运行包已置已发布 versionId={}, pkgId={}(version/project 回写归 project 发布编排)",
|
||||||
log.info("[publishPackage] 运行包已置已发布 versionId={}, pkgId={}(project.game_version 回写为 TODO 对接点)",
|
|
||||||
versionId, pkg.getId());
|
versionId, pkg.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer getPackageStatus(Long versionId) {
|
||||||
|
// §3.1 C1 可见态第三条件只读:取该版本运行包 status;无就绪运行包返回 null(feed 写侧据此拒写)
|
||||||
|
RuntimePackageDO pkg = runtimePackageMapper.selectByVersionId(versionId);
|
||||||
|
return pkg == null ? null : pkg.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 预览归属校验:仅版本 owner(创作者本人)可预览未发布运行包
|
* 预览归属校验:仅版本 owner(创作者本人)可预览未发布运行包
|
||||||
*
|
*
|
||||||
|
|||||||
@ -0,0 +1,47 @@
|
|||||||
|
package cn.wanxiang.game.module.runtime.service.pkg.store;
|
||||||
|
|
||||||
|
import cn.wanxiang.game.module.runtime.dal.dataobject.pkg.RuntimePackageDO;
|
||||||
|
import cn.wanxiang.game.module.runtime.dal.mysql.pkg.RuntimePackageMapper;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整包 manifest 存储 DB 实现(黄金闭环 §3.4 C4,MVP 无 OSS:读写 game_runtime_package.package_json)
|
||||||
|
*
|
||||||
|
* 退场契约:M3 接 OSS 后新增 OSS impl 并切换 @Primary,本类下线,{@link PackageStore} 调用方不变。
|
||||||
|
*
|
||||||
|
* @author 造梦AI
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class DbPackageStore implements PackageStore {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private RuntimePackageMapper runtimePackageMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void putManifest(Long versionId, String json) {
|
||||||
|
// 落包写入:按 versionId 定位运行包行,写 package_json 原始 JSON(不 parse/re-serialize,保字节一致 §3.4 C4)
|
||||||
|
RuntimePackageDO pkg = runtimePackageMapper.selectByVersionId(versionId);
|
||||||
|
if (pkg == null) {
|
||||||
|
// 防御:无运行包行无法落 manifest(PackageFactory 应先建 game_runtime_package 再写 manifest)
|
||||||
|
log.warn("[putManifest] 无对应运行包,跳过写入 manifest versionId={}", versionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
RuntimePackageDO update = new RuntimePackageDO();
|
||||||
|
update.setId(pkg.getId());
|
||||||
|
update.setPackageJson(json);
|
||||||
|
runtimePackageMapper.updateById(update);
|
||||||
|
log.info("[putManifest] manifest 已写入 DB versionId={}, pkgId={}, jsonLen={}",
|
||||||
|
versionId, pkg.getId(), json == null ? 0 : json.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getManifest(Long versionId) {
|
||||||
|
// 服务读出:取该版本运行包 package_json 原样字符串;无运行包/未写入返回 null(端点据此判 404/未就绪)
|
||||||
|
RuntimePackageDO pkg = runtimePackageMapper.selectByVersionId(versionId);
|
||||||
|
return pkg == null ? null : pkg.getPackageJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package cn.wanxiang.game.module.runtime.service.pkg.store;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整包 manifest 存储抽象(黄金闭环 §3.4 C4 新增,退场契约隔离层)
|
||||||
|
*
|
||||||
|
* 目的:把「整包 manifest 的存取介质」与上层取包/服务逻辑解耦——
|
||||||
|
* MVP impl={@link DbPackageStore}(读写 game_runtime_package.package_json,无 OSS);
|
||||||
|
* M3 接 OSS 后换 OSS impl(读写 OSS/CDN 版本化对象),调用方/契约不变(退场契约)。
|
||||||
|
*
|
||||||
|
* 字节一致性约束(§3.4 C4):putManifest 写入与 getManifest 读出必须是同一原始字节串,
|
||||||
|
* 不得 parse/re-serialize/加换行——manifest 端点据此原样返回,宿主对响应文本算 sha256 与 checksum 严格比对。
|
||||||
|
*
|
||||||
|
* @author 造梦AI
|
||||||
|
*/
|
||||||
|
public interface PackageStore {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入指定版本的整包 manifest 原始 JSON(PackageFactory 落包时调用)
|
||||||
|
*
|
||||||
|
* @param versionId 版本 ID(定位 game_runtime_package 行)
|
||||||
|
* @param json manifest 原始 JSON 字符串(原样字节,调用方据相同字节计算 checksum)
|
||||||
|
*/
|
||||||
|
void putManifest(Long versionId, String json);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取指定版本的整包 manifest 原始 JSON(manifest 端点服务时调用)
|
||||||
|
*
|
||||||
|
* @param versionId 版本 ID
|
||||||
|
* @return manifest 原始 JSON 字符串;该版本无运行包或未写入 package_json 时返回 null
|
||||||
|
*/
|
||||||
|
String getManifest(Long versionId);
|
||||||
|
|
||||||
|
}
|
||||||
@ -74,4 +74,21 @@ class RuntimeConvertTest {
|
|||||||
assertNull(RuntimeConvert.toPackageRespVO(null));
|
assertNull(RuntimeConvert.toPackageRespVO(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================== §3.4 C4:MVP 无 OSS manifestUrl 指向 DB 端点 ==============================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testToPackageRespVO_mvpEmptyPackageUrl_manifestUrlPointsToEndpoint() {
|
||||||
|
// MVP 无 OSS(packageUrl 空,整包存 DB):manifestUrl 指向 /app-api/runtime/package/{versionId}/manifest 端点
|
||||||
|
RuntimePackageDO pkg = new RuntimePackageDO();
|
||||||
|
pkg.setGameId(1024L);
|
||||||
|
pkg.setVersionId(2048L);
|
||||||
|
pkg.setPackageUrl(""); // MVP:包存 DB,packageUrl 为空
|
||||||
|
pkg.setSandboxAttr("allow-scripts");
|
||||||
|
pkg.setAllowOrigins("");
|
||||||
|
|
||||||
|
RuntimePackageRespVO vo = RuntimeConvert.toPackageRespVO(pkg);
|
||||||
|
|
||||||
|
assertEquals("/app-api/runtime/package/2048/manifest", vo.getManifestUrl());
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,10 @@ import java.util.Map;
|
|||||||
@Data
|
@Data
|
||||||
public class EnvelopeReqVO {
|
public class EnvelopeReqVO {
|
||||||
|
|
||||||
|
@Schema(description = "事件实例 UUID(必填,每事件实例唯一,幂等真身,对应 #5 eventId / game_telemetry_event.event_id 的 uk_event_id 唯一键)。前端 crypto.randomUUID() 生成;重放同 eventId 不重复计数", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440000")
|
||||||
|
@NotBlank(message = "eventId 不能为空")
|
||||||
|
private String eventId;
|
||||||
|
|
||||||
@Schema(description = "事件名(须在 #5 eventRegistry 登记,否则计 rejected)", requiredMode = Schema.RequiredMode.REQUIRED, example = "game_play_start")
|
@Schema(description = "事件名(须在 #5 eventRegistry 登记,否则计 rejected)", requiredMode = Schema.RequiredMode.REQUIRED, example = "game_play_start")
|
||||||
@NotBlank(message = "事件名不能为空")
|
@NotBlank(message = "事件名不能为空")
|
||||||
private String event;
|
private String event;
|
||||||
|
|||||||
@ -26,6 +26,13 @@ public class TelemetryEventDO extends TenantBaseDO {
|
|||||||
* 事件记录 ID
|
* 事件记录 ID
|
||||||
*/
|
*/
|
||||||
private Long id;
|
private Long id;
|
||||||
|
/**
|
||||||
|
* 事件实例 UUID(黄金闭环 §3.5 C5,V10 新增 event_id,幂等真身)
|
||||||
|
*
|
||||||
|
* 对应表 uk_event_id 唯一键(V10 起替换旧 uk_dedup);前端 crypto.randomUUID() 生成,逐事件实例全局唯一。
|
||||||
|
* ingestBatch 据「真插入(affected>0)」判定幂等:同 eventId 重复投递只落一条、只累加一次,彻底解决「同毫秒同名误吞 + 重放重计数」。
|
||||||
|
*/
|
||||||
|
private String eventId;
|
||||||
/**
|
/**
|
||||||
* 事件名(须在 #5 events.schema.json eventRegistry 登记,对应 {@link cn.wanxiang.game.module.telemetry.enums.TelemetryEventEnum})
|
* 事件名(须在 #5 events.schema.json eventRegistry 登记,对应 {@link cn.wanxiang.game.module.telemetry.enums.TelemetryEventEnum})
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -2,16 +2,31 @@ package cn.wanxiang.game.module.telemetry.dal.mysql.event;
|
|||||||
|
|
||||||
import cn.wanxiang.game.module.telemetry.dal.dataobject.event.TelemetryEventDO;
|
import cn.wanxiang.game.module.telemetry.dal.dataobject.event.TelemetryEventDO;
|
||||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
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 org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 遥测原始事件 Mapper
|
* 遥测原始事件 Mapper
|
||||||
*
|
*
|
||||||
* 消费侧幂等落库的写入入口;幂等去重依赖表 uk_dedup(trace_id, event, ts),
|
* 黄金闭环 §3.5 C5 同步写:幂等去重依赖表 uk_event_id(event_id)(V10 起替换旧 uk_dedup)。
|
||||||
* 重复信封落库时的 INSERT IGNORE 语义在消费侧实现(留 MQ 对接点)。
|
* 消费侧(同步写本地事务)据「按 event_id 预查 + 真插入」判定幂等:同 eventId 重复投递只落一条、只聚合一次。
|
||||||
*
|
*
|
||||||
* @author 绘境AI
|
* @author 绘境AI
|
||||||
*/
|
*/
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface TelemetryEventMapper extends BaseMapperX<TelemetryEventDO> {
|
public interface TelemetryEventMapper extends BaseMapperX<TelemetryEventDO> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按事件实例 UUID 取原始事件(§3.5 C5 幂等预查:命中即已落库,跳过累加,避免重放重计数)
|
||||||
|
*
|
||||||
|
* 命中 uk_event_id 唯一键;同一信封重复投递时返回已落行,消费侧据此判定「非真插入」不再累加。
|
||||||
|
*
|
||||||
|
* @param eventId 事件实例 UUID
|
||||||
|
* @return 已落原始事件;不存在返回 null
|
||||||
|
*/
|
||||||
|
default TelemetryEventDO selectByEventId(String eventId) {
|
||||||
|
return selectOne(new LambdaQueryWrapperX<TelemetryEventDO>()
|
||||||
|
.eq(TelemetryEventDO::getEventId, eventId));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
|||||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 游戏维度聚合 Mapper(看板数据源只读端)
|
* 游戏维度聚合 Mapper(看板数据源只读端)
|
||||||
*
|
*
|
||||||
@ -46,4 +48,19 @@ public interface GameStatMapper extends BaseMapperX<GameStatDO> {
|
|||||||
.last("LIMIT 1"));
|
.last("LIMIT 1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 游戏×统计日 取聚合行(黄金闭环 §3.5 C5 同步写累加:命中 uk_game_date 唯一键)
|
||||||
|
*
|
||||||
|
* 消费侧同步写在本地事务内据此做「无则建(首事件初值)/ 有则行级累加」的 upsert。
|
||||||
|
*
|
||||||
|
* @param gameId 游戏 ID
|
||||||
|
* @param statDate 统计日
|
||||||
|
* @return 聚合行;不存在返回 null
|
||||||
|
*/
|
||||||
|
default GameStatDO selectByGameAndDate(Long gameId, LocalDate statDate) {
|
||||||
|
return selectOne(new LambdaQueryWrapperX<GameStatDO>()
|
||||||
|
.eq(GameStatDO::getGameId, gameId)
|
||||||
|
.eq(GameStatDO::getStatDate, statDate));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,22 +3,38 @@ package cn.wanxiang.game.module.telemetry.service.event;
|
|||||||
import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EnvelopeReqVO;
|
import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EnvelopeReqVO;
|
||||||
import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EventBatchReqVO;
|
import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EventBatchReqVO;
|
||||||
import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EventBatchResultVO;
|
import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EventBatchResultVO;
|
||||||
|
import cn.wanxiang.game.module.telemetry.dal.dataobject.event.TelemetryEventDO;
|
||||||
|
import cn.wanxiang.game.module.telemetry.dal.dataobject.stat.GameStatDO;
|
||||||
|
import cn.wanxiang.game.module.telemetry.dal.mysql.event.TelemetryEventMapper;
|
||||||
|
import cn.wanxiang.game.module.telemetry.dal.mysql.stat.GameStatMapper;
|
||||||
|
import cn.wanxiang.game.module.telemetry.enums.EventProcessStatusEnum;
|
||||||
import cn.wanxiang.game.module.telemetry.enums.TelemetryEventEnum;
|
import cn.wanxiang.game.module.telemetry.enums.TelemetryEventEnum;
|
||||||
|
import cn.wanxiang.game.module.feed.api.FeedApi;
|
||||||
|
import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static cn.wanxiang.game.module.telemetry.enums.ErrorCodeConstants.TELEMETRY_BATCH_SIZE_EXCEEDED;
|
import static cn.wanxiang.game.module.telemetry.enums.ErrorCodeConstants.TELEMETRY_BATCH_SIZE_EXCEEDED;
|
||||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 事件摄取 Service 实现(上报通道)
|
* 事件摄取 Service 实现(上报通道,黄金闭环 §3.5 C5 同步写本地事务,最核心数据回路)
|
||||||
*
|
*
|
||||||
* 全部轻量校验规则(批量上限/信封必填/事件名注册表)在此承载,可单测、可追溯。
|
* v1→v2 关键改动(HJ-EXEC-006 §3.5):把原 dispatchToMq() 占位换成同步本地 @Transactional 落库 + 聚合 + 回灌:
|
||||||
* MQ 投递为外部依赖,本骨架留清晰中文 TODO 对接点(不 mock、不实现);
|
* 逐信封按 event_id INSERT(uk_event_id 幂等)→ 仅「真插入(affected>0)」才累加 game_telemetry_game_stat
|
||||||
* 落原始表与增量聚合在消费侧异步完成,详见 mq.consumer 对接点说明。
|
* → 算 quality_score → 调 FeedApi.upsertRank(QUALITY_REFRESH) 回灌 feed rank → 置该事件 process_status=1 已聚合;
|
||||||
|
* 任一步失败整体回滚(事件不计)。MQ producer/consumer 仅留契约 + TODO(不实现)。
|
||||||
*
|
*
|
||||||
* @author 绘境AI
|
* @author 绘境AI
|
||||||
*/
|
*/
|
||||||
@ -29,16 +45,33 @@ public class EventIngestServiceImpl implements EventIngestService {
|
|||||||
/** 单次批量上限(契约 maxItems=200;超限整体 rejected,前端分批重传) */
|
/** 单次批量上限(契约 maxItems=200;超限整体 rejected,前端分批重传) */
|
||||||
private static final int MAX_BATCH_SIZE = 200;
|
private static final int MAX_BATCH_SIZE = 200;
|
||||||
|
|
||||||
|
/** quality_score 权重(§3.5 口径):完玩率 60 + like 率 20 + share 率 20,clamp[0,100] */
|
||||||
|
private static final BigDecimal W_COMPLETE = new BigDecimal("60");
|
||||||
|
private static final BigDecimal W_LIKE = new BigDecimal("20");
|
||||||
|
private static final BigDecimal W_SHARE = new BigDecimal("20");
|
||||||
|
private static final BigDecimal SCORE_MAX = new BigDecimal("100");
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private TelemetryEventMapper telemetryEventMapper;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private GameStatMapper gameStatMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO 对接点(MQ 生产):注入 RocketMQ 生产者,投递 topic=telemetry-event。
|
* feed 算分回灌 seam(仅依赖 -api,§3.5 C5)。MVP 单体由 feed 模块 FeedApiImpl(@RestController @Primary) 就地解析,
|
||||||
* message body = { envelope: EnvelopeReqVO },keys=traceId,tag=event|perf,orderly=false;
|
* 在本同步写 @Transactional 内本地调用(非 Feign),抛错则整批回滚。
|
||||||
* 投递失败本地落兜底表后定时补投(不丢事件)。待 yudao-spring-boot-starter-mq 的 producer 就绪后注入。
|
*/
|
||||||
* 见 contracts/api-schemas/telemetry.yaml#x-mq-contracts.producer。
|
@Resource
|
||||||
|
private FeedApi feedApi;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* TODO 对接点(MQ 生产,§3.5 仅留契约+TODO 不实现):拆"上报快速 ACK + 异步消费聚合"时,
|
||||||
|
* 注入 RocketMQ 生产者投递 topic=telemetry-event(幂等键=eventId,对应 uk_event_id),消费侧复用本类同步写聚合逻辑。
|
||||||
|
* 见 contracts/api-schemas/telemetry.yaml#x-mq-contracts。MVP 同步写已满足闭环,不引入 MQ。
|
||||||
*/
|
*/
|
||||||
// @Resource
|
|
||||||
// private RocketMQTemplate rocketMQTemplate;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class) // §3.5 C5 同步写:落库+聚合+回灌+置已聚合在同一本地事务,任一步失败整批回滚
|
||||||
public EventBatchResultVO ingestBatch(EventBatchReqVO reqVO, Long userId) {
|
public EventBatchResultVO ingestBatch(EventBatchReqVO reqVO, Long userId) {
|
||||||
// 批量上限:超限整体拒绝(统一错误码出口,前端据此分批重传)
|
// 批量上限:超限整体拒绝(统一错误码出口,前端据此分批重传)
|
||||||
if (reqVO.getBatch().size() > MAX_BATCH_SIZE) {
|
if (reqVO.getBatch().size() > MAX_BATCH_SIZE) {
|
||||||
@ -49,18 +82,17 @@ public class EventIngestServiceImpl implements EventIngestService {
|
|||||||
|
|
||||||
int accepted = 0;
|
int accepted = 0;
|
||||||
int rejected = 0;
|
int rejected = 0;
|
||||||
// 部分成功语义:逐条轻量校验,通过则投递 MQ,失败计 rejected(不中断整批)
|
// 部分成功语义:逐条轻量校验,通过则同步写(落库+聚合+回灌),失败计 rejected(不中断整批)
|
||||||
for (EnvelopeReqVO envelope : reqVO.getBatch()) {
|
for (EnvelopeReqVO envelope : reqVO.getBatch()) {
|
||||||
if (!isEnvelopeValid(envelope)) {
|
if (!isEnvelopeValid(envelope)) {
|
||||||
rejected++;
|
rejected++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 投递 MQ(快速 ACK 后异步入库 + 聚合,幂等键 = traceId+event+ts)
|
ingestOne(envelope, userId);
|
||||||
dispatchToMq(envelope, userId);
|
|
||||||
accepted++;
|
accepted++;
|
||||||
}
|
}
|
||||||
// 关键链路日志:受理成功记 INFO,含回执 traceId 与计数,便于全链路排障
|
// 关键链路日志:受理成功记 INFO,含回执 traceId 与计数,便于全链路排障
|
||||||
log.info("[ingestBatch] 批量受理完成 batchTraceId={}, accepted={}, rejected={}", batchTraceId, accepted, rejected);
|
log.info("[ingestBatch] 批量同步写完成 batchTraceId={}, accepted={}, rejected={}", batchTraceId, accepted, rejected);
|
||||||
|
|
||||||
EventBatchResultVO result = new EventBatchResultVO();
|
EventBatchResultVO result = new EventBatchResultVO();
|
||||||
result.setAccepted(accepted);
|
result.setAccepted(accepted);
|
||||||
@ -70,31 +102,176 @@ public class EventIngestServiceImpl implements EventIngestService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class) // 兜底通道单条同步写:与 ingestBatch 同口径(落库+聚合+回灌原子)
|
||||||
public Boolean ingestBeacon(EnvelopeReqVO reqVO, Long userId) {
|
public Boolean ingestBeacon(EnvelopeReqVO reqVO, Long userId) {
|
||||||
// 兜底通道:页面卸载场景,永远受理成功(不阻塞前端卸载)
|
// 兜底通道:页面卸载场景,校验失败不抛错(返回受理成功,不触发前端异常);
|
||||||
// 校验失败也不抛错,仅记日志/丢弃,避免 sendBeacon 因 4xx 触发前端异常
|
// 校验通过则同步写——若同步写抛错(极少:DB 故障),由全局异常处理返回错误响应,
|
||||||
if (isEnvelopeValid(reqVO)) {
|
// 但 sendBeacon 为 fire-and-forget 不读响应、不阻塞卸载(事务该回滚仍回滚,保证不脏写)。
|
||||||
dispatchToMq(reqVO, userId);
|
if (!isEnvelopeValid(reqVO)) {
|
||||||
} else {
|
log.warn("[ingestBeacon] 信封校验未通过,丢弃 eventId={}, event={}, traceId={}",
|
||||||
log.warn("[ingestBeacon] 信封校验未通过,丢弃 event={}, traceId={}", reqVO.getEvent(), reqVO.getTraceId());
|
reqVO == null ? null : reqVO.getEventId(),
|
||||||
|
reqVO == null ? null : reqVO.getEvent(),
|
||||||
|
reqVO == null ? null : reqVO.getTraceId());
|
||||||
|
return Boolean.TRUE;
|
||||||
}
|
}
|
||||||
|
ingestOne(reqVO, userId);
|
||||||
return Boolean.TRUE;
|
return Boolean.TRUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================== 私有校验/投递辅助 ==============================
|
// ============================== 同步写核心(§3.5 C5)==============================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 信封轻量校验:必填字段非空 + 事件名在注册表登记
|
* 单条信封同步写:落原始事件(eventId 幂等)→ 仅真插入才累加 stat → 算 quality_score → 回灌 feed → 置已聚合
|
||||||
*
|
*
|
||||||
* 注:必填字段(event/schemaVersion/ts/traceId)已由 VO @NotBlank/@NotNull 在 Controller 层校验;
|
* 调用前提:处于 @Transactional 内(由 ingestBatch / ingestOneTx 提供),任一步抛错则整体回滚。
|
||||||
* 此处再做一次"事件名注册表"业务校验,未登记即视为 rejected(与契约 rejected 语义一致)。
|
*
|
||||||
|
* @param envelope 单条信封(已通过 isEnvelopeValid)
|
||||||
|
* @param userId 当前登录用户 ID(补全 user_id 用,匿名为 null)
|
||||||
|
*/
|
||||||
|
private void ingestOne(EnvelopeReqVO envelope, Long userId) {
|
||||||
|
// 1) 幂等预查:同 eventId 已落库 → 非真插入,直接跳过(不重复累加,杜绝重放重计数)
|
||||||
|
if (telemetryEventMapper.selectByEventId(envelope.getEventId()) != null) {
|
||||||
|
log.info("[ingestOne] 幂等命中,跳过累加 eventId={}, event={}", envelope.getEventId(), envelope.getEvent());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 2) 真插入原始事件(uk_event_id 为硬幂等后盾:并发双插会抛唯一键冲突 → 事务回滚,仍不重复计数)
|
||||||
|
TelemetryEventDO eventDO = buildEventDO(envelope, userId);
|
||||||
|
telemetryEventMapper.insert(eventDO);
|
||||||
|
|
||||||
|
// 3) 仅当事件落到具体游戏(game_id 非空)才累加聚合;否则只落事件、置已聚合(无聚合落点)
|
||||||
|
Long gameId = parseLong(envelope.getContext() == null ? null : envelope.getContext().getGameId());
|
||||||
|
if (gameId != null) {
|
||||||
|
// 统计日:以事件 ts(毫秒)所在系统日期归集(与 stat_date 按日聚合粒度一致)
|
||||||
|
LocalDate statDate = Instant.ofEpochMilli(envelope.getTs()).atZone(ZoneId.systemDefault()).toLocalDate();
|
||||||
|
GameStatDO stat = accumulateStat(gameId, statDate, envelope);
|
||||||
|
// 4) 算 quality_score(DECIMAL5,2)写回 stat
|
||||||
|
BigDecimal qualityScore = computeQualityScore(stat);
|
||||||
|
GameStatDO scoreUpdate = new GameStatDO();
|
||||||
|
scoreUpdate.setId(stat.getId());
|
||||||
|
scoreUpdate.setQualityScore(qualityScore);
|
||||||
|
gameStatMapper.updateById(scoreUpdate);
|
||||||
|
// 5) 回灌 feed rank(QUALITY_REFRESH):quality_score 精度转 DECIMAL10,4;sort_score 由 feed 侧据既有 boost 权威重算
|
||||||
|
FeedRankUpsertReqDTO feedReq = new FeedRankUpsertReqDTO();
|
||||||
|
feedReq.setGameId(gameId);
|
||||||
|
feedReq.setVersionId(parseLong(envelope.getContext().getVersionId()));
|
||||||
|
feedReq.setZoneId(0L); // 回灌默认混合流 zone=0(与发布基线默认 zone 对齐)
|
||||||
|
feedReq.setQualityScore(qualityScore.doubleValue()); // DECIMAL5,2 → Double,feed 侧落 DECIMAL10,4
|
||||||
|
feedReq.setSortScore(qualityScore.doubleValue()); // 占位;feed 侧 QUALITY_REFRESH 据既有 boost 重算 sort_score(保留 boost)
|
||||||
|
feedReq.setMode(FeedRankUpsertReqDTO.UpsertMode.QUALITY_REFRESH);
|
||||||
|
feedApi.upsertRank(feedReq);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) 置该事件 process_status=1 已聚合(无 game_id 的事件同样置已聚合:已落库无需再聚合)
|
||||||
|
TelemetryEventDO statusUpdate = new TelemetryEventDO();
|
||||||
|
statusUpdate.setId(eventDO.getId());
|
||||||
|
statusUpdate.setProcessStatus(EventProcessStatusEnum.AGGREGATED.getStatus());
|
||||||
|
telemetryEventMapper.updateById(statusUpdate);
|
||||||
|
log.info("[ingestOne] 同步写完成 eventId={}, event={}, gameId={}", envelope.getEventId(), envelope.getEvent(), gameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 (game_id, stat_date) 累加聚合行(无则建首事件初值,有则行级累加;同事务内顺序累加可见前序写)
|
||||||
|
*
|
||||||
|
* 按事件名映射计数增量(§3.5 计数口径):
|
||||||
|
* game_play_start → play_count;game_play_end → play_end_count(+completed_count if completed)+total_duration_ms;
|
||||||
|
* like → like_count;share → share_count。其余事件不增计数(仅落原始事件)。
|
||||||
|
*
|
||||||
|
* @param gameId 游戏 ID
|
||||||
|
* @param statDate 统计日
|
||||||
|
* @param envelope 单条信封
|
||||||
|
* @return 累加后的聚合行(含 id,供算分回写)
|
||||||
|
*/
|
||||||
|
private GameStatDO accumulateStat(Long gameId, LocalDate statDate, EnvelopeReqVO envelope) {
|
||||||
|
String event = envelope.getEvent();
|
||||||
|
Map<String, Object> props = envelope.getProps();
|
||||||
|
// 计数增量
|
||||||
|
long dPlay = TelemetryEventEnum.GAME_PLAY_START.getEvent().equals(event) ? 1L : 0L;
|
||||||
|
long dPlayEnd = TelemetryEventEnum.GAME_PLAY_END.getEvent().equals(event) ? 1L : 0L;
|
||||||
|
long dCompleted = (dPlayEnd == 1L && isCompleted(props)) ? 1L : 0L;
|
||||||
|
long dDuration = dPlayEnd == 1L ? parseDurationMs(props) : 0L;
|
||||||
|
long dLike = TelemetryEventEnum.LIKE.getEvent().equals(event) ? 1L : 0L;
|
||||||
|
long dShare = TelemetryEventEnum.SHARE.getEvent().equals(event) ? 1L : 0L;
|
||||||
|
|
||||||
|
GameStatDO existing = gameStatMapper.selectByGameAndDate(gameId, statDate);
|
||||||
|
if (existing == null) {
|
||||||
|
// 首事件:建聚合行,计数为本事件增量(quality_score 先 0,下一步算分回写)
|
||||||
|
GameStatDO stat = new GameStatDO();
|
||||||
|
stat.setGameId(gameId);
|
||||||
|
stat.setStatDate(statDate);
|
||||||
|
stat.setPlayCount(dPlay);
|
||||||
|
stat.setPlayEndCount(dPlayEnd);
|
||||||
|
stat.setCompletedCount(dCompleted);
|
||||||
|
stat.setTotalDurationMs(dDuration);
|
||||||
|
stat.setLikeCount(dLike);
|
||||||
|
stat.setFavoriteCount(0L);
|
||||||
|
stat.setShareCount(dShare);
|
||||||
|
stat.setReportCount(0L);
|
||||||
|
stat.setLoadFailCount(0L);
|
||||||
|
stat.setQualityScore(BigDecimal.ZERO);
|
||||||
|
gameStatMapper.insert(stat);
|
||||||
|
return stat;
|
||||||
|
}
|
||||||
|
// 已存在:行级累加(仅累加本事件涉及的计数列;其余列只显式回写既有值,避免被 null 覆盖)
|
||||||
|
GameStatDO update = new GameStatDO();
|
||||||
|
update.setId(existing.getId());
|
||||||
|
update.setPlayCount(nz(existing.getPlayCount()) + dPlay);
|
||||||
|
update.setPlayEndCount(nz(existing.getPlayEndCount()) + dPlayEnd);
|
||||||
|
update.setCompletedCount(nz(existing.getCompletedCount()) + dCompleted);
|
||||||
|
update.setTotalDurationMs(nz(existing.getTotalDurationMs()) + dDuration);
|
||||||
|
update.setLikeCount(nz(existing.getLikeCount()) + dLike);
|
||||||
|
update.setShareCount(nz(existing.getShareCount()) + dShare);
|
||||||
|
gameStatMapper.updateById(update);
|
||||||
|
// 回填累加后的值到 existing,供算分(避免再查一次库)
|
||||||
|
existing.setPlayCount(update.getPlayCount());
|
||||||
|
existing.setPlayEndCount(update.getPlayEndCount());
|
||||||
|
existing.setCompletedCount(update.getCompletedCount());
|
||||||
|
existing.setTotalDurationMs(update.getTotalDurationMs());
|
||||||
|
existing.setLikeCount(update.getLikeCount());
|
||||||
|
existing.setShareCount(update.getShareCount());
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 算 quality_score(§3.5 口径):clamp(60×完玩率 + 20×like率 + 20×share率, 0, 100),DECIMAL5,2
|
||||||
|
*
|
||||||
|
* 完玩率 = completed_count / play_end_count(§3.5 明确);
|
||||||
|
* like率 = like_count / play_count、share率 = share_count / play_count(每次试玩的互动密度;分母为 0 则该率记 0,避免除零)。
|
||||||
|
* 注:like率/share率分母口径(play_count)为本实现按"互动密度"取定,§3.5 未显式给出分母——见交付说明待主 agent 复核。
|
||||||
|
*
|
||||||
|
* @param stat 聚合行
|
||||||
|
* @return quality_score(DECIMAL5,2,clamp[0,100])
|
||||||
|
*/
|
||||||
|
private BigDecimal computeQualityScore(GameStatDO stat) {
|
||||||
|
BigDecimal completeRate = ratio(stat.getCompletedCount(), stat.getPlayEndCount());
|
||||||
|
BigDecimal likeRate = ratio(stat.getLikeCount(), stat.getPlayCount());
|
||||||
|
BigDecimal shareRate = ratio(stat.getShareCount(), stat.getPlayCount());
|
||||||
|
BigDecimal score = W_COMPLETE.multiply(completeRate)
|
||||||
|
.add(W_LIKE.multiply(likeRate))
|
||||||
|
.add(W_SHARE.multiply(shareRate));
|
||||||
|
// clamp[0,100],量化到 2 位小数(DECIMAL5,2)
|
||||||
|
if (score.compareTo(BigDecimal.ZERO) < 0) {
|
||||||
|
score = BigDecimal.ZERO;
|
||||||
|
} else if (score.compareTo(SCORE_MAX) > 0) {
|
||||||
|
score = SCORE_MAX;
|
||||||
|
}
|
||||||
|
return score.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== 私有校验/工具 ==============================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 信封轻量校验:必填字段非空(含 eventId)+ 事件名在注册表登记
|
||||||
|
*
|
||||||
|
* 注:必填字段(eventId/event/schemaVersion/ts/traceId)已由 VO @NotBlank/@NotNull 在 Controller 层校验;
|
||||||
|
* 此处再做一次兜底(beacon 通道未走 @Valid 级联时也安全)+ 事件名注册表业务校验,未通过即 rejected。
|
||||||
*
|
*
|
||||||
* @param envelope 单条信封
|
* @param envelope 单条信封
|
||||||
* @return true=通过可投递;false=拒绝
|
* @return true=通过可同步写;false=拒绝
|
||||||
*/
|
*/
|
||||||
private boolean isEnvelopeValid(EnvelopeReqVO envelope) {
|
private boolean isEnvelopeValid(EnvelopeReqVO envelope) {
|
||||||
// 信封必填兜底(beacon 通道未走 @Valid 级联时也安全)
|
// 信封必填兜底(eventId 缺失计 rejected,§3.5 幂等真身必填)
|
||||||
if (envelope == null
|
if (envelope == null
|
||||||
|
|| !StringUtils.hasText(envelope.getEventId())
|
||||||
|| !StringUtils.hasText(envelope.getEvent())
|
|| !StringUtils.hasText(envelope.getEvent())
|
||||||
|| !StringUtils.hasText(envelope.getSchemaVersion())
|
|| !StringUtils.hasText(envelope.getSchemaVersion())
|
||||||
|| envelope.getTs() == null
|
|| envelope.getTs() == null
|
||||||
@ -106,21 +283,115 @@ public class EventIngestServiceImpl implements EventIngestService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 投递事件到 MQ(对接点)
|
* 信封 → 原始事件 DO(落库前组装;props 透传原始 JSON 由前端保证为 JSON 文本)
|
||||||
*
|
|
||||||
* TODO 对接点(MQ 生产):此处应调用 RocketMQ 生产者投递 topic=telemetry-event。
|
|
||||||
* - 幂等键 = (traceId, event, ts),对应原始表 uk_dedup;
|
|
||||||
* - 投递失败需本地落兜底表后定时补投(不丢事件);
|
|
||||||
* - 消费侧落 game_telemetry_event + 增量聚合 game_telemetry_game_stat + 回灌 feed/project。
|
|
||||||
* 骨架阶段仅记 DEBUG 日志占位,待 MQ producer 就绪后替换为真实投递。
|
|
||||||
*
|
*
|
||||||
* @param envelope 单条信封
|
* @param envelope 单条信封
|
||||||
* @param userId 当前登录用户 ID(补全信封 user 用,匿名为 null)
|
* @param userId 当前登录用户 ID(匿名为 null)
|
||||||
|
* @return 原始事件 DO(process_status 初始 0 待聚合,由同步写置 1)
|
||||||
*/
|
*/
|
||||||
private void dispatchToMq(EnvelopeReqVO envelope, Long userId) {
|
private TelemetryEventDO buildEventDO(EnvelopeReqVO envelope, Long userId) {
|
||||||
// TODO 对接点:rocketMQTemplate.asyncSend("telemetry-event", buildMessage(envelope, userId), callback)
|
TelemetryEventDO eventDO = new TelemetryEventDO();
|
||||||
log.debug("[dispatchToMq] TODO 待投递 MQ event={}, traceId={}, userId={}",
|
eventDO.setEventId(envelope.getEventId());
|
||||||
envelope.getEvent(), envelope.getTraceId(), userId);
|
eventDO.setEvent(envelope.getEvent());
|
||||||
|
eventDO.setSchemaVersion(envelope.getSchemaVersion());
|
||||||
|
eventDO.setTs(envelope.getTs());
|
||||||
|
eventDO.setTraceId(envelope.getTraceId());
|
||||||
|
eventDO.setSessionId(StringUtils.hasText(envelope.getSessionId()) ? envelope.getSessionId() : "");
|
||||||
|
// user_id:优先信封 user.userId,回退当前登录态 userId(匿名为 null)
|
||||||
|
eventDO.setUserId(resolveUserId(envelope, userId));
|
||||||
|
eventDO.setAnonId(envelope.getUser() != null && StringUtils.hasText(envelope.getUser().getAnonId())
|
||||||
|
? envelope.getUser().getAnonId() : "");
|
||||||
|
if (envelope.getContext() != null) {
|
||||||
|
eventDO.setGameId(parseLong(envelope.getContext().getGameId()));
|
||||||
|
eventDO.setVersionId(parseLong(envelope.getContext().getVersionId()));
|
||||||
|
eventDO.setChannel(StringUtils.hasText(envelope.getContext().getChannel()) ? envelope.getContext().getChannel() : "");
|
||||||
|
eventDO.setDeviceType(StringUtils.hasText(envelope.getContext().getDeviceType()) ? envelope.getContext().getDeviceType() : "");
|
||||||
|
} else {
|
||||||
|
eventDO.setChannel("");
|
||||||
|
eventDO.setDeviceType("");
|
||||||
|
}
|
||||||
|
// props:DO 以字符串承载(DB JSON 列);MVP 不深解析,存原始 toString(前端按 #5 传 JSON)
|
||||||
|
eventDO.setProps(envelope.getProps() == null ? null : envelope.getProps().toString());
|
||||||
|
eventDO.setProcessStatus(EventProcessStatusEnum.PENDING.getStatus());
|
||||||
|
return eventDO;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析事件 user_id:优先信封 user.userId(string→Long),回退当前登录 userId
|
||||||
|
*/
|
||||||
|
private Long resolveUserId(EnvelopeReqVO envelope, Long userId) {
|
||||||
|
if (envelope.getUser() != null && StringUtils.hasText(envelope.getUser().getUserId())) {
|
||||||
|
Long parsed = parseLong(envelope.getUser().getUserId());
|
||||||
|
if (parsed != null) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* props.completed 是否为真(兼容 Boolean / "true" 字符串)
|
||||||
|
*/
|
||||||
|
private boolean isCompleted(Map<String, Object> props) {
|
||||||
|
if (props == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Object completed = props.get("completed");
|
||||||
|
if (completed instanceof Boolean) {
|
||||||
|
return (Boolean) completed;
|
||||||
|
}
|
||||||
|
return completed != null && Boolean.parseBoolean(completed.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 props.duration_ms(兼容 Number / 数字字符串;缺失或非法记 0)
|
||||||
|
*/
|
||||||
|
private long parseDurationMs(Map<String, Object> props) {
|
||||||
|
if (props == null) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
Object duration = props.get("duration_ms");
|
||||||
|
if (duration instanceof Number) {
|
||||||
|
return ((Number) duration).longValue();
|
||||||
|
}
|
||||||
|
if (duration != null) {
|
||||||
|
try {
|
||||||
|
return Long.parseLong(duration.toString());
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比率 = 分子/分母(DECIMAL 安全;分母≤0 记 0,避免除零),保留 6 位中间精度
|
||||||
|
*/
|
||||||
|
private BigDecimal ratio(Long numerator, Long denominator) {
|
||||||
|
long den = nz(denominator);
|
||||||
|
if (den <= 0L) {
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
return BigDecimal.valueOf(nz(numerator)).divide(BigDecimal.valueOf(den), 6, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Long 空安全转 long(null→0) */
|
||||||
|
private static long nz(Long v) {
|
||||||
|
return v == null ? 0L : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字符串安全转 Long(空/非法返回 null;信封 context.gameId/versionId、user.userId 均为 string)
|
||||||
|
*/
|
||||||
|
private static Long parseLong(String s) {
|
||||||
|
if (!StringUtils.hasText(s)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Long.parseLong(s.trim());
|
||||||
|
} catch (NumberFormatException ex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -3,22 +3,38 @@ package cn.wanxiang.game.module.telemetry.service.event;
|
|||||||
import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EnvelopeReqVO;
|
import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EnvelopeReqVO;
|
||||||
import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EventBatchReqVO;
|
import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EventBatchReqVO;
|
||||||
import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EventBatchResultVO;
|
import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EventBatchResultVO;
|
||||||
|
import cn.wanxiang.game.module.telemetry.dal.dataobject.event.TelemetryEventDO;
|
||||||
|
import cn.wanxiang.game.module.telemetry.dal.dataobject.stat.GameStatDO;
|
||||||
|
import cn.wanxiang.game.module.telemetry.dal.mysql.event.TelemetryEventMapper;
|
||||||
|
import cn.wanxiang.game.module.telemetry.dal.mysql.stat.GameStatMapper;
|
||||||
|
import cn.wanxiang.game.module.feed.api.FeedApi;
|
||||||
|
import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO;
|
||||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||||
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
|
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
import static cn.wanxiang.game.module.telemetry.enums.ErrorCodeConstants.TELEMETRY_BATCH_SIZE_EXCEEDED;
|
import static cn.wanxiang.game.module.telemetry.enums.ErrorCodeConstants.TELEMETRY_BATCH_SIZE_EXCEEDED;
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link EventIngestServiceImpl} 单元测试(纯 Mockito,不依赖 DB/MQ)
|
* {@link EventIngestServiceImpl} 单元测试(纯 Mockito,不依赖 DB/MQ)
|
||||||
*
|
*
|
||||||
* 覆盖核心摄取规则守卫:批量上限、信封轻量校验(事件名注册表/必填)、部分成功计数、beacon 恒受理。
|
* 覆盖黄金闭环 §3.5 C5 同步写:批量上限、信封轻量校验(eventId/event 必填 + 注册表)、部分成功计数、
|
||||||
* 这些规则是上报通道的契约语义,必须有测试兜底。MQ 投递为对接点(仅日志占位),不在单测断言范围。
|
* eventId 幂等跳过、计数累加、quality_score 算分 + feed 回灌、beacon 恒受理。
|
||||||
*
|
*
|
||||||
* @author 绘境AI
|
* @author 绘境AI
|
||||||
*/
|
*/
|
||||||
@ -27,11 +43,18 @@ class EventIngestServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
@InjectMocks
|
@InjectMocks
|
||||||
private EventIngestServiceImpl eventIngestService;
|
private EventIngestServiceImpl eventIngestService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private TelemetryEventMapper telemetryEventMapper;
|
||||||
|
@Mock
|
||||||
|
private GameStatMapper gameStatMapper;
|
||||||
|
@Mock
|
||||||
|
private FeedApi feedApi; // §3.5 算分回灌 seam
|
||||||
|
|
||||||
// ============================== ingestBatch 批量上限 ==============================
|
// ============================== ingestBatch 批量上限 ==============================
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testIngestBatch_sizeExceeded() {
|
void testIngestBatch_sizeExceeded() {
|
||||||
// 201 条(> 上限 200)→ 整体拒绝
|
// 201 条(> 上限 200)→ 整体拒绝(在落库前即抛,无 DB 交互)
|
||||||
EventBatchReqVO reqVO = new EventBatchReqVO();
|
EventBatchReqVO reqVO = new EventBatchReqVO();
|
||||||
List<EnvelopeReqVO> batch = new ArrayList<>();
|
List<EnvelopeReqVO> batch = new ArrayList<>();
|
||||||
for (int i = 0; i < 201; i++) {
|
for (int i = 0; i < 201; i++) {
|
||||||
@ -42,21 +65,23 @@ class EventIngestServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
ServiceException ex = assertThrows(ServiceException.class,
|
ServiceException ex = assertThrows(ServiceException.class,
|
||||||
() -> eventIngestService.ingestBatch(reqVO, 99L));
|
() -> eventIngestService.ingestBatch(reqVO, 99L));
|
||||||
assertEquals(TELEMETRY_BATCH_SIZE_EXCEEDED.getCode(), ex.getCode());
|
assertEquals(TELEMETRY_BATCH_SIZE_EXCEEDED.getCode(), ex.getCode());
|
||||||
|
verify(telemetryEventMapper, never()).insert(any(TelemetryEventDO.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================== ingestBatch 部分成功(注册表校验) ==============================
|
// ============================== ingestBatch 部分成功(eventId/注册表/必填校验) ==============================
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testIngestBatch_partialAcceptedRejected() {
|
void testIngestBatch_partialAcceptedRejected() {
|
||||||
// 2 条已登记事件 + 1 条未登记事件 + 1 条信封字段缺失 → accepted=2, rejected=2
|
// 2 条合法(无 game_id,仅落事件不聚合)+ 1 条未登记 + 1 条 eventId 缺失 → accepted=2, rejected=2
|
||||||
|
when(telemetryEventMapper.selectByEventId(anyString())).thenReturn(null); // 均为真插入
|
||||||
EventBatchReqVO reqVO = new EventBatchReqVO();
|
EventBatchReqVO reqVO = new EventBatchReqVO();
|
||||||
List<EnvelopeReqVO> batch = new ArrayList<>();
|
List<EnvelopeReqVO> batch = new ArrayList<>();
|
||||||
batch.add(validEnvelope("game_play_start", "t1")); // 已登记
|
batch.add(validEnvelope("game_play_start", "t1")); // 已登记 + eventId 齐全
|
||||||
batch.add(validEnvelope("like", "t2")); // 已登记
|
batch.add(validEnvelope("like", "t2")); // 已登记 + eventId 齐全
|
||||||
batch.add(validEnvelope("not_registered_evt", "t3")); // 未登记 → rejected
|
batch.add(validEnvelope("not_registered_evt", "t3")); // 未登记 → rejected
|
||||||
EnvelopeReqVO missing = validEnvelope("share", "t4");
|
EnvelopeReqVO missingEventId = validEnvelope("share", "t4");
|
||||||
missing.setTraceId(""); // traceId 缺失 → rejected
|
missingEventId.setEventId(""); // eventId 缺失 → rejected(§3.5 幂等真身必填)
|
||||||
batch.add(missing);
|
batch.add(missingEventId);
|
||||||
reqVO.setBatch(batch);
|
reqVO.setBatch(batch);
|
||||||
|
|
||||||
EventBatchResultVO result = eventIngestService.ingestBatch(reqVO, 99L);
|
EventBatchResultVO result = eventIngestService.ingestBatch(reqVO, 99L);
|
||||||
@ -64,28 +89,115 @@ class EventIngestServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
assertEquals(2, result.getAccepted());
|
assertEquals(2, result.getAccepted());
|
||||||
assertEquals(2, result.getRejected());
|
assertEquals(2, result.getRejected());
|
||||||
assertEquals("t1", result.getTraceId()); // 回执复用首条 traceId
|
assertEquals("t1", result.getTraceId()); // 回执复用首条 traceId
|
||||||
|
verify(telemetryEventMapper, times(2)).insert(any(TelemetryEventDO.class)); // 仅 2 条真插入
|
||||||
|
verifyNoInteractions(feedApi); // 无 game_id → 不回灌
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== eventId 幂等(重放不重复计数) ==============================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testIngestOne_idempotentSkipOnDuplicateEventId() {
|
||||||
|
// 同 eventId 已落库 → 非真插入,跳过:不再 insert、不累加、不回灌(重放同批次不重复计数)
|
||||||
|
EnvelopeReqVO env = gameEnvelope("game_play_start", "t1", "1024", "2048", null);
|
||||||
|
when(telemetryEventMapper.selectByEventId(env.getEventId())).thenReturn(new TelemetryEventDO());
|
||||||
|
|
||||||
|
EventBatchResultVO result = eventIngestService.ingestBatch(batchOf(env), 99L);
|
||||||
|
|
||||||
|
assertEquals(1, result.getAccepted()); // 校验通过仍计 accepted(已受理),但不重复写
|
||||||
|
verify(telemetryEventMapper, never()).insert(any(TelemetryEventDO.class));
|
||||||
|
verify(gameStatMapper, never()).insert(any(GameStatDO.class));
|
||||||
|
verify(gameStatMapper, never()).updateById(any(GameStatDO.class));
|
||||||
|
verifyNoInteractions(feedApi);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== 计数累加 + 算分 + 回灌 ==============================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testIngestOne_playEndAccumulatesAndRefreshesFeed() {
|
||||||
|
// game_play_end(completed=true,duration=5000) 首事件 → 建 stat 行;算分回灌 feed(QUALITY_REFRESH)
|
||||||
|
Map<String, Object> props = new HashMap<>();
|
||||||
|
props.put("completed", true);
|
||||||
|
props.put("duration_ms", 5000);
|
||||||
|
EnvelopeReqVO env = gameEnvelope("game_play_end", "t1", "1024", "2048", props);
|
||||||
|
when(telemetryEventMapper.selectByEventId(env.getEventId())).thenReturn(null);
|
||||||
|
when(gameStatMapper.selectByGameAndDate(eq(1024L), any())).thenReturn(null); // 首事件无聚合行
|
||||||
|
|
||||||
|
eventIngestService.ingestBatch(batchOf(env), 99L);
|
||||||
|
|
||||||
|
// 建 stat 行:play_end_count=1, completed_count=1, total_duration_ms=5000
|
||||||
|
ArgumentCaptor<GameStatDO> statCaptor = ArgumentCaptor.forClass(GameStatDO.class);
|
||||||
|
verify(gameStatMapper).insert(statCaptor.capture());
|
||||||
|
GameStatDO stat = statCaptor.getValue();
|
||||||
|
assertEquals(1024L, stat.getGameId());
|
||||||
|
assertEquals(1L, stat.getPlayEndCount());
|
||||||
|
assertEquals(1L, stat.getCompletedCount());
|
||||||
|
assertEquals(5000L, stat.getTotalDurationMs());
|
||||||
|
// 回灌 feed:mode=QUALITY_REFRESH,gameId/versionId 透传;quality_score=60×完玩率(1.0)=60
|
||||||
|
ArgumentCaptor<FeedRankUpsertReqDTO> feedCaptor = ArgumentCaptor.forClass(FeedRankUpsertReqDTO.class);
|
||||||
|
verify(feedApi).upsertRank(feedCaptor.capture());
|
||||||
|
FeedRankUpsertReqDTO feedReq = feedCaptor.getValue();
|
||||||
|
assertEquals(FeedRankUpsertReqDTO.UpsertMode.QUALITY_REFRESH, feedReq.getMode());
|
||||||
|
assertEquals(1024L, feedReq.getGameId());
|
||||||
|
assertEquals(2048L, feedReq.getVersionId());
|
||||||
|
assertEquals(60.0D, feedReq.getQualityScore(), 0.001); // 完玩率 1/1=1 → 60×1=60
|
||||||
|
// 置事件已聚合
|
||||||
|
verify(telemetryEventMapper).updateById(any(TelemetryEventDO.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testIngestOne_likeAccumulatesOnExistingStat() {
|
||||||
|
// like 事件命中既有 stat(play_count=2,like_count=1)→ like_count 累加为 2;quality_score 含 like 率
|
||||||
|
GameStatDO existing = new GameStatDO();
|
||||||
|
existing.setId(7L);
|
||||||
|
existing.setPlayCount(2L);
|
||||||
|
existing.setPlayEndCount(2L);
|
||||||
|
existing.setCompletedCount(0L);
|
||||||
|
existing.setLikeCount(1L);
|
||||||
|
existing.setShareCount(0L);
|
||||||
|
existing.setTotalDurationMs(0L);
|
||||||
|
EnvelopeReqVO env = gameEnvelope("like", "t1", "1024", "2048", null);
|
||||||
|
when(telemetryEventMapper.selectByEventId(env.getEventId())).thenReturn(null);
|
||||||
|
when(gameStatMapper.selectByGameAndDate(eq(1024L), any())).thenReturn(existing);
|
||||||
|
|
||||||
|
eventIngestService.ingestBatch(batchOf(env), 99L);
|
||||||
|
|
||||||
|
// 累加:like_count 1→2(仅累加 like 涉及列)
|
||||||
|
ArgumentCaptor<GameStatDO> statCaptor = ArgumentCaptor.forClass(GameStatDO.class);
|
||||||
|
// 两次 updateById:一次累加计数、一次写 quality_score(捕获首个累加调用)
|
||||||
|
verify(gameStatMapper, atLeastOnce()).updateById(statCaptor.capture());
|
||||||
|
GameStatDO accUpdate = statCaptor.getAllValues().get(0);
|
||||||
|
assertEquals(7L, accUpdate.getId());
|
||||||
|
assertEquals(2L, accUpdate.getLikeCount()); // like 累加
|
||||||
|
assertEquals(2L, accUpdate.getPlayCount()); // play_count 保持(显式回写既有值)
|
||||||
|
verify(feedApi).upsertRank(any(FeedRankUpsertReqDTO.class)); // 有 game_id → 回灌
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================== ingestBeacon 恒受理 ==============================
|
// ============================== ingestBeacon 恒受理 ==============================
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testIngestBeacon_validAlwaysTrue() {
|
void testIngestBeacon_invalidStillTrue() {
|
||||||
Boolean ok = eventIngestService.ingestBeacon(validEnvelope("perf_first_screen", "t-beacon"), 99L);
|
// 未登记事件:beacon 通道不抛错,仍恒返回 true(不阻塞前端卸载,无 DB 交互)
|
||||||
|
Boolean ok = eventIngestService.ingestBeacon(validEnvelope("bad_event", "t-beacon"), null);
|
||||||
assertTrue(ok);
|
assertTrue(ok);
|
||||||
|
verify(telemetryEventMapper, never()).insert(any(TelemetryEventDO.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testIngestBeacon_invalidStillTrue() {
|
void testIngestBeacon_validNoGameIdAcceptedTrue() {
|
||||||
// 未登记事件:beacon 通道不抛错,仍恒返回 true(不阻塞前端卸载)
|
// 合法但无 game_id:落事件、置已聚合、不回灌,返回 true
|
||||||
Boolean ok = eventIngestService.ingestBeacon(validEnvelope("bad_event", "t-beacon"), null);
|
when(telemetryEventMapper.selectByEventId(anyString())).thenReturn(null);
|
||||||
|
Boolean ok = eventIngestService.ingestBeacon(validEnvelope("perf_first_screen", "t-beacon"), 99L);
|
||||||
assertTrue(ok);
|
assertTrue(ok);
|
||||||
|
verify(telemetryEventMapper).insert(any(TelemetryEventDO.class));
|
||||||
|
verifyNoInteractions(feedApi);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================== 测试夹具 ==============================
|
// ============================== 测试夹具 ==============================
|
||||||
|
|
||||||
/** 构造一条信封必填项齐全的事件 */
|
/** 构造一条信封必填项齐全的事件(含 eventId;无 context,不触发聚合) */
|
||||||
private static EnvelopeReqVO validEnvelope(String event, String traceId) {
|
private static EnvelopeReqVO validEnvelope(String event, String traceId) {
|
||||||
EnvelopeReqVO envelope = new EnvelopeReqVO();
|
EnvelopeReqVO envelope = new EnvelopeReqVO();
|
||||||
|
envelope.setEventId(UUID.randomUUID().toString());
|
||||||
envelope.setEvent(event);
|
envelope.setEvent(event);
|
||||||
envelope.setSchemaVersion("v1");
|
envelope.setSchemaVersion("v1");
|
||||||
envelope.setTs(1733600000000L);
|
envelope.setTs(1733600000000L);
|
||||||
@ -93,4 +205,24 @@ class EventIngestServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
return envelope;
|
return envelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 构造带 context(game_id/version_id) + props 的事件(触发聚合 + 回灌) */
|
||||||
|
private static EnvelopeReqVO gameEnvelope(String event, String traceId, String gameId, String versionId, Map<String, Object> props) {
|
||||||
|
EnvelopeReqVO envelope = validEnvelope(event, traceId);
|
||||||
|
EnvelopeReqVO.ContextVO ctx = new EnvelopeReqVO.ContextVO();
|
||||||
|
ctx.setGameId(gameId);
|
||||||
|
ctx.setVersionId(versionId);
|
||||||
|
envelope.setContext(ctx);
|
||||||
|
envelope.setProps(props);
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单条信封打成批量入参 */
|
||||||
|
private static EventBatchReqVO batchOf(EnvelopeReqVO env) {
|
||||||
|
EventBatchReqVO reqVO = new EventBatchReqVO();
|
||||||
|
List<EnvelopeReqVO> batch = new ArrayList<>();
|
||||||
|
batch.add(env);
|
||||||
|
reqVO.setBatch(batch);
|
||||||
|
return reqVO;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user