增加同步物流模块
修改现有架构,改为分模块( 简易版)
This commit is contained in:
1 parent
15e11618c9
commit
fa03add754
120 files changed
+9438
-299
No files matched your search
@@ -0,0 +1,431 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// API 请求录制工具:挂接在 Global.asax 的 BeginRequest / EndRequest,
|
||||
/// 捕获请求链接(action/service)、请求内容(body/query)与响应,
|
||||
/// 按请求路径生成文件夹,每一条请求落盘为一个 JSON 文件,
|
||||
/// 用于 .NET Core 迁移后的回放校验。
|
||||
/// 配置(Web.config appSettings):
|
||||
/// ApiRequestLog = 1 开启 / 0 关闭(默认开启)
|
||||
/// ApiRequestLogDir = 日志目录(默认 ~/App_Data/ApiLogs)
|
||||
/// 存储上限:达到 MaxStoredRequests(常量)后跳过不再存储。
|
||||
/// 写盘为后台异步:请求线程只入队,由后台线程序列化并落盘,不阻塞接口返回。
|
||||
/// </summary>
|
||||
public static class ApiRequestLogger
|
||||
{
|
||||
private const string ItemKey = "__ApiRequestLog__";
|
||||
private const string FilterItemKey = "__ApiResponseCapture__";
|
||||
private const int MaxBodyLogLength = 100000;
|
||||
|
||||
/// <summary>最大存储条数(所有请求路径合计)。达到该数量后,跳过不再存储新请求。</summary>
|
||||
private const int MaxStoredRequests = 10000;
|
||||
/// <summary>后台队列最大积压条数;队列满时丢弃新日志(不影响接口返回)。</summary>
|
||||
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;
|
||||
/// <summary>已存储条数;-1 表示尚未统计(由后台线程统计)。</summary>
|
||||
private static long _storedCount = -1;
|
||||
/// <summary>后台写盘队列:请求线程只入队,不落盘。</summary>
|
||||
private static readonly BlockingCollection<LogEntry> _queue =
|
||||
new BlockingCollection<LogEntry>(new ConcurrentQueue<LogEntry>(), MaxQueuedEntries);
|
||||
private static Thread _writerThread;
|
||||
/// <summary>已创建过的文件夹缓存,避免每次写盘重复 CreateDirectory。</summary>
|
||||
private static readonly HashSet<string> _createdDirs = new HashSet<string>(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;
|
||||
}
|
||||
|
||||
/// <summary>待后台写盘的一条日志。</summary>
|
||||
private sealed class LogEntry
|
||||
{
|
||||
public RequestRecord Record;
|
||||
public byte[] ResponseBytes;
|
||||
public int StatusCode;
|
||||
}
|
||||
|
||||
/// <summary>请求入口:解析 action/service、读取并缓存请求体、安装响应捕获。</summary>
|
||||
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 { }
|
||||
}
|
||||
|
||||
/// <summary>请求出口:只把日志放入后台队列,由后台线程序列化并写盘,不阻塞请求。</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>统计日志目录下已存储的 JSON 请求文件数量。</summary>
|
||||
private static long CountStoredFiles()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(_logDir)) return 0;
|
||||
return Directory.GetFiles(_logDir, "*.json", SearchOption.AllDirectories).LongLength;
|
||||
}
|
||||
catch { return 0; }
|
||||
}
|
||||
|
||||
/// <summary>按请求路径生成存储文件夹(每段路径一层子目录,清洗非法字符、防目录穿越)。</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>清洗路径段为合法的文件夹名。</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>启动后台写盘线程(仅一次)。</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>后台写盘主循环:先统计已有条数,再持续消费队列。</summary>
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>在后台线程中把一条日志序列化并写盘。</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>响应字节转文本;二进制内容只记录长度。</summary>
|
||||
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 "<binary length=" + bytes.Length + ">";
|
||||
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<T>(Func<T> fn, T def)
|
||||
{
|
||||
try { return fn(); }
|
||||
catch { return def; }
|
||||
}
|
||||
|
||||
/// <summary>响应捕获流:包装 Response.Filter,缓存写出内容。</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user