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"></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"></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"></i> 新增规则
+ </button>
+ </div>
+ </div>
+ <div class="layui-col-md2 layui-col-sm12 layui-col-xs12 table-action-area">
+ <div class="layui-btn layui-btn-sm layui-btn-primary febs-button-green-plain table-action" id="reset">
+ <i class="layui-icon"></i>
+ </div>
+ </div>
+ </div>
+ <div class="layui-row">
+ <div class="layui-col-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"></i></a>
+ <a lay-event="del"><i class="layui-icon febs-edit-area febs-red"></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"></i> 新增站点
+ </button>
+ </div>
+ </div>
+ <div class="layui-col-md2 layui-col-sm12 layui-col-xs12 table-action-area">
+ <div class="layui-btn layui-btn-sm layui-btn-primary febs-button-green-plain table-action" id="reset">
+ <i class="layui-icon"></i>
+ </div>
+ </div>
+ </div>
+ </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"></i></a>
+ <a lay-event="del"><i class="layui-icon febs-edit-area febs-red"></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