初始化1

This commit is contained in:
zg
2026-07-16 13:03:11 +08:00
parent 0bdfcbc1c8
commit e6b0f287cc
100 changed files with 12184 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
FROM harbor.zgitm.com/library/eclipse-temurin:17-jre
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 32402
ENTRYPOINT ["java","-jar","app.jar"]

View File

@@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/>
</parent>
<groupId>com.mokee</groupId>
<artifactId>nginx-manager</artifactId>
<version>1.0.0</version>
<name>Nginx Manager Backend</name>
<description>Nginx域名转发管理平台 - Spring Boot 后端</description>
<properties>
<java.version>17</java.version>
<!-- 默认激活的环境: qa -->
<spring.profiles.active>qa</spring.profiles.active>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis-Plus (Spring Boot 3.x) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.9</version>
</dependency>
<!-- MySQL Driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<!-- Maven 环境 Profiles -->
<profiles>
<!-- QA 测试环境 -->
<profile>
<id>qa</id>
<properties>
<spring.profiles.active>qa</spring.profiles.active>
</properties>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
</profile>
<!-- PROD 生产环境 -->
<profile>
<id>prod</id>
<properties>
<spring.profiles.active>prod</spring.profiles.active>
</properties>
</profile>
</profiles>
<build>
<!-- Maven 资源过滤:将 pom 变量 @xxx@ 替换到 application.yml -->
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!-- 将 Maven profile 传给 Spring Boot -->
<jvmArguments>-Dspring.profiles.active=${spring.profiles.active}</jvmArguments>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,17 @@
package com.mokee.nginx;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* Nginx域名转发管理平台 - Spring Boot 启动类
*/
@SpringBootApplication
@EnableScheduling
public class NginxManagerApplication {
public static void main(String[] args) {
SpringApplication.run(NginxManagerApplication.class, args);
}
}

View File

@@ -0,0 +1,30 @@
package com.mokee.nginx.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 跨域配置 — 允许网关域名的跨域请求
*
* 网关转发请求时 origin 是浏览器地址(非网关地址),
* 业务后端必须开放跨域,否则浏览器拒绝响应。
*
* 注意allowCredentials=true 时不能使用 allowedOrigins("*")
* 必须用 allowedOriginPatterns 指定具体模式。
*/
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns(
"https://*.zgitm.com",
"http://localhost:*"
)
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true);
}
}

View File

@@ -0,0 +1,20 @@
package com.mokee.nginx.config;
import com.fasterxml.jackson.core.JsonGenerator;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Jackson 配置 — 中文不转义为 Unicode
*/
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
return builder -> builder.featuresToDisable(
JsonGenerator.Feature.ESCAPE_NON_ASCII
);
}
}

View File

@@ -0,0 +1,55 @@
package com.mokee.nginx.config;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.mokee.nginx.context.UserContext;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* MyBatis-Plus 自动填充处理器
* 替代 JPA 的 @PrePersist / @PreUpdate
* 自动填充创建时间/更新时间/创建人/更新人
*/
@Slf4j
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
LocalDateTime now = LocalDateTime.now();
this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, now);
this.strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, now);
String operator = resolveOperator();
this.strictInsertFill(metaObject, "createdBy", String.class, operator);
this.strictInsertFill(metaObject, "updatedBy", String.class, operator);
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
String operator = resolveOperator();
this.strictUpdateFill(metaObject, "updatedBy", String.class, operator);
}
/**
* 从 UserContext 获取当前操作人
* 优先级userName > userId > "系统"
* 定时任务等无用户上下文的场景返回 "系统"
*/
private String resolveOperator() {
try {
String userName = UserContext.getUserName();
if (userName != null && !userName.isEmpty()) return userName;
String userId = UserContext.getUserId();
if (userId != null && !userId.isEmpty()) return userId;
} catch (Exception e) {
// UserContext ThreadLocal 可能未初始化(定时任务、测试等场景)
}
return "系统";
}
}

View File

@@ -0,0 +1,87 @@
package com.mokee.nginx.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import javax.net.ssl.*;
import java.net.HttpURLConnection;
import java.net.URI;
import java.security.cert.X509Certificate;
import java.time.Duration;
/**
* RestTemplate 配置,用于调用 Python API 和宝塔面板 API
*/
@Configuration
public class RestTemplateConfig {
@Value("${python-api.base-url}")
private String pythonApiBaseUrl;
@Value("${python-api.connect-timeout:5000}")
private int connectTimeout;
@Value("${python-api.read-timeout:30000}")
private int readTimeout;
@Value("${python-api.cert-read-timeout:210000}")
private int certReadTimeout;
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder
.rootUri(pythonApiBaseUrl)
.setConnectTimeout(Duration.ofMillis(connectTimeout))
.setReadTimeout(Duration.ofMillis(readTimeout))
.build();
}
/** 证书操作专用 RestTemplate签发/续期超时: 5分钟 */
@Bean
public RestTemplate certRestTemplate(RestTemplateBuilder builder) {
return builder
.rootUri(pythonApiBaseUrl)
.setConnectTimeout(Duration.ofMillis(connectTimeout))
.setReadTimeout(Duration.ofMillis(certReadTimeout))
.build();
}
/** 宝塔面板专用 RestTemplate信任自签名证书跳过证书校验 */
@Bean
public RestTemplate btRestTemplate() throws Exception {
// 创建信任所有证书的 TrustManager
TrustManager[] trustAll = new TrustManager[]{
new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
public void checkClientTrusted(X509Certificate[] certs, String authType) {}
public void checkServerTrusted(X509Certificate[] certs, String authType) {}
}
};
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAll, new java.security.SecureRandom());
// 设置默认 SSL 工厂 + 跳过主机名验证
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory());
HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true);
// 使用 SimpleClientHttpRequestFactory不依赖 Apache HttpClient
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory() {
@Override
protected void prepareConnection(HttpURLConnection connection, String httpMethod) {
if (connection instanceof HttpsURLConnection httpsConn) {
httpsConn.setSSLSocketFactory(sslContext.getSocketFactory());
httpsConn.setHostnameVerifier((hostname, session) -> true);
}
}
};
factory.setConnectTimeout(Duration.ofMillis(connectTimeout));
factory.setReadTimeout(Duration.ofMillis(readTimeout));
return new RestTemplate(factory);
}
}

View File

@@ -0,0 +1,38 @@
package com.mokee.nginx.context;
import java.util.*;
/**
* 用户上下文 — ThreadLocal 缓存网关注入的请求头
*
* 业务代码零侵入获取当前登录用户信息:
* <pre>
* String userId = UserContext.getUserId();
* String userName = UserContext.getUserName();
* </pre>
*
* 由 {@link com.mokee.nginx.filter.UserContextFilter} 在请求进入时设置,
* 请求结束后自动清理。
*/
public class UserContext {
private static final ThreadLocal<Map<String, String>> CTX = new ThreadLocal<>();
public static void set(Map<String, String> user) { CTX.set(user); }
public static Map<String, String> get() {
Map<String, String> map = CTX.get();
return map != null ? map : Collections.emptyMap();
}
public static void clear() { CTX.remove(); }
// 快捷方法
public static String getUserId() { return get().get("X-User-Id"); }
public static String getUserName() { return get().get("X-User-Name"); }
public static String getUserAccount() { return get().get("X-User-Account"); }
public static String getUserEmail() { return get().get("X-User-Email"); }
public static String getUserPost() { return get().get("X-User-Post"); }
public static String getUserRoles() { return get().get("X-User-Roles"); }
public static String getSystemCode() { return get().get("X-System-Code"); }
public static String getSystemId() { return get().get("X-System-Id"); }
public static String getSystemName() { return get().get("X-System-Name"); }
}

View File

@@ -0,0 +1,82 @@
package com.mokee.nginx.controller;
import com.mokee.nginx.dto.ApiResponse;
import com.mokee.nginx.entity.Certificate;
import com.mokee.nginx.service.CertificateService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* SSL证书管理 Controller
*/
@Slf4j
@RestController
@RequestMapping("/api/certificates")
public class CertificateController {
@Autowired
private CertificateService certificateService;
/**
* 获取所有证书记录
*/
@GetMapping
public ResponseEntity<ApiResponse<List<Certificate>>> listCertificates() {
log.info("获取证书列表");
List<Certificate> certs = certificateService.listCertificates();
return ResponseEntity.ok(ApiResponse.success(certs));
}
/**
* 获取单个证书详情
*/
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<Certificate>> getCertificate(@PathVariable Long id) {
log.info("获取证书详情: id={}", id);
Certificate cert = certificateService.getCertificate(id);
return ResponseEntity.ok(ApiResponse.success(cert));
}
/**
* @deprecated 证书签发请在宝塔面板上操作
*/
@PostMapping("/{id}/issue")
public ResponseEntity<ApiResponse<Void>> issueCertificate(@PathVariable Long id) {
log.warn("签发证书请求被拒绝(请到宝塔面板操作): id={}", id);
return ResponseEntity.status(405).body(
ApiResponse.error(405, "证书签发请在宝塔面板上操作SSL → Let's Encrypt"));
}
/**
* @deprecated 证书续期请在宝塔面板上操作
*/
@PostMapping("/{id}/renew")
public ResponseEntity<ApiResponse<Void>> renewCertificate(@PathVariable Long id) {
log.warn("续期证书请求被拒绝(请到宝塔面板操作): id={}", id);
return ResponseEntity.status(405).body(
ApiResponse.error(405, "证书续期请在宝塔面板上操作SSL → 续期)"));
}
/**
* 更新通知配置
*/
@PutMapping("/{id}/notify-config")
public ResponseEntity<ApiResponse<Certificate>> updateNotifyConfig(
@PathVariable Long id, @RequestBody Map<String, Object> body) {
log.info("更新通知配置: id={}", id);
String notifyEmail = (String) body.get("notifyEmail");
Integer notifyEnabled = body.get("notifyEnabled") != null
? ((Number) body.get("notifyEnabled")).intValue() : null;
Integer notifyDaysBefore = body.get("notifyDaysBefore") != null
? ((Number) body.get("notifyDaysBefore")).intValue() : null;
Certificate cert = certificateService.updateNotifyConfig(
id, notifyEmail, notifyEnabled, notifyDaysBefore);
return ResponseEntity.ok(ApiResponse.success("通知配置已更新", cert));
}
}

