diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..b5050677 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,96 @@ +name: Build, Push Docker Image, and Deploy + +on: + push: + branches: [day3_homework] + pull_request: + branches: [day3_homework] + +jobs: + build-and-test-backend: + name: Build And Test Backend Service + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cashe Maven packages + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Build with Maven + working-directory: ./backend-services + run: mvn -B clean package + + build-and-push-images: + name: Build and Push Docker Images + runs-on: ubuntu-latest + needs: build-and-test-backend + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up docker buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and Push API Getway Image + uses: docker/build-push-action@v5 + with: + context: ./backend-services + file: ./backend-services/Dockerfile_api_getway + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/ai-qa-api-gateway:latest + + - name: Build and Push User Service Image + uses: docker/build-push-action@v5 + with: + context: ./backend-services + file: ./backend-services/Dockerfile_user_service + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/ai-qa-user-service:latest + + - name: Build and Push QA Service Image + uses: docker/build-push-action@v5 + with: + context: ./backend-services + file: ./backend-services/Dockerfile_qa_service + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/ai-qa-qa-service:latest + + - name: Build and Push Frontend Image + uses: docker/build-push-action@v5 + with: + context: ./frontend-nextjs/frontend + file: ./frontend-nextjs/frontend/Dockerfile + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/ai-qa-frontend:latest + + - name: Deploy to EC2 + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + cd ~/airline-cicd-xuwei + docker-compose pull + docker-compose up -d --remove-orphans + docker image prune -f diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..0baa80ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/backend-services/api-gateway/target +/backend-services/qa-service/target +/backend-services/user-service/target \ No newline at end of file diff --git a/backend-services/Dockerfile_api_getway b/backend-services/Dockerfile_api_getway new file mode 100644 index 00000000..c748560d --- /dev/null +++ b/backend-services/Dockerfile_api_getway @@ -0,0 +1,27 @@ +# === Build stage === +FROM maven:3.9.9-eclipse-temurin-17-alpine AS build + +# WORKDIR /workspace + +COPY ./pom.xml ./pom.xml +COPY ./api-gateway/pom.xml ./api-gateway/pom.xml +COPY ./api-gateway/src ./api-gateway/src +COPY ./user-service/pom.xml ./user-service/pom.xml +COPY ./user-service/src ./user-service/src +COPY ./qa-service/pom.xml ./qa-service/pom.xml +COPY ./qa-service/src ./qa-service/src + +RUN mvn -pl api-gateway -am -B clean package -DskipTests + +# === Runtime stage === +FROM eclipse-temurin:17-jre-alpine + +WORKDIR /app + +ENV JAVA_OPTS="" + +COPY --from=build ./api-gateway/target/*.jar app.jar + +EXPOSE 8080 + +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/backend-services/Dockerfile_qa_service b/backend-services/Dockerfile_qa_service new file mode 100644 index 00000000..142f50be --- /dev/null +++ b/backend-services/Dockerfile_qa_service @@ -0,0 +1,27 @@ +# === Build stage === +FROM maven:3.9.9-eclipse-temurin-17-alpine AS build + +# WORKDIR /workspace + +COPY ./pom.xml ./pom.xml +COPY ./api-gateway/pom.xml ./api-gateway/pom.xml +COPY ./api-gateway/src ./api-gateway/src +COPY ./user-service/pom.xml ./user-service/pom.xml +COPY ./user-service/src ./user-service/src +COPY ./qa-service/pom.xml ./qa-service/pom.xml +COPY ./qa-service/src ./qa-service/src + +RUN mvn -pl qa-service -am -B clean package -DskipTests + +# === Runtime stage === +FROM eclipse-temurin:17-jre-alpine + +WORKDIR /app + +ENV JAVA_OPTS="" + +COPY --from=build ./qa-service/target/*.jar app.jar + +EXPOSE 8082 + +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/backend-services/Dockerfile_user_service b/backend-services/Dockerfile_user_service new file mode 100644 index 00000000..1c6a444b --- /dev/null +++ b/backend-services/Dockerfile_user_service @@ -0,0 +1,27 @@ +# === Build stage === +FROM maven:3.9.9-eclipse-temurin-17-alpine AS build + +# WORKDIR /workspace + +COPY ./pom.xml ./pom.xml +COPY ./api-gateway/pom.xml ./api-gateway/pom.xml +COPY ./api-gateway/src ./api-gateway/src +COPY ./user-service/pom.xml ./user-service/pom.xml +COPY ./user-service/src ./user-service/src +COPY ./qa-service/pom.xml ./qa-service/pom.xml +COPY ./qa-service/src ./qa-service/src + +RUN mvn -pl user-service -am -B clean package -DskipTests + +# === Runtime stage === +FROM eclipse-temurin:17-jre-alpine + +WORKDIR /app + +ENV JAVA_OPTS="" + +COPY --from=build ./user-service/target/*.jar app.jar + +EXPOSE 8081 + +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/backend-services/api-gateway/.dockerignore b/backend-services/api-gateway/.dockerignore new file mode 100644 index 00000000..1cbb75bb --- /dev/null +++ b/backend-services/api-gateway/.dockerignore @@ -0,0 +1,5 @@ +# .dockerignore +**/target +.idea +.vscode +*.iml \ No newline at end of file diff --git a/backend-services/api-gateway/.gitignore b/backend-services/api-gateway/.gitignore new file mode 100644 index 00000000..b83d2226 --- /dev/null +++ b/backend-services/api-gateway/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/backend-services/api-gateway/pom.xml b/backend-services/api-gateway/pom.xml index 3cf9637e..5491a0fe 100644 --- a/backend-services/api-gateway/pom.xml +++ b/backend-services/api-gateway/pom.xml @@ -26,6 +26,45 @@ com.alibaba.cloud spring-cloud-starter-alibaba-nacos-discovery + + org.springframework.cloud + spring-cloud-starter-loadbalancer + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + org.springframework.cloud + spring-cloud-starter-openfeign + + + com.google.guava + guava + 32.1.2-jre + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + runtime + + + com.alibaba.nacos + nacos-client + + diff --git a/backend-services/api-gateway/src/main/java/com/ai/qa/gateway/ApiGatewayApplication.java b/backend-services/api-gateway/src/main/java/com/ai/qa/gateway/ApiGatewayApplication.java new file mode 100644 index 00000000..2751412d --- /dev/null +++ b/backend-services/api-gateway/src/main/java/com/ai/qa/gateway/ApiGatewayApplication.java @@ -0,0 +1,15 @@ +package com.ai.qa.gateway; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.cloud.openfeign.EnableFeignClients; + +@SpringBootApplication +@EnableDiscoveryClient +@EnableFeignClients +public class ApiGatewayApplication { + public static void main(String[] args) { + SpringApplication.run(ApiGatewayApplication.class, args); + } +} \ No newline at end of file diff --git a/backend-services/api-gateway/src/main/java/com/ai/qa/gateway/api/controller/TestConfigController.java b/backend-services/api-gateway/src/main/java/com/ai/qa/gateway/api/controller/TestConfigController.java new file mode 100644 index 00000000..bec1482c --- /dev/null +++ b/backend-services/api-gateway/src/main/java/com/ai/qa/gateway/api/controller/TestConfigController.java @@ -0,0 +1,22 @@ +package com.ai.qa.gateway.api.controller; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/test") +public class TestConfigController { + + + @Value("${jwt.secret}") + private String jwtSecret; + + @GetMapping("/config") + public String login() { + System.out.println("测试config"); + return "测试JWT:"+jwtSecret; + } +} diff --git a/backend-services/api-gateway/src/main/java/com/ai/qa/gateway/api/web/filter/AuthenticationFilter.java b/backend-services/api-gateway/src/main/java/com/ai/qa/gateway/api/web/filter/AuthenticationFilter.java new file mode 100644 index 00000000..2e4ff2dd --- /dev/null +++ b/backend-services/api-gateway/src/main/java/com/ai/qa/gateway/api/web/filter/AuthenticationFilter.java @@ -0,0 +1,66 @@ +package com.ai.qa.gateway.api.web.filter; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.util.List; + +//@Component +//@RefreshScope // 为了动态刷新JWT密钥 +public class AuthenticationFilter implements GlobalFilter, Ordered { + + @Value("${jwt.secret}") + private String jwtSecret; + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + + // 定义白名单路径,这些路径不需要JWT验证 + List whiteList = List.of("/api/user/register", "/api/user/login"); + if (whiteList.contains(request.getURI().getPath())) { + return chain.filter(exchange); // 放行 + } + + String authHeader = request.getHeaders().getFirst("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); + return exchange.getResponse().setComplete(); + } + + String token = authHeader.substring(7); + try { + Claims claims = Jwts.parserBuilder() + .setSigningKey(jwtSecret.getBytes()) + .build() + .parseClaimsJws(token) + .getBody(); + + // 验证通过,可以将用户信息放入请求头,传递给下游服务 + ServerHttpRequest mutatedRequest = request.mutate() + .header("X-User-Id", claims.getSubject()) + .header("X-User-Name", claims.get("username", String.class)) + .build(); + return chain.filter(exchange.mutate().request(mutatedRequest).build()); + } catch (Exception e) { + exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); + return exchange.getResponse().setComplete(); + } + } + + @Override + public int getOrder() { + // 鉴权过滤器应在日志过滤器之后,在路由之前,优先级要高 + return -100; + } +} \ No newline at end of file diff --git a/backend-services/api-gateway/src/main/java/com/ai/qa/gateway/infrastructure/config/CorsConfig.java b/backend-services/api-gateway/src/main/java/com/ai/qa/gateway/infrastructure/config/CorsConfig.java new file mode 100644 index 00000000..d8ea08a6 --- /dev/null +++ b/backend-services/api-gateway/src/main/java/com/ai/qa/gateway/infrastructure/config/CorsConfig.java @@ -0,0 +1,25 @@ +package com.ai.qa.gateway.infrastructure.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.reactive.CorsWebFilter; +import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; + +@Configuration +public class CorsConfig { + + @Bean + public CorsWebFilter corsWebFilter() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(false); // 允许携带凭证 + config.addAllowedOrigin("*"); // 允许所有域名(生产环境不建议这么做) + config.addAllowedHeader("*"); // 允许所有Header + config.addAllowedMethod("*"); // 允许所有方法 + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + + return new CorsWebFilter(source); + } +} diff --git a/backend-services/api-gateway/src/main/java/com/ai/qa/gateway/infrastructure/config/InMemoryRateLimiterConfig.java b/backend-services/api-gateway/src/main/java/com/ai/qa/gateway/infrastructure/config/InMemoryRateLimiterConfig.java new file mode 100644 index 00000000..468befdf --- /dev/null +++ b/backend-services/api-gateway/src/main/java/com/ai/qa/gateway/infrastructure/config/InMemoryRateLimiterConfig.java @@ -0,0 +1,129 @@ + +package com.ai.qa.gateway.infrastructure.config; + +import com.google.common.util.concurrent.RateLimiter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import reactor.core.publisher.Mono; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Configuration +public class InMemoryRateLimiterConfig { + + private static final Logger log = LoggerFactory.getLogger(InMemoryRateLimiterConfig.class); + // ipKeyResolver Bean 保持不变 + @Bean + public KeyResolver ipKeyResolver() { + return exchange -> Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress()); + } + + /** + * 自定义的内存限流器 Bean (完整版) + */ + @Bean + @Primary + public org.springframework.cloud.gateway.filter.ratelimit.RateLimiter inMemoryRateLimiter() { + + // 定义默认的限流速率 + final double defaultReplenishRate = 5.0; // 每秒生成的令牌数 + final int defaultBurstCapacity = 100; // 令牌桶总容量 + + return new org.springframework.cloud.gateway.filter.ratelimit.RateLimiter() { + + private final ConcurrentHashMap limiters = new ConcurrentHashMap<>(); + private final ConcurrentHashMap configs = new ConcurrentHashMap<>(); + + @Override + public Mono isAllowed(String routeId, String id) { + // routeId 是当前请求匹配的路由ID + // id 是 KeyResolver 解析出的 key (IP地址) + // 获取当前路由的配置,如果不存在则使用默认配置 + RateLimiterConfig config = configs.getOrDefault(routeId, new RateLimiterConfig(defaultReplenishRate, defaultBurstCapacity)); + + // 根据 IP 地址获取或创建 Guava RateLimiter + RateLimiter limiter = limiters.computeIfAbsent(id, k -> { + // 使用配置的速率来创建限流器 + RateLimiter newLimiter = RateLimiter.create(config.getReplenishRate()); + // 预热 Guava RateLimiter (可选,但更好),让令牌桶初始时就是满的 + newLimiter.tryAcquire(config.getBurstCapacity()); + return newLimiter; + }); + + // 尝试获取一个令牌 + boolean allowed = limiter.tryAcquire(); + + if (allowed) { + log.info("Request ALLOWED. Route: {}, Key: {}", routeId, id); + return Mono.just(new Response(true, new HashMap<>())); + } else { + log.warn("Request DENIED (Rate Limited). Route: {}, Key: {}", routeId, id); + // 当被限流时,可以返回一些有用的头信息 + HashMap headers = new HashMap<>(); + headers.put("X-RateLimit-Remaining", "0"); + headers.put("X-RateLimit-Burst-Capacity", String.valueOf(config.getBurstCapacity())); + headers.put("X-RateLimit-Replenish-Rate", String.valueOf(config.getReplenishRate())); + return Mono.just(new Response(false, headers)); + } + } + + // ======================================================= + // 补全缺失的方法 + // ======================================================= + @Override + public Map getConfig() { + // 返回当前所有路由的配置信息 + return this.configs; + } + // ======================================================= + + @Override + public Class getConfigClass() { + return RateLimiterConfig.class; + } + + @Override + public RateLimiterConfig newConfig() { + // 提供一个默认的空配置对象 + return new RateLimiterConfig(); + } + }; + } + + /** + * 配置类,用于存储限流参数 + * 现在它不再是空的了,包含了速率和容量 + */ + public static class RateLimiterConfig { + private double replenishRate; + private int burstCapacity; + + public RateLimiterConfig() { + } + + public RateLimiterConfig(double replenishRate, int burstCapacity) { + this.replenishRate = replenishRate; + this.burstCapacity = burstCapacity; + } + + // Getters and Setters + public double getReplenishRate() { + return replenishRate; + } + public void setReplenishRate(double replenishRate) { + this.replenishRate = replenishRate; + } + public int getBurstCapacity() { + return burstCapacity; + } + public void setBurstCapacity(int burstCapacity) { + this.burstCapacity = burstCapacity; + } + } +} diff --git a/backend-services/api-gateway/src/main/resources/application.yml b/backend-services/api-gateway/src/main/resources/application.yml index 1d64a9bb..ba6eef13 100644 --- a/backend-services/api-gateway/src/main/resources/application.yml +++ b/backend-services/api-gateway/src/main/resources/application.yml @@ -1,23 +1,47 @@ server: port: 8080 # 所有后端请求的入口 +logging: + level: + # 将 Gateway 的核心日志级别设置为 DEBUG + org.springframework.cloud.gateway: DEBUG + # (可选) 将 Reactor Netty 的日志也设为 DEBUG,可以看到更底层的网络交互 + reactor.netty.http.client: DEBUG + com.alibaba.nacos.client: DEBUG spring: application: name: api-gateway + config: + import: + # 引入共享配置 + - "nacos:shared-config-xuwei.yml" cloud: nacos: - discovery: - server-addr: localhost:8848 + server-addr: 13.54.174.64:8848 + username: nacos # Nacos控制台登录用户名 + password: nacos # Nacos控制台登录密码 + config: + # 明确告诉 Nacos Config 默认的文件扩展名是 yml + file-extension: yml + group: DEFAULT_GROUP gateway: discovery: locator: - enabled: true # 开启基于服务发现的路由功能 - lower-case-service-id: true + enabled: false # 开启基于服务发现的路由功能 + lower-case-service-id: true # 将服务名转为小写路径,e.g., user-service -> /user-service/** routes: - id: user_service_route - uri: lb://user-service # lb:// 表示从Nacos负载均衡地选择一个user-service实例 + uri: lb://user-service-xuwei # lb:// 表示从Nacos负载均衡地选择一个user-service实例 +# uri: http://192.168.31.186:8081 predicates: - Path=/api/user/** # 匹配所有/api/user/开头的请求 + # filters: + # - StripPrefix=2 - id: qa_service_route - uri: lb://qa-service + uri: lb://qa-service-xuwei predicates: - - Path=/api/qa/** \ No newline at end of file + - Path=/api/qa/** + # default-filters: + # # 配置默认的限流过滤器 + # - name: RequestRateLimiter + # args: + # key-resolver: '#{@ipKeyResolver}' \ No newline at end of file diff --git a/backend-services/pom.xml b/backend-services/pom.xml index d22dd893..22fbeae1 100644 --- a/backend-services/pom.xml +++ b/backend-services/pom.xml @@ -7,7 +7,7 @@ org.springframework.boot spring-boot-starter-parent 2.7.17 - + ../pom.xml com.example diff --git a/backend-services/qa-service/.dockerignore b/backend-services/qa-service/.dockerignore new file mode 100644 index 00000000..1cbb75bb --- /dev/null +++ b/backend-services/qa-service/.dockerignore @@ -0,0 +1,5 @@ +# .dockerignore +**/target +.idea +.vscode +*.iml \ No newline at end of file diff --git a/backend-services/qa-service/.gitignore b/backend-services/qa-service/.gitignore new file mode 100644 index 00000000..6f6ea3e4 --- /dev/null +++ b/backend-services/qa-service/.gitignore @@ -0,0 +1,3 @@ +/target/ +/.apt_generated/ +/.apt_generated_tests/ diff --git a/backend-services/qa-service/pom.xml b/backend-services/qa-service/pom.xml index 0312dca0..65661aaf 100644 --- a/backend-services/qa-service/pom.xml +++ b/backend-services/qa-service/pom.xml @@ -14,7 +14,9 @@ qa-service - + + false + @@ -26,17 +28,71 @@ com.alibaba.cloud spring-cloud-starter-alibaba-nacos-discovery + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + org.springframework.cloud spring-cloud-starter-openfeign + + org.springframework.cloud + spring-cloud-starter-loadbalancer + + + org.springframework.boot + spring-boot-starter-data-jpa + + + mysql + mysql-connector-java + 8.0.33 + org.projectlombok lombok true + + org.mapstruct + mapstruct + 1.5.5.Final + + + org.mapstruct + mapstruct-processor + 1.5.5.Final + provided + + + io.jsonwebtoken + jjwt-api + 0.12.5 + + + io.jsonwebtoken + jjwt-impl + 0.12.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.5 + runtime + + + org.springframework.boot + spring-boot-starter-security + + + org.springdoc + springdoc-openapi-ui + 1.7.0 + @@ -45,8 +101,41 @@ org.springframework.boot spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 17 + 17 + + + org.projectlombok + lombok + 1.18.30 + + + org.mapstruct + mapstruct-processor + 1.5.5.Final + + + + + -Amapstruct.defaultComponentModel=spring + + + - \ No newline at end of file diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/QAServiceApplication.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/QAServiceApplication.java new file mode 100644 index 00000000..8d57e3ad --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/QAServiceApplication.java @@ -0,0 +1,16 @@ +package com.ai.qa.service; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.cloud.openfeign.EnableFeignClients; // <--- 1. 导入注解 + +@SpringBootApplication +@EnableDiscoveryClient // (在新版中可选,但建议保留) +@EnableFeignClients // <--- 启用 Feign 客户端功能 +public class QAServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(QAServiceApplication.class, args); + } +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/api/controller/QAController.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/api/controller/QAController.java new file mode 100644 index 00000000..423ad422 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/api/controller/QAController.java @@ -0,0 +1,83 @@ +package com.ai.qa.service.api.controller; + +import com.ai.qa.service.api.dto.QAHistoryDTO; +import com.ai.qa.service.api.dto.Response; +import com.ai.qa.service.api.dto.SaveHistoryRequest; +import com.ai.qa.service.application.service.QAHistoryService; +import com.ai.qa.service.domain.service.QAService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import com.ai.qa.service.infrastructure.persistence.mapper.QAHistoryMapper; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + + +@RestController +@RequestMapping("/api/qa") +@RequiredArgsConstructor +public class QAController { + private final QAService qaService; + private final QAHistoryService qaHistoryService; + private final QAHistoryMapper qaHistoryMapper; + + @GetMapping("/test") + public String testFeign() { + System.out.println("测试feign"); + return qaService.processQuestion(1L); + } + + @PostMapping("/save") + @Operation(summary = "保存历史请求", description = "保存历史记录") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "注册登录", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = SaveHistoryRequest.class))) + }) + public ResponseEntity> saveHistory(@RequestBody SaveHistoryRequest request){ + String userId = request.getUserId(); + if (userId != null && userId.isEmpty()) { + throw new IllegalArgumentException("用户名不能为空"); + } + QAHistoryDTO dto= qaHistoryService.saveHistory(request); + + return ResponseEntity.ok(Response.success(dto)); + } + + @GetMapping("/user/{username}") + @Operation(summary = "获取历史列表请求", description = "根据用户ID获取历史列表") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "注册登录") + }) + public ResponseEntity>> getByUserId(@PathVariable("username") String userId) { + if (userId != null && userId.isEmpty()) { + throw new IllegalArgumentException("用户名不能为空"); + } + List dtoList = qaHistoryService.queryUserHistoryByUserId(userId); + return ResponseEntity.ok(Response.success(dtoList)); + } + + @GetMapping("/gethistory/{sessionid}") + @Operation(summary = "获取历史列表请求", description = "根据会话ID获取历史列表") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "注册登录") + }) + public ResponseEntity> getBySessionId(@PathVariable("sessionid") String sessionId) { + if (sessionId != null && sessionId.isEmpty()) { + throw new IllegalArgumentException("会话ID不能为空"); + } + QAHistoryDTO dtoList = qaHistoryService.queryUserHistoryBySessionId(sessionId); + if (dtoList == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Response.error(404, "Register failed")); + } + return ResponseEntity.ok(Response.success(dtoList)); + } +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/api/dto/QAHistoryDTO.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/api/dto/QAHistoryDTO.java new file mode 100644 index 00000000..1ec32254 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/api/dto/QAHistoryDTO.java @@ -0,0 +1,15 @@ +package com.ai.qa.service.api.dto; + +import java.time.LocalDateTime; + +import lombok.Data; + +@Data +public class QAHistoryDTO { + private Long id; + private String userId; + private String question; + private String answer; + private String sessionId; + private LocalDateTime createTime; +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/api/dto/Response.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/api/dto/Response.java new file mode 100644 index 00000000..7a0511c7 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/api/dto/Response.java @@ -0,0 +1,32 @@ +package com.ai.qa.service.api.dto; + +import com.ai.qa.service.api.exception.ErrCode; + +import lombok.Data; + + +@Data +public class Response { + + private int code; + private String message; + private T data; + + // 预设成功响应 + public static Response success(T data) { + Response response = new Response<>(); + response.setCode(200); + response.setMessage(ErrCode.SUCCESS); + response.setData(data); + return response; + } + + // 预设失败响应 + public static Response error(int code, String message) { + Response response = new Response<>(); + response.setCode(code); + response.setMessage(message); + response.setData(null); + return response; + } +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/api/dto/SaveHistoryRequest.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/api/dto/SaveHistoryRequest.java new file mode 100644 index 00000000..843746dc --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/api/dto/SaveHistoryRequest.java @@ -0,0 +1,11 @@ +package com.ai.qa.service.api.dto; + +import lombok.Data; + +@Data +public class SaveHistoryRequest { + private String userId; + private String question; + private String answer; + private String sessionId; +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/api/exception/BusinessException.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/api/exception/BusinessException.java new file mode 100644 index 00000000..d433a467 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/api/exception/BusinessException.java @@ -0,0 +1,15 @@ +package com.ai.qa.service.api.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class BusinessException extends RuntimeException { + + private final HttpStatus status; + + public BusinessException(HttpStatus status, String message) { + super(message); + this.status = status; + } +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/api/exception/ErrCode.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/api/exception/ErrCode.java new file mode 100644 index 00000000..8ae97b12 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/api/exception/ErrCode.java @@ -0,0 +1,29 @@ +package com.ai.qa.service.api.exception; + +public final class ErrCode { + + /* + * Success Message + */ + public static String SUCCESS = "Success"; + + /* + * 注册成功 Message + */ + public static String REGISTER_SUCCESS = "Register success."; + + /* + * 注册失败 Message + */ + public static String REGISTER_FAILED = "Register failed."; + + /* + * 用户名更新成功 Message + */ + public static String UPDATE_NICK_SUCCESS = "Uapdate nick success."; + + /* + * 用户名更新失败 Message + */ + public static String UPDATE_NICK_FAILED = "Uapdate nick failed."; +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/api/exception/GlobalExceptionHandler.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/api/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..88d89841 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/api/exception/GlobalExceptionHandler.java @@ -0,0 +1,43 @@ +package com.ai.qa.service.api.exception; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import com.ai.qa.service.api.dto.Response; + +@RestControllerAdvice +public class GlobalExceptionHandler { + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handleBusinessException(BusinessException ex) { + log.error("业务异常: {}", ex.getMessage(), ex); + Response apiResponse = Response.error(ex.getStatus().value(), ex.getMessage()); + return new ResponseEntity<>(apiResponse, ex.getStatus()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGlobalException(Exception ex) { + log.error("未捕获的系统异常: {}", ex.getMessage(), ex); + Response apiResponse = Response.error(500, "服务器内部错误, 请联系管理员"); + return new ResponseEntity<>(apiResponse, HttpStatus.INTERNAL_SERVER_ERROR); + } + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity> handleResourceNotFoundException(ResourceNotFoundException ex) { + log.error("未找到资源异常: {}", ex.getMessage(), ex); + Response apiResponse = Response.error(404, ex.getMessage()); + return new ResponseEntity<>(apiResponse, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(InvalidCredentialsException.class) + public ResponseEntity> handleInvalidCredentialsException(InvalidCredentialsException ex) { + log.error("登录凭据无效异常: {}", ex.getMessage(), ex); + Response apiResponse = Response.error(403, ex.getMessage()); + return new ResponseEntity<>(apiResponse, HttpStatus.UNAUTHORIZED); + } +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/api/exception/InvalidCredentialsException.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/api/exception/InvalidCredentialsException.java new file mode 100644 index 00000000..e91c78c2 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/api/exception/InvalidCredentialsException.java @@ -0,0 +1,7 @@ +package com.ai.qa.service.api.exception; + +public class InvalidCredentialsException extends RuntimeException { + public InvalidCredentialsException(String message) { + super(message); + } +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/api/exception/ResourceNotFoundException.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/api/exception/ResourceNotFoundException.java new file mode 100644 index 00000000..2b0ea063 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/api/exception/ResourceNotFoundException.java @@ -0,0 +1,7 @@ +package com.ai.qa.service.api.exception; + +public class ResourceNotFoundException extends RuntimeException { + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/application/dto/QAHistoryQuery.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/application/dto/QAHistoryQuery.java new file mode 100644 index 00000000..c2de0631 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/application/dto/QAHistoryQuery.java @@ -0,0 +1,8 @@ +package com.ai.qa.service.application.dto; + +import lombok.Data; + +@Data +public class QAHistoryQuery { + private String userid; +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/application/dto/SaveHistoryCommand.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/application/dto/SaveHistoryCommand.java new file mode 100644 index 00000000..a2f49215 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/application/dto/SaveHistoryCommand.java @@ -0,0 +1,11 @@ +package com.ai.qa.service.application.dto; + +import lombok.Data; + +@Data +public class SaveHistoryCommand { + private String userId; + private String question; + private String answer; + private String sessionId; +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/application/service/QAHistoryService.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/application/service/QAHistoryService.java new file mode 100644 index 00000000..28df9b43 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/application/service/QAHistoryService.java @@ -0,0 +1,48 @@ +package com.ai.qa.service.application.service; + +import com.ai.qa.service.application.dto.SaveHistoryCommand; +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ai.qa.service.api.dto.QAHistoryDTO; +import com.ai.qa.service.api.dto.SaveHistoryRequest; +import com.ai.qa.service.domain.model.QAHistory; +import com.ai.qa.service.domain.repo.QAHistoryRepo; +import com.ai.qa.service.infrastructure.persistence.mapper.QAHistoryMapper; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional +@RequiredArgsConstructor +public class QAHistoryService { + private final QAHistoryMapper qaHistoryMapper; + private final QAHistoryRepo qaHistoryRepo; + + public QAHistoryDTO saveHistory(SaveHistoryRequest request){ + String userId = request.getUserId(); + if (userId != null && userId.isEmpty()) { + throw new IllegalArgumentException("用户名不能为空"); + } + QAHistory history = QAHistory.createNew(request.getUserId(), request.getQuestion(),request.getAnswer(), request.getSessionId()); + qaHistoryRepo.save(history); + return qaHistoryMapper.toDto(history); + } + + public List queryUserHistoryByUserId(String userId){ + if (userId != null && userId.isEmpty()) { + throw new IllegalArgumentException("用户名不能为空"); + } + List qaHistoryList = qaHistoryRepo.findHistoryByUserId(userId); + return qaHistoryMapper.toDtoList(qaHistoryList); + } + + public QAHistoryDTO queryUserHistoryBySessionId(String sessinId) { + if (sessinId != null && sessinId.isEmpty()) { + throw new IllegalArgumentException("用户名不能为空"); + } + QAHistory qaHistory = qaHistoryRepo.findHistoryBySession(sessinId); + return qaHistoryMapper.toDto(qaHistory); + } +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/domain/model/QAHistory.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/domain/model/QAHistory.java new file mode 100644 index 00000000..ca773022 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/domain/model/QAHistory.java @@ -0,0 +1,52 @@ +package com.ai.qa.service.domain.model; + +import java.time.LocalDateTime; + +import lombok.Data; + +@Data +public class QAHistory { + + private Long id; + private String userId; + private String question; + private String answer; + private String sessionId; + private LocalDateTime createTime; + +// private Object rag; + + public Long getId() { + return this.id; + } + + /** + * + * @param question + * @return + */ + // public String getAnswer(String question) { + // String response = rag.getContext(); + // return answer+response; + // } + + public String getUserId() { + return this.userId; + } + + // public String getRAGAnswer() { + // getAnswer(); + // serivice.sss(); + // return ""; + // } + + public static QAHistory createNew(String userId, String question, String answer,String sessionId) { + QAHistory qaHistory = new QAHistory(); + qaHistory.userId = userId; + qaHistory.question = question; + qaHistory.answer = answer; + qaHistory.sessionId = sessionId; + qaHistory.createTime = LocalDateTime.now(); + return qaHistory; + } +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/domain/model/QARAG.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/domain/model/QARAG.java new file mode 100644 index 00000000..24bfd365 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/domain/model/QARAG.java @@ -0,0 +1,4 @@ +package com.ai.qa.service.domain.model; + +public class QARAG { +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/domain/repo/QAHistoryRepo.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/domain/repo/QAHistoryRepo.java new file mode 100644 index 00000000..57d65772 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/domain/repo/QAHistoryRepo.java @@ -0,0 +1,15 @@ +package com.ai.qa.service.domain.repo; + +import com.ai.qa.service.domain.model.QAHistory; + +import java.util.List; +import java.util.Optional; + +public interface QAHistoryRepo { + + void save(QAHistory history); + Optional findHistoryById(Long id); + List findHistoryByUserId(String userid); + QAHistory findHistoryBySession(String sessionId); + +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/domain/service/JwtServiceImpl.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/domain/service/JwtServiceImpl.java new file mode 100644 index 00000000..3ab4e6f0 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/domain/service/JwtServiceImpl.java @@ -0,0 +1,68 @@ +package com.ai.qa.service.domain.service; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import javax.crypto.SecretKey; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; + +@Service +public class JwtServiceImpl { + @Value("${jwt.secret}") + private String SECRET; + + @Value("${jwt.expiration.ms}") + private long EXPIRATION; + + public String extractUsername(String jwt) { + return Jwts.parser() + .verifyWith(getSignInKey()).build() + .parseSignedClaims(jwt).getPayload() + .getSubject(); + } + + public boolean isTokenValid(String jwt, UserDetails userDetails) { + final String username = extractUsername(jwt); + return username.equals(userDetails.getUsername()) && !isTokenExpired(jwt); + } + + public String generateToken(String username) { + Map claims = new HashMap(); + return createToken(claims, username); + } + + private boolean isTokenExpired(String jwt) { + return Jwts.parser() + .verifyWith(getSignInKey()).build() + .parseSignedClaims(jwt).getPayload() + .getExpiration().before(new Date()); + } + + private String createToken(Map claims, String username) { + return Jwts.builder() + .claims(claims) + .subject(username) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + EXPIRATION)) + .signWith(getSignInKey()) + .compact(); + } + + private SecretKey getSignInKey() { + String encodedSecret = Base64.getEncoder().encodeToString(SECRET.getBytes(StandardCharsets.UTF_8)); + byte[] keyBytes = Base64.getDecoder().decode(encodedSecret); + + SecretKey key = Keys.hmacShaKeyFor(keyBytes); + return key; + } + +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/domain/service/QAService.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/domain/service/QAService.java new file mode 100644 index 00000000..8ed29162 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/domain/service/QAService.java @@ -0,0 +1,37 @@ +package com.ai.qa.service.domain.service; + +import com.ai.qa.service.infrastructure.feign.UserClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class QAService { + + @Autowired + private UserClient userClient; + + public String processQuestion(Long userId) { + // 1. 调用 user-service 获取用户信息 + System.out.println("Fetching user info for userId: " + userId); + String user; + try { + + // 就像调用一个本地方法一样! + user = userClient.getUserById(userId); + } catch (Exception e) { + // Feign 在遇到 4xx/5xx 错误时会抛出异常,需要处理 + System.err.println("Failed to fetch user info for userId: " + userId + ". Error: " + e.getMessage()); + // 可以根据业务返回一个默认的、友好的错误信息 + return "Sorry, I cannot get your user information right now."; + } + + if (user == null) { + return "Sorry, user with ID " + userId + " not found."; + } + + System.out.println("Question from user: " + user); + + // 返回最终结果 + return user; + } +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/domain/service/UserDetailsServiceImpl.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/domain/service/UserDetailsServiceImpl.java new file mode 100644 index 00000000..b8e64e9b --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/domain/service/UserDetailsServiceImpl.java @@ -0,0 +1,28 @@ +package com.ai.qa.service.domain.service; + +import java.util.ArrayList; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import com.ai.qa.service.api.exception.ResourceNotFoundException; +import com.ai.qa.service.infrastructure.persistence.entities.UserPO; +import com.ai.qa.service.infrastructure.persistence.repositories.JpaUserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserDetailsServiceImpl implements UserDetailsService { + + private final JpaUserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + UserPO userpo = userRepository.findByUsername(username) + .orElseThrow(() -> new ResourceNotFoundException("未找到指定用戶。用戶名:" + username)); + return new org.springframework.security.core.userdetails.User(userpo.getUsername(), userpo.getPassword(), new ArrayList<>()); + } +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/config/JwtAuthFilter.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/config/JwtAuthFilter.java new file mode 100644 index 00000000..fc3bb355 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/config/JwtAuthFilter.java @@ -0,0 +1,62 @@ +package com.ai.qa.service.infrastructure.config; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.ai.qa.service.domain.service.JwtServiceImpl; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import java.io.IOException; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +@Component +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtServiceImpl jwtServiceImpl; + private final UserDetailsService userDetailsService; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + final String authHeader = request.getHeader("Authorization"); + final String jwt; + final String username; + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + jwt = authHeader.substring(7); + username = jwtServiceImpl.extractUsername(jwt); + + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = this.userDetailsService.loadUserByUsername(username); + + if (jwtServiceImpl.isTokenValid(jwt, userDetails)) { + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities()); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/config/SecurityConfig.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/config/SecurityConfig.java new file mode 100644 index 00000000..6ec27c04 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/config/SecurityConfig.java @@ -0,0 +1,79 @@ +package com.ai.qa.service.infrastructure.config; + +import java.util.Arrays; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + private final JwtAuthFilter jwtAuthFilter; + private final UserDetailsService userDetailsService; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(authz -> authz + .antMatchers("/", "/index.html", "/**.js", "/**.css", "/**.ico", "/**.png", "/asset", + "/api/user/login", "/api/user/register", + "/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**", "/webjars/**" // Swagger相关 + ).permitAll() + .anyRequest().authenticated()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authenticationProvider(authenticationProvider()) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Arrays.asList("*")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("*")); + configuration.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/config/SwaggerConfig.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/config/SwaggerConfig.java new file mode 100644 index 00000000..39ceca86 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/config/SwaggerConfig.java @@ -0,0 +1,42 @@ +package com.ai.qa.service.infrastructure.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; + +import java.util.Arrays; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("AI问答系统") + .description("AI问答系统的RESTful API") + .version("1.0.0") + .contact(new Contact() + .name("Postion") + .email("support@postion.app") + .url("https://github.com/buooooou"))) + .servers(Arrays.asList( + new Server().url("http://localhost:8082").description("开发环境"), + new Server().url("https://ai.qa.com").description("生产环境"))) + .addSecurityItem(new SecurityRequirement().addList("bearerAuth")) + .components(new Components() + .addSecuritySchemes("bearerAuth", new SecurityScheme() + .name("bearerAuth") + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("JWT认证令牌"))); + } +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/feign/UserClient.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/feign/UserClient.java new file mode 100644 index 00000000..cd744406 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/feign/UserClient.java @@ -0,0 +1,31 @@ +package com.ai.qa.service.infrastructure.feign; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +/** + * 调用 user-service 的 Feign 客户端 + */ +// name/value 属性值必须与目标服务在 Nacos 上注册的服务名完全一致! +@FeignClient(name = "user-service-xuwei") +public interface UserClient { + + /** + * 根据用户ID获取用户信息 + * + * @param userId 用户ID + * @return 用户信息String + * + * 注意: + * 1. @GetMapping 里的路径必须与 user-service 中 Controller 方法的完整路径匹配。 + * 2. 方法签名 (方法名、参数) 可以自定义,但 @PathVariable, @RequestParam 等注解必须和远程接口保持一致。 + */ + @GetMapping("/nick/{userId}") // <-- 这个路径要和 user-service 的接口完全匹配 + String getUserById(@PathVariable("userId") Long userId); + + // 你可以在这里定义 user-service 暴露的其他任何接口 + // 例如: + // @PostMapping("/api/user/internal/check-status") + // StatusDTO checkUserStatus(@RequestBody CheckRequest request); +} \ No newline at end of file diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/persistence/entities/QAHistoryPO.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/persistence/entities/QAHistoryPO.java new file mode 100644 index 00000000..a77fd80a --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/persistence/entities/QAHistoryPO.java @@ -0,0 +1,26 @@ +package com.ai.qa.service.infrastructure.persistence.entities; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Entity +@Table(name= "qa_history") +public class QAHistoryPO { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String userId; + private String question; + private String answer; + private String sessionId; + private LocalDateTime createTime; +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/persistence/entities/UserPO.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/persistence/entities/UserPO.java new file mode 100644 index 00000000..c25605ae --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/persistence/entities/UserPO.java @@ -0,0 +1,26 @@ +package com.ai.qa.service.infrastructure.persistence.entities; + +import java.time.LocalDateTime; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; + +import lombok.Data; + +@Data +@Entity +@Table(name = "user") +public class UserPO { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String username; + private String password; + private String email; + private String avatar; + private LocalDateTime createTime; + private LocalDateTime updateTime; +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/persistence/mapper/QAHistoryMapper.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/persistence/mapper/QAHistoryMapper.java new file mode 100644 index 00000000..f2f7e4bf --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/persistence/mapper/QAHistoryMapper.java @@ -0,0 +1,21 @@ +package com.ai.qa.service.infrastructure.persistence.mapper; + +import java.util.List; + +import org.mapstruct.Mapper; +import org.mapstruct.factory.Mappers; + +import com.ai.qa.service.api.dto.QAHistoryDTO; +import com.ai.qa.service.domain.model.QAHistory; +import com.ai.qa.service.infrastructure.persistence.entities.QAHistoryPO; + +@Mapper(componentModel = "spring") +public interface QAHistoryMapper { + QAHistoryMapper INSTANCE = Mappers.getMapper(QAHistoryMapper.class); + + QAHistory toDomain(QAHistoryPO qaHistoryPO); + List toDomainList(List qaHistoryPO); + QAHistoryPO toPO(QAHistory qaHistory); + QAHistoryDTO toDto(QAHistory qaHistory); + List toDtoList(List qaHistory); +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/persistence/repositories/JpaQAHistoryRepository.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/persistence/repositories/JpaQAHistoryRepository.java new file mode 100644 index 00000000..673bcf08 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/persistence/repositories/JpaQAHistoryRepository.java @@ -0,0 +1,16 @@ +package com.ai.qa.service.infrastructure.persistence.repositories; + +import com.ai.qa.service.infrastructure.persistence.entities.QAHistoryPO; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface JpaQAHistoryRepository extends JpaRepository { + + QAHistoryPO findHistoryById(Long id); + List findHistoryByUserId(String userid); + List findHistoryBySessionId(String sessionid); +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/persistence/repositories/JpaUserRepository.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/persistence/repositories/JpaUserRepository.java new file mode 100644 index 00000000..e4afc5b9 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/persistence/repositories/JpaUserRepository.java @@ -0,0 +1,12 @@ +package com.ai.qa.service.infrastructure.persistence.repositories; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import com.ai.qa.service.infrastructure.persistence.entities.UserPO; + +@Repository +public interface JpaUserRepository extends JpaRepository { + Optional findByUsername(String username); +} diff --git a/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/persistence/repositories/QAHistoryRepoImpl.java b/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/persistence/repositories/QAHistoryRepoImpl.java new file mode 100644 index 00000000..d6893e23 --- /dev/null +++ b/backend-services/qa-service/src/main/java/com/ai/qa/service/infrastructure/persistence/repositories/QAHistoryRepoImpl.java @@ -0,0 +1,71 @@ +package com.ai.qa.service.infrastructure.persistence.repositories; + +import com.ai.qa.service.domain.model.QAHistory; +import com.ai.qa.service.domain.repo.QAHistoryRepo; +import com.ai.qa.service.infrastructure.persistence.entities.QAHistoryPO; +import com.ai.qa.service.infrastructure.persistence.mapper.QAHistoryMapper; +import lombok.RequiredArgsConstructor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class QAHistoryRepoImpl implements QAHistoryRepo { + + private final JpaQAHistoryRepository jpaQAHistoryRepository; + + private final QAHistoryMapper qaHistoryMapper; + + @Override + public void save(QAHistory history) { + Long id = history.getId(); + if (id != null) { + throw new IllegalArgumentException("ID不能为空"); + } + String userId = history.getUserId(); + if (userId != null && userId.isEmpty()) { + throw new IllegalArgumentException("用户名不能为空"); + } + QAHistoryPO qaHistoryPO = qaHistoryMapper.toPO(history); + jpaQAHistoryRepository.save(qaHistoryPO); + } + + @Override + public Optional findHistoryById(Long id) { + if (id != null) { + throw new IllegalArgumentException("ID不能为空"); + } + QAHistoryPO qaHistoryPO = jpaQAHistoryRepository.findHistoryById(id); + return Optional.ofNullable(qaHistoryMapper.toDomain(qaHistoryPO)); + } + + @Override + public List findHistoryByUserId(String userId) { + if (userId != null && userId.isEmpty()) { + throw new IllegalArgumentException("用户名不能为空"); + } + List qaHistoryPO = jpaQAHistoryRepository.findHistoryByUserId(userId); + if (qaHistoryPO.isEmpty()) { + List qaHistoryList = new ArrayList<>(); + return qaHistoryList; + } + return qaHistoryMapper.toDomainList(qaHistoryPO); + } + + @Override + public QAHistory findHistoryBySession(String sessionId) { + if (sessionId != null && sessionId.isEmpty()) { + throw new IllegalArgumentException("用户名不能为空"); + } + List qaHistoryPOList = jpaQAHistoryRepository.findHistoryBySessionId(sessionId); + Collections.sort(qaHistoryPOList, Comparator.comparingLong(QAHistoryPO::getId).reversed()); + QAHistoryPO qaHistoryPO = qaHistoryPOList.isEmpty() ? null : qaHistoryPOList.get(0); + return qaHistoryMapper.toDomain(qaHistoryPO); + } +} diff --git a/backend-services/qa-service/src/main/resources/application.yml b/backend-services/qa-service/src/main/resources/application.yml index bc6093e4..fc413362 100644 --- a/backend-services/qa-service/src/main/resources/application.yml +++ b/backend-services/qa-service/src/main/resources/application.yml @@ -2,8 +2,39 @@ server: port: 8082 spring: application: - name: qa-service + name: qa-service-xuwei + config: + import: + # 引入共享配置 + - "nacos:shared-config-xuwei.yml" cloud: nacos: + server-addr: 13.54.174.64:8848 + username: nacos # Nacos控制台登录用户名 + password: nacos # Nacos控制台登录密码 discovery: - server-addr: localhost:8848 \ No newline at end of file + server-addr: 13.54.174.64:8848 + config: + # 明确告诉 Nacos Config 默认的文件扩展名是 yml + file-extension: yml + group: DEFAULT_GROUP + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect + show-sql: true + sql: + init: + mode: always + schema-locations: classpath:sql/init.sql + springdoc: + swagger-ui: + path: /swagger-ui.html + +logging: + level: + # 将你的FeignClient接口所在的包路径设置为DEBUG + # 假设 UserClient 在 com.ai.qa.qaservice.feign 包下 + com.ai.qa.qaservice.feign: DEBUG \ No newline at end of file diff --git a/backend-services/qa-service/src/main/resources/sql/init.sql b/backend-services/qa-service/src/main/resources/sql/init.sql new file mode 100644 index 00000000..040fe7f7 --- /dev/null +++ b/backend-services/qa-service/src/main/resources/sql/init.sql @@ -0,0 +1,35 @@ +-- 创建一个名为 'ai_qa_system' 的数据库,如果它不存在的话 +CREATE DATABASE IF NOT EXISTS `ai_qa_system` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 切换到该数据库 +USE `ai_qa_system`; + +-- ---------------------------- +-- 用户表 (user) +-- ---------------------------- +CREATE TABLE IF NOT EXISTS `user` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `username` VARCHAR(255) NOT NULL COMMENT '用户名', + `password` VARCHAR(255) NOT NULL COMMENT '加密后的密码', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_username` (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表'; + +-- ---------------------------- +-- 问答历史表 (qa_history) (可选,用于功能扩展) +-- ---------------------------- +CREATE TABLE IF NOT EXISTS `qa_history` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `user_id` VARCHAR(255) NOT NULL COMMENT '用户名', + `question` TEXT NOT NULL COMMENT '用户提出的问题', + `answer` LONGTEXT COMMENT 'AI返回的回答', + `session_id` VARCHAR(255) NOT NULL COMMENT '会话ID', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='问答历史表'; + +-- 插入一些测试数据 (可选) +-- INSERT INTO `user` (`username`, `password`) VALUES ('testuser', '$2a$10$abcdefghijklmnopqrstuv'); -- 密码是加密的,请通过注册接口创建用户 \ No newline at end of file diff --git a/backend-services/qa-service/src/test/com/ai/qa/service/domain/QAHistoryTest.java b/backend-services/qa-service/src/test/com/ai/qa/service/domain/QAHistoryTest.java new file mode 100644 index 00000000..9bd69379 --- /dev/null +++ b/backend-services/qa-service/src/test/com/ai/qa/service/domain/QAHistoryTest.java @@ -0,0 +1,30 @@ +package com.ai.qa.service.domain; + + +import com.ai.qa.service.domain.model.QAHistory; +import org.springframework.util.Assert; + +public class QAHistoryTest { + + + public void setup(){ + + } + + @Test + public void createNew(){ + + QAHistory history = QAHistory.createNew("","",""); + Assert.isNull(history.getUserId(),""); + } + + @Test + public void answerQuestion(){ + + QAHistory history = QAHistory.createNew("","",""); + String sss = history.getRAGAnswer(); + Assert.isTrue(sss.equals("adfa"),""); + } + + +} diff --git a/backend-services/user-service/.dockerignore b/backend-services/user-service/.dockerignore new file mode 100644 index 00000000..1cbb75bb --- /dev/null +++ b/backend-services/user-service/.dockerignore @@ -0,0 +1,5 @@ +# .dockerignore +**/target +.idea +.vscode +*.iml \ No newline at end of file diff --git a/backend-services/user-service/.gitignore b/backend-services/user-service/.gitignore new file mode 100644 index 00000000..6f6ea3e4 --- /dev/null +++ b/backend-services/user-service/.gitignore @@ -0,0 +1,3 @@ +/target/ +/.apt_generated/ +/.apt_generated_tests/ diff --git a/backend-services/user-service/pom.xml b/backend-services/user-service/pom.xml index 64766627..bbac92c6 100644 --- a/backend-services/user-service/pom.xml +++ b/backend-services/user-service/pom.xml @@ -29,6 +29,23 @@ com.alibaba.cloud spring-cloud-starter-alibaba-nacos-discovery + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + org.springframework.boot + spring-boot-starter + + + org.springdoc + springdoc-openapi-ui + 1.7.0 + + + org.springframework.boot + spring-boot-starter-security + mysql mysql-connector-java @@ -37,8 +54,39 @@ org.projectlombok lombok + 1.18.30 + provided true + + org.mapstruct + mapstruct + 1.5.5.Final + + + org.mapstruct + mapstruct-processor + 1.5.5.Final + provided + + + + io.jsonwebtoken + jjwt-api + 0.12.5 + + + io.jsonwebtoken + jjwt-impl + 0.12.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.5 + runtime + @@ -47,8 +95,41 @@ org.springframework.boot spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 17 + 17 + + + org.projectlombok + lombok + 1.18.30 + + + org.mapstruct + mapstruct-processor + 1.5.5.Final + + + + + -Amapstruct.defaultComponentModel=spring + + + - \ No newline at end of file diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/api/controller/NickController.java b/backend-services/user-service/src/main/java/com/ai/qa/user/api/controller/NickController.java new file mode 100644 index 00000000..88a61160 --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/api/controller/NickController.java @@ -0,0 +1,52 @@ +package com.ai.qa.user.api.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.ai.qa.user.api.dto.request.UpdateNickRequest; +import com.ai.qa.user.api.dto.response.Response; +import com.ai.qa.user.api.exception.ErrCode; +import com.ai.qa.user.application.service.UserCaseService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/nick") +@RequiredArgsConstructor +public class NickController { + private final UserCaseService userService; + + @PostMapping("/update/{username}") + @Operation(summary = "更新昵称请求", description = "根据用户名更改昵称") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "更新登录") + }) + public ResponseEntity> updateNick(@PathVariable("username") String username, + @RequestBody UpdateNickRequest updateNickRequest) { + boolean result = userService.updateNick(username, updateNickRequest.getUsername()); + if (result) { + Response message = Response.success(ErrCode.REGISTER_SUCCESS); + return ResponseEntity.status(HttpStatus.OK).body(message); + } + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Response.error(400, ErrCode.UPDATE_NICK_FAILED)); + } + + @GetMapping("/get/{userid}") + @Operation(summary = "获取新昵称请求", description = "根据用户ID获取昵称") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "成功获得昵称") + }) + public ResponseEntity> getUserName(@PathVariable("userid") Long userId) { + String result = userService.getUserNamebyId(userId); + return ResponseEntity.ok(Response.success(result)); + } +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/api/controller/UserController.java b/backend-services/user-service/src/main/java/com/ai/qa/user/api/controller/UserController.java new file mode 100644 index 00000000..1310abe2 --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/api/controller/UserController.java @@ -0,0 +1,72 @@ +package com.ai.qa.user.api.controller; + +import javax.servlet.http.HttpServletRequest; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.ai.qa.user.api.dto.request.LoginRequest; +import com.ai.qa.user.api.dto.request.RegisterRequest; +import com.ai.qa.user.api.dto.response.LoginRsponse; +import com.ai.qa.user.api.dto.response.RegisterResponse; +import com.ai.qa.user.api.dto.response.Response; +import com.ai.qa.user.api.dto.response.UserInfo; +import com.ai.qa.user.application.service.UserCaseService; +import com.ai.qa.user.infrastructure.mapper.UserMapper; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/user") +@RequiredArgsConstructor +public class UserController { + private final UserCaseService userService; + private final UserMapper mapper; + + @PostMapping("/login") + @Operation(summary = "登录请求", description = "根据用户名密码验证登录") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "成功登录", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = LoginRequest.class))), + @ApiResponse(responseCode = "401", description = "未授权"), + @ApiResponse(responseCode = "403", description = "密码错误"), + @ApiResponse(responseCode = "404", description = "用户不存在") + }) + public ResponseEntity> login(@RequestBody LoginRequest loginRequest) { + return ResponseEntity.ok(Response.success(userService.login(loginRequest.getUsername()))); + } + + @PostMapping("/register") + @Operation(summary = "注册请求", description = "根据输入内容进行注册") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "注册登录", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = RegisterRequest.class))) + }) + public ResponseEntity> register(@RequestBody RegisterRequest registerRequest) { + return ResponseEntity.ok(Response.success(userService.register(mapper.toCommand(registerRequest)))); + } + + @GetMapping("/me") + @Operation(summary = "用户查询请求", description = "根据token查询用户信息") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "用户查询", + content = @Content(mediaType = "application/json")) + }) + public ResponseEntity> getUser(HttpServletRequest request) { + String authorizationHeader = request.getHeader("Authorization"); + String token = authorizationHeader.substring(7); + return ResponseEntity.ok(Response.success(userService.getUserName(token))); + } +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/api/dto/request/LoginRequest.java b/backend-services/user-service/src/main/java/com/ai/qa/user/api/dto/request/LoginRequest.java new file mode 100644 index 00000000..7fcef654 --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/api/dto/request/LoginRequest.java @@ -0,0 +1,16 @@ +package com.ai.qa.user.api.dto.request; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +import lombok.Data; + +@Data +public class LoginRequest { + @NotBlank(message = "用户名不能为空") + @Size(min = 4, max = 10, message = "用户名长度必须在4-10位之间") + private String username; + @NotBlank(message = "密码不能为空") + @Size(min = 8, max = 16, message = "密码长度必须在8-16位之间") + private String password; +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/api/dto/request/RegisterRequest.java b/backend-services/user-service/src/main/java/com/ai/qa/user/api/dto/request/RegisterRequest.java new file mode 100644 index 00000000..451c3118 --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/api/dto/request/RegisterRequest.java @@ -0,0 +1,19 @@ +package com.ai.qa.user.api.dto.request; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +import lombok.Data; + +@Data +public class RegisterRequest { + @NotBlank(message = "用户名不能为空") + @Size(min = 4, max = 10, message = "用户名长度必须在4-10位之间") + private String username; + @NotBlank(message = "密码不能为空") + @Size(min = 8, max = 16, message = "密码长度必须在8-16位之间") + private String password; + @NotBlank(message = "邮箱不能为空") + private String email; + private String avatar; +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/api/dto/request/UpdateNickRequest.java b/backend-services/user-service/src/main/java/com/ai/qa/user/api/dto/request/UpdateNickRequest.java new file mode 100644 index 00000000..6bcc5c99 --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/api/dto/request/UpdateNickRequest.java @@ -0,0 +1,13 @@ +package com.ai.qa.user.api.dto.request; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +import lombok.Data; + +@Data +public class UpdateNickRequest { + @NotBlank(message = "用户名不能为空") + @Size(min = 4, max = 10, message = "用户名长度必须在4-10位之间") + private String username; +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/api/dto/response/LoginRsponse.java b/backend-services/user-service/src/main/java/com/ai/qa/user/api/dto/response/LoginRsponse.java new file mode 100644 index 00000000..0ea7f624 --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/api/dto/response/LoginRsponse.java @@ -0,0 +1,9 @@ +package com.ai.qa.user.api.dto.response; + +import lombok.Data; + +@Data +public class LoginRsponse { + private String token; + private UserInfo user; +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/api/dto/response/RegisterResponse.java b/backend-services/user-service/src/main/java/com/ai/qa/user/api/dto/response/RegisterResponse.java new file mode 100644 index 00000000..647de7c2 --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/api/dto/response/RegisterResponse.java @@ -0,0 +1,9 @@ +package com.ai.qa.user.api.dto.response; + +import lombok.Data; + +@Data +public class RegisterResponse { + private String token; + private UserInfo user; +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/api/dto/response/Response.java b/backend-services/user-service/src/main/java/com/ai/qa/user/api/dto/response/Response.java new file mode 100644 index 00000000..33a01266 --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/api/dto/response/Response.java @@ -0,0 +1,30 @@ +package com.ai.qa.user.api.dto.response; + +import lombok.Data; +import com.ai.qa.user.api.exception.ErrCode;; + +@Data +public class Response { + + private int code; + private String message; + private T data; + + // 预设成功响应 + public static Response success(T data) { + Response response = new Response<>(); + response.setCode(200); + response.setMessage(ErrCode.SUCCESS); + response.setData(data); + return response; + } + + // 预设失败响应 + public static Response error(int code, String message) { + Response response = new Response<>(); + response.setCode(code); + response.setMessage(message); + response.setData(null); + return response; + } +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/api/dto/response/UserInfo.java b/backend-services/user-service/src/main/java/com/ai/qa/user/api/dto/response/UserInfo.java new file mode 100644 index 00000000..366f148e --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/api/dto/response/UserInfo.java @@ -0,0 +1,11 @@ +package com.ai.qa.user.api.dto.response; + +import lombok.Data; + +@Data +public class UserInfo { + private Long id; + private String username; + private String email; + private String avatar; +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/api/exception/BusinessException.java b/backend-services/user-service/src/main/java/com/ai/qa/user/api/exception/BusinessException.java new file mode 100644 index 00000000..d41000fc --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/api/exception/BusinessException.java @@ -0,0 +1,15 @@ +package com.ai.qa.user.api.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class BusinessException extends RuntimeException { + + private final HttpStatus status; + + public BusinessException(HttpStatus status, String message) { + super(message); + this.status = status; + } +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/api/exception/ErrCode.java b/backend-services/user-service/src/main/java/com/ai/qa/user/api/exception/ErrCode.java new file mode 100644 index 00000000..9f1dd3be --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/api/exception/ErrCode.java @@ -0,0 +1,29 @@ +package com.ai.qa.user.api.exception; + +public final class ErrCode { + /* + * Success Message + */ + public static String SUCCESS = "Success"; + + /* + * 注册成功 Message + */ + public static String REGISTER_SUCCESS = "Register success."; + + /* + * 注册失败 Message + */ + public static String REGISTER_FAILED = "Register failed."; + + /* + * 用户名更新成功 Message + */ + public static String UPDATE_NICK_SUCCESS = "Uapdate nick success."; + + /* + * 用户名更新失败 Message + */ + public static String UPDATE_NICK_FAILED = "Uapdate nick failed."; + +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/api/exception/GlobalExceptionHandler.java b/backend-services/user-service/src/main/java/com/ai/qa/user/api/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..1890d464 --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/api/exception/GlobalExceptionHandler.java @@ -0,0 +1,43 @@ +package com.ai.qa.user.api.exception; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import com.ai.qa.user.api.dto.response.Response; + +@RestControllerAdvice +public class GlobalExceptionHandler { + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handleBusinessException(BusinessException ex) { + log.error("业务异常: {}", ex.getMessage(), ex); + Response apiResponse = Response.error(ex.getStatus().value(), ex.getMessage()); + return new ResponseEntity<>(apiResponse, ex.getStatus()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGlobalException(Exception ex) { + log.error("未捕获的系统异常: {}", ex.getMessage(), ex); + Response apiResponse = Response.error(500, "服务器内部错误, 请联系管理员"); + return new ResponseEntity<>(apiResponse, HttpStatus.INTERNAL_SERVER_ERROR); + } + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity> handleResourceNotFoundException(ResourceNotFoundException ex) { + log.error("未找到资源异常: {}", ex.getMessage(), ex); + Response apiResponse = Response.error(404, ex.getMessage()); + return new ResponseEntity<>(apiResponse, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(InvalidCredentialsException.class) + public ResponseEntity> handleInvalidCredentialsException(InvalidCredentialsException ex) { + log.error("登录凭据无效异常: {}", ex.getMessage(), ex); + Response apiResponse = Response.error(403, ex.getMessage()); + return new ResponseEntity<>(apiResponse, HttpStatus.UNAUTHORIZED); + } +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/api/exception/InvalidCredentialsException.java b/backend-services/user-service/src/main/java/com/ai/qa/user/api/exception/InvalidCredentialsException.java new file mode 100644 index 00000000..cd76db6e --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/api/exception/InvalidCredentialsException.java @@ -0,0 +1,7 @@ +package com.ai.qa.user.api.exception; + +public class InvalidCredentialsException extends RuntimeException { + public InvalidCredentialsException(String message) { + super(message); + } +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/api/exception/ResourceNotFoundException.java b/backend-services/user-service/src/main/java/com/ai/qa/user/api/exception/ResourceNotFoundException.java new file mode 100644 index 00000000..43313ce9 --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/api/exception/ResourceNotFoundException.java @@ -0,0 +1,7 @@ +package com.ai.qa.user.api.exception; + +public class ResourceNotFoundException extends RuntimeException { + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/application/dto/SaveRegisterCommand.java b/backend-services/user-service/src/main/java/com/ai/qa/user/application/dto/SaveRegisterCommand.java new file mode 100644 index 00000000..9f28d7a3 --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/application/dto/SaveRegisterCommand.java @@ -0,0 +1,11 @@ +package com.ai.qa.user.application.dto; + +import lombok.Data; + +@Data +public class SaveRegisterCommand { + private String username; + private String password; + private String email; + private String avatar; +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/application/impl/UserServiceCaseImpl.java b/backend-services/user-service/src/main/java/com/ai/qa/user/application/impl/UserServiceCaseImpl.java new file mode 100644 index 00000000..bd4bce1a --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/application/impl/UserServiceCaseImpl.java @@ -0,0 +1,81 @@ +package com.ai.qa.user.application.impl; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ai.qa.user.api.dto.response.LoginRsponse; +import com.ai.qa.user.api.dto.response.RegisterResponse; +import com.ai.qa.user.api.dto.response.UserInfo; +import com.ai.qa.user.api.exception.ResourceNotFoundException; +import com.ai.qa.user.domain.service.JwtServiceImpl; +import com.ai.qa.user.infrastructure.mapper.UserMapper; +import com.ai.qa.user.application.dto.SaveRegisterCommand; +import com.ai.qa.user.application.service.UserCaseService; +import com.ai.qa.user.domain.repository.UserRepository; +import com.ai.qa.user.domain.model.User; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional +@RequiredArgsConstructor +public class UserServiceCaseImpl implements UserCaseService { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtServiceImpl jwtServiceImpl; + private final UserMapper userMapper; + + @Override + public LoginRsponse login(String username) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new ResourceNotFoundException("未找到指定用戶。用戶名:" + username)); + LoginRsponse loginRsponse = new LoginRsponse(); + loginRsponse.setToken(jwtServiceImpl.generateToken(user.getUsername())); + loginRsponse.setUser(userMapper.toUserInfo(user)); + return loginRsponse; + } + + @Override + public RegisterResponse register(SaveRegisterCommand command) { + try { + User user = User.createNew(command.getUsername(), + passwordEncoder.encode(command.getPassword()), command.getEmail(), command.getAvatar()); + userRepository.save(user); + RegisterResponse registerResponse = new RegisterResponse(); + registerResponse.setToken(jwtServiceImpl.generateToken(user.getUsername())); + registerResponse.setUser(userMapper.toUserInfo(user)); + return registerResponse; + } catch (Exception e) { + throw new ResourceNotFoundException("创建用户失败。"); + } + } + + @Override + public boolean updateNick(String username, String updatename) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new ResourceNotFoundException("未找到指定用戶。用戶名:" + username)); + try { + user.updateNick(updatename); + userRepository.save(user); + } catch (Exception e) { + throw new ResourceNotFoundException("用户更新失败。"); + } + return true; + } + + @Override + public String getUserNamebyId(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new ResourceNotFoundException("未找到指定用戶。用戶ID: " + userId)); + return user.getUsername(); + } + + @Override + public UserInfo getUserName(String token) { + + User user = userRepository.findByUsername(jwtServiceImpl.extractUsername(token)) + .orElseThrow(() -> new ResourceNotFoundException("未找到指定用戶。用戶名: " + jwtServiceImpl.extractUsername(token))); + return userMapper.toUserInfo(user); + } +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/application/service/UserCaseService.java b/backend-services/user-service/src/main/java/com/ai/qa/user/application/service/UserCaseService.java new file mode 100644 index 00000000..bbf378ed --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/application/service/UserCaseService.java @@ -0,0 +1,49 @@ +package com.ai.qa.user.application.service; + +import com.ai.qa.user.api.dto.response.LoginRsponse; +import com.ai.qa.user.api.dto.response.RegisterResponse; +import com.ai.qa.user.api.dto.response.UserInfo; +import com.ai.qa.user.application.dto.SaveRegisterCommand; + +public interface UserCaseService { + /** + * 登录 + * + * @param username 登录用户名 + * @return LoginRsponse + */ + LoginRsponse login(String username); + + /** + * 注册 + * + * @param SaveRegisterCommand 注册DTO + * @return RegisterResponse + */ + RegisterResponse register(SaveRegisterCommand command); + + /** + * 更新用户名 + * + * @param String 现有用户名 + * @param updatename 新用户名 + * @return boolean + */ + boolean updateNick(String username, String updatename); + + /** + * 取得用户信息 + * + * @param token + * @return UserInfo + */ + UserInfo getUserName(String token); + + /** + * 取得用户名 + * + * @param userId + * @return UserInfo + */ + String getUserNamebyId(Long userId); +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/domain/model/User.java b/backend-services/user-service/src/main/java/com/ai/qa/user/domain/model/User.java new file mode 100644 index 00000000..f6724523 --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/domain/model/User.java @@ -0,0 +1,35 @@ +package com.ai.qa.user.domain.model; + +import java.time.LocalDateTime; + +import lombok.Data; + +@Data +public class User { + private Long id; + private String username; + private String password; + private String email; + private String avatar; + private LocalDateTime createTime; + private LocalDateTime updateTime; + + public static User createNew(String username, String password, String email, String avatar) { + User user = new User(); + user.username = username; + user.password = password; + user.email = email; + user.avatar = avatar; + user.createTime = LocalDateTime.now(); + user.updateTime = LocalDateTime.now(); + return user; + } + + public void updateNick(String username) { + this.username = username; + this.updateTime = LocalDateTime.now(); + } + public String getUsername() { + return this.username; + } +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/domain/repository/UserRepository.java b/backend-services/user-service/src/main/java/com/ai/qa/user/domain/repository/UserRepository.java new file mode 100644 index 00000000..d63decc3 --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/domain/repository/UserRepository.java @@ -0,0 +1,10 @@ +package com.ai.qa.user.domain.repository; + +import java.util.Optional; +import com.ai.qa.user.domain.model.User; + +public interface UserRepository { + void save(User user); + Optional findByUsername(String username); + Optional findById(Long userId); +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/domain/service/JwtServiceImpl.java b/backend-services/user-service/src/main/java/com/ai/qa/user/domain/service/JwtServiceImpl.java new file mode 100644 index 00000000..8a38e563 --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/domain/service/JwtServiceImpl.java @@ -0,0 +1,68 @@ +package com.ai.qa.user.domain.service; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import javax.crypto.SecretKey; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; + +@Service +public class JwtServiceImpl { + @Value("${jwt.secret}") + private String SECRET; + + @Value("${jwt.expiration.ms}") + private long EXPIRATION; + + public String extractUsername(String jwt) { + return Jwts.parser() + .verifyWith(getSignInKey()).build() + .parseSignedClaims(jwt).getPayload() + .getSubject(); + } + + public boolean isTokenValid(String jwt, UserDetails userDetails) { + final String username = extractUsername(jwt); + return username.equals(userDetails.getUsername()) && !isTokenExpired(jwt); + } + + public String generateToken(String username) { + Map claims = new HashMap(); + return createToken(claims, username); + } + + private boolean isTokenExpired(String jwt) { + return Jwts.parser() + .verifyWith(getSignInKey()).build() + .parseSignedClaims(jwt).getPayload() + .getExpiration().before(new Date()); + } + + private String createToken(Map claims, String username) { + return Jwts.builder() + .claims(claims) + .subject(username) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + EXPIRATION)) + .signWith(getSignInKey()) + .compact(); + } + + private SecretKey getSignInKey() { + String encodedSecret = Base64.getEncoder().encodeToString(SECRET.getBytes(StandardCharsets.UTF_8)); + byte[] keyBytes = Base64.getDecoder().decode(encodedSecret); + + SecretKey key = Keys.hmacShaKeyFor(keyBytes); + return key; + } + +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/domain/service/UserDetailsServiceImpl.java b/backend-services/user-service/src/main/java/com/ai/qa/user/domain/service/UserDetailsServiceImpl.java new file mode 100644 index 00000000..b88bb7e6 --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/domain/service/UserDetailsServiceImpl.java @@ -0,0 +1,26 @@ +package com.ai.qa.user.domain.service; + +import java.util.ArrayList; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import com.ai.qa.user.infrastructure.entity.UserPO; +import com.ai.qa.user.infrastructure.repository.JpaUserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserDetailsServiceImpl implements UserDetailsService { + + private final JpaUserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + UserPO userpo = userRepository.findByUsername(username); + return new org.springframework.security.core.userdetails.User(userpo.getUsername(), userpo.getPassword(), new ArrayList<>()); + } +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/infrastructure/config/JwtAuthFilter.java b/backend-services/user-service/src/main/java/com/ai/qa/user/infrastructure/config/JwtAuthFilter.java new file mode 100644 index 00000000..3d16fa76 --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/infrastructure/config/JwtAuthFilter.java @@ -0,0 +1,62 @@ +package com.ai.qa.user.infrastructure.config; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.ai.qa.user.domain.service.JwtServiceImpl; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import java.io.IOException; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +@Component +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtServiceImpl jwtServiceImpl; + private final UserDetailsService userDetailsService; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + final String authHeader = request.getHeader("Authorization"); + final String jwt; + final String username; + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + jwt = authHeader.substring(7); + username = jwtServiceImpl.extractUsername(jwt); + + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = this.userDetailsService.loadUserByUsername(username); + + if (jwtServiceImpl.isTokenValid(jwt, userDetails)) { + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities()); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/infrastructure/config/SecurityConfig.java b/backend-services/user-service/src/main/java/com/ai/qa/user/infrastructure/config/SecurityConfig.java new file mode 100644 index 00000000..968fe9fe --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/infrastructure/config/SecurityConfig.java @@ -0,0 +1,70 @@ +package com.ai.qa.user.infrastructure.config; + +import java.util.Arrays; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthFilter jwtAuthFilter; + private final UserDetailsService userDetailsService; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(authz -> authz + .antMatchers("/", "/index.html", "/**.js", "/**.css", "/**.ico", "/**.png", "/asset", + "/api/user/login", "/api/user/register", + "/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**", "/webjars/**" // Swagger相关 + ).permitAll() + .anyRequest().authenticated()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authenticationProvider(authenticationProvider()) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} + + diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/infrastructure/config/SwaggerConfig.java b/backend-services/user-service/src/main/java/com/ai/qa/user/infrastructure/config/SwaggerConfig.java new file mode 100644 index 00000000..9df79b23 --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/infrastructure/config/SwaggerConfig.java @@ -0,0 +1,42 @@ +package com.ai.qa.user.infrastructure.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; + +import java.util.Arrays; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("AI问答系统") + .description("AI问答系统的RESTful API") + .version("1.0.0") + .contact(new Contact() + .name("Postion") + .email("support@postion.app") + .url("https://github.com/buooooou"))) + .servers(Arrays.asList( + new Server().url("http://localhost:8081").description("开发环境"), + new Server().url("https://ai.qa.com").description("生产环境"))) + .addSecurityItem(new SecurityRequirement().addList("bearerAuth")) + .components(new Components() + .addSecuritySchemes("bearerAuth", new SecurityScheme() + .name("bearerAuth") + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("JWT认证令牌"))); + } +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/infrastructure/entity/UserPO.java b/backend-services/user-service/src/main/java/com/ai/qa/user/infrastructure/entity/UserPO.java new file mode 100644 index 00000000..58ddbcc7 --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/infrastructure/entity/UserPO.java @@ -0,0 +1,25 @@ +package com.ai.qa.user.infrastructure.entity; + +import java.time.LocalDateTime; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; + +import lombok.Data; + +@Data +@Entity +@Table(name = "user") +public class UserPO { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String username; + private String password; + private String email; + private String avatar; + private LocalDateTime createTime; + private LocalDateTime updateTime; +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/infrastructure/mapper/UserMapper.java b/backend-services/user-service/src/main/java/com/ai/qa/user/infrastructure/mapper/UserMapper.java new file mode 100644 index 00000000..8279aad8 --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/infrastructure/mapper/UserMapper.java @@ -0,0 +1,20 @@ +package com.ai.qa.user.infrastructure.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.factory.Mappers; + +import com.ai.qa.user.api.dto.request.RegisterRequest; +import com.ai.qa.user.api.dto.response.UserInfo; +import com.ai.qa.user.application.dto.SaveRegisterCommand; +import com.ai.qa.user.domain.model.User; +import com.ai.qa.user.infrastructure.entity.UserPO; + +@Mapper(componentModel = "spring") +public interface UserMapper { + UserMapper INSTANCE = Mappers.getMapper(UserMapper.class); + + User toUser(UserPO userpo); + UserPO toUserOP(User user); + SaveRegisterCommand toCommand(RegisterRequest registerRequest); + UserInfo toUserInfo(User user); +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/infrastructure/repository/JpaUserRepository.java b/backend-services/user-service/src/main/java/com/ai/qa/user/infrastructure/repository/JpaUserRepository.java new file mode 100644 index 00000000..1506c3f7 --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/infrastructure/repository/JpaUserRepository.java @@ -0,0 +1,10 @@ +package com.ai.qa.user.infrastructure.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import com.ai.qa.user.infrastructure.entity.UserPO; + +@Repository +public interface JpaUserRepository extends JpaRepository { + UserPO findByUsername(String username); +} diff --git a/backend-services/user-service/src/main/java/com/ai/qa/user/infrastructure/repository/UserRepositoryImpl.java b/backend-services/user-service/src/main/java/com/ai/qa/user/infrastructure/repository/UserRepositoryImpl.java new file mode 100644 index 00000000..6f00a96a --- /dev/null +++ b/backend-services/user-service/src/main/java/com/ai/qa/user/infrastructure/repository/UserRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.ai.qa.user.infrastructure.repository; + +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import com.ai.qa.user.domain.model.User; +import com.ai.qa.user.domain.repository.UserRepository; +import com.ai.qa.user.infrastructure.entity.UserPO; +import com.ai.qa.user.infrastructure.mapper.UserMapper; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class UserRepositoryImpl implements UserRepository { + + private final JpaUserRepository jpaUserRepository; + private final UserMapper mapper; + + @Override + public void save(User user) { + jpaUserRepository.save(mapper.toUserOP(user)); + } + + @Override + public Optional findByUsername(String username) { + UserPO userpo = jpaUserRepository.findByUsername(username); + return Optional.ofNullable(mapper.toUser(userpo)); + } + + @Override + public Optional findById(Long userId) { + Optional userpo = jpaUserRepository.findById(userId); + return Optional.ofNullable(mapper.toUser(userpo.get())); + } +} diff --git a/backend-services/user-service/src/main/resources/application.yml b/backend-services/user-service/src/main/resources/application.yml index 36af4402..c895388d 100644 --- a/backend-services/user-service/src/main/resources/application.yml +++ b/backend-services/user-service/src/main/resources/application.yml @@ -2,19 +2,33 @@ server: port: 8081 spring: application: - name: user-service-1 + name: user-service-xuwei + config: + import: + # 引入共享配置 + - "nacos:shared-config-xuwei.yml" cloud: nacos: + server-addr: 13.54.174.64:8848 + username: nacos + password: nacos discovery: - server-addr: 54.219.180.170:8848 - datasource: - url: jdbc:mysql://54.219.180.170:3306/ai_qa_system?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai - username: root - password: ai_qa_system + server-addr: 13.54.174.64:8848 + config: + # 明确告诉 Nacos Config 默认的文件扩展名是 yml + file-extension: yml + group: DEFAULT_GROUP jpa: hibernate: ddl-auto: update properties: hibernate: dialect: org.hibernate.dialect.MySQL8Dialect - show-sql: true \ No newline at end of file + show-sql: true + sql: + init: + mode: always + schema-locations: classpath:sql/init.sql + springdoc: + swagger-ui: + path: /swagger-ui.html diff --git a/backend-services/user-service/src/main/resources/sql/init.sql b/backend-services/user-service/src/main/resources/sql/init.sql index 0d6d22a2..639fd921 100644 --- a/backend-services/user-service/src/main/resources/sql/init.sql +++ b/backend-services/user-service/src/main/resources/sql/init.sql @@ -4,14 +4,16 @@ CREATE DATABASE IF NOT EXISTS `ai_qa_system` DEFAULT CHARACTER SET utf8mb4 COLLA -- 切换到该数据库 USE `ai_qa_system`; +drop table if exists `user`; -- ---------------------------- -- 用户表 (user) -- ---------------------------- -DROP TABLE IF EXISTS `user`; -CREATE TABLE `user` ( +CREATE TABLE IF NOT EXISTS `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `username` VARCHAR(255) NOT NULL COMMENT '用户名', `password` VARCHAR(255) NOT NULL COMMENT '加密后的密码', + `email` VARCHAR(255) DEFAULT NULL COMMENT '用户邮箱', + `avatar` VARCHAR(255) DEFAULT NULL COMMENT '用户头像', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), @@ -21,16 +23,16 @@ CREATE TABLE `user` ( -- ---------------------------- -- 问答历史表 (qa_history) (可选,用于功能扩展) -- ---------------------------- -DROP TABLE IF EXISTS `qa_history`; -CREATE TABLE `qa_history` ( +CREATE TABLE IF NOT EXISTS `qa_history` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `user_id` BIGINT NOT NULL COMMENT '用户ID', + `user_id` VARCHAR(255) NOT NULL COMMENT '用户名', `question` TEXT NOT NULL COMMENT '用户提出的问题', `answer` LONGTEXT COMMENT 'AI返回的回答', + `session_id` VARCHAR(255) NOT NULL COMMENT '会话ID', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='问答历史表'; -- 插入一些测试数据 (可选) -INSERT INTO `user` (`username`, `password`) VALUES ('testuser', '$2a$10$abcdefghijklmnopqrstuv'); -- 密码是加密的,请通过注册接口创建用户 \ No newline at end of file +INSERT INTO `user` (`username`, `password`,`email`) VALUES ('xuwei', '$2a$10$YtoTkTTZCQ6V7Pgk2N4N8uR4Z6IWPBahEUmhq8svQTHK.8CTSYvRC','maxvsmax@126.com'); -- 密码是加密的,请通过注册接口创建用户 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..b4e32464 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +services: + api-gateway: + # 核心:使用 CI/CD 构建的镜像 + image: xuweixw/ai-qa-api-gateway:latest + container_name: api-gateway + restart: always + ports: + - "8080:8080" + networks: + - app-network + user-service: + # 核心:使用 CI/CD 构建的镜像 + image: xuweixw/ai-qa-user-service:latest + container_name: user-service + restart: always + ports: + - "8081:8081" + networks: + - app-network + qa-service: + # 核心:使用 CI/CD 构建的镜像 + image: xuweixw/ai-qa-qa-service:latest + container_name: api-gateway + restart: always + ports: + - "8082:8082" + networks: + - app-network + ai-qa-frontend: + # 核心:使用 CI/CD 构建的镜像 + image: xuweixw/ai-qa-frontend:latest + container_name: ai-qa-frontend + restart: always + ports: + - "3000:3000" + networks: + - app-network + +networks: + app-network: \ No newline at end of file diff --git a/frontend-nextjs/frontend/.dockerignore b/frontend-nextjs/frontend/.dockerignore new file mode 100644 index 00000000..b86da240 --- /dev/null +++ b/frontend-nextjs/frontend/.dockerignore @@ -0,0 +1,7 @@ +# .dockerignore +**/node_modules +**/target +**/.next +.idea +.vscode +*.iml \ No newline at end of file diff --git a/frontend-nextjs/frontend/.gitignore b/frontend-nextjs/frontend/.gitignore new file mode 100644 index 00000000..4a15a131 --- /dev/null +++ b/frontend-nextjs/frontend/.gitignore @@ -0,0 +1,77 @@ +# dependencies + +/node_modules + +/.pnp + +.pnp.* + +.yarn/* + +!.yarn/patches + +!.yarn/plugins + +!.yarn/releases + +!.yarn/versions + + + +# testing + +/coverage + + + +# next.js + +/.next/ + +/out/ + + + +# production + +/build + + + +# misc + +.DS_Store + +*.pem + + + +# debug + +npm-debug.log* + +yarn-debug.log* + +yarn-error.log* + +.pnpm-debug.log* + + + +# env files (can opt-in for committing if needed) + +.env* + + + +# vercel + +.vercel + + + +# typescript + +*.tsbuildinfo + +next-env.d.ts \ No newline at end of file diff --git a/frontend-nextjs/frontend/Dockerfile b/frontend-nextjs/frontend/Dockerfile new file mode 100644 index 00000000..e84fda94 --- /dev/null +++ b/frontend-nextjs/frontend/Dockerfile @@ -0,0 +1,32 @@ +# === Build stage === +FROM node:20-alpine AS build + +WORKDIR /app + +RUN npm install -g pnpm@10.17.1 +ENV PNPM_HOME=/usr/local/share/pnpm +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable + +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY . . +RUN pnpm run build + +# === Runtime stage === +FROM node:20-alpine + +WORKDIR /app + +ENV NODE_ENV=production +ENV MISTRAL_API_KEY=KUzeQl82Zh6Abq3ePGVwZdcNWLESO9df + +COPY --from=build /app/public ./public +COPY --from=build /app/.next ./.next +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/package.json ./package.json + +EXPOSE 3000 + +CMD ["npm", "start"] \ No newline at end of file diff --git a/frontend-nextjs/frontend/README.md b/frontend-nextjs/frontend/README.md new file mode 100644 index 00000000..e215bc4c --- /dev/null +++ b/frontend-nextjs/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/frontend-nextjs/frontend/app/api/chat/route.ts b/frontend-nextjs/frontend/app/api/chat/route.ts new file mode 100644 index 00000000..aa761912 --- /dev/null +++ b/frontend-nextjs/frontend/app/api/chat/route.ts @@ -0,0 +1,38 @@ +import { mistral } from "@ai-sdk/mistral"; +import { convertToModelMessages, streamText, type UIMessage } from "ai"; +import { NextRequest } from "next/server"; + +export const maxDuration = 30; + +// process.env.sss="ssss" + +export async function POST(req: NextRequest) { + try { + const { messages }: { messages: UIMessage[] } = await req.json(); + + if (!messages || !Array.isArray(messages) || messages.length === 0) { + return new Response("Messages are required", { status: 400 }); + } + + const prompt = convertToModelMessages(messages); + + const result = streamText({ + model: mistral("mistral-large-latest"), + messages: prompt, + abortSignal: req.signal, + // maxTokens: 2000, + temperature: 0.7, + }); + + return result.toUIMessageStreamResponse({ + onFinish: async ({ isAborted }) => { + if (isAborted) { + console.log("Chat request aborted"); + } + }, + }); + } catch (error) { + console.error("Chat API error:", error); + return new Response("Internal server error", { status: 500 }); + } +} diff --git a/frontend-nextjs/frontend/app/api/hello/route.ts b/frontend-nextjs/frontend/app/api/hello/route.ts new file mode 100644 index 00000000..482825bb --- /dev/null +++ b/frontend-nextjs/frontend/app/api/hello/route.ts @@ -0,0 +1,11 @@ +// 文件路径: app/api/hello/route.ts +import { NextResponse } from 'next/server'; + +export async function GET(request: Request) { + // 在这里可以进行数据库查询等后端操作 + const data = { message: 'Hello from the Server-side API!' }; + + // 返回 JSON 响应 + return NextResponse.json(data); +} + diff --git a/frontend-nextjs/frontend/app/auth/page.tsx b/frontend-nextjs/frontend/app/auth/page.tsx new file mode 100644 index 00000000..fffe4c25 --- /dev/null +++ b/frontend-nextjs/frontend/app/auth/page.tsx @@ -0,0 +1,47 @@ +"use client" + +import { useState, useEffect } from "react" +import { useRouter } from "next/navigation" +import { LoginForm } from "@/components/auth/login-form" +import { RegisterForm } from "@/components/auth/register-form" +import { useAuth } from "@/contexts/auth-context" + +export default function AuthPage() { + const [isLogin, setIsLogin] = useState(true) + const { user, isLoading } = useAuth() + const router = useRouter() + + // Redirect if already logged in + useEffect(() => { + if (!isLoading && user) { + router.push("/") + } + }, [user, isLoading, router]) + + if (isLoading) { + return ( +
+
+
+

