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
207
208
209
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<String, Object> login(AdminLoginRequest request, String requestId) {
        String account = request.account().trim();
        List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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) {
        return hasAnyPermission(operatorId, List.of(permission));
    }
 
    /**
     * 一个接口可能同时服务多个页面(例如家属卡接口既挂在「家属卡申请」也挂在「家属卡管理」下),
     * 这时只要命中其中任意一个页面权限就放行。
     */
    @Transactional(readOnly = true)
    public boolean hasAnyPermission(Long operatorId, List<String> permissions) {
        if (permissions.isEmpty()) return true;
        List<Map<String, Object>> 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<String, Object> operator = result.get(0);
        if ("platform_admin".equals(String.valueOf(operator.get("roleCode")))) return true;
        String raw = String.valueOf(operator.getOrDefault("permissionCodes", ""));
        try {
            List<String> granted = JSON.parseArray(raw, String.class);
            return permissions.stream().anyMatch(granted::contains);
        } catch (Exception ignored) {
            return false;
        }
    }
 
    private Map<String, Object> issueSession(Map<String, Object> 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<String, Object> result = new LinkedHashMap<>();
        result.put("accessToken", accessToken);
        result.put("expiresIn", ACCESS_TOKEN_SECONDS);
        Map<String, Object> 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<String> permissions(Map<String, Object> 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<Map<String, Object>> 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);
        }
    }
}