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"></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"></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"></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>