From d0c3fec4015f24a9d3cb999076afc62accad2190 Mon Sep 17 00:00:00 2001 From: imac Date: Sun, 7 Jun 2026 02:30:44 +0930 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20OAuth2=20Provider=20=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为学练平台提供 OAuth2 单点登录服务: 新增接口: - GET /oauth2/authorize: 获取授权码 - POST /oauth2/token: 用授权码换取访问令牌 - GET /oauth2/userinfo: 获取用户信息 新增组件: - OAuth2AppEntity/Dao/Service: 应用配置管理 - OAuth2CodeEntity/Dao/Service: 授权码管理(5分钟有效) - OAuth2TokenEntity/Dao/Service: 令牌管理(2小时有效) - OAuth2SignatureUtil: 签名验证(MD5+SHA1) - OAuth2UserInfoVO: 用户信息响应模型 数据库表: - acc_oauth2_app: 应用配置表 - acc_oauth2_code: 授权码表 - acc_oauth2_token: 令牌表 参考技术文档:docs/梦想盒子对接学练平台-技术文档.md --- sql/oauth2_tables.sql | 59 ++++ .../controller/OAuth2ProviderController.java | 261 ++++++++++++++++++ .../org/adream/account/dao/OAuth2AppDao.java | 32 +++ .../adream/account/dao/OAuth2App_mapper.xml | 38 +++ .../org/adream/account/dao/OAuth2CodeDao.java | 32 +++ .../adream/account/dao/OAuth2Code_mapper.xml | 35 +++ .../adream/account/dao/OAuth2TokenDao.java | 37 +++ .../adream/account/dao/OAuth2Token_mapper.xml | 37 +++ .../account/entity/OAuth2AppEntity.java | 100 +++++++ .../account/entity/OAuth2CodeEntity.java | 95 +++++++ .../account/entity/OAuth2TokenEntity.java | 75 +++++ .../account/model/OAuth2UserInfoVO.java | 104 +++++++ .../account/service/OAuth2AppService.java | 50 ++++ .../account/service/OAuth2CodeService.java | 111 ++++++++ .../service/OAuth2ProviderService.java | 185 +++++++++++++ .../account/service/OAuth2TokenService.java | 106 +++++++ .../account/util/OAuth2SignatureUtil.java | 154 +++++++++++ 17 files changed, 1511 insertions(+) create mode 100644 sql/oauth2_tables.sql create mode 100644 src/main/java/org/adream/account/controller/OAuth2ProviderController.java create mode 100644 src/main/java/org/adream/account/dao/OAuth2AppDao.java create mode 100644 src/main/java/org/adream/account/dao/OAuth2App_mapper.xml create mode 100644 src/main/java/org/adream/account/dao/OAuth2CodeDao.java create mode 100644 src/main/java/org/adream/account/dao/OAuth2Code_mapper.xml create mode 100644 src/main/java/org/adream/account/dao/OAuth2TokenDao.java create mode 100644 src/main/java/org/adream/account/dao/OAuth2Token_mapper.xml create mode 100644 src/main/java/org/adream/account/entity/OAuth2AppEntity.java create mode 100644 src/main/java/org/adream/account/entity/OAuth2CodeEntity.java create mode 100644 src/main/java/org/adream/account/entity/OAuth2TokenEntity.java create mode 100644 src/main/java/org/adream/account/model/OAuth2UserInfoVO.java create mode 100644 src/main/java/org/adream/account/service/OAuth2AppService.java create mode 100644 src/main/java/org/adream/account/service/OAuth2CodeService.java create mode 100644 src/main/java/org/adream/account/service/OAuth2ProviderService.java create mode 100644 src/main/java/org/adream/account/service/OAuth2TokenService.java create mode 100644 src/main/java/org/adream/account/util/OAuth2SignatureUtil.java diff --git a/sql/oauth2_tables.sql b/sql/oauth2_tables.sql new file mode 100644 index 0000000..fdabbda --- /dev/null +++ b/sql/oauth2_tables.sql @@ -0,0 +1,59 @@ +-- OAuth2 Provider 数据库表结构 +-- 用于梦想盒子对接学练平台 + +-- 1. OAuth2 应用表(存储接入方配置) +CREATE TABLE IF NOT EXISTS `acc_oauth2_app` ( + `id` INT(11) NOT NULL AUTO_INCREMENT COMMENT '主键', + `app_id` VARCHAR(64) NOT NULL COMMENT '应用标识', + `app_secret` VARCHAR(128) NOT NULL COMMENT '应用密钥', + `app_name` VARCHAR(128) DEFAULT NULL COMMENT '应用名称', + `redirect_uri` VARCHAR(512) DEFAULT NULL COMMENT '注册的回调地址', + `status` VARCHAR(32) DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE-启用,DISABLED-禁用', + `cts` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `mts` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + `dr` INT(1) DEFAULT 1 COMMENT '删除标记:1-正常,0-删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_app_id` (`app_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OAuth2应用配置表'; + +-- 2. OAuth2 授权码表(一次性授权码,5分钟有效) +CREATE TABLE IF NOT EXISTS `acc_oauth2_code` ( + `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键', + `code` VARCHAR(64) NOT NULL COMMENT '授权码', + `app_id` VARCHAR(64) NOT NULL COMMENT '应用ID', + `uid` VARCHAR(64) NOT NULL COMMENT '用户ID', + `redirect_uri` VARCHAR(512) DEFAULT NULL COMMENT '回调地址', + `source` VARCHAR(16) DEFAULT NULL COMMENT '来源:pc/h5', + `cts` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `expires_at` DATETIME NOT NULL COMMENT '过期时间', + `used` INT(1) DEFAULT 0 COMMENT '是否已使用:0-未使用,1-已使用', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_code` (`code`), + KEY `idx_app_id` (`app_id`), + KEY `idx_uid` (`uid`), + KEY `idx_expires_at` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OAuth2授权码表'; + +-- 3. OAuth2 令牌表(访问令牌,2小时有效) +CREATE TABLE IF NOT EXISTS `acc_oauth2_token` ( + `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键', + `access_token` VARCHAR(128) NOT NULL COMMENT '访问令牌', + `app_id` VARCHAR(64) NOT NULL COMMENT '应用ID', + `uid` VARCHAR(64) NOT NULL COMMENT '用户ID', + `cts` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `expires_at` DATETIME NOT NULL COMMENT '过期时间', + `dr` INT(1) DEFAULT 1 COMMENT '有效标记:1-有效,0-失效', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_access_token` (`access_token`), + KEY `idx_app_id` (`app_id`), + KEY `idx_uid` (`uid`), + KEY `idx_expires_at` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OAuth2访问令牌表'; + +-- 4. 插入学练平台测试环境应用配置(示例) +-- INSERT INTO `acc_oauth2_app` (`app_id`, `app_secret`, `app_name`, `redirect_uri`, `status`) +-- VALUES ('ailearning_test', 'your_app_secret_here', '企鹅智慧学练平台-测试', 'https://test.ailearning.qq.com/sso/callback', 'ACTIVE'); + +-- 5. 插入学练平台生产环境应用配置(示例) +-- INSERT INTO `acc_oauth2_app` (`app_id`, `app_secret`, `app_name`, `redirect_uri`, `status`) +-- VALUES ('ailearning_prod', 'your_app_secret_here', '企鹅智慧学练平台-生产', 'https://ailearning.qq.com/sso/callback', 'ACTIVE'); diff --git a/src/main/java/org/adream/account/controller/OAuth2ProviderController.java b/src/main/java/org/adream/account/controller/OAuth2ProviderController.java new file mode 100644 index 0000000..67e9d62 --- /dev/null +++ b/src/main/java/org/adream/account/controller/OAuth2ProviderController.java @@ -0,0 +1,261 @@ +package org.adream.account.controller; + +import com.alibaba.fastjson.JSON; +import org.adream.account.entity.OAuth2AppEntity; +import org.adream.account.entity.OAuth2CodeEntity; +import org.adream.account.entity.OAuth2TokenEntity; +import org.adream.account.model.OAuth2UserInfoVO; +import org.adream.account.model.ResultModel; +import org.adream.account.service.OAuth2AppService; +import org.adream.account.service.OAuth2CodeService; +import org.adream.account.service.OAuth2ProviderService; +import org.adream.account.util.UserUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * OAuth2 Provider Controller + * 为第三方应用(如学练平台)提供 OAuth2 授权服务 + * + * @author adream-ac + */ +@Controller +@RequestMapping(value = "/oauth2") +public class OAuth2ProviderController { + + private static final Logger logger = LoggerFactory.getLogger(OAuth2ProviderController.class); + + @Autowired + private OAuth2AppService oauth2AppService; + + @Autowired + private OAuth2CodeService oauth2CodeService; + + @Autowired + private OAuth2ProviderService oauth2ProviderService; + + /** + * 错误码定义 + */ + private static final String CODE_SUCCESS = "0"; + private static final String CODE_INVALID_APP = "40001"; + private static final String CODE_INVALID_CODE = "40002"; + private static final String CODE_INVALID_REDIRECT_URI = "40003"; + private static final String CODE_INVALID_TOKEN = "40004"; + private static final String CODE_NO_PERMISSION = "40005"; + private static final String CODE_SERVER_ERROR = "50000"; + + /** + * 1. 获取授权码(authorize) + * 用户从梦想盒子点击【企鹅智慧学练】后,前端请求此接口 + * adream-ac 校验用户登录态后,生成授权码并重定向到学练平台回调地址 + * + * @param appId 应用ID + * @param redirectUri 回调地址 + * @param source 来源(pc/h5) + * @param request HttpServletRequest + * @param response HttpServletResponse + */ + @RequestMapping(value = "/authorize", method = RequestMethod.GET) + public void authorize( + @RequestParam("app_id") String appId, + @RequestParam("redirect_uri") String redirectUri, + @RequestParam(value = "source", required = false, defaultValue = "pc") String source, + HttpServletRequest request, + HttpServletResponse response) { + + logger.info("收到授权请求: appId={}, redirectUri={}, source={}", appId, redirectUri, source); + + // 验证应用 + OAuth2AppEntity app = oauth2AppService.queryByAppId(appId); + if (app == null || !oauth2AppService.isAppActive(app)) { + logger.warn("应用无效: appId={}", appId); + redirectToError(response, redirectUri, CODE_INVALID_APP, "invalid app_id"); + return; + } + + // 验证 redirect_uri + if (!oauth2AppService.validateRedirectUri(app, redirectUri)) { + logger.warn("redirect_uri 未注册: redirectUri={}", redirectUri); + redirectToError(response, redirectUri, CODE_INVALID_REDIRECT_URI, "redirect_uri not registered"); + return; + } + + // 获取当前登录用户 + String uid = UserUtil.getUidByRequest(request); + if (StringUtils.isEmpty(uid)) { + logger.warn("用户未登录"); + // 可以跳转到登录页面,登录后再跳回 + try { + response.sendRedirect("/account/login?redirect=" + encodeUrl(request.getRequestURL() + "?" + request.getQueryString())); + } catch (IOException e) { + logger.error("重定向失败", e); + } + return; + } + + // 生成授权码 + OAuth2CodeEntity codeEntity = oauth2CodeService.generateCode(appId, uid, redirectUri, source); + + // 重定向到回调地址 + String callbackUrl = redirectUri + "?code=" + codeEntity.getCode(); + try { + logger.info("授权成功,重定向到: {}", callbackUrl); + response.sendRedirect(callbackUrl); + } catch (IOException e) { + logger.error("重定向失败", e); + } + } + + /** + * 2. 用授权码换取访问令牌(token) + * 学练平台收到回调后,用 code 调用此接口换取 access_token + * + * @param appId 应用ID + * @param code 授权码 + * @param timestamp 时间戳 + * @param signature 签名 + * @return JSON 响应 + */ + @RequestMapping(value = "/token", method = RequestMethod.POST, produces = "application/json;charset=UTF-8") + @ResponseBody + public Map token( + @RequestParam("app_id") String appId, + @RequestParam("code") String code, + @RequestParam("timestamp") String timestamp, + @RequestParam("signature") String signature) { + + logger.info("收到 token 请求: appId={}, code={}", appId, code); + + Map result = new HashMap<>(); + + try { + // 换取令牌 + OAuth2TokenEntity token = oauth2ProviderService.exchangeCodeForToken(code, appId, timestamp, signature); + + if (token == null) { + result.put("code", CODE_INVALID_CODE); + result.put("message", "invalid code or code expired"); + result.put("data", null); + return result; + } + + // 成功响应 + Map data = new HashMap<>(); + data.put("access_token", token.getAccessToken()); + data.put("expires_in", 7200); + data.put("user_id", token.getUid()); + + result.put("code", CODE_SUCCESS); + result.put("message", "success"); + result.put("data", data); + + logger.info("token 生成成功: uid={}", token.getUid()); + return result; + + } catch (Exception e) { + logger.error("token 接口异常", e); + result.put("code", CODE_SERVER_ERROR); + result.put("message", "server internal error"); + result.put("data", null); + return result; + } + } + + /** + * 3. 获取用户信息(userinfo) + * 学练平台使用 access_token 获取梦想盒子用户信息 + * + * @param accessToken 访问令牌 + * @param appId 应用ID + * @param timestamp 时间戳 + * @param signature 签名 + * @return JSON 响应 + */ + @RequestMapping(value = "/userinfo", method = RequestMethod.GET, produces = "application/json;charset=UTF-8") + @ResponseBody + public Map userinfo( + @RequestParam("access_token") String accessToken, + @RequestParam("app_id") String appId, + @RequestParam("timestamp") String timestamp, + @RequestParam("signature") String signature) { + + logger.info("收到 userinfo 请求: appId={}, accessToken={}", appId, accessToken); + + Map result = new HashMap<>(); + + try { + // 获取用户信息 + OAuth2UserInfoVO userInfo = oauth2ProviderService.getUserInfo(accessToken, appId, timestamp, signature); + + if (userInfo == null) { + result.put("code", CODE_INVALID_TOKEN); + result.put("message", "invalid access_token or access_token expired"); + result.put("data", null); + return result; + } + + // 成功响应 + Map data = new HashMap<>(); + data.put("user_id", userInfo.getUserId()); + data.put("phone_number", userInfo.getPhoneNumber()); + data.put("teacher_name", userInfo.getTeacherName()); + data.put("school_name", userInfo.getSchoolName()); + data.put("province_id", userInfo.getProvinceId()); + data.put("city_id", userInfo.getCityId()); + data.put("area_id", userInfo.getAreaId()); + data.put("create_time", userInfo.getCreateTime()); + data.put("update_time", userInfo.getUpdateTime()); + + result.put("code", CODE_SUCCESS); + result.put("message", "success"); + result.put("data", data); + + logger.info("userinfo 返回成功: userId={}", userInfo.getUserId()); + return result; + + } catch (Exception e) { + logger.error("userinfo 接口异常", e); + result.put("code", CODE_SERVER_ERROR); + result.put("message", "server internal error"); + result.put("data", null); + return result; + } + } + + /** + * 重定向到错误页面 + */ + private void redirectToError(HttpServletResponse response, String redirectUri, String code, String message) { + try { + String errorUrl = redirectUri + "?error=" + code + "&error_description=" + encodeUrl(message); + response.sendRedirect(errorUrl); + } catch (IOException e) { + logger.error("重定向失败", e); + } + } + + /** + * URL 编码 + */ + private String encodeUrl(String url) { + try { + return java.net.URLEncoder.encode(url, "UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + return url; + } + } +} \ No newline at end of file diff --git a/src/main/java/org/adream/account/dao/OAuth2AppDao.java b/src/main/java/org/adream/account/dao/OAuth2AppDao.java new file mode 100644 index 0000000..5dc9099 --- /dev/null +++ b/src/main/java/org/adream/account/dao/OAuth2AppDao.java @@ -0,0 +1,32 @@ +package org.adream.account.dao; + +import org.adream.account.entity.OAuth2AppEntity; +import org.apache.ibatis.annotations.Param; + +/** + * OAuth2 应用 DAO + * + * @author adream-ac + */ +public interface OAuth2AppDao { + + /** + * 根据 appId 查询应用 + */ + OAuth2AppEntity queryByAppId(@Param("appId") String appId); + + /** + * 新增应用 + */ + int addApp(OAuth2AppEntity app); + + /** + * 更新应用 + */ + int updateApp(OAuth2AppEntity app); + + /** + * 删除应用 + */ + int deleteApp(@Param("appId") String appId); +} \ No newline at end of file diff --git a/src/main/java/org/adream/account/dao/OAuth2App_mapper.xml b/src/main/java/org/adream/account/dao/OAuth2App_mapper.xml new file mode 100644 index 0000000..c76cda6 --- /dev/null +++ b/src/main/java/org/adream/account/dao/OAuth2App_mapper.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + INSERT INTO acc_oauth2_app (app_id, app_secret, app_name, redirect_uri, status, cts, mts, dr) + VALUES (#{appId}, #{appSecret}, #{appName}, #{redirectUri}, #{status}, NOW(), NOW(), 1) + + + + UPDATE acc_oauth2_app + SET app_secret = #{appSecret}, app_name = #{appName}, redirect_uri = #{redirectUri}, + status = #{status}, mts = NOW() + WHERE app_id = #{appId} + + + + UPDATE acc_oauth2_app SET dr = 0, mts = NOW() WHERE app_id = #{appId} + + + \ No newline at end of file diff --git a/src/main/java/org/adream/account/dao/OAuth2CodeDao.java b/src/main/java/org/adream/account/dao/OAuth2CodeDao.java new file mode 100644 index 0000000..d2584da --- /dev/null +++ b/src/main/java/org/adream/account/dao/OAuth2CodeDao.java @@ -0,0 +1,32 @@ +package org.adream.account.dao; + +import org.adream.account.entity.OAuth2CodeEntity; +import org.apache.ibatis.annotations.Param; + +/** + * OAuth2 授权码 DAO + * + * @author adream-ac + */ +public interface OAuth2CodeDao { + + /** + * 根据 code 查询授权码记录 + */ + OAuth2CodeEntity queryByCode(@Param("code") String code); + + /** + * 新增授权码 + */ + int addCode(OAuth2CodeEntity code); + + /** + * 标记授权码已使用 + */ + int markCodeUsed(@Param("code") String code); + + /** + * 删除过期的授权码 + */ + int deleteExpiredCodes(); +} \ No newline at end of file diff --git a/src/main/java/org/adream/account/dao/OAuth2Code_mapper.xml b/src/main/java/org/adream/account/dao/OAuth2Code_mapper.xml new file mode 100644 index 0000000..209c112 --- /dev/null +++ b/src/main/java/org/adream/account/dao/OAuth2Code_mapper.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + INSERT INTO acc_oauth2_code (code, app_id, uid, redirect_uri, source, cts, expires_at, used) + VALUES (#{code}, #{appId}, #{uid}, #{redirectUri}, #{source}, NOW(), #{expiresAt}, 0) + + + + UPDATE acc_oauth2_code SET used = 1 WHERE code = #{code} + + + + DELETE FROM acc_oauth2_code WHERE expires_at < NOW() OR used = 1 + + + \ No newline at end of file diff --git a/src/main/java/org/adream/account/dao/OAuth2TokenDao.java b/src/main/java/org/adream/account/dao/OAuth2TokenDao.java new file mode 100644 index 0000000..5c5cb34 --- /dev/null +++ b/src/main/java/org/adream/account/dao/OAuth2TokenDao.java @@ -0,0 +1,37 @@ +package org.adream.account.dao; + +import org.adream.account.entity.OAuth2TokenEntity; +import org.apache.ibatis.annotations.Param; + +/** + * OAuth2 令牌 DAO + * + * @author adream-ac + */ +public interface OAuth2TokenDao { + + /** + * 根据 accessToken 查询令牌 + */ + OAuth2TokenEntity queryByAccessToken(@Param("accessToken") String accessToken); + + /** + * 新增令牌 + */ + int addToken(OAuth2TokenEntity token); + + /** + * 删除令牌(使其失效) + */ + int deleteToken(@Param("accessToken") String accessToken); + + /** + * 根据 uid 和 appId 删除令牌 + */ + int deleteTokenByUidAndAppId(@Param("uid") String uid, @Param("appId") String appId); + + /** + * 删除过期的令牌 + */ + int deleteExpiredTokens(); +} \ No newline at end of file diff --git a/src/main/java/org/adream/account/dao/OAuth2Token_mapper.xml b/src/main/java/org/adream/account/dao/OAuth2Token_mapper.xml new file mode 100644 index 0000000..baff443 --- /dev/null +++ b/src/main/java/org/adream/account/dao/OAuth2Token_mapper.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + INSERT INTO acc_oauth2_token (access_token, app_id, uid, cts, expires_at, dr) + VALUES (#{accessToken}, #{appId}, #{uid}, NOW(), #{expiresAt}, 1) + + + + UPDATE acc_oauth2_token SET dr = 0 WHERE access_token = #{accessToken} + + + + UPDATE acc_oauth2_token SET dr = 0 WHERE uid = #{uid} AND app_id = #{appId} + + + + DELETE FROM acc_oauth2_token WHERE expires_at < NOW() OR dr = 0 + + + \ No newline at end of file diff --git a/src/main/java/org/adream/account/entity/OAuth2AppEntity.java b/src/main/java/org/adream/account/entity/OAuth2AppEntity.java new file mode 100644 index 0000000..a152ccf --- /dev/null +++ b/src/main/java/org/adream/account/entity/OAuth2AppEntity.java @@ -0,0 +1,100 @@ +package org.adream.account.entity; + +import java.io.Serializable; +import java.util.Date; + +/** + * OAuth2 接入应用配置实体 + * 用于存储接入方(如学练平台)的 app_id 和 app_secret + * + * @author adream-ac + */ +public class OAuth2AppEntity extends PubClass implements Serializable { + + private static final long serialVersionUID = 1L; + + private String appId; // 应用标识 + + private String appSecret; // 应用密钥(不传输,仅参与签名) + + private String appName; // 应用名称 + + private String redirectUri; // 注册的回调地址 + + private String status; // 状态:ACTIVE/DISABLED + + private Date cts; // 创建时间 + + private Date mts; // 修改时间 + + private Integer dr; // 删除标记:1-正常,0-删除 + + public String getAppId() { + return appId; + } + + public void setAppId(String appId) { + this.appId = appId; + } + + public String getAppSecret() { + return appSecret; + } + + public void setAppSecret(String appSecret) { + this.appSecret = appSecret; + } + + public String getAppName() { + return appName; + } + + public void setAppName(String appName) { + this.appName = appName; + } + + public String getRedirectUri() { + return redirectUri; + } + + public void setRedirectUri(String redirectUri) { + this.redirectUri = redirectUri; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public Date getCts() { + return cts; + } + + public void setCts(Date cts) { + this.cts = cts; + } + + public Date getMts() { + return mts; + } + + public void setMts(Date mts) { + this.mts = mts; + } + + public Integer getDr() { + return dr; + } + + public void setDr(Integer dr) { + this.dr = dr; + } + + @Override + public String getTable() { + return "acc_oauth2_app"; + } +} \ No newline at end of file diff --git a/src/main/java/org/adream/account/entity/OAuth2CodeEntity.java b/src/main/java/org/adream/account/entity/OAuth2CodeEntity.java new file mode 100644 index 0000000..d6b4548 --- /dev/null +++ b/src/main/java/org/adream/account/entity/OAuth2CodeEntity.java @@ -0,0 +1,95 @@ +package org.adream.account.entity; + +import java.io.Serializable; +import java.util.Date; + +/** + * OAuth2 授权码实体 + * 一次性授权码,有效期5分钟 + * + * @author adream-ac + */ +public class OAuth2CodeEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + private String code; // 授权码 + + private String appId; // 应用ID + + private String uid; // 用户ID + + private String redirectUri; // 回调地址 + + private String source; // 来源:pc/h5 + + private Date cts; // 创建时间 + + private Date expiresAt; // 过期时间 + + private Integer used; // 是否已使用:0-未使用,1-已使用 + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getAppId() { + return appId; + } + + public void setAppId(String appId) { + this.appId = appId; + } + + public String getUid() { + return uid; + } + + public void setUid(String uid) { + this.uid = uid; + } + + public String getRedirectUri() { + return redirectUri; + } + + public void setRedirectUri(String redirectUri) { + this.redirectUri = redirectUri; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public Date getCts() { + return cts; + } + + public void setCts(Date cts) { + this.cts = cts; + } + + public Date getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Date expiresAt) { + this.expiresAt = expiresAt; + } + + public Integer getUsed() { + return used; + } + + public void setUsed(Integer used) { + this.used = used; + } +} \ No newline at end of file diff --git a/src/main/java/org/adream/account/entity/OAuth2TokenEntity.java b/src/main/java/org/adream/account/entity/OAuth2TokenEntity.java new file mode 100644 index 0000000..dd55506 --- /dev/null +++ b/src/main/java/org/adream/account/entity/OAuth2TokenEntity.java @@ -0,0 +1,75 @@ +package org.adream.account.entity; + +import java.io.Serializable; +import java.util.Date; + +/** + * OAuth2 访问令牌实体 + * access_token 有效期2小时 + * + * @author adream-ac + */ +public class OAuth2TokenEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + private String accessToken; // 访问令牌 + + private String appId; // 应用ID + + private String uid; // 用户ID + + private Date cts; // 创建时间 + + private Date expiresAt; // 过期时间 + + private Integer dr; // 删除标记:1-有效,0-失效 + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getAppId() { + return appId; + } + + public void setAppId(String appId) { + this.appId = appId; + } + + public String getUid() { + return uid; + } + + public void setUid(String uid) { + this.uid = uid; + } + + public Date getCts() { + return cts; + } + + public void setCts(Date cts) { + this.cts = cts; + } + + public Date getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Date expiresAt) { + this.expiresAt = expiresAt; + } + + public Integer getDr() { + return dr; + } + + public void setDr(Integer dr) { + this.dr = dr; + } +} \ No newline at end of file diff --git a/src/main/java/org/adream/account/model/OAuth2UserInfoVO.java b/src/main/java/org/adream/account/model/OAuth2UserInfoVO.java new file mode 100644 index 0000000..e98b229 --- /dev/null +++ b/src/main/java/org/adream/account/model/OAuth2UserInfoVO.java @@ -0,0 +1,104 @@ +package org.adream.account.model; + +import java.io.Serializable; +import java.util.Date; + +/** + * OAuth2 用户信息响应模型 + * + * @author adream-ac + */ +public class OAuth2UserInfoVO implements Serializable { + + private static final long serialVersionUID = 1L; + + private String userId; // 用户ID(梦想盒子用户ID) + + private String phoneNumber; // 手机号 + + private String teacherName; // 教师真实姓名 + + private String schoolName; // 学校全称 + + private String provinceId; // 省ID(学练平台省市区ID) + + private String cityId; // 市ID + + private String areaId; // 区ID + + private Date createTime; // 注册时间 + + private Date updateTime; // 最后更新时间 + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getPhoneNumber() { + return phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public String getTeacherName() { + return teacherName; + } + + public void setTeacherName(String teacherName) { + this.teacherName = teacherName; + } + + public String getSchoolName() { + return schoolName; + } + + public void setSchoolName(String schoolName) { + this.schoolName = schoolName; + } + + public String getProvinceId() { + return provinceId; + } + + public void setProvinceId(String provinceId) { + this.provinceId = provinceId; + } + + public String getCityId() { + return cityId; + } + + public void setCityId(String cityId) { + this.cityId = cityId; + } + + public String getAreaId() { + return areaId; + } + + public void setAreaId(String areaId) { + this.areaId = areaId; + } + + public Date getCreateTime() { + return createTime; + } + + public void setCreateTime(Date createTime) { + this.createTime = createTime; + } + + public Date getUpdateTime() { + return updateTime; + } + + public void setUpdateTime(Date updateTime) { + this.updateTime = updateTime; + } +} \ No newline at end of file diff --git a/src/main/java/org/adream/account/service/OAuth2AppService.java b/src/main/java/org/adream/account/service/OAuth2AppService.java new file mode 100644 index 0000000..220924e --- /dev/null +++ b/src/main/java/org/adream/account/service/OAuth2AppService.java @@ -0,0 +1,50 @@ +package org.adream.account.service; + +import org.adream.account.dao.OAuth2AppDao; +import org.adream.account.entity.OAuth2AppEntity; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * OAuth2 应用 Service + * + * @author adream-ac + */ +@Service +public class OAuth2AppService { + + private static final Logger logger = LoggerFactory.getLogger(OAuth2AppService.class); + + @Autowired + private OAuth2AppDao oauth2AppDao; + + /** + * 根据 appId 查询应用 + */ + public OAuth2AppEntity queryByAppId(String appId) { + if (appId == null || appId.isEmpty()) { + return null; + } + return oauth2AppDao.queryByAppId(appId); + } + + /** + * 验证应用状态 + */ + public boolean isAppActive(OAuth2AppEntity app) { + return app != null && "ACTIVE".equals(app.getStatus()) && app.getDr() == 1; + } + + /** + * 验证 redirect_uri 是否匹配 + */ + public boolean validateRedirectUri(OAuth2AppEntity app, String redirectUri) { + if (app == null || redirectUri == null) { + return false; + } + // 完全匹配 + return redirectUri.equals(app.getRedirectUri()); + } +} \ No newline at end of file diff --git a/src/main/java/org/adream/account/service/OAuth2CodeService.java b/src/main/java/org/adream/account/service/OAuth2CodeService.java new file mode 100644 index 0000000..d3f8a6b --- /dev/null +++ b/src/main/java/org/adream/account/service/OAuth2CodeService.java @@ -0,0 +1,111 @@ +package org.adream.account.service; + +import org.adream.account.dao.OAuth2CodeDao; +import org.adream.account.entity.OAuth2CodeEntity; +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.util.Calendar; +import java.util.Date; +import java.util.UUID; + +/** + * OAuth2 授权码 Service + * + * @author adream-ac + */ +@Service +public class OAuth2CodeService { + + private static final Logger logger = LoggerFactory.getLogger(OAuth2CodeService.class); + + /** + * 授权码有效期:5分钟 + */ + private static final int CODE_EXPIRE_MINUTES = 5; + + @Autowired + private OAuth2CodeDao oauth2CodeDao; + + /** + * 生成授权码 + * + * @param appId 应用ID + * @param uid 用户ID + * @param redirectUri 回调地址 + * @param source 来源(pc/h5) + * @return 授权码实体 + */ + @Transactional + public OAuth2CodeEntity generateCode(String appId, String uid, String redirectUri, String source) { + OAuth2CodeEntity entity = new OAuth2CodeEntity(); + + // 生成唯一授权码(UUID去掉横线) + String code = UUID.randomUUID().toString().replace("-", ""); + + entity.setCode(code); + entity.setAppId(appId); + entity.setUid(uid); + entity.setRedirectUri(redirectUri); + entity.setSource(source); + entity.setCts(new Date()); + + // 设置过期时间 + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.MINUTE, CODE_EXPIRE_MINUTES); + entity.setExpiresAt(calendar.getTime()); + entity.setUsed(0); + + oauth2CodeDao.addCode(entity); + logger.info("生成授权码: appId={}, uid={}, code={}", appId, uid, code); + + return entity; + } + + /** + * 验证并消费授权码 + * + * @param code 授权码 + * @param appId 应用ID + * @param redirectUri 回调地址 + * @return 用户ID,验证失败返回null + */ + @Transactional + public String consumeCode(String code, String appId, String redirectUri) { + OAuth2CodeEntity entity = oauth2CodeDao.queryByCode(code); + + if (entity == null) { + logger.warn("授权码不存在或已过期: code={}", code); + return null; + } + + if (!entity.getAppId().equals(appId)) { + logger.warn("授权码 appId 不匹配: expected={}, actual={}", entity.getAppId(), appId); + return null; + } + + // redirect_uri 可选验证(根据业务需求) + // if (redirectUri != null && !redirectUri.equals(entity.getRedirectUri())) { + // logger.warn("授权码 redirect_uri 不匹配: expected={}, actual={}", entity.getRedirectUri(), redirectUri); + // return null; + // } + + // 标记已使用 + oauth2CodeDao.markCodeUsed(code); + logger.info("授权码已消费: code={}, uid={}", code, entity.getUid()); + + return entity.getUid(); + } + + /** + * 清理过期授权码 + */ + @Transactional + public void cleanExpiredCodes() { + int count = oauth2CodeDao.deleteExpiredCodes(); + logger.info("清理过期授权码: {} 条", count); + } +} \ No newline at end of file diff --git a/src/main/java/org/adream/account/service/OAuth2ProviderService.java b/src/main/java/org/adream/account/service/OAuth2ProviderService.java new file mode 100644 index 0000000..f873f59 --- /dev/null +++ b/src/main/java/org/adream/account/service/OAuth2ProviderService.java @@ -0,0 +1,185 @@ +package org.adream.account.service; + +import org.adream.account.dao.SchoolDao; +import org.adream.account.dao.UserDao; +import org.adream.account.entity.OAuth2AppEntity; +import org.adream.account.entity.OAuth2TokenEntity; +import org.adream.account.entity.SchoolEntity; +import org.adream.account.entity.UserEntity; +import org.adream.account.model.OAuth2UserInfoVO; +import org.adream.account.util.OAuth2SignatureUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.HashMap; +import java.util.Map; + +/** + * OAuth2 Provider Service + * 提供给学练平台的 OAuth2 接口服务 + * + * @author adream-ac + */ +@Service +public class OAuth2ProviderService { + + private static final Logger logger = LoggerFactory.getLogger(OAuth2ProviderService.class); + + @Autowired + private OAuth2AppService oauth2AppService; + + @Autowired + private OAuth2CodeService oauth2CodeService; + + @Autowired + private OAuth2TokenService oauth2TokenService; + + @Autowired + private UserDao userDao; + + @Autowired + private SchoolDao schoolDao; + + /** + * 验证签名 + * + * @param params 请求参数 + * @param appId 应用ID + * @param timestamp 时间戳 + * @param signature 签名 + * @return 验证结果 + */ + public boolean verifySignature(Map params, String appId, String timestamp, String signature) { + // 查询应用 + OAuth2AppEntity app = oauth2AppService.queryByAppId(appId); + if (app == null) { + logger.warn("应用不存在: appId={}", appId); + return false; + } + + if (!oauth2AppService.isAppActive(app)) { + logger.warn("应用未激活: appId={}", appId); + return false; + } + + // 验证时间戳 + if (!OAuth2SignatureUtil.isTimestampValid(timestamp)) { + logger.warn("时间戳无效: timestamp={}", timestamp); + return false; + } + + // 验证签名 + return OAuth2SignatureUtil.verifySignature(params, appId, app.getAppSecret(), timestamp, signature); + } + + /** + * 用授权码换取访问令牌 + * + * @param code 授权码 + * @param appId 应用ID + * @param appSecret 应用密钥(用于验证) + * @param timestamp 时间戳 + * @param signature 签名 + * @return 令牌实体,失败返回 null + */ + public OAuth2TokenEntity exchangeCodeForToken(String code, String appId, String timestamp, String signature) { + // 构建参数 map + Map params = new HashMap<>(); + params.put("app_id", appId); + params.put("code", code); + params.put("timestamp", timestamp); + + // 验证签名 + if (!verifySignature(params, appId, timestamp, signature)) { + logger.warn("签名验证失败: appId={}, code={}", appId, code); + return null; + } + + // 消费授权码 + String uid = oauth2CodeService.consumeCode(code, appId, null); + if (uid == null) { + logger.warn("授权码无效或已过期: code={}", code); + return null; + } + + // 生成令牌 + return oauth2TokenService.generateToken(appId, uid); + } + + /** + * 获取用户信息 + * + * @param accessToken 访问令牌 + * @param appId 应用ID + * @param timestamp 时间戳 + * @param signature 签名 + * @return 用户信息,失败返回 null + */ + public OAuth2UserInfoVO getUserInfo(String accessToken, String appId, String timestamp, String signature) { + // 构建参数 map + Map params = new HashMap<>(); + params.put("access_token", accessToken); + params.put("app_id", appId); + params.put("timestamp", timestamp); + + // 验证签名 + if (!verifySignature(params, appId, timestamp, signature)) { + logger.warn("签名验证失败: appId={}, accessToken={}", appId, accessToken); + return null; + } + + // 验证令牌 + String uid = oauth2TokenService.verifyToken(accessToken); + if (uid == null) { + logger.warn("访问令牌无效或已过期: accessToken={}", accessToken); + return null; + } + + // 查询用户信息 + UserEntity user = userDao.queryUserByUid(uid); + if (user == null) { + logger.warn("用户不存在: uid={}", uid); + return null; + } + + // 构建 VO + OAuth2UserInfoVO vo = new OAuth2UserInfoVO(); + vo.setUserId(user.getUid()); + vo.setPhoneNumber(user.getPhone()); + vo.setTeacherName(user.getRealName()); + vo.setCreateTime(user.getCts()); + vo.setUpdateTime(user.getMts()); + + // 查询学校信息 + SchoolEntity school = schoolDao.queryMySchoolByUid(uid); + if (school != null) { + vo.setSchoolName(school.getSname()); + // 省市区ID需要转换为学练平台的ID(这里暂时使用梦想盒子的ID,后续需要做映射) + if (school.getProvince() != null) { + vo.setProvinceId(String.valueOf(school.getProvince())); + } + if (school.getCity() != null) { + vo.setCityId(String.valueOf(school.getCity())); + } + if (school.getArea() != null) { + vo.setAreaId(String.valueOf(school.getArea())); + } + } else { + // 用户没有绑定学校,从用户基本信息获取省市区 + if (user.getProvince() != null) { + vo.setProvinceId(String.valueOf(user.getProvince())); + } + if (user.getCity() != null) { + vo.setCityId(String.valueOf(user.getCity())); + } + if (user.getArea() != null) { + vo.setAreaId(String.valueOf(user.getArea())); + } + } + + return vo; + } +} \ No newline at end of file diff --git a/src/main/java/org/adream/account/service/OAuth2TokenService.java b/src/main/java/org/adream/account/service/OAuth2TokenService.java new file mode 100644 index 0000000..7c8faec --- /dev/null +++ b/src/main/java/org/adream/account/service/OAuth2TokenService.java @@ -0,0 +1,106 @@ +package org.adream.account.service; + +import org.adream.account.dao.OAuth2TokenDao; +import org.adream.account.entity.OAuth2TokenEntity; +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.util.Calendar; +import java.util.Date; +import java.util.UUID; + +/** + * OAuth2 令牌 Service + * + * @author adream-ac + */ +@Service +public class OAuth2TokenService { + + private static final Logger logger = LoggerFactory.getLogger(OAuth2TokenService.class); + + /** + * Token 有效期:2小时(秒) + */ + private static final int TOKEN_EXPIRE_SECONDS = 7200; + + @Autowired + private OAuth2TokenDao oauth2TokenDao; + + /** + * 生成访问令牌 + * + * @param appId 应用ID + * @param uid 用户ID + * @return 令牌实体 + */ + @Transactional + public OAuth2TokenEntity generateToken(String appId, String uid) { + // 先使该用户在该应用下的旧令牌失效 + oauth2TokenDao.deleteTokenByUidAndAppId(uid, appId); + + OAuth2TokenEntity entity = new OAuth2TokenEntity(); + + // 生成唯一 access_token + String accessToken = UUID.randomUUID().toString().replace("-", ""); + + entity.setAccessToken(accessToken); + entity.setAppId(appId); + entity.setUid(uid); + entity.setCts(new Date()); + + // 设置过期时间 + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.SECOND, TOKEN_EXPIRE_SECONDS); + entity.setExpiresAt(calendar.getTime()); + entity.setDr(1); + + oauth2TokenDao.addToken(entity); + logger.info("生成访问令牌: appId={}, uid={}, token={}", appId, uid, accessToken); + + return entity; + } + + /** + * 验证访问令牌 + * + * @param accessToken 访问令牌 + * @return 用户ID,验证失败返回null + */ + public String verifyToken(String accessToken) { + if (accessToken == null || accessToken.isEmpty()) { + return null; + } + + OAuth2TokenEntity entity = oauth2TokenDao.queryByAccessToken(accessToken); + if (entity == null) { + logger.warn("访问令牌无效或已过期: token={}", accessToken); + return null; + } + + return entity.getUid(); + } + + /** + * 使令牌失效 + * + * @param accessToken 访问令牌 + */ + @Transactional + public void revokeToken(String accessToken) { + oauth2TokenDao.deleteToken(accessToken); + logger.info("令牌已失效: token={}", accessToken); + } + + /** + * 清理过期令牌 + */ + @Transactional + public void cleanExpiredTokens() { + int count = oauth2TokenDao.deleteExpiredTokens(); + logger.info("清理过期令牌: {} 条", count); + } +} \ No newline at end of file diff --git a/src/main/java/org/adream/account/util/OAuth2SignatureUtil.java b/src/main/java/org/adream/account/util/OAuth2SignatureUtil.java new file mode 100644 index 0000000..dc40060 --- /dev/null +++ b/src/main/java/org/adream/account/util/OAuth2SignatureUtil.java @@ -0,0 +1,154 @@ +package org.adream.account.util; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.DigestUtils; +import org.springframework.util.StringUtils; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.*; + +/** + * OAuth2 签名工具类 + * 签名算法:参数按 key 升序拼接 → MD5 → AppID + AppSecret + Timestamp + MD5 → SHA1 + * + * @author adream-ac + */ +public class OAuth2SignatureUtil { + + private static final Logger logger = LoggerFactory.getLogger(OAuth2SignatureUtil.class); + + /** + * 生成签名 + * + * @param params 请求参数(不包含 signature) + * @param appId 应用ID + * @param appSecret 应用密钥 + * @param timestamp 时间戳 + * @return 签名字符串 + */ + public static String generateSignature(Map params, String appId, String appSecret, String timestamp) { + if (params == null || StringUtils.isEmpty(appId) || StringUtils.isEmpty(appSecret) || StringUtils.isEmpty(timestamp)) { + return null; + } + + // 1. 按 key 字典序升序排列 + List keys = new ArrayList<>(params.keySet()); + Collections.sort(keys); + + // 2. 拼接成 key=value&key=value 格式 + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < keys.size(); i++) { + String key = keys.get(i); + String value = params.get(key); + if (!StringUtils.isEmpty(value)) { + if (i > 0) { + sb.append("&"); + } + try { + sb.append(key).append("=").append(URLEncoder.encode(value, "UTF-8")); + } catch (UnsupportedEncodingException e) { + logger.error("URL编码失败", e); + sb.append(key).append("=").append(value); + } + } + } + + // 3. 对拼接结果取 MD5 + String md5Digest = DigestUtils.md5DigestAsHex(getBytes(sb.toString())); + + // 4. 将 AppID + AppSecret + Timestamp + md5Digest 拼接后取 SHA1 + String sha1Source = appId + appSecret + timestamp + md5Digest; + return sha1(sha1Source); + } + + /** + * 验证签名 + * + * @param params 请求参数(不包含 signature) + * @param appId 应用ID + * @param appSecret 应用密钥 + * @param timestamp 时间戳 + * @param signature 待验证的签名 + * @return 验证结果 + */ + public static boolean verifySignature(Map params, String appId, String appSecret, + String timestamp, String signature) { + if (StringUtils.isEmpty(signature)) { + logger.warn("签名为空"); + return false; + } + + String calculatedSignature = generateSignature(params, appId, appSecret, timestamp); + if (calculatedSignature == null) { + logger.warn("签名计算失败"); + return false; + } + + boolean result = calculatedSignature.equalsIgnoreCase(signature); + if (!result) { + logger.warn("签名验证失败, 期望: {}, 实际: {}", calculatedSignature, signature); + } + return result; + } + + /** + * 验证时间戳是否在有效期内(允许前后5分钟) + * + * @param timestamp 时间戳(秒) + * @return 是否有效 + */ + public static boolean isTimestampValid(String timestamp) { + if (StringUtils.isEmpty(timestamp)) { + return false; + } + try { + long ts = Long.parseLong(timestamp); + long now = System.currentTimeMillis() / 1000; + // 允许前后5分钟的误差 + return Math.abs(now - ts) <= 300; + } catch (NumberFormatException e) { + logger.warn("时间戳格式错误: {}", timestamp); + return false; + } + } + + /** + * SHA1 加密 + */ + private static String sha1(String input) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + byte[] digest = md.digest(getBytes(input)); + return bytesToHex(digest); + } catch (NoSuchAlgorithmException e) { + logger.error("SHA-1算法不可用", e); + return null; + } + } + + /** + * 字节数组转十六进制字符串 + */ + private static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + /** + * 字符串转字节数组(UTF-8) + */ + private static byte[] getBytes(String str) { + try { + return str.getBytes("UTF-8"); + } catch (UnsupportedEncodingException e) { + return str.getBytes(); + } + } +} \ No newline at end of file -- Gitee From a1972603c60dc655e911bfa63a95308b07edf36d Mon Sep 17 00:00:00 2001 From: imac Date: Sun, 7 Jun 2026 02:41:50 +0930 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20userinfo=20?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E8=AE=B0=E5=BD=95=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 acc_oauth2_userinfo_log 表,记录每次 userinfo 接口调用 - 记录内容包括:uid、appId、当时返回的用户信息快照 - 用于 Kafka 数据同步时判断哪些用户信息发生了变更 新增组件: - OAuth2UserinfoLogEntity: 调用记录实体 - OAuth2UserinfoLogDao/Mapper: 数据访问层 - OAuth2UserinfoLogService: 调用记录服务 修改: - OAuth2ProviderService: getUserInfo 方法增加调用记录逻辑 --- sql/oauth2_tables.sql | 20 +++ .../account/dao/OAuth2UserinfoLogDao.java | 27 ++++ .../account/dao/OAuth2UserinfoLog_mapper.xml | 40 ++++++ .../entity/OAuth2UserinfoLogEntity.java | 125 ++++++++++++++++++ .../service/OAuth2ProviderService.java | 24 +++- .../service/OAuth2UserinfoLogService.java | 77 +++++++++++ 6 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/adream/account/dao/OAuth2UserinfoLogDao.java create mode 100644 src/main/java/org/adream/account/dao/OAuth2UserinfoLog_mapper.xml create mode 100644 src/main/java/org/adream/account/entity/OAuth2UserinfoLogEntity.java create mode 100644 src/main/java/org/adream/account/service/OAuth2UserinfoLogService.java diff --git a/sql/oauth2_tables.sql b/sql/oauth2_tables.sql index fdabbda..409d660 100644 --- a/sql/oauth2_tables.sql +++ b/sql/oauth2_tables.sql @@ -57,3 +57,23 @@ CREATE TABLE IF NOT EXISTS `acc_oauth2_token` ( -- 5. 插入学练平台生产环境应用配置(示例) -- INSERT INTO `acc_oauth2_app` (`app_id`, `app_secret`, `app_name`, `redirect_uri`, `status`) -- VALUES ('ailearning_prod', 'your_app_secret_here', '企鹅智慧学练平台-生产', 'https://ailearning.qq.com/sso/callback', 'ACTIVE'); + +-- 6. OAuth2 用户信息调用记录表(用于 Kafka 数据同步判断) +CREATE TABLE IF NOT EXISTS `acc_oauth2_userinfo_log` ( + `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键', + `app_id` VARCHAR(64) NOT NULL COMMENT '应用ID', + `uid` VARCHAR(64) NOT NULL COMMENT '用户ID', + `access_token` VARCHAR(128) DEFAULT NULL COMMENT '访问令牌(脱敏)', + `phone_number` VARCHAR(32) DEFAULT NULL COMMENT '调用时返回的手机号', + `teacher_name` VARCHAR(64) DEFAULT NULL COMMENT '调用时返回的教师姓名', + `school_name` VARCHAR(256) DEFAULT NULL COMMENT '调用时返回的学校名称', + `province_id` VARCHAR(32) DEFAULT NULL COMMENT '调用时返回的省ID', + `city_id` VARCHAR(32) DEFAULT NULL COMMENT '调用时返回的市ID', + `area_id` VARCHAR(32) DEFAULT NULL COMMENT '调用时返回的区ID', + `cts` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '调用时间', + PRIMARY KEY (`id`), + KEY `idx_app_id` (`app_id`), + KEY `idx_uid` (`uid`), + KEY `idx_uid_appid` (`uid`, `app_id`), + KEY `idx_cts` (`cts`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OAuth2用户信息调用记录表'; diff --git a/src/main/java/org/adream/account/dao/OAuth2UserinfoLogDao.java b/src/main/java/org/adream/account/dao/OAuth2UserinfoLogDao.java new file mode 100644 index 0000000..8212a10 --- /dev/null +++ b/src/main/java/org/adream/account/dao/OAuth2UserinfoLogDao.java @@ -0,0 +1,27 @@ +package org.adream.account.dao; + +import org.adream.account.entity.OAuth2UserinfoLogEntity; +import org.apache.ibatis.annotations.Param; + +/** + * OAuth2 用户信息调用记录 DAO + * + * @author adream-ac + */ +public interface OAuth2UserinfoLogDao { + + /** + * 新增调用记录 + */ + int addLog(OAuth2UserinfoLogEntity log); + + /** + * 根据 uid 和 appId 查询最近一次调用记录 + */ + OAuth2UserinfoLogEntity queryLatestByUidAndAppId(@Param("uid") String uid, @Param("appId") String appId); + + /** + * 删除指定天数之前的记录 + */ + int deleteOldLogs(@Param("days") int days); +} \ No newline at end of file diff --git a/src/main/java/org/adream/account/dao/OAuth2UserinfoLog_mapper.xml b/src/main/java/org/adream/account/dao/OAuth2UserinfoLog_mapper.xml new file mode 100644 index 0000000..a1d4731 --- /dev/null +++ b/src/main/java/org/adream/account/dao/OAuth2UserinfoLog_mapper.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + INSERT INTO acc_oauth2_userinfo_log + (app_id, uid, access_token, phone_number, teacher_name, school_name, province_id, city_id, area_id, cts) + VALUES (#{appId}, #{uid}, #{accessToken}, #{phoneNumber}, #{teacherName}, #{schoolName}, + #{provinceId}, #{cityId}, #{areaId}, NOW()) + + + + + + DELETE FROM acc_oauth2_userinfo_log + WHERE cts < DATE_SUB(NOW(), INTERVAL #{days} DAY) + + + \ No newline at end of file diff --git a/src/main/java/org/adream/account/entity/OAuth2UserinfoLogEntity.java b/src/main/java/org/adream/account/entity/OAuth2UserinfoLogEntity.java new file mode 100644 index 0000000..70c921f --- /dev/null +++ b/src/main/java/org/adream/account/entity/OAuth2UserinfoLogEntity.java @@ -0,0 +1,125 @@ +package org.adream.account.entity; + +import java.io.Serializable; +import java.util.Date; + +/** + * OAuth2 用户信息调用记录实体 + * 记录每次 userinfo 接口的调用,用于 Kafka 数据同步判断 + * + * @author adream-ac + */ +public class OAuth2UserinfoLogEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long id; // 主键 + + private String appId; // 应用ID + + private String uid; // 用户ID + + private String accessToken; // 访问令牌(脱敏) + + private String phoneNumber; // 当时返回的手机号 + + private String teacherName; // 当时返回的教师姓名 + + private String schoolName; // 当时返回的学校名称 + + private String provinceId; // 当时返回的省ID + + private String cityId; // 当时返回的市ID + + private String areaId; // 当时返回的区ID + + private Date cts; // 调用时间 + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getAppId() { + return appId; + } + + public void setAppId(String appId) { + this.appId = appId; + } + + public String getUid() { + return uid; + } + + public void setUid(String uid) { + this.uid = uid; + } + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getPhoneNumber() { + return phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public String getTeacherName() { + return teacherName; + } + + public void setTeacherName(String teacherName) { + this.teacherName = teacherName; + } + + public String getSchoolName() { + return schoolName; + } + + public void setSchoolName(String schoolName) { + this.schoolName = schoolName; + } + + public String getProvinceId() { + return provinceId; + } + + public void setProvinceId(String provinceId) { + this.provinceId = provinceId; + } + + public String getCityId() { + return cityId; + } + + public void setCityId(String cityId) { + this.cityId = cityId; + } + + public String getAreaId() { + return areaId; + } + + public void setAreaId(String areaId) { + this.areaId = areaId; + } + + public Date getCts() { + return cts; + } + + public void setCts(Date cts) { + this.cts = cts; + } +} \ No newline at end of file diff --git a/src/main/java/org/adream/account/service/OAuth2ProviderService.java b/src/main/java/org/adream/account/service/OAuth2ProviderService.java index f873f59..82808eb 100644 --- a/src/main/java/org/adream/account/service/OAuth2ProviderService.java +++ b/src/main/java/org/adream/account/service/OAuth2ProviderService.java @@ -43,6 +43,9 @@ public class OAuth2ProviderService { @Autowired private SchoolDao schoolDao; + @Autowired + private OAuth2UserinfoLogService userinfoLogService; + /** * 验证签名 * @@ -110,7 +113,7 @@ public class OAuth2ProviderService { } /** - * 获取用户信息 + * 获取用户信息(含调用记录) * * @param accessToken 访问令牌 * @param appId 应用ID @@ -119,6 +122,20 @@ public class OAuth2ProviderService { * @return 用户信息,失败返回 null */ public OAuth2UserInfoVO getUserInfo(String accessToken, String appId, String timestamp, String signature) { + return getUserInfo(accessToken, appId, timestamp, signature, true); + } + + /** + * 获取用户信息 + * + * @param accessToken 访问令牌 + * @param appId 应用ID + * @param timestamp 时间戳 + * @param signature 签名 + * @param recordLog 是否记录调用日志 + * @return 用户信息,失败返回 null + */ + public OAuth2UserInfoVO getUserInfo(String accessToken, String appId, String timestamp, String signature, boolean recordLog) { // 构建参数 map Map params = new HashMap<>(); params.put("access_token", accessToken); @@ -180,6 +197,11 @@ public class OAuth2ProviderService { } } + // 记录调用日志 + if (recordLog) { + userinfoLogService.logUserinfoCall(appId, uid, accessToken, vo); + } + return vo; } } \ No newline at end of file diff --git a/src/main/java/org/adream/account/service/OAuth2UserinfoLogService.java b/src/main/java/org/adream/account/service/OAuth2UserinfoLogService.java new file mode 100644 index 0000000..e942ee5 --- /dev/null +++ b/src/main/java/org/adream/account/service/OAuth2UserinfoLogService.java @@ -0,0 +1,77 @@ +package org.adream.account.service; + +import org.adream.account.dao.OAuth2UserinfoLogDao; +import org.adream.account.entity.OAuth2UserinfoLogEntity; +import org.adream.account.model.OAuth2UserInfoVO; +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; + +/** + * OAuth2 用户信息调用记录 Service + * + * @author adream-ac + */ +@Service +public class OAuth2UserinfoLogService { + + private static final Logger logger = LoggerFactory.getLogger(OAuth2UserinfoLogService.class); + + @Autowired + private OAuth2UserinfoLogDao userinfoLogDao; + + /** + * 记录 userinfo 调用 + * + * @param appId 应用ID + * @param uid 用户ID + * @param accessToken 访问令牌(脱敏存储) + * @param userInfo 返回的用户信息 + */ + @Transactional + public void logUserinfoCall(String appId, String uid, String accessToken, OAuth2UserInfoVO userInfo) { + OAuth2UserinfoLogEntity log = new OAuth2UserinfoLogEntity(); + log.setAppId(appId); + log.setUid(uid); + // 脱敏:只存储 token 前8位 + if (accessToken != null && accessToken.length() > 8) { + log.setAccessToken(accessToken.substring(0, 8) + "..."); + } else { + log.setAccessToken(accessToken); + } + + if (userInfo != null) { + log.setPhoneNumber(userInfo.getPhoneNumber()); + log.setTeacherName(userInfo.getTeacherName()); + log.setSchoolName(userInfo.getSchoolName()); + log.setProvinceId(userInfo.getProvinceId()); + log.setCityId(userInfo.getCityId()); + log.setAreaId(userInfo.getAreaId()); + } + + userinfoLogDao.addLog(log); + logger.info("记录 userinfo 调用: appId={}, uid={}", appId, uid); + } + + /** + * 查询用户最近一次调用记录 + * + * @param uid 用户ID + * @param appId 应用ID + * @return 最近调用记录,无则返回 null + */ + public OAuth2UserinfoLogEntity getLatestLog(String uid, String appId) { + return userinfoLogDao.queryLatestByUidAndAppId(uid, appId); + } + + /** + * 清理旧记录(保留最近30天) + */ + @Transactional + public void cleanOldLogs() { + int count = userinfoLogDao.deleteOldLogs(30); + logger.info("清理旧调用记录: {} 条", count); + } +} \ No newline at end of file -- Gitee