feat(aigc): WU2③ job userToken 位 + per-user 额度门(只对真实 member 生效)

dispatchGeneric 组 §6.1 job 时追加 userToken 字段(向后兼容,worker .get 忽略未知键)。
per-user 门只对真实 member 在线创作生效——按 creatorUserId 是否命中一行 game_player 分流:
- 无 game_player 行(系统/编排器/bake-off/admin 触发)→ 旁路:不查池不懒 claim,userToken
  留空,worker 回落全局 key(不误伤 n=5 收敛环/批跑既有验证路);
- 命中 game_player 行 → 取其 CLAIMED 条目 token_key;无 CLAIMED 则派发线单 CAS 懒 claim
  (纯 DB CAS 非慢外呼,派发热路安全),抢到就用、池空抢不到当次干净失败 quota_exhausted。
token 随 job 内网下发日志脱敏(仅留前后 4 位)。经 12 参构造软注入,主开关关或未接线=现行行为字节零变。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-07 12:22:14 -07:00
parent 4cf0e14ec3
commit a069afb649
2 changed files with 76 additions and 4 deletions

View File

@ -10,6 +10,7 @@ import com.wanxiang.huijing.game.module.aigc.service.executor.GameConfigSchemaVa
import com.wanxiang.huijing.game.module.aigc.service.executor.PromptResourceLoader;
import com.wanxiang.huijing.game.module.aigc.service.executor.WorkerClassifyClient;
import com.wanxiang.huijing.game.module.aigc.mq.GenTaskProducer;
import com.wanxiang.huijing.game.module.aigc.service.quota.NewapiQuotaService;
import com.wanxiang.huijing.game.module.aigc.saa.SaaGraphDispatcher;
import com.wanxiang.huijing.game.module.aigc.service.executor.GenerationDispatcher;
import com.wanxiang.huijing.game.module.aigc.service.executor.WorkerDispatchClient;
@ -178,7 +179,8 @@ public class AigcExecutorConfiguration {
WorkerDispatchClient workerDispatchClient,
SaaGraphDispatcher saaGraphDispatcher,
ObjectProvider<SourceProjectApi> sourceProjectApiProvider,
ObjectProvider<GenTaskProducer> genTaskProducerProvider) {
ObjectProvider<GenTaskProducer> genTaskProducerProvider,
ObjectProvider<NewapiQuotaService> newapiQuotaServiceProvider) {
// A11 M1软取 SourceProjectApistudio @Primary 就地解析modify 路据 baseVersionId 反查 base 源注入 §6.1 job sourceProject
// 供便宜档 HTTP worker DB 被动消费 SaaGraphDispatcher 同一 seam缺席单模块装配/studio 未在 classpath modify 取源旁路不注入 sourceProject 不阻断装配
SourceProjectApi sourceProjectApi = sourceProjectApiProvider.getIfAvailable();
@ -186,11 +188,15 @@ public class AigcExecutorConfiguration {
// stale queued重发信号交消费者重走认领+派发不在 tick 直派投递可靠性补偿 MQ 保持唯一投递路径与并发 cap 无关
// 缺席防御分支 null 兜底 tick 退回直接认领+派发韧性降级不让任务卡死
GenTaskProducer genTaskProducer = genTaskProducerProvider.getIfAvailable();
// WU2 §3.6软取 NewapiQuotaService@Service aigc 模块常在席传入使 dispatchGeneric §6.1 job 时对真实 member
// per-user 额度门取其 claim 到的 token 放进 userToken 其主开关 aigc.newapi-quota.enabled 默认 false 时对所有创作者返 null
// opt-in 才生效缺席防御分支/单模块装配 null 不接线worker 全局 key现行行为字节零变
NewapiQuotaService newapiQuotaService = newapiQuotaServiceProvider.getIfAvailable();
// SAA 迁移 #2传入 http worker + saa 进程内派发器由执行器构造按 aigc.executor.dispatcher 选用
// 默认 http=现行行为不变saa=opt-in 进程内形态
return new AigcGenerateExecutor(properties, aigcTaskMapper, difyCallbackService,
promptResourceLoader, schemaValidator, llmClient, workerDispatchClient, saaGraphDispatcher,
sourceProjectApi, Clock.systemDefaultZone(), genTaskProducer);
sourceProjectApi, Clock.systemDefaultZone(), genTaskProducer, newapiQuotaService);
}
}

View File

