Administrator
5 hours ago f1cf8741bad27ff99e644ad9cfa5458c78d79501
feat(firewall): 添加防火墙管理功能

- 新增防火墙站点管理功能,支持站点的增删改查
- 实现国家白名单规则管理,支持按站点配置国家访问控制
- 集成Nginx配置自动生成与reload机制
- 添加防火墙相关数据库表结构定义
- 实现IP白名单管理功能
- 添加前端管理界面用于防火墙规则配置
- 配置数据库连接池idle超时参数
- 添加Nginx安全reload脚本防止配置错误
- 实现Redis队列管理Nginx reload任务
- 优化防火墙规则存储与查询逻辑
20 files added
2 files modified
1572 ■■■■■ changed files
etc/nginx/conf.d/firewall_country_1.conf 7 ●●●●● patch | view | raw | blame | history
sql/firewall.sql 66 ●●●●● patch | view | raw | blame | history
sql/nginx_reload.sh 23 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/controller/AdminFirewallController.java 159 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/dto/CountryVo.java 19 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/dto/SaveCountryRuleDto.java 23 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/entity/FirewallCountryRule.java 30 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/entity/FirewallIpWhite.java 35 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/entity/FirewallSite.java 33 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/mapper/FirewallCountryRuleMapper.java 26 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/mapper/FirewallIpWhiteMapper.java 15 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/mapper/FirewallSiteMapper.java 15 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/nginx/NginxConfigGenerator.java 93 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/service/IFirewallRuleService.java 68 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/service/impl/FirewallRuleServiceImpl.java 226 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/firewall/task/NginxReloadTask.java 90 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/mall/controller/ViewSystemController.java 18 ●●●●● patch | view | raw | blame | history
src/main/resources/application-prod.yml 1 ●●●● patch | view | raw | blame | history
src/main/resources/mapper/firewall/FirewallCountryRuleMapper.xml 13 ●●●●● patch | view | raw | blame | history
src/main/resources/templates/febs/views/modules/system/firewallCountryList.html 222 ●●●●● patch | view | raw | blame | history
src/main/resources/templates/febs/views/modules/system/firewallCountryRuleList.html 222 ●●●●● patch | view | raw | blame | history
src/main/resources/templates/febs/views/modules/system/firewallSiteList.html 168 ●●●●● patch | view | raw | blame | history
etc/nginx/conf.d/firewall_country_1.conf
New file
@@ -0,0 +1,7 @@
# Firewall Country Allow Map
# Site: 独立站 (polypepprotein.com)
# Generated at: Fri Jul 31 13:49:31 CST 2026
map $geoip_country_code $allow_country_1 {
    default 0;
}
sql/firewall.sql
New file
@@ -0,0 +1,66 @@
-- ============================================================
-- 防火墙站点表
-- ============================================================
DROP TABLE IF EXISTS firewall_site;
CREATE TABLE firewall_site (
    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_NAME     VARCHAR(100) NOT NULL                 COMMENT '站点名称',
    DOMAIN        VARCHAR(200) NOT NULL                 COMMENT '站点域名',
    NGINX_CONF    VARCHAR(255)                          COMMENT 'Nginx配置文件路径',
    STATUS        TINYINT      DEFAULT 1                COMMENT '状态:1-启用,0-禁用',
    REMARK        VARCHAR(255)                          COMMENT '备注',
    PRIMARY KEY (ID),
    UNIQUE KEY uk_domain (DOMAIN)
) COMMENT = '防火墙站点表';
-- ============================================================
-- 国家白名单规则表
-- ============================================================
DROP TABLE IF EXISTS firewall_country_rule;
CREATE TABLE firewall_country_rule (
    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       NOT NULL                 COMMENT '站点ID,关联firewall_site表',
    COUNTRY_CODE  VARCHAR(10)  NOT NULL                 COMMENT '国家编码(ISO 3166-1 alpha-2,如 JP/US/SG)',
    COUNTRY_NAME  VARCHAR(50)                           COMMENT '国家名称',
    ENABLED       TINYINT      DEFAULT 1                COMMENT '状态:1-启用,0-禁用',
    PRIMARY KEY (ID),
    UNIQUE KEY uk_site_country (SITE_ID, COUNTRY_CODE),
    INDEX idx_site_id (SITE_ID)
) COMMENT = '防火墙国家白名单规则表';
-- ============================================================
-- IP白名单表
-- ============================================================
DROP TABLE IF EXISTS firewall_ip_white;
CREATE TABLE firewall_ip_white (
    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       NOT NULL                 COMMENT '站点ID',
    IP            VARCHAR(64)  NOT NULL                 COMMENT 'IP地址(支持IPv4/IPv6)',
    REMARK        VARCHAR(255)                          COMMENT '备注',
    EXPIRE_TIME   DATETIME                              COMMENT '过期时间,NULL表示永久有效',
    ENABLED       TINYINT      DEFAULT 1                COMMENT '状态:1-启用,0-禁用',
    PRIMARY KEY (ID),
    UNIQUE KEY uk_site_ip (SITE_ID, IP),
    INDEX idx_ip (IP)
) COMMENT = '防火墙IP白名单表';
-- ----------------------------
-- 默认数据
-- ----------------------------
INSERT INTO firewall_site (SITE_NAME, DOMAIN, STATUS) VALUES
('默认站点', 'shop.xxx.com', 1);
sql/nginx_reload.sh
New file
@@ -0,0 +1,23 @@
# Nginx 安全 reload 脚本
# 路径: /usr/local/bin/nginx_reload.sh
# 权限: chmod +x /usr/local/bin/nginx_reload.sh
# sudo配置: springboot ALL=(ALL) NOPASSWD: /usr/local/bin/nginx_reload.sh
# 用途:
# - 防止直接执行 nginx 命令
# - 支持 sudo 授权最小权限原则
# - SpringBoot 通过 sudo /usr/local/bin/nginx_reload.sh 调用
#!/bin/bash
# 检查 nginx 配置文件语法
nginx -t
# 语法检查通过后 reload
if [ $? -eq 0 ]; then
    nginx -s reload
    exit $?
else
    echo "Nginx config check failed, aborting reload" >&2
    exit 1
fi
src/main/java/cc/mrbird/febs/firewall/controller/AdminFirewallController.java
New file
@@ -0,0 +1,159 @@
package cc.mrbird.febs.firewall.controller;
import cc.mrbird.febs.common.annotation.ControllerEndpoint;
import cc.mrbird.febs.common.controller.BaseController;
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.dto.SaveCountryRuleDto;
import cc.mrbird.febs.firewall.entity.FirewallCountryRule;
import cc.mrbird.febs.firewall.entity.FirewallSite;
import cc.mrbird.febs.firewall.mapper.FirewallSiteMapper;
import cc.mrbird.febs.firewall.service.IFirewallRuleService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.constraints.NotNull;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
 * 防火墙管理控制器
 * <p>
 * 提供站点、国家白名单的完整 CRUD + Nginx配置生成 + reload 等管理接口
 *
 * @author auto-generated
 * @date 2026-07-31
 */
