| | |
| | | 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 org.springframework.web.server.ResponseStatusException; |
| | | import org.springframework.http.HttpStatus; |
| | | 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 final AdminAuthService service; |
| | | private static final String FORBIDDEN_MESSAGE = "当前账号没有该操作权限"; |
| | | |
| | | public AdminAuthInterceptor(AdminAuthService service) { |
| | | /** 服务预约申请是「一个接口服务多个页面」(按 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) { |
| | | Long operatorId = service.authenticate(request.getHeader(HttpHeaders.AUTHORIZATION)); |
| | | 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, service.operatorName(operatorId)); |
| | | String permission = permission(request.getMethod(), request.getRequestURI()); |
| | | if (!permission.isBlank() && !service.hasPermission(operatorId, permission)) { |
| | | if (request.getRequestURI().startsWith("/api/admin/management/merchant-settlement/onboardings")) { |
| | | throw new com.talentcard.server.common.BusinessException(com.talentcard.server.common.ErrorCode.FORBIDDEN, "当前账号没有该操作权限"); |
| | | } |
| | | throw new ResponseStatusException(HttpStatus.FORBIDDEN, "当前账号没有该操作权限"); |
| | | 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 String permission(String method, String uri) { |
| | | 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 "admin.merchant-settlement." + ("GET".equalsIgnoreCase(method) ? "view" : "edit"); |
| | | return List.of("admin.merchant-settlement." + ("GET".equalsIgnoreCase(method) ? "view" : "edit")); |
| | | } |
| | | if (uri.startsWith("/api/admin/management/roles")) { |
| | | return managementPermission(method, uri, "roles"); |
| | | return only(managementPermission(method, uri, "roles")); |
| | | } |
| | | if (uri.startsWith("/api/admin/management/staff")) { |
| | | return managementPermission(method, uri, "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); |
| | | if (page.isBlank()) return ""; |
| | | String action = "GET".equalsIgnoreCase(method) ? "view" : "edit"; |
| | | if ("POST".equalsIgnoreCase(method)) action = "create"; |
| | | if ("DELETE".equalsIgnoreCase(method)) action = "delete"; |
| | | 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)) { |
| | | action = uri.endsWith("/audit") || uri.endsWith("/status") ? (uri.endsWith("/audit") ? "audit" : "disable") : "edit"; |
| | | String action = uri.endsWith("/audit") ? "audit" : uri.endsWith("/status") ? "disable" : "edit"; |
| | | return "admin." + page + "." + action; |
| | | } |
| | | return "admin." + page + "." + action; |
| | | return "admin." + page + ".edit"; |
| | | } |
| | | |
| | | private String page(String uri) { |
| | |
| | | else return ""; |
| | | return "admin." + page + "." + action; |
| | | } |
| | | |
| | | /** 空权限码表示「这个请求不校验页面权限」,不能当成一个空字符串去比权限。 */ |
| | | private List<String> only(String permission) { |
| | | return permission == null || permission.isBlank() ? List.of() : List.of(permission); |
| | | } |
| | | } |