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(string table, IReadOnlyCollection 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 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 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())); } }