shinnosuke
2026-09-27 35c0810b268661c79f776fe922f7e3834cba4186
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
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
package com.talentcard.server.business.admin.auth;
 
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
 
import com.talentcard.server.common.ApiResponse;
import com.talentcard.server.common.BusinessException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import tools.jackson.databind.ObjectMapper;
 
/**
 * 管理后台的登录与页面权限拦截。
 *
 * <p>鉴权失败时直接写响应,不再抛异常:仓库里有一批按控制器限定的 {@code @RestControllerAdvice}
 * ({@code com.talentcard.server.config} 下的 {@code *ExceptionHandler}),会把落在自己控制器上的
 * 任何异常统一改写成 500「服务器内部错误 / 某某处理失败」。权限不足这类本该是 403 的响应被它们吞掉
 * 之后,前端既看不出原因,也不会跳回登录页。直接写响应可以绕开这些兜底,保证状态码和提示语如实送达。
 *
 * <p>权限码按「这请求属于哪个页面」推导,并且只使用角色管理里真正能勾选的权限码——否则员工账号
 * 配好了权限也进不去对应模块(例如体检预约的接口曾要求一个角色界面根本不提供的权限码)。
 */
@Component
public class AdminAuthInterceptor implements HandlerInterceptor {
    public static final String OPERATOR_ID_ATTRIBUTE = "authenticatedAdminOperatorId";
    public static final String OPERATOR_NAME_ATTRIBUTE = "authenticatedAdminOperatorName";
 
    private static final String FORBIDDEN_MESSAGE = "当前账号没有该操作权限";
 
    /** 服务预约申请是「一个接口服务多个页面」(按 applicationType 区分),各归到自己的页面权限。 */
    private static final Map<String, String> SERVICE_APPLICATION_PAGES = Map.of(
            "MEDICAL_RESERVATION", "medical-reservations",
            "MEDICAL_APPLICATION", "medical-applications",
            "FUEL_SERVICE", "fuel-services",
            "HOTEL_RESERVATION", "hotel-reservations",
            "SPOUSE_EMPLOYMENT", "spouse-employment",
            "PARKING_PLATE", "parking-plates"
    );
    private static final Pattern SERVICE_APPLICATION_ID =
            Pattern.compile("^/api/admin/service-appointment-applications/(\\d+)(/.*)?$");
 
    /** 家属卡接口同时挂在「家属卡申请」和「家属卡管理」两个页面下,两边对应的权限都认。 */
    private static final List<String> FAMILY_CARD_VIEW_PERMISSIONS =
            List.of("admin.family-card-applications.view", "admin.family-cards.view");
    private static final List<String> FAMILY_CARD_UNBIND_PERMISSIONS =
            List.of("admin.family-card-applications.audit", "admin.family-cards.disable");
 
    private final AdminAuthService service;
    private final JdbcTemplate jdbc;
    private final ObjectMapper objectMapper;
 
