package com.talentcard.server.business.auth; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.SecureRandom; import java.time.LocalDateTime; import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import com.talentcard.server.business.DatabaseRows; 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 TalentAuthService { private static final SecureRandom RANDOM = new SecureRandom(); private static final int ACCESS_TOKEN_HOURS = 2; private static final int REFRESH_TOKEN_DAYS = 30; private final JdbcTemplate jdbc; private final WeChatMiniProgramClient weChat; public TalentAuthService(JdbcTemplate jdbc, WeChatMiniProgramClient weChat) { this.jdbc = jdbc; this.weChat = weChat; } @Transactional public Map login(TalentLoginRequest request) { String openId = weChat.exchangeOpenId(request.wechatCode()); String mobile = weChat.exchangePhoneNumber(request.phoneCode()); return establishSession(openId, mobile); } @Transactional public Map createDevelopmentSession(long talentId) { List> talents = rows(""" select id, openid, status from talent_user where id = ? and deleted = 0 limit 1 for update """, talentId); if (talents.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "开发测试人才账号不存在"); if (number(talents.get(0).get("status")) != 1) { throw new BusinessException(ErrorCode.FORBIDDEN, "持卡人被停用"); } Object openId = talents.get(0).get("openid"); if (openId == null || String.valueOf(openId).isBlank()) { throw new BusinessException(ErrorCode.CONFLICT, "开发测试人才账号未配置微信标识"); } return issueSession(talentId); } @Transactional public Map refresh(TalentRefreshRequest request) { List> sessions = rows(""" select s.id, s.talent_id from talent_session s join talent_user t on t.id = s.talent_id and t.deleted = 0 and t.status = 1 where s.refresh_token_hash = ? and s.revoked_at is null and s.refresh_expires_at > now() limit 1 for update """, sha256(request.refreshToken().trim())); if (sessions.isEmpty()) throw new BusinessException(ErrorCode.UNAUTHORIZED, "登录已失效,请重新登录"); Map session = sessions.get(0); String accessToken = token(); String refreshToken = token(); LocalDateTime accessExpiresAt = LocalDateTime.now().plusHours(ACCESS_TOKEN_HOURS); LocalDateTime refreshExpiresAt = LocalDateTime.now().plusDays(REFRESH_TOKEN_DAYS); jdbc.update(""" update talent_session set access_token_hash = ?, refresh_token_hash = ?, access_expires_at = ?, refresh_expires_at = ?, last_used_at = now() where id = ? """, sha256(accessToken), sha256(refreshToken), accessExpiresAt, refreshExpiresAt, session.get("id")); return sessionResult(number(session.get("talentId")), accessToken, refreshToken, accessExpiresAt); } @Transactional public Map logout(String authorization) { String token = bearerToken(authorization); if (!token.isBlank()) { jdbc.update("update talent_session set revoked_at = coalesce(revoked_at, now()) where access_token_hash = ?", sha256(token)); } return Map.of("loggedOut", true); } @Transactional(readOnly = true) public TalentSessionPrincipal authenticate(String authorization) { String token = bearerToken(authorization); if (token.isBlank()) throw new BusinessException(ErrorCode.UNAUTHORIZED, "请先登录"); List> sessions = rows(""" select s.talent_id from talent_session s join talent_user t on t.id = s.talent_id and t.deleted = 0 and t.status = 1 where s.access_token_hash = ? and s.revoked_at is null and s.access_expires_at > now() limit 1 """, sha256(token)); if (sessions.isEmpty()) throw new BusinessException(ErrorCode.UNAUTHORIZED, "登录已失效,请重新登录"); return new TalentSessionPrincipal(number(sessions.get(0).get("talentId"))); } private Map establishSession(String openId, String mobile) { List> talents = rows(""" select id, openid, status from talent_user where mobile = ? and deleted = 0 limit 1 for update """, mobile); if (talents.isEmpty()) return issueSession(registerVisitor(openId, mobile)); Map talent = talents.get(0); if (number(talent.get("status")) != 1) { throw new BusinessException(ErrorCode.FORBIDDEN, "持卡人被停用"); } long talentId = number(talent.get("id")); List> binding = rows(""" select t.id, exists(select 1 from talent_card c where c.talent_id = t.id and c.deleted = 0 and c.card_type = 'PERSONAL') has_talent_card from talent_user t where t.openid = ? and t.deleted = 0 limit 1 for update """, openId); if (!binding.isEmpty() && number(binding.get(0).get("id")) != talentId) { if (number(binding.get(0).get("hasTalentCard")) != 0) { throw new BusinessException(ErrorCode.CONFLICT, "当前微信已绑定其他人才账号,请联系管理员"); } jdbc.update("update talent_user set openid = null where id = ?", number(binding.get(0).get("id"))); } jdbc.update("update talent_user set openid = ? where id = ?", openId, talentId); return issueSession(talentId); } private long registerVisitor(String openId, String mobile) { List> bindings = rows(""" select id from talent_user where openid = ? and deleted = 0 and status = 1 limit 1 for update """, openId); if (!bindings.isEmpty()) { long talentId = number(bindings.get(0).get("id")); jdbc.update("update talent_user set mobile = ? where id = ?", mobile, talentId); return talentId; } jdbc.update(""" insert into talent_user(openid, name, mobile, talent_level, source_area_type, status) values (?, '普通游客', ?, '普通游客', 'LOCAL', 1) """, openId, mobile); Long talentId = jdbc.queryForObject("select last_insert_id()", Long.class); if (talentId == null) throw new BusinessException(ErrorCode.INTERNAL_ERROR, "普通游客注册失败"); return talentId; } private Map issueSession(long talentId) { jdbc.update("update talent_session set revoked_at = now() where talent_id = ? and revoked_at is null", talentId); String accessToken = token(); String refreshToken = token(); LocalDateTime accessExpiresAt = LocalDateTime.now().plusHours(ACCESS_TOKEN_HOURS); LocalDateTime refreshExpiresAt = LocalDateTime.now().plusDays(REFRESH_TOKEN_DAYS); jdbc.update(""" insert into talent_session(talent_id, access_token_hash, refresh_token_hash, access_expires_at, refresh_expires_at, last_used_at) values (?, ?, ?, ?, ?, now()) """, talentId, sha256(accessToken), sha256(refreshToken), accessExpiresAt, refreshExpiresAt); return sessionResult(talentId, accessToken, refreshToken, accessExpiresAt); } private Map sessionResult(long talentId, String accessToken, String refreshToken, LocalDateTime accessExpiresAt) { Map result = new LinkedHashMap<>(); result.put("talentId", talentId); result.put("accessToken", accessToken); result.put("refreshToken", refreshToken); result.put("expireAt", accessExpiresAt.atZone(java.time.ZoneId.of("Asia/Shanghai")).toInstant().toEpochMilli()); return result; } private String token() { byte[] bytes = new byte[32]; RANDOM.nextBytes(bytes); return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } private String bearerToken(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 session token", exception); } } private List> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); } private long number(Object value) { if (value instanceof Number number) return number.longValue(); try { return Long.parseLong(String.valueOf(value)); } catch (Exception exception) { throw new BusinessException(ErrorCode.INTERNAL_ERROR, "人才会话数据异常"); } } }