feat: 完成图片异步生成

This commit is contained in:
ccnetcore
2025-12-26 23:46:36 +08:00
parent a9267bfc0e
commit 047937af4c
9 changed files with 470 additions and 36 deletions

View File

@@ -2,7 +2,8 @@
"permissions": {
"allow": [
"Bash(dotnet build \"E:\\code\\github\\Yi\\Yi.Abp.Net8\\module\\ai-hub\\Yi.Framework.AiHub.Application\\Yi.Framework.AiHub.Application.csproj\" --no-restore)",
"Read(//e/code/github/Yi/Yi.Ai.Vue3/**)"
"Read(//e/code/github/Yi/Yi.Ai.Vue3/**)",
"Bash(dotnet build:*)"
],
"deny": [],
"ask": []

View File

@@ -0,0 +1,22 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Chat;
/// <summary>
/// 图片生成输入
/// </summary>
public class ImageGenerationInput
{
/// <summary>
/// 提示词
/// </summary>
public string Prompt { get; set; } = string.Empty;
/// <summary>
/// 模型ID
/// </summary>
public string ModelId { get; set; } = string.Empty;
/// <summary>
/// 参考图Base64列表可选包含前缀如 data:image/png;base64,...
/// </summary>
public List<string>? ReferenceImagesBase64 { get; set; }
}

View File

@@ -0,0 +1,49 @@
using Yi.Framework.AiHub.Domain.Shared.Enums;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Chat;
/// <summary>
/// 图片任务输出
/// </summary>
public class ImageTaskOutput
{
/// <summary>
/// 任务ID
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// 提示词
/// </summary>
public string Prompt { get; set; } = string.Empty;
/// <summary>
/// 参考图Base64列表
/// </summary>
public List<string>? ReferenceImagesBase64 { get; set; }
/// <summary>
/// 参考图URL列表
/// </summary>
public List<string>? ReferenceImagesUrl { get; set; }
/// <summary>
/// 生成图片Base64包含前缀
/// </summary>
public string? StoreBase64 { get; set; }
/// <summary>
/// 生成图片URL
/// </summary>
public string? StoreUrl { get; set; }
/// <summary>
/// 任务状态
/// </summary>
public TaskStatusEnum TaskStatus { get; set; }
/// <summary>
/// 创建时间
/// </summary>
public DateTime CreationTime { get; set; }
}

View File

@@ -0,0 +1,24 @@
using Yi.Framework.AiHub.Domain.Shared.Enums;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Chat;
/// <summary>
/// 图片任务分页查询输入
/// </summary>
public class ImageTaskPageInput
{
/// <summary>
/// 页码从1开始
/// </summary>
public int PageIndex { get; set; } = 1;
/// <summary>
/// 每页数量
/// </summary>
public int PageSize { get; set; } = 10;
/// <summary>
/// 任务状态筛选(可选)
/// </summary>
public TaskStatusEnum? TaskStatus { get; set; }
}

View File

@@ -0,0 +1,61 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Volo.Abp.BackgroundJobs;
using Volo.Abp.DependencyInjection;
using Yi.Framework.AiHub.Domain.Entities.Chat;
using Yi.Framework.AiHub.Domain.Managers;
using Yi.Framework.AiHub.Domain.Shared.Enums;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace Yi.Framework.AiHub.Application.Jobs;
/// <summary>
/// 图片生成后台任务
/// </summary>
public class ImageGenerationJob : AsyncBackgroundJob<ImageGenerationJobArgs>, ITransientDependency
{
private readonly ILogger<ImageGenerationJob> _logger;
private readonly AiGateWayManager _aiGateWayManager;
private readonly ISqlSugarRepository<ImageStoreTaskAggregateRoot> _imageStoreTaskRepository;
public ImageGenerationJob(
ILogger<ImageGenerationJob> logger,
AiGateWayManager aiGateWayManager,
ISqlSugarRepository<ImageStoreTaskAggregateRoot> imageStoreTaskRepository)
{
_logger = logger;
_aiGateWayManager = aiGateWayManager;
_imageStoreTaskRepository = imageStoreTaskRepository;
}
public override async Task ExecuteAsync(ImageGenerationJobArgs args)
{
_logger.LogInformation("开始执行图片生成任务TaskId: {TaskId}, ModelId: {ModelId}, UserId: {UserId}",
args.TaskId, args.ModelId, args.UserId);
try
{
var request = JsonSerializer.Deserialize<JsonElement>(args.RequestJson);
await _aiGateWayManager.GeminiGenerateContentImageForStatisticsAsync(
args.TaskId,
args.ModelId,
request,
args.UserId);
_logger.LogInformation("图片生成任务完成TaskId: {TaskId}", args.TaskId);
}
catch (Exception ex)
{
_logger.LogError(ex, "图片生成任务失败TaskId: {TaskId}, Error: {Error}", args.TaskId, ex.Message);
// 更新任务状态为失败
var task = await _imageStoreTaskRepository.GetFirstAsync(x => x.Id == args.TaskId);
if (task != null)
{
task.TaskStatus = TaskStatusEnum.Fail;
await _imageStoreTaskRepository.UpdateAsync(task);
}
}
}
}

View File

@@ -0,0 +1,27 @@
namespace Yi.Framework.AiHub.Application.Jobs;
/// <summary>
/// 图片生成后台任务参数
/// </summary>
public class ImageGenerationJobArgs
{
/// <summary>
/// 图片任务ID
/// </summary>
public Guid TaskId { get; set; }
/// <summary>
/// 模型ID
/// </summary>
public string ModelId { get; set; } = string.Empty;
/// <summary>
/// 请求JSON字符串
/// </summary>
public string RequestJson { get; set; } = string.Empty;
/// <summary>
/// 用户ID
/// </summary>
public Guid UserId { get; set; }
}

View File

@@ -0,0 +1,271 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Volo.Abp;
using Volo.Abp.Application.Services;
using Volo.Abp.BackgroundJobs;
using Volo.Abp.Guids;
using Volo.Abp.Users;
using Yi.Framework.AiHub.Application.Contracts.Dtos.Chat;
using Yi.Framework.AiHub.Application.Jobs;
using Yi.Framework.AiHub.Domain.Entities.Chat;
using Yi.Framework.AiHub.Domain.Extensions;
using Yi.Framework.AiHub.Domain.Managers;
using Yi.Framework.AiHub.Domain.Shared.Consts;
using Yi.Framework.AiHub.Domain.Shared.Enums;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace Yi.Framework.AiHub.Application.Services.Chat;
/// <summary>
/// AI图片生成服务
/// </summary>
[Authorize]
public class AiImageService : ApplicationService
{
private readonly ISqlSugarRepository<ImageStoreTaskAggregateRoot> _imageTaskRepository;
private readonly IBackgroundJobManager _backgroundJobManager;
private readonly AiBlacklistManager _aiBlacklistManager;
private readonly PremiumPackageManager _premiumPackageManager;
private readonly IGuidGenerator _guidGenerator;
private readonly IWebHostEnvironment _webHostEnvironment;
public AiImageService(
ISqlSugarRepository<ImageStoreTaskAggregateRoot> imageTaskRepository,
IBackgroundJobManager backgroundJobManager,
AiBlacklistManager aiBlacklistManager,
PremiumPackageManager premiumPackageManager,
IGuidGenerator guidGenerator,
IWebHostEnvironment webHostEnvironment)
{
_imageTaskRepository = imageTaskRepository;
_backgroundJobManager = backgroundJobManager;
_aiBlacklistManager = aiBlacklistManager;
_premiumPackageManager = premiumPackageManager;
_guidGenerator = guidGenerator;
_webHostEnvironment = webHostEnvironment;
}
/// <summary>
/// 生成图片(异步任务)
/// </summary>
/// <param name="input">图片生成输入参数</param>
/// <returns>任务ID</returns>
[HttpPost("ai-image/generate")]
[Authorize]
public async Task<Guid> GenerateAsync([FromBody] ImageGenerationInput input)
{
var userId = CurrentUser.GetId();
// 黑名单校验
await _aiBlacklistManager.VerifiyAiBlacklist(userId);
// VIP校验
if (!CurrentUser.IsAiVip())
{
throw new UserFriendlyException("图片生成功能需要VIP用户才能使用请购买VIP后重新登录重试");
}
// 尊享包校验
if (PremiumPackageConst.ModeIds.Contains(input.ModelId))
{
var availableTokens = await _premiumPackageManager.GetAvailableTokensAsync(userId);
if (availableTokens <= 0)
{
throw new UserFriendlyException("尊享token包用量不足请先购买尊享token包");
}
}
// 创建任务实体
var task = new ImageStoreTaskAggregateRoot
{
Prompt = input.Prompt,
ReferenceImagesBase64 = input.ReferenceImagesBase64 ?? new List<string>(),
ReferenceImagesUrl = new List<string>(),
TaskStatus = TaskStatusEnum.Processing,
UserId = userId
};
await _imageTaskRepository.InsertAsync(task);
var taskId = task.Id;
// 构建请求JSON
var requestJson = JsonSerializer.Serialize(new
{
prompt = input.Prompt,
referenceImages = input.ReferenceImagesBase64
});
// 入队后台任务
await _backgroundJobManager.EnqueueAsync(new ImageGenerationJobArgs
{
TaskId = taskId,
ModelId = input.ModelId,
RequestJson = requestJson,
UserId = userId
});
return taskId;
}
/// <summary>
/// 查询任务状态
/// </summary>
/// <param name="taskId">任务ID</param>
/// <returns>任务详情</returns>
[HttpGet("ai-image/task/{taskId}")]
public async Task<ImageTaskOutput> GetTaskAsync([FromRoute] Guid taskId)
{
var userId = CurrentUser.GetId();
var task = await _imageTaskRepository.GetFirstAsync(x => x.Id == taskId && x.UserId == userId);
if (task == null)
{
throw new UserFriendlyException("任务不存在或无权访问");
}
return new ImageTaskOutput
{
Id = task.Id,
Prompt = task.Prompt,
ReferenceImagesBase64 = task.ReferenceImagesBase64,
ReferenceImagesUrl = task.ReferenceImagesUrl,
StoreBase64 = task.StoreBase64,
StoreUrl = task.StoreUrl,
TaskStatus = task.TaskStatus,
CreationTime = task.CreationTime
};
}
/// <summary>
/// 上传Base64图片转换为URL
/// </summary>
/// <param name="base64Data">Base64图片数据包含前缀如 data:image/png;base64,</param>
/// <returns>图片访问URL</returns>
[HttpPost("ai-image/upload-base64")]
public async Task<string> UploadBase64ToUrlAsync([FromBody] string base64Data)
{
if (string.IsNullOrWhiteSpace(base64Data))
{
throw new UserFriendlyException("Base64数据不能为空");
}
// 解析Base64数据
string mimeType = "image/png";
string base64Content = base64Data;
if (base64Data.Contains(","))
{
var parts = base64Data.Split(',');
if (parts.Length == 2)
{
// 提取MIME类型
var header = parts[0];
if (header.Contains(":") && header.Contains(";"))
{
mimeType = header.Split(':')[1].Split(';')[0];
}
base64Content = parts[1];
}
}
// 获取文件扩展名
var extension = mimeType switch
{
"image/png" => ".png",
"image/jpeg" => ".jpg",
"image/jpg" => ".jpg",
"image/gif" => ".gif",
"image/webp" => ".webp",
_ => ".png"
};
// 解码Base64
byte[] imageBytes;
try
{
imageBytes = Convert.FromBase64String(base64Content);
}
catch (FormatException)
{
throw new UserFriendlyException("Base64格式无效");
}
// 创建存储目录
var uploadPath = Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot", "ai-images");
if (!Directory.Exists(uploadPath))
{
Directory.CreateDirectory(uploadPath);
}
// 生成文件名并保存
var fileId = _guidGenerator.Create();
var fileName = $"{fileId}{extension}";
var filePath = Path.Combine(uploadPath, fileName);
await File.WriteAllBytesAsync(filePath, imageBytes);
// 返回访问URL
return $"/ai-images/{fileName}";
}
/// <summary>
/// 分页查询任务列表
/// </summary>
/// <param name="input">分页查询参数</param>
/// <returns>任务列表</returns>
[HttpGet("ai-image/tasks")]
public async Task<PagedResult<ImageTaskOutput>> GetTaskPageAsync([FromQuery] ImageTaskPageInput input)
{
var userId = CurrentUser.GetId();
var query = _imageTaskRepository._DbQueryable
.Where(x => x.UserId == userId)
.WhereIF(input.TaskStatus.HasValue, x => x.TaskStatus == input.TaskStatus!.Value)
.OrderByDescending(x => x.CreationTime);
var total = await query.CountAsync();
var items = await query
.Skip((input.PageIndex - 1) * input.PageSize)
.Take(input.PageSize)
.Select(x => new ImageTaskOutput
{
Id = x.Id,
Prompt = x.Prompt,
ReferenceImagesBase64 = x.ReferenceImagesBase64,
ReferenceImagesUrl = x.ReferenceImagesUrl,
StoreBase64 = x.StoreBase64,
StoreUrl = x.StoreUrl,
TaskStatus = x.TaskStatus,
CreationTime = x.CreationTime
})
.ToListAsync();
return new PagedResult<ImageTaskOutput>(total, items);
}
}
/// <summary>
/// 分页结果
/// </summary>
/// <typeparam name="T">数据类型</typeparam>
public class PagedResult<T>
{
/// <summary>
/// 总数
/// </summary>
public long Total { get; set; }
/// <summary>
/// 数据列表
/// </summary>
public List<T> Items { get; set; }
public PagedResult(long total, List<T> items)
{
Total = total;
Items = items;
}
}

View File

@@ -2,6 +2,10 @@
<Import Project="..\..\..\common.props" />
<ItemGroup>
<PackageReference Include="Volo.Abp.BackgroundJobs.Abstractions" Version="$(AbpVersion)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\framework\Yi.Framework.Ddd.Application\Yi.Framework.Ddd.Application.csproj" />
<ProjectReference Include="..\Yi.Framework.AiHub.Application.Contracts\Yi.Framework.AiHub.Application.Contracts.csproj" />

View File

@@ -966,9 +966,14 @@ public class AiGateWayManager : DomainService
//解析json获取base64字符串
var imageBase64 = GeminiGenerateContentAcquirer.GetImageBase64(data);
//base64字符串存储返回绝对路径用于最后存储
var storeUrl = Base64ToPng(imageBase64, "存储的路径?这个放什么");
//远程调用上传接口将base64转换为URL
var httpClient = LazyServiceProvider.LazyGetRequiredService<IHttpClientFactory>().CreateClient();
var uploadUrl = $"https://ccnetcore.com/prod-api/ai-hub/ai-image/upload-base64";
var content = new StringContent(JsonSerializer.Serialize(imageBase64), Encoding.UTF8, "application/json");
var uploadResponse = await httpClient.PostAsync(uploadUrl, content, cancellationToken);
uploadResponse.EnsureSuccessStatusCode();
var storeUrl = await uploadResponse.Content.ReadAsStringAsync(cancellationToken);
storeUrl = storeUrl.Trim('"'); // 移除JSON字符串的引号
var tokenUsage = new ThorUsageResponse
{
@@ -994,42 +999,12 @@ public class AiGateWayManager : DomainService
await PremiumPackageManager.TryConsumeTokensAsync(userId, totalTokens);
}
//设置存储url
//设置存储base64和url
imageStoreTask.StoreBase64 = imageBase64;
imageStoreTask.SetSuccess(storeUrl);
await _imageStoreTaskRepository.UpdateAsync(imageStoreTask);
}
/// <summary>
/// 将 Base64 字符串转换为 PNG 图片并保存
/// </summary>
/// <param name="base64String">Base64 字符串(可以包含或不包含 data:image/png;base64, 前缀)</param>
/// <param name="outputPath">输出文件路径</param>
private string Base64ToPng(string base64String, string outputPath)
{
try
{
// 移除可能存在的 data URI scheme 前缀
if (base64String.Contains(","))
{
base64String = base64String.Substring(base64String.IndexOf(",") + 1);
}
// 将 base64 字符串转换为字节数组
byte[] imageBytes = Convert.FromBase64String(base64String);
// 将字节数组写入文件
File.WriteAllBytes(outputPath, imageBytes);
}
catch (Exception ex)
{
throw new UserFriendlyException("gemini Base64转图像失败", innerException: ex);
}
//todo
//路径拼接一下?
return outputPath;
}
#region Http响应
private static readonly byte[] EventPrefix = "event: "u8.ToArray();