加载中...

+
+
+ ) + } + + if (user) { + return null // Will redirect + } + + return ( +
+
+ {isLogin ? ( + setIsLogin(false)} /> + ) : ( + setIsLogin(true)} /> + )} +
+
+ ) +} diff --git a/frontend-nextjs/frontend/app/favicon.ico b/frontend-nextjs/frontend/app/favicon.ico new file mode 100644 index 00000000..718d6fea Binary files /dev/null and b/frontend-nextjs/frontend/app/favicon.ico differ diff --git a/frontend-nextjs/frontend/app/globals.css b/frontend-nextjs/frontend/app/globals.css new file mode 100644 index 00000000..913a2c53 --- /dev/null +++ b/frontend-nextjs/frontend/app/globals.css @@ -0,0 +1,53 @@ +@import "tailwindcss"; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + --popover: #1a1a1a; + --border: #404040; + --primary: #1976d2; + --primary-foreground: #ffffff; + } +} + +body { + background: var(--background); + color: var(--foreground); + font-family: Arial, Helvetica, sans-serif; +} + +.dropdown-content { + background: var(--popover)!important; + border: 1px solid var(--border)!important; + box-shadow: 0 10px 38px -10px rgba(22, 23, 24, 0.35), 0 10px 20px -15px rgba(22, 23, 24, 0.2)!important; + backdrop-filter: blur(8px); + animation: sclaleIn 0.2s ease-out; +} + +.new-chat-button { + position: relative; + overflow: hidden; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.loading-spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(25, 118, 210, 0.3); + border-top: 2px solid rgba(--primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} diff --git a/frontend-nextjs/frontend/app/hello/page.tsx b/frontend-nextjs/frontend/app/hello/page.tsx new file mode 100644 index 00000000..5b145053 --- /dev/null +++ b/frontend-nextjs/frontend/app/hello/page.tsx @@ -0,0 +1,9 @@ +import Image from "next/image"; + +export default function Home() { + return ( +
+ Hello world +
+ ); +} diff --git a/frontend-nextjs/frontend/app/layout.tsx b/frontend-nextjs/frontend/app/layout.tsx new file mode 100644 index 00000000..86aa88d8 --- /dev/null +++ b/frontend-nextjs/frontend/app/layout.tsx @@ -0,0 +1,36 @@ +import type React from "react" +import type { Metadata } from "next" + +import { AuthProvider } from "@/contexts/auth-context" +import { QaProvider } from "@/contexts/qa-context" +import { Toaster } from "@/components/ui/toaster" +import { Suspense } from "react" +import "./globals.css" + +export const metadata: Metadata = { + title: "AI Chatbot App", + description: "AI-powered chatbot with user authentication", + generator: "v0.app", + +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + + + + {children} + + + + + + + ) +} diff --git a/frontend-nextjs/frontend/app/loading.tsx b/frontend-nextjs/frontend/app/loading.tsx new file mode 100644 index 00000000..21b686d9 --- /dev/null +++ b/frontend-nextjs/frontend/app/loading.tsx @@ -0,0 +1,103 @@ +import Image from "next/image"; + +export default function Home() { + return ( +
+
+ Next.js logo +
    +
  1. + Get started by editing{" "} + + app/page.tsx + + . +
  2. +
  3. + Save and see your changes instantly. +
  4. +
+ + +
+ +
+ ); +} diff --git a/frontend-nextjs/frontend/app/page.tsx b/frontend-nextjs/frontend/app/page.tsx new file mode 100644 index 00000000..ac1c346d --- /dev/null +++ b/frontend-nextjs/frontend/app/page.tsx @@ -0,0 +1,186 @@ +"use client" + +import { useState, useCallback, useEffect } from "react" +import { Sidebar } from "@/components/sidebar" +import { ChatWindow } from "@/components/chat-window" +import { ProtectedRoute } from "@/components/auth/protected-route" +import type { Conversation } from "@/types/chat" + +function HomePage() { + const [conversations, setConversations] = useState([]) + const [activeConversationId, setActiveConversationId] = useState("") + const [newConversationId, setNewConversationId] = useState() // 跟踪最新创建的对话 + const [isLoading, setIsLoading] = useState(false) // 新建对话的加载状态 + const [conversationMessages, setConversationMessages] = useState>({}) // 存储各对话的消息 + + const generateConversationTitle = (firstMessage: string): string => { + const title = firstMessage.length > 30 ? firstMessage.substring(0, 30) + "..." : firstMessage + return title || "新对话" + } + + useEffect(() => { + // 只在首次加载且没有会话时创建 + setIsLoading(true) + try { + setConversations([]); + handleNewChat(); + } finally { + setIsLoading(false) + } + }, []) + + const handleNewChat = useCallback(() => { + setIsLoading(true) + try { + const newConversation: Conversation = { + id: Math.random().toString(36).substr(2, 9), + title: "新对话", + messages: [], + createdAt: new Date(), + updatedAt: new Date(), + } + + // 添加新对话到列表 + setConversations((prev) => [newConversation, ...prev]) + // 设置新对话ID用于高亮 + setNewConversationId(newConversation.id) + // 清空当前活跃对话,显示初始页面 + setActiveConversationId(newConversation.id) + // 清空消息存储 + setConversationMessages(prev => ({ ...prev, [newConversation.id]: [] })) + } finally { + setIsLoading(false) + } + }, []) + + const handleSelectConversation = useCallback((conversationId: string) => { + // const handleSelectConversation = useCallback((conversationId: string, messages?: Conversation["messages"]) => { + + setActiveConversationId(conversationId) + // 清除新建对话的高亮状态 + setNewConversationId(undefined) + // 设置当前新对话ID(如果是新对话的话) + setNewConversationId(conversationId) + // 存储加载的消息 + // setConversationMessages(prev => ({ ...prev, [conversationId]: messages || [] })) + }, []) + + const handleDeleteConversation = useCallback( + (conversationId: string) => { + setConversations((prev) => prev.filter((conv) => conv.id !== conversationId)) + // 删除消息存储 + setConversationMessages(prev => { + const newMessages = { ...prev } + delete newMessages[conversationId] + return newMessages + }) + + if (activeConversationId === conversationId) { + setActiveConversationId("") + } + }, + [activeConversationId], + ) + + const handleRenameConversation = useCallback((conversationId: string, newTitle: string) => { + setConversations((prev) => + prev.map((conv) => (conv.id === conversationId ? { ...conv, title: newTitle, updatedAt: new Date() } : conv)), + ) + }, []) + + const handleMessageAdded = useCallback( + (message: { role: "user" | "assistant"; content: string }) => { + if (!activeConversationId) return + + const newMessage = { + id: Math.random().toString(36).substr(2, 9), + role: message.role, + content: message.content, + timestamp: new Date(), + } + + // 更新对话列表中的消息 + setConversations((prev) => + prev.map((conv) => + conv.id === activeConversationId + ? { + ...conv, + messages: [...conv.messages, newMessage], + updatedAt: new Date(), + } + : conv, + ), + ) + + // 更新消息存储 + setConversationMessages(prev => ({ + ...prev, + [activeConversationId]: [...(prev[activeConversationId] || []), newMessage] + })) + }, + [activeConversationId], + ) + + const handleFirstMessage = useCallback( + (content: string) => { + if (!newConversationId) return + + const title = generateConversationTitle(content) + // 更新新对话的标题 + setConversations((prev) => + prev.map((conv) => + conv.id === newConversationId + ? { + ...conv, + title, + updatedAt: new Date(), + } + : conv, + ), + ) + + // 将新对话设为活跃状态 + setActiveConversationId(newConversationId) + }, + [newConversationId], + ) + + const activeConversation = conversations.find((conv) => conv.id === activeConversationId) + // 获取当前对话的消息 + const currentMessages = activeConversationId + ? conversationMessages[activeConversationId] || [] + : [] + + return ( +
+ + +
+ +
+
+ ) +} + +export default function ProtectedHomePage() { + return ( + + + + ) +} diff --git a/frontend-nextjs/frontend/components/auth/login-form.tsx b/frontend-nextjs/frontend/components/auth/login-form.tsx new file mode 100644 index 00000000..a7327e50 --- /dev/null +++ b/frontend-nextjs/frontend/components/auth/login-form.tsx @@ -0,0 +1,102 @@ +"use client" + +import type React from "react" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { useAuth } from "@/contexts/auth-context" +import { Eye, EyeOff, MessageSquare } from "lucide-react" + +interface LoginFormProps { + onSwitchToRegister: () => void +} + +export function LoginForm({ onSwitchToRegister }: LoginFormProps) { + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + const [showPassword, setShowPassword] = useState(false) + const { login, isLoading } = useAuth() + const router = useRouter() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + try { + await login({ username, password }) + router.push("/") + } catch (error) { + // Error is handled by the auth context + } + } + + return ( + + +
+
+ +
+
+ 欢迎回来 + 登录您的账户以继续使用 AI 聊天机器人 +
+ +
+
+ + setUsername(e.target.value)} + required + disabled={isLoading} + /> +
+ +
+ +
+ setPassword(e.target.value)} + required + disabled={isLoading} + /> + +
+
+ + +
+ +
+

