123 lines
4.9 KiB
C#
123 lines
4.9 KiB
C#
using System.Net.Http.Headers;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
|
||
namespace Jiaowu.Api.Infrastructure.Analytics;
|
||
|
||
public sealed class ClickHouseAnalyticsClient(
|
||
HttpClient httpClient,
|
||
ClickHouseAnalyticsOptions options)
|
||
{
|
||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||
|
||
public bool IsEnabled => options.Enabled;
|
||
|
||
public async Task EnsureSchemaAsync(CancellationToken cancellationToken)
|
||
{
|
||
await ExecuteAsync($"CREATE DATABASE IF NOT EXISTS {options.Database}", cancellationToken);
|
||
|
||
await ExecuteAsync($"""
|
||
CREATE TABLE IF NOT EXISTS {options.Database}.auditEvents
|
||
(
|
||
id UUID,
|
||
occurredAt DateTime64(3, 'UTC'),
|
||
userId Nullable(UUID),
|
||
method LowCardinality(String),
|
||
path String,
|
||
statusCode UInt16,
|
||
ipAddress Nullable(String),
|
||
projectedAt DateTime64(3, 'UTC')
|
||
) ENGINE = ReplacingMergeTree(projectedAt)
|
||
PARTITION BY toYYYYMM(occurredAt)
|
||
ORDER BY (id)
|
||
""", cancellationToken);
|
||
|
||
await ExecuteAsync($"""
|
||
CREATE TABLE IF NOT EXISTS {options.Database}.attendanceRecords
|
||
(
|
||
attendanceSheetId UUID,
|
||
studentId UUID,
|
||
attendanceDate Date,
|
||
teachingTaskId UUID,
|
||
academicTermId UUID,
|
||
collegeId UUID,
|
||
status UInt8,
|
||
checkInAt Nullable(DateTime64(3, 'UTC')),
|
||
checkedInMethod Nullable(UInt8),
|
||
appealStatus UInt8,
|
||
projectedAt DateTime64(3, 'UTC')
|
||
) ENGINE = ReplacingMergeTree(projectedAt)
|
||
PARTITION BY toYYYYMM(attendanceDate)
|
||
ORDER BY (attendanceSheetId, studentId)
|
||
""", cancellationToken);
|
||
|
||
await ExecuteAsync($"""
|
||
CREATE TABLE IF NOT EXISTS {options.Database}.gradeStatistics
|
||
(
|
||
gradeSheetId UUID,
|
||
teachingTaskId UUID,
|
||
courseId UUID,
|
||
academicTermId UUID,
|
||
collegeId UUID,
|
||
academicTermName LowCardinality(String),
|
||
studentCount UInt32,
|
||
passedCount UInt32,
|
||
excellentCount UInt32,
|
||
averageScore Decimal(8, 2),
|
||
passRate Decimal(8, 4),
|
||
excellentRate Decimal(8, 4),
|
||
calculatedAt DateTime64(3, 'UTC'),
|
||
projectedAt DateTime64(3, 'UTC')
|
||
) ENGINE = ReplacingMergeTree(projectedAt)
|
||
PARTITION BY toYYYYMM(calculatedAt)
|
||
ORDER BY (gradeSheetId)
|
||
""", cancellationToken);
|
||
}
|
||
|
||
public async Task InsertAsync<T>(string table, IReadOnlyCollection<T> rows, CancellationToken cancellationToken)
|
||
{
|
||
if (rows.Count == 0) return;
|
||
var payload = JsonSerializer.Serialize(rows, JsonOptions);
|
||
await ExecuteAsync(
|
||
$"INSERT INTO {options.Database}.{table} FORMAT JSONEachRow\n{ToJsonLines(payload)}",
|
||
cancellationToken);
|
||
}
|
||
|
||
public async Task<JsonElement[]> QueryAsync(string sql, CancellationToken cancellationToken)
|
||
{
|
||
using var response = await SendAsync(sql + " FORMAT JSON", cancellationToken);
|
||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||
if (!response.IsSuccessStatusCode)
|
||
throw new HttpRequestException($"ClickHouse 查询失败 ({(int)response.StatusCode}):{content}");
|
||
using var document = JsonDocument.Parse(content);
|
||
return document.RootElement.GetProperty("data")
|
||
.EnumerateArray().Select(x => x.Clone()).ToArray();
|
||
}
|
||
|
||
private async Task ExecuteAsync(string sql, CancellationToken cancellationToken)
|
||
{
|
||
using var response = await SendAsync(sql, cancellationToken);
|
||
if (response.IsSuccessStatusCode) return;
|
||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||
throw new HttpRequestException($"ClickHouse 写入失败 ({(int)response.StatusCode}):{content}");
|
||
}
|
||
|
||
private Task<HttpResponseMessage> SendAsync(string sql, CancellationToken cancellationToken)
|
||
{
|
||
var request = new HttpRequestMessage(HttpMethod.Post, "")
|
||
{
|
||
Content = new StringContent(sql, Encoding.UTF8, "text/plain")
|
||
};
|
||
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes(
|
||
$"{options.UserName}:{options.Password}"));
|
||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||
return httpClient.SendAsync(request, cancellationToken);
|
||
}
|
||
|
||
private static string ToJsonLines(string json)
|
||
{
|
||
using var document = JsonDocument.Parse(json);
|
||
return string.Join('\n', document.RootElement.EnumerateArray().Select(x => x.GetRawText()));
|
||
}
|
||
}
|