Эх сурвалжийг харах

feat(auth): 实现标准 Authorization header 认证支持

- 添加对 Authorization: Bearer <token> 标准认证方式的支持
- 保留对 X-Token 和 token header 的向后兼容性
- 在 AuthInterceptor 中增加详细的日志记录
- 优化 token 解析逻辑,支持多种传参方式
- 更新相关实体类字段命名风格(驼峰命名)
- 修改 WeChatController 使用拦截器传递的 userId
- 完善 token 验证失败时的响应处理逻辑
- 统一 token 相关字段的 getter/setter 方法命名
mcbaiyun 2 сар өмнө
parent
commit
2b2d973738

+ 60 - 0
docs/DEVELOPMENT_GUIDELINES.md

@@ -0,0 +1,60 @@
+开发规范草案
+
+本文件根据项目现有代码样本提取,目标是将隐含约定显式化,便于团队统一风格与快速上手。
+
+1. 项目结构与包命名
+- 采用标准 Spring Boot 项目结构,顶级包为 `work.baiyun.chronicdiseaseapp`。
+- 分层清晰:`config`, `controller`, `service`, `mapper`, `model` (含 `po`, `vo`, `bo`), `common`, `exception`, `handler`, `util` 等。
+- 包命名使用小写,按功能划分。
+
+2. 类与命名约定
+- 类名采用 PascalCase(大驼峰)。
+- 接口与实现类分别放在 `service` 与 `service.impl` 或使用明确后缀 `*Service`/`*ServiceImpl`。
+- Mapper 使用 `*Mapper` 命名,位于 `mapper` 包。
+
+3. POJO 与数据库字段映射
+- 建议 POJO 字段使用驼峰命名(camelCase)。
+- 通过 `@TableField` 等注解映射数据库 snake_case 列名(项目 README 中已有建议)。
+
+4. 控制器与返回结构
+- 全局统一返回类型为 `work.baiyun.chronicdiseaseapp.common.R<T>`。
+- `R` 提供 `success` / `fail` 静态方法,包含 `code`、`message`、`data` 字段。
+- Controller 层尽量返回 `R<T>`,业务异常与验证异常由全局异常处理器处理。
+
+5. 分页
+- 分页封装使用 `work.baiyun.chronicdiseaseapp.common.Page<T>`,构造器接受 `pageNum`, `pageSize`, `list`,并使用 `PageInfo` 获取 total。
+
+6. 异常处理
+- 使用自定义 `CustomException` 抛出业务异常。
+- 全局异常处理器 `CustomExceptionHandler`(使用 `@RestControllerAdvice`)统一处理:
+  - 参数校验异常:`BindException`、`ConstraintViolationException` → 返回验证失败信息(合并消息)
+  - 自定义异常:`CustomException` → 返回自定义消息
+  - 其他异常:打印堆栈并返回通用错误码/信息(参考 `ExceptionResultCode`)
+
+7. 依赖与自动配置
+- 项目使用 Spring Boot,并排除了 Thymeleaf 的自动配置(见 `SpringAPP`)。
+- 使用 Knife4j(API 文档)注解 `@EnableKnife4j`。
+
+8. 注解与校验
+- 参数校验使用 Jakarta Validation(见 `ConstraintViolationException` 的处理),并在 Controller/DTO 上使用注解进行字段校验。
+
+9. 日志与调试
+- 在全局未捕获异常时打印堆栈(当前实现为 `exception.printStackTrace()`)。建议使用日志框架(如 SLF4J + Logback)替换直接打印。
+
+10. Lombok 使用
+- 项目使用 Lombok(见 `@Data`, `@Getter`, `@Setter`),请在 IDE 中启用 Lombok 插件并配置编译器注解处理。
+
+11. 风格建议(可选增强)
+- 引入 Checkstyle / SpotBugs / PMD 做静态检查,统一代码风格。
+- 使用 Git 钩子(pre-commit)自动格式化(例如使用 google-java-format 或者 Eclipse formatter)。
+- 将 `R` 的 code/message 常量化到枚举(项目已有 `ExceptionResultCode`、`SuccessResultCode`、`FailResultCode`)。
+- 用统一 Logger 替换 `printStackTrace()`,并在生产环境禁用 DEBUG/TRACE 日志。
+
+附录:样本来源文件
+- `src/main/java/work/baiyun/chronicdiseaseapp/SpringAPP.java`
+- `src/main/java/work/baiyun/chronicdiseaseapp/common/Page.java`
+- `src/main/java/work/baiyun/chronicdiseaseapp/common/R.java`
+- `src/main/java/work/baiyun/chronicdiseaseapp/exception/CustomException.java`
+- `src/main/java/work/baiyun/chronicdiseaseapp/exception/CustomExceptionHandler.java`
+
+说明:这是初稿,可依据团队反馈补充 Controller 注解约定、事务管理、Service 层返回类型、单元测试约定(JUnit 版本、Mock 框架)等内容。

