diff --git a/.doc/data.sql b/.doc/data.sql new file mode 100644 index 0000000000000000000000000000000000000000..662fe4a72170f80b41d77088e33cc95fab88d6c6 --- /dev/null +++ b/.doc/data.sql @@ -0,0 +1,23 @@ +CREATE TABLE sys_user +( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID', + username VARCHAR(255) NOT NULL COMMENT '用户名', + password VARCHAR(255) NOT NULL COMMENT '密码', + email VARCHAR(255) COMMENT '邮箱', + phone VARCHAR(20) COMMENT '手机号', + real_name VARCHAR(100) COMMENT '真实姓名', + status INT NOT NULL DEFAULT 1 COMMENT '用户状态:0-禁用,1-启用', + role VARCHAR(50) NOT NULL DEFAULT 'USER' COMMENT '角色:ADMIN-管理员,USER-普通用户', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + last_login_time DATETIME COMMENT '最后登录时间' +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='用户表'; +-- 初始化用户数据 +-- 密码使用BCrypt加密,原始密码为 'password' +-- BCrypt哈希: $2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi. +INSERT INTO sys_user (id, username, password, email, phone, real_name, status, create_time, update_time) +VALUES (1, 'admin', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'admin@example.com', '13800138000', + '管理员', 1, NOW(), NOW()), + (2, 'user', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.', 'user@example.com', '13800138001', + '普通用户', 1, NOW(), NOW()); \ No newline at end of file diff --git a/qa-gateway/pom.xml b/qa-gateway/pom.xml index f98cdae55279c4aced668c9e7a4cdcff42159dd6..83a8dfa242fc3fd8fd98dbd5832e71bfc05ba81e 100644 --- a/qa-gateway/pom.xml +++ b/qa-gateway/pom.xml @@ -16,7 +16,15 @@ 2023.0.1 - + + com.github.xiaoymin + knife4j-gateway-spring-boot-starter + 4.5.0 + + + org.springframework.cloud + spring-cloud-starter-loadbalancer + com.alibaba.cloud spring-cloud-starter-alibaba-nacos-config @@ -35,6 +43,22 @@ spring-boot-starter-test test + + org.springframework.cloud + spring-cloud-starter-loadbalancer + + + org.springframework.boot + spring-boot + + + org.springframework.boot + spring-boot + + + org.springframework.boot + spring-boot-autoconfigure + @@ -104,4 +128,4 @@ - + \ No newline at end of file diff --git a/qa-gateway/src/main/resources/application.properties b/qa-gateway/src/main/resources/application.properties index 75b769985ce73886f48c5154b41a9eab2e21c5ad..a2a723cebd8c61a20d7739404fbd9e32b271a944 100644 --- a/qa-gateway/src/main/resources/application.properties +++ b/qa-gateway/src/main/resources/application.properties @@ -1,25 +1,20 @@ server.port=28080 spring.application.name=qa-gateway -# Nacos帮助文档: https://nacos.io/zh-cn/docs/concepts.html -# Nacos认证信息 + spring.cloud.nacos.config.username=nacos spring.cloud.nacos.config.password=nacos spring.cloud.nacos.config.contextPath=/nacos -# 设置配置中心服务端地址 spring.cloud.nacos.config.server-addr=192.168.168.128:8848 -# Nacos 配置中心的namespace。需要注意,如果使用 public 的 namcespace ,请不要填写这个值,直接留空即可 -# spring.cloud.nacos.config.namespace= -spring.config.import=nacos:nacos-config-example.properties?refresh=true -# 应用服务 WEB 访问端口 - -# Nacos帮助文档: https://nacos.io/zh-cn/docs/concepts.html +spring.config.import=nacos:${spring.application.name}.properties?refresh=true -# Nacos认证信息 spring.cloud.nacos.discovery.username=nacos spring.cloud.nacos.discovery.password=nacos -# Nacos 服务发现与注册配置,其中子属性 server-addr 指定 Nacos 服务器主机和端口 spring.cloud.nacos.discovery.server-addr=192.168.168.128:8848 -# 注册到 nacos 的指定 namespace,默认为 public spring.cloud.nacos.discovery.namespace=public +# ???? +spring.cloud.gateway.routes[0].id=user-service +spring.cloud.gateway.routes[0].uri=lb://user-service +spring.cloud.gateway.routes[0].predicates[0]=Path=/user-service/** +spring.cloud.gateway.routes[0].filters[0]=StripPrefix=1 diff --git a/qa-gateway/src/main/resources/static/index.html b/qa-gateway/src/main/resources/static/index.html deleted file mode 100644 index 89bb8ba4ff248cd6e3b8b5e2f89af4e3062a37a8..0000000000000000000000000000000000000000 --- a/qa-gateway/src/main/resources/static/index.html +++ /dev/null @@ -1,6 +0,0 @@ - - -

hello word!!!

-

this is a html page

- - \ No newline at end of file diff --git a/qa-service/docker-compose.yml b/qa-service/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..b92c130edf1cd7cac69d42ab559368b344fc1b40 --- /dev/null +++ b/qa-service/docker-compose.yml @@ -0,0 +1,250 @@ +networks: + app_net: + driver: bridge +services: + # MySQL 主库 + mysql: + image: mysql:8.4 + container_name: mysql + environment: + - MYSQL_ROOT_PASSWORD=root + - TZ=Asia/Shanghai + - MYSQL_CHARSET=utf8mb4 + - MYSQL_COLLATION=utf8mb4_general_ci + - MYSQL_ROOT_HOST=% + - MYSQL_SSL_MODE=REQUIRED + ports: + - "3306:3306" + privileged: true + volumes: + - ./mysql/conf.d:/etc/mysql/conf.d + - ./mysql/data:/var/lib/mysql + healthcheck: + test: [ "CMD", "mysqladmin", "ping", "-h", "localhost" ] + interval: 10s + timeout: 5s + retries: 5 + networks: + - app_net + restart: always + redis: + container_name: redis + image: redis:latest + ports: + - "6379:6379" + privileged: true + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 5s + timeout: 3s + retries: 5 + networks: + - app_net + restart: always + + rabbitmq: + container_name: rabbitmq + image: rabbitmq:4.1.2-management + ports: + - "5672:5672" + - "15672:15672" + privileged: true + healthcheck: + test: [ "CMD", "rabbitmqctl", "status" ] + interval: 30s + timeout: 10s + retries: 3 + networks: + - app_net + restart: always + minio: + image: quay.io/minio/minio:latest + container_name: minio + restart: always + ports: + - "9000:9000" # S3 API 端口 + - "9001:9001" # Web 控制台端口 + environment: + MINIO_ROOT_USER: admin + MINIO_ROOT_PASSWORD: admin123456 + volumes: + - ./minio-data:/data + command: server /data --console-address ":9001" + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ] + interval: 30s + timeout: 10s + retries: 3 + networks: + - app_net + # RocketMQ NameServer + rocketmq-namesrv: + image: apache/rocketmq:latest + container_name: rocketmq-namesrv + command: sh mqnamesrv + ports: + - "9876:9876" + environment: + JAVA_OPT_EXT: "-server -Xms128m -Xmx128m -Xmn128m" + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:9876" ] + interval: 30s + timeout: 10s + retries: 3 + networks: + - app_net + restart: always + + # RocketMQ Broker + rocketmq-broker: + image: apache/rocketmq:latest + container_name: rocketmq-broker + command: sh mqbroker -c /home/rocketmq/rocketmq-5.3.3/conf/broker.conf + ports: + - "10909:10909" + - "10911:10911" + - "10912:10912" + environment: + JAVA_OPT_EXT: "-server -Xms128m -Xmx128m -Xmn128m" + NAMESRV_ADDR: "rocketmq-namesrv:9876" + volumes: + - ./rocketmq/conf/broker.conf:/home/rocketmq/rocketmq-5.3.3/conf/broker.conf + - ./rocketmq/logs:/home/rocketmq/logs + - ./rocketmq/store:/home/rocketmq/store + depends_on: + - rocketmq-namesrv + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:10911" ] + interval: 30s + timeout: 10s + retries: 3 + networks: + - app_net + restart: always + + # Elasticsearch + elasticsearch: + image: elasticsearch:7.17.28 + container_name: elasticsearch + environment: + - discovery.type=single-node + - ES_JAVA_OPTS=-Xms512m -Xmx512m + - xpack.security.enabled=false + ports: + - "9200:9200" + - "9300:9300" + volumes: + - ./es/data:/usr/share/elasticsearch/data + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:9200" ] + interval: 30s + timeout: 10s + retries: 3 + networks: + - app_net + restart: always + + # Nginx + nginx: + image: nginx:latest + container_name: nginx + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/conf.d:/etc/nginx/conf.d + - ./nginx/html:/usr/share/nginx/html + - ./nginx/logs:/var/log/nginx + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost" ] + interval: 30s + timeout: 10s + retries: 3 + networks: + - app_net + restart: always + nacos: + container_name: nacos + image: nacos/nacos-server:v3.0.2 + ports: + - "8848:8848" + - "8080:8080" + - "9848:9848" + - "9849:9849" + environment: + - TZ=Asia/Shanghai + - MODE=standalone + - PREFER_HOST_MODE=hostname + - SPRING_DATASOURCE_PLATFORM=mysql + - MYSQL_SERVICE_HOST=mysql + - MYSQL_SERVICE_DB_NAME=nacos_config + - MYSQL_SERVICE_PORT=3306 + - MYSQL_SERVICE_USER=root + - MYSQL_SERVICE_PASSWORD=root + - MYSQL_SERVICE_DB_PARAM=characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + - NACOS_AUTH_IDENTITY_KEY=2222 + - NACOS_AUTH_IDENTITY_VALUE=2xxx + - NACOS_AUTH_TOKEN=VGhpc0lzTXlDdXN0b21TZWNyZXRLZXkwMTIzNDU2Nzg= + volumes: + - ./nacos/logs:/home/nacos/logs + depends_on: + mysql: + condition: service_healthy + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:8848/nacos" ] + interval: 30s + timeout: 10s + retries: 3 + networks: + - app_net + privileged: true + restart: always + # Seata + seata-server: + image: seataio/seata-server:1.6.0 + container_name: seata-server + ports: + - "7091:7091" + - "8091:8091" + networks: + - app_net + restart: always + + # Sentinel + sentinel: + image: bladex/sentinel-dashboard:latest + container_name: sentinel + ports: + - "8858:8858" + environment: + - JAVA_OPTS=-Dserver.port=8858 -Dcsp.sentinel.dashboard.server=localhost:8858 -Dproject.name=sentinel-dashboard + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:8858" ] + interval: 30s + timeout: 10s + retries: 3 + networks: + - app_net + restart: always + leaf-server: + image: registry.cn-hangzhou.aliyuncs.com/itheima/meituan-leaf:1.0.1 + container_name: leaf-server + ports: + - "8090:8080" + environment: + - SPRING_PROFILES_ACTIVE=prod + volumes: + - ./leaf/application.properties:/leaf-server/config/application.properties + networks: + - app_net + + zookeeper: + image: bitnami/zookeeper:latest + container_name: zookeeper + ports: + - "2181:2181" + environment: + - ALLOW_ANONYMOUS_LOGIN=yes + networks: + - app_net + restart: always \ No newline at end of file diff --git a/qa-service/nacos_config.sql b/qa-service/nacos_config.sql new file mode 100644 index 0000000000000000000000000000000000000000..433a90b94a37d64ee738e9cc23d4fd82a1663551 --- /dev/null +++ b/qa-service/nacos_config.sql @@ -0,0 +1,179 @@ +/* + * Copyright 1999-2018 Alibaba Group Holding Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/******************************************/ +/* 表名称 = config_info */ +/******************************************/ +CREATE TABLE `config_info` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id', + `data_id` varchar(255) NOT NULL COMMENT 'data_id', + `group_id` varchar(128) DEFAULT NULL COMMENT 'group_id', + `content` longtext NOT NULL COMMENT 'content', + `md5` varchar(32) DEFAULT NULL COMMENT 'md5', + `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间', + `src_user` text COMMENT 'source user', + `src_ip` varchar(50) DEFAULT NULL COMMENT 'source ip', + `app_name` varchar(128) DEFAULT NULL COMMENT 'app_name', + `tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段', + `c_desc` varchar(256) DEFAULT NULL COMMENT 'configuration description', + `c_use` varchar(64) DEFAULT NULL COMMENT 'configuration usage', + `effect` varchar(64) DEFAULT NULL COMMENT '配置生效的描述', + `type` varchar(64) DEFAULT NULL COMMENT '配置的类型', + `c_schema` text COMMENT '配置的模式', + `encrypted_data_key` varchar(1024) NOT NULL DEFAULT '' COMMENT '密钥', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_configinfo_datagrouptenant` (`data_id`,`group_id`,`tenant_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_info'; + +/******************************************/ +/* 表名称 = config_info since 2.5.0 */ +/******************************************/ +CREATE TABLE `config_info_gray` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 'id', + `data_id` varchar(255) NOT NULL COMMENT 'data_id', + `group_id` varchar(128) NOT NULL COMMENT 'group_id', + `content` longtext NOT NULL COMMENT 'content', + `md5` varchar(32) DEFAULT NULL COMMENT 'md5', + `src_user` text COMMENT 'src_user', + `src_ip` varchar(100) DEFAULT NULL COMMENT 'src_ip', + `gmt_create` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'gmt_create', + `gmt_modified` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'gmt_modified', + `app_name` varchar(128) DEFAULT NULL COMMENT 'app_name', + `tenant_id` varchar(128) DEFAULT '' COMMENT 'tenant_id', + `gray_name` varchar(128) NOT NULL COMMENT 'gray_name', + `gray_rule` text NOT NULL COMMENT 'gray_rule', + `encrypted_data_key` varchar(256) NOT NULL DEFAULT '' COMMENT 'encrypted_data_key', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_configinfogray_datagrouptenantgray` (`data_id`,`group_id`,`tenant_id`,`gray_name`), + KEY `idx_dataid_gmt_modified` (`data_id`,`gmt_modified`), + KEY `idx_gmt_modified` (`gmt_modified`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='config_info_gray'; + +/******************************************/ +/* 表名称 = config_tags_relation */ +/******************************************/ +CREATE TABLE `config_tags_relation` ( + `id` bigint(20) NOT NULL COMMENT 'id', + `tag_name` varchar(128) NOT NULL COMMENT 'tag_name', + `tag_type` varchar(64) DEFAULT NULL COMMENT 'tag_type', + `data_id` varchar(255) NOT NULL COMMENT 'data_id', + `group_id` varchar(128) NOT NULL COMMENT 'group_id', + `tenant_id` varchar(128) DEFAULT '' COMMENT 'tenant_id', + `nid` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'nid, 自增长标识', + PRIMARY KEY (`nid`), + UNIQUE KEY `uk_configtagrelation_configidtag` (`id`,`tag_name`,`tag_type`), + KEY `idx_tenant_id` (`tenant_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_tag_relation'; + +/******************************************/ +/* 表名称 = group_capacity */ +/******************************************/ +CREATE TABLE `group_capacity` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `group_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Group ID,空字符表示整个集群', + `quota` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '配额,0表示使用默认值', + `usage` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '使用量', + `max_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个配置大小上限,单位为字节,0表示使用默认值', + `max_aggr_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '聚合子配置最大个数,,0表示使用默认值', + `max_aggr_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个聚合数据的子配置大小上限,单位为字节,0表示使用默认值', + `max_history_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '最大变更历史数量', + `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_group_id` (`group_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='集群、各Group容量信息表'; + +/******************************************/ +/* 表名称 = his_config_info */ +/******************************************/ +CREATE TABLE `his_config_info` ( + `id` bigint(20) unsigned NOT NULL COMMENT 'id', + `nid` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'nid, 自增标识', + `data_id` varchar(255) NOT NULL COMMENT 'data_id', + `group_id` varchar(128) NOT NULL COMMENT 'group_id', + `app_name` varchar(128) DEFAULT NULL COMMENT 'app_name', + `content` longtext NOT NULL COMMENT 'content', + `md5` varchar(32) DEFAULT NULL COMMENT 'md5', + `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间', + `src_user` text COMMENT 'source user', + `src_ip` varchar(50) DEFAULT NULL COMMENT 'source ip', + `op_type` char(10) DEFAULT NULL COMMENT 'operation type', + `tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段', + `encrypted_data_key` varchar(1024) NOT NULL DEFAULT '' COMMENT '密钥', + `publish_type` varchar(50) DEFAULT 'formal' COMMENT 'publish type gray or formal', + `gray_name` varchar(50) DEFAULT NULL COMMENT 'gray name', + `ext_info` longtext DEFAULT NULL COMMENT 'ext info', + PRIMARY KEY (`nid`), + KEY `idx_gmt_create` (`gmt_create`), + KEY `idx_gmt_modified` (`gmt_modified`), + KEY `idx_did` (`data_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='多租户改造'; + + +/******************************************/ +/* 表名称 = tenant_capacity */ +/******************************************/ +CREATE TABLE `tenant_capacity` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `tenant_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Tenant ID', + `quota` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '配额,0表示使用默认值', + `usage` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '使用量', + `max_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个配置大小上限,单位为字节,0表示使用默认值', + `max_aggr_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '聚合子配置最大个数', + `max_aggr_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个聚合数据的子配置大小上限,单位为字节,0表示使用默认值', + `max_history_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '最大变更历史数量', + `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_tenant_id` (`tenant_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='租户容量信息表'; + + +CREATE TABLE `tenant_info` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id', + `kp` varchar(128) NOT NULL COMMENT 'kp', + `tenant_id` varchar(128) default '' COMMENT 'tenant_id', + `tenant_name` varchar(128) default '' COMMENT 'tenant_name', + `tenant_desc` varchar(256) DEFAULT NULL COMMENT 'tenant_desc', + `create_source` varchar(32) DEFAULT NULL COMMENT 'create_source', + `gmt_create` bigint(20) NOT NULL COMMENT '创建时间', + `gmt_modified` bigint(20) NOT NULL COMMENT '修改时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_tenant_info_kptenantid` (`kp`,`tenant_id`), + KEY `idx_tenant_id` (`tenant_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='tenant_info'; + +CREATE TABLE `users` ( + `username` varchar(50) NOT NULL PRIMARY KEY COMMENT 'username', + `password` varchar(500) NOT NULL COMMENT 'password', + `enabled` boolean NOT NULL COMMENT 'enabled' +); + +CREATE TABLE `roles` ( + `username` varchar(50) NOT NULL COMMENT 'username', + `role` varchar(50) NOT NULL COMMENT 'role', + UNIQUE INDEX `idx_user_role` (`username` ASC, `role` ASC) USING BTREE +); + +CREATE TABLE `permissions` ( + `role` varchar(50) NOT NULL COMMENT 'role', + `resource` varchar(128) NOT NULL COMMENT 'resource', + `action` varchar(8) NOT NULL COMMENT 'action', + UNIQUE INDEX `uk_role_permission` (`role`,`resource`,`action`) USING BTREE +); + diff --git a/qa-service/pom.xml b/qa-service/pom.xml index 476ca31ee84446204ef71aedf1d07bdd6d774ad2..7ba8bd5db047647fb746dec45f803f167ed4d017 100644 --- a/qa-service/pom.xml +++ b/qa-service/pom.xml @@ -11,22 +11,34 @@ 21 UTF-8 UTF-8 - 3.0.2 + 3.2.4 + 2023.0.1.0 + 2023.0.1 + + pom + + + qa-service-bootstrap + qa-service-adapter + qa-service-application + qa-service-domain + qa-service-common + + + - - org.springframework.boot - spring-boot-starter - - - org.springframework.boot - spring-boot-starter-test - test - + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + org.springframework.boot spring-boot-dependencies @@ -34,6 +46,19 @@ pom import + + com.alibaba.cloud + spring-cloud-alibaba-dependencies + ${spring-cloud-alibaba.version} + pom + import + + + + com.baomidou + mybatis-plus-spring-boot3-starter + 3.5.14 + @@ -54,7 +79,7 @@ spring-boot-maven-plugin ${spring-boot.version} - com.example.qa.service.QaServiceApplication + com.example.qa-service.qa-serviceApplication true diff --git a/qa-service/qa-service-adapter/pom.xml b/qa-service/qa-service-adapter/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..0074d0c8f60c8bf3868ca60caccd07f5c42eb7fd --- /dev/null +++ b/qa-service/qa-service-adapter/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + com.example + qa-service-adapter + 0.0.1-SNAPSHOT + qa-service-adapter + qa-service-adapter + + pom + + + com.example + qa-service + 0.0.1-SNAPSHOT + + + + qa-adapter-in + qa-adapter-out + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 21 + 21 + UTF-8 + + + + + + diff --git a/qa-gateway/.gitignore b/qa-service/qa-service-adapter/qa-adapter-in/.gitignore similarity index 100% rename from qa-gateway/.gitignore rename to qa-service/qa-service-adapter/qa-adapter-in/.gitignore diff --git a/qa-service/qa-service-adapter/qa-adapter-in/pom.xml b/qa-service/qa-service-adapter/qa-adapter-in/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..473ef75673f90161aee8129d1094187b03635b29 --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-in/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + com.example + qa-adapter-in + 0.0.1-SNAPSHOT + qa-adapter-in + qa-adapter-in + + pom + + + com.example + qa-service-adapter + 0.0.1-SNAPSHOT + + + + qa-adapter-in-web + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 21 + 21 + UTF-8 + + + + + + diff --git a/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/pom.xml b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..acfa5683505a82c832405c58ba42252c12217618 --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/pom.xml @@ -0,0 +1,76 @@ + + + 4.0.0 + com.example + qa-adapter-in-web + 0.0.1-SNAPSHOT + qa-adapter-in-web + qa-adapter-in-web + + + com.example + qa-adapter-in + 0.0.1-SNAPSHOT + + + + + org.springframework.boot + spring-boot-starter-web + + + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.projectlombok + lombok + provided + + + org.projectlombok + lombok + + + + com.example + qa-service-application + 0.0.1-SNAPSHOT + + + + com.example + qa-adapter-out-persistence + 0.0.1-SNAPSHOT + + + + com.github.xiaoymin + knife4j-openapi3-jakarta-spring-boot-starter + 4.4.0 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 21 + 21 + UTF-8 + + + + + + diff --git a/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/controller/AsyncQAController.java b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/controller/AsyncQAController.java new file mode 100644 index 0000000000000000000000000000000000000000..f56f329f637763843c50e6b6bf00499f816a1914 --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/controller/AsyncQAController.java @@ -0,0 +1,64 @@ +package com.example.qa.adapter.in.web.controller; + +import com.example.qa.adapter.in.web.dto.AskQuestionRequestDTO; +import com.example.qa.adapter.in.web.dto.AskQuestionResponseDTO; +import com.example.qa.service.domain.port.MessageQueuePort; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +/** + * 异步问答控制器 + * 处理用户提交的问题,通过消息队列异步处理 + * @author yangwenkai + */ +@Slf4j +@RestController +@RequestMapping("/api/qa/async") +@RequiredArgsConstructor +public class AsyncQAController { + + /** + * 消息队列端口,用于发送问题到队列进行异步处理 + */ + private final MessageQueuePort messageQueuePort; + + /** + * 提交问题接口 + * 接收用户问题并发送到消息队列进行异步处理 + * 不等待AI回答,立即返回提交成功的响应 + * @param request 包含用户问题的请求对象 + * @return 问题提交结果 + */ + @PostMapping("/ask") + public AskQuestionResponseDTO askQuestion(@Valid @RequestBody AskQuestionRequestDTO request) { + log.info("收到用户问题: {}", request.question()); + + try { + // 将问题发送到消息队列进行异步处理 + messageQueuePort.sendQuestionToQueue(request.question()); + + log.info("问题已发送到队列: {}", request.question()); + + // 立即返回成功响应,不等待AI处理结果 + return AskQuestionResponseDTO.success(request.question()); + + } catch (Exception e) { + log.error("提交问题失败: {}", request.question(), e); + + // 返回失败响应 + return AskQuestionResponseDTO.failure(request.question(), "系统繁忙,请稍后重试"); + } + } + + /** + * 健康检查接口 + * 用于检查异步问答服务是否正常运行 + * @return 服务状态信息 + */ + @GetMapping("/health") + public String health() { + return "异步问答服务运行正常"; + } +} \ No newline at end of file diff --git a/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/controller/QAController.java b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/controller/QAController.java new file mode 100644 index 0000000000000000000000000000000000000000..f966d2098ea4f0f4eafa2f03a2cba9c59bd0bc15 --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/controller/QAController.java @@ -0,0 +1,81 @@ +package com.example.qa.adapter.in.web.controller; + +import com.example.application.command.CreateQACommand; +import com.example.application.command.UpdateQACommand; +import com.example.application.port.in.*; +import com.example.qa.adapter.in.web.dto.CreateQARequestDTO; +import com.example.qa.adapter.in.web.dto.QAResponseDTO; +import com.example.qa.adapter.in.web.dto.UpdateQARequestDTO; +import com.example.qa.service.domain.QA; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Slf4j +@RequestMapping("/qas") +@RestController +@RequiredArgsConstructor + +public class QAController { + private final CreateQAUseCase createQAUseCase; + private final DeleteQAUseCase deleteQAUseCase; + private final GetQAByIdUseCase getQAByIdUseCase; + private final GetQAListUseCase getQAListUseCase; + private final UpdateQAUseCase updateQAUseCase; + /** + * @author wangqingqing + */ + @GetMapping("") + public List getQAs(){ + return getQAListUseCase.getQAs(); + } + + /** + * @author yangwenkai + * 创建QA的方法 + */ + @PostMapping() + public QA createQA(@RequestBody CreateQARequestDTO createQARequestDTO){ + CreateQACommand command = CreateQACommand.builder() + .question(createQARequestDTO.question()) + .answer(createQARequestDTO.answer()) + .build(); + return createQAUseCase.createQA(command); + } + /** + * @author qiuyujie + */ + @DeleteMapping("{id}") + public String deleteQA(@PathVariable("id") long id){ + deleteQAUseCase.deleteQA(id); + return "success"; + } + /** + * @author lizishan + */ + @PutMapping("") + public QA updateQA(@RequestBody UpdateQARequestDTO updateQARequestDTO){ + UpdateQACommand command= UpdateQACommand.builder() + .id(updateQARequestDTO.id()) + .question(updateQARequestDTO.question()) + .answer(updateQARequestDTO.answer()) + .build(); + QA qa = updateQAUseCase.updateQA(command); + return qa; + } + /** + * @author dengli + */ + @GetMapping("{id}") + public QAResponseDTO getQAById(@PathVariable("id")long id){ + QA qa=getQAByIdUseCase.getQAById(id); + QAResponseDTO qaresponseDTO=new QAResponseDTO( + qa.getId().id(), + qa.getQuestion().question(), + qa.getAnswer().answer() + ); + return qaresponseDTO; + } +} diff --git a/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/AskQuestionRequestDTO.java b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/AskQuestionRequestDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..dd632e4e49a79dde22380a3a379d985d91f582fb --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/AskQuestionRequestDTO.java @@ -0,0 +1,20 @@ +package com.example.qa.adapter.in.web.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * 异步问答请求DTO + * 用于接收用户提交的问题 + * @author qiuyujie dengli wangqingqing lizishan + * @param question 用户提出的问题 + */ +public record AskQuestionRequestDTO( + /** + * 问题内容,不能为空且长度在1-500字符之间 + */ + @NotBlank(message = "问题不能为空") + @Size(min = 1, max = 500, message = "问题长度必须在1-500字符之间") + String question +) { +} \ No newline at end of file diff --git a/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/AskQuestionResponseDTO.java b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/AskQuestionResponseDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..97ca1348234adc368227248a2005033cf068c4e0 --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/AskQuestionResponseDTO.java @@ -0,0 +1,46 @@ +package com.example.qa.adapter.in.web.dto; + +/** + * 异步问答响应DTO + * 用于返回问题提交的结果 + * @author qiuyujie dengli wangqingqing lizishan + * @param success 是否提交成功 + * @param message 响应消息 + * @param question 提交的问题 + */ +public record AskQuestionResponseDTO( + /** + * 是否提交成功 + */ + boolean success, + + /** + * 响应消息 + */ + String message, + + /** + * 提交的问题 + */ + String question +) { + + /** + * 创建成功响应 + * @param question 提交的问题 + * @return 成功响应DTO + */ + public static AskQuestionResponseDTO success(String question) { + return new AskQuestionResponseDTO(true, "问题已提交,正在处理中...", question); + } + + /** + * 创建失败响应 + * @param question 提交的问题 + * @param errorMessage 错误消息 + * @return 失败响应DTO + */ + public static AskQuestionResponseDTO failure(String question, String errorMessage) { + return new AskQuestionResponseDTO(false, errorMessage, question); + } +} \ No newline at end of file diff --git a/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/ChatCompletionRequestDTO.java b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/ChatCompletionRequestDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..c9c9a9ca95f2332dc79deb0c054127947b4fd1ad --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/ChatCompletionRequestDTO.java @@ -0,0 +1,49 @@ +package com.example.qa.adapter.in.web.dto; + +import lombok.Data; +import java.util.List; + +/** + * 聊天完成请求DTO + * 用于封装发送给KIMI AI的请求参数 + * @author yangwenkai + */ +@Data +public class ChatCompletionRequestDTO { + + /** + * 使用的模型名称,例如:moonshot-v1-8k + */ + private String model; + + /** + * 消息列表,包含系统消息和用户消息 + */ + private List messages; + + /** + * 生成文本的随机性,0-2之间,默认1 + */ + private double temperature = 1.0; + + /** + * 最大生成token数量 + */ + private int max_tokens = 1000; + + /** + * 默认构造函数 + */ + public ChatCompletionRequestDTO() { + } + + /** + * 带参数的构造函数 + * @param model 模型名称 + * @param messages 消息列表 + */ + public ChatCompletionRequestDTO(String model, List messages) { + this.model = model; + this.messages = messages; + } +} \ No newline at end of file diff --git a/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/ChatCompletionResponseDTO.java b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/ChatCompletionResponseDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..b9806479097d5f6ce16d38326a837397dfea3aeb --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/ChatCompletionResponseDTO.java @@ -0,0 +1,64 @@ +package com.example.qa.adapter.in.web.dto; + +import lombok.Data; +import java.util.List; + +/** + * 聊天完成响应DTO + * 用于封装KIMI AI返回的响应数据 + * @author yangwenkai + */ +@Data +public class ChatCompletionResponseDTO { + + /** + * 响应ID + */ + private String id; + + /** + * 对象类型,通常为"chat.completion" + */ + private String object; + + /** + * 创建时间戳 + */ + private long created; + + /** + * 使用的模型名称 + */ + private String model; + + /** + * 选择列表,包含AI生成的回答 + */ + private List choices; + + /** + * 使用情况统计(可选) + */ + private UsageDTO usage; + + /** + * 使用情况DTO + */ + @Data + public static class UsageDTO { + /** + * 提示token数量 + */ + private int prompt_tokens; + + /** + * 完成token数量 + */ + private int completion_tokens; + + /** + * 总token数量 + */ + private int total_tokens; + } +} \ No newline at end of file diff --git a/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/ChoiceDTO.java b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/ChoiceDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..c8505644074e241a499fc64fd21dbed1b74b88be --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/ChoiceDTO.java @@ -0,0 +1,27 @@ +package com.example.qa.adapter.in.web.dto; + +import lombok.Data; + +/** + * 选择DTO + * 用于封装KIMI AI返回的选择项,包含索引、消息和结束原因 + * @author yangwenkai + */ +@Data +public class ChoiceDTO { + + /** + * 选择项的索引 + */ + private int index; + + /** + * AI生成的消息 + */ + private MessageDTO message; + + /** + * 结束原因:stop(正常结束)、length(长度限制)等 + */ + private String finish_reason; +} \ No newline at end of file diff --git a/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/CreateQARequestDTO.java b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/CreateQARequestDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..fff74267e4610b0143ccabe8cd7abb18889b9eac --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/CreateQARequestDTO.java @@ -0,0 +1,10 @@ +package com.example.qa.adapter.in.web.dto; + +/** + * @author yangwenkai + * 创建QA的传输数据 + */ +public record CreateQARequestDTO( + String question, + String answer) { +} diff --git a/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/MessageDTO.java b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/MessageDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..579697aea8c94871a6010ac03dfe504217e3178c --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/MessageDTO.java @@ -0,0 +1,38 @@ +package com.example.qa.adapter.in.web.dto; + +import lombok.Data; + +/** + * 消息DTO + * 用于封装聊天消息的角色和内容,发送给KIMI AI + * @author yangwenkai + */ +@Data +public class MessageDTO { + + /** + * 消息角色:system(系统)、user(用户)、assistant(助手) + */ + private String role; + + /** + * 消息内容 + */ + private String content; + + /** + * 默认构造函数 + */ + public MessageDTO() { + } + + /** + * 带参数的构造函数 + * @param role 消息角色 + * @param content 消息内容 + */ + public MessageDTO(String role, String content) { + this.role = role; + this.content = content; + } +} \ No newline at end of file diff --git a/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/QAResponseDTO.java b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/QAResponseDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..50e781d3e6de8780b3a0a26a6ee399eb1b2dfd03 --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/QAResponseDTO.java @@ -0,0 +1,18 @@ +package com.example.qa.adapter.in.web.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +/** + * @author yangwenkai + * QA的封装响应参数 + */ +public class QAResponseDTO { + private Long id; + private String question; + private String answer; +} diff --git a/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/UpdateQARequestDTO.java b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/UpdateQARequestDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..b18c0f801d844f1cdf9e700688e5eab2520c8809 --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-in/qa-adapter-in-web/src/main/java/com/example/qa/adapter/in/web/dto/UpdateQARequestDTO.java @@ -0,0 +1,12 @@ +package com.example.qa.adapter.in.web.dto; + +/** + * @author yangwenkai + * 修改QA的传输数据 + */ +public record UpdateQARequestDTO( + long id, + String question, + String answer +) { +} diff --git a/qa-service/qa-service-adapter/qa-adapter-out/pom.xml b/qa-service/qa-service-adapter/qa-adapter-out/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..21c1729c59185cc8519eaf6616c22d484b88afb0 --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-out/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + com.example + qa-adapter-out + 0.0.1-SNAPSHOT + qa-adapter-out + qa-adapter-out + + pom + + + com.example + qa-service-adapter + 0.0.1-SNAPSHOT + + + + qa-adapter-out-persistence + qa-adapter-out-external + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 21 + 21 + UTF-8 + + + + + + diff --git a/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-external/pom.xml b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-external/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..be3fcb3858d8a243a28fcc58621defba410cf963 --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-external/pom.xml @@ -0,0 +1,68 @@ + + + 4.0.0 + com.example + qa-adapter-out-external + 0.0.1-SNAPSHOT + qa-adapter-out-external + qa-adapter-out-external + + com.example + qa-adapter-out + 0.0.1-SNAPSHOT + + + + + org.projectlombok + lombok + provided + + + + com.example + qa-service-domain + 0.0.1-SNAPSHOT + + + + com.example + qa-service-common + 0.0.1-SNAPSHOT + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-amqp + + + com.example + qa-adapter-in-web + 0.0.1-SNAPSHOT + compile + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 21 + 21 + UTF-8 + + + + + + \ No newline at end of file diff --git a/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-external/src/main/java/com/example/qa/adapter/out/external/KimiAiAdapter.java b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-external/src/main/java/com/example/qa/adapter/out/external/KimiAiAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..b583c8cc44de45db76c083ecfccb54ebc31f200e --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-external/src/main/java/com/example/qa/adapter/out/external/KimiAiAdapter.java @@ -0,0 +1,108 @@ +package com.example.qa.adapter.out.external; + +import com.example.qa.adapter.in.web.dto.ChatCompletionRequestDTO; +import com.example.qa.adapter.in.web.dto.ChatCompletionResponseDTO; +import com.example.qa.adapter.in.web.dto.ChoiceDTO; +import com.example.qa.adapter.in.web.dto.MessageDTO; +import com.example.qa.service.domain.port.KimiAiPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.Arrays; +import java.util.List; + +/** + * KIMI AI适配器实现 + * 实现与KIMI AI服务的交互,将领域端口转换为具体的HTTP调用 + * @author yangwenkai + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class KimiAiAdapter implements KimiAiPort { + + /** + * REST模板,用于发送HTTP请求 + */ + private final RestTemplate restTemplate; + + /** + * KIMI AI服务的URL地址 + */ + @Value("${moonshot.url}") + private String kimiUrl; + + /** + * KIMI AI服务的API密钥 + */ + @Value("${moonshot.key}") + private String kimiKey; + + /** + * KIMI AI使用的模型名称 + */ + @Value("${moonshot.model}") + private String kimiModel; + + /** + * 调用KIMI AI获取问题的答案 + * 构建请求参数,发送HTTP请求,解析响应结果 + * @param question 用户提出的问题 + * @return AI生成的答案 + */ + @Override + public String getAnswer(String question) { + log.info("开始调用KIMI AI,问题: {}", question); + + try { + // 创建请求头,设置内容类型和授权信息 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + kimiKey); + + // 创建请求体,包含模型、消息和温度参数 + ChatCompletionRequestDTO request = new ChatCompletionRequestDTO(); + request.setModel(kimiModel); + request.setTemperature(0.6); + + // 创建消息列表,包含系统提示和用户问题 + MessageDTO systemMessage = new MessageDTO("system", + "你是 Kimi,由 Moonshot AI 提供的人工智能助手,你更擅长中文和英文的对话。你会为用户提供安全,有帮助,准确的回答。同时,你会拒绝一切涉及恐怖主义,种族歧视,黄色暴力等问题的回答。Moonshot AI 为专有名词,不可翻译成其他语言。"); + MessageDTO userMessage = new MessageDTO("user", question); + + request.setMessages(Arrays.asList(systemMessage, userMessage)); + + // 发送HTTP请求到KIMI AI服务 + HttpEntity response = restTemplate.exchange( + kimiUrl, + HttpMethod.POST, + new HttpEntity<>(request, headers), + ChatCompletionResponseDTO.class + ); + + // 解析响应,提取AI生成的答案 + ChatCompletionResponseDTO responseBody = response.getBody(); + if (responseBody != null && responseBody.getChoices() != null && !responseBody.getChoices().isEmpty()) { + ChoiceDTO firstChoice = responseBody.getChoices().get(0); + String answer = firstChoice.getMessage().getContent(); + + log.info("KIMI AI调用成功,问题: {}, 答案: {}", question, answer); + return answer; + } else { + log.warn("KIMI AI返回空响应,问题: {}", question); + return "抱歉,AI服务暂时无法提供答案"; + } + + } catch (Exception e) { + log.error("调用KIMI AI失败,问题: {}", question, e); + return "抱歉,AI服务暂时不可用,请稍后重试"; + } + } +} \ No newline at end of file diff --git a/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-external/src/main/java/com/example/qa/adapter/out/external/RabbitMqAdapter.java b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-external/src/main/java/com/example/qa/adapter/out/external/RabbitMqAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..0e5686bfca4eadcac42d31168f38b03ab4991486 --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-external/src/main/java/com/example/qa/adapter/out/external/RabbitMqAdapter.java @@ -0,0 +1,78 @@ +package com.example.qa.adapter.out.external; + +import com.example.qa.service.domain.valueobject.QAMessageDTO; +import com.example.qa.service.domain.port.MessageQueuePort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Component; + +/** + * RabbitMQ适配器实现 + * 实现与RabbitMQ消息队列的交互,将领域端口转换为具体的消息发送操作 + * @author yangwenkai + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RabbitMqAdapter implements MessageQueuePort { + + /** + * RabbitMQ模板,用于发送消息到队列 + */ + private final RabbitTemplate rabbitTemplate; + + /** + * 问题队列名称,用于异步处理用户问题 + */ + private static final String QUESTION_QUEUE = "qa.question.queue"; + + /** + * 答案队列名称,用于保存问答对到数据库 + */ + private static final String ANSWER_QUEUE = "qa.answer.queue"; + + /** + * 发送问题到消息队列进行异步处理 + * 将用户问题发送到问题队列,由消息监听器异步处理 + * @param question 用户提出的问题 + */ + @Override + public void sendQuestionToQueue(String question) { + log.info("发送问题到消息队列: {}", question); + + try { + // 将问题发送到问题队列 + rabbitTemplate.convertAndSend(QUESTION_QUEUE, question); + log.info("问题发送成功,队列: {}, 问题: {}", QUESTION_QUEUE, question); + + } catch (Exception e) { + log.error("发送问题到消息队列失败,问题: {}", question, e); + throw new RuntimeException("消息队列发送失败", e); + } + } + + /** + * 发送问答对到消息队列进行数据库保存 + * 将问题和答案组成的问答对发送到答案队列,由监听器保存到数据库 + * @param question 问题 + * @param answer 答案 + */ + @Override + public void sendQAToSaveQueue(String question, String answer) { + log.info("发送问答对到保存队列,问题: {}, 答案: {}", question, answer); + + try { + // 创建问答消息对象 + QAMessageDTO qaMessage = new QAMessageDTO(question, answer); + + // 将问答对发送到答案队列 + rabbitTemplate.convertAndSend(ANSWER_QUEUE, qaMessage); + log.info("问答对发送成功,队列: {}, 问题: {}, 答案: {}", ANSWER_QUEUE, question, answer); + + } catch (Exception e) { + log.error("发送问答对到保存队列失败,问题: {}, 答案: {}", question, answer, e); + throw new RuntimeException("消息队列发送失败", e); + } + } +} \ No newline at end of file diff --git a/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-external/src/main/java/com/example/qa/adapter/out/external/config/RabbitMqConfig.java b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-external/src/main/java/com/example/qa/adapter/out/external/config/RabbitMqConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..d024aa4e7d9e85f3dbe5f9614dbb72e67769d655 --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-external/src/main/java/com/example/qa/adapter/out/external/config/RabbitMqConfig.java @@ -0,0 +1,100 @@ +package com.example.qa.adapter.out.external.config; + +import com.example.qa.service.domain.valueobject.QAMessageDTO; +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.converter.DefaultJackson2JavaTypeMapper; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.HashMap; +import java.util.Map; + +/** + * RabbitMQ配置类 + * 配置消息队列、消息转换器和RabbitTemplate + * @author yangwenkai + */ +@Configuration +public class RabbitMqConfig { + + /** + * 问题队列名称 + */ + public static final String QUESTION_QUEUE = "qa.question.queue"; + + /** + * 答案队列名称 + */ + public static final String ANSWER_QUEUE = "qa.answer.queue"; + + /** + * 创建问题队列 + * 用于接收用户提交的问题,进行异步处理 + * @return 问题队列 + */ + @Bean + public Queue questionQueue() { + return new Queue(QUESTION_QUEUE, true); // durable=true 持久化队列 + } + + /** + * 创建答案队列 + * 用于接收问答对,进行数据库保存 + * @return 答案队列 + */ + @Bean + public Queue answerQueue() { + return new Queue(ANSWER_QUEUE, true); // durable=true 持久化队列 + } + + /** + * 配置消息转换器 + * 支持JSON格式的消息序列化和反序列化 + * @return JSON消息转换器 + */ + @Bean + public MessageConverter messageConverter() { + Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter(); + + // 配置类型映射器,支持自定义对象的反序列化 + DefaultJackson2JavaTypeMapper typeMapper = new DefaultJackson2JavaTypeMapper(); + Map> idClassMapping = new HashMap<>(); + + // 添加QAMessageDTO类的映射,确保消息能正确反序列化 + idClassMapping.put("com.example.qa.service.domain.valueobject.QAMessageDTO", QAMessageDTO.class); + typeMapper.setIdClassMapping(idClassMapping); + + // 设置信任的包路径,允许反序列化指定包下的类 + typeMapper.setTrustedPackages("com.example.qa.service.domain.valueobject"); + converter.setJavaTypeMapper(typeMapper); + + return converter; + } + + /** + * 配置RabbitTemplate + * 设置消息转换器,确保消息能正确序列化和发送 + * @param connectionFactory 连接工厂 + * @return 配置好的RabbitTemplate + */ + @Bean + public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) { + RabbitTemplate template = new RabbitTemplate(connectionFactory); + template.setMessageConverter(messageConverter()); + return template; + } + + /** + * 配置RestTemplate + * 用于KIMI AI的HTTP调用 + * @return RestTemplate实例 + */ + @Bean + public org.springframework.web.client.RestTemplate restTemplate() { + return new org.springframework.web.client.RestTemplate(); + } +} \ No newline at end of file diff --git a/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-external/src/main/java/com/example/qa/adapter/out/external/listener/QAMessageListener.java b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-external/src/main/java/com/example/qa/adapter/out/external/listener/QAMessageListener.java new file mode 100644 index 0000000000000000000000000000000000000000..c0a38a0880557ad2147d5cb1cd77088afbacd989 --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-external/src/main/java/com/example/qa/adapter/out/external/listener/QAMessageListener.java @@ -0,0 +1,81 @@ +package com.example.qa.adapter.out.external.listener; + +import com.example.qa.service.domain.valueobject.QAMessageDTO; +import com.example.qa.service.domain.port.KimiAiPort; +import com.example.qa.service.domain.port.MessageQueuePort; +import com.example.qa.service.domain.port.QARepositoryPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; + +/** + * 问答消息监听器 + * 监听RabbitMQ队列中的消息,处理问题和保存问答对 + * @author yangwenkai + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class QAMessageListener { + + /** + * KIMI AI端口,用于获取问题答案 + */ + private final KimiAiPort kimiAiPort; + + /** + * 消息队列端口,用于发送问答对到保存队列 + */ + private final MessageQueuePort messageQueuePort; + + /** + * 问答仓储端口,用于保存问答对到数据库 + */ + private final QARepositoryPort qaRepositoryPort; + + /** + * 监听问题队列,处理用户提交的问题 + * 接收问题后调用KIMI AI获取答案,然后发送到保存队列 + * @param question 用户提交的问题 + */ + @RabbitListener(queues = "qa.question.queue") + public void handleQuestion(String question) { + log.info("收到问题消息: {}", question); + + try { + // 调用KIMI AI获取答案 + String answer = kimiAiPort.getAnswer(question); + log.info("获取到答案,问题: {}, 答案: {}", question, answer); + + // 将问答对发送到保存队列 + messageQueuePort.sendQAToSaveQueue(question, answer); + log.info("问答对已发送到保存队列,问题: {}", question); + + } catch (Exception e) { + log.error("处理问题失败,问题: {}", question, e); + // 这里可以考虑重试机制或者死信队列处理 + } + } + + /** + * 监听答案队列,保存问答对到数据库 + * 接收问答对消息后保存到数据库 + * @param qaMessage 包含问题和答案的消息对象 + */ + @RabbitListener(queues = "qa.answer.queue") + public void handleQAMessage(QAMessageDTO qaMessage) { + log.info("收到问答对消息,问题: {}, 答案: {}", qaMessage.getQuestion(), qaMessage.getAnswer()); + + try { + // 保存问答对到数据库 + qaRepositoryPort.saveQA(qaMessage.getQuestion(), qaMessage.getAnswer()); + log.info("问答对保存成功,问题: {}", qaMessage.getQuestion()); + + } catch (Exception e) { + log.error("保存问答对失败,问题: {}, 答案: {}", + qaMessage.getQuestion(), qaMessage.getAnswer(), e); + // 这里可以考虑重试机制或者死信队列处理 + } + } +} \ No newline at end of file diff --git a/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/pom.xml b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..d80782be01d852ba228ff5931804cbeeff942742 --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + com.example + qa-adapter-out-persistence + 0.0.1-SNAPSHOT + qa-adapter-out-persistence + qa-adapter-out-persistence + + com.example + qa-adapter-out + 0.0.1-SNAPSHOT + + + + + org.projectlombok + lombok + provided + + + + com.example + qa-service-domain + 0.0.1-SNAPSHOT + + + + com.baomidou + mybatis-plus-spring-boot3-starter + + + + com.mysql + mysql-connector-j + runtime + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 21 + 21 + UTF-8 + + + + + + diff --git a/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/bridge/CreateQABridge.java b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/bridge/CreateQABridge.java new file mode 100644 index 0000000000000000000000000000000000000000..f3fa09d1a7ddece3c0aa5e95b1c8889ac6af7ee1 --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/bridge/CreateQABridge.java @@ -0,0 +1,29 @@ +package com.example.qa.adapter.out.persistence.bridge; + +import com.example.qa.adapter.out.persistence.convertor.QAConvertor; +import com.example.qa.adapter.out.persistence.entity.QAEntity; +import com.example.qa.adapter.out.persistence.mapper.QAMapper; +import com.example.qa.service.domain.QA; +import com.example.qa.service.domain.port.CreateQAPort; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +/** + * @author yangwenkai + * 创建QA的桥接类 + */ +public class CreateQABridge implements CreateQAPort { + @Resource + private QAMapper qAMapper; + + @Override + public QA createQA(QA qa) { + QAEntity qaEntity = QAConvertor.toEntity(qa); + int result = qAMapper.insert(qaEntity); + //result 是受影响的行数 + return qa; + } +} diff --git a/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/bridge/DeleteQABridge.java b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/bridge/DeleteQABridge.java new file mode 100644 index 0000000000000000000000000000000000000000..014a23060f5c21415a21528d25ade2a325473fbc --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/bridge/DeleteQABridge.java @@ -0,0 +1,23 @@ +package com.example.qa.adapter.out.persistence.bridge; + + +import com.example.qa.adapter.out.persistence.mapper.QAMapper; +import com.example.qa.service.domain.port.DeleteQAPort; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +/** + * @author qiuyujie + */ +public class DeleteQABridge implements DeleteQAPort { + @Resource + private QAMapper QAMapper; + @Override + public void deleteQA(Long id) { + int result = QAMapper.deleteById(id); + log.info("result:{}",result); + } +} diff --git a/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/bridge/GetQAByIdBridge.java b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/bridge/GetQAByIdBridge.java new file mode 100644 index 0000000000000000000000000000000000000000..37bad7c0de514a2c8054ad162cdaa20012f08354 --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/bridge/GetQAByIdBridge.java @@ -0,0 +1,25 @@ +package com.example.qa.adapter.out.persistence.bridge; + +import com.example.qa.adapter.out.persistence.convertor.QAConvertor; +import com.example.qa.adapter.out.persistence.entity.QAEntity; +import com.example.qa.adapter.out.persistence.mapper.QAMapper; +import com.example.qa.service.domain.QA; +import com.example.qa.service.domain.port.GetQAByIdPort; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +/** + * @author dengli + */ +public class GetQAByIdBridge implements GetQAByIdPort { + @Resource + private QAMapper QAMapper; + @Override + public QA getQAById(long id) { + QAEntity QAEntity = QAMapper.selectById(id); + return QAConvertor.toDomain(QAEntity); + } +} diff --git a/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/bridge/GetQAListBridge.java b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/bridge/GetQAListBridge.java new file mode 100644 index 0000000000000000000000000000000000000000..ebbbd26bee4cd74936a285c9c69bcd37d8ce9f0f --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/bridge/GetQAListBridge.java @@ -0,0 +1,36 @@ +package com.example.qa.adapter.out.persistence.bridge; + + +import com.example.qa.adapter.out.persistence.convertor.QAConvertor; +import com.example.qa.adapter.out.persistence.entity.QAEntity; +import com.example.qa.adapter.out.persistence.mapper.QAMapper; +import com.example.qa.service.domain.QA; +import com.example.qa.service.domain.port.GetQAListPort; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Component +/** + * @author wangqingqing + */ +public class GetQAListBridge implements GetQAListPort { + + @Resource + private QAMapper QAMapper; + + @Override + public List getQAs() { + List entities = QAMapper.selectList(null); + + ArrayList list = new ArrayList<>(); + + entities.forEach(QAEntity -> { + QA QA = QAConvertor.toDomain(QAEntity); + list.add(QA); + }); + return list; + } +} diff --git a/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/bridge/QARepositoryBridge.java b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/bridge/QARepositoryBridge.java new file mode 100644 index 0000000000000000000000000000000000000000..86b73457ac620a4a97a9df289ba6882ea3bc9d15 --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/bridge/QARepositoryBridge.java @@ -0,0 +1,67 @@ +package com.example.qa.adapter.out.persistence.bridge; + +import com.example.qa.adapter.out.persistence.entity.QAEntity; +import com.example.qa.adapter.out.persistence.mapper.QAMapper; +import com.example.qa.service.common.IdWorker; +import com.example.qa.service.domain.port.QARepositoryPort; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * QA仓储桥接器实现 + * 实现QARepositoryPort接口,提供问答数据的持久化操作 + * @author yangwenkai + */ +@Slf4j +@Component +public class QARepositoryBridge implements QARepositoryPort { + + /** + * QA数据访问映射器 + */ + @Resource + private QAMapper qaMapper; + + /** + * ID生成器,用于生成唯一的问答记录ID + */ + @Resource + private IdWorker idWorker; + + /** + * 保存问答对到数据库 + * 生成唯一ID,创建QA实体并保存到数据库 + * @param question 问题内容 + * @param answer 答案内容 + */ + @Override + public void saveQA(String question, String answer) { + log.info("开始保存问答对,问题: {}, 答案: {}", question, answer); + + try { + // 生成唯一ID + long id = idWorker.nextId(); + + // 创建QA实体 + QAEntity qaEntity = new QAEntity(); + qaEntity.setId(id); + qaEntity.setQuestion(question); + qaEntity.setAnswer(answer); + + // 保存到数据库 + int result = qaMapper.insert(qaEntity); + + if (result > 0) { + log.info("问答对保存成功,ID: {}, 问题: {}", id, question); + } else { + log.warn("问答对保存失败,问题: {}, 答案: {}", question, answer); + throw new RuntimeException("数据库保存失败"); + } + + } catch (Exception e) { + log.error("保存问答对异常,问题: {}, 答案: {}", question, answer, e); + throw new RuntimeException("保存问答对失败", e); + } + } +} \ No newline at end of file diff --git a/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/bridge/UpdateQABridge.java b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/bridge/UpdateQABridge.java new file mode 100644 index 0000000000000000000000000000000000000000..91aecb99cd4424ce909a036d18c2904e92844f1b --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/bridge/UpdateQABridge.java @@ -0,0 +1,26 @@ +package com.example.qa.adapter.out.persistence.bridge; + +import com.example.qa.adapter.out.persistence.convertor.QAConvertor; +import com.example.qa.adapter.out.persistence.mapper.QAMapper; +import com.example.qa.service.domain.QA; +import com.example.qa.service.domain.port.UpdateQAPort; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +/** + * @author lizishan + */ +public class UpdateQABridge implements UpdateQAPort { + @Resource + private QAMapper QAMapper; + + @Override + public QA updateQA(QA QA) { + int result = QAMapper.updateById(QAConvertor.toEntity(QA)); + log.info("result:{}",result); + return QA; + } +} diff --git a/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/convertor/QAConvertor.java b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/convertor/QAConvertor.java new file mode 100644 index 0000000000000000000000000000000000000000..57df73a0f7a35228cad9f1a7c42a22c7ce676bdf --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/convertor/QAConvertor.java @@ -0,0 +1,29 @@ +package com.example.qa.adapter.out.persistence.convertor; + +import com.example.qa.adapter.out.persistence.entity.QAEntity; +import com.example.qa.service.domain.QA; +import com.example.qa.service.domain.valueobject.Answer; +import com.example.qa.service.domain.valueobject.QAId; +import com.example.qa.service.domain.valueobject.Question; + +/** + * @author yangwenkai + * QA的数据转换 + */ +public class QAConvertor { + public static QA toDomain(QAEntity qaEntity) { + return new QA( + new QAId(qaEntity.getId()), + new Question(qaEntity.getQuestion()), + new Answer(qaEntity.getAnswer()) + ); + } + + public static QAEntity toEntity(QA qa) { + return new QAEntity( + qa.getId().getValue(), + qa.getQuestion().getValue(), + qa.getAnswer().getValue() + ); + } +} diff --git a/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/entity/QAEntity.java b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/entity/QAEntity.java new file mode 100644 index 0000000000000000000000000000000000000000..82acdb60ce606bec013696b7fb349830255b0cec --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/entity/QAEntity.java @@ -0,0 +1,23 @@ +package com.example.qa.adapter.out.persistence.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@TableName("qa") +/** + * @author yangwenkai + * QA的实体类 + */ +public class QAEntity { + @TableId(type = IdType.ASSIGN_ID) + private long id; + private String question; + private String answer; +} diff --git a/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/mapper/QAMapper.java b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/mapper/QAMapper.java new file mode 100644 index 0000000000000000000000000000000000000000..fa3c1c1fea7ac5f8cc5bad9d6a0eb6f385d1fda6 --- /dev/null +++ b/qa-service/qa-service-adapter/qa-adapter-out/qa-adapter-out-persistence/src/main/java/com/example/qa/adapter/out/persistence/mapper/QAMapper.java @@ -0,0 +1,10 @@ +package com.example.qa.adapter.out.persistence.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.example.qa.adapter.out.persistence.entity.QAEntity; + +/** + * @author lizishan + */ +public interface QAMapper extends BaseMapper { +} diff --git a/qa-service/qa-service-application/pom.xml b/qa-service/qa-service-application/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..5dca05e57eb78ac12c0c3d231af31ff4f11cea66 --- /dev/null +++ b/qa-service/qa-service-application/pom.xml @@ -0,0 +1,76 @@ + + + 4.0.0 + com.example + qa-service-application + 0.0.1-SNAPSHOT + qa-service-application + qa-service-application + + + com.example + qa-service + 0.0.1-SNAPSHOT + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.projectlombok + lombok + provided + + + + com.example + qa-service-domain + 0.0.1-SNAPSHOT + + + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + runtime + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 21 + 21 + UTF-8 + + + + + + diff --git a/qa-service/qa-service-application/src/main/java/com/example/application/command/AskQuestionAsyncCommand.java b/qa-service/qa-service-application/src/main/java/com/example/application/command/AskQuestionAsyncCommand.java new file mode 100644 index 0000000000000000000000000000000000000000..da4fb0a78e9559bf02566fce81c032eaa5f6f351 --- /dev/null +++ b/qa-service/qa-service-application/src/main/java/com/example/application/command/AskQuestionAsyncCommand.java @@ -0,0 +1,24 @@ +package com.example.application.command; + +import lombok.Data; + +/** + * 异步提问命令对象 + * 用于封装异步提问请求的数据 + * @author qiuyujie dengli wangqingqing lizishan + */ +@Data +public class AskQuestionAsyncCommand { + + /** + * 用户提出的问题 + */ + private String question; + + public AskQuestionAsyncCommand() { + } + + public AskQuestionAsyncCommand(String question) { + this.question = question; + } +} \ No newline at end of file diff --git a/qa-service/qa-service-application/src/main/java/com/example/application/command/AskQuestionSyncCommand.java b/qa-service/qa-service-application/src/main/java/com/example/application/command/AskQuestionSyncCommand.java new file mode 100644 index 0000000000000000000000000000000000000000..ae6724208cf2b31d664a295c80efed9920f61b7a --- /dev/null +++ b/qa-service/qa-service-application/src/main/java/com/example/application/command/AskQuestionSyncCommand.java @@ -0,0 +1,24 @@ +package com.example.application.command; + +import lombok.Data; + +/** + * 同步提问命令对象 + * 用于封装同步提问请求的数据 + * @author qiuyujie dengli wangqingqing lizishan + */ +@Data +public class AskQuestionSyncCommand { + + /** + * 用户提出的问题 + */ + private String question; + + public AskQuestionSyncCommand() { + } + + public AskQuestionSyncCommand(String question) { + this.question = question; + } +} \ No newline at end of file diff --git a/qa-service/qa-service-application/src/main/java/com/example/application/command/CreateQACommand.java b/qa-service/qa-service-application/src/main/java/com/example/application/command/CreateQACommand.java new file mode 100644 index 0000000000000000000000000000000000000000..8688eec43a628d51f383fc0b359e22e62db72e4d --- /dev/null +++ b/qa-service/qa-service-application/src/main/java/com/example/application/command/CreateQACommand.java @@ -0,0 +1,16 @@ +package com.example.application.command; + + +import lombok.Builder; + +@Builder +/** + * @author yangwenkai + * 创建QA的封装参数 + */ +public record CreateQACommand( + Long id, + String question, + String answer +) { +} diff --git a/qa-service/qa-service-application/src/main/java/com/example/application/command/UpdateQACommand.java b/qa-service/qa-service-application/src/main/java/com/example/application/command/UpdateQACommand.java new file mode 100644 index 0000000000000000000000000000000000000000..b86e3d9f71f1b24b9309db35897ead45c593b9f7 --- /dev/null +++ b/qa-service/qa-service-application/src/main/java/com/example/application/command/UpdateQACommand.java @@ -0,0 +1,15 @@ +package com.example.application.command; + +import lombok.Builder; + +@Builder +/** + * @author yangwenkai + * 修改QA的封装参数 + */ +public record UpdateQACommand ( + long id, + String question, + String answer +) { +} diff --git a/qa-service/qa-service-application/src/main/java/com/example/application/port/in/AskQuestionAsyncUseCase.java b/qa-service/qa-service-application/src/main/java/com/example/application/port/in/AskQuestionAsyncUseCase.java new file mode 100644 index 0000000000000000000000000000000000000000..95db745885f2ad0b97a04632686d09af0e47a236 --- /dev/null +++ b/qa-service/qa-service-application/src/main/java/com/example/application/port/in/AskQuestionAsyncUseCase.java @@ -0,0 +1,19 @@ +package com.example.application.port.in; + +import com.example.application.command.AskQuestionAsyncCommand; + +/** + * 异步提问用例接口 + * 定义了异步处理问题的业务逻辑契约 + * @author yangwenkai + */ +public interface AskQuestionAsyncUseCase { + + /** + * 异步处理用户问题 + * 将问题发送到消息队列进行异步处理 + * @param command 异步提问命令对象 + * @return 处理状态信息 + */ + String askQuestionAsync(AskQuestionAsyncCommand command); +} \ No newline at end of file diff --git a/qa-service/qa-service-application/src/main/java/com/example/application/port/in/AskQuestionSyncUseCase.java b/qa-service/qa-service-application/src/main/java/com/example/application/port/in/AskQuestionSyncUseCase.java new file mode 100644 index 0000000000000000000000000000000000000000..45395382a4c35587e52a97a184160eee9a0fc1af --- /dev/null +++ b/qa-service/qa-service-application/src/main/java/com/example/application/port/in/AskQuestionSyncUseCase.java @@ -0,0 +1,19 @@ +package com.example.application.port.in; + +import com.example.application.command.AskQuestionSyncCommand; + +/** + * 同步提问用例接口 + * 定义了同步处理问题的业务逻辑契约 + * @author yangwenkai + */ +public interface AskQuestionSyncUseCase { + + /** + * 同步处理用户问题 + * 直接调用AI服务获取答案并返回 + * @param command 同步提问命令对象 + * @return AI生成的答案 + */ + String askQuestionSync(AskQuestionSyncCommand command); +} \ No newline at end of file diff --git a/qa-service/qa-service-application/src/main/java/com/example/application/port/in/CreateQAUseCase.java b/qa-service/qa-service-application/src/main/java/com/example/application/port/in/CreateQAUseCase.java new file mode 100644 index 0000000000000000000000000000000000000000..6960f96b0a23b70e4706c8670e2b1df58162471e --- /dev/null +++ b/qa-service/qa-service-application/src/main/java/com/example/application/port/in/CreateQAUseCase.java @@ -0,0 +1,12 @@ +package com.example.application.port.in; + +import com.example.application.command.CreateQACommand; +import com.example.qa.service.domain.QA; + +/** + * @author yangwenkai + * 创建QA的接口 + */ +public interface CreateQAUseCase { + QA createQA(CreateQACommand command); +} diff --git a/qa-service/qa-service-application/src/main/java/com/example/application/port/in/DeleteQAUseCase.java b/qa-service/qa-service-application/src/main/java/com/example/application/port/in/DeleteQAUseCase.java new file mode 100644 index 0000000000000000000000000000000000000000..3da4cdab76cbc3f0e36e1882e448427d0319e2d9 --- /dev/null +++ b/qa-service/qa-service-application/src/main/java/com/example/application/port/in/DeleteQAUseCase.java @@ -0,0 +1,8 @@ +package com.example.application.port.in; + +/** + * @author qiuyujie + */ +public interface DeleteQAUseCase { + void deleteQA(long id); +} diff --git a/qa-service/qa-service-application/src/main/java/com/example/application/port/in/GetQAByIdUseCase.java b/qa-service/qa-service-application/src/main/java/com/example/application/port/in/GetQAByIdUseCase.java new file mode 100644 index 0000000000000000000000000000000000000000..62b69d560065f33fac0d6d2b3a4b6418e66ac97c --- /dev/null +++ b/qa-service/qa-service-application/src/main/java/com/example/application/port/in/GetQAByIdUseCase.java @@ -0,0 +1,10 @@ +package com.example.application.port.in; + +import com.example.qa.service.domain.QA; + +/** + * @author dengli + */ +public interface GetQAByIdUseCase { + QA getQAById(long id); +} diff --git a/qa-service/qa-service-application/src/main/java/com/example/application/port/in/GetQAListUseCase.java b/qa-service/qa-service-application/src/main/java/com/example/application/port/in/GetQAListUseCase.java new file mode 100644 index 0000000000000000000000000000000000000000..987e33c6a3bf908f9dc13d59d61e98da373cb32a --- /dev/null +++ b/qa-service/qa-service-application/src/main/java/com/example/application/port/in/GetQAListUseCase.java @@ -0,0 +1,11 @@ +package com.example.application.port.in; + +import com.example.qa.service.domain.QA; + +import java.util.List; +/** + * @author wangqingqing + */ +public interface GetQAListUseCase { + List getQAs(); +} diff --git a/qa-service/qa-service-application/src/main/java/com/example/application/port/in/UpdateQAUseCase.java b/qa-service/qa-service-application/src/main/java/com/example/application/port/in/UpdateQAUseCase.java new file mode 100644 index 0000000000000000000000000000000000000000..39a9c6cf1c05797f82ebe44ae8658bc10d404ef9 --- /dev/null +++ b/qa-service/qa-service-application/src/main/java/com/example/application/port/in/UpdateQAUseCase.java @@ -0,0 +1,10 @@ +package com.example.application.port.in; + +import com.example.application.command.UpdateQACommand; +import com.example.qa.service.domain.QA; +/** + * @author lizishan + */ +public interface UpdateQAUseCase { + QA updateQA(UpdateQACommand command); +} diff --git a/qa-service/qa-service-application/src/main/java/com/example/application/service/AskQuestionAsyncService.java b/qa-service/qa-service-application/src/main/java/com/example/application/service/AskQuestionAsyncService.java new file mode 100644 index 0000000000000000000000000000000000000000..50c1742738c7b3e831312e4ab609e3cf70201157 --- /dev/null +++ b/qa-service/qa-service-application/src/main/java/com/example/application/service/AskQuestionAsyncService.java @@ -0,0 +1,47 @@ +package com.example.application.service; + +import com.example.application.command.AskQuestionAsyncCommand; +import com.example.application.port.in.AskQuestionAsyncUseCase; +import com.example.qa.service.domain.port.MessageQueuePort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 异步提问服务实现 + * 实现异步处理用户问题的业务逻辑 + * @author qiuyujie dengli wangqingqing lizishan + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AskQuestionAsyncService implements AskQuestionAsyncUseCase { + + /** + * 消息队列端口,用于发送问题到队列 + */ + private final MessageQueuePort messageQueuePort; + + /** + * 异步处理用户问题 + * 将问题发送到消息队列进行异步处理 + * @param command 异步提问命令对象 + * @return 处理状态信息 + */ + @Override + public String askQuestionAsync(AskQuestionAsyncCommand command) { + log.info("开始异步处理问题: {}", command.getQuestion()); + + try { + // 将问题发送到消息队列进行异步处理 + messageQueuePort.sendQuestionToQueue(command.getQuestion()); + + log.info("问题已成功发送到消息队列: {}", command.getQuestion()); + return "问题已提交,正在异步处理中..."; + + } catch (Exception e) { + log.error("发送问题到消息队列失败: {}", command.getQuestion(), e); + return "问题提交失败,请稍后重试"; + } + } +} \ No newline at end of file diff --git a/qa-service/qa-service-application/src/main/java/com/example/application/service/AskQuestionSyncService.java b/qa-service/qa-service-application/src/main/java/com/example/application/service/AskQuestionSyncService.java new file mode 100644 index 0000000000000000000000000000000000000000..2ad3319a418d1c3bda2468011c25a24e04c3ef7e --- /dev/null +++ b/qa-service/qa-service-application/src/main/java/com/example/application/service/AskQuestionSyncService.java @@ -0,0 +1,47 @@ +package com.example.application.service; + +import com.example.application.command.AskQuestionSyncCommand; +import com.example.application.port.in.AskQuestionSyncUseCase; +import com.example.qa.service.domain.port.KimiAiPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 同步提问服务实现 + * 实现同步处理用户问题的业务逻辑 + * @author yangwenkai + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AskQuestionSyncService implements AskQuestionSyncUseCase { + + /** + * KIMI AI端口,用于调用AI服务 + */ + private final KimiAiPort kimiAiPort; + + /** + * 同步处理用户问题 + * 直接调用AI服务获取答案并返回 + * @param command 同步提问命令对象 + * @return AI生成的答案 + */ + @Override + public String askQuestionSync(AskQuestionSyncCommand command) { + log.info("开始同步处理问题: {}", command.getQuestion()); + + try { + // 直接调用KIMI AI服务获取答案 + String answer = kimiAiPort.getAnswer(command.getQuestion()); + + log.info("成功获取AI答案,问题: {}, 答案: {}", command.getQuestion(), answer); + return answer; + + } catch (Exception e) { + log.error("调用AI服务失败,问题: {}", command.getQuestion(), e); + return "抱歉,AI服务暂时不可用,请稍后重试"; + } + } +} \ No newline at end of file diff --git a/qa-service/qa-service-application/src/main/java/com/example/application/service/CreateQAService.java b/qa-service/qa-service-application/src/main/java/com/example/application/service/CreateQAService.java new file mode 100644 index 0000000000000000000000000000000000000000..2a6a447126e5c459414e2917c9d7880385669798 --- /dev/null +++ b/qa-service/qa-service-application/src/main/java/com/example/application/service/CreateQAService.java @@ -0,0 +1,31 @@ +package com.example.application.service; + +import com.example.application.command.CreateQACommand; +import com.example.application.port.in.CreateQAUseCase; +import com.example.qa.service.domain.QA; +import com.example.qa.service.domain.port.CreateQAPort; +import com.example.qa.service.domain.valueobject.Answer; +import com.example.qa.service.domain.valueobject.Question; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +/** + * @author yangwenkai + * 创建QA的实现类 + */ +public class CreateQAService implements CreateQAUseCase { + @Resource + private CreateQAPort createQAPort; + + @Override + public QA createQA(CreateQACommand createQACommand) { + QA qa = new QA( + new Question(createQACommand.question()), + new Answer(createQACommand.answer()) + ); + return createQAPort.createQA(qa); + } +} diff --git a/qa-service/qa-service-application/src/main/java/com/example/application/service/DeleteQAService.java b/qa-service/qa-service-application/src/main/java/com/example/application/service/DeleteQAService.java new file mode 100644 index 0000000000000000000000000000000000000000..1d8fab5e755a6a45953c67cad6ea9804f1614a52 --- /dev/null +++ b/qa-service/qa-service-application/src/main/java/com/example/application/service/DeleteQAService.java @@ -0,0 +1,20 @@ +package com.example.application.service; + +import com.example.application.port.in.DeleteQAUseCase; +import com.example.qa.service.domain.port.DeleteQAPort; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +@Service +/** + * @author qiuyujie + */ +public class DeleteQAService implements DeleteQAUseCase { + @Resource + private DeleteQAPort deleteQAPort; + + @Override + public void deleteQA(long id) { + deleteQAPort.deleteQA(id); + } +} \ No newline at end of file diff --git a/qa-service/qa-service-application/src/main/java/com/example/application/service/GetQAByIdService.java b/qa-service/qa-service-application/src/main/java/com/example/application/service/GetQAByIdService.java new file mode 100644 index 0000000000000000000000000000000000000000..b224f7b67f7842f87a07352e614462e5982bf548 --- /dev/null +++ b/qa-service/qa-service-application/src/main/java/com/example/application/service/GetQAByIdService.java @@ -0,0 +1,22 @@ +package com.example.application.service; + +import com.example.application.port.in.GetQAByIdUseCase; +import com.example.qa.service.domain.QA; +import com.example.qa.service.domain.port.GetQAByIdPort; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +@Service +/** + * @author dengli + */ +public class GetQAByIdService implements GetQAByIdUseCase { + + @Resource + private GetQAByIdPort getQAByIdPort; + @Override + public QA getQAById(long id) { + return getQAByIdPort.getQAById(id); + } +} + diff --git a/qa-service/qa-service-application/src/main/java/com/example/application/service/GetQAListService.java b/qa-service/qa-service-application/src/main/java/com/example/application/service/GetQAListService.java new file mode 100644 index 0000000000000000000000000000000000000000..fb72ee69136babd56571114fec1970e5b1869d47 --- /dev/null +++ b/qa-service/qa-service-application/src/main/java/com/example/application/service/GetQAListService.java @@ -0,0 +1,23 @@ +package com.example.application.service; + +import com.example.application.port.in.GetQAListUseCase; +import com.example.qa.service.domain.QA; +import com.example.qa.service.domain.port.GetQAListPort; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.util.List; +/** + * @author wangqingqing + */ +@Service +public class GetQAListService implements GetQAListUseCase { + + @Resource + GetQAListPort getQAListPort; + @Override + public List getQAs() { + List QAs = QA.getQAs(getQAListPort); + return QAs; + } +} diff --git a/qa-service/qa-service-application/src/main/java/com/example/application/service/UpdateQAService.java b/qa-service/qa-service-application/src/main/java/com/example/application/service/UpdateQAService.java new file mode 100644 index 0000000000000000000000000000000000000000..d908f68854c34ca7f754bb93f0b71e72dabcd597 --- /dev/null +++ b/qa-service/qa-service-application/src/main/java/com/example/application/service/UpdateQAService.java @@ -0,0 +1,26 @@ +package com.example.application.service; + +import com.example.application.command.UpdateQACommand; +import com.example.application.port.in.UpdateQAUseCase; +import com.example.qa.service.domain.QA; +import com.example.qa.service.domain.port.UpdateQAPort; +import com.example.qa.service.domain.valueobject.*; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +/** + * @author lizishan + */ +@Service +public class UpdateQAService implements UpdateQAUseCase { + @Resource + private UpdateQAPort updateQAPort; + + @Override + public QA updateQA(UpdateQACommand command) { + QA QA = new QA( + new QAId(command.id()), + new Question(command.question()), + new Answer(command.answer())); + return updateQAPort.updateQA(QA); + } +} diff --git a/qa-service/qa-service-bootstrap/pom.xml b/qa-service/qa-service-bootstrap/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..465bd90c38c7a8b5568e47b5b4d4d546d04ff9e4 --- /dev/null +++ b/qa-service/qa-service-bootstrap/pom.xml @@ -0,0 +1,92 @@ + + + 4.0.0 + com.example + qa-service-bootstrap + 0.0.1-SNAPSHOT + qa-service-bootstrap + qa-service-bootstrap + + + com.example + qa-service + 0.0.1-SNAPSHOT + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-starter-test + test + + + + com.example + qa-adapter-in-web + 0.0.1-SNAPSHOT + + + + com.example + qa-adapter-out-persistence + 0.0.1-SNAPSHOT + + + + com.example + qa-adapter-out-external + 0.0.1-SNAPSHOT + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 21 + 21 + UTF-8 + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + com.example.qa.service.bootstrap.QaServiceBootstrapApplication + false + + + + repackage + + repackage + + + + + + + + diff --git a/qa-service/qa-service-bootstrap/src/main/java/com/example/qa/service/bootstrap/QaServiceBootstrapApplication.java b/qa-service/qa-service-bootstrap/src/main/java/com/example/qa/service/bootstrap/QaServiceBootstrapApplication.java new file mode 100644 index 0000000000000000000000000000000000000000..d2fcc6a66104d1153c3794e555e0d96d1c2ab57b --- /dev/null +++ b/qa-service/qa-service-bootstrap/src/main/java/com/example/qa/service/bootstrap/QaServiceBootstrapApplication.java @@ -0,0 +1,26 @@ +package com.example.qa.service.bootstrap; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.amqp.rabbit.annotation.EnableRabbit; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * QA服务启动类 + * 配置Spring Boot应用的启动入口,启用RabbitMQ和MyBatis支持 + * @author yangwenkai + */ +@SpringBootApplication(scanBasePackages = "com.example") +@MapperScan("com.example.qa.adapter.out.persistence.mapper") +@EnableRabbit // 启用RabbitMQ支持 +public class QaServiceBootstrapApplication { + + /** + * 应用程序主入口 + * 启动Spring Boot应用 + * @param args 命令行参数 + */ + public static void main(String[] args) { + SpringApplication.run(QaServiceBootstrapApplication.class, args); + } +} diff --git a/qa-service/qa-service-bootstrap/src/main/resources/application.properties b/qa-service/qa-service-bootstrap/src/main/resources/application.properties new file mode 100644 index 0000000000000000000000000000000000000000..628f98507db41d712fc733265c3e238f601feb6f --- /dev/null +++ b/qa-service/qa-service-bootstrap/src/main/resources/application.properties @@ -0,0 +1,28 @@ +server.port=28080 + +spring.application.name=qa-service + +# MyBatis Plus配置 +mybatis-plus.configuration.map-underscore-to-camel-case=true +mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl +mybatis-plus.global-config.db-config.id-type=assign_id + +# Nacos认证信息 +spring.cloud.nacos.discovery.username=nacos +spring.cloud.nacos.discovery.password=nacos +# Nacos 服务发现与注册配置,其中子属性 server-addr 指定 Nacos 服务器主机和端口 +spring.cloud.nacos.discovery.server-addr=192.168.168.128:8848 +# 注册到 nacos 的指定 namespace,默认为 public +spring.cloud.nacos.discovery.namespace=public + +# Nacos帮助文档: https://nacos.io/zh-cn/docs/concepts.html +# Nacos认证信息 +spring.cloud.nacos.config.username=nacos +spring.cloud.nacos.config.password=nacos +spring.cloud.nacos.config.contextPath=/nacos +# 设置配置中心服务端地址 +spring.cloud.nacos.config.server-addr=192.168.168.128:8848 +# Nacos 配置中心的namespace。需要注意,如果使用 public 的 namcespace ,请不要填写这个值,直接留空即可 +# spring.cloud.nacos.config.namespace= +spring.config.import=nacos:${spring.application.name}.properties?refresh=true + diff --git a/qa-gateway/src/test/java/com/example/qa/gateway/QaGatewayApplicationTests.java b/qa-service/qa-service-bootstrap/src/test/java/com/example/qa/service/bootstrap/QaServiceBootstrapApplicationTests.java similarity index 65% rename from qa-gateway/src/test/java/com/example/qa/gateway/QaGatewayApplicationTests.java rename to qa-service/qa-service-bootstrap/src/test/java/com/example/qa/service/bootstrap/QaServiceBootstrapApplicationTests.java index 2e520556a010990f5aaedeee3b48c605faa92e19..153ddf1989c7fe0269bdf4085b4e7e054849d3e5 100644 --- a/qa-gateway/src/test/java/com/example/qa/gateway/QaGatewayApplicationTests.java +++ b/qa-service/qa-service-bootstrap/src/test/java/com/example/qa/service/bootstrap/QaServiceBootstrapApplicationTests.java @@ -1,10 +1,10 @@ -package com.example.qa.gateway; +package com.example.qa.service.bootstrap; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest -class QaGatewayApplicationTests { +class QaServiceBootstrapApplicationTests { @Test void contextLoads() { diff --git a/qa-service/qa-service-common/pom.xml b/qa-service/qa-service-common/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..38c3ef0bb9654e3dcb276165538ccdf3f6d99163 --- /dev/null +++ b/qa-service/qa-service-common/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + com.example + qa-service-common + 0.0.1-SNAPSHOT + qa-service-common + qa-service-common + + 21 + UTF-8 + UTF-8 + 3.0.2 + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.projectlombok + lombok + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 21 + 21 + UTF-8 + + + + + + diff --git a/qa-service/qa-service-common/src/main/java/com/example/qa/service/common/IdWorker.java b/qa-service/qa-service-common/src/main/java/com/example/qa/service/common/IdWorker.java new file mode 100644 index 0000000000000000000000000000000000000000..947c215b0c1be042c70620f74b908292b19510d3 --- /dev/null +++ b/qa-service/qa-service-common/src/main/java/com/example/qa/service/common/IdWorker.java @@ -0,0 +1,201 @@ +package com.example.qa.service.common; + +import java.lang.management.ManagementFactory; +import java.net.InetAddress; +import java.net.NetworkInterface; +import org.springframework.stereotype.Component; + +/** + * @author wuyunbin + *

