Compare commits
5
Commits
25d481d7d6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0c94f3eac | ||
|
|
9edbe83376 | ||
|
|
8019b24b61 | ||
|
|
5875ffe671 | ||
|
|
6d41f52de5 |
@@ -15,6 +15,7 @@ namespace LMS.Repository.DTO
|
||||
public required string FileName { get; set; }
|
||||
public required string ContentType { get; set; }
|
||||
public Dictionary<string, string> Metadata { get; set; } = new();
|
||||
public string? Type { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -16,4 +16,6 @@ public class FileUploadSettings
|
||||
{
|
||||
public long MaxFileSize { get; set; } = 3 * 1024 * 1024; // 5MB
|
||||
public List<string> AllowedContentTypes { get; set; } = new();
|
||||
public int DailyUploadLimit { get; set; } = 5;
|
||||
public int VideoDailyUploadLimit { get; set; } = 50;
|
||||
}
|
||||
|
||||
@@ -46,3 +46,9 @@ public class ForwardModel
|
||||
[Required]
|
||||
public string Word { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class ForwardModelOpenAI : ForwardModel
|
||||
{
|
||||
[Required]
|
||||
public string OpenAIBodyString { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ namespace LMS.Tools.FileTool
|
||||
UploadTime = DateTime.Now,
|
||||
Status = "active",
|
||||
CreatedAt = DateTime.Now,
|
||||
DeleteTime = qiniuSettings.DeleteDay != null ? BeijingTimeExtension.GetBeijingTime().AddDays((double)qiniuSettings.DeleteDay) : DateTime.MaxValue // 默认未删除
|
||||
DeleteTime = qiniuSettings.DeleteDay != null ? BeijingTimeExtension.GetBeijingTime().AddDays((double)qiniuSettings.DeleteDay) : new DateTime(2099, 12, 31, 23, 59, 59) // 默认未删除
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ public class ForwardController(ForwardWordService forwardWordService, ILogger<Fo
|
||||
#endregion
|
||||
|
||||
|
||||
#region 流式转发接口,需要系统数据
|
||||
#region 流式转发接口(只返回数据),需要系统数据
|
||||
/// <summary>
|
||||
/// 流式转发
|
||||
/// </summary>
|
||||
@@ -83,6 +83,82 @@ public class ForwardController(ForwardWordService forwardWordService, ILogger<Fo
|
||||
|
||||
#endregion
|
||||
|
||||
#region OpenAI 格式流式返回接口(保留接口),需要系统数据
|
||||
|
||||
[HttpPost]
|
||||
[Route("/lms/Forward/forward-stream-struct")]
|
||||
public async Task<IActionResult> ForwardStreamStruct([FromBody] ForwardModelOpenAI req)
|
||||
{
|
||||
HttpResponseMessage? upstreamResponse = null;
|
||||
try
|
||||
{
|
||||
// 1. Service 层请求 (保持不变,这里已经是 HeadersRead 模式了)
|
||||
upstreamResponse = await _forwardWordService.ForwardWordStreamRaw(req);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return BadRequest(e.Message);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 2. 处理错误情况
|
||||
if (!upstreamResponse.IsSuccessStatusCode)
|
||||
{
|
||||
Response.StatusCode = (int)upstreamResponse.StatusCode;
|
||||
// 复制 Content-Type,防止前端解析乱码
|
||||
if (upstreamResponse.Content.Headers.ContentType != null)
|
||||
Response.ContentType = upstreamResponse.Content.Headers.ContentType.ToString();
|
||||
|
||||
var errorContent = await upstreamResponse.Content.ReadAsStringAsync();
|
||||
await Response.WriteAsync(errorContent);
|
||||
return new EmptyResult();
|
||||
}
|
||||
|
||||
// 3. 成功连接,设置 SSE 响应头
|
||||
Response.ContentType = "text/event-stream";
|
||||
Response.Headers.Add("Cache-Control", "no-cache");
|
||||
Response.Headers.Add("Connection", "keep-alive");
|
||||
// 禁用缓冲 (对于某些服务器环境很重要)
|
||||
// var responseFeature = HttpContext.Features.Get<IHttpResponseBodyFeature>();
|
||||
// responseFeature?.DisableBuffering();
|
||||
|
||||
// 4. 【核心修改】手动流式转发循环
|
||||
await using var upstreamStream = await upstreamResponse.Content.ReadAsStreamAsync();
|
||||
|
||||
// 定义一个较小的缓冲区,比如 1024 甚至更小,其实 buffer 大小不影响实时性,因为 ReadAsync 会在收到任何数据时立即返回
|
||||
var buffer = new byte[4096];
|
||||
int bytesRead;
|
||||
|
||||
// 使用 HttpContext.RequestAborted,这样前端断开时后端也会停止读取
|
||||
while ((bytesRead = await upstreamStream.ReadAsync(buffer, HttpContext.RequestAborted)) != 0)
|
||||
{
|
||||
// 收到多少发多少
|
||||
await Response.Body.WriteAsync(buffer.AsMemory(0, bytesRead), HttpContext.RequestAborted);
|
||||
|
||||
// 【关键】立刻刷新缓冲区,将数据强制推送到网络
|
||||
await Response.Body.FlushAsync(HttpContext.RequestAborted);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 客户端(前端)主动断开连接,这是正常现象,不做处理
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 网络异常处理
|
||||
if (!Response.HasStarted) return StatusCode(502);
|
||||
}
|
||||
finally
|
||||
{
|
||||
upstreamResponse?.Dispose();
|
||||
}
|
||||
|
||||
return new EmptyResult();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
#region Post 直接转发接口
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
builder.WebHost.UseUrls("https://0.0.0.0:5001", "http://0.0.0.0:5002");
|
||||
}
|
||||
|
||||
// Add services to the container.
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ using LMS.Tools.HttpTool;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OneOf.Types;
|
||||
using static LMS.Common.Enums.ResponseCodeEnum;
|
||||
using static LMS.Repository.DTO.FileUploadDto;
|
||||
using static LMS.Repository.FileUpload.FileRequestReturn;
|
||||
@@ -88,19 +89,36 @@ namespace LMS.service.Service.FileUploadService
|
||||
{
|
||||
return APIResponseModel<UploadResult>.CreateErrorResponseModel(ResponseCode.ParameterError, "无效的机器ID或未找到关联用户");
|
||||
}
|
||||
|
||||
// 3. 校验当前用户是不是超出了上传限制
|
||||
var userFilesCount = await GetUserUploadToday(userId.Value);
|
||||
if (userFilesCount >= 5)
|
||||
string fileKey;
|
||||
string fileName = $"{Guid.NewGuid().ToString("N")}{Path.GetExtension(request.FileName)}";
|
||||
if (request.Type != "video")
|
||||
{
|
||||
return APIResponseModel<UploadResult>.CreateErrorResponseModel(ResponseCode.ParameterError, "今日上传文件数量已达上限,请明天再试");
|
||||
// 3. 校验当前用户是不是超出了上传限制
|
||||
var userFilesCount = await GetUserUploadToday(userId.Value, request.Type);
|
||||
if (userFilesCount >= _uploadSettings.DailyUploadLimit)
|
||||
{
|
||||
return APIResponseModel<UploadResult>.CreateErrorResponseModel(ResponseCode.ParameterError, "今日上传文件数量已达上限,请明天再试");
|
||||
}
|
||||
fileKey = $"diantu/user/{userId}/{DateTime.Now:yyyyMMdd}/{fileName}";
|
||||
}
|
||||
else
|
||||
{
|
||||
var userFilesCount = await GetUserUploadToday(userId.Value, request.Type);
|
||||
if (userFilesCount >= _uploadSettings.VideoDailyUploadLimit)
|
||||
{
|
||||
return APIResponseModel<UploadResult>.CreateErrorResponseModel(ResponseCode.ParameterError, "今日上传文件数量已达上限,请明天再试");
|
||||
}
|
||||
fileKey = $"upload/user/{userId}/{DateTime.Now:yyyyMMdd}/upload_{fileName}";
|
||||
}
|
||||
|
||||
string fileKey = $"diantu/user/{userId}/{DateTime.Now:yyyyMMdd}/{request.FileName}";
|
||||
|
||||
// 4. 上传到七牛云
|
||||
FileUploads fileUpload = await _qiniuService.UploadFileToQiNiu(fileBytes, userId.Value, request.FileName, fileKey);
|
||||
|
||||
if (request.Type == "video")
|
||||
{
|
||||
fileUpload.Status = "unactive";
|
||||
}
|
||||
// 5. 修改数据库
|
||||
_dbContext.FileUploads.Add(fileUpload);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
@@ -279,9 +297,18 @@ namespace LMS.service.Service.FileUploadService
|
||||
return (totalCount, fileUploads);
|
||||
}
|
||||
|
||||
private async Task<int> GetUserUploadToday(long userId)
|
||||
private async Task<int> GetUserUploadToday(long userId, string type)
|
||||
{
|
||||
return await _dbContext.FileUploads
|
||||
var query = _dbContext.FileUploads.AsQueryable();
|
||||
if (type == "video")
|
||||
{
|
||||
query = query.Where(x => x.Status == "unactive");
|
||||
}
|
||||
else
|
||||
{
|
||||
query = query.Where(x => x.Status == "active");
|
||||
}
|
||||
return await query
|
||||
.CountAsync(f => f.UserId == userId && f.CreatedAt.Date == BeijingTimeExtension.GetBeijingTime().Date);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
using LMS.DAO;
|
||||
using Betalgo.Ranul.OpenAI;
|
||||
using Betalgo.Ranul.OpenAI.Managers;
|
||||
using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels;
|
||||
using LMS.Common.Extensions;
|
||||
using LMS.DAO;
|
||||
using LMS.Repository.DB;
|
||||
using LMS.Repository.Forward;
|
||||
using LMS.Repository.Model;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json;
|
||||
using static LMS.Common.Enums.ResponseCodeEnum;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using LMS.Repository.Model;
|
||||
using Betalgo.Ranul.OpenAI.Managers;
|
||||
using Betalgo.Ranul.OpenAI;
|
||||
using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels;
|
||||
using LMS.Common.Extensions;
|
||||
using static LMS.Common.Enums.ResponseCodeEnum;
|
||||
using static LMS.service.Controllers.ForwardController;
|
||||
|
||||
namespace LMS.service.Service;
|
||||
|
||||
public class ForwardWordService(ApplicationDbContext context)
|
||||
public class ForwardWordService(ApplicationDbContext context, IHttpClientFactory httpClientFactory, MachineService machineService)
|
||||
{
|
||||
private readonly ApplicationDbContext _context = context;
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
|
||||
|
||||
private readonly MachineService _machineService = machineService;
|
||||
|
||||
#region 非流转发接口,需要系统数据
|
||||
/// <summary>
|
||||
/// 转发OpenAi格式的请求 非流
|
||||
@@ -228,7 +235,115 @@ public class ForwardWordService(ApplicationDbContext context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
#region OpenAI 格式流式返回接口(保留接口),需要系统数据
|
||||
|
||||
public async Task<HttpResponseMessage> ForwardWordStreamRaw(ForwardModelOpenAI request)
|
||||
{
|
||||
// --- 1. 基础校验 (保持原有逻辑) ---
|
||||
if (string.IsNullOrWhiteSpace(request.OpenAIBodyString))
|
||||
{
|
||||
throw new Exception("OpenAIBodyString 不能为空");
|
||||
}
|
||||
if (string.IsNullOrEmpty(request.GptUrl)) throw new Exception("请求的url为空");
|
||||
|
||||
var allowedUrls = new[] {
|
||||
"https://laitool.net",
|
||||
"https://api.laitool.cc",
|
||||
"https://laitool.cc",
|
||||
"https://zhiluoai.net"
|
||||
};
|
||||
|
||||
// 简单的校验逻辑优化
|
||||
if (!allowedUrls.Any(url => request.GptUrl.StartsWith(url)))
|
||||
{
|
||||
throw new Exception("请求的url不合法");
|
||||
}
|
||||
|
||||
// 校验机器码
|
||||
Machine? machine = await _machineService.GetActiveMachineByMachineId(request.MachineId);
|
||||
if (machine == null)
|
||||
{
|
||||
throw new Exception("机器码不存在或已过期");
|
||||
}
|
||||
|
||||
// ================= 2. JSON 结构解析与校验 =================
|
||||
JObject jsonBody;
|
||||
try
|
||||
{
|
||||
jsonBody = JObject.Parse(request.OpenAIBodyString);
|
||||
}
|
||||
catch (JsonReaderException)
|
||||
{
|
||||
throw new Exception("OpenAIBodyString 不是有效的 JSON 格式");
|
||||
}
|
||||
|
||||
// 检查 messages 是否存在且为数组
|
||||
var messages = jsonBody["messages"] as JArray;
|
||||
if (messages == null || messages.Count == 0)
|
||||
{
|
||||
throw new Exception("请求体结构错误: 缺少 'messages' 数组");
|
||||
}
|
||||
|
||||
// --- 2. 获取提示词预设 (保持原有逻辑) ---
|
||||
Prompt? prompt = await _context.Prompt.FirstOrDefaultAsync(x => x.PromptTypeId == request.PromptTypeId && x.Id == request.PromptId);
|
||||
if (prompt == null)
|
||||
{
|
||||
throw new Exception("FindPromptStringFail"); // 建议使用具体错误码
|
||||
}
|
||||
|
||||
// ================= 4. 替换逻辑 (System) =================
|
||||
// 遍历寻找 role 为 system 的消息
|
||||
var systemMsg = messages.FirstOrDefault(m => m["role"]?.ToString() == "system");
|
||||
if (systemMsg != null)
|
||||
{
|
||||
// 没有数据 默认为 "{{SYSTEM}}"
|
||||
var content = systemMsg["content"]?.ToString() ?? "{{SYSTEM}}";
|
||||
// 检查是否包含占位符 "{{SYSTEM}}"
|
||||
if (content.Contains("{{SYSTEM}}"))
|
||||
{
|
||||
// 替换占位符为真实的 promptString
|
||||
systemMsg["content"] = content.Replace("{{SYSTEM}}", prompt.PromptString);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception("缺少 system 消息节点");
|
||||
}
|
||||
|
||||
// ================= 5. 强制修正关键字段 =================
|
||||
// 强制使用 C# 模型类中指定的 Model,或者校验两者是否一致 (这里选择覆盖,以 C# 参数为准)
|
||||
jsonBody["model"] = request.Model;
|
||||
|
||||
// 强制开启流式
|
||||
jsonBody["stream"] = true;
|
||||
var finalJsonContent = jsonBody.ToString(Formatting.None);
|
||||
|
||||
//var jsonContent = JsonConvert.SerializeObject(finalJsonContent, new JsonSerializerSettings { ContractResolver = new LowercaseContractResolver() });
|
||||
var httpContent = new StringContent(finalJsonContent, Encoding.UTF8, "application/json");
|
||||
|
||||
// --- 4. 发起 HTTP 请求 ---
|
||||
var client = _httpClientFactory.CreateClient(); // 或者直接 new HttpClient();
|
||||
|
||||
// 拼接完整的 API 地址,通常 OpenAI 兼容接口的路径是 /v1/chat/completions
|
||||
// 注意处理 request.GptUrl 结尾是否有 / 的情况
|
||||
var baseUrl = request.GptUrl.TrimEnd('/');
|
||||
var targetUrl = $"{baseUrl}/v1/chat/completions";
|
||||
|
||||
var requestMessage = new HttpRequestMessage(HttpMethod.Post, targetUrl);
|
||||
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", request.ApiKey);
|
||||
requestMessage.Content = httpContent;
|
||||
|
||||
// *** 关键点:使用 HttpCompletionOption.ResponseHeadersRead ***
|
||||
// 这表示一旦读到响应头就返回,不要等待整个 Body 下载完成,这样才能实现流式转发
|
||||
var response = await client.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
#region Get直接转发接口
|
||||
internal async Task<ActionResult<APIResponseModel<object>>> GetTransfer(GetTransferModel getTransferModel)
|
||||
|
||||
@@ -216,6 +216,19 @@ namespace LMS.service.Service
|
||||
|
||||
#endregion
|
||||
|
||||
#region 查询当前的机器码状态,返回机器码
|
||||
|
||||
public async Task<Machine?> GetActiveMachineByMachineId(string machineId)
|
||||
{
|
||||
Machine? machine = await _context.Machine.FirstOrDefaultAsync(
|
||||
x => x.MachineId == machineId
|
||||
&& x.Status == MachineStatus.Active
|
||||
&& (x.DeactivationTime == null || x.DeactivationTime > BeijingTimeExtension.GetBeijingTime()));
|
||||
return machine;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 获取机器码状态
|
||||
|
||||
/// <summary>
|
||||
@@ -224,12 +237,12 @@ namespace LMS.service.Service
|
||||
/// <param name="machineId"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="NotImplementedException"></exception>
|
||||
internal async Task<ActionResult<APIResponseModel<MachineStatusResponse>>> GetMachineStatus(string machineId)
|
||||
public async Task<ActionResult<APIResponseModel<MachineStatusResponse>>> GetMachineStatus(string machineId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 获取对应的machine
|
||||
Machine? machine = await _context.Machine.FirstOrDefaultAsync(x => x.MachineId == machineId && x.Status == MachineStatus.Active && x.DeactivationTime > BeijingTimeExtension.GetBeijingTime());
|
||||
Machine? machine = await GetActiveMachineByMachineId(machineId);
|
||||
if (machine == null)
|
||||
{
|
||||
return APIResponseModel<MachineStatusResponse>.CreateErrorResponseModel(ResponseCode.MachineNotFound);
|
||||
|
||||
@@ -412,6 +412,7 @@ namespace LMS.service.Service.Other
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
#region 验证对应的程序和机器码是不是有效
|
||||
/// <summary>
|
||||
/// 验证对应的程序和机器码是不是有效
|
||||
|
||||
@@ -76,7 +76,9 @@
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp"
|
||||
]
|
||||
],
|
||||
"DailyUploadLimit": 100,
|
||||
"VideoDailyUploadLimit": 50
|
||||
},
|
||||
"Version": "1.1.5",
|
||||
"AllowedHosts": "*"
|
||||
|
||||
Reference in New Issue
Block a user