feat(voice): 上行接线——最终转写→组DSL→提交任务(Phase 1 打通)

ASR 最终转写触发一次任务提交,复用 HTTP SubmitTask 那条共用关卡
(preflightCore/launchCore),语音只是"嘴替键盘",编排/工具/计费一行不新造。

- task_handler.go: preflight/launch 抽出无 gin 内核 preflightCore/launchCore
  (preflightBlock 承载拦截态),gin 版做薄封装;语音会话无 gin.Context 也走同一关卡
- voice_task.go: buildVoiceGraph(转写→input→agent 单图) + submitVoiceTask(校验/关卡/落库发射)
- voice.go: 会话升级时抓租户/会话;结果 goroutine 见 Final→onFinalTranscript 提交、
  回 ServerTask{task_id};去重连发的重复 final;画布图一次性消费
- voice_task_test.go: 组图合法性 + 带转写 + input→agent 连边

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-22 09:16:29 +08:00
parent 4ee4a91a51
commit 302e1ebaff
5 changed files with 238 additions and 33 deletions
@@ -36,45 +36,64 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blob *blob.Store
return &Handler{db: db, cache: cache, bus: bus, blob: blob, pay: payment.NewManager()}
}
// preflight 是「会烧钱的执行」提交前的统一关卡:当日 token 预算 → 计费租户 → 积分硬拦截。
// 返回计费租户;ok=false 表示已写过响应,调用方直接 return
//
// 抽出来是因为这套关卡曾经只长在 SubmitTask 上,报告生成(GenerateReport)是另一条路径、
// 一直停在最初的「发个 NATS」——于是报告绕过了预算、不记计费租户、余额为 0 也照生成。
// 两条路径共用同一个函数,才不会再各长各的。
func (h *Handler) preflight(c *gin.Context) (string, bool) {
// preflightBlock 是关卡未通过时的拒绝信息(HTTP 状态 + 响应体)。core 返回它、由具体调用方
// 决定怎么把它变成回应(HTTP 写 JSON / 语音会话取 error 文案播报)
type preflightBlock struct {
Status int
Body gin.H
}
func (b *preflightBlock) message() string {
if b == nil {
return ""
}
if m, ok := b.Body["error"].(string); ok {
return m
}
return "提交被拦截"
}
// preflightCore 是「会烧钱的执行」提交前统一关卡的**无 gin 内核**:当日 token 预算 → 暂停管控
// → 计费租户解析 → 积分硬拦截。返回计费租户;block 非空表示被拦(HTTP 与语音两条入口共用它,
// 谁也别再各长各的关卡——见记忆 execution-single-entry)。
func (h *Handler) preflightCore(ctx context.Context, uid, tid string) (billingTenant string, block *preflightBlock) {
// 成本护栏:单用户当日 token 日预算门控(USER_DAILY_TOKEN_BUDGET0=不限)。
if budget := userDailyTokenBudget(); budget > 0 {
uid := userID(c)
used := h.cache.GetUsage(c.Request.Context(), uid, time.Now().Format("20060102"))
used := h.cache.GetUsage(ctx, uid, time.Now().Format("20060102"))
if used >= int64(budget) {
c.JSON(http.StatusPaymentRequired, gin.H{
return "", &preflightBlock{http.StatusPaymentRequired, gin.H{
"error": "已达当日 token 预算上限", "used": used, "budget": budget,
})
return "", false
}}
}
}
// 暂停管控:活跃租户(工作区)被暂停 → 拒绝提交。否则「暂停」只是个装了没接线的开关。
if tid := tenantID(c); h.db.TenantSuspended(c.Request.Context(), tid) {
c.JSON(http.StatusForbidden, gin.H{"error": "租户已被暂停,暂无法提交任务"})
return "", false
if h.db.TenantSuspended(ctx, tid) {
return "", &preflightBlock{http.StatusForbidden, gin.H{"error": "租户已被暂停,暂无法提交任务"}}
}
// 计费目标:数据落在活跃租户(工作区),但消耗记到"计费租户"——owner/共享计费→活跃租户,
// 否则→本人个人租户(各付各的)。硬拦截与用量都按计费租户走。
billingTenant := h.db.ResolveBillingTenantID(c.Request.Context(), userID(c), tenantID(c))
billingTenant = h.db.ResolveBillingTenantID(ctx, uid, tid)
// 计费租户与活跃租户不同(共享计费分叉)时,计费租户被暂停也拦——别让暂停的组织被人借道烧积分。
if billingTenant != "" && billingTenant != tenantID(c) && h.db.TenantSuspended(c.Request.Context(), billingTenant) {
c.JSON(http.StatusForbidden, gin.H{"error": "计费租户已被暂停,暂无法提交任务"})
return "", false
if billingTenant != "" && billingTenant != tid && h.db.TenantSuspended(ctx, billingTenant) {
return "", &preflightBlock{http.StatusForbidden, gin.H{"error": "计费租户已被暂停,暂无法提交任务"}}
}
// 积分硬拦截(默认关;开关 credit_enforce):计费租户积分余额 ≤0 则拒绝,提示充值。
if billingTenant != "" && h.db.CreditEnforceEnabled(c.Request.Context()) {
if h.db.TenantBalance(c.Request.Context(), billingTenant) <= 0 {
c.JSON(http.StatusPaymentRequired, gin.H{"error": "租户积分余额不足,请充值后再试", "balance_micro": 0})
return "", false
if billingTenant != "" && h.db.CreditEnforceEnabled(ctx) {
if h.db.TenantBalance(ctx, billingTenant) <= 0 {
return "", &preflightBlock{http.StatusPaymentRequired, gin.H{"error": "租户积分余额不足,请充值后再试", "balance_micro": 0}}
}
}
return billingTenant, true
return billingTenant, nil
}
// preflight 是 preflightCore 的 gin 薄封装:ok=false 表示已写过响应,调用方直接 return。
func (h *Handler) preflight(c *gin.Context) (string, bool) {
bt, block := h.preflightCore(c.Request.Context(), userID(c), tenantID(c))
if block != nil {
c.JSON(block.Status, block.Body)
return "", false
}
return bt, true
}
// launch 把一次执行真正发出去,并接上「执行」该有的全套基建:
@@ -82,16 +101,23 @@ func (h *Handler) preflight(c *gin.Context) (string, bool) {
// 报告生成此前只 PublishTask,这两样都没有,所以报告既进不了运行历史,
// 切个页面回来也彻底找不回——它明明在后端好好地跑完了。
func (h *Handler) launch(c *gin.Context, task *contract.Task) error {
return h.launchCore(c.Request.Context(), userID(c), task)
}
// launchCore 是 launch 的**无 gin 内核**:落库 + Publish + 起 token/轨迹录像。
// 语音会话(无 gin.Context)也走它,与 HTTP 提交共用同一条发射流程。
// 注意:ctx 只用于落库与 Publish(同步、瞬时完成),录像器自持后台 ctx,不受此 ctx 生命周期影响。
func (h *Handler) launchCore(ctx context.Context, uid string, task *contract.Task) error {
// 持久化任务提交。DB 降级(nil)时 SaveTask 返 nil 静默跳过(开发态本就无库,不阻断);
// 但 DB 活着却写失败 → 真故障,绝不能吞:一旦 PublishTask 发出去,任务就在后端跑了,
// 却不进运行历史、复盘不了、报告类的会彻底"丢"(用户切页面回来找不回)。
// 宁可这里失败上浮 5xx 让用户重试,也不发一个"看不见的执行"。落库在 Publish 之前,
// 失败时还没发布,中止是干净的。
if err := h.db.SaveTask(c.Request.Context(), userID(c), task.ID, string(task.Graph)); err != nil {
if err := h.db.SaveTask(ctx, uid, task.ID, string(task.Graph)); err != nil {
log.Printf("[gateway] save task %s failed: %v", task.ID, err)
return fmt.Errorf("任务落库失败,请重试: %w", err)
}
if err := h.bus.PublishTask(c.Request.Context(), task); err != nil {
if err := h.bus.PublishTask(ctx, task); err != nil {
return err
}
// 从提交即开始把 token 流 + 执行轨迹录进 Redis Stream(订阅早于 dispatcher 产出)→