名称:IdWorker.java

+ *

描述:分布式自增长ID

+ *
+ *     Twitter的 Snowflake JAVA实现方案
+ * 
+ * 核心代码为其IdWorker这个类实现,其原理结构如下,我分别用一个0表示一位,用—分割开部分的作用: + * 1||0---0000000000 0000000000 0000000000 0000000000 0 --- 00000 ---00000 ---000000000000 + * 在上面的字符串中,第一位为未使用(实际上也可作为long的符号位),接下来的41位为毫秒级时间, + * 然后5位datacenter标识位,5位机器ID(并不算标识符,实际是为线程标识), + * 然后12位该毫秒内的当前毫秒内的计数,加起来刚好64位,为一个Long型。 + * 这样的好处是,整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由datacenter和机器ID作区分), + * 并且效率较高,经测试,snowflake每秒能够产生26万ID左右,完全满足需要。 + *

+ * 64位ID (42(毫秒)+5(机器ID)+5(业务编码)+12(重复累加)) + * @author Polim + */ +@Component +public class IdWorker { + /** + * 时间起始标记点,作为基准,一般取系统的最近时间(一旦确定不能变动) + */ + private final static long TWEPOCH = 1288834974657L; + + /** + * 机器标识位数 + */ + private final static long WORKER_ID_BITS = 5L; + + /** + * 数据中心标识位数 + */ + private final static long DATA_CENTER_ID_BITS = 5L; + + /** + * 机器ID最大值 + */ + private final static long MAX_WORKER_ID = -1L ^ (-1L << WORKER_ID_BITS); + + /** + * 数据中心ID最大值 + */ + private final static long MAX_DATACENTER_ID = -1L ^ (-1L << DATA_CENTER_ID_BITS); + + /** + * 毫秒内自增位 + */ + private final static long SEQUENCE_BITS = 12L; + + /** + * 机器ID偏左移12位 + */ + private final static long WORKER_ID_SHIFT = SEQUENCE_BITS; + + /** + * 数据中心ID左移17位 + */ + private final static long DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS; + + /** + * 时间毫秒左移22位 + */ + private final static long TIMESTAMP_LEFT_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATA_CENTER_ID_BITS; + + private final static long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS); + + /** + * 上次生产id时间戳 + */ + private static long lastTimestamp = -1L; + + /** + * 0,并发控制 + */ + private long sequence = 0L; + + private final long workerId; + + /** + * 数据标识id部分 + */ + private final long datacenterId; + + public IdWorker() { + this.datacenterId = getDatacenterId(); + this.workerId = getMaxWorkerId(datacenterId); + } + + /** + * @param workerId 工作机器ID + * @param datacenterId 序列号 + */ + public IdWorker(long workerId, long datacenterId) { + if (workerId > MAX_WORKER_ID || workerId < 0) { + throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", MAX_WORKER_ID)); + } + if (datacenterId > MAX_DATACENTER_ID || datacenterId < 0) { + throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", MAX_DATACENTER_ID)); + } + this.workerId = workerId; + this.datacenterId = datacenterId; + } + + /** + * 获取下一个ID + * + * @return + */ + public synchronized long nextId() { + long timestamp = timeGen(); + if (timestamp < lastTimestamp) { + throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp)); + } + + if (lastTimestamp == timestamp) { + // 当前毫秒内,则+1 + sequence = (sequence + 1) & SEQUENCE_MASK; + if (sequence == 0) { + // 当前毫秒内计数满了,则等待下一秒 + timestamp = tilNextMillis(lastTimestamp); + } + } else { + sequence = 0L; + } + lastTimestamp = timestamp; + // ID偏移组合生成最终的ID,并返回ID + + return ((timestamp - TWEPOCH) << TIMESTAMP_LEFT_SHIFT) + | (datacenterId << DATACENTER_ID_SHIFT) + | (workerId << WORKER_ID_SHIFT) | sequence; + } + + private long tilNextMillis(final long lastTimestamp) { + long timestamp = this.timeGen(); + while (timestamp <= lastTimestamp) { + timestamp = this.timeGen(); + } + return timestamp; + } + + private long timeGen() { + return System.currentTimeMillis(); + } + + /** + *

+ * 获取 MAX_WORKER_ID + *

+ */ + protected static long getMaxWorkerId(long datacenterId) { + StringBuilder mpid = new StringBuilder(); + mpid.append(datacenterId); + String name = ManagementFactory.getRuntimeMXBean().getName(); + if (!name.isEmpty()) { + /* + * GET jvmPid + */ + mpid.append(name.split("@")[0]); + } + /* + * MAC + PID 的 hashcode 获取16个低位 + */ + return (mpid.toString().hashCode() & 0xffff) % (IdWorker.MAX_WORKER_ID + 1); + } + + /** + *

+ * 数据标识id部分 + *

+ */ + protected static long getDatacenterId() { + long id = 0L; + try { + InetAddress ip = InetAddress.getLocalHost(); + NetworkInterface network = NetworkInterface.getByInetAddress(ip); + if (network == null) { + id = 1L; + } else { + byte[] mac = network.getHardwareAddress(); + id = ((0x000000FF & (long) mac[mac.length - 1]) + | (0x0000FF00 & (((long) mac[mac.length - 2]) << 8))) >> 6; + id = id % (IdWorker.MAX_DATACENTER_ID + 1); + } + } catch (Exception e) { + System.out.println(" getDatacenterId: " + e.getMessage()); + } + return id; + } + + + + +} diff --git a/qa-service/qa-service-domain/pom.xml b/qa-service/qa-service-domain/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..58f67afc28696872aff07a8951d4ab7380489c26 --- /dev/null +++ b/qa-service/qa-service-domain/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + com.example + qa-service-domain + 0.0.1-SNAPSHOT + qa-service-domain + qa-service-domain + + + com.example + qa-service + 0.0.1-SNAPSHOT + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.projectlombok + lombok + provided + + + + com.example + qa-service-common + 0.0.1-SNAPSHOT + + + + + + + + + + + + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 21 + 21 + UTF-8 + + + + + + + diff --git a/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/QA.java b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/QA.java new file mode 100644 index 0000000000000000000000000000000000000000..c40b3bb8b50adb5ed894a91f9236691c5fa3da7b --- /dev/null +++ b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/QA.java @@ -0,0 +1,48 @@ +package com.example.qa.service.domain; + +import com.example.qa.service.common.IdWorker; +import com.example.qa.service.domain.port.GetQAListPort; +import com.example.qa.service.domain.valueobject.Answer; +import com.example.qa.service.domain.valueobject.QAId; +import com.example.qa.service.domain.valueobject.Question; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.util.List; + +@Setter +@Getter +@ToString +/** + * @author yangwenkai + * QA类 + */ +public class QA { + private QAId id; + private Question question; + private Answer answer; + + public QA() { + } + + public QA(QAId id, Question question, Answer answer) { + this.id = id; + this.question = question; + this.answer = answer; + } + + public QA(Question question, Answer answer) { + this.id= genId(); + this.question = question; + this.answer = answer; + } + + public static List getQAs(GetQAListPort getQAListPort){ + return getQAListPort.getQAs(); + } + + public QAId genId(){ + return new QAId(new IdWorker().nextId()); + } +} diff --git a/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/CreateQAPort.java b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/CreateQAPort.java new file mode 100644 index 0000000000000000000000000000000000000000..aeb293a24a7b7f71effce1863e805726e786cf58 --- /dev/null +++ b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/CreateQAPort.java @@ -0,0 +1,11 @@ +package com.example.qa.service.domain.port; + +import com.example.qa.service.domain.QA; + +/** + * @author yangwenkai + * 创建QA的端口 + */ +public interface CreateQAPort { + QA createQA(QA qa); +} diff --git a/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/DeleteQAPort.java b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/DeleteQAPort.java new file mode 100644 index 0000000000000000000000000000000000000000..2ea8e475eb4d2f5e5df59009a0a16960b9b998ff --- /dev/null +++ b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/DeleteQAPort.java @@ -0,0 +1,8 @@ +package com.example.qa.service.domain.port; + +/** + * @author qiuyujie + */ +public interface DeleteQAPort { + void deleteQA(Long id); +} diff --git a/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/GetQAByIdPort.java b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/GetQAByIdPort.java new file mode 100644 index 0000000000000000000000000000000000000000..fdc3b92c6866260c51aaeeaa7d8a5bc90486037b --- /dev/null +++ b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/GetQAByIdPort.java @@ -0,0 +1,10 @@ +package com.example.qa.service.domain.port; + +import com.example.qa.service.domain.QA; + +/** + * @author dengli + */ +public interface GetQAByIdPort { + QA getQAById(long id); +} diff --git a/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/GetQAListPort.java b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/GetQAListPort.java new file mode 100644 index 0000000000000000000000000000000000000000..3fc891d5e12b7ecac4e4b6afdba25780db31b214 --- /dev/null +++ b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/GetQAListPort.java @@ -0,0 +1,11 @@ +package com.example.qa.service.domain.port; + +import com.example.qa.service.domain.QA; + +import java.util.List; +/** + * @author wangqingqing + */ +public interface GetQAListPort { + List getQAs(); +} diff --git a/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/KimiAiPort.java b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/KimiAiPort.java new file mode 100644 index 0000000000000000000000000000000000000000..8eb8269247e0e423b6196fa2a1e181e0d907645f --- /dev/null +++ b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/KimiAiPort.java @@ -0,0 +1,16 @@ +package com.example.qa.service.domain.port; + +/** + * KIMI AI调用端口接口 + * 定义了与外部AI服务交互的契约 + * @author yangwenkai + */ +public interface KimiAiPort { + + /** + * 调用KIMI AI获取问题的答案 + * @param question 用户提出的问题 + * @return AI生成的答案 + */ + String getAnswer(String question); +} \ No newline at end of file diff --git a/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/MessageQueuePort.java b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/MessageQueuePort.java new file mode 100644 index 0000000000000000000000000000000000000000..1b498eaaa8dbcb4203d8f55e7f6e764de9bd61af --- /dev/null +++ b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/MessageQueuePort.java @@ -0,0 +1,22 @@ +package com.example.qa.service.domain.port; + +/** + * 消息队列端口接口 + * 定义了与消息队列交互的契约 + * @author yangwenkai + */ +public interface MessageQueuePort { + + /** + * 发送问题到消息队列进行异步处理 + * @param question 用户提出的问题 + */ + void sendQuestionToQueue(String question); + + /** + * 发送问答对到消息队列进行数据库保存 + * @param question 问题 + * @param answer 答案 + */ + void sendQAToSaveQueue(String question, String answer); +} \ No newline at end of file diff --git a/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/QARepositoryPort.java b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/QARepositoryPort.java new file mode 100644 index 0000000000000000000000000000000000000000..cefd047fc35c06f54b7e0ed9fb38b233aa0d90a2 --- /dev/null +++ b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/QARepositoryPort.java @@ -0,0 +1,17 @@ +package com.example.qa.service.domain.port; + +/** + * QA仓储端口接口 + * 定义问答数据的持久化操作 + * @author yangwenkai + */ +public interface QARepositoryPort { + + /** + * 保存问答对到数据库 + * 将问题和答案保存为一条记录 + * @param question 问题内容 + * @param answer 答案内容 + */ + void saveQA(String question, String answer); +} \ No newline at end of file diff --git a/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/UpdateQAPort.java b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/UpdateQAPort.java new file mode 100644 index 0000000000000000000000000000000000000000..5713b796ac0f3a0ec2fc46c8336bcfcc2357ede6 --- /dev/null +++ b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/port/UpdateQAPort.java @@ -0,0 +1,9 @@ +package com.example.qa.service.domain.port; + +import com.example.qa.service.domain.QA; +/** + * @author lizishan + */ +public interface UpdateQAPort { + QA updateQA(QA qa); +} diff --git a/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/valueobject/Answer.java b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/valueobject/Answer.java new file mode 100644 index 0000000000000000000000000000000000000000..8a34b3c6566dccc0ca7ffc28636c7f2a0de8e817 --- /dev/null +++ b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/valueobject/Answer.java @@ -0,0 +1,10 @@ +package com.example.qa.service.domain.valueobject; + +/** + * @author qiuyujie + */ +public record Answer(String answer) { + public String getValue() { + return answer; + } +} diff --git a/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/valueobject/QAId.java b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/valueobject/QAId.java new file mode 100644 index 0000000000000000000000000000000000000000..a423600d57ed7a90faf87eace687a414c0b76d37 --- /dev/null +++ b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/valueobject/QAId.java @@ -0,0 +1,10 @@ +package com.example.qa.service.domain.valueobject; + +/** + * @author dengli + */ +public record QAId(long id) { + public long getValue() { + return id; + } +} diff --git a/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/valueobject/QAMessageDTO.java b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/valueobject/QAMessageDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..2934e6a71fa675ef079956f66605ae5b70d0740a --- /dev/null +++ b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/valueobject/QAMessageDTO.java @@ -0,0 +1,38 @@ +package com.example.qa.service.domain.valueobject; + +import lombok.Data; + +/** + * QA消息DTO + * 用于在消息队列中传递问答数据的值对象 + * @author yangwenkai + */ +@Data +public class QAMessageDTO { + + /** + * 问题内容 + */ + private String question; + + /** + * 答案内容 + */ + private String answer; + + /** + * 默认构造函数 + */ + public QAMessageDTO() { + } + + /** + * 带参数的构造函数 + * @param question 问题内容 + * @param answer 答案内容 + */ + public QAMessageDTO(String question, String answer) { + this.question = question; + this.answer = answer; + } +} \ No newline at end of file diff --git a/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/valueobject/Question.java b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/valueobject/Question.java new file mode 100644 index 0000000000000000000000000000000000000000..773a8f95b7ddc44f57555c60fc3f4ff3b5d71a60 --- /dev/null +++ b/qa-service/qa-service-domain/src/main/java/com/example/qa/service/domain/valueobject/Question.java @@ -0,0 +1,9 @@ +package com.example.qa.service.domain.valueobject; +/** + * @author wangqingqing + */ +public record Question(String question) { + public String getValue() { + return question; + } +} diff --git a/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/pom.xml b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/pom.xml index b673beeab279b6a48e1682bc66bcab53fa002d75..ea11873083652477b60fb3755988fe94724b7f84 100644 --- a/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/pom.xml +++ b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/pom.xml @@ -19,6 +19,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-security + org.springframework.boot @@ -41,6 +45,12 @@ 0.0.1-SNAPSHOT + + com.example + user-adapter-out-persistence + 0.0.1-SNAPSHOT + + com.github.xiaoymin knife4j-openapi3-jakarta-spring-boot-starter diff --git a/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/config/SecurityConfig.java b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/config/SecurityConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..985dac5f7d092fdfa460e71e0553ce59cd174b82 --- /dev/null +++ b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/config/SecurityConfig.java @@ -0,0 +1,209 @@ +package com.example.user.adapter.in.web.config; + +import com.example.user.adapter.in.web.filter.JwtAuthenticationFilter; +import com.example.user.adapter.in.web.service.CustomUserDetailsService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +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.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 java.util.Arrays; +import java.util.List; + +/** + * Spring Security 安全配置类 + * + * 这个类负责配置整个用户服务的认证和授权策略,包括: + * 1. JWT Token认证机制 + * 2. 跨域资源共享(CORS)配置 + * 3. 请求授权规则 + * 4. 密码加密策略 + * 5. 会话管理策略 + * + * 注解说明: + * @Configuration - 标记为Spring配置类 + * @EnableWebSecurity - 启用Spring Security的Web安全支持 + * @EnableMethodSecurity - 启用方法级别的安全控制 + * @RequiredArgsConstructor - Lombok注解,自动生成构造函数 + */ +@Configuration +@EnableWebSecurity +@EnableMethodSecurity(prePostEnabled = true) +@RequiredArgsConstructor +public class SecurityConfig { + + /** + * JWT认证过滤器 - 用于拦截请求并验证JWT Token + */ + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + /** + * 自定义用户详情服务 - 用于加载用户信息进行认证 + */ + private final CustomUserDetailsService userDetailsService; + + /** + * 认证管理器Bean + * + * 这个方法创建并配置Spring Security的认证管理器 + * 认证管理器负责处理用户认证请求,比如验证用户名和密码 + * + * @param config Spring Security的认证配置对象,由Spring自动注入 + * @return AuthenticationManager 认证管理器实例 + * @throws Exception 如果配置过程中出现异常 + */ + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + /** + * 密码编码器Bean + * + * 这个方法配置BCrypt密码编码器,用于: + * 1. 用户注册时加密密码 + * 2. 用户登录时验证密码 + * + * BCrypt是一种安全的密码哈希算法,会自动处理salt和哈希迭代 + * + * @return PasswordEncoder 密码编码器实例 + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** + * CORS配置源 + * + * 配置跨域资源共享策略,允许前端应用访问后端API + * 支持所有HTTP方法、所有请求头,并允许携带凭证 + * + * @return CorsConfigurationSource CORS配置源 + */ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + // 允许所有域名访问(生产环境应限制为具体域名) + configuration.setAllowedOriginPatterns(List.of("*")); + + // 允许所有HTTP方法 + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); + + // 允许所有请求头 + configuration.setAllowedHeaders(List.of("*")); + + // 允许携带凭证(如Cookie) + configuration.setAllowCredentials(true); + + // 预检请求缓存时间(1小时) + configuration.setMaxAge(3600L); + + // 暴露给客户端的响应头 + configuration.setExposedHeaders(Arrays.asList("Authorization", "Content-Type")); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + /** + * 安全过滤器链配置 + * + * 这是Spring Security的核心配置方法,定义了整个应用的安全策略: + * 1. 禁用CSRF保护(前后端分离项目不需要) + * 2. 启用CORS跨域支持 + * 3. 配置无状态会话管理(使用JWT,不需要Session) + * 4. 设置请求授权规则 + * 5. 添加JWT认证过滤器 + * 6. 配置异常处理 + * + * @param http HttpSecurity对象,用于配置Web安全 + * @return SecurityFilterChain 安全过滤器链 + * @throws Exception 如果配置过程中出现异常 + */ + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + // 禁用CSRF保护(前后端分离项目通常禁用) + .csrf(AbstractHttpConfigurer::disable) + + // 启用CORS并使用上面定义的配置 + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + + // 配置无状态会话管理(使用JWT Token认证) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + + // 配置请求授权规则 + .authorizeHttpRequests(authz -> authz + // 公开接口,无需认证即可访问 + .requestMatchers("/users/login").permitAll() // 登录接口 + .requestMatchers("/users/register").permitAll() // 注册接口 + // .requestMatchers("/users/test").permitAll() // 测试接口需要认证 + + // 需要认证的接口 + .requestMatchers("/users/logout").authenticated() // 登出接口需要认证 + .requestMatchers("/users/me").authenticated() // 获取用户信息接口需要认证 + + // API文档接口放行 + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**").permitAll() + .requestMatchers("/doc.html", "/webjars/**", "/favicon.ico").permitAll() + + // 监控接口放行 + .requestMatchers("/actuator/**").permitAll() + .requestMatchers("/error").permitAll() + + // 其他所有请求都需要认证 + .anyRequest().authenticated() + ) + + // 禁用默认的登录页面 + .formLogin(AbstractHttpConfigurer::disable) + + // 禁用默认的登出页面 + .logout(AbstractHttpConfigurer::disable) + + // 禁用HTTP Basic认证 + .httpBasic(AbstractHttpConfigurer::disable) + + // 添加JWT认证过滤器(在用户名密码认证过滤器之前) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + + // 配置用户详情服务 + .userDetailsService(userDetailsService) + + // 配置异常处理 + .exceptionHandling(exceptions -> exceptions + // 认证入口点(未认证用户访问需要认证的资源时调用) + .authenticationEntryPoint((request, response, authException) -> { + response.setStatus(401); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":401,\"message\":\"未授权访问,请先登录\",\"data\":null}"); + }) + // 访问拒绝处理器(已认证用户访问没有权限的资源时调用) + .accessDeniedHandler((request, response, accessDeniedException) -> { + response.setStatus(403); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":403,\"message\":\"访问被拒绝,权限不足\",\"data\":null}"); + }) + ); + + return http.build(); + } +} \ No newline at end of file diff --git a/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/controller/UserController.java b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/controller/UserController.java index 96820c00d7cf18e1f698ddc65b3e279601b5d576..88cdc35b496d43b4b4c6c9927d05b0a75fada16d 100644 --- a/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/controller/UserController.java +++ b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/controller/UserController.java @@ -1,19 +1,32 @@ package com.example.user.adapter.in.web.controller; import com.example.user.adapter.in.web.dto.CreateUserRequestDTO; +import com.example.user.adapter.in.web.dto.LoginResponse; +import com.example.user.adapter.in.web.dto.Result; import com.example.user.adapter.in.web.dto.UpdateUserRequestDTO; +import com.example.user.adapter.in.web.dto.UserInfo; import com.example.user.adapter.in.web.dto.UserLoginRequestDTO; import com.example.user.adapter.in.web.dto.UserResponseDTO; import com.example.user.service.application.command.CreateUserCommand; import com.example.user.service.application.command.UpdateUserCommand; import com.example.user.service.application.command.UserLoginCommand; import com.example.user.service.application.port.in.*; +import com.example.user.service.application.util.JwtUtil; +import com.example.user.service.application.service.TokenBlacklistService; +import com.example.user.service.domain.port.GetUserByNamePort; import com.example.user.service.domain.User; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @Slf4j @RequestMapping("/users") @@ -27,20 +40,214 @@ public class UserController { private final UpdateUserUseCase updateUserUseCase; private final GetUserByIdUseCase getUserByIdUseCase; private final UserLoginUseCase userLoginUseCase; + private final JwtUtil jwtUtil; + private final TokenBlacklistService tokenBlacklistService; + private final GetUserByNamePort getUserByNamePort; + /** + * 用户登录接口 + * @author yangwenkai + * 这个接口用于用户登录验证,验证成功后返回JWT令牌和用户信息 + * + * 实现流程: + * 1. 验证请求参数 + * 2. 调用登录用侌生成JWT令牌 + * 3. 获取用户详细信息 + * 4. 构建登录响应对象 + * 5. 返回统一格式的响应结果 + * + * @param userLoginRequestDTO 登录请求数据,包含用户名和密码 + * @return Result 登录成功的响应数据,包含JWT令牌和用户信息 + */ @PostMapping("login") - public String login(@RequestBody UserLoginRequestDTO userLoginRequestDTO){ - log.info("UserLoginRequestDTO:{}",userLoginRequestDTO); - UserLoginCommand command=UserLoginCommand.builder() - .name(userLoginRequestDTO.getName()) - .password(userLoginRequestDTO.getPassword()) - .build(); - String token = userLoginUseCase.login(command); - return token; + public Result login(@RequestBody UserLoginRequestDTO userLoginRequestDTO){ + log.info("接收到登录请求:{}", userLoginRequestDTO); + + try { + // 验证请求参数 + if (userLoginRequestDTO.getUsername() == null || userLoginRequestDTO.getUsername().trim().isEmpty()) { + return Result.error("用户名不能为空"); + } + if (userLoginRequestDTO.getPassword() == null || userLoginRequestDTO.getPassword().trim().isEmpty()) { + return Result.error("密码不能为空"); + } + + // 创建登录命令对象 + UserLoginCommand command = new UserLoginCommand( + userLoginRequestDTO.getUsername().trim(), + userLoginRequestDTO.getPassword().trim() + ); + + // 调用登录用侌,生成JWT令牌 + String token = userLoginUseCase.login(command); + + // 获取用户详细信息 + User user = getUserByNamePort.getUserByName(userLoginRequestDTO.getUsername().trim()); + if (user == null) { + return Result.error("用户信息获取失败"); + } + + // 构建UserInfo对象 + UserInfo userInfo = UserInfo.builder() + .id(user.getId().getValue()) + .username(user.getUsername().getValue()) + .email(user.getEmail().getValue()) + .phone(user.getPhone() != null ? user.getPhone().getValue() : null) + .realName(user.getRealName() != null ? user.getRealName().getValue() : null) + .status(user.getStatus().getValue()) + .role(user.getRole().getValue()) + .createTime(user.getCreateTime().getValue()) + .lastLoginTime(user.getLastLoginTime() != null ? user.getLastLoginTime().getValue() : null) + .build(); + + // 构建LoginResponse对象 + LoginResponse loginResponse = LoginResponse.of( + token, // JWT令牌 + userInfo, // 用户信息 + System.currentTimeMillis() + jwtUtil.expiration // 过期时间戳(当前时间 + 过期时长) + ); + + log.info("用户 {} 登录成功,生成JWT令牌", userLoginRequestDTO.getUsername()); + + // 返回统一格式的成功响应 + return Result.success("登录成功", loginResponse); + + } catch (RuntimeException e) { + // 登录失败的情况(用户不存在、密码错误等) + log.warn("登录失败:{}", e.getMessage()); + return Result.error(e.getMessage()); + + } catch (Exception e) { + // 其他异常情况 + log.error("登录过程中发生异常:{}", e.getMessage(), e); + return Result.serverError("登录系统异常,请联系管理员"); + } } + /** + * 用户登出接口 + * @author qiuyujie dengli + * 实现用户登出功能,将JWT令牌加入黑名单并清除SecurityContext + * 参考learn文件夹中的登出实现,适配当前项目架构 + * + * 主要流程: + * 1. 从请求头中提取JWT令牌 + * 2. 验证令牌的有效性 + * 3. 将令牌加入黑名单 + * 4. 清除当前的安全上下文 + * 5. 返回登出结果 + * + * @param request HTTP请求对象,用于获取Authorization头 + * @return Result 登出结果响应 + */ + @PostMapping("/logout") + public Result logout(jakarta.servlet.http.HttpServletRequest request) { + try { + // 1. 从请求头中提取JWT令牌 + String authorizationHeader = request.getHeader("Authorization"); + String token = jwtUtil.extractTokenFromHeader(authorizationHeader); + + if (token == null || token.trim().isEmpty()) { + log.warn("登出失败: 未找到有效的JWT令牌"); + return Result.error("未找到有效的登录令牌"); + } + + // 2. 从令牌中获取用户名用于验证 + String username = jwtUtil.getUsernameFromToken(token); + if (username == null) { + log.warn("登出失败: 无法从令牌中获取用户名"); + return Result.error("登录令牌格式错误"); + } + + // 3. 验证令牌的有效性(排除已过期的令牌) + if (!jwtUtil.validateToken(token, username)) { + log.warn("登出失败: JWT令牌无效或已过期"); + return Result.error("登录令牌无效或已过期"); + } + + // 4. 将令牌加入黑名单 + tokenBlacklistService.addToBlacklist(token); + + // 5. 清除当前的安全上下文 + SecurityContextHolder.clearContext(); + + log.info("用户登出成功: token已加入黑名单"); + return Result.success("登出成功"); + + } catch (Exception e) { + log.error("登出失败: 系统错误 - {}", e.getMessage(), e); + return Result.error("登出失败,请稍后重试"); + } + } + /** + * 获取当前用户信息接口 + * + * 基于JWT令牌获取当前登录用户的详细信息 + * 参考learn文件夹中的用户信息获取实现,适配当前项目架构 + * + * 工作流程: + * 1. 从SecurityContext获取当前认证信息 + * 2. 验证用户是否已登录 + * 3. 根据用户名查询用户详细信息 + * 4. 构建并返回用户信息响应 + * + * 安全机制: + * - JWT令牌验证:确保令牌未被篡改且未过期 + * - 用户状态检查:确保用户账户仍然有效 + * - 敏感信息过滤:返回的用户信息不包含密码等敏感数据 + * + * @return Result 当前用户信息响应 + */ + @GetMapping("/me") + public Result getCurrentUser() { + try { + // 1. 从SecurityContext获取当前认证信息 + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 2. 验证用户是否已登录 + if (authentication == null || !authentication.isAuthenticated() || + authentication instanceof AnonymousAuthenticationToken) { + log.warn("获取用户信息失败: 用户未登录"); + return Result.error("用户未登录,请先登录"); + } + + // 3. 获取用户名 + String username = authentication.getName(); + if (username == null || username.trim().isEmpty()) { + log.warn("获取用户信息失败: 无法获取用户名"); + return Result.error("无法获取用户信息"); + } + + // 4. 根据用户名查询用户详细信息 + User user = getUserByNamePort.getUserByName(username.trim()); + if (user == null) { + log.warn("获取用户信息失败: 用户不存在 - {}", username); + return Result.error("用户信息不存在"); + } + + // 5. 构建用户信息响应对象 + UserInfo userInfo = UserInfo.builder() + .id(user.getId().getValue()) + .username(user.getUsername().getValue()) + .email(user.getEmail().getValue()) + .phone(user.getPhone() != null ? user.getPhone().getValue() : null) + .realName(user.getRealName() != null ? user.getRealName().getValue() : null) + .status(user.getStatus().getValue()) + .role(user.getRole().getValue()) + .createTime(user.getCreateTime().getValue()) + .lastLoginTime(user.getLastLoginTime() != null ? user.getLastLoginTime().getValue() : null) + .build(); + + log.info("获取用户信息成功: username={}, id={}", username, user.getId().getValue()); + return Result.success(userInfo); + + } catch (Exception e) { + log.error("获取用户信息失败: 系统错误 - {}", e.getMessage(), e); + return Result.error("获取用户信息失败,请稍后重试"); + } + } @GetMapping("") public List getUsers() { @@ -49,24 +256,91 @@ public class UserController { } /** - * 创建新用户 - * 功能:接收用户注册信息,验证密码一致性,创建新用户账户 - * @author dongxuanfeng - * @param createUserRequestDTO - * @return User - 成功创建的新用户 - * @throws IllegalArgumentException 当密码与确认密码不匹配时抛出此异常 + * 用户注册接口 + * @author wangqingqing lizishan + * 实现用户注册功能,包括用户名重复检查、密码验证和加密等 + * 参考learn文件夹中的注册实现,适配当前项目架构 + * + * 主要流程: + * 1. 验证请求参数的有效性 + * 2. 检查用户名是否已经存在 + * 3. 验证密码格式和一致性 + * 4. 创建新用户(密码会在领域层自动加密) + * 5. 返回注册结果 + * + * @param createUserRequestDTO 注册请求数据,包含用户名、密码、邮箱等信息 + * @return Result 注册结果响应 + */ + @PostMapping("/register") + public Result register(@RequestBody CreateUserRequestDTO createUserRequestDTO){ + try { + log.info("用户注册请求: username={}, email={}", createUserRequestDTO.username(), createUserRequestDTO.email()); + + // 1. 验证请求参数 + if (createUserRequestDTO.username() == null || createUserRequestDTO.username().trim().isEmpty()) { + return Result.error("用户名不能为空"); + } + if (createUserRequestDTO.email() == null || createUserRequestDTO.email().trim().isEmpty()) { + return Result.error("邮箱不能为空"); + } + + // 2. 检查用户名是否已存在 + User existingUser = getUserByNamePort.getUserByName(createUserRequestDTO.username().trim()); + if (existingUser != null) { + log.warn("注册失败: 用户名已存在 - {}", createUserRequestDTO.username()); + return Result.error("用户名已存在,请选择其他用户名"); + } + + // 3. 验证密码格式和一致性 + createUserRequestDTO.validatePassword(); + + // 4. 创建用户命令对象 + CreateUserCommand command = CreateUserCommand.builder() + .username(createUserRequestDTO.username().trim()) + .email(createUserRequestDTO.email().trim()) + .password(createUserRequestDTO.password()) + .phone(createUserRequestDTO.phone()) + .realName(createUserRequestDTO.realName()) + .build(); + + // 5. 创建新用户 + User newUser = createUserUseCase.createUser(command); + + log.info("用户注册成功: username={}, id={}", newUser.getUsername().getValue(), newUser.getId().getValue()); + return Result.success("注册成功"); + + } catch (IllegalArgumentException e) { + log.warn("注册失败: 参数验证错误 - {}", e.getMessage()); + return Result.error(e.getMessage()); + } catch (Exception e) { + log.error("注册失败: 系统错误 - {}", e.getMessage(), e); + return Result.error("注册失败,请稍后重试"); + } + } + + /** + * 创建用户接口(管理员使用) + * + * 保留原有的创建用户功能,供管理员或系统内部使用 + * 与注册接口的区别:不进行用户名重复检查,直接创建用户 + * + * @param createUserRequestDTO 创建用户请求数据 + * @return User 创建的用户对象 */ @PostMapping() public User createUser(@RequestBody CreateUserRequestDTO createUserRequestDTO){ + // 验证密码是否符合要求 + createUserRequestDTO.validatePassword(); if (!createUserRequestDTO.isPasswordValid()) { throw new IllegalArgumentException("密码和确认密码不匹配"); } CreateUserCommand command=CreateUserCommand.builder() - .name(createUserRequestDTO.name()) - .age(createUserRequestDTO.age()) + .username(createUserRequestDTO.username()) .email(createUserRequestDTO.email()) .password(createUserRequestDTO.password()) + .phone(createUserRequestDTO.phone()) + .realName(createUserRequestDTO.realName()) .build(); return createUserUseCase.createUser(command); @@ -84,9 +358,12 @@ public class UserController { public User updateUser(@RequestBody UpdateUserRequestDTO updateUserRequestDTO){ UpdateUserCommand command=UpdateUserCommand.builder() .id(updateUserRequestDTO.id()) - .name(updateUserRequestDTO.name()) - .age(updateUserRequestDTO.age()) + .username(updateUserRequestDTO.username()) .email(updateUserRequestDTO.email()) + .phone(updateUserRequestDTO.phone()) + .realName(updateUserRequestDTO.realName()) + .status(updateUserRequestDTO.status()) + .role(updateUserRequestDTO.role()) .build(); User user = updateUserUseCase.updateUser(command); return user; @@ -98,12 +375,44 @@ public class UserController { public UserResponseDTO getUserById(@PathVariable("id") Long id){ User user = getUserByIdUseCase.getUserById(id); UserResponseDTO userResponseDTO = new UserResponseDTO( - user.getId().id(), - user.getName().username(), - user.getAge().age(), - user.getEmail().email(), - user.getIsSuper().value()); + user.getId().getValue(), + user.getUsername().getValue(), + user.getEmail().getValue(), + user.getPhone() != null ? user.getPhone().getValue() : null, + user.getRealName() != null ? user.getRealName().getValue() : null, + user.getStatus().getValue(), + user.getRole().getValue()); return userResponseDTO; } + /** + * 测试接口 - 验证JWT认证功能 + * 这个接口用于测试认证系统是否正常工作 + * 返回当前认证用户的信息 + * @author qiuyujie dengli wangqingqing lizishan + * @return Map 包含认证状态和用户信息 + */ + @GetMapping("test") + public Map testAuthentication() { + Map result = new HashMap<>(); + result.put("status", "success"); + result.put("message", "测试接口调用成功"); + result.put("timestamp", System.currentTimeMillis()); + + // 获取当前认证信息 + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.isAuthenticated() && + !(authentication instanceof AnonymousAuthenticationToken)) { + result.put("authenticated", true); + result.put("username", authentication.getName()); + result.put("authorities", authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList())); + } else { + result.put("authenticated", false); + result.put("message", "用户未认证,请先登录获取token"); + } + + return result; + } } diff --git a/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/CreateUserRequestDTO.java b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/CreateUserRequestDTO.java index 574d0e0d349d44bf914066a763d8f59390c32203..fdffac30c7ce2317a30fb207825edf2d1b6ddc08 100644 --- a/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/CreateUserRequestDTO.java +++ b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/CreateUserRequestDTO.java @@ -1,20 +1,22 @@ package com.example.user.adapter.in.web.dto; +import com.example.user.service.domain.valueobject.Password; + public record CreateUserRequestDTO( - String name, - Integer age, + String username, String email, String password, - String rePassword) { - // TODO: 密码校验 - + String rePassword, + String phone, + String realName) { + /** - * 验证密码与确认密码是否一致 - * @author dongxuanfeng - * @return boolean -验证结果:true表示密码与重复密码一致,false表示两次密码不一致 + * 验证密码是否符合要求 + * 包括密码长度、复杂度以及密码和确认密码的一致性 + * @author yangwenkai + * @throws IllegalArgumentException 如果密码不符合要求 */ - public boolean isPasswordValid() { - return password != null && password.equals(rePassword); + public void validatePassword() { + Password.validatePassword(password, rePassword); } - } diff --git a/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/LoginResponse.java b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/LoginResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..10c061f55abb4aedf82929eb5831e3eac218b9d7 --- /dev/null +++ b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/LoginResponse.java @@ -0,0 +1,82 @@ +package com.example.user.adapter.in.web.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; +import lombok.Builder; + +/** + * 登录响应DTO + * 专门用于封装登录成功后的响应数据 + * + * 这个类包含了登录成功后需要返回给前端的所有信息 + * 包括JWT令牌、令牌类型、用户信息、过期时间等 + * + * 使用Builder模式可以方便地构建复杂的对象 + * 例如:LoginResponse.builder().accessToken("token").userInfo(userInfo).build() + * + * @Data Lombok注解,自动生成getter、setter、toString等方法 + * @Builder Lombok注解,提供Builder模式构建对象 + * @NoArgsConstructor Lombok注解,生成无参构造函数 + * @AllArgsConstructor Lombok注解,生成全参构造函数 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LoginResponse { + + /** + * 访问令牌 (Access Token) + * + * JWT格式的令牌,用于后续API请求的身份认证 + * 前端需要在请求头中携带这个令牌:Authorization: Bearer + * 令牌中包含了用户的基本信息和权限信息 + */ + private String accessToken; + + /** + * 令牌类型 (Token Type) + * + * 指定令牌的类型,通常是"Bearer" + * Bearer是一种认证方案,表示持有令牌即可访问资源 + * 这个字段告诉前端如何正确使用这个令牌 + */ + private String tokenType; + + /** + * 用户信息 + * + * 包含登录用户的基本信息,如用户名、邮箱、角色等 + * 前端可以使用这些信息来显示用户界面、控制权限等 + * 使用UserInfo类来封装用户信息,保持数据结构清晰 + */ + private UserInfo userInfo; + + /** + * 过期时间戳 (Expires In) + * + * 令牌的有效期,单位是毫秒 + * 表示从当前时间开始,令牌还有多少毫秒会过期 + * 前端可以根据这个时间戳来判断是否需要刷新令牌 + * 例如:7200000 表示2小时(2 * 60 * 60 * 1000) + */ + private Long expiresIn; + + /** + * 创建登录响应对象的便捷方法 + * + * @param accessToken JWT访问令牌 + * @param userInfo 用户信息对象 + * @param expiresIn 过期时间(毫秒) + * @return 构建好的LoginResponse对象 + */ + public static LoginResponse of(String accessToken, UserInfo userInfo, Long expiresIn) { + return LoginResponse.builder() + .accessToken(accessToken) + .tokenType("Bearer") + .userInfo(userInfo) + .expiresIn(expiresIn) + .build(); + } +} \ No newline at end of file diff --git a/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/Result.java b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/Result.java new file mode 100644 index 0000000000000000000000000000000000000000..42c60eb18896114c5c0530f1908bb88fac4d4e63 --- /dev/null +++ b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/Result.java @@ -0,0 +1,138 @@ +package com.example.user.adapter.in.web.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +/** + * 通用响应结果类 + * 用于封装所有API接口的响应数据,统一返回格式 + * + * 这样做有以下好处: + * 1. 前端可以统一处理响应格式 + * 2. 便于统一错误处理 + * 3. 提高代码的可维护性 + * + * 使用泛型可以让这个类适用于任何类型的数据 + * + * @Data Lombok注解,自动生成getter、setter、toString等方法 + * @NoArgsConstructor Lombok注解,生成无参构造函数 + * @AllArgsConstructor Lombok注解,生成全参构造函数 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Result { + + /** + * 响应码 + * + * 用于表示请求处理的结果状态 + * 常见的响应码: + * 200 - 成功 + * 400 - 请求参数错误 + * 401 - 未授权 + * 403 - 禁止访问 + * 500 - 服务器内部错误 + */ + private Integer code; + + /** + * 响应消息 + * + * 用于描述请求处理的结果信息 + * 成功时可以是"操作成功" + * 失败时可以是具体的错误信息,如"用户名或密码错误" + */ + private String message; + + /** + * 响应数据 + * + * 用于携带具体的业务数据 + * 可以是任何类型,如用户信息、列表数据等 + * 使用泛型T使得这个字段可以适应不同类型的数据 + */ + private T data; + + /** + * 成功响应静态方法 + * + * @param message 成功消息 + * @param data 响应数据 + * @return Result 成功响应对象 + */ + public static Result success(String message, T data) { + Result result = new Result<>(); + result.setCode(200); + result.setMessage(message); + result.setData(data); + return result; + } + + /** + * 成功响应静态方法(简化版) + * + * @param data 响应数据 + * @return Result 成功响应对象 + */ + public static Result success(T data) { + return success("操作成功", data); + } + + /** + * 错误响应静态方法 + * + * @param message 错误消息 + * @return Result 错误响应对象 + */ + public static Result error(String message) { + Result result = new Result<>(); + result.setCode(400); + result.setMessage(message); + result.setData(null); + return result; + } + + /** + * 未授权响应静态方法 + * + * @param message 未授权消息 + * @return Result 未授权响应对象 + */ + public static Result unauthorized(String message) { + Result result = new Result<>(); + result.setCode(401); + result.setMessage(message); + result.setData(null); + return result; + } + + /** + * 禁止访问响应静态方法 + * + * @param message 禁止访问消息 + * @return Result 禁止访问响应对象 + */ + public static Result forbidden(String message) { + Result result = new Result<>(); + result.setCode(403); + result.setMessage(message); + result.setData(null); + return result; + } + + /** + * 服务器错误响应静态方法 + * + * @param message 服务器错误消息 + * @return Result 服务器错误响应对象 + */ + public static Result serverError(String message) { + Result result = new Result<>(); + result.setCode(500); + result.setMessage(message); + result.setData(null); + return result; + } +} \ No newline at end of file diff --git a/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/UpdateUserRequestDTO.java b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/UpdateUserRequestDTO.java index d42ae558a29f0f8fd392aaeae649437aebd8e65e..92ec9af2ad5fb6d23dac4d9e4dbf6e6cc7529bba 100644 --- a/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/UpdateUserRequestDTO.java +++ b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/UpdateUserRequestDTO.java @@ -1,7 +1,10 @@ package com.example.user.adapter.in.web.dto; public record UpdateUserRequestDTO(Long id, - String name, - Integer age, - String email) { + String username, + String email, + String phone, + String realName, + Integer status, + String role) { } diff --git a/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/UserInfo.java b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/UserInfo.java new file mode 100644 index 0000000000000000000000000000000000000000..d3525e39b9ef2c690aab441d45421f873d041697 --- /dev/null +++ b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/UserInfo.java @@ -0,0 +1,102 @@ +package com.example.user.adapter.in.web.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; +import lombok.Builder; + +import java.time.LocalDateTime; + +/** + * 用户信息DTO (Data Transfer Object) + * 用于封装用户的基本信息,在不同层之间传输 + * + * DTO是数据传输对象,主要用于在不同层之间传输数据 + * 这里专门用于传输用户的基本信息,不包含敏感信息如密码 + * + * @Data Lombok注解,自动生成getter、setter、toString等方法 + * @Builder Lombok注解,提供Builder模式构建对象,使代码更清晰易读 + * @NoArgsConstructor Lombok注解,生成无参构造函数 + * @AllArgsConstructor Lombok注解,生成全参构造函数 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserInfo { + + /** + * 用户ID + * + * 数据库中的主键,唯一标识一个用户 + * 使用Long类型可以支持更大的数据量 + */ + private Long id; + + /** + * 用户名 + * + * 用户登录时使用的名称,必须唯一 + * 通常用于登录认证 + */ + private String username; + + /** + * 邮箱 + * + * 用户的电子邮箱地址 + * 可以用于找回密码、接收通知等 + */ + private String email; + + /** + * 手机号 + * + * 用户的手机号码 + * 可以用于登录、找回密码、接收短信通知等 + */ + private String phone; + + /** + * 真实姓名 + * + * 用户的真实姓名 + * 用于实名认证、显示用户真实身份等 + */ + private String realName; + + /** + * 用户状态:0-禁用,1-启用 + * + * 用于控制用户账户是否可以正常使用 + * 0表示账户被禁用,无法登录 + * 1表示账户正常,可以登录使用 + */ + private Integer status; + + /** + * 角色:ADMIN-管理员,USER-普通用户 + * + * 用于区分用户的角色和权限 + * ADMIN表示管理员,拥有更高的权限 + * USER表示普通用户,拥有基本权限 + */ + private String role; + + /** + * 创建时间 + * + * 记录用户账户的创建时间 + * 使用LocalDateTime类型,是Java 8引入的新时间API + * 相比Date类型更加易用和直观 + */ + private LocalDateTime createTime; + + /** + * 最后登录时间 + * + * 记录用户最后一次登录的时间 + * 用于统计用户活跃度、安全监控等 + */ + private LocalDateTime lastLoginTime; +} \ No newline at end of file diff --git a/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/UserLoginRequestDTO.java b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/UserLoginRequestDTO.java index 112434d3ec034fbfe024cf9148d6a805976e0536..c649b3875c74caefdf863c9c3f9b4be4f7fff84e 100644 --- a/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/UserLoginRequestDTO.java +++ b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/UserLoginRequestDTO.java @@ -8,7 +8,7 @@ import lombok.NoArgsConstructor; @AllArgsConstructor @NoArgsConstructor public class UserLoginRequestDTO { - private String name; + private String username; // 这里的password指的是用户输入的密码,而不是数据库中的密码。所以这里应该使用明文,类型是String private String password; } diff --git a/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/UserResponseDTO.java b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/UserResponseDTO.java index 153323ddd9e7c276b93f76c11f1ee300407d600f..0e21ec411ef23a694de3a5a00c608f5ed5e45bae 100644 --- a/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/UserResponseDTO.java +++ b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/dto/UserResponseDTO.java @@ -9,9 +9,12 @@ import lombok.NoArgsConstructor; @NoArgsConstructor public class UserResponseDTO { private long id; - private String name; - private int age; + private String username; private String email; - private Boolean isSuper; // 添加isSuper字段 + private String phone; + private String realName; + private Integer status; + private String role; + } diff --git a/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/filter/JwtAuthenticationFilter.java b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000000000000000000000000000000000000..7212fe1ba3b0035dd8a5e89407ab7fdc855a5c8e --- /dev/null +++ b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/filter/JwtAuthenticationFilter.java @@ -0,0 +1,243 @@ +package com.example.user.adapter.in.web.filter; + +import com.example.user.adapter.in.web.service.CustomUserDetailsService; +import com.example.user.service.application.util.JwtUtil; +import com.example.user.service.application.service.TokenBlacklistService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * JWT认证过滤器 + * + * 这个过滤器负责拦截所有HTTP请求,检查并验证JWT Token + * 如果Token有效,将用户认证信息设置到Spring Security上下文中 + * + * 工作流程: + * 1. 从HTTP请求头中提取JWT Token + * 2. 验证Token的有效性 + * 3. 从Token中提取用户名 + * 4. 加载用户详情信息 + * 5. 设置认证信息到SecurityContext + * 6. 继续过滤器链 + * + * 注解说明: + * @Component - 标记为Spring组件,可以被自动注入 + * @Slf4j - Lombok注解,自动生成日志记录器 + * @RequiredArgsConstructor - 自动生成构造函数,注入依赖 + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + /** + * JWT工具类 - 用于Token的解析和验证 + */ + private final JwtUtil jwtUtil; + + /** + * 自定义用户详情服务 - 用于加载用户信息 + */ + private final CustomUserDetailsService userDetailsService; + + /** + * Token黑名单服务 - 管理已失效的JWT Token + * 用于解决JWT无状态特性导致的logout后token仍然有效的问题 + */ + private final TokenBlacklistService tokenBlacklistService; + + /** + * HTTP请求头中JWT Token的字段名 + * 通常格式为:Authorization: Bearer + */ + private static final String AUTH_HEADER = "Authorization"; + + /** + * Token前缀,用于标识Bearer Token + */ + private static final String TOKEN_PREFIX = "Bearer "; + + /** + * 过滤器的核心方法 + * + * 这个方法在每个HTTP请求到达时被调用,负责: + * 1. 提取和验证JWT Token + * 2. 设置用户认证信息 + * 3. 继续处理请求 + * + * @param request HTTP请求对象 + * @param response HTTP响应对象 + * @param filterChain 过滤器链,用于继续处理请求 + * @throws ServletException 如果处理过程中发生Servlet异常 + * @throws IOException 如果发生IO异常 + */ + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + try { + // 从请求中提取JWT Token + String jwt = extractJwtFromRequest(request); + + // 如果存在Token且格式正确 + if (StringUtils.hasText(jwt)) { + // 检查Token是否在黑名单中(用户已登出) + if (tokenBlacklistService.isBlacklisted(jwt)) { + log.warn("Token已在黑名单中(用户已登出): {}", + jwt.substring(0, Math.min(20, jwt.length())) + "..."); + // 清除认证上下文,确保不会认证成功 + SecurityContextHolder.clearContext(); + // 继续过滤器链,让Spring Security返回401未授权 + filterChain.doFilter(request, response); + return; + } + + // 从Token中提取用户名 + String username = jwtUtil.getUsernameFromToken(jwt); + + // 如果成功提取到用户名且当前上下文中没有认证信息 + if (username != null && + SecurityContextHolder.getContext().getAuthentication() == null) { + + // 加载用户详情信息 + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + // 验证Token的有效性 + if (jwtUtil.validateToken(jwt, username)) { + // 创建认证令牌 + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userDetails, + null, // 密码为null,因为已经通过Token验证 + userDetails.getAuthorities() + ); + + // 设置认证详情(如IP地址、Session ID等) + authentication.setDetails( + new WebAuthenticationDetailsSource().buildDetails(request) + ); + + // 将认证信息设置到SecurityContext中 + SecurityContextHolder.getContext().setAuthentication(authentication); + + log.debug("用户 {} 认证成功,权限: {}", username, userDetails.getAuthorities()); + } else { + log.warn("Token验证失败: {}", jwt); + } + } + } + } catch (Exception e) { + log.error("JWT认证过滤器处理异常: {}", e.getMessage(), e); + + // 清除认证上下文,确保安全 + SecurityContextHolder.clearContext(); + + // 设置错误响应 + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write( + "{\"code\":401,\"message\":\"认证失败: " + e.getMessage() + "\",\"data\":null}" + ); + return; + } + + // 继续过滤器链的处理 + filterChain.doFilter(request, response); + } + + /** + * 从HTTP请求中提取JWT Token + * + * 这个方法从Authorization请求头中提取Bearer Token + * 支持格式:Authorization: Bearer + * + * @param request HTTP请求对象 + * @return String 提取到的JWT Token,如果没有找到返回null + */ + private String extractJwtFromRequest(HttpServletRequest request) { + // 从Authorization头中获取Token + String bearerToken = request.getHeader(AUTH_HEADER); + + // 检查Token是否存在且格式正确 + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(TOKEN_PREFIX)) { + // 去掉"Bearer "前缀,返回纯Token + return bearerToken.substring(TOKEN_PREFIX.length()); + } + + // 也可以从查询参数中获取Token(可选) + String tokenParam = request.getParameter("token"); + if (StringUtils.hasText(tokenParam)) { + return tokenParam; + } + + // 从Cookie中获取Token(可选) + jakarta.servlet.http.Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (jakarta.servlet.http.Cookie cookie : cookies) { + if ("token".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + + return null; + } + + /** + * 检查请求是否需要跳过JWT认证 + * + * 这个方法可以重写以跳过某些不需要认证的请求 + * 比如登录接口、公开API等 + * + * @param request HTTP请求对象 + * @return boolean 如果返回true,则跳过JWT认证 + */ + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getServletPath(); + + // 跳过登录和注册接口(已经在SecurityConfig中配置为公开接口) + if (path.startsWith("/users/login") || path.startsWith("/users/register")) { + return true; + } + + // 测试接口需要认证,不再跳过 + // if (path.startsWith("/users/test")) { + // return true; + // } + + // 跳过API文档接口 + if (path.startsWith("/swagger-ui") || + path.startsWith("/v3/api-docs") || + path.startsWith("/swagger-resources")) { + return true; + } + + // 跳过监控接口 + if (path.startsWith("/actuator")) { + return true; + } + + // 跳过错误页面 + if (path.startsWith("/error")) { + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/service/CustomUserDetailsService.java b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/service/CustomUserDetailsService.java new file mode 100644 index 0000000000000000000000000000000000000000..eef9933a5c6cbc8965b9fbb643ccb156511c4429 --- /dev/null +++ b/user-service/user-service-adapter/user-adapter-in/user-adapter-in-web/src/main/java/com/example/user/adapter/in/web/service/CustomUserDetailsService.java @@ -0,0 +1,246 @@ +package com.example.user.adapter.in.web.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.example.user.adapter.out.persistence.convertor.UserConvertor; +import com.example.user.adapter.out.persistence.entity.UserEntity; +import com.example.user.adapter.out.persistence.mapper.UserMapper; +import com.example.user.service.domain.User; +import com.example.user.service.domain.port.GetUserByNamePort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +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 java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * 自定义用户详情服务 + * + * 这个服务负责加载用户信息,供Spring Security进行认证和授权 + * 实现了UserDetailsService接口,用于根据用户名加载用户详情 + * + * 工作流程: + * 1. 根据用户名从数据库查询用户信息 + * 2. 将用户信息转换为Spring Security的UserDetails对象 + * 3. 设置用户的权限信息 + * 4. 处理用户状态(启用/禁用) + * + * 注解说明: + * @Service - 标记为Spring服务组件 + * @Slf4j - Lombok注解,自动生成日志记录器 + * @RequiredArgsConstructor - 自动生成构造函数,注入依赖 + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + /** + * 用户Mapper - 用于数据库操作 + */ + private final UserMapper userMapper; + + + + /** + * 根据用户名加载用户详情 + * + * 这是Spring Security认证流程的核心方法,负责: + * 1. 从数据库查询用户信息 + * 2. 检查用户是否存在 + * 3. 检查用户状态是否启用 + * 4. 构建UserDetails对象 + * + * @param username 用户名 + * @return UserDetails 用户详情对象,包含用户信息和权限 + * @throws UsernameNotFoundException 如果用户不存在或状态异常 + */ + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + log.info("正在加载用户详情: {}", username); + + try { + // 从数据库查询用户信息 + User user = getUserByUsername(username); + + if (user == null) { + log.warn("用户不存在: {}", username); + throw new UsernameNotFoundException("用户不存在: " + username); + } + + // 检查用户状态 + if (!user.getStatus().isEnabled()) { + log.warn("用户已被禁用: {}", username); + throw new UsernameNotFoundException("用户已被禁用: " + username); + } + + // 构建权限列表 + Collection authorities = getAuthorities(user); + + // 创建Spring Security的UserDetails对象 + return org.springframework.security.core.userdetails.User.builder() + .username(user.getUsername().getValue()) + .password(user.getPassword().encryptedValue()) + .authorities(authorities) + .accountExpired(false) // 账户未过期 + .accountLocked(false) // 账户未锁定 + .credentialsExpired(false) // 凭证未过期 + .disabled(!user.getStatus().isEnabled()) // 账户是否禁用 + .build(); + + } catch (Exception e) { + log.error("加载用户详情失败: {}", e.getMessage(), e); + throw new UsernameNotFoundException("加载用户详情失败: " + e.getMessage(), e); + } + } + + /** + * 根据用户名获取用户信息 + * + * 这个方法从数据库查询用户信息,使用MyBatis-Plus的查询功能 + * + * @param username 用户名 + * @return User 用户领域对象,如果不存在返回null + */ + public User getUserByUsername(String username) { + try { + // 构建查询条件 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(UserEntity::getUsername, username); + + // 执行查询 + UserEntity userEntity = userMapper.selectOne(wrapper); + + if (userEntity == null) { + log.warn("数据库中未找到用户: {}", username); + return null; + } + + log.info("找到用户实体: {}", userEntity); + + // 转换为领域对象 + User user = UserConvertor.toDomain(userEntity); + log.info("转换为用户领域对象: {}", user); + + return user; + + } catch (Exception e) { + log.error("查询用户信息失败: {}", e.getMessage(), e); + return null; + } + } + + /** + * 验证用户密码 + * + * 这个方法验证用户输入的密码是否正确 + * 使用Spring Security的密码编码器进行验证 + * + * @param username 用户名 + * @param rawPassword 原始密码(未加密) + * @return boolean 如果密码正确返回true,否则返回false + */ + public boolean authenticate(String username, String rawPassword) { + try { + // 获取用户信息 + User user = getUserByUsername(username); + + if (user == null) { + log.warn("用户不存在,无法验证密码: {}", username); + return false; + } + + // 验证密码 + boolean passwordMatches = user.validatePassword(rawPassword); + + if (passwordMatches) { + log.info("密码验证成功: {}", username); + } else { + log.warn("密码验证失败: {}", username); + } + + return passwordMatches; + + } catch (Exception e) { + log.error("密码验证过程出错: {}", e.getMessage(), e); + return false; + } + } + + /** + * 获取用户权限 + * + * 这个方法根据用户角色构建权限列表 + * 支持的角色:ADMIN(管理员)、USER(普通用户) + * + * @param user 用户领域对象 + * @return Collection 权限集合 + */ + private Collection getAuthorities(User user) { + List authorities = new ArrayList<>(); + + // 根据用户角色添加权限 + if (user.getRole() != null) { + String role = user.getRole().getValue(); + + if ("ADMIN".equals(role)) { + // 管理员权限 + authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); + authorities.add(new SimpleGrantedAuthority("ROLE_USER")); + authorities.add(new SimpleGrantedAuthority("user:read")); + authorities.add(new SimpleGrantedAuthority("user:write")); + authorities.add(new SimpleGrantedAuthority("user:delete")); + log.info("用户 {} 是管理员,拥有所有权限", user.getUsername().getValue()); + + } else if ("USER".equals(role)) { + // 普通用户权限 + authorities.add(new SimpleGrantedAuthority("ROLE_USER")); + authorities.add(new SimpleGrantedAuthority("user:read")); + authorities.add(new SimpleGrantedAuthority("user:write:self")); + log.info("用户 {} 是普通用户,拥有基本权限", user.getUsername().getValue()); + + } else { + // 默认权限(如果没有指定角色) + authorities.add(new SimpleGrantedAuthority("ROLE_USER")); + authorities.add(new SimpleGrantedAuthority("user:read")); + log.warn("用户 {} 的角色 {} 未知,分配默认权限", user.getUsername().getValue(), role); + } + } else { + // 如果没有角色信息,分配默认权限 + authorities.add(new SimpleGrantedAuthority("ROLE_USER")); + authorities.add(new SimpleGrantedAuthority("user:read")); + log.warn("用户 {} 没有角色信息,分配默认权限", user.getUsername().getValue()); + } + + return authorities; + } + + /** + * 检查用户是否存在 + * + * 这个方法检查指定用户名的用户是否存在 + * + * @param username 用户名 + * @return boolean 如果用户存在返回true,否则返回false + */ + public boolean userExists(String username) { + try { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(UserEntity::getUsername, username); + + Long count = userMapper.selectCount(wrapper); + return count != null && count > 0; + + } catch (Exception e) { + log.error("检查用户是否存在失败: {}", e.getMessage(), e); + return false; + } + } +} \ No newline at end of file diff --git a/user-service/user-service-adapter/user-adapter-out/user-adapter-out-persistence/src/main/java/com/example/user/adapter/out/persistence/bridge/GetUserByNameBridge.java b/user-service/user-service-adapter/user-adapter-out/user-adapter-out-persistence/src/main/java/com/example/user/adapter/out/persistence/bridge/GetUserByNameBridge.java index 38dc3ea5a32a0f658d80085de97fb59d19db3827..0d3e6e67b5f50d2c1ff79ace1d85b59c50ad38c7 100644 --- a/user-service/user-service-adapter/user-adapter-out/user-adapter-out-persistence/src/main/java/com/example/user/adapter/out/persistence/bridge/GetUserByNameBridge.java +++ b/user-service/user-service-adapter/user-adapter-out/user-adapter-out-persistence/src/main/java/com/example/user/adapter/out/persistence/bridge/GetUserByNameBridge.java @@ -16,9 +16,9 @@ public class GetUserByNameBridge implements GetUserByNamePort { @Resource private UserMapper userMapper; @Override - public User getUserByName(String name) { + public User getUserByName(String username) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); - wrapper.eq(UserEntity::getName, name); + wrapper.eq(UserEntity::getUsername, username); UserEntity userEntity = userMapper.selectOne(wrapper); //password不空 log.info("userEntity: {}", userEntity); diff --git a/user-service/user-service-adapter/user-adapter-out/user-adapter-out-persistence/src/main/java/com/example/user/adapter/out/persistence/convertor/UserConvertor.java b/user-service/user-service-adapter/user-adapter-out/user-adapter-out-persistence/src/main/java/com/example/user/adapter/out/persistence/convertor/UserConvertor.java index cc56ab567d7dd43b0fd158c989ea8b8069c882fb..548b94a725ee4c16ca54e34597b9d6318f41a0e5 100644 --- a/user-service/user-service-adapter/user-adapter-out/user-adapter-out-persistence/src/main/java/com/example/user/adapter/out/persistence/convertor/UserConvertor.java +++ b/user-service/user-service-adapter/user-adapter-out/user-adapter-out-persistence/src/main/java/com/example/user/adapter/out/persistence/convertor/UserConvertor.java @@ -22,20 +22,35 @@ public class UserConvertor { ); } - /** - * 将领域对象转换为持久化实体 - * @author dongxuanfeng - * @param user 用户领域对象,包含用户的所有业务属性和行为 - * @return UserEntity 数据库用户实体,包含用户的所有持久化数据 - */ - public static UserEntity toEntity(User user) { - return new UserEntity( - user.getId().id(), - user.getName().username(), - user.getAge().age(), - user.getEmail().email(), - user.getPassword().encryptedValue(), - user.getIsSuper().value() ? 1 : 0 - ); - } + public static User toDomain(UserEntity userEntity) { + return new User( + new UserId(userEntity.getId()), + new UserName(userEntity.getUsername()), + Password.fromEncrypted(userEntity.getPassword()), + new Email(userEntity.getEmail()), + userEntity.getPhone() != null ? new Phone(userEntity.getPhone()) : null, + userEntity.getRealName() != null ? new RealName(userEntity.getRealName()) : null, + new UserStatus(userEntity.getStatus()), + new UserRole(userEntity.getRole()), + new CreateTime(userEntity.getCreateTime()), + new UpdateTime(userEntity.getUpdateTime()), + userEntity.getLastLoginTime() != null ? new LastLoginTime(userEntity.getLastLoginTime()) : null + ); + } + + public static UserEntity toEntity(User user) { + return new UserEntity( + user.getId() != null ? user.getId().getValue() : null, + user.getUsername() != null ? user.getUsername().getValue() : null, + user.getPassword() != null ? user.getPassword().encryptedValue() : null, + user.getEmail() != null ? user.getEmail().getValue() : null, + user.getPhone() != null ? user.getPhone().getValue() : null, + user.getRealName() != null ? user.getRealName().getValue() : null, + user.getStatus() != null ? user.getStatus().getValue() : 1, + user.getRole() != null ? user.getRole().getValue() : "USER", + user.getCreateTime() != null ? user.getCreateTime().getValue() : null, + user.getUpdateTime() != null ? user.getUpdateTime().getValue() : null, + user.getLastLoginTime() != null ? user.getLastLoginTime().getValue() : null + ); + } } diff --git a/user-service/user-service-adapter/user-adapter-out/user-adapter-out-persistence/src/main/java/com/example/user/adapter/out/persistence/entity/UserEntity.java b/user-service/user-service-adapter/user-adapter-out/user-adapter-out-persistence/src/main/java/com/example/user/adapter/out/persistence/entity/UserEntity.java index 1455f81d5b6a2f987fc9344007f8013df1f4c4fa..65df8392bb01aa9a69e52a2346ddad35962795ae 100644 --- a/user-service/user-service-adapter/user-adapter-out/user-adapter-out-persistence/src/main/java/com/example/user/adapter/out/persistence/entity/UserEntity.java +++ b/user-service/user-service-adapter/user-adapter-out/user-adapter-out-persistence/src/main/java/com/example/user/adapter/out/persistence/entity/UserEntity.java @@ -1,27 +1,42 @@ package com.example.user.adapter.out.persistence.entity; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + @Data @AllArgsConstructor @NoArgsConstructor -@TableName("user") +@TableName("sys_user") public class UserEntity { - @TableId(type= IdType.ASSIGN_ID) - private long id; - private String name; - private Integer age; - private String email; + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("username") + private String username; + private String password; - private Integer isSuper; - public UserEntity(long value, String value1, int value2, String value3) { - } - public UserEntity(long id, String name, Integer age, String email, String password) { - this(id, name, age, email, password, 0); // 默认isSuper为0 - } + private String email; + private String phone; + + @TableField("real_name") + private String realName; + + private Integer status; + private String role; + + @TableField("create_time") + private LocalDateTime createTime; + + @TableField("update_time") + private LocalDateTime updateTime; + + @TableField("last_login_time") + private LocalDateTime lastLoginTime; } diff --git a/user-service/user-service-application/pom.xml b/user-service/user-service-application/pom.xml index df05045dc789c7f49514ce9c6c7c90a6a664231f..1e0b8b6dfe3940f02ba38fbda28200301dd0ec97 100644 --- a/user-service/user-service-application/pom.xml +++ b/user-service/user-service-application/pom.xml @@ -36,6 +36,25 @@ user-service-domain 0.0.1-SNAPSHOT + + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + runtime +
diff --git a/user-service/user-service-application/src/main/java/com/example/user/service/application/command/CreateUserCommand.java b/user-service/user-service-application/src/main/java/com/example/user/service/application/command/CreateUserCommand.java index d7f351917f3750105968088e03b460e2b9ba8bcb..3474d03f4e9a26e2c0af7458d19d660b8c0e972c 100644 --- a/user-service/user-service-application/src/main/java/com/example/user/service/application/command/CreateUserCommand.java +++ b/user-service/user-service-application/src/main/java/com/example/user/service/application/command/CreateUserCommand.java @@ -5,9 +5,10 @@ import lombok.Builder; @Builder public record CreateUserCommand( Long id, - String name, - Integer age, + String username, + String password, String email, - String password + String phone, + String realName ) { } diff --git a/user-service/user-service-application/src/main/java/com/example/user/service/application/command/UpdateUserCommand.java b/user-service/user-service-application/src/main/java/com/example/user/service/application/command/UpdateUserCommand.java index 0ef0ed9076efe403013ff1e6c169c353387b6b30..719e6771679d6b654316e8a5f00b7175a64244b6 100644 --- a/user-service/user-service-application/src/main/java/com/example/user/service/application/command/UpdateUserCommand.java +++ b/user-service/user-service-application/src/main/java/com/example/user/service/application/command/UpdateUserCommand.java @@ -4,7 +4,10 @@ import lombok.Builder; @Builder public record UpdateUserCommand(Long id, - String name, - Integer age, - String email) { + String username, + String email, + String phone, + String realName, + Integer status, + String role) { } diff --git a/user-service/user-service-application/src/main/java/com/example/user/service/application/command/UserLoginCommand.java b/user-service/user-service-application/src/main/java/com/example/user/service/application/command/UserLoginCommand.java index f5132799d94db877dc88c9e60b37a31023929e74..7b9a6609f5b6da2f66f91318261a805c551f8860 100644 --- a/user-service/user-service-application/src/main/java/com/example/user/service/application/command/UserLoginCommand.java +++ b/user-service/user-service-application/src/main/java/com/example/user/service/application/command/UserLoginCommand.java @@ -3,5 +3,5 @@ package com.example.user.service.application.command; import lombok.Builder; @Builder -public record UserLoginCommand(String name, String password) { +public record UserLoginCommand(String username, String password) { } diff --git a/user-service/user-service-application/src/main/java/com/example/user/service/application/service/CreateUserService.java b/user-service/user-service-application/src/main/java/com/example/user/service/application/service/CreateUserService.java index 931828a4d32e7cae7a78cc0e55f92ade3a7ff2e5..fb2c65b66de624cfde010ba36f27f50301805c88 100644 --- a/user-service/user-service-application/src/main/java/com/example/user/service/application/service/CreateUserService.java +++ b/user-service/user-service-application/src/main/java/com/example/user/service/application/service/CreateUserService.java @@ -6,8 +6,11 @@ import com.example.user.service.domain.User; import com.example.user.service.domain.port.CreateUserPort; import com.example.user.service.domain.valueobject.Email; import com.example.user.service.domain.valueobject.Password; -import com.example.user.service.domain.valueobject.UserAge; +import com.example.user.service.domain.valueobject.Phone; +import com.example.user.service.domain.valueobject.RealName; import com.example.user.service.domain.valueobject.UserName; +import com.example.user.service.domain.valueobject.UserRole; +import com.example.user.service.domain.valueobject.UserStatus; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -17,17 +20,20 @@ import org.springframework.stereotype.Service; public class CreateUserService implements CreateUserUseCase { @Resource private CreateUserPort createUserPort; + @Override public User createUser(CreateUserCommand createUserCommand) { //command -> domain - User user=new User( - new UserName(createUserCommand.name()), - new UserAge(createUserCommand.age()), + User user = new User( + new UserName(createUserCommand.username()), + Password.fromRaw(createUserCommand.password()), new Email(createUserCommand.email()), -// new Password( createUserCommand.password()) - Password.fromRaw(createUserCommand.password()) + createUserCommand.phone() != null ? new Phone(createUserCommand.phone()) : null, + createUserCommand.realName() != null ? new RealName(createUserCommand.realName()) : null, + new UserStatus(1), // 默认启用状态 + new UserRole("USER") // 默认用户角色 ); - log.info("user:{}",user); + log.info("user:{}", user); return createUserPort.createUser(user); } } diff --git a/user-service/user-service-application/src/main/java/com/example/user/service/application/service/TokenBlacklistService.java b/user-service/user-service-application/src/main/java/com/example/user/service/application/service/TokenBlacklistService.java new file mode 100644 index 0000000000000000000000000000000000000000..dd29b304dc062e075209b3128e0162482a52c478 --- /dev/null +++ b/user-service/user-service-application/src/main/java/com/example/user/service/application/service/TokenBlacklistService.java @@ -0,0 +1,224 @@ +package com.example.user.service.application.service; + +import com.example.user.service.application.util.JwtUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Token黑名单服务类 - JWT令牌失效管理 + * + * 这个服务类解决了JWT无状态认证中的一个重要问题:如何在用户登出后立即使令牌失效 + * + * JWT的无状态特性说明: + * - JWT令牌是自包含的,包含了所有必要的用户信息 + * - 服务端不需要存储会话状态,每次请求都通过验证令牌签名来确认身份 + * - 这种设计的优点是可扩展性好,但缺点是无法在服务端主动"销毁"令牌 + * - 令牌在过期时间之前始终有效,即使用户已经登出 + * + * 黑名单机制原理: + * 1. 维护一个已失效令牌的黑名单列表 + * 2. 用户登出时,将令牌添加到黑名单 + * 3. 每次验证令牌时,先检查是否在黑名单中 + * 4. 如果在黑名单中,则拒绝访问,即使令牌本身是有效的 + * + * 存储方案选择: + * - 内存存储(ConcurrentHashMap):适合单机部署,性能最好 + * - Redis存储:适合分布式部署,多个服务实例共享黑名单 + * - 数据库存储:适合对数据持久性要求高的场景 + * + * 本实现使用内存存储,具有以下特点: + * - 高性能:内存访问速度快,不涉及网络IO + * - 线程安全:使用ConcurrentHashMap保证并发安全 + * - 自动清理:定时清理过期的黑名单记录,避免内存泄漏 + * - 简单可靠:无外部依赖,部署简单 + * + * 注意事项: + * - 重启服务会丢失黑名单数据,已登出的用户令牌可能重新生效 + * - 多实例部署时,各实例的黑名单不共享 + * - 如需解决以上问题,建议改用Redis等外部存储 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TokenBlacklistService { + + /** + * JWT工具类,用于解析令牌获取过期时间 + */ + private final JwtUtil jwtUtil; + + /** + * 黑名单存储容器 + * + * 使用ConcurrentHashMap确保线程安全的并发访问 + * Key: JWT令牌字符串 + * Value: 令牌的过期时间,用于定时清理 + * + * 为什么存储过期时间: + * - 避免永久存储已过期的令牌,节省内存空间 + * - 支持定时清理机制,自动移除不再需要的黑名单记录 + * - 提供调试信息,便于排查问题 + */ + private final ConcurrentHashMap blacklistedTokens = new ConcurrentHashMap<>(); + + /** + * 将令牌添加到黑名单 + * + * 当用户登出时调用此方法,将JWT令牌加入黑名单 + * 加入黑名单后,该令牌将无法再用于身份验证 + * + * 实现细节: + * 1. 解析令牌获取过期时间 + * 2. 将令牌和过期时间存入黑名单Map + * 3. 记录操作日志,便于审计和调试 + * + * 异常处理: + * - 如果令牌格式无效,会记录警告日志但不抛出异常 + * - 确保即使个别令牌处理失败,也不影响整体功能 + * + * @param token JWT令牌字符串 + */ + public void addToBlacklist(String token) { + try { + // 解析令牌获取过期时间 + // 这样可以在令牌自然过期后自动清理黑名单记录 + Date expirationTime = getExpirationDateFromToken(token); + + // 将令牌添加到黑名单 + blacklistedTokens.put(token, expirationTime); + + // 记录操作日志(只记录令牌的前几位,避免泄露完整令牌) + log.info("令牌已添加到黑名单,过期时间: {}, 令牌前缀: {}...", + expirationTime, + token.substring(0, Math.min(token.length(), 10))); + + } catch (Exception e) { + // 如果解析令牌失败,记录警告日志 + log.warn("添加令牌到黑名单时发生错误: {}, 令牌前缀: {}...", + e.getMessage(), + token.substring(0, Math.min(token.length(), 10))); + } + } + + /** + * 检查令牌是否在黑名单中 + * + * 在JWT认证过滤器中调用此方法,检查令牌是否已被列入黑名单 + * 如果令牌在黑名单中,则应拒绝该请求的访问 + * + * 性能考虑: + * - ConcurrentHashMap的containsKey操作时间复杂度为O(1) + * - 即使黑名单中有大量令牌,查询性能也很好 + * - 无需额外的网络请求或磁盘IO + * + * @param token JWT令牌字符串 + * @return boolean true表示令牌在黑名单中(应拒绝访问),false表示不在黑名单中 + */ + public boolean isBlacklisted(String token) { + boolean isBlacklisted = blacklistedTokens.containsKey(token); + + if (isBlacklisted) { + log.debug("检测到黑名单令牌访问尝试,令牌前缀: {}...", + token.substring(0, Math.min(token.length(), 10))); + } + + return isBlacklisted; + } + + /** + * 定时清理过期的黑名单令牌 + * + * 使用Spring的@Scheduled注解实现定时任务 + * 每小时执行一次清理操作,移除已经自然过期的令牌 + * + * 清理的必要性: + * - 避免内存泄漏:长期运行的服务会积累大量过期令牌 + * - 提高性能:减少黑名单大小,提高查询效率 + * - 节省资源:释放不再需要的内存空间 + * + * 清理策略: + * - 只清理已经过期的令牌(当前时间 > 令牌过期时间) + * - 使用迭代器安全地删除元素,避免并发修改异常 + * - 记录清理统计信息,便于监控和调试 + * + * 定时配置说明: + * - fixedRate = 3600000:每3600000毫秒(1小时)执行一次 + * - 可以根据实际需求调整清理频率 + * - 频率过高会增加CPU开销,频率过低会占用更多内存 + */ + @Scheduled(fixedRate = 3600000) // 每小时执行一次 + public void cleanupExpiredTokens() { + Date now = new Date(); + int initialSize = blacklistedTokens.size(); + + // 使用removeIf方法安全地移除过期令牌 + // 这个方法是线程安全的,不会与其他操作产生冲突 + blacklistedTokens.entrySet().removeIf(entry -> { + Date expirationTime = entry.getValue(); + return expirationTime != null && now.after(expirationTime); + }); + + int finalSize = blacklistedTokens.size(); + int cleanedCount = initialSize - finalSize; + + if (cleanedCount > 0) { + log.info("黑名单清理完成,清理了 {} 个过期令牌,当前黑名单大小: {}", cleanedCount, finalSize); + } else { + log.debug("黑名单清理完成,无过期令牌需要清理,当前黑名单大小: {}", finalSize); + } + } + + /** + * 获取当前黑名单大小 + * + * 提供监控和调试功能,可以了解当前黑名单的使用情况 + * + * @return int 黑名单中令牌的数量 + */ + public int getBlacklistSize() { + return blacklistedTokens.size(); + } + + /** + * 清空所有黑名单令牌 + * + * 提供管理功能,在特殊情况下可以清空整个黑名单 + * 注意:此操作会使所有已登出用户的令牌重新生效 + * + * 使用场景: + * - 系统维护时需要重置黑名单状态 + * - 测试环境中需要快速清理数据 + * - 紧急情况下需要恢复所有用户访问 + */ + public void clearBlacklist() { + int size = blacklistedTokens.size(); + blacklistedTokens.clear(); + log.warn("黑名单已被清空,共清理了 {} 个令牌", size); + } + + /** + * 从令牌中获取过期时间的私有方法 + * + * 由于当前JwtUtil没有公开的getExpirationDateFromToken方法, + * 这里提供一个简化的实现,基于令牌的过期时间配置 + * + * @param token JWT令牌字符串 + * @return Date 令牌的过期时间 + */ + private Date getExpirationDateFromToken(String token) { + try { + // 简化实现:基于当前时间和配置的过期时间计算 + // 在实际项目中,应该解析JWT令牌获取真实的过期时间 + return new Date(System.currentTimeMillis() + jwtUtil.expiration); + } catch (Exception e) { + log.error("获取令牌过期时间失败: {}", e.getMessage()); + // 返回一个默认的过期时间(当前时间 + 1天) + return new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000); + } + } +} \ No newline at end of file diff --git a/user-service/user-service-application/src/main/java/com/example/user/service/application/service/UpdateUserService.java b/user-service/user-service-application/src/main/java/com/example/user/service/application/service/UpdateUserService.java index 97da325352c39b77ddfdc0f9982a3f654a201c91..4cf1ec17ca3f59082d08f85b23602d8c950341dd 100644 --- a/user-service/user-service-application/src/main/java/com/example/user/service/application/service/UpdateUserService.java +++ b/user-service/user-service-application/src/main/java/com/example/user/service/application/service/UpdateUserService.java @@ -5,9 +5,12 @@ import com.example.user.service.application.port.in.UpdateUserUseCase; import com.example.user.service.domain.User; import com.example.user.service.domain.port.UpdateUserPort; import com.example.user.service.domain.valueobject.Email; -import com.example.user.service.domain.valueobject.UserAge; +import com.example.user.service.domain.valueobject.Phone; +import com.example.user.service.domain.valueobject.RealName; import com.example.user.service.domain.valueobject.UserId; import com.example.user.service.domain.valueobject.UserName; +import com.example.user.service.domain.valueobject.UserRole; +import com.example.user.service.domain.valueobject.UserStatus; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; @@ -20,9 +23,17 @@ public class UpdateUserService implements UpdateUserUseCase { public User updateUser(UpdateUserCommand command) { User user = new User( new UserId(command.id()), - new UserName(command.name()), - new UserAge(command.age()), - new Email(command.email())); + new UserName(command.username()), + null, // 密码不通过更新接口修改 + new Email(command.email()), + command.phone() != null ? new Phone(command.phone()) : null, + command.realName() != null ? new RealName(command.realName()) : null, + command.status() != null ? new UserStatus(command.status()) : null, + command.role() != null ? new UserRole(command.role()) : null, + null, // createTime不通过更新接口修改 + null, // updateTime不通过更新接口修改 + null // lastLoginTime不通过更新接口修改 + ); return updateUserPort.updateUser(user); } } diff --git a/user-service/user-service-application/src/main/java/com/example/user/service/application/service/UserLoginService.java b/user-service/user-service-application/src/main/java/com/example/user/service/application/service/UserLoginService.java index b10d43e0179a76381f25cb3e62b9319077c76549..b18a9399b30e9960efa227ddaa35856b9e3995ee 100644 --- a/user-service/user-service-application/src/main/java/com/example/user/service/application/service/UserLoginService.java +++ b/user-service/user-service-application/src/main/java/com/example/user/service/application/service/UserLoginService.java @@ -2,7 +2,7 @@ package com.example.user.service.application.service; import com.example.user.service.application.command.UserLoginCommand; import com.example.user.service.application.port.in.UserLoginUseCase; -import com.example.user.service.common.JwtUtil; +import com.example.user.service.application.util.JwtUtil; import com.example.user.service.domain.User; import com.example.user.service.domain.port.GetUserByNamePort; import jakarta.annotation.Resource; @@ -15,11 +15,14 @@ public class UserLoginService implements UserLoginUseCase { @Resource private GetUserByNamePort getUserByNamePort; + + @Resource + private JwtUtil jwtUtil; @Override public String login(UserLoginCommand userLoginCommand) { //验证用户 - User user = User.getUserByName(userLoginCommand.name(), getUserByNamePort); + User user = User.getUserByName(userLoginCommand.username(), getUserByNamePort); log.info("user:{}", user); if(user==null){ throw new RuntimeException("用户不存在"); @@ -28,13 +31,21 @@ public class UserLoginService implements UserLoginUseCase { if(!user.validatePassword(userLoginCommand.password())){ throw new RuntimeException("密码错误"); } - // 签发token - String token = JwtUtil.generateToken( - user.getId().id(), - user.getName().username(), - user.getIsSuper().value() + //签发token + /** + * @author yangwenkai + */ + // 使用JwtUtil生成JWT令牌,包含用户信息 + // 根据用户角色判断是否是超级用户 + boolean isSuperUser = user.getRole().isAdmin(); + + String token = jwtUtil.generateToken( + user.getId().getValue(), // 用户ID + user.getUsername().getValue(), // 用户名 + isSuperUser // 是否是超级用户 ); - log.info("生成的JWT令牌: {}", token); + + log.info("用户登录成功,生成JWT令牌: {}", token); return token; } } diff --git a/user-service/user-service-application/src/main/java/com/example/user/service/application/util/JwtUtil.java b/user-service/user-service-application/src/main/java/com/example/user/service/application/util/JwtUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..9d0bc15146b9f4dc9063afacf5b050c75f5e3e1f --- /dev/null +++ b/user-service/user-service-application/src/main/java/com/example/user/service/application/util/JwtUtil.java @@ -0,0 +1,376 @@ +package com.example.user.service.application.util; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/** + * JWT工具类 + * + * 这个类负责JWT Token的生成、解析和验证,包括: + * 1. 生成JWT Token + * 2. 从Token中提取用户名 + * 3. 验证Token的有效性 + * 4. 刷新Token + * + * JWT(JSON Web Token)是一种安全的身份验证方式,包含三部分: + * 1. Header(头部)- 包含算法和类型信息 + * 2. Payload(载荷)- 包含用户信息和声明 + * 3. Signature(签名)- 用于验证Token的真实性 + * + * 注解说明: + * @Component - 标记为Spring组件,可以被自动注入 + * @Slf4j - Lombok注解,自动生成日志记录器 + */ +@Component +@Slf4j +public class JwtUtil { + + /** + * JWT密钥 - 用于签名和验证Token + * 从配置文件application.yml中读取,默认值为"mySecretKey123456789012345678901234567890" + * 密钥长度至少32位,建议使用更长的随机字符串 + * HS512算法需要至少512位(64字节)的密钥 + */ + @Value("${jwt.secret:mySecretKey123456789012345678901234567890123456789012345678901234567890}") + private String secret; + + /** + * Token过期时间(毫秒) + * 从配置文件application.yml中读取,默认值为7天(604800000毫秒) + */ + @Value("${jwt.expiration:604800000}") + public Long expiration; + + /** + * 生成安全的密钥对象 + * + * 这个方法将字符串密钥转换为安全的SecretKey对象 + * 使用HMAC-SHA算法进行签名 + * + * @return SecretKey 安全的密钥对象 + */ + private SecretKey getSigningKey() { + return Keys.hmacShaKeyFor(secret.getBytes()); + } + + /** + * 生成JWT Token + * + * 这个方法根据用户名生成JWT Token,包含: + * 1. 主题(subject)- 用户名 + * 2. 签发时间(issuedAt)- 当前时间 + * 3. 过期时间(expiration)- 当前时间 + 过期时长 + * 4. 签名(signature)- 使用密钥进行签名 + * + * @param username 用户名,将作为Token的主题 + * @return String 生成的JWT Token字符串 + */ + public String generateToken(String username) { + Map claims = new HashMap<>(); + return createToken(claims, username); + } + + /** + * 创建Token的内部方法 + * + * 这个方法实际构建JWT Token,设置各种声明和签名 + * + * @param claims 额外的声明信息(可以存放用户角色、权限等) + * @param subject Token的主题,通常是用户名 + * @return String 生成的JWT Token + */ + private String createToken(Map claims, String subject) { + return Jwts.builder() + // 设置额外的声明信息 + .setClaims(claims) + // 设置主题(通常是用户名) + .setSubject(subject) + // 设置签发时间 + .setIssuedAt(new Date(System.currentTimeMillis())) + // 设置过期时间 + .setExpiration(new Date(System.currentTimeMillis() + expiration)) + // 使用密钥进行签名 + .signWith(getSigningKey(), SignatureAlgorithm.HS512) + // 压缩Token(可选) + .compact(); + } + + /** + * 从Token中提取用户名 + * + * 这个方法解析JWT Token,提取其中的主题(subject)作为用户名 + * + * @param token JWT Token字符串 + * @return String 从Token中提取的用户名 + */ + public String getUsernameFromToken(String token) { + try { + // 解析Token并获取主题(用户名) + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject(); + } catch (Exception e) { + log.error("从Token中提取用户名失败", e); + return null; + } + } + + /** + * 验证JWT Token的有效性 + * + * 这个方法检查Token是否: + * 1. 格式正确 + * 2. 签名有效 + * 3. 没有过期 + * 4. 包含的用户名与预期一致 + * + * @param token 需要验证的JWT Token + * @param username 预期的用户名 + * @return boolean 如果Token有效返回true,否则返回false + */ + public boolean validateToken(String token, String username) { + try { + // 解析Token获取用户名 + String extractedUsername = getUsernameFromToken(token); + + // 检查用户名是否匹配且Token未过期 + return (extractedUsername != null && + extractedUsername.equals(username) && + !isTokenExpired(token)); + } catch (Exception e) { + log.warn("Token验证失败: {}", e.getMessage()); + return false; + } + } + + /** + * 检查Token是否过期 + * + * 这个方法检查Token的过期时间是否在当前时间之前 + * + * @param token JWT Token + * @return boolean 如果Token已过期返回true,否则返回false + */ + private boolean isTokenExpired(String token) { + try { + // 获取Token的过期时间 + Date expirationDate = getExpirationDateFromToken(token); + + // 检查是否过期 + return expirationDate.before(new Date()); + } catch (Exception e) { + log.error("检查Token过期状态失败", e); + return true; // 如果解析失败,视为过期 + } + } + + /** + * 从Token中获取过期时间 + * + * 这个方法解析Token并提取其中的过期时间 + * + * @param token JWT Token + * @return Date Token的过期时间 + */ + public Date getExpirationDateFromToken(String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody() + .getExpiration(); + } catch (ExpiredJwtException e) { + // 如果Token已过期,仍然返回过期时间 + return e.getClaims().getExpiration(); + } catch (Exception e) { + log.error("从Token中获取过期时间失败", e); + return new Date(0); // 返回一个很早的日期表示无效 + } + } + + /** + * 刷新JWT Token + * + * 这个方法根据旧的Token生成一个新的Token + * 新的Token使用相同的用户信息,但更新了签发时间和过期时间 + * + * @param token 旧的JWT Token + * @return String 新的JWT Token,如果刷新失败返回null + */ + public String refreshToken(String token) { + try { + // 从旧Token中提取用户名 + String username = getUsernameFromToken(token); + + if (username != null) { + // 生成新的Token + return generateToken(username); + } + return null; + } catch (Exception e) { + log.error("刷新Token失败", e); + return null; + } + } + + /** + * 获取Token的剩余有效时间(毫秒) + * + * 这个方法计算Token还有多少毫秒会过期 + * + * @param token JWT Token + * @return long 剩余有效时间(毫秒),如果无效返回-1 + */ + public long getRemainingMillis(String token) { + try { + Date expirationDate = getExpirationDateFromToken(token); + return expirationDate.getTime() - System.currentTimeMillis(); + } catch (Exception e) { + log.error("获取Token剩余时间失败", e); + return -1; + } + } + + /** + * 生成包含用户ID、用户名和角色信息的JWT Token + * + * @param userId 用户ID + * @param username 用户名 + * @param isSuperUser 是否是超级用户 + * @return String 生成的JWT Token + */ + public String generateToken(long userId, String username, boolean isSuperUser) { + Map claims = new HashMap<>(); + claims.put("userId", userId); + claims.put("isSuperUser", isSuperUser); + + return createToken(claims, username); + } + + /** + * 从Token中获取Claims(载荷信息) + * + * 这个方法解析JWT Token并返回其中包含的所有声明信息 + * Claims包含了用户信息、过期时间、签发时间等数据 + * + * @param token JWT Token字符串 + * @return Claims Token中的载荷信息 + */ + public Claims getClaimsFromToken(String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (ExpiredJwtException e) { + // 即使Token过期,也返回Claims信息 + return e.getClaims(); + } catch (Exception e) { + log.error("解析Token获取Claims失败: {}", e.getMessage()); + throw new RuntimeException("无效的JWT Token", e); + } + } + + /** + * 从HTTP请求头中提取JWT Token + * + * 这个方法从HTTP Authorization头中提取Bearer Token + * 标准的Authorization头格式为:"Bearer " + * + * 使用场景: + * - 在过滤器中提取Token进行身份验证 + * - 在控制器中获取当前用户的Token + * - 在服务层中处理需要Token的业务逻辑 + * + * 安全考虑: + * - 只接受Bearer类型的Token,拒绝其他格式 + * - 对Token格式进行严格验证 + * - 不在日志中记录完整的Token内容 + * + * @param authorizationHeader HTTP Authorization头的值 + * @return String 提取的JWT Token,如果格式不正确返回null + */ + public String extractTokenFromHeader(String authorizationHeader) { + // 检查Authorization头是否存在且不为空 + if (authorizationHeader == null || authorizationHeader.trim().isEmpty()) { + return null; + } + + // 检查是否以"Bearer "开头(注意Bearer后面有一个空格) + if (authorizationHeader.startsWith("Bearer ")) { + // 提取Bearer后面的Token部分 + String token = authorizationHeader.substring(7); // "Bearer "长度为7 + + // 检查提取的Token是否为空 + if (token.trim().isEmpty()) { + return null; + } + + return token; + } + + // 如果不是Bearer格式,返回null + return null; + } + + /** + * 从Token中获取用户ID + * + * 这个方法从JWT Token的Claims中提取用户ID信息 + * + * @param token JWT Token字符串 + * @return Long 用户ID,如果获取失败返回null + */ + public Long getUserIdFromToken(String token) { + try { + Claims claims = getClaimsFromToken(token); + Object userIdObj = claims.get("userId"); + + if (userIdObj instanceof Number) { + return ((Number) userIdObj).longValue(); + } + + return null; + } catch (Exception e) { + log.error("从Token中获取用户ID失败: {}", e.getMessage()); + return null; + } + } + + /** + * 从Token中获取用户角色信息 + * + * 这个方法从JWT Token的Claims中提取用户是否为超级用户的信息 + * + * @param token JWT Token字符串 + * @return Boolean 是否为超级用户,如果获取失败返回false + */ + public Boolean isSuperUserFromToken(String token) { + try { + Claims claims = getClaimsFromToken(token); + Object isSuperUserObj = claims.get("isSuperUser"); + + if (isSuperUserObj instanceof Boolean) { + return (Boolean) isSuperUserObj; + } + + return false; + } catch (Exception e) { + log.error("从Token中获取用户角色失败: {}", e.getMessage()); + return false; + } + } +} \ No newline at end of file diff --git a/user-service/user-service-bootstrap/src/main/resources/application.properties b/user-service/user-service-bootstrap/src/main/resources/application.properties index 3ffae5ba70d1950bb4d5530c8b2e561a15ee9e5e..c9babd151f6e6a99da84febc046c9f720aa98683 100644 --- a/user-service/user-service-bootstrap/src/main/resources/application.properties +++ b/user-service/user-service-bootstrap/src/main/resources/application.properties @@ -1,4 +1,4 @@ -server.port=28080 +server.port=28081 spring.application.name=user-service diff --git a/user-service/user-service-domain/src/main/java/com/example/user/service/domain/User.java b/user-service/user-service-domain/src/main/java/com/example/user/service/domain/User.java index 8d9521b5c44f57f74deb13c1872241318475abe4..255a08804fcf778cb7692da76ab945cf3a18a87e 100644 --- a/user-service/user-service-domain/src/main/java/com/example/user/service/domain/User.java +++ b/user-service/user-service-domain/src/main/java/com/example/user/service/domain/User.java @@ -9,6 +9,7 @@ import lombok.Setter; import lombok.ToString; import lombok.extern.slf4j.Slf4j; +import java.time.LocalDateTime; import java.util.List; @Slf4j @@ -17,41 +18,65 @@ import java.util.List; @ToString public class User { private UserId id; - private UserName name; - private UserAge age; - private Email email; + private UserName username; private Password password; - private IsSuper isSuper; // 添加isSuper字段 + private Email email; + private Phone phone; + private RealName realName; + private UserStatus status; + private UserRole role; + private CreateTime createTime; + private UpdateTime updateTime; + private LastLoginTime lastLoginTime; + public User() { } - public User(UserId id, UserName name, UserAge age, Email email, Password password,IsSuper isSuper) { + public User(UserId id, UserName username, Password password, Email email, Phone phone, + RealName realName, UserStatus status, UserRole role, CreateTime createTime, + UpdateTime updateTime, LastLoginTime lastLoginTime) { this.id = id; - this.name = name; - this.age = age; - this.email = email; + this.username = username; this.password = password; - this.isSuper = isSuper; + this.email = email; + this.phone = phone; + this.realName = realName; + this.status = status; + this.role = role; + this.createTime = createTime; + this.updateTime = updateTime; + this.lastLoginTime = lastLoginTime; } - public User( UserName name, UserAge age, Email email, Password password) { - this.id= genId() ; - this.name = name; - this.age = age; - this.email = email; + public User(UserName username, Password password, Email email, Phone phone, + RealName realName, UserStatus status, UserRole role) { + this.id = genId(); + this.username = username; this.password = password; - this.isSuper = new IsSuper(false); + this.email = email; + this.phone = phone; + this.realName = realName; + this.status = status; + this.role = role; + this.createTime = new CreateTime(LocalDateTime.now()); + this.updateTime = new UpdateTime(LocalDateTime.now()); + this.lastLoginTime = null; } - public User(UserId userId, UserName userName, UserAge userAge, Email email) { - this.id = id; - this.name = name; - this.age = age; + public User(UserId userId, UserName userName, Email email) { + this.id = userId; + this.username = userName; this.email = email; - this.isSuper = new IsSuper(false); + this.password = null; + this.phone = null; + this.realName = null; + this.status = new UserStatus(1); // 默认启用状态 + this.role = new UserRole("USER"); // 默认用户角色 + this.createTime = new CreateTime(LocalDateTime.now()); + this.updateTime = new UpdateTime(LocalDateTime.now()); + this.lastLoginTime = null; } - public static List getUsers(GetUserListPort getUserListPort){ return getUserListPort.getUsers(); } @@ -60,12 +85,12 @@ public class User { * 根据用户名查询用户 * 当需要使用类似GetUserByNamePort这种对象的时候,需要在方法参数注入该对象 * 因为通过构造方法或者字段注入都会失败,因为方法是静态方法,会早于对象创建,导致对象无法注入 - * @param name 用户名 + * @param username 用户名 * @param getUserByNamePort 查询用户的端口 * @return 用户模型 */ - public static User getUserByName(String name, GetUserByNamePort getUserByNamePort){ - User user = getUserByNamePort.getUserByName(name); + public static User getUserByName(String username, GetUserByNamePort getUserByNamePort){ + User user = getUserByNamePort.getUserByName(username); log.info("user:{}", user); return user; } diff --git a/user-service/user-service-domain/src/main/java/com/example/user/service/domain/port/GetUserByNamePort.java b/user-service/user-service-domain/src/main/java/com/example/user/service/domain/port/GetUserByNamePort.java index 599d5ab921037d14bef8fd463e7a638f416c5158..3f5cbea71a2197e9daa1e21186314a381d6aa7b8 100644 --- a/user-service/user-service-domain/src/main/java/com/example/user/service/domain/port/GetUserByNamePort.java +++ b/user-service/user-service-domain/src/main/java/com/example/user/service/domain/port/GetUserByNamePort.java @@ -3,5 +3,5 @@ package com.example.user.service.domain.port; import com.example.user.service.domain.User; public interface GetUserByNamePort { - User getUserByName(String name ); + User getUserByName(String username); } diff --git a/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/CreateTime.java b/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/CreateTime.java new file mode 100644 index 0000000000000000000000000000000000000000..1cfbf8a5267e037c9950eb8aaa451408afe15652 --- /dev/null +++ b/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/CreateTime.java @@ -0,0 +1,15 @@ +package com.example.user.service.domain.valueobject; + +import java.time.LocalDateTime; + +/** + * 创建时间值对象 + * 表示用户的创建时间 + * + * @param createTime 创建时间 + */ +public record CreateTime(LocalDateTime createTime) { + public LocalDateTime getValue() { + return createTime; + } +} \ No newline at end of file diff --git a/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/LastLoginTime.java b/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/LastLoginTime.java new file mode 100644 index 0000000000000000000000000000000000000000..be47dbb4115a3ce59819f1e5c727be3985ee8771 --- /dev/null +++ b/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/LastLoginTime.java @@ -0,0 +1,15 @@ +package com.example.user.service.domain.valueobject; + +import java.time.LocalDateTime; + +/** + * 最后登录时间值对象 + * 表示用户的最后登录时间 + * + * @param lastLoginTime 最后登录时间 + */ +public record LastLoginTime(LocalDateTime lastLoginTime) { + public LocalDateTime getValue() { + return lastLoginTime; + } +} \ No newline at end of file diff --git a/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/Password.java b/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/Password.java index 57d9be7020b4d75f261b1aa30f2da10dc40b657d..5fbaea15e4a7b139f4973f7734a1cff2c28e8da2 100644 --- a/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/Password.java +++ b/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/Password.java @@ -4,36 +4,138 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; /** - * - * @param encryptedValue 密码 明文密码还是密文密码?->密文 + * 密码值对象 + * 负责密码的验证、加密和存储 + * + * @param encryptedValue 加密后的密码值 */ @Slf4j public record Password(String encryptedValue) { private static final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + /** + * 从原始密码创建Password对象 + * 会先验证密码复杂度,然后进行加密 + * @author qiuyujie + * @param rawPassword 原始密码 + * @return 加密后的Password对象 + * @throws IllegalArgumentException 如果密码不符合复杂度要求 + */ public static Password fromRaw(String rawPassword) { - if (rawPassword == null || rawPassword.length() < 6) { - throw new RuntimeException("密码长度至少6位"); - } - - log.info("密码明文: {},密码密文{}", rawPassword,encoder.encode(rawPassword)); - return new Password(encoder.encode(rawPassword)); + validatePasswordComplexity(rawPassword); + String encryptedPassword = encoder.encode(rawPassword); + log.info("密码明文: {},密码密文{}", rawPassword, encryptedPassword); + return new Password(encryptedPassword); } /** * 从已加密的密码创建Password对象 * 用于从持久化层恢复数据 + * @author qiuyujie + * @param encryptedPassword 已加密的密码 + * @return Password对象 */ public static Password fromEncrypted(String encryptedPassword) { return new Password(encryptedPassword); } + /** + * 验证原始密码是否与加密密码匹配 + * @author dengli + * @param rawPassword 原始密码 + * @return 是否匹配 + */ public boolean verify(String rawPassword) { return encoder.matches(rawPassword, encryptedValue); } - // 用于密码重置 + /** + * 用于密码重置 + * @author dengli + * @param encryptedValue 加密后的密码值 + * @return Password对象 + */ public static Password of(String encryptedValue) { return new Password(encryptedValue); } + + /** + * 验证密码长度 + * @author wangqingqing + * @param password 待验证的密码 + * @throws IllegalArgumentException 如果密码长度不符合要求 + */ + public static void validatePasswordLength(String password) { + if (password == null) { + throw new IllegalArgumentException("密码不能为空"); + } + if (password.length() < 6) { + throw new IllegalArgumentException("密码长度不足,至少需要6个字符"); + } + if (password.length() > 20) { + throw new IllegalArgumentException("密码长度过长,最多允许20个字符"); + } + } + + /** + * 验证密码复杂度 + * @@author wangqingqing + * @param password 待验证的密码 + * @throws IllegalArgumentException 根据具体缺失的元素提供详细的错误信息 + */ + public static void validatePasswordComplexity(String password) { + if (password == null) { + throw new IllegalArgumentException("密码不能为空"); + } + + boolean hasLetter = password.matches(".*[A-Za-z].*"); + boolean hasDigit = password.matches(".*\\d.*"); + + if (!hasLetter && !hasDigit) { + throw new IllegalArgumentException("密码必须包含至少一个字母和一个数字"); + } + if (!hasLetter) { + throw new IllegalArgumentException("密码必须包含至少一个字母"); + } + if (!hasDigit) { + throw new IllegalArgumentException("密码必须包含至少一个数字"); + } + + // 额外的复杂度检查:特殊字符(可选) + boolean hasSpecialChar = password.matches(".*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?].*"); + if (!hasSpecialChar) { + // 这里只是记录,不抛出异常,因为特殊字符不是强制要求 + // 可以根据需要调整为强制要求 + } + } + + /** + * 验证密码和确认密码是否一致 + * @author lizishan + * @param password 密码 + * @param rePassword 确认密码 + * @throws IllegalArgumentException 根据具体情况提供详细的错误信息 + */ + public static void validatePasswordMatch(String password, String rePassword) { + if (rePassword == null) { + throw new IllegalArgumentException("确认密码不能为空"); + } + if (!password.equals(rePassword)) { + throw new IllegalArgumentException("密码和确认密码不匹配,请重新输入"); + } + } + + /** + * 完整的密码验证流程 + * 包括密码长度、复杂度以及密码和确认密码的一致性 + * @author lizishan + * @param password 密码 + * @param rePassword 确认密码 + * @throws IllegalArgumentException 如果密码不符合要求 + */ + public static void validatePassword(String password, String rePassword) { + validatePasswordLength(password); + validatePasswordComplexity(password); + validatePasswordMatch(password, rePassword); + } } diff --git a/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/Phone.java b/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/Phone.java new file mode 100644 index 0000000000000000000000000000000000000000..be68d97a3a063755b4f6b55f0829507ec59ea69a --- /dev/null +++ b/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/Phone.java @@ -0,0 +1,26 @@ +package com.example.user.service.domain.valueobject; + +/** + * 手机号值对象 + * 负责手机号的格式验证和存储 + * + * @param phone 手机号字符串 + */ +public record Phone(String phone) { + public String getValue() { + return phone; + } + + /** + * 验证手机号格式 + * 简单的手机号格式验证(中国手机号) + */ + public static void validatePhoneFormat(String phone) { + if (phone == null) { + throw new IllegalArgumentException("手机号不能为空"); + } + if (!phone.matches("^1[3-9]\\d{9}$")) { + throw new IllegalArgumentException("手机号格式不正确"); + } + } +} \ No newline at end of file diff --git a/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/RealName.java b/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/RealName.java new file mode 100644 index 0000000000000000000000000000000000000000..23b7f3d8e4d294c4a8972d7a08a0ea3da02031de --- /dev/null +++ b/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/RealName.java @@ -0,0 +1,26 @@ +package com.example.user.service.domain.valueobject; + +/** + * 真实姓名的值对象 + * 负责真实姓名的验证和存储 + * + * @param realName 真实姓名 + */ +public record RealName(String realName) { + public String getValue() { + return realName; + } + + /** + * 验证真实姓名格式 + * 简单的姓名格式验证(2-10个中文字符) + */ + public static void validateRealNameFormat(String realName) { + if (realName == null) { + throw new IllegalArgumentException("真实姓名不能为空"); + } + if (!realName.matches("^[\\u4e00-\\u9fa5]{2,10}$")) { + throw new IllegalArgumentException("真实姓名格式不正确,应为2-10个中文字符"); + } + } +} \ No newline at end of file diff --git a/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/UpdateTime.java b/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/UpdateTime.java new file mode 100644 index 0000000000000000000000000000000000000000..5037d37949ca1f2081fa0d140715846ba27f7c8d --- /dev/null +++ b/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/UpdateTime.java @@ -0,0 +1,15 @@ +package com.example.user.service.domain.valueobject; + +import java.time.LocalDateTime; + +/** + * 更新时间值对象 + * 表示用户的最后更新时间 + * + * @param updateTime 更新时间 + */ +public record UpdateTime(LocalDateTime updateTime) { + public LocalDateTime getValue() { + return updateTime; + } +} \ No newline at end of file diff --git a/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/UserRole.java b/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/UserRole.java new file mode 100644 index 0000000000000000000000000000000000000000..c1948f674963b3838a034e1c08ba807c0cd5c437 --- /dev/null +++ b/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/UserRole.java @@ -0,0 +1,39 @@ +package com.example.user.service.domain.valueobject; + +/** + * 用户角色值对象 + * 表示用户的角色:ADMIN-管理员,USER-普通用户 + * + * @param role 角色字符串 + */ +public record UserRole(String role) { + public String getValue() { + return role; + } + + /** + * 验证角色值是否有效 + */ + public static void validateRole(String role) { + if (role == null) { + throw new IllegalArgumentException("用户角色不能为空"); + } + if (!"ADMIN".equals(role) && !"USER".equals(role)) { + throw new IllegalArgumentException("用户角色无效,只能是'ADMIN'或'USER'"); + } + } + + /** + * 判断用户是否是管理员 + */ + public boolean isAdmin() { + return "ADMIN".equals(role); + } + + /** + * 判断用户是否是普通用户 + */ + public boolean isUser() { + return "USER".equals(role); + } +} \ No newline at end of file diff --git a/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/UserStatus.java b/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/UserStatus.java new file mode 100644 index 0000000000000000000000000000000000000000..728db919c97525840cf9bb876561e9ade54b04e0 --- /dev/null +++ b/user-service/user-service-domain/src/main/java/com/example/user/service/domain/valueobject/UserStatus.java @@ -0,0 +1,36 @@ +package com.example.user.service.domain.valueobject; + +/** + * 用户状态值对象 + * 表示用户的状态:0-禁用,1-启用 + * + * @param status 状态值 + */ +public record UserStatus(int status) { + public int getValue() { + return status; + } + + /** + * 验证状态值是否有效 + */ + public static void validateStatus(int status) { + if (status != 0 && status != 1) { + throw new IllegalArgumentException("用户状态值无效,只能是0(禁用)或1(启用)"); + } + } + + /** + * 判断用户是否启用 + */ + public boolean isEnabled() { + return status == 1; + } + + /** + * 判断用户是否禁用 + */ + public boolean isDisabled() { + return status == 0; + } +} \ No newline at end of file