@ -8,6 +8,8 @@ import com.wanxiang.huijing.game.module.aigc.mq.GenTaskProducer;
import com.wanxiang.huijing.game.module.aigc.service.callback.DifyCallbackService;
import com.wanxiang.huijing.game.module.studio.api.SourceProjectApi;
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectFetchRespDTO;
import com.wanxiang.huijing.game.module.aigc.service.quota.NewapiQuotaService;
import com.wanxiang.huijing.game.module.aigc.service.quota.QuotaPoolExhaustedException;
import com.wanxiang.huijing.framework.common.enums.UserTypeEnum;
import com.wanxiang.huijing.framework.common.exception.ServiceException;
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
@ -124,6 +126,13 @@ public class AigcGenerateExecutor {
* 扫描-认领-派发行为避免任务卡死韧性降级非主路径生产经 AigcExecutorConfiguration 注入非空
*/
private final GenTaskProducer genTaskProducer;
/**
* new-api per-user 额度门 seamWU2 §3.6<b>可为 null</b>真实 member 在线创作派发时取其 claim 到的 token 放进
* §6.1 job {@code userToken} 系统/编排/bake-off game_player 旁路留空worker 回落全局 key
* <p><b>null 旧构造/单测态</b>不注入 userToken = 现行行为worker 全局 key生产经 AigcExecutorConfiguration
* 软注入非空其内部主开关 {@code aigc.newapi-quota.enabled}默认 false关时对所有创作者返 null故即便在席也零行为变更opt-in 生效
*/
private final NewapiQuotaService newapiQuotaService;
/** 自禁用连续 tick 计数(自检恢复即清零;告警节流判据) */
private int selfCheckFailTicks = 0;
@ -200,6 +209,24 @@ public class AigcGenerateExecutor {
GameConfigSchemaValidator schemaValidator, ExecutorLlmClient llmClient,
WorkerDispatchClient workerDispatchClient, GenerationDispatcher saaDispatcher,
SourceProjectApi sourceProjectApi, Clock clock, GenTaskProducer genTaskProducer) {
// 委托 12 参构造newapiQuotaService null per-user 额度门不接线现行行为worker 全局 key现行单测沿用此签名
this(properties, aigcTaskMapper, difyCallbackService, promptResourceLoader, schemaValidator,
llmClient, workerDispatchClient, saaDispatcher, sourceProjectApi, clock, genTaskProducer, null);
}
/**
* 12 参构造WU2 §3.6 收口 AigcExecutorConfiguration @Bean 调用 11 参基础上接 new-api per-user 额度门
* {@link NewapiQuotaService}真实 member 在线创作派发时取其 claim 到的 token 放进 §6.1 job {@code userToken}
* 系统/编排 game_player 旁路留空worker 回落全局 key null旧构造/单测态=不接线现行行为字节零变
*
* @param newapiQuotaService per-user 额度门 seam null=不接线其主开关 aigc.newapi-quota.enabled 默认 false 时对所有创作者返 nullopt-in 生效
*/
public AigcGenerateExecutor(AigcExecutorProperties properties, AigcTaskMapper aigcTaskMapper,
DifyCallbackService difyCallbackService, PromptResourceLoader promptResourceLoader,
GameConfigSchemaValidator schemaValidator, ExecutorLlmClient llmClient,
WorkerDispatchClient workerDispatchClient, GenerationDispatcher saaDispatcher,
SourceProjectApi sourceProjectApi, Clock clock, GenTaskProducer genTaskProducer,
NewapiQuotaService newapiQuotaService) {
// §5.2 阈值关系铁律stale×60 > budget + 273装配期校验误配置宁可启动失败也不带病收割在飞任务
properties.validateThresholds();
this.properties = properties;
@ -218,12 +245,15 @@ public class AigcGenerateExecutor {
this.clock = clock;
// C3gen 队列生产者兜底 tick 重发信号用非空=MQ 主触发 + 兜底重发信号投递可靠性补偿null=兜底退回直派单测/降级态
this.genTaskProducer = genTaskProducer;
// WU2 §3.6per-user 额度门 null=不接线现行行为在席时其主开关关默认仍旁路opt-in 才生效
this.newapiQuotaService = newapiQuotaService;
// 启动三行自检日志§12- 冒烟观察面执行器已启用 / prompt 资源版本 / key 配置态严禁打印密钥本身
log.info("[executor-selfcheck] aigc 生成执行器已启用poll={}ms, batch={}, budget={}s, stale={}min, maxAge={}h, templates={}, sourceSeam={}, 触发={}",
log.info("[executor-selfcheck] aigc 生成执行器已启用poll={}ms, batch={}, budget={}s, stale={}min, maxAge={}h, templates={}, sourceSeam={}, 触发={}, perUser额度门={}",
properties.getPollIntervalMs(), properties.getScanBatchSize(), properties.getTaskBudgetSeconds(),
properties.getStaleRunningMinutes(), properties.getMaxTaskAgeHours(), properties.getSupportedTemplates(),
sourceProjectApi != null ? "在席" : "缺席(modify取源旁路)",
genTaskProducer != null ? "MQ主触发+tick兜底重发信号" : "tick直派(无MQ生产者/单测态)");
genTaskProducer != null ? "MQ主触发+tick兜底重发信号" : "tick直派(无MQ生产者/单测态)",
newapiQuotaService != null ? "在席(opt-in)" : "缺席(worker全局key)");
// HJ-MC-TPL-EXEC-001 §6.4多模板装载后无单一版本 templateId 逐模板列出版本就绪时
log.info("[executor-selfcheck] prompt 资源版本 {}", promptResourceLoader.isReady()
? promptResourceLoader.describePromptVersions() : ("未就绪:" + promptResourceLoader.getNotReadyReason()));
@ -680,6 +710,29 @@ public class AigcGenerateExecutor {
// 便宜档 HTTP workerPython DB只被动消费 job["sourceProject"]键名同 SAA K_SOURCE_PROJECT反查失败/缺源则不放该键best-effort 非阻断
putModifyFieldsIntoJob(job, task);
// WU2 §3.6per-user new-api 额度门 §6.1 job userToken
// 身份边界只对真实 member 在线创作生效命中一行 game_player 取其 claim 到的 token_key 放进 job
// 系统/编排器/bake-off game_player 旁路留空worker 回落全局 key§3.7不误伤既有验证路
// newapiQuotaService null旧构造/单测态或主开关关 resolveUserTokenForDispatch null不注入 = 现行行为
if (newapiQuotaService != null) {
try {
String userToken = newapiQuotaService.resolveUserTokenForDispatch(task.getCreatorUserId());
if (StringUtils.hasText(userToken)) {
job.put("userToken", userToken); // §6.1 追加字段向后兼容worker .get 忽略未知键 worker 收到不炸
// 传输安全§3.8token new-api 调用凭据仅留前后 4 位入日志绝不整条打印
log.info("[executor-dispatch] per-user 额度 token 已随 job 下发脱敏taskId={}, traceId={}, creatorUserId={}, tokenMasked={}",
task.getId(), task.getTraceId(), task.getCreatorUserId(), maskToken(userToken));
}
// userToken 为空 = 系统/编排旁路正常且预期不注入不打扰日志
} catch (QuotaPoolExhaustedException e) {
// 真实 member 需要 token 但池空 claim 抢不到 当次干净失败给可读拒因§3.6 池空处置
// 绝不派一个没 token create job 下去静默烧共享额度水位告警已在 service 内触发
log.warn("[executor-dispatch] per-user 额度池空/懒 claim 未果,按 quota_exhausted 干净失败 taskId={}, traceId={}, creatorUserId={}",
task.getId(), task.getTraceId(), task.getCreatorUserId());
return callbackFailed(task, FailureReasonEnum.QUOTA_EXHAUSTED);
}
}
boolean dispatched = generationDispatcher.dispatch(job);
if (dispatched) {
// 投递成功任务留 RUNNING worker 回调终态由回调驱动不在此写tick 汇总以派发计数 succeeded/failed
@ -694,6 +747,19 @@ public class AigcGenerateExecutor {
return callbackFailed(task, FailureReasonEnum.LLM_ERROR);
}
/**
* per-user token 日志脱敏WU2 §3.8只留前后各 4 中间打码绝不整条打印 new-api 调用凭据
*
* @param token 原始 token_key可能为空
* @return 脱敏串 sk-A****Z9zK/过短返回固定占位
*/
private static String maskToken(String token) {
if (token == null || token.length() <= 8) {
return "****";
}
return token.substring(0, 4) + "****" + token.substring(token.length() - 4);
}
/**
* modify/extend 四件套契约 C3接进 §6.1 job modify 使 {@link com.wanxiang.huijing.game.module.aigc.saa.SaaGraphDispatcher}
* {@code buildInputs} 能读到 modifyMode/modifyPatch/baseVersionId 并据此分流到 SAA modify