View File

@@ -0,0 +1,105 @@
package com.mokee.nginx.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.mokee.nginx.dto.ApiResponse;
import com.mokee.nginx.entity.DnsZoneConfig;
import com.mokee.nginx.mapper.DnsZoneConfigMapper;
import com.mokee.nginx.service.DnsRecordService;
import com.mokee.nginx.service.PythonApiClient;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* DNS 解析管理 Controller
* DNS 记录通过 DnsRecordService 管理(阿里云为权威数据源 + 本地审计追踪)
*/
@Slf4j
@RestController
@RequestMapping("/api/dns")
public class DnsController {
@Autowired
private DnsRecordService dnsRecordService;
@Autowired
private DnsZoneConfigMapper zoneConfigMapper;
/**
* 获取 DNS Zone 配置列表
*/
@GetMapping("/zones")
public ResponseEntity<ApiResponse<List<DnsZoneConfig>>> listZones() {
List<DnsZoneConfig> zones = zoneConfigMapper.selectList(null);
return ResponseEntity.ok(ApiResponse.success(zones));
}
/**
* 获取 DNS 解析记录列表(含本地审计信息:创建人/时间/更新人/时间)
*/
@GetMapping("/records")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> listRecords(
@RequestParam(defaultValue = "zgitm.com") String zone) {
log.info("获取DNS记录: zone={}", zone);
List<Map<String, Object>> records = dnsRecordService.listRecords(zone);
return ResponseEntity.ok(ApiResponse.success(records));
}
/**
* 添加 DNS 解析记录
*/
@PostMapping("/records")
public ResponseEntity<ApiResponse<Map<String, Object>>> addRecord(
@RequestBody Map<String, Object> body) {
log.info("添加DNS记录: {}", body);
Map<String, Object> result = dnsRecordService.addRecord(body);
return ResponseEntity.ok(ApiResponse.success(result));
}
/**
* 删除 DNS 解析记录
*/
@DeleteMapping("/records/{recordId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteRecord(
@PathVariable String recordId) {
log.info("删除DNS记录: recordId={}", recordId);
Map<String, Object> result = dnsRecordService.deleteRecord(recordId);
return ResponseEntity.ok(ApiResponse.success(result));
}
/**
* 修改 DNS 解析记录
*/
@PutMapping("/records/{recordId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateRecord(
@PathVariable String recordId, @RequestBody Map<String, Object> body) {
log.info("修改DNS记录: recordId={}, body={}", recordId, body);
Map<String, Object> result = dnsRecordService.updateRecord(recordId, body);
return ResponseEntity.ok(ApiResponse.success(result));
}
/**
* 获取默认 zone 配置(前端默认值用)
*/
@GetMapping("/default-zone")
public ResponseEntity<ApiResponse<Map<String, Object>>> getDefaultZone() {
DnsZoneConfig zone = zoneConfigMapper.selectOne(
new LambdaQueryWrapper<DnsZoneConfig>()
.eq(DnsZoneConfig::getDomainZone, "zgitm.com")
.eq(DnsZoneConfig::getStatus, 1));
Map<String, Object> data = new HashMap<>();
if (zone != null) {
data.put("zone", zone.getDomainZone());
data.put("defaultIp", zone.getDefaultIp());
} else {
data.put("zone", "zgitm.com");
data.put("defaultIp", "10.20.1.160");
}
return ResponseEntity.ok(ApiResponse.success(data));
}
}

View File