+ 还没有账户?{" "} + +

+
+
+
+ ) +} diff --git a/frontend-nextjs/frontend/components/auth/protected-route.tsx b/frontend-nextjs/frontend/components/auth/protected-route.tsx new file mode 100644 index 00000000..02306182 --- /dev/null +++ b/frontend-nextjs/frontend/components/auth/protected-route.tsx @@ -0,0 +1,39 @@ +"use client" + +import type React from "react" + +import { useEffect } from "react" +import { useRouter } from "next/navigation" +import { useAuth } from "@/contexts/auth-context" + +interface ProtectedRouteProps { + children: React.ReactNode +} + +export function ProtectedRoute({ children }: ProtectedRouteProps) { + const { user, isLoading } = useAuth() + const router = useRouter() + + useEffect(() => { + if (!isLoading && !user) { + router.push("/auth") + } + }, [user, isLoading, router]) + + if (isLoading) { + return ( +
+
+
+

验证身份中...

+
+
+ ) + } + + if (!user) { + return null // Will redirect to auth + } + + return <>{children} +} diff --git a/frontend-nextjs/frontend/components/auth/register-form.tsx b/frontend-nextjs/frontend/components/auth/register-form.tsx new file mode 100644 index 00000000..2ddc04af --- /dev/null +++ b/frontend-nextjs/frontend/components/auth/register-form.tsx @@ -0,0 +1,149 @@ +"use client" + +import type React from "react" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { useAuth } from "@/contexts/auth-context" +import { Eye, EyeOff, MessageSquare } from "lucide-react" + +interface RegisterFormProps { + onSwitchToLogin: () => void +} + +export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) { + const [username, setUsername] = useState("") + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) + const { register, isLoading } = useAuth() + const router = useRouter() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (password !== confirmPassword) { + return + } + + try { + await register({ username, email, password }) + router.push("/") + } catch (error) { + // Error is handled by the auth context + } + } + + return ( + + +
+
+ +
+
+ 创建账户 + 注册新账户以开始使用 AI 聊天机器人 +
+ +
+
+ + setUsername(e.target.value)} + required + disabled={isLoading} + /> +
+ +
+ + setEmail(e.target.value)} + required + disabled={isLoading} + /> +
+ +
+ +
+ setPassword(e.target.value)} + required + disabled={isLoading} + /> + +
+
+ +
+ +
+ setConfirmPassword(e.target.value)} + required + disabled={isLoading} + /> + +
+ {password !== confirmPassword && confirmPassword &&

密码不匹配

} +
+ + +
+ +
+

