diff --git a/.env.release.example b/.env.release.example
index 5b2fbc9d..126c0035 100644
--- a/.env.release.example
+++ b/.env.release.example
@@ -94,6 +94,8 @@ OAUTH2_GITLAB_DISPLAY_NAME=GitLab
# Optional: configure DingTalk (钉钉) OAuth2 login.
# Register your app at https://open-dev.dingtalk.com and request the Contact.User.Read scope.
+# The scope must be "openid" (not "dingtalk") — DingTalk uses openid for OAuth2 authorization.
+# Add "openid corpid" if you also need corporate identity information.
OAUTH2_DINGTALK_CLIENT_ID=
OAUTH2_DINGTALK_CLIENT_SECRET=
OAUTH2_DINGTALK_DISPLAY_NAME=钉钉
diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml
index 5621c8c9..3f85d0e4 100644
--- a/server/skillhub-app/src/main/resources/application.yml
+++ b/server/skillhub-app/src/main/resources/application.yml
@@ -73,7 +73,7 @@ spring:
client-id: ${OAUTH2_DINGTALK_CLIENT_ID:placeholder}
client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET:placeholder}
scope:
- - dingtalk
+ - openid
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-name: ${OAUTH2_DINGTALK_DISPLAY_NAME:钉钉}
@@ -89,7 +89,7 @@ spring:
authorization-uri: https://login.dingtalk.com/oauth2/auth
token-uri: https://api.dingtalk.com/v1.0/oauth2/userAccessToken
user-info-uri: https://api.dingtalk.com/v1.0/contact/users/me
- user-name-attribute: openId
+ user-name-attribute: unionId
servlet:
multipart:
max-file-size: 100MB
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java
index 51f6d27b..1a5956f3 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java
@@ -1,6 +1,8 @@
package com.iflytek.skillhub.auth.oauth;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
@@ -14,11 +16,16 @@ import java.util.Map;
*
*
Field mapping:
*
- * - subject → openId
+ * - subject → unionId (unique across all apps under the same developer account)
* - email → unionId@dingtalk.local (synthetic, DingTalk users may not have email)
* - emailVerified → true (synthetic)
* - providerLogin → nick
*
+ *
+ * Note: unionId is used instead of openId because openId is only unique within
+ * a single DingTalk application. If a user logs in through different DingTalk apps
+ * under the same developer account, openId would differ, causing duplicate accounts.
+ * unionId remains stable across all apps under the same developer.
*/
@Component
public class DingTalkClaimsExtractor implements OAuthClaimsExtractor {
@@ -32,18 +39,25 @@ public class DingTalkClaimsExtractor implements OAuthClaimsExtractor {
public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) {
Map attrs = oAuth2User.getAttributes();
- String openId = (String) attrs.get("openId");
String unionId = (String) attrs.get("unionId");
+ String openId = (String) attrs.get("openId");
String nick = (String) attrs.get("nick");
+ // unionId is required — it is the cross-app stable identity for DingTalk users
+ if (unionId == null || unionId.isEmpty()) {
+ throw new OAuth2AuthenticationException(
+ new OAuth2Error("missing_union_id",
+ "DingTalk response missing required unionId field. "
+ + "Ensure the 'openid' scope is configured and the DingTalk app "
+ + "has the Contact.User.Read permission.", null));
+ }
+
// DingTalk users may not have email; synthesize one for downstream compatibility
- String syntheticEmail = (unionId != null && !unionId.isEmpty())
- ? unionId + "@dingtalk.local"
- : (openId != null ? openId + "@dingtalk.local" : null);
+ String syntheticEmail = unionId + "@dingtalk.local";
return new OAuthClaims(
"dingtalk",
- openId,
+ unionId, // Use unionId (cross-app unique) instead of openId (single-app only)
syntheticEmail,
true,
nick,
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java
index 6280972a..ba929aac 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java
@@ -1,5 +1,6 @@
package com.iflytek.skillhub.auth.oauth;
+import java.time.Duration;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
@@ -10,6 +11,7 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
@@ -41,14 +43,32 @@ public class DingTalkOAuth2UserService implements OAuth2UserService requestEntity = new HttpEntity<>(headers);
ResponseEntity