feat(swap): SwapCalcTrace 支持运行时按请求开关 + 响应头可见(无需重启)

- SwapCalcTrace 改为双模式:保留全局静态开关(单元测试用 IsEnabled/Dump),
  新增请求级 AsyncLocal 缓冲(EnableForRequest/DumpForRequest/ClearForRequest),
  由中间件按请求开关,不跨请求串扰。
- 新增 SwapCalcTraceMiddleware:开启方式三选一——URL ?swaptrace=1 /
  请求头 X-SwapCalc-Trace:1 / 标记文件 App_Data/swapcalctrace.on(无需重启);
  Response.OnStarting 时把逐日计息过程写入响应头 X-SwapCalc-Trace(超长落
  logs/swapcalctrace-YYYYMMDD.log 并在头给路径),同时写服务器日志。
  开发者在浏览器“网络”面板即可查看,零前端改码、零行为影响、性能可忽略。
- Program.cs 在 ExceptionMiddleware 后注册该中间件。
- 删除 CalcDailyCompoundInterestByEod 遗留垃圾日志 LogFactory.GetLogger("test").Error("lksafhasdhfjas")。
- CalcDailySimpleInterest 两处追踪守卫改为 IsEnabled||IsRequestEnabled。
- 既有 PrepaidPrincipalCloseTraceTest / ClosingChainTraceTest 2/2 仍全绿。

