by tcdos 2026-07-10

从细节看品质 - 缓存策略、审计日志、定时任务与授权保护

一、引言

一个生产级平台,功能做得再好,如果缺少这些「基础设施」层面的设计,一样跑不稳:

  • 每次请求都查数据库,扛不住并发
  • 没有操作日志,出了问题追查不了
  • 定时任务全靠人肉触发
  • 软件被随便拷贝部署,没有版权保护

这一篇不讲业务,专门聊聊那些藏在业务代码后面的支撑性设计: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 后台的定时任务管理页面配置:

任务名称类型URLCron 表达式状态
清理心跳数据GET/api/biz/heart/autoClear0 0 3 * * ?启用
取消超时订单POST/api/biz/order/timeout/cancel0 */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 天的自动归档删除,避免磁盘写满。


七、小结

这一篇覆盖了四个看似零散但实际紧密相关的模块:

  1. AOP 缓存:[Caching] 注解实现方法级缓存,高频查询接口直接从缓存返回,数据库压力大幅降低
  2. 审计日志:[Logger] 注解自动记录操作日志,操作可追溯
  3. Quartz 定时任务HTTP Job 支持动态配置,新增任务不需要改代码
  4. License 授权HMAC 签名 + 硬件指纹 + 时间防回退,五道关卡保护软件版权
  5. NLog 日志文件 + 数据库双写,覆盖全链路

这些设计有一个共同点:用注解和 AOP 把横切关注点从业务代码中抽离出来。业务开发只需要专注业务逻辑,缓存和日志由拦截器自动处理。

下一篇是系列最后一篇——复盘与展望,聊聊踩过的坑、做过的取舍、以及接下来的方向。

有疑问或者想讨论的点,留言区见。

天草工坊公众号

欢迎关注 天草工坊 公众号

好的设计被看见,好的开发被落地。

围观:17| 收获点赞:0

评论 0

暂无评论,来说点什么吧

?
内容目录
快捷操作
点赞 评论 返回顶部