+ 45 - 2
src/main/java/work/baiyun/chronicdiseaseapp/config/AuthInterceptor.java

@@ -2,6 +2,8 @@ package work.baiyun.chronicdiseaseapp.config;
 
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.http.HttpStatus;
 import org.springframework.stereotype.Component;
@@ -10,32 +12,73 @@ import work.baiyun.chronicdiseaseapp.service.TokenService;
 
 
 @Component
+/**
+ * AuthInterceptor - 负责从请求中提取 token 并校验。
+ *
+ * 优先采用标准 Authorization header:
+ *   Authorization: Bearer <token>
+ * 作为首选认证方式(推荐)。
+ *
+ * 向后兼容:如果没有 Authorization,则支持 X-Token header 或 token header 或 body.token。
+ */
 public class AuthInterceptor implements HandlerInterceptor {
 
     private final TokenService tokenService;
 
+    private static final Logger logger = LoggerFactory.getLogger(AuthInterceptor.class);
+
     @Autowired
     public AuthInterceptor(TokenService tokenService) {
         this.tokenService = tokenService;
     }
 
     @Override
-    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
-        String token = request.getHeader("X-Token");
+    public boolean preHandle(@org.springframework.lang.NonNull HttpServletRequest request,
+                             @org.springframework.lang.NonNull HttpServletResponse response,
+                             @org.springframework.lang.NonNull Object handler) throws Exception {
+        // Allow CORS preflight requests to pass through without authentication
+        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
+            logger.debug("preHandle: OPTIONS request, allow preflight.");
+            return true;
+        }
+
+        // 优先解析标准 Authorization: Bearer <token>
+        String authHeader = request.getHeader("Authorization");
+        String xToken = request.getHeader("X-Token");
+        String tokenHeader = request.getHeader("token");
+        logger.debug("preHandle: method={}, Authorization present={}, X-Token present={}, token header present={}", request.getMethod(), authHeader != null, xToken != null, tokenHeader != null);
+
+        String token = null;
+        if (authHeader != null && !authHeader.isEmpty()) {
+            // 支持 Bearer 方案,忽略大小写
+            if (authHeader.toLowerCase().startsWith("bearer ")) {
+                token = authHeader.substring(7).trim();
+            } else {
+                // 如果 Authorization 以其他方式传入,直接使用整个值
+                token = authHeader.trim();
+            }
+        }
+        // 如果 Authorization 未提供 token,则回退到 X-Token 或 token header
+        if (token == null || token.isEmpty()) {
+            token = xToken != null && !xToken.isEmpty() ? xToken : (tokenHeader != null && !tokenHeader.isEmpty() ? tokenHeader : null);
+        }
         if (token == null || token.isEmpty()) {
             // no token
+            logger.info("preHandle: missing token - rejecting request");
             response.setStatus(HttpStatus.UNAUTHORIZED.value());
             return false;
         }
 
         Long userId = tokenService.validateToken(token);
         if (userId == null) {
+            logger.info("preHandle: validateToken returned null for token starting with {}... - rejecting request", token.length() > 8 ? token.substring(0,8) : token);
             response.setStatus(HttpStatus.UNAUTHORIZED.value());
             return false;
         }
 
         // 将 userId 放入 request attribute,后续 controller 可用
         request.setAttribute("currentUserId", userId);
+        logger.debug("preHandle: token validated, userId={}", userId);
         return true;
     }
 }

+ 36 - 16
src/main/java/work/baiyun/chronicdiseaseapp/controller/WeChatController.java

