mirror of
https://github.com/239573049/FastGateway.git
synced 2026-10-17 05:20:30 +08:00
添加请求日志
This commit is contained in:
@@ -1,32 +0,0 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Gateway.Domain.Users.Aggregations;
|
||||
|
||||
public class User : IdentityUser<long>
|
||||
{
|
||||
protected User()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public User(string userName,string email) : base(userName)
|
||||
{
|
||||
Email = email;
|
||||
}
|
||||
|
||||
public void SetPassword(string password)
|
||||
{
|
||||
PasswordHash = new PasswordHasher<User>().HashPassword(this, password);
|
||||
}
|
||||
|
||||
public void SetPhoneNumber(string phoneNumber)
|
||||
{
|
||||
PhoneNumber = phoneNumber;
|
||||
}
|
||||
|
||||
public void Lockout()
|
||||
{
|
||||
LockoutEnabled = true;
|
||||
LockoutEnd = DateTimeOffset.MaxValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Gateway.Dto;
|
||||
|
||||
public class RequestLogPanel
|
||||
{
|
||||
public int RequestCount { get; set; }
|
||||
|
||||
public DateTime StartTime { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using FreeSql.DataAnnotations;
|
||||
|
||||
namespace Gateway.Entities;
|
||||
|
||||
[Index("uk_path", nameof(Path), false)]
|
||||
[Index("uk_ip", nameof(Ip), false)]
|
||||
public class RequestLog
|
||||
{
|
||||
[Column(IsIdentity = true, IsPrimary = true)]
|
||||
public int Id { get; set; }
|
||||
|
||||
public string Path { get; set; }
|
||||
|
||||
public string Method { get; set; }
|
||||
|
||||
public string QueryString { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间
|
||||
/// </summary>
|
||||
public DateTime CreatedTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 请求IP
|
||||
/// </summary>
|
||||
public string? Ip { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标地址
|
||||
/// </summary>
|
||||
public string? Target { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态码
|
||||
/// </summary>
|
||||
public int StatusCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 执行时长(ms)
|
||||
/// </summary>
|
||||
public double ExecutionDuration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 浏览器信息
|
||||
/// </summary>
|
||||
public string BrowserInfo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展属性
|
||||
/// </summary>
|
||||
[Column(MapType = typeof(string), StringLength = -1)]
|
||||
public Dictionary<string,string> ExtraProperties { get; set; } = new();
|
||||
}
|
||||
@@ -8,8 +8,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CoreFlex.Jwt" Version="0.1.4" />
|
||||
<PackageReference Include="LiteDB" Version="5.0.17" />
|
||||
<PackageReference Include="FreeSql" Version="3.2.810" />
|
||||
<PackageReference Include="FreeSql.DbContext" Version="3.2.810" />
|
||||
<PackageReference Include="FreeSql.Provider.Sqlite" Version="3.2.810" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.19.6" />
|
||||
<PackageReference Include="Yarp.ReverseProxy" Version="2.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
using CoreFlex.Jwt;
|
||||
using CoreFlex.Module;
|
||||
|
||||
namespace Gateway;
|
||||
|
||||
[DependOns(typeof(CoreFlexJwtModule))]
|
||||
public class GatewayModule : CoreFlexModule
|
||||
{
|
||||
public override void ConfigureServices(CoreFlexServiceContext context)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Gateway.Options;
|
||||
|
||||
public class RequestOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 过滤后缀
|
||||
/// </summary>
|
||||
public static string[] FilterSuffixes { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 需要过滤的ContentType
|
||||
/// </summary>
|
||||
public static string[] FilterContentTypes { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
+52
-16
@@ -1,31 +1,67 @@
|
||||
using CoreFlex.Jwt;
|
||||
using CoreFlex.Module.Extensions;
|
||||
using Gateway;
|
||||
using System.Diagnostics;
|
||||
using FreeSql;
|
||||
using FreeSql.Internal;
|
||||
using Gateway.Entities;
|
||||
using Gateway.Options;
|
||||
using Gateway.Services;
|
||||
using LiteDB;
|
||||
using Gateway.TypeHandlers;
|
||||
|
||||
Utils.TypeHandlers.TryAdd(typeof(Dictionary<string, string>), new StringJsonHandler());
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration.GetSection(nameof(JwtOptions)).Get<JwtOptions>();
|
||||
builder.Configuration.GetSection(nameof(RequestOptions)).Get<RequestOptions>();
|
||||
|
||||
await builder.AddCoreFlexAutoInjectAsync<GatewayModule>();
|
||||
|
||||
builder.Services.AddSingleton<ILiteDatabase>(_ =>
|
||||
builder.Services.AddSingleton<RequestLogService>();
|
||||
builder.Services.AddSingleton<IFreeSql>((_ =>
|
||||
{
|
||||
var dbPath = Path.Combine(builder.Environment.ContentRootPath, "gateway.db");
|
||||
return new LiteDatabase(dbPath);
|
||||
});
|
||||
var freeSql = new FreeSqlBuilder()
|
||||
.UseConnectionString(DataType.Sqlite, builder.Configuration.GetConnectionString("DefaultConnection"))
|
||||
.UseMonitorCommand(cmd => Console.WriteLine($"Sql:{cmd.CommandText}")) //监听SQL语句
|
||||
.UseAutoSyncStructure(true) //自动同步实体结构到数据库,FreeSql不会扫描程序集,只有CRUD时才会生成表。
|
||||
.Build();
|
||||
return freeSql;
|
||||
}));
|
||||
|
||||
builder.Services.AddReverseProxy()
|
||||
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.Use((async (context, next) =>
|
||||
{
|
||||
if (RequestOptions.FilterSuffixes.Any(x => context.Request.Path.Value?.EndsWith(x) == true))
|
||||
return;
|
||||
|
||||
var requestLogService = context.RequestServices.GetRequiredService<RequestLogService>();
|
||||
var ip = context.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
var requestLog = new RequestLog
|
||||
{
|
||||
Path = context.Request.Path,
|
||||
Method = context.Request.Method,
|
||||
QueryString = context.Request.QueryString.ToString(),
|
||||
CreatedTime = DateTime.Now,
|
||||
Ip = ip,
|
||||
BrowserInfo = context.Request.Headers.UserAgent.ToString(),
|
||||
};
|
||||
context.Items["RequestLog"] = requestLog;
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
await next();
|
||||
stopwatch.Stop();
|
||||
|
||||
// 过滤Content-Type
|
||||
if (RequestOptions.FilterContentTypes.Any(x => context.Response.ContentType?.Contains(x) == true))
|
||||
return;
|
||||
|
||||
requestLog.ExecutionDuration = stopwatch.Elapsed.TotalMilliseconds;
|
||||
requestLog.StatusCode = context.Response.StatusCode;
|
||||
await requestLogService.LogAsync(requestLog);
|
||||
}));
|
||||
|
||||
app.MapGet("/api/panel", async (RequestLogService requestLogService, int hours) =>
|
||||
await requestLogService.PanelAsync(hours));
|
||||
|
||||
app.MapReverseProxy();
|
||||
|
||||
app.MapPost("/api/authority/token", async (AuthorityService service, string userName, string password) =>
|
||||
await service.GetTokenAsync(userName, password));
|
||||
|
||||
await app.Services.UseCoreFlexAsync();
|
||||
|
||||
await app.RunAsync();
|
||||
@@ -1,49 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using CoreFlex.Jwt;
|
||||
using CoreFlex.Module;
|
||||
using Gateway.Domain.Users.Aggregations;
|
||||
using LiteDB;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
public class AuthorityService(ILiteDatabase database) : IScopedDependency
|
||||
{
|
||||
public async Task<string> GetTokenAsync(string userName, string password)
|
||||
{
|
||||
var userCollection = database.GetCollection<User>();
|
||||
|
||||
var user = userCollection.FindOne(x => x.UserName == userName);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
throw new Exception("用户不存在");
|
||||
}
|
||||
|
||||
var passwordHasher = new PasswordHasher<User>();
|
||||
|
||||
var verifyResult = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
|
||||
|
||||
if (verifyResult == PasswordVerificationResult.Failed)
|
||||
{
|
||||
throw new Exception("密码错误");
|
||||
}
|
||||
|
||||
if (verifyResult == PasswordVerificationResult.SuccessRehashNeeded)
|
||||
{
|
||||
user.SetPassword(password);
|
||||
|
||||
userCollection.Update(user);
|
||||
}
|
||||
|
||||
var doc = new Dictionary<string, object>();
|
||||
// 使用微软提供的角色常量
|
||||
doc.Add(ClaimTypes.Role, "admin");
|
||||
doc.Add(ClaimTypes.NameIdentifier, user!.Id.ToString());
|
||||
doc.Add(ClaimTypes.Name, user.UserName);
|
||||
doc.Add(ClaimTypes.Email, user.Email);
|
||||
var token = JwtHelper.GeneratorAccessToken(doc);
|
||||
|
||||
return token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using Gateway.Dto;
|
||||
using Gateway.Entities;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 请求日志服务处理
|
||||
/// </summary>
|
||||
public class RequestLogService
|
||||
{
|
||||
private readonly Channel<RequestLog> _channel;
|
||||
private readonly IFreeSql _freeSql;
|
||||
|
||||
public RequestLogService(IFreeSql freeSql)
|
||||
{
|
||||
_freeSql = freeSql;
|
||||
_channel = Channel.CreateUnbounded<RequestLog>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
});
|
||||
|
||||
Task.Run(HandleAsync);
|
||||
}
|
||||
|
||||
private async Task HandleAsync()
|
||||
{
|
||||
while (await _channel.Reader.WaitToReadAsync())
|
||||
{
|
||||
while (_channel.Reader.TryRead(out var requestLog))
|
||||
{
|
||||
await _freeSql.Insert(requestLog).ExecuteAffrowsAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask LogAsync(RequestLog requestLog)
|
||||
{
|
||||
await _channel.Writer.WriteAsync(requestLog);
|
||||
}
|
||||
|
||||
public async Task<List<RequestLogPanel>> PanelAsync(int hours)
|
||||
{
|
||||
// hours是传递的多少小时,查询每小时的请求量,返回一个数组
|
||||
var result = await _freeSql.Ado.QueryAsync<RequestLogPanel>(
|
||||
$"""
|
||||
WITH RECURSIVE HoursBefore AS (
|
||||
SELECT strftime('%Y-%m-%d %H:00:00', 'now', 'localtime') AS StartTime,
|
||||
strftime('%Y-%m-%d %H:59:59', 'now', 'localtime') AS EndTime
|
||||
UNION ALL
|
||||
SELECT datetime(StartTime, '-1 hour') AS StartTime,
|
||||
datetime(EndTime, '-1 hour') AS EndTime
|
||||
FROM HoursBefore
|
||||
WHERE StartTime > datetime('now', 'localtime', '-' || {hours} || ' hours')
|
||||
)
|
||||
SELECT
|
||||
HB.StartTime,
|
||||
COUNT(Log.Id) AS RequestCount
|
||||
FROM
|
||||
HoursBefore HB
|
||||
LEFT JOIN
|
||||
(SELECT * FROM RequestLog WHERE CreatedTime BETWEEN datetime('now', 'localtime', '-' || {hours} || ' hours') AND datetime('now', 'localtime')) AS Log
|
||||
ON
|
||||
Log.CreatedTime >= HB.StartTime
|
||||
AND Log.CreatedTime <= HB.EndTime
|
||||
GROUP BY
|
||||
HB.StartTime
|
||||
ORDER BY
|
||||
HB.StartTime DESC
|
||||
""");
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Text.Json;
|
||||
using FreeSql.Internal.Model;
|
||||
|
||||
namespace Gateway.TypeHandlers;
|
||||
|
||||
public class StringJsonHandler : TypeHandler<Dictionary<string,string>>
|
||||
{
|
||||
public override Dictionary<string, string> Deserialize(object value)
|
||||
{
|
||||
return JsonSerializer.Deserialize<Dictionary<string,string>>((string)value);
|
||||
}
|
||||
|
||||
public override object Serialize(Dictionary<string, string> value)
|
||||
{
|
||||
return JsonSerializer.Serialize(value);
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,39 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"JwtOptions": {
|
||||
"Secret": "123456789asdfghjkl1&345678*asdfghjkl",
|
||||
"EffectiveHours": 12,
|
||||
"Issuer": "Gateway",
|
||||
"Audience": "gateway"
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Data Source=./Gateway.db"
|
||||
},
|
||||
"RequestOptions": {
|
||||
"FilterSuffixes": [
|
||||
".js",
|
||||
".css",
|
||||
".jpg",
|
||||
".png",
|
||||
".gif",
|
||||
".ico",
|
||||
".svg",
|
||||
".woff",
|
||||
".woff2",
|
||||
".ttf",
|
||||
".js.map",
|
||||
".css.map"
|
||||
],
|
||||
"FilterContentTypes": [
|
||||
"text/html",
|
||||
"text/css",
|
||||
"text/javascript",
|
||||
"application/javascript",
|
||||
"application/json",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/x-icon",
|
||||
"image/svg+xml",
|
||||
"font/woff",
|
||||
"font/woff2",
|
||||
"font/ttf"
|
||||
]
|
||||
},
|
||||
"ReverseProxy": {
|
||||
"Routes": {
|
||||
|
||||
@@ -4,6 +4,9 @@ import { RouterProvider, createBrowserRouter } from 'react-router-dom';
|
||||
import MainLayout from './layout/main-layout';
|
||||
import Home from './pages/home';
|
||||
import 'reset-css';
|
||||
import Gateway from './pages/gateway';
|
||||
import Logger from './pages/logger';
|
||||
import Setting from './pages/settings';
|
||||
|
||||
const router = createBrowserRouter([{
|
||||
path: "/",
|
||||
@@ -12,6 +15,18 @@ const router = createBrowserRouter([{
|
||||
{
|
||||
path: "",
|
||||
element: <Home />
|
||||
},
|
||||
{
|
||||
path: "Gateway",
|
||||
element: <Gateway />
|
||||
},
|
||||
{
|
||||
path: "Logger",
|
||||
element: <Logger />
|
||||
},
|
||||
{
|
||||
path: "Setting",
|
||||
element: <Setting />
|
||||
}
|
||||
]
|
||||
}]);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Layout, Nav, Button, Avatar, Icon } from '@douyinfe/semi-ui';
|
||||
import { IconBell, IconHelpCircle, IconGithubLogo, IconHome, IconHistogram, IconLive, IconSetting, IconSemiLogo } from '@douyinfe/semi-icons';
|
||||
import { Gateway } from '../components/icon';
|
||||
import { IconBell, IconHelpCircle, IconGithubLogo, IconHome, IconServerStroked, IconLive, IconSetting, } from '@douyinfe/semi-icons';
|
||||
import { Outlet,useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function MainLayout() {
|
||||
const [selectedKeys, setSelectedKeys] = React.useState(['Home']);
|
||||
|
||||
const { Header, Footer, Sider, Content } = Layout;
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Layout style={{ border: '1px solid var(--semi-color-border)', height: '100vh' }}>
|
||||
<Sider style={{ backgroundColor: 'var(--semi-color-bg-1)' }}>
|
||||
@@ -14,11 +16,20 @@ export default function MainLayout() {
|
||||
defaultSelectedKeys={selectedKeys}
|
||||
onSelect={key => {
|
||||
setSelectedKeys([key.itemKey.toString()]);
|
||||
if (key.itemKey === 'Home') {
|
||||
navigate('/');
|
||||
} else if (key.itemKey === 'Gateway') {
|
||||
navigate('/Gateway');
|
||||
} else if (key.itemKey === 'Logger') {
|
||||
navigate('/Logger');
|
||||
} else if (key.itemKey === 'Setting') {
|
||||
navigate('/Setting');
|
||||
}
|
||||
}}
|
||||
style={{ maxWidth: 220, height: '100%' }}
|
||||
items={[
|
||||
{ itemKey: 'Home', text: '首页', icon: <IconHome size="large" /> },
|
||||
{ itemKey: 'Gateway', text: '代理设置', icon: <Icon svg={<Gateway color={(selectedKeys[0] === "Gateway" ? "#3661F8" : "#333333")} />}></Icon> },
|
||||
{ itemKey: 'Gateway', text: '代理设置', icon: <IconServerStroked /> },
|
||||
{ itemKey: 'Logger', text: '日志', icon: <IconLive size="large" /> },
|
||||
{ itemKey: 'Setting', text: '设置', icon: <IconSetting size="large" /> },
|
||||
]}
|
||||
@@ -37,24 +48,9 @@ export default function MainLayout() {
|
||||
mode="horizontal"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
theme="borderless"
|
||||
icon={<IconBell size="large" />}
|
||||
style={{
|
||||
color: 'var(--semi-color-text-2)',
|
||||
marginRight: '12px',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
theme="borderless"
|
||||
icon={<IconHelpCircle size="large" />}
|
||||
style={{
|
||||
color: 'var(--semi-color-text-2)',
|
||||
marginRight: '12px',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Avatar color="orange" size="small">
|
||||
YJ
|
||||
Gateway
|
||||
</Avatar>
|
||||
</>
|
||||
}
|
||||
@@ -66,6 +62,7 @@ export default function MainLayout() {
|
||||
backgroundColor: 'var(--semi-color-bg-0)',
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Content>
|
||||
<Footer
|
||||
style={{
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { TabPane, Tabs } from "@douyinfe/semi-ui";
|
||||
|
||||
export default function Gateway() {
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
<Tabs tabPosition="left" type='line'>
|
||||
<TabPane
|
||||
tab={
|
||||
<span>
|
||||
集群
|
||||
</span>
|
||||
}
|
||||
itemKey="1"
|
||||
>
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane
|
||||
tab={
|
||||
<span>
|
||||
路由
|
||||
</span>
|
||||
}
|
||||
itemKey="2"
|
||||
>
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<h3>快速起步</h3>
|
||||
<pre
|
||||
style={{
|
||||
margin: '24px 0',
|
||||
padding: '20px',
|
||||
border: 'none',
|
||||
whiteSpace: 'normal',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--semi-color-text-1)',
|
||||
backgroundColor: 'var(--semi-color-fill-0)',
|
||||
}}
|
||||
>
|
||||
<code>
|
||||
yarn add @douyinfe/semi-ui
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default function Logger() {
|
||||
return (
|
||||
<div>Logger</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default function Setting() {
|
||||
return (
|
||||
<div>Setting</div>
|
||||
)
|
||||
}
|
||||
+177
-369
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user