From 6479f6fd0100a2c444cb4b2f7cd5ffabe64ca7cb Mon Sep 17 00:00:00 2001 From: kangwenjing <1138819403@qq.com> Date: Sun, 29 Mar 2026 03:05:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BC=81=E4=B8=9A=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E9=80=9A=E8=BF=87=E6=89=8B=E6=9C=BA=E5=8F=B7=E4=B8=8E?= =?UTF-8?q?CRM=E7=94=A8=E6=88=B7=E5=90=8D=E5=AF=B9=E5=BA=94=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E7=99=BB=E5=BD=95=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unis/crm/UnisCrmBackendApplication.java | 3 + .../com/unis/crm/config/WecomProperties.java | 98 ++++ .../crm/controller/WecomSsoController.java | 46 ++ .../dto/wecom/WecomSsoExchangeRequest.java | 17 + .../com/unis/crm/service/WecomSsoService.java | 12 + .../crm/service/impl/WecomSsoServiceImpl.java | 441 ++++++++++++++++++ .../src/main/resources/application-prod.yml | 11 + backend/src/main/resources/application.yml | 11 + frontend/src/App.tsx | 2 + frontend/src/lib/auth.ts | 11 + frontend/src/pages/WecomLoginCallback.tsx | 115 +++++ 11 files changed, 767 insertions(+) create mode 100644 backend/src/main/java/com/unis/crm/config/WecomProperties.java create mode 100644 backend/src/main/java/com/unis/crm/controller/WecomSsoController.java create mode 100644 backend/src/main/java/com/unis/crm/dto/wecom/WecomSsoExchangeRequest.java create mode 100644 backend/src/main/java/com/unis/crm/service/WecomSsoService.java create mode 100644 backend/src/main/java/com/unis/crm/service/impl/WecomSsoServiceImpl.java create mode 100644 frontend/src/pages/WecomLoginCallback.tsx diff --git a/backend/src/main/java/com/unis/crm/UnisCrmBackendApplication.java b/backend/src/main/java/com/unis/crm/UnisCrmBackendApplication.java index 4c995da4..bc81b68e 100644 --- a/backend/src/main/java/com/unis/crm/UnisCrmBackendApplication.java +++ b/backend/src/main/java/com/unis/crm/UnisCrmBackendApplication.java @@ -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) { diff --git a/backend/src/main/java/com/unis/crm/config/WecomProperties.java b/backend/src/main/java/com/unis/crm/config/WecomProperties.java new file mode 100644 index 00000000..51543429 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/config/WecomProperties.java @@ -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; + } +} diff --git a/backend/src/main/java/com/unis/crm/controller/WecomSsoController.java b/backend/src/main/java/com/unis/crm/controller/WecomSsoController.java new file mode 100644 index 00000000..7e6b4dde --- /dev/null +++ b/backend/src/main/java/com/unis/crm/controller/WecomSsoController.java @@ -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 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 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 exchange(@Valid @RequestBody WecomSsoExchangeRequest request) { + return ApiResponse.success(wecomSsoService.exchangeTicket(request.getTicket())); + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/wecom/WecomSsoExchangeRequest.java b/backend/src/main/java/com/unis/crm/dto/wecom/WecomSsoExchangeRequest.java new file mode 100644 index 00000000..2204c035 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/wecom/WecomSsoExchangeRequest.java @@ -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; + } +} diff --git a/backend/src/main/java/com/unis/crm/service/WecomSsoService.java b/backend/src/main/java/com/unis/crm/service/WecomSsoService.java new file mode 100644 index 00000000..93a62a43 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/service/WecomSsoService.java @@ -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); +} diff --git a/backend/src/main/java/com/unis/crm/service/impl/WecomSsoServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/WecomSsoServiceImpl.java new file mode 100644 index 00000000..b29b2a92 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/service/impl/WecomSsoServiceImpl.java @@ -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 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 resolveAvailableTenants(SysUser user) { + List 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 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 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 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 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) { + } +} diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index eb449f89..43c69ebf 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -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 diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index c5c39916..b057f4f4 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 92ef77e5..9f8e2609 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> + } /> ("/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) { diff --git a/frontend/src/pages/WecomLoginCallback.tsx b/frontend/src/pages/WecomLoginCallback.tsx new file mode 100644 index 00000000..a41ecf44 --- /dev/null +++ b/frontend/src/pages/WecomLoginCallback.tsx @@ -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 = { + 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 ( +
+
+
+
+
+
+

企业微信登录

+

{status === "loading" ? "正在自动登录..." : "自动登录失败"}

+
+ + {status === "loading" ? ( +
+
+ 正在校验企业微信身份并进入CRM,请稍候。 +
+
+ ) : ( +
+
+ {errorMessage} +
+ + 返回普通登录 + +
+ )} +
+
+
+
+ ); +}