using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Configuration; using System.IO; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Web; using Newtonsoft.Json; namespace KJD_Web.Utils { /// /// API 请求录制工具:挂接在 Global.asax 的 BeginRequest / EndRequest, /// 捕获请求链接(action/service)、请求内容(body/query)与响应, /// 按请求路径生成文件夹,每一条请求落盘为一个 JSON 文件, /// 用于 .NET Core 迁移后的回放校验。 /// 配置(Web.config appSettings): /// ApiRequestLog = 1 开启 / 0 关闭(默认开启) /// ApiRequestLogDir = 日志目录(默认 ~/App_Data/ApiLogs) /// 存储上限:达到 MaxStoredRequests(常量)后跳过不再存储。 /// 写盘为后台异步:请求线程只入队,由后台线程序列化并落盘,不阻塞接口返回。 /// public static class ApiRequestLogger { private const string ItemKey = "__ApiRequestLog__"; private const string FilterItemKey = "__ApiResponseCapture__"; private const int MaxBodyLogLength = 100000; /// 最大存储条数(所有请求路径合计)。达到该数量后,跳过不再存储新请求。 private const int MaxStoredRequests = 10000; /// 后台队列最大积压条数;队列满时丢弃新日志(不影响接口返回)。 private const int MaxQueuedEntries = 5000; private static readonly object SyncRoot = new object(); private static readonly Regex SensitiveKeyValueRegex = new Regex( @"(?i)(\b(?:password|passwd|pwd|token|access_token|secret|app_secret|client_secret|sign|signature|api_key|authorization|key)=)([^&\s""]*)", RegexOptions.Compiled); private static readonly Regex SensitiveRegex = new Regex( "\"((?i)password|passwd|pwd|token|access_token|secret|app_secret|client_secret|sign|signature|api_key|authorization|key)\"\\s*:\\s*\"([^\"]*)\"", RegexOptions.Compiled); private static readonly string[] FriendlyPrefixes = { "/user/", "/shop/", "/update/", "/order/", "/product/", "/product_third/", "/price/", "/bxx_user/", "/bxx_shop/", "/bxx_product/", "/bxx_product_third/", "/bxx_price/", "/dyxd_user/", "/dyxd_shop/", "/dyxd_product/", "/dyxd_product_third/", "/dyxd_price/", "/wxxd_user/", "/wxxd_shop/", "/wxxd_product/", "/wxxd_product_third/", "/wxxd_price/", "/wxxd_order/" }; private static bool _configLoaded; private static bool _enabled = true; private static string _logDir; /// 已存储条数;-1 表示尚未统计(由后台线程统计)。 private static long _storedCount = -1; /// 后台写盘队列:请求线程只入队,不落盘。 private static readonly BlockingCollection _queue = new BlockingCollection(new ConcurrentQueue(), MaxQueuedEntries); private static Thread _writerThread; /// 已创建过的文件夹缓存,避免每次写盘重复 CreateDirectory。 private static readonly HashSet _createdDirs = new HashSet(StringComparer.OrdinalIgnoreCase); private sealed class RequestRecord { public string id; public DateTime time; public string service; public string action; public string url; public string path; public string method; public string query; public string body; public string ip; public string ua; } /// 待后台写盘的一条日志。 private sealed class LogEntry { public RequestRecord Record; public byte[] ResponseBytes; public int StatusCode; } /// 请求入口:解析 action/service、读取并缓存请求体、安装响应捕获。 public static void BeginRequest(HttpContext context) { try { EnsureConfig(context); if (!_enabled) return; if (Interlocked.Read(ref _storedCount) >= MaxStoredRequests) return; // 达到上限,跳过录制 string path = context.Request.Path ?? ""; if (!IsApiPath(path)) return; string service, action; ParsePath(path, out service, out action); string body = ReadBody(context); var record = new RequestRecord { id = Guid.NewGuid().ToString("N"), time = DateTime.Now, service = service, action = action, path = path, method = context.Request.HttpMethod, url = SafeGet(() => context.Request.Url == null ? path : context.Request.Url.ToString(), path), query = Desensitize(SafeGet(() => context.Request.Url == null ? "" : context.Request.Url.Query, "")), body = Truncate(Desensitize(body)), ip = SafeGet(() => context.Request.UserHostAddress, ""), ua = SafeGet(() => context.Request.UserAgent, "") }; context.Items[ItemKey] = record; try { context.Response.Headers["X-Request-Id"] = record.id; } catch { } try { var filter = new ApiResponseCaptureStream(context.Response.Filter); context.Response.Filter = filter; context.Items[FilterItemKey] = filter; } catch { } } catch { } } /// 请求出口:只把日志放入后台队列,由后台线程序列化并写盘,不阻塞请求。 public static void EndRequest(HttpContext context) { try { if (!_enabled) return; var record = context.Items[ItemKey] as RequestRecord; if (record == null) return; byte[] responseBytes = null; try { var filter = context.Items[FilterItemKey] as ApiResponseCaptureStream; if (filter != null) responseBytes = filter.GetCapturedBytes(); } catch { } int status = SafeGet(() => context.Response.StatusCode, 0); _queue.TryAdd(new LogEntry { Record = record, ResponseBytes = responseBytes, StatusCode = status }); } catch { } } private static void EnsureConfig(HttpContext context) { if (_configLoaded) return; lock (SyncRoot) { if (_configLoaded) return; try { string sw = ConfigurationManager.AppSettings["ApiRequestLog"]; if (!string.IsNullOrEmpty(sw)) _enabled = (sw == "1" || sw.Equals("true", StringComparison.OrdinalIgnoreCase) || sw.Equals("on", StringComparison.OrdinalIgnoreCase)); string dir = ConfigurationManager.AppSettings["ApiRequestLogDir"]; if (string.IsNullOrEmpty(dir)) dir = "~/App_Data/ApiLogs"; _logDir = dir.StartsWith("~/") ? context.Server.MapPath(dir) : dir; if (!Directory.Exists(_logDir)) Directory.CreateDirectory(_logDir); } catch { } StartWriterThread(); // 后台线程负责统计条数与落盘 _configLoaded = true; } } /// 统计日志目录下已存储的 JSON 请求文件数量。 private static long CountStoredFiles() { try { if (!Directory.Exists(_logDir)) return 0; return Directory.GetFiles(_logDir, "*.json", SearchOption.AllDirectories).LongLength; } catch { return 0; } } /// 按请求路径生成存储文件夹(每段路径一层子目录,清洗非法字符、防目录穿越)。 private static string GetRecordDirectory(string path) { string p = (path ?? "").Trim().TrimStart('/').TrimStart('\\'); if (string.IsNullOrEmpty(p)) p = "_root"; var sb = new StringBuilder(_logDir); string[] segs = p.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries); foreach (string seg in segs) { if (seg == "." || seg == "..") continue; // 防目录穿越 string clean = SanitizeSegment(seg); if (string.IsNullOrEmpty(clean)) continue; sb.Append(Path.DirectorySeparatorChar).Append(clean); } return sb.ToString(); } /// 清洗路径段为合法的文件夹名。 private static string SanitizeSegment(string segment) { if (string.IsNullOrEmpty(segment)) return null; char[] invalid = Path.GetInvalidFileNameChars(); var sb = new StringBuilder(segment.Length); foreach (char c in segment) { sb.Append(Array.IndexOf(invalid, c) >= 0 ? '_' : c); } string s = sb.ToString().Trim().TrimEnd('.'); if (s.Length == 0) return "_"; if (s.Length > 80) s = s.Substring(0, 80); return s; } /// 启动后台写盘线程(仅一次)。 private static void StartWriterThread() { if (_writerThread != null) return; lock (SyncRoot) { if (_writerThread != null) return; var thread = new Thread(WriterLoop) { IsBackground = true, Name = "ApiRequestLogWriter" }; _writerThread = thread; thread.Start(); } } /// 后台写盘主循环:先统计已有条数,再持续消费队列。 private static void WriterLoop() { try { if (Interlocked.Read(ref _storedCount) < 0) Interlocked.Exchange(ref _storedCount, CountStoredFiles()); } catch { } foreach (LogEntry entry in _queue.GetConsumingEnumerable()) { try { WriteEntry(entry); } catch { } } } /// 在后台线程中把一条日志序列化并写盘。 private static void WriteEntry(LogEntry entry) { if (Interlocked.Read(ref _storedCount) >= MaxStoredRequests) return; // 达到上限,跳过不再存储 string responseText = entry.ResponseBytes == null || entry.ResponseBytes.Length == 0 ? "" : Truncate(Desensitize(BytesToText(entry.ResponseBytes))); var json = JsonConvert.SerializeObject(new { id = entry.Record.id, time = entry.Record.time.ToString("yyyy-MM-dd HH:mm:ss.fff"), service = entry.Record.service, action = entry.Record.action, method = entry.Record.method, url = entry.Record.url, path = entry.Record.path, query = entry.Record.query, body = entry.Record.body, response = responseText, ip = entry.Record.ip, ua = entry.Record.ua, status = entry.StatusCode }); lock (SyncRoot) { if (Interlocked.Read(ref _storedCount) >= MaxStoredRequests) return; // 双检,防止超限 string dir = GetRecordDirectory(entry.Record.path); if (_createdDirs.Add(dir)) Directory.CreateDirectory(dir); string fileName = entry.Record.time.ToString("yyyyMMdd_HHmmss_fff") + "_" + entry.Record.id + ".json"; File.WriteAllText(Path.Combine(dir, fileName), json, new UTF8Encoding(false)); // 不带 BOM 的 UTF-8 Interlocked.Increment(ref _storedCount); } } /// 响应字节转文本;二进制内容只记录长度。 private static string BytesToText(byte[] bytes) { if (bytes == null || bytes.Length == 0) return ""; bool binary = false; for (int i = 0; i < bytes.Length; i++) { byte b = bytes[i]; if (b == 0 || b < 8) { binary = true; break; } } if (binary) return ""; return Encoding.UTF8.GetString(bytes); } private static string ReadBody(HttpContext context) { try { var input = context.Request.InputStream; if (input == null || !input.CanRead || input.Length <= 0) return ""; string text; using (var reader = new StreamReader(input, Encoding.UTF8, true, 1024, true)) { text = reader.ReadToEnd(); } try { if (input.CanSeek) input.Position = 0; } catch { } return text; } catch { return ""; } } private static bool IsApiPath(string path) { if (string.IsNullOrEmpty(path)) return false; if (path.IndexOf(".asmx", StringComparison.OrdinalIgnoreCase) >= 0) return true; foreach (string p in FriendlyPrefixes) { if (path.StartsWith(p, StringComparison.OrdinalIgnoreCase)) return true; } return false; } private static void ParsePath(string path, out string service, out string action) { service = ""; action = ""; if (string.IsNullOrEmpty(path)) return; string p = path.TrimStart('/'); if (p.EndsWith("/")) p = p.TrimEnd('/'); string[] segs = p.Split('/'); if (segs.Length == 0) return; action = segs[segs.Length - 1]; if (segs.Length >= 2) { int asmxIdx = -1; for (int i = 0; i < segs.Length; i++) { if (segs[i].EndsWith(".asmx", StringComparison.OrdinalIgnoreCase)) { asmxIdx = i; break; } } if (asmxIdx >= 0) service = segs[asmxIdx].Replace(".asmx", ""); else service = segs[0]; } else if (p.EndsWith(".asmx", StringComparison.OrdinalIgnoreCase)) { service = p.Replace(".asmx", ""); action = service; } } private static string Desensitize(string text) { if (string.IsNullOrEmpty(text)) return text; string r = SensitiveRegex.Replace(text, "\"$1\":\"***\""); r = SensitiveKeyValueRegex.Replace(r, "$1***"); return r; } private static string Truncate(string text) { if (string.IsNullOrEmpty(text) || text.Length <= MaxBodyLogLength) return text; return text.Substring(0, MaxBodyLogLength) + " [truncated...]"; } private static T SafeGet(Func fn, T def) { try { return fn(); } catch { return def; } } /// 响应捕获流:包装 Response.Filter,缓存写出内容。 private sealed class ApiResponseCaptureStream : Stream { private readonly Stream _inner; private readonly MemoryStream _buffer = new MemoryStream(); private string _capturedText; public ApiResponseCaptureStream(Stream inner) { _inner = inner ?? new MemoryStream(); } public byte[] GetCapturedBytes() { return _buffer.ToArray(); } public string GetCapturedText() { if (_capturedText == null) _capturedText = BytesToText(_buffer.ToArray()); return _capturedText; } public override bool CanRead { get { return _inner.CanRead; } } public override bool CanSeek { get { return _inner.CanSeek; } } public override bool CanWrite { get { return _inner.CanWrite; } } public override long Length { get { return _inner.Length; } } public override long Position { get { return _inner.Position; } set { _inner.Position = value; } } public override void Flush() { _inner.Flush(); } public override int Read(byte[] buffer, int offset, int count) { return _inner.Read(buffer, offset, count); } public override long Seek(long offset, SeekOrigin origin) { return _inner.Seek(offset, origin); } public override void SetLength(long value) { _inner.SetLength(value); } public override void Write(byte[] buffer, int offset, int count) { _buffer.Write(buffer, offset, count); _inner.Write(buffer, offset, count); } protected override void Dispose(bool disposing) { if (disposing) { try { GetCapturedText(); } catch { } try { _inner.Dispose(); } catch { } } base.Dispose(disposing); } } } }