From f1cf8741bad27ff99e644ad9cfa5458c78d79501 Mon Sep 17 00:00:00 2001
From: Administrator <15274802129@163.com>
Date: Fri, 31 Jul 2026 13:53:30 +0800
Subject: [PATCH] feat(firewall): 添加防火墙管理功能

---
 src/main/java/cc/mrbird/febs/firewall/entity/FirewallIpWhite.java                   |   35 +
 src/main/java/cc/mrbird/febs/firewall/mapper/FirewallIpWhiteMapper.java             |   15 
 src/main/java/cc/mrbird/febs/firewall/service/IFirewallRuleService.java             |   68 ++
 sql/firewall.sql                                                                    |   66 ++
 src/main/java/cc/mrbird/febs/mall/controller/ViewSystemController.java              |   18 
 src/main/java/cc/mrbird/febs/firewall/dto/SaveCountryRuleDto.java                   |   23 
 src/main/java/cc/mrbird/febs/firewall/mapper/FirewallSiteMapper.java                |   15 
 src/main/resources/application-prod.yml                                             |    1 
 src/main/resources/templates/febs/views/modules/system/firewallSiteList.html        |  168 ++++++
 src/main/resources/templates/febs/views/modules/system/firewallCountryRuleList.html |  222 ++++++++
 sql/nginx_reload.sh                                                                 |   23 
 src/main/java/cc/mrbird/febs/firewall/dto/CountryVo.java                            |   19 
 src/main/java/cc/mrbird/febs/firewall/controller/AdminFirewallController.java       |  159 +++++
 src/main/java/cc/mrbird/febs/firewall/entity/FirewallSite.java                      |   33 +
 src/main/java/cc/mrbird/febs/firewall/entity/FirewallCountryRule.java               |   30 +
 src/main/java/cc/mrbird/febs/firewall/service/impl/FirewallRuleServiceImpl.java     |  226 ++++++++
 src/main/resources/mapper/firewall/FirewallCountryRuleMapper.xml                    |   13 
 src/main/java/cc/mrbird/febs/firewall/task/NginxReloadTask.java                     |   90 +++
 etc/nginx/conf.d/firewall_country_1.conf                                            |    7 
 src/main/java/cc/mrbird/febs/firewall/mapper/FirewallCountryRuleMapper.java         |   26 
 src/main/java/cc/mrbird/febs/firewall/nginx/NginxConfigGenerator.java               |   93 +++
 src/main/resources/templates/febs/views/modules/system/firewallCountryList.html     |  222 ++++++++
 22 files changed, 1,572 insertions(+), 0 deletions(-)

