Files
ai-agent/workflow/service/flow/billing.go
T
19904408334 f7957761f0 fix(workflow): 修复子流程批量执行缓存键冲突
递归识别子工作流中的视频模型以支持 per_second 计费,并为子流程批量子执行添加 SubFlowScope 隔离 async/segment 缓存键。
2026-09-03 16:50:18 +08:00

444 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package flow
import (
"context"
"errors"
"fmt"
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
"gitea.redpowerfuture.com/red-future/common/utils"
"github.com/gogf/gf/v2/os/glog"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
"github.com/google/uuid"
nodeDao "ai-agent/workflow/dao/node"
sessionDao "ai-agent/workflow/dao/session"
flowDto "ai-agent/workflow/model/dto/flow"
nodeDto "ai-agent/workflow/model/dto/node"
"ai-agent/workflow/model/entity"
)
// ====================== 计费本地 DTOshop-user-trade pricing,独立 module 不可 importJSON 对齐) ======================
type pricingOpenOrderReq struct {
UserId int64 `json:"userId"`
SubjectType string `json:"subjectType"`
SubjectID string `json:"subjectId"`
ChargeMode string `json:"chargeMode"`
BizOrderNo string `json:"bizOrderNo"`
}
type pricingGetConfigReq struct {
SubjectType string `json:"subjectType"`
SubjectID string `json:"subjectId"`
}
type pricingGetConfigRes struct {
Enabled int `json:"enabled"`
}
type pricingChargeOrderInfo struct {
ID int64 `json:"id"`
Status int `json:"status"` // 1已建单 2已结算 3已失败
ActualAmount float64 `json:"actualAmount"` // 实收/实扣(元):settle/cancel 响应回填,回写 exec_workflow.actual_amount
CreatedAt string `json:"createdAt"`
ChargeMode string `json:"chargeMode"` // per_item / per_token
}
// pricingSettleReq Settle 与 Cancel 同形状(OrderId + Usage
type pricingSettleReq struct {
OrderId int64 `json:"orderId"`
Usage map[string]any `json:"usage"`
}
type pricingFailReq struct {
OrderId int64 `json:"orderId"`
Reason string `json:"reason"`
}
// 计价对象/模式常量(对齐 shop-user-trade consts/pricing
const (
pricingSubjectWorkflow = "workflow"
pricingChargeModePerItem = "per_item"
pricingChargeModePerSecond = "per_second"
pricingChargeModePerToken = "per_token"
pricingOrderStatusCreated = 1
)
// errBillingGateBlocked 计费门禁拦截(余额不足/钱包不可用/费率非法/未配置计价/用户缺失/per_second 无视频模型):执行终局失败,
// 不进进程内重试、不落 recoverableshouldRetry 与 handleExecute 分类均排除)。
var errBillingGateBlocked = errors.New("计费门禁拦截")
// pricingURL 组装 shop-user-trade 计价接口地址。
// 跨服务路由前缀 /pricing/controller/ 由 common http.RouteRegister 按 controller struct 名推导
// pricingController → pricing/controller),GoFrame doSetHandler 恒 prefix+uri 拼接。
func pricingURL(sub string) string {
return "shop-user-trade/pricing/controller/" + sub
}
// ====================== 建单(执行开始,不动钱) ======================
// openBillingOrder 工作流执行开始建计费单(不动钱)。
// 幂等键 bizOrderNo=wf:{execId};复用终态 execId 重跑(原单已结算/失败)→ 开新单 wf:{execId}:{uuid8}
// 新单 ID 落 exec_workflow.charge_order_id,结算据此定位。
// 门禁(余额>=min_balance、钱包须存在)失败 → errBillingGateBlocked,执行终局失败不重试。
// 计费是工作流执行的前置条件:用户缺失/计价未配置/预检失败/per_second 无视频模型 → 终局失败,不免费跑。
func openBillingOrder(ctx context.Context, execId int64, flowContent *entity.FlowInfo) error {
user, err := utils.GetUserInfo(ctx)
if err != nil || user == nil || user.Id == 0 {
return fmt.Errorf("%w: 取不到用户 %v", errBillingGateBlocked, err)
}
// config/get 预检 = 服务存活探针 + 启用开关:
// 预检失败(服务宕机/路由不通)→ 终局失败;
// 预检通过(服务确认在、计价已开)后再调 open_order,其失败即为业务错误(余额/钱包/费率)→ 阻塞,
// 确保 shop-user-trade 宕机时工作流执行也被阻断而非免费跑。
var cfg pricingGetConfigRes
if err = commonHttp.Get(ctx, pricingURL("config/get"), utils.HeadersFromCtx(ctx, utils.HeadersOptions{TokenFromQuery: true}), &cfg,
"subjectType", pricingSubjectWorkflow, "subjectId", pricingSubjectWorkflow); err != nil {
return fmt.Errorf("%w: 计价配置查询失败 %v", errBillingGateBlocked, err)
}
if cfg.Enabled != 1 {
return fmt.Errorf("%w: 计价未启用", errBillingGateBlocked)
}
chargeMode := ""
if flowContent != nil && flowContent.ChargeMode != "" {
chargeMode = flowContent.ChargeMode
} else {
return fmt.Errorf("%w: 工作流执行未选择计费模式", errBillingGateBlocked)
}
// per_second 按秒计费以生成视频总时长为基础,工作流须含视频模型节点,否则配置非法 → 终局失败
if chargeMode == pricingChargeModePerSecond && !flowHasVideoModel(ctx, flowContent) {
return fmt.Errorf("%w: per_second 计费须工作流包含视频模型", errBillingGateBlocked)
}
info, err := openPricingOrder(ctx, int64(user.Id), chargeMode, fmt.Sprintf("wf:%d", execId))
if err != nil {
return err
}
orderId := info.ID
if info.Status != pricingOrderStatusCreated {
// 复用终态 execId:原单已结算/失败,开新单让本次运行独立计费
info, err = openPricingOrder(ctx, int64(user.Id), chargeMode,
fmt.Sprintf("wf:%d:%s", execId, uuid.NewString()[:8]))
if err != nil {
return err
}
orderId = info.ID
}
if err = sessionDao.ExecWorkflowDao.UpdateMap(ctx, execId, map[string]any{
entity.ExecWorkflowCol.ChargeOrderId: orderId,
}); err != nil {
// 结算时按 bizOrderNo=wf:{execId} 兜底定位,不阻塞
glog.Errorf(ctx, "工作流计费:记录 charge_order_id 失败 execId=%d: %v", execId, err)
}
return nil
}
// flowHasVideoModel 工作流是否包含视频模型节点(per_second 计费前置条件)。
// 遍历节点,按 modelId 去重后经 isVideoModel 查模型类型;sub_flow 节点按 subConfig.workflowId
// 递归展开其子工作流(与 SubFlowLambda 运行期解析一致),任一可达层级的视频模型即 true——
// 视频模型放在子工作流内同样满足 per_second 前置条件。
// flowContent/子工作流失联、取子工作流失败按该支无视频模型处理(fail-closedper_second 被拦截)。
// 递归以被展开的子流程 workflowId 去重防环、跨层共享 modelId 去重,避免重复取子流程/重复查模型类型。
func flowHasVideoModel(ctx context.Context, flowContent *entity.FlowInfo) bool {
if flowContent == nil {
return false
}
return hasVideoModelRecursive(ctx, flowContent,
make(map[int64]struct{}), make(map[int64]struct{}))
}
// hasVideoModelRecursive 递归扫描单层 flow 是否含视频模型节点。
// 先查节点自身模型 id,再展开 sub_flow 子工作流递归(sub_flow 节点自身无模型,ModelId 恒 0)。
// expandedSubFlows 记录已展开的子流程 workflowId(展开前先标记,防 A→B→A 无限递归并去重重复引用);
// seenModels 记录已查过的模型 id,跨层共享避免同一模型重复调 isVideoModel。
func hasVideoModelRecursive(ctx context.Context, flowContent *entity.FlowInfo,
expandedSubFlows, seenModels map[int64]struct{}) bool {
for i := range flowContent.Nodes {
n := &flowContent.Nodes[i]
// sub_flow 节点:取子工作流继续递归;取不到/无法解析按该支无视频模型,继续扫其余节点
if sc := n.SubConfig; sc != nil && sc.WorkflowId > 0 {
if _, ok := expandedSubFlows[sc.WorkflowId]; ok {
continue
}
expandedSubFlows[sc.WorkflowId] = struct{}{}
subRes, err := FlowUserService.Get(ctx, &flowDto.GetFlowUserReq{Id: sc.WorkflowId})
if err != nil || subRes == nil || subRes.FlowContent == nil {
continue
}
if hasVideoModelRecursive(ctx, subRes.FlowContent, expandedSubFlows, seenModels) {
return true
}
continue
}
// 普通模型节点:按 modelId 去重后查类型
modelId := n.ModelConfig.ModelId
if modelId <= 0 {
continue
}
if _, ok := seenModels[modelId]; ok {
continue
}
seenModels[modelId] = struct{}{}
if isVideoModel(ctx, modelId) {
return true
}
}
return false
}
// openPricingOrder 调 shop-user-trade 建单(幂等:同 subject+bizOrderNo 返回既有单)
func openPricingOrder(ctx context.Context, userId int64, chargeMode, bizOrderNo string) (*pricingChargeOrderInfo, error) {
info := new(pricingChargeOrderInfo)
err := commonHttp.Post(ctx, pricingURL("open_order"), utils.HeadersFromCtx(ctx, utils.HeadersOptions{TokenFromQuery: true}), info, &pricingOpenOrderReq{
UserId: userId,
SubjectType: pricingSubjectWorkflow,
SubjectID: pricingSubjectWorkflow,
ChargeMode: chargeMode,
BizOrderNo: bizOrderNo,
})
if err != nil {
return nil, fmt.Errorf("%w: %v", errBillingGateBlocked, err)
}
if info.ID == 0 {
return nil, fmt.Errorf("%w: 未返回计费单ID", errBillingGateBlocked)
}
return info, nil
}
// ====================== 结算(终态) ======================
// settleBilling 工作流终态计费(recordWorkflow 汇聚全部执行路径后调用):
// 成功→Settle 实收;用户取消→Cancel 按已消耗实收;永久失败(retryable=0/重试耗尽)→Fail 不扣费;
// 可恢复失败→跳过(订单留 CREATED,恢复续跑后结算/失败)。
// 全部计费调用错误仅记日志,不拖垮工作流终态落库。
func settleBilling(ctx context.Context, execId int64, runErr error, retryable, retryCount *int) {
exec, err := sessionDao.ExecWorkflowDao.GetById(ctx, execId)
if err != nil || exec == nil {
glog.Errorf(ctx, "工作流计费:查询执行失败,跳过结算 execId=%d: %v", execId, err)
return
}
orderId := exec.ChargeOrderId
if orderId == 0 {
// 兜底:charge_order_id 未落库(UpdateMap 失败)时按 bizOrderNo=wf:{execId} 定位;查不到→跳过
info, e := getPricingOrder(ctx, "", fmt.Sprintf("wf:%d", execId))
if e != nil || info == nil {
return
}
orderId = info.ID
}
usage, full, err := workflowChargeUsage(ctx, exec, orderId, errors.Is(runErr, context.Canceled))
if err != nil {
glog.Errorf(ctx, "工作流计费:计算用量失败,跳过结算 execId=%d: %v", execId, err)
return
}
// 终局实扣金额:settle/cancel 由 shop 结算响应回填(元);fail / 可恢复=0(未扣费)
var actual float64
switch {
case runErr == nil:
actual, _ = callSettlePricing(ctx, orderId, usage, pricingURL("settle"))
case errors.Is(runErr, context.Canceled):
actual, _ = callSettlePricing(ctx, orderId, usage, pricingURL("cancel")) // Cancel 按已消耗实收
case retryable != nil && *retryable == 0:
callFailPricing(ctx, orderId, runErr.Error())
case retryCount != nil && *retryCount >= execMaxRetryCount:
callFailPricing(ctx, orderId, runErr.Error()) // 重试耗尽 → 永久失败
default:
// 可恢复失败(retryable=1 且未耗尽/关停中断):订单留 CREATED,恢复续跑后结算
}
// 终局回填 exec_workflow(成功/取消/失败/可恢复统一落库,前端与对账均看此行):
// 模型消耗(total_tokens/total_fee,本次运行节点 token_info 按订单窗口聚合)+ 业务实扣
// actual_amount=settle/cancel 实收金额,失败/可恢复未结算=0)。失败/取消路径 SummaryLambda 不跑,
// total 列此前恒为空,此处按同一窗口补齐(与结算口径一致,不混入上一运行残留);
// 可恢复失败也先落已消耗,续跑成功后同一订单收敛重算覆盖。错误仅记日志不拖垮终态落库。
if full != nil {
if err := sessionDao.ExecWorkflowDao.UpdateMap(ctx, execId, map[string]any{
entity.ExecWorkflowCol.TotalTokens: full.TotalTokens,
entity.ExecWorkflowCol.TotalFee: full.TotalFee,
entity.ExecWorkflowCol.ActualAmount: actual,
}); err != nil {
glog.Errorf(ctx, "exec_workflow 回填消耗/实扣失败 execId=%d: %v", execId, err)
}
}
}
// workflowChargeUsage 计算工作流结算用量并返回全量聚合(full,含 TotalTokens/TotalFee 供终局回填 exec 行):
// per_token → feeByModel(各模型按次已消耗费用,结算侧按此合计实收——每次模型调用在发生时已由
// shop /calc 计价,含「不足1分按1分」的按次兜底与调用时媒体/费率快照,不再按聚合 token 重算,
// 避免两笔 0.01 合并重算成 0.01);
// per_item(按条)→ durationItems = 逐条产出时长(每条视频节点执行记录一项,各自落档求和,
// 子流程 maxConcurrency=N → N 条;见根目录《工作流按条(per_item)结算多产出设计.md》);
// per_second → durationSec = 本次生成视频总时长。仅生成视频的工作流按条/秒计费,其余模型调用按 token 计费。
// 用户取消(forCancel=true)时 per_item/per_second 在时长之外补收已消耗 token:把非视频节点的按次费用
// 一并上报(视频节点消耗已由时长计价覆盖,排除防双计)——中途取消通常无视频产出,但文本/分析节点
// 可能已完成并消耗了 token,须按已消耗补收而非按 0 计。
func workflowChargeUsage(ctx context.Context, exec *entity.ExecWorkflow, orderId int64, forCancel bool) (usage map[string]any, full *nodeUsageAgg, err error) {
info, err := getPricingOrder(ctx, gconv.String(orderId), "")
if err != nil {
return nil, nil, err
}
// 按订单收敛:只聚合订单创建后产生的节点记录。每次重跑(上单已终态)开新单,created_at 各自独立,
// 隔离「上次已结算运行」与「本次运行」——否则重跑后取消会把上一次已扣费的 token 一起再扣。
// shop 返回 gtime.String() 无时区本地墙钟,与 node_execution.created_attimestamp without tz)同格式可比。
var createdAtFrom *gtime.Time
if info.CreatedAt != "" {
createdAtFrom = gtime.NewFromStr(info.CreatedAt)
if createdAtFrom == nil || createdAtFrom.IsZero() {
return nil, nil, fmt.Errorf("计费单创建时间解析失败: %s", info.CreatedAt)
}
}
full, nonVideo, err := workflowNodeUsage(ctx, exec, createdAtFrom)
if err != nil {
return nil, nil, err
}
switch {
case info.ChargeMode == pricingChargeModePerToken:
// per_token:按次已记录费用结算(含视频模型——per_token 无时长计价,视频模型按自身按次费用计收)
return tokenUsageMap(full), full, nil
case info.ChargeMode == pricingChargeModePerItem:
// per_item 按条:逐条产出时长各自落档(成功只报逐条;取消附非视频按次已消耗费用补收)
if forCancel {
usage := tokenUsageMap(nonVideo)
usage["durationItems"] = full.DurationItems
return usage, full, nil
}
return map[string]any{"durationItems": full.DurationItems}, full, nil
case forCancel:
// per_second 取消补收:总时长(通常 0)+ 非视频节点按次已消耗费用
// (nonVideo 排除视频节点:其消耗已由时长计价覆盖,避免双计)
usage := tokenUsageMap(nonVideo)
usage["durationSec"] = full.DurationSec
return usage, full, nil
default:
// per_second 正常结算按总时长秒价;不附 token/费用拆分
return map[string]any{"durationSec": full.DurationSec}, full, nil
}
}
// nodeUsageAgg 本次执行聚合出的结算用量:FeeByModel(按生效(系统)模型 id 的按次已记录费用合计,
// shop 实收依据)+ DurationSec(时长,供 per_item/per_second 用)。逐模型 token/媒体明细不上报——
// 每调用一条留在 node_execution.token_infomodel_id/total_tokens/prompt_tokens/completion_tokens/
// media_type/total_fee),订单层按需可从明细再聚合,不再冗余携带。
type nodeUsageAgg struct {
// FeeByModel 各模型窗口内已消耗费用合计 = 节点 token_info.total_fee 求和。total_fee 本身是
// 该节点内各次模型调用(每次经 shop /calc 计价,calculator.charge 内部已 ceilFen,「不足1分按1分」
// 按调用次生效)费用之和 → 此处合计即「按次已记录费用」。结算侧按此实收,不再按聚合 token 重算。
FeeByModel map[string]float64
// DurationSec 各视频节点生成视频总时长(per_second 计价依据、终局展示口径)。
// 注意:per_item 已改按 DurationItems 逐条计价,本字段不再作 per_item 结算入参
DurationSec float64
// DurationItems 本次执行产出的逐条视频时长(per_item 计价依据):一条视频节点执行记录计一项
// (一条 node_execution = 一条成片产出;记录内 token_info 各条 total_duration 合计 >0 计一条)。
// 与 DurationSec(求和口径)并存:per_second 用求和、per_item 用逐条各自落档。
DurationItems []float64
// TotalTokens / TotalFee 全量节点消耗合计(模型消耗 token / 模型按次费用),
// 终局回填 exec_workflow.total_tokens/total_fee(区别于钱包实扣 ActualAmount)。
TotalTokens int64
TotalFee float64
}
// workflowNodeUsage 聚合本次执行(FlowExecutionId + 订单创建时间下界)下各节点执行记录写入的用量。
// 返回两组聚合:
// - full:全部节点记录——per_token 结算(视频模型也按自身按次费用计收,无时长计价)与时长累计用;
// - nonVideo:排除 total_duration>0 的节点(视频节点生成时长,per_item/per_second 取消补收时其消耗
// 已由时长计价覆盖,不再按次补收,避免双计)。
//
// 聚合内容:feeByModel = 各模型 total_fee 求和(total_fee = 该节点内各次模型调用经 shop /calc 计价
// (已 ceilFen)的费用之和 → 按次已记录费用,shop 实收依据);durationSec = 各视频节点生成视频总时长
// (模型返回,total_duration)累加。逐模型 token/媒体明细留在 node_execution.token_info,订单层不上报。
//
// 收敛到当前运行:重跑复用同一 exec 记录与节点组(检查点恢复的 SavedFlowInput 携带旧 node_group_id
// exec_workflow 表也无该列持久化),node_group_id 无法区分运行;改按 created_at >= 订单创建时间过滤——
// 每次重跑开新单,各自 created_at 隔离本次运行消耗,避免把已结算的上一次运行用量一起计入。
func workflowNodeUsage(ctx context.Context, exec *entity.ExecWorkflow, createdAtFrom *gtime.Time) (full, nonVideo *nodeUsageAgg, err error) {
records, _, err := nodeDao.NodeExecutionDao.ListByFlowExecutionId(ctx, &nodeDto.ListNodeExecutionByFlowReq{
FlowExecutionId: exec.Id,
CreatedAtFrom: createdAtFrom,
})
if err != nil {
return nil, nil, err
}
full = &nodeUsageAgg{FeeByModel: make(map[string]float64)}
nonVideo = &nodeUsageAgg{FeeByModel: make(map[string]float64)}
for _, rec := range records {
// 记录级时长:一条 node_execution = 一条成片产出(token_info 内部多段已先加总后 concat,见 ModelLambda)。
// 该记录各 token_info 的 total_duration 合计 >0 → 计一条,供 per_item 逐条落档(子流程多产出=多条同 NodeId 记录)
var recDuration float64
for _, ti := range rec.TokenInfo {
addNodeUsageEntry(full, ti)
// total_duration>0 = 视频节点产出了生成时长(lambda 只有视频模型节点累加并落库该字段),
// 该节点消耗由时长计价覆盖,排除出 nonVideoper_item/per_second 取消补收按此计,防双计)
if d := gconv.Float64(ti["total_duration"]); d > 0 {
recDuration += d
} else {
addNodeUsageEntry(nonVideo, ti)
}
}
if recDuration > 0 {
full.DurationItems = append(full.DurationItems, recDuration)
}
}
return full, nonVideo, nil
}
// addNodeUsageEntry 把单条 token_info(节点一次模型调用或调用汇总)累加进聚合。
// gconv.String 兼容字符串与 JSONB 数字(float64)两种 model_id 写入,避免 (string) 断言丢弃条目。
func addNodeUsageEntry(agg *nodeUsageAgg, ti map[string]any) {
modelID := gconv.String(ti["model_id"])
agg.DurationSec += gconv.Float64(ti["total_duration"])
agg.TotalTokens += gconv.Int64(ti["total_tokens"])
agg.TotalFee += gconv.Float64(ti["total_fee"])
if modelID == "" {
return // 无模型 id(异常条目)不计费用(feeByModel 按模型键)
}
agg.FeeByModel[modelID] += gconv.Float64(ti["total_fee"])
}
// tokenUsageMap 按次费用口径组装结算用量(token 部分):仅 feeByModelshop 实收依据,
// per_token 无建单快照)。逐模型 token/媒体明细留在 node_execution.token_info,订单层不上报。
func tokenUsageMap(agg *nodeUsageAgg) map[string]any {
return map[string]any{
"feeByModel": agg.FeeByModel,
}
}
// getPricingOrder 查计费单:id>0 按ID,否则按 subjectType+subjectId+bizOrderNo
func getPricingOrder(ctx context.Context, id, bizOrderNo string) (*pricingChargeOrderInfo, error) {
info := new(pricingChargeOrderInfo)
var data []any
if id != "" {
data = []any{"id", id}
} else {
data = []any{"subjectType", pricingSubjectWorkflow, "subjectId", pricingSubjectWorkflow, "bizOrderNo", bizOrderNo}
}
err := commonHttp.Get(ctx, pricingURL("order"), utils.HeadersFromCtx(ctx, utils.HeadersOptions{TokenFromQuery: true}), info, data...)
if err != nil {
return nil, err
}
if info.ID == 0 {
return nil, errors.New("计费单不存在")
}
return info, nil
}
// callSettlePricing Settle(成功)或 Cancel(用户取消按已消耗实收),返回实收金额(元)。
// shop settle/cancel 响应体是结算后的 ChargeOrderInfoactualAmount=本次实扣),幂等重复结算返回既有金额;
// 调用失败返回 0 并记日志(此时无法确知钱包是否已扣,actual_amount 置 0 保守,不臆造金额)。
func callSettlePricing(ctx context.Context, orderId int64, usage map[string]any, url string) (actual float64, err error) {
info := new(pricingChargeOrderInfo)
if err := commonHttp.Post(ctx, url, utils.HeadersFromCtx(ctx, utils.HeadersOptions{TokenFromQuery: true}), info,
&pricingSettleReq{OrderId: orderId, Usage: usage}); err != nil {
glog.Errorf(ctx, "工作流计费:结算失败 orderId=%d: %v", orderId, err)
return 0, err
}
return info.ActualAmount, nil
}
// callFailPricing Fail(不扣费)
func callFailPricing(ctx context.Context, orderId int64, reason string) {
if err := commonHttp.Post(ctx, pricingURL("fail"), utils.HeadersFromCtx(ctx, utils.HeadersOptions{TokenFromQuery: true}), &pricingChargeOrderInfo{},
&pricingFailReq{OrderId: orderId, Reason: reason}); err != nil {
glog.Errorf(ctx, "工作流计费:失败处理失败 orderId=%d: %v", orderId, err)
}
}