package com.talentcard.server.business.admin.auth; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.SecureRandom; import java.sql.Timestamp; import java.time.LocalDateTime; import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import com.alibaba.fastjson.JSON; import com.talentcard.server.business.DatabaseRows; import com.talentcard.server.business.merchant.auth.MerchantPasswordHasher; import com.talentcard.server.common.BusinessException; import com.talentcard.server.common.ErrorCode; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class AdminAuthService { private static final int MAX_FAILURES = 5; private static final int ACCESS_TOKEN_SECONDS = 8 * 60 * 60; private static final SecureRandom RANDOM = new SecureRandom(); private final JdbcTemplate jdbc; public AdminAuthService(JdbcTemplate jdbc) { this.jdbc = jdbc; } @Transactional public Map login(AdminLoginRequest request, String requestId) { String account = request.account().trim(); List> rows = rows(""" select o.id operator_id, o.password_hash, o.display_name, o.role_code, o.status, r.permission_codes, login_failed_count, locked_until from admin_operator o left join admin_role r on r.role_code = o.role_code where o.account = ? and o.deleted = 0 limit 1 """, account); if (rows.isEmpty()) { audit(null, account, false, "INVALID_CREDENTIALS", requestId); throw invalidCredentials(); } Map operator = rows.get(0); Long operatorId = number(operator.get("operatorId")); if (number(operator.get("status")) != 1) { audit(operatorId, account, false, "ACCOUNT_DISABLED", requestId); throw new BusinessException(ErrorCode.FORBIDDEN, "当前管理员账号已停用"); } LocalDateTime lockedUntil = time(operator.get("lockedUntil")); if (lockedUntil != null && lockedUntil.isAfter(LocalDateTime.now())) { audit(operatorId, account, false, "ACCOUNT_LOCKED", requestId); throw new BusinessException(ErrorCode.FORBIDDEN, "登录尝试过多,请15分钟后再试"); } if (!MerchantPasswordHasher.matches(request.password(), String.valueOf(operator.get("passwordHash")))) { int failures = Math.toIntExact(number(operator.get("loginFailedCount")) + 1); LocalDateTime nextLockedUntil = failures >= MAX_FAILURES ? LocalDateTime.now().plusMinutes(15) : null; jdbc.update("update admin_operator set login_failed_count = ?, locked_until = ? where id = ?", failures, nextLockedUntil, operatorId); audit(operatorId, account, false, "INVALID_CREDENTIALS", requestId); throw invalidCredentials(); } jdbc.update("update admin_operator set login_failed_count = 0, locked_until = null, last_login_at = now() where id = ?", operatorId); audit(operatorId, account, true, null, requestId); return issueSession(operator); } @Transactional public Map logout(String authorization) { String token = bearer(authorization); if (!token.isBlank()) { jdbc.update("update admin_session set revoked_at = now() where access_token_hash = ? and revoked_at is null", sha256(token)); } return Map.of("loggedOut", true); } @Transactional public Long authenticate(String authorization) { String token = bearer(authorization); if (token.isBlank()) throw new BusinessException(ErrorCode.UNAUTHORIZED, "请先登录管理后台"); List> rows = rows(""" select s.id session_id, s.operator_id, o.status from admin_session s join admin_operator o on o.id = s.operator_id where s.access_token_hash = ? and s.revoked_at is null and s.access_expires_at > now() and o.deleted = 0 limit 1 """, sha256(token)); if (rows.isEmpty()) throw new BusinessException(ErrorCode.UNAUTHORIZED, "登录已失效,请重新登录"); if (number(rows.get(0).get("status")) != 1) { throw new BusinessException(ErrorCode.FORBIDDEN, "当前管理员账号已停用"); } jdbc.update("update admin_session set last_used_at = now() where id = ?", number(rows.get(0).get("sessionId"))); return number(rows.get(0).get("operatorId")); } @Transactional(readOnly = true) public String operatorName(Long operatorId) { List> rows = rows("select display_name from admin_operator where id = ? and deleted = 0 limit 1", operatorId); if (rows.isEmpty()) throw new BusinessException(ErrorCode.UNAUTHORIZED, "请先登录管理后台"); String displayName = String.valueOf(rows.get(0).get("displayName")).trim(); return displayName.isBlank() ? "管理员" : displayName; } @Transactional(readOnly = true) public boolean hasPermission(Long operatorId, String permission) { List> result = rows(""" select o.role_code, r.permission_codes from admin_operator o left join admin_role r on r.role_code = o.role_code where o.id = ? and o.deleted = 0 limit 1 """, operatorId); if (result.isEmpty()) return false; Map operator = result.get(0); if ("platform_admin".equals(String.valueOf(operator.get("roleCode")))) return true; String raw = String.valueOf(operator.getOrDefault("permissionCodes", "")); try { return JSON.parseArray(raw, String.class).contains(permission); } catch (Exception ignored) { return false; } } private Map issueSession(Map operator) { String accessToken = token(); LocalDateTime now = LocalDateTime.now(); jdbc.update("insert into admin_session(operator_id, access_token_hash, access_expires_at) values (?, ?, ?)", number(operator.get("operatorId")), sha256(accessToken), now.plusSeconds(ACCESS_TOKEN_SECONDS)); Map result = new LinkedHashMap<>(); result.put("accessToken", accessToken); result.put("expiresIn", ACCESS_TOKEN_SECONDS); Map principal = new LinkedHashMap<>(); principal.put("id", number(operator.get("operatorId"))); principal.put("name", String.valueOf(operator.get("displayName"))); principal.put("roleCode", String.valueOf(operator.get("roleCode"))); principal.put("permissionCodes", permissions(operator)); result.put("operator", principal); return result; } private List permissions(Map operator) { if ("platform_admin".equals(String.valueOf(operator.get("roleCode")))) return List.of("*"); String raw = String.valueOf(operator.getOrDefault("permissionCodes", "")); try { return JSON.parseArray(raw, String.class); } catch (Exception ignored) { return List.of(); } } private void audit(Long operatorId, String account, boolean success, String reason, String requestId) { jdbc.update("insert into admin_login_audit(operator_id, account, success, failure_reason, request_id) values (?, ?, ?, ?, ?)", operatorId, account, success ? 1 : 0, reason, requestId); } private BusinessException invalidCredentials() { return new BusinessException(ErrorCode.UNAUTHORIZED, "账号或密码错误"); } private List> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); } private Long number(Object value) { return value instanceof Number number ? number.longValue() : 0L; } private LocalDateTime time(Object value) { if (value instanceof LocalDateTime dateTime) return dateTime; if (value instanceof Timestamp timestamp) return timestamp.toLocalDateTime(); return null; } private String token() { byte[] bytes = new byte[32]; RANDOM.nextBytes(bytes); return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } private String bearer(String authorization) { return authorization == null ? "" : authorization.replaceFirst("(?i)^Bearer\\s+", "").trim(); } private String sha256(String value) { try { byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); StringBuilder result = new StringBuilder(); for (byte item : digest) result.append(String.format("%02x", item)); return result.toString(); } catch (Exception exception) { throw new IllegalStateException("Unable to process admin session", exception); } } }