Compare commits

...
5 Commits
Author SHA1 Message Date
admin777 a0c94f3eac 修改图片上传和视频上传限制可配置化 2026-05-26 13:53:40 +08:00
admin777 9edbe83376 修改文件日期和程序的启动端口 2026-05-26 10:13:31 +08:00
admin777 8019b24b61 修改OpenAI原格式的转发逻辑和参数 2025-11-23 18:17:00 +08:00
admin777 5875ffe671 修改机器码的验证,抽象通用方法
添加 OpenAI 格式的 流式转发接口 保持返回的数据格式
2025-11-23 12:04:48 +08:00
admin777 6d41f52de5 新增文件上传分类 不同分类 次数限制不同 文件保存时间不同 2025-09-14 13:57:54 +08:00
11 changed files with 270 additions and 23 deletions
+2 -1
View File
@@ -15,6 +15,7 @@ namespace LMS.Repository.DTO
public required string FileName { get; set; } public required string FileName { get; set; }
public required string ContentType { get; set; } public required string ContentType { get; set; }
public Dictionary<string, string> Metadata { get; set; } = new(); public Dictionary<string, string> Metadata { get; set; } = new();
public string? Type { get; set; }
} }
/// <summary> /// <summary>
@@ -25,7 +26,7 @@ namespace LMS.Repository.DTO
[Required] [Required]
[Url] [Url]
public required string Url { get; set; } public required string Url { get; set; }
[Required] [Required]
public required string FileName { get; set; } public required string FileName { get; set; }
} }
@@ -16,4 +16,6 @@ public class FileUploadSettings
{ {
public long MaxFileSize { get; set; } = 3 * 1024 * 1024; // 5MB public long MaxFileSize { get; set; } = 3 * 1024 * 1024; // 5MB
public List<string> AllowedContentTypes { get; set; } = new(); public List<string> AllowedContentTypes { get; set; } = new();
public int DailyUploadLimit { get; set; } = 5;
public int VideoDailyUploadLimit { get; set; } = 50;
} }
+6
View File
@@ -46,3 +46,9 @@ public class ForwardModel
[Required] [Required]
public string Word { get; set; } = string.Empty; public string Word { get; set; } = string.Empty;
} }
public class ForwardModelOpenAI : ForwardModel
{
[Required]
public string OpenAIBodyString { get; set; } = string.Empty;
}
+1 -1
View File
@@ -128,7 +128,7 @@ namespace LMS.Tools.FileTool
UploadTime = DateTime.Now, UploadTime = DateTime.Now,
Status = "active", Status = "active",
CreatedAt = DateTime.Now, 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) // 默认未删除
}; };
} }
} }
+77 -1
View File
@@ -38,7 +38,7 @@ public class ForwardController(ForwardWordService forwardWordService, ILogger<Fo
#endregion #endregion
#region #region ()
/// <summary> /// <summary>
/// 流式转发 /// 流式转发
/// </summary> /// </summary>
@@ -83,6 +83,82 @@ public class ForwardController(ForwardWordService forwardWordService, ILogger<Fo
#endregion #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 #region Post
+4
View File
@@ -11,6 +11,10 @@ using Microsoft.EntityFrameworkCore;
using Serilog; using Serilog;
var builder = WebApplication.CreateBuilder(args); 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. // Add services to the container.
@@ -10,6 +10,7 @@ using LMS.Tools.HttpTool;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using OneOf.Types;
using static LMS.Common.Enums.ResponseCodeEnum; using static LMS.Common.Enums.ResponseCodeEnum;
using static LMS.Repository.DTO.FileUploadDto; using static LMS.Repository.DTO.FileUploadDto;
using static LMS.Repository.FileUpload.FileRequestReturn; using static LMS.Repository.FileUpload.FileRequestReturn;
@@ -88,19 +89,36 @@ namespace LMS.service.Service.FileUploadService
{ {
return APIResponseModel<UploadResult>.CreateErrorResponseModel(ResponseCode.ParameterError, "无效的机器ID或未找到关联用户"); return APIResponseModel<UploadResult>.CreateErrorResponseModel(ResponseCode.ParameterError, "无效的机器ID或未找到关联用户");
} }
string fileKey;
// 3. 校验当前用户是不是超出了上传限制 string fileName = $"{Guid.NewGuid().ToString("N")}{Path.GetExtension(request.FileName)}";
var userFilesCount = await GetUserUploadToday(userId.Value); if (request.Type != "video")
if (userFilesCount >= 5)
{ {
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. 上传到七牛云 // 4. 上传到七牛云
FileUploads fileUpload = await _qiniuService.UploadFileToQiNiu(fileBytes, userId.Value, request.FileName, fileKey); FileUploads fileUpload = await _qiniuService.UploadFileToQiNiu(fileBytes, userId.Value, request.FileName, fileKey);
if (request.Type == "video")
{
fileUpload.Status = "unactive";
}
// 5. 修改数据库 // 5. 修改数据库
_dbContext.FileUploads.Add(fileUpload); _dbContext.FileUploads.Add(fileUpload);
await _dbContext.SaveChangesAsync(); await _dbContext.SaveChangesAsync();
@@ -279,9 +297,18 @@ namespace LMS.service.Service.FileUploadService
return (totalCount, fileUploads); 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); .CountAsync(f => f.UserId == userId && f.CreatedAt.Date == BeijingTimeExtension.GetBeijingTime().Date);
} }
+124 -9
View File
@@ -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.DB;
using LMS.Repository.Forward; using LMS.Repository.Forward;
using LMS.Repository.Model;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json; using Newtonsoft.Json;
using static LMS.Common.Enums.ResponseCodeEnum; using Newtonsoft.Json.Linq;
using System.Net; using System.Net;
using System.Net.Http.Headers;
using System.Text; using System.Text;
using LMS.Repository.Model; using static LMS.Common.Enums.ResponseCodeEnum;
using Betalgo.Ranul.OpenAI.Managers; using static LMS.service.Controllers.ForwardController;
using Betalgo.Ranul.OpenAI;
using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels;
using LMS.Common.Extensions;
namespace LMS.service.Service; 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 ApplicationDbContext _context = context;
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
private readonly MachineService _machineService = machineService;
#region #region
/// <summary> /// <summary>
/// 转发OpenAi格式的请求 非流 /// 转发OpenAi格式的请求 非流
@@ -228,7 +235,115 @@ public class ForwardWordService(ApplicationDbContext context)
} }
} }
#endregion
#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直接转发接口 #region Get直接转发接口
internal async Task<ActionResult<APIResponseModel<object>>> GetTransfer(GetTransferModel getTransferModel) internal async Task<ActionResult<APIResponseModel<object>>> GetTransfer(GetTransferModel getTransferModel)
+15 -2
View File
@@ -216,6 +216,19 @@ namespace LMS.service.Service
#endregion #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 #region
/// <summary> /// <summary>
@@ -224,12 +237,12 @@ namespace LMS.service.Service
/// <param name="machineId"></param> /// <param name="machineId"></param>
/// <returns></returns> /// <returns></returns>
/// <exception cref="NotImplementedException"></exception> /// <exception cref="NotImplementedException"></exception>
internal async Task<ActionResult<APIResponseModel<MachineStatusResponse>>> GetMachineStatus(string machineId) public async Task<ActionResult<APIResponseModel<MachineStatusResponse>>> GetMachineStatus(string machineId)
{ {
try try
{ {
// 获取对应的machine // 获取对应的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) if (machine == null)
{ {
return APIResponseModel<MachineStatusResponse>.CreateErrorResponseModel(ResponseCode.MachineNotFound); return APIResponseModel<MachineStatusResponse>.CreateErrorResponseModel(ResponseCode.MachineNotFound);
@@ -412,6 +412,7 @@ namespace LMS.service.Service.Other
#endregion #endregion
#region #region
/// <summary> /// <summary>
/// 验证对应的程序和机器码是不是有效 /// 验证对应的程序和机器码是不是有效
+3 -1
View File
@@ -76,7 +76,9 @@
"image/png", "image/png",
"image/gif", "image/gif",
"image/webp" "image/webp"
] ],
"DailyUploadLimit": 100,
"VideoDailyUploadLimit": 50
}, },
"Version": "1.1.5", "Version": "1.1.5",
"AllowedHosts": "*" "AllowedHosts": "*"