Net企业级AI项目1:项目基础搭建
2025-09-06 18:02:47前面我做了一个使用 LangChain 做的 AI 通用聊天平台的示例,接下来我们回到 .NET 环境,完成一个企业级的 .NET+AI 的项目。目标是为企业通电,完成一个可扩展、可私有化部署的 AI 应用。让企业能用自然语音操作内部其他系统(ERP/CRM/OA)、获取知识、分析报告。
一、项目分析
1. 背景
我们需要完成一个AI企业助理系统,在现有的系统之上,覆盖一层“智能化层”,完成:
- 智能体和工具调用:赋予 AI 行动能力。
- 检索增强生成,企业知识中枢:赋予 AI 记忆和知识能力
- AI数据分析,一句话生成可视化报表:赋予 AI 分析能力,如 NL2SQL
2. 需求分析
通过背景分析,我们梳理一下大致需要完成的功能:
智能助理(Agent):AI 交互入口(大脑)
对话与上下文管理:支持多轮上下文
意图识别:准确分析用户的输入,判断命令意图
工具调用:调用通过 MCP 接入的外部插件
富响应生成:响应不能局限于纯文本,包含表格、图表
企业知识中枢(RAG):处理非结构化知识(记忆)
文档处理流程:支持多种文档格式上传,实现自动解析、自动分块、向量化计算(嵌入)、向量存储
检索与回答:支持语义搜索,结合LLM生成精准、有来源依据的问答
企业级特性:权限控制、数据时效性
数据报表分析(NL2SQL):处理结构化数据(分析)
NL2SQL引擎:自然语言翻译成 SQL 查询
多数据源支持
自动化分析与可视化:自动生成可视化图表,利用LLM总结图表中的趋势
报告导出
MCP 接入管理(Tools):负责连接外部系统(行动)
服务发现与管理:实现 MCP 服务的注册,注册到AI的能力库
调试与权限:确保操作安全
3. 技术选型
- 后端框架:ASP.NET Core(.NET 10)
- AI 框架:Semantic Kernerl(SK)、Agent Framework
- 知识库:向量数据库(Qdrant)+关系型数据库(PostgreSQL + pgvector)
- 大模型:兼容 OpenAI 接口、支持私有化部署
- 安全方案:Jwt + RABC
- 开发方式:云原生开发 .NET Aspire
- 部署方案:容器化部署
4. 开发流程
- 搭建环境与项目骨架
- 实现核心服务(认证 + AI 网关)
- 构建知识中枢(RAG 核心)
- 构建数据报表服务
- 构建 MCP 插件服务
- 完善与部署准备
二、Aspire
1. 云原生概述
什么是云原生:云原生是一种软件开发的方法论,通俗来说就是在云计算环境中构建和运行应用程序 云计算的特点:弹性、可扩展、高可用 云原生关键点:
- 容器化
- 微服务
- 动态管理
- CI/CD
- 弹性(容错机制)
- 服务网格
- 容器编排工具
- 监控平台(指标、链路、日志)
云原生开发与微服务开发:
- 理念(云原生:云计算为基础;传统:固定的硬件)
- 架构(云原生:微服务、Serverless;传统:单机架构)
- 开发流程(云原生:CI/CD;传统:瀑布模型(没有自动化))
- 基础设施(云原生:通过代码管理依赖资源、自动配置;传统:事先准备、手动配置)
- 服务发现通信(云原生:环境中自带服务发现;传统:Consul、自己实现、提前准备、依赖硬编码、配置文件)
- 监控和日志(云原生:环境中准备好了各种监控、日志、链路追踪;传统:过于依赖外部组件)
- 资源利用率(云原生:动态调整资源;传统:资源分配不灵活)
开发环境和生产环境:
云原生概要已经提出很久了,k8s可以说就是上就是一个云原生环境。
我在2019年的时候跟团队成员开始公司系统微服务转型,那个时候都是自己手撸 k8s 环境,各种中间件集群也是自己搭建的。
那个时候云原生环境确实不好,虽然各大云平台都提供直接的paas产品,但很多收费都较贵,最终还是自己搭建。
更重要的时候本地开发时虽然安装的 docker 版本,提供了mini k8s 环境,但是开发和部署的环境还是不一样的。
2. Aspire 概述
如果能有一个让开发和部署都是云原生的,开发的时候不需要自己去安装 mysql,redis;同时开发的时候也不需要知道最终部署的是阿里云,还是微软云。只要开发环境是基于云原生的,开发环境拥有云原生的特性,比如监控、日志、链路追踪。部署的时候只需要提供对应的云产品的产品就能让系统跑起来,这种跑起来的方式,可能就是提供一个连接字符串就可以了。如果有这样的环境,那云原生开发将变得简单很多。
对的,这就是我们当前的明星出场的时候了。
.NET Aspire 是NET 8.0 LTS提出的一套开发工具,它的作用就是构建和运行云原生应用程序。
特点:
- Dashboard - 中心化的应用监控和探查:F5 启动 .NET Aspire可显示服务的统一视图以及它们的日志、指标和跟踪。
- 组件 Component:开发Asprise应用时,我们无需再在本地安装需要用到的组件环境了,比如mysql、redis。只需要为启动的服务体提供配置,告诉 Asprise该服务需要用到 MongoDb、RabbitMQ 等。
- Azure 专用组件:这里我们用不到,但还是介绍一下,Asprise提供了专用的Azure组件,可以将应用直接部署到 Azure,并且本地开发也能直接只用 Azure 云服务,比如Blob、Cosmos DB 等
- 服务发现:使用Asprise后,开发人员不需要在配置服务发现,而且每一个服务 Asprise 还提供一个代理,这样我们启动某个服务的时候可以直接生成集群,跨服务的请求通过代理来访问集群
- 生成k8s部署清单,当然这个目前还存在 bug,但生成出来的清单稍做修改基本就可以用了。
- Asprise它很新,每天都在变化,目前我只研究到这里。还有很多特点我可能还不知道,期待 Asprise 更好的发展。
3. 创建Aspire项目
环境:.NET 10.0、Docker桌面版、VS 2026
创建名为 Qjy.AICopilot.AppHost 的 .NET Asprise 应用主机项目。添加下列引用,目前我们只用到了 PostgreSQL
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.0.0" /> <PackageReference Include="Aspire.Hosting.AppHost" Version="13.0.0" />后面有项目目录结构图
在Program.cs中添加下列代码,用于创建组件:
var builder = DistributedApplication.CreateBuilder(args); // 添加一个PgSql中间件 var postgresdb = builder.AddPostgres("postgres") .WithDataVolume("postgres-aicopilot") .WithPgWeb(pgWeb => pgWeb.WithHostPort(5050)) .AddDatabase("ai-copilot"); // 启动主Api项目 builder.AddProject<Qjy_AICopilot_HttpApi>("aicopilot-httpapi") .WithUrl("swagger") .WaitFor(postgresdb) .WithReference(postgresdb) .WithReference(migration) .WaitForCompletion(migration); builder.Build().Run();上面代码会自动在 docker 里为项目创建 PostgreSQL 组件,开发者不需要另外安装。
设置 Qjy.AICopilot.AppHost 为启动项目
创建名为 Qjy.AICopilot.ServiceDefaults 的 Aspire 服务默认值项目,这个项目创建后什么都不用管,也不用写代码
后续我们的其他启动项目 Qjy.AICopilot.HttpApi 和 Qjy.AICopilot.MigrationWorker 需要引用 Aspire 服务默认值项目
接下来我们搭建整个项目环境
三、项目环境搭建
1. 系统架构设计

