Просмотр исходного кода

feat(promotion-query): 支持采样日志入库与分页查询

- 将 PromotionQuery 采样从文件日志切换到 MySQL 表\n- 新增日志查询分页、总数统计和日志 ID 搜索\n- 更新日志查看页,支持分页查询且不暴露采样开关
root 2 месяцев назад
Родитель
Сommit
25c541d9af

+ 292 - 238
molilian.api/Controllers/public/ApiController.cs

@@ -1,238 +1,292 @@
-using molilian.core;
-using dodohold.core;
-using Microsoft.AspNetCore.Mvc;
-using Org.BouncyCastle.Ocsp;
-using System.Text.Json;
-using System.Runtime.InteropServices;
-using System.Net;
-using System.Security.Cryptography;
-using Microsoft.AspNetCore.Authentication;
-using TencentCloud.Ecm.V20190719.Models;
-using COSXML.Network;
-using System.Linq;
-using static dodohold.core.ZTOExpress.CreateOrderArgs;
-using TencentCloud.Lighthouse.V20200324.Models;
-using TencentCloud.Ame.V20190916.Models;
-using TencentCloud.Omics.V20221128.Models;
-using System.Threading.Channels;
-
-namespace molilian.api.Controllers
-{
-    [ApiController]
-    [Route("api/[action]")]
-    public class ApiController : ControllerBase
-    {
-        protected IHttpContextAccessor _accessor;
-        TaokeOpenCore core;
-        public ApiController(IHttpContextAccessor accessor)
-        {
-            _accessor = accessor;
-            core = new TaokeOpenCore();
-        }
-
-        //[HttpGet]
-        //public async Task<ActionResult> RequestAsync([FromQuery] string url, [FromQuery] int proxyId)
-        //{
-        //    if (string.IsNullOrEmpty(url))
-        //    {
-        //        return BadRequest("URL cannot be empty");
-        //    }
-
-        //    var proxy = ProxyNodesCore.GetOne(proxyId);
-        //    if (proxy == null)
-        //    {
-        //        return Unauthorized("Invalid proxy configuration");
-        //    }
-        //    WebClientUtility cli = new()
-        //    {
-        //        Proxy = proxy
-        //    };
-        //    await cli.RequestAsync(url);
-        //    var content = cli.Body();
-        //    var contentType = cli.ResponseMessage.Content.Headers.ContentType?.MediaType ?? "text/plain";
-        //    return Content(content, contentType);
-        //}
-
-        [HttpGet]
-        public async Task<ActionResult> RequestAsync([FromQuery] string url, [FromQuery] string proxy)
-        {
-            if (string.IsNullOrEmpty(url))
-            {
-                return BadRequest("URL cannot be empty");
-            }
-
-            WebProxy _proxy = ProxyManager.Parse(proxy);
-            WebClientUtility cli = new();
-            cli.Proxy = _proxy;
-            cli.Timeout = TimeSpan.FromSeconds(10);
-            await cli.RequestAsync(url);
-            if (cli.Successed)
-            {
-                var content = cli.Body();
-
-                var contentType = cli.ResponseMessage.Content.Headers.ContentType?.MediaType ?? "text/plain";
-                return Content(content, contentType);
-            }
-            throw cli.ResponseException;
-        }
-
-
-        [HttpPost]
-        public async Task<ActionResult> PromotionQuery([FromBody] JsonElement form, [FromQuery] string a = "", [FromQuery] int t = 0, [FromQuery] string sign = "")
-        {
-            var img = form.Read("img", string.Empty);
-            var url = form.Read("url", string.Empty);
-            var ip = form.Read("ip", string.Empty);
-            var oaid = form.Read("oaid", string.Empty);
-            var scene = form.Read("scene", string.Empty);
-            var recommand = form.Read("recommand", true);
-            var useCouponLinkFirst = form.Read("useCouponLinkFirst", true);
-            var aid = form.Read("aid", 0);
-
-            //验证签名
-            if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(sign))
-            {
-                return new APIResult(new { success = false, message = "验证错误" }, APIResultCodeEnum.Unauthorized);
-            }
-
-            DateTime time1 = t.Convert2Datetime();
-            if (time1 < DateTime.Now.AddMinutes(-10))
-            {
-                return new APIResult(new { success = false, message = "验证错误2" }, APIResultCodeEnum.Unauthorized);
-            }
-
-            var account = await ApiAccountCore.GetOneAsync(a);
-            if (account == null)
-            {
-                return new APIResult(new { success = false, message = "验证错误3" }, APIResultCodeEnum.Unauthorized);
-            }
-            string app_sign = $"{account.api_secret}@{t}".MD5();
-            if (sign != app_sign)
-            {
-                return new APIResult(new { success = false, message = "验证错误4" }, APIResultCodeEnum.Unauthorized);
-            }
-
-            if (!string.IsNullOrEmpty(url))
-            {
-                var bytes = new WebClientUtility().Request(url).ResponseBody;
-                img = Convert.ToBase64String(bytes);
-            }
-            return await core.PromotionQueryAsync(img, oaid, ip, scene, recommand, false, aid, useCouponLinkFirst);
-        }
-
-
-        [HttpPost]
-        public async Task<ActionResult> unsafePromotionQuery([FromBody] JsonElement form)
-        {
-            var img = form.Read("img", string.Empty);
-            var url = form.Read("url", string.Empty);
-            var tkl = form.Read("tkl", string.Empty);
-            var itemid = form.Read("itemid", string.Empty);
-            var ip = form.Read("ip", string.Empty);
-            var oaid = form.Read("oaid", string.Empty);
-            var scene = form.Read("scene", string.Empty);
-            var recommand = form.Read("recommand", true);
-            var debug = form.Read("debug", false);
-            var useCouponLinkFirst = form.Read("useCouponLinkFirst", true);
-            var aid = form.Read("aid", 0);
-
-            int count = form.Read("count", 0);
-
-            if (!string.IsNullOrEmpty(url))
-            {
-                var bytes = new WebClientUtility().Request(url).ResponseBody;
-                img = Convert.ToBase64String(bytes);
-            }
-
-            if (string.IsNullOrEmpty(oaid))
-            {
-                Random rand = new Random();
-                oaid = $"{rand.Next(100000, 999999)}-{rand.Next(100000, 999999)}-{rand.Next(100000, 999999)}";
-            }
-
-            if (count > 1)
-            {
-                for (int i = 0; i < count; i++)
-                {
-                    _ = core.PromotionQueryAsync(img, oaid, ip, scene, recommand, debug, aid, useCouponLinkFirst);
-                }
-                return new APIResult(new { msg = "ok" });
-            }
-
-            // 从口令解析商品图 临时需求
-            if (!string.IsNullOrEmpty(tkl))
-            {
-                string goods_url = AlimamaPlus.GetLink(tkl);
-                (bool successed, string itemUrl) = AlimamaPlus.GetItemUrl(goods_url);
-                if (successed && !string.IsNullOrEmpty(itemUrl))
-                {
-                    itemid = AlimamaPlus.ExtractItemId(itemUrl);
-                    (APIResult target, PromotionQueryDTO data) = await core.PromotionQueryByItemIdAsync(itemid, oaid, ip, scene, recommand, debug, aid);
-
-
-                    if (data != null)
-                    {
-                        url = data.PromotionImg[0].pic;
-                        if (!string.IsNullOrEmpty(url))
-                        {
-                            var bytes = new WebClientUtility().Request(url).ResponseBody;
-                            img = Convert.ToBase64String(bytes);
-                        }
-                    }
-                    return await core.PromotionQueryAsync(img, oaid, ip, scene, recommand, debug, aid, useCouponLinkFirst);
-                }
-
-                if (!string.IsNullOrEmpty(itemid))
-                {
-                    (APIResult result, PromotionQueryDTO data) = await core.PromotionQueryByItemIdAsync(itemid, oaid, ip, scene, recommand, debug, aid, useCouponLinkFirst);
-                    return result;
-                }
-            }
-            return await core.PromotionQueryAsync(img, oaid, ip, scene, recommand, debug, aid, useCouponLinkFirst);
-
-        }
-
-        [HttpGet]
-        public async Task<ActionResult> PromotionQuerySampleLogs([FromQuery] int n = 20)
-        {
-            int count = Math.Clamp(n, 1, 100);
-            string logDir = Path.Combine(AppContext.BaseDirectory, "log", "promotion_query_samples");
-
-            if (!Directory.Exists(logDir))
-            {
-                return new APIResult(new
-                {
-                    success = true,
-                    count = 0,
-                    logs = Array.Empty<object>()
-                });
-            }
-
-            var files = Directory.EnumerateFiles(logDir, "*.log", SearchOption.TopDirectoryOnly)
-                .Where(file => !Path.GetFileName(file).StartsWith("write_error_", StringComparison.OrdinalIgnoreCase))
-                .Select(file => new FileInfo(file))
-                .OrderByDescending(file => file.LastWriteTime)
-                .Take(count)
-                .ToList();
-
-            var logs = new List<object>();
-            foreach (var file in files)
-            {
-                logs.Add(new
-                {
-                    fileName = file.Name,
-                    writeTime = file.LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss.fff"),
-                    content = await System.IO.File.ReadAllTextAsync(file.FullName)
-                });
-            }
-
-            return new APIResult(new
-            {
-                success = true,
-                count = logs.Count,
-                logs
-            });
-        }
-
-    }
-}
+using molilian.core;
+using dodohold.core;
+using Microsoft.AspNetCore.Mvc;
+using Org.BouncyCastle.Ocsp;
+using System.Text.Json;
+using System.Runtime.InteropServices;
+using System.Net;
+using System.Security.Cryptography;
+using Microsoft.AspNetCore.Authentication;
+using TencentCloud.Ecm.V20190719.Models;
+using COSXML.Network;
+using System.Linq;
+using static dodohold.core.ZTOExpress.CreateOrderArgs;
+using TencentCloud.Lighthouse.V20200324.Models;
+using TencentCloud.Ame.V20190916.Models;
+using TencentCloud.Omics.V20221128.Models;
+using System.Threading.Channels;
+
+namespace molilian.api.Controllers
+{
+    [ApiController]
+    [Route("api/[action]")]
+    public class ApiController : ControllerBase
+    {
+        protected IHttpContextAccessor _accessor;
+        TaokeOpenCore core;
+        public ApiController(IHttpContextAccessor accessor)
+        {
+            _accessor = accessor;
+            core = new TaokeOpenCore();
+        }
+
+        //[HttpGet]
+        //public async Task<ActionResult> RequestAsync([FromQuery] string url, [FromQuery] int proxyId)
+        //{
+        //    if (string.IsNullOrEmpty(url))
+        //    {
+        //        return BadRequest("URL cannot be empty");
+        //    }
+
+        //    var proxy = ProxyNodesCore.GetOne(proxyId);
+        //    if (proxy == null)
+        //    {
+        //        return Unauthorized("Invalid proxy configuration");
+        //    }
+        //    WebClientUtility cli = new()
+        //    {
+        //        Proxy = proxy
+        //    };
+        //    await cli.RequestAsync(url);
+        //    var content = cli.Body();
+        //    var contentType = cli.ResponseMessage.Content.Headers.ContentType?.MediaType ?? "text/plain";
+        //    return Content(content, contentType);
+        //}
+
+        [HttpGet]
+        public async Task<ActionResult> RequestAsync([FromQuery] string url, [FromQuery] string proxy)
+        {
+            if (string.IsNullOrEmpty(url))
+            {
+                return BadRequest("URL cannot be empty");
+            }
+
+            WebProxy _proxy = ProxyManager.Parse(proxy);
+            WebClientUtility cli = new();
+            cli.Proxy = _proxy;
+            cli.Timeout = TimeSpan.FromSeconds(10);
+            await cli.RequestAsync(url);
+            if (cli.Successed)
+            {
+                var content = cli.Body();
+
+                var contentType = cli.ResponseMessage.Content.Headers.ContentType?.MediaType ?? "text/plain";
+                return Content(content, contentType);
+            }
+            throw cli.ResponseException;
+        }
+
+
+        [HttpGet]
+        public ActionResult enable_sampling([FromQuery] int count = 1000)
+        {
+            int requestedCount = count;
+            int remainingCount = PromotionQuerySamplingCore.EnableSampling(count);
+            return new APIResult(new
+            {
+                success = true,
+                message = remainingCount > 0 ? "ok" : "stopped",
+                requestedCount,
+                count = remainingCount,
+                remainingCount
+            });
+        }
+
+        [HttpPost]
+        public async Task<ActionResult> PromotionQuery([FromBody] JsonElement form, [FromQuery] string a = "", [FromQuery] int t = 0, [FromQuery] string sign = "")
+        {
+            var img = form.Read("img", string.Empty);
+            var url = form.Read("url", string.Empty);
+            var ip = form.Read("ip", string.Empty);
+            var oaid = form.Read("oaid", string.Empty);
+            var scene = form.Read("scene", string.Empty);
+            var recommand = form.Read("recommand", true);
+            var useCouponLinkFirst = form.Read("useCouponLinkFirst", true);
+            var aid = form.Read("aid", 0);
+
+            //验证签名
+            if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(sign))
+            {
+                return new APIResult(new { success = false, message = "验证错误" }, APIResultCodeEnum.Unauthorized);
+            }
+
+            DateTime time1 = t.Convert2Datetime();
+            if (time1 < DateTime.Now.AddMinutes(-10))
+            {
+                return new APIResult(new { success = false, message = "验证错误2" }, APIResultCodeEnum.Unauthorized);
+            }
+
+            var account = await ApiAccountCore.GetOneAsync(a);
+            if (account == null)
+            {
+                return new APIResult(new { success = false, message = "验证错误3" }, APIResultCodeEnum.Unauthorized);
+            }
+            string app_sign = $"{account.api_secret}@{t}".MD5();
+            if (sign != app_sign)
+            {
+                return new APIResult(new { success = false, message = "验证错误4" }, APIResultCodeEnum.Unauthorized);
+            }
+
+            if (!string.IsNullOrEmpty(url))
+            {
+                var bytes = new WebClientUtility().Request(url).ResponseBody;
+                img = Convert.ToBase64String(bytes);
+            }
+            var response = await core.PromotionQueryAsync(img, oaid, ip, scene, recommand, false, aid, useCouponLinkFirst);
+            _ = PromotionQuerySamplingCore.TrySampleAsync(img, ExtractActionResultPayload(response));
+            return response;
+        }
+
+
+        [HttpPost]
+        public async Task<ActionResult> unsafePromotionQuery([FromBody] JsonElement form)
+        {
+            var img = form.Read("img", string.Empty);
+            var url = form.Read("url", string.Empty);
+            var tkl = form.Read("tkl", string.Empty);
+            var itemid = form.Read("itemid", string.Empty);
+            var ip = form.Read("ip", string.Empty);
+            var oaid = form.Read("oaid", string.Empty);
+            var scene = form.Read("scene", string.Empty);
+            var recommand = form.Read("recommand", true);
+            var debug = form.Read("debug", false);
+            var useCouponLinkFirst = form.Read("useCouponLinkFirst", true);
+            var aid = form.Read("aid", 0);
+
+            int count = form.Read("count", 0);
+
+            if (!string.IsNullOrEmpty(url))
+            {
+                var bytes = new WebClientUtility().Request(url).ResponseBody;
+                img = Convert.ToBase64String(bytes);
+            }
+
+            if (string.IsNullOrEmpty(oaid))
+            {
+                Random rand = new Random();
+                oaid = $"{rand.Next(100000, 999999)}-{rand.Next(100000, 999999)}-{rand.Next(100000, 999999)}";
+            }
+
+            if (count > 1)
+            {
+                for (int i = 0; i < count; i++)
+                {
+                    _ = core.PromotionQueryAsync(img, oaid, ip, scene, recommand, debug, aid, useCouponLinkFirst);
+                }
+                return new APIResult(new { msg = "ok" });
+            }
+
+            // 从口令解析商品图 临时需求
+            if (!string.IsNullOrEmpty(tkl))
+            {
+                string goods_url = AlimamaPlus.GetLink(tkl);
+                (bool successed, string itemUrl) = AlimamaPlus.GetItemUrl(goods_url);
+                if (successed && !string.IsNullOrEmpty(itemUrl))
+                {
+                    itemid = AlimamaPlus.ExtractItemId(itemUrl);
+                    (APIResult target, PromotionQueryDTO data) = await core.PromotionQueryByItemIdAsync(itemid, oaid, ip, scene, recommand, debug, aid);
+
+
+                    if (data != null)
+                    {
+                        url = data.PromotionImg[0].pic;
+                        if (!string.IsNullOrEmpty(url))
+                        {
+                            var bytes = new WebClientUtility().Request(url).ResponseBody;
+                            img = Convert.ToBase64String(bytes);
+                        }
+                    }
+                    return await core.PromotionQueryAsync(img, oaid, ip, scene, recommand, debug, aid, useCouponLinkFirst);
+                }
+
+                if (!string.IsNullOrEmpty(itemid))
+                {
+                    (APIResult result, PromotionQueryDTO data) = await core.PromotionQueryByItemIdAsync(itemid, oaid, ip, scene, recommand, debug, aid, useCouponLinkFirst);
+                    return result;
+                }
+            }
+
+            return await core.PromotionQueryAsync(img, oaid, ip, scene, recommand, debug, aid, useCouponLinkFirst);
+
+        }
+
+        [HttpGet]
+        public async Task<ActionResult> PromotionQuerySampleLogs([FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] int n = 0, [FromQuery] string keyword = "")
+        {
+            int resolvedPageSize = n > 0 ? n : pageSize;
+            try
+            {
+                var result = await PromotionQuerySamplingCore.QueryLogsAsync(page, resolvedPageSize, keyword);
+                return new APIResult(new
+                {
+                    success = true,
+                    count = result.logs.Count,
+                    page = result.page,
+                    pageSize = result.pageSize,
+                    totalCount = result.totalCount,
+                    totalPages = result.totalPages,
+                    remainingCount = PromotionQuerySamplingCore.GetRemainingCount(),
+                    logs = result.logs
+                });
+            }
+            catch (Exception ex)
+            {
+                _ = new LoggerLibrary("promotion_query_samples", "query_error")
+                    .Info(ex.Message, ex.StackTrace)
+                    .SaveAsync();
+                return new APIResult(new
+                {
+                    success = false,
+                    message = ex.Message,
+                    count = 0,
+                    page = Math.Max(page, 1),
+                    pageSize = Math.Clamp(resolvedPageSize, 1, PromotionQuerySamplingCore.MaxQueryCount),
+                    totalCount = 0,
+                    totalPages = 0,
+                    remainingCount = PromotionQuerySamplingCore.GetRemainingCount(),
+                    logs = Array.Empty<object>()
+                });
+            }
+        }
+
+        private static string ExtractActionResultPayload(ActionResult response)
+        {
+            var jsonOptions = new JsonSerializerOptions
+            {
+                Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
+            };
+
+            if (response is ContentResult contentResult && !string.IsNullOrWhiteSpace(contentResult.Content))
+            {
+                return contentResult.Content;
+            }
+
+            if (response is JsonResult jsonResult && jsonResult.Value != null)
+            {
+                return JsonSerializer.Serialize(jsonResult.Value, jsonOptions);
+            }
+
+            if (response is ObjectResult objectResult && objectResult.Value != null)
+            {
+                return JsonSerializer.Serialize(objectResult.Value, jsonOptions);
+            }
+
+            var responseType = response.GetType();
+            var contentProperty = responseType.GetProperty("Content");
+            if (contentProperty?.GetValue(response) is string rawContent && !string.IsNullOrWhiteSpace(rawContent))
+            {
+                return rawContent;
+            }
+
+            var valueProperty = responseType.GetProperty("Value");
+            var value = valueProperty?.GetValue(response);
+            if (value != null)
+            {
+                return JsonSerializer.Serialize(value, jsonOptions);
+            }
+
+            return string.Empty;
+        }
+
+    }
+}