    public AdminAuthInterceptor(AdminAuthService service, JdbcTemplate jdbc, ObjectMapper objectMapper) {
        this.service = service;
        this.jdbc = jdbc;
        this.objectMapper = objectMapper;
    }
 
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
        Long operatorId;
        String operatorName;
        try {
            operatorId = service.authenticate(request.getHeader(HttpHeaders.AUTHORIZATION));
            operatorName = service.operatorName(operatorId);
        } catch (BusinessException exception) {
            write(response, exception.errorCode().code(), exception.getMessage());
            return false;
        }
        request.setAttribute(OPERATOR_ID_ATTRIBUTE, operatorId);
        request.setAttribute(OPERATOR_NAME_ATTRIBUTE, operatorName);
        List<String> permissions = permissions(request);
        if (!permissions.isEmpty() && !service.hasAnyPermission(operatorId, permissions)) {
            write(response, HttpStatus.FORBIDDEN.value(), FORBIDDEN_MESSAGE);
            return false;
        }
        return true;
    }
 
    private void write(HttpServletResponse response, int code, String message) throws IOException {
        response.setStatus(code);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        objectMapper.writeValue(response.getWriter(),
                ApiResponse.failure(code, message == null || message.isBlank() ? FORBIDDEN_MESSAGE : message));
    }
 
    /** 本次请求允许的权限码;返回空表示这个接口不校验页面权限。 */
    private List<String> permissions(HttpServletRequest request) {
        String method = request.getMethod();
        String uri = request.getRequestURI();
        if (uri.startsWith("/api/admin/management/merchant-settlement/onboardings")) {
            return List.of("admin.merchant-settlement." + ("GET".equalsIgnoreCase(method) ? "view" : "edit"));
        }
        if (uri.startsWith("/api/admin/management/roles")) {
            return only(managementPermission(method, uri, "roles"));
        }
        if (uri.startsWith("/api/admin/management/staff")) {
            return only(managementPermission(method, uri, "staff"));
        }
        if (uri.startsWith("/api/admin/service-appointment-applications")) {
            return serviceApplicationPermissions(request);
        }
        if (uri.startsWith("/api/admin/dependent-card-applies")) {
            return dependentCardPermissions(method, uri);
        }
        String page = page(uri);
        return page.isBlank() ? List.of() : only(pagePermission(method, uri, page));
    }
 
    /** 服务预约申请:列表/详情看模块的「查看」,导出看「下载」,处理/驳回看「审核」。 */
    private List<String> serviceApplicationPermissions(HttpServletRequest request) {
        String method = request.getMethod();
        String uri = request.getRequestURI();
        String action = uri.endsWith("/export") ? "export"
                : "PATCH".equalsIgnoreCase(method) ? "audit" : "view";
        String page = serviceApplicationPage(request);
        // 归属不出来的(历史数据、通用页)沿用原来的 appointment-management 权限,避免放开成不校验。
        if (page.isBlank()) page = "appointment-management";
        return List.of("admin." + page + "." + action);
    }
 
    private String serviceApplicationPage(HttpServletRequest request) {
        String type = request.getParameter("applicationType");
        if (type == null || type.isBlank()) return applicationTypeById(request.getRequestURI());
        return SERVICE_APPLICATION_PAGES.getOrDefault(type.trim().toUpperCase(Locale.ROOT), "");
    }
 
    /** 详情、处理这类只带 id 的请求拿不到 applicationType,回表查一次才知道属于哪个模块。 */
    private String applicationTypeById(String uri) {
        Matcher matcher = SERVICE_APPLICATION_ID.matcher(uri);
        if (!matcher.matches()) return "";
        List<String> types = jdbc.queryForList(
                "select application_type from service_application where id = ? and deleted = 0 limit 1",
                String.class, Long.valueOf(matcher.group(1)));
        if (types.isEmpty()) return "";
        String type = types.get(0);
        return type == null ? "" : SERVICE_APPLICATION_PAGES.getOrDefault(type.trim().toUpperCase(Locale.ROOT), "");
    }
 
    private List<String> dependentCardPermissions(String method, String uri) {
        if (uri.endsWith("/unbind")) return FAMILY_CARD_UNBIND_PERMISSIONS;
        if (uri.endsWith("/audit")) return List.of("admin.family-card-applications.audit");
        if ("GET".equalsIgnoreCase(method)) return FAMILY_CARD_VIEW_PERMISSIONS;
        return List.of("admin.family-card-applications.edit");
    }
 
    private String pagePermission(String method, String uri, String page) {
        if ("GET".equalsIgnoreCase(method)) return "admin." + page + ".view";
        if ("POST".equalsIgnoreCase(method)) return "admin." + page + ".create";
        if ("DELETE".equalsIgnoreCase(method)) return "admin." + page + ".delete";
        if ("PATCH".equalsIgnoreCase(method)) {
            String action = uri.endsWith("/audit") ? "audit" : uri.endsWith("/status") ? "disable" : "edit";
            return "admin." + page + "." + action;
        }
        return "admin." + page + ".edit";
    }
 
    private String page(String uri) {
        if (uri.startsWith("/api/admin/business/merchants")) return "merchants";
        if (uri.startsWith("/api/admin/business/products")) return "products";
        if (uri.startsWith("/api/admin/business/news")) return "news";
        if (uri.startsWith("/api/admin/business/policies")) return "policies";
        if (uri.startsWith("/api/admin/business/coupons")) return "benefit-coupons";
        if (uri.startsWith("/api/admin/dependent-card-applies")) return "family-card-applications";
        if (uri.startsWith("/api/admin/cards/child-applications")) return "child-cards";
        if (uri.startsWith("/api/admin/service-appointment-applications")) return "appointment-management";
        if (uri.startsWith("/api/admin/medical-reservations")) return "medical-reservations";
        if (uri.startsWith("/api/admin/parking-plate-submissions")) return "parking-plates";
        if (uri.startsWith("/api/admin/car-wash-benefits") || uri.startsWith("/api/admin/car-wash-merchant-jump-configs")) return "car-wash-benefits";
        if (uri.startsWith("/api/admin/fuel-services")) return "fuel-services";
        if (uri.startsWith("/api/admin/spouse-employment")) return "spouse-employment";
        if (uri.startsWith("/api/admin/hotel-reservations")) return "hotel-reservations";
        if (uri.startsWith("/api/admin/merchant-settlement")) return "merchant-settlement";
        if (uri.startsWith("/api/admin/ticket-packages")) return "movie-tickets";
        if (uri.startsWith("/api/admin/display-services")) return "display-services";
        if (uri.startsWith("/api/admin/equity-images")) return "equity-images";
        return "";
    }
 
    private String managementPermission(String method, String uri, String page) {
        String action;
        if ("GET".equalsIgnoreCase(method)) action = "view";
        else if ("POST".equalsIgnoreCase(method)) action = "create";
        else if ("DELETE".equalsIgnoreCase(method)) action = "delete";
        else if ("PUT".equalsIgnoreCase(method)) action = "edit";
        else if ("PATCH".equalsIgnoreCase(method)) action = uri.endsWith("/status") ? "disable" : "reset";
        else return "";
        return "admin." + page + "." + action;
    }
 
    /** 空权限码表示「这个请求不校验页面权限」,不能当成一个空字符串去比权限。 */
    private List<String> only(String permission) {
        return permission == null || permission.isBlank() ? List.of() : List.of(permission);
    }
}