@@ -33,6 +33,56 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blob *blob.Store
return & Handler { db : db , cache : cache , bus : bus , blob : blob }
}
// preflight 是「会烧钱的执行」提交前的统一关卡:当日 token 预算 → 计费租户 → 积分硬拦截。
// 返回计费租户;ok=false 表示已写过响应,调用方直接 return。
//
// 抽出来是因为这套关卡曾经只长在 SubmitTask 上,报告生成(GenerateReport)是另一条路径、
// 一直停在最初的「发个 NATS」——于是报告绕过了预算、不记计费租户、余额为 0 也照生成。
// 两条路径共用同一个函数,才不会再各长各的。
func ( h * Handler ) preflight ( c * gin . Context ) ( string , bool ) {
// 成本护栏:单用户当日 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" ) )
if used >= int64 ( budget ) {
c . JSON ( http . StatusPaymentRequired , gin . H {
"error" : "已达当日 token 预算上限" , "used" : used , "budget" : budget ,
} )
return "" , false
}
}
// 计费目标:数据落在活跃租户(工作区),但消耗记到"计费租户"——owner/共享计费→活跃租户,
// 否则→本人个人租户(各付各的)。硬拦截与用量都按计费租户走。
billingTenant := h . db . ResolveBillingTenantID ( c . Request . Context ( ) , userID ( c ) , tenantID ( c ) )
// 积分硬拦截(默认关;开关 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
}
}
return billingTenant , true
}
// launch 把一次执行真正发出去,并接上「执行」该有的全套基建:
// 落库(→ 运行历史能看到、能复盘)+ token/轨迹录像(→ SSE 可回放/断点续传,切走再回来不丢)。
// 报告生成此前只 PublishTask,这两样都没有,所以报告既进不了运行历史,
// 切个页面回来也彻底找不回——它明明在后端好好地跑完了。
func ( h * Handler ) launch ( c * gin . Context , task * contract . Task ) error {
// 持久化任务提交(best-effort:降级模式下静默跳过,不阻断发布)。
if err := h . db . SaveTask ( c . Request . Context ( ) , userID ( c ) , task . ID , string ( task . Graph ) ) ; err != nil {
log . Printf ( "[gateway] save task %s failed: %v" , task . ID , err )
}
if err := h . bus . PublishTask ( c . Request . Context ( ) , task ) ; err != nil {
return err
}
// 从提交即开始把 token 流 + 执行轨迹录进 Redis Stream(订阅早于 dispatcher 产出)→
// SSE 可从中回放/断点续传,根治"连晚/重连丢 token / 丢轨迹事件"。
h . startTokenRecorder ( task . ID )
h . startExecRecorder ( task . ID )
return nil
}
// SubmitTask: 解析客户端导出的 JSON DSL,组装为 Task, Publish 到 sundynix.tasks.*。
func ( h * Handler ) SubmitTask ( c * gin . Context ) {
var raw json . RawMessage
@@ -45,27 +95,10 @@ func (h *Handler) SubmitTask(c *gin.Context) {
c . JSON ( http . StatusUnprocessableEntity , gin . H { "error" : err . Error ( ) } )
return
}
// 成本护栏:单用户当日 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" ) )
if used >= int64 ( budget ) {
c . JSON ( http . StatusPaymentRequired , gin . H {
"error" : "已达当日 token 预算上限" , "used" : used , "budget" : budget ,
} )
billingTenant , ok := h . preflight ( c )
if ! ok {
return
}
}
// 计费目标:数据落在活跃租户(工作区),但消耗记到"计费租户"——owner/共享计费→活跃租户,
// 否则→本人个人租户(各付各的)。硬拦截与用量都按计费租户走。
billingTenant := h . db . ResolveBillingTenantID ( c . Request . Context ( ) , userID ( c ) , tenantID ( c ) )
// 积分硬拦截(默认关;开关 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
}
}
// 附上用户标识(召回偏好记忆)与会话标识(召回短期多轮历史)。
// 真实场景由鉴权/会话中间件注入;此处用请求头,缺省匿名/默认会话。
task . Meta [ contract . MetaUserID ] = userID ( c )
@@ -75,18 +108,10 @@ func (h *Handler) SubmitTask(c *gin.Context) {
if c . GetBool ( "guardrail_suspect" ) {
task . Meta [ contract . MetaSafetyCheck ] = true
}
// 持久化任务提交(best-effort:降级模式下静默跳过,不阻断发布)。
if err := h . db . SaveTask ( c . Request . Context ( ) , userID ( c ) , task . ID , string ( task . Graph ) ) ; err != nil {
log . Printf ( "[gateway] save task %s failed: %v" , task . ID , err )
}
if err := h . bus . PublishTask ( c . Request . Context ( ) , task ) ; err != nil {
if err := h . launch ( c , task ) ; err != nil {
c . JSON ( http . StatusBadGateway , gin . H { "error" : err . Error ( ) } )
return
}
// 从提交即开始把 token 流 + 执行轨迹录进 Redis Stream(订阅早于 dispatcher 产出)→
// SSE 可从中回放/断点续传,根治"连晚/重连丢 token / 丢轨迹事件"。
h . startTokenRecorder ( task . ID )
h . startExecRecorder ( task . ID )
c . JSON ( http . StatusAccepted , gin . H { "task_id" : task . ID } )
}