Junjie
9 小时以前 8bfe1168a42d4e3750a15b0c0fb0a7629d6cf91c
#日志清理与手动操作权限
17个文件已添加
5个文件已修改
1182 ■■■■■ 已修改文件
src/main/java/com/zy/asrs/controller/WrkMastController.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/enums/RedisKeyType.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/controller/HighPrivilegeGrantController.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/controller/LogCleanupController.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/domain/param/HighPrivilegeGrantParam.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/domain/param/LogCleanupConfigParam.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/domain/param/LogCleanupRunParam.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/mapper/LogCleanupMapper.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/model/HighPrivilegeGrantStatus.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/model/LogCleanupExecutionResult.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/service/HighPrivilegeGrantService.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/service/LogCleanupService.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/service/impl/HighPrivilegeGrantServiceImpl.java 114 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/service/impl/LogCleanupServiceImpl.java 168 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/timer/LogCleanupScheduler.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/LogCleanupMapper.xml 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260401_add_log_cleanup_menu_and_config.sql 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/config/config.js 158 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/logCleanup/logCleanup.js 164 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/wrkMast/wrkMast.js 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/config/config.html 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/logCleanup/logCleanup.html 165 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/controller/WrkMastController.java
@@ -7,10 +7,13 @@
import com.core.common.DateUtils;
import com.core.common.R;
import com.zy.asrs.domain.param.CreateOutTaskBatchParam;
import com.zy.asrs.domain.param.CancelTaskParam;
import com.zy.asrs.domain.param.CompleteTaskParam;
import com.zy.asrs.entity.WrkMast;
import com.zy.asrs.service.WrkMastService;
import com.zy.common.service.CommonService;
import com.zy.common.web.BaseController;
import com.zy.system.service.HighPrivilegeGrantService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
@@ -30,6 +33,8 @@
    private WrkMastService wrkMastService;
    @Autowired
    private CommonService commonService;
    @Autowired
    private HighPrivilegeGrantService highPrivilegeGrantService;
    @RequestMapping(value = "/wrkMast/list/auth")
    @ManagerAuth
