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:
@@ -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_BUDGET,0=不限)。
|
||||
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 产出)→
|
||||
|
||||
Reference in New Issue
Block a user