Administrator
2026-07-31 aeb0696f457ca85b62d15ab6586c5779cf5cd97b
feat(firewall): 添加访问日志功能

- 新增防火墙访问日志实体类 FirewallAccessLog
- 新建访问日志数据库表 firewall_access_log 并添加索引
- 实现访问日志记录服务方法 recordAccess 和分页查询 accessLogList
- 在 AdminFirewallController 中添加访问日志查询接口 /accessLogList
- 创建 ApiTrackController 提供公开访问追踪API用于记录访问日志
- 添加前端访问日志列表页面 firewallAccessLogList.html 实现查询界面
- 在 ViewSystemController 中注册访问日志页面路由
- 更新 SQL 脚本添加访问日志表结构定义
4 files added
5 files modified
372 ■■■■■ changed files
sql/firewall.sql 26 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/controller/AdminFirewallController.java 9 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/controller/ApiTrackController.java 52 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/entity/FirewallAccessLog.java 51 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/mapper/FirewallAccessLogMapper.java 15 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/service/IFirewallRuleService.java 13 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/service/impl/FirewallRuleServiceImpl.java 22 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/mall/controller/ViewSystemController.java 6 ●●●●● patch | view | raw | blame | history
src/main/resources/templates/febs/views/modules/system/firewallAccessLogList.html 178 ●●●●● patch | view | raw | blame | history
sql/firewall.sql
@@ -59,6 +59,32 @@
    INDEX idx_ip (IP)
) COMMENT = '防火墙IP白名单表';
-- ============================================================
-- 访问日志表
-- ============================================================
DROP TABLE IF EXISTS firewall_access_log;
CREATE TABLE firewall_access_log (
    ID            BIGINT       NOT NULL AUTO_INCREMENT  COMMENT '主键',
    REVISION      INT          DEFAULT 1                COMMENT '乐观锁',
    CREATED_BY    VARCHAR(32)  DEFAULT 'system'         COMMENT '创建人',
    CREATED_TIME  DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    UPDATED_BY    VARCHAR(32)  DEFAULT 'system'         COMMENT '更新人',
    UPDATED_TIME  DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    SITE_ID       BIGINT                                COMMENT '站点ID',
    IP            VARCHAR(64)  NOT NULL                  COMMENT '访问者IP',
    COUNTRY_CODE  VARCHAR(10)                            COMMENT '国家编码(ISO 3166-1 alpha-2)',
    COUNTRY_NAME  VARCHAR(50)                            COMMENT '国家名称',
    USER_AGENT    VARCHAR(500)                           COMMENT '浏览器UA',
    REQUEST_URI   VARCHAR(500)                           COMMENT '请求路径',
    IS_BLOCKED    TINYINT      DEFAULT 0                 COMMENT '是否被拦截:1-是,0-否',
    PRIMARY KEY (ID),
    INDEX idx_site_id (SITE_ID),
    INDEX idx_ip (IP),
    INDEX idx_country (COUNTRY_CODE),
    INDEX idx_created_time (CREATED_TIME),
    INDEX idx_blocked (IS_BLOCKED)
) COMMENT = '防火墙访问日志表';
-- ----------------------------
-- 默认数据
-- ----------------------------
src/main/java/cc/mrbird/febs/firewall/controller/AdminFirewallController.java
@@ -6,6 +6,7 @@
import cc.mrbird.febs.common.entity.QueryRequest;
import cc.mrbird.febs.firewall.dto.CountryVo;
import cc.mrbird.febs.firewall.dto.SaveCountryRuleDto;
import cc.mrbird.febs.firewall.entity.FirewallAccessLog;
import cc.mrbird.febs.firewall.entity.FirewallCountryRule;
import cc.mrbird.febs.firewall.entity.FirewallIpWhite;
import cc.mrbird.febs.firewall.entity.FirewallSite;
@@ -186,4 +187,12 @@
    public FebsResponse deleteIpWhite(@PathVariable @NotNull Long id) {
        return firewallRuleService.deleteIpWhite(id);
    }
    // ==================== 访问日志查询 ====================
    @GetMapping("/accessLogList")
    public FebsResponse accessLogList(FirewallAccessLog log, QueryRequest request) {
        Map<String, Object> data = getDataTable(firewallRuleService.accessLogList(log, request));
        return new FebsResponse().success().data(data);
    }
}
src/main/java/cc/mrbird/febs/firewall/controller/ApiTrackController.java
New file
@@ -0,0 +1,52 @@
package cc.mrbird.febs.firewall.controller;
import cc.mrbird.febs.common.entity.FebsResponse;
import cc.mrbird.febs.firewall.entity.FirewallAccessLog;
import cc.mrbird.febs.firewall.service.IFirewallRuleService;
import cn.hutool.extra.servlet.ServletUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
/**
 * 公开访问追踪API(无需登录)
 * <p>
 * 前端 SPA 页面加载时调用此接口记录访问。
 * Nginx 需配置 proxy_set_header 传递国家和真实IP:
 * <pre>
 *   proxy_set_header X-Real-IP $remote_addr;
 *   proxy_set_header X-Geo-Country $geoip_country_code;
 * </pre>
 *
 * @author auto-generated
 * @date 2026-07-31
 */