+ 250 - 0
molilian.core/Core/API/PromotionQuerySamplingCore.cs

@@ -0,0 +1,250 @@
+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<int>(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<PromotionQuerySampleQueryResultDTO> 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<int>(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<PromotionQuerySampleDTO>(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();
+            }
+        }
+    }
+}

+ 38 - 0
molilian.core/DTO/api/PromotionQuerySampleDTO.cs

@@ -0,0 +1,38 @@
+using dodohold.core;
+using System.Text.Json.Serialization;
+using YunhuiKit;
+
+namespace molilian.core
+{
+    [Table("promotion_query_samples")]
+    public class PromotionQuerySampleDTO
+    {
+        [Key]
+        public string id { get; set; } = string.Empty;
+        public string image_base64 { get; set; } = string.Empty;
+        public string response_json { get; set; } = string.Empty;
+
+        [IgnoreUpdate]
+        [JsonConverter(typeof(NullableDateTimeConverter))]
+        public DateTime create_time { get; set; }
+    }
+
+    public class PromotionQuerySampleLogDTO
+    {
+        public string id { get; set; } = string.Empty;
+        public string fileName { get; set; } = string.Empty;
+        public string writeTime { get; set; } = string.Empty;
+        public string content { get; set; } = string.Empty;
+        public string imageBase64 { get; set; } = string.Empty;
+        public string responseJson { get; set; } = string.Empty;
+    }
+
+    public class PromotionQuerySampleQueryResultDTO
+    {
+        public int page { get; set; } = 1;
+        public int pageSize { get; set; }
+        public int totalCount { get; set; }
+        public int totalPages => pageSize <= 0 || totalCount <= 0 ? 0 : (int)Math.Ceiling(totalCount / (double)pageSize);
+        public List<PromotionQuerySampleLogDTO> logs { get; set; } = new();
+    }
+}

