diff --git a/.DS_Store b/.DS_Store index 57e47aa2..5a67d32f 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.idea/misc.xml b/.idea/misc.xml index 1d351d1c..0c36ad4d 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/backend/src/main/java/com/unis/crm/controller/WecomSsoController.java b/backend/src/main/java/com/unis/crm/controller/WecomSsoController.java index 7e6b4dde..3e0ac957 100644 --- a/backend/src/main/java/com/unis/crm/controller/WecomSsoController.java +++ b/backend/src/main/java/com/unis/crm/controller/WecomSsoController.java @@ -1,6 +1,7 @@ package com.unis.crm.controller; import com.unis.crm.common.ApiResponse; +import com.unis.crm.dto.wecom.WecomJsSdkConfigDTO; import com.unis.crm.dto.wecom.WecomSsoExchangeRequest; import com.unis.crm.service.WecomSsoService; import com.unisbase.dto.TokenResponse; @@ -43,4 +44,9 @@ public class WecomSsoController { public ApiResponse exchange(@Valid @RequestBody WecomSsoExchangeRequest request) { return ApiResponse.success(wecomSsoService.exchangeTicket(request.getTicket())); } + + @GetMapping("/js-sdk-config") + public ApiResponse getJsSdkConfig(@RequestParam("url") String url) { + return ApiResponse.success(wecomSsoService.buildJsSdkConfig(url)); + } } diff --git a/backend/src/main/java/com/unis/crm/dto/wecom/WecomJsSdkConfigDTO.java b/backend/src/main/java/com/unis/crm/dto/wecom/WecomJsSdkConfigDTO.java new file mode 100644 index 00000000..e30a83bb --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/wecom/WecomJsSdkConfigDTO.java @@ -0,0 +1,41 @@ +package com.unis.crm.dto.wecom; + +public class WecomJsSdkConfigDTO { + + private String corpId; + private long timestamp; + private String nonceStr; + private String signature; + + public String getCorpId() { + return corpId; + } + + public void setCorpId(String corpId) { + this.corpId = corpId; + } + + public long getTimestamp() { + return timestamp; + } + + public void setTimestamp(long timestamp) { + this.timestamp = timestamp; + } + + public String getNonceStr() { + return nonceStr; + } + + public void setNonceStr(String nonceStr) { + this.nonceStr = nonceStr; + } + + public String getSignature() { + return signature; + } + + public void setSignature(String signature) { + this.signature = signature; + } +} diff --git a/backend/src/main/java/com/unis/crm/service/WecomSsoService.java b/backend/src/main/java/com/unis/crm/service/WecomSsoService.java index 93a62a43..32c19aa5 100644 --- a/backend/src/main/java/com/unis/crm/service/WecomSsoService.java +++ b/backend/src/main/java/com/unis/crm/service/WecomSsoService.java @@ -1,5 +1,6 @@ package com.unis.crm.service; +import com.unis.crm.dto.wecom.WecomJsSdkConfigDTO; import com.unisbase.dto.TokenResponse; public interface WecomSsoService { @@ -9,4 +10,6 @@ public interface WecomSsoService { String handleCallback(String code, String state); TokenResponse exchangeTicket(String ticket); + + WecomJsSdkConfigDTO buildJsSdkConfig(String url); } 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 index b29b2a92..2e6efaeb 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/WecomSsoServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/WecomSsoServiceImpl.java @@ -5,6 +5,7 @@ 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.dto.wecom.WecomJsSdkConfigDTO; import com.unis.crm.service.WecomSsoService; import com.unisbase.auth.JwtTokenProvider; import com.unisbase.common.RedisKeys; @@ -14,7 +15,10 @@ import com.unisbase.mapper.SysUserMapper; import com.unisbase.service.AuthVersionService; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Duration; +import java.time.Instant; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; @@ -33,7 +37,9 @@ public class WecomSsoServiceImpl implements WecomSsoService { 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 WECOM_GET_JSAPI_TICKET_URL = "https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket"; private static final String ACCESS_TOKEN_CACHE_KEY = "crm:wecom:access-token"; + private static final String JSAPI_TICKET_CACHE_KEY = "crm:wecom:jsapi-ticket"; private static final String STATE_CACHE_PREFIX = "crm:wecom:sso:state:"; private static final String TICKET_CACHE_PREFIX = "crm:wecom:sso:ticket:"; @@ -145,6 +151,27 @@ public class WecomSsoServiceImpl implements WecomSsoService { return response; } + @Override + public WecomJsSdkConfigDTO buildJsSdkConfig(String url) { + ensureEnabled(); + validateRequiredConfig(); + String normalizedUrl = normalizeJsSdkUrl(url); + String jsapiTicket = getJsApiTicket(getCorpAccessToken()); + String nonceStr = UUID.randomUUID().toString().replace("-", ""); + long timestamp = Instant.now().getEpochSecond(); + String signature = sha1("jsapi_ticket=" + jsapiTicket + + "&noncestr=" + nonceStr + + "×tamp=" + timestamp + + "&url=" + normalizedUrl); + + WecomJsSdkConfigDTO dto = new WecomJsSdkConfigDTO(); + dto.setCorpId(wecomProperties.getCorpId()); + dto.setTimestamp(timestamp); + dto.setNonceStr(nonceStr); + dto.setSignature(signature); + return dto; + } + private void ensureEnabled() { if (!wecomProperties.isEnabled()) { throw new BusinessException("企业微信单点登录未启用"); @@ -181,6 +208,26 @@ public class WecomSsoServiceImpl implements WecomSsoService { return accessToken; } + private String getJsApiTicket(String accessToken) { + String cached = stringRedisTemplate.opsForValue().get(JSAPI_TICKET_CACHE_KEY); + if (StringUtils.hasText(cached)) { + return cached; + } + + JsonNode response = getJson(WECOM_GET_JSAPI_TICKET_URL + + "?access_token=" + urlEncode(accessToken)); + assertWecomSuccess(response, "获取企业微信JS-SDK票据失败"); + String jsapiTicket = response.path("ticket").asText(""); + long expiresIn = response.path("expires_in").asLong(7200); + if (!StringUtils.hasText(jsapiTicket)) { + throw new BusinessException("企业微信JS-SDK票据为空"); + } + + long ttlSeconds = Math.max(60L, expiresIn - wecomProperties.getAccessTokenSafetySeconds()); + stringRedisTemplate.opsForValue().set(JSAPI_TICKET_CACHE_KEY, jsapiTicket, Duration.ofSeconds(ttlSeconds)); + return jsapiTicket; + } + private String getWecomUserId(String accessToken, String code) { JsonNode response = getJson(WECOM_GET_USER_INFO_URL + "?access_token=" + urlEncode(accessToken) @@ -377,6 +424,32 @@ public class WecomSsoServiceImpl implements WecomSsoService { return digits; } + private String normalizeJsSdkUrl(String url) { + if (!StringUtils.hasText(url)) { + throw new BusinessException("企业微信JS-SDK签名地址不能为空"); + } + String trimmed = url.trim(); + if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { + throw new BusinessException("企业微信JS-SDK签名地址格式不正确"); + } + int fragmentIndex = trimmed.indexOf('#'); + return fragmentIndex >= 0 ? trimmed.substring(0, fragmentIndex) : trimmed; + } + + private String sha1(String content) { + try { + MessageDigest messageDigest = MessageDigest.getInstance("SHA-1"); + byte[] digest = messageDigest.digest(content.getBytes(StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(digest.length * 2); + for (byte current : digest) { + builder.append(String.format("%02x", current)); + } + return builder.toString(); + } catch (NoSuchAlgorithmException ex) { + throw new BusinessException("企业微信JS-SDK签名生成失败"); + } + } + private String urlEncode(String value) { return URLEncoder.encode(defaultIfBlank(value, ""), StandardCharsets.UTF_8); } diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index c1229154..c53c6403 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -5,6 +5,8 @@ import { cn } from "@/lib/utils"; import { useTheme } from "./ThemeProvider"; import { motion, AnimatePresence } from "motion/react"; import { getWorkOverview } from "@/lib/auth"; +import { useIsMobileViewport } from "@/hooks/useIsMobileViewport"; +import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser"; type NavChildItem = { name: string; @@ -48,6 +50,9 @@ export default function Layout() { const [afterReminderTime, setAfterReminderTime] = useState(isAfterDailyReportReminderTime()); const [mobileBellHint, setMobileBellHint] = useState(""); const mobileBellTimerRef = useRef(null); + const isMobileViewport = useIsMobileViewport(); + const isWecomBrowser = useIsWecomBrowser(); + const isWecomMobile = isMobileViewport && isWecomBrowser; const isActivePath = (path: string) => location.pathname === path || (path !== "/" && location.pathname.startsWith(`${path}/`)); const activeNavItem = navItems.find((item) => isActivePath(item.path)) ?? navItems[0]; const contentKey = activeNavItem.path; @@ -152,9 +157,14 @@ export default function Layout() { {/* Main Content */} -
+
-
+

紫光汇智CRM @@ -166,7 +176,12 @@ export default function Layout() {

) : null} - - + {isMobileViewport ? ( +
- - +
+ ) : ( + + + + + + )}
{/* Mobile Bottom Nav */} -