feat(auth): 实现飞书OAuth认证及相关配置

- 新增飞书OAuth认证控制器,支持获取授权URL、登录和登录状态检查
- 实现飞书认证服务接口及实现类,完成授权码换取用户信息及用户创建或更新
- 配置Sa-Token拦截器和全局过滤器,实现登录鉴权和跨域支持
- 添加系统用户Mapper及对应XML,支持用户和角色查询及分页筛选
- 新增飞书开放平台配置类,支持应用ID、密钥和回调地址配置
- 添加开发环境配置文件,配置数据库、MinIO、MyBatis Plus、飞书和日志等相关参数
- 新增Maven项目依赖,包含Spring Boot、Sa-Token、MyBatis Plus、MinIO客户端、文档解析和飞书SDK等依赖
This commit is contained in:
2026-03-27 16:36:55 +08:00
parent 0b5af874ca
commit b9aacf0f10
9 changed files with 401 additions and 0 deletions

View File

@@ -145,6 +145,13 @@
<version>1.39.0</version>
</dependency>
<!-- 飞书开放平台SDK -->
<dependency>
<groupId>com.larksuite.oapi</groupId>
<artifactId>larksuite-oapi</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View File

@@ -0,0 +1,34 @@
package cn.yinlihupo.common.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* 飞书开放平台配置类
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "feishu")
public class FeishuConfig {
/**
* 应用ID (App ID)
*/
private String appId;
/**
* 应用密钥 (App Secret)
*/
private String appSecret;
/**
* 授权回调地址
*/
private String redirectUri;
/**
* 飞书开放平台域名
*/
private String domain = "https://open.feishu.cn";
}

View File