@Slf4j
@Validated
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/admin/firewall")
public class AdminFirewallController extends BaseController {
    private final IFirewallRuleService firewallRuleService;
    private final FirewallSiteMapper firewallSiteMapper;
    // ==================== 站点数据表 CRUD ====================
    /**
     * 站点分页列表
     */
    @GetMapping("/siteList")
    public FebsResponse siteList(FirewallSite site, QueryRequest request) {
        Map<String, Object> data = getDataTable(firewallRuleService.siteList(site, request));
        return new FebsResponse().success().data(data);
    }
    /**
     * 保存/更新站点
     */
    @PostMapping("/site/save")
    @ControllerEndpoint(operation = "保存防火墙站点")
    public FebsResponse saveSite(@Validated FirewallSite site) {
        return firewallRuleService.saveSite(site);
    }
    /**
     * 删除站点
     */
    @GetMapping("/site/delete/{id}")
    @ControllerEndpoint(operation = "删除防火墙站点")
    public FebsResponse deleteSite(@PathVariable @NotNull Long id) {
        return firewallRuleService.deleteSite(id);
    }
    /**
     * 查询所有启用站点(下拉框用)
     */
    @GetMapping("/sites")
    public FebsResponse listSites() {
        List<FirewallSite> sites = firewallSiteMapper.selectList(
                new LambdaQueryWrapper<FirewallSite>()
                        .eq(FirewallSite::getStatus, 1)
        );
        List<Map<String, Object>> result = new java.util.ArrayList<>();
        for (FirewallSite site : sites) {
            Map<String, Object> item = new HashMap<>();
            item.put("id", site.getId());
            item.put("siteName", site.getSiteName());
            item.put("domain", site.getDomain());
            item.put("status", site.getStatus());
            result.add(item);
        }
        return new FebsResponse().success().data(result);
    }
    // ==================== 国家规则数据表 CRUD ====================
    /**
     * 国家规则分页列表
     */
    @GetMapping("/countryRuleList")
    public FebsResponse countryRuleList(FirewallCountryRule rule, QueryRequest request) {
        Map<String, Object> data = getDataTable(firewallRuleService.countryRuleList(rule, request));
        return new FebsResponse().success().data(data);
    }
    /**
     * 保存/更新单个国家规则(保存后自动生成Nginx配置)
     */
    @PostMapping("/countryRule/save")
    @ControllerEndpoint(operation = "保存防火墙国家规则")
    public FebsResponse saveCountryRuleSingle(@Validated FirewallCountryRule rule) {
        return firewallRuleService.saveCountryRuleSingle(rule);
    }
    /**
     * 删除单个国家规则(删除后自动生成Nginx配置)
     */
    @GetMapping("/countryRule/delete/{id}")
    @ControllerEndpoint(operation = "删除防火墙国家规则")
    public FebsResponse deleteCountryRule(@PathVariable @NotNull Long id) {
        return firewallRuleService.deleteCountryRule(id);
    }
    // ==================== 国家白名单(原有接口) ====================
    /**
     * 查询站点允许的国家列表
     */
    @GetMapping("/site/{siteId}/countries")
    public FebsResponse getCountries(@PathVariable @NotNull Long siteId) {
        List<CountryVo> countries = firewallRuleService.getCountries(siteId);
        return new FebsResponse().success().data(countries);
    }
    /**
     * 批量保存站点国家白名单规则(交互式checkbox页面用)
     */
    @PostMapping("/site/country/save")
    @ControllerEndpoint(operation = "批量保存防火墙国家白名单")
    public FebsResponse saveCountryRule(@RequestBody @Validated SaveCountryRuleDto dto) {
        firewallRuleService.saveCountryRule(dto.getSiteId(), dto.getCountries());
        return new FebsResponse().success().message("保存成功,Nginx 配置已更新");
    }
    // ==================== Nginx Reload ====================
    /**
     * 手动触发 Nginx 配置生成 & reload
     */
    @PostMapping("/site/{siteId}/reload")
    @ControllerEndpoint(operation = "手动reload Nginx配置")
    public FebsResponse triggerReload(@PathVariable @NotNull Long siteId) {
        firewallRuleService.triggerNginxReload(siteId);
        return new FebsResponse().success().message("Nginx reload 信号已发送");
    }
}
src/main/java/cc/mrbird/febs/firewall/dto/CountryVo.java
New file
@@ -0,0 +1,19 @@
package cc.mrbird.febs.firewall.dto;
import lombok.Data;
/**
 * 国家信息 VO
 *
 * @author auto-generated
 * @date 2026-07-31
 */
@Data
public class CountryVo {
    /** 国家编码 */
    private String code;
    /** 国家名称 */
    private String name;
}
src/main/java/cc/mrbird/febs/firewall/dto/SaveCountryRuleDto.java
New file
@@ -0,0 +1,23 @@
package cc.mrbird.febs.firewall.dto;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.util.List;
/**
 * 国家白名单保存请求 DTO
 *
 * @author auto-generated
 * @date 2026-07-31
 */
@Data
public class SaveCountryRuleDto {
    @NotNull(message = "站点ID不能为空")
    private Long siteId;
    @NotEmpty(message = "国家列表不能为空")
    private List<String> countries;
}
src/main/java/cc/mrbird/febs/firewall/entity/FirewallCountryRule.java
New file
@@ -0,0 +1,30 @@
package cc.mrbird.febs.firewall.entity;
import cc.mrbird.febs.common.entity.BaseEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
 * 防火墙国家白名单规则表
 *
 * @author auto-generated
 * @date 2026-07-31
 */
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("firewall_country_rule")
public class FirewallCountryRule extends BaseEntity {
    /** 站点ID */
    private Long siteId;
    /** 国家编码(ISO 3166-1 alpha-2,如 JP/US/SG) */
    private String countryCode;
    /** 国家名称 */
    private String countryName;
    /** 状态:1-启用,0-禁用 */
    private Integer enabled;
}
src/main/java/cc/mrbird/febs/firewall/entity/FirewallIpWhite.java
New file
@@ -0,0 +1,35 @@
package cc.mrbird.febs.firewall.entity;
import cc.mrbird.febs.common.entity.BaseEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.Date;
/**
 * 防火墙IP白名单表
 *
 * @author auto-generated
 * @date 2026-07-31
 */
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("firewall_ip_white")
public class FirewallIpWhite extends BaseEntity {
    /** 站点ID */
    private Long siteId;
    /** IP地址 */
    private String ip;
    /** 备注 */
    private String remark;
    /** 过期时间 */
    private Date expireTime;
    /** 状态:1-启用,0-禁用 */
    private Integer enabled;
}
src/main/java/cc/mrbird/febs/firewall/entity/FirewallSite.java
New file
@@ -0,0 +1,33 @@
package cc.mrbird.febs.firewall.entity;
import cc.mrbird.febs.common.entity.BaseEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
 * 防火墙站点表
 *
 * @author auto-generated
 * @date 2026-07-31
 */
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("firewall_site")
public class FirewallSite extends BaseEntity {
    /** 站点名称 */
    private String siteName;
    /** 站点域名 */
    private String domain;
    /** Nginx配置文件路径 */
    private String nginxConf;
    /** 状态:1-启用,0-禁用 */
    private Integer status;
    /** 备注 */
    private String remark;
}
src/main/java/cc/mrbird/febs/firewall/mapper/FirewallCountryRuleMapper.java
New file
@@ -0,0 +1,26 @@
package cc.mrbird.febs.firewall.mapper;
import cc.mrbird.febs.firewall.entity.FirewallCountryRule;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
 * 防火墙国家规则 Mapper
 *
 * @author auto-generated
 * @date 2026-07-31
 */