diff --git a/etc/nginx/conf.d/firewall_country_1.conf b/etc/nginx/conf.d/firewall_country_1.conf
new file mode 100644
index 0000000..11933ae
--- /dev/null
+++ b/etc/nginx/conf.d/firewall_country_1.conf
@@ -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;
+}
diff --git a/sql/firewall.sql b/sql/firewall.sql
new file mode 100644
index 0000000..36d889d
--- /dev/null
+++ b/sql/firewall.sql
@@ -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);
diff --git a/sql/nginx_reload.sh b/sql/nginx_reload.sh
new file mode 100644
index 0000000..0d7f8b7
--- /dev/null
+++ b/sql/nginx_reload.sh
@@ -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
diff --git a/src/main/java/cc/mrbird/febs/firewall/controller/AdminFirewallController.java b/src/main/java/cc/mrbird/febs/firewall/controller/AdminFirewallController.java
new file mode 100644
index 0000000..45096c8
--- /dev/null
+++ b/src/main/java/cc/mrbird/febs/firewall/controller/AdminFirewallController.java
@@ -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 信号已发送");
+    }
+}
diff --git a/src/main/java/cc/mrbird/febs/firewall/dto/CountryVo.java b/src/main/java/cc/mrbird/febs/firewall/dto/CountryVo.java
new file mode 100644
index 0000000..fe6a786
--- /dev/null
+++ b/src/main/java/cc/mrbird/febs/firewall/dto/CountryVo.java
@@ -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;
+}
diff --git a/src/main/java/cc/mrbird/febs/firewall/dto/SaveCountryRuleDto.java b/src/main/java/cc/mrbird/febs/firewall/dto/SaveCountryRuleDto.java
new file mode 100644
index 0000000..20d94b9
--- /dev/null
+++ b/src/main/java/cc/mrbird/febs/firewall/dto/SaveCountryRuleDto.java
@@ -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;
+}
diff --git a/src/main/java/cc/mrbird/febs/firewall/entity/FirewallCountryRule.java b/src/main/java/cc/mrbird/febs/firewall/entity/FirewallCountryRule.java
new file mode 100644
index 0000000..99b2e79
--- /dev/null
+++ b/src/main/java/cc/mrbird/febs/firewall/entity/FirewallCountryRule.java
@@ -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;
+}
diff --git a/src/main/java/cc/mrbird/febs/firewall/entity/FirewallIpWhite.java b/src/main/java/cc/mrbird/febs/firewall/entity/FirewallIpWhite.java
new file mode 100644
index 0000000..bad6d4e
--- /dev/null
+++ b/src/main/java/cc/mrbird/febs/firewall/entity/FirewallIpWhite.java
@@ -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;
+}
diff --git a/src/main/java/cc/mrbird/febs/firewall/entity/FirewallSite.java b/src/main/java/cc/mrbird/febs/firewall/entity/FirewallSite.java
new file mode 100644
index 0000000..1f13ffd
--- /dev/null
+++ b/src/main/java/cc/mrbird/febs/firewall/entity/FirewallSite.java
@@ -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;
+}
diff --git a/src/main/java/cc/mrbird/febs/firewall/mapper/FirewallCountryRuleMapper.java b/src/main/java/cc/mrbird/febs/firewall/mapper/FirewallCountryRuleMapper.java
new file mode 100644
index 0000000..ad5998d
--- /dev/null
+++ b/src/main/java/cc/mrbird/febs/firewall/mapper/FirewallCountryRuleMapper.java
@@ -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);
+}
diff --git a/src/main/java/cc/mrbird/febs/firewall/mapper/FirewallIpWhiteMapper.java b/src/main/java/cc/mrbird/febs/firewall/mapper/FirewallIpWhiteMapper.java
new file mode 100644
index 0000000..3f931f2
--- /dev/null
+++ b/src/main/java/cc/mrbird/febs/firewall/mapper/FirewallIpWhiteMapper.java
@@ -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> {
+}
diff --git a/src/main/java/cc/mrbird/febs/firewall/mapper/FirewallSiteMapper.java b/src/main/java/cc/mrbird/febs/firewall/mapper/FirewallSiteMapper.java
new file mode 100644
index 0000000..3e3511d
--- /dev/null
+++ b/src/main/java/cc/mrbird/febs/firewall/mapper/FirewallSiteMapper.java
@@ -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> {
+}
diff --git a/src/main/java/cc/mrbird/febs/firewall/nginx/NginxConfigGenerator.java b/src/main/java/cc/mrbird/febs/firewall/nginx/NginxConfigGenerator.java
new file mode 100644
index 0000000..661b82b
--- /dev/null
+++ b/src/main/java/cc/mrbird/febs/firewall/nginx/NginxConfigGenerator.java
@@ -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);
+        }
+    }
+}
diff --git a/src/main/java/cc/mrbird/febs/firewall/service/IFirewallRuleService.java b/src/main/java/cc/mrbird/febs/firewall/service/IFirewallRuleService.java
new file mode 100644
index 0000000..c5137ce
--- /dev/null
+++ b/src/main/java/cc/mrbird/febs/firewall/service/IFirewallRuleService.java
@@ -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);
+}
diff --git a/src/main/java/cc/mrbird/febs/firewall/service/impl/FirewallRuleServiceImpl.java b/src/main/java/cc/mrbird/febs/firewall/service/impl/FirewallRuleServiceImpl.java
new file mode 100644
index 0000000..f7e6d11
--- /dev/null
+++ b/src/main/java/cc/mrbird/febs/firewall/service/impl/FirewallRuleServiceImpl.java
@@ -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);
+    }
+}
diff --git a/src/main/java/cc/mrbird/febs/firewall/task/NginxReloadTask.java b/src/main/java/cc/mrbird/febs/firewall/task/NginxReloadTask.java
new file mode 100644
index 0000000..26ade5e
--- /dev/null
+++ b/src/main/java/cc/mrbird/febs/firewall/task/NginxReloadTask.java
@@ -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);
+        }
+    }
+}
diff --git a/src/main/java/cc/mrbird/febs/mall/controller/ViewSystemController.java b/src/main/java/cc/mrbird/febs/mall/controller/ViewSystemController.java
index 70a3723..3c9f071 100644
--- a/src/main/java/cc/mrbird/febs/mall/controller/ViewSystemController.java
+++ b/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) {
diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml
index ebfca6a..b036af2 100644
--- a/src/main/resources/application-prod.yml
+++ b/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
       # 配置默认数据源
diff --git a/src/main/resources/mapper/firewall/FirewallCountryRuleMapper.xml b/src/main/resources/mapper/firewall/FirewallCountryRuleMapper.xml
new file mode 100644
index 0000000..2c76e69
--- /dev/null
+++ b/src/main/resources/mapper/firewall/FirewallCountryRuleMapper.xml
@@ -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>
diff --git a/src/main/resources/templates/febs/views/modules/system/firewallCountryList.html b/src/main/resources/templates/febs/views/modules/system/firewallCountryList.html
new file mode 100644
index 0000000..4fa105a
--- /dev/null
+++ b/src/main/resources/templates/febs/views/modules/system/firewallCountryList.html
@@ -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>
diff --git a/src/main/resources/templates/febs/views/modules/system/firewallCountryRuleList.html b/src/main/resources/templates/febs/views/modules/system/firewallCountryRuleList.html
new file mode 100644
index 0000000..a5afe84
--- /dev/null
+++ b/src/main/resources/templates/febs/views/modules/system/firewallCountryRuleList.html
@@ -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>
diff --git a/src/main/resources/templates/febs/views/modules/system/firewallSiteList.html b/src/main/resources/templates/febs/views/modules/system/firewallSiteList.html
new file mode 100644
index 0000000..01d86ff
--- /dev/null
+++ b/src/main/resources/templates/febs/views/modules/system/firewallSiteList.html
@@ -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>

--
Gitblit v1.9.1