@@ -68,6 +73,26 @@
        return R.error("生成批量出库任务失败");
    }
    @PostMapping(value = "/wrkMast/complete/auth")
    @ManagerAuth(memo = "工作档完成任务")
    public R completeTask(@RequestBody CompleteTaskParam param) {
        if (param == null) {
            return R.error("参数不能为空");
        }
        highPrivilegeGrantService.assertGranted(request.getHeader("token"), "完成任务");
        return commonService.completeTask(param) ? R.ok() : R.error("任务完成失败");
    }
    @PostMapping(value = "/wrkMast/cancel/auth")
    @ManagerAuth(memo = "工作档取消任务")
    public R cancelTask(@RequestBody CancelTaskParam param) {
        if (param == null) {
            return R.error("参数不能为空");
        }
        highPrivilegeGrantService.assertGranted(request.getHeader("token"), "取消任务");
        return commonService.cancelTask(param) ? R.ok() : R.error("任务取消失败");
    }
    private <T> void convert(Map<String, Object> map, QueryWrapper<T> wrapper){
        for (Map.Entry<String, Object> entry : map.entrySet()){
            String val = String.valueOf(entry.getValue());
src/main/java/com/zy/core/enums/RedisKeyType.java
@@ -74,6 +74,7 @@
    CURRENT_CIRCLE_TASK_CRN_NO("current_circle_task_crn_no_"),
    MAIN_PROCESS_PSEUDOCODE("main_process_pseudocode"),
    PLANNER_SCHEDULE("planner_schedule_"),
    HIGH_PRIVILEGE_GRANT("high_privilege_grant_"),
    ;
    public String key;
src/main/java/com/zy/system/controller/HighPrivilegeGrantController.java
New file
@@ -0,0 +1,33 @@
package com.zy.system.controller;
import com.core.annotations.ManagerAuth;
import com.core.common.R;
import com.zy.common.web.BaseController;
import com.zy.system.domain.param.HighPrivilegeGrantParam;
import com.zy.system.model.HighPrivilegeGrantStatus;
import com.zy.system.service.HighPrivilegeGrantService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HighPrivilegeGrantController extends BaseController {
    @Autowired
    private HighPrivilegeGrantService highPrivilegeGrantService;
    @PostMapping("/highPrivilege/grant/auth")
    @ManagerAuth(memo = "最高权限授权")
    public R grant(@RequestBody HighPrivilegeGrantParam param) {
        HighPrivilegeGrantStatus status = highPrivilegeGrantService.grant(request.getHeader("token"), param);
        return R.ok().add(status);
    }
    @GetMapping("/highPrivilege/status/auth")
    @ManagerAuth
    public R status() {
        return R.ok().add(highPrivilegeGrantService.getStatus(request.getHeader("token")));
    }
}
src/main/java/com/zy/system/controller/LogCleanupController.java
New file
@@ -0,0 +1,63 @@
package com.zy.system.controller;
import com.core.annotations.ManagerAuth;
import com.core.common.R;
import com.zy.common.web.BaseController;
import com.zy.system.domain.param.LogCleanupConfigParam;
import com.zy.system.domain.param.LogCleanupRunParam;
import com.zy.system.model.LogCleanupExecutionResult;
import com.zy.system.service.HighPrivilegeGrantService;
import com.zy.system.service.LogCleanupService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
@RestController
public class LogCleanupController extends BaseController {
    @Autowired
    private LogCleanupService logCleanupService;
    @Autowired
    private HighPrivilegeGrantService highPrivilegeGrantService;
    @GetMapping("/logCleanup/config/auth")
    @ManagerAuth
    public R getConfig() {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("expireDays", logCleanupService.getExpireDays());
        result.put("autoTime", "每日 23:00");
        result.put("tables", logCleanupService.getSupportedTables());
        return R.ok().add(result);
    }
    @PostMapping("/logCleanup/config/save/auth")
    @ManagerAuth(memo = "保存日志清理配置")
    public R saveConfig(@RequestBody LogCleanupConfigParam param) {
        if (param == null) {
            return R.error("参数不能为空");
        }
        logCleanupService.saveExpireDays(param.getExpireDays());
        return R.ok();
    }
    @PostMapping("/logCleanup/run/auth")
    @ManagerAuth(memo = "手动清理日志")
    public R run(@RequestBody LogCleanupRunParam param) {
        if (param == null) {
            return R.error("参数不能为空");
        }
        highPrivilegeGrantService.assertGranted(request.getHeader("token"), "手动清理日志");
        LogCleanupExecutionResult result;
        if ("selected".equals(param.getMode())) {
            result = logCleanupService.cleanupSelected(logCleanupService.getExpireDays(), param.getTables());
        } else {
            result = logCleanupService.cleanupAll(logCleanupService.getExpireDays());
        }
        return R.ok().add(result);
    }
}
src/main/java/com/zy/system/domain/param/HighPrivilegeGrantParam.java
New file
@@ -0,0 +1,11 @@
package com.zy.system.domain.param;
import lombok.Data;
@Data
public class HighPrivilegeGrantParam {
    private String account;
    private String password;
}
src/main/java/com/zy/system/domain/param/LogCleanupConfigParam.java
New file
@@ -0,0 +1,9 @@
package com.zy.system.domain.param;
import lombok.Data;
@Data
public class LogCleanupConfigParam {
    private Integer expireDays;
}
src/main/java/com/zy/system/domain/param/LogCleanupRunParam.java
New file
@@ -0,0 +1,13 @@
package com.zy.system.domain.param;
import lombok.Data;
import java.util.List;
@Data
public class LogCleanupRunParam {
    private String mode;
    private List<String> tables;
}
src/main/java/com/zy/system/mapper/LogCleanupMapper.java
New file
@@ -0,0 +1,15 @@
package com.zy.system.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;
@Mapper
@Repository
public interface LogCleanupMapper {
    int deleteExpiredBatch(@Param("table") String table,
                           @Param("timeColumn") String timeColumn,
                           @Param("expireDays") int expireDays,
                           @Param("limit") int limit);
}
src/main/java/com/zy/system/model/HighPrivilegeGrantStatus.java
New file
@@ -0,0 +1,13 @@
package com.zy.system.model;
import lombok.Data;
@Data
public class HighPrivilegeGrantStatus {
    private boolean granted;
    private long remainingSeconds;
    private Long expireAt;
}
src/main/java/com/zy/system/model/LogCleanupExecutionResult.java
New file
@@ -0,0 +1,20 @@
package com.zy.system.model;
import lombok.Data;
import java.util.LinkedHashMap;
import java.util.Map;
@Data
public class LogCleanupExecutionResult {
    private String mode;
    private Integer expireDays;
    private long totalDeleted;
    private long executeTime;
    private Map<String, Long> detail = new LinkedHashMap<>();
}
src/main/java/com/zy/system/service/HighPrivilegeGrantService.java
New file
@@ -0,0 +1,13 @@
package com.zy.system.service;
import com.zy.system.domain.param.HighPrivilegeGrantParam;
import com.zy.system.model.HighPrivilegeGrantStatus;
public interface HighPrivilegeGrantService {
    HighPrivilegeGrantStatus grant(String token, HighPrivilegeGrantParam param);
    HighPrivilegeGrantStatus getStatus(String token);
    void assertGranted(String token, String actionName);
}
src/main/java/com/zy/system/service/LogCleanupService.java
New file
@@ -0,0 +1,20 @@
package com.zy.system.service;
import com.zy.system.model.LogCleanupExecutionResult;
import java.util.Map;
public interface LogCleanupService {
    Integer getExpireDays();
    void saveExpireDays(Integer expireDays);
    LogCleanupExecutionResult cleanupAll(Integer expireDays);
    LogCleanupExecutionResult cleanupSelected(Integer expireDays, java.util.List<String> tables);
    LogCleanupExecutionResult cleanupScheduled();
    Map<String, String> getSupportedTables();
}
src/main/java/com/zy/system/service/impl/HighPrivilegeGrantServiceImpl.java
New file
@@ -0,0 +1,114 @@
package com.zy.system.service.impl;
import com.core.common.Cools;
import com.core.exception.CoolException;
import com.zy.common.utils.RedisUtil;
import com.zy.core.enums.RedisKeyType;
import com.zy.system.domain.param.HighPrivilegeGrantParam;
import com.zy.system.entity.Role;
import com.zy.system.entity.User;
import com.zy.system.model.HighPrivilegeGrantStatus;
import com.zy.system.service.HighPrivilegeGrantService;
import com.zy.system.service.RoleService;
import com.zy.system.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Service("highPrivilegeGrantService")
public class HighPrivilegeGrantServiceImpl implements HighPrivilegeGrantService {
    private static final String ADMIN_ROLE_CODE = "admin";
    private static final int USER_ENABLED = 1;
    private static final long GRANT_TTL_SECONDS = 30L * 60L;
    @Autowired
    private UserService userService;
    @Autowired
    private RoleService roleService;
    @Autowired
    private RedisUtil redisUtil;
    @Override
    public HighPrivilegeGrantStatus grant(String token, HighPrivilegeGrantParam param) {
        if (Cools.isEmpty(token)) {
            throw new CoolException("当前登录已失效,请重新登录");
        }
        if (param == null || Cools.isEmpty(param.getAccount(), param.getPassword())) {
            throw new CoolException("账号和密码不能为空");
        }
        User user = userService.getByMobileWithSecurity(param.getAccount());
        if (user == null) {
            throw new CoolException("账号或密码错误");
        }
        if (!Integer.valueOf(USER_ENABLED).equals(user.getStatus())) {
            throw new CoolException("授权账号已禁用");
        }
        Role role = roleService.getById(user.getRoleId());
        if (role == null || !ADMIN_ROLE_CODE.equals(role.getCode())) {
            throw new CoolException("仅admin管理员账号可获取最高权限");
        }
        if (!Cools.eq(user.getPassword(), param.getPassword())) {
            throw new CoolException("账号或密码错误");
        }
        long now = System.currentTimeMillis();
        Map<String, Object> payload = new HashMap<>();
        payload.put("account", user.getMobile());
        payload.put("userId", user.getId());
        payload.put("grantTime", now);
        payload.put("expireAt", now + GRANT_TTL_SECONDS * 1000L);
        redisUtil.set(buildRedisKey(token), payload, GRANT_TTL_SECONDS);
        return getStatus(token);
    }
    @Override
    public HighPrivilegeGrantStatus getStatus(String token) {
        HighPrivilegeGrantStatus status = new HighPrivilegeGrantStatus();
        if (Cools.isEmpty(token)) {
            status.setGranted(false);
            return status;
        }
        String redisKey = buildRedisKey(token);
        long remainingSeconds = redisUtil.getExpire(redisKey);
        if (remainingSeconds <= 0 || !redisUtil.hasKey(redisKey)) {
            status.setGranted(false);
            status.setRemainingSeconds(0L);
            status.setExpireAt(null);
            return status;
        }
        status.setGranted(true);
        status.setRemainingSeconds(remainingSeconds);
        Object payload = redisUtil.get(redisKey);
        if (payload instanceof Map) {
            Object expireAt = ((Map<?, ?>) payload).get("expireAt");
            if (expireAt instanceof Number) {
                status.setExpireAt(((Number) expireAt).longValue());
            } else if (expireAt != null) {
                try {
                    status.setExpireAt(Long.parseLong(String.valueOf(expireAt)));
                } catch (NumberFormatException ignore) {
                    status.setExpireAt(System.currentTimeMillis() + remainingSeconds * 1000L);
                }
            }
        }
        if (status.getExpireAt() == null) {
            status.setExpireAt(System.currentTimeMillis() + remainingSeconds * 1000L);
        }
        return status;
    }
    @Override
    public void assertGranted(String token, String actionName) {
        HighPrivilegeGrantStatus status = getStatus(token);
        if (!status.isGranted()) {
            throw new CoolException(actionName + "需要最高权限授权,请先在开发专用->系统配置完成授权");
        }
    }
    private String buildRedisKey(String token) {
        return RedisKeyType.HIGH_PRIVILEGE_GRANT.key + token;
    }
}
src/main/java/com/zy/system/service/impl/LogCleanupServiceImpl.java
New file
@@ -0,0 +1,168 @@
package com.zy.system.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.core.exception.CoolException;
import com.zy.system.entity.Config;
import com.zy.system.mapper.LogCleanupMapper;
import com.zy.system.model.LogCleanupExecutionResult;
import com.zy.system.service.ConfigService;
import com.zy.system.service.LogCleanupService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@Slf4j
@Service("logCleanupService")
public class LogCleanupServiceImpl implements LogCleanupService {
    private static final String CONFIG_CODE_EXPIRE_DAYS = "logCleanupExpireDays";
    private static final int DEFAULT_EXPIRE_DAYS = 180;
    private static final int DELETE_BATCH_LIMIT = 50000;
    private static final String MODE_ALL = "all";
    private static final String MODE_SELECTED = "selected";
    private static final Map<String, String> SUPPORTED_TABLES = new LinkedHashMap<>();
    private static final Map<String, String> TIME_COLUMNS = new LinkedHashMap<>();
    static {
        register("asr_bas_crnp_opt", "堆垛机操作日志", "update_time");
        register("asr_bas_dual_crnp_opt", "双工位堆垛机操作日志", "update_time");
        register("asr_bas_rgv_err_log", "RGV故障日志", "create_time");
        register("asr_wrk_mast_log", "工作档日志", "modi_time");
        register("asr_bas_dual_crnp_err_log", "双工位堆垛机故障日志", "create_time");
        register("asr_bas_station_err_log", "站点故障日志", "create_time");
        register("asr_bas_station_opt", "站点操作日志", "update_time");
        register("asr_wrk_analysis", "任务执行分析", "finish_time");
        register("sys_http_request_log", "HTTP请求日志", "create_time");
        register("sys_operate_log", "操作日志", "create_time");
    }
    @Autowired
    private ConfigService configService;
    @Autowired
    private LogCleanupMapper logCleanupMapper;
    @Override
    public Integer getExpireDays() {
        Config config = configService.getOne(new QueryWrapper<Config>().eq("code", CONFIG_CODE_EXPIRE_DAYS));
        if (config == null || config.getValue() == null || config.getValue().trim().isEmpty()) {
            return DEFAULT_EXPIRE_DAYS;
        }
        try {
            int value = Integer.parseInt(config.getValue().trim());
            return value > 0 ? value : DEFAULT_EXPIRE_DAYS;
        } catch (NumberFormatException ignore) {
            return DEFAULT_EXPIRE_DAYS;
        }
    }
    @Override
    public void saveExpireDays(Integer expireDays) {
        int normalized = normalizeExpireDays(expireDays);
        Config config = configService.getOne(new QueryWrapper<Config>().eq("code", CONFIG_CODE_EXPIRE_DAYS));
        if (config == null) {
            config = new Config();
            config.setName("日志流水清理保留天数");
            config.setCode(CONFIG_CODE_EXPIRE_DAYS);
            config.setValue(String.valueOf(normalized));
            config.setType((short) 1);
            config.setStatus((short) 1);
            config.setSelectType("develop");
            configService.save(config);
            return;
        }
        config.setValue(String.valueOf(normalized));
        configService.updateById(config);
    }
    @Override
    public LogCleanupExecutionResult cleanupAll(Integer expireDays) {
        return executeCleanup(MODE_ALL, expireDays, new ArrayList<>(SUPPORTED_TABLES.keySet()));
    }
    @Override
    public LogCleanupExecutionResult cleanupSelected(Integer expireDays, List<String> tables) {
        if (tables == null || tables.isEmpty()) {
            throw new CoolException("请选择至少一张日志表");
        }
        LinkedHashSet<String> normalized = new LinkedHashSet<>();
        for (String table : tables) {
            if (table == null || table.trim().isEmpty()) {
                continue;
            }
            String tableName = table.trim();
            if (!SUPPORTED_TABLES.containsKey(tableName)) {
                throw new CoolException("存在不支持清理的日志表: " + tableName);
            }
            normalized.add(tableName);
        }
        if (normalized.isEmpty()) {
            throw new CoolException("请选择至少一张日志表");
        }
        return executeCleanup(MODE_SELECTED, expireDays, new ArrayList<>(normalized));
    }
    @Override
    public LogCleanupExecutionResult cleanupScheduled() {
        try {
            return cleanupAll(getExpireDays());
        } catch (Exception ex) {
            log.error("日志流水定时清理失败", ex);
            throw ex;
        }
    }
    @Override
    public Map<String, String> getSupportedTables() {
        return new LinkedHashMap<>(SUPPORTED_TABLES);
    }
    private LogCleanupExecutionResult executeCleanup(String mode, Integer expireDays, List<String> tables) {
        int normalized = normalizeExpireDays(expireDays);
        LogCleanupExecutionResult result = new LogCleanupExecutionResult();
        result.setMode(mode);
        result.setExpireDays(normalized);
        result.setExecuteTime(System.currentTimeMillis());
        long totalDeleted = 0L;
        for (String table : tables) {
            long tableDeleted = deleteTable(table, TIME_COLUMNS.get(table), normalized);
            result.getDetail().put(table, tableDeleted);
            totalDeleted += tableDeleted;
        }
        result.setTotalDeleted(totalDeleted);
        return result;
    }
    private long deleteTable(String table, String timeColumn, int expireDays) {
        long deleted = 0L;
        while (true) {
            int affected = logCleanupMapper.deleteExpiredBatch(table, timeColumn, expireDays, DELETE_BATCH_LIMIT);
            if (affected <= 0) {
                break;
            }
            deleted += affected;
            if (affected < DELETE_BATCH_LIMIT) {
                break;
            }
        }
        return deleted;
    }
    private int normalizeExpireDays(Integer expireDays) {
        if (expireDays == null || expireDays <= 0) {
            throw new CoolException("日志保留天数必须大于0");
        }
        return expireDays;
    }
    private static void register(String table, String label, String timeColumn) {
        SUPPORTED_TABLES.put(table, label);
        TIME_COLUMNS.put(table, timeColumn);
    }
}
src/main/java/com/zy/system/timer/LogCleanupScheduler.java
New file
@@ -0,0 +1,24 @@
package com.zy.system.timer;
import com.zy.system.service.LogCleanupService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class LogCleanupScheduler {
    @Autowired
    private LogCleanupService logCleanupService;
    @Scheduled(cron = "0 0 23 * * ?")
    public void cleanupExpiredLogs() {
        try {
            logCleanupService.cleanupScheduled();
        } catch (Exception ex) {
            log.error("日志流水自动清理失败", ex);
        }
    }
}
src/main/resources/mapper/LogCleanupMapper.xml
New file
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zy.system.mapper.LogCleanupMapper">
    <delete id="deleteExpiredBatch">
        DELETE FROM ${table}
        WHERE ${timeColumn} IS NOT NULL
          AND ${timeColumn} &lt; DATE_SUB(NOW(), INTERVAL #{expireDays} DAY)
        LIMIT #{limit}
    </delete>
</mapper>
src/main/resources/sql/20260401_add_log_cleanup_menu_and_config.sql
New file
@@ -0,0 +1,80 @@
-- 新增 日志流水清理 菜单,并初始化日志保留天数配置
-- 说明:执行本脚本后,请在“角色授权”里给对应角色勾选新菜单和“查看”权限。
INSERT INTO sys_config(name, code, value, type, status, select_type)
SELECT '日志流水清理保留天数', 'logCleanupExpireDays', '180', 1, 1, 'develop'
FROM dual
WHERE NOT EXISTS (
    SELECT 1
    FROM sys_config
    WHERE code = 'logCleanupExpireDays'
);
SET @log_cleanup_parent_id := COALESCE(
  (
    SELECT id
    FROM sys_resource
    WHERE code = 'develop' AND level = 1
    ORDER BY id
    LIMIT 1
  ),
  (
    SELECT id
    FROM sys_resource
    WHERE code = 'logReport' AND level = 1
    ORDER BY id
    LIMIT 1
  )
);
INSERT INTO sys_resource(code, name, resource_id, level, sort, status)
SELECT 'logCleanup/logCleanup.html', '日志流水清理', @log_cleanup_parent_id, 2, 995, 1
FROM dual
WHERE @log_cleanup_parent_id IS NOT NULL
  AND NOT EXISTS (
    SELECT 1
    FROM sys_resource
    WHERE code = 'logCleanup/logCleanup.html' AND level = 2
  );
UPDATE sys_resource
SET name = '日志流水清理',
    resource_id = @log_cleanup_parent_id,
    level = 2,
    sort = 995,
    status = 1
WHERE code = 'logCleanup/logCleanup.html' AND level = 2;
SET @log_cleanup_id := (
  SELECT id
  FROM sys_resource
  WHERE code = 'logCleanup/logCleanup.html' AND level = 2
  ORDER BY id
  LIMIT 1
);
INSERT INTO sys_resource(code, name, resource_id, level, sort, status)
SELECT 'logCleanup/logCleanup.html#view', '查看', @log_cleanup_id, 3, 1, 1
FROM dual
WHERE @log_cleanup_id IS NOT NULL
  AND NOT EXISTS (
    SELECT 1
    FROM sys_resource
    WHERE code = 'logCleanup/logCleanup.html#view' AND level = 3
  );
UPDATE sys_resource
SET name = '查看',
    resource_id = @log_cleanup_id,
    level = 3,
    sort = 1,
    status = 1
WHERE code = 'logCleanup/logCleanup.html#view' AND level = 3;
SELECT id, code, name, resource_id, level, sort, status
FROM sys_resource
WHERE code IN (
  'logCleanup/logCleanup.html',
  'logCleanup/logCleanup.html#view'
)
ORDER BY level, sort, id;
src/main/webapp/static/js/config/config.js
@@ -1437,9 +1437,31 @@
                    tableHeight: 420,
                    layoutTimer: null,
                    tableResizeHandler: null,
                    grantTimer: null,
                    dialogForm: createFormDefaults(),
                    dialogDisplay: createDisplayDefaults(),
                    dialogRules: createFormRules()
                    dialogRules: createFormRules(),
                    grantStatus: {
                        granted: false,
                        remainingSeconds: 0,
                        expireAt: null
                    },
                    grantDialog: {
                        visible: false,
                        submitting: false,
                        form: {
                            account: '',
                            password: ''
                        },
                        rules: {
                            account: [
                                { required: true, message: '请输入管理员账号', trigger: 'blur' }
                            ],
                            password: [
                                { required: true, message: '请输入密码', trigger: 'blur' }
                            ]
                        }
                    }
                };
            },
            computed: {
@@ -1496,11 +1518,18 @@
                },
                isDialogReadonly: function () {
                    return this.dialog.mode === 'detail';
                },
                grantStatusText: function () {
                    if (!this.grantStatus.granted) {
                        return '当前无最高权限授权';
                    }
                    return '剩余 ' + this.formatRemainingSeconds(this.grantStatus.remainingSeconds);
                }
            },
            created: function () {
                this.fetchSelectTypeOptions();
                this.loadTable();
                this.loadGrantStatus();
            },
            mounted: function () {
                var self = this;
@@ -1519,8 +1548,135 @@
                    window.removeEventListener('resize', this.tableResizeHandler);
                    this.tableResizeHandler = null;
                }
                this.stopGrantCountdown();
            },
            methods: $.extend({}, sharedMethods, {
                formatRemainingSeconds: function (seconds) {
                    var total = Number(seconds || 0);
                    var minutes;
                    var remainSeconds;
                    if (total <= 0) {
                        return '0秒';
                    }
                    minutes = Math.floor(total / 60);
                    remainSeconds = total % 60;
                    if (minutes <= 0) {
                        return remainSeconds + '秒';
                    }
                    if (remainSeconds === 0) {
                        return minutes + '分钟';
                    }
                    return minutes + '分' + remainSeconds + '秒';
                },
                stopGrantCountdown: function () {
                    if (this.grantTimer) {
                        clearInterval(this.grantTimer);
                        this.grantTimer = null;
                    }
                },
                startGrantCountdown: function () {
                    var self = this;
                    self.stopGrantCountdown();
                    if (!self.grantStatus.granted || Number(self.grantStatus.remainingSeconds) <= 0) {
                        return;
                    }
                    self.grantTimer = setInterval(function () {
                        if (!self.grantStatus.granted) {
                            self.stopGrantCountdown();
                            return;
                        }
                        if (self.grantStatus.remainingSeconds <= 1) {
                            self.grantStatus = {
                                granted: false,
                                remainingSeconds: 0,
                                expireAt: null
                            };
                            self.stopGrantCountdown();
                            return;
                        }
                        self.grantStatus.remainingSeconds -= 1;
                    }, 1000);
                },
                applyGrantStatus: function (payload) {
                    var status = payload || {};
                    this.grantStatus = {
                        granted: !!status.granted,
                        remainingSeconds: Number(status.remainingSeconds || 0),
                        expireAt: status.expireAt || null
                    };
                    this.startGrantCountdown();
                },
                loadGrantStatus: function () {
                    var self = this;
                    $.ajax({
                        url: baseUrl + '/highPrivilege/status/auth',
                        method: 'GET',
                        headers: self.authHeaders(),
                        success: function (res) {
                            if (self.handleForbidden(res)) {
                                return;
                            }
                            if (!res || res.code !== 200) {
                                return;
                            }
                            self.applyGrantStatus(res.data || {});
                        }
                    });
                },
                resetGrantDialog: function () {
                    this.grantDialog.submitting = false;
                    this.grantDialog.form = {
                        account: '',
                        password: ''
                    };
                    if (this.$refs.grantForm) {
                        this.$refs.grantForm.clearValidate();
                    }
                },
                openGrantDialog: function () {
                    this.grantDialog.visible = true;
                    this.$nextTick(this.resetGrantDialog);
                },
                submitGrant: function () {
                    var self = this;
                    if (!self.$refs.grantForm) {
                        return;
                    }
                    self.$refs.grantForm.validate(function (valid) {
                        if (!valid) {
                            return false;
                        }
                        self.grantDialog.submitting = true;
                        $.ajax({
                            url: baseUrl + '/highPrivilege/grant/auth',
                            method: 'POST',
                            contentType: 'application/json;charset=UTF-8',
                            headers: self.authHeaders(),
                            data: JSON.stringify({
                                account: self.grantDialog.form.account,
                                password: hex_md5(self.grantDialog.form.password || '')
                            }),
                            success: function (res) {
                                self.grantDialog.submitting = false;
                                if (self.handleForbidden(res)) {
                                    return;
                                }
                                if (!res || res.code !== 200) {
                                    self.$message.error((res && res.msg) ? res.msg : '授权失败');
                                    return;
                                }
                                self.$message.success('授权成功');
                                self.grantDialog.visible = false;
                                self.applyGrantStatus(res.data || {});
                            },
                            error: function () {
                                self.grantDialog.submitting = false;
                                self.$message.error('授权失败');
                            }
                        });
                        return true;
                    });
                },
                calculateTableHeight: function () {
                    var viewportHeight = window.innerHeight || document.documentElement.clientHeight || 860;
                    var tableWrap = this.$refs.tableWrap;
src/main/webapp/static/js/logCleanup/logCleanup.js
New file
@@ -0,0 +1,164 @@
(function () {
    new Vue({
        el: "#app",
        data: function () {
            return {
                loading: false,
                saving: false,
                running: false,
                tableOptions: [],
                result: null,
                form: {
                    expireDays: 180,
                    mode: "all",
                    tables: []
                }
            };
        },
        computed: {
            resultDetails: function () {
                var vm = this;
                if (!vm.result || !vm.result.detail) {
                    return [];
                }
                return Object.keys(vm.result.detail).map(function (table) {
                    return {
                        table: table,
                        label: vm.resolveTableLabel(table),
                        count: vm.result.detail[table]
                    };
                });
            }
        },
        created: function () {
            this.loadConfig();
        },
        methods: {
            authHeaders: function () {
                return { token: localStorage.getItem("token") };
            },
            handleForbidden: function (res) {
                if (res && Number(res.code) === 403) {
                    top.location.href = baseUrl + "/";
                    return true;
                }
                return false;
            },
            resolveTableLabel: function (table) {
                var index;
                for (index = 0; index < this.tableOptions.length; index += 1) {
                    if (this.tableOptions[index].value === table) {
                        return this.tableOptions[index].label;
                    }
                }
                return table;
            },
            loadConfig: function () {
                var vm = this;
                vm.loading = true;
                $.ajax({
                    url: baseUrl + "/logCleanup/config/auth",
                    method: "GET",
                    headers: vm.authHeaders(),
                    success: function (res) {
                        var tables;
                        vm.loading = false;
                        if (vm.handleForbidden(res)) {
                            return;
                        }
                        if (!res || res.code !== 200) {
                            vm.$message.error((res && res.msg) ? res.msg : "加载日志清理配置失败");
                            return;
                        }
                        vm.form.expireDays = Number((res.data && res.data.expireDays) || 180);
                        tables = (res.data && res.data.tables) || {};
                        vm.tableOptions = Object.keys(tables).map(function (key) {
                            return {
                                value: key,
                                label: tables[key]
                            };
                        });
                    },
                    error: function () {
                        vm.loading = false;
                        vm.$message.error("加载日志清理配置失败");
                    }
                });
            },
            saveConfig: function () {
                var vm = this;
                if (!vm.form.expireDays || Number(vm.form.expireDays) <= 0) {
                    vm.$message.warning("日志保留天数必须大于0");
                    return;
                }
                vm.saving = true;
                $.ajax({
                    url: baseUrl + "/logCleanup/config/save/auth",
                    method: "POST",
                    contentType: "application/json;charset=UTF-8",
                    headers: vm.authHeaders(),
                    data: JSON.stringify({
                        expireDays: Number(vm.form.expireDays)
                    }),
                    success: function (res) {
                        vm.saving = false;
                        if (vm.handleForbidden(res)) {
                            return;
                        }
                        if (!res || res.code !== 200) {
                            vm.$message.error((res && res.msg) ? res.msg : "保存日志清理配置失败");
                            return;
                        }
                        vm.$message.success("保存成功");
                    },
                    error: function () {
                        vm.saving = false;
                        vm.$message.error("保存日志清理配置失败");
                    }
                });
            },
            runCleanup: function () {
                var vm = this;
                var requestBody;
                if (vm.form.mode === "selected" && (!vm.form.tables || !vm.form.tables.length)) {
                    vm.$message.warning("请选择至少一张日志表");
                    return;
                }
                requestBody = {
                    mode: vm.form.mode,
                    tables: vm.form.mode === "selected" ? vm.form.tables : []
                };
                vm.$confirm("确定立即执行日志清理吗?", "提示", {
                    type: "warning",
                    confirmButtonText: "确定",
                    cancelButtonText: "取消"
                }).then(function () {
                    vm.running = true;
                    $.ajax({
                        url: baseUrl + "/logCleanup/run/auth",
                        method: "POST",
                        contentType: "application/json;charset=UTF-8",
                        headers: vm.authHeaders(),
                        data: JSON.stringify(requestBody),
                        success: function (res) {
                            vm.running = false;
                            if (vm.handleForbidden(res)) {
                                return;
                            }
                            if (!res || res.code !== 200) {
                                vm.$message.error((res && res.msg) ? res.msg : "手动清理失败");
                                return;
                            }
                            vm.result = res.data || null;
                            vm.$message.success("手动清理执行完成");
                        },
                        error: function () {
                            vm.running = false;
                            vm.$message.error("手动清理失败");
                        }
                    });
                }).catch(function () {});
            }
        }
    });
})();
src/main/webapp/static/js/wrkMast/wrkMast.js
@@ -308,7 +308,7 @@
                    cancelButtonText: "取消"
                }).then(function () {
                    $.ajax({
                        url: baseUrl + "/openapi/completeTask",
                        url: baseUrl + "/wrkMast/complete/auth",
                        contentType: "application/json",
                        headers: { token: localStorage.getItem("token") },
                        data: JSON.stringify({ wrkNo: row.wrkNo }),
@@ -339,7 +339,7 @@
                    cancelButtonText: "取消"
                }).then(function () {
                    $.ajax({
                        url: baseUrl + "/openapi/cancelTask",
                        url: baseUrl + "/wrkMast/cancel/auth",
                        contentType: "application/json",
                        headers: { token: localStorage.getItem("token") },
                        data: JSON.stringify({ wrkNo: row.wrkNo }),
src/main/webapp/views/config/config.html
@@ -114,6 +114,7 @@
        .toolbar-ops {
            justify-content: flex-end;
            align-items: center;
        }
        .list-toolbar .el-input__inner,
@@ -246,6 +247,15 @@
            max-height: 280px;
            overflow: auto;
            padding-right: 4px;
        }
        .grant-status-text {
            display: inline-flex;
            align-items: center;
            min-height: 32px;
            padding: 0 6px;
            color: #5c6b7a;
            font-size: 12px;
        }
        .dialog-panel .el-dialog {
@@ -468,6 +478,11 @@
                        </el-popover>
                        <el-button size="small" plain icon="el-icon-download" :loading="exporting" @click="exportRows">导出</el-button>
                        <el-button size="small" plain type="warning" icon="el-icon-refresh-right" @click="refreshCache">刷新缓存</el-button>
                        <el-button size="small" plain type="success" icon="el-icon-key" @click="openGrantDialog">最高权限授权</el-button>
                        <el-tag size="small" :type="grantStatus.granted ? 'success' : 'info'">
                            {{ grantStatus.granted ? '已授权' : '未授权' }}
                        </el-tag>
                        <span class="grant-status-text">{{ grantStatusText }}</span>
                    </div>
                </div>
            </div>
@@ -717,10 +732,53 @@
            <el-button v-if="!isDialogReadonly" type="primary" :loading="dialog.submitting" @click="submitDialog">保存</el-button>
        </div>
    </el-dialog>
    <el-dialog
        class="dialog-panel"
        title="最高权限授权"
        :visible.sync="grantDialog.visible"
        width="460px"
        :close-on-click-modal="false"
        @closed="resetGrantDialog">
        <el-alert
            title="仅角色编码为admin的启用管理员账号可授权,授权有效期30分钟。"
            type="warning"
            :closable="false"
            show-icon
            style="margin-bottom: 16px;">
        </el-alert>
        <el-form
            ref="grantForm"
            :model="grantDialog.form"
            :rules="grantDialog.rules"
            label-width="90px"
            size="small">
            <el-form-item label="账号" prop="account">
                <el-input
                    v-model.trim="grantDialog.form.account"
                    placeholder="请输入管理员账号">
                </el-input>
            </el-form-item>
            <el-form-item label="密码" prop="password">
                <el-input
                    v-model="grantDialog.form.password"
                    type="password"
                    show-password
                    placeholder="请输入密码"
                    @keyup.enter.native="submitGrant">
                </el-input>
            </el-form-item>
        </el-form>
        <div slot="footer" class="dialog-footer">
            <el-button @click="grantDialog.visible = false">取消</el-button>
            <el-button type="primary" :loading="grantDialog.submitting" @click="submitGrant">确认授权</el-button>
        </div>
    </el-dialog>
</div>
<script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
<script type="text/javascript" src="../../static/js/tools/md5.js"></script>
<script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../../static/vue/element/element.js"></script>
<script type="text/javascript" src="../../static/js/config/config.js" charset="utf-8"></script>
src/main/webapp/views/logCleanup/logCleanup.html
New file
@@ -0,0 +1,165 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>日志流水清理</title>
    <meta name="renderer" content="webkit">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
    <link rel="stylesheet" href="../../static/vue/element/element.css">
    <link rel="stylesheet" href="../../static/css/cool.css">
    <style>
        [v-cloak] { display: none; }
        html, body {
            margin: 0;
            min-height: 100%;
            color: #243447;
            font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif;
            background:
                radial-gradient(1000px 420px at 0% -10%, rgba(44, 107, 193, 0.12), transparent 56%),
                radial-gradient(900px 400px at 100% 0%, rgba(28, 150, 126, 0.10), transparent 58%),
                linear-gradient(180deg, #f2f6fb 0%, #f8fafc 100%);
        }
        .page-shell {
            max-width: 1380px;
            margin: 0 auto;
            padding: 14px;
            box-sizing: border-box;
        }
        .card-shell {
            border-radius: 24px;
            border: 1px solid rgba(216, 226, 238, 0.95);
            background: rgba(255, 255, 255, 0.94);
            box-shadow: 0 16px 32px rgba(44, 67, 96, 0.08);
            overflow: hidden;
        }
        .section-head {
            padding: 18px 20px 10px;
            border-bottom: 1px solid rgba(222, 230, 239, 0.92);
        }
        .section-title {
            margin: 0;
            font-size: 22px;
            font-weight: 700;
        }
        .section-subtitle {
            margin-top: 8px;
            font-size: 13px;
            color: #6c7d90;
        }
        .content-wrap {
            padding: 18px 20px 20px;
        }
        .config-card,
        .result-card {
            border: 1px solid rgba(220, 229, 239, 0.96);
            border-radius: 20px;
            background: rgba(255, 255, 255, 0.96);
            padding: 18px;
        }
        .result-card {
            margin-top: 16px;
        }
        .inline-tip {
            font-size: 12px;
            color: #6f7d8c;
            margin-left: 10px;
        }
        .table-tag-list {
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
        }
        @media (max-width: 900px) {
            .page-shell {
                padding: 10px;
            }
            .content-wrap,
            .section-head {
                padding-left: 14px;
                padding-right: 14px;
            }
        }
    </style>
</head>
<body>
<div id="app" class="page-shell" v-cloak>
    <section class="card-shell">
        <div class="section-head">
            <h1 class="section-title">日志流水清理</h1>
            <div class="section-subtitle">系统固定每天 23:00 自动清理过期日志。手动清理需要先在系统配置中获取最高权限授权。</div>
        </div>
        <div class="content-wrap" v-loading="loading">
            <div class="config-card">
                <el-alert
                    title="手动清理会按当前保留天数删除过期数据,不会删除保留期内日志。"
                    type="warning"
                    :closable="false"
                    show-icon
                    style="margin-bottom: 18px;">
                </el-alert>
                <el-form label-width="120px" size="small">
                    <el-form-item label="保留天数">
                        <el-input-number v-model="form.expireDays" :min="1" :step="1" controls-position="right"></el-input-number>
                        <span class="inline-tip">常用值:180、360</span>
                    </el-form-item>
                    <el-form-item label="自动清理时间">
                        <el-tag type="info">每日 23:00</el-tag>
                    </el-form-item>
                    <el-form-item label="手动清理模式">
                        <el-radio-group v-model="form.mode">
                            <el-radio label="all">所有日志表</el-radio>
                            <el-radio label="selected">指定日志表</el-radio>
                        </el-radio-group>
                    </el-form-item>
                    <el-form-item v-if="form.mode === 'selected'" label="日志表选择">
                        <el-select
                            v-model="form.tables"
                            multiple
                            collapse-tags
                            clearable
                            filterable
                            placeholder="请选择要清理的日志表"
                            style="width: 100%;">
                            <el-option
                                v-for="item in tableOptions"
                                :key="item.value"
                                :label="item.label + ' (' + item.value + ')'"
                                :value="item.value">
                            </el-option>
                        </el-select>
                    </el-form-item>
                    <el-form-item>
                        <el-button type="primary" :loading="saving" @click="saveConfig">保存配置</el-button>
                        <el-button type="danger" plain :loading="running" @click="runCleanup">手动清理</el-button>
                    </el-form-item>
                </el-form>
            </div>
            <div v-if="result" class="result-card">
                <el-descriptions title="最近一次手动清理结果" :column="3" border size="small">
                    <el-descriptions-item label="清理模式">{{ result.mode === 'selected' ? '指定日志表' : '所有日志表' }}</el-descriptions-item>
                    <el-descriptions-item label="保留天数">{{ result.expireDays }}</el-descriptions-item>
                    <el-descriptions-item label="删除总数">{{ result.totalDeleted }}</el-descriptions-item>
                </el-descriptions>
                <div style="margin-top: 16px;" class="table-tag-list">
                    <el-tag
                        v-for="item in resultDetails"
                        :key="item.table"
                        type="success"
                        effect="plain">
                        {{ item.label }}: {{ item.count }}
                    </el-tag>
                </div>
            </div>
        </div>
    </section>
</div>
<script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
<script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../../static/vue/element/element.js"></script>
<script type="text/javascript" src="../../static/js/logCleanup/logCleanup.js" charset="utf-8"></script>
</body>
</html>