From 42c21cc4fc626d08e43519aad34ed758c3505764 Mon Sep 17 00:00:00 2001 From: kangwenjing <1138819403@qq.com> Date: Mon, 30 Mar 2026 14:29:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1=E5=86=85?= =?UTF-8?q?=E9=83=A8=E5=AE=9A=E4=BD=8D=E4=B8=8E=E5=AF=86=E7=A0=81=E7=BC=93?= =?UTF-8?q?=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 10244 -> 10244 bytes .idea/misc.xml | 1 - .../crm/controller/WecomSsoController.java | 6 + .../crm/dto/wecom/WecomJsSdkConfigDTO.java | 41 ++++ .../com/unis/crm/service/WecomSsoService.java | 3 + .../crm/service/impl/WecomSsoServiceImpl.java | 73 +++++++ frontend/src/components/Layout.tsx | 61 ++++-- frontend/src/hooks/useIsMobileViewport.ts | 30 +++ frontend/src/hooks/useIsWecomBrowser.ts | 11 + frontend/src/lib/auth.ts | 12 ++ frontend/src/lib/tencentMap.ts | 10 +- frontend/src/lib/wecom.ts | 204 ++++++++++++++++++ frontend/src/pages/Dashboard.tsx | 29 +-- frontend/src/pages/Expansion.tsx | 40 ++-- frontend/src/pages/Login.tsx | 80 ++++++- frontend/src/pages/Opportunities.tsx | 41 +++- frontend/src/pages/Profile.tsx | 26 ++- frontend/src/pages/Work.tsx | 131 ++++++++--- 18 files changed, 702 insertions(+), 97 deletions(-) create mode 100644 backend/src/main/java/com/unis/crm/dto/wecom/WecomJsSdkConfigDTO.java create mode 100644 frontend/src/hooks/useIsMobileViewport.ts create mode 100644 frontend/src/hooks/useIsWecomBrowser.ts create mode 100644 frontend/src/lib/wecom.ts diff --git a/.DS_Store b/.DS_Store index 57e47aa2d9387d604ccc140855ebe0c101798bc4..5a67d32fb3e9cbb09073fdec2eec4e410a45936f 100644 GIT binary patch literal 10244 zcmeHMX>8m?6n@W1iCt6bhR}i2(QcDe^rXio&Cw%x*KT6fu{Ue)rll%` zKY(aKAeAC+{!mdsKm{$7KU6AEkPt{!skkIsA>apm5Zq=Q<$7R!-^1arV~Z72dg`tOgp`qVVV&mLLfpQ zLLfpQLLfrmLP3D`Y*s{>)2NORh!BVn7({@5A7b<{nh59sr~apdD*p&Tw3661ayE?l2AczN|c*oaKu1vPW)J)mk8(pr`#McIDB9*GX^IV^k=8>W4=0Ig43vu z5Qq>MjsQEmSHgAB4JLTu^Sf}c)eFmdo#y70<)nxw?mKpTZT`rKrNwgnyz)%X(A|J= z=s_}tQ8cd!IvAjZX9l?9gJNlT?!IoTr;pDavhQ_0!9 zKqL83q7n9E&i=DAdbuCNw1#{cS*zF||EpC6DbUE9?SzO8dXYbs->>uiDbfi0^U=dX zO{U;aiPn%mBWo4;BmO{>QC#SROd4F>Orm7^}I601a6+>-2Z`jejK<=jRsyNUH$2>Vo_ zt)(`aMt_%5)8yEmX4*#9H!yXBR5rJnw(j&dIyqZ+ecL1#h>|FEDU){Xn%6MDJ~sFI z1-t8GyW;Wq!ur^P_}tyQCGoPl**9+J+HUpQ&K~AF?V-V53uAbHu-;3pGD|DG5oT6K z9DXOV{MYFbwA_3X$EPA$z~{39KGJAe9;3FkcXV#r+@&Zv*VtfKnrG&XX3aC)E@iAs zaIHu?_PVT*Cz#sOj+N`RNir_2S?L@p8%r%KY2MDY(^*>(kv|;O}-Q7uV$o9-_rjeD#VZGF1YrV#)>WPykUp_@uOSKE5 zwC7T0U(WP)Ds`gB=a{P2Ny=eyvrda#4JKg^F(qdfB zYgC&>C+&rFu-rsrgRG;W=$ zsvT^orMhnDwCKqbCsnISwN+G3l8s{8d6UwH-T}`Zv91uXb%~k0S$8b`jNkGXSeIzv z>>5r6YxZMQg$CLJR>D@uz)si$`{8ML4GzN*cn3a&)9?j+13$t~@C*D3e_$n!#u}W0 zSK~CCjx%r$&c*q-0Gn_%uEATe6Vs^UHta?VbC|~+xD)Tdd+=Vo5BK0h_!vHpPvTQ} z0AI#e@DRR<$MJ1^7f;}6{1VULSv-fo;IH_bAPLpN6hRfP5T*&!g_*)EVX?48ST3v= zI)yHwhp#~>WbT&1HTex;Cy{cg>E?rvlJHXbq^rGU`HGdRR$s75_vs}p%`UCEZ2W}S z)iY<$yZ+{-gTcsSw-B!3H1H$8C|~@*@H4P9YI#1&wXKvI#*UHFQmJAoES_T8Q?Hb3 zl~I&DynD?w>Rv>N!@INUYikvO@|AZR;!yawwjz87PHV!RobQH-y{4Vc6h+=5-G zVTvN&LK`#KhgsZ(yYWsOz`H2k_u~Ed06v0`Qp`U=QGWoR!-M!dzKVzOb$kPl`qBRZ zevF^tS0(YhrZ}GQ=koC!-rB#bG74LJnaB@s?&sGGZ|`GY9AJ)=884sA+LmK?la%FP z1^vA211r2U2CVRI2r%#DkaF1h4(*eDIsUsOunRjpTpD(gAKGc$s6+@v2t){!B2d8@ zF?RpIY~=s{OU#oA7p>%BmF=F)GMh`1YZ-7(Zges=O`2KY~ vb=Y-0yIW+u;FRUJ69I34Q{S9)d~>4z(w_m*`QN{kh|d2k3IYC)=l{O}apcm@ delta 1585 zcmeH`+fP(i6vo$g7<6xMY!QZm?G&uehyx-)DT>NP5ifvX0Re5q8O|}DIL_dl<7j*k zH6(3gt(Wtt_VUtJV~7bFFAqNXpe9hpx_iiI$Ti(egxr&KfY@2&?d(^T! zhmFLWH#Nyt#}Jk=e;}tRLe2~S{E=d+qy}oGEi^<4+DBi}G5VU$(hoF7m*@uFraN?(?$Iyw zh<>NP=n1``S0DgWFcs4=0}}HPLKrnzjumJ?BU;dbjp)KAY{nqABZeWENWsEhq;U|R z;|qL=Q#g$?UHAsyVhmSs71wYRcX1Ct<1zlk6FkK;ywXV1v>eT^6=`K!NUNgE(%?lJ z$i{5Y8lC0>e<*IHhhy<%j7W?BmxY3BTGy_7$3m6)(_ClF%%4?I_|e?*g*BfnZ*=|$ zbTZF7Jm{<}4v)B^w2TKut^&;_TsbeK867AD?Oy&=x=&X zFX2;={0Jyevk_FF$`L^YDp89%EJMA*)`C{7!+LB$H+s>BehesRIt=VU5_=RlTcJCM zLpY2hI9iQkIF1uInPK=H&Uugf7s~o9jcmYm7s~kKl5p 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 */} -