@Mapper
public interface FirewallCountryRuleMapper extends BaseMapper<FirewallCountryRule> {
    /**
     * 查询站点下所有启用的国家编码
     *
     * @param siteId 站点ID
     * @return 国家编码列表
     */
    List<String> selectEnableCountry(@Param("siteId") Long siteId);
}
src/main/java/cc/mrbird/febs/firewall/mapper/FirewallIpWhiteMapper.java
New file
@@ -0,0 +1,15 @@
package cc.mrbird.febs.firewall.mapper;
import cc.mrbird.febs.firewall.entity.FirewallIpWhite;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
 * 防火墙IP白名单 Mapper
 *
 * @author auto-generated
 * @date 2026-07-31
 */
@Mapper
public interface FirewallIpWhiteMapper extends BaseMapper<FirewallIpWhite> {
}
src/main/java/cc/mrbird/febs/firewall/mapper/FirewallSiteMapper.java
New file
@@ -0,0 +1,15 @@
package cc.mrbird.febs.firewall.mapper;
import cc.mrbird.febs.firewall.entity.FirewallSite;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
 * 防火墙站点 Mapper
 *
 * @author auto-generated
 * @date 2026-07-31
 */
@Mapper
public interface FirewallSiteMapper extends BaseMapper<FirewallSite> {
}
src/main/java/cc/mrbird/febs/firewall/nginx/NginxConfigGenerator.java
New file
@@ -0,0 +1,93 @@
package cc.mrbird.febs.firewall.nginx;
import cc.mrbird.febs.firewall.entity.FirewallSite;
import cc.mrbird.febs.firewall.mapper.FirewallCountryRuleMapper;
import cc.mrbird.febs.firewall.mapper.FirewallSiteMapper;
import cn.hutool.core.io.FileUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
 * Nginx 国家黑名单配置文件生成器
 * <p>
 * 默认放行所有国家,仅拦截配置中选中的国家(黑名单模式)。
 * <p>
 * 输出文件: /etc/nginx/conf.d/firewall_country_{siteId}.conf
 * <p>
 * 不直接执行 reload,仅负责文件生成。
 * reload 由 NginxReloadTask 消费 Redis 队列后执行,防止误操作。
 *
 * @author auto-generated
 * @date 2026-07-31
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class NginxConfigGenerator {
    private final FirewallCountryRuleMapper mapper;
    private final FirewallSiteMapper siteMapper;
    /** Nginx 配置目录 */
    private static final String NGINX_CONF_DIR = "/etc/nginx/conf.d/";
    /** 配置文件前缀 */
    private static final String CONF_PREFIX = "firewall_country_";
    /**
     * 生成 Nginx 国家黑名单 map 配置
     * <pre>
     * map $geoip_country_code $allow_country {
     *     default 1;
     *     JP 0;
     *     US 0;
     * }
     * </pre>
     * default 1 表示默认放行,列表中国家设为 0 表示拦截。
     * Nginx 中判断: if ($allow_country_1 = 0) { return 403; }
     *
     * @param siteId 站点ID
     */
    public void generate(Long siteId) {
        // 查询站点配置路径
        FirewallSite site = siteMapper.selectById(siteId);
        if (site == null) {
            log.warn("防火墙站点不存在: siteId={}", siteId);
            return;
        }
        List<String> countries = mapper.selectEnableCountry(siteId);
        StringBuilder sb = new StringBuilder();
        sb.append("# Firewall Country Block Map (Blacklist)\n");
        sb.append("# Site: ").append(site.getSiteName()).append(" (").append(site.getDomain()).append(")\n");
        sb.append("# Generated at: ").append(new java.util.Date()).append("\n\n");
        sb.append("map $geoip_country_code $allow_country_").append(siteId).append(" {\n");
        sb.append("    default 1;\n");
        for (String c : countries) {
            sb.append("    ").append(c.toUpperCase()).append(" 0;\n");
        }
        sb.append("}\n");
        // 写入文件
        String confFileName = site.getNginxConf();
        if (confFileName == null || confFileName.isEmpty()) {
            confFileName = NGINX_CONF_DIR + CONF_PREFIX + siteId + ".conf";
        }
        File confFile = new File(confFileName);
        try {
            FileUtil.writeString(sb.toString(), confFile, StandardCharsets.UTF_8);
            log.info("Nginx 黑名单配置已生成: {}, 拦截国家: {}", confFile.getAbsolutePath(), countries);
        } catch (Exception e) {
            log.error("Nginx 配置文件写入失败: path={}", confFile.getAbsolutePath(), e);
            throw new RuntimeException("Nginx 配置文件写入失败: " + e.getMessage(), e);
        }
    }
}
src/main/java/cc/mrbird/febs/firewall/service/IFirewallRuleService.java
New file
@@ -0,0 +1,68 @@
package cc.mrbird.febs.firewall.service;
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.FirewallCountryRule;
import cc.mrbird.febs.firewall.entity.FirewallSite;
import com.baomidou.mybatisplus.core.metadata.IPage;
import java.util.List;
/**
 * 防火墙规则 Service 接口
 *
 * @author auto-generated
 * @date 2026-07-31
 */
