Files
Academic-Affairs-System/src/Jiaowu.Api/Infrastructure/Analytics/ClickHouseAnalyticsClient.cs
T
2026-08-10 17:30:52 +08:00

123 lines
4.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()));
}
}