Files
RockYang d4fd38ab7e feat: 迁移 geekai-plus v4.3.1 到开源版
- 同步 Plus v4.3.1 功能源并移除商业 License 闭环
- 更新开源镜像命名、Docker 部署版本和 geekai 数据库配置
- 补充前端 ESLint 检查配置并修复存量解析与模板问题
- 保留 JWT、管理员权限、API Key 和 OAuth 等正常鉴权机制
2026-09-13 14:37:12 +08:00

898 lines
24 KiB
Go
Raw Permalink 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 jimeng
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"time"
"gorm.io/gorm"
"geekai/core/types"
"geekai/log"
"geekai/service"
"geekai/service/oss"
"geekai/store"
"geekai/store/model"
"geekai/utils"
"github.com/go-redis/redis/v8"
)
var logger = log.GetLogger()
const seedanceOfficialBaseURL = "https://ark.cn-beijing.volces.com/api/v3"
const (
jimengMediaRepairInterval = 60 * time.Second
jimengMediaRepairBatch = 50
)
// Service 即梦服务(合并了消费者功能)
type Service struct {
db *gorm.DB
redis *redis.Client
taskQueue *store.RedisQueue
client *Client
ctx context.Context
cancel context.CancelFunc
running bool
uploader *oss.UploaderManager
userService *service.UserService
}
// NewService 创建即梦服务
func NewService(db *gorm.DB, redisCli *redis.Client, uploader *oss.UploaderManager, client *Client, userService *service.UserService) *Service {
taskQueue := store.NewRedisQueue("JimengTaskQueue", redisCli)
ctx, cancel := context.WithCancel(context.Background())
return &Service{
db: db,
redis: redisCli,
taskQueue: taskQueue,
client: client,
ctx: ctx,
cancel: cancel,
running: false,
uploader: uploader,
userService: userService,
}
}
// Start 启动服务(包含消费者)
func (s *Service) Start() {
if s.running {
return
}
logger.Info("Starting Jimeng service and task consumer...")
s.running = true
go s.consumeTasks()
go s.pollTaskStatus()
go s.runJimengSuccessMediaRepairLoop()
}
// Stop 停止服务
func (s *Service) Stop() {
if !s.running {
return
}
logger.Info("Stopping Jimeng service and task consumer...")
s.running = false
s.cancel()
}
// consumeTasks 消费任务
func (s *Service) consumeTasks() {
for {
select {
case <-s.ctx.Done():
logger.Info("Jimeng task consumer stopped")
return
default:
s.processNextTask()
}
}
}
// processNextTask 处理下一个任务
func (s *Service) processNextTask() {
var jobId uint
if err := s.taskQueue.LPop(&jobId); err != nil {
// 队列为空,等待1秒后重试
time.Sleep(time.Second)
return
}
logger.Infof("Processing Jimeng task: job_id=%d", jobId)
if err := s.ProcessTask(jobId); err != nil {
logger.Errorf("process jimeng task failed: job_id=%d, error=%v", jobId, err)
s.UpdateJobStatus(jobId, types.JMTaskStatusFailed, err.Error())
} else {
logger.Infof("Jimeng task processed successfully: job_id=%d", jobId)
}
}
// CreateTask 创建任务
func (s *Service) CreateTask(userId uint, req *types.JimengTaskRequest) (*model.JimengJob, error) {
// 生成任务ID
taskId := utils.RandString(20)
// 创建任务记录
job := &model.JimengJob{
UserId: userId,
TaskId: taskId,
Type: req.TaskType,
ReqKey: req.ReqKey,
Prompt: req.Prompt,
Params: utils.JsonEncode(req),
Status: types.JMTaskStatusSubmited,
Power: req.Power,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// 保存到数据库
if err := s.db.Create(job).Error; err != nil {
return nil, fmt.Errorf("create jimeng job failed: %w", err)
}
// 推送到任务队列
if err := s.taskQueue.RPush(job.Id); err != nil {
return nil, fmt.Errorf("push jimeng task to queue failed: %w", err)
}
return job, nil
}
// ProcessTask 处理任务
func (s *Service) ProcessTask(jobId uint) error {
// 获取任务记录
var job model.JimengJob
if err := s.db.First(&job, jobId).Error; err != nil {
return fmt.Errorf("get jimeng job failed: %w", err)
}
// 解析任务参数
var req types.JimengTaskRequest
err := utils.JsonDecode(job.Params, &req)
if err != nil {
return fmt.Errorf("parse task params failed: %w", err)
}
// 数字人任务,先识别主体
if req.TaskType == types.JMTaskTypeVirtualHuman {
if err := s.client.AvatarRecognition(req.ImageUrls[0], req.RecognizeKey); err != nil {
return s.handleTaskError(job.Id, fmt.Sprintf("avatar recognition failed: %v", err))
}
}
// Seedream 同步生图(Ark
if IsSeedreamReqKey(req.ReqKey) {
go func() {
resp, err := s.client.SubmitSyncImageTask(req)
if err != nil {
_ = s.handleTaskError(job.Id, fmt.Sprintf("submit task failed: %v", err))
return
}
logger.Infof("同步任务提交成功: %+v", resp)
// 更新原始数据
rawData, _ := json.Marshal(resp)
updates := map[string]any{
"raw_data": string(rawData),
}
if resp.Error != nil {
updates["status"] = types.JMTaskStatusFailed
updates["err_msg"] = resp.Error.Message
s.db.Model(&model.JimengJob{}).Where("id = ?", job.Id).Updates(updates)
return
}
if len(resp.Data) == 0 || resp.Data[0] == nil || resp.Data[0].Url == nil || strings.TrimSpace(*resp.Data[0].Url) == "" {
_ = s.db.Model(&model.JimengJob{}).Where("id = ?", job.Id).Update("raw_data", string(rawData)).Error
_ = s.handleTaskError(job.Id, "seedream response has no image url")
return
}
remoteURL := strings.TrimSpace(*resp.Data[0].Url)
ext := filepath.Ext(strings.Split(remoteURL, "?")[0])
if ext == "" {
ext = ".png"
}
// 更新任务状态
updates["status"] = types.JMTaskStatusSuccess
// 转存到本地/OSS(失败时回退为官方临时 URL,与即梦异步任务一致)
imgURL, err := s.uploader.GetUploadHandler().PutUrlFile(remoteURL, ext, false)
if err != nil {
logger.Errorf("jimeng seedream upload image failed, job_id=%d: %v", job.Id, err)
imgURL = remoteURL
}
updates["img_url"] = imgURL
updates["progress"] = 100
s.db.Model(&model.JimengJob{}).Where("id = ?", job.Id).Updates(updates)
}()
return nil
}
// Seedance 视频任务(DoubaoAdapter
if IsSeedanceReqKey(req.ReqKey) {
return s.submitSeedanceTask(job.Id, &req)
}
// 其他请求走即梦 Visual 异步任务
params, err := s.buildTaskRequest(&req)
if err != nil {
return s.handleTaskError(job.Id, fmt.Sprintf("build task request failed: %v", err))
}
logger.Debugf("提交即梦任务: %+v", params)
// 异步任务 ,前台执行
resp, err := s.client.SubmitTask(params)
if err != nil {
return s.handleTaskError(job.Id, fmt.Sprintf("submit task failed: %v", err))
}
if resp.Code != CodeSuccess {
return s.handleTaskError(job.Id, fmt.Sprintf("submit task failed: %s", resp.Message))
}
// 更新任务ID和原始数据
rawData, _ := json.Marshal(resp)
if err := s.db.Model(&model.JimengJob{}).Where("id = ?", job.Id).Updates(map[string]any{
"task_id": resp.Data.TaskId,
"raw_data": string(rawData),
"status": types.JMTaskStatusInQueue, // 把任务状态改成排队中,以便开启轮询
"updated_at": time.Now(),
}).Error; err != nil {
logger.Errorf("update jimeng job task_id failed: %v", err)
}
return nil
}
func (s *Service) submitSeedanceTask(jobId uint, req *types.JimengTaskRequest) error {
jimengConfig, err := s.getJimengConfig()
if err != nil {
return s.handleTaskError(jobId, fmt.Sprintf("load jimeng config failed: %v", err))
}
content := s.buildSeedanceContent(req)
if len(content) == 0 {
return s.handleTaskError(jobId, "seedance content 不能为空")
}
payload := map[string]any{
"model": req.ReqKey,
"content": content,
"duration": req.Duration,
"ratio": req.AspectRatio,
"resolution": req.Resolution,
}
// 兼容旧参数:0 让官方走默认值
if req.Duration == 0 {
delete(payload, "duration")
}
if req.AspectRatio == "" {
delete(payload, "ratio")
}
if req.Resolution == "" {
delete(payload, "resolution")
}
if req.ReturnLastFrame {
payload["return_last_frame"] = req.ReturnLastFrame
}
if req.Watermark != nil {
payload["watermark"] = *req.Watermark
}
if req.GenerateAudio != nil {
payload["generate_audio"] = *req.GenerateAudio
}
resp, rawData, err := s.callSeedanceCreate(payload, jimengConfig)
if err != nil {
return s.handleTaskError(jobId, fmt.Sprintf("submit seedance task failed: %v", err))
}
logger.Debugf("seedance create response: %+v", resp)
if err := s.db.Model(&model.JimengJob{}).Where("id = ?", jobId).Updates(map[string]any{
"task_id": resp.TaskID,
"raw_data": rawData,
"status": types.JMTaskStatusInQueue,
"updated_at": time.Now(),
}).Error; err != nil {
logger.Errorf("update seedance task_id failed: %v", err)
}
return nil
}
func (s *Service) buildSeedanceContent(req *types.JimengTaskRequest) []types.JMContentItem {
if len(req.Content) > 0 {
return req.Content
}
content := make([]types.JMContentItem, 0, 4)
if req.Prompt != "" {
content = append(content, types.JMContentItem{
Type: "text",
Text: req.Prompt,
})
}
if len(req.ImageUrls) > 0 {
for index, imageURL := range req.ImageUrls {
if imageURL == "" {
continue
}
role := "reference_image"
if len(req.ImageUrls) == 1 {
role = "first_frame"
} else if len(req.ImageUrls) == 2 {
if index == 0 {
role = "first_frame"
} else {
role = "last_frame"
}
}
content = append(content, types.JMContentItem{
Type: "image_url",
ImageURL: &types.JMAssetRef{
URL: imageURL,
},
Role: role,
})
}
}
if req.VideoURL != "" {
content = append(content, types.JMContentItem{
Type: "video_url",
VideoURL: &types.JMAssetRef{
URL: req.VideoURL,
},
Role: "reference_video",
})
}
if req.AudioURL != "" {
content = append(content, types.JMContentItem{
Type: "audio_url",
AudioURL: &types.JMAssetRef{
URL: req.AudioURL,
},
Role: "reference_audio",
})
}
return content
}
// buildTaskRequest 构建任务请求(统一的参数解析)
func (s *Service) buildTaskRequest(req *types.JimengTaskRequest) (map[string]any, error) {
var params map[string]any
err := utils.JsonDecode(utils.JsonEncode(req), &params)
if err != nil {
return nil, fmt.Errorf("parse task params failed: %w", err)
}
// 把 size 转成 width 和 height
if size, ok := params["size"]; ok {
if sizeStr, ok := size.(string); ok {
if strings.Contains(sizeStr, "x") {
sizes := strings.Split(sizeStr, "x")
params["width"] = sizes[0]
params["height"] = sizes[1]
}
}
delete(params, "size")
}
// duration 转成 frames
if duration, ok := params["duration"]; ok {
if v, ok := duration.(int); ok {
params["frames"] = v*24 + 1
} else if v, ok := duration.(float64); ok {
params["frames"] = int(v*24) + 1
}
delete(params, "duration")
}
// 单独处理图片特效任务
if req.ReqKey == ImageEffectReqKey {
params["image_input1"] = req.ImageUrls[0]
delete(params, "image_urls")
}
// 动作迁移,数字人任务参数处理
if req.TaskType == types.JMTaskTypeVirtualHuman || req.TaskType == types.JMTaskTypeActionTransfer {
params["image_url"] = req.ImageUrls[0]
delete(params, "image_urls")
}
if req.RecognizeKey != "" {
delete(params, "recognize_key")
}
// 删除多余参数,剩下的就是各个任务自己专有参数了
delete(params, "type")
delete(params, "power")
return params, nil
}
// pollTaskStatus 轮询任务状态
func (s *Service) pollTaskStatus() {
for {
var jobs []model.JimengJob
// 找出排队中和处理中的任务进行轮询
s.db.Where("status IN (?)", []types.JMTaskStatus{
types.JMTaskStatusGenerating,
types.JMTaskStatusInQueue}).Find(&jobs)
for _, job := range jobs {
// 任务超时处理
if job.UpdatedAt.Before(time.Now().Add(-10 * time.Minute)) {
s.handleTaskError(job.Id, "task timeout")
continue
}
// Seedream 为同步任务,不需要轮询
if IsSeedreamReqKey(job.ReqKey) {
continue
}
if IsSeedanceReqKey(job.ReqKey) {
if err := s.pollSeedanceTask(&job); err != nil {
s.handleTaskError(job.Id, err.Error())
}
continue
}
// 查询任务状态
resp, err := s.client.QueryTask(&QueryTaskRequest{
ReqKey: job.ReqKey,
TaskId: job.TaskId,
ReqJson: `{"return_url":true}`,
}, ASyncActionGetResult)
if err != nil {
s.handleTaskError(job.Id, fmt.Sprintf("query task failed: %s", err.Error()))
continue
}
// 更新原始数据
rawData, _ := json.Marshal(resp)
s.db.Model(&model.JimengJob{}).Where("id = ?", job.Id).Update("raw_data", string(rawData))
if resp.Code != CodeSuccess {
s.handleTaskError(job.Id, fmt.Sprintf("query task failed: %s", resp.Message))
continue
}
switch resp.Data.Status {
case types.JMTaskStatusDone:
// 判断任务是否成功
if resp.Message != "Success" {
s.handleTaskError(job.Id, fmt.Sprintf("task failed: %s", resp.Data.AlgorithmBaseResp.StatusMessage))
continue
}
// 任务完成,更新结果
updates := map[string]any{
"status": types.JMTaskStatusSuccess,
"updated_at": time.Now(),
"progress": 100,
}
// 设置结果URL
if len(resp.Data.ImageUrls) > 0 {
imgUrl, err := s.uploader.GetUploadHandler().PutUrlFile(resp.Data.ImageUrls[0], ".png", false)
if err != nil {
logger.Errorf("upload image failed: %v", err)
imgUrl = resp.Data.ImageUrls[0]
}
updates["img_url"] = imgUrl
}
if resp.Data.VideoUrl != "" {
videoUrl, err := s.uploader.GetUploadHandler().PutUrlFile(resp.Data.VideoUrl, ".mp4", false)
if err != nil {
logger.Errorf("upload video failed: %v", err)
videoUrl = resp.Data.VideoUrl
}
updates["video_url"] = videoUrl
}
s.db.Model(&model.JimengJob{}).Where("id = ?", job.Id).Updates(updates)
case types.JMTaskStatusInQueue, types.JMTaskStatusGenerating:
// 任务处理中
s.UpdateJobStatus(job.Id, types.JMTaskStatusGenerating, "")
case types.JMTaskStatusNotFound:
// 任务未找到
s.handleTaskError(job.Id, "task not found")
case types.JMTaskStatusExpired:
continue
default:
logger.Warnf("unknown task status: %s", resp.Data.Status)
}
}
// 找出失败的任务,并恢复其扣减积分
s.db.Where("status = ?", types.JMTaskStatusFailed).Where("power > ?", 0).Find(&jobs)
for _, job := range jobs {
err := s.userService.IncreasePower(job.UserId, job.Power, model.PowerLog{
Type: types.PowerRefund,
Model: job.ReqKey,
Remark: fmt.Sprintf("任务失败,退回积分。任务ID%d", job.Id),
})
if err != nil {
continue
}
// 更新任务状态
s.db.Model(&job).UpdateColumn("power", 0)
}
time.Sleep(5 * time.Second)
}
}
func (s *Service) pollSeedanceTask(job *model.JimengJob) error {
jimengConfig, err := s.getJimengConfig()
if err != nil {
return fmt.Errorf("load jimeng config failed: %w", err)
}
resp, rawData, err := s.callSeedanceQuery(job.TaskId, jimengConfig)
if err != nil {
return fmt.Errorf("query seedance task failed: %w", err)
}
logger.Debugf("seedance query response: %+v", resp)
s.db.Model(&model.JimengJob{}).Where("id = ?", job.Id).Update("raw_data", rawData)
switch resp.Status {
case "succeeded":
updates := map[string]any{
"status": types.JMTaskStatusSuccess,
"updated_at": time.Now(),
"progress": 100,
}
if resp.Content.VideoURL != "" {
videoURL, upErr := s.uploader.GetUploadHandler().PutUrlFile(resp.Content.VideoURL, ".mp4", false)
if upErr != nil {
logger.Errorf("upload seedance video failed: %v", upErr)
videoURL = resp.Content.VideoURL
}
updates["video_url"] = videoURL
}
return s.db.Model(&model.JimengJob{}).Where("id = ?", job.Id).Updates(updates).Error
case "queued", "running":
return s.UpdateJobStatus(job.Id, types.JMTaskStatusGenerating, "")
case "failed", "cancelled":
errMsg := resp.Error
if errMsg == "" {
errMsg = "seedance task failed"
}
return fmt.Errorf("%s", errMsg)
default:
return nil
}
}
type seedanceCreateResponse struct {
ID string `json:"id"`
PlatformID string `json:"platform_id"`
}
type seedanceCreateResult struct {
TaskID string
}
type seedanceQueryResponse struct {
ID string `json:"id"`
PlatformID string `json:"platform_id"`
Status string `json:"status"`
Error string `json:"error"`
Content struct {
VideoURL string `json:"video_url"`
} `json:"content"`
}
func (s *Service) callSeedanceCreate(payload map[string]any, jimengConfig *types.JimengConfig) (*seedanceCreateResult, string, error) {
if jimengConfig == nil || strings.TrimSpace(jimengConfig.ApiKey) == "" {
return nil, "", fmt.Errorf("jimeng api key 未配置")
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
return nil, "", err
}
url := fmt.Sprintf("%s/contents/generations/tasks", seedanceOfficialBaseURL)
req, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
if err != nil {
return nil, "", err
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(jimengConfig.ApiKey))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, string(raw), fmt.Errorf("status=%d body=%s", resp.StatusCode, string(raw))
}
var parsed seedanceCreateResponse
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, string(raw), err
}
taskID := parsed.PlatformID
if taskID == "" {
taskID = parsed.ID
}
if taskID == "" {
return nil, string(raw), fmt.Errorf("seedance create 响应缺少 task id: %s", string(raw))
}
return &seedanceCreateResult{TaskID: taskID}, string(raw), nil
}
func (s *Service) callSeedanceQuery(taskID string, jimengConfig *types.JimengConfig) (*seedanceQueryResponse, string, error) {
if jimengConfig == nil || strings.TrimSpace(jimengConfig.ApiKey) == "" {
return nil, "", fmt.Errorf("jimeng api key 未配置")
}
url := fmt.Sprintf("%s/contents/generations/tasks/%s", seedanceOfficialBaseURL, taskID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, "", err
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(jimengConfig.ApiKey))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, string(raw), fmt.Errorf("status=%d body=%s", resp.StatusCode, string(raw))
}
var parsed seedanceQueryResponse
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, string(raw), err
}
return &parsed, string(raw), nil
}
func (s *Service) getJimengConfig() (*types.JimengConfig, error) {
var configRow model.Config
if err := s.db.Where("name = ?", types.ConfigKeyJimeng).First(&configRow).Error; err != nil {
return nil, err
}
var jimengConfig types.JimengConfig
if err := utils.JsonDecode(configRow.Value, &jimengConfig); err != nil {
return nil, err
}
if strings.TrimSpace(jimengConfig.ApiKey) == "" {
return nil, fmt.Errorf("jimeng api key 未配置")
}
return &jimengConfig, nil
}
// UpdateJobStatus 更新任务状态
func (s *Service) UpdateJobStatus(jobId uint, status types.JMTaskStatus, errMsg string) error {
updates := map[string]any{
"status": status,
"updated_at": time.Now(),
}
if errMsg != "" {
updates["err_msg"] = errMsg
}
return s.db.Model(&model.JimengJob{}).Where("id = ?", jobId).Updates(updates).Error
}
// handleTaskError 处理任务错误
func (s *Service) handleTaskError(jobId uint, errMsg string) error {
logger.Errorf("Jimeng task error (job_id: %d): %s", jobId, errMsg)
return s.UpdateJobStatus(jobId, types.JMTaskStatusFailed, errMsg)
}
// PushTaskToQueue 推送任务到队列(用于手动重试)
func (s *Service) PushTaskToQueue(jobId uint) error {
return s.taskQueue.RPush(jobId)
}
// GetTaskStats 获取任务统计信息
func (s *Service) GetTaskStats() (map[string]any, error) {
type StatResult struct {
Status types.JMTaskStatus `json:"status"`
Count int64 `json:"count"`
}
var stats []StatResult
err := s.db.Model(&model.JimengJob{}).
Select("status, COUNT(*) as count").
Group("status").
Find(&stats).Error
if err != nil {
return nil, err
}
result := map[string]any{
"total": int64(0),
"completed": int64(0),
"processing": int64(0),
"failed": int64(0),
"pending": int64(0),
}
for _, stat := range stats {
result["total"] = result["total"].(int64) + stat.Count
result[string(stat.Status)] = stat.Count
}
return result, nil
}
// runJimengSuccessMediaRepairLoop 定时修复:状态已是 success 但进度未满且媒体地址均为空的任务,从 raw_data 重新解析并转存。
func (s *Service) runJimengSuccessMediaRepairLoop() {
ticker := time.NewTicker(jimengMediaRepairInterval)
defer ticker.Stop()
for {
select {
case <-s.ctx.Done():
logger.Info("Jimeng success-media repair loop stopped")
return
case <-ticker.C:
s.repairJimengSuccessJobsMediaOnce()
}
}
}
func (s *Service) repairJimengSuccessJobsMediaOnce() {
var jobs []model.JimengJob
err := s.db.Where("status = ?", types.JMTaskStatusSuccess).
Where("progress <> ?", 100).
Where("(COALESCE(img_url, '') = ? AND COALESCE(video_url, '') = ?)", "", "").
Where("raw_data IS NOT NULL AND raw_data <> ?", "").
Order("id ASC").
Limit(jimengMediaRepairBatch).
Find(&jobs).Error
if err != nil {
logger.Errorf("jimeng media repair query failed: %v", err)
return
}
if len(jobs) == 0 {
return
}
for i := range jobs {
job := jobs[i]
remoteImg, remoteVid := parseJimengRawMediaURLs(job.RawData)
if strings.TrimSpace(remoteImg) == "" && strings.TrimSpace(remoteVid) == "" {
logger.Warnf("jimeng media repair: job_id=%d no media url in raw_data", job.Id)
continue
}
updates := map[string]any{
"updated_at": time.Now(),
"progress": 100,
}
if strings.TrimSpace(remoteImg) != "" {
updates["img_url"] = s.putJimengRemoteMedia(strings.TrimSpace(remoteImg), ".png", false)
}
if strings.TrimSpace(remoteVid) != "" {
updates["video_url"] = s.putJimengRemoteMedia(strings.TrimSpace(remoteVid), ".mp4", true)
}
if err := s.db.Model(&model.JimengJob{}).Where("id = ?", job.Id).Updates(updates).Error; err != nil {
logger.Errorf("jimeng media repair update failed job_id=%d: %v", job.Id, err)
} else {
logger.Infof("jimeng media repair ok job_id=%d", job.Id)
}
}
}
// parseJimengRawMediaURLs 从 raw_data 解析远程图片/视频地址(即梦异步、Ark Seedream、Seedance)。
func parseJimengRawMediaURLs(raw string) (remoteImg, remoteVideo string) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", ""
}
var root map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &root); err != nil {
return "", ""
}
if dataRaw, ok := root["data"]; ok {
dataBytes := []byte(dataRaw)
trimmed := bytes.TrimSpace(dataBytes)
if len(trimmed) > 0 && trimmed[0] == '[' {
var items []struct {
Url *string `json:"url"`
}
if json.Unmarshal(dataBytes, &items) == nil {
for _, it := range items {
if it.Url != nil && strings.TrimSpace(*it.Url) != "" {
return strings.TrimSpace(*it.Url), ""
}
}
}
} else {
var qd struct {
ImageUrls []string `json:"image_urls"`
VideoUrl string `json:"video_url"`
}
if json.Unmarshal(dataBytes, &qd) == nil {
img := ""
if len(qd.ImageUrls) > 0 {
img = strings.TrimSpace(qd.ImageUrls[0])
}
vid := strings.TrimSpace(qd.VideoUrl)
if img != "" || vid != "" {
return img, vid
}
}
}
}
if contentRaw, ok := root["content"]; ok {
var c struct {
VideoURL string `json:"video_url"`
}
if json.Unmarshal(contentRaw, &c) == nil && strings.TrimSpace(c.VideoURL) != "" {
return "", strings.TrimSpace(c.VideoURL)
}
}
return "", ""
}
func (s *Service) putJimengRemoteMedia(remote, fallbackExt string, isVideo bool) string {
remote = strings.TrimSpace(remote)
if remote == "" {
return ""
}
ext := fallbackExt
if !isVideo {
u := remote
if i := strings.Index(u, "?"); i >= 0 {
u = u[:i]
}
if e := filepath.Ext(u); e != "" {
ext = e
}
} else {
ext = ".mp4"
}
out, err := s.uploader.GetUploadHandler().PutUrlFile(remote, ext, false)
if err != nil {
logger.Errorf("jimeng putJimengRemoteMedia failed: %v", err)
return remote
}
return out
}
// GetJob 获取任务
func (s *Service) GetJob(jobId uint) (*model.JimengJob, error) {
var job model.JimengJob
if err := s.db.First(&job, jobId).Error; err != nil {
return nil, err
}
return &job, nil
}