+ 已有账户?{" "} + +

+
+
+
+ ) +} diff --git a/frontend-nextjs/frontend/components/chat-header.tsx b/frontend-nextjs/frontend/components/chat-header.tsx new file mode 100644 index 00000000..80e0ccad --- /dev/null +++ b/frontend-nextjs/frontend/components/chat-header.tsx @@ -0,0 +1,16 @@ +import { MessageSquare } from "lucide-react" + +interface ChatHeaderProps { + title?: string +} + +export function ChatHeader({ title }: ChatHeaderProps) { + return ( +
+
+ +

{title || "AI 聊天助手"}

+
+
+ ) +} diff --git a/frontend-nextjs/frontend/components/chat-input.tsx b/frontend-nextjs/frontend/components/chat-input.tsx new file mode 100644 index 00000000..70c2e6ef --- /dev/null +++ b/frontend-nextjs/frontend/components/chat-input.tsx @@ -0,0 +1,64 @@ +"use client" + +import type React from "react" + +import { useState, useRef, useEffect } from "react" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { Send } from "lucide-react" +import { cn } from "@/lib/utils" + +interface ChatInputProps { + onSendMessage: (message: string) => void + disabled?: boolean + placeholder?: string +} + +export function ChatInput({ onSendMessage, disabled = false, placeholder = "输入您的消息..." }: ChatInputProps) { + const [input, setInput] = useState("") + const textareaRef = useRef(null) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (input.trim() && !disabled) { + onSendMessage(input.trim()) + setInput("") + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + handleSubmit(e) + } + } + + // Auto-resize textarea + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.style.height = "auto" + textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px` + } + }, [input]) + + return ( +
+
+