From 6d6bb52d9da70e9b11a8b66ad31f4b67da12e82d Mon Sep 17 00:00:00 2001 From: lili Date: Wed, 1 Jul 2026 09:59:31 -0700 Subject: [PATCH] =?UTF-8?q?feat(game-cloud):=20Sentinel=20=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=20QPS=20=E7=AA=81=E5=8F=91=E4=BF=9D=E6=8A=A4(FLOW=5FG?= =?UTF-8?q?RADE=5FQPS=C2=B7=E8=A7=84=E5=88=99=E7=83=AD=E6=BA=90=20Nacos)(?= =?UTF-8?q?=E5=88=87=E7=89=87=E4=B8=80=20=E9=98=B6=E6=AE=B5=E3=80=87=20C2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 入队入口套 @SentinelResource(aigc:gen:admission)做入口速率闸,与 DB 三门(per-creator 配额)分层; 越限 blockHandler 优雅拒非 500。并发≤15 权威=C3 consumeThreadMax(非本 Task)。规则改 Nacos dataId 热生效。 单测锁降级语义;真限流集成验证随后端窗口。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../module/aigc/enums/ErrorCodeConstants.java | 9 ++- .../game-module-aigc-server/pom.xml | 15 ++++ .../aigc/admission/GenAdmissionResource.java | 71 +++++++++++++++++++ .../service/task/AigcTaskServiceImpl.java | 21 +++++- .../admission/GenAdmissionResourceTest.java | 43 +++++++++++ .../service/task/AigcControlPlaneTest.java | 4 ++ .../service/task/AigcTaskServiceImplTest.java | 3 + .../src/main/resources/application.yaml | 15 ++++ 8 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/admission/GenAdmissionResource.java create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/admission/GenAdmissionResourceTest.java diff --git a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/ErrorCodeConstants.java b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/ErrorCodeConstants.java index da1a79fe..f11fb710 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/ErrorCodeConstants.java +++ b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/ErrorCodeConstants.java @@ -7,7 +7,7 @@ import com.wanxiang.huijing.framework.common.exception.ErrorCode; * * aigc 模块,独占 1-101-***-*** 段(见 .agents/rules/engineering-conventions.md §1.3)。 * 约定:禁止与其它模块错误码段重叠;新增错误码在此登记。 - * 段内细分:000 任务基础校验 / 001 生成控制平面(W-G1 组A · D12+GP9)/ 002 状态机非法流转(取值对齐契约 aigc.yaml)/ 003 Dify 回调(HJ-AGENT-LOOP-EXEC-001 §8)。 + * 段内细分:000 任务基础校验 / 001 生成控制平面(W-G1 组A · D12+GP9 + C2 Sentinel 入口速率闸)/ 002 状态机非法流转(取值对齐契约 aigc.yaml)/ 003 Dify 回调(HJ-AGENT-LOOP-EXEC-001 §8)。 * * @author 绘境AI */ @@ -44,6 +44,13 @@ public interface ErrorCodeConstants { * 避免 new-api 抖动时正常用户被误标"内容不安全/系统异常"而流失;故消息独立为中性提示,归因 tag 仍记 llm_error(见日志)。 */ ErrorCode AIGC_LLM_SAFETY_ERROR = new ErrorCode(1_101_001_005, "安全检查暂不可用,请稍后重试"); + /** + * 入口 QPS 突发闸拦截(切片一 阶段〇 C2):Sentinel FLOW_GRADE_QPS(规则热源 Nacos dataId sentinel-gen-flow-rules) + * 判入队速率超限 → blockHandler 优雅转此业务码(经全局 GlobalExceptionHandler 转 CommonResult.error,HTTP 仍 200、 + * body.code 非 0),绝不 500、不静默丢。与门①②③(per-creator 业务配额)分层:本码 = 入口速率闸削洪峰; + * 「在跑生成并发≤15」由 C3 RocketMQ consumeThreadMax 保证、不由本门保证(三层正交)。 + */ + ErrorCode AIGC_ADMISSION_RATE_LIMITED = new ErrorCode(1_101_001_006, "生成服务繁忙(入队速率超限),请稍后重试"); // ========== 生成任务状态机 1-101-002-*** ========== /** 取消非法:仅非终态(queued/running)可取消(契约 aigc.yaml cancel:错误码 1-101-002-001) */ diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml b/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml index 7f5cafbb..02b1fccf 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml +++ b/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml @@ -54,6 +54,21 @@ lili-mac spike 1.1.2 实测纠偏:**绝不加 /anthropic 前缀**,否则命中 SPA catch-all 返 HTML 假挂起)驱动 MiniMax-M3 走 Anthropic Messages 协议(thinking 原生分离),与 OpenAI-兼容路 flag 旁挂。 --> org.springframework.aispring-ai-anthropic + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-sentinel + + + com.alibaba.csp + sentinel-datasource-nacos + + com.wanxiang diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/admission/GenAdmissionResource.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/admission/GenAdmissionResource.java new file mode 100644 index 00000000..8f21fec3 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/admission/GenAdmissionResource.java @@ -0,0 +1,71 @@ +package com.wanxiang.huijing.game.module.aigc.admission; + +import com.alibaba.csp.sentinel.annotation.SentinelResource; +import com.alibaba.csp.sentinel.slots.block.BlockException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_ADMISSION_RATE_LIMITED; +import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception; + +/** + * 生成入队的 Sentinel 资源门 —— 入口 QPS 突发保护(FLOW_GRADE_QPS,防洪峰)。切片一 阶段〇 C2。 + * + *

职责边界(经 Opus 评审纠正,别把空体 admit() 当缺陷):本门统计「每秒入队请求数」,不是「在跑生成并发」。 + * 空体入口的 entry/exit 是纳秒级,thread-grade 统计不到真正在跑的生成——那发生在 admit() 返回之后的 RocketMQ + * 消费线程里。故「在跑生成并发≤15」由 C3 消费者 consumeThreadMax=15 保证,不由本门保证。 + * + *

三层正交: + *

    + *
  • 入口速率(本门 · Sentinel FLOW_GRADE_QPS):每秒入队请求数上限,削洪峰;
  • + *
  • 在跑并发(C3 · RocketMQ consumeThreadMax=15):同时在跑的生成数结构性上限;
  • + *
  • 业务配额(既有 DB 三门):per-creator×level 日额度 + per-creator 并发 + 全局背压,在 admit() 之后执行。
  • + *
+ * + *

越限行为:Sentinel 抛 {@link BlockException} → {@link #onAdmissionBlocked} 优雅转成业务异常 + * {@code AIGC_ADMISSION_RATE_LIMITED}「生成服务繁忙…请稍后重试」(经全局 GlobalExceptionHandler 转 CommonResult.error, + * HTTP 仍 200、body.code 非 0),绝不 500、不静默丢。 + * + *

规则源:Nacos dataId {@code sentinel-gen-flow-rules}(JSON),改规则即热生效、不重启; + * 其回滚杠杆(调大 count / 删 dataId)独立于控制平面 {@code aigc.control-plane.enabled} 开关。 + * + * @author 绘境AI + */ +@Slf4j +@Component +public class GenAdmissionResource { + + /** 生成准入资源名(须与 Nacos dataId sentinel-gen-flow-rules 里的 resource 字段字面对齐)。 */ + public static final String RESOURCE = "aigc:gen:admission"; + + /** + * 入口速率闸:受 Sentinel {@code FLOW_GRADE_QPS} 规则约束(入队 QPS 超限即 block)。 + * 通过则直接返回(调用方继续走 DB 三门 + 入队);被 block 则由 Sentinel 切面转调 {@link #onAdmissionBlocked}。 + * + *

空体是设计,不是缺陷:{@code FLOW_GRADE_QPS} 只统计「每秒通过本资源的次数」(与方法耗时无关), + * 空体正合适——让 Sentinel 计入队 QPS 削洪峰。业务准入(配额)在调用方 DB 三门;在跑并发≤15 在 C3 consumeThreadMax,均不在此。 + */ + @SentinelResource(value = RESOURCE, blockHandler = "onAdmissionBlocked") + public void admit() { + // 有意留空:见方法注释——本资源只为 Sentinel 统计入队 QPS 服务,不承载任何业务逻辑(业务准入在调用方 DB 三门)。 + } + + /** + * 越入口 QPS 闸的优雅降级(Sentinel blockHandler)。 + * + *

Sentinel 约定:blockHandler 签名 = 原方法参数列表 + 末尾追加一个 {@link BlockException},返回类型与原方法一致(void), + * 且可见性 public、与原方法同类——否则切面找不到 blockHandler、退化为直接向上抛 BlockException(会被当 500)。 + * admit() 无参,故此处仅一个 BlockException 参。 + * + * @param ex Sentinel 抛出的限流/降级异常(如 FlowException 等 BlockException 子类) + */ + public void onAdmissionBlocked(BlockException ex) { + // 可追溯日志(创始人铁律:错误路径必须留痕)——记资源名 + block 类型(哪种 Sentinel 规则触发,如 FlowException=流控)。 + // 刻意不调 ex.getRule():Sentinel 1.8.9 FlowException.getRule() 在 rule 为空时会 rule.as(..) 抛 NPE, + // 若该 NPE 冒泡会把「优雅限流」渲染成 500——与本门「绝不 500、不静默丢」相悖;exception 类名恒可取、已足够定位规则类型。 + log.warn("[aigc-admission] Sentinel 入口 QPS 闸拦截:入队速率超限,拒绝新入队请求 resource={} blockType={}", + RESOURCE, ex.getClass().getSimpleName()); + // 优雅转业务异常(错误码段 aigc 控制平面 1-101-001-006),前端得「繁忙,请稍后重试」提示;绝不 500、不静默丢。 + throw exception(AIGC_ADMISSION_RATE_LIMITED); + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/task/AigcTaskServiceImpl.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/task/AigcTaskServiceImpl.java index c2451e51..7e1ac3e9 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/task/AigcTaskServiceImpl.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/task/AigcTaskServiceImpl.java @@ -77,6 +77,15 @@ public class AigcTaskServiceImpl implements AigcTaskService { @Resource private ObjectProvider configApiProvider; + /** + * 入口 QPS 突发闸(Sentinel FLOW_GRADE_QPS,规则热源 Nacos;切片一 阶段〇 C2)。 + * 与上方控制平面软注入件不同:GenAdmissionResource 是无条件 @Component(恒在席),故直接 @Resource 硬注入 + * (同 aigcTaskMapper/playerApi),不走 ObjectProvider。职责 = 入队入口速率闸削洪峰,与 DB 三门(业务配额) + * / C3 consumeThreadMax(在跑并发≤15)三层正交,详见 {@link GenAdmissionResource} 类注释。 + */ + @Resource + private com.wanxiang.huijing.game.module.aigc.admission.GenAdmissionResource genAdmissionResource; + /** * v0 会员档位占位值 = L1(执行版 §5.1/B3):AigcGenerateReqVO 无 level 字段、会员档数据模型未建, * 提交侧统一落 L1,避免为未建会员体系造孤儿字段。分档逻辑 v0 即可用(§8 手塞 level=2 读 L2 档单测覆盖)。 @@ -289,7 +298,10 @@ public class AigcTaskServiceImpl implements AigcTaskService { // ============================== D12 控制平面 + GP9 共享入队(W-G1 组A·执行版 §6.1)============================== /** - * 共享入队门:submitGenerate 与 retryTask 同走(决策D)。门序固定(最便宜→最贵): + * 共享入队门:submitGenerate 与 retryTask 同走(决策D)。 + * 【C2 入口速率闸 · 先于下方全部 DB 门】方法体最前调 genAdmissionResource.admit():Sentinel FLOW_GRADE_QPS + * 统计每秒入队请求数削洪峰(规则热源 Nacos),越限由 blockHandler 优雅拒(非 500);独立于控制平面 enabled 开关。 + * 门序固定(最便宜→最贵,均在 admit() 之后): * ① 降级门(infra ConfigApi 读 aigc.generate.paused,无 LLM,new-api 挂了直接挡) * → ② 配额+并发门(per-creator×level 当日非取消计数 + 在飞并发计数,纯计数) * → ③ 背压门(全局在飞计数,纯计数) @@ -308,6 +320,13 @@ public class AigcTaskServiceImpl implements AigcTaskService { * @param task 已组装好(含 creatorUserId/level/prompt/status=queued/traceId)但尚未落库的任务 */ private void enqueueWithControlPlane(AigcTaskDO task) { + // 入口 QPS 突发闸(Sentinel FLOW_GRADE_QPS,规则热源 Nacos dataId sentinel-gen-flow-rules)——先于下方全部 DB 门: + // 统计「每秒入队请求数」削洪峰,越限由 blockHandler 优雅转 AIGC_ADMISSION_RATE_LIMITED(非 500、不静默丢)。 + // 与下方 DB 三门(per-creator 业务配额)分层正交;「在跑生成并发≤15」由 C3 RocketMQ consumeThreadMax 保证、不在此。 + // 置于 props 判空之前 = 独立于控制平面 enabled 开关(基础设施级速率保护恒生效);无 Nacos 规则时为纳秒级 no-op, + // 其回滚杠杆是 Nacos 流控规则本身(调大 count / 删 dataId),与控制平面 enabled=false 各自独立。 + genAdmissionResource.admit(); + AigcControlPlaneProperties props = controlPlanePropertiesProvider.getIfAvailable(); // 控制平面关闭/未装配:全门 + GP9 旁路,直接落库(现行行为逐字不变,§10 回滚首选层) if (props == null || !Boolean.TRUE.equals(props.getEnabled())) { diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/admission/GenAdmissionResourceTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/admission/GenAdmissionResourceTest.java new file mode 100644 index 00000000..0b8a8a17 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/admission/GenAdmissionResourceTest.java @@ -0,0 +1,43 @@ +package com.wanxiang.huijing.game.module.aigc.admission; + +import com.alibaba.csp.sentinel.slots.block.BlockException; +import com.alibaba.csp.sentinel.slots.block.flow.FlowException; +import com.wanxiang.huijing.framework.common.exception.ServiceException; +import org.junit.jupiter.api.Test; + +import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.AIGC_ADMISSION_RATE_LIMITED; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link GenAdmissionResource} 单元测试(切片一 阶段〇 C2)。 + * + *

只锁 blockHandler 的优雅降级语义:越 QPS 闸 → 转可读业务异常(含「繁忙」、带业务错误码),绝不 500、不静默丢。 + * admit() 空体的真 QPS 限流依赖 Sentinel 运行时统计 + Nacos 规则(集成态),不在单测范围——由后端窗口的集成验证覆盖。 + * + * @author 绘境AI + */ +class GenAdmissionResourceTest { + + /** + * 越入口 QPS 闸:blockHandler 必须抛业务 {@link ServiceException}(消息含「繁忙」、错误码 = aigc 控制平面段 1-101-001-006), + * 而非裸 500 服务器错误——锁死「优雅拒、不静默丢」这条降级契约不回退。 + */ + @Test + void onAdmissionBlocked_throwsBusinessServiceException_notServerError() { + GenAdmissionResource resource = new GenAdmissionResource(); + // 模拟 Sentinel 流控拦截:FlowException 是 BlockException 子类,资源名对齐 RESOURCE + BlockException blockEx = new FlowException(GenAdmissionResource.RESOURCE); + + ServiceException ex = assertThrows(ServiceException.class, + () -> resource.onAdmissionBlocked(blockEx)); + + // ① 优雅业务提示(含「繁忙」)——反 500、反静默丢 + assertTrue(ex.getMessage().contains("繁忙"), + "blockHandler 应转成含「繁忙」的可读业务提示,实际=" + ex.getMessage()); + // ② 携带 aigc 控制平面段业务码(经全局 handler 转 CommonResult.error、HTTP 200 body.code 非 0) + assertEquals(AIGC_ADMISSION_RATE_LIMITED.getCode(), ex.getCode(), + "错误码应 = AIGC_ADMISSION_RATE_LIMITED(1-101-001-006)"); + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/task/AigcControlPlaneTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/task/AigcControlPlaneTest.java index bbaeca1b..3fead6ca 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/task/AigcControlPlaneTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/task/AigcControlPlaneTest.java @@ -54,6 +54,10 @@ class AigcControlPlaneTest extends BaseMockitoUnitTest { private ObjectProvider safetyCheckClientProvider; @Mock private ObjectProvider configApiProvider; + // C2 入口 QPS 闸(硬注入 @Component):@InjectMocks 会注入本 mock,其 void admit() 默认 no-op(不 block、不 NPE), + // 故控制平面各门用例行为不变;真限流降级语义由 GenAdmissionResourceTest 单独锁。 + @Mock + private com.wanxiang.huijing.game.module.aigc.admission.GenAdmissionResource genAdmissionResource; @Mock private SafetyCheckClient safetyCheckClient; diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/task/AigcTaskServiceImplTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/task/AigcTaskServiceImplTest.java index a12a9529..1cd7aff0 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/task/AigcTaskServiceImplTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/task/AigcTaskServiceImplTest.java @@ -55,6 +55,9 @@ class AigcTaskServiceImplTest extends BaseMockitoUnitTest { private ObjectProvider safetyCheckClientProvider; @Mock private ObjectProvider configApiProvider; + // C2 入口 QPS 闸(硬注入 @Component):@InjectMocks 注入本 mock,void admit() 默认 no-op → 现行提交/重试基线行为不变。 + @Mock + private com.wanxiang.huijing.game.module.aigc.admission.GenAdmissionResource genAdmissionResource; // ============================== submitGenerate ============================== diff --git a/game-cloud/huijing-server/src/main/resources/application.yaml b/game-cloud/huijing-server/src/main/resources/application.yaml index 5609277b..bde623ae 100644 --- a/game-cloud/huijing-server/src/main/resources/application.yaml +++ b/game-cloud/huijing-server/src/main/resources/application.yaml @@ -69,6 +69,21 @@ spring: enabled: true # 重启用 Nacos 配置中心(was false):从 Nacos 读外部化配置 namespace: public file-extension: yaml # 配置文件格式 + # Sentinel 入口 QPS 突发保护(切片一 阶段〇 C2):流控规则从 Nacos dataId sentinel-gen-flow-rules 热加载(改即生效、不重启)。 + # 职责 = 生成入队入口速率闸(FLOW_GRADE_QPS 削洪峰),与 DB 三门(业务配额)/ C3 consumeThreadMax(在跑并发≤15)三层正交。 + sentinel: + transport: + dashboard: ${SENTINEL_DASHBOARD:} # 留空 = 不接 Sentinel 控制台(mini-infra RAM 紧、不部面板);规则直存 Nacos + datasource: + gen-flow: # 生成入队流控规则数据源(名字任意,仅作 datasource 标识) + nacos: + server-addr: 100.64.0.8:8848 # 复用 C1 同一 mini-infra Nacos + username: nacos # 鉴权同 C1 nacos 段(见 docs/内网凭据与端点.md) + password: ${NACOS_PASSWORD} # 口令走 env 注入,严禁明文入仓 + dataId: sentinel-gen-flow-rules # 流控规则 dataId(JSON;resource=aigc:gen:admission) + groupId: DEFAULT_GROUP + data-type: json + rule-type: flow # 流控规则(FLOW_GRADE_QPS 入口速率闸) # openfeign: # lazy-attributes-resolution: true # client: