liujie
2026-09-11 4d15e6b553c448e2e07527e9c06aa4b5f9b099cf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
package com.ruoyi.framework.interceptor;
 
import com.ruoyi.common.config.RuoYiConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
 
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
 
/**
 * 集群版对接中台时的写操作兜底拦截。
 *
 * <p>目的:前端隐藏按钮只能减少误触,不能阻止绕过页面直接调用接口。</p>
 * <p>影响范围:访客后台非本地管理模块的新增、编辑、删除、状态变更、注销、导入等写接口。</p>
 */
@Component
public class MiddlePlatformReadonlyInterceptor implements HandlerInterceptor {
 
    private static final String MESSAGE = "集群版对接中台时,当前模块仅支持查询和导出";
    private static final String RESET_PASSWORD_MESSAGE = "集群版对接中台时,不支持重置密码";
 
    /** 受中台只读规则约束的访客后台接口前缀。 */
    private static final List<String> CONTROLLED_PREFIXES = Arrays.asList(
            "/system/visit/area",
            "/system/dept",
            "/system/employee",
            "/system/visitor/record",
            "/system/visitor/machine",
            "/system/door/device",
            "/system/version/update",
            "/system/protocol/config",
            "/system/platform/admin",
            "/system/role/manage"
    );
 
    /** 本地仍可维护的白名单模块:账号管理、系统配置、入园承诺、访客机版本、首页、操作日志。 */
    private static final List<String> LOCAL_MODULE_PREFIXES = Arrays.asList(
            "/system/platform/admin",
            "/system/role/manage",
            "/system/visitor/config",
            "/system/protocol/config",
            "/system/version/update",
            "/system/dashboard",
            "/system/operation/log"
    );
 
    /** 非白名单模块在中台模式下只允许这些查询、导出和模板下载接口通过。 */
    private static final List<String> READONLY_SUFFIXES = Arrays.asList(
            "/page",
            "/detail",
            "/tree",
            "/overview",
            "/roleOptions",
            "/permissionTree",
            "/export",
            "/importTemplate"
    );
 
    @Autowired
    private RuoYiConfig ruoyiConfig;
 
    /**
     * 目的:在请求进入 Controller 前判断中台只读规则,阻止非白名单模块写操作。
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (!ruoyiConfig.isConnectMiddlePlatform()) {
            return true;
        }
 
        String path = request.getRequestURI();
        String contextPath = request.getContextPath();
        if (contextPath != null && contextPath.length() > 0 && path.startsWith(contextPath)) {
            path = path.substring(contextPath.length());
        }
 
        if (!isControlledPath(path)) {
            return true;
        }
        String method = request.getMethod();
        // 放行预检请求,避免 CORS OPTIONS 被业务只读规则误拦截。
        if (isPreflight(method)) {
            return true;
        }
        // 账号管理属于本地白名单,但业务要求中台模式下不允许展示或调用重置密码。
        if (path.startsWith("/system/platform/admin/resetPassword")) {
            return reject(response, RESET_PASSWORD_MESSAGE);
        }
        // GET/HEAD 和明确的查询导出接口为只读请求;白名单模块按本地管理逻辑继续放行。
        if (isLocalModule(path) || isReadonlyEndpoint(path) || isReadMethod(method)) {
            return true;
        }
        return reject(response);
    }
 
    /**
     * 目的:判断当前请求是否属于访客后台需要受控的接口范围。
     */
    private boolean isControlledPath(String path) {
        for (String prefix : CONTROLLED_PREFIXES) {
            if (path.startsWith(prefix)) {
                return true;
            }
        }
        return false;
    }
 
    /**
     * 目的:判断当前请求是否属于中台模式仍允许本地维护的白名单模块。
     */
    private boolean isLocalModule(String path) {
        for (String prefix : LOCAL_MODULE_PREFIXES) {
            if (path.startsWith(prefix)) {
                return true;
            }
        }
        return false;
    }
 
    /**
     * 目的:判断 POST 查询、导出和模板下载这类只读接口是否可放行。
     */
    private boolean isReadonlyEndpoint(String path) {
        for (String suffix : READONLY_SUFFIXES) {
            if (path.endsWith(suffix)) {
                return true;
            }
        }
        return false;
    }
 
    /**
     * 目的:识别 HTTP 层面的只读方法,支持原生 GET 查询接口。
     */
    private boolean isReadMethod(String method) {
        return "GET".equalsIgnoreCase(method) || "HEAD".equalsIgnoreCase(method);
    }
 
    /**
     * 目的:识别 CORS 预检请求,避免 OPTIONS 被业务规则误拦截。
     */
    private boolean isPreflight(String method) {
        return "OPTIONS".equalsIgnoreCase(method);
    }
 
    /**
     * 目的:使用默认只读提示拒绝当前写操作请求。
     */
    private boolean reject(HttpServletResponse response) throws Exception {
        return reject(response, MESSAGE);
    }
 
    /**
     * 目的:按指定提示返回统一 Ajax JSON 错误。
     */
    private boolean reject(HttpServletResponse response, String message) throws Exception {
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"code\":500,\"msg\":\"" + message + "\"}");
        return false;
    }
}