添加企业微信通过手机号与CRM用户名对应自动登录的功能
parent
c5c0652088
commit
6479f6fd01
|
|
@ -1,11 +1,14 @@
|
|||
package com.unis.crm;
|
||||
|
||||
import com.unis.crm.config.WecomProperties;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
|
||||
@SpringBootApplication(scanBasePackages = "com.unis.crm")
|
||||
@MapperScan("com.unis.crm.mapper")
|
||||
@EnableConfigurationProperties(WecomProperties.class)
|
||||
public class UnisCrmBackendApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
package com.unis.crm.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "unisbase.app.wecom")
|
||||
public class WecomProperties {
|
||||
|
||||
private boolean enabled;
|
||||
private String corpId;
|
||||
private String agentId;
|
||||
private String secret;
|
||||
private String redirectUri;
|
||||
private String frontendCallbackPath = "/login/wecom";
|
||||
private String scope = "snsapi_base";
|
||||
private long stateTtlSeconds = 300;
|
||||
private long ticketTtlSeconds = 180;
|
||||
private long accessTokenSafetySeconds = 120;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getCorpId() {
|
||||
return corpId;
|
||||
}
|
||||
|
||||
public void setCorpId(String corpId) {
|
||||
this.corpId = corpId;
|
||||
}
|
||||
|
||||
public String getAgentId() {
|
||||
return agentId;
|
||||
}
|
||||
|
||||
public void setAgentId(String agentId) {
|
||||
this.agentId = agentId;
|
||||
}
|
||||
|
||||
public String getSecret() {
|
||||
return secret;
|
||||
}
|
||||
|
||||
public void setSecret(String secret) {
|
||||
this.secret = secret;
|
||||
}
|
||||
|
||||
public String getRedirectUri() {
|
||||
return redirectUri;
|
||||
}
|
||||
|
||||
public void setRedirectUri(String redirectUri) {
|
||||
this.redirectUri = redirectUri;
|
||||
}
|
||||
|
||||
public String getFrontendCallbackPath() {
|
||||
return frontendCallbackPath;
|
||||
}
|
||||
|
||||
public void setFrontendCallbackPath(String frontendCallbackPath) {
|
||||
this.frontendCallbackPath = frontendCallbackPath;
|
||||
}
|
||||
|
||||
public String getScope() {
|
||||
return scope;
|
||||
}
|
||||
|
||||
public void setScope(String scope) {
|
||||
this.scope = scope;
|
||||
}
|
||||
|
||||
public long getStateTtlSeconds() {
|
||||
return stateTtlSeconds;
|
||||
}
|
||||
|
||||
public void setStateTtlSeconds(long stateTtlSeconds) {
|
||||
this.stateTtlSeconds = stateTtlSeconds;
|
||||
}
|
||||
|
||||
public long getTicketTtlSeconds() {
|
||||
return ticketTtlSeconds;
|
||||
}
|
||||
|
||||
public void setTicketTtlSeconds(long ticketTtlSeconds) {
|
||||
this.ticketTtlSeconds = ticketTtlSeconds;
|
||||
}
|
||||
|
||||
public long getAccessTokenSafetySeconds() {
|
||||
return accessTokenSafetySeconds;
|
||||
}
|
||||
|
||||
public void setAccessTokenSafetySeconds(long accessTokenSafetySeconds) {
|
||||
this.accessTokenSafetySeconds = accessTokenSafetySeconds;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package com.unis.crm.controller;
|
||||
|
||||
import com.unis.crm.common.ApiResponse;
|
||||
import com.unis.crm.dto.wecom.WecomSsoExchangeRequest;
|
||||
import com.unis.crm.service.WecomSsoService;
|
||||
import com.unisbase.dto.TokenResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/wecom/sso")
|
||||
public class WecomSsoController {
|
||||
|
||||
private final WecomSsoService wecomSsoService;
|
||||
|
||||
public WecomSsoController(WecomSsoService wecomSsoService) {
|
||||
this.wecomSsoService = wecomSsoService;
|
||||
}
|
||||
|
||||
@GetMapping("/entry")
|
||||
public ResponseEntity<Void> entry(@RequestParam(value = "redirect", required = false) String redirectPath) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.add(HttpHeaders.LOCATION, wecomSsoService.buildAuthorizeRedirect(redirectPath));
|
||||
return new ResponseEntity<>(headers, HttpStatus.FOUND);
|
||||
}
|
||||
|
||||
@GetMapping("/callback")
|
||||
public ResponseEntity<Void> callback(@RequestParam("code") String code, @RequestParam("state") String state) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.add(HttpHeaders.LOCATION, wecomSsoService.handleCallback(code, state));
|
||||
return new ResponseEntity<>(headers, HttpStatus.FOUND);
|
||||
}
|
||||
|
||||
@PostMapping("/exchange")
|
||||
public ApiResponse<TokenResponse> exchange(@Valid @RequestBody WecomSsoExchangeRequest request) {
|
||||
return ApiResponse.success(wecomSsoService.exchangeTicket(request.getTicket()));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.unis.crm.dto.wecom;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public class WecomSsoExchangeRequest {
|
||||
|
||||
@NotBlank(message = "ticket不能为空")
|
||||
private String ticket;
|
||||
|
||||
public String getTicket() {
|
||||
return ticket;
|
||||
}
|
||||
|
||||
public void setTicket(String ticket) {
|
||||
this.ticket = ticket;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.unis.crm.service;
|
||||
|
||||
import com.unisbase.dto.TokenResponse;
|
||||
|
||||
public interface WecomSsoService {
|
||||
|
||||
String buildAuthorizeRedirect(String redirectPath);
|
||||
|
||||
String handleCallback(String code, String state);
|
||||
|
||||
TokenResponse exchangeTicket(String ticket);
|
||||
}
|
||||
|
|
@ -0,0 +1,441 @@
|
|||
package com.unis.crm.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.unis.crm.common.BusinessException;
|
||||
import com.unis.crm.config.WecomProperties;
|
||||
import com.unis.crm.service.WecomSsoService;
|
||||
import com.unisbase.auth.JwtTokenProvider;
|
||||
import com.unisbase.common.RedisKeys;
|
||||
import com.unisbase.dto.TokenResponse;
|
||||
import com.unisbase.entity.SysUser;
|
||||
import com.unisbase.mapper.SysUserMapper;
|
||||
import com.unisbase.service.AuthVersionService;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
@Service
|
||||
public class WecomSsoServiceImpl implements WecomSsoService {
|
||||
|
||||
private static final String WECOM_AUTHORIZE_URL = "https://open.weixin.qq.com/connect/oauth2/authorize";
|
||||
private static final String WECOM_GET_TOKEN_URL = "https://qyapi.weixin.qq.com/cgi-bin/gettoken";
|
||||
private static final String WECOM_GET_USER_INFO_URL = "https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo";
|
||||
private static final String WECOM_GET_USER_URL = "https://qyapi.weixin.qq.com/cgi-bin/user/get";
|
||||
private static final String ACCESS_TOKEN_CACHE_KEY = "crm:wecom:access-token";
|
||||
private static final String STATE_CACHE_PREFIX = "crm:wecom:sso:state:";
|
||||
private static final String TICKET_CACHE_PREFIX = "crm:wecom:sso:ticket:";
|
||||
|
||||
private final WecomProperties wecomProperties;
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final SysUserMapper sysUserMapper;
|
||||
private final AuthVersionService authVersionService;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final RestClient restClient;
|
||||
|
||||
public WecomSsoServiceImpl(
|
||||
WecomProperties wecomProperties,
|
||||
StringRedisTemplate stringRedisTemplate,
|
||||
SysUserMapper sysUserMapper,
|
||||
AuthVersionService authVersionService,
|
||||
JwtTokenProvider jwtTokenProvider,
|
||||
ObjectMapper objectMapper) {
|
||||
this.wecomProperties = wecomProperties;
|
||||
this.stringRedisTemplate = stringRedisTemplate;
|
||||
this.sysUserMapper = sysUserMapper;
|
||||
this.authVersionService = authVersionService;
|
||||
this.jwtTokenProvider = jwtTokenProvider;
|
||||
this.objectMapper = objectMapper;
|
||||
this.restClient = RestClient.builder()
|
||||
.defaultHeader("Accept", MediaType.APPLICATION_JSON_VALUE)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String buildAuthorizeRedirect(String redirectPath) {
|
||||
ensureEnabled();
|
||||
validateRequiredConfig();
|
||||
|
||||
String safeRedirectPath = sanitizeRedirectPath(redirectPath);
|
||||
String state = UUID.randomUUID().toString().replace("-", "");
|
||||
stringRedisTemplate.opsForValue().set(
|
||||
STATE_CACHE_PREFIX + state,
|
||||
safeRedirectPath,
|
||||
Duration.ofSeconds(wecomProperties.getStateTtlSeconds()));
|
||||
|
||||
return WECOM_AUTHORIZE_URL
|
||||
+ "?appid=" + urlEncode(wecomProperties.getCorpId())
|
||||
+ "&redirect_uri=" + urlEncode(wecomProperties.getRedirectUri())
|
||||
+ "&response_type=code"
|
||||
+ "&scope=" + urlEncode(defaultIfBlank(wecomProperties.getScope(), "snsapi_base"))
|
||||
+ "&agentid=" + urlEncode(wecomProperties.getAgentId())
|
||||
+ "&state=" + urlEncode(state)
|
||||
+ "#wechat_redirect";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String handleCallback(String code, String state) {
|
||||
ensureEnabled();
|
||||
validateRequiredConfig();
|
||||
if (!StringUtils.hasText(code)) {
|
||||
return buildFrontendErrorRedirect("wecom_code_invalid");
|
||||
}
|
||||
if (!StringUtils.hasText(state)) {
|
||||
return buildFrontendErrorRedirect("wecom_state_invalid");
|
||||
}
|
||||
|
||||
String redirectPath = stringRedisTemplate.opsForValue().getAndDelete(STATE_CACHE_PREFIX + state);
|
||||
if (!StringUtils.hasText(redirectPath)) {
|
||||
return buildFrontendErrorRedirect("wecom_state_invalid");
|
||||
}
|
||||
|
||||
try {
|
||||
String accessToken = getCorpAccessToken();
|
||||
String userId = getWecomUserId(accessToken, code);
|
||||
String mobile = getWecomUserMobile(accessToken, userId);
|
||||
String normalizedMobile = normalizeMobile(mobile);
|
||||
SysUser user = resolveCrmUserByUsername(normalizedMobile);
|
||||
String ticket = createTicket(user, redirectPath);
|
||||
return buildFrontendSuccessRedirect(ticket, redirectPath);
|
||||
} catch (BusinessException ex) {
|
||||
return buildFrontendErrorRedirect(resolveErrorCode(ex.getMessage()));
|
||||
} catch (Exception ex) {
|
||||
return buildFrontendErrorRedirect("wecom_login_failed");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public TokenResponse exchangeTicket(String ticket) {
|
||||
ensureEnabled();
|
||||
if (!StringUtils.hasText(ticket)) {
|
||||
throw new BusinessException("登录票据不能为空");
|
||||
}
|
||||
|
||||
String payload = stringRedisTemplate.opsForValue().getAndDelete(TICKET_CACHE_PREFIX + ticket);
|
||||
if (!StringUtils.hasText(payload)) {
|
||||
throw new BusinessException("登录票据无效或已过期");
|
||||
}
|
||||
|
||||
WecomTicketContext context = deserializeTicket(payload);
|
||||
SysUser user = sysUserMapper.selectByIdIgnoreTenant(context.userId());
|
||||
if (user == null || user.getStatus() == null || user.getStatus() != 1
|
||||
|| user.getIsDeleted() == null || user.getIsDeleted() != 0) {
|
||||
throw new BusinessException("用户不存在或已停用");
|
||||
}
|
||||
|
||||
Long tenantId = context.tenantId();
|
||||
String sessionId = newSessionId();
|
||||
long accessMinutes = resolveAccessDefaultMinutes();
|
||||
long refreshDays = resolveRefreshDefaultDays();
|
||||
TokenResponse response = issueTokens(user, tenantId, sessionId, accessMinutes, refreshDays);
|
||||
response.setAvailableTenants(resolveAvailableTenants(user));
|
||||
cacheIssuedTokens(user.getUserId(), tenantId, sessionId, response, accessMinutes, refreshDays);
|
||||
return response;
|
||||
}
|
||||
|
||||
private void ensureEnabled() {
|
||||
if (!wecomProperties.isEnabled()) {
|
||||
throw new BusinessException("企业微信单点登录未启用");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateRequiredConfig() {
|
||||
if (!StringUtils.hasText(wecomProperties.getCorpId())
|
||||
|| !StringUtils.hasText(wecomProperties.getAgentId())
|
||||
|| !StringUtils.hasText(wecomProperties.getSecret())
|
||||
|| !StringUtils.hasText(wecomProperties.getRedirectUri())) {
|
||||
throw new BusinessException("企业微信登录配置不完整");
|
||||
}
|
||||
}
|
||||
|
||||
private String getCorpAccessToken() {
|
||||
String cached = stringRedisTemplate.opsForValue().get(ACCESS_TOKEN_CACHE_KEY);
|
||||
if (StringUtils.hasText(cached)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
JsonNode response = getJson(WECOM_GET_TOKEN_URL
|
||||
+ "?corpid=" + urlEncode(wecomProperties.getCorpId())
|
||||
+ "&corpsecret=" + urlEncode(wecomProperties.getSecret()));
|
||||
assertWecomSuccess(response, "获取企业微信访问令牌失败");
|
||||
String accessToken = response.path("access_token").asText("");
|
||||
long expiresIn = response.path("expires_in").asLong(7200);
|
||||
if (!StringUtils.hasText(accessToken)) {
|
||||
throw new BusinessException("企业微信访问令牌为空");
|
||||
}
|
||||
|
||||
long ttlSeconds = Math.max(60L, expiresIn - wecomProperties.getAccessTokenSafetySeconds());
|
||||
stringRedisTemplate.opsForValue().set(ACCESS_TOKEN_CACHE_KEY, accessToken, Duration.ofSeconds(ttlSeconds));
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
private String getWecomUserId(String accessToken, String code) {
|
||||
JsonNode response = getJson(WECOM_GET_USER_INFO_URL
|
||||
+ "?access_token=" + urlEncode(accessToken)
|
||||
+ "&code=" + urlEncode(code));
|
||||
assertWecomSuccess(response, "获取企业微信用户身份失败");
|
||||
String userId = response.path("UserId").asText("");
|
||||
if (!StringUtils.hasText(userId)) {
|
||||
throw new BusinessException("未识别到企业微信成员身份");
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
private String getWecomUserMobile(String accessToken, String userId) {
|
||||
JsonNode response = getJson(WECOM_GET_USER_URL
|
||||
+ "?access_token=" + urlEncode(accessToken)
|
||||
+ "&userid=" + urlEncode(userId));
|
||||
assertWecomSuccess(response, "读取企业微信成员信息失败");
|
||||
String mobile = response.path("mobile").asText("");
|
||||
if (!StringUtils.hasText(mobile)) {
|
||||
throw new BusinessException("企业微信账号未维护手机号");
|
||||
}
|
||||
return mobile;
|
||||
}
|
||||
|
||||
private SysUser resolveCrmUserByUsername(String normalizedMobile) {
|
||||
SysUser user = sysUserMapper.selectByUsernameIgnoreTenant(normalizedMobile);
|
||||
if (user == null) {
|
||||
throw new BusinessException("企业微信手机号未开通CRM账号");
|
||||
}
|
||||
if (user.getStatus() == null || user.getStatus() != 1 || user.getIsDeleted() == null || user.getIsDeleted() != 0) {
|
||||
throw new BusinessException("CRM账号已停用");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private String createTicket(SysUser user, String redirectPath) {
|
||||
List<TokenResponse.TenantInfo> tenants = resolveAvailableTenants(user);
|
||||
Long tenantId = resolveActiveTenantId(user, tenants);
|
||||
String ticket = UUID.randomUUID().toString().replace("-", "");
|
||||
WecomTicketContext context = new WecomTicketContext(user.getUserId(), tenantId, user.getUsername(), redirectPath);
|
||||
stringRedisTemplate.opsForValue().set(
|
||||
TICKET_CACHE_PREFIX + ticket,
|
||||
serializeTicket(context),
|
||||
Duration.ofSeconds(wecomProperties.getTicketTtlSeconds()));
|
||||
return ticket;
|
||||
}
|
||||
|
||||
private List<TokenResponse.TenantInfo> resolveAvailableTenants(SysUser user) {
|
||||
List<TokenResponse.TenantInfo> tenants = sysUserMapper.selectTenantsByUsername(user.getUsername());
|
||||
if (Boolean.TRUE.equals(user.getIsPlatformAdmin())) {
|
||||
boolean hasSystem = tenants.stream().anyMatch(item -> item.getTenantId() != null && item.getTenantId() == 0L);
|
||||
if (!hasSystem) {
|
||||
tenants.add(0, TokenResponse.TenantInfo.builder()
|
||||
.tenantId(0L)
|
||||
.tenantCode("SYSTEM")
|
||||
.tenantName("系统平台")
|
||||
.build());
|
||||
}
|
||||
}
|
||||
if (tenants.isEmpty()) {
|
||||
throw new BusinessException("该账号未关联任何租户");
|
||||
}
|
||||
return tenants;
|
||||
}
|
||||
|
||||
private Long resolveActiveTenantId(SysUser user, List<TokenResponse.TenantInfo> tenants) {
|
||||
if (Boolean.TRUE.equals(user.getIsPlatformAdmin())) {
|
||||
return 0L;
|
||||
}
|
||||
return tenants.stream()
|
||||
.map(TokenResponse.TenantInfo::getTenantId)
|
||||
.filter(item -> item != null && item > 0)
|
||||
.findFirst()
|
||||
.orElse(tenants.get(0).getTenantId());
|
||||
}
|
||||
|
||||
private TokenResponse issueTokens(SysUser user, Long tenantId, String sessionId, long accessMinutes, long refreshDays) {
|
||||
long authVersion = authVersionService.getVersion(user.getUserId(), tenantId);
|
||||
|
||||
Map<String, Object> accessClaims = new HashMap<>();
|
||||
accessClaims.put("tokenType", "access");
|
||||
accessClaims.put("userId", user.getUserId());
|
||||
accessClaims.put("tenantId", tenantId);
|
||||
accessClaims.put("username", user.getUsername());
|
||||
accessClaims.put("authVersion", authVersion);
|
||||
accessClaims.put("sessionId", sessionId);
|
||||
|
||||
Map<String, Object> refreshClaims = new HashMap<>();
|
||||
refreshClaims.put("tokenType", "refresh");
|
||||
refreshClaims.put("userId", user.getUserId());
|
||||
refreshClaims.put("tenantId", tenantId);
|
||||
refreshClaims.put("authVersion", authVersion);
|
||||
refreshClaims.put("sessionId", sessionId);
|
||||
|
||||
String accessToken = jwtTokenProvider.createToken(accessClaims, Duration.ofMinutes(accessMinutes).toMillis());
|
||||
String refreshToken = jwtTokenProvider.createToken(refreshClaims, Duration.ofDays(refreshDays).toMillis());
|
||||
return TokenResponse.builder()
|
||||
.accessToken(accessToken)
|
||||
.refreshToken(refreshToken)
|
||||
.accessExpiresInMinutes(accessMinutes)
|
||||
.refreshExpiresInDays(refreshDays)
|
||||
.build();
|
||||
}
|
||||
|
||||
private void cacheIssuedTokens(Long userId, Long tenantId, String sessionId, TokenResponse response, long accessMinutes, long refreshDays) {
|
||||
stringRedisTemplate.opsForValue().set(
|
||||
RedisKeys.accessSessionTokenKey(sessionId),
|
||||
response.getAccessToken(),
|
||||
Duration.ofMinutes(accessMinutes));
|
||||
stringRedisTemplate.opsForValue().set(
|
||||
RedisKeys.refreshSessionTokenKey(sessionId),
|
||||
response.getRefreshToken(),
|
||||
Duration.ofDays(refreshDays));
|
||||
String userSessionKey = RedisKeys.userSessionIndexKey(userId, tenantId);
|
||||
stringRedisTemplate.opsForSet().add(userSessionKey, sessionId);
|
||||
stringRedisTemplate.expire(userSessionKey, Duration.ofDays(refreshDays));
|
||||
}
|
||||
|
||||
private long resolveAccessDefaultMinutes() {
|
||||
return 30L;
|
||||
}
|
||||
|
||||
private long resolveRefreshDefaultDays() {
|
||||
return 7L;
|
||||
}
|
||||
|
||||
private String newSessionId() {
|
||||
return UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
|
||||
private JsonNode getJson(String url) {
|
||||
String body = restClient.get()
|
||||
.uri(url)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
if (!StringUtils.hasText(body)) {
|
||||
throw new BusinessException("企业微信接口返回为空");
|
||||
}
|
||||
try {
|
||||
return objectMapper.readTree(body);
|
||||
} catch (JsonProcessingException ex) {
|
||||
throw new BusinessException("企业微信接口返回格式异常");
|
||||
}
|
||||
}
|
||||
|
||||
private void assertWecomSuccess(JsonNode response, String fallbackMessage) {
|
||||
int errCode = response.path("errcode").asInt(-1);
|
||||
if (errCode != 0) {
|
||||
String errMsg = response.path("errmsg").asText(fallbackMessage);
|
||||
throw new BusinessException(fallbackMessage + ":" + errMsg);
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitizeRedirectPath(String redirectPath) {
|
||||
if (!StringUtils.hasText(redirectPath)) {
|
||||
return "/";
|
||||
}
|
||||
String trimmed = redirectPath.trim();
|
||||
if (!trimmed.startsWith("/") || trimmed.startsWith("//")) {
|
||||
return "/";
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private String buildFrontendSuccessRedirect(String ticket, String redirectPath) {
|
||||
return sanitizeFrontendCallbackPath()
|
||||
+ "?ticket=" + urlEncode(ticket)
|
||||
+ "&redirect=" + urlEncode(redirectPath);
|
||||
}
|
||||
|
||||
private String buildFrontendErrorRedirect(String errorCode) {
|
||||
return sanitizeFrontendCallbackPath() + "?error=" + urlEncode(errorCode);
|
||||
}
|
||||
|
||||
private String sanitizeFrontendCallbackPath() {
|
||||
String path = defaultIfBlank(wecomProperties.getFrontendCallbackPath(), "/login/wecom");
|
||||
if (!path.startsWith("/")) {
|
||||
return "/login/wecom";
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private String normalizeMobile(String mobile) {
|
||||
if (!StringUtils.hasText(mobile)) {
|
||||
throw new BusinessException("企业微信账号未维护手机号");
|
||||
}
|
||||
String digits = mobile.replaceAll("[^0-9]", "");
|
||||
if (digits.startsWith("86") && digits.length() == 13) {
|
||||
digits = digits.substring(2);
|
||||
}
|
||||
if (digits.length() != 11) {
|
||||
throw new BusinessException("企业微信手机号格式不正确");
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
private String urlEncode(String value) {
|
||||
return URLEncoder.encode(defaultIfBlank(value, ""), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private String defaultIfBlank(String value, String defaultValue) {
|
||||
return StringUtils.hasText(value) ? value : defaultValue;
|
||||
}
|
||||
|
||||
private String serializeTicket(WecomTicketContext context) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("userId", context.userId());
|
||||
payload.put("tenantId", context.tenantId());
|
||||
payload.put("username", context.username());
|
||||
payload.put("redirectPath", context.redirectPath());
|
||||
try {
|
||||
return objectMapper.writeValueAsString(payload);
|
||||
} catch (JsonProcessingException ex) {
|
||||
throw new BusinessException("企业微信登录票据生成失败");
|
||||
}
|
||||
}
|
||||
|
||||
private WecomTicketContext deserializeTicket(String payload) {
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(payload);
|
||||
return new WecomTicketContext(
|
||||
node.path("userId").asLong(),
|
||||
node.path("tenantId").asLong(),
|
||||
node.path("username").asText(""),
|
||||
sanitizeRedirectPath(node.path("redirectPath").asText("/")));
|
||||
} catch (JsonProcessingException ex) {
|
||||
throw new BusinessException("登录票据解析失败");
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveErrorCode(String message) {
|
||||
if (!StringUtils.hasText(message)) {
|
||||
return "wecom_login_failed";
|
||||
}
|
||||
if (message.contains("手机号")) {
|
||||
return "wecom_mobile_missing";
|
||||
}
|
||||
if (message.contains("未开通CRM账号")) {
|
||||
return "crm_user_not_found";
|
||||
}
|
||||
if (message.contains("停用")) {
|
||||
return "crm_user_disabled";
|
||||
}
|
||||
if (message.contains("配置")) {
|
||||
return "wecom_config_error";
|
||||
}
|
||||
if (message.contains("成员身份")) {
|
||||
return "wecom_userid_not_found";
|
||||
}
|
||||
if (message.contains("state")) {
|
||||
return "wecom_state_invalid";
|
||||
}
|
||||
return "wecom_login_failed";
|
||||
}
|
||||
|
||||
private record WecomTicketContext(Long userId, Long tenantId, String username, String redirectPath) {
|
||||
}
|
||||
}
|
||||
|
|
@ -55,3 +55,14 @@ unisbase:
|
|||
token:
|
||||
access-default-minutes: 30
|
||||
refresh-default-days: 7
|
||||
wecom:
|
||||
enabled: ${WECOM_ENABLED:false}
|
||||
corp-id: ${WECOM_CORP_ID:}
|
||||
agent-id: ${WECOM_AGENT_ID:}
|
||||
secret: ${WECOM_SECRET:}
|
||||
redirect-uri: ${WECOM_REDIRECT_URI:https://crm.unissense.top/api/wecom/sso/callback}
|
||||
frontend-callback-path: ${WECOM_FRONTEND_CALLBACK_PATH:/login/wecom}
|
||||
scope: ${WECOM_SCOPE:snsapi_base}
|
||||
state-ttl-seconds: 300
|
||||
ticket-ttl-seconds: 180
|
||||
access-token-safety-seconds: 120
|
||||
|
|
|
|||
|
|
@ -60,3 +60,14 @@ unisbase:
|
|||
token:
|
||||
access-default-minutes: 30
|
||||
refresh-default-days: 7
|
||||
wecom:
|
||||
enabled: false
|
||||
corp-id: ${WECOM_CORP_ID:}
|
||||
agent-id: ${WECOM_AGENT_ID:}
|
||||
secret: ${WECOM_SECRET:}
|
||||
redirect-uri: ${WECOM_REDIRECT_URI:https://crm.unissense.top/api/wecom/sso/callback}
|
||||
frontend-callback-path: ${WECOM_FRONTEND_CALLBACK_PATH:/login/wecom}
|
||||
scope: ${WECOM_SCOPE:snsapi_base}
|
||||
state-ttl-seconds: 300
|
||||
ticket-ttl-seconds: 180
|
||||
access-token-safety-seconds: 120
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import Work from "./pages/Work";
|
|||
import Profile from "./pages/Profile";
|
||||
import { ThemeProvider } from "./components/ThemeProvider";
|
||||
import LoginPage from "./pages/Login";
|
||||
import WecomLoginCallbackPage from "./pages/WecomLoginCallback";
|
||||
import { isAuthed } from "./lib/auth";
|
||||
|
||||
function RequireAuth({ children }: { children: ReactNode }) {
|
||||
|
|
@ -29,6 +30,7 @@ export default function App() {
|
|||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/login/wecom" element={<WecomLoginCallbackPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ export interface PlatformConfig {
|
|||
systemDescription?: string;
|
||||
}
|
||||
|
||||
export interface WecomExchangePayload {
|
||||
ticket: string;
|
||||
}
|
||||
|
||||
export interface DashboardStat {
|
||||
name: string;
|
||||
value?: number;
|
||||
|
|
@ -681,6 +685,13 @@ export async function login(payload: LoginPayload) {
|
|||
});
|
||||
}
|
||||
|
||||
export async function exchangeWecomTicket(payload: WecomExchangePayload) {
|
||||
return request<TokenResponse>("/api/wecom/sso/exchange", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSystemParamValue(key: string, defaultValue?: string) {
|
||||
const params = new URLSearchParams({ key });
|
||||
if (defaultValue !== undefined) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { clearAuth, exchangeWecomTicket, getCurrentUser, persistLogin } from "@/lib/auth";
|
||||
import "./login.css";
|
||||
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
wecom_config_error: "企业微信登录配置不完整,请联系管理员。",
|
||||
wecom_state_invalid: "企业微信登录状态已失效,请从企业微信应用重新进入。",
|
||||
wecom_code_invalid: "企业微信授权失败,请重新从应用进入。",
|
||||
wecom_userid_not_found: "未识别到企业微信成员身份,请确认应用可见范围。",
|
||||
wecom_mobile_missing: "企业微信账号未维护手机号,无法自动登录。",
|
||||
crm_user_not_found: "企业微信手机号未开通CRM账号。",
|
||||
crm_user_disabled: "CRM账号已停用,无法自动登录。",
|
||||
wecom_login_failed: "企业微信自动登录失败,请稍后重试。",
|
||||
};
|
||||
|
||||
export default function WecomLoginCallbackPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [status, setStatus] = useState<"loading" | "error">("loading");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const ticket = searchParams.get("ticket")?.trim() || "";
|
||||
const redirectPath = searchParams.get("redirect")?.trim() || "/";
|
||||
const errorCode = searchParams.get("error")?.trim() || "";
|
||||
|
||||
const targetPath = useMemo(() => {
|
||||
if (!redirectPath.startsWith("/") || redirectPath.startsWith("//")) {
|
||||
return "/";
|
||||
}
|
||||
return redirectPath;
|
||||
}, [redirectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (errorCode) {
|
||||
setStatus("error");
|
||||
setErrorMessage(ERROR_MESSAGES[errorCode] || "企业微信自动登录失败,请稍后重试。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
setStatus("error");
|
||||
setErrorMessage("缺少登录票据,请从企业微信应用重新进入。");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
try {
|
||||
clearAuth();
|
||||
const tokenData = await exchangeWecomTicket({ ticket });
|
||||
persistLogin(tokenData, "");
|
||||
|
||||
try {
|
||||
const profile = await getCurrentUser();
|
||||
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
||||
const username = profile.username?.trim();
|
||||
if (username) {
|
||||
localStorage.setItem("username", username);
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem("userProfile");
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
navigate(targetPath || "/", { replace: true });
|
||||
}
|
||||
} catch (error) {
|
||||
clearAuth();
|
||||
if (!cancelled) {
|
||||
setStatus("error");
|
||||
setErrorMessage(error instanceof Error ? error.message : "企业微信自动登录失败,请稍后重试。");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [errorCode, navigate, targetPath, ticket]);
|
||||
|
||||
return (
|
||||
<div className="login-page-shell">
|
||||
<div className="login-page-backdrop" />
|
||||
<div className="login-page-grid" style={{ gridTemplateColumns: "minmax(0, 460px)" }}>
|
||||
<section className="login-panel">
|
||||
<div className="login-panel-card">
|
||||
<div className="login-panel-header">
|
||||
<p className="login-panel-eyebrow">企业微信登录</p>
|
||||
<h3>{status === "loading" ? "正在自动登录..." : "自动登录失败"}</h3>
|
||||
</div>
|
||||
|
||||
{status === "loading" ? (
|
||||
<div className="login-form">
|
||||
<div className="login-error" style={{ marginTop: 0 }}>
|
||||
正在校验企业微信身份并进入CRM,请稍候。
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="login-form">
|
||||
<div className="login-error" style={{ marginTop: 0 }}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
<Link className="login-submit" to="/login">
|
||||
返回普通登录
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue