diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6003b54 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,119 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a Spring Boot multi-module Java backend application called "peipei-backend" that appears to be a social platform/gaming system with WeChat Mini Program integration. The system supports clerk management, customer interactions, orders, gifts, and various social features. + +## Project Structure + +The project is organized as a Maven multi-module application: + +- **play-admin**: Main backend API module containing REST controllers and business logic +- **play-common**: Shared utilities, configurations, and common components +- **play-generator**: Code generation tool for creating CRUD operations from database tables + +## Technology Stack + +- **Java 11** with Spring Boot 2.5.4 +- **MyBatis Plus 3.5.3** with join support for database operations +- **MySQL 8** with Flyway migrations +- **Redis** for caching and session management +- **JWT** for authentication +- **WeChat Mini Program SDK** for WeChat integration +- **Aliyun OSS** for file storage +- **Lombok** for reducing boilerplate code +- **Knife4j/Swagger** for API documentation + +## Development Commands + +### Building and Running +```bash +# Build the entire project +mvn clean compile + +# Package the application +mvn clean package + +# Run the main application (play-admin module) +cd play-admin +mvn spring-boot:run + +# Or run the packaged jar +java -jar play-admin/target/play-admin-1.0.jar +``` + +### Database Migrations +```bash +# Run Flyway migrations +mvn flyway:migrate + +# Check migration status +mvn flyway:info +``` + +### Code Generation +```bash +# Generate CRUD code from database tables +cd play-generator +mvn clean compile exec:java +# Or run directly: ./run.sh (Linux/Mac) or run.bat (Windows) +``` + +## Configuration + +The application uses Spring profiles with separate configuration files: +- `application.yml`: Main configuration +- `application-dev.yml`: Development environment +- `application-test.yml`: Test environment +- `application-prod.yml`: Production environment + +Default active profile is `test`. Change via `spring.profiles.active` property. + +## Architecture + +### Module Structure +- **Controllers**: Located in `modules/{domain}/controller/` - Handle HTTP requests and responses +- **Services**: Located in `modules/{domain}/service/` - Business logic layer +- **Mappers**: Located in `modules/{domain}/mapper/` - Database access layer using MyBatis Plus +- **Entities**: Domain objects representing database tables + +### Key Domains +- **clerk**: Clerk/staff management and operations +- **custom**: Customer management and interactions +- **order**: Order processing and management +- **shop**: Product catalog and commerce features +- **system**: System administration and user management +- **weichat**: WeChat Mini Program integration + +### Authentication & Security +- JWT-based authentication with Spring Security +- Multi-tenant architecture support +- Role-based access control +- XSS protection and input validation + +### Database +- Uses MyBatis Plus for ORM with automatic CRUD generation +- Flyway for database migration management +- Logical deletion support (soft delete) +- Multi-tenant data isolation + +## Code Generation Tool + +The project includes a powerful code generator (`play-generator`) that can: +- Read MySQL table structures +- Generate Entity, Mapper, Service, Controller classes +- Create MyBatis XML mapping files +- Support batch generation for multiple tables + +Configure database connection in `play-generator/src/main/resources/config.properties` and specify table names to generate complete CRUD operations. + +## Deployment + +The project includes a deployment script (`deploy.sh`) that: +- Builds and packages the application +- Deploys to remote server via SCP +- Restarts the application service + +Server runs on port 7002 with context path `/api`. \ No newline at end of file diff --git a/play-admin/src/main/java/com/starry/admin/common/filter/CorrelationFilter.java b/play-admin/src/main/java/com/starry/admin/common/filter/CorrelationFilter.java new file mode 100644 index 0000000..cf9ef1c --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/common/filter/CorrelationFilter.java @@ -0,0 +1,185 @@ +package com.starry.admin.common.filter; + +import com.starry.admin.modules.weichat.service.WxTokenService; +import com.starry.admin.modules.clerk.service.impl.PlayClerkUserInfoServiceImpl; +import com.starry.admin.modules.custom.service.impl.PlayCustomUserInfoServiceImpl; +import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; +import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import javax.servlet.*; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.UUID; + +/** + * 请求关联ID过滤器,为每个HTTP请求生成唯一的跟踪ID + * 用于日志关联和请求链路追踪 + * + * @author Claude + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class CorrelationFilter implements Filter { + + @Resource + private WxTokenService wxTokenService; + + @Resource + private PlayClerkUserInfoServiceImpl clerkUserInfoService; + + @Resource + private PlayCustomUserInfoServiceImpl customUserInfoService; + + public static final String CORRELATION_ID_HEADER = "X-Correlation-ID"; + public static final String CORRELATION_ID_MDC_KEY = "correlationId"; + public static final String USER_ID_MDC_KEY = "userId"; + public static final String REQUEST_URI_MDC_KEY = "requestUri"; + public static final String REQUEST_METHOD_MDC_KEY = "requestMethod"; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + if (!(request instanceof HttpServletRequest)) { + chain.doFilter(request, response); + return; + } + + HttpServletRequest httpRequest = (HttpServletRequest) request; + HttpServletResponse httpResponse = (HttpServletResponse) response; + + try { + // 生成或获取关联ID + String correlationId = getOrGenerateCorrelationId(httpRequest); + + // 设置MDC上下文 + MDC.put(CORRELATION_ID_MDC_KEY, correlationId); + MDC.put(REQUEST_URI_MDC_KEY, httpRequest.getRequestURI()); + MDC.put(REQUEST_METHOD_MDC_KEY, httpRequest.getMethod()); + + // 尝试获取用户ID(可能来自JWT或其他认证信息) + String userId = extractUserId(httpRequest); + if (userId != null) { + MDC.put(USER_ID_MDC_KEY, userId); + } + + // 将关联ID添加到响应头 + httpResponse.setHeader(CORRELATION_ID_HEADER, correlationId); + + // 继续过滤器链 + chain.doFilter(request, response); + + } finally { + // 清理MDC上下文 + MDC.clear(); + } + } + + /** + * 获取或生成关联ID + * 优先从请求头获取,如果没有则生成新的 + */ + private String getOrGenerateCorrelationId(HttpServletRequest request) { + String correlationId = request.getHeader(CORRELATION_ID_HEADER); + if (correlationId == null || correlationId.trim().isEmpty()) { + correlationId = "REQ-" + UUID.randomUUID().toString().substring(0, 8); + } + return correlationId; + } + + /** + * 尝试从请求中提取用户ID + * 根据项目的认证机制:支持微信端(clerk/custom token)和管理端(Authorization header) + */ + private String extractUserId(HttpServletRequest request) { + try { + // 1. 微信端 - Clerk用户认证 + String clerkToken = request.getHeader("clerkusertoken"); + if (clerkToken != null && !clerkToken.trim().isEmpty()) { + return extractUserIdFromWxToken(clerkToken, "clerk"); + } + + // 2. 微信端 - Custom用户认证 + String customToken = request.getHeader("customusertoken"); + if (customToken != null && !customToken.trim().isEmpty()) { + return extractUserIdFromWxToken(customToken, "custom"); + } + + // 3. 管理端 - JWT Bearer token认证 + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return extractUserIdFromJwtToken(authHeader); + } + + } catch (Exception e) { + // 如果解析失败,不影响主流程,只是记录日志时没有用户ID + // 可以选择记录debug日志,但不抛异常 + } + + return null; + } + + /** + * 从微信token中提取真实用户ID + */ + private String extractUserIdFromWxToken(String token, String userType) { + try { + // 使用WxTokenService解析JWT token获取真实用户ID + String userId = wxTokenService.getWxUserIdByToken(token); + + if (userId != null) { + // 根据用户类型获取更多用户信息(可选) + if ("clerk".equals(userType)) { + PlayClerkUserInfoEntity clerkUser = clerkUserInfoService.selectById(userId); + if (clerkUser != null) { + // 返回格式: clerk_userId 或者可以包含昵称等信息 + return "clerk_" + userId; + } + } else if ("custom".equals(userType)) { + PlayCustomUserInfoEntity customUser = customUserInfoService.selectById(userId); + if (customUser != null) { + return "custom_" + userId; + } + } + + // 如果查询用户详情失败,至少返回基础用户ID + return userType + "_" + userId; + } + + } catch (Exception e) { + // Token解析失败,可能是过期或无效token,不影响主流程 + } + + return null; + } + + /** + * 从管理端JWT token中提取用户ID + */ + private String extractUserIdFromJwtToken(String authHeader) { + try { + // 管理端的JWT解析比较复杂,这里先返回标识 + // 实际应该通过JwtToken组件或SecurityUtils获取当前登录用户信息 + return "admin_user"; + + } catch (Exception e) { + return null; + } + } + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + // 初始化逻辑 + } + + @Override + public void destroy() { + // 清理逻辑 + } +} \ No newline at end of file diff --git a/play-common/src/main/java/com/starry/common/config/LoggingConfig.java b/play-common/src/main/java/com/starry/common/config/LoggingConfig.java new file mode 100644 index 0000000..872e922 --- /dev/null +++ b/play-common/src/main/java/com/starry/common/config/LoggingConfig.java @@ -0,0 +1,41 @@ +package com.starry.common.config; + +import com.starry.common.interceptor.RequestLoggingInterceptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * 日志记录配置类 + * 配置请求日志拦截器 + * + * 注意:CorrelationFilter已移至play-admin模块,通过@Component自动注册 + * + * @author Claude + */ +@Configuration +public class LoggingConfig implements WebMvcConfigurer { + + @Autowired + private RequestLoggingInterceptor requestLoggingInterceptor; + + /** + * 添加请求日志拦截器 + */ + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(requestLoggingInterceptor) + .addPathPatterns("/**") + .excludePathPatterns( + "/static/**", + "/webjars/**", + "/swagger-resources/**", + "/v2/api-docs/**", + "/swagger-ui.html/**", + "/doc.html/**", + "/error", + "/favicon.ico" + ); + } +} \ No newline at end of file diff --git a/play-common/src/main/java/com/starry/common/interceptor/RequestLoggingInterceptor.java b/play-common/src/main/java/com/starry/common/interceptor/RequestLoggingInterceptor.java new file mode 100644 index 0000000..51a006f --- /dev/null +++ b/play-common/src/main/java/com/starry/common/interceptor/RequestLoggingInterceptor.java @@ -0,0 +1,210 @@ +package com.starry.common.interceptor; + +import com.alibaba.fastjson2.JSON; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.Collections; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 请求响应日志拦截器 + * 记录HTTP请求和响应的详细信息,用于调试和监控 + * + * @author Claude + */ +@Component +public class RequestLoggingInterceptor implements HandlerInterceptor { + + private static final Logger log = LoggerFactory.getLogger(RequestLoggingInterceptor.class); + + private static final String START_TIME_ATTRIBUTE = "startTime"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + + long startTime = System.currentTimeMillis(); + request.setAttribute(START_TIME_ATTRIBUTE, startTime); + + // 记录请求开始 + logRequestStart(request); + + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, + Object handler, Exception ex) throws Exception { + + Long startTime = (Long) request.getAttribute(START_TIME_ATTRIBUTE); + long duration = startTime != null ? System.currentTimeMillis() - startTime : 0; + + // 记录请求完成 + logRequestCompletion(request, response, duration, ex); + } + + /** + * 记录请求开始信息 + */ + private void logRequestStart(HttpServletRequest request) { + try { + String method = request.getMethod(); + String uri = request.getRequestURI(); + String queryString = request.getQueryString(); + String remoteAddr = getClientIpAddress(request); + String userAgent = request.getHeader("User-Agent"); + + // 构建完整URL + String fullUrl = uri; + if (queryString != null && !queryString.isEmpty()) { + fullUrl += "?" + queryString; + } + + log.info("Request started: {} {} from {} [{}]", + method, fullUrl, remoteAddr, getUserAgentInfo(userAgent)); + + // 记录请求头(过滤敏感信息) + Map headers = Collections.list(request.getHeaderNames()) + .stream() + .filter(this::isSafeHeader) + .collect(Collectors.toMap( + name -> name, + request::getHeader + )); + + if (!headers.isEmpty()) { + log.debug("Request headers: {}", JSON.toJSONString(headers)); + } + + } catch (Exception e) { + log.warn("Error logging request start: {}", e.getMessage()); + } + } + + /** + * 记录请求完成信息 + */ + private void logRequestCompletion(HttpServletRequest request, HttpServletResponse response, + long duration, Exception ex) { + try { + String method = request.getMethod(); + String uri = request.getRequestURI(); + int status = response.getStatus(); + String statusText = getStatusText(status); + + if (ex != null) { + log.error("Request completed with error: {} {} - {} {} ({}ms) - Exception: {}", + method, uri, status, statusText, duration, ex.getMessage()); + } else if (status >= 400) { + log.warn("Request completed with error: {} {} - {} {} ({}ms)", + method, uri, status, statusText, duration); + } else { + log.info("Request completed: {} {} - {} {} ({}ms)", + method, uri, status, statusText, duration); + } + + // 记录响应头(过滤敏感信息) + if (log.isDebugEnabled()) { + Map responseHeaders = response.getHeaderNames() + .stream() + .filter(this::isSafeHeader) + .collect(Collectors.toMap( + name -> name, + response::getHeader + )); + + if (!responseHeaders.isEmpty()) { + log.debug("Response headers: {}", JSON.toJSONString(responseHeaders)); + } + } + + } catch (Exception e) { + log.warn("Error logging request completion: {}", e.getMessage()); + } + } + + /** + * 获取客户端真实IP地址 + */ + private String getClientIpAddress(HttpServletRequest request) { + String[] headerNames = { + "X-Forwarded-For", + "X-Real-IP", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_CLIENT_IP", + "HTTP_X_FORWARDED_FOR" + }; + + for (String headerName : headerNames) { + String ip = request.getHeader(headerName); + if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { + // X-Forwarded-For可能包含多个IP,取第一个 + if (ip.contains(",")) { + ip = ip.substring(0, ip.indexOf(",")).trim(); + } + return ip; + } + } + + return request.getRemoteAddr(); + } + + /** + * 简化User-Agent信息 + */ + private String getUserAgentInfo(String userAgent) { + if (userAgent == null || userAgent.isEmpty()) { + return "Unknown"; + } + + if (userAgent.contains("WeChat")) { + return "WeChat"; + } else if (userAgent.contains("Chrome")) { + return "Chrome"; + } else if (userAgent.contains("Safari")) { + return "Safari"; + } else if (userAgent.contains("Firefox")) { + return "Firefox"; + } else if (userAgent.contains("PostmanRuntime")) { + return "Postman"; + } else { + return "Other"; + } + } + + /** + * 获取HTTP状态码描述 + */ + private String getStatusText(int status) { + if (status >= 200 && status < 300) { + return "OK"; + } else if (status >= 300 && status < 400) { + return "Redirect"; + } else if (status >= 400 && status < 500) { + return "Client Error"; + } else if (status >= 500) { + return "Server Error"; + } else { + return "Unknown"; + } + } + + /** + * 判断是否为安全的请求头(不包含敏感信息) + */ + private boolean isSafeHeader(String headerName) { + String lowerName = headerName.toLowerCase(); + return !lowerName.contains("authorization") && + !lowerName.contains("cookie") && + !lowerName.contains("password") && + !lowerName.contains("token") && + !lowerName.contains("secret"); + } +} \ No newline at end of file diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..e4f220a --- /dev/null +++ b/start.sh @@ -0,0 +1,76 @@ +#!/bin/bash +#这里可替换为你自己的执行程序,其他代码无需更改 +APP_NAME=play-admin-1.0.jar + +#使用说明,用来提示输入参数 +usage() { + echo "Usage: sh 脚本名.sh [start|stop|restart|status]" + exit 1 +} + +#检查程序是否在运行 +is_exist(){ + pid=`ps -ef|grep $APP_NAME|grep -v grep|awk '{print $2}' ` + #如果不存在返回1,存在返回0 + if [ -z "${pid}" ]; then + return 1 + else + return 0 + fi +} + +#启动方法 +start(){ + is_exist + if [ $? -eq "0" ]; then + echo "${APP_NAME} is already running. pid=${pid} ." + else + nohup java -Dloader.path=./lib/ -Xms2g -Xmx2g -jar $APP_NAME --spring.profiles.active=test > /dev/null 2>&1 & + echo "${APP_NAME} start success" + fi +} + +#停止方法 +stop(){ + is_exist + if [ $? -eq "0" ]; then + kill -9 $pid + else + echo "${APP_NAME} is not running" + fi +} + +#输出运行状态 +status(){ + is_exist + if [ $? -eq "0" ]; then + echo "${APP_NAME} is running. Pid is ${pid}" + else + echo "${APP_NAME} is NOT running." + fi +} + +#重启 +restart(){ + stop + start +} + +#根据输入参数,选择执行对应方法,不输入则执行使用说明 +case "$1" in + "start") + start + ;; + "stop") + stop + ;; + "status") + status + ;; + "restart") + restart + ;; + *) + usage + ;; +esac