@@ -30,6 +30,8 @@ public class SaTokenConfig implements WebMvcConfigurer {
.excludePathPatterns(
"/auth/login",
"/auth/register",
"/auth/feishu/authorize",
"/auth/feishu/login",
"/error",
"/swagger-ui/**",
"/v3/api-docs/**"

View File

@@ -0,0 +1,94 @@
package cn.yinlihupo.controller.auth;
import cn.dev33.satoken.stp.StpUtil;
import cn.yinlihupo.common.core.BaseResponse;
import cn.yinlihupo.common.util.ResultUtils;
import cn.yinlihupo.domain.entity.SysUser;
import cn.yinlihupo.service.system.FeishuAuthService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* 飞书OAuth认证控制器
*/
@Slf4j
@RestController
@RequestMapping("/auth/feishu")
@RequiredArgsConstructor
public class FeishuAuthController {
private final FeishuAuthService feishuAuthService;
/**
* 获取飞书OAuth授权URL
*
* @param state 可选的状态参数防CSRF攻击
* @return 授权URL
*/
@GetMapping("/authorize")
public BaseResponse<Map<String, String>> getAuthUrl(@RequestParam(required = false) String state) {
String authUrl = feishuAuthService.buildAuthUrl(state);
Map<String, String> result = new HashMap<>();
result.put("authUrl", authUrl);
return ResultUtils.success("获取授权URL成功",result);
}
/**
* 飞书OAuth登录接口前端回调后调用
* 前端从飞书回调中获取code然后调用此接口完成登录
*
* @param code 飞书授权码
* @return 登录结果包含token和用户信息
*/
@PostMapping("/login")
public BaseResponse<Map<String, Object>> login(@RequestParam String code) {
log.info("收到飞书登录请求, code: {}", code);
try {
// 使用code换取用户信息并完成登录
SysUser user = feishuAuthService.loginByCode(code);
// 使用Sa-Token登录
StpUtil.login(user.getId());
// 构建返回数据
Map<String, Object> result = new HashMap<>();
result.put("token", StpUtil.getTokenValue());
result.put("tokenName", StpUtil.getTokenName());
result.put("userId", user.getId());
result.put("username", user.getUsername());
result.put("realName", user.getRealName());
result.put("avatar", user.getAvatar());
result.put("phone", user.getPhone());
result.put("email", user.getEmail());
log.info("飞书登录成功, userId: {}, phone: {}", user.getId(), user.getPhone());
return ResultUtils.success("登录成功", result);
} catch (Exception e) {
log.error("飞书登录失败", e);
return ResultUtils.error("登录失败: " + e.getMessage());
}
}
/**
* 检查当前登录状态
*
* @return 登录状态
*/
@GetMapping("/check")
public BaseResponse<Map<String, Object>> checkLogin() {
boolean isLogin = StpUtil.isLogin();
Map<String, Object> result = new HashMap<>();
result.put("isLogin", isLogin);
if (isLogin) {
result.put("userId", StpUtil.getLoginId());
}
return ResultUtils.success(isLogin ? "已登录" : "未登录", result);
}
}

View File

@@ -23,6 +23,11 @@ public interface SysUserMapper extends BaseMapper<SysUser> {
*/
SysUser selectByUsername(@Param("username") String username);
/**
* 根据手机号查询用户
*/
SysUser selectByPhone(@Param("phone") String phone);
/**
* 分页查询用户列表(支持按部门、状态、关键字筛选)
*/

View File

@@ -0,0 +1,39 @@
package cn.yinlihupo.service.system;
import cn.yinlihupo.domain.entity.SysUser;
/**
* 飞书认证服务接口
*/
public interface FeishuAuthService {
/**
* 构建飞书OAuth授权URL
*
* @param state 状态参数防CSRF攻击
* @return 授权URL
*/
String buildAuthUrl(String state);
/**
* 通过授权码登录
* 使用code换取accessToken获取用户信息完成登录
*
* @param code 飞书授权码
* @return 登录后的用户信息
*/
SysUser loginByCode(String code);
/**
* 根据手机号获取或创建用户
* 如果手机号存在则更新用户信息,不存在则创建新用户
*
* @param phone 手机号
* @param realName 真实姓名
* @param avatar 头像URL
* @param email 邮箱
* @param openId 飞书OpenID
* @return 用户实体
*/
SysUser getOrCreateUserByPhone(String phone, String realName, String avatar, String email, String openId);
}

View File

@@ -0,0 +1,206 @@
package cn.yinlihupo.service.system.impl;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import cn.yinlihupo.common.config.FeishuConfig;
import cn.yinlihupo.domain.entity.SysUser;
import cn.yinlihupo.mapper.SysUserMapper;
import cn.yinlihupo.service.system.FeishuAuthService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
/**
* 飞书认证服务实现类
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FeishuAuthServiceImpl implements FeishuAuthService {
private final FeishuConfig feishuConfig;
private final SysUserMapper sysUserMapper;
/**
* 飞书OAuth授权端点
*/
private static final String FEISHU_OAUTH_URL = "https://open.feishu.cn/open-apis/authen/v1/authorize";
/**
* 获取访问令牌端点
*/
private static final String FEISHU_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v1/oidc/access_token";
/**
* 获取用户信息端点
*/
private static final String FEISHU_USER_INFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info";
/**
* 应用访问令牌端点用于获取应用级token
*/
private static final String FEISHU_APP_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal";
@Override
public String buildAuthUrl(String state) {
String encodedRedirectUri = URLEncoder.encode(feishuConfig.getRedirectUri(), StandardCharsets.UTF_8);
return FEISHU_OAUTH_URL +
"?app_id=" + feishuConfig.getAppId() +
"&redirect_uri=" + encodedRedirectUri +
"&state=" + (state != null ? state : "") +
"&scope=contact:user.base contact:user.phone:readonly";
}
@Override
@Transactional(rollbackFor = Exception.class)
public SysUser loginByCode(String code) {
log.info("处理飞书登录, code: {}", code);
// 1. 获取应用访问令牌
String appAccessToken = getAppAccessToken();
// 2. 使用授权码获取用户访问令牌
String userAccessToken = getUserAccessToken(code, appAccessToken);
// 3. 获取用户信息
JSONObject userInfo = getUserInfo(userAccessToken);
// 4. 提取用户信息
String phone = userInfo.getStr("mobile");
String realName = userInfo.getStr("name");
String avatar = userInfo.getStr("avatar_url");
String email = userInfo.getStr("email");
String openId = userInfo.getStr("open_id");
log.info("飞书用户信息: phone={}, name={}, openId={}", phone, realName, openId);
// 5. 根据手机号获取或创建用户
return getOrCreateUserByPhone(phone, realName, avatar, email, openId);
}
/**
* 获取应用访问令牌
*/
private String getAppAccessToken() {
JSONObject requestBody = new JSONObject();
requestBody.set("app_id", feishuConfig.getAppId());
requestBody.set("app_secret", feishuConfig.getAppSecret());
try (HttpResponse response = HttpRequest.post(FEISHU_APP_TOKEN_URL)
.header("Content-Type", "application/json")
.body(requestBody.toString())
.execute()) {
String body = response.body();
log.debug("获取应用访问令牌响应: {}", body);
JSONObject jsonObject = JSONUtil.parseObj(body);
if (jsonObject.getInt("code") != 0) {
throw new RuntimeException("获取应用访问令牌失败: " + jsonObject.getStr("msg"));
}
return jsonObject.getJSONObject("app_access_token").getStr("app_access_token");
}
}
/**
* 使用授权码获取用户访问令牌
*/
private String getUserAccessToken(String code, String appAccessToken) {
JSONObject requestBody = new JSONObject();
requestBody.set("grant_type", "authorization_code");
requestBody.set("code", code);
try (HttpResponse response = HttpRequest.post(FEISHU_TOKEN_URL)
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + appAccessToken)
.body(requestBody.toString())
.execute()) {
String body = response.body();
log.debug("获取用户访问令牌响应: {}", body);
JSONObject jsonObject = JSONUtil.parseObj(body);
if (jsonObject.getInt("code") != 0) {
throw new RuntimeException("获取用户访问令牌失败: " + jsonObject.getStr("msg"));
}
return jsonObject.getJSONObject("data").getStr("access_token");
}
}
/**
* 获取用户信息
*/
private JSONObject getUserInfo(String userAccessToken) {
try (HttpResponse response = HttpRequest.get(FEISHU_USER_INFO_URL)
.header("Authorization", "Bearer " + userAccessToken)
.execute()) {
String body = response.body();
log.debug("获取用户信息响应: {}", body);
JSONObject jsonObject = JSONUtil.parseObj(body);
if (jsonObject.getInt("code") != 0) {
throw new RuntimeException("获取用户信息失败: " + jsonObject.getStr("msg"));
}
return jsonObject.getJSONObject("data");
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public SysUser getOrCreateUserByPhone(String phone, String realName, String avatar, String email, String openId) {
// 根据手机号查询用户
LambdaQueryWrapper<SysUser> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SysUser::getPhone, phone);
SysUser user = sysUserMapper.selectOne(queryWrapper);
LocalDateTime now = LocalDateTime.now();
if (user != null) {
// 用户已存在,更新信息
log.info("用户已存在,更新用户信息, phone: {}", phone);
user.setRealName(realName);
user.setAvatar(avatar);
user.setEmail(email);
user.setNickname(realName);
user.setLastLoginTime(now);
user.setUpdateTime(now);
// 使用openId作为用户名如果没有设置过
if (user.getUsername() == null || user.getUsername().isEmpty()) {
user.setUsername(openId);
}
sysUserMapper.updateById(user);
} else {
// 用户不存在,创建新用户
log.info("用户不存在,创建新用户, phone: {}", phone);
user = new SysUser();
user.setUsername(openId);
user.setRealName(realName);
user.setNickname(realName);
user.setAvatar(avatar);
user.setPhone(phone);
user.setEmail(email);
user.setStatus(1); // 正常状态
user.setLastLoginTime(now);
user.setCreateTime(now);
user.setUpdateTime(now);
user.setDeleted(0);
// 飞书登录用户不需要密码,设置一个随机密码
user.setPassword("FEISHU_OAUTH_USER");
sysUserMapper.insert(user);
}
return user;
}
}

View File

@@ -45,6 +45,12 @@ mybatis-plus:
logic-delete-value: 1
logic-not-delete-value: 0
# 飞书开放平台配置
feishu:
app-id: cli_a94c8a7930badcd5
app-secret: e6d39745a94b42099f014e9d6f3a25d0
redirect-uri: http://localhost:8080/auth/feishu/callback
# 日志配置
logging:
level:

View File

@@ -45,6 +45,14 @@
AND deleted = 0
</select>
<!-- 根据手机号查询用户 -->
<select id="selectByPhone" resultMap="BaseResultMap">
SELECT *
FROM sys_user
WHERE phone = #{phone}
AND deleted = 0
</select>
<!-- 分页查询用户列表(支持按部门、状态、关键字筛选) -->
<select id="selectPageList" resultMap="BaseResultMap">
SELECT u.*