添加企业微信通过手机号与CRM用户名对应自动登录的功能

main
kangwenjing 2026-03-29 03:05:54 +08:00
parent c5c0652088
commit 6479f6fd01
11 changed files with 767 additions and 0 deletions

View File

@ -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) {

View File

@ -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;
}
}

View File

@ -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()));
}
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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) {
}
}

View File

@ -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

View File

@ -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

View File

@ -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={

View File

@ -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) {

View File

@ -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>
);
}