using Dapper; using dodohold.core; using System.Text; using System.Text.Json; namespace molilian.core { public static class PromotionQuerySamplingCore { private const string SamplingRemainingKey = "promotion_query_sampling:remaining"; private const int SamplingSwitchExpireSeconds = 7 * 86400; private const int MaxEnableCount = 5000; public const int MaxQueryCount = 1100; private static readonly SemaphoreSlim EnsureTableLock = new(1, 1); private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, WriteIndented = true }; private static bool _tableEnsured = false; public static int EnableSampling(int count) { count = Math.Clamp(count, 0, MaxEnableCount); if (count == 0) { RedisHelper.Del(SamplingRemainingKey); return 0; } RedisHelper.Set(SamplingRemainingKey, count, SamplingSwitchExpireSeconds); return count; } public static int GetRemainingCount() { return Math.Max(0, RedisHelper.Get(SamplingRemainingKey)); } public static async Task TrySampleAsync(string imageBase64, string responseJson) { if (string.IsNullOrWhiteSpace(imageBase64) && string.IsNullOrWhiteSpace(responseJson)) { return; } if (!TryConsumeQuota()) { return; } string sampleId = BuildSampleId(); try { await EnsureTableAsync(); using var conn = DBContext.GetOpenConnection(); const string sql = @" INSERT INTO promotion_query_samples (id, image_base64, response_json, create_time) VALUES (@id, @image_base64, @response_json, @create_time);"; await conn.ExecuteAsync(sql, new { id = sampleId, image_base64 = imageBase64 ?? string.Empty, response_json = responseJson ?? string.Empty, create_time = DateTime.Now }); } catch (Exception ex) { RedisHelper.IncrBy(SamplingRemainingKey); RedisHelper.Expire(SamplingRemainingKey, SamplingSwitchExpireSeconds); _ = new LoggerLibrary("promotion_query_samples", "save_error") .Info(sampleId) .Info(ex.Message, ex.StackTrace) .SaveAsync(); } } public static async Task QueryLogsAsync(int page, int pageSize, string keyword) { page = Math.Max(page, 1); pageSize = Math.Clamp(pageSize, 1, MaxQueryCount); keyword = NormalizeKeyword(keyword); await EnsureTableAsync(); using var conn = DBContext.GetOpenConnection(); string where = string.Empty; var args = new DynamicParameters(); args.Add("count", pageSize); if (!string.IsNullOrWhiteSpace(keyword)) { where = "WHERE id LIKE @keyword"; args.Add("keyword", $"%{keyword}%"); } string countSql = $@" SELECT COUNT(*) FROM promotion_query_samples {where}"; int totalCount = await conn.QuerySingleAsync(countSql, args); int totalPages = totalCount <= 0 ? 0 : (int)Math.Ceiling(totalCount / (double)pageSize); if (totalPages > 0 && page > totalPages) { page = totalPages; } int offset = (page - 1) * pageSize; args.Add("offset", offset); string sql = $@" SELECT id, image_base64, response_json, create_time FROM promotion_query_samples {where} ORDER BY create_time DESC LIMIT @offset, @count"; var list = (await conn.QueryAsync(sql, args)).ToList(); return new PromotionQuerySampleQueryResultDTO { page = totalCount > 0 ? page : 1, pageSize = pageSize, totalCount = totalCount, logs = list.Select(ToLogDto).ToList() }; } private static PromotionQuerySampleLogDTO ToLogDto(PromotionQuerySampleDTO item) { return new PromotionQuerySampleLogDTO { id = item.id, fileName = $"{item.id}.log", writeTime = item.create_time.ToString("yyyy-MM-dd HH:mm:ss.fff"), content = BuildLegacyContent(item), imageBase64 = item.image_base64, responseJson = item.response_json }; } private static string BuildLegacyContent(PromotionQuerySampleDTO item) { var builder = new StringBuilder(); if (!string.IsNullOrWhiteSpace(item.image_base64)) { string clientPost = JsonSerializer.Serialize(new { img = item.image_base64 }, JsonOptions); AppendSection(builder, item.create_time, "clientPost", clientPost); } if (!string.IsNullOrWhiteSpace(item.response_json)) { AppendSection(builder, item.create_time, "responseJson", item.response_json.Trim()); } return builder.ToString().TrimEnd(); } private static void AppendSection(StringBuilder builder, DateTime timestamp, string name, string content) { if (string.IsNullOrWhiteSpace(content)) { return; } builder.Append(timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff")) .Append('\t') .Append(name) .AppendLine() .AppendLine(content); } private static string NormalizeKeyword(string keyword) { keyword = (keyword ?? string.Empty).Trim(); if (keyword.EndsWith(".log", StringComparison.OrdinalIgnoreCase)) { keyword = keyword[..^4]; } return keyword; } private static bool TryConsumeQuota() { int remaining = GetRemainingCount(); if (remaining <= 0) { return false; } long after = RedisHelper.IncrBy(SamplingRemainingKey, -1); RedisHelper.Expire(SamplingRemainingKey, SamplingSwitchExpireSeconds); if (after < 0) { RedisHelper.Set(SamplingRemainingKey, 0, SamplingSwitchExpireSeconds); return false; } return true; } private static string BuildSampleId() { var now = DateTime.Now; return $"{now:yyyyMMdd_HHmmss_fff}_{Guid.NewGuid():N}_{now:yyyyMMdd}"; } private static async Task EnsureTableAsync() { if (_tableEnsured) { return; } await EnsureTableLock.WaitAsync(); try { if (_tableEnsured) { return; } const string sql = @" CREATE TABLE IF NOT EXISTS promotion_query_samples ( id VARCHAR(80) NOT NULL PRIMARY KEY COMMENT '采样日志ID', image_base64 LONGTEXT NOT NULL COMMENT '入参图片base64', response_json LONGTEXT NOT NULL COMMENT '返回JSON', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='PromotionQuery采样记录';"; using var conn = DBContext.GetOpenConnection(); await conn.ExecuteAsync(sql); _tableEnsured = true; } finally { EnsureTableLock.Release(); } } } }