回答用户两点:① dynomicPrincipal 公式字段含义;② 原 Trace 需重启才能开,
现改为前端 otcdebug 式(?swaptrace=1) 即可在响应头看到逐步计息过程。
This commit is contained in:
hjhan
2026-08-08 15:57:00 +08:00
parent f6a5c1dc83
commit 97029897c4
4 changed files with 142 additions and 27 deletions
+34 -24
View File
@@ -5,48 +5,58 @@ using System.Text;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 计息计算过程追踪器默认关闭,零运行时成本)。
/// 计息计算过程追踪器默认完全关闭,零运行时成本;开启后逐日记录:
/// 计息起点、日终归档 ValueDate 地板、计息基数 dynomicPrincipal、当日利率、当日利息、累计利息。
/// 便于定位"算出来一个数却不对"的根因,也便于把一次真实平仓的逐步过程打印出来与 Excel 对账。
///
/// 在 CalcDailySimpleInterest / CalcDailyCompoundInterest 等计息函数中调用
/// SwapCalcTrace.Record(...),开启后逐日记录:计息起点、日终归档 ValueDate 地板、
/// 计息基数、当日利率、当日利息、累计利息。便于定位"算出来一个数却不对"的根因,
/// 也便于把一次真实平仓的逐步过程打印出来与 Excel 对账
///
/// 用法(单元测试或临时排障):
/// SwapCalcTrace.IsEnabled = true;
/// SwapCalcTrace.Reset();
/// ... 调用计息 ...
/// Console.WriteLine(SwapCalcTrace.Dump());
/// SwapCalcTrace.IsEnabled = false;
/// 两种开启方式(互不冲突):
/// 1) 全局(单元测试):SwapCalcTrace.IsEnabled = true; SwapCalcTrace.Reset(); ...; SwapCalcTrace.Dump();
/// 2) 请求(运行时排障,无需重启):经 SwapCalcTraceMiddleware,在 URL 追加 ?swaptrace=1
/// 或请求头 X-SwapCalc-Trace: 1 即可,追踪文本会出现在响应头 X-SwapCalc-Trace(超长落 logs/ 并在头部给路径)
/// </summary>
public static class SwapCalcTrace
{
// ---- 全局模式(单元测试,保持 ThreadStatic 兼容既有测试) ----
public static bool IsEnabled { get; set; } = false;
[ThreadStatic]
private static List<string> _lines;
private static List<string> _globalLines;
private static List<string> Lines => _lines ??= new List<string>();
private static List<string> GlobalLines => _globalLines ??= new List<string>();
public static void Reset() => Lines.Clear();
// ---- 请求模式(运行时排障,按请求异步流隔离,不会跨请求串扰) ----
private static readonly AsyncLocal<StringBuilder> _reqBuf = new AsyncLocal<StringBuilder>();
private static readonly AsyncLocal<bool> _reqOn = new AsyncLocal<bool>();
public static void Header(string title)
public static void EnableForRequest(bool on = true) => _reqOn.Value = on;
public static bool IsRequestEnabled => _reqOn.Value;
public static void ClearForRequest() => _reqBuf.Value = null;
public static void Reset()
{
if (IsEnabled) Lines.Add($"== {title} ==");
GlobalLines.Clear();
_reqBuf.Value = null;
}
public static void Line(string text)
{
if (IsEnabled) Lines.Add(text);
}
public static void Header(string title) => Record($"== {title} ==");
public static void Line(string text) => Record(text);
/// <summary>记录某一计息日的明细。</summary>
public static void Day(int idx, DateTime date, decimal rate, decimal basePrincipal, decimal dayInterest, decimal accumulated)
=> Record($" [{idx}] {date:yyyy-MM-dd} rate={rate:P6} base={basePrincipal:F4} day={dayInterest:F6} acc={accumulated:F6}");
private static void Record(string s)
{
if (IsEnabled)
Lines.Add($" [{idx}] {date:yyyy-MM-dd} rate={rate:P6} base={basePrincipal:F4} day={dayInterest:F6} acc={accumulated:F6}");
if (IsEnabled) GlobalLines.Add(s);
if (_reqOn.Value)
{
_reqBuf.Value ??= new StringBuilder();
_reqBuf.Value.AppendLine(s);
}
}
public static string Dump() => string.Join(Environment.NewLine, Lines);
public static string Dump() => string.Join(Environment.NewLine, GlobalLines);
public static string DumpForRequest() => _reqBuf.Value?.ToString() ?? "";
}
}
@@ -1360,7 +1360,7 @@ namespace YLErp.Modules.SwapModule
decimal dynomicPrincipal = preEodPosition.TdInterestPrincipal + posiPrincipal - orginPv;
decimal tdDynomicPrincipal = dynomicPrincipal;
var calcDays = (endDate - startDate).Days;
if (SwapCalcTrace.IsEnabled)
if (SwapCalcTrace.IsEnabled || SwapCalcTrace.IsRequestEnabled)
{
SwapCalcTrace.Header($"CalcDailySimpleInterest posId={position.id} mode={position.InterestMode} type={(position.InterestType == (int)InterestTypeEnum.复利 ? "" : "")}");
SwapCalcTrace.Line($" PosiStartDate={startDate:yyyy-MM-dd} endDate={endDate:yyyy-MM-dd} calcDays={calcDays} calcFirst={calcFirst} calcLast={calcLast}");
@@ -1409,7 +1409,7 @@ namespace YLErp.Modules.SwapModule
}
interest += interest1;
tdinterest += tdinterest1;
if (SwapCalcTrace.IsEnabled)
if (SwapCalcTrace.IsEnabled || SwapCalcTrace.IsRequestEnabled)
SwapCalcTrace.Day(i, accrueDate, (decimal)floatRate, flowEvent.InterestPrincipal, interest1, interest);
}
}
@@ -1438,7 +1438,6 @@ namespace YLErp.Modules.SwapModule
decimal tdDynomicPrincipal = posiPrincipal;
double floatRate = Convert.ToDouble(floateRate);
var days = (endDate - tradeDate).Days;
LogFactory.GetLogger("test").Error("lksafhasdhfjas");
if (days % interestPeriod == 0)
{
LogFactory.GetLogger("test").Error("kluausdyfh");
@@ -0,0 +1,103 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using System;
using System.IO;
using System.Threading.Tasks;
using YLErp.Helpers;
using YLErp.Modules.SwapModule;
namespace YLErp.Web.Middleware
{
/// <summary>
/// 计息计算过程追踪中间件(运行时排障用,无需重启、无需改前端)。
///
/// 开启方式(三选一):
/// 1) 请求 URL 追加 ?swaptrace=1
/// 2) 请求头带 X-SwapCalc-Trace: 1
/// 3) 站点根目录 App_Data/swapcalctrace.on 标记文件存在(对所有请求开启,谨慎使用)
///
/// 开启后,SwapCalcTrace 会在计息函数中逐日记录过程。本中间件在响应发出前把追踪文本写入:
/// - 响应头 X-SwapCalc-Trace(开发者在浏览器"网络"面板即可看到,超长则写入日志并在头部给文件路径)
/// - 服务器日志 logs/swapcalctrace-YYYYMMDD.log(完整文本,便于 tail/检索)
///
/// 性能影响可忽略(仅被显式开启的少数请求触发,且每次只是拼字符串)。
/// </summary>
public class SwapCalcTraceMiddleware
{
private readonly RequestDelegate _next;
private readonly IWebHostEnvironment _env;
public SwapCalcTraceMiddleware(RequestDelegate next, IWebHostEnvironment env)
{
_next = next;
_env = env;
}
public async Task InvokeAsync(HttpContext context)
{
bool on = context.Request.Query["swaptrace"] == "1"
|| context.Request.Headers["X-SwapCalc-Trace"] == "1";
if (!on) on = MarkerFileExists();
if (on) SwapCalcTrace.EnableForRequest(true);
try
{
if (on)
{
// OnStarting 在响应体写出前触发,确保整个请求(含计息)都已被追踪
context.Response.OnStarting(() =>
{
FlushTrace(context);
return Task.CompletedTask;
});
}
await _next(context);
}
finally
{
if (on) SwapCalcTrace.ClearForRequest();
}
}
private void FlushTrace(HttpContext context)
{
var trace = SwapCalcTrace.DumpForRequest();
if (string.IsNullOrEmpty(trace)) return;
var logger = LogFactory.GetLogger("SwapCalcTrace");
logger.Info($"[SwapCalcTrace] {context.Request.Path}\n{trace}");
var headerVal = trace;
if (trace.Length > 4000)
{
try
{
var dir = Path.Combine(_env.ContentRootPath, "logs");
Directory.CreateDirectory(dir);
var file = Path.Combine(dir, $"swapcalctrace-{DateTime.Now:yyyyMMdd}.log");
File.AppendAllText(file,
$"=== {DateTime.Now:yyyy-MM-dd HH:mm:ss} {context.Request.Path} ===\n{trace}\n\n");
headerVal = $"(trace truncated, full in {file})\n" + trace.Substring(0, 4000);
}
catch
{
headerVal = trace.Substring(0, 4000);
}
}
try { context.Response.Headers["X-SwapCalc-Trace"] = headerVal; } catch { }
}
private bool MarkerFileExists()
{
try
{
return File.Exists(Path.Combine(_env.ContentRootPath, "App_Data", "swapcalctrace.on"));
}
catch
{
return false;
}
}
}
}
+3
View File
@@ -171,6 +171,9 @@ try
app.UseMiddleware<ExceptionMiddleware>();
// 计息计算过程追踪(?swaptrace=1 / X-SwapCalc-Trace:1 开启,详见 SwapCalcTraceMiddleware
app.UseMiddleware<SwapCalcTraceMiddleware>();
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
// Linux 路径区分大小写,需要对静态文件做处理