Files
chfei fa03add754 增加同步物流模块
修改现有架构,改为分模块( 简易版)
2026-09-22 17:52:42 +08:00

431 lines
18 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}
}
}