public interface IFirewallRuleService {
    // ==================== 站点管理 ====================
    /**
     * 站点分页列表
     */
    IPage<FirewallSite> siteList(FirewallSite site, QueryRequest request);
    /**
     * 站点保存/更新
     */
    FebsResponse saveSite(FirewallSite site);
    /**
     * 站点删除
     */
    FebsResponse deleteSite(Long id);
    // ==================== 国家规则管理 ====================
    /**
     * 国家规则分页列表
     */
    IPage<FirewallCountryRule> countryRuleList(FirewallCountryRule rule, QueryRequest request);
    /**
     * 单个国家规则保存/更新(保存后自动生成Nginx配置)
     */
    FebsResponse saveCountryRuleSingle(FirewallCountryRule rule);
    /**
     * 单个国家规则删除(删除后自动生成Nginx配置)
     */
    FebsResponse deleteCountryRule(Long id);
    /**
     * 查询站点允许的国家列表
     */
    List<CountryVo> getCountries(Long siteId);
    /**
     * 批量保存站点国家白名单规则(删旧→插新→生成配置→reload信号)
     */
    void saveCountryRule(Long siteId, List<String> countries);
    /**
     * 手动触发 Nginx 配置生成 & reload
     */
    void triggerNginxReload(Long siteId);
}
src/main/java/cc/mrbird/febs/firewall/service/impl/FirewallRuleServiceImpl.java
New file
@@ -0,0 +1,226 @@
package cc.mrbird.febs.firewall.service.impl;
import cc.mrbird.febs.common.entity.FebsResponse;
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.FirewallCountryRule;
import cc.mrbird.febs.firewall.entity.FirewallSite;
import cc.mrbird.febs.firewall.mapper.FirewallCountryRuleMapper;
import cc.mrbird.febs.firewall.mapper.FirewallSiteMapper;
import cc.mrbird.febs.firewall.nginx.NginxConfigGenerator;
import cc.mrbird.febs.firewall.service.IFirewallRuleService;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
/**
 * 防火墙规则 Service 实现
 *
 * @author auto-generated
 * @date 2026-07-31
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class FirewallRuleServiceImpl implements IFirewallRuleService {
    private final FirewallCountryRuleMapper countryRuleMapper;
    private final FirewallSiteMapper siteMapper;
    private final NginxConfigGenerator generator;
    private final RedisUtils redisUtils;
    /** Redis 中 Nginx reload 任务队列 key */
    private static final String NGINX_RELOAD_QUEUE_KEY = "firewall:nginx_reload_queue";
    // ==================== 站点管理 ====================
    @Override
    public IPage<FirewallSite> siteList(FirewallSite site, QueryRequest request) {
        Page<FirewallSite> page = new Page<>(request.getPageNum(), request.getPageSize());
        LambdaQueryWrapper<FirewallSite> wrapper = new LambdaQueryWrapper<FirewallSite>()
                .like(StrUtil.isNotBlank(site.getSiteName()), FirewallSite::getSiteName, site.getSiteName())
                .like(StrUtil.isNotBlank(site.getDomain()), FirewallSite::getDomain, site.getDomain())
                .eq(site.getStatus() != null, FirewallSite::getStatus, site.getStatus())
                .orderByDesc(FirewallSite::getCreatedTime);
        return siteMapper.selectPage(page, wrapper);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public FebsResponse saveSite(FirewallSite site) {
        if (site.getId() != null) {
            // 更新
            FirewallSite exist = siteMapper.selectById(site.getId());
            if (exist == null) {
                return new FebsResponse().fail().message("站点不存在");
            }
            siteMapper.updateById(site);
            log.info("防火墙站点已更新: id={}, name={}", site.getId(), site.getSiteName());
        } else {
            // 新增
            siteMapper.insert(site);
            // 回填 nginxConf(如果没填则用默认值)
            if (StrUtil.isBlank(site.getNginxConf())) {
                site.setNginxConf("/etc/nginx/conf.d/firewall_country_" + site.getId() + ".conf");
                siteMapper.updateById(site);
            }
            log.info("防火墙站点已新增: id={}, name={}", site.getId(), site.getSiteName());
        }
        return new FebsResponse().success().message("保存成功");
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public FebsResponse deleteSite(Long id) {
        FirewallSite site = siteMapper.selectById(id);
        if (site == null) {
            return new FebsResponse().fail().message("站点不存在");
        }
        // 删除站点关联的国家规则
        countryRuleMapper.delete(new LambdaQueryWrapper<FirewallCountryRule>()
                .eq(FirewallCountryRule::getSiteId, id));
        // 删除Nginx配置文件
        if (StrUtil.isNotBlank(site.getNginxConf())) {
            cn.hutool.core.io.FileUtil.del(site.getNginxConf());
        }
        siteMapper.deleteById(id);
        log.info("防火墙站点已删除: id={}, name={}", id, site.getSiteName());
        return new FebsResponse().success().message("删除成功");
    }
    // ==================== 国家规则管理 ====================
    @Override
    public IPage<FirewallCountryRule> countryRuleList(FirewallCountryRule rule, QueryRequest request) {
        Page<FirewallCountryRule> page = new Page<>(request.getPageNum(), request.getPageSize());
        LambdaQueryWrapper<FirewallCountryRule> wrapper = new LambdaQueryWrapper<FirewallCountryRule>()
                .eq(rule.getSiteId() != null, FirewallCountryRule::getSiteId, rule.getSiteId())
                .eq(rule.getEnabled() != null, FirewallCountryRule::getEnabled, rule.getEnabled())
                .like(StrUtil.isNotBlank(rule.getCountryName()), FirewallCountryRule::getCountryName, rule.getCountryName())
                .orderByDesc(FirewallCountryRule::getCreatedTime);
        return countryRuleMapper.selectPage(page, wrapper);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public FebsResponse saveCountryRuleSingle(FirewallCountryRule rule) {
        if (rule.getSiteId() == null) {
            return new FebsResponse().fail().message("请选择站点");
        }
        if (StrUtil.isBlank(rule.getCountryCode())) {
            return new FebsResponse().fail().message("国家编码不能为空");
        }
        rule.setCountryCode(rule.getCountryCode().toUpperCase());
        if (rule.getId() != null) {
            countryRuleMapper.updateById(rule);
            log.info("防火墙国家规则已更新: id={}, siteId={}, country={}", rule.getId(), rule.getSiteId(), rule.getCountryCode());
        } else {
            // 检查同站点下是否已存在相同国家编码
            Integer count = countryRuleMapper.selectCount(new LambdaQueryWrapper<FirewallCountryRule>()
                    .eq(FirewallCountryRule::getSiteId, rule.getSiteId())
                    .eq(FirewallCountryRule::getCountryCode, rule.getCountryCode()));
            if (count > 0) {
                return new FebsResponse().fail().message("该站点下已存在相同国家编码: " + rule.getCountryCode());
            }
            countryRuleMapper.insert(rule);
            log.info("防火墙国家规则已新增: siteId={}, country={}", rule.getSiteId(), rule.getCountryCode());
        }
        // 保存后自动生成Nginx配置并发送reload信号
        regenerateNginxConfig(rule.getSiteId());
        return new FebsResponse().success().message("保存成功,Nginx 配置已更新");
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public FebsResponse deleteCountryRule(Long id) {
        FirewallCountryRule rule = countryRuleMapper.selectById(id);
        if (rule == null) {
            return new FebsResponse().fail().message("规则不存在");
        }
        Long siteId = rule.getSiteId();
        countryRuleMapper.deleteById(id);
        log.info("防火墙国家规则已删除: id={}, siteId={}, country={}", id, siteId, rule.getCountryCode());
        // 删除后自动生成Nginx配置并发送reload信号
        regenerateNginxConfig(siteId);
        return new FebsResponse().success().message("删除成功,Nginx 配置已更新");
    }
    // ==================== 内部方法 ====================
    /** 重新生成Nginx配置并发送reload信号 */
    private void regenerateNginxConfig(Long siteId) {
        try {
            generator.generate(siteId);
            redisUtils.lSet(NGINX_RELOAD_QUEUE_KEY, String.valueOf(siteId));
            log.info("Nginx reload 信号已入队: siteId={}", siteId);
        } catch (Exception e) {
            log.error("Nginx 配置生成失败: siteId={}", siteId, e);
        }
    }
    // ==================== 原有方法 ====================
    @Override
    public List<CountryVo> getCountries(Long siteId) {
        List<FirewallCountryRule> rules = countryRuleMapper.selectList(
                new LambdaQueryWrapper<FirewallCountryRule>()
                        .eq(FirewallCountryRule::getSiteId, siteId)
                        .eq(FirewallCountryRule::getEnabled, 1)
        );
        List<CountryVo> result = new ArrayList<>();
        for (FirewallCountryRule rule : rules) {
            CountryVo vo = new CountryVo();
            vo.setCode(rule.getCountryCode());
            vo.setName(rule.getCountryName());
            result.add(vo);
        }
        return result;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void saveCountryRule(Long siteId, List<String> countries) {
        // 1. 删除旧规则
        countryRuleMapper.delete(new LambdaQueryWrapper<FirewallCountryRule>()
                .eq(FirewallCountryRule::getSiteId, siteId));
        // 2. 批量插入新规则
        for (String country : countries) {
            FirewallCountryRule rule = new FirewallCountryRule();
            rule.setSiteId(siteId);
            rule.setCountryCode(country.toUpperCase());
            rule.setCountryName(country);
            rule.setEnabled(1);
            countryRuleMapper.insert(rule);
        }
        log.info("防火墙国家规则已更新: siteId={}, countries={}", siteId, countries);
        // 3. 生成 Nginx 配置文件
        generator.generate(siteId);
        // 4. 发送 reload 信号
        redisUtils.lSet(NGINX_RELOAD_QUEUE_KEY, String.valueOf(siteId));
        log.info("Nginx reload 信号已入队: siteId={}", siteId);
    }
    @Override
    public void triggerNginxReload(Long siteId) {
        generator.generate(siteId);
        redisUtils.lSet(NGINX_RELOAD_QUEUE_KEY, String.valueOf(siteId));
        log.info("手动触发 Nginx reload: siteId={}", siteId);
    }
}
src/main/java/cc/mrbird/febs/firewall/task/NginxReloadTask.java
New file
@@ -0,0 +1,90 @@
package cc.mrbird.febs.firewall.task;
import cc.mrbird.febs.common.utils.RedisUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
 * Nginx Reload 后台任务
 * <p>
 * 每 10 秒轮询 Redis 队列,有任务时执行 nginx reload。
 * <p>
 * 设计目的:
 * 1. 避免 SpringBoot 直接调用系统命令
 * 2. 支持批量合并 reload(多次修改只 reload 一次)
 * 3. 防止误操作频繁 reload
 *
 * @author auto-generated
 * @date 2026-07-31
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class NginxReloadTask {
    private final RedisUtils redisUtils;
    /** Redis reload 任务队列 key */
    private static final String NGINX_RELOAD_QUEUE_KEY = "firewall:nginx_reload_queue";
    /** reload 脚本路径 */
    private static final String RELOAD_SCRIPT = "/usr/local/bin/nginx_reload.sh";
    /** Nginx 命令(备用) */
    private static final String NGINX_CMD = "nginx -s reload";
    /**
     * 每 10 秒检查并消费 reload 队列
     */
    @Scheduled(fixedDelay = 10_000)
    public void checkAndReload() {
        // 批量消费队列中所有待 reload 任务
        int count = 0;
        while (true) {
            Object item = redisUtils.lGetIndex(NGINX_RELOAD_QUEUE_KEY, 0);
            if (item == null) {
                break;
            }
            count++;
            redisUtils.lRemove(NGINX_RELOAD_QUEUE_KEY, 1, item);
            log.info("消费 Nginx reload 任务: siteId={}", item);
        }
        if (count > 0) {
            log.info("开始执行 Nginx reload (合并 {} 个任务)", count);
            executeReload();
        }
    }
    /**
     * 执行 Nginx reload
     * 优先使用 sudo 脚本,失败时回退到直接命令
     */
    private void executeReload() {
        // 方案1:通过 sudo 脚本执行(推荐,生产环境)
        try {
            Process process = Runtime.getRuntime().exec(new String[]{"sudo", RELOAD_SCRIPT});
            process.waitFor();
            if (process.exitValue() == 0) {
                log.info("Nginx reload 成功 (via sudo script)");
                return;
            }
            log.warn("Nginx reload 脚本执行返回非0: exitCode={}", process.exitValue());
        } catch (Exception e) {
            log.warn("Nginx reload 脚本执行失败,尝试直接命令: {}", e.getMessage());
        }
        // 方案2:直接执行 nginx reload(开发/测试环境)
        try {
            Process process = Runtime.getRuntime().exec(NGINX_CMD);
            process.waitFor();
            if (process.exitValue() == 0) {
                log.info("Nginx reload 成功 (via direct command)");
            } else {
                log.error("Nginx reload 失败: exitCode={}", process.exitValue());
            }
        } catch (Exception e) {
            log.error("Nginx reload 执行异常", e);
        }
    }
}
src/main/java/cc/mrbird/febs/mall/controller/ViewSystemController.java
@@ -163,6 +163,24 @@
        return FebsUtil.view("modules/system/bsPayConfig");
    }
    @GetMapping("firewallCountryList")
    @RequiresPermissions("firewallCountry:view")
    public String firewallCountryList() {
        return FebsUtil.view("modules/system/firewallCountryList");
    }
    @GetMapping("firewallSiteList")
    @RequiresPermissions("firewallSite:view")
    public String firewallSiteList() {
        return FebsUtil.view("modules/system/firewallSiteList");
    }
    @GetMapping("firewallCountryRuleList")
    @RequiresPermissions("firewallCountryRule:view")
    public String firewallCountryRuleList() {
        return FebsUtil.view("modules/system/firewallCountryRuleList");
    }
    @GetMapping("tokenviewConfig")
    @RequiresPermissions("tokenviewConfig:update")
    public String tokenviewConfig(Model model) {
src/main/resources/application-prod.yml
@@ -8,6 +8,7 @@
        max-lifetime: 1800000
        max-pool-size: 15
        min-idle: 5
        idle-timeout: 300000
        connection-test-query: select 1
        pool-name: FebsHikariCP
      # 配置默认数据源
src/main/resources/mapper/firewall/FirewallCountryRuleMapper.xml
New file
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cc.mrbird.febs.firewall.mapper.FirewallCountryRuleMapper">
    <select id="selectEnableCountry" resultType="string">
        SELECT COUNTRY_CODE
        FROM firewall_country_rule
        WHERE SITE_ID = #{siteId}
          AND ENABLED = 1
    </select>
</mapper>
src/main/resources/templates/febs/views/modules/system/firewallCountryList.html
New file
@@ -0,0 +1,222 @@
<div class="layui-fluid layui-anim febs-anim" id="firewall-country-list" lay-title="防火墙国家白名单">
    <div class="layui-row febs-container">
        <div class="layui-col-md12">
            <div class="layui-card">
                <div class="layui-card-header">站点国家白名单管理</div>
                <div class="layui-card-body">
                    <!-- 站点选择 -->
                    <div class="layui-form-item">
                        <label class="layui-form-label">选择站点:</label>
                        <div class="layui-input-inline" style="width: 250px;">
                            <select id="site-select" lay-filter="site-select" lay-search="">
                                <option value="">请选择站点</option>
                            </select>
                        </div>
                        <button class="layui-btn layui-btn-normal" type="button" id="reload-btn" style="margin-left: 10px;">
                            <i class="layui-icon">&#xe669;</i> 手动reload
                        </button>
                    </div>
                    <hr class="layui-bg-gray">
                    <!-- 国家选择区域 -->
                    <div id="country-area" style="display:none;">
                        <div class="layui-form-item">
                            <label class="layui-form-label">当前允许国家:</label>
                            <div class="layui-input-block" id="current-countries" style="padding-top: 9px;">
                                <span style="color:#999;">暂无数据</span>
                            </div>
                        </div>
                        <div class="layui-form-item">
                            <label class="layui-form-label">选择国家:</label>
                            <div class="layui-input-block" id="country-checkboxes" style="padding-top: 5px;">
                            </div>
                        </div>
                        <div class="layui-form-item">
                            <div class="layui-input-block">
                                <button class="layui-btn" lay-submit="" lay-filter="save-country-rule" id="save-btn">
                                    <i class="layui-icon">&#xe605;</i> 保存规则
                                </button>
                                <span class="layui-word-aux" style="margin-left: 15px;">保存后自动生成Nginx配置并reload</span>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>
<style>
    .layui-form-label { width: 130px; }
    .layui-form-item .layui-input-block { margin-left: 160px; }
    .country-tag {
        display: inline-block;
        padding: 2px 12px;
        margin: 2px 4px;
        background: #5FB878;
        color: #fff;
        border-radius: 2px;
        font-size: 13px;
    }
    .country-checkbox-item {
        display: inline-block;
        width: 140px;
        margin: 4px 0;
    }
</style>
<script data-th-inline="none" type="text/javascript">
    layui.use(['jquery', 'form', 'febs', 'layer'], function () {
        var $ = layui.jquery,
            febs = layui.febs,
            form = layui.form,
            layer = layui.layer,
            $view = $('#firewall-country-list'),
            currentSiteId = null;
        // 常用国家列表
        var allCountries = [
            {code: 'BR', name: '巴西'},
            {code: 'US', name: '美国'},
            {code: 'JP', name: '日本'},
            {code: 'KR', name: '韩国'},
            {code: 'SG', name: '新加坡'},
            {code: 'GB', name: '英国'},
            {code: 'DE', name: '德国'},
            {code: 'FR', name: '法国'},
            {code: 'AU', name: '澳大利亚'},
            {code: 'CA', name: '加拿大'},
            {code: 'IT', name: '意大利'},
            {code: 'ES', name: '西班牙'},
            {code: 'NL', name: '荷兰'},
            {code: 'SE', name: '瑞典'},
            {code: 'CH', name: '瑞士'},
            {code: 'MX', name: '墨西哥'},
            {code: 'AR', name: '阿根廷'},
            {code: 'CL', name: '智利'},
            {code: 'CO', name: '哥伦比亚'},
            {code: 'PE', name: '秘鲁'},
            {code: 'TH', name: '泰国'},
            {code: 'VN', name: '越南'},
            {code: 'MY', name: '马来西亚'},
            {code: 'ID', name: '印度尼西亚'},
            {code: 'PH', name: '菲律宾'},
            {code: 'IN', name: '印度'},
            {code: 'AE', name: '阿联酋'},
            {code: 'SA', name: '沙特阿拉伯'},
            {code: 'ZA', name: '南非'},
            {code: 'RU', name: '俄罗斯'},
            {code: 'CN', name: '中国'},
            {code: 'HK', name: '中国香港'},
            {code: 'TW', name: '中国台湾'},
            {code: 'MO', name: '中国澳门'}
        ];
        // 加载站点列表
        function loadSites() {
            febs.get(ctx + 'admin/firewall/sites', null, function (res) {
                var sites = res.data || [];
                var $select = $('#site-select');
                $select.empty();
                $select.append('<option value="">请选择站点</option>');
                sites.forEach(function (s) {
                    $select.append('<option value="' + s.id + '">' + s.siteName + ' (' + s.domain + ')</option>');
                });
                form.render('select');
            });
        }
        // 加载当前站点的国家白名单
        function loadCountries(siteId) {
            currentSiteId = siteId;
            febs.get(ctx + 'admin/firewall/site/' + siteId + '/countries', null, function (res) {
                var countries = res.data || [];
                var enabledCodes = {};
                countries.forEach(function (c) {
                    enabledCodes[c.code] = true;
                });
                // 先显示区域,再渲染表单元素(layui 在隐藏 DOM 上渲染可能失败)
                $('#country-area').show();
                // 渲染当前允许国家标签
                var $current = $('#current-countries');
                if (countries.length === 0) {
                    $current.html('<span style="color:#999;">暂无允许的国家</span>');
                } else {
                    var html = '';
                    countries.forEach(function (c) {
                        html += '<span class="country-tag">' + c.name + ' (' + c.code + ')</span>';
                    });
                    $current.html(html);
                }
                // 渲染复选框(在可见区域中渲染)
                var $checkboxes = $('#country-checkboxes');
                var checkboxHtml = '';
                allCountries.forEach(function (c) {
                    var checked = enabledCodes[c.code] ? ' checked' : '';
                    checkboxHtml += '<span class="country-checkbox-item">'
                        + '<input type="checkbox" name="country" value="' + c.code + '" title="' + c.name + ' (' + c.code + ')" lay-skin="primary"' + checked + '>'
                        + '</span>';
                });
                $checkboxes.html(checkboxHtml);
                form.render('checkbox');
                // 完整渲染一次表单,确保所有 layui 组件(select/checkbox/button)正确初始化
                form.render(null, 'firewall-country-list');
            });
        }
        // 站点切换
        form.on('select(site-select)', function (data) {
            if (data.value) {
                loadCountries(data.value);
            } else {
                $('#country-area').hide();
            }
        });
        // 保存规则
        form.on('submit(save-country-rule)', function () {
            if (!currentSiteId) {
                layer.msg('请先选择站点', {icon: 2});
                return false;
            }
            var countries = [];
            $view.find('input[name="country"]:checked').each(function () {
                countries.push($(this).val());
            });
            febs.post(ctx + 'admin/firewall/site/country/save', {
                siteId: currentSiteId,
                countries: countries
            }, function (res) {
                layer.msg('保存成功,Nginx 配置已更新', {icon: 1});
                loadCountries(currentSiteId);
            });
            return false;
        });
        // 手动reload
        $('#reload-btn').on('click', function () {
            if (!currentSiteId) {
                layer.msg('请先选择站点', {icon: 2});
                return;
            }
            layer.confirm('确定要手动触发 Nginx reload 吗?', function (index) {
                febs.post(ctx + 'admin/firewall/site/' + currentSiteId + '/reload', {}, function (res) {
                    layer.close(index);
                    layer.msg('Nginx reload 信号已发送', {icon: 1});
                });
            });
        });
        // 初始化
        loadSites();
    });
</script>
src/main/resources/templates/febs/views/modules/system/firewallCountryRuleList.html
New file
@@ -0,0 +1,222 @@
<div class="layui-fluid layui-anim febs-anim" id="firewall-country-rule-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-rule-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="add-btn">
                                        <i class="layui-icon">&#xe654;</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-md3">
                                <div class="layui-form-item">
                                    <label class="layui-form-label">选择站点</label>
                                    <div class="layui-input-block">
                                        <select id="filter-site-id" name="siteId" lay-filter="filter-site" lay-search="">
                                            <option value="">全部站点</option>
                                        </select>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </form>
                    <table lay-filter="firewallRuleTable" lay-data="{id: 'firewallRuleTable'}"></table>
                </div>
            </div>
        </div>
    </div>
</div>
<!-- 表格操作栏 -->
<script type="text/html" id="firewall-rule-option">
    <a lay-event="edit"><i class="layui-icon febs-edit-area febs-blue">&#xe7a5;</i></a>
    <a lay-event="del"><i class="layui-icon febs-edit-area febs-red">&#xe640;</i></a>
</script>
<style>
    .layui-form-label { width: 110px; }
    .layui-form-item .layui-input-block { margin-left: 140px; }
</style>
<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,
            layer = layui.layer,
            $view = $('#firewall-country-rule-list'),
            $reset = $view.find('#reset'),
            $addBtn = $view.find('#add-btn'),
            tableIns;
        form.render();
        loadSiteFilter();
        initTable();
        // 加载站点下拉选项
        function loadSiteFilter() {
            febs.get(ctx + 'admin/firewall/sites', null, function (res) {
                var sites = res.data || [];
                var $select = $('#filter-site-id');
                sites.forEach(function (s) {
                    $select.append('<option value="' + s.id + '">' + s.siteName + ' (' + s.domain + ')</option>');
                });
                form.render('select');
            });
        }
        // 站点筛选切换
        form.on('select(filter-site)', function () {
            tableIns.reload();
        });
        // 表格操作
        table.on('tool(firewallRuleTable)', function (obj) {
            var data = obj.data,
                layEvent = obj.event;
            if (layEvent === 'edit') {
                openEditModal(data);
            } else if (layEvent === 'del') {
                layer.confirm('确定删除 <b>' + data.countryName + ' (' + data.countryCode + ')</b> 的规则吗?<br>删除后会自动更新Nginx配置!', function (index) {
                    febs.get(ctx + 'admin/firewall/countryRule/delete/' + data.id, null, function () {
                        layer.close(index);
                        febs.alert.success('删除成功,Nginx配置已更新');
                        tableIns.reload();
                    });
                });
            }
        });
        // 新增按钮
        $addBtn.on('click', function () {
            openEditModal(null);
        });
        // 刷新按钮
        $reset.on('click', function () {
            tableIns.reload();
        });
        // 初始化表格
        function initTable() {
            tableIns = febs.table.init({
                elem: $view.find('table'),
                id: 'firewallRuleTable',
                url: ctx + 'admin/firewall/countryRuleList',
                where: function () {
                    var siteId = $('#filter-site-id').val();
                    return siteId ? { siteId: siteId } : {};
                },
                cols: [[
                    {field: 'id', title: 'ID', width: 60, align: 'center'},
                    {field: 'siteId', title: '站点ID', width: 80, align: 'center'},
                    {field: 'countryCode', title: '国家编码', width: 110, align: 'center'},
                    {field: 'countryName', title: '国家名称', width: 130, align: 'center'},
                    {field: 'enabled', title: '状态', width: 90, align: 'center',
                        templet: function (d) {
                            return d.enabled === 1
                                ? '<span style="color:#5FB878;">● 启用</span>'
                                : '<span style="color:#999;">● 禁用</span>';
                        }
                    },
                    {title: '操作', templet: '#firewall-rule-option', width: 120, align: 'center'},
                    {field: 'createdTime', title: '创建时间', width: 170, align: 'center'},
                ]]
            });
        }
        // 打开编辑弹窗
        function openEditModal(data) {
            var title = data ? '编辑国家规则' : '新增国家规则';
            var siteIdVal = data ? 'value="' + data.siteId + '"' : '';
            var html = '<div style="padding:20px 25px 0 0;">' +
                '<form class="layui-form" lay-filter="firewall-rule-form">' +
                (data ? '<div class="layui-form-item febs-hide"><label class="layui-form-label">ID</label><div class="layui-input-block"><input type="text" name="id" value="' + data.id + '"></div></div>' : '') +
                '  <div class="layui-form-item">' +
                '    <label class="layui-form-label febs-form-item-require">所属站点</label>' +
                '    <div class="layui-input-block">' +
                '      <select name="siteId" lay-verify="required" lay-search="" data-value="' + (data ? data.siteId : '') + '">' +
                '        <option value="">请选择站点</option>' +
                '      </select>' +
                '    </div>' +
                '  </div>' +
                '  <div class="layui-form-item">' +
                '    <label class="layui-form-label febs-form-item-require">国家编码</label>' +
                '    <div class="layui-input-block">' +
                '      <input type="text" name="countryCode" class="layui-input" ' + (data ? 'value="' + data.countryCode + '"' : 'placeholder="ISO国家编码,如CN/US/JP"') + ' lay-verify="required">' +
                '      <div class="layui-form-mid layui-word-aux">ISO 3166-1 alpha-2编码,如BR/US/JP</div>' +
                '    </div>' +
                '  </div>' +
                '  <div class="layui-form-item">' +
                '    <label class="layui-form-label febs-form-item-require">国家名称</label>' +
                '    <div class="layui-input-block">' +
                '      <input type="text" name="countryName" class="layui-input" ' + (data ? 'value="' + (data.countryName || '') + '"' : 'placeholder="中文名或英文名"') + ' lay-verify="required">' +
                '    </div>' +
                '  </div>' +
                '  <div class="layui-form-item">' +
                '    <label class="layui-form-label">状态</label>' +
                '    <div class="layui-input-block">' +
                '      <input type="radio" name="enabled" value="1" title="启用" ' + (!data || data.enabled === 1 ? 'checked' : '') + '>' +
                '      <input type="radio" name="enabled" value="0" title="禁用" ' + (data && data.enabled === 0 ? 'checked' : '') + '>' +
                '    </div>' +
                '  </div>' +
                '</form></div>';
            var layerIndex = layer.open({
                type: 1,
                title: title,
                area: ['520px', '400px'],
                content: html,
                success: function (layero) {
                    // 加载弹窗中的站点下拉
                    var $popupSelect = $(layero).find('select[name="siteId"]');
                    febs.get(ctx + 'admin/firewall/sites', null, function (res) {
                        var sites = res.data || [];
                        var preselected = data ? data.siteId : '';
                        sites.forEach(function (s) {
                            var sel = s.id == preselected ? ' selected' : '';
                            $popupSelect.append('<option value="' + s.id + '"' + sel + '>' + s.siteName + ' (' + s.domain + ')</option>');
                        });
                        form.render('select');
                    });
                    form.render('radio', 'firewall-rule-form');
                },
                btn: ['保存', '取消'],
                yes: function (index, layero) {
                    var formObj = $(layero).find('form');
                    var formData = form.val("firewall-rule-form");
                    if (!formData.siteId) {
                        layer.msg('请选择站点', {icon: 2});
                        return;
                    }
                    if (!formData.countryCode) {
                        layer.msg('请输入国家编码', {icon: 2});
                        return;
                    }
                    if (!formData.countryName) {
                        layer.msg('请输入国家名称', {icon: 2});
                        return;
                    }
                    febs.post(ctx + 'admin/firewall/countryRule/save', formData, function (res) {
                        layer.close(index);
                        febs.alert.success('保存成功,Nginx配置已自动更新');
                        tableIns.reload();
                    });
                },
                btn2: function () {
                    layer.closeAll();
                }
            });
        }
    });
</script>
src/main/resources/templates/febs/views/modules/system/firewallSiteList.html
New file
@@ -0,0 +1,168 @@
<div class="layui-fluid layui-anim febs-anim" id="firewall-site-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-site-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="add-btn">
                                        <i class="layui-icon">&#xe654;</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>
                    </form>
                    <table lay-filter="firewallSiteTable" lay-data="{id: 'firewallSiteTable'}"></table>
                </div>
            </div>
        </div>
    </div>
</div>
<!-- 表格操作栏 -->
<script type="text/html" id="firewall-site-option">
    <a lay-event="edit"><i class="layui-icon febs-edit-area febs-blue">&#xe7a5;</i></a>
    <a lay-event="del"><i class="layui-icon febs-edit-area febs-red">&#xe640;</i></a>
</script>
<style>
    .layui-form-label { width: 120px; }
    .layui-form-item .layui-input-block { margin-left: 150px; }
</style>
<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,
            layer = layui.layer,
            $view = $('#firewall-site-list'),
            $reset = $view.find('#reset'),
            $addBtn = $view.find('#add-btn'),
            tableIns;
        form.render();
        initTable();
        // 表格操作
        table.on('tool(firewallSiteTable)', function (obj) {
            var data = obj.data,
                layEvent = obj.event;
            if (layEvent === 'edit') {
                openEditModal(data);
            } else if (layEvent === 'del') {
                layer.confirm('确定删除站点 <b>' + data.siteName + '</b> 吗?该站点关联的国家规则也将一并删除!', function (index) {
                    febs.get(ctx + 'admin/firewall/site/delete/' + data.id, null, function () {
                        layer.close(index);
                        febs.alert.success('删除成功');
                        tableIns.reload();
                    });
                });
            }
        });
        // 新增按钮
        $addBtn.on('click', function () {
            openEditModal(null);
        });
        // 刷新按钮
        $reset.on('click', function () {
            tableIns.reload();
        });
        // 初始化表格
        function initTable() {
            tableIns = febs.table.init({
                elem: $view.find('table'),
                id: 'firewallSiteTable',
                url: ctx + 'admin/firewall/siteList',
                cols: [[
                    {field: 'id', title: 'ID', width: 60, align: 'center'},
                    {field: 'siteName', title: '站点名称', width: 180, align: 'center'},
                    {field: 'domain', title: '域名', width: 220, align: 'center'},
                    {field: 'nginxConf', title: 'Nginx配置路径', minWidth: 280, align: 'center'},
                    {field: 'status', title: '状态', width: 90, align: 'center',
                        templet: function (d) {
                            return d.status === 1
                                ? '<span style="color:#5FB878;">● 启用</span>'
                                : '<span style="color:#999;">● 禁用</span>';
                        }
                    },
                    {title: '操作', templet: '#firewall-site-option', width: 120, align: 'center'},
                    {field: 'remark', title: '备注', minWidth: 120, align: 'center'},
                ]]
            });
        }
        // 打开编辑弹窗
        function openEditModal(data) {
            var title = data ? '编辑站点' : '新增站点';
            var html = '<div style="padding:20px 25px 0 0;">' +
                '<form class="layui-form" lay-filter="firewall-site-form">' +
                (data ? '<div class="layui-form-item febs-hide"><label class="layui-form-label">ID</label><div class="layui-input-block"><input type="text" name="id" value="' + data.id + '"></div></div>' : '') +
                '  <div class="layui-form-item">' +
                '    <label class="layui-form-label febs-form-item-require">站点名称</label>' +
                '    <div class="layui-input-block">' +
                '      <input type="text" name="siteName" class="layui-input" ' + (data ? 'value="' + (data.siteName || '') + '"' : 'placeholder="如:polypepprotein独立站"') + ' lay-verify="required">' +
                '    </div>' +
                '  </div>' +
                '  <div class="layui-form-item">' +
                '    <label class="layui-form-label febs-form-item-require">域名</label>' +
                '    <div class="layui-input-block">' +
                '      <input type="text" name="domain" class="layui-input" ' + (data ? 'value="' + (data.domain || '') + '"' : 'placeholder="如:polypepprotein.com"') + ' lay-verify="required">' +
                '      <div class="layui-form-mid layui-word-aux">用于Nginx server_name匹配</div>' +
                '    </div>' +
                '  </div>' +
                '  <div class="layui-form-item">' +
                '    <label class="layui-form-label">Nginx配置路径</label>' +
                '    <div class="layui-input-block">' +
                '      <input type="text" name="nginxConf" class="layui-input" ' + (data && data.nginxConf ? 'value="' + data.nginxConf + '"' : 'placeholder="/etc/nginx/conf.d/firewall_country_1.conf"') + '>' +
                '      <div class="layui-form-mid layui-word-aux">生成的国家白名单配置文件路径,留空自动生成</div>' +
                '    </div>' +
                '  </div>' +
                '  <div class="layui-form-item">' +
                '    <label class="layui-form-label">状态</label>' +
                '    <div class="layui-input-block">' +
                '      <input type="radio" name="status" value="1" title="启用" ' + (!data || data.status === 1 ? 'checked' : '') + '>' +
                '      <input type="radio" name="status" value="0" title="禁用" ' + (data && data.status === 0 ? 'checked' : '') + '>' +
                '    </div>' +
                '  </div>' +
                '  <div class="layui-form-item">' +
                '    <label class="layui-form-label">备注</label>' +
                '    <div class="layui-input-block">' +
                '      <input type="text" name="remark" class="layui-input" ' + (data && data.remark ? 'value="' + data.remark + '"' : '') + '>' +
                '    </div>' +
                '  </div>' +
                '</form></div>';
            var layerIndex = layer.open({
                type: 1,
                title: title,
                area: ['580px', '460px'],
                content: html,
                success: function (layero) {
                    form.render('radio', 'firewall-site-form');
                },
                btn: ['保存', '取消'],
                yes: function (index, layero) {
                    var formObj = $(layero).find('form');
                    var formData = form.val("firewall-site-form");
                    febs.post(ctx + 'admin/firewall/site/save', formData, function (res) {
                        layer.close(index);
                        febs.alert.success('保存成功');
                        tableIns.reload();
                    });
                },
                btn2: function () {
                    layer.closeAll();
                }
            });
        }
    });
</script>