feat(workflow): 增加工作流计费与执行生命周期管理

- 新增计费模块:执行开始建单、终态结算/取消/失败处理,支持按条/按秒/按token计费
- 新增执行生命周期跟踪:优雅关停时取消运行中执行并等待落库
- 新增异步任务等待/通知机制(Wait/Notify)
- 重构执行记录落库与进度上报,统一失败分类与重试语义
- 重命名文件:async_task.go→async.go、flow_checkpoint_store.go→exec_checkpoint.go、flow_graph_util.go→exec_record.go
- 更新 .gitignore 与数据库密码配置
This commit is contained in:
2026-09-03 13:22:22 +08:00
parent 67d049e586
commit d699f7ce14
46 changed files with 2690 additions and 2450 deletions
+113
View File
@@ -0,0 +1,113 @@
package flow
import (
"ai-agent/gateway"
"context"
"encoding/base64"
"fmt"
"strings"
"gitea.redpowerfuture.com/red-future/common/oss"
"github.com/gogf/gf/v2/util/gconv"
"github.com/google/uuid"
)
// resolveSaveFileResult 解析结果值为可入库的 URL:
// - 已是 http(s) URL 或 MinIO 对象裸路径 → 直接返回
// - 非路径(base64 图片/文本)→ 上传 OSS 换取 URL
func resolveSaveFileResult(ctx context.Context, val any) (string, error) {
isPath, path, fileBytes, ext := resolveFileContent(val)
if isPath {
return path, nil
}
if ext == "" {
ext = ".png"
}
fileUrl, err := gateway.Upload(ctx, fmt.Sprintf("workflow_result_%s%s", uuid.NewString(), ext), fileBytes)
if err != nil {
return "", err
}
return fileUrl, nil
}
// resolveFileContent 判断结果值形态:
// - 已是 URL 路径(http/https 开头)→ 直接使用
// - data URIdata:<mime>;base64,<data>)→ 解码为字节,扩展名按 mime 推断
// - 纯 base64(可解码且长度足以认为是编码数据)→ 解码为字节,默认 .png
// - 其余(文本)→ 以 .inc 扩展名上传原文
func resolveFileContent(val any) (isPath bool, path string, fileBytes []byte, ext string) {
s := gconv.String(val)
if isFileURL(s) {
return true, s, nil, ""
}
// MinIO 对象裸路径(无 http 前缀,模型网关转存 OSS 后返回)
if oss.IsOSSPath(s) {
return true, s, nil, ""
}
// data URIdata:<mime>;base64,<payload>
if b, mime, ok := parseDataURI(s); ok {
return false, "", b, extOfMime(mime)
}
// 纯 base64:可解码且长度足够,视为编码后的文件内容
trimmed := strings.TrimSpace(s)
if len(trimmed) >= 64 {
if b, err := base64.StdEncoding.DecodeString(trimmed); err == nil && len(b) > 0 {
return false, "", b, ".png"
}
}
// 文本:以 .inc 存储
return false, "", []byte(s), ".inc"
}
// isFileURL 判断字符串是否已是对外可访问的 URL 路径(http/https 开头)
func isFileURL(s string) bool {
lower := strings.ToLower(s)
return strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://")
}
// extOfMime 按 MIME 类型推断文件扩展名
func extOfMime(mime string) string {
switch strings.ToLower(strings.TrimSpace(mime)) {
case "image/png", "png":
return ".png"
case "image/jpeg", "image/jpg", "jpeg", "jpg":
return ".jpg"
case "image/webp":
return ".webp"
case "image/gif":
return ".gif"
case "audio/mpeg", "audio/mp3", "mp3":
return ".mp3"
case "audio/wav", "wav":
return ".wav"
case "video/mp4", "mp4":
return ".mp4"
case "application/json", "json":
return ".json"
default:
return ""
}
}
// parseDataURI 解析 data URIdata:<mime>;base64,<payload>,返回解码字节与 mime
func parseDataURI(s string) ([]byte, string, bool) {
const prefix = "data:"
if !strings.HasPrefix(s, prefix) {
return nil, "", false
}
rest := s[len(prefix):]
comma := strings.Index(rest, ",")
if comma < 0 {
return nil, "", false
}
mime := rest[:comma]
if semicolon := strings.Index(mime, ";"); semicolon >= 0 {
mime = mime[:semicolon]
}
payload := strings.TrimPrefix(rest[comma+1:], "base64,")
b, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
return nil, "", false
}
return b, mime, true
}