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;
/**
* 管理后台的登录与页面权限拦截。
*
*
鉴权失败时直接写响应,不再抛异常:仓库里有一批按控制器限定的 {@code @RestControllerAdvice}
* ({@code com.talentcard.server.config} 下的 {@code *ExceptionHandler}),会把落在自己控制器上的
* 任何异常统一改写成 500「服务器内部错误 / 某某处理失败」。权限不足这类本该是 403 的响应被它们吞掉
* 之后,前端既看不出原因,也不会跳回登录页。直接写响应可以绕开这些兜底,保证状态码和提示语如实送达。
*
*
权限码按「这请求属于哪个页面」推导,并且只使用角色管理里真正能勾选的权限码——否则员工账号
* 配好了权限也进不去对应模块(例如体检预约的接口曾要求一个角色界面根本不提供的权限码)。
*/
@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 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 FAMILY_CARD_VIEW_PERMISSIONS =
List.of("admin.family-card-applications.view", "admin.family-cards.view");
private static final List 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 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 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 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 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 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 only(String permission) {
return permission == null || permission.isBlank() ? List.of() : List.of(permission);
}
}