@@ -64,13 +64,13 @@ public class WeChatController {
 
         // 查找是否存在 role + wx_openid 的用户
         QueryWrapper<UserInfo> qw = new QueryWrapper<>();
-        qw.eq("role", pg.getCode()).eq("wx_openid", openid);
+    qw.eq("role", pg.getCode()).eq("wx_openid", openid);
         UserInfo ui = userInfoMapper.selectOne(qw);
         if (ui == null) {
             // create new user info with provided role and openid
             ui = new UserInfo();
             ui.setRole(pg);
-            ui.setWx_openid(openid);
+            ui.setWxOpenid(openid);
             userInfoMapper.insert(ui);
         }
 
@@ -83,25 +83,45 @@ public class WeChatController {
     }
 
     /**
-     * 根据 token 返回当前用户信息(id, wx_openid, role, avatar, nickname, sex, phone, age, address)
-     * token 可从 Header("token") 或 POST JSON body 的 { "token": "..." } 中获取。
+     * 根据 token 返回当前用户信息(id, wx_openid, role, avatar, nickname, sex, phone, age, address)。
+     *
+     * 推荐(首选):通过标准 Authorization header 传递 token:
+     *   Authorization: Bearer &lt;token&gt;
+     *
+     * 向后兼容:如果没有 Authorization header,仍然支持 X-Token 或 token header,或 POST body 中的 { "token": "..." }。
      */
     @PostMapping(path = "/user_info", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
     public R<?> getUserInfo(@RequestBody(required = false) Map<String, String> body, HttpServletRequest request) {
-        String token = null;
-        if (request.getHeader("token") != null && !request.getHeader("token").isEmpty()) {
-            token = request.getHeader("token");
-        } else if (body != null && body.get("token") != null && !body.get("token").isEmpty()) {
-            token = body.get("token");
+        // 优先使用拦截器放入的 currentUserId(AuthInterceptor 已验证 X-Token)
+        Object attr = request.getAttribute("currentUserId");
+        Long userId = null;
+        if (attr instanceof Long) {
+            userId = (Long) attr;
+        } else if (attr instanceof Integer) {
+            // 有时框架可能将数字解析为 Integer
+            userId = ((Integer) attr).longValue();
         }
 
-        if (token == null || token.isEmpty()) {
-            return R.fail(401, "Missing token");
-        }
-
-        Long userId = tokenService.validateToken(token);
+        // 如果拦截器没有提供 userId,则回退到兼容旧接口的 token (header: token 或 body.token)
         if (userId == null) {
-            return R.fail(401, "Invalid or expired token");
+            String token = null;
+            // 兼容拦截器使用的 X-Token header,如果外部直接调用也允许使用 token header 或 body
+            if (request.getHeader("X-Token") != null && !request.getHeader("X-Token").isEmpty()) {
+                token = request.getHeader("X-Token");
+            } else if (request.getHeader("token") != null && !request.getHeader("token").isEmpty()) {
+                token = request.getHeader("token");
+            } else if (body != null && body.get("token") != null && !body.get("token").isEmpty()) {
+                token = body.get("token");
+            }
+
+            if (token == null || token.isEmpty()) {
+                return R.fail(401, "Missing token");
+            }
+
+            userId = tokenService.validateToken(token);
+            if (userId == null) {
+                return R.fail(401, "Invalid or expired token");
+            }
         }
 
         UserInfo ui = userInfoMapper.selectById(userId);
@@ -111,7 +131,7 @@ public class WeChatController {
 
         Map<String, Object> out = new HashMap<>();
         out.put("id", ui.getId());
-        out.put("wx_openid", ui.getWx_openid());
+    out.put("wx_openid", ui.getWxOpenid());
         out.put("role", ui.getRole() != null ? ui.getRole().getCode() : null);
         out.put("avatar", ui.getAvatar());
         out.put("nickname", ui.getNickname());

+ 2 - 2
src/main/java/work/baiyun/chronicdiseaseapp/model/bo/UserLoginBo.java

@@ -4,8 +4,8 @@ import io.swagger.v3.oas.annotations.media.Schema;
 
 public class UserLoginBo {
     @Schema(description = "")
-    private String wx_openid;
+    private String wxOpenid;
     /** 密码;密码 */
     @Schema(description = "密码")
-    private String wx_session_key;
+    private String wxSessionKey;
 }

+ 2 - 1
src/main/java/work/baiyun/chronicdiseaseapp/model/po/UserInfo.java

@@ -24,7 +24,8 @@ public class UserInfo extends BaseEntity {
     private PermissionGroup role;
     /** wx_openid;wx_openid */
     @Schema(description = "微信ID(可选)")
-    private String wx_openid;
+    @TableField("wx_openid")
+    private String wxOpenid;
     /** 头像;头像 */
     @Schema(description = "头像(可选)")
     private String avatar;

+ 5 - 2
src/main/java/work/baiyun/chronicdiseaseapp/model/po/UserToken.java

@@ -1,6 +1,7 @@
 package work.baiyun.chronicdiseaseapp.model.po;
 
 import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.annotation.TableField;
 import io.swagger.v3.oas.annotations.media.Schema;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
@@ -23,12 +24,14 @@ import java.time.LocalDateTime;
 public class UserToken extends BaseEntity{
     /** 用户ID;用户ID */
     @Schema(description = "用户ID;用户ID")
-    private Long user_id;
+    @TableField("user_id")
+    private Long userId;
     /** Token;Token */
     @Schema(description = "Token")
     private String token;
     /** 过期时间;过期时间 */
     @Schema(description = "过期时间")
-    private LocalDateTime expire_time;
+    @TableField("expire_time")
+    private LocalDateTime expireTime;
 
 }

+ 13 - 8
src/main/java/work/baiyun/chronicdiseaseapp/service/impl/TokenServiceImpl.java

@@ -15,6 +15,8 @@ public class TokenServiceImpl implements TokenService {
 
     private final UserTokenMapper userTokenMapper;
 
+    private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(TokenServiceImpl.class);
+
     @Autowired
     public TokenServiceImpl(UserTokenMapper userTokenMapper) {
         this.userTokenMapper = userTokenMapper;
@@ -30,13 +32,13 @@ public class TokenServiceImpl implements TokenService {
         UserToken existing = userTokenMapper.selectOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<UserToken>().eq("user_id", userId));
         if (existing == null) {
             UserToken ut = new UserToken();
-            ut.setUser_id(userId);
+            ut.setUserId(userId);
             ut.setToken(token);
-            ut.setExpire_time(expire);
+            ut.setExpireTime(expire);
             userTokenMapper.insert(ut);
         } else {
             existing.setToken(token);
-            existing.setExpire_time(expire);
+            existing.setExpireTime(expire);
             userTokenMapper.updateById(existing);
         }
         return token;
@@ -46,22 +48,25 @@ public class TokenServiceImpl implements TokenService {
     public Long validateToken(String token) {
         if (token == null || token.isEmpty()) return null;
         UserToken ut = userTokenMapper.selectOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<UserToken>().eq("token", token));
+        logger.debug("validateToken: token={}, userToken={}", token, ut);
         if (ut == null) return null;
 
         LocalDateTime now = LocalDateTime.now();
-        if (ut.getExpire_time() == null || ut.getExpire_time().isBefore(now)) {
+        logger.debug("validateToken: token={}, expire_time={}, now={}", token, ut.getExpireTime(), now);
+        if (ut.getExpireTime() == null || ut.getExpireTime().isBefore(now)) {
+            logger.info("validateToken: token {} is expired or has null expire_time (expire_time={}), now={} - returning null", token, ut.getExpireTime(), now);
             // expired
             return null;
         }
 
         // if token will expire in less than REFRESH_THRESHOLD_HOURS, extend it
         LocalDateTime refreshThreshold = now.plusHours(TokenUtil.REFRESH_THRESHOLD_HOURS);
-        if (ut.getExpire_time().isBefore(refreshThreshold)) {
-            ut.setExpire_time(now.plusHours(TokenUtil.TOKEN_TTL_HOURS));
+        if (ut.getExpireTime().isBefore(refreshThreshold)) {
+            ut.setExpireTime(now.plusHours(TokenUtil.TOKEN_TTL_HOURS));
             userTokenMapper.updateById(ut);
         }
 
-        return ut.getUser_id();
+        return ut.getUserId();
     }
 
     @Override
@@ -72,7 +77,7 @@ public class TokenServiceImpl implements TokenService {
         if (ut != null) {
             // 删除或清空 token 字段以表示失效
             ut.setToken(null);
-            ut.setExpire_time(null);
+            ut.setExpireTime(null);
             userTokenMapper.updateById(ut);
         }
     }