Spiga

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项目

  1. 环境:.NET 10.0、Docker桌面版、VS 2026

  2. 创建名为 Qjy.AICopilot.AppHost 的 .NET Asprise 应用主机项目。添加下列引用,目前我们只用到了 PostgreSQL

    <PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.0.0" />
    <PackageReference Include="Aspire.Hosting.AppHost" Version="13.0.0" />
    

    后面有项目目录结构图

  3. 在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 组件,开发者不需要另外安装。

  4. 设置 Qjy.AICopilot.AppHost 为启动项目

  5. 创建名为 Qjy.AICopilot.ServiceDefaults 的 Aspire 服务默认值项目,这个项目创建后什么都不用管,也不用写代码

  6. 后续我们的其他启动项目 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"
}