@Slf4j
@RestController
@RequestMapping("api/track")
@RequiredArgsConstructor
public class ApiTrackController {
    private final IFirewallRuleService firewallRuleService;
    @GetMapping("visit")
    public FebsResponse trackVisit(HttpServletRequest request) {
        try {
            FirewallAccessLog accessLog = new FirewallAccessLog();
            accessLog.setIp(ServletUtil.getClientIP(request));
            accessLog.setCountryCode(request.getHeader("X-Geo-Country"));
            accessLog.setUserAgent(request.getHeader("User-Agent"));
            accessLog.setRequestUri(request.getHeader("Referer"));
            accessLog.setIsBlocked(0);
            firewallRuleService.recordAccess(accessLog);
            return new FebsResponse().success();
        } catch (Exception e) {
            log.error("访问追踪记录失败", e);
            return new FebsResponse().fail().message("记录失败");
        }
    }
}
src/main/java/cc/mrbird/febs/firewall/entity/FirewallAccessLog.java
New file
@@ -0,0 +1,51 @@
package cc.mrbird.febs.firewall.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
 * 防火墙访问日志
 *
 * @author auto-generated
 * @date 2026-07-31
 */
@Data
@TableName("firewall_access_log")
public class FirewallAccessLog implements Serializable {
    private static final long serialVersionUID = 1L;
    @TableId(value = "ID", type = IdType.AUTO)
    private Long id;
    private Integer revision;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date createdTime;
    private Long siteId;
    /** 访问者IP */
    private String ip;
    /** 国家编码(ISO 3166-1 alpha-2,如 CN/US/JP) */
    private String countryCode;
    /** 国家名称 */
    private String countryName;
    /** 浏览器UA */
    private String userAgent;
    /** 请求路径 */
    private String requestUri;
    /** 是否被拦截:1-是,0-否 */
    private Integer isBlocked;
}
src/main/java/cc/mrbird/febs/firewall/mapper/FirewallAccessLogMapper.java
New file
@@ -0,0 +1,15 @@
package cc.mrbird.febs.firewall.mapper;
import cc.mrbird.febs.firewall.entity.FirewallAccessLog;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
 * 防火墙访问日志 Mapper
 *
 * @author auto-generated
 * @date 2026-07-31
 */
@Mapper
public interface FirewallAccessLogMapper extends BaseMapper<FirewallAccessLog> {
}
src/main/java/cc/mrbird/febs/firewall/service/IFirewallRuleService.java
@@ -3,6 +3,7 @@
import cc.mrbird.febs.common.entity.FebsResponse;
import cc.mrbird.febs.common.entity.QueryRequest;
import cc.mrbird.febs.firewall.dto.CountryVo;
import cc.mrbird.febs.firewall.entity.FirewallAccessLog;
import cc.mrbird.febs.firewall.entity.FirewallCountryRule;
import cc.mrbird.febs.firewall.entity.FirewallIpWhite;
import cc.mrbird.febs.firewall.entity.FirewallSite;
@@ -83,4 +84,16 @@
     * 删除IP白名单(删除后自动生成Nginx配置)
     */
    FebsResponse deleteIpWhite(Long id);
    // ==================== 访问日志管理 ====================
    /**
     * 记录一次访问
     */
    void recordAccess(FirewallAccessLog log);
    /**
     * 访问日志分页列表
     */
    IPage<FirewallAccessLog> accessLogList(FirewallAccessLog log, QueryRequest request);
}
src/main/java/cc/mrbird/febs/firewall/service/impl/FirewallRuleServiceImpl.java
@@ -4,9 +4,11 @@
import cc.mrbird.febs.common.entity.QueryRequest;
import cc.mrbird.febs.common.utils.RedisUtils;
import cc.mrbird.febs.firewall.dto.CountryVo;
import cc.mrbird.febs.firewall.entity.FirewallAccessLog;
import cc.mrbird.febs.firewall.entity.FirewallCountryRule;
import cc.mrbird.febs.firewall.entity.FirewallIpWhite;
import cc.mrbird.febs.firewall.entity.FirewallSite;
import cc.mrbird.febs.firewall.mapper.FirewallAccessLogMapper;
import cc.mrbird.febs.firewall.mapper.FirewallCountryRuleMapper;
import cc.mrbird.febs.firewall.mapper.FirewallIpWhiteMapper;
import cc.mrbird.febs.firewall.mapper.FirewallSiteMapper;
@@ -38,6 +40,7 @@
    private final FirewallCountryRuleMapper countryRuleMapper;
    private final FirewallIpWhiteMapper ipWhiteMapper;
    private final FirewallAccessLogMapper accessLogMapper;
    private final FirewallSiteMapper siteMapper;
    private final NginxConfigGenerator generator;
    private final IpWhitelistNginxConfigGenerator ipWhitelistGenerator;
