diff --git a/backend/.flattened-pom.xml b/backend/.flattened-pom.xml
index c97e1af..ce445d4 100644
--- a/backend/.flattened-pom.xml
+++ b/backend/.flattened-pom.xml
@@ -28,6 +28,7 @@
1.5.0
UTF-8
1.2.83
+ 6.4.0
true
8.7.3-20251210
0.15.0
@@ -237,6 +238,11 @@
ip2region
${ip2region.version}
+
+ com.github.oshi
+ oshi-core
+ ${oshi.version}
+
com.alibaba
fastjson
diff --git a/backend/pangu-modules/pangu-business/sql/pangu_menu.sql b/backend/pangu-modules/pangu-business/sql/pangu_menu.sql
index cdffd15..5b1706b 100644
--- a/backend/pangu-modules/pangu-business/sql/pangu_menu.sql
+++ b/backend/pangu-modules/pangu-business/sql/pangu_menu.sql
@@ -45,15 +45,25 @@ INSERT INTO sys_menu VALUES (2204, '学生删除', 2200, 4, '', '', '', 1, 0, 'F
INSERT INTO sys_menu VALUES (2205, '学生导入', 2200, 5, '', '', '', 1, 0, 'F', '0', '0', 'business:student:import', '#', 103, 1, NOW(), NULL, NULL, '');
INSERT INTO sys_menu VALUES (2206, '学生导出', 2200, 6, '', '', '', 1, 0, 'F', '0', '0', 'business:student:export', '#', 103, 1, NOW(), NULL, NULL, '');
--- ===================== 应用管理(一级菜单,order_num=4) =====================
+-- ===================== 应用管理(一级目录,order_num=4,下挂 应用列表 + 接口字典) =====================
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query_param, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time)
-VALUES (2300, '应用管理', 0, 4, 'application', 'business/application/index', '', 1, 0, 'C', '0', '0', 'business:application:list', 'component', 103, 1, NOW());
-INSERT INTO sys_menu VALUES (2301, '应用查询', 2300, 1, '', '', '', 1, 0, 'F', '0', '0', 'business:application:query', '#', 103, 1, NOW(), NULL, NULL, '');
-INSERT INTO sys_menu VALUES (2302, '应用新增', 2300, 2, '', '', '', 1, 0, 'F', '0', '0', 'business:application:add', '#', 103, 1, NOW(), NULL, NULL, '');
-INSERT INTO sys_menu VALUES (2303, '应用修改', 2300, 3, '', '', '', 1, 0, 'F', '0', '0', 'business:application:edit', '#', 103, 1, NOW(), NULL, NULL, '');
-INSERT INTO sys_menu VALUES (2304, '应用删除', 2300, 4, '', '', '', 1, 0, 'F', '0', '0', 'business:application:remove', '#', 103, 1, NOW(), NULL, NULL, '');
-INSERT INTO sys_menu VALUES (2305, '重置密钥', 2300, 5, '', '', '', 1, 0, 'F', '0', '0', 'business:application:resetSecret', '#', 103, 1, NOW(), NULL, NULL, '');
-INSERT INTO sys_menu VALUES (2306, '接口授权', 2300, 6, '', '', '', 1, 0, 'F', '0', '0', 'business:application:api', '#', 103, 1, NOW(), NULL, NULL, '');
+VALUES (2300, '应用管理', 0, 4, 'application', NULL, '', 1, 0, 'M', '0', '0', '', 'component', 103, 1, NOW());
+-- 应用列表(二级菜单)
+INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query_param, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time)
+VALUES (2307, '应用列表', 2300, 1, 'list', 'business/application/index', '', 1, 0, 'C', '0', '0', 'business:application:list', '#', 103, 1, NOW());
+INSERT INTO sys_menu VALUES (2301, '应用查询', 2307, 1, '', '', '', 1, 0, 'F', '0', '0', 'business:application:query', '#', 103, 1, NOW(), NULL, NULL, '');
+INSERT INTO sys_menu VALUES (2302, '应用新增', 2307, 2, '', '', '', 1, 0, 'F', '0', '0', 'business:application:add', '#', 103, 1, NOW(), NULL, NULL, '');
+INSERT INTO sys_menu VALUES (2303, '应用修改', 2307, 3, '', '', '', 1, 0, 'F', '0', '0', 'business:application:edit', '#', 103, 1, NOW(), NULL, NULL, '');
+INSERT INTO sys_menu VALUES (2304, '应用删除', 2307, 4, '', '', '', 1, 0, 'F', '0', '0', 'business:application:remove', '#', 103, 1, NOW(), NULL, NULL, '');
+INSERT INTO sys_menu VALUES (2305, '重置密钥', 2307, 5, '', '', '', 1, 0, 'F', '0', '0', 'business:application:resetSecret', '#', 103, 1, NOW(), NULL, NULL, '');
+INSERT INTO sys_menu VALUES (2306, '接口授权', 2307, 6, '', '', '', 1, 0, 'F', '0', '0', 'business:application:api', '#', 103, 1, NOW(), NULL, NULL, '');
+-- 接口字典(二级菜单)
+INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query_param, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time)
+VALUES (2310, '接口字典', 2300, 2, 'apiDict', 'business/apiDict/index', '', 1, 0, 'C', '0', '0', 'business:apiDict:list', 'list', 103, 1, NOW());
+INSERT INTO sys_menu VALUES (2311, '接口字典查询', 2310, 1, '', '', '', 1, 0, 'F', '0', '0', 'business:apiDict:query', '#', 103, 1, NOW(), NULL, NULL, '');
+INSERT INTO sys_menu VALUES (2312, '接口字典新增', 2310, 2, '', '', '', 1, 0, 'F', '0', '0', 'business:apiDict:add', '#', 103, 1, NOW(), NULL, NULL, '');
+INSERT INTO sys_menu VALUES (2313, '接口字典修改', 2310, 3, '', '', '', 1, 0, 'F', '0', '0', 'business:apiDict:edit', '#', 103, 1, NOW(), NULL, NULL, '');
+INSERT INTO sys_menu VALUES (2314, '接口字典删除', 2310, 4, '', '', '', 1, 0, 'F', '0', '0', 'business:apiDict:remove', '#', 103, 1, NOW(), NULL, NULL, '');
-- ===================== 基础数据(一级目录,order_num=5) =====================
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query_param, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time)
diff --git a/backend/pangu-modules/pangu-business/sql/pangu_tables.sql b/backend/pangu-modules/pangu-business/sql/pangu_tables.sql
index 511e197..0b8f678 100644
--- a/backend/pangu-modules/pangu-business/sql/pangu_tables.sql
+++ b/backend/pangu-modules/pangu-business/sql/pangu_tables.sql
@@ -151,7 +151,7 @@ CREATE TABLE `pg_school` (
`school_id` bigint NOT NULL COMMENT '学校ID',
`school_code` varchar(32) NOT NULL COMMENT '学校编码',
`school_name` varchar(100) NOT NULL COMMENT '学校名称',
- `school_type` char(1) DEFAULT '1' COMMENT '学校类型(1小学 2初中 3高中 4完全中学)',
+ `school_type` char(1) DEFAULT '1' COMMENT '学校类型(1小学 2初中 3高中 4中专 5大学)',
`region_id` bigint DEFAULT NULL COMMENT '所属区域ID',
`region_path` varchar(500) DEFAULT NULL COMMENT '区域路径',
`address` varchar(500) DEFAULT NULL COMMENT '详细地址',
diff --git a/backend/pangu-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/impl/H5AuthServiceImpl.java b/backend/pangu-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/impl/H5AuthServiceImpl.java
index 66542f3..b2035d9 100644
--- a/backend/pangu-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/impl/H5AuthServiceImpl.java
+++ b/backend/pangu-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/impl/H5AuthServiceImpl.java
@@ -216,27 +216,33 @@ public class H5AuthServiceImpl implements H5AuthService {
// 发送短信
if (smsProperties.isEnabled()) {
- try {
- LinkedHashMap map = new LinkedHashMap<>(1);
- map.put("code", code);
- SmsBlend smsBlend = SmsFactory.getSmsBlend(smsProperties.getSmsConfigName());
- String templateId = "login".equals(type) ? smsProperties.getLoginTemplateId() : smsProperties.getRegisterTemplateId();
- SmsResponse smsResponse;
- if (StringUtils.isNotBlank(templateId)) {
- smsResponse = smsBlend.sendMessage(phone, templateId, map);
- } else {
- smsResponse = smsBlend.sendMessage(phone, map);
- }
- if (!smsResponse.isSuccess()) {
- log.error("短信发送失败: phone={}, response={}", phone, smsResponse);
+ SmsBlend smsBlend = SmsFactory.getSmsBlend(smsProperties.getSmsConfigName());
+ if (smsBlend == null) {
+ // 配置了启用但未配置短信实现(如生产未配 sms4j)时降级为测试模式,避免 NPE,验证码已写入 Redis 可正常登录
+ log.warn("短信服务未配置(smsBlend=null),降级为测试模式,验证码仅写入Redis并打印日志");
+ log.info("【测试模式】短信验证码: phone={}, code={}, type={}", phone, code, type);
+ } else {
+ try {
+ LinkedHashMap map = new LinkedHashMap<>(1);
+ map.put("code", code);
+ String templateId = "login".equals(type) ? smsProperties.getLoginTemplateId() : smsProperties.getRegisterTemplateId();
+ SmsResponse smsResponse;
+ if (StringUtils.isNotBlank(templateId)) {
+ smsResponse = smsBlend.sendMessage(phone, templateId, map);
+ } else {
+ smsResponse = smsBlend.sendMessage(phone, map);
+ }
+ if (!smsResponse.isSuccess()) {
+ log.error("短信发送失败: phone={}, response={}", phone, smsResponse);
+ throw new ServiceException("短信发送失败,请稍后重试");
+ }
+ log.info("短信发送成功: phone={}, type={}, ip={}", phone, type, clientIp);
+ } catch (ServiceException e) {
+ throw e;
+ } catch (Exception e) {
+ log.error("短信发送异常: phone={}, error={}", phone, e.getMessage());
throw new ServiceException("短信发送失败,请稍后重试");
}
- log.info("短信发送成功: phone={}, type={}, ip={}", phone, type, clientIp);
- } catch (ServiceException e) {
- throw e;
- } catch (Exception e) {
- log.error("短信发送异常: phone={}, error={}", phone, e.getMessage());
- throw new ServiceException("短信发送失败,请稍后重试");
}
} else {
// 测试模式,打印验证码到日志
@@ -823,27 +829,32 @@ public class H5AuthServiceImpl implements H5AuthService {
incrementCounter(dailyPhoneKey, Duration.ofDays(1));
if (smsProperties.isEnabled()) {
- try {
- LinkedHashMap map = new LinkedHashMap<>(1);
- map.put("code", code);
- SmsBlend smsBlend = SmsFactory.getSmsBlend(smsProperties.getSmsConfigName());
- String templateId = smsProperties.getLoginTemplateId();
- SmsResponse smsResponse;
- if (StringUtils.isNotBlank(templateId)) {
- smsResponse = smsBlend.sendMessage(phone, templateId, map);
- } else {
- smsResponse = smsBlend.sendMessage(phone, map);
- }
- if (!smsResponse.isSuccess()) {
- log.error("短信发送失败: phone={}, response={}", phone, smsResponse);
+ SmsBlend smsBlend = SmsFactory.getSmsBlend(smsProperties.getSmsConfigName());
+ if (smsBlend == null) {
+ log.warn("短信服务未配置(smsBlend=null),降级为测试模式");
+ log.info("【测试模式】微信绑定短信验证码: phone={}, code={}", phone, code);
+ } else {
+ try {
+ LinkedHashMap map = new LinkedHashMap<>(1);
+ map.put("code", code);
+ String templateId = smsProperties.getLoginTemplateId();
+ SmsResponse smsResponse;
+ if (StringUtils.isNotBlank(templateId)) {
+ smsResponse = smsBlend.sendMessage(phone, templateId, map);
+ } else {
+ smsResponse = smsBlend.sendMessage(phone, map);
+ }
+ if (!smsResponse.isSuccess()) {
+ log.error("短信发送失败: phone={}, response={}", phone, smsResponse);
+ throw new ServiceException("短信发送失败,请稍后重试");
+ }
+ log.info("微信绑定短信发送成功: phone={}, ip={}", phone, clientIp);
+ } catch (ServiceException e) {
+ throw e;
+ } catch (Exception e) {
+ log.error("短信发送异常: phone={}, error={}", phone, e.getMessage());
throw new ServiceException("短信发送失败,请稍后重试");
}
- log.info("微信绑定短信发送成功: phone={}, ip={}", phone, clientIp);
- } catch (ServiceException e) {
- throw e;
- } catch (Exception e) {
- log.error("短信发送异常: phone={}, error={}", phone, e.getMessage());
- throw new ServiceException("短信发送失败,请稍后重试");
}
} else {
log.info("【测试模式】微信绑定短信验证码: phone={}, code={}", phone, code);
diff --git a/backend/pangu-modules/pangu-business/src/main/java/org/dromara/pangu/openapi/domain/vo/OpenSchoolVo.java b/backend/pangu-modules/pangu-business/src/main/java/org/dromara/pangu/openapi/domain/vo/OpenSchoolVo.java
index c3db8e5..97286c5 100644
--- a/backend/pangu-modules/pangu-business/src/main/java/org/dromara/pangu/openapi/domain/vo/OpenSchoolVo.java
+++ b/backend/pangu-modules/pangu-business/src/main/java/org/dromara/pangu/openapi/domain/vo/OpenSchoolVo.java
@@ -23,7 +23,7 @@ public class OpenSchoolVo implements Serializable {
@Schema(description = "学校名称")
private String schoolName;
- @Schema(description = "学校类型(1小学 2初中 3高中 4完全中学)")
+ @Schema(description = "学校类型(1小学 2初中 3高中 4中专 5大学)")
private String schoolType;
@Schema(description = "区域ID")
diff --git a/backend/pangu-modules/pangu-business/src/main/java/org/dromara/pangu/school/domain/PgSchool.java b/backend/pangu-modules/pangu-business/src/main/java/org/dromara/pangu/school/domain/PgSchool.java
index 40b2f67..f4304c0 100644
--- a/backend/pangu-modules/pangu-business/src/main/java/org/dromara/pangu/school/domain/PgSchool.java
+++ b/backend/pangu-modules/pangu-business/src/main/java/org/dromara/pangu/school/domain/PgSchool.java
@@ -27,7 +27,7 @@ public class PgSchool extends BaseEntity {
private String schoolName;
/**
- * 学校类型(1小学 2初中 3高中 4完全中学)
+ * 学校类型(1小学 2初中 3高中 4中专 5大学)
*/
private String schoolType;
diff --git a/backend/pangu-modules/pangu-system/.flattened-pom.xml b/backend/pangu-modules/pangu-system/.flattened-pom.xml
index ade9960..40d84c4 100644
--- a/backend/pangu-modules/pangu-system/.flattened-pom.xml
+++ b/backend/pangu-modules/pangu-system/.flattened-pom.xml
@@ -76,5 +76,9 @@
org.dromara
pangu-common-sse
+
+ com.github.oshi
+ oshi-core
+
diff --git a/backend/pangu-modules/pangu-system/pom.xml b/backend/pangu-modules/pangu-system/pom.xml
index b1b444b..cc1abcd 100644
--- a/backend/pangu-modules/pangu-system/pom.xml
+++ b/backend/pangu-modules/pangu-system/pom.xml
@@ -100,6 +100,12 @@
pangu-common-sse
+
+
+ com.github.oshi
+ oshi-core
+
+
diff --git a/backend/pangu-modules/pangu-system/src/main/java/org/dromara/system/controller/monitor/ServerController.java b/backend/pangu-modules/pangu-system/src/main/java/org/dromara/system/controller/monitor/ServerController.java
new file mode 100644
index 0000000..8edec66
--- /dev/null
+++ b/backend/pangu-modules/pangu-system/src/main/java/org/dromara/system/controller/monitor/ServerController.java
@@ -0,0 +1,206 @@
+package org.dromara.system.controller.monitor;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import lombok.extern.slf4j.Slf4j;
+import oshi.SystemInfo;
+import oshi.hardware.CentralProcessor;
+import oshi.hardware.GlobalMemory;
+import oshi.hardware.HardwareAbstractionLayer;
+import oshi.software.os.OSFileStore;
+import oshi.software.os.OperatingSystem;
+import org.dromara.common.core.domain.R;
+import org.dromara.system.domain.vo.ServerVo;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.lang.management.ManagementFactory;
+import java.lang.management.MemoryMXBean;
+import java.lang.management.RuntimeMXBean;
+import java.net.InetAddress;
+import java.text.DecimalFormat;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 服务监控
+ *
+ * @author pangu
+ */
+@Slf4j
+@RestController
+@RequestMapping("/monitor/server")
+public class ServerController {
+
+ private static final int WAIT_MS = 300;
+
+ /**
+ * 获取服务监控信息(CPU、内存、JVM、服务器信息、磁盘)
+ */
+ @SaCheckPermission("monitor:server:list")
+ @GetMapping()
+ public R getInfo() {
+ try {
+ ServerVo server = new ServerVo();
+ SystemInfo si = new SystemInfo();
+ HardwareAbstractionLayer hal = si.getHardware();
+ OperatingSystem os = si.getOperatingSystem();
+
+ // CPU
+ server.setCpu(buildCpu(hal.getProcessor()));
+
+ // 内存
+ server.setMem(buildMem(hal.getMemory()));
+
+ // JVM
+ server.setJvm(buildJvm());
+
+ // 服务器信息
+ server.setSys(buildSys());
+
+ // 磁盘
+ server.setSysFiles(buildSysFiles(os));
+
+ return R.ok(server);
+ } catch (Exception e) {
+ log.warn("获取服务监控信息失败", e);
+ return R.fail("获取服务监控信息失败: " + e.getMessage());
+ }
+ }
+
+ private static ServerVo.CpuVo buildCpu(CentralProcessor processor) {
+ ServerVo.CpuVo cpu = new ServerVo.CpuVo();
+ cpu.setCpuNum(processor.getLogicalProcessorCount());
+ long[][] prevTicks = processor.getProcessorCpuLoadTicks();
+ try {
+ Thread.sleep(WAIT_MS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ double[] loadPerCpu = processor.getProcessorCpuLoadBetweenTicks(prevTicks);
+ double load = 0;
+ if (loadPerCpu != null && loadPerCpu.length > 0) {
+ for (double v : loadPerCpu) load += v;
+ load = load / loadPerCpu.length;
+ }
+ double used = load >= 0 ? load * 100 : 0;
+ cpu.setUsed(round(used, 2));
+ cpu.setSys(0.0);
+ cpu.setFree(round(100 - used, 2));
+ return cpu;
+ }
+
+ private static ServerVo.MemVo buildMem(GlobalMemory memory) {
+ ServerVo.MemVo mem = new ServerVo.MemVo();
+ long total = memory.getTotal();
+ long available = memory.getAvailable();
+ long used = total - available;
+ double totalG = total / (1024.0 * 1024.0 * 1024.0);
+ double usedG = used / (1024.0 * 1024.0 * 1024.0);
+ double freeG = available / (1024.0 * 1024.0 * 1024.0);
+ mem.setTotal(round(totalG, 2));
+ mem.setUsed(round(usedG, 2));
+ mem.setFree(round(freeG, 2));
+ mem.setUsage(total > 0 ? round(used * 100.0 / total, 2) : 0.0);
+ return mem;
+ }
+
+ private static ServerVo.JvmVo buildJvm() {
+ ServerVo.JvmVo jvm = new ServerVo.JvmVo();
+ Runtime runtime = Runtime.getRuntime();
+ MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
+ RuntimeMXBean runtimeMXBean = ManagementFactory.getRuntimeMXBean();
+
+ long total = memoryMXBean.getHeapMemoryUsage().getMax() > 0
+ ? memoryMXBean.getHeapMemoryUsage().getMax()
+ : runtime.totalMemory();
+ long used = memoryMXBean.getHeapMemoryUsage().getUsed();
+ long free = total - used;
+
+ double totalM = total / (1024.0 * 1024.0);
+ double usedM = used / (1024.0 * 1024.0);
+ double freeM = free / (1024.0 * 1024.0);
+
+ jvm.setTotal(round(totalM, 2));
+ jvm.setUsed(round(usedM, 2));
+ jvm.setFree(round(freeM, 2));
+ jvm.setUsage(total > 0 ? round(used * 100.0 / total, 2) : 0.0);
+ jvm.setName(runtimeMXBean.getVmName());
+ jvm.setVersion(System.getProperty("java.version"));
+ jvm.setStartTime(new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
+ .format(new java.util.Date(runtimeMXBean.getStartTime())));
+ long runMs = System.currentTimeMillis() - runtimeMXBean.getStartTime();
+ jvm.setRunTime(formatRunTime(runMs));
+ jvm.setHome(System.getProperty("java.home"));
+ jvm.setInputArgs(runtimeMXBean.getInputArguments().toString());
+ return jvm;
+ }
+
+ private static ServerVo.SysVo buildSys() {
+ ServerVo.SysVo sys = new ServerVo.SysVo();
+ try {
+ InetAddress addr = InetAddress.getLocalHost();
+ sys.setComputerName(addr.getHostName());
+ sys.setComputerIp(addr.getHostAddress());
+ } catch (Exception e) {
+ sys.setComputerName("未知");
+ sys.setComputerIp("未知");
+ }
+ sys.setOsName(System.getProperty("os.name"));
+ sys.setOsArch(System.getProperty("os.arch"));
+ sys.setUserDir(System.getProperty("user.dir"));
+ return sys;
+ }
+
+ private static List buildSysFiles(OperatingSystem os) {
+ List list = new ArrayList<>();
+ try {
+ for (OSFileStore fs : os.getFileSystem().getFileStores()) {
+ ServerVo.SysFileVo vo = new ServerVo.SysFileVo();
+ vo.setDirName(fs.getMount());
+ vo.setSysTypeName(fs.getType());
+ vo.setTypeName(fs.getName());
+ long total = fs.getTotalSpace();
+ long free = fs.getUsableSpace();
+ long used = total - free;
+ vo.setTotal(formatSize(total));
+ vo.setFree(formatSize(free));
+ vo.setUsed(formatSize(used));
+ vo.setUsage(total > 0 ? round(used * 100.0 / total, 2) : 0.0);
+ list.add(vo);
+ }
+ } catch (Exception e) {
+ log.debug("获取磁盘信息失败", e);
+ }
+ return list;
+ }
+
+ private static double round(double value, int scale) {
+ return Math.round(value * Math.pow(10, scale)) / Math.pow(10, scale);
+ }
+
+ private static String formatSize(long size) {
+ double g = size / (1024.0 * 1024.0 * 1024.0);
+ if (g >= 1) {
+ return new DecimalFormat("#.##").format(g) + "G";
+ }
+ double m = size / (1024.0 * 1024.0);
+ if (m >= 1) {
+ return new DecimalFormat("#.##").format(m) + "M";
+ }
+ return size / 1024 + "K";
+ }
+
+ private static String formatRunTime(long ms) {
+ long day = ms / (24 * 3600 * 1000);
+ long hour = (ms % (24 * 3600 * 1000)) / (3600 * 1000);
+ long minute = (ms % (3600 * 1000)) / (60 * 1000);
+ long second = (ms % (60 * 1000)) / 1000;
+ StringBuilder sb = new StringBuilder();
+ if (day > 0) sb.append(day).append("天");
+ if (hour > 0) sb.append(hour).append("小时");
+ if (minute > 0) sb.append(minute).append("分钟");
+ sb.append(second).append("秒");
+ return sb.toString();
+ }
+}
diff --git a/backend/pangu-modules/pangu-system/src/main/java/org/dromara/system/domain/vo/ServerVo.java b/backend/pangu-modules/pangu-system/src/main/java/org/dromara/system/domain/vo/ServerVo.java
new file mode 100644
index 0000000..103e97d
--- /dev/null
+++ b/backend/pangu-modules/pangu-system/src/main/java/org/dromara/system/domain/vo/ServerVo.java
@@ -0,0 +1,90 @@
+package org.dromara.system.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 服务监控信息视图对象(与前端 /monitor/server 页面对应)
+ *
+ * @author pangu
+ */
+@Data
+public class ServerVo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /** CPU 信息 */
+ private CpuVo cpu;
+ /** 内存信息 */
+ private MemVo mem;
+ /** JVM 信息 */
+ private JvmVo jvm;
+ /** 服务器系统信息 */
+ private SysVo sys;
+ /** 磁盘列表 */
+ private List sysFiles;
+
+ @Data
+ public static class CpuVo implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
+ private Integer cpuNum;
+ private Double used;
+ private Double sys;
+ private Double free;
+ }
+
+ @Data
+ public static class MemVo implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
+ private Double total;
+ private Double used;
+ private Double free;
+ private Double usage;
+ }
+
+ @Data
+ public static class JvmVo implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
+ private Double total;
+ private Double used;
+ private Double free;
+ private Double usage;
+ private String name;
+ private String version;
+ private String startTime;
+ private String runTime;
+ private String home;
+ private String inputArgs;
+ }
+
+ @Data
+ public static class SysVo implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
+ private String computerName;
+ private String computerIp;
+ private String osName;
+ private String osArch;
+ private String userDir;
+ }
+
+ @Data
+ public static class SysFileVo implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
+ private String dirName;
+ private String sysTypeName;
+ private String typeName;
+ private String total;
+ private String free;
+ private String used;
+ private Double usage;
+ }
+}
diff --git a/backend/pom.xml b/backend/pom.xml
index cac50f5..6fe2781 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -49,6 +49,8 @@
8.7.3-20251210
1.8.4
+
+ 6.4.0
3.4.2
@@ -326,6 +328,13 @@
${ip2region.version}
+
+
+ com.github.oshi
+ oshi-core
+ ${oshi.version}
+
+
com.alibaba
fastjson
diff --git a/docs/发布计划_2026-02-05.md b/docs/发布计划_2026-02-05.md
index fa022c7..1c51be5 100644
--- a/docs/发布计划_2026-02-05.md
+++ b/docs/发布计划_2026-02-05.md
@@ -11,8 +11,8 @@
| 项目 | 本地最新版本 | 服务器当前版本 | 待发布提交数 |
|------|-------------|---------------|-------------|
-| pangu-user-platform (后端+管理后台) | add00c9 | 2026-02-03 22:29 部署 | 5 个 |
-| user_authentication_center_front (H5前端) | 70fc1ad | 2026-02-03 22:04 部署 | 2 个 |
+| pangu-user-platform (后端+管理后台) | a9bbb30 | 2026-02-03 22:29 部署 | 6 个 |
+| user_authentication_center_front (H5前端) | 76f27ce | 2026-02-03 22:04 部署 | 3 个 |
---
@@ -22,28 +22,33 @@
| 序号 | Commit ID | 提交说明 | 类型 |
|------|----------|---------|------|
-| 1 | add00c9 | 新增学校自动添加年级 + 修复区域层级查询 + 清理区域数据 | feat |
-| 2 | 72cb666 | 年级管理增加学段字段(小学/初中/高中/中专/大学) | feat |
-| 3 | 80dd406 | 新增OpenApi基础数据接口 + 学生完整数据接口 + UI文案优化 | feat |
-| 4 | 1a0b75e | 同步需求与技术方案文档 | docs |
-| 5 | 6027a8c | 修改后端欢迎语为盘古后台管理系统 | refactor |
+| 1 | a9bbb30 | 业务功能按钮权限统一(v-hasPermi,与角色配置一致) | fix |
+| 2 | add00c9 | 新增学校自动添加年级 + 修复区域层级查询 + 清理区域数据 | feat |
+| 3 | 72cb666 | 年级管理增加学段字段(小学/初中/高中/中专/大学) | feat |
+| 4 | 80dd406 | 新增OpenApi基础数据接口 + 学生完整数据接口 + UI文案优化 | feat |
+| 5 | 1a0b75e | 同步需求与技术方案文档 | docs |
+| 6 | 6027a8c | 修改后端欢迎语为盘古后台管理系统 | refactor |
### 2.2 功能变更摘要
-1. **学校管理优化**
+1. **业务功能按钮权限统一(本次新增)**
+ - 学生、会员、学校、应用管理、接口字典、年级、班级、学科、区域等列表页的「新增」「编辑」「删除」「导入」「导出」等按钮增加 `v-hasPermi` 控制
+ - 与角色管理中配置的菜单权限一致,无权限用户不显示对应按钮
+
+2. **学校管理优化**
- 新增学校时自动添加对应学段的年级
- 修复选择省/市时无法显示学校的Bug(支持区域层级查询)
- 区域树默认只展开湖北省,平行显示市级
-2. **年级管理增强**
+3. **年级管理增强**
- 新增学段字段(小学/初中/高中/中专/大学)
- 支持按学段筛选年级
-3. **OpenAPI接口扩展**
+4. **OpenAPI接口扩展**
- 新增学校/年级/班级基础数据查询接口
- 新增学生完整数据接口(不脱敏,需特殊授权)
-4. **UI文案优化**
+5. **UI文案优化**
- "教育身份" 改为 "任教信息"
- 后端欢迎语改为 "盘古后台管理系统"
@@ -91,16 +96,22 @@
| 序号 | Commit ID | 提交说明 | 类型 |
|------|----------|---------|------|
-| 1 | 70fc1ad | 优化登录注册页面交互和弹窗内容 | feat |
-| 2 | 842d64f | 允许通过IP地址访问H5前端 | fix |
+| 1 | 76f27ce | 会员支持所在区域:注册和个人中心可填可改,教育身份和绑定学生表单默认带出区域 | feat |
+| 2 | 70fc1ad | 优化登录注册页面交互和弹窗内容 | feat |
+| 3 | 842d64f | 允许通过IP地址访问H5前端 | fix |
### 3.2 功能变更摘要
-1. **登录注册优化**
+1. **会员所在区域(本次新增)**
+ - 注册时增加所在区域选择(级联)
+ - 个人中心可查看、修改所在区域
+ - 添加教育身份、绑定学生时区域默认带出会员区域,可修改
+
+2. **登录注册优化**
- 优化登录注册页面交互体验
- 改进弹窗内容展示
-2. **访问限制修复**
+3. **访问限制修复**
- 允许通过IP地址直接访问H5前端
### 3.3 服务器部署路径
@@ -195,11 +206,13 @@ cd /opt/pangu-user-platform/scripts
|--------|---------|---------|
| 后端服务 | `curl http://localhost:9083/actuator/health` | {"status":"UP"} |
| 管理后台登录 | 浏览器访问管理后台 | 正常登录 |
+| 权限按钮显隐 | 用无增删改权限账号登录,进入学生/会员等列表 | 仅显示查询,不显示新增/编辑/删除等按钮 |
| 学校管理-区域树 | 查看区域树展开状态 | 默认展开湖北省 |
| 学校管理-层级查询 | 选择武汉市查看学校 | 显示武汉市下所有学校 |
| 年级管理-学段 | 查看年级列表 | 显示学段列 |
| OpenAPI接口 | 调用新增的接口 | 正常返回数据 |
| H5前端登录 | 浏览器访问H5 | 正常登录 |
+| H5会员区域 | 注册选区域、个人中心改区域,教育身份/绑定学生表单 | 区域可选且默认带出会员区域 |
---
@@ -287,4 +300,5 @@ mysql -h 8.148.25.55 -uroot -paly2024A pguser-db < /opt/backup/pg_region_YYYYMMD
---
-*文档生成时间: 2026-02-05*
+*文档生成时间: 2026-02-05*
+*更新说明: 已纳入业务功能按钮权限统一(a9bbb30)与 H5 会员所在区域(76f27ce)两项提交。*
diff --git a/docs/发布计划_PROD_SQL脚本变更检查.md b/docs/发布计划_PROD_SQL脚本变更检查.md
new file mode 100644
index 0000000..1c7ef75
--- /dev/null
+++ b/docs/发布计划_PROD_SQL脚本变更检查.md
@@ -0,0 +1,102 @@
+# PROD 发布 - SQL 脚本变更检查
+
+**检查日期**: 2026-02-05
+**目标**: 部署前确认 PROD 库需执行的增量脚本及执行顺序
+
+---
+
+## 一、本次发布涉及的 SQL 脚本(scripts/sql/)
+
+| 序号 | 脚本文件 | 说明 | 涉及表 | 风险 | 是否幂等 |
+|------|----------|------|--------|------|----------|
+| 1 | V1.0.3__open_api_dict.sql | 开放API接口字典数据 | pg_api_dict | 低 | 是(ON DUPLICATE KEY UPDATE) |
+| 2 | V1.0.4__grade_add_stage.sql | 年级表增加学段字段 | pg_grade | 中 | 否(重复执行会报错:列已存在) |
+| 3 | V1.0.5__clean_region_data.sql | 清理非湖北省区域数据 | pg_region | 高 | 否(物理删除,不可逆) |
+
+---
+
+## 二、各脚本变更内容摘要
+
+### V1.0.3__open_api_dict.sql
+
+- **操作类型**: INSERT(存在则 UPDATE)
+- **变更内容**:
+ - 学生:OPEN_STUDENT_LIST、OPEN_STUDENT_LIST_FULL(2 条)
+ - 学校:OPEN_SCHOOL_LIST、OPEN_SCHOOL_LIST_ALL、OPEN_SCHOOL_INFO(3 条)
+ - 年级:OPEN_GRADE_LIST、OPEN_GRADE_LIST_ALL、OPEN_GRADE_INFO(3 条)
+ - 班级:OPEN_CLASS_LIST、OPEN_CLASS_LIST_ALL、OPEN_CLASS_INFO(3 条)
+- **共 11 条** pg_api_dict 记录
+- **执行前**: 确认 PROD 存在表 `pg_api_dict`,且主键/唯一约束与脚本一致(api_id 或 api_code)
+- **重复执行**: 安全,仅更新名称/路径/描述
+
+### V1.0.4__grade_add_stage.sql
+
+- **操作类型**: ALTER TABLE + UPDATE
+- **变更内容**:
+ 1. `ALTER TABLE pg_grade ADD COLUMN stage CHAR(1) NULL COMMENT '学段(1小学 2初中 3高中 4中专 5大学)' AFTER grade_name;`
+ 2. 按年级名称回填:小学(1)/初中(2)/高中(3),未匹配默认小学(1)
+ 3. `ALTER TABLE pg_grade MODIFY COLUMN stage ... NOT NULL DEFAULT '1';`
+- **执行前**: 必须确认 PROD 的 `pg_grade` **没有** `stage` 列,否则跳过或先判断列是否存在
+- **重复执行**: 会报错(列已存在),需做存在性检查或仅执行一次
+
+### V1.0.5__clean_region_data.sql
+
+- **操作类型**: 物理 DELETE
+- **变更内容**:
+ - 删除所有「非湖北省及其下级」的区域(ancestors 不以 `0,42` 开头,且 region_id≠42)
+ - 再删除其他省份根节点(parent_id=0 且 region_id≠42)
+- **执行前**: 必须备份 `pg_region`;确认湖北省 region_id=42;建议业务低峰期、单独审批后执行
+- **重复执行**: 可执行(已删的不会再删),但不可逆
+
+---
+
+## 三、PROD 执行前必做检查
+
+1. **确认表是否存在**
+ - `pg_api_dict`、`pg_grade`、`pg_region` 在 PROD 是否存在且结构一致。
+
+2. **确认是否已执行过**
+ - **V1.0.4**:在 PROD 执行
+ `SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pg_grade' AND COLUMN_NAME='stage';`
+ - 若结果为 1,说明已有 `stage`,**不要**再执行 V1.0.4。
+ - **V1.0.3**:可重复执行,无需跳过。
+
+3. **V1.0.5 是否在本次执行**
+ - 若 PROD 需保留多省数据,**不要**执行 V1.0.5。
+ - 若只保留湖北省,执行前必须备份:
+ `mysqldump -h -u<> -p pguser-db pg_region > pg_region_backup_YYYYMMDD.sql`
+
+4. **会员区域字段(本次 H5 会员区域功能依赖)**
+ - 若 PROD 的 `pg_member` 表是早期建表、**没有** `region_id` 列,需先执行增量加列(见下节),再发布应用与 H5。
+
+---
+
+## 四、可选:pg_member.region_id 增量脚本
+
+若 PROD 的 `pg_member` 尚无 `region_id` 列,需在应用发布前执行(仅执行一次):
+
+```sql
+-- 检查是否已有列(结果为 0 表示需要执行下面的 ADD)
+-- SELECT COUNT(*) FROM information_schema.COLUMNS
+-- WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pg_member' AND COLUMN_NAME='region_id';
+
+ALTER TABLE pg_member
+ADD COLUMN region_id bigint DEFAULT NULL COMMENT '区域ID' AFTER open_id;
+```
+
+建议将上述内容保存为 `scripts/sql/V1.0.2__member_region_id.sql`(或 V1.0.6,按你们版本号规范),并在发布计划中注明执行顺序:在 V1.0.3 之前或之后均可(与 pg_grade、pg_region 无依赖)。
+
+---
+
+## 五、推荐执行顺序(PROD)
+
+| 顺序 | 脚本 | 条件 |
+|------|------|------|
+| 1 | V1.0.2__member_region_id.sql(若有) | 仅当 pg_member 无 region_id 时执行 |
+| 2 | V1.0.3__open_api_dict.sql | 必执行 |
+| 3 | V1.0.4__grade_add_stage.sql | 仅当 pg_grade 无 stage 列时执行 |
+| 4 | V1.0.5__clean_region_data.sql | 仅当确定只保留湖北省且已备份 pg_region 时执行 |
+
+---
+
+*检查说明:按上述顺序与条件执行,可避免重复加列报错与误删区域数据。*
diff --git a/frontend/src/views/business/base/region/index.vue b/frontend/src/views/business/base/region/index.vue
index 97b1317..b660610 100644
--- a/frontend/src/views/business/base/region/index.vue
+++ b/frontend/src/views/business/base/region/index.vue
@@ -29,6 +29,7 @@
{
+ if (!list || list.length === 0) return []
+ const k = (keyword || '').trim().toLowerCase()
+ const filter = (nodes) => {
+ return nodes
+ .map((node) => ({ ...node, children: node.children ? filter(node.children) : [] }))
+ .filter((node) => {
+ const matchName = !k || (node.regionName && node.regionName.toLowerCase().includes(k))
+ const matchStatus = !status || String(node.status) === String(status)
+ const childMatch = node.children && node.children.length > 0
+ return (matchName && matchStatus) || childMatch
+ })
+ }
+ return filter(list)
+}
+
+// 获取区域列表(树形);有搜索条件时强制刷新并过滤
const getList = async () => {
loading.value = true
try {
- // 使用 Store,自动缓存
- const data = await baseDataStore.fetchRegionTree()
- tableData.value = data || []
+ const hasQuery = (queryParams.value.regionName || '').trim() || (queryParams.value.status !== '' && queryParams.value.status !== undefined)
+ const data = await baseDataStore.fetchRegionTree(!!hasQuery)
+ let tree = data || []
+ if ((queryParams.value.regionName || '').trim()) {
+ tree = filterTreeByKeyword(tree, queryParams.value.regionName, queryParams.value.status)
+ } else if (queryParams.value.status !== '' && queryParams.value.status !== undefined) {
+ tree = filterTreeByKeyword(tree, '', queryParams.value.status)
+ }
+ tableData.value = tree
+ // 默认展开湖北省及二级(只展开到市),有 tableRef 时在 nextTick 后执行
+ nextTick(() => {
+ if (!isExpandAll.value) expandToLevel2()
+ })
} finally {
loading.value = false
}
}
+// 递归收集树中所有节点(扁平)
+const flattenTree = (list) => {
+ const out = []
+ const walk = (nodes) => {
+ if (!nodes) return
+ nodes.forEach((row) => {
+ out.push(row)
+ walk(row.children)
+ })
+ }
+ walk(list)
+ return out
+}
+
+// 默认展开一级(省)和二级(市),不展开三级及以下
+const expandToLevel2 = () => {
+ if (!tableRef.value || !tableData.value.length) return
+ const rows = flattenTree(tableData.value)
+ rows.forEach((row) => {
+ const level = row.level
+ if (level === 1 || level === 2) {
+ tableRef.value.toggleRowExpansion(row, true)
+ }
+ })
+}
+
// 获取区域树选项(用于下拉选择)
const getRegionOptions = async () => {
const data = await baseDataStore.fetchRegionTree()
diff --git a/frontend/src/views/business/school/components/SchoolDialog.vue b/frontend/src/views/business/school/components/SchoolDialog.vue
index 4cbf912..9a2bccd 100644
--- a/frontend/src/views/business/school/components/SchoolDialog.vue
+++ b/frontend/src/views/business/school/components/SchoolDialog.vue
@@ -23,8 +23,8 @@
-
-
+
+
@@ -184,14 +184,14 @@ const handleRegionChange = (value) => {
}
}
-// 学校类型对应学段映射
+// 学校类型对应学段映射(与 pg_grade.stage 一致:1小学 2初中 3高中 4中专 5大学)
const getStagesBySchoolType = (schoolType) => {
const map = {
- '1': ['1'], // 小学
- '2': ['2'], // 初中
- '3': ['3'], // 高中
- '4': ['1', '2'], // 九年一贯制
- '5': ['2', '3'] // 完全中学
+ '1': ['1'], // 小学
+ '2': ['2'], // 初中
+ '3': ['3'], // 高中
+ '4': ['4'], // 中专
+ '5': ['5'] // 大学
}
return map[schoolType] || []
}
diff --git a/frontend/src/views/monitor/server/index.vue b/frontend/src/views/monitor/server/index.vue
index 60ff80c..2b4a1d9 100644
--- a/frontend/src/views/monitor/server/index.vue
+++ b/frontend/src/views/monitor/server/index.vue
@@ -180,6 +180,9 @@ function getList() {
getServer().then(response => {
server.value = response.data
proxy.$modal.closeLoading()
+ }).catch(() => {
+ proxy.$modal.closeLoading()
+ proxy.$modal.msgError("加载服务监控数据失败")
})
}
diff --git a/scripts/clear-h5-captcha-cache.sh b/scripts/clear-h5-captcha-cache.sh
new file mode 100644
index 0000000..2bf029f
--- /dev/null
+++ b/scripts/clear-h5-captcha-cache.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+# 清理 H5 图形验证码、短信验证码、限流与黑名单等 Redis 缓存
+# 使用场景:验证码异常、发送过于频繁、需要重置限流时,在生产服务器执行
+#
+# 用法(在生产服务器 192.168.71.56 上执行):
+# cd /opt/pangu-user-platform && bash scripts/clear-h5-captcha-cache.sh
+# REDIS_PASSWORD=你的密码 bash scripts/clear-h5-captcha-cache.sh
+# 若后端使用 application-test.yml,密码请以该文件为准。
+
+set -e
+REDIS_HOST="${REDIS_HOST:-127.0.0.1}"
+REDIS_PORT="${REDIS_PORT:-6379}"
+REDIS_PASSWORD="${REDIS_PASSWORD:-}"
+
+if ! command -v redis-cli &>/dev/null; then
+ echo "未找到 redis-cli,请安装 Redis 客户端或在已安装的机器上执行"
+ exit 1
+fi
+
+CLI=(redis-cli -h "$REDIS_HOST" -p "$REDIS_PORT")
+if [[ -n "$REDIS_PASSWORD" ]]; then
+ CLI+=(-a "$REDIS_PASSWORD")
+fi
+
+echo "清理 H5 验证码与短信相关缓存 (host=$REDIS_HOST port=$REDIS_PORT) ..."
+
+for pattern in "global:captcha_codes:*" "h5:sms:*"; do
+ count=0
+ while IFS= read -r key; do
+ [[ -z "$key" ]] && continue
+ "${CLI[@]}" DEL "$key" &>/dev/null && ((count++)) || true
+ done < <("${CLI[@]}" --no-auth-warning KEYS "$pattern" 2>/dev/null || true)
+ echo " 已删除 pattern=$pattern 数量=$count"
+done
+
+echo "清理完成。"
diff --git a/scripts/sql/V1.0.4__grade_add_stage.sql b/scripts/sql/V1.0.4__grade_add_stage.sql
index 08d9724..daa681f 100644
--- a/scripts/sql/V1.0.4__grade_add_stage.sql
+++ b/scripts/sql/V1.0.4__grade_add_stage.sql
@@ -1,21 +1,41 @@
-- =====================================================
--- 年级表增加学段字段 - V1.0.4
+-- 年级表增加学段字段 - V1.0.4(幂等,可重复执行)
-- 执行时间: 2026-02-04
-- 说明: 年级管理增加学段(小学/初中/高中/中专/大学)
+-- PROD 对比: 若 pg_grade 已有 stage 列则仅做空值回填,否则加列并初始化
-- =====================================================
--- 1. 新增学段字段
-ALTER TABLE pg_grade ADD COLUMN stage CHAR(1) NULL COMMENT '学段(1小学 2初中 3高中 4中专 5大学)' AFTER grade_name;
+DELIMITER //
+DROP PROCEDURE IF EXISTS proc_grade_add_stage//
+CREATE PROCEDURE proc_grade_add_stage()
+BEGIN
+ DECLARE col_exists INT DEFAULT 0;
--- 2. 初始化现有数据(根据年级名称自动匹配学段)
--- 小学
-UPDATE pg_grade SET stage = '1' WHERE grade_name IN ('一年级', '二年级', '三年级', '四年级', '五年级', '六年级');
--- 初中
-UPDATE pg_grade SET stage = '2' WHERE grade_name IN ('七年级', '八年级', '九年级');
--- 高中
-UPDATE pg_grade SET stage = '3' WHERE grade_name IN ('高一', '高二', '高三');
--- 未匹配的默认设为小学
-UPDATE pg_grade SET stage = '1' WHERE stage IS NULL;
+ SELECT COUNT(*) INTO col_exists
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'pg_grade'
+ AND COLUMN_NAME = 'stage';
--- 3. 设置为非空并添加默认值
-ALTER TABLE pg_grade MODIFY COLUMN stage CHAR(1) NOT NULL DEFAULT '1' COMMENT '学段(1小学 2初中 3高中 4中专 5大学)';
+ IF col_exists = 0 THEN
+ -- 1. 新增学段字段
+ ALTER TABLE pg_grade ADD COLUMN stage CHAR(1) NULL COMMENT '学段(1小学 2初中 3高中 4中专 5大学)' AFTER grade_name;
+ -- 2. 初始化现有数据
+ UPDATE pg_grade SET stage = '1' WHERE grade_name IN ('一年级', '二年级', '三年级', '四年级', '五年级', '六年级');
+ UPDATE pg_grade SET stage = '2' WHERE grade_name IN ('七年级', '八年级', '九年级');
+ UPDATE pg_grade SET stage = '3' WHERE grade_name IN ('高一', '高二', '高三');
+ UPDATE pg_grade SET stage = '1' WHERE stage IS NULL;
+ -- 3. 设置为非空并添加默认值
+ ALTER TABLE pg_grade MODIFY COLUMN stage CHAR(1) NOT NULL DEFAULT '1' COMMENT '学段(1小学 2初中 3高中 4中专 5大学)';
+ ELSE
+ -- 列已存在:仅回填可能为空的(兼容已加列但未回填的环境)
+ UPDATE pg_grade SET stage = '1' WHERE grade_name IN ('一年级', '二年级', '三年级', '四年级', '五年级', '六年级') AND (stage IS NULL OR stage = '');
+ UPDATE pg_grade SET stage = '2' WHERE grade_name IN ('七年级', '八年级', '九年级') AND (stage IS NULL OR stage = '');
+ UPDATE pg_grade SET stage = '3' WHERE grade_name IN ('高一', '高二', '高三') AND (stage IS NULL OR stage = '');
+ UPDATE pg_grade SET stage = '1' WHERE (stage IS NULL OR stage = '');
+ END IF;
+END//
+DELIMITER ;
+
+CALL proc_grade_add_stage();
+DROP PROCEDURE IF EXISTS proc_grade_add_stage;
diff --git a/scripts/sql/V1.0.5__clean_region_data.sql b/scripts/sql/V1.0.5__clean_region_data.sql
index 0afcd6e..7982d72 100644
--- a/scripts/sql/V1.0.5__clean_region_data.sql
+++ b/scripts/sql/V1.0.5__clean_region_data.sql
@@ -1,30 +1,26 @@
-- ================================================================
--- V1.0.5 清理非湖北省区域数据
--- 执行说明:先检查湖北省的region_id,确认为42后再执行删除
--- 执行时间:需在业务低峰期执行
+-- V1.0.5 清理非湖北省区域数据(幂等,可重复执行)
+-- 执行说明:先确认湖北省 region_id=42;若当前仅含湖北省则删除 0 行
+-- PROD 对比: 当前 pg_region 仅湖北省及其下级(120 条),执行本脚本将删除 0 行
-- ================================================================
--- 0. 首先确认湖北省的region_id
+-- 0. 确认湖北省的 region_id(预期 region_id = 42)
SELECT region_id, region_name, parent_id, ancestors FROM pg_region WHERE region_name = '湖北省';
--- 预期结果:region_id = 42
--- 1. 查看要删除的数据量(预检查,不会执行删除)
-SELECT COUNT(*) as '待删除的区域数量' FROM pg_region
-WHERE region_id != 42
+-- 1. 预检查:将要删除的行数(非湖北省及其下级的区域;湖北省 region_id=42,其下级 ancestors 以 0,42 开头)
+SELECT COUNT(*) AS '待删除的区域数量(非湖北)' FROM pg_region
+WHERE region_id != 42
AND ancestors NOT LIKE '0,42%';
--- 2. 物理删除非湖北省的区域数据
--- 删除所有ancestors不以"0,42"开头的区域(即非湖北省及其下级区域)
--- 同时排除湖北省本身(region_id=42)
-DELETE FROM pg_region
-WHERE region_id != 42
+-- 2. 物理删除:非湖北省及其下级(保留 region_id=42 及 ancestors 以 0,42 开头的行)
+DELETE FROM pg_region
+WHERE region_id != 42
AND ancestors NOT LIKE '0,42%';
--- 3. 删除其他省份的根节点(parent_id = 0 且 region_id != 42)
-DELETE FROM pg_region
-WHERE parent_id = 0
+-- 3. 删除其他省份根节点(parent_id=0 且 region_id 非 42)
+DELETE FROM pg_region
+WHERE parent_id = 0
AND region_id != 42;
-- 4. 验证结果
-SELECT COUNT(*) as '剩余区域数量' FROM pg_region;
-SELECT region_name, level, COUNT(*) as '数量' FROM pg_region GROUP BY level, region_name LIMIT 20;
+SELECT COUNT(*) AS '剩余区域数量' FROM pg_region;
diff --git a/scripts/sql/V1.0.6__menu_application_submenu.sql b/scripts/sql/V1.0.6__menu_application_submenu.sql
new file mode 100644
index 0000000..59d9665
--- /dev/null
+++ b/scripts/sql/V1.0.6__menu_application_submenu.sql
@@ -0,0 +1,25 @@
+-- =====================================================
+-- 菜单调整:应用管理改为目录,下挂 应用列表 + 接口字典(幂等,可重复执行)
+-- 适用:已存在旧版「应用管理」单页菜单的环境,无需重跑全量 pangu_menu.sql
+-- =====================================================
+
+-- 1. 应用管理改为目录(M),去掉 component
+UPDATE sys_menu SET menu_type = 'M', component = NULL, perms = '' WHERE menu_id = 2300;
+
+-- 2. 新增「应用列表」二级菜单(若不存在)
+INSERT IGNORE INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query_param, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time)
+VALUES (2307, '应用列表', 2300, 1, 'list', 'business/application/index', '', 1, 0, 'C', '0', '0', 'business:application:list', '#', 103, 1, NOW());
+
+-- 3. 应用相关按钮归属到「应用列表」下
+UPDATE sys_menu SET parent_id = 2307 WHERE menu_id IN (2301, 2302, 2303, 2304, 2305, 2306);
+
+-- 4. 新增「接口字典」二级菜单(若不存在)
+INSERT IGNORE INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query_param, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time)
+VALUES (2310, '接口字典', 2300, 2, 'apiDict', 'business/apiDict/index', '', 1, 0, 'C', '0', '0', 'business:apiDict:list', 'list', 103, 1, NOW());
+
+-- 5. 接口字典按钮(若不存在)
+INSERT IGNORE INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query_param, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time) VALUES
+(2311, '接口字典查询', 2310, 1, '', '', '', 1, 0, 'F', '0', '0', 'business:apiDict:query', '#', 103, 1, NOW()),
+(2312, '接口字典新增', 2310, 2, '', '', '', 1, 0, 'F', '0', '0', 'business:apiDict:add', '#', 103, 1, NOW()),
+(2313, '接口字典修改', 2310, 3, '', '', '', 1, 0, 'F', '0', '0', 'business:apiDict:edit', '#', 103, 1, NOW()),
+(2314, '接口字典删除', 2310, 4, '', '', '', 1, 0, 'F', '0', '0', 'business:apiDict:remove', '#', 103, 1, NOW());
diff --git a/scripts/sql/V1.0.7__hide_system_menus.sql b/scripts/sql/V1.0.7__hide_system_menus.sql
new file mode 100644
index 0000000..499982e
--- /dev/null
+++ b/scripts/sql/V1.0.7__hide_system_menus.sql
@@ -0,0 +1,26 @@
+-- =====================================================
+-- 隐藏指定系统菜单(与菜单管理界面中圈出的项一致)
+-- 显示状态:0=显示 1=隐藏
+-- 幂等,可重复执行
+--
+-- 在 192.168.71.56 服务器上执行(更新程序使用的库 pguser-db@8.148.25.55):
+-- 1. 上传本文件到服务器:scp scripts/sql/V1.0.7__hide_system_menus.sql root@192.168.71.56:/opt/pangu-user-platform/scripts/
+-- 2. ssh root@192.168.71.56
+-- 3. mysql -h 8.148.25.55 -uroot -p pguser-db < /opt/pangu-user-platform/scripts/V1.0.7__hide_system_menus.sql
+-- 执行后管理员重新登录或刷新菜单后即可生效。
+-- =====================================================
+
+-- Admin监控、任务调度中心、系统工具、租户管理、PLUS官网、测试菜单及其子菜单
+UPDATE sys_menu SET visible = '1' WHERE menu_id IN (
+ 117, -- Admin监控
+ 120, -- 任务调度中心
+ 3, -- 系统工具
+ 6, -- 租户管理(目录)
+ 121, -- 租户管理(页面)
+ 4, -- PLUS官网
+ 5, -- 测试菜单(目录)
+ 1500, -- 测试单表
+ 1501, 1502, 1503, 1504, 1505, -- 测试单表按钮
+ 1506, -- 测试树表
+ 1507, 1508, 1509, 1510, 1511 -- 测试树表按钮
+);
diff --git a/scripts/sql/V1.0.8__delete_system_menus.sql b/scripts/sql/V1.0.8__delete_system_menus.sql
new file mode 100644
index 0000000..d0a13b1
--- /dev/null
+++ b/scripts/sql/V1.0.8__delete_system_menus.sql
@@ -0,0 +1,31 @@
+-- =====================================================
+-- 删除指定系统菜单(与 V1.0.7 隐藏的菜单一致,改为物理删除)
+-- 删除范围:Admin监控、任务调度中心、系统工具(及代码生成等子菜单)、
+-- 租户管理(及子菜单)、PLUS官网、测试菜单(及测试单表/树表等子菜单)
+-- 执行前请确认数据库与备份;执行顺序:先删角色-菜单关联,再删菜单。
+-- =====================================================
+
+-- 1. 删除角色菜单关联
+DELETE FROM sys_role_menu WHERE menu_id IN (
+ 117, -- Admin监控
+ 120, -- 任务调度中心
+ 3, -- 系统工具(目录)
+ 115, -- 代码生成
+ 116, -- 修改生成配置
+ 1055, 1056, 1057, 1058, 1059, 1060, -- 代码生成按钮
+ 6, -- 租户管理(目录)
+ 121, -- 租户管理
+ 122, -- 租户套餐管理
+ 4, -- PLUS官网
+ 5, -- 测试菜单(目录)
+ 1500, -- 测试单表
+ 1501, 1502, 1503, 1504, 1505, -- 测试单表按钮
+ 1506, -- 测试树表
+ 1507, 1508, 1509, 1510, 1511 -- 测试树表按钮
+);
+
+-- 2. 删除菜单
+DELETE FROM sys_menu WHERE menu_id IN (
+ 117, 120, 3, 115, 116, 1055, 1056, 1057, 1058, 1059, 1060,
+ 6, 121, 122, 4, 5, 1500, 1501, 1502, 1503, 1504, 1505, 1506, 1507, 1508, 1509, 1510, 1511
+);
diff --git a/scripts/sql/V1.0.9__delete_tenant_button_menus.sql b/scripts/sql/V1.0.9__delete_tenant_button_menus.sql
new file mode 100644
index 0000000..7f7c4f9
--- /dev/null
+++ b/scripts/sql/V1.0.9__delete_tenant_button_menus.sql
@@ -0,0 +1,15 @@
+-- =====================================================
+-- 删除租户/租户套餐相关按钮菜单(父菜单 121、122 已在 V1.0.8 中删除,此处清理遗留按钮项)
+-- 租户查询/新增/修改/删除/导出(1606-1610)、租户套餐查询/新增/修改/删除/导出(1611-1615)
+-- =====================================================
+
+-- 1. 删除角色菜单关联
+DELETE FROM sys_role_menu WHERE menu_id IN (
+ 1606, 1607, 1608, 1609, 1610, -- 租户-按钮
+ 1611, 1612, 1613, 1614, 1615 -- 租户套餐-按钮
+);
+
+-- 2. 删除菜单
+DELETE FROM sys_menu WHERE menu_id IN (
+ 1606, 1607, 1608, 1609, 1610, 1611, 1612, 1613, 1614, 1615
+);