+ 95 - 12
promotion-query-logs.html

@@ -44,7 +44,7 @@
 
 
     .toolbar {
     .toolbar {
       display: grid;
       display: grid;
-      grid-template-columns: minmax(260px, 1fr) 120px auto auto;
+      grid-template-columns: minmax(260px, 1fr) 180px 110px 110px auto auto;
       gap: 10px;
       gap: 10px;
       align-items: end;
       align-items: end;
       margin-bottom: 14px;
       margin-bottom: 14px;
@@ -100,8 +100,13 @@
       background: #f1f6fd;
       background: #f1f6fd;
     }
     }
 
 
+    button:disabled {
+      opacity: 0.6;
+      cursor: not-allowed;
+    }
+
     .status {
     .status {
-      margin: 0 0 14px;
+      margin: 0 0 8px;
       color: var(--muted);
       color: var(--muted);
     }
     }
 
 
@@ -109,6 +114,17 @@
       color: var(--danger);
       color: var(--danger);
     }
     }
 
 
+    .pager {
+      display: flex;
+      gap: 10px;
+      align-items: center;
+      margin: 0 0 14px;
+    }
+
+    .page-info {
+      color: var(--muted);
+    }
+
     .logs {
     .logs {
       display: grid;
       display: grid;
       gap: 12px;
       gap: 12px;
@@ -410,33 +426,66 @@
         <input id="apiBase" autocomplete="off">
         <input id="apiBase" autocomplete="off">
       </label>
       </label>
       <label>
       <label>
-        条数
-        <input id="count" type="number" min="1" max="100" value="20">
+        日志 ID
+        <input id="keyword" autocomplete="off" placeholder="支持输入 .log">
+      </label>
+      <label>
+        每页
+        <input id="count" type="number" min="1" max="1100" value="20">
+      </label>
+      <label>
+        页码
+        <input id="page" type="number" min="1" value="1">
       </label>
       </label>
       <button id="loadBtn" type="button">查询</button>
       <button id="loadBtn" type="button">查询</button>
       <button id="copyUrlBtn" class="secondary" type="button">复制接口</button>
       <button id="copyUrlBtn" class="secondary" type="button">复制接口</button>
     </div>
     </div>
 
 
     <p id="status" class="status"></p>
     <p id="status" class="status"></p>
+    <div class="pager">
+      <button id="prevBtn" class="secondary" type="button">上一页</button>
+      <span id="pageInfo" class="page-info">共 0 条</span>
+      <button id="nextBtn" class="secondary" type="button">下一页</button>
+    </div>
     <section id="logs" class="logs"></section>
     <section id="logs" class="logs"></section>
   </main>
   </main>
 
 
   <script>
   <script>
     const endpointPath = "/api/PromotionQuerySampleLogs";
     const endpointPath = "/api/PromotionQuerySampleLogs";
     const apiBaseInput = document.getElementById("apiBase");
     const apiBaseInput = document.getElementById("apiBase");
+    const keywordInput = document.getElementById("keyword");
     const countInput = document.getElementById("count");
     const countInput = document.getElementById("count");
+    const pageInput = document.getElementById("page");
     const loadBtn = document.getElementById("loadBtn");
     const loadBtn = document.getElementById("loadBtn");
     const copyUrlBtn = document.getElementById("copyUrlBtn");
     const copyUrlBtn = document.getElementById("copyUrlBtn");
     const statusEl = document.getElementById("status");
     const statusEl = document.getElementById("status");
+    const prevBtn = document.getElementById("prevBtn");
+    const nextBtn = document.getElementById("nextBtn");
+    const pageInfoEl = document.getElementById("pageInfo");
     const logsEl = document.getElementById("logs");
     const logsEl = document.getElementById("logs");
+    let currentPage = 1;
+    let currentTotalPages = 0;
 
 
     apiBaseInput.value = "http://similar.molilian.com:5005";
     apiBaseInput.value = "http://similar.molilian.com:5005";
 
 
-    function buildUrl() {
+    function buildUrl(pageOverride) {
       const base = apiBaseInput.value.replace(/\/+$/, "");
       const base = apiBaseInput.value.replace(/\/+$/, "");
-      const count = Math.min(Math.max(Number(countInput.value) || 20, 1), 100);
-      countInput.value = String(count);
-      return `${base}${endpointPath}?n=${encodeURIComponent(count)}`;
+      const pageSize = Math.min(Math.max(Number(countInput.value) || 20, 1), 1100);
+      const requestedPage = Number(pageOverride);
+      const page = Math.max(
+        Number.isFinite(requestedPage) ? requestedPage : (Number(pageInput.value) || 1),
+        1
+      );
+      const keyword = keywordInput.value.trim();
+      countInput.value = String(pageSize);
+      pageInput.value = String(page);
+      const url = new URL(`${base}${endpointPath}`);
+      url.searchParams.set("page", String(page));
+      url.searchParams.set("pageSize", String(pageSize));
+      if (keyword) {
+        url.searchParams.set("keyword", keyword);
+      }
+      return url.toString();
     }
     }
 
 
     function setStatus(text, isError = false) {
     function setStatus(text, isError = false) {
@@ -444,6 +493,17 @@
       statusEl.classList.toggle("error", isError);
       statusEl.classList.toggle("error", isError);
     }
     }
 
 
+    function updatePager(page, totalPages, totalCount) {
+      currentPage = Math.max(page, 1);
+      currentTotalPages = Math.max(totalPages, 0);
+      pageInput.value = String(currentPage);
+      prevBtn.disabled = currentPage <= 1;
+      nextBtn.disabled = currentTotalPages <= 0 || currentPage >= currentTotalPages;
+      pageInfoEl.textContent = totalCount > 0
+        ? `第 ${currentPage} / ${Math.max(currentTotalPages, 1)} 页,共 ${totalCount} 条`
+        : "共 0 条";
+    }
+
     function getLogSection(content, name) {
     function getLogSection(content, name) {
       const marker = new RegExp(`\\t${name}\\r?\\n`);
       const marker = new RegExp(`\\t${name}\\r?\\n`);
       const match = marker.exec(content);
       const match = marker.exec(content);
@@ -851,9 +911,11 @@
       });
       });
     }
     }
 
 
