一、引言
一个生产级平台,功能做得再好,如果缺少这些「基础设施」层面的设计,一样跑不稳:
- 每次请求都查数据库,扛不住并发
- 没有操作日志,出了问题追查不了
- 定时任务全靠人肉触发
- 软件被随便拷贝部署,没有版权保护
这一篇不讲业务,专门聊聊那些藏在业务代码后面的支撑性设计:AOP 缓存拦截器、审计日志、Quartz 定时任务、License 硬件授权,以及NLog 全链路日志。
二、AOP 缓存拦截器:一行注解搞定缓存
做共享充电宝平台,很多数据是高频读取、低频更新的——比如店铺信息、设备信息、数据字典。每次请求都去查数据库,完全是浪费。
我们用 Castle DynamicProxy + Autofac 实现了一个方法级别的缓存拦截器,原理很朴素:在方法执行前检查缓存,有就直接返回,没有就执行方法并写入缓存。
缓存注解:
[AttributeUsage(AttributeTargets.Method)]
public class CachingAttribute : Attribute
{
public int AbsoluteExpiration { get; set; } = 5; // 默认缓存 5 分钟
}
使用方式:
[Caching(AbsoluteExpiration = 5)]
public async Task<List<ModuleResDto>> GetListAsync(int appId)
{
// 正常的查询逻辑
return await query.ToListAsync();
}
拦截器效果: 调用 GetListAsync 时,拦截器先根据方法名 + 参数生成缓存键,检查内存缓存中是否有值。有则直接返回,没有则执行方法并将结果写入缓存。后续 5 分钟内的相同请求都走缓存。
public override void Intercept(IInvocation invocation)
{
var method = invocation.MethodInvocationTarget ?? invocation.Method;
var cacheAttr = method.GetCustomAttributes(true)
.FirstOrDefault(x => x.GetType() == typeof(CachingAttribute));
if (cacheAttr is CachingAttribute cachingAttribute)
{
var cacheKey = CustomCacheKey(invocation);
var cacheValue = cacheService.Get(cacheKey);
if (cacheValue != null)
{
invocation.ReturnValue = cacheValue;
return;
}
invocation.Proceed();
cacheService.Add(cacheKey, invocation.ReturnValue,
TimeSpan.FromMinutes(cachingAttribute.AbsoluteExpiration),
TimeSpan.FromMinutes(cachingAttribute.AbsoluteExpiration));
}
else
{
invocation.Proceed();
}
}
缓存键生成规则:
private string CustomCacheKey(IInvocation invocation)
{
var typeName = invocation.TargetType.FullName;
var methodName = invocation.Method.Name;
var parameters = string.Join(",",
invocation.Arguments.Select(a => a?.ToString() ?? ""));
// 结果:COM.HS.Application.System.ModuleService.GetListAsync_1
return $"{typeName}_{methodName}_{parameters}";
}
搭配审计拦截器自动清缓存:
每次增删改操作完成后,审计拦截器会清除相关缓存:
public override void Intercept(IInvocation invocation)
{
invocation.Proceed();
var method = invocation.MethodInvocationTarget ?? invocation.Method;
var loggingAttribute = method.GetCustomAttributes(true)
.FirstOrDefault(x => x.GetType() == typeof(LoggerAttribute));
if (loggingAttribute is LoggerAttribute logAttr)
{
// 增删改操作后清除缓存
cacheService.RemoveByPattern(logAttr.CacheKey);
// 记录审计日志
}
}
RemoveByPattern 通过正则匹配批量移除缓存键,以「父级模块」为粒度。比如店铺相关的所有缓存键都包含 "Shop" 前缀,修改店铺时一次清除所有店铺缓存。
三、审计日志:操作有迹可循
审计拦截器 AuditAop 工作在 Service 层,拦截所有被 [Logger] 标记的方法。
注解定义:
[AttributeUsage(AttributeTargets.Method)]
public class LoggerAttribute : Attribute
{
public string Summary { get; set; } // 操作描述
public string CacheKey { get; set; } // 关联的缓存键(用于清缓存)
}
使用示例:
[Logger(Summary = "修改店铺信息", CacheKey = "Shop")]
public async Task<ResultModel> UpdateAsync(ShopEditDto model) { ... }
[Logger(Summary = "新增设备")]
public async Task<ResultModel> AddAsync(DeviceAddDto model) { ... }
审计日志的拦截逻辑:
public override void Intercept(IInvocation invocation)
{
var user = GetCurrentUser();
var method = invocation.MethodInvocationTarget ?? invocation.Method;
var logAttr = method.GetCustomAttributes(true)
.FirstOrDefault(x => x.GetType() == typeof(LoggerAttribute)) as LoggerAttribute;
// 1. 清缓存(如果有指定 CacheKey)
if (!string.IsNullOrEmpty(logAttr?.CacheKey))
cacheService.RemoveByPattern(logAttr.CacheKey);
// 2. 执行方法
invocation.Proceed();
// 3. 记录审计日志
if (logAttr != null)
{
var log = new SysAuditLogEntity
{
UserId = user?.UserId,
UserName = user?.UserName,
Summary = logAttr.Summary,
ApiUrl = GetRequestUrl(),
LogDate = DateTime.Now,
Status = (invocation.ReturnValue as ResultModel)?.Status ?? true
};
efDbContext.SysAuditLog.Add(log);
}
}
关键设计: 审计日志是异步写入的。拦截器中只 Add 到 EF Core 的 ChangeTracker,跟着事务一起提交,不需要额外调用 SaveChangesAsync。这样既不会漏记,也不会多一次 I/O。
审计日志页面支持按用户、操作、时间段查询。运营人员可以追溯每一个关键操作的发生时间、操作人和结果。
四、Quartz.NET 定时任务:HTTP Job 的灵活调度
有些场景需要定时执行的后台任务:
- 心跳数据超过 72 小时自动清理
- 超时未支付订单自动取消
- 定时统计报表生成
- 数据字典缓存预热
我们用了 Quartz.NET 来做任务调度,并实现了一个 HTTP Job——任务的执行逻辑不写死代码,而是通过配置 HTTP 请求地址,动态调用 Controller 接口。
HttpJob 核心逻辑:
public class HttpJob : IJob
{
private readonly IHttpClientFactory httpClientFactory;
public async Task Execute(IJobExecutionContext context)
{
JobDataMap dataMap = context.JobDetail.JobDataMap;
var type = dataMap.GetString("Type"); // GET / POST
var url = dataMap.GetString("Url"); // 请求地址
var header = dataMap.GetString("Header"); // 请求头
var body = dataMap.GetString("Body"); // POST 请求体
var client = httpClientFactory.CreateClient();
var request = new HttpRequestMessage(
type == "POST" ? HttpMethod.Post : HttpMethod.Get, url);
// 添加请求头(如 JWT Token)
if (!string.IsNullOrEmpty(header))
{
var headers = JsonConvert.DeserializeObject<Dictionary<string, string>>(header);
foreach (var h in headers)
request.Headers.TryAddWithoutValidation(h.Key, h.Value);
}
// POST 请求体
if (type == "POST" && !string.IsNullOrEmpty(body))
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
var response = await client.SendAsync(request);
var result = await response.Content.ReadAsStringAsync();
// 记录执行日志
}
}
配置方式: 在 OPS 后台的定时任务管理页面配置:
| 任务名称 | 类型 | URL | Cron 表达式 | 状态 |
|---|---|---|---|---|
| 清理心跳数据 | GET | /api/biz/heart/autoClear | 0 0 3 * * ? | 启用 |
| 取消超时订单 | POST | /api/biz/order/timeout/cancel | 0 */5 * * * ? | 启用 |
Cron 表达式支持在线验证,运营人员可以随时启用/禁用任务。
调度启动:
// Program.cs
builder.Services.AddQuartz(q =>
{
q.UseMicrosoftDependencyInjectionJobFactory();
});
builder.Services.AddQuartzServer(options =>
{
options.WaitForJobsToComplete = true;
});
每个 Job 的类型和触发时间在数据库 SysTaskEntity 表中配置,StartAsync 时遍历所有启用的任务并注册到 Quartz 调度器。新增任务也通过 JobService 动态注册,不需要重启服务:
public async Task StartAsync()
{
var list = await currentRepository.GetListAsync(x => x.IsEnabled == 1);
foreach (var item in list)
{
var job = JobBuilder.Create<HttpJob>()
.WithIdentity(item.TaskId)
.UsingJobData("Type", item.RequestType)
.UsingJobData("Url", item.Url)
.UsingJobData("Header", item.Header)
.Build();
var trigger = TriggerBuilder.Create()
.WithIdentity(item.TaskId)
.WithCronSchedule(item.CronExpression)
.StartNow()
.Build();
await scheduler.ScheduleJob(job, trigger);
}
}
五、License 硬件授权:保护你的心血
软件可以被任意拷贝部署,如果不做保护,就等于白干。
我们的做法是给每一套部署的系统绑定一个 硬件指纹。部署时生成 License 文件,只有授权服务器上才能正常运行。
License 文件结构:
public class LicenseModel
{
public string HardwareFingerprint { get; set; } // 硬件指纹
public DateTime ExpiryUtc { get; set; } // 过期时间
public string HmacSignature { get; set; } // HMAC 签名
}
验证流程(五道关卡):
public bool ValidateLicense(LicenseModel license)
{
// 1. 非空校验
if (license == null) return false;
// 2. 验证 HMAC 签名(防篡改)
var dataForSign = license.SerializeForSignature();
bool hmacValid = LicenseUtils.VerifyHMAC(dataForSign, license.HmacSignature);
if (!hmacValid) return false;
// 3. 验证过期时间
if (license.ExpiryUtc < DateTime.UtcNow) return false;
// 4. 验证硬件指纹(防拷贝到其他服务器)
var currentFingerprint = hardwareIdService.GetFingerprint();
if (currentFingerprint != license.HardwareFingerprint) return false;
// 5. 验证系统时间(防止时间回退绕过过期)
var lastCheck = GetLastCheckUtc();
if (lastCheck.HasValue && DateTime.UtcNow < lastCheck.Value) return false;
SaveLastCheckUtc(DateTime.UtcNow);
return true;
}
启动即校验:
Program.cs 启动时先检查 License:
using (var scope = app.Services.CreateScope())
{
var checker = scope.ServiceProvider
.GetRequiredService<ILicenseStartupChecker>();
checker.Check();
}
如果 License 无效,系统直接返回 403,所有接口不可用。
运行中持续校验:
OfflineLicenseCheckerService 是一个后台服务,每 60 秒重新检查一次 License 的有效性:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var license = licenseService.LoadLicense();
if (licenseService.ValidateLicense(license))
LicenseState.SetValid();
else
LicenseState.SetInvalid();
await Task.Delay(60000, stoppingToken);
}
}
LicenseState.IsValid 是一个全局状态标志,在中间件中检查:
public async Task InvokeAsync(HttpContext context)
{
// 白名单(健康检查等)
if (context.Request.Path.StartsWithSegments("/health"))
{
await next(context);
return;
}
if (!LicenseState.IsValid)
{
context.Response.StatusCode = 200;
await context.Response.WriteAsync(
JsonConvert.SerializeObject(
new ResultModel("License 已过期或无效,请联系管理员", 403)));
return;
}
await next(context);
}
为什么还要做运行中持续校验?
因为如果有人部署后把系统时间调回到 License 过期之前,启动时的校验会被绕过。OfflineLicenseChecker 每 60 秒检查一次系统时间和 License 状态,一旦发现时间回退或 License 过期,立即标记为无效,拦截后续请求。
六、NLog 全链路日志
日志不是功能,但比功能更重要。我们选用了 NLog,配置支持文件 + 数据库双写。
配置文件:
<nlog>
<targets>
<!-- 文件日志:按天切割,保留 30 天 -->
<target xsi:type="File" name="allfile"
fileName="logs/${shortdate}/${logger}.log"
layout="${longdate} | ${level} | ${logger} | ${message}"
maxArchiveFiles="30" archiveAboveSize="10485760" />
<!-- 数据库日志 -->
<target xsi:type="Database" name="database"
connectionString="${var:connectionString}"
commandText="insert into SysLog(...) values(...)" />
</targets>
<rules>
<logger name="*" minlevel="Info" writeTo="allfile,database" />
</rules>
</nlog>
日志覆盖范围:
- 所有 API 请求的入参、出参、耗时
- AOP 审计日志
- Socket 通信日志
- License 校验日志
- Quartz 任务执行日志
- NLog 自身的错误日志(fallback)
每个日志条目包含时间戳、日志等级、记录器名称、消息内容。文件按天切割,超过 30 天的自动归档删除,避免磁盘写满。
七、小结
这一篇覆盖了四个看似零散但实际紧密相关的模块:
- AOP 缓存:
[Caching]注解实现方法级缓存,高频查询接口直接从缓存返回,数据库压力大幅降低 - 审计日志:
[Logger]注解自动记录操作日志,操作可追溯 - Quartz 定时任务HTTP Job 支持动态配置,新增任务不需要改代码
- License 授权HMAC 签名 + 硬件指纹 + 时间防回退,五道关卡保护软件版权
- NLog 日志文件 + 数据库双写,覆盖全链路
这些设计有一个共同点:用注解和 AOP 把横切关注点从业务代码中抽离出来。业务开发只需要专注业务逻辑,缓存和日志由拦截器自动处理。
下一篇是系列最后一篇——复盘与展望,聊聊踩过的坑、做过的取舍、以及接下来的方向。
有疑问或者想讨论的点,留言区见。