diff --git a/vetti-admin/src/main/java/com/vetti/web/controller/hotake/HotakeSocialLoginController.java b/vetti-admin/src/main/java/com/vetti/web/controller/hotake/HotakeSocialLoginController.java new file mode 100644 index 0000000..9918c19 --- /dev/null +++ b/vetti-admin/src/main/java/com/vetti/web/controller/hotake/HotakeSocialLoginController.java @@ -0,0 +1,88 @@ +package com.vetti.web.controller.hotake; + +import com.vetti.common.annotation.Anonymous; +import com.vetti.common.core.domain.AjaxResult; +import com.vetti.common.core.domain.R; +import com.vetti.common.utils.SecurityUtils; +import com.vetti.hotake.domain.HotakeSocialUser; +import com.vetti.hotake.domain.dto.HotakeSocialLoginRequestDto; +import com.vetti.hotake.domain.dto.HotakeSocialLoginResultDto; +import com.vetti.hotake.service.IHotakeSocialUserService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 社交登录控制器 + * + * @author vetti + */ +@Api(tags = "社交登录模块") +@RestController +@RequestMapping("/oauth2") +public class HotakeSocialLoginController { + + @Autowired + private IHotakeSocialUserService socialUserService; + + /** + * 获取OAuth授权URL + */ + @Anonymous + @ApiOperation("获取OAuth授权URL") + @GetMapping("/authorize/{provider}") + public R getAuthorizationUrl( + @ApiParam(value = "平台类型:google/microsoft/linkedin", required = true) + @PathVariable String provider, + @ApiParam(value = "state参数,用于防止CSRF攻击") + @RequestParam(required = false) String state) { + String authUrl = socialUserService.getAuthorizationUrl(provider, state); + return R.ok(authUrl, ""); + } + + /** + * 社交登录回调(用code换取token并登录) + */ + @Anonymous + @ApiOperation("社交登录") + @PostMapping("/login") + public R socialLogin(@Validated @RequestBody HotakeSocialLoginRequestDto requestDto) { + HotakeSocialLoginResultDto resultDto = socialUserService.socialLogin(requestDto); + return R.ok(resultDto, ""); + } + + /** + * 获取当前用户绑定的社交账号列表 + */ + @ApiOperation("获取当前用户绑定的社交账号列表") + @GetMapping("/bindList") + public R> getBindList() { + Long userId = SecurityUtils.getUserId(); + List list = socialUserService.listByUserId(userId); + // 脱敏处理,不返回token等敏感信息 + list.forEach(item -> { + item.setAccessToken(null); + item.setRefreshToken(null); + item.setRawUserInfo(null); + }); + return R.ok(list, ""); + } + + /** + * 解绑社交账号 + */ + @ApiOperation("解绑社交账号") + @DeleteMapping("/unbind/{provider}") + public AjaxResult unbind( + @ApiParam(value = "平台类型:google/microsoft/linkedin", required = true) + @PathVariable String provider) { + Long userId = SecurityUtils.getUserId(); + socialUserService.unbind(userId, provider); + return AjaxResult.success("解绑成功"); + } +} diff --git a/vetti-admin/src/main/java/com/vetti/web/service/impl/HotakeSocialUserServiceImpl.java b/vetti-admin/src/main/java/com/vetti/web/service/impl/HotakeSocialUserServiceImpl.java new file mode 100644 index 0000000..c388b0a --- /dev/null +++ b/vetti-admin/src/main/java/com/vetti/web/service/impl/HotakeSocialUserServiceImpl.java @@ -0,0 +1,431 @@ +package com.vetti.web.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.vetti.common.config.HotakeOAuth2Properties; +import com.vetti.common.core.domain.entity.SysUser; +import com.vetti.common.core.domain.model.LoginUser; +import com.vetti.common.enums.UserFlagEnum; +import com.vetti.common.exception.ServiceException; +import com.vetti.common.utils.DateUtils; +import com.vetti.common.utils.ip.AddressUtils; +import com.vetti.common.utils.ip.IpUtils; +import com.vetti.framework.web.service.SysPermissionService; +import com.vetti.framework.web.service.TokenService; +import com.vetti.hotake.domain.HotakeSocialLoginLog; +import com.vetti.hotake.domain.HotakeSocialUser; +import com.vetti.hotake.domain.dto.HotakeSocialLoginRequestDto; +import com.vetti.hotake.domain.dto.HotakeSocialLoginResultDto; +import com.vetti.hotake.domain.dto.HotakeSocialUserInfoDto; +import com.vetti.hotake.mapper.HotakeSocialLoginLogMapper; +import com.vetti.hotake.mapper.HotakeSocialUserMapper; +import com.vetti.hotake.service.IHotakeSocialUserService; +import com.vetti.system.service.ISysUserService; +import eu.bitwalker.useragentutils.UserAgent; +import okhttp3.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * 社交用户服务实现 + * + * @author vetti + */ +@Service +public class HotakeSocialUserServiceImpl implements IHotakeSocialUserService { + + private static final Logger log = LoggerFactory.getLogger(HotakeSocialUserServiceImpl.class); + + @Autowired + private HotakeOAuth2Properties oAuth2Properties; + + @Autowired + private HotakeSocialUserMapper socialUserMapper; + + @Autowired + private HotakeSocialLoginLogMapper socialLoginLogMapper; + + @Autowired + private ISysUserService sysUserService; + + @Autowired + private TokenService tokenService; + + @Autowired + private SysPermissionService permissionService; + + private final OkHttpClient httpClient = new OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build(); + + @Override + @Transactional(rollbackFor = Exception.class) + public HotakeSocialLoginResultDto socialLogin(HotakeSocialLoginRequestDto requestDto) { + String provider = requestDto.getProvider().toLowerCase(); + String code = requestDto.getCode(); + + // 1. 获取第三方用户信息 + HotakeSocialUserInfoDto socialUserInfo = getSocialUserInfo(provider, code); + if (socialUserInfo == null || StrUtil.isBlank(socialUserInfo.getProviderUserId())) { + recordLoginLog(null, provider, null, "login", "1", "获取第三方用户信息失败"); + throw new ServiceException("获取第三方用户信息失败"); + } + + // 2. 查询是否已绑定 + HotakeSocialUser existSocialUser = socialUserMapper.selectByProviderAndProviderUserId( + provider, socialUserInfo.getProviderUserId()); + + SysUser sysUser; + String loginType; + boolean isNewUser = false; + + if (existSocialUser != null && existSocialUser.getUserId() != null) { + // 已绑定,直接登录 + loginType = "login"; + sysUser = sysUserService.selectUserById(existSocialUser.getUserId()); + if (sysUser == null) { + recordLoginLog(null, provider, socialUserInfo.getProviderUserId(), loginType, "1", "绑定的用户不存在"); + throw new ServiceException("绑定的用户不存在"); + } + // 更新token信息 + updateSocialUserToken(existSocialUser, socialUserInfo); + } else { + // 未绑定,自动注册 + loginType = "register"; + isNewUser = true; + sysUser = autoRegister(socialUserInfo, requestDto.getSysUserType()); + // 如果是已存在用户绑定,则不是新用户 + if (sysUser.getCreateTime() != null && + System.currentTimeMillis() - sysUser.getCreateTime().getTime() > 5000) { + isNewUser = false; + } + // 创建社交绑定 + createSocialUserBinding(sysUser.getUserId(), socialUserInfo); + } + + // 3. 生成登录Token + HotakeSocialLoginResultDto resultDto = createLoginToken(sysUser, provider, isNewUser); + + // 4. 记录登录日志 + recordLoginLog(sysUser.getUserId(), provider, socialUserInfo.getProviderUserId(), loginType, "0", "登录成功"); + + // 5. 更新登录信息 + recordLoginInfo(sysUser.getUserId()); + + return resultDto; + } + + @Override + public String getAuthorizationUrl(String provider, String state) { + HotakeOAuth2Properties.OAuthClientConfig config = oAuth2Properties.getByProvider(provider); + if (config == null) { + throw new ServiceException("不支持的登录平台: " + provider); + } + + try { + StringBuilder url = new StringBuilder(config.getAuthUri()); + url.append("?client_id=").append(URLEncoder.encode(config.getClientId(), StandardCharsets.UTF_8.name())); + url.append("&redirect_uri=").append(URLEncoder.encode(config.getRedirectUri(), StandardCharsets.UTF_8.name())); + url.append("&response_type=code"); + url.append("&scope=").append(URLEncoder.encode(config.getScope(), StandardCharsets.UTF_8.name())); + if (StrUtil.isNotBlank(state)) { + url.append("&state=").append(URLEncoder.encode(state, StandardCharsets.UTF_8.name())); + } + return url.toString(); + } catch (Exception e) { + throw new ServiceException("生成授权URL失败"); + } + } + + @Override + public List listByUserId(Long userId) { + return socialUserMapper.selectByUserId(userId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void unbind(Long userId, String provider) { + HotakeSocialUser socialUser = socialUserMapper.selectByUserIdAndProvider(userId, provider); + if (socialUser == null) { + throw new ServiceException("未绑定该平台账号"); + } + socialUserMapper.deleteById(socialUser.getId()); + } + + /** + * 获取第三方用户信息 + */ + private HotakeSocialUserInfoDto getSocialUserInfo(String provider, String code) { + HotakeOAuth2Properties.OAuthClientConfig config = oAuth2Properties.getByProvider(provider); + if (config == null) { + throw new ServiceException("不支持的登录平台: " + provider); + } + + // 1. 用code换取access_token + JSONObject tokenResponse = getAccessToken(config, code); + if (tokenResponse == null) { + return null; + } + + String accessToken = tokenResponse.getString("access_token"); + String refreshToken = tokenResponse.getString("refresh_token"); + Long expiresIn = tokenResponse.getLong("expires_in"); + + // 2. 用access_token获取用户信息 + JSONObject userInfo = getUserInfo(config, accessToken); + if (userInfo == null) { + return null; + } + + // 3. 解析用户信息(不同平台字段不同) + return parseSocialUserInfo(provider, userInfo, accessToken, refreshToken, expiresIn); + } + + /** + * 获取access_token + */ + private JSONObject getAccessToken(HotakeOAuth2Properties.OAuthClientConfig config, String code) { + try { + FormBody.Builder formBuilder = new FormBody.Builder() + .add("client_id", config.getClientId()) + .add("client_secret", config.getClientSecret()) + .add("code", code) + .add("redirect_uri", config.getRedirectUri()) + .add("grant_type", "authorization_code"); + + Request request = new Request.Builder() + .url(config.getTokenUri()) + .post(formBuilder.build()) + .addHeader("Accept", "application/json") + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (response.isSuccessful() && response.body() != null) { + String body = response.body().string(); + log.debug("Token response: {}", body); + return JSON.parseObject(body); + } + } + } catch (IOException e) { + log.error("获取access_token失败", e); + } + return null; + } + + /** + * 获取用户信息 + */ + private JSONObject getUserInfo(HotakeOAuth2Properties.OAuthClientConfig config, String accessToken) { + try { + Request request = new Request.Builder() + .url(config.getUserInfoUri()) + .get() + .addHeader("Authorization", "Bearer " + accessToken) + .addHeader("Accept", "application/json") + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (response.isSuccessful() && response.body() != null) { + String body = response.body().string(); + log.debug("UserInfo response: {}", body); + return JSON.parseObject(body); + } + } + } catch (IOException e) { + log.error("获取用户信息失败", e); + } + return null; + } + + /** + * 解析不同平台的用户信息为统一格式 + */ + private HotakeSocialUserInfoDto parseSocialUserInfo(String provider, JSONObject userInfo, + String accessToken, String refreshToken, Long expiresIn) { + HotakeSocialUserInfoDto dto = new HotakeSocialUserInfoDto(); + dto.setProvider(provider); + dto.setAccessToken(accessToken); + dto.setRefreshToken(refreshToken); + dto.setExpiresIn(expiresIn); + dto.setRawUserInfo(userInfo.toJSONString()); + + switch (provider.toLowerCase()) { + case "google": + dto.setProviderUserId(userInfo.getString("sub")); + dto.setEmail(userInfo.getString("email")); + dto.setName(userInfo.getString("name")); + dto.setAvatar(userInfo.getString("picture")); + break; + case "microsoft": + dto.setProviderUserId(userInfo.getString("id")); + // Microsoft返回的邮箱字段是mail或userPrincipalName + String email = userInfo.getString("mail"); + if (StrUtil.isBlank(email)) { + email = userInfo.getString("userPrincipalName"); + } + dto.setEmail(email); + dto.setName(userInfo.getString("displayName")); + // Microsoft头像需要单独请求,这里暂不处理 + break; + case "linkedin": + dto.setProviderUserId(userInfo.getString("sub")); + dto.setEmail(userInfo.getString("email")); + dto.setName(userInfo.getString("name")); + dto.setAvatar(userInfo.getString("picture")); + break; + default: + throw new ServiceException("不支持的登录平台: " + provider); + } + + return dto; + } + + /** + * 自动注册用户 + */ + private SysUser autoRegister(HotakeSocialUserInfoDto socialUserInfo, String sysUserType) { + // 检查邮箱是否已存在 + if (StrUtil.isNotBlank(socialUserInfo.getEmail())) { + SysUser existUser = sysUserService.selectUserByUserName(socialUserInfo.getEmail()); + if (existUser != null) { + // 邮箱已存在,直接绑定 + return existUser; + } + } + + // 创建新用户 + SysUser sysUser = new SysUser(); + sysUser.setUserName(socialUserInfo.getEmail()); + sysUser.setEmail(socialUserInfo.getEmail()); + sysUser.setNickName(socialUserInfo.getName()); + sysUser.setAvatar(socialUserInfo.getAvatar()); + sysUser.setSysUserType(StrUtil.isNotBlank(sysUserType) ? sysUserType : "candidate"); + sysUser.setUserFlag(UserFlagEnum.FLAG_1.getCode()); + sysUser.setUserOperStatus("1"); + sysUser.setPwdUpdateDate(DateUtils.getNowDate()); + // 社交登录用户不设置密码,或设置随机密码 + sysUser.setPassword(""); + + boolean success = sysUserService.registerUser(sysUser); + if (!success) { + throw new ServiceException("自动注册用户失败"); + } + + return sysUser; + } + + /** + * 创建社交账号绑定 + */ + private void createSocialUserBinding(Long userId, HotakeSocialUserInfoDto socialUserInfo) { + HotakeSocialUser socialUser = new HotakeSocialUser(); + socialUser.setUserId(userId); + socialUser.setProvider(socialUserInfo.getProvider()); + socialUser.setProviderUserId(socialUserInfo.getProviderUserId()); + socialUser.setEmail(socialUserInfo.getEmail()); + socialUser.setName(socialUserInfo.getName()); + socialUser.setAvatar(socialUserInfo.getAvatar()); + socialUser.setAccessToken(socialUserInfo.getAccessToken()); + socialUser.setRefreshToken(socialUserInfo.getRefreshToken()); + if (socialUserInfo.getExpiresIn() != null) { + socialUser.setTokenExpireTime(new Date(System.currentTimeMillis() + socialUserInfo.getExpiresIn() * 1000)); + } + socialUser.setRawUserInfo(socialUserInfo.getRawUserInfo()); + + socialUserMapper.insert(socialUser); + } + + /** + * 更新社交账号token信息 + */ + private void updateSocialUserToken(HotakeSocialUser socialUser, HotakeSocialUserInfoDto socialUserInfo) { + socialUser.setAccessToken(socialUserInfo.getAccessToken()); + socialUser.setRefreshToken(socialUserInfo.getRefreshToken()); + if (socialUserInfo.getExpiresIn() != null) { + socialUser.setTokenExpireTime(new Date(System.currentTimeMillis() + socialUserInfo.getExpiresIn() * 1000)); + } + socialUser.setRawUserInfo(socialUserInfo.getRawUserInfo()); + socialUserMapper.update(socialUser); + } + + /** + * 创建登录Token + */ + private HotakeSocialLoginResultDto createLoginToken(SysUser sysUser, String provider, boolean isNewUser) { + // 获取权限 + Set permissions = permissionService.getMenuPermission(sysUser); + + // 创建LoginUser + LoginUser loginUser = new LoginUser(sysUser.getUserId(), sysUser.getDeptId(), sysUser, permissions); + + // 生成token + String token = tokenService.createToken(loginUser); + + // 构建返回对象 + HotakeSocialLoginResultDto resultDto = new HotakeSocialLoginResultDto(); + resultDto.setToken(token); + resultDto.setUserId(sysUser.getUserId()); + resultDto.setSysUserType(sysUser.getSysUserType()); + resultDto.setIsNewUser(isNewUser); + resultDto.setProvider(provider); + resultDto.setUser(sysUser); + + return resultDto; + } + + /** + * 记录登录信息 + */ + private void recordLoginInfo(Long userId) { + SysUser sysUser = new SysUser(); + sysUser.setUserId(userId); + sysUser.setLoginIp(IpUtils.getIpAddr()); + sysUser.setLoginDate(DateUtils.getNowDate()); + sysUserService.updateUserProfile(sysUser); + } + + /** + * 记录社交登录日志 + */ + private void recordLoginLog(Long userId, String provider, String providerUserId, + String loginType, String status, String msg) { + try { + HotakeSocialLoginLog loginLog = new HotakeSocialLoginLog(); + loginLog.setUserId(userId); + loginLog.setProvider(provider); + loginLog.setProviderUserId(providerUserId); + loginLog.setLoginType(loginType); + loginLog.setLoginIp(IpUtils.getIpAddr()); + loginLog.setLoginLocation(AddressUtils.getRealAddressByIP(IpUtils.getIpAddr())); + + try { + UserAgent userAgent = UserAgent.parseUserAgentString( + com.vetti.common.utils.ServletUtils.getRequest().getHeader("User-Agent")); + loginLog.setBrowser(userAgent.getBrowser().getName()); + loginLog.setOs(userAgent.getOperatingSystem().getName()); + } catch (Exception ignored) { + } + + loginLog.setStatus(status); + loginLog.setMsg(msg); + loginLog.setLoginTime(new Date()); + + socialLoginLogMapper.insert(loginLog); + } catch (Exception e) { + log.error("记录社交登录日志失败", e); + } + } +} diff --git a/vetti-admin/src/main/resources/application-dev.yml b/vetti-admin/src/main/resources/application-dev.yml index 7fc1b85..7e64ea5 100644 --- a/vetti-admin/src/main/resources/application-dev.yml +++ b/vetti-admin/src/main/resources/application-dev.yml @@ -171,3 +171,44 @@ chatGpt: http: client: connect-timeout-seconds: 10 + +# OAuth2.0 社交登录配置 +oauth2: + # ================================ + # Google (Gmail) 登录配置 + # 申请地址: https://console.cloud.google.com/apis/credentials + # ================================ + google: + client-id: your-google-client-id # Google Cloud Console获取 + client-secret: your-google-client-secret # Google Cloud Console获取 + redirect-uri: https://your-domain.com/api/oauth2/callback/google + scope: openid email profile # Google标准scope(空格分隔) + auth-uri: https://accounts.google.com/o/oauth2/v2/auth # Google授权地址 + token-uri: https://oauth2.googleapis.com/token # Google令牌地址 + user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo # Google用户信息接口 + + # ================================ + # Microsoft (Outlook) 登录配置 + # 申请地址: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps + # ================================ + microsoft: + client-id: your-microsoft-client-id # Azure Portal获取 + client-secret: your-microsoft-client-secret # Azure Portal获取 + redirect-uri: https://your-domain.com/api/oauth2/callback/microsoft + scope: openid,email,profile,User.Read # 需要额外的User.Read权限才能读取用户信息 + auth-uri: https://login.microsoftonline.com/common/oauth2/v2.0/authorize # 微软授权地址 + token-uri: https://login.microsoftonline.com/common/oauth2/v2.0/token # 微软令牌地址 + user-info-uri: https://graph.microsoft.com/v1.0/me # 使用Microsoft Graph API + + # ================================ + # LinkedIn 登录配置 + # 申请地址: https://www.linkedin.com/developers/apps + # ================================ + linkedin: + client-id: 86uq3opzshd3bq # LinkedIn Developer Portal获取 + client-secret: 86uq3opzshd3bq # LinkedIn Developer Portal获取 + redirect-uri: http://localhost:8080/oauth2/callback/linkedin + scope: openid profile email # LinkedIn使用OpenID Connect,scope顺序和命名略有不同 + auth-uri: https://www.linkedin.com/oauth/v2/authorization # LinkedIn授权地址 + token-uri: https://www.linkedin.com/oauth/v2/accessToken # LinkedIn令牌地址 + user-info-uri: https://api.linkedin.com/v2/userinfo # LinkedIn用户信息接口 \ No newline at end of file diff --git a/vetti-admin/src/main/resources/application-druid.yml b/vetti-admin/src/main/resources/application-druid.yml index cd48dc4..ba7c81c 100644 --- a/vetti-admin/src/main/resources/application-druid.yml +++ b/vetti-admin/src/main/resources/application-druid.yml @@ -197,3 +197,44 @@ chatGpt: http: client: connect-timeout-seconds: 600 + +# OAuth2.0 社交登录配置 +oauth2: + # ================================ + # Google (Gmail) 登录配置 + # 申请地址: https://console.cloud.google.com/apis/credentials + # ================================ + google: + client-id: 398978985110-ve0usu381mmdio12ff01iqvv1g087qvi.apps.googleusercontent.com # Google Cloud Console获取 + client-secret: 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 # Google Cloud Console获取 + redirect-uri: https://vetti.hotake.cn/oauth2/callback/google + scope: openid email profile # Google标准scope(空格分隔) + auth-uri: https://accounts.google.com/o/oauth2/v2/auth # Google授权地址 + token-uri: https://oauth2.googleapis.com/token # Google令牌地址 + user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo # Google用户信息接口 + + # ================================ + # Microsoft (Outlook) 登录配置 + # 申请地址: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps + # ================================ + microsoft: + client-id: 608cbc4f-3ee2-4f51-a72c-b2e3133fc5b2 # Azure Portal获取 + client-secret: gvw8Q~dwG8Sv7HN3R3W3R7TQtcZyvrh88ZiJubPa # Azure Portal获取 + redirect-uri: https://vetti.hotake.cn/api/oauth2/callback/microsoft + scope: openid,email,profile,User.Read # 需要额外的User.Read权限才能读取用户信息 + auth-uri: https://login.microsoftonline.com/common/oauth2/v2.0/authorize # 微软授权地址 + token-uri: https://login.microsoftonline.com/common/oauth2/v2.0/token # 微软令牌地址 + user-info-uri: https://graph.microsoft.com/v1.0/me # 使用Microsoft Graph API + + # ================================ + # LinkedIn 登录配置 + # 申请地址: https://www.linkedin.com/developers/apps + # ================================ + linkedin: + client-id: 86uq3opzshd3bq # LinkedIn Developer Portal获取 + client-secret: WPL_AP1.mipgyxfgfBoN12Th.1TXeFg== # LinkedIn Developer Portal获取 + redirect-uri: https://vetti.hotake.cn/api/oauth2/callback/linkedin + scope: openid profile email # LinkedIn使用OpenID Connect,scope顺序和命名略有不同 + auth-uri: https://www.linkedin.com/oauth/v2/authorization # LinkedIn授权地址 + token-uri: https://www.linkedin.com/oauth/v2/accessToken # LinkedIn令牌地址 + user-info-uri: https://api.linkedin.com/v2/userinfo # LinkedIn用户信息接口 diff --git a/vetti-admin/target/classes/application-druid.yml b/vetti-admin/target/classes/application-druid.yml index feb4f08..ba7c81c 100644 --- a/vetti-admin/target/classes/application-druid.yml +++ b/vetti-admin/target/classes/application-druid.yml @@ -191,8 +191,50 @@ chatGpt: modelAiIntPf: gpt-4o-mini modelAiCvSr: gpt-4o-mini modelAiCac: gpt-4o-mini + modelAiCiv: gpt-4o-mini role: system http: client: connect-timeout-seconds: 600 + +# OAuth2.0 社交登录配置 +oauth2: + # ================================ + # Google (Gmail) 登录配置 + # 申请地址: https://console.cloud.google.com/apis/credentials + # ================================ + google: + client-id: 398978985110-ve0usu381mmdio12ff01iqvv1g087qvi.apps.googleusercontent.com # Google Cloud Console获取 + client-secret: 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 # Google Cloud Console获取 + redirect-uri: https://vetti.hotake.cn/oauth2/callback/google + scope: openid email profile # Google标准scope(空格分隔) + auth-uri: https://accounts.google.com/o/oauth2/v2/auth # Google授权地址 + token-uri: https://oauth2.googleapis.com/token # Google令牌地址 + user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo # Google用户信息接口 + + # ================================ + # Microsoft (Outlook) 登录配置 + # 申请地址: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps + # ================================ + microsoft: + client-id: 608cbc4f-3ee2-4f51-a72c-b2e3133fc5b2 # Azure Portal获取 + client-secret: gvw8Q~dwG8Sv7HN3R3W3R7TQtcZyvrh88ZiJubPa # Azure Portal获取 + redirect-uri: https://vetti.hotake.cn/api/oauth2/callback/microsoft + scope: openid,email,profile,User.Read # 需要额外的User.Read权限才能读取用户信息 + auth-uri: https://login.microsoftonline.com/common/oauth2/v2.0/authorize # 微软授权地址 + token-uri: https://login.microsoftonline.com/common/oauth2/v2.0/token # 微软令牌地址 + user-info-uri: https://graph.microsoft.com/v1.0/me # 使用Microsoft Graph API + + # ================================ + # LinkedIn 登录配置 + # 申请地址: https://www.linkedin.com/developers/apps + # ================================ + linkedin: + client-id: 86uq3opzshd3bq # LinkedIn Developer Portal获取 + client-secret: WPL_AP1.mipgyxfgfBoN12Th.1TXeFg== # LinkedIn Developer Portal获取 + redirect-uri: https://vetti.hotake.cn/api/oauth2/callback/linkedin + scope: openid profile email # LinkedIn使用OpenID Connect,scope顺序和命名略有不同 + auth-uri: https://www.linkedin.com/oauth/v2/authorization # LinkedIn授权地址 + token-uri: https://www.linkedin.com/oauth/v2/accessToken # LinkedIn令牌地址 + user-info-uri: https://api.linkedin.com/v2/userinfo # LinkedIn用户信息接口 diff --git a/vetti-admin/target/classes/i18n/messages_en_US.properties b/vetti-admin/target/classes/i18n/messages_en_US.properties index 7035452..f49a7cc 100644 --- a/vetti-admin/target/classes/i18n/messages_en_US.properties +++ b/vetti-admin/target/classes/i18n/messages_en_US.properties @@ -63,3 +63,6 @@ HotakeRolesInfoServiceImpl10001 = The job information is abnormal. Please try ag # manager.页面,字段 = User Manager VerificationEmailTiTle = Your verification code VerificationEmailContent = Your verification code is: {0}, valid for {1} minutes. + + +HotakeRolesApplyInfoServiceImpl10001 = You have already applied for this position \ No newline at end of file diff --git a/vetti-admin/target/classes/i18n/messages_zh_CN.properties b/vetti-admin/target/classes/i18n/messages_zh_CN.properties index 69451eb..90b799f 100644 --- a/vetti-admin/target/classes/i18n/messages_zh_CN.properties +++ b/vetti-admin/target/classes/i18n/messages_zh_CN.properties @@ -62,3 +62,4 @@ HotakeRolesInfoServiceImpl10001 = 岗位信息异常,请稍后再试 VerificationEmailTiTle = 你的验证码 VerificationEmailContent = 你的验证码是: {0},有效期为 {1} 分钟。 +HotakeRolesApplyInfoServiceImpl10001 = 您已申请该职位 \ No newline at end of file diff --git a/vetti-common/src/main/java/com/vetti/common/config/HotakeOAuth2Properties.java b/vetti-common/src/main/java/com/vetti/common/config/HotakeOAuth2Properties.java new file mode 100644 index 0000000..4aa4799 --- /dev/null +++ b/vetti-common/src/main/java/com/vetti/common/config/HotakeOAuth2Properties.java @@ -0,0 +1,139 @@ +package com.vetti.common.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * OAuth2.0 社交登录配置属性类 + * + * @author vetti + */ +@Component +@ConfigurationProperties(prefix = "oauth2") +public class HotakeOAuth2Properties { + + /** Google配置 */ + private OAuthClientConfig google; + + /** Microsoft配置 */ + private OAuthClientConfig microsoft; + + /** LinkedIn配置 */ + private OAuthClientConfig linkedin; + + public OAuthClientConfig getGoogle() { + return google; + } + + public void setGoogle(OAuthClientConfig google) { + this.google = google; + } + + public OAuthClientConfig getMicrosoft() { + return microsoft; + } + + public void setMicrosoft(OAuthClientConfig microsoft) { + this.microsoft = microsoft; + } + + public OAuthClientConfig getLinkedin() { + return linkedin; + } + + public void setLinkedin(OAuthClientConfig linkedin) { + this.linkedin = linkedin; + } + + /** + * 根据provider获取对应配置 + */ + public OAuthClientConfig getByProvider(String provider) { + switch (provider.toLowerCase()) { + case "google": + return google; + case "microsoft": + return microsoft; + case "linkedin": + return linkedin; + default: + return null; + } + } + + /** + * OAuth客户端配置 + */ + public static class OAuthClientConfig { + /** 客户端ID */ + private String clientId; + /** 客户端密钥 */ + private String clientSecret; + /** 回调地址 */ + private String redirectUri; + /** 授权范围 */ + private String scope; + /** 授权端点 */ + private String authUri; + /** 令牌端点 */ + private String tokenUri; + /** 用户信息端点 */ + private String userInfoUri; + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public void setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + } + + public String getRedirectUri() { + return redirectUri; + } + + public void setRedirectUri(String redirectUri) { + this.redirectUri = redirectUri; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public String getAuthUri() { + return authUri; + } + + public void setAuthUri(String authUri) { + this.authUri = authUri; + } + + public String getTokenUri() { + return tokenUri; + } + + public void setTokenUri(String tokenUri) { + this.tokenUri = tokenUri; + } + + public String getUserInfoUri() { + return userInfoUri; + } + + public void setUserInfoUri(String userInfoUri) { + this.userInfoUri = userInfoUri; + } + } +} diff --git a/vetti-hotakes/src/main/java/com/vetti/hotake/domain/HotakeSocialLoginLog.java b/vetti-hotakes/src/main/java/com/vetti/hotake/domain/HotakeSocialLoginLog.java new file mode 100644 index 0000000..0c639a0 --- /dev/null +++ b/vetti-hotakes/src/main/java/com/vetti/hotake/domain/HotakeSocialLoginLog.java @@ -0,0 +1,53 @@ +package com.vetti.hotake.domain; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 社交登录日志对象 hotake_social_login_log + * + * @author vetti + */ +@Data +public class HotakeSocialLoginLog implements Serializable { + private static final long serialVersionUID = 1L; + + @ApiModelProperty("主键ID") + private Long id; + + @ApiModelProperty("系统用户ID") + private Long userId; + + @ApiModelProperty("第三方平台类型") + private String provider; + + @ApiModelProperty("第三方平台用户ID") + private String providerUserId; + + @ApiModelProperty("登录类型:login/register/bind") + private String loginType; + + @ApiModelProperty("登录IP") + private String loginIp; + + @ApiModelProperty("登录地点") + private String loginLocation; + + @ApiModelProperty("浏览器类型") + private String browser; + + @ApiModelProperty("操作系统") + private String os; + + @ApiModelProperty("登录状态(0成功 1失败)") + private String status; + + @ApiModelProperty("提示消息") + private String msg; + + @ApiModelProperty("登录时间") + private Date loginTime; +} diff --git a/vetti-hotakes/src/main/java/com/vetti/hotake/domain/HotakeSocialUser.java b/vetti-hotakes/src/main/java/com/vetti/hotake/domain/HotakeSocialUser.java new file mode 100644 index 0000000..abaea60 --- /dev/null +++ b/vetti-hotakes/src/main/java/com/vetti/hotake/domain/HotakeSocialUser.java @@ -0,0 +1,52 @@ +package com.vetti.hotake.domain; + +import com.vetti.common.core.domain.BaseEntity; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Date; + +/** + * 用户社交账号绑定对象 hotake_social_user + * + * @author vetti + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class HotakeSocialUser extends BaseEntity { + private static final long serialVersionUID = 1L; + + @ApiModelProperty("主键ID") + private Long id; + + @ApiModelProperty("系统用户ID") + private Long userId; + + @ApiModelProperty("第三方平台类型:google/microsoft/linkedin") + private String provider; + + @ApiModelProperty("第三方平台用户唯一标识") + private String providerUserId; + + @ApiModelProperty("第三方平台邮箱") + private String email; + + @ApiModelProperty("第三方平台用户名") + private String name; + + @ApiModelProperty("第三方平台头像URL") + private String avatar; + + @ApiModelProperty("访问令牌") + private String accessToken; + + @ApiModelProperty("刷新令牌") + private String refreshToken; + + @ApiModelProperty("令牌过期时间") + private Date tokenExpireTime; + + @ApiModelProperty("第三方平台原始用户信息JSON") + private String rawUserInfo; +} diff --git a/vetti-hotakes/src/main/java/com/vetti/hotake/domain/dto/HotakeSocialLoginRequestDto.java b/vetti-hotakes/src/main/java/com/vetti/hotake/domain/dto/HotakeSocialLoginRequestDto.java new file mode 100644 index 0000000..59b6b09 --- /dev/null +++ b/vetti-hotakes/src/main/java/com/vetti/hotake/domain/dto/HotakeSocialLoginRequestDto.java @@ -0,0 +1,30 @@ +package com.vetti.hotake.domain.dto; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +/** + * 社交登录请求DTO + * + * @author vetti + */ +@Data +public class HotakeSocialLoginRequestDto { + + @NotBlank(message = "平台类型不能为空") + @ApiModelProperty(value = "第三方平台类型:google/microsoft/linkedin", required = true) + private String provider; + + @NotBlank(message = "授权码不能为空") + @ApiModelProperty(value = "OAuth授权码", required = true) + private String code; + + @NotBlank(message = "用户类型不能为空") + @ApiModelProperty(value = "用户类型(interviewer:面试官,candidate:候选者)", required = true) + private String sysUserType; + + @ApiModelProperty("state参数(用于防止CSRF攻击)") + private String state; +} diff --git a/vetti-hotakes/src/main/java/com/vetti/hotake/domain/dto/HotakeSocialLoginResultDto.java b/vetti-hotakes/src/main/java/com/vetti/hotake/domain/dto/HotakeSocialLoginResultDto.java new file mode 100644 index 0000000..70d1cf5 --- /dev/null +++ b/vetti-hotakes/src/main/java/com/vetti/hotake/domain/dto/HotakeSocialLoginResultDto.java @@ -0,0 +1,32 @@ +package com.vetti.hotake.domain.dto; + +import com.vetti.common.core.domain.entity.SysUser; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +/** + * 社交登录返回结果DTO + * + * @author vetti + */ +@Data +public class HotakeSocialLoginResultDto { + + @ApiModelProperty("令牌") + private String token; + + @ApiModelProperty("用户ID") + private Long userId; + + @ApiModelProperty("用户类型(interviewer:面试官,candidate:候选者)") + private String sysUserType; + + @ApiModelProperty("是否新注册用户(true:新注册,false:已存在用户登录)") + private Boolean isNewUser; + + @ApiModelProperty("第三方平台类型") + private String provider; + + @ApiModelProperty("用户信息对象") + private SysUser user; +} diff --git a/vetti-hotakes/src/main/java/com/vetti/hotake/domain/dto/HotakeSocialUserInfoDto.java b/vetti-hotakes/src/main/java/com/vetti/hotake/domain/dto/HotakeSocialUserInfoDto.java new file mode 100644 index 0000000..4c77043 --- /dev/null +++ b/vetti-hotakes/src/main/java/com/vetti/hotake/domain/dto/HotakeSocialUserInfoDto.java @@ -0,0 +1,40 @@ +package com.vetti.hotake.domain.dto; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +/** + * 第三方平台用户信息DTO(统一格式) + * + * @author vetti + */ +@Data +public class HotakeSocialUserInfoDto { + + @ApiModelProperty("第三方平台类型") + private String provider; + + @ApiModelProperty("第三方平台用户唯一标识") + private String providerUserId; + + @ApiModelProperty("邮箱") + private String email; + + @ApiModelProperty("用户名/昵称") + private String name; + + @ApiModelProperty("头像URL") + private String avatar; + + @ApiModelProperty("访问令牌") + private String accessToken; + + @ApiModelProperty("刷新令牌") + private String refreshToken; + + @ApiModelProperty("令牌有效期(秒)") + private Long expiresIn; + + @ApiModelProperty("原始用户信息JSON") + private String rawUserInfo; +} diff --git a/vetti-hotakes/src/main/java/com/vetti/hotake/mapper/HotakeSocialLoginLogMapper.java b/vetti-hotakes/src/main/java/com/vetti/hotake/mapper/HotakeSocialLoginLogMapper.java new file mode 100644 index 0000000..755fe3b --- /dev/null +++ b/vetti-hotakes/src/main/java/com/vetti/hotake/mapper/HotakeSocialLoginLogMapper.java @@ -0,0 +1,16 @@ +package com.vetti.hotake.mapper; + +import com.vetti.hotake.domain.HotakeSocialLoginLog; + +/** + * 社交登录日志Mapper接口 + * + * @author vetti + */ +public interface HotakeSocialLoginLogMapper { + + /** + * 新增社交登录日志 + */ + int insert(HotakeSocialLoginLog log); +} diff --git a/vetti-hotakes/src/main/java/com/vetti/hotake/mapper/HotakeSocialUserMapper.java b/vetti-hotakes/src/main/java/com/vetti/hotake/mapper/HotakeSocialUserMapper.java new file mode 100644 index 0000000..f676bd6 --- /dev/null +++ b/vetti-hotakes/src/main/java/com/vetti/hotake/mapper/HotakeSocialUserMapper.java @@ -0,0 +1,45 @@ +package com.vetti.hotake.mapper; + +import com.vetti.hotake.domain.HotakeSocialUser; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 用户社交账号绑定Mapper接口 + * + * @author vetti + */ +public interface HotakeSocialUserMapper { + + /** + * 根据provider和providerUserId查询社交用户 + */ + HotakeSocialUser selectByProviderAndProviderUserId(@Param("provider") String provider, + @Param("providerUserId") String providerUserId); + + /** + * 根据用户ID和provider查询社交绑定 + */ + HotakeSocialUser selectByUserIdAndProvider(@Param("userId") Long userId, @Param("provider") String provider); + + /** + * 根据用户ID查询所有社交绑定 + */ + List selectByUserId(@Param("userId") Long userId); + + /** + * 新增社交用户绑定 + */ + int insert(HotakeSocialUser socialUser); + + /** + * 更新社交用户绑定 + */ + int update(HotakeSocialUser socialUser); + + /** + * 删除社交用户绑定(逻辑删除) + */ + int deleteById(@Param("id") Long id); +} diff --git a/vetti-hotakes/src/main/java/com/vetti/hotake/service/IHotakeSocialUserService.java b/vetti-hotakes/src/main/java/com/vetti/hotake/service/IHotakeSocialUserService.java new file mode 100644 index 0000000..709b562 --- /dev/null +++ b/vetti-hotakes/src/main/java/com/vetti/hotake/service/IHotakeSocialUserService.java @@ -0,0 +1,49 @@ +package com.vetti.hotake.service; + +import com.vetti.common.core.domain.entity.SysUser; +import com.vetti.hotake.domain.HotakeSocialUser; +import com.vetti.hotake.domain.dto.HotakeSocialLoginRequestDto; +import com.vetti.hotake.domain.dto.HotakeSocialLoginResultDto; + +import java.util.List; + +/** + * 社交用户服务接口 + * + * @author vetti + */ +public interface IHotakeSocialUserService { + + /** + * 社交登录(登录或自动注册) + * + * @param requestDto 社交登录请求 + * @return 登录结果 + */ + HotakeSocialLoginResultDto socialLogin(HotakeSocialLoginRequestDto requestDto); + + /** + * 获取OAuth授权URL + * + * @param provider 平台类型 + * @param state state参数 + * @return 授权URL + */ + String getAuthorizationUrl(String provider, String state); + + /** + * 根据用户ID查询绑定的社交账号 + * + * @param userId 用户ID + * @return 社交账号列表 + */ + List listByUserId(Long userId); + + /** + * 解绑社交账号 + * + * @param userId 用户ID + * @param provider 平台类型 + */ + void unbind(Long userId, String provider); +} diff --git a/vetti-hotakes/src/main/java/com/vetti/hotake/service/impl/HotakeReferenceCheckServiceImpl.java b/vetti-hotakes/src/main/java/com/vetti/hotake/service/impl/HotakeReferenceCheckServiceImpl.java index 9aa8439..1acd129 100644 --- a/vetti-hotakes/src/main/java/com/vetti/hotake/service/impl/HotakeReferenceCheckServiceImpl.java +++ b/vetti-hotakes/src/main/java/com/vetti/hotake/service/impl/HotakeReferenceCheckServiceImpl.java @@ -129,8 +129,8 @@ public class HotakeReferenceCheckServiceImpl extends BaseServiceImpl implements if (CollectionUtil.isNotEmpty(cvInfoList)) { HotakeCvInfo cvInfo = cvInfoList.get(0); - if (StrUtil.isNotEmpty(cvInfo.getAnalyzedCvJson())) { - HotakeCvInfoDto cvInfoDto = JSONUtil.toBean(cvInfo.getAnalyzedCvJson(), HotakeCvInfoDto.class); + if (StrUtil.isNotEmpty(cvInfo.getCvTemplateJson())) { + HotakeCvInfoDto cvInfoDto = JSONUtil.toBean(cvInfo.getCvTemplateJson(), HotakeCvInfoDto.class); if (cvInfoDto != null && CollectionUtil.isNotEmpty(cvInfoDto.getExperience())) { experienceList = cvInfoDto.getExperience(); } diff --git a/vetti-hotakes/src/main/resources/mapper/hotake/HotakeSocialLoginLogMapper.xml b/vetti-hotakes/src/main/resources/mapper/hotake/HotakeSocialLoginLogMapper.xml new file mode 100644 index 0000000..39ffa6a --- /dev/null +++ b/vetti-hotakes/src/main/resources/mapper/hotake/HotakeSocialLoginLogMapper.xml @@ -0,0 +1,15 @@ + + + + + + insert into hotake_social_login_log ( + user_id, provider, provider_user_id, login_type, login_ip, login_location, + browser, os, status, msg, login_time + ) values ( + #{userId}, #{provider}, #{providerUserId}, #{loginType}, #{loginIp}, #{loginLocation}, + #{browser}, #{os}, #{status}, #{msg}, #{loginTime} + ) + + + diff --git a/vetti-hotakes/src/main/resources/mapper/hotake/HotakeSocialUserMapper.xml b/vetti-hotakes/src/main/resources/mapper/hotake/HotakeSocialUserMapper.xml new file mode 100644 index 0000000..4a85765 --- /dev/null +++ b/vetti-hotakes/src/main/resources/mapper/hotake/HotakeSocialUserMapper.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + id, user_id, provider, provider_user_id, email, name, avatar, access_token, refresh_token, + token_expire_time, raw_user_info, del_flag, create_by, create_time, update_by, update_time, remark + + + + + + + + + + insert into hotake_social_user ( + user_id, provider, provider_user_id, email, name, avatar, access_token, refresh_token, + token_expire_time, raw_user_info, del_flag, create_by, create_time, remark + ) values ( + #{userId}, #{provider}, #{providerUserId}, #{email}, #{name}, #{avatar}, #{accessToken}, #{refreshToken}, + #{tokenExpireTime}, #{rawUserInfo}, '0', #{createBy}, now(), #{remark} + ) + + + + update hotake_social_user + + user_id = #{userId}, + email = #{email}, + name = #{name}, + avatar = #{avatar}, + access_token = #{accessToken}, + refresh_token = #{refreshToken}, + token_expire_time = #{tokenExpireTime}, + raw_user_info = #{rawUserInfo}, + update_by = #{updateBy}, + update_time = now() + + where id = #{id} + + + + update hotake_social_user set del_flag = '2', update_time = now() where id = #{id} + + +