初始化
This commit is contained in:
49
.codex/agents/AGENTS.md
Normal file
49
.codex/agents/AGENTS.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
|
||||||
|
# 项目背景
|
||||||
|
一个培训项目
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
前后端分离
|
||||||
|
- 前端:react 版本 18,typescript,vite
|
||||||
|
- 后端:springboot 3.4.5,java 21,mybatis-plus
|
||||||
|
- 数据库:mysql 8
|
||||||
|
- 缓存:redis
|
||||||
|
|
||||||
|
## 工作规则
|
||||||
|
### 整体规则
|
||||||
|
- 所有文件使用 uft-8 格式,避免中文乱码。
|
||||||
|
- 修改代码前,必须先阅读项目结构。
|
||||||
|
- 修改前必须说明会影响哪些文件。
|
||||||
|
- 修改后必须给出启动命令和测试步骤。
|
||||||
|
- 不允许把所有功能堆在一个页面或一个文件里。
|
||||||
|
- 建表或修改表是要完整的加上相应的注释,注释使用中文。
|
||||||
|
- 涉及删除文件、删除结果、清空数据库时,必须有二次确认、操作日志和回滚说明。
|
||||||
|
|
||||||
|
## 环境
|
||||||
|
- 使用 .env.example 做为环境的配置
|
||||||
|
|
||||||
|
### 前端规则
|
||||||
|
|
||||||
|
### 后端规则
|
||||||
|
- 所有类型为大驼峰格式,变量名为小驼峰格式
|
||||||
|
- 代码采用四层结构完成,包括 controller,service,mapper,domain。
|
||||||
|
- controller层为接口申明,为前端提供接口。
|
||||||
|
- service层为业务逻辑的接口,其中子文件impl,为业务逻辑接口的实现层,专门写业务逻辑。
|
||||||
|
- mapper层包括mapper接口和mapper的xml文件,用户操作数据库。
|
||||||
|
- domain层包括各个pojo类。
|
||||||
|
- 所有的方法参数不允许使用map,需创建对应的dto类。
|
||||||
|
- 使用@RequiredArgsConstructor完成依赖注入
|
||||||
|
|
||||||
|
## 数据库规则
|
||||||
|
- 表名为大写,多个单词之间用下划线"_"连接。
|
||||||
|
- 数据库建表或修改表结构后,将最终的建表语句保存在sql/xxx.sql中,xxx为对应数据库表名。
|
||||||
|
|
||||||
|
## 输出规范
|
||||||
|
- 采用标准的代码格式。
|
||||||
|
|
||||||
|
## 完成标准
|
||||||
|
- 修改了哪些文件。
|
||||||
|
- 每个文件修改了什么。
|
||||||
|
- 如何验证功能。
|
||||||
|
- 是否涉及数据库 SQL。
|
||||||
|
- 是否存在风险和回滚方式。
|
||||||
129
.codex/skills/database-connect/SKILL.md
Normal file
129
.codex/skills/database-connect/SKILL.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
---
|
||||||
|
name: database-connect
|
||||||
|
description: 连接 MySQL 数据库并完成安全的查询、数据写入、更新、删除、表结构探查、事务处理和数据库诊断。用户要求 Codex 连接 MySQL、查看或修改数据库数据、分析表结构、执行 SQL、排查数据库连接或查询问题时使用。
|
||||||
|
---
|
||||||
|
|
||||||
|
# MySQL 数据库连接
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
按照“发现配置、验证连接、了解结构、最小化操作、核对结果”的流程连接项目 MySQL,并完成用户授权范围内的数据库操作。优先复用项目已有的驱动、配置和命令,不擅自安装依赖或改变数据库环境。
|
||||||
|
|
||||||
|
## 安全边界
|
||||||
|
|
||||||
|
- 将数据库凭据视为机密。读取 `.env`、`.env.example`配置,只提取连接所需值,绝不在回复、日志、补丁或命令输出中打印密码、完整连接串或令牌。
|
||||||
|
- 禁止把密码直接写在命令行参数、SQL、源码或新建文件中。优先使用项目现有的安全配置、环境变量、凭据助手或 MySQL 客户端配置文件。
|
||||||
|
- 默认使用只读方式探查:`SELECT`、`SHOW`、`DESCRIBE`、`EXPLAIN` 和 `information_schema` 查询可以先执行;不要把探查语句改成写入语句。
|
||||||
|
- 执行 `INSERT`、`UPDATE`、`DELETE`、`REPLACE`、`TRUNCATE`、`DROP`、`ALTER`、`CREATE`、批量导入、权限变更或存储过程时,先说明目标、范围、预计影响和事务策略。
|
||||||
|
- 对删除、清空、改表、批量更新等不可逆或高影响操作,先展示将执行的精确 SQL(敏感值脱敏)并请求用户确认;用户已在当前请求中明确给出精确语句和范围时,可视为已确认,但仍先做影响评估。
|
||||||
|
- 不执行未限定范围的 `UPDATE` 或 `DELETE`,不执行生产库上的 `DROP DATABASE`、`TRUNCATE` 或全表更新,除非用户明确确认精确目标和范围。
|
||||||
|
- 不为了“修复连接”而修改防火墙、开放公网端口、重置密码、创建高权限账号或暴露数据库服务;这些需要单独授权。
|
||||||
|
|
||||||
|
## 标准工作流
|
||||||
|
|
||||||
|
### 1. 发现连接方式
|
||||||
|
|
||||||
|
按以下顺序检查,找到一种可用方式后停止扩散搜索:
|
||||||
|
|
||||||
|
1. 查看项目的 `README`、数据库迁移配置和应用配置,识别 host、port、database、user 的来源。密码只在本地使用,不回显。
|
||||||
|
2. 检查当前环境变量,如 `MYSQL_HOST`、`MYSQL_PORT`、`MYSQL_DATABASE`、`MYSQL_USER`、`MYSQL_PASSWORD` 或项目自定义变量。优先确认变量是否存在,不输出其值。
|
||||||
|
3. 检查项目依赖和现有数据库访问代码。Java 项目优先复用 JDBC/迁移工具,Node.js 项目优先复用现有 MySQL 驱动,Python 项目优先复用已安装的驱动。
|
||||||
|
4. 检查 `mysql` 客户端是否可用及版本。客户端不可用时,先报告缺少的工具,并优先使用项目已有驱动;不要未经授权下载或安装软件。
|
||||||
|
|
||||||
|
连接参数不完整时,只询问缺少的参数。接受用户提供的 `host`、`port`、数据库名和用户名,但不要要求用户把密码粘贴到聊天中;建议通过环境变量或本地凭据配置提供密码。
|
||||||
|
|
||||||
|
### 2. 验证连接
|
||||||
|
|
||||||
|
建立连接后先执行轻量验证,不直接操作业务数据:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT DATABASE() AS current_database,
|
||||||
|
USER() AS connected_user,
|
||||||
|
VERSION() AS mysql_version;
|
||||||
|
```
|
||||||
|
|
||||||
|
验证以下事项:
|
||||||
|
|
||||||
|
- 实际连接的主机、端口、数据库和账号是否符合目标;不要仅凭配置文件推断已经连对。
|
||||||
|
- 当前账号是否有完成任务所需的最小权限。
|
||||||
|
- 是否存在只读副本、开发库和生产库混淆的风险。发现环境不明确时暂停写操作并询问。
|
||||||
|
- 字符集和时区是否可能影响结果,必要时读取 `SELECT @@character_set_connection, @@time_zone`。
|
||||||
|
|
||||||
|
连接失败时按错误码和现象诊断:先区分 DNS/网络不可达、端口拒绝、认证失败、数据库不存在、权限不足、TLS 配置错误和客户端/驱动缺失;不要用反复重试掩盖根因。记录可公开的错误类型和下一步,不回显凭据。
|
||||||
|
|
||||||
|
### 3. 探查结构和数据
|
||||||
|
|
||||||
|
在写操作前确认表、列、键和约束。按需执行:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SHOW TABLES;
|
||||||
|
SHOW CREATE TABLE `table_name`;
|
||||||
|
SELECT TABLE_NAME, TABLE_ROWS
|
||||||
|
FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE();
|
||||||
|
|
||||||
|
SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'table_name'
|
||||||
|
ORDER BY ORDINAL_POSITION;
|
||||||
|
```
|
||||||
|
|
||||||
|
- 从实际结构确认表名、列名、主键、唯一键、外键、默认值和软删除字段;不要根据名称猜测 schema。
|
||||||
|
- 样本数据只取必要列并加 `LIMIT`,避免 `SELECT *` 扫描大表。需要总量时优先 `COUNT(*)`,需要性能判断时使用 `EXPLAIN`。
|
||||||
|
- 识别敏感列(密码、身份证、手机号、令牌、支付信息等),查询结果脱敏或只返回聚合结果。
|
||||||
|
- 表名和列名不能用普通 SQL 参数绑定。对动态标识符只允许来自已探查且白名单校验通过的名称,并使用 MySQL 反引号转义。
|
||||||
|
|
||||||
|
### 4. 执行查询或修改
|
||||||
|
|
||||||
|
- 复杂任务拆成可验证的小步骤,每一步说明目的和结果。
|
||||||
|
- 所有用户输入值使用驱动的参数绑定(如 `?`、`:name`),不要拼接值生成 SQL。只在无法参数化的标识符上使用白名单。
|
||||||
|
- 先用等价的 `SELECT` 验证筛选条件,再执行 `UPDATE`/`DELETE`。修改前记录主键集合或预估影响行数。
|
||||||
|
- 查询大表时必须有合理的过滤条件、索引或分页;避免在未了解数据量时执行全表排序、笛卡尔积和无条件扫描。
|
||||||
|
- 对写操作优先使用事务:开始事务,执行变更,检查受影响行数和关键结果,确认无误后提交;异常时回滚。不要在无法确认事务状态时重复提交。
|
||||||
|
- 批量操作分批执行并记录每批结果。遇到锁等待、死锁、超时或影响行数异常时停止后续批次,回滚当前事务并报告。
|
||||||
|
- DDL 可能隐式提交,执行前明确告知这一点;涉及表结构变更时先备份/导出或确认已有恢复方案。
|
||||||
|
|
||||||
|
通用写操作模板:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
START TRANSACTION;
|
||||||
|
|
||||||
|
-- 先用同一 WHERE 条件确认目标和数量
|
||||||
|
SELECT `primary_key`
|
||||||
|
FROM `table_name`
|
||||||
|
WHERE ...
|
||||||
|
LIMIT 1000;
|
||||||
|
|
||||||
|
-- 使用参数绑定执行精确变更
|
||||||
|
UPDATE `table_name`
|
||||||
|
SET `column_name` = ?
|
||||||
|
WHERE `primary_key` IN (...);
|
||||||
|
|
||||||
|
-- 核对受影响行数和结果后再提交
|
||||||
|
COMMIT;
|
||||||
|
-- 任一步失败时执行 ROLLBACK;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 核对并汇报
|
||||||
|
|
||||||
|
操作完成后重新查询关键结果,核对实际影响行数、唯一性、关联完整性和业务约束。向用户报告:使用的连接环境(不含密码)、执行的操作、结果摘要、是否提交事务、异常或未验证事项;不要粘贴大批量原始数据。
|
||||||
|
|
||||||
|
## 工具选择
|
||||||
|
|
||||||
|
- 已有应用或迁移工具能安全提供连接时,优先使用它,避免引入第二套连接配置。
|
||||||
|
- 使用 `mysql` CLI 时,把密码交给安全凭据机制或交互式输入,不使用 `--password=明文`。长 SQL 用受控的临时文件或标准输入传递,并在使用后清理临时文件。
|
||||||
|
- 使用脚本时复用项目锁定的依赖和配置,设置连接超时、查询超时(若驱动支持)和结果上限;脚本结束时关闭连接。
|
||||||
|
- 不把结果写入仓库,不提交临时凭据,不把生产数据导出到工作区。确需导出时先确认文件位置、字段范围和保留期限。
|
||||||
|
|
||||||
|
## 常见问题处理
|
||||||
|
|
||||||
|
- `Access denied`: 核对实际用户名、认证插件、目标主机和账号来源;不要通过授予 `ALL PRIVILEGES` 规避权限问题。
|
||||||
|
- `Unknown database`: 确认数据库名和当前连接环境,先列出允许访问的数据库或检查 Compose/迁移配置。
|
||||||
|
- `Table doesn't exist`: 核对大小写、schema、迁移状态和连接库,不要直接创建同名表覆盖问题。
|
||||||
|
- `Lock wait timeout` 或死锁: 回滚当前事务,识别长事务和索引缺失,缩小批次;不要盲目重试写入。
|
||||||
|
- 字符集乱码: 检查连接、表和列的字符集与排序规则,先读取并评估已有数据,再决定是否迁移。
|
||||||
|
- 结果数量异常: 重新检查 JOIN 条件、NULL 语义、时区、软删除条件和分页边界,并保留可复现的只读查询。
|
||||||
|
|
||||||
|
## 完成标准
|
||||||
|
|
||||||
|
只有在连接目标已验证、操作范围已确认、SQL 使用参数绑定、写操作结果已核对且事务状态明确时,才报告数据库任务完成。任何凭据、环境或影响范围无法确认时,保持只读并明确阻塞原因。
|
||||||
4
.codex/skills/database-connect/agents/openai.yaml
Normal file
4
.codex/skills/database-connect/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "MySQL 数据库连接"
|
||||||
|
short_description: "连接 MySQL 数据库并安全执行查询、写入与结构操作"
|
||||||
|
default_prompt: "使用 $database-connect 连接项目 MySQL 数据库,先检查连接和表结构,再按要求安全执行数据库操作。"
|
||||||
20
.env.example
Normal file
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
|
||||||
|
# 当前环境标识。项目连接外部 MySQL,不通过 Docker 创建数据库。
|
||||||
|
APP_ENV=development
|
||||||
|
|
||||||
|
# 外部 MySQL 连接配置。Spring Boot 会读取这一组变量。
|
||||||
|
MYSQL_HOST=24.233.2.106
|
||||||
|
MYSQL_PORT=13322
|
||||||
|
MYSQL_DATABASE=training
|
||||||
|
MYSQL_USER=admin
|
||||||
|
MYSQL_PASSWORD=13518200336LsD@@
|
||||||
|
MYSQL_ROOT_PASSWORD=root_password
|
||||||
|
|
||||||
|
# Spring Boot 运行配置。
|
||||||
|
SERVER_PORT=8080
|
||||||
|
SPRING_JPA_HIBERNATE_DDL_AUTO=update
|
||||||
|
SPRING_JPA_SHOW_SQL=true
|
||||||
|
SPRING_JPA_OPEN_IN_VIEW=false
|
||||||
|
SPRING_JPA_PROPERTIES_HIBERNATE_FORMAT_SQL=true
|
||||||
|
LOGGING_LEVEL_COM_EXAMPLE_TRAINING=DEBUG
|
||||||
|
LOGGING_LEVEL_ORG_HIBERNATE_SQL=DEBUG
|
||||||
18
.env.production.example
Normal file
18
.env.production.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 生产环境模板。复制为 .env 后填写真实值,或由部署平台注入同名环境变量。
|
||||||
|
APP_ENV=production
|
||||||
|
|
||||||
|
# 不要提交真实密码。生产环境建议使用部署平台的 Secret 管理能力。
|
||||||
|
MYSQL_HOST=your-production-mysql-host
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_DATABASE=training
|
||||||
|
MYSQL_USER=training_app
|
||||||
|
MYSQL_PASSWORD=change-this-password
|
||||||
|
MYSQL_ROOT_PASSWORD=change-this-root-password
|
||||||
|
|
||||||
|
SERVER_PORT=8080
|
||||||
|
SPRING_JPA_HIBERNATE_DDL_AUTO=validate
|
||||||
|
SPRING_JPA_SHOW_SQL=false
|
||||||
|
SPRING_JPA_OPEN_IN_VIEW=false
|
||||||
|
SPRING_JPA_PROPERTIES_HIBERNATE_FORMAT_SQL=false
|
||||||
|
LOGGING_LEVEL_COM_EXAMPLE_TRAINING=INFO
|
||||||
|
LOGGING_LEVEL_ORG_HIBERNATE_SQL=WARN
|
||||||
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.vscode/
|
||||||
|
.m2/
|
||||||
|
.m2-local/
|
||||||
|
.tmp-db-query/
|
||||||
|
.tmp-vite/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.production.example
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/dev-server*.log
|
||||||
|
frontend/.env
|
||||||
|
frontend/.env.*
|
||||||
|
!frontend/.env.example
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
backend/target/
|
||||||
|
backend/build/
|
||||||
|
backend/.mvn/
|
||||||
|
backend/*.log
|
||||||
|
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
62
README.md
Normal file
62
README.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Training Full-Stack Project
|
||||||
|
|
||||||
|
前后端分离的全栈项目基础工程:
|
||||||
|
|
||||||
|
- `frontend`: React 18 + TypeScript + Vite
|
||||||
|
- `backend`: Spring Boot + Java 21 + Maven + MyBatis-Plus
|
||||||
|
- `MySQL`: 外部 MySQL 8 数据库,由环境变量提供连接配置
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- JDK 21+
|
||||||
|
- Maven 3.9+(或使用项目中的 Maven Wrapper)
|
||||||
|
- 可访问目标 MySQL 的网络和账号
|
||||||
|
|
||||||
|
## 配置数据库
|
||||||
|
|
||||||
|
项目不会创建或启动 MySQL。请先确保 `.env.example` 中配置的 MySQL 服务已经运行并允许当前机器访问。
|
||||||
|
|
||||||
|
Spring Boot 启动时按以下顺序读取配置:根目录 `.env`,然后是根目录 `.env.example`。同名变量以 `.env` 为准;没有 `.env` 时直接使用已配置的 `.env.example`。
|
||||||
|
|
||||||
|
建议将 `.env.example` 复制为本地 `.env`,实际 `.env` 不提交到仓库:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
生产环境使用 `.env.production.example` 作为 `.env` 模板,或由部署平台注入同名环境变量:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cp .env.production.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
需要确认 `MYSQL_HOST`、`MYSQL_PORT`、`MYSQL_DATABASE`、`MYSQL_USER` 和 `MYSQL_PASSWORD` 均已配置。生产环境建议使用部署平台的 Secret 管理能力。
|
||||||
|
|
||||||
|
## 启动后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn -f backend/pom.xml spring-boot:run
|
||||||
|
```
|
||||||
|
|
||||||
|
后端默认运行在 `http://localhost:8080`,健康检查地址为 `GET /api/health`。
|
||||||
|
|
||||||
|
## 启动前端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
前端默认运行在 `http://localhost:5173`,开发服务器会将 `/api` 请求代理到后端。
|
||||||
|
|
||||||
|
## 构建检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
cd ../backend
|
||||||
|
mvn clean verify
|
||||||
|
```
|
||||||
68
backend/pom.xml
Normal file
68
backend/pom.xml
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.4.5</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>training-backend</artifactId>
|
||||||
|
<version>0.1.0-SNAPSHOT</version>
|
||||||
|
<name>training-backend</name>
|
||||||
|
<description>Training project backend service</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>21</java.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.baomidou</groupId>
|
||||||
|
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||||
|
<version>3.5.12</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.baomidou</groupId>
|
||||||
|
<artifactId>mybatis-plus-jsqlparser</artifactId>
|
||||||
|
<version>3.5.12</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.mysql</groupId>
|
||||||
|
<artifactId>mysql-connector-j</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.h2database</groupId>
|
||||||
|
<artifactId>h2</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<directory>${project.basedir}/build</directory>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.example.training;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class TrainingApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(TrainingApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.example.training.config;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.DbType;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class MybatisPlusConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||||
|
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||||
|
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
|
||||||
|
return interceptor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.example.training.controller;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/health")
|
||||||
|
public class HealthController {
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public HealthResponse health() {
|
||||||
|
return new HealthResponse("UP", "training-backend", OffsetDateTime.now(ZoneOffset.UTC));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record HealthResponse(String status, String service, OffsetDateTime timestamp) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.example.training.controller;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.example.training.domain.User;
|
||||||
|
import com.example.training.service.UserService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/users")
|
||||||
|
public class UserController {
|
||||||
|
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
public UserController(UserService userService) {
|
||||||
|
this.userService = userService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public UserPage list(
|
||||||
|
@RequestParam(defaultValue = "") String keyword,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "50") int size) {
|
||||||
|
int safePage = Math.max(page, 0);
|
||||||
|
int safeSize = Math.min(Math.max(size, 1), 100);
|
||||||
|
Page<User> resultPage = userService.pageUsers(keyword, safePage, safeSize);
|
||||||
|
return new UserPage(resultPage.getRecords().stream().map(UserResponse::from).toList(),
|
||||||
|
resultPage.getTotal(), (int) resultPage.getCurrent() - 1, (int) resultPage.getSize());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<UserResponse> create(@Valid @RequestBody UserRequest request) {
|
||||||
|
User user = userService.createUser(request.fullName(), request.email(),
|
||||||
|
request.role(), request.status());
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(UserResponse.from(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public UserResponse update(@PathVariable Long id, @Valid @RequestBody UserRequest request) {
|
||||||
|
User user = userService.updateUser(id, request.fullName(), request.email(),
|
||||||
|
request.role(), request.status());
|
||||||
|
if (user == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
|
||||||
|
}
|
||||||
|
return UserResponse.from(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||||
|
if (!userService.deleteUser(id)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
|
||||||
|
}
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||||
|
ResponseEntity<ApiError> handleDuplicateEmail() {
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||||
|
.body(new ApiError("该邮箱已被使用,请更换后重试"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UserRequest(
|
||||||
|
@NotBlank @Size(max = 80) String fullName,
|
||||||
|
@NotBlank @Email @Size(max = 160) String email,
|
||||||
|
@NotBlank @Size(max = 40) String role,
|
||||||
|
@NotBlank @Size(max = 20) String status) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UserPage(List<UserResponse> items, long total, int page, int size) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UserResponse(Long id, String fullName, String email, String role,
|
||||||
|
String status, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||||
|
static UserResponse from(User user) {
|
||||||
|
return new UserResponse(user.getId(), user.getFullName(), user.getEmail(),
|
||||||
|
user.getRole(), user.getStatus(), user.getCreatedAt(), user.getUpdatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ApiError(String message) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.example.training.database;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.ConfigurableApplicationContext;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "app.database.verify-on-startup", havingValue = "true")
|
||||||
|
public class DatabaseConnectionVerifier implements ApplicationRunner {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(DatabaseConnectionVerifier.class);
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final ConfigurableApplicationContext applicationContext;
|
||||||
|
private final boolean exitAfterVerify;
|
||||||
|
|
||||||
|
public DatabaseConnectionVerifier(
|
||||||
|
JdbcTemplate jdbcTemplate,
|
||||||
|
ConfigurableApplicationContext applicationContext,
|
||||||
|
@Value("${app.database.exit-after-verify:false}") boolean exitAfterVerify) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.applicationContext = applicationContext;
|
||||||
|
this.exitAfterVerify = exitAfterVerify;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
Map<String, Object> result = jdbcTemplate.queryForMap(
|
||||||
|
"SELECT DATABASE() AS database_name, CURRENT_USER() AS connected_user, VERSION() AS mysql_version");
|
||||||
|
log.info("Database connectivity verified: database={}, user={}, version={}",
|
||||||
|
result.get("database_name"), result.get("connected_user"), result.get("mysql_version"));
|
||||||
|
if (exitAfterVerify) {
|
||||||
|
SpringApplication.exit(applicationContext, () -> 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
backend/src/main/java/com/example/training/domain/User.java
Normal file
57
backend/src/main/java/com/example/training/domain/User.java
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package com.example.training.domain;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
|
||||||
|
@TableName("users")
|
||||||
|
public class User {
|
||||||
|
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@TableField("full_name")
|
||||||
|
private String fullName;
|
||||||
|
|
||||||
|
private String email;
|
||||||
|
private String role;
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@TableField("created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@TableField("updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public User() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public User(String fullName, String email, String role, String status) {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
this.fullName = fullName;
|
||||||
|
this.email = email;
|
||||||
|
this.role = role;
|
||||||
|
this.status = status;
|
||||||
|
this.createdAt = now;
|
||||||
|
this.updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public String getFullName() { return fullName; }
|
||||||
|
public String getEmail() { return email; }
|
||||||
|
public String getRole() { return role; }
|
||||||
|
public String getStatus() { return status; }
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
|
||||||
|
public void update(String fullName, String email, String role, String status) {
|
||||||
|
this.fullName = fullName;
|
||||||
|
this.email = email;
|
||||||
|
this.role = role;
|
||||||
|
this.status = status;
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.example.training.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.example.training.domain.User;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface UserMapper extends BaseMapper<User> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.example.training.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.example.training.domain.User;
|
||||||
|
|
||||||
|
public interface UserService {
|
||||||
|
|
||||||
|
Page<User> pageUsers(String keyword, int page, int size);
|
||||||
|
|
||||||
|
User createUser(String fullName, String email, String role, String status);
|
||||||
|
|
||||||
|
User updateUser(Long id, String fullName, String email, String role, String status);
|
||||||
|
|
||||||
|
boolean deleteUser(Long id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.example.training.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.example.training.domain.User;
|
||||||
|
import com.example.training.mapper.UserMapper;
|
||||||
|
import com.example.training.service.UserService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class UserServiceImpl implements UserService {
|
||||||
|
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
|
||||||
|
public UserServiceImpl(UserMapper userMapper) {
|
||||||
|
this.userMapper = userMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<User> pageUsers(String keyword, int page, int size) {
|
||||||
|
Page<User> requestPage = new Page<>(page + 1L, size);
|
||||||
|
LambdaQueryWrapper<User> query = new LambdaQueryWrapper<>();
|
||||||
|
if (keyword != null && !keyword.isBlank()) {
|
||||||
|
String trimmedKeyword = keyword.trim();
|
||||||
|
query.like(User::getFullName, trimmedKeyword)
|
||||||
|
.or()
|
||||||
|
.like(User::getEmail, trimmedKeyword);
|
||||||
|
}
|
||||||
|
query.orderByDesc(User::getCreatedAt);
|
||||||
|
return userMapper.selectPage(requestPage, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public User createUser(String fullName, String email, String role, String status) {
|
||||||
|
User user = new User(normalize(fullName), normalizeEmail(email), role, status);
|
||||||
|
userMapper.insert(user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public User updateUser(Long id, String fullName, String email, String role, String status) {
|
||||||
|
User user = userMapper.selectById(id);
|
||||||
|
if (user == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
user.update(normalize(fullName), normalizeEmail(email), role, status);
|
||||||
|
userMapper.updateById(user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public boolean deleteUser(Long id) {
|
||||||
|
if (userMapper.selectById(id) == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return userMapper.deleteById(id) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String value) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeEmail(String value) {
|
||||||
|
return normalize(value).toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
38
backend/src/main/resources/application.yml
Normal file
38
backend/src/main/resources/application.yml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
spring:
|
||||||
|
config:
|
||||||
|
import: optional:file:../.env.example[.properties],optional:file:../.env[.properties]
|
||||||
|
application:
|
||||||
|
name: ${SPRING_APPLICATION_NAME:training-backend}
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DATABASE}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&connectTimeout=${MYSQL_CONNECT_TIMEOUT_MS:5000}&socketTimeout=${MYSQL_SOCKET_TIMEOUT_MS:10000}&tcpKeepAlive=true
|
||||||
|
username: ${MYSQL_USER}
|
||||||
|
password: ${MYSQL_PASSWORD}
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
hikari:
|
||||||
|
minimum-idle: ${MYSQL_POOL_MINIMUM_IDLE:1}
|
||||||
|
maximum-pool-size: ${MYSQL_POOL_MAXIMUM_SIZE:5}
|
||||||
|
connection-timeout: ${MYSQL_POOL_CONNECTION_TIMEOUT_MS:10000}
|
||||||
|
validation-timeout: ${MYSQL_POOL_VALIDATION_TIMEOUT_MS:5000}
|
||||||
|
sql:
|
||||||
|
init:
|
||||||
|
mode: always
|
||||||
|
continue-on-error: false
|
||||||
|
mybatis-plus:
|
||||||
|
configuration:
|
||||||
|
map-underscore-to-camel-case: true
|
||||||
|
global-config:
|
||||||
|
banner: false
|
||||||
|
|
||||||
|
app:
|
||||||
|
database:
|
||||||
|
verify-on-startup: ${APP_DATABASE_VERIFY_ON_STARTUP:false}
|
||||||
|
exit-after-verify: ${APP_DATABASE_EXIT_AFTER_VERIFY:false}
|
||||||
|
|
||||||
|
server:
|
||||||
|
port: ${SERVER_PORT:8080}
|
||||||
|
shutdown: graceful
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.example.training: ${LOGGING_LEVEL_COM_EXAMPLE_TRAINING:INFO}
|
||||||
|
com.baomidou.mybatisplus: INFO
|
||||||
13
backend/src/main/resources/schema.sql
Normal file
13
backend/src/main/resources/schema.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
full_name VARCHAR(80) NOT NULL,
|
||||||
|
email VARCHAR(160) NOT NULL,
|
||||||
|
role VARCHAR(40) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_users_email (email),
|
||||||
|
KEY idx_users_full_name (full_name),
|
||||||
|
KEY idx_users_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.example.training;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import com.example.training.controller.HealthController;
|
||||||
|
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@WebMvcTest(HealthController.class)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
class TrainingApplicationTests {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void healthEndpointReturnsUp() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/health"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.status").value("UP"))
|
||||||
|
.andExpect(jsonPath("$.service").value("training-backend"));
|
||||||
|
}
|
||||||
|
}
|
||||||
20
backend/src/test/resources/application-test.yml
Normal file
20
backend/src/test/resources/application-test.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
spring:
|
||||||
|
config:
|
||||||
|
activate:
|
||||||
|
on-profile: test
|
||||||
|
datasource:
|
||||||
|
url: jdbc:h2:mem:training-test;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
|
||||||
|
username: sa
|
||||||
|
password:
|
||||||
|
driver-class-name: org.h2.Driver
|
||||||
|
sql:
|
||||||
|
init:
|
||||||
|
mode: never
|
||||||
|
|
||||||
|
server:
|
||||||
|
port: 0
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.example.training: INFO
|
||||||
|
org.springframework: WARN
|
||||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.4
|
||||||
|
container_name: training-mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-training}
|
||||||
|
MYSQL_USER: ${MYSQL_USER:-training}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-training_password}
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_password}
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
volumes:
|
||||||
|
- mysql-data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p$${MYSQL_ROOT_PASSWORD}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql-data:
|
||||||
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=/api
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Training full-stack project" />
|
||||||
|
<title>Training Project</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1732
frontend/package-lock.json
generated
Normal file
1732
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "training-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
176
frontend/src/App.tsx
Normal file
176
frontend/src/App.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
type User = {
|
||||||
|
id: number
|
||||||
|
fullName: string
|
||||||
|
email: string
|
||||||
|
role: string
|
||||||
|
status: 'ACTIVE' | 'INACTIVE'
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserForm = Omit<User, 'id' | 'createdAt'>
|
||||||
|
|
||||||
|
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '/api'
|
||||||
|
const emptyForm: UserForm = { fullName: '', email: '', role: 'Member', status: 'ACTIVE' }
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [users, setUsers] = useState<User[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [form, setForm] = useState<UserForm>(emptyForm)
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const loadUsers = useCallback(async (value = search) => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/users?keyword=${encodeURIComponent(value)}&size=100`)
|
||||||
|
if (!response.ok) throw new Error('无法加载用户列表')
|
||||||
|
const data = await response.json() as { items: User[]; total: number }
|
||||||
|
setUsers(data.items)
|
||||||
|
setTotal(data.total)
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError instanceof Error ? requestError.message : '请求失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [search])
|
||||||
|
|
||||||
|
useEffect(() => { void loadUsers() }, [loadUsers])
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditingId(null)
|
||||||
|
setForm(emptyForm)
|
||||||
|
setError('')
|
||||||
|
setIsFormOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(user: User) {
|
||||||
|
setEditingId(user.id)
|
||||||
|
setForm({ fullName: user.fullName, email: user.email, role: user.role, status: user.status })
|
||||||
|
setError('')
|
||||||
|
setIsFormOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
setIsSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/users${editingId ? `/${editingId}` : ''}`, {
|
||||||
|
method: editingId ? 'PUT' : 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => null) as { message?: string } | null
|
||||||
|
throw new Error(body?.message ?? '保存失败,请检查输入')
|
||||||
|
}
|
||||||
|
setIsFormOpen(false)
|
||||||
|
await loadUsers()
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError instanceof Error ? requestError.message : '保存失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUser(user: User) {
|
||||||
|
if (!window.confirm(`确定删除用户“${user.fullName}”吗?`)) return
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/users/${user.id}`, { method: 'DELETE' })
|
||||||
|
if (!response.ok) throw new Error('删除失败,请稍后重试')
|
||||||
|
await loadUsers()
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError instanceof Error ? requestError.message : '删除失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitSearch(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
setSearch(keyword.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="app-shell">
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="brand-lockup">
|
||||||
|
<div className="brand-mark">U</div>
|
||||||
|
<div>
|
||||||
|
<p className="brand-name">Console</p>
|
||||||
|
<p className="brand-context">Operations workspace</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="connection-status"><span className="status-dot" /> API connected</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="content-wrap" aria-labelledby="page-title">
|
||||||
|
<div className="page-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">DIRECTORY</p>
|
||||||
|
<h1 id="page-title">User management</h1>
|
||||||
|
<p className="page-summary">Manage access, roles, and account status from one place.</p>
|
||||||
|
</div>
|
||||||
|
<button className="primary-button" type="button" onClick={openCreate}>+ Add user</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar">
|
||||||
|
<form className="search-form" onSubmit={submitSearch}>
|
||||||
|
<label className="sr-only" htmlFor="user-search">Search users</label>
|
||||||
|
<input id="user-search" value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="Search by name or email" />
|
||||||
|
<button type="submit" className="secondary-button">Search</button>
|
||||||
|
</form>
|
||||||
|
<span className="result-count">{total} {total === 1 ? 'user' : 'users'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error-banner" role="alert">{error}</div>}
|
||||||
|
|
||||||
|
<section className="table-frame" aria-live="polite">
|
||||||
|
{isLoading ? <div className="empty-state">Loading users...</div> : users.length === 0 ? (
|
||||||
|
<div className="empty-state"><strong>No users found</strong><span>Try another search or add a new user.</span></div>
|
||||||
|
) : (
|
||||||
|
<div className="table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>User</th><th>Role</th><th>Status</th><th>Created</th><th><span className="sr-only">Actions</span></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<td><div className="user-cell"><span className="avatar">{user.fullName.charAt(0).toUpperCase()}</span><span><strong>{user.fullName}</strong><small>{user.email}</small></span></div></td>
|
||||||
|
<td><span className="role-label">{user.role}</span></td>
|
||||||
|
<td><span className={`status-label ${user.status.toLowerCase()}`}>{user.status === 'ACTIVE' ? 'Active' : 'Inactive'}</span></td>
|
||||||
|
<td className="muted-cell">{new Date(user.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</td>
|
||||||
|
<td><div className="row-actions"><button type="button" onClick={() => openEdit(user)}>Edit</button><button type="button" className="danger-action" onClick={() => void removeUser(user)}>Delete</button></div></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{isFormOpen && (
|
||||||
|
<div className="modal-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setIsFormOpen(false) }}>
|
||||||
|
<section className="modal" role="dialog" aria-modal="true" aria-labelledby="form-title">
|
||||||
|
<div className="modal-heading"><div><p className="eyebrow">USER RECORD</p><h2 id="form-title">{editingId ? 'Edit user' : 'Add user'}</h2></div><button className="close-button" type="button" aria-label="Close" onClick={() => setIsFormOpen(false)}>×</button></div>
|
||||||
|
<form onSubmit={submitForm}>
|
||||||
|
<label>Full name<input required maxLength={80} value={form.fullName} onChange={(event) => setForm({ ...form, fullName: event.target.value })} placeholder="e.g. Alex Morgan" /></label>
|
||||||
|
<label>Email address<input required type="email" maxLength={160} value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} placeholder="alex@company.com" /></label>
|
||||||
|
<div className="form-grid"><label>Role<select value={form.role} onChange={(event) => setForm({ ...form, role: event.target.value })}><option>Admin</option><option>Manager</option><option>Member</option><option>Viewer</option></select></label><label>Status<select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value as UserForm['status'] })}><option value="ACTIVE">Active</option><option value="INACTIVE">Inactive</option></select></label></div>
|
||||||
|
<div className="modal-actions"><button type="button" className="secondary-button" onClick={() => setIsFormOpen(false)}>Cancel</button><button disabled={isSaving} type="submit" className="primary-button">{isSaving ? 'Saving...' : editingId ? 'Save changes' : 'Create user'}</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
83
frontend/src/styles.css
Normal file
83
frontend/src/styles.css
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
:root {
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
color: #16222b;
|
||||||
|
background: #f5f7f8;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; min-width: 320px; }
|
||||||
|
button, input, select { font: inherit; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
.app-shell { min-height: 100vh; }
|
||||||
|
.topbar { height: 72px; padding: 0 5vw; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e1e7ea; background: #fff; }
|
||||||
|
.brand-lockup, .connection-status, .user-cell, .row-actions { display: flex; align-items: center; }
|
||||||
|
.brand-lockup { gap: 11px; }
|
||||||
|
.brand-mark { display: grid; width: 32px; height: 32px; place-items: center; border-radius: 7px; background: #176b73; color: white; font-weight: 800; }
|
||||||
|
.brand-name, .brand-context { margin: 0; }
|
||||||
|
.brand-name { font-weight: 750; letter-spacing: .01em; }
|
||||||
|
.brand-context { margin-top: 2px; color: #86939a; font-size: 11px; }
|
||||||
|
.connection-status { gap: 7px; color: #6c7c84; font-size: 12px; }
|
||||||
|
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: #33947c; }
|
||||||
|
.content-wrap { width: min(1180px, 90vw); margin: 0 auto; padding: 62px 0 80px; }
|
||||||
|
.page-heading { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin-bottom: 42px; }
|
||||||
|
.eyebrow { margin: 0 0 10px; color: #176b73; font-size: 11px; font-weight: 800; letter-spacing: .16em; }
|
||||||
|
h1, h2 { margin: 0; letter-spacing: -.025em; }
|
||||||
|
h1 { font-size: clamp(30px, 4vw, 43px); line-height: 1.1; }
|
||||||
|
h2 { font-size: 25px; }
|
||||||
|
.page-summary { margin: 13px 0 0; color: #728087; font-size: 15px; }
|
||||||
|
.primary-button, .secondary-button { min-height: 40px; padding: 0 16px; border: 1px solid transparent; border-radius: 5px; font-size: 13px; font-weight: 700; transition: .15s ease; }
|
||||||
|
.primary-button { background: #176b73; color: white; }
|
||||||
|
.primary-button:hover { background: #11575e; }
|
||||||
|
.primary-button:disabled { cursor: wait; opacity: .6; }
|
||||||
|
.secondary-button { border-color: #d5dfe3; background: #fff; color: #32444d; }
|
||||||
|
.secondary-button:hover { border-color: #9fb3b8; background: #f8fafb; }
|
||||||
|
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
|
||||||
|
.search-form { display: flex; gap: 8px; width: min(490px, 100%); }
|
||||||
|
input, select { width: 100%; height: 42px; padding: 0 12px; border: 1px solid #d5dfe3; border-radius: 5px; outline: 0; background: white; color: #16222b; }
|
||||||
|
input:focus, select:focus { border-color: #4b9aa0; box-shadow: 0 0 0 3px rgb(23 107 115 / 10%); }
|
||||||
|
.result-count { color: #859299; font-size: 13px; }
|
||||||
|
.error-banner { margin: 16px 0; padding: 12px 14px; border: 1px solid #f0c7c4; border-radius: 5px; background: #fff5f4; color: #ae3f3c; font-size: 13px; }
|
||||||
|
.table-frame { overflow: hidden; min-height: 220px; border: 1px solid #e0e7e9; border-radius: 7px; background: white; box-shadow: 0 8px 28px rgb(39 59 68 / 4%); }
|
||||||
|
.table-scroll { overflow-x: auto; }
|
||||||
|
table { width: 100%; min-width: 760px; border-collapse: collapse; }
|
||||||
|
th, td { padding: 16px 20px; border-bottom: 1px solid #edf1f2; text-align: left; }
|
||||||
|
th { color: #819097; font-size: 11px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
tbody tr:last-child td { border-bottom: 0; }
|
||||||
|
tbody tr:hover { background: #fbfcfc; }
|
||||||
|
.user-cell { gap: 11px; min-width: 240px; }
|
||||||
|
.avatar { display: grid; width: 33px; height: 33px; flex: 0 0 auto; place-items: center; border-radius: 50%; background: #dcefee; color: #176b73; font-size: 13px; font-weight: 800; }
|
||||||
|
.user-cell strong, .user-cell small { display: block; }
|
||||||
|
.user-cell strong { font-size: 14px; }
|
||||||
|
.user-cell small { margin-top: 3px; color: #859299; font-size: 12px; }
|
||||||
|
.role-label { color: #4b5b63; font-size: 13px; }
|
||||||
|
.status-label { display: inline-block; padding: 5px 9px; border-radius: 99px; font-size: 11px; font-weight: 800; }
|
||||||
|
.status-label.active { background: #e3f4ed; color: #21775e; }
|
||||||
|
.status-label.inactive { background: #edf0f1; color: #6e7b80; }
|
||||||
|
.muted-cell { color: #819097; font-size: 13px; white-space: nowrap; }
|
||||||
|
.row-actions { justify-content: end; gap: 14px; }
|
||||||
|
.row-actions button { padding: 0; border: 0; background: none; color: #176b73; font-size: 13px; font-weight: 700; }
|
||||||
|
.row-actions .danger-action { color: #b5534d; }
|
||||||
|
.empty-state { min-height: 220px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: #8a989e; font-size: 13px; }
|
||||||
|
.empty-state strong { color: #46565e; font-size: 15px; }
|
||||||
|
.modal-backdrop { position: fixed; inset: 0; display: grid; place-items: center; padding: 20px; background: rgb(17 32 39 / 42%); }
|
||||||
|
.modal { width: min(100%, 480px); padding: 28px; border-radius: 8px; background: white; box-shadow: 0 20px 55px rgb(17 32 39 / 20%); }
|
||||||
|
.modal-heading { display: flex; justify-content: space-between; align-items: start; margin-bottom: 26px; }
|
||||||
|
.close-button { padding: 0; border: 0; background: none; color: #87949a; font-size: 26px; line-height: 1; }
|
||||||
|
.modal form { display: grid; gap: 17px; }
|
||||||
|
.modal label { display: grid; gap: 7px; color: #53636b; font-size: 12px; font-weight: 750; }
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.modal-actions { display: flex; justify-content: end; gap: 9px; margin-top: 9px; }
|
||||||
|
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.topbar { padding: 0 5vw; }
|
||||||
|
.connection-status { display: none; }
|
||||||
|
.content-wrap { width: 90vw; padding-top: 42px; }
|
||||||
|
.page-heading { align-items: start; flex-direction: column; margin-bottom: 30px; }
|
||||||
|
.page-heading .primary-button { width: 100%; }
|
||||||
|
.toolbar { align-items: stretch; flex-direction: column; }
|
||||||
|
.search-form { width: 100%; }
|
||||||
|
.result-count { align-self: end; }
|
||||||
|
.modal { padding: 22px; }
|
||||||
|
}
|
||||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
20
frontend/tsconfig.app.json
Normal file
20
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
17
frontend/tsconfig.node.json
Normal file
17
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": false,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.mjs"]
|
||||||
|
}
|
||||||
16
frontend/vite.config.mjs
Normal file
16
frontend/vite.config.mjs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
13
sql/users.sql
Normal file
13
sql/users.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
full_name VARCHAR(80) NOT NULL,
|
||||||
|
email VARCHAR(160) NOT NULL,
|
||||||
|
role VARCHAR(40) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_users_email (email),
|
||||||
|
KEY idx_users_full_name (full_name),
|
||||||
|
KEY idx_users_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
Reference in New Issue
Block a user