项目目录结构:
src
|--Hosts //接入层
|--|--Qjy.AICopilot.AppHost //Asprise启动项目
|--|--Qjy.AICopilot.HttpApi //主Api应用项目
|--|--Qjy.AICopilot.MigrationWorkApp //数据库迁移
|--|--Qjy.AICopilot.ServiceDefaults //Asprise服务默认值项目
|--Infrastructure //基础设施层
|--|--Qjy.AICopilot.EntityFrameworkCore //数据访问
|--|--Qjy.AICopilot.Infrastructure //基础设施
|--Services //服务层(用例层)
|--|--Qjy.AICopilot.Services.Common //服务层基类
|--|--Qjy.AICopilot.IdentityService //身份服务项目
|--|--Qjy.AICopilot.xxxService //其他服务项目
|--Shared //共享类库
|--|--Qjy.AICopilot.SharedKernel //框架基础类库
2. 搭建共享层核心类库
- 命令查询职责分离
我们的项目采用 CQRS 来实现,命令查询职责分离(CQRS,Command Query Responsibility Segregation)是一种架构模式,它将系统中的写操作与读操作分离开来。CQRS 模式能够提升系统的可伸缩性、性能和可维护性,尤其适用于复杂的业务场景和高并发的系统。在传统的 CRUD(增、删、改、查)架构中,读写操作通常共享同一数据模型,而 CQRS 将这两者彻底分开,让它们有独立的模型、接口和存储方式。
CQRS 模式特别适合与事件溯源(Event Sourcing)一起使用,可以通过事件追溯系统的状态变化,确保数据的一致性。
我们的项目使用 MediatR 来实现 CQRS,首先我们在Qjy.AICopilot.SharedKernel项目创建Messaging文件夹,代码如下:
//Qjy.AICopilot.SharedKernel/Messaging/ICommand.cs
public interface ICommand<out TResponse> : IRequest<TResponse>;
public interface ICommandHandler<in TCommand, TResponse> : IRequestHandler<TCommand, TResponse> where TCommand : ICommand<TResponse>;
//Qjy.AICopilot.SharedKernel/Messaging/IQuery.cs
public interface IQuery<out TResponse> : IRequest<TResponse>;
public interface IQueryHandler<in TQuery, TResponse> : IRequestHandler<TQuery, TResponse> where TQuery : IQuery<TResponse>;
- 通用数据返回对象
通用数据返回对象是一个项目设计模式:项目中,每个接口要返回数据类型是不一样的,比如有些接口返回的是整数、有的返回的是List等等,那么前端在解析不同的返回数据类型时就会很麻烦,为了解决这个问题,需要对返回结果进行统一的封装。
不仅仅是前端访问接口,即使是后端程序各个服务之间,或者各个层次之间都会有数据返回,因此需要规范一个通用的数据返回对象。
接着我们继续创造一个 Result 文件夹,实现下面3个cs代码:
//Qjy.AICopilot.SharedKernel/Result/ResultStatus.CS
public enum ResultStatus
{
Ok,
Error,
Forbidden,
Unauthorized,
NotFound,
Invalid
}
//Qjy.AICopilot.SharedKernel/Result/IResult.cs
public interface IResult
{
IEnumerable<object>? Errors { get; }
bool IsSuccess { get; }
ResultStatus Status { get; }
object? GetValue();
}
//Qjy.AICopilot.SharedKernel/Result/Result.cs
public class Result<T> : IResult
{
protected internal Result(T value)
{
Value = value;
}
protected internal Result(ResultStatus status)
{
Status = status;
}
public T? Value { get; init; }
public bool IsSuccess => Status == ResultStatus.Ok;
public IEnumerable<object>? Errors { get; protected set; }
public ResultStatus Status { get; protected set; } = ResultStatus.Ok;
public object? GetValue()
{
return Value;
}
public static implicit operator Result<T>(Result result)
{
return new Result<T>(default(T))
{
Status = result.Status,
Errors = result.Errors
};
}
}
public class Result : Result<Result>
{
protected internal Result(Result value) : base(value)
{
}
protected internal Result(ResultStatus status) : base(status)
{
}
public static Result From(IResult result)
{
return new Result(result.Status)
{
Errors = result.Errors
};
}
public static Result Success()
{
return new Result(ResultStatus.Ok);
}
public static Result<T> Success<T>(T value)
{
return new Result<T>(value);
}
public static Result Failure()
{
return new Result(ResultStatus.Error);
}
public static Result Failure(params object[] errors)
{
return new Result(ResultStatus.Error)
{
Errors = errors.AsEnumerable()
};
}
public static Result NotFound()
{
return new Result(ResultStatus.NotFound);
}
public static Result NotFound(params string[] errors)
{
return new Result(ResultStatus.NotFound)
{
Errors = errors.AsEnumerable()
};
}
public static Result Forbidden()
{
return new Result(ResultStatus.Forbidden);
}
public static Result Unauthorized(params string[] errors)
{
return new Result(ResultStatus.Unauthorized)
{
Errors = errors.AsEnumerable()
};
}
public static Result Invalid()
{
return new Result(ResultStatus.Invalid);
}
public static Result Invalid(params string[] errors)
{
return new Result(ResultStatus.Invalid)
{
Errors = errors
};
}
}
3. 搭建基础设施层
- Qjy.AICopilot.EntityFrameworkCore
首先,我们创建一个 Qjy.AICopilot.EntityFrameworkCore 类库项目。nuget引入如下:
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.5.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
虽然我们的项目是.NET 10的,但由于pgsql的ORM操作库还没有更新,所以我们这里使用的是Npgsql.EntityFrameworkCore.PostgreSQL 9.0.4的版本。
与此同时我们EntityFrameworkCore也使用的9.x的版本
- Qjy.AICopilot.Infrastructure
接着,我们再创建一个 Qjy.AICopilot.Infrastructure 基础设施项目,该项目是基础设施层的统一入库,其他层只需要引用该项目就可以获取项目中所有的基础设施能力。
4. 搭建服务层
Qjy.AICopilot.Services.Common
这是项目是服务层的基础类库项目,由于服务层可能有很多项目(如身份服务、AI助理服务等)。它们之间有一些通用的业务,可以提炼一个基础类库来供所有服务层共用。
接着创建一个 Contracts 文件夹,用定义服务层的接口。由于具体的实现可能是在其他服务层项目,也有可能是在基础设施层中,甚至可能是在更上层的接口层。抽象一个服务接口层出现,使得应用之间只依赖与接口,不用关系具体的实现。
5. 搭建接口层
- 创建 Web API项目Qjy.AICopilot.HttpApi,API层我们先完成如下的一些基础功能:
- 接入 Swagger 完成 API 文档支持,引用 Swashbuckle.AspNetCore.SwaggerUI。
- 抽象 Controller 基类,目前完成2个功能。提供 MediatR 的 ISender 对象和统一的 ReturnResult 方法。所有的 Controller 都需要继承这个基类。
- 提供一个 CurrentUser 对象,用于获取当前登录用户的信息。
- 提供一个统一的业务异常处理方法,由于我们自定义了统一的 API 返回类型 Result 类,.NET框架并不能从这个类型对象中提供正确的请求状态(如请求正常时识别成200,异常为500。如果是认证失败,就不能正常识别了)。当请求是为认证或授权失败试,返回403状态。
- 提供 DI 扩展方法,我们这里扩展2个方法,分别是 AddMediatR 的注入和服务的注入。
//Qjy.AICopilot.HttpApi/Infrastructure/ApiControllerBase.cs
[ApiController]
public abstract class ApiControllerBase : ControllerBase
{
protected ISender Sender => HttpContext.RequestServices.GetRequiredService<ISender>();
[NonAction]
protected IActionResult ReturnResult(IResult result)
{
switch (result.Status)
{
case ResultStatus.Ok:
{
var value = result.GetValue();
return value is null ? NoContent() : Ok(value);
}
case ResultStatus.Error:
return result.Errors is null ? BadRequest() : BadRequest(new { errors = result.Errors });
case ResultStatus.NotFound:
return result.Errors is null ? NotFound() : NotFound(new { errors = result.Errors });
case ResultStatus.Invalid:
return result.Errors is null ? BadRequest() : BadRequest(new { errors = result.Errors });
case ResultStatus.Forbidden:
return StatusCode(403);
case ResultStatus.Unauthorized:
return Unauthorized();
default:
return BadRequest();
}
}
}
//Qjy.AICopilot.HttpApi/Infrastructure/CurrentUser.cs
public class CurrentUser : ICurrentUser
{
public string? Id { get; }
public string? UserName { get; }
public string? Role { get; }
public bool IsAuthenticated { get; } = false;
public CurrentUser(IHttpContextAccessor httpContextAccessor)
{
var user = httpContextAccessor.HttpContext?.User;
if (user == null) return;
if (!user.Identity!.IsAuthenticated) return;
Id = user.FindFirstValue(ClaimTypes.NameIdentifier);
UserName = user.FindFirstValue(ClaimTypes.Name);
Role = user.FindFirstValue(ClaimTypes.Role);
IsAuthenticated = true;
}
}
//Qjy.AICopilot.HttpApi/Infrastructure/UseCaseExceptionHandler.cs
public class UseCaseExceptionHandler : IExceptionHandler
{
private readonly Dictionary<Type, Func<HttpContext, Exception, Task>> _exceptionHandlers = new()
{
{ typeof(ForbiddenException), HandleForbiddenExceptionAsync }
};
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception,
CancellationToken cancellationToken)
{
var exceptionType = exception.GetType();
if (!_exceptionHandlers.TryGetValue(exceptionType, out var handler)) return false;
await handler.Invoke(httpContext, exception);
return true;
}
private static async Task HandleForbiddenExceptionAsync(HttpContext httpContext, Exception exception)
{
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = StatusCodes.Status403Forbidden,
Title = "Forbidden",
Type = "https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Status/403",
Detail = exception.Message
});
}
}
//Qjy.AICopilot.HttpApi/DependencyInjection.cs
public static class DependencyInjection
{
extension(IHostApplicationBuilder builder)
{
public void AddApplicationService()
{
builder.Services.AddMediatR(cfg =>
{
cfg.LicenseKey = "xxxx";
});
}
public void AddWebServices()
{
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddExceptionHandler<UseCaseExceptionHandler>();
builder.Services.AddProblemDetails();
}
}
}
我们再来看一下 Program 的代码
//Qjy.AICopilot.HttpApi/Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.AddInfrastructures();
builder.AddApplicationService();
builder.AddWebServices();
// Add services to the container.
builder.Services.AddControllers();
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/openapi/v1.json", "v1");
});
}
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.MapDefaultEndpoints();
app.Run();
创建 辅助角色服务项目 Qjy.AICopilot.MigrationWorkApp,用于实现数据库的自动迁移,该项目只需要引用 Qjy.AICopilot.EntityFrameworkCore。
创建 Aspire AppHost 项目,AspHost.cs 代码如下:
//Qjy.AICopilot.EntityFrameworkCore/AspHost.cs
var builder = DistributedApplication.CreateBuilder(args);
// 添加一个PgSql中间件
var postgresdb = builder.AddPostgres("postgres")
.WithDataVolume("postgres-aicopilot")
.WithPgWeb(pgWeb => pgWeb.WithHostPort(5050))
.AddDatabase("ai-copilot");
// 启动数据迁移项目
var migration = builder.AddProject<Qjy_AICopilot_MigrationWorkApp>("aicopilot-migration")
.WithReference(postgresdb)
.WaitFor(postgresdb);
// 启动主Api项目
builder.AddProject<Qjy_AICopilot_HttpApi>("aicopilot-httpapi")
.WithUrl("swagger")
.WaitFor(postgresdb)
.WithReference(postgresdb)
.WithReference(migration)
.WaitForCompletion(migration);
builder.Build().Run();
- 创建 Aspire 服务默认值项目,该项目创建即可,不用修改代码。
以上,我们就完成了基础项目的搭建。
四、实现身份认证服务
身份认证的话,我们直接采用微软提供的 Identity 来实现。
1. 实现 Identity
我们在 Qjy.AICopilot.EntityFrameworkCore 项目添加 Microsoft.AspNetCore.Identity.EntityFrameworkCore 引用,然后创建 AiCopilotDbContext 类,我们这里没有任何扩展,只需要继承 IdentityDbContext 就可以得到 EF Core 提供的数据上下文能力和 Identity 支持。
//Qjy.AICopilot.EntityFrameworkCore/AiCopilotDbContext.cs
public class AiCopilotDbContext(DbContextOptions<AiCopilotDbContext> options) : IdentityDbContext(options);
接着我们在添加一个注册扩展方法
//Qjy.AICopilot.EntityFrameworkCore/DependencyInjection.cs
public static class DependencyInjection
{
public static void AddEfCore(this IHostApplicationBuilder builder)
{
builder.AddNpgsqlDbContext<AiCopilotDbContext>("ai-copilot");
builder.Services.AddIdentityCore<IdentityUser>(options =>
{
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 8;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<AiCopilotDbContext>();
}
}
DI 设计原则:每个项目除了功能的实现外,需要自己来实现注入,上层只需要调用扩展方法即可。不用将所有的注册都放到应用层,会让应用层代码混乱。
2. 实现jwt认证逻辑
身份认证处理数据的持久化支持外,还需要一个标准的认证模式,比如Session、Cookie、Jwt等。由于我们的是一个前后台分离的API服务,使用Jwt模式最合适。接下来我们在 Qjy.AICopilot.Infrastructure 项目提供 Jwt的实现。
我们创建一个 Authentication 文件夹,里面存放认证相关的代码,代码内容如下:
//JwtSettings.cs
public record JwtSettings
{
public required string Issuer { get; set; }
public required string Audience { get; set; }
public required string SecretKey { get; set; }
public int AccessTokenExpirationMinutes { get; set; } = 30;
}
//JwtTokenGenerator.cs
public class JwtTokenGenerator(IOptions<JwtSettings> jwtSettings, UserManager<IdentityUser> userManager) : IJwtTokenGenerator
{
public async Task<string> GenerateTokenAsync(IdentityUser user)
{
// 1. 从配置中读取 JWT 设置
var issuer = jwtSettings.Value.Issuer;
var audience = jwtSettings.Value.Audience;
var secretKey = jwtSettings.Value.SecretKey;
var accessTokenExpirationMinutes = jwtSettings.Value.AccessTokenExpirationMinutes;
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
// 2. 准备 Claims (声明)
// Claims 是 Token 中包含的用户信息,例如用户ID、用户名、角色等
var userClaims = await userManager.GetClaimsAsync(user);
var userRoles = await userManager.GetRolesAsync(user);
var authClaims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id),
new(ClaimTypes.Name, user.UserName!),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), // 保证 Token 唯一性
};
// 添加用户角色到 Claims
authClaims.AddRange(userRoles.Select(role => new Claim(ClaimTypes.Role, role)));
// 添加自定义 Claims
authClaims.AddRange(userClaims);
// 3. 创建 Token 描述符
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(authClaims),
Issuer = issuer,
Audience = audience,
Expires = DateTime.UtcNow.AddMinutes(accessTokenExpirationMinutes),
SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256)
};
// 4. 创建 Token
var tokenHandler = new JwtSecurityTokenHandler();
var token = tokenHandler.CreateToken(tokenDescriptor);
// 5. 序列化为字符串
return tokenHandler.WriteToken(token);
}
}
//Qjy.AICopilot.IdentityService/Contracts/IJwtTokenGenerator.cs
public interface IJwtTokenGenerator
{
Task<string> GenerateTokenAsync(IdentityUser user);
}
3. 服务层实现
我们在服务层创建 Qjy.AICopilot.IdentityService 类库项目,添加 Microsoft.Extensions.Identity.Core 和 Microsoft.Extensions.Identity.Stores 引用。我们这里使用9.x的版本,因为我们底层 ORM 项目是 9.x 的。
创建 Commands 文件夹,提供3个 Command 实现:
//Qjy.AICopilot.IdentityService/Commands/CreateRole.cs
public record CreateRoleCommand(string RoleName) : ICommand<Result<CreatedRoleDto>>;
public class CreateRoleCommandHandler(RoleManager<IdentityRole> roleManager) : ICommandHandler<CreateRoleCommand, Result<CreatedRoleDto>>
{
public async Task<Result<CreatedRoleDto>> Handle(CreateRoleCommand command, CancellationToken cancellationToken)
{
var role = new IdentityRole
{
Name = command.RoleName
};
var result = await roleManager.CreateAsync(role);
return !result.Succeeded ? Result.Failure(result.Errors) : Result.Success(new CreatedRoleDto(role.Id, role.Name));
}
}
//Qjy.AICopilot.IdentityService/Commands/CreateUser.cs
public record CreatedUserDto(string Id, string UserName);
public record CreateUserCommand(string UserName, string Password) : ICommand<Result<CreatedUserDto>>;
public class CreateUserCommandHandler(UserManager<IdentityUser> userManager) : ICommandHandler<CreateUserCommand, Result<CreatedUserDto>>
{
public async Task<Result<CreatedUserDto>> Handle(CreateUserCommand command, CancellationToken cancellationToken)
{
var user = new IdentityUser
{
UserName = command.UserName
};
var result = await userManager.CreateAsync(user, command.Password);
if (!result.Succeeded)
return Result.Failure(result.Errors);
// 默认分配角色
await userManager.AddToRoleAsync(user, "User");
return Result.Success(new CreatedUserDto(user.Id, user.UserName));
}
}
//Qjy.AICopilot.IdentityService/Commands/LoginUser.cs
public record LoginUserDto(string UserName, string Token);
public record LoginUserCommand(string UserName, string Password) : ICommand<Result<LoginUserDto>>;
public class LoginUserCommandHandler(UserManager<IdentityUser> userManager, IJwtTokenGenerator jwtTokenGenerator)
: ICommandHandler<LoginUserCommand, Result<LoginUserDto>>
{
public async Task<Result<LoginUserDto>> Handle(LoginUserCommand command, CancellationToken cancellationToken)
{
// 1. 查找用户
var user = await userManager.FindByNameAsync(command.UserName);
if (user == null)
{
return Result.Unauthorized("用户名或密码无效。");
}
// 2. 验证密码
var result = await userManager.CheckPasswordAsync(user, command.Password);
if (!result)
{
return Result.Unauthorized("用户名或密码无效。");
}
// 3. 登录成功,生成 Token
var token = await jwtTokenGenerator.GenerateTokenAsync(user);
// 4. 返回结果
return Result.Success(new LoginUserDto(user.UserName!, token));
}
}
4. 实现请求授权
虽然 ASP.NET 提供了一个 Authorize 中间件,虽然它可以在 API 层的每个 Action 上判断是否拥有请求 API 的权限。但是授权应该是一个业务逻辑,除了 API 权限校验外,我们还需要对每一个需要授权的 Command 做校验(API 是粗粒度的判断用户是否拥有某个模块的访问权限,Command 中是判断该模块中具体的某项操作是否拥有权限,甚至可以做的更细的数据权限)。
授权是每个业务服务层项目都需要的功能,所以我们在 Qjy.AICopilot.Services.Common 项目中来实现。
- 定义一个 ForbiddenException 异常
- 添加一个 Behavior,之后注册到 MediatR 的 Pipeline 行为中
- 添加一个特性,在每个需要授权的 Command 中添加该特性
//Qjy.AICopilot.Services.Common/ExceptionS/ForbiddenException.cs
public class ForbiddenException(string? message) : Exception(message);
//Qjy.AICopilot.Services.Common/Behaviors/AuthorizationBehavior.cs
public class AuthorizationBehavior<TRequest, TResponse>(ICurrentUser user) :
IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
var requiredPermissions = typeof(TRequest)
.GetCustomAttributes(typeof(AuthorizeRequirementAttribute), true)
.Cast<AuthorizeRequirementAttribute>()
.Select(a => a.Permission)
.ToList();
if (requiredPermissions.Count == 0)
return await next(cancellationToken);
// 1. 用户是否已认证
if (!user.IsAuthenticated) throw new ForbiddenException("用户未登录");
if (user.UserName != "admin")
{
// 2. 获取这些角色包含的权限(可以从数据库查询)
var userPermissions = LoadPermissions(user.Role!);
// 3. 权限校验
if (!requiredPermissions.All(p => userPermissions.Contains(p)))
throw new ForbiddenException("未授权访问");
}
return await next(cancellationToken);
}
private List<string> LoadPermissions(string role)
{
var permissions = new Dictionary<string, List<string>>()
{
["Admin"] = ["Identity.CreateRole"],
["User"] = []
};
return permissions[role];
}
}
//Qjy.AICopilot.Services.Common/Attributes/AuthorizeRequirementAttribute.cs
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class AuthorizeRequirementAttribute(string permission) : Attribute
{
public string Permission { get; } = permission;
}
我们可以在前面完成的 CreateRoleCommand 中添加授权验证,只有管理员才拥有添加角色的权限
[AuthorizeRequirement("Identity.CreateRole")]
public record CreateRoleCommand(string RoleName) : ICommand<Result<CreatedRoleDto>>;
5. 实现API服务
//Qjy.AICopilot.HttpApi/Models/IdentityModels.cs
public record UserRegisterRequest(string Username, string Password);
public record UserLoginRequest(string Username, string Password);
public record CreateRoleRequest(string RoleName);
//Qjy.AICopilot.HttpApi/Controllers/IdentityController.cs
public class IdentityController : ApiControllerBase
{
[HttpPost("register")]
public async Task<IActionResult> Register(UserRegisterRequest request)
{
var result = await Sender.Send(new CreateUserCommand(request.Username, request.Password));
return ReturnResult(result);
}
[HttpPost("login")]
public async Task<IActionResult> Login(UserLoginRequest request)
{
var result = await Sender.Send(new LoginUserCommand(request.Username, request.Password));
return ReturnResult(result);
}
[HttpPost("role")]
public async Task<IActionResult> CreateRole(CreateRoleRequest request)
{
var result = await Sender.Send(new CreateRoleCommand(request.RoleName));
return ReturnResult(result);
}
}
配置依赖注入,我们在 Qjy.AICopilot.HttpApi 项目中的 DependencyInjection 类中修改配置
//Qjy.AICopilot.HttpApi/DependencyInjection.cs
//部分代码
public static class DependencyInjection
{
extension(IHostApplicationBuilder builder)
{
public void AddWebServices()
{
// 添加认证服务
builder.Services.AddAuthentication(options =>
{
// 默认的认证方案和质询方案都设置为 JWT Bearer
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options => // 添加 JWT Bearer 认证处理器
{
options.TokenValidationParameters = new TokenValidationParameters
{
// 服务器时钟偏差
ClockSkew = TimeSpan.Zero,
// --- 关键验证项 ---
ValidateIssuer = true, // 验证颁发者
ValidateAudience = true, // 验证受众
ValidateLifetime = true, // 验证生命周期(是否过期)
ValidateIssuerSigningKey = true, // 验证签名密钥
// --- 配置具体的值 ---
ValidIssuer = jwtSettings.Issuer,
ValidAudience = jwtSettings.Audience,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtSettings.SecretKey)
)
};
});
}
}
}
6. 数据库迁移
在普通的 EF Core 项目中,我们可以使用 add-migration 命令来生成数据库迁移文件。但是,我们现在的环境是在 Aspire 中,数据库会随着项目启动时创建,项目关闭的时候而销毁(可以创建 docker 的卷来持久化数据,但是容器是随着项目关闭也停用的)。因此,我们无法使用命令来生成数据库迁移文件,甚至数据库的连接字符串都是 Aspire 启动的时候注入到 API 项目中去的,如果要写命令也不知道连接字符串怎么写,那怎么解决这个问题呢?
这就是搭建项目时,为什么我们要创建一个数据库迁移项目了,我们让 Aspire 来管理这个项目,那环境就是一样的了,我们启动 Aspire 时使用它来自动完成数据库迁移。
到目前我们还没有为该项目写任何代码,现在我们来只用它,完成 Identity 数据库的迁移。
在 Worker.cs 代码完成2个功能,数据迁移和种子数据。我们来看看代码:
//Qjy.AICopilot.MigrationWorkApp/Worker.cs
public class Worker(IServiceProvider serviceProvider, IHostApplicationLifetime hostApplicationLifetime) : BackgroundService
{
public const string ActivitySourceName = "Migrations";
private static readonly ActivitySource ActivitySource = new(ActivitySourceName);
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
using var activity = ActivitySource.StartActivity("Migrating database", ActivityKind.Client);
try
{
using var scope = serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AiCopilotDbContext>();
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
await RunMigrationAsync(dbContext, cancellationToken);
await SeedDataAsync(roleManager, userManager, cancellationToken);
}
catch (Exception ex)
{
activity?.AddException(ex);
throw;
}
hostApplicationLifetime.StopApplication();
}
private static async Task RunMigrationAsync(AiCopilotDbContext dbContext, CancellationToken cancellationToken)
{
var strategy = dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await dbContext.Database.MigrateAsync(cancellationToken);
});
}
private static async Task SeedDataAsync(RoleManager<IdentityRole> roleManager, UserManager<IdentityUser> userManager, CancellationToken cancellationToken)
{
// 创建默认角色
var roles = new[] { "Admin", "User" };
foreach (var role in roles)
{
if (!await roleManager.RoleExistsAsync(role))
{
await roleManager.CreateAsync(new IdentityRole(role));
}
}
// 创建默认管理员账户
const string adminUserName = "admin";
const string adminPassword = "Admin123!";
var adminUser = await userManager.FindByNameAsync(adminUserName);
if (adminUser == null)
{
adminUser = new IdentityUser
{
UserName = adminUserName
};
var result = await userManager.CreateAsync(adminUser, adminPassword);
if (result.Succeeded)
{
await userManager.AddToRoleAsync(adminUser, "Admin");
}
else
{
Console.WriteLine("创建管理员失败:" + string.Join(",", result.Errors.Select(e => e.Description)));
}
}
}
}
然后在 Program 中启动 Worker,并且启动了一个追踪,我们可以在 Aspire 中查看迁移过程。
//Qjy.AICopilot.MigrationWorkApp/Program.cs
var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
builder.AddEfCore();
builder.Services.AddHostedService<Worker>();
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddSource(Worker.ActivitySourceName));
var host = builder.Build();
host.Run();
完整迁移命令
"C:\Program Files\dotnet\dotnet.exe" ef migrations add --project Qjy.AICopilot.EntityFrameworkCore\Qjy.AICopilot.EntityFrameworkCore.csproj --startup-project Qjy.AICopilot.HttpApi\Qjy.AICopilot.HttpApi.csproj --context Qjy.AICopilot.EntityFrameworkCore.AiCopilotDbContext --configuration Debug Initial --output-dir Migrations
至此,身份认证服务就完成了。
五、实现AI网关服务
1. 设计AI网关领域模型
我们的需求是设计一个与 AI 模型无关的应用平台,因而在搭建项目基础架构时,我们需要设计一个 AI 的网关服务,它既可以对接本地私有化部署的 AI 模型,也有可以任何一个实现 OpenAI 接口的云模型,下面我们来看看如何 AI 网关模型的设计:
- 语言模型相关:可以对接多个语言模型
- 会话相关:可以有多个会话,每个会话有独立上下文。每个会话有很多消息
- 对话模板相关:创建会话时可以选择的预设模板
classDiagram
%% 接口定义
class IAggregateRoot~T~ {
<<interface>>
}
class IEntity~T~ {
<<interface>>
}
%% 主要类定义
class Session {
+Guid Id
+Guid UserId
+Guid TemplateId
+IReadOnlyCollection~Message~ Messages
+AddMessage(string content, MessageType type)
}
class Message {
+int Id
+Guid SessionId
+string Content
+DateTime CreatedAt
+MessageType Type
+Session Session
}
class LanguageModel {
+Guid Id
+string Provider
+string Name
+string BaseUrl
+string ApiKey
+ModelParameters Parameters
+UpdateParameters(ModelParameters parameters)
}
class ConversationTemplate {
+Guid Id
+string Name
+string Description
+string SystemPrompt
+TemplateSpecification Specification
+bool IsEnabled
+UpdateSpecification(TemplateSpecification spec)
}
%% 记录类(值对象)
class ModelParameters {
<<record>>
+int MaxTokens
+double Temperature
}
class TemplateSpecification {
<<record>>
+int? MaxTokens
+double Temperature
+double TopP
}
class MessageType {
}
%% 实现关系
Message ..|> IEntity~int~
LanguageModel ..|> IAggregateRoot~Guid~
ConversationTemplate ..|> IAggregateRoot~Guid~
%% 关联关系
Session "1" *-- "*" Message : contains
LanguageModel "1" --> "1" ModelParameters : has
ConversationTemplate "1" --> "1" TemplateSpecification : has
Message "1" --> "1" MessageType : has type
Session "1" --> "1" ConversationTemplate : uses template
2. 领域驱动开发规范
我们采用领域驱动设计来开发,在实现具体的 AI 网关层之前,我们先要定义一些领域驱动开发的规范。
- 实体模型规范
//Qjy.AICopilot.SharedKernel/Domain/IEntity.cs
public interface IEntity;
public interface IEntity<TId> : IEntity
{
TId Id { get; }
}
//Qjy.AICopilot.SharedKernel/Domain/IAggregateRoot.cs
public interface IAggregateRoot : IEntity;
public interface IAggregateRoot<TId> : IEntity<TId>;
//Qjy.AICopilot.SharedKernel/Domain/BaseEntity.cs
public abstract class BaseEntity<TId> : IEntity<TId>
{
public TId Id { get; } = default!;
}
- 通用仓储模式
//Qjy.AICopilot.SharedKernel/Repository/IReadRepository.cs
public interface IReadRepository<T> where T : class, IAggregateRoot
{
IQueryable<T> GetQueryable();
Task<T?> GetByIdAsync<TKey>(TKey id, CancellationToken cancellationToken = default);
Task<List<T>> GetListAsync(Expression<Func<T, bool>> expression, CancellationToken cancellationToken = default);
Task<int> GetCountAsync(Expression<Func<T, bool>> expression, CancellationToken cancellationToken = default);
Task<T?> GetAsync(Expression<Func<T, bool>> expression, Expression<Func<T, object>>[]? includes = null, CancellationToken cancellationToken = default);
Task<List<T>> GetListAsync(Expression<Func<T, bool>> expression, Expression<Func<T, object>>[]? includes = null, CancellationToken cancellationToken = default);
}
//Qjy.AICopilot.SharedKernel/Repository/IRepository.cs
public interface IRepository<T> : IReadRepository<T> where T : class, IEntity, IAggregateRoot
{
T Add(T entity);
void Update(T entity);
void Delete(T entity);
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
//Qjy.AICopilot.EntityFrameworkCore/Repository/EfReadRepository.cs
public class EfReadRepository<T>(AiCopilotDbContext dbContext) : IReadRepository<T> where T : class, IAggregateRoot
{
public IQueryable<T> GetQueryable()
{
return dbContext.Set<T>().AsQueryable();
}
public async Task<T?> GetByIdAsync<TKey>(TKey id, CancellationToken cancellationToken = default)
where TKey : notnull
{
return await dbContext.Set<T>().FindAsync([id], cancellationToken);
}
public async Task<List<T>> GetListAsync(Expression<Func<T, bool>> expression,
CancellationToken cancellationToken = default)
{
return await dbContext.Set<T>().Where(expression).ToListAsync(cancellationToken);
}
public async Task<int> GetCountAsync(Expression<Func<T, bool>> expression,
CancellationToken cancellationToken = default)
{
return await dbContext.Set<T>().Where(expression).CountAsync(cancellationToken);
}
public async Task<T?> GetAsync(Expression<Func<T, bool>> expression, Expression<Func<T, object>>[]? includes = null, CancellationToken cancellationToken = default)
{
var query = dbContext.Set<T>().AsQueryable();
if (includes != null)
{
foreach (var include in includes)
{
query = query.Include(include);
}
}
return await query.FirstOrDefaultAsync(expression, cancellationToken);
}
public async Task<List<T>> GetListAsync(Expression<Func<T, bool>> expression, Expression<Func<T, object>>[]? includes = null, CancellationToken cancellationToken = default)
{
var query = dbContext.Set<T>().AsQueryable();
if (includes != null)
{
foreach (var include in includes)
{
query = query.Include(include);
}
}
return await query.Where(expression).ToListAsync(cancellationToken);
}
}
//Qjy.AICopilot.EntityFrameworkCore/Repository/EfRepository.cs
public class EfRepository<T>(AiCopilotDbContext dbContext) : EfReadRepository<T>(dbContext), IRepository<T>
where T : class, IEntity, IAggregateRoot
{
private readonly AiCopilotDbContext _dbContext = dbContext;
public T Add(T entity)
{
_dbContext.Set<T>().Add(entity);
return entity;
}
public void Update(T entity)
{
_dbContext.Set<T>().Update(entity);
}
public void Delete(T entity)
{
_dbContext.Set<T>().Remove(entity);
}
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await _dbContext.SaveChangesAsync(cancellationToken);
}
}
//注册
//Qjy.AICopilot.EntityFrameworkCore/DependencyInjection.cs
public static class DependencyInjection
{
public static void AddEfCore(this IHostApplicationBuilder builder)
{
builder.AddNpgsqlDbContext<AiCopilotDbContext>("ai-copilot");
builder.Services.AddScoped(typeof(IReadRepository<>), typeof(EfReadRepository<>));
builder.Services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));
builder.Services.AddScoped<IDataQueryService, DataQueryService>();
builder.Services.AddIdentityCore<IdentityUser>(options =>
{
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 8;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<AiCopilotDbContext>();
}
}
3. 实现AI网关领域实体
我们在 Core 目录下创建一个 Qjy.AICopilot.Core.AiGateway 的类库项目,并创建 Aggregates 文件夹
//Qjy.AICopilot.Core.AiGateway/Aggregates/ConversationTemplate/ConversationTemplate.cs
public class ConversationTemplate : IAggregateRoot
{
protected ConversationTemplate()
{
}
public ConversationTemplate(
string name,
string description,
string systemPrompt,
Guid modelId,
TemplateSpecification specification)
{
Id = Guid.NewGuid();
Name = name;
Description = description;
SystemPrompt = systemPrompt;
Specification = specification;
ModelId = modelId;
IsEnabled = true;
}
public Guid Id { get; private set; }
public string Name { get; private set; } = null!;
public string Description { get; private set; } = null!;
public string SystemPrompt { get; private set; } = null!;
public Guid ModelId { get; private set; }
public TemplateSpecification Specification { get; set; } = null!;
public bool IsEnabled { get; private set; }
public void UpdateSpecification(TemplateSpecification spec)
{
Specification = spec;
}
public void SetSystemPrompt(string systemPrompt)
{
SystemPrompt = systemPrompt;
}
}
//Qjy.AICopilot.Core.AiGateway/Aggregates/ConversationTemplate/TemplateSpecification.cs
public record TemplateSpecification
{
public int? MaxTokens { get; set; }
public float? Temperature { get; set; }
}
//Qjy.AICopilot.Core.AiGateway/Aggregates/LanguageModel/LanguageModel.cs
public class LanguageModel : IAggregateRoot
{
protected LanguageModel()
{
}
public LanguageModel(Guid id, string provider, string name, string baseUrl, string? apiKey, ModelParameters parameters)
{
Id = id;
Provider = provider;
Name = name;
BaseUrl = baseUrl;
ApiKey = apiKey;
Parameters = parameters;
}
public Guid Id { get; private set; }
public string Provider { get; private set; } = null!;
public string Name { get; private set; } = null!;
public string BaseUrl { get; private set; } = null!;
public string? ApiKey { get; private set; }
public ModelParameters Parameters { get; private set; } = null!;
public void UpdateParameters(ModelParameters parameters)
{
Parameters = parameters;
}
}
//Qjy.AICopilot.Core.AiGateway/Aggregates/LanguageModel/ModelParameters.cs
public record ModelParameters
{
public int MaxTokens { get; set; }
public float Temperature { get; set; } = 0.7f;
}
//Qjy.AICopilot.Core.AiGateway/Aggregates/Sessions/Session.cs
public class Session : IAggregateRoot
{
private readonly List<Message> _messages = [];
protected Session()
{
}
public Session(Guid userId, Guid templateId)
{
Id = Guid.NewGuid();
Title = "新会话";
UserId = userId;
TemplateId = templateId;
}
public Guid Id { get; private set; }
public string Title { get; private set; } = null!;
public Guid UserId { get; private set; }
public Guid TemplateId { get; private set; }
public IReadOnlyCollection<Message> Messages => _messages.AsReadOnly();
public void AddMessage(string content, MessageType type)
{
var message = new Message(this, content, type);
_messages.Add(message);
}
}
//Qjy.AICopilot.Core.AiGateway/Aggregates/Sessions/Message.cs
public class Message : IEntity<int>
{
protected Message()
{
}
public Message(Session session, string content, MessageType type)
{
Session = session;
SessionId = session.Id;
Content = content;
Type = type;
CreatedAt = DateTime.UtcNow;
}
public int Id { get; private set; }
public Guid SessionId { get; private set; }
public string Content { get; private set; } = null!;
public DateTime CreatedAt { get; private set; }
public MessageType Type { get; private set; }
public Session Session { get; private set; } = null!;
}
//Qjy.AICopilot.Core.AiGateway/Aggregates/Sessions/MessageType.cs
public enum MessageType
{
User,
Assistant,
System,
Tool
}
4. 数据库映射
- 我们先配置数据库映射关系,在 Qjy.AICopilot.EntityFrameworkCore 项目中添加 Configuration 文件夹,节奏完成网关实体模型与数据库表的映射关系。
//Qjy.AICopilot.EntityFrameworkCore/Configuration/AiGateway/ConversationTemplateConfiguration.cs
public class ConversationTemplateConfiguration : IEntityTypeConfiguration<ConversationTemplate>
{
public void Configure(EntityTypeBuilder<ConversationTemplate> builder)
{
// 配置表名
builder.ToTable("conversation_templates");
// 配置主键
builder.HasKey(ct => ct.Id);
builder.Property(ct => ct.Id).HasColumnName("id");
// 配置属性
builder.Property(ct => ct.Name)
.IsRequired()
.HasMaxLength(200)
.HasColumnName("name");
// 唯一约束
builder.HasIndex(ct => ct.Name)
.IsUnique();
builder.Property(ct => ct.Description)
.HasMaxLength(1000)
.HasColumnName("description"); // 允许为空
builder.Property(ct => ct.SystemPrompt)
.IsRequired()
.HasColumnName("system_prompt");
builder.Property(ct => ct.ModelId)
.IsRequired()
.HasColumnName("model_id");
builder.Property(ct => ct.IsEnabled)
.IsRequired()
.HasColumnName("is_enabled");
// 配置值对象 TemplateSpecification (Record)
builder.OwnsOne(ct => ct.Specification, specBuilder =>
{
// 列名将默认为 Specification_MaxTokens 等
specBuilder.Property(s => s.MaxTokens)
.HasColumnName("max_tokens");
specBuilder.Property(s => s.Temperature)
.HasColumnName("temperature");
});
}
}
//Qjy.AICopilot.EntityFrameworkCore/Configuration/AiGateway/LanguageModelConfiguration.cs
public class LanguageModelConfiguration : IEntityTypeConfiguration<LanguageModel>
{
public void Configure(EntityTypeBuilder<LanguageModel> builder)
{
// 配置表名
builder.ToTable("language_models");
// 配置主键
builder.HasKey(lm => lm.Id);
builder.Property(lm => lm.Id).HasColumnName("id");
// 配置属性
builder.Property(lm => lm.Provider)
.IsRequired()
.HasMaxLength(100)
.HasColumnName("provider");
builder.Property(lm => lm.Name)
.IsRequired()
.HasMaxLength(100)
.HasColumnName("name");
// 唯一约束
builder.HasIndex(lm => new { lm.Provider, lm.Name })
.IsUnique();
builder.Property(lm => lm.BaseUrl)
.IsRequired()
.HasMaxLength(100)
.HasColumnName("base_url");
builder.Property(lm => lm.ApiKey)
.HasMaxLength(100)
.HasColumnName("api_key");
// 配置值对象 ModelParameters
// 这会将其属性映射为 "LanguageModels" 表中的列
builder.OwnsOne(lm => lm.Parameters, parametersBuilder =>
{
parametersBuilder.Property(p => p.MaxTokens)
.IsRequired()
.HasColumnName("max_tokens");
parametersBuilder.Property(p => p.Temperature)
.IsRequired()
.HasColumnName("temperature");
});
}
}
//Qjy.AICopilot.EntityFrameworkCore/Configuration/AiGateway/SessionConfiguration.cs
public class SessionConfiguration : IEntityTypeConfiguration<Session>
{
public void Configure(EntityTypeBuilder<Session> builder)
{
// 配置表名
builder.ToTable("sessions");
// 配置主键
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).HasColumnName("id");
// 配置属性
builder.Property(s => s.Title)
.HasMaxLength(20)
.IsRequired()
.HasColumnName("title");
builder.Property(s => s.TemplateId)
.IsRequired()
.HasColumnName("template_id");
builder.Property(s => s.UserId)
.IsRequired()
.HasColumnName("user_id");
// 为 UserId 创建索引,因为很可能会按用户查询会话
builder.HasIndex(s => s.UserId)
.HasDatabaseName("ix_sessions_user_id");
}
}
//Qjy.AICopilot.EntityFrameworkCore/Configuration/AiGateway/MessageConfiguration.cs
public class MessageConfiguration : IEntityTypeConfiguration<Message>
{
public void Configure(EntityTypeBuilder<Message> builder)
{
// 配置表名
builder.ToTable("messages");
// 配置主键 (int 类型,自动增长)
builder.HasKey(m => m.Id);
builder.Property(m => m.Id)
.HasColumnName("id")
.ValueGeneratedOnAdd();
// 配置属性
builder.Property(m => m.Content)
.IsRequired()
.HasColumnName("content");
builder.Property(m => m.CreatedAt)
.IsRequired()
.HasColumnName("created_at"); //
// 配置枚举 MessageType
// 将枚举存储为字符串("User", "Assistant")而不是整数
builder.Property(m => m.Type)
.IsRequired()
.HasConversion<string>()
.HasMaxLength(50)
.HasColumnName("type"); // <--- 修改
// 外键属性的列名改为小写
builder.Property(m => m.SessionId)
.IsRequired()
.HasColumnName("session_id"); // <--- 修改
// 配置与 Session 的多对一关系
builder.HasOne(m => m.Session) // Message 有一个 Session
.WithMany(s => s.Messages) // Session 有多个 Messages
.HasForeignKey(m => m.SessionId) // 外键是 SessionId
.HasConstraintName("fk_messages_sessions_session_id")
.IsRequired()
.OnDelete(DeleteBehavior.Cascade); // 当 Session 被删除时,其 Messages 也被删除
}
}
- 添加 DbSet 属性
//Qjy.AICopilot.EntityFrameworkCore/AiCopilotDbContext.cs
public class AiCopilotDbContext(DbContextOptions<AiCopilotDbContext> options) : IdentityDbContext(options)
{
// AiGateway 实体模型
public DbSet<LanguageModel> LanguageModels => Set<LanguageModel>();
public DbSet<ConversationTemplate> ConversationTemplates => Set<ConversationTemplate>();
public DbSet<Session> Sessions => Set<Session>();
public DbSet<Message> Messages => Set<Message>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
}
}
5. 实现数据查询服务
我们是架构是采用 CQRS 设计的,对于 Command 操作的操作,我们对仓储对象的范围有严格的要求(可以看到仓储接口只提供了很简单的几个方法)。但是对于查询,因为它不会修改数据,所以查询直接使用 EF 提供的上下文对象更为方便,我们只需要取消数据变更的状态跟踪,以及 SaveChanges 方法即可。 加上我们的项目是个单体项目,因此可以直接创建一个专门的查询服务对象来提供数据库查询服务。我们来看代码
//Qjy.AICopilot.Services.Contracts/IDataQueryService.cs
public interface IDataQueryService
{
public IQueryable<ConversationTemplate> ConversationTemplates { get; }
public IQueryable<LanguageModel> LanguageModels { get; }
public IQueryable<Session> Sessions { get; }
public IQueryable<Message> Messages { get; }
Task<T?> FirstOrDefaultAsync<T>(IQueryable<T> queryable) where T : class;
Task<IList<T>> ToListAsync<T>(IQueryable<T> queryable) where T : class;
Task<bool> AnyAsync<T>(IQueryable<T> queryable) where T : class;
}
//Qjy.AICopilot.EntityFrameworkCore/DataQueryService.cs
public class DataQueryService(AiCopilotDbContext dbContext) : IDataQueryService
{
public IQueryable<ConversationTemplate> ConversationTemplates => dbContext.ConversationTemplates.AsNoTracking();
public IQueryable<LanguageModel> LanguageModels => dbContext.LanguageModels.AsNoTracking();
public IQueryable<Session> Sessions => dbContext.Sessions.AsNoTracking();
public IQueryable<Message> Messages => dbContext.Messages.AsNoTracking();
public async Task<T?> FirstOrDefaultAsync<T>(IQueryable<T> queryable) where T : class
{
return await queryable.AsNoTracking().FirstOrDefaultAsync();
}
public async Task<IList<T>> ToListAsync<T>(IQueryable<T> queryable) where T : class
{
return await queryable.AsNoTracking().ToListAsync();
}
public async Task<bool> AnyAsync<T>(IQueryable<T> queryable) where T : class
{
return await queryable.AsNoTracking().AnyAsync();
}
}
6. 实现AI网关服务用例
在服务层新建一个 Qjy.AICopilot.AiGatewayService 类库项目
- 实现语言模型用例
//Qjy.AICopilot.AiGatewayService/Commands/LanguageModels/CreateLanguageModel.cs
public record CreatedLanguageModelDto(Guid Id, string Provider, string Name);
[AuthorizeRequirement("AiGateway.CreateLanguageModel")]
public record CreateLanguageModelCommand(
string Provider,
string Name,
string BaseUrl,
string? ApiKey,
int MaxTokens,
double Temperature = 0.7) : ICommand<Result<CreatedLanguageModelDto>>;
public class CreateLanguageModelCommandHandler(IRepository<LanguageModel> repo)
: ICommandHandler<CreateLanguageModelCommand, Result<CreatedLanguageModelDto>>
{
public async Task<Result<CreatedLanguageModelDto>> Handle(CreateLanguageModelCommand request, CancellationToken cancellationToken)
{
var result = new LanguageModel(
Guid.NewGuid(),
request.Provider,
request.Name,
request.BaseUrl,
request.ApiKey,
new ModelParameters
{
MaxTokens = request.MaxTokens,
Temperature = request.Temperature
});
repo.Add(result);
await repo.SaveChangesAsync(cancellationToken);
return Result.Success(new CreatedLanguageModelDto(result.Id, result.Provider, result.Name));
}
}
//Qjy.AICopilot.AiGatewayService/Commands/LanguageModels/DeleteLanguageModel.cs
[AuthorizeRequirement("AiGateway.DeleteLanguageModel")]
public record DeleteLanguageModelCommand(Guid Id) : ICommand<Result>;
public class DeleteLanguageModelCommandHandler(IRepository<LanguageModel> repo)
: ICommandHandler<DeleteLanguageModelCommand, Result>
{
public async Task<Result> Handle(DeleteLanguageModelCommand request, CancellationToken cancellationToken)
{
var result = await repo.GetByIdAsync(request.Id, cancellationToken);
if (result == null) return Result.Success();
repo.Delete(result);
await repo.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}
//Qjy.AICopilot.AiGatewayService/Queries/LanguageModels/GetListLanguageModels.cs
public record LanguageModelDto
{
public Guid Id { get; set; }
public required string Provider { get; set; }
public required string Name { get; set; }
public required string BaseUrl { get; set; }
public string? ApiKey { get; set; }
public int MaxTokens { get; set; }
public double Temperature { get; set; }
}
[AuthorizeRequirement("AiGateway.GetListLanguageModels")]
public record GetListLanguageModelsQuery : IQuery<Result<IList<LanguageModelDto>>>;
public class GetListLanguageModelsQueryHandler(
IDataQueryService dataQueryService) : IQueryHandler<GetListLanguageModelsQuery, Result<IList<LanguageModelDto>>>
{
public async Task<Result<IList<LanguageModelDto>>> Handle(GetListLanguageModelsQuery request, CancellationToken cancellationToken)
{
var queryable = dataQueryService.LanguageModels
.Select(lm => new LanguageModelDto
{
Id = lm.Id,
Provider = lm.Provider,
Name = lm.Name,
BaseUrl = lm.BaseUrl,
ApiKey = lm.ApiKey,
MaxTokens = lm.Parameters.MaxTokens,
Temperature = lm.Parameters.Temperature
});
var result= await dataQueryService.ToListAsync(queryable);
return Result.Success(result);
}
}
- 实现对话模版用例
//Qjy.AICopilot.AiGatewayService/Commands/ConversationTemplates/CreateConversationTemplate.cs
public record CreatedConversationTemplateDto(Guid Id, string Name);
[AuthorizeRequirement("AiGateway.CreateConversationTemplate")]
public record CreateConversationTemplateCommand(
string Name,
string Description,
string SystemPrompt,
int? MaxTokens,
double? Temperature) : ICommand<Result<CreatedConversationTemplateDto>>;
public class CreateConversationTemplateCommandHandler(IRepository<ConversationTemplate> repo)
: ICommandHandler<CreateConversationTemplateCommand, Result<CreatedConversationTemplateDto>>
{
public async Task<Result<CreatedConversationTemplateDto>> Handle(CreateConversationTemplateCommand request, CancellationToken cancellationToken)
{
var model = new ConversationTemplate(
request.Name,
request.Description,
request.SystemPrompt,
new TemplateSpecification
{
MaxTokens = request.MaxTokens,
Temperature = request.Temperature
});
repo.Add(model);
await repo.SaveChangesAsync(cancellationToken);
return Result.Success(new CreatedConversationTemplateDto(model.Id, model.Name));
}
}
//Qjy.AICopilot.AiGatewayService/Commands/ConversationTemplates/DeleteConversationTemplate
[AuthorizeRequirement("AiGateway.DeleteConversationTemplate")]
public record DeleteConversationTemplateCommand(Guid Id) : ICommand<Result>;
public class DeleteConversationTemplateCommandHandler(IRepository<ConversationTemplate> modelRepo)
: ICommandHandler<DeleteConversationTemplateCommand, Result>
{
public async Task<Result> Handle(DeleteConversationTemplateCommand request, CancellationToken cancellationToken)
{
var model = await modelRepo.GetByIdAsync(request.Id, cancellationToken);
if (model == null) return Result.Success();
modelRepo.Delete(model);
await modelRepo.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}
//Qjy.AICopilot.AiGatewayService/Queries/ConversationTemplates/GetConversationTemplate.cs
[AuthorizeRequirement("AiGateway.GetConversationTemplate")]
public record GetConversationTemplateQuery(Guid Id) : IQuery<Result<ConversationTemplateDto>>;
public class GetConversationTemplateQueryHandler(
IDataQueryService dataQueryService) : IQueryHandler<GetConversationTemplateQuery, Result<ConversationTemplateDto>>
{
public async Task<Result<ConversationTemplateDto>> Handle(GetConversationTemplateQuery request, CancellationToken cancellationToken)
{
var queryable = dataQueryService.ConversationTemplates
.Where(template => template.Id == request.Id)
.Select(ct => new ConversationTemplateDto
{
Id = ct.Id,
Name = ct.Name,
Description = ct.Description,
SystemPrompt = ct.SystemPrompt,
MaxTokens = ct.Specification.MaxTokens,
Temperature = ct.Specification.Temperature
});
var result= await dataQueryService.FirstOrDefaultAsync(queryable);
return result == null ? Result.NotFound() : Result.Success(result);
}
}
//Qjy.AICopilot.AiGatewayService/Queries/ConversationTemplates/GetListConversationTemplates.cs
[AuthorizeRequirement("AiGateway.GetListConversationTemplates")]
public record GetListConversationTemplatesQuery : IQuery<Result<IList<ConversationTemplateDto>>>;
public class GetListConversationTemplatesQueryHandler(
IDataQueryService dataQueryService) : IQueryHandler<GetListConversationTemplatesQuery, Result<IList<ConversationTemplateDto>>>
{
public async Task<Result<IList<ConversationTemplateDto>>> Handle(GetListConversationTemplatesQuery request, CancellationToken cancellationToken)
{
var queryable = dataQueryService.ConversationTemplates
.Select(ct => new ConversationTemplateDto
{
Id = ct.Id,
Name = ct.Name,
Description = ct.Description,
SystemPrompt = ct.SystemPrompt,
MaxTokens = ct.Specification.MaxTokens,
Temperature = ct.Specification.Temperature
});
var result= await dataQueryService.ToListAsync(queryable);
return Result.Success(result);
}
}
//Qjy.AICopilot.AiGatewayService/Queries/ConversationTemplates/ConversationTemplateDto
public record ConversationTemplateDto
{
public Guid Id { get; set; }
public required string Name { get; set; }
public required string Description { get; set; }
public required string SystemPrompt { get; set; }
public int? MaxTokens { get; set; }
public double? Temperature { get; set; }
public bool IsEnabled { get; set; }
}
- 实现会话用例
//Qjy.AICopilot.AiGatewayService/Commands/Sesstions/CreateSession.cs
public record CreatedSessionDto(Guid Id);
[AuthorizeRequirement("AiGateway.CreateSession")]
public record CreateSessionCommand(Guid TemplateId) : ICommand<Result<CreatedSessionDto>>;
public class CreateSessionCommandHandler(IRepository<Session> repo, ICurrentUser user)
: ICommandHandler<CreateSessionCommand, Result<CreatedSessionDto>>
{
public async Task<Result<CreatedSessionDto>> Handle(CreateSessionCommand request, CancellationToken cancellationToken)
{
var result = new Session(new Guid(user.Id!), request.TemplateId);
repo.Add(result);
await repo.SaveChangesAsync(cancellationToken);
return Result.Success(new CreatedSessionDto(result.Id));
}
}
//Qjy.AICopilot.AiGatewayService/Commands/Sesstions/DeleteSession.cs
[AuthorizeRequirement("AiGateway.DeleteSession")]
public record DeleteSessionCommand(Guid Id) : ICommand<Result>;
public class DeleteSessionCommandHandler(IRepository<Session> repo)
: ICommandHandler<DeleteSessionCommand, Result>
{
public async Task<Result> Handle(DeleteSessionCommand request, CancellationToken cancellationToken)
{
var result = await repo.GetByIdAsync(request.Id, cancellationToken);
if (result == null) return Result.Success();
repo.Delete(result);
await repo.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}
//Qjy.AICopilot.AiGatewayService/Queries/Sesstions/GetListSessions.cs
public record SessionDto
{
public Guid Id { get; set; }
public required string Title { get; set; }
}
[AuthorizeRequirement("AiGateway.GetListSessions")]
public record GetListSessionsQuery : IQuery<Result<IList<SessionDto>>>;
public class GetListSessionsQueryHandler(
IDataQueryService dataQueryService) : IQueryHandler<GetListSessionsQuery, Result<IList<SessionDto>>>
{
public async Task<Result<IList<SessionDto>>> Handle(GetListSessionsQuery request, CancellationToken cancellationToken)
{
var queryable = dataQueryService.Sessions
.Select(s => new SessionDto
{
Id = s.Id,
Title = s.Title
});
var result= await dataQueryService.ToListAsync(queryable);
return Result.Success(result);
}
}
//Qjy.AICopilot.AiGatewayService/Queries/Sesstions/GetListChatMessages.cs
public record GetListChatMessagesQuery(Guid SessionId, int Count, bool IsDesc = true) : IQuery<Result<List<ChatMessage>>>;
public class GetListChatMessagesQueryHandler(
IDataQueryService queryService) : IQueryHandler<GetListChatMessagesQuery, Result<List<ChatMessage>>>
{
public async Task<Result<List<ChatMessage>>> Handle(GetListChatMessagesQuery request, CancellationToken cancellationToken)
{
var query = queryService.Messages
.Where(m => m.SessionId == request.SessionId)
.Take(request.Count);
query = request.IsDesc ? query.OrderByDescending(m => m.CreatedAt) : query.OrderBy(m => m.CreatedAt);
var messages = await queryService.ToListAsync(query);
var orderedMessages = messages.OrderBy(m => m.CreatedAt);
// 将实体转换为 Agent 框架的 ChatMessage
var chatMessages = new List<ChatMessage>();
foreach (var msg in orderedMessages)
{
var role = msg.Type switch
{
MessageType.User => ChatRole.User,
MessageType.Assistant => ChatRole.Assistant,
MessageType.System => ChatRole.System,
_ => ChatRole.User
};
chatMessages.Add(new ChatMessage(role, msg.Content));
}
return Result.Success(chatMessages);
}
}
7. 实现API
//Qjy.AICopilot.HttpApi/Controllers/AiGatewayController.cs
[Route("/api/aigateway")]
public class AiGatewayController : ApiControllerBase
{
[HttpPost("language-model")]
public async Task<IActionResult> CreateLanguageModel(CreateLanguageModelCommand command)
{
var result = await Sender.Send(command);
return ReturnResult(result);
}
[HttpDelete("language-model")]
public async Task<IActionResult> DeleteLanguageModel(DeleteLanguageModelCommand command)
{
var result = await Sender.Send(command);
return ReturnResult(result);
}
[HttpGet("language-model/list")]
public async Task<IActionResult> GetListLanguageModels()
{
var result = await Sender.Send(new GetListLanguageModelsQuery());
return ReturnResult(result);
}
[HttpPost("conversation-template")]
public async Task<IActionResult> CreateConversationTemplate(CreateConversationTemplateCommand command)
{
var result = await Sender.Send(command);
return ReturnResult(result);
}
[HttpDelete("conversation-template")]
public async Task<IActionResult> DeleteConversationTemplate(DeleteConversationTemplateCommand command)
{
var result = await Sender.Send(command);
return ReturnResult(result);
}
[HttpGet("conversation-template")]
public async Task<IActionResult> GetConversationTemplate(GetConversationTemplateQuery query)
{
var result = await Sender.Send(query);
return ReturnResult(result);
}
[HttpGet("conversation-template/list")]
public async Task<IActionResult> GetListConversationTemplates()
{
var result = await Sender.Send(new GetListConversationTemplatesQuery());
return ReturnResult(result);
}
[HttpPost("session")]
public async Task<IActionResult> CreateSession(CreateSessionCommand command)
{
var result = await Sender.Send(command);
return ReturnResult(result);
}
[HttpDelete("session")]
public async Task<IActionResult> DeleteSession(DeleteSessionCommand command)
{
var result = await Sender.Send(command);
return ReturnResult(result);
}
[HttpGet("session/list")]
public async Task<IActionResult> GetListSessions()
{
var result = await Sender.Send(new GetListSessionsQuery());
return ReturnResult(result);
}
}
六、项目演示与测试
1. Asprise 演示

- Asprise 最左侧提供了5个菜单,分别是资源、控制台、结构化、跟踪和指标,从这些菜单我们也能大概清楚 Asprise 的能力
- 上图是资源菜单的截图,可以看到 Asprise 不仅启动了 API 项目和迁移项目,还同时启动了项目依赖的 pgsql 环境,并且创建了 ai-copilot 数据库,同时还提供了一个 pgweb 的数据库管理页面。

上面是控制台的信息,我们可以从这里查看控制台日志。从图中我们可以看到 Asprise 启动时自动创建组件和迁移数据库的过程。
其他菜单就不截图了,可以自行运行项目学习。
2. 身份服务测试
我们点击 API 项目的地址 http://localhost:5110/swagger ,就可以进入项目了,现在我们尝试访问一下身份服务 API。
- 登录测试,前面我们已经创建了默认的 admin 用户
/* post /api/identity/login */
{
"username": "Admin",
"password": "Admin123!"
}
{ "userName": "admin", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1laWQiOiIyOWI3N2JmZS05ZjJlLTRjNzQtYWNmNC00ZTQ4ZWJmYzkzZGMiLCJ1bmlxdWVfbmFtZSI6ImFkbWluIiwianRpIjoiNzdkNWM5MTItZTRlMi00ZDU2LTk1MGItMWQ4NDM0MWRmNDBhIiwicm9sZSI6IkFkbWluIiwibmJmIjoxNzY0NzI3NzgxLCJleHAiOjE3NjQ3Mjk1ODEsImlhdCI6MTc2NDcyNzc4MSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdCIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3QifQ.mgT3XhwkiNU1Vd-m5HLha53O-fTG3r3ydmiUvNL2Qbc" }我们可以看到登录成功,可以拿到了 token
3. AI 网关服务测试
- 创建语言模型
/* post /api/aigateway/language-model */
{
"provider": "qwen3-max-2025-09-23",
"name": "通义千问",
"baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"apiKey": "xxx",
"maxTokens": 100000,
"temperature": 0.7
}
{ "id": "2166f26f-8392-421d-b373-c410e7654a4c", "provider": "qwen3-max-2025-09-23", "name": "通义千问Max" }
- 创建对话模版:接着我们使用刚刚创建的语音模型,创建一个对话模版。
/* post /api/aigateway/conversation-template */
{
"name": "企业助理模版",
"description": "企业助理使用的聊天模版",
"systemPrompt": "你是一位企业助理,转为企业员工提供普通聊天和企业系统服务。",
"modelId": "2166f26f-8392-421d-b373-c410e7654a4c",
"maxTokens": 100000,
"temperature": 0.7
}
{ "id": "abd220f4-32ce-404c-92d2-9ed52e6baeac", "name": "企业助理模版" }
- 创建会话:使用对话模版创能一个新的会话
/* post /api/aigateway/sesion */
{
"templateId": "abd220f4-32ce-404c-92d2-9ed52e6baeac"
}
{ "id": "0e7d7a81-fee0-4b47-8176-c5416d1fc87e" }