@@ -298,4 +301,23 @@
        redisUtils.lSet(NGINX_RELOAD_QUEUE_KEY, String.valueOf(siteId));
        return new FebsResponse().success().message("删除成功,Nginx 配置已更新");
    }
    // ==================== 访问日志 ====================
    @Override
    public void recordAccess(FirewallAccessLog accessLog) {
        accessLogMapper.insert(accessLog);
    }
    @Override
    public IPage<FirewallAccessLog> accessLogList(FirewallAccessLog log, QueryRequest request) {
        Page<FirewallAccessLog> page = new Page<>(request.getPageNum(), request.getPageSize());
        LambdaQueryWrapper<FirewallAccessLog> wrapper = new LambdaQueryWrapper<FirewallAccessLog>()
                .eq(log.getSiteId() != null, FirewallAccessLog::getSiteId, log.getSiteId())
                .eq(log.getIsBlocked() != null, FirewallAccessLog::getIsBlocked, log.getIsBlocked())
                .eq(StrUtil.isNotBlank(log.getCountryCode()), FirewallAccessLog::getCountryCode, log.getCountryCode())
                .like(StrUtil.isNotBlank(log.getIp()), FirewallAccessLog::getIp, log.getIp())
                .orderByDesc(FirewallAccessLog::getCreatedTime);
        return accessLogMapper.selectPage(page, wrapper);
    }
}
src/main/java/cc/mrbird/febs/mall/controller/ViewSystemController.java
@@ -187,6 +187,12 @@
        return FebsUtil.view("modules/system/firewallIpWhiteList");
    }
    @GetMapping("firewallAccessLogList")
    @RequiresPermissions("firewallAccessLog:view")
    public String firewallAccessLogList() {
        return FebsUtil.view("modules/system/firewallAccessLogList");
    }
    @GetMapping("tokenviewConfig")
    @RequiresPermissions("tokenviewConfig:update")
    public String tokenviewConfig(Model model) {
src/main/resources/templates/febs/views/modules/system/firewallAccessLogList.html
New file
@@ -0,0 +1,178 @@
<div class="layui-fluid layui-anim febs-anim" id="firewall-access-log-list" lay-title="防火墙访问日志">
    <div class="layui-row febs-container">
        <div class="layui-col-md12">
            <div class="layui-card">
                <div class="layui-card-body febs-table-full">
                    <form class="layui-form layui-table-form" lay-filter="firewall-accesslog-table-form">
                        <div class="layui-row">
                            <div class="layui-col-md10">
                                <div class="layui-form-item">
                                    <button class="layui-btn layui-btn-normal" type="button" id="refresh-btn">
                                        <i class="layui-icon">&#xe79b;</i> 刷新
                                    </button>
                                </div>
                            </div>
                            <div class="layui-col-md2 layui-col-sm12 layui-col-xs12 table-action-area">
                                <div class="layui-btn layui-btn-sm layui-btn-primary febs-button-green-plain table-action" id="reset">
                                    <i class="layui-icon">&#xe79b;</i>
                                </div>
                            </div>
                        </div>
                        <div class="layui-row">
                            <div class="layui-col-md2">
                                <div class="layui-form-item">
                                    <label class="layui-form-label">IP地址</label>
                                    <div class="layui-input-block">
                                        <input type="text" name="ip" class="layui-input" placeholder="搜索IP">
                                    </div>
                                </div>
                            </div>
                            <div class="layui-col-md2">
                                <div class="layui-form-item">
                                    <label class="layui-form-label">国家</label>
                                    <div class="layui-input-block">
                                        <input type="text" name="countryCode" class="layui-input" placeholder="如 CN/US">
                                    </div>
                                </div>
                            </div>
                            <div class="layui-col-md2">
                                <div class="layui-form-item">
                                    <label class="layui-form-label">状态</label>
                                    <div class="layui-input-block">
                                        <select name="isBlocked" lay-filter="filter-blocked">
                                            <option value="">全部</option>
                                            <option value="0">放行</option>
                                            <option value="1">拦截</option>
                                        </select>
                                    </div>
                                </div>
                            </div>
                            <div class="layui-col-md2">
                                <div class="layui-form-item">
                                    <label class="layui-form-label">选择站点</label>
                                    <div class="layui-input-block">
                                        <select name="siteId" lay-filter="filter-site" lay-search="">
                                            <option value="">全部站点</option>
                                        </select>
                                    </div>
                                </div>
                            </div>
                            <div class="layui-col-md2">
                                <button class="layui-btn layui-btn-sm" type="button" id="search-btn">
                                    <i class="layui-icon">&#xe615;</i> 搜索
                                </button>
                            </div>
                        </div>
                    </form>
                    <table lay-filter="firewallAccessLogTable" lay-data="{id: 'firewallAccessLogTable'}"></table>
                </div>
            </div>
        </div>
    </div>
</div>
<script data-th-inline="none" type="text/javascript">
    layui.use(['jquery', 'form', 'table', 'febs'], function () {
        var $ = layui.jquery,
            febs = layui.febs,
            form = layui.form,
            table = layui.table,
            $view = $('#firewall-access-log-list'),
            $reset = $view.find('#reset'),
            $refreshBtn = $view.find('#refresh-btn'),
            $searchBtn = $view.find('#search-btn'),
            tableIns;
        form.render();
        loadSiteFilter();
        initTable();
        function loadSiteFilter() {
            febs.get(ctx + 'admin/firewall/sites', null, function (res) {
                var sites = res.data || [];
                var $select = $view.find('select[name="siteId"]');
                sites.forEach(function (s) {
                    $select.append('<option value="' + s.id + '">' + s.siteName + ' (' + s.domain + ')</option>');
                });
                form.render('select');
            });
        }
        // 定时自动刷新(每30秒)
        var autoRefreshTimer = setInterval(function () {
            tableIns.reload();
        }, 30000);
        $refreshBtn.on('click', function () {
            tableIns.reload();
        });
        $reset.on('click', function () {
            $view.find('input[name="ip"]').val('');
            $view.find('input[name="countryCode"]').val('');
            $view.find('select[name="isBlocked"]').val('');
            $view.find('select[name="siteId"]').val('');
            form.render('select');
            tableIns.reload();
        });
        $searchBtn.on('click', function () {
            tableIns.reload();
        });
        form.on('select(filter-blocked)', function () {
            tableIns.reload();
        });
        form.on('select(filter-site)', function () {
            tableIns.reload();
        });
        function initTable() {
            tableIns = febs.table.init({
                elem: $view.find('table'),
                id: 'firewallAccessLogTable',
                url: ctx + 'admin/firewall/accessLogList',
                where: function () {
                    return {
                        ip: $view.find('input[name="ip"]').val() || undefined,
                        countryCode: $view.find('input[name="countryCode"]').val() || undefined,
                        isBlocked: $view.find('select[name="isBlocked"]').val() || undefined,
                        siteId: $view.find('select[name="siteId"]').val() || undefined
                    };
                },
                cols: [[
                    {field: 'id', title: 'ID', width: 70, align: 'center'},
                    {field: 'ip', title: 'IP地址', width: 160, align: 'center'},
                    {field: 'countryCode', title: '国家', width: 80, align: 'center',
                        templet: function (d) {
                            return d.countryCode
                                ? '<span class="layui-badge layui-bg-blue">' + d.countryCode + '</span>'
                                : '<span style="color:#999;">未知</span>';
                        }
                    },
                    {field: 'countryName', title: '国家名称', width: 120, align: 'center'},
                    {field: 'requestUri', title: '来源页面', minWidth: 180,
                        templet: function (d) {
                            return d.requestUri
                                ? '<span style="color:#5FB878;">' + d.requestUri + '</span>'
                                : '<span style="color:#999;">直接访问</span>';
                        }
                    },
                    {field: 'isBlocked', title: '状态', width: 90, align: 'center',
                        templet: function (d) {
                            return d.isBlocked === 1
                                ? '<span style="color:#FF5722;">● 拦截</span>'
                                : '<span style="color:#5FB878;">● 放行</span>';
                        }
                    },
                    {field: 'userAgent', title: '浏览器', minWidth: 200,
                        templet: function (d) {
                            return d.userAgent ? d.userAgent.substring(0, 60) + '...' : '-';
                        }
                    },
                    {field: 'createdTime', title: '访问时间', width: 170, align: 'center'}
                ]],
                page: true
            });
        }
    });
</script>