@@ -0,0 +1,133 @@
package com.mokee.nginx.controller;
import com.mokee.nginx.dto.ApiResponse;
import com.mokee.nginx.dto.RuleRequest;
import com.mokee.nginx.entity.ForwardingRule;
import com.mokee.nginx.service.ForwardingRuleService;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 转发规则 REST Controller只读模式
* 数据来源:宝塔面板 API
* 新增/修改/删除请在宝塔面板上操作
*/
@Slf4j
@RestController
@RequestMapping("/api/rules")
public class ForwardingRuleController {
@Autowired
private ForwardingRuleService service;
/**
* 获取所有转发规则列表(从宝塔面板拉取)
*/
@GetMapping
public ResponseEntity<ApiResponse<List<ForwardingRule>>> listRules() {
log.info("获取转发规则列表(数据源: 宝塔面板)");
List<ForwardingRule> rules = service.listRules();
return ResponseEntity.ok(ApiResponse.success(rules));
}
/**
* 根据ID获取规则详情从当前列表中匹配
*/
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<ForwardingRule>> getRule(@PathVariable Long id) {
log.info("获取规则详情: id={}", id);
ForwardingRule rule = service.listRules().stream()
.filter(r -> r.getId().equals(id))
.findFirst()
.orElseThrow(() -> new RuntimeException("规则不存在: id=" + id));
return ResponseEntity.ok(ApiResponse.success(rule));
}
// ═══════════════════════════════════════════════════════════
// 以下写操作已废弃 — 请在宝塔面板上操作
// ═══════════════════════════════════════════════════════════
@PostMapping
public ResponseEntity<ApiResponse<Void>> addRule(@Valid @RequestBody RuleRequest request) {
log.warn("新增规则请求被拒绝(请到宝塔面板操作): domain={}", request.getDomain());
return ResponseEntity.status(405).body(
ApiResponse.error(405, "新增转发规则请在宝塔面板上操作(站点 → 反向代理)"));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> updateRule(
@PathVariable Long id, @Valid @RequestBody RuleRequest request) {
log.warn("修改规则请求被拒绝(请到宝塔面板操作): id={}", id);
return ResponseEntity.status(405).body(
ApiResponse.error(405, "修改转发规则请在宝塔面板上操作(站点 → 反向代理)"));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> deleteRule(@PathVariable Long id) {
log.warn("删除规则请求被拒绝(请到宝塔面板操作): id={}", id);
return ResponseEntity.status(405).body(
ApiResponse.error(405, "删除转发规则请在宝塔面板上操作(站点 → 反向代理)"));
}
@PostMapping("/reload")
public ResponseEntity<ApiResponse<Void>> reloadNginx() {
log.warn("重载Nginx请求被拒绝请到宝塔面板操作");
return ResponseEntity.status(405).body(
ApiResponse.error(405, "重载Nginx请在宝塔面板上操作"));
}
@GetMapping("/status")
public ResponseEntity<ApiResponse<String>> getStatus() {
log.info("Nginx状态查询已迁移至宝塔");
return ResponseEntity.ok(ApiResponse.success("Nginx 反代已迁移至宝塔面板管理"));
}
/**
* 获取规则详情(配置文件内容 + SSL证书信息
*/
@GetMapping("/{id}/config")
public ResponseEntity<ApiResponse<Map<String, Object>>> getConfig(@PathVariable Long id) {
log.info("获取规则详情: id={}", id);
Map<String, Object> result = service.getConfig(id);
return ResponseEntity.ok(ApiResponse.success(result));
}
@PutMapping("/{id}/config")
public ResponseEntity<ApiResponse<Void>> updateConfig(
@PathVariable Long id, @RequestBody Object body) {
return ResponseEntity.status(410).body(
ApiResponse.error(410, "配置文件编辑已不可用,请前往宝塔面板"));
}
@GetMapping("/{id}/ssl")
public ResponseEntity<ApiResponse<Void>> getSslFiles(@PathVariable Long id) {
return ResponseEntity.status(410).body(
ApiResponse.error(410, "SSL证书查看请前往宝塔面板"));
}
@GetMapping("/{id}/gzip")
public ResponseEntity<ApiResponse<Void>> getGzipConfig(@PathVariable Long id) {
return ResponseEntity.status(410).body(
ApiResponse.error(410, "内容压缩配置请前往宝塔面板查看"));
}
@PutMapping("/{id}/gzip")
public ResponseEntity<ApiResponse<Void>> updateGzipConfig(
@PathVariable Long id, @RequestBody Object body) {
return ResponseEntity.status(410).body(
ApiResponse.error(410, "内容压缩配置请前往宝塔面板修改"));
}
@GetMapping("/{id}/logs")
public ResponseEntity<ApiResponse<Void>> getDomainLogs(
@PathVariable Long id, @RequestParam(defaultValue = "200") int lines) {
return ResponseEntity.status(410).body(
ApiResponse.error(410, "日志查看请前往宝塔面板"));
}
}

View File

@@ -0,0 +1,53 @@
package com.mokee.nginx.controller;
import com.mokee.nginx.dto.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
/**
* 全局异常处理器
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 参数校验失败
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidationException(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
log.warn("参数校验失败: {}", message);
return ResponseEntity.badRequest()
.body(ApiResponse.error(message));
}
/**
* 业务异常
*/
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ApiResponse<Void>> handleRuntimeException(RuntimeException ex) {
log.error("业务异常: {}", ex.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error(ex.getMessage()));
}
/**
* 其他未捕获异常
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception ex) {
log.error("系统异常: ", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("系统内部错误: " + ex.getMessage()));
}
}

View File

@@ -0,0 +1,52 @@
package com.mokee.nginx.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 统一响应结构
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
private int code;
private String message;
private T data;
public static <T> ApiResponse<T> success(T data) {
return ApiResponse.<T>builder()
.code(0)
.message("success")
.data(data)
.build();
}
public static <T> ApiResponse<T> success(String message, T data) {
return ApiResponse.<T>builder()
.code(0)
.message(message)
.data(data)
.build();
}
public static <T> ApiResponse<T> error(int code, String message) {
return ApiResponse.<T>builder()
.code(code)
.message(message)
.data(null)
.build();
}
public static <T> ApiResponse<T> error(String message) {
return ApiResponse.<T>builder()
.code(-1)
.message(message)
.data(null)
.build();
}
}

View File

@@ -0,0 +1,28 @@
package com.mokee.nginx.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
/**
* 新增/修改转发规则请求DTO
*/
@Data
public class RuleRequest {
/** 域名 */
@NotBlank(message = "域名不能为空")
@Pattern(regexp = "^[a-zA-Z0-9]([a-zA-Z0-9\\-\\.]*[a-zA-Z0-9])?$",
message = "域名格式不正确")
private String domain;
/** 协议类型: http 或 https */
@NotBlank(message = "协议类型不能为空")
@Pattern(regexp = "^(http|https)$", message = "协议类型必须为 http 或 https")
private String protocol;
/** 转发目标地址 */
@NotBlank(message = "转发目标地址不能为空")
@Pattern(regexp = "^https?://.+", message = "转发目标地址必须以 http:// 或 https:// 开头")
private String target;
}

View File

@@ -0,0 +1,91 @@
package com.mokee.nginx.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* SSL证书实体
*/
@TableName("certificates")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Certificate {
@TableId(type = IdType.AUTO)
private Long id;
/** 域名 */
@TableField("domain")
private String domain;
/** 证书类型: internal_ca / letsencrypt */
@TableField("cert_type")
@Builder.Default
private String certType = "internal_ca";
/** 证书文件路径 */
@TableField("cert_path")
private String certPath;
/** 私钥文件路径 */
@TableField("key_path")
private String keyPath;
/** 签发日期 */
@TableField("issue_date")
private LocalDateTime issueDate;
/** 过期日期 */
@TableField("expire_date")
private LocalDateTime expireDate;
/** 状态: active / expired / revoked */
@TableField("status")
@Builder.Default
private String status = "active";
/** 通知收件人邮箱 */
@TableField("notify_email")
private String notifyEmail;
/** 是否启用到期通知 */
@TableField("notify_enabled")
@Builder.Default
private Integer notifyEnabled = 1;
/** 提前多少天开始通知 */
@TableField("notify_days_before")
@Builder.Default
private Integer notifyDaysBefore = 15;
/** 上次续期时间 */
@TableField("last_renew_time")
private LocalDateTime lastRenewTime;
/** 续期/签发日志 */
@TableField("renew_log")
private String renewLog;
/** 创建人 */
@TableField(value = "created_by", fill = FieldFill.INSERT)
private String createdBy;
/** 更新人 */
@TableField(value = "updated_by", fill = FieldFill.INSERT_UPDATE)
private String updatedBy;
/** 创建时间 */
@TableField(value = "created_at", fill = FieldFill.INSERT)
private LocalDateTime createdAt;
/** 更新时间 */
@TableField(value = "updated_at", fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,71 @@
package com.mokee.nginx.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* DNS 解析记录本地审计实体
* 阿里云 DNS 为权威数据源,本表仅做操作审计追踪
*/
@TableName("dns_records")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DnsRecord {
@TableId(type = IdType.AUTO)
private Long id;
/** 阿里云解析记录ID */
@TableField("record_id")
private String recordId;
/** 主域名(如 zgitm.com */
@TableField("domain_zone")
private String domainZone;
/** 主机记录 */
@TableField("rr")
private String rr;
/** 记录类型: A / CNAME / TXT */
@TableField("record_type")
private String recordType;
/** 记录值IP 或域名) */
@TableField("record_value")
private String recordValue;
/** TTL 生效时间(秒) */
@TableField("ttl")
@Builder.Default
private Integer ttl = 600;
/** 状态: 1-正常, 0-已删除(逻辑删除) */
@TableField("status")
@TableLogic(value = "1", delval = "0")
@Builder.Default
private Integer status = 1;
/** 创建人 */
@TableField(value = "created_by", fill = FieldFill.INSERT)
private String createdBy;
/** 更新人 */
@TableField(value = "updated_by", fill = FieldFill.INSERT_UPDATE)
private String updatedBy;
/** 创建时间 */
@TableField(value = "created_at", fill = FieldFill.INSERT)
private LocalDateTime createdAt;
/** 更新时间 */
@TableField(value = "updated_at", fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,59 @@
package com.mokee.nginx.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* DNS Zone 配置实体
*/
@TableName("dns_zone_config")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DnsZoneConfig {
@TableId(type = IdType.AUTO)
private Long id;
/** 主域名 (如 zgitm.com) */
@TableField("domain_zone")
private String domainZone;
/** 默认解析 IP */
@TableField("default_ip")
@Builder.Default
private String defaultIp = "10.20.1.160";
/** DNS 服务商 */
@TableField("dns_provider")
@Builder.Default
private String dnsProvider = "aliyun";
/** 状态: 1-启用 0-停用 */
@TableField("status")
@TableLogic(value = "1", delval = "0")
@Builder.Default
private Integer status = 1;
/** 创建人 */
@TableField(value = "created_by", fill = FieldFill.INSERT)
private String createdBy;
/** 更新人 */
@TableField(value = "updated_by", fill = FieldFill.INSERT_UPDATE)
private String updatedBy;
/** 创建时间 */
@TableField(value = "created_at", fill = FieldFill.INSERT)
private LocalDateTime createdAt;
/** 更新时间 */
@TableField(value = "updated_at", fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,89 @@
package com.mokee.nginx.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Nginx转发规则实体
*/
@TableName("forwarding_rules")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ForwardingRule {
@TableId(type = IdType.AUTO)
private Long id;
/** 域名 */
@TableField("domain")
private String domain;
/** 协议类型: http / https */
@TableField("protocol")
private String protocol;
/** 转发目标地址 (http://host:port 或 https://host:port) */
@TableField("target")
private String target;
/** Nginx配置文件路径 */
@TableField("config_file")
private String configFile;
/** SSL证书路径 */
@TableField("ssl_cert")
private String sslCert;
/** SSL私钥路径 */
@TableField("ssl_key")
private String sslKey;
/** gzip压缩开关: true-开启, false-关闭 */
@TableField("gzip_enabled")
@Builder.Default
private Boolean gzipEnabled = false;
/** gzip压缩MIME类型空格分隔 */
@TableField("gzip_types")
@Builder.Default
private String gzipTypes = "text/plain text/css text/xml text/javascript application/x-javascript application/json application/xml application/xml+rss application/vnd.ms-fontobject application/x-font-ttf application/x-font-opentype application/x-font-truetype";
/** gzip压缩级别 1-9 */
@TableField("gzip_comp_level")
@Builder.Default
private Integer gzipCompLevel = 6;
/** gzip最小压缩长度可带k/m单位如 "1000", "1k", "2m" */
@TableField("gzip_min_length")
@Builder.Default
private String gzipMinLength = "1000";
/** 状态: 1-正常, 0-已删除 */
@TableField("status")
@TableLogic(value = "1", delval = "0")
@Builder.Default
private Integer status = 1;
/** 创建人 */
@TableField(value = "created_by", fill = FieldFill.INSERT)
private String createdBy;
/** 更新人 */
@TableField(value = "updated_by", fill = FieldFill.INSERT_UPDATE)
private String updatedBy;
/** 创建时间 */
@TableField(value = "created_at", fill = FieldFill.INSERT)
private LocalDateTime createdAt;
/** 更新时间 */
@TableField(value = "updated_at", fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,52 @@
package com.mokee.nginx.filter;
import com.mokee.nginx.context.UserContext;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.*;
/**
* 用户上下文过滤器 — 提取网关注入的请求头并缓存到 ThreadLocal
*
* 网关验证 Token 后在转发请求时注入以下请求头(可信任,无需再鉴权):
* X-User-Id, X-User-Name, X-User-Account, X-User-Email,
* X-User-Post, X-User-Roles, X-System-Id, X-System-Code, X-System-Name
*/
@Component
public class UserContextFilter implements Filter {
private static final String[] HEADERS = {
"X-User-Id", "X-User-Name", "X-User-Account",
"X-User-Email", "X-User-Post", "X-User-Roles",
"X-System-Id", "X-System-Code", "X-System-Name"
};
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
Map<String, String> user = new HashMap<>();
for (String h : HEADERS) {
String v = request.getHeader(h);
if (v != null) {
// 网关部分请求头值可能被 URL 编码(如中文用户名),解码后再存入
try {
v = URLDecoder.decode(v, StandardCharsets.UTF_8);
} catch (Exception ignored) { }
user.put(h, v);
}
}
UserContext.set(user);
try {
chain.doFilter(req, res);
} finally {
// 请求结束,清理 ThreadLocal 防止内存泄漏
UserContext.clear();
}
}
}

View File

@@ -0,0 +1,12 @@
package com.mokee.nginx.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.nginx.entity.Certificate;
import org.apache.ibatis.annotations.Mapper;
/**
* SSL证书 Mapper
*/
@Mapper
public interface CertificateMapper extends BaseMapper<Certificate> {
}

View File

@@ -0,0 +1,12 @@
package com.mokee.nginx.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.nginx.entity.DnsRecord;
import org.apache.ibatis.annotations.Mapper;
/**
* DNS 记录本地审计 Mapper
*/
@Mapper
public interface DnsRecordMapper extends BaseMapper<DnsRecord> {
}

View File

@@ -0,0 +1,12 @@
package com.mokee.nginx.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.nginx.entity.DnsZoneConfig;
import org.apache.ibatis.annotations.Mapper;
/**
* DNS Zone 配置 Mapper
*/
@Mapper
public interface DnsZoneConfigMapper extends BaseMapper<DnsZoneConfig> {
}

View File

@@ -0,0 +1,12 @@
package com.mokee.nginx.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.nginx.entity.ForwardingRule;
import org.apache.ibatis.annotations.Mapper;
/**
* Nginx转发规则 Mapper
*/
@Mapper
public interface ForwardingRuleMapper extends BaseMapper<ForwardingRule> {
}

View File

@@ -0,0 +1,230 @@
package com.mokee.nginx.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
import java.util.Map;
/**
* 宝塔面板 API HTTP 客户端(只读)
*
* 接口清单(来自宝塔官方 API 文档 api-doc.pdf
* - 站点列表: POST /data?action=getData&table=sites (参数: p, limit, type=-1)
* - 反代列表: POST /site?action=GetProxyList (参数: sitename)
* - 证书订单: POST /acme?action=get_orders
*
* 签名规则request_token = MD5(request_time + MD5(api_key))
*/
@Slf4j
@Service
public class BTPanelApiClient {
@Value("${bt-panel.base-url}")
private String baseUrl;
@Value("${bt-panel.api-key}")
private String apiKey;
private final RestTemplate btRestTemplate;
private final ObjectMapper objectMapper = new ObjectMapper();
public BTPanelApiClient(
@org.springframework.beans.factory.annotation.Qualifier("btRestTemplate") RestTemplate btRestTemplate) {
this.btRestTemplate = btRestTemplate;
}
// ═══════════════════════════════════════════════════════════
// 签名工具
// ═══════════════════════════════════════════════════════════
private String md5(String input) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
throw new RuntimeException("MD5计算失败", e);
}
}
/**
* 发送 POST 请求到宝塔 API参数通过 URL query string 传递)
*/
private Map<String, Object> postRequest(String route, String action,
MultiValueMap<String, String> extraParams) {
String url = buildUrl(route, action, extraParams);
log.debug("宝塔API请求: POST {}", url);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
// 发送空 body参数都在URL里
HttpEntity<String> entity = new HttpEntity<>("", headers);
try {
ResponseEntity<Map<String, Object>> response = btRestTemplate.exchange(
url, HttpMethod.POST, entity,
new ParameterizedTypeReference<>() {});
return response.getBody();
} catch (Exception e) {
log.error("宝塔API请求失败: url={}, error={}", url, e.getMessage());
throw new RuntimeException("宝塔API请求失败: " + e.getMessage());
}
}
/**
* 发送 POST 请求,返回列表
*/
private List<Map<String, Object>> postRequestForList(String route, String action,
MultiValueMap<String, String> extraParams) {
String url = buildUrl(route, action, extraParams);
log.debug("宝塔API请求(List): POST {}", url);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
HttpEntity<String> entity = new HttpEntity<>("", headers);
try {
ResponseEntity<List<Map<String, Object>>> response = btRestTemplate.exchange(
url, HttpMethod.POST, entity,
new ParameterizedTypeReference<>() {});
return response.getBody();
} catch (Exception e) {
log.error("宝塔API请求失败(List): url={}, error={}", url, e.getMessage());
throw new RuntimeException("宝塔API请求失败: " + e.getMessage());
}
}
/**
* 构建带签名的 URL
*/
private String buildUrl(String route, String action,
MultiValueMap<String, String> extraParams) {
String requestTime = String.valueOf(System.currentTimeMillis() / 1000);
String rawToken = requestTime + md5(apiKey);
String requestToken = md5(rawToken);
StringBuilder url = new StringBuilder(baseUrl);
url.append(route);
url.append("?action=").append(action);
url.append("&request_time=").append(requestTime);
url.append("&request_token=").append(requestToken);
if (extraParams != null) {
for (Map.Entry<String, List<String>> entry : extraParams.entrySet()) {
for (String value : entry.getValue()) {
url.append("&").append(entry.getKey()).append("=").append(value);
}
}
}
return url.toString();
}
// ═══════════════════════════════════════════════════════════
// 业务接口
// ═══════════════════════════════════════════════════════════
/**
* 获取所有站点列表
* API: POST /data?action=getData&table=sites
*
* 返回: {"data": [{"id":12,"name":"xxx.com","status":"1","project_type":"proxy",...}]}
*/
public Map<String, Object> getSiteList() {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("table", "sites");
params.add("p", "1");
params.add("limit", "200");
params.add("type", "-1");
Map<String, Object> result = postRequest("/data", "getData", params);
log.info("宝塔API: 获取站点列表成功");
return result;
}
/**
* 获取指定站点的反向代理列表
* API: POST /site?action=GetProxyList
*
* 参数: sitename=域名
* 返回: [{"proxyname":"...","proxydir":"/","proxysite":"http://...",...}]
*/
@SuppressWarnings("unchecked")
public List<Map<String, Object>> getProxyList(String siteName) {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("sitename", siteName);
try {
List<Map<String, Object>> result = postRequestForList("/site", "GetProxyList", params);
log.debug("宝塔API: 获取站点 {} 反代列表成功, 数量={}", siteName,
result != null ? result.size() : 0);
return result;
} catch (Exception e) {
log.warn("获取宝塔站点 {} 反代列表失败: {}", siteName, e.getMessage());
return List.of();
}
}
/**
* 获取 ACME 证书订单列表
* API: POST /acme?action=get_orders
*
* 返回: [{"domains":["xxx.com"],"expires":1780111135,"status":"valid",...}]
*/
@SuppressWarnings("unchecked")
public List<Map<String, Object>> getCertOrders() {
try {
List<Map<String, Object>> result = postRequestForList("/acme", "get_orders", null);
log.info("宝塔API: 获取证书订单列表成功, 数量={}", result != null ? result.size() : 0);
return result;
} catch (Exception e) {
log.error("获取宝塔证书订单列表失败: {}", e.getMessage());
throw new RuntimeException("获取宝塔证书订单列表失败: " + e.getMessage());
}
}
/**
* 读取宝塔服务器上的文件内容
* API: POST /files?action=GetFileBody
*
* @param filePath 文件绝对路径 (如 /www/server/panel/vhost/nginx/xxx.com.conf)
* @return 文件内容字符串
*/
public String getFileContent(String filePath) {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("path", filePath);
Map<String, Object> result = postRequest("/files", "GetFileBody", params);
if (result != null && result.get("data") != null) {
return (String) result.get("data");
}
return null;
}
/**
* 获取指定域名的 Nginx 配置文件内容
*/
public String getNginxConfig(String domain) {
String configPath = "/www/server/panel/vhost/nginx/" + domain + ".conf";
try {
return getFileContent(configPath);
} catch (Exception e) {
log.warn("获取 {} 的 nginx 配置失败: {}", domain, e.getMessage());
return null;
}
}
}

View File

@@ -0,0 +1,213 @@
package com.mokee.nginx.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.mokee.nginx.entity.Certificate;
import com.mokee.nginx.mapper.CertificateMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 证书到期邮件通知调度器
* 每天 10:00 检查 Let's Encrypt 证书,到期前 N 天起连续发邮件提醒
*/
@Slf4j
@Service
public class CertNotifyScheduler {
@Autowired
private CertificateMapper certificateMapper;
/** 网关 API 地址(可配置,不用 rootUri 的 RestTemplate */
private final RestTemplate gatewayRestTemplate;
@Value("${gateway.base-url:https://gw.server.mokee.com}")
private String gatewayBaseUrl;
@Value("${gateway.system-code:nginxServer}")
private String systemCode;
public CertNotifyScheduler() {
this.gatewayRestTemplate = new RestTemplate();
}
/**
* 每天 10:00 执行证书到期检查并发送邮件
*/
@Scheduled(cron = "0 0 10 * * ?")
public void checkAndNotify() {
log.info("=== 证书到期通知检查开始 ===");
List<Certificate> certs = certificateMapper.selectList(
new LambdaQueryWrapper<Certificate>()
.eq(Certificate::getCertType, "letsencrypt")
.eq(Certificate::getStatus, "active")
.eq(Certificate::getNotifyEnabled, 1)
.isNotNull(Certificate::getNotifyEmail));
if (certs.isEmpty()) {
log.info("无需通知的 Let's Encrypt 证书");
return;
}
String token = null;
int notifiedCount = 0;
for (Certificate cert : certs) {
if (cert.getExpireDate() == null) {
log.warn("{}: 无过期日期信息,跳过", cert.getDomain());
continue;
}
long daysLeft = ChronoUnit.DAYS.between(
LocalDate.now(),
cert.getExpireDate().toLocalDate());
log.info("{}: 剩余 {} 天到期 (阈值: {}天前开始)",
cert.getDomain(), daysLeft, cert.getNotifyDaysBefore());
// 判断是否在通知窗口:到期前 notifyDaysBefore 天 到期前1天
if (daysLeft > cert.getNotifyDaysBefore() || daysLeft <= 0) {
log.info("{}: 不在通知窗口内 (daysLeft={})", cert.getDomain(), daysLeft);
continue;
}
// 获取 Token
if (token == null) {
token = fetchToken();
}
if (token == null) {
log.error("无法获取网关 Token跳过 {}", cert.getDomain());
continue;
}
// 发送邮件
String subject = String.format("⚠️ SSL证书到期提醒 - %s (剩余%d天)",
cert.getDomain(), daysLeft);
String content = buildEmailHtml(cert.getDomain(), daysLeft, cert.getExpireDate());
boolean success = sendEmail(token, cert.getNotifyEmail(), subject, content);
if (success) {
token = null; // Token 已消费,下次需重新获取
notifiedCount++;
log.info("{}: 邮件发送成功 → {}", cert.getDomain(), cert.getNotifyEmail());
} else {
log.warn("{}: 邮件发送失败Token 未消费可重试", cert.getDomain());
}
}
log.info("=== 证书到期通知完成: 发送 {} 封邮件 ===", notifiedCount);
}
/**
* 从网关获取一次性 Token
*/
private String fetchToken() {
try {
String url = gatewayBaseUrl + "/zgapi/v1/open/token";
Map<String, String> body = new HashMap<>();
body.put("systemCode", systemCode);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, String>> request = new HttpEntity<>(body, headers);
ResponseEntity<Map> response = gatewayRestTemplate.postForEntity(
url, request, Map.class);
Map<String, Object> data = (Map<String, Object>) response.getBody();
if (data != null && Integer.valueOf(200).equals(data.get("code"))) {
Map<String, Object> inner = (Map<String, Object>) data.get("data");
String token = (String) inner.get("token");
log.info("获取 Token 成功: {}", systemCode);
return token;
} else {
log.error("获取 Token 失败: {}", data);
return null;
}
} catch (Exception e) {
log.error("获取 Token 异常: {}", e.getMessage());
return null;
}
}
/**
* 通过网关发送邮件
*/
private boolean sendEmail(String token, String to, String subject, String content) {
try {
String url = gatewayBaseUrl + "/zgapi/v1/open/email/send";
Map<String, String> body = new HashMap<>();
body.put("to", to);
body.put("subject", subject);
body.put("content", content);
body.put("contentType", "html");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(token);
HttpEntity<Map<String, String>> request = new HttpEntity<>(body, headers);
ResponseEntity<Map> response = gatewayRestTemplate.postForEntity(
url, request, Map.class);
Map<String, Object> data = response.getBody();
return data != null && Integer.valueOf(200).equals(data.get("code"));
} catch (Exception e) {
log.error("发送邮件异常: {}", e.getMessage());
return false;
}
}
/**
* 生成 HTML 邮件内容
*/
private String buildEmailHtml(String domain, long daysLeft, LocalDateTime expireDate) {
String color;
String level;
if (daysLeft <= 5) {
color = "#F56C6C"; level = "危险";
} else if (daysLeft <= 10) {
color = "#E6A23C"; level = "警告";
} else {
color = "#409EFF"; level = "提醒";
}
return String.format(
"<!DOCTYPE html>" +
"<html><body style=\"font-family:'Microsoft YaHei',Arial,sans-serif;\">" +
"<div style=\"max-width:600px;margin:0 auto;padding:20px;border:1px solid #EBEEF5;border-radius:8px;\">" +
"<div style=\"background:%s;padding:16px;border-radius:8px 8px 0 0;text-align:center;\">" +
"<h2 style=\"color:#fff;margin:0;\">⚠️ SSL证书即将到期 [%s]</h2></div>" +
"<div style=\"padding:20px;\">" +
"<table style=\"width:100%%;border-collapse:collapse;\">" +
"<tr><td style=\"padding:8px 0;color:#909399;width:80px;\">域名</td>" +
"<td style=\"padding:8px 0;font-weight:bold;font-size:16px;\">%s</td></tr>" +
"<tr><td style=\"padding:8px 0;color:#909399;\">到期时间</td>" +
"<td style=\"padding:8px 0;\">%s</td></tr>" +
"<tr><td style=\"padding:8px 0;color:#909399;\">剩余天数</td>" +
"<td style=\"padding:8px 0;color:%s;font-weight:bold;font-size:20px;\">%d 天</td></tr>" +
"<tr><td style=\"padding:8px 0;color:#909399;\">证书类型</td>" +
"<td style=\"padding:8px 0;\">Let's Encrypt (阿里云DNS验证)</td></tr>" +
"</table>" +
"<hr style=\"border:0;border-top:1px solid #EBEEF5;margin:16px 0;\">" +
"<p style=\"color:#909399;font-size:13px;\">系统已配置 acme.sh 自动续期。" +
"如续期失败请检查阿里云 AccessKey 和网络连通性。</p>" +
"<p style=\"color:#C0C4CC;font-size:12px;\">此邮件由 Nginx域名管理平台自动发送</p>" +
"</div></div></body></html>",
color, level, domain,
expireDate.toLocalDate().toString(),
color, daysLeft);
}
}

View File

@@ -0,0 +1,208 @@
package com.mokee.nginx.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.mokee.nginx.entity.Certificate;
import com.mokee.nginx.mapper.CertificateMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* SSL证书管理服务只读模式
* 数据来源:宝塔面板 ACME 证书订单 API
* 签发/续期请在宝塔面板上操作
*/
@Slf4j
@Service
public class CertificateService {
@Autowired
private CertificateMapper certificateMapper;
@Autowired
private BTPanelApiClient btPanelApiClient;
/**
* 获取所有证书列表(宝塔数据 + 本地通知配置合并)
*/
public List<Certificate> listCertificates() {
List<Certificate> certs = new ArrayList<>();
long index = 1;
try {
List<Map<String, Object>> orders = btPanelApiClient.getCertOrders();
if (orders == null || orders.isEmpty()) {
log.info("宝塔证书订单列表为空");
return certs;
}
log.info("宝塔证书订单数量: {}", orders.size());
for (Map<String, Object> order : orders) {
@SuppressWarnings("unchecked")
List<String> domains = (List<String>) order.get("domains");
String domain = (domains != null && !domains.isEmpty())
? domains.get(0) : "未知域名";
// 过期时间Unix时间戳
Object expiresObj = order.get("cert_timeout");
if (expiresObj == null) {
expiresObj = order.get("expires");
}
LocalDateTime expireDate = null;
if (expiresObj instanceof Number) {
long expiresTs = ((Number) expiresObj).longValue();
expireDate = LocalDateTime.ofInstant(
Instant.ofEpochSecond(expiresTs), ZoneId.of("Asia/Shanghai"));
}
String orderStatus = String.valueOf(order.getOrDefault("status", "unknown"));
String status = "valid".equals(orderStatus) ? "active" : orderStatus;
// 查找本地数据库中该域名的通知配置
Certificate localCert = certificateMapper.selectOne(
new LambdaQueryWrapper<Certificate>()
.eq(Certificate::getDomain, domain));
Certificate cert = Certificate.builder()
.domain(domain)
.certType("letsencrypt")
.status(status)
.expireDate(expireDate)
.build();
// 合并本地通知配置
if (localCert != null) {
cert.setId(localCert.getId());
cert.setNotifyEmail(localCert.getNotifyEmail());
cert.setNotifyEnabled(localCert.getNotifyEnabled());
cert.setNotifyDaysBefore(localCert.getNotifyDaysBefore());
} else {
cert.setId(index);
cert.setNotifyEnabled(0); // 默认未配置通知
}
index++;
certs.add(cert);
}
log.info("从宝塔获取到 {} 条证书记录(已合并本地通知配置)", certs.size());
} catch (Exception e) {
log.error("从宝塔获取证书列表失败: {}", e.getMessage());
throw new RuntimeException("获取证书列表失败: " + e.getMessage());
}
return certs;
}
/**
* 获取单个证书详情
*/
public Certificate getCertificate(Long id) {
// 从列表中匹配宝塔数据源下id是序号非持久化
List<Certificate> allCerts = listCertificates();
return allCerts.stream()
.filter(c -> c.getId().equals(id))
.findFirst()
.orElseThrow(() -> new RuntimeException("证书不存在: id=" + id));
}
/**
* 更新通知配置(本地数据库操作,不影响宝塔)
*/
@Transactional
public Certificate updateNotifyConfig(Long id, String notifyEmail,
Integer notifyEnabled, Integer notifyDaysBefore) {
// 通知配置存本地数据库,按域名匹配
List<Certificate> allCerts = listCertificates();
Certificate btCert = allCerts.stream()
.filter(c -> c.getId().equals(id))
.findFirst()
.orElseThrow(() -> new RuntimeException("证书不存在: id=" + id));
// 查找或创建本地通知配置记录
Certificate localCert = certificateMapper.selectOne(
new LambdaQueryWrapper<Certificate>()
.eq(Certificate::getDomain, btCert.getDomain()));
if (localCert == null) {
localCert = new Certificate();
localCert.setDomain(btCert.getDomain());
localCert.setCertType(btCert.getCertType());
localCert.setStatus(btCert.getStatus());
localCert.setExpireDate(btCert.getExpireDate());
}
if (notifyEmail != null) {
localCert.setNotifyEmail(notifyEmail);
}
if (notifyEnabled != null) {
localCert.setNotifyEnabled(notifyEnabled);
}
if (notifyDaysBefore != null) {
if (notifyDaysBefore < 1 || notifyDaysBefore > 30) {
throw new RuntimeException("通知天数必须在 1~30 之间");
}
localCert.setNotifyDaysBefore(notifyDaysBefore);
}
if (localCert.getId() != null) {
certificateMapper.updateById(localCert);
} else {
certificateMapper.insert(localCert);
}
log.info("通知配置已更新: domain={}, email={}, enabled={}, daysBefore={}",
localCert.getDomain(), localCert.getNotifyEmail(),
localCert.getNotifyEnabled(), localCert.getNotifyDaysBefore());
return localCert;
}
// ═══════════════════════════════════════════════════════════
// 以下写操作已废弃 — 请在宝塔面板上操作
// ═══════════════════════════════════════════════════════════
/**
* @deprecated 证书签发请在宝塔面板上操作
*/
@Deprecated
public Certificate issueCertificate(Long id) {
throw new UnsupportedOperationException(
"证书签发请在宝塔面板上操作SSL → Let's Encrypt");
}
/**
* @deprecated 证书续期请在宝塔面板上操作
*/
@Deprecated
public Certificate renewCertificate(Long id) {
throw new UnsupportedOperationException(
"证书续期请在宝塔面板上操作SSL → 续期)");
}
/**
* @deprecated 证书记录同步已不再需要数据源为宝塔API
*/
@Deprecated
public void syncCertificateRecord(String domain, String certPath, String keyPath) {
log.info("syncCertificateRecord 已废弃数据源已切换至宝塔API: domain={}", domain);
}
/**
* @deprecated 缓存失效已不再需要数据源为宝塔API
*/
@Deprecated
public void invalidateCache() {
log.info("invalidateCache 已废弃数据源已切换至宝塔API");
}
}

View File

@@ -0,0 +1,208 @@
package com.mokee.nginx.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.mokee.nginx.entity.DnsRecord;
import com.mokee.nginx.mapper.DnsRecordMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
/**
* DNS 记录业务层 — 阿里云 DNS 为权威数据源,本地 dns_records 表仅做操作审计
*
* 核心流程:
* - 列表查询:阿里云 API → 本地表按 record_id 合并审计字段 → 返回富化结果
* - 新增/修改/删除:先操作阿里云 → 再同步本地审计表
*/
@Slf4j
@Service
public class DnsRecordService {
@Autowired
private DnsRecordMapper dnsRecordMapper;
@Autowired
private PythonApiClient pythonApiClient;
/**
* 获取 DNS 解析记录列表(含本地审计信息)
*
* @param zone 主域名,如 zgitm.com
* @return 富化后的记录列表,每条包含 createdBy/createdAt/updatedBy/updatedAt
*/
@SuppressWarnings("unchecked")
public List<Map<String, Object>> listRecords(String zone) {
// 1. 从阿里云获取权威 DNS 记录
Map<String, Object> aliyunResult = pythonApiClient.listDnsRecords(zone);
Object dataObj = aliyunResult.get("data");
List<Map<String, Object>> aliyunRecords;
if (dataObj instanceof List) {
aliyunRecords = (List<Map<String, Object>>) dataObj;
} else if (dataObj instanceof Map) {
// 兼容 Python API 返回格式:{ data: { data: [...] } }
Object inner = ((Map<String, Object>) dataObj).get("data");
if (inner instanceof List) {
aliyunRecords = (List<Map<String, Object>>) inner;
} else {
aliyunRecords = new ArrayList<>();
}
} else {
aliyunRecords = new ArrayList<>();
}
if (aliyunRecords.isEmpty()) {
return aliyunRecords;
}
// 2. 查询本地审计表
List<String> recordIds = aliyunRecords.stream()
.map(r -> String.valueOf(r.get("record_id")))
.filter(id -> !id.isEmpty())
.collect(Collectors.toList());
Map<String, DnsRecord> auditMap = new HashMap<>();
if (!recordIds.isEmpty()) {
List<DnsRecord> localRecords = dnsRecordMapper.selectList(
new LambdaQueryWrapper<DnsRecord>()
.in(DnsRecord::getRecordId, recordIds)
.eq(DnsRecord::getStatus, 1));
auditMap = localRecords.stream()
.collect(Collectors.toMap(DnsRecord::getRecordId, r -> r, (a, b) -> a));
}
// 3. 合并审计字段
for (Map<String, Object> record : aliyunRecords) {
String recordId = String.valueOf(record.get("record_id"));
DnsRecord audit = auditMap.get(recordId);
if (audit != null) {
record.put("createdBy", audit.getCreatedBy());
record.put("createdAt", audit.getCreatedAt());
record.put("updatedBy", audit.getUpdatedBy());
record.put("updatedAt", audit.getUpdatedAt());
} else {
record.put("createdBy", null);
record.put("createdAt", null);
record.put("updatedBy", null);
record.put("updatedAt", null);
}
}
// 4. 按创建时间倒序排列(有审计数据的优先)
aliyunRecords.sort((a, b) -> {
Object ta = a.get("createdAt");
Object tb = b.get("createdAt");
if (ta == null && tb == null) return 0;
if (ta == null) return 1;
if (tb == null) return -1;
return String.valueOf(tb).compareTo(String.valueOf(ta));
});
return aliyunRecords;
}
/**
* 添加 DNS 解析记录(阿里云 + 本地审计)
*/
@Transactional
public Map<String, Object> addRecord(Map<String, Object> body) {
// 1. 调用阿里云 API 创建
Map<String, Object> result = pythonApiClient.addDnsRecord(body);
// 2. 提取 record_id 并存本地审计
Object dataObj = result.get("data");
String recordId = null;
if (dataObj instanceof Map) {
recordId = String.valueOf(((Map<?, ?>) dataObj).get("record_id"));
}
if (recordId != null && !recordId.isEmpty() && !"null".equals(recordId)) {
DnsRecord localRecord = DnsRecord.builder()
.recordId(recordId)
.domainZone(String.valueOf(body.getOrDefault("zone", "")))
.rr(String.valueOf(body.getOrDefault("rr", "")))
.recordType(String.valueOf(body.getOrDefault("type", "A")))
.recordValue(String.valueOf(body.getOrDefault("value", "")))
.ttl(body.get("ttl") instanceof Integer ? (Integer) body.get("ttl") : 600)
.status(1)
.build();
dnsRecordMapper.insert(localRecord);
log.info("DNS 审计记录已保存: recordId={}, rr={}.{}", recordId, localRecord.getRr(), localRecord.getDomainZone());
}
return result;
}
/**
* 修改 DNS 解析记录(阿里云 + 本地审计)
*/
@Transactional
public Map<String, Object> updateRecord(String recordId, Map<String, Object> body) {
// 1. 调用阿里云 API 更新
Map<String, Object> result = pythonApiClient.updateDnsRecord(recordId, body);
// 2. 更新本地审计记录
DnsRecord localRecord = dnsRecordMapper.selectOne(
new LambdaQueryWrapper<DnsRecord>()
.eq(DnsRecord::getRecordId, recordId)
.eq(DnsRecord::getStatus, 1));
if (localRecord != null) {
if (body.containsKey("rr")) {
localRecord.setRr(String.valueOf(body.get("rr")));
}
if (body.containsKey("type")) {
localRecord.setRecordType(String.valueOf(body.get("type")));
}
if (body.containsKey("value")) {
localRecord.setRecordValue(String.valueOf(body.get("value")));
}
if (body.containsKey("ttl")) {
localRecord.setTtl(body.get("ttl") instanceof Integer ? (Integer) body.get("ttl") : 600);
}
// updatedBy/updatedAt 由 MyMetaObjectHandler 自动填充
dnsRecordMapper.updateById(localRecord);
log.info("DNS 审计记录已更新: recordId={}", recordId);
} else {
// 本地无记录但有阿里云记录(可能是旧数据),尝试补建
log.info("DNS 审计记录不存在,尝试补建: recordId={}", recordId);
// 从 body 推断信息
DnsRecord newAudit = DnsRecord.builder()
.recordId(recordId)
.domainZone(String.valueOf(body.getOrDefault("zone", "")))
.rr(String.valueOf(body.getOrDefault("rr", "")))
.recordType(String.valueOf(body.getOrDefault("type", "A")))
.recordValue(String.valueOf(body.getOrDefault("value", "")))
.ttl(body.get("ttl") instanceof Integer ? (Integer) body.get("ttl") : 600)
.status(1)
.build();
dnsRecordMapper.insert(newAudit);
}
return result;
}
/**
* 删除 DNS 解析记录(阿里云 + 本地软删除)
*/
@Transactional
public Map<String, Object> deleteRecord(String recordId) {
// 1. 调用阿里云 API 删除
Map<String, Object> result = pythonApiClient.deleteDnsRecord(recordId);
// 2. 本地软删除
DnsRecord localRecord = dnsRecordMapper.selectOne(
new LambdaQueryWrapper<DnsRecord>()
.eq(DnsRecord::getRecordId, recordId)
.eq(DnsRecord::getStatus, 1));
if (localRecord != null) {
dnsRecordMapper.deleteById(localRecord.getId());
log.info("DNS 审计记录已软删除: recordId={}", recordId);
}
return result;
}
}

View File

@@ -0,0 +1,290 @@
package com.mokee.nginx.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mokee.nginx.entity.ForwardingRule;
import com.mokee.nginx.mapper.ForwardingRuleMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 转发规则业务逻辑层(只读模式)
* 数据来源:宝塔面板 API
* 新增/修改/删除操作请在宝塔面板上操作
*/
@Slf4j
@Service
public class ForwardingRuleService {
@Autowired
private ForwardingRuleMapper forwardingRuleMapper;
@Autowired
private BTPanelApiClient btPanelApiClient;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* 获取所有转发规则(从宝塔面板拉取)
*
* 流程:
* 1. POST /data action=getData&table=sites → 获取站点列表
* 2. 遍历站点project_type="proxy" 的站点直接解析 project_config
* 3. 非 proxy 站点调 POST /site action=GetProxyList 检查是否有 nginx 级反代
*/
@SuppressWarnings("unchecked")
public List<ForwardingRule> listRules() {
List<ForwardingRule> rules = new ArrayList<>();
long index = 1;
try {
// 1. 获取站点列表
Map<String, Object> siteResult = btPanelApiClient.getSiteList();
List<Map<String, Object>> sites = siteResult != null
? (List<Map<String, Object>>) siteResult.get("data")
: null;
if (sites == null || sites.isEmpty()) {
log.info("宝塔站点列表为空");
return rules;
}
log.info("宝塔站点数量: {}", sites.size());
// 2. 遍历每个站点
for (Map<String, Object> site : sites) {
String siteName = (String) site.get("name");
if (siteName == null || siteName.isEmpty()) continue;
boolean isRunning = "1".equals(String.valueOf(site.getOrDefault("status", "1")));
String projectType = (String) site.getOrDefault("project_type", "");
// 统一通过 GetProxyList 获取反代规则(适用于 proxy 类型和普通站点)
List<Map<String, Object>> proxies = btPanelApiClient.getProxyList(siteName);
if (proxies != null && !proxies.isEmpty()) {
for (Map<String, Object> proxy : proxies) {
ForwardingRule rule = buildRuleFromProxyData(
index++, siteName, site, proxy, isRunning);
rules.add(rule);
}
} else if ("proxy".equals(projectType)) {
// proxy 类型但 GetProxyList 为空:降级显示
ForwardingRule rule = ForwardingRule.builder()
.id(index++)
.domain(siteName)
.protocol("https")
.target("[反代项目 - 点击查看详情]")
.status(isRunning ? 1 : 0)
.build();
rules.add(rule);
}
// 非 proxy 且无反代规则的站点不显示
}
log.info("从宝塔获取到 {} 条转发规则", rules.size());
} catch (Exception e) {
log.error("从宝塔获取转发规则失败: {}", e.getMessage());
throw new RuntimeException("获取转发规则失败: " + e.getMessage());
}
return rules;
}
/**
* 从宝塔"反向代理项目"中解析转发规则
* project_config 包含: proxy_pass, domain_list, https_port 等
*/
private ForwardingRule buildRuleFromProxyProject(long id, String siteName,
Map<String, Object> site, boolean isRunning) {
try {
String configJson = (String) site.get("project_config");
if (configJson == null || configJson.isEmpty()) return null;
Map<String, Object> config = objectMapper.readValue(configJson, Map.class);
// 提取目标地址
String proxyPass = (String) config.get("proxy_pass");
if (proxyPass == null || proxyPass.isEmpty()) {
// 可能存的是 proxy_dir 格式
Object proxyDirObj = config.get("proxy_dir");
if (proxyDirObj instanceof List && !((List<?>) proxyDirObj).isEmpty()) {
Map<String, Object> firstProxy = (Map<String, Object>) ((List<?>) proxyDirObj).get(0);
proxyPass = (String) firstProxy.get("proxy_pass");
}
}
String target = proxyPass != null ? proxyPass : "未知目标";
// 协议判断:有 https_port 配置则为 https
String httpsPort = (String) config.get("https_port");
String protocol = (httpsPort != null && !httpsPort.isEmpty()) ? "https" : "http";
return ForwardingRule.builder()
.id(id)
.domain(siteName)
.protocol(protocol)
.target(target)
.status(isRunning ? 1 : 0)
.build();
} catch (Exception e) {
log.warn("解析 {} 的 project_config 失败: {}", siteName, e.getMessage());
// 降级仍然显示站点target 标记为未知
return ForwardingRule.builder()
.id(id)
.domain(siteName)
.protocol("http")
.target("解析失败,请查看宝塔面板")
.status(isRunning ? 1 : 0)
.build();
}
}
/**
* 从 nginx 级反向代理数据中组装规则
*/
private ForwardingRule buildRuleFromProxyData(long id, String siteName,
Map<String, Object> site, Map<String, Object> proxy, boolean isRunning) {
String proxysite = (String) proxy.getOrDefault("proxysite", "");
String proxydir = (String) proxy.getOrDefault("proxydir", "/");
String protocol = proxysite.startsWith("https://") ? "https" : "http";
String target = proxysite;
if (!"/".equals(proxydir) && proxydir != null && !proxydir.isEmpty()) {
target = proxysite + " (路径: " + proxydir + ")";
}
return ForwardingRule.builder()
.id(id)
.domain(siteName)
.protocol(protocol)
.target(target)
.status(isRunning ? 1 : 0)
.build();
}
// ═══════════════════════════════════════════════════════════
// 以下写操作已废弃
// ═══════════════════════════════════════════════════════════
@Deprecated
public ForwardingRule addRule(Object request) {
throw new UnsupportedOperationException("新增转发规则请在宝塔面板上操作");
}
@Deprecated
public ForwardingRule updateRule(Long id, Object request) {
throw new UnsupportedOperationException("修改转发规则请在宝塔面板上操作");
}
@Deprecated
public void deleteRule(Long id) {
throw new UnsupportedOperationException("删除转发规则请在宝塔面板上操作");
}
@Deprecated
public void reloadNginx() {
throw new UnsupportedOperationException("重载Nginx请在宝塔面板上操作");
}
/**
* 获取规则详情:配置文件内容 + SSL证书信息
*/
public Map<String, Object> getConfig(Long id) {
// 从列表中匹配域名
List<ForwardingRule> rules = listRules();
ForwardingRule rule = rules.stream()
.filter(r -> r.getId().equals(id))
.findFirst()
.orElseThrow(() -> new RuntimeException("规则不存在: id=" + id));
String domain = rule.getDomain();
Map<String, Object> result = new java.util.LinkedHashMap<>();
result.put("domain", domain);
result.put("protocol", rule.getProtocol());
result.put("target", rule.getTarget());
// 获取 nginx 配置文件内容
try {
String configContent = btPanelApiClient.getNginxConfig(domain);
result.put("configContent", configContent != null ? configContent : "");
} catch (Exception e) {
log.warn("获取 {} 配置文件失败: {}", domain, e.getMessage());
result.put("configContent", "");
}
// 获取 SSL 证书信息
try {
List<Map<String, Object>> orders = btPanelApiClient.getCertOrders();
Map<String, Object> matchedCert = null;
if (orders != null) {
for (Map<String, Object> order : orders) {
@SuppressWarnings("unchecked")
List<String> domains = (List<String>) order.get("domains");
if (domains != null && domains.contains(domain)) {
matchedCert = order;
break;
}
}
}
if (matchedCert != null) {
result.put("sslStatus", String.valueOf(matchedCert.getOrDefault("status", "unknown")));
Object expiresObj = matchedCert.get("cert_timeout");
if (expiresObj == null) expiresObj = matchedCert.get("expires");
if (expiresObj instanceof Number) {
long expiresTs = ((Number) expiresObj).longValue();
java.time.LocalDateTime expireDate = java.time.LocalDateTime.ofInstant(
java.time.Instant.ofEpochSecond(expiresTs),
java.time.ZoneId.of("Asia/Shanghai"));
result.put("sslExpireDate", expireDate.toString());
long daysLeft = java.time.Duration.between(
java.time.LocalDateTime.now(), expireDate).toDays();
result.put("sslDaysLeft", daysLeft);
}
result.put("sslAuthType", String.valueOf(matchedCert.getOrDefault("auth_type", "")));
} else {
result.put("sslStatus", "none");
}
} catch (Exception e) {
log.warn("获取 {} SSL信息失败: {}", domain, e.getMessage());
result.put("sslStatus", "error");
}
return result;
}
@Deprecated
public Map<String, Object> updateConfig(Long id, String content) {
throw new UnsupportedOperationException("配置文件编辑已不可用");
}
@Deprecated
public Map<String, Object> getSslFiles(Long id) {
throw new UnsupportedOperationException("SSL证书查看请前往宝塔面板");
}
@Deprecated
public Map<String, Object> getGzipConfig(Long id) {
throw new UnsupportedOperationException("内容压缩配置请前往宝塔面板查看");
}
@Deprecated
public Map<String, Object> updateGzipConfig(Long id, Map<String, Object> gzipConfig) {
throw new UnsupportedOperationException("内容压缩配置请前往宝塔面板修改");
}
@Deprecated
public Map<String, Object> getDomainLogs(Long id, int lines) {
throw new UnsupportedOperationException("日志查看请前往宝塔面板");
}
@Deprecated
public Map<String, Object> getNginxStatus() {
throw new UnsupportedOperationException("Nginx状态请前往宝塔面板查看");
}
}

View File

@@ -0,0 +1,421 @@
package com.mokee.nginx.service;
import com.mokee.nginx.dto.RuleRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.List;
import java.util.Map;
/**
* Python API HTTP 客户端
* 调用 10.1.1.160:5000 上的 Nginx 管理 API
*/
@Slf4j
@Service
public class PythonApiClient {
@Autowired
private RestTemplate restTemplate;
/** 证书操作专用(签发/续期超时 5分钟 */
@Autowired
@org.springframework.beans.factory.annotation.Qualifier("certRestTemplate")
private RestTemplate certRestTemplate;
/**
* 获取所有Nginx转发规则
*/
public Map<String, Object> getRules() {
try {
ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
"/api/nginx/rules",
HttpMethod.GET,
null,
new ParameterizedTypeReference<>() {}
);
return response.getBody();
} catch (Exception e) {
log.error("调用Python API获取规则列表失败: {}", e.getMessage());
throw new RuntimeException("无法连接到Nginx管理服务: " + e.getMessage());
}
}
/**
* 新增转发规则HTTPS 可能触发 LE 签发,用长超时 RestTemplate
*/
public Map<String, Object> addRule(RuleRequest request) {
try {
Map<String, Object> body = Map.of(
"domain", request.getDomain(),
"protocol", request.getProtocol(),
"target", request.getTarget()
);
// HTTPS + zgitm 域名会触发 LE 签发40s+),用长超时
RestTemplate rt = "https".equals(request.getProtocol()) ? certRestTemplate : restTemplate;
ResponseEntity<Map<String, Object>> response = rt.exchange(
"/api/nginx/rules",
HttpMethod.POST,
new HttpEntity<>(body),
new ParameterizedTypeReference<>() {}
);
return response.getBody();
} catch (Exception e) {
log.error("调用Python API新增规则失败: {}", e.getMessage());
throw new RuntimeException("新增Nginx规则失败: " + e.getMessage());
}
}
/**
* 修改转发规则HTTPS 可能触发 LE 签发,用长超时 RestTemplate
*/
public Map<String, Object> updateRule(String domain, RuleRequest request) {
try {
Map<String, Object> body = Map.of(
"domain", request.getDomain(),
"protocol", request.getProtocol(),
"target", request.getTarget()
);
RestTemplate rt = "https".equals(request.getProtocol()) ? certRestTemplate : restTemplate;
ResponseEntity<Map<String, Object>> response = rt.exchange(
"/api/nginx/rules/{domain}",
HttpMethod.PUT,
new HttpEntity<>(body),
new ParameterizedTypeReference<>() {},
domain
);
return response.getBody();
} catch (Exception e) {
log.error("调用Python API修改规则失败: {}", e.getMessage());
throw new RuntimeException("修改Nginx规则失败: " + e.getMessage());
}
}
/**
* 删除转发规则
*/
public Map<String, Object> deleteRule(String domain) {
try {
ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
"/api/nginx/rules/{domain}",
HttpMethod.DELETE,
null,
new ParameterizedTypeReference<>() {},
domain
);
return response.getBody();
} catch (Exception e) {
log.error("调用Python API删除规则失败: {}", e.getMessage());
throw new RuntimeException("删除Nginx规则失败: " + e.getMessage());
}
}
/**
* 重载Nginx配置
*/
public Map<String, Object> reloadNginx() {
try {
ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
"/api/nginx/reload",
HttpMethod.POST,
null,
new ParameterizedTypeReference<>() {}
);
return response.getBody();
} catch (Exception e) {
log.error("调用Python API重载Nginx失败: {}", e.getMessage());
throw new RuntimeException("重载Nginx配置失败: " + e.getMessage());
}
}
/**
* 获取指定域名的 Nginx 配置文件原始内容
*/
public Map<String, Object> getRuleConfig(String domain) {
try {
ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
"/api/nginx/rules/{domain}/config",
HttpMethod.GET,
null,
new ParameterizedTypeReference<>() {},
domain
);
return response.getBody();
} catch (Exception e) {
log.error("获取配置文件失败: {}", e.getMessage());
throw new RuntimeException("获取配置文件失败: " + e.getMessage());
}
}
/**
* 直接写入 Nginx 配置文件(会触发语法检查和重载,失败自动回滚)
*/
public Map<String, Object> updateRuleConfig(String domain, String content) {
try {
Map<String, Object> body = Map.of("content", content);
ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
"/api/nginx/rules/{domain}/config",
HttpMethod.PUT,
new HttpEntity<>(body),
new ParameterizedTypeReference<>() {},
domain
);
return response.getBody();
} catch (Exception e) {
log.error("更新配置文件失败: {}", e.getMessage());
throw new RuntimeException("更新配置文件失败: " + e.getMessage());
}
}
/**
* 获取指定域名的 SSL 证书和密钥文件内容
*/
public Map<String, Object> getSslFiles(String domain) {
try {
ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
"/api/nginx/rules/{domain}/ssl",
HttpMethod.GET,
null,
new ParameterizedTypeReference<>() {},
domain
);
return response.getBody();
} catch (Exception e) {
log.error("获取SSL文件失败: {}", e.getMessage());
throw new RuntimeException("获取SSL文件失败: " + e.getMessage());
}
}
// ═══════════════════════════════════════════════════════════
// 内容压缩gzipAPI
// ═══════════════════════════════════════════════════════════
/**
* 获取指定域名的 gzip 压缩配置
*/
public Map<String, Object> getGzipConfig(String domain) {
try {
ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
"/api/nginx/rules/{domain}/gzip",
HttpMethod.GET,
null,
new ParameterizedTypeReference<>() {},
domain
);
return response.getBody();
} catch (Exception e) {
log.error("获取gzip配置失败: {}", e.getMessage());
throw new RuntimeException("获取gzip配置失败: " + e.getMessage());
}
}
/**
* 更新指定域名的 gzip 压缩配置
*/
public Map<String, Object> updateGzipConfig(String domain, Map<String, Object> gzipConfig) {
try {
ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
"/api/nginx/rules/{domain}/gzip",
HttpMethod.PUT,
new HttpEntity<>(gzipConfig),
new ParameterizedTypeReference<>() {},
domain
);
return response.getBody();
} catch (Exception e) {
log.error("更新gzip配置失败: {}", e.getMessage());
throw new RuntimeException("更新gzip配置失败: " + e.getMessage());
}
}
/**
* 获取指定域名的 Nginx 日志
*/
public Map<String, Object> getDomainLogs(String domain, int lines) {
try {
String path = "/api/nginx/rules/" + domain + "/logs?lines=" + lines;
ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
path,
HttpMethod.GET,
null,
new ParameterizedTypeReference<>() {}
);
return response.getBody();
} catch (Exception e) {
log.error("获取域名日志失败: {}", e.getMessage());
throw new RuntimeException("获取域名日志失败: " + e.getMessage());
}
}
/**
* 健康检查
*/
public boolean healthCheck() {
try {
ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
"/api/nginx/health",
HttpMethod.GET,
null,
new ParameterizedTypeReference<>() {}
);
Map<String, Object> body = response.getBody();
return body != null && Integer.valueOf(0).equals(body.get("code"));
} catch (Exception e) {
log.warn("Python API健康检查失败: {}", e.getMessage());
return false;
}
}
// ═══════════════════════════════════════════════════════════
// 证书管理 APILet's Encrypt
// ═══════════════════════════════════════════════════════════
/**
* 获取单个域名证书信息
*/
public Map<String, Object> getCertInfo(String domain) {
try {
ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
"/api/nginx/certs/{domain}",
HttpMethod.GET,
null,
new ParameterizedTypeReference<>() {},
domain
);
Map<String, Object> body = response.getBody();
return body != null ? (Map<String, Object>) body.get("data") : null;
} catch (Exception e) {
log.error("获取证书信息失败: {}", e.getMessage());
throw new RuntimeException("获取证书信息失败: " + e.getMessage());
}
}
/**
* 签发 Let's Encrypt 证书(超时: 5分钟
*/
public Map<String, Object> issueCert(String domain) {
try {
ResponseEntity<Map<String, Object>> response = certRestTemplate.exchange(
"/api/nginx/certs/{domain}/issue",
HttpMethod.POST,
null,
new ParameterizedTypeReference<>() {},
domain
);
return response.getBody();
} catch (Exception e) {
log.error("签发证书失败: {}", e.getMessage());
throw new RuntimeException("签发证书失败: " + e.getMessage());
}
}
/**
* 续期 Let's Encrypt 证书(超时: 5分钟
*/
public Map<String, Object> renewCert(String domain) {
try {
ResponseEntity<Map<String, Object>> response = certRestTemplate.exchange(
"/api/nginx/certs/{domain}/renew",
HttpMethod.POST,
null,
new ParameterizedTypeReference<>() {},
domain
);
return response.getBody();
} catch (Exception e) {
log.error("续期证书失败: {}", e.getMessage());
throw new RuntimeException("续期证书失败: " + e.getMessage());
}
}
/**
* 获取所有 LE 证书信息列表
*/
public Map<String, Object> listCerts() {
try {
ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
"/api/nginx/certs",
HttpMethod.GET,
null,
new ParameterizedTypeReference<>() {}
);
return response.getBody();
} catch (Exception e) {
log.error("获取证书列表失败: {}", e.getMessage());
throw new RuntimeException("获取证书列表失败: " + e.getMessage());
}
}
// ═══════════════════════════════════════════════════════════
// DNS 解析管理 API
// ═══════════════════════════════════════════════════════════
/**
* 获取 DNS 记录列表
*/
public Map<String, Object> listDnsRecords(String zone) {
try {
String path = "/api/nginx/dns/records?zone=" + zone;
ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
path, HttpMethod.GET, null,
new ParameterizedTypeReference<>() {});
return response.getBody();
} catch (Exception e) {
log.error("获取DNS记录失败: {}", e.getMessage());
throw new RuntimeException("获取DNS记录失败: " + e.getMessage());
}
}
/**
* 添加 DNS 记录
*/
public Map<String, Object> addDnsRecord(Map<String, Object> body) {
try {
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body);
ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
"/api/nginx/dns/records", HttpMethod.POST, request,
new ParameterizedTypeReference<>() {});
return response.getBody();
} catch (Exception e) {
log.error("添加DNS记录失败: {}", e.getMessage());
throw new RuntimeException("添加DNS记录失败: " + e.getMessage());
}
}
/**
* 删除 DNS 记录
*/
public Map<String, Object> deleteDnsRecord(String recordId) {
try {
ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
"/api/nginx/dns/records/{recordId}", HttpMethod.DELETE, null,
new ParameterizedTypeReference<>() {}, recordId);
return response.getBody();
} catch (Exception e) {
log.error("删除DNS记录失败: {}", e.getMessage());
throw new RuntimeException("删除DNS记录失败: " + e.getMessage());
}
}
/**
* 修改 DNS 记录
*/
public Map<String, Object> updateDnsRecord(String recordId, Map<String, Object> body) {
try {
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body);
ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
"/api/nginx/dns/records/{recordId}", HttpMethod.PUT, request,
new ParameterizedTypeReference<>() {}, recordId);
return response.getBody();
} catch (Exception e) {
log.error("修改DNS记录失败: {}", e.getMessage());
throw new RuntimeException("修改DNS记录失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,16 @@
# ==========================================
# PROD 生产环境配置
# 激活方式: mvn spring-boot:run -Pprod
# 或 java -jar xxx.jar --spring.profiles.active=prod
# ==========================================
spring:
datasource:
url: jdbc:mysql://10.1.1.122:3306/nginxserver?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: nginxserver
password: mokee.
driver-class-name: com.mysql.cj.jdbc.Driver
logging:
level:
com.mokee.nginx: INFO
org.springframework.web: WARN

View File

@@ -0,0 +1,15 @@
# ==========================================
# QA 测试环境配置
# 激活方式: mvn spring-boot:run -Pqa
# 或 java -jar xxx.jar --spring.profiles.active=qa
# ==========================================
spring:
datasource:
url: jdbc:mysql://10.1.1.122:3306/nginxserver?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: nginxserver
password: mokee.
driver-class-name: com.mysql.cj.jdbc.Driver
logging:
level:
com.mokee.nginx: DEBUG

View File

@@ -0,0 +1,47 @@
# ==========================================
# 基础配置(所有环境共享)
# ==========================================
server:
port: 32402
spring:
application:
name: nginx-manager
# 由 pom.xml Maven profile 控制编译时替换qa 或 prod
profiles:
active: @spring.profiles.active@
# Python Nginx API 连接配置(所有环境指向同一台 Nginx 服务器)
python-api:
base-url: http://101.132.183.138:32210
connect-timeout: 5000
read-timeout: 30000
cert-read-timeout: 300000
# 宝塔面板 API 配置(只读:仅获取反代列表和证书列表)
bt-panel:
base-url: https://101.132.183.138:18888
api-key: ahv5RRcYm3Vi2PzEFvqYsZvsS8LeGkYp
# Mokee Gateway 对外开放 API 配置(证书邮件通知用)
gateway:
base-url: https://gw.server.zgitm.com
system-code: nginxServer
# MyBatis-Plus 配置
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
type-aliases-package: com.mokee.nginx.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
global-config:
db-config:
id-type: auto
# 日志配置
logging:
level:
com.mokee.nginx: DEBUG
org.springframework.web: INFO