-    async function loadLogs() {
-      const url = buildUrl();
+    async function loadLogs(pageOverride) {
+      const url = buildUrl(pageOverride);
       loadBtn.disabled = true;
       loadBtn.disabled = true;
+      prevBtn.disabled = true;
+      nextBtn.disabled = true;
       setStatus(`请求中:${url}`);
       setStatus(`请求中:${url}`);
 
 
       try {
       try {
@@ -864,9 +926,20 @@
 
 
         const data = await response.json();
         const data = await response.json();
         const logs = Array.isArray(data.logs) ? data.logs : [];
         const logs = Array.isArray(data.logs) ? data.logs : [];
+        const page = Math.max(Number(data.page || pageInput.value) || 1, 1);
+        const pageSize = Math.min(Math.max(Number(data.pageSize || countInput.value) || 20, 1), 1100);
+        const totalCount = Math.max(Number(data.totalCount || 0), 0);
+        const totalPages = Math.max(Number(data.totalPages || 0), 0);
+        countInput.value = String(pageSize);
+        updatePager(page, totalPages, totalCount);
         renderLogs(logs);
         renderLogs(logs);
-        setStatus(`已加载 ${logs.length} 条,${new Date().toLocaleString()}`);
+        setStatus(
+          totalCount > 0
+            ? `已加载第 ${page} 页 ${logs.length} 条,共 ${totalCount} 条,${new Date().toLocaleString()}`
+            : `暂无日志,${new Date().toLocaleString()}`
+        );
       } catch (error) {
       } catch (error) {
+        updatePager(Math.max(Number(pageInput.value) || 1, 1), 0, 0);
         renderLogs([]);
         renderLogs([]);
         setStatus(`查询失败:${error.message}`, true);
         setStatus(`查询失败:${error.message}`, true);
       } finally {
       } finally {
@@ -874,7 +947,17 @@
       }
       }
     }
     }
 
 
-    loadBtn.addEventListener("click", loadLogs);
+    loadBtn.addEventListener("click", () => loadLogs());
+    prevBtn.addEventListener("click", () => {
+      if (currentPage > 1) {
+        loadLogs(currentPage - 1);
+      }
+    });
+    nextBtn.addEventListener("click", () => {
+      if (currentTotalPages > 0 && currentPage < currentTotalPages) {
+        loadLogs(currentPage + 1);
+      }
+    });
     copyUrlBtn.addEventListener("click", async () => {
     copyUrlBtn.addEventListener("click", async () => {
       await navigator.clipboard.writeText(buildUrl());
       await navigator.clipboard.writeText(buildUrl());
       copyUrlBtn.textContent = "已复制";
       copyUrlBtn.textContent = "已复制";

+ 8 - 0
sql/20260627_create_promotion_query_samples.sql

@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS `promotion_query_samples` (
+    `id` VARCHAR(80) NOT NULL 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 '创建时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_create_time` (